diff --git a/hooks/post-process.js b/hooks/post-process.js index e0b7f5a08..f5e01fd0f 100644 --- a/hooks/post-process.js +++ b/hooks/post-process.js @@ -21,19 +21,61 @@ if ( if (fs.existsSync(androidGradleFilePath)) fs.unlinkSync(androidGradleFilePath); fs.copyFileSync(gradleFilePath, androidGradleFilePath); -deleteDirRecursively(resPath, [ - path.join('values', 'strings.xml'), - path.join('values', 'colors.xml'), - path.join('values', 'styles.xml'), - 'anim', - 'xml', -]); -copyDirRecursively(localResPath, resPath); +const preservedRes = collectPreservedAndroidRes(resPath); +deleteDirRecursively(resPath, preservedRes); + +const localResSkip = collectLocalResourceSkipList(resPath); + +copyDirRecursively(localResPath, resPath, localResSkip); enableLegacyJni(); enableStaticContext(); patchTargetSdkVersion(); +function collectPreservedAndroidRes(androidResPath) { + const preserved = [ + path.join('values', 'styles.xml'), + 'anim', + 'xml', + ]; + + const optionalEntries = [ + path.join('values', 'strings.xml'), + path.join('values', 'colors.xml'), + path.join('values', 'themes.xml'), + path.join('values', 'cdv_strings.xml'), + path.join('values', 'cdv_colors.xml'), + path.join('values', 'cdv_themes.xml'), + 'values-night', + 'values-night-v34', + 'values-v34', + ]; + + for (const entry of optionalEntries) { + if (fs.existsSync(path.join(androidResPath, entry))) { + preserved.push(entry); + } + } + + return preserved; +} + +function collectLocalResourceSkipList(androidResPath) { + const skip = []; + + // Cordova Android 15+ provides splash/theme defaults in cdv_* resources. + // Keep using local colors/themes on older layouts where those files do not exist. + if (fs.existsSync(path.join(androidResPath, 'values', 'cdv_colors.xml'))) { + skip.push(path.join('values', 'colors.xml')); + } + if (fs.existsSync(path.join(androidResPath, 'values', 'cdv_themes.xml'))) { + skip.push(path.join('values', 'themes.xml')); + } + + return skip; +} + + function getTmpDir() { const tmpdirEnv = process.env.TMPDIR; diff --git a/package-lock.json b/package-lock.json index d6abc9a78..5fce2d7dd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -153,6 +153,7 @@ "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", @@ -1978,6 +1979,7 @@ "resolved": "https://registry.npmjs.org/@codemirror/autocomplete/-/autocomplete-6.20.0.tgz", "integrity": "sha512-bOwvTOIJcG5FVo5gUUupiwYh8MioPLQ4UcqbcRf7UQ98X90tCa9E1kZ3Z7tqwpZxYyOvh1YTYbmZE9RTfTp5hg==", "license": "MIT", + "peer": true, "dependencies": { "@codemirror/language": "^6.0.0", "@codemirror/state": "^6.0.0", @@ -1990,6 +1992,7 @@ "resolved": "https://registry.npmjs.org/@codemirror/commands/-/commands-6.10.2.tgz", "integrity": "sha512-vvX1fsih9HledO1c9zdotZYUZnE4xV0m6i3m25s5DIfXofuprk6cRcLUZvSk3CASUbwjQX21tOGbkY2BH8TpnQ==", "license": "MIT", + "peer": true, "dependencies": { "@codemirror/language": "^6.0.0", "@codemirror/state": "^6.4.0", @@ -2026,6 +2029,7 @@ "resolved": "https://registry.npmjs.org/@codemirror/lang-css/-/lang-css-6.3.1.tgz", "integrity": "sha512-kr5fwBGiGtmz6l0LSJIbno9QrifNMUusivHbnA1H6Dmqy4HZFte3UAICix1VuKo0lMPKQr2rqB+0BkKi/S3Ejg==", "license": "MIT", + "peer": true, "dependencies": { "@codemirror/autocomplete": "^6.0.0", "@codemirror/language": "^6.0.0", @@ -2052,6 +2056,7 @@ "resolved": "https://registry.npmjs.org/@codemirror/lang-html/-/lang-html-6.4.11.tgz", "integrity": "sha512-9NsXp7Nwp891pQchI7gPdTwBuSuT3K65NGTHWHNJ55HjYcHLllr0rbIZNdOzas9ztc1EUVBlHou85FFZS4BNnw==", "license": "MIT", + "peer": true, "dependencies": { "@codemirror/autocomplete": "^6.0.0", "@codemirror/lang-css": "^6.0.0", @@ -2279,6 +2284,7 @@ "resolved": "https://registry.npmjs.org/@codemirror/language/-/language-6.12.1.tgz", "integrity": "sha512-Fa6xkSiuGKc8XC8Cn96T+TQHYj4ZZ7RdFmXA3i9xe/3hLHfwPZdM+dqfX0Cp0zQklBKhVD8Yzc8LS45rkqcwpQ==", "license": "MIT", + "peer": true, "dependencies": { "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.23.0", @@ -2371,6 +2377,7 @@ "resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.5.4.tgz", "integrity": "sha512-8y7xqG/hpB53l25CIoit9/ngxdfoG+fx+V3SHBrinnhOtLvKHRyAJJuHzkWrR4YXXLX8eXBsejgAAxHUOdW1yw==", "license": "MIT", + "peer": true, "dependencies": { "@marijn/find-cluster-break": "^1.0.0" } @@ -2392,6 +2399,7 @@ "resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.39.15.tgz", "integrity": "sha512-aCWjgweIIXLBHh7bY6cACvXuyrZ0xGafjQ2VInjp4RM4gMfscK5uESiNdrH0pE+e1lZr2B4ONGsjchl2KsKZzg==", "license": "MIT", + "peer": true, "dependencies": { "@codemirror/state": "^6.5.0", "crelt": "^1.0.6", @@ -4204,6 +4212,7 @@ "integrity": "sha512-Iax6UhrfZqJajA778c1d5DBFbSIqPOSrI34kpNIiNpWd8Jq7mFIa+Z60SQb5ZQDZuUxcCZikjz5BxinFjTkg7Q==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@module-federation/runtime-tools": "0.22.0", "@rspack/binding": "1.7.6", @@ -4493,8 +4502,7 @@ "version": "5.0.0", "resolved": "https://registry.npmjs.org/@types/linkify-it/-/linkify-it-5.0.0.tgz", "integrity": "sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/@types/markdown-it": { "version": "14.1.2", @@ -4511,8 +4519,7 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/@types/mdurl/-/mdurl-2.0.0.tgz", "integrity": "sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/@types/mime": { "version": "1.3.5", @@ -4929,7 +4936,8 @@ "version": "5.5.0", "resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-5.5.0.tgz", "integrity": "sha512-hqJHYaQb5OptNunnyAnkHyM8aCjZ1MEIDTQu1iIbbTD/xops91NB5yq1ZK/dC2JDbVWtF23zUtl9JE2NqwT87A==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/@xtuc/ieee754": { "version": "1.2.0", @@ -5006,6 +5014,7 @@ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -5054,6 +5063,7 @@ "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -5570,6 +5580,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -5732,6 +5743,7 @@ "integrity": "sha512-ci2iJH6LeIkvP9eJW6gpueU8cnZhv85ELY8w8WiFtNjMHA5ad6pQLaJo9mEly/9qUyCpvqX8/POVUTf18/HFdw==", "dev": true, "license": "Apache-2.0", + "peer": true, "dependencies": { "@chevrotain/cst-dts-gen": "11.0.3", "@chevrotain/gast": "11.0.3", @@ -6740,9 +6752,9 @@ } }, "node_modules/dedent": { - "version": "1.7.1", - "resolved": "https://registry.npmjs.org/dedent/-/dedent-1.7.1.tgz", - "integrity": "sha512-9JmrhGZpOlEgOLdQgSm0zxFaYoQon408V1v49aqTWuXENVlnCuY9JBZcXZiCsZQWDjTm5Qf/nIvAy77mXDAjEg==", + "version": "1.7.2", + "resolved": "https://registry.npmjs.org/dedent/-/dedent-1.7.2.tgz", + "integrity": "sha512-WzMx3mW98SN+zn3hgemf4OzdmyNhhhKz5Ay0pUfQiMQ3e1g+xmTJWp/pKdwKVXhdSkAEGIIzqeuWrL3mV/AXbA==", "dev": true, "license": "MIT", "peerDependencies": { @@ -8968,6 +8980,7 @@ "resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-14.1.1.tgz", "integrity": "sha512-BuU2qnTti9YKgK5N+IeMubp14ZUKUUw7yeJbkjtosvHiP0AZ5c8IAgEMk79D0eC8F23r4Ac/q8cAIFdm2FtyoA==", "license": "MIT", + "peer": true, "dependencies": { "argparse": "^2.0.1", "entities": "^4.4.0", @@ -10066,6 +10079,7 @@ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -10186,6 +10200,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -10329,6 +10344,7 @@ "integrity": "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==", "dev": true, "license": "MIT", + "peer": true, "bin": { "prettier": "bin/prettier.cjs" }, @@ -10599,6 +10615,7 @@ "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", @@ -12025,7 +12042,8 @@ "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", "dev": true, - "license": "0BSD" + "license": "0BSD", + "peer": true }, "node_modules/tuf-js": { "version": "4.1.0", @@ -12093,6 +12111,7 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -12395,6 +12414,7 @@ "integrity": "sha512-dRXm0a2qcHPUBEzVk8uph0xWSjV/xZxenQQbLwnwP7caQCYpqG1qddwlyEkIDkYn0K8tvmcrZ+bOrzoQ3HxCDw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@types/eslint-scope": "^3.7.7", "@types/estree": "^1.0.8", diff --git a/src/components/terminal/terminal.js b/src/components/terminal/terminal.js index 39ede08c1..476232f10 100644 --- a/src/components/terminal/terminal.js +++ b/src/components/terminal/terminal.js @@ -34,7 +34,7 @@ export default class TerminalComponent { port: options.port || 8767, renderer: options.renderer || "auto", // 'auto' | 'canvas' | 'webgl' fontSize: terminalSettings.fontSize, - fontFamily: terminalSettings.fontFamily, + fontFamily: `"${terminalSettings.fontFamily}", monospace`, fontWeight: terminalSettings.fontWeight, theme: TerminalThemeManager.getTheme(terminalSettings.theme), cursorBlink: terminalSettings.cursorBlink, @@ -181,17 +181,13 @@ export default class TerminalComponent { let resizeTimeout = null; let lastKnownScrollPosition = 0; let isResizing = false; - let resizeCount = 0; const RESIZE_DEBOUNCE = 100; - const MAX_RAPID_RESIZES = 3; // Store original dimensions for comparison let originalRows = this.terminal.rows; let originalCols = this.terminal.cols; this.terminal.onResize((size) => { - // Track resize events - resizeCount++; isResizing = true; // Store current scroll position before resize @@ -207,19 +203,22 @@ export default class TerminalComponent { // Debounced resize handling resizeTimeout = setTimeout(async () => { try { - // Only proceed with server resize if dimensions actually changed significantly - const rowDiff = Math.abs(size.rows - originalRows); - const colDiff = Math.abs(size.cols - originalCols); - - // If this is a minor resize (likely intermediate state), skip server update - if (rowDiff < 2 && colDiff < 2 && resizeCount > 1) { - console.log("Skipping minor resize to prevent instability"); - isResizing = false; - resizeCount = 0; - return; - } - - // Handle server resize + // Always sync cols/rows to backend PTY on every client resize. + // + // The original code only synced when heightRatio < 0.75 (the "keyboard resize" + // heuristic below). That fails in several real scenarios on mobile: + // 1. Small keyboard height changes (e.g. switching input methods, suggestion + // bar appearing/disappearing) shrink height by <25%, bypassing the threshold. + // 2. Width-only changes (screen rotation, split-screen toggle) leave height + // unchanged so heightRatio ≈ 1.0, but cols change and PTY must know. + // 3. Animated keyboard open/close fires many incremental resize events; each + // individual step is a tiny delta that never crosses 0.75, yet the + // accumulated drift corrupts output. + // 4. Different ROMs / WebView versions report slightly different viewport sizes + // for the same keyboard, making any fixed threshold unreliable. + // + // Unconditionally syncing is cheap (one small HTTP POST per debounced resize) + // and guarantees the PTY always matches the client grid. if (this.serverMode) { await this.resizeTerminal(size.cols, size.rows); } @@ -262,7 +261,6 @@ export default class TerminalComponent { // Mark resize as complete isResizing = false; - resizeCount = 0; // Notify touch selection if it exists if (this.touchSelection) { @@ -271,7 +269,6 @@ export default class TerminalComponent { } catch (error) { console.error("Resize handling failed:", error); isResizing = false; - resizeCount = 0; } }, RESIZE_DEBOUNCE); }); @@ -581,31 +578,101 @@ export default class TerminalComponent { try { // Check if terminal is installed before starting AXS - if (!(await Terminal.isInstalled())) { + const installed = await Terminal.isInstalled(); + if (!installed) { throw new Error( "Terminal not installed. Please install terminal first.", ); } // Start AXS if not running - if (!(await Terminal.isAxsRunning())) { - await Terminal.startAxs(false, () => {}, console.error); + const axsRunning = await Terminal.isAxsRunning(); + + // Poll by hitting the actual HTTP endpoint, not just checking PID liveness. + // isAxsRunning() only does kill -0 on the PID file, which can return true + // while the HTTP server inside proot is still booting. + const fetchWithTimeout = async (url, options = {}, timeoutMs = 2000) => { + const hasAbortSignalTimeout = + typeof AbortSignal !== "undefined" && + typeof AbortSignal.timeout === "function"; + + if (hasAbortSignalTimeout) { + return fetch(url, { + ...options, + signal: AbortSignal.timeout(timeoutMs), + }); + } + + if (typeof AbortController === "undefined") { + return fetch(url, options); + } - // Check if AXS started with interval polling - const maxRetries = 10; - let retries = 0; - while (retries < maxRetries) { - await new Promise((resolve) => setTimeout(resolve, 1000)); - if (await Terminal.isAxsRunning()) { - break; + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), timeoutMs); + try { + return await fetch(url, { + ...options, + signal: controller.signal, + }); + } finally { + clearTimeout(timeoutId); + } + }; + + const pollAxs = async (maxRetries = 30, intervalMs = 1000) => { + for (let i = 0; i < maxRetries; i++) { + await new Promise((r) => setTimeout(r, intervalMs)); + try { + const resp = await fetchWithTimeout( + `http://localhost:${this.options.port}/`, + { method: "GET" }, + 2000, + ); + if (resp.ok || resp.status < 500) return true; + } catch (_) { + // HTTP not yet reachable } - retries++; + } + return false; + }; + + if (!axsRunning) { + await Terminal.startAxs(false, () => {}, console.error); + } + + // Always wait for the HTTP endpoint, even if the PID is already alive. + // kill -0 only tells us the outer process exists; the embedded server may + // still be starting, especially after crash recovery on slower devices. + const initialPollRetries = axsRunning ? 10 : 30; + if (!(await pollAxs(initialPollRetries))) { + // AXS failed to become reachable — attempt auto-repair + toast("Repairing terminal environment..."); + + try { + await Terminal.stopAxs(); + } catch (_) { + /* ignore */ } - // If AXS still not running after retries, throw error - if (!(await Terminal.isAxsRunning())) { - toast("Failed to start AXS server after multiple attempts"); - //throw new Error("Failed to start AXS server after multiple attempts"); + // Re-run installing flow to repair packages / config + const repairOk = await Terminal.startAxs( + true, + console.log, + console.error, + ); + if (repairOk) { + // Start AXS again after repair + await Terminal.startAxs(false, () => {}, console.error); + } + + if (!(await pollAxs(30))) { + // Still broken — clear .configured so next open re-triggers install + try { + await Terminal.resetConfigured(); + } catch (_) { + /* ignore */ + } + throw new Error("Failed to start AXS server after repair attempt"); } } @@ -614,6 +681,24 @@ export default class TerminalComponent { rows: this.terminal.rows, }; + const parsePtyOpenError = (payload) => { + if (typeof payload !== "string") { + return null; + } + + const trimmed = payload.trim(); + if (!trimmed.startsWith("{")) { + return null; + } + + try { + const parsed = JSON.parse(trimmed); + return typeof parsed?.error === "string" ? parsed.error : null; + } catch { + return null; + } + }; + const response = await fetch( `http://localhost:${this.options.port}/terminals`, { @@ -630,6 +715,12 @@ export default class TerminalComponent { } const data = await response.text(); + const ptyOpenError = parsePtyOpenError(data); + + if (ptyOpenError?.includes("Failed to open PTY")) { + throw new Error("Failed to open PTY"); + } + this.pid = data.trim(); return this.pid; } catch (error) { @@ -654,6 +745,11 @@ export default class TerminalComponent { } this.pid = pid; + this._relocationSniffDisabled = false; + clearTimeout(this._relocationSniffTimer); + this._relocationSniffTimer = setTimeout(() => { + this._relocationSniffDisabled = true; + }, 15000); const wsUrl = `ws://localhost:${this.options.port}/terminals/${pid}`; @@ -663,6 +759,16 @@ export default class TerminalComponent { this.isConnected = true; this.onConnect?.(); + // Explicitly dispose old AttachAddon before replacing it. + // Reassigning this.attachAddon does NOT auto-clean listeners bound by the old instance, + // which can cause duplicate socket handlers and leaks after reconnects. + if (this.attachAddon) { + try { + this.attachAddon.dispose(); + } catch (_) {} + this.attachAddon = null; + } + // Load attach addon after connection this.attachAddon = new AttachAddon(this.websocket); this.terminal.loadAddon(this.attachAddon); @@ -689,13 +795,56 @@ export default class TerminalComponent { // For binary data or non-exit text messages, let attachAddon handle them }; + // Also sniff the data to detect critical Alpine container corruption (e.g. bash/readline broken) + this.websocket.addEventListener("message", async (event) => { + if (this._relocationSniffDisabled) { + return; + } + + const MAX_SNIFF_BYTES = 4096; + + try { + let text = ""; + if (typeof event.data === "string") { + text = event.data.slice(0, MAX_SNIFF_BYTES); + } else if (event.data instanceof ArrayBuffer) { + const byteLength = Math.min(event.data.byteLength, MAX_SNIFF_BYTES); + const view = new Uint8Array(event.data, 0, byteLength); + text = new TextDecoder("utf-8", { fatal: false }).decode(view); + } else if (event.data instanceof Blob) { + const slice = + event.data.size > MAX_SNIFF_BYTES + ? event.data.slice(0, MAX_SNIFF_BYTES) + : event.data; + text = await new Response(slice).text(); + } + + if (!text) { + return; + } + + if ( + text.includes("Error relocating") && + text.includes("symbol not found") + ) { + console.error( + "Detected critical Alpine libc corruption! Terminating and triggering reinstall.", + ); + if (this.onCrashData) { + this.onCrashData("relocation_error"); + } + this._relocationSniffDisabled = true; + clearTimeout(this._relocationSniffTimer); + } + } catch (err) {} + }); + this.websocket.onclose = (event) => { this.isConnected = false; this.onDisconnect?.(); }; this.websocket.onerror = (error) => { - console.error("WebSocket error:", error); this.onError?.(error); }; } @@ -922,10 +1071,25 @@ export default class TerminalComponent { * Load terminal font if it's not already loaded */ async loadTerminalFont() { - const fontFamily = this.options.fontFamily; + // Use original name without quotes for Acode fonts.get + const fontFamily = this.options.fontFamily + .replace(/^"|"$/g, "") + .replace(/",\s*monospace$/, ""); if (fontFamily && fonts.get(fontFamily)) { try { await fonts.loadFont(fontFamily); + // Make Xterm.js aware that the font is fully loaded + // Setting options.fontFamily triggers a re-eval of character dimensions + if (this.terminal) { + this.terminal.options.fontFamily = `"${fontFamily}", monospace`; + if (this.webglAddon) { + try { + this.webglAddon.clearTextureAtlas(); + } catch (e) {} + } + // Ensure terminal dimensions are updated after font load changes char size + setTimeout(() => this.fit(), 100); + } } catch (error) { console.warn(`Failed to load terminal font ${fontFamily}:`, error); } @@ -986,6 +1150,9 @@ export default class TerminalComponent { * Terminate terminal session */ async terminate() { + clearTimeout(this._relocationSniffTimer); + this._relocationSniffDisabled = true; + if (this.websocket) { this.websocket.close(); } @@ -998,8 +1165,8 @@ export default class TerminalComponent { method: "POST", }, ); - } catch (error) { - console.error("Failed to terminate terminal:", error); + } catch { + // Expected: terminal process may have already exited and acodex-server disconnected } } } diff --git a/src/components/terminal/terminalDefaults.js b/src/components/terminal/terminalDefaults.js index 4f42242e7..b16f53fbe 100644 --- a/src/components/terminal/terminalDefaults.js +++ b/src/components/terminal/terminalDefaults.js @@ -25,8 +25,15 @@ export const DEFAULT_TERMINAL_SETTINGS = { export function getTerminalSettings() { const settings = appSettings.value.terminalSettings || {}; - return { + const merged = { ...DEFAULT_TERMINAL_SETTINGS, ...settings, }; + + let spacing = Number.parseFloat(merged.letterSpacing); + if (!Number.isFinite(spacing)) + spacing = DEFAULT_TERMINAL_SETTINGS.letterSpacing; + merged.letterSpacing = Math.max(0, Math.min(2, spacing)); + + return merged; } diff --git a/src/components/terminal/terminalManager.js b/src/components/terminal/terminalManager.js index 8dc394948..5ea59e445 100644 --- a/src/components/terminal/terminalManager.js +++ b/src/components/terminal/terminalManager.js @@ -187,6 +187,7 @@ class TerminalManager { const { render, serverMode, ...terminalOptions } = options; const shouldRender = render !== false; const isServerMode = serverMode !== false; + const isReconnecting = terminalOptions.reconnecting === true; const terminalId = `terminal_${++this.terminalCounter}`; const providedName = @@ -199,14 +200,6 @@ class TerminalManager { ? `Terminal ${terminalNumber}` : terminalName; - // Check if terminal is installed before proceeding - if (isServerMode) { - const installationResult = await this.checkAndInstallTerminal(); - if (!installationResult.success) { - throw new Error(installationResult.error); - } - } - // Create terminal component const terminalComponent = new TerminalComponent({ serverMode: isServerMode, @@ -244,9 +237,31 @@ class TerminalManager { // Mount terminal component terminalComponent.mount(terminalContainer); + if (terminalComponent.serverMode) { + // Run install check after mount so install logs can stream into this + // exact terminal tab (via progressTerminal.component), instead of + // opening a separate "Terminal Installation" tab. Keeping it inside + // this init try/catch also reuses the same cleanup path on failure + // (dispose component + remove broken tab). + const installationResult = await this.checkAndInstallTerminal( + false, + { + component: terminalComponent, + }, + ); + if (!installationResult.success) { + throw new Error(installationResult.error); + } + } + // Connect to session if in server mode if (terminalComponent.serverMode) { await terminalComponent.connectToSession(terminalOptions.pid); + if (isReconnecting) { + terminalComponent.write( + "\x1b[36m[Restored existing terminal session. MOTD is only shown when a new shell starts.]\x1b[0m\r\n", + ); + } } else { // For local mode, just write a welcome message terminalComponent.write( @@ -323,13 +338,20 @@ class TerminalManager { /** * Check if terminal is installed and install if needed + * @param {boolean} [forceReinstall=false] - Whether to force reinstall even if already installed. + * Usually false for normal terminal creation. Set to true only for recovery flows + * (currently relocation_error auto-repair) to bypass the "already installed" + * early return and run a full reinstall. * @returns {Promise<{success: boolean, error?: string}>} */ - async checkAndInstallTerminal() { + async checkAndInstallTerminal( + forceReinstall = false, + progressTerminal = null, + ) { try { // Check if terminal is already installed const isInstalled = await Terminal.isInstalled(); - if (isInstalled) { + if (isInstalled && !forceReinstall) { return { success: true }; } @@ -342,8 +364,14 @@ class TerminalManager { }; } - // Create installation progress terminal - const installTerminal = await this.createInstallationTerminal(); + // Create installation progress terminal (or reuse current one) + const installTerminal = + progressTerminal || (await this.createInstallationTerminal()); + if (progressTerminal?.component) { + installTerminal.component.write( + "\x1b[33mInstalling terminal environment...\x1b[0m\r\n", + ); + } // Install terminal with progress logging const installResult = await Terminal.install( @@ -503,25 +531,11 @@ class TerminalManager { // Handle tab focus/blur terminalFile.onfocus = () => { - // Guarded fit on focus: only fit if cols/rows would change, then focus - const run = () => { - try { - const pd = terminalComponent.fitAddon?.proposeDimensions?.(); - if ( - pd && - (pd.cols !== terminalComponent.terminal.cols || - pd.rows !== terminalComponent.terminal.rows) - ) { - terminalComponent.fitAddon.fit(); - } - } catch {} - terminalComponent.focus(); - }; - if (typeof requestAnimationFrame === "function") { - requestAnimationFrame(run); - } else { - setTimeout(run, 0); - } + // Do NOT forcefully call fit() here, as the DOM might still be animating + // or transitioning from display:none. + // terminalFile._resizeObserver (ResizeObserver) already handles fitting + // securely when the container's true dimensions are realized. + terminalComponent.focus(); }; // Handle tab close @@ -572,6 +586,11 @@ class TerminalManager { return; } + // Ignore resizes where the container is hidden or too small + if (width < 10 || height < 10) { + return; + } + // Only fit if actual size changed to reduce reflows if ( Math.abs(width - lastWidth) > 0.5 || @@ -663,6 +682,65 @@ class TerminalManager { toast(message); }; + // Auto-recovery for corrupted rootfs (in-place, no new tab or toast) + terminalComponent.onCrashData = async (reason) => { + if (reason === "relocation_error") { + try { + // Disconnect websocket to stop feeding garbage + if (terminalComponent.websocket) { + terminalComponent.websocket.close(); + } + terminalComponent.isConnected = false; + + // Write recovery status directly into the current terminal + terminalComponent.clear(); + terminalComponent.write( + "\x1b[33m⚠ Detected terminal environment corruption (libc/readline).\x1b[0m\r\n", + ); + terminalComponent.write( + "\x1b[33m Starting automatic repair...\x1b[0m\r\n\r\n", + ); + + // Uninstall corrupted rootfs + terminalComponent.write("Removing corrupted rootfs...\r\n"); + if ( + !window.Terminal || + typeof window.Terminal.uninstall !== "function" + ) { + throw new Error( + "Terminal uninstall API is unavailable; cannot repair corrupted rootfs.", + ); + } + await window.Terminal.uninstall(); + terminalComponent.write("Rootfs removed. Reinstalling...\r\n\r\n"); + + // Reinstall, routing all progress output to this terminal + const result = await this.checkAndInstallTerminal(true, { + component: terminalComponent, + }); + + if (result.success) { + terminalComponent.write( + "\r\n\x1b[32m✔ Recovery complete. Reconnecting session...\x1b[0m\r\n", + ); + // Clear the terminal buffer so the new shell prompt starts clean + terminalComponent.clear(); + // Reconnect a fresh session in the same terminal + await terminalComponent.connectToSession(); + } else { + terminalComponent.write( + `\r\n\x1b[31m✘ Recovery failed: ${result.error}\x1b[0m\r\n`, + ); + } + } catch (e) { + console.error("In-place terminal recovery failed:", e); + terminalComponent.write( + `\r\n\x1b[31m✘ Recovery error: ${e.message}\x1b[0m\r\n`, + ); + } + } + }; + // Handle acode CLI open commands (OSC 7777) terminalComponent.onOscOpen = async (type, path) => { if (!path) return; diff --git a/src/lib/auth.js b/src/lib/auth.js index c8911be88..4014aabe0 100644 --- a/src/lib/auth.js +++ b/src/lib/auth.js @@ -78,8 +78,7 @@ class AuthService { await this._exec("isLoggedIn"); return true; } catch (error) { - console.error(error); - // error is typically the status code (0 if no token, 401 if invalid) + // Not an error — native rejects when not logged in (0 = no token, 401 = expired) return false; } } diff --git a/src/plugins/system/android/com/foxdebug/system/System.java b/src/plugins/system/android/com/foxdebug/system/System.java index 69edbffb8..66b430311 100644 --- a/src/plugins/system/android/com/foxdebug/system/System.java +++ b/src/plugins/system/android/com/foxdebug/system/System.java @@ -384,6 +384,47 @@ public void run() { } return true; + case "copyAsset": { + final String assetName = args.getString(0); + final String destPath = args.getString(1); + cordova + .getThreadPool() + .execute( + new Runnable() { + @Override + public void run() { + File destFile = new File(destPath); + File parentDir = destFile.getParentFile(); + if (parentDir != null && !parentDir.exists() && !parentDir.mkdirs()) { + callbackContext.error("Failed to create destination directory: " + parentDir.getAbsolutePath()); + return; + } + + try (java.io.InputStream in = cordova.getActivity().getAssets().open(assetName); + java.io.FileOutputStream out = new java.io.FileOutputStream(destFile)) { + byte[] buf = new byte[65536]; + int len; + while ((len = in.read(buf)) != -1) { + out.write(buf, 0, len); + } + } catch (Exception e) { + if (destFile.exists()) { + destFile.delete(); + } + callbackContext.error(e.getMessage()); + return; + } + + if (!destFile.setExecutable(true)) { + callbackContext.error("Failed to set executable permission on: " + destFile.getAbsolutePath()); + return; + } + + callbackContext.success(); + } + }); + return true; + } default: return false; } diff --git a/src/plugins/system/www/plugin.js b/src/plugins/system/www/plugin.js index 01d77ecb3..00cea819f 100644 --- a/src/plugins/system/www/plugin.js +++ b/src/plugins/system/www/plugin.js @@ -27,6 +27,9 @@ module.exports = { setExec: function (path, executable, success, error) { cordova.exec(success, error, 'System', 'setExec', [path, String(executable)]); }, + copyAsset: function (assetName, destPath, success, error) { + cordova.exec(success, error, 'System', 'copyAsset', [assetName, destPath]); + }, getNativeLibraryPath: function (success, error) { @@ -135,32 +138,32 @@ module.exports = { onError: null, }; - cordova.exec(function (data) { - if (typeof data !== 'string') { - console.warn('System.inAppBrowser: invalid callback payload', data); - return; - } - var separatorIndex = data.indexOf(':'); - if (separatorIndex < 0) { - console.warn('System.inAppBrowser: malformed callback payload', data); - return; - } - var dataTag = data.slice(0, separatorIndex); - var dataUrl = data.slice(separatorIndex + 1); - if (dataTag === 'onOpenExternalBrowser') { - if (typeof myInAppBrowser.onOpenExternalBrowser === 'function') { - myInAppBrowser.onOpenExternalBrowser(dataUrl); - } else { - console.warn('System.inAppBrowser: onOpenExternalBrowser handler is not set'); - } - } - }, function (err) { - if (typeof myInAppBrowser.onError === 'function') { - myInAppBrowser.onError(err); - return; - } - console.warn('System.inAppBrowser error callback not handled', err); - }, 'System', 'in-app-browser', [url, title, !!showButtons, disableCache]); + cordova.exec(function (data) { + if (typeof data !== 'string') { + console.warn('System.inAppBrowser: invalid callback payload', data); + return; + } + var separatorIndex = data.indexOf(':'); + if (separatorIndex < 0) { + console.warn('System.inAppBrowser: malformed callback payload', data); + return; + } + var dataTag = data.slice(0, separatorIndex); + var dataUrl = data.slice(separatorIndex + 1); + if (dataTag === 'onOpenExternalBrowser') { + if (typeof myInAppBrowser.onOpenExternalBrowser === 'function') { + myInAppBrowser.onOpenExternalBrowser(dataUrl); + } else { + console.warn('System.inAppBrowser: onOpenExternalBrowser handler is not set'); + } + } + }, function (err) { + if (typeof myInAppBrowser.onError === 'function') { + myInAppBrowser.onError(err); + return; + } + console.warn('System.inAppBrowser error callback not handled', err); + }, 'System', 'in-app-browser', [url, title, !!showButtons, disableCache]); return myInAppBrowser; }, setUiTheme: function (systemBarColor, theme, onSuccess, onFail) { diff --git a/src/plugins/terminal/plugin.xml b/src/plugins/terminal/plugin.xml index 72273257f..f0b9be33c 100644 --- a/src/plugins/terminal/plugin.xml +++ b/src/plugins/terminal/plugin.xml @@ -26,6 +26,7 @@ + diff --git a/src/plugins/terminal/scripts/init-alpine.sh b/src/plugins/terminal/scripts/init-alpine.sh index 88c071fd1..e5cfab195 100644 --- a/src/plugins/terminal/scripts/init-alpine.sh +++ b/src/plugins/terminal/scripts/init-alpine.sh @@ -1,26 +1,58 @@ export PATH=/bin:/sbin:/usr/bin:/usr/sbin:/usr/share/bin:/usr/share/sbin:/usr/local/bin:/usr/local/sbin:/system/bin:/system/xbin:$PREFIX/local/bin -export PS1="\[\e[38;5;46m\]\u\[\033[39m\]@localhost \[\033[39m\]\w \[\033[0m\]\\$ " export HOME=/home export TERM=xterm-256color -required_packages="bash command-not-found tzdata wget" +# ── Package check (runs every startup, file-stat only — negligible cost) ── +# Check by file existence rather than apk info (which is unreliable in proot) +is_apk_installed() { + local package_name="$1" + [ -f /lib/apk/db/installed ] && grep -q "^P:${package_name}$" /lib/apk/db/installed +} + +find_bash_path() { + command -v bash 2>/dev/null || true +} + missing_packages="" +[ -z "$(find_bash_path)" ] && missing_packages="$missing_packages bash" +[ ! -f /usr/share/zoneinfo/UTC ] && missing_packages="$missing_packages tzdata" +[ ! -f /usr/bin/wget ] && missing_packages="$missing_packages wget" +! is_apk_installed command-not-found && missing_packages="$missing_packages command-not-found" + +if [ -n "$missing_packages" ]; then + echo -e "\e[34;1m[*] \e[0mInstalling packages:$missing_packages\e[0m" + + # In proot, post-install scripts may fail with error 127; + # manual fixup below compensates if needed. + apk update 2>/dev/null + + apk add $missing_packages 2>/dev/null + if [ $? -ne 0 ]; then + echo -e "\e[33;1m[!] \e[0mRetrying with mirror...\e[0m" + cp /etc/apk/repositories /etc/apk/repositories.bak + echo "https://mirrors.tuna.tsinghua.edu.cn/alpine/v3.21/main" > /etc/apk/repositories + echo "https://mirrors.tuna.tsinghua.edu.cn/alpine/v3.21/community" >> /etc/apk/repositories + apk update 2>/dev/null + apk add $missing_packages 2>/dev/null + mv /etc/apk/repositories.bak /etc/apk/repositories 2>/dev/null + fi + + bash_path="$(find_bash_path)" -for pkg in $required_packages; do - if ! apk info -e "$pkg" >/dev/null 2>&1; then - missing_packages="$missing_packages $pkg" + # Post-install fixup: ensure /bin/bash exists if bash resolves elsewhere. + if [ -n "$bash_path" ] && [ ! -e /bin/bash ]; then + ln -sf "$bash_path" /bin/bash 2>/dev/null fi -done -if [ -n "$missing_packages" ]; then - echo -e "\e[34;1m[*] \e[0mInstalling important packages\e[0m" - apk update && apk upgrade - apk add $missing_packages - if [ $? -eq 0 ]; then - echo -e "\e[32;1m[+] \e[0mSuccessfully installed\e[0m" + # Ensure /etc/shells has bash + if [ -n "$bash_path" ] && ! grep -q "/bin/bash" /etc/shells 2>/dev/null; then + echo "/bin/bash" >> /etc/shells 2>/dev/null fi - echo -e "\e[34m[*] \e[0mUse \e[32mapk\e[0m to install new packages\e[0m" + + # Verify + [ -z "$bash_path" ] && echo -e "\e[31;1m[!] \e[0mbash still missing\e[0m" + [ ! -f /usr/bin/wget ] && echo -e "\e[31;1m[!] \e[0mwget still missing\e[0m" fi @@ -41,7 +73,9 @@ if [ "$1" = "--installing" ]; then echo "Failed to detect timezone" fi - mkdir -p "$PREFIX/.configured" + # .configured marker is created by JS layer (system.writeText) after proot exits. + # Do NOT create it here — proot bind-mount mkdir causes Java mkdirs() to fail + # because it sees the directory already exists. echo "Installation completed." exit 0 fi @@ -65,90 +99,9 @@ Working with packages: EOF fi - # Create acode CLI tool - if [ ! -e "$PREFIX/alpine/usr/local/bin/acode" ]; then - mkdir -p "$PREFIX/alpine/usr/local/bin" - cat <<'ACODE_CLI' > "$PREFIX/alpine/usr/local/bin/acode" -#!/bin/bash -# acode - Open files/folders in Acode editor -# Uses OSC escape sequences to communicate with the Acode terminal - -usage() { - echo "Usage: acode [file/folder...]" - echo "" - echo "Open files or folders in Acode editor." - echo "" - echo "Examples:" - echo " acode file.txt # Open a file" - echo " acode . # Open current folder" - echo " acode ~/project # Open a folder" - echo " acode -h, --help # Show this help" -} - -get_abs_path() { - local path="$1" - local abs_path="" - - if command -v realpath >/dev/null 2>&1; then - abs_path=$(realpath -- "$path" 2>/dev/null) - fi - - if [[ -z "$abs_path" ]]; then - if [[ -d "$path" ]]; then - abs_path=$(cd -- "$path" 2>/dev/null && pwd -P) - elif [[ -e "$path" ]]; then - local dir_name file_name - dir_name=$(dirname -- "$path") - file_name=$(basename -- "$path") - abs_path="$(cd -- "$dir_name" 2>/dev/null && pwd -P)/$file_name" - elif [[ "$path" == /* ]]; then - abs_path="$path" - else - abs_path="$PWD/$path" - fi - fi - - echo "$abs_path" -} - -open_in_acode() { - local path=$(get_abs_path "$1") - local type="file" - [[ -d "$path" ]] && type="folder" - - # Send OSC 7777 escape sequence: \e]7777;cmd;type;path\a - # The terminal component will intercept and handle this - printf '\e]7777;open;%s;%s\a' "$type" "$path" -} - -if [[ $# -eq 0 ]]; then - open_in_acode "." - exit 0 -fi - -for arg in "$@"; do - case "$arg" in - -h|--help) - usage - exit 0 - ;; - *) - if [[ -e "$arg" ]]; then - open_in_acode "$arg" - else - echo "Error: '$arg' does not exist" >&2 - exit 1 - fi - ;; - esac -done -ACODE_CLI - chmod +x "$PREFIX/alpine/usr/local/bin/acode" - fi - - # Create initrc if it doesn't exist + # Create/update initrc (always overwrite to keep in sync with app updates) + # Cost: ~3KB heredoc write per startup, sub-millisecond — negligible. #initrc runs in bash so we can use bash features -if [ ! -e "$PREFIX/alpine/initrc" ]; then cat <<'EOF' > "$PREFIX/alpine/initrc" # Source rc files if they exist @@ -195,7 +148,16 @@ _shorten_path() { [[ "$path" == /* ]] && echo "/$result" || echo "$result" } -PROMPT_COMMAND='_PS1_PATH=$(_shorten_path); _PS1_EXIT=$?' +_PS1_PATH="$(_shorten_path)" +_PS1_EXIT=0 + +_update_prompt_state() { + local last_exit=$? + _PS1_PATH="$(_shorten_path)" + _PS1_EXIT=$last_exit +} + +PROMPT_COMMAND='_update_prompt_state' # Source user configs AFTER defaults (so user can override PROMPT_COMMAND) if [ -f "$HOME/.bashrc" ]; then @@ -206,37 +168,95 @@ if [ -f /etc/bash/bashrc ]; then source /etc/bash/bashrc fi - -# Display MOTD if available +# Display MOTD (only source that reliably runs in proot bash) if [ -s /etc/acode_motd ]; then cat /etc/acode_motd fi -# Command-not-found handler +# Work around proot shebang execution failures for Bash's missing-command hook. +# Bash normally auto-executes /usr/libexec/command-not-found when a command is +# missing, but direct shebang execution can fail in proot with +# "bad interpreter: Bad address". Run the original script explicitly through +# /bin/sh so the script keeps working without relying on the broken shebang path. command_not_found_handle() { - cmd="$1" - pkg="" - green="\e[1;32m" - reset="\e[0m" - - pkg=$(apk search -x "cmd:$cmd" 2>/dev/null | awk -F'-[0-9]' '{print $1}' | head -n 1) - - if [ -n "$pkg" ]; then - echo -e "The program '$cmd' is not installed.\nInstall it by executing:\n ${green}apk add $pkg${reset}" >&2 - else - echo "The program '$cmd' is not installed and no package provides it." >&2 + if [ -x /usr/libexec/command-not-found ]; then + /bin/sh /usr/libexec/command-not-found "$@" + return $? fi + printf '%s: command not found\n' "$1" >&2 return 127 } +# acode CLI: defined as a bash function instead of a standalone script. +# In proot, a script with #!/bin/bash triggers execve("/bin/bash"), which the +# kernel handles in kernel-space. proot relies on ptrace to intercept execve and +# translate paths, but the kernel's shebang-triggered second execve can bypass +# proot's path translation (especially with --link2symlink or Android's ptrace +# restrictions), causing "bad interpreter: No such file or directory". +# A bash function runs in the current process — no execve, no shebang, no issue. +_acode_get_abs_path() { + local path="$1" abs_path="" + if command -v realpath >/dev/null 2>&1; then + abs_path=$(realpath -- "$path" 2>/dev/null) + fi + if [[ -z "$abs_path" ]]; then + if [[ -d "$path" ]]; then + abs_path=$(cd -- "$path" 2>/dev/null && pwd -P) + elif [[ -e "$path" ]]; then + abs_path="$(cd -- "$(dirname -- "$path")" 2>/dev/null && pwd -P)/$(basename -- "$path")" + elif [[ "$path" == /* ]]; then + abs_path="$path" + else + abs_path="$PWD/$path" + fi + fi + echo "$abs_path" +} +_acode_open() { + local path=$(_acode_get_abs_path "$1") + local type="file" + [[ -d "$path" ]] && type="folder" + printf '\e]7777;open;%s;%s\a' "$type" "$path" +} +acode() { + if [[ $# -eq 0 ]]; then + _acode_open "." + return 0 + fi + local arg + for arg in "$@"; do + case "$arg" in + -h|--help) + echo "Usage: acode [file/folder...]" + echo "" + echo "Open files or folders in Acode editor." + echo "" + echo "Examples:" + echo " acode file.txt # Open a file" + echo " acode . # Open current folder" + echo " acode ~/project # Open a folder" + echo " acode -h, --help # Show this help" + return 0 + ;; + *) + if [[ -e "$arg" ]]; then + _acode_open "$arg" + else + echo "Error: '$arg' does not exist" >&2 + return 1 + fi + ;; + esac + done +} + EOF -fi # Add PS1 only if not already present if ! grep -q 'PS1=' "$PREFIX/alpine/initrc"; then # Smart path shortening (fish-style: ~/p/s/components) - echo 'PS1="\[\033[1;32m\]\u\[\033[0m\]@localhost \[\033[1;34m\]\$_PS1_PATH\[\033[0m\] \[\$([ \$_PS1_EXIT -ne 0 ] && echo \"\033[31m\")\]\$\[\033[0m\] "' >> "$PREFIX/alpine/initrc" + echo 'PS1="\[\033[1;32m\]\u\[\033[0m\]@localhost \[\033[1;34m\]\$_PS1_PATH\[\033[0m\] \[\$([ "${_PS1_EXIT:-0}" -ne 0 ] && echo \"\033[31m\")\]\$\[\033[0m\] "' >> "$PREFIX/alpine/initrc" # Simple prompt (uncomment below and comment above if you prefer full paths) # echo 'PS1="\[\033[1;32m\]\u\[\033[0m\]@localhost \[\033[1;34m\]\w\[\033[0m\] \$ "' >> "$PREFIX/alpine/initrc" fi diff --git a/src/plugins/terminal/scripts/init-sandbox.sh b/src/plugins/terminal/scripts/init-sandbox.sh index 3b8a7c3d8..58a441d82 100644 --- a/src/plugins/terminal/scripts/init-sandbox.sh +++ b/src/plugins/terminal/scripts/init-sandbox.sh @@ -6,6 +6,14 @@ mkdir -p "$PREFIX/public" export PROOT_TMP_DIR=$PREFIX/tmp +# Disable seccomp filter in proot to avoid SIGSEGV/SIGBUS on kernels +# with strict seccomp policies. +# Impact: may slightly reduce syscall-level sandboxing on permissive kernels. +# Rationale: proot's seccomp filter is a performance optimization, not a security +# boundary; disabling it universally is the only way to prevent hard crashes on +# affected devices, with negligible downside on unaffected ones. +export PROOT_NO_SECCOMP=1 + if [ "$FDROID" = "true" ]; then if [ -f "$PREFIX/libproot.so" ]; then @@ -86,8 +94,12 @@ fi ARGS="$ARGS -r $PREFIX/alpine" ARGS="$ARGS -0" ARGS="$ARGS --link2symlink" -ARGS="$ARGS --sysvipc" +# --sysvipc removed: SysV IPC emulation causes Bus Error on some Android kernels. +# Impact: programs relying on SysV semaphores/shared-memory (e.g. PostgreSQL) +# will fail; most CLI tools are unaffected. +# Rationale: Bus Error is an unrecoverable crash that kills the entire proot +# session; the few programs needing SysV IPC are niche in a mobile editor +# context, whereas the crash affects all users on vulnerable kernels. ARGS="$ARGS -L" - $PROOT $ARGS /bin/sh $PREFIX/init-alpine.sh "$@" diff --git a/src/plugins/terminal/src/android/BackgroundExecutor.java b/src/plugins/terminal/src/android/BackgroundExecutor.java index 79e3f33ad..582bc1278 100644 --- a/src/plugins/terminal/src/android/BackgroundExecutor.java +++ b/src/plugins/terminal/src/android/BackgroundExecutor.java @@ -42,6 +42,11 @@ public boolean execute(String action, JSONArray args, CallbackContext callbackCo case "loadLibrary": loadLibrary(args.getString(0), callbackContext); return true; + case "download": + String downloadUrl = args.getString(0); + String downloadDest = args.getString(1); + cordova.getThreadPool().execute(() -> DownloadHelper.download(downloadUrl, downloadDest, callbackContext)); + return true; default: callbackContext.error("Unknown action: " + action); return false; @@ -161,4 +166,5 @@ private void cleanup(String pid) { processInputs.remove(pid); processCallbacks.remove(pid); } + } \ No newline at end of file diff --git a/src/plugins/terminal/src/android/DownloadHelper.java b/src/plugins/terminal/src/android/DownloadHelper.java new file mode 100644 index 000000000..3667dda93 --- /dev/null +++ b/src/plugins/terminal/src/android/DownloadHelper.java @@ -0,0 +1,115 @@ +package com.foxdebug.acode.rk.exec.terminal; + +import org.apache.cordova.*; +import org.json.*; + +class DownloadHelper { + + static void download(String url, String dst, CallbackContext callbackContext) { + java.net.HttpURLConnection conn = null; + try { + java.net.URI originalUri = java.net.URI.create(url); + java.net.URI currentUri = originalUri; + boolean originalHttps = "https".equalsIgnoreCase(originalUri.getScheme()); + int redirectCount = 0; + final int maxRedirects = 5; + int code; + + while (true) { + if (conn != null) { + conn.disconnect(); + } + + java.net.URL u = currentUri.toURL(); + conn = (java.net.HttpURLConnection) u.openConnection(); + conn.setInstanceFollowRedirects(false); + conn.setConnectTimeout(30000); + conn.setReadTimeout(60000); + conn.connect(); + code = conn.getResponseCode(); + + if (code == 301 || code == 302 || code == 303 || code == 307 || code == 308) { + if (redirectCount >= maxRedirects) { + callbackContext.error("Too many redirects"); + return; + } + + String loc = conn.getHeaderField("Location"); + if (loc == null || loc.isEmpty()) { + callbackContext.error("Redirect with no Location header (HTTP " + code + ")"); + return; + } + + java.net.URI redirectUri = java.net.URI.create(loc); + if (!redirectUri.isAbsolute()) { + redirectUri = currentUri.resolve(redirectUri); + } + + String scheme = redirectUri.getScheme(); + if (scheme == null || + (!"http".equalsIgnoreCase(scheme) && !"https".equalsIgnoreCase(scheme))) { + callbackContext.error("Unsupported redirect scheme: " + scheme); + return; + } + + if (originalHttps && "http".equalsIgnoreCase(scheme)) { + callbackContext.error("Refusing to follow HTTPS to HTTP redirect"); + return; + } + + // GitHub release assets routinely redirect to signed CDN hosts, so + // same-host enforcement would break valid downloads. We only allow + // HTTP(S) targets and block HTTPS downgrade instead of pinning hosts. + currentUri = redirectUri; + redirectCount++; + continue; + } + + break; + } + + if (code != 200) { + callbackContext.error("HTTP " + code); + return; + } + + long contentLength = conn.getContentLengthLong(); + java.io.File dstFile = new java.io.File(dst); + byte[] buf = new byte[65536]; + long downloaded = 0; + long startTime = System.currentTimeMillis(); + long lastReportTime = 0; + try (java.io.InputStream is = conn.getInputStream(); + java.io.FileOutputStream fos = new java.io.FileOutputStream(dstFile)) { + int len; + while ((len = is.read(buf)) > 0) { + fos.write(buf, 0, len); + downloaded += len; + long now = System.currentTimeMillis(); + if (now - lastReportTime >= 500) { + lastReportTime = now; + long elapsed = now - startTime; + long speed = elapsed > 0 ? downloaded * 1000 / elapsed : 0; + long eta = (speed > 0 && contentLength > 0) ? (contentLength - downloaded) / speed : -1; + JSONObject progress = new JSONObject(); + progress.put("type", "progress"); + progress.put("downloaded", downloaded); + progress.put("total", contentLength); + progress.put("speed", speed); + progress.put("eta", eta); + PluginResult pr = new PluginResult(PluginResult.Status.OK, progress.toString()); + pr.setKeepCallback(true); + callbackContext.sendPluginResult(pr); + } + } + } + callbackContext.success(dst); + } catch (Exception e) { + callbackContext.error("download failed: " + e.getMessage()); + } finally { + if (conn != null) { + conn.disconnect(); + } + } + } +} diff --git a/src/plugins/terminal/src/android/Executor.java b/src/plugins/terminal/src/android/Executor.java index 5d0e90777..f6409ea30 100644 --- a/src/plugins/terminal/src/android/Executor.java +++ b/src/plugins/terminal/src/android/Executor.java @@ -284,6 +284,11 @@ public boolean execute(String action, JSONArray args, CallbackContext callbackCo callbackContextMap.put(pidCheck, callbackContext); isProcessRunning(pidCheck); return true; + case "download": + String downloadUrl = args.getString(0); + String downloadDest = args.getString(1); + cordova.getThreadPool().execute(() -> DownloadHelper.download(downloadUrl, downloadDest, callbackContext)); + return true; default: callbackContext.error("Unknown action: " + action); return false; diff --git a/src/plugins/terminal/www/Executor.js b/src/plugins/terminal/www/Executor.js index 4cb6c970c..1b30bd591 100644 --- a/src/plugins/terminal/www/Executor.js +++ b/src/plugins/terminal/www/Executor.js @@ -192,6 +192,29 @@ class Executor { exec(resolve, reject, this.ExecutorType, "loadLibrary", [path]); }); } + + download(url, dst, onProgress) { + return new Promise((resolve, reject) => { + exec( + (msg) => { + if (onProgress && typeof msg === "string") { + try { + const data = JSON.parse(msg); + if (data.type === "progress") { + onProgress(data); + return; + } + } catch (_) {} + } + resolve(msg); + }, + reject, + this.ExecutorType, + "download", + [url, dst] + ); + }); + } } //backward compatibility diff --git a/src/plugins/terminal/www/Terminal.js b/src/plugins/terminal/www/Terminal.js index d63c29c16..78afdd9b1 100644 --- a/src/plugins/terminal/www/Terminal.js +++ b/src/plugins/terminal/www/Terminal.js @@ -9,6 +9,9 @@ const Terminal = { * @returns {Promise} - Returns true if installation completes with exit code 0, void if not installing */ async startAxs(installing = false, logger = console.log, err_logger = console.error) { + // Keep app alive in background + await Executor.moveToForeground().catch(() => {}); + const filesDir = await new Promise((resolve, reject) => { system.getFilesDir(resolve, reject); }); @@ -33,10 +36,25 @@ const Terminal = { // Check for exit code during installation if (type === "exit") { - resolve(data === "0"); + const success = data === "0"; + if (success) { + // Delete old .configured if it's a directory (from earlier proot mkdir -p). + // Then create it as a file via writeText (idempotent). + const writeMarker = () => { + system.writeText(`${filesDir}/.configured`, "1", () => { + resolve(true); + }, (err) => { + resolve(true); // still consider install OK + }); + }; + // Try to remove old directory first, ignore errors + Executor.execute(`rm -rf "${filesDir}/.configured"`).then(writeMarker).catch(writeMarker); + } else { + resolve(false); + } } }).then(async (uuid) => { - await Executor.write(uuid, `source ${filesDir}/init-sandbox.sh ${installing ? "--installing" : ""}; exit`); + await Executor.write(uuid, `source "${filesDir}/init-sandbox.sh" ${installing ? "--installing" : ""}; exit`); }).catch((error) => { err_logger("Failed to start AXS:", error); resolve(false); @@ -60,7 +78,11 @@ const Terminal = { Executor.start("sh", (type, data) => { logger(`${type} ${data}`); }).then(async (uuid) => { - await Executor.write(uuid, `source ${filesDir}/init-sandbox.sh ${installing ? "--installing" : ""}; exit`); + // Normal startup path: do not pass --installing. + // This avoids entering install-time setup when just launching AXS. + await Executor.write(uuid, `source "${filesDir}/init-sandbox.sh"; exit`); + }).catch((error) => { + err_logger("Failed to start AXS:", error); }); }); } @@ -71,7 +93,7 @@ const Terminal = { * @returns {Promise} */ async stopAxs() { - await Executor.execute(`kill -KILL $(cat $PREFIX/pid)`); + await Executor.execute(`kill -KILL $(cat $PREFIX/pid) 2>/dev/null`); }, /** @@ -98,19 +120,18 @@ const Terminal = { /** * Installs Alpine by downloading binaries and extracting the root filesystem. * Also sets up additional dependencies for F-Droid variant. + * Supports incremental install: skips already-completed steps based on + * marker files (.downloaded, .extracted, .configured) and existing binaries. * @param {Function} [logger=console.log] - Function to log standard output. * @param {Function} [err_logger=console.error] - Function to log errors. * @returns {Promise} - Returns true if installation completes with exit code 0 */ - async install(logger = console.log, err_logger = console.error) { + async install(logger = console.log, err_logger = console.error, _retried = false) { if (!(await this.isSupported())) return false; - try { - //cleanup before insatll - await this.uninstall(); - } catch (e) { - //supress error - } + // Start foreground service to prevent Android from killing the app + // during lengthy downloads/extraction when user switches away + await Executor.moveToForeground().catch(() => {}); const filesDir = await new Promise((resolve, reject) => { system.getFilesDir(resolve, reject); @@ -120,6 +141,30 @@ const Terminal = { system.getArch(resolve, reject); }); + // Helper: check if a file exists + const fileExists = (path) => new Promise((resolve) => { + system.fileExists(path, false, (result) => resolve(result == 1), () => resolve(false)); + }); + const writeText = (path, content) => new Promise((resolve, reject) => { + system.writeText(path, content, resolve, reject); + }); + + const formatBytes = (bytes) => { + if (bytes < 1024) return bytes + " B"; + if (bytes < 1048576) return (bytes / 1024).toFixed(1) + " KB"; + return (bytes / 1048576).toFixed(1) + " MB"; + }; + const formatEta = (seconds) => { + if (seconds < 60) return seconds + "s"; + const m = Math.floor(seconds / 60); + const s = seconds % 60; + return m + "m" + (s > 0 ? s + "s" : ""); + }; + + // Check which stages are already done + let alreadyDownloaded = await fileExists(`${filesDir}/.downloaded`); + const alreadyExtracted = await fileExists(`${filesDir}/.extracted`); + try { let alpineUrl; let axsUrl; @@ -152,106 +197,132 @@ const Terminal = { throw new Error(`Unsupported architecture: ${arch}`); } + // Invalidate download cache if URLs changed (e.g. version bump) + if (alreadyDownloaded) { + const currentManifest = [alpineUrl, axsUrl].join("\n"); + const savedManifest = await Executor.execute(`cat "${filesDir}/.download-manifest" 2>/dev/null || echo ""`); + if (savedManifest !== currentManifest) { + logger("🔄 Update detected, clearing download cache..."); + await Executor.execute(`rm -rf "${filesDir}/.downloaded" "${filesDir}/.extracted" "${filesDir}/alpine" "${filesDir}/alpine.tar.gz" "${filesDir}/axs" "${filesDir}/.download-manifest"`).catch(() => {}); + alreadyDownloaded = false; + } + } - logger("⬇️ Downloading sandbox filesystem..."); - await new Promise((resolve, reject) => { - cordova.plugin.http.downloadFile( - alpineUrl, {}, {}, - cordova.file.dataDirectory + "alpine.tar.gz", - resolve, reject - ); - }); - - logger("⬇️ Downloading axs..."); - await new Promise((resolve, reject) => { - cordova.plugin.http.downloadFile( - axsUrl, {}, {}, - cordova.file.dataDirectory + "axs", - resolve, reject - ); - }); - - const isFdroid = await Executor.execute("echo $FDROID"); - if (isFdroid === "true") { - logger("🐧 F-Droid flavor detected, downloading additional files..."); - logger("⬇️ Downloading compatibility layer..."); - await new Promise((resolve, reject) => { - cordova.plugin.http.downloadFile( - prootUrl, {}, {}, - cordova.file.dataDirectory + "libproot-xed.so", - resolve, reject - ); - }); - - logger("⬇️ Downloading supporting library..."); - await new Promise((resolve, reject) => { - cordova.plugin.http.downloadFile( - libTalloc, {}, {}, - cordova.file.dataDirectory + "libtalloc.so.2", - resolve, reject - ); - }); - - if (libproot != null) { - await new Promise((resolve, reject) => { - cordova.plugin.http.downloadFile( - libproot, {}, {}, - cordova.file.dataDirectory + "libproot.so", - resolve, reject - ); + // ── Phase 1: Download (skip if .downloaded marker exists) ── + if (!alreadyDownloaded) { + // Check individual files and only download what's missing + const hasAlpineTar = await fileExists(`${filesDir}/alpine.tar.gz`); + const hasAxs = await fileExists(`${filesDir}/axs`); + + if (!hasAlpineTar) { + logger("⬇️ Downloading sandbox filesystem..."); + await Executor.download(alpineUrl, `${filesDir}/alpine.tar.gz`, (p) => { + const dl = formatBytes(p.downloaded); + const total = p.total > 0 ? formatBytes(p.total) : "?"; + const speed = formatBytes(p.speed) + "/s"; + const eta = p.eta > 0 ? formatEta(p.eta) : "--"; + logger(`⬇️ ${dl} / ${total} ${speed} ETA ${eta}`); }); + } else { + logger("✅ Sandbox filesystem already downloaded"); } - if (libproot32 != null) { - await new Promise((resolve, reject) => { - cordova.plugin.http.downloadFile( - libproot32, {}, {}, - cordova.file.dataDirectory + "libproot32.so", - resolve, reject - ); + if (!hasAxs) { + logger("⬇️ Downloading axs..."); + await Executor.download(axsUrl, `${filesDir}/axs`, (p) => { + const dl = formatBytes(p.downloaded); + const total = p.total > 0 ? formatBytes(p.total) : "?"; + const speed = formatBytes(p.speed) + "/s"; + const eta = p.eta > 0 ? formatEta(p.eta) : "--"; + logger(`⬇️ ${dl} / ${total} ${speed} ETA ${eta}`); }); + } else { + logger("✅ AXS binary already downloaded"); } - } + const isFdroid = await Executor.execute("echo $FDROID"); + if (isFdroid === "true") { + logger("🐧 F-Droid flavor detected, checking additional files..."); + + const hasProot = await fileExists(`${filesDir}/libproot-xed.so`); + if (!hasProot) { + logger("⬇️ Downloading compatibility layer..."); + await Executor.download(prootUrl, `${filesDir}/libproot-xed.so`); + } + + const hasTalloc = await fileExists(`${filesDir}/libtalloc.so.2`); + if (!hasTalloc) { + logger("⬇️ Downloading supporting library..."); + await Executor.download(libTalloc, `${filesDir}/libtalloc.so.2`); + } + + if (libproot != null && !(await fileExists(`${filesDir}/libproot.so`))) { + await Executor.download(libproot, `${filesDir}/libproot.so`); + } + + if (libproot32 != null && !(await fileExists(`${filesDir}/libproot32.so`))) { + await Executor.download(libproot32, `${filesDir}/libproot32.so`); + } + } - logger("✅ All downloads completed"); + logger("✅ All downloads completed"); - logger("📁 Setting up directories..."); + // Save URL manifest for cache invalidation on version change + await writeText(`${filesDir}/.download-manifest`, [alpineUrl, axsUrl].join("\n")); - await new Promise((resolve, reject) => { - system.mkdirs(`${filesDir}/.downloaded`, resolve, reject); - }); + logger("📁 Setting up directories..."); + await new Promise((resolve, reject) => { + system.mkdirs(`${filesDir}/.downloaded`, resolve, reject); + }); + } else { + logger("✅ Downloads cached, skipping download phase"); + } - const alpineDir = `${filesDir}/alpine`; + // ── Phase 2: Extract (skip if .extracted marker exists) ── + if (!alreadyExtracted) { + const alpineDir = `${filesDir}/alpine`; - await new Promise((resolve, reject) => { - system.mkdirs(alpineDir, resolve, reject); - }); + // Clean up partial extraction from previous failed attempt + await Executor.execute(`rm -rf "${alpineDir}"`).catch(() => {}); + await new Promise((resolve, reject) => { + system.mkdirs(alpineDir, resolve, reject); + }); - logger("📦 Extracting sandbox filesystem..."); - await Executor.execute(`tar --no-same-owner -xf ${filesDir}/alpine.tar.gz -C ${alpineDir}`); + logger("📦 Extracting sandbox filesystem..."); + await Executor.execute(`tar --no-same-owner -xf "${filesDir}/alpine.tar.gz" -C "${alpineDir}"`); - logger("⚙️ Applying basic configuration..."); - system.writeText(`${alpineDir}/etc/resolv.conf`, `nameserver 8.8.4.4 \nnameserver 8.8.8.8`); + logger("⚙️ Applying basic configuration..."); + system.writeText(`${alpineDir}/etc/resolv.conf`, `nameserver 8.8.4.4\nnameserver 8.8.8.8`); - readAsset("rm-wrapper.sh", async (content) => { - system.deleteFile(`${alpineDir}/bin/rm`, logger, err_logger); - system.writeText(`${alpineDir}/bin/rm`, content, logger, err_logger); - system.setExec(`${alpineDir}/bin/rm`, true, logger, err_logger); - }); + readAsset("rm-wrapper.sh", async (content) => { + system.deleteFile(`${alpineDir}/bin/rm`, logger, err_logger); + system.writeText(`${alpineDir}/bin/rm`, content, logger, err_logger); + system.setExec(`${alpineDir}/bin/rm`, true, logger, err_logger); + }); - logger("✅ Extraction complete"); - await new Promise((resolve, reject) => { - system.mkdirs(`${filesDir}/.extracted`, resolve, reject); - }); + logger("✅ Extraction complete"); + await new Promise((resolve, reject) => { + system.mkdirs(`${filesDir}/.extracted`, resolve, reject); + }); + } else { + logger("✅ Extraction cached, skipping extraction phase"); + } + // ── Phase 3: Configure (always run — installs packages, creates configs) ── logger("⚙️ Updating sandbox enviroment..."); const installResult = await this.startAxs(true, logger, err_logger); + // .configured marker is now created inside startAxs(true) via system.writeText return installResult; } catch (e) { err_logger("Installation failed:", e); console.error("Installation failed:", e); + // Clean up everything so retry starts fresh (including potentially corrupted downloads) + await Executor.execute(`rm -rf "${filesDir}/.downloaded" "${filesDir}/.extracted" "${filesDir}/.configured" "${filesDir}/alpine" "${filesDir}/alpine.tar.gz" "${filesDir}/alpine.tar" "${filesDir}/.download-manifest"`).catch(() => {}); + if (!_retried) { + logger("🔄 Retrying installation from scratch..."); + return this.install(logger, err_logger, true); + } return false; } }, @@ -399,22 +470,39 @@ const Terminal = { } }); }, + /** + * Removes the .configured marker so the next terminal open triggers re-install. + * Does NOT delete the rootfs or downloaded files — only the config flag. + * @returns {Promise} - `true` if marker is removed, `false` otherwise. + */ + async resetConfigured() { + const filesDir = await new Promise((resolve, reject) => { + system.getFilesDir(resolve, reject); + }); + + try { + await Executor.execute(`rm -rf "$PREFIX/.configured" "${filesDir}/.configured"`); + } catch (error) { + // continue to existence check below + } + + const stillExists = await new Promise((resolve, reject) => { + system.fileExists(`${filesDir}/.configured`, false, (result) => { + resolve(result == 1); + }, reject); + }); + + return !stillExists; + }, + /** * Uninstalls the Alpine Linux installation * @async * @function uninstall - * @description Completely removes the Alpine Linux installation from the device by deleting all - * Alpine-related files and directories. This function stops any running Alpine processes before - * removal. NOTE: This does not perform cleanup of $PREFIX + * @description Removes the Alpine Linux rootfs and config markers, but preserves + * downloaded binaries (alpine.tar.gz, axs) as cache for faster re-install. * @returns {Promise} Promise that resolves to "ok" when uninstallation completes successfully * @throws {string} Rejects with command output if uninstallation fails - * @example - * try { - * await uninstall(); - * console.log("Alpine installation removed successfully"); - * } catch (error) { - * console.error(`Uninstall failed: ${error}`); - * } */ uninstall() { return new Promise(async (resolve, reject) => { @@ -422,19 +510,45 @@ const Terminal = { await this.stopAxs(); } + // Remove rootfs and markers, but keep downloaded files as cache + // (alpine.tar.gz, axs binary, libproot*.so, libtalloc.so.2) const cmd = ` set -e - INCLUDE_FILES="$PREFIX/alpine $PREFIX/.downloaded $PREFIX/.extracted $PREFIX/axs" - - if [ "$FDROID" = "true" ]; then - INCLUDE_FILES="$INCLUDE_FILES $PREFIX/libtalloc.so.2 $PREFIX/libproot-xed.so" - fi + INCLUDE_FILES="$PREFIX/alpine $PREFIX/.downloaded $PREFIX/.extracted $PREFIX/.configured" for item in $INCLUDE_FILES; do rm -rf -- "$item" done + echo "ok" + `; + const result = await Executor.execute(cmd); + if (result === "ok") { + resolve(result); + } else { + reject(result); + } + }); + }, + + /** + * Fully uninstalls Alpine including download cache. + * @returns {Promise} Resolves to "ok" when complete. + */ + uninstallFull() { + return new Promise(async (resolve, reject) => { + if (await this.isAxsRunning()) { + await this.stopAxs(); + } + + const filesDir = await new Promise((resolve, reject) => { + system.getFilesDir(resolve, reject); + }); + + const cmd = ` + set -e + rm -rf "${filesDir}/alpine" "${filesDir}/.downloaded" "${filesDir}/.extracted" "${filesDir}/.configured" "${filesDir}/alpine.tar.gz" "${filesDir}/alpine.tar" "${filesDir}/axs" "${filesDir}/libproot-xed.so" "${filesDir}/libtalloc.so.2" "${filesDir}/libproot.so" "${filesDir}/libproot32.so" "${filesDir}/.download-manifest" echo "ok" `; const result = await Executor.execute(cmd); diff --git a/src/settings/terminalSettings.js b/src/settings/terminalSettings.js index ca435600e..773baa543 100644 --- a/src/settings/terminalSettings.js +++ b/src/settings/terminalSettings.js @@ -111,6 +111,12 @@ export default function terminalSettings() { value: terminalValues.letterSpacing, prompt: strings["letter spacing"], promptType: "number", + promptOptions: { + test(value) { + value = Number.parseFloat(value); + return Number.isFinite(value) && value >= 0 && value <= 2; + }, + }, info: strings["info-letterSpacing"], category: categories.display, }, @@ -261,8 +267,12 @@ export default function terminalSettings() { "Are you sure you want to uninstall the terminal?", ); if (confirmation) { + const deleteCache = await confirm( + strings.confirm, + "Also delete download cache? Keeping it makes re-install faster.", + ); loader.showTitleLoader(); - Terminal.uninstall() + (deleteCache ? Terminal.uninstallFull() : Terminal.uninstall()) .then(() => { loader.removeTitleLoader(); alert( @@ -279,6 +289,13 @@ export default function terminalSettings() { return; default: + if (key === "letterSpacing") { + value = Number.parseFloat(value); + if (!Number.isFinite(value)) + value = DEFAULT_TERMINAL_SETTINGS.letterSpacing; + value = Math.max(0, Math.min(2, value)); + } + appSettings.update({ terminalSettings: { ...values.terminalSettings, @@ -388,11 +405,18 @@ async function updateActiveTerminals(key, value) { } catch (error) { console.warn(`Failed to load font ${value}:`, error); } - tab.terminalComponent.terminal.options.fontFamily = value; + tab.terminalComponent.options.fontFamily = `"${value}", monospace`; + tab.terminalComponent.terminal.options.fontFamily = `"${value}", monospace`; + if (tab.terminalComponent.webglAddon) { + try { + tab.terminalComponent.webglAddon.clearTextureAtlas(); + } catch (e) {} + } tab.terminalComponent.terminal.refresh( 0, tab.terminalComponent.terminal.rows - 1, ); + setTimeout(() => tab.terminalComponent.fit(), 100); break; case "fontWeight": tab.terminalComponent.terminal.options.fontWeight = value; @@ -416,7 +440,13 @@ async function updateActiveTerminals(key, value) { tab.terminalComponent.terminal.options.convertEol = value; break; case "letterSpacing": - tab.terminalComponent.terminal.options.letterSpacing = value; + { + let spacing = Number.parseFloat(value); + if (!Number.isFinite(spacing)) + spacing = DEFAULT_TERMINAL_SETTINGS.letterSpacing; + spacing = Math.max(0, Math.min(2, spacing)); + tab.terminalComponent.terminal.options.letterSpacing = spacing; + } break; case "theme": tab.terminalComponent.terminal.options.theme =