From f9459d6ede53b85857c86ce963c718fee7a9a91e Mon Sep 17 00:00:00 2001 From: ZacharyZcR Date: Mon, 13 Jul 2026 22:59:53 +0800 Subject: [PATCH] Fix Android Vietnamese IME input (#1032) --- package.json | 2 +- scripts/patch-xterm-android-ime.cjs | 85 ++++++++++++++++++++ src/ui/features/terminal/android-ime.test.ts | 48 +++++++++++ 3 files changed, 134 insertions(+), 1 deletion(-) create mode 100644 scripts/patch-xterm-android-ime.cjs create mode 100644 src/ui/features/terminal/android-ime.test.ts diff --git a/package.json b/package.json index d81a87bf..36978ce3 100644 --- a/package.json +++ b/package.json @@ -14,7 +14,7 @@ "format:check": "prettier --check .", "biome:check": "biome check biome.json package.json", "biome:fix": "biome check --write biome.json package.json", - "postinstall": "node scripts/patch-app-builder-lib.cjs && node scripts/patch-guacamole-lite.cjs && node scripts/patch-better-sqlite3.cjs && node scripts/patch-nan.cjs", + "postinstall": "node scripts/patch-app-builder-lib.cjs && node scripts/patch-guacamole-lite.cjs && node scripts/patch-better-sqlite3.cjs && node scripts/patch-nan.cjs && node scripts/patch-xterm-android-ime.cjs", "prebuild": "node scripts/write-electron-build-info.cjs", "lint": "eslint .", "lint:fix": "eslint --fix .", diff --git a/scripts/patch-xterm-android-ime.cjs b/scripts/patch-xterm-android-ime.cjs new file mode 100644 index 00000000..e7738b32 --- /dev/null +++ b/scripts/patch-xterm-android-ime.cjs @@ -0,0 +1,85 @@ +const fs = require("node:fs"); +const path = require("node:path"); + +const xtermDir = path.join( + __dirname, + "..", + "node_modules", + "@xterm", + "xterm", + "lib", +); + +// Backport the textarea-shrink fix from gmuxapp/xterm.js@6a011cf while +// xtermjs/xterm.js#3600 remains unresolved upstream. Android IMEs can restart +// composition on the previous word and replace it with a shorter value (for +// example, Vietnamese "Hoar" -> "Hỏa"). xterm 6.0 otherwise emits nothing. +const patches = [ + { + file: "xterm.mjs", + replacements: [ + [ + 'this._compositionPosition={start:0,end:0},this._dataAlreadySent=""', + 'this._compositionPosition={start:0,end:0},this._preCompositionValue="",this._dataAlreadySent=""', + ], + [ + 'this._compositionPosition.start=this._textarea.value.length,this._compositionView.textContent=""', + 'this._compositionPosition.start=this._textarea.value.length,this._preCompositionValue=this._textarea.value,this._compositionView.textContent=""', + ], + [ + "let e={start:this._compositionPosition.start,end:this._compositionPosition.end};this._isSendingComposition=!0", + "let e={start:this._compositionPosition.start,end:this._compositionPosition.end};const s=this._preCompositionValue;this._isSendingComposition=!0", + ], + [ + "e.start+=this._dataAlreadySent.length,this._isComposing?i=this._textarea.value.substring(e.start,this._compositionPosition.start):i=this._textarea.value.substring(e.start),i.length>0&&", + "e.start+=this._dataAlreadySent.length;if(this._isComposing)i=this._textarea.value.substring(e.start,this._compositionPosition.start);else{const t=this._textarea.value;if(t.length0&&", + ], + ], + }, + { + file: "xterm.js", + replacements: [ + [ + 'this._compositionPosition={start:0,end:0},this._dataAlreadySent=""', + 'this._compositionPosition={start:0,end:0},this._preCompositionValue="",this._dataAlreadySent=""', + ], + [ + 'this._compositionPosition.start=this._textarea.value.length,this._compositionView.textContent=""', + 'this._compositionPosition.start=this._textarea.value.length,this._preCompositionValue=this._textarea.value,this._compositionView.textContent=""', + ], + [ + "const e={start:this._compositionPosition.start,end:this._compositionPosition.end};this._isSendingComposition=!0", + "const e={start:this._compositionPosition.start,end:this._compositionPosition.end},i=this._preCompositionValue;this._isSendingComposition=!0", + ], + [ + "e.start+=this._dataAlreadySent.length,t=this._isComposing?this._textarea.value.substring(e.start,this._compositionPosition.start):this._textarea.value.substring(e.start),t.length>0&&", + "e.start+=this._dataAlreadySent.length;this._isComposing?t=this._textarea.value.substring(e.start,this._compositionPosition.start):(()=>{const s=this._textarea.value;if(s.length0&&", + ], + ], + }, +]; + +for (const { file, replacements } of patches) { + const filePath = path.join(xtermDir, file); + if (!fs.existsSync(filePath)) { + throw new Error(`[patch-xterm-android-ime] Missing ${filePath}`); + } + + let source = fs.readFileSync(filePath, "utf8"); + if (source.includes("_preCompositionValue")) { + console.log(`[patch-xterm-android-ime] ${file} already patched`); + continue; + } + + for (const [original, patched] of replacements) { + if (!source.includes(original)) { + throw new Error( + `[patch-xterm-android-ime] Expected source not found in ${file}`, + ); + } + source = source.replace(original, patched); + } + + fs.writeFileSync(filePath, source); + console.log(`[patch-xterm-android-ime] Patched ${file}`); +} diff --git a/src/ui/features/terminal/android-ime.test.ts b/src/ui/features/terminal/android-ime.test.ts new file mode 100644 index 00000000..c3572d9d --- /dev/null +++ b/src/ui/features/terminal/android-ime.test.ts @@ -0,0 +1,48 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { Terminal } from "@xterm/xterm"; + +const tick = () => new Promise((resolve) => setTimeout(resolve, 0)); + +describe("Android IME composition", () => { + let terminal: Terminal | undefined; + let container: HTMLDivElement | undefined; + + afterEach(() => { + terminal?.dispose(); + container?.remove(); + terminal = undefined; + container = undefined; + }); + + it("forwards a shorter Vietnamese replacement to the shell", async () => { + container = document.createElement("div"); + document.body.appendChild(container); + terminal = new Terminal(); + terminal.open(container); + + const input: string[] = []; + terminal.onData((data) => input.push(data)); + + const textarea = terminal.textarea!; + textarea.value = "Hoar"; + textarea.selectionStart = textarea.value.length; + textarea.selectionEnd = textarea.value.length; + textarea.dispatchEvent(new CompositionEvent("compositionstart")); + textarea.dispatchEvent( + new CompositionEvent("compositionupdate", { data: "Hoar" }), + ); + await tick(); + + textarea.value = "Hỏa"; + textarea.selectionStart = textarea.value.length; + textarea.selectionEnd = textarea.value.length; + textarea.dispatchEvent( + new CompositionEvent("compositionupdate", { data: "Hỏa" }), + ); + await tick(); + textarea.dispatchEvent(new CompositionEvent("compositionend")); + await tick(); + + expect(input.join("")).toBe("\x7f\x7f\x7fỏa"); + }); +});