Skip to content

Commit 778c3e3

Browse files
author
埃博拉酱
committed
fix(terminal): address review follow-ups for install and recovery
Tighten terminal startup probing and relocation-error sniffing so review feedback does not regress startup reliability on older WebView builds. Harden Android-side download and asset copy helpers with safer redirect handling, proper resource cleanup, and threaded asset extraction. Quote shell paths consistently, refine bash availability detection in init-alpine.sh, and keep the allow-any-origin choice documented until axs exposes a real origin allowlist.
1 parent d2ee007 commit 778c3e3

File tree

8 files changed

+268
-76
lines changed

8 files changed

+268
-76
lines changed

src/components/terminal/terminal.js

Lines changed: 106 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -591,11 +591,43 @@ export default class TerminalComponent {
591591
// Poll by hitting the actual HTTP endpoint, not just checking PID liveness.
592592
// isAxsRunning() only does kill -0 on the PID file, which can return true
593593
// while the HTTP server inside proot is still booting.
594+
const fetchWithTimeout = async (url, options = {}, timeoutMs = 2000) => {
595+
const hasAbortSignalTimeout =
596+
typeof AbortSignal !== "undefined" &&
597+
typeof AbortSignal.timeout === "function";
598+
599+
if (hasAbortSignalTimeout) {
600+
return fetch(url, {
601+
...options,
602+
signal: AbortSignal.timeout(timeoutMs),
603+
});
604+
}
605+
606+
if (typeof AbortController === "undefined") {
607+
return fetch(url, options);
608+
}
609+
610+
const controller = new AbortController();
611+
const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
612+
try {
613+
return await fetch(url, {
614+
...options,
615+
signal: controller.signal,
616+
});
617+
} finally {
618+
clearTimeout(timeoutId);
619+
}
620+
};
621+
594622
const pollAxs = async (maxRetries = 30, intervalMs = 1000) => {
595623
for (let i = 0; i < maxRetries; i++) {
596624
await new Promise((r) => setTimeout(r, intervalMs));
597625
try {
598-
const resp = await fetch(`http://localhost:${this.options.port}/`, { method: 'GET', signal: AbortSignal.timeout(2000) });
626+
const resp = await fetchWithTimeout(
627+
`http://localhost:${this.options.port}/`,
628+
{ method: "GET" },
629+
2000,
630+
);
599631
if (resp.ok || resp.status < 500) return true;
600632
} catch (_) {
601633
// HTTP not yet reachable
@@ -615,18 +647,30 @@ export default class TerminalComponent {
615647
// AXS failed to start — attempt auto-repair
616648
toast("Repairing terminal environment...");
617649

618-
try { await Terminal.stopAxs(); } catch (_) { /* ignore */ }
650+
try {
651+
await Terminal.stopAxs();
652+
} catch (_) {
653+
/* ignore */
654+
}
619655

620656
// Re-run installing flow to repair packages / config
621-
const repairOk = await Terminal.startAxs(true, console.log, console.error);
657+
const repairOk = await Terminal.startAxs(
658+
true,
659+
console.log,
660+
console.error,
661+
);
622662
if (repairOk) {
623663
// Start AXS again after repair
624664
await Terminal.startAxs(false, () => {}, console.error);
625665
}
626666

627667
if (!(await pollAxs(30))) {
628668
// Still broken — clear .configured so next open re-triggers install
629-
try { await Terminal.resetConfigured(); } catch (_) { /* ignore */ }
669+
try {
670+
await Terminal.resetConfigured();
671+
} catch (_) {
672+
/* ignore */
673+
}
630674
throw new Error("Failed to start AXS server after repair attempt");
631675
}
632676
}
@@ -655,17 +699,23 @@ export default class TerminalComponent {
655699
const data = await response.text();
656700

657701
// Detect PTY errors from axs server (e.g. incompatible binary)
658-
if (data.includes('"error"') && data.includes('Failed to open PTY')) {
702+
if (data.includes('"error"') && data.includes("Failed to open PTY")) {
659703
const refreshed = await Terminal.refreshAxsBinary();
660704
if (refreshed) {
661705
// Kill old axs, restart with fresh binary, and retry once
662-
try { await Terminal.stopAxs(); } catch (_) {}
706+
try {
707+
await Terminal.stopAxs();
708+
} catch (_) {}
663709
await Terminal.startAxs(false, () => {}, console.error);
664710
const pollResult = await pollAxs(30);
665711
if (pollResult) {
666712
const retryResp = await fetch(
667713
`http://localhost:${this.options.port}/terminals`,
668-
{ method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(requestBody) },
714+
{
715+
method: "POST",
716+
headers: { "Content-Type": "application/json" },
717+
body: JSON.stringify(requestBody),
718+
},
669719
);
670720
if (retryResp.ok) {
671721
const retryData = await retryResp.text();
@@ -676,7 +726,7 @@ export default class TerminalComponent {
676726
}
677727
}
678728
}
679-
throw new Error('Failed to open PTY even after refreshing AXS binary');
729+
throw new Error("Failed to open PTY even after refreshing AXS binary");
680730
}
681731

682732
this.pid = data.trim();
@@ -703,6 +753,11 @@ export default class TerminalComponent {
703753
}
704754

705755
this.pid = pid;
756+
this._relocationSniffDisabled = false;
757+
clearTimeout(this._relocationSniffTimer);
758+
this._relocationSniffTimer = setTimeout(() => {
759+
this._relocationSniffDisabled = true;
760+
}, 15000);
706761

707762
const wsUrl = `ws://localhost:${this.options.port}/terminals/${pid}`;
708763

@@ -716,7 +771,9 @@ export default class TerminalComponent {
716771
// Reassigning this.attachAddon does NOT auto-clean listeners bound by the old instance,
717772
// which can cause duplicate socket handlers and leaks after reconnects.
718773
if (this.attachAddon) {
719-
try { this.attachAddon.dispose(); } catch (_) {}
774+
try {
775+
this.attachAddon.dispose();
776+
} catch (_) {}
720777
this.attachAddon = null;
721778
}
722779

@@ -748,19 +805,44 @@ export default class TerminalComponent {
748805

749806
// Also sniff the data to detect critical Alpine container corruption (e.g. bash/readline broken)
750807
this.websocket.addEventListener("message", async (event) => {
808+
if (this._relocationSniffDisabled) {
809+
return;
810+
}
811+
812+
const MAX_SNIFF_BYTES = 4096;
813+
751814
try {
752815
let text = "";
753816
if (typeof event.data === "string") {
754-
text = event.data;
755-
} else if (event.data instanceof ArrayBuffer || event.data instanceof Blob) {
756-
text = await new Response(event.data).text();
817+
text = event.data.slice(0, MAX_SNIFF_BYTES);
818+
} else if (event.data instanceof ArrayBuffer) {
819+
const byteLength = Math.min(event.data.byteLength, MAX_SNIFF_BYTES);
820+
const view = new Uint8Array(event.data, 0, byteLength);
821+
text = new TextDecoder("utf-8", { fatal: false }).decode(view);
822+
} else if (event.data instanceof Blob) {
823+
const slice =
824+
event.data.size > MAX_SNIFF_BYTES
825+
? event.data.slice(0, MAX_SNIFF_BYTES)
826+
: event.data;
827+
text = await new Response(slice).text();
828+
}
829+
830+
if (!text) {
831+
return;
757832
}
758-
759-
if (text.includes("Error relocating") && text.includes("symbol not found")) {
760-
console.error("Detected critical Alpine libc corruption! Terminating and triggering reinstall.");
833+
834+
if (
835+
text.includes("Error relocating") &&
836+
text.includes("symbol not found")
837+
) {
838+
console.error(
839+
"Detected critical Alpine libc corruption! Terminating and triggering reinstall.",
840+
);
761841
if (this.onCrashData) {
762842
this.onCrashData("relocation_error");
763843
}
844+
this._relocationSniffDisabled = true;
845+
clearTimeout(this._relocationSniffTimer);
764846
}
765847
} catch (err) {}
766848
});
@@ -998,7 +1080,9 @@ export default class TerminalComponent {
9981080
*/
9991081
async loadTerminalFont() {
10001082
// Use original name without quotes for Acode fonts.get
1001-
const fontFamily = this.options.fontFamily.replace(/^"|"$/g, '').replace(/",\s*monospace$/, '');
1083+
const fontFamily = this.options.fontFamily
1084+
.replace(/^"|"$/g, "")
1085+
.replace(/",\s*monospace$/, "");
10021086
if (fontFamily && fonts.get(fontFamily)) {
10031087
try {
10041088
await fonts.loadFont(fontFamily);
@@ -1007,7 +1091,9 @@ export default class TerminalComponent {
10071091
if (this.terminal) {
10081092
this.terminal.options.fontFamily = `"${fontFamily}", monospace`;
10091093
if (this.webglAddon) {
1010-
try { this.webglAddon.clearTextureAtlas(); } catch (e) {}
1094+
try {
1095+
this.webglAddon.clearTextureAtlas();
1096+
} catch (e) {}
10111097
}
10121098
// Ensure terminal dimensions are updated after font load changes char size
10131099
setTimeout(() => this.fit(), 100);
@@ -1072,6 +1158,9 @@ export default class TerminalComponent {
10721158
* Terminate terminal session
10731159
*/
10741160
async terminate() {
1161+
clearTimeout(this._relocationSniffTimer);
1162+
this._relocationSniffDisabled = true;
1163+
10751164
if (this.websocket) {
10761165
this.websocket.close();
10771166
}

src/components/terminal/terminalDefaults.js

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,8 @@ export function getTerminalSettings() {
3131
};
3232

3333
let spacing = Number.parseFloat(merged.letterSpacing);
34-
if (!Number.isFinite(spacing)) spacing = DEFAULT_TERMINAL_SETTINGS.letterSpacing;
34+
if (!Number.isFinite(spacing))
35+
spacing = DEFAULT_TERMINAL_SETTINGS.letterSpacing;
3536
merged.letterSpacing = Math.max(0, Math.min(2, spacing));
3637

3738
return merged;

src/components/terminal/terminalManager.js

Lines changed: 35 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -242,9 +242,12 @@ class TerminalManager {
242242
// opening a separate "Terminal Installation" tab. Keeping it inside
243243
// this init try/catch also reuses the same cleanup path on failure
244244
// (dispose component + remove broken tab).
245-
const installationResult = await this.checkAndInstallTerminal(false, {
246-
component: terminalComponent,
247-
});
245+
const installationResult = await this.checkAndInstallTerminal(
246+
false,
247+
{
248+
component: terminalComponent,
249+
},
250+
);
248251
if (!installationResult.success) {
249252
throw new Error(installationResult.error);
250253
}
@@ -335,7 +338,10 @@ class TerminalManager {
335338
* early return and run a full reinstall.
336339
* @returns {Promise<{success: boolean, error?: string}>}
337340
*/
338-
async checkAndInstallTerminal(forceReinstall = false, progressTerminal = null) {
341+
async checkAndInstallTerminal(
342+
forceReinstall = false,
343+
progressTerminal = null,
344+
) {
339345
try {
340346
// Check if terminal is already installed
341347
const isInstalled = await Terminal.isInstalled();
@@ -353,9 +359,12 @@ class TerminalManager {
353359
}
354360

355361
// Create installation progress terminal (or reuse current one)
356-
const installTerminal = progressTerminal || await this.createInstallationTerminal();
362+
const installTerminal =
363+
progressTerminal || (await this.createInstallationTerminal());
357364
if (progressTerminal?.component) {
358-
installTerminal.component.write("\x1b[33mInstalling terminal environment...\x1b[0m\r\n");
365+
installTerminal.component.write(
366+
"\x1b[33mInstalling terminal environment...\x1b[0m\r\n",
367+
);
359368
}
360369

361370
// Install terminal with progress logging
@@ -518,7 +527,7 @@ class TerminalManager {
518527
terminalFile.onfocus = () => {
519528
// Do NOT forcefully call fit() here, as the DOM might still be animating
520529
// or transitioning from display:none.
521-
// terminalFile._resizeObserver (ResizeObserver) already handles fitting
530+
// terminalFile._resizeObserver (ResizeObserver) already handles fitting
522531
// securely when the container's true dimensions are realized.
523532
terminalComponent.focus();
524533
};
@@ -679,12 +688,19 @@ class TerminalManager {
679688

680689
// Write recovery status directly into the current terminal
681690
terminalComponent.clear();
682-
terminalComponent.write("\x1b[33m⚠ Detected terminal environment corruption (libc/readline).\x1b[0m\r\n");
683-
terminalComponent.write("\x1b[33m Starting automatic repair...\x1b[0m\r\n\r\n");
691+
terminalComponent.write(
692+
"\x1b[33m⚠ Detected terminal environment corruption (libc/readline).\x1b[0m\r\n",
693+
);
694+
terminalComponent.write(
695+
"\x1b[33m Starting automatic repair...\x1b[0m\r\n\r\n",
696+
);
684697

685698
// Uninstall corrupted rootfs
686699
terminalComponent.write("Removing corrupted rootfs...\r\n");
687-
if (window.Terminal && typeof window.Terminal.uninstall === "function") {
700+
if (
701+
window.Terminal &&
702+
typeof window.Terminal.uninstall === "function"
703+
) {
688704
await window.Terminal.uninstall();
689705
}
690706
terminalComponent.write("Rootfs removed. Reinstalling...\r\n\r\n");
@@ -695,17 +711,23 @@ class TerminalManager {
695711
});
696712

697713
if (result.success) {
698-
terminalComponent.write("\r\n\x1b[32m✔ Recovery complete. Reconnecting session...\x1b[0m\r\n");
714+
terminalComponent.write(
715+
"\r\n\x1b[32m✔ Recovery complete. Reconnecting session...\x1b[0m\r\n",
716+
);
699717
// Clear the terminal buffer so the new shell prompt starts clean
700718
terminalComponent.clear();
701719
// Reconnect a fresh session in the same terminal
702720
await terminalComponent.connectToSession();
703721
} else {
704-
terminalComponent.write(`\r\n\x1b[31m✘ Recovery failed: ${result.error}\x1b[0m\r\n`);
722+
terminalComponent.write(
723+
`\r\n\x1b[31m✘ Recovery failed: ${result.error}\x1b[0m\r\n`,
724+
);
705725
}
706726
} catch (e) {
707727
console.error("In-place terminal recovery failed:", e);
708-
terminalComponent.write(`\r\n\x1b[31m✘ Recovery error: ${e.message}\x1b[0m\r\n`);
728+
terminalComponent.write(
729+
`\r\n\x1b[31m✘ Recovery error: ${e.message}\x1b[0m\r\n`,
730+
);
709731
}
710732
}
711733
};

src/plugins/system/android/com/foxdebug/system/System.java

Lines changed: 38 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -385,21 +385,44 @@ public void run() {
385385

386386
return true;
387387
case "copyAsset": {
388-
String assetName = args.getString(0);
389-
String destPath = args.getString(1);
390-
try {
391-
java.io.InputStream in = cordova.getActivity().getAssets().open(assetName);
392-
java.io.FileOutputStream out = new java.io.FileOutputStream(destPath);
393-
byte[] buf = new byte[65536];
394-
int len;
395-
while ((len = in.read(buf)) != -1) out.write(buf, 0, len);
396-
out.close();
397-
in.close();
398-
new File(destPath).setExecutable(true);
399-
callbackContext.success();
400-
} catch (Exception e) {
401-
callbackContext.error(e.getMessage());
402-
}
388+
final String assetName = args.getString(0);
389+
final String destPath = args.getString(1);
390+
cordova
391+
.getThreadPool()
392+
.execute(
393+
new Runnable() {
394+
@Override
395+
public void run() {
396+
File destFile = new File(destPath);
397+
File parentDir = destFile.getParentFile();
398+
if (parentDir != null && !parentDir.exists() && !parentDir.mkdirs()) {
399+
callbackContext.error("Failed to create destination directory: " + parentDir.getAbsolutePath());
400+
return;
401+
}
402+
403+
try (java.io.InputStream in = cordova.getActivity().getAssets().open(assetName);
404+
java.io.FileOutputStream out = new java.io.FileOutputStream(destFile)) {
405+
byte[] buf = new byte[65536];
406+
int len;
407+
while ((len = in.read(buf)) != -1) {
408+
out.write(buf, 0, len);
409+
}
410+
} catch (Exception e) {
411+
if (destFile.exists()) {
412+
destFile.delete();
413+
}
414+
callbackContext.error(e.getMessage());
415+
return;
416+
}
417+
418+
if (!destFile.setExecutable(true)) {
419+
callbackContext.error("Failed to set executable permission on: " + destFile.getAbsolutePath());
420+
return;
421+
}
422+
423+
callbackContext.success();
424+
}
425+
});
403426
return true;
404427
}
405428
default:

0 commit comments

Comments
 (0)