From 67cbac0eaa24149634ae81a11efe42edb3ddcd23 Mon Sep 17 00:00:00 2001 From: "ASKA C." Date: Tue, 15 Sep 2026 10:35:54 +0800 Subject: [PATCH 01/43] Keep Desktop login valid for its backend lifetime ## Why Desktop sleep can outlast the browser login deadline and leave the renderer locked without a usable recovery token. ## What changed - Create a process-lifetime login through the private Desktop launcher. - Preserve its session-only cookie across reload, renewal and recovery. - Keep ordinary browser expiry, Agent authorization and older Core fallback. ## Testing Pass 164 Core backend cases, 95 Windows Desktop unit tests and isolated backend smoke on Windows and WSL. Cover simulated long idle, browser expiry, Agent gates, cookie policy and rejection after backend restart. --- app.py | 33 +++++++--- desktop/README.md | 16 ++++- desktop/backend.py | 9 ++- desktop/smoke.cjs | 11 ++++ desktop/test/backend_smoke.py | 20 ++++++ tests/agent_backend_smoke.py | 112 ++++++++++++++++++++++++++++++---- 6 files changed, 178 insertions(+), 23 deletions(-) diff --git a/app.py b/app.py index b926f8b..1d4347f 100644 --- a/app.py +++ b/app.py @@ -2518,6 +2518,8 @@ def request_browser_ssh_signature(bridge, signer_sid, browser_key, challenge, al pending_terminal_starts = {} pending_terminal_bridges = {} pending_terminal_start_context = {} +# Deadlines are epoch seconds; None is reserved for a private Desktop login +# whose lifetime is the owned backend process, not a browser idle timeout. active_sessions = {} socket_session_tokens = {} socket_client_ips = {} @@ -5551,7 +5553,9 @@ def is_valid_launcher_shutdown_token(token): def is_valid_session(session_token): if not isinstance(session_token, str): return False - expires_at = active_sessions.get(session_token) + expires_at = active_sessions.get(session_token, 0) + if expires_at is None: + return True if not expires_at: return False if time.time() > expires_at: @@ -5567,7 +5571,7 @@ def cleanup_expired_sessions(): expired_tokens = [ session_token for session_token, expires_at in list(active_sessions.items()) - if now > expires_at + if expires_at is not None and now > expires_at ] for session_token in expired_tokens: active_sessions.pop(session_token, None) @@ -6211,11 +6215,26 @@ def parse_terminal_size(data): return None return cols, rows +def create_desktop_session(): + # Called only by the owned Desktop launcher, never by an HTTP login route. + # The private pipe conveys the cookie; process exit destroys the authority. + session_token = secrets.token_urlsafe(32) + active_sessions[session_token] = None + ensure_session_cleanup_task() + return session_token + +def session_cookie_max_age(session_token): + return None if active_sessions.get(session_token, 0) is None else SESSION_COOKIE_MAX_AGE + +def refresh_session_deadline(session_token): + if session_cookie_max_age(session_token) is not None: + active_sessions[session_token] = time.time() + SESSION_COOKIE_MAX_AGE + def set_session_cookie(response, session_token): response.set_cookie( SESSION_COOKIE_NAME, session_token, - max_age=SESSION_COOKIE_MAX_AGE, + max_age=session_cookie_max_age(session_token), httponly=True, samesite='Strict', secure=HTTPS_ENABLED, @@ -6246,16 +6265,16 @@ def build_session_redirect_response(): return add_common_headers(response) def build_existing_session_response(session_token): - active_sessions[session_token] = time.time() + SESSION_COOKIE_MAX_AGE + refresh_session_deadline(session_token) response = set_session_cookie(build_index_response(), session_token) return add_common_headers(response) def renew_session_response(session_token): - active_sessions[session_token] = time.time() + SESSION_COOKIE_MAX_AGE + refresh_session_deadline(session_token) response = jsonify({ 'status': 'ok', 'session_expires_at': active_sessions[session_token], - 'session_max_age_seconds': SESSION_COOKIE_MAX_AGE, + 'session_max_age_seconds': session_cookie_max_age(session_token), 'renew_interval_seconds': SESSION_RENEW_INTERVAL_SECONDS, }) set_session_cookie(response, session_token) @@ -6437,7 +6456,7 @@ def session_recovery_authenticate_complete(): 'message': 'No live StandTerm session is available for this platform credential. Enter the current access token.', }, status_code=409) - active_sessions[recovered_session_token] = time.time() + SESSION_COOKIE_MAX_AGE + refresh_session_deadline(recovered_session_token) response = jsonify({ 'status': 'ok', 'result': 'recovered', diff --git a/desktop/README.md b/desktop/README.md index 1e16fb8..9d65425 100644 --- a/desktop/README.md +++ b/desktop/README.md @@ -695,6 +695,15 @@ the credential-free Desktop startup URL. Other clients still need normal StandTerm authentication. External-agent discovery files retain their existing permission model and are separate from desktop-login credentials. +The owned Desktop login lasts until its backend process exits; it does not use +Core's 12-hour browser idle deadline or ask for a token after a long sleep. +Page reloads and renewal keep its HttpOnly cookie session-only. Closing Desktop +invalidates that process's login, and a new process rejects the old cookie. +This does not disable authentication for other browsers, prolong External Agent +tokens, bypass minting, or change human-input and per-copy approval gates. +An older user-selected Git Core without this Desktop-session capability retains +its original expiry behavior; select an updated Core to obtain this policy. + The authenticated Core UI has Node integration disabled, context isolation, renderer sandboxing and web security enabled, and no preload or IPC bridge. Network requests are limited to the owned loopback HTTP/WebSocket origin and local data/blob images. @@ -787,8 +796,11 @@ Generated capture samples are retained under ignored `desktop/dist/`; unit-test scratch files in the OS temporary directory are disposable and reproducible. `desktop/test/backend_smoke.py`, run through the selected project venv, separately -checks the private handshake, unauthenticated HTTP rejection, HttpOnly cookie -behavior, pipe-EOF exit, port closure and runtime artifact cleanup. +checks the private handshake, unauthenticated HTTP rejection, process-lifetime +HttpOnly cookies, renewal, stale-cookie rejection by a new process, pipe-EOF +exit, port closure and runtime artifact cleanup. Core's backend smoke simulates +30 days without renewal, checks ordinary browser expiry in the same process, +and verifies that Desktop login does not bypass external-agent minting or expiry. `desktop/test/bootstrap_smoke.py` checks manifest tampering, traversal/link rejection, existing-data preservation and retry after dependency failure. diff --git a/desktop/backend.py b/desktop/backend.py index 979d48e..101e636 100644 --- a/desktop/backend.py +++ b/desktop/backend.py @@ -88,8 +88,13 @@ def main(): return standterm.DEFAULT_PORT = actual_port origin = f'http://127.0.0.1:{actual_port}' - session_token = secrets.token_urlsafe(32) - standterm.active_sessions[session_token] = time.time() + standterm.SESSION_COOKIE_MAX_AGE + create_desktop_session = getattr(standterm, 'create_desktop_session', None) + if callable(create_desktop_session): + session_token = create_desktop_session() + else: + # Older user-selected Git Core keeps its original authentication policy. + session_token = secrets.token_urlsafe(32) + standterm.active_sessions[session_token] = time.time() + standterm.SESSION_COOKIE_MAX_AGE standterm.ensure_session_cleanup_task() standterm.write_external_agentinfo_files(base_url=origin) diff --git a/desktop/smoke.cjs b/desktop/smoke.cjs index 89e7128..e3bfad3 100644 --- a/desktop/smoke.cjs +++ b/desktop/smoke.cjs @@ -98,6 +98,17 @@ async function run(win, origin, contents = win.webContents, browserAccess) { assert.equal(isolated.loginVisible, false); assert.equal(isolated.cookie, ''); assert.equal(new URL(isolated.url).searchParams.has('token'), false); + const renewedSession = await contents.executeJavaScript(`fetch('/session/renew', { method: 'POST' }) + .then(response => response.json())`); + assert.equal(renewedSession.status, 'ok'); + assert.equal(renewedSession.session_expires_at, null); + assert.equal(renewedSession.session_max_age_seconds, null); + const loginCookies = await contents.session.cookies.get({ url: origin, name: 'standterm_session' }); + assert.equal(loginCookies.length, 1); + assert.equal(loginCookies[0].session, true); + assert.equal(loginCookies[0].httpOnly, true); + assert.equal(loginCookies[0].sameSite, 'strict'); + assert.equal(loginCookies[0].expirationDate, undefined); const prefs = contents.getLastWebPreferences(); assert.equal(prefs.sandbox, true); assert.equal(prefs.contextIsolation, true); diff --git a/desktop/test/backend_smoke.py b/desktop/test/backend_smoke.py index 73815c3..64a4ad6 100644 --- a/desktop/test/backend_smoke.py +++ b/desktop/test/backend_smoke.py @@ -75,9 +75,20 @@ def main(): assert response.status == 200 cookie_header = response.headers.get('Set-Cookie', '') assert 'HttpOnly' in cookie_header and 'SameSite=Strict' in cookie_header + assert 'Max-Age=' not in cookie_header and 'Expires=' not in cookie_header, \ + 'Owned Desktop login must not acquire a browser idle deadline' html = response.read().decode('utf-8') assert frame['session_token'] not in html assert 'const useDesktopFloatingWindows = true;' in html + request = urllib.request.Request(origin + '/session/renew', data=b'', headers={ + 'Cookie': frame['cookie_name'] + '=' + frame['session_token'], + }) + with opener.open(request, timeout=5) as response: + renewed = json.load(response) + assert renewed['session_expires_at'] is None + assert renewed['session_max_age_seconds'] is None + cookie_header = response.headers.get('Set-Cookie', '') + assert 'Max-Age=' not in cookie_header and 'Expires=' not in cookie_header proc.stdin.close() assert proc.wait(timeout=10) == 0 address = urllib.parse.urlparse(origin) @@ -116,6 +127,15 @@ def main(): raise AssertionError('Fixed-port desktop startup timed out') from None assert reused['type'] == 'standterm_desktop_ready' assert urllib.parse.urlparse(reused['origin']).port == busy_port + stale_request = urllib.request.Request(reused['origin'], headers={ + 'Cookie': frame['cookie_name'] + '=' + frame['session_token'], + }) + try: + opener.open(stale_request, timeout=5) + except urllib.error.HTTPError as exc: + assert exc.code == 401 + else: + raise AssertionError('A previous Desktop process cookie was accepted') proc.stdin.close() assert proc.wait(timeout=10) == 0 print('Desktop backend smoke: private handoff, authentication and EOF cleanup passed.') diff --git a/tests/agent_backend_smoke.py b/tests/agent_backend_smoke.py index 9e22549..1b00cb4 100644 --- a/tests/agent_backend_smoke.py +++ b/tests/agent_backend_smoke.py @@ -12,6 +12,7 @@ import stat import struct from types import SimpleNamespace +from unittest.mock import patch import zlib from pathlib import Path @@ -249,6 +250,82 @@ def test_session_renew_rejects_missing_or_expired_session(): assert session_token not in standterm.active_sessions +def make_desktop_flask_client(): + client = standterm.app.test_client() + client.set_cookie(standterm.SESSION_COOKIE_NAME, standterm.create_desktop_session()) + return client + + +def test_desktop_session_survives_idle_cleanup_and_renewal(): + desktop_client = make_desktop_flask_client() + desktop_token = flask_session_cookie_value(desktop_client) + desktop_bridge = add_dummy_bridge(desktop_token) + browser_client = make_flask_client() + browser_token = flask_session_cookie_value(browser_client) + browser_bridge = add_dummy_bridge(browser_token) + future = standterm.time.time() + 30 * 24 * 60 * 60 + + with patch.object(standterm.time, 'time', return_value=future): + assert standterm.is_valid_session(desktop_token) + standterm.cleanup_expired_sessions() + assert standterm.get_bridge(desktop_token, standterm.TERMINAL_ID_MAIN) is desktop_bridge + assert not desktop_bridge.closing + assert browser_bridge.closing + assert browser_token not in standterm.active_sessions + assert browser_client.post('/session/renew').status_code == 403 + for response in (desktop_client.get('/'), desktop_client.post('/session/renew')): + assert response.status_code == 200 + cookie = response.headers['Set-Cookie'] + assert 'Max-Age=' not in cookie and 'Expires=' not in cookie + assert 'HttpOnly' in cookie and 'SameSite=Strict' in cookie + payload = response.get_json() + assert payload['session_expires_at'] is None + assert payload['session_max_age_seconds'] is None + assert payload['renew_interval_seconds'] == standterm.SESSION_RENEW_INTERVAL_SECONDS + assert standterm.active_sessions[desktop_token] is None + + +def test_desktop_session_still_requires_private_cookie_and_live_process(): + client = make_desktop_flask_client() + token = flask_session_cookie_value(client) + unknown = standterm.app.test_client() + assert unknown.get('/?desktop=1').status_code == 401 + assert unknown.post('/session/renew', headers={'X-StandTerm-Desktop': '1'}).status_code == 403 + ordinary_login = unknown.post('/login', data={'token': standterm.ACCESS_TOKEN, 'desktop': '1'}) + assert ordinary_login.status_code == 302 + assert f'Max-Age={standterm.SESSION_COOKIE_MAX_AGE}' in ordinary_login.headers['Set-Cookie'] + assert standterm.active_sessions[flask_session_cookie_value(unknown)] is not None + + standterm.active_sessions.pop(token) + assert not standterm.is_valid_session(token) + assert client.post('/session/renew').status_code == 403 + assert client.get('/').status_code == 401 + assert token not in standterm.active_sessions + + +def test_desktop_session_does_not_bypass_external_agent_mint_or_expiry(): + flask_client = make_desktop_flask_client() + token = flask_session_cookie_value(flask_client) + client = make_socket_client(flask_client) + try: + add_dummy_bridge(token) + sid = current_sid_for_session(token) + external_token, _record, error = standterm.mint_external_agent_attach_token( + token, standterm.TERMINAL_ID_MAIN, sid) + assert error is not None and external_token is None + client.emit(standterm.AGENT_EVENT_ATTACH, {'terminal_id': standterm.TERMINAL_ID_MAIN}) + external_token, _record, error = standterm.mint_external_agent_attach_token( + token, standterm.TERMINAL_ID_MAIN, sid, idle_timeout_seconds=-1) + assert error is None + result = standterm.process_external_agent_command({ + 'op': 'state', 'token': external_token, 'terminal_id': standterm.TERMINAL_ID_MAIN, + }) + assert result['error_code'] == standterm.AGENT_ERROR_EXTERNAL_AGENT_EXPIRED + assert standterm.is_valid_session(token) + finally: + client.disconnect() + + def test_session_recovery_context_requires_hostname_and_secure_origin(): assert build_webauthn_context('http://localhost:5000/') == { 'rp_id': 'localhost', @@ -391,10 +468,7 @@ def test_session_recovery_unauthenticated_options_offer_only_armed_credentials() def test_session_recovery_complete_restores_bound_live_session_cookie(): - owner_client = make_flask_client() - owner_session = flask_session_cookie_value(owner_client) service = standterm.session_recovery_service - service.bind('localhost', 'credential-id', owner_session) original_finish = service.finish_authentication service.finish_authentication = lambda *_args, **_kwargs: { 'credential_id': 'credential-id', @@ -402,15 +476,26 @@ def test_session_recovery_complete_restores_bound_live_session_cookie(): 'backed_up': False, } try: - recovery_client = standterm.app.test_client() - response = recovery_client.post( - '/session-recovery/authenticate/complete', - base_url='http://localhost', - json={'ceremony_id': 'test', 'credential': {}}, - ) - assert response.status_code == 200 - assert response.get_json()['result'] == 'recovered' - assert flask_session_cookie_value(recovery_client) == owner_session + for owner_client in (make_flask_client(), make_desktop_flask_client()): + owner_session = flask_session_cookie_value(owner_client) + process_lifetime = standterm.active_sessions[owner_session] is None + service.bind('localhost', 'credential-id', owner_session) + recovery_client = standterm.app.test_client() + response = recovery_client.post( + '/session-recovery/authenticate/complete', + base_url='http://localhost', + json={'ceremony_id': 'test', 'credential': {}}, + ) + assert response.status_code == 200 + assert response.get_json()['result'] == 'recovered' + assert flask_session_cookie_value(recovery_client) == owner_session + cookie = response.headers['Set-Cookie'] + if process_lifetime: + assert standterm.active_sessions[owner_session] is None + assert 'Max-Age=' not in cookie and 'Expires=' not in cookie + else: + assert standterm.active_sessions[owner_session] > standterm.time.time() + assert f'Max-Age={standterm.SESSION_COOKIE_MAX_AGE}' in cookie finally: service.finish_authentication = original_finish @@ -8307,6 +8392,9 @@ def main(): test_access_required_page_rejects_invalid_login_token, test_session_renew_extends_existing_cookie_session, test_session_renew_rejects_missing_or_expired_session, + test_desktop_session_survives_idle_cleanup_and_renewal, + test_desktop_session_still_requires_private_cookie_and_live_process, + test_desktop_session_does_not_bypass_external_agent_mint_or_expiry, test_session_recovery_context_requires_hostname_and_secure_origin, test_native_loopback_access_host_uses_localhost_for_webauthn, test_session_recovery_registration_options_require_live_session_and_hostname, From 9dec47bd255c48422ba347cef44c63f93c4beba7 Mon Sep 17 00:00:00 2001 From: "ASKA C." Date: Tue, 15 Sep 2026 11:37:57 +0800 Subject: [PATCH 02/43] Prepare Desktop 0.5.2 with Core 2.13.1-dev --- core_version.py | 2 +- desktop/package-lock.json | 4 ++-- desktop/package.json | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/core_version.py b/core_version.py index fbf66a9..317acc8 100644 --- a/core_version.py +++ b/core_version.py @@ -1,3 +1,3 @@ """Core release identity, shared by source and packaged launchers.""" -CORE_VERSION = '2.13.0' +CORE_VERSION = '2.13.1-dev' diff --git a/desktop/package-lock.json b/desktop/package-lock.json index b74f890..eae2c3e 100644 --- a/desktop/package-lock.json +++ b/desktop/package-lock.json @@ -1,12 +1,12 @@ { "name": "standterm-desktop-evaluation", - "version": "0.5.1", + "version": "0.5.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "standterm-desktop-evaluation", - "version": "0.5.1", + "version": "0.5.2", "license": "MIT", "devDependencies": { "electron": "44.2.0", diff --git a/desktop/package.json b/desktop/package.json index 8a67e5d..a0c90cf 100644 --- a/desktop/package.json +++ b/desktop/package.json @@ -1,6 +1,6 @@ { "name": "standterm-desktop-evaluation", - "version": "0.5.1", + "version": "0.5.2", "private": true, "productName": "StandTerm Desktop", "author": "ASKA C.", From 6e2fa47e453de357b8d567ce7338d1bfa0d04026 Mon Sep 17 00:00:00 2001 From: "ASKA C." Date: Tue, 15 Sep 2026 11:45:44 +0800 Subject: [PATCH 03/43] Highlight Agent Info copy failures --- templates/index.html | 38 ++++++++++++++++++++++++++------------ 1 file changed, 26 insertions(+), 12 deletions(-) diff --git a/templates/index.html b/templates/index.html index ec673ae..93fcec0 100644 --- a/templates/index.html +++ b/templates/index.html @@ -292,6 +292,12 @@ .agent-connect-activity { white-space: pre-wrap; } .agent-connect-steps { padding-left: 24px; line-height: 1.5; } #agent-tunnel-message { white-space: pre-wrap; } + .agent-copy-warning { + position: sticky; top: 0; z-index: 1; + padding: 12px 14px; border: 2px solid #e3a632; border-radius: 6px; + background: #392b12; color: #ffe3a1; font-weight: 600; line-height: 1.5; + } + .agent-copy-warning::before { content: '\26A0\00A0'; } .agent-tunnel-actions { display: flex; flex-wrap: wrap; gap: 8px; justify-content: flex-end; margin-top: 12px; } #paste-review-modal { display: none; position: fixed; top: 0; left: 0; right: 0; bottom: 0; @@ -2652,16 +2658,24 @@

Recover StandTerm session

}).join('\n') : 'No active grants. Enable Agent and authorize the intended tabs first.'; } + function setAgentConnectionMessage(element, message, copyFailed = false) { + element.classList.toggle('agent-copy-warning', copyFailed); + element.setAttribute('role', copyFailed ? 'alert' : 'status'); + element.textContent = message; + } + async function copyAgentConnectionField(fieldId, messageElement) { const field = document.getElementById(fieldId); if (!field.value) return; try { await navigator.clipboard.writeText(field.value); - messageElement.textContent = 'Copied. Paste this to the agent in the indicated environment.'; + setAgentConnectionMessage(messageElement, 'Copied. Paste this to the agent in the indicated environment.'); } catch (_error) { field.focus(); field.select(); - messageElement.textContent = 'Clipboard unavailable. Text selected; copy it manually.'; + const shortcut = isApplePlatform() ? 'Command+C' : 'Ctrl+C'; + setAgentConnectionMessage(messageElement, `Not copied. Text selected; press ${shortcut} to copy it manually.`, true); + messageElement.scrollIntoView({ block: 'nearest' }); } } @@ -2675,10 +2689,10 @@

Recover StandTerm session

document.getElementById('agent-connect-copy').disabled = true; document.getElementById('agent-connect-copy-url').disabled = true; if (!socket.connected) { - agentConnectMessage.textContent = 'Core is disconnected. Reconnect before copying connection info.'; + setAgentConnectionMessage(agentConnectMessage, 'Core is disconnected. Reconnect before copying connection info.'); return; } - agentConnectMessage.textContent = 'Loading connection info…'; + setAgentConnectionMessage(agentConnectMessage, 'Loading connection info…'); socket.emit('agent_connect_info', result => { if (requestId !== agentConnectRequest || !agentConnectDialog.open) return; const available = result && result.status === 'ok'; @@ -2689,9 +2703,9 @@

Recover StandTerm session

document.getElementById('agent-connect-copy-url').disabled = !available; agentConnectActivity = available ? result.terminals || [] : []; renderAgentConnectionActivity('agent-connect-activity', agentConnectActivity); - agentConnectMessage.textContent = available + setAgentConnectionMessage(agentConnectMessage, available ? 'Copy Prompt to the agent running on the Core host. Its first authenticated request confirms access.' - : result?.message || 'Connection info unavailable.'; + : result?.message || 'Connection info unavailable.'); }); } @@ -2759,7 +2773,7 @@

Recover StandTerm session

if (data.status === 'stopped') { invalidateAgentTunnel(data.terminal_id, 'Tunnel stopped. Start it again before copying a remote prompt.'); } else if (data.status === 'sync_failed' && data.carrier_id === agentTunnelId) { - agentTunnelMessage.textContent = data.message; + setAgentConnectionMessage(agentTunnelMessage, data.message); } } @@ -2773,7 +2787,7 @@

Recover StandTerm session

document.getElementById('agent-tunnel-check').disabled = true; document.getElementById('agent-tunnel-stop').disabled = true; document.getElementById('agent-tunnel-copy').hidden = true; - agentTunnelMessage.textContent = message; + setAgentConnectionMessage(agentTunnelMessage, message); } function renderAgentTunnelTargets(selected) { @@ -2799,10 +2813,10 @@

Recover StandTerm session

readyAgentTunnels.delete(agentTunnelCarrier); } updateAgentTunnelButtons(); - agentTunnelMessage.textContent = ready ? 'Copy Prompt to the agent running on this SSH host. The URL below belongs to that remote host. Agent activity is shown separately.' + setAgentConnectionMessage(agentTunnelMessage, ready ? 'Copy Prompt to the agent running on this SSH host. The URL below belongs to that remote host. Agent activity is shown separately.' : result && result.status === 'stopped' ? (result.cleanup_pending ? 'Access revoked. Remote cleanup is pending.' : 'Tunnel stopped.') - : (result && result.message) || 'Tunnel setup failed.'; + : (result && result.message) || 'Tunnel setup failed.'); agentTunnelInfo.value = ready ? result.connect_info : ''; agentTunnelInfo.hidden = !ready; agentTunnelId = ready ? result.carrier_id : null; @@ -2838,8 +2852,8 @@

Recover StandTerm session

const requestId = ++agentTunnelRequest; const terminalId = agentTunnelCarrier; const connection = agentTunnelConnection; - agentTunnelMessage.textContent = operation === 'apply' ? 'Preparing and verifying Agent access…' - : operation === 'check' ? 'Checking the remote listener, helpers, and connection to Core…' : 'Updating tunnel status…'; + setAgentConnectionMessage(agentTunnelMessage, operation === 'apply' ? 'Preparing and verifying Agent access…' + : operation === 'check' ? 'Checking the remote listener, helpers, and connection to Core…' : 'Updating tunnel status…'); document.getElementById('agent-tunnel-apply').disabled = true; document.getElementById('agent-tunnel-check').disabled = true; if (operation === 'apply') document.getElementById('agent-tunnel-stop').disabled = false; From b936070586aa184c65f9a190b6e6878f9ec4a006 Mon Sep 17 00:00:00 2001 From: "ASKA C." Date: Tue, 15 Sep 2026 13:40:49 +0800 Subject: [PATCH 04/43] Clarify Files copy progress and download results ## What changed - Check destination conflicts before waiting for a busy SSH source, and revalidate the source after the user chooses an action. - Keep copy progress, conflict choices and cancellation visible in short Files windows. - Show the actual download path with a native folder reveal action. ## Testing Cover conflict ordering, progress during a 4 MiB transfer, source changes, cancellation and short-window layout. Windows Electron downloads through WSL Core pass the focused Files smoke. The full Desktop smoke stops at the existing window-focus assertion. --- app.py | 5 +- desktop/README.md | 7 +- desktop/floating-windows.cjs | 24 ++++- desktop/main.cjs | 13 ++- desktop/test/floating-smoke.cjs | 19 ++++ desktop/test/floating-windows.test.cjs | 32 +++++++ templates/index.html | 19 +++- tests/agent_backend_smoke.py | 123 +++++++++++++++++++++++++ tests/agent_browser_smoke.py | 33 ++++++- 9 files changed, 267 insertions(+), 8 deletions(-) diff --git a/app.py b/app.py index 1d4347f..17a1549 100644 --- a/app.py +++ b/app.py @@ -10657,13 +10657,16 @@ def discard_request_reservation(): source_bridge, source_file_id, ) - source_file = prepare_current_bridge_file(source_bridge, source_file) upload = prepare_bridge_upload( destination_bridge, destination_directory, destination_filename, conflict_mode, ) + # Ask about an existing destination before waiting for a busy source's + # transfer lock. Revalidate the source after the user chooses an action. + if upload.get('status') != 'conflict': + source_file = prepare_current_bridge_file(source_bridge, source_file) validate_distinct_file_copy_target( source_bridge, destination_bridge, diff --git a/desktop/README.md b/desktop/README.md index 9d65425..dd0a827 100644 --- a/desktop/README.md +++ b/desktop/README.md @@ -720,6 +720,10 @@ The URL itself can contain private information, so review it before approval. Non-HTTP(S), credential-bearing URLs, loopback links and POST popups are rejected; pending confirmations are coalesced and owner lifetime is checked again after approval. Owned Files download tickets retain their existing download path. +Completed Files downloads show the actual saved path in a native dialog with a +**Show in folder** button. The folder belongs to the Desktop computer, including +when Core runs in WSL or the source is an SSH host. Cancelled downloads do not +show a completion notice; interrupted downloads offer a retry hint. An OS browser-launch failure displays an error. Normal Web preview/popup behavior is unchanged. This follows the restrictive handling required by [Electron's external-link security guidance](https://www.electronjs.org/docs/latest/tutorial/security#15-do-not-use-shellopenexternal-with-untrusted-content). @@ -785,7 +789,8 @@ Floating-window regression also checks real child creation, private session and sandbox inheritance, rapid mixed clicks, denied navigation/nesting, close/restore, PiP-to-Files transition, reload cleanup and visible failure alerts. Where Local Files is supported, it browses a synthetic fixture and verifies downloaded binary -bytes without opening a save dialog or another child window. +bytes without opening a save dialog or another child window. Native dialog and +folder-reveal spies verify the actual saved path without opening Explorer/Finder. Capture smoke additionally saves a PNG, checks clipboard image packaging without touching the user's clipboard, records and decodes WebM with a source-page pixel diff --git a/desktop/floating-windows.cjs b/desktop/floating-windows.cjs index bdd656b..910900c 100644 --- a/desktop/floating-windows.cjs +++ b/desktop/floating-windows.cjs @@ -2,9 +2,31 @@ const { allowedFloatingWindow, allowedFilesDownload, allowedNavigation } = require('./policy.cjs'); -function installFloatingWindows(opener, origin, openExternal = () => {}, contents = opener.webContents) { +function installFloatingWindows(opener, origin, openExternal = () => {}, contents = opener.webContents, + downloadDone = () => {}) { const children = new Set(); const closing = new WeakSet(); + const downloads = new Map(); + const downloadSession = contents.session; + const onDownload = (_event, item, source) => { + const child = [...children].find(candidate => candidate.webContents === source); + if (source !== contents && !child) return; + if (!allowedFilesDownload(item.getURL(), origin)) return; + const done = (_doneEvent, state) => { + downloads.delete(item); + if (state === 'completed' || state === 'interrupted') { + downloadDone({ state, path: item.getSavePath() }, child && !child.isDestroyed() ? child : opener); + } + }; + downloads.set(item, done); + item.once('done', done); + }; + downloadSession.on('will-download', onDownload); + opener.once('closed', () => { + downloadSession.removeListener('will-download', onDownload); + for (const [item, done] of downloads) item.removeListener('done', done); + downloads.clear(); + }); const download = (target, details) => { if (!details.postBody && allowedNavigation(contents.getURL(), origin)) { if (allowedFilesDownload(details.url, origin)) target.downloadURL(details.url); diff --git a/desktop/main.cjs b/desktop/main.cjs index 091b61b..8a23554 100644 --- a/desktop/main.cjs +++ b/desktop/main.cjs @@ -459,7 +459,18 @@ async function start() { notify: () => dialog.showMessageBox(win, { type: 'warning', message: 'Could not open the default browser.', detail: 'Check the default HTTP/HTTPS browser in your operating system settings.' }), }); - installFloatingWindows(win, handoff.origin, openExternal, contents); + installFloatingWindows(win, handoff.origin, openExternal, contents, (result, owner) => { + const completed = result.state === 'completed' && !!result.path; + void dialog.showMessageBox(owner, { + type: completed ? 'info' : 'warning', title: 'Files download', + message: completed ? 'Download complete' : 'Download did not complete', + detail: completed ? `Saved to:\n${result.path}` : 'The connection was interrupted. Retry the download from Files.', + buttons: completed ? ['Close', 'Show in folder'] : ['Close'], + defaultId: 0, cancelId: 0, noLink: true, + }).then(answer => { + if (completed && answer.response === 1) shell.showItemInFolder(result.path); + }).catch(() => diagnostics.write('download_notice_failed')); + }); contents.on('will-navigate', (event, url) => { if (!allowedNavigation(url, handoff.origin)) event.preventDefault(); }); diff --git a/desktop/test/floating-smoke.cjs b/desktop/test/floating-smoke.cjs index 3b790e2..612386a 100644 --- a/desktop/test/floating-smoke.cjs +++ b/desktop/test/floating-smoke.cjs @@ -2,6 +2,7 @@ const assert = require('node:assert/strict'); const fs = require('node:fs'); const path = require('node:path'); +const { dialog, shell } = require('electron'); async function run(win, origin, contents = win.webContents) { const evaluate = async script => { @@ -92,12 +93,30 @@ async function run(win, origin, contents = win.webContents) { }, 15000).unref(); }); let result; + const originalDialog = dialog.showMessageBox; + const originalReveal = shell.showItemInFolder; + const notices = []; + const revealed = []; + dialog.showMessageBox = async (owner, options) => { + notices.push({ owner, options }); + return { response: 1 }; + }; + shell.showItemInFolder = selected => revealed.push(selected); try { await child.webContents.executeJavaScript("document.querySelector('.sftp-file-download').click()", true); result = await completed; + await new Promise(resolve => setImmediate(resolve)); + assert.equal(notices.length, 1, 'Files download must show one completion notice'); + assert.equal(notices[0].owner, child, 'notice must appear over the Files window'); + assert.equal(notices[0].options.message, 'Download complete'); + assert.equal(notices[0].options.detail, `Saved to:\n${output}`); + assert.deepEqual(notices[0].options.buttons, ['Close', 'Show in folder']); + assert.deepEqual(revealed, [output], 'reveal must use the actual renamed download path'); } finally { clearTimeout(timer); downloadSession.removeListener('will-download', onDownload); + dialog.showMessageBox = originalDialog; + shell.showItemInFolder = originalReveal; } console.log(`Floating smoke: download ${result}.`); if (result !== 'completed') item?.cancel(); diff --git a/desktop/test/floating-windows.test.cjs b/desktop/test/floating-windows.test.cjs index 7f00aec..ac8969a 100644 --- a/desktop/test/floating-windows.test.cjs +++ b/desktop/test/floating-windows.test.cjs @@ -32,6 +32,7 @@ test('Files downloads are exact-origin tickets, not arbitrary external links', ( test('floating children deny navigation/nesting and close with opener lifecycle', () => { const opener = new EventEmitter(); opener.webContents = new EventEmitter(); + opener.webContents.session = new EventEmitter(); opener.webContents.getURL = () => origin; opener.webContents.setWindowOpenHandler = fn => { opener.handler = fn; }; const downloads = []; @@ -73,3 +74,34 @@ test('floating children deny navigation/nesting and close with opener lifecycle' assert.equal(child.closed, true); assert.equal(opener.handler(valid).action, 'allow'); }); + +test('Files download notices use the completed native path and owned contents', () => { + const opener = new EventEmitter(); + const contents = opener.webContents = new EventEmitter(); + contents.session = new EventEmitter(); + contents.setWindowOpenHandler = () => {}; + const results = []; + installFloatingWindows(opener, origin, undefined, contents, result => results.push(result)); + const ticket = `${origin}/sftp/download/${'a'.repeat(32)}`; + const download = (source, url = ticket) => { + const item = new EventEmitter(); + item.getURL = () => url; + item.getSavePath = () => '/chosen folder/renamed.bin'; + contents.session.emit('will-download', {}, item, source); + return item; + }; + download(contents).emit('done', {}, 'completed'); + assert.deepEqual(results, [{ state: 'completed', path: '/chosen folder/renamed.bin' }]); + download(contents).emit('done', {}, 'cancelled'); + download(new EventEmitter()).emit('done', {}, 'completed'); + download(contents, 'https://example.com/file').emit('done', {}, 'completed'); + assert.equal(results.length, 1); + download(contents).emit('done', {}, 'interrupted'); + assert.equal(results.at(-1).state, 'interrupted'); + const pending = download(contents); + opener.emit('closed'); + pending.emit('done', {}, 'completed'); + assert.equal(results.length, 2); + assert.equal(contents.session.listenerCount('will-download'), 0); + assert.equal(pending.listenerCount('done'), 0); +}); diff --git a/templates/index.html b/templates/index.html index 93fcec0..ff7da3d 100644 --- a/templates/index.html +++ b/templates/index.html @@ -149,6 +149,15 @@ .sftp-destination-pane { animation: sftp-destination-slide 0.16s ease-out; } + .sftp-copy-details { + flex: 1; min-height: 0; display: flex; flex-direction: column; gap: 10px; overflow: auto; + } + .sftp-copy-details > :not(.sftp-directory-list) { flex-shrink: 0; } + .sftp-copy-feedback { flex-shrink: 0; display: flex; flex-direction: column; gap: 10px; } + .sftp-copy-feedback .sftp-transfer-status { color: #eee; font-weight: 600; } + .sftp-copy-feedback .sftp-transfer-status, .sftp-copy-feedback .sftp-conflict-text { + max-height: 4.2em; overflow: auto; + } @keyframes sftp-destination-slide { from { opacity: 0; transform: translateX(18px); } to { opacity: 1; transform: translateX(0); } @@ -265,7 +274,7 @@ .sftp-transfer-status { min-height: 18px; color: #aaa; overflow-wrap: anywhere; } .sftp-transfer-status.error { color: #ff6b61; } .sftp-transfer-status.success { color: #34c759; } - .sftp-progress { display: none; width: 100%; height: 6px; overflow: hidden; border-radius: 3px; background: #333; } + .sftp-progress { display: none; flex-shrink: 0; width: 100%; height: 6px; overflow: hidden; border-radius: 3px; background: #333; } .sftp-progress.visible { display: block; } .sftp-progress-bar { width: 0; height: 100%; background: #0a84ff; transition: width 0.1s linear; } .sftp-pip-actions, .sftp-conflict-actions { display: flex; justify-content: flex-end; gap: 7px; } @@ -9635,13 +9644,13 @@

Recover StandTerm session

state, data.status === 'committing' ? 'Publishing copied file…' - : `Copying ${formatSftpBytes(copied)} / ${formatSftpBytes(total)}…`, + : `Copying ${Math.floor(percent)}% · ${formatSftpBytes(copied)} / ${formatSftpBytes(total)}…`, ); state.elements.lifecycle.textContent = data.status === 'committing' ? 'Publishing has started. It cannot be cancelled; wait for the result before closing Files.' : (state.cancelPending ? 'Cancelling before the destination is published…' - : 'Use Cancel copy before closing Files. Closing the system window does not cancel the copy.'); + : 'Keep Files open until finished, or use Cancel copy.'); setSftpPipBusy(state, false); return; } @@ -9696,6 +9705,7 @@

Recover StandTerm session

const pane = sourceState.window.document.createElement('div'); pane.className = 'sftp-destination-pane'; pane.innerHTML = ` +
Choose destination
@@ -9721,6 +9731,8 @@

Recover StandTerm session

+ +
@@ -9736,6 +9748,7 @@

Recover StandTerm session

+
`; const destinationState = { mode: 'destination', diff --git a/tests/agent_backend_smoke.py b/tests/agent_backend_smoke.py index 1b00cb4..51fa37b 100644 --- a/tests/agent_backend_smoke.py +++ b/tests/agent_backend_smoke.py @@ -5546,6 +5546,127 @@ def test_files_copy_request_runs_human_local_copy_without_agent_approval(): client.disconnect() +def test_files_copy_conflict_precedes_reads_and_progress_precedes_completion(): + client = make_client() + session_token = current_session_token() + sid = current_sid_for_session(session_token) + source = make_local_file_test_bridge(session_token, 'copy-source') + destination = make_local_file_test_bridge(session_token, 'copy-destination') + for bridge in (source, destination): + bridge.attach(sid) + standterm.set_bridge(session_token, bridge.terminal_id, bridge) + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + (root / 'source').mkdir() + (root / 'destination').mkdir() + content = bytes(range(256)) * (16 * 1024) + (root / 'source' / 'large.bin').write_bytes(content) + existing = root / 'destination' / 'large.bin' + existing.write_bytes(b'original') + entry = source.browse_local_files(str(root / 'source'))['files'][0] + payload = { + 'request_id': 'large-conflict', 'source_terminal_id': source.terminal_id, + 'source_file_id': entry['file_id'], 'destination_terminal_id': destination.terminal_id, + 'destination_directory': str(existing.parent), 'destination_filename': existing.name, + 'conflict_mode': 'ask', + } + client.get_received() + with patch.object(source, 'download_local_chunks', side_effect=AssertionError('Read before conflict choice')) as read: + client.emit(standterm.FILES_COPY_REQUEST_EVENT, payload) + assert last_payload(client, standterm.FILES_COPY_RESULT_EVENT)['status'] == 'conflict' + read.assert_not_called() + assert not standterm.files_copy_jobs + assert existing.read_bytes() == b'original' + original_download = source.download_local_chunks + for mode in ('keep_both', 'replace'): + paused, resume = threading.Event(), threading.Event() + + def paced_download(snapshot): + transferred = 0 + for chunk in original_download(snapshot): + yield chunk + transferred += len(chunk) + if transferred == len(content) // 2: + paused.set() + assert resume.wait(5), 'Copy test did not release the source stream' + + request_id = f'large-{mode}' + client.get_received() + with patch.object(source, 'download_local_chunks', side_effect=paced_download): + try: + client.emit(standterm.FILES_COPY_REQUEST_EVENT, { + **payload, 'request_id': request_id, 'conflict_mode': mode, + }) + assert paused.wait(5), 'Copy did not reach the transfer midpoint' + events = [event['args'][0] for event in client.get_received() + if event['name'] == standterm.FILES_COPY_RESULT_EVENT] + assert any(event['status'] == 'running' and 0 < event['bytes_copied'] < len(content) + for event in events), 'Progress was not delivered during transfer' + assert not any(event['status'] in ('completed', 'conflict') for event in events) + assert existing.read_bytes() == b'original' + finally: + resume.set() + wait_until(lambda: any(job['request_id'] == request_id and job['status'] == 'completed' + for job in standterm.files_copy_jobs.values()), 'Large copy did not complete') + job = next(job for job in standterm.files_copy_jobs.values() if job['request_id'] == request_id) + assert Path(job['result']['destination_path']).read_bytes() == content + assert (job['result']['destination_path'] == str(existing)) == (mode == 'replace') + client.disconnect() + + +def test_files_copy_conflict_does_not_wait_for_busy_ssh_source(): + client = make_client() + session_token = current_session_token() + sid = current_sid_for_session(session_token) + source = make_sftp_test_bridge(session_token, 'busy-source') + destination = make_local_file_test_bridge(session_token, 'conflict-destination') + for bridge in (source, destination): + bridge.attach(sid) + standterm.set_bridge(session_token, bridge.terminal_id, bridge) + snapshot = { + 'directory': '/source', 'filename': 'large.bin', 'path': '/source/large.bin', + 'size': 4 * 1024 * 1024, 'mtime': 25, 'endpoint': source.sftp_endpoint(), + } + file_id = source._register_sftp_file_reference(snapshot) + source._open_sftp = lambda: SimpleNamespace( + normalize=lambda path: path, + stat=lambda path: SimpleNamespace(st_mode=stat.S_IFDIR | 0o755), + lstat=lambda path: SimpleNamespace(st_mode=stat.S_IFREG | 0o644, st_size=snapshot['size'], st_mtime=25), + close=lambda: None, + ) + with tempfile.TemporaryDirectory() as directory: + existing = Path(directory) / 'large.bin' + existing.write_bytes(b'original') + payload = { + 'request_id': 'busy-source-conflict', 'source_terminal_id': source.terminal_id, + 'source_file_id': file_id, 'destination_terminal_id': destination.terminal_id, + 'destination_directory': directory, 'destination_filename': existing.name, 'conflict_mode': 'ask', + } + # An ongoing SSH download holds this same lock until its stream closes. + source._sftp_lock.acquire() + worker = threading.Thread(target=lambda: client.emit(standterm.FILES_COPY_REQUEST_EVENT, payload), daemon=True) + client.get_received() + worker.start() + try: + worker.join(1) + assert not worker.is_alive(), 'Conflict checking waited for the busy SSH source' + assert last_payload(client, standterm.FILES_COPY_RESULT_EVENT)['status'] == 'conflict' + assert not standterm.files_copy_jobs + assert existing.read_bytes() == b'original' + finally: + source._sftp_lock.release() + worker.join(5) + with patch.object(source, 'prepare_sftp_file', side_effect=standterm.SFTPTransferError( + 'sftp_file_changed', 'Source changed while choosing a conflict action.')): + client.emit(standterm.FILES_COPY_REQUEST_EVENT, { + **payload, 'request_id': 'busy-source-replace', 'conflict_mode': 'replace', + }) + assert last_payload(client, standterm.FILES_COPY_RESULT_EVENT)['error_code'] == 'sftp_file_changed' + assert existing.read_bytes() == b'original' + assert not standterm.files_copy_jobs + client.disconnect() + + def test_files_copy_request_rejects_same_ssh_endpoint_path(): client = make_client() session_token = current_session_token() @@ -8489,6 +8610,8 @@ def main(): test_external_agent_file_copy_supports_local_shell_endpoints, test_local_shell_files_supports_browse_transfer_rename_and_delete, test_files_copy_request_runs_human_local_copy_without_agent_approval, + test_files_copy_conflict_precedes_reads_and_progress_precedes_completion, + test_files_copy_conflict_does_not_wait_for_busy_ssh_source, test_files_copy_request_rejects_same_ssh_endpoint_path, test_files_copy_start_failure_keeps_correlated_terminal_result, test_files_copy_cancel_request_stops_before_commit_barrier, diff --git a/tests/agent_browser_smoke.py b/tests/agent_browser_smoke.py index 937cc14..9a14302 100644 --- a/tests/agent_browser_smoke.py +++ b/tests/agent_browser_smoke.py @@ -1183,6 +1183,28 @@ def test_sftp_send_context_action_is_limited_to_connected_ssh_tabs(browser, acce check(copy_payload['destination_directory'] == '/home/local', 'Files copy lost the canonical destination view') check(copy_payload['destination_filename'] == 'reference.txt', 'Files copy lost the destination filename') check('source_path' not in copy_payload, 'Files copy trusted the displayed source path as authority') + conflict_ui = page.evaluate( + """payload => { + const w = documentPictureInPicture.window; + w.document.documentElement.style.height = '480px'; + w.document.documentElement.style.width = '620px'; + window.terminalTest.handleFilesCopyResultForTest({ + request_id: payload.request_id, status: 'conflict', + destination_path: '/home/' + 'nested/'.repeat(100) + 'reference.txt', existing_size: 9 + }); + const box = w.document.querySelector('.sftp-destination-pane .sftp-conflict-box'); + const rect = box.getBoundingClientRect(); + return { visible: w.getComputedStyle(box).display !== 'none', + top: rect.top, bottom: rect.bottom }; + }""", copy_payload, + ) + check(conflict_ui['visible'] and 0 <= conflict_ui['top'] < conflict_ui['bottom'] <= 480, + 'conflict choices were clipped in a short Files window') + check(len(get_emitted(page, 'files_copy_request')) == 1, + 'conflict started another copy before the user chose an action') + page.evaluate("() => documentPictureInPicture.window.document.querySelector('.sftp-destination-pane .sftp-conflict-keep').click()") + copy_payload = get_emitted(page, 'files_copy_request')[-1]['args'][0] + check(copy_payload['conflict_mode'] == 'keep_both', 'Keep Both lost the explicit conflict choice') copy_result_ui = page.evaluate( """payload => { window.terminalTest.handleFilesCopyResultForTest({ @@ -1191,7 +1213,7 @@ def test_sftp_send_context_action_is_limited_to_connected_ssh_tabs(browser, acce status: 'running', revision: 0, source_size: 9, - bytes_copied: 0, + bytes_copied: 4, total_bytes: 9, destination_path: '/home/local/reference.txt' }); @@ -1204,6 +1226,10 @@ def test_sftp_send_context_action_is_limited_to_connected_ssh_tabs(browser, acce }); const statusAfterForeign = documentPictureInPicture.window.document .querySelector('.sftp-destination-pane .sftp-transfer-status').innerText; + const feedback = documentPictureInPicture.window.document.querySelector('.sftp-copy-feedback'); + const progress = feedback.querySelector('.sftp-progress'); + const progressVisible = progress.getBoundingClientRect().height >= 6 + && feedback.getBoundingClientRect().top >= 0 && feedback.getBoundingClientRect().bottom <= 480; window.terminalTest.handleFilesCopyResultForTest({ request_id: payload.request_id, copy_id: 'filesc_test', @@ -1246,6 +1272,7 @@ def test_sftp_send_context_action_is_limited_to_connected_ssh_tabs(browser, acce }); return { statusAfterForeign, + progressVisible, publishing, terminalButtonText: documentPictureInPicture.window.document .querySelector('.sftp-copy-cancel').innerText, @@ -1256,6 +1283,10 @@ def test_sftp_send_context_action_is_limited_to_connected_ssh_tabs(browser, acce copy_payload, ) check(copy_result_ui['statusAfterForeign'].startswith('Copying '), 'Files copy accepted a foreign copy_id with the same request_id') + check('44%' in copy_result_ui['statusAfterForeign'] and '4 B / 9 B' in copy_result_ui['statusAfterForeign'], + 'Files copy omitted the percentage or transferred bytes') + check(copy_result_ui['progressVisible'], 'Files copy progress or cancel controls were clipped') + page.evaluate("() => { const s = documentPictureInPicture.window.document.documentElement.style; s.height = ''; s.width = ''; }") check(copy_result_ui['publishing']['text'] == 'Publishing…', 'commit barrier did not replace the cancel action') check(copy_result_ui['publishing']['disabled'] is True, 'commit barrier still allowed cancellation') check('cannot be cancelled' in copy_result_ui['publishing']['lifecycle'], 'commit barrier did not explain its cancellation boundary') From 3e90e5eda4e5f0fe28c661d677962442b43a1d81 Mon Sep 17 00:00:00 2001 From: "ASKA C." Date: Tue, 15 Sep 2026 15:21:36 +0800 Subject: [PATCH 05/43] Add user-managed SSH TCP tunnels ## Why Connected SSH tabs expose only the managed Agent Tunnel preset, leaving users without general local and remote TCP forwarding controls. ## What changed - Add temporary browser-owned local and remote forwards with status and Stop. - Share bounded duplex relays and reverse-port dispatch with Agent Tunnel. - Preserve Agent API permissions and reject non-loopback remote clients. ## Testing Exercise both directions through real OpenSSH and three jump hosts, large half-close and duplex transfers, independent Agent Tunnel cancellation, viewer ownership, late setup and Core API permission checks. Verify browser controls and the existing Core headless smoke collection. --- README.md | 45 ++++ agent_tunnel.py | 35 ++- app.py | 94 +++++++ scripts/run_smoke_tests.py | 4 + ssh_forwarding.py | 132 ++++++++++ ssh_tunnels.py | 224 ++++++++++++++++ templates/index.html | 223 +++++++++++++++- tests/agent_tunnel_smoke.py | 4 +- tests/ssh_tunnels_browser_smoke.py | 96 +++++++ tests/ssh_tunnels_smoke.py | 410 +++++++++++++++++++++++++++++ 10 files changed, 1245 insertions(+), 22 deletions(-) create mode 100644 ssh_forwarding.py create mode 100644 ssh_tunnels.py create mode 100644 tests/ssh_tunnels_browser_smoke.py create mode 100644 tests/ssh_tunnels_smoke.py diff --git a/README.md b/README.md index deccc68..adfb05a 100644 --- a/README.md +++ b/README.md @@ -622,6 +622,51 @@ Python environment. UART access follows the same local-client/browser-authorization gate as Local Shell unless `STANDTERM_ALLOW_REMOTE_UART=1` is set. +## User SSH Tunnels + +After connecting an SSH tab, open **Tunnels** to create temporary TCP forwards +without reconnecting. Choose a direction, listening port, target host and target +port; listening port `0` lets the operating system choose an available port. +Each tunnel shows its assigned port, state, active connections and byte counts. +**Stop** closes that tunnel's listener and connections while keeping SSH, Files +and other tunnels available. **Listening** confirms the forward is ready to +accept connections; target-service failures appear when a client connects. + +| Direction | Listener | Target is reached from | +| --- | --- | --- | +| Local (`-L`) | Core host's `127.0.0.1` | Final SSH host | +| Remote (`-R`) | Requested on final SSH host's `127.0.0.1` | Core host | + +For example, local port `8080` to target `127.0.0.1:80` makes the SSH host's web +service available at `http://127.0.0.1:8080` on the Core host. A remote forward +with those ports makes the Core host's port `80` available from the SSH host's +port `8080`. Here, **Core host** means where the Core process runs: WSL when +started by `run_wsl.bat`, even if the Desktop window runs on Windows. Target +hostnames are resolved on the side shown in the table. Jump routes use the final +SSH connection, including routes with three jump hosts. + +These controls require an authenticated browser viewer. External-agent commands, +helpers and skills cannot create, inspect or stop user tunnels. Tunnels stop when +SSH closes or their creating viewer disconnects, including page reload or loss +of its Core connection. Closing only the Tunnels dialog keeps them running. +They are not saved or restored automatically. A tab allows up to eight user +tunnels, with eight simultaneous connections per tunnel and 32 shared with Agent +Tunnel on its SSH transport. + +User tunnels require SSH TCP forwarding support but no remote StandTerm helper, +Python or listener-inspection tool. Local listeners bind only to `127.0.0.1`. +Remote forwards request that address and reject peers with non-loopback source +addresses; the SSH server controls the actual listening interfaces. A server +configured with `GatewayPorts yes` can bind more broadly than requested. Use +`GatewayPorts no` or `clientspecified` when the listener itself must stay on +loopback. Generic tunnels do not inspect or change server configuration. + +The target may be Core or an Agent HTTP endpoint. TCP forwarding grants no API +access by itself: the endpoint's authentication and tab permissions still apply. +**Agent Tunnel** remains the managed preset that prepares helpers, grants and +Agent Info, and verifies the remote listener. Its low privilege comes from its +scoped HTTP API and grants; generic TCP forwarding does not reproduce that setup. + ## SSH Agent Tunnel On a connected SSH tab, **Agent Tunnel** can provision remote Agent access diff --git a/agent_tunnel.py b/agent_tunnel.py index 130822f..58f779f 100644 --- a/agent_tunnel.py +++ b/agent_tunnel.py @@ -3,7 +3,6 @@ import base64 import hashlib import secrets -import select import re import shlex import socket @@ -14,6 +13,7 @@ from flask import Flask, jsonify, request from werkzeug.serving import WSGIRequestHandler, make_server +from ssh_forwarding import forwarding_for, relay_tcp TUNNEL_IO_TIMEOUT = 30 @@ -21,7 +21,6 @@ TUNNEL_MAX_CHANNELS = 16 TUNNEL_MAX_REQUEST_BYTES = 1024 * 1024 TUNNEL_MONITOR_INTERVAL = 1 -TUNNEL_FORWARD_POLL_SECONDS = 1 TUNNEL_REMOTE_PYTHON = 'python3' TUNNEL_HELPERS = ('cli', 'input', 'jsonl', 'repl', 'scp', 'shcmd', 'type', 'rsfile', 'mcp', 'tunnel_runtime') TUNNEL_SKILLS = ('standterm-external-agent-skill', 'standterm-file-transfer', 'standterm-privileged-hitl') @@ -46,6 +45,7 @@ def __init__(self, bridge, sid, app_dir, *, build_info, dispatch, revoke): self.revoke = revoke self.id = 'tun_' + secrets.token_urlsafe(18) self.transport = bridge.ssh.get_transport() + self.forwarding = forwarding_for(self.transport) if self.transport else None self.runtime = None self.port = None self.active = False @@ -228,27 +228,24 @@ def command(): def _accept(self, channel, origin, destination): with self.lock: if (not self.active or destination != ('127.0.0.1', self.port) - or len(self._channels) >= TUNNEL_MAX_CHANNELS): + or len(self._channels) >= TUNNEL_MAX_CHANNELS + or not self.forwarding.connections.acquire(blocking=False)): channel.close() return self._channels.add(channel) - threading.Thread(target=self._forward, args=(channel,), daemon=True).start() + try: + threading.Thread(target=self._forward, args=(channel,), daemon=True).start() + except Exception: + with self.lock: + self._channels.discard(channel) + self.forwarding.connections.release() + channel.close() def _forward(self, channel): local = None try: local = socket.create_connection(('127.0.0.1', self._server.server_port), TUNNEL_IO_TIMEOUT) - local.settimeout(TUNNEL_IO_TIMEOUT) - channel.settimeout(TUNNEL_IO_TIMEOUT) - while self.active: - readable, _, _ = select.select([channel, local], [], [], TUNNEL_FORWARD_POLL_SECONDS) - if not readable: - continue - for source in readable: - data = source.recv(65536) - if not data: - return - (local if source is channel else channel).sendall(data) + relay_tcp(channel, local, lambda: not self.active) except (OSError, EOFError): pass finally: @@ -257,6 +254,7 @@ def _forward(self, channel): local.close() with self.lock: self._channels.discard(channel) + self.forwarding.connections.release() def start(self): with self.setup_lock: @@ -312,9 +310,8 @@ def _monitor(self): return def _cancel_forward(self): - if self.port and self.transport.is_active(): - # A cancel acknowledgement is not needed to fence access locally. - self.transport.global_request('cancel-tcpip-forward', ('127.0.0.1', self.port), wait=False) + if self.port: + self.forwarding.cancel_remote(self.port, self) def _request_forward(self): completed = threading.Event() @@ -323,7 +320,7 @@ def _request_forward(self): def run(): try: - self.port = self.transport.request_port_forward('127.0.0.1', 0, handler=self._accept) + self.port = self.forwarding.request_remote(0, self, self._accept, self._closed.is_set) if self._closed.is_set(): self._cancel_forward() except Exception as exc: diff --git a/app.py b/app.py index 17a1549..90f8f42 100644 --- a/app.py +++ b/app.py @@ -23,6 +23,7 @@ from pathlib import Path, PurePosixPath from functools import partial from agent_tunnel import AgentTunnel, tunnel_ingress +from ssh_tunnels import UserTunnel, parse_tunnel_spec, USER_TUNNEL_MAX_ACTIVE, USER_TUNNEL_MAX_RECORDS from core_version import CORE_VERSION from flask import Flask, Response, render_template, request, abort, make_response, redirect, send_file, jsonify, stream_with_context from flask_socketio import SocketIO, ConnectionRefusedError @@ -3636,6 +3637,8 @@ def clear(self): external_agent_attach_store = ExternalAgentAttachStore() agent_tunnels = {} +user_ssh_tunnels = {} +user_ssh_tunnels_lock = threading.RLock() def get_agent_session_id(session_token): if not session_token: @@ -5852,6 +5855,10 @@ def close_bridge(bridge): if not bridge: return bridge.closing = True + with user_ssh_tunnels_lock: + user_tunnels = [record['tunnel'] for record in user_ssh_tunnels.values() if record['bridge'] is bridge] + for user_tunnel in user_tunnels: + user_tunnel.stop() tunnel = getattr(bridge, 'agent_tunnel', None) if tunnel: tunnel.close() @@ -7663,6 +7670,89 @@ def agent_tunnel_status(tunnel): } +def user_ssh_tunnel_authorized(record): + bridge = record['bridge'] + return (socket_session_tokens.get(record['sid']) == record['session_token'] + and get_bridge(record['session_token'], record['terminal_id']) is bridge + and not bridge.closing and bridge.ssh is not None + and bridge.ssh.get_transport() is record['transport'] + and is_terminal_bridge_allowed_for_sid(bridge, record['sid'])) + + +def emit_user_ssh_tunnel(record): + if not user_ssh_tunnel_authorized(record): + return + with user_ssh_tunnels_lock: + if user_ssh_tunnels.get(record['tunnel'].id) is not record: + return + socketio.emit('ssh_tunnel_state', { + 'terminal_id': record['terminal_id'], 'connection_id': record['tunnel'].forwarding.id, + 'tunnel': record['tunnel'].snapshot(), + }, room=record['sid']) + + +@socketio.on('ssh_tunnel') +def on_user_ssh_tunnel(data): + # This adapter requires browser-session authentication. Do not register it + # in the external-agent command table without a separate authorization flow. + session_token = socket_session_tokens.get(request.sid) + terminal_id = validate_terminal_id_payload(data) + if not session_token or not terminal_id: + return {'status': 'failed', 'message': 'Invalid SSH terminal.'} + bridge = get_allowed_bridge(session_token, terminal_id, request.sid) + transport = bridge.ssh.get_transport() if isinstance(bridge, SSHBridge) and bridge.ssh else None + if not transport or not transport.is_authenticated() or bridge.closing: + return {'status': 'failed', 'message': 'Tunnels require a connected SSH terminal.'} + operation = data.get('operation') + if operation == 'status': + with user_ssh_tunnels_lock: + records = [record for record in user_ssh_tunnels.values() + if record['bridge'] is bridge and record['sid'] == request.sid and record['transport'] is transport] + return {'status': 'ok', 'terminal_id': terminal_id, + 'tunnels': [record['tunnel'].snapshot() for record in records], + 'connection_id': UserTunnel.connection_id(transport), + 'ssh_endpoint': bridge.sftp_endpoint()} + if operation == 'stop': + tunnel_id = data.get('tunnel_id') + if not isinstance(tunnel_id, str) or len(tunnel_id) > 128: + return {'status': 'failed', 'message': 'Invalid tunnel.'} + with user_ssh_tunnels_lock: + record = user_ssh_tunnels.get(tunnel_id) + if not record or record['bridge'] is not bridge or record['sid'] != request.sid or record['transport'] is not transport: + return {'status': 'failed', 'message': 'This tunnel is unavailable to this viewer.'} + record['tunnel'].stop() + return {'status': 'ok', 'tunnel': record['tunnel'].snapshot()} + if operation != 'start': + return {'status': 'failed', 'message': 'Invalid tunnel operation.'} + try: + spec = parse_tunnel_spec(data.get('spec')) + except ValueError as exc: + return {'status': 'failed', 'message': str(exc)} + record = {'session_token': session_token, 'sid': request.sid, 'terminal_id': terminal_id, + 'bridge': bridge, 'transport': transport} + tunnel = UserTunnel(transport, spec, authorized=lambda: user_ssh_tunnel_authorized(record), + changed=lambda: emit_user_ssh_tunnel(record)) + record['tunnel'] = tunnel + with user_ssh_tunnels_lock: + if not user_ssh_tunnel_authorized(record): + return {'status': 'failed', 'message': 'The SSH connection or viewer changed during setup.'} + active = [item for item in user_ssh_tunnels.values() if not item['tunnel'].closed.is_set()] + if len(active) >= USER_TUNNEL_MAX_RECORDS or sum(item['bridge'] is bridge for item in active) >= USER_TUNNEL_MAX_ACTIVE: + return {'status': 'failed', 'message': 'The active tunnel limit was reached. Stop an unused tunnel first.'} + finished = sorted((item for item in user_ssh_tunnels.values() if item['tunnel'].closed.is_set()), + key=lambda item: item['tunnel'].created_at) + while len(user_ssh_tunnels) >= USER_TUNNEL_MAX_RECORDS and finished: + user_ssh_tunnels.pop(finished.pop(0)['tunnel'].id, None) + # Reserve the pending owner before any network I/O or background work. + user_ssh_tunnels[tunnel.id] = record + try: + socketio.start_background_task(tunnel.start) + except Exception: + tunnel.setup_done.set() + tunnel.stop('The tunnel worker could not be started.') + return {'status': 'ok', 'tunnel': tunnel.snapshot()} + + @socketio.on('agent_tunnel') def on_agent_tunnel(data): session_token = socket_session_tokens.get(request.sid) @@ -11374,6 +11464,10 @@ def on_disconnect(reason=None): agent_viewer_ids.pop(request.sid, None) if session_token: cancel_terminal_starts(session_token, sid=request.sid) + with user_ssh_tunnels_lock: + user_tunnels = [record['tunnel'] for record in user_ssh_tunnels.values() if record['sid'] == request.sid] + for user_tunnel in user_tunnels: + user_tunnel.stop() for tunnel in list(agent_tunnels.values()): if tunnel.sid == request.sid: tunnel.close() diff --git a/scripts/run_smoke_tests.py b/scripts/run_smoke_tests.py index d87a592..69a553a 100644 --- a/scripts/run_smoke_tests.py +++ b/scripts/run_smoke_tests.py @@ -10,6 +10,8 @@ 'app.py', 'session_recovery.py', 'server_startup.py', + 'ssh_forwarding.py', + 'ssh_tunnels.py', 'scripts/access_window.py', 'tests/access_window_smoke.py', 'tests/server_startup_smoke.py', @@ -28,6 +30,8 @@ 'tests/ssh_start_smoke.py', 'tests/ssh_login_smoke.py', 'tests/ssh_node_credentials_smoke.py', + 'tests/ssh_tunnels_smoke.py', + 'tests/ssh_tunnels_browser_smoke.py', 'tests/ime_anchor_browser_smoke.py', ] diff --git a/ssh_forwarding.py b/ssh_forwarding.py new file mode 100644 index 0000000..2364154 --- /dev/null +++ b/ssh_forwarding.py @@ -0,0 +1,132 @@ +"""Shared SSH forwarding primitives for user tunnels and the Agent preset.""" +import ipaddress +import secrets +import socket +import threading + + +FORWARD_BIND_HOST = '127.0.0.1' +FORWARD_CONNECT_TIMEOUT = 10 +FORWARD_POLL_SECONDS = 1 +FORWARD_BUFFER_BYTES = 65536 +FORWARD_MAX_CONNECTIONS = 32 +_contexts_lock = threading.Lock() + + +def is_loopback_address(address): + try: + value = ipaddress.ip_address(address) + return value.is_loopback or bool(getattr(value, 'ipv4_mapped', None) and value.ipv4_mapped.is_loopback) + except ValueError: + return False + + +class SSHForwarding: + def __init__(self, transport): + self.transport = transport + self.id = secrets.token_urlsafe(12) + self.connections = threading.BoundedSemaphore(FORWARD_MAX_CONNECTIONS) + self.lock = threading.Lock() + self.request_lock = threading.Lock() + self.routes = {} + + def request_remote(self, port, owner, accept, cancelled): + # Paramiko has one global request response slot and one TCP handler. + # A timed-out caller must not release this lock for its still-running I/O. + if not self.request_lock.acquire(blocking=False): + raise RuntimeError('Another remote tunnel request is still pending. Retry after it finishes.') + try: + if cancelled(): + raise RuntimeError('Tunnel setup was cancelled.') + allocated = self.transport.request_port_forward(FORWARD_BIND_HOST, port, handler=self._dispatch) + if not 1 <= allocated <= 65535: + raise RuntimeError('The SSH server returned an invalid listening port.') + with self.lock: + if allocated in self.routes: + raise RuntimeError('The SSH server reused an active tunnel port.') + self.routes[allocated] = (owner, accept) + if cancelled(): + self.cancel_remote(allocated, owner) + return allocated + finally: + self.request_lock.release() + + def cancel_remote(self, port, owner): + with self.lock: + route = self.routes.get(port) + if not route or route[0] is not owner: + return + del self.routes[port] + if self.transport.is_active(): + # cancel_port_forward clears Paramiko's handler for every listener. + self.transport.global_request('cancel-tcpip-forward', (FORWARD_BIND_HOST, port), wait=False) + + def _dispatch(self, channel, origin, destination): + with self.lock: + route = self.routes.get(destination[1]) + if not route: + channel.close() + return + # Callbacks only admit and start a worker; never connect or relay here. + try: + route[1](channel, origin, destination) + except Exception: + channel.close() + + +def forwarding_for(transport): + with _contexts_lock: + context = getattr(transport, '_standterm_forwarding', None) + if context is None: + context = SSHForwarding(transport) + transport._standterm_forwarding = context + return context + + +def relay_tcp(left, right, stopped, progress=None): + """Drain both TCP directions, including responses after a write half-close.""" + failed = threading.Event() + left.settimeout(FORWARD_POLL_SECONDS) + right.settimeout(FORWARD_POLL_SECONDS) + + def pump(source, destination, direction): + try: + while not stopped() and not failed.is_set(): + try: + data = source.recv(FORWARD_BUFFER_BYTES) + except socket.timeout: + continue + if not data: + if hasattr(destination, 'shutdown_write'): + destination.shutdown_write() + else: + destination.shutdown(socket.SHUT_WR) + return + pending = memoryview(data) + while pending and not stopped() and not failed.is_set(): + try: + sent = destination.send(pending) + except socket.timeout: + continue + if not sent: + raise EOFError('The forwarding destination closed.') + pending = pending[sent:] + if progress: + progress(direction, sent) + except (OSError, EOFError): + failed.set() + source.close() + destination.close() + + reverse = threading.Thread(target=pump, args=(right, left, 'received'), daemon=True) + try: + reverse.start() + pump(left, right, 'sent') + while reverse.is_alive(): + if stopped() or failed.is_set(): + left.close() + right.close() + reverse.join(FORWARD_POLL_SECONDS) + finally: + left.close() + right.close() diff --git a/ssh_tunnels.py b/ssh_tunnels.py new file mode 100644 index 0000000..95d33ea --- /dev/null +++ b/ssh_tunnels.py @@ -0,0 +1,224 @@ +"""User-configured TCP tunnels; authentication belongs to the calling adapter.""" +import ipaddress +import secrets +import socket +import threading +import time + +from ssh_forwarding import ( + FORWARD_BIND_HOST, FORWARD_CONNECT_TIMEOUT, FORWARD_POLL_SECONDS, + forwarding_for, is_loopback_address, relay_tcp, +) + + +USER_TUNNEL_SETUP_TIMEOUT = 45 +USER_TUNNEL_MAX_CONNECTIONS = 8 +USER_TUNNEL_MAX_ACTIVE = 8 +USER_TUNNEL_MAX_RECORDS = 64 + + +def parse_tunnel_spec(data): + if not isinstance(data, dict) or not isinstance(data.get('direction'), str) or data['direction'] not in {'local', 'remote'}: + raise ValueError('Choose a tunnel direction.') + ports = {} + for field, minimum in (('listen_port', 0), ('target_port', 1)): + value = data.get(field) + if type(value) is not int or not minimum <= value <= 65535: + raise ValueError('Enter valid port numbers. Listening port 0 chooses an available port.') + ports[field] = value + host = data.get('target_host') + if not isinstance(host, str) or not host or len(host) > 253: + raise ValueError('Enter a target hostname or IP address.') + if any(character.isspace() or ord(character) < 32 for character in host) or any(c in host for c in '/\\@\x7f'): + raise ValueError('Enter a hostname or IP address without a URL scheme or path.') + try: + ipaddress.ip_address(host) + except ValueError: + try: + host = host.encode('idna').decode('ascii') + except UnicodeError: + raise ValueError('The target hostname is invalid.') from None + if len(host) > 253 or ':' in host or any(not label or len(label) > 63 or label.startswith('-') or label.endswith('-') + or not all(c.isalnum() or c in '-_' for c in label) + for label in host.rstrip('.').split('.')): + raise ValueError('The target hostname is invalid.') + name = data.get('name', '') + if not isinstance(name, str) or len(name) > 80 or any(ord(c) < 32 or ord(c) == 127 for c in name): + raise ValueError('Tunnel names must be at most 80 characters without control characters.') + return {'direction': data['direction'], 'target_host': host, 'name': name, **ports} + + +class UserTunnel: + @staticmethod + def connection_id(transport): + return forwarding_for(transport).id + + def __init__(self, transport, spec, *, authorized, changed): + self.id = 'ssht_' + secrets.token_urlsafe(18) + self.transport = transport + self.forwarding = forwarding_for(transport) + self.spec = dict(spec) + self.authorized = authorized + self.changed = changed + self.lock = threading.RLock() + self.closed = threading.Event() + self.setup_done = threading.Event() + self.status = 'starting' + self.error = None + self.port = None + self.listener = None + self.channels = set() + self.bytes_sent = 0 + self.bytes_received = 0 + self.revision = 0 + self.created_at = time.monotonic() + + def snapshot(self): + with self.lock: + self.revision += 1 + return {'tunnel_id': self.id, **self.spec, 'status': self.status, + 'revision': self.revision, + 'bound_port': self.port, 'bind_host': FORWARD_BIND_HOST, + 'connections': len(self.channels), 'bytes_sent': self.bytes_sent, + 'bytes_received': self.bytes_received, 'error': self.error, + 'cleanup_pending': self.closed.is_set() and not self.setup_done.is_set()} + + def available(self): + return not self.closed.is_set() and self.transport.is_active() and self.authorized() + + def start(self): + if self.closed.is_set(): + self.setup_done.set() + return + try: + threading.Thread(target=self._setup, daemon=True).start() + except Exception: + self.setup_done.set() + self.stop('The tunnel setup worker could not be started.') + return + if not self.setup_done.wait(USER_TUNNEL_SETUP_TIMEOUT): + self.stop('Tunnel setup timed out. A pending remote request must finish before another can start.') + + def _setup(self): + try: + if not self.available(): + raise RuntimeError('The SSH connection or owning viewer is no longer available.') + if self.spec['direction'] == 'local': + listener = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + with self.lock: + self.listener = listener + if self.closed.is_set(): + listener.close() + return + listener.bind((FORWARD_BIND_HOST, self.spec['listen_port'])) + listener.listen(USER_TUNNEL_MAX_CONNECTIONS) + listener.settimeout(FORWARD_POLL_SECONDS) + self.port = listener.getsockname()[1] + else: + self.port = self.forwarding.request_remote( + self.spec['listen_port'], self, self._accept_remote, lambda: not self.available()) + with self.lock: + if not self.available(): + raise RuntimeError('Tunnel setup was cancelled.') + self.status = 'listening' + self.changed() + threading.Thread(target=self._monitor, daemon=True).start() + except Exception as exc: + self.stop(str(exc)) + finally: + self.setup_done.set() + if self.closed.is_set() and self.port and self.spec['direction'] == 'remote': + self.forwarding.cancel_remote(self.port, self) + self.changed() + + def _monitor(self): + while self.available(): + if self.spec['direction'] == 'remote': + self.closed.wait(FORWARD_POLL_SECONDS) + continue + try: + channel, origin = self.listener.accept() + except socket.timeout: + continue + except OSError: + break + self._accept(channel, origin) + self.stop() + + def _accept_remote(self, channel, origin, destination): + if destination != (FORWARD_BIND_HOST, self.port) or not is_loopback_address(origin[0]): + channel.close() + return + self._accept(channel, origin) + + def _accept(self, channel, origin): + with self.lock: + if (not self.available() or len(self.channels) >= USER_TUNNEL_MAX_CONNECTIONS + or not self.forwarding.connections.acquire(blocking=False)): + channel.close() + return + self.channels.add(channel) + try: + threading.Thread(target=self._connect, args=(channel, origin), daemon=True).start() + except Exception: + with self.lock: + self.channels.discard(channel) + self.error = 'A forwarding worker could not be started.' + self.forwarding.connections.release() + channel.close() + self.changed() + + def _connect(self, channel, origin): + target = None + try: + address = (self.spec['target_host'], self.spec['target_port']) + if self.spec['direction'] == 'local': + target = self.transport.open_channel('direct-tcpip', address, origin, timeout=FORWARD_CONNECT_TIMEOUT) + else: + target = socket.create_connection(address, FORWARD_CONNECT_TIMEOUT) + if not self.available(): + return + with self.lock: + self.error = None + self.changed() + relay_tcp(channel, target, lambda: not self.available(), self._progress) + except Exception: + with self.lock: + if not self.closed.is_set(): + self.error = 'A connection to the target failed. Check its address, service and SSH forwarding policy.' + self.changed() + finally: + channel.close() + if target: + target.close() + with self.lock: + self.channels.discard(channel) + self.forwarding.connections.release() + self.changed() + + def _progress(self, direction, size): + with self.lock: + if direction == 'sent': + self.bytes_sent += size + else: + self.bytes_received += size + + def stop(self, error=None): + with self.lock: + if self.closed.is_set(): + return + self.closed.set() + self.status = 'failed' if error else 'stopped' + self.error = error + listener, channels = self.listener, list(self.channels) + if listener: + try: + listener.shutdown(socket.SHUT_RDWR) + except OSError: + pass + listener.close() + for channel in channels: + channel.close() + if self.port and self.spec['direction'] == 'remote': + threading.Thread(target=self.forwarding.cancel_remote, args=(self.port, self), daemon=True).start() + self.changed() diff --git a/templates/index.html b/templates/index.html index ff7da3d..9ecbabc 100644 --- a/templates/index.html +++ b/templates/index.html @@ -301,6 +301,24 @@ .agent-connect-activity { white-space: pre-wrap; } .agent-connect-steps { padding-left: 24px; line-height: 1.5; } #agent-tunnel-message { white-space: pre-wrap; } + #ssh-tunnels-dialog { width: min(720px, calc(100vw - 32px)); box-sizing: border-box; + max-height: 85vh; overflow: auto; background: #222; color: #ddd; border: 1px solid #555; border-radius: 8px; } + #ssh-tunnels-dialog::backdrop { background: rgba(0,0,0,0.75); } + #ssh-tunnel-form { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; } + #ssh-tunnel-form label { display: flex; flex-direction: column; gap: 4px; font-size: 12px; } + #ssh-tunnel-form input, #ssh-tunnel-form select { box-sizing: border-box; width: 100%; margin: 0; min-width: 0; + padding: 8px; color: #eee; background: #333; border: 1px solid #555; border-radius: 4px; font: inherit; } + #ssh-tunnels-dialog button { padding: 8px 14px; color: #fff; background: #087fe7; + border: 0; border-radius: 4px; cursor: pointer; } + #ssh-tunnels-dialog button:disabled { opacity: 0.5; cursor: default; } + #ssh-tunnel-start { align-self: end; } + #ssh-tunnels-list { display: grid; gap: 8px; max-height: 35vh; overflow: auto; } + .ssh-tunnel-item { padding: 10px; border: 1px solid #555; border-radius: 5px; } + .ssh-tunnel-route, .ssh-tunnel-error { overflow-wrap: anywhere; } + .ssh-tunnel-details { color: #aaa; font-size: 12px; } + .ssh-tunnel-error, #ssh-tunnels-message.error { color: #ff8a83; } + .ssh-tunnel-item button { margin-bottom: 0; } + #ssh-tunnel-policy { color: #aaa; font-size: 12px; } .agent-copy-warning { position: sticky; top: 0; z-index: 1; padding: 12px 14px; border: 2px solid #e3a632; border-radius: 6px; @@ -1277,6 +1295,7 @@

Manual browser authorization

+
@@ -1342,6 +1361,32 @@

Agent Info

+ +

Tunnels for Current SSH Tab

+

+

TCP only. Core host means the computer running Core, including WSL when Core runs there. + These temporary tunnels stop when this SSH connection closes or this page disconnects, including a reload.

+
+

No user tunnels on this tab.

+

Add tunnel

+
+ + + + + + +
+

Listens on Core's 127.0.0.1.

+

+
+ + +
+
Socket: Disconnected @@ -2753,10 +2798,169 @@

Recover StandTerm session

} const state = getActiveTerminalState(); document.getElementById('agent-tunnel-btn').hidden = !state || !state.connected || state.connectionType !== 'ssh'; + document.getElementById('ssh-tunnels-btn').hidden = !state || !state.connected || state.connectionType !== 'ssh'; document.getElementById('agent-connect-btn').hidden = !state || (state.connectionType === 'ssh' && !readyAgentTunnels.has(state.id)); } + const sshTunnelsDialog = document.getElementById('ssh-tunnels-dialog'); + const sshTunnelsMessage = document.getElementById('ssh-tunnels-message'); + let sshTunnelView = null; + let sshTunnelTimer = null; + let sshTunnelRequest = 0; + let sshTunnelPending = false; + + function setSshTunnelMessage(message, error = false) { + sshTunnelsMessage.textContent = message; + sshTunnelsMessage.classList.toggle('error', error); + } + + function invalidateSshTunnelView() { + if (!sshTunnelView) return; + sshTunnelView.invalid = true; + clearInterval(sshTunnelTimer); + setSshTunnelMessage('The SSH connection or viewer changed. Reopen Tunnels after reconnecting.', true); + document.getElementById('ssh-tunnel-start').disabled = true; + for (const entry of sshTunnelView.entries.values()) { + entry.element.querySelector('.ssh-tunnel-details').textContent = 'Status unavailable for this viewer.'; + entry.element.querySelector('button').disabled = true; + } + } + + function renderSshTunnel(snapshot) { + const view = sshTunnelView; + if (!view || view.invalid || !snapshot || typeof snapshot.tunnel_id !== 'string') return; + let entry = view.entries.get(snapshot.tunnel_id); + if (entry && snapshot.revision < entry.snapshot.revision) return; + if (!entry) { + const element = document.createElement('div'); + element.className = 'ssh-tunnel-item'; + element.dataset.tunnelId = snapshot.tunnel_id; + element.innerHTML = '
' + + '
'; + entry = { element, snapshot }; + view.entries.set(snapshot.tunnel_id, entry); + element.querySelector('button').addEventListener('click', () => requestSshTunnel('stop', { tunnel_id: snapshot.tunnel_id })); + document.getElementById('ssh-tunnels-list').appendChild(element); + } + entry.snapshot = snapshot; + const remote = snapshot.direction === 'remote'; + const port = snapshot.bound_port || snapshot.listen_port || 'automatic'; + const target = snapshot.target_host.includes(':') ? `[${snapshot.target_host}]` : snapshot.target_host; + entry.element.querySelector('.ssh-tunnel-name').textContent = snapshot.name || (remote ? 'Remote tunnel (-R)' : 'Local tunnel (-L)'); + entry.element.querySelector('.ssh-tunnel-route').textContent = `${remote ? 'SSH remote' : 'Core host'} 127.0.0.1:${port}` + + ` → ${remote ? 'Core host' : 'SSH remote'} → ${target}:${snapshot.target_port}`; + const statuses = { starting: 'Starting', listening: 'Listening', stopped: 'Stopped', failed: 'Failed' }; + entry.element.querySelector('.ssh-tunnel-details').textContent = `${statuses[snapshot.status] || 'Unknown'} · ${snapshot.connections} connection(s)` + + ` · Sent ${formatSftpBytes(snapshot.bytes_sent)} · Received ${formatSftpBytes(snapshot.bytes_received)}` + + (snapshot.cleanup_pending ? ' · Cleanup pending' : ''); + entry.element.querySelector('.ssh-tunnel-error').textContent = snapshot.error || ''; + entry.element.querySelector('button').disabled = !['starting', 'listening'].includes(snapshot.status); + document.getElementById('ssh-tunnels-empty').hidden = true; + } + + function requestSshTunnel(operation, fields = {}) { + const view = sshTunnelView; + if (!view || view.invalid || !sshTunnelsDialog.open || sshTunnelPending) return; + if (!isCurrentAgentTunnelConnection(view.connection)) { + invalidateSshTunnelView(); + return; + } + sshTunnelPending = true; + const requestId = ++sshTunnelRequest; + document.getElementById('ssh-tunnel-start').disabled = true; + const timer = setTimeout(() => { + if (requestId !== sshTunnelRequest) return; + sshTunnelPending = false; + ++sshTunnelRequest; + setSshTunnelMessage('Tunnel status request timed out. Refresh status before retrying.', true); + document.getElementById('ssh-tunnel-start').disabled = false; + }, 15000); + socket.emit('ssh_tunnel', { terminal_id: view.connection.state.id, operation, ...fields }, result => { + clearTimeout(timer); + if (requestId !== sshTunnelRequest || view !== sshTunnelView || !sshTunnelsDialog.open) return; + sshTunnelPending = false; + if (!isCurrentAgentTunnelConnection(view.connection)) { + invalidateSshTunnelView(); + return; + } + document.getElementById('ssh-tunnel-start').disabled = false; + if (!result || result.status !== 'ok') { + setSshTunnelMessage(result?.message || 'Tunnel request failed.', true); + return; + } + if (operation === 'status') { + if (view.connectionId && view.connectionId !== result.connection_id) { + invalidateSshTunnelView(); + return; + } + setSshTunnelMessage(''); + view.connectionId = result.connection_id; + const current = new Set(result.tunnels.map(item => item.tunnel_id)); + for (const [id, entry] of view.entries) { + if (!current.has(id) && ['stopped', 'failed'].includes(entry.snapshot.status)) { + entry.element.remove(); + view.entries.delete(id); + } + } + result.tunnels.forEach(renderSshTunnel); + document.getElementById('ssh-tunnels-empty').hidden = view.entries.size > 0; + } else { + renderSshTunnel(result.tunnel); + setSshTunnelMessage(operation === 'start' ? 'Tunnel requested. Watch the tunnel status.' : 'Tunnel stopped.'); + } + }); + } + + function applySshTunnelState(data) { + const view = sshTunnelView; + if (view && isCurrentAgentTunnelConnection(view.connection) && data?.terminal_id === view.connection.state.id + && view.connectionId && data.connection_id === view.connectionId) renderSshTunnel(data.tunnel); + } + + document.getElementById('ssh-tunnels-btn').addEventListener('click', () => { + const state = getActiveTerminalState(); + if (!state || !state.connected || state.connectionType !== 'ssh') return; + sshTunnelView = { connection: { state, connectedAt: state.connectedAt }, connectionId: null, entries: new Map() }; + ++sshTunnelRequest; + sshTunnelPending = false; + document.getElementById('ssh-tunnels-carrier').textContent = `SSH tab: ${state.title} (${state.id}). Tunnels use this route's final SSH connection.`; + document.getElementById('ssh-tunnels-list').replaceChildren(); + document.getElementById('ssh-tunnels-empty').hidden = false; + setSshTunnelMessage(''); + sshTunnelsDialog.showModal(); + requestSshTunnel('status'); + clearInterval(sshTunnelTimer); + sshTunnelTimer = setInterval(() => requestSshTunnel('status'), 2000); + }); + document.getElementById('ssh-tunnels-close').addEventListener('click', () => sshTunnelsDialog.close()); + sshTunnelsDialog.addEventListener('close', () => { + if (sshTunnelsDialog.open) return; + clearInterval(sshTunnelTimer); + sshTunnelView = null; + sshTunnelPending = false; + ++sshTunnelRequest; + }); + document.getElementById('ssh-tunnels-refresh').addEventListener('click', () => requestSshTunnel('status')); + document.getElementById('ssh-tunnel-direction').addEventListener('change', event => { + const remote = event.target.value === 'remote'; + document.getElementById('ssh-tunnel-listen-label').firstChild.textContent = `${remote ? 'Remote' : 'Core'} listening port (0 = automatic)`; + document.getElementById('ssh-tunnel-target-host-label').firstChild.textContent = `Target host, reached from ${remote ? 'Core host' : 'SSH remote'}`; + document.getElementById('ssh-tunnel-bind-hint').textContent = remote + ? 'Requests remote 127.0.0.1; non-loopback clients are rejected. The SSH server controls its actual listening interfaces.' + : "Listens on Core's 127.0.0.1."; + }); + document.getElementById('ssh-tunnel-form').addEventListener('submit', event => { + event.preventDefault(); + requestSshTunnel('start', { spec: { + name: document.getElementById('ssh-tunnel-name').value, + direction: document.getElementById('ssh-tunnel-direction').value, + listen_port: document.getElementById('ssh-tunnel-listen').valueAsNumber, + target_host: document.getElementById('ssh-tunnel-target-host').value.trim(), + target_port: document.getElementById('ssh-tunnel-target-port').valueAsNumber, + } }); + }); + function setAgentTunnelInfoView(infoView) { document.getElementById('agent-tunnel-title').textContent = infoView ? 'Agent Info for Current Tab' : 'Agent Tunnel'; document.getElementById('agent-tunnel-setup').hidden = infoView; @@ -6485,6 +6689,7 @@

Recover StandTerm session

return Object.entries(value).some(([key, item]) => key === 'privateKey' || key === 'temporaryKeys' || containsPrivateSshData(item)); } let pendingAgentTunnelAcks = null; + let pendingSshTunnelAcks = null; const originalEmit = socket.emit.bind(socket); socket.emit = (eventName, ...args) => { if (['start_ssh', 'ssh_browser_sign_response'].includes(eventName)) privateSshWireData ||= args.some(containsPrivateSshData); @@ -6504,6 +6709,10 @@

Recover StandTerm session

pendingAgentTunnelAcks.push(args[1]); return socket; } + if (pendingSshTunnelAcks && eventName === 'ssh_tunnel') { + pendingSshTunnelAcks.push(args[1]); + return socket; + } return originalEmit(eventName, ...args); }; window.terminalTest = { @@ -6578,6 +6787,17 @@

Recover StandTerm session

applyAgentTunnelStateForTest(payload) { applyAgentTunnelState(cloneForTest(payload)); }, + holdSshTunnelRequestsForTest() { + pendingSshTunnelAcks = []; + }, + completeSshTunnelRequestForTest(index, payload) { + const acknowledge = pendingSshTunnelAcks[index]; + pendingSshTunnelAcks[index] = null; + acknowledge(cloneForTest(payload)); + }, + applySshTunnelStateForTest(payload) { + applySshTunnelState(cloneForTest(payload)); + }, applyAgentConnectionActivityForTest(payload) { applyAgentConnectionActivity(cloneForTest(payload)); }, @@ -7240,6 +7460,7 @@

Recover StandTerm session

applyAgentStatePayload(data); }); socket.on('agent_tunnel_state', applyAgentTunnelState); + socket.on('ssh_tunnel_state', applySshTunnelState); socket.on('agent_connection_activity', applyAgentConnectionActivity); socket.on('agent_action_request', data => { applyAgentActionPayload(data); @@ -10319,7 +10540,7 @@

Recover StandTerm session

return !!request && request.state === getActiveTerminalState() && isCurrentTerminalState(request.state) && !request.state.inPip && request.state.connected && socket.connected && document.activeElement === request.focus - && !document.querySelector('#settings-modal.open, #paste-review-modal.open, #session-recovery-modal.open, #browser-auth-help-modal.open, #agent-tunnel-dialog[open], #agent-connect-dialog[open]'); + && !document.querySelector('#settings-modal.open, #paste-review-modal.open, #session-recovery-modal.open, #browser-auth-help-modal.open, #agent-tunnel-dialog[open], #agent-connect-dialog[open], #ssh-tunnels-dialog[open]'); } document.addEventListener('focusin', () => { if (pendingContextPaste && document.activeElement !== pendingContextPaste.focus) pendingContextPaste = null; diff --git a/tests/agent_tunnel_smoke.py b/tests/agent_tunnel_smoke.py index b0e3a4f..bf90ccd 100644 --- a/tests/agent_tunnel_smoke.py +++ b/tests/agent_tunnel_smoke.py @@ -31,7 +31,7 @@ @contextlib.contextmanager -def ssh_server(gateway_ports='no'): +def ssh_server(gateway_ports='no', forwarding='remote'): executable = shutil.which('sshd') or '/usr/sbin/sshd' if not Path(executable).is_file(): raise unittest.SkipTest('OpenSSH server is required for transport integration.') @@ -51,7 +51,7 @@ def ssh_server(gateway_ports='no'): 'PidFile ' + str(root / 'pid'), 'StrictModes no', 'UsePAM no', 'PasswordAuthentication no', 'KbdInteractiveAuthentication no', 'PermitRootLogin prohibit-password', - 'AllowTcpForwarding remote', 'GatewayPorts ' + gateway_ports, + 'AllowTcpForwarding ' + forwarding, 'GatewayPorts ' + gateway_ports, 'Subsystem sftp internal-sftp', 'LogLevel ERROR', ]) (root / 'sshd_config').write_text(config + '\n') diff --git a/tests/ssh_tunnels_browser_smoke.py b/tests/ssh_tunnels_browser_smoke.py new file mode 100644 index 0000000..af2ac45 --- /dev/null +++ b/tests/ssh_tunnels_browser_smoke.py @@ -0,0 +1,96 @@ +"""Check human tunnel controls, direction labels and stale SSH/UI replies.""" +import agent_browser_smoke as fixture + + +def test_tunnel_controls(browser, url): + context, page = fixture.new_page(browser, url) + try: + result = page.evaluate("""() => { + const test = window.terminalTest; + const button = document.getElementById('ssh-tunnels-btn'); + const hiddenForLocal = button.hidden; + test.applyTerminalListForTest({ terminals: [{ terminal_id: 'main', connection_type: 'ssh', + terminal_label: 'Tunnel fixture', connected: true, files_available: true }] }); + const visibleForSsh = !button.hidden; + test.holdSshTunnelRequestsForTest(); + button.click(); + const dialog = document.getElementById('ssh-tunnels-dialog'); + test.completeSshTunnelRequestForTest(0, { status: 'ok', tunnels: [], connection_id: 'connection-1' }); + const direction = document.getElementById('ssh-tunnel-direction'); + direction.value = 'remote'; + direction.dispatchEvent(new Event('change')); + const labels = { + listen: document.getElementById('ssh-tunnel-listen-label').innerText, + target: document.getElementById('ssh-tunnel-target-host-label').innerText, + bind: document.getElementById('ssh-tunnel-bind-hint').innerText, + lifetime: document.getElementById('ssh-tunnel-policy').innerText + }; + const name = ''; + document.getElementById('ssh-tunnel-name').value = name; + document.getElementById('ssh-tunnel-target-host').value = '127.0.0.1'; + document.getElementById('ssh-tunnel-target-port').value = '8080'; + document.getElementById('ssh-tunnel-form').requestSubmit(); + const requests = () => test.getEmitted().filter(item => item.event === 'ssh_tunnel'); + const start = requests().at(-1).args[0]; + const snapshot = { tunnel_id: 'ssht_one', name, direction: 'remote', target_host: '127.0.0.1', + listen_port: 0, target_port: 8080, bound_port: null, status: 'starting', revision: 1, + connections: 0, bytes_sent: 0, bytes_received: 0 }; + test.completeSshTunnelRequestForTest(1, { status: 'ok', tunnel: snapshot }); + const update = tunnel => test.applySshTunnelStateForTest({ + terminal_id: 'main', connection_id: 'connection-1', tunnel }); + update({ ...snapshot, bound_port: 50001, status: 'listening', revision: 2, bytes_sent: 4096 }); + const row = document.querySelector('.ssh-tunnel-item'); + const running = row.innerText; + const safeName = row.querySelector('.ssh-tunnel-name').textContent === name && !row.querySelector('img'); + test.applySshTunnelStateForTest({ terminal_id: 'main', connection_id: 'foreign', + tunnel: { ...snapshot, status: 'failed', revision: 99 } }); + const foreignIgnored = row.innerText === running; + row.querySelector('button').click(); + const stop = requests().at(-1).args[0]; + test.completeSshTunnelRequestForTest(2, { status: 'ok', tunnel: { ...snapshot, status: 'stopped', revision: 3 } }); + update({ ...snapshot, status: 'listening', revision: 2 }); + const stopped = row.querySelector('button').disabled && row.innerText.includes('Stopped'); + document.getElementById('ssh-tunnel-form').requestSubmit(); + dialog.close(); + test.completeSshTunnelRequestForTest(3, { status: 'ok', tunnel: { ...snapshot, tunnel_id: 'ssht_late' } }); + const lateIgnored = !dialog.querySelector('[data-tunnel-id="ssht_late"]'); + button.click(); + test.completeSshTunnelRequestForTest(4, { status: 'ok', tunnels: [{ ...snapshot, status: 'listening' }], connection_id: 'connection-2' }); + document.getElementById('ssh-tunnels-refresh').click(); + test.completeSshTunnelRequestForTest(5, { status: 'ok', tunnels: [], connection_id: 'connection-3' }); + const changedConnection = document.getElementById('ssh-tunnel-start').disabled + && document.querySelector('.ssh-tunnel-item button').disabled + && document.querySelector('.ssh-tunnel-details').textContent.includes('unavailable'); + return { hiddenForLocal, visibleForSsh, labels, start, stop, running, safeName, foreignIgnored, stopped, lateIgnored, changedConnection }; + }""") + assert result['hiddenForLocal'] and result['visibleForSsh'] + assert result['labels']['listen'].startswith('Remote listening port') + assert 'Core host' in result['labels']['target'] + assert 'actual listening interfaces' in result['labels']['bind'] + assert 'reload' in result['labels']['lifetime'] and 'WSL' in result['labels']['lifetime'] + assert result['start']['spec'] == { + 'direction': 'remote', 'listen_port': 0, 'target_host': '127.0.0.1', 'target_port': 8080, + 'name': '', + } + assert result['start']['operation'] == 'start' and result['start']['terminal_id'] == 'main' + assert result['stop']['operation'] == 'stop' and result['stop']['tunnel_id'] == 'ssht_one' + assert 'SSH remote 127.0.0.1:50001' in result['running'] and 'Sent 4.00 KiB' in result['running'], result['running'] + assert all(result[key] for key in ('safeName', 'foreignIgnored', 'stopped', 'lateIgnored', 'changedConnection')) + finally: + fixture.close_context(context) + + +if __name__ == '__main__': + server = None + try: + server, access_url = fixture.start_server() + with fixture.load_playwright()[0]() as playwright: + browser = playwright.chromium.launch(headless=True) + try: + test_tunnel_controls(browser, access_url) + print('SSH tunnel browser controls: PASS') + finally: + browser.close() + finally: + if server: + fixture.stop_server(server) diff --git a/tests/ssh_tunnels_smoke.py b/tests/ssh_tunnels_smoke.py new file mode 100644 index 0000000..e356a80 --- /dev/null +++ b/tests/ssh_tunnels_smoke.py @@ -0,0 +1,410 @@ +"""Exercise user TCP tunnels and the Agent preset on real SSH transports.""" +import contextlib +import shutil +import socket +import sys +import threading +import time +import unittest +from pathlib import Path +from unittest.mock import patch + +sys.path.insert(0, str(Path(__file__).resolve().parent)) +import agent_tunnel_smoke as agent_fixture +import ssh_tunnels +from ssh_forwarding import forwarding_for +from ssh_tunnels import parse_tunnel_spec + +fixture = agent_fixture.fixture +ssh_server = agent_fixture.ssh_server +standterm = fixture.standterm + + +@contextlib.contextmanager +def tcp_server(handler): + listener = socket.socket() + listener.bind(('127.0.0.1', 0)) + listener.listen(8) + listener.settimeout(0.1) + closed = threading.Event() + errors, peers, workers = [], [], [] + + def serve_peer(peer): + try: + peer.settimeout(10) + handler(peer) + except (OSError, AssertionError) as exc: + if not closed.is_set(): + errors.append(exc) + finally: + peer.close() + + def accept(): + while not closed.is_set(): + try: + peer, _ = listener.accept() + except socket.timeout: + continue + except OSError: + return + peers.append(peer) + worker = threading.Thread(target=serve_peer, args=(peer,), daemon=True) + workers.append(worker) + worker.start() + + thread = threading.Thread(target=accept, daemon=True) + thread.start() + try: + yield listener.getsockname()[1] + assert not errors, errors + finally: + closed.set() + listener.close() + for peer in peers: + peer.close() + thread.join(2) + for worker in workers: + worker.join(2) + + +def receive_all(peer): + chunks = [] + while True: + chunk = peer.recv(65536) + if not chunk: + return b''.join(chunks) + chunks.append(chunk) + + +class UserTunnelTests(unittest.TestCase): + def setUp(self): + fixture.reset_state() + with standterm.user_ssh_tunnels_lock: + previous = list(standterm.user_ssh_tunnels.values()) + standterm.user_ssh_tunnels.clear() + for record in previous: + record['tunnel'].stop() + self.flask_client = fixture.make_flask_client() + self.client = fixture.make_socket_client(self.flask_client) + self.session = fixture.current_session_token() + self.sid = fixture.current_sid_for_session(self.session) + self.tunnels = [] + + def tearDown(self): + for tunnel in self.tunnels: + tunnel.stop() + tunnel.setup_done.wait(5) + if self.client.is_connected(): + self.client.disconnect() + + def carrier(self, ssh): + bridge = fixture.make_sftp_test_bridge(self.session, 'carrier') + bridge.ssh = ssh + bridge.attach(self.sid) + standterm.set_bridge(self.session, 'carrier', bridge) + return bridge + + def start(self, ssh, direction, port, **changes): + if not standterm.get_bridge(self.session, 'carrier'): + self.carrier(ssh) + spec = {'direction': direction, 'listen_port': 0, 'target_host': '127.0.0.1', 'target_port': port, **changes} + result = self.client.emit('ssh_tunnel', {'terminal_id': 'carrier', 'operation': 'start', 'spec': spec}, callback=True) + self.assertEqual(result['status'], 'ok', result) + tunnel = standterm.user_ssh_tunnels[result['tunnel']['tunnel_id']]['tunnel'] + self.tunnels.append(tunnel) + fixture.wait_until(lambda: tunnel.status != 'starting', 'Tunnel did not settle', timeout=10) + self.assertEqual(tunnel.status, 'listening', tunnel.snapshot()) + return tunnel + + def test_half_close_keeps_large_response_in_both_directions(self): + payload = bytes(range(256)) * 16384 + + def reply(peer): + self.assertEqual(receive_all(peer), b'request') + time.sleep(0.05) + peer.sendall(payload) + peer.shutdown(socket.SHUT_WR) + + with ssh_server(forwarding='yes') as ssh, tcp_server(reply) as port: + for direction in ('local', 'remote'): + tunnel = self.start(ssh, direction, port) + with socket.create_connection(('127.0.0.1', tunnel.port), timeout=10) as client: + client.sendall(b'request') + client.shutdown(socket.SHUT_WR) + self.assertEqual(receive_all(client), payload) + fixture.wait_until(lambda: not tunnel.channels, 'Completed channels were retained') + self.assertEqual(tunnel.bytes_sent, len(b'request')) + self.assertEqual(tunnel.bytes_received, len(payload)) + + def test_simultaneous_large_duplex_transfer(self): + payload = b'x' * (8 * 1024 * 1024) + + def exchange(peer): + peer.sendall(payload) + peer.shutdown(socket.SHUT_WR) + self.assertEqual(receive_all(peer), payload) + + with ssh_server(forwarding='yes') as ssh, tcp_server(exchange) as port: + for direction in ('local', 'remote'): + tunnel = self.start(ssh, direction, port) + with socket.create_connection(('127.0.0.1', tunnel.port), timeout=10) as client: + def send(): + client.sendall(payload) + client.shutdown(socket.SHUT_WR) + writer = threading.Thread(target=send, daemon=True) + writer.start() + self.assertEqual(receive_all(client), payload) + writer.join(10) + self.assertFalse(writer.is_alive()) + fixture.wait_until(lambda: not tunnel.channels, 'Duplex channels were retained') + + def test_agent_and_two_remote_tunnels_stop_independently(self): + self.client.disconnect() + helper = agent_fixture.AgentTunnelTests() + helper.setUp() + try: + with ssh_server(forwarding='yes') as ssh, tcp_server(lambda peer: peer.sendall(b'alive')) as port: + agent = helper.open_tunnel(ssh) + self.session, self.sid, self.client = helper.session, helper.sid, helper.client + first = self.start(ssh, 'remote', port) + second = self.start(ssh, 'remote', port) + self.assertEqual(helper.command(agent, 'main', 'hello')['status'], 'ok') + first.stop() + self.assertEqual(helper.command(agent, 'main', 'hello')['status'], 'ok') + with socket.create_connection(('127.0.0.1', second.port), timeout=5) as client: + self.assertEqual(client.recv(16), b'alive') + agent.close() + self.assertTrue(agent._cleanup_done.wait(5)) + with socket.create_connection(('127.0.0.1', second.port), timeout=5) as client: + self.assertEqual(client.recv(16), b'alive') + second.stop() + self.assertTrue(ssh.get_transport().is_authenticated()) + finally: + helper.tearDown() + + def test_user_remote_tunnel_reaches_core_api_without_granting_access(self): + from werkzeug.serving import make_server + self.client.disconnect() + helper = agent_fixture.AgentTunnelTests() + helper.setUp() + server = make_server('127.0.0.1', 0, standterm.app, threaded=True) + worker = threading.Thread(target=server.serve_forever, daemon=True) + worker.start() + try: + self.session, self.sid, self.client = helper.session, helper.sid, helper.client + token, _ = helper.mint() + with ssh_server() as ssh: + tunnel = self.start(ssh, 'remote', server.server_port) + url = f'http://127.0.0.1:{tunnel.port}/agent/external/command' + status, result = agent_fixture.request_json(url, {'op': 'hello', 'terminal_id': 'main'}) + self.assertNotEqual(result.get('status'), 'ok') + status, result = agent_fixture.request_json(url, {'op': 'hello', 'terminal_id': 'main', 'token': token}) + self.assertEqual(status, 200) + self.assertEqual(result['status'], 'ok') + helper.client.emit(standterm.AGENT_EVENT_MODE_SET, {'terminal_id': 'main', 'mode': 'disabled'}) + _, result = agent_fixture.request_json(url, {'op': 'hello', 'terminal_id': 'main', 'token': token}) + self.assertNotEqual(result.get('status'), 'ok') + finally: + for tunnel in self.tunnels: + tunnel.stop() + server.shutdown() + server.server_close() + worker.join(5) + helper.tearDown() + + def test_pending_remote_setup_cannot_revive_after_disconnect(self): + with ssh_server() as ssh: + self.carrier(ssh) + entered, release = threading.Event(), threading.Event() + original = ssh.get_transport().request_port_forward + + def delayed(*args, **kwargs): + entered.set() + self.assertTrue(release.wait(5)) + return original(*args, **kwargs) + + with patch.object(ssh.get_transport(), 'request_port_forward', delayed), \ + patch.object(ssh_tunnels, 'USER_TUNNEL_SETUP_TIMEOUT', 0.1): + result = self.client.emit('ssh_tunnel', {'terminal_id': 'carrier', 'operation': 'start', 'spec': { + 'direction': 'remote', 'listen_port': 0, 'target_host': '127.0.0.1', 'target_port': 80, + }}, callback=True) + tunnel = standterm.user_ssh_tunnels[result['tunnel']['tunnel_id']]['tunnel'] + self.tunnels.append(tunnel) + try: + self.assertTrue(entered.wait(2)) + fixture.wait_until(lambda: tunnel.status == 'failed', 'Setup did not time out') + with self.assertRaisesRegex(RuntimeError, 'pending'): + forwarding_for(ssh.get_transport()).request_remote(0, object(), lambda *_: None, lambda: False) + self.client.disconnect() + finally: + release.set() + self.assertTrue(tunnel.setup_done.wait(5)) + self.assertNotIn(tunnel.port, forwarding_for(ssh.get_transport()).routes) + self.assertTrue(ssh.get_transport().is_authenticated()) + + def test_viewer_ownership_and_disconnect_stop_active_tunnels(self): + with ssh_server(forwarding='yes') as ssh, tcp_server(lambda peer: receive_all(peer)) as port: + tunnel = self.start(ssh, 'local', port) + other = fixture.make_socket_client(self.flask_client) + try: + status = other.emit('ssh_tunnel', {'terminal_id': 'carrier', 'operation': 'status'}, callback=True) + self.assertEqual(status['status'], 'ok') + self.assertEqual(status['tunnels'], []) + denied = other.emit('ssh_tunnel', {'terminal_id': 'carrier', 'operation': 'stop', 'tunnel_id': tunnel.id}, callback=True) + self.assertEqual(denied['status'], 'failed') + self.assertFalse(tunnel.closed.is_set()) + self.client.disconnect() + self.assertTrue(tunnel.closed.is_set()) + with self.assertRaises(OSError): + socket.create_connection(('127.0.0.1', tunnel.port), timeout=1) + finally: + other.disconnect() + + def test_non_loopback_remote_peer_is_rejected(self): + with ssh_server(gateway_ports='yes') as ssh, tcp_server(lambda peer: peer.sendall(b'ok')) as port: + tunnel = self.start(ssh, 'remote', port) + fake = type('Channel', (), {'closed': False, 'close': lambda channel: setattr(channel, 'closed', True)})() + tunnel._accept_remote(fake, ('192.0.2.10', 1234), ('127.0.0.1', tunnel.port)) + self.assertTrue(fake.closed) + with socket.create_connection(('127.0.0.1', tunnel.port), timeout=5) as client: + self.assertEqual(client.recv(16), b'ok') + + def test_pending_owner_disconnect_and_ssh_close_prevent_setup(self): + spec = {'direction': 'local', 'listen_port': 0, 'target_host': '127.0.0.1', 'target_port': 80} + with ssh_server(forwarding='yes') as ssh: + bridge = self.carrier(ssh) + with patch.object(standterm.socketio, 'start_background_task'): + pending = [] + for _ in range(2): + result = self.client.emit('ssh_tunnel', { + 'terminal_id': 'carrier', 'operation': 'start', 'spec': spec, + }, callback=True) + tunnel = standterm.user_ssh_tunnels[result['tunnel']['tunnel_id']]['tunnel'] + pending.append(tunnel) + self.tunnels.append(tunnel) + stopped = self.client.emit('ssh_tunnel', { + 'terminal_id': 'carrier', 'operation': 'stop', 'tunnel_id': pending[0].id, + }, callback=True) + self.assertEqual(stopped['tunnel']['status'], 'stopped') + self.client.disconnect() + for tunnel in pending: + tunnel.start() + self.assertTrue(tunnel.closed.is_set()) + self.assertTrue(tunnel.setup_done.is_set()) + self.assertIsNone(tunnel.listener) + self.assertTrue(ssh.get_transport().is_authenticated()) + # SSH closure also fences an owner whose browser is still connected. + self.client = fixture.make_socket_client(self.flask_client) + self.sid = fixture.current_sid_for_session(self.session) + bridge.attach(self.sid) + with patch.object(standterm.socketio, 'start_background_task'): + result = self.client.emit('ssh_tunnel', { + 'terminal_id': 'carrier', 'operation': 'start', 'spec': spec, + }, callback=True) + tunnel = standterm.user_ssh_tunnels[result['tunnel']['tunnel_id']]['tunnel'] + self.tunnels.append(tunnel) + standterm.close_bridge(bridge) + tunnel.start() + self.assertTrue(tunnel.closed.is_set()) + self.assertIsNone(tunnel.listener) + + def test_slow_remote_target_does_not_block_other_forward_or_ssh(self): + entered, release = threading.Event(), threading.Event() + original_connect = socket.create_connection + blocked_host = 'blocked-target.invalid' + + def delayed_connect(address, *args, **kwargs): + if address[0] == blocked_host: + entered.set() + release.wait(5) + raise OSError('Simulated slow target') + return original_connect(address, *args, **kwargs) + + with ssh_server(forwarding='yes') as ssh, tcp_server(lambda peer: peer.sendall(b'ok')) as port: + slow = self.start(ssh, 'remote', port, target_host=blocked_host) + healthy = self.start(ssh, 'remote', port) + with patch.object(ssh_tunnels.socket, 'create_connection', delayed_connect): + with original_connect(('127.0.0.1', slow.port), timeout=5): + try: + self.assertTrue(entered.wait(2)) + with original_connect(('127.0.0.1', healthy.port), timeout=2) as client: + self.assertEqual(client.recv(16), b'ok') + _, stdout, _ = ssh.exec_command('printf tunnel-shell-alive', timeout=2) + self.assertEqual(stdout.read(), b'tunnel-shell-alive') + slow.stop() + finally: + release.set() + fixture.wait_until(lambda: not slow.channels, 'Stopped slow target retained its slot') + + @unittest.skipUnless(shutil.which('sshd') or Path('/usr/sbin/sshd').is_file(), 'OpenSSH server is required') + def test_both_directions_use_final_transport_through_three_jumps(self): + from ssh_jump_smoke import SSHJumpTests, server + helper = SSHJumpTests() + helper.setUp() + try: + servers = [helper.stack.enter_context(server()) for _ in range(4)] + route = helper.route(servers) + helper.trust(route, servers) + bridge = helper.bridge(servers) + bridge.owner_session, bridge.terminal_id = self.session, 'carrier' + success, result = helper.connect(bridge, route) + self.assertTrue(success, result) + bridge.attach(self.sid) + standterm.set_bridge(self.session, 'carrier', bridge) + with tcp_server(lambda peer: peer.sendall(b'final-target')) as port: + for direction in ('local', 'remote'): + tunnel = self.start(bridge.ssh, direction, port) + self.assertIs(tunnel.transport, bridge.ssh.get_transport()) + with socket.create_connection(('127.0.0.1', tunnel.port), timeout=5) as client: + self.assertEqual(client.recv(32), b'final-target') + standterm.close_bridge(bridge) + self.assertTrue(all(tunnel.closed.is_set() for tunnel in self.tunnels)) + finally: + helper.doCleanups() + + def test_busy_listening_port_and_server_denial_leave_no_forward(self): + with ssh_server(forwarding='no') as ssh, tcp_server(lambda peer: None) as port: + self.carrier(ssh) + for direction, listen_port in (('local', port), ('remote', 0)): + result = self.client.emit('ssh_tunnel', { + 'terminal_id': 'carrier', 'operation': 'start', 'spec': { + 'direction': direction, 'listen_port': listen_port, + 'target_host': '127.0.0.1', 'target_port': port, + }, + }, callback=True) + tunnel = standterm.user_ssh_tunnels[result['tunnel']['tunnel_id']]['tunnel'] + self.tunnels.append(tunnel) + fixture.wait_until(lambda: tunnel.setup_done.is_set(), 'Rejected setup did not finish') + self.assertEqual(tunnel.status, 'failed') + self.assertTrue(tunnel.closed.is_set()) + self.assertFalse(tunnel.forwarding.routes) + self.assertTrue(ssh.get_transport().is_authenticated()) + + def test_invalid_specs_and_agent_commands_do_not_create_tunnels(self): + base = {'direction': 'local', 'listen_port': 0, 'target_host': '127.0.0.1', 'target_port': 80} + for change in ({'direction': 'socks'}, {'direction': []}, {'listen_port': True}, {'target_port': 0}, + {'listen_port': 65536}, {'target_host': 'http://example.com'}, {'target_host': 'a\nb'}): + with self.assertRaises(ValueError): + parse_tunnel_spec({**base, **change}) + self.assertEqual(parse_tunnel_spec({**base, 'target_host': '::1'})['target_host'], '::1') + self.client.disconnect() + helper = agent_fixture.AgentTunnelTests() + helper.setUp() + try: + token, _ = helper.mint() + hello = standterm.app.test_client().post('/agent/external/command', json={ + 'op': 'hello', 'token': token, 'terminal_id': 'main', + }, environ_base={'REMOTE_ADDR': '127.0.0.1'}) + self.assertEqual(hello.json['status'], 'ok') + for operation in ('ssh_tunnel', 'ssh-tunnel', 'tunnel-start', 'tunnel-stop'): + response = standterm.app.test_client().post('/agent/external/command', json={ + 'op': operation, 'token': token, 'terminal_id': 'main', 'spec': base, + }, environ_base={'REMOTE_ADDR': '127.0.0.1'}) + self.assertNotEqual(response.json.get('status'), 'ok') + self.assertFalse(standterm.user_ssh_tunnels) + finally: + helper.tearDown() + + +if __name__ == '__main__': + unittest.main() From f4c32c35721e44550e6ed86657e570915870b246 Mon Sep 17 00:00:00 2001 From: "ASKA C." Date: Wed, 16 Sep 2026 06:45:05 +0800 Subject: [PATCH 06/43] Fix SSH session editing and add browser popout fallback ## Why Settings retains a previously selected SSH profile across connections and only exposes the final route target. Browsers without Document PiP cannot open a floating terminal or Files window. ## What changed - Bind new-session drafts to structured metadata from the current tab. - Separate name and order management from atomic full-route editing. - Reject changed connection preparation before saving or connecting. - Reuse an ordinary popup when Document PiP is unavailable, restoring the terminal on close and handling browser navigation cleanup restrictions. ## Testing Headless checks, SSH profile/key and route regressions, native PiP regressions, and real Chromium/WebKit popup input, Files and restore checks pass. Linux WebKit may require native close after reloading an opaque-origin blank popup; macOS Safari still needs manual qualification. --- README.md | 21 +- app.py | 2 + scripts/run_smoke_tests.py | 2 + static/js/standterm-ssh-route-editor.js | 32 +- templates/index.html | 510 ++++++++++----------- terminal_backends/ssh.py | 6 + tests/agent_backend_smoke.py | 18 + tests/agent_browser_smoke.py | 51 +-- tests/browser_popout_smoke.py | 182 ++++++++ tests/ssh_profile_context_browser_smoke.py | 218 +++++++++ tests/ssh_routes_browser_smoke.py | 1 - 11 files changed, 730 insertions(+), 313 deletions(-) create mode 100644 tests/browser_popout_smoke.py create mode 100644 tests/ssh_profile_context_browser_smoke.py diff --git a/README.md b/README.md index adfb05a..ff0e452 100644 --- a/README.md +++ b/README.md @@ -323,7 +323,14 @@ STANDTERM_HOST=127.0.0.1 STANDTERM_PORT=5000 ./run.sh Quick Connect can load saved SSH profiles and the six most recent successful SSH targets. Use **Settings > SSH Sessions** to create, update, reorder, or -delete profiles and to clear history. Profiles and history stay in the current +delete profiles and to clear history. The first level shows names, ordering, and +full route summaries. **Save name** only renames the selected entry. **Edit +connection…** opens every node in a separate editor, including host identity and +**Use key**; Direct uses the same editor with one node. Its **Save route** button +saves all nodes and referenced keys immediately for the next connection. **New +session…** starts from the current SSH tab's target, or Quick Connect when no SSH +tab is connected. Switching tabs or connections clears the previous edit selection. +Profiles and history stay in the current browser and never store passwords. In Direct connect, **Save session** saves the profile and its referenced browser key when **Connect** is pressed, before SSH starts. A failed connection does not undo that explicit save. History records @@ -379,7 +386,8 @@ Ed25519 key. Its public key can be copied immediately to `authorized_keys`. A compatible saved key does not prove that the remote account has installed it; server fingerprints are still checked separately during SSH login. -The editor ends with **Save route**, **Cancel**, and **Done**. Done retains only +The connection-preparation editor ends with a **Save route** checkbox, **Cancel**, +and **Done**. Done retains only the connection draft. **Connect** saves the route and referenced temporary keys together only when Save route is selected; otherwise they remain temporary. Cancelling an editor discards changes made since opening it. Temporary private @@ -479,9 +487,12 @@ returned with the connection failure. For a connected SSH or supported Local Shell tab, use the folder button in the status bar, the terminal context menu, or the folder button in Terminal -Picture-in-Picture. StandTerm opens a compact Files window in -Picture-in-Picture. When opened from a terminal PiP, the terminal first returns -to its tab so the single Document PiP window can switch cleanly to Files. +Picture-in-Picture. StandTerm uses Document Picture-in-Picture when available, +or a separate browser popup for terminals and Files when it is unavailable +(including Safari). Allow popups for StandTerm. The fallback action is labeled +**Pop out terminal**; an ordinary browser popup is not guaranteed to stay on top. +When opened from a floating terminal, the terminal returns to its tab before the +window switches to Files. Closing the floating window restores the terminal. Files browses one directory at a time and supports manual path navigation, drag-and-drop upload, explicit download, rename, and permanent deletion. diff --git a/app.py b/app.py index 90f8f42..ece18c7 100644 --- a/app.py +++ b/app.py @@ -6052,6 +6052,8 @@ def build_terminal_list(session_token, sid=None): 'buffered_events': len(bridge.replay_buffer), 'files_available': bool(bridge.files_available()), } + if isinstance(bridge, SSHBridge): + terminal_info['ssh_target'] = bridge.metadata().get('ssh_target') terminals.append(terminal_info) return terminals diff --git a/scripts/run_smoke_tests.py b/scripts/run_smoke_tests.py index 69a553a..c291652 100644 --- a/scripts/run_smoke_tests.py +++ b/scripts/run_smoke_tests.py @@ -32,6 +32,8 @@ 'tests/ssh_node_credentials_smoke.py', 'tests/ssh_tunnels_smoke.py', 'tests/ssh_tunnels_browser_smoke.py', + 'tests/ssh_profile_context_browser_smoke.py', + 'tests/browser_popout_smoke.py', 'tests/ime_anchor_browser_smoke.py', ] diff --git a/static/js/standterm-ssh-route-editor.js b/static/js/standterm-ssh-route-editor.js index 56a9af4..d83bfe0 100644 --- a/static/js/standterm-ssh-route-editor.js +++ b/static/js/standterm-ssh-route-editor.js @@ -3,18 +3,20 @@ const routes = window.StandTermSshRoutes; routes.edit = function({ state, entryId, target, onDone, keys = [], temporaryKeys = [], saveRoute = false, keyAllowed = false, - createKey, copyPublicKey, hostIdentity }) { + createKey, copyPublicKey, hostIdentity, mode = 'prepare', entryName = '' }) { const original = routes.clone(state); const savedKeys = [...keys]; const newKeys = new Map(temporaryKeys.map(record => [record.keyId, record])); const editorId = routes.id(); let pendingGenerations = 0; + let saving = false; + const managed = mode === 'manage'; let draft = routes.clone(state); let entry = [...draft.profiles, ...draft.history].find(item => item.id === entryId); if (!entry) { const node = { id: routes.id(), endpoint: { host: target.host || '', port: target.port || '22', username: target.username || '' }, nextNodeId: null, hostKeyAlias: '', authentication: { method: 'password' } }; - entry = { id: routes.id(), name: '', startNodeId: node.id, + entry = { id: routes.id(), name: entryName, startNodeId: node.id, sortOrder: draft.profiles.length, keyId: null, keyTarget: null }; draft.nodes.push(node); draft.profiles.push(entry); @@ -46,6 +48,7 @@ element.type = 'button'; element.textContent = label; element.onclick = () => { + if (saving) return; try { Promise.resolve(callback()).catch(fail); } catch (err) { fail(err); } }; parent.append(element); @@ -60,7 +63,7 @@ function path() { return routes.resolve(draft, entry.startNodeId).path; } function updateSaveState() { - doneButton.disabled = pendingGenerations > 0; + doneButton.disabled = saving || pendingGenerations > 0; } // Keep incomplete values inside the modal. Done validates the completed draft. @@ -386,9 +389,9 @@ saveRouteLabel.className = 'ssh-key-toggle'; saveRouteLabel.append(saveRouteInput, document.createTextNode('Save route on Connect')); saveRouteInput.setAttribute('aria-label', 'Save route'); - actions.append(saveRouteLabel); + if (!managed) actions.append(saveRouteLabel); button('Cancel', () => dialog.close()); - const doneButton = button('Done', () => { + const doneButton = button(managed ? 'Save route' : 'Done', async () => { if (pendingGenerations) return; flush(); if (offerRepair()) return; @@ -418,8 +421,18 @@ discardUnusedDraftNodes(); routes.validate(draft); const used = new Set(draft.nodes.map(node => node.authentication.keyRef?.keyId)); - onDone(draft, entry.id, [...newKeys.values()].filter(record => used.has(record.keyId)), saveRouteInput.checked); - dialog.close(); + saving = true; + updateSaveState(); + rows.inert = heading.inert = advanced.inert = true; + status.textContent = managed ? 'Saving route…' : ''; + try { + await onDone(draft, entry.id, [...newKeys.values()].filter(record => used.has(record.keyId)), saveRouteInput.checked); + dialog.close(); + } finally { + saving = false; + rows.inert = heading.inert = advanced.inert = false; + updateSaveState(); + } }); doneButton.className = 'primary'; const heading = document.createElement('div'); @@ -442,8 +455,11 @@ } scope.onchange = updateScopeNotice; const help = document.createElement('p'); - help.textContent = `Connect from Core through the cards, top to bottom. The last card is the Target. Drag the handle or use ↑ / ↓. Up to ${routes.MAX_JUMPS} jumps. Done returns to connection settings. Connect saves only when Save route is selected.`; + help.textContent = `Connect from Core through the cards, top to bottom. The last card is the Target. Drag the handle or use ↑ / ↓. Up to ${routes.MAX_JUMPS} jumps. ${managed + ? 'Save route stores all nodes and selected keys. Changes apply to the next connection.' + : 'Done returns to connection settings. Connect saves only when Save route is selected.'}`; dialog.append(title, help, heading, advanced, scopeNotice, rows, preview, status, actions); + dialog.addEventListener('cancel', event => { if (saving) event.preventDefault(); }); dialog.addEventListener('close', () => dialog.remove()); document.body.append(dialog); render(); diff --git a/templates/index.html b/templates/index.html index 9ecbabc..648d6fe 100644 --- a/templates/index.html +++ b/templates/index.html @@ -392,6 +392,7 @@ .ssh-profile-list button.active { color: #64a9ff; } .ssh-profile-list-name { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .ssh-profile-list-target { display: block; margin-top: 3px; color: #777; font-size: 11px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } + .ssh-profile-route-summary { color: #aaa; font-size: 12px; overflow-wrap: anywhere; line-height: 1.6; } .ssh-profile-editor { display: grid; align-content: start; gap: 9px; } .ssh-profile-editor label { display: grid; gap: 4px; color: #aaa; font-size: 11px; } .ssh-profile-editor input { width: 100%; box-sizing: border-box; padding: 7px 8px; background: #2c2c2e; border: 1px solid #555; border-radius: 4px; color: #fff; outline: none; } @@ -460,12 +461,6 @@ .ssh-route-card-toggle::after { margin-left: auto; } .ssh-route-actions { bottom: -12px; } } - .ssh-key-controls { display: grid; gap: 8px; padding: 9px; border: 1px solid #3a3a3c; border-radius: 6px; background: #171717; } - .ssh-key-controls > label { display: flex; grid-template-columns: none; align-items: center; gap: 8px; color: #ddd; } - .ssh-key-controls input[type="checkbox"] { width: auto; } - .ssh-key-public { min-height: 62px; width: 100%; box-sizing: border-box; resize: vertical; padding: 7px 8px; background: #111; border: 1px solid #444; border-radius: 4px; color: #aaa; font: 11px/1.4 monospace; } - .ssh-key-public-actions { display: flex; gap: 8px; align-items: center; } - .ssh-key-public-actions button { padding: 6px 9px; border: none; border-radius: 4px; background: #3a3a3c; color: #fff; cursor: pointer; } .settings-transfer-actions { display: flex; gap: 8px; flex-wrap: wrap; } .settings-transfer-actions button { padding: 7px 10px; border: none; border-radius: 4px; background: #3a3a3c; color: #fff; cursor: pointer; } .diagnostics-actions { display: flex; justify-content: flex-end; gap: 8px; margin-bottom: 10px; } @@ -1059,21 +1054,14 @@

Settings

- - - -
- -
No browser key is linked.
- - +
+
+
-
Create always saves a new profile. Save updates only the loaded profile.
+
Select an entry to rename or reorder it. Edit connection opens all nodes and their key settings.
- - + +
@@ -2169,14 +2157,8 @@

Recover StandTerm session

const sshProfileStatus = document.getElementById('ssh-profile-status'); const sshProfileList = document.getElementById('ssh-profile-list'); const sshProfileNameInput = document.getElementById('ssh-profile-name'); - const sshProfileHostInput = document.getElementById('ssh-profile-host'); - const sshProfilePortInput = document.getElementById('ssh-profile-port'); - const sshProfileUsernameInput = document.getElementById('ssh-profile-username'); - const sshProfileKeyEnabledInput = document.getElementById('ssh-profile-key-enabled'); - const sshProfileKeyStatus = document.getElementById('ssh-profile-key-status'); - const sshProfileKeyPublic = document.getElementById('ssh-profile-key-public'); - const sshProfileKeyActions = document.getElementById('ssh-profile-key-actions'); - const sshProfileKeyCopyBtn = document.getElementById('ssh-profile-key-copy'); + const sshProfileRouteSummary = document.getElementById('ssh-profile-route-summary'); + const sshProfileEditRouteBtn = document.getElementById('ssh-profile-edit-route'); const sshProfileCreateBtn = document.getElementById('ssh-profile-create'); const sshProfileSaveBtn = document.getElementById('ssh-profile-save'); const sshProfileDeleteBtn = document.getElementById('ssh-profile-delete'); @@ -2339,7 +2321,8 @@

Recover StandTerm session

const pendingSshHistory = []; let savingSshHistory = false; let editingSshProfileId = null; - let editingSshKeyRecord = null; + let sshProfileEditorContext = null; + let sshProfileEditorVersion = 0; let quickConnectSshKeyRecord = null; let quickConnectKeyLoadVersion = 0; const handledSshSignRequestIds = new Set(); @@ -4735,19 +4718,30 @@

Recover StandTerm session

} async function prepareSshConnection(formData, interactiveLogin = false, commit = false) { + const formRevision = sshConnectionFormRevision; + const terminal = getActiveTerminalState(); + const fromRoute = sshPreparationMode === 'route'; + const routeDraft = fromRoute ? sshRouteDraft : null; + const selectedId = fromRoute ? selectedSshRouteEntry : selectedSshPickerEntry?.id; + const saveSession = fromRoute ? !!routeDraft?.saveRoute : sshSaveSessionInput.checked; + const temporaryKeys = fromRoute ? routeDraft?.temporaryKeys || [] : [...directTemporarySshKeys.values()]; + const assertCurrent = () => { + if (formRevision !== sshConnectionFormRevision || terminal !== getActiveTerminalState() + || (!fromRoute && saveSession !== sshSaveSessionInput.checked)) { + throw new Error('SSH connection settings changed. Review them and connect again.'); + } + }; await sshSessionReady; await sshSessionWriteQueue.catch(() => {}); const snapshot = await SshRoutes.load(openSshSessionsDb, normalizeLegacySshSessionState, true); - const fromRoute = sshPreparationMode === 'route'; - const routeDraft = fromRoute ? sshRouteDraft : null; + assertCurrent(); const source = routeDraft?.state || snapshot.state; - const selected = fromRoute ? currentSshRouteEntry(source) : currentSshEntry(source); + const selected = [...source.profiles, ...source.history].find(entry => entry.id === selectedId); if (fromRoute && !selected) throw new Error('Choose a saved route before connecting.'); - if (!fromRoute && selectedSshPickerEntry && !selected) throw new Error('The SSH entry was removed. Reload the settings.'); + if (!fromRoute && selectedId && !selected) throw new Error('The SSH entry was removed. Reload the settings.'); if (snapshot.state.revision !== sshSessionState.revision || source.revision !== snapshot.state.revision) { throw new Error('SSH settings changed in another window. Reload before connecting.'); } - const temporaryKeys = fromRoute ? routeDraft?.temporaryKeys || [] : [...directTemporarySshKeys.values()]; const records = [...snapshot.keys, ...temporaryKeys]; const path = fromRoute ? SshRoutes.clone(SshRoutes.checkedPath(source, selected)) : [{ id: SshRoutes.id(), endpoint: SshRoutes.endpoint(formData), nextNodeId: null, @@ -4774,6 +4768,7 @@

Recover StandTerm session

if (!isBrowserSshKeyAllowedByPolicy()) throw new Error('Browser SSH keys require localhost or authorized HTTPS.'); const record = await validateBrowserSshKeyRecord(records.find(key => key.keyId === ref.keyId), owner, ref.kind === 'credential' ? ref : null); + assertCurrent(); const identity = ref.kind === 'credential' ? { credential_id: ref.keyId } : { profile_id: owner.id }; Object.assign(payload, { password: '', use_browser_key: true, ...identity, key_id: record.keyId, browser_public_key: record.publicKeyRawB64 }); @@ -4784,7 +4779,8 @@

Recover StandTerm session

} let revision = snapshot.state.revision; let localKeys = temporaryKeys.filter(record => signers.some(signer => signer.keyId === record.keyId)); - if (commit && (fromRoute ? routeDraft?.saveRoute : sshSaveSessionInput.checked)) { + assertCurrent(); + if (commit && saveSession) { const nextState = SshRoutes.clone(source); let entry = selected; if (!fromRoute) { @@ -4806,6 +4802,7 @@

Recover StandTerm session

const operation = SshRoutes.save(openSshSessionsDb, nextState, keyChanges); sshSessionWriteQueue = operation; sshSessionState = await operation; + assertCurrent(); revision = sshSessionState.revision; localKeys = []; if (fromRoute) { @@ -4985,84 +4982,88 @@

Recover StandTerm session

updateSshProfileIndicator(); } - function clearSshProfileEditor(useQuickConnectValues = false) { + function getSshProfileEditorContext() { + const state = getActiveTerminalState(); + const fromConnection = state?.connectionType === 'ssh' && (state.connected || state.connecting); + const target = fromConnection ? normalizeSshTarget(state.sshTarget || {}) : getCurrentSshTarget(); + return { target, fromConnection, key: JSON.stringify([ + state?.instanceId, fromConnection, fromConnection ? state.connectedAt : null, target + ]) }; + } + + function syncSshProfileEditorContext() { + const context = getSshProfileEditorContext(); + if (sshProfileEditorContext === context.key) return false; + sshProfileEditorContext = context.key; + clearSshProfileEditor(true); + setSshProfileStatus(context.fromConnection + ? 'New direct session from the current SSH tab. Select a profile to edit an existing entry.' + : 'New session from Quick Connect. Select a profile to edit an existing entry.'); + return true; + } + + function isCurrentSshProfileEditor(version) { + return version === sshProfileEditorVersion && sshProfileEditorContext === getSshProfileEditorContext().key; + } + + function clearSshProfileEditor(useCurrentValues = false) { + ++sshProfileEditorVersion; editingSshProfileId = null; - editingSshKeyRecord = null; - const quickTarget = useQuickConnectValues ? getCurrentSshTarget() : { host: '', port: '22', username: '' }; - sshProfileNameInput.value = useQuickConnectValues && quickTarget.host - ? normalizeSshProfileName(`${quickTarget.username}@${quickTarget.host}`) - : ''; - sshProfileHostInput.value = quickTarget.host; - sshProfilePortInput.value = quickTarget.port; - sshProfileUsernameInput.value = quickTarget.username; - sshProfileKeyEnabledInput.checked = false; - renderSshProfileKeyEditor(); + const target = useCurrentValues ? getSshProfileEditorContext().target : null; + sshProfileNameInput.value = target?.host + ? normalizeSshProfileName(`${target.username}@${target.host}`) : ''; renderSshProfileManager(); } - async function loadSshProfileEditor(profileId) { + function loadSshProfileEditor(profileId) { const profile = findSshProfile(profileId); if (!profile) return; + ++sshProfileEditorVersion; editingSshProfileId = profile.id; - editingSshKeyRecord = null; sshProfileNameInput.value = profile.name; - sshProfileHostInput.value = profile.host; - sshProfilePortInput.value = profile.port; - sshProfileUsernameInput.value = profile.username; - setSshProfileStatus(`Loaded ${profile.name}. Save will update this entry; Create will make a copy.`); - if (profile.keyId) { - try { - editingSshKeyRecord = await validateBrowserSshKeyRecord(await loadSshKeyRecord(profile.keyId), profile); - sshProfileKeyEnabledInput.checked = true; - } catch (err) { - sshProfileKeyEnabledInput.checked = false; - setSshProfileStatus(err.message || 'The linked browser SSH key is unavailable.', true); - } - } else { - sshProfileKeyEnabledInput.checked = false; - } - renderSshProfileKeyEditor(); + setSshProfileStatus(`Selected ${profile.name}. Save name only renames this entry.`); renderSshProfileManager(); } - function renderSshProfileKeyEditor() { - const enabled = sshProfileKeyEnabledInput.checked; - const record = editingSshKeyRecord; - sshProfileKeyPublic.hidden = !record; - sshProfileKeyActions.hidden = !record; - sshProfileKeyPublic.value = record ? record.publicKeyOpenSsh : ''; - if (record && enabled) { - sshProfileKeyStatus.innerText = `${record.fingerprint} · private key stays non-extractable in this browser`; - } else if (record) { - sshProfileKeyStatus.innerText = 'This key will be removed only when the loaded profile is saved.'; - } else if (enabled) { - sshProfileKeyStatus.innerText = 'Generating a non-extractable Ed25519 key...'; - } else { - sshProfileKeyStatus.innerText = 'No browser key is linked.'; - } + function describeSshProfile(profile) { + const route = SshRoutes.resolve(sshSessionState, profile.startNodeId); + const endpoints = route.path.map(node => `${node.endpoint.username}@${node.endpoint.host}:${node.endpoint.port}`); + return `Core host → ${endpoints.join(' → ')}${route.error ? ' · Route needs repair' : ''}`; } - async function toggleSshProfileKey() { - if (!sshProfileKeyEnabledInput.checked) { - renderSshProfileKeyEditor(); - return; - } - if (!editingSshKeyRecord) { - if (!isBrowserSshKeyAllowedByPolicy()) { - sshProfileKeyEnabledInput.checked = false; - renderSshProfileKeyEditor(); - throw new Error('Browser SSH keys are not allowed for this browser connection.'); - } - renderSshProfileKeyEditor(); - try { - editingSshKeyRecord = await createBrowserSshKeyRecord(editingSshProfileId || ''); - } catch (err) { - sshProfileKeyEnabledInput.checked = false; - renderSshProfileKeyEditor(); - throw err; + async function editSshProfileRoute(createNew = false) { + if (syncSshProfileEditorContext()) return; + const version = sshProfileEditorVersion; + const profileId = createNew ? null : editingSshProfileId; + if (!createNew && !profileId) return; + const target = getSshProfileEditorContext().target; + const entryName = createNew && editingSshProfileId ? '' : sshProfileNameInput.value; + const terminalId = activeTerminalId; + await sshSessionWriteQueue.catch(() => {}); + const snapshot = await SshRoutes.load(openSshSessionsDb, normalizeLegacySshSessionState, true); + if (!isCurrentSshProfileEditor(version)) return; + if (snapshot.state.revision !== sshSessionState.revision) { + throw new Error('SSH settings changed in another window. Reload before editing.'); + } + const profile = snapshot.state.profiles.find(item => item.id === profileId); + if (!createNew && !profile) throw new Error('This SSH entry was removed. Reload before editing.'); + if (profile) profile.name = entryName; + SshRoutes.edit({ state: snapshot.state, entryId: profileId, target, entryName, + mode: 'manage', keys: snapshot.keys, keyAllowed: isBrowserSshKeyAllowedByPolicy(), + createKey: createTemporarySshKey, copyPublicKey: value => copyToClipboard(value), + hostIdentity: { request: requestSshHostIdentity, terminalId }, + onDone: async (draft, entryId, temporaryKeys) => { + const operation = sshSessionWriteQueue.catch(() => {}).then(() => SshRoutes.save( + openSshSessionsDb, draft, temporaryKeys.map(record => ({ type: 'put', record })) + )); + sshSessionWriteQueue = operation; + sshSessionState = await operation; + renderSshSessionState(); + if (!isCurrentSshProfileEditor(version)) return; + loadSshProfileEditor(entryId); + setSshProfileStatus(`Saved ${findSshProfile(entryId).name}. Changes apply to the next connection.`); } - } - renderSshProfileKeyEditor(); + }); } function renderSshProfileManager() { @@ -5083,16 +5084,19 @@

Recover StandTerm session

name.innerText = profile.name; const target = document.createElement('span'); target.className = 'ssh-profile-list-target'; - target.innerText = `${profile.username}@${profile.host}:${profile.port}`; + target.innerText = describeSshProfile(profile); button.append(name, target); - button.onclick = () => { - loadSshProfileEditor(profile.id) - .catch(err => setSshProfileStatus(err.message || 'Profile could not be loaded.', true)); - }; + button.onclick = () => loadSshProfileEditor(profile.id); sshProfileList.appendChild(button); }); } const selectedIndex = sshSessionState.profiles.findIndex(profile => profile.id === editingSshProfileId); + const profile = findSshProfile(editingSshProfileId); + const target = getSshProfileEditorContext().target; + sshProfileRouteSummary.textContent = profile ? describeSshProfile(profile) + : target.host ? `New direct session: ${target.username}@${target.host}:${target.port}` + : 'Select a saved entry, or create a new session.'; + sshProfileEditRouteBtn.disabled = selectedIndex < 0; sshProfileSaveBtn.disabled = selectedIndex < 0; sshProfileDeleteBtn.disabled = selectedIndex < 0; sshProfileUpBtn.disabled = selectedIndex <= 0; @@ -5201,139 +5205,26 @@

Recover StandTerm session

finally { savingSshHistory = false; } } - function readSshProfileEditor() { - const target = normalizeSshTarget({ - host: sshProfileHostInput.value, - port: sshProfilePortInput.value, - username: sshProfileUsernameInput.value - }); - return { - name: normalizeSshProfileName(sshProfileNameInput.value), - ...target - }; - } - - function validateSshProfileEditor() { - if (sshProfileNameInput.value.trim().length > SSH_PROFILE_NAME_MAX_LENGTH) { - setSshProfileStatus(`Profile name must be ${SSH_PROFILE_NAME_MAX_LENGTH} characters or fewer.`, true); - return null; - } - const portNumber = Number.parseInt(sshProfilePortInput.value, 10); - if (!Number.isInteger(portNumber) || portNumber < 1 || portNumber > 65535) { - setSshProfileStatus('Port must be between 1 and 65535.', true); - return null; - } - const editorValue = readSshProfileEditor(); - if (!editorValue.name || !editorValue.host || !editorValue.username) { - setSshProfileStatus('Profile name, host, and username are required.', true); - return null; - } - return editorValue; - } - - async function createSshProfileEditor() { - const editorValue = validateSshProfileEditor(); - if (!editorValue) return; - const profileId = createSshSessionId('profile'); - const skippedLoadedKey = !!(editingSshKeyRecord && editingSshProfileId); - const mayAttachDraftKey = editingSshKeyRecord - && sshProfileKeyEnabledInput.checked - && !editingSshProfileId; - const keyRecord = mayAttachDraftKey - ? { ...editingSshKeyRecord, ownerProfileId: profileId, targetKey: getSshTargetKey(editorValue) } - : null; - await updateSshSessionStateWithKeyChanges(nextState => { - nextState.profiles.push({ - id: profileId, - sortOrder: nextState.profiles.length, - ...editorValue, - keyId: keyRecord ? keyRecord.keyId : null - }); - }, keyRecord ? [{ type: 'put', record: keyRecord }] : []); - editingSshProfileId = profileId; - editingSshKeyRecord = keyRecord; - sshProfileKeyEnabledInput.checked = !!keyRecord; - renderSshProfileKeyEditor(); - renderSshSessionState(); - setSshProfileStatus( - mayAttachDraftKey - ? `Created ${editorValue.name} with a browser SSH key.` - : skippedLoadedKey - ? `Created ${editorValue.name}. Browser keys from loaded profiles are not copied.` - : `Created ${editorValue.name}.` - ); - } - async function saveSshProfileEditor() { - const existingProfile = findSshProfile(editingSshProfileId); - if (!existingProfile) { - setSshProfileStatus('Load a profile before using Save, or choose Create.', true); + if (syncSshProfileEditorContext()) return; + const version = sshProfileEditorVersion; + const profileId = editingSshProfileId; + if (!findSshProfile(profileId)) return; + const name = sshProfileNameInput.value.trim(); + if (!name || name.length > SSH_PROFILE_NAME_MAX_LENGTH) { + setSshProfileStatus(`Profile name must be 1–${SSH_PROFILE_NAME_MAX_LENGTH} characters.`, true); return; } - const editorValue = validateSshProfileEditor(); - if (!editorValue) return; - const wantsKey = sshProfileKeyEnabledInput.checked; - if (wantsKey && !editingSshKeyRecord) { - setSshProfileStatus('Generate the browser SSH key before saving.', true); - return; - } - if (!wantsKey && existingProfile.keyId && !window.confirm( - `Remove the browser SSH key from ${existingProfile.name}? The private key cannot be recovered.` - )) return; - const keyTarget = existingProfile.keyTarget - && getSshTargetKey(existingProfile.keyTarget) !== getSshTargetKey(existingProfile) - ? existingProfile.keyTarget : editorValue; - const keyRecord = wantsKey - ? { - ...editingSshKeyRecord, - ownerProfileId: existingProfile.id, - targetKey: getSshTargetKey(keyTarget) - } - : null; - const keyBindingChanged = existingProfile.keyId && (!keyRecord - || existingProfile.keyId !== keyRecord.keyId - || getSshTargetKey(existingProfile.keyTarget || existingProfile) !== getSshTargetKey(keyTarget)); - if (keyBindingChanged) requireUnreferencedSshKey(existingProfile, true); - const keyChanges = []; - if (keyRecord) keyChanges.push({ type: 'put', record: keyRecord }); - if (existingProfile.keyId && (!keyRecord || existingProfile.keyId !== keyRecord.keyId)) { - const oldKeyRecord = await loadSshKeyRecord(existingProfile.keyId); - if (oldKeyRecord && oldKeyRecord.ownerProfileId === existingProfile.id) { - keyChanges.push({ type: 'delete', keyId: existingProfile.keyId }); - } - } - await updateSshSessionStateWithKeyChanges(nextState => { - const existing = nextState.profiles.find(profile => profile.id === existingProfile.id); - if (existing) { - const path = SshRoutes.checkedPath(nextState, existing); - const previousAuth = path.at(-1).authentication; - let authentication = previousAuth; - if (keyBindingChanged || (!existingProfile.keyId && keyRecord)) { - if (keyRecord && getSshTargetKey(keyTarget) === getSshTargetKey(editorValue)) { - authentication = { method: 'browser-key', keyRef: { - ownerProfileId: existing.id, keyId: keyRecord.keyId, targetKey: keyRecord.targetKey - } }; - } else if (previousAuth.keyRef?.ownerProfileId === existing.id) { - authentication = { method: 'password' }; - } - } - if (getSshTargetKey(path.at(-1).endpoint) !== getSshTargetKey(editorValue) - || JSON.stringify(authentication) !== JSON.stringify(previousAuth)) { - SshRoutes.replaceNode(nextState, existing, path.length - 1, { - ...path.at(-1), endpoint: SshRoutes.endpoint(editorValue), authentication - }); - } - Object.assign(existing, editorValue, { keyId: keyRecord ? keyRecord.keyId : null, - keyTarget: keyRecord ? SshRoutes.endpoint(keyTarget) : null }); - } - }, keyChanges); - editingSshKeyRecord = keyRecord; - renderSshProfileKeyEditor(); - renderSshSessionState(); - setSshProfileStatus(`Saved ${editorValue.name}.`); + await updateSshSessionState(nextState => { + const profile = nextState.profiles.find(item => item.id === profileId); + if (!profile) throw new Error('This SSH entry was removed. Reload before saving.'); + profile.name = name; + }); + if (isCurrentSshProfileEditor(version)) setSshProfileStatus(`Saved ${name}.`); } async function deleteEditingSshProfile() { + const version = sshProfileEditorVersion; const profile = findSshProfile(editingSshProfileId); if (!profile) return; if (profile.keyId) requireUnreferencedSshKey(profile); @@ -5351,15 +5242,16 @@

Recover StandTerm session

if (selectedSshPickerEntry && selectedSshPickerEntry.type === 'profile' && selectedSshPickerEntry.id === profile.id) { selectedSshPickerEntry = null; } - clearSshProfileEditor(false); - setSshProfileStatus(`Deleted ${profile.name}.`); + if (isCurrentSshProfileEditor(version)) { + clearSshProfileEditor(false); + setSshProfileStatus(`Deleted ${profile.name}.`); + } } - function requireUnreferencedSshKey(owner, keepEntry = false) { + function requireUnreferencedSshKey(owner) { const dependents = [...sshSessionState.profiles, ...sshSessionState.history].filter(entry => { const path = SshRoutes.resolve(sshSessionState, entry.startNodeId).path; - return path.some((node, index) => node.authentication.keyRef?.ownerProfileId === owner.id - && (entry.id !== owner.id || (keepEntry && index < path.length - 1))); + return path.some(node => node.authentication.keyRef?.ownerProfileId === owner.id && entry.id !== owner.id); }); if (dependents.length) throw new Error(`Browser key is used by: ${dependents.map(entry => entry.name || entry.host).join(', ')}. Change those routes before removing or rebinding the key.`); } @@ -5373,8 +5265,9 @@

Recover StandTerm session

} async function moveEditingSshProfile(offset) { + const profileId = editingSshProfileId; await updateSshSessionState(nextState => { - const currentIndex = nextState.profiles.findIndex(profile => profile.id === editingSshProfileId); + const currentIndex = nextState.profiles.findIndex(profile => profile.id === profileId); const nextIndex = currentIndex + offset; if (currentIndex < 0 || nextIndex < 0 || nextIndex >= nextState.profiles.length) return; const profiles = [...nextState.profiles]; @@ -6441,6 +6334,7 @@

Recover StandTerm session

label: 'Disconnected', applicationTitle: '', connectionType: null, + sshTarget: null, connected: false, connecting: false, error: false, @@ -6618,6 +6512,8 @@

Recover StandTerm session

state.connecting = false; state.error = false; state.connectionType = normalizeConnectionType(item.connection_type); + if (item.ssh_target) state.sshTarget = normalizeSshTarget(item.ssh_target); + if (state.connectionType !== 'ssh') state.sshTarget = null; state.sftpAvailable = typeof item.files_available === 'boolean' ? item.files_available : (state.connectionType === 'ssh' ? null : false); @@ -8022,6 +7918,7 @@

Recover StandTerm session

state.connecting = true; state.error = false; state.connectionType = formData.connection_type; + state.sshTarget = state.connectionType === 'ssh' ? normalizeSshTarget(formData) : null; state.label = formData.connection_type === 'ssh' && formData.profile_name ? getSshProfileTabLabel(formData.profile_name) : (CONNECTION_LABELS[state.connectionType] || 'Terminal'); @@ -8129,10 +8026,12 @@

Recover StandTerm session

document.getElementById('ssh-edit-route').onclick = async () => { try { invalidateSshRetry(); - await sshSessionWriteQueue; + const revision = sshConnectionFormRevision; + const terminalId = activeTerminalId; + await sshSessionWriteQueue.catch(() => {}); const snapshot = await SshRoutes.load(openSshSessionsDb, normalizeLegacySshSessionState, true); if (snapshot.state.revision !== sshSessionState.revision) throw new Error('SSH settings changed in another window. Reload before editing.'); - const terminalId = activeTerminalId; + if (revision !== sshConnectionFormRevision || terminalId !== activeTerminalId) return; SshRoutes.edit({ state: sshRouteDraft?.state || snapshot.state, entryId: currentSshRouteEntry()?.id, temporaryKeys: sshRouteDraft?.temporaryKeys || [], saveRoute: sshRouteDraft?.saveRoute || false, target: getCurrentSshTarget(), @@ -8141,6 +8040,9 @@

Recover StandTerm session

copyPublicKey: value => copyToClipboard(value), hostIdentity: { request: requestSshHostIdentity, terminalId }, onDone: (state, entryId, temporaryKeys, saveRoute) => { + if (revision !== sshConnectionFormRevision || terminalId !== activeTerminalId) { + throw new Error('The connection changed. Cancel and reopen the route editor.'); + } sshRouteDraft = { state, entryId, temporaryKeys, saveRoute }; selectedSshRouteEntry = entryId; selectSshPreparationMode('route'); @@ -8165,21 +8067,16 @@

Recover StandTerm session

document.addEventListener('click', event => { if (!event.target.closest('.ssh-session-picker')) closeSshSessionPicker(); }); + sshSaveSessionInput.addEventListener('change', invalidateSshRetry); sshSaveHistoryInput.onchange = () => { prefs.saveSshHistory = sshSaveHistoryInput.checked; savePrefs(prefs); }; sshProfileCreateBtn.onclick = () => { - createSshProfileEditor().catch(err => setSshProfileStatus(err.message || 'Profile could not be created.', true)); - }; - sshProfileKeyEnabledInput.onchange = () => { - toggleSshProfileKey() - .catch(err => setSshProfileStatus(err.message || 'Browser SSH key could not be generated.', true)); + editSshProfileRoute(true).catch(err => setSshProfileStatus(err.message || 'Profile could not be created.', true)); }; - sshProfileKeyCopyBtn.onclick = () => { - if (!editingSshKeyRecord) return; - copyToClipboard(editingSshKeyRecord.publicKeyOpenSsh); - setSshProfileStatus('SSH public key copied.'); + sshProfileEditRouteBtn.onclick = () => { + editSshProfileRoute().catch(err => setSshProfileStatus(err.message || 'Profile could not be edited.', true)); }; sshProfileSaveBtn.onclick = () => { saveSshProfileEditor().catch(err => setSshProfileStatus(err.message || 'Profile could not be saved.', true)); @@ -8722,6 +8619,8 @@

Recover StandTerm session

state.connecting = false; state.error = false; state.connectionType = normalizeConnectionType(data.connection_type); + if (data.ssh_target) state.sshTarget = normalizeSshTarget(data.ssh_target); + if (state.connectionType !== 'ssh') state.sshTarget = null; state.sftpAvailable = typeof data.files_available === 'boolean' ? data.files_available : (state.connectionType === 'ssh' ? null : false); @@ -10289,6 +10188,34 @@

Recover StandTerm session

} let floatingWindowOpening = false; + const browserPopupWindows = new Set(); + function closeBrowserPopup(child) { + try { + if (child.closed) return; + // An opaque-origin reload can also make WebKit reject close. + // Only dispose windows whose original document is accessible. + if (child.document) child.close(); + } catch (_) { + // Some browsers reject script-close after about:blank reloads. + // Terminal/Files state is already released by pagehide. + } + } + + function usesBrowserPopup() { + return !useDesktopFloatingWindows && typeof window.documentPictureInPicture?.requestWindow !== 'function'; + } + + function floatingWindowError() { + alert(useDesktopFloatingWindows + ? 'Could not open the floating window. Please retry or update StandTerm Desktop.' + : 'Could not open the floating window. Allow pop-up windows for this site, then click the control again.'); + } + + window.addEventListener('pagehide', () => { + for (const child of browserPopupWindows) closeBrowserPopup(child); + browserPopupWindows.clear(); + }); + async function requestFloatingWindow(width, height) { if (floatingWindowOpening) return null; floatingWindowOpening = true; @@ -10298,25 +10225,40 @@

Recover StandTerm session

if (!child) throw new Error('Floating window was blocked.'); return await Promise.resolve(child); } + if (usesBrowserPopup()) { + const child = window.open('about:blank', '_blank', `popup,width=${width},height=${height}`); + if (!child) throw new Error('Floating window was blocked.'); + child.opener = null; + browserPopupWindows.add(child); + child.addEventListener('pagehide', () => { + // Let the terminal/Files handlers restore their DOM + // before disposing a closed or navigated popup. + setTimeout(() => { closeBrowserPopup(child); browserPopupWindows.delete(child); }, 0); + }, { once: true }); + return await Promise.resolve(child); + } return await window.documentPictureInPicture.requestWindow({ width, height }); } catch { - alert('Could not open the floating window. Window creation may be blocked or unavailable. Please retry or update StandTerm Desktop.'); + floatingWindowError(); return null; } finally { floatingWindowOpening = false; } } - async function openSftpPip(state) { + async function openSftpPip(state, existingWindow = null) { if (!canUseSftpFileManager(state)) return; - if (!useDesktopFloatingWindows && !window.documentPictureInPicture) return alert('PiP not supported.'); - if (pipTerminalState) return alert('Restore the terminal from PiP before opening Files.'); + if (pipTerminalState) return alert('Restore the terminal from its floating window before opening Files.'); if (sftpPipState) { if (sftpPipState.window && !sftpPipState.window.closed) sftpPipState.window.focus(); return; } - const pipWindow = await requestFloatingWindow(720, 620); + const pipWindow = existingWindow || await requestFloatingWindow(720, 620); if (!pipWindow) return; + if (!isCurrentTerminalState(state) || !canUseSftpFileManager(state)) { + pipWindow.close(); + return; + } const transferState = { mode: 'source', window: pipWindow, @@ -10352,19 +10294,35 @@

Recover StandTerm session

xhr: null, }; sftpPipState = transferState; - createSftpPipShell(transferState); - copyStylesToPipWindow(pipWindow); - pipWindow.addEventListener('pagehide', () => { + const release = () => { transferState.window = null; transferState.elements = null; if (sftpPipState === transferState) sftpPipState = null; - }, { once: true }); - requestSftpBrowse(transferState); + }; + pipWindow.addEventListener('pagehide', release, { once: true }); + try { + if (existingWindow) pipWindow.document.head.replaceChildren(); + createSftpPipShell(transferState); + copyStylesToPipWindow(pipWindow); + requestSftpBrowse(transferState); + pipWindow.focus(); + } catch { + release(); + pipWindow.close(); + floatingWindowError(); + } } async function openSftpFromTerminalPip(state) { if (!state || pipTerminalState !== state || !canUseSftpFileManager(state)) return; const pipWindow = state.pipWindow; + if (browserPopupWindows.has(pipWindow)) { + // The click activates the child, not its opener. Reuse this + // window so the transition needs no second popup permission. + restoreTerminalFromPip(state); + openSftpPip(state, pipWindow); + return; + } const closing = useDesktopFloatingWindows && pipWindow && !pipWindow.closed ? new Promise(resolve => pipWindow.addEventListener('pagehide', resolve, { once: true })) : null; @@ -10390,6 +10348,8 @@

Recover StandTerm session

const OVERLAY_MIN_HEIGHT = 150; const IMAGE_EXTENSIONS = ['.jpg', '.jpeg', '.png', '.gif', '.webp', '.svg', '.bmp']; function showContextMenu(x, y, target = null) { + pipOption.textContent = usesBrowserPopup() ? '\u29C9 Pop out terminal' : '\u29C9 Terminal to PiP'; + pipOption.title = usesBrowserPopup() ? 'Open a separate browser window; it may not stay on top.' : ''; const activeTerm = getActiveTerm(); const targetTab = target && target.closest ? target.closest('.terminal-tab') : null; contextMenuTerminalId = targetTab ? targetTab.dataset.terminalId : activeTerminalId; @@ -10823,8 +10783,7 @@

Recover StandTerm session

async function moveActiveTerminalToPip() { contextMenu.style.display = 'none'; if (!canMoveActiveTerminalToPip()) return; - if (!useDesktopFloatingWindows && !window.documentPictureInPicture) return alert("PiP not supported."); - if (sftpPipState) return alert('Close the SFTP transfer window before moving a terminal to PiP.'); + if (sftpPipState) return alert('Close Files before moving a terminal to its own window.'); const state = getActiveTerminalState(); if (!state) return; if (pipTerminalState && pipTerminalState !== state) { @@ -10832,21 +10791,33 @@

Recover StandTerm session

} const pipWindow = await requestFloatingWindow(800, 480); if (!pipWindow) return; + if (!isCurrentTerminalState(state) || !state.connected) { + pipWindow.close(); + return; + } pipTerminalState = state; state.pipWindow = pipWindow; state.pipReturnNextSibling = state.container.nextSibling; state.pipResizeHandler = () => fitTerminalState(state); state.pipPagehideHandler = () => restoreTerminalFromPip(state); - setTerminalPipMode(state, true); - const host = createPipShell(state, pipWindow); - host.append(state.container); - state.container.classList.add('active'); - copyStylesToPipWindow(pipWindow); - updatePipStatus(state); - requestAnimationFrame(() => fitTerminalState(state)); pipWindow.addEventListener('resize', state.pipResizeHandler); pipWindow.addEventListener('pagehide', state.pipPagehideHandler); + try { + setTerminalPipMode(state, true); + const host = createPipShell(state, pipWindow); + host.append(state.container); + state.container.classList.add('active'); + copyStylesToPipWindow(pipWindow); + updatePipStatus(state); + requestAnimationFrame(() => fitTerminalState(state)); + pipWindow.focus(); + state.term.focus(); + } catch { + restoreTerminalFromPip(state, { closeWindow: true }); + pipWindow.close(); + floatingWindowError(); + } } pipOption.addEventListener('click', moveActiveTerminalToPip); @@ -11188,11 +11159,8 @@

Recover StandTerm session

document.getElementById('pref-fontWeight').value = prefs.fontWeight; document.getElementById('pref-cursorStyle').value = prefs.cursorStyle; document.getElementById('pref-showTerminalTitleInStatusBar').checked = prefs.showTerminalTitleInStatusBar; - if (editingSshProfileId) { - renderSshProfileManager(); - } else { - clearSshProfileEditor(true); - } + syncSshProfileEditorContext(); + renderSshProfileManager(); settingsModal.classList.add('open'); renderConnectionDiagnostics(); requestServerSettingsSnapshot(); diff --git a/terminal_backends/ssh.py b/terminal_backends/ssh.py index da3986f..6f0dac0 100644 --- a/terminal_backends/ssh.py +++ b/terminal_backends/ssh.py @@ -146,6 +146,12 @@ def metadata(self, cols=None, rows=None): metadata = super().metadata(cols=cols, rows=rows) if self.auth_method: metadata['auth_method'] = self.auth_method + if self._sftp_endpoint: + metadata['ssh_target'] = { + 'host': self._sftp_endpoint['host'], + 'port': self._sftp_endpoint['port'], + 'username': self._sftp_endpoint['user'], + } return metadata def sftp_endpoint(self): diff --git a/tests/agent_backend_smoke.py b/tests/agent_backend_smoke.py index 51fa37b..c14dbb9 100644 --- a/tests/agent_backend_smoke.py +++ b/tests/agent_backend_smoke.py @@ -4452,6 +4452,7 @@ def make_sftp_test_bridge(session_token, terminal_id=standterm.TERMINAL_ID_MAIN) 'port': 22, 'route': 'direct', } + bridge.auth_method = None bridge._sftp_lock = threading.Lock() bridge._sftp_file_refs_lock = threading.Lock() bridge._sftp_file_refs = {} @@ -4461,6 +4462,22 @@ def make_sftp_test_bridge(session_token, terminal_id=standterm.TERMINAL_ID_MAIN) return bridge +def test_ssh_target_metadata_is_structured_and_access_scoped(): + session = 'target-metadata-test' + bridge = make_sftp_test_bridge(session) + bridge._sftp_endpoint.update(password='not-public', route='not-an-endpoint') + expected = {'host': 'host.example', 'port': 22, 'username': 'tester'} + assert bridge.metadata()['ssh_target'] == expected + standterm.bridges[session] = {bridge.terminal_id: bridge} + try: + with patch.object(standterm, 'is_terminal_bridge_allowed_for_sid', return_value=True): + assert standterm.build_terminal_list(session, sid='allowed')[0]['ssh_target'] == expected + with patch.object(standterm, 'is_terminal_bridge_allowed_for_sid', return_value=False): + assert standterm.build_terminal_list(session, sid='denied') == [] + finally: + standterm.bridges.pop(session) + + def make_local_file_test_bridge(session_token, terminal_id): return standterm.LocalShellBridge( session_token, @@ -8664,6 +8681,7 @@ def main(): test_transcript_store_sanitizes_terminal_output, test_transcript_retains_batched_terminal_output_and_utf8_boundaries, test_terminal_bridge_tracks_shared_session_metadata, + test_ssh_target_metadata_is_structured_and_access_scoped, test_ssh_input_records_agent_metadata_after_validation, test_agent_input_metadata_bounds_and_sanitized_preview, test_privacy_state_blocks_agent_context_and_redacts_input_metadata, diff --git a/tests/agent_browser_smoke.py b/tests/agent_browser_smoke.py index 9a14302..5e2d1b9 100644 --- a/tests/agent_browser_smoke.py +++ b/tests/agent_browser_smoke.py @@ -4314,7 +4314,8 @@ def test_ssh_profile_picker_and_settings_save_semantics(browser, access_url): }); window.terminalTest.handleSshOutput({ terminal_id: terminalId, message_type: 'ssh_connected', - connection_type: 'ssh', terminal_label: 'SSH - Build Server' + connection_type: 'ssh', terminal_label: 'SSH - Build Server', + ssh_target: {host: 'build.example', port: '22', username: 'builder'} }); }""" ) @@ -4383,19 +4384,20 @@ def test_ssh_profile_picker_and_settings_save_semantics(browser, access_url): preloaded_editor = page.evaluate( """() => ({ name: document.getElementById('ssh-profile-name').value, - host: document.getElementById('ssh-profile-host').value, + summary: document.getElementById('ssh-profile-route-summary').textContent, saveDisabled: document.getElementById('ssh-profile-save').disabled })""" ) check( - preloaded_editor == {'name': 'recent@recent.example', 'host': 'recent.example', 'saveDisabled': True}, - 'SSH Settings did not preload Quick Connect as a create-only draft', + preloaded_editor == {'name': 'builder@build.example', 'summary': 'New direct session: builder@build.example:22', 'saveDisabled': True}, + 'SSH Settings did not preload the active SSH tab as a create-only draft', ) page.click('#ssh-profile-list button[data-profile-id="profile-a"]') - page.fill('#ssh-profile-username', 'builder2') - page.click('#ssh-profile-save') + page.click('#ssh-profile-edit-route') + page.get_by_label('Target Username', exact=True).fill('builder2') + page.get_by_role('button', name='Save route', exact=True).click() page.wait_for_function( - """() => document.getElementById('ssh-profile-status').innerText === 'Saved Build Server.'""", + """() => document.getElementById('ssh-profile-status').innerText === 'Saved Build Server. Changes apply to the next connection.'""", timeout=5000, ) updated = page.evaluate("() => window.terminalTest.getSshSessionState()") @@ -4411,17 +4413,20 @@ def test_ssh_profile_picker_and_settings_save_semantics(browser, access_url): reordered = page.evaluate("() => window.terminalTest.getSshSessionState()") check([profile['id'] for profile in reordered['profiles']] == ['profile-b', 'profile-a'], 'profile move used list index as identity') - page.fill('#ssh-profile-name', 'Build Server Copy') - page.fill('#ssh-profile-port', '2222') page.click('#ssh-profile-create') + page.get_by_label('Entry name', exact=True).fill('Build Server Copy') + page.get_by_label('Target Host', exact=True).fill('build.example') + page.get_by_label('Target Username', exact=True).fill('builder') + page.get_by_label('Target Port', exact=True).fill('2222') + page.get_by_role('button', name='Save route', exact=True).click() page.wait_for_function( """async () => (await window.terminalTest.getSshSessionState()).profiles.length === 3""", timeout=5000, ) created = page.evaluate("() => window.terminalTest.getSshSessionState()") check(created['profiles'][-1]['name'] == 'Build Server Copy', 'Create did not add a separate profile') - check(created['profiles'][-1]['host'] == 'build.example', 'Create did not copy the loaded profile draft') - check(created['profiles'][-1]['port'] == '2222', 'Create did not retain edits made after Load') + check(created['profiles'][-1]['host'] == 'build.example', 'New session did not save its edited target') + check(created['profiles'][-1]['port'] == '2222', 'New session did not retain its port') created_profile_id = created['profiles'][-1]['id'] check(created_profile_id != 'profile-a', 'Create reused the loaded stable ID') original_profile = next(profile for profile in created['profiles'] if profile['id'] == 'profile-a') @@ -4535,20 +4540,9 @@ def test_browser_ssh_key_lifecycle_and_settings_transfer(browser, access_url): "() => document.getElementById('ssh-profile-name').value === 'Primary'", timeout=5000, ) - page.check('#ssh-profile-key-enabled') - page.wait_for_function( - "() => document.getElementById('ssh-profile-key-status').innerText.includes('SHA256:')", - timeout=10000, - ) - check( - page.locator('#ssh-profile-key-public').input_value().startswith('ssh-ed25519 '), - 'generated browser SSH key did not expose an OpenSSH public key', - ) - page.click('#ssh-profile-save') - page.wait_for_function( - "() => document.getElementById('ssh-profile-status').innerText === 'Saved Primary.'", - timeout=5000, - ) + # Keep coverage of credentials owned by legacy profiles. New node keys + # are exercised through the managed route editor in its dedicated suite. + page.evaluate("() => window.terminalTest.createBrowserSshKeyForProfileForTest('profile-primary')") metadata = page.evaluate( "() => window.terminalTest.getBrowserSshKeyMetadataForTest('profile-primary')" ) @@ -4713,9 +4707,11 @@ def test_browser_ssh_key_lifecycle_and_settings_transfer(browser, access_url): page.click('#quick-settings') page.click('.settings-nav-item[data-tab="ssh-sessions"]') page.click('#ssh-profile-list button[data-profile-id="profile-primary"]') - page.wait_for_function("() => document.getElementById('ssh-profile-key-enabled').checked", timeout=5000) - page.fill('#ssh-profile-name', 'Primary Copy') page.click('#ssh-profile-create') + page.get_by_label('Entry name', exact=True).fill('Primary Copy') + page.get_by_label('Target Host', exact=True).fill('copy.example') + page.get_by_label('Target Username', exact=True).fill('copy') + page.get_by_role('button', name='Save route', exact=True).click() page.wait_for_function( """async () => (await window.terminalTest.getSshSessionState()).profiles .some(profile => profile.name === 'Primary Copy')""", @@ -4726,7 +4722,6 @@ def test_browser_ssh_key_lifecycle_and_settings_transfer(browser, access_url): check(copied['keyId'] is None, 'Create copied a browser key from the loaded profile') page.click('#ssh-profile-list button[data-profile-id="profile-primary"]') - page.wait_for_function("() => document.getElementById('ssh-profile-key-enabled').checked", timeout=5000) page.once('dialog', lambda dialog: dialog.accept()) page.click('#ssh-profile-delete') page.wait_for_function( diff --git a/tests/browser_popout_smoke.py b/tests/browser_popout_smoke.py new file mode 100644 index 0000000..880e4e5 --- /dev/null +++ b/tests/browser_popout_smoke.py @@ -0,0 +1,182 @@ +"""Verify the non-Document-PiP path with actual popup input and lifecycle.""" +import argparse +from urllib.parse import urlsplit + +from playwright.sync_api import TimeoutError as PlaywrightTimeoutError + +import agent_browser_smoke as fixture + + +def open_terminal(page): + active = page.evaluate('() => window.terminalTest.getTerminalTabsState().activeTerminalId') + page.evaluate('id => window.terminalTest.showContextMenuForTest(id)', active) + assert 'Pop out' in page.locator('#pip-option').inner_text() + with page.expect_popup() as opened: + page.click('#pip-option') + popup = opened.value + popup.locator('.pip-terminal-host').wait_for() + assert popup.url == 'about:blank' + assert popup.evaluate('window.opener === null') + return popup, active + + +def send_title(popup, title): + popup.locator('.xterm-helper-textarea').focus() + popup.keyboard.type(f"printf '\\033]2;{title}\\007'\n") + popup.wait_for_function('title => document.querySelector(".pip-application-title").textContent === title', arg=title) + + +def wait_released(page, terminal_id=None): + page.wait_for_function('() => !window.terminalTest.getFloatingWindowForTest()') + if terminal_id: + page.wait_for_function( + 'id => !window.terminalTest.getTerminalTabsState().tabs.find(t => t.id === id).inPip', + arg=terminal_id, + ) + + +def select_terminal(page, terminal_id): + page.click(f'.terminal-tab[data-terminal-id="{terminal_id}"]') + + +def navigate_and_wait_released(page, popup, terminal_id, destination=None, allow_close_quirk=False): + try: + with popup.expect_event('close', timeout=5000): + popup.evaluate( + 'url => setTimeout(() => url ? location.assign(url) : location.reload(), 0)', + destination, + ) + except PlaywrightTimeoutError: + if not allow_close_quirk: + raise + # Linux WPE may refuse script-close after navigation. Restoring and + # releasing the original terminal is still required; report the gap. + wait_released(page, terminal_id) + assert popup.locator('.pip-terminal-host').count() == 0 + popup.close() + print('LIMITATION: navigated popup restored its terminal but required browser close.', flush=True) + return 1 + wait_released(page, terminal_id) + return 0 + + +def test_popup_input_files_and_cleanup(browser, url, allow_close_quirk=False): + print('Popup smoke: opening the connected Core page.', flush=True) + context, page = fixture.new_page(browser, url) + errors = [] + page.on('pageerror', lambda error: errors.append(str(error))) + navigation_close_gaps = 0 + try: + page.evaluate("Object.defineProperty(window, 'documentPictureInPicture', {value: undefined, configurable: true})") + page.click('#new-tab-btn') + page.click('#connectBtn') + page.wait_for_function('() => window.terminalTest.getActiveAgentState()?.connected === true') + popup, active = open_terminal(page) + popup.on('pageerror', lambda error: errors.append(str(error))) + send_title(popup, 'POPUP_INPUT_OK') + print('Popup smoke: child input passed.', flush=True) + old_size = popup.locator('.pip-title-meta').inner_text() + popup.set_viewport_size({'width': 500, 'height': 300}) + popup.wait_for_function('old => document.querySelector(".pip-title-meta").textContent !== old', arg=old_size) + # A trusted click occurs inside the child. It must not create another popup. + count = len(context.pages) + popup.locator('.pip-sftp-button').click() + popup.locator('.sftp-pip-title').wait_for() + assert len(context.pages) == count + assert popup.locator('.sftp-pip-title').inner_text() == 'StandTerm - Files' + print('Popup smoke: trusted Files transition passed.', flush=True) + page.wait_for_function('id => !window.terminalTest.getTerminalTabsState().tabs.find(t => t.id === id).inPip', arg=active) + popup.close() + wait_released(page, active) + select_terminal(page, active) + # Native close restores an interactive terminal to its parent document. + page.locator('.terminal-pane.active .xterm-helper-textarea').focus() + page.keyboard.type("printf '\\033]2;RESTORED_INPUT_OK\\007'\n") + page.wait_for_function('() => document.getElementById("terminal-title").textContent === "RESTORED_INPUT_OK"') + popup, _ = open_terminal(page) + navigation_close_gaps += navigate_and_wait_released(page, popup, active, allow_close_quirk=allow_close_quirk) + select_terminal(page, active) + popup, _ = open_terminal(page) + parsed = urlsplit(url) + navigation_close_gaps += navigate_and_wait_released( + page, popup, active, f'{parsed.scheme}://{parsed.netloc}/robots.txt', allow_close_quirk, + ) + select_terminal(page, active) + popup, _ = open_terminal(page) + popup.close() + wait_released(page, active) + select_terminal(page, active) + + # Blocked admission keeps the terminal visible, then a new click retries. + print('Popup smoke: close, reload and navigation restored terminal state.', flush=True) + page.evaluate("""() => { + window.__popupOriginalOpen = window.open; + window.__popupAlerts = []; + window.alert = value => window.__popupAlerts.push(value); + window.open = () => null; + window.terminalTest.showContextMenuForTest(window.terminalTest.getTerminalTabsState().activeTerminalId); + }""") + page.click('#pip-option') + page.wait_for_function('() => window.__popupAlerts.length === 1') + assert 'Allow pop-up windows' in page.evaluate('window.__popupAlerts[0]') + wait_released(page, active) + page.evaluate('() => { window.open = window.__popupOriginalOpen; }') + popup, _ = open_terminal(page) + send_title(popup, 'RETRY_INPUT_OK') + print('Popup smoke: blocked popup retry passed.', flush=True) + popup.close() + wait_released(page, active) + select_terminal(page, active) + assert len(context.pages) == 1, [(item.url, item.is_closed()) for item in context.pages] + + # Reentrant handlers in the same trusted click must share one admission. + page.evaluate("""() => { + window.__popupOpenCount = 0; + window.open = (...args) => { + window.__popupOpenCount++; + return window.__popupOriginalOpen(...args); + }; + document.getElementById('pip-option').addEventListener('click', () => { + document.getElementById('pip-option').click(); + document.getElementById('sftp-status-btn').click(); + }, {once: true}); + }""") + popup, _ = open_terminal(page) + assert page.evaluate('window.__popupOpenCount') == 1 + assert len(context.pages) == 2, [(item.url, item.is_closed()) for item in context.pages] + popup.close() + wait_released(page, active) + select_terminal(page, active) + page.evaluate('() => { window.open = window.__popupOriginalOpen; }') + + popup, _ = open_terminal(page) + with popup.expect_event('close'): + page.reload(wait_until='domcontentloaded') + page.wait_for_function('() => !!window.terminalTest') + assert not errors, errors + return navigation_close_gaps + finally: + fixture.close_context(context) + + +if __name__ == '__main__': + parser = argparse.ArgumentParser() + parser.add_argument('--browser', choices=['chromium', 'webkit'], default='chromium') + parser.add_argument('--executable') + parser.add_argument('--allow-navigation-close-quirk', action='store_true', + help='Report a navigated Linux WPE popup requiring browser close instead of failing.') + args = parser.parse_args() + server, url = fixture.start_server() + try: + with fixture.load_playwright()[0]() as playwright: + options = {'headless': True} + if args.executable: + options['executable_path'] = args.executable + browser = getattr(playwright, args.browser).launch(**options) + try: + gaps = test_popup_input_files_and_cleanup(browser, url, args.allow_navigation_close_quirk) + print(f'{args.browser} popup input, Files and restore: PASS; navigation auto-close gaps: {gaps}') + finally: + browser.close() + finally: + fixture.stop_server(server) diff --git a/tests/ssh_profile_context_browser_smoke.py b/tests/ssh_profile_context_browser_smoke.py new file mode 100644 index 0000000..a5d694c --- /dev/null +++ b/tests/ssh_profile_context_browser_smoke.py @@ -0,0 +1,218 @@ +"""Keep the SSH profile editor bound to its current connection context.""" +import agent_browser_smoke as fixture +from ssh_routes_browser_smoke import show_ssh, open_card + + +def set_target(page, host, username, terminal_id='main'): + page.evaluate("""({host, username, terminalId}) => { + document.getElementById('host').value = host; + document.getElementById('port').value = '2222'; + document.getElementById('username').value = username; + window.terminalTest.applyTerminalListForTest({terminals: [{ + terminal_id: terminalId, connection_type: 'ssh', connected: true, + terminal_label: 'A display label is not an endpoint', + ssh_target: {host, port: '2222', username} + }]}); + }""", {'host': host, 'username': username, 'terminalId': terminal_id}) + + +def open_profiles(page): + page.click('#quick-settings') + page.click('.settings-nav-item[data-tab="ssh-sessions"]') + + +def editor(page): + return page.evaluate("""() => ({ + name: document.getElementById('ssh-profile-name').value, + summary: document.getElementById('ssh-profile-route-summary').textContent, + saveDisabled: document.getElementById('ssh-profile-save').disabled + })""") + + +def test_new_connection_replaces_previous_editor_context(browser, url): + context, page = fixture.new_page(browser, url) + try: + page.evaluate('() => window.terminalTest.setSshSessionState({profiles: [], history: []})') + set_target(page, 'first.example', 'first') + open_profiles(page) + page.fill('#ssh-profile-name', 'First profile') + page.click('#ssh-profile-create') + page.get_by_role('button', name='Save route', exact=True).click() + page.locator('#ssh-route-editor').wait_for(state='detached') + page.wait_for_function("() => document.getElementById('ssh-profile-status').textContent === 'Saved First profile. Changes apply to the next connection.'") + first = page.evaluate('() => window.terminalTest.getSshSessionState()')['profiles'][0] + page.click('#settings-close') + set_target(page, 'second.example', 'second') + open_profiles(page) + assert editor(page) == {'name': 'second@second.example', + 'summary': 'New direct session: second@second.example:2222', + 'saveDisabled': True}, editor(page) + # Reopening the same context preserves an unfinished draft. + page.fill('#ssh-profile-name', 'Second draft') + page.click('#settings-close') + open_profiles(page) + assert editor(page)['name'] == 'Second draft' + page.click('#ssh-profile-create') + page.get_by_role('button', name='Save route', exact=True).click() + page.locator('#ssh-route-editor').wait_for(state='detached') + page.wait_for_function("() => document.getElementById('ssh-profile-status').textContent === 'Saved Second draft. Changes apply to the next connection.'") + state = page.evaluate('() => window.terminalTest.getSshSessionState()') + assert len(state['profiles']) == 2 + assert next(item for item in state['profiles'] if item['id'] == first['id']) == first + page.click('#settings-close') + # Restore two tabs from structured server metadata while Quick Connect + # still contains the second target. Selecting the first must use its own. + page.evaluate("""() => window.terminalTest.applyTerminalListForTest({terminals: [ + {terminal_id: 'main', connection_type: 'ssh', connected: true, terminal_label: 'Second', + ssh_target: {host: 'second.example', port: '2222', username: 'second'}}, + {terminal_id: 'first-tab', connection_type: 'ssh', connected: true, terminal_label: 'Unrelated title', + ssh_target: {host: 'first.example', port: '2222', username: 'first'}} + ]})""") + page.click('.terminal-tab[data-terminal-id="first-tab"]') + open_profiles(page) + assert 'first@first.example:2222' in editor(page)['summary'], editor(page) + assert editor(page)['saveDisabled'] is True + # Explicit profile selection remains an edit until the context changes. + page.click(f'#ssh-profile-list [data-profile-id="{first["id"]}"]') + page.fill('#ssh-profile-name', 'Unfinished explicit edit') + page.click('#settings-close') + open_profiles(page) + assert editor(page)['name'] == 'Unfinished explicit edit' + assert editor(page)['saveDisabled'] is False + finally: + fixture.close_context(context) + + +def saved_state(page): + return page.evaluate('() => window.terminalTest.getSshSessionState()') + + +def managed_editor(page): + page.click('#ssh-profile-edit-route') + page.locator('#ssh-route-editor').wait_for() + assert page.get_by_role('checkbox', name='Save route', exact=True).count() == 0 + + +def test_route_management_preserves_full_path_and_failed_draft(browser, url): + context, page = fixture.new_page(browser, url) + try: + show_ssh(page) + page.evaluate("""async () => { + await window.terminalTest.setSshSessionState({profiles: [ + {id: 'route', name: 'Full route', host: 'target.test', port: '22', username: 'target'} + ], history: []}); + const state = await window.terminalTest.getSshSessionState(); + const target = state.nodes[0]; + state.nodes.push({...structuredClone(target), id: 'jump', + endpoint: {host: 'jump.test', port: '2222', username: 'jumper'}, nextNodeId: target.id}); + state.profiles[0].startNodeId = 'jump'; + await window.terminalTest.setSshSessionState(state); + }""") + open_profiles(page) + page.click('#ssh-profile-list [data-profile-id="route"]') + assert editor(page)['summary'] == 'Core host → jumper@jump.test:2222 → target@target.test:22' + original = saved_state(page) + page.fill('#ssh-profile-name', 'Renamed route') + page.click('#ssh-profile-save') + page.wait_for_function("() => document.getElementById('ssh-profile-status').textContent === 'Saved Renamed route.'") + assert saved_state(page)['nodes'] == original['nodes'] + managed_editor(page) + open_card(page, 'Jump 1') + page.get_by_label('Jump 1 Host', exact=True).fill('edited.jump') + open_card(page, 'Target') + page.get_by_label('Target Host', exact=True).fill('edited.target') + page.get_by_label('Target Use key', exact=True).check() + page.wait_for_function("() => document.querySelector('[aria-label=\"Target Public key\"]').value.startsWith('ssh-ed25519 ')") + public_key = page.get_by_label('Target Public key', exact=True).input_value() + page.get_by_role('button', name='Save route', exact=True).click() + page.locator('#ssh-route-editor').wait_for(state='detached') + state = saved_state(page) + assert state['profiles'][0]['id'] == 'route' + assert state['profiles'][0]['host'] == 'edited.target' + assert 'jumper@edited.jump:2222' in editor(page)['summary'] + managed_editor(page) + assert page.get_by_label('Jump 1 Host', exact=True).input_value() == 'edited.jump' + open_card(page, 'Target') + assert page.get_by_label('Target Use key', exact=True).is_checked() + assert page.get_by_label('Target Public key', exact=True).input_value() == public_key + page.get_by_label('Target Host', exact=True).fill('cancelled.target') + page.get_by_role('button', name='Cancel', exact=True).click() + assert saved_state(page) == state + managed_editor(page) + open_card(page, 'Jump 1') + page.get_by_label('Jump 1 Host', exact=True).fill('unsaved.jump') + page.get_by_label('Jump 1 Use key', exact=True).check() + page.wait_for_function("() => document.querySelector('[aria-label=\"Jump 1 Public key\"]').value.startsWith('ssh-ed25519 ')") + # Simulate another tab committing while this editor retains its older revision. + page.evaluate("""async () => { + const state = await window.terminalTest.getSshSessionState(); + state.profiles[0].name = 'Other window'; + await window.terminalTest.setSshSessionState(state); + }""") + concurrent = saved_state(page) + page.evaluate("""() => { + const save = StandTermSshRoutes.save; + StandTermSshRoutes.save = (...args) => { + StandTermSshRoutes.save = save; + window.failedKeyIds = args[2].map(change => change.record.keyId); + return save(...args); + }; + }""") + page.get_by_role('button', name='Save route', exact=True).click() + page.wait_for_function("() => document.querySelector('#ssh-route-editor > [role=status]').textContent.includes('changed in another window')") + assert saved_state(page) == concurrent + key_ids = page.evaluate('() => window.failedKeyIds') + assert len(key_ids) == 1 + assert not page.evaluate('id => window.terminalTest.browserSshKeyRecordExistsForTest(id)', key_ids[0]) + assert page.get_by_label('Jump 1 Host', exact=True).input_value() == 'unsaved.jump' + assert page.get_by_role('button', name='Save route', exact=True).is_enabled() + page.get_by_role('button', name='Cancel', exact=True).click() + finally: + fixture.close_context(context) + + +def test_connect_rejects_changed_form_during_storage_load(browser, url): + context, page = fixture.new_page(browser, url) + try: + show_ssh(page) + page.fill('#host', 'first.test') + page.fill('#username', 'first') + page.check('#ssh-save-session') + original = saved_state(page) + page.evaluate("""() => { + const load = StandTermSshRoutes.load; + StandTermSshRoutes.load = async (...args) => { + StandTermSshRoutes.load = load; + await new Promise(resolve => { window.releaseSshLoad = resolve; }); + return load(...args); + }; + window.pendingPreparation = window.terminalTest.prepareSshConnectionForTest(true) + .then(() => 'unexpected success', error => error.message); + }""") + page.wait_for_function('() => !!window.releaseSshLoad') + page.fill('#host', 'second.test') + page.uncheck('#ssh-save-session') + page.evaluate('() => window.releaseSshLoad()') + error = page.evaluate('() => window.pendingPreparation') + assert 'settings changed' in error, error + assert saved_state(page) == original + finally: + fixture.close_context(context) + + +if __name__ == '__main__': + server, url = fixture.start_server() + try: + with fixture.load_playwright()[0]() as playwright: + browser = playwright.chromium.launch(headless=True) + try: + test_new_connection_replaces_previous_editor_context(browser, url) + print('SSH profile editor context: PASS', flush=True) + test_route_management_preserves_full_path_and_failed_draft(browser, url) + print('SSH route management: PASS', flush=True) + test_connect_rejects_changed_form_during_storage_load(browser, url) + print('SSH preparation context: PASS', flush=True) + finally: + browser.close() + finally: + fixture.stop_server(server) diff --git a/tests/ssh_routes_browser_smoke.py b/tests/ssh_routes_browser_smoke.py index 6fbdad9..385c0e5 100644 --- a/tests/ssh_routes_browser_smoke.py +++ b/tests/ssh_routes_browser_smoke.py @@ -272,7 +272,6 @@ def test_receiver_and_owner_renames_preserve_credentials(browser, url): await window.terminalTest.setSshSessionState(state); }""") page.click('#ssh-profile-list button[data-profile-id="owner"]') - page.wait_for_function("() => document.getElementById('ssh-profile-key-enabled').checked") page.fill('#ssh-profile-name', 'Owner renamed') page.click('#ssh-profile-save') page.wait_for_function("() => document.getElementById('ssh-profile-status').innerText === 'Saved Owner renamed.'") From f6018892957f5e2e0e9af5095324d938f7f105e1 Mon Sep 17 00:00:00 2001 From: "ASKA C." Date: Wed, 16 Sep 2026 07:10:14 +0800 Subject: [PATCH 07/43] Edit Direct SSH settings inline with shared node fields ## Why A single-node SSH entry requires an extra editor layer even though its fields fit within Settings. Direct and jump nodes need consistent key and host identity controls without duplicating their persistence behavior. ## What changed - Show Direct node fields inline and save name, endpoint and key together. - Share node controls and revision-checked graph/key persistence with routes. - Carry incomplete drafts and temporary keys into Add jump node while keeping the inline draft on cancellation and preserving other node references. - Keep multi-node details in the second layer and retain inline edits on reorder. ## Testing SSH context, shared-node preservation, key persistence, incomplete route promotion, cancel and cross-window save conflicts pass in isolated Chromium. Existing route, profile/key transfer and PiP regressions pass. Narrow-window controls, JavaScript syntax, Python compilation and static-site checks pass. --- README.md | 20 +- static/js/standterm-ssh-route-editor.js | 120 ++++++----- templates/index.html | 219 ++++++++++++++++----- tests/agent_browser_smoke.py | 14 +- tests/ssh_profile_context_browser_smoke.py | 172 +++++++++++++++- tests/ssh_routes_browser_smoke.py | 2 +- 6 files changed, 424 insertions(+), 123 deletions(-) diff --git a/README.md b/README.md index ff0e452..cf07272 100644 --- a/README.md +++ b/README.md @@ -323,13 +323,19 @@ STANDTERM_HOST=127.0.0.1 STANDTERM_PORT=5000 ./run.sh Quick Connect can load saved SSH profiles and the six most recent successful SSH targets. Use **Settings > SSH Sessions** to create, update, reorder, or -delete profiles and to clear history. The first level shows names, ordering, and -full route summaries. **Save name** only renames the selected entry. **Edit -connection…** opens every node in a separate editor, including host identity and -**Use key**; Direct uses the same editor with one node. Its **Save route** button -saves all nodes and referenced keys immediately for the next connection. **New -session…** starts from the current SSH tab's target, or Quick Connect when no SSH -tab is connected. Switching tabs or connections clears the previous edit selection. +delete profiles and to clear history. Direct entries expose their single node +in the first level: host, port, username, **Use key**, public key copy and host +identity. **Save** stores the name, node and referenced key together. **Add jump +node…** carries unsaved fields and temporary keys into the full route editor; +cancelling returns to the unchanged Direct draft. Shared nodes in other entries +remain unchanged by default. + +Multi-node entries show names, ordering and full route summaries in the first +level. **Save name** only renames the selected entry. **Edit connection…** opens +all nodes in a separate editor; **Save route** stores all node and referenced +key changes for the next connection. **New session** starts an inline draft from +the current SSH tab's target, or Quick Connect when no SSH tab is connected. +Switching tabs or connections clears the previous edit selection. Profiles and history stay in the current browser and never store passwords. In Direct connect, **Save session** saves the profile and its referenced browser key when **Connect** is pressed, before SSH diff --git a/static/js/standterm-ssh-route-editor.js b/static/js/standterm-ssh-route-editor.js index d83bfe0..c51bc5e 100644 --- a/static/js/standterm-ssh-route-editor.js +++ b/static/js/standterm-ssh-route-editor.js @@ -2,9 +2,56 @@ 'use strict'; const routes = window.StandTermSshRoutes; + routes.nodeFields = function({ parent, role, node, profiles, savedKeys, newKeys, keyAllowed, + createKey, copyPublicKey, hostIdentity, editorId, onBusy, onChange }) { + const fields = document.createElement('div'); + fields.className = 'ssh-route-fields'; + parent.append(fields); + const input = (label, value, parent = fields) => { + const wrapper = document.createElement('label'); + wrapper.textContent = label; + const field = document.createElement('input'); + field.value = value; + field.setAttribute('aria-label', `${role} ${label}`); + wrapper.append(field); + parent.append(wrapper); + return field; + }; + const host = input('Host', node.endpoint.host); + const port = input('Port', node.endpoint.port); + const username = input('Username', node.endpoint.username); + port.inputMode = 'numeric'; + const auth = StandTermSshNodeAuth({ parent: fields, role, authentication: node.authentication, + endpoint: () => ({ host: host.value, port: port.value, username: username.value }), + profiles, savedKeys, newKeys, keyAllowed, createKey, copyPublicKey, onBusy, onChange + }); + const identity = document.createElement('details'); + identity.className = 'ssh-host-identity'; + const identityTitle = document.createElement('summary'); + identityTitle.textContent = 'Host identity'; + identity.append(identityTitle); + parent.append(identity); + const alias = input('Host key alias (optional)', node.hostKeyAlias, identity); + const identityBody = document.createElement('div'); + identity.append(identityBody); + const identityControl = hostIdentity && StandTermSshHostIdentity({ + parent: identityBody, editorId, nodeId: node.id, request: hostIdentity.request, + read: () => ({ ...routes.endpoint({ host: host.value, port: port.value, username: username.value }), + host_key_alias: alias.value.trim(), terminal_id: hostIdentity.terminalId }) + }); + identity.ontoggle = () => { if (identity.open) identityControl?.inspect(); }; + for (const field of [host, port, username, alias]) field.addEventListener('input', () => { + auth.update(); identityControl?.invalidate(); + }); + return { host, port, username, alias, auth, read: () => ({ + endpoint: { host: host.value, port: port.value, username: username.value }, + hostKeyAlias: alias.value.trim(), authentication: auth.read() + }) }; + }; + routes.edit = function({ state, entryId, target, onDone, keys = [], temporaryKeys = [], saveRoute = false, keyAllowed = false, - createKey, copyPublicKey, hostIdentity, mode = 'prepare', entryName = '' }) { - const original = routes.clone(state); + createKey, copyPublicKey, hostIdentity, mode = 'prepare', entryName = '', baseState = state }) { + const original = routes.clone(baseState); const savedKeys = [...keys]; const newKeys = new Map(temporaryKeys.map(record => [record.keyId, record])); const editorId = routes.id(); @@ -169,6 +216,17 @@ return true; } + function addJumpNode() { + canChangeOrder(); + flush(); + const current = path(); + current.splice(current.length - 1, 0, { id: routes.id(), + endpoint: { host: '', port: '22', username: target.username || '' }, + hostKeyAlias: '', nextNodeId: null, authentication: { method: 'password' } }); + replacePath(current, current.length - 2); + controls[current.length - 2].host.focus(); + } + function render() { controls = []; rows.replaceChildren(); @@ -180,16 +238,7 @@ preview.textContent = labelPath(result.path); result.path.forEach((node, index) => { if (index === result.path.length - 1) { - const add = button('Add jump node', () => { - canChangeOrder(); - flush(); - const current = path(); - current.splice(current.length - 1, 0, { id: routes.id(), - endpoint: { host: '', port: '22', username: target.username || '' }, - hostKeyAlias: '', nextNodeId: null, authentication: { method: 'password' } }); - replacePath(current, current.length - 2); - controls[current.length - 2].host.focus(); - }, rows); + const add = button('Add jump node', addJumpNode, rows); add.className = 'ssh-route-add'; } const fieldset = document.createElement('fieldset'); @@ -277,47 +326,12 @@ body.id = `ssh-route-card-body-${index}`; toggle.setAttribute('aria-controls', body.id); fieldset.append(body); - const fields = document.createElement('div'); - fields.className = 'ssh-route-fields'; - body.append(fields); - const input = (label, value, parent = fields) => { - const wrapper = document.createElement('label'); - wrapper.textContent = label; - const field = document.createElement('input'); - field.value = value; - field.setAttribute('aria-label', `${role} ${label}`); - wrapper.append(field); - parent.append(wrapper); - return field; - }; - const host = input('Host', node.endpoint.host); - const port = input('Port', node.endpoint.port); - const username = input('Username', node.endpoint.username); - port.inputMode = 'numeric'; - const auth = StandTermSshNodeAuth({ parent: fields, role, authentication: node.authentication, - endpoint: () => ({ host: host.value, port: port.value, username: username.value }), - profiles: draft.profiles, savedKeys, newKeys, keyAllowed, createKey, copyPublicKey, + const nodeFields = routes.nodeFields({ parent: body, role, node, profiles: draft.profiles, + savedKeys, newKeys, keyAllowed, createKey, copyPublicKey, hostIdentity, editorId, onBusy: delta => { pendingGenerations += delta; updateSaveState(); }, onChange: () => { updateSummary(); updateSaveState(); } }); - const identity = document.createElement('details'); - identity.className = 'ssh-host-identity'; - const identityTitle = document.createElement('summary'); - identityTitle.textContent = 'Host identity'; - identity.append(identityTitle); - body.append(identity); - const alias = input('Host key alias (optional)', node.hostKeyAlias, identity); - const identityBody = document.createElement('div'); - identity.append(identityBody); - const identityControl = hostIdentity && StandTermSshHostIdentity({ - parent: identityBody, editorId, nodeId: node.id, request: hostIdentity.request, - read: () => ({ ...routes.endpoint({ host: host.value, port: port.value, username: username.value }), - host_key_alias: alias.value.trim(), terminal_id: hostIdentity.terminalId }) - }); - identity.ontoggle = () => { if (identity.open) identityControl?.inspect(); }; - for (const field of [host, port, username, alias]) field.addEventListener('input', () => { - auth.update(); identityControl?.invalidate(); - }); + const { host, port, username, alias, auth } = nodeFields; const advanced = document.createElement('details'); const advancedTitle = document.createElement('summary'); advancedTitle.textContent = 'Advanced node settings'; @@ -326,10 +340,7 @@ const affected = document.createElement('p'); affected.textContent = `Referenced by: ${routes.references(draft, node.id).map(item => item.name || 'This Entry').join(', ')}`; advanced.append(affected); - controls.push({ card: fieldset, handle, host, advanced, body, toggle, read: () => ({ - endpoint: { host: host.value, port: port.value, username: username.value }, - hostKeyAlias: alias.value.trim(), authentication: auth.read() - }) }); + controls.push({ card: fieldset, handle, host, advanced, body, toggle, read: nodeFields.read }); const otherEntries = document.createElement('select'); otherEntries.setAttribute('aria-label', `${role} Next route`); otherEntries.add(new Option('Choose the next route...', '')); @@ -464,5 +475,6 @@ document.body.append(dialog); render(); dialog.showModal(); + return { addJump: addJumpNode }; }; })(); diff --git a/templates/index.html b/templates/index.html index 648d6fe..0dcdd13 100644 --- a/templates/index.html +++ b/templates/index.html @@ -396,6 +396,10 @@ .ssh-profile-editor { display: grid; align-content: start; gap: 9px; } .ssh-profile-editor label { display: grid; gap: 4px; color: #aaa; font-size: 11px; } .ssh-profile-editor input { width: 100%; box-sizing: border-box; padding: 7px 8px; background: #2c2c2e; border: 1px solid #555; border-radius: 4px; color: #fff; outline: none; } + #ssh-profile-node .ssh-route-fields { grid-template-columns: minmax(0, 1fr) 70px; } + #ssh-profile-node .ssh-route-fields > label:nth-child(3) { grid-column: 1 / -1; } + #ssh-profile-node .ssh-key-toggle { display: flex; } + #ssh-profile-node details { margin-top: 10px; } .ssh-profile-actions { display: flex; gap: 8px; flex-wrap: wrap; } .ssh-profile-actions button { flex: 1; min-width: 86px; padding: 7px 9px; border: none; border-radius: 4px; background: #3a3a3c; color: #fff; cursor: pointer; } .ssh-profile-actions button.primary { background: #0a84ff; } @@ -405,8 +409,8 @@ #ssh-route-editor::backdrop { background: rgba(0, 0, 0, 0.55); } #ssh-route-editor h3 { margin: 0 0 14px; color: #64a9ff; } #ssh-route-editor label { display: grid; gap: 5px; color: #aaa; font-size: 12px; } - #ssh-route-editor input, #ssh-route-editor select { width: 100%; min-width: 0; box-sizing: border-box; padding: 7px 8px; background: #2c2c2e; color: #fff; border: 1px solid #555; border-radius: 4px; font-size: 12px; } - #ssh-route-editor input:focus, #ssh-route-editor select:focus { outline: 1px solid #0a84ff; } + #ssh-route-editor input, #ssh-route-editor select, #ssh-profile-node input, #ssh-profile-node select { width: 100%; min-width: 0; box-sizing: border-box; padding: 7px 8px; background: #2c2c2e; color: #fff; border: 1px solid #555; border-radius: 4px; font-size: 12px; } + #ssh-route-editor input:focus, #ssh-route-editor select:focus, #ssh-profile-node input:focus, #ssh-profile-node select:focus { outline: 1px solid #0a84ff; } #ssh-route-editor fieldset { position: relative; margin: 18px 0 0; padding: 6px; border: 1px solid #555; border-radius: 6px; background: #2a2a2b; min-width: 0; } #ssh-route-editor fieldset::before { content: '↓'; position: absolute; top: -18px; left: 20px; color: #888; } #ssh-route-editor fieldset[data-role="Target"] { border-color: #64a9ff; } @@ -415,8 +419,8 @@ .ssh-route-accessible-label { position: absolute; width: 1px; height: 1px; padding: 0; overflow: hidden; clip-path: inset(50%); white-space: nowrap; } #ssh-route-editor p { overflow-wrap: anywhere; line-height: 1.5; } #ssh-route-editor details { margin-top: 12px; } - #ssh-route-editor summary { cursor: pointer; color: #aaa; margin-bottom: 10px; } - #ssh-route-editor details > label { margin: 10px 0; } + #ssh-route-editor summary, #ssh-profile-node summary { cursor: pointer; color: #aaa; margin-bottom: 10px; } + #ssh-route-editor details > label, #ssh-profile-node details > label { margin: 10px 0; } .ssh-route-source { margin-top: 18px; color: #ddd; font-weight: 600; } .ssh-route-preview { padding: 10px; background: #1e1e1e; border-radius: 4px; } .ssh-route-heading, .ssh-route-fields { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px 14px; } @@ -425,9 +429,9 @@ .ssh-route-buttons { display: flex; gap: 8px; flex-wrap: wrap; margin-top: 10px; } .ssh-route-card-header { display: flex; align-items: center; gap: 4px; } .ssh-route-card-body { padding: 10px 6px 4px; } - #ssh-route-editor button { padding: 7px 10px; border: 1px solid #555; border-radius: 4px; background: #3a3a3c; color: #fff; cursor: pointer; font-size: 12px; } - #ssh-route-editor button:hover:not(:disabled) { background: #48484a; } - #ssh-route-editor button:disabled { opacity: 0.4; cursor: default; } + #ssh-route-editor button, #ssh-profile-node button { padding: 7px 10px; border: 1px solid #555; border-radius: 4px; background: #3a3a3c; color: #fff; cursor: pointer; font-size: 12px; } + #ssh-route-editor button:hover:not(:disabled), #ssh-profile-node button:hover:not(:disabled) { background: #48484a; } + #ssh-route-editor button:disabled, #ssh-profile-node button:disabled { opacity: 0.4; cursor: default; } #ssh-route-editor .ssh-route-card-header > button { flex-shrink: 0; padding: 5px 8px; border-color: transparent; background: transparent; } #ssh-route-editor .ssh-route-drag { cursor: grab; font-size: 18px; } #ssh-route-editor .ssh-route-card-toggle { display: flex; align-items: center; gap: 10px; flex: 1; min-width: 0; text-align: left; } @@ -443,10 +447,10 @@ .ssh-node-key p { font-size: 12px; white-space: pre-wrap; overflow-wrap: anywhere; } .ssh-key-row { display: grid; grid-template-columns: auto minmax(0, 1fr) auto; align-items: center; gap: 8px; } .ssh-key-toggle { display: flex; align-items: center; gap: 5px; white-space: nowrap; font-size: 12px; } - #controls .ssh-key-toggle input, #ssh-route-editor .ssh-key-toggle input { width: auto; margin: 0; padding: 0; } - #controls .ssh-node-public-key, #ssh-route-editor .ssh-node-public-key { width: 100%; min-width: 0; margin: 0; box-sizing: border-box; font: 11px monospace; } - #controls .ssh-node-public-key.inactive, #ssh-route-editor .ssh-node-public-key.inactive { background: #292929; color: #888; } - #controls .ssh-key-copy, #ssh-route-editor .ssh-key-copy { width: auto; margin: 0; padding: 7px; background: #444; } + #controls .ssh-key-toggle input, #ssh-route-editor .ssh-key-toggle input, #ssh-profile-node .ssh-key-toggle input { width: auto; margin: 0; padding: 0; } + #controls .ssh-node-public-key, #ssh-route-editor .ssh-node-public-key, #ssh-profile-node .ssh-node-public-key { width: 100%; min-width: 0; margin: 0; box-sizing: border-box; font: 11px monospace; } + #controls .ssh-node-public-key.inactive, #ssh-route-editor .ssh-node-public-key.inactive, #ssh-profile-node .ssh-node-public-key.inactive { background: #292929; color: #888; } + #controls .ssh-key-copy, #ssh-route-editor .ssh-key-copy, #ssh-profile-node .ssh-key-copy { width: auto; margin: 0; padding: 7px; background: #444; } .ssh-key-copy:disabled { opacity: 0.4; cursor: default; } .ssh-key-status { margin: 7px 0 12px; color: #999; font-size: 11px; } #controls input[type="password"]:disabled { background: #252525; color: #777; border-color: #3b3b3b; } @@ -1055,12 +1059,13 @@

Settings

+
-
Select an entry to rename or reorder it. Edit connection opens all nodes and their key settings.
+
- +
@@ -2157,6 +2162,8 @@

Recover StandTerm session

const sshProfileStatus = document.getElementById('ssh-profile-status'); const sshProfileList = document.getElementById('ssh-profile-list'); const sshProfileNameInput = document.getElementById('ssh-profile-name'); + const sshProfileNode = document.getElementById('ssh-profile-node'); + const sshProfileHelp = document.getElementById('ssh-profile-help'); const sshProfileRouteSummary = document.getElementById('ssh-profile-route-summary'); const sshProfileEditRouteBtn = document.getElementById('ssh-profile-edit-route'); const sshProfileCreateBtn = document.getElementById('ssh-profile-create'); @@ -2323,6 +2330,8 @@

Recover StandTerm session

let editingSshProfileId = null; let sshProfileEditorContext = null; let sshProfileEditorVersion = 0; + let sshProfileNodeDraft = null; + let sshProfileNodeLoading = false; let quickConnectSshKeyRecord = null; let quickConnectKeyLoadVersion = 0; const handledSshSignRequestIds = new Set(); @@ -5012,17 +5021,97 @@

Recover StandTerm session

const target = useCurrentValues ? getSshProfileEditorContext().target : null; sshProfileNameInput.value = target?.host ? normalizeSshProfileName(`${target.username}@${target.host}`) : ''; - renderSshProfileManager(); + loadSshProfileNode(target).catch(err => setSshProfileStatus(err.message, true)); } - function loadSshProfileEditor(profileId) { + async function loadSshProfileEditor(profileId) { const profile = findSshProfile(profileId); if (!profile) return; - ++sshProfileEditorVersion; + const version = ++sshProfileEditorVersion; editingSshProfileId = profile.id; sshProfileNameInput.value = profile.name; - setSshProfileStatus(`Selected ${profile.name}. Save name only renames this entry.`); + await loadSshProfileNode(); + if (isCurrentSshProfileEditor(version)) setSshProfileStatus(`Selected ${profile.name}.`); + } + + async function loadSshProfileNode(target = null) { + const version = sshProfileEditorVersion; + const profileId = editingSshProfileId; + const terminalId = activeTerminalId; + sshProfileNodeDraft = null; + sshProfileNodeLoading = true; + sshProfileNode.replaceChildren(); renderSshProfileManager(); + try { + await sshSessionWriteQueue.catch(() => {}); + const snapshot = await SshRoutes.load(openSshSessionsDb, normalizeLegacySshSessionState, true); + if (!isCurrentSshProfileEditor(version)) return; + if (snapshot.state.revision !== sshSessionState.revision) { + throw new Error('SSH settings changed in another window. Reload before editing.'); + } + const baseState = SshRoutes.clone(snapshot.state); + let entry = snapshot.state.profiles.find(item => item.id === profileId); + if (!entry) { + if (profileId) throw new Error('This SSH entry was removed. Reload before editing.'); + const node = { id: SshRoutes.id(), nextNodeId: null, hostKeyAlias: '', + endpoint: target || { host: '', port: '22', username: '' }, authentication: { method: 'password' } }; + entry = { id: SshRoutes.id(), name: '', startNodeId: node.id, + sortOrder: snapshot.state.profiles.length, keyId: null, keyTarget: null }; + snapshot.state.nodes.push(node); + snapshot.state.profiles.push(entry); + } + const route = SshRoutes.resolve(snapshot.state, entry.startNodeId); + if (route.error || route.path.length !== 1) return; + const draft = { state: snapshot.state, baseState, isNew: !profileId, entryId: entry.id, node: route.path[0], + savedKeys: snapshot.keys, newKeys: new Map(), pending: 0, saving: false, fields: null }; + sshProfileNodeDraft = draft; + draft.fields = SshRoutes.nodeFields({ parent: sshProfileNode, role: 'Target', node: draft.node, + profiles: draft.state.profiles, savedKeys: draft.savedKeys, newKeys: draft.newKeys, + keyAllowed: isBrowserSshKeyAllowedByPolicy(), createKey: createTemporarySshKey, + copyPublicKey: value => copyToClipboard(value), editorId: SshRoutes.id(), + hostIdentity: { request: requestSshHostIdentity, terminalId }, + onBusy: delta => { draft.pending += delta; renderSshProfileManager(); }, + onChange: () => renderSshProfileManager() + }); + } catch (err) { + if (isCurrentSshProfileEditor(version)) throw err; + } finally { + if (isCurrentSshProfileEditor(version)) { + sshProfileNodeLoading = false; + renderSshProfileManager(); + } + } + } + + function readSshProfileNodeDraft() { + const current = sshProfileNodeDraft; + if (!current || current.pending || current.saving) throw new Error('Wait for the SSH settings to finish loading or saving.'); + const state = SshRoutes.clone(current.state); + const entry = state.profiles.find(item => item.id === current.entryId); + const node = { ...current.node, ...current.fields.read() }; + // Copy this entry's node without changing references from other routes. + // Incomplete values remain a draft until the shared save validation. + if (current.isNew) Object.assign(state.nodes.find(item => item.id === current.node.id), node); + else if (JSON.stringify(node) !== JSON.stringify(current.node)) entry.startNodeId = SshRoutes.copyPath(state, [node]); + entry.name = sshProfileNameInput.value.trim(); + return { state, entryId: entry.id, temporaryKeys: [...current.newKeys.values()] }; + } + + async function persistSshProfileRoute(draft, entryId, temporaryKeys, version) { + const used = new Set(draft.nodes.map(node => node.authentication.keyRef?.keyId)); + const operation = sshSessionWriteQueue.catch(() => {}).then(() => SshRoutes.save( + openSshSessionsDb, draft, temporaryKeys.filter(record => used.has(record.keyId)).map(record => ({ type: 'put', record })) + )); + sshSessionWriteQueue = operation; + sshSessionState = await operation; + renderSshSessionState(); + if (!isCurrentSshProfileEditor(version)) return; + const loading = loadSshProfileEditor(entryId); + const loadedVersion = sshProfileEditorVersion; + await loading; + if (isCurrentSshProfileEditor(loadedVersion)) { + setSshProfileStatus(`Saved ${findSshProfile(entryId).name}. Changes apply to the next connection.`); + } } function describeSshProfile(profile) { @@ -5031,39 +5120,39 @@

Recover StandTerm session

return `Core host → ${endpoints.join(' → ')}${route.error ? ' · Route needs repair' : ''}`; } - async function editSshProfileRoute(createNew = false) { - if (syncSshProfileEditorContext()) return; + async function editSshProfileRoute() { + if (syncSshProfileEditorContext() || sshProfileNodeLoading) return; const version = sshProfileEditorVersion; - const profileId = createNew ? null : editingSshProfileId; - if (!createNew && !profileId) return; + const profileId = editingSshProfileId; + const inline = sshProfileNodeDraft; + if (!inline && !profileId) return; const target = getSshProfileEditorContext().target; - const entryName = createNew && editingSshProfileId ? '' : sshProfileNameInput.value; const terminalId = activeTerminalId; - await sshSessionWriteQueue.catch(() => {}); - const snapshot = await SshRoutes.load(openSshSessionsDb, normalizeLegacySshSessionState, true); - if (!isCurrentSshProfileEditor(version)) return; - if (snapshot.state.revision !== sshSessionState.revision) { - throw new Error('SSH settings changed in another window. Reload before editing.'); + let snapshot; + let prepared; + if (inline) { + prepared = readSshProfileNodeDraft(); + snapshot = { state: prepared.state, keys: inline.savedKeys }; + } else { + await sshSessionWriteQueue.catch(() => {}); + snapshot = await SshRoutes.load(openSshSessionsDb, normalizeLegacySshSessionState, true); + if (!isCurrentSshProfileEditor(version)) return; + if (snapshot.state.revision !== sshSessionState.revision) { + throw new Error('SSH settings changed in another window. Reload before editing.'); + } + const profile = snapshot.state.profiles.find(item => item.id === profileId); + if (!profile) throw new Error('This SSH entry was removed. Reload before editing.'); + profile.name = sshProfileNameInput.value.trim(); } - const profile = snapshot.state.profiles.find(item => item.id === profileId); - if (!createNew && !profile) throw new Error('This SSH entry was removed. Reload before editing.'); - if (profile) profile.name = entryName; - SshRoutes.edit({ state: snapshot.state, entryId: profileId, target, entryName, + const editor = SshRoutes.edit({ state: snapshot.state, entryId: prepared?.entryId || profileId, target, + baseState: inline?.baseState || snapshot.state, + temporaryKeys: prepared?.temporaryKeys || [], mode: 'manage', keys: snapshot.keys, keyAllowed: isBrowserSshKeyAllowedByPolicy(), createKey: createTemporarySshKey, copyPublicKey: value => copyToClipboard(value), hostIdentity: { request: requestSshHostIdentity, terminalId }, - onDone: async (draft, entryId, temporaryKeys) => { - const operation = sshSessionWriteQueue.catch(() => {}).then(() => SshRoutes.save( - openSshSessionsDb, draft, temporaryKeys.map(record => ({ type: 'put', record })) - )); - sshSessionWriteQueue = operation; - sshSessionState = await operation; - renderSshSessionState(); - if (!isCurrentSshProfileEditor(version)) return; - loadSshProfileEditor(entryId); - setSshProfileStatus(`Saved ${findSshProfile(entryId).name}. Changes apply to the next connection.`); - } + onDone: (draft, entryId, temporaryKeys) => persistSshProfileRoute(draft, entryId, temporaryKeys, version) }); + if (inline) editor.addJump(); } function renderSshProfileManager() { @@ -5086,7 +5175,8 @@

Recover StandTerm session

target.className = 'ssh-profile-list-target'; target.innerText = describeSshProfile(profile); button.append(name, target); - button.onclick = () => loadSshProfileEditor(profile.id); + button.onclick = () => loadSshProfileEditor(profile.id) + .catch(err => setSshProfileStatus(err.message, true)); sshProfileList.appendChild(button); }); } @@ -5096,8 +5186,18 @@

Recover StandTerm session

sshProfileRouteSummary.textContent = profile ? describeSshProfile(profile) : target.host ? `New direct session: ${target.username}@${target.host}:${target.port}` : 'Select a saved entry, or create a new session.'; - sshProfileEditRouteBtn.disabled = selectedIndex < 0; - sshProfileSaveBtn.disabled = selectedIndex < 0; + const direct = !profile || SshRoutes.resolve(sshSessionState, profile.startNodeId).path.length === 1; + const busy = sshProfileNodeLoading || !!(sshProfileNodeDraft?.pending || sshProfileNodeDraft?.saving); + sshProfileNode.hidden = !direct; + sshProfileNode.inert = !!sshProfileNodeDraft?.saving; + sshProfileNameInput.disabled = !!sshProfileNodeDraft?.saving; + sshProfileEditRouteBtn.textContent = direct ? 'Add jump node…' : 'Edit connection…'; + sshProfileEditRouteBtn.disabled = busy || (direct && !sshProfileNodeDraft); + sshProfileSaveBtn.textContent = direct ? 'Save' : 'Save name'; + sshProfileSaveBtn.disabled = busy || (direct ? !sshProfileNodeDraft : selectedIndex < 0); + sshProfileHelp.textContent = direct + ? 'Save stores the name, connection settings and selected key. Changes apply to the next connection.' + : 'Save name only renames this entry. Edit connection opens all route nodes and their key settings.'; sshProfileDeleteBtn.disabled = selectedIndex < 0; sshProfileUpBtn.disabled = selectedIndex <= 0; sshProfileDownBtn.disabled = selectedIndex < 0 || selectedIndex >= sshSessionState.profiles.length - 1; @@ -5209,6 +5309,24 @@

Recover StandTerm session

if (syncSshProfileEditorContext()) return; const version = sshProfileEditorVersion; const profileId = editingSshProfileId; + if (sshProfileNodeLoading) return; + const inline = sshProfileNodeDraft; + if (inline) { + const prepared = readSshProfileNodeDraft(); + const entry = prepared.state.profiles.find(item => item.id === prepared.entryId); + const node = SshRoutes.checkedPath(prepared.state, entry)[0]; + SshRoutes.publicNode(node); + if (node.authentication.method === 'browser-key' && !node.authentication.keyRef) { + throw new Error('Choose or create a browser key for this node.'); + } + if (!entry.name) entry.name = normalizeSshProfileName(`${node.endpoint.username}@${node.endpoint.host}`); + if (entry.name.length > SSH_PROFILE_NAME_MAX_LENGTH) throw new Error(`Profile name must be ${SSH_PROFILE_NAME_MAX_LENGTH} characters or fewer.`); + inline.saving = true; + renderSshProfileManager(); + try { await persistSshProfileRoute(prepared.state, entry.id, prepared.temporaryKeys, version); } + finally { inline.saving = false; renderSshProfileManager(); } + return; + } if (!findSshProfile(profileId)) return; const name = sshProfileNameInput.value.trim(); if (!name || name.length > SSH_PROFILE_NAME_MAX_LENGTH) { @@ -5266,6 +5384,8 @@

Recover StandTerm session

async function moveEditingSshProfile(offset) { const profileId = editingSshProfileId; + const inline = sshProfileNodeDraft; + let reorderedRevision = null; await updateSshSessionState(nextState => { const currentIndex = nextState.profiles.findIndex(profile => profile.id === profileId); const nextIndex = currentIndex + offset; @@ -5275,7 +5395,13 @@

Recover StandTerm session

profiles.splice(nextIndex, 0, profile); profiles.forEach((item, index) => { item.sortOrder = index; }); nextState.profiles = profiles; + if (inline?.state.revision === nextState.revision) reorderedRevision = nextState.revision + 1; }); + // Adopt only this successful local order change, retaining inline fields. + if (inline && sshProfileNodeDraft === inline && sshSessionState.revision === reorderedRevision) { + inline.state = SshRoutes.clone(sshSessionState); + inline.baseState = SshRoutes.clone(sshSessionState); + } setSshProfileStatus('Profile order updated.'); } @@ -8073,7 +8199,8 @@

Recover StandTerm session

savePrefs(prefs); }; sshProfileCreateBtn.onclick = () => { - editSshProfileRoute(true).catch(err => setSshProfileStatus(err.message || 'Profile could not be created.', true)); + clearSshProfileEditor(true); + setSshProfileStatus('New direct session. Save creates a new entry.'); }; sshProfileEditRouteBtn.onclick = () => { editSshProfileRoute().catch(err => setSshProfileStatus(err.message || 'Profile could not be edited.', true)); diff --git a/tests/agent_browser_smoke.py b/tests/agent_browser_smoke.py index 5e2d1b9..c3caf26 100644 --- a/tests/agent_browser_smoke.py +++ b/tests/agent_browser_smoke.py @@ -4381,6 +4381,7 @@ def test_ssh_profile_picker_and_settings_save_semantics(browser, access_url): page.click('#quick-settings') page.click('.settings-nav-item[data-tab="ssh-sessions"]') + page.wait_for_function("() => !document.getElementById('ssh-profile-save').disabled") preloaded_editor = page.evaluate( """() => ({ name: document.getElementById('ssh-profile-name').value, @@ -4389,13 +4390,12 @@ def test_ssh_profile_picker_and_settings_save_semantics(browser, access_url): })""" ) check( - preloaded_editor == {'name': 'builder@build.example', 'summary': 'New direct session: builder@build.example:22', 'saveDisabled': True}, + preloaded_editor == {'name': 'builder@build.example', 'summary': 'New direct session: builder@build.example:22', 'saveDisabled': False}, 'SSH Settings did not preload the active SSH tab as a create-only draft', ) page.click('#ssh-profile-list button[data-profile-id="profile-a"]') - page.click('#ssh-profile-edit-route') page.get_by_label('Target Username', exact=True).fill('builder2') - page.get_by_role('button', name='Save route', exact=True).click() + page.click('#ssh-profile-save') page.wait_for_function( """() => document.getElementById('ssh-profile-status').innerText === 'Saved Build Server. Changes apply to the next connection.'""", timeout=5000, @@ -4414,11 +4414,11 @@ def test_ssh_profile_picker_and_settings_save_semantics(browser, access_url): check([profile['id'] for profile in reordered['profiles']] == ['profile-b', 'profile-a'], 'profile move used list index as identity') page.click('#ssh-profile-create') - page.get_by_label('Entry name', exact=True).fill('Build Server Copy') + page.fill('#ssh-profile-name', 'Build Server Copy') page.get_by_label('Target Host', exact=True).fill('build.example') page.get_by_label('Target Username', exact=True).fill('builder') page.get_by_label('Target Port', exact=True).fill('2222') - page.get_by_role('button', name='Save route', exact=True).click() + page.click('#ssh-profile-save') page.wait_for_function( """async () => (await window.terminalTest.getSshSessionState()).profiles.length === 3""", timeout=5000, @@ -4708,10 +4708,10 @@ def test_browser_ssh_key_lifecycle_and_settings_transfer(browser, access_url): page.click('.settings-nav-item[data-tab="ssh-sessions"]') page.click('#ssh-profile-list button[data-profile-id="profile-primary"]') page.click('#ssh-profile-create') - page.get_by_label('Entry name', exact=True).fill('Primary Copy') + page.fill('#ssh-profile-name', 'Primary Copy') page.get_by_label('Target Host', exact=True).fill('copy.example') page.get_by_label('Target Username', exact=True).fill('copy') - page.get_by_role('button', name='Save route', exact=True).click() + page.click('#ssh-profile-save') page.wait_for_function( """async () => (await window.terminalTest.getSshSessionState()).profiles .some(profile => profile.name === 'Primary Copy')""", diff --git a/tests/ssh_profile_context_browser_smoke.py b/tests/ssh_profile_context_browser_smoke.py index a5d694c..c4c26cf 100644 --- a/tests/ssh_profile_context_browser_smoke.py +++ b/tests/ssh_profile_context_browser_smoke.py @@ -19,6 +19,7 @@ def set_target(page, host, username, terminal_id='main'): def open_profiles(page): page.click('#quick-settings') page.click('.settings-nav-item[data-tab="ssh-sessions"]') + page.wait_for_function("() => !document.getElementById('ssh-profile-save').disabled") def editor(page): @@ -36,9 +37,7 @@ def test_new_connection_replaces_previous_editor_context(browser, url): set_target(page, 'first.example', 'first') open_profiles(page) page.fill('#ssh-profile-name', 'First profile') - page.click('#ssh-profile-create') - page.get_by_role('button', name='Save route', exact=True).click() - page.locator('#ssh-route-editor').wait_for(state='detached') + page.click('#ssh-profile-save') page.wait_for_function("() => document.getElementById('ssh-profile-status').textContent === 'Saved First profile. Changes apply to the next connection.'") first = page.evaluate('() => window.terminalTest.getSshSessionState()')['profiles'][0] page.click('#settings-close') @@ -46,15 +45,13 @@ def test_new_connection_replaces_previous_editor_context(browser, url): open_profiles(page) assert editor(page) == {'name': 'second@second.example', 'summary': 'New direct session: second@second.example:2222', - 'saveDisabled': True}, editor(page) + 'saveDisabled': False}, editor(page) # Reopening the same context preserves an unfinished draft. page.fill('#ssh-profile-name', 'Second draft') page.click('#settings-close') open_profiles(page) assert editor(page)['name'] == 'Second draft' - page.click('#ssh-profile-create') - page.get_by_role('button', name='Save route', exact=True).click() - page.locator('#ssh-route-editor').wait_for(state='detached') + page.click('#ssh-profile-save') page.wait_for_function("() => document.getElementById('ssh-profile-status').textContent === 'Saved Second draft. Changes apply to the next connection.'") state = page.evaluate('() => window.terminalTest.getSshSessionState()') assert len(state['profiles']) == 2 @@ -71,7 +68,7 @@ def test_new_connection_replaces_previous_editor_context(browser, url): page.click('.terminal-tab[data-terminal-id="first-tab"]') open_profiles(page) assert 'first@first.example:2222' in editor(page)['summary'], editor(page) - assert editor(page)['saveDisabled'] is True + assert editor(page)['saveDisabled'] is False # Explicit profile selection remains an edit until the context changes. page.click(f'#ssh-profile-list [data-profile-id="{first["id"]}"]') page.fill('#ssh-profile-name', 'Unfinished explicit edit') @@ -174,6 +171,8 @@ def test_route_management_preserves_full_path_and_failed_draft(browser, url): def test_connect_rejects_changed_form_during_storage_load(browser, url): context, page = fixture.new_page(browser, url) try: + page.click('#new-tab-btn') + page.evaluate('() => window.terminalTest.captureTerminalIoForTest()') show_ssh(page) page.fill('#host', 'first.test') page.fill('#username', 'first') @@ -200,6 +199,157 @@ def test_connect_rejects_changed_form_during_storage_load(browser, url): fixture.close_context(context) +def inline_fields(page): + return page.locator('#ssh-profile-node') + + +def wait_saved(page): + page.wait_for_function("() => document.getElementById('ssh-profile-status').textContent.endsWith('Changes apply to the next connection.')") + + +def test_inline_direct_save_preserves_shared_nodes_and_order(browser, url): + context, page = fixture.new_page(browser, url) + try: + show_ssh(page) + page.evaluate("""async () => { + await window.terminalTest.setSshSessionState({profiles: [ + {id: 'direct', name: 'Direct', host: 'old.test', port: '22', username: 'u'} + ], history: []}); + const state = await window.terminalTest.getSshSessionState(); + const target = state.nodes[0]; + state.nodes.push({...structuredClone(target), id: 'jump', + endpoint: {host: 'jump.test', port: '22', username: 'j'}, nextNodeId: target.id}); + state.profiles.push({...state.profiles[0], id: 'shared', name: 'Shared route', + sortOrder: 1, startNodeId: 'jump'}); + await window.terminalTest.setSshSessionState(state); + }""") + original = saved_state(page) + open_profiles(page) + page.click('#ssh-profile-list [data-profile-id="direct"]') + fields = inline_fields(page) + fields.get_by_label('Target Host', exact=True).fill('edited.test') + page.fill('#ssh-profile-name', 'Edited Direct') + fields.locator('summary').click() + fields.get_by_label('Target Host key alias (optional)', exact=True).fill('lab') + fields.get_by_label('Target Use key', exact=True).check() + page.wait_for_function("() => document.querySelector('#ssh-profile-node [aria-label=\"Target Public key\"]').value.startsWith('ssh-ed25519 ')") + public_key = fields.get_by_label('Target Public key', exact=True).input_value() + page.click('#ssh-profile-down') + page.wait_for_function("() => document.getElementById('ssh-profile-status').textContent === 'Profile order updated.'") + assert fields.get_by_label('Target Host', exact=True).input_value() == 'edited.test' + page.click('#ssh-profile-save') + wait_saved(page) + state = saved_state(page) + assert [item['id'] for item in state['profiles']] == ['shared', 'direct'] + for old in original['nodes']: + assert next(node for node in state['nodes'] if node['id'] == old['id']) == old + path = page.evaluate("""async () => { + const state = await window.terminalTest.getSshSessionState(); + return StandTermSshRoutes.checkedPath(state, state.profiles.find(p => p.id === 'direct')); + }""") + assert path[0]['endpoint']['host'] == 'edited.test' and path[0]['hostKeyAlias'] == 'lab' + assert path[0]['authentication']['keyRef']['kind'] == 'credential' + page.click('#settings-close') + page.reload(wait_until='domcontentloaded') + page.wait_for_function('() => !!window.terminalTest') + open_profiles(page) + page.click('#ssh-profile-list [data-profile-id="direct"]') + fields.get_by_label('Target Host', exact=True).wait_for() + assert fields.get_by_label('Target Use key', exact=True).is_checked() + assert fields.get_by_label('Target Public key', exact=True).input_value() == public_key + finally: + fixture.close_context(context) + + +def test_inline_draft_promotes_to_route_without_saving_on_cancel(browser, url): + context, page = fixture.new_page(browser, url) + try: + show_ssh(page) + page.evaluate('() => window.terminalTest.setSshSessionState({profiles: [], history: []})') + open_profiles(page) + fields = inline_fields(page) + page.fill('#ssh-profile-name', 'New draft') + fields.get_by_label('Target Host', exact=True).fill('target.test') + fields.get_by_label('Target Username', exact=True).fill('user') + fields.get_by_label('Target Port', exact=True).fill('2222') + fields.get_by_label('Target Use key', exact=True).check() + page.wait_for_function("() => document.querySelector('#ssh-profile-node [aria-label=\"Target Public key\"]').value.startsWith('ssh-ed25519 ')") + public_key = fields.get_by_label('Target Public key', exact=True).input_value() + original = saved_state(page) + page.click('#ssh-profile-edit-route') + modal = page.locator('#ssh-route-editor') + assert modal.get_by_label('Entry name', exact=True).input_value() == 'New draft' + assert modal.get_by_label('Jump 1 Host', exact=True).input_value() == '' + open_card(page, 'Target') + assert modal.get_by_label('Target Port', exact=True).input_value() == '2222' + assert modal.get_by_label('Target Public key', exact=True).input_value() == public_key + modal.get_by_role('button', name='Cancel', exact=True).click() + assert saved_state(page) == original + assert fields.get_by_label('Target Public key', exact=True).input_value() == public_key + # Incomplete inline edits can be completed after opening the full editor. + fields.get_by_label('Target Host', exact=True).fill('') + page.click('#ssh-profile-edit-route') + modal.get_by_label('Jump 1 Host', exact=True).fill('jump.test') + modal.get_by_label('Jump 1 Username', exact=True).fill('jumper') + open_card(page, 'Target') + modal.get_by_label('Target Host', exact=True).fill('target.test') + assert modal.get_by_label('Target Public key', exact=True).input_value() == public_key + modal.get_by_role('button', name='Save route', exact=True).click() + modal.wait_for(state='detached') + wait_saved(page) + state = saved_state(page) + assert len(state['profiles']) == 1 and state['profiles'][0]['name'] == 'New draft' + assert len(state['nodes']) == 2, state + assert fields.is_hidden() + assert page.locator('#ssh-profile-save').inner_text() == 'Save name' + assert 'jump.test' in editor(page)['summary'] and 'target.test' in editor(page)['summary'] + finally: + fixture.close_context(context) + + +def test_inline_conflict_retains_fields_and_does_not_store_key(browser, url): + context, page = fixture.new_page(browser, url) + try: + show_ssh(page) + page.evaluate("() => window.terminalTest.setSshSessionState({profiles:[{id:'direct',name:'Direct',host:'host.test',port:'22',username:'u'}],history:[]})") + open_profiles(page) + page.click('#ssh-profile-list [data-profile-id="direct"]') + fields = inline_fields(page) + fields.get_by_label('Target Host', exact=True).fill('unsaved.test') + fields.get_by_label('Target Use key', exact=True).check() + page.wait_for_function("() => document.querySelector('#ssh-profile-node [aria-label=\"Target Public key\"]').value.startsWith('ssh-ed25519 ')") + other = context.new_page() + other.goto(fixture.debug_url(url), wait_until='domcontentloaded') + other.wait_for_function('() => !!window.terminalTest') + other.evaluate("""async () => { + const state = await window.terminalTest.getSshSessionState(); + state.profiles[0].name = 'Other window'; + await window.terminalTest.setSshSessionState(state); + }""") + concurrent = saved_state(other) + page.evaluate("""() => { + const save = StandTermSshRoutes.save; + StandTermSshRoutes.save = (...args) => { + StandTermSshRoutes.save = save; + window.failedKeyIds = args[2].map(change => change.record.keyId); + return save(...args); + }; + }""") + page.click('#ssh-profile-save') + page.wait_for_function("() => document.getElementById('ssh-profile-status').textContent.includes('changed in another window')") + assert page.locator('#ssh-profile-save').is_enabled() + assert fields.get_by_label('Target Host', exact=True).input_value() == 'unsaved.test' + assert fields.get_by_label('Target Use key', exact=True).is_checked() + key_ids = page.evaluate('() => window.failedKeyIds') + assert len(key_ids) == 1 + assert not page.evaluate('id => window.terminalTest.browserSshKeyRecordExistsForTest(id)', key_ids[0]) + other.reload(wait_until='domcontentloaded') + other.wait_for_function('() => !!window.terminalTest') + assert saved_state(other) == concurrent + finally: + fixture.close_context(context) + + if __name__ == '__main__': server, url = fixture.start_server() try: @@ -212,6 +362,12 @@ def test_connect_rejects_changed_form_during_storage_load(browser, url): print('SSH route management: PASS', flush=True) test_connect_rejects_changed_form_during_storage_load(browser, url) print('SSH preparation context: PASS', flush=True) + test_inline_direct_save_preserves_shared_nodes_and_order(browser, url) + print('Inline Direct save and references: PASS', flush=True) + test_inline_draft_promotes_to_route_without_saving_on_cancel(browser, url) + print('Inline Direct route promotion: PASS', flush=True) + test_inline_conflict_retains_fields_and_does_not_store_key(browser, url) + print('Inline Direct save conflict: PASS', flush=True) finally: browser.close() finally: diff --git a/tests/ssh_routes_browser_smoke.py b/tests/ssh_routes_browser_smoke.py index 385c0e5..491f591 100644 --- a/tests/ssh_routes_browser_smoke.py +++ b/tests/ssh_routes_browser_smoke.py @@ -257,7 +257,7 @@ def test_receiver_and_owner_renames_preserve_credentials(browser, url): page.click('#ssh-profile-list button[data-profile-id="receiver"]') page.fill('#ssh-profile-name', 'Receiver renamed') page.click('#ssh-profile-save') - page.wait_for_function("() => document.getElementById('ssh-profile-status').innerText === 'Saved Receiver renamed.'") + page.wait_for_function("() => document.getElementById('ssh-profile-status').innerText === 'Saved Receiver renamed. Changes apply to the next connection.'") after = page.evaluate('() => window.terminalTest.getSshSessionState()') assert before['nodes'] == after['nodes'], 'Renaming the receiver modified route nodes' page.evaluate("""async () => { From ef672e7c8f9139f8d8df59ae93c731f1e0fb680a Mon Sep 17 00:00:00 2001 From: "ASKA C." Date: Wed, 16 Sep 2026 09:42:56 +0800 Subject: [PATCH 08/43] Preview terminal color schemes in Settings --- templates/index.html | 42 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 42 insertions(+) diff --git a/templates/index.html b/templates/index.html index 0dcdd13..03f1c57 100644 --- a/templates/index.html +++ b/templates/index.html @@ -368,6 +368,10 @@ .settings-row:last-child { border-bottom: none; } .settings-row label { flex: 1; color: #ddd; cursor: pointer; } .settings-row input, .settings-row select { background: #2c2c2e; border: 1px solid #555; color: #fff; padding: 4px 8px; border-radius: 4px; font-size: 12px; outline: none; } + .settings-theme-preview { margin: 8px 0; padding: 12px; border: 1px solid #555; border-radius: 5px; font: 12px/1.6 Consolas, "Courier New", monospace; overflow-wrap: anywhere; } + .settings-theme-cursor { display: inline-block; width: 0.6em; height: 1.1em; vertical-align: text-bottom; } + .settings-theme-palette { display: grid; grid-template-columns: 42px repeat(8, minmax(0, 1fr)); gap: 4px; align-items: center; margin-top: 8px; font-size: 10px; } + .settings-theme-swatch { height: 14px; border: 1px solid currentColor; border-radius: 2px; } .settings-value { color: #f2f2f2; text-align: right; overflow-wrap: anywhere; max-width: 58%; } .settings-status { color: #aaa; font-size: 12px; line-height: 1.4; margin-bottom: 10px; overflow-wrap: anywhere; } .settings-list { display: grid; gap: 6px; margin: 8px 0 0; padding: 0; list-style: none; color: #ddd; } @@ -1032,6 +1036,14 @@

Settings

+
+
user@host:~$ ls
+
README.md  logs/  config.json
+
Ready  Warning  Error
+
$
+
+
+
Preview only. Save Changes applies this theme.
@@ -11271,6 +11283,35 @@

Recover StandTerm session

} const settingsModal = document.getElementById('settings-modal'); + function renderColorSchemePreview() { + const theme = SCHEMES[document.getElementById('pref-colorScheme').value] || SCHEMES.vintage; + const preview = document.getElementById('settings-theme-preview'); + preview.style.backgroundColor = theme.background; + preview.style.color = theme.foreground; + preview.style.fontFamily = getTerminalFontFace(); + preview.querySelectorAll('[data-theme-color]').forEach(element => { + element.style.color = theme[element.dataset.themeColor]; + }); + preview.querySelector('.settings-theme-cursor').style.backgroundColor = theme.cursor; + const palette = preview.querySelector('.settings-theme-palette'); + palette.replaceChildren(); + for (const bright of [false, true]) { + const label = document.createElement('span'); + label.textContent = bright ? 'Bright' : 'Normal'; + palette.appendChild(label); + for (const color of ['black', 'red', 'green', 'yellow', 'blue', 'magenta', 'cyan', 'white']) { + const key = bright ? `bright${color[0].toUpperCase()}${color.slice(1)}` : color; + const swatch = document.createElement('span'); + swatch.className = 'settings-theme-swatch'; + swatch.style.backgroundColor = theme[key]; + swatch.title = `${bright ? 'Bright ' : ''}${color}: ${theme[key]}`; + swatch.setAttribute('role', 'img'); + swatch.setAttribute('aria-label', swatch.title); + palette.appendChild(swatch); + } + } + } + document.getElementById('pref-colorScheme').onchange = renderColorSchemePreview; const openSettings = () => { document.getElementById('pref-useCustomMenu').checked = prefs.useCustomMenu; document.getElementById('pref-copyOnSelect').checked = prefs.copyOnSelect; @@ -11280,6 +11321,7 @@

Recover StandTerm session

document.getElementById('pref-cjkWideAmbiguous').checked = prefs.cjkWideAmbiguous; document.getElementById('pref-urlClickAction').value = prefs.urlClickAction; document.getElementById('pref-colorScheme').value = prefs.colorScheme; + renderColorSchemePreview(); document.getElementById('pref-fontFace').value = prefs.fontFace; document.getElementById('pref-powerlineSymbols').checked = prefs.powerlineSymbols; document.getElementById('pref-fontSize').value = prefs.fontSize; From 8d84f878a6b8836353253bdad0dc294788911638 Mon Sep 17 00:00:00 2001 From: "ASKA C." Date: Wed, 16 Sep 2026 11:45:21 +0800 Subject: [PATCH 09/43] Advertise terminal RGB capabilities without changing TERM ## Why tmux cannot infer StandTerm RGB support from COLORTERM alone and quantizes truecolor output when its client capabilities omit RGB. ## What changed - Answer bounded XTVERSION and XTGETTCAP queries with truthful capabilities. - Isolate automatic replies from human input and deduplicate live viewers. - Add a private POSIX terminfo overlay while preserving explicit overrides. - Document the explicit RGB fallback required by remote tmux 3.4. ## Testing Cover protocol bounds, authorization, replay, multiple viewers and overrides. Verify actual RGB cells through Chromium with local and SSH tmux 3.4 clients, including existing sessions, plus the complete 168-test backend smoke suite. --- README.md | 20 +++ app.py | 18 +++ static/js/standterm-capabilities.js | 74 +++++++++ templates/index.html | 23 ++- terminal_backends/base.py | 27 +++- terminal_backends/local_shell.py | 2 + terminal_backends/terminfo.py | 59 ++++++++ terminal_capabilities.py | 37 +++++ tests/agent_backend_smoke.py | 30 ++++ tests/terminal_capabilities_browser_smoke.py | 150 +++++++++++++++++++ tests/terminal_capabilities_smoke.cjs | 52 +++++++ tests/terminal_capabilities_smoke.py | 125 ++++++++++++++++ tests/terminal_capabilities_ssh_smoke.py | 112 ++++++++++++++ 13 files changed, 721 insertions(+), 8 deletions(-) create mode 100644 static/js/standterm-capabilities.js create mode 100644 terminal_backends/terminfo.py create mode 100644 terminal_capabilities.py create mode 100644 tests/terminal_capabilities_browser_smoke.py create mode 100644 tests/terminal_capabilities_smoke.cjs create mode 100644 tests/terminal_capabilities_smoke.py create mode 100644 tests/terminal_capabilities_ssh_smoke.py diff --git a/README.md b/README.md index cf07272..279a537 100644 --- a/README.md +++ b/README.md @@ -277,6 +277,26 @@ xterm.js 24-bit color support without requiring a less widely installed terminfo entry. SSH sessions continue to request the compatible `xterm-256color` PTY; remote environment-variable propagation remains controlled by the SSH server. +On POSIX hosts with `infocmp` and `tic`, Local Shell adds RGB flags to a private +copy of the effective `xterm-256color` terminfo entry. This lets ordinary `tmux` +clients detect truecolor without changing your tmux configuration or installed +terminfo database. Explicit `TERMINFO` / `TERMINFO_DIRS` overrides are preserved; +missing tools or compilation errors retain the normal shell environment. The +private entry remains in temporary storage for detached processes to use. +Native Windows shells do not use this overlay. + +StandTerm also answers XTVERSION as `StandTerm()` and XTGETTCAP +queries for `TN` / `name`, `Co` / `colors` (256 indexed colors), `RGB` (8 bits +per component), and `Tc`. These replies do not advertise unsupported clipboard, +extended-keyboard, or margin capabilities. + +Remote tmux 3.4 does not discover RGB from these queries. On such hosts, use +`tmux -T RGB` (or `tmux -T RGB attach` for an existing session). For tmux 3.2+ +you can instead opt into `set -as terminal-features ',xterm-256color:RGB'` in +your own tmux configuration and reattach. That setting applies to every client +using that TERM, including other terminal applications. StandTerm does not +modify remote configuration or install remote terminfo automatically. + Windows Local Shell uses pywinpty 3.0.5 to avoid the fixed per-read delay in the older 2.x backend. The launchers refresh dependencies when `requirements.txt` changes; an existing running server must be restarted to use the new dependency. diff --git a/app.py b/app.py index ece18c7..73a9148 100644 --- a/app.py +++ b/app.py @@ -25,6 +25,7 @@ from agent_tunnel import AgentTunnel, tunnel_ingress from ssh_tunnels import UserTunnel, parse_tunnel_spec, USER_TUNNEL_MAX_ACTIVE, USER_TUNNEL_MAX_RECORDS from core_version import CORE_VERSION +from terminal_capabilities import build_capability_response from flask import Flask, Response, render_template, request, abort, make_response, redirect, send_file, jsonify, stream_with_context from flask_socketio import SocketIO, ConnectionRefusedError from external_agent_dispatch import ExternalAgentCommandDispatcher @@ -11372,6 +11373,23 @@ def on_ssh_login_response(data): return bridge.resolve_login_input(request.sid, data) +@socketio.on('terminal_capability_query') +def on_terminal_capability_query(data): + session_token = socket_session_tokens.get(request.sid) + terminal_id = validate_terminal_id_payload(data) + if not session_token or not terminal_id: + return + bridge = get_allowed_bridge(session_token, terminal_id, request.sid) + if not bridge or request.sid not in bridge.attached_sids: + return + response = build_capability_response(data.get('kind'), data.get('names')) + if response: + query_identity = (data.get('kind'), data.get('names') if data.get('kind') == 'terminfo' else None) + bridge.write_capability_response(data.get('capability_epoch'), data.get('output_seq'), + data.get('query_index'), response, + query_identity=query_identity) + + @socketio.on('ssh_input') def on_ssh_input(data): session_token = socket_session_tokens.get(request.sid) diff --git a/static/js/standterm-capabilities.js b/static/js/standterm-capabilities.js new file mode 100644 index 0000000..394d082 --- /dev/null +++ b/static/js/standterm-capabilities.js @@ -0,0 +1,74 @@ +(function (root, factory) { + const api = factory(); + if (typeof module === 'object' && module.exports) module.exports = api; + else root.StandTermCapabilities = api; +})(typeof globalThis !== 'undefined' ? globalThis : this, function () { + 'use strict'; + + class CapabilityAddon { + constructor(sendQuery) { + this.sendQuery = sendQuery; + this.queue = []; + this.current = null; + this.disposed = false; + } + + activate(term) { + this.term = term; + this.handlers = [ + term.parser.registerCsiHandler({ prefix: '>', final: 'q' }, params => { + if (params.length !== 1 || params[0] !== 0) return false; + this.query('version'); + return true; + }), + term.parser.registerDcsHandler({ intermediates: '+', final: 'q' }, (names, params) => { + if (params.length !== 1 || params[0] !== 0) return false; + // The server validates names and constructs the reply; never send raw input. + if (names.length <= 1024) this.query('terminfo', names); + return true; + }) + ]; + } + + query(kind, names) { + const item = this.current; + if (!item) return; + if (item.queryCount++ >= 32) return; + const signature = JSON.stringify([kind, names]); + const queryIndex = item.queries.get(signature) || 0; + item.queries.set(signature, queryIndex + 1); + const context = item.context; + if (this.disposed || !context || context.replay || !context.capability_epoch) return; + this.sendQuery({ kind, names, output_seq: context.output_seq, + capability_epoch: context.capability_epoch, query_index: queryIndex }); + } + + write(data, context, callback) { + if (this.disposed) return; + this.queue.push({ data, context, callback, queries: new Map(), queryCount: 0 }); + this.drain(); + } + + drain() { + if (this.disposed || this.current || !this.queue.length) return; + const item = this.current = this.queue.shift(); + const epoch = item.context?.capability_epoch; + const cancel = epoch && this.epoch && epoch !== this.epoch ? '\x18' : ''; + if (epoch) this.epoch = epoch; + // Preserve source metadata until xterm has finished parsing this write. + this.term.write(cancel + item.data, () => { + this.current = null; + if (!this.disposed && item.callback) item.callback(); + this.drain(); + }); + } + + dispose() { + this.disposed = true; + this.queue.length = 0; + for (const handler of this.handlers || []) handler.dispose(); + } + } + + return CapabilityAddon; +}); diff --git a/templates/index.html b/templates/index.html index 03f1c57..37dd745 100644 --- a/templates/index.html +++ b/templates/index.html @@ -1501,6 +1501,7 @@

Recover StandTerm session

+ @@ -6514,6 +6515,11 @@

Recover StandTerm session

}; enableWebglRenderer(state); terminals.set(terminalId, state); + state.capabilityAddon = new window.StandTermCapabilities(query => { + if (!isCurrentTerminalState(state) || !socket.connected || !state.connected) return; + socket.emit('terminal_capability_query', { terminal_id: state.id, ...query }); + }); + term.loadAddon(state.capabilityAddon); term.onTitleChange(value => { if (!isCurrentTerminalState(state)) return; const title = normalizeTerminalApplicationTitle(value); @@ -6760,8 +6766,10 @@

Recover StandTerm session

captureTerminalIo = true; socket.off('ssh_output', handleSshOutputPayload); const state = getActiveTerminalState(); - await Promise.all([state?.term, state?.agentTerminalMirror].filter(Boolean) - .map(term => new Promise(resolve => term.write('', resolve)))); + await Promise.all([ + state && new Promise(resolve => state.capabilityAddon.write('', null, resolve)), + state?.agentTerminalMirror && new Promise(resolve => state.agentTerminalMirror.write('', resolve)) + ].filter(Boolean)); }, clearEmitted() { emitted.length = 0; @@ -6870,7 +6878,7 @@

Recover StandTerm session

if (state.agentTerminalMirror) state.agentTerminalMirror.options.fontSize = Number(fontSize); return true; }, - getActiveTerminalBufferCellsForTest(row, useMirror = false) { + getActiveTerminalBufferCellsForTest(row, useMirror = false, includeColors = false) { const state = getActiveTerminalState(); const term = state && useMirror ? state.agentTerminalMirror : (state && state.term); const activeBuffer = term && term.buffer && term.buffer.active; @@ -6881,7 +6889,8 @@

Recover StandTerm session

const cells = []; for (let column = 0; column < term.cols; column += 1) { const cell = line.getCell(column); - cells.push(cell ? { chars: cell.getChars(), width: cell.getWidth() } : null); + cells.push(cell ? { chars: cell.getChars(), width: cell.getWidth(), + ...(includeColors ? { fg: cell.getFgColor(), fgRgb: !!cell.isFgRGB() } : {}) } : null); } return cloneForTest(cells); }, @@ -8641,10 +8650,10 @@

Recover StandTerm session

hideActionPrompt(); resetConnectButton(); }; - function writeTerminalOutput(state, data, outputSeq) { + function writeTerminalOutput(state, data, outputSeq, context = null) { if (typeof data !== 'string') return; const scheduleAfterMirror = writeAgentTerminalMirror(state, data, outputSeq, () => scheduleAgentViewportSnapshot(state)); - state.term.write(data, scheduleAfterMirror ? undefined : () => scheduleAgentViewportSnapshot(state)); + state.capabilityAddon.write(data, context, scheduleAfterMirror ? undefined : () => scheduleAgentViewportSnapshot(state)); } function handleSshOutputPayload(data) { if (!data || typeof data !== 'object') return; @@ -8809,7 +8818,7 @@

Recover StandTerm session

state.lastOutputSeq = data.output_seq; } if (typeof data.data === 'string') { - writeTerminalOutput(state, data.data, data.output_seq); + writeTerminalOutput(state, data.data, data.output_seq, data); } break; default: diff --git a/terminal_backends/base.py b/terminal_backends/base.py index 3eb48e5..38753ae 100644 --- a/terminal_backends/base.py +++ b/terminal_backends/base.py @@ -1,10 +1,13 @@ import re +import secrets import threading import time from collections import deque from dataclasses import dataclass from typing import Any, Optional +from terminal_capabilities import CAPABILITY_RESPONSE_WINDOW, MAX_CAPABILITY_QUERIES_PER_OUTPUT + @dataclass(frozen=True) class BackendPolicyContext: @@ -199,6 +202,8 @@ def __init__(self, owner_session, terminal_id, runtime=None): self.cols = 80 self.rows = 24 self.output_seq = 0 + self.capability_epoch = secrets.token_urlsafe(12) + self.capability_responses = {} self.last_output_at = None self.replay_buffer = deque() self.replay_buffer_bytes = 0 @@ -262,6 +267,7 @@ def emit_output(self, payload): self.output_seq += 1 self.last_output_at = time.time() payload.setdefault('output_seq', self.output_seq) + payload['capability_epoch'] = self.capability_epoch self._remember_terminal_payload(payload) self.runtime.append_transcript( self.owner_session, @@ -297,7 +303,26 @@ def _remember_terminal_payload(self, payload): def replay_to(self, sid): for payload in list(self.replay_buffer): - self.runtime.emit_socket('ssh_output', payload, room=sid) + self.runtime.emit_socket('ssh_output', dict(payload, replay=True), room=sid) + + def write_capability_response(self, epoch, output_seq, query_index, response, *, query_identity=None): + with self.input_lock: + earliest = max(1, self.output_seq - CAPABILITY_RESPONSE_WINDOW + 1) + if (epoch != self.capability_epoch or type(output_seq) is not int + or not earliest <= output_seq <= self.output_seq + or type(query_index) is not int + or not 0 <= query_index < MAX_CAPABILITY_QUERIES_PER_OUTPUT + or self.closing): + return + self.capability_responses = {seq: indices for seq, indices in self.capability_responses.items() + if seq >= earliest} + indices = self.capability_responses.setdefault(output_seq, set()) + # Include the reply identity: a viewer may have joined after a split query's prefix. + key = (query_identity if query_identity is not None else response, query_index) + if key in indices or len(indices) >= MAX_CAPABILITY_QUERIES_PER_OUTPUT: + return + indices.add(key) + self.write(response) def read_loop(self): raise NotImplementedError diff --git a/terminal_backends/local_shell.py b/terminal_backends/local_shell.py index 503caa8..772066a 100644 --- a/terminal_backends/local_shell.py +++ b/terminal_backends/local_shell.py @@ -9,6 +9,7 @@ from pathlib import Path from .base import BackendSettingSchema, BackendStartFieldSchema, TerminalBackendPlugin, TerminalBridge +from .terminfo import add_local_rgb_terminfo from runtime_logging import log_message try: @@ -701,6 +702,7 @@ def _build_process_environment(self): env['TERM'] = self._ssh_term env['COLORTERM'] = 'truecolor' env['TERM_PROGRAM'] = 'StandTerm' + add_local_rgb_terminfo(env) return env def _connect_windows(self, cols, rows): diff --git a/terminal_backends/terminfo.py b/terminal_backends/terminfo.py new file mode 100644 index 0000000..c5bce2f --- /dev/null +++ b/terminal_backends/terminfo.py @@ -0,0 +1,59 @@ +"""A process-scoped RGB hint without changing TERM or installed terminfo.""" + +import functools +import os +import re +import shutil +import subprocess +import sys +import tempfile +from pathlib import Path + + +TERMINFO_TOOL_TIMEOUT = 2 + + +@functools.lru_cache(maxsize=4) +def _rgb_terminfo(term, search_path, home): + infocmp = shutil.which('infocmp', path=search_path) + tic = shutil.which('tic', path=search_path) + if not infocmp or not tic: + return None + environment = dict(os.environ, HOME=home, PATH=search_path) + environment.pop('TERMINFO', None) + environment.pop('TERMINFO_DIRS', None) + directory = None + try: + def run(command, **kwargs): + return subprocess.run(command, env=environment, text=True, capture_output=True, + check=True, timeout=TERMINFO_TOOL_TIMEOUT, **kwargs).stdout + + source = run([infocmp, '-x', '-1', term]) + if re.search(r'^\s+(?:RGB|Tc)[,=#]', source, re.MULTILINE): + return None + lines = source.splitlines(keepends=True) + header = next(index for index, line in enumerate(lines) if line and not line.startswith('#')) + lines.insert(header + 1, '\tRGB, Tc,\n') + # Keep this private directory after Core exits: detached children may still use it. + directory = tempfile.mkdtemp(prefix='standterm-terminfo-') + source_path = Path(directory) / 'source.ti' + source_path.write_text(''.join(lines), encoding='utf-8') + run([tic, '-x', '-o', directory, str(source_path)]) + compiled = run([infocmp, '-A', directory, '-x', '-1', term]) + if not re.search(r'^\s+RGB,', compiled, re.MULTILINE): + raise ValueError('Compiled terminfo does not advertise RGB.') + return directory + except (OSError, subprocess.SubprocessError, ValueError, StopIteration): + if directory: + shutil.rmtree(directory, ignore_errors=True) + return None + + +def add_local_rgb_terminfo(environment): + if (sys.platform.startswith('win') or environment.get('TERM') != 'xterm-256color' + or 'TERMINFO' in environment or 'TERMINFO_DIRS' in environment): + return + directory = _rgb_terminfo(environment['TERM'], environment.get('PATH', os.defpath), + environment.get('HOME', str(Path.home()))) + if directory: + environment['TERMINFO'] = directory diff --git a/terminal_capabilities.py b/terminal_capabilities.py new file mode 100644 index 0000000..75e8489 --- /dev/null +++ b/terminal_capabilities.py @@ -0,0 +1,37 @@ +"""Bounded, read-only replies for the capabilities of the shipped terminal.""" + +import re + +from core_version import CORE_VERSION + + +CAPABILITY_RESPONSE_WINDOW = 256 +MAX_CAPABILITY_QUERIES_PER_OUTPUT = 32 +MAX_CAPABILITY_NAMES = 16 +MAX_CAPABILITY_REQUEST_LENGTH = 1024 + + +def build_capability_response(kind, names=None): + if kind == 'version': + return f'\x1bP>|StandTerm({CORE_VERSION})\x1b\\' + if kind != 'terminfo' or not isinstance(names, str): + return None + if not names or len(names) > MAX_CAPABILITY_REQUEST_LENGTH: + return None + encoded_names = names.split(';') + if len(encoded_names) > MAX_CAPABILITY_NAMES: + return None + capabilities = {'TN': 'xterm-256color', 'name': 'xterm-256color', + 'Co': '256', 'colors': '256', 'RGB': '8', 'Tc': None} + replies = [] + for encoded_name in encoded_names: + if not re.fullmatch(r'(?:[0-9a-fA-F]{2}){1,64}', encoded_name): + return None + name = bytes.fromhex(encoded_name).decode('ascii', errors='replace') + if name not in capabilities: + replies.append('\x1bP0+r\x1b\\') + break + value = capabilities[name] + encoded_value = '' if value is None else '=' + value.encode('ascii').hex() + replies.append(f'\x1bP1+r{encoded_name}{encoded_value}\x1b\\') + return ''.join(replies) diff --git a/tests/agent_backend_smoke.py b/tests/agent_backend_smoke.py index c14dbb9..bfdc888 100644 --- a/tests/agent_backend_smoke.py +++ b/tests/agent_backend_smoke.py @@ -8212,6 +8212,35 @@ def test_terminal_bridge_tracks_shared_session_metadata(): assert metadata['terminal_quiet_ms'] >= 0 +def test_capability_queries_are_authorized_and_not_human_input(): + client = make_client() + session_token = current_session_token() + bridge = add_dummy_bridge(session_token) + sid = current_sid_for_session(session_token) + bridge.attach(sid) + bridge.emit_output({'message_type': 'terminal', 'data': '\x1b[>q'}) + query = {'terminal_id': standterm.TERMINAL_ID_MAIN, 'kind': 'version', + 'capability_epoch': bridge.capability_epoch, 'output_seq': 1, 'query_index': 0} + with patch.object(standterm, 'note_agent_human_input_for_terminal') as human_input: + client.emit('terminal_capability_query', query) + client.emit('terminal_capability_query', query) + human_input.assert_not_called() + assert len(bridge.writes) == 1 + assert 'StandTerm(' in bridge.writes[0] + assert not standterm.agent_user_input_metadata_store.get_recent(session_token, standterm.TERMINAL_ID_MAIN) + client.emit('terminal_capability_query', dict(query, kind='input', names='whoami\n', query_index=1)) + client.emit('terminal_capability_query', dict(query, capability_epoch='old-bridge', query_index=1)) + with patch.object(standterm, 'is_terminal_bridge_allowed_for_sid', return_value=False): + client.emit('terminal_capability_query', dict(query, query_index=1)) + bridge.detach(sid) + client.emit('terminal_capability_query', dict(query, query_index=1)) + assert len(bridge.writes) == 1 + client.emit('ssh_input', {'terminal_id': standterm.TERMINAL_ID_MAIN, 'data': 'real input'}) + assert bridge.writes[-1] == 'real input' + assert len(standterm.agent_user_input_metadata_store.get_recent(session_token, standterm.TERMINAL_ID_MAIN)) == 1 + client.disconnect() + + def test_ssh_input_records_agent_metadata_after_validation(): client = make_client() session_token = current_session_token() @@ -8683,6 +8712,7 @@ def main(): test_terminal_bridge_tracks_shared_session_metadata, test_ssh_target_metadata_is_structured_and_access_scoped, test_ssh_input_records_agent_metadata_after_validation, + test_capability_queries_are_authorized_and_not_human_input, test_agent_input_metadata_bounds_and_sanitized_preview, test_privacy_state_blocks_agent_context_and_redacts_input_metadata, test_ssh_input_does_not_record_invalid_or_oversized_metadata, diff --git a/tests/terminal_capabilities_browser_smoke.py b/tests/terminal_capabilities_browser_smoke.py new file mode 100644 index 0000000..ca30c20 --- /dev/null +++ b/tests/terminal_capabilities_browser_smoke.py @@ -0,0 +1,150 @@ +"""Verify real tmux output through an isolated Core and the shipped browser terminal.""" + +import os +import json +from pathlib import Path +import shlex +import shutil +import subprocess +import sys +import tempfile +import time +import textwrap + +import agent_browser_smoke as fixture + + +def send(page, text): + page.evaluate("text => window.terminalTest.emitSocket('ssh_input', {terminal_id: 'main', data: text})", text) + + +def client_info(socket_path): + result = subprocess.run(['tmux', '-S', str(socket_path), 'list-clients', '-F', + '#{client_termname}|#{client_termtype}|#{client_termfeatures}'], + capture_output=True, text=True) + return result.stdout.strip() + + +def run_case(page, directory, name, *, baseline=False, explicit=False, existing=False): + socket_path = directory / name + command = ['tmux', '-S', str(socket_path), '-f', '/dev/null'] + if explicit: + command += ['-T', 'RGB'] + if existing: + environment = dict(os.environ, TERM='xterm-256color') + environment.pop('TERMINFO', None) + environment.pop('TERMINFO_DIRS', None) + subprocess.run(command + ['new-session', '-d', '-s', 'probe', '/bin/sh'], + env=environment, check=True) + command += ['attach-session', '-t', 'probe'] if existing else ['new-session', '-s', 'probe', '/bin/sh'] + prefix = 'env -u TERMINFO -u TERMINFO_DIRS ' if baseline else '' + try: + send(page, prefix + shlex.join(command) + '\r') + deadline = time.monotonic() + 10 + info = '' + while time.monotonic() < deadline: + info = client_info(socket_path) + if 'StandTerm(' in info: + break + page.wait_for_timeout(100) + fixture.check('StandTerm(' in info, f'{name}: missing real XTVERSION reply: {info}') + expect_rgb = not baseline or explicit + fixture.check(('RGB' in info.split('|')[-1].split(',')) == expect_rgb, f'{name}: {info}') + send(page, "printf '\\033[2J\\033[H\\033[38;2;12;34;56mRGB_PROBE\\033[0m\\n'\r") + page.wait_for_function("() => window.terminalTest.getActiveTerminalBufferCellsForTest(0)?.slice(0, 9).map(c => c.chars).join('') === 'RGB_PROBE'") + cell = page.evaluate('() => window.terminalTest.getActiveTerminalBufferCellsForTest(0, false, true)[0]') + fixture.check((cell['fgRgb'] and cell['fg'] == 0x0c2238) == expect_rgb, + f'{name}: RGB preservation mismatch: {cell}') + print(f'{name}: {info}; rendered cell={cell}', flush=True) + finally: + subprocess.run(['tmux', '-S', str(socket_path), 'kill-server'], capture_output=True) + page.wait_for_timeout(250) + + +def check_query_replay(context, page, directory): + script = directory / 'query_probe.py' + result = directory / 'query_result.json' + ready = directory / 'query_ready' + finish = directory / 'query_finish' + again = directory / 'query_again' + script.write_text(textwrap.dedent(r''' + import json, os, select, sys, termios, time, tty + from pathlib import Path + fd = os.open('/dev/tty', os.O_RDWR) + previous = termios.tcgetattr(fd) + replies = b'' + repeated = False + try: + tty.setraw(fd) + os.write(fd, b'\x1b[>q\x1bP+q524742\x1b\\') + deadline = time.monotonic() + 12 + while time.monotonic() < deadline: + if select.select([fd], [], [], 0.05)[0]: + replies += os.read(fd, 4096) + if replies.count(b'\x1b\\') >= 2: + Path(sys.argv[2]).touch() + if Path(sys.argv[4]).exists() and not repeated: + os.write(fd, b'\x1b[>q\x1bP+q524742\x1b\\') + repeated = True + if Path(sys.argv[3]).exists(): + break + finally: + termios.tcsetattr(fd, termios.TCSANOW, previous) + os.close(fd) + Path(sys.argv[1]).write_text(json.dumps(replies.decode('ascii'))) + ''')) + send(page, shlex.join([sys.executable, str(script), str(result), str(ready), str(finish), str(again)]) + '\r') + deadline = time.monotonic() + 10 + while not ready.exists() and time.monotonic() < deadline: + page.wait_for_timeout(50) + fixture.check(ready.exists(), 'live terminal did not answer capability queries') + second = context.new_page() + try: + second.goto(page.url, wait_until='domcontentloaded') + second.wait_for_function('() => window.terminalTest?.getActiveAgentState()?.connected') + page.reload(wait_until='domcontentloaded') + page.wait_for_function('() => window.terminalTest?.getActiveAgentState()?.connected') + again.touch() + page.wait_for_timeout(500) + finish.touch() + deadline = time.monotonic() + 3 + while not result.exists() and time.monotonic() < deadline: + page.wait_for_timeout(50) + replies = json.loads(result.read_text()) + # Existing xterm DA/color replies use a separate path; count the new protocol replies only. + fixture.check(replies.count('\x1bP>|StandTerm(') == 2, 'version replies duplicated or missing') + fixture.check(replies.count('\x1bP1+r524742=38\x1b\\') == 2, 'RGB replies duplicated or missing') + print('Core capability path: two live query rounds answered once each; refresh/replay silent: ok', flush=True) + finally: + finish.touch() + second.close() + + +def main(): + if not shutil.which('tmux') or not shutil.which('tic'): + raise RuntimeError('This smoke requires tmux and ncurses tools.') + server = None + try: + server, url = fixture.start_server() + with tempfile.TemporaryDirectory(prefix='standterm-tmux-browser-') as directory: + with fixture.load_playwright()[0]() as playwright: + browser = playwright.chromium.launch(headless=True) + context, page = fixture.new_page(browser, url) + try: + run_case(page, Path(directory), 'baseline', baseline=True) + run_case(page, Path(directory), 'automatic') + run_case(page, Path(directory), 'existing', existing=True) + run_case(page, Path(directory), 'explicit', baseline=True, explicit=True) + check_query_replay(context, page, Path(directory)) + fixture.test_terminal_payload_text_is_not_control(browser, url) + fixture.test_unicode_provider_keeps_emoji_text_in_separate_cells(browser, url) + finally: + context.close() + browser.close() + finally: + if server: + fixture.stop_server(server) + + +if __name__ == '__main__': + main() diff --git a/tests/terminal_capabilities_smoke.cjs b/tests/terminal_capabilities_smoke.cjs new file mode 100644 index 0000000..86ff0e3 --- /dev/null +++ b/tests/terminal_capabilities_smoke.cjs @@ -0,0 +1,52 @@ +'use strict'; + +const assert = require('node:assert/strict'); +const { Terminal } = require('../static/js/xterm.js'); +const CapabilityAddon = require('../static/js/standterm-capabilities.js'); + +async function run() { + const queries = []; + const input = []; + const term = new Terminal({ allowProposedApi: true }); + const addon = new CapabilityAddon(query => queries.push(query)); + term.loadAddon(addon); + term.onData(data => input.push(data)); + const context = (seq, replay = false) => ({ output_seq: seq, replay, capability_epoch: 'bridge-1' }); + const write = (data, ctx) => new Promise(resolve => addon.write(data, ctx, resolve)); + try { + await Promise.all([ + write('\x1b[>q\x1bP+q524742;436f\x1b\\', context(1)), + write('\x1b[>q', context(2, true)), + write('\x1bP+q52', context(3, true)), + write('4742\x1b\\\x1b[>0q', context(4)) + ]); + assert.deepEqual(queries.map(q => [q.kind, q.output_seq, q.query_index]), + [['version', 1, 0], ['terminfo', 1, 0], ['terminfo', 4, 0], ['version', 4, 0]]); + assert.equal(queries[1].names, '524742;436f'); + assert.deepEqual(input, [], 'automatic replies must not enter human input'); + await write('\x1bP+q' + '41'.repeat(1025) + '\x1b\\', context(5)); + assert.equal(queries.length, 4); + await write('\x1b[38;2;12;34;56mX\x1b[0m', context(6)); + const cell = term.buffer.active.getLine(0).getCell(0); + assert.ok(cell.isFgRGB()); + assert.equal(cell.getFgColor(), 0x0c2238); + await write('\x1b[?69$p', context(7)); + assert.deepEqual(input, ['\x1b[?69;0$y'], 'unsupported margins must stay unsupported'); + await write('\x1bP+q52', context(8)); + await write('4742\x1b\\', { ...context(1), capability_epoch: 'bridge-2' }); + assert.equal(queries.length, 4, 'a new bridge must not finish an old query'); + // A late viewer without the DCS prefix must still identify the following version query identically. + const lateQueries = []; + const late = new Terminal({ allowProposedApi: true }); + const lateAddon = new CapabilityAddon(query => lateQueries.push(query)); + late.loadAddon(lateAddon); + await new Promise(resolve => lateAddon.write('4742\x1b\\\x1b[>0q', context(4), resolve)); + assert.deepEqual(lateQueries[0], queries[3]); + late.dispose(); + console.log('Shipped xterm: live, replay, fragmented queries, input isolation, RGB: ok'); + } finally { + term.dispose(); + } +} + +run().catch(error => { console.error(error); process.exitCode = 1; }); diff --git a/tests/terminal_capabilities_smoke.py b/tests/terminal_capabilities_smoke.py new file mode 100644 index 0000000..425c8a8 --- /dev/null +++ b/tests/terminal_capabilities_smoke.py @@ -0,0 +1,125 @@ +"""Run with Python; no application dependencies or live terminals are required.""" + +import os +from pathlib import Path +import shutil +import subprocess +import sys +import tempfile +from types import SimpleNamespace +import unittest +from unittest.mock import patch + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) + +from terminal_capabilities import build_capability_response +from terminal_backends.base import TerminalBridge +from terminal_backends.terminfo import add_local_rgb_terminfo, _rgb_terminfo + + +class RecordingBridge(TerminalBridge): + def write(self, data): + self.writes.append(data) + + +class CapabilityTests(unittest.TestCase): + def test_truthful_bounded_responses(self): + self.assertIn('StandTerm(', build_capability_response('version')) + self.assertEqual(build_capability_response('terminfo', '524742;436f;5463'), + '\x1bP1+r524742=38\x1b\\\x1bP1+r436f=323536\x1b\\\x1bP1+r5463\x1b\\') + self.assertEqual(build_capability_response('terminfo', '4d73;524742'), '\x1bP0+r\x1b\\') + for names in ('', 'g1', '123', 'ff', '1b5b324a', '00'): + result = build_capability_response('terminfo', names) + self.assertIn(result, (None, '\x1bP0+r\x1b\\')) + for names in (None, [], '41' * 1025, ';'.join(['524742'] * 17)): + self.assertIsNone(build_capability_response('terminfo', names)) + self.assertIsNone(build_capability_response('input', 'rm -rf')) + + def test_replay_multiple_viewers_and_stale_generation(self): + emitted = [] + runtime = SimpleNamespace(max_replay_events=256, max_replay_bytes=65536, + append_transcript=lambda *args: None, update_headless_mirror=None, + emit_socket=lambda *args, **kwargs: emitted.append((args, kwargs))) + bridge = RecordingBridge('session', 'main', runtime=runtime) + bridge.writes = [] + bridge.attach('first') + bridge.attach('second') + bridge.emit_output({'message_type': 'terminal', 'data': '\x1b[>q'}) + epoch = bridge.capability_epoch + for _ in range(2): + bridge.write_capability_response(epoch, 1, 0, 'version') + bridge.write_capability_response(epoch, 1, 1, 'other-query') + self.assertEqual(bridge.writes, ['version', 'other-query']) + bridge.replay_to('second') + self.assertTrue(emitted[-1][0][1]['replay']) + self.assertNotIn('replay', bridge.replay_buffer[0]) + for args in [('old', 1, 2), (epoch, 2, 0), (epoch, True, 0), (epoch, 1, 32)]: + bridge.write_capability_response(*args, 'invalid') + bridge.output_seq = 300 + bridge.write_capability_response(epoch, 300, 0, 'fresh') + bridge.write_capability_response(epoch, 1, 0, 'expired') + self.assertEqual(bridge.writes, ['version', 'other-query', 'fresh']) + self.assertEqual(list(bridge.capability_responses), [300]) + bridge.closing = True + bridge.write_capability_response(epoch, 300, 1, 'closed') + self.assertEqual(len(bridge.writes), 3) + + @unittest.skipUnless(shutil.which('tic') and shutil.which('infocmp'), 'ncurses tools unavailable') + def test_effective_user_entry_is_preserved(self): + with tempfile.TemporaryDirectory(prefix='standterm-terminfo-custom-') as home: + env = dict(os.environ, HOME=home, TERM='xterm-256color') + env.pop('TERMINFO', None) + env.pop('TERMINFO_DIRS', None) + source = subprocess.check_output(['infocmp', '-x', '-1', 'xterm-256color'], env=env, text=True) + source += '\tStandTermTestFlag,\n' + path = Path(home) / 'custom.ti' + path.write_text(source) + subprocess.run(['tic', '-x', '-o', str(Path(home) / '.terminfo'), str(path)], check=True) + add_local_rgb_terminfo(env) + compiled = subprocess.check_output(['infocmp', '-x', '-1', 'xterm-256color'], env=env, text=True) + self.assertIn('StandTermTestFlag,', compiled) + self.assertIn('\tRGB,', compiled) + shutil.rmtree(env['TERMINFO']) + _rgb_terminfo.cache_clear() + + def test_preserve_overrides_and_fall_back(self): + for extra in ({'TERMINFO': '/custom'}, {'TERMINFO_DIRS': ''}, {'TERM': 'vt100'}): + env = dict(TERM='xterm-256color', **{'PATH': os.defpath}) + env.update(extra) + before = dict(env) + with patch('terminal_backends.terminfo._rgb_terminfo') as compile_entry: + add_local_rgb_terminfo(env) + compile_entry.assert_not_called() + self.assertEqual(env, before) + with patch('terminal_backends.terminfo.sys.platform', 'win32'): + env = {'TERM': 'xterm-256color'} + add_local_rgb_terminfo(env) + self.assertNotIn('TERMINFO', env) + _rgb_terminfo.cache_clear() + self.assertIsNone(_rgb_terminfo('xterm-256color', '/nonexistent', '/tmp')) + with patch('terminal_backends.terminfo.subprocess.run', side_effect=subprocess.TimeoutExpired('tic', 2)): + self.assertIsNone(_rgb_terminfo('xterm-256color', os.defpath, '/tmp')) + _rgb_terminfo.cache_clear() + + @unittest.skipUnless(shutil.which('tic') and shutil.which('infocmp'), 'ncurses tools unavailable') + def test_compiled_overlay_preserves_base_and_system_fallback(self): + with tempfile.TemporaryDirectory(prefix='standterm-terminfo-test-') as home: + env = dict(os.environ, HOME=home, TERM='xterm-256color') + env.pop('TERMINFO', None) + env.pop('TERMINFO_DIRS', None) + before = subprocess.check_output(['infocmp', '-x', '-1', 'xterm-256color'], env=env, text=True) + add_local_rgb_terminfo(env) + directory = env.get('TERMINFO') + self.assertIsNotNone(directory) + after = subprocess.check_output(['infocmp', '-x', '-1', 'xterm-256color'], env=env, text=True) + lines = lambda text: {line.strip() for line in text.splitlines() if line.startswith('\t')} + self.assertEqual(lines(after) - lines(before), {'RGB,', 'Tc,'}) + self.assertFalse(lines(before) - lines(after)) + subprocess.run(['infocmp', 'vt100'], env=env, check=True, capture_output=True) + self.assertEqual(Path(directory).stat().st_mode & 0o777, 0o700) + shutil.rmtree(directory) + _rgb_terminfo.cache_clear() + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/terminal_capabilities_ssh_smoke.py b/tests/terminal_capabilities_ssh_smoke.py new file mode 100644 index 0000000..5fea0cf --- /dev/null +++ b/tests/terminal_capabilities_ssh_smoke.py @@ -0,0 +1,112 @@ +"""Exercise real SSH PTYs and tmux with the shipped xterm in Chromium.""" + +from pathlib import Path +import queue +import shlex +import subprocess +import threading +import time +from types import MethodType, SimpleNamespace + +import agent_browser_smoke as browser_fixture +from ssh_jump_smoke import SSHJumpTests, server +from terminal_backends.base import TerminalBridge +from terminal_capabilities import build_capability_response + + +def main(): + fixture = SSHJumpTests() + fixture.setUp() + try: + host = fixture.stack.enter_context(server()) + bridge = fixture.bridge([host]) + bridge._ssh_term = 'xterm-256color' + outputs = queue.Queue() + bridge.runtime = SimpleNamespace( + emit_socket=lambda event, payload, **kwargs: outputs.put(payload), + append_transcript=lambda *args: None, update_headless_mirror=None, + unregister_bridge=lambda *args: None, sleep=time.sleep, + max_replay_events=256, max_replay_bytes=65536, + ) + bridge.emit_output = MethodType(TerminalBridge.emit_output, bridge) + bridge.attach('test-sid') + route = fixture.route([host]) + fixture.trust(route, [host]) + success, error = fixture.connect(bridge, route) + assert success, error + reader = threading.Thread(target=bridge.read_loop, daemon=True) + reader.start() + with browser_fixture.load_playwright()[0]() as playwright: + browser = playwright.chromium.launch(headless=True) + page = browser.new_page() + page.add_script_tag(path=str(browser_fixture.ROOT / 'static/js/xterm.js')) + page.add_script_tag(path=str(browser_fixture.ROOT / 'static/js/standterm-capabilities.js')) + + def query(payload): + reply = build_capability_response(payload['kind'], payload.get('names')) + if reply: + bridge.write_capability_response(payload['capability_epoch'], payload['output_seq'], + payload['query_index'], reply, + query_identity=(payload['kind'], payload.get('names'))) + + page.expose_function('capabilityQuery', query) + page.expose_function('terminalReply', bridge.write) + page.evaluate("""() => { + window.term = new Terminal({ allowProposedApi: true, cols: 80, rows: 24 }); + window.addon = new StandTermCapabilities(query => window.capabilityQuery(query)); + term.loadAddon(addon); + term.onData(data => window.terminalReply(data)); + }""") + + def pump(): + while not outputs.empty(): + payload = outputs.get_nowait() + if payload.get('message_type') == 'terminal': + page.evaluate('p => new Promise(resolve => addon.write(p.data, p, resolve))', payload) + page.wait_for_timeout(50) + + for explicit in (False, True): + socket_path = fixture.directory / ('explicit.sock' if explicit else 'plain.sock') + command = ['tmux', '-S', str(socket_path), '-f', '/dev/null'] + if explicit: + command += ['-T', 'RGB'] + command += ['new-session', '-s', 'probe', '/bin/sh'] + try: + bridge.write(shlex.join(command) + '\r') + deadline = time.monotonic() + 10 + info = '' + while time.monotonic() < deadline: + pump() + info = subprocess.run(['tmux', '-S', str(socket_path), 'list-clients', '-F', + '#{client_termname}|#{client_termtype}|#{client_termfeatures}'], + capture_output=True, text=True).stdout.strip() + if 'StandTerm(' in info: + break + assert 'StandTerm(' in info, info + assert ('RGB' in info.split('|')[-1].split(',')) == explicit, info + bridge.write("printf '\\033[2J\\033[H\\033[38;2;12;34;56mRGB_PROBE\\033[0m\\n'\r") + deadline = time.monotonic() + 5 + while time.monotonic() < deadline: + pump() + cell = page.evaluate("""() => { + const c = term.buffer.active.getLine(0).getCell(0); + return { text: c.getChars(), fg: c.getFgColor(), rgb: !!c.isFgRGB() }; + }""") + if cell['text'] == 'R': + break + assert cell['text'] == 'R', cell + assert (cell['rgb'] and cell['fg'] == 0x0c2238) == explicit, cell + print(f'SSH {"explicit" if explicit else "plain"}: {info}; cell={cell}', flush=True) + finally: + subprocess.run(['tmux', '-S', str(socket_path), 'kill-server'], capture_output=True) + for _ in range(5): + pump() + browser.close() + bridge.close() + reader.join(timeout=3) + finally: + fixture.doCleanups() + + +if __name__ == '__main__': + main() From 690fad25b4f53996a813396f0d5cbeef6b3b4d77 Mon Sep 17 00:00:00 2001 From: "ASKA C." Date: Wed, 16 Sep 2026 23:28:04 +0800 Subject: [PATCH 10/43] Distinguish direct and routed SSH connection panes --- templates/index.html | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/templates/index.html b/templates/index.html index 37dd745..186772c 100644 --- a/templates/index.html +++ b/templates/index.html @@ -489,9 +489,13 @@ #controls input, #controls select { display: block; width: 100%; margin-bottom: 15px; padding: 10px; background: #333; border: 1px solid #555; color: white; border-radius: 4px; box-sizing: border-box; } #controls button { width: 100%; padding: 12px; background: #0a84ff; border: none; color: white; border-radius: 4px; cursor: pointer; font-weight: bold; } #controls button:hover { background: #007aff; } - .ssh-preparation-pane { border: 1px solid #444; border-radius: 5px; margin: 10px 0; } - #controls .ssh-preparation-heading { display: flex; align-items: center; gap: 8px; padding: 10px; background: #292929; text-align: left; } - #controls .ssh-preparation-heading[aria-expanded="true"] { background: #233146; } + #ssh-direct-pane { --ssh-pane-accent: #78b4ef; --ssh-pane-border: #3e5872; --ssh-pane-heading: #28384a; --ssh-pane-selected: #304b68; background: #222a33; } + #ssh-route-pane { --ssh-pane-accent: #c2a4e4; --ssh-pane-border: #615078; --ssh-pane-heading: #3a3047; --ssh-pane-selected: #503e63; background: #2b2633; } + .ssh-preparation-pane { border: 1px solid var(--ssh-pane-border); border-left: 3px solid var(--ssh-pane-accent); border-radius: 5px; margin: 10px 0; } + #controls .ssh-preparation-heading, #controls .ssh-preparation-heading:hover { display: flex; align-items: center; gap: 8px; padding: 10px; background: var(--ssh-pane-heading); text-align: left; } + #controls .ssh-preparation-heading[aria-expanded="true"], #controls .ssh-preparation-heading[aria-expanded="true"]:hover { background: var(--ssh-pane-selected); } + #controls .ssh-preparation-heading:focus-visible { outline: 2px solid var(--ssh-pane-accent); outline-offset: 2px; } + .ssh-pane-selection { color: var(--ssh-pane-accent); } .ssh-preparation-heading small { margin-left: auto; color: #aaa; font-weight: normal; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .ssh-preparation-body { padding: 12px; } .ssh-preparation-body[hidden] { display: none; } From ed2bcdb8b2188e7101e6bbefedf55542f6360e13 Mon Sep 17 00:00:00 2001 From: "ASKA C." Date: Thu, 17 Sep 2026 21:28:37 +0800 Subject: [PATCH 11/43] Add one-click Agent Mint action --- README.md | 13 +++-- templates/index.html | 105 +++++++++++++++++++++++++++++++++++ tests/agent_backend_smoke.py | 14 +++++ tests/agent_browser_smoke.py | 76 ++++++++++++++++++++++++- 4 files changed, 201 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 279a537..704709b 100644 --- a/README.md +++ b/README.md @@ -316,8 +316,12 @@ states the token status; tab labels include remaining idle seconds, for example does not mean an agent is currently executing. Revocation, invalidation, or disabling access removes the tint, and connection warnings take priority. Background tabs update without opening the Agent panel. -The tab-row Mint and Mint 3× buttons sit beside Pause Agent when the Agent panel -is hidden, and always target the active terminal. +The **🤖 Agent Mint** action is the leftmost action in the right-side tab tools. +It applies the permission selected in **Settings > General > Agent Access** to +the active terminal, waits for Core to confirm it, and mints a standard token. +The default is **Full + Mint**. The tab-row Mint and Mint 3× buttons remain +beside Pause Agent when the Agent panel is hidden, and always target the active +terminal; compact windows keep those actions in the Agent panel. This development build also enables an **experimental IME positioning PoC**: the composition overlay follows its starting input line during terminal redraws. @@ -768,8 +772,9 @@ Typical local flow: 1. Launch StandTerm and open the browser. 2. Connect a terminal. -3. Open the Agent panel for that terminal. -4. Mint a standard or 3x-idle external-agent token from the browser Agent UI. +3. Choose **🤖 Agent Mint** to apply the saved permission and mint a standard + token for that terminal in one action. The default is **Full + Mint**. +4. For another permission or a 3x-idle token, use the browser Agent panel. When the Agent panel is hidden, the same actions are available in the status bar for the active terminal. 5. On a local tab, open **Agent Info for Current Tab** in the toolbar. **Copy URL** provides the diff --git a/templates/index.html b/templates/index.html index 186772c..bbaa254 100644 --- a/templates/index.html +++ b/templates/index.html @@ -60,6 +60,8 @@ #agent-toggle-btn.shifted { margin-left: 0; } .agent-status-mint { display: none; } .agent-status-mint.visible { display: inline-flex; align-items: center; justify-content: center; } + #agent-access-mint-btn { border-color: #2d756e; color: #8ce9df; } + #agent-access-mint-btn:not(:disabled):hover { border-color: #40e0d0; background: #173a37; color: #fff; } #new-tab-btn { width: 26px; padding: 0; font-size: 20px; } body.operator-observing #status-bar { background: #5a1010; border-top-color: #ff453a; color: #fff; } #terminal { position: absolute; top: 0; left: 0; right: 0; height: calc(100vh - var(--status-bar-height)); width: 100vw; background: #000; z-index: 1; display: none; } @@ -926,6 +928,9 @@ border-radius: 5px; cursor: pointer; font-weight: 700; } #session-recovery-message { min-height: 18px; margin-top: 10px; color: #ff9f0a; font-size: 12px; } + @media (max-width: 760px) { + .agent-status-mint.visible { display: none; } + } @media (max-width: 620px) { .settings-nav { width: 104px; } .settings-nav-item { padding: 9px 10px; } @@ -1009,6 +1014,17 @@

Settings

+
+ Agent Access +
+ +
+
The tab-bar action applies this permission to the current tab, then mints a standard token.
+
Import & Export
Exports include browser preferences, SSH profiles, and SSH history. SSH keys are never included.
@@ -1301,6 +1317,7 @@

Manual browser authorization

+ @@ -1542,6 +1559,11 @@

Recover StandTerm session

const AGENT_MODE_APPROVAL_PENDING = 'approval_pending'; const AGENT_MODE_DIRECT_ACTIVE = 'direct_active'; const AGENT_MODE_PAUSED = 'paused'; + const AGENT_ACCESS_MINT_MODE_OPTIONS = [ + AGENT_MODE_OBSERVE, + AGENT_MODE_APPROVAL_PENDING, + AGENT_MODE_DIRECT_ACTIVE + ]; const AGENT_OPEN_ACTION_STATUSES = new Set([ 'pending_approval', 'direct_pending', @@ -1602,6 +1624,7 @@

Recover StandTerm session

colorScheme: 'vintage', cursorStyle: 'block', showTerminalTitleInStatusBar: true, + agentAccessMintMode: AGENT_MODE_DIRECT_ACTIVE, // Render East Asian Ambiguous characters (box drawing, symbols) // as wide cells. Keep this off by default; enable it only for // legacy BBS/screen setups that require wide ambiguous width. @@ -1645,10 +1668,15 @@

Recover StandTerm session

const text = String(value).trim(); return CURSOR_STYLE_OPTIONS.includes(text) ? text : fallback; } + function normalizeAgentAccessMintMode(value, fallback = PREF_DEFAULTS.agentAccessMintMode) { + const text = String(value).trim(); + return AGENT_ACCESS_MINT_MODE_OPTIONS.includes(text) ? text : fallback; + } let prefs = loadPrefs(); prefs.fontSize = normalizeFontSize(prefs.fontSize); prefs.fontWeight = normalizeFontWeight(prefs.fontWeight); prefs.cursorStyle = normalizeCursorStyle(prefs.cursorStyle); + prefs.agentAccessMintMode = normalizeAgentAccessMintMode(prefs.agentAccessMintMode); const UNICODE11_WIDTH_PROVIDER_VERSION = '11'; const CJK_WIDTH_PROVIDER_VERSION = 'ptt-cjk'; @@ -2234,6 +2262,7 @@

Recover StandTerm session

const agentExternalToken3xBtn = document.getElementById('agent-external-token-3x-btn'); const agentStatusMintBtn = document.getElementById('agent-status-mint-btn'); const agentStatusMint3xBtn = document.getElementById('agent-status-mint-3x-btn'); + const agentAccessMintBtn = document.getElementById('agent-access-mint-btn'); const agentGatePrivacy = document.getElementById('agent-gate-privacy'); const agentGateHuman = document.getElementById('agent-gate-human'); const agentGatePause = document.getElementById('agent-gate-pause'); @@ -2366,6 +2395,7 @@

Recover StandTerm session

let agentPanelPosition = null; let agentPanelDrag = null; let agentExternalTokenCountdownTimer = null; + const agentAccessMintPending = new Set(); let pipTerminalState = null; let sftpPipState = null; let contextMenuTerminalId = null; @@ -3624,6 +3654,20 @@

Recover StandTerm session

} } + function updateAgentAccessMintButton() { + const state = getActiveTerminalState(); + const token = getCurrentAgentExternalToken(state); + const starting = !!(state && agentAccessMintPending.has(state.id)); + const minting = !!(token && token.status === 'minting'); + const available = canUseAgentPanel(state) && !starting && !minting; + agentAccessMintBtn.disabled = !available; + agentAccessMintBtn.innerText = starting ? 'Starting…' : (minting ? 'Minting…' : '🤖 Agent Mint'); + const modeLabel = formatAgentModeLabel(prefs.agentAccessMintMode); + agentAccessMintBtn.title = state + ? `Grant ${modeLabel} access and mint a standard token for ${getTerminalDisplayLabel(state)} (${state.id})` + : `Grant ${modeLabel} access and mint a standard token for the current tab`; + } + function applyAgentExternalTokenState(data) { if (!data || typeof data.terminal_id !== 'string') return; const state = terminals.get(data.terminal_id); @@ -3756,6 +3800,7 @@

Recover StandTerm session

agentProviderRunBtn.disabled = agentMockInput.disabled; updateAgentExternalUi(usable, mode, agent, state); updateAgentStatusMintButtons(); + updateAgentAccessMintButton(); updatePipStatus(pipTerminalState); renderAgentGateState(state); renderAgentStatusPanel(state); @@ -5980,6 +6025,9 @@

Recover StandTerm session

if ('fontSize' in result) result.fontSize = normalizeFontSize(result.fontSize); if ('fontWeight' in result) result.fontWeight = normalizeFontWeight(result.fontWeight); if ('cursorStyle' in result) result.cursorStyle = normalizeCursorStyle(result.cursorStyle); + if ('agentAccessMintMode' in result) { + result.agentAccessMintMode = normalizeAgentAccessMintMode(result.agentAccessMintMode); + } return result; } @@ -8323,6 +8371,32 @@

Recover StandTerm session

socket.emit(eventName, { terminal_id: state.id, mode }); } + function waitForAgentMode(state, mode, previousModeVersion, timeoutMs = 5000) { + return new Promise((resolve, reject) => { + const deadline = Date.now() + timeoutMs; + const check = () => { + if (!canUseAgentPanel(state)) { + reject(new Error('Agent access is unavailable for this terminal.')); + return; + } + if ( + state.agent.mode === mode + && !state.agent.paused + && state.agent.modeVersion !== previousModeVersion + ) { + resolve(); + return; + } + if (Date.now() >= deadline) { + reject(new Error('Timed out while applying Agent access permission.')); + return; + } + setTimeout(check, 25); + }; + check(); + }); + } + function pauseAgentForState(state) { if (!socket || !socket.connected || !hasAgentPauseCapability(state)) return; socket.emit('agent_pause', { terminal_id: state.id }); @@ -8473,6 +8547,32 @@

Recover StandTerm session

if (agentStatusMint3xBtn.disabled) return; mintAgentExternalToken(getActiveTerminalState(), 3); }; + agentAccessMintBtn.onclick = async () => { + const state = getActiveTerminalState(); + if (!canUseAgentPanel(state) || agentAccessMintPending.has(state.id)) return; + const targetMode = normalizeAgentAccessMintMode(prefs.agentAccessMintMode); + agentAccessMintPending.add(state.id); + updateAgentAccessMintButton(); + try { + if (state.agent.mode !== targetMode || state.agent.paused) { + const previousModeVersion = state.agent.modeVersion; + setAgentMode(state, targetMode); + await waitForAgentMode(state, targetMode, previousModeVersion); + } + await mintAgentExternalToken(state, 1); + } catch (error) { + if (isCurrentTerminalState(state)) { + setAgentExternalTokenState(state, { + status: 'error', + command: `error: ${error.message || error}`, + errorCode: 'agent_access_mint_failed' + }); + } + } finally { + agentAccessMintPending.delete(state.id); + updateAgentPanel(); + } + }; function getPendingAgentAction(state) { return state && state.agent ? state.agent.pendingAction : null; } @@ -11333,6 +11433,7 @@

Recover StandTerm session

document.getElementById('pref-showDetailedSshLabels').checked = prefs.showDetailedSshLabels; document.getElementById('pref-cjkWideAmbiguous').checked = prefs.cjkWideAmbiguous; document.getElementById('pref-urlClickAction').value = prefs.urlClickAction; + document.getElementById('pref-agentAccessMintMode').value = prefs.agentAccessMintMode; document.getElementById('pref-colorScheme').value = prefs.colorScheme; renderColorSchemePreview(); document.getElementById('pref-fontFace').value = prefs.fontFace; @@ -11547,6 +11648,10 @@

Recover StandTerm session

prefs.showDetailedSshLabels = document.getElementById('pref-showDetailedSshLabels').checked; prefs.cjkWideAmbiguous = document.getElementById('pref-cjkWideAmbiguous').checked; prefs.urlClickAction = document.getElementById('pref-urlClickAction').value; + prefs.agentAccessMintMode = normalizeAgentAccessMintMode( + document.getElementById('pref-agentAccessMintMode').value, + prefs.agentAccessMintMode + ); prefs.colorScheme = document.getElementById('pref-colorScheme').value; prefs.fontFace = document.getElementById('pref-fontFace').value; prefs.powerlineSymbols = document.getElementById('pref-powerlineSymbols').checked; diff --git a/tests/agent_backend_smoke.py b/tests/agent_backend_smoke.py index bfdc888..f4ef123 100644 --- a/tests/agent_backend_smoke.py +++ b/tests/agent_backend_smoke.py @@ -1970,6 +1970,20 @@ def test_external_agent_observe_cannot_send(): assert result['error_code'] == standterm.AGENT_ERROR_MODE_NOT_WRITABLE assert bridge.writes == [] + started_at = time.monotonic() + captured_result = standterm.process_external_agent_command({ + 'op': 'send-wait', + 'token': token, + 'terminal_id': standterm.TERMINAL_ID_MAIN, + 'kind': 'text', + 'text': 'blocked\n', + 'wait_ms': standterm.AGENT_EXTERNAL_TAIL_MAX_WAIT_MS, + }) + assert captured_result['status'] == standterm.AGENT_STATUS_FAILED + assert captured_result['error_code'] == standterm.AGENT_ERROR_MODE_NOT_WRITABLE + assert time.monotonic() - started_at < 0.5 + assert bridge.writes == [] + client.disconnect() diff --git a/tests/agent_browser_smoke.py b/tests/agent_browser_smoke.py index c3caf26..467f8ef 100644 --- a/tests/agent_browser_smoke.py +++ b/tests/agent_browser_smoke.py @@ -601,6 +601,71 @@ def test_toolbar_pause_targets_main_tab_not_panel_override(browser, access_url): close_context(context) +def test_agent_mint_quick_action_applies_saved_permission(browser, access_url): + context, page = new_page(browser, access_url) + try: + placement = page.evaluate( + """() => ({ + firstTool: document.querySelector('#terminal-tools > :first-child')?.id, + text: document.getElementById('agent-access-mint-btn').innerText, + disabled: document.getElementById('agent-access-mint-btn').disabled, + panelVisible: document.getElementById('agent-panel').classList.contains('visible') + })""" + ) + check(placement == { + 'firstTool': 'agent-access-mint-btn', + 'text': '🤖 Agent Mint', + 'disabled': False, + 'panelVisible': False, + }, 'Agent Mint was not the ready leftmost right-side action') + + page.click('#quick-settings') + check(page.locator('#pref-agentAccessMintMode').input_value() == 'direct_active', + 'Agent Mint permission did not default to Full + Mint') + page.click('#settings-close') + + clear_emitted(page) + page.click('#agent-access-mint-btn') + page.wait_for_function( + """() => { + const state = window.terminalTest.getActiveAgentState(); + return state?.mode === 'direct_active' && state?.external_token?.status === 'active'; + }""", + timeout=10000, + ) + minted = active_agent_state(page) + check(minted['mode'] == 'direct_active', 'one-click Agent Mint did not apply Full permission') + check(minted['external_token']['idleTimeoutMultiplier'] == 1, + 'one-click Agent Mint did not mint the standard token lifetime') + check(page.locator('#agent-access-mint-btn').inner_text() == '🤖 Agent Mint', + 'Agent Mint action did not return to its ready label') + mode_events = [ + entry['args'][0] for entry in get_emitted(page, 'agent_mode_set') + if entry['args'] and entry['args'][0].get('terminal_id') == TERMINAL_ID + ] + check(mode_events == [{'terminal_id': TERMINAL_ID, 'mode': 'direct_active'}], + 'Agent Mint did not make one structured Full permission request') + + emit_socket(page, 'agent_mode_set', {'terminal_id': TERMINAL_ID, 'mode': 'disabled'}) + wait_for_agent(page, "state.mode === 'disabled'") + page.click('#quick-settings') + page.select_option('#pref-agentAccessMintMode', 'approval_pending') + page.click('#settings-save') + clear_emitted(page) + page.click('#agent-access-mint-btn') + page.wait_for_function( + """() => { + const state = window.terminalTest.getActiveAgentState(); + return state?.mode === 'approval_pending' && state?.external_token?.status === 'active'; + }""", + timeout=10000, + ) + check('Grant Approval access' in page.locator('#agent-access-mint-btn').get_attribute('title'), + 'Agent Mint title did not reflect the saved permission') + finally: + close_context(context) + + def test_agent_panel_can_be_dragged(browser, access_url): context, page = new_page(browser, access_url) try: @@ -2033,12 +2098,16 @@ def token_event(status, remaining_ms): page.evaluate('id => window.terminalTest.switchTerminalForTest(id)', TERMINAL_ID) set_agent_mode(page, 'direct', 'direct_active') page.set_viewport_size({'width': 640, 'height': 600}) - for selector in ['#new-tab-btn', '#agent-pause-btn', '#agent-status-mint-btn', - '#agent-status-mint-3x-btn', '#agent-toggle-btn', '#quick-settings']: + for selector in ['#new-tab-btn', '#agent-pause-btn', '#agent-access-mint-btn', + '#agent-toggle-btn', '#quick-settings']: bounds = page.locator(selector).bounding_box() check(bounds is not None and bounds['x'] >= 0 and bounds['x'] + bounds['width'] <= 640, f'{selector} is clipped beside the compact tab row') - page.click('#agent-status-mint-btn') + check(page.locator('#agent-status-mint-btn').is_hidden(), + 'compact tab row did not hide the redundant standard Mint action') + check(page.locator('#agent-status-mint-3x-btn').is_hidden(), + 'compact tab row did not move the 3x Mint action into the Agent panel') + page.click('#agent-access-mint-btn') page.wait_for_selector(main_tab + '.agent-token-active', state='attached') page.click('#agent-pause-btn') wait_for_agent(page, "state.mode === 'paused'") @@ -4748,6 +4817,7 @@ def main(): test_platform_passkey_recovers_live_session_without_access_token, test_agent_panel_can_be_dragged, test_toolbar_pause_targets_main_tab_not_panel_override, + test_agent_mint_quick_action_applies_saved_permission, test_terminal_pip_hides_selected_tab_and_keeps_background_tab, test_sftp_status_actions_and_terminal_pip_transition, test_sftp_send_context_action_is_limited_to_connected_ssh_tabs, From 9ce1c870b07d4f5ba6dccaec9c8093035a12fd59 Mon Sep 17 00:00:00 2001 From: "ASKA C." Date: Sat, 19 Sep 2026 18:01:02 +0800 Subject: [PATCH 12/43] Keep Files actions visible in short windows --- templates/index.html | 4 ++++ tests/agent_browser_smoke.py | 17 ++++++++++++++++- 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/templates/index.html b/templates/index.html index bbaa254..25925ac 100644 --- a/templates/index.html +++ b/templates/index.html @@ -148,6 +148,8 @@ .sftp-source-pane, .sftp-destination-pane { min-width: 0; min-height: 0; display: flex; flex-direction: column; gap: 10px; overflow: hidden; } + .sftp-source-pane { overflow-y: auto; } + .sftp-source-pane > :not(.sftp-directory-list) { flex-shrink: 0; } .sftp-destination-pane { animation: sftp-destination-slide 0.16s ease-out; } @@ -9118,6 +9120,7 @@

Recover StandTerm session

state.elements.conflict.classList.remove('visible'); setSftpPipStatus(state, 'Ready to upload.'); updateSftpSelectedFile(state); + state.elements.selectedFile.scrollIntoView({ block: 'nearest' }); } function getSftpRemoteFilePath(state, file) { @@ -9190,6 +9193,7 @@

Recover StandTerm session

state.preparedDownload = null; state.downloadConsumed = false; prepareSftpDownloadTicket(state, file); + state.elements.fileOperation.scrollIntoView({ block: 'nearest' }); } function getSftpRenameValidation(state) { diff --git a/tests/agent_browser_smoke.py b/tests/agent_browser_smoke.py index 467f8ef..8ca40a8 100644 --- a/tests/agent_browser_smoke.py +++ b/tests/agent_browser_smoke.py @@ -1064,6 +1064,7 @@ def test_sftp_send_context_action_is_limited_to_connected_ssh_tabs(browser, acce const typeaheadMatch = pipDocument.activeElement?.dataset.entryName; const files = [...pipDocument.querySelectorAll('.sftp-file-entry')]; const reference = files.find(button => button.dataset.entryName === 'reference.txt'); + pipDocument.documentElement.style.height = '360px'; reference.click(); const preparing = { disabled: pipDocument.querySelector('.sftp-file-download').disabled, @@ -1097,6 +1098,11 @@ def test_sftp_send_context_action_is_limited_to_connected_ssh_tabs(browser, acce downloadReady: !pipDocument.querySelector('.sftp-file-download').disabled, selected: reference.classList.contains('selected'), selectedPressed: reference.getAttribute('aria-pressed'), + sourceOverflow: pipDocument.defaultView.getComputedStyle( + pipDocument.querySelector('.sftp-source-pane')).overflowY, + actionBottom: pipDocument.querySelector('.sftp-file-operation-actions') + .getBoundingClientRect().bottom, + viewportHeight: pipDocument.documentElement.clientHeight, status: pipDocument.querySelector('.sftp-transfer-status').innerText }; }""" @@ -1114,6 +1120,9 @@ def test_sftp_send_context_action_is_limited_to_connected_ssh_tabs(browser, acce check(file_ui['preparing'] == {'disabled': True, 'text': 'Preparing…'}, 'SFTP Download was enabled before its ticket was ready') check(file_ui['downloadReady'] is True, 'SFTP Download was not enabled after its ticket became ready') check(file_ui['selected'] is True and file_ui['selectedPressed'] == 'true', 'selected Files row was not highlighted') + check(file_ui['sourceOverflow'] == 'auto', 'short Files source pane did not provide a scroll fallback') + check(file_ui['actionBottom'] <= file_ui['viewportHeight'], 'short Files window clipped the selected file actions') + page.evaluate("() => { documentPictureInPicture.window.document.documentElement.style.height = ''; }") check( file_ui['status'] == 'Selected reference.txt. Click Download to save it.', 'selecting a file implied that it had already downloaded', @@ -1464,6 +1473,7 @@ def test_sftp_send_context_action_is_limited_to_connected_ssh_tabs(browser, acce type: 'text/plain', lastModified: Date.UTC(2026, 8, 2, 2, 20) })); + pipDocument.documentElement.style.height = '360px'; input.files = transfer.files; input.dispatchEvent(new Event('change', { bubbles: true })); const card = pipDocument.querySelector('.sftp-selected-file'); @@ -1471,8 +1481,11 @@ def test_sftp_send_context_action_is_limited_to_connected_ssh_tabs(browser, acce visible: card.classList.contains('visible'), path: pipDocument.querySelector('.sftp-selected-file-path').innerText, meta: pipDocument.querySelector('.sftp-selected-file-meta').innerText, - actions: [...card.querySelectorAll('button')].map(button => button.innerText) + actions: [...card.querySelectorAll('button')].map(button => button.innerText), + actionBottom: card.querySelector('.sftp-file-operation-actions').getBoundingClientRect().bottom, + viewportHeight: pipDocument.documentElement.clientHeight }; + pipDocument.documentElement.style.height = ''; pipDocument.querySelector('.sftp-local-copy').click(); const request = window.terminalTest.getEmitted() .find(item => item.event === 'sftp_browse_request' && item.args[0].terminal_id === 'main'); @@ -1497,6 +1510,8 @@ def test_sftp_send_context_action_is_limited_to_connected_ssh_tabs(browser, acce check(local_file_picker['path'] == 'local-upload.txt', 'selected local file card omitted the browser file name') check('6 B (6 bytes)' in local_file_picker['meta'] and '2026' in local_file_picker['meta'], 'selected local file card omitted exact metadata') check(local_file_picker['actions'] == ['Send', 'Copy to…'], 'selected local file card actions were unclear') + check(local_file_picker['actionBottom'] <= local_file_picker['viewportHeight'], + 'short Files window clipped the selected local file actions') check(local_file_picker['sessions'] == ['main', 'term-2'], 'local file Copy to omitted an eligible Files destination') check(local_file_picker['sourcePath'] == 'local-upload.txt', 'local file destination pane omitted its source card') clear_emitted(page) From 7a791073b7d12df7a5d21202589d4c3e1b920ee2 Mon Sep 17 00:00:00 2001 From: "ASKA C." Date: Sat, 19 Sep 2026 19:26:44 +0800 Subject: [PATCH 13/43] Add cross-tab Agent transfer queue --- README.md | 3 + app.py | 58 ++++++ docs/agent_socket_contract.md | 20 +++ templates/index.html | 329 +++++++++++++++++++++++++++++++++- tests/agent_backend_smoke.py | 55 ++++++ tests/agent_browser_smoke.py | 62 +++++-- 6 files changed, 509 insertions(+), 18 deletions(-) diff --git a/README.md b/README.md index 704709b..8e13cf3 100644 --- a/README.md +++ b/README.md @@ -542,6 +542,9 @@ atomic commit barrier has been crossed and cancellation is no longer possible. Keep Files open for the final result; closing the system PiP window does not cancel the backend transaction. Agent-initiated copies use the same bounded transfer core but still require their separate, fresh **Approve copy** decision. +After approval, progress moves to the cross-tab **Transfer Queue** between Files +and Settings. It can stop a running copy, and finished entries disappear after +about ten seconds. If the backend cannot determine whether an SSH publish succeeded, inspect the destination before retrying; a blind retry may duplicate or replace a file that was already published. diff --git a/app.py b/app.py index 73a9148..51febd3 100644 --- a/app.py +++ b/app.py @@ -325,6 +325,7 @@ def parse_positive_int_env(name, default): AGENT_EVENT_PROVIDER_RUN_REQUEST = 'agent_provider_run_request' AGENT_EVENT_ACTION_APPROVE = 'agent_action_approve' AGENT_EVENT_ACTION_REJECT = 'agent_action_reject' +AGENT_EVENT_ACTION_CANCEL = 'agent_action_cancel' AGENT_EVENT_VIEWPORT_SNAPSHOT = 'agent_viewport_snapshot' AGENT_EVENT_VIEWPORT_RENDER_REQUEST = 'agent_viewport_render_request' AGENT_EVENT_VIEWPORT_RENDER_RESULT = 'agent_viewport_render_result' @@ -413,6 +414,7 @@ def parse_positive_int_env(name, default): AGENT_ERROR_EXTERNAL_AGENT_DISABLED = 'agent_external_disabled' AGENT_ERROR_HUMAN_INPUT_ACTIVE = 'agent_human_input_active' AGENT_ERROR_FILE_COPY_BUSY = 'file_copy_busy' +AGENT_ERROR_FILE_COPY_CANCELLED = 'file_copy_cancelled_by_operator' AGENT_ERROR_FILE_COPY_PUBLISH_OUTCOME_UNKNOWN = 'file_copy_publish_outcome_unknown' AGENT_REASON_DETACHED = 'agent_detached' AGENT_REASON_DISABLED = 'agent_disabled' @@ -516,6 +518,7 @@ def parse_positive_int_env(name, default): AGENT_AUDIT_PROPOSAL_CREATED = 'proposal_created' AGENT_AUDIT_ACTION_APPROVE = 'action_approve' AGENT_AUDIT_ACTION_REJECT = 'action_reject' +AGENT_AUDIT_ACTION_CANCEL = 'action_cancel' AGENT_AUDIT_ACTION_RESULT = 'action_result' AGENT_AUDIT_DIRECT_WRITE = 'direct_write' AGENT_AUDIT_TERMINAL_CLEANUP = 'terminal_cleanup' @@ -10184,6 +10187,61 @@ def on_agent_action_reject(data): emit_agent_state(request.sid, state) +@socketio.on(AGENT_EVENT_ACTION_CANCEL) +def on_agent_action_cancel(data): + session_token = socket_session_tokens.get(request.sid) + terminal_id = validate_terminal_id_payload(data) + if not session_token or not terminal_id or not isinstance(data, dict): + return + action_id = data.get('action_id') + proposal_id = data.get('proposal_id') + if not isinstance(action_id, str) and not isinstance(proposal_id, str): + emit_agent_error(request.sid, terminal_id, AGENT_ERROR_ACTION_NOT_FOUND) + return + with agent_lock: + state = get_agent_state(session_token, terminal_id, request.sid) + if not state: + emit_agent_error(request.sid, terminal_id, AGENT_ERROR_NOT_ATTACHED) + return + action, error_code = validate_agent_action_decision(state, data) + if error_code: + emit_agent_decision_error(request.sid, terminal_id, state, action, error_code) + return + if action.get('action_type') != AGENT_ACTION_FILE_COPY: + emit_agent_action_failure(request.sid, action, AGENT_ERROR_ACTION_NOT_ALLOWED) + emit_agent_state(request.sid, state) + return + if action.get('status') not in {AGENT_STATUS_APPROVED, AGENT_STATUS_RUNNING}: + emit_agent_action_result( + request.sid, + action, + action.get('status') or AGENT_STATUS_FAILED, + ) + emit_agent_state(request.sid, state) + return + if not transition_agent_file_copy_action( + state, + action, + AGENT_FILE_COPY_EVENT_CANCEL, + error_code=AGENT_ERROR_FILE_COPY_CANCELLED, + ): + emit_agent_action_result( + request.sid, + action, + action.get('status') or AGENT_STATUS_FAILED, + ) + emit_agent_state(request.sid, state) + return + record_agent_audit_event(state, AGENT_AUDIT_ACTION_CANCEL, action=action) + emit_agent_action_result( + request.sid, + action, + AGENT_STATUS_FAILED, + error_code=AGENT_ERROR_FILE_COPY_CANCELLED, + ) + emit_agent_state(request.sid, state) + + @socketio.on(AGENT_EVENT_VIEWPORT_SNAPSHOT) def on_agent_viewport_snapshot(data): session_token = socket_session_tokens.get(request.sid) diff --git a/docs/agent_socket_contract.md b/docs/agent_socket_contract.md index 5bda44a..aeb7f7f 100644 --- a/docs/agent_socket_contract.md +++ b/docs/agent_socket_contract.md @@ -49,6 +49,7 @@ terminal display text. The mock Agent panel may send `agent_mode_set`, `agent_suggestion_request`, `agent_provider_run_request`, `agent_action_approve`, `agent_action_reject`, +`agent_action_cancel`, `agent_privacy_set`, and `agent_pause`. The approval panel must display only the public action metadata returned by the backend, including `escaped_preview`; it must not receive or render the raw terminal input payload. @@ -820,6 +821,11 @@ that the user requested a copy as authorization. The authorizing viewer shows file-copy approval globally even when another terminal tab is active; ordinary terminal-input approvals remain scoped to their terminal tab. +After approval, the authorizing viewer moves the copy into a cross-tab Transfer +Queue. The queue may request cancellation while the action is `approved` or +`running`. Once the action is `committing`, the destination publish attempt is +already inside the commit barrier and cannot be cancelled. + The browser Files UI has a separate human-initiated **Copy to…** path. Its final **Copy** button is the explicit authorization for that one browser transaction, so it does not create an Agent action. It shares the bounded backend stream, @@ -1151,6 +1157,20 @@ Payload: Rejects a pending action for this exact sid and terminal. +### `agent_action_cancel` + +Payload: + +```json +{ "terminal_id": "main", "action_id": "...", "proposal_id": "agp_..." } +``` + +Stops an approved or running `file_copy` for this exact sid and source +terminal. A successful stop transitions the action to `failed` with +`file_copy_cancelled_by_operator`, which prevents the worker from reading the +next source chunk or entering the commit barrier. A request received after the +action reaches `committing` returns the authoritative current action unchanged. + ### `agent_viewport_snapshot` Payload: diff --git a/templates/index.html b/templates/index.html index 25925ac..fd62e7e 100644 --- a/templates/index.html +++ b/templates/index.html @@ -796,6 +796,14 @@ order: 1; } #agent-action-box.visible { display: flex; flex-direction: column; gap: 8px; } + #agent-action-box.file-copy { + max-height: min(320px, 48dvh); + padding: 7px; + margin-top: 6px; + } + #agent-action-box.file-copy #agent-action-preview, + #agent-action-box.file-copy #agent-action-meta, + #agent-action-box.file-copy #agent-action-pause-btn { display: none; } #agent-action-content { display: grid; gap: 8px; min-height: 0; overflow: auto; overscroll-behavior: contain; } #agent-action-controls { flex-shrink: 0; } #agent-action-meta { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: 6px 10px; color: #bbb; overflow-wrap: anywhere; } @@ -811,6 +819,13 @@ overflow-wrap: anywhere; } #agent-file-copy-details.visible { display: grid; } + #agent-action-box.file-copy #agent-file-copy-details { padding: 6px 8px; gap: 4px; } + #agent-file-copy-details .agent-file-copy-route { + display: grid; + grid-template-columns: 34px minmax(0, 1fr); + gap: 6px; + } + #agent-file-copy-details .agent-file-copy-label { color: #aaa; } #agent-file-copy-warning { color: #ff9f0a; font-weight: 700; } .agent-preview { max-height: 120px; @@ -842,6 +857,114 @@ /* Quick Settings Button */ #quick-settings { cursor: pointer; opacity: 0.6; transition: opacity 0.2s; padding: 0 5px; } #quick-settings:hover { opacity: 1; color: #0a84ff; } + #transfer-queue-btn { cursor: pointer; padding: 0 5px; } + #transfer-queue-btn[hidden] { display: none; } + #transfer-queue-count { + display: inline-flex; + min-width: 15px; + height: 15px; + padding: 0 3px; + box-sizing: border-box; + align-items: center; + justify-content: center; + border-radius: 8px; + background: #0a84ff; + color: #fff; + font-size: 10px; + font-weight: 700; + } + #transfer-queue-panel { + display: none; + position: fixed; + top: calc(var(--tab-bar-height) + 7px); + right: 8px; + z-index: 220; + width: min(430px, calc(100vw - 16px)); + max-height: min(440px, calc(100dvh - var(--tab-bar-height) - 36px)); + box-sizing: border-box; + overflow: hidden; + border: 1px solid #454545; + border-radius: 10px; + background: #202020; + color: #eee; + box-shadow: 0 10px 28px rgba(0, 0, 0, 0.48); + } + #transfer-queue-panel.visible { display: flex; flex-direction: column; } + #transfer-queue-header { + display: flex; + align-items: center; + gap: 8px; + padding: 9px 10px 7px; + border-bottom: 1px solid #383838; + } + #transfer-queue-title { flex: 1; font-size: 13px; } + #transfer-queue-header button, + .transfer-queue-stop { + border: 0; + border-radius: 5px; + background: transparent; + color: #bbb; + cursor: pointer; + font-size: 12px; + } + #transfer-queue-close { width: 26px; height: 26px; font-size: 18px !important; } + #transfer-queue-list { min-height: 0; overflow: auto; } + .transfer-queue-item { + display: grid; + grid-template-columns: 30px minmax(0, 1fr) auto; + gap: 8px; + align-items: start; + padding: 10px; + border-bottom: 1px solid #343434; + } + .transfer-queue-item:last-child { border-bottom: 0; } + .transfer-queue-icon { + display: flex; + width: 28px; + height: 28px; + align-items: center; + justify-content: center; + border-radius: 50%; + background: #303030; + color: #7ab8ff; + font-size: 16px; + } + .transfer-queue-name { + overflow: hidden; + color: #f2f2f2; + font-size: 12px; + font-weight: 600; + text-overflow: ellipsis; + white-space: nowrap; + } + .transfer-queue-route, + .transfer-queue-status { + margin-top: 3px; + overflow: hidden; + color: #aaa; + font: 11px/1.35 Consolas, "Courier New", monospace; + text-overflow: ellipsis; + white-space: nowrap; + } + .transfer-queue-progress { + height: 3px; + margin-top: 7px; + overflow: hidden; + border-radius: 2px; + background: #444; + } + .transfer-queue-progress > span { + display: block; + height: 100%; + background: #0a84ff; + transition: width 0.2s ease; + } + .transfer-queue-item.completed .transfer-queue-icon { color: #54c96b; } + .transfer-queue-item.failed .transfer-queue-icon { color: #ff6961; } + .transfer-queue-stop { min-width: 38px; padding: 5px 6px; } + .transfer-queue-stop:hover:not(:disabled), + #transfer-queue-header button:hover { background: #383838; color: #fff; } + .transfer-queue-stop:disabled { cursor: default; opacity: 0.45; } #debug-hud { position: fixed; right: 10px; @@ -1325,6 +1448,7 @@

Manual browser authorization

+
⋯ @@ -1332,6 +1456,14 @@

Manual browser authorization

+

Agent Info for Current Tab

For an agent running on the Core host. If you launched Core with run_wsl.bat, this means WSL, not the Windows browser environment. It can access any individually authorized tab.

@@ -1487,10 +1619,10 @@

Add tunnel

-
source:
-
destination:
-
size:
-
conflict:
+
From
+
To
+
Size
+
@@ -1574,6 +1706,7 @@

Recover StandTerm session

'committing' ]); const AGENT_VIEWPORT_SNAPSHOT_DEBOUNCE_MS = 250; + const TRANSFER_QUEUE_FINISHED_RETENTION_MS = 10000; const AGENT_SNAPSHOT_BLOCKED_PRIVACY_STATES = new Set(['private_input', 'paste_review', 'paused']); const AGENT_VIEWPORT_RENDER_MAX_PIXELS = 4096 * 4096; const AGENT_VIEWPORT_RENDER_MAX_IMAGE_BYTES = 1024 * 1024; @@ -2307,6 +2440,12 @@

Recover StandTerm session

const newTabBtn = document.getElementById('new-tab-btn'); const closeTabsBtn = document.getElementById('close-tabs-btn'); const sftpStatusBtn = document.getElementById('sftp-status-btn'); + const transferQueueBtn = document.getElementById('transfer-queue-btn'); + const transferQueueCount = document.getElementById('transfer-queue-count'); + const transferQueuePanel = document.getElementById('transfer-queue-panel'); + const transferQueueList = document.getElementById('transfer-queue-list'); + const transferQueueClearBtn = document.getElementById('transfer-queue-clear'); + const transferQueueCloseBtn = document.getElementById('transfer-queue-close'); const sessionRecoveryModal = document.getElementById('session-recovery-modal'); const sessionRecoveryForm = document.getElementById('session-recovery-form'); const sessionRecoveryToken = document.getElementById('session-recovery-token'); @@ -2394,6 +2533,8 @@

Recover StandTerm session

let agentPanelVisible = false; let agentPanelTerminalIdOverride = null; const agentDecisionActionIds = new Set(); + const agentTransfers = new Map(); + let transferQueueVisible = false; let agentPanelPosition = null; let agentPanelDrag = null; let agentExternalTokenCountdownTimer = null; @@ -3686,8 +3827,15 @@

Recover StandTerm session

} function renderAgentActionPanel(state) { - const action = state && state.agent ? state.agent.pendingAction : null; + const pendingAction = state && state.agent ? state.agent.pendingAction : null; + const isPendingFileCopy = pendingAction + && pendingAction.action_type === 'file_copy' + && pendingAction.status === 'pending_approval'; + const action = pendingAction && (pendingAction.action_type !== 'file_copy' || isPendingFileCopy) + ? pendingAction + : null; agentActionBox.classList.toggle('visible', !!action); + agentActionBox.classList.toggle('file-copy', !!isPendingFileCopy); if (!action) { agentActionPreview.innerText = ''; agentFileCopyDetails.classList.remove('visible'); @@ -3703,6 +3851,7 @@

Recover StandTerm session

agentActionControl.innerText = 'no'; agentActionStatus.innerText = '--'; agentApproveBtn.innerText = 'Approve'; + agentRejectBtn.innerText = 'Reject'; agentApproveBtn.disabled = true; agentRejectBtn.disabled = true; agentActionPauseBtn.disabled = true; @@ -3731,7 +3880,7 @@

Recover StandTerm session

} else if (action.destination_exists) { agentFileCopyWarning.innerText = 'The destination exists. Approval reveals the conflict result; no file will be copied.'; } else { - agentFileCopyWarning.innerText = 'The source file will be preserved.'; + agentFileCopyWarning.innerText = ''; } } if (isFileCopy) { @@ -3755,6 +3904,7 @@

Recover StandTerm session

? (action.status || '--') : `${action.status || '--'} · ${percent}%`; agentApproveBtn.innerText = isFileCopy ? 'Approve copy' : 'Approve'; + agentRejectBtn.innerText = isFileCopy ? 'Deny' : 'Reject'; const canDecide = canUseAgentPanel(state) && action.status === 'pending_approval' && !!action.action_id @@ -4550,6 +4700,7 @@

Recover StandTerm session

state.agent.actionRevisions.set(actionId, actionRevision); } if (actionId && status !== 'pending_approval') agentDecisionActionIds.delete(actionId); + if (isFileCopy) updateAgentTransfer(data); state.agent.lastAction = { actionId, actionRevision, @@ -7589,6 +7740,17 @@

Recover StandTerm session

for (const terminalId of [...sshLoginFlows.keys()]) discardSshLogin(terminalId); handledSshSignRequestIds.clear(); agentDecisionActionIds.clear(); + agentTransfers.forEach(record => { + if (isFinishedAgentTransfer(record.action)) return; + record.action = { + ...record.action, + status: 'failed', + error_code: 'connection_lost' + }; + record.cancelPending = false; + scheduleAgentTransferRemoval(record); + }); + renderTransferQueue(); clearSessionRenewTimer(); setServerConnectionState('unavailable'); terminals.forEach(state => { @@ -8965,6 +9127,148 @@

Recover StandTerm session

return `${size.toFixed(size >= 10 ? 1 : 2)} ${unit}`; } + function isFinishedAgentTransfer(action) { + return !!action && ['completed', 'failed'].includes(action.status); + } + + function agentTransferFilename(action) { + const path = typeof action.destination_path === 'string' ? action.destination_path : ''; + const parts = path.split(/[\\/]/).filter(Boolean); + return parts.at(-1) || 'File transfer'; + } + + function agentTransferStatusText(action) { + const copied = normalizeAgentCount(action.bytes_copied); + const total = normalizeAgentCount(action.total_bytes || action.source_size); + const amount = total > 0 ? `${formatSftpBytes(copied)} of ${formatSftpBytes(total)}` : formatSftpBytes(copied); + if (action.status === 'approved') return `Starting · ${formatSftpBytes(total)}`; + if (action.status === 'running') { + const percent = total > 0 ? Math.min(100, Math.floor(copied * 100 / total)) : 0; + return `${percent}% · ${amount}`; + } + if (action.status === 'committing') return `Finishing · ${formatSftpBytes(total)}`; + if (action.status === 'completed') return `Completed · ${formatSftpBytes(total)}`; + if (action.error_code === 'file_copy_cancelled_by_operator') return 'Stopped'; + return action.error_code ? `Failed · ${action.error_code}` : 'Failed'; + } + + function removeAgentTransfer(actionId) { + const record = agentTransfers.get(actionId); + if (!record) return; + if (record.removeTimer) clearTimeout(record.removeTimer); + agentTransfers.delete(actionId); + renderTransferQueue(); + } + + function scheduleAgentTransferRemoval(record) { + if (record.removeTimer || !isFinishedAgentTransfer(record.action)) return; + const actionId = record.action.action_id; + record.removeTimer = setTimeout(() => { + const current = agentTransfers.get(actionId); + if (current === record && isFinishedAgentTransfer(current.action)) { + agentTransfers.delete(actionId); + renderTransferQueue(); + } + }, TRANSFER_QUEUE_FINISHED_RETENTION_MS); + } + + function updateAgentTransfer(data) { + if (!data || data.action_type !== 'file_copy' || typeof data.action_id !== 'string') return; + const status = typeof data.status === 'string' ? data.status : ''; + const existing = agentTransfers.get(data.action_id); + const visibleStatus = ['approved', 'running', 'committing', 'completed'].includes(status); + if (!existing && !visibleStatus) return; + const record = existing || { action: data, cancelPending: false, removeTimer: null }; + if (!existing) { + agentTransfers.set(data.action_id, record); + transferQueueVisible = true; + } + record.action = data; + if (!['approved', 'running'].includes(status)) record.cancelPending = false; + if (isFinishedAgentTransfer(data)) { + scheduleAgentTransferRemoval(record); + } else if (record.removeTimer) { + clearTimeout(record.removeTimer); + record.removeTimer = null; + } + renderTransferQueue(); + } + + function renderTransferQueue() { + const records = [...agentTransfers.values()]; + transferQueueBtn.hidden = records.length === 0; + transferQueueCount.innerText = String(records.length); + if (!records.length) transferQueueVisible = false; + transferQueuePanel.classList.toggle('visible', transferQueueVisible && records.length > 0); + transferQueuePanel.setAttribute('aria-hidden', transferQueueVisible && records.length > 0 ? 'false' : 'true'); + transferQueueClearBtn.disabled = !records.some(record => isFinishedAgentTransfer(record.action)); + transferQueueList.replaceChildren(); + records.forEach(record => { + const action = record.action; + const item = document.createElement('div'); + item.className = `transfer-queue-item ${isFinishedAgentTransfer(action) ? action.status : ''}`; + item.dataset.actionId = action.action_id; + + const icon = document.createElement('div'); + icon.className = 'transfer-queue-icon'; + icon.setAttribute('aria-hidden', 'true'); + icon.innerText = action.status === 'completed' ? '✓' : (action.status === 'failed' ? '!' : '⇄'); + + const content = document.createElement('div'); + const name = document.createElement('div'); + name.className = 'transfer-queue-name'; + name.innerText = agentTransferFilename(action); + const route = document.createElement('div'); + route.className = 'transfer-queue-route'; + route.innerText = `${formatSftpEndpoint(action.source_endpoint)}:${action.source_path || ''} → ${formatSftpEndpoint(action.destination_endpoint)}:${action.destination_path || ''}`; + route.title = route.innerText; + const status = document.createElement('div'); + status.className = 'transfer-queue-status'; + status.innerText = agentTransferStatusText(action); + content.append(name, route, status); + if (action.status !== 'failed') { + const total = normalizeAgentCount(action.total_bytes || action.source_size); + const copied = normalizeAgentCount(action.bytes_copied); + const percent = action.status === 'completed' || action.status === 'committing' + ? 100 + : (total > 0 ? Math.min(100, copied * 100 / total) : 0); + const progress = document.createElement('div'); + progress.className = 'transfer-queue-progress'; + progress.setAttribute('role', 'progressbar'); + progress.setAttribute('aria-valuemin', '0'); + progress.setAttribute('aria-valuemax', '100'); + progress.setAttribute('aria-valuenow', String(Math.floor(percent))); + const bar = document.createElement('span'); + bar.style.width = `${percent}%`; + progress.appendChild(bar); + content.appendChild(progress); + } + + const button = document.createElement('button'); + button.className = 'transfer-queue-stop'; + button.type = 'button'; + if (isFinishedAgentTransfer(action)) { + button.innerText = '×'; + button.title = 'Dismiss'; + button.setAttribute('aria-label', `Dismiss ${agentTransferFilename(action)}`); + button.onclick = () => removeAgentTransfer(action.action_id); + } else { + button.innerText = action.status === 'committing' ? '…' : 'Stop'; + button.disabled = action.status === 'committing' || record.cancelPending; + button.title = action.status === 'committing' ? 'The destination is being published and cannot be stopped' : 'Stop transfer'; + button.onclick = () => { + const state = terminals.get(action.terminal_id); + if (!state || !socket || !socket.connected || button.disabled) return; + record.cancelPending = true; + renderTransferQueue(); + socket.emit('agent_action_cancel', buildAgentActionDecisionPayload(state, action)); + }; + } + item.append(icon, content, button); + transferQueueList.appendChild(item); + }); + } + function formatSftpDetailedBytes(value) { const bytes = Math.max(0, Math.trunc(Number(value) || 0)); return `${formatSftpBytes(bytes)} (${bytes} bytes)`; @@ -11455,6 +11759,19 @@

Recover StandTerm session

}; document.getElementById('settings-option').onclick = openSettings; document.getElementById('quick-settings').onclick = openSettings; + transferQueueBtn.onclick = () => { + transferQueueVisible = !transferQueueVisible; + renderTransferQueue(); + }; + transferQueueCloseBtn.onclick = () => { + transferQueueVisible = false; + renderTransferQueue(); + }; + transferQueueClearBtn.onclick = () => { + [...agentTransfers.entries()].forEach(([actionId, record]) => { + if (isFinishedAgentTransfer(record.action)) removeAgentTransfer(actionId); + }); + }; // This interface exposes only existing page actions, not native privileges. // Desktop checks its own window/origin before invoking it. Targets are IDs, // never terminal labels or other display text. diff --git a/tests/agent_backend_smoke.py b/tests/agent_backend_smoke.py index f4ef123..fa4f71c 100644 --- a/tests/agent_backend_smoke.py +++ b/tests/agent_backend_smoke.py @@ -4684,6 +4684,61 @@ def upload_stream(stream, upload, expected_size, before_read_callback=None, assert completed['result']['source_preserved'] is True assert completed['result']['destination_path'] == '/srv/output/payload (1).bin' + stop_started = threading.Event() + stop_gate = threading.Event() + stop_finished = threading.Event() + + def stopped_upload(stream, _upload, expected_size, before_read_callback=None, + progress_callback=None, pre_commit_callback=None, + report_publish_outcome_unknown=False): + assert report_publish_outcome_unknown is True + stop_started.set() + assert stop_gate.wait(2), 'stopped file copy gate was not released' + try: + before_read_callback(0, expected_size) + finally: + stop_finished.set() + raise AssertionError('operator stop should prevent the next file read') + + destination_bridge.upload_sftp_stream = stopped_upload + stopped_pending = standterm.process_external_agent_command({ + 'op': 'file-copy', + 'token': source_token, + 'terminal_id': source_terminal_id, + 'source_path': '/srv/input/../payload.bin', + 'destination_token': destination_token, + 'destination_terminal_id': destination_terminal_id, + 'destination_path': '/srv/output/payload.bin', + 'conflict_mode': 'keep_both', + }) + stopped_action = last_payload(client, standterm.AGENT_EVENT_ACTION_REQUEST) + client.emit(standterm.AGENT_EVENT_ACTION_APPROVE, { + 'terminal_id': source_terminal_id, + 'action_id': stopped_action['action_id'], + 'proposal_id': stopped_action['proposal_id'], + }) + assert stopped_pending['status'] == standterm.AGENT_STATUS_PENDING_APPROVAL + assert stop_started.wait(1), 'file copy did not start before operator stop' + client.emit(standterm.AGENT_EVENT_ACTION_CANCEL, { + 'terminal_id': source_terminal_id, + 'action_id': stopped_action['action_id'], + 'proposal_id': stopped_action['proposal_id'], + }) + stopped_result = last_payload(client, standterm.AGENT_EVENT_ACTION_RESULT) + assert stopped_result['status'] == standterm.AGENT_STATUS_FAILED + assert stopped_result['error_code'] == standterm.AGENT_ERROR_FILE_COPY_CANCELLED + stop_gate.set() + assert stop_finished.wait(1), 'stopped file copy worker did not exit' + stopped = standterm.process_external_agent_command({ + 'op': 'action-status', + 'token': source_token, + 'terminal_id': source_terminal_id, + 'action_id': stopped_action['action_id'], + }) + assert stopped['status'] == standterm.AGENT_STATUS_FAILED + assert stopped['error_code'] == standterm.AGENT_ERROR_FILE_COPY_CANCELLED + + destination_bridge.upload_sftp_stream = upload_stream rejected_pending = standterm.process_external_agent_command({ 'op': 'file-copy', 'token': source_token, diff --git a/tests/agent_browser_smoke.py b/tests/agent_browser_smoke.py index 8ca40a8..83d6b9d 100644 --- a/tests/agent_browser_smoke.py +++ b/tests/agent_browser_smoke.py @@ -2685,9 +2685,12 @@ def test_file_copy_approval_shows_canonical_plan(browser, access_url): source: document.getElementById('agent-file-copy-source').innerText, destination: document.getElementById('agent-file-copy-destination').innerText, size: document.getElementById('agent-file-copy-size').innerText, - conflict: document.getElementById('agent-file-copy-conflict').innerText, warning: document.getElementById('agent-file-copy-warning').innerText, approve: document.getElementById('agent-approve-btn').innerText, + deny: document.getElementById('agent-reject-btn').innerText, + previewDisplay: getComputedStyle(document.getElementById('agent-action-preview')).display, + metaDisplay: getComputedStyle(document.getElementById('agent-action-meta')).display, + actionHeight: document.getElementById('agent-action-box').getBoundingClientRect().height, approveDisabled: document.getElementById('agent-approve-btn').disabled })""" ) @@ -2695,9 +2698,11 @@ def test_file_copy_approval_shows_canonical_plan(browser, access_url): check(details['source'] == 'builder@source.example:22:/srv/releases/image.bin', 'source plan was not exact') check(details['destination'] == 'Local Shell (bash):/tmp/image.bin', 'destination plan was not exact') check(details['size'] == '1.50 KiB', 'source size was not rendered') - check(details['conflict'] == 'replace', 'replace mode was not rendered') check('atomically replace' in details['warning'], 'replace warning was not explicit') check(details['approve'] == 'Approve copy', 'copy approval button was not explicit') + check(details['deny'] == 'Deny', 'copy denial button was not concise') + check(details['previewDisplay'] == 'none' and details['metaDisplay'] == 'none', 'generic action details expanded the copy prompt') + check(details['actionHeight'] < 220, 'copy approval prompt was not compact') check(details['approveDisabled'] is False, 'copy approval button was unexpectedly disabled') page.evaluate( """payload => window.terminalTest.applyAgentActionPayloadForTest(payload)""", @@ -2796,16 +2801,32 @@ def test_file_copy_approval_is_global_and_decision_is_single_shot(browser, acces ) progress = page.evaluate( """() => ({ - visible: document.getElementById('agent-action-box').classList.contains('visible'), - bytes: document.getElementById('agent-action-bytes').innerText, - status: document.getElementById('agent-action-status').innerText, - approveDisabled: document.getElementById('agent-approve-btn').disabled + actionVisible: document.getElementById('agent-action-box').classList.contains('visible'), + queueButtonVisible: !document.getElementById('transfer-queue-btn').hidden, + queueVisible: document.getElementById('transfer-queue-panel').classList.contains('visible'), + count: document.getElementById('transfer-queue-count').innerText, + status: document.querySelector('.transfer-queue-status')?.innerText, + progress: document.querySelector('.transfer-queue-progress')?.getAttribute('aria-valuenow'), + stop: document.querySelector('.transfer-queue-stop')?.innerText, + toolbarOrder: [ + document.getElementById('sftp-status-btn').nextElementSibling.id, + document.getElementById('transfer-queue-btn').nextElementSibling.id + ] })""" ) - check(progress['visible'] is True, 'running file copy progress was hidden') - check(progress['bytes'] == '768 B / 1.50 KiB', 'file copy byte progress was incorrect') - check(progress['status'] == 'running · 50%', 'file copy percentage was incorrect') - check(progress['approveDisabled'] is True, 'running file copy could still be approved') + check(progress['actionVisible'] is False, 'running file copy kept the approval prompt open') + check(progress['queueButtonVisible'] is True and progress['queueVisible'] is True, 'running transfer queue was hidden') + check(progress['count'] == '1', 'transfer queue count was incorrect') + check(progress['status'] == '50% · 768 B of 1.50 KiB', 'file copy progress was incorrect') + check(progress['progress'] == '50', 'file copy progress bar was incorrect') + check(progress['stop'] == 'Stop', 'running transfer could not be stopped') + check(progress['toolbarOrder'] == ['transfer-queue-btn', 'quick-settings'], 'transfer queue was not between Files and Settings') + + clear_emitted(page) + page.click('.transfer-queue-stop') + cancel_events = get_emitted(page, 'agent_action_cancel') + check(len(cancel_events) == 1, 'transfer stop did not emit one cancellation') + check(cancel_events[0]['args'][0]['terminal_id'] == 'main', 'transfer stop targeted the active tab instead of its source tab') page.evaluate( "payload => window.terminalTest.applyAgentActionPayloadForTest(payload)", @@ -2820,6 +2841,18 @@ def test_file_copy_approval_is_global_and_decision_is_single_shot(browser, acces ) check(monotonic['last_action']['status'] == 'completed', 'stale progress replaced the completed action') check(monotonic['pending_action'] is None, 'stale progress reopened the completed action') + completed_queue = page.evaluate( + """() => ({ + status: document.querySelector('.transfer-queue-status')?.innerText, + dismiss: document.querySelector('.transfer-queue-stop')?.innerText, + buttonVisible: !document.getElementById('transfer-queue-btn').hidden + })""" + ) + check(completed_queue['status'] == 'Completed · 1.50 KiB', 'completed transfer result was not retained') + check(completed_queue['dismiss'] == '×', 'completed transfer did not offer dismissal') + check(completed_queue['buttonVisible'] is True, 'completed transfer disappeared immediately') + page.wait_for_selector('#transfer-queue-btn', state='hidden', timeout=12000) + check(page.locator('#transfer-queue-panel').get_attribute('aria-hidden') == 'true', 'empty transfer queue stayed open') page.click('#agent-panel-close-btn') page.evaluate( """payload => window.terminalTest.applyAgentActionPayloadForTest(payload)""", @@ -2871,14 +2904,17 @@ def test_file_copy_approval_keeps_controls_visible_with_long_paths(browser, acce geometry = page.evaluate("""() => { const panel = document.getElementById('agent-panel').getBoundingClientRect(); const content = document.getElementById('agent-action-content'); - const buttons = ['agent-approve-btn', 'agent-reject-btn', 'agent-action-pause-btn'].map(id => { + const actionBox = document.getElementById('agent-action-box').getBoundingClientRect(); + const buttons = ['agent-approve-btn', 'agent-reject-btn'].map(id => { const button = document.getElementById(id); const r = button.getBoundingClientRect(); return r.top >= 0 && r.bottom <= innerHeight && r.left >= 0 && r.right <= innerWidth && document.elementFromPoint(r.x + r.width / 2, r.y + r.height / 2) === button; }); return { buttons, panel: { top: panel.top, bottom: panel.bottom, height: panel.height }, - hits: ['agent-approve-btn', 'agent-reject-btn', 'agent-action-pause-btn'].map(id => { + actionHeight: actionBox.height, + pauseDisplay: getComputedStyle(document.getElementById('agent-action-pause-btn')).display, + hits: ['agent-approve-btn', 'agent-reject-btn'].map(id => { const r = document.getElementById(id).getBoundingClientRect(); const hit = document.elementFromPoint(r.x + r.width / 2, r.y + r.height / 2); return { id: hit?.id, tag: hit?.tagName }; @@ -2890,6 +2926,8 @@ def test_file_copy_approval_keeps_controls_visible_with_long_paths(browser, acce }""") check(all(geometry['buttons']), f'long copy details hid or covered an approval control: {geometry}') check(geometry['panelFits'], 'approval panel exceeded the viewport') + check(geometry['actionHeight'] <= min(320, height * 0.48) + 1, 'copy approval card exceeded its compact limit') + check(geometry['pauseDisplay'] == 'none', 'copy approval showed the unrelated pause control') check(geometry['scrollable'], 'long copy details were not scrollable') check(geometry['noHorizontalOverflow'], 'long paths caused horizontal overflow') page.evaluate("document.getElementById('agent-action-content').scrollTop = 999999") From ed7e4dfea753089be03f9d39a7f22faf15252258 Mon Sep 17 00:00:00 2001 From: "ASKA C." Date: Sat, 19 Sep 2026 21:42:40 +0800 Subject: [PATCH 14/43] Preserve agent targets and bound capture waits ## Why Treating main as an omitted terminal can route explicitly targeted input to another handoff terminal. Continuous output can extend capture settling indefinitely after the initial output wait. ## What changed - Preserve explicit terminal and token selections while retaining latest-handoff defaults. - Bound capture by wait_ms and retain completed writes and captured output on timeout. - Document the contracts and add a staged UI copy review and translation table draft. ## Testing Backend smoke checks pass across 169 cases, including multi-terminal isolation, CLI mismatch rejection without writes, continuous and late output, and capture timeouts. All 56 CLI/helper smoke cases pass, including explicit overrides and omitted-target defaults. The 19-row copy table passes key and placeholder checks. --- app.py | 7 ++- docs/agent_socket_contract.md | 25 +++++--- docs/agent_ui_review_plan.md | 112 ++++++++++++++++++++++++++++++++++ docs/ui_copy_review.tsv | 20 ++++++ scripts/agent_cli.py | 2 +- scripts/agent_repl.py | 2 +- tests/agent_backend_smoke.py | 80 +++++++++++++++++++++++- tests/agent_repl_smoke.py | 31 +++++++++- 8 files changed, 264 insertions(+), 15 deletions(-) create mode 100644 docs/agent_ui_review_plan.md create mode 100644 docs/ui_copy_review.tsv diff --git a/app.py b/app.py index 51febd3..e6f326c 100644 --- a/app.py +++ b/app.py @@ -4387,6 +4387,7 @@ def build_external_agent_send_capture_payload(bridge, state, before_output_seq, strip_ansi=False): wait_ms = parse_external_agent_send_capture_wait_ms(wait_ms) settle_ms = parse_external_agent_send_capture_settle_ms(settle_ms) + deadline = time.monotonic() + wait_ms / 1000.0 context_error = get_external_agent_capture_context_error(state) if context_error: return None, context_error @@ -4395,7 +4396,6 @@ def build_external_agent_send_capture_payload(bridge, state, before_output_seq, since_output_seq=before_output_seq, limit=limit, ) - deadline = time.monotonic() + wait_ms / 1000.0 timed_out = False while not tail['events'] and not tail['gap']['detected']: @@ -4422,8 +4422,9 @@ def build_external_agent_send_capture_payload(bridge, state, before_output_seq, context_error = get_external_agent_capture_context_error(state) if context_error: return None, context_error - remaining = settle_deadline - time.monotonic() + remaining = min(settle_deadline, deadline) - time.monotonic() if remaining <= 0: + timed_out = settle_deadline > deadline break with bridge.output_condition: bridge.output_condition.wait(timeout=min(remaining, 0.25)) @@ -4436,7 +4437,7 @@ def build_external_agent_send_capture_payload(bridge, state, before_output_seq, tail = latest last_output_seq = latest['output_seq'] settle_deadline = time.monotonic() + settle_ms / 1000.0 - settled = True + settled = not timed_out context_error = get_external_agent_capture_context_error(state) if context_error: diff --git a/docs/agent_socket_contract.md b/docs/agent_socket_contract.md index aeb7f7f..c315acf 100644 --- a/docs/agent_socket_contract.md +++ b/docs/agent_socket_contract.md @@ -178,9 +178,13 @@ browser-facing address is retained as `browser_url`. Tokenless agentinfo exposes only a structured terminal-id-to-handoff-path index, never the token-bearing file contents. A caller can therefore use `--agentinfo --terminal ` to resolve the matching local token. -Omitting `--terminal` preserves the latest-handoff behavior. Per-terminal files -are written atomically with restrictive permissions and removed when their -matching token is revoked or its terminal/viewer binding is invalidated. Each +Omitting `--terminal` preserves the latest-handoff behavior. An explicit +`--terminal`, including `main`, takes precedence over the handoff terminal. +An explicit token also takes precedence over the handoff token; the server +validates that the effective token belongs to the selected terminal before +allowing a write. Per-terminal files are written atomically with restrictive +permissions and removed when their matching token is revoked or its +terminal/viewer binding is invalidated. Each server process uses a distinct directory so an old launch is never selected as the current instance; graceful shutdown removes the current directory, while fresh agentinfo generation prunes handoffs whose tokens expired or became @@ -978,10 +982,17 @@ adds typed observation metadata: } ``` -If no terminal output arrives before `wait_ms`, the send may still be -`completed`; the timeout is reported only as `capture.status: "timeout"` and -`capture.timed_out: true`. In approval mode, capture is not executed because no -bytes have been written yet; the response remains `pending_approval` and +`wait_ms` bounds the entire capture after the write, including waiting for the +first output and for `settle_ms` of output silence. New output restarts the +silence interval but never extends the total capture deadline. If no output +arrives, or output does not settle within that deadline, the send remains +`completed`; only capture reports `status: "timeout"`, `timed_out: true`, and +`settled: false`. Output already captured is retained, with the usual cursor and +event-limit metadata. A capture timeout does not undo the write and must not +cause the caller to resend the input automatically. + +In approval mode, capture is not executed because no bytes have been written +yet; the response remains `pending_approval` and includes `capture.status: "skipped"` with reason `pending_approval`. Captured tail events are display data only and must not be parsed as StandTerm control state. `strip_ansi` affects only the captured `events[*].data` formatting and diff --git a/docs/agent_ui_review_plan.md b/docs/agent_ui_review_plan.md new file mode 100644 index 0000000..d02f3e9 --- /dev/null +++ b/docs/agent_ui_review_plan.md @@ -0,0 +1,112 @@ +# Agent reliability, UI copy, and localization plan + +## Scope and decisions + +Complete reliability fixes before changing UI copy or introducing localization. +The traverse brief is review input, not a requirement to expand public discovery. + +| Decision | Resolution | +| --- | --- | +| Tokenless agentinfo | Keep the limited bootstrap and handoff index. Do not expose all ungranted terminals or add localization metadata. | +| Omitted terminal | Preserve latest-handoff compatibility. Explicit terminal and token arguments remain authoritative. | +| Capture timeout | `wait_ms` bounds the entire capture after the write, including settling. Retain the completed write and captured events on timeout; never resend automatically. | +| Agent Mint default | Preserve the saved permission and current Full default. Copy changes do not change authorization. | +| Display and control | Localized text is display data. Keep protocol values, error codes, terminal IDs, and control decisions independent of wording. | + +## Work sequence + +| Order | Work | State | Exit criteria | +| --- | --- | --- | --- | +| 1 | Preserve explicit CLI and REPL terminal selection | Implemented and validated | Explicit `main` and other IDs survive handoff loading; omitted IDs retain defaults; mismatched tokens cannot write. | +| 2 | Bound capture settling by the total deadline | Implemented and validated | Quiet, continuous, late, and absent output produce bounded results without replaying input; the send result survives a capture timeout. | +| 3 | Review English UI copy and terminology | Initial review table prepared | Review each proposed change for target, permissions, consequences, and next action; check related tooltips, Desktop help, and tests before applying it. | +| 4 | Finalize the translation exchange table | Draft schema prepared | Stable keys, approved English source, context, and placeholder constraints are sufficient for an independent translator. | +| 5 | Pilot localization in one complete Core workflow | Deferred | Cover static and dynamic text, titles, and accessible names; preserve connected sessions and authorization; support English fallback. | +| 6 | Translate and integrate approved rows | Deferred | AI edits only target-language cells; validate keys and placeholders; review authorization and destructive-action wording. | +| 7 | Expand Core and Desktop coverage | Deferred | Verify secondary windows, native menus, setup, diagnostics, packaging, and representative layouts. | + +## Validation of the reliability changes + +The new explicit-terminal regression failed before the CLI/REPL fix. The new +continuous-output regression failed before the capture deadline fix. + +| Check | Result | +| --- | --- | +| Complete backend smoke suite | 169 checks passed. | +| Complete CLI/helper smoke suite | 56 checks passed. | +| Independent read-only diff review | No correctness or regression findings; separate in-memory checks covered deadline boundaries and explicit overrides. | +| Initial copy-review table | 19 unique keys; valid columns and placeholders; translation cells intentionally empty. | +| Diff whitespace check | Passed. | + +These checks ran in WSL. Browser, Desktop packaging, and physical UART checks +were not rerun for these backend/helper changes. No runtime UI wording changed. + +## Deferred UI work + +Stages 3 and 4 do not change runtime UI text. Translation does not start until +the English source for the selected workflow is reviewed. Extra screen diffing, +render hints, key aliases, and byte-limit options remain optional optimizations. +Unverified UART end-to-end coverage is a qualification gap, not evidence that +UART is broken. + +## Copy review and AI translation exchange + +The adjacent `ui_copy_review.tsv` is an initial review table, not a complete +catalog or a runtime resource. `en` contains a proposed source revision; +`zh-TW` is intentionally empty. `current_en` is the visible English text, with +dynamic values normalized to the named placeholders declared in the row. +HTML emphasis is omitted from the table. Source references identify the current +implementation and can move as the code changes. + +| Column | Ownership and purpose | +| --- | --- | +| `key` | Stable semantic identity, maintained by the implementer; never translate it. | +| `current_en` | Review baseline, not a second runtime source. | +| `en` | Proposed English source; becomes translation input only after review. | +| `zh-TW` | Target-language text; the only field an outsourced translation agent edits. | +| `context` | Meaning, audience, and placement. | +| `placeholders` | Named runtime values that must remain unchanged in translation. | +| `constraints` | Scope, safety, terminology, and formatting requirements. | +| `status` | `proposed`, `retain`, or `remove` during copy review; use `source-approved` before translation and `translation-reviewed` after review. | +| `source` | Implementation location for checking behavior and surrounding text. | + +Use UTF-8 TSV with one physical line per row. Represent intended line breaks as +literal `\n`; disallow literal tabs or newlines inside cells. Use a proper TSV +reader/writer for spreadsheet exchange. A removal row has an empty `en` cell +and must not be translated. Empty translations fall back to English; they do +not mean that the UI text should be removed. + +For translation, export only source-approved rows with their context and +constraints. Keep the key set and all non-target cells unchanged on return. +Validate duplicate/missing keys, named placeholder multiplicity, and unknown +columns before accepting an AI-produced file. Render interpolated values as +text; do not let translations introduce executable markup. + +Keep terminal output, commands, paths, fingerprints, protocol enums, and error +codes unchanged. Human-facing labels may be localized independently of the +machine-facing connection prompt. Do not translate an arbitrary backend error +by matching its English message; use a known structured error code or preserve +the original diagnostic as fallback. + +## Localization implementation boundary + +Use the approved table as the only manually maintained catalog and generate +runtime dictionaries from it. A small exporter/validator and lookup helper are +sufficient for a pilot; a new UI framework or bundler is not required. Keep the +current direct-launch workflow and verify generated resources during packaging. + +Store language as a local display preference, not server-global session state. +Do not reload a connected UI merely to change its language. A first version +may apply the choice on the next UI opening; live switching across all open +windows is a separate scope decision. + +The pilot must include dynamic states, `title`, `aria-label`, document language, +and a narrow-window check. Keep behavior tests tied to IDs and typed state; +assert localized wording in dedicated presentation checks. Fix the locale of +existing English smoke tests so OS language does not change their results. + +The initial estimate is 4-7 engineering days for common Core workflows and +15-25 days cumulatively for full Core plus Desktop. These are preliminary +estimates, excluding the reliability fixes, website and CLI documentation, +remote terminal output, and complete live language switching. A full string +inventory and pilot are needed before committing to a delivery estimate. diff --git a/docs/ui_copy_review.tsv b/docs/ui_copy_review.tsv new file mode 100644 index 0000000..f5b581c --- /dev/null +++ b/docs/ui_copy_review.tsv @@ -0,0 +1,20 @@ +key current_en en zh-TW context placeholders constraints status source +browser.auth.warning YOU SHALL NOT PASS!! Decorative warning above the browser authorization form. Remove only this decorative warning; keep the authorization state and recovery actions. remove templates/index.html:1345 +browser.auth.required Unauthorized browser Browser authorization required Title above the browser authorization form. Do not imply that an Agent token can authorize a browser. proposed templates/index.html:1346 +browser.auth.instructions First time? Please use an Auth URL. Paste a browser authorization URL to continue. Instruction above the browser authorization URL input. Keep browser authorization separate from Agent access and the launcher Access URL. proposed templates/index.html:1347 +agent.access.grant Agent Mint Authorize agent Primary action applies the saved permission and creates a token for the active tab. Retain a tooltip naming the target and permission and explaining token creation; this action can grant direct input. proposed templates/index.html:1445 +agent.connection.title Agent Info for Current Tab Agent connection Connection info for the environment selected by the active tab. Show the Core or SSH host nearby. This dialog does not restrict grants to the active tab. proposed templates/index.html:1446 +agent.connection.copy Copy Prompt Copy Prompt Copies connection instructions for an external agent. Keep the adjacent destination environment visible. Copying does not authorize or mint. retain templates/index.html:1486 +agent.permission.observe Observer Read only Agent permission selector. Keep the underlying observe value unchanged; this mode cannot send terminal input. proposed templates/index.html:3555 +agent.permission.approval Approval Approval required Agent permission selector. Keep approval_pending unchanged; input waits for human approval. proposed templates/index.html:3556 +agent.permission.direct Full Direct input Agent permission selector. Keep direct_active unchanged; do not imply unrestricted file-copy authority. proposed templates/index.html:3557 +agent.permission.observe_hint Observer can read terminal output. It cannot type. Read terminal output without sending input. Explanation for the read-only permission. No promise of access through pause, privacy, expiry, or other gates. proposed templates/index.html:3563 +agent.permission.approval_hint Approval lets the agent propose input. You approve before it is sent. Remote file copies always need separate approval. Approve each input before it is sent. File copies require separate approval. Explanation for approval-mode Agent access. File copies include supported local endpoints; do not describe them as remote-only. proposed templates/index.html:3564 +agent.permission.direct_hint Full lets the agent type into this terminal without approval. Remote file copies still need separate approval. Allow input without individual approval. File copies still require approval. Explanation for direct-mode Agent access. Retain the file-copy exception; do not change other input gates. proposed templates/index.html:3565 +agent.token.create Mint token Create token Advanced action for a tab whose Agent access is already enabled. Distinguish token creation from changing the permission or enabling access. proposed templates/index.html:1594 +agent.token.expired Permission expired. Renew the token to continue. Token expired. Renew it to continue. Expired token hint in the Agent panel. Expiry concerns the token; it does not necessarily reset the selected permission. proposed templates/index.html:3762 +agent.token.idle_remaining Permission expires in {seconds}s without activity. Token expires in {seconds}s without activity. Token idle countdown in the Agent panel. {seconds} Use the typed remaining idle time; map secondsRemaining to seconds when implementing. proposed templates/index.html:3764 +agent.copy.replace This will atomically replace an existing regular file ({size}). Replace the existing file ({size}). Warning before approval of a replace-mode Agent file copy. {size} Keep the destination path, size, and replacement consequence visible. Atomic publication remains a backend guarantee. proposed templates/index.html:3876 +agent.copy.keep_both The displayed destination is the exact keep-both name selected by the backend. Copy to the destination shown; keep the existing file. Warning for a keep-both copy using the canonical destination. Do not imply the user can edit the chosen path in this approval dialog. proposed templates/index.html:3879 +agent.transfer.stop Stop Stop Stops a running transfer before publication. Do not merge with Reject, Pause, Disable, Close, or Dismiss; each has a different effect. retain templates/index.html:9256 +agent.transfer.committing The destination is being published and cannot be stopped Finishing the copy; it can no longer be stopped. Tooltip while transfer publication has crossed the cancellation barrier. Keep the stop control disabled; do not imply a finished or failed result. proposed templates/index.html:9258 diff --git a/scripts/agent_cli.py b/scripts/agent_cli.py index ed84106..5103cf5 100644 --- a/scripts/agent_cli.py +++ b/scripts/agent_cli.py @@ -227,7 +227,7 @@ def apply_handoff(args): args.url = payload.get('url') if not args.token: args.token = payload.get('token') - if args.terminal in (None, 'main') and isinstance(payload.get('terminal_id'), str): + if args.terminal is None and isinstance(payload.get('terminal_id'), str): args.terminal = payload['terminal_id'] if not args.terminal: args.terminal = 'main' diff --git a/scripts/agent_repl.py b/scripts/agent_repl.py index da06530..9d95f53 100644 --- a/scripts/agent_repl.py +++ b/scripts/agent_repl.py @@ -182,7 +182,7 @@ def apply_handoff(args): args.url = payload.get('url') if not args.token: args.token = payload.get('token') - if args.terminal in (None, 'main') and isinstance(payload.get('terminal_id'), str): + if args.terminal is None and isinstance(payload.get('terminal_id'), str): args.terminal = payload['terminal_id'] if not args.terminal: args.terminal = 'main' diff --git a/tests/agent_backend_smoke.py b/tests/agent_backend_smoke.py index fa4f71c..d17c8f6 100644 --- a/tests/agent_backend_smoke.py +++ b/tests/agent_backend_smoke.py @@ -2322,6 +2322,67 @@ def test_external_agent_send_wait_times_out_without_output(): client.disconnect() +def test_external_agent_send_capture_bounds_settle_and_preserves_written_result(): + cases = ( + ('settled', [0.01], False), + ('continuous', [0.01 + index * 0.02 for index in range(20)], True), + ('late', [0.09], True), + ) + for name, output_times, timed_out in cases: + client = make_client() + session_token = current_session_token() + bridge = add_dummy_bridge(session_token) + sid = current_sid_for_session(session_token) + client.emit(standterm.AGENT_EVENT_ATTACH, {'terminal_id': standterm.TERMINAL_ID_MAIN}) + client.emit(standterm.AGENT_EVENT_MODE_SET, { + 'terminal_id': standterm.TERMINAL_ID_MAIN, + 'mode': 'direct', + }) + token, _record, error_code = standterm.mint_external_agent_attach_token( + session_token, standterm.TERMINAL_ID_MAIN, sid, + ) + assert error_code is None + clock = [0.0] + pending = list(output_times) + emitted = [] + + def wait_for_output(timeout): + wake_at = clock[0] + timeout + if pending and pending[0] <= wake_at: + clock[0] = pending.pop(0) + text = f'{name}-{len(emitted)}\n' + emitted.append(text) + bridge.emit_output({'message_type': 'terminal', 'data': text}) + else: + clock[0] = wake_at + + try: + with patch.object(standterm.time, 'monotonic', side_effect=lambda: clock[0]), \ + patch.object(bridge.output_condition, 'wait', side_effect=wait_for_output): + result = standterm.process_external_agent_command({ + 'op': 'send-wait', + 'token': token, + 'terminal_id': standterm.TERMINAL_ID_MAIN, + 'data': 'run-once\n', + 'wait_ms': 100, + 'settle_ms': 30, + }) + assert clock[0] <= 0.1 + 1e-9, name + assert result['status'] == standterm.AGENT_STATUS_COMPLETED + assert result['bytes_written'] == len('run-once\n') + assert bridge.writes == ['run-once\n'] + capture = result['capture'] + assert capture['status'] == ('timeout' if timed_out else 'ok'), name + assert capture['timed_out'] is timed_out, name + assert capture['settled'] is not timed_out, name + assert [event['data'] for event in capture['events']] == emitted + assert emitted + if timed_out: + assert abs(clock[0] - 0.1) < 1e-9, name + finally: + client.disconnect() + + def test_external_agent_approval_send_capture_is_pending_without_capture(): client = make_client() session_token = current_session_token() @@ -3007,7 +3068,7 @@ def test_external_agent_per_terminal_handoffs_are_isolated_and_cli_resolvable(): flask_client = make_flask_client() client = make_socket_client(flask_client) session_token = current_session_token() - terminal_ids = ('term-2', 'term-3') + terminal_ids = ('main', 'term-2', 'term-3') bridges_by_terminal = {} for terminal_id in terminal_ids: bridge = DummyBridge(session_token, terminal_id) @@ -3106,9 +3167,25 @@ def send_to_terminal(terminal_id): for thread in threads: thread.join() assert all(result['status'] == standterm.AGENT_STATUS_COMPLETED for result in results.values()) + assert bridges_by_terminal['main'].writes == ['main-input\n'] assert bridges_by_terminal['term-2'].writes == ['term-2-input\n'] assert bridges_by_terminal['term-3'].writes == ['term-3-input\n'] + def post_cli_command(_url, payload, **_kwargs): + result = standterm.process_external_agent_command(payload) + return 200, result + + output = io.StringIO() + with patch.object(sys, 'argv', [ + 'agent_cli.py', '--handoff', str(standterm.EXTERNAL_AGENT_HANDOFF_PATH), + '--terminal', 'main', 'send', '--text', 'wrong-target\n', + ]), patch.object(agent_cli, 'post_json', side_effect=post_cli_command), \ + contextlib.redirect_stdout(output): + assert agent_cli.main() == 1 + assert json.loads(output.getvalue())['error_code'] == standterm.AGENT_ERROR_TERMINAL_MISMATCH + for terminal_id in terminal_ids: + assert bridges_by_terminal[terminal_id].writes == [f'{terminal_id}-input\n'] + mismatch = standterm.process_external_agent_command({ 'op': 'screen', 'token': minted['term-3']['token'], @@ -8675,6 +8752,7 @@ def main(): test_external_agent_direct_send_capture_returns_tail_after_write, test_external_agent_send_wait_strip_ansi_formats_capture_only_when_requested, test_external_agent_send_wait_times_out_without_output, + test_external_agent_send_capture_bounds_settle_and_preserves_written_result, test_external_agent_approval_send_capture_is_pending_without_capture, test_external_agent_send_capture_reports_pause_as_nested_capture_error, test_human_input_lease_blocks_external_agent_send, diff --git a/tests/agent_repl_smoke.py b/tests/agent_repl_smoke.py index 6ef9f8a..25ebcc0 100644 --- a/tests/agent_repl_smoke.py +++ b/tests/agent_repl_smoke.py @@ -378,7 +378,7 @@ def test_cli_and_repl_apply_handoff_defaults(tmp_path=None): handoff=str(handoff_path), url=None, token=None, - terminal='main', + terminal=None, ca_file=None, ) cli.apply_handoff(cli_args) @@ -391,7 +391,7 @@ def test_cli_and_repl_apply_handoff_defaults(tmp_path=None): handoff=str(handoff_path), url='http://override', token=None, - terminal='main', + terminal=None, ca_file=None, ) repl.apply_handoff(repl_args) @@ -403,6 +403,32 @@ def test_cli_and_repl_apply_handoff_defaults(tmp_path=None): handoff_path.unlink(missing_ok=True) +def test_cli_and_repl_preserve_explicit_terminal_and_token(): + with tempfile.TemporaryDirectory(prefix='standterm-handoff-selection-smoke-') as temp_dir: + handoff_path = Path(temp_dir) / 'handoff.json' + handoff_path.write_text(json.dumps({ + 'url': 'http://127.0.0.1:5012', + 'token': 'agt_handoff', + 'terminal_id': 'term-2', + }), encoding='utf-8') + old_argv = sys.argv + try: + for helper, command in ((cli, ['hello']), (repl, ['--no-initial-screen'])): + for terminal in (None, 'main', 'term-3'): + for token in (None, 'agt_override'): + sys.argv = [helper.__file__, '--handoff', str(handoff_path)] + if terminal is not None: + sys.argv.extend(['--terminal', terminal]) + if token is not None: + sys.argv.extend(['--token', token]) + sys.argv.extend(command) + args = helper.parse_args() + assert args.terminal == (terminal if terminal is not None else 'term-2') + assert args.token == (token or 'agt_handoff') + finally: + sys.argv = old_argv + + def test_agentinfo_bootstraps_jsonl_repl_and_type_helpers(): with tempfile.TemporaryDirectory(prefix='standterm-agentinfo-helper-smoke-') as temp_dir: temp_path = Path(temp_dir) @@ -1416,6 +1442,7 @@ def main(): test_repl_startup_type_stops_on_fatal_error, test_format_token_status_reports_idle_countdown, test_cli_and_repl_apply_handoff_defaults, + test_cli_and_repl_preserve_explicit_terminal_and_token, test_agentinfo_bootstraps_jsonl_repl_and_type_helpers, test_cli_plain_send_payload_uses_structured_text, test_cli_screen_tail_lines_payload, From cf9958d65b04c177f534a7e0b783785b4f9605b0 Mon Sep 17 00:00:00 2001 From: "ASKA C." Date: Sat, 19 Sep 2026 22:17:50 +0800 Subject: [PATCH 15/43] Define Agent terminology and translate review copy --- docs/agent_ui_review_plan.md | 40 +++++++++++++++++++++++++++++------- docs/ui_copy_review.tsv | 36 ++++++++++++++++---------------- 2 files changed, 51 insertions(+), 25 deletions(-) diff --git a/docs/agent_ui_review_plan.md b/docs/agent_ui_review_plan.md index d02f3e9..cb99e53 100644 --- a/docs/agent_ui_review_plan.md +++ b/docs/agent_ui_review_plan.md @@ -13,16 +13,37 @@ The traverse brief is review input, not a requirement to expand public discovery | Agent Mint default | Preserve the saved permission and current Full default. Copy changes do not change authorization. | | Display and control | Localized text is display data. Keep protocol values, error codes, terminal IDs, and control decisions independent of wording. | +## Agreed terminology + +The operator confirmed the following terminology. Keep these distinctions in +both the English source and Traditional Chinese translations. + +| Concept | English UI wording | Traditional Chinese (Taiwan) | Meaning and boundary | +| --- | --- | --- | --- | +| Agent | Agent | Agent | Retain the name; avoid confusing the terminal agent with a network proxy. | +| Grant access | Authorize agent | 授權 Agent | The operator permits Agent access to the selected terminal. The primary action also applies the saved permission and creates a token; identify the permission and target in nearby text or the tooltip. | +| Allowed operations | Permission | 權限 | Determines what an authorized Agent may do. | +| Connection credential | Token | 權杖 | Presented by the Agent when connecting. Expiry does not necessarily reset the selected permission. | +| Create a credential | Create token | 建立權杖 | Creates a token for already enabled access; do not conflate it with changing permission or the primary Authorize agent action. | +| Observe mode | Read only | 唯讀 | Reads terminal information but cannot send terminal input. | +| Approval mode | Approval required | 需核准 | Each input proposal requires human approval before it is sent. | +| Direct mode | Direct input | 直接輸入 | Input does not require individual approval; file copies still require approval and other input gates still apply. | + +The protocol values `observe`, `approval_pending`, and `direct_active` remain +unchanged. Permission labels describe behavior, not unrestricted authority. +Pause, Disable, Stop, Cancel, Reject, Close, and Dismiss remain distinct actions; +do not consolidate them merely to shorten labels. + ## Work sequence | Order | Work | State | Exit criteria | | --- | --- | --- | --- | | 1 | Preserve explicit CLI and REPL terminal selection | Implemented and validated | Explicit `main` and other IDs survive handoff loading; omitted IDs retain defaults; mismatched tokens cannot write. | | 2 | Bound capture settling by the total deadline | Implemented and validated | Quiet, continuous, late, and absent output produce bounded results without replaying input; the send result survives a capture timeout. | -| 3 | Review English UI copy and terminology | Initial review table prepared | Review each proposed change for target, permissions, consequences, and next action; check related tooltips, Desktop help, and tests before applying it. | -| 4 | Finalize the translation exchange table | Draft schema prepared | Stable keys, approved English source, context, and placeholder constraints are sufficient for an independent translator. | +| 3 | Review English UI copy and terminology | Core terms agreed; initial English sources reviewed | Review each proposed change for target, permissions, consequences, and next action; check related tooltips, Desktop help, and tests before applying it. | +| 4 | Finalize the translation exchange table | Initial exchange validated with an independent translator | Stable keys, approved English source, context, and placeholder constraints are sufficient for an independent translator. | | 5 | Pilot localization in one complete Core workflow | Deferred | Cover static and dynamic text, titles, and accessible names; preserve connected sessions and authorization; support English fallback. | -| 6 | Translate and integrate approved rows | Deferred | AI edits only target-language cells; validate keys and placeholders; review authorization and destructive-action wording. | +| 6 | Translate and integrate approved rows | Initial translations reviewed; runtime integration deferred | AI edits only target-language cells; validate keys and placeholders; review authorization and destructive-action wording. | | 7 | Expand Core and Desktop coverage | Deferred | Verify secondary windows, native menus, setup, diagnostics, packaging, and representative layouts. | ## Validation of the reliability changes @@ -35,7 +56,8 @@ continuous-output regression failed before the capture deadline fix. | Complete backend smoke suite | 169 checks passed. | | Complete CLI/helper smoke suite | 56 checks passed. | | Independent read-only diff review | No correctness or regression findings; separate in-memory checks covered deadline boundaries and explicit overrides. | -| Initial copy-review table | 19 unique keys; valid columns and placeholders; translation cells intentionally empty. | +| Initial copy-review table | 19 unique keys; valid columns and placeholders. | +| Translation exchange | 18 translations reviewed; one removal row excluded. All translator edits were confined to the target column; keys, placeholders, and agreed terminology were verified before the reviewer updated statuses. | | Diff whitespace check | Passed. | These checks ran in WSL. Browser, Desktop packaging, and physical UART checks @@ -52,8 +74,9 @@ UART is broken. ## Copy review and AI translation exchange The adjacent `ui_copy_review.tsv` is an initial review table, not a complete -catalog or a runtime resource. `en` contains a proposed source revision; -`zh-TW` is intentionally empty. `current_en` is the visible English text, with +catalog or a runtime resource. `en` contains the reviewed English source; +`zh-TW` holds its translation when available. `current_en` is the visible +English text, with dynamic values normalized to the named placeholders declared in the row. HTML emphasis is omitted from the table. Source references identify the current implementation and can move as the code changes. @@ -62,7 +85,7 @@ implementation and can move as the code changes. | --- | --- | | `key` | Stable semantic identity, maintained by the implementer; never translate it. | | `current_en` | Review baseline, not a second runtime source. | -| `en` | Proposed English source; becomes translation input only after review. | +| `en` | English source; becomes translation input only after review. | | `zh-TW` | Target-language text; the only field an outsourced translation agent edits. | | `context` | Meaning, audience, and placement. | | `placeholders` | Named runtime values that must remain unchanged in translation. | @@ -78,6 +101,9 @@ not mean that the UI text should be removed. For translation, export only source-approved rows with their context and constraints. Keep the key set and all non-target cells unchanged on return. +Source approval records editorial review for translation, not deployment to +the UI. A translation agent fills only the target-language column; the reviewer +updates the status after checking its meaning and formatting. Validate duplicate/missing keys, named placeholder multiplicity, and unknown columns before accepting an AI-produced file. Render interpolated values as text; do not let translations introduce executable markup. diff --git a/docs/ui_copy_review.tsv b/docs/ui_copy_review.tsv index f5b581c..da7c2c2 100644 --- a/docs/ui_copy_review.tsv +++ b/docs/ui_copy_review.tsv @@ -1,20 +1,20 @@ key current_en en zh-TW context placeholders constraints status source browser.auth.warning YOU SHALL NOT PASS!! Decorative warning above the browser authorization form. Remove only this decorative warning; keep the authorization state and recovery actions. remove templates/index.html:1345 -browser.auth.required Unauthorized browser Browser authorization required Title above the browser authorization form. Do not imply that an Agent token can authorize a browser. proposed templates/index.html:1346 -browser.auth.instructions First time? Please use an Auth URL. Paste a browser authorization URL to continue. Instruction above the browser authorization URL input. Keep browser authorization separate from Agent access and the launcher Access URL. proposed templates/index.html:1347 -agent.access.grant Agent Mint Authorize agent Primary action applies the saved permission and creates a token for the active tab. Retain a tooltip naming the target and permission and explaining token creation; this action can grant direct input. proposed templates/index.html:1445 -agent.connection.title Agent Info for Current Tab Agent connection Connection info for the environment selected by the active tab. Show the Core or SSH host nearby. This dialog does not restrict grants to the active tab. proposed templates/index.html:1446 -agent.connection.copy Copy Prompt Copy Prompt Copies connection instructions for an external agent. Keep the adjacent destination environment visible. Copying does not authorize or mint. retain templates/index.html:1486 -agent.permission.observe Observer Read only Agent permission selector. Keep the underlying observe value unchanged; this mode cannot send terminal input. proposed templates/index.html:3555 -agent.permission.approval Approval Approval required Agent permission selector. Keep approval_pending unchanged; input waits for human approval. proposed templates/index.html:3556 -agent.permission.direct Full Direct input Agent permission selector. Keep direct_active unchanged; do not imply unrestricted file-copy authority. proposed templates/index.html:3557 -agent.permission.observe_hint Observer can read terminal output. It cannot type. Read terminal output without sending input. Explanation for the read-only permission. No promise of access through pause, privacy, expiry, or other gates. proposed templates/index.html:3563 -agent.permission.approval_hint Approval lets the agent propose input. You approve before it is sent. Remote file copies always need separate approval. Approve each input before it is sent. File copies require separate approval. Explanation for approval-mode Agent access. File copies include supported local endpoints; do not describe them as remote-only. proposed templates/index.html:3564 -agent.permission.direct_hint Full lets the agent type into this terminal without approval. Remote file copies still need separate approval. Allow input without individual approval. File copies still require approval. Explanation for direct-mode Agent access. Retain the file-copy exception; do not change other input gates. proposed templates/index.html:3565 -agent.token.create Mint token Create token Advanced action for a tab whose Agent access is already enabled. Distinguish token creation from changing the permission or enabling access. proposed templates/index.html:1594 -agent.token.expired Permission expired. Renew the token to continue. Token expired. Renew it to continue. Expired token hint in the Agent panel. Expiry concerns the token; it does not necessarily reset the selected permission. proposed templates/index.html:3762 -agent.token.idle_remaining Permission expires in {seconds}s without activity. Token expires in {seconds}s without activity. Token idle countdown in the Agent panel. {seconds} Use the typed remaining idle time; map secondsRemaining to seconds when implementing. proposed templates/index.html:3764 -agent.copy.replace This will atomically replace an existing regular file ({size}). Replace the existing file ({size}). Warning before approval of a replace-mode Agent file copy. {size} Keep the destination path, size, and replacement consequence visible. Atomic publication remains a backend guarantee. proposed templates/index.html:3876 -agent.copy.keep_both The displayed destination is the exact keep-both name selected by the backend. Copy to the destination shown; keep the existing file. Warning for a keep-both copy using the canonical destination. Do not imply the user can edit the chosen path in this approval dialog. proposed templates/index.html:3879 -agent.transfer.stop Stop Stop Stops a running transfer before publication. Do not merge with Reject, Pause, Disable, Close, or Dismiss; each has a different effect. retain templates/index.html:9256 -agent.transfer.committing The destination is being published and cannot be stopped Finishing the copy; it can no longer be stopped. Tooltip while transfer publication has crossed the cancellation barrier. Keep the stop control disabled; do not imply a finished or failed result. proposed templates/index.html:9258 +browser.auth.required Unauthorized browser Browser authorization required 需要瀏覽器授權 Title above the browser authorization form. Do not imply that an Agent token can authorize a browser. translation-reviewed templates/index.html:1346 +browser.auth.instructions First time? Please use an Auth URL. Paste a browser authorization URL to continue. 貼上瀏覽器授權網址以繼續。 Instruction above the browser authorization URL input. Keep browser authorization separate from Agent access and the launcher Access URL. translation-reviewed templates/index.html:1347 +agent.access.grant Agent Mint Authorize agent 授權 Agent Primary action applies the saved permission and creates a token for the active tab. Retain a tooltip naming the target and permission and explaining token creation; this action can grant direct input. translation-reviewed templates/index.html:1445 +agent.connection.title Agent Info for Current Tab Agent connection Agent 連線 Connection info for the environment selected by the active tab. Show the Core or SSH host nearby. This dialog does not restrict grants to the active tab. translation-reviewed templates/index.html:1446 +agent.connection.copy Copy Prompt Copy Prompt 複製連線指引 Copies connection instructions for an external agent. Keep the adjacent destination environment visible. Copying does not authorize or mint. translation-reviewed templates/index.html:1486 +agent.permission.observe Observer Read only 唯讀 Agent permission selector. Keep the underlying observe value unchanged; this mode cannot send terminal input. translation-reviewed templates/index.html:3555 +agent.permission.approval Approval Approval required 需核准 Agent permission selector. Keep approval_pending unchanged; input waits for human approval. translation-reviewed templates/index.html:3556 +agent.permission.direct Full Direct input 直接輸入 Agent permission selector. Keep direct_active unchanged; do not imply unrestricted file-copy authority. translation-reviewed templates/index.html:3557 +agent.permission.observe_hint Observer can read terminal output. It cannot type. Read terminal output without sending input. 讀取終端輸出,不傳送輸入。 Explanation for the read-only permission. No promise of access through pause, privacy, expiry, or other gates. translation-reviewed templates/index.html:3563 +agent.permission.approval_hint Approval lets the agent propose input. You approve before it is sent. Remote file copies always need separate approval. Approve each input before it is sent. File copies require separate approval. 每筆輸入都需經您核准後才會送出。檔案複製需另外核准。 Explanation for approval-mode Agent access. File copies include supported local endpoints; do not describe them as remote-only. translation-reviewed templates/index.html:3564 +agent.permission.direct_hint Full lets the agent type into this terminal without approval. Remote file copies still need separate approval. Allow input without individual approval. File copies still require approval. 允許直接輸入,無需逐次核准。檔案複製仍需核准。 Explanation for direct-mode Agent access. Retain the file-copy exception; do not change other input gates. translation-reviewed templates/index.html:3565 +agent.token.create Mint token Create token 建立權杖 Advanced action for a tab whose Agent access is already enabled. Distinguish token creation from changing the permission or enabling access. translation-reviewed templates/index.html:1594 +agent.token.expired Permission expired. Renew the token to continue. Token expired. Renew it to continue. 權杖已過期。請更新權杖以繼續。 Expired token hint in the Agent panel. Expiry concerns the token; it does not necessarily reset the selected permission. translation-reviewed templates/index.html:3762 +agent.token.idle_remaining Permission expires in {seconds}s without activity. Token expires in {seconds}s without activity. 若無活動,權杖將在 {seconds} 秒後過期。 Token idle countdown in the Agent panel. {seconds} Use the typed remaining idle time; map secondsRemaining to seconds when implementing. translation-reviewed templates/index.html:3764 +agent.copy.replace This will atomically replace an existing regular file ({size}). Replace the existing file ({size}). 將覆寫既有檔案({size})。 Warning before approval of a replace-mode Agent file copy. {size} Keep the destination path, size, and replacement consequence visible. Atomic publication remains a backend guarantee. translation-reviewed templates/index.html:3876 +agent.copy.keep_both The displayed destination is the exact keep-both name selected by the backend. Copy to the destination shown; keep the existing file. 複製到顯示的目的地,並保留既有檔案。 Warning for a keep-both copy using the canonical destination. Do not imply the user can edit the chosen path in this approval dialog. translation-reviewed templates/index.html:3879 +agent.transfer.stop Stop Stop 停止 Stops a running transfer before publication. Do not merge with Reject, Pause, Disable, Close, or Dismiss; each has a different effect. translation-reviewed templates/index.html:9256 +agent.transfer.committing The destination is being published and cannot be stopped Finishing the copy; it can no longer be stopped. 正在完成複製,已無法停止。 Tooltip while transfer publication has crossed the cancellation barrier. Keep the stop control disabled; do not imply a finished or failed result. translation-reviewed templates/index.html:9258 From b522feba07598d39ca878e88ea63b150b0c070cb Mon Sep 17 00:00:00 2001 From: "ASKA C." Date: Sat, 19 Sep 2026 22:34:40 +0800 Subject: [PATCH 16/43] Add table-driven Agent language preview ## What changed - Clarify Agent authorization, permission and token wording while preserving protocol values and access behavior. - Generate reviewed English and Traditional Chinese messages from a translation exchange table with English fallback. - Apply the saved local language on the next page opening and include required resources in Core bundles. ## Testing Agent browser coverage includes both languages, token lifecycle, unchanged active sessions after saving language, connection info and approval flows. Translation validation, safe interpolation, bilingual dialog layouts and Core bundle selection pass. --- .github/workflows/smoke.yml | 7 ++ README.md | 31 ++++-- desktop/core-files.cjs | 3 +- desktop/main.cjs | 6 +- docs/agent_ui_review_plan.md | 68 ++++++++++-- docs/ui_copy_review.tsv | 63 +++++++++++ scripts/build_ui_messages.py | 87 +++++++++++++++ scripts/run_smoke_tests.py | 5 + static/js/standterm-i18n.js | 37 +++++++ static/js/standterm-messages.js | 174 +++++++++++++++++++++++++++++ templates/index.html | 188 +++++++++++++++++--------------- tests/agent_browser_smoke.py | 124 +++++++++++++++++++-- tests/ui_i18n_smoke.cjs | 97 ++++++++++++++++ tests/ui_messages_smoke.py | 137 +++++++++++++++++++++++ 14 files changed, 902 insertions(+), 125 deletions(-) create mode 100644 scripts/build_ui_messages.py create mode 100644 static/js/standterm-i18n.js create mode 100644 static/js/standterm-messages.js create mode 100644 tests/ui_i18n_smoke.cjs create mode 100644 tests/ui_messages_smoke.py diff --git a/.github/workflows/smoke.yml b/.github/workflows/smoke.yml index 769682a..1cf0994 100644 --- a/.github/workflows/smoke.yml +++ b/.github/workflows/smoke.yml @@ -25,3 +25,10 @@ jobs: - name: Run headless smoke tests run: tools/.venv_linux/bin/python scripts/run_smoke_tests.py + + - uses: actions/setup-node@v4 + with: + node-version: '24' + + - name: Check UI translation lookup + run: node tests/ui_i18n_smoke.cjs diff --git a/README.md b/README.md index 8e13cf3..17c1ebe 100644 --- a/README.md +++ b/README.md @@ -316,13 +316,20 @@ states the token status; tab labels include remaining idle seconds, for example does not mean an agent is currently executing. Revocation, invalidation, or disabling access removes the tint, and connection warnings take priority. Background tabs update without opening the Agent panel. -The **🤖 Agent Mint** action is the leftmost action in the right-side tab tools. -It applies the permission selected in **Settings > General > Agent Access** to +The **🤖 Authorize agent** action is the leftmost action in the right-side tab tools. +It applies the permission selected in **Settings > General > Agent access** to the active terminal, waits for Core to confirm it, and mints a standard token. -The default is **Full + Mint**. The tab-row Mint and Mint 3× buttons remain -beside Pause Agent when the Agent panel is hidden, and always target the active +The default is **Direct input + token**. The tab-row Create token and Create +token 3× buttons remain beside Pause Agent when the Agent panel is hidden, and always target the active terminal; compact windows keep those actions in the Agent panel. +**Settings > General > Language (Agent preview)** selects English or Traditional +Chinese (Taiwan) for Agent access and local connection information. The choice +applies the next time the page opens; saving it does not reload the current +page or change its connections and grants. Other areas remain in English. +The [copy and translation table](docs/ui_copy_review.tsv) and +[localization plan](docs/agent_ui_review_plan.md) describe the review workflow. + This development build also enables an **experimental IME positioning PoC**: the composition overlay follows its starting input line during terminal redraws. It is not yet qualified with real Windows/macOS candidate windows. See @@ -723,7 +730,7 @@ Python helpers, discovery, and per-tab permissions as a local external agent, including normal file-copy approval between two authorized tabs. The dialog shows the remote **Agent Info URL** with **Copy URL** and **Copy -Prompt** actions. **Agent Info for Current Tab** appears in the toolbar only after that +Prompt** actions. **Agent connection** appears in the toolbar only after that SSH tab's tunnel is ready, and opens the same prompt and activity information. The URL's `127.0.0.1` belongs to the SSH host. Paste the prompt to the agent there; it identifies the SSH host and tab and includes the existing skill and discovery @@ -749,7 +756,7 @@ handoffs live in a private temporary directory on the SSH host. The tunnel exposes only scoped Agent discovery and commands over HTTP inside SSH. Disabling or pausing access in Agent Panel immediately restricts remote access. -**Start / Renew Access**, a new Enable, or an explicit browser Mint can renew +**Start / Renew Access**, a new Enable, or an explicit browser Create token can renew expired or revoked grants. Reading info, checking the tunnel, ordinary mode changes, and resume do not renew invalid grants. **Stop Tunnel**, SSH disconnect, or browser viewer disconnect revokes this tunnel's grants and pending input without revoking local agents. @@ -775,19 +782,19 @@ Typical local flow: 1. Launch StandTerm and open the browser. 2. Connect a terminal. -3. Choose **🤖 Agent Mint** to apply the saved permission and mint a standard - token for that terminal in one action. The default is **Full + Mint**. +3. Choose **🤖 Authorize agent** to apply the saved permission and mint a standard + token for that terminal in one action. The default is **Direct input + token**. 4. For another permission or a 3x-idle token, use the browser Agent panel. When the Agent panel is hidden, the same actions are available in the status bar for the active terminal. -5. On a local tab, open **Agent Info for Current Tab** in the toolbar. **Copy URL** provides the +5. On a local tab, open **Agent connection** in the toolbar. **Copy URL** provides the local Agent Info URL; **Copy Prompt** includes the skill, discovery command, and instructions to run `hello` for each intended tab. Give this to the agent running in the Core host environment (WSL when Core runs in WSL). The dialog shows each tab's last authenticated request to confirm access. -Reading or copying a prompt does not mint tokens. The single **Agent Info for -Current Tab** button chooses the environment from the active tab: local tabs +Reading or copying a prompt does not mint tokens. The single **Agent connection** +button chooses the environment from the active tab: local tabs show Core host information; SSH tabs show that host's information after **Agent Tunnel** is ready. The dialog identifies where to run the agent. This choice does not narrow access to one tab; permissions still follow Agent Panel. @@ -892,7 +899,7 @@ stderr. any two attached SSH or Local Shell terminals. Both terminals need separately minted external-agent tokens from the same browser session. Every copy opens a dedicated browser approval card showing the backend-canonical source, -destination, size, and conflict behavior; Full mode does not bypass this +destination, size, and conflict behavior; Direct input mode does not bypass this per-operation approval. File-copy approval appears even when a different terminal tab is active, while ordinary command approvals remain terminal scoped. Approved copies expose typed byte progress through the browser card and diff --git a/desktop/core-files.cjs b/desktop/core-files.cjs index 52f3fc1..976fd83 100644 --- a/desktop/core-files.cjs +++ b/desktop/core-files.cjs @@ -7,7 +7,8 @@ const SKILL_DIRS = ['standterm-external-agent-skill', 'standterm-file-transfer', const REQUIRED = ['app.py', 'core_version.py', 'requirements.txt', 'README.md', 'run.sh', 'run.bat', 'run.command', 'run_at_wsl.bat', 'run_at_wsl+screen.bat', 'install.sh', 'install.ps1', 'install.command', 'LICENSE', 'THIRD-PARTY-NOTICES.md', 'desktop/backend.py', 'desktop/runtime.py', 'docs/agent_socket_contract.md', - 'docs/backend_plugin_contract.md', 'docs/venv_prompt.txt', + 'docs/backend_plugin_contract.md', 'docs/venv_prompt.txt', 'docs/ui_copy_review.tsv', + 'static/js/standterm-i18n.js', 'static/js/standterm-messages.js', ...['cli', 'jsonl', 'repl', 'shcmd', 'scp', 'type', 'rsfile', 'mcp'].map(name => `scripts/agent_${name}.py`), ...SKILL_DIRS.flatMap(name => ['SKILL.md', 'boot_prompt.txt', 'skill_prompt.txt'] .map(file => `docs/examples/${name}/${file}`)), diff --git a/desktop/main.cjs b/desktop/main.cjs index 8a23554..d244f67 100644 --- a/desktop/main.cjs +++ b/desktop/main.cjs @@ -411,9 +411,9 @@ async function start() { type: 'info', title: 'StandTerm Agent', message: 'Give your agent a StandTerm prompt', detail: 'Select the tab where your agent runs. Use Agent Panel to enable access and choose permissions ' + 'on each tab it may operate.\n\n' - + 'For an SSH agent, start Agent Tunnel on its SSH tab. Agent Info for Current Tab appears after setup succeeds. ' - + 'For a local agent, mint a token in Agent Panel.\n\n' - + 'Open Agent Info for Current Tab, choose Copy Prompt, and paste it into your agent with the intended task. ' + + 'For an SSH agent, start Agent Tunnel on its SSH tab. Agent connection appears after setup succeeds. ' + + 'For a local agent, use Authorize agent on each intended tab.\n\n' + + 'Open Agent connection, choose Copy Prompt, and paste it into your agent with the intended task. ' + 'Follow the environment shown in that dialog.\n\n' + 'Skills do not need to be installed first. The prompt leads to the bundled skills and helpers; ' + 'Agent Info also provides installation instructions when persistent skills are wanted.', diff --git a/docs/agent_ui_review_plan.md b/docs/agent_ui_review_plan.md index cb99e53..efea0e7 100644 --- a/docs/agent_ui_review_plan.md +++ b/docs/agent_ui_review_plan.md @@ -10,7 +10,7 @@ The traverse brief is review input, not a requirement to expand public discovery | Tokenless agentinfo | Keep the limited bootstrap and handoff index. Do not expose all ungranted terminals or add localization metadata. | | Omitted terminal | Preserve latest-handoff compatibility. Explicit terminal and token arguments remain authoritative. | | Capture timeout | `wait_ms` bounds the entire capture after the write, including settling. Retain the completed write and captured events on timeout; never resend automatically. | -| Agent Mint default | Preserve the saved permission and current Full default. Copy changes do not change authorization. | +| Primary authorization default | Preserve the saved permission and current Direct input default. Copy changes do not change authorization. | | Display and control | Localized text is display data. Keep protocol values, error codes, terminal IDs, and control decisions independent of wording. | ## Agreed terminology @@ -42,8 +42,8 @@ do not consolidate them merely to shorten labels. | 2 | Bound capture settling by the total deadline | Implemented and validated | Quiet, continuous, late, and absent output produce bounded results without replaying input; the send result survives a capture timeout. | | 3 | Review English UI copy and terminology | Core terms agreed; initial English sources reviewed | Review each proposed change for target, permissions, consequences, and next action; check related tooltips, Desktop help, and tests before applying it. | | 4 | Finalize the translation exchange table | Initial exchange validated with an independent translator | Stable keys, approved English source, context, and placeholder constraints are sufficient for an independent translator. | -| 5 | Pilot localization in one complete Core workflow | Deferred | Cover static and dynamic text, titles, and accessible names; preserve connected sessions and authorization; support English fallback. | -| 6 | Translate and integrate approved rows | Initial translations reviewed; runtime integration deferred | AI edits only target-language cells; validate keys and placeholders; review authorization and destructive-action wording. | +| 5 | Pilot Agent access and local connection information | Implemented; validation below | Cover static and dynamic text, titles, and accessible names; preserve connected sessions and authorization; support English fallback. | +| 6 | Translate and integrate approved rows | Pilot translations reviewed and integrated; remaining workflows deferred | AI edits only target-language cells; validate keys and placeholders; review authorization and destructive-action wording. | | 7 | Expand Core and Desktop coverage | Deferred | Verify secondary windows, native menus, setup, diagnostics, packaging, and representative layouts. | ## Validation of the reliability changes @@ -61,22 +61,25 @@ continuous-output regression failed before the capture deadline fix. | Diff whitespace check | Passed. | These checks ran in WSL. Browser, Desktop packaging, and physical UART checks -were not rerun for these backend/helper changes. No runtime UI wording changed. +were not rerun for these backend/helper changes. The subsequent UI pilot is +validated separately below. ## Deferred UI work -Stages 3 and 4 do not change runtime UI text. Translation does not start until -the English source for the selected workflow is reviewed. Extra screen diffing, -render hints, key aliases, and byte-limit options remain optional optimizations. +Browser authorization, file-copy review text, Agent diagnostics, SSH tunnel setup, +secondary windows, and Desktop localization remain outside this pilot. Reviewed +rows in the table do not imply that every screen has integrated them. Translation +starts only after the English source for the selected workflow is reviewed. +Extra screen diffing, render hints, key aliases, and byte-limit options remain optional optimizations. Unverified UART end-to-end coverage is a qualification gap, not evidence that UART is broken. ## Copy review and AI translation exchange -The adjacent `ui_copy_review.tsv` is an initial review table, not a complete -catalog or a runtime resource. `en` contains the reviewed English source; -`zh-TW` holds its translation when available. `current_en` is the visible -English text, with +The adjacent `ui_copy_review.tsv` is the editable source for the pilot catalog and +future copy review; it does not inventory the entire product. `en` contains the +reviewed English source; `zh-TW` holds its translation when available. +`current_en` records the English review baseline, with dynamic values normalized to the named placeholders declared in the row. HTML emphasis is omitted from the table. Source references identify the current implementation and can move as the code changes. @@ -136,3 +139,46 @@ The initial estimate is 4-7 engineering days for common Core workflows and estimates, excluding the reliability fixes, website and CLI documentation, remote terminal output, and complete live language switching. A full string inventory and pilot are needed before committing to a delivery estimate. + +## Pilot implementation and translation handoff + +The table contains 82 rows: 81 reviewed translations and one removal row. +The pilot integrates 75 keys; six reviewed keys belong to deferred workflows. +An independent translator edited only the 63 new target cells. The integrator +checked unchanged metadata and placeholders, clarified that Settings changes +the default permission, and reviewed the translations before changing statuses. + +Generate the checked-in browser catalog with +`python scripts/build_ui_messages.py`; use `--check` to reject a stale catalog. +The standard headless checks validate the table and generated output. CI also +runs `node tests/ui_i18n_smoke.cjs` for lookup, interpolation and safe DOM output. +English source rows require `source-approved` or `translation-reviewed`; target +text is exported only from `translation-reviewed` rows. Empty translations use +English. Removed rows are excluded. Runtime needs only the generated assets; +no new build tool or third-party localization dependency is required. Core +packaging includes the source table and both runtime scripts. + +To outsource another translation batch, send the table and agreed terminology +above. Ask the translator to edit only target cells in the selected source-approved +rows and return the same UTF-8 TSV. Compare all other cells and the complete key +set against the sent copy, review the meaning, then update statuses and regenerate. +Never accept source or metadata changes merely because placeholder checks pass. + +The local `uiLanguage` preference supports `en` and `zh-TW`; unknown values +fall back to English. Save applies on the next page opening. The active page +retains its locale and its existing terminal connections, permissions and tokens. +Machine prompts, protocol values and backend diagnostics are unchanged. + +| Pilot check | Result | +| --- | --- | +| Complete Agent browser smoke suite | 51 cases passed, including the new Traditional Chinese authorization, connection-info and next-opening language case. | +| Active session preservation | Saving a language choice retained the socket, terminal session, permission and token; it emitted no authorization or connection mutation. A new page applied the saved language. | +| Table/exporter tests | Six cases passed; the checked-in catalog also passed `--check`. | +| Translation lookup tests | Six cases passed for fallback, literal interpolation, safe display attributes and browser loading. | +| Integrated key audit | All 75 keys have English and reviewed Traditional Chinese text. | +| Bilingual connection-dialog layout | All controls fit at 600, 800 and 1280 pixel viewport widths. | +| Core bundle selection | Three focused tests passed, including required catalog assets and source table. | +| Independent read-only review | No correctness or security findings in permission control, language persistence, interpolation or fallback. | + +These checks ran with WSL Chromium and local test servers. Native Desktop +packaging and physical UART qualification were not part of this UI pilot. diff --git a/docs/ui_copy_review.tsv b/docs/ui_copy_review.tsv index da7c2c2..6ac0d24 100644 --- a/docs/ui_copy_review.tsv +++ b/docs/ui_copy_review.tsv @@ -18,3 +18,66 @@ agent.copy.replace This will atomically replace an existing regular file ({size} agent.copy.keep_both The displayed destination is the exact keep-both name selected by the backend. Copy to the destination shown; keep the existing file. 複製到顯示的目的地,並保留既有檔案。 Warning for a keep-both copy using the canonical destination. Do not imply the user can edit the chosen path in this approval dialog. translation-reviewed templates/index.html:3879 agent.transfer.stop Stop Stop 停止 Stops a running transfer before publication. Do not merge with Reject, Pause, Disable, Close, or Dismiss; each has a different effect. translation-reviewed templates/index.html:9256 agent.transfer.committing The destination is being published and cannot be stopped Finishing the copy; it can no longer be stopped. 正在完成複製,已無法停止。 Tooltip while transfer publication has crossed the cancellation barrier. Keep the stop control disabled; do not imply a finished or failed result. translation-reviewed templates/index.html:9258 +settings.language.label Language (Agent preview) Language (Agent preview) 語言(Agent 預覽) Local display preference for the limited Agent workflow pilot. Plain text. Preserve named values, command names, and agreed Agent terminology. translation-reviewed templates/index.html +settings.language.hint Applies to Agent access and connection info next time you open this page. Other areas remain in English. Applies to Agent access and local connection info next time you open this page. Other areas remain in English. 下次開啟此頁面時套用於 Agent 存取與本機連線資訊,其他區域仍顯示英文。 Language change never reloads an active terminal. Plain text. Preserve named values, command names, and agreed Agent terminology. translation-reviewed templates/index.html +settings.agent.legend Agent access Agent access Agent 存取 Settings fieldset title. Plain text. Preserve named values, command names, and agreed Agent terminology. translation-reviewed templates/index.html +settings.agent.permission Default Agent permission Default Agent permission Agent 預設權限 Saved permission used by the Authorize agent button; saving alone does not grant access. Plain text. Preserve named values, command names, and agreed Agent terminology. translation-reviewed templates/index.html +settings.agent.hint Authorize agent applies this permission to the current tab and creates a standard token. Authorize agent applies this permission to the current tab and creates a standard token. 「授權 Agent」會將此權限套用至目前分頁,並建立標準權杖。 Explain the primary button without implying that saving a setting authorizes a tab. Plain text. Preserve named values, command names, and agreed Agent terminology. translation-reviewed templates/index.html +settings.agent.observe Read only + token Read only + token 唯讀 + 權杖 Authorize agent setting option; keep the read-only meaning. Plain text. Preserve named values, command names, and agreed Agent terminology. translation-reviewed templates/index.html +settings.agent.approval Approval required + token Approval required + token 需核准 + 權杖 Authorize agent setting option; each input proposal needs approval. Plain text. Preserve named values, command names, and agreed Agent terminology. translation-reviewed templates/index.html +settings.agent.direct Direct input + token Direct input + token 直接輸入 + 權杖 Authorize agent setting option; file-copy approval is still required. Plain text. Preserve named values, command names, and agreed Agent terminology. translation-reviewed templates/index.html +common.close Close Close 關閉 Close a dialog without cancelling terminal work. Plain text. Preserve named values, command names, and agreed Agent terminology. translation-reviewed templates/index.html +common.refresh Refresh Status Refresh Status 更新狀態 Refresh displayed status, not the page. Plain text. Preserve named values, command names, and agreed Agent terminology. translation-reviewed templates/index.html +common.copy_url Copy URL Copy URL 複製網址 Copy the URL without granting access. Plain text. Preserve named values, command names, and agreed Agent terminology. translation-reviewed templates/index.html +agent.name Agent Agent Agent Product term; retain Agent. Plain text. Preserve named values, command names, and agreed Agent terminology. translation-reviewed templates/index.html +agent.panel.show Show Agent Panel Show Agent Panel 顯示 Agent 面板 Show the Agent panel. Plain text. Preserve named values, command names, and agreed Agent terminology. translation-reviewed templates/index.html +agent.panel.hide Hide Agent Panel Hide Agent Panel 隱藏 Agent 面板 Hide the Agent panel; do not disable Agent access. Plain text. Preserve named values, command names, and agreed Agent terminology. translation-reviewed templates/index.html +agent.panel.open Open Agent Panel Open Agent Panel 開啟 Agent 面板 Open the permission panel from connection info. Plain text. Preserve named values, command names, and agreed Agent terminology. translation-reviewed templates/index.html +agent.panel.pending {count} pending {count} pending {count} 筆待核准 Count of input proposals waiting for human action. {count} Plain text. Preserve named values, command names, and agreed Agent terminology. translation-reviewed templates/index.html +agent.permission.label Agent permission Agent permission Agent 權限 Accessible label for the permission selector. Plain text. Preserve named values, command names, and agreed Agent terminology. translation-reviewed templates/index.html +agent.permission.paused Paused Paused 已暫停 Agent access is paused. Plain text. Preserve named values, command names, and agreed Agent terminology. translation-reviewed templates/index.html +agent.permission.off Off Off 已停用 Agent access is disabled. Plain text. Preserve named values, command names, and agreed Agent terminology. translation-reviewed templates/index.html +agent.permission.paused_hint Agent access is paused. Choose a permission to resume, or disable access. Agent access is paused. Choose a permission to resume, or disable access. Agent 存取已暫停。選擇權限以恢復存取,或停用存取。 Paused access is not a token expiry. Plain text. Preserve named values, command names, and agreed Agent terminology. translation-reviewed templates/index.html +agent.permission.disabled_hint Enable Agent access to choose its permission. Enable Agent access to choose its permission. 啟用 Agent 存取後,即可選擇權限。 No implicit authorization on connection. Plain text. Preserve named values, command names, and agreed Agent terminology. translation-reviewed templates/index.html +agent.access.enable Enable external agent Enable external agent 啟用外部 Agent Enable the Agent panel in read-only mode. Plain text. Preserve named values, command names, and agreed Agent terminology. translation-reviewed templates/index.html +agent.access.disable Disable external agent Disable external agent 停用外部 Agent Disable Agent access for this terminal. Plain text. Preserve named values, command names, and agreed Agent terminology. translation-reviewed templates/index.html +agent.access.enable_title Enable Agent access in Read only mode Enable Agent access in Read only mode 以唯讀模式啟用 Agent 存取 Enable action tooltip; does not create a token. Plain text. Preserve named values, command names, and agreed Agent terminology. translation-reviewed templates/index.html +agent.access.disable_title Disable Agent access for this terminal Disable Agent access for this terminal 停用此終端的 Agent 存取 Disable action tooltip. Plain text. Preserve named values, command names, and agreed Agent terminology. translation-reviewed templates/index.html +agent.access.starting Starting… Starting… 啟動中… Primary authorization action is in progress. Plain text. Preserve named values, command names, and agreed Agent terminology. translation-reviewed templates/index.html +agent.access.grant_title Authorize {permission} access and create a standard token for {target} Authorize {permission} access and create a standard token for {target} 授權 Agent 以「{permission}」權限存取 {target},並建立標準權杖 Primary action tooltip names the permission and terminal target. {permission} {target} Plain text. Preserve named values, command names, and agreed Agent terminology. translation-reviewed templates/index.html +agent.access.current_tab the current tab the current tab 目前分頁 Fallback target in a tooltip when no terminal is selected. Plain text. Preserve named values, command names, and agreed Agent terminology. translation-reviewed templates/index.html +agent.token.creating Creating token… Creating token… 正在建立權杖… Token creation is in progress. Plain text. Preserve named values, command names, and agreed Agent terminology. translation-reviewed templates/index.html +agent.token.create_3x Create token 3× Create token 3× 建立權杖 3× Create a token with three times the standard idle lifetime. Plain text. Preserve named values, command names, and agreed Agent terminology. translation-reviewed templates/index.html +agent.token.renew Renew token Renew token 更新權杖 Renew a credential; do not imply a permission change. Plain text. Preserve named values, command names, and agreed Agent terminology. translation-reviewed templates/index.html +agent.token.renew_3x Renew token 3× Renew token 3× 更新權杖 3× Renew a token with three times the standard idle lifetime. Plain text. Preserve named values, command names, and agreed Agent terminology. translation-reviewed templates/index.html +agent.token.connect_required Connect a terminal before creating a token. Connect a terminal before creating a token. 請先連線至終端,再建立權杖。 Prerequisite for token creation. Plain text. Preserve named values, command names, and agreed Agent terminology. translation-reviewed templates/index.html +agent.token.resume_required Resume Agent access before creating a token. Resume Agent access before creating a token. 請先恢復 Agent 存取,再建立權杖。 Paused state prevents token creation. Plain text. Preserve named values, command names, and agreed Agent terminology. translation-reviewed templates/index.html +agent.token.enable_required Enable external agent before creating a token. Enable external agent before creating a token. 請先啟用外部 Agent,再建立權杖。 Disabled state prevents token creation. Plain text. Preserve named values, command names, and agreed Agent terminology. translation-reviewed templates/index.html +agent.token.revoked Token revoked. Renew it to continue. Token revoked. Renew it to continue. 權杖已撤銷。請更新權杖以繼續。 Revocation differs from idle expiry. Plain text. Preserve named values, command names, and agreed Agent terminology. translation-reviewed templates/index.html +agent.token.ready Token created for this terminal and permission. Token created for this terminal and permission. 已為此終端與權限建立權杖。 Token creation succeeded. Plain text. Preserve named values, command names, and agreed Agent terminology. translation-reviewed templates/index.html +agent.token.invalidated Token invalidated. Create a new token to continue. Token invalidated. Create a new token to continue. 權杖已失效。請建立新權杖以繼續。 Binding changed; do not say the permission was reset. Plain text. Preserve named values, command names, and agreed Agent terminology. translation-reviewed templates/index.html +agent.token.local_hint Create a local-only token for this terminal and permission. Create a local-only token for this terminal and permission. 為此終端與權限建立僅限本機使用的權杖。 The command endpoint is loopback-only. Plain text. Preserve named values, command names, and agreed Agent terminology. translation-reviewed templates/index.html +agent.token.target_title Create a standard token for {target} Create a standard token for {target} 為 {target} 建立標準權杖 Toolbar token action tooltip. {target} Plain text. Preserve named values, command names, and agreed Agent terminology. translation-reviewed templates/index.html +agent.token.target_3x_title Create a token with 3× idle time for {target} Create a token with 3× idle time for {target} 為 {target} 建立閒置期限為標準 3 倍的權杖 Toolbar extended-token action tooltip. {target} Plain text. Preserve named values, command names, and agreed Agent terminology. translation-reviewed templates/index.html +agent.token.command Agent command Agent command Agent 指令 Accessible label of the generated command field; never translate its value. Plain text. Preserve named values, command names, and agreed Agent terminology. translation-reviewed templates/index.html +agent.token.command_output Command output Command output 指令輸出 Expandable area containing a generated Agent command. Plain text. Preserve named values, command names, and agreed Agent terminology. translation-reviewed templates/index.html +agent.token.tab_active Agent token active ({permission}); {seconds}s remaining without activity Agent token active ({permission}); {seconds}s remaining without activity Agent 權杖有效({permission});若無活動,將在 {seconds} 秒後過期 Tab tooltip; activity means use of the Agent token. {permission} {seconds} Plain text. Preserve named values, command names, and agreed Agent terminology. translation-reviewed templates/index.html +agent.token.tab_expired Agent token expired; create a new token to continue Agent token expired; create a new token to continue Agent 權杖已過期;請建立新權杖以繼續 Tab tooltip for an expired token. Plain text. Preserve named values, command names, and agreed Agent terminology. translation-reviewed templates/index.html +agent.pause.action Pause Agent Pause Agent 暫停 Agent Pause Agent access; do not close or disconnect the terminal. Plain text. Preserve named values, command names, and agreed Agent terminology. translation-reviewed templates/index.html +agent.pause.target Pause Agent access for {target} Pause Agent access for {target} 暫停 {target} 的 Agent 存取 Pause action tooltip naming its target. {target} Plain text. Preserve named values, command names, and agreed Agent terminology. translation-reviewed templates/index.html +agent.connection.tooltip Copy the agent prompt for the current tab's environment Copy the agent prompt for the current tab's environment 複製目前分頁所在環境的 Agent 連線指引 The active tab selects the execution environment, not the grant scope. Plain text. Preserve named values, command names, and agreed Agent terminology. translation-reviewed templates/index.html +agent.connection.environment Run the agent where Core runs (WSL if Core runs in WSL). It can access only individually authorized tabs. Run the agent where Core runs (WSL if Core runs in WSL). It can access only individually authorized tabs. 請在 Core 所在環境執行 Agent(若 Core 在 WSL 中執行,Agent 也須在 WSL 中執行)。Agent 僅能存取已個別授權的分頁。 Core-host connection info; distinguish Windows browser from WSL Core. Plain text. Preserve named values, command names, and agreed Agent terminology. translation-reviewed templates/index.html +agent.connection.step_authorize Choose Authorize agent on each intended tab. Change its permission in Settings or the Agent panel. Choose the default permission in Settings, then Authorize agent on each intended tab. Adjust an authorized tab in the Agent panel. 先在 Settings 選擇預設權限,再於各個要開放存取的分頁選擇「授權 Agent」。已授權分頁的權限可在 Agent 面板調整。 Local connection setup step; the primary action creates the token. Plain text. Preserve named values, command names, and agreed Agent terminology. translation-reviewed templates/index.html +agent.connection.step_copy Copy the prompt to the agent running on the Core host. Copy the prompt to the agent running on the Core host. 將連線指引複製給在 Core 主機上執行的 Agent。 Give the prompt to the correct runtime environment. Plain text. Preserve named values, command names, and agreed Agent terminology. translation-reviewed templates/index.html +agent.connection.step_confirm Run discover, then hello. Authenticated requests appear below. Run discover, then hello. Authenticated requests appear below. 先執行 discover,再執行 hello。已通過驗證的請求會顯示在下方。 Keep discover and hello unchanged as command names. Plain text. Preserve named values, command names, and agreed Agent terminology. translation-reviewed templates/index.html +agent.connection.url Agent Info URL Agent Info URL Agent 資訊網址 Label and accessible name for the tokenless bootstrap URL. Plain text. Preserve named values, command names, and agreed Agent terminology. translation-reviewed templates/index.html +agent.connection.prompt Agent connection prompt Agent connection prompt Agent 連線指引 Accessible name for a copied instruction field; do not translate its value. Plain text. Preserve named values, command names, and agreed Agent terminology. translation-reviewed templates/index.html +agent.connection.request_at last authenticated request {time} last authenticated request {time} 最近一次通過驗證的請求:{time} Reports an authenticated request, not a persistent connection or successful input. {time} Plain text. Preserve named values, command names, and agreed Agent terminology. translation-reviewed templates/index.html +agent.connection.waiting waiting for agent waiting for agent 等待 Agent A grant exists, but no authenticated request has been observed. Plain text. Preserve named values, command names, and agreed Agent terminology. translation-reviewed templates/index.html +agent.connection.no_grants No active grants. Authorize the intended tabs first. No active grants. Authorize the intended tabs first. 目前沒有有效授權。請先授權要開放存取的分頁。 Do not expose ungranted tabs in public discovery. Plain text. Preserve named values, command names, and agreed Agent terminology. translation-reviewed templates/index.html +agent.connection.copied Copied. Paste this to the agent in the indicated environment. Copied. Paste this to the agent in the indicated environment. 已複製。請貼給在指定環境執行的 Agent。 Clipboard copy succeeded; this does not grant access. Plain text. Preserve named values, command names, and agreed Agent terminology. translation-reviewed templates/index.html +agent.connection.copy_failed Not copied. Text selected; press {shortcut} to copy it manually. Not copied. Text selected; press {shortcut} to copy it manually. 複製失敗,已選取文字。請按 {shortcut} 手動複製。 Clipboard fallback; preserve the keyboard shortcut placeholder. {shortcut} Plain text. Preserve named values, command names, and agreed Agent terminology. translation-reviewed templates/index.html +agent.connection.disconnected Core is disconnected. Reconnect before copying connection info. Core is disconnected. Reconnect before copying connection info. Core 連線已中斷。請重新連線後再複製連線資訊。 No valid Core connection info is available. Plain text. Preserve named values, command names, and agreed Agent terminology. translation-reviewed templates/index.html +agent.connection.loading Loading connection info… Loading connection info… 正在載入連線資訊… Connection info request is pending. Plain text. Preserve named values, command names, and agreed Agent terminology. translation-reviewed templates/index.html +agent.connection.ready Copy Prompt to the agent running on the Core host. Its first authenticated request confirms access. Copy Prompt to the agent running on the Core host. Its first authenticated request confirms access. 按「複製連線指引」,再貼給在 Core 主機上執行的 Agent。Agent 首次通過驗證的請求可確認其存取權。 Keep request activity separate from terminal input success. Plain text. Preserve named values, command names, and agreed Agent terminology. translation-reviewed templates/index.html +agent.connection.unavailable Connection info unavailable. Connection info unavailable. 無法取得連線資訊。 Fallback when the server does not provide a diagnostic message. Plain text. Preserve named values, command names, and agreed Agent terminology. translation-reviewed templates/index.html diff --git a/scripts/build_ui_messages.py b/scripts/build_ui_messages.py new file mode 100644 index 0000000..82297be --- /dev/null +++ b/scripts/build_ui_messages.py @@ -0,0 +1,87 @@ +#!/usr/bin/env python3 +"""Validate the translation table and generate the browser/CommonJS catalog.""" + +import argparse +from collections import Counter +import csv +import json +from pathlib import Path +import re + + +ROOT = Path(__file__).resolve().parents[1] +SOURCE = ROOT / 'docs' / 'ui_copy_review.tsv' +OUTPUT = ROOT / 'static' / 'js' / 'standterm-messages.js' +FIELDS = ['key', 'current_en', 'en', 'zh-TW', 'context', 'placeholders', 'constraints', 'status', 'source'] +STATUSES = {'proposed', 'retain', 'remove', 'source-approved', 'translation-reviewed'} +PLACEHOLDER = re.compile(r'\{[A-Za-z_][A-Za-z0-9_]*\}') + + +def build_catalog(source): + messages = {'en': {}, 'zh-TW': {}} + seen = set() + with source.open(encoding='utf-8-sig', newline='') as handle: + reader = csv.DictReader(handle, delimiter='\t') + if reader.fieldnames != FIELDS: + raise ValueError('Unexpected translation table columns') + for row in reader: + key = row['key'] + if None in row or any(value is None for value in row.values()): + raise ValueError(f'Invalid columns for {key}') + if not re.fullmatch(r'[a-z][a-z0-9_]*(?:\.[a-z][a-z0-9_]*)+', key) or key in seen: + raise ValueError(f'Invalid or duplicate key: {key}') + seen.add(key) + if any(any(char in value for char in '\t\r\n') for value in row.values()): + raise ValueError(f'Use escaped line breaks in {key}') + if row['status'] not in STATUSES: + raise ValueError(f'Unknown status for {key}') + if row['status'] == 'remove': + if row['en'] or row['zh-TW']: + raise ValueError(f'Removal row must have empty text: {key}') + continue + if not row['en'].strip(): + raise ValueError(f'Missing English source: {key}') + expected = Counter(PLACEHOLDER.findall(row['placeholders'])) + for language in ('en', 'zh-TW'): + text = row[language] + if text and Counter(PLACEHOLDER.findall(text)) != expected: + raise ValueError(f'Placeholder mismatch in {key}: {language}') + if row['status'] not in {'source-approved', 'translation-reviewed'}: + continue + messages['en'][key] = row['en'].replace('\\n', '\n') + if row['status'] == 'translation-reviewed' and row['zh-TW'].strip(): + messages['zh-TW'][key] = row['zh-TW'].replace('\\n', '\n') + return messages + + +def render_catalog(messages): + payload = json.dumps(messages, ensure_ascii=True, indent=4, sort_keys=True) + return ( + '// Generated by scripts/build_ui_messages.py. Edit docs/ui_copy_review.tsv.\n' + '(function (root) {\n' + " 'use strict';\n" + f' const messages = {payload};\n' + " if (typeof module === 'object' && module.exports) module.exports = messages;\n" + ' else root.StandTermMessages = messages;\n' + '})(globalThis);\n' + ) + + +def main(argv=None): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--check', action='store_true', help='Fail if the committed catalog is stale') + args = parser.parse_args(argv) + try: + expected = render_catalog(build_catalog(SOURCE)) + if args.check: + if not OUTPUT.is_file() or OUTPUT.read_text(encoding='utf-8') != expected: + raise ValueError('UI catalog is stale; run scripts/build_ui_messages.py') + else: + OUTPUT.write_text(expected, encoding='utf-8') + except (OSError, ValueError) as exc: + parser.exit(1, f'{exc}\n') + return 0 + + +if __name__ == '__main__': + raise SystemExit(main()) diff --git a/scripts/run_smoke_tests.py b/scripts/run_smoke_tests.py index c291652..f96ee89 100644 --- a/scripts/run_smoke_tests.py +++ b/scripts/run_smoke_tests.py @@ -13,6 +13,8 @@ 'ssh_forwarding.py', 'ssh_tunnels.py', 'scripts/access_window.py', + 'scripts/build_ui_messages.py', + 'tests/ui_messages_smoke.py', 'tests/access_window_smoke.py', 'tests/server_startup_smoke.py', 'scripts/agent_cli.py', @@ -38,6 +40,7 @@ ] HEADLESS_SMOKE_TESTS = [ + 'tests/ui_messages_smoke.py', 'tests/access_window_smoke.py', 'tests/terminal_read_smoke.py', 'tests/ssh_start_smoke.py', @@ -74,6 +77,8 @@ def main(argv=None): [sys.executable, '-m', 'py_compile', *COMPILE_TARGETS], ) + run_step('Check generated UI catalog', [sys.executable, 'scripts/build_ui_messages.py', '--check']) + for test_path in HEADLESS_SMOKE_TESTS: run_step(test_path, [sys.executable, test_path]) diff --git a/static/js/standterm-i18n.js b/static/js/standterm-i18n.js new file mode 100644 index 0000000..98ee408 --- /dev/null +++ b/static/js/standterm-i18n.js @@ -0,0 +1,37 @@ +(function (root, factory) { + if (typeof module === 'object' && module.exports) module.exports = factory(); + else root.StandTermI18n = factory(); +})(globalThis, function () { + 'use strict'; + + function normalizeLocale(value) { + return value === 'zh-TW' ? 'zh-TW' : 'en'; + } + + function create(messages, requestedLocale) { + const locale = normalizeLocale(requestedLocale); + const english = messages.en || {}; + const translated = messages[locale] || {}; + const own = (object, key) => Object.prototype.hasOwnProperty.call(object, key); + function t(key, params = {}) { + const template = (own(translated, key) && translated[key]) + || (own(english, key) && english[key]) || key; + return template.replace(/\{([A-Za-z_][A-Za-z0-9_]*)\}/g, + (placeholder, name) => own(params, name) ? String(params[name]) : placeholder); + } + + function apply(root) { + root.querySelectorAll('[data-i18n]').forEach(element => { + element.textContent = t(element.dataset.i18n); + }); + for (const attribute of ['title', 'aria-label', 'placeholder']) { + root.querySelectorAll(`[data-i18n-${attribute}]`).forEach(element => { + element.setAttribute(attribute, t(element.getAttribute(`data-i18n-${attribute}`))); + }); + } + } + return { locale, t, apply }; + } + + return { normalizeLocale, create }; +}); diff --git a/static/js/standterm-messages.js b/static/js/standterm-messages.js new file mode 100644 index 0000000..7ea72c1 --- /dev/null +++ b/static/js/standterm-messages.js @@ -0,0 +1,174 @@ +// Generated by scripts/build_ui_messages.py. Edit docs/ui_copy_review.tsv. +(function (root) { + 'use strict'; + const messages = { + "en": { + "agent.access.current_tab": "the current tab", + "agent.access.disable": "Disable external agent", + "agent.access.disable_title": "Disable Agent access for this terminal", + "agent.access.enable": "Enable external agent", + "agent.access.enable_title": "Enable Agent access in Read only mode", + "agent.access.grant": "Authorize agent", + "agent.access.grant_title": "Authorize {permission} access and create a standard token for {target}", + "agent.access.starting": "Starting\u2026", + "agent.connection.copied": "Copied. Paste this to the agent in the indicated environment.", + "agent.connection.copy": "Copy Prompt", + "agent.connection.copy_failed": "Not copied. Text selected; press {shortcut} to copy it manually.", + "agent.connection.disconnected": "Core is disconnected. Reconnect before copying connection info.", + "agent.connection.environment": "Run the agent where Core runs (WSL if Core runs in WSL). It can access only individually authorized tabs.", + "agent.connection.loading": "Loading connection info\u2026", + "agent.connection.no_grants": "No active grants. Authorize the intended tabs first.", + "agent.connection.prompt": "Agent connection prompt", + "agent.connection.ready": "Copy Prompt to the agent running on the Core host. Its first authenticated request confirms access.", + "agent.connection.request_at": "last authenticated request {time}", + "agent.connection.step_authorize": "Choose the default permission in Settings, then Authorize agent on each intended tab. Adjust an authorized tab in the Agent panel.", + "agent.connection.step_confirm": "Run discover, then hello. Authenticated requests appear below.", + "agent.connection.step_copy": "Copy the prompt to the agent running on the Core host.", + "agent.connection.title": "Agent connection", + "agent.connection.tooltip": "Copy the agent prompt for the current tab's environment", + "agent.connection.unavailable": "Connection info unavailable.", + "agent.connection.url": "Agent Info URL", + "agent.connection.waiting": "waiting for agent", + "agent.copy.keep_both": "Copy to the destination shown; keep the existing file.", + "agent.copy.replace": "Replace the existing file ({size}).", + "agent.name": "Agent", + "agent.panel.hide": "Hide Agent Panel", + "agent.panel.open": "Open Agent Panel", + "agent.panel.pending": "{count} pending", + "agent.panel.show": "Show Agent Panel", + "agent.pause.action": "Pause Agent", + "agent.pause.target": "Pause Agent access for {target}", + "agent.permission.approval": "Approval required", + "agent.permission.approval_hint": "Approve each input before it is sent. File copies require separate approval.", + "agent.permission.direct": "Direct input", + "agent.permission.direct_hint": "Allow input without individual approval. File copies still require approval.", + "agent.permission.disabled_hint": "Enable Agent access to choose its permission.", + "agent.permission.label": "Agent permission", + "agent.permission.observe": "Read only", + "agent.permission.observe_hint": "Read terminal output without sending input.", + "agent.permission.off": "Off", + "agent.permission.paused": "Paused", + "agent.permission.paused_hint": "Agent access is paused. Choose a permission to resume, or disable access.", + "agent.token.command": "Agent command", + "agent.token.command_output": "Command output", + "agent.token.connect_required": "Connect a terminal before creating a token.", + "agent.token.create": "Create token", + "agent.token.create_3x": "Create token 3\u00d7", + "agent.token.creating": "Creating token\u2026", + "agent.token.enable_required": "Enable external agent before creating a token.", + "agent.token.expired": "Token expired. Renew it to continue.", + "agent.token.idle_remaining": "Token expires in {seconds}s without activity.", + "agent.token.invalidated": "Token invalidated. Create a new token to continue.", + "agent.token.local_hint": "Create a local-only token for this terminal and permission.", + "agent.token.ready": "Token created for this terminal and permission.", + "agent.token.renew": "Renew token", + "agent.token.renew_3x": "Renew token 3\u00d7", + "agent.token.resume_required": "Resume Agent access before creating a token.", + "agent.token.revoked": "Token revoked. Renew it to continue.", + "agent.token.tab_active": "Agent token active ({permission}); {seconds}s remaining without activity", + "agent.token.tab_expired": "Agent token expired; create a new token to continue", + "agent.token.target_3x_title": "Create a token with 3\u00d7 idle time for {target}", + "agent.token.target_title": "Create a standard token for {target}", + "agent.transfer.committing": "Finishing the copy; it can no longer be stopped.", + "agent.transfer.stop": "Stop", + "browser.auth.instructions": "Paste a browser authorization URL to continue.", + "browser.auth.required": "Browser authorization required", + "common.close": "Close", + "common.copy_url": "Copy URL", + "common.refresh": "Refresh Status", + "settings.agent.approval": "Approval required + token", + "settings.agent.direct": "Direct input + token", + "settings.agent.hint": "Authorize agent applies this permission to the current tab and creates a standard token.", + "settings.agent.legend": "Agent access", + "settings.agent.observe": "Read only + token", + "settings.agent.permission": "Default Agent permission", + "settings.language.hint": "Applies to Agent access and local connection info next time you open this page. Other areas remain in English.", + "settings.language.label": "Language (Agent preview)" + }, + "zh-TW": { + "agent.access.current_tab": "\u76ee\u524d\u5206\u9801", + "agent.access.disable": "\u505c\u7528\u5916\u90e8 Agent", + "agent.access.disable_title": "\u505c\u7528\u6b64\u7d42\u7aef\u7684 Agent \u5b58\u53d6", + "agent.access.enable": "\u555f\u7528\u5916\u90e8 Agent", + "agent.access.enable_title": "\u4ee5\u552f\u8b80\u6a21\u5f0f\u555f\u7528 Agent \u5b58\u53d6", + "agent.access.grant": "\u6388\u6b0a Agent", + "agent.access.grant_title": "\u6388\u6b0a Agent \u4ee5\u300c{permission}\u300d\u6b0a\u9650\u5b58\u53d6 {target}\uff0c\u4e26\u5efa\u7acb\u6a19\u6e96\u6b0a\u6756", + "agent.access.starting": "\u555f\u52d5\u4e2d\u2026", + "agent.connection.copied": "\u5df2\u8907\u88fd\u3002\u8acb\u8cbc\u7d66\u5728\u6307\u5b9a\u74b0\u5883\u57f7\u884c\u7684 Agent\u3002", + "agent.connection.copy": "\u8907\u88fd\u9023\u7dda\u6307\u5f15", + "agent.connection.copy_failed": "\u8907\u88fd\u5931\u6557\uff0c\u5df2\u9078\u53d6\u6587\u5b57\u3002\u8acb\u6309 {shortcut} \u624b\u52d5\u8907\u88fd\u3002", + "agent.connection.disconnected": "Core \u9023\u7dda\u5df2\u4e2d\u65b7\u3002\u8acb\u91cd\u65b0\u9023\u7dda\u5f8c\u518d\u8907\u88fd\u9023\u7dda\u8cc7\u8a0a\u3002", + "agent.connection.environment": "\u8acb\u5728 Core \u6240\u5728\u74b0\u5883\u57f7\u884c Agent\uff08\u82e5 Core \u5728 WSL \u4e2d\u57f7\u884c\uff0cAgent \u4e5f\u9808\u5728 WSL \u4e2d\u57f7\u884c\uff09\u3002Agent \u50c5\u80fd\u5b58\u53d6\u5df2\u500b\u5225\u6388\u6b0a\u7684\u5206\u9801\u3002", + "agent.connection.loading": "\u6b63\u5728\u8f09\u5165\u9023\u7dda\u8cc7\u8a0a\u2026", + "agent.connection.no_grants": "\u76ee\u524d\u6c92\u6709\u6709\u6548\u6388\u6b0a\u3002\u8acb\u5148\u6388\u6b0a\u8981\u958b\u653e\u5b58\u53d6\u7684\u5206\u9801\u3002", + "agent.connection.prompt": "Agent \u9023\u7dda\u6307\u5f15", + "agent.connection.ready": "\u6309\u300c\u8907\u88fd\u9023\u7dda\u6307\u5f15\u300d\uff0c\u518d\u8cbc\u7d66\u5728 Core \u4e3b\u6a5f\u4e0a\u57f7\u884c\u7684 Agent\u3002Agent \u9996\u6b21\u901a\u904e\u9a57\u8b49\u7684\u8acb\u6c42\u53ef\u78ba\u8a8d\u5176\u5b58\u53d6\u6b0a\u3002", + "agent.connection.request_at": "\u6700\u8fd1\u4e00\u6b21\u901a\u904e\u9a57\u8b49\u7684\u8acb\u6c42\uff1a{time}", + "agent.connection.step_authorize": "\u5148\u5728 Settings \u9078\u64c7\u9810\u8a2d\u6b0a\u9650\uff0c\u518d\u65bc\u5404\u500b\u8981\u958b\u653e\u5b58\u53d6\u7684\u5206\u9801\u9078\u64c7\u300c\u6388\u6b0a Agent\u300d\u3002\u5df2\u6388\u6b0a\u5206\u9801\u7684\u6b0a\u9650\u53ef\u5728 Agent \u9762\u677f\u8abf\u6574\u3002", + "agent.connection.step_confirm": "\u5148\u57f7\u884c discover\uff0c\u518d\u57f7\u884c hello\u3002\u5df2\u901a\u904e\u9a57\u8b49\u7684\u8acb\u6c42\u6703\u986f\u793a\u5728\u4e0b\u65b9\u3002", + "agent.connection.step_copy": "\u5c07\u9023\u7dda\u6307\u5f15\u8907\u88fd\u7d66\u5728 Core \u4e3b\u6a5f\u4e0a\u57f7\u884c\u7684 Agent\u3002", + "agent.connection.title": "Agent \u9023\u7dda", + "agent.connection.tooltip": "\u8907\u88fd\u76ee\u524d\u5206\u9801\u6240\u5728\u74b0\u5883\u7684 Agent \u9023\u7dda\u6307\u5f15", + "agent.connection.unavailable": "\u7121\u6cd5\u53d6\u5f97\u9023\u7dda\u8cc7\u8a0a\u3002", + "agent.connection.url": "Agent \u8cc7\u8a0a\u7db2\u5740", + "agent.connection.waiting": "\u7b49\u5f85 Agent", + "agent.copy.keep_both": "\u8907\u88fd\u5230\u986f\u793a\u7684\u76ee\u7684\u5730\uff0c\u4e26\u4fdd\u7559\u65e2\u6709\u6a94\u6848\u3002", + "agent.copy.replace": "\u5c07\u8986\u5beb\u65e2\u6709\u6a94\u6848\uff08{size}\uff09\u3002", + "agent.name": "Agent", + "agent.panel.hide": "\u96b1\u85cf Agent \u9762\u677f", + "agent.panel.open": "\u958b\u555f Agent \u9762\u677f", + "agent.panel.pending": "{count} \u7b46\u5f85\u6838\u51c6", + "agent.panel.show": "\u986f\u793a Agent \u9762\u677f", + "agent.pause.action": "\u66ab\u505c Agent", + "agent.pause.target": "\u66ab\u505c {target} \u7684 Agent \u5b58\u53d6", + "agent.permission.approval": "\u9700\u6838\u51c6", + "agent.permission.approval_hint": "\u6bcf\u7b46\u8f38\u5165\u90fd\u9700\u7d93\u60a8\u6838\u51c6\u5f8c\u624d\u6703\u9001\u51fa\u3002\u6a94\u6848\u8907\u88fd\u9700\u53e6\u5916\u6838\u51c6\u3002", + "agent.permission.direct": "\u76f4\u63a5\u8f38\u5165", + "agent.permission.direct_hint": "\u5141\u8a31\u76f4\u63a5\u8f38\u5165\uff0c\u7121\u9700\u9010\u6b21\u6838\u51c6\u3002\u6a94\u6848\u8907\u88fd\u4ecd\u9700\u6838\u51c6\u3002", + "agent.permission.disabled_hint": "\u555f\u7528 Agent \u5b58\u53d6\u5f8c\uff0c\u5373\u53ef\u9078\u64c7\u6b0a\u9650\u3002", + "agent.permission.label": "Agent \u6b0a\u9650", + "agent.permission.observe": "\u552f\u8b80", + "agent.permission.observe_hint": "\u8b80\u53d6\u7d42\u7aef\u8f38\u51fa\uff0c\u4e0d\u50b3\u9001\u8f38\u5165\u3002", + "agent.permission.off": "\u5df2\u505c\u7528", + "agent.permission.paused": "\u5df2\u66ab\u505c", + "agent.permission.paused_hint": "Agent \u5b58\u53d6\u5df2\u66ab\u505c\u3002\u9078\u64c7\u6b0a\u9650\u4ee5\u6062\u5fa9\u5b58\u53d6\uff0c\u6216\u505c\u7528\u5b58\u53d6\u3002", + "agent.token.command": "Agent \u6307\u4ee4", + "agent.token.command_output": "\u6307\u4ee4\u8f38\u51fa", + "agent.token.connect_required": "\u8acb\u5148\u9023\u7dda\u81f3\u7d42\u7aef\uff0c\u518d\u5efa\u7acb\u6b0a\u6756\u3002", + "agent.token.create": "\u5efa\u7acb\u6b0a\u6756", + "agent.token.create_3x": "\u5efa\u7acb\u6b0a\u6756 3\u00d7", + "agent.token.creating": "\u6b63\u5728\u5efa\u7acb\u6b0a\u6756\u2026", + "agent.token.enable_required": "\u8acb\u5148\u555f\u7528\u5916\u90e8 Agent\uff0c\u518d\u5efa\u7acb\u6b0a\u6756\u3002", + "agent.token.expired": "\u6b0a\u6756\u5df2\u904e\u671f\u3002\u8acb\u66f4\u65b0\u6b0a\u6756\u4ee5\u7e7c\u7e8c\u3002", + "agent.token.idle_remaining": "\u82e5\u7121\u6d3b\u52d5\uff0c\u6b0a\u6756\u5c07\u5728 {seconds} \u79d2\u5f8c\u904e\u671f\u3002", + "agent.token.invalidated": "\u6b0a\u6756\u5df2\u5931\u6548\u3002\u8acb\u5efa\u7acb\u65b0\u6b0a\u6756\u4ee5\u7e7c\u7e8c\u3002", + "agent.token.local_hint": "\u70ba\u6b64\u7d42\u7aef\u8207\u6b0a\u9650\u5efa\u7acb\u50c5\u9650\u672c\u6a5f\u4f7f\u7528\u7684\u6b0a\u6756\u3002", + "agent.token.ready": "\u5df2\u70ba\u6b64\u7d42\u7aef\u8207\u6b0a\u9650\u5efa\u7acb\u6b0a\u6756\u3002", + "agent.token.renew": "\u66f4\u65b0\u6b0a\u6756", + "agent.token.renew_3x": "\u66f4\u65b0\u6b0a\u6756 3\u00d7", + "agent.token.resume_required": "\u8acb\u5148\u6062\u5fa9 Agent \u5b58\u53d6\uff0c\u518d\u5efa\u7acb\u6b0a\u6756\u3002", + "agent.token.revoked": "\u6b0a\u6756\u5df2\u64a4\u92b7\u3002\u8acb\u66f4\u65b0\u6b0a\u6756\u4ee5\u7e7c\u7e8c\u3002", + "agent.token.tab_active": "Agent \u6b0a\u6756\u6709\u6548\uff08{permission}\uff09\uff1b\u82e5\u7121\u6d3b\u52d5\uff0c\u5c07\u5728 {seconds} \u79d2\u5f8c\u904e\u671f", + "agent.token.tab_expired": "Agent \u6b0a\u6756\u5df2\u904e\u671f\uff1b\u8acb\u5efa\u7acb\u65b0\u6b0a\u6756\u4ee5\u7e7c\u7e8c", + "agent.token.target_3x_title": "\u70ba {target} \u5efa\u7acb\u9592\u7f6e\u671f\u9650\u70ba\u6a19\u6e96 3 \u500d\u7684\u6b0a\u6756", + "agent.token.target_title": "\u70ba {target} \u5efa\u7acb\u6a19\u6e96\u6b0a\u6756", + "agent.transfer.committing": "\u6b63\u5728\u5b8c\u6210\u8907\u88fd\uff0c\u5df2\u7121\u6cd5\u505c\u6b62\u3002", + "agent.transfer.stop": "\u505c\u6b62", + "browser.auth.instructions": "\u8cbc\u4e0a\u700f\u89bd\u5668\u6388\u6b0a\u7db2\u5740\u4ee5\u7e7c\u7e8c\u3002", + "browser.auth.required": "\u9700\u8981\u700f\u89bd\u5668\u6388\u6b0a", + "common.close": "\u95dc\u9589", + "common.copy_url": "\u8907\u88fd\u7db2\u5740", + "common.refresh": "\u66f4\u65b0\u72c0\u614b", + "settings.agent.approval": "\u9700\u6838\u51c6 + \u6b0a\u6756", + "settings.agent.direct": "\u76f4\u63a5\u8f38\u5165 + \u6b0a\u6756", + "settings.agent.hint": "\u300c\u6388\u6b0a Agent\u300d\u6703\u5c07\u6b64\u6b0a\u9650\u5957\u7528\u81f3\u76ee\u524d\u5206\u9801\uff0c\u4e26\u5efa\u7acb\u6a19\u6e96\u6b0a\u6756\u3002", + "settings.agent.legend": "Agent \u5b58\u53d6", + "settings.agent.observe": "\u552f\u8b80 + \u6b0a\u6756", + "settings.agent.permission": "Agent \u9810\u8a2d\u6b0a\u9650", + "settings.language.hint": "\u4e0b\u6b21\u958b\u555f\u6b64\u9801\u9762\u6642\u5957\u7528\u65bc Agent \u5b58\u53d6\u8207\u672c\u6a5f\u9023\u7dda\u8cc7\u8a0a\uff0c\u5176\u4ed6\u5340\u57df\u4ecd\u986f\u793a\u82f1\u6587\u3002", + "settings.language.label": "\u8a9e\u8a00\uff08Agent \u9810\u89bd\uff09" + } +}; + if (typeof module === 'object' && module.exports) module.exports = messages; + else root.StandTermMessages = messages; +})(globalThis); diff --git a/templates/index.html b/templates/index.html index fd62e7e..948e8c8 100644 --- a/templates/index.html +++ b/templates/index.html @@ -1,5 +1,5 @@ - + StandTerm @@ -1129,6 +1129,10 @@

Settings

General +
+ +
+
Applies to Agent access and local connection info next time you open this page. Other areas remain in English.
@@ -1140,15 +1144,15 @@

Settings

- Agent Access -
+ Agent access +
-
The tab-bar action applies this permission to the current tab, then mints a standard token.
+
Authorize agent applies this permission to the current tab and creates a standard token.
Import & Export @@ -1438,12 +1442,12 @@

Manual browser authorization

- - - + + +
- - + + @@ -1465,26 +1469,26 @@

Manual browser authorization

-

Agent Info for Current Tab

-

For an agent running on the Core host. If you launched Core with run_wsl.bat, this means WSL, not the Windows browser environment. It can access any individually authorized tab.

+

Agent connection

+

Run the agent where Core runs (WSL if Core runs in WSL). It can access only individually authorized tabs.

    -
  1. On each target tab, open the Agent panel, enable external agent, choose its permission, and click Mint.
  2. -
  3. Click Copy Prompt below and paste it to the agent running on the Core host.
  4. -
  5. Ask the agent to run discover, then hello. Its authenticated requests appear below.
  6. +
  7. Choose the default permission in Settings, then Authorize agent on each intended tab. Adjust an authorized tab in the Agent panel.
  8. +
  9. Copy the prompt to the agent running on the Core host.
  10. +
  11. Run discover, then hello. Authenticated requests appear below.
- - - + + +

- +
- - - - + + + +
@@ -1565,19 +1569,19 @@

Add tunnel

- Agent + Agent disabled - +
Off
-
- - - +
+ + +
Enable external agent to choose what a local agent can do.
@@ -1591,13 +1595,13 @@

Add tunnel

- - + +
Enable external agent before minting a token.
- Command output - + Command output +
@@ -1656,6 +1660,8 @@

Recover StandTerm session

+ + @@ -1742,6 +1748,7 @@

Recover StandTerm session

return WINDOWS_TERMINAL_FONT_FACE; } const PREF_DEFAULTS = { + uiLanguage: 'en', useCustomMenu: true, copyOnSelect: false, enablePicPreview: true, @@ -1808,6 +1815,11 @@

Recover StandTerm session

return AGENT_ACCESS_MINT_MODE_OPTIONS.includes(text) ? text : fallback; } let prefs = loadPrefs(); + prefs.uiLanguage = window.StandTermI18n.normalizeLocale(prefs.uiLanguage); + const uiText = window.StandTermI18n.create(window.StandTermMessages, prefs.uiLanguage); + const t = uiText.t; + uiText.apply(document); + document.documentElement.lang = uiText.locale; prefs.fontSize = normalizeFontSize(prefs.fontSize); prefs.fontWeight = normalizeFontWeight(prefs.fontWeight); prefs.cursorStyle = normalizeCursorStyle(prefs.cursorStyle); @@ -2890,8 +2902,8 @@

Recover StandTerm session

document.getElementById(elementId).textContent = entries.length ? entries.map(entry => { const time = Number(entry.last_request_at); return `${entry.terminal_id}: ${time > 0 && Number.isFinite(time) - ? `last authenticated request ${new Date(time * 1000).toLocaleString()}` : 'waiting for agent'}`; - }).join('\n') : 'No active grants. Enable Agent and authorize the intended tabs first.'; + ? t('agent.connection.request_at', { time: new Date(time * 1000).toLocaleString(uiText.locale) }) : t('agent.connection.waiting')}`; + }).join('\n') : t('agent.connection.no_grants'); } function setAgentConnectionMessage(element, message, copyFailed = false) { @@ -2905,12 +2917,12 @@

Recover StandTerm session

if (!field.value) return; try { await navigator.clipboard.writeText(field.value); - setAgentConnectionMessage(messageElement, 'Copied. Paste this to the agent in the indicated environment.'); + setAgentConnectionMessage(messageElement, t('agent.connection.copied')); } catch (_error) { field.focus(); field.select(); const shortcut = isApplePlatform() ? 'Command+C' : 'Ctrl+C'; - setAgentConnectionMessage(messageElement, `Not copied. Text selected; press ${shortcut} to copy it manually.`, true); + setAgentConnectionMessage(messageElement, t('agent.connection.copy_failed', { shortcut }), true); messageElement.scrollIntoView({ block: 'nearest' }); } } @@ -2925,10 +2937,10 @@

Recover StandTerm session

document.getElementById('agent-connect-copy').disabled = true; document.getElementById('agent-connect-copy-url').disabled = true; if (!socket.connected) { - setAgentConnectionMessage(agentConnectMessage, 'Core is disconnected. Reconnect before copying connection info.'); + setAgentConnectionMessage(agentConnectMessage, t('agent.connection.disconnected')); return; } - setAgentConnectionMessage(agentConnectMessage, 'Loading connection info…'); + setAgentConnectionMessage(agentConnectMessage, t('agent.connection.loading')); socket.emit('agent_connect_info', result => { if (requestId !== agentConnectRequest || !agentConnectDialog.open) return; const available = result && result.status === 'ok'; @@ -2940,8 +2952,8 @@

Recover StandTerm session

agentConnectActivity = available ? result.terminals || [] : []; renderAgentConnectionActivity('agent-connect-activity', agentConnectActivity); setAgentConnectionMessage(agentConnectMessage, available - ? 'Copy Prompt to the agent running on the Core host. Its first authenticated request confirms access.' - : result?.message || 'Connection info unavailable.'); + ? t('agent.connection.ready') + : result?.message || t('agent.connection.unavailable')); }); } @@ -3144,7 +3156,7 @@

Recover StandTerm session

}); function setAgentTunnelInfoView(infoView) { - document.getElementById('agent-tunnel-title').textContent = infoView ? 'Agent Info for Current Tab' : 'Agent Tunnel'; + document.getElementById('agent-tunnel-title').textContent = infoView ? t('agent.connection.title') : 'Agent Tunnel'; document.getElementById('agent-tunnel-setup').hidden = infoView; document.getElementById('agent-tunnel-apply').hidden = infoView; document.getElementById('agent-tunnel-stop').hidden = infoView; @@ -3381,7 +3393,7 @@

Recover StandTerm session

agentPauseBtn.classList.toggle('visible', visible); agentToggleBtn.classList.toggle('shifted', visible); agentPauseBtn.disabled = !(visible && socket && socket.connected && state && state.connected); - agentPauseBtn.title = state ? `Pause Agent input for ${getTerminalDisplayLabel(state)} (${state.id})` : 'Pause Agent input'; + agentPauseBtn.title = t('agent.pause.target', { target: state ? `${getTerminalDisplayLabel(state)} (${state.id})` : t('agent.access.current_tab') }); agentPauseBtn.setAttribute('aria-label', agentPauseBtn.title); updatePipStatus(pipTerminalState); } @@ -3552,19 +3564,19 @@

Recover StandTerm session

} function formatAgentModeLabel(mode) { - if (mode === AGENT_MODE_OBSERVE) return 'Observer'; - if (mode === AGENT_MODE_APPROVAL_PENDING) return 'Approval'; - if (mode === AGENT_MODE_DIRECT_ACTIVE) return 'Full'; - if (mode === AGENT_MODE_PAUSED) return 'Paused'; - return 'Off'; + if (mode === AGENT_MODE_OBSERVE) return t('agent.permission.observe'); + if (mode === AGENT_MODE_APPROVAL_PENDING) return t('agent.permission.approval'); + if (mode === AGENT_MODE_DIRECT_ACTIVE) return t('agent.permission.direct'); + if (mode === AGENT_MODE_PAUSED) return t('agent.permission.paused'); + return t('agent.permission.off'); } function formatExternalAgentPermissionHint(mode) { - if (mode === AGENT_MODE_OBSERVE) return 'Observer can read terminal output. It cannot type.'; - if (mode === AGENT_MODE_APPROVAL_PENDING) return 'Approval lets the agent propose input. You approve before it is sent. Remote file copies always need separate approval.'; - if (mode === AGENT_MODE_DIRECT_ACTIVE) return 'Full lets the agent type into this terminal without approval. Remote file copies still need separate approval.'; - if (mode === AGENT_MODE_PAUSED) return 'Agent input is paused. Choose a permission to resume or disable access.'; - return 'Enable external agent to choose what a local agent can do.'; + if (mode === AGENT_MODE_OBSERVE) return t('agent.permission.observe_hint'); + if (mode === AGENT_MODE_APPROVAL_PENDING) return t('agent.permission.approval_hint'); + if (mode === AGENT_MODE_DIRECT_ACTIVE) return t('agent.permission.direct_hint'); + if (mode === AGENT_MODE_PAUSED) return t('agent.permission.paused_hint'); + return t('agent.permission.disabled_hint'); } function clearAgentExternalTokenCountdownTimer() { @@ -3737,37 +3749,37 @@

Recover StandTerm session

agentExternalTokenBtn.disabled = disabled; agentExternalToken3xBtn.disabled = disabled; agentExternalTokenBtn.innerText = minting - ? 'Minting...' - : (hasRenewableAgentExternalToken(state) ? 'Renew token' : 'Mint token'); + ? t('agent.token.creating') + : (hasRenewableAgentExternalToken(state) ? t('agent.token.renew') : t('agent.token.create')); agentExternalToken3xBtn.innerText = minting - ? 'Minting...' - : (hasRenewableAgentExternalToken(state) ? 'Renew token 3×' : 'Mint token 3×'); + ? t('agent.token.creating') + : (hasRenewableAgentExternalToken(state) ? t('agent.token.renew_3x') : t('agent.token.create_3x')); if (!usable) { - agentExternalHint.innerText = 'Connect a terminal before minting a token.'; + agentExternalHint.innerText = t('agent.token.connect_required'); } else if (minting) { - agentExternalHint.innerText = 'Minting external agent token...'; + agentExternalHint.innerText = t('agent.token.creating'); } else if (paused) { - agentExternalHint.innerText = 'Resume Observer, Approval, or Full before minting a token.'; + agentExternalHint.innerText = t('agent.token.resume_required'); } else if (mode === AGENT_MODE_DISABLED) { - agentExternalHint.innerText = 'Enable external agent before minting a token.'; + agentExternalHint.innerText = t('agent.token.enable_required'); } else if (hasRenewableAgentExternalToken(state) && agent) { if (token.status === 'revoked') { - agentExternalHint.innerText = 'Permission revoked. Renew the token to continue.'; + agentExternalHint.innerText = t('agent.token.revoked'); return; } const secondsRemaining = getAgentExternalTokenSecondsRemaining(agent); if (secondsRemaining === null) { - agentExternalHint.innerText = 'Token minted for this terminal and permission.'; + agentExternalHint.innerText = t('agent.token.ready'); } else if (secondsRemaining <= 0) { - agentExternalHint.innerText = 'Permission expired. Renew the token to continue.'; + agentExternalHint.innerText = t('agent.token.expired'); } else { - agentExternalHint.innerText = `Permission expires in ${secondsRemaining}s without activity.`; + agentExternalHint.innerText = t('agent.token.idle_remaining', { seconds: secondsRemaining }); } scheduleAgentExternalTokenCountdown(secondsRemaining); } else if (token && token.status === 'invalidated') { - agentExternalHint.innerText = 'Permission invalidated. Mint a new token to continue.'; + agentExternalHint.innerText = t('agent.token.invalidated'); } else { - agentExternalHint.innerText = 'Mint a local-only token for this terminal and permission.'; + agentExternalHint.innerText = t('agent.token.local_hint'); } } @@ -3788,12 +3800,12 @@

Recover StandTerm session

button.classList.toggle('visible', available); button.disabled = !available || minting; }); - agentStatusMintBtn.innerText = minting ? 'Minting...' : 'Mint'; - agentStatusMint3xBtn.innerText = minting ? 'Minting...' : 'Mint 3×'; + agentStatusMintBtn.innerText = minting ? t('agent.token.creating') : t('agent.token.create'); + agentStatusMint3xBtn.innerText = minting ? t('agent.token.creating') : t('agent.token.create_3x'); if (state) { const target = state.label || state.id; - agentStatusMintBtn.title = `Mint a standard external-agent token for ${target}`; - agentStatusMint3xBtn.title = `Mint a 3× idle-time external-agent token for ${target}`; + agentStatusMintBtn.title = t('agent.token.target_title', { target }); + agentStatusMint3xBtn.title = t('agent.token.target_3x_title', { target }); } } @@ -3804,11 +3816,12 @@

Recover StandTerm session

const minting = !!(token && token.status === 'minting'); const available = canUseAgentPanel(state) && !starting && !minting; agentAccessMintBtn.disabled = !available; - agentAccessMintBtn.innerText = starting ? 'Starting…' : (minting ? 'Minting…' : '🤖 Agent Mint'); + agentAccessMintBtn.innerText = starting ? t('agent.access.starting') : (minting ? t('agent.token.creating') : `🤖 ${t('agent.access.grant')}`); const modeLabel = formatAgentModeLabel(prefs.agentAccessMintMode); - agentAccessMintBtn.title = state - ? `Grant ${modeLabel} access and mint a standard token for ${getTerminalDisplayLabel(state)} (${state.id})` - : `Grant ${modeLabel} access and mint a standard token for the current tab`; + agentAccessMintBtn.title = t('agent.access.grant_title', { + permission: modeLabel, + target: state ? `${getTerminalDisplayLabel(state)} (${state.id})` : t('agent.access.current_tab') + }); } function applyAgentExternalTokenState(data) { @@ -3925,21 +3938,21 @@

Recover StandTerm session

agentPanel.classList.toggle('visible', usable && agentPanelVisible); const mainPanelVisible = agentPanelVisible && !agentPanelTerminalIdOverride; agentToggleBtn.disabled = !canUseAgentPanel(getActiveTerminalState()); - agentToggleBtn.innerText = mainPanelVisible ? 'Hide Agent Panel' : 'Show Agent Panel'; - agentToggleBtn.title = mainPanelVisible ? 'Hide Agent panel' : 'Show Agent panel'; + agentToggleBtn.innerText = t(mainPanelVisible ? 'agent.panel.hide' : 'agent.panel.show'); + agentToggleBtn.title = t(mainPanelVisible ? 'agent.panel.hide' : 'agent.panel.show'); const agent = state ? state.agent : null; const mode = agent ? agent.mode : AGENT_MODE_DISABLED; const pending = agent ? agent.pendingActions : 0; const accessEnabled = mode !== AGENT_MODE_DISABLED; const modeLabel = formatAgentModeLabel(mode); - agentPanelState.innerText = state ? `${state.label || state.id} · ${modeLabel}${pending ? ` · ${pending} pending` : ''}` : 'Off'; + agentPanelState.innerText = state ? `${state.label || state.id} · ${modeLabel}${pending ? ` · ${t('agent.panel.pending', { count: pending })}` : ''}` : t('agent.permission.off'); agentAccessToggleBtn.disabled = !usable; - agentAccessToggleBtn.innerText = accessEnabled ? 'Disable external agent' : 'Enable external agent'; + agentAccessToggleBtn.innerText = t(accessEnabled ? 'agent.access.disable' : 'agent.access.enable'); agentAccessToggleBtn.classList.toggle('disable', accessEnabled); agentAccessToggleBtn.title = accessEnabled - ? 'Turn off external agent access for this terminal' - : 'Enable external agent access in Observer mode'; - agentAccessSummary.innerText = accessEnabled ? modeLabel : 'Off'; + ? t('agent.access.disable_title') + : t('agent.access.enable_title'); + agentAccessSummary.innerText = accessEnabled ? modeLabel : t('agent.permission.off'); agentPermissionHint.innerText = formatExternalAgentPermissionHint(mode); agentModeButtons.forEach(button => { const buttonMode = button.dataset.agentMode; @@ -6172,6 +6185,7 @@

Recover StandTerm session

Object.entries(PREF_DEFAULTS).forEach(([key, defaultValue]) => { if (typeof source[key] === typeof defaultValue) result[key] = source[key]; }); + if ('uiLanguage' in result) result.uiLanguage = window.StandTermI18n.normalizeLocale(result.uiLanguage); if (result.urlClickAction && !['overlay', 'popup', 'newtab'].includes(result.urlClickAction)) delete result.urlClickAction; if (result.colorScheme && !SCHEMES[result.colorScheme]) delete result.colorScheme; if (result.fontFace) result.fontFace = result.fontFace.slice(0, 240); @@ -6425,8 +6439,8 @@

Recover StandTerm session

if (state.agentCountdownEl.textContent !== countdown) state.agentCountdownEl.textContent = countdown; state.agentCountdownEl.hidden = !active; const title = active - ? `${label}\nExternal agent token active (${formatAgentModeLabel(state.agent.mode)}); ${secondsRemaining}s remaining without activity` - : expired ? `${label}\nExternal agent token expired; mint again to continue` : label; + ? `${label}\n${t('agent.token.tab_active', { permission: formatAgentModeLabel(state.agent.mode), seconds: secondsRemaining })}` + : expired ? `${label}\n${t('agent.token.tab_expired')}` : label; if (state.tabEl.title !== title) { state.tabEl.title = title; state.tabEl.setAttribute('aria-label', title); @@ -11734,6 +11748,7 @@

Recover StandTerm session

} document.getElementById('pref-colorScheme').onchange = renderColorSchemePreview; const openSettings = () => { + document.getElementById('pref-uiLanguage').value = prefs.uiLanguage; document.getElementById('pref-useCustomMenu').checked = prefs.useCustomMenu; document.getElementById('pref-copyOnSelect').checked = prefs.copyOnSelect; document.getElementById('pref-enablePicPreview').checked = prefs.enablePicPreview; @@ -11962,6 +11977,7 @@

Recover StandTerm session

}; }); document.getElementById('settings-save').onclick = () => { + prefs.uiLanguage = window.StandTermI18n.normalizeLocale(document.getElementById('pref-uiLanguage').value); prefs.useCustomMenu = document.getElementById('pref-useCustomMenu').checked; prefs.copyOnSelect = document.getElementById('pref-copyOnSelect').checked; prefs.enablePicPreview = document.getElementById('pref-enablePicPreview').checked; diff --git a/tests/agent_browser_smoke.py b/tests/agent_browser_smoke.py index 83d6b9d..1ed2cc1 100644 --- a/tests/agent_browser_smoke.py +++ b/tests/agent_browser_smoke.py @@ -142,9 +142,12 @@ def stop_server(proc): proc.wait(timeout=5) -def new_page(browser, access_url): - context = browser.new_context(viewport={'width': 1280, 'height': 800}) +def new_page(browser, access_url, ui_language=None): + context = browser.new_context(viewport={'width': 1280, 'height': 800}, locale='en-US') page = context.new_page() + if ui_language is not None: + preferences = json.dumps({'uiLanguage': ui_language}) + page.add_init_script(f"localStorage.setItem('terminal.pref.v1', JSON.stringify({preferences}));") page.goto(debug_url(access_url), wait_until='domcontentloaded') # Keep debug-only overlays from covering controls during normal UI tests. page.add_style_tag(content='#debug-hud, #payload-log { display: none !important; }') @@ -614,7 +617,7 @@ def test_agent_mint_quick_action_applies_saved_permission(browser, access_url): ) check(placement == { 'firstTool': 'agent-access-mint-btn', - 'text': '🤖 Agent Mint', + 'text': '🤖 Authorize agent', 'disabled': False, 'panelVisible': False, }, 'Agent Mint was not the ready leftmost right-side action') @@ -637,7 +640,7 @@ def test_agent_mint_quick_action_applies_saved_permission(browser, access_url): check(minted['mode'] == 'direct_active', 'one-click Agent Mint did not apply Full permission') check(minted['external_token']['idleTimeoutMultiplier'] == 1, 'one-click Agent Mint did not mint the standard token lifetime') - check(page.locator('#agent-access-mint-btn').inner_text() == '🤖 Agent Mint', + check(page.locator('#agent-access-mint-btn').inner_text() == '🤖 Authorize agent', 'Agent Mint action did not return to its ready label') mode_events = [ entry['args'][0] for entry in get_emitted(page, 'agent_mode_set') @@ -660,12 +663,108 @@ def test_agent_mint_quick_action_applies_saved_permission(browser, access_url): }""", timeout=10000, ) - check('Grant Approval access' in page.locator('#agent-access-mint-btn').get_attribute('title'), + check('Authorize Approval required access' in page.locator('#agent-access-mint-btn').get_attribute('title'), 'Agent Mint title did not reflect the saved permission') finally: close_context(context) +def test_agent_language_preview_preserves_access_and_applies_on_next_page(browser, access_url): + context, page = new_page(browser, access_url, ui_language='zh-TW') + token_requests = [] + page.on('request', lambda request: token_requests.append(request.url) + if request.method == 'POST' and urllib.parse.urlparse(request.url).path == '/agent/external/token' + else None) + try: + check(page.locator('html').get_attribute('lang') == 'zh-TW', 'saved language did not set the document language') + check(page.inner_text('#agent-access-mint-btn') == '🤖 授權 Agent', 'authorization action was not localized') + check(page.inner_text('#agent-connect-btn') == 'Agent 連線', 'connection action was not localized') + clear_emitted(page) + page.click('#agent-access-mint-btn') + wait_for_agent(page, "state.mode === 'direct_active' && state.external_token?.status === 'active'") + check(len(token_requests) == 1, 'localized authorization did not create exactly one token') + check([entry['args'][0] for entry in get_emitted(page, 'agent_mode_set')] == [ + {'terminal_id': TERMINAL_ID, 'mode': 'direct_active'} + ], 'localized authorization changed the permission protocol value') + check('--token' in active_agent_state(page)['external_token']['command'], + 'localized authorization did not preserve the generated command') + + page.click('#agent-toggle-btn') + page.wait_for_selector('#agent-panel.visible') + modes = page.locator('[data-agent-mode]').evaluate_all( + "buttons => buttons.map(button => ({value: button.dataset.agentMode, label: button.innerText}))" + ) + check(modes == [ + {'value': 'observe', 'label': '唯讀'}, + {'value': 'approval_pending', 'label': '需核准'}, + {'value': 'direct_active', 'label': '直接輸入'}, + ], 'localized permission labels changed their protocol values') + check(page.locator('#agent-mode-controls').get_attribute('aria-label') == 'Agent 權限', + 'permission selector accessible name was not localized') + check(page.inner_text('#agent-external-token-btn') == '更新權杖', 'active token action was not localized') + check('檔案複製仍需核准' in page.inner_text('#agent-permission-hint'), + 'localized direct input omitted file-copy approval') + page.click('#agent-panel-close-btn') + + page.evaluate('''() => Object.defineProperty(navigator, 'clipboard', { + configurable: true, value: {writeText: async text => {window.copiedAgentText = text;}} + })''') + page.click('#agent-connect-btn') + page.wait_for_selector('#agent-connect-copy:not([disabled])') + check(page.get_by_role('dialog', name='Agent 連線').is_visible(), 'connection dialog accessible name was not localized') + check(page.locator('#agent-connect-url').get_attribute('aria-label') == 'Agent 資訊網址', + 'connection URL accessible name was not localized') + check(page.locator('#agent-connect-info').get_attribute('aria-label') == 'Agent 連線指引', + 'connection prompt accessible name was not localized') + check(page.inner_text('#agent-connect-copy') == '複製連線指引', 'connection copy action was not localized') + check('main: 等待 Agent' in page.inner_text('#agent-connect-activity'), + 'localized connection activity did not distinguish a grant from Agent activity') + prompt = page.input_value('#agent-connect-info') + check('Run discover, then hello' in prompt, 'display language translated the machine-facing connection prompt') + page.click('#agent-connect-copy') + page.wait_for_function('text => window.copiedAgentText === text', arg=prompt) + check('已複製' in page.inner_text('#agent-connect-message'), 'clipboard result was not localized') + page.click('#agent-connect-close') + + page.evaluate('() => { window.languagePreviewSentinel = true; }') + before = active_agent_state(page) + socket_before = page.evaluate('() => window.terminalTest.getSocketState()') + requests_before = len(token_requests) + page.click('#quick-settings') + check(page.input_value('#pref-uiLanguage') == 'zh-TW', 'settings did not show the saved language') + check(page.input_value('#pref-agentAccessMintMode') == 'direct_active', 'language changed the saved permission') + page.select_option('#pref-uiLanguage', 'en') + clear_emitted(page) + page.click('#settings-save') + page.wait_for_selector('#settings-modal.open', state='hidden') + check(page.evaluate("() => JSON.parse(localStorage.getItem('terminal.pref.v1')).uiLanguage") == 'en', + 'settings did not save the display preference') + check(page.evaluate('() => window.languagePreviewSentinel === true'), 'saving language reloaded the active page') + check(page.inner_text('#agent-access-mint-btn') == '🤖 授權 Agent', 'language changed before opening another page') + check(page.locator('html').get_attribute('lang') == 'zh-TW', 'active document language changed before reopening') + after = active_agent_state(page) + for field in ['terminal_id', 'connected', 'session_id', 'viewer_id', 'agent_binding_id', + 'mode', 'mode_version', 'external_token']: + check(after[field] == before[field], f'saving language changed Agent state field {field}') + check(page.evaluate('() => window.terminalTest.getSocketState()') == socket_before, + 'saving language changed the active socket') + check(len(token_requests) == requests_before, 'saving language created or renewed a token') + check(not any(entry['event'] in {'agent_attach', 'agent_detach', 'agent_mode_set', 'agent_pause', 'start_ssh', 'stop_ssh'} + for entry in get_emitted(page)), 'saving language emitted an access or connection mutation') + + next_page = context.new_page() + next_page.goto(debug_url(access_url), wait_until='domcontentloaded') + next_page.wait_for_function('() => !!window.terminalTest', timeout=10000) + check(next_page.locator('html').get_attribute('lang') == 'en', 'new page did not apply the saved language') + check(next_page.inner_text('#agent-access-mint-btn') == '🤖 Authorize agent', 'new page did not show English authorization') + check(next_page.inner_text('#agent-connect-btn') == 'Agent connection', 'new page did not show English connection text') + check(next_page.locator('#agent-mode-controls').get_attribute('aria-label') == 'Agent permission', + 'new page did not apply English accessible names') + check(page.inner_text('#agent-access-mint-btn') == '🤖 授權 Agent', 'new page changed the existing page language') + finally: + close_context(context) + + def test_agent_panel_can_be_dragged(browser, access_url): context, page = new_page(browser, access_url) try: @@ -1989,9 +2088,9 @@ def test_agent_panel_status_gates_and_external_hint(browser, access_url): })""" ) check(enabled_external['accessText'] == 'Disable external agent', 'agent access toggle did not offer disable after enabling') - check(enabled_external['modeLabels'] == ['Observer', 'Approval', 'Full'], 'agent permission buttons did not use user-facing labels') + check(enabled_external['modeLabels'] == ['Read only', 'Approval required', 'Direct input'], 'agent permission buttons did not use user-facing labels') check(enabled_external['buttonDisabled'] is False, 'external token button did not enable in observe mode') - check('Mint' in enabled_external['hint'], 'external token hint did not show available state') + check('Create a local-only token' in enabled_external['hint'], 'external token hint did not show available state') panel_mint_state = page.evaluate( """() => ({ @@ -2063,7 +2162,7 @@ def test_external_token_tab_indicator_tracks_background_lifecycle(browser, acces 'Background tab lost its minted-token indicator') check(page.locator('.terminal-tab.active.agent-token-active').count() == 0, 'New tab inherited the other terminal token indicator') - check('External agent token active' in page.locator(main_tab).get_attribute('title'), + check('Agent token active' in page.locator(main_tab).get_attribute('title'), 'Active token has no text explanation') color = lambda: page.locator(main_tab + ' .tab-state').evaluate( 'element => getComputedStyle(element).backgroundColor') @@ -2098,7 +2197,7 @@ def token_event(status, remaining_ms): token_event('revoked', 60000) check(page.locator(main_tab + '.agent-token-active').count() == 0, 'Revoked token stayed bright') check(page.locator(main_tab + '.agent-token-expired').count() == 0, 'Revocation was shown as expiry') - check('External agent token' not in page.locator(main_tab).get_attribute('title'), + check('Agent token' not in page.locator(main_tab).get_attribute('title'), 'Revoked token tooltip remained stale') check(page.locator(main_tab + ' .tab-agent-countdown').is_hidden(), 'Revoked token retained a countdown label') @@ -2190,7 +2289,7 @@ def test_session_recovery_new_tab_can_renew_external_agent_token(browser, access command: document.getElementById('agent-external-command').value })""" ) - check(recovered_token_ui['buttonText'] == 'Mint token', 'new terminal reused stale external token command state') + check(recovered_token_ui['buttonText'] == 'Create token', 'new terminal reused stale external token command state') check(recovered_token_ui['command'] == '', 'new terminal kept stale external token command text') page.evaluate("() => document.getElementById('agent-external-token-btn').click()") page.wait_for_function( @@ -4001,7 +4100,7 @@ def test_core_agent_connect_info_can_be_copied_and_confirmed(browser, access_url check('No active grants' in page.inner_text('#agent-connect-activity'), 'Missing authorization was not explained') check(page.locator('#agent-tunnel-btn').is_hidden(), 'Local shell offered an SSH tunnel') check(page.locator('#agent-remote-info-btn').count() == 0, 'A separate remote Agent Info button remains') - check(page.inner_text('#agent-connect-btn') == 'Agent Info for Current Tab', 'Info button did not identify its tab context') + check(page.inner_text('#agent-connect-btn') == 'Agent connection', 'Info button did not identify the Agent connection workflow') check(page.inner_text('#agent-connect-copy') == 'Copy Prompt', 'Local info did not offer a prompt') page.click('#agent-connect-copy-url') page.wait_for_function('url => window.copiedAgentText === url', arg=agentinfo_url) @@ -4137,7 +4236,7 @@ def ready(carrier, port): page.wait_for_function('text => window.copiedAgentText === text', arg=prompt) page.click('#agent-tunnel-close') page.click('#agent-connect-btn') - check(page.inner_text('#agent-tunnel-title') == 'Agent Info for Current Tab', 'Remote shortcut opened the wrong view') + check(page.inner_text('#agent-tunnel-title') == 'Agent connection', 'Remote shortcut opened the wrong view') check(page.locator('#agent-tunnel-setup').is_hidden(), 'Remote info repeated setup controls') check(page.locator('#agent-tunnel-copy').is_hidden(), 'Remote shortcut offered a stale cached prompt') page.evaluate('payload => window.terminalTest.completeAgentTunnelRequestForTest(1, payload)', first) @@ -4871,6 +4970,7 @@ def main(): test_agent_panel_can_be_dragged, test_toolbar_pause_targets_main_tab_not_panel_override, test_agent_mint_quick_action_applies_saved_permission, + test_agent_language_preview_preserves_access_and_applies_on_next_page, test_terminal_pip_hides_selected_tab_and_keeps_background_tab, test_sftp_status_actions_and_terminal_pip_transition, test_sftp_send_context_action_is_limited_to_connected_ssh_tabs, diff --git a/tests/ui_i18n_smoke.cjs b/tests/ui_i18n_smoke.cjs new file mode 100644 index 0000000..e5d1722 --- /dev/null +++ b/tests/ui_i18n_smoke.cjs @@ -0,0 +1,97 @@ +'use strict'; + +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const { test } = require('node:test'); +const vm = require('node:vm'); +const i18n = require('../static/js/standterm-i18n.js'); + +test('reviewed translations fall back to English and unknown keys remain visible', () => { + const messages = { en: { 'test.ready': 'Ready', 'test.pending': 'Pending', 'test.empty': 'Empty' }, + 'zh-TW': { 'test.ready': 'Ready translation', 'test.empty': '' } }; + const translated = i18n.create(messages, 'zh-TW'); + assert.equal(translated.locale, 'zh-TW'); + assert.equal(translated.t('test.ready'), 'Ready translation'); + assert.equal(translated.t('test.pending'), 'Pending'); + assert.equal(translated.t('test.empty'), 'Empty'); + assert.equal(translated.t('test.missing'), 'test.missing'); + assert.equal(i18n.create({ en: messages.en }, 'zh-TW').t('test.ready'), 'Ready'); + assert.equal(i18n.create({}, 'en').t('test.missing'), 'test.missing'); +}); + +test('unsupported locale input consistently selects English', () => { + for (const locale of [undefined, null, '', 'en', 'fr', 'zh-CN', 'zh-TW + + diff --git a/desktop/toolbar.js b/desktop/toolbar.js index 104bbe1..37a8f38 100644 --- a/desktop/toolbar.js +++ b/desktop/toolbar.js @@ -1,6 +1,8 @@ 'use strict'; const byId = id => document.getElementById(id); +let i18n = window.StandTermDesktopI18n.create('en'); +let appliedLocale; let lastNoticeId = 0; let noticeTimer; function showNotice(message, error = false) { @@ -24,13 +26,19 @@ for (const button of document.querySelectorAll('[data-action], [data-menu]')) { button.addEventListener('click', () => { const action = button.dataset.action || `menu:${button.dataset.menu}`; window.desktopToolbar.invoke(action).then(result => { - if (result === false) showNotice('Action unavailable in the current window state.', true); + if (result === false) showNotice(i18n.t('desktop.toolbar.action_unavailable'), true); }).catch(() => { - showNotice('Could not confirm the action result. Check the current state.', true); + showNotice(i18n.t('desktop.toolbar.action_unconfirmed'), true); }); }); } window.desktopToolbar.onState(state => { + if (typeof state.locale === 'string' && state.locale !== appliedLocale) { + i18n = window.StandTermDesktopI18n.create(state.locale); + appliedLocale = state.locale; + document.documentElement.lang = i18n.locale; + i18n.apply(document); + } if (typeof state.mac === 'boolean') { byId('menus').hidden = state.mac; } @@ -47,7 +55,7 @@ window.desktopToolbar.onState(state => { byId('stop').disabled = !stoppable; byId('save').disabled = byId('copy').disabled = state.screenshotBusy === true; byId('recording-status').textContent = active ? state.label : ''; - const pauseLabel = state.state === 'paused' ? 'Resume recording' : 'Pause recording'; + const pauseLabel = i18n.t(state.state === 'paused' ? 'desktop.toolbar.record_resume' : 'desktop.toolbar.record_pause'); byId('pause').title = pauseLabel; byId('pause').setAttribute('aria-label', pauseLabel); byId('pause-shape').setAttribute('d', state.state === 'paused' ? 'M7 4l13 8-13 8z' : 'M8 5v14M16 5v14'); diff --git a/desktop/ui-commands.cjs b/desktop/ui-commands.cjs index c56c71a..bdef687 100644 --- a/desktop/ui-commands.cjs +++ b/desktop/ui-commands.cjs @@ -2,14 +2,15 @@ const { BrowserWindow, Menu } = require('electron'); const { allowedNavigation } = require('./policy.cjs'); +const { create } = require('./i18n.js'); const UI_ACTIONS = Object.freeze({ - settings: 'Settings...', newTab: 'New terminal tab', closeTab: 'Close terminal tab', - closeAll: 'Close all terminal tabs...', files: 'Files...', pip: 'Terminal to PiP', - agentPanel: 'Show / hide Agent Panel', pauseAgent: 'Pause Agent for current terminal', + settings: 'desktop.menu.settings', newTab: 'desktop.menu.new_tab', closeTab: 'desktop.menu.close_tab', + closeAll: 'desktop.menu.close_all', files: 'desktop.menu.files', pip: 'desktop.menu.pip', + agentPanel: 'desktop.menu.agent_panel', pauseAgent: 'desktop.menu.pause_agent', }); -function createUiCommands(win, contents, origin) { +function createUiCommands(win, contents, origin, t = create('en').t) { let refreshing = false; const current = () => !win.isDestroyed() && !contents.isDestroyed() && allowedNavigation(contents.getURL(), origin); const focused = () => current() && BrowserWindow.getFocusedWindow() === win; @@ -54,7 +55,7 @@ function createUiCommands(win, contents, origin) { win.on('blur', () => { void refresh(); }); return { run, refresh, edit, - item: action => ({ id: `ui-${action}`, label: UI_ACTIONS[action], enabled: false, click: () => run(action) }), + item: action => ({ id: `ui-${action}`, label: t(UI_ACTIONS[action]), enabled: false, click: () => run(action) }), }; } diff --git a/docs/desktop_ui_copy_review.tsv b/docs/desktop_ui_copy_review.tsv index 54d4c70..60b3885 100644 --- a/docs/desktop_ui_copy_review.tsv +++ b/docs/desktop_ui_copy_review.tsv @@ -1,29 +1,29 @@ key current_en en zh-TW context placeholders constraints status source -desktop.toolbar.application_menu Application menu Application menu Navigation accessible name; custom menu captions are separate entries. Translate title/aria-label only; preserve SVG children, dataset action, fixed IPC IDs and focus checks. proposed desktop/toolbar.html -desktop.toolbar.clipboard_actions Clipboard actions Clipboard actions Clipboard toolbar accessible name. Translate title/aria-label only; preserve SVG children, dataset action, fixed IPC IDs and focus checks. proposed desktop/toolbar.html -desktop.toolbar.copy_hint Copy selected text (not Ctrl+C) Copy selected text (not Ctrl+C) Native clipboard copy, not terminal interrupt input; retain the distinction. Translate title/aria-label only; preserve SVG children, dataset action, fixed IPC IDs and focus checks. proposed desktop/toolbar.html -desktop.toolbar.copy_label Copy selected text Copy selected text Accessible name of native clipboard copy. Translate title/aria-label only; preserve SVG children, dataset action, fixed IPC IDs and focus checks. proposed desktop/toolbar.html -desktop.toolbar.paste Paste clipboard text Paste clipboard text Native clipboard paste into the Core editing target. Translate title/aria-label only; preserve SVG children, dataset action, fixed IPC IDs and focus checks. proposed desktop/toolbar.html -desktop.toolbar.capture_scope Capture the StandTerm terminal view Capture the terminal view Capture toolbar accessible name; scope stays the Core view, not the whole desktop. Translate title/aria-label only; preserve SVG children, dataset action, fixed IPC IDs and focus checks. proposed desktop/toolbar.html -desktop.toolbar.screenshot_save Save screenshot (PNG) Save screenshot (PNG) Screenshot file action; initial folder selection is separate. Translate title/aria-label only; preserve SVG children, dataset action, fixed IPC IDs and focus checks. proposed desktop/toolbar.html -desktop.toolbar.screenshot_copy Copy screenshot to clipboard Copy screenshot to clipboard Screenshot clipboard action; distinct from copying selected text. Translate title/aria-label only; preserve SVG children, dataset action, fixed IPC IDs and focus checks. proposed desktop/toolbar.html -desktop.toolbar.record_start Start silent recording (WebM) Start recording (WebM, no audio) Recording start button; preserve silent capture scope. Translate title/aria-label only; preserve SVG children, dataset action, fixed IPC IDs and focus checks. proposed desktop/toolbar.html -desktop.toolbar.record_pause Pause recording Pause recording Recording pause state, not Pause Agent. Translate title/aria-label only; preserve SVG children, dataset action, fixed IPC IDs and focus checks. proposed desktop/toolbar.html -desktop.toolbar.record_stop Stop and save recording Stop and save recording Requests stop/save; this label alone is not a save-success notification. Translate title/aria-label only; preserve SVG children, dataset action, fixed IPC IDs and focus checks. proposed desktop/toolbar.html -desktop.toolbar.record_resume Resume recording Resume recording Dynamic pause-button label for typed paused state. Translate title/aria-label only; preserve SVG children, dataset action, fixed IPC IDs and focus checks. Select by state.state, never translated label. proposed desktop/toolbar.js:onState -desktop.toolbar.action_unavailable Action unavailable in the current window state. Action unavailable in the current window state. Notice for an explicit false result from the toolbar action handler. English feedback implemented in order 0; localization remains proposed. Handle exactly false. Invoke once; do not replay. Do not reinterpret true as proof that output was saved. proposed desktop/toolbar.js:click handler; desktop/toolbar.cjs:standterm-toolbar-action -desktop.toolbar.action_unconfirmed Could not confirm the action result. Check the current state. Could not confirm the action result. Check the current state. Rejected IPC promise: operation outcome is uncertain. English feedback implemented in order 0; localization remains proposed. Keep exception handling distinct from explicit false. Invoke once; never automatically retry or claim the action did not happen. proposed desktop/toolbar.js:click handler -desktop.menu.settings Settings... Settings... Custom UI_ACTIONS menu label; preserve target scope and existing confirmation behavior. Keep UI_ACTIONS keys, ui-* IDs, action availability, terminalId, accelerators and focus/origin checks unchanged. Do not dispatch by label. proposed desktop/ui-commands.cjs:UI_ACTIONS -desktop.menu.new_tab New terminal tab New terminal tab Custom UI_ACTIONS menu label; preserve target scope and existing confirmation behavior. Keep UI_ACTIONS keys, ui-* IDs, action availability, terminalId, accelerators and focus/origin checks unchanged. Do not dispatch by label. proposed desktop/ui-commands.cjs:UI_ACTIONS -desktop.menu.close_tab Close terminal tab Close terminal tab Custom UI_ACTIONS menu label; preserve target scope and existing confirmation behavior. Keep UI_ACTIONS keys, ui-* IDs, action availability, terminalId, accelerators and focus/origin checks unchanged. Do not dispatch by label. proposed desktop/ui-commands.cjs:UI_ACTIONS -desktop.menu.close_all Close all terminal tabs... Close all terminal tabs... Custom UI_ACTIONS menu label; preserve target scope and existing confirmation behavior. Keep UI_ACTIONS keys, ui-* IDs, action availability, terminalId, accelerators and focus/origin checks unchanged. Do not dispatch by label. proposed desktop/ui-commands.cjs:UI_ACTIONS -desktop.menu.files Files... Files... Custom UI_ACTIONS menu label; preserve target scope and existing confirmation behavior. Keep UI_ACTIONS keys, ui-* IDs, action availability, terminalId, accelerators and focus/origin checks unchanged. Do not dispatch by label. proposed desktop/ui-commands.cjs:UI_ACTIONS -desktop.menu.pip Terminal to PiP Open terminal in PiP Custom UI_ACTIONS menu label; preserve target scope and existing confirmation behavior. Keep UI_ACTIONS keys, ui-* IDs, action availability, terminalId, accelerators and focus/origin checks unchanged. Do not dispatch by label. proposed desktop/ui-commands.cjs:UI_ACTIONS -desktop.menu.agent_panel Show / hide Agent Panel Show / hide Agent Panel Custom UI_ACTIONS menu label; preserve target scope and existing confirmation behavior. Keep UI_ACTIONS keys, ui-* IDs, action availability, terminalId, accelerators and focus/origin checks unchanged. Do not dispatch by label. proposed desktop/ui-commands.cjs:UI_ACTIONS -desktop.menu.pause_agent Pause Agent for current terminal Pause Agent for current terminal Custom UI_ACTIONS menu label; preserve target scope and existing confirmation behavior. Keep UI_ACTIONS keys, ui-* IDs, action availability, terminalId, accelerators and focus/origin checks unchanged. Do not dispatch by label. proposed desktop/ui-commands.cjs:UI_ACTIONS -desktop.agent.help_title Give your agent a StandTerm prompt Connect your agent to StandTerm Getting-started dialog heading; descriptive text alone grants no access. Keep permission requirements in the accompanying instructions. Do not expand public Agent Info. proposed desktop/main.cjs:agentMenu.showHelp -desktop.agent.help_permissions Select the tab where your agent runs. Use Agent Panel to enable access and choose permissions on each tab it may operate. Select the tab where your agent runs. In Agent Panel, enable access and choose permissions for each tab it may operate. First paragraph of Agent help; caller tab and permitted target tabs can differ. Keep per-tab authorization, local/SSH distinction and current permission defaults. No implicit cross-tab grants. proposed desktop/main.cjs:agentMenu.showHelp -desktop.agent.help_connection Open Agent connection, choose Copy Prompt, and paste it into your agent with the intended task. Follow the environment shown in that dialog. Open Agent connection, choose Copy Prompt, and paste it into your agent with the task. Follow the environment shown in the dialog. Connection-instructions paragraph; SSH/local setup remains in the preceding paragraph. Match the actual Core control names, including Copy Prompt; retain environment/target guidance. Do not translate or modify the copied prompt payload here. proposed desktop/main.cjs:agentMenu.showHelp +desktop.toolbar.application_menu Application menu Application menu 應用程式選單 Navigation accessible name; custom menu captions are separate entries. Translate title/aria-label only; preserve SVG children, dataset action, fixed IPC IDs and focus checks. translation-reviewed desktop/toolbar.html +desktop.toolbar.clipboard_actions Clipboard actions Clipboard actions 剪貼簿操作 Clipboard toolbar accessible name. Translate title/aria-label only; preserve SVG children, dataset action, fixed IPC IDs and focus checks. translation-reviewed desktop/toolbar.html +desktop.toolbar.copy_hint Copy selected text (not Ctrl+C) Copy selected text (not Ctrl+C) 複製選取文字(不送出 Ctrl+C) Native clipboard copy, not terminal interrupt input; retain the distinction. Translate title/aria-label only; preserve SVG children, dataset action, fixed IPC IDs and focus checks. translation-reviewed desktop/toolbar.html +desktop.toolbar.copy_label Copy selected text Copy selected text 複製選取文字 Accessible name of native clipboard copy. Translate title/aria-label only; preserve SVG children, dataset action, fixed IPC IDs and focus checks. translation-reviewed desktop/toolbar.html +desktop.toolbar.paste Paste clipboard text Paste clipboard text 貼上剪貼簿文字 Native clipboard paste into the Core editing target. Translate title/aria-label only; preserve SVG children, dataset action, fixed IPC IDs and focus checks. translation-reviewed desktop/toolbar.html +desktop.toolbar.capture_scope Capture the StandTerm terminal view Capture the terminal view 擷取終端畫面 Capture toolbar accessible name; scope stays the Core view, not the whole desktop. Translate title/aria-label only; preserve SVG children, dataset action, fixed IPC IDs and focus checks. translation-reviewed desktop/toolbar.html +desktop.toolbar.screenshot_save Save screenshot (PNG) Save screenshot (PNG) 儲存截圖(PNG) Screenshot file action; initial folder selection is separate. Translate title/aria-label only; preserve SVG children, dataset action, fixed IPC IDs and focus checks. translation-reviewed desktop/toolbar.html +desktop.toolbar.screenshot_copy Copy screenshot to clipboard Copy screenshot to clipboard 複製截圖至剪貼簿 Screenshot clipboard action; distinct from copying selected text. Translate title/aria-label only; preserve SVG children, dataset action, fixed IPC IDs and focus checks. translation-reviewed desktop/toolbar.html +desktop.toolbar.record_start Start silent recording (WebM) Start recording (WebM, no audio) 開始錄影(WebM,無音訊) Recording start button; preserve silent capture scope. Translate title/aria-label only; preserve SVG children, dataset action, fixed IPC IDs and focus checks. translation-reviewed desktop/toolbar.html +desktop.toolbar.record_pause Pause recording Pause recording 暫停錄影 Recording pause state, not Pause Agent. Translate title/aria-label only; preserve SVG children, dataset action, fixed IPC IDs and focus checks. translation-reviewed desktop/toolbar.html +desktop.toolbar.record_stop Stop and save recording Stop and save recording 停止並儲存錄影 Requests stop/save; this label alone is not a save-success notification. Translate title/aria-label only; preserve SVG children, dataset action, fixed IPC IDs and focus checks. translation-reviewed desktop/toolbar.html +desktop.toolbar.record_resume Resume recording Resume recording 繼續錄影 Dynamic pause-button label for typed paused state. Translate title/aria-label only; preserve SVG children, dataset action, fixed IPC IDs and focus checks. Select by state.state, never translated label. translation-reviewed desktop/toolbar.js:onState +desktop.toolbar.action_unavailable Action unavailable in the current window state. Action unavailable in the current window state. 目前的視窗狀態無法執行此操作。 Notice for an explicit false result from the toolbar action handler. English feedback implemented in order 0; included in the localization pilot. Handle exactly false. Invoke once; do not replay. Do not reinterpret true as proof that output was saved. translation-reviewed desktop/toolbar.js:click handler; desktop/toolbar.cjs:standterm-toolbar-action +desktop.toolbar.action_unconfirmed Could not confirm the action result. Check the current state. Could not confirm the action result. Check the current state. 無法確認操作結果。請檢查目前狀態。 Rejected IPC promise: operation outcome is uncertain. English feedback implemented in order 0; included in the localization pilot. Keep exception handling distinct from explicit false. Invoke once; never automatically retry or claim the action did not happen. translation-reviewed desktop/toolbar.js:click handler +desktop.menu.settings Settings... Settings... 設定… Custom UI_ACTIONS menu label; preserve target scope and existing confirmation behavior. Keep UI_ACTIONS keys, ui-* IDs, action availability, terminalId, accelerators and focus/origin checks unchanged. Do not dispatch by label. translation-reviewed desktop/ui-commands.cjs:UI_ACTIONS +desktop.menu.new_tab New terminal tab New terminal tab 新增終端分頁 Custom UI_ACTIONS menu label; preserve target scope and existing confirmation behavior. Keep UI_ACTIONS keys, ui-* IDs, action availability, terminalId, accelerators and focus/origin checks unchanged. Do not dispatch by label. translation-reviewed desktop/ui-commands.cjs:UI_ACTIONS +desktop.menu.close_tab Close terminal tab Close terminal tab 關閉終端分頁 Custom UI_ACTIONS menu label; preserve target scope and existing confirmation behavior. Keep UI_ACTIONS keys, ui-* IDs, action availability, terminalId, accelerators and focus/origin checks unchanged. Do not dispatch by label. translation-reviewed desktop/ui-commands.cjs:UI_ACTIONS +desktop.menu.close_all Close all terminal tabs... Close all terminal tabs... 關閉所有終端分頁… Custom UI_ACTIONS menu label; preserve target scope and existing confirmation behavior. Keep UI_ACTIONS keys, ui-* IDs, action availability, terminalId, accelerators and focus/origin checks unchanged. Do not dispatch by label. translation-reviewed desktop/ui-commands.cjs:UI_ACTIONS +desktop.menu.files Files... Files... 檔案… Custom UI_ACTIONS menu label; preserve target scope and existing confirmation behavior. Keep UI_ACTIONS keys, ui-* IDs, action availability, terminalId, accelerators and focus/origin checks unchanged. Do not dispatch by label. translation-reviewed desktop/ui-commands.cjs:UI_ACTIONS +desktop.menu.pip Terminal to PiP Open terminal in PiP 以子母畫面開啟終端 Custom UI_ACTIONS menu label; preserve target scope and existing confirmation behavior. Keep UI_ACTIONS keys, ui-* IDs, action availability, terminalId, accelerators and focus/origin checks unchanged. Do not dispatch by label. translation-reviewed desktop/ui-commands.cjs:UI_ACTIONS +desktop.menu.agent_panel Show / hide Agent Panel Show / hide Agent Panel 顯示/隱藏 Agent 面板 Custom UI_ACTIONS menu label; preserve target scope and existing confirmation behavior. Keep UI_ACTIONS keys, ui-* IDs, action availability, terminalId, accelerators and focus/origin checks unchanged. Do not dispatch by label. translation-reviewed desktop/ui-commands.cjs:UI_ACTIONS +desktop.menu.pause_agent Pause Agent for current terminal Pause Agent for current terminal 暫停目前終端的 Agent Custom UI_ACTIONS menu label; preserve target scope and existing confirmation behavior. Keep UI_ACTIONS keys, ui-* IDs, action availability, terminalId, accelerators and focus/origin checks unchanged. Do not dispatch by label. translation-reviewed desktop/ui-commands.cjs:UI_ACTIONS +desktop.agent.help_title Give your agent a StandTerm prompt Connect your agent to StandTerm 讓 Agent 連線至 StandTerm Getting-started dialog heading; descriptive text alone grants no access. Keep permission requirements in the accompanying instructions. Do not expand public Agent Info. Desktop and Core may use different languages; keep the English Core control names recognizable alongside Chinese labels. translation-reviewed desktop/main.cjs:agentMenu.showHelp +desktop.agent.help_permissions Select the tab where your agent runs. Use Agent Panel to enable access and choose permissions on each tab it may operate. Select the tab where your agent runs. In Agent Panel, enable access and choose permissions for each tab it may operate. 選取 Agent 所在的分頁。在 Agent 面板(Agent Panel)中,逐一為 Agent 可操作的分頁啟用存取並選擇權限。 First paragraph of Agent help; caller tab and permitted target tabs can differ. Keep per-tab authorization, local/SSH distinction and current permission defaults. No implicit cross-tab grants. Desktop and Core may use different languages; keep the English Core control names recognizable alongside Chinese labels. translation-reviewed desktop/main.cjs:agentMenu.showHelp +desktop.agent.help_connection Open Agent connection, choose Copy Prompt, and paste it into your agent with the intended task. Follow the environment shown in that dialog. Open Agent connection, choose Copy Prompt, and paste it into your agent with the task. Follow the environment shown in the dialog. 開啟「Agent 連線」(Agent connection),選擇「複製連線指引」(Copy Prompt),再將指引連同任務貼給 Agent。請依對話框顯示的環境操作。 Connection-instructions paragraph; SSH/local setup remains in the preceding paragraph. Match the actual Core control names, including Copy Prompt; retain environment/target guidance. Do not translate or modify the copied prompt payload here. Desktop and Core may use different languages; keep the English Core control names recognizable alongside Chinese labels. translation-reviewed desktop/main.cjs:agentMenu.showHelp desktop.capture.folder_policy Future captures save here automatically. Change this in StandTerm > Capture Settings. Future files of this capture type save here automatically. Change the folder in Capture Settings. Folder chooser message. PNG and WebM have separate saved folder preferences; this is a retained per-type choice, not a one-off Save As path. The first folder selection also seeds the other format if unset; later choices update only the selected format. Keep extension values png/webm and chosen filesystem path literal. Cancel still returns null; do not change folder persistence or auto-save behavior. Preserve first-choice seeding; do not describe the two preferences as fully independent. proposed desktop/capture.cjs:chooseDirectory:94-103 desktop.capture.settings_detail Screenshots: {screenshot_folder}\nRecordings: {recording_folder}\n\nPNG screenshots and silent WebM recordings are saved automatically. Screenshots: {screenshot_folder}\nRecordings: {recording_folder}\n\nPNG screenshots and WebM recordings save to these folders. Recordings have no audio. Capture Settings detail. Resolve the two current unset-folder labels separately in the later full catalog pass; this row preserves raw folder paths. {screenshot_folder}, {recording_folder} Keep response indices 0 Done, 1 screenshot-folder chooser, 2 recording-folder chooser, 3 open screenshot folder, 4 open recording folder. defaultId=0 and cancelId=0. No action inference from labels. proposed desktop/capture.cjs:configure:110-121 desktop.capture.capture_failed The terminal view could not be captured. Bring StandTerm to the foreground and retry. Window state is available in Diagnostics. Could not capture the terminal view. Bring StandTerm to the foreground and try again. Check Diagnostics if it still fails. Screenshot capturePage or empty-image failure before file output begins; retained diagnostic includes window state. Preserve original Error cause and diagnostic fields. No automatic retry, focus manipulation, or capture-scope expansion. proposed desktop/capture.cjs:screenshot:136-146 @@ -44,3 +44,28 @@ desktop.core_source.failure_choices The Desktop shell can retry or restore its i desktop.browser_access.opened Browser authorization opened in the default browser. Authorization link opened in the default browser. Success notice after awaiting OS open callback; browser authorization has not been observed as completed. Keep action open distinct from copy-auth/copy-url/copy-token; retain existing confirmation. Do not claim the browser is authorized or reveal the URL/token in the notice. proposed desktop/browser-access.cjs:createBrowserAccess.run:46-62 desktop.browser_access.copied Access information copied. Treat it as a password. Access information copied. Keep it private, like a password. Shared notice for copied authorization URL, access URL, or access token; the copied payload is sensitive. Keep exact clipboard payload and fixed action IDs; do not insert secrets into catalog parameters, logs or notifications. No extra mint, reveal, or retry. proposed desktop/browser-access.cjs:createBrowserAccess.run:58-62 desktop.browser_access.failed Could not prepare browser access. Check that this Desktop backend is still running. Could not prepare browser access. Check that this Desktop backend is running. Sanitized generic failure notice; implementation deliberately discards URL-bearing network errors. Preserve generic failure and error=true notification. Never display raw caught errors, URLs, grants or tokens. Keep pending/available guards and no automatic retry. proposed desktop/browser-access.cjs:createBrowserAccess.run catch:64-67 +desktop.toolbar.menu_standterm StandTerm StandTerm StandTerm Visible toolbar menu caption for data-menu=standterm. Translate textContent only. Preserve data-menu, menu IDs, IPC routing and the macOS visibility rule. translation-reviewed desktop/toolbar.html:12 +desktop.toolbar.menu_edit Edit Edit 編輯 Visible toolbar menu caption for data-menu=edit. Translate textContent only. Preserve data-menu, menu IDs, IPC routing and the macOS visibility rule. translation-reviewed desktop/toolbar.html:12 +desktop.toolbar.menu_agent Agent Agent Agent Visible toolbar menu caption for data-menu=agent-menu. Translate textContent only. Preserve data-menu, menu IDs, IPC routing and the macOS visibility rule. translation-reviewed desktop/toolbar.html:12 +desktop.toolbar.menu_view View View 檢視 Visible toolbar menu caption for data-menu=view. Translate textContent only. Preserve data-menu, menu IDs, IPC routing and the macOS visibility rule. translation-reviewed desktop/toolbar.html:12 +desktop.toolbar.menu_diagnostics Diagnostics Diagnostics 診斷 Visible toolbar menu caption for data-menu=diagnostics. Translate textContent only. Preserve data-menu, menu IDs, IPC routing and the macOS visibility rule. translation-reviewed desktop/toolbar.html:12 +desktop.agent.menu_title Agent Agent Agent Custom native Agent submenu title. Keep Agent in English and id=agent-menu unchanged. Do not dispatch by label. translation-reviewed desktop/agent-menu.cjs:4 +desktop.agent.getting_started Getting started... Getting started… 開始使用… Agent help menu item; opens the existing informational dialog. Preserve id=agent-help, showHelp callback and UI action order. Opening help grants no access. translation-reviewed desktop/agent-menu.cjs:6 +desktop.agent.help_environment For an SSH agent, start Agent Tunnel on its SSH tab. Agent connection appears after setup succeeds. For a local agent, use Authorize agent on each intended tab. For an Agent running on an SSH host, start Agent Tunnel on its SSH tab. Agent connection appears after setup succeeds. For a local Agent, use Authorize agent on each intended tab. 若 Agent 在 SSH 遠端執行,請在其 SSH 分頁啟動 Agent 通道(Agent Tunnel)。設定成功後會出現「Agent 連線」(Agent connection)。若 Agent 在本機執行,請在每個預定操作的分頁選擇「授權 Agent」(Authorize agent)。 Complete SSH/local paragraph between the per-tab permission and Copy Prompt instructions. Preserve the SSH carrier tab, successful-setup prerequisite, per-target authorization and local/SSH distinction. Keep English Core control names for independently selected Desktop/Core languages. Do not imply setup authorizes additional tabs. translation-reviewed desktop/main.cjs:414-415:agentMenu.showHelp +desktop.agent.help_skills Skills do not need to be installed first. The prompt leads to the bundled skills and helpers; Agent Info also provides installation instructions when persistent skills are wanted. Skills do not need to be installed first. The prompt leads to bundled skills and helpers. Agent Info also provides installation instructions for persistent skills. 不必先安裝技能(skills)。連線指引會引導 Agent 使用隨附的技能與輔助工具;若希望安裝後持續使用技能,Agent Info 也提供安裝說明。 Complete final Agent help paragraph about optional skill installation and bundled helpers. Keep Agent Info recognizable. Preserve optional installation, bundled-skill/helper guidance and the distinction between copying instructions and installing skills. Do not modify prompt contents or imply automatic installation. translation-reviewed desktop/main.cjs:418-419:agentMenu.showHelp +desktop.common.cancel Cancel Cancel 取消 Desktop language dialog; language names are self-names in both locales. Keep response indices 0 Cancel, 1 en, 2 zh-TW and cancel/default index 0. Persist locale only; do not restart, stop recording, authorize, or change Core preferences. translation-reviewed desktop/language.cjs:createLanguage +desktop.common.ok OK OK 確定 Desktop language dialog; language names are self-names in both locales. Keep response indices 0 Cancel, 1 en, 2 zh-TW and cancel/default index 0. Persist locale only; do not restart, stop recording, authorize, or change Core preferences. translation-reviewed desktop/language.cjs:createLanguage +desktop.language.menu Desktop language... Desktop 語系… Desktop language dialog; language names are self-names in both locales. Keep response indices 0 Cancel, 1 en, 2 zh-TW and cancel/default index 0. Persist locale only; do not restart, stop recording, authorize, or change Core preferences. translation-reviewed desktop/language.cjs:createLanguage +desktop.language.title Desktop language Desktop 語系 Desktop language dialog; language names are self-names in both locales. Keep response indices 0 Cancel, 1 en, 2 zh-TW and cancel/default index 0. Persist locale only; do not restart, stop recording, authorize, or change Core preferences. translation-reviewed desktop/language.cjs:createLanguage +desktop.language.choose Choose the Desktop language for the next launch. 選擇下次啟動時使用的 Desktop 語系。 Desktop language dialog; language names are self-names in both locales. Keep response indices 0 Cancel, 1 en, 2 zh-TW and cancel/default index 0. Persist locale only; do not restart, stop recording, authorize, or change Core preferences. translation-reviewed desktop/language.cjs:createLanguage +desktop.language.detail Saved choice: {language}\n\nCore has its own language setting. Some Desktop text remains in English. 已儲存的選擇:{language}\n\nCore 的語系需另外設定。部分 Desktop 文字仍使用英文。 Desktop language dialog; language names are self-names in both locales. {language} Keep response indices 0 Cancel, 1 en, 2 zh-TW and cancel/default index 0. Persist locale only; do not restart, stop recording, authorize, or change Core preferences. translation-reviewed desktop/language.cjs:createLanguage +desktop.language.saved Language saved. It applies the next time you launch StandTerm. 已儲存語系,下次啟動 StandTerm 時生效。 Desktop language dialog; language names are self-names in both locales. Keep response indices 0 Cancel, 1 en, 2 zh-TW and cancel/default index 0. Persist locale only; do not restart, stop recording, authorize, or change Core preferences. translation-reviewed desktop/language.cjs:createLanguage +desktop.language.failed Could not confirm the language setting. Reopen Desktop language to check the saved choice. 無法確認語系設定。請重新開啟「Desktop 語系」查看已儲存的選擇。 Desktop language dialog; language names are self-names in both locales. Keep response indices 0 Cancel, 1 en, 2 zh-TW and cancel/default index 0. Persist locale only; do not restart, stop recording, authorize, or change Core preferences. translation-reviewed desktop/language.cjs:createLanguage +desktop.language.name_en English English Desktop language dialog; language names are self-names in both locales. Keep response indices 0 Cancel, 1 en, 2 zh-TW and cancel/default index 0. Persist locale only; do not restart, stop recording, authorize, or change Core preferences. translation-reviewed desktop/language.cjs:createLanguage +desktop.language.name_zh_tw 繁體中文 繁體中文 Desktop language dialog; language names are self-names in both locales. Keep response indices 0 Cancel, 1 en, 2 zh-TW and cancel/default index 0. Persist locale only; do not restart, stop recording, authorize, or change Core preferences. translation-reviewed desktop/language.cjs:createLanguage +desktop.menu.open Open StandTerm Open StandTerm 開啟 StandTerm Custom main/tray menu label; destination dialog may remain English during the pilot. Translate display label only. Keep callbacks, accelerators, native roles and action IDs unchanged. translation-reviewed desktop/main.cjs:createTray/start +desktop.menu.show Show window Show window 顯示視窗 Custom main/tray menu label; destination dialog may remain English during the pilot. Translate display label only. Keep callbacks, accelerators, native roles and action IDs unchanged. translation-reviewed desktop/main.cjs:createTray/start +desktop.menu.quit Quit StandTerm Quit StandTerm 結束 StandTerm Custom main/tray menu label; destination dialog may remain English during the pilot. Translate display label only. Keep callbacks, accelerators, native roles and action IDs unchanged. translation-reviewed desktop/main.cjs:createTray/start +desktop.menu.about About StandTerm Desktop About StandTerm Desktop 關於 StandTerm Desktop Custom main/tray menu label; destination dialog may remain English during the pilot. Translate display label only. Keep callbacks, accelerators, native roles and action IDs unchanged. translation-reviewed desktop/main.cjs:createTray/start +desktop.menu.core_source Core source (Advanced)... Core source (Advanced)... Core 來源(進階)… Custom main/tray menu label; destination dialog may remain English during the pilot. Translate display label only. Keep callbacks, accelerators, native roles and action IDs unchanged. translation-reviewed desktop/main.cjs:createTray/start +desktop.menu.capture_settings Capture Settings... Capture Settings... 畫面擷取設定… Custom main/tray menu label; destination dialog may remain English during the pilot. Translate display label only. Keep callbacks, accelerators, native roles and action IDs unchanged. translation-reviewed desktop/main.cjs:createTray/start diff --git a/docs/desktop_ui_review_plan.md b/docs/desktop_ui_review_plan.md index 844b25d..7009093 100644 --- a/docs/desktop_ui_review_plan.md +++ b/docs/desktop_ui_review_plan.md @@ -2,7 +2,8 @@ Review date: 2026-09-20. The initial review inventories Desktop copy, reviews behavior and orders implementation. Completed implementation steps are recorded -below; a Desktop language setting is not yet enabled. Browser acceptance is recorded separately in +below. The Desktop language pilot is available; broader translation and native +acceptance remain separate. Browser acceptance is recorded in [browser_ui_acceptance.md](browser_ui_acceptance.md). ## Recommended implementation order @@ -10,12 +11,13 @@ below; a Desktop language setting is not yet enabled. Browser acceptance is reco | Order | Deliverable | Relative effort | Completion evidence | | --- | --- | --- | --- | | 0 — Complete | Clarify toolbar action feedback before localization. A resolved `false` now shows unavailable feedback; a rejected invocation reports an uncertain result without suggesting retry. | Small | Both original failures reproduced before the fix; renderer and command-guard checks passed. Each click invokes once, with no automatic replay or invented completion notice. | -| 1 | Add a Desktop-owned language preference and catalog; pilot custom menus, toolbar labels and Agent help. | Medium | English default/fallback, `en` and `zh-TW`, malformed preference fallback, next-launch application, translated title/ARIA labels without losing SVGs, fixed command IDs, focus/origin guards and explicit packaged asset inventory. | +| 1 — Complete | Add a Desktop-owned language preference and catalog; pilot custom menus, toolbar labels and Agent help. | Medium | English default/fallback, `en` and `zh-TW`, malformed preference fallback, next-launch application, translated title/ARIA labels without losing SVGs, fixed command IDs, focus/origin guards and staging inclusion verified. Native acceptance remains order 4. | | 2 | Review and localize Capture, Browser Access and Diagnostics. Resolve the recording save-failure exit policy before the Capture portion. | Medium | Typed recording states, partial-file paths, cancel/default buttons, first-folder seeding, sensitive clipboard feedback and escaped diagnostic fields retain their contracts. Add combined save-failure plus close/quit coverage. | | 3 | Localize setup, Core source selection, startup failure and recoverable environment cleanup. | Medium to large | Both languages work before Core is available. Cancellation waits for owned installers; stale confirmations do nothing; source switching, restart/session closure, retained files and recovery moves remain explicit. | | 4 | Complete Windows and macOS native acceptance and packaged asset checks. | Platform-dependent | Menus, native dialogs, narrow layouts, keyboard/ARIA labels, clipboard, setup and recovery are checked on each OS. Verify staged and packaged Desktop catalogs independently of the selected Core version. | -The next implementation is the language preference and small pilot in order 1. +The next implementation is Browser Access/Diagnostics and then Capture in order 2. +Resolve the recording save-failure exit policy before changing its confirmation. Orders 1–3 should remain separate reviewable changes. First-run setup can move ahead of order 2 if onboarding becomes the priority; it is not required to prove the small localization pilot. @@ -28,19 +30,21 @@ scriptless diagnostics. A complete Desktop rollout has moderate implementation cost and broader acceptance cost than the toolbar pilot. The estimates above are relative scope assessments, not measured delivery times. -Recommend storing the Desktop language in the existing profile's `userData`, +The pilot stores the Desktop language in `language.json` under the existing profile's `userData`, with English as default and only `en` / `zh-TW` initially. Apply a change on the next launch; changing language should not itself restart StandTerm or stop a recording. Keep the existing Core browser preference independent. This covers setup and recovery before Core starts, at the cost of two language preferences. -It is a proposed product choice, not a security requirement. A validated +It is a product choice, not a security requirement. A validated two-value advisory preference from Core is also feasible, but needs startup, origin and older-Core fallback rules. Automatic OS-language selection is deferred. Ship the Desktop catalog with the shell. Do not depend on the selected bundled or Git Core supplying compatible renderer scripts. Reuse the existing TSV -schema and validation rules; extend generator support minimally when integration -starts. Do not add the Desktop rows to the Core runtime catalog. +schema and validation rules. `build_ui_messages.py --desktop` produces +`desktop/messages.js`; the default command still produces only Core's catalog. +The headless smoke runner checks both targets. Do not add the Desktop rows to +the Core runtime catalog. Keep these implementation boundaries: @@ -64,11 +68,13 @@ Keep these implementation boundaries: [desktop_ui_copy_review.tsv](desktop_ui_copy_review.tsv) is a prioritized seed inventory, not a claim that every Desktop string has been extracted. It uses -the same nine columns as the browser table. All initial rows are `proposed`; -`zh-TW` is empty. Some `current_en` cells are exact fragments or normalize +the same nine columns as the browser table. After the pilot, 50 rows are +`translation-reviewed` with English and Traditional Chinese text; 20 workflow +rows remain `proposed` with empty `zh-TW` cells. Some `current_en` cells are exact fragments or normalize dynamic values to named placeholders; `context` identifies these cases. -Approve the English behavior and terminology before requesting translations. +Approve the English behavior and terminology before requesting translations of +the remaining proposed rows. Keep already reviewed rows unchanged. An external translation AI should return the same keys/order/columns, fill only `zh-TW`, preserve placeholders and literal identifiers, and keep `status` unchanged. Human/source review promotes rows to `translation-reviewed`; a @@ -122,8 +128,44 @@ limited public Agent Info, per-tab permission instructions, exact renderer allowlists and recoverable cleanup. No discovery expansion is needed for Desktop localization. +## Pilot review and evidence + +The independent implementation review accepted the preference lifetime, numeric +dialog responses, typed commands and exact renderer asset boundaries. Its only +copy finding changed the language dialog from “Some Desktop dialogs remain in +English” to “Some Desktop text remains in English”: recording status and some +menus are also outside this pilot. The focused second pass found no material +remaining gap in the new preference, DOM and command tests. + +| Finding | Severity | Evidence | Critic remedy | Main response | Resolution | Validation | +| --- | --- | --- | --- | --- | --- | --- | +| Partial coverage disclosure mentions dialogs only | Low | `toolbar.js` preserves raw capture status; View retains capture menu labels | Say some Desktop text remains English | Applied to both languages | Accept | Reviewed TSV and regenerated catalog. | +| Save can finish before its notification fails | Validation boundary | `language.cjs:choose` persists before notification | Verify saved choice survives notification failure | Keep uncertain-result wording and the stored selection | Accept | Lost-notification test plus reopening the chooser passed. | +| Labels must not change action or target dispatch | Correctness boundary | `ui-commands.cjs` action and snapshot target | Test both locales with unchanged dispatch values | Retained existing guards | Accept | Typed command/terminal ID, invalid display-label dispatch and focus tests passed. | + +No policy was reopened. Core's independent language preference and the existing +recording-exit behavior remain intact. Native OS qualification is still order 4. + ## Evidence and acceptance limits +Order 1 completed on 2026-09-20 with 50 reviewed bilingual messages: + +- All 110 Desktop unit tests passed under Electron's Node 24.20.0 runtime. +- Seven catalog regression tests passed, including independent Desktop output + and stale detection without changing Core output. Both generated catalogs + passed their freshness checks. +- The real toolbar DOM passed in English and Traditional Chinese at 640px: + labels, ARIA, SVG preservation, typed recording state, literal notices, + failure feedback without replay and unknown-locale fallback. Native IPC was + mocked in this headless Chromium check. +- The actual staging script copied the new catalog/helper/preferences module. + Their bytes matched the source; the staged catalog loaded independently, and + the builder's inclusion rules covered all three files. No installer was built. + +The restricted sandbox initially blocked Chromium startup and a Git subprocess +used by the full Desktop suite. The same checks passed after running outside +that process restriction. This was an environment failure, not a product fix. + Order 0 completed on 2026-09-20. Before the renderer change, regression tests reproduced both the missing explicit-rejection notice and the misleading retry notice. After the change, all seven renderer notice tests passed, covering @@ -142,7 +184,7 @@ inspection of Capture/setup/recovery does not imply their smoke suites ran in this review. The review table is checked using `build_ui_messages.build_catalog` for schema, -keys and placeholders, plus an assertion that all rows are proposed and all -Traditional Chinese cells remain empty. No runtime catalog is generated. +keys, placeholders and review gates. Only the 50 reviewed pilot rows enter the +Desktop runtime catalog; the remaining workflow proposals stay out of it. Windows/macOS native localization, installer lifecycle and packaged acceptance remain future work. This plan does not qualify or publish a release. diff --git a/scripts/build_ui_messages.py b/scripts/build_ui_messages.py index 82297be..f026c11 100644 --- a/scripts/build_ui_messages.py +++ b/scripts/build_ui_messages.py @@ -54,15 +54,17 @@ def build_catalog(source): return messages -def render_catalog(messages): +def render_catalog(messages, desktop=False): payload = json.dumps(messages, ensure_ascii=True, indent=4, sort_keys=True) + source = 'desktop_ui_copy_review.tsv' if desktop else 'ui_copy_review.tsv' + global_name = 'StandTermDesktopMessages' if desktop else 'StandTermMessages' return ( - '// Generated by scripts/build_ui_messages.py. Edit docs/ui_copy_review.tsv.\n' + f'// Generated by scripts/build_ui_messages.py. Edit docs/{source}.\n' '(function (root) {\n' " 'use strict';\n" f' const messages = {payload};\n' " if (typeof module === 'object' && module.exports) module.exports = messages;\n" - ' else root.StandTermMessages = messages;\n' + f' else root.{global_name} = messages;\n' '})(globalThis);\n' ) @@ -70,14 +72,18 @@ def render_catalog(messages): def main(argv=None): parser = argparse.ArgumentParser(description=__doc__) parser.add_argument('--check', action='store_true', help='Fail if the committed catalog is stale') + parser.add_argument('--desktop', action='store_true', help='Build the independent Desktop catalog') args = parser.parse_args(argv) + source = ROOT / 'docs' / 'desktop_ui_copy_review.tsv' if args.desktop else SOURCE + output = ROOT / 'desktop' / 'messages.js' if args.desktop else OUTPUT try: - expected = render_catalog(build_catalog(SOURCE)) + expected = render_catalog(build_catalog(source), desktop=args.desktop) if args.check: - if not OUTPUT.is_file() or OUTPUT.read_text(encoding='utf-8') != expected: - raise ValueError('UI catalog is stale; run scripts/build_ui_messages.py') + if not output.is_file() or output.read_text(encoding='utf-8') != expected: + command = 'scripts/build_ui_messages.py' + (' --desktop' if args.desktop else '') + raise ValueError(f'UI catalog is stale; run {command}') else: - OUTPUT.write_text(expected, encoding='utf-8') + output.write_text(expected, encoding='utf-8') except (OSError, ValueError) as exc: parser.exit(1, f'{exc}\n') return 0 diff --git a/scripts/run_smoke_tests.py b/scripts/run_smoke_tests.py index f96ee89..32bb58f 100644 --- a/scripts/run_smoke_tests.py +++ b/scripts/run_smoke_tests.py @@ -78,6 +78,7 @@ def main(argv=None): ) run_step('Check generated UI catalog', [sys.executable, 'scripts/build_ui_messages.py', '--check']) + run_step('Check generated Desktop catalog', [sys.executable, 'scripts/build_ui_messages.py', '--desktop', '--check']) for test_path in HEADLESS_SMOKE_TESTS: run_step(test_path, [sys.executable, test_path]) diff --git a/tests/ui_messages_smoke.py b/tests/ui_messages_smoke.py index e76b8db..160ba83 100644 --- a/tests/ui_messages_smoke.py +++ b/tests/ui_messages_smoke.py @@ -132,6 +132,33 @@ def invoke(*args): self.assertNotEqual(output.read_bytes(), original) self.assertEqual(invoke('--check').returncode, 0) + def test_desktop_cli_targets_its_own_catalog_without_touching_core(self): + root = Path(self.directory.name) + script = root / 'scripts' / BUILDER.name + source = root / 'docs' / 'desktop_ui_copy_review.tsv' + output = root / 'desktop' / 'messages.js' + core_output = root / 'static' / 'js' / 'standterm-messages.js' + for path in (script, source, output, core_output): + path.parent.mkdir(parents=True, exist_ok=True) + shutil.copyfile(BUILDER, script) + core_output.write_text('Core fixture stays unchanged', encoding='utf-8') + write_table(source, [message(status='translation-reviewed', **{'zh-TW': 'Translated'})]) + + def invoke(*args): + return subprocess.run([sys.executable, str(script), '--desktop', *args], + capture_output=True, text=True, timeout=10, check=False) + + self.assertEqual(invoke('--check').returncode, 1) + result = invoke() + self.assertEqual(result.returncode, 0, result.stderr) + original = output.read_bytes() + self.assertIn(b'root.StandTermDesktopMessages', original) + self.assertEqual(invoke('--check').returncode, 0) + write_table(source, [message(en='Changed')]) + self.assertEqual(invoke('--check').returncode, 1) + self.assertEqual(output.read_bytes(), original) + self.assertEqual(core_output.read_text(encoding='utf-8'), 'Core fixture stays unchanged') + if __name__ == '__main__': unittest.main() From e2b417974a1b9d743a4673fab2e1893000cc49a2 Mon Sep 17 00:00:00 2001 From: "ASKA C." Date: Sun, 20 Sep 2026 09:56:11 +0800 Subject: [PATCH 30/43] Localize Desktop browser access and diagnostics ## Why Browser access confirmations and diagnostic windows stay English after changing the Desktop language. The log status label also implies a live writability check although it reflects recorded write outcomes. ## What changed - Add 76 reviewed bilingual messages for browser access, diagnostics, About and external-browser confirmations. - Keep authorization actions, consent indices, sensitive clipboard payloads and sanitized error feedback unchanged. - Escape translated diagnostic text while retaining scriptless rendering and literal event JSON, identifiers, paths and version metadata. - Describe log write outcomes without promising current filesystem writability. ## Testing All 115 Desktop unit tests and seven catalog regression tests pass; both catalogs are current. Headless Chromium checks pass in both languages for diagnostic headings, literal paths/events, escaped markup, empty state and narrow layout. Native OS dialogs, menus and installer acceptance remain separate. --- desktop/README.md | 8 +- desktop/browser-access.cjs | 18 ++- desktop/diagnostics-window.cjs | 34 ++-- desktop/diagnostics.cjs | 25 +-- desktop/main.cjs | 75 +++++---- desktop/messages.js | 152 ++++++++++++++++++ desktop/test/browser-access.test.cjs | 63 +++++++- .../test/diagnostics-i18n-browser-smoke.py | 74 +++++++++ desktop/test/diagnostics.test.cjs | 106 ++++++++++++ docs/desktop_ui_copy_review.tsv | 79 ++++++++- docs/desktop_ui_review_plan.md | 46 +++++- 11 files changed, 590 insertions(+), 90 deletions(-) create mode 100644 desktop/test/diagnostics-i18n-browser-smoke.py diff --git a/desktop/README.md b/desktop/README.md index 545a5a7..9001300 100644 --- a/desktop/README.md +++ b/desktop/README.md @@ -13,8 +13,9 @@ Desktop copy and localization are planned in the Choose **StandTerm > Desktop language...** to select English or Traditional Chinese (Taiwan) for the next launch. The preference belongs to the Desktop profile; Core keeps its own language setting. Saving a choice does not restart -StandTerm or interrupt recording. This initial coverage includes custom menus, -toolbar labels and Agent help. Capture status, setup, recovery and other Desktop +StandTerm or interrupt recording. Coverage includes custom menus, toolbar labels, +Agent help, Browser Access, Diagnostics, About and external-browser confirmations. +Capture status, setup, recovery and other Desktop text still use English; native role labels follow the platform. Edit reviewed messages in the table, then generate the independent shell @@ -24,6 +25,9 @@ headless smoke runner checks both Desktop and Core catalogs. The toolbar DOM check, `desktop/test/toolbar-i18n-browser-smoke.py`, uses the existing Playwright development environment and mocked native IPC; it does not qualify native menus, dialogs or installers. +`desktop/test/diagnostics-i18n-browser-smoke.py` checks the actual diagnostics +HTML in both languages, including escaped display data and unchanged event JSON. +It needs Node 22.12+; use `--node ` to select a prepared runtime. ## macOS Apple Silicon evaluation diff --git a/desktop/browser-access.cjs b/desktop/browser-access.cjs index 9893a4a..af7c66b 100644 --- a/desktop/browser-access.cjs +++ b/desktop/browser-access.cjs @@ -1,5 +1,7 @@ 'use strict'; +const { create } = require('./i18n.js'); + function validateAccessUrl(value, origin, authorization = false) { if (typeof value !== 'string' || value.length > 8192) throw new Error('Invalid access response.'); const url = new URL(value); @@ -12,7 +14,7 @@ function validateAccessUrl(value, origin, authorization = false) { return url; } -function createBrowserAccess({ origin, session, launcherToken, available, confirm, copy, open, notify }) { +function createBrowserAccess({ origin, session, launcherToken, available, confirm, copy, open, notify, t = create('en').t }) { const base = new URL(origin); if (base.origin !== origin || base.protocol !== 'http:' || base.hostname !== '127.0.0.1' || !base.port || typeof launcherToken !== 'string' || !launcherToken) throw new Error('Invalid browser access authority.'); @@ -56,23 +58,23 @@ function createBrowserAccess({ origin, session, launcherToken, available, confir if (!available()) return false; if (action === 'open') await open(url.href); else copy(action === 'copy-token' ? url.searchParams.get('token') : url.href); - await notify(action === 'open' ? 'Browser authorization opened in the default browser.' : 'Access information copied. Treat it as a password.'); + await notify(t(action === 'open' ? 'desktop.browser_access.opened' : 'desktop.browser_access.copied')); return true; } catch { // Never forward URL-bearing network errors, access tokens or grants to logs/UI. - await notify('Could not prepare browser access. Check that this Desktop backend is still running.', true); + await notify(t('desktop.browser_access.failed'), true); return false; } finally { pending = false; } } return { run, dispose: () => { launcherToken = ''; }, - menu: { label: 'Browser Access', submenu: [ - { label: 'Open in browser...', click: () => run('open') }, - { label: 'Copy browser authorization URL...', click: () => run('copy-auth') }, + menu: { label: t('desktop.browser_access.menu'), submenu: [ + { label: t('desktop.browser_access.open'), click: () => run('open') }, + { label: t('desktop.browser_access.copy_authorization'), click: () => run('copy-auth') }, { type: 'separator' }, - { label: 'Copy access URL (sensitive)', click: () => run('copy-url') }, - { label: 'Copy access token (sensitive)', click: () => run('copy-token') }, + { label: t('desktop.browser_access.copy_url'), click: () => run('copy-url') }, + { label: t('desktop.browser_access.copy_token'), click: () => run('copy-token') }, ] }, }; } diff --git a/desktop/diagnostics-window.cjs b/desktop/diagnostics-window.cjs index fc9c619..9658130 100644 --- a/desktop/diagnostics-window.cjs +++ b/desktop/diagnostics-window.cjs @@ -1,31 +1,31 @@ 'use strict'; const { randomUUID } = require('node:crypto'); +const { create, normalizeLocale } = require('./i18n.js'); -function statusHtml(rows, events) { +function statusHtml(rows, events, { locale, t } = create('en')) { const escape = value => String(value).replace(/[&<>"']/g, char => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''', })[char]); - return ` + const text = key => escape(t(`desktop.diagnostics.${key}`)); + return ` - StandTerm Desktop - Diagnostics + ${text('window_title')} -

Desktop diagnostics

Read-only snapshot. Use View > Refresh (Ctrl/Cmd+R) for current data.

-

Runtime and storage

${rows.map(([name, value]) => ``).join('')}
${escape(name)}${escape(value)}
-

Preview and browser handoff

External embedded previews are blocked in Desktop. - Use the preview's Open in browser button and confirm the destination. This opens your OS default browser; - its cookies and login are separate. Desktop credentials are not added to the URL.

-

Recent startup events

Up to 200 structured events from this run. No terminal content, - private keys, tokens or external URLs. Previous runs are available in the diagnostics log folder.

-
${escape(events.map(event => JSON.stringify(event)).join('\n') || 'No events yet.')}
`; +

${text('title')}

${text('read_only_hint')}

+

${text('runtime_heading')}

${rows.map(([name, value]) => ``).join('')}
${escape(name)}${escape(value)}
+

${text('handoff_heading')}

${text('handoff_hint')}

+

${text('events_heading')}

${text('events_hint')}

+
${escape(events.map(event => JSON.stringify(event)).join('\n') || t('desktop.diagnostics.no_events'))}
`; } -function createStatusWindow(owner, snapshot, { copyUrl }) { +function createStatusWindow(owner, snapshot, { copyUrl, i18n = create('en') }) { const { BrowserWindow, Menu, session } = require('electron'); + const { t } = i18n; let win; let opening; async function show() { @@ -36,16 +36,16 @@ function createStatusWindow(owner, snapshot, { copyUrl }) { isolated.setPermissionCheckHandler(() => false); isolated.setDevicePermissionHandler(() => false); isolated.webRequest.onBeforeRequest((details, callback) => callback({ cancel: !details.url.startsWith('data:text/html,') })); - win = new BrowserWindow({ title: 'StandTerm Desktop - Diagnostics', width: 900, height: 720, + win = new BrowserWindow({ title: t('desktop.diagnostics.window_title'), width: 900, height: 720, show: false, parent: owner, webPreferences: { session: isolated, sandbox: true, contextIsolation: true, nodeIntegration: false, webviewTag: false, devTools: false } }); win.webContents.setWindowOpenHandler(() => ({ action: 'deny' })); for (const name of ['will-navigate', 'will-frame-navigate', 'will-redirect', 'will-attach-webview']) { win.webContents.on(name, event => event.preventDefault()); } - win.setMenu(Menu.buildFromTemplate([{ label: 'View', submenu: [ - { label: 'Refresh', accelerator: 'CommandOrControl+R', click: () => refresh().catch(() => {}) }, - { label: 'Copy backend URL', click: copyUrl }, + win.setMenu(Menu.buildFromTemplate([{ label: t('desktop.toolbar.menu_view'), submenu: [ + { label: t('desktop.diagnostics.refresh'), accelerator: 'CommandOrControl+R', click: () => refresh().catch(() => {}) }, + { label: t('desktop.diagnostics.copy_backend_url'), click: copyUrl }, { role: 'close' }, ] }])); const owned = win; @@ -55,7 +55,7 @@ function createStatusWindow(owner, snapshot, { copyUrl }) { } async function refresh() { const { rows, events } = snapshot(); - await win.loadURL('data:text/html,' + encodeURIComponent(statusHtml(rows, events))); + await win.loadURL('data:text/html,' + encodeURIComponent(statusHtml(rows, events, i18n))); } await refresh(); win.show(); diff --git a/desktop/diagnostics.cjs b/desktop/diagnostics.cjs index 21296ce..a108f1d 100644 --- a/desktop/diagnostics.cjs +++ b/desktop/diagnostics.cjs @@ -2,6 +2,7 @@ const fs = require('node:fs'); const path = require('node:path'); +const { create } = require('./i18n.js'); const EVENTS = new Set(['startup', 'setup_start', 'setup_ready', 'backend_launch', 'backend_ready', 'backend_exit', 'backend_spawn_failed', 'backend_verify_retry', 'backend_verified', 'host_port_rejected', 'port_change', 'window_ready', 'startup_failed', 'core_failed', 'shutdown', 'devtools_opened', 'capture_failed']); @@ -56,20 +57,20 @@ function agentConnectionInfo({ origin, mode, instanceId }) { } function diagnosticsMenu({ origin, mode, instanceId, version, coreVersion, logger, openLogs, openTools, openStatus, - copyText, persistent = false }) { + copyText, persistent = false, t = create('en').t }) { const info = agentConnectionInfo({ origin, mode, instanceId }); - return { id: 'diagnostics', label: 'Diagnostics', submenu: [ - { id: 'diagnostics-status', label: 'Status and recent events...', click: openStatus }, - { id: 'diagnostics-version', label: `StandTerm Desktop ${version}`, enabled: false }, - { id: 'diagnostics-core-version', label: `Core version: ${coreVersion || 'Unknown (older Core)'}`, enabled: false }, - { id: 'diagnostics-backend', label: `Backend: ${mode === 'wsl' ? 'WSL' : 'Native'}`, enabled: false }, - { id: 'diagnostics-origin', label: `URL: ${origin}`, enabled: false }, - { id: 'diagnostics-copy-origin', label: 'Copy backend URL', click: () => copyText(info.base_url) }, - { label: persistent ? 'Web settings: saved per origin (same as Core)' : 'Web settings: temporary test profile', enabled: false }, + return { id: 'diagnostics', label: t('desktop.toolbar.menu_diagnostics'), submenu: [ + { id: 'diagnostics-status', label: t('desktop.diagnostics.show_status'), click: openStatus }, + { id: 'diagnostics-version', label: t('desktop.about.desktop_version', { version }), enabled: false }, + { id: 'diagnostics-core-version', label: t('desktop.about.core_version', { version: coreVersion || t('desktop.about.unknown_version') }), enabled: false }, + { id: 'diagnostics-backend', label: t('desktop.about.backend', { backend: mode === 'wsl' ? 'WSL' : t('desktop.diagnostics.native_backend') }), enabled: false }, + { id: 'diagnostics-origin', label: t('desktop.diagnostics.origin', { origin }), enabled: false }, + { id: 'diagnostics-copy-origin', label: t('desktop.diagnostics.copy_backend_url'), click: () => copyText(info.base_url) }, + { label: t(persistent ? 'desktop.diagnostics.web_settings_persistent' : 'desktop.diagnostics.web_settings_temporary'), enabled: false }, { type: 'separator' }, - { id: 'diagnostics-logs', label: 'Open diagnostics log folder', click: openLogs }, - { label: logger.available ? 'Logs exclude credentials and terminal content' : 'Diagnostic log could not be written', enabled: false }, - { id: 'diagnostics-devtools', label: 'Developer Tools...', click: openTools }, + { id: 'diagnostics-logs', label: t('desktop.diagnostics.open_logs'), click: openLogs }, + { label: t(logger.available ? 'desktop.diagnostics.logs_filtered' : 'desktop.diagnostics.log_unwritable'), enabled: false }, + { id: 'diagnostics-devtools', label: t('desktop.diagnostics.devtools_menu'), click: openTools }, ] }; } diff --git a/desktop/main.cjs b/desktop/main.cjs index 6ef424a..a268f11 100644 --- a/desktop/main.cjs +++ b/desktop/main.cjs @@ -346,12 +346,12 @@ async function start() { toolbar = installToolbar(win, coreView, capture, commands, language.locale); installContextPaste(win, contents, handoff.origin, toolbar.notify); const browserAccess = createBrowserAccess({ origin: handoff.origin, session: desktopSession, launcherToken, + t, available: () => !win.isDestroyed() && !contents.isDestroyed() && allowedNavigation(contents.getURL(), handoff.origin), confirm: async () => { - const result = await dialog.showMessageBox(win, { type: 'warning', title: 'Authorize another browser', - message: 'Allow another browser to access this StandTerm instance?', - detail: 'The authorization URL contains sensitive access information. Share it only with your own trusted browser. Opening it may store it in browser history. A new authorization link replaces the previous unused link.', - buttons: ['Cancel', 'Continue'], defaultId: 0, cancelId: 0, noLink: true }); + const result = await dialog.showMessageBox(win, { type: 'warning', title: t('desktop.browser_access.confirm_title'), + message: t('desktop.browser_access.confirm_message'), detail: t('desktop.browser_access.confirm_detail'), + buttons: [t('desktop.common.cancel'), t('desktop.browser_access.continue')], defaultId: 0, cancelId: 0, noLink: true }); return result.response === 1; }, copy: value => clipboard.writeText(value), open: value => shell.openExternal(value), notify: toolbar.notify, @@ -363,29 +363,29 @@ async function start() { updateTitle(); }); const connectionInfo = agentConnectionInfo({ origin: handoff.origin, instanceId: handoff.instance_id, mode }); - const coreVersion = handoff.core_version || 'Unknown (older Core)'; + const coreVersion = handoff.core_version || t('desktop.about.unknown_version'); const coreBuild = prepared?.source === 'git' ? prepared.coreSource - : handoff.core_bundle_id || 'Source checkout / no managed bundle identity'; - const pythonVersion = handoff.python_version || 'Unknown (older Core)'; - const buildLabel = prepared?.source === 'git' ? 'Core Git revision' : 'Core bundle SHA-256'; - const aboutDetails = `Core version: ${coreVersion}\n${buildLabel}: ${coreBuild}\n` - + `Backend: ${MODES[mode]}\nPython: ${pythonVersion}\n` - + `Electron: ${process.versions.electron}\nChromium: ${process.versions.chrome}\nNode.js: ${process.versions.node}\n` - + `Platform: ${process.platform} / ${process.arch}\n\nEvaluation build; updates are installed manually.`; + : handoff.core_bundle_id || t('desktop.about.unmanaged_source'); + const pythonVersion = handoff.python_version || t('desktop.about.unknown_version'); + const buildLabel = t(prepared?.source === 'git' ? 'desktop.about.git_revision' : 'desktop.about.bundle_sha256'); + const aboutDetails = t('desktop.about.details', { core_version: coreVersion, build_label: buildLabel, + core_build: coreBuild, backend: MODES[mode], python_version: pythonVersion, + electron_version: process.versions.electron, chromium_version: process.versions.chrome, + node_version: process.versions.node, platform: process.platform, arch: process.arch }); const openStatus = createStatusWindow(win, () => ({ rows: [ - ['Desktop version', app.getVersion()], ['Backend mode', MODES[mode]], - ['Core version', coreVersion], [buildLabel, coreBuild], ['Python version', pythonVersion], - ['Backend URL', handoff.origin], ['Instance ID', handoff.instance_id], - ['Platform', `${process.platform} / ${process.arch}`], - ['Electron / Chromium / Node', `${process.versions.electron} / ${process.versions.chrome} / ${process.versions.node}`], - ['Backend process', child && child.exitCode === null && child.signalCode === null ? 'Running' : 'Stopped'], - ['Renderer process', contents.isCrashed() ? 'Crashed' : 'Running'], - ['Browser storage', smoke ? 'Temporary test profile' : 'Persistent per origin; same as Core'], - ['Profile directory', app.getPath('userData')], ['Port settings', settingsPath], - ['Diagnostic log', diagnostics.file], ['Log writable', diagnostics.available ? 'Yes' : 'No'], - ['Security', 'Core sandbox on; Node/preload off; isolated Desktop toolbar; external preview network blocked'], + [t('desktop.diagnostics.desktop_version'), app.getVersion()], [t('desktop.diagnostics.backend_mode'), MODES[mode]], + [t('desktop.diagnostics.core_version'), coreVersion], [buildLabel, coreBuild], [t('desktop.diagnostics.python_version'), pythonVersion], + [t('desktop.diagnostics.backend_url'), handoff.origin], [t('desktop.diagnostics.instance_id'), handoff.instance_id], + [t('desktop.diagnostics.platform'), `${process.platform} / ${process.arch}`], + [t('desktop.diagnostics.engines'), `${process.versions.electron} / ${process.versions.chrome} / ${process.versions.node}`], + [t('desktop.diagnostics.backend_process'), t(child && child.exitCode === null && child.signalCode === null ? 'desktop.diagnostics.running' : 'desktop.diagnostics.stopped')], + [t('desktop.diagnostics.renderer_process'), t(contents.isCrashed() ? 'desktop.diagnostics.crashed' : 'desktop.diagnostics.running')], + [t('desktop.diagnostics.browser_storage'), t(smoke ? 'desktop.diagnostics.storage_temporary' : 'desktop.diagnostics.storage_persistent')], + [t('desktop.diagnostics.profile_directory'), app.getPath('userData')], [t('desktop.diagnostics.port_settings'), settingsPath], + [t('desktop.diagnostics.log_file'), diagnostics.file], [t('desktop.diagnostics.log_writable'), t(diagnostics.available ? 'desktop.diagnostics.log_write_ok' : 'desktop.diagnostics.log_write_failed')], + [t('desktop.diagnostics.security'), t('desktop.diagnostics.security_detail')], ], events: diagnostics.snapshot() }), { - copyUrl: () => clipboard.writeText(connectionInfo.base_url), + copyUrl: () => clipboard.writeText(connectionInfo.base_url), i18n: language, }); Menu.setApplicationMenu(Menu.buildFromTemplate([ { id: 'standterm', label: 'StandTerm', submenu: [ @@ -401,7 +401,7 @@ async function start() { commands.item('files'), commands.item('pip'), { type: 'separator' }, { id: 'desktop-about', label: t('desktop.menu.about'), click: () => dialog.showMessageBox(win, { - type: 'info', title: t('desktop.menu.about'), message: `StandTerm Desktop ${app.getVersion()}`, + type: 'info', title: t('desktop.menu.about'), message: t('desktop.about.desktop_version', { version: app.getVersion() }), detail: aboutDetails, buttons: [t('desktop.common.ok')], noLink: true, }) }, { label: t('desktop.menu.show'), click: showWindow }, @@ -424,22 +424,21 @@ async function start() { ...capture.menu().submenu, ] }, diagnosticsMenu({ origin: handoff.origin, mode, instanceId: handoff.instance_id, + t, version: app.getVersion(), coreVersion: handoff.core_version, logger: diagnostics, persistent: !smoke, copyText: text => clipboard.writeText(text), openStatus: () => openStatus().catch(() => { - if (!win.isDestroyed()) dialog.showMessageBox(win, { type: 'warning', message: 'Could not open the diagnostics page.' }); + if (!win.isDestroyed()) dialog.showMessageBox(win, { type: 'warning', message: t('desktop.diagnostics.open_failed') }); }), openLogs: async () => { const error = await shell.openPath(diagnostics.directory); - if (error) await dialog.showMessageBox(win, { type: 'warning', message: 'Could not open the diagnostics folder.', detail: diagnostics.directory }); + if (error) await dialog.showMessageBox(win, { type: 'warning', message: t('desktop.diagnostics.folder_failed'), detail: diagnostics.directory }); }, openTools: async () => { const opened = await openDeveloperTools(contents, async () => { - const answer = await dialog.showMessageBox(win, { type: 'warning', title: 'Developer Tools', - message: 'Open Developer Tools for this authenticated terminal UI?', - detail: 'Console code can read page data and operate connected terminals. Only run code you trust. ' - + 'This opens a local frontend debugger, not a remote debugging port or a Node.js bridge.', - buttons: ['Cancel', 'Open Developer Tools'], defaultId: 0, cancelId: 0, noLink: true }); + const answer = await dialog.showMessageBox(win, { type: 'warning', title: t('desktop.diagnostics.devtools_title'), + message: t('desktop.diagnostics.devtools_message'), detail: t('desktop.diagnostics.devtools_detail'), + buttons: [t('desktop.common.cancel'), t('desktop.diagnostics.devtools_open')], defaultId: 0, cancelId: 0, noLink: true }); return answer.response === 1; }); if (opened) diagnostics.write('devtools_opened'); @@ -448,15 +447,15 @@ async function start() { ])); const openExternal = createExternalOpener({ origin: handoff.origin, owner: contents, confirm: async url => { - const answer = await dialog.showMessageBox(win, { type: 'question', title: 'Open in default browser', - message: `Open ${new URL(url).host} in your default browser?`, - detail: `${url}\n\nThe browser uses its own login. StandTerm does not add its token or cookies.`, - buttons: ['Cancel', 'Open in browser'], defaultId: 0, cancelId: 0, noLink: true }); + const answer = await dialog.showMessageBox(win, { type: 'question', title: t('desktop.external_browser.title'), + message: t('desktop.external_browser.message', { host: new URL(url).host }), + detail: t('desktop.external_browser.detail', { url }), + buttons: [t('desktop.common.cancel'), t('desktop.external_browser.open')], defaultId: 0, cancelId: 0, noLink: true }); return answer.response === 1; }, open: url => shell.openExternal(url), - notify: () => dialog.showMessageBox(win, { type: 'warning', message: 'Could not open the default browser.', - detail: 'Check the default HTTP/HTTPS browser in your operating system settings.' }), + notify: () => dialog.showMessageBox(win, { type: 'warning', message: t('desktop.external_browser.failed'), + detail: t('desktop.external_browser.failure_hint') }), }); installFloatingWindows(win, handoff.origin, openExternal, contents, (result, owner) => { const completed = result.state === 'completed' && !!result.path; diff --git a/desktop/messages.js b/desktop/messages.js index 6343851..f76b232 100644 --- a/desktop/messages.js +++ b/desktop/messages.js @@ -3,6 +3,14 @@ 'use strict'; const messages = { "en": { + "desktop.about.backend": "Backend: {backend}", + "desktop.about.bundle_sha256": "Core bundle SHA-256", + "desktop.about.core_version": "Core version: {version}", + "desktop.about.desktop_version": "StandTerm Desktop {version}", + "desktop.about.details": "Core version: {core_version}\n{build_label}: {core_build}\nBackend: {backend}\nPython: {python_version}\nElectron: {electron_version}\nChromium: {chromium_version}\nNode.js: {node_version}\nPlatform: {platform} / {arch}\n\nEvaluation build. Install updates manually.", + "desktop.about.git_revision": "Core Git revision", + "desktop.about.unknown_version": "Unknown (older Core)", + "desktop.about.unmanaged_source": "Source checkout / no managed bundle identity", "desktop.agent.getting_started": "Getting started\u2026", "desktop.agent.help_connection": "Open Agent connection, choose Copy Prompt, and paste it into your agent with the task. Follow the environment shown in the dialog.", "desktop.agent.help_environment": "For an Agent running on an SSH host, start Agent Tunnel on its SSH tab. Agent connection appears after setup succeeds. For a local Agent, use Authorize agent on each intended tab.", @@ -10,8 +18,76 @@ "desktop.agent.help_skills": "Skills do not need to be installed first. The prompt leads to bundled skills and helpers. Agent Info also provides installation instructions for persistent skills.", "desktop.agent.help_title": "Connect your agent to StandTerm", "desktop.agent.menu_title": "Agent", + "desktop.browser_access.confirm_detail": "The authorization URL contains sensitive access information. Use it only in your own trusted browser. Opening it may save it in browser history. A new link replaces the previous unused authorization link.", + "desktop.browser_access.confirm_message": "Allow another browser to access this StandTerm instance?", + "desktop.browser_access.confirm_title": "Authorize another browser", + "desktop.browser_access.continue": "Continue", + "desktop.browser_access.copied": "Access information copied. Keep it private, like a password.", + "desktop.browser_access.copy_authorization": "Copy browser authorization URL\u2026", + "desktop.browser_access.copy_token": "Copy access token (sensitive)", + "desktop.browser_access.copy_url": "Copy access URL (sensitive)", + "desktop.browser_access.failed": "Could not prepare browser access. Check that this Desktop backend is running.", + "desktop.browser_access.menu": "Browser access", + "desktop.browser_access.open": "Open in browser\u2026", + "desktop.browser_access.opened": "Authorization link opened in the default browser.", "desktop.common.cancel": "Cancel", "desktop.common.ok": "OK", + "desktop.diagnostics.backend_mode": "Backend mode", + "desktop.diagnostics.backend_process": "Backend process", + "desktop.diagnostics.backend_url": "Backend URL", + "desktop.diagnostics.browser_storage": "Browser storage", + "desktop.diagnostics.copy_backend_url": "Copy backend URL", + "desktop.diagnostics.core_version": "Core version", + "desktop.diagnostics.crashed": "Crashed", + "desktop.diagnostics.desktop_version": "Desktop version", + "desktop.diagnostics.devtools_detail": "Console code can read page data and operate connected terminals. Run only code you trust. This opens a local frontend debugger, not a remote debugging port or Node.js bridge.", + "desktop.diagnostics.devtools_menu": "Developer Tools\u2026", + "desktop.diagnostics.devtools_message": "Open Developer Tools for this authenticated terminal UI?", + "desktop.diagnostics.devtools_open": "Open Developer Tools", + "desktop.diagnostics.devtools_title": "Developer Tools", + "desktop.diagnostics.engines": "Electron / Chromium / Node", + "desktop.diagnostics.events_heading": "Recent startup events", + "desktop.diagnostics.events_hint": "Up to 200 structured events from this run. No terminal content, private keys, tokens or external URLs. Find previous runs in the diagnostics log folder.", + "desktop.diagnostics.folder_failed": "Could not open the diagnostics folder.", + "desktop.diagnostics.handoff_heading": "Preview and browser handoff", + "desktop.diagnostics.handoff_hint": "Desktop blocks external embedded previews. Choose Open in browser in the preview and confirm the destination. Your default browser uses separate cookies and login. Desktop credentials are not added to the URL.", + "desktop.diagnostics.instance_id": "Instance ID", + "desktop.diagnostics.log_file": "Diagnostic log", + "desktop.diagnostics.log_unwritable": "Could not write the diagnostic log", + "desktop.diagnostics.log_writable": "Log write status", + "desktop.diagnostics.log_write_failed": "Write failed", + "desktop.diagnostics.log_write_ok": "No write error reported", + "desktop.diagnostics.logs_filtered": "Logs exclude credentials and terminal content", + "desktop.diagnostics.native_backend": "Native", + "desktop.diagnostics.no_events": "No events yet.", + "desktop.diagnostics.open_failed": "Could not open the diagnostics page.", + "desktop.diagnostics.open_logs": "Open diagnostics log folder", + "desktop.diagnostics.origin": "URL: {origin}", + "desktop.diagnostics.platform": "Platform", + "desktop.diagnostics.port_settings": "Port settings", + "desktop.diagnostics.profile_directory": "Profile directory", + "desktop.diagnostics.python_version": "Python version", + "desktop.diagnostics.read_only_hint": "Read-only snapshot. Use View > Refresh (Ctrl/Cmd+R) to update.", + "desktop.diagnostics.refresh": "Refresh", + "desktop.diagnostics.renderer_process": "Renderer process", + "desktop.diagnostics.running": "Running", + "desktop.diagnostics.runtime_heading": "Runtime and storage", + "desktop.diagnostics.security": "Security", + "desktop.diagnostics.security_detail": "Core sandbox on; Node/preload off; isolated Desktop toolbar; external preview network blocked", + "desktop.diagnostics.show_status": "Status and recent events\u2026", + "desktop.diagnostics.stopped": "Stopped", + "desktop.diagnostics.storage_persistent": "Persistent per origin; same as Core", + "desktop.diagnostics.storage_temporary": "Temporary test profile", + "desktop.diagnostics.title": "Desktop diagnostics", + "desktop.diagnostics.web_settings_persistent": "Web settings: saved per origin (same as Core)", + "desktop.diagnostics.web_settings_temporary": "Web settings: temporary test profile", + "desktop.diagnostics.window_title": "StandTerm Desktop - Diagnostics", + "desktop.external_browser.detail": "{url}\n\nThe browser uses its own login. StandTerm does not add its token or cookies.", + "desktop.external_browser.failed": "Could not open the default browser.", + "desktop.external_browser.failure_hint": "Check the default HTTP/HTTPS browser in your operating system settings.", + "desktop.external_browser.message": "Open {host} in your default browser?", + "desktop.external_browser.open": "Open in browser", + "desktop.external_browser.title": "Open in default browser", "desktop.language.choose": "Choose the Desktop language for the next launch.", "desktop.language.detail": "Saved choice: {language}\n\nCore has its own language setting. Some Desktop text remains in English.", "desktop.language.failed": "Could not confirm the language setting. Reopen Desktop language to check the saved choice.", @@ -55,6 +131,14 @@ "desktop.toolbar.screenshot_save": "Save screenshot (PNG)" }, "zh-TW": { + "desktop.about.backend": "\u5f8c\u7aef\uff1a{backend}", + "desktop.about.bundle_sha256": "Core \u5957\u4ef6 SHA-256", + "desktop.about.core_version": "Core \u7248\u672c\uff1a{version}", + "desktop.about.desktop_version": "StandTerm Desktop {version}", + "desktop.about.details": "Core \u7248\u672c\uff1a{core_version}\n{build_label}\uff1a{core_build}\n\u5f8c\u7aef\uff1a{backend}\nPython\uff1a{python_version}\nElectron\uff1a{electron_version}\nChromium\uff1a{chromium_version}\nNode.js\uff1a{node_version}\n\u5e73\u53f0\uff1a{platform} / {arch}\n\n\u8a55\u4f30\u7248\u672c\uff0c\u66f4\u65b0\u9700\u624b\u52d5\u5b89\u88dd\u3002", + "desktop.about.git_revision": "Core Git \u4fee\u8a02\u7248\u672c", + "desktop.about.unknown_version": "\u672a\u77e5\uff08\u820a\u7248 Core\uff09", + "desktop.about.unmanaged_source": "\u539f\u59cb\u78bc\u5de5\u4f5c\u76ee\u9304\uff0f\u7121\u53d7\u7ba1\u7406\u7684\u5957\u4ef6\u8b58\u5225\u8cc7\u8a0a", "desktop.agent.getting_started": "\u958b\u59cb\u4f7f\u7528\u2026", "desktop.agent.help_connection": "\u958b\u555f\u300cAgent \u9023\u7dda\u300d\uff08Agent connection\uff09\uff0c\u9078\u64c7\u300c\u8907\u88fd\u9023\u7dda\u6307\u5f15\u300d\uff08Copy Prompt\uff09\uff0c\u518d\u5c07\u6307\u5f15\u9023\u540c\u4efb\u52d9\u8cbc\u7d66 Agent\u3002\u8acb\u4f9d\u5c0d\u8a71\u6846\u986f\u793a\u7684\u74b0\u5883\u64cd\u4f5c\u3002", "desktop.agent.help_environment": "\u82e5 Agent \u5728 SSH \u9060\u7aef\u57f7\u884c\uff0c\u8acb\u5728\u5176 SSH \u5206\u9801\u555f\u52d5 Agent \u901a\u9053\uff08Agent Tunnel\uff09\u3002\u8a2d\u5b9a\u6210\u529f\u5f8c\u6703\u51fa\u73fe\u300cAgent \u9023\u7dda\u300d\uff08Agent connection\uff09\u3002\u82e5 Agent \u5728\u672c\u6a5f\u57f7\u884c\uff0c\u8acb\u5728\u6bcf\u500b\u9810\u5b9a\u64cd\u4f5c\u7684\u5206\u9801\u9078\u64c7\u300c\u6388\u6b0a Agent\u300d\uff08Authorize agent\uff09\u3002", @@ -62,8 +146,76 @@ "desktop.agent.help_skills": "\u4e0d\u5fc5\u5148\u5b89\u88dd\u6280\u80fd\uff08skills\uff09\u3002\u9023\u7dda\u6307\u5f15\u6703\u5f15\u5c0e Agent \u4f7f\u7528\u96a8\u9644\u7684\u6280\u80fd\u8207\u8f14\u52a9\u5de5\u5177\uff1b\u82e5\u5e0c\u671b\u5b89\u88dd\u5f8c\u6301\u7e8c\u4f7f\u7528\u6280\u80fd\uff0cAgent Info \u4e5f\u63d0\u4f9b\u5b89\u88dd\u8aaa\u660e\u3002", "desktop.agent.help_title": "\u8b93 Agent \u9023\u7dda\u81f3 StandTerm", "desktop.agent.menu_title": "Agent", + "desktop.browser_access.confirm_detail": "\u6388\u6b0a\u7db2\u5740\u542b\u6709\u654f\u611f\u7684\u5b58\u53d6\u8cc7\u8a0a\uff0c\u8acb\u50c5\u5728\u81ea\u5df1\u4fe1\u4efb\u7684\u700f\u89bd\u5668\u4e2d\u4f7f\u7528\u3002\u958b\u555f\u5f8c\u53ef\u80fd\u7559\u5b58\u5728\u700f\u89bd\u7d00\u9304\u4e2d\u3002\u65b0\u7684\u6388\u6b0a\u9023\u7d50\u6703\u53d6\u4ee3\u5148\u524d\u5c1a\u672a\u4f7f\u7528\u7684\u6388\u6b0a\u9023\u7d50\u3002", + "desktop.browser_access.confirm_message": "\u5141\u8a31\u5176\u4ed6\u700f\u89bd\u5668\u5b58\u53d6\u6b64 StandTerm \u57f7\u884c\u500b\u9ad4\u55ce\uff1f", + "desktop.browser_access.confirm_title": "\u6388\u6b0a\u5176\u4ed6\u700f\u89bd\u5668", + "desktop.browser_access.continue": "\u7e7c\u7e8c", + "desktop.browser_access.copied": "\u5df2\u8907\u88fd\u5b58\u53d6\u8cc7\u8a0a\u3002\u8acb\u50cf\u4fdd\u7ba1\u5bc6\u78bc\u4e00\u6a23\u59a5\u5584\u4fdd\u5bc6\u3002", + "desktop.browser_access.copy_authorization": "\u8907\u88fd\u700f\u89bd\u5668\u6388\u6b0a\u7db2\u5740\u2026", + "desktop.browser_access.copy_token": "\u8907\u88fd\u5b58\u53d6\u6b0a\u6756\uff08\u654f\u611f\u8cc7\u8a0a\uff09", + "desktop.browser_access.copy_url": "\u8907\u88fd\u5b58\u53d6\u7db2\u5740\uff08\u654f\u611f\u8cc7\u8a0a\uff09", + "desktop.browser_access.failed": "\u7121\u6cd5\u6e96\u5099\u700f\u89bd\u5668\u5b58\u53d6\u8cc7\u8a0a\u3002\u8acb\u78ba\u8a8d\u6b64 Desktop \u7684\u5f8c\u7aef\u4ecd\u5728\u57f7\u884c\u3002", + "desktop.browser_access.menu": "\u700f\u89bd\u5668\u5b58\u53d6", + "desktop.browser_access.open": "\u5728\u700f\u89bd\u5668\u958b\u555f\u2026", + "desktop.browser_access.opened": "\u5df2\u5728\u9810\u8a2d\u700f\u89bd\u5668\u958b\u555f\u6388\u6b0a\u9023\u7d50\u3002", "desktop.common.cancel": "\u53d6\u6d88", "desktop.common.ok": "\u78ba\u5b9a", + "desktop.diagnostics.backend_mode": "\u5f8c\u7aef\u6a21\u5f0f", + "desktop.diagnostics.backend_process": "\u5f8c\u7aef\u7a0b\u5e8f", + "desktop.diagnostics.backend_url": "\u5f8c\u7aef\u7db2\u5740", + "desktop.diagnostics.browser_storage": "\u700f\u89bd\u5668\u5132\u5b58", + "desktop.diagnostics.copy_backend_url": "\u8907\u88fd\u5f8c\u7aef\u7db2\u5740", + "desktop.diagnostics.core_version": "Core \u7248\u672c", + "desktop.diagnostics.crashed": "\u5df2\u7576\u6a5f", + "desktop.diagnostics.desktop_version": "Desktop \u7248\u672c", + "desktop.diagnostics.devtools_detail": "\u4e3b\u63a7\u53f0\u7a0b\u5f0f\u78bc\u53ef\u8b80\u53d6\u9801\u9762\u8cc7\u6599\u4e26\u64cd\u4f5c\u5df2\u9023\u7dda\u7684\u7d42\u7aef\u3002\u8acb\u53ea\u57f7\u884c\u53ef\u4fe1\u4efb\u7684\u7a0b\u5f0f\u78bc\u3002\u6b64\u64cd\u4f5c\u6703\u958b\u555f\u672c\u6a5f\u524d\u7aef\u5075\u932f\u5de5\u5177\uff0c\u4e0d\u6703\u958b\u555f\u9060\u7aef\u5075\u932f\u9023\u63a5\u57e0\u6216 Node.js \u6a4b\u63a5\u4ecb\u9762\u3002", + "desktop.diagnostics.devtools_menu": "\u958b\u767c\u4eba\u54e1\u5de5\u5177\u2026", + "desktop.diagnostics.devtools_message": "\u8981\u70ba\u6b64\u5df2\u901a\u904e\u8eab\u5206\u9a57\u8b49\u7684\u7d42\u7aef\u4ecb\u9762\u958b\u555f\u958b\u767c\u4eba\u54e1\u5de5\u5177\u55ce\uff1f", + "desktop.diagnostics.devtools_open": "\u958b\u555f\u958b\u767c\u4eba\u54e1\u5de5\u5177", + "desktop.diagnostics.devtools_title": "\u958b\u767c\u4eba\u54e1\u5de5\u5177", + "desktop.diagnostics.engines": "Electron / Chromium / Node", + "desktop.diagnostics.events_heading": "\u8fd1\u671f\u555f\u52d5\u4e8b\u4ef6", + "desktop.diagnostics.events_hint": "\u986f\u793a\u672c\u6b21\u57f7\u884c\u6700\u591a 200 \u7b46\u7d50\u69cb\u5316\u4e8b\u4ef6\uff0c\u4e0d\u542b\u7d42\u7aef\u5167\u5bb9\u3001\u79c1\u5bc6\u91d1\u9470\u3001\u6b0a\u6756\u6216\u5916\u90e8\u7db2\u5740\u3002\u5148\u524d\u57f7\u884c\u7684\u7d00\u9304\u53ef\u5728\u8a3a\u65b7\u7d00\u9304\u8cc7\u6599\u593e\u4e2d\u67e5\u770b\u3002", + "desktop.diagnostics.folder_failed": "\u7121\u6cd5\u958b\u555f\u8a3a\u65b7\u8cc7\u6599\u593e\u3002", + "desktop.diagnostics.handoff_heading": "\u9810\u89bd\u8207\u5916\u90e8\u700f\u89bd\u5668", + "desktop.diagnostics.handoff_hint": "Desktop \u6703\u5c01\u9396\u5916\u90e8\u7db2\u9801\u7684\u5167\u5d4c\u9810\u89bd\u3002\u8acb\u5728\u9810\u89bd\u4e2d\u9078\u64c7\u300c\u5728\u700f\u89bd\u5668\u958b\u555f\u300d\uff08Open in browser\uff09\u4e26\u78ba\u8a8d\u76ee\u7684\u5730\u3002\u9810\u8a2d\u700f\u89bd\u5668\u4f7f\u7528\u7368\u7acb\u7684 Cookie \u8207\u767b\u5165\u72c0\u614b\uff0c\u7db2\u5740\u4e0d\u6703\u52a0\u5165 Desktop \u6191\u8b49\u3002", + "desktop.diagnostics.instance_id": "\u57f7\u884c\u500b\u9ad4 ID", + "desktop.diagnostics.log_file": "\u8a3a\u65b7\u7d00\u9304", + "desktop.diagnostics.log_unwritable": "\u7121\u6cd5\u5beb\u5165\u8a3a\u65b7\u7d00\u9304", + "desktop.diagnostics.log_writable": "\u8a18\u9304\u5beb\u5165\u72c0\u614b", + "desktop.diagnostics.log_write_failed": "\u5beb\u5165\u5931\u6557", + "desktop.diagnostics.log_write_ok": "\u672a\u56de\u5831\u5beb\u5165\u932f\u8aa4", + "desktop.diagnostics.logs_filtered": "\u7d00\u9304\u4e0d\u542b\u6191\u8b49\u8207\u7d42\u7aef\u5167\u5bb9", + "desktop.diagnostics.native_backend": "\u539f\u751f", + "desktop.diagnostics.no_events": "\u76ee\u524d\u6c92\u6709\u4e8b\u4ef6\u3002", + "desktop.diagnostics.open_failed": "\u7121\u6cd5\u958b\u555f\u8a3a\u65b7\u9801\u9762\u3002", + "desktop.diagnostics.open_logs": "\u958b\u555f\u8a3a\u65b7\u7d00\u9304\u8cc7\u6599\u593e", + "desktop.diagnostics.origin": "\u7db2\u5740\uff1a{origin}", + "desktop.diagnostics.platform": "\u5e73\u53f0", + "desktop.diagnostics.port_settings": "\u9023\u63a5\u57e0\u8a2d\u5b9a", + "desktop.diagnostics.profile_directory": "\u8a2d\u5b9a\u6a94\u76ee\u9304", + "desktop.diagnostics.python_version": "Python \u7248\u672c", + "desktop.diagnostics.read_only_hint": "\u552f\u8b80\u5feb\u7167\u3002\u8acb\u9078\u64c7\u300c\u6aa2\u8996 > \u91cd\u65b0\u6574\u7406\u300d\uff08Ctrl/Cmd+R\uff09\u53d6\u5f97\u6700\u65b0\u8cc7\u6599\u3002", + "desktop.diagnostics.refresh": "\u91cd\u65b0\u6574\u7406", + "desktop.diagnostics.renderer_process": "\u6e32\u67d3\u7a0b\u5e8f", + "desktop.diagnostics.running": "\u57f7\u884c\u4e2d", + "desktop.diagnostics.runtime_heading": "\u57f7\u884c\u74b0\u5883\u8207\u5132\u5b58", + "desktop.diagnostics.security": "\u5b89\u5168\u6027", + "desktop.diagnostics.security_detail": "Core \u6c99\u7bb1\u5df2\u555f\u7528\uff1bNode\uff0fpreload \u5df2\u505c\u7528\uff1bDesktop \u5de5\u5177\u5217\u7368\u7acb\u9694\u96e2\uff1b\u5df2\u5c01\u9396\u5916\u90e8\u9810\u89bd\u7684\u7db2\u8def\u5b58\u53d6", + "desktop.diagnostics.show_status": "\u72c0\u614b\u8207\u8fd1\u671f\u4e8b\u4ef6\u2026", + "desktop.diagnostics.stopped": "\u5df2\u505c\u6b62", + "desktop.diagnostics.storage_persistent": "\u4f9d\u4f86\u6e90\u6301\u7e8c\u4fdd\u7559\uff1b\u8207 Core \u76f8\u540c", + "desktop.diagnostics.storage_temporary": "\u66ab\u5b58\u6e2c\u8a66\u8a2d\u5b9a\u6a94", + "desktop.diagnostics.title": "Desktop \u8a3a\u65b7", + "desktop.diagnostics.web_settings_persistent": "\u7db2\u9801\u8a2d\u5b9a\uff1a\u4f9d\u4f86\u6e90\u5132\u5b58\uff08\u8207 Core \u76f8\u540c\uff09", + "desktop.diagnostics.web_settings_temporary": "\u7db2\u9801\u8a2d\u5b9a\uff1a\u66ab\u5b58\u6e2c\u8a66\u8a2d\u5b9a\u6a94", + "desktop.diagnostics.window_title": "StandTerm Desktop - \u8a3a\u65b7", + "desktop.external_browser.detail": "{url}\n\n\u700f\u89bd\u5668\u4f7f\u7528\u81ea\u5df1\u7684\u767b\u5165\u72c0\u614b\u3002StandTerm \u4e0d\u6703\u52a0\u5165\u81ea\u5df1\u7684\u6b0a\u6756\u6216 Cookie\u3002", + "desktop.external_browser.failed": "\u7121\u6cd5\u958b\u555f\u9810\u8a2d\u700f\u89bd\u5668\u3002", + "desktop.external_browser.failure_hint": "\u8acb\u5728\u4f5c\u696d\u7cfb\u7d71\u8a2d\u5b9a\u4e2d\u6aa2\u67e5 HTTP\uff0fHTTPS \u7684\u9810\u8a2d\u700f\u89bd\u5668\u3002", + "desktop.external_browser.message": "\u8981\u5728\u9810\u8a2d\u700f\u89bd\u5668\u958b\u555f {host} \u55ce\uff1f", + "desktop.external_browser.open": "\u5728\u700f\u89bd\u5668\u958b\u555f", + "desktop.external_browser.title": "\u5728\u9810\u8a2d\u700f\u89bd\u5668\u958b\u555f", "desktop.language.choose": "\u9078\u64c7\u4e0b\u6b21\u555f\u52d5\u6642\u4f7f\u7528\u7684 Desktop \u8a9e\u7cfb\u3002", "desktop.language.detail": "\u5df2\u5132\u5b58\u7684\u9078\u64c7\uff1a{language}\n\nCore \u7684\u8a9e\u7cfb\u9700\u53e6\u5916\u8a2d\u5b9a\u3002\u90e8\u5206 Desktop \u6587\u5b57\u4ecd\u4f7f\u7528\u82f1\u6587\u3002", "desktop.language.failed": "\u7121\u6cd5\u78ba\u8a8d\u8a9e\u7cfb\u8a2d\u5b9a\u3002\u8acb\u91cd\u65b0\u958b\u555f\u300cDesktop \u8a9e\u7cfb\u300d\u67e5\u770b\u5df2\u5132\u5b58\u7684\u9078\u64c7\u3002", diff --git a/desktop/test/browser-access.test.cjs b/desktop/test/browser-access.test.cjs index ea8f7c9..66ef392 100644 --- a/desktop/test/browser-access.test.cjs +++ b/desktop/test/browser-access.test.cjs @@ -3,20 +3,23 @@ const test = require('node:test'); const assert = require('node:assert/strict'); const { createBrowserAccess, validateAccessUrl } = require('../browser-access.cjs'); +const { create } = require('../i18n.js'); const origin = 'http://127.0.0.1:64487'; -function fixture({ consent = true, badUrl = false } = {}) { - const requests = [], copied = [], opened = [], notices = []; +function fixture({ consent = true, badUrl = false, locale = 'en', failure = false } = {}) { + const requests = [], copied = [], opened = [], notices = [], confirmations = [], errors = []; const access = createBrowserAccess({ origin, launcherToken: 'launcher-test', available: () => true, - confirm: async () => consent, copy: value => copied.push(value), open: async value => opened.push(value), - notify: async value => notices.push(value), session: { fetch: async (url, options) => { + t: create(locale).t, + confirm: async action => { confirmations.push(action); return consent; }, copy: value => copied.push(value), open: async value => opened.push(value), + notify: async (value, error = false) => { notices.push(value); errors.push(error); }, session: { fetch: async (url, options) => { requests.push({ url, options }); + if (failure) throw new Error(`${origin}/?token=access-test&authorize=grant-test`); return new Response(JSON.stringify(url.endsWith('/access-url') ? { status: 'ok', access_url: badUrl ? 'https://example.com/?token=access-test' : `${origin}/?token=access-test` } : { status: 'ok', authorization_url: `${origin}/?token=access-test&authorize=grant-test` })); } }, }); - return { access, requests, copied, opened, notices }; + return { access, requests, copied, opened, notices, confirmations, errors }; } test('access token is copied on demand, never the Desktop session cookie', async () => { @@ -30,6 +33,56 @@ test('access token is copied on demand, never the Desktop session cookie', async assert.ok(!f.notices.join().includes('access-test')); }); +test('translated browser access menus preserve all four fixed actions and sensitive payloads', async () => { + for (const locale of ['en', 'zh-TW']) { + const { t } = create(locale); + for (const [index, action, key] of [[0, 'open', 'open'], [1, 'copy-auth', 'copy_authorization'], + [3, 'copy-url', 'copy_url'], [4, 'copy-token', 'copy_token']]) { + const f = fixture({ locale }); + assert.equal(f.access.menu.label, t('desktop.browser_access.menu')); + assert.equal(f.access.menu.submenu[index].label, t(`desktop.browser_access.${key}`)); + assert.equal(await f.access.menu.submenu[index].click(), true); + const authorization = ['open', 'copy-auth'].includes(action); + assert.deepEqual(f.confirmations, authorization ? [action] : []); + assert.deepEqual(f.requests.map(request => request.url), authorization + ? [`${origin}/access-url`, `${origin}/launcher/browser_authorization_url`] : [`${origin}/access-url`]); + if (authorization) { + assert.equal(f.requests[1].options.method, 'POST'); + assert.equal(new URLSearchParams(f.requests[1].options.body).get('access_url'), `${origin}/?token=access-test`); + } + const payload = action === 'copy-token' ? 'access-test' + : `${origin}/?token=access-test${authorization ? '&authorize=grant-test' : ''}`; + assert.deepEqual(f.opened, action === 'open' ? [payload] : []); + assert.deepEqual(f.copied, action === 'open' ? [] : [payload]); + assert.deepEqual(f.notices, [t(action === 'open' ? 'desktop.browser_access.opened' : 'desktop.browser_access.copied')]); + assert.deepEqual(f.errors, [false]); + assert.ok(!f.notices.join().includes('access-test')); + assert.ok(!f.notices.join().includes('grant-test')); + assert.equal(await f.access.run(f.access.menu.submenu[index].label), false); + assert.equal(f.requests.length, authorization ? 2 : 1); + } + } +}); + +test('both languages retain zero-request cancellation and sanitized errors without retry', async () => { + for (const locale of ['en', 'zh-TW']) { + for (const action of ['open', 'copy-auth']) { + const canceled = fixture({ consent: false, locale }); + assert.equal(await canceled.access.run(action), false); + assert.deepEqual(canceled.requests, []); + assert.deepEqual(canceled.notices, []); + } + const failed = fixture({ locale, failure: true }); + assert.equal(await failed.access.run('open'), false); + assert.equal(failed.requests.length, 1); + assert.deepEqual(failed.copied, []); + assert.deepEqual(failed.opened, []); + assert.deepEqual(failed.notices, [create(locale).t('desktop.browser_access.failed')]); + assert.deepEqual(failed.errors, [true]); + for (const privateValue of [origin, 'access-test', 'grant-test']) assert.ok(!failed.notices.join().includes(privateValue)); + } +}); + test('new browser authorization requires consent and stays bound to the backend origin', async () => { const f = fixture(); assert.equal(await f.access.run('open'), true); diff --git a/desktop/test/diagnostics-i18n-browser-smoke.py b/desktop/test/diagnostics-i18n-browser-smoke.py new file mode 100644 index 0000000..9d7af77 --- /dev/null +++ b/desktop/test/diagnostics-i18n-browser-smoke.py @@ -0,0 +1,74 @@ +"""Render the actual scriptless diagnostics HTML in both Desktop languages.""" + +import argparse +import json +import os +from pathlib import Path +import subprocess +from urllib.parse import quote + + +ROOT = Path(__file__).resolve().parents[2] +os.environ.setdefault('PLAYWRIGHT_BROWSERS_PATH', str(ROOT / 'tools' / '.ms-playwright')) + +from playwright.sync_api import sync_playwright + + +SNAPSHOTS = r""" +const { statusHtml } = require('./desktop/diagnostics-window.cjs'); +const { create } = require('./desktop/i18n.js'); +const raw = '/tmp/&{path}'; +const events = [{ event: 'backend_ready', mode: 'wsl', code: 'ECONNREFUSED', + literal: '&' }]; +const snapshots = ['en', 'zh-TW'].map(locale => { + const i18n = create(locale); + return { locale, raw, eventText: events.map(event => JSON.stringify(event)).join('\n'), + title: i18n.t('desktop.diagnostics.title'), + windowTitle: i18n.t('desktop.diagnostics.window_title'), + label: i18n.t('desktop.diagnostics.profile_directory'), + emptyText: i18n.t('desktop.diagnostics.no_events'), + html: statusHtml([[i18n.t('desktop.diagnostics.profile_directory'), raw]], events, i18n), + emptyHtml: statusHtml([], [], i18n) }; +}); +console.log(JSON.stringify(snapshots)); +""" + + +def run(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--node', default='node', help='Node 22.12+ or an Electron executable in Node mode') + args = parser.parse_args() + result = subprocess.run([args.node, '-e', SNAPSHOTS], cwd=ROOT, + env={**os.environ, 'ELECTRON_RUN_AS_NODE': '1'}, + capture_output=True, text=True, check=True, timeout=30) + snapshots = json.loads(result.stdout) + with sync_playwright() as playwright: + browser = playwright.chromium.launch(headless=True) + try: + for snapshot in snapshots: + page = browser.new_page(viewport={'width': 640, 'height': 720}) + requests = [] + page.on('request', lambda request: requests.append(request.url)) + page.goto('data:text/html,' + quote(snapshot['html'], safe='')) + assert page.locator('html').get_attribute('lang') == snapshot['locale'] + assert page.title() == snapshot['windowTitle'] + assert page.locator('h1').text_content() == snapshot['title'] + assert page.locator('th').text_content() == snapshot['label'] + assert page.locator('td').text_content() == snapshot['raw'] + assert page.locator('pre').text_content() == snapshot['eventText'] + assert page.locator('script, img, iframe, a').count() == 0 + assert page.evaluate('typeof window.injected') == 'undefined' + csp = page.locator('meta[http-equiv="Content-Security-Policy"]').get_attribute('content') + assert "default-src 'none'" in csp + assert page.evaluate('document.documentElement.scrollWidth <= innerWidth') + page.goto('data:text/html,' + quote(snapshot['emptyHtml'], safe='')) + assert page.locator('pre').text_content() == snapshot['emptyText'] + assert not any(url.startswith(('http:', 'https:', 'file:')) for url in requests), requests + page.close() + print(json.dumps({'locale': snapshot['locale'], 'diagnostics_dom': 'passed', 'events': 'literal'})) + finally: + browser.close() + + +if __name__ == '__main__': + run() diff --git a/desktop/test/diagnostics.test.cjs b/desktop/test/diagnostics.test.cjs index a105178..bacefe2 100644 --- a/desktop/test/diagnostics.test.cjs +++ b/desktop/test/diagnostics.test.cjs @@ -4,6 +4,9 @@ const assert = require('node:assert/strict'); const fs = require('node:fs'); const os = require('node:os'); const path = require('node:path'); +const vm = require('node:vm'); +const { EventEmitter } = require('node:events'); +const { create } = require('../i18n.js'); const { createDiagnostics, diagnosticsMenu, agentConnectionInfo, openDeveloperTools, MAX_LOG_BYTES } = require('../diagnostics.cjs'); const { statusHtml } = require('../diagnostics-window.cjs'); @@ -32,6 +35,109 @@ test('diagnostics persist only bounded structured metadata, never payloads or cr assert.equal(Object.hasOwn(exited, 'port'), false); }); +test('both diagnostic languages preserve menu IDs, callbacks and literal backend URLs', t => { + const { logger } = fixture(t); + for (const locale of ['en', 'zh-TW']) { + const { t: translate } = create(locale), calls = [], copied = []; + const menu = diagnosticsMenu({ t: translate, origin: 'http://127.0.0.1:64487', mode: 'wsl', + instanceId: 'test-instance', version: '0.5.2', logger, persistent: true, + openLogs: () => calls.push('logs'), openTools: () => calls.push('tools'), openStatus: () => calls.push('status'), + copyText: value => copied.push(value) }); + assert.equal(menu.id, 'diagnostics'); + assert.equal(menu.label, translate('desktop.toolbar.menu_diagnostics')); + const item = id => menu.submenu.find(item => item.id === id); + item('diagnostics-status').click(); item('diagnostics-logs').click(); item('diagnostics-devtools').click(); + item('diagnostics-copy-origin').click(); + assert.deepEqual(calls, ['status', 'logs', 'tools']); + assert.deepEqual(copied, ['http://127.0.0.1:64487']); + assert.equal(item('diagnostics-origin').label, translate('desktop.diagnostics.origin', { origin: copied[0] })); + assert.equal(item('diagnostics-core-version').label, translate('desktop.about.core_version', { version: translate('desktop.about.unknown_version') })); + assert.ok(menu.submenu.some(item => item.label === translate('desktop.diagnostics.web_settings_persistent'))); + } +}); + +test('diagnostic translations and raw events stay escaped in a scriptless snapshot', () => { + const event = { event: 'backend_ready', mode: 'wsl', code: 'ECONNREFUSED', value: '&' }; + for (const locale of ['en', 'zh-TW']) { + const i18n = create(locale); + const html = statusHtml([['Literal label', '/tmp/&{value}']], [event], i18n); + assert.ok(html.includes(``)); + assert.ok(html.includes(i18n.t('desktop.diagnostics.title'))); + assert.ok(html.includes('/tmp/<profile>&{value}')); + assert.ok(html.includes('"event":"backend_ready"')); + assert.ok(html.includes('ECONNREFUSED')); + assert.ok(!html.includes('')); + assert.ok(!injected.includes(' { + for (const locale of ['en', 'zh-TW']) { + const owner = new EventEmitter(); owner.isDestroyed = () => false; + let requestFilter, permission, device, snapshotCalls = 0, copied = 0; + const windows = []; + class Window extends EventEmitter { + constructor(options) { + super(); this.options = options; this.urls = []; this.destroyed = false; + this.webContents = new EventEmitter(); + this.webContents.setWindowOpenHandler = handler => { this.openHandler = handler; }; + windows.push(this); + } + isDestroyed() { return this.destroyed; } + destroy() { this.destroyed = true; this.emit('closed'); } + setMenu(menu) { this.menu = menu; } + async loadURL(url) { this.urls.push(url); } + show() {} + focus() {} + } + const isolated = { + setPermissionRequestHandler: handler => { permission = handler; }, + setPermissionCheckHandler: handler => { isolated.permissionCheck = handler; }, + setDevicePermissionHandler: handler => { device = handler; }, + webRequest: { onBeforeRequest: handler => { requestFilter = handler; } }, + }; + const electron = { BrowserWindow: Window, Menu: { buildFromTemplate: template => template }, + session: { fromPartition: (_partition, options) => { assert.equal(options.cache, false); return isolated; } } }; + const api = { exports: {} }; + vm.runInNewContext(fs.readFileSync(path.join(__dirname, '..', 'diagnostics-window.cjs'), 'utf8'), { + require: name => name === 'electron' ? electron : require(name.startsWith('.') ? path.join(__dirname, '..', name) : name), + module: api, + }); + const i18n = create(locale); + const show = api.exports.createStatusWindow(owner, () => ({ rows: [['Snapshot', ++snapshotCalls]], events: [] }), + { copyUrl: () => copied++, i18n }); + const win = await show(); + assert.equal(win.options.title, i18n.t('desktop.diagnostics.window_title')); + assert.equal(win.options.webPreferences.sandbox, true); + assert.equal(win.options.webPreferences.nodeIntegration, false); + assert.equal(win.options.webPreferences.preload, undefined); + assert.equal(win.menu[0].label, i18n.t('desktop.toolbar.menu_view')); + const [refresh, copy] = win.menu[0].submenu; + assert.equal(refresh.label, i18n.t('desktop.diagnostics.refresh')); + assert.equal(refresh.accelerator, 'CommandOrControl+R'); + await refresh.click(); copy.click(); + assert.equal(snapshotCalls, 2); + assert.equal(copied, 1); + assert.equal(await show(), win); + assert.equal(windows.length, 1); + assert.ok(decodeURIComponent(win.urls.at(-1)).includes(``)); + assert.equal(win.openHandler().action, 'deny'); + for (const [url, blocked] of [[win.urls[0], false], ['https://example.com/', true], ['file:///private.txt', true]]) { + requestFilter({ url }, result => assert.equal(result.cancel, blocked)); + } + permission(null, 'media', allowed => assert.equal(allowed, false)); + assert.equal(isolated.permissionCheck(), false); + assert.equal(device(), false); + owner.emit('closed'); + assert.equal(win.isDestroyed(), true); + } +}); + test('diagnostic rotation is bounded and write failure does not block startup', t => { const { directory, logger } = fixture(t); fs.writeFileSync(logger.file, 'x'.repeat(MAX_LOG_BYTES)); diff --git a/docs/desktop_ui_copy_review.tsv b/docs/desktop_ui_copy_review.tsv index 60b3885..8eb31cb 100644 --- a/docs/desktop_ui_copy_review.tsv +++ b/docs/desktop_ui_copy_review.tsv @@ -41,9 +41,9 @@ desktop.core_source.recovery_retention Core files and data in the previous envir desktop.core_source.git_policy Git uses the official askac/standterm repository, main branch. Its files are not checked against the installed bundle hashes. Local changes are allowed, but updates refuse to overwrite them. The Desktop shell stays installed. Git Core uses askac/standterm on the main branch. Its files are not verified against the installed bundle hashes. Updates refuse to overwrite local changes. The Desktop shell stays installed. Git-source confirmation detail; removes marketing qualifier while keeping source provenance and integrity boundary. Keep askac/standterm and main literal. Preserve refusal on local changes and no reset/stash behavior. No automatic update, retry, or implied equivalence to the verified installed bundle. proposed desktop/core-source.cjs:coreController.confirm:62-63 desktop.core_source.reauthorization Authorization and recovery data are local to each Core source; switching may require reauthorization. Each Core source has separate authorization and recovery data. Switching may require reauthorization. Common source-change confirmation suffix; switching may change which credentials or recovery registrations are available. Do not promise migration, reuse, deletion, or automatic authorization. Preserve sourceStore identity/source/pending fields and backend credential boundaries. proposed desktop/core-source.cjs:coreController.confirm:64 desktop.core_source.failure_choices The Desktop shell can retry or restore its installed Core. Existing files are retained. Choose Retry to restart StandTerm and try again, or restore bundled Core. Existing files are kept. Core unavailable dialog detail. Retry invokes restart(lastAction); displaying this dialog does not itself retry. Keep response indices 0 Quit, 1 Retry, 2 Restore bundled Core, 3 Core source, 4 Open logs. defaultId=0/cancelId=0. Preserve fixed action IDs and lastAction replay; no automatic retry. proposed desktop/core-source.cjs:coreController.failure:111-124 -desktop.browser_access.opened Browser authorization opened in the default browser. Authorization link opened in the default browser. Success notice after awaiting OS open callback; browser authorization has not been observed as completed. Keep action open distinct from copy-auth/copy-url/copy-token; retain existing confirmation. Do not claim the browser is authorized or reveal the URL/token in the notice. proposed desktop/browser-access.cjs:createBrowserAccess.run:46-62 -desktop.browser_access.copied Access information copied. Treat it as a password. Access information copied. Keep it private, like a password. Shared notice for copied authorization URL, access URL, or access token; the copied payload is sensitive. Keep exact clipboard payload and fixed action IDs; do not insert secrets into catalog parameters, logs or notifications. No extra mint, reveal, or retry. proposed desktop/browser-access.cjs:createBrowserAccess.run:58-62 -desktop.browser_access.failed Could not prepare browser access. Check that this Desktop backend is still running. Could not prepare browser access. Check that this Desktop backend is running. Sanitized generic failure notice; implementation deliberately discards URL-bearing network errors. Preserve generic failure and error=true notification. Never display raw caught errors, URLs, grants or tokens. Keep pending/available guards and no automatic retry. proposed desktop/browser-access.cjs:createBrowserAccess.run catch:64-67 +desktop.browser_access.opened Browser authorization opened in the default browser. Authorization link opened in the default browser. 已在預設瀏覽器開啟授權連結。 Success notice after awaiting OS open callback; browser authorization has not been observed as completed. Keep action open distinct from copy-auth/copy-url/copy-token; retain existing confirmation. Do not claim the browser is authorized or reveal the URL/token in the notice. translation-reviewed desktop/browser-access.cjs:createBrowserAccess.run:46-62 +desktop.browser_access.copied Access information copied. Treat it as a password. Access information copied. Keep it private, like a password. 已複製存取資訊。請像保管密碼一樣妥善保密。 Shared notice for copied authorization URL, access URL, or access token; the copied payload is sensitive. Keep exact clipboard payload and fixed action IDs; do not insert secrets into catalog parameters, logs or notifications. No extra mint, reveal, or retry. translation-reviewed desktop/browser-access.cjs:createBrowserAccess.run:58-62 +desktop.browser_access.failed Could not prepare browser access. Check that this Desktop backend is still running. Could not prepare browser access. Check that this Desktop backend is running. 無法準備瀏覽器存取資訊。請確認此 Desktop 的後端仍在執行。 Sanitized generic failure notice; implementation deliberately discards URL-bearing network errors. Preserve generic failure and error=true notification. Never display raw caught errors, URLs, grants or tokens. Keep pending/available guards and no automatic retry. translation-reviewed desktop/browser-access.cjs:createBrowserAccess.run catch:64-67 desktop.toolbar.menu_standterm StandTerm StandTerm StandTerm Visible toolbar menu caption for data-menu=standterm. Translate textContent only. Preserve data-menu, menu IDs, IPC routing and the macOS visibility rule. translation-reviewed desktop/toolbar.html:12 desktop.toolbar.menu_edit Edit Edit 編輯 Visible toolbar menu caption for data-menu=edit. Translate textContent only. Preserve data-menu, menu IDs, IPC routing and the macOS visibility rule. translation-reviewed desktop/toolbar.html:12 desktop.toolbar.menu_agent Agent Agent Agent Visible toolbar menu caption for data-menu=agent-menu. Translate textContent only. Preserve data-menu, menu IDs, IPC routing and the macOS visibility rule. translation-reviewed desktop/toolbar.html:12 @@ -69,3 +69,76 @@ desktop.menu.quit Quit StandTerm Quit StandTerm 結束 StandTerm Custom main/tra desktop.menu.about About StandTerm Desktop About StandTerm Desktop 關於 StandTerm Desktop Custom main/tray menu label; destination dialog may remain English during the pilot. Translate display label only. Keep callbacks, accelerators, native roles and action IDs unchanged. translation-reviewed desktop/main.cjs:createTray/start desktop.menu.core_source Core source (Advanced)... Core source (Advanced)... Core 來源(進階)… Custom main/tray menu label; destination dialog may remain English during the pilot. Translate display label only. Keep callbacks, accelerators, native roles and action IDs unchanged. translation-reviewed desktop/main.cjs:createTray/start desktop.menu.capture_settings Capture Settings... Capture Settings... 畫面擷取設定… Custom main/tray menu label; destination dialog may remain English during the pilot. Translate display label only. Keep callbacks, accelerators, native roles and action IDs unchanged. translation-reviewed desktop/main.cjs:createTray/start +desktop.browser_access.menu Browser Access Browser access 瀏覽器存取 Native Browser Access submenu. Display text only. Preserve typed action IDs, response indices, origin checks, permissions and focus rules. Escape every translated string and runtime value when rendering scriptless HTML. Never put token, grant or URL values into a notice or translated label. Preserve exact clipboard payloads and action-specific confirmation rules. translation-reviewed desktop/browser-access.cjs:createBrowserAccess.menu +desktop.browser_access.open Open in browser... Open in browser… 在瀏覽器開啟… Action open; requires existing authorization confirmation. Display text only. Preserve typed action IDs, response indices, origin checks, permissions and focus rules. Escape every translated string and runtime value when rendering scriptless HTML. Never put token, grant or URL values into a notice or translated label. Preserve exact clipboard payloads and action-specific confirmation rules. translation-reviewed desktop/browser-access.cjs:createBrowserAccess.menu +desktop.browser_access.copy_authorization Copy browser authorization URL... Copy browser authorization URL… 複製瀏覽器授權網址… Action copy-auth; requests a new authorization link after confirmation. Display text only. Preserve typed action IDs, response indices, origin checks, permissions and focus rules. Escape every translated string and runtime value when rendering scriptless HTML. Never put token, grant or URL values into a notice or translated label. Preserve exact clipboard payloads and action-specific confirmation rules. translation-reviewed desktop/browser-access.cjs:createBrowserAccess.menu +desktop.browser_access.copy_url Copy access URL (sensitive) Copy access URL (sensitive) 複製存取網址(敏感資訊) Action copy-url; copies the current token-bearing access URL. Display text only. Preserve typed action IDs, response indices, origin checks, permissions and focus rules. Escape every translated string and runtime value when rendering scriptless HTML. Never put token, grant or URL values into a notice or translated label. Preserve exact clipboard payloads and action-specific confirmation rules. translation-reviewed desktop/browser-access.cjs:createBrowserAccess.menu +desktop.browser_access.copy_token Copy access token (sensitive) Copy access token (sensitive) 複製存取權杖(敏感資訊) Action copy-token; copies only the current access token. Display text only. Preserve typed action IDs, response indices, origin checks, permissions and focus rules. Escape every translated string and runtime value when rendering scriptless HTML. Never put token, grant or URL values into a notice or translated label. Preserve exact clipboard payloads and action-specific confirmation rules. translation-reviewed desktop/browser-access.cjs:createBrowserAccess.menu +desktop.browser_access.confirm_title Authorize another browser Authorize another browser 授權其他瀏覽器 Browser Access confirmation before opening or copying a browser authorization link. Display text only. Preserve typed action IDs, response indices, origin checks, permissions and focus rules. Escape every translated string and runtime value when rendering scriptless HTML. Keep Cancel at response/defaultId/cancelId 0 and Continue at response 1. Preserve replacement of the previous unused grant; do not claim current browser authorization or existing sessions are revoked. translation-reviewed desktop/main.cjs:createBrowserAccess.confirm +desktop.browser_access.confirm_message Allow another browser to access this StandTerm instance? Allow another browser to access this StandTerm instance? 允許其他瀏覽器存取此 StandTerm 執行個體嗎? Browser Access confirmation before opening or copying a browser authorization link. Display text only. Preserve typed action IDs, response indices, origin checks, permissions and focus rules. Escape every translated string and runtime value when rendering scriptless HTML. Keep Cancel at response/defaultId/cancelId 0 and Continue at response 1. Preserve replacement of the previous unused grant; do not claim current browser authorization or existing sessions are revoked. translation-reviewed desktop/main.cjs:createBrowserAccess.confirm +desktop.browser_access.confirm_detail The authorization URL contains sensitive access information. Share it only with your own trusted browser. Opening it may store it in browser history. A new authorization link replaces the previous unused link. The authorization URL contains sensitive access information. Use it only in your own trusted browser. Opening it may save it in browser history. A new link replaces the previous unused authorization link. 授權網址含有敏感的存取資訊,請僅在自己信任的瀏覽器中使用。開啟後可能留存在瀏覽紀錄中。新的授權連結會取代先前尚未使用的授權連結。 Browser Access confirmation before opening or copying a browser authorization link. Display text only. Preserve typed action IDs, response indices, origin checks, permissions and focus rules. Escape every translated string and runtime value when rendering scriptless HTML. Keep Cancel at response/defaultId/cancelId 0 and Continue at response 1. Preserve replacement of the previous unused grant; do not claim current browser authorization or existing sessions are revoked. translation-reviewed desktop/main.cjs:createBrowserAccess.confirm +desktop.browser_access.continue Continue Continue 繼續 Browser Access confirmation before opening or copying a browser authorization link. Display text only. Preserve typed action IDs, response indices, origin checks, permissions and focus rules. Escape every translated string and runtime value when rendering scriptless HTML. Keep Cancel at response/defaultId/cancelId 0 and Continue at response 1. Preserve replacement of the previous unused grant; do not claim current browser authorization or existing sessions are revoked. translation-reviewed desktop/main.cjs:createBrowserAccess.confirm +desktop.about.desktop_version StandTerm Desktop {version} StandTerm Desktop {version} StandTerm Desktop {version} About/runtime text also reusable by the native diagnostics menu. {version} Display text only. Preserve typed action IDs, response indices, origin checks, permissions and focus rules. Escape every translated string and runtime value when rendering scriptless HTML. Keep version strings, Git revisions, SHA-256 values, WSL/Native menu values and MODES runtime labels literal. Translate only an explicit missing-value fallback. translation-reviewed desktop/main.cjs:coreVersion/coreBuild/buildLabel/aboutDetails; desktop/diagnostics.cjs:diagnosticsMenu +desktop.about.core_version Core version: {version} Core version: {version} Core 版本:{version} About/runtime text also reusable by the native diagnostics menu. {version} Display text only. Preserve typed action IDs, response indices, origin checks, permissions and focus rules. Escape every translated string and runtime value when rendering scriptless HTML. Keep version strings, Git revisions, SHA-256 values, WSL/Native menu values and MODES runtime labels literal. Translate only an explicit missing-value fallback. translation-reviewed desktop/main.cjs:coreVersion/coreBuild/buildLabel/aboutDetails; desktop/diagnostics.cjs:diagnosticsMenu +desktop.about.backend Backend: {backend} Backend: {backend} 後端:{backend} About/runtime text also reusable by the native diagnostics menu. {backend} Display text only. Preserve typed action IDs, response indices, origin checks, permissions and focus rules. Escape every translated string and runtime value when rendering scriptless HTML. Keep version strings, Git revisions, SHA-256 values, WSL/Native menu values and MODES runtime labels literal. Translate only an explicit missing-value fallback. translation-reviewed desktop/main.cjs:coreVersion/coreBuild/buildLabel/aboutDetails; desktop/diagnostics.cjs:diagnosticsMenu +desktop.about.unknown_version Unknown (older Core) Unknown (older Core) 未知(舊版 Core) About/runtime text also reusable by the native diagnostics menu. Display text only. Preserve typed action IDs, response indices, origin checks, permissions and focus rules. Escape every translated string and runtime value when rendering scriptless HTML. Keep version strings, Git revisions, SHA-256 values, WSL/Native menu values and MODES runtime labels literal. Translate only an explicit missing-value fallback. translation-reviewed desktop/main.cjs:coreVersion/coreBuild/buildLabel/aboutDetails; desktop/diagnostics.cjs:diagnosticsMenu +desktop.about.unmanaged_source Source checkout / no managed bundle identity Source checkout / no managed bundle identity 原始碼工作目錄/無受管理的套件識別資訊 About/runtime text also reusable by the native diagnostics menu. Display text only. Preserve typed action IDs, response indices, origin checks, permissions and focus rules. Escape every translated string and runtime value when rendering scriptless HTML. Keep version strings, Git revisions, SHA-256 values, WSL/Native menu values and MODES runtime labels literal. Translate only an explicit missing-value fallback. translation-reviewed desktop/main.cjs:coreVersion/coreBuild/buildLabel/aboutDetails; desktop/diagnostics.cjs:diagnosticsMenu +desktop.about.git_revision Core Git revision Core Git revision Core Git 修訂版本 About/runtime text also reusable by the native diagnostics menu. Display text only. Preserve typed action IDs, response indices, origin checks, permissions and focus rules. Escape every translated string and runtime value when rendering scriptless HTML. Keep version strings, Git revisions, SHA-256 values, WSL/Native menu values and MODES runtime labels literal. Translate only an explicit missing-value fallback. translation-reviewed desktop/main.cjs:coreVersion/coreBuild/buildLabel/aboutDetails; desktop/diagnostics.cjs:diagnosticsMenu +desktop.about.bundle_sha256 Core bundle SHA-256 Core bundle SHA-256 Core 套件 SHA-256 About/runtime text also reusable by the native diagnostics menu. Display text only. Preserve typed action IDs, response indices, origin checks, permissions and focus rules. Escape every translated string and runtime value when rendering scriptless HTML. Keep version strings, Git revisions, SHA-256 values, WSL/Native menu values and MODES runtime labels literal. Translate only an explicit missing-value fallback. translation-reviewed desktop/main.cjs:coreVersion/coreBuild/buildLabel/aboutDetails; desktop/diagnostics.cjs:diagnosticsMenu +desktop.about.details Core version: {core_version}\n{build_label}: {core_build}\nBackend: {backend}\nPython: {python_version}\nElectron: {electron_version}\nChromium: {chromium_version}\nNode.js: {node_version}\nPlatform: {platform} / {arch}\n\nEvaluation build; updates are installed manually. Core version: {core_version}\n{build_label}: {core_build}\nBackend: {backend}\nPython: {python_version}\nElectron: {electron_version}\nChromium: {chromium_version}\nNode.js: {node_version}\nPlatform: {platform} / {arch}\n\nEvaluation build. Install updates manually. Core 版本:{core_version}\n{build_label}:{core_build}\n後端:{backend}\nPython:{python_version}\nElectron:{electron_version}\nChromium:{chromium_version}\nNode.js:{node_version}\n平台:{platform} / {arch}\n\n評估版本,更新需手動安裝。 Complete About detail with existing line breaks and literal runtime values. {core_version}, {build_label}, {core_build}, {backend}, {python_version}, {electron_version}, {chromium_version}, {node_version}, {platform}, {arch} Display text only. Preserve typed action IDs, response indices, origin checks, permissions and focus rules. Escape every translated string and runtime value when rendering scriptless HTML. TSV stores literal \n sequences; convert them to line breaks when generating messages. build_label is the translated Git-revision or bundle-SHA-256 label. Never translate MODES values, platform/arch, version, revision or hash parameters. translation-reviewed desktop/main.cjs:aboutDetails +desktop.diagnostics.show_status Status and recent events... Status and recent events… 狀態與近期事件… Native diagnostics status-window action. Display text only. Preserve typed action IDs, response indices, origin checks, permissions and focus rules. Escape every translated string and runtime value when rendering scriptless HTML. Copy backend URL means the validated base_url/origin without token or authorization query parameters. Keep diagnostics event and schema field names unchanged. translation-reviewed desktop/diagnostics.cjs:diagnosticsMenu +desktop.diagnostics.origin URL: {origin} URL: {origin} 網址:{origin} Disabled native-menu row showing the tokenless backend origin. {origin} Display text only. Preserve typed action IDs, response indices, origin checks, permissions and focus rules. Escape every translated string and runtime value when rendering scriptless HTML. Copy backend URL means the validated base_url/origin without token or authorization query parameters. Keep diagnostics event and schema field names unchanged. translation-reviewed desktop/diagnostics.cjs:diagnosticsMenu +desktop.diagnostics.copy_backend_url Copy backend URL Copy backend URL 複製後端網址 Native menu action shared with diagnostics window. Display text only. Preserve typed action IDs, response indices, origin checks, permissions and focus rules. Escape every translated string and runtime value when rendering scriptless HTML. Copy backend URL means the validated base_url/origin without token or authorization query parameters. Keep diagnostics event and schema field names unchanged. translation-reviewed desktop/diagnostics.cjs:diagnosticsMenu +desktop.diagnostics.web_settings_persistent Web settings: saved per origin (same as Core) Web settings: saved per origin (same as Core) 網頁設定:依來源儲存(與 Core 相同) Native menu persistence status for a normal profile. Display text only. Preserve typed action IDs, response indices, origin checks, permissions and focus rules. Escape every translated string and runtime value when rendering scriptless HTML. Copy backend URL means the validated base_url/origin without token or authorization query parameters. Keep diagnostics event and schema field names unchanged. translation-reviewed desktop/diagnostics.cjs:diagnosticsMenu +desktop.diagnostics.web_settings_temporary Web settings: temporary test profile Web settings: temporary test profile 網頁設定:暫存測試設定檔 Native menu persistence status for the smoke profile. Display text only. Preserve typed action IDs, response indices, origin checks, permissions and focus rules. Escape every translated string and runtime value when rendering scriptless HTML. Copy backend URL means the validated base_url/origin without token or authorization query parameters. Keep diagnostics event and schema field names unchanged. translation-reviewed desktop/diagnostics.cjs:diagnosticsMenu +desktop.diagnostics.open_logs Open diagnostics log folder Open diagnostics log folder 開啟診斷紀錄資料夾 Native log-folder action. Display text only. Preserve typed action IDs, response indices, origin checks, permissions and focus rules. Escape every translated string and runtime value when rendering scriptless HTML. Copy backend URL means the validated base_url/origin without token or authorization query parameters. Keep diagnostics event and schema field names unchanged. translation-reviewed desktop/diagnostics.cjs:diagnosticsMenu +desktop.diagnostics.logs_filtered Logs exclude credentials and terminal content Logs exclude credentials and terminal content 紀錄不含憑證與終端內容 Status for the existing structured diagnostic logger. Display text only. Preserve typed action IDs, response indices, origin checks, permissions and focus rules. Escape every translated string and runtime value when rendering scriptless HTML. Copy backend URL means the validated base_url/origin without token or authorization query parameters. Keep diagnostics event and schema field names unchanged. translation-reviewed desktop/diagnostics.cjs:diagnosticsMenu +desktop.diagnostics.log_unwritable Diagnostic log could not be written Could not write the diagnostic log 無法寫入診斷紀錄 Logger available=false display; no assurance that a file was created. Display text only. Preserve typed action IDs, response indices, origin checks, permissions and focus rules. Escape every translated string and runtime value when rendering scriptless HTML. Copy backend URL means the validated base_url/origin without token or authorization query parameters. Keep diagnostics event and schema field names unchanged. translation-reviewed desktop/diagnostics.cjs:diagnosticsMenu +desktop.diagnostics.devtools_menu Developer Tools... Developer Tools… 開發人員工具… Native developer-tools menu action; confirmation remains required. Display text only. Preserve typed action IDs, response indices, origin checks, permissions and focus rules. Escape every translated string and runtime value when rendering scriptless HTML. Copy backend URL means the validated base_url/origin without token or authorization query parameters. Keep diagnostics event and schema field names unchanged. translation-reviewed desktop/diagnostics.cjs:diagnosticsMenu +desktop.diagnostics.window_title StandTerm Desktop - Diagnostics StandTerm Desktop - Diagnostics StandTerm Desktop - 診斷 Native window title and HTML title. Display text only. Preserve typed action IDs, response indices, origin checks, permissions and focus rules. Escape every translated string and runtime value when rendering scriptless HTML. Keep the page scriptless and isolated; escape both translated prose and runtime data before HTML interpolation. JSON event names, field names, schema, mode and code values stay literal. Reuse desktop.toolbar.menu_view. translation-reviewed desktop/diagnostics-window.cjs:statusHtml/createStatusWindow +desktop.diagnostics.title Desktop diagnostics Desktop diagnostics Desktop 診斷 Scriptless diagnostics page heading. Display text only. Preserve typed action IDs, response indices, origin checks, permissions and focus rules. Escape every translated string and runtime value when rendering scriptless HTML. Keep the page scriptless and isolated; escape both translated prose and runtime data before HTML interpolation. JSON event names, field names, schema, mode and code values stay literal. Reuse desktop.toolbar.menu_view. translation-reviewed desktop/diagnostics-window.cjs:statusHtml/createStatusWindow +desktop.diagnostics.read_only_hint Read-only snapshot. Use View > Refresh (Ctrl/Cmd+R) for current data. Read-only snapshot. Use View > Refresh (Ctrl/Cmd+R) to update. 唯讀快照。請選擇「檢視 > 重新整理」(Ctrl/Cmd+R)取得最新資料。 Snapshot freshness explanation; no automatic refresh promise. Display text only. Preserve typed action IDs, response indices, origin checks, permissions and focus rules. Escape every translated string and runtime value when rendering scriptless HTML. Keep the page scriptless and isolated; escape both translated prose and runtime data before HTML interpolation. JSON event names, field names, schema, mode and code values stay literal. Reuse desktop.toolbar.menu_view. translation-reviewed desktop/diagnostics-window.cjs:statusHtml/createStatusWindow +desktop.diagnostics.runtime_heading Runtime and storage Runtime and storage 執行環境與儲存 Runtime table heading. Display text only. Preserve typed action IDs, response indices, origin checks, permissions and focus rules. Escape every translated string and runtime value when rendering scriptless HTML. Keep the page scriptless and isolated; escape both translated prose and runtime data before HTML interpolation. JSON event names, field names, schema, mode and code values stay literal. Reuse desktop.toolbar.menu_view. translation-reviewed desktop/diagnostics-window.cjs:statusHtml/createStatusWindow +desktop.diagnostics.handoff_heading Preview and browser handoff Preview and browser handoff 預覽與外部瀏覽器 External preview behavior heading. Display text only. Preserve typed action IDs, response indices, origin checks, permissions and focus rules. Escape every translated string and runtime value when rendering scriptless HTML. Keep the page scriptless and isolated; escape both translated prose and runtime data before HTML interpolation. JSON event names, field names, schema, mode and code values stay literal. Reuse desktop.toolbar.menu_view. translation-reviewed desktop/diagnostics-window.cjs:statusHtml/createStatusWindow +desktop.diagnostics.handoff_hint External embedded previews are blocked in Desktop. Use the preview's Open in browser button and confirm the destination. This opens your OS default browser; its cookies and login are separate. Desktop credentials are not added to the URL. Desktop blocks external embedded previews. Choose Open in browser in the preview and confirm the destination. Your default browser uses separate cookies and login. Desktop credentials are not added to the URL. Desktop 會封鎖外部網頁的內嵌預覽。請在預覽中選擇「在瀏覽器開啟」(Open in browser)並確認目的地。預設瀏覽器使用獨立的 Cookie 與登入狀態,網址不會加入 Desktop 憑證。 Full external-preview explanation; preserve explicit destination confirmation and separate browser login. Display text only. Preserve typed action IDs, response indices, origin checks, permissions and focus rules. Escape every translated string and runtime value when rendering scriptless HTML. Keep the page scriptless and isolated; escape both translated prose and runtime data before HTML interpolation. JSON event names, field names, schema, mode and code values stay literal. Reuse desktop.toolbar.menu_view. translation-reviewed desktop/diagnostics-window.cjs:statusHtml/createStatusWindow +desktop.diagnostics.events_heading Recent startup events Recent startup events 近期啟動事件 Structured startup-event list heading. Display text only. Preserve typed action IDs, response indices, origin checks, permissions and focus rules. Escape every translated string and runtime value when rendering scriptless HTML. Keep the page scriptless and isolated; escape both translated prose and runtime data before HTML interpolation. JSON event names, field names, schema, mode and code values stay literal. Reuse desktop.toolbar.menu_view. translation-reviewed desktop/diagnostics-window.cjs:statusHtml/createStatusWindow +desktop.diagnostics.events_hint Up to 200 structured events from this run. No terminal content, private keys, tokens or external URLs. Previous runs are available in the diagnostics log folder. Up to 200 structured events from this run. No terminal content, private keys, tokens or external URLs. Find previous runs in the diagnostics log folder. 顯示本次執行最多 200 筆結構化事件,不含終端內容、私密金鑰、權杖或外部網址。先前執行的紀錄可在診斷紀錄資料夾中查看。 Event retention and redaction explanation; events themselves remain raw JSON. Display text only. Preserve typed action IDs, response indices, origin checks, permissions and focus rules. Escape every translated string and runtime value when rendering scriptless HTML. Keep the page scriptless and isolated; escape both translated prose and runtime data before HTML interpolation. JSON event names, field names, schema, mode and code values stay literal. Reuse desktop.toolbar.menu_view. translation-reviewed desktop/diagnostics-window.cjs:statusHtml/createStatusWindow +desktop.diagnostics.no_events No events yet. No events yet. 目前沒有事件。 Empty preformatted event list fallback. Display text only. Preserve typed action IDs, response indices, origin checks, permissions and focus rules. Escape every translated string and runtime value when rendering scriptless HTML. Keep the page scriptless and isolated; escape both translated prose and runtime data before HTML interpolation. JSON event names, field names, schema, mode and code values stay literal. Reuse desktop.toolbar.menu_view. translation-reviewed desktop/diagnostics-window.cjs:statusHtml/createStatusWindow +desktop.diagnostics.refresh Refresh Refresh 重新整理 Native diagnostics View submenu action; accelerator unchanged. Display text only. Preserve typed action IDs, response indices, origin checks, permissions and focus rules. Escape every translated string and runtime value when rendering scriptless HTML. Keep the page scriptless and isolated; escape both translated prose and runtime data before HTML interpolation. JSON event names, field names, schema, mode and code values stay literal. Reuse desktop.toolbar.menu_view. translation-reviewed desktop/diagnostics-window.cjs:statusHtml/createStatusWindow +desktop.diagnostics.desktop_version Desktop version Desktop version Desktop 版本 Read-only runtime table row label. Display text only. Preserve typed action IDs, response indices, origin checks, permissions and focus rules. Escape every translated string and runtime value when rendering scriptless HTML. Translate the label only. Keep MODES values, IDs, URL origin, versions, platform/architecture, paths and hashes literal and escaped. translation-reviewed desktop/main.cjs:openStatus.snapshot.rows +desktop.diagnostics.backend_mode Backend mode Backend mode 後端模式 Read-only runtime table row label. Display text only. Preserve typed action IDs, response indices, origin checks, permissions and focus rules. Escape every translated string and runtime value when rendering scriptless HTML. Translate the label only. Keep MODES values, IDs, URL origin, versions, platform/architecture, paths and hashes literal and escaped. translation-reviewed desktop/main.cjs:openStatus.snapshot.rows +desktop.diagnostics.core_version Core version Core version Core 版本 Read-only runtime table row label. Display text only. Preserve typed action IDs, response indices, origin checks, permissions and focus rules. Escape every translated string and runtime value when rendering scriptless HTML. Translate the label only. Keep MODES values, IDs, URL origin, versions, platform/architecture, paths and hashes literal and escaped. translation-reviewed desktop/main.cjs:openStatus.snapshot.rows +desktop.diagnostics.python_version Python version Python version Python 版本 Read-only runtime table row label. Display text only. Preserve typed action IDs, response indices, origin checks, permissions and focus rules. Escape every translated string and runtime value when rendering scriptless HTML. Translate the label only. Keep MODES values, IDs, URL origin, versions, platform/architecture, paths and hashes literal and escaped. translation-reviewed desktop/main.cjs:openStatus.snapshot.rows +desktop.diagnostics.backend_url Backend URL Backend URL 後端網址 Read-only runtime table row label. Display text only. Preserve typed action IDs, response indices, origin checks, permissions and focus rules. Escape every translated string and runtime value when rendering scriptless HTML. Translate the label only. Keep MODES values, IDs, URL origin, versions, platform/architecture, paths and hashes literal and escaped. translation-reviewed desktop/main.cjs:openStatus.snapshot.rows +desktop.diagnostics.instance_id Instance ID Instance ID 執行個體 ID Read-only runtime table row label. Display text only. Preserve typed action IDs, response indices, origin checks, permissions and focus rules. Escape every translated string and runtime value when rendering scriptless HTML. Translate the label only. Keep MODES values, IDs, URL origin, versions, platform/architecture, paths and hashes literal and escaped. translation-reviewed desktop/main.cjs:openStatus.snapshot.rows +desktop.diagnostics.platform Platform Platform 平台 Read-only runtime table row label. Display text only. Preserve typed action IDs, response indices, origin checks, permissions and focus rules. Escape every translated string and runtime value when rendering scriptless HTML. Translate the label only. Keep MODES values, IDs, URL origin, versions, platform/architecture, paths and hashes literal and escaped. translation-reviewed desktop/main.cjs:openStatus.snapshot.rows +desktop.diagnostics.engines Electron / Chromium / Node Electron / Chromium / Node Electron / Chromium / Node Read-only runtime table row label. Display text only. Preserve typed action IDs, response indices, origin checks, permissions and focus rules. Escape every translated string and runtime value when rendering scriptless HTML. Translate the label only. Keep MODES values, IDs, URL origin, versions, platform/architecture, paths and hashes literal and escaped. translation-reviewed desktop/main.cjs:openStatus.snapshot.rows +desktop.diagnostics.backend_process Backend process Backend process 後端程序 Read-only runtime table row label. Display text only. Preserve typed action IDs, response indices, origin checks, permissions and focus rules. Escape every translated string and runtime value when rendering scriptless HTML. Translate the label only. Keep MODES values, IDs, URL origin, versions, platform/architecture, paths and hashes literal and escaped. translation-reviewed desktop/main.cjs:openStatus.snapshot.rows +desktop.diagnostics.renderer_process Renderer process Renderer process 渲染程序 Read-only runtime table row label. Display text only. Preserve typed action IDs, response indices, origin checks, permissions and focus rules. Escape every translated string and runtime value when rendering scriptless HTML. Translate the label only. Keep MODES values, IDs, URL origin, versions, platform/architecture, paths and hashes literal and escaped. translation-reviewed desktop/main.cjs:openStatus.snapshot.rows +desktop.diagnostics.browser_storage Browser storage Browser storage 瀏覽器儲存 Read-only runtime table row label. Display text only. Preserve typed action IDs, response indices, origin checks, permissions and focus rules. Escape every translated string and runtime value when rendering scriptless HTML. Translate the label only. Keep MODES values, IDs, URL origin, versions, platform/architecture, paths and hashes literal and escaped. translation-reviewed desktop/main.cjs:openStatus.snapshot.rows +desktop.diagnostics.profile_directory Profile directory Profile directory 設定檔目錄 Read-only runtime table row label. Display text only. Preserve typed action IDs, response indices, origin checks, permissions and focus rules. Escape every translated string and runtime value when rendering scriptless HTML. Translate the label only. Keep MODES values, IDs, URL origin, versions, platform/architecture, paths and hashes literal and escaped. translation-reviewed desktop/main.cjs:openStatus.snapshot.rows +desktop.diagnostics.port_settings Port settings Port settings 連接埠設定 Read-only runtime table row label. Display text only. Preserve typed action IDs, response indices, origin checks, permissions and focus rules. Escape every translated string and runtime value when rendering scriptless HTML. Translate the label only. Keep MODES values, IDs, URL origin, versions, platform/architecture, paths and hashes literal and escaped. translation-reviewed desktop/main.cjs:openStatus.snapshot.rows +desktop.diagnostics.log_file Diagnostic log Diagnostic log 診斷紀錄 Read-only runtime table row label. Display text only. Preserve typed action IDs, response indices, origin checks, permissions and focus rules. Escape every translated string and runtime value when rendering scriptless HTML. Translate the label only. Keep MODES values, IDs, URL origin, versions, platform/architecture, paths and hashes literal and escaped. translation-reviewed desktop/main.cjs:openStatus.snapshot.rows +desktop.diagnostics.log_writable Log writable Log write status 記錄寫入狀態 Runtime log status label. The available flag records the last write outcome; it is not a current filesystem-writability probe. Display text only. Preserve typed action IDs, response indices, origin checks, permissions and focus rules. Escape every translated string and runtime value when rendering scriptless HTML. Translate the label only. Keep MODES values, IDs, URL origin, versions, platform/architecture, paths and hashes literal and escaped. translation-reviewed desktop/main.cjs:openStatus.snapshot.rows +desktop.diagnostics.security Security Security 安全性 Read-only runtime table row label. Display text only. Preserve typed action IDs, response indices, origin checks, permissions and focus rules. Escape every translated string and runtime value when rendering scriptless HTML. Translate the label only. Keep MODES values, IDs, URL origin, versions, platform/architecture, paths and hashes literal and escaped. translation-reviewed desktop/main.cjs:openStatus.snapshot.rows +desktop.diagnostics.running Running Running 執行中 Runtime table display value selected from existing typed process/storage/logging state. Display text only. Preserve typed action IDs, response indices, origin checks, permissions and focus rules. Escape every translated string and runtime value when rendering scriptless HTML. Translate display state only. Keep running/crashed checks, smoke-profile selection, logging availability, sandbox and network policy unchanged. Do not translate diagnostic JSON values. translation-reviewed desktop/main.cjs:openStatus.snapshot.rows +desktop.diagnostics.stopped Stopped Stopped 已停止 Runtime table display value selected from existing typed process/storage/logging state. Display text only. Preserve typed action IDs, response indices, origin checks, permissions and focus rules. Escape every translated string and runtime value when rendering scriptless HTML. Translate display state only. Keep running/crashed checks, smoke-profile selection, logging availability, sandbox and network policy unchanged. Do not translate diagnostic JSON values. translation-reviewed desktop/main.cjs:openStatus.snapshot.rows +desktop.diagnostics.crashed Crashed Crashed 已當機 Runtime table display value selected from existing typed process/storage/logging state. Display text only. Preserve typed action IDs, response indices, origin checks, permissions and focus rules. Escape every translated string and runtime value when rendering scriptless HTML. Translate display state only. Keep running/crashed checks, smoke-profile selection, logging availability, sandbox and network policy unchanged. Do not translate diagnostic JSON values. translation-reviewed desktop/main.cjs:openStatus.snapshot.rows +desktop.diagnostics.log_write_ok Yes No write error reported 未回報寫入錯誤 Log-write status when diagnostics.available is true, including its initial true state. Not a generic Yes label. Display text only. Preserve typed action IDs, response indices, origin checks, permissions and focus rules. Escape every translated string and runtime value when rendering scriptless HTML. Translate display state only. Keep running/crashed checks, smoke-profile selection, logging availability, sandbox and network policy unchanged. Do not translate diagnostic JSON values. Do not claim a successful write or a current writable-path probe. translation-reviewed desktop/main.cjs:openStatus.snapshot.rows +desktop.diagnostics.log_write_failed No Write failed 寫入失敗 Log-write status when diagnostics.available is false after a write exception. Not a generic No label. Display text only. Preserve typed action IDs, response indices, origin checks, permissions and focus rules. Escape every translated string and runtime value when rendering scriptless HTML. Translate display state only. Keep running/crashed checks, smoke-profile selection, logging availability, sandbox and network policy unchanged. Do not translate diagnostic JSON values. translation-reviewed desktop/main.cjs:openStatus.snapshot.rows +desktop.diagnostics.storage_persistent Persistent per origin; same as Core Persistent per origin; same as Core 依來源持續保留;與 Core 相同 Runtime table display value selected from existing typed process/storage/logging state. Display text only. Preserve typed action IDs, response indices, origin checks, permissions and focus rules. Escape every translated string and runtime value when rendering scriptless HTML. Translate display state only. Keep running/crashed checks, smoke-profile selection, logging availability, sandbox and network policy unchanged. Do not translate diagnostic JSON values. translation-reviewed desktop/main.cjs:openStatus.snapshot.rows +desktop.diagnostics.storage_temporary Temporary test profile Temporary test profile 暫存測試設定檔 Runtime table display value selected from existing typed process/storage/logging state. Display text only. Preserve typed action IDs, response indices, origin checks, permissions and focus rules. Escape every translated string and runtime value when rendering scriptless HTML. Translate display state only. Keep running/crashed checks, smoke-profile selection, logging availability, sandbox and network policy unchanged. Do not translate diagnostic JSON values. translation-reviewed desktop/main.cjs:openStatus.snapshot.rows +desktop.diagnostics.security_detail Core sandbox on; Node/preload off; isolated Desktop toolbar; external preview network blocked Core sandbox on; Node/preload off; isolated Desktop toolbar; external preview network blocked Core 沙箱已啟用;Node/preload 已停用;Desktop 工具列獨立隔離;已封鎖外部預覽的網路存取 Runtime table display value selected from existing typed process/storage/logging state. Display text only. Preserve typed action IDs, response indices, origin checks, permissions and focus rules. Escape every translated string and runtime value when rendering scriptless HTML. Translate display state only. Keep running/crashed checks, smoke-profile selection, logging availability, sandbox and network policy unchanged. Do not translate diagnostic JSON values. translation-reviewed desktop/main.cjs:openStatus.snapshot.rows +desktop.diagnostics.open_failed Could not open the diagnostics page. Could not open the diagnostics page. 無法開啟診斷頁面。 Status-window open failure; no raw Error text in the notice. Display text only. Preserve typed action IDs, response indices, origin checks, permissions and focus rules. Escape every translated string and runtime value when rendering scriptless HTML. Keep Cancel response/defaultId/cancelId 0 and developer-tools response 1. Preserve devtools_opened event ID and detached frontend-only debugger. Raw log-folder path stays in the designated detail field. translation-reviewed desktop/main.cjs:diagnosticsMenu.openStatus/openLogs/openTools +desktop.diagnostics.folder_failed Could not open the diagnostics folder. Could not open the diagnostics folder. 無法開啟診斷資料夾。 Log-folder open failure; existing raw path remains in dialog detail. Display text only. Preserve typed action IDs, response indices, origin checks, permissions and focus rules. Escape every translated string and runtime value when rendering scriptless HTML. Keep Cancel response/defaultId/cancelId 0 and developer-tools response 1. Preserve devtools_opened event ID and detached frontend-only debugger. Raw log-folder path stays in the designated detail field. translation-reviewed desktop/main.cjs:diagnosticsMenu.openStatus/openLogs/openTools +desktop.diagnostics.devtools_title Developer Tools Developer Tools 開發人員工具 Developer-tools confirmation title. Display text only. Preserve typed action IDs, response indices, origin checks, permissions and focus rules. Escape every translated string and runtime value when rendering scriptless HTML. Keep Cancel response/defaultId/cancelId 0 and developer-tools response 1. Preserve devtools_opened event ID and detached frontend-only debugger. Raw log-folder path stays in the designated detail field. translation-reviewed desktop/main.cjs:diagnosticsMenu.openStatus/openLogs/openTools +desktop.diagnostics.devtools_message Open Developer Tools for this authenticated terminal UI? Open Developer Tools for this authenticated terminal UI? 要為此已通過身分驗證的終端介面開啟開發人員工具嗎? Developer-tools confirmation question. Display text only. Preserve typed action IDs, response indices, origin checks, permissions and focus rules. Escape every translated string and runtime value when rendering scriptless HTML. Keep Cancel response/defaultId/cancelId 0 and developer-tools response 1. Preserve devtools_opened event ID and detached frontend-only debugger. Raw log-folder path stays in the designated detail field. translation-reviewed desktop/main.cjs:diagnosticsMenu.openStatus/openLogs/openTools +desktop.diagnostics.devtools_detail Console code can read page data and operate connected terminals. Only run code you trust. This opens a local frontend debugger, not a remote debugging port or a Node.js bridge. Console code can read page data and operate connected terminals. Run only code you trust. This opens a local frontend debugger, not a remote debugging port or Node.js bridge. 主控台程式碼可讀取頁面資料並操作已連線的終端。請只執行可信任的程式碼。此操作會開啟本機前端偵錯工具,不會開啟遠端偵錯連接埠或 Node.js 橋接介面。 Complete consequence explanation before attaching frontend developer tools. Display text only. Preserve typed action IDs, response indices, origin checks, permissions and focus rules. Escape every translated string and runtime value when rendering scriptless HTML. Keep Cancel response/defaultId/cancelId 0 and developer-tools response 1. Preserve devtools_opened event ID and detached frontend-only debugger. Raw log-folder path stays in the designated detail field. translation-reviewed desktop/main.cjs:diagnosticsMenu.openStatus/openLogs/openTools +desktop.diagnostics.devtools_open Open Developer Tools Open Developer Tools 開啟開發人員工具 Affirmative developer-tools confirmation button. Display text only. Preserve typed action IDs, response indices, origin checks, permissions and focus rules. Escape every translated string and runtime value when rendering scriptless HTML. Keep Cancel response/defaultId/cancelId 0 and developer-tools response 1. Preserve devtools_opened event ID and detached frontend-only debugger. Raw log-folder path stays in the designated detail field. translation-reviewed desktop/main.cjs:diagnosticsMenu.openStatus/openLogs/openTools +desktop.external_browser.title Open in default browser Open in default browser 在預設瀏覽器開啟 External-browser destination confirmation or generic OS open failure. Display text only. Preserve typed action IDs, response indices, origin checks, permissions and focus rules. Escape every translated string and runtime value when rendering scriptless HTML. Only the confirmation may show the raw destination URL and host. Never insert URL, token, grant or cookies into generic notices. Preserve Cancel response/defaultId/cancelId 0, Open response 1, destination validation and user confirmation. TSV detail stores literal \n separators. translation-reviewed desktop/main.cjs:createExternalOpener.confirm/notify +desktop.external_browser.message Open {host} in your default browser? Open {host} in your default browser? 要在預設瀏覽器開啟 {host} 嗎? External-browser destination confirmation or generic OS open failure. {host} Display text only. Preserve typed action IDs, response indices, origin checks, permissions and focus rules. Escape every translated string and runtime value when rendering scriptless HTML. Only the confirmation may show the raw destination URL and host. Never insert URL, token, grant or cookies into generic notices. Preserve Cancel response/defaultId/cancelId 0, Open response 1, destination validation and user confirmation. TSV detail stores literal \n separators. translation-reviewed desktop/main.cjs:createExternalOpener.confirm/notify +desktop.external_browser.detail {url}\n\nThe browser uses its own login. StandTerm does not add its token or cookies. {url}\n\nThe browser uses its own login. StandTerm does not add its token or cookies. {url}\n\n瀏覽器使用自己的登入狀態。StandTerm 不會加入自己的權杖或 Cookie。 External-browser destination confirmation or generic OS open failure. {url} Display text only. Preserve typed action IDs, response indices, origin checks, permissions and focus rules. Escape every translated string and runtime value when rendering scriptless HTML. Only the confirmation may show the raw destination URL and host. Never insert URL, token, grant or cookies into generic notices. Preserve Cancel response/defaultId/cancelId 0, Open response 1, destination validation and user confirmation. TSV detail stores literal \n separators. translation-reviewed desktop/main.cjs:createExternalOpener.confirm/notify +desktop.external_browser.open Open in browser Open in browser 在瀏覽器開啟 External-browser destination confirmation or generic OS open failure. Display text only. Preserve typed action IDs, response indices, origin checks, permissions and focus rules. Escape every translated string and runtime value when rendering scriptless HTML. Only the confirmation may show the raw destination URL and host. Never insert URL, token, grant or cookies into generic notices. Preserve Cancel response/defaultId/cancelId 0, Open response 1, destination validation and user confirmation. TSV detail stores literal \n separators. translation-reviewed desktop/main.cjs:createExternalOpener.confirm/notify +desktop.external_browser.failed Could not open the default browser. Could not open the default browser. 無法開啟預設瀏覽器。 External-browser destination confirmation or generic OS open failure. Display text only. Preserve typed action IDs, response indices, origin checks, permissions and focus rules. Escape every translated string and runtime value when rendering scriptless HTML. Only the confirmation may show the raw destination URL and host. Never insert URL, token, grant or cookies into generic notices. Preserve Cancel response/defaultId/cancelId 0, Open response 1, destination validation and user confirmation. TSV detail stores literal \n separators. translation-reviewed desktop/main.cjs:createExternalOpener.confirm/notify +desktop.external_browser.failure_hint Check the default HTTP/HTTPS browser in your operating system settings. Check the default HTTP/HTTPS browser in your operating system settings. 請在作業系統設定中檢查 HTTP/HTTPS 的預設瀏覽器。 External-browser destination confirmation or generic OS open failure. Display text only. Preserve typed action IDs, response indices, origin checks, permissions and focus rules. Escape every translated string and runtime value when rendering scriptless HTML. Only the confirmation may show the raw destination URL and host. Never insert URL, token, grant or cookies into generic notices. Preserve Cancel response/defaultId/cancelId 0, Open response 1, destination validation and user confirmation. TSV detail stores literal \n separators. translation-reviewed desktop/main.cjs:createExternalOpener.confirm/notify +desktop.diagnostics.native_backend Native Native 原生 Native diagnostics-menu backend display selected when mode is not wsl. Display label only. Keep wsl/windows/macos mode IDs, MODES runtime values and event JSON unchanged. Keep WSL literal. Escape translated values in any HTML renderer. translation-reviewed desktop/diagnostics.cjs:diagnosticsMenu:diagnostics-backend diff --git a/docs/desktop_ui_review_plan.md b/docs/desktop_ui_review_plan.md index 7009093..5071e3c 100644 --- a/docs/desktop_ui_review_plan.md +++ b/docs/desktop_ui_review_plan.md @@ -12,11 +12,11 @@ acceptance remain separate. Browser acceptance is recorded in | --- | --- | --- | --- | | 0 — Complete | Clarify toolbar action feedback before localization. A resolved `false` now shows unavailable feedback; a rejected invocation reports an uncertain result without suggesting retry. | Small | Both original failures reproduced before the fix; renderer and command-guard checks passed. Each click invokes once, with no automatic replay or invented completion notice. | | 1 — Complete | Add a Desktop-owned language preference and catalog; pilot custom menus, toolbar labels and Agent help. | Medium | English default/fallback, `en` and `zh-TW`, malformed preference fallback, next-launch application, translated title/ARIA labels without losing SVGs, fixed command IDs, focus/origin guards and staging inclusion verified. Native acceptance remains order 4. | -| 2 | Review and localize Capture, Browser Access and Diagnostics. Resolve the recording save-failure exit policy before the Capture portion. | Medium | Typed recording states, partial-file paths, cancel/default buttons, first-folder seeding, sensitive clipboard feedback and escaped diagnostic fields retain their contracts. Add combined save-failure plus close/quit coverage. | +| 2 — Partial | Browser Access and Diagnostics are localized, including About and external-browser confirmations. Capture remains; resolve its save-failure exit policy first. | Medium | Sensitive clipboard feedback, fixed authorization actions, escaped diagnostic fields and literal event JSON verified in both languages. Capture still needs typed state, partial-file, folder-seeding and combined save-failure plus close/quit coverage. | | 3 | Localize setup, Core source selection, startup failure and recoverable environment cleanup. | Medium to large | Both languages work before Core is available. Cancellation waits for owned installers; stale confirmations do nothing; source switching, restart/session closure, retained files and recovery moves remain explicit. | | 4 | Complete Windows and macOS native acceptance and packaged asset checks. | Platform-dependent | Menus, native dialogs, narrow layouts, keyboard/ARIA labels, clipboard, setup and recovery are checked on each OS. Verify staged and packaged Desktop catalogs independently of the selected Core version. | -The next implementation is Browser Access/Diagnostics and then Capture in order 2. +The next implementation is Capture in order 2. Resolve the recording save-failure exit policy before changing its confirmation. Orders 1–3 should remain separate reviewable changes. First-run setup can move ahead of order 2 if onboarding becomes the priority; it is not required to prove @@ -68,8 +68,8 @@ Keep these implementation boundaries: [desktop_ui_copy_review.tsv](desktop_ui_copy_review.tsv) is a prioritized seed inventory, not a claim that every Desktop string has been extracted. It uses -the same nine columns as the browser table. After the pilot, 50 rows are -`translation-reviewed` with English and Traditional Chinese text; 20 workflow +the same nine columns as the browser table. After Browser Access/Diagnostics, +126 rows are `translation-reviewed` with English and Traditional Chinese text; 17 workflow rows remain `proposed` with empty `zh-TW` cells. Some `current_en` cells are exact fragments or normalize dynamic values to named placeholders; `context` identifies these cases. @@ -146,8 +146,44 @@ remaining gap in the new preference, DOM and command tests. No policy was reopened. Core's independent language preference and the existing recording-exit behavior remain intact. Native OS qualification is still order 4. +## Browser Access and Diagnostics review + +The second implementation batch adds 76 reviewed bilingual messages. Browser +authorization notices describe opening a link, without claiming the browser +completed authorization. Copying a token/access URL still makes no grant request; +creating an authorization link still requires the existing confirmation. The +warning retains sensitive URL handling, browser history and replacement of the +previous unused link. Exceptions still produce a sanitized notice. + +Diagnostics localizes menus, runtime field labels/states and explanatory text. +Version strings, paths, bundle hashes, instance IDs, backend origins and event +JSON remain literal data. The diagnostic HTML still has no scripts; every +translated string and displayed value is escaped at the final HTML boundary. +About and external-browser confirmations share these display conventions. + +| Finding | Severity | Evidence | Critic remedy | Main response | Resolution | Validation | +| --- | --- | --- | --- | --- | --- | --- | +| “Log writable” implies a live filesystem check | Low | `createDiagnostics.available` starts true and tracks write outcomes | Describe write status, not present writability | Use “Log write status” with “No write error reported” / “Write failed” | Accept | Existing log-failure tests and localized display reviewed; flag semantics unchanged. | +| An opened authorization link does not prove completed authorization | Correctness boundary | `createBrowserAccess.run` awaits the OS open callback | Keep link-open feedback distinct from authorization | Applied reviewed copy; no request or permission changes | Accept | Both locales exercise four actions, exact payloads/request counts, cancellation and sanitized exceptions. | +| Translation must not introduce markup or change diagnostic exports | Correctness boundary | `statusHtml` and structured logger | Escape all display content; retain raw event JSON and CSP | No event translation layer or renderer scripts added | Accept | Malicious translation/data tests, isolated window callbacks and real browser DOM checks passed. | + +The critic's focused second pass found no remaining material issue. It also +checked that Browser Access, DevTools and external-browser confirmations retain +`response === 1`, default/cancel index 0 and the original sensitive-data boundaries. +Native dialog layout and interaction are still part of order 4. + ## Evidence and acceptance limits +Browser Access/Diagnostics completed on 2026-09-20: + +- All 115 Desktop unit tests passed under Electron's Node 24.20.0 runtime. +- Seven catalog regression tests and both catalog freshness checks passed. +- The actual diagnostics HTML passed headless Chromium checks in both locales + at 640px: translated headings/labels, raw paths and JSON, empty-state copy, + escaped markup, no external requests and no horizontal overflow. +- No native OS GUI or installer acceptance is claimed. The new messages use + the catalog already included by the existing staging and builder rules. + Order 1 completed on 2026-09-20 with 50 reviewed bilingual messages: - All 110 Desktop unit tests passed under Electron's Node 24.20.0 runtime. @@ -184,7 +220,7 @@ inspection of Capture/setup/recovery does not imply their smoke suites ran in this review. The review table is checked using `build_ui_messages.build_catalog` for schema, -keys, placeholders and review gates. Only the 50 reviewed pilot rows enter the +keys, placeholders and review gates. Only the 126 reviewed rows enter the Desktop runtime catalog; the remaining workflow proposals stay out of it. Windows/macOS native localization, installer lifecycle and packaged acceptance remain future work. This plan does not qualify or publish a release. From 27fdebb8bf72fbfea3410d2afc8369c0c4e1e0a9 Mon Sep 17 00:00:00 2001 From: "ASKA C." Date: Sun, 20 Sep 2026 10:22:36 +0800 Subject: [PATCH 31/43] Keep Desktop open when recording save fails ## Why Recording finalization can fail while close or quit is pending. Losing the file outcome across startup or notification awaits permits premature exit and hides the retained file locations. ## What changed - Cancel the current close or quit on failed or unconfirmed recording saves and show the original error with available partial and destination paths. - Preserve structured job results across concurrent operations and keep notification failure from changing publication or exit decisions. - Localize Capture settings, status and dialogs in the Desktop table while retaining typed actions, raw diagnostics and later explicit exit. - Keep paused recording controls visible in the compact toolbar. ## Testing All 133 Desktop unit tests and seven catalog tests pass. Both generated catalogs are current. English and Traditional Chinese toolbar DOM checks pass at 640px with mocked native IPC. Native OS dialogs, encoder smoke and installer acceptance remain separate. --- desktop/README.md | 19 ++- desktop/capture.cjs | 127 +++++++++++------ desktop/main.cjs | 10 +- desktop/messages.js | 88 ++++++++++++ desktop/test/capture-close.test.cjs | 65 +++++++++ desktop/test/capture-file.test.cjs | 22 +++ desktop/test/capture-lifecycle.test.cjs | 145 +++++++++++++++++++ desktop/test/capture-shutdown.test.cjs | 156 +++++++++++++++++++++ desktop/test/capture-smoke.cjs | 2 +- desktop/test/capture-status.test.cjs | 18 ++- desktop/test/capture-ui.test.cjs | 58 ++++++++ desktop/test/toolbar-i18n-browser-smoke.py | 8 +- desktop/toolbar.css | 2 +- docs/desktop_ui_copy_review.tsv | 51 ++++++- docs/desktop_ui_review_plan.md | 65 +++++++-- 15 files changed, 753 insertions(+), 83 deletions(-) create mode 100644 desktop/test/capture-close.test.cjs create mode 100644 desktop/test/capture-lifecycle.test.cjs create mode 100644 desktop/test/capture-shutdown.test.cjs create mode 100644 desktop/test/capture-ui.test.cjs diff --git a/desktop/README.md b/desktop/README.md index 9001300..47b9aec 100644 --- a/desktop/README.md +++ b/desktop/README.md @@ -14,8 +14,8 @@ Choose **StandTerm > Desktop language...** to select English or Traditional Chinese (Taiwan) for the next launch. The preference belongs to the Desktop profile; Core keeps its own language setting. Saving a choice does not restart StandTerm or interrupt recording. Coverage includes custom menus, toolbar labels, -Agent help, Browser Access, Diagnostics, About and external-browser confirmations. -Capture status, setup, recovery and other Desktop +Agent help, Browser Access, Diagnostics, About, external-browser confirmations +and Capture dialogs/status. Setup, recovery and other Desktop text still use English; native role labels follow the platform. Edit reviewed messages in the table, then generate the independent shell @@ -484,9 +484,9 @@ layout is shared with Core Web and does not change approval policy or payloads. Use the direct capture buttons in the Desktop toolbar. Capture shortcuts are also available under **View**; there is no separate Capture dropdown: -- **Copy screenshot** (Ctrl/Cmd+Alt+S): PNG to the OS clipboard. +- **Copy screenshot to clipboard** (Ctrl/Cmd+Alt+S): PNG to the OS clipboard. - **Save screenshot (PNG)**: save directly to the configured screenshot folder. -- **Start recording (WebM)**: record directly to the configured recording folder. +- **Start recording (WebM, no audio)**: record directly to the configured recording folder. - **Pause / resume recording**: suspend capture without finishing the file. - **Stop and save recording** (Ctrl/Cmd+Alt+R): finalize the silent WebM. @@ -498,7 +498,12 @@ while recording records the newly visible tab as well. The native title and Desktop toolbar show recording status and elapsed active recording time. The stop button remains available while paused. Closing -or quitting asks whether to keep recording or stop and save. Hiding, minimizing, +or quitting asks whether to keep the window open or stop and save. If saving +fails or its result cannot be confirmed, Desktop cancels that close/quit, +keeps the window open and shows an error dialog with the original error and +available unfinished-file and requested-destination paths. Dismissing the error +does not retry saving; a later explicit close/quit is allowed once recording is +inactive. Hiding, minimizing, reloading or entering fullscreen stops and saves automatically, because hidden pages can stop producing frames or hide the indicator. Leave fullscreen before starting a recording; maximizing is supported. @@ -516,7 +521,9 @@ varies between players because this prototype does not rewrite container indexes Successful output is published without overwriting existing files or copying the video a second time. This currently requires a filesystem with hard-link support (for example NTFS or ext4); choose a local supported drive rather than FAT/exFAT. -If publication fails, the error reports the retained partial file. Unix files +If publication fails, the error reports the retained partial file. If publication +succeeds but removing the partial link fails, both paths may exist; a requested +destination in an error does not imply a confirmed save. Unix files are created with mode `0600`; Windows access follows the destination directory's ACL. Screenshot files use the same no-overwrite publication policy. diff --git a/desktop/capture.cjs b/desktop/capture.cjs index 1bfcb38..dd35ee9 100644 --- a/desktop/capture.cjs +++ b/desktop/capture.cjs @@ -7,6 +7,7 @@ const { pathToFileURL } = require('node:url'); const { randomUUID } = require('node:crypto'); const { CaptureFile, MAX_CHUNK_BYTES } = require('./capture-file.cjs'); const { CaptureSettings, captureName } = require('./capture-settings.cjs'); +const { create } = require('./i18n.js'); const RECORDER_URL = pathToFileURL(path.join(__dirname, 'recorder.html')).href; const RECORDER_SCRIPT_URL = pathToFileURL(path.join(__dirname, 'recorder.js')).href; @@ -22,13 +23,14 @@ async function bounded(promise) { } class DesktopCapture { - constructor(win, { contents = win.webContents, onChange = () => {}, onDiagnostic = () => {}, notify = null } = {}) { + constructor(win, { contents = win.webContents, onChange = () => {}, onDiagnostic = () => {}, notify = null, t = create('en').t } = {}) { this.win = win; this.contents = contents; this.onChange = onChange; this.onDiagnostic = onDiagnostic; + this.t = t; this.notify = notify || ((message, error = false) => dialog.showMessageBox(win, { - type: error ? 'error' : 'info', title: 'StandTerm Capture', message, + type: error ? 'error' : 'info', title: this.t('desktop.capture.title'), message, })); this.state = 'idle'; this.job = null; @@ -46,28 +48,28 @@ class DesktopCapture { const menu = Menu.getApplicationMenu(); const elapsed = this.job?.startedAt ? Math.floor(((this.job.pausedAt || Date.now()) - this.job.startedAt - (this.job.pausedMs || 0)) / 1000) : 0; const clock = `${Math.floor(elapsed / 60).toString().padStart(2, '0')}:${(elapsed % 60).toString().padStart(2, '0')}`; - const label = this.state === 'paused' ? `Recording paused ${clock}` : this.state === 'recording' ? `Recording ${clock}` - : this.state === 'idle' ? 'Not recording' : `${this.state === 'starting' ? 'Starting' : 'Saving'} recording...`; + const label = this.t(this.state === 'paused' ? 'desktop.capture.status_paused' : this.state === 'recording' ? 'desktop.capture.status_recording' + : this.state === 'idle' ? 'desktop.capture.status_idle' : this.state === 'starting' ? 'desktop.capture.status_starting' : 'desktop.capture.status_saving', { time: clock }); const status = menu?.getMenuItemById('capture-status'); if (status) status.label = label; const start = menu?.getMenuItemById('capture-start'); if (start) start.enabled = !this.active; const stop = menu?.getMenuItemById('capture-stop'); if (stop) stop.enabled = ['recording', 'paused'].includes(this.state); - this.onChange(this.active ? `${this.state === 'paused' ? 'PAUSED' : 'REC'} ${clock}` : '', { + this.onChange(this.active ? this.t(this.state === 'paused' ? 'desktop.capture.title_paused' : 'desktop.capture.title_recording', { time: clock }) : '', { state: this.state, label, screenshotBusy: this.screenshotBusy, }); } menu() { - return { label: 'Capture', submenu: [ - { label: 'Copy screenshot', accelerator: 'CommandOrControl+Alt+S', click: () => this.screenshot('clipboard') }, - { label: 'Save screenshot (PNG)', click: () => this.screenshot('file') }, + return { label: this.t('desktop.capture.menu'), submenu: [ + { label: this.t('desktop.toolbar.screenshot_copy'), accelerator: 'CommandOrControl+Alt+S', click: () => this.screenshot('clipboard') }, + { label: this.t('desktop.toolbar.screenshot_save'), click: () => this.screenshot('file') }, { type: 'separator' }, - { id: 'capture-start', label: 'Start recording (WebM)', click: () => this.start() }, - { id: 'capture-stop', label: 'Stop and save recording', accelerator: 'CommandOrControl+Alt+R', + { id: 'capture-start', label: this.t('desktop.toolbar.record_start'), click: () => this.start() }, + { id: 'capture-stop', label: this.t('desktop.toolbar.record_stop'), accelerator: 'CommandOrControl+Alt+R', enabled: false, click: () => this.stop() }, - { id: 'capture-status', label: 'Not recording', enabled: false }, + { id: 'capture-status', label: this.t('desktop.capture.status_idle'), enabled: false }, ] }; } @@ -78,7 +80,7 @@ class DesktopCapture { if (!(await fs.stat(directory)).isDirectory()) throw new Error('Not a folder.'); await fs.access(directory, fs.constants.W_OK); } catch { - await this.notify('The saved capture folder is unavailable. Choose a writable folder again.', true); + await this.notify(this.t('desktop.capture.folder_unavailable'), true); directory = null; } } @@ -90,14 +92,14 @@ class DesktopCapture { if (this.folderSelection) { await this.folderSelection; return this.settings.get(extension); } this.folderSelection = (async () => { const result = await dialog.showOpenDialog(this.win, { - title: extension === 'png' ? 'Choose screenshot folder' : 'Choose recording folder', - message: 'Future captures save here automatically. Change this in StandTerm > Capture Settings.', + title: this.t(extension === 'png' ? 'desktop.capture.choose_screenshot_folder' : 'desktop.capture.choose_recording_folder'), + message: this.t('desktop.capture.folder_policy'), defaultPath: this.settings.get(extension) || this.directory, - properties: ['openDirectory', 'createDirectory'], buttonLabel: 'Use this folder', + properties: ['openDirectory', 'createDirectory'], buttonLabel: this.t('desktop.capture.use_folder'), }); if (result.canceled || !result.filePaths?.[0]) return null; const directory = result.filePaths[0]; - if (!(await fs.stat(directory)).isDirectory()) throw new Error('Choose a folder.'); + if (!(await fs.stat(directory)).isDirectory()) throw new Error(this.t('desktop.capture.folder_required')); await fs.access(directory, fs.constants.W_OK); this.settings.set(extension, directory); return directory; @@ -108,9 +110,13 @@ class DesktopCapture { async configure() { try { const { response } = await dialog.showMessageBox(this.win, { - title: 'Capture Settings', message: 'Desktop capture folders', - detail: `Screenshots: ${this.settings.get('png') || 'Choose on first save'}\nRecordings: ${this.settings.get('webm') || 'Choose on first recording'}\n\nPNG screenshots and silent WebM recordings are saved automatically.`, - buttons: ['Done', 'Screenshot folder...', 'Recording folder...', 'Open screenshot folder', 'Open recording folder'], + title: this.t('desktop.capture.settings_title'), message: this.t('desktop.capture.settings_message'), + detail: this.t('desktop.capture.settings_detail', { + screenshot_folder: this.settings.get('png') || this.t('desktop.capture.first_screenshot'), + recording_folder: this.settings.get('webm') || this.t('desktop.capture.first_recording'), + }), + buttons: ['done', 'change_screenshot_folder', 'change_recording_folder', 'open_screenshot_folder', 'open_recording_folder'] + .map(key => this.t(`desktop.capture.${key}`)), defaultId: 0, cancelId: 0, noLink: true, }); if (response === 1 || response === 2) await this.chooseDirectory(response === 1 ? 'png' : 'webm'); @@ -118,7 +124,7 @@ class DesktopCapture { const directory = this.settings.get(response === 3 ? 'png' : 'webm'); if (!directory) return; const error = await shell.openPath(directory); - if (error) throw new Error('Could not open the capture folder.'); + if (error) throw new Error(this.t('desktop.capture.open_folder_failed')); } } catch (error) { await this.notify(error.message, true); } } @@ -130,7 +136,7 @@ class DesktopCapture { let output; try { if (this.win.isDestroyed() || !this.win.isVisible() || this.win.isMinimized()) { - throw new Error('Show the StandTerm window before capturing it.'); + throw new Error(this.t('desktop.capture.show_window_capture')); } // Capture before opening a dialog so the saved image is the requested view. let image; @@ -143,12 +149,12 @@ class DesktopCapture { this.onDiagnostic({ width, height, visible: this.win.isVisible(), minimized: this.win.isMinimized(), focused: this.win.isFocused() }); } - throw new Error('The terminal view could not be captured. Bring StandTerm to the foreground and retry. Window state is available in Diagnostics.', { cause: error }); + throw new Error(this.t('desktop.capture.capture_failed'), { cause: error }); } const png = image.toPNG(); if (kind === 'clipboard') { await clipboard.write([new ClipboardItem({ 'image/png': new Blob([png], { type: 'image/png' }) })]); - await this.notify('Screenshot copied to the clipboard.'); + await this.notify(this.t('desktop.capture.screenshot_copied')); return { copied: true }; } destination = destination || await this.chooseFile('png'); @@ -158,11 +164,11 @@ class DesktopCapture { await output.write(png.subarray(offset, offset + MAX_CHUNK_BYTES)); } await output.finish(); - await this.notify(`Screenshot saved to:\n${destination}`); + await this.notify(this.t('desktop.capture.screenshot_saved', { path: destination })); return { destination }; } catch (error) { await output?.close().catch(() => {}); - await this.notify(`${error.message}${output?.partial ? `\nUnfinished file retained at:\n${output.partial}` : ''}`, true); + await this.notify(`${error.message}${output?.partial ? '\n' + this.t('desktop.capture.partial_file', { path: output.partial }) : ''}`, true); return { error: true }; } finally { this.screenshotBusy = false; this.update(); } } @@ -180,11 +186,11 @@ class DesktopCapture { this.job = job; try { destination = destination || await this.chooseFile('webm'); - if (!destination) { this.job = null; this.state = 'idle'; this.update(); return null; } + if (!destination) { job.result = { canceled: true }; this.job = null; this.state = 'idle'; this.update(); return null; } if (this.win.isDestroyed() || !this.win.isVisible() || this.win.isMinimized()) { - throw new Error('Show the StandTerm window before recording it.'); + throw new Error(this.t('desktop.capture.show_window_recording')); } - if (this.win.isFullScreen()) throw new Error('Leave fullscreen before recording so the recording indicator remains visible.'); + if (this.win.isFullScreen()) throw new Error(this.t('desktop.capture.leave_fullscreen')); job.output = await CaptureFile.create(destination); const isolated = session.fromPartition(this.recorderPartition); const trustedRecorder = contents => contents === job.recorder?.webContents @@ -240,8 +246,7 @@ class DesktopCapture { return { destination, mimeType: job.mimeType }; } catch (error) { job.error = error; - await this.finish(job, false); - return { error: true }; + return this.finish(job, false); } } @@ -267,9 +272,12 @@ class DesktopCapture { } async stop() { - if (this.state === 'starting') await this.starting; - if (!this.job) return null; + if (this.state === 'starting') { + const started = await this.starting; + if (started?.error) return started; + } if (this.stopping) return this.stopping; + if (!this.job) return null; const job = this.job; this.state = 'stopping'; clearInterval(job.timer); @@ -314,28 +322,55 @@ class DesktopCapture { if (publish) destination = await job.output.finish(); } catch (error) { job.error = error; } await job.output?.close().catch(error => { job.error = job.error || error; }); + const result = job.result = job.error + ? { error: true, message: job.error.message || String(job.error), partial: job.output?.partial, destination: job.output?.destination } + : { destination, bytes: job.output.bytes, mimeType: job.mimeType }; try { - if (job.error) { - await this.notify(`${job.error.message || String(job.error)}${job.output?.partial ? `\nUnfinished recording retained at:\n${job.output.partial}` : ''}`, true); - return { error: true, partial: job.output?.partial }; - } - await this.notify(`Recording saved to:\n${destination}`); - return { destination, bytes: job.output.bytes, mimeType: job.mimeType }; + await this.notify(result.error ? this.recordingError(result) : this.t('desktop.capture.recording_saved', { path: destination }), !!result.error); + } catch { /* Notification failure must not change the file outcome or repeat publication. */ } finally { if (this.job === job) { this.job = null; this.state = 'idle'; this.update(); } } + return result; + } + + recordingError(result) { + return (result.message || this.t('desktop.capture.save_unconfirmed')) + + (result.partial ? '\n' + this.t('desktop.capture.partial_recording', { path: result.partial }) : '') + + (result.destination ? '\n' + this.t('desktop.capture.requested_destination', { path: result.destination }) : ''); } async confirmStop(action) { + if (!['close', 'quit'].includes(action) || this.confirming) return false; if (!this.active) return true; - const { response } = await dialog.showMessageBox(this.win, { - type: 'question', title: 'StandTerm recording is active', - message: `Stop and save the recording before ${action}?`, - buttons: ['Keep recording', 'Stop and save'], defaultId: 0, cancelId: 0, - }); - if (response !== 1) return false; - await this.stop(); - return true; + const job = this.job; + this.confirming = true; + try { + const { response } = await dialog.showMessageBox(this.win, { + type: 'question', title: this.t('desktop.capture.confirm_title'), + message: this.t(action === 'close' ? 'desktop.capture.confirm_close' : 'desktop.capture.confirm_quit'), + buttons: [this.t('desktop.capture.keep_recording'), this.t('desktop.toolbar.record_stop')], defaultId: 0, cancelId: 0, + }); + if (response !== 1 || this.win.isDestroyed() || (this.job && this.job !== job)) return false; + let result; + try { result = job?.result || await this.stop() || job?.result; } + catch (error) { + result = { error: true, message: error.message || String(error), partial: job?.output?.partial, destination: job?.output?.destination }; + } + if (this.job && this.job !== job) return false; + if (result?.canceled === true || (!result?.error && typeof result?.destination === 'string')) return true; + if (!this.win.isDestroyed()) { + if (this.win.isMinimized()) this.win.restore(); + this.win.show(); this.win.focus(); + await dialog.showMessageBox(this.win, { + type: 'error', title: this.t('desktop.capture.save_failed_title'), message: this.t('desktop.capture.save_failed_message'), + detail: this.t('desktop.capture.save_failed_detail', { error: this.recordingError(result || {}) }), + buttons: [this.t('desktop.common.ok')], defaultId: 0, cancelId: 0, noLink: true, + }); + } + return false; + } catch { return false; } + finally { this.confirming = false; } } } diff --git a/desktop/main.cjs b/desktop/main.cjs index a268f11..47881ab 100644 --- a/desktop/main.cjs +++ b/desktop/main.cjs @@ -335,7 +335,7 @@ async function start() { if (!win.isDestroyed()) win.setTitle(`${captureTitle ? `[${captureTitle}] ` : ''}${MODES[mode]} - ${pageTitle}`); }; capture = new DesktopCapture(win, { - contents, + contents, t, onDiagnostic: state => diagnostics.write('capture_failed', state), onChange: (value, state) => { captureTitle = value; updateTitle(); toolbar?.send(state); }, notify: async (message, error) => toolbar?.notify(message, error), @@ -477,11 +477,11 @@ async function start() { }); contents.on('will-attach-webview', event => event.preventDefault()); win.on('close', event => { - if (capture.active) { + if (capture.active || capture.confirming || closePending) { event.preventDefault(); if (!quitting && !closePending) { closePending = true; - capture.confirmStop('closing the window').then(allowed => { + capture.confirmStop('close').then(allowed => { closePending = false; if (allowed) win.close(); }).catch(() => { closePending = false; }); @@ -572,8 +572,8 @@ app.on('before-quit', event => { diagnostics.write('shutdown'); (async () => { if (!await confirmSetupQuit()) { restartRequest = null; quitting = false; return; } - if (capture?.active) { - const allowed = smoke ? (await capture.stop(), true) : await capture.confirmStop('quitting StandTerm'); + if (capture?.active || capture?.confirming) { + const allowed = smoke ? (await capture.stop(), true) : await capture.confirmStop('quit').catch(() => false); if (!allowed) { restartRequest = null; quitting = false; return; } } cancelSetup(); diff --git a/desktop/messages.js b/desktop/messages.js index f76b232..fc8de4f 100644 --- a/desktop/messages.js +++ b/desktop/messages.js @@ -30,6 +30,50 @@ "desktop.browser_access.menu": "Browser access", "desktop.browser_access.open": "Open in browser\u2026", "desktop.browser_access.opened": "Authorization link opened in the default browser.", + "desktop.capture.capture_failed": "Could not capture the terminal view. Bring StandTerm to the foreground and try again. Check Diagnostics if it still fails.", + "desktop.capture.change_recording_folder": "Recording folder\u2026", + "desktop.capture.change_screenshot_folder": "Screenshot folder\u2026", + "desktop.capture.choose_recording_folder": "Choose recording folder", + "desktop.capture.choose_screenshot_folder": "Choose screenshot folder", + "desktop.capture.confirm_close": "Stop and save the recording before closing this window?", + "desktop.capture.confirm_quit": "Stop and save the recording before quitting StandTerm?", + "desktop.capture.confirm_title": "Recording in progress", + "desktop.capture.done": "Done", + "desktop.capture.first_recording": "Choose on first recording", + "desktop.capture.first_screenshot": "Choose on first save", + "desktop.capture.folder_policy": "Files of this capture type save here automatically. If the other capture folder is unset, this choice sets it too. Change either folder in Capture Settings.", + "desktop.capture.folder_required": "Choose a folder.", + "desktop.capture.folder_unavailable": "The saved capture folder is unavailable. Choose a writable folder again.", + "desktop.capture.keep_recording": "Keep window open", + "desktop.capture.leave_fullscreen": "Leave fullscreen before recording so the recording indicator stays visible.", + "desktop.capture.menu": "Capture", + "desktop.capture.open_folder_failed": "Could not open the capture folder.", + "desktop.capture.open_recording_folder": "Open recording folder", + "desktop.capture.open_screenshot_folder": "Open screenshot folder", + "desktop.capture.partial_file": "Partial screenshot kept at:\n{path}", + "desktop.capture.partial_recording": "Partial recording kept at:\n{path}", + "desktop.capture.recording_saved": "Recording saved to:\n{path}", + "desktop.capture.requested_destination": "Requested destination:\n{path}", + "desktop.capture.save_failed_detail": "Review the error and file locations below.\n\n{error}", + "desktop.capture.save_failed_message": "Recording save was not confirmed. This close or quit request was cancelled.", + "desktop.capture.save_failed_title": "Recording save not confirmed", + "desktop.capture.save_unconfirmed": "Could not confirm the recording save result.", + "desktop.capture.screenshot_copied": "Screenshot copied to the clipboard.", + "desktop.capture.screenshot_saved": "Screenshot saved to:\n{path}", + "desktop.capture.settings_detail": "Screenshots: {screenshot_folder}\nRecordings: {recording_folder}\n\nPNG screenshots and WebM recordings save to these folders. Recordings have no audio.", + "desktop.capture.settings_message": "Desktop capture folders", + "desktop.capture.settings_title": "Capture Settings", + "desktop.capture.show_window_capture": "Show the StandTerm window before capturing it.", + "desktop.capture.show_window_recording": "Show the StandTerm window before recording it.", + "desktop.capture.status_idle": "Not recording", + "desktop.capture.status_paused": "Recording paused {time}", + "desktop.capture.status_recording": "Recording {time}", + "desktop.capture.status_saving": "Saving recording\u2026", + "desktop.capture.status_starting": "Starting recording\u2026", + "desktop.capture.title": "StandTerm Capture", + "desktop.capture.title_paused": "PAUSED {time}", + "desktop.capture.title_recording": "REC {time}", + "desktop.capture.use_folder": "Use this folder", "desktop.common.cancel": "Cancel", "desktop.common.ok": "OK", "desktop.diagnostics.backend_mode": "Backend mode", @@ -158,6 +202,50 @@ "desktop.browser_access.menu": "\u700f\u89bd\u5668\u5b58\u53d6", "desktop.browser_access.open": "\u5728\u700f\u89bd\u5668\u958b\u555f\u2026", "desktop.browser_access.opened": "\u5df2\u5728\u9810\u8a2d\u700f\u89bd\u5668\u958b\u555f\u6388\u6b0a\u9023\u7d50\u3002", + "desktop.capture.capture_failed": "\u7121\u6cd5\u64f7\u53d6\u7d42\u7aef\u756b\u9762\u3002\u8acb\u5c07 StandTerm \u5207\u63db\u81f3\u524d\u666f\u5f8c\u518d\u8a66\u4e00\u6b21\uff1b\u82e5\u4ecd\u5931\u6557\uff0c\u8acb\u67e5\u770b\u300c\u8a3a\u65b7\u300d\u3002", + "desktop.capture.change_recording_folder": "\u9304\u5f71\u8cc7\u6599\u593e\u2026", + "desktop.capture.change_screenshot_folder": "\u622a\u5716\u8cc7\u6599\u593e\u2026", + "desktop.capture.choose_recording_folder": "\u9078\u64c7\u9304\u5f71\u8cc7\u6599\u593e", + "desktop.capture.choose_screenshot_folder": "\u9078\u64c7\u622a\u5716\u8cc7\u6599\u593e", + "desktop.capture.confirm_close": "\u8981\u5148\u505c\u6b62\u4e26\u5132\u5b58\u9304\u5f71\uff0c\u518d\u95dc\u9589\u6b64\u8996\u7a97\u55ce\uff1f", + "desktop.capture.confirm_quit": "\u8981\u5148\u505c\u6b62\u4e26\u5132\u5b58\u9304\u5f71\uff0c\u518d\u7d50\u675f StandTerm \u55ce\uff1f", + "desktop.capture.confirm_title": "\u9304\u5f71\u5c1a\u672a\u7d50\u675f", + "desktop.capture.done": "\u5b8c\u6210", + "desktop.capture.first_recording": "\u9996\u6b21\u9304\u5f71\u6642\u9078\u64c7", + "desktop.capture.first_screenshot": "\u9996\u6b21\u5132\u5b58\u6642\u9078\u64c7", + "desktop.capture.folder_policy": "\u6b64\u985e\u64f7\u53d6\u6a94\u6848\u4e4b\u5f8c\u6703\u81ea\u52d5\u5132\u5b58\u65bc\u6b64\u3002\u82e5\u53e6\u4e00\u985e\u64f7\u53d6\u7684\u8cc7\u6599\u593e\u5c1a\u672a\u8a2d\u5b9a\uff0c\u4e5f\u6703\u63a1\u7528\u6b64\u8cc7\u6599\u593e\u3002\u4e4b\u5f8c\u53ef\u5728\u300c\u756b\u9762\u64f7\u53d6\u8a2d\u5b9a\u300d\u5206\u5225\u8b8a\u66f4\u3002", + "desktop.capture.folder_required": "\u8acb\u9078\u64c7\u8cc7\u6599\u593e\u3002", + "desktop.capture.folder_unavailable": "\u5df2\u5132\u5b58\u7684\u64f7\u53d6\u8cc7\u6599\u593e\u7121\u6cd5\u4f7f\u7528\u3002\u8acb\u91cd\u65b0\u9078\u64c7\u53ef\u5beb\u5165\u7684\u8cc7\u6599\u593e\u3002", + "desktop.capture.keep_recording": "\u4fdd\u6301\u8996\u7a97\u958b\u555f", + "desktop.capture.leave_fullscreen": "\u8acb\u5148\u96e2\u958b\u5168\u87a2\u5e55\u518d\u958b\u59cb\u9304\u5f71\uff0c\u8b93\u9304\u5f71\u6307\u793a\u4fdd\u6301\u53ef\u898b\u3002", + "desktop.capture.menu": "\u756b\u9762\u64f7\u53d6", + "desktop.capture.open_folder_failed": "\u7121\u6cd5\u958b\u555f\u64f7\u53d6\u8cc7\u6599\u593e\u3002", + "desktop.capture.open_recording_folder": "\u958b\u555f\u9304\u5f71\u8cc7\u6599\u593e", + "desktop.capture.open_screenshot_folder": "\u958b\u555f\u622a\u5716\u8cc7\u6599\u593e", + "desktop.capture.partial_file": "\u672a\u5b8c\u6210\u7684\u622a\u5716\u5df2\u4fdd\u7559\u65bc\uff1a\n{path}", + "desktop.capture.partial_recording": "\u672a\u5b8c\u6210\u7684\u9304\u5f71\u5df2\u4fdd\u7559\u65bc\uff1a\n{path}", + "desktop.capture.recording_saved": "\u9304\u5f71\u5df2\u5132\u5b58\u81f3\uff1a\n{path}", + "desktop.capture.requested_destination": "\u9810\u5b9a\u5132\u5b58\u4f4d\u7f6e\uff1a\n{path}", + "desktop.capture.save_failed_detail": "\u8acb\u6aa2\u67e5\u4ee5\u4e0b\u932f\u8aa4\u8207\u6a94\u6848\u4f4d\u7f6e\u3002\n\n{error}", + "desktop.capture.save_failed_message": "\u7121\u6cd5\u78ba\u8a8d\u9304\u5f71\u5df2\u5132\u5b58\u3002\u5df2\u53d6\u6d88\u672c\u6b21\u95dc\u9589\u6216\u7d50\u675f\u64cd\u4f5c\u3002", + "desktop.capture.save_failed_title": "\u7121\u6cd5\u78ba\u8a8d\u9304\u5f71\u5132\u5b58\u7d50\u679c", + "desktop.capture.save_unconfirmed": "\u7121\u6cd5\u78ba\u8a8d\u9304\u5f71\u5132\u5b58\u7d50\u679c\u3002", + "desktop.capture.screenshot_copied": "\u5df2\u5c07\u622a\u5716\u8907\u88fd\u81f3\u526a\u8cbc\u7c3f\u3002", + "desktop.capture.screenshot_saved": "\u622a\u5716\u5df2\u5132\u5b58\u81f3\uff1a\n{path}", + "desktop.capture.settings_detail": "\u622a\u5716\uff1a{screenshot_folder}\n\u9304\u5f71\uff1a{recording_folder}\n\nPNG \u622a\u5716\u8207 WebM \u9304\u5f71\u6703\u5132\u5b58\u81f3\u4e0a\u8ff0\u8cc7\u6599\u593e\u3002\u9304\u5f71\u4e0d\u542b\u97f3\u8a0a\u3002", + "desktop.capture.settings_message": "Desktop \u756b\u9762\u64f7\u53d6\u8cc7\u6599\u593e", + "desktop.capture.settings_title": "\u756b\u9762\u64f7\u53d6\u8a2d\u5b9a", + "desktop.capture.show_window_capture": "\u8acb\u5148\u986f\u793a StandTerm \u8996\u7a97\uff0c\u518d\u64f7\u53d6\u756b\u9762\u3002", + "desktop.capture.show_window_recording": "\u8acb\u5148\u986f\u793a StandTerm \u8996\u7a97\uff0c\u518d\u958b\u59cb\u9304\u5f71\u3002", + "desktop.capture.status_idle": "\u672a\u5728\u9304\u5f71", + "desktop.capture.status_paused": "\u9304\u5f71\u5df2\u66ab\u505c {time}", + "desktop.capture.status_recording": "\u9304\u5f71\u4e2d {time}", + "desktop.capture.status_saving": "\u6b63\u5728\u5132\u5b58\u9304\u5f71\u2026", + "desktop.capture.status_starting": "\u6b63\u5728\u6e96\u5099\u9304\u5f71\u2026", + "desktop.capture.title": "StandTerm \u756b\u9762\u64f7\u53d6", + "desktop.capture.title_paused": "\u66ab\u505c {time}", + "desktop.capture.title_recording": "\u9304\u5f71 {time}", + "desktop.capture.use_folder": "\u4f7f\u7528\u6b64\u8cc7\u6599\u593e", "desktop.common.cancel": "\u53d6\u6d88", "desktop.common.ok": "\u78ba\u5b9a", "desktop.diagnostics.backend_mode": "\u5f8c\u7aef\u6a21\u5f0f", diff --git a/desktop/test/capture-close.test.cjs b/desktop/test/capture-close.test.cjs new file mode 100644 index 0000000..45e6593 --- /dev/null +++ b/desktop/test/capture-close.test.cjs @@ -0,0 +1,65 @@ +'use strict'; + +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const vm = require('node:vm'); +const { EventEmitter } = require('node:events'); + +// Exercise the actual close handler without starting Core or an Electron window. +const source = fs.readFileSync(path.join(__dirname, '..', 'main.cjs'), 'utf8'); +const registration = source.match(/win\.on\('close', event => \{[\s\S]*?\n \}\);/)[0]; + +function fixture() { + const win = new EventEmitter(), calls = []; + let resolve; + const capture = { active: true, confirming: false, confirmStop: action => { + calls.push(action); capture.confirming = true; + return new Promise(done => { resolve = value => { capture.confirming = false; done(value); }; }); + } }; + const context = vm.createContext({ win, capture, closePending: false, quitting: false, tray: false, smoke: false }); + let closed = 0, hidden = 0; + const close = () => { + let prevented = false; + win.emit('close', { preventDefault: () => { prevented = true; } }); + if (!prevented) closed++; + return prevented; + }; + win.close = close; win.hide = () => hidden++; + vm.runInContext(registration, context); + return { capture, context, calls, close, resolve: value => resolve(value), + closed: () => closed, hidden: () => hidden, settle: () => new Promise(setImmediate) }; +} + +test('clearing the recording job cannot bypass a pending close failure notice', async () => { + const f = fixture(); + assert.equal(f.close(), true); + f.capture.active = false; + assert.equal(f.close(), true); + assert.deepEqual(f.calls, ['close']); + assert.equal(f.closed(), 0); + f.resolve(false); await f.settle(); + assert.equal(f.closed(), 0); + assert.equal(f.context.closePending, false); + assert.equal(f.close(), false, 'a later explicit close remains available'); +}); + +test('closing during a quit error notice neither hides nor destroys the window', () => { + const f = fixture(); + f.capture.active = false; f.capture.confirming = true; + f.context.quitting = true; f.context.tray = true; + assert.equal(f.close(), true); + assert.equal(f.closed(), 0); + assert.equal(f.hidden(), 0); + assert.deepEqual(f.calls, []); +}); + +test('a confirmed save permits exactly one subsequent close', async () => { + const f = fixture(); + assert.equal(f.close(), true); + f.capture.active = false; + f.resolve(true); await f.settle(); + assert.equal(f.closed(), 1); + assert.equal(f.context.closePending, false); +}); diff --git a/desktop/test/capture-file.test.cjs b/desktop/test/capture-file.test.cjs index 6e666fb..4956a73 100644 --- a/desktop/test/capture-file.test.cjs +++ b/desktop/test/capture-file.test.cjs @@ -5,6 +5,7 @@ const assert = require('node:assert/strict'); const fs = require('node:fs/promises'); const path = require('node:path'); const os = require('node:os'); +const vm = require('node:vm'); const { CaptureFile, MAX_CHUNK_BYTES } = require('../capture-file.cjs'); test('capture publishes exact bytes without overwriting existing files', async () => { @@ -22,6 +23,27 @@ test('capture publishes exact bytes without overwriting existing files', async ( assert.equal(await fs.readFile(target, 'utf8'), 'firstsecond'); }); +test('failed partial cleanup retains both published and partial paths without another publication', async () => { + const directory = await fs.mkdtemp(path.join(os.tmpdir(), 'standterm-capture-unlink-')); + const target = path.join(directory, 'test.webm'); + let links = 0; + const api = { exports: {} }; + vm.runInNewContext(await fs.readFile(path.join(__dirname, '..', 'capture-file.cjs'), 'utf8'), { + Uint8Array, module: api, + require: name => name === 'node:fs/promises' ? { ...fs, + link: async (...args) => { links++; return fs.link(...args); }, + unlink: async () => { throw new Error('Fixture partial cleanup failure'); }, + } : require(name), + }); + const output = await api.exports.CaptureFile.create(target); + await output.write(Buffer.from('recording bytes')); + await assert.rejects(output.finish(), /Fixture partial cleanup failure/); + assert.equal(await fs.readFile(target, 'utf8'), 'recording bytes'); + assert.equal(await fs.readFile(output.partial, 'utf8'), 'recording bytes'); + assert.equal(links, 1); + assert.equal(output.handle, null); +}); + test('capture retains partial output on a publish race or empty recording', async () => { const directory = await fs.mkdtemp(path.join(os.tmpdir(), 'standterm-capture-race-')); const target = path.join(directory, 'test.webm'); diff --git a/desktop/test/capture-lifecycle.test.cjs b/desktop/test/capture-lifecycle.test.cjs new file mode 100644 index 0000000..95c9d75 --- /dev/null +++ b/desktop/test/capture-lifecycle.test.cjs @@ -0,0 +1,145 @@ +'use strict'; + +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const vm = require('node:vm'); +const { createRequire } = require('node:module'); +const { create } = require('../i18n.js'); + +function fixture({ locale = 'en', failure = true, notifyFailure = false, decision = null } = {}) { + const filename = path.join(__dirname, '..', 'capture.cjs'); + const localRequire = createRequire(filename); + const dialogs = [], notices = [], calls = []; + const dialog = { showMessageBox: async (_win, options) => { + dialogs.push(options); + if (options.type === 'question') return decision ? decision() : { response: 1 }; + return { response: 0 }; + } }; + const context = { module: { exports: {} }, __dirname: path.dirname(filename), + setTimeout, clearTimeout, setInterval, clearInterval, + require: name => name === 'electron' ? { Menu: { getApplicationMenu: () => null }, dialog } : localRequire(name) }; + vm.runInNewContext(fs.readFileSync(filename, 'utf8'), context); + const capture = Object.create(context.module.exports.DesktopCapture.prototype); + const output = { partial: '/tmp/capture .partial', destination: '/tmp/capture .webm', bytes: 5, + finish: async () => { + calls.push('publish'); + if (failure) throw new Error('Fixture disk error'); + output.partial = null; return output.destination; + }, close: async () => { calls.push('close-output'); } }; + const job = { startedAt: Date.now(), output, mimeType: 'video/webm', + recorder: { isDestroyed: () => false, destroy: () => calls.push('destroy-recorder'), + webContents: { executeJavaScript: async script => { + calls.push(script); + return script === 'window.recorder.drain()' ? { chunks: [] } : undefined; + } } } }; + Object.assign(capture, { t: create(locale).t, state: 'recording', job, + onChange: () => {}, notify: async (message, error) => { + notices.push({ message, error }); + if (notifyFailure) throw new Error('Fixture notice unavailable'); + }, + win: { isDestroyed: () => false, isMinimized: () => true, + restore: () => calls.push('restore'), show: () => calls.push('show'), focus: () => calls.push('focus') }, + }); + return { capture, job, output, dialogs, notices, calls, dialog }; +} + +test('failed save cancels this close or quit and leaves a readable error with retained paths', async () => { + for (const locale of ['en', 'zh-TW']) for (const action of ['close', 'quit']) { + const f = fixture({ locale }); + assert.equal(await f.capture.confirmStop(action), false); + assert.equal(f.capture.state, 'idle'); + assert.equal(f.calls.filter(call => call === 'publish').length, 1); + assert.equal(f.dialogs[0].defaultId, 0); + assert.equal(f.dialogs[0].cancelId, 0); + assert.equal(f.dialogs[0].message, f.capture.t(`desktop.capture.confirm_${action}`)); + const error = f.dialogs.at(-1); + assert.equal(error.type, 'error'); + assert.ok(error.detail.includes('Fixture disk error')); + assert.ok(error.detail.includes(f.output.partial)); + assert.ok(error.detail.includes(f.output.destination)); + assert.ok(f.calls.includes('show')); + assert.equal(await f.capture.confirmStop(action), true, 'a later explicit exit is not permanently blocked'); + } +}); + +test('cancel keeps the recording untouched and a successful save permits closing', async () => { + const canceled = fixture({ decision: async () => ({ response: 0 }) }); + assert.equal(await canceled.capture.confirmStop('close'), false); + assert.equal(canceled.capture.state, 'recording'); + assert.deepEqual(canceled.calls, []); + const saved = fixture({ failure: false }); + assert.equal(await saved.capture.confirmStop('quit'), true); + assert.equal(saved.capture.state, 'idle'); + assert.equal(saved.dialogs.length, 1); + assert.equal(saved.calls.filter(call => call === 'publish').length, 1); +}); + +test('failure while a confirmation is waiting cannot be lost when the job is cleared', async () => { + let answer; + const f = fixture({ decision: () => new Promise(resolve => { answer = resolve; }) }); + const confirming = f.capture.confirmStop('quit'); + assert.equal((await f.capture.stop()).error, true); + assert.equal(f.capture.job, null); + answer({ response: 1 }); + assert.equal(await confirming, false); + assert.equal(f.calls.filter(call => call === 'publish').length, 1); + assert.ok(f.dialogs.at(-1).detail.includes(f.output.partial)); +}); + +test('stop retains startup failure and confirmation distinguishes canceled startup', async () => { + const f = fixture(); + f.capture.state = 'idle'; f.capture.job = null; + f.capture.chooseFile = async () => { throw new Error('Fixture startup error'); }; + const starting = f.capture.start(); + const [result, allowed] = await Promise.all([f.capture.stop(), f.capture.confirmStop('quit')]); + assert.equal(result.error, true); + assert.equal(result, await starting); + assert.equal(result.partial, undefined); + assert.equal(allowed, false); + assert.ok(f.dialogs.at(-1).detail.includes('Fixture startup error')); + const canceled = fixture(); + canceled.capture.state = 'idle'; canceled.capture.job = null; + canceled.capture.chooseFile = async () => null; + const canceling = canceled.capture.start(); + assert.equal(await canceled.capture.confirmStop('quit'), true); + assert.equal(await canceling, null); +}); + +test('notification failure never changes the saved result or permits a failed-save exit', async () => { + for (const failure of [false, true]) { + const f = fixture({ failure, notifyFailure: true }); + assert.equal(await f.capture.confirmStop('quit'), !failure); + assert.equal(f.calls.filter(call => call === 'publish').length, 1); + if (failure) assert.ok(f.dialogs.at(-1).detail.includes('Fixture disk error')); + } +}); + +test('an unavailable error dialog still cancels closing after a failed save', async () => { + const f = fixture(); + f.dialog.showMessageBox = async (_win, options) => { + if (options.type === 'question') return { response: 1 }; + throw new Error('Fixture dialog unavailable'); + }; + assert.equal(await f.capture.confirmStop('quit'), false); + assert.equal(f.capture.state, 'idle'); +}); + +test('concurrent stops share one publication and a stale confirmation never stops a new job', { timeout: 3000 }, async () => { + const f = fixture({ failure: false }); + const [first, second] = await Promise.all([f.capture.stop(), f.capture.stop()]); + assert.equal(first, second); + assert.equal(f.calls.filter(call => call === 'publish').length, 1); + let answer; + const stale = fixture({ failure: false, decision: () => new Promise(resolve => { answer = resolve; }) }); + const confirming = stale.capture.confirmStop('close'); + assert.equal(await stale.capture.confirmStop('quit'), false); + await stale.capture.stop(); + const replacement = {}; + stale.capture.job = replacement; stale.capture.state = 'recording'; + answer({ response: 1 }); + assert.equal(await confirming, false); + assert.equal(stale.capture.job, replacement); + assert.equal(stale.calls.filter(call => call === 'publish').length, 1); +}); diff --git a/desktop/test/capture-shutdown.test.cjs b/desktop/test/capture-shutdown.test.cjs new file mode 100644 index 0000000..38f9982 --- /dev/null +++ b/desktop/test/capture-shutdown.test.cjs @@ -0,0 +1,156 @@ +'use strict'; + +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const vm = require('node:vm'); +const { EventEmitter } = require('node:events'); + +function fixture(confirmStop, restartRequest = { action: 'recover' }) { + const calls = { confirm: [], cancelSetup: 0, stopBackend: 0, resetBrowser: 0, + flushBrowser: 0, queue: [], relaunch: 0, exit: [], errors: [], destroyTray: 0 }; + const forbidden = name => () => { throw new Error(`Unexpected fixture operation: ${name}`); }; + const app = new EventEmitter(); + Object.assign(app, { + isPackaged: false, + commandLine: { getSwitchValue: () => '', appendSwitch: () => {} }, + setName: () => {}, setAppUserModelId: () => {}, enableSandbox: () => {}, + getPath: () => path.join(__dirname, 'unused-profile'), getVersion: () => 'fixture', + requestSingleInstanceLock: () => true, + whenReady: () => new Promise(() => {}), + quit: forbidden('app.quit'), + relaunch: () => { calls.relaunch++; }, + exit: code => { calls.exit.push(code); }, + }); + const processStub = new EventEmitter(); + Object.assign(processStub, { platform: 'win32', argv: ['node', 'main.cjs'], env: {} }); + const electron = { app, + BrowserWindow: forbidden('BrowserWindow'), WebContentsView: forbidden('WebContentsView'), + dialog: { showErrorBox: (title, message) => { calls.errors.push({ title, message }); } }, + }; + const modules = { + './installer.cjs': { installerRequest: () => null }, + './desktop-mode.cjs': { APP_ID: 'fixture', desktopMode: () => 'windows' }, + './language.cjs': { createLanguage: () => ({ t: key => key, locale: 'en' }) }, + './diagnostics.cjs': { createDiagnostics: () => ({ write: () => {} }) }, + './setup.cjs': { confirmSetupQuit: async () => true, + cancelSetup: () => { calls.cancelSetup++; } }, + './backend-stop.cjs': { stopOwnedBackend: async () => { calls.stopBackend++; } }, + './browser-session.cjs': { resetBrowserAuthentication: async () => { calls.resetBrowser++; } }, + }; + const context = vm.createContext({ + process: processStub, console, Buffer, URL, setTimeout, clearTimeout, + __dirname: path.resolve(__dirname, '..'), + require: name => { + if (name === 'electron') return electron; + if (Object.hasOwn(modules, name)) return modules[name]; + if (name === 'node:child_process') return { spawn: forbidden('spawn') }; + if (name === 'node:http') return { request: forbidden('http.request') }; + if (name === 'node:fs') return new Proxy({}, { get: (_target, method) => forbidden(`fs.${String(method)}`) }); + if (name.startsWith('./')) return {}; + return require(name); + }, + }); + const source = fs.readFileSync(path.join(__dirname, '..', 'main.cjs'), 'utf8'); + vm.runInContext('(function () {\n' + source + ` + globalThis.injectShutdownState = state => { + capture = state.capture; + child = state.child; + desktopSession = state.desktopSession; + coreStore = state.coreStore; + tray = state.tray; + restartRequest = state.restartRequest; + }; + globalThis.readShutdownState = () => ({ quitting, stopped, restartRequest }); + })()`, context); + context.injectShutdownState({ + capture: { active: true, confirmStop: async action => { + calls.confirm.push(action); + return confirmStop(); + } }, + child: {}, + desktopSession: { flushStorageData: () => { calls.flushBrowser++; } }, + coreStore: { queue: async action => { calls.queue.push(action); } }, + tray: { destroy: () => { calls.destroyTray++; } }, + restartRequest, + }); + let prevented = 0; + return { calls, state: () => context.readShutdownState(), + quit: () => app.emit('before-quit', { preventDefault: () => { prevented++; } }), + prevented: () => prevented, + settle: () => new Promise(setImmediate), + }; +} + +function assertKeptRunning(f) { + assert.equal(f.calls.cancelSetup, 0); + assert.equal(f.calls.stopBackend, 0); + assert.equal(f.calls.resetBrowser, 0); + assert.equal(f.calls.flushBrowser, 0); + assert.deepEqual(f.calls.queue, []); + assert.equal(f.calls.relaunch, 0); + assert.deepEqual(f.calls.exit, []); + assert.equal(f.calls.destroyTray, 0); + assert.equal(f.state().quitting, false); + assert.equal(f.state().stopped, false); + assert.equal(f.state().restartRequest, null); +} + +test('cancelled recording confirmation keeps Desktop running and clears restart intent', async () => { + const f = fixture(async () => false); + f.quit(); + await f.settle(); + assert.equal(f.prevented(), 1); + assert.equal(f.calls.confirm.length, 1); + assertKeptRunning(f); + f.quit(); + await f.settle(); + assert.equal(f.calls.confirm.length, 2); + assertKeptRunning(f); +}); + +test('rejected recording confirmation keeps Desktop running and clears restart intent', async () => { + const f = fixture(async () => { throw new Error('Synthetic confirmation failure'); }); + f.quit(); + await f.settle(); + assert.equal(f.prevented(), 1); + assert.equal(f.calls.confirm.length, 1); + assertKeptRunning(f); + f.quit(); + await f.settle(); + assert.equal(f.calls.confirm.length, 2); + assertKeptRunning(f); +}); + +for (const restartRequest of [null, { action: 'recover' }]) { + test(`confirmed recording shutdown ${restartRequest ? 'relaunches' : 'exits'} once despite duplicate quit events`, async () => { + let resolveConfirmation; + const confirmation = new Promise(resolve => { resolveConfirmation = resolve; }); + const f = fixture(() => confirmation, restartRequest); + f.quit(); + await f.settle(); + f.quit(); + assert.equal(f.state().quitting, true); + assert.equal(f.calls.confirm.length, 1); + assert.equal(f.calls.stopBackend, 0); + assert.deepEqual(f.calls.exit, []); + resolveConfirmation(true); + await f.settle(); + assert.equal(f.calls.cancelSetup, 1); + assert.equal(f.calls.stopBackend, 1); + assert.equal(f.calls.resetBrowser, 1); + assert.equal(f.calls.flushBrowser, 1); + assert.deepEqual(f.calls.queue, restartRequest ? ['recover'] : []); + assert.equal(f.calls.relaunch, restartRequest ? 1 : 0); + assert.deepEqual(f.calls.exit, [0]); + assert.equal(f.calls.destroyTray, 1); + assert.equal(f.state().stopped, true); + assert.deepEqual(f.calls.errors, []); + f.quit(); + await f.settle(); + assert.equal(f.calls.confirm.length, 1); + assert.equal(f.calls.stopBackend, 1); + assert.deepEqual(f.calls.exit, [0]); + }); +} diff --git a/desktop/test/capture-smoke.cjs b/desktop/test/capture-smoke.cjs index 329b2d5..104dcbc 100644 --- a/desktop/test/capture-smoke.cjs +++ b/desktop/test/capture-smoke.cjs @@ -229,7 +229,7 @@ async function run(win, capture, contents = win.webContents) { await new Promise(resolve => setTimeout(resolve, 1200)); dialog.showMessageBox = async () => ({ response: 1 }); try { - assert.equal(await capture.confirmStop('closing the test window'), true); + assert.equal(await capture.confirmStop('close'), true); assert.equal(capture.active, false); assert.ok((await fs.stat(confirmed)).size > 0); } finally { dialog.showMessageBox = originalMessage; } diff --git a/desktop/test/capture-status.test.cjs b/desktop/test/capture-status.test.cjs index 0176cd0..163d670 100644 --- a/desktop/test/capture-status.test.cjs +++ b/desktop/test/capture-status.test.cjs @@ -6,8 +6,9 @@ const fs = require('node:fs'); const path = require('node:path'); const vm = require('node:vm'); const { createRequire } = require('node:module'); +const { create } = require('../i18n.js'); -function fixture() { +function fixture(locale = 'en') { const filename = path.join(__dirname, '..', 'capture.cjs'); const localRequire = createRequire(filename); let now = 13000, status, diagnostic, message; @@ -18,7 +19,7 @@ function fixture() { vm.runInNewContext(fs.readFileSync(filename, 'utf8'), context); const capture = Object.create(context.module.exports.DesktopCapture.prototype); Object.assign(capture, { - state: 'recording', job: { startedAt: 1000 }, + t: create(locale).t, state: 'recording', job: { startedAt: 1000 }, onChange: (_title, state) => { status = state; }, onDiagnostic: state => { diagnostic = state; }, notify: async value => { message = value; }, @@ -48,7 +49,18 @@ test('failed capture reports only window metadata and releases the screenshot ac assert.deepEqual(JSON.parse(JSON.stringify(f.diagnostic())), { width: 624, height: 561, visible: true, minimized: false, focused: true, }); - assert.match(f.message(), /foreground and retry/); + assert.equal(f.message(), create('en').t('desktop.capture.capture_failed')); assert.ok(!f.message().includes('sensitive fixture')); assert.equal(f.capture.screenshotBusy, false); }); + +test('translated status preserves typed recording state and raw clock values', () => { + const f = fixture('zh-TW'); + for (const [state, key] of [['recording', 'status_recording'], ['paused', 'status_paused'], + ['starting', 'status_starting'], ['stopping', 'status_saving'], ['idle', 'status_idle']]) { + f.capture.state = state; + f.capture.update(); + assert.equal(f.status().state, state); + assert.equal(f.status().label, f.capture.t(`desktop.capture.${key}`, { time: '00:12' })); + } +}); diff --git a/desktop/test/capture-ui.test.cjs b/desktop/test/capture-ui.test.cjs new file mode 100644 index 0000000..1c217d3 --- /dev/null +++ b/desktop/test/capture-ui.test.cjs @@ -0,0 +1,58 @@ +'use strict'; + +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const vm = require('node:vm'); +const { createRequire } = require('node:module'); +const { create } = require('../i18n.js'); + +function fixture(locale, response = 0) { + const filename = path.join(__dirname, '..', 'capture.cjs'), localRequire = createRequire(filename); + const dialogs = [], calls = [], folders = { png: '/tmp/PNG {recording_folder}', webm: '/tmp/WebM ' }; + const api = { exports: {} }; + vm.runInNewContext(fs.readFileSync(filename, 'utf8'), { + module: api, __dirname: path.dirname(filename), + require: name => name === 'electron' ? { + dialog: { showMessageBox: async (_win, options) => { dialogs.push(options); return { response }; } }, + shell: { openPath: async directory => { calls.push(directory); return ''; } }, + } : localRequire(name), + }); + const capture = Object.create(api.exports.DesktopCapture.prototype); + Object.assign(capture, { t: create(locale).t, win: {}, settings: { get: extension => folders[extension] }, + chooseDirectory: async extension => calls.push(extension), + screenshot: kind => calls.push(kind), start: () => calls.push('start'), stop: () => calls.push('stop'), + notify: async () => assert.fail('Unexpected capture error'), + }); + return { capture, dialogs, calls, folders }; +} + +test('both languages preserve Capture Settings response indices and raw folder paths', async () => { + for (const locale of ['en', 'zh-TW']) for (const response of [0, 1, 2, 3, 4]) { + const f = fixture(locale, response); + await f.capture.configure(); + const options = f.dialogs[0]; + assert.equal(options.title, f.capture.t('desktop.capture.settings_title')); + assert.equal(options.defaultId, 0); assert.equal(options.cancelId, 0); + assert.equal(options.buttons.length, 5); + assert.equal(options.buttons[1], f.capture.t('desktop.capture.change_screenshot_folder')); + assert.ok(options.detail.includes(f.folders.png)); + assert.ok(options.detail.includes(f.folders.webm)); + assert.deepEqual(f.calls, [[], ['png'], ['webm'], [f.folders.png], [f.folders.webm]][response]); + } +}); + +test('translated capture menu labels keep fixed callbacks, IDs and accelerators', () => { + for (const locale of ['en', 'zh-TW']) { + const f = fixture(locale), menu = f.capture.menu(); + assert.equal(menu.label, f.capture.t('desktop.capture.menu')); + assert.equal(menu.submenu[0].accelerator, 'CommandOrControl+Alt+S'); + assert.equal(menu.submenu[3].id, 'capture-start'); + assert.equal(menu.submenu[4].id, 'capture-stop'); + assert.equal(menu.submenu[4].accelerator, 'CommandOrControl+Alt+R'); + assert.equal(menu.submenu[5].id, 'capture-status'); + for (const item of menu.submenu) if (item.click) item.click(); + assert.deepEqual(f.calls, ['clipboard', 'file', 'start', 'stop']); + } +}); diff --git a/desktop/test/toolbar-i18n-browser-smoke.py b/desktop/test/toolbar-i18n-browser-smoke.py index c4cdf57..723cfdd 100644 --- a/desktop/test/toolbar-i18n-browser-smoke.py +++ b/desktop/test/toolbar-i18n-browser-smoke.py @@ -47,11 +47,15 @@ def run(): const box = el.getBoundingClientRect(); return box.width > 0 && box.x >= 0 && box.right <= innerWidth; })""") for state, key in [('recording', 'record_pause'), ('paused', 'record_resume')]: - page.evaluate("state => fixture.receive({state, label:'Fixture 00:04'})", state) + label = page.evaluate("({locale,state}) => StandTermDesktopI18n.create(locale).t('desktop.capture.status_' + state, {time:'00:04'})", dict(locale=locale, state=state)) + page.evaluate("({state,label}) => fixture.receive({state,label})", dict(state=state, label=label)) expected = page.evaluate("({locale,key}) => StandTermDesktopI18n.create(locale).t('desktop.toolbar.' + key)", dict(locale=locale, key=key)) assert page.locator('#pause').get_attribute('title') == expected assert page.locator('#pause').get_attribute('aria-label') == expected - assert page.locator('#recording-status').text_content() == 'Fixture 00:04' + assert page.locator('#recording-status').text_content() == label + layout = page.evaluate("""() => [...document.querySelectorAll('#capture-tools button')] + .filter(el => !el.hidden).map(el => ({id:el.id, right:el.getBoundingClientRect().right, width:innerWidth}))""") + assert all(item['right'] <= item['width'] for item in layout), (locale, state, layout) page.evaluate("fixture.receive({state:'idle'}); fixture.result = false") page.click('#save') expected = page.evaluate("locale => StandTermDesktopI18n.create(locale).t('desktop.toolbar.action_unavailable')", locale) diff --git a/desktop/toolbar.css b/desktop/toolbar.css index f79d3c6..a301c85 100644 --- a/desktop/toolbar.css +++ b/desktop/toolbar.css @@ -17,5 +17,5 @@ button:disabled { opacity: .4; cursor: default; } #capture-tools { border-left: 1px solid #555; padding-left: 6px; margin-left: auto; } svg { width: 20px; height: 20px; fill: none; stroke: currentColor; stroke-width: 1.7; stroke-linecap: round; stroke-linejoin: round; vertical-align: middle; } #record svg, #stop svg { fill: #ed6666; stroke: #ed6666; } -@media (max-width: 750px) { button { padding: 0 5px; } } +@media (max-width: 750px) { button { padding: 0 3px; } } @media (prefers-reduced-motion: reduce) { #notice-area { transition: none; } } diff --git a/docs/desktop_ui_copy_review.tsv b/docs/desktop_ui_copy_review.tsv index 8eb31cb..4710519 100644 --- a/docs/desktop_ui_copy_review.tsv +++ b/docs/desktop_ui_copy_review.tsv @@ -24,12 +24,12 @@ desktop.menu.pause_agent Pause Agent for current terminal Pause Agent for curren desktop.agent.help_title Give your agent a StandTerm prompt Connect your agent to StandTerm 讓 Agent 連線至 StandTerm Getting-started dialog heading; descriptive text alone grants no access. Keep permission requirements in the accompanying instructions. Do not expand public Agent Info. Desktop and Core may use different languages; keep the English Core control names recognizable alongside Chinese labels. translation-reviewed desktop/main.cjs:agentMenu.showHelp desktop.agent.help_permissions Select the tab where your agent runs. Use Agent Panel to enable access and choose permissions on each tab it may operate. Select the tab where your agent runs. In Agent Panel, enable access and choose permissions for each tab it may operate. 選取 Agent 所在的分頁。在 Agent 面板(Agent Panel)中,逐一為 Agent 可操作的分頁啟用存取並選擇權限。 First paragraph of Agent help; caller tab and permitted target tabs can differ. Keep per-tab authorization, local/SSH distinction and current permission defaults. No implicit cross-tab grants. Desktop and Core may use different languages; keep the English Core control names recognizable alongside Chinese labels. translation-reviewed desktop/main.cjs:agentMenu.showHelp desktop.agent.help_connection Open Agent connection, choose Copy Prompt, and paste it into your agent with the intended task. Follow the environment shown in that dialog. Open Agent connection, choose Copy Prompt, and paste it into your agent with the task. Follow the environment shown in the dialog. 開啟「Agent 連線」(Agent connection),選擇「複製連線指引」(Copy Prompt),再將指引連同任務貼給 Agent。請依對話框顯示的環境操作。 Connection-instructions paragraph; SSH/local setup remains in the preceding paragraph. Match the actual Core control names, including Copy Prompt; retain environment/target guidance. Do not translate or modify the copied prompt payload here. Desktop and Core may use different languages; keep the English Core control names recognizable alongside Chinese labels. translation-reviewed desktop/main.cjs:agentMenu.showHelp -desktop.capture.folder_policy Future captures save here automatically. Change this in StandTerm > Capture Settings. Future files of this capture type save here automatically. Change the folder in Capture Settings. Folder chooser message. PNG and WebM have separate saved folder preferences; this is a retained per-type choice, not a one-off Save As path. The first folder selection also seeds the other format if unset; later choices update only the selected format. Keep extension values png/webm and chosen filesystem path literal. Cancel still returns null; do not change folder persistence or auto-save behavior. Preserve first-choice seeding; do not describe the two preferences as fully independent. proposed desktop/capture.cjs:chooseDirectory:94-103 -desktop.capture.settings_detail Screenshots: {screenshot_folder}\nRecordings: {recording_folder}\n\nPNG screenshots and silent WebM recordings are saved automatically. Screenshots: {screenshot_folder}\nRecordings: {recording_folder}\n\nPNG screenshots and WebM recordings save to these folders. Recordings have no audio. Capture Settings detail. Resolve the two current unset-folder labels separately in the later full catalog pass; this row preserves raw folder paths. {screenshot_folder}, {recording_folder} Keep response indices 0 Done, 1 screenshot-folder chooser, 2 recording-folder chooser, 3 open screenshot folder, 4 open recording folder. defaultId=0 and cancelId=0. No action inference from labels. proposed desktop/capture.cjs:configure:110-121 -desktop.capture.capture_failed The terminal view could not be captured. Bring StandTerm to the foreground and retry. Window state is available in Diagnostics. Could not capture the terminal view. Bring StandTerm to the foreground and try again. Check Diagnostics if it still fails. Screenshot capturePage or empty-image failure before file output begins; retained diagnostic includes window state. Preserve original Error cause and diagnostic fields. No automatic retry, focus manipulation, or capture-scope expansion. proposed desktop/capture.cjs:screenshot:136-146 -desktop.capture.partial_file Unfinished file retained at:\n{path} Partial screenshot kept at:\n{path} Suffix added only if failed screenshot output has a partial path; it is not a successful PNG save notice. {path} Keep the raw path and preceding error. Do not delete or overwrite partial files, promise readability, or automatically retry. Preserve conditional display only when output.partial exists. proposed desktop/capture.cjs:screenshot catch:163-166 -desktop.capture.partial_recording Unfinished recording retained at:\n{path} Partial recording kept at:\n{path} Recording failure suffix after recorder teardown and output close; partial WebM may be incomplete. {path} Keep raw error and path; do not promise playable or recoverable video, delete partial output, publish it as complete, or retry automatically. proposed desktop/capture.cjs:finish:311-324 -desktop.capture.stop_confirm Stop and save the recording before {action}? Stop recording and attempt to save it before {action}? Recording guard for another Desktop action. Current confirmStop awaits stop but returns true even if stop returns an error result; wording cannot guarantee a successful save. {action} Keep numeric response 1 as stop-and-continue; response 0/defaultId=0/cancelId=0 keeps recording and cancels the pending action. Keep capture-start/capture-stop/capture-status IDs and state unchanged. No new retry or save-success gate in this copy-only stage. Policy checkpoint before integration: decide whether save failure should block close/quit; use separate whole-message keys for close and quit if grammar requires it. proposed desktop/capture.cjs:confirmStop:329-339 +desktop.capture.folder_policy Future captures save here automatically. Change this in StandTerm > Capture Settings. Files of this capture type save here automatically. If the other capture folder is unset, this choice sets it too. Change either folder in Capture Settings. 此類擷取檔案之後會自動儲存於此。若另一類擷取的資料夾尚未設定,也會採用此資料夾。之後可在「畫面擷取設定」分別變更。 Folder chooser message. PNG and WebM have separate saved folder preferences; this is a retained per-type choice, not a one-off Save As path. The first folder selection also seeds the other format if unset; later choices update only the selected format. Keep extension values png/webm and chosen filesystem path literal. Cancel still returns null; do not change folder persistence or auto-save behavior. Preserve first-choice seeding; do not describe the two preferences as fully independent. translation-reviewed desktop/capture.cjs:chooseDirectory:94-103 +desktop.capture.settings_detail Screenshots: {screenshot_folder}\nRecordings: {recording_folder}\n\nPNG screenshots and silent WebM recordings are saved automatically. Screenshots: {screenshot_folder}\nRecordings: {recording_folder}\n\nPNG screenshots and WebM recordings save to these folders. Recordings have no audio. 截圖:{screenshot_folder}\n錄影:{recording_folder}\n\nPNG 截圖與 WebM 錄影會儲存至上述資料夾。錄影不含音訊。 Capture Settings detail. Resolve the two current unset-folder labels separately in the later full catalog pass; this row preserves raw folder paths. {screenshot_folder}, {recording_folder} Keep response indices 0 Done, 1 screenshot-folder chooser, 2 recording-folder chooser, 3 open screenshot folder, 4 open recording folder. defaultId=0 and cancelId=0. No action inference from labels. translation-reviewed desktop/capture.cjs:configure:110-121 +desktop.capture.capture_failed The terminal view could not be captured. Bring StandTerm to the foreground and retry. Window state is available in Diagnostics. Could not capture the terminal view. Bring StandTerm to the foreground and try again. Check Diagnostics if it still fails. 無法擷取終端畫面。請將 StandTerm 切換至前景後再試一次;若仍失敗,請查看「診斷」。 Screenshot capturePage or empty-image failure before file output begins; retained diagnostic includes window state. Preserve original Error cause and diagnostic fields. No automatic retry, focus manipulation, or capture-scope expansion. translation-reviewed desktop/capture.cjs:screenshot:136-146 +desktop.capture.partial_file Unfinished file retained at:\n{path} Partial screenshot kept at:\n{path} 未完成的截圖已保留於:\n{path} Suffix added only if failed screenshot output has a partial path; it is not a successful PNG save notice. {path} Keep the raw path and preceding error. Do not delete or overwrite partial files, promise readability, or automatically retry. Preserve conditional display only when output.partial exists. translation-reviewed desktop/capture.cjs:screenshot catch:163-166 +desktop.capture.partial_recording Unfinished recording retained at:\n{path} Partial recording kept at:\n{path} 未完成的錄影已保留於:\n{path} Recording failure suffix after recorder teardown and output close; partial WebM may be incomplete. {path} Keep raw error and path; do not promise playable or recoverable video, delete partial output, publish it as complete, or retry automatically. translation-reviewed desktop/capture.cjs:finish:311-324 +desktop.capture.stop_confirm Stop and save the recording before {action}? Replaced by desktop.capture.confirm_close and desktop.capture.confirm_quit. The caller supplies typed close/quit actions; complete translated sentences no longer interpolate an English action fragment. {action} Remove this message from runtime catalogs. Keep the original source and {action} placeholder here for review history only. The authorized implementation cancels the current close/quit if stop/save is not confirmed; preserve the window, show the error and any partial path, and do not retry automatically. remove desktop/capture.cjs:confirmStop:329-339 desktop.setup.cancel_detail Keep this window open or minimize it to continue. Canceling stops the owned installation processes; prepared files are retained so you can retry on the next launch. Keep this window open or minimize it to continue. Canceling stops this setup’s installation processes and keeps prepared files. Relaunch StandTerm to try again. Close confirmation during preparation. Cancellation targets owned setup processes and waits for cleanup; prepared files remain. Keep button index 0 Keep preparing and 1 Cancel setup; defaultId=0/cancelId=0. Preserve stale-window check and cooperative cancellation. Do not imply all Python processes are stopped or retry is automatic. proposed desktop/setup.cjs:requestSetupCancel:61-76 desktop.setup.install_effects and downloads and installs Python dependencies from your configured package index. Dependencies can execute installation code. Internet access and disk space are required. Python dependencies are downloaded from your configured package index and installed in the private environment. Installation can run code and requires internet access and disk space. Exact dependency-effects fragment in preparePackagedBackend confirmation detail; preceding Core-copy/path/Python requirements and following retention policy remain separate. Keep existing environment/backend selection and raw destination path. Confirm response 1 creates the environment; 0/defaultId/cancelId cancel. Do not imply bundled or verified dependencies, system Python installation, or privilege elevation. proposed desktop/setup.cjs:preparePackagedBackend:262-268 desktop.setup.retention Failed setup is retained for retry. Uninstall keeps environments by default; optional cleanup moves only verified idle venvs to a recovery folder. Core and user data are retained. Failed setup files are kept for retry. Uninstall keeps environments by default. Optional cleanup moves verified idle venvs to recovery and keeps Core files and user data. Exact retention fragment of the initial setup confirmation. The cleanup operation is a recoverable move rather than disk-space reclamation. Keep venv-only cleanup boundary and verified/idle checks. Do not imply uninstall deletes Core or user data, that partial setup is ready, or that retry occurs without a user action. proposed desktop/setup.cjs:preparePackagedBackend:267 @@ -142,3 +142,42 @@ desktop.external_browser.open Open in browser Open in browser 在瀏覽器開啟 desktop.external_browser.failed Could not open the default browser. Could not open the default browser. 無法開啟預設瀏覽器。 External-browser destination confirmation or generic OS open failure. Display text only. Preserve typed action IDs, response indices, origin checks, permissions and focus rules. Escape every translated string and runtime value when rendering scriptless HTML. Only the confirmation may show the raw destination URL and host. Never insert URL, token, grant or cookies into generic notices. Preserve Cancel response/defaultId/cancelId 0, Open response 1, destination validation and user confirmation. TSV detail stores literal \n separators. translation-reviewed desktop/main.cjs:createExternalOpener.confirm/notify desktop.external_browser.failure_hint Check the default HTTP/HTTPS browser in your operating system settings. Check the default HTTP/HTTPS browser in your operating system settings. 請在作業系統設定中檢查 HTTP/HTTPS 的預設瀏覽器。 External-browser destination confirmation or generic OS open failure. Display text only. Preserve typed action IDs, response indices, origin checks, permissions and focus rules. Escape every translated string and runtime value when rendering scriptless HTML. Only the confirmation may show the raw destination URL and host. Never insert URL, token, grant or cookies into generic notices. Preserve Cancel response/defaultId/cancelId 0, Open response 1, destination validation and user confirmation. TSV detail stores literal \n separators. translation-reviewed desktop/main.cjs:createExternalOpener.confirm/notify desktop.diagnostics.native_backend Native Native 原生 Native diagnostics-menu backend display selected when mode is not wsl. Display label only. Keep wsl/windows/macos mode IDs, MODES runtime values and event JSON unchanged. Keep WSL literal. Escape translated values in any HTML renderer. translation-reviewed desktop/diagnostics.cjs:diagnosticsMenu:diagnostics-backend +desktop.capture.title StandTerm Capture StandTerm Capture StandTerm 畫面擷取 Fallback capture notification dialog title. Display text only. Keep capture scope, no-audio recording, png/webm values, IPC/menu IDs, typed states, accelerators and file paths unchanged. Do not dispatch actions by translated labels. translation-reviewed desktop/capture.cjs:DesktopCapture.constructor:notify +desktop.capture.menu Capture Capture 畫面擷取 Native capture submenu title. Display text only. Keep capture scope, no-audio recording, png/webm values, IPC/menu IDs, typed states, accelerators and file paths unchanged. Do not dispatch actions by translated labels. Reuse desktop.toolbar.screenshot_copy/screenshot_save/record_start/record_stop for action labels. Pause/resume already use desktop.toolbar.record_pause/record_resume. Do not add duplicate catalog entries. translation-reviewed desktop/capture.cjs:DesktopCapture.menu +desktop.capture.status_recording Recording {time} Recording {time} 錄影中 {time} Recording state label in the disabled native menu item and toolbar status. {time} Display text only. Keep capture scope, no-audio recording, png/webm values, IPC/menu IDs, typed states, accelerators and file paths unchanged. Do not dispatch actions by translated labels. Use the existing formatted clock unchanged as time. Preserve idle/starting/recording/paused/stopping state values, active-state calculation and menu enablement. translation-reviewed desktop/capture.cjs:DesktopCapture.update +desktop.capture.status_paused Recording paused {time} Recording paused {time} 錄影已暫停 {time} Paused state label; elapsed clock excludes paused duration. {time} Display text only. Keep capture scope, no-audio recording, png/webm values, IPC/menu IDs, typed states, accelerators and file paths unchanged. Do not dispatch actions by translated labels. Use the existing formatted clock unchanged as time. Preserve idle/starting/recording/paused/stopping state values, active-state calculation and menu enablement. translation-reviewed desktop/capture.cjs:DesktopCapture.update +desktop.capture.status_idle Not recording Not recording 未在錄影 Idle menu status; does not mean a previous recording was saved. Display text only. Keep capture scope, no-audio recording, png/webm values, IPC/menu IDs, typed states, accelerators and file paths unchanged. Do not dispatch actions by translated labels. Use the existing formatted clock unchanged as time. Preserve idle/starting/recording/paused/stopping state values, active-state calculation and menu enablement. translation-reviewed desktop/capture.cjs:DesktopCapture.update +desktop.capture.status_starting Starting recording... Starting recording… 正在準備錄影… Starting state while folder selection and recorder preparation may still be pending. Display text only. Keep capture scope, no-audio recording, png/webm values, IPC/menu IDs, typed states, accelerators and file paths unchanged. Do not dispatch actions by translated labels. Use the existing formatted clock unchanged as time. Preserve idle/starting/recording/paused/stopping state values, active-state calculation and menu enablement. translation-reviewed desktop/capture.cjs:DesktopCapture.update +desktop.capture.status_saving Saving recording... Saving recording… 正在儲存錄影… Stopping state; completion and final output are not yet confirmed. Display text only. Keep capture scope, no-audio recording, png/webm values, IPC/menu IDs, typed states, accelerators and file paths unchanged. Do not dispatch actions by translated labels. Use the existing formatted clock unchanged as time. Preserve idle/starting/recording/paused/stopping state values, active-state calculation and menu enablement. translation-reviewed desktop/capture.cjs:DesktopCapture.update +desktop.capture.title_recording REC {time} REC {time} 錄影 {time} Window-title recording indicator for existing active, non-paused states. {time} Display text only. Keep capture scope, no-audio recording, png/webm values, IPC/menu IDs, typed states, accelerators and file paths unchanged. Do not dispatch actions by translated labels. Use the existing formatted clock unchanged as time. Preserve idle/starting/recording/paused/stopping state values, active-state calculation and menu enablement. translation-reviewed desktop/capture.cjs:DesktopCapture.update +desktop.capture.title_paused PAUSED {time} PAUSED {time} 暫停 {time} Window-title recording indicator for typed paused state. {time} Display text only. Keep capture scope, no-audio recording, png/webm values, IPC/menu IDs, typed states, accelerators and file paths unchanged. Do not dispatch actions by translated labels. Use the existing formatted clock unchanged as time. Preserve idle/starting/recording/paused/stopping state values, active-state calculation and menu enablement. translation-reviewed desktop/capture.cjs:DesktopCapture.update +desktop.capture.folder_unavailable The saved capture folder is unavailable. Choose a writable folder again. The saved capture folder is unavailable. Choose a writable folder again. 已儲存的擷取資料夾無法使用。請重新選擇可寫入的資料夾。 Saved folder failed directory or write-access validation; a new picker follows. Display text only. Keep capture scope, no-audio recording, png/webm values, IPC/menu IDs, typed states, accelerators and file paths unchanged. Do not dispatch actions by translated labels. Cancel returns null. Preserve initial seeding of the other unset format and subsequent per-format changes. Do not claim the selected path is already saved or any capture has completed. translation-reviewed desktop/capture.cjs:DesktopCapture.chooseFile +desktop.capture.choose_screenshot_folder Choose screenshot folder Choose screenshot folder 選擇截圖資料夾 Native folder chooser title for png. Display text only. Keep capture scope, no-audio recording, png/webm values, IPC/menu IDs, typed states, accelerators and file paths unchanged. Do not dispatch actions by translated labels. Cancel returns null. Preserve initial seeding of the other unset format and subsequent per-format changes. Do not claim the selected path is already saved or any capture has completed. translation-reviewed desktop/capture.cjs:DesktopCapture.chooseDirectory +desktop.capture.choose_recording_folder Choose recording folder Choose recording folder 選擇錄影資料夾 Native folder chooser title for webm. Display text only. Keep capture scope, no-audio recording, png/webm values, IPC/menu IDs, typed states, accelerators and file paths unchanged. Do not dispatch actions by translated labels. Cancel returns null. Preserve initial seeding of the other unset format and subsequent per-format changes. Do not claim the selected path is already saved or any capture has completed. translation-reviewed desktop/capture.cjs:DesktopCapture.chooseDirectory +desktop.capture.use_folder Use this folder Use this folder 使用此資料夾 Affirmative native folder-picker action. Display text only. Keep capture scope, no-audio recording, png/webm values, IPC/menu IDs, typed states, accelerators and file paths unchanged. Do not dispatch actions by translated labels. Cancel returns null. Preserve initial seeding of the other unset format and subsequent per-format changes. Do not claim the selected path is already saved or any capture has completed. translation-reviewed desktop/capture.cjs:DesktopCapture.chooseDirectory +desktop.capture.folder_required Choose a folder. Choose a folder. 請選擇資料夾。 Selected path is not a directory; visible local validation error. Display text only. Keep capture scope, no-audio recording, png/webm values, IPC/menu IDs, typed states, accelerators and file paths unchanged. Do not dispatch actions by translated labels. Cancel returns null. Preserve initial seeding of the other unset format and subsequent per-format changes. Do not claim the selected path is already saved or any capture has completed. translation-reviewed desktop/capture.cjs:DesktopCapture.chooseDirectory +desktop.capture.settings_title Capture Settings Capture Settings 畫面擷取設定 Native capture-settings dialog title; no menu ellipsis. Display text only. Keep capture scope, no-audio recording, png/webm values, IPC/menu IDs, typed states, accelerators and file paths unchanged. Do not dispatch actions by translated labels. Use desktop.capture.done at response index 0; keep defaultId=0/cancelId=0 and other response indices unchanged. Do not open an unset folder or translate an existing folder path. translation-reviewed desktop/capture.cjs:DesktopCapture.configure +desktop.capture.settings_message Desktop capture folders Desktop capture folders Desktop 畫面擷取資料夾 Capture-settings dialog heading. Display text only. Keep capture scope, no-audio recording, png/webm values, IPC/menu IDs, typed states, accelerators and file paths unchanged. Do not dispatch actions by translated labels. Use desktop.capture.done at response index 0; keep defaultId=0/cancelId=0 and other response indices unchanged. Do not open an unset folder or translate an existing folder path. translation-reviewed desktop/capture.cjs:DesktopCapture.configure +desktop.capture.first_screenshot Choose on first save Choose on first save 首次儲存時選擇 Fallback screenshot folder display when png preference is unset. Display text only. Keep capture scope, no-audio recording, png/webm values, IPC/menu IDs, typed states, accelerators and file paths unchanged. Do not dispatch actions by translated labels. Use desktop.capture.done at response index 0; keep defaultId=0/cancelId=0 and other response indices unchanged. Do not open an unset folder or translate an existing folder path. translation-reviewed desktop/capture.cjs:DesktopCapture.configure +desktop.capture.first_recording Choose on first recording Choose on first recording 首次錄影時選擇 Fallback recording folder display when webm preference is unset. Display text only. Keep capture scope, no-audio recording, png/webm values, IPC/menu IDs, typed states, accelerators and file paths unchanged. Do not dispatch actions by translated labels. Use desktop.capture.done at response index 0; keep defaultId=0/cancelId=0 and other response indices unchanged. Do not open an unset folder or translate an existing folder path. translation-reviewed desktop/capture.cjs:DesktopCapture.configure +desktop.capture.change_screenshot_folder Screenshot folder... Screenshot folder… 截圖資料夾… Settings button at response index 1 opens png folder selection. Display text only. Keep capture scope, no-audio recording, png/webm values, IPC/menu IDs, typed states, accelerators and file paths unchanged. Do not dispatch actions by translated labels. Use desktop.capture.done at response index 0; keep defaultId=0/cancelId=0 and other response indices unchanged. Do not open an unset folder or translate an existing folder path. translation-reviewed desktop/capture.cjs:DesktopCapture.configure +desktop.capture.change_recording_folder Recording folder... Recording folder… 錄影資料夾… Settings button at response index 2 opens webm folder selection. Display text only. Keep capture scope, no-audio recording, png/webm values, IPC/menu IDs, typed states, accelerators and file paths unchanged. Do not dispatch actions by translated labels. Use desktop.capture.done at response index 0; keep defaultId=0/cancelId=0 and other response indices unchanged. Do not open an unset folder or translate an existing folder path. translation-reviewed desktop/capture.cjs:DesktopCapture.configure +desktop.capture.open_screenshot_folder Open screenshot folder Open screenshot folder 開啟截圖資料夾 Settings button at response index 3 opens the current png folder. Display text only. Keep capture scope, no-audio recording, png/webm values, IPC/menu IDs, typed states, accelerators and file paths unchanged. Do not dispatch actions by translated labels. Use desktop.capture.done at response index 0; keep defaultId=0/cancelId=0 and other response indices unchanged. Do not open an unset folder or translate an existing folder path. translation-reviewed desktop/capture.cjs:DesktopCapture.configure +desktop.capture.open_recording_folder Open recording folder Open recording folder 開啟錄影資料夾 Settings button at response index 4 opens the current webm folder. Display text only. Keep capture scope, no-audio recording, png/webm values, IPC/menu IDs, typed states, accelerators and file paths unchanged. Do not dispatch actions by translated labels. Use desktop.capture.done at response index 0; keep defaultId=0/cancelId=0 and other response indices unchanged. Do not open an unset folder or translate an existing folder path. translation-reviewed desktop/capture.cjs:DesktopCapture.configure +desktop.capture.open_folder_failed Could not open the capture folder. Could not open the capture folder. 無法開啟擷取資料夾。 Generic local failure after OS folder-open callback reports an error. Display text only. Keep capture scope, no-audio recording, png/webm values, IPC/menu IDs, typed states, accelerators and file paths unchanged. Do not dispatch actions by translated labels. Use desktop.capture.done at response index 0; keep defaultId=0/cancelId=0 and other response indices unchanged. Do not open an unset folder or translate an existing folder path. translation-reviewed desktop/capture.cjs:DesktopCapture.configure +desktop.capture.show_window_capture Show the StandTerm window before capturing it. Show the StandTerm window before capturing it. 請先顯示 StandTerm 視窗,再擷取畫面。 Visible local guard before screenshot capture; do not focus or show the window automatically. Display text only. Keep capture scope, no-audio recording, png/webm values, IPC/menu IDs, typed states, accelerators and file paths unchanged. Do not dispatch actions by translated labels. Preserve raw filesystem/recorder errors and existing output guarantees. Keep paths literal. Literal \n separates message text from its path; never claim success for partial output. translation-reviewed desktop/capture.cjs:DesktopCapture.screenshot +desktop.capture.show_window_recording Show the StandTerm window before recording it. Show the StandTerm window before recording it. 請先顯示 StandTerm 視窗,再開始錄影。 Visible local guard before recorder creation. Display text only. Keep capture scope, no-audio recording, png/webm values, IPC/menu IDs, typed states, accelerators and file paths unchanged. Do not dispatch actions by translated labels. Preserve raw filesystem/recorder errors and existing output guarantees. Keep paths literal. Literal \n separates message text from its path; never claim success for partial output. translation-reviewed desktop/capture.cjs:DesktopCapture.begin +desktop.capture.leave_fullscreen Leave fullscreen before recording so the recording indicator remains visible. Leave fullscreen before recording so the recording indicator stays visible. 請先離開全螢幕再開始錄影,讓錄影指示保持可見。 Fullscreen recording guard; no automatic window-mode changes. Display text only. Keep capture scope, no-audio recording, png/webm values, IPC/menu IDs, typed states, accelerators and file paths unchanged. Do not dispatch actions by translated labels. Preserve raw filesystem/recorder errors and existing output guarantees. Keep paths literal. Literal \n separates message text from its path; never claim success for partial output. translation-reviewed desktop/capture.cjs:DesktopCapture.begin +desktop.capture.screenshot_copied Screenshot copied to the clipboard. Screenshot copied to the clipboard. 已將截圖複製至剪貼簿。 Shown only after the existing awaited image clipboard operation succeeds. Display text only. Keep capture scope, no-audio recording, png/webm values, IPC/menu IDs, typed states, accelerators and file paths unchanged. Do not dispatch actions by translated labels. Preserve raw filesystem/recorder errors and existing output guarantees. Keep paths literal. Literal \n separates message text from its path; never claim success for partial output. translation-reviewed desktop/capture.cjs:DesktopCapture.screenshot +desktop.capture.screenshot_saved Screenshot saved to:\n{path} Screenshot saved to:\n{path} 截圖已儲存至:\n{path} Success notification after screenshot output.finish completes. {path} Display text only. Keep capture scope, no-audio recording, png/webm values, IPC/menu IDs, typed states, accelerators and file paths unchanged. Do not dispatch actions by translated labels. Preserve raw filesystem/recorder errors and existing output guarantees. Keep paths literal. Literal \n separates message text from its path; never claim success for partial output. translation-reviewed desktop/capture.cjs:DesktopCapture.screenshot +desktop.capture.recording_saved Recording saved to:\n{path} Recording saved to:\n{path} 錄影已儲存至:\n{path} Success notification after recording output finalization. {path} Display text only. Keep capture scope, no-audio recording, png/webm values, IPC/menu IDs, typed states, accelerators and file paths unchanged. Do not dispatch actions by translated labels. Preserve raw filesystem/recorder errors and existing output guarantees. Keep paths literal. Literal \n separates message text from its path; never claim success for partial output. translation-reviewed desktop/capture.cjs:DesktopCapture.finish +desktop.capture.confirm_title StandTerm recording is active Recording in progress 錄影尚未結束 Confirmation title for an active recording, including paused/starting states. Display text only. Keep capture scope, no-audio recording, png/webm values, IPC/menu IDs, typed states, accelerators and file paths unchanged. Do not dispatch actions by translated labels. Keep defaultId=0/cancelId=0 and response 1 as stop-and-save. Reuse desktop.toolbar.record_stop for the affirmative button. Do not interpolate localized action text as control data. The authorized gate cancels this close/quit unless save is confirmed; retain the window to show errors/partial paths. translation-reviewed desktop/capture.cjs:DesktopCapture.confirmStop; desktop/main.cjs:window-close/before-quit +desktop.capture.confirm_close Stop and save the recording before closing the window? Stop and save the recording before closing this window? 要先停止並儲存錄影,再關閉此視窗嗎? Complete question selected only by typed action close. Display text only. Keep capture scope, no-audio recording, png/webm values, IPC/menu IDs, typed states, accelerators and file paths unchanged. Do not dispatch actions by translated labels. Keep defaultId=0/cancelId=0 and response 1 as stop-and-save. Reuse desktop.toolbar.record_stop for the affirmative button. Do not interpolate localized action text as control data. The authorized gate cancels this close/quit unless save is confirmed; retain the window to show errors/partial paths. translation-reviewed desktop/capture.cjs:DesktopCapture.confirmStop; desktop/main.cjs:window-close/before-quit +desktop.capture.confirm_quit Stop and save the recording before quitting StandTerm? Stop and save the recording before quitting StandTerm? 要先停止並儲存錄影,再結束 StandTerm 嗎? Complete question selected only by typed action quit. Display text only. Keep capture scope, no-audio recording, png/webm values, IPC/menu IDs, typed states, accelerators and file paths unchanged. Do not dispatch actions by translated labels. Keep defaultId=0/cancelId=0 and response 1 as stop-and-save. Reuse desktop.toolbar.record_stop for the affirmative button. Do not interpolate localized action text as control data. The authorized gate cancels this close/quit unless save is confirmed; retain the window to show errors/partial paths. translation-reviewed desktop/capture.cjs:DesktopCapture.confirmStop; desktop/main.cjs:window-close/before-quit +desktop.capture.keep_recording Keep recording Keep window open 保持視窗開啟 Safe response 0 cancels the pending close/quit and preserves the current recording or paused state; it does not resume a paused recorder. Display text only. Keep capture scope, no-audio recording, png/webm values, IPC/menu IDs, typed states, accelerators and file paths unchanged. Do not dispatch actions by translated labels. Keep defaultId=0/cancelId=0 and response 1 as stop-and-save. Reuse desktop.toolbar.record_stop for the affirmative button. Do not interpolate localized action text as control data. The authorized gate cancels this close/quit unless save is confirmed; retain the window to show errors/partial paths. translation-reviewed desktop/capture.cjs:DesktopCapture.confirmStop; desktop/main.cjs:window-close/before-quit +desktop.capture.save_failed_title Recording save not confirmed 無法確認錄影儲存結果 Error dialog title when stop/save failed during a pending close or quit. Display text only. Keep capture scope, no-audio recording, png/webm values, IPC/menu IDs, typed states, accelerators and file paths unchanged. Do not dispatch actions by translated labels. Requires the user-authorized failure gate: no closing/quitting on unconfirmed save. Preserve raw error and optional partial path; keep the window open. Do not imply recording continues, a partial WebM is playable, or saving can be retried automatically. Reuse desktop.common.ok for acknowledgment. translation-reviewed desktop/capture.cjs:DesktopCapture.confirmStop +desktop.capture.save_failed_message Recording save was not confirmed. This close or quit request was cancelled. 無法確認錄影已儲存。已取消本次關閉或結束操作。 Failure gate feedback; does not assert that no output file exists. Display text only. Keep capture scope, no-audio recording, png/webm values, IPC/menu IDs, typed states, accelerators and file paths unchanged. Do not dispatch actions by translated labels. Requires the user-authorized failure gate: no closing/quitting on unconfirmed save. Preserve raw error and optional partial path; keep the window open. Do not imply recording continues, a partial WebM is playable, or saving can be retried automatically. Reuse desktop.common.ok for acknowledgment. translation-reviewed desktop/capture.cjs:DesktopCapture.confirmStop +desktop.capture.save_failed_detail Review the error and file locations below.\n\n{error} 請檢查以下錯誤與檔案位置。\n\n{error} Failure detail with original error. Append partial_recording for a reported partial path and requested_destination for the intended output path. Neither location is a success guarantee. {error} Display text only. Keep capture scope, no-audio recording, png/webm values, IPC/menu IDs, typed states, accelerators and file paths unchanged. Do not dispatch actions by translated labels. Requires the user-authorized failure gate: no closing/quitting on unconfirmed save. Preserve raw error and optional partial path; keep the window open. Do not imply recording continues, a partial WebM is playable, or saving can be retried automatically. Reuse desktop.common.ok for acknowledgment. Publishing may have succeeded before partial-file cleanup failed; do not claim the final file is absent. translation-reviewed desktop/capture.cjs:DesktopCapture.confirmStop +desktop.capture.save_unconfirmed Could not confirm the recording save result. 無法確認錄影儲存結果。 Fallback error when a stop/save result does not positively confirm completion. Cancel the pending close/quit and retain the window. Do not claim that no output exists, retry automatically, or imply recording continues. translation-reviewed desktop/capture.cjs:DesktopCapture.confirmStop +desktop.capture.requested_destination Requested destination:\n{path} 預定儲存位置:\n{path} Failure-dialog suffix naming the requested final output path, distinct from a retained partial path. {path} Keep path literal. Final publication may have happened before partial cleanup failed. This label does not guarantee that the path exists, that the file is complete or playable, or that saving succeeded. translation-reviewed desktop/capture.cjs:DesktopCapture.confirmStop +desktop.capture.done Done Done 完成 Close Capture Settings without changing either folder preference. Keep response index 0 and defaultId=0/cancelId=0. This closes the settings dialog only; it is not a capture-save result. translation-reviewed desktop/capture.cjs:DesktopCapture.configure diff --git a/docs/desktop_ui_review_plan.md b/docs/desktop_ui_review_plan.md index 5071e3c..88f0a4e 100644 --- a/docs/desktop_ui_review_plan.md +++ b/docs/desktop_ui_review_plan.md @@ -12,15 +12,14 @@ acceptance remain separate. Browser acceptance is recorded in | --- | --- | --- | --- | | 0 — Complete | Clarify toolbar action feedback before localization. A resolved `false` now shows unavailable feedback; a rejected invocation reports an uncertain result without suggesting retry. | Small | Both original failures reproduced before the fix; renderer and command-guard checks passed. Each click invokes once, with no automatic replay or invented completion notice. | | 1 — Complete | Add a Desktop-owned language preference and catalog; pilot custom menus, toolbar labels and Agent help. | Medium | English default/fallback, `en` and `zh-TW`, malformed preference fallback, next-launch application, translated title/ARIA labels without losing SVGs, fixed command IDs, focus/origin guards and staging inclusion verified. Native acceptance remains order 4. | -| 2 — Partial | Browser Access and Diagnostics are localized, including About and external-browser confirmations. Capture remains; resolve its save-failure exit policy first. | Medium | Sensitive clipboard feedback, fixed authorization actions, escaped diagnostic fields and literal event JSON verified in both languages. Capture still needs typed state, partial-file, folder-seeding and combined save-failure plus close/quit coverage. | +| 2 — Complete | Localize Browser Access, Diagnostics, About, external-browser confirmations and Capture; retain the window when recording save fails during close/quit. | Medium | Sensitive clipboard feedback, fixed authorization actions, escaped diagnostic fields, literal event JSON, typed Capture state, folder settings and combined save-failure plus close/quit coverage verified. | | 3 | Localize setup, Core source selection, startup failure and recoverable environment cleanup. | Medium to large | Both languages work before Core is available. Cancellation waits for owned installers; stale confirmations do nothing; source switching, restart/session closure, retained files and recovery moves remain explicit. | | 4 | Complete Windows and macOS native acceptance and packaged asset checks. | Platform-dependent | Menus, native dialogs, narrow layouts, keyboard/ARIA labels, clipboard, setup and recovery are checked on each OS. Verify staged and packaged Desktop catalogs independently of the selected Core version. | -The next implementation is Capture in order 2. -Resolve the recording save-failure exit policy before changing its confirmation. -Orders 1–3 should remain separate reviewable changes. First-run setup can move -ahead of order 2 if onboarding becomes the priority; it is not required to prove -the small localization pilot. +The next implementation is setup and Core source/recovery in order 3. +The operator chose to retain the window and show the error and unfinished-file +location when recording save fails during close/quit. Orders 1–3 remain separate +reviewable changes; native OS acceptance remains order 4. ## Difficulty and design choices @@ -68,9 +67,10 @@ Keep these implementation boundaries: [desktop_ui_copy_review.tsv](desktop_ui_copy_review.tsv) is a prioritized seed inventory, not a claim that every Desktop string has been extracted. It uses -the same nine columns as the browser table. After Browser Access/Diagnostics, -126 rows are `translation-reviewed` with English and Traditional Chinese text; 17 workflow -rows remain `proposed` with empty `zh-TW` cells. Some `current_en` cells are exact fragments or normalize +the same nine columns as the browser table. After Capture, +170 rows are `translation-reviewed` with English and Traditional Chinese text; +11 setup/Core source rows remain `proposed` with empty `zh-TW` cells. One retired +Capture sentence fragment is marked `remove`. Some `current_en` cells are exact fragments or normalize dynamic values to named placeholders; `context` identifies these cases. Approve the English behavior and terminology before requesting translations of @@ -115,7 +115,7 @@ proposals, not implementation authorization. | --- | --- | --- | --- | --- | --- | --- | | Rejected toolbar invocation encourages retry despite an uncertain outcome | Low | `toolbar.js` click handler; `toolbar-notice.test.cjs` rejection case | Clarify result and avoid replay | Use “Could not confirm the action result. Check the current state.” | Modify; order 0 | Add assertion that invocation occurs once and the unknown-result notice appears. | | Explicit toolbar rejection has no notice | Low; main-review addition | `toolbar.cjs` handler returns `false`; renderer only catches exceptions | Complement exception handling | Handle exactly `false`; do not infer completion from truthiness or message content | Accept; order 0 | Cover explicit rejection, exception and successful operation separately. | -| Stop/save failure still permits close or quit | Medium | `capture.cjs:confirmStop` awaits `stop()` then returns true; `main.cjs` close and before-quit handlers | Do not promise a successful save before leaving | Current copy proposal says “attempt to save”; retaining the window on failure is a separate behavior decision | Policy before order 2 | Existing capture smoke checks partial output and successful confirmation separately; combined failure plus exit coverage is missing. | +| Stop/save failure still permits close or quit | Medium | Original `capture.cjs:confirmStop` awaited `stop()` then returned true | Retain the window when saving fails | Operator approved retaining the window and showing error/file locations | Resolved in Capture batch below | Combined failure, close/quit and concurrent-dialog coverage now passes. | | Capture folder hint sounds global | Low | `capture.cjs:chooseDirectory`; `capture-settings.cjs:set` | Describe per-format preference | Preserve first-selection seeding of the other unset format; do not claim preferences are completely independent | Modify; order 2 | Cover initial seeding and later independent PNG/WebM changes. | | Localization could alter command or renderer boundaries | Integration constraint | `ui-commands.cjs:UI_ACTIONS`; `toolbar.cjs` sender and asset guards | Preserve structural commands and strict assets | Accept as invariants, not findings of a current bypass | Accept; order 1 | Existing guard tests plus explicit asset and localized-label cases. | | Independent Desktop locale adds a second preference | Product tradeoff | Pre-Core setup/recovery; independently selectable Core source | Consider shared/advisory locale | Recommend independence for startup coverage; validated advisory locale is a feasible alternative | Policy; recommended for order 1 | Test missing/malformed preference and unavailable/older Core. | @@ -143,8 +143,9 @@ remaining gap in the new preference, DOM and command tests. | Save can finish before its notification fails | Validation boundary | `language.cjs:choose` persists before notification | Verify saved choice survives notification failure | Keep uncertain-result wording and the stored selection | Accept | Lost-notification test plus reopening the chooser passed. | | Labels must not change action or target dispatch | Correctness boundary | `ui-commands.cjs` action and snapshot target | Test both locales with unchanged dispatch values | Retained existing guards | Accept | Typed command/terminal ID, invalid display-label dispatch and focus tests passed. | -No policy was reopened. Core's independent language preference and the existing -recording-exit behavior remain intact. Native OS qualification is still order 4. +The pilot preserved Core's independent language preference and recording-exit +behavior. The later Capture batch changes the exit policy with operator approval. +Native OS qualification is still order 4. ## Browser Access and Diagnostics review @@ -172,6 +173,44 @@ checked that Browser Access, DevTools and external-browser confirmations retain `response === 1`, default/cancel index 0 and the original sensitive-data boundaries. Native dialog layout and interaction are still part of order 4. +## Capture review and evidence + +The operator-approved contract cancels the current close/quit when recording +save fails or cannot be confirmed. Desktop restores/shows the window and displays +a persistent error dialog with the original error and available unfinished-file +and requested-destination paths. It does not automatically retry. Once recording +is inactive and the dialog is dismissed, a later explicit close/quit is allowed; +this is not a permanent exit lock. A canceled recording folder chooser is not a +save failure. + +The batch adds 44 reviewed bilingual messages for Capture settings, menus, +recording state, notifications and whole close/quit prompts. The old grammatical +`{action}` fragment is retired. Action IDs and numeric dialog responses remain +structural; paths and lower-level recorder/file errors remain literal data. +Folder selection still seeds the other format only when that preference is unset. + +| Finding | Severity | Evidence | Critic remedy | Main response | Resolution | Validation | +| --- | --- | --- | --- | --- | --- | --- | +| Startup/background failure can clear the job before confirmation observes it | High | `begin`, `stop`, `finish` and pending confirmation lifetime | Preserve the original job outcome across awaits | Save structured results on the job and propagate startup failure; reject stale replacement jobs | Accept | Startup failure, background completion, canceled chooser and stale/duplicate confirmation tests pass. | +| A notification exception can obscure a saved result or reach generic process exit | High | Publication precedes notification; before-quit generic exception path exits | Separate file outcome from notification; reject uncertain quit | Store result before notification, treat notification as best effort and make quit rejection keep the app running | Accept | Notification/dialog rejection, single publication and actual main shutdown callback tests pass. | +| Final and partial paths can both exist after partial-link cleanup fails | Medium | Hard-link publication precedes partial unlink | Avoid claiming the destination is absent | Show a requested destination without asserting save completion; preserve both files | Accept | Real temporary-file test verifies both paths retain bytes after an injected unlink failure. | + +The critic's focused second pass found no remaining material race or premature +exit issue. Main review also blocks repeated close events while the failure +dialog is pending. Native dialog readability remains unqualified. + +Capture completed on 2026-09-20: + +- All 133 Desktop unit tests passed under Electron's Node 24.20.0 runtime. +- Seven catalog regression tests and both catalog freshness checks passed. +- The real toolbar DOM passed in both locales at 640px, including active/paused + labels, pause/resume ARIA and visible capture controls. The paused English case + initially placed the stop button about 29px outside the viewport; reducing the + existing compact button padding fixes this without shortening state text. +- Native IPC is mocked in the DOM test. No native OS dialog, encoder smoke or + installer acceptance was run for this batch. Capture permissions, recorder + isolation and automatic finalization on hide/minimize/navigation are unchanged. + ## Evidence and acceptance limits Browser Access/Diagnostics completed on 2026-09-20: @@ -220,7 +259,7 @@ inspection of Capture/setup/recovery does not imply their smoke suites ran in this review. The review table is checked using `build_ui_messages.build_catalog` for schema, -keys, placeholders and review gates. Only the 126 reviewed rows enter the +keys, placeholders and review gates. Only the 170 reviewed rows enter the Desktop runtime catalog; the remaining workflow proposals stay out of it. Windows/macOS native localization, installer lifecycle and packaged acceptance remain future work. This plan does not qualify or publish a release. From b83f5f1f57cf3d13d6922961554647f440a7d2af Mon Sep 17 00:00:00 2001 From: "ASKA C." Date: Sun, 20 Sep 2026 10:37:48 +0800 Subject: [PATCH 32/43] Localize Desktop setup and Core recovery ## Why Environment preparation and Core recovery can run before the Core UI is available. Their English-only dialogs do not follow the Desktop language selected for the current launch. ## What changed - Localize setup consent, progress, cancellation, typed error explanations, per-mode cleanup, and Core source management through the review table. - Keep normal dialogs on the launch language and read the relevant mode profile for installer preparation and cleanup. - Preserve numeric actions, process ownership, raw diagnostics, retained files and restart consequences; remove an inaccurate shared Git promise. - Leave installer-wide summaries and remaining shell notices explicitly scheduled for a separate batch. ## Testing All 145 Desktop unit tests and seven catalog regression tests pass. Both catalogs are current. Six real setup DOM cases cover both languages and all platform display branches, with native processes mocked. Focused checks also cover conflicting installer profile preferences and the final copy changes. Native GUI and packaged acceptance remain separate. --- desktop/README.md | 10 +- desktop/core-source.cjs | 48 ++--- desktop/macos-python.cjs | 7 +- desktop/main.cjs | 12 +- desktop/messages.js | 198 ++++++++++++++++++++ desktop/setup.cjs | 226 +++++++++++------------ desktop/setup.html | 8 +- desktop/test/core-source-i18n.test.cjs | 115 ++++++++++++ desktop/test/setup-i18n-browser-smoke.py | 70 +++++++ desktop/test/setup-i18n-fixture.cjs | 100 ++++++++++ desktop/test/setup.test.cjs | 108 ++++++++++- docs/desktop_ui_copy_review.tsv | 113 ++++++++++-- docs/desktop_ui_review_plan.md | 70 +++++-- 13 files changed, 899 insertions(+), 186 deletions(-) create mode 100644 desktop/test/core-source-i18n.test.cjs create mode 100644 desktop/test/setup-i18n-browser-smoke.py create mode 100644 desktop/test/setup-i18n-fixture.cjs diff --git a/desktop/README.md b/desktop/README.md index 47b9aec..f4dc478 100644 --- a/desktop/README.md +++ b/desktop/README.md @@ -15,8 +15,11 @@ Chinese (Taiwan) for the next launch. The preference belongs to the Desktop profile; Core keeps its own language setting. Saving a choice does not restart StandTerm or interrupt recording. Coverage includes custom menus, toolbar labels, Agent help, Browser Access, Diagnostics, About, external-browser confirmations -and Capture dialogs/status. Setup, recovery and other Desktop -text still use English; native role labels follow the platform. +and Capture dialogs/status, environment preparation and Core source/recovery. +Installer preparation and cleanup confirmations use the relevant mode profile's +language. Installer-wide summary/error dialogs, port selection and Files download +notices still use English; raw technical errors remain unchanged and native role +labels follow the platform. Edit reviewed messages in the table, then generate the independent shell catalog with `python scripts/build_ui_messages.py --desktop` from the repository @@ -28,6 +31,9 @@ menus, dialogs or installers. `desktop/test/diagnostics-i18n-browser-smoke.py` checks the actual diagnostics HTML in both languages, including escaped display data and unchanged event JSON. It needs Node 22.12+; use `--node ` to select a prepared runtime. +`desktop/test/setup-i18n-browser-smoke.py` uses the same option and checks the +actual setup initialization, progress and cancellation scripts in both languages +for Windows, macOS and WSL. Setup processes and native dialogs are mocked. ## macOS Apple Silicon evaluation diff --git a/desktop/core-source.cjs b/desktop/core-source.cjs index 24cf679..8c62645 100644 --- a/desktop/core-source.cjs +++ b/desktop/core-source.cjs @@ -3,6 +3,7 @@ const fs = require('node:fs/promises'); const path = require('node:path'); const { randomUUID } = require('node:crypto'); +const { create } = require('./i18n.js'); const ACTIONS = ['enable', 'update', 'prepare', 'recover']; @@ -51,18 +52,16 @@ async function installedStore(profile, resources, version) { return sourceStore(profile, `${version}:${metadata.id}`); } -function coreController({ store, prepareBundled, manage, dialog, openLogs, restart }) { +function coreController({ store, prepareBundled, manage, dialog, openLogs, restart, t = create('en').t }) { let lastAction = null; let managing = false; async function confirm(action) { - const result = await dialog.showMessageBox({ type: 'warning', title: 'Change StandTerm Core', - message: action === 'recover' ? 'Restore the Core bundled with this Desktop installation?' : 'Use the advanced Git Core environment?', - detail: 'This restarts StandTerm and closes terminal sessions. Python dependencies may be downloaded and installed. ' - + (action === 'recover' ? 'Core files and data in the previous environment are retained. Recovery does not roll back user data. ' - : 'Git uses the official askac/standterm repository, main branch. Its files are not checked against the installed bundle hashes. ' - + 'Local changes are allowed, but updates refuse to overwrite them. The Desktop shell stays installed. ') - + 'Authorization and recovery data are local to each Core source; switching may require reauthorization.', - buttons: ['Cancel', 'Restart and continue'], defaultId: 0, cancelId: 0, noLink: true }); + const result = await dialog.showMessageBox({ type: 'warning', title: t('desktop.core_source.change_title'), + message: t(action === 'recover' ? 'desktop.core_source.confirm_recover' : 'desktop.core_source.confirm_git'), + detail: [t('desktop.core_source.restart_notice'), + t(action === 'recover' ? 'desktop.core_source.recovery_retention' : 'desktop.core_source.git_policy'), + t('desktop.core_source.reauthorization')].join('\n\n'), + buttons: [t('desktop.common.cancel'), t('desktop.core_source.restart_continue')], defaultId: 0, cancelId: 0, noLink: true }); if (result.response === 1) restart(action); return result.response === 1; } @@ -74,22 +73,24 @@ function coreController({ store, prepareBundled, manage, dialog, openLogs, resta let status; let issue = ''; try { status = await manage('status'); } catch (error) { issue = error.message; } - const actions = [{ id: 'cancel', label: 'Close' }]; + const actions = [{ id: 'cancel', label: t('desktop.core_source.close') }]; if (status?.git_available) { actions.push({ id: settings.source === 'git' ? 'prepare' : 'enable', - label: settings.source === 'git' ? 'Prepare Git environment' : 'Enable Git Core' }); + label: t(settings.source === 'git' ? 'desktop.core_source.prepare_git' : 'desktop.core_source.enable_git') }); if (status.workspace === 'present') { - if (settings.source !== 'git') actions.push({ id: 'prepare', label: 'Prepare Git environment' }); - actions.push({ id: 'update', label: 'Update Git Core' }); + if (settings.source !== 'git') actions.push({ id: 'prepare', label: t('desktop.core_source.prepare_git') }); + actions.push({ id: 'update', label: t('desktop.core_source.update_git') }); } } - actions.push({ id: 'recover', label: 'Restore bundled Core' }, { id: 'logs', label: 'Open Desktop logs' }); - const answer = await dialog.showMessageBox({ type: 'info', title: 'Core source (Advanced)', - message: `Core source: ${settings.source === 'git' ? 'Git' : 'Bundled with Desktop'}`, - detail: `Git in this backend environment: ${status?.git_available ? 'Available' : 'Unavailable'}\n` - + (status?.commit ? `Commit: ${status.commit}${status.dirty ? ' (local changes)' : ''}\n` : '') - + (status ? `Workspace: ${status.workspace}\n` : '') + issue - + '\nGit updates are manual. Install Git in the selected Windows, macOS or WSL environment to enable them.', + actions.push({ id: 'recover', label: t('desktop.core_source.restore_bundled') }, { id: 'logs', label: t('desktop.core_source.open_logs') }); + const workspace = ['absent', 'present', 'unavailable', 'invalid'].includes(status?.workspace) + ? t(`desktop.core_source.workspace_${status.workspace}`) : status?.workspace; + const answer = await dialog.showMessageBox({ type: 'info', title: t('desktop.core_source.manager_title'), + message: t('desktop.core_source.source', { source: settings.source === 'git' ? 'Git' : t('desktop.core_source.bundled') }), + detail: t(status?.git_available ? 'desktop.core_source.git_available' : 'desktop.core_source.git_unavailable') + '\n' + + (status?.commit ? t(status.dirty ? 'desktop.core_source.commit_dirty' : 'desktop.core_source.commit', { commit: status.commit }) + '\n' : '') + + (status ? t('desktop.core_source.workspace', { workspace }) + '\n' : '') + issue + + '\n' + t('desktop.core_source.manual_updates'), buttons: actions.map(item => item.label), defaultId: 0, cancelId: 0, noLink: true }); const action = actions[answer.response]?.id; if (action === 'logs') await openLogs(); @@ -110,9 +111,10 @@ function coreController({ store, prepareBundled, manage, dialog, openLogs, resta }, async failure(error) { while (true) { - const answer = await dialog.showMessageBox({ type: 'error', title: 'StandTerm Core is unavailable', - message: error.message, detail: 'The Desktop shell can retry or restore its installed Core. Existing files are retained.', - buttons: ['Quit', 'Retry', 'Restore bundled Core', 'Core source (Advanced)', 'Open Desktop logs'], + const answer = await dialog.showMessageBox({ type: 'error', title: t('desktop.core_source.unavailable_title'), + message: error.message, detail: t('desktop.core_source.failure_choices'), + buttons: [t('desktop.core_source.quit'), t('desktop.core_source.retry'), t('desktop.core_source.restore_bundled'), + t('desktop.core_source.manager_title'), t('desktop.core_source.open_logs')], defaultId: 0, cancelId: 0, noLink: true }); if (answer.response === 4) { await openLogs(); continue; } if (answer.response === 3) { if (await showManager()) return 'restart'; continue; } diff --git a/desktop/macos-python.cjs b/desktop/macos-python.cjs index e9df23b..d4f1e52 100644 --- a/desktop/macos-python.cjs +++ b/desktop/macos-python.cjs @@ -2,11 +2,6 @@ const path = require('node:path'); -const MACOS_HELP = 'Install native macOS Python 3.10+ with venv and ensurepip support first.\n\n' - + 'Python must match this app’s CPU architecture. StandTerm checks Homebrew, MacPorts and PATH, ' - + 'or lets you select an installed interpreter. Apple’s /usr/bin/python3 developer-tools stub is not launched. ' - + 'StandTerm does not install Python, Homebrew, Rosetta or system packages.'; - function macPythonCandidates(saved, env = {}) { // Finder launch has a minimal PATH; known package-manager locations remain usable. return [...new Set([saved, '/opt/homebrew/bin/python3', '/opt/local/bin/python3', '/usr/local/bin/python3', @@ -25,4 +20,4 @@ function validMacPython(info, arch) { && info.executable !== '/usr/bin/python3' && !/[\r\n\0]/.test(info.executable); } -module.exports = { MACOS_HELP, macPythonCandidates, validMacPython }; +module.exports = { macPythonCandidates, validMacPython }; diff --git a/desktop/main.cjs b/desktop/main.cjs index 47881ab..2e5e391 100644 --- a/desktop/main.cjs +++ b/desktop/main.cjs @@ -244,8 +244,8 @@ async function start() { diagnostics.write('setup_start'); if (app.isPackaged && !smoke) { coreStore = await installedStore(app.getPath('userData'), process.resourcesPath, app.getVersion()); - coreManager = coreController({ store: coreStore, prepareBundled: () => preparePackagedBackend(mode), - manage: action => manageCore(mode, action), dialog, + coreManager = coreController({ store: coreStore, prepareBundled: () => preparePackagedBackend(mode, { language }), + manage: action => manageCore(mode, action, { language }), dialog, t, openLogs: () => shell.openPath(path.dirname(diagnostics.file)), restart: action => { restartRequest = { action }; app.quit(); }, }); @@ -392,7 +392,7 @@ async function start() { commands.item('settings'), { id: 'desktop-language', label: t('desktop.language.menu'), click: () => { void language.choose(dialog, win); } }, ...(coreManager ? [{ label: t('desktop.menu.core_source'), click: () => { - void coreManager.showManager().catch(error => dialog.showErrorBox('Core management unavailable', error.message)); + void coreManager.showManager().catch(error => dialog.showErrorBox(t('desktop.startup.management_unavailable'), error.message)); } }] : []), { label: t('desktop.menu.capture_settings'), click: () => capture.configure() }, browserAccess.menu, @@ -544,12 +544,12 @@ async function handleCoreFailure(error) { } else { exitCode = 1; if (smoke) console.error(error.stack || error.message); - else dialog.showErrorBox('StandTerm Desktop could not start', `${error.message}\n\nDiagnostics: ${diagnostics.file}`); + else dialog.showErrorBox(t('desktop.startup.start_failed'), t('desktop.startup.diagnostics_detail', { error: error.message, path: diagnostics.file })); app.quit(); } } catch (failure) { restartRequest = null; - dialog.showErrorBox('StandTerm Desktop could not recover', failure.message); + dialog.showErrorBox(t('desktop.startup.recovery_failed'), failure.message); app.quit(); } finally { failurePending = false; } } @@ -587,7 +587,7 @@ app.on('before-quit', event => { app.exit(exitCode); })().catch(error => { restartRequest = null; - if (!smoke) dialog.showErrorBox('StandTerm could not complete shutdown', error.message); + if (!smoke) dialog.showErrorBox(t('desktop.startup.shutdown_failed'), error.message); stopped = true; app.exit(1); }); diff --git a/desktop/messages.js b/desktop/messages.js index fc8de4f..12a3113 100644 --- a/desktop/messages.js +++ b/desktop/messages.js @@ -76,6 +76,37 @@ "desktop.capture.use_folder": "Use this folder", "desktop.common.cancel": "Cancel", "desktop.common.ok": "OK", + "desktop.core_source.bundled": "Bundled with Desktop", + "desktop.core_source.change_title": "Change StandTerm Core", + "desktop.core_source.close": "Close", + "desktop.core_source.commit": "Commit: {commit}", + "desktop.core_source.commit_dirty": "Commit: {commit} (local changes)", + "desktop.core_source.confirm_git": "Use the advanced Git Core environment?", + "desktop.core_source.confirm_recover": "Restore the Core bundled with this Desktop installation?", + "desktop.core_source.enable_git": "Enable Git Core", + "desktop.core_source.failure_choices": "Choose Retry to restart StandTerm and try again, or restore bundled Core. Existing files are kept.", + "desktop.core_source.git_available": "Git in this backend environment: Available", + "desktop.core_source.git_policy": "Git Core uses askac/standterm on the main branch. Its files are not verified against the installed bundle hashes. Updates refuse to overwrite local changes. The Desktop shell stays installed.", + "desktop.core_source.git_unavailable": "Git in this backend environment: Unavailable", + "desktop.core_source.manager_title": "Core source (Advanced)", + "desktop.core_source.manual_updates": "Git Core updates are manual. Install Git in the selected Windows, macOS or WSL environment to enable them.", + "desktop.core_source.open_logs": "Open Desktop logs", + "desktop.core_source.prepare_git": "Prepare Git environment", + "desktop.core_source.quit": "Quit", + "desktop.core_source.reauthorization": "Each Core source has separate authorization and recovery data. Switching may require reauthorization.", + "desktop.core_source.recovery_retention": "The previous environment\u2019s Core files and data are kept. Restoring bundled Core does not roll back user data.", + "desktop.core_source.restart_continue": "Restart and continue", + "desktop.core_source.restart_notice": "StandTerm will restart and close terminal sessions. Python dependencies may be downloaded and installed.", + "desktop.core_source.restore_bundled": "Restore bundled Core", + "desktop.core_source.retry": "Retry", + "desktop.core_source.source": "Core source: {source}", + "desktop.core_source.unavailable_title": "StandTerm Core is unavailable", + "desktop.core_source.update_git": "Update Git Core", + "desktop.core_source.workspace": "Workspace: {workspace}", + "desktop.core_source.workspace_absent": "Not created", + "desktop.core_source.workspace_invalid": "Invalid or inaccessible", + "desktop.core_source.workspace_present": "Present", + "desktop.core_source.workspace_unavailable": "Cannot inspect without Git", "desktop.diagnostics.backend_mode": "Backend mode", "desktop.diagnostics.backend_process": "Backend process", "desktop.diagnostics.backend_url": "Backend URL", @@ -154,6 +185,74 @@ "desktop.menu.quit": "Quit StandTerm", "desktop.menu.settings": "Settings...", "desktop.menu.show": "Show window", + "desktop.setup.cancel_detail": "Keep this window open or minimize it to continue. Canceling stops this setup\u2019s installation processes and keeps prepared files. Relaunch StandTerm to try again.", + "desktop.setup.cancel_message": "The Python environment is still being prepared.", + "desktop.setup.cancel_setup": "Cancel setup", + "desktop.setup.cancel_title": "Cancel StandTerm setup?", + "desktop.setup.canceling_status": "Canceling setup. Waiting for installation processes to stop...", + "desktop.setup.canceling_title": "StandTerm Desktop - Canceling setup", + "desktop.setup.cleanup_detail": "{paths}\n\nThis move does not free disk space. Core and settings stay in place. Busy or unverified environments are kept. Cancel keeps every listed venv.", + "desktop.setup.cleanup_inventory_unavailable": "Environment inventory was unavailable. No venv cleanup was started.", + "desktop.setup.cleanup_keep": "Keep environments", + "desktop.setup.cleanup_message": "Move these idle venvs to recovery in {platform}?", + "desktop.setup.cleanup_move": "Move listed venvs to recovery", + "desktop.setup.cleanup_title": "Confirm environment cleanup", + "desktop.setup.cleanup_unconfirmed": "Cleanup completion was not confirmed. Check venv-recovery before retrying.", + "desktop.setup.create_environment": "Create environment and install dependencies", + "desktop.setup.error_dependencies_failed": "Dependency installation or verification failed. Check internet access and the runtime setup.log, then retry.", + "desktop.setup.error_git_dirty": "The Git Core has local changes. Keep them or resolve them in its checkout before updating. Nothing was reset or stashed.", + "desktop.setup.error_git_diverged": "The Git Core cannot fast-forward to the official branch. Local history is retained. Restore the bundled Core or resolve the checkout manually.", + "desktop.setup.error_git_failed": "Git could not complete the operation. Check network access and the runtime setup.log. Prepared files are retained.", + "desktop.setup.error_git_needs_setup": "The Git Core requirements changed or its environment is missing. Use Prepare Git environment from Core source (Advanced).", + "desktop.setup.error_git_required": "Git is unavailable in the selected backend environment. Install Git there, or restore the bundled Core.", + "desktop.setup.error_git_source_changed": "The managed Git origin or branch has changed. The official repository and main branch are required.", + "desktop.setup.error_invalid_archive": "The recovery archive failed verification. Reinstall StandTerm Desktop if its installed bundle is also damaged.", + "desktop.setup.error_invalid_bundle": "The bundled Core failed its integrity check. Reinstall StandTerm Desktop.", + "desktop.setup.error_invalid_git_workspace": "The private Git checkout is incomplete or damaged. It is retained. Restore the bundled Core or repair that checkout manually.", + "desktop.setup.error_modified_runtime": "The managed Core contains modified files. Setup will not overwrite them.", + "desktop.setup.error_setup_busy": "This runtime is in use by StandTerm or another setup. Quit that desktop mode before retrying.", + "desktop.setup.error_setup_canceled": "Setup was canceled. Relaunch StandTerm to retry.", + "desktop.setup.error_setup_failed": "Setup failed. Check the selected Python environment and available disk space.", + "desktop.setup.error_setup_timeout": "Setup timed out. Check internet access and the setup log before retrying.", + "desktop.setup.error_unsafe_runtime_path": "The runtime directory is not safe to use. Setup will not overwrite unrelated files or follow directory links.", + "desktop.setup.help_macos": "Install native macOS Python 3.10+ with venv and ensurepip support first.\n\nPython must match this app\u2019s CPU architecture. StandTerm checks Homebrew, MacPorts and PATH, or lets you select an installed interpreter. Apple\u2019s /usr/bin/python3 developer-tools stub is not launched. StandTerm does not install Python, Homebrew, Rosetta or system packages.", + "desktop.setup.help_windows": "Install 64-bit Python 3.10+ with venv support on Windows first.\n\nStandTerm checks PATH for python.exe or lets you select it. Microsoft Store aliases and py.exe are not launched automatically. No system Python installation or administrator access is requested.", + "desktop.setup.help_wsl": "Install WSL and Python 3.10+ with venv support in the selected distribution first.\n\nFor Ubuntu/Debian, run this yourself in WSL:\nsudo apt install python3 python3-venv\n\nStandTerm never runs sudo or installs system Python automatically.", + "desktop.setup.keep_preparing": "Keep preparing", + "desktop.setup.preparation_macos": "Preparing Core for native macOS.", + "desktop.setup.preparation_windows": "Preparing Core for native Windows.", + "desktop.setup.preparation_wsl": "Preparing Core inside WSL: {distro}.", + "desktop.setup.prepare_detail": "{requirements}\n\nThis copies the bundled Core to {path} and creates a private venv. Python dependencies are downloaded from your configured package index and installed there. Installation can run code and requires internet access and disk space.\n\nNo system Python installation, sudo, Git checkout changes or existing-session interruption. Failed setup files are kept for retry. Uninstall keeps environments by default. Optional cleanup moves verified idle venvs to recovery and keeps Core files and user data.", + "desktop.setup.prepare_message": "Create a private StandTerm environment in {platform}?", + "desktop.setup.prepare_title": "Prepare StandTerm Core", + "desktop.setup.progress_aria": "Environment preparation in progress", + "desktop.setup.progress_close_hint": "You can minimize this window while setup continues. Closing it asks for confirmation before canceling.", + "desktop.setup.progress_detail": "Creating a private venv and installing dependencies may take several minutes. The progress indicator does not show a completion percentage.", + "desktop.setup.progress_heading": "Preparing your Python environment", + "desktop.setup.progress_scope": "StandTerm uses your installed Python; it does not install system Python or run sudo. If canceled, prepared files are kept for retry.", + "desktop.setup.progress_starting": "Starting setup...", + "desktop.setup.progress_title": "StandTerm Desktop - Preparing environment", + "desktop.setup.python_executable": "Python executable", + "desktop.setup.python_required_title": "StandTerm Desktop: Python required", + "desktop.setup.requirements_macos": "Requires Python 3.10+ with venv support on native macOS.", + "desktop.setup.requirements_windows": "Requires 64-bit Python 3.10+ with venv support on Windows.", + "desktop.setup.requirements_wsl": "Requires Python 3.10+ with venv support inside WSL.", + "desktop.setup.select_python_macos": "Select installed Python...", + "desktop.setup.select_python_macos_title": "Select a native macOS Python 3.10+ interpreter", + "desktop.setup.select_python_windows": "Select installed python.exe...", + "desktop.setup.select_python_windows_title": "Select an installed 64-bit Python interpreter", + "desktop.setup.select_wsl_message": "Select an existing WSL distribution for StandTerm Core.", + "desktop.setup.select_wsl_title": "StandTerm Desktop: select WSL", + "desktop.setup.stage_copy": "Copying verified Core files...", + "desktop.setup.stage_dependencies": "Installing Python dependencies. This can take several minutes...", + "desktop.setup.stage_git": "Updating the private Git checkout...", + "desktop.setup.stage_venv": "Creating the private Python environment...", + "desktop.setup.stage_verify": "Verifying the installed dependencies...", + "desktop.startup.diagnostics_detail": "{error}\n\nDiagnostics: {path}", + "desktop.startup.management_unavailable": "Core management unavailable", + "desktop.startup.recovery_failed": "StandTerm Desktop could not recover", + "desktop.startup.shutdown_failed": "StandTerm could not complete shutdown", + "desktop.startup.start_failed": "StandTerm Desktop could not start", "desktop.toolbar.action_unavailable": "Action unavailable in the current window state.", "desktop.toolbar.action_unconfirmed": "Could not confirm the action result. Check the current state.", "desktop.toolbar.application_menu": "Application menu", @@ -248,6 +347,37 @@ "desktop.capture.use_folder": "\u4f7f\u7528\u6b64\u8cc7\u6599\u593e", "desktop.common.cancel": "\u53d6\u6d88", "desktop.common.ok": "\u78ba\u5b9a", + "desktop.core_source.bundled": "Desktop \u96a8\u9644", + "desktop.core_source.change_title": "\u8b8a\u66f4 StandTerm Core", + "desktop.core_source.close": "\u95dc\u9589", + "desktop.core_source.commit": "Commit\uff1a{commit}", + "desktop.core_source.commit_dirty": "Commit\uff1a{commit}\uff08\u6709\u672c\u6a5f\u8b8a\u66f4\uff09", + "desktop.core_source.confirm_git": "\u8981\u4f7f\u7528\u9032\u968e Git Core \u74b0\u5883\u55ce\uff1f", + "desktop.core_source.confirm_recover": "\u8981\u9084\u539f\u6b64 Desktop \u5b89\u88dd\u7248\u672c\u96a8\u9644\u7684 Core \u55ce\uff1f", + "desktop.core_source.enable_git": "\u555f\u7528 Git Core", + "desktop.core_source.failure_choices": "\u9078\u64c7\u300c\u91cd\u8a66\u300d\u4ee5\u91cd\u65b0\u555f\u52d5 StandTerm \u518d\u8a66\u4e00\u6b21\uff0c\u6216\u9084\u539f\u96a8\u9644 Core\u3002\u73fe\u6709\u6a94\u6848\u6703\u4fdd\u7559\u3002", + "desktop.core_source.git_available": "\u6b64\u5f8c\u7aef\u74b0\u5883\u7684 Git\uff1a\u53ef\u7528", + "desktop.core_source.git_policy": "Git Core \u4f7f\u7528 askac/standterm \u7684 main \u5206\u652f\u3002\u5176\u6a94\u6848\u4e0d\u6703\u4f9d\u5df2\u5b89\u88dd\u5957\u4ef6\u7684\u96dc\u6e4a\u503c\u9a57\u8b49\u3002\u66f4\u65b0\u6642\u4e0d\u6703\u8986\u5beb\u672c\u6a5f\u8b8a\u66f4\u3002Desktop \u61c9\u7528\u7a0b\u5f0f\u6703\u4fdd\u7559\u3002", + "desktop.core_source.git_unavailable": "\u6b64\u5f8c\u7aef\u74b0\u5883\u7684 Git\uff1a\u7121\u6cd5\u4f7f\u7528", + "desktop.core_source.manager_title": "Core \u4f86\u6e90\uff08\u9032\u968e\uff09", + "desktop.core_source.manual_updates": "Git Core \u9700\u624b\u52d5\u66f4\u65b0\u3002\u8acb\u5728\u6240\u9078\u7684 Windows\u3001macOS \u6216 WSL \u74b0\u5883\u4e2d\u5b89\u88dd Git\uff0c\u624d\u80fd\u9032\u884c\u66f4\u65b0\u3002", + "desktop.core_source.open_logs": "\u958b\u555f Desktop \u7d00\u9304\u8cc7\u6599\u593e", + "desktop.core_source.prepare_git": "\u6e96\u5099 Git \u74b0\u5883", + "desktop.core_source.quit": "\u7d50\u675f", + "desktop.core_source.reauthorization": "\u5404 Core \u4f86\u6e90\u7684\u6388\u6b0a\u8207\u5fa9\u539f\u8cc7\u6599\u4e92\u76f8\u7368\u7acb\u3002\u5207\u63db\u5f8c\u53ef\u80fd\u9700\u8981\u91cd\u65b0\u6388\u6b0a\u3002", + "desktop.core_source.recovery_retention": "\u539f\u74b0\u5883\u7684 Core \u6a94\u6848\u8207\u8cc7\u6599\u6703\u4fdd\u7559\u3002\u9084\u539f\u96a8\u9644 Core \u4e0d\u6703\u56de\u5fa9\u4f7f\u7528\u8005\u8cc7\u6599\u3002", + "desktop.core_source.restart_continue": "\u91cd\u65b0\u555f\u52d5\u4e26\u7e7c\u7e8c", + "desktop.core_source.restart_notice": "StandTerm \u5c07\u91cd\u65b0\u555f\u52d5\u4e26\u95dc\u9589\u7d42\u7aef\u5de5\u4f5c\u968e\u6bb5\u3002\u53ef\u80fd\u6703\u4e0b\u8f09\u4e26\u5b89\u88dd Python \u76f8\u4f9d\u5957\u4ef6\u3002", + "desktop.core_source.restore_bundled": "\u9084\u539f\u96a8\u9644 Core", + "desktop.core_source.retry": "\u91cd\u8a66", + "desktop.core_source.source": "Core \u4f86\u6e90\uff1a{source}", + "desktop.core_source.unavailable_title": "StandTerm Core \u7121\u6cd5\u4f7f\u7528", + "desktop.core_source.update_git": "\u66f4\u65b0 Git Core", + "desktop.core_source.workspace": "\u5de5\u4f5c\u76ee\u9304\uff1a{workspace}", + "desktop.core_source.workspace_absent": "\u5c1a\u672a\u5efa\u7acb", + "desktop.core_source.workspace_invalid": "\u7121\u6548\u6216\u7121\u6cd5\u5b58\u53d6", + "desktop.core_source.workspace_present": "\u5df2\u5efa\u7acb", + "desktop.core_source.workspace_unavailable": "\u7f3a\u5c11 Git\uff0c\u7121\u6cd5\u6aa2\u67e5", "desktop.diagnostics.backend_mode": "\u5f8c\u7aef\u6a21\u5f0f", "desktop.diagnostics.backend_process": "\u5f8c\u7aef\u7a0b\u5e8f", "desktop.diagnostics.backend_url": "\u5f8c\u7aef\u7db2\u5740", @@ -326,6 +456,74 @@ "desktop.menu.quit": "\u7d50\u675f StandTerm", "desktop.menu.settings": "\u8a2d\u5b9a\u2026", "desktop.menu.show": "\u986f\u793a\u8996\u7a97", + "desktop.setup.cancel_detail": "\u4fdd\u6301\u6b64\u8996\u7a97\u958b\u555f\u6216\u6700\u5c0f\u5316\u5373\u53ef\u7e7c\u7e8c\u3002\u53d6\u6d88\u6703\u505c\u6b62\u6b64\u6b21\u5b89\u88dd\u7684\u8655\u7406\u7a0b\u5e8f\uff0c\u4e26\u4fdd\u7559\u5df2\u6e96\u5099\u7684\u6a94\u6848\u3002\u8acb\u91cd\u65b0\u555f\u52d5 StandTerm \u4ee5\u91cd\u8a66\u3002", + "desktop.setup.cancel_message": "Python \u74b0\u5883\u4ecd\u5728\u6e96\u5099\u4e2d\u3002", + "desktop.setup.cancel_setup": "\u53d6\u6d88\u6e96\u5099", + "desktop.setup.cancel_title": "\u53d6\u6d88 StandTerm \u6e96\u5099\uff1f", + "desktop.setup.canceling_status": "\u6b63\u5728\u53d6\u6d88\u6e96\u5099\uff0c\u7b49\u5f85\u5b89\u88dd\u7a0b\u5e8f\u505c\u6b62\u2026", + "desktop.setup.canceling_title": "StandTerm Desktop\uff0d\u6b63\u5728\u53d6\u6d88\u6e96\u5099", + "desktop.setup.cleanup_detail": "{paths}\n\n\u79fb\u52d5\u4e0d\u6703\u91cb\u653e\u78c1\u789f\u7a7a\u9593\u3002Core \u8207\u8a2d\u5b9a\u6703\u7559\u5728\u539f\u8655\u3002\u4f7f\u7528\u4e2d\u6216\u672a\u901a\u904e\u9a57\u8b49\u7684\u74b0\u5883\u6703\u4fdd\u7559\u3002\u53d6\u6d88\u6703\u4fdd\u7559\u6e05\u55ae\u4e2d\u7684\u6240\u6709 venv\u3002", + "desktop.setup.cleanup_inventory_unavailable": "\u7121\u6cd5\u53d6\u5f97\u74b0\u5883\u6e05\u55ae\uff0c\u5c1a\u672a\u958b\u59cb\u6e05\u7406 venv\u3002", + "desktop.setup.cleanup_keep": "\u4fdd\u7559\u74b0\u5883", + "desktop.setup.cleanup_message": "\u8981\u5c07 {platform} \u4e2d\u9019\u4e9b\u672a\u4f7f\u7528\u4e2d\u7684 venv \u79fb\u81f3\u5fa9\u539f\u8cc7\u6599\u593e\u55ce\uff1f", + "desktop.setup.cleanup_move": "\u5c07\u6e05\u55ae\u4e2d\u7684 venv \u79fb\u81f3\u5fa9\u539f\u8cc7\u6599\u593e", + "desktop.setup.cleanup_title": "\u78ba\u8a8d\u6e05\u7406\u74b0\u5883", + "desktop.setup.cleanup_unconfirmed": "\u7121\u6cd5\u78ba\u8a8d\u6e05\u7406\u662f\u5426\u5b8c\u6210\u3002\u91cd\u8a66\u524d\uff0c\u8acb\u5148\u6aa2\u67e5 venv-recovery\u3002", + "desktop.setup.create_environment": "\u5efa\u7acb\u74b0\u5883\u4e26\u5b89\u88dd\u76f8\u4f9d\u5957\u4ef6", + "desktop.setup.error_dependencies_failed": "\u76f8\u4f9d\u5957\u4ef6\u5b89\u88dd\u6216\u9a57\u8b49\u5931\u6557\u3002\u8acb\u6aa2\u67e5\u7db2\u8def\u9023\u7dda\u53ca\u57f7\u884c\u74b0\u5883\u4e2d\u7684 setup.log\uff0c\u518d\u91cd\u8a66\u3002", + "desktop.setup.error_git_dirty": "Git Core \u6709\u672c\u6a5f\u8b8a\u66f4\u3002\u66f4\u65b0\u524d\uff0c\u8acb\u6c7a\u5b9a\u4fdd\u7559\u6216\u5728\u5176\u5de5\u4f5c\u76ee\u9304\u4e2d\u8655\u7406\u9019\u4e9b\u8b8a\u66f4\u3002\u7cfb\u7d71\u672a\u57f7\u884c reset \u6216 stash\u3002", + "desktop.setup.error_git_diverged": "Git Core \u7121\u6cd5\u4ee5\u5feb\u8f49\u65b9\u5f0f\u66f4\u65b0\u81f3\u5b98\u65b9\u5206\u652f\u3002\u672c\u6a5f\u6b77\u53f2\u7d00\u9304\u5df2\u4fdd\u7559\u3002\u8acb\u9084\u539f\u96a8\u9644 Core\uff0c\u6216\u624b\u52d5\u8655\u7406\u5176\u5de5\u4f5c\u76ee\u9304\u3002", + "desktop.setup.error_git_failed": "Git \u7121\u6cd5\u5b8c\u6210\u64cd\u4f5c\u3002\u8acb\u6aa2\u67e5\u7db2\u8def\u9023\u7dda\u53ca\u57f7\u884c\u74b0\u5883\u4e2d\u7684 setup.log\u3002\u5df2\u6e96\u5099\u7684\u6a94\u6848\u6703\u4fdd\u7559\u3002", + "desktop.setup.error_git_needs_setup": "Git Core \u7684\u76f8\u4f9d\u9700\u6c42\u5df2\u8b8a\u66f4\uff0c\u6216\u7f3a\u5c11\u57f7\u884c\u74b0\u5883\u3002\u8acb\u5728\u300cCore \u4f86\u6e90\uff08\u9032\u968e\uff09\u300d\u9078\u64c7\u300c\u6e96\u5099 Git \u74b0\u5883\u300d\u3002", + "desktop.setup.error_git_required": "\u9078\u53d6\u7684\u5f8c\u7aef\u74b0\u5883\u7121\u6cd5\u4f7f\u7528 Git\u3002\u8acb\u5728\u8a72\u74b0\u5883\u5b89\u88dd Git\uff0c\u6216\u9084\u539f\u96a8\u9644 Core\u3002", + "desktop.setup.error_git_source_changed": "\u53d7\u7ba1\u7406\u7684 Git origin \u6216\u5206\u652f\u5df2\u8b8a\u66f4\u3002\u5fc5\u9808\u4f7f\u7528\u5b98\u65b9\u5132\u5b58\u5eab\u53ca main \u5206\u652f\u3002", + "desktop.setup.error_invalid_archive": "\u5fa9\u539f\u5c01\u5b58\u6a94\u672a\u901a\u904e\u9a57\u8b49\u3002\u82e5\u5df2\u5b89\u88dd\u7684\u96a8\u9644\u6a94\u6848\u4e5f\u5df2\u640d\u58de\uff0c\u8acb\u91cd\u65b0\u5b89\u88dd StandTerm Desktop\u3002", + "desktop.setup.error_invalid_bundle": "\u96a8\u9644 Core \u672a\u901a\u904e\u5b8c\u6574\u6027\u6aa2\u67e5\u3002\u8acb\u91cd\u65b0\u5b89\u88dd StandTerm Desktop\u3002", + "desktop.setup.error_invalid_git_workspace": "\u5c08\u7528 Git \u5de5\u4f5c\u76ee\u9304\u4e0d\u5b8c\u6574\u6216\u5df2\u640d\u58de\uff0c\u7cfb\u7d71\u5df2\u4fdd\u7559\u8a72\u76ee\u9304\u3002\u8acb\u9084\u539f\u96a8\u9644 Core\uff0c\u6216\u624b\u52d5\u4fee\u5fa9\u8a72\u76ee\u9304\u3002", + "desktop.setup.error_modified_runtime": "\u53d7\u7ba1\u7406\u7684 Core \u542b\u6709\u5df2\u4fee\u6539\u7684\u6a94\u6848\u3002\u5b89\u88dd\u7a0b\u5e8f\u4e0d\u6703\u8986\u5beb\u9019\u4e9b\u6a94\u6848\u3002", + "desktop.setup.error_setup_busy": "StandTerm \u6216\u5176\u4ed6\u5b89\u88dd\u7a0b\u5e8f\u6b63\u5728\u4f7f\u7528\u6b64\u57f7\u884c\u74b0\u5883\u3002\u8acb\u5148\u7d50\u675f\u4f7f\u7528\u8a72\u74b0\u5883\u7684 Desktop \u6a21\u5f0f\uff0c\u518d\u91cd\u8a66\u3002", + "desktop.setup.error_setup_canceled": "\u5df2\u53d6\u6d88\u6e96\u5099\u3002\u8acb\u91cd\u65b0\u555f\u52d5 StandTerm \u4ee5\u91cd\u8a66\u3002", + "desktop.setup.error_setup_failed": "\u5b89\u88dd\u5931\u6557\u3002\u8acb\u6aa2\u67e5\u9078\u53d6\u7684 Python \u74b0\u5883\u53ca\u53ef\u7528\u78c1\u789f\u7a7a\u9593\u3002", + "desktop.setup.error_setup_timeout": "\u5b89\u88dd\u903e\u6642\u3002\u8acb\u5148\u6aa2\u67e5\u7db2\u8def\u9023\u7dda\u53ca\u5b89\u88dd\u8a18\u9304\uff0c\u518d\u91cd\u8a66\u3002", + "desktop.setup.error_unsafe_runtime_path": "\u7121\u6cd5\u5b89\u5168\u4f7f\u7528\u6b64\u57f7\u884c\u74b0\u5883\u76ee\u9304\u3002\u5b89\u88dd\u7a0b\u5e8f\u4e0d\u6703\u8986\u5beb\u7121\u95dc\u6a94\u6848\uff0c\u4e5f\u4e0d\u6703\u6cbf\u7528\u76ee\u9304\u9023\u7d50\u3002", + "desktop.setup.help_macos": "\u8acb\u5148\u5b89\u88dd\u652f\u63f4 venv \u8207 ensurepip \u7684 macOS \u539f\u751f Python 3.10 \u4ee5\u4e0a\u7248\u672c\u3002\n\nPython \u5fc5\u9808\u7b26\u5408\u6b64\u61c9\u7528\u7a0b\u5f0f\u7684 CPU \u67b6\u69cb\u3002StandTerm \u6703\u5f9e Homebrew\u3001MacPorts \u8207 PATH \u5c0b\u627e\uff0c\u6216\u8b93\u4f60\u9078\u53d6\u5df2\u5b89\u88dd\u7684\u76f4\u8b6f\u5668\u3002\u7cfb\u7d71\u4e0d\u6703\u555f\u52d5 Apple \u7684 /usr/bin/python3 \u958b\u767c\u5de5\u5177\u555f\u52d5\u7a0b\u5f0f\uff0c\u4e5f\u4e0d\u6703\u5b89\u88dd Python\u3001Homebrew\u3001Rosetta \u6216\u7cfb\u7d71\u5957\u4ef6\u3002", + "desktop.setup.help_windows": "\u8acb\u5148\u5728 Windows \u5b89\u88dd\u652f\u63f4 venv \u7684 64 \u4f4d\u5143 Python 3.10 \u4ee5\u4e0a\u7248\u672c\u3002\n\nStandTerm \u6703\u5f9e PATH \u5c0b\u627e python.exe\uff0c\u6216\u8b93\u4f60\u81ea\u884c\u9078\u53d6\u3002\u7cfb\u7d71\u4e0d\u6703\u81ea\u52d5\u555f\u52d5 Microsoft Store \u5225\u540d\u6216 py.exe\uff0c\u4e5f\u4e0d\u6703\u5b89\u88dd\u7cfb\u7d71 Python \u6216\u8981\u6c42\u7cfb\u7d71\u7ba1\u7406\u54e1\u6b0a\u9650\u3002", + "desktop.setup.help_wsl": "\u8acb\u5148\u5b89\u88dd WSL\uff0c\u4e26\u5728\u9078\u53d6\u7684\u767c\u884c\u7248\u4e2d\u5b89\u88dd\u652f\u63f4 venv \u7684 Python 3.10 \u4ee5\u4e0a\u7248\u672c\u3002\n\n\u82e5\u4f7f\u7528 Ubuntu\uff0fDebian\uff0c\u8acb\u81ea\u884c\u5728 WSL \u57f7\u884c\uff1a\nsudo apt install python3 python3-venv\n\nStandTerm \u4e0d\u6703\u81ea\u52d5\u57f7\u884c sudo \u6216\u5b89\u88dd\u7cfb\u7d71 Python\u3002", + "desktop.setup.keep_preparing": "\u7e7c\u7e8c\u6e96\u5099", + "desktop.setup.preparation_macos": "\u6b63\u5728\u70ba macOS \u539f\u751f\u74b0\u5883\u6e96\u5099 Core\u3002", + "desktop.setup.preparation_windows": "\u6b63\u5728\u70ba Windows \u539f\u751f\u74b0\u5883\u6e96\u5099 Core\u3002", + "desktop.setup.preparation_wsl": "\u6b63\u5728 WSL \u767c\u884c\u7248 {distro} \u5167\u6e96\u5099 Core\u3002", + "desktop.setup.prepare_detail": "{requirements}\n\n\u7cfb\u7d71\u6703\u5c07\u96a8\u9644 Core \u8907\u88fd\u5230 {path}\uff0c\u4e26\u5efa\u7acb\u5c08\u7528 venv\u3002Python \u76f8\u4f9d\u5957\u4ef6\u6703\u5f9e\u4f60\u8a2d\u5b9a\u7684\u5957\u4ef6\u7d22\u5f15\u4e0b\u8f09\uff0c\u4e26\u5b89\u88dd\u5230\u8a72\u74b0\u5883\u3002\u5b89\u88dd\u53ef\u80fd\u57f7\u884c\u7a0b\u5f0f\u78bc\uff0c\u4e14\u9700\u8981\u7db2\u8def\u9023\u7dda\u8207\u78c1\u789f\u7a7a\u9593\u3002\n\n\u7cfb\u7d71\u4e0d\u6703\u5b89\u88dd\u7cfb\u7d71 Python\u3001\u57f7\u884c sudo\u3001\u8b8a\u66f4 Git \u5de5\u4f5c\u76ee\u9304\uff0c\u6216\u4e2d\u65b7\u73fe\u6709\u5de5\u4f5c\u968e\u6bb5\u3002\u5b89\u88dd\u5931\u6557\u6642\u6703\u4fdd\u7559\u6a94\u6848\u4f9b\u91cd\u8a66\u3002\u89e3\u9664\u5b89\u88dd\u9810\u8a2d\u6703\u4fdd\u7559\u74b0\u5883\u3002\u9078\u7528\u7684\u6e05\u7406\u529f\u80fd\u53ea\u6703\u5c07\u901a\u904e\u9a57\u8b49\u4e14\u672a\u4f7f\u7528\u4e2d\u7684 venv \u79fb\u81f3\u5fa9\u539f\u8cc7\u6599\u593e\uff0c\u4e26\u4fdd\u7559 Core \u6a94\u6848\u8207\u4f7f\u7528\u8005\u8cc7\u6599\u3002", + "desktop.setup.prepare_message": "\u8981\u5728 {platform} \u5efa\u7acb StandTerm \u5c08\u7528\u74b0\u5883\u55ce\uff1f", + "desktop.setup.prepare_title": "\u6e96\u5099 StandTerm Core", + "desktop.setup.progress_aria": "\u6b63\u5728\u6e96\u5099\u74b0\u5883", + "desktop.setup.progress_close_hint": "\u6e96\u5099\u671f\u9593\u53ef\u5c07\u6b64\u8996\u7a97\u6700\u5c0f\u5316\u3002\u95dc\u9589\u8996\u7a97\u6642\uff0c\u7cfb\u7d71\u6703\u5148\u78ba\u8a8d\u662f\u5426\u53d6\u6d88\u3002", + "desktop.setup.progress_detail": "\u5efa\u7acb\u5c08\u7528 venv \u53ca\u5b89\u88dd\u76f8\u4f9d\u5957\u4ef6\u53ef\u80fd\u9700\u8981\u6578\u5206\u9418\u3002\u9032\u5ea6\u6307\u793a\u5668\u4e0d\u4ee3\u8868\u5b8c\u6210\u767e\u5206\u6bd4\u3002", + "desktop.setup.progress_heading": "\u6b63\u5728\u6e96\u5099 Python \u74b0\u5883", + "desktop.setup.progress_scope": "StandTerm \u4f7f\u7528\u5df2\u5b89\u88dd\u7684 Python\uff0c\u4e0d\u6703\u5b89\u88dd\u7cfb\u7d71 Python \u6216\u57f7\u884c sudo\u3002\u82e5\u53d6\u6d88\uff0c\u5df2\u6e96\u5099\u7684\u6a94\u6848\u6703\u4fdd\u7559\u4f9b\u91cd\u8a66\u3002", + "desktop.setup.progress_starting": "\u6b63\u5728\u958b\u59cb\u6e96\u5099\u2026", + "desktop.setup.progress_title": "StandTerm Desktop\uff0d\u6b63\u5728\u6e96\u5099\u74b0\u5883", + "desktop.setup.python_executable": "Python \u57f7\u884c\u6a94", + "desktop.setup.python_required_title": "StandTerm Desktop\uff1a\u9700\u8981 Python", + "desktop.setup.requirements_macos": "\u9700\u8981 macOS \u539f\u751f Python 3.10 \u4ee5\u4e0a\u7248\u672c\uff0c\u4e26\u652f\u63f4 venv\u3002", + "desktop.setup.requirements_windows": "\u9700\u8981 Windows 64 \u4f4d\u5143 Python 3.10 \u4ee5\u4e0a\u7248\u672c\uff0c\u4e26\u652f\u63f4 venv\u3002", + "desktop.setup.requirements_wsl": "\u9700\u8981 WSL \u5167\u7684 Python 3.10 \u4ee5\u4e0a\u7248\u672c\uff0c\u4e26\u652f\u63f4 venv\u3002", + "desktop.setup.select_python_macos": "\u9078\u53d6\u5df2\u5b89\u88dd\u7684 Python\u2026", + "desktop.setup.select_python_macos_title": "\u9078\u53d6 macOS \u539f\u751f Python 3.10 \u4ee5\u4e0a\u7248\u672c\u7684\u76f4\u8b6f\u5668", + "desktop.setup.select_python_windows": "\u9078\u53d6\u5df2\u5b89\u88dd\u7684 python.exe\u2026", + "desktop.setup.select_python_windows_title": "\u9078\u53d6\u5df2\u5b89\u88dd\u7684 64 \u4f4d\u5143 Python \u76f4\u8b6f\u5668", + "desktop.setup.select_wsl_message": "\u70ba StandTerm Core \u9078\u53d6\u73fe\u6709\u7684 WSL \u767c\u884c\u7248\u3002", + "desktop.setup.select_wsl_title": "StandTerm Desktop\uff1a\u9078\u53d6 WSL", + "desktop.setup.stage_copy": "\u6b63\u5728\u8907\u88fd\u5df2\u9a57\u8b49\u7684 Core \u6a94\u6848\u2026", + "desktop.setup.stage_dependencies": "\u6b63\u5728\u5b89\u88dd Python \u76f8\u4f9d\u5957\u4ef6\uff0c\u53ef\u80fd\u9700\u8981\u6578\u5206\u9418\u2026", + "desktop.setup.stage_git": "\u6b63\u5728\u66f4\u65b0\u5c08\u7528 Git \u5de5\u4f5c\u76ee\u9304\u2026", + "desktop.setup.stage_venv": "\u6b63\u5728\u5efa\u7acb\u5c08\u7528 Python \u74b0\u5883\u2026", + "desktop.setup.stage_verify": "\u6b63\u5728\u9a57\u8b49\u5df2\u5b89\u88dd\u7684\u76f8\u4f9d\u5957\u4ef6\u2026", + "desktop.startup.diagnostics_detail": "{error}\n\n\u8a3a\u65b7\u7d00\u9304\uff1a{path}", + "desktop.startup.management_unavailable": "\u7121\u6cd5\u4f7f\u7528 Core \u7ba1\u7406\u529f\u80fd", + "desktop.startup.recovery_failed": "StandTerm Desktop \u7121\u6cd5\u5fa9\u539f", + "desktop.startup.shutdown_failed": "StandTerm \u7121\u6cd5\u5b8c\u6210\u7d50\u675f\u7a0b\u5e8f", + "desktop.startup.start_failed": "StandTerm Desktop \u7121\u6cd5\u555f\u52d5", "desktop.toolbar.action_unavailable": "\u76ee\u524d\u7684\u8996\u7a97\u72c0\u614b\u7121\u6cd5\u57f7\u884c\u6b64\u64cd\u4f5c\u3002", "desktop.toolbar.action_unconfirmed": "\u7121\u6cd5\u78ba\u8a8d\u64cd\u4f5c\u7d50\u679c\u3002\u8acb\u6aa2\u67e5\u76ee\u524d\u72c0\u614b\u3002", "desktop.toolbar.application_menu": "\u61c9\u7528\u7a0b\u5f0f\u9078\u55ae", diff --git a/desktop/setup.cjs b/desktop/setup.cjs index 9cff3df..8c564c7 100644 --- a/desktop/setup.cjs +++ b/desktop/setup.cjs @@ -5,45 +5,25 @@ const { spawn } = require('node:child_process'); const fs = require('node:fs/promises'); const path = require('node:path'); const { pathToFileURL } = require('node:url'); -const { macPythonCandidates, validMacPython, MACOS_HELP } = require('./macos-python.cjs'); +const { macPythonCandidates, validMacPython } = require('./macos-python.cjs'); +const { create } = require('./i18n.js'); +const { createLanguage } = require('./language.cjs'); const SETUP_URL = pathToFileURL(path.join(__dirname, 'setup.html')).href; -const HELP = 'Install WSL and Python 3.10+ with venv support in the selected distribution first.\n\n' - + 'For Ubuntu/Debian, run this yourself in WSL:\nsudo apt install python3 python3-venv\n\n' - + 'StandTerm never runs sudo or installs system Python automatically.'; -const WINDOWS_HELP = 'Install 64-bit Python 3.10+ with venv support on Windows first.\n\n' - + 'StandTerm can locate python.exe on PATH or let you select it. Microsoft Store aliases and py.exe ' - + 'are not launched automatically. No system Python installation or administrator access is requested.'; const PYTHON_PROBE = 'import sys, struct, importlib.util, json; print(json.dumps({"type":"python_info",' + '"executable":sys.executable,"platform":sys.platform,"version":list(sys.version_info[:2]),' + '"machine":__import__("platform").machine(),"bits":struct.calcsize("P")*8,"venv":bool(importlib.util.find_spec("venv") and importlib.util.find_spec("ensurepip"))}))'; -const ERRORS = { - python_required: HELP, - venv_failed: `Python could not create the private venv.\n\n${HELP}`, - dependencies_failed: 'Dependency installation or verification failed. Check internet access and the runtime setup.log, then retry.', - setup_busy: 'This runtime is in use by StandTerm or another setup. Quit that desktop mode before retrying.', - modified_runtime: 'The managed Core contains modified files. Setup will not overwrite them.', - unsafe_runtime_path: 'The runtime directory is not safe to use. Setup will not overwrite unrelated files or follow directory links.', - invalid_bundle: 'The bundled Core failed its integrity check. Reinstall StandTerm Desktop.', - setup_canceled: 'Setup was canceled. Restart StandTerm to retry.', - setup_timeout: 'Setup timed out. Check internet access and retry.', - git_required: 'Git is unavailable in the selected backend environment. Install Git there, or restore the bundled Core.', - git_dirty: 'The Git Core has local changes. Keep them or resolve them in its checkout before updating. Nothing was reset or stashed.', - git_diverged: 'The Git Core cannot fast-forward to the official branch. Local history is retained. Restore bundled Core or resolve the checkout manually.', - git_source_changed: 'The managed Git origin or branch has changed. Expected the official repository and main branch.', - invalid_git_workspace: 'The private Git checkout is incomplete or damaged. It is retained. Restore the bundled Core or repair that checkout manually.', - git_needs_setup: 'The Git Core requirements changed or its environment is missing. Use Prepare Git environment from Core source (Advanced).', - git_failed: 'Git could not complete the operation. Check network access and the runtime setup.log. Prepared files are retained.', - invalid_archive: 'The recovery archive failed verification. Reinstall StandTerm Desktop if its installed bundle is also damaged.', - setup_failed: 'Setup failed. Check the selected Python environment and available disk space.', -}; +const ERROR_CODES = ['dependencies_failed', 'setup_busy', 'modified_runtime', 'unsafe_runtime_path', + 'invalid_bundle', 'setup_canceled', 'setup_timeout', 'git_required', 'git_dirty', 'git_diverged', + 'git_source_changed', 'invalid_git_workspace', 'git_needs_setup', 'git_failed', 'invalid_archive', 'setup_failed']; let window; +let setupLanguage = create('en'); let current; let canceled = false; let closeRequest; let setupFinished; let executionFinished; -const canceledError = () => Object.assign(new Error(ERRORS.setup_canceled), { code: 'SETUP_CANCELED' }); +const canceledError = (t = setupLanguage.t) => Object.assign(new Error(t('desktop.setup.error_setup_canceled')), { code: 'SETUP_CANCELED' }); function focusSetup() { if (window && !window.isDestroyed()) { window.show(); window.focus(); } } function cancelSetup() { canceled = true; current?.cancelSetup?.(); } @@ -58,20 +38,20 @@ async function requestSetupCancel() { if (!window || window.isDestroyed() || canceled) return true; if (closeRequest) return closeRequest; const target = window; + const { t } = setupLanguage; closeRequest = dialog.showMessageBox(target, { - type: 'question', title: 'Cancel StandTerm setup?', - message: 'The Python environment is still being prepared.', - detail: 'Keep this window open or minimize it to continue. Canceling stops the owned installation processes; ' - + 'prepared files are retained so you can retry on the next launch.', - buttons: ['Keep preparing', 'Cancel setup'], defaultId: 0, cancelId: 0, noLink: true, + type: 'question', title: t('desktop.setup.cancel_title'), + message: t('desktop.setup.cancel_message'), + detail: t('desktop.setup.cancel_detail'), + buttons: [t('desktop.setup.keep_preparing'), t('desktop.setup.cancel_setup')], defaultId: 0, cancelId: 0, noLink: true, }).then(answer => { // Setup may finish while the confirmation is open. Do not cancel a // completed setup or apply its stale answer to a subsequent window. if (window !== target || target.isDestroyed() || answer.response !== 1) return false; cancelSetup(); - target.setTitle('StandTerm Desktop - Canceling setup'); + target.setTitle(t('desktop.setup.canceling_title')); void target.webContents.executeJavaScript( - "document.getElementById('stage').textContent = 'Canceling setup. Waiting for installation processes to stop...';", + `document.getElementById('stage').textContent = ${JSON.stringify(t('desktop.setup.canceling_status'))}`, ).catch(() => {}); return true; }).catch(() => false).finally(() => { closeRequest = null; }); @@ -87,9 +67,9 @@ async function confirmSetupQuit() { return true; } -function execute(executable, args, { encoding = 'utf8', timeout = 20000, progress = null, stream = false, help = HELP } = {}) { +function execute(executable, args, { encoding = 'utf8', timeout = 20000, progress = null, stream = false, t = create('en').t, help = t('desktop.setup.help_wsl') } = {}) { const running = new Promise((resolve, reject) => { - if (canceled) { reject(canceledError()); return; } + if (canceled) { reject(canceledError(t)); return; } const child = spawn(executable, args, { windowsHide: true, shell: false, stdio: ['pipe', 'pipe', 'pipe'], env: { ...process.env, PYTHON_MANAGER_AUTOMATIC_INSTALL: 'false' } }); current = child; @@ -108,8 +88,8 @@ function execute(executable, args, { encoding = 'utf8', timeout = 20000, progres // the outer launcher. A new setup cannot race a still-closing process. forceTimer = setTimeout(() => { child.kill(); reject(abortError); }, 25000); } - child.cancelSetup = () => abort(canceledError()); - const timer = setTimeout(() => abort(new Error(ERRORS.setup_timeout)), timeout); + child.cancelSetup = () => abort(canceledError(t)); + const timer = setTimeout(() => abort(new Error(t('desktop.setup.error_setup_timeout'))), timeout); child.on('error', () => { clearTimeout(timer); clearTimeout(forceTimer); reject(new Error(help)); }); child.stdout.on('data', bytes => { output = Buffer.concat([output, bytes]); @@ -130,13 +110,13 @@ function execute(executable, args, { encoding = 'utf8', timeout = 20000, progres clearTimeout(timer); clearTimeout(forceTimer); if (current === child) current = null; - if (canceled) reject(canceledError()); + if (canceled) reject(canceledError(t)); else if (abortError) reject(abortError); else if (protocolError || (stream && output.length)) reject(new Error('Invalid setup control response.')); else if (stream) { const result = frames.at(-1); if (code !== 0 || !result || result.type === 'error') reject(Object.assign(new Error( - ['python_required', 'venv_failed'].includes(result?.code) ? help : ERRORS[result?.code] || help), { code: result?.code })); + ERROR_CODES.includes(result?.code) ? t(`desktop.setup.error_${result.code}`) : help), { code: result?.code })); else resolve(result); } else if (code !== 0) reject(new Error(help)); else resolve(output.toString(encoding).replace(/^\uFEFF/, '').trim()); @@ -146,63 +126,66 @@ function execute(executable, args, { encoding = 'utf8', timeout = 20000, progres return running; } -async function windowsPython(saved) { +async function windowsPython(saved, t) { + const help = t('desktop.setup.help_windows'); const candidates = []; if (typeof saved === 'string') candidates.push(saved); try { - candidates.push(...(await execute('where.exe', ['python.exe'], { help: WINDOWS_HELP })).split(/\r?\n/)); + candidates.push(...(await execute('where.exe', ['python.exe'], { help, t })).split(/\r?\n/)); } catch { /* Manual selection remains available when PATH has no Python. */ } async function probe(candidate) { if (!path.win32.isAbsolute(candidate) || /\\Microsoft\\WindowsApps\\/i.test(candidate) - || !/^python(?:3(?:\.\d+)?)?\.exe$/i.test(path.win32.basename(candidate))) throw new Error(WINDOWS_HELP); - const result = await execute(candidate, ['-I', '-c', PYTHON_PROBE], { stream: true, help: WINDOWS_HELP }); + || !/^python(?:3(?:\.\d+)?)?\.exe$/i.test(path.win32.basename(candidate))) throw new Error(help); + const result = await execute(candidate, ['-I', '-c', PYTHON_PROBE], { stream: true, help, t }); if (result.type !== 'python_info' || result.platform !== 'win32' || result.bits !== 64 || !result.venv || !Array.isArray(result.version) || result.version[0] !== 3 || result.version[1] < 10 - || !path.win32.isAbsolute(result.executable)) throw new Error(WINDOWS_HELP); + || !path.win32.isAbsolute(result.executable)) throw new Error(help); return result.executable; } for (const candidate of [...new Set(candidates)].slice(0, 5)) { - try { return await probe(candidate.trim()); } catch { if (canceled) throw canceledError(); } + try { return await probe(candidate.trim()); } catch { if (canceled) throw canceledError(t); } } - const answer = await dialog.showMessageBox({ type: 'info', title: 'StandTerm Desktop: Python required', - message: WINDOWS_HELP, buttons: ['Cancel', 'Select installed python.exe...'], defaultId: 0, cancelId: 0 }); - if (answer.response !== 1) throw canceledError(); - const selected = await dialog.showOpenDialog({ title: 'Select an installed 64-bit Python interpreter', - properties: ['openFile'], filters: [{ name: 'Python executable', extensions: ['exe'] }] }); - if (selected.canceled || selected.filePaths.length !== 1) throw canceledError(); + const answer = await dialog.showMessageBox({ type: 'info', title: t('desktop.setup.python_required_title'), + message: help, buttons: [t('desktop.common.cancel'), t('desktop.setup.select_python_windows')], defaultId: 0, cancelId: 0 }); + if (answer.response !== 1) throw canceledError(t); + const selected = await dialog.showOpenDialog({ title: t('desktop.setup.select_python_windows_title'), + properties: ['openFile'], filters: [{ name: t('desktop.setup.python_executable'), extensions: ['exe'] }] }); + if (selected.canceled || selected.filePaths.length !== 1) throw canceledError(t); return probe(selected.filePaths[0]); } -async function macosPython(saved) { +async function macosPython(saved, t) { + const help = t('desktop.setup.help_macos'); async function probe(candidate) { if (!path.posix.isAbsolute(candidate) || /[\r\n\0]/.test(candidate) || candidate === '/usr/bin/python3') { - throw new Error(MACOS_HELP); + throw new Error(help); } - const result = await execute(candidate, ['-I', '-c', PYTHON_PROBE], { stream: true, help: MACOS_HELP }); - if (!validMacPython(result, process.arch)) throw new Error(MACOS_HELP); + const result = await execute(candidate, ['-I', '-c', PYTHON_PROBE], { stream: true, help, t }); + if (!validMacPython(result, process.arch)) throw new Error(help); return result.executable; } for (const candidate of macPythonCandidates(saved, process.env)) { - try { return await probe(candidate); } catch { if (canceled) throw canceledError(); } + try { return await probe(candidate); } catch { if (canceled) throw canceledError(t); } } - const answer = await dialog.showMessageBox({ type: 'info', title: 'StandTerm Desktop: Python required', - message: MACOS_HELP, buttons: ['Cancel', 'Select installed Python...'], defaultId: 0, cancelId: 0 }); - if (answer.response !== 1) throw canceledError(); - const selected = await dialog.showOpenDialog({ title: 'Select a native macOS Python 3.10+ interpreter', + const answer = await dialog.showMessageBox({ type: 'info', title: t('desktop.setup.python_required_title'), + message: help, buttons: [t('desktop.common.cancel'), t('desktop.setup.select_python_macos')], defaultId: 0, cancelId: 0 }); + if (answer.response !== 1) throw canceledError(t); + const selected = await dialog.showOpenDialog({ title: t('desktop.setup.select_python_macos_title'), properties: ['openFile'] }); - if (selected.canceled || selected.filePaths.length !== 1) throw canceledError(); + if (selected.canceled || selected.filePaths.length !== 1) throw canceledError(t); return probe(selected.filePaths[0]); } -async function setupEnvironment(mode, installer = false) { +async function setupEnvironment(mode, installer, language) { + const { t } = language; if (!['windows', 'wsl', 'macos'].includes(mode)) throw new Error('Choose a supported desktop backend.'); const windows = mode === 'windows'; const macos = mode === 'macos'; const native = windows || macos; - const help = macos ? MACOS_HELP : windows ? WINDOWS_HELP : HELP; + const help = t(`desktop.setup.help_${mode}`); const bundle = path.join(process.resourcesPath, 'bundle'); const metadata = JSON.parse(await fs.readFile(path.join(bundle, 'manifest.json'), 'utf8')); - if (!/^[a-f0-9]{64}$/.test(metadata.id)) throw new Error(ERRORS.invalid_bundle); + if (!/^[a-f0-9]{64}$/.test(metadata.id)) throw new Error(t('desktop.setup.error_invalid_bundle')); const settingsPath = path.join(installer ? modeProfile(mode) : app.getPath('userData'), 'launcher.json'); let settings; try { @@ -214,7 +197,7 @@ async function setupEnvironment(mode, installer = false) { let args; let saved; if (native) { - executable = await (macos ? macosPython(settings?.python) : windowsPython(settings?.python)); + executable = await (macos ? macosPython(settings?.python, t) : windowsPython(settings?.python, t)); args = ['-I', path.join(bundle, 'bootstrap.py'), '--bundle', bundle]; saved = { version: 1, python: executable }; } else { @@ -226,48 +209,48 @@ async function setupEnvironment(mode, installer = false) { if (legacy.version === 1 && typeof legacy.distro === 'string') settings = legacy; } catch { /* A fresh selection is safe if the legacy file is absent/invalid. */ } } - const distributions = (await execute('wsl.exe', ['--list', '--quiet'], { encoding: 'utf16le' })) + const distributions = (await execute('wsl.exe', ['--list', '--quiet'], { encoding: 'utf16le', t, help })) .split(/\r?\n/).map(item => item.trim()).filter(Boolean); - if (!distributions.length) throw new Error(HELP); + if (!distributions.length) throw new Error(help); distro = settings?.distro; if (installer || !distributions.includes(distro)) { const choices = distributions.slice(0, 12); const result = await dialog.showMessageBox({ - type: 'question', title: 'StandTerm Desktop: select WSL', - message: 'Select an existing WSL distribution for StandTerm Core.', detail: HELP, - buttons: [...choices, 'Cancel'], cancelId: choices.length, defaultId: choices.length, + type: 'question', title: t('desktop.setup.select_wsl_title'), + message: t('desktop.setup.select_wsl_message'), detail: help, + buttons: [...choices, t('desktop.common.cancel')], cancelId: choices.length, defaultId: choices.length, noLink: true, }); - if (result.response >= choices.length) throw canceledError(); + if (result.response >= choices.length) throw canceledError(t); distro = choices[result.response]; } const prefix = ['--distribution', distro, '--exec']; - const linuxBundle = await execute('wsl.exe', [...prefix, 'wslpath', '-u', bundle]); - if (!linuxBundle.startsWith('/') || /[\r\n\0]/.test(linuxBundle)) throw new Error(ERRORS.invalid_bundle); + const linuxBundle = await execute('wsl.exe', [...prefix, 'wslpath', '-u', bundle], { t }); + if (!linuxBundle.startsWith('/') || /[\r\n\0]/.test(linuxBundle)) throw new Error(t('desktop.setup.error_invalid_bundle')); executable = 'wsl.exe'; args = [...prefix, 'python3', '-I', `${linuxBundle}/bootstrap.py`, '--bundle', linuxBundle]; saved = { version: 1, distro }; } - return { windows, macos, native, help, bundle, metadata, settingsPath, executable, args, saved, distro }; + return { language, t, windows, macos, native, help, bundle, metadata, settingsPath, executable, args, saved, distro }; } -async function preparePackagedBackend(mode, { installer = false } = {}) { - const environment = await setupEnvironment(mode, installer); - const { windows, macos, native, help, metadata, settingsPath, executable, args, saved, distro } = environment; - let result = await execute(executable, args, { stream: true, timeout: 60000, help }); +async function preparePackagedBackend(mode, { installer = false, + language = createLanguage(path.join(installer ? modeProfile(mode) : app.getPath('userData'), 'language.json')) } = {}) { + const environment = await setupEnvironment(mode, installer, language); + const { t, windows, macos, native, help, metadata, settingsPath, executable, args, saved, distro } = environment; + let result = await execute(executable, args, { stream: true, timeout: 60000, help, t }); if (result.type === 'needs_setup') { const answer = await dialog.showMessageBox({ - type: 'question', title: 'Prepare StandTerm Core', - message: `Create a private StandTerm environment in ${macos ? 'macOS' : windows ? 'Windows' : distro}?`, - detail: `Requires Python 3.10+ and venv support ${macos ? 'on native macOS' : windows ? 'on Windows (64-bit)' : 'inside WSL'}.\n\n` - + `This copies the bundled Core into ${macos ? '~/Library/Application Support/StandTermDesktop/runtimes/' : windows ? '%LOCALAPPDATA%\\StandTermDesktop\\runtimes\\' : '~/.local/share/standterm-desktop/runtimes/'}, creates its own venv, ` - + 'and downloads and installs Python dependencies from your configured package index. ' - + 'Dependencies can execute installation code. Internet access and disk space are required.\n\n' - + 'No system Python installation, sudo, Git checkout changes or existing-session interruption. ' - + 'Failed setup is retained for retry. Uninstall keeps environments by default; optional cleanup moves only verified idle venvs to a recovery folder. Core and user data are retained.', - buttons: ['Cancel', 'Create environment and install dependencies'], defaultId: 0, cancelId: 0, + type: 'question', title: t('desktop.setup.prepare_title'), + message: t('desktop.setup.prepare_message', { platform: macos ? 'macOS' : windows ? 'Windows' : distro }), + detail: t('desktop.setup.prepare_detail', { + requirements: t(`desktop.setup.requirements_${mode}`), + path: macos ? '~/Library/Application Support/StandTermDesktop/runtimes/' + : windows ? '%LOCALAPPDATA%\\StandTermDesktop\\runtimes\\' : '~/.local/share/standterm-desktop/runtimes/', + }), + buttons: [t('desktop.common.cancel'), t('desktop.setup.create_environment')], defaultId: 0, cancelId: 0, }); - if (answer.response !== 1) throw canceledError(); + if (answer.response !== 1) throw canceledError(t); result = await runPreparation(environment, [...args, '--prepare']); } const runtimePath = windows ? path.win32 : path.posix; @@ -285,7 +268,8 @@ async function preparePackagedBackend(mode, { installer = false } = {}) { '-u', `${result.root}/desktop/backend.py`], cwd: process.resourcesPath }; } -async function cleanupManagedVenvs(mode) { +async function cleanupManagedVenvs(mode, { language = createLanguage(path.join(modeProfile(mode), 'language.json')) } = {}) { + const { t } = language; if (!['windows', 'wsl'].includes(mode)) throw new Error('Environment cleanup is available through the Windows installer only.'); // Only the configured interpreter/distribution is considered. Never discover // other projects or provision a WSL distribution during uninstallation. @@ -310,26 +294,25 @@ async function cleanupManagedVenvs(mode) { return { mode, status: 'retained', reason: 'No configured WSL distribution.' }; } const prefix = ['--distribution', settings.distro, '--exec']; - const linuxBundle = await execute('wsl.exe', [...prefix, 'wslpath', '-u', bundle]); - if (!linuxBundle.startsWith('/') || /[\r\n\0]/.test(linuxBundle)) throw new Error(ERRORS.invalid_bundle); + const linuxBundle = await execute('wsl.exe', [...prefix, 'wslpath', '-u', bundle], { t }); + if (!linuxBundle.startsWith('/') || /[\r\n\0]/.test(linuxBundle)) throw new Error(t('desktop.setup.error_invalid_bundle')); executable = 'wsl.exe'; args = [...prefix, 'python3', '-I', `${linuxBundle}/runtime_cleanup.py`]; } const inventory = await execute(executable, [...args, '--inventory'], { stream: true, timeout: 60000, - help: 'Environment inventory was unavailable. No venv cleanup was started.' }); + t, help: t('desktop.setup.cleanup_inventory_unavailable') }); if (inventory.type !== 'cleanup_inventory' || !Array.isArray(inventory.results) || inventory.results.length > 32 || inventory.results.some(item => !/^[a-f0-9]{64}$/.test(item.id) || typeof item.source !== 'string' || !['retained', 'candidate'].includes(item.status))) throw new Error('Invalid cleanup inventory.'); const candidates = inventory.results.filter(item => item.status === 'candidate'); if (!candidates.length) return { mode, status: 'checked', results: inventory.results }; - const answer = await dialog.showMessageBox({ type: 'question', title: 'Confirm environment cleanup', - message: `Move these idle venvs to recovery in ${mode === 'windows' ? 'Windows' : `WSL: ${settings.distro}`}?`, - detail: candidates.map(item => item.source).join('\n') + '\n\nNo disk space is freed. Core and settings are retained. ' - + 'Any environment that becomes busy or fails verification will be retained. Cancel keeps all listed venvs.', - buttons: ['Keep environments', 'Move listed venvs to recovery'], defaultId: 0, cancelId: 0, noLink: true }); + const answer = await dialog.showMessageBox({ type: 'question', title: t('desktop.setup.cleanup_title'), + message: t('desktop.setup.cleanup_message', { platform: mode === 'windows' ? 'Windows' : `WSL: ${settings.distro}` }), + detail: t('desktop.setup.cleanup_detail', { paths: candidates.map(item => item.source).join('\n') }), + buttons: [t('desktop.setup.cleanup_keep'), t('desktop.setup.cleanup_move')], defaultId: 0, cancelId: 0, noLink: true }); if (answer.response !== 1) return { mode, status: 'retained', reason: 'User kept environments.' }; const result = await execute(executable, [...args, '--detach-idle-venvs', JSON.stringify(candidates.map(item => item.id))], - { stream: true, timeout: 60000, help: 'Cleanup did not report completion. Check venv-recovery before retrying.' }); + { stream: true, timeout: 60000, t, help: t('desktop.setup.cleanup_unconfirmed') }); if (result.type !== 'cleanup_summary' || !Array.isArray(result.results) || result.results.length > 32 || result.results.some(item => !/^[a-f0-9]{64}$/.test(item.id) || !['retained', 'detached'].includes(item.status))) { throw new Error('Invalid cleanup response.'); @@ -340,11 +323,11 @@ async function cleanupManagedVenvs(mode) { module.exports = { preparePackagedBackend, focusSetup, cancelSetup, confirmSetupQuit, stopSetup, cleanupManagedVenvs, modeProfile, manageCore }; -async function manageCore(mode, action) { +async function manageCore(mode, action, { language = createLanguage(path.join(app.getPath('userData'), 'language.json')) } = {}) { if (!['status', 'enable', 'update', 'prepare', 'check', 'recover'].includes(action)) throw new Error('Invalid Core action.'); // Select the base interpreter without requiring either Core or venv to work. - const environment = await setupEnvironment(mode); - const { windows, macos, native, help, executable, args, metadata, settingsPath, saved, distro } = environment; + const environment = await setupEnvironment(mode, false, language); + const { t, windows, macos, native, help, executable, args, metadata, settingsPath, saved, distro } = environment; const runtimePath = windows ? path.win32 : path.posix; const scriptIndex = args.indexOf('-I') + 1; const bundle = args[args.indexOf('--bundle') + 1]; @@ -352,7 +335,7 @@ async function manageCore(mode, action) { managedArgs[scriptIndex] = runtimePath.join(bundle, 'core_manager.py'); managedArgs.push('--action', action); const result = ['status', 'check'].includes(action) - ? await execute(executable, managedArgs, { stream: true, timeout: 60000, help }) + ? await execute(executable, managedArgs, { stream: true, timeout: 60000, help, t }) : await runPreparation(environment, managedArgs); if (action === 'status') { if (result.type !== 'core_status' || typeof result.git_available !== 'boolean' @@ -383,12 +366,13 @@ async function manageCore(mode, action) { } async function runPreparation(environment, args) { - const { executable, help, macos, windows, distro } = environment; + const { language, t, executable, help, macos, windows, distro } = environment; + setupLanguage = language; const isolated = session.fromPartition('standterm-setup'); isolated.setPermissionRequestHandler((_contents, _permission, callback) => callback(false)); isolated.setPermissionCheckHandler(() => false); isolated.webRequest.onBeforeRequest((details, callback) => callback({ cancel: details.url !== SETUP_URL })); - window = new BrowserWindow({ title: 'StandTerm Desktop - Preparing environment', + window = new BrowserWindow({ title: t('desktop.setup.progress_title'), width: 700, height: 500, resizable: false, autoHideMenuBar: true, webPreferences: { session: isolated, sandbox: true, contextIsolation: true, nodeIntegration: false, devTools: false } }); window.webContents.setWindowOpenHandler(() => ({ action: 'deny' })); @@ -402,14 +386,28 @@ async function runPreparation(environment, args) { setupFinished = new Promise(resolve => { finishSetup = resolve; }); try { await window.loadURL(SETUP_URL); - await window.webContents.executeJavaScript(`document.getElementById('requirements').textContent = ${JSON.stringify( - macos ? 'Preparing Core for native macOS.' : windows ? 'Preparing Core for native Windows.' : `Preparing Core inside WSL: ${distro}.`)}`); - return await execute(executable, args, { stream: true, timeout: 30 * 60 * 1000, help, progress: stage => { - const labels = { git: 'Updating the private Git checkout...', copy: 'Copying verified Core files...', venv: 'Creating the private Python environment...', - dependencies: 'Installing Python dependencies. This can take several minutes...', verify: 'Verifying the installed dependencies...' }; - if (labels[stage] && !canceled && !window.isDestroyed()) void window.webContents.executeJavaScript( - `document.getElementById('stage').textContent = ${JSON.stringify(labels[stage])}`, - ).catch(() => {}); + await window.webContents.executeJavaScript(`(() => { + const copy = ${JSON.stringify({ + locale: language.locale, title: t('desktop.setup.progress_title'), + heading: t('desktop.setup.progress_heading'), + requirements: t(`desktop.setup.preparation_${macos ? 'macos' : windows ? 'windows' : 'wsl'}`, { distro }), + stage: t('desktop.setup.progress_starting'), detail: t('desktop.setup.progress_detail'), + closeHint: t('desktop.setup.progress_close_hint'), scope: t('desktop.setup.progress_scope'), + aria: t('desktop.setup.progress_aria'), + })}; + document.documentElement.lang = copy.locale; + document.title = copy.title; + for (const id of ['heading', 'requirements', 'stage', 'detail', 'closeHint', 'scope']) { + document.getElementById(id).textContent = copy[id]; + } + document.querySelector('progress').setAttribute('aria-label', copy.aria); + })()`); + return await execute(executable, args, { stream: true, timeout: 30 * 60 * 1000, help, t, progress: stage => { + if (['git', 'copy', 'venv', 'dependencies', 'verify'].includes(stage) && !canceled && !window.isDestroyed()) { + void window.webContents.executeJavaScript( + `document.getElementById('stage').textContent = ${JSON.stringify(t(`desktop.setup.stage_${stage}`))}`, + ).catch(() => {}); + } } }); } finally { if (!window.isDestroyed()) { window.removeListener('closed', cancelSetup); window.destroy(); } diff --git a/desktop/setup.html b/desktop/setup.html index 4a3a2d8..44d4024 100644 --- a/desktop/setup.html +++ b/desktop/setup.html @@ -3,10 +3,10 @@ StandTerm Desktop Setup -

Preparing your Python environment

+

Preparing your Python environment

Preparing StandTerm Core...

Starting setup...

-

Creating a private venv and installing dependencies may take several minutes. The progress indicator does not represent a completion percentage.

-

You can minimize this window while setup continues. Closing it asks for confirmation before canceling.

-StandTerm uses your installed Python; it does not install system Python, run sudo, or modify an existing Git checkout. If canceled, prepared files are retained for retry. +

Creating a private venv and installing dependencies may take several minutes. The progress indicator does not represent a completion percentage.

+

You can minimize this window while setup continues. Closing it asks for confirmation before canceling.

+StandTerm uses your installed Python; it does not install system Python or run sudo. If canceled, prepared files are retained for retry. diff --git a/desktop/test/core-source-i18n.test.cjs b/desktop/test/core-source-i18n.test.cjs new file mode 100644 index 0000000..1bc3407 --- /dev/null +++ b/desktop/test/core-source-i18n.test.cjs @@ -0,0 +1,115 @@ +'use strict'; + +const test = require('node:test'); +const assert = require('node:assert/strict'); +const { coreController } = require('../core-source.cjs'); +const { create } = require('../i18n.js'); + +function fixture(locale, { source = 'bundled', status = { git_available: true, workspace: 'present', commit: 'a'.repeat(40), dirty: true }, responses = [], issue, pending = null, t = create(locale).t } = {}) { + const dialogs = []; + const restarts = []; + const managed = []; + let logs = 0; + const controller = coreController({ t, + store: { read: async () => ({ source }), consume: async () => ({ source, pending }), select: async () => assert.fail('Dialogs must not select a source') }, + manage: async action => { managed.push(action); if (issue) throw new Error(issue); return status; }, + restart: action => restarts.push(action), openLogs: async () => { logs++; }, + dialog: { showMessageBox: async options => { + assert.equal(options.defaultId, 0); + assert.equal(options.cancelId, 0); + assert.equal(options.noLink, true); + dialogs.push(options); + assert.ok(responses.length, 'Unexpected additional dialog'); + return { response: responses.shift() }; + } }, + }); + return { controller, dialogs, restarts, managed, get logs() { return logs; } }; +} + +for (const locale of ['en', 'zh-TW']) { + const { t } = create(locale); + test(`${locale}: Core manager preserves every typed action across source and Git states`, async () => { + const cases = [ + ['bundled', false, 'absent', ['cancel', 'recover', 'logs']], + ['bundled', true, 'absent', ['cancel', 'enable', 'recover', 'logs']], + ['bundled', true, 'present', ['cancel', 'enable', 'prepare', 'update', 'recover', 'logs']], + ['git', true, 'present', ['cancel', 'prepare', 'update', 'recover', 'logs']], + ]; + for (const [source, git_available, workspace, actions] of cases) { + for (let index = 0; index < actions.length; index++) { + const action = actions[index]; + const changesSource = !['cancel', 'logs'].includes(action); + const f = fixture(locale, { source, status: { git_available, workspace }, responses: changesSource ? [index, 1] : [index] }); + await f.controller.showManager(); + assert.equal(f.dialogs[0].buttons.length, actions.length); + assert.equal(f.dialogs[0].title, t('desktop.core_source.manager_title')); + assert.equal(f.dialogs[0].message, t('desktop.core_source.source', { source: source === 'git' ? 'Git' : t('desktop.core_source.bundled') })); + assert.deepEqual(f.managed, ['status']); + assert.deepEqual(f.restarts, changesSource ? [action] : []); + assert.equal(f.logs, action === 'logs' ? 1 : 0); + if (changesSource) { + const confirm = f.dialogs[1]; + assert.deepEqual(confirm.buttons, [t('desktop.common.cancel'), t('desktop.core_source.restart_continue')]); + assert.equal(confirm.message, t(action === 'recover' ? 'desktop.core_source.confirm_recover' : 'desktop.core_source.confirm_git')); + assert.ok(confirm.detail.includes(t('desktop.core_source.restart_notice'))); + assert.ok(confirm.detail.includes(t('desktop.core_source.reauthorization'))); + assert.ok(confirm.detail.includes(t(action === 'recover' ? 'desktop.core_source.recovery_retention' : 'desktop.core_source.git_policy'))); + } + } + } + const canceled = fixture(locale, { responses: [1, 0] }); + assert.equal(await canceled.controller.showManager(), false); + assert.deepEqual(canceled.restarts, []); + }); + + test(`${locale}: status renders known enums while preserving raw data and unknown values`, async () => { + const commit = 'raw-{workspace}-'; + for (const workspace of ['absent', 'present', 'unavailable', 'invalid', 'future-{commit}']) { + const f = fixture(locale, { status: { git_available: true, workspace, commit, dirty: true }, responses: [0] }); + await f.controller.showManager(); + const detail = f.dialogs[0].detail; + const label = workspace.startsWith('future-') ? workspace : t(`desktop.core_source.workspace_${workspace}`); + assert.ok(detail.includes(t('desktop.core_source.workspace', { workspace: label }))); + assert.ok(detail.includes(t('desktop.core_source.commit_dirty', { commit }))); + assert.ok(detail.includes(commit)); + } + const issue = 'raw failure {source} '; + const f = fixture(locale, { issue, responses: [0] }); + await f.controller.showManager(); + assert.ok(f.dialogs[0].detail.includes(issue)); + assert.ok(f.dialogs[0].detail.includes(t('desktop.core_source.git_unavailable'))); + }); + + test(`${locale}: recovery and retry keep failure-dialog response semantics`, async () => { + const issue = 'Original failure {commit} '; + const quit = fixture(locale, { responses: [0] }); + assert.equal(await quit.controller.failure(new Error(issue)), 'quit'); + assert.equal(quit.dialogs[0].message, issue); + assert.deepEqual(quit.dialogs[0].buttons, ['quit', 'retry', 'restore_bundled', 'manager_title', 'open_logs'].map(key => t(`desktop.core_source.${key}`))); + assert.equal(quit.dialogs[0].detail, t('desktop.core_source.failure_choices')); + assert.deepEqual(quit.restarts, []); + const retry = fixture(locale, { responses: [1], issue, pending: 'update' }); + await assert.rejects(retry.controller.prepare(), /Original failure/); + assert.equal(await retry.controller.failure(new Error(issue)), 'restart'); + assert.deepEqual(retry.restarts, ['update']); + const recovery = fixture(locale, { responses: [2, 1] }); + assert.equal(await recovery.controller.failure(new Error(issue)), 'restart'); + assert.deepEqual(recovery.restarts, ['recover']); + const cancelRecovery = fixture(locale, { responses: [2, 0, 0] }); + assert.equal(await cancelRecovery.controller.failure(new Error(issue)), 'quit'); + assert.deepEqual(cancelRecovery.restarts, []); + const logs = fixture(locale, { responses: [4, 0] }); + assert.equal(await logs.controller.failure(new Error(issue)), 'quit'); + assert.equal(logs.logs, 1); + assert.equal(logs.dialogs.length, 2); + const manager = fixture(locale, { responses: [3, 1, 1] }); + assert.equal(await manager.controller.failure(new Error(issue)), 'restart'); + assert.deepEqual(manager.restarts, ['enable']); + }); +} + +test('duplicate translated labels cannot select a different Core action', async () => { + const f = fixture('en', { t: () => 'same display label', responses: [3, 1] }); + await f.controller.showManager(); + assert.deepEqual(f.restarts, ['update']); +}); diff --git a/desktop/test/setup-i18n-browser-smoke.py b/desktop/test/setup-i18n-browser-smoke.py new file mode 100644 index 0000000..8428d0d --- /dev/null +++ b/desktop/test/setup-i18n-browser-smoke.py @@ -0,0 +1,70 @@ +"""Render actual setup initialization, progress and cancellation scripts in Chromium.""" + +import argparse +import json +import os +from pathlib import Path +import subprocess + + +ROOT = Path(__file__).resolve().parents[2] +os.environ.setdefault('PLAYWRIGHT_BROWSERS_PATH', str(ROOT / 'tools' / '.ms-playwright')) + +from playwright.sync_api import sync_playwright + + +def run(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--node', default='node', help='Node 22.12+ or an Electron executable in Node mode') + args = parser.parse_args() + result = subprocess.run([args.node, str(ROOT / 'desktop/test/setup-i18n-fixture.cjs')], cwd=ROOT, + env={**os.environ, 'ELECTRON_RUN_AS_NODE': '1'}, + capture_output=True, text=True, check=True, timeout=30) + snapshots = json.loads(result.stdout) + setup_url = (ROOT / 'desktop/setup.html').as_uri() + with sync_playwright() as playwright: + browser = playwright.chromium.launch(headless=True) + try: + for snapshot in snapshots: + page = browser.new_page(viewport={'width': 700, 'height': 500}) + requests = [] + errors = [] + page.on('request', lambda request: requests.append(request.url)) + page.on('pageerror', lambda error: errors.append(str(error))) + page.goto(setup_url) + page.evaluate(snapshot['init']) + assert page.locator('html').get_attribute('lang') == snapshot['locale'] + assert page.title() == snapshot['title'] + assert page.locator('h1').text_content() == snapshot['heading'] + assert page.locator('progress').get_attribute('aria-label') == snapshot['aria'] + assert page.locator('progress').get_attribute('value') is None + for element in ('requirements', 'scope', 'detail', 'closeHint'): + assert page.locator('#' + element).text_content() == snapshot[element] + assert page.locator('#stage').text_content() == snapshot['starting'] + assert page.locator('#stage').get_attribute('role') == 'status' + assert page.locator('#stage').get_attribute('aria-live') == 'polite' + if snapshot['mode'] == 'wsl': + assert snapshot['raw'] in page.locator('#requirements').text_content() + assert 'sudo' in page.locator('#scope').text_content() + for frame in snapshot['progress']: + page.evaluate(frame['script']) + assert page.locator('#stage').text_content() == frame['expected'] + page.evaluate(snapshot['cancel']) + assert page.locator('#stage').text_content() == snapshot['canceling'] + assert page.locator('script, img, iframe, a').count() == 0 + assert page.evaluate('typeof window.injected') == 'undefined' + assert page.evaluate('document.documentElement.scrollWidth <= innerWidth') + assert page.evaluate('document.documentElement.scrollHeight <= innerHeight') + csp = page.locator('meta[http-equiv="Content-Security-Policy"]').get_attribute('content') + assert "default-src 'none'" in csp + assert requests == [setup_url], requests + assert not errors, errors + page.close() + print(json.dumps({'locale': snapshot['locale'], 'mode': snapshot['mode'], + 'setup_dom': 'passed', 'native_processes': 'mocked'})) + finally: + browser.close() + + +if __name__ == '__main__': + run() diff --git a/desktop/test/setup-i18n-fixture.cjs b/desktop/test/setup-i18n-fixture.cjs new file mode 100644 index 0000000..f3e0cc0 --- /dev/null +++ b/desktop/test/setup-i18n-fixture.cjs @@ -0,0 +1,100 @@ +'use strict'; + +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const vm = require('node:vm'); +const { EventEmitter } = require('node:events'); +const { createRequire } = require('node:module'); +const { create } = require('../i18n.js'); + +const filename = path.join(__dirname, '..', 'setup.cjs'); +const nativeRequire = createRequire(filename); +const source = fs.readFileSync(filename, 'utf8'); +const raw = 'Distro

{distro}'; + +async function snapshot(locale, mode) { + const language = create(locale); + const { t } = language; + const scripts = []; + const titles = []; + let child; + let spawned; + const preparing = new Promise(resolve => { spawned = resolve; }); + class Window extends EventEmitter { + constructor(options) { + super(); + assert.equal(options.webPreferences.sandbox, true); + assert.equal(options.webPreferences.nodeIntegration, false); + this.webContents = new EventEmitter(); + this.webContents.setWindowOpenHandler = callback => assert.equal(callback().action, 'deny'); + this.webContents.executeJavaScript = async script => { scripts.push(script); }; + } + async loadURL(url) { assert.ok(url.endsWith('/desktop/setup.html')); } + isDestroyed() { return !!this.destroyed; } + destroy() { this.destroyed = true; this.emit('closed'); } + setTitle(title) { titles.push(title); } + } + const electron = { app: {}, BrowserWindow: Window, + dialog: { showMessageBox: async (_window, options) => { + assert.equal(options.defaultId, 0); + assert.equal(options.cancelId, 0); + return { response: 1 }; + } }, + session: { fromPartition: () => ({ setPermissionRequestHandler() {}, setPermissionCheckHandler() {}, + webRequest: { onBeforeRequest(callback) { + callback({ url: 'https://example.invalid/' }, result => assert.equal(result.cancel, true)); + } } }) }, + }; + function spawn() { + child = new EventEmitter(); + child.stdin = new EventEmitter(); + child.stdin.end = () => {}; + child.stdout = new EventEmitter(); + child.stderr = { resume() {} }; + child.kill = () => assert.fail('Cancellation must finish without forced termination'); + spawned(); + return child; + } + const context = vm.createContext({ module: { exports: {} }, __dirname: path.dirname(filename), + require: name => name === 'electron' ? electron : name === 'node:child_process' ? { spawn } : nativeRequire(name), + process: { env: {} }, Buffer, setTimeout, clearTimeout }); + vm.runInContext(source + '\nmodule.exports.fixture = { runPreparation, requestSetupCancel };', context, { filename }); + const fixture = context.module.exports.fixture; + const completion = fixture.runPreparation({ language, t, executable: 'fixture', help: 'fixture help', + windows: mode === 'windows', macos: mode === 'macos', distro: raw }, []); + await preparing; + assert.equal(scripts.length, 1); + const init = scripts[0]; + const progress = []; + function frame(stage) { child.stdout.emit('data', Buffer.from(JSON.stringify({ type: 'progress', stage }) + '\n')); } + for (const stage of ['git', 'copy', 'venv', 'dependencies', 'verify']) { + frame(stage); + progress.push({ stage, script: scripts.at(-1), expected: t(`desktop.setup.stage_${stage}`) }); + } + const beforeUnknown = scripts.length; + for (const stage of ['__proto__', 'constructor', '']) frame(stage); + assert.equal(scripts.length, beforeUnknown); + assert.equal(await fixture.requestSetupCancel(), true); + const cancel = scripts.at(-1); + assert.equal(scripts.length, beforeUnknown + 1); + frame('dependencies'); + assert.equal(scripts.length, beforeUnknown + 1); + child.emit('close', 1); + await assert.rejects(completion, error => error.code === 'SETUP_CANCELED'); + assert.deepEqual(titles, [t('desktop.setup.canceling_title')]); + return { locale, mode, raw, init, progress, cancel, + title: t('desktop.setup.progress_title'), heading: t('desktop.setup.progress_heading'), + requirements: t(`desktop.setup.preparation_${mode}`, { distro: raw }), + aria: t('desktop.setup.progress_aria'), scope: t('desktop.setup.progress_scope'), + detail: t('desktop.setup.progress_detail'), closeHint: t('desktop.setup.progress_close_hint'), + starting: t('desktop.setup.progress_starting'), canceling: t('desktop.setup.canceling_status') }; +} + +(async () => { + const snapshots = []; + for (const locale of ['en', 'zh-TW']) { + for (const mode of ['windows', 'macos', 'wsl']) snapshots.push(await snapshot(locale, mode)); + } + process.stdout.write(JSON.stringify(snapshots)); +})().catch(error => { console.error(error); process.exitCode = 1; }); diff --git a/desktop/test/setup.test.cjs b/desktop/test/setup.test.cjs index f923abf..3457550 100644 --- a/desktop/test/setup.test.cjs +++ b/desktop/test/setup.test.cjs @@ -7,16 +7,20 @@ const path = require('node:path'); const os = require('node:os'); const vm = require('node:vm'); const { EventEmitter } = require('node:events'); +const { create } = require('../i18n.js'); async function fixture({ consent = true, pythonMissing = false, ready = false, native = false, - macos = false, machine = 'arm64', wrongVenv = false, + macos = false, machine = 'arm64', wrongVenv = false, locale = 'en', coreError = null, holdPrepare = false, cleanupConfirm = false, closeDecision = async () => ({ response: 0 }) } = {}) { const root = await fs.mkdtemp(path.join(os.tmpdir(), 'standterm-setup-test-')); await fs.mkdir(path.join(root, 'bundle')); const id = 'a'.repeat(64); await fs.writeFile(path.join(root, 'bundle', 'manifest.json'), JSON.stringify({ id })); + const language = create(locale), { t } = language; + await fs.writeFile(path.join(root, 'language.json'), JSON.stringify({ version: 1, locale })); const calls = []; + const dialogs = [], scripts = []; let progressWindow; let preparedChild; let completePrepare; @@ -29,7 +33,7 @@ async function fixture({ consent = true, pythonMissing = false, ready = false, n assert.equal(options.webPreferences.nodeIntegration, false); this.webContents = new EventEmitter(); this.webContents.setWindowOpenHandler = () => {}; - this.webContents.executeJavaScript = async () => {}; + this.webContents.executeJavaScript = async script => { scripts.push(script); }; progressWindow = this; } async loadURL() {} @@ -41,13 +45,14 @@ async function fixture({ consent = true, pythonMissing = false, ready = false, n app: { getPath: () => root }, BrowserWindow: Window, dialog: { showMessageBox: async (...args) => { const options = args.at(-1); - if (options.title === 'Confirm environment cleanup') { + dialogs.push(options); + if (options.title === t('desktop.setup.cleanup_title')) { assert.equal(options.defaultId, 0); assert.equal(options.cancelId, 0); assert.match(options.detail, /C:\\Runtime\\tools\\.venv_win/); return { response: cleanupConfirm ? 1 : 0 }; } - if (options.title === 'Cancel StandTerm setup?') { + if (options.title === t('desktop.setup.cancel_title')) { closeDialogs++; assert.equal(args[0], progressWindow); assert.equal(options.defaultId, 0); @@ -55,9 +60,9 @@ async function fixture({ consent = true, pythonMissing = false, ready = false, n return closeDecision(); } assert.equal(options.cancelId, options.defaultId); - if (options.title === 'StandTerm Desktop: select WSL') return { response: 0 }; - if (options.title === 'StandTerm Desktop: Python required') return { response: 0 }; - assert.match(options.detail, /Requires Python 3.10\+/); + if (options.title === t('desktop.setup.select_wsl_title')) return { response: 0 }; + if (options.title === t('desktop.setup.python_required_title')) return { response: 0 }; + assert.ok(options.detail.includes(t(`desktop.setup.requirements_${macos ? 'macos' : native ? 'windows' : 'wsl'}`))); return { response: consent ? 1 : 0 }; } }, session: { fromPartition: () => ({ setPermissionRequestHandler() {}, setPermissionCheckHandler() {}, @@ -112,12 +117,13 @@ async function fixture({ consent = true, pythonMissing = false, ready = false, n }; const context = vm.createContext({ module: { exports: {} }, __dirname: path.join(__dirname, '..'), require: name => name === 'electron' ? electron : name === 'node:child_process' ? { spawn } - : name === './macos-python.cjs' ? require('../macos-python.cjs') : require(name), + : name.startsWith('./') ? require(path.join(__dirname, '..', name)) : require(name), process: { resourcesPath: root, arch: 'arm64', env: {} }, Buffer, setTimeout, clearTimeout }); vm.runInContext(await fs.readFile(path.join(__dirname, '..', 'setup.cjs'), 'utf8'), context); return { run: options => context.module.exports.preparePackagedBackend(macos ? 'macos' : native ? 'windows' : 'wsl', options), root, calls, manage: action => context.module.exports.manageCore(macos ? 'macos' : native ? 'windows' : 'wsl', action), - cleanup: () => context.module.exports.cleanupManagedVenvs(native ? 'windows' : 'wsl'), + cleanup: options => context.module.exports.cleanupManagedVenvs(native ? 'windows' : 'wsl', options), + dialogs, scripts, language, preparing, window: () => progressWindow, child: () => preparedChild, complete: () => completePrepare(), closeDialogs: () => closeDialogs, quit: () => context.module.exports.confirmSetupQuit() }; } @@ -288,3 +294,87 @@ test('a cancellation answer arriving after successful setup is ignored', async ( assert.equal(f.child().cancelRequested, undefined); assert.ok(await fs.stat(path.join(f.root, 'launcher.json'))); }); + +test('both setup languages preserve consent, backend selection and cancellation codes', async () => { + for (const locale of ['en', 'zh-TW']) for (const platform of [{}, { native: true }, { macos: true }]) { + for (const consent of [false, true]) { + const f = await fixture({ locale, consent, ...platform }); + const { t } = f.language; + if (consent) await f.run({ language: f.language }); + else await assert.rejects(f.run({ language: f.language }), { code: 'SETUP_CANCELED' }); + const confirm = f.dialogs.find(item => item.title === t('desktop.setup.prepare_title')); + assert.deepEqual(Array.from(confirm.buttons), [t('desktop.common.cancel'), t('desktop.setup.create_environment')]); + assert.equal(confirm.defaultId, 0); + assert.equal(confirm.cancelId, 0); + assert.equal(f.calls.filter(args => args.includes('--prepare')).length, Number(consent)); + assert.equal(f.calls.some(args => args.includes('--distribution')), !platform.native && !platform.macos); + if (consent) assert.ok(f.scripts[0].includes(t('desktop.setup.progress_heading'))); + } + } +}); + +test('localized cancellation waits for owned processes and ignores stale confirmations', async () => { + for (const locale of ['en', 'zh-TW']) { + const f = await fixture({ locale, holdPrepare: true, closeDecision: async () => ({ response: 1 }) }); + const running = assert.rejects(f.run(), { code: 'SETUP_CANCELED' }); + await f.preparing; + let done = false; + const quitting = f.quit().then(result => { done = true; return result; }); + await new Promise(setImmediate); + assert.equal(done, false); + assert.equal(f.child().cancelRequested, true); + assert.equal(f.window().title, f.language.t('desktop.setup.canceling_title')); + assert.ok(f.scripts.at(-1).includes(f.language.t('desktop.setup.canceling_status'))); + f.child().emit('close', 1); + await running; + assert.equal(await quitting, true); + + let answer; + const stale = await fixture({ locale, holdPrepare: true, + closeDecision: () => new Promise(resolve => { answer = resolve; }) }); + const preparing = stale.run(); + await stale.preparing; + const closing = stale.quit(); + stale.complete(); await preparing; + answer({ response: 1 }); + assert.equal(await closing, false); + assert.equal(stale.child().cancelRequested, undefined); + } +}); + +test('typed setup errors select translated messages without interpreting payload text', async () => { + for (const locale of ['en', 'zh-TW']) for (const code of ['git_dirty', 'invalid_bundle', 'setup_timeout', 'constructor', 'python_required']) { + const f = await fixture({ locale, native: true, coreError: code }); + await assert.rejects(f.manage('update'), error => { + assert.equal(error.code, code); + assert.equal(error.message, f.language.t(['constructor', 'python_required'].includes(code) + ? 'desktop.setup.help_windows' : `desktop.setup.error_${code}`)); + return true; + }); + } +}); + +test('installer preparation and cleanup use the selected mode profile language', async () => { + for (const locale of ['en', 'zh-TW']) for (const cleanupConfirm of [false, true]) for (const native of [false, true]) { + const f = await fixture({ locale, native, cleanupConfirm }); + const profile = path.join(f.root, 'StandTermDesktopEvaluation', native ? 'windows' : 'wsl'); + await fs.mkdir(profile, { recursive: true }); + await fs.writeFile(path.join(profile, 'language.json'), JSON.stringify({ version: 1, locale })); + await fs.writeFile(path.join(f.root, 'language.json'), JSON.stringify({ version: 1, locale: locale === 'en' ? 'zh-TW' : 'en' })); + await f.run({ installer: true }); + const result = await f.cleanup(); + const { t } = f.language; + const confirm = f.dialogs.find(item => item.title === t('desktop.setup.cleanup_title')); + assert.deepEqual(Array.from(confirm.buttons), [t('desktop.setup.cleanup_keep'), t('desktop.setup.cleanup_move')]); + assert.equal(result.status, cleanupConfirm ? 'checked' : 'retained'); + assert.equal(f.calls.filter(args => args.includes('--detach-idle-venvs')).length, Number(cleanupConfirm)); + assert.equal(f.calls.some(args => args.includes('--distribution')), !native); + } +}); + +test('launch language stays fixed when a different next-launch preference is saved', async () => { + const f = await fixture({ locale: 'en' }); + await fs.writeFile(path.join(f.root, 'language.json'), JSON.stringify({ version: 1, locale: 'zh-TW' })); + await f.run({ language: f.language }); + assert.ok(f.dialogs.some(item => item.title === 'Prepare StandTerm Core')); +}); diff --git a/docs/desktop_ui_copy_review.tsv b/docs/desktop_ui_copy_review.tsv index 4710519..19e755e 100644 --- a/docs/desktop_ui_copy_review.tsv +++ b/docs/desktop_ui_copy_review.tsv @@ -30,17 +30,17 @@ desktop.capture.capture_failed The terminal view could not be captured. Bring St desktop.capture.partial_file Unfinished file retained at:\n{path} Partial screenshot kept at:\n{path} 未完成的截圖已保留於:\n{path} Suffix added only if failed screenshot output has a partial path; it is not a successful PNG save notice. {path} Keep the raw path and preceding error. Do not delete or overwrite partial files, promise readability, or automatically retry. Preserve conditional display only when output.partial exists. translation-reviewed desktop/capture.cjs:screenshot catch:163-166 desktop.capture.partial_recording Unfinished recording retained at:\n{path} Partial recording kept at:\n{path} 未完成的錄影已保留於:\n{path} Recording failure suffix after recorder teardown and output close; partial WebM may be incomplete. {path} Keep raw error and path; do not promise playable or recoverable video, delete partial output, publish it as complete, or retry automatically. translation-reviewed desktop/capture.cjs:finish:311-324 desktop.capture.stop_confirm Stop and save the recording before {action}? Replaced by desktop.capture.confirm_close and desktop.capture.confirm_quit. The caller supplies typed close/quit actions; complete translated sentences no longer interpolate an English action fragment. {action} Remove this message from runtime catalogs. Keep the original source and {action} placeholder here for review history only. The authorized implementation cancels the current close/quit if stop/save is not confirmed; preserve the window, show the error and any partial path, and do not retry automatically. remove desktop/capture.cjs:confirmStop:329-339 -desktop.setup.cancel_detail Keep this window open or minimize it to continue. Canceling stops the owned installation processes; prepared files are retained so you can retry on the next launch. Keep this window open or minimize it to continue. Canceling stops this setup’s installation processes and keeps prepared files. Relaunch StandTerm to try again. Close confirmation during preparation. Cancellation targets owned setup processes and waits for cleanup; prepared files remain. Keep button index 0 Keep preparing and 1 Cancel setup; defaultId=0/cancelId=0. Preserve stale-window check and cooperative cancellation. Do not imply all Python processes are stopped or retry is automatic. proposed desktop/setup.cjs:requestSetupCancel:61-76 -desktop.setup.install_effects and downloads and installs Python dependencies from your configured package index. Dependencies can execute installation code. Internet access and disk space are required. Python dependencies are downloaded from your configured package index and installed in the private environment. Installation can run code and requires internet access and disk space. Exact dependency-effects fragment in preparePackagedBackend confirmation detail; preceding Core-copy/path/Python requirements and following retention policy remain separate. Keep existing environment/backend selection and raw destination path. Confirm response 1 creates the environment; 0/defaultId/cancelId cancel. Do not imply bundled or verified dependencies, system Python installation, or privilege elevation. proposed desktop/setup.cjs:preparePackagedBackend:262-268 -desktop.setup.retention Failed setup is retained for retry. Uninstall keeps environments by default; optional cleanup moves only verified idle venvs to a recovery folder. Core and user data are retained. Failed setup files are kept for retry. Uninstall keeps environments by default. Optional cleanup moves verified idle venvs to recovery and keeps Core files and user data. Exact retention fragment of the initial setup confirmation. The cleanup operation is a recoverable move rather than disk-space reclamation. Keep venv-only cleanup boundary and verified/idle checks. Do not imply uninstall deletes Core or user data, that partial setup is ready, or that retry occurs without a user action. proposed desktop/setup.cjs:preparePackagedBackend:267 -desktop.setup.cleanup_detail {paths}\n\nNo disk space is freed. Core and settings are retained. Any environment that becomes busy or fails verification will be retained. Cancel keeps all listed venvs. {paths}\n\nThis move does not free disk space. Core and settings stay in place. Busy or unverified environments are kept. Cancel keeps every listed venv. Cleanup confirmation detail after candidate inventory, before detach-idle-venvs. Candidate paths are shown as a raw list. {paths} Keep candidate IDs and source paths literal. Button index 0 keeps environments; index 1 moves listed candidates. defaultId=0/cancelId=0. Recheck eligibility; no deletion or permission expansion. proposed desktop/setup.cjs:cleanupManagedVenvs:319-332 -desktop.setup.cleanup_unconfirmed Cleanup did not report completion. Check venv-recovery before retrying. Cleanup completion was not confirmed. Check venv-recovery before retrying. execute help when detach-idle-venvs does not provide confirmed completion; moves may have partially occurred. Keep recovery-directory name venv-recovery literal. Do not claim no files moved, roll back, delete, or automatically retry. proposed desktop/setup.cjs:cleanupManagedVenvs:331-333 -desktop.setup.timeout Setup timed out. Check internet access and retry. Setup timed out. Check internet access and the setup log before retrying. Fixed ERRORS.setup_timeout message. Timeout does not guarantee prepared files were removed or no installation work occurred. Keep setup_timeout error key, timeout values, cancellation and retained files unchanged. No automatic retry or promise of a clean environment. proposed desktop/setup.cjs:ERRORS.setup_timeout:29 -desktop.core_source.restart_notice This restarts StandTerm and closes terminal sessions. Python dependencies may be downloaded and installed. StandTerm will restart and close terminal sessions. Python dependencies may be downloaded and installed. Common consequence fragment for source enable/update/prepare/recover confirmations. Keep action IDs enable/update/prepare/recover and source values bundled/git. Button index 1 restarts; 0/defaultId/cancelId cancel. Do not treat translated labels as action IDs. proposed desktop/core-source.cjs:coreController.confirm:58-66 -desktop.core_source.recovery_retention Core files and data in the previous environment are retained. Recovery does not roll back user data. The previous environment’s Core files and data are kept. Restoring bundled Core does not roll back user data. Conditional recover confirmation detail; restores the installed Core source rather than reverting user data. Only for typed action recover. Preserve previous environment and pending-action consumption. No destructive cleanup or rollback feature. proposed desktop/core-source.cjs:coreController.confirm:61 -desktop.core_source.git_policy Git uses the official askac/standterm repository, main branch. Its files are not checked against the installed bundle hashes. Local changes are allowed, but updates refuse to overwrite them. The Desktop shell stays installed. Git Core uses askac/standterm on the main branch. Its files are not verified against the installed bundle hashes. Updates refuse to overwrite local changes. The Desktop shell stays installed. Git-source confirmation detail; removes marketing qualifier while keeping source provenance and integrity boundary. Keep askac/standterm and main literal. Preserve refusal on local changes and no reset/stash behavior. No automatic update, retry, or implied equivalence to the verified installed bundle. proposed desktop/core-source.cjs:coreController.confirm:62-63 -desktop.core_source.reauthorization Authorization and recovery data are local to each Core source; switching may require reauthorization. Each Core source has separate authorization and recovery data. Switching may require reauthorization. Common source-change confirmation suffix; switching may change which credentials or recovery registrations are available. Do not promise migration, reuse, deletion, or automatic authorization. Preserve sourceStore identity/source/pending fields and backend credential boundaries. proposed desktop/core-source.cjs:coreController.confirm:64 -desktop.core_source.failure_choices The Desktop shell can retry or restore its installed Core. Existing files are retained. Choose Retry to restart StandTerm and try again, or restore bundled Core. Existing files are kept. Core unavailable dialog detail. Retry invokes restart(lastAction); displaying this dialog does not itself retry. Keep response indices 0 Quit, 1 Retry, 2 Restore bundled Core, 3 Core source, 4 Open logs. defaultId=0/cancelId=0. Preserve fixed action IDs and lastAction replay; no automatic retry. proposed desktop/core-source.cjs:coreController.failure:111-124 +desktop.setup.cancel_detail Keep this window open or minimize it to continue. Canceling stops the owned installation processes; prepared files are retained so you can retry on the next launch. Keep this window open or minimize it to continue. Canceling stops this setup’s installation processes and keeps prepared files. Relaunch StandTerm to try again. 保持此視窗開啟或最小化即可繼續。取消會停止此次安裝的處理程序,並保留已準備的檔案。請重新啟動 StandTerm 以重試。 Cancel confirmation detail. Only owned installation processes are stopped. Translate display text only. Keep stable codes, action IDs, numeric responses, validation, process ownership and cancellation behavior unchanged. Insert paths and diagnostic text literally. translation-reviewed desktop/setup.cjs +desktop.setup.install_effects and downloads and installs Python dependencies from your configured package index. Dependencies can execute installation code. Internet access and disk space are required. Exact dependency-effects fragment in preparePackagedBackend confirmation detail; preceding Core-copy/path/Python requirements and following retention policy remain separate. Merged into the complete prepare_detail message to avoid grammatical fragments. Keep existing environment/backend selection and raw destination path. Confirm response 1 creates the environment; 0/defaultId/cancelId cancel. Do not imply bundled or verified dependencies, system Python installation, or privilege elevation. remove desktop/setup.cjs:preparePackagedBackend:262-268 +desktop.setup.retention Failed setup is retained for retry. Uninstall keeps environments by default; optional cleanup moves only verified idle venvs to a recovery folder. Core and user data are retained. Exact retention fragment of the initial setup confirmation. The cleanup operation is a recoverable move rather than disk-space reclamation. Merged into the complete prepare_detail message to avoid grammatical fragments. Keep venv-only cleanup boundary and verified/idle checks. Do not imply uninstall deletes Core or user data, that partial setup is ready, or that retry occurs without a user action. remove desktop/setup.cjs:preparePackagedBackend:267 +desktop.setup.cleanup_detail {paths}\n\nNo disk space is freed. Core and settings are retained. Any environment that becomes busy or fails verification will be retained. Cancel keeps all listed venvs. {paths}\n\nThis move does not free disk space. Core and settings stay in place. Busy or unverified environments are kept. Cancel keeps every listed venv. {paths}\n\n移動不會釋放磁碟空間。Core 與設定會留在原處。使用中或未通過驗證的環境會保留。取消會保留清單中的所有 venv。 Candidate paths remain a raw newline-separated list. Eligibility is checked again before moving. {paths} Translate display text only. Keep stable codes, action IDs, numeric responses, validation, process ownership and cancellation behavior unchanged. Insert paths and diagnostic text literally. translation-reviewed desktop/setup.cjs +desktop.setup.cleanup_unconfirmed Cleanup did not report completion. Check venv-recovery before retrying. Cleanup completion was not confirmed. Check venv-recovery before retrying. 無法確認清理是否完成。重試前,請先檢查 venv-recovery。 Detach command failure may mean partial moves; do not imply no mutation occurred. Translate display text only. Keep stable codes, action IDs, numeric responses, validation, process ownership and cancellation behavior unchanged. Insert paths and diagnostic text literally. translation-reviewed desktop/setup.cjs +desktop.setup.timeout Setup timed out. Check internet access and retry. Fixed ERRORS.setup_timeout message. Timeout does not guarantee prepared files were removed or no installation work occurred. Replaced by error_setup_timeout for consistent stable-code mapping. Keep setup_timeout error key, timeout values, cancellation and retained files unchanged. No automatic retry or promise of a clean environment. remove desktop/setup.cjs:ERRORS.setup_timeout:29 +desktop.core_source.restart_notice This restarts StandTerm and closes terminal sessions. Python dependencies may be downloaded and installed. StandTerm will restart and close terminal sessions. Python dependencies may be downloaded and installed. StandTerm 將重新啟動並關閉終端工作階段。可能會下載並安裝 Python 相依套件。 Common consequence fragment for source enable/update/prepare/recover confirmations. Keep action IDs enable/update/prepare/recover and source values bundled/git. Button index 1 restarts; 0/defaultId/cancelId cancel. Do not treat translated labels as action IDs. translation-reviewed desktop/core-source.cjs:coreController +desktop.core_source.recovery_retention Core files and data in the previous environment are retained. Recovery does not roll back user data. The previous environment’s Core files and data are kept. Restoring bundled Core does not roll back user data. 原環境的 Core 檔案與資料會保留。還原隨附 Core 不會回復使用者資料。 Conditional recover confirmation detail; restores the installed Core source rather than reverting user data. Only for typed action recover. Preserve previous environment and pending-action consumption. No destructive cleanup or rollback feature. translation-reviewed desktop/core-source.cjs:coreController +desktop.core_source.git_policy Git uses the official askac/standterm repository, main branch. Its files are not checked against the installed bundle hashes. Local changes are allowed, but updates refuse to overwrite them. The Desktop shell stays installed. Git Core uses askac/standterm on the main branch. Its files are not verified against the installed bundle hashes. Updates refuse to overwrite local changes. The Desktop shell stays installed. Git Core 使用 askac/standterm 的 main 分支。其檔案不會依已安裝套件的雜湊值驗證。更新時不會覆寫本機變更。Desktop 應用程式會保留。 Git-source confirmation detail; removes marketing qualifier while keeping source provenance and integrity boundary. Keep askac/standterm and main literal. Preserve refusal on local changes and no reset/stash behavior. No automatic update, retry, or implied equivalence to the verified installed bundle. translation-reviewed desktop/core-source.cjs:coreController +desktop.core_source.reauthorization Authorization and recovery data are local to each Core source; switching may require reauthorization. Each Core source has separate authorization and recovery data. Switching may require reauthorization. 各 Core 來源的授權與復原資料互相獨立。切換後可能需要重新授權。 Common source-change confirmation suffix; switching may change which credentials or recovery registrations are available. Do not promise migration, reuse, deletion, or automatic authorization. Preserve sourceStore identity/source/pending fields and backend credential boundaries. translation-reviewed desktop/core-source.cjs:coreController +desktop.core_source.failure_choices The Desktop shell can retry or restore its installed Core. Existing files are retained. Choose Retry to restart StandTerm and try again, or restore bundled Core. Existing files are kept. 選擇「重試」以重新啟動 StandTerm 再試一次,或還原隨附 Core。現有檔案會保留。 Core unavailable dialog detail. Retry invokes restart(lastAction); displaying this dialog does not itself retry. Keep response indices 0 Quit, 1 Retry, 2 Restore bundled Core, 3 Core source, 4 Open logs. defaultId=0/cancelId=0. Preserve fixed action IDs and lastAction replay; no automatic retry. translation-reviewed desktop/core-source.cjs:coreController desktop.browser_access.opened Browser authorization opened in the default browser. Authorization link opened in the default browser. 已在預設瀏覽器開啟授權連結。 Success notice after awaiting OS open callback; browser authorization has not been observed as completed. Keep action open distinct from copy-auth/copy-url/copy-token; retain existing confirmation. Do not claim the browser is authorized or reveal the URL/token in the notice. translation-reviewed desktop/browser-access.cjs:createBrowserAccess.run:46-62 desktop.browser_access.copied Access information copied. Treat it as a password. Access information copied. Keep it private, like a password. 已複製存取資訊。請像保管密碼一樣妥善保密。 Shared notice for copied authorization URL, access URL, or access token; the copied payload is sensitive. Keep exact clipboard payload and fixed action IDs; do not insert secrets into catalog parameters, logs or notifications. No extra mint, reveal, or retry. translation-reviewed desktop/browser-access.cjs:createBrowserAccess.run:58-62 desktop.browser_access.failed Could not prepare browser access. Check that this Desktop backend is still running. Could not prepare browser access. Check that this Desktop backend is running. 無法準備瀏覽器存取資訊。請確認此 Desktop 的後端仍在執行。 Sanitized generic failure notice; implementation deliberately discards URL-bearing network errors. Preserve generic failure and error=true notification. Never display raw caught errors, URLs, grants or tokens. Keep pending/available guards and no automatic retry. translation-reviewed desktop/browser-access.cjs:createBrowserAccess.run catch:64-67 @@ -181,3 +181,94 @@ desktop.capture.save_failed_detail Review the error and file locations below.\n desktop.capture.save_unconfirmed Could not confirm the recording save result. 無法確認錄影儲存結果。 Fallback error when a stop/save result does not positively confirm completion. Cancel the pending close/quit and retain the window. Do not claim that no output exists, retry automatically, or imply recording continues. translation-reviewed desktop/capture.cjs:DesktopCapture.confirmStop desktop.capture.requested_destination Requested destination:\n{path} 預定儲存位置:\n{path} Failure-dialog suffix naming the requested final output path, distinct from a retained partial path. {path} Keep path literal. Final publication may have happened before partial cleanup failed. This label does not guarantee that the path exists, that the file is complete or playable, or that saving succeeded. translation-reviewed desktop/capture.cjs:DesktopCapture.confirmStop desktop.capture.done Done Done 完成 Close Capture Settings without changing either folder preference. Keep response index 0 and defaultId=0/cancelId=0. This closes the settings dialog only; it is not a capture-save result. translation-reviewed desktop/capture.cjs:DesktopCapture.configure +desktop.setup.help_wsl Install WSL and Python 3.10+ with venv support in the selected distribution first.\n\nFor Ubuntu/Debian, run this yourself in WSL:\nsudo apt install python3 python3-venv\n\nStandTerm never runs sudo or installs system Python automatically. Install WSL and Python 3.10+ with venv support in the selected distribution first.\n\nFor Ubuntu/Debian, run this yourself in WSL:\nsudo apt install python3 python3-venv\n\nStandTerm never runs sudo or installs system Python automatically. 請先安裝 WSL,並在選取的發行版中安裝支援 venv 的 Python 3.10 以上版本。\n\n若使用 Ubuntu/Debian,請自行在 WSL 執行:\nsudo apt install python3 python3-venv\n\nStandTerm 不會自動執行 sudo 或安裝系統 Python。 WSL prerequisites and manual installation instructions. Preserve the command exactly. Translate display text only. Keep stable codes, action IDs, numeric responses, validation, process ownership and cancellation behavior unchanged. Insert paths and diagnostic text literally. translation-reviewed desktop/setup.cjs +desktop.setup.help_windows Install 64-bit Python 3.10+ with venv support on Windows first.\n\nStandTerm checks PATH for python.exe or lets you select it. Microsoft Store aliases and py.exe are not launched automatically. No system Python installation or administrator access is requested. Install 64-bit Python 3.10+ with venv support on Windows first.\n\nStandTerm checks PATH for python.exe or lets you select it. Microsoft Store aliases and py.exe are not launched automatically. No system Python installation or administrator access is requested. 請先在 Windows 安裝支援 venv 的 64 位元 Python 3.10 以上版本。\n\nStandTerm 會從 PATH 尋找 python.exe,或讓你自行選取。系統不會自動啟動 Microsoft Store 別名或 py.exe,也不會安裝系統 Python 或要求系統管理員權限。 Windows Python discovery and prerequisites. Keep executable names literal. Translate display text only. Keep stable codes, action IDs, numeric responses, validation, process ownership and cancellation behavior unchanged. Insert paths and diagnostic text literally. translation-reviewed desktop/setup.cjs +desktop.setup.help_macos Install native macOS Python 3.10+ with venv and ensurepip support first.\n\nPython must match this app’s CPU architecture. StandTerm checks Homebrew, MacPorts and PATH, or lets you select an installed interpreter. Apple’s /usr/bin/python3 developer-tools stub is not launched. StandTerm does not install Python, Homebrew, Rosetta or system packages. Install native macOS Python 3.10+ with venv and ensurepip support first.\n\nPython must match this app’s CPU architecture. StandTerm checks Homebrew, MacPorts and PATH, or lets you select an installed interpreter. Apple’s /usr/bin/python3 developer-tools stub is not launched. StandTerm does not install Python, Homebrew, Rosetta or system packages. 請先安裝支援 venv 與 ensurepip 的 macOS 原生 Python 3.10 以上版本。\n\nPython 必須符合此應用程式的 CPU 架構。StandTerm 會從 Homebrew、MacPorts 與 PATH 尋找,或讓你選取已安裝的直譯器。系統不會啟動 Apple 的 /usr/bin/python3 開發工具啟動程式,也不會安裝 Python、Homebrew、Rosetta 或系統套件。 macOS native architecture and interpreter prerequisites. Translate display text only. Keep stable codes, action IDs, numeric responses, validation, process ownership and cancellation behavior unchanged. Insert paths and diagnostic text literally. translation-reviewed desktop/setup.cjs:macosPython +desktop.setup.error_dependencies_failed Dependency installation or verification failed. Check internet access and the runtime setup.log, then retry. Dependency installation or verification failed. Check internet access and the runtime setup.log, then retry. 相依套件安裝或驗證失敗。請檢查網路連線及執行環境中的 setup.log,再重試。 Display message for stable dependencies_failed error code. Preserve code-based behavior and retained files. Translate display text only. Keep stable codes, action IDs, numeric responses, validation, process ownership and cancellation behavior unchanged. Insert paths and diagnostic text literally. translation-reviewed desktop/setup.cjs +desktop.setup.error_setup_busy This runtime is in use by StandTerm or another setup. Quit that desktop mode before retrying. This runtime is in use by StandTerm or another setup. Quit that desktop mode before retrying. StandTerm 或其他安裝程序正在使用此執行環境。請先結束使用該環境的 Desktop 模式,再重試。 Display message for stable setup_busy error code. Preserve code-based behavior and retained files. Translate display text only. Keep stable codes, action IDs, numeric responses, validation, process ownership and cancellation behavior unchanged. Insert paths and diagnostic text literally. translation-reviewed desktop/setup.cjs +desktop.setup.error_modified_runtime The managed Core contains modified files. Setup will not overwrite them. The managed Core contains modified files. Setup will not overwrite them. 受管理的 Core 含有已修改的檔案。安裝程序不會覆寫這些檔案。 Display message for stable modified_runtime error code. Preserve code-based behavior and retained files. Translate display text only. Keep stable codes, action IDs, numeric responses, validation, process ownership and cancellation behavior unchanged. Insert paths and diagnostic text literally. translation-reviewed desktop/setup.cjs +desktop.setup.error_unsafe_runtime_path The runtime directory is not safe to use. Setup will not overwrite unrelated files or follow directory links. The runtime directory is not safe to use. Setup will not overwrite unrelated files or follow directory links. 無法安全使用此執行環境目錄。安裝程序不會覆寫無關檔案,也不會沿用目錄連結。 Display message for stable unsafe_runtime_path error code. Preserve code-based behavior and retained files. Translate display text only. Keep stable codes, action IDs, numeric responses, validation, process ownership and cancellation behavior unchanged. Insert paths and diagnostic text literally. translation-reviewed desktop/setup.cjs +desktop.setup.error_invalid_bundle The bundled Core failed its integrity check. Reinstall StandTerm Desktop. The bundled Core failed its integrity check. Reinstall StandTerm Desktop. 隨附 Core 未通過完整性檢查。請重新安裝 StandTerm Desktop。 Display message for stable invalid_bundle error code. Preserve code-based behavior and retained files. Translate display text only. Keep stable codes, action IDs, numeric responses, validation, process ownership and cancellation behavior unchanged. Insert paths and diagnostic text literally. translation-reviewed desktop/setup.cjs +desktop.setup.error_setup_canceled Setup was canceled. Relaunch StandTerm to retry. Setup was canceled. Relaunch StandTerm to retry. 已取消準備。請重新啟動 StandTerm 以重試。 Display message for stable setup_canceled error code. Preserve code-based behavior and retained files. Translate display text only. Keep stable codes, action IDs, numeric responses, validation, process ownership and cancellation behavior unchanged. Insert paths and diagnostic text literally. translation-reviewed desktop/setup.cjs +desktop.setup.error_setup_timeout Setup timed out. Check internet access and the setup log before retrying. Setup timed out. Check internet access and the setup log before retrying. 安裝逾時。請先檢查網路連線及安裝記錄,再重試。 Display message for stable setup_timeout error code. Preserve code-based behavior and retained files. Translate display text only. Keep stable codes, action IDs, numeric responses, validation, process ownership and cancellation behavior unchanged. Insert paths and diagnostic text literally. translation-reviewed desktop/setup.cjs +desktop.setup.error_git_required Git is unavailable in the selected backend environment. Install Git there, or restore the bundled Core. Git is unavailable in the selected backend environment. Install Git there, or restore the bundled Core. 選取的後端環境無法使用 Git。請在該環境安裝 Git,或還原隨附 Core。 Display message for stable git_required error code. Preserve code-based behavior and retained files. Translate display text only. Keep stable codes, action IDs, numeric responses, validation, process ownership and cancellation behavior unchanged. Insert paths and diagnostic text literally. translation-reviewed desktop/setup.cjs +desktop.setup.error_git_dirty The Git Core has local changes. Keep them or resolve them in its checkout before updating. Nothing was reset or stashed. The Git Core has local changes. Keep them or resolve them in its checkout before updating. Nothing was reset or stashed. Git Core 有本機變更。更新前,請決定保留或在其工作目錄中處理這些變更。系統未執行 reset 或 stash。 Display message for stable git_dirty error code. Preserve code-based behavior and retained files. Translate display text only. Keep stable codes, action IDs, numeric responses, validation, process ownership and cancellation behavior unchanged. Insert paths and diagnostic text literally. translation-reviewed desktop/setup.cjs +desktop.setup.error_git_diverged The Git Core cannot fast-forward to the official branch. Local history is retained. Restore the bundled Core or resolve the checkout manually. The Git Core cannot fast-forward to the official branch. Local history is retained. Restore the bundled Core or resolve the checkout manually. Git Core 無法以快轉方式更新至官方分支。本機歷史紀錄已保留。請還原隨附 Core,或手動處理其工作目錄。 Display message for stable git_diverged error code. Preserve code-based behavior and retained files. Translate display text only. Keep stable codes, action IDs, numeric responses, validation, process ownership and cancellation behavior unchanged. Insert paths and diagnostic text literally. translation-reviewed desktop/setup.cjs +desktop.setup.error_git_source_changed The managed Git origin or branch has changed. The official repository and main branch are required. The managed Git origin or branch has changed. The official repository and main branch are required. 受管理的 Git origin 或分支已變更。必須使用官方儲存庫及 main 分支。 Display message for stable git_source_changed error code. Preserve code-based behavior and retained files. Translate display text only. Keep stable codes, action IDs, numeric responses, validation, process ownership and cancellation behavior unchanged. Insert paths and diagnostic text literally. translation-reviewed desktop/setup.cjs +desktop.setup.error_invalid_git_workspace The private Git checkout is incomplete or damaged. It is retained. Restore the bundled Core or repair that checkout manually. The private Git checkout is incomplete or damaged. It is retained. Restore the bundled Core or repair that checkout manually. 專用 Git 工作目錄不完整或已損壞,系統已保留該目錄。請還原隨附 Core,或手動修復該目錄。 Display message for stable invalid_git_workspace error code. Preserve code-based behavior and retained files. Translate display text only. Keep stable codes, action IDs, numeric responses, validation, process ownership and cancellation behavior unchanged. Insert paths and diagnostic text literally. translation-reviewed desktop/setup.cjs +desktop.setup.error_git_needs_setup The Git Core requirements changed or its environment is missing. Use Prepare Git environment from Core source (Advanced). The Git Core requirements changed or its environment is missing. Use Prepare Git environment from Core source (Advanced). Git Core 的相依需求已變更,或缺少執行環境。請在「Core 來源(進階)」選擇「準備 Git 環境」。 Display message for stable git_needs_setup error code. Preserve code-based behavior and retained files. Translate display text only. Keep stable codes, action IDs, numeric responses, validation, process ownership and cancellation behavior unchanged. Insert paths and diagnostic text literally. translation-reviewed desktop/setup.cjs +desktop.setup.error_git_failed Git could not complete the operation. Check network access and the runtime setup.log. Prepared files are retained. Git could not complete the operation. Check network access and the runtime setup.log. Prepared files are retained. Git 無法完成操作。請檢查網路連線及執行環境中的 setup.log。已準備的檔案會保留。 Display message for stable git_failed error code. Preserve code-based behavior and retained files. Translate display text only. Keep stable codes, action IDs, numeric responses, validation, process ownership and cancellation behavior unchanged. Insert paths and diagnostic text literally. translation-reviewed desktop/setup.cjs +desktop.setup.error_invalid_archive The recovery archive failed verification. Reinstall StandTerm Desktop if its installed bundle is also damaged. The recovery archive failed verification. Reinstall StandTerm Desktop if its installed bundle is also damaged. 復原封存檔未通過驗證。若已安裝的隨附檔案也已損壞,請重新安裝 StandTerm Desktop。 Display message for stable invalid_archive error code. Preserve code-based behavior and retained files. Translate display text only. Keep stable codes, action IDs, numeric responses, validation, process ownership and cancellation behavior unchanged. Insert paths and diagnostic text literally. translation-reviewed desktop/setup.cjs +desktop.setup.error_setup_failed Setup failed. Check the selected Python environment and available disk space. Setup failed. Check the selected Python environment and available disk space. 安裝失敗。請檢查選取的 Python 環境及可用磁碟空間。 Display message for stable setup_failed error code. Preserve code-based behavior and retained files. Translate display text only. Keep stable codes, action IDs, numeric responses, validation, process ownership and cancellation behavior unchanged. Insert paths and diagnostic text literally. translation-reviewed desktop/setup.cjs +desktop.setup.cancel_title Cancel StandTerm setup? Cancel StandTerm setup? 取消 StandTerm 準備? Cancel confirmation title. Translate display text only. Keep stable codes, action IDs, numeric responses, validation, process ownership and cancellation behavior unchanged. Insert paths and diagnostic text literally. translation-reviewed desktop/setup.cjs +desktop.setup.cancel_message The Python environment is still being prepared. The Python environment is still being prepared. Python 環境仍在準備中。 Cancel confirmation message. Translate display text only. Keep stable codes, action IDs, numeric responses, validation, process ownership and cancellation behavior unchanged. Insert paths and diagnostic text literally. translation-reviewed desktop/setup.cjs +desktop.setup.keep_preparing Keep preparing Keep preparing 繼續準備 Cancel confirmation response 0; default and escape preserve preparation. Translate display text only. Keep stable codes, action IDs, numeric responses, validation, process ownership and cancellation behavior unchanged. Insert paths and diagnostic text literally. translation-reviewed desktop/setup.cjs +desktop.setup.cancel_setup Cancel setup Cancel setup 取消準備 Cancel confirmation response 1. Translate display text only. Keep stable codes, action IDs, numeric responses, validation, process ownership and cancellation behavior unchanged. Insert paths and diagnostic text literally. translation-reviewed desktop/setup.cjs +desktop.setup.canceling_title StandTerm Desktop - Canceling setup StandTerm Desktop - Canceling setup StandTerm Desktop-正在取消準備 Progress window title after confirmed cancellation. Translate display text only. Keep stable codes, action IDs, numeric responses, validation, process ownership and cancellation behavior unchanged. Insert paths and diagnostic text literally. translation-reviewed desktop/setup.cjs +desktop.setup.canceling_status Canceling setup. Waiting for installation processes to stop... Canceling setup. Waiting for installation processes to stop... 正在取消準備,等待安裝程序停止… Progress live status while cooperative cancellation finishes. Translate display text only. Keep stable codes, action IDs, numeric responses, validation, process ownership and cancellation behavior unchanged. Insert paths and diagnostic text literally. translation-reviewed desktop/setup.cjs +desktop.setup.python_required_title StandTerm Desktop: Python required StandTerm Desktop: Python required StandTerm Desktop:需要 Python Windows and macOS missing interpreter dialog title. Translate display text only. Keep stable codes, action IDs, numeric responses, validation, process ownership and cancellation behavior unchanged. Insert paths and diagnostic text literally. translation-reviewed desktop/setup.cjs +desktop.setup.select_python_windows Select installed python.exe... Select installed python.exe... 選取已安裝的 python.exe… Windows interpreter selection button, response 1. Translate display text only. Keep stable codes, action IDs, numeric responses, validation, process ownership and cancellation behavior unchanged. Insert paths and diagnostic text literally. translation-reviewed desktop/setup.cjs +desktop.setup.select_python_macos Select installed Python... Select installed Python... 選取已安裝的 Python… macOS interpreter selection button, response 1. Translate display text only. Keep stable codes, action IDs, numeric responses, validation, process ownership and cancellation behavior unchanged. Insert paths and diagnostic text literally. translation-reviewed desktop/setup.cjs +desktop.setup.select_python_windows_title Select an installed 64-bit Python interpreter Select an installed 64-bit Python interpreter 選取已安裝的 64 位元 Python 直譯器 Windows native file picker title. Translate display text only. Keep stable codes, action IDs, numeric responses, validation, process ownership and cancellation behavior unchanged. Insert paths and diagnostic text literally. translation-reviewed desktop/setup.cjs +desktop.setup.select_python_macos_title Select a native macOS Python 3.10+ interpreter Select a native macOS Python 3.10+ interpreter 選取 macOS 原生 Python 3.10 以上版本的直譯器 macOS native file picker title. Translate display text only. Keep stable codes, action IDs, numeric responses, validation, process ownership and cancellation behavior unchanged. Insert paths and diagnostic text literally. translation-reviewed desktop/setup.cjs +desktop.setup.python_executable Python executable Python executable Python 執行檔 Windows exe file filter label. Translate display text only. Keep stable codes, action IDs, numeric responses, validation, process ownership and cancellation behavior unchanged. Insert paths and diagnostic text literally. translation-reviewed desktop/setup.cjs +desktop.setup.select_wsl_title StandTerm Desktop: select WSL StandTerm Desktop: select WSL StandTerm Desktop:選取 WSL WSL distribution dialog title. Translate display text only. Keep stable codes, action IDs, numeric responses, validation, process ownership and cancellation behavior unchanged. Insert paths and diagnostic text literally. translation-reviewed desktop/setup.cjs +desktop.setup.select_wsl_message Select an existing WSL distribution for StandTerm Core. Select an existing WSL distribution for StandTerm Core. 為 StandTerm Core 選取現有的 WSL 發行版。 Distribution names remain literal. No installation of distributions. Translate display text only. Keep stable codes, action IDs, numeric responses, validation, process ownership and cancellation behavior unchanged. Insert paths and diagnostic text literally. translation-reviewed desktop/setup.cjs +desktop.setup.prepare_title Prepare StandTerm Core Prepare StandTerm Core 準備 StandTerm Core Initial dependency installation consent title. Translate display text only. Keep stable codes, action IDs, numeric responses, validation, process ownership and cancellation behavior unchanged. Insert paths and diagnostic text literally. translation-reviewed desktop/setup.cjs +desktop.setup.prepare_message Create a private StandTerm environment in {platform}? Create a private StandTerm environment in {platform}? 要在 {platform} 建立 StandTerm 專用環境嗎? Initial consent message. Platform is Windows, macOS or the raw WSL distro name. {platform} Translate display text only. Keep stable codes, action IDs, numeric responses, validation, process ownership and cancellation behavior unchanged. Insert paths and diagnostic text literally. translation-reviewed desktop/setup.cjs +desktop.setup.requirements_macos Requires Python 3.10+ with venv support on native macOS. Requires Python 3.10+ with venv support on native macOS. 需要 macOS 原生 Python 3.10 以上版本,並支援 venv。 Complete requirement sentence inserted as data into prepare_detail. Translate display text only. Keep stable codes, action IDs, numeric responses, validation, process ownership and cancellation behavior unchanged. Insert paths and diagnostic text literally. translation-reviewed desktop/setup.cjs +desktop.setup.requirements_windows Requires 64-bit Python 3.10+ with venv support on Windows. Requires 64-bit Python 3.10+ with venv support on Windows. 需要 Windows 64 位元 Python 3.10 以上版本,並支援 venv。 Complete requirement sentence inserted as data into prepare_detail. Translate display text only. Keep stable codes, action IDs, numeric responses, validation, process ownership and cancellation behavior unchanged. Insert paths and diagnostic text literally. translation-reviewed desktop/setup.cjs +desktop.setup.requirements_wsl Requires Python 3.10+ with venv support inside WSL. Requires Python 3.10+ with venv support inside WSL. 需要 WSL 內的 Python 3.10 以上版本,並支援 venv。 Complete requirement sentence inserted as data into prepare_detail. Translate display text only. Keep stable codes, action IDs, numeric responses, validation, process ownership and cancellation behavior unchanged. Insert paths and diagnostic text literally. translation-reviewed desktop/setup.cjs +desktop.setup.prepare_detail {requirements}\n\nThis copies the bundled Core to {path} and creates a private venv. Python dependencies are downloaded from your configured package index and installed there. Installation can run code and requires internet access and disk space.\n\nNo system Python installation, sudo, Git checkout changes or existing-session interruption. Failed setup files are kept for retry. Uninstall keeps environments by default. Optional cleanup moves verified idle venvs to recovery and keeps Core files and user data. {requirements}\n\nThis copies the bundled Core to {path} and creates a private venv. Python dependencies are downloaded from your configured package index and installed there. Installation can run code and requires internet access and disk space.\n\nNo system Python installation, sudo, Git checkout changes or existing-session interruption. Failed setup files are kept for retry. Uninstall keeps environments by default. Optional cleanup moves verified idle venvs to recovery and keeps Core files and user data. {requirements}\n\n系統會將隨附 Core 複製到 {path},並建立專用 venv。Python 相依套件會從你設定的套件索引下載,並安裝到該環境。安裝可能執行程式碼,且需要網路連線與磁碟空間。\n\n系統不會安裝系統 Python、執行 sudo、變更 Git 工作目錄,或中斷現有工作階段。安裝失敗時會保留檔案供重試。解除安裝預設會保留環境。選用的清理功能只會將通過驗證且未使用中的 venv 移至復原資料夾,並保留 Core 檔案與使用者資料。 Full initial setup consent. This dialog prepares bundled Core only; do not reuse unchanged for managed Git updates. {requirements} {path} Translate display text only. Keep stable codes, action IDs, numeric responses, validation, process ownership and cancellation behavior unchanged. Insert paths and diagnostic text literally. translation-reviewed desktop/setup.cjs +desktop.setup.create_environment Create environment and install dependencies Create environment and install dependencies 建立環境並安裝相依套件 Initial setup response 1; default/cancel response 0 remains Cancel. Translate display text only. Keep stable codes, action IDs, numeric responses, validation, process ownership and cancellation behavior unchanged. Insert paths and diagnostic text literally. translation-reviewed desktop/setup.cjs +desktop.setup.progress_title StandTerm Desktop - Preparing environment StandTerm Desktop - Preparing environment StandTerm Desktop-正在準備環境 Native progress window title and document title. Translate display text only. Keep stable codes, action IDs, numeric responses, validation, process ownership and cancellation behavior unchanged. Insert paths and diagnostic text literally. translation-reviewed desktop/setup.cjs; desktop/setup.html +desktop.setup.progress_heading Preparing your Python environment Preparing your Python environment 正在準備 Python 環境 Progress HTML heading. Translate display text only. Keep stable codes, action IDs, numeric responses, validation, process ownership and cancellation behavior unchanged. Insert paths and diagnostic text literally. translation-reviewed desktop/setup.html +desktop.setup.progress_starting Starting setup... Starting setup... 正在開始準備… Initial preparation status. Translate display text only. Keep stable codes, action IDs, numeric responses, validation, process ownership and cancellation behavior unchanged. Insert paths and diagnostic text literally. translation-reviewed desktop/setup.html +desktop.setup.progress_aria Environment preparation in progress Environment preparation in progress 正在準備環境 Indeterminate progress accessible label. Translate display text only. Keep stable codes, action IDs, numeric responses, validation, process ownership and cancellation behavior unchanged. Insert paths and diagnostic text literally. translation-reviewed desktop/setup.html +desktop.setup.progress_detail Creating a private venv and installing dependencies may take several minutes. The progress indicator does not show a completion percentage. Creating a private venv and installing dependencies may take several minutes. The progress indicator does not show a completion percentage. 建立專用 venv 及安裝相依套件可能需要數分鐘。進度指示器不代表完成百分比。 Indeterminate progress explanation. Translate display text only. Keep stable codes, action IDs, numeric responses, validation, process ownership and cancellation behavior unchanged. Insert paths and diagnostic text literally. translation-reviewed desktop/setup.html +desktop.setup.progress_close_hint You can minimize this window while setup continues. Closing it asks for confirmation before canceling. You can minimize this window while setup continues. Closing it asks for confirmation before canceling. 準備期間可將此視窗最小化。關閉視窗時,系統會先確認是否取消。 Window close semantics; close is not immediate cancellation. Translate display text only. Keep stable codes, action IDs, numeric responses, validation, process ownership and cancellation behavior unchanged. Insert paths and diagnostic text literally. translation-reviewed desktop/setup.html +desktop.setup.progress_scope StandTerm uses your installed Python. It does not install system Python or run sudo. It prepares the managed Core environment and leaves unrelated Git checkouts unchanged. If canceled, prepared files are kept for retry. StandTerm uses your installed Python; it does not install system Python or run sudo. If canceled, prepared files are kept for retry. StandTerm 使用已安裝的 Python,不會安裝系統 Python 或執行 sudo。若取消,已準備的檔案會保留供重試。 Shared by bundled setup and private Git Core management; private managed checkout may change. Translate display text only. Keep stable codes, action IDs, numeric responses, validation, process ownership and cancellation behavior unchanged. Insert paths and diagnostic text literally. translation-reviewed desktop/setup.html +desktop.setup.preparation_macos Preparing Core for native macOS. Preparing Core for native macOS. 正在為 macOS 原生環境準備 Core。 Progress requirements line for macOS. Translate display text only. Keep stable codes, action IDs, numeric responses, validation, process ownership and cancellation behavior unchanged. Insert paths and diagnostic text literally. translation-reviewed desktop/setup.cjs +desktop.setup.preparation_windows Preparing Core for native Windows. Preparing Core for native Windows. 正在為 Windows 原生環境準備 Core。 Progress requirements line for Windows. Translate display text only. Keep stable codes, action IDs, numeric responses, validation, process ownership and cancellation behavior unchanged. Insert paths and diagnostic text literally. translation-reviewed desktop/setup.cjs +desktop.setup.preparation_wsl Preparing Core inside WSL: {distro}. Preparing Core inside WSL: {distro}. 正在 WSL 發行版 {distro} 內準備 Core。 Progress requirements line; distro remains raw. {distro} Translate display text only. Keep stable codes, action IDs, numeric responses, validation, process ownership and cancellation behavior unchanged. Insert paths and diagnostic text literally. translation-reviewed desktop/setup.cjs +desktop.setup.stage_git Updating the private Git checkout... Updating the private Git checkout... 正在更新專用 Git 工作目錄… Progress text selected by stable git stage ID. Unknown stages remain ignored. Translate display text only. Keep stable codes, action IDs, numeric responses, validation, process ownership and cancellation behavior unchanged. Insert paths and diagnostic text literally. translation-reviewed desktop/setup.cjs +desktop.setup.stage_copy Copying verified Core files... Copying verified Core files... 正在複製已驗證的 Core 檔案… Progress text selected by stable copy stage ID. Unknown stages remain ignored. Translate display text only. Keep stable codes, action IDs, numeric responses, validation, process ownership and cancellation behavior unchanged. Insert paths and diagnostic text literally. translation-reviewed desktop/setup.cjs +desktop.setup.stage_venv Creating the private Python environment... Creating the private Python environment... 正在建立專用 Python 環境… Progress text selected by stable venv stage ID. Unknown stages remain ignored. Translate display text only. Keep stable codes, action IDs, numeric responses, validation, process ownership and cancellation behavior unchanged. Insert paths and diagnostic text literally. translation-reviewed desktop/setup.cjs +desktop.setup.stage_dependencies Installing Python dependencies. This can take several minutes... Installing Python dependencies. This can take several minutes... 正在安裝 Python 相依套件,可能需要數分鐘… Progress text selected by stable dependencies stage ID. Unknown stages remain ignored. Translate display text only. Keep stable codes, action IDs, numeric responses, validation, process ownership and cancellation behavior unchanged. Insert paths and diagnostic text literally. translation-reviewed desktop/setup.cjs +desktop.setup.stage_verify Verifying the installed dependencies... Verifying the installed dependencies... 正在驗證已安裝的相依套件… Progress text selected by stable verify stage ID. Unknown stages remain ignored. Translate display text only. Keep stable codes, action IDs, numeric responses, validation, process ownership and cancellation behavior unchanged. Insert paths and diagnostic text literally. translation-reviewed desktop/setup.cjs +desktop.setup.cleanup_inventory_unavailable Environment inventory was unavailable. No venv cleanup was started. Environment inventory was unavailable. No venv cleanup was started. 無法取得環境清單,尚未開始清理 venv。 Inventory command failure; no detach operation has been requested. Translate display text only. Keep stable codes, action IDs, numeric responses, validation, process ownership and cancellation behavior unchanged. Insert paths and diagnostic text literally. translation-reviewed desktop/setup.cjs +desktop.setup.cleanup_title Confirm environment cleanup Confirm environment cleanup 確認清理環境 Windows installer optional cleanup confirmation. Translate display text only. Keep stable codes, action IDs, numeric responses, validation, process ownership and cancellation behavior unchanged. Insert paths and diagnostic text literally. translation-reviewed desktop/setup.cjs +desktop.setup.cleanup_message Move these idle venvs to recovery in {platform}? Move these idle venvs to recovery in {platform}? 要將 {platform} 中這些未使用中的 venv 移至復原資料夾嗎? Platform is Windows or WSL followed by the raw selected distro name. {platform} Translate display text only. Keep stable codes, action IDs, numeric responses, validation, process ownership and cancellation behavior unchanged. Insert paths and diagnostic text literally. translation-reviewed desktop/setup.cjs +desktop.setup.cleanup_keep Keep environments Keep environments 保留環境 Cleanup response 0, default and escape. Translate display text only. Keep stable codes, action IDs, numeric responses, validation, process ownership and cancellation behavior unchanged. Insert paths and diagnostic text literally. translation-reviewed desktop/setup.cjs +desktop.setup.cleanup_move Move listed venvs to recovery Move listed venvs to recovery 將清單中的 venv 移至復原資料夾 Cleanup response 1; recoverable move only, no deletion. Translate display text only. Keep stable codes, action IDs, numeric responses, validation, process ownership and cancellation behavior unchanged. Insert paths and diagnostic text literally. translation-reviewed desktop/setup.cjs +desktop.startup.start_failed StandTerm Desktop could not start StandTerm Desktop could not start StandTerm Desktop 無法啟動 Startup failure dialog title. Translate display text only. Keep stable codes, action IDs, numeric responses, validation, process ownership and cancellation behavior unchanged. Insert paths and diagnostic text literally. translation-reviewed desktop/main.cjs +desktop.startup.recovery_failed StandTerm Desktop could not recover StandTerm Desktop could not recover StandTerm Desktop 無法復原 Recovery failure dialog title. Translate display text only. Keep stable codes, action IDs, numeric responses, validation, process ownership and cancellation behavior unchanged. Insert paths and diagnostic text literally. translation-reviewed desktop/main.cjs +desktop.startup.shutdown_failed StandTerm could not complete shutdown StandTerm could not complete shutdown StandTerm 無法完成結束程序 Shutdown failure dialog title. Translate display text only. Keep stable codes, action IDs, numeric responses, validation, process ownership and cancellation behavior unchanged. Insert paths and diagnostic text literally. translation-reviewed desktop/main.cjs +desktop.startup.management_unavailable Core management unavailable Core management unavailable 無法使用 Core 管理功能 Core management failure dialog title. Translate display text only. Keep stable codes, action IDs, numeric responses, validation, process ownership and cancellation behavior unchanged. Insert paths and diagnostic text literally. translation-reviewed desktop/main.cjs +desktop.startup.diagnostics_detail {error}\n\nDiagnostics: {path} {error}\n\nDiagnostics: {path} {error}\n\n診斷紀錄:{path} Startup raw error and diagnostic log path. Never translate or parse the error payload. {error} {path} Translate display text only. Keep stable codes, action IDs, numeric responses, validation, process ownership and cancellation behavior unchanged. Insert paths and diagnostic text literally. translation-reviewed desktop/main.cjs +desktop.core_source.change_title Change StandTerm Core Change StandTerm Core 變更 StandTerm Core Native Core source manager and recovery dialogs; fixed action IDs and response indices. Display text only. Preserve typed action IDs enable/update/prepare/recover and bundled/git source values. Keep numeric response mapping and defaultId=0/cancelId=0. Never dispatch by translated text. Preserve raw error messages and commit identifiers; unknown workspace values remain literal. Restart explicitly closes sessions; do not imply automatic retries, data rollback, source authorization migration, or verified Git files. translation-reviewed desktop/core-source.cjs:coreController +desktop.core_source.confirm_recover Restore the Core bundled with this Desktop installation? Restore the Core bundled with this Desktop installation? 要還原此 Desktop 安裝版本隨附的 Core 嗎? Native Core source manager and recovery dialogs; fixed action IDs and response indices. Display text only. Preserve typed action IDs enable/update/prepare/recover and bundled/git source values. Keep numeric response mapping and defaultId=0/cancelId=0. Never dispatch by translated text. Preserve raw error messages and commit identifiers; unknown workspace values remain literal. Restart explicitly closes sessions; do not imply automatic retries, data rollback, source authorization migration, or verified Git files. translation-reviewed desktop/core-source.cjs:coreController +desktop.core_source.confirm_git Use the advanced Git Core environment? Use the advanced Git Core environment? 要使用進階 Git Core 環境嗎? Native Core source manager and recovery dialogs; fixed action IDs and response indices. Display text only. Preserve typed action IDs enable/update/prepare/recover and bundled/git source values. Keep numeric response mapping and defaultId=0/cancelId=0. Never dispatch by translated text. Preserve raw error messages and commit identifiers; unknown workspace values remain literal. Restart explicitly closes sessions; do not imply automatic retries, data rollback, source authorization migration, or verified Git files. translation-reviewed desktop/core-source.cjs:coreController +desktop.core_source.restart_continue Restart and continue Restart and continue 重新啟動並繼續 Native Core source manager and recovery dialogs; fixed action IDs and response indices. Display text only. Preserve typed action IDs enable/update/prepare/recover and bundled/git source values. Keep numeric response mapping and defaultId=0/cancelId=0. Never dispatch by translated text. Preserve raw error messages and commit identifiers; unknown workspace values remain literal. Restart explicitly closes sessions; do not imply automatic retries, data rollback, source authorization migration, or verified Git files. translation-reviewed desktop/core-source.cjs:coreController +desktop.core_source.close Close Close 關閉 Native Core source manager and recovery dialogs; fixed action IDs and response indices. Display text only. Preserve typed action IDs enable/update/prepare/recover and bundled/git source values. Keep numeric response mapping and defaultId=0/cancelId=0. Never dispatch by translated text. Preserve raw error messages and commit identifiers; unknown workspace values remain literal. Restart explicitly closes sessions; do not imply automatic retries, data rollback, source authorization migration, or verified Git files. translation-reviewed desktop/core-source.cjs:coreController +desktop.core_source.prepare_git Prepare Git environment Prepare Git environment 準備 Git 環境 Native Core source manager and recovery dialogs; fixed action IDs and response indices. Display text only. Preserve typed action IDs enable/update/prepare/recover and bundled/git source values. Keep numeric response mapping and defaultId=0/cancelId=0. Never dispatch by translated text. Preserve raw error messages and commit identifiers; unknown workspace values remain literal. Restart explicitly closes sessions; do not imply automatic retries, data rollback, source authorization migration, or verified Git files. translation-reviewed desktop/core-source.cjs:coreController +desktop.core_source.enable_git Enable Git Core Enable Git Core 啟用 Git Core Native Core source manager and recovery dialogs; fixed action IDs and response indices. Display text only. Preserve typed action IDs enable/update/prepare/recover and bundled/git source values. Keep numeric response mapping and defaultId=0/cancelId=0. Never dispatch by translated text. Preserve raw error messages and commit identifiers; unknown workspace values remain literal. Restart explicitly closes sessions; do not imply automatic retries, data rollback, source authorization migration, or verified Git files. translation-reviewed desktop/core-source.cjs:coreController +desktop.core_source.update_git Update Git Core Update Git Core 更新 Git Core Native Core source manager and recovery dialogs; fixed action IDs and response indices. Display text only. Preserve typed action IDs enable/update/prepare/recover and bundled/git source values. Keep numeric response mapping and defaultId=0/cancelId=0. Never dispatch by translated text. Preserve raw error messages and commit identifiers; unknown workspace values remain literal. Restart explicitly closes sessions; do not imply automatic retries, data rollback, source authorization migration, or verified Git files. translation-reviewed desktop/core-source.cjs:coreController +desktop.core_source.restore_bundled Restore bundled Core Restore bundled Core 還原隨附 Core Native Core source manager and recovery dialogs; fixed action IDs and response indices. Display text only. Preserve typed action IDs enable/update/prepare/recover and bundled/git source values. Keep numeric response mapping and defaultId=0/cancelId=0. Never dispatch by translated text. Preserve raw error messages and commit identifiers; unknown workspace values remain literal. Restart explicitly closes sessions; do not imply automatic retries, data rollback, source authorization migration, or verified Git files. translation-reviewed desktop/core-source.cjs:coreController +desktop.core_source.open_logs Open Desktop logs Open Desktop logs 開啟 Desktop 紀錄資料夾 Native Core source manager and recovery dialogs; fixed action IDs and response indices. Display text only. Preserve typed action IDs enable/update/prepare/recover and bundled/git source values. Keep numeric response mapping and defaultId=0/cancelId=0. Never dispatch by translated text. Preserve raw error messages and commit identifiers; unknown workspace values remain literal. Restart explicitly closes sessions; do not imply automatic retries, data rollback, source authorization migration, or verified Git files. translation-reviewed desktop/core-source.cjs:coreController +desktop.core_source.manager_title Core source (Advanced) Core source (Advanced) Core 來源(進階) Native Core source manager and recovery dialogs; fixed action IDs and response indices. Display text only. Preserve typed action IDs enable/update/prepare/recover and bundled/git source values. Keep numeric response mapping and defaultId=0/cancelId=0. Never dispatch by translated text. Preserve raw error messages and commit identifiers; unknown workspace values remain literal. Restart explicitly closes sessions; do not imply automatic retries, data rollback, source authorization migration, or verified Git files. translation-reviewed desktop/core-source.cjs:coreController +desktop.core_source.source Core source: {source} Core source: {source} Core 來源:{source} Native Core source manager and recovery dialogs; fixed action IDs and response indices. {source} Display text only. Preserve typed action IDs enable/update/prepare/recover and bundled/git source values. Keep numeric response mapping and defaultId=0/cancelId=0. Never dispatch by translated text. Preserve raw error messages and commit identifiers; unknown workspace values remain literal. Restart explicitly closes sessions; do not imply automatic retries, data rollback, source authorization migration, or verified Git files. translation-reviewed desktop/core-source.cjs:coreController +desktop.core_source.bundled Bundled with Desktop Bundled with Desktop Desktop 隨附 Native Core source manager and recovery dialogs; fixed action IDs and response indices. Display text only. Preserve typed action IDs enable/update/prepare/recover and bundled/git source values. Keep numeric response mapping and defaultId=0/cancelId=0. Never dispatch by translated text. Preserve raw error messages and commit identifiers; unknown workspace values remain literal. Restart explicitly closes sessions; do not imply automatic retries, data rollback, source authorization migration, or verified Git files. translation-reviewed desktop/core-source.cjs:coreController +desktop.core_source.git_available Git in this backend environment: Available Git in this backend environment: Available 此後端環境的 Git:可用 Native Core source manager and recovery dialogs; fixed action IDs and response indices. Display text only. Preserve typed action IDs enable/update/prepare/recover and bundled/git source values. Keep numeric response mapping and defaultId=0/cancelId=0. Never dispatch by translated text. Preserve raw error messages and commit identifiers; unknown workspace values remain literal. Restart explicitly closes sessions; do not imply automatic retries, data rollback, source authorization migration, or verified Git files. translation-reviewed desktop/core-source.cjs:coreController +desktop.core_source.git_unavailable Git in this backend environment: Unavailable Git in this backend environment: Unavailable 此後端環境的 Git:無法使用 Native Core source manager and recovery dialogs; fixed action IDs and response indices. Display text only. Preserve typed action IDs enable/update/prepare/recover and bundled/git source values. Keep numeric response mapping and defaultId=0/cancelId=0. Never dispatch by translated text. Preserve raw error messages and commit identifiers; unknown workspace values remain literal. Restart explicitly closes sessions; do not imply automatic retries, data rollback, source authorization migration, or verified Git files. translation-reviewed desktop/core-source.cjs:coreController +desktop.core_source.commit Commit: {commit} Commit: {commit} Commit:{commit} Native Core source manager and recovery dialogs; fixed action IDs and response indices. {commit} Display text only. Preserve typed action IDs enable/update/prepare/recover and bundled/git source values. Keep numeric response mapping and defaultId=0/cancelId=0. Never dispatch by translated text. Preserve raw error messages and commit identifiers; unknown workspace values remain literal. Restart explicitly closes sessions; do not imply automatic retries, data rollback, source authorization migration, or verified Git files. translation-reviewed desktop/core-source.cjs:coreController +desktop.core_source.commit_dirty Commit: {commit} (local changes) Commit: {commit} (local changes) Commit:{commit}(有本機變更) Native Core source manager and recovery dialogs; fixed action IDs and response indices. {commit} Display text only. Preserve typed action IDs enable/update/prepare/recover and bundled/git source values. Keep numeric response mapping and defaultId=0/cancelId=0. Never dispatch by translated text. Preserve raw error messages and commit identifiers; unknown workspace values remain literal. Restart explicitly closes sessions; do not imply automatic retries, data rollback, source authorization migration, or verified Git files. translation-reviewed desktop/core-source.cjs:coreController +desktop.core_source.workspace Workspace: {workspace} Workspace: {workspace} 工作目錄:{workspace} Native Core source manager and recovery dialogs; fixed action IDs and response indices. {workspace} Display text only. Preserve typed action IDs enable/update/prepare/recover and bundled/git source values. Keep numeric response mapping and defaultId=0/cancelId=0. Never dispatch by translated text. Preserve raw error messages and commit identifiers; unknown workspace values remain literal. Restart explicitly closes sessions; do not imply automatic retries, data rollback, source authorization migration, or verified Git files. translation-reviewed desktop/core-source.cjs:coreController +desktop.core_source.workspace_absent absent Not created 尚未建立 Native Core source manager and recovery dialogs; fixed action IDs and response indices. Display text only. Preserve typed action IDs enable/update/prepare/recover and bundled/git source values. Keep numeric response mapping and defaultId=0/cancelId=0. Never dispatch by translated text. Preserve raw error messages and commit identifiers; unknown workspace values remain literal. Restart explicitly closes sessions; do not imply automatic retries, data rollback, source authorization migration, or verified Git files. translation-reviewed desktop/core-source.cjs:coreController +desktop.core_source.workspace_present present Present 已建立 Git workspace is present; this does not assert that its Python environment is ready. Display text only. Preserve typed action IDs enable/update/prepare/recover and bundled/git source values. Keep numeric response mapping and defaultId=0/cancelId=0. Never dispatch by translated text. Preserve raw error messages and commit identifiers; unknown workspace values remain literal. Restart explicitly closes sessions; do not imply automatic retries, data rollback, source authorization migration, or verified Git files. translation-reviewed desktop/core-source.cjs:coreController +desktop.core_source.workspace_unavailable unavailable Cannot inspect without Git 缺少 Git,無法檢查 Native Core source manager and recovery dialogs; fixed action IDs and response indices. Display text only. Preserve typed action IDs enable/update/prepare/recover and bundled/git source values. Keep numeric response mapping and defaultId=0/cancelId=0. Never dispatch by translated text. Preserve raw error messages and commit identifiers; unknown workspace values remain literal. Restart explicitly closes sessions; do not imply automatic retries, data rollback, source authorization migration, or verified Git files. translation-reviewed desktop/core-source.cjs:coreController +desktop.core_source.workspace_invalid invalid Invalid or inaccessible 無效或無法存取 Native Core source manager and recovery dialogs; fixed action IDs and response indices. Display text only. Preserve typed action IDs enable/update/prepare/recover and bundled/git source values. Keep numeric response mapping and defaultId=0/cancelId=0. Never dispatch by translated text. Preserve raw error messages and commit identifiers; unknown workspace values remain literal. Restart explicitly closes sessions; do not imply automatic retries, data rollback, source authorization migration, or verified Git files. translation-reviewed desktop/core-source.cjs:coreController +desktop.core_source.manual_updates Git updates are manual. Install Git in the selected Windows, macOS or WSL environment to enable them. Git Core updates are manual. Install Git in the selected Windows, macOS or WSL environment to enable them. Git Core 需手動更新。請在所選的 Windows、macOS 或 WSL 環境中安裝 Git,才能進行更新。 Native Core source manager and recovery dialogs; fixed action IDs and response indices. Display text only. Preserve typed action IDs enable/update/prepare/recover and bundled/git source values. Keep numeric response mapping and defaultId=0/cancelId=0. Never dispatch by translated text. Preserve raw error messages and commit identifiers; unknown workspace values remain literal. Restart explicitly closes sessions; do not imply automatic retries, data rollback, source authorization migration, or verified Git files. translation-reviewed desktop/core-source.cjs:coreController +desktop.core_source.unavailable_title StandTerm Core is unavailable StandTerm Core is unavailable StandTerm Core 無法使用 Native Core source manager and recovery dialogs; fixed action IDs and response indices. Display text only. Preserve typed action IDs enable/update/prepare/recover and bundled/git source values. Keep numeric response mapping and defaultId=0/cancelId=0. Never dispatch by translated text. Preserve raw error messages and commit identifiers; unknown workspace values remain literal. Restart explicitly closes sessions; do not imply automatic retries, data rollback, source authorization migration, or verified Git files. translation-reviewed desktop/core-source.cjs:coreController +desktop.core_source.quit Quit Quit 結束 Native Core source manager and recovery dialogs; fixed action IDs and response indices. Display text only. Preserve typed action IDs enable/update/prepare/recover and bundled/git source values. Keep numeric response mapping and defaultId=0/cancelId=0. Never dispatch by translated text. Preserve raw error messages and commit identifiers; unknown workspace values remain literal. Restart explicitly closes sessions; do not imply automatic retries, data rollback, source authorization migration, or verified Git files. translation-reviewed desktop/core-source.cjs:coreController +desktop.core_source.retry Retry Retry 重試 Native Core source manager and recovery dialogs; fixed action IDs and response indices. Display text only. Preserve typed action IDs enable/update/prepare/recover and bundled/git source values. Keep numeric response mapping and defaultId=0/cancelId=0. Never dispatch by translated text. Preserve raw error messages and commit identifiers; unknown workspace values remain literal. Restart explicitly closes sessions; do not imply automatic retries, data rollback, source authorization migration, or verified Git files. translation-reviewed desktop/core-source.cjs:coreController diff --git a/docs/desktop_ui_review_plan.md b/docs/desktop_ui_review_plan.md index 88f0a4e..a566d11 100644 --- a/docs/desktop_ui_review_plan.md +++ b/docs/desktop_ui_review_plan.md @@ -13,10 +13,11 @@ acceptance remain separate. Browser acceptance is recorded in | 0 — Complete | Clarify toolbar action feedback before localization. A resolved `false` now shows unavailable feedback; a rejected invocation reports an uncertain result without suggesting retry. | Small | Both original failures reproduced before the fix; renderer and command-guard checks passed. Each click invokes once, with no automatic replay or invented completion notice. | | 1 — Complete | Add a Desktop-owned language preference and catalog; pilot custom menus, toolbar labels and Agent help. | Medium | English default/fallback, `en` and `zh-TW`, malformed preference fallback, next-launch application, translated title/ARIA labels without losing SVGs, fixed command IDs, focus/origin guards and staging inclusion verified. Native acceptance remains order 4. | | 2 — Complete | Localize Browser Access, Diagnostics, About, external-browser confirmations and Capture; retain the window when recording save fails during close/quit. | Medium | Sensitive clipboard feedback, fixed authorization actions, escaped diagnostic fields, literal event JSON, typed Capture state, folder settings and combined save-failure plus close/quit coverage verified. | -| 3 | Localize setup, Core source selection, startup failure and recoverable environment cleanup. | Medium to large | Both languages work before Core is available. Cancellation waits for owned installers; stale confirmations do nothing; source switching, restart/session closure, retained files and recovery moves remain explicit. | +| 3 — Complete | Localize setup, Core source selection/recovery, startup error wrappers and per-mode environment cleanup confirmations. | Medium to large | Both languages work before Core is available. Cancellation waits for owned installers; stale confirmations do nothing; source switching, restart/session closure, retained files and recovery moves remain explicit. Installer-wide dialogs remain in order 3b. | +| 3b | Review and localize remaining shell notices: port selection, Files download feedback and installer-wide summary/error dialogs. | Small to medium | Preserve structured port outcomes, download status and recovery counts. Settle the installer-wide locale when Windows/WSL preferences differ; do not infer a new shared preference from per-mode setup. | | 4 | Complete Windows and macOS native acceptance and packaged asset checks. | Platform-dependent | Menus, native dialogs, narrow layouts, keyboard/ARIA labels, clipboard, setup and recovery are checked on each OS. Verify staged and packaged Desktop catalogs independently of the selected Core version. | -The next implementation is setup and Core source/recovery in order 3. +The next implementation is the remaining shell notices in order 3b. The operator chose to retain the window and show the error and unfinished-file location when recording save fails during close/quit. Orders 1–3 remain separate reviewable changes; native OS acceptance remains order 4. @@ -67,14 +68,15 @@ Keep these implementation boundaries: [desktop_ui_copy_review.tsv](desktop_ui_copy_review.tsv) is a prioritized seed inventory, not a claim that every Desktop string has been extracted. It uses -the same nine columns as the browser table. After Capture, -170 rows are `translation-reviewed` with English and Traditional Chinese text; -11 setup/Core source rows remain `proposed` with empty `zh-TW` cells. One retired -Capture sentence fragment is marked `remove`. Some `current_en` cells are exact fragments or normalize +the same nine columns as the browser table. After setup/Core source, +269 rows are `translation-reviewed` with English and Traditional Chinese text. +Four retired Capture/setup fragments or renamed messages are marked `remove`; +no seed rows remain `proposed`. This does not include every remaining shell +notice listed in order 3b. Some `current_en` cells are exact fragments or normalize dynamic values to named placeholders; `context` identifies these cases. Approve the English behavior and terminology before requesting translations of -the remaining proposed rows. Keep already reviewed rows unchanged. +newly inventoried rows. Keep already reviewed rows unchanged. An external translation AI should return the same keys/order/columns, fill only `zh-TW`, preserve placeholders and literal identifiers, and keep `status` unchanged. Human/source review promotes rows to `translation-reviewed`; a @@ -91,9 +93,9 @@ separate keys instead of a grammatical `{action}` fragment. Existing agreed terms remain in [agent_ui_review_plan.md](agent_ui_review_plan.md#agreed-terminology). The -following Desktop additions are proposals to settle before translation: +following Desktop additions are used by the reviewed messages: -| Concept | English | Proposed Traditional Chinese | Boundary | +| Concept | English | Traditional Chinese | Boundary | | --- | --- | --- | --- | | Image of the terminal view | Screenshot | 螢幕截圖 | A PNG of the Core view; not CLI output capture. | | Silent video of the terminal view | Recording | 錄影 | WebM without audio; not a transcript or terminal log. | @@ -211,6 +213,52 @@ Capture completed on 2026-09-20: installer acceptance was run for this batch. Capture permissions, recorder isolation and automatic finalization on hide/minimize/navigation are unchanged. +## Setup and Core source review and evidence + +The batch adds 99 reviewed bilingual messages. Python prerequisites, environment +creation consent, preparation/cancellation progress, per-mode cleanup, Core source +management and recovery use the Desktop catalog before Core is available. Stable +setup error codes select translated explanations; unknown codes retain the +platform-help fallback. Original technical errors, paths, distribution names and +commit identifiers remain data. The replaced macOS help constant was removed; +interpreter discovery and architecture checks are unchanged. + +Normal Desktop calls pass the language captured at launch, so a newly saved +preference does not change later dialogs until restart. Installer preparation +and cleanup read the selected Windows/WSL mode profile directly; the maintenance +profile does not override either mode. Installer-wide summary/error dialogs stay +English for now. Their common-language policy is separate from per-mode setup. +No OS-language inference, preference migration or installer lifecycle change was +introduced. + +| Finding | Severity | Evidence | Critic remedy | Main response | Resolution | Validation | +| --- | --- | --- | --- | --- | --- | --- | +| Shared setup page promises not to modify a Git checkout while Git update uses that page | Medium; preexisting copy defect | `setup.html`, `manageCore`, `runPreparation` | Remove the shared Git assertion | Keep the narrower bundled-setup promise in its action-specific consent; remove it from shared progress copy | Accept | Actual page and Git/copy progress scripts checked in both locales. | +| Maintenance exits before normal Desktop language initialization | Medium; integration requirement | `main.cjs` maintenance branch and `installer.cjs` setup/cleanup callbacks | Wire per-mode locale or explicitly defer installer UI | Read the relevant mode profile in setup/cleanup defaults; pass launch language explicitly for normal Desktop | Modify | Both Windows and WSL tests use a conflicting maintenance preference; installer ownership tests remain passing. | +| Existing setup tests do not execute injected DOM updates | Medium; validation gap | `setup.test.cjs` original no-op renderer | Add bilingual DOM execution with literal interpolation | Capture actual initialization/progress/cancel scripts and execute them against actual HTML in Chromium | Accept | Six locale/platform cases passed, including malicious distribution text, ARIA and 700px layout. | +| English-only button mocks do not prove localized routing | Low; validation gap | Setup and Core manager dialog fixtures | Test numeric action effects in both languages | Preserve numeric decisions and fixed action IDs; add bilingual consent, cleanup, cancellation, retry/recover and label-collision cases | Accept | Both-language action tests and retained typed error codes passed. | +| Inherited progress-map properties can display unknown stages | Low; preexisting hardening | Original `labels[stage]` lookup | Use an own-property or supported-stage check when translating | Limit display updates to five existing stage IDs | Accept | Unknown `__proto__`, `constructor` and markup-like stages leave the display unchanged; cancellation suppresses later progress. | + +The focused second review found no remaining material correctness issue. The +review changed shared copy, maintenance locale wiring and validation coverage; +it preserved process ownership, stale-confirmation guards, no-space-freed cleanup, +restart/session closure, reauthorization warnings and raw error details. Native +dialog and installer-wide language qualification remain deferred until their +explicit acceptance/implementation stages. No behavioral policy was reopened. + +Setup/Core source completed on 2026-09-20: + +- All 145 Desktop unit tests passed under Electron's Node 24.20.0 runtime. +- Seven catalog regression tests and both generated catalog freshness checks + passed. The table contains 269 reviewed messages and four retired rows. +- The real setup HTML and injected scripts passed six Chromium cases: both + locales on Windows, macOS and WSL display branches. Progress/cancel text, + language/title, ARIA, literal data, no injection, no external requests and + 700x500 layout passed. Native dialogs and setup processes are mocked. +- No renderer scripts/assets or permissions were added; the existing setup URL + allowlist and CSP are unchanged. No native GUI, real dependency installation, + installer build or packaged acceptance is claimed for this batch. + ## Evidence and acceptance limits Browser Access/Diagnostics completed on 2026-09-20: @@ -259,7 +307,7 @@ inspection of Capture/setup/recovery does not imply their smoke suites ran in this review. The review table is checked using `build_ui_messages.build_catalog` for schema, -keys, placeholders and review gates. Only the 170 reviewed rows enter the -Desktop runtime catalog; the remaining workflow proposals stay out of it. +keys, placeholders and review gates. Only the 269 reviewed rows enter the +Desktop runtime catalog; retired rows stay out of it. Windows/macOS native localization, installer lifecycle and packaged acceptance remain future work. This plan does not qualify or publish a release. From 38861555f667f05a45ae499298d813a255349966 Mon Sep 17 00:00:00 2001 From: "ASKA C." Date: Sun, 20 Sep 2026 11:09:11 +0800 Subject: [PATCH 33/43] Localize port, download, installer and paste notices ## Why Remaining native notices ignore the Desktop language, and several messages infer more than their structured operation results establish. ## What changed - Localize port decisions, download results, installer summaries and native paste confirmation through the reviewed Desktop table. - Select the common effective installer profile language or fall back to English without writing a shared preference. - Describe retained result counts, partial maintenance changes and uncertain download or paste completion without suggesting automatic replay. - Preserve numeric decisions, typed states, clipboard guards, verification timing and installer ownership. ## Testing All 165 Desktop unit tests and seven catalog regression tests pass. Both catalogs are current. Bilingual fixtures cover partial uninstall, owner loss, mixed locale preferences, port persistence timing, pathless downloads and lost paste acknowledgments. Native GUI and packaged acceptance remain separate. --- desktop/README.md | 10 +- desktop/context-paste.cjs | 19 +- desktop/installer.cjs | 25 ++- desktop/main.cjs | 23 ++- desktop/messages.js | 64 ++++++ desktop/port.cjs | 15 +- desktop/test/context-paste.test.cjs | 46 ++++- desktop/test/installer-i18n.test.cjs | 235 +++++++++++++++++++++++ desktop/test/port.test.cjs | 57 ++++++ desktop/test/shell-notices-i18n.test.cjs | 88 +++++++++ docs/desktop_ui_copy_review.tsv | 32 +++ docs/desktop_ui_review_plan.md | 62 +++++- 12 files changed, 623 insertions(+), 53 deletions(-) create mode 100644 desktop/test/installer-i18n.test.cjs create mode 100644 desktop/test/shell-notices-i18n.test.cjs diff --git a/desktop/README.md b/desktop/README.md index f4dc478..d159adc 100644 --- a/desktop/README.md +++ b/desktop/README.md @@ -15,10 +15,14 @@ Chinese (Taiwan) for the next launch. The preference belongs to the Desktop profile; Core keeps its own language setting. Saving a choice does not restart StandTerm or interrupt recording. Coverage includes custom menus, toolbar labels, Agent help, Browser Access, Diagnostics, About, external-browser confirmations -and Capture dialogs/status, environment preparation and Core source/recovery. +and Capture dialogs/status, environment preparation, Core source/recovery, port +selection, Files download notices and native paste confirmation. Installer preparation and cleanup confirmations use the relevant mode profile's -language. Installer-wide summary/error dialogs, port selection and Files download -notices still use English; raw technical errors remain unchanged and native role +language. Installer-wide summary/error dialogs use the common language of the +selected modes, or English when they differ. Uninstall considers both Windows +and WSL; missing or invalid preferences use the usual English fallback. This +does not write a shared preference. Raw technical errors, early startup/legacy +installer messages and the external installer UI remain English; native role labels follow the platform. Edit reviewed messages in the table, then generate the independent shell diff --git a/desktop/context-paste.cjs b/desktop/context-paste.cjs index e660bd6..c3ed274 100644 --- a/desktop/context-paste.cjs +++ b/desktop/context-paste.cjs @@ -2,8 +2,9 @@ const { BrowserWindow, clipboard, dialog } = require('electron'); const { allowedNavigation } = require('./policy.cjs'); +const { create } = require('./i18n.js'); -function installContextPaste(win, contents, origin, notify) { +function installContextPaste(win, contents, origin, notify, t = create('en').t) { let pending = false; let navigation = 0; contents.on('did-start-navigation', (_event, _url, _inPlace, mainFrame) => { @@ -28,24 +29,22 @@ function installContextPaste(win, contents, origin, notify) { const id = await frame.executeJavaScript('window.standtermUi?.contextPasteRequest()'); if (typeof id !== 'string' || id.length > 80 || !current()) return; const result = await dialog.showMessageBox(win, { - type: 'question', title: 'Paste into StandTerm', - message: 'Paste clipboard text into this terminal?', - detail: 'This reads clipboard text once. Multi-line or large text still requires review. ' - + 'For direct paste, use the Paste button beside the application menu.', - buttons: ['Cancel', 'Paste'], defaultId: 0, cancelId: 0, noLink: true, + type: 'question', title: t('desktop.paste.title'), + message: t('desktop.paste.message'), detail: t('desktop.paste.detail'), + buttons: [t('desktop.common.cancel'), t('desktop.paste.confirm')], defaultId: 0, cancelId: 0, noLink: true, }); if (result.response !== 1) return; if (!current()) return; const valid = await frame.executeJavaScript(`window.standtermUi?.contextPasteRequest() === ${JSON.stringify(id)}`); - if (!valid || !current()) { await notify('Paste canceled because the target changed.', true); return; } + if (!valid || !current()) { await notify(t('desktop.paste.target_changed'), true); return; } const text = await clipboard.readText(); if (!current()) return; const delivered = await frame.executeJavaScript( `window.standtermUi?.completeContextPaste(${JSON.stringify(id)}, ${JSON.stringify(text)})`); - if (!delivered) await notify('Paste canceled because the target changed.', true); - else if (!text) await notify('The clipboard contains no text.'); + if (!delivered) await notify(t('desktop.paste.target_changed'), true); + else if (!text) await notify(t('desktop.paste.empty')); })().catch(async () => { - if (!win.isDestroyed()) await notify('Paste unavailable. Use the Paste toolbar button or your terminal paste shortcut.', true); + if (!win.isDestroyed()) await notify(t('desktop.paste.unconfirmed'), true); }).finally(() => { pending = false; callback(false); diff --git a/desktop/installer.cjs b/desktop/installer.cjs index 89a23a7..a352334 100644 --- a/desktop/installer.cjs +++ b/desktop/installer.cjs @@ -3,6 +3,8 @@ const { spawn } = require('node:child_process'); const path = require('node:path'); const { shortcutPlan, applyShortcuts } = require('./installer-shortcuts.cjs'); +const { createLanguage } = require('./language.cjs'); +const { create } = require('./i18n.js'); function installerRequest(argv) { const flags = argv.filter(value => value.startsWith('--installer-')); @@ -91,6 +93,9 @@ async function runInstaller(request) { const { app, dialog, shell } = require('electron'); const { preparePackagedBackend, cleanupManagedVenvs, stopSetup, confirmSetupQuit, modeProfile } = require('./setup.cjs'); const { installedStore } = require('./core-source.cjs'); + const modes = request.action === 'prepare' ? request.modes : ['windows', 'wsl']; + const locales = modes.map(mode => createLanguage(path.join(modeProfile(mode), 'language.json')).locale); + const { t } = create(locales.every(locale => locale === locales[0]) ? locales[0] : 'en'); let exiting = false; const watcher = watchInstaller(request.parent, () => { void stopSetup().finally(() => { exiting = true; app.exit(2); }); @@ -118,23 +123,23 @@ async function runInstaller(request) { const detached = results.flatMap(result => result.results || []).filter(item => item.status === 'detached').length; const retained = results.flatMap(result => result.results || [result]).filter(item => item.status === 'retained').length; const unknown = results.filter(result => result.status === 'unknown').length; - await dialog.showMessageBox({ type: 'info', title: 'StandTerm environment cleanup', - message: `${detached} confirmed move(s); ${retained} retained entry/entries; ${unknown} unconfirmed environment(s).`, - detail: 'In-use, legacy, unverified and unavailable environments are retained. Only Windows and the configured WSL distribution were checked.\n\n' - + 'Recovery folders (disk space is not freed):\n%LOCALAPPDATA%\\StandTermDesktop\\venv-recovery\n' - + '~/.local/share/standterm-desktop/venv-recovery\n\nIf a result is unconfirmed, inspect these folders; some moves may already have completed. ' - + 'Core, settings, captures, system Python and WSL distributions are untouched.', - buttons: ['Continue uninstall'] }); + await dialog.showMessageBox({ type: 'info', title: t('desktop.installer.cleanup_title'), + message: t('desktop.installer.cleanup_summary', { detached, retained, unknown }), + detail: t('desktop.installer.cleanup_detail', { + windows_path: '%LOCALAPPDATA%\\StandTermDesktop\\venv-recovery', + wsl_path: '~/.local/share/standterm-desktop/venv-recovery', + }), + buttons: [t('desktop.installer.continue_uninstall')] }); }, }); ensureAlive(); return 0; } catch (error) { await stopSetup(); - if (watcher.alive()) await dialog.showMessageBox({ type: 'error', title: 'StandTerm setup did not complete', + if (watcher.alive()) await dialog.showMessageBox({ type: 'error', title: t('desktop.installer.setup_failed_title'), message: error.message, - detail: 'Prepared environments and application files are retained. Run the installer again to retry. No terminal backend was started.', - buttons: ['Return to installer'] }); + detail: t('desktop.installer.setup_failed_detail'), + buttons: [t('desktop.installer.return_to_installer')] }); return error.code === 'SETUP_CANCELED' ? 2 : 1; } finally { exiting = true; watcher.stop(); } } diff --git a/desktop/main.cjs b/desktop/main.cjs index 2e5e391..09952a2 100644 --- a/desktop/main.cjs +++ b/desktop/main.cjs @@ -261,7 +261,7 @@ async function start() { fs.writeFileSync(settingsPath, JSON.stringify({ version: 1, port }), { flag: 'wx' }); } const handoff = await startWithPort({ - settingsPath, + settingsPath, t, launch: port => launchBackend(prepared, port), verify: verifyBackend, stop: stopBackend, checkHost: process.platform === 'win32' && mode === 'wsl' ? async (port, options) => { try { await checkHostPort(port, options); } @@ -270,13 +270,12 @@ async function start() { confirm: async (port, candidate, reason) => { diagnostics.write('port_change', { port, candidate }); if (smoke) { testPortChanges++; console.log(`Port smoke: approved replacement ${port} -> ${candidate} (${reason || 'backend_address_in_use'}).`); return 'remember'; } - const problem = reason === 'host_permission_denied' ? 'is reserved or denied by Windows' - : reason === 'host_address_in_use' ? 'is already in use on Windows' : 'is already in use'; + const message = reason === 'host_permission_denied' ? 'desktop.port.confirm_permission' + : reason === 'host_address_in_use' ? 'desktop.port.confirm_host_in_use' : 'desktop.port.confirm_in_use'; const answer = await dialog.showMessageBox({ type: 'question', title: MODES[mode], - message: `Port ${port} ${problem}. Use port ${candidate}?`, - detail: 'No existing service will be stopped or reused. Changing the port changes the browser origin; ' - + 'browser settings and SSH keys are not migrated. Windows and WSL remember their ports separately.', - buttons: ['Cancel', 'Use once', 'Use and remember'], defaultId: 0, cancelId: 0, noLink: true }); + message: t(message, { port, candidate }), detail: t('desktop.port.change_detail'), + buttons: [t('desktop.common.cancel'), t('desktop.port.use_once'), t('desktop.port.use_and_remember')], + defaultId: 0, cancelId: 0, noLink: true }); return ['cancel', 'once', 'remember'][answer.response]; }, notify: message => smoke ? console.warn(message) : dialog.showMessageBox({ type: 'warning', title: MODES[mode], message }), @@ -344,7 +343,7 @@ async function start() { } } : {}), }); toolbar = installToolbar(win, coreView, capture, commands, language.locale); - installContextPaste(win, contents, handoff.origin, toolbar.notify); + installContextPaste(win, contents, handoff.origin, toolbar.notify, t); const browserAccess = createBrowserAccess({ origin: handoff.origin, session: desktopSession, launcherToken, t, available: () => !win.isDestroyed() && !contents.isDestroyed() && allowedNavigation(contents.getURL(), handoff.origin), @@ -460,10 +459,10 @@ async function start() { installFloatingWindows(win, handoff.origin, openExternal, contents, (result, owner) => { const completed = result.state === 'completed' && !!result.path; void dialog.showMessageBox(owner, { - type: completed ? 'info' : 'warning', title: 'Files download', - message: completed ? 'Download complete' : 'Download did not complete', - detail: completed ? `Saved to:\n${result.path}` : 'The connection was interrupted. Retry the download from Files.', - buttons: completed ? ['Close', 'Show in folder'] : ['Close'], + type: completed ? 'info' : 'warning', title: t('desktop.download.title'), + message: t(completed ? 'desktop.download.complete' : 'desktop.download.incomplete'), + detail: completed ? t('desktop.download.saved_to', { path: result.path }) : t('desktop.download.retry'), + buttons: completed ? [t('desktop.download.close'), t('desktop.download.show_in_folder')] : [t('desktop.download.close')], defaultId: 0, cancelId: 0, noLink: true, }).then(answer => { if (completed && answer.response === 1) shell.showItemInFolder(result.path); diff --git a/desktop/messages.js b/desktop/messages.js index 12a3113..be084bc 100644 --- a/desktop/messages.js +++ b/desktop/messages.js @@ -157,12 +157,26 @@ "desktop.diagnostics.web_settings_persistent": "Web settings: saved per origin (same as Core)", "desktop.diagnostics.web_settings_temporary": "Web settings: temporary test profile", "desktop.diagnostics.window_title": "StandTerm Desktop - Diagnostics", + "desktop.download.close": "Close", + "desktop.download.complete": "Download complete", + "desktop.download.incomplete": "Download completion could not be confirmed.", + "desktop.download.retry": "Check the download destination before downloading again.", + "desktop.download.saved_to": "Saved to:\n{path}", + "desktop.download.show_in_folder": "Show in folder", + "desktop.download.title": "Files download", "desktop.external_browser.detail": "{url}\n\nThe browser uses its own login. StandTerm does not add its token or cookies.", "desktop.external_browser.failed": "Could not open the default browser.", "desktop.external_browser.failure_hint": "Check the default HTTP/HTTPS browser in your operating system settings.", "desktop.external_browser.message": "Open {host} in your default browser?", "desktop.external_browser.open": "Open in browser", "desktop.external_browser.title": "Open in default browser", + "desktop.installer.cleanup_detail": "Cleanup is limited to Windows and the configured WSL distribution. In-use, legacy, unverified and unavailable environments are retained. A retained result may represent one item or an entire mode.\n\nRecovery folders (disk space is not freed):\n{windows_path}\n{wsl_path}\n\nFor unconfirmed results, inspect these folders; some moves may already have completed. This cleanup leaves Core, settings, captures, system Python and WSL distributions unchanged.", + "desktop.installer.cleanup_summary": "Confirmed moves: {detached}; retained results: {retained}; modes with unconfirmed results: {unknown}.", + "desktop.installer.cleanup_title": "StandTerm environment cleanup", + "desktop.installer.continue_uninstall": "Continue uninstall", + "desktop.installer.return_to_installer": "Return to installer", + "desktop.installer.setup_failed_detail": "Some environment preparation, moves to recovery, or shortcut changes may already have completed. Completed changes are not automatically undone. Check venv-recovery before retrying with the installer. No terminal backend was started.", + "desktop.installer.setup_failed_title": "StandTerm operation did not complete", "desktop.language.choose": "Choose the Desktop language for the next launch.", "desktop.language.detail": "Saved choice: {language}\n\nCore has its own language setting. Some Desktop text remains in English.", "desktop.language.failed": "Could not confirm the language setting. Reopen Desktop language to check the saved choice.", @@ -185,6 +199,24 @@ "desktop.menu.quit": "Quit StandTerm", "desktop.menu.settings": "Settings...", "desktop.menu.show": "Show window", + "desktop.paste.confirm": "Paste", + "desktop.paste.detail": "This reads clipboard text once. Multi-line or large text still requires review. For direct paste, use the Paste button beside the application menu.", + "desktop.paste.empty": "The clipboard contains no text.", + "desktop.paste.message": "Paste clipboard text into this terminal?", + "desktop.paste.target_changed": "Paste canceled because the target changed.", + "desktop.paste.title": "Paste into StandTerm", + "desktop.paste.unconfirmed": "Could not confirm the paste result. Check the terminal before pasting again.", + "desktop.port.change_detail": "Existing services will keep running and will not be reused. Changing the port changes the browser origin; browser settings and SSH keys are not migrated. Windows and WSL remember their ports separately.", + "desktop.port.confirm_host_in_use": "Port {port} is already in use on Windows. Use port {candidate}?", + "desktop.port.confirm_in_use": "Port {port} is already in use. Use port {candidate}?", + "desktop.port.confirm_permission": "Windows reserves port {port} or denies access to it. Use port {candidate}?", + "desktop.port.no_candidate": "Port {port} is in use and no automatic port is available.", + "desktop.port.no_host_port": "No usable Windows/WSL loopback port was found. The saved port was not changed.", + "desktop.port.save_failed": "Port {port} passed verification, but its setting could not be saved.", + "desktop.port.saved_read_failed": "Could not read the saved desktop port. Selecting an automatic port.", + "desktop.port.startup_canceled": "Desktop startup canceled. The saved port was not changed.", + "desktop.port.use_and_remember": "Use and remember", + "desktop.port.use_once": "Use once", "desktop.setup.cancel_detail": "Keep this window open or minimize it to continue. Canceling stops this setup\u2019s installation processes and keeps prepared files. Relaunch StandTerm to try again.", "desktop.setup.cancel_message": "The Python environment is still being prepared.", "desktop.setup.cancel_setup": "Cancel setup", @@ -428,12 +460,26 @@ "desktop.diagnostics.web_settings_persistent": "\u7db2\u9801\u8a2d\u5b9a\uff1a\u4f9d\u4f86\u6e90\u5132\u5b58\uff08\u8207 Core \u76f8\u540c\uff09", "desktop.diagnostics.web_settings_temporary": "\u7db2\u9801\u8a2d\u5b9a\uff1a\u66ab\u5b58\u6e2c\u8a66\u8a2d\u5b9a\u6a94", "desktop.diagnostics.window_title": "StandTerm Desktop - \u8a3a\u65b7", + "desktop.download.close": "\u95dc\u9589", + "desktop.download.complete": "\u4e0b\u8f09\u5b8c\u6210", + "desktop.download.incomplete": "\u7121\u6cd5\u78ba\u8a8d\u4e0b\u8f09\u5df2\u5b8c\u6210\u3002", + "desktop.download.retry": "\u518d\u6b21\u4e0b\u8f09\u524d\uff0c\u8acb\u5148\u6aa2\u67e5\u4e0b\u8f09\u76ee\u7684\u5730\u3002", + "desktop.download.saved_to": "\u5df2\u5132\u5b58\u81f3\uff1a\n{path}", + "desktop.download.show_in_folder": "\u5728\u8cc7\u6599\u593e\u4e2d\u986f\u793a", + "desktop.download.title": "\u6a94\u6848\u4e0b\u8f09", "desktop.external_browser.detail": "{url}\n\n\u700f\u89bd\u5668\u4f7f\u7528\u81ea\u5df1\u7684\u767b\u5165\u72c0\u614b\u3002StandTerm \u4e0d\u6703\u52a0\u5165\u81ea\u5df1\u7684\u6b0a\u6756\u6216 Cookie\u3002", "desktop.external_browser.failed": "\u7121\u6cd5\u958b\u555f\u9810\u8a2d\u700f\u89bd\u5668\u3002", "desktop.external_browser.failure_hint": "\u8acb\u5728\u4f5c\u696d\u7cfb\u7d71\u8a2d\u5b9a\u4e2d\u6aa2\u67e5 HTTP\uff0fHTTPS \u7684\u9810\u8a2d\u700f\u89bd\u5668\u3002", "desktop.external_browser.message": "\u8981\u5728\u9810\u8a2d\u700f\u89bd\u5668\u958b\u555f {host} \u55ce\uff1f", "desktop.external_browser.open": "\u5728\u700f\u89bd\u5668\u958b\u555f", "desktop.external_browser.title": "\u5728\u9810\u8a2d\u700f\u89bd\u5668\u958b\u555f", + "desktop.installer.cleanup_detail": "\u6e05\u7406\u7bc4\u570d\u50c5\u9650 Windows \u8207\u5df2\u8a2d\u5b9a\u7684 WSL \u767c\u884c\u7248\u3002\u4f7f\u7528\u4e2d\u3001\u820a\u7248\u3001\u672a\u901a\u904e\u9a57\u8b49\u6216\u7121\u6cd5\u5b58\u53d6\u7684\u74b0\u5883\u6703\u4fdd\u7559\u3002\u4e00\u7b46\u4fdd\u7559\u7d50\u679c\u53ef\u80fd\u4ee3\u8868\u55ae\u4e00\u9805\u76ee\u6216\u6574\u500b\u57f7\u884c\u6a21\u5f0f\u3002\n\n\u5fa9\u539f\u8cc7\u6599\u593e\uff08\u4e0d\u6703\u91cb\u653e\u78c1\u789f\u7a7a\u9593\uff09\uff1a\n{windows_path}\n{wsl_path}\n\n\u82e5\u7d50\u679c\u672a\u78ba\u8a8d\uff0c\u8acb\u6aa2\u67e5\u9019\u4e9b\u8cc7\u6599\u593e\uff1b\u90e8\u5206\u79fb\u52d5\u53ef\u80fd\u5df2\u5b8c\u6210\u3002\u6b64\u6e05\u7406\u4e0d\u6703\u8b8a\u66f4 Core\u3001\u8a2d\u5b9a\u3001\u64f7\u53d6\u6a94\u6848\u3001\u7cfb\u7d71 Python \u6216 WSL \u767c\u884c\u7248\u3002", + "desktop.installer.cleanup_summary": "\u5df2\u78ba\u8a8d\u79fb\u52d5\uff1a{detached}\uff1b\u4fdd\u7559\u7d50\u679c\uff1a{retained}\uff1b\u7d50\u679c\u672a\u78ba\u8a8d\u7684\u57f7\u884c\u6a21\u5f0f\uff1a{unknown}\u3002", + "desktop.installer.cleanup_title": "StandTerm \u74b0\u5883\u6e05\u7406", + "desktop.installer.continue_uninstall": "\u7e7c\u7e8c\u89e3\u9664\u5b89\u88dd", + "desktop.installer.return_to_installer": "\u8fd4\u56de\u5b89\u88dd\u7a0b\u5f0f", + "desktop.installer.setup_failed_detail": "\u90e8\u5206\u74b0\u5883\u6e96\u5099\u3001\u79fb\u81f3\u5fa9\u539f\u8cc7\u6599\u593e\u6216\u6377\u5f91\u8b8a\u66f4\u53ef\u80fd\u5df2\u5b8c\u6210\u3002\u5df2\u5b8c\u6210\u7684\u8b8a\u66f4\u4e0d\u6703\u81ea\u52d5\u9084\u539f\u3002\u8acb\u5148\u6aa2\u67e5 venv-recovery\uff0c\u518d\u900f\u904e\u5b89\u88dd\u7a0b\u5f0f\u91cd\u8a66\u3002\u672a\u555f\u52d5\u7d42\u7aef\u5f8c\u7aef\u3002", + "desktop.installer.setup_failed_title": "StandTerm \u64cd\u4f5c\u672a\u5b8c\u6210", "desktop.language.choose": "\u9078\u64c7\u4e0b\u6b21\u555f\u52d5\u6642\u4f7f\u7528\u7684 Desktop \u8a9e\u7cfb\u3002", "desktop.language.detail": "\u5df2\u5132\u5b58\u7684\u9078\u64c7\uff1a{language}\n\nCore \u7684\u8a9e\u7cfb\u9700\u53e6\u5916\u8a2d\u5b9a\u3002\u90e8\u5206 Desktop \u6587\u5b57\u4ecd\u4f7f\u7528\u82f1\u6587\u3002", "desktop.language.failed": "\u7121\u6cd5\u78ba\u8a8d\u8a9e\u7cfb\u8a2d\u5b9a\u3002\u8acb\u91cd\u65b0\u958b\u555f\u300cDesktop \u8a9e\u7cfb\u300d\u67e5\u770b\u5df2\u5132\u5b58\u7684\u9078\u64c7\u3002", @@ -456,6 +502,24 @@ "desktop.menu.quit": "\u7d50\u675f StandTerm", "desktop.menu.settings": "\u8a2d\u5b9a\u2026", "desktop.menu.show": "\u986f\u793a\u8996\u7a97", + "desktop.paste.confirm": "\u8cbc\u4e0a", + "desktop.paste.detail": "\u9019\u6703\u8b80\u53d6\u526a\u8cbc\u7c3f\u6587\u5b57\u4e00\u6b21\u3002\u591a\u884c\u6216\u5927\u91cf\u6587\u5b57\u4ecd\u9808\u78ba\u8a8d\u3002\u82e5\u8981\u76f4\u63a5\u8cbc\u4e0a\uff0c\u8acb\u4f7f\u7528\u61c9\u7528\u7a0b\u5f0f\u9078\u55ae\u65c1\u7684\u300c\u8cbc\u4e0a\u300d\u6309\u9215\u3002", + "desktop.paste.empty": "\u526a\u8cbc\u7c3f\u6c92\u6709\u6587\u5b57\u3002", + "desktop.paste.message": "\u8981\u5c07\u526a\u8cbc\u7c3f\u6587\u5b57\u8cbc\u4e0a\u81f3\u6b64\u7d42\u7aef\u55ce\uff1f", + "desktop.paste.target_changed": "\u8cbc\u4e0a\u76ee\u6a19\u5df2\u8b8a\u66f4\uff0c\u56e0\u6b64\u53d6\u6d88\u8cbc\u4e0a\u3002", + "desktop.paste.title": "\u8cbc\u4e0a\u81f3 StandTerm", + "desktop.paste.unconfirmed": "\u7121\u6cd5\u78ba\u8a8d\u8cbc\u4e0a\u7d50\u679c\u3002\u518d\u6b21\u8cbc\u4e0a\u524d\uff0c\u8acb\u5148\u6aa2\u67e5\u7d42\u7aef\u3002", + "desktop.port.change_detail": "\u73fe\u6709\u670d\u52d9\u6703\u7e7c\u7e8c\u57f7\u884c\uff0c\u4e5f\u4e0d\u6703\u88ab\u91cd\u7528\u3002\u8b8a\u66f4\u9023\u63a5\u57e0\u6703\u6539\u8b8a\u700f\u89bd\u5668\u4f86\u6e90\uff1b\u700f\u89bd\u5668\u8a2d\u5b9a\u8207 SSH \u91d1\u9470\u4e0d\u6703\u79fb\u8f49\u3002Windows \u8207 WSL \u5206\u5225\u8a18\u4f4f\u5404\u81ea\u7684\u9023\u63a5\u57e0\u3002", + "desktop.port.confirm_host_in_use": "Windows \u4e0a\u7684\u9023\u63a5\u57e0 {port} \u5df2\u88ab\u4f7f\u7528\u3002\u8981\u6539\u7528\u9023\u63a5\u57e0 {candidate} \u55ce\uff1f", + "desktop.port.confirm_in_use": "\u9023\u63a5\u57e0 {port} \u5df2\u88ab\u4f7f\u7528\u3002\u8981\u6539\u7528\u9023\u63a5\u57e0 {candidate} \u55ce\uff1f", + "desktop.port.confirm_permission": "Windows \u4fdd\u7559\u4e86\u9023\u63a5\u57e0 {port}\uff0c\u6216\u62d2\u7d55\u5b58\u53d6\u3002\u8981\u6539\u7528\u9023\u63a5\u57e0 {candidate} \u55ce\uff1f", + "desktop.port.no_candidate": "\u9023\u63a5\u57e0 {port} \u5df2\u88ab\u4f7f\u7528\uff0c\u4e14\u6c92\u6709\u53ef\u81ea\u52d5\u9078\u7528\u7684\u9023\u63a5\u57e0\u3002", + "desktop.port.no_host_port": "\u627e\u4e0d\u5230\u53ef\u7528\u7684 Windows\uff0fWSL \u672c\u6a5f\u56de\u9001\u9023\u63a5\u57e0\u3002\u5df2\u5132\u5b58\u7684\u9023\u63a5\u57e0\u8a2d\u5b9a\u672a\u8b8a\u66f4\u3002", + "desktop.port.save_failed": "\u9023\u63a5\u57e0 {port} \u5df2\u901a\u904e\u9a57\u8b49\uff0c\u4f46\u7121\u6cd5\u5132\u5b58\u5176\u8a2d\u5b9a\u3002", + "desktop.port.saved_read_failed": "\u7121\u6cd5\u8b80\u53d6\u5df2\u5132\u5b58\u7684 Desktop \u9023\u63a5\u57e0\u8a2d\u5b9a\uff0c\u5c07\u81ea\u52d5\u9078\u64c7\u9023\u63a5\u57e0\u3002", + "desktop.port.startup_canceled": "\u5df2\u53d6\u6d88\u555f\u52d5 Desktop\u3002\u5df2\u5132\u5b58\u7684\u9023\u63a5\u57e0\u8a2d\u5b9a\u672a\u8b8a\u66f4\u3002", + "desktop.port.use_and_remember": "\u4f7f\u7528\u4e26\u8a18\u4f4f", + "desktop.port.use_once": "\u50c5\u672c\u6b21\u4f7f\u7528", "desktop.setup.cancel_detail": "\u4fdd\u6301\u6b64\u8996\u7a97\u958b\u555f\u6216\u6700\u5c0f\u5316\u5373\u53ef\u7e7c\u7e8c\u3002\u53d6\u6d88\u6703\u505c\u6b62\u6b64\u6b21\u5b89\u88dd\u7684\u8655\u7406\u7a0b\u5e8f\uff0c\u4e26\u4fdd\u7559\u5df2\u6e96\u5099\u7684\u6a94\u6848\u3002\u8acb\u91cd\u65b0\u555f\u52d5 StandTerm \u4ee5\u91cd\u8a66\u3002", "desktop.setup.cancel_message": "Python \u74b0\u5883\u4ecd\u5728\u6e96\u5099\u4e2d\u3002", "desktop.setup.cancel_setup": "\u53d6\u6d88\u6e96\u5099", diff --git a/desktop/port.cjs b/desktop/port.cjs index 47c0302..1db73c8 100644 --- a/desktop/port.cjs +++ b/desktop/port.cjs @@ -4,6 +4,7 @@ const fs = require('node:fs/promises'); const path = require('node:path'); const { randomUUID } = require('node:crypto'); const net = require('node:net'); +const { create } = require('./i18n.js'); const validPort = value => Number.isInteger(value) && value >= 1 && value <= 65535; const HOST_PORT_ATTEMPTS = 20; @@ -42,14 +43,14 @@ function parsePortConflict(line, requestedPort) { }); } -async function startWithPort({ settingsPath, launch, verify, stop, confirm, notify, checkHost }) { +async function startWithPort({ settingsPath, launch, verify, stop, confirm, notify, checkHost, t = create('en').t }) { let savedPort; try { const settings = JSON.parse(await fs.readFile(settingsPath, 'utf8')); if (settings.version !== 1 || !validPort(settings.port)) throw new Error('Invalid port settings.'); savedPort = settings.port; } catch (error) { - if (error.code !== 'ENOENT') await notify('Could not read the saved desktop port. Selecting an automatic port.'); + if (error.code !== 'ENOENT') await notify(t('desktop.port.saved_read_failed')); } let port = savedPort ?? 0; let remember = true; @@ -68,7 +69,7 @@ async function startWithPort({ settingsPath, launch, verify, stop, confirm, noti } catch (error) { await stop(); if (checkHost && error.code === 'HOST_PORT_UNAVAILABLE') { - if (++hostAttempts >= HOST_PORT_ATTEMPTS) throw new Error('No usable Windows/WSL loopback port was found. The saved port was not changed.'); + if (++hostAttempts >= HOST_PORT_ATTEMPTS) throw new Error(t('desktop.port.no_host_port')); if (port && !pendingChange) pendingChange = { port, reason: error.reason }; // Core still selects candidates using its service exclusions and real // binding. Do not replace that policy with an unchecked host-only port. @@ -76,10 +77,10 @@ async function startWithPort({ settingsPath, launch, verify, stop, confirm, noti continue; } if (error.code !== 'PORT_IN_USE') throw error; - if (error.suggestedPort === null) throw new Error(`Port ${port} is in use and no automatic port is available.`); + if (error.suggestedPort === null) throw new Error(t('desktop.port.no_candidate', { port })); const choice = await confirm(port, error.suggestedPort); if (!['once', 'remember'].includes(choice)) { - throw Object.assign(new Error('Desktop startup canceled. The saved port was not changed.'), { code: 'SETUP_CANCELED' }); + throw Object.assign(new Error(t('desktop.port.startup_canceled')), { code: 'SETUP_CANCELED' }); } port = error.suggestedPort; remember = choice === 'remember'; @@ -91,7 +92,7 @@ async function startWithPort({ settingsPath, launch, verify, stop, confirm, noti catch (error) { await stop(); throw error; } if (!['once', 'remember'].includes(choice)) { await stop(); - throw Object.assign(new Error('Desktop startup canceled. The saved port was not changed.'), { code: 'SETUP_CANCELED' }); + throw Object.assign(new Error(t('desktop.port.startup_canceled')), { code: 'SETUP_CANCELED' }); } remember = choice === 'remember'; } @@ -104,7 +105,7 @@ async function startWithPort({ settingsPath, launch, verify, stop, confirm, noti await fs.writeFile(temporary, JSON.stringify({ version: 1, port }, null, 2) + '\n', { flag: 'wx', mode: 0o600 }); await fs.rename(temporary, settingsPath); } catch { - await notify(`Port ${port} is active, but could not be saved. This launch will continue.`); + await notify(t('desktop.port.save_failed', { port })); } finally { await fs.unlink(temporary).catch(() => {}); } diff --git a/desktop/test/context-paste.test.cjs b/desktop/test/context-paste.test.cjs index 7ab953a..313b56a 100644 --- a/desktop/test/context-paste.test.cjs +++ b/desktop/test/context-paste.test.cjs @@ -7,11 +7,12 @@ const path = require('node:path'); const vm = require('node:vm'); const { EventEmitter } = require('node:events'); const { allowedNavigation } = require('../policy.cjs'); +const { create } = require('../i18n.js'); const origin = 'http://127.0.0.1:64487'; const deferred = () => { let resolve; const promise = new Promise(done => { resolve = done; }); return { promise, resolve }; }; const tick = () => new Promise(resolve => setImmediate(resolve)); -function fixture() { +function fixture(locale = 'en') { const consent = deferred(), read = deferred(), notices = [], scripts = [], callbacks = []; const state = { focused: true, visible: true, minimized: false, destroyed: false, loading: false, url: origin + '/', target: 'fixture-request', prompts: 0, reads: 0, delivered: true }; @@ -27,13 +28,13 @@ function fixture() { Object.assign(contents, { mainFrame: frame, isDestroyed: () => state.destroyed, getURL: () => state.url, isLoadingMainFrame: () => state.loading, session: { setPermissionRequestHandler: fn => { handler = fn; } } }); const electron = { BrowserWindow: { getFocusedWindow: () => state.focused ? win : null }, - dialog: { showMessageBox: () => { state.prompts++; return consent.promise; } }, + dialog: { showMessageBox: (_owner, options) => { state.prompts++; state.dialog = options; return consent.promise; } }, clipboard: { readText: () => { state.reads++; return read.promise; } } }; const api = { exports: {} }; vm.runInNewContext(fs.readFileSync(path.join(__dirname, '..', 'context-paste.cjs'), 'utf8'), { - require: name => name === 'electron' ? electron : { allowedNavigation }, module: api, + require: name => name === 'electron' ? electron : name === './i18n.js' ? { create } : { allowedNavigation }, module: api, }); - api.exports.installContextPaste(win, contents, origin, async value => notices.push(value)); + api.exports.installContextPaste(win, contents, origin, async value => notices.push(value), create(locale).t); return { state, consent, read, contents, scripts, callbacks, notices, request: (permission = 'clipboard-read', requester = contents, details = { isMainFrame: true, requestingUrl: state.url }) => new Promise(resolve => handler(requester, permission, value => { callbacks.push(value); resolve(value); }, details)), @@ -102,3 +103,40 @@ test('renderer failures deny exactly once, report no private data and clear pend assert.equal(f.notices.length, 2); assert.ok(f.notices.every(value => !value.includes('private fixture'))); }); + +test('both paste languages preserve explicit consent, one read and stale-target denial', async () => { + for (const locale of ['en', 'zh-TW']) for (const phase of ['cancel', 'paste', 'stale', 'empty']) { + const f = fixture(locale), { t } = create(locale); + const result = f.request(); await tick(); + assert.equal(f.state.dialog.message, t('desktop.paste.message')); + assert.deepEqual(Array.from(f.state.dialog.buttons), [t('desktop.common.cancel'), t('desktop.paste.confirm')]); + assert.equal(f.state.dialog.defaultId, 0); + assert.equal(f.state.dialog.cancelId, 0); + if (phase === 'stale') f.state.target = 'other'; + f.consent.resolve({ response: phase === 'cancel' ? 0 : 1 }); await tick(); + f.read.resolve(phase === 'empty' ? '' : 'literal {text} '); + assert.equal(await result, false, 'renderer clipboard permission is always denied'); + assert.equal(f.state.reads, ['paste', 'empty'].includes(phase) ? 1 : 0); + assert.equal(f.scripts.filter(code => code.includes('completeContextPaste')).length, ['paste', 'empty'].includes(phase) ? 1 : 0); + assert.deepEqual(f.notices, phase === 'stale' ? [t('desktop.paste.target_changed')] + : phase === 'empty' ? [t('desktop.paste.empty')] : []); + } +}); + +test('lost paste delivery acknowledgment reports uncertainty without reading or delivering twice', async () => { + for (const locale of ['en', 'zh-TW']) { + const f = fixture(locale); + const original = f.contents.mainFrame.executeJavaScript; + f.contents.mainFrame.executeJavaScript = async code => { + const value = await original(code); + if (code.includes('completeContextPaste')) throw new Error('Private delivery acknowledgment failure'); + return value; + }; + const result = f.request(); await tick(); + f.consent.resolve({ response: 1 }); await tick(); f.read.resolve('private clipboard text'); + assert.equal(await result, false); + assert.equal(f.state.reads, 1); + assert.equal(f.scripts.filter(code => code.includes('completeContextPaste')).length, 1); + assert.deepEqual(f.notices, [create(locale).t('desktop.paste.unconfirmed')]); + } +}); diff --git a/desktop/test/installer-i18n.test.cjs b/desktop/test/installer-i18n.test.cjs new file mode 100644 index 0000000..5989ca7 --- /dev/null +++ b/desktop/test/installer-i18n.test.cjs @@ -0,0 +1,235 @@ +'use strict'; + +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); +const vm = require('node:vm'); +const { EventEmitter } = require('node:events'); +const { create } = require('../i18n.js'); +const { createLanguage } = require('../language.cjs'); + +function fixture(testContext, preferences = {}, options = {}) { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'standterm-installer-i18n-')); + testContext.after(() => fs.rmSync(root, { recursive: true, force: true })); + const profiles = Object.fromEntries(['windows', 'wsl'].map(mode => [mode, path.join(root, mode)])); + for (const [mode, value] of Object.entries(preferences)) { + fs.mkdirSync(profiles[mode], { recursive: true }); + fs.writeFileSync(path.join(profiles[mode], 'language.json'), + typeof value === 'string' ? value : JSON.stringify(value)); + } + const snapshot = () => Object.fromEntries(Object.entries(profiles).map(([mode, directory]) => [mode, + fs.existsSync(directory) ? fs.readdirSync(directory).map(name => [name, fs.readFileSync(path.join(directory, name), 'utf8')]) : null])); + const initialPreferences = snapshot(); + const calls = { dialogs: [], events: [], exit: [], stopSetup: 0, watcherKills: 0, launches: [] }; + const app = new EventEmitter(); + Object.assign(app, { getVersion: () => 'test-version', getPath: name => path.join(root, name), + exit: code => calls.exit.push(code) }); + let watcher; + const modules = { + electron: { app, shell: {}, dialog: { showMessageBox: async settings => { + calls.dialogs.push(settings); return { response: 0 }; + } } }, + 'node:child_process': { spawn: (executable, args, launchOptions) => { + calls.launches.push({ executable, args, options: launchOptions }); + watcher = new EventEmitter(); + watcher.stdout = new EventEmitter(); + watcher.kill = () => { calls.watcherKills++; watcher.emit('exit', 0); }; + queueMicrotask(() => watcher.stdout.emit('data', Buffer.from('{"type":"parent_ready"}\n'))); + return watcher; + } }, + './language.cjs': { createLanguage }, + './i18n.js': require('../i18n.js'), + './setup.cjs': { + modeProfile: mode => profiles[mode], + preparePackagedBackend: async (mode, settings) => { + assert.equal(settings.installer, true); + calls.events.push(`prepare:${mode}`); + await options.prepare?.(mode); + }, + cleanupManagedVenvs: async mode => { + calls.events.push(`cleanup:${mode}`); + return options.cleanup ? options.cleanup(mode) : { mode, status: 'retained' }; + }, + stopSetup: async () => { calls.stopSetup++; }, + confirmSetupQuit: async () => false, + }, + './core-source.cjs': { installedStore: async (profile, resources, version) => { + assert.equal(resources, path.join(root, 'resources')); + assert.equal(version, 'test-version'); + const mode = Object.keys(profiles).find(key => profiles[key] === profile); + assert.ok(mode); + return { reset: async () => { calls.events.push(`reset:${mode}`); } }; + } }, + './installer-shortcuts.cjs': { + shortcutPlan: (_executable, _desktop, _programs, selected) => Array.from(selected), + applyShortcuts: async (_shell, selected) => { + calls.events.push(`shortcuts:${selected.join(',')}`); + await options.shortcuts?.(selected); + }, + }, + }; + const exported = { exports: {} }; + vm.runInNewContext(fs.readFileSync(path.join(__dirname, '..', 'installer.cjs'), 'utf8'), { + module: exported, exports: exported.exports, Buffer, setTimeout, clearTimeout, + process: { env: {}, resourcesPath: path.join(root, 'resources'), execPath: path.join(root, 'StandTermDesktop.exe') }, + require: name => { + if (Object.hasOwn(modules, name)) return modules[name]; + if (name === 'node:path') return path; + throw new Error(`Unexpected installer dependency: ${name}`); + }, + }, { filename: 'installer.cjs' }); + return { calls, + run: request => exported.exports.runInstaller({ parent: 123, modes: [], ...request }), + loseWatcher: () => watcher.emit('exit', 0), + assertUnchanged: () => assert.deepEqual(snapshot(), initialPreferences), + }; +} + +const preference = locale => ({ version: 1, locale }); +const rawError = 'Failed at C:\\User {retained}\\core: permission denied
'; + +for (const locale of ['en', 'zh-TW']) { + test(`installer cleanup summary uses ${locale} and preserves separate result counts and recovery paths`, async context => { + const f = fixture(context, { windows: preference(locale), wsl: preference(locale) }, { + cleanup: mode => mode === 'windows' + ? { mode, status: 'checked', results: [{ status: 'detached' }, { status: 'detached' }, { status: 'retained' }] } + : { mode, status: 'retained' }, + }); + assert.equal(await f.run({ action: 'uninstall', cleanup: true }), 0); + const [dialog] = f.calls.dialogs; + const { t } = create(locale); + assert.notEqual(t('desktop.installer.cleanup_title'), 'desktop.installer.cleanup_title'); + assert.equal(dialog.title, t('desktop.installer.cleanup_title')); + assert.equal(dialog.message, t('desktop.installer.cleanup_summary', { detached: 2, retained: 2, unknown: 0 })); + assert.ok(dialog.detail.includes('%LOCALAPPDATA%\\StandTermDesktop\\venv-recovery')); + assert.ok(dialog.detail.includes('~/.local/share/standterm-desktop/venv-recovery')); + assert.deepEqual(Array.from(dialog.buttons), [t('desktop.installer.continue_uninstall')]); + assert.deepEqual(f.calls.events, ['cleanup:windows', 'cleanup:wsl', 'shortcuts:']); + assert.equal(f.calls.dialogs.length, 1); + assert.equal(f.calls.launches.length, 1); + assert.match(f.calls.launches[0].executable, /powershell\.exe$/); + assert.equal(f.calls.launches[0].options.shell, false); + assert.equal(f.calls.watcherKills, 1); + assert.deepEqual(f.calls.exit, []); + f.assertUnchanged(); + }); + + test(`installer setup failure uses ${locale} while preserving the original error and source reset order`, async context => { + const f = fixture(context, { windows: preference(locale), wsl: preference(locale) }, { + prepare: async mode => { if (mode === 'wsl') throw new Error(rawError); }, + }); + assert.equal(await f.run({ action: 'prepare', modes: ['windows', 'wsl'] }), 1); + const [dialog] = f.calls.dialogs; + const { t } = create(locale); + assert.equal(dialog.title, t('desktop.installer.setup_failed_title')); + assert.equal(dialog.message, rawError); + assert.equal(dialog.detail, t('desktop.installer.setup_failed_detail')); + assert.deepEqual(Array.from(dialog.buttons), [t('desktop.installer.return_to_installer')]); + assert.deepEqual(f.calls.events, ['prepare:windows', 'reset:windows', 'prepare:wsl']); + assert.equal(f.calls.stopSetup, 1); + assert.equal(f.calls.dialogs.length, 1); + f.assertUnchanged(); + }); +} + +test('installer summary falls back to English for mixed, missing or invalid profile preferences without writing them', async context => { + for (const preferences of [ + { windows: preference('zh-TW'), wsl: preference('en') }, + { windows: preference('zh-TW') }, + { windows: preference('zh-TW'), wsl: '{malformed' }, + { windows: preference('zh-TW'), wsl: preference('zh-CN') }, + { windows: preference('zh-TW'), wsl: { version: 99, locale: 'zh-TW' } }, + {}, + ]) { + const f = fixture(context, preferences); + assert.equal(await f.run({ action: 'uninstall', cleanup: true }), 0); + assert.equal(f.calls.dialogs[0].title, create('en').t('desktop.installer.cleanup_title')); + f.assertUnchanged(); + } +}); + +test('single-mode preparation uses only its selected profile language', async context => { + for (const mode of ['windows', 'wsl']) { + const other = mode === 'windows' ? 'wsl' : 'windows'; + const f = fixture(context, { [mode]: preference('zh-TW'), [other]: preference('en') }, { + prepare: async () => { throw new Error(rawError); }, + }); + assert.equal(await f.run({ action: 'prepare', modes: [mode] }), 1); + assert.equal(f.calls.dialogs[0].title, create('zh-TW').t('desktop.installer.setup_failed_title')); + assert.deepEqual(f.calls.events, [`prepare:${mode}`]); + f.assertUnchanged(); + } +}); + +test('preparation resets each selected source before the next mode and publishes shortcuts only after both finish', async context => { + const f = fixture(context); + assert.equal(await f.run({ action: 'prepare', modes: ['windows', 'wsl'] }), 0); + assert.deepEqual(f.calls.events, ['prepare:windows', 'reset:windows', 'prepare:wsl', 'reset:wsl', 'shortcuts:windows,wsl']); + assert.deepEqual(f.calls.dialogs, []); + f.assertUnchanged(); +}); + +test('cleanup exceptions count as unknown modes rather than retained entries', async context => { + const f = fixture(context, { windows: preference('zh-TW'), wsl: preference('zh-TW') }, { + cleanup: async mode => { if (mode === 'wsl') throw new Error('WSL unavailable'); return { mode, status: 'retained' }; }, + }); + assert.equal(await f.run({ action: 'uninstall', cleanup: true }), 0); + assert.equal(f.calls.dialogs[0].message, create('zh-TW').t('desktop.installer.cleanup_summary', { + detached: 0, retained: 1, unknown: 1, + })); +}); + +test('installer cancellation remains exit code 2 and other setup failures remain exit code 1', async context => { + for (const [code, expected] of [['SETUP_CANCELED', 2], ['OTHER', 1]]) { + const f = fixture(context, { windows: preference('zh-TW') }, { + prepare: async () => { throw Object.assign(new Error(rawError), { code }); }, + }); + assert.equal(await f.run({ action: 'prepare', modes: ['windows'] }), expected); + assert.equal(f.calls.dialogs[0].message, rawError); + assert.deepEqual(f.calls.events, ['prepare:windows']); + assert.equal(f.calls.stopSetup, 1); + } +}); + +test('losing the owning installer prevents dialogs, source reset, later preparation and shortcuts', async context => { + const f = fixture(context, { windows: preference('zh-TW'), wsl: preference('zh-TW') }, { + prepare: async () => { f.loseWatcher(); }, + }); + assert.equal(await f.run({ action: 'prepare', modes: ['windows', 'wsl'] }), 1); + assert.deepEqual(f.calls.events, ['prepare:windows']); + assert.deepEqual(f.calls.dialogs, []); + assert.deepEqual(f.calls.exit, [2]); + assert.equal(f.calls.watcherKills, 1); + f.assertUnchanged(); +}); + +test('losing the owning installer during cleanup prevents the summary and shortcut removal', async context => { + const f = fixture(context, { windows: preference('zh-TW'), wsl: preference('zh-TW') }, { + cleanup: async mode => { f.loseWatcher(); return { mode, status: 'retained' }; }, + }); + assert.equal(await f.run({ action: 'uninstall', cleanup: true }), 1); + assert.deepEqual(f.calls.events, ['cleanup:windows']); + assert.deepEqual(f.calls.dialogs, []); + assert.deepEqual(f.calls.exit, [2]); + assert.equal(f.calls.watcherKills, 1); + f.assertUnchanged(); +}); + +test('uninstall failure after confirmed recovery moves uses the partial-operation notice', async context => { + for (const locale of ['en', 'zh-TW']) { + const f = fixture(context, { windows: preference(locale), wsl: preference(locale) }, { + cleanup: async mode => ({ mode, status: 'checked', results: [{ status: 'detached' }] }), + shortcuts: async () => { throw new Error(rawError); }, + }); + assert.equal(await f.run({ action: 'uninstall', cleanup: true }), 1); + const { t } = create(locale); + assert.equal(f.calls.dialogs[0].message, t('desktop.installer.cleanup_summary', { detached: 2, retained: 0, unknown: 0 })); + const failure = f.calls.dialogs[1]; + assert.equal(failure.message, rawError); + assert.equal(failure.detail, t('desktop.installer.setup_failed_detail')); + assert.ok(failure.detail.includes('venv-recovery')); + assert.deepEqual(f.calls.events, ['cleanup:windows', 'cleanup:wsl', 'shortcuts:']); + } +}); diff --git a/desktop/test/port.test.cjs b/desktop/test/port.test.cjs index 004d66d..de544d8 100644 --- a/desktop/test/port.test.cjs +++ b/desktop/test/port.test.cjs @@ -184,3 +184,60 @@ test('host probing uses typed socket errors and allows an existing relay only af } await assert.rejects(checkHostPort(0)); }); + +test('both port languages preserve save timing and cancel or use-once decisions', async context => { + const { create } = require('../i18n.js'); + for (const locale of ['en', 'zh-TW']) for (const choice of ['cancel', 'once', 'remember']) { + const { options, read } = await fixture(context, { version: 1, port: 51000 }); + options.t = create(locale).t; + const launch = options.launch; + options.launch = async port => { if (port === 51000) throw conflict(port); return launch(port); }; + options.confirm = async () => choice; + options.verify = async () => assert.equal((await read()).port, 51000); + if (choice === 'cancel') await assert.rejects(startWithPort(options), { + code: 'SETUP_CANCELED', message: options.t('desktop.port.startup_canceled'), + }); + else await startWithPort(options); + assert.equal((await read()).port, choice === 'remember' ? 51001 : 51000); + } +}); + +test('localized port warnings distinguish read fallback and verified-port persistence failure', async context => { + const { create } = require('../i18n.js'); + for (const locale of ['en', 'zh-TW']) { + const { options } = await fixture(context, { version: 1, port: false }); + const messages = []; + options.t = create(locale).t; + options.notify = async message => messages.push(message); + await startWithPort(options); + assert.deepEqual(messages, [options.t('desktop.port.saved_read_failed')]); + await fs.unlink(options.settingsPath); + await fs.mkdir(options.settingsPath); + messages.length = 0; + let verified = false; + options.verify = async () => { verified = true; }; + options.notify = async message => { + if (messages.length) assert.equal(verified, true); + messages.push(message); + }; + await startWithPort(options); + assert.deepEqual(messages, [options.t('desktop.port.saved_read_failed'), options.t('desktop.port.save_failed', { port: 45678 })]); + } +}); + +test('localized exhausted-port errors retain the saved port and existing retry bound', async context => { + const { create } = require('../i18n.js'); + for (const locale of ['en', 'zh-TW']) { + const { options, read } = await fixture(context, { version: 1, port: 51000 }); + options.t = create(locale).t; + options.launch = async port => { throw Object.assign(conflict(port), { suggestedPort: null }); }; + await assert.rejects(startWithPort(options), { message: options.t('desktop.port.no_candidate', { port: 51000 }) }); + let attempts = 0; + options.checkHost = async port => { throw hostConflict(port); }; + options.launch = async () => ({ origin: 'http://127.0.0.1:51001' }); + options.stop = async () => { attempts++; }; + await assert.rejects(startWithPort(options), { message: options.t('desktop.port.no_host_port') }); + assert.equal(attempts, 20); + assert.equal((await read()).port, 51000); + } +}); diff --git a/desktop/test/shell-notices-i18n.test.cjs b/desktop/test/shell-notices-i18n.test.cjs new file mode 100644 index 0000000..4a6a360 --- /dev/null +++ b/desktop/test/shell-notices-i18n.test.cjs @@ -0,0 +1,88 @@ +'use strict'; + +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const vm = require('node:vm'); +const { create } = require('../i18n.js'); + +// Run the actual main-process callbacks without launching Core or a native window. +const source = fs.readFileSync(path.join(__dirname, '..', 'main.cjs'), 'utf8'); +const portOptions = source.match(/ const handoff = await startWithPort\(([\s\S]*?\n })\);/)[1]; +const downloadRegistration = source.match(/ installFloatingWindows\(win,[\s\S]*?\n }\);/)[0]; + +test('localized port prompts preserve numeric choices and structured reasons', async () => { + for (const locale of ['en', 'zh-TW']) for (const response of [0, 1, 2]) { + const { t } = create(locale); + const dialogs = []; + const options = vm.runInNewContext('(' + portOptions + ')', { + settingsPath: 'fixture', t, mode: 'windows', MODES: { windows: 'Windows' }, + process: { platform: 'linux' }, smoke: false, + launchBackend: () => assert.fail('Unexpected launch'), prepared: null, + verifyBackend: () => assert.fail('Unexpected verify'), stopBackend: () => assert.fail('Unexpected stop'), + diagnostics: { write() {} }, + dialog: { showMessageBox: async options => { dialogs.push(options); return { response }; } }, + }); + assert.equal(options.t, t); + for (const [reason, key] of [[undefined, 'confirm_in_use'], ['host_permission_denied', 'confirm_permission'], + ['host_address_in_use', 'confirm_host_in_use'], ['is reserved or denied by Windows', 'confirm_in_use']]) { + assert.equal(await options.confirm(51000, 51001, reason), ['cancel', 'once', 'remember'][response]); + const notice = dialogs.at(-1); + assert.equal(notice.message, t(`desktop.port.${key}`, { port: 51000, candidate: 51001 })); + assert.deepEqual(Array.from(notice.buttons), [t('desktop.common.cancel'), t('desktop.port.use_once'), t('desktop.port.use_and_remember')]); + assert.equal(notice.defaultId, 0); + assert.equal(notice.cancelId, 0); + } + } +}); + +function downloadFixture(locale, response, reject = false) { + const { t } = create(locale); + const dialogs = [], reveals = [], events = []; + const owner = {}; + let callback; + vm.runInNewContext(downloadRegistration, { + t, win: {}, handoff: { origin: 'http://127.0.0.1:51000' }, openExternal() {}, contents: {}, + installFloatingWindows: (_win, _origin, _open, _contents, done) => { callback = done; }, + dialog: { showMessageBox: async (target, options) => { + assert.equal(target, owner); + dialogs.push(options); + if (reject) throw new Error('Private dialog failure'); + return { response }; + } }, + shell: { showItemInFolder: value => reveals.push(value) }, + diagnostics: { write: value => events.push(value) }, + }); + return { t, dialogs, reveals, events, + send: async result => { callback(result, owner); await new Promise(setImmediate); } }; +} + +test('download notices reveal only a confirmed completed native path in both languages', async () => { + const rawPath = 'C:\\Downloads\\ {path}.txt'; + for (const locale of ['en', 'zh-TW']) for (const response of [0, 1]) { + for (const result of [{ state: 'completed', path: rawPath }, { state: 'completed', path: '' }, + { state: 'interrupted', path: rawPath }]) { + const f = downloadFixture(locale, response); + await f.send(result); + const completed = result.state === 'completed' && !!result.path; + const notice = f.dialogs[0]; + assert.equal(notice.type, completed ? 'info' : 'warning'); + assert.equal(notice.message, f.t(completed ? 'desktop.download.complete' : 'desktop.download.incomplete')); + assert.equal(notice.detail, f.t(completed ? 'desktop.download.saved_to' : 'desktop.download.retry', { path: rawPath })); + assert.equal(notice.buttons.length, completed ? 2 : 1); + assert.equal(notice.cancelId, 0); + assert.equal(notice.defaultId, 0); + assert.deepEqual(f.reveals, completed && response === 1 ? [rawPath] : []); + assert.deepEqual(f.events, []); + } + } +}); + +test('download notification failure neither reveals a path nor retries the operation', async () => { + const f = downloadFixture('zh-TW', 1, true); + await f.send({ state: 'completed', path: 'C:\\Downloads\\secret.txt' }); + assert.equal(f.dialogs.length, 1); + assert.deepEqual(f.reveals, []); + assert.deepEqual(f.events, ['download_notice_failed']); +}); diff --git a/docs/desktop_ui_copy_review.tsv b/docs/desktop_ui_copy_review.tsv index 19e755e..69359cc 100644 --- a/docs/desktop_ui_copy_review.tsv +++ b/docs/desktop_ui_copy_review.tsv @@ -272,3 +272,35 @@ desktop.core_source.manual_updates Git updates are manual. Install Git in the se desktop.core_source.unavailable_title StandTerm Core is unavailable StandTerm Core is unavailable StandTerm Core 無法使用 Native Core source manager and recovery dialogs; fixed action IDs and response indices. Display text only. Preserve typed action IDs enable/update/prepare/recover and bundled/git source values. Keep numeric response mapping and defaultId=0/cancelId=0. Never dispatch by translated text. Preserve raw error messages and commit identifiers; unknown workspace values remain literal. Restart explicitly closes sessions; do not imply automatic retries, data rollback, source authorization migration, or verified Git files. translation-reviewed desktop/core-source.cjs:coreController desktop.core_source.quit Quit Quit 結束 Native Core source manager and recovery dialogs; fixed action IDs and response indices. Display text only. Preserve typed action IDs enable/update/prepare/recover and bundled/git source values. Keep numeric response mapping and defaultId=0/cancelId=0. Never dispatch by translated text. Preserve raw error messages and commit identifiers; unknown workspace values remain literal. Restart explicitly closes sessions; do not imply automatic retries, data rollback, source authorization migration, or verified Git files. translation-reviewed desktop/core-source.cjs:coreController desktop.core_source.retry Retry Retry 重試 Native Core source manager and recovery dialogs; fixed action IDs and response indices. Display text only. Preserve typed action IDs enable/update/prepare/recover and bundled/git source values. Keep numeric response mapping and defaultId=0/cancelId=0. Never dispatch by translated text. Preserve raw error messages and commit identifiers; unknown workspace values remain literal. Restart explicitly closes sessions; do not imply automatic retries, data rollback, source authorization migration, or verified Git files. translation-reviewed desktop/core-source.cjs:coreController +desktop.port.confirm_permission Port {port} is reserved or denied by Windows. Use port {candidate}? Windows reserves port {port} or denies access to it. Use port {candidate}? Windows 保留了連接埠 {port},或拒絕存取。要改用連接埠 {candidate} 嗎? Whole prompt for typed host_permission_denied; do not translate a sentence fragment. {port} {candidate} Display text only. Preserve typed reasons, error codes, numeric button responses and once/remember semantics. Reuse desktop.common.cancel for response 0. Keep raw port values literal; do not stop or reuse an existing service. translation-reviewed desktop/main.cjs:port confirmation +desktop.port.confirm_host_in_use Port {port} is already in use on Windows. Use port {candidate}? Port {port} is already in use on Windows. Use port {candidate}? Windows 上的連接埠 {port} 已被使用。要改用連接埠 {candidate} 嗎? Whole prompt for typed host_address_in_use. {port} {candidate} Display text only. Preserve typed reasons, error codes, numeric button responses and once/remember semantics. Reuse desktop.common.cancel for response 0. Keep raw port values literal; do not stop or reuse an existing service. translation-reviewed desktop/main.cjs:port confirmation +desktop.port.confirm_in_use Port {port} is already in use. Use port {candidate}? Port {port} is already in use. Use port {candidate}? 連接埠 {port} 已被使用。要改用連接埠 {candidate} 嗎? Whole prompt for backend address conflict; default typed reason mapping. {port} {candidate} Display text only. Preserve typed reasons, error codes, numeric button responses and once/remember semantics. Reuse desktop.common.cancel for response 0. Keep raw port values literal; do not stop or reuse an existing service. translation-reviewed desktop/main.cjs:port confirmation +desktop.port.change_detail No existing service will be stopped or reused. Changing the port changes the browser origin; browser settings and SSH keys are not migrated. Windows and WSL remember their ports separately. Existing services will keep running and will not be reused. Changing the port changes the browser origin; browser settings and SSH keys are not migrated. Windows and WSL remember their ports separately. 現有服務會繼續執行,也不會被重用。變更連接埠會改變瀏覽器來源;瀏覽器設定與 SSH 金鑰不會移轉。Windows 與 WSL 分別記住各自的連接埠。 Consequences of choosing a replacement port; origin-scoped browser data is not migrated. Display text only. Preserve typed reasons, error codes, numeric button responses and once/remember semantics. Reuse desktop.common.cancel for response 0. Keep raw port values literal; do not stop or reuse an existing service. translation-reviewed desktop/main.cjs:port confirmation +desktop.port.use_once Use once Use once 僅本次使用 Response 1 returns once and leaves saved port preference unchanged. Display text only. Preserve typed reasons, error codes, numeric button responses and once/remember semantics. Reuse desktop.common.cancel for response 0. Keep raw port values literal; do not stop or reuse an existing service. translation-reviewed desktop/main.cjs:port confirmation +desktop.port.use_and_remember Use and remember Use and remember 使用並記住 Response 2 returns remember; persist only after verified launch. Display text only. Preserve typed reasons, error codes, numeric button responses and once/remember semantics. Reuse desktop.common.cancel for response 0. Keep raw port values literal; do not stop or reuse an existing service. translation-reviewed desktop/main.cjs:port confirmation +desktop.port.saved_read_failed Could not read the saved desktop port. Selecting an automatic port. Could not read the saved desktop port. Selecting an automatic port. 無法讀取已儲存的 Desktop 連接埠設定,將自動選擇連接埠。 Warning on unreadable saved preference; missing file is silent. Display text only. Preserve typed reasons, error codes, numeric button responses and once/remember semantics. Reuse desktop.common.cancel for response 0. Keep raw port values literal; do not stop or reuse an existing service. translation-reviewed desktop/port.cjs:startWithPort +desktop.port.no_host_port No usable Windows/WSL loopback port was found. The saved port was not changed. No usable Windows/WSL loopback port was found. The saved port was not changed. 找不到可用的 Windows/WSL 本機回送連接埠。已儲存的連接埠設定未變更。 User-facing error after host retry limit; keep the attempt limit unchanged. Display text only. Preserve typed reasons, error codes, numeric button responses and once/remember semantics. Reuse desktop.common.cancel for response 0. Keep raw port values literal; do not stop or reuse an existing service. translation-reviewed desktop/port.cjs:startWithPort +desktop.port.no_candidate Port {port} is in use and no automatic port is available. Port {port} is in use and no automatic port is available. 連接埠 {port} 已被使用,且沒有可自動選用的連接埠。 User-facing error when structured Core conflict reports no suggested port. {port} Display text only. Preserve typed reasons, error codes, numeric button responses and once/remember semantics. Reuse desktop.common.cancel for response 0. Keep raw port values literal; do not stop or reuse an existing service. translation-reviewed desktop/port.cjs:startWithPort +desktop.port.startup_canceled Desktop startup canceled. The saved port was not changed. Desktop startup canceled. The saved port was not changed. 已取消啟動 Desktop。已儲存的連接埠設定未變更。 Cancellation after rejecting replacement; preserve SETUP_CANCELED and backend teardown. Display text only. Preserve typed reasons, error codes, numeric button responses and once/remember semantics. Reuse desktop.common.cancel for response 0. Keep raw port values literal; do not stop or reuse an existing service. translation-reviewed desktop/port.cjs:startWithPort +desktop.port.save_failed Port {port} is active, but could not be saved. This launch will continue. Port {port} passed verification, but its setting could not be saved. 連接埠 {port} 已通過驗證,但無法儲存其設定。 Persistence warning after verified backend launch; port itself is active. {port} Display text only. Preserve typed reasons, error codes, numeric button responses and once/remember semantics. Reuse desktop.common.cancel for response 0. Keep raw port values literal; do not stop or reuse an existing service. translation-reviewed desktop/port.cjs:startWithPort +desktop.download.title Files download Files download 檔案下載 Native completion/failure dialog for an allowed Files download. Display text only. Keep completed/interrupted state checks and nonempty path requirement. Canceled downloads remain silent. Response 1 reveals the completed file only; never retry or open the file automatically. Insert the raw saved path literally. translation-reviewed desktop/main.cjs:installFloatingWindows downloadDone +desktop.download.complete Download complete Download complete 下載完成 Only for completed state with a nonempty save path. Display text only. Keep completed/interrupted state checks and nonempty path requirement. Canceled downloads remain silent. Response 1 reveals the completed file only; never retry or open the file automatically. Insert the raw saved path literally. translation-reviewed desktop/main.cjs:installFloatingWindows downloadDone +desktop.download.incomplete Download did not complete Download completion could not be confirmed. 無法確認下載已完成。 Interrupted state or missing completion path; do not infer the underlying cause. Display text only. Keep completed/interrupted state checks and nonempty path requirement. Canceled downloads remain silent. Response 1 reveals the completed file only; never retry or open the file automatically. Insert the raw saved path literally. translation-reviewed desktop/main.cjs:installFloatingWindows downloadDone +desktop.download.saved_to Saved to:\n{path} Saved to:\n{path} 已儲存至:\n{path} Completed download destination; path is unmodified display data. {path} Display text only. Keep completed/interrupted state checks and nonempty path requirement. Canceled downloads remain silent. Response 1 reveals the completed file only; never retry or open the file automatically. Insert the raw saved path literally. translation-reviewed desktop/main.cjs:installFloatingWindows downloadDone +desktop.download.retry The connection was interrupted. Retry the download from Files. Check the download destination before downloading again. 再次下載前,請先檢查下載目的地。 Completion could not be confirmed; inspect the local download destination before manually downloading again. Display text only. Keep completed/interrupted state checks and nonempty path requirement. Canceled downloads remain silent. Response 1 reveals the completed file only; never retry or open the file automatically. Insert the raw saved path literally. translation-reviewed desktop/main.cjs:installFloatingWindows downloadDone +desktop.download.close Close Close 關閉 Response 0 closes only this dialog. Display text only. Keep completed/interrupted state checks and nonempty path requirement. Canceled downloads remain silent. Response 1 reveals the completed file only; never retry or open the file automatically. Insert the raw saved path literally. translation-reviewed desktop/main.cjs:installFloatingWindows downloadDone +desktop.download.show_in_folder Show in folder Show in folder 在資料夾中顯示 Response 1 calls showItemInFolder only after completed download. Display text only. Keep completed/interrupted state checks and nonempty path requirement. Canceled downloads remain silent. Response 1 reveals the completed file only; never retry or open the file automatically. Insert the raw saved path literally. translation-reviewed desktop/main.cjs:installFloatingWindows downloadDone +desktop.installer.cleanup_title StandTerm environment cleanup StandTerm environment cleanup StandTerm 環境清理 Native summary after optional uninstall cleanup. Display text only. Preserve installer action, mode scope, parent watcher, exit codes and raw error message. Do not infer a complete rollback or zero changes from an unconfirmed result. Recovery moves do not free disk space. Keep path placeholders literal and outside translated source text. translation-reviewed desktop/installer.cjs:runInstaller +desktop.installer.cleanup_summary {detached} confirmed move(s); {retained} retained entry/entries; {unknown} unconfirmed environment(s). Confirmed moves: {detached}; retained results: {retained}; modes with unconfirmed results: {unknown}. 已確認移動:{detached};保留結果:{retained};結果未確認的執行模式:{unknown}。 Detached counts entries; retained mixes individual entries and whole-mode results; unknown counts modes with unknown results. Do not call these three environment counts. {detached} {retained} {unknown} Display text only. Preserve installer action, mode scope, parent watcher, exit codes and raw error message. Do not infer a complete rollback or zero changes from an unconfirmed result. Recovery moves do not free disk space. Keep path placeholders literal and outside translated source text. translation-reviewed desktop/installer.cjs:runInstaller +desktop.installer.cleanup_detail In-use, legacy, unverified and unavailable environments are retained. Only Windows and the configured WSL distribution were checked.\n\nRecovery folders (disk space is not freed):\n{windows_path}\n{wsl_path}\n\nIf a result is unconfirmed, inspect these folders; some moves may already have completed. Core, settings, captures, system Python and WSL distributions are untouched. Cleanup is limited to Windows and the configured WSL distribution. In-use, legacy, unverified and unavailable environments are retained. A retained result may represent one item or an entire mode.\n\nRecovery folders (disk space is not freed):\n{windows_path}\n{wsl_path}\n\nFor unconfirmed results, inspect these folders; some moves may already have completed. This cleanup leaves Core, settings, captures, system Python and WSL distributions unchanged. 清理範圍僅限 Windows 與已設定的 WSL 發行版。使用中、舊版、未通過驗證或無法存取的環境會保留。一筆保留結果可能代表單一項目或整個執行模式。\n\n復原資料夾(不會釋放磁碟空間):\n{windows_path}\n{wsl_path}\n\n若結果未確認,請檢查這些資料夾;部分移動可能已完成。此清理不會變更 Core、設定、擷取檔案、系統 Python 或 WSL 發行版。 Recovery scope and retained data; only claims this cleanup preserves these resources, not subsequent NSIS uninstall. {windows_path} {wsl_path} Display text only. Preserve installer action, mode scope, parent watcher, exit codes and raw error message. Do not infer a complete rollback or zero changes from an unconfirmed result. Recovery moves do not free disk space. Keep path placeholders literal and outside translated source text. translation-reviewed desktop/installer.cjs:runInstaller +desktop.installer.continue_uninstall Continue uninstall Continue uninstall 繼續解除安裝 Summary acknowledgment returns control to the owning installer. Display text only. Preserve installer action, mode scope, parent watcher, exit codes and raw error message. Do not infer a complete rollback or zero changes from an unconfirmed result. Recovery moves do not free disk space. Keep path placeholders literal and outside translated source text. translation-reviewed desktop/installer.cjs:runInstaller +desktop.installer.setup_failed_title StandTerm setup did not complete StandTerm operation did not complete StandTerm 操作未完成 Failure in preparation, cleanup, source reset or shortcuts; do not narrow to preparation only. Display text only. Preserve installer action, mode scope, parent watcher, exit codes and raw error message. Do not infer a complete rollback or zero changes from an unconfirmed result. Recovery moves do not free disk space. Keep path placeholders literal and outside translated source text. translation-reviewed desktop/installer.cjs:runInstaller +desktop.installer.setup_failed_detail Prepared environments and application files are retained. Run the installer again to retry. No terminal backend was started. Some environment preparation, moves to recovery, or shortcut changes may already have completed. Completed changes are not automatically undone. Check venv-recovery before retrying with the installer. No terminal backend was started. 部分環境準備、移至復原資料夾或捷徑變更可能已完成。已完成的變更不會自動還原。請先檢查 venv-recovery,再透過安裝程式重試。未啟動終端後端。 Generic maintenance failure can follow partial shortcut modifications or recovery moves; avoid blanket application-files-retained claim. Raw error remains the message. Display text only. Preserve installer action, mode scope, parent watcher, exit codes and raw error message. Do not infer a complete rollback or zero changes from an unconfirmed result. Recovery moves do not free disk space. Keep path placeholders literal and outside translated source text. translation-reviewed desktop/installer.cjs:runInstaller +desktop.installer.return_to_installer Return to installer Return to installer 返回安裝程式 Failure acknowledgment; keep existing exit-code handling. Display text only. Preserve installer action, mode scope, parent watcher, exit codes and raw error message. Do not infer a complete rollback or zero changes from an unconfirmed result. Recovery moves do not free disk space. Keep path placeholders literal and outside translated source text. translation-reviewed desktop/installer.cjs:runInstaller +desktop.paste.title Paste into StandTerm Paste into StandTerm 貼上至 StandTerm Native context-paste confirmation and notices; an exception can occur after delivery. Preserve numeric consent, focus/frame/navigation/target guards and exactly one clipboard read. Never grant renderer clipboard permission, infer control flow from labels, expose clipboard text in notices or automatically retry. translation-reviewed desktop/context-paste.cjs +desktop.paste.message Paste clipboard text into this terminal? Paste clipboard text into this terminal? 要將剪貼簿文字貼上至此終端嗎? Native context-paste confirmation and notices; an exception can occur after delivery. Preserve numeric consent, focus/frame/navigation/target guards and exactly one clipboard read. Never grant renderer clipboard permission, infer control flow from labels, expose clipboard text in notices or automatically retry. translation-reviewed desktop/context-paste.cjs +desktop.paste.detail This reads clipboard text once. Multi-line or large text still requires review. For direct paste, use the Paste button beside the application menu. This reads clipboard text once. Multi-line or large text still requires review. For direct paste, use the Paste button beside the application menu. 這會讀取剪貼簿文字一次。多行或大量文字仍須確認。若要直接貼上,請使用應用程式選單旁的「貼上」按鈕。 Native context-paste confirmation and notices; an exception can occur after delivery. Preserve numeric consent, focus/frame/navigation/target guards and exactly one clipboard read. Never grant renderer clipboard permission, infer control flow from labels, expose clipboard text in notices or automatically retry. translation-reviewed desktop/context-paste.cjs +desktop.paste.confirm Paste Paste 貼上 Native context-paste confirmation and notices; an exception can occur after delivery. Preserve numeric consent, focus/frame/navigation/target guards and exactly one clipboard read. Never grant renderer clipboard permission, infer control flow from labels, expose clipboard text in notices or automatically retry. translation-reviewed desktop/context-paste.cjs +desktop.paste.target_changed Paste canceled because the target changed. Paste canceled because the target changed. 貼上目標已變更,因此取消貼上。 Native context-paste confirmation and notices; an exception can occur after delivery. Preserve numeric consent, focus/frame/navigation/target guards and exactly one clipboard read. Never grant renderer clipboard permission, infer control flow from labels, expose clipboard text in notices or automatically retry. translation-reviewed desktop/context-paste.cjs +desktop.paste.empty The clipboard contains no text. The clipboard contains no text. 剪貼簿沒有文字。 Native context-paste confirmation and notices; an exception can occur after delivery. Preserve numeric consent, focus/frame/navigation/target guards and exactly one clipboard read. Never grant renderer clipboard permission, infer control flow from labels, expose clipboard text in notices or automatically retry. translation-reviewed desktop/context-paste.cjs +desktop.paste.unconfirmed Paste unavailable. Use the Paste toolbar button or your terminal paste shortcut. Could not confirm the paste result. Check the terminal before pasting again. 無法確認貼上結果。再次貼上前,請先檢查終端。 Native context-paste confirmation and notices; an exception can occur after delivery. Preserve numeric consent, focus/frame/navigation/target guards and exactly one clipboard read. Never grant renderer clipboard permission, infer control flow from labels, expose clipboard text in notices or automatically retry. translation-reviewed desktop/context-paste.cjs diff --git a/docs/desktop_ui_review_plan.md b/docs/desktop_ui_review_plan.md index a566d11..bd9401d 100644 --- a/docs/desktop_ui_review_plan.md +++ b/docs/desktop_ui_review_plan.md @@ -14,10 +14,12 @@ acceptance remain separate. Browser acceptance is recorded in | 1 — Complete | Add a Desktop-owned language preference and catalog; pilot custom menus, toolbar labels and Agent help. | Medium | English default/fallback, `en` and `zh-TW`, malformed preference fallback, next-launch application, translated title/ARIA labels without losing SVGs, fixed command IDs, focus/origin guards and staging inclusion verified. Native acceptance remains order 4. | | 2 — Complete | Localize Browser Access, Diagnostics, About, external-browser confirmations and Capture; retain the window when recording save fails during close/quit. | Medium | Sensitive clipboard feedback, fixed authorization actions, escaped diagnostic fields, literal event JSON, typed Capture state, folder settings and combined save-failure plus close/quit coverage verified. | | 3 — Complete | Localize setup, Core source selection/recovery, startup error wrappers and per-mode environment cleanup confirmations. | Medium to large | Both languages work before Core is available. Cancellation waits for owned installers; stale confirmations do nothing; source switching, restart/session closure, retained files and recovery moves remain explicit. Installer-wide dialogs remain in order 3b. | -| 3b | Review and localize remaining shell notices: port selection, Files download feedback and installer-wide summary/error dialogs. | Small to medium | Preserve structured port outcomes, download status and recovery counts. Settle the installer-wide locale when Windows/WSL preferences differ; do not infer a new shared preference from per-mode setup. | +| 3b — Complete | Localize port selection, Files download feedback, installer-wide summary/error dialogs and native paste confirmation. | Small to medium | Preserve structured outcomes/counts, numeric actions and clipboard guards. Installer-wide notices use the common effective language of relevant modes, otherwise English; no shared preference is written. | | 4 | Complete Windows and macOS native acceptance and packaged asset checks. | Platform-dependent | Menus, native dialogs, narrow layouts, keyboard/ARIA labels, clipboard, setup and recovery are checked on each OS. Verify staged and packaged Desktop catalogs independently of the selected Core version. | -The next implementation is the remaining shell notices in order 3b. +The next stage is native Windows/macOS and packaged acceptance in order 4. +Raw diagnostic errors, errors before profile selection, legacy shortcut setup +and external installer UI are outside the current Desktop catalog coverage. The operator chose to retain the window and show the error and unfinished-file location when recording save fails during close/quit. Orders 1–3 remain separate reviewable changes; native OS acceptance remains order 4. @@ -68,11 +70,11 @@ Keep these implementation boundaries: [desktop_ui_copy_review.tsv](desktop_ui_copy_review.tsv) is a prioritized seed inventory, not a claim that every Desktop string has been extracted. It uses -the same nine columns as the browser table. After setup/Core source, -269 rows are `translation-reviewed` with English and Traditional Chinese text. +the same nine columns as the browser table. After the remaining shell notices, +301 rows are `translation-reviewed` with English and Traditional Chinese text. Four retired Capture/setup fragments or renamed messages are marked `remove`; -no seed rows remain `proposed`. This does not include every remaining shell -notice listed in order 3b. Some `current_en` cells are exact fragments or normalize +no seed rows remain `proposed`. This does not claim translation of raw errors or +the external installer UI. Some `current_en` cells are exact fragments or normalize dynamic values to named placeholders; `context` identifies these cases. Approve the English behavior and terminology before requesting translations of @@ -259,6 +261,52 @@ Setup/Core source completed on 2026-09-20: allowlist and CSP are unchanged. No native GUI, real dependency installation, installer build or packaged acceptance is claimed for this batch. +## Remaining shell notices review and evidence + +The batch adds 32 reviewed bilingual messages for port selection, download +results, installer-wide summaries/errors and the native context-paste dialog. +Port prompts use whole messages selected by typed reasons, preserving cancel, +use-once and remember decisions. Saving still follows host checks, authenticated +verification and consent. The saved-port warning now states only that the port +passed verification but its setting could not be saved; it does not promise that +later startup steps succeed. + +Installer-wide notices capture the common effective language of the relevant +profiles at entry: selected modes for preparation, Windows and WSL for uninstall. +Missing/invalid preferences retain the existing English fallback, and mixed +effective locales use English. No preference is written or migrated; per-mode +preparation and cleanup keep their own language. The operator selected this +policy: use the common setting, otherwise English, without a new preference. + +| Finding | Severity | Evidence | Critic remedy | Main response | Resolution | Validation | +| --- | --- | --- | --- | --- | --- | --- | +| Interrupted or pathless download result does not establish a connection failure | Medium | `floating-windows.cjs` reports state/path only; `main.cjs` requires a completed state and path | Use neutral outcome wording | Report unconfirmed completion and ask the operator to inspect the download destination before downloading again | Accept | Both languages cover completed/pathless/interrupted outcomes; reveal requires completed plus response 1; no automatic replay. | +| Retained count mixes per-item results and whole-mode retention | Medium | `installer.cjs` aggregation and `cleanupManagedVenvs` top-level retained result | Name the count as results, not environments | Preserve aggregation and explain the mixed units; count unknown modes separately | Accept | Mixed item/mode results and cleanup exceptions retain exact numeric totals. | +| Generic installer failure can occur after completed recovery moves or shortcut changes | Medium | Uninstall cleanup/report precede shortcut updates | Acknowledge partial changes without implying rollback | Explain that completed changes remain and recovery locations should be checked before retry | Accept | Injected shortcut failure after two confirmed moves retains original error and failure exit code. | +| Cleanup scope wording implies both environments were successfully checked | Low | Missing settings/interpreter can retain a mode before inventory | Describe the permitted scope only | State that cleanup is limited to Windows and the configured WSL distribution | Accept | Copy review; mode iteration and owned installer lifecycle unchanged. | +| Notification rejection can still abort startup despite a saved-port notice | Preexisting behavior boundary | `startWithPort` awaits `notify` | Avoid silently changing notification behavior during localization | Remove the future-continuation promise; defer any best-effort notification change to a separate behavior patch | Modify | Existing startup flow retained; verification and persistence-failure ordering tested. | +| Files is the source UI, not the local download destination | Low; focused second pass | Download completion reports a native path | Direct the operator to the destination | Applied in both languages | Accept | Final table/callback review. | +| Paste delivery can precede a rejected acknowledgment | Main-review addition after independent review | `context-paste.cjs` awaits `completeContextPaste` before generic catch | Avoid encouraging a second paste when the outcome is unknown | Use an unconfirmed-result notice and tell the operator to inspect the terminal | Accept | One clipboard read and one delivery attempt despite lost acknowledgment; both-language cancel/stale-target tests pass. | + +The independent two-round review of port/download/installer changes found no +remaining material correctness, authorization or lifetime issue. The final +context-paste inventory addition was reviewed by the main agent and covered by +the final suite; it was not a third independent review round. Existing clipboard +permission denial, focus/frame/navigation/target guards, silent canceled downloads, +typed action dispatch, owner watching and shortcut publication order remain. + +Order 3b completed on 2026-09-20: + +- All 165 Desktop unit tests passed under Electron's Node 24.20.0 runtime, + including actual main-process callbacks and the installer coordinator in VM + fixtures. Native dialogs and installer subprocesses are mocked. +- Seven catalog regression tests and both catalog freshness checks passed; + the catalog contains 301 reviewed messages and four retired rows. +- No renderer HTML/CSS/assets changed in this batch, so the earlier DOM results + remain separate evidence; no new browser or native GUI acceptance is claimed. +- Native dialog readability, Windows installer behavior and packaged acceptance + remain order 4. This batch does not build or publish a release. + ## Evidence and acceptance limits Browser Access/Diagnostics completed on 2026-09-20: @@ -307,7 +355,7 @@ inspection of Capture/setup/recovery does not imply their smoke suites ran in this review. The review table is checked using `build_ui_messages.build_catalog` for schema, -keys, placeholders and review gates. Only the 269 reviewed rows enter the +keys, placeholders and review gates. Only the 301 reviewed rows enter the Desktop runtime catalog; retired rows stay out of it. Windows/macOS native localization, installer lifecycle and packaged acceptance remain future work. This plan does not qualify or publish a release. From b8fae88396695f3f586d59526fe2107b962f7947 Mon Sep 17 00:00:00 2001 From: "ASKA C." Date: Sun, 20 Sep 2026 11:26:51 +0800 Subject: [PATCH 34/43] Verify Windows localization and installer payload ## Why Browser and mocked dialog tests cannot establish Windows font/layout or prove that a built installer contains every bootstrap helper. ## What changed - Add an isolated Windows Electron rendering probe for both Desktop locales, native menu labels, compact toolbar states and setup progress. - Let the setup fixture target an arbitrary build stage using an exact URL. - Compare all six packaged Python helpers against the matching stage. - Record the candidate installer and source hashes, automated acceptance and remaining native dialog, installer lifecycle and macOS limits. ## Testing Windows Node passes all 165 Desktop tests. Native localization captures pass for both locales. The fresh extracted installer payload passes inspection, and its Windows and WSL backends pass packaged smoke with isolated profiles. The installed StandTerm instance is not replaced; installation, upgrade and uninstall are not exercised. --- desktop/test/native-i18n-smoke.cjs | 167 ++++++++++++++++++++++++++++ desktop/test/package-inspect.cjs | 4 + desktop/test/setup-i18n-fixture.cjs | 3 +- docs/desktop_ui_acceptance.md | 80 +++++++++++++ docs/desktop_ui_review_plan.md | 9 +- 5 files changed, 259 insertions(+), 4 deletions(-) create mode 100644 desktop/test/native-i18n-smoke.cjs create mode 100644 docs/desktop_ui_acceptance.md diff --git a/desktop/test/native-i18n-smoke.cjs b/desktop/test/native-i18n-smoke.cjs new file mode 100644 index 0000000..eaa5af9 --- /dev/null +++ b/desktop/test/native-i18n-smoke.cjs @@ -0,0 +1,167 @@ +'use strict'; + +// Run with Windows Electron, not ELECTRON_RUN_AS_NODE. Only owned windows are captured. +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const vm = require('node:vm'); +const { createRequire } = require('node:module'); +const { pathToFileURL } = require('node:url'); +const { app, BrowserWindow, Menu, ipcMain, session } = require('electron'); + +const [stageArgument, evidenceArgument] = process.argv.slice(2); +assert.ok(stageArgument && evidenceArgument, 'Usage: electron native-i18n-smoke.cjs '); +assert.equal(process.platform, 'win32', 'This probe qualifies Windows rendering only'); +const stage = path.resolve(stageArgument); +const evidence = path.resolve(evidenceArgument); +fs.mkdirSync(evidence, { recursive: true }); +app.setPath('userData', path.join(evidence, 'profile')); +app.setPath('sessionData', path.join(evidence, 'session')); +app.setAppLogsPath(path.join(evidence, 'logs')); +const { create } = require(path.join(stage, 'i18n.js')); +const { createUiCommands, UI_ACTIONS } = require(path.join(stage, 'ui-commands.cjs')); +const results = { platform: process.platform, electron: process.versions.electron, + stage, checks: [], screenshots: [], limitations: [ + 'No Core, installer, clipboard, capture encoder, or user profile is exercised.', + 'Setup process events are fixtures; HTML and generated scripts are rendered by Windows Electron.', + 'Menu labels are checked through the native Menu API; OS dialog interaction is not qualified.', + ] }; +const windows = new Set(); +const errors = []; +let finished = false; +const timeout = setTimeout(() => finish(new Error('Native localization probe timed out')), 45000); + +function finish(error) { + if (finished) return; + finished = true; + clearTimeout(timeout); + if (error) results.error = error.stack || String(error); + results.passed = !error; + results.rendererErrors = errors; + for (const win of windows) if (!win.isDestroyed()) win.destroy(); + fs.writeFileSync(path.join(evidence, 'native-i18n-result.json'), JSON.stringify(results, null, 2) + '\n'); + process.stdout.write(JSON.stringify(results) + '\n'); + app.exit(error ? 1 : 0); +} + +function createWindow(width, height, preload) { + const partition = `native-i18n-${windows.size}-${Date.now()}`; + const isolated = session.fromPartition(partition); + const allowed = new Set(['toolbar.html', 'toolbar.css', 'toolbar.js', 'toolbar-preload.cjs', + 'messages.js', 'i18n.js', 'setup.html'].map(name => pathToFileURL(path.join(stage, name)).href)); + isolated.webRequest.onBeforeRequest((details, callback) => callback({ cancel: !allowed.has(details.url) })); + isolated.setPermissionRequestHandler((_contents, _permission, callback) => callback(false)); + isolated.setPermissionCheckHandler(() => false); + const win = new BrowserWindow({ width, height, useContentSize: true, show: false, + title: 'StandTerm localization acceptance probe', + webPreferences: { preload, session: isolated, sandbox: true, contextIsolation: true, + nodeIntegration: false, backgroundThrottling: false } }); + windows.add(win); + win.webContents.setWindowOpenHandler(() => ({ action: 'deny' })); + win.webContents.on('will-navigate', event => event.preventDefault()); + win.webContents.on('console-message', (_event, level, message) => { + if (level >= 3) errors.push(message); + }); + win.webContents.on('render-process-gone', (_event, detail) => errors.push(JSON.stringify(detail))); + return win; +} + +async function capture(win, name) { + await win.webContents.executeJavaScript('new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)))'); + const file = `${name}.png`; + fs.writeFileSync(path.join(evidence, file), (await win.webContents.capturePage()).toPNG()); + results.screenshots.push(file); +} + +async function setupSnapshots() { + // Reuse the existing process fixture against the staged setup module and catalog. + const filename = path.join(__dirname, 'setup-i18n-fixture.cjs'); + const nativeRequire = createRequire(filename); + let output = ''; + const context = { __dirname: path.join(stage, 'test'), Buffer, setTimeout, clearTimeout, + console, process: { stdout: { write: text => { output += text; } } }, + require: name => name === '../i18n.js' ? require(path.join(stage, 'i18n.js')) : nativeRequire(name) }; + await vm.runInNewContext(fs.readFileSync(filename, 'utf8'), context, { filename }); + assert.equal(context.process.exitCode, undefined, 'Setup fixture generation failed'); + return JSON.parse(output); +} + +async function run() { + await app.whenReady(); + Menu.setApplicationMenu(null); + const toolbarStates = new Map(); + ipcMain.handle('standterm-toolbar-action', (event, action) => { + assert.equal(action, 'ready', 'The probe never invokes terminal or clipboard actions'); + event.sender.send('standterm-toolbar-state', toolbarStates.get(event.sender.id)); + return true; + }); + for (const locale of ['en', 'zh-TW']) { + const { t } = create(locale); + const win = createWindow(640, 180, path.join(stage, 'toolbar-preload.cjs')); + toolbarStates.set(win.webContents.id, { locale, mac: false, state: 'idle' }); + await win.loadFile(path.join(stage, 'toolbar.html')); + win.show(); + const commands = createUiCommands(win, win.webContents, 'http://127.0.0.1:1', t); + const menu = Menu.buildFromTemplate(Object.keys(UI_ACTIONS).map(action => commands.item(action))); + for (const [action, key] of Object.entries(UI_ACTIONS)) { + assert.equal(menu.getMenuItemById(`ui-${action}`).label, t(key)); + } + results.checks.push({ locale, nativeMenuLabels: menu.items.map(item => item.label) }); + for (const state of ['idle', 'recording', 'paused']) { + const label = state === 'idle' ? '' : t(`desktop.capture.status_${state}`, { time: '00:04' }); + win.webContents.send('standterm-toolbar-state', { locale, mac: false, state, label }); + await capture(win, `toolbar-${locale}-${state}`); + const actual = await win.webContents.executeJavaScript(`(() => ({ + locale: document.documentElement.lang, + label: document.getElementById('recording-status').textContent, + pause: document.getElementById('pause').getAttribute('aria-label'), + csp: document.querySelector('meta[http-equiv="Content-Security-Policy"]').content, + buttons: [...document.querySelectorAll('button')].filter(button => !button.hidden && button.getBoundingClientRect().width) + .map(button => ({ id: button.id || button.dataset.menu, left: button.getBoundingClientRect().left, + right: button.getBoundingClientRect().right, viewport: innerWidth })) + }))()`); + assert.equal(actual.locale, locale); + assert.equal(actual.label, label); + assert.ok(actual.csp.includes("default-src 'none'")); + assert.ok(actual.buttons.every(button => button.left >= 0 && button.right <= button.viewport), JSON.stringify(actual)); + assert.equal(actual.pause, t(state === 'paused' ? 'desktop.toolbar.record_resume' : 'desktop.toolbar.record_pause')); + results.checks.push({ locale, toolbarState: state, ...actual }); + } + win.close(); + } + for (const snapshot of await setupSnapshots()) { + const win = createWindow(700, 500); + await win.loadFile(path.join(stage, 'setup.html')); + await win.webContents.executeJavaScript(snapshot.init); + win.show(); + const actual = await win.webContents.executeJavaScript(`(() => ({ + locale: document.documentElement.lang, title: document.title, + requirements: document.getElementById('requirements').textContent, + scope: document.getElementById('scope').textContent, + overflow: document.documentElement.scrollWidth > innerWidth || document.documentElement.scrollHeight > innerHeight, + injected: typeof window.injected !== 'undefined', elements: document.querySelectorAll('script,img,iframe,a').length + }))()`); + assert.equal(actual.locale, snapshot.locale); + assert.equal(actual.title, snapshot.title); + assert.equal(actual.requirements, snapshot.requirements); + assert.equal(actual.scope, snapshot.scope); + assert.equal(actual.overflow, false); + assert.equal(actual.injected, false); + assert.equal(actual.elements, 0); + for (const frame of snapshot.progress) { + await win.webContents.executeJavaScript(frame.script); + assert.equal(await win.webContents.executeJavaScript('document.getElementById("stage").textContent'), frame.expected); + } + await capture(win, `setup-${snapshot.locale}-${snapshot.mode}`); + await win.webContents.executeJavaScript(snapshot.cancel); + assert.equal(await win.webContents.executeJavaScript('document.getElementById("stage").textContent'), snapshot.canceling); + results.checks.push({ locale: snapshot.locale, setupMode: snapshot.mode, ...actual }); + win.close(); + } + assert.deepEqual(errors, []); + finish(); +} + +// Keep the probe alive between its separately owned windows. +app.on('window-all-closed', () => {}); +run().catch(finish); diff --git a/desktop/test/package-inspect.cjs b/desktop/test/package-inspect.cjs index f851103..8a6127d 100644 --- a/desktop/test/package-inspect.cjs +++ b/desktop/test/package-inspect.cjs @@ -17,6 +17,10 @@ const archive = path.join(resources, 'app.asar'); const manifest = JSON.parse(fs.readFileSync(path.join(resources, 'bundle', 'manifest.json'), 'utf8')); const stagedManifest = JSON.parse(fs.readFileSync(path.join(stage, 'bundle', 'manifest.json'), 'utf8')); assert.deepEqual(manifest, stagedManifest); +for (const file of ['bootstrap.py', 'windows_job.py', 'runtime.py', 'runtime_cleanup.py', 'core_manager.py', 'backend.py']) { + assert.deepEqual(fs.readFileSync(path.join(resources, 'bundle', file)), + fs.readFileSync(path.join(stage, 'bundle', file)), `Bootstrap helper mismatch: ${file}`); +} validateCoreFiles(path.join(resources, 'bundle', 'core'), Object.keys(manifest.files)); const hash = bytes => createHash('sha256').update(bytes).digest('hex'); for (const [file, expected] of Object.entries(manifest.files)) { diff --git a/desktop/test/setup-i18n-fixture.cjs b/desktop/test/setup-i18n-fixture.cjs index f3e0cc0..58ae5dc 100644 --- a/desktop/test/setup-i18n-fixture.cjs +++ b/desktop/test/setup-i18n-fixture.cjs @@ -6,6 +6,7 @@ const path = require('node:path'); const vm = require('node:vm'); const { EventEmitter } = require('node:events'); const { createRequire } = require('node:module'); +const { pathToFileURL } = require('node:url'); const { create } = require('../i18n.js'); const filename = path.join(__dirname, '..', 'setup.cjs'); @@ -30,7 +31,7 @@ async function snapshot(locale, mode) { this.webContents.setWindowOpenHandler = callback => assert.equal(callback().action, 'deny'); this.webContents.executeJavaScript = async script => { scripts.push(script); }; } - async loadURL(url) { assert.ok(url.endsWith('/desktop/setup.html')); } + async loadURL(url) { assert.equal(url, pathToFileURL(path.join(path.dirname(filename), 'setup.html')).href); } isDestroyed() { return !!this.destroyed; } destroy() { this.destroyed = true; this.emit('closed'); } setTitle(title) { titles.push(title); } diff --git a/docs/desktop_ui_acceptance.md b/docs/desktop_ui_acceptance.md new file mode 100644 index 0000000..508cefe --- /dev/null +++ b/docs/desktop_ui_acceptance.md @@ -0,0 +1,80 @@ +# Desktop localization candidate acceptance + +Date: 2026-09-20. This records an unsigned Windows x64 local evaluation +candidate, not a public release or completed Windows/macOS qualification. + +## Artifact and source identity + +| Field | Value | +| --- | --- | +| Desktop / Core | `0.5.2` / `2.13.1-dev` | +| Source repository | `/mnt/d/workspace/github/standterm` | +| Build source commit | `38861555f667f05a45ae499298d813a255349966` | +| Candidate directory | `desktop/dist/candidate-0.5.2-2.13.1-dev-3886155/` | +| Installer | `StandTerm-Desktop-0.5.2-2.13.1-dev-win32-x64-Setup.exe` | +| Installer bytes | `112385015` | +| Installer SHA-256 | `f31b8ee9cb1ab91af155e55eb6b48fdaf380c7cb87bf38749bd109040cf36979` | +| Exact build-source snapshot | `standterm-build-source-3886155.tar.gz` | +| Snapshot SHA-256 | `16a2fdfb818e6dce92a9a2be13b2a9bf8f7d51046b92ecdded9f1a5e33e4bba2` | +| Core bundle ID | `229a71a3026ea6c63c05d74d3ded05aed6b9886a4b9205987e25f7d96c3bdeb6` | +| Tools | Windows Node 22.14.0, Electron 44.2.0, electron-builder 26.15.3 | +| Host | Windows 10.0.26200 with WSL Ubuntu-24.04.1 | +| Authenticode | `NotSigned` | + +The directory also contains the full Git source archive, release identity, +build metadata, checksum list and selected validation evidence. All 145 staged +source inputs match the Git archive bytes; Git archive honors the repository's +line-ending attributes. The build-source snapshot retains those inputs, all 270 +tracked source files and the exact local build invocation. Neither archive +contains a venv, credentials or an operator profile. Acceptance-tool additions +are a later validation commit, not changes to the installer payload. + +## Completed checks + +| Check | Result and scope | +| --- | --- | +| Windows unit suite | 165 passed using Windows Node 22.14.0. | +| Native Windows localization rendering | English and Traditional Chinese passed with real Electron windows, staged HTML/CSP/preload and native Menu API labels. | +| Compact toolbar | All controls remain visible at 640 CSS px in idle, recording and paused states, in both languages. | +| Setup rendering | Windows, macOS and WSL text branches render on Windows in both languages at 700x500 CSS px; progress/cancellation updates and literal distribution text pass. This is not macOS OS qualification. | +| Visual evidence | Twelve captures of owned test windows retained; Traditional Chinese toolbar and setup captures visually inspected. No operator window was captured. | +| Installer payload | Extracted from the new NSIS installer; a fresh extraction passes ASAR/stage equality, 92 exact Core files, all six Python bootstrap helpers and release identity checks. | +| Packaged Windows backend | Passed authenticated terminal, sandbox/navigation, focus return, native UI actions, toolbar and isolated clipboard-routing smoke using the extracted bundled Core. | +| Packaged WSL backend | The same checks pass, including Files browse/download/transition. | +| Isolation | Separate temporary profiles, runtime and recovery paths. No installation or replacement of the running StandTerm instance. | + +Evidence is retained in `desktop/dist/desktop-i18n-3886155/`; selected logs, +screenshots and JSON results are copied under the candidate's `validation/`. +The application-smoke clipboard is mocked; real terminal I/O and window behavior +are exercised. The localization probe's setup events are fixtures rendered by +Windows Electron, not dependency installations. + +## Test-environment findings + +The first packaged Windows smoke used `tools/.venv_win`, which lacks Flask and +exited before backend startup. The successful run uses the existing complete +Windows `tools/.venv`; WSL uses `/tmp/standterm_wslvenv`. No dependencies were +installed and no product change was needed. + +Python creates bytecode in an executed extraction. Final package inspection +therefore uses a second untouched extraction; it does not relax the exact-file +allowlist to accept runtime-generated files. The inspector now explicitly checks +the six bundle-root Python helpers, closing a validation gap identified in the +independent packaging review. + +## Remaining qualification + +- Real install, upgrade and uninstall flows were not executed. Packaged smoke + bypasses first-run setup and uses prepared, isolated test environments. +- Native OS dialog interaction/readability, physical clipboard/IME and a real + recording-save failure remain manual acceptance items. +- macOS requires a native Mac build and acceptance run; no macOS artifact was + produced. Windows rendering of macOS setup text does not substitute for it. +- No signing, public release, tag, push or mirror synchronization was performed. + +To repeat the native rendering probe, run Windows Electron with +`desktop/test/native-i18n-smoke.cjs`, the Desktop stage directory and a separate +evidence directory. Leave `ELECTRON_RUN_AS_NODE` unset. The probe creates only +owned test windows and writes a structured result plus PNGs. Use +`desktop/test/package-inspect.cjs` against a fresh extracted resources directory +and its matching stage before running Core from that extraction. diff --git a/docs/desktop_ui_review_plan.md b/docs/desktop_ui_review_plan.md index bd9401d..a5da076 100644 --- a/docs/desktop_ui_review_plan.md +++ b/docs/desktop_ui_review_plan.md @@ -15,9 +15,11 @@ acceptance remain separate. Browser acceptance is recorded in | 2 — Complete | Localize Browser Access, Diagnostics, About, external-browser confirmations and Capture; retain the window when recording save fails during close/quit. | Medium | Sensitive clipboard feedback, fixed authorization actions, escaped diagnostic fields, literal event JSON, typed Capture state, folder settings and combined save-failure plus close/quit coverage verified. | | 3 — Complete | Localize setup, Core source selection/recovery, startup error wrappers and per-mode environment cleanup confirmations. | Medium to large | Both languages work before Core is available. Cancellation waits for owned installers; stale confirmations do nothing; source switching, restart/session closure, retained files and recovery moves remain explicit. Installer-wide dialogs remain in order 3b. | | 3b — Complete | Localize port selection, Files download feedback, installer-wide summary/error dialogs and native paste confirmation. | Small to medium | Preserve structured outcomes/counts, numeric actions and clipboard guards. Installer-wide notices use the common effective language of relevant modes, otherwise English; no shared preference is written. | -| 4 | Complete Windows and macOS native acceptance and packaged asset checks. | Platform-dependent | Menus, native dialogs, narrow layouts, keyboard/ARIA labels, clipboard, setup and recovery are checked on each OS. Verify staged and packaged Desktop catalogs independently of the selected Core version. | +| 4 — Partial; Windows candidate produced | Complete Windows and macOS native acceptance and packaged asset checks. | Platform-dependent | Windows native rendering, 165 Windows unit tests, exact extracted-installer payload and packaged Windows/WSL smoke passed. Native OS dialogs, install/upgrade/uninstall and macOS remain; see the acceptance report. | -The next stage is native Windows/macOS and packaged acceptance in order 4. +Order 4 produced a Windows evaluation candidate; results, source/artifact hashes +and remaining native acceptance are recorded in +[desktop_ui_acceptance.md](desktop_ui_acceptance.md). Raw diagnostic errors, errors before profile selection, legacy shortcut setup and external installer UI are outside the current Desktop catalog coverage. The operator chose to retain the window and show the error and unfinished-file @@ -357,5 +359,6 @@ this review. The review table is checked using `build_ui_messages.build_catalog` for schema, keys, placeholders and review gates. Only the 301 reviewed rows enter the Desktop runtime catalog; retired rows stay out of it. -Windows/macOS native localization, installer lifecycle and packaged acceptance +Windows automated native rendering and packaged acceptance are recorded in the +acceptance report. Native dialog/installer lifecycle and macOS qualification remain future work. This plan does not qualify or publish a release. From 98e490d4b21fef8f276a16b4420c2f070014226b Mon Sep 17 00:00:00 2001 From: "ASKA C." Date: Mon, 21 Sep 2026 00:31:47 +0800 Subject: [PATCH 35/43] Simplify Agent connection and restore Desktop copy --- desktop/README.md | 3 + desktop/context-paste.cjs | 11 +++- desktop/test/context-paste.test.cjs | 38 ++++++++++- docs/desktop_ui_review_plan.md | 40 +++++++++++- docs/ui_copy_review.tsv | 2 + static/js/standterm-messages.js | 4 ++ templates/index.html | 99 ++++++++++++++++++----------- tests/agent_browser_smoke.py | 53 +++++++++++---- 8 files changed, 193 insertions(+), 57 deletions(-) diff --git a/desktop/README.md b/desktop/README.md index d159adc..5269824 100644 --- a/desktop/README.md +++ b/desktop/README.md @@ -588,6 +588,9 @@ places Copy/Paste after the title in the window's Desktop toolbar. Menu labels a not selectable; terminal text, text fields and status notices remain selectable. **Copy selected text** uses native Copy, never the terminal Ctrl+C interrupt. +Core's copy buttons may write sanitized clipboard content only from the focused, +visible main Core page at the owned backend origin. This does not grant clipboard +reads, child-frame access, or access to other windows sharing the session. **Paste clipboard text** restores the Core editing target and uses native Paste; text fields keep normal editing behavior. Windows/Linux Ctrl+V remains the terminal control code; use Ctrl+Shift+V for keyboard paste (Cmd+V on macOS). diff --git a/desktop/context-paste.cjs b/desktop/context-paste.cjs index c3ed274..4573e27 100644 --- a/desktop/context-paste.cjs +++ b/desktop/context-paste.cjs @@ -13,7 +13,16 @@ function installContextPaste(win, contents, origin, notify, t = create('en').t) const available = () => !win.isDestroyed() && !contents.isDestroyed() && !contents.isLoadingMainFrame() && allowedNavigation(contents.getURL(), origin) && win.isVisible() && !win.isMinimized() && BrowserWindow.getFocusedWindow() === win; + const canWrite = (requester, permission, details) => permission === 'clipboard-sanitized-write' + && requester === contents && details?.isMainFrame === true + && allowedNavigation(details.requestingUrl, origin) && available(); + contents.session.setPermissionCheckHandler((requester, permission, requestingOrigin, details) => + allowedNavigation(requestingOrigin, origin) && canWrite(requester, permission, details)); contents.session.setPermissionRequestHandler((requester, permission, callback, details) => { + if (canWrite(requester, permission, details)) { + callback(true); + return; + } if (permission !== 'clipboard-read' || requester !== contents || details?.isMainFrame !== true || !allowedNavigation(details.requestingUrl, origin) || pending || !available()) { callback(false); @@ -23,7 +32,7 @@ function installContextPaste(win, contents, origin, notify, t = create('en').t) const epoch = navigation; const frame = contents.mainFrame; const current = () => epoch === navigation && !frame.isDestroyed() && contents.mainFrame === frame && available(); - // Never grant web clipboard access. Only the native confirmation authorizes + // Never grant web clipboard reads. Only the native confirmation authorizes // a single text read, delivered to the captured Core paste-review target. void (async () => { const id = await frame.executeJavaScript('window.standtermUi?.contextPasteRequest()'); diff --git a/desktop/test/context-paste.test.cjs b/desktop/test/context-paste.test.cjs index 313b56a..a3b4e92 100644 --- a/desktop/test/context-paste.test.cjs +++ b/desktop/test/context-paste.test.cjs @@ -17,7 +17,7 @@ function fixture(locale = 'en') { const state = { focused: true, visible: true, minimized: false, destroyed: false, loading: false, url: origin + '/', target: 'fixture-request', prompts: 0, reads: 0, delivered: true }; const win = { isDestroyed: () => state.destroyed, isVisible: () => state.visible, isMinimized: () => state.minimized }; - let handler; + let handler, checkHandler; const contents = new EventEmitter(); const frame = { isDestroyed: () => state.destroyed, executeJavaScript: async code => { scripts.push(code); @@ -26,7 +26,10 @@ function fixture(locale = 'en') { return state.target; } }; Object.assign(contents, { mainFrame: frame, isDestroyed: () => state.destroyed, getURL: () => state.url, - isLoadingMainFrame: () => state.loading, session: { setPermissionRequestHandler: fn => { handler = fn; } } }); + isLoadingMainFrame: () => state.loading, session: { + setPermissionRequestHandler: fn => { handler = fn; }, + setPermissionCheckHandler: fn => { checkHandler = fn; }, + } }); const electron = { BrowserWindow: { getFocusedWindow: () => state.focused ? win : null }, dialog: { showMessageBox: (_owner, options) => { state.prompts++; state.dialog = options; return consent.promise; } }, clipboard: { readText: () => { state.reads++; return read.promise; } } }; @@ -36,12 +39,43 @@ function fixture(locale = 'en') { }); api.exports.installContextPaste(win, contents, origin, async value => notices.push(value), create(locale).t); return { state, consent, read, contents, scripts, callbacks, notices, + check: (permission = 'clipboard-sanitized-write', requester = contents, requestingOrigin = origin, + details = { isMainFrame: true, requestingUrl: state.url }) => checkHandler(requester, permission, requestingOrigin, details), request: (permission = 'clipboard-read', requester = contents, details = { isMainFrame: true, requestingUrl: state.url }) => new Promise(resolve => handler(requester, permission, value => { callbacks.push(value); resolve(value); }, details)), navigate: () => contents.emit('did-start-navigation', {}, origin + '/', false, true), }; } +test('focused Core may copy without gaining clipboard read permission', async () => { + const f = fixture(); + assert.equal(f.check(), true); + assert.equal(await f.request('clipboard-sanitized-write'), true); + assert.equal(f.check('clipboard-read'), false); + assert.equal(f.check('deprecated-sync-clipboard-read'), false); + assert.equal(f.state.prompts + f.state.reads, 0); +}); + +test('copy permission rejects foreign, child, loading and inactive contents', async () => { + for (const change of [s => { s.focused = false; }, s => { s.visible = false; }, + s => { s.minimized = true; }, s => { s.destroyed = true; }, s => { s.loading = true; }, + s => { s.url = 'https://example.com/'; }]) { + const f = fixture(); change(f.state); + assert.equal(f.check(), false); + assert.equal(await f.request('clipboard-sanitized-write'), false); + } + const f = fixture(); + assert.equal(f.check('clipboard-sanitized-write', {}), false); + assert.equal(f.check('clipboard-sanitized-write', f.contents, 'https://example.com'), false); + for (const details of [undefined, { isMainFrame: false, requestingUrl: origin }, + { isMainFrame: true, requestingUrl: 'https://example.com' }]) { + const invalid = details || {}; + assert.equal(f.check('clipboard-sanitized-write', f.contents, origin, invalid), false); + assert.equal(await f.request('clipboard-sanitized-write', f.contents, invalid), false); + } + assert.equal(f.state.prompts + f.state.reads, 0); +}); + test('one explicit native confirmation reads text once without granting renderer permission', async () => { const f = fixture(); const result = f.request(); diff --git a/docs/desktop_ui_review_plan.md b/docs/desktop_ui_review_plan.md index a5da076..461259d 100644 --- a/docs/desktop_ui_review_plan.md +++ b/docs/desktop_ui_review_plan.md @@ -26,6 +26,41 @@ The operator chose to retain the window and show the error and unfinished-file location when recording save fails during close/quit. Orders 1–3 remain separate reviewable changes; native OS acceptance remains order 4. +## Follow-up from candidate feedback + +The candidate exposed a copy regression: the Desktop permission handlers denied +`clipboard-sanitized-write` along with reads, so Core's Agent connection copy +buttons always reported failure. The source fix permits sanitized writes only +for the owned, focused, visible Core main frame at its backend origin. Clipboard +reads retain their native confirmation and target guards. The existing candidate +installer does not contain this fix until rebuilt. + +Validation: all 167 Desktop unit tests pass, including foreground write access +and foreign-frame/background denial. A separate Windows Electron 44.2.0 window +reports clipboard-write permission changing from `denied` to `granted` with the +fix while clipboard-read stays `denied`. This probe queries permissions only; +it does not read or replace the operator's clipboard and is not OS copy/paste +acceptance. The attempted Linux headless runtime probe exited before producing +results; Windows provided the native permission evidence. + +The operator requests a single language setting and asks about live switching. +The following is the proposed next implementation phase, not completed behavior: + +| Order | Change | Effort | Required acceptance | +| --- | --- | --- | --- | +| 1 | Apply the saved Core language without reloading the page. Replace the fixed translator with a current-locale lookup and refresh labels from structured state. | Medium | Switch both ways with connected terminals, an active Agent grant, Files and floating windows; preserve input, selection, drafts and pending operations. | +| 2 | Make Desktop follow the current Core page's language and remove the separate Desktop language menu. | Medium | Validate the exact owned sender/frame/origin and the two supported locale values; rebuild menus and refresh toolbar, tray and diagnostics without restarting Core or recording. | +| 3 | Keep `language.json` as the last synchronized language for startup/setup/recovery before Core is available. | Small | Retain the cached value for older Core versions lacking synchronization; use English when no valid cache exists. Preserve the agreed installer common-language/English fallback. | + +Core's setting remains scoped to its browser profile; this does not synchronize +unrelated browsers, hosts or backend modes. A browser connection does not acquire +authority to change another Desktop instance. Already-open native confirmations +keep their language and button meanings until dismissed; subsequent dialogs use +the new language. Existing terminal output and raw diagnostic messages are not +translated retroactively. Dynamic UI must render from state/message keys rather +than infer state by matching displayed text. Settings import/reset must use the +same language application path as Settings Save. + ## Difficulty and design choices Copy extraction is straightforward. Most work lies in several display contexts: @@ -34,14 +69,15 @@ scriptless diagnostics. A complete Desktop rollout has moderate implementation cost and broader acceptance cost than the toolbar pilot. The estimates above are relative scope assessments, not measured delivery times. -The pilot stores the Desktop language in `language.json` under the existing profile's `userData`, +The shipped pilot stores the Desktop language in `language.json` under the existing profile's `userData`, with English as default and only `en` / `zh-TW` initially. Apply a change on the next launch; changing language should not itself restart StandTerm or stop a recording. Keep the existing Core browser preference independent. This covers setup and recovery before Core starts, at the cost of two language preferences. It is a product choice, not a security requirement. A validated two-value advisory preference from Core is also feasible, but needs startup, -origin and older-Core fallback rules. Automatic OS-language selection is deferred. +origin and older-Core fallback rules. The follow-up above supersedes the independent +preference design once implemented. Automatic OS-language selection is deferred. Ship the Desktop catalog with the shell. Do not depend on the selected bundled or Git Core supplying compatible renderer scripts. Reuse the existing TSV diff --git a/docs/ui_copy_review.tsv b/docs/ui_copy_review.tsv index 454789f..76ee4d5 100644 --- a/docs/ui_copy_review.tsv +++ b/docs/ui_copy_review.tsv @@ -66,6 +66,8 @@ agent.token.tab_expired Agent token expired; create a new token to continue Agen agent.pause.action Pause Agent Pause Agent 暫停 Agent Pause Agent access; do not close or disconnect the terminal. Plain text. Preserve named values, command names, and agreed Agent terminology. translation-reviewed templates/index.html agent.pause.target Pause Agent access for {target} Pause Agent access for {target} 暫停 {target} 的 Agent 存取 Pause action tooltip naming its target. {target} Plain text. Preserve named values, command names, and agreed Agent terminology. translation-reviewed templates/index.html agent.connection.tooltip Copy the agent prompt for the current tab's environment Copy the agent prompt for the current tab's environment 複製目前分頁所在環境的 Agent 連線指引 The active tab selects the execution environment, not the grant scope. Plain text. Preserve named values, command names, and agreed Agent terminology. translation-reviewed templates/index.html +agent.connection.summary Copy the connection instructions and give them to the agent in the current tab’s environment. Copy the connection instructions and give them to the agent in the current tab’s environment. 複製連線指引,貼給目前分頁環境中的 Agent。 Compact local or SSH connection introduction; the adjacent button copies the full prompt, including the URL and execution environment. Copying does not authorize tabs. Full instructions remain in Read more. translation-reviewed templates/index.html +agent.connection.read_more Read more Read more 閱讀更多 Native disclosure for connection instructions, activity and secondary actions. Keep keyboard-accessible expand and collapse behavior. translation-reviewed templates/index.html agent.connection.environment Run the agent where Core runs (WSL if Core runs in WSL). It can access only individually authorized tabs. Run the agent where Core runs (WSL if Core runs in WSL). It can access only individually authorized tabs. 請在 Core 所在環境執行 Agent(若 Core 在 WSL 中執行,Agent 也須在 WSL 中執行)。Agent 僅能存取已個別授權的分頁。 Core-host connection info; distinguish Windows browser from WSL Core. Plain text. Preserve named values, command names, and agreed Agent terminology. translation-reviewed templates/index.html agent.connection.step_authorize Choose Authorize agent on each intended tab. Change its permission in Settings or the Agent panel. Choose the default permission in Settings, then Authorize agent on each intended tab. Adjust an authorized tab in the Agent panel. 先在 Settings 選擇預設權限,再於各個要開放存取的分頁選擇「授權 Agent」。已授權分頁的權限可在 Agent 面板調整。 Local connection setup step; the primary action creates the token. Plain text. Preserve named values, command names, and agreed Agent terminology. translation-reviewed templates/index.html agent.connection.step_copy Copy the prompt to the agent running on the Core host. Copy the prompt to the agent running on the Core host. 將連線指引複製給在 Core 主機上執行的 Agent。 Give the prompt to the correct runtime environment. Plain text. Preserve named values, command names, and agreed Agent terminology. translation-reviewed templates/index.html diff --git a/static/js/standterm-messages.js b/static/js/standterm-messages.js index dd2603d..e762d48 100644 --- a/static/js/standterm-messages.js +++ b/static/js/standterm-messages.js @@ -30,11 +30,13 @@ "agent.connection.loading": "Loading connection info\u2026", "agent.connection.no_grants": "No active grants. Authorize the intended tabs first.", "agent.connection.prompt": "Agent connection prompt", + "agent.connection.read_more": "Read more", "agent.connection.ready": "Copy Prompt to the agent running on the Core host. Its first authenticated request confirms access.", "agent.connection.request_at": "last authenticated request {time}", "agent.connection.step_authorize": "Choose the default permission in Settings, then Authorize agent on each intended tab. Adjust an authorized tab in the Agent panel.", "agent.connection.step_confirm": "Run discover, then hello. Authenticated requests appear below.", "agent.connection.step_copy": "Copy the prompt to the agent running on the Core host.", + "agent.connection.summary": "Copy the connection instructions and give them to the agent in the current tab\u2019s environment.", "agent.connection.title": "Agent connection", "agent.connection.tooltip": "Copy the agent prompt for the current tab's environment", "agent.connection.unavailable": "Connection info unavailable.", @@ -828,11 +830,13 @@ "agent.connection.loading": "\u6b63\u5728\u8f09\u5165\u9023\u7dda\u8cc7\u8a0a\u2026", "agent.connection.no_grants": "\u76ee\u524d\u6c92\u6709\u6709\u6548\u6388\u6b0a\u3002\u8acb\u5148\u6388\u6b0a\u8981\u958b\u653e\u5b58\u53d6\u7684\u5206\u9801\u3002", "agent.connection.prompt": "Agent \u9023\u7dda\u6307\u5f15", + "agent.connection.read_more": "\u95b1\u8b80\u66f4\u591a", "agent.connection.ready": "\u6309\u300c\u8907\u88fd\u9023\u7dda\u6307\u5f15\u300d\uff0c\u518d\u8cbc\u7d66\u5728 Core \u4e3b\u6a5f\u4e0a\u57f7\u884c\u7684 Agent\u3002Agent \u9996\u6b21\u901a\u904e\u9a57\u8b49\u7684\u8acb\u6c42\u53ef\u78ba\u8a8d\u5176\u5b58\u53d6\u6b0a\u3002", "agent.connection.request_at": "\u6700\u8fd1\u4e00\u6b21\u901a\u904e\u9a57\u8b49\u7684\u8acb\u6c42\uff1a{time}", "agent.connection.step_authorize": "\u5148\u5728 Settings \u9078\u64c7\u9810\u8a2d\u6b0a\u9650\uff0c\u518d\u65bc\u5404\u500b\u8981\u958b\u653e\u5b58\u53d6\u7684\u5206\u9801\u9078\u64c7\u300c\u6388\u6b0a Agent\u300d\u3002\u5df2\u6388\u6b0a\u5206\u9801\u7684\u6b0a\u9650\u53ef\u5728 Agent \u9762\u677f\u8abf\u6574\u3002", "agent.connection.step_confirm": "\u5148\u57f7\u884c discover\uff0c\u518d\u57f7\u884c hello\u3002\u5df2\u901a\u904e\u9a57\u8b49\u7684\u8acb\u6c42\u6703\u986f\u793a\u5728\u4e0b\u65b9\u3002", "agent.connection.step_copy": "\u5c07\u9023\u7dda\u6307\u5f15\u8907\u88fd\u7d66\u5728 Core \u4e3b\u6a5f\u4e0a\u57f7\u884c\u7684 Agent\u3002", + "agent.connection.summary": "\u8907\u88fd\u9023\u7dda\u6307\u5f15\uff0c\u8cbc\u7d66\u76ee\u524d\u5206\u9801\u74b0\u5883\u4e2d\u7684 Agent\u3002", "agent.connection.title": "Agent \u9023\u7dda", "agent.connection.tooltip": "\u8907\u88fd\u76ee\u524d\u5206\u9801\u6240\u5728\u74b0\u5883\u7684 Agent \u9023\u7dda\u6307\u5f15", "agent.connection.unavailable": "\u7121\u6cd5\u53d6\u5f97\u9023\u7dda\u8cc7\u8a0a\u3002", diff --git a/templates/index.html b/templates/index.html index 3b8d9e0..fb8076c 100644 --- a/templates/index.html +++ b/templates/index.html @@ -304,6 +304,7 @@ .agent-connect-url input { flex: 1; min-width: 220px; background: #111; color: #ddd; padding: 8px; border: 1px solid #555; } .agent-connect-activity { white-space: pre-wrap; } .agent-connect-steps { padding-left: 24px; line-height: 1.5; } + #agent-connect-details summary, #agent-tunnel-details summary { cursor: pointer; margin: 12px 0; } #agent-tunnel-message { white-space: pre-wrap; } #ssh-tunnels-dialog { width: min(720px, calc(100vw - 32px)); box-sizing: border-box; max-height: 85vh; overflow: auto; background: #222; color: #ddd; border: 1px solid #555; border-radius: 8px; } @@ -1480,61 +1481,72 @@

Manual brow

Agent connection

-

Run the agent where Core runs (WSL if Core runs in WSL). It can access only individually authorized tabs.

-
    -
  1. Choose the default permission in Settings, then Authorize agent on each intended tab. Adjust an authorized tab in the Agent panel.
  2. -
  3. Copy the prompt to the agent running on the Core host.
  4. -
  5. Run discover, then hello. Authenticated requests appear below.
  6. -
+

Copy the connection instructions and give them to the agent in the current tab’s environment.

- +

-

- +
+ Read more +

Run the agent where Core runs (WSL if Core runs in WSL). It can access only individually authorized tabs.

+
    +
  1. Choose the default permission in Settings, then Authorize agent on each intended tab. Adjust an authorized tab in the Agent panel.
  2. +
  3. Copy the prompt to the agent running on the Core host.
  4. +
  5. Run discover, then hello. Authenticated requests appear below.
  6. +
+

+ +
+ + + +
+
- - -

Agent Tunnel

-

-
-
    -
  1. In each intended tab’s Agent panel, enable access and choose its permission.
  2. -
  3. Click Start / Renew Access to start or update this tunnel’s access. Valid tokens keep their expiry; separate local tokens are not required.
  4. -
  5. Once ready, click Copy Prompt and paste it to the agent on this SSH host.
  6. -
-

A new tunnel copies the current helpers and skills to a private temporary directory on this SSH host. Requires Python 3.9+, SFTP, remote forwarding and listener inspection; checks support Linux, FreeBSD and macOS.

-

Refresh Status updates this list; it does not check the tunnel.

-
-
-

This tunnel follows this page’s tab permissions, including tabs enabled later. Close keeps it running. Stop revokes this tunnel’s access. It stops when its SSH connection closes or this page disconnects, including a reload.

-

Check Tunnel verifies the remote loopback listener, helpers, and connection to this Core instance. Failure stops this tunnel and revokes its access.

-

+ +

+
+ +

+
+
    +
  1. In each intended tab’s Agent panel, enable access and choose its permission.
  2. +
  3. Click Start / Renew Access to start or update this tunnel’s access. Valid tokens keep their expiry; separate local tokens are not required.
  4. +
  5. Once ready, click Copy Prompt and paste it to the agent on this SSH host.
  6. +
+

A new tunnel copies the current helpers and skills to a private temporary directory on this SSH host. Requires Python 3.9+, SFTP, remote forwarding and listener inspection; checks support Linux, FreeBSD and macOS.

+

Refresh Status updates this list; it does not check the tunnel.

+
+
+

This tunnel follows this page’s tab permissions, including tabs enabled later. Close keeps it running. Stop revokes this tunnel’s access. It stops when its SSH connection closes or this page disconnects, including a reload.

+

Check Tunnel verifies the remote loopback listener, helpers, and connection to this Core instance. Failure stops this tunnel and revokes its access.

-

- + +
+ + + + + + +
+
- - - - - -
@@ -2910,6 +2922,7 @@

Restore StandTerm access

const agentConnectDialog = document.getElementById('agent-connect-dialog'); const agentConnectInfo = document.getElementById('agent-connect-info'); const agentConnectMessage = document.getElementById('agent-connect-message'); + let agentTunnelInfoView = false; let agentTunnelCarrier = null; let agentTunnelRequest = 0; let agentTunnelId = null; @@ -2931,6 +2944,7 @@

Restore StandTerm access

element.classList.toggle('agent-copy-warning', copyFailed); element.setAttribute('role', copyFailed ? 'alert' : 'status'); element.textContent = message; + element.hidden = !message; } async function copyAgentConnectionField(fieldId, messageElement) { @@ -2940,6 +2954,8 @@

Restore StandTerm access

await navigator.clipboard.writeText(field.value); setAgentConnectionMessage(messageElement, t('agent.connection.copied')); } catch (_error) { + const details = field.closest('details'); + if (details) details.open = true; field.focus(); field.select(); const shortcut = isApplePlatform() ? 'Command+C' : 'Ctrl+C'; @@ -2973,7 +2989,7 @@

Restore StandTerm access

agentConnectActivity = available ? result.terminals || [] : []; renderAgentConnectionActivity('agent-connect-activity', agentConnectActivity); setAgentConnectionMessage(agentConnectMessage, available - ? t('agent.connection.ready') + ? '' : result?.message || t('agent.connection.unavailable')); }); } @@ -2986,6 +3002,7 @@

Restore StandTerm access

return; } agentConnectInfo.hidden = true; + document.getElementById('agent-connect-details').open = false; agentConnectDialog.showModal(); requestAgentConnectInfo(); }); @@ -3179,6 +3196,10 @@

Restore StandTerm access

}); function setAgentTunnelInfoView(infoView) { + agentTunnelInfoView = infoView; + document.getElementById('agent-tunnel-introduction').hidden = !infoView; + document.getElementById('agent-tunnel-details-summary').hidden = !infoView; + document.getElementById('agent-tunnel-details').open = !infoView; document.getElementById('agent-tunnel-title').textContent = t(infoView ? 'agent.connection.title' : 'agent.tunnel.title'); document.getElementById('agent-tunnel-setup').hidden = infoView; document.getElementById('agent-tunnel-apply').hidden = infoView; @@ -3214,6 +3235,7 @@

Restore StandTerm access

agentTunnelInfo.hidden = true; document.getElementById('agent-tunnel-connection').hidden = true; document.getElementById('agent-tunnel-url').value = ''; + document.getElementById('agent-tunnel-copy-url').disabled = true; document.getElementById('agent-tunnel-check').disabled = true; document.getElementById('agent-tunnel-stop').disabled = true; document.getElementById('agent-tunnel-copy').hidden = true; @@ -3246,7 +3268,7 @@

Restore StandTerm access

readyAgentTunnels.delete(agentTunnelCarrier); } updateAgentTunnelButtons(); - setAgentConnectionMessage(agentTunnelMessage, ready ? t('agent.tunnel.ready') + setAgentConnectionMessage(agentTunnelMessage, ready ? (agentTunnelInfoView ? '' : t('agent.tunnel.ready')) : result && result.status === 'stopped' ? t(result.cleanup_pending ? 'agent.tunnel.cleanup_pending' : 'agent.tunnel.stopped') : (result && result.message) || t('agent.tunnel.setup_failed')); @@ -3256,6 +3278,7 @@

Restore StandTerm access

agentTunnelActivity = ready ? result.terminals || [] : []; document.getElementById('agent-tunnel-connection').hidden = !ready; document.getElementById('agent-tunnel-url').value = ready ? result.agentinfo_url : ''; + document.getElementById('agent-tunnel-copy-url').disabled = !ready; document.getElementById('agent-tunnel-check').disabled = !ready; document.getElementById('agent-tunnel-verification').textContent = ready ? t('agent.tunnel.verified_at', { time: new Date(result.verified_at * 1000).toLocaleString(uiText.locale) }) : ''; diff --git a/tests/agent_browser_smoke.py b/tests/agent_browser_smoke.py index 9d7bf29..a179866 100644 --- a/tests/agent_browser_smoke.py +++ b/tests/agent_browser_smoke.py @@ -895,7 +895,7 @@ def test_agent_language_preview_preserves_access_and_applies_on_next_page(browse check(page.locator('#agent-connect-info').get_attribute('aria-label') == 'Agent 連線指引', 'connection prompt accessible name was not localized') check(page.inner_text('#agent-connect-copy') == '複製連線指引', 'connection copy action was not localized') - check('main: 等待 Agent' in page.inner_text('#agent-connect-activity'), + check('main: 等待 Agent' in page.text_content('#agent-connect-activity'), 'localized connection activity did not distinguish a grant from Agent activity') prompt = page.input_value('#agent-connect-info') check('Run discover, then hello' in prompt, 'display language translated the machine-facing connection prompt') @@ -4486,8 +4486,9 @@ def test_terminal_payload_text_is_not_control(browser, access_url): close_context(context) -def test_core_agent_connect_info_can_be_copied_and_confirmed(browser, access_url): - context, page = new_page(browser, access_url) +def test_core_agent_connect_info_can_be_copied_and_confirmed(browser, access_url, ui_language='en'): + context, page = new_page(browser, access_url, ui_language) + zh = ui_language == 'zh-TW' try: parsed = urllib.parse.urlparse(access_url) agentinfo_url = urllib.parse.urlunparse(parsed._replace( @@ -4497,21 +4498,30 @@ def test_core_agent_connect_info_can_be_copied_and_confirmed(browser, access_url })''') page.click('#agent-connect-btn') page.wait_for_selector('#agent-connect-copy:not([disabled])') + check(not page.locator('#agent-connect-details').evaluate('element => element.open'), + 'Connection details were not collapsed by default') + for selector in ['#agent-connect-info', '#agent-connect-activity', '#agent-connect-copy-url', + '#agent-connect-refresh', '#agent-connect-open-panel']: + check(page.locator(selector).is_hidden(), 'Secondary connection content remained visible: ' + selector) + check(page.locator('#agent-connect-message').is_hidden(), 'Ready state repeated the introduction') check(page.input_value('#agent-connect-url') == agentinfo_url, 'Core did not expose its Agent Info URL') info_text = page.input_value('#agent-connect-info') check('Core host environment' in info_text and 'Run discover, then hello' in info_text, 'Connect Info did not explain where and how to confirm access') check('--token' not in info_text and 'agt_' not in info_text, 'Connect Info exposed a token') check(page.locator('.terminal-tab.agent-token-active').count() == 0, 'Reading Connect Info minted a token') - check('No active grants' in page.inner_text('#agent-connect-activity'), 'Missing authorization was not explained') + check(('目前沒有有效授權' if zh else 'No active grants') in page.text_content('#agent-connect-activity'), 'Missing authorization was not explained') check(page.locator('#agent-tunnel-btn').is_hidden(), 'Local shell offered an SSH tunnel') check(page.locator('#agent-remote-info-btn').count() == 0, 'A separate remote Agent Info button remains') - check(page.inner_text('#agent-connect-btn') == 'Agent connection', 'Info button did not identify the Agent connection workflow') - check(page.inner_text('#agent-connect-copy') == 'Copy Prompt', 'Local info did not offer a prompt') - page.click('#agent-connect-copy-url') - page.wait_for_function('url => window.copiedAgentText === url', arg=agentinfo_url) + check(page.inner_text('#agent-connect-btn') == ('Agent 連線' if zh else 'Agent connection'), 'Info button did not identify the Agent connection workflow') + check(page.inner_text('#agent-connect-copy') == ('複製連線指引' if zh else 'Copy Prompt'), 'Local info did not offer a prompt') page.click('#agent-connect-copy') page.wait_for_function('text => window.copiedAgentText === text', arg=info_text) + page.locator('#agent-connect-details summary').focus() + page.keyboard.press('Enter') + check(page.locator('#agent-connect-info').is_visible(), 'Keyboard disclosure did not reveal the prompt') + page.click('#agent-connect-copy-url') + page.wait_for_function('url => window.copiedAgentText === url', arg=agentinfo_url) page.focus('#agent-connect-url') page.evaluate("() => { window.dispatchEvent(new Event('blur')); window.dispatchEvent(new Event('focus')); }") page.wait_for_timeout(150) @@ -4524,7 +4534,11 @@ def test_core_agent_connect_info_can_be_copied_and_confirmed(browser, access_url page.click('#agent-external-token-btn') page.wait_for_selector('.terminal-tab.agent-token-active', state='attached') page.click('#agent-connect-btn') - page.wait_for_function("() => document.getElementById('agent-connect-activity').innerText.includes('main: waiting for agent')") + check(not page.locator('#agent-connect-details').evaluate('element => element.open'), + 'Reopening connection details retained the expanded view') + page.click('#agent-connect-details summary') + page.wait_for_function("text => document.getElementById('agent-connect-activity').innerText.includes(text)", + arg='main: ' + ('等待 Agent' if zh else 'waiting for agent')) with urllib.request.urlopen(agentinfo_url, timeout=5) as response: info = json.load(response) hello = subprocess.run([ @@ -4532,12 +4546,17 @@ def test_core_agent_connect_info_can_be_copied_and_confirmed(browser, access_url '--terminal', 'main', 'hello', ], capture_output=True, text=True, timeout=15) check(hello.returncode == 0, 'The copied Agent Info URL could not run the shared hello helper') - page.wait_for_function("() => document.getElementById('agent-connect-activity').innerText.includes('last authenticated request')") + page.wait_for_function("text => document.getElementById('agent-connect-activity').innerText.includes(text)", + arg='最近一次通過驗證的請求' if zh else 'last authenticated request') + page.click('#agent-connect-details summary') page.evaluate("() => { navigator.clipboard.writeText = async () => { throw new Error('Denied'); }; }") - page.click('#agent-connect-copy-url') - page.wait_for_function("() => document.getElementById('agent-connect-message').innerText.includes('copy it manually')") - selection = page.locator('#agent-connect-url').evaluate('field => field.value.slice(field.selectionStart, field.selectionEnd)') - check(selection == agentinfo_url, 'Clipboard fallback did not select the URL') + page.click('#agent-connect-copy') + page.wait_for_function("text => document.getElementById('agent-connect-message').innerText.includes(text)", + arg='手動複製' if zh else 'copy it manually') + check(page.locator('#agent-connect-message').is_visible(), 'Copy failure was hidden in collapsed details') + check(page.locator('#agent-connect-info').is_visible(), 'Copy failure did not reveal the selected prompt') + selection = page.locator('#agent-connect-info').evaluate('field => field.value.slice(field.selectionStart, field.selectionEnd)') + check(selection == page.input_value('#agent-connect-info'), 'Clipboard fallback did not select the prompt') page.click('#agent-connect-close') finally: close_context(context) @@ -4669,8 +4688,14 @@ def ready(carrier, port): check(page.locator('#agent-tunnel-setup').is_hidden(), 'Remote info repeated setup controls') check(page.locator('#agent-tunnel-copy').is_hidden(), 'Remote shortcut offered a stale cached prompt') page.evaluate('payload => window.terminalTest.completeAgentTunnelRequestForTest(1, payload)', first) + check(page.locator('#agent-tunnel-info').is_hidden(), 'Remote info did not collapse the full prompt') + for selector in ['#agent-tunnel-manage', '#agent-tunnel-refresh', '#agent-tunnel-check', + '#agent-tunnel-copy-url', '#agent-tunnel-carrier']: + check(page.locator(selector).is_hidden(), 'Remote secondary content remained visible: ' + selector) + check(page.locator('#agent-tunnel-message').is_hidden(), 'Remote ready state repeated the introduction') page.click('#agent-tunnel-copy') page.wait_for_function('text => window.copiedAgentText === text', arg=prompt) + page.click('#agent-tunnel-details summary') page.click('#agent-tunnel-manage') check(page.locator('#agent-tunnel-setup').is_visible(), 'Manage did not reveal tunnel controls') page.click('#agent-tunnel-refresh') From ca5c319b7d4ffd78c47faba6a6878356670dee92 Mon Sep 17 00:00:00 2001 From: "ASKA C." Date: Mon, 21 Sep 2026 23:40:16 +0800 Subject: [PATCH 36/43] Sync Desktop language with Core Settings --- desktop/README.md | 18 +++- desktop/browser-access.cjs | 4 +- desktop/diagnostics-window.cjs | 12 ++- desktop/language.cjs | 49 ++++------ desktop/main.cjs | 148 ++++++++++++++++-------------- desktop/messages.js | 16 ---- desktop/test/diagnostics.test.cjs | 1 + desktop/test/language.test.cjs | 90 ++++++------------ desktop/test/toolbar-smoke.cjs | 20 ++++ desktop/test/ui-commands.test.cjs | 56 ++++++++++- desktop/ui-commands.cjs | 12 ++- docs/desktop_ui_copy_review.tsv | 20 ++-- docs/desktop_ui_review_plan.md | 38 +++++--- templates/index.html | 1 + 14 files changed, 269 insertions(+), 216 deletions(-) diff --git a/desktop/README.md b/desktop/README.md index 5269824..e57b8da 100644 --- a/desktop/README.md +++ b/desktop/README.md @@ -10,10 +10,20 @@ Desktop copy and localization are planned in the [review plan](../docs/desktop_ui_review_plan.md), with a separate [translation review table](../docs/desktop_ui_copy_review.tsv). -Choose **StandTerm > Desktop language...** to select English or Traditional -Chinese (Taiwan) for the next launch. The preference belongs to the Desktop -profile; Core keeps its own language setting. Saving a choice does not restart -StandTerm or interrupt recording. Coverage includes custom menus, toolbar labels, +Choose English or Traditional Chinese (Taiwan) in Core **Settings**. After Save, +Desktop follows that stored preference through its existing Core status polling; +the separate Desktop language menu is removed. Menus, toolbar and capture status +update without restarting Core or interrupting recording. Core's page text retains +its existing next-load language behavior. Open native confirmations keep their +original labels; subsequent dialogs use the synchronized language. Diagnostics +updates when opened or refreshed. + +Desktop caches the last synchronized language in the mode profile's `language.json` +for startup, setup and recovery before Core is ready. Older Core versions without +the language snapshot field retain that cache; missing or invalid cache uses English. +A cache write failure is logged once per changed preference and does not stop the +active language update. This does not synchronize unrelated browser profiles or +backend modes. Coverage includes custom menus, toolbar labels, Agent help, Browser Access, Diagnostics, About, external-browser confirmations and Capture dialogs/status, environment preparation, Core source/recovery, port selection, Files download notices and native paste confirmation. diff --git a/desktop/browser-access.cjs b/desktop/browser-access.cjs index af7c66b..4a9a41f 100644 --- a/desktop/browser-access.cjs +++ b/desktop/browser-access.cjs @@ -69,13 +69,13 @@ function createBrowserAccess({ origin, session, launcherToken, available, confir return { run, dispose: () => { launcherToken = ''; }, - menu: { label: t('desktop.browser_access.menu'), submenu: [ + get menu() { return { label: t('desktop.browser_access.menu'), submenu: [ { label: t('desktop.browser_access.open'), click: () => run('open') }, { label: t('desktop.browser_access.copy_authorization'), click: () => run('copy-auth') }, { type: 'separator' }, { label: t('desktop.browser_access.copy_url'), click: () => run('copy-url') }, { label: t('desktop.browser_access.copy_token'), click: () => run('copy-token') }, - ] }, + ] }; }, }; } diff --git a/desktop/diagnostics-window.cjs b/desktop/diagnostics-window.cjs index 9658130..cdba704 100644 --- a/desktop/diagnostics-window.cjs +++ b/desktop/diagnostics-window.cjs @@ -43,17 +43,19 @@ function createStatusWindow(owner, snapshot, { copyUrl, i18n = create('en') }) { for (const name of ['will-navigate', 'will-frame-navigate', 'will-redirect', 'will-attach-webview']) { win.webContents.on(name, event => event.preventDefault()); } - win.setMenu(Menu.buildFromTemplate([{ label: t('desktop.toolbar.menu_view'), submenu: [ - { label: t('desktop.diagnostics.refresh'), accelerator: 'CommandOrControl+R', click: () => refresh().catch(() => {}) }, - { label: t('desktop.diagnostics.copy_backend_url'), click: copyUrl }, - { role: 'close' }, - ] }])); const owned = win; const close = () => { if (!owned.isDestroyed()) owned.destroy(); }; owner.once('closed', close); owned.once('closed', () => owner.removeListener('closed', close)); } async function refresh() { + win.setTitle(t('desktop.diagnostics.window_title')); + win.setMenu(Menu.buildFromTemplate([{ label: t('desktop.toolbar.menu_view'), submenu: [ + { label: t('desktop.diagnostics.refresh'), accelerator: 'CommandOrControl+R', click: () => refresh().catch(() => {}) }, + { label: t('desktop.diagnostics.copy_backend_url'), click: copyUrl }, + { role: 'close' }, + ] }])); + const { rows, events } = snapshot(); await win.loadURL('data:text/html,' + encodeURIComponent(statusHtml(rows, events, i18n))); } diff --git a/desktop/language.cjs b/desktop/language.cjs index f86557a..aac7113 100644 --- a/desktop/language.cjs +++ b/desktop/language.cjs @@ -13,40 +13,27 @@ function createLanguage(file) { if (data?.version === 1) selected = normalizeLocale(data.locale); } } catch { /* Missing or invalid preferences use English. */ } - const { locale, t } = create(selected); - let pending = false; - async function choose(dialog, win) { - if (pending || win.isDestroyed()) return false; - pending = true; + let active = create(selected); + let lastSynchronized; + function sync(locale) { + if (!['en', 'zh-TW'].includes(locale) || locale === lastSynchronized) return { changed: false }; + lastSynchronized = locale; + const changed = active.locale !== locale; + active = create(locale); + const temporary = `${file}.${randomUUID()}.tmp`; try { - const answer = await dialog.showMessageBox(win, { - type: 'question', title: t('desktop.language.title'), message: t('desktop.language.choose'), - detail: t('desktop.language.detail', { language: t(selected === 'zh-TW' ? 'desktop.language.name_zh_tw' : 'desktop.language.name_en') }), - buttons: [t('desktop.common.cancel'), t('desktop.language.name_en'), t('desktop.language.name_zh_tw')], - defaultId: 0, cancelId: 0, noLink: true, - }); - if (win.isDestroyed() || ![1, 2].includes(answer.response)) return false; - const next = answer.response === 2 ? 'zh-TW' : 'en'; - if (next === selected) return true; - const temporary = `${file}.${randomUUID()}.tmp`; - try { - fs.mkdirSync(path.dirname(file), { recursive: true }); - fs.writeFileSync(temporary, JSON.stringify({ version: 1, locale: next }, null, 2), { flag: 'wx', mode: 0o600 }); - fs.renameSync(temporary, file); - } finally { - try { fs.unlinkSync(temporary); } catch (error) { if (error.code !== 'ENOENT') throw error; } - } - selected = next; - if (!win.isDestroyed()) await dialog.showMessageBox(win, { type: 'info', title: t('desktop.language.title'), - message: t('desktop.language.saved'), buttons: [t('desktop.common.ok')], noLink: true }); - return true; + fs.mkdirSync(path.dirname(file), { recursive: true }); + fs.writeFileSync(temporary, JSON.stringify({ version: 1, locale }, null, 2), { flag: 'wx', mode: 0o600 }); + fs.renameSync(temporary, file); + return { changed, persisted: true }; } catch { - if (!win.isDestroyed()) await dialog.showMessageBox(win, { type: 'error', title: t('desktop.language.title'), - message: t('desktop.language.failed'), buttons: [t('desktop.common.ok')], noLink: true }).catch(() => {}); - return false; - } finally { pending = false; } + // Keep the current Core preference active even when its startup cache cannot be saved. + return { changed, persisted: false }; + } finally { + try { fs.unlinkSync(temporary); } catch { /* A failed cache write must not block the UI. */ } + } } - return { locale, t, choose }; + return { get locale() { return active.locale; }, t: (...args) => active.t(...args), sync }; } module.exports = { createLanguage }; diff --git a/desktop/main.cjs b/desktop/main.cjs index 09952a2..4f01d72 100644 --- a/desktop/main.cjs +++ b/desktop/main.cjs @@ -231,13 +231,17 @@ function createTray() { const icon = nativeImage.createFromBitmap(pixels, { width: size, height: size }); tray = new Tray(icon); tray.setToolTip('StandTerm Desktop'); - tray.setContextMenu(Menu.buildFromTemplate([ + updateTrayMenu(); + tray.on('click', showWindow); + return icon; +} + +function updateTrayMenu() { + tray?.setContextMenu(Menu.buildFromTemplate([ { label: t('desktop.menu.open'), click: showWindow }, { type: 'separator' }, { label: t('desktop.menu.quit'), click: () => app.quit() }, ])); - tray.on('click', showWindow); - return icon; } async function start() { @@ -328,7 +332,15 @@ async function start() { sandbox: true, webSecurity: true, webviewTag: false, allowRunningInsecureContent: false, devTools: true, } }); const contents = coreView.webContents; - const commands = createUiCommands(win, contents, handoff.origin, t); + const commands = createUiCommands(win, contents, handoff.origin, t, locale => { + const result = language.sync(locale); + if (result.persisted === false) diagnostics.write('language_cache_failed'); + if (!result.changed) return; + rebuildMenu(); + updateTrayMenu(); + toolbar.send({ locale: language.locale }); + capture.update(); + }); let toolbar; const updateTitle = () => { if (!win.isDestroyed()) win.setTitle(`${captureTitle ? `[${captureTitle}] ` : ''}${MODES[mode]} - ${pageTitle}`); @@ -362,18 +374,18 @@ async function start() { updateTitle(); }); const connectionInfo = agentConnectionInfo({ origin: handoff.origin, instanceId: handoff.instance_id, mode }); - const coreVersion = handoff.core_version || t('desktop.about.unknown_version'); - const coreBuild = prepared?.source === 'git' ? prepared.coreSource + const coreVersion = () => handoff.core_version || t('desktop.about.unknown_version'); + const coreBuild = () => prepared?.source === 'git' ? prepared.coreSource : handoff.core_bundle_id || t('desktop.about.unmanaged_source'); - const pythonVersion = handoff.python_version || t('desktop.about.unknown_version'); - const buildLabel = t(prepared?.source === 'git' ? 'desktop.about.git_revision' : 'desktop.about.bundle_sha256'); - const aboutDetails = t('desktop.about.details', { core_version: coreVersion, build_label: buildLabel, - core_build: coreBuild, backend: MODES[mode], python_version: pythonVersion, + const pythonVersion = () => handoff.python_version || t('desktop.about.unknown_version'); + const buildLabel = () => t(prepared?.source === 'git' ? 'desktop.about.git_revision' : 'desktop.about.bundle_sha256'); + const aboutDetails = () => t('desktop.about.details', { core_version: coreVersion(), build_label: buildLabel(), + core_build: coreBuild(), backend: MODES[mode], python_version: pythonVersion(), electron_version: process.versions.electron, chromium_version: process.versions.chrome, node_version: process.versions.node, platform: process.platform, arch: process.arch }); const openStatus = createStatusWindow(win, () => ({ rows: [ [t('desktop.diagnostics.desktop_version'), app.getVersion()], [t('desktop.diagnostics.backend_mode'), MODES[mode]], - [t('desktop.diagnostics.core_version'), coreVersion], [buildLabel, coreBuild], [t('desktop.diagnostics.python_version'), pythonVersion], + [t('desktop.diagnostics.core_version'), coreVersion()], [buildLabel(), coreBuild()], [t('desktop.diagnostics.python_version'), pythonVersion()], [t('desktop.diagnostics.backend_url'), handoff.origin], [t('desktop.diagnostics.instance_id'), handoff.instance_id], [t('desktop.diagnostics.platform'), `${process.platform} / ${process.arch}`], [t('desktop.diagnostics.engines'), `${process.versions.electron} / ${process.versions.chrome} / ${process.versions.node}`], @@ -386,64 +398,66 @@ async function start() { ], events: diagnostics.snapshot() }), { copyUrl: () => clipboard.writeText(connectionInfo.base_url), i18n: language, }); - Menu.setApplicationMenu(Menu.buildFromTemplate([ - { id: 'standterm', label: 'StandTerm', submenu: [ - commands.item('settings'), - { id: 'desktop-language', label: t('desktop.language.menu'), click: () => { void language.choose(dialog, win); } }, - ...(coreManager ? [{ label: t('desktop.menu.core_source'), click: () => { - void coreManager.showManager().catch(error => dialog.showErrorBox(t('desktop.startup.management_unavailable'), error.message)); - } }] : []), - { label: t('desktop.menu.capture_settings'), click: () => capture.configure() }, - browserAccess.menu, - { type: 'separator' }, - commands.item('newTab'), commands.item('closeTab'), commands.item('closeAll'), - commands.item('files'), commands.item('pip'), - { type: 'separator' }, - { id: 'desktop-about', label: t('desktop.menu.about'), click: () => dialog.showMessageBox(win, { - type: 'info', title: t('desktop.menu.about'), message: t('desktop.about.desktop_version', { version: app.getVersion() }), - detail: aboutDetails, buttons: [t('desktop.common.ok')], noLink: true, - }) }, - { label: t('desktop.menu.show'), click: showWindow }, - ...(process.platform === 'darwin' ? [{ role: 'services' }, { type: 'separator' }, { role: 'hide' }, { role: 'hideOthers' }, { role: 'unhide' }, { type: 'separator' }] : []), - { label: t('desktop.menu.quit'), accelerator: 'CommandOrControl+Q', click: () => app.quit() }, - ] }, - { id: 'edit', role: 'editMenu', label: t('desktop.toolbar.menu_edit') }, - agentMenu({ - t, - uiItems: [commands.item('agentPanel'), commands.item('pauseAgent'), { type: 'separator' }], - showHelp: () => dialog.showMessageBox(win, { - type: 'info', title: 'StandTerm Agent', message: t('desktop.agent.help_title'), - detail: ['desktop.agent.help_permissions', 'desktop.agent.help_environment', - 'desktop.agent.help_connection', 'desktop.agent.help_skills'].map(key => t(key)).join('\n\n'), - buttons: [t('desktop.common.ok')], noLink: true, + function rebuildMenu() { + Menu.setApplicationMenu(Menu.buildFromTemplate([ + { id: 'standterm', label: 'StandTerm', submenu: [ + commands.item('settings'), + ...(coreManager ? [{ label: t('desktop.menu.core_source'), click: () => { + void coreManager.showManager().catch(error => dialog.showErrorBox(t('desktop.startup.management_unavailable'), error.message)); + } }] : []), + { label: t('desktop.menu.capture_settings'), click: () => capture.configure() }, + browserAccess.menu, + { type: 'separator' }, + commands.item('newTab'), commands.item('closeTab'), commands.item('closeAll'), + commands.item('files'), commands.item('pip'), + { type: 'separator' }, + { id: 'desktop-about', label: t('desktop.menu.about'), click: () => dialog.showMessageBox(win, { + type: 'info', title: t('desktop.menu.about'), message: t('desktop.about.desktop_version', { version: app.getVersion() }), + detail: aboutDetails(), buttons: [t('desktop.common.ok')], noLink: true, + }) }, + { label: t('desktop.menu.show'), click: showWindow }, + ...(process.platform === 'darwin' ? [{ role: 'services' }, { type: 'separator' }, { role: 'hide' }, { role: 'hideOthers' }, { role: 'unhide' }, { type: 'separator' }] : []), + { label: t('desktop.menu.quit'), accelerator: 'CommandOrControl+Q', click: () => app.quit() }, + ] }, + { id: 'edit', role: 'editMenu', label: t('desktop.toolbar.menu_edit') }, + agentMenu({ + t, + uiItems: [commands.item('agentPanel'), commands.item('pauseAgent'), { type: 'separator' }], + showHelp: () => dialog.showMessageBox(win, { + type: 'info', title: 'StandTerm Agent', message: t('desktop.agent.help_title'), + detail: ['desktop.agent.help_permissions', 'desktop.agent.help_environment', + 'desktop.agent.help_connection', 'desktop.agent.help_skills'].map(key => t(key)).join('\n\n'), + buttons: [t('desktop.common.ok')], noLink: true, + }), }), - }), - { id: 'view', label: t('desktop.toolbar.menu_view'), submenu: [{ role: 'resetZoom' }, { role: 'zoomIn' }, { role: 'zoomOut' }, { role: 'togglefullscreen' }, - { type: 'separator' }, - ...capture.menu().submenu, - ] }, - diagnosticsMenu({ origin: handoff.origin, mode, instanceId: handoff.instance_id, - t, - version: app.getVersion(), coreVersion: handoff.core_version, logger: diagnostics, persistent: !smoke, - copyText: text => clipboard.writeText(text), - openStatus: () => openStatus().catch(() => { - if (!win.isDestroyed()) dialog.showMessageBox(win, { type: 'warning', message: t('desktop.diagnostics.open_failed') }); + { id: 'view', label: t('desktop.toolbar.menu_view'), submenu: [{ role: 'resetZoom' }, { role: 'zoomIn' }, { role: 'zoomOut' }, { role: 'togglefullscreen' }, + { type: 'separator' }, + ...capture.menu().submenu, + ] }, + diagnosticsMenu({ origin: handoff.origin, mode, instanceId: handoff.instance_id, + t, + version: app.getVersion(), coreVersion: handoff.core_version, logger: diagnostics, persistent: !smoke, + copyText: text => clipboard.writeText(text), + openStatus: () => openStatus().catch(() => { + if (!win.isDestroyed()) dialog.showMessageBox(win, { type: 'warning', message: t('desktop.diagnostics.open_failed') }); + }), + openLogs: async () => { + const error = await shell.openPath(diagnostics.directory); + if (error) await dialog.showMessageBox(win, { type: 'warning', message: t('desktop.diagnostics.folder_failed'), detail: diagnostics.directory }); + }, + openTools: async () => { + const opened = await openDeveloperTools(contents, async () => { + const answer = await dialog.showMessageBox(win, { type: 'warning', title: t('desktop.diagnostics.devtools_title'), + message: t('desktop.diagnostics.devtools_message'), detail: t('desktop.diagnostics.devtools_detail'), + buttons: [t('desktop.common.cancel'), t('desktop.diagnostics.devtools_open')], defaultId: 0, cancelId: 0, noLink: true }); + return answer.response === 1; + }); + if (opened) diagnostics.write('devtools_opened'); + }, }), - openLogs: async () => { - const error = await shell.openPath(diagnostics.directory); - if (error) await dialog.showMessageBox(win, { type: 'warning', message: t('desktop.diagnostics.folder_failed'), detail: diagnostics.directory }); - }, - openTools: async () => { - const opened = await openDeveloperTools(contents, async () => { - const answer = await dialog.showMessageBox(win, { type: 'warning', title: t('desktop.diagnostics.devtools_title'), - message: t('desktop.diagnostics.devtools_message'), detail: t('desktop.diagnostics.devtools_detail'), - buttons: [t('desktop.common.cancel'), t('desktop.diagnostics.devtools_open')], defaultId: 0, cancelId: 0, noLink: true }); - return answer.response === 1; - }); - if (opened) diagnostics.write('devtools_opened'); - }, - }), - ])); + ])); + } + rebuildMenu(); const openExternal = createExternalOpener({ origin: handoff.origin, owner: contents, confirm: async url => { const answer = await dialog.showMessageBox(win, { type: 'question', title: t('desktop.external_browser.title'), diff --git a/desktop/messages.js b/desktop/messages.js index be084bc..86cc310 100644 --- a/desktop/messages.js +++ b/desktop/messages.js @@ -177,14 +177,6 @@ "desktop.installer.return_to_installer": "Return to installer", "desktop.installer.setup_failed_detail": "Some environment preparation, moves to recovery, or shortcut changes may already have completed. Completed changes are not automatically undone. Check venv-recovery before retrying with the installer. No terminal backend was started.", "desktop.installer.setup_failed_title": "StandTerm operation did not complete", - "desktop.language.choose": "Choose the Desktop language for the next launch.", - "desktop.language.detail": "Saved choice: {language}\n\nCore has its own language setting. Some Desktop text remains in English.", - "desktop.language.failed": "Could not confirm the language setting. Reopen Desktop language to check the saved choice.", - "desktop.language.menu": "Desktop language...", - "desktop.language.name_en": "English", - "desktop.language.name_zh_tw": "\u7e41\u9ad4\u4e2d\u6587", - "desktop.language.saved": "Language saved. It applies the next time you launch StandTerm.", - "desktop.language.title": "Desktop language", "desktop.menu.about": "About StandTerm Desktop", "desktop.menu.agent_panel": "Show / hide Agent Panel", "desktop.menu.capture_settings": "Capture Settings...", @@ -480,14 +472,6 @@ "desktop.installer.return_to_installer": "\u8fd4\u56de\u5b89\u88dd\u7a0b\u5f0f", "desktop.installer.setup_failed_detail": "\u90e8\u5206\u74b0\u5883\u6e96\u5099\u3001\u79fb\u81f3\u5fa9\u539f\u8cc7\u6599\u593e\u6216\u6377\u5f91\u8b8a\u66f4\u53ef\u80fd\u5df2\u5b8c\u6210\u3002\u5df2\u5b8c\u6210\u7684\u8b8a\u66f4\u4e0d\u6703\u81ea\u52d5\u9084\u539f\u3002\u8acb\u5148\u6aa2\u67e5 venv-recovery\uff0c\u518d\u900f\u904e\u5b89\u88dd\u7a0b\u5f0f\u91cd\u8a66\u3002\u672a\u555f\u52d5\u7d42\u7aef\u5f8c\u7aef\u3002", "desktop.installer.setup_failed_title": "StandTerm \u64cd\u4f5c\u672a\u5b8c\u6210", - "desktop.language.choose": "\u9078\u64c7\u4e0b\u6b21\u555f\u52d5\u6642\u4f7f\u7528\u7684 Desktop \u8a9e\u7cfb\u3002", - "desktop.language.detail": "\u5df2\u5132\u5b58\u7684\u9078\u64c7\uff1a{language}\n\nCore \u7684\u8a9e\u7cfb\u9700\u53e6\u5916\u8a2d\u5b9a\u3002\u90e8\u5206 Desktop \u6587\u5b57\u4ecd\u4f7f\u7528\u82f1\u6587\u3002", - "desktop.language.failed": "\u7121\u6cd5\u78ba\u8a8d\u8a9e\u7cfb\u8a2d\u5b9a\u3002\u8acb\u91cd\u65b0\u958b\u555f\u300cDesktop \u8a9e\u7cfb\u300d\u67e5\u770b\u5df2\u5132\u5b58\u7684\u9078\u64c7\u3002", - "desktop.language.menu": "Desktop \u8a9e\u7cfb\u2026", - "desktop.language.name_en": "English", - "desktop.language.name_zh_tw": "\u7e41\u9ad4\u4e2d\u6587", - "desktop.language.saved": "\u5df2\u5132\u5b58\u8a9e\u7cfb\uff0c\u4e0b\u6b21\u555f\u52d5 StandTerm \u6642\u751f\u6548\u3002", - "desktop.language.title": "Desktop \u8a9e\u7cfb", "desktop.menu.about": "\u95dc\u65bc StandTerm Desktop", "desktop.menu.agent_panel": "\u986f\u793a\uff0f\u96b1\u85cf Agent \u9762\u677f", "desktop.menu.capture_settings": "\u756b\u9762\u64f7\u53d6\u8a2d\u5b9a\u2026", diff --git a/desktop/test/diagnostics.test.cjs b/desktop/test/diagnostics.test.cjs index bacefe2..a1c1f2b 100644 --- a/desktop/test/diagnostics.test.cjs +++ b/desktop/test/diagnostics.test.cjs @@ -91,6 +91,7 @@ test('translated diagnostic windows refresh through the same isolated scriptless isDestroyed() { return this.destroyed; } destroy() { this.destroyed = true; this.emit('closed'); } setMenu(menu) { this.menu = menu; } + setTitle(title) { this.title = title; } async loadURL(url) { this.urls.push(url); } show() {} focus() {} diff --git a/desktop/test/language.test.cjs b/desktop/test/language.test.cjs index eb64e9a..871b0d5 100644 --- a/desktop/test/language.test.cjs +++ b/desktop/test/language.test.cjs @@ -8,31 +8,21 @@ const path = require('node:path'); const { createLanguage } = require('../language.cjs'); const { create } = require('../i18n.js'); -const owner = { isDestroyed: () => false }; function fixture() { const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'standterm-language-')); return { directory, file: path.join(directory, 'language.json') }; } -function dialog(response) { - const calls = []; - return { calls, showMessageBox: async (_win, options) => { calls.push(options); return { response }; } }; -} -test('language persists per profile and takes effect only for a new instance', async () => { - const f = fixture(), original = createLanguage(f.file), prompt = dialog(2); - assert.equal(original.locale, 'en'); - assert.equal(await original.choose(prompt, owner), true); - assert.deepEqual(JSON.parse(fs.readFileSync(f.file, 'utf8')), { version: 1, locale: 'zh-TW' }); - assert.equal(original.locale, 'en'); - assert.equal(original.t('desktop.menu.settings'), 'Settings...'); - const reloaded = createLanguage(f.file); - assert.equal(reloaded.locale, 'zh-TW'); - assert.equal(reloaded.t('desktop.menu.settings'), create('zh-TW').t('desktop.menu.settings')); - assert.equal(createLanguage(path.join(f.directory, 'other-profile', 'language.json')).locale, 'en'); - const english = dialog(1); - assert.equal(await reloaded.choose(english, owner), true); - assert.equal(english.calls[0].buttons[0], reloaded.t('desktop.common.cancel')); - assert.equal(createLanguage(f.file).locale, 'en'); +test('Core language updates existing translators and the next-launch cache per profile', () => { + const f = fixture(), language = createLanguage(f.file), { t } = language; + assert.equal(language.locale, 'en'); + for (const locale of ['zh-TW', 'en']) { + assert.deepEqual(language.sync(locale), { changed: true, persisted: true }); + assert.equal(language.locale, locale); + assert.equal(t('desktop.menu.settings'), create(locale).t('desktop.menu.settings')); + assert.equal(createLanguage(f.file).locale, locale); + } + assert.equal(createLanguage(path.join(f.directory, 'other-profile/language.json')).locale, 'en'); assert.deepEqual(fs.readdirSync(f.directory), ['language.json']); }); @@ -47,55 +37,31 @@ test('missing, malformed and unsupported preferences fall back to English', () = } }); -test('cancel, unknown responses and selecting the current language do not write', async () => { +test('invalid and older-Core values leave the active language and cache untouched', () => { const f = fixture(), language = createLanguage(f.file); - for (const response of [0, -1, 3, '2', undefined, 1]) { - const prompt = dialog(response); - assert.equal(await language.choose(prompt, owner), response === 1); - assert.equal(prompt.calls[0].defaultId, 0); - assert.equal(prompt.calls[0].cancelId, 0); - assert.equal(fs.existsSync(f.file), false); + language.sync('zh-TW'); + const before = fs.readFileSync(f.file, 'utf8'); + for (const value of [undefined, null, '', 'fr', 'zh-CN', {}, 1]) { + assert.deepEqual(language.sync(value), { changed: false }); + assert.equal(language.locale, 'zh-TW'); + assert.equal(fs.readFileSync(f.file, 'utf8'), before); } }); -test('duplicate and stale language dialogs cannot write a selection', async () => { +test('first English snapshot creates a cache and repeated snapshots do not rewrite it', () => { const f = fixture(), language = createLanguage(f.file); - let resolve, destroyed = false, calls = 0; - const win = { isDestroyed: () => destroyed }; - const prompt = { showMessageBox: () => { calls++; return new Promise(done => { resolve = done; }); } }; - const pending = language.choose(prompt, win); - assert.equal(await language.choose(prompt, win), false); - destroyed = true; - resolve({ response: 2 }); - assert.equal(await pending, false); - assert.equal(calls, 1); - assert.equal(fs.existsSync(f.file), false); - assert.equal(await language.choose(prompt, win), false); + assert.deepEqual(language.sync('en'), { changed: false, persisted: true }); + fs.utimesSync(f.file, 1, 1); + assert.deepEqual(language.sync('en'), { changed: false }); + assert.equal(fs.statSync(f.file).mtimeMs, 1000); }); -test('failed persistence leaves the saved choice and active language unchanged', async () => { - const f = fixture(), language = createLanguage(f.file), prompt = dialog(2); +test('failed cache writes still follow Core without repeated I/O or temporary files', () => { + const f = fixture(), language = createLanguage(f.file); fs.mkdirSync(f.file); - assert.equal(await language.choose(prompt, owner), false); - assert.equal(language.locale, 'en'); - assert.equal(prompt.calls.at(-1).type, 'error'); - assert.equal(prompt.calls.at(-1).message, language.t('desktop.language.failed')); + assert.deepEqual(language.sync('zh-TW'), { changed: true, persisted: false }); + assert.equal(language.locale, 'zh-TW'); + assert.deepEqual(language.sync('zh-TW'), { changed: false }); assert.deepEqual(fs.readdirSync(f.directory), ['language.json']); - const check = dialog(0); - await language.choose(check, owner); - assert.match(check.calls[0].detail, /Saved choice: English/); -}); - -test('a lost save notification does not report rollback or discard the persisted choice', async () => { - const f = fixture(), language = createLanguage(f.file); - let calls = 0; - const prompt = { showMessageBox: async () => { - if (++calls > 1) throw new Error('Window closed'); - return { response: 2 }; - } }; - assert.equal(await language.choose(prompt, owner), false); - assert.equal(createLanguage(f.file).locale, 'zh-TW'); - const check = dialog(0); - await language.choose(check, owner); - assert.equal(check.calls[0].detail, language.t('desktop.language.detail', { language: language.t('desktop.language.name_zh_tw') })); + assert.equal(createLanguage(f.file).locale, 'en'); }); diff --git a/desktop/test/toolbar-smoke.cjs b/desktop/test/toolbar-smoke.cjs index cfd4232..b09f644 100644 --- a/desktop/test/toolbar-smoke.cjs +++ b/desktop/test/toolbar-smoke.cjs @@ -164,6 +164,26 @@ async function run(win, contents, browserAccess) { if (layout.reducedMotion) assert.equal(layout.transition, '0s'); console.log(`macOS toolbar renderer: native menu, background five/ten-second notices and wide layout passed; ${JSON.stringify(layout)}`); } + const originalLanguage = await evaluate('window.standtermUi.snapshot().uiLanguage'); + const originalTerminal = await evaluate('window.standtermUi.snapshot().terminalId'); + await evaluate('window.desktopLocaleSmokeSentinel = true'); + assert.ok(!Menu.getApplicationMenu().getMenuItemById('desktop-language')); + for (const locale of [originalLanguage === 'en' ? 'zh-TW' : 'en', originalLanguage]) { + const { t } = require('../i18n.js').create(locale); + await until(() => Menu.getApplicationMenu().getMenuItemById('ui-settings').enabled, 'localized Settings enabled'); + Menu.getApplicationMenu().getMenuItemById('ui-settings').click(); + await until(() => evaluate("document.getElementById('settings-modal').classList.contains('open')"), 'language Settings opened'); + await evaluate(`document.getElementById('pref-uiLanguage').value = ${JSON.stringify(locale)}`); + assert.notEqual(await evaluate('window.standtermUi.snapshot().uiLanguage'), locale, 'unsaved dropdown must not change Desktop'); + await evaluate("document.getElementById('settings-save').click()"); + await until(async () => await win.webContents.executeJavaScript('document.documentElement.lang') === locale, 'Desktop locale synchronized'); + assert.equal(Menu.getApplicationMenu().getMenuItemById('ui-settings').label, t('desktop.menu.settings')); + assert.equal(browserAccess.menu.label, t('desktop.browser_access.menu')); + assert.equal(await evaluate('window.desktopLocaleSmokeSentinel'), true, 'locale synchronization reloaded Core'); + assert.equal(await evaluate('window.standtermUi.snapshot().terminalId'), originalTerminal); + assert.deepEqual(JSON.parse(await fs.readFile(path.join(app.getPath('userData'), 'language.json'), 'utf8')), { version: 1, locale }); + } + console.log('Desktop locale smoke: saved Core Settings update native menus, toolbar and startup cache without reloading Core.'); console.log(`Desktop toolbar smoke: isolated SVG toolbar, focus guards, native Settings/tab actions and compact layout passed (${directory}).`); } diff --git a/desktop/test/ui-commands.test.cjs b/desktop/test/ui-commands.test.cjs index 9439e3d..1f95d04 100644 --- a/desktop/test/ui-commands.test.cjs +++ b/desktop/test/ui-commands.test.cjs @@ -12,7 +12,7 @@ test('native clipboard commands preserve the Core editing target and reject stal const calls = [], state = { focused: true, hidden: false, minimized: false, destroyed: false, url: 'http://127.0.0.1:64487/' }; const win = new EventEmitter(); Object.assign(win, { isDestroyed: () => state.destroyed, isVisible: () => !state.hidden, isMinimized: () => state.minimized }); - const contents = { isDestroyed: () => state.destroyed, getURL: () => state.url, + const contents = { on: () => {}, isLoadingMainFrame: () => false, mainFrame: {}, isDestroyed: () => state.destroyed, getURL: () => state.url, focus: () => calls.push('focus'), copy: () => calls.push('copy'), paste: () => calls.push('paste') }; const api = { exports: {} }; vm.runInNewContext(fs.readFileSync(path.join(__dirname, '..', 'ui-commands.cjs'), 'utf8'), { @@ -41,7 +41,7 @@ test('localized menu labels preserve typed commands, target IDs and focus checks let focused = true; const win = new EventEmitter(); Object.assign(win, { isDestroyed: () => false }); - const contents = { isDestroyed: () => false, getURL: () => 'http://127.0.0.1:64487/', + const contents = { on: () => {}, isLoadingMainFrame: () => false, mainFrame: {}, isDestroyed: () => false, getURL: () => 'http://127.0.0.1:64487/', executeJavaScript: async script => { if (script.includes('.snapshot()')) return { version: 1, ready: true, actions: { newTab: true }, terminalId: 'terminal-1' }; calls.push(script); return true; @@ -66,3 +66,55 @@ test('localized menu labels preserve typed commands, target IDs and focus checks assert.equal(calls.length, 1); } }); + +function localeFixture() { + const win = new EventEmitter(), contents = new EventEmitter(), locales = []; + const state = { destroyed: false, loading: false, url: 'http://127.0.0.1:64487/', + snapshot: { version: 1, ready: true, actions: {}, uiLanguage: 'zh-TW' } }; + Object.assign(win, { isDestroyed: () => state.destroyed }); + Object.assign(contents, { isDestroyed: () => state.destroyed, isLoadingMainFrame: () => state.loading, + getURL: () => state.url, mainFrame: {}, executeJavaScript: async () => state.snapshot }); + const api = { exports: {} }; + vm.runInNewContext(fs.readFileSync(path.join(__dirname, '..', 'ui-commands.cjs'), 'utf8'), { + require: name => name === 'electron' ? { BrowserWindow: { getFocusedWindow: () => win }, + Menu: { getApplicationMenu: () => null } } : name === './policy.cjs' ? { allowedNavigation } : require('../i18n.js'), + module: api, setInterval: () => 0, clearInterval: () => {}, + }); + return { state, contents, locales, + commands: api.exports.createUiCommands(win, contents, 'http://127.0.0.1:64487', undefined, value => locales.push(value)) }; +} + +test('only ready Core snapshots with supported locale values update Desktop', async () => { + const f = localeFixture(); + await f.commands.refresh(); + for (const value of [undefined, null, 'fr', {}, 1]) { + f.state.snapshot.uiLanguage = value; + await f.commands.refresh(); + } + f.state.snapshot.uiLanguage = 'en'; f.state.snapshot.ready = false; + await f.commands.refresh(); + f.state.snapshot.ready = true; f.state.url = 'https://example.com/'; + await f.commands.refresh(); + f.state.url = 'http://127.0.0.1:64487/'; f.state.loading = true; + await f.commands.refresh(); + f.state.loading = false; + await f.commands.refresh(); + assert.deepEqual(f.locales, ['zh-TW', 'en']); +}); + +test('navigation, frame replacement and closed windows discard late locale snapshots', async () => { + for (const phase of ['navigation', 'frame', 'destroyed', 'loading', 'foreign']) { + const f = localeFixture(); + let resolve; + f.contents.executeJavaScript = () => new Promise(done => { resolve = done; }); + const pending = f.commands.refresh(); + if (phase === 'navigation') f.contents.emit('did-start-navigation', {}, f.state.url, false, true); + if (phase === 'frame') f.contents.mainFrame = {}; + if (phase === 'destroyed') f.state.destroyed = true; + if (phase === 'loading') f.state.loading = true; + if (phase === 'foreign') f.state.url = 'https://example.com/'; + resolve(f.state.snapshot); + await pending; + assert.deepEqual(f.locales, [], phase); + } +}); diff --git a/desktop/ui-commands.cjs b/desktop/ui-commands.cjs index bdef687..ea68ca2 100644 --- a/desktop/ui-commands.cjs +++ b/desktop/ui-commands.cjs @@ -10,8 +10,12 @@ const UI_ACTIONS = Object.freeze({ agentPanel: 'desktop.menu.agent_panel', pauseAgent: 'desktop.menu.pause_agent', }); -function createUiCommands(win, contents, origin, t = create('en').t) { +function createUiCommands(win, contents, origin, t = create('en').t, onLocale = () => {}) { let refreshing = false; + let navigation = 0; + contents.on('did-start-navigation', (_event, _url, _inPlace, mainFrame) => { + if (mainFrame) navigation++; + }); const current = () => !win.isDestroyed() && !contents.isDestroyed() && allowedNavigation(contents.getURL(), origin); const focused = () => current() && BrowserWindow.getFocusedWindow() === win; function edit(action) { @@ -22,9 +26,12 @@ function createUiCommands(win, contents, origin, t = create('en').t) { return true; } async function snapshot() { - if (!current()) return null; + if (!current() || contents.isLoadingMainFrame()) return null; + const epoch = navigation; + const frame = contents.mainFrame; try { const state = await contents.executeJavaScript('window.standtermUi?.version === 1 ? window.standtermUi.snapshot() : null'); + if (!current() || contents.isLoadingMainFrame() || navigation !== epoch || contents.mainFrame !== frame) return null; return state?.version === 1 && state.ready === true && typeof state.actions === 'object' ? state : null; } catch { return null; } } @@ -43,6 +50,7 @@ function createUiCommands(win, contents, origin, t = create('en').t) { refreshing = true; try { const state = await snapshot(); + if (['en', 'zh-TW'].includes(state?.uiLanguage)) onLocale(state.uiLanguage); for (const action of Object.keys(UI_ACTIONS)) { const item = Menu.getApplicationMenu()?.getMenuItemById(`ui-${action}`); if (item) item.enabled = !!state?.actions?.[action] && (action === 'settings' || focused()); diff --git a/docs/desktop_ui_copy_review.tsv b/docs/desktop_ui_copy_review.tsv index 69359cc..3523c57 100644 --- a/docs/desktop_ui_copy_review.tsv +++ b/docs/desktop_ui_copy_review.tsv @@ -53,16 +53,16 @@ desktop.agent.menu_title Agent Agent Agent Custom native Agent submenu title. K desktop.agent.getting_started Getting started... Getting started… 開始使用… Agent help menu item; opens the existing informational dialog. Preserve id=agent-help, showHelp callback and UI action order. Opening help grants no access. translation-reviewed desktop/agent-menu.cjs:6 desktop.agent.help_environment For an SSH agent, start Agent Tunnel on its SSH tab. Agent connection appears after setup succeeds. For a local agent, use Authorize agent on each intended tab. For an Agent running on an SSH host, start Agent Tunnel on its SSH tab. Agent connection appears after setup succeeds. For a local Agent, use Authorize agent on each intended tab. 若 Agent 在 SSH 遠端執行,請在其 SSH 分頁啟動 Agent 通道(Agent Tunnel)。設定成功後會出現「Agent 連線」(Agent connection)。若 Agent 在本機執行,請在每個預定操作的分頁選擇「授權 Agent」(Authorize agent)。 Complete SSH/local paragraph between the per-tab permission and Copy Prompt instructions. Preserve the SSH carrier tab, successful-setup prerequisite, per-target authorization and local/SSH distinction. Keep English Core control names for independently selected Desktop/Core languages. Do not imply setup authorizes additional tabs. translation-reviewed desktop/main.cjs:414-415:agentMenu.showHelp desktop.agent.help_skills Skills do not need to be installed first. The prompt leads to the bundled skills and helpers; Agent Info also provides installation instructions when persistent skills are wanted. Skills do not need to be installed first. The prompt leads to bundled skills and helpers. Agent Info also provides installation instructions for persistent skills. 不必先安裝技能(skills)。連線指引會引導 Agent 使用隨附的技能與輔助工具;若希望安裝後持續使用技能,Agent Info 也提供安裝說明。 Complete final Agent help paragraph about optional skill installation and bundled helpers. Keep Agent Info recognizable. Preserve optional installation, bundled-skill/helper guidance and the distinction between copying instructions and installing skills. Do not modify prompt contents or imply automatic installation. translation-reviewed desktop/main.cjs:418-419:agentMenu.showHelp -desktop.common.cancel Cancel Cancel 取消 Desktop language dialog; language names are self-names in both locales. Keep response indices 0 Cancel, 1 en, 2 zh-TW and cancel/default index 0. Persist locale only; do not restart, stop recording, authorize, or change Core preferences. translation-reviewed desktop/language.cjs:createLanguage -desktop.common.ok OK OK 確定 Desktop language dialog; language names are self-names in both locales. Keep response indices 0 Cancel, 1 en, 2 zh-TW and cancel/default index 0. Persist locale only; do not restart, stop recording, authorize, or change Core preferences. translation-reviewed desktop/language.cjs:createLanguage -desktop.language.menu Desktop language... Desktop 語系… Desktop language dialog; language names are self-names in both locales. Keep response indices 0 Cancel, 1 en, 2 zh-TW and cancel/default index 0. Persist locale only; do not restart, stop recording, authorize, or change Core preferences. translation-reviewed desktop/language.cjs:createLanguage -desktop.language.title Desktop language Desktop 語系 Desktop language dialog; language names are self-names in both locales. Keep response indices 0 Cancel, 1 en, 2 zh-TW and cancel/default index 0. Persist locale only; do not restart, stop recording, authorize, or change Core preferences. translation-reviewed desktop/language.cjs:createLanguage -desktop.language.choose Choose the Desktop language for the next launch. 選擇下次啟動時使用的 Desktop 語系。 Desktop language dialog; language names are self-names in both locales. Keep response indices 0 Cancel, 1 en, 2 zh-TW and cancel/default index 0. Persist locale only; do not restart, stop recording, authorize, or change Core preferences. translation-reviewed desktop/language.cjs:createLanguage -desktop.language.detail Saved choice: {language}\n\nCore has its own language setting. Some Desktop text remains in English. 已儲存的選擇:{language}\n\nCore 的語系需另外設定。部分 Desktop 文字仍使用英文。 Desktop language dialog; language names are self-names in both locales. {language} Keep response indices 0 Cancel, 1 en, 2 zh-TW and cancel/default index 0. Persist locale only; do not restart, stop recording, authorize, or change Core preferences. translation-reviewed desktop/language.cjs:createLanguage -desktop.language.saved Language saved. It applies the next time you launch StandTerm. 已儲存語系,下次啟動 StandTerm 時生效。 Desktop language dialog; language names are self-names in both locales. Keep response indices 0 Cancel, 1 en, 2 zh-TW and cancel/default index 0. Persist locale only; do not restart, stop recording, authorize, or change Core preferences. translation-reviewed desktop/language.cjs:createLanguage -desktop.language.failed Could not confirm the language setting. Reopen Desktop language to check the saved choice. 無法確認語系設定。請重新開啟「Desktop 語系」查看已儲存的選擇。 Desktop language dialog; language names are self-names in both locales. Keep response indices 0 Cancel, 1 en, 2 zh-TW and cancel/default index 0. Persist locale only; do not restart, stop recording, authorize, or change Core preferences. translation-reviewed desktop/language.cjs:createLanguage -desktop.language.name_en English English Desktop language dialog; language names are self-names in both locales. Keep response indices 0 Cancel, 1 en, 2 zh-TW and cancel/default index 0. Persist locale only; do not restart, stop recording, authorize, or change Core preferences. translation-reviewed desktop/language.cjs:createLanguage -desktop.language.name_zh_tw 繁體中文 繁體中文 Desktop language dialog; language names are self-names in both locales. Keep response indices 0 Cancel, 1 en, 2 zh-TW and cancel/default index 0. Persist locale only; do not restart, stop recording, authorize, or change Core preferences. translation-reviewed desktop/language.cjs:createLanguage +desktop.common.cancel Cancel Cancel 取消 Shared native dialog action. Preserve structured response indices and cancellation behavior. translation-reviewed desktop/main.cjs +desktop.common.ok OK OK 確定 Shared native dialog action. Preserve structured response indices and cancellation behavior. translation-reviewed desktop/main.cjs +desktop.language.menu Removed independent Desktop language dialog; follow Core Settings instead. Retired key; do not translate. remove desktop/language.cjs:createLanguage +desktop.language.title Removed independent Desktop language dialog; follow Core Settings instead. Retired key; do not translate. remove desktop/language.cjs:createLanguage +desktop.language.choose Removed independent Desktop language dialog; follow Core Settings instead. Retired key; do not translate. remove desktop/language.cjs:createLanguage +desktop.language.detail Removed independent Desktop language dialog; follow Core Settings instead. Retired key; do not translate. remove desktop/language.cjs:createLanguage +desktop.language.saved Removed independent Desktop language dialog; follow Core Settings instead. Retired key; do not translate. remove desktop/language.cjs:createLanguage +desktop.language.failed Removed independent Desktop language dialog; follow Core Settings instead. Retired key; do not translate. remove desktop/language.cjs:createLanguage +desktop.language.name_en Removed independent Desktop language dialog; follow Core Settings instead. Retired key; do not translate. remove desktop/language.cjs:createLanguage +desktop.language.name_zh_tw Removed independent Desktop language dialog; follow Core Settings instead. Retired key; do not translate. remove desktop/language.cjs:createLanguage desktop.menu.open Open StandTerm Open StandTerm 開啟 StandTerm Custom main/tray menu label; destination dialog may remain English during the pilot. Translate display label only. Keep callbacks, accelerators, native roles and action IDs unchanged. translation-reviewed desktop/main.cjs:createTray/start desktop.menu.show Show window Show window 顯示視窗 Custom main/tray menu label; destination dialog may remain English during the pilot. Translate display label only. Keep callbacks, accelerators, native roles and action IDs unchanged. translation-reviewed desktop/main.cjs:createTray/start desktop.menu.quit Quit StandTerm Quit StandTerm 結束 StandTerm Custom main/tray menu label; destination dialog may remain English during the pilot. Translate display label only. Keep callbacks, accelerators, native roles and action IDs unchanged. translation-reviewed desktop/main.cjs:createTray/start diff --git a/docs/desktop_ui_review_plan.md b/docs/desktop_ui_review_plan.md index 461259d..c56a504 100644 --- a/docs/desktop_ui_review_plan.md +++ b/docs/desktop_ui_review_plan.md @@ -33,7 +33,8 @@ The candidate exposed a copy regression: the Desktop permission handlers denied buttons always reported failure. The source fix permits sanitized writes only for the owned, focused, visible Core main frame at its backend origin. Clipboard reads retain their native confirmation and target guards. The existing candidate -installer does not contain this fix until rebuilt. +installer from commit `3886155` does not contain this fix. The subsequent +`98e490d` candidate includes the copy fix and compact local/SSH connection dialogs. Validation: all 167 Desktop unit tests pass, including foreground write access and foreign-frame/background denial. A separate Windows Electron 44.2.0 window @@ -44,13 +45,22 @@ acceptance. The attempted Linux headless runtime probe exited before producing results; Windows provided the native permission evidence. The operator requests a single language setting and asks about live switching. -The following is the proposed next implementation phase, not completed behavior: +Desktop synchronization is now implemented ahead of full Core live translation, +at the operator's request. The language is read from the saved Core preference +through the existing version-1 UI snapshot; unsaved dropdown changes do not apply. +Native confirmations already open retain their labels, and diagnostics update on +open/refresh. Validation on 2026-09-21 passes all 168 Desktop unit tests +and real Windows Electron smoke with both Windows and WSL Core backends. The +smoke saves both languages through Settings and verifies native menu/toolbar +updates, the startup cache, unchanged terminal identity and no Core page reload. +These source changes are newer than the `98e490d` installer and require a rebuild. Older Core versions keep the cached language. Full Core live +translation remains planned; its current text still changes on the next page load. | Order | Change | Effort | Required acceptance | | --- | --- | --- | --- | | 1 | Apply the saved Core language without reloading the page. Replace the fixed translator with a current-locale lookup and refresh labels from structured state. | Medium | Switch both ways with connected terminals, an active Agent grant, Files and floating windows; preserve input, selection, drafts and pending operations. | -| 2 | Make Desktop follow the current Core page's language and remove the separate Desktop language menu. | Medium | Validate the exact owned sender/frame/origin and the two supported locale values; rebuild menus and refresh toolbar, tray and diagnostics without restarting Core or recording. | -| 3 | Keep `language.json` as the last synchronized language for startup/setup/recovery before Core is available. | Small | Retain the cached value for older Core versions lacking synchronization; use English when no valid cache exists. Preserve the agreed installer common-language/English fallback. | +| 2 — Implemented | Make Desktop follow the current Core page's language and remove the separate Desktop language menu. | Medium | Validate the exact owned sender/frame/origin and the two supported locale values; rebuild menus and refresh toolbar, tray and diagnostics without restarting Core or recording. | +| 3 — Implemented | Keep `language.json` as the last synchronized language for startup/setup/recovery before Core is available. | Small | Retain the cached value for older Core versions lacking synchronization; use English when no valid cache exists. Preserve the agreed installer common-language/English fallback. | Core's setting remains scoped to its browser profile; this does not synchronize unrelated browsers, hosts or backend modes. A browser connection does not acquire @@ -69,15 +79,13 @@ scriptless diagnostics. A complete Desktop rollout has moderate implementation cost and broader acceptance cost than the toolbar pilot. The estimates above are relative scope assessments, not measured delivery times. -The shipped pilot stores the Desktop language in `language.json` under the existing profile's `userData`, -with English as default and only `en` / `zh-TW` initially. Apply a change on the -next launch; changing language should not itself restart StandTerm or stop a -recording. Keep the existing Core browser preference independent. This covers -setup and recovery before Core starts, at the cost of two language preferences. -It is a product choice, not a security requirement. A validated -two-value advisory preference from Core is also feasible, but needs startup, -origin and older-Core fallback rules. The follow-up above supersedes the independent -preference design once implemented. Automatic OS-language selection is deferred. +The independent Desktop language selector is removed. Core Settings is the +source of the saved preference; Desktop follows supported values and caches the +last one in `language.json` for pre-Core startup/setup/recovery. Missing or invalid +cache uses English. Poll results from navigated/replaced frames, loading pages, +foreign origins or closed windows are discarded. Cache-write failures keep the +active language and are logged once per changed value without repeatedly writing. +Automatic OS-language selection and full Core live translation remain deferred. Ship the Desktop catalog with the shell. Do not depend on the selected bundled or Git Core supplying compatible renderer scripts. Reuse the existing TSV @@ -109,8 +117,8 @@ Keep these implementation boundaries: [desktop_ui_copy_review.tsv](desktop_ui_copy_review.tsv) is a prioritized seed inventory, not a claim that every Desktop string has been extracted. It uses the same nine columns as the browser table. After the remaining shell notices, -301 rows are `translation-reviewed` with English and Traditional Chinese text. -Four retired Capture/setup fragments or renamed messages are marked `remove`; +293 rows are `translation-reviewed` with English and Traditional Chinese text. +Twelve retired Capture/setup/language-dialog messages are marked `remove`; no seed rows remain `proposed`. This does not claim translation of raw errors or the external installer UI. Some `current_en` cells are exact fragments or normalize dynamic values to named placeholders; `context` identifies these cases. diff --git a/templates/index.html b/templates/index.html index fb8076c..bad7905 100644 --- a/templates/index.html +++ b/templates/index.html @@ -11891,6 +11891,7 @@

Restore StandTerm access

const ready = !!(socket && socket.connected); return { version: 1, ready, terminalId: state?.id || null, + uiLanguage: loadPrefs().uiLanguage, actions: { settings: ready, newTab: ready, closeTab: ready && terminals.size > 1, From 5fe4b5230e07f434a219203666b894ea94813ea2 Mon Sep 17 00:00:00 2001 From: "ASKA C." Date: Mon, 21 Sep 2026 23:40:29 +0800 Subject: [PATCH 37/43] Draft eight-language interface translations --- docs/ui_locale_expansion.md | 9 +++++++++ docs/ui_locale_expansion.tsv | 28 ++++++++++++++++++++++++++++ 2 files changed, 37 insertions(+) create mode 100644 docs/ui_locale_expansion.md create mode 100644 docs/ui_locale_expansion.tsv diff --git a/docs/ui_locale_expansion.md b/docs/ui_locale_expansion.md new file mode 100644 index 0000000..2799133 --- /dev/null +++ b/docs/ui_locale_expansion.md @@ -0,0 +1,9 @@ +# UI locale expansion draft + +This draft compares the confirmed eight-language expansion for the Desktop surface and three Core connection strings: `en`, `zh-TW`, `zh-CN`, `ja`, `ko`, `de`, `fr`, and `es`. + +Use `ui_locale_expansion.tsv` as an exchange table for external AI review. Keep `key` stable, copy source English and Traditional Chinese exactly from the source TSVs, preserve product tokens such as Agent, Core, and StandTerm, and preserve every placeholder if rows with interpolation are added. Return proposed edits in the same TSV shape, with `status` changed only after human review; this file is a draft and is not runtime-approved. + +Tentative glossary: preserve the `Agent`, `Core`, and `StandTerm` tokens; `Prompt` is translatable UI terminology, not an invariant product token. Menu actions should stay concise and retain their ellipsis style. The source catalogs contain approximately 799 Core/UI review rows and 305 Desktop review rows; the current Desktop review has 293 translation-reviewed rows and 12 removed rows (counts are review coverage, not a promise of runtime coverage). + +Main risks are language-specific plural and gender rules, interpolation and placeholder ordering, and labels that expand enough to affect menu width, truncation, keyboard access, or native menu layout. External review should also check typography, punctuation, and whether product names or UI labels should remain self-named. diff --git a/docs/ui_locale_expansion.tsv b/docs/ui_locale_expansion.tsv new file mode 100644 index 0000000..af875c5 --- /dev/null +++ b/docs/ui_locale_expansion.tsv @@ -0,0 +1,28 @@ +key en zh-TW zh-CN ja ko de fr es status context +desktop.toolbar.application_menu Application menu 應用程式選單 应用程序菜单 アプリケーションメニュー 애플리케이션 메뉴 Anwendungsmenü Menu de l’application Menú de la aplicación draft Desktop navigation accessible name. +desktop.toolbar.clipboard_actions Clipboard actions 剪貼簿操作 剪贴板操作 クリップボード操作 클립보드 작업 Zwischenablageaktionen Actions du presse-papiers Acciones del portapapeles draft Desktop clipboard toolbar accessible name. +desktop.toolbar.copy_label Copy selected text 複製選取文字 复制所选文本 選択したテキストをコピー 선택한 텍스트 복사 Ausgewählten Text kopieren Copier le texte sélectionné Copiar texto seleccionado draft Native clipboard action. +desktop.toolbar.screenshot_copy Copy screenshot to clipboard 複製截圖至剪貼簿 将屏幕截图复制到剪贴板 スクリーンショットをクリップボードにコピー 스크린샷을 클립보드에 복사 Screenshot in die Zwischenablage kopieren Copier la capture d’écran dans le presse-papiers Copiar captura de pantalla al portapapeles draft Screenshot clipboard action. +desktop.menu.settings Settings... 設定… 设置… 設定… 설정… Einstellungen … Paramètres… Configuración… draft Desktop menu action. +desktop.menu.new_tab New terminal tab 新增終端分頁 新建终端标签页 新しいターミナルタブ 새 터미널 탭 Neuer Terminal-Tab Nouvel onglet de terminal Nueva pestaña de terminal draft Desktop menu action. +desktop.menu.close_tab Close terminal tab 關閉終端分頁 关闭终端标签页 ターミナルタブを閉じる 터미널 탭 닫기 Terminal-Tab schließen Fermer l’onglet de terminal Cerrar pestaña de terminal draft Desktop menu action. +desktop.menu.close_all Close all terminal tabs... 關閉所有終端分頁… 关闭所有终端标签页… すべてのターミナルタブを閉じる… 모든 터미널 탭 닫기… Alle Terminal-Tabs schließen … Fermer tous les onglets de terminal… Cerrar todas las pestañas de terminal… draft Desktop menu action. +desktop.menu.files Files... 檔案… 文件… ファイル… 파일… Dateien … Fichiers… Archivos… draft Desktop menu action. +desktop.menu.pip Open terminal in PiP 以子母畫面開啟終端 在画中画中打开终端 ピクチャーインピクチャーでターミナルを開く PiP로 터미널 열기 Terminal im Bild-im-Bild-Modus öffnen Ouvrir le terminal en incrustation Abrir terminal en PiP draft Desktop menu action. +desktop.menu.agent_panel Show / hide Agent Panel 顯示/隱藏 Agent 面板 显示/隐藏 Agent 面板 Agent パネルを表示/非表示 Agent 패널 표시/숨기기 Agent-Panel ein-/ausblenden Afficher/masquer le panneau Agent Mostrar/ocultar el panel del Agent draft Desktop menu action; preserve Agent token. +desktop.menu.pause_agent Pause Agent for current terminal 暫停目前終端的 Agent 暂停当前终端的 Agent 現在のターミナルの Agent を一時停止 현재 터미널의 Agent 일시 중지 Agent für das aktuelle Terminal pausieren Mettre l’Agent en pause pour le terminal actuel Pausar el Agent para el terminal actual draft Desktop menu action; preserve Agent token. +desktop.toolbar.menu_standterm StandTerm StandTerm StandTerm StandTerm StandTerm StandTerm StandTerm StandTerm draft Product name. +desktop.toolbar.menu_edit Edit 編輯 编辑 編集 편집 Bearbeiten Modifier Editar draft Desktop menu caption. +desktop.toolbar.menu_agent Agent Agent Agent Agent Agent Agent Agent Agent draft Agent token is preserved. +desktop.toolbar.menu_view View 檢視 查看 表示 보기 Ansicht Affichage Ver draft Desktop menu caption. +desktop.toolbar.menu_diagnostics Diagnostics 診斷 诊断 診断 진단 Diagnose Diagnostics Diagnóstico draft Desktop menu caption. +desktop.menu.open Open StandTerm 開啟 StandTerm 打开 StandTerm StandTerm を開く StandTerm 열기 StandTerm öffnen Ouvrir StandTerm Abrir StandTerm draft Product name preserved. +desktop.menu.show Show window 顯示視窗 显示窗口 ウィンドウを表示 창 표시 Fenster anzeigen Afficher la fenêtre Mostrar ventana draft Desktop tray menu action. +desktop.menu.quit Quit StandTerm 結束 StandTerm 退出 StandTerm StandTerm を終了 StandTerm 종료 StandTerm beenden Quitter StandTerm Salir de StandTerm draft Product name preserved. +desktop.menu.about About StandTerm Desktop 關於 StandTerm Desktop 关于 StandTerm Desktop StandTerm Desktop について StandTerm Desktop 정보 Über StandTerm Desktop À propos de StandTerm Desktop Acerca de StandTerm Desktop draft Product name preserved. +desktop.browser_access.menu Browser access 瀏覽器存取 浏览器访问 ブラウザーアクセス 브라우저 액세스 Browserzugriff Accès au navigateur Acceso del navegador draft Desktop submenu title. +desktop.capture.menu Capture 畫面擷取 捕获 キャプチャ 캡처 Aufnahme Capture Captura draft Desktop submenu title. +desktop.diagnostics.devtools_menu Developer Tools… 開發人員工具… 开发者工具… 開発者ツール… 개발자 도구… Entwicklertools … Outils de développement… Herramientas de desarrollo… draft Desktop menu action. +agent.connection.summary Copy the connection instructions and give them to the agent in the current tab’s environment. 複製連線指引,貼給目前分頁環境中的 Agent。 复制连接说明,并将其提供给当前标签页环境中的 Agent。 接続手順をコピーし、現在のタブの環境にいる Agent に渡します。 연결 지침을 복사해 현재 탭 환경의 Agent에게 전달합니다. Verbindungsanweisungen kopieren und dem Agent in der Umgebung des aktuellen Tabs geben. Copier les instructions de connexion et les transmettre à l’Agent dans l’environnement de l’onglet actuel. Copia las instrucciones de conexión y dáselas al Agent en el entorno de la pestaña actual. draft Core connection summary; preserve Agent token. +agent.connection.read_more Read more 閱讀更多 阅读更多 続きを読む 더 보기 Mehr erfahren En savoir plus Leer más draft Core common action. +agent.connection.copy Copy Prompt 複製連線指引 复制连接指引 接続手順をコピー 연결 지침 복사 Verbindungsanleitung kopieren Copier les instructions de connexion Copiar instrucciones de conexión draft Copies full connection instructions, including the environment and URL; not a generic hint. From b6bd3a98a199ead19db02927ae57094b940723e8 Mon Sep 17 00:00:00 2001 From: "ASKA C." Date: Tue, 22 Sep 2026 17:42:39 +0800 Subject: [PATCH 38/43] Make Agent Info URL the primary copy action --- docs/ui_copy_review.tsv | 4 ++-- docs/ui_locale_expansion.tsv | 2 +- static/js/standterm-messages.js | 8 ++++---- templates/index.html | 14 ++++++------- tests/agent_browser_smoke.py | 36 ++++++++++++++++++++------------- 5 files changed, 36 insertions(+), 28 deletions(-) diff --git a/docs/ui_copy_review.tsv b/docs/ui_copy_review.tsv index 76ee4d5..9aeb882 100644 --- a/docs/ui_copy_review.tsv +++ b/docs/ui_copy_review.tsv @@ -65,8 +65,8 @@ agent.token.tab_active Agent token active ({permission}); {seconds}s remaining w agent.token.tab_expired Agent token expired; create a new token to continue Agent token expired; create a new token to continue Agent 權杖已過期;請建立新權杖以繼續 Tab tooltip for an expired token. Plain text. Preserve named values, command names, and agreed Agent terminology. translation-reviewed templates/index.html agent.pause.action Pause Agent Pause Agent 暫停 Agent Pause Agent access; do not close or disconnect the terminal. Plain text. Preserve named values, command names, and agreed Agent terminology. translation-reviewed templates/index.html agent.pause.target Pause Agent access for {target} Pause Agent access for {target} 暫停 {target} 的 Agent 存取 Pause action tooltip naming its target. {target} Plain text. Preserve named values, command names, and agreed Agent terminology. translation-reviewed templates/index.html -agent.connection.tooltip Copy the agent prompt for the current tab's environment Copy the agent prompt for the current tab's environment 複製目前分頁所在環境的 Agent 連線指引 The active tab selects the execution environment, not the grant scope. Plain text. Preserve named values, command names, and agreed Agent terminology. translation-reviewed templates/index.html -agent.connection.summary Copy the connection instructions and give them to the agent in the current tab’s environment. Copy the connection instructions and give them to the agent in the current tab’s environment. 複製連線指引,貼給目前分頁環境中的 Agent。 Compact local or SSH connection introduction; the adjacent button copies the full prompt, including the URL and execution environment. Copying does not authorize tabs. Full instructions remain in Read more. translation-reviewed templates/index.html +agent.connection.tooltip View Agent connection info for the current tab's environment View Agent connection info for the current tab's environment 檢視目前分頁環境的 Agent 連線資訊 The active tab selects the execution environment, not the grant scope. Plain text. Preserve named values, command names, and agreed Agent terminology. translation-reviewed templates/index.html +agent.connection.summary Copy the Agent Info URL and give it to the agent in the current tab’s environment. Copy the Agent Info URL and give it to the agent in the current tab’s environment. 複製 Agent 資訊網址,貼給目前分頁環境中的 Agent。 Compact local or SSH connection introduction; the adjacent button copies only the Agent Info URL. Copying does not authorize tabs. Full instructions remain in Read more. translation-reviewed templates/index.html agent.connection.read_more Read more Read more 閱讀更多 Native disclosure for connection instructions, activity and secondary actions. Keep keyboard-accessible expand and collapse behavior. translation-reviewed templates/index.html agent.connection.environment Run the agent where Core runs (WSL if Core runs in WSL). It can access only individually authorized tabs. Run the agent where Core runs (WSL if Core runs in WSL). It can access only individually authorized tabs. 請在 Core 所在環境執行 Agent(若 Core 在 WSL 中執行,Agent 也須在 WSL 中執行)。Agent 僅能存取已個別授權的分頁。 Core-host connection info; distinguish Windows browser from WSL Core. Plain text. Preserve named values, command names, and agreed Agent terminology. translation-reviewed templates/index.html agent.connection.step_authorize Choose Authorize agent on each intended tab. Change its permission in Settings or the Agent panel. Choose the default permission in Settings, then Authorize agent on each intended tab. Adjust an authorized tab in the Agent panel. 先在 Settings 選擇預設權限,再於各個要開放存取的分頁選擇「授權 Agent」。已授權分頁的權限可在 Agent 面板調整。 Local connection setup step; the primary action creates the token. Plain text. Preserve named values, command names, and agreed Agent terminology. translation-reviewed templates/index.html diff --git a/docs/ui_locale_expansion.tsv b/docs/ui_locale_expansion.tsv index af875c5..d1c5185 100644 --- a/docs/ui_locale_expansion.tsv +++ b/docs/ui_locale_expansion.tsv @@ -23,6 +23,6 @@ desktop.menu.about About StandTerm Desktop 關於 StandTerm Desktop 关于 Stand desktop.browser_access.menu Browser access 瀏覽器存取 浏览器访问 ブラウザーアクセス 브라우저 액세스 Browserzugriff Accès au navigateur Acceso del navegador draft Desktop submenu title. desktop.capture.menu Capture 畫面擷取 捕获 キャプチャ 캡처 Aufnahme Capture Captura draft Desktop submenu title. desktop.diagnostics.devtools_menu Developer Tools… 開發人員工具… 开发者工具… 開発者ツール… 개발자 도구… Entwicklertools … Outils de développement… Herramientas de desarrollo… draft Desktop menu action. -agent.connection.summary Copy the connection instructions and give them to the agent in the current tab’s environment. 複製連線指引,貼給目前分頁環境中的 Agent。 复制连接说明,并将其提供给当前标签页环境中的 Agent。 接続手順をコピーし、現在のタブの環境にいる Agent に渡します。 연결 지침을 복사해 현재 탭 환경의 Agent에게 전달합니다. Verbindungsanweisungen kopieren und dem Agent in der Umgebung des aktuellen Tabs geben. Copier les instructions de connexion et les transmettre à l’Agent dans l’environnement de l’onglet actuel. Copia las instrucciones de conexión y dáselas al Agent en el entorno de la pestaña actual. draft Core connection summary; preserve Agent token. +agent.connection.summary Copy the Agent Info URL and give it to the agent in the current tab’s environment. 複製 Agent 資訊網址,貼給目前分頁環境中的 Agent。 复制 Agent 信息网址,提供给当前标签页环境中的 Agent。 Agent 情報 URL をコピーし、現在のタブの環境で実行している Agent に渡します。 Agent 정보 URL을 복사해 현재 탭 환경의 Agent에게 전달합니다. Agent-Info-URL kopieren und dem Agent in der Umgebung des aktuellen Tabs geben. Copier l’URL des informations Agent et la transmettre à l’Agent dans l’environnement de l’onglet actuel. Copia la URL de información de Agent y dásela al Agent en el entorno de la pestaña actual. draft Primary action copies only the Agent Info URL; preserve Agent token. agent.connection.read_more Read more 閱讀更多 阅读更多 続きを読む 더 보기 Mehr erfahren En savoir plus Leer más draft Core common action. agent.connection.copy Copy Prompt 複製連線指引 复制连接指引 接続手順をコピー 연결 지침 복사 Verbindungsanleitung kopieren Copier les instructions de connexion Copiar instrucciones de conexión draft Copies full connection instructions, including the environment and URL; not a generic hint. diff --git a/static/js/standterm-messages.js b/static/js/standterm-messages.js index e762d48..bca6278 100644 --- a/static/js/standterm-messages.js +++ b/static/js/standterm-messages.js @@ -36,9 +36,9 @@ "agent.connection.step_authorize": "Choose the default permission in Settings, then Authorize agent on each intended tab. Adjust an authorized tab in the Agent panel.", "agent.connection.step_confirm": "Run discover, then hello. Authenticated requests appear below.", "agent.connection.step_copy": "Copy the prompt to the agent running on the Core host.", - "agent.connection.summary": "Copy the connection instructions and give them to the agent in the current tab\u2019s environment.", + "agent.connection.summary": "Copy the Agent Info URL and give it to the agent in the current tab\u2019s environment.", "agent.connection.title": "Agent connection", - "agent.connection.tooltip": "Copy the agent prompt for the current tab's environment", + "agent.connection.tooltip": "View Agent connection info for the current tab's environment", "agent.connection.unavailable": "Connection info unavailable.", "agent.connection.url": "Agent Info URL", "agent.connection.waiting": "waiting for agent", @@ -836,9 +836,9 @@ "agent.connection.step_authorize": "\u5148\u5728 Settings \u9078\u64c7\u9810\u8a2d\u6b0a\u9650\uff0c\u518d\u65bc\u5404\u500b\u8981\u958b\u653e\u5b58\u53d6\u7684\u5206\u9801\u9078\u64c7\u300c\u6388\u6b0a Agent\u300d\u3002\u5df2\u6388\u6b0a\u5206\u9801\u7684\u6b0a\u9650\u53ef\u5728 Agent \u9762\u677f\u8abf\u6574\u3002", "agent.connection.step_confirm": "\u5148\u57f7\u884c discover\uff0c\u518d\u57f7\u884c hello\u3002\u5df2\u901a\u904e\u9a57\u8b49\u7684\u8acb\u6c42\u6703\u986f\u793a\u5728\u4e0b\u65b9\u3002", "agent.connection.step_copy": "\u5c07\u9023\u7dda\u6307\u5f15\u8907\u88fd\u7d66\u5728 Core \u4e3b\u6a5f\u4e0a\u57f7\u884c\u7684 Agent\u3002", - "agent.connection.summary": "\u8907\u88fd\u9023\u7dda\u6307\u5f15\uff0c\u8cbc\u7d66\u76ee\u524d\u5206\u9801\u74b0\u5883\u4e2d\u7684 Agent\u3002", + "agent.connection.summary": "\u8907\u88fd Agent \u8cc7\u8a0a\u7db2\u5740\uff0c\u8cbc\u7d66\u76ee\u524d\u5206\u9801\u74b0\u5883\u4e2d\u7684 Agent\u3002", "agent.connection.title": "Agent \u9023\u7dda", - "agent.connection.tooltip": "\u8907\u88fd\u76ee\u524d\u5206\u9801\u6240\u5728\u74b0\u5883\u7684 Agent \u9023\u7dda\u6307\u5f15", + "agent.connection.tooltip": "\u6aa2\u8996\u76ee\u524d\u5206\u9801\u74b0\u5883\u7684 Agent \u9023\u7dda\u8cc7\u8a0a", "agent.connection.unavailable": "\u7121\u6cd5\u53d6\u5f97\u9023\u7dda\u8cc7\u8a0a\u3002", "agent.connection.url": "Agent \u8cc7\u8a0a\u7db2\u5740", "agent.connection.waiting": "\u7b49\u5f85 Agent", diff --git a/templates/index.html b/templates/index.html index bad7905..e1f3600 100644 --- a/templates/index.html +++ b/templates/index.html @@ -1458,7 +1458,7 @@

Manual brow
- + @@ -1481,11 +1481,11 @@

Manual brow

Agent connection

-

Copy the connection instructions and give them to the agent in the current tab’s environment.

+

Copy the Agent Info URL and give it to the agent in the current tab’s environment.

- +

@@ -1501,7 +1501,7 @@

Agent connection
- +

@@ -1510,12 +1510,12 @@

Agent connection

Agent Tunnel

- +

@@ -1538,7 +1538,7 @@

Agent Tunnel

- + diff --git a/tests/agent_browser_smoke.py b/tests/agent_browser_smoke.py index a179866..1c82342 100644 --- a/tests/agent_browser_smoke.py +++ b/tests/agent_browser_smoke.py @@ -888,17 +888,18 @@ def test_agent_language_preview_preserves_access_and_applies_on_next_page(browse configurable: true, value: {writeText: async text => {window.copiedAgentText = text;}} })''') page.click('#agent-connect-btn') - page.wait_for_selector('#agent-connect-copy:not([disabled])') + page.wait_for_selector('#agent-connect-copy-url:not([disabled])') check(page.get_by_role('dialog', name='Agent 連線').is_visible(), 'connection dialog accessible name was not localized') check(page.locator('#agent-connect-url').get_attribute('aria-label') == 'Agent 資訊網址', 'connection URL accessible name was not localized') check(page.locator('#agent-connect-info').get_attribute('aria-label') == 'Agent 連線指引', 'connection prompt accessible name was not localized') - check(page.inner_text('#agent-connect-copy') == '複製連線指引', 'connection copy action was not localized') check('main: 等待 Agent' in page.text_content('#agent-connect-activity'), 'localized connection activity did not distinguish a grant from Agent activity') prompt = page.input_value('#agent-connect-info') check('Run discover, then hello' in prompt, 'display language translated the machine-facing connection prompt') + page.click('#agent-connect-details summary') + check(page.inner_text('#agent-connect-copy') == '複製連線指引', 'connection copy action was not localized') page.click('#agent-connect-copy') page.wait_for_function('text => window.copiedAgentText === text', arg=prompt) check('已複製' in page.inner_text('#agent-connect-message'), 'clipboard result was not localized') @@ -4497,10 +4498,10 @@ def test_core_agent_connect_info_can_be_copied_and_confirmed(browser, access_url configurable: true, value: {writeText: async text => {window.copiedAgentText = text;}} })''') page.click('#agent-connect-btn') - page.wait_for_selector('#agent-connect-copy:not([disabled])') + page.wait_for_selector('#agent-connect-copy-url:not([disabled])') check(not page.locator('#agent-connect-details').evaluate('element => element.open'), 'Connection details were not collapsed by default') - for selector in ['#agent-connect-info', '#agent-connect-activity', '#agent-connect-copy-url', + for selector in ['#agent-connect-info', '#agent-connect-activity', '#agent-connect-copy', '#agent-connect-refresh', '#agent-connect-open-panel']: check(page.locator(selector).is_hidden(), 'Secondary connection content remained visible: ' + selector) check(page.locator('#agent-connect-message').is_hidden(), 'Ready state repeated the introduction') @@ -4514,14 +4515,14 @@ def test_core_agent_connect_info_can_be_copied_and_confirmed(browser, access_url check(page.locator('#agent-tunnel-btn').is_hidden(), 'Local shell offered an SSH tunnel') check(page.locator('#agent-remote-info-btn').count() == 0, 'A separate remote Agent Info button remains') check(page.inner_text('#agent-connect-btn') == ('Agent 連線' if zh else 'Agent connection'), 'Info button did not identify the Agent connection workflow') - check(page.inner_text('#agent-connect-copy') == ('複製連線指引' if zh else 'Copy Prompt'), 'Local info did not offer a prompt') - page.click('#agent-connect-copy') - page.wait_for_function('text => window.copiedAgentText === text', arg=info_text) + page.click('#agent-connect-copy-url') + page.wait_for_function('url => window.copiedAgentText === url', arg=agentinfo_url) page.locator('#agent-connect-details summary').focus() page.keyboard.press('Enter') check(page.locator('#agent-connect-info').is_visible(), 'Keyboard disclosure did not reveal the prompt') - page.click('#agent-connect-copy-url') - page.wait_for_function('url => window.copiedAgentText === url', arg=agentinfo_url) + check(page.inner_text('#agent-connect-copy') == ('複製連線指引' if zh else 'Copy Prompt'), 'Local info did not offer a prompt') + page.click('#agent-connect-copy') + page.wait_for_function('text => window.copiedAgentText === text', arg=info_text) page.focus('#agent-connect-url') page.evaluate("() => { window.dispatchEvent(new Event('blur')); window.dispatchEvent(new Event('focus')); }") page.wait_for_timeout(150) @@ -4550,11 +4551,14 @@ def test_core_agent_connect_info_can_be_copied_and_confirmed(browser, access_url arg='最近一次通過驗證的請求' if zh else 'last authenticated request') page.click('#agent-connect-details summary') page.evaluate("() => { navigator.clipboard.writeText = async () => { throw new Error('Denied'); }; }") - page.click('#agent-connect-copy') + page.click('#agent-connect-copy-url') page.wait_for_function("text => document.getElementById('agent-connect-message').innerText.includes(text)", arg='手動複製' if zh else 'copy it manually') check(page.locator('#agent-connect-message').is_visible(), 'Copy failure was hidden in collapsed details') - check(page.locator('#agent-connect-info').is_visible(), 'Copy failure did not reveal the selected prompt') + selection = page.locator('#agent-connect-url').evaluate('field => field.value.slice(field.selectionStart, field.selectionEnd)') + check(selection == agentinfo_url, 'Clipboard fallback did not select the URL') + page.click('#agent-connect-details summary') + page.click('#agent-connect-copy') selection = page.locator('#agent-connect-info').evaluate('field => field.value.slice(field.selectionStart, field.selectionEnd)') check(selection == page.input_value('#agent-connect-info'), 'Clipboard fallback did not select the prompt') page.click('#agent-connect-close') @@ -4687,15 +4691,18 @@ def ready(carrier, port): check(page.inner_text('#agent-tunnel-title') == title, 'Remote shortcut opened the wrong view') check(page.locator('#agent-tunnel-setup').is_hidden(), 'Remote info repeated setup controls') check(page.locator('#agent-tunnel-copy').is_hidden(), 'Remote shortcut offered a stale cached prompt') + check(page.locator('#agent-tunnel-copy-url').is_disabled(), 'Remote shortcut offered a stale cached URL') page.evaluate('payload => window.terminalTest.completeAgentTunnelRequestForTest(1, payload)', first) check(page.locator('#agent-tunnel-info').is_hidden(), 'Remote info did not collapse the full prompt') for selector in ['#agent-tunnel-manage', '#agent-tunnel-refresh', '#agent-tunnel-check', - '#agent-tunnel-copy-url', '#agent-tunnel-carrier']: + '#agent-tunnel-copy', '#agent-tunnel-carrier']: check(page.locator(selector).is_hidden(), 'Remote secondary content remained visible: ' + selector) check(page.locator('#agent-tunnel-message').is_hidden(), 'Remote ready state repeated the introduction') + page.click('#agent-tunnel-copy-url') + page.wait_for_function('url => window.copiedAgentText === url', arg=first['agentinfo_url']) + page.click('#agent-tunnel-details summary') page.click('#agent-tunnel-copy') page.wait_for_function('text => window.copiedAgentText === text', arg=prompt) - page.click('#agent-tunnel-details summary') page.click('#agent-tunnel-manage') check(page.locator('#agent-tunnel-setup').is_visible(), 'Manage did not reveal tunnel controls') page.click('#agent-tunnel-refresh') @@ -4710,6 +4717,7 @@ def ready(carrier, port): check(page.input_value('#agent-tunnel-info') == second['connect_info'], 'Old carrier stop cleared the new tunnel') page.evaluate("() => window.terminalTest.applyAgentTunnelStateForTest({terminal_id: 'second', carrier_id: 'host-b', status: 'stopped'})") check(page.locator('#agent-tunnel-copy').is_hidden(), 'Stop retained a usable prompt') + check(page.locator('#agent-tunnel-copy-url').is_disabled(), 'Stop retained a usable URL') check(page.locator('#agent-connect-btn').is_hidden(), 'Stop retained the remote shortcut') page.click('#agent-tunnel-apply') page.evaluate('''() => window.terminalTest.applyTerminalListForTest({terminals: [ @@ -4727,7 +4735,7 @@ def ready(carrier, port): page.click('.terminal-tab[data-terminal-id="main"]') check(page.locator('#agent-connect-btn').is_visible(), 'Returning to a local tab did not restore Agent Info') page.click('#agent-connect-btn') - page.wait_for_selector('#agent-connect-copy:not([disabled])') + page.wait_for_selector('#agent-connect-copy-url:not([disabled])') check(page.locator('#agent-tunnel-dialog').is_hidden(), 'Local tab opened SSH info') check('Core host environment' in page.input_value('#agent-connect-info'), 'Local tab retained the SSH prompt') page.click('#agent-connect-close') From a51cf5679f660b7e9001b59f1198f2665dc9a586 Mon Sep 17 00:00:00 2001 From: "ASKA C." Date: Wed, 23 Sep 2026 21:47:08 +0800 Subject: [PATCH 39/43] Show startup feedback and restore window state --- desktop/README.md | 11 +++ desktop/diagnostics.cjs | 2 +- desktop/main.cjs | 35 +++++++-- desktop/messages.js | 2 + desktop/stage-windows.cjs | 1 + desktop/startup-window.cjs | 48 ++++++++++++ desktop/test/startup-window-smoke.cjs | 85 ++++++++++++++++++++ desktop/test/startup-window.test.cjs | 99 ++++++++++++++++++++++++ desktop/test/window-state.test.cjs | 107 ++++++++++++++++++++++++++ desktop/window-state.cjs | 81 +++++++++++++++++++ docs/desktop_ui_copy_review.tsv | 1 + 11 files changed, 465 insertions(+), 7 deletions(-) create mode 100644 desktop/startup-window.cjs create mode 100644 desktop/test/startup-window-smoke.cjs create mode 100644 desktop/test/startup-window.test.cjs create mode 100644 desktop/test/window-state.test.cjs create mode 100644 desktop/window-state.cjs diff --git a/desktop/README.md b/desktop/README.md index e57b8da..a948394 100644 --- a/desktop/README.md +++ b/desktop/README.md @@ -18,6 +18,17 @@ its existing next-load language behavior. Open native confirmations keep their original labels; subsequent dialogs use the synchronized language. Diagnostics updates when opened or refreshed. +Desktop remembers the main window's normal position, size and maximized state +in `window-.json` in its profile. Windows and WSL modes keep separate state. +Restored bounds fit the current display's work area; a removed display falls back +to the primary display. Minimized, hidden and fullscreen states are not restored. +Invalid state or a failed write does not prevent startup or shutdown. + +A small startup window appears before Core preparation or WSL startup begins. +It uses the cached Desktop language and closes when the main window is ready or +startup fails. Launching the same mode again focuses startup or its setup window +while Core is still starting. + Desktop caches the last synchronized language in the mode profile's `language.json` for startup, setup and recovery before Core is ready. Older Core versions without the language snapshot field retain that cache; missing or invalid cache uses English. diff --git a/desktop/diagnostics.cjs b/desktop/diagnostics.cjs index a108f1d..3ef5963 100644 --- a/desktop/diagnostics.cjs +++ b/desktop/diagnostics.cjs @@ -5,7 +5,7 @@ const path = require('node:path'); const { create } = require('./i18n.js'); const EVENTS = new Set(['startup', 'setup_start', 'setup_ready', 'backend_launch', 'backend_ready', 'backend_exit', 'backend_spawn_failed', 'backend_verify_retry', 'backend_verified', - 'host_port_rejected', 'port_change', 'window_ready', 'startup_failed', 'core_failed', 'shutdown', 'devtools_opened', 'capture_failed']); + 'host_port_rejected', 'port_change', 'window_ready', 'window_state_save_failed', 'startup_failed', 'core_failed', 'shutdown', 'devtools_opened', 'capture_failed']); const CODES = new Set(['EACCES', 'EADDRINUSE', 'ECONNREFUSED', 'ECONNRESET', 'ETIMEDOUT', 'ENOENT', 'EPIPE', 'HOST_PORT_UNAVAILABLE', 'PORT_IN_USE', 'SETUP_CANCELED', 'git_required', 'git_dirty', 'git_diverged', 'git_source_changed', 'invalid_git_workspace', diff --git a/desktop/main.cjs b/desktop/main.cjs index 4f01d72..ac8dff3 100644 --- a/desktop/main.cjs +++ b/desktop/main.cjs @@ -1,6 +1,6 @@ 'use strict'; -const { app, BrowserWindow, WebContentsView, Menu, Tray, nativeImage, dialog, session, shell, clipboard } = require('electron'); +const { app, BrowserWindow, WebContentsView, Menu, Tray, nativeImage, dialog, session, shell, clipboard, screen } = require('electron'); const { spawn } = require('node:child_process'); const http = require('node:http'); const path = require('node:path'); @@ -26,6 +26,8 @@ const { installToolbar } = require('./toolbar.cjs'); const { createBrowserAccess } = require('./browser-access.cjs'); const { installContextPaste } = require('./context-paste.cjs'); const { createLanguage } = require('./language.cjs'); +const { loadWindowState, trackWindowState } = require('./window-state.cjs'); +const { createStartupWindow } = require('./startup-window.cjs'); let maintenance; try { maintenance = installerRequest(process.argv); } catch (error) { @@ -85,6 +87,8 @@ let child; let desktopSession; const expectedBackendExits = new WeakSet(); let win; +let windowState; +let startupWindow; let tray; let capture; let captureTitle = ''; @@ -103,12 +107,18 @@ const diagnostics = createDiagnostics(path.join(app.getPath('userData'), 'diagno }); function showWindow() { + if (booting && startupWindow) { startupWindow.focus(); focusSetup(); return; } if (!win || win.isDestroyed()) { focusSetup(); return; } if (win.isMinimized()) win.restore(); win.show(); win.focus(); } +function closeStartupWindow() { + startupWindow?.close(); + startupWindow = null; +} + function launchBackend(preparedCommand, port = 0) { diagnostics.write('backend_launch', { port }); const root = smoke && process.env.STANDTERM_DESKTOP_TEST_ROOT @@ -245,6 +255,9 @@ function updateTrayMenu() { } async function start() { + startupWindow = createStartupWindow(MODES[mode], language); + await startupWindow.ready; + if (quitting) return; diagnostics.write('setup_start'); if (app.isPackaged && !smoke) { coreStore = await installedStore(app.getPath('userData'), process.resourcesPath, app.getVersion()); @@ -314,9 +327,10 @@ async function start() { if (!smoke) { try { icon = createTray(); } catch { tray = null; } } + const windowStatePath = path.join(app.getPath('userData'), `window-${mode}.json`); + const savedWindow = loadWindowState(windowStatePath, screen); win = new BrowserWindow({ - title: MODES[mode], width: 1280, height: 850, - minWidth: 640, minHeight: 480, show: false, icon, + title: MODES[mode], ...savedWindow.options, show: false, icon, webPreferences: { partition: 'standterm-desktop-toolbar', preload: path.join(__dirname, 'toolbar-preload.cjs'), // The trusted status strip must keep timers/notices current when unfocused. @@ -327,6 +341,8 @@ async function start() { allowRunningInsecureContent: false, devTools: true, }, }); + windowState = trackWindowState(win, windowStatePath, savedWindow, + () => diagnostics.write('window_state_save_failed')); const coreView = new WebContentsView({ webPreferences: { session: desktopSession, nodeIntegration: false, contextIsolation: true, sandbox: true, webSecurity: true, webviewTag: false, allowRunningInsecureContent: false, devTools: true, @@ -523,10 +539,15 @@ async function start() { await contents.loadURL(`${handoff.origin}/${smoke ? '?debug=1' : ''}`); if (child.exitCode !== null || child.signalCode !== null) throw new Error('The owned Core exited during startup.'); diagnostics.write('window_ready', { port: Number(new URL(handoff.origin).port) }); + // Apply outer bounds after native frame/menu initialization, before maximizing. + const { x, y, width, height } = savedWindow.options; + win.setBounds({ x, y, width, height }); + if (savedWindow.maximized) win.maximize(); booting = false; + showWindow(); + closeStartupWindow(); if (smoke) { // WebContentsView visibility follows its owner; exercise a real visible UI. - showWindow(); contents.focus(); await require('./smoke.cjs').run(win, handoff.origin, contents, browserAccess); if (captureSmoke) { @@ -535,8 +556,6 @@ async function start() { } console.log('Desktop smoke: authenticated terminal, sandbox and navigation checks passed.'); app.quit(); - } else { - showWindow(); } } @@ -547,6 +566,7 @@ async function stopBackend() { async function handleCoreFailure(error) { if (failurePending || quitting) return; + closeStartupWindow(); failurePending = true; booting = true; diagnostics.write('core_failed', { code: error.code }); @@ -590,6 +610,8 @@ app.on('before-quit', event => { if (!allowed) { restartRequest = null; quitting = false; return; } } cancelSetup(); + windowState?.save(); + closeStartupWindow(); await finishBackendAndBrowser(); if (restartRequest) { if (restartRequest.action) await coreStore.queue(restartRequest.action); @@ -613,6 +635,7 @@ if (!app.requestSingleInstanceLock()) { diagnostics.write('startup'); app.on('second-instance', showWindow); app.whenReady().then(start).catch(error => { + closeStartupWindow(); diagnostics.write('startup_failed', { code: error.code }); if (error.code === 'SETUP_CANCELED') { app.quit(); return; } void handleCoreFailure(error); diff --git a/desktop/messages.js b/desktop/messages.js index 86cc310..be2552a 100644 --- a/desktop/messages.js +++ b/desktop/messages.js @@ -277,6 +277,7 @@ "desktop.startup.recovery_failed": "StandTerm Desktop could not recover", "desktop.startup.shutdown_failed": "StandTerm could not complete shutdown", "desktop.startup.start_failed": "StandTerm Desktop could not start", + "desktop.startup.starting": "Starting Core...", "desktop.toolbar.action_unavailable": "Action unavailable in the current window state.", "desktop.toolbar.action_unconfirmed": "Could not confirm the action result. Check the current state.", "desktop.toolbar.application_menu": "Application menu", @@ -572,6 +573,7 @@ "desktop.startup.recovery_failed": "StandTerm Desktop \u7121\u6cd5\u5fa9\u539f", "desktop.startup.shutdown_failed": "StandTerm \u7121\u6cd5\u5b8c\u6210\u7d50\u675f\u7a0b\u5e8f", "desktop.startup.start_failed": "StandTerm Desktop \u7121\u6cd5\u555f\u52d5", + "desktop.startup.starting": "\u6b63\u5728\u555f\u52d5 Core\u2026", "desktop.toolbar.action_unavailable": "\u76ee\u524d\u7684\u8996\u7a97\u72c0\u614b\u7121\u6cd5\u57f7\u884c\u6b64\u64cd\u4f5c\u3002", "desktop.toolbar.action_unconfirmed": "\u7121\u6cd5\u78ba\u8a8d\u64cd\u4f5c\u7d50\u679c\u3002\u8acb\u6aa2\u67e5\u76ee\u524d\u72c0\u614b\u3002", "desktop.toolbar.application_menu": "\u61c9\u7528\u7a0b\u5f0f\u9078\u55ae", diff --git a/desktop/stage-windows.cjs b/desktop/stage-windows.cjs index 55c6b7b..e06fd6a 100644 --- a/desktop/stage-windows.cjs +++ b/desktop/stage-windows.cjs @@ -36,6 +36,7 @@ const shellFiles = [ 'capture-settings.cjs', 'ui-commands.cjs', 'toolbar.cjs', 'toolbar-preload.cjs', 'toolbar.html', 'toolbar.js', 'toolbar.css', 'language.cjs', 'messages.js', 'i18n.js', + 'window-state.cjs', 'startup-window.cjs', 'test/toolbar-smoke.cjs', 'browser-access.cjs', 'context-paste.cjs', diff --git a/desktop/startup-window.cjs b/desktop/startup-window.cjs new file mode 100644 index 0000000..6d3f521 --- /dev/null +++ b/desktop/startup-window.cjs @@ -0,0 +1,48 @@ +'use strict'; + +const { create, normalizeLocale } = require('./i18n.js'); + +function startupHtml(title, { locale, t } = create('en')) { + const escape = value => String(value).replace(/[&<>"']/g, char => ({ + '&': '&', '<': '<', '>': '>', '"': '"', "'": ''', + })[char]); + return ` + + ${escape(title)} + +

${escape(title)}

${escape(t('desktop.startup.starting'))}

+ `; +} + +function createStartupWindow(title, i18n) { + const { BrowserWindow, session } = require('electron'); + const url = 'data:text/html,' + encodeURIComponent(startupHtml(title, i18n)); + const isolated = session.fromPartition('standterm-startup', { cache: false }); + isolated.setPermissionRequestHandler((_contents, _permission, callback) => callback(false)); + isolated.setPermissionCheckHandler(() => false); + isolated.setDevicePermissionHandler(() => false); + isolated.webRequest.onBeforeRequest((details, callback) => callback({ cancel: details.url !== url })); + const win = new BrowserWindow({ title, width: 440, height: 210, show: true, + backgroundColor: '#1e1e1e', resizable: false, maximizable: false, fullscreenable: false, + closable: false, autoHideMenuBar: true, + webPreferences: { session: isolated, sandbox: true, contextIsolation: true, + nodeIntegration: false, webviewTag: false, devTools: false } }); + win.setMenu(null); + win.webContents.setWindowOpenHandler(() => ({ action: 'deny' })); + for (const event of ['will-navigate', 'will-frame-navigate', 'will-redirect', 'will-attach-webview']) { + win.webContents.on(event, event => event.preventDefault()); + } + return { + ready: win.loadURL(url), + focus() { + if (win.isDestroyed()) return; + if (win.isMinimized()) win.restore(); + win.show(); + win.focus(); + }, + close() { if (!win.isDestroyed()) win.destroy(); }, + }; +} + +module.exports = { startupHtml, createStartupWindow }; diff --git a/desktop/test/startup-window-smoke.cjs b/desktop/test/startup-window-smoke.cjs new file mode 100644 index 0000000..491e890 --- /dev/null +++ b/desktop/test/startup-window-smoke.cjs @@ -0,0 +1,85 @@ +'use strict'; + +// Run with native Electron. Uses a disposable profile and never starts Core. +const { app, BrowserWindow, screen } = require('electron'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const os = require('node:os'); +const { create } = require('../i18n.js'); +const { createStartupWindow } = require('../startup-window.cjs'); +const { loadWindowState, trackWindowState } = require('../window-state.cjs'); + +app.enableSandbox(); +if (process.platform === 'win32') app.commandLine.appendSwitch('disable-features', 'CalculateNativeWinOcclusion'); +const profile = fs.mkdtempSync(path.join(os.tmpdir(), 'standterm-startup-smoke-')); +app.setPath('userData', profile); +app.on('window-all-closed', () => {}); +const delay = ms => new Promise(resolve => setTimeout(resolve, ms)); +async function until(check) { + const deadline = Date.now() + 5000; + while (!check()) { + if (Date.now() >= deadline) throw new Error('Window transition timed out.'); + await delay(50); + } +} +setTimeout(() => { console.error('Startup window smoke timed out.'); app.exit(1); }, 30000).unref(); +app.whenReady().then(async () => { + for (const locale of ['en', 'zh-TW']) { + console.log(`Checking startup window: ${locale}`); + const splash = createStartupWindow('StandTerm Desktop (WSL)', create(locale)); + await splash.ready; + const win = BrowserWindow.getAllWindows()[0]; + assert.ok(win.isVisible()); + assert.equal(await win.webContents.executeJavaScript('document.documentElement.lang'), locale); + assert.equal(await win.webContents.executeJavaScript('document.querySelector("[role=status]").textContent'), + create(locale).t('desktop.startup.starting')); + win.minimize(); await until(() => win.isMinimized()); + splash.focus(); await until(() => !win.isMinimized() && win.isVisible()); + if (process.env.STANDTERM_STARTUP_SMOKE_OUTPUT) { + const output = process.env.STANDTERM_STARTUP_SMOKE_OUTPUT; + fs.mkdirSync(output, { recursive: true }); + await delay(200); + fs.writeFileSync(path.join(output, `startup-${locale}.png`), (await win.webContents.capturePage()).toPNG()); + } + splash.close(); splash.close(); + assert.ok(win.isDestroyed()); + } + const file = path.join(profile, 'window-wsl.json'); + console.log('Checking native window state transitions.'); + let initial = loadWindowState(file, screen); + let win = new BrowserWindow({ ...initial.options, show: false }); + win.setMenu(null); + let tracker = trackWindowState(win, file, initial); + await win.loadURL('data:text/html,Window state smoke'); + win.show(); + win.setBounds({ x: initial.options.x, y: initial.options.y, width: 800, height: 600 }); + await delay(300); + const normal = win.getNormalBounds(); + win.maximize(); await until(() => win.isMaximized()); await delay(300); + tracker.save(); + assert.deepEqual(JSON.parse(fs.readFileSync(file)), { version: 1, bounds: normal, maximized: true }); + win.minimize(); await until(() => win.isMinimized()); await delay(300); + tracker.save(); + assert.equal(JSON.parse(fs.readFileSync(file)).maximized, true); + win.destroy(); + initial = loadWindowState(file, screen); + win = new BrowserWindow({ ...initial.options, show: false }); + win.setMenu(null); + tracker = trackWindowState(win, file, initial); + await win.loadURL('data:text/html,Restored window smoke'); + win.setBounds({ x: initial.options.x, y: initial.options.y, width: initial.options.width, height: initial.options.height }); + if (initial.maximized) win.maximize(); + win.show(); await until(() => win.isMaximized()); + win.unmaximize(); await until(() => !win.isMaximized()); await delay(300); + for (const key of ['x', 'y', 'width', 'height']) { + assert.ok(Math.abs(win.getNormalBounds()[key] - normal[key]) <= 1, `Restored ${key} drifted beyond native rounding.`); + } + tracker.save(); + assert.equal(JSON.parse(fs.readFileSync(file)).maximized, false); + assert.deepEqual(JSON.parse(fs.readFileSync(file)).bounds, normal); + win.hide(); tracker.save(); win.destroy(); + assert.equal(loadWindowState(path.join(profile, 'window-windows.json'), screen).maximized, false); + console.log('Startup/window smoke passed: bilingual splash, focus, bounds, maximize/minimize, reopen and profile isolation.'); + app.exit(0); +}).catch(error => { console.error(error.stack); app.exit(1); }); diff --git a/desktop/test/startup-window.test.cjs b/desktop/test/startup-window.test.cjs new file mode 100644 index 0000000..0ee95ec --- /dev/null +++ b/desktop/test/startup-window.test.cjs @@ -0,0 +1,99 @@ +'use strict'; + +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const vm = require('node:vm'); +const { EventEmitter } = require('node:events'); +const { startupHtml } = require('../startup-window.cjs'); +const { create } = require('../i18n.js'); + +test('startup copy follows the cached language and escapes scriptless display data', () => { + for (const locale of ['en', 'zh-TW']) { + const language = create(locale), html = startupHtml('StandTerm Desktop (WSL)', language); + assert.ok(html.includes(``)); + assert.ok(html.includes(language.t('desktop.startup.starting'))); + assert.ok(html.includes("default-src 'none'")); + assert.ok(!html.includes('', { locale: 'invalid', t: () => '' }); + assert.ok(html.includes('<img')); + assert.ok(!html.includes('']) { + request({ url }, result => assert.equal(result.cancel, true)); + } + request({ url: win.url }, result => assert.equal(result.cancel, false)); + startup.focus(); + assert.ok(win.shown && win.focused && !win.minimized); + finish(); await startup.ready; + startup.close(); startup.close(); startup.focus(); + assert.equal(win.destroyed, true); +}); + +test('recovery restores the main window after the startup window closes', () => { + const source = fs.readFileSync(path.join(__dirname, '../main.cjs'), 'utf8'); + const functions = ['closeStartupWindow', 'showWindow'].map(name => { + const match = source.match(new RegExp(`function ${name}\\(\\) \\{[\\s\\S]*?\\n\\}`)); + assert.ok(match, `${name} must be available`); + return match[0]; + }).join('\n'); + const calls = []; + let destroyed = false, minimized = true; + const context = vm.createContext({ + booting: false, + startupWindow: { + close: () => { destroyed = true; calls.push('close-startup'); }, + focus: () => { if (!destroyed) calls.push('focus-startup'); }, + }, + focusSetup: () => calls.push('focus-setup'), + win: { + isDestroyed: () => false, isMinimized: () => minimized, + restore: () => { minimized = false; calls.push('restore-main'); }, + show: () => calls.push('show-main'), focus: () => calls.push('focus-main'), + }, + }); + vm.runInContext(functions + '\ncloseStartupWindow(); booting = true; showWindow();', context); + assert.deepEqual(calls, ['close-startup', 'restore-main', 'show-main', 'focus-main']); + assert.equal(minimized, false); +}); diff --git a/desktop/test/window-state.test.cjs b/desktop/test/window-state.test.cjs new file mode 100644 index 0000000..8b489b5 --- /dev/null +++ b/desktop/test/window-state.test.cjs @@ -0,0 +1,107 @@ +'use strict'; + +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); +const { EventEmitter } = require('node:events'); +const { loadWindowState, trackWindowState } = require('../window-state.cjs'); + +const primary = { x: 0, y: 0, width: 1920, height: 1040 }; +function fixture(area = primary) { + const file = path.join(fs.mkdtempSync(path.join(os.tmpdir(), 'standterm-window-state-')), 'window-wsl.json'); + const screen = { getPrimaryDisplay: () => ({ workArea: primary }), getDisplayMatching: () => ({ workArea: area }) }; + const write = (bounds, maximized = true) => fs.writeFileSync(file, JSON.stringify({ version: 1, bounds, maximized })); + return { file, screen, write }; +} + +test('restores maximization and normal bounds on an attached secondary display', () => { + const f = fixture({ x: -1600, y: 0, width: 1600, height: 900 }); + f.write({ x: -1400, y: 30, width: 1000, height: 700 }); + assert.deepEqual(loadWindowState(f.file, f.screen), { + options: { x: -1400, y: 30, width: 1000, height: 700, minWidth: 640, minHeight: 480 }, maximized: true, + }); +}); + +test('removed displays and changed work areas keep the entire normal window reachable', () => { + const f = fixture(); + f.write({ x: -2400, y: -100, width: 2200, height: 1200 }); + assert.deepEqual(loadWindowState(f.file, f.screen).options, { ...primary, minWidth: 640, minHeight: 480 }); + f.write({ x: 1850, y: 1000, width: 800, height: 600 }); + assert.deepEqual(loadWindowState(f.file, f.screen).options, { + x: 1120, y: 440, width: 800, height: 600, minWidth: 640, minHeight: 480, + }); +}); + +test('missing, oversized, malformed and unsupported state falls back without blocking startup', () => { + const f = fixture(); + const defaults = loadWindowState(f.file, f.screen); + for (const value of ['null', 'broken', '{"version":2}', JSON.stringify({ version: 1, + bounds: { x: Number.MAX_SAFE_INTEGER, y: 0, width: 1000, height: 700 }, maximized: true }), + ' '.repeat(16385)]) { + fs.writeFileSync(f.file, value); + assert.deepEqual(loadWindowState(f.file, f.screen), defaults); + } +}); + +function fakeWindow(bounds) { + const win = new EventEmitter(); + Object.assign(win, { visible: true, minimized: false, maximized: false, fullscreen: false, bounds }); + win.isDestroyed = () => false; + win.isVisible = () => win.visible; + win.isMinimized = () => win.minimized; + win.isMaximized = () => win.maximized; + win.isFullScreen = () => win.fullscreen; + win.getNormalBounds = () => win.bounds; + return win; +} + +test('exit flushes normal bounds and preserves maximization across minimize, tray hide and fullscreen', () => { + for (const state of ['minimized', 'hidden', 'fullscreen']) { + const f = fixture(), initial = loadWindowState(f.file, f.screen); + const bounds = { x: 80, y: 60, width: 900, height: 650 }; + const win = fakeWindow(bounds); + const tracker = trackWindowState(win, f.file, initial); + win.emit('move'); + win.maximized = true; + win.emit('maximize'); + win[state === 'hidden' ? 'visible' : state] = state !== 'hidden'; + win.maximized = false; + win.bounds = { x: -32000, y: -32000, width: 1, height: 1 }; + win.emit('resize'); + tracker.save(); + assert.deepEqual(JSON.parse(fs.readFileSync(f.file)), { version: 1, bounds, maximized: true }); + win.emit('closed'); + } +}); + +test('close to tray saves restored bounds and an unwritable cache does not block closing', () => { + const f = fixture(), initial = loadWindowState(f.file, f.screen); + const win = fakeWindow({ x: 100, y: 70, width: 800, height: 600 }); + trackWindowState(win, f.file, initial); + win.emit('close'); + assert.equal(loadWindowState(f.file, f.screen).maximized, false); + assert.equal(loadWindowState(f.file, f.screen).options.width, 800); + const other = fixture(); + fs.mkdirSync(other.file); + let failures = 0; + trackWindowState(win, other.file, initial, () => failures++); + assert.doesNotThrow(() => win.emit('close')); + assert.equal(failures, 1); + assert.deepEqual(fs.readdirSync(path.dirname(other.file)), ['window-wsl.json']); + win.emit('closed'); +}); + +test('native rounding does not accumulate in the saved dimensions across launches', () => { + const f = fixture(); + const bounds = { x: 100, y: 70, width: 800, height: 600 }; + f.write(bounds, false); + for (let launch = 0; launch < 3; launch++) { + const initial = loadWindowState(f.file, f.screen); + const win = fakeWindow({ ...bounds, height: 601 }); + trackWindowState(win, f.file, initial); + win.emit('resize'); win.emit('close'); win.emit('closed'); + assert.deepEqual(JSON.parse(fs.readFileSync(f.file)).bounds, bounds); + } +}); diff --git a/desktop/window-state.cjs b/desktop/window-state.cjs new file mode 100644 index 0000000..b1f1b2d --- /dev/null +++ b/desktop/window-state.cjs @@ -0,0 +1,81 @@ +'use strict'; + +const fs = require('node:fs'); +const path = require('node:path'); +const { randomUUID } = require('node:crypto'); + +const SAVE_DELAY_MS = 250; +const validBounds = value => value && ['x', 'y', 'width', 'height'].every(key => Number.isInteger(value[key]) + && value[key] >= -2147483648 && value[key] <= 2147483647) + && value.width > 0 && value.height > 0; + +function loadWindowState(file, screen) { + let saved; + try { + if (fs.statSync(file).size <= 16384) { + const data = JSON.parse(fs.readFileSync(file, 'utf8')); + if (data?.version === 1 && validBounds(data.bounds) && typeof data.maximized === 'boolean') saved = data; + } + } catch { /* Missing or invalid state uses the default window. */ } + const primary = screen.getPrimaryDisplay().workArea; + let area = primary; + if (saved) { + const match = screen.getDisplayMatching(saved.bounds).workArea; + const b = saved.bounds; + if (b.x < match.x + match.width && b.x + b.width > match.x + && b.y < match.y + match.height && b.y + b.height > match.y) area = match; + } + const minWidth = Math.min(640, area.width), minHeight = Math.min(480, area.height); + const width = Math.min(area.width, Math.max(minWidth, saved?.bounds.width || 1280)); + const height = Math.min(area.height, Math.max(minHeight, saved?.bounds.height || 850)); + const x = Math.max(area.x, Math.min(saved?.bounds.x ?? area.x + Math.floor((area.width - width) / 2), area.x + area.width - width)); + const y = Math.max(area.y, Math.min(saved?.bounds.y ?? area.y + Math.floor((area.height - height) / 2), area.y + area.height - height)); + return { options: { x, y, width, height, minWidth, minHeight }, maximized: saved?.maximized || false }; +} + +function trackWindowState(win, file, initial, onError = () => {}) { + let bounds = { x: initial.options.x, y: initial.options.y, width: initial.options.width, height: initial.options.height }; + let maximized = initial.maximized; + let timer; + let lastSaved; + function remember() { + // Minimizing can change the native maximized flag. Keep the last visible state. + if (!win.isDestroyed() && win.isVisible() && !win.isMinimized() && !win.isFullScreen()) { + maximized = win.isMaximized(); + const normal = win.getNormalBounds(); + if (validBounds(normal)) { + // Windows display scaling can round a restored coordinate by one DIP. + // Keep the requested value in that case so each launch does not grow it. + bounds = Object.fromEntries(Object.keys(bounds).map(key => [key, + Math.abs(normal[key] - bounds[key]) <= 1 ? bounds[key] : normal[key]])); + } + } + } + function save() { + clearTimeout(timer); + remember(); + const value = JSON.stringify({ version: 1, bounds, maximized }, null, 2); + if (value === lastSaved) return; + const temporary = `${file}.${randomUUID()}.tmp`; + try { + fs.mkdirSync(path.dirname(file), { recursive: true }); + fs.writeFileSync(temporary, value, { flag: 'wx', mode: 0o600 }); + fs.renameSync(temporary, file); + lastSaved = value; + } catch { onError(); } + finally { try { fs.unlinkSync(temporary); } catch { /* No temporary file remains after a successful rename. */ } } + } + function changed() { + // Remember before a following minimize or hide event obscures native state. + remember(); + clearTimeout(timer); + timer = setTimeout(save, SAVE_DELAY_MS); + timer.unref(); + } + for (const event of ['move', 'resize', 'maximize', 'unmaximize']) win.on(event, changed); + win.on('close', save); + win.on('closed', () => clearTimeout(timer)); + return { save }; +} + +module.exports = { loadWindowState, trackWindowState }; diff --git a/docs/desktop_ui_copy_review.tsv b/docs/desktop_ui_copy_review.tsv index 3523c57..3efc6c5 100644 --- a/docs/desktop_ui_copy_review.tsv +++ b/docs/desktop_ui_copy_review.tsv @@ -241,6 +241,7 @@ desktop.setup.cleanup_title Confirm environment cleanup Confirm environment clea desktop.setup.cleanup_message Move these idle venvs to recovery in {platform}? Move these idle venvs to recovery in {platform}? 要將 {platform} 中這些未使用中的 venv 移至復原資料夾嗎? Platform is Windows or WSL followed by the raw selected distro name. {platform} Translate display text only. Keep stable codes, action IDs, numeric responses, validation, process ownership and cancellation behavior unchanged. Insert paths and diagnostic text literally. translation-reviewed desktop/setup.cjs desktop.setup.cleanup_keep Keep environments Keep environments 保留環境 Cleanup response 0, default and escape. Translate display text only. Keep stable codes, action IDs, numeric responses, validation, process ownership and cancellation behavior unchanged. Insert paths and diagnostic text literally. translation-reviewed desktop/setup.cjs desktop.setup.cleanup_move Move listed venvs to recovery Move listed venvs to recovery 將清單中的 venv 移至復原資料夾 Cleanup response 1; recoverable move only, no deletion. Translate display text only. Keep stable codes, action IDs, numeric responses, validation, process ownership and cancellation behavior unchanged. Insert paths and diagnostic text literally. translation-reviewed desktop/setup.cjs +desktop.startup.starting Starting Core... Starting Core... 正在啟動 Core… Startup splash shown before Core preparation, including WSL cold start. Display only; no connection credentials or progress percentage. translation-reviewed desktop/startup-window.cjs desktop.startup.start_failed StandTerm Desktop could not start StandTerm Desktop could not start StandTerm Desktop 無法啟動 Startup failure dialog title. Translate display text only. Keep stable codes, action IDs, numeric responses, validation, process ownership and cancellation behavior unchanged. Insert paths and diagnostic text literally. translation-reviewed desktop/main.cjs desktop.startup.recovery_failed StandTerm Desktop could not recover StandTerm Desktop could not recover StandTerm Desktop 無法復原 Recovery failure dialog title. Translate display text only. Keep stable codes, action IDs, numeric responses, validation, process ownership and cancellation behavior unchanged. Insert paths and diagnostic text literally. translation-reviewed desktop/main.cjs desktop.startup.shutdown_failed StandTerm could not complete shutdown StandTerm could not complete shutdown StandTerm 無法完成結束程序 Shutdown failure dialog title. Translate display text only. Keep stable codes, action IDs, numeric responses, validation, process ownership and cancellation behavior unchanged. Insert paths and diagnostic text literally. translation-reviewed desktop/main.cjs From 63cb3239cd12de04d311cc9623965a117306bf36 Mon Sep 17 00:00:00 2001 From: "ASKA C." Date: Wed, 23 Sep 2026 22:28:20 +0800 Subject: [PATCH 40/43] Add Windows network routing for WSL SSH --- desktop/README.md | 21 +++ docs/backend_plugin_contract.md | 9 + docs/ui_copy_review.tsv | 4 + scripts/run_smoke_tests.py | 3 + static/js/standterm-messages.js | 8 + templates/index.html | 19 +- terminal_backends/ssh.py | 52 +++++- terminal_backends/windows_network.py | 186 ++++++++++++++++++++ tests/ssh_network_origin_browser_smoke.py | 123 +++++++++++++ tests/ssh_network_origin_smoke.py | 201 ++++++++++++++++++++++ tests/ssh_windows_network_smoke.py | 84 +++++++++ tests/windows_network_smoke.py | 140 +++++++++++++++ 12 files changed, 843 insertions(+), 7 deletions(-) create mode 100644 terminal_backends/windows_network.py create mode 100644 tests/ssh_network_origin_browser_smoke.py create mode 100644 tests/ssh_network_origin_smoke.py create mode 100644 tests/ssh_windows_network_smoke.py create mode 100644 tests/windows_network_smoke.py diff --git a/desktop/README.md b/desktop/README.md index a948394..8369b5c 100644 --- a/desktop/README.md +++ b/desktop/README.md @@ -29,6 +29,27 @@ It uses the cached Desktop language and closes when the main window is ready or startup fails. Launching the same mode again focuses startup or its setup window while Core is still starting. +WSL Core offers **SSH network source (this connection)** when WSL interoperability +and `powershell.exe` on its PATH are available. **Windows (preview)** opens the +first SSH hop through a temporary Windows PowerShell TCP helper. It needs Windows +PowerShell policy to allow that helper and Windows DNS/routing/firewall to permit +the target connection. No Windows SSH server, Python, administrator access, new +listener or firewall rule is required by the relay. The target still needs SSH. + +SSH authentication, browser keys, host-key storage and SFTP remain in Core. +Windows `localhost` means the Windows host and never inherits Core's localhost +automatic trust or key setup. Use a host key alias when different hosts share +an address across the two networks. The choice applies to the current connection +and its retries, is not saved in profiles, and never silently falls back to Core. +Later jump hosts, SSH forwarding targets and local forwarding listeners keep +their existing semantics; selecting Windows does not move those listeners. +This preview is available in WSL browser launches too, under the same conditions. + +`tests/windows_network_smoke.py` checks the real Windows binary relay and process +cleanup (Windows Node is only a test fixture dependency). +`tests/ssh_windows_network_smoke.py` additionally needs a local WSL `sshd` binary +to create disposable SSH servers for trust, jump, SFTP and forwarding checks. + Desktop caches the last synchronized language in the mode profile's `language.json` for startup, setup and recovery before Core is ready. Older Core versions without the language snapshot field retain that cache; missing or invalid cache uses English. diff --git a/docs/backend_plugin_contract.md b/docs/backend_plugin_contract.md index eee6963..3d14ba8 100644 --- a/docs/backend_plugin_contract.md +++ b/docs/backend_plugin_contract.md @@ -150,6 +150,15 @@ Keep these compatibility surfaces unless there is an explicit migration plan: fields. - Secret start fields must not expose `default_value`. +SSH may advertise a `network_origin` start field with `core` and `windows` values +on WSL when Windows interoperability and PowerShell are available. Omission means +`core`. The `windows` preview applies only to the route's first TCP connection; +the field belongs to the top-level start payload, never an individual route node. +Unsupported values or an unavailable Windows helper are rejected without fallback. +Authentication and host-key checks remain in Core; Windows loopback targets do +not qualify for Core localhost trust/key-setup shortcuts. The selected origin is +retained in retries and Windows-backed SFTP endpoint metadata. + When adding a backend, implement `build_policy_option()`, `get_start_form_schema()`, `validate_start_payload()`, `create_bridge()`, and `connect_bridge()`. Add focused backend and browser smoke tests for the exposed diff --git a/docs/ui_copy_review.tsv b/docs/ui_copy_review.tsv index 9aeb882..ba4be8a 100644 --- a/docs/ui_copy_review.tsv +++ b/docs/ui_copy_review.tsv @@ -798,3 +798,7 @@ browser.files.copy_unknown_detail {detail} Inspect the destination before retryi browser.chrome.key_unavailable The browser SSH key is unavailable. The browser SSH key is unavailable. 無法使用瀏覽器 SSH 金鑰。 Browser-owned display fallback only. Preserve raw labels and identifiers. translation-reviewed templates/index.html browser.chrome.terminal Terminal Terminal 終端 Browser-owned display fallback only. Preserve raw labels and identifiers. translation-reviewed templates/index.html browser.chrome.file_transfer File transfer File transfer 檔案傳輸 Browser-owned display fallback only. Preserve raw labels and identifiers. translation-reviewed templates/index.html +connection.network_origin SSH network source (this connection) SSH network source (this connection) SSH 網路來源(本次連線) WSL SSH first-hop network source preview. Display only. Keep core/windows payload values literal; preserve Core, WSL, Windows and localhost. translation-reviewed templates/index.html +connection.network_core Core (WSL) Core (WSL) Core(WSL) WSL SSH first-hop network source preview. Display only. Keep core/windows payload values literal; preserve Core, WSL, Windows and localhost. translation-reviewed templates/index.html +connection.network_windows Windows (preview) Windows (preview) Windows(試用) WSL SSH first-hop network source preview. Display only. Keep core/windows payload values literal; preserve Core, WSL, Windows and localhost. translation-reviewed templates/index.html +connection.network_hint First hop only; not saved in profiles. With Windows selected, localhost means the Windows host. First hop only; not saved in profiles. With Windows selected, localhost means the Windows host. 僅影響第一站,不隨設定檔儲存。選 Windows 時,localhost 指 Windows 本機。 WSL SSH first-hop network source preview. Display only. Keep core/windows payload values literal; preserve Core, WSL, Windows and localhost. translation-reviewed templates/index.html diff --git a/scripts/run_smoke_tests.py b/scripts/run_smoke_tests.py index 32bb58f..22adc99 100644 --- a/scripts/run_smoke_tests.py +++ b/scripts/run_smoke_tests.py @@ -30,6 +30,8 @@ 'tests/static_site_smoke.py', 'tests/terminal_read_smoke.py', 'tests/ssh_start_smoke.py', + 'terminal_backends/windows_network.py', + 'tests/ssh_network_origin_smoke.py', 'tests/ssh_login_smoke.py', 'tests/ssh_node_credentials_smoke.py', 'tests/ssh_tunnels_smoke.py', @@ -44,6 +46,7 @@ 'tests/access_window_smoke.py', 'tests/terminal_read_smoke.py', 'tests/ssh_start_smoke.py', + 'tests/ssh_network_origin_smoke.py', 'tests/ssh_login_smoke.py', 'tests/ssh_node_credentials_smoke.py', 'tests/server_startup_smoke.py', diff --git a/static/js/standterm-messages.js b/static/js/standterm-messages.js index bca6278..1a8fddc 100644 --- a/static/js/standterm-messages.js +++ b/static/js/standterm-messages.js @@ -462,6 +462,10 @@ "connection.local_shell": "Local Shell", "connection.local_shell_hint": "Runs on the StandTerm host.", "connection.manual_port": "Manual port\u2026", + "connection.network_core": "Core (WSL)", + "connection.network_hint": "First hop only; not saved in profiles. With Windows selected, localhost means the Windows host.", + "connection.network_origin": "SSH network source (this connection)", + "connection.network_windows": "Windows (preview)", "connection.no_routes": "No saved routes", "connection.none_selected": "None selected", "connection.password": "Password (optional)", @@ -1262,6 +1266,10 @@ "connection.local_shell": "\u672c\u6a5f Shell", "connection.local_shell_hint": "\u5728 StandTerm \u4e3b\u6a5f\u4e0a\u57f7\u884c\u3002", "connection.manual_port": "\u624b\u52d5\u8f38\u5165\u5e8f\u5217\u57e0\u2026", + "connection.network_core": "Core\uff08WSL\uff09", + "connection.network_hint": "\u50c5\u5f71\u97ff\u7b2c\u4e00\u7ad9\uff0c\u4e0d\u96a8\u8a2d\u5b9a\u6a94\u5132\u5b58\u3002\u9078 Windows \u6642\uff0clocalhost \u6307 Windows \u672c\u6a5f\u3002", + "connection.network_origin": "SSH \u7db2\u8def\u4f86\u6e90\uff08\u672c\u6b21\u9023\u7dda\uff09", + "connection.network_windows": "Windows\uff08\u8a66\u7528\uff09", "connection.no_routes": "\u6c92\u6709\u5df2\u5132\u5b58\u8def\u5f91", "connection.none_selected": "\u5c1a\u672a\u9078\u64c7", "connection.password": "\u5bc6\u78bc\uff08\u9078\u586b\uff09", diff --git a/templates/index.html b/templates/index.html index e1f3600..3b5394d 100644 --- a/templates/index.html +++ b/templates/index.html @@ -1378,6 +1378,14 @@

StandTerm

+
@@ -2373,6 +2381,7 @@

Restore StandTerm access

const modeSelector = document.getElementById('modeSelector'); const sshFields = document.getElementById('ssh-fields'); const sshHostInput = document.getElementById('host'); + const sshNetworkOrigin = document.getElementById('ssh-network-origin'); const sshPortInput = document.getElementById('port'); const sshUsernameInput = document.getElementById('username'); const sshPasswordInput = document.getElementById('password'); @@ -5782,6 +5791,11 @@

Restore StandTerm access

}); modeSelector.classList.toggle('locked', !!forcedConnectionType || getActiveModeCount() <= 1); sshFields.style.display = normalized === 'ssh' ? 'block' : 'none'; + const windowsNetworkAvailable = getConnectionFieldOptions('ssh', 'network_origin') + .some(option => option.value === 'windows'); + document.getElementById('ssh-network-origin-field').hidden = !windowsNetworkAvailable; + sshNetworkOrigin.disabled = !windowsNetworkAvailable; + if (!windowsNetworkAvailable) sshNetworkOrigin.value = 'core'; localShellFields.style.display = ( normalized === 'local_shell' && localShellKindSelect.options.length > 0 ) ? 'block' : 'none'; @@ -8292,6 +8306,7 @@

Restore StandTerm access

terminal_id: activeTerminalId }; if (connectionType === 'ssh') { + if (!sshNetworkOrigin.disabled && sshNetworkOrigin.value === 'windows') formData.network_origin = 'windows'; if (sshPreparationMode === 'route') { const entry = currentSshRouteEntry(); if (!entry) throw new Error(t('browser.chrome.choose_route')); @@ -8328,6 +8343,7 @@

Restore StandTerm access

} function resetSshFormFields() { + sshNetworkOrigin.value = 'core'; Object.entries(CONNECTION_FIELD_IDS.ssh).forEach(([fieldName, id]) => { clearConnectionFieldEdited('ssh', fieldName); const input = document.getElementById(id); @@ -8407,7 +8423,7 @@

Restore StandTerm access

function sshConnectionDiagnostic(value) { const result = {}; - for (const key of ['connection_type', 'terminal_id', 'attempt_id', 'node_id', 'host', 'port', 'username', 'host_key_alias', 'use_browser_key']) { + for (const key of ['connection_type', 'terminal_id', 'attempt_id', 'node_id', 'host', 'port', 'username', 'host_key_alias', 'use_browser_key', 'network_origin']) { if (['string', 'number', 'boolean'].includes(typeof value[key])) result[key] = value[key]; } if (Array.isArray(value.route)) result.route = value.route.map(sshConnectionDiagnostic); @@ -8498,6 +8514,7 @@

Restore StandTerm access

input.addEventListener('input', invalidateSshRetry); input.addEventListener('input', () => { directHostIdentity.invalidate(); directKeyControl.update(); applyQuickConnectKeySelection(); renderSshPreparation(); }); }); + sshNetworkOrigin.addEventListener('change', invalidateSshRetry); sshSessionPickerToggle.onclick = () => { const opening = !sshSessionPickerPanel.classList.contains('open'); sshSessionPickerPanel.classList.toggle('open', opening); diff --git a/terminal_backends/ssh.py b/terminal_backends/ssh.py index 6f0dac0..c2ad939 100644 --- a/terminal_backends/ssh.py +++ b/terminal_backends/ssh.py @@ -18,6 +18,7 @@ SSHHostKeyStore, fingerprint, host_key_name, ) from runtime_logging import log_message +from .windows_network import WindowsNetworkSocket, windows_network_executable SSH_PROFILE_NAME_MAX_LENGTH = 64 @@ -138,6 +139,7 @@ def __init__( self._sftp_endpoint = None self.ssh = None self.auth_method = None + self.network_origin = 'core' self._reset_ssh_client() self.channel = None self._output_decoder = codecs.getincrementaldecoder('utf-8')(errors='ignore') @@ -152,6 +154,8 @@ def metadata(self, cols=None, rows=None): 'port': self._sftp_endpoint['port'], 'username': self._sftp_endpoint['user'], } + if self.network_origin == 'windows': + metadata['ssh_target']['network_origin'] = 'windows' return metadata def sftp_endpoint(self): @@ -1208,8 +1212,12 @@ def _connect_route(self, route, cols, rows, interactive_login=False): self._pending_host_key = None if previous is None: # Connect to the network address; the alias is only a trust identity. - sock = self._own_connection_resource(socket.create_connection( - (node['host'], node['port']), timeout=SSH_CONNECT_TIMEOUT_SECONDS)) + if self.network_origin == 'windows': + sock = self._own_connection_resource(WindowsNetworkSocket()) + sock.connect((node['host'], node['port']), timeout=SSH_CONNECT_TIMEOUT_SECONDS) + else: + sock = self._own_connection_resource(socket.create_connection( + (node['host'], node['port']), timeout=SSH_CONNECT_TIMEOUT_SECONDS)) else: self._connection_progress(node, index, len(route), 'forward') sock = self._own_connection_resource(previous.get_transport().open_channel( @@ -1219,7 +1227,8 @@ def _connect_route(self, route, cols, rows, interactive_login=False): self.ssh = None self._reset_ssh_client( interactive_node=node if interactive_login else None, - local_direct=len(route) == 1 and not node.get('host_key_alias') and self._is_local_target(node['host'])) + local_direct=self.network_origin == 'core' and len(route) == 1 + and not node.get('host_key_alias') and self._is_local_target(node['host'])) client = self._own_connection_resource(self.ssh) key = node.get('browser_key') pkey = None @@ -1251,6 +1260,8 @@ def _connect_route(self, route, cols, rows, interactive_login=False): 'route': json.dumps([[n['host'].lower(), n['port'], n['username'], n.get('host_key_alias', '')] for n in route], separators=(',', ':')), } + if self.network_origin == 'windows': + self._sftp_endpoint['network_origin'] = 'windows' return True, None except Exception as exc: if isinstance(exc, (HostKeyConfirmationRequired, paramiko_module.BadHostKeyException)): @@ -1263,8 +1274,16 @@ def _connect_route(self, route, cols, rows, interactive_login=False): return False, result def connect(self, host, port, user, password=None, browser_key=None, cols=80, rows=24, - route=None, attempt_id=None, interactive_login=False): + route=None, attempt_id=None, interactive_login=False, network_origin='core'): self.attempt_id = attempt_id + if network_origin not in ('core', 'windows'): + return False, {'message': 'Invalid SSH network origin.', 'error_code': 'ssh_network_origin_invalid'} + self.network_origin = network_origin + if network_origin == 'windows': + # Windows localhost is not Core localhost: never offer local keys or automatic trust. + route = route or [{'node_id': 'direct', 'host': host, 'port': port, 'username': user, + 'password': password or '', 'browser_key': browser_key}] + return self._connect_route(route, cols, rows, interactive_login=interactive_login) if interactive_login: node = route[0] if (len(route) == 1 and not node.get('host_key_alias') and self._is_local_target(node['host']) @@ -1366,6 +1385,8 @@ def _host_key_confirmation_hint(self, key): details.append('Received: ' + fingerprint(key)) details.append('Verify this fingerprint with the host administrator before trusting it.') details.append('This updates the Core account\'s known_hosts file, shared with other SSH clients.') + if self.network_origin == 'windows': + details.append('The first SSH hop uses Windows networking. Use a host key alias for a different host at the same address.') return { 'message': message, 'error_code': 'ssh_host_key_changed' if saved else 'ssh_host_key_unknown', @@ -1636,7 +1657,7 @@ def get_start_form_schema(self, context=None): default_host = self._get_default_host(context=context) default_port = self._get_default_port(context=context) default_user = self._get_default_user(context=context) - return [ + fields = [ BackendStartFieldSchema( name='host', label='Host', @@ -1675,6 +1696,13 @@ def get_start_form_schema(self, context=None): max_bytes=self._max_password_bytes, ), ] + if windows_network_executable(): + fields.append(BackendStartFieldSchema( + name='network_origin', label='Connect from', value_type='string', input_type='select', + default_value='core', options=({'value': 'core', 'label': 'Core (WSL)'}, + {'value': 'windows', 'label': 'Windows (preview)'}), + )) + return fields def validate_setting_update(self, setting_key, value, current_value=None): if setting_key == 'ssh.default_host': @@ -1739,6 +1767,13 @@ def validate_start_payload(self, data, terminal_id, client_ip, browser_authorize 'error_code': 'ssh_remote_unauthorized', } + network_origin = data.get('network_origin', 'core') + if network_origin not in ('core', 'windows'): + return None, 'Invalid SSH network origin.' + if network_origin == 'windows' and not windows_network_executable(): + return None, {'error_code': 'ssh_windows_network_unavailable', + 'message': 'Windows network access requires WSL interoperability and Windows PowerShell on PATH.'} + if 'route' in data: route = data['route'] interactive_login = data.get('interactive_login', False) @@ -1752,7 +1787,7 @@ def validate_start_payload(self, data, terminal_id, client_ip, browser_authorize return None, f'SSH routes support at most {SSH_MAX_JUMP_HOSTS} jump hosts plus the target.' nodes, seen = [], set() for index, raw in enumerate(route): - if not isinstance(raw, dict) or 'route' in raw: + if not isinstance(raw, dict) or 'route' in raw or 'network_origin' in raw: return None, 'SSH route node is invalid.' if not all(field in raw for field in ('host', 'port', 'username')): return None, 'Each SSH route node requires its host, port and username.' @@ -1775,6 +1810,8 @@ def validate_start_payload(self, data, terminal_id, client_ip, browser_authorize or self._has_control_chars(name)): return None, 'SSH entry name is invalid.' payload.update(route=nodes, attempt_id=attempt_id, profile_name=name or None, interactive_login=interactive_login) + if network_origin == 'windows': + payload['network_origin'] = network_origin return payload, None host = data.get('host', self._get_default_host(context=context)) @@ -1882,6 +1919,7 @@ def validate_start_payload(self, data, terminal_id, client_ip, browser_authorize 'profile_name': profile_name or None, 'browser_key': browser_key, 'host_key_alias': alias, + **({'network_origin': network_origin} if network_origin == 'windows' else {}), }, None def create_bridge(self, session_token, terminal_id, payload): @@ -1893,6 +1931,8 @@ def create_bridge(self, session_token, terminal_id, payload): def connect_bridge(self, bridge, payload, cols, rows): options = {} + if payload.get('network_origin') == 'windows': + options['network_origin'] = 'windows' if payload.get('route'): options.update(route=payload['route'], attempt_id=payload['attempt_id']) if payload.get('interactive_login'): diff --git a/terminal_backends/windows_network.py b/terminal_backends/windows_network.py new file mode 100644 index 0000000..a4e9df5 --- /dev/null +++ b/terminal_backends/windows_network.py @@ -0,0 +1,186 @@ +"""WSL-only TCP transport through Windows; SSH remains in the Core process.""" + +import base64 +import errno +import json +import os +from pathlib import Path +import select +import shutil +import socket +import subprocess +import sys +import threading +import time + + +HELPER_EXIT_TIMEOUT = 1 +READY = b'STANDTERM_TCP_READY\n' +# Endpoint values arrive as JSON data, never as PowerShell source or arguments. +# The helper opens only an outbound socket and exits when either pipe closes. +RELAY_SCRIPT = r''' +$ErrorActionPreference = 'Stop' +$ProgressPreference = 'SilentlyContinue' +$inputStream = [Console]::OpenStandardInput() +$outputStream = [Console]::OpenStandardOutput() +$client = $null +try { + $reader = New-Object IO.StreamReader($inputStream, [Text.Encoding]::UTF8, $false, 1024, $true) + $line = $reader.ReadLine() + if ($null -eq $line -or $line.Length -gt 4096) { throw 'Invalid request' } + $request = $line | ConvertFrom-Json + if ($request.version -ne 1 -or $request.host -isnot [string] -or !$request.host.Length -or + $request.host.Length -gt 255 -or $request.port -lt 1 -or $request.port -gt 65535 -or + $request.timeout_ms -lt 1 -or $request.timeout_ms -gt 60000) { throw 'Invalid request' } + $reader.Dispose() + $hello = [Text.Encoding]::ASCII.GetBytes("STANDTERM_TCP_HELPER $PID`n") + $outputStream.Write($hello, 0, $hello.Length) + $outputStream.Flush() + $client = New-Object Net.Sockets.TcpClient + $pending = $client.ConnectAsync([string]$request.host, [int]$request.port) + if (!$pending.Wait([int]$request.timeout_ms)) { throw 'Connection timed out' } + $client.NoDelay = $true + $stream = $client.GetStream() + $ready = [Text.Encoding]::ASCII.GetBytes("STANDTERM_TCP_READY`n") + $outputStream.Write($ready, 0, $ready.Length) + $outputStream.Flush() + $up = $inputStream.CopyToAsync($stream) + $down = $stream.CopyToAsync($outputStream) + [Threading.Tasks.Task]::WaitAny([Threading.Tasks.Task[]]@($up, $down)) | Out-Null +} catch { + exit 1 +} finally { + if ($null -ne $client) { $client.Close() } +} +''' + + +def windows_network_executable(): + if sys.platform != 'linux': + return None + try: + if 'microsoft' not in Path('/proc/sys/kernel/osrelease').read_text().lower(): + return None + except OSError: + return None + if not os.environ.get('WSL_INTEROP'): + return None + return shutil.which('powershell.exe') + + +class WindowsNetworkSocket: + def __init__(self): + self.process = None + self.helper_pid = None + self.timeout = None + self._closed = False + self._lock = threading.Lock() + + @property + def closed(self): + return self._closed + + def connect(self, address, timeout): + executable = windows_network_executable() + if not executable: + raise OSError('Windows network access requires WSL interoperability and Windows PowerShell on PATH.') + host, port = address + if (not isinstance(host, str) or not 1 <= len(host) <= 255 + or any(ord(char) < 32 or ord(char) == 127 for char in host) + or type(port) is not int or not 1 <= port <= 65535 or not 0 < timeout <= 60): + raise ValueError('Invalid Windows network endpoint.') + script = base64.b64encode(RELAY_SCRIPT.encode('utf-16-le')).decode('ascii') + with self._lock: + if self._closed: + raise OSError('Windows network connection canceled.') + if self.process is not None: + raise OSError('Windows network socket is already connected.') + self.process = subprocess.Popen( + [executable, '-NoLogo', '-NoProfile', '-NonInteractive', '-WindowStyle', 'Hidden', '-EncodedCommand', script], + stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, bufsize=0) + os.set_blocking(self.process.stdin.fileno(), False) + os.set_blocking(self.process.stdout.fileno(), False) + deadline = time.monotonic() + timeout + request = (json.dumps({'version': 1, 'host': host, 'port': port, + 'timeout_ms': int(timeout * 1000)}) + '\n').encode('utf-8') + try: + while request: + self.settimeout(max(0, deadline - time.monotonic())) + request = request[self.send(request):] + # Do not send SSH bytes until the helper's bounded request reader is done. + hello = self._read_line(deadline) + prefix, _, pid = hello.rstrip(b'\n').partition(b' ') + if prefix != b'STANDTERM_TCP_HELPER' or not pid.isdigit() or not 0 < int(pid) <= 2147483647: + raise OSError('Invalid Windows network helper identity.') + self.helper_pid = int(pid) + if self._read_line(deadline) != READY: + raise OSError('Invalid Windows network helper response.') + self.settimeout(timeout) + except BaseException: + self.close() + raise + + def _read_line(self, deadline): + response = b'' + while len(response) < 128: + self.settimeout(max(0, deadline - time.monotonic())) + part = self.recv(1) + if not part: + raise OSError('Windows could not open the network connection. Check Windows reachability and PowerShell policy.') + response += part + if part == b'\n': + return response + raise OSError('Invalid Windows network helper response.') + + def settimeout(self, timeout): + self.timeout = timeout + + def _pipe(self, sending): + if self._closed or self.process is None: + raise OSError(errno.EBADF, 'Windows network socket is closed.') + return self.process.stdin if sending else self.process.stdout + + def send(self, data): + pipe = self._pipe(True) + try: + if not select.select([], [pipe], [], self.timeout)[1]: + raise socket.timeout('Windows network write timed out.') + return os.write(pipe.fileno(), data) + except ValueError as exc: + raise OSError(errno.EBADF, 'Windows network socket is closed.') from exc + + def recv(self, size): + if not size or self._closed: + return b'' + pipe = self._pipe(False) + try: + if not select.select([pipe], [], [], self.timeout)[0]: + raise socket.timeout('Windows network read timed out.') + return os.read(pipe.fileno(), size) + except (OSError, ValueError): + if self._closed: + return b'' + raise + + def close(self): + with self._lock: + if self._closed: + return + self._closed = True + process = self.process + if process is None: + return + for pipe in (process.stdin, process.stdout): + try: + pipe.close() + except OSError: + pass + try: + process.wait(timeout=HELPER_EXIT_TIMEOUT) + except subprocess.TimeoutExpired: + process.terminate() + try: + process.wait(timeout=HELPER_EXIT_TIMEOUT) + except subprocess.TimeoutExpired: + process.kill() + process.wait(timeout=HELPER_EXIT_TIMEOUT) diff --git a/tests/ssh_network_origin_browser_smoke.py b/tests/ssh_network_origin_browser_smoke.py new file mode 100644 index 0000000..4ba519d --- /dev/null +++ b/tests/ssh_network_origin_browser_smoke.py @@ -0,0 +1,123 @@ +"""Verify one-connection network selection, policy availability and bound SSH retries.""" + +from pathlib import Path +import sys + +sys.path.insert(0, str(Path(__file__).resolve().parent)) +import agent_browser_smoke as fixture +import ssh_routes_browser_smoke as routes + + +def set_available(page, available): + page.evaluate('''available => { + const policy = window.terminalTest.getTerminalPolicy(); + const ssh = policy.connection_options.find(item => item.connection_type === 'ssh'); + ssh.start_fields = ssh.start_fields.filter(field => field.name !== 'network_origin'); + if (available) ssh.start_fields.push({name: 'network_origin', input_type: 'select', + options: [{value: 'core'}, {value: 'windows'}], default_value: 'core'}); + window.terminalTest.applyTerminalPolicy(policy); + window.terminalTest.setConnectionTypeForTest('ssh'); + }''', available) + + +def test_selection_and_route_scope(browser, url, locale): + context, page = fixture.new_page(browser, url, ui_language=locale) + try: + page.click('#new-tab-btn') + routes.show_ssh(page) + set_available(page, True) + assert page.locator('#ssh-network-origin-field').is_visible() + assert page.input_value('#ssh-network-origin') == 'core' + assert page.locator('label[for="ssh-network-origin"]').inner_text() == ( + 'SSH 網路來源(本次連線)' if locale == 'zh-TW' else 'SSH network source (this connection)') + page.fill('#host', 'target.test'); page.fill('#username', 'operator') + direct = page.evaluate('() => window.terminalTest.prepareSshConnectionForTest()') + assert 'network_origin' not in direct + page.select_option('#ssh-network-origin', 'windows') + direct = page.evaluate('() => window.terminalTest.prepareSshConnectionForTest()') + assert direct['network_origin'] == 'windows' + page.evaluate('''() => window.terminalTest.setSshSessionState({version:2, revision:0, + profiles:[{id:'route-a',name:'Route A',startNodeId:'jump'}], history:[], nodes:[ + {id:'jump',endpoint:{host:'jump.test',port:'22',username:'u'}, + authentication:{method:'password'},hostKeyAlias:'',nextNodeId:'target'}, + {id:'target',endpoint:{host:'target.test',port:'22',username:'u'}, + authentication:{method:'password'},hostKeyAlias:'',nextNodeId:null} + ]})''') + routes.select(page, 'route-a') + route = page.evaluate('() => window.terminalTest.prepareSshConnectionForTest()') + assert route['network_origin'] == 'windows' + assert [node['host'] for node in route['route']] == ['jump.test', 'target.test'] + assert all('network_origin' not in node for node in route['route']) + state = page.evaluate('() => window.terminalTest.getSshSessionState()') + assert all('network_origin' not in entry for entry in state['profiles'] + state['nodes']) + set_available(page, False) + assert page.locator('#ssh-network-origin-field').is_hidden() + assert page.locator('#ssh-network-origin').is_disabled() + assert page.input_value('#ssh-network-origin') == 'core' + route = page.evaluate('() => window.terminalTest.prepareSshConnectionForTest()') + assert 'network_origin' not in route + finally: + fixture.close_context(context) + + +def test_trust_retry_keeps_the_selected_network(browser, url): + context, page = routes.new_page(browser, url) + try: + routes.show_ssh(page) + set_available(page, True) + page.fill('#host', 'target.test'); page.fill('#username', 'operator') + page.select_option('#ssh-network-origin', 'windows') + page.evaluate('''() => { + window.terminalTest.captureSshStartsForTest(); + window.terminalTest.clearEmitted(); + document.getElementById('connectBtn').click(); + }''') + page.wait_for_function("() => window.terminalTest.getEmitted().some(item => item.event === 'start_ssh')") + start = page.evaluate("() => window.terminalTest.getEmitted().find(item => item.event === 'start_ssh').args[0]") + assert start['network_origin'] == 'windows' + failure = {'terminal_id': start['terminal_id'], 'message_type': 'connection_error', + 'attempt_id': start['attempt_id'], 'action_type': 'confirm_ssh_host_key', + 'action_id': 'trust-origin', 'message': 'Host key is unknown', + 'action_message': 'Fingerprint fixture', 'action_question': 'Trust this key?'} + page.evaluate('data => window.terminalTest.handleSshOutput(data)', failure) + page.click('#actionYesBtn') + response = {'terminal_id': start['terminal_id'], 'message_type': 'host_key_result', + 'attempt_id': start['attempt_id'], 'action_type': 'confirm_ssh_host_key', + 'action_id': 'trust-origin', 'operation': 'confirm', 'status': 'success'} + page.evaluate('data => window.terminalTest.handleSshOutput(data)', response) + page.wait_for_function("() => window.terminalTest.getEmitted().filter(item => item.event === 'start_ssh').length === 2") + starts = page.evaluate("() => window.terminalTest.getEmitted().filter(item => item.event === 'start_ssh').map(item => item.args[0])") + assert [item['network_origin'] for item in starts] == ['windows', 'windows'] + assert starts[0]['route'] == starts[1]['route'] + page.evaluate('data => window.terminalTest.handleSshOutput(data)', + {**failure, 'attempt_id': starts[1]['attempt_id'], 'action_id': 'trust-second'}) + page.click('#actionYesBtn') + # The synthetic retry hides the form; still exercise its change handler + # before delivering the deliberately delayed trust result. + page.select_option('#ssh-network-origin', 'core', force=True) + page.evaluate('data => window.terminalTest.handleSshOutput(data)', + {**response, 'attempt_id': starts[1]['attempt_id'], 'action_id': 'trust-second'}) + page.wait_for_timeout(200) + assert page.evaluate("() => window.terminalTest.getEmitted().filter(item => item.event === 'start_ssh').length") == 2 + finally: + fixture.close_context(context) + + +def main(): + process, url = fixture.start_server() + try: + with fixture.load_playwright()[0]() as playwright: + browser = playwright.chromium.launch(headless=True) + try: + for locale in ['en', 'zh-TW']: + test_selection_and_route_scope(browser, url, locale) + test_trust_retry_keeps_the_selected_network(browser, url) + finally: + browser.close() + finally: + fixture.stop_server(process) + print('SSH network browser smoke passed: bilingual selector, route scope, unavailable capability and trust retry.') + + +if __name__ == '__main__': + main() diff --git a/tests/ssh_network_origin_smoke.py b/tests/ssh_network_origin_smoke.py new file mode 100644 index 0000000..9cdc653 --- /dev/null +++ b/tests/ssh_network_origin_smoke.py @@ -0,0 +1,201 @@ +"""Check SSH network-origin routing without Windows or network connections.""" +import base64 +from contextlib import ExitStack +from pathlib import Path +import sys +import unittest +from unittest.mock import Mock, patch + +sys.path.insert(0, str(Path(__file__).resolve().parent)) +arguments = sys.argv[1:] +sys.argv[1:] = [] +import agent_backend_smoke as fixture +sys.argv[1:] = arguments + +import paramiko +from terminal_backends import ssh + +standterm = fixture.standterm + + +class SSHNetworkOriginTests(unittest.TestCase): + def setUp(self): + fixture.reset_state() + self.plugin = standterm.TERMINAL_BACKEND_REGISTRY.get('ssh') + self.direct = {'connection_type': 'ssh', 'terminal_id': 'main', + 'host': 'localhost', 'port': 22, 'username': 'fixture', 'password': ''} + self.node = {'node_id': 'first', 'host': 'localhost', 'port': 22, + 'username': 'fixture', 'password': '', 'browser_key': None} + + def validate(self, data): + return standterm.validate_start_ssh_payload(data, '127.0.0.1') + + def bridge(self): + bridge = ssh.SSHBridge('fixture-session', get_paramiko=lambda: paramiko, + ssh_term='xterm', local_public_key_types=[]) + bridge.emit_output = Mock() + self.addCleanup(bridge.close) + return bridge + + def test_default_and_explicit_core_preserve_existing_payload(self): + route = {**self.direct, 'attempt_id': 'fixture-attempt', 'route': [self.node]} + with patch.object(ssh, 'windows_network_executable', return_value=None): + for data in (self.direct, route): + with self.subTest(route='route' in data): + original, error = self.validate(data) + self.assertIsNone(error) + explicit, error = self.validate({**data, 'network_origin': 'core'}) + self.assertIsNone(error) + self.assertEqual(explicit, original) + self.assertNotIn('network_origin', original) + + def test_windows_capability_controls_schema_and_validation(self): + for executable in (None, '/fixture/powershell.exe'): + with self.subTest(available=bool(executable)), patch.object( + ssh, 'windows_network_executable', return_value=executable): + fields = {field.name: field for field in self.plugin.get_start_form_schema()} + self.assertEqual('network_origin' in fields, bool(executable)) + if executable: + self.assertEqual(fields['network_origin'].default_value, 'core') + self.assertEqual([item['value'] for item in fields['network_origin'].options], ['core', 'windows']) + for data in (self.direct, {**self.direct, 'attempt_id': 'fixture-attempt', 'route': [self.node]}): + payload, error = self.validate({**data, 'network_origin': 'windows'}) + if executable: + self.assertIsNone(error) + self.assertEqual(payload['network_origin'], 'windows') + for node in payload.get('route', []): + self.assertNotIn('network_origin', node) + else: + self.assertIsNone(payload) + self.assertEqual(error['error_code'], 'ssh_windows_network_unavailable') + + def test_unknown_origins_and_per_node_origins_are_rejected(self): + with patch.object(ssh, 'windows_network_executable', return_value='/fixture/powershell.exe'): + for origin in ('Windows', 'other', '', None, False, {}, []): + with self.subTest(origin=origin): + payload, error = self.validate({**self.direct, 'network_origin': origin}) + self.assertIsNone(payload) + self.assertTrue(error) + for origin in ('core', 'windows'): + payload, error = self.validate({**self.direct, 'network_origin': 'windows', + 'attempt_id': 'fixture-attempt', 'route': [{**self.node, 'network_origin': origin}]}) + self.assertIsNone(payload) + self.assertTrue(error) + + def test_connect_bridge_forwards_origin_without_changing_core_calls(self): + for origin in ('core', 'windows'): + bridge = Mock() + payload = {**self.direct, 'browser_key': None} + if origin == 'windows': + payload['network_origin'] = origin + self.plugin.connect_bridge(bridge, payload, 90, 30) + options = {'browser_key': None, 'cols': 90, 'rows': 30} + if origin == 'windows': + options['network_origin'] = origin + bridge.connect.assert_called_once_with('localhost', 22, 'fixture', '', **options) + bridge.reset_mock() + payload.update(route=[self.node], attempt_id='fixture-attempt', interactive_login=True) + self.plugin.connect_bridge(bridge, payload, 90, 30) + options.update(route=[self.node], attempt_id='fixture-attempt', interactive_login=True) + bridge.connect.assert_called_once_with('localhost', 22, 'fixture', '', **options) + + def test_windows_direct_interactive_and_browser_key_never_use_local_shortcuts(self): + browser_key = {'public_key': base64.b64encode(b'fixture-key').decode()} + for interactive in (False, True): + for key in (None, browser_key): + with self.subTest(interactive=interactive, browser_key=bool(key)): + bridge = self.bridge() + with patch.object(bridge, '_connect_route', return_value=(True, None)) as route, \ + patch.object(bridge, '_connect_with_local_keys') as local_keys, \ + patch.object(bridge, '_connect_with_browser_key') as local_browser_key: + result = bridge.connect('localhost', 22, 'fixture', browser_key=key, + network_origin='windows', interactive_login=interactive) + self.assertEqual(result, (True, None)) + route.assert_called_once() + self.assertEqual(route.call_args.args[0][0]['browser_key'], key) + self.assertEqual(route.call_args.kwargs, {'interactive_login': interactive}) + local_keys.assert_not_called() + local_browser_key.assert_not_called() + + def test_bridge_rejects_unknown_origin_before_any_connection(self): + bridge = self.bridge() + with patch.object(bridge, '_connect_route') as route: + success, error = bridge.connect('localhost', 22, 'fixture', network_origin='other') + self.assertFalse(success) + self.assertEqual(error['error_code'], 'ssh_network_origin_invalid') + route.assert_not_called() + + def test_windows_route_owns_first_hop_before_connect_and_preserves_remote_jumps(self): + for interactive in (False, True): + with self.subTest(interactive=interactive), ExitStack() as stack: + clients = [] + def client(): + value = Mock() + clients.append(value) + return value + stack.enter_context(patch.object(paramiko, 'SSHClient', side_effect=client)) + bridge = self.bridge() + stack.enter_context(patch.object(bridge._host_key_store, 'snapshot', + return_value={'keys': [], 'host_key_name': 'localhost'})) + reset = stack.enter_context(patch.object(bridge, '_reset_ssh_client', wraps=bridge._reset_ssh_client)) + stack.enter_context(patch.object(bridge, '_login_auth_strategy', return_value=object())) + relay = Mock() + relay.connect.side_effect = lambda *args, **kwargs: self.assertIn(relay, bridge._connection_resources) + factory = stack.enter_context(patch.object(ssh, 'WindowsNetworkSocket', return_value=relay)) + direct = stack.enter_context(patch.object(ssh.socket, 'create_connection', side_effect=AssertionError('Core socket used'))) + nodes = [self.node, {**self.node, 'node_id': 'target', 'host': 'target.test'}] + success, error = bridge.connect('target.test', 22, 'fixture', route=nodes, + network_origin='windows', interactive_login=interactive) + self.assertTrue(success, error) + factory.assert_called_once_with() + relay.connect.assert_called_once_with(('localhost', 22), timeout=ssh.SSH_CONNECT_TIMEOUT_SECONDS) + direct.assert_not_called() + for call in reset.call_args_list: + self.assertIs(call.kwargs['local_direct'], False) + self.assertFalse(call.kwargs.get('trust_unknown_host', False)) + first, target = clients[-2:] + first.get_transport().open_channel.assert_called_once_with('direct-tcpip', + ('target.test', 22), ('127.0.0.1', 0), timeout=ssh.SSH_FORWARD_TIMEOUT_SECONDS) + self.assertIs(first.connect.call_args.kwargs['sock'], relay) + self.assertIs(target.connect.call_args.kwargs['sock'], first.get_transport().open_channel.return_value) + self.assertEqual(bridge.sftp_endpoint()['network_origin'], 'windows') + bridge.close() + self.assertTrue(relay.close.called) + + def test_sftp_same_route_keeps_network_origins_distinct(self): + endpoints = [] + for origin in ('core', 'windows'): + with ExitStack() as stack: + stack.enter_context(patch.object(paramiko, 'SSHClient', side_effect=Mock)) + bridge = self.bridge() + stack.enter_context(patch.object(bridge._host_key_store, 'snapshot', + return_value={'keys': [], 'host_key_name': 'localhost'})) + stack.enter_context(patch.object(ssh, 'WindowsNetworkSocket', return_value=Mock())) + stack.enter_context(patch.object(ssh.socket, 'create_connection', return_value=Mock())) + bridge.network_origin = origin + success, error = bridge._connect_route([self.node], 80, 24) + self.assertTrue(success, error) + endpoints.append(bridge.sftp_endpoint()) + self.assertNotEqual(endpoints[0], endpoints[1]) + self.assertNotIn('network_origin', endpoints[0]) + self.assertEqual(endpoints[1].pop('network_origin'), 'windows') + self.assertEqual(endpoints[0], endpoints[1]) + + def test_windows_connect_failure_never_falls_back_to_core(self): + bridge = self.bridge() + relay = Mock() + relay.connect.side_effect = OSError('Fixture Windows connection failure') + with patch.object(bridge._host_key_store, 'snapshot', return_value={'keys': [], 'host_key_name': 'localhost'}), \ + patch.object(ssh, 'WindowsNetworkSocket', return_value=relay), \ + patch.object(ssh.socket, 'create_connection') as direct: + success, error = bridge.connect('localhost', 22, 'fixture', network_origin='windows') + self.assertFalse(success) + self.assertEqual(error['error_code'], 'ssh_route_failed') + self.assertIsNone(bridge.sftp_endpoint()) + direct.assert_not_called() + bridge.close() + relay.close.assert_called_once_with() + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/ssh_windows_network_smoke.py b/tests/ssh_windows_network_smoke.py new file mode 100644 index 0000000..075fb1c --- /dev/null +++ b/tests/ssh_windows_network_smoke.py @@ -0,0 +1,84 @@ +"""Exercise real SSH through Windows networking with disposable WSL OpenSSH servers.""" + +import functools +from pathlib import Path +import sys +import unittest +from unittest.mock import patch + +sys.path.insert(0, str(Path(__file__).resolve().parent)) +import ssh_jump_smoke as fixture +from terminal_backends.windows_network import windows_network_executable, WindowsNetworkSocket + + +@unittest.skipUnless(windows_network_executable(), 'Requires WSL interoperability and Windows PowerShell') +class WindowsSSHTests(fixture.SSHJumpTests): + def bridge(self, servers): + bridge = super().bridge(servers) + bridge.connect = functools.partial(bridge.connect, network_origin='windows') + return bridge + + def test_windows_localhost_requires_trust_before_browser_key_or_local_authentication(self): + servers = [self.stack.enter_context(fixture.server())] + route = self.route(servers) + route[0]['host_key_alias'] = '' + bridge = self.bridge(servers) + with patch.object(bridge, '_connect_with_local_keys') as keys: + success, result = self.connect(bridge, route) + self.assertFalse(success) + self.assertEqual(result['error_code'], 'ssh_host_key_unknown') + self.assertEqual(self.signatures, []) + keys.assert_not_called() + bridge.close() + + bridge = self.bridge(servers) + prompts = [] + def reject_trust(value): + if value['message_type'] == 'ssh_login_prompt': + prompts.append(value['kind']) + self.assertEqual(value['kind'], 'host_key') + reply = {key: value[key] for key in ('attempt_id', 'node_id', 'kind', 'request_id')} + bridge.resolve_login_input('test-sid', {**reply, 'terminal_id': 'main', 'accept': False}) + bridge.emit_output = reject_trust + route[0]['browser_key'] = None + with patch.object(bridge, '_connect_with_local_keys') as keys: + success, result = bridge.connect('127.0.0.1', route[0]['port'], route[0]['username'], + route=route, attempt_id='test-attempt', interactive_login=True) + self.assertFalse(success) + self.assertEqual(prompts, ['host_key']) + keys.assert_not_called() + + def test_large_sftp_round_trip_preserves_origin_and_failure_never_uses_core_network(self): + servers = [self.stack.enter_context(fixture.server())] + route = self.route(servers) + self.trust(route, servers) + bridge = self.bridge(servers) + success, result = self.connect(bridge, route) + self.assertTrue(success, result) + self.assertEqual(bridge.sftp_endpoint()['network_origin'], 'windows') + with patch.object(fixture.TerminalBridge, 'metadata', return_value={}): + self.assertEqual(bridge.metadata()['ssh_target']['network_origin'], 'windows') + data = bytes(range(256)) * 16384 + file = servers[0]['root'] / 'binary-payload' + with bridge.ssh.open_sftp() as sftp: + with sftp.open(str(file), 'wb') as output: + output.write(data) + with sftp.open(str(file), 'rb') as source: + self.assertEqual(source.read(), data) + self.assertEqual(file.read_bytes(), data) + relay = next(item for item in bridge._connection_resources if isinstance(item, WindowsNetworkSocket)) + bridge.close() + self.assertTrue(relay.closed) + self.assertIsNotNone(relay.process.poll()) + + bridge = self.bridge(servers) + with patch('terminal_backends.ssh.WindowsNetworkSocket.connect', side_effect=OSError('Windows unavailable')), \ + patch('terminal_backends.ssh.socket.create_connection') as direct: + success, result = self.connect(bridge, route) + self.assertFalse(success) + self.assertEqual(result['error_code'], 'ssh_route_failed') + direct.assert_not_called() + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/windows_network_smoke.py b/tests/windows_network_smoke.py new file mode 100644 index 0000000..bf2c72c --- /dev/null +++ b/tests/windows_network_smoke.py @@ -0,0 +1,140 @@ +"""Run on WSL with Windows PowerShell and Node available; no installed service is changed.""" + +import json +from pathlib import Path +import shutil +import socket +import subprocess +import sys +import threading +import time +import unittest + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) +from terminal_backends.windows_network import WindowsNetworkSocket, windows_network_executable + + +NODE = shutil.which('node.exe') +SERVER = ''' +const net = require('net'), sockets = new Set(); +const server = net.createServer(c => { + sockets.add(c); c.on('error', () => {}); c.on('close', () => sockets.delete(c)); + if (process.argv[1] === 'sink') c.pause(); else c.pipe(c); +}); +server.listen(0, '127.0.0.1', () => console.log(JSON.stringify({port: server.address().port}))); +process.stdin.on('data', () => { for (const c of sockets) c.end(); }); +process.stdin.on('end', () => { for (const c of sockets) c.destroy(); server.close(); }); +''' + + +@unittest.skipUnless(windows_network_executable() and NODE, 'Requires WSL interop, Windows PowerShell and Windows Node') +class WindowsNetworkTests(unittest.TestCase): + def server(self, mode='echo'): + process = subprocess.Popen([NODE, '-e', SERVER, mode], stdin=subprocess.PIPE, + stdout=subprocess.PIPE, stderr=subprocess.DEVNULL) + def close(): + process.stdin.close() + process.wait(timeout=5) + process.stdout.close() + self.addCleanup(close) + return process, json.loads(process.stdout.readline())['port'] + + def transport(self, port): + sock = WindowsNetworkSocket() + self.addCleanup(sock.close) + sock.connect(('127.0.0.1', port), 15) + self.assertIsNotNone(sock.helper_pid) + return sock + + def assert_helper_gone(self, sock): + sock.close() + self.assertIsNotNone(sock.process.poll()) + # Check the actual Windows process, not just its WSL interop PID. + result = subprocess.run([windows_network_executable(), '-NoProfile', '-NonInteractive', '-Command', + f'if (Get-Process -Id {sock.helper_pid} -ErrorAction SilentlyContinue) {{ exit 1 }}'], + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=10) + self.assertEqual(result.returncode, 0) + + def test_binary_full_duplex_timeout_remote_eof_and_windows_process_exit(self): + server, port = self.server() + sock = self.transport(port) + sock.settimeout(0.1) + with self.assertRaises(socket.timeout): + sock.recv(1) + sock.settimeout(10) + data = bytes(range(256)) * 16384 + errors = [] + def send(): + try: + remaining = memoryview(data) + while remaining: + remaining = remaining[sock.send(remaining):] + except Exception as error: + errors.append(error) + writer = threading.Thread(target=send) + writer.start() + received = bytearray() + while len(received) < len(data): + chunk = sock.recv(65536) + self.assertTrue(chunk) + received.extend(chunk) + writer.join(5) + self.assertFalse(writer.is_alive()) + self.assertEqual(errors, []) + self.assertEqual(received, data) + server.stdin.write(b'close\n'); server.stdin.flush() + self.assertEqual(sock.recv(1), b'') + self.assert_helper_gone(sock) + + def test_backpressure_is_bounded_and_close_stops_the_windows_peer(self): + _, port = self.server('sink') + sock = self.transport(port) + sock.settimeout(0.2) + block = b'x' * 65536 + sent = 0 + with self.assertRaises(socket.timeout): + while sent < 64 * 1024 * 1024: + sent += sock.send(block) + self.assertGreater(sent, 0) + self.assertLess(sent, 64 * 1024 * 1024) + self.assert_helper_gone(sock) + + def test_cancel_during_connect_does_not_leave_a_windows_process(self): + sock = WindowsNetworkSocket() + self.addCleanup(sock.close) + errors = [] + def connect(): + try: + sock.connect(('192.0.2.1', 22), 15) + except OSError as error: + errors.append(error) + worker = threading.Thread(target=connect) + worker.start() + deadline = time.monotonic() + 10 + while sock.helper_pid is None and worker.is_alive() and time.monotonic() < deadline: + time.sleep(0.05) + self.assertIsNotNone(sock.helper_pid) + self.assert_helper_gone(sock) + worker.join(3) + self.assertFalse(worker.is_alive()) + self.assertTrue(errors) + + def test_parent_crash_closes_an_established_windows_connection(self): + _, port = self.server() + code = '''import os, sys +from terminal_backends.windows_network import WindowsNetworkSocket +s = WindowsNetworkSocket(); s.connect(('127.0.0.1', int(sys.argv[1])), 15) +print(s.helper_pid, flush=True) +os._exit(0) +''' + worker = subprocess.Popen([sys.executable, '-c', code, str(port)], stdout=subprocess.PIPE) + pid = int(worker.stdout.readline()) + worker.wait(timeout=5); worker.stdout.close() + result = subprocess.run([windows_network_executable(), '-NoProfile', '-NonInteractive', '-Command', + f'if (Get-Process -Id {pid} -ErrorAction SilentlyContinue) {{ exit 1 }}'], + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=10) + self.assertEqual(result.returncode, 0) + + +if __name__ == '__main__': + unittest.main() From 2117c8d36a8a3c88e01df071495da158b2c05670 Mon Sep 17 00:00:00 2001 From: "ASKA C." Date: Wed, 23 Sep 2026 23:14:30 +0800 Subject: [PATCH 41/43] Collapse Windows SSH networking under Advanced --- desktop/README.md | 5 ++-- docs/ui_copy_review.tsv | 5 ++-- static/js/standterm-messages.js | 10 +++---- templates/index.html | 28 +++++++++++-------- tests/ssh_network_origin_browser_smoke.py | 34 +++++++++++++++++++---- 5 files changed, 54 insertions(+), 28 deletions(-) diff --git a/desktop/README.md b/desktop/README.md index 8369b5c..c9da219 100644 --- a/desktop/README.md +++ b/desktop/README.md @@ -29,8 +29,9 @@ It uses the cached Desktop language and closes when the main window is ready or startup fails. Launching the same mode again focuses startup or its setup window while Core is still starting. -WSL Core offers **SSH network source (this connection)** when WSL interoperability -and `powershell.exe` on its PATH are available. **Windows (preview)** opens the +WSL Core offers **Advanced → Use Windows networking (preview)** when WSL interoperability +and `powershell.exe` on its PATH are available. Advanced is collapsed by default; +leaving the checkbox unchecked uses Core networking. Enabling it opens the first SSH hop through a temporary Windows PowerShell TCP helper. It needs Windows PowerShell policy to allow that helper and Windows DNS/routing/firewall to permit the target connection. No Windows SSH server, Python, administrator access, new diff --git a/docs/ui_copy_review.tsv b/docs/ui_copy_review.tsv index ba4be8a..cdb244d 100644 --- a/docs/ui_copy_review.tsv +++ b/docs/ui_copy_review.tsv @@ -798,7 +798,6 @@ browser.files.copy_unknown_detail {detail} Inspect the destination before retryi browser.chrome.key_unavailable The browser SSH key is unavailable. The browser SSH key is unavailable. 無法使用瀏覽器 SSH 金鑰。 Browser-owned display fallback only. Preserve raw labels and identifiers. translation-reviewed templates/index.html browser.chrome.terminal Terminal Terminal 終端 Browser-owned display fallback only. Preserve raw labels and identifiers. translation-reviewed templates/index.html browser.chrome.file_transfer File transfer File transfer 檔案傳輸 Browser-owned display fallback only. Preserve raw labels and identifiers. translation-reviewed templates/index.html -connection.network_origin SSH network source (this connection) SSH network source (this connection) SSH 網路來源(本次連線) WSL SSH first-hop network source preview. Display only. Keep core/windows payload values literal; preserve Core, WSL, Windows and localhost. translation-reviewed templates/index.html -connection.network_core Core (WSL) Core (WSL) Core(WSL) WSL SSH first-hop network source preview. Display only. Keep core/windows payload values literal; preserve Core, WSL, Windows and localhost. translation-reviewed templates/index.html -connection.network_windows Windows (preview) Windows (preview) Windows(試用) WSL SSH first-hop network source preview. Display only. Keep core/windows payload values literal; preserve Core, WSL, Windows and localhost. translation-reviewed templates/index.html connection.network_hint First hop only; not saved in profiles. With Windows selected, localhost means the Windows host. First hop only; not saved in profiles. With Windows selected, localhost means the Windows host. 僅影響第一站,不隨設定檔儲存。選 Windows 時,localhost 指 Windows 本機。 WSL SSH first-hop network source preview. Display only. Keep core/windows payload values literal; preserve Core, WSL, Windows and localhost. translation-reviewed templates/index.html +connection.advanced Advanced Advanced 進階 Collapsed SSH network settings for this connection. Display only. Preserve the typed core/windows payload values. translation-reviewed templates/index.html +connection.use_windows_network Use Windows networking (preview) Use Windows networking (preview) 使用 Windows 網路(試用) Collapsed SSH network settings for this connection. Display only. Preserve the typed core/windows payload values. translation-reviewed templates/index.html diff --git a/static/js/standterm-messages.js b/static/js/standterm-messages.js index 1a8fddc..dc73e11 100644 --- a/static/js/standterm-messages.js +++ b/static/js/standterm-messages.js @@ -432,6 +432,7 @@ "common.or": "or", "common.refresh": "Refresh Status", "common.yes": "Yes", + "connection.advanced": "Advanced", "connection.baud": "Baud rate", "connection.cancelled": "Connection cancelled.", "connection.choose_route": "Choose a saved route...", @@ -462,10 +463,7 @@ "connection.local_shell": "Local Shell", "connection.local_shell_hint": "Runs on the StandTerm host.", "connection.manual_port": "Manual port\u2026", - "connection.network_core": "Core (WSL)", "connection.network_hint": "First hop only; not saved in profiles. With Windows selected, localhost means the Windows host.", - "connection.network_origin": "SSH network source (this connection)", - "connection.network_windows": "Windows (preview)", "connection.no_routes": "No saved routes", "connection.none_selected": "None selected", "connection.password": "Password (optional)", @@ -488,6 +486,7 @@ "connection.trust_key": "Trust key", "connection.uart_example": "COM3 or /dev/ttyUSB0", "connection.uart_port": "UART port", + "connection.use_windows_network": "Use Windows networking (preview)", "connection.username": "Username", "connection.verify_authenticate": "Verifying host and authenticating", "connection.will_save": "Will save on Connect.", @@ -1236,6 +1235,7 @@ "common.or": "\u6216", "common.refresh": "\u66f4\u65b0\u72c0\u614b", "common.yes": "\u662f", + "connection.advanced": "\u9032\u968e", "connection.baud": "\u9b91\u7387", "connection.cancelled": "\u5df2\u53d6\u6d88\u9023\u7dda\u3002", "connection.choose_route": "\u9078\u64c7\u5df2\u5132\u5b58\u8def\u5f91\u2026", @@ -1266,10 +1266,7 @@ "connection.local_shell": "\u672c\u6a5f Shell", "connection.local_shell_hint": "\u5728 StandTerm \u4e3b\u6a5f\u4e0a\u57f7\u884c\u3002", "connection.manual_port": "\u624b\u52d5\u8f38\u5165\u5e8f\u5217\u57e0\u2026", - "connection.network_core": "Core\uff08WSL\uff09", "connection.network_hint": "\u50c5\u5f71\u97ff\u7b2c\u4e00\u7ad9\uff0c\u4e0d\u96a8\u8a2d\u5b9a\u6a94\u5132\u5b58\u3002\u9078 Windows \u6642\uff0clocalhost \u6307 Windows \u672c\u6a5f\u3002", - "connection.network_origin": "SSH \u7db2\u8def\u4f86\u6e90\uff08\u672c\u6b21\u9023\u7dda\uff09", - "connection.network_windows": "Windows\uff08\u8a66\u7528\uff09", "connection.no_routes": "\u6c92\u6709\u5df2\u5132\u5b58\u8def\u5f91", "connection.none_selected": "\u5c1a\u672a\u9078\u64c7", "connection.password": "\u5bc6\u78bc\uff08\u9078\u586b\uff09", @@ -1292,6 +1289,7 @@ "connection.trust_key": "\u4fe1\u4efb\u91d1\u9470", "connection.uart_example": "COM3 \u6216 /dev/ttyUSB0", "connection.uart_port": "\u5e8f\u5217\u57e0", + "connection.use_windows_network": "\u4f7f\u7528 Windows \u7db2\u8def\uff08\u8a66\u7528\uff09", "connection.username": "\u4f7f\u7528\u8005\u540d\u7a31", "connection.verify_authenticate": "\u6b63\u5728\u9a57\u8b49\u4e3b\u6a5f\u8207\u8eab\u5206", "connection.will_save": "\u5c07\u65bc\u6309\u4e0b\u9023\u7dda\u6642\u5132\u5b58\u3002", diff --git a/templates/index.html b/templates/index.html index 3b5394d..db02817 100644 --- a/templates/index.html +++ b/templates/index.html @@ -553,6 +553,11 @@ .ssh-save-options label { display: flex; align-items: center; gap: 8px; cursor: pointer; user-select: none; } .ssh-save-options label[hidden] { display: none; } #controls .ssh-save-options input { display: inline-block; width: auto; margin: 0; padding: 0; } + #ssh-network-origin-field { margin: 10px 0; color: #bbb; font-size: 12px; } + #ssh-network-origin-field summary { cursor: pointer; } + #ssh-network-origin-field label { display: flex; align-items: center; gap: 8px; margin-top: 10px; cursor: pointer; } + #controls #ssh-network-origin { width: auto; margin: 0; padding: 0; } + #ssh-network-origin-hint { margin: 8px 0 0; line-height: 1.5; } .ssh-session-picker { position: relative; margin-bottom: 15px; } #controls #ssh-session-picker-toggle { width: 100%; padding: 9px 10px; background: #333; border: 1px solid #555; color: #ddd; border-radius: 4px; font-weight: normal; text-align: left; } #controls #ssh-session-picker-toggle::after { content: "\25BE"; float: right; color: #888; } @@ -1378,14 +1383,6 @@

StandTerm

-
@@ -1418,6 +1415,11 @@

StandTerm

+
@@ -5795,7 +5797,10 @@

Restore StandTerm access

.some(option => option.value === 'windows'); document.getElementById('ssh-network-origin-field').hidden = !windowsNetworkAvailable; sshNetworkOrigin.disabled = !windowsNetworkAvailable; - if (!windowsNetworkAvailable) sshNetworkOrigin.value = 'core'; + if (!windowsNetworkAvailable) { + sshNetworkOrigin.checked = false; + document.getElementById('ssh-network-origin-field').open = false; + } localShellFields.style.display = ( normalized === 'local_shell' && localShellKindSelect.options.length > 0 ) ? 'block' : 'none'; @@ -8306,7 +8311,7 @@

Restore StandTerm access

terminal_id: activeTerminalId }; if (connectionType === 'ssh') { - if (!sshNetworkOrigin.disabled && sshNetworkOrigin.value === 'windows') formData.network_origin = 'windows'; + if (!sshNetworkOrigin.disabled && sshNetworkOrigin.checked) formData.network_origin = 'windows'; if (sshPreparationMode === 'route') { const entry = currentSshRouteEntry(); if (!entry) throw new Error(t('browser.chrome.choose_route')); @@ -8343,7 +8348,8 @@

Restore StandTerm access

} function resetSshFormFields() { - sshNetworkOrigin.value = 'core'; + sshNetworkOrigin.checked = false; + document.getElementById('ssh-network-origin-field').open = false; Object.entries(CONNECTION_FIELD_IDS.ssh).forEach(([fieldName, id]) => { clearConnectionFieldEdited('ssh', fieldName); const input = document.getElementById(id); diff --git a/tests/ssh_network_origin_browser_smoke.py b/tests/ssh_network_origin_browser_smoke.py index 4ba519d..e8cb59d 100644 --- a/tests/ssh_network_origin_browser_smoke.py +++ b/tests/ssh_network_origin_browser_smoke.py @@ -27,13 +27,23 @@ def test_selection_and_route_scope(browser, url, locale): routes.show_ssh(page) set_available(page, True) assert page.locator('#ssh-network-origin-field').is_visible() - assert page.input_value('#ssh-network-origin') == 'core' + assert page.locator('#ssh-network-origin').is_hidden() + assert not page.is_checked('#ssh-network-origin') + assert page.locator('#ssh-network-origin-field summary').inner_text() == ( + '進階' if locale == 'zh-TW' else 'Advanced') + collapsed_height = page.locator('#controls').bounding_box()['height'] + page.locator('#ssh-network-origin-field summary').click() + assert page.locator('#ssh-network-origin').is_visible() + assert page.locator('#controls').bounding_box()['height'] > collapsed_height assert page.locator('label[for="ssh-network-origin"]').inner_text() == ( - 'SSH 網路來源(本次連線)' if locale == 'zh-TW' else 'SSH network source (this connection)') + '使用 Windows 網路(試用)' if locale == 'zh-TW' else 'Use Windows networking (preview)') page.fill('#host', 'target.test'); page.fill('#username', 'operator') direct = page.evaluate('() => window.terminalTest.prepareSshConnectionForTest()') assert 'network_origin' not in direct - page.select_option('#ssh-network-origin', 'windows') + page.check('#ssh-network-origin') + page.locator('#ssh-network-origin-field summary').click() + assert page.locator('#ssh-network-origin').is_hidden() + assert page.is_checked('#ssh-network-origin') direct = page.evaluate('() => window.terminalTest.prepareSshConnectionForTest()') assert direct['network_origin'] == 'windows' page.evaluate('''() => window.terminalTest.setSshSessionState({version:2, revision:0, @@ -53,7 +63,14 @@ def test_selection_and_route_scope(browser, url, locale): set_available(page, False) assert page.locator('#ssh-network-origin-field').is_hidden() assert page.locator('#ssh-network-origin').is_disabled() - assert page.input_value('#ssh-network-origin') == 'core' + assert not page.is_checked('#ssh-network-origin') + route = page.evaluate('() => window.terminalTest.prepareSshConnectionForTest()') + assert 'network_origin' not in route + set_available(page, True) + assert page.locator('#ssh-network-origin').is_hidden() + page.locator('#ssh-network-origin-field summary').click() + page.check('#ssh-network-origin') + page.uncheck('#ssh-network-origin') route = page.evaluate('() => window.terminalTest.prepareSshConnectionForTest()') assert 'network_origin' not in route finally: @@ -66,7 +83,8 @@ def test_trust_retry_keeps_the_selected_network(browser, url): routes.show_ssh(page) set_available(page, True) page.fill('#host', 'target.test'); page.fill('#username', 'operator') - page.select_option('#ssh-network-origin', 'windows') + page.locator('#ssh-network-origin-field summary').click() + page.check('#ssh-network-origin') page.evaluate('''() => { window.terminalTest.captureSshStartsForTest(); window.terminalTest.clearEmitted(); @@ -94,7 +112,11 @@ def test_trust_retry_keeps_the_selected_network(browser, url): page.click('#actionYesBtn') # The synthetic retry hides the form; still exercise its change handler # before delivering the deliberately delayed trust result. - page.select_option('#ssh-network-origin', 'core', force=True) + page.evaluate('''() => { + const input = document.getElementById('ssh-network-origin'); + input.checked = false; + input.dispatchEvent(new Event('change', {bubbles: true})); + }''') page.evaluate('data => window.terminalTest.handleSshOutput(data)', {**response, 'attempt_id': starts[1]['attempt_id'], 'action_id': 'trust-second'}) page.wait_for_timeout(200) From 0c8e7f44409bf0d6a32d8c24efce5cba407742df Mon Sep 17 00:00:00 2001 From: "ASKA C." Date: Wed, 23 Sep 2026 23:41:17 +0800 Subject: [PATCH 42/43] Release Desktop 0.5.3 with Core 2.14.0 --- README.md | 5 +++++ core_version.py | 2 +- desktop/README.md | 12 +++++++++--- desktop/package-lock.json | 4 ++-- desktop/package.json | 2 +- tests/agent_backend_smoke.py | 5 ++++- 6 files changed, 22 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 745a241..3ced0c5 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,11 @@ to the StandTerm server process across page reloads. [Download and install StandTerm Desktop](#desktop-downloads-evaluation), or use the [browser-based Core quick start](#quick-start). +The current source is **Core 2.14.0**, paired with Desktop 0.5.3. It adds English +and Traditional Chinese interface text, simplified Agent connection controls, +and optional Windows networking for SSH from WSL. The published downloads below +retain their original versions until new packages are uploaded. + **Core 2.13.0** is a [source release](https://github.com/askac/standterm/releases/tag/v2.13.0). It adds SSH routes with up to three jump hosts, ordered node editing and per-site login cards. Direct connections and route nodes share browser-key controls; diff --git a/core_version.py b/core_version.py index 317acc8..8eb4d62 100644 --- a/core_version.py +++ b/core_version.py @@ -1,3 +1,3 @@ """Core release identity, shared by source and packaged launchers.""" -CORE_VERSION = '2.13.1-dev' +CORE_VERSION = '2.14.0' diff --git a/desktop/README.md b/desktop/README.md index c9da219..5b451b0 100644 --- a/desktop/README.md +++ b/desktop/README.md @@ -6,6 +6,10 @@ source-run workflow, an unsigned Windows x64 evaluation installer and a native Apple Silicon macOS evaluation app/DMG. It is not a production release or a replacement for `run.sh` / `run.bat`. +The 0.5.3 evaluation bundles Core 2.14.0, adds startup feedback and window +state restoration, and includes the optional Windows SSH network source for WSL +under the collapsed Advanced connection settings. + Desktop copy and localization are planned in the [review plan](../docs/desktop_ui_review_plan.md), with a separate [translation review table](../docs/desktop_ui_copy_review.tsv). @@ -100,7 +104,7 @@ The published [Windows **0.5.1 / Core 2.13.0-dev** evaluation](https://github.co includes ordered SSH jump routes, per-site login cards, shared Direct/node key controls and opt-in profile/route saving on Connect. It retains its original development identity. -The current source declares Core **2.13.0**; matching installers require a fresh +The current source declares Core **2.14.0**; matching installers require a fresh build and validation. A new Mac installer remains separate; the 0.5.0 candidates remain local. @@ -156,13 +160,15 @@ Build on an Apple Silicon Mac with Node 22.12+ and the checkout's macOS venv: ```sh cd desktop npm ci -npm run stage:mac -- --core-ref v2.13.0 +npm run stage:mac -- --core-ref HEAD # Change to the absolute stage directory printed above, then: npm ci npm run make:mac ``` -Staging shares the tracked-file allowlist used by Windows, excludes internal +Use a clean checkout of the intended release commit: `HEAD` selects that Core +revision, while the Desktop shell comes from the working tree. Staging shares +the tracked-file allowlist used by Windows, excludes internal documents and Git state, and generates the native icon with macOS `sips` and `iconutil`. macOS outputs are in the stage's `out.noindex/` directory so local development app copies stay out of Spotlight results. The current build uses diff --git a/desktop/package-lock.json b/desktop/package-lock.json index eae2c3e..318d752 100644 --- a/desktop/package-lock.json +++ b/desktop/package-lock.json @@ -1,12 +1,12 @@ { "name": "standterm-desktop-evaluation", - "version": "0.5.2", + "version": "0.5.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "standterm-desktop-evaluation", - "version": "0.5.2", + "version": "0.5.3", "license": "MIT", "devDependencies": { "electron": "44.2.0", diff --git a/desktop/package.json b/desktop/package.json index a0c90cf..7c2378b 100644 --- a/desktop/package.json +++ b/desktop/package.json @@ -1,6 +1,6 @@ { "name": "standterm-desktop-evaluation", - "version": "0.5.2", + "version": "0.5.3", "private": true, "productName": "StandTerm Desktop", "author": "ASKA C.", diff --git a/tests/agent_backend_smoke.py b/tests/agent_backend_smoke.py index 491bc8b..882a91d 100644 --- a/tests/agent_backend_smoke.py +++ b/tests/agent_backend_smoke.py @@ -4538,6 +4538,7 @@ def test_browser_ssh_sign_request_store_uses_monotonic_deadlines(): def make_sftp_test_bridge(session_token, terminal_id=standterm.TERMINAL_ID_MAIN): bridge = object.__new__(standterm.SSHBridge) standterm.TerminalBridge.__init__(bridge, session_token, terminal_id) + bridge.network_origin = 'core' bridge._sftp_endpoint = { 'user': 'tester', 'host': 'host.example', @@ -6858,7 +6859,9 @@ def test_backend_start_form_schema_is_declared_and_typed(): standterm.SETTING_UART_DEFAULT_BAUD_RATE: 230400, }, ) - options = standterm.TERMINAL_BACKEND_REGISTRY.build_policy_options(context=context) + # Keep the base schema deterministic; optional Windows fields have dedicated coverage. + with patch('terminal_backends.ssh.windows_network_executable', return_value=None): + options = standterm.TERMINAL_BACKEND_REGISTRY.build_policy_options(context=context) finally: standterm.is_wsl = original_is_wsl From 5939a1f2de511694707a198ca07a455b1712beb7 Mon Sep 17 00:00:00 2001 From: "ASKA C." Date: Thu, 24 Sep 2026 09:44:52 +0800 Subject: [PATCH 43/43] Update downloads for Core 2.14.0 and Desktop 0.5.3 --- README.md | 36 +++++++++++++++++++----------------- 1 file changed, 19 insertions(+), 17 deletions(-) diff --git a/README.md b/README.md index 3ced0c5..7ed0985 100644 --- a/README.md +++ b/README.md @@ -9,10 +9,11 @@ to the StandTerm server process across page reloads. [Download and install StandTerm Desktop](#desktop-downloads-evaluation), or use the [browser-based Core quick start](#quick-start). -The current source is **Core 2.14.0**, paired with Desktop 0.5.3. It adds English +The current source is **[Core 2.14.0](https://github.com/askac/standterm/releases/tag/v2.14.0)**, +paired with Desktop 0.5.3. It adds English and Traditional Chinese interface text, simplified Agent connection controls, -and optional Windows networking for SSH from WSL. The published downloads below -retain their original versions until new packages are uploaded. +and optional Windows networking for SSH from WSL. Both Desktop packages below +contain Core 2.14.0 and use the same reviewed source. **Core 2.13.0** is a [source release](https://github.com/askac/standterm/releases/tag/v2.13.0). It adds SSH routes with up to three jump hosts, ordered node editing and per-site @@ -20,35 +21,36 @@ login cards. Direct connections and route nodes share browser-key controls; new keys stay temporary unless saving is selected when connecting. Existing SSH Agent Tunnel, current-tab Agent Info and host fingerprint controls remain available. IME input-line anchoring remains an [experimental PoC](docs/ime_anchor_poc.md). -The Windows Desktop 0.5.1 evaluation below contains Core 2.13.0-dev with these -features. The macOS Desktop 0.4.3 evaluation predates them. +These features are included in both current Desktop packages. ![StandTerm Desktop with terminal rendering tests, local and SSH tabs, and a floating PowerShell terminal](standterm_desktop.png) -*Desktop preview. The controls shown are available in the Windows 0.5.1 -evaluation; the macOS 0.4.3 evaluation predates them.* +*Desktop preview from the Windows 0.5.1 evaluation; current interface text and +controls may differ.* ## Desktop Downloads (Evaluation) -[Windows Desktop 0.5.1 / Core 2.13.0-dev](https://github.com/askac/standterm/releases/tag/desktop-v0.5.1-2.13.0-dev) -and [macOS Desktop 0.4.3](https://github.com/askac/standterm/releases/tag/desktop-v0.4.3) -are evaluation pre-releases. The Windows package retains its tested development -Core identity; it was built before the formal Core 2.13.0 source release. +[Desktop 0.5.3 / Core 2.14.0](https://github.com/askac/standterm/releases/tag/desktop-v0.5.3-2.14.0) +is an evaluation pre-release for Windows x64 and macOS Apple Silicon. + +**Known issue:** repeated maximize, restore and reopen cycles can accumulate +window position and size drift on macOS. A shared-code fix and Windows regression +checks are pending; window-restoration acceptance is incomplete. The packages +retain the tested source rather than including an unverified fix. | Platform | Download | Required before installation | | --- | --- | --- | -| Windows x64, including Windows + WSL | [Desktop 0.5.1 / Core 2.13.0-dev (.exe)](https://github.com/askac/standterm/releases/download/desktop-v0.5.1-2.13.0-dev/StandTerm-Desktop-0.5.1-2.13.0-dev-win32-x64-Setup.exe) | Python 3.10+ with venv/ensurepip in each selected environment; WSL mode also needs an existing WSL distribution. | -| macOS Apple Silicon | [macOS installer (.dmg)](https://github.com/askac/standterm/releases/download/desktop-v0.4.3/StandTerm-Desktop-0.4.3-mac-arm64.dmg) | Native arm64 Python 3.10+ with venv/ensurepip. Intel/Rosetta is not qualified. | +| Windows x64, including Windows + WSL | [Desktop 0.5.3 / Core 2.14.0 (.exe)](https://github.com/askac/standterm/releases/download/desktop-v0.5.3-2.14.0/StandTerm-Desktop-0.5.3-2.14.0-win32-x64-Setup.exe) | Python 3.10+ with venv/ensurepip in each selected environment; WSL mode also needs an existing WSL distribution. | +| macOS Apple Silicon | [Desktop 0.5.3 / Core 2.14.0 (.dmg)](https://github.com/askac/standterm/releases/download/desktop-v0.5.3-2.14.0/StandTerm-Desktop-0.5.3-2.14.0-mac-arm64.dmg) | Native arm64 Python 3.10+ with venv/ensurepip. Intel/Rosetta is not qualified. | Packages include Electron and Core. **Git, Node.js and npm are not required**; Python and its virtual environment are not bundled. -Windows Desktop 0.5.1 includes an optional advanced Git Core source, which +Desktop includes an optional advanced Git Core source, which requires Git in the selected backend environment, plus bundled Core recovery -without Git. Those controls are not included in the macOS 0.4.3 installer. +without Git. 1. Download the package for your platform and verify its checksum: - [Windows SHA256SUMS](https://github.com/askac/standterm/releases/download/desktop-v0.5.1-2.13.0-dev/SHA256SUMS) - or [macOS SHA256SUMS](https://github.com/askac/standterm/releases/download/desktop-v0.4.3/SHA256SUMS). + [SHA256SUMS for both platforms](https://github.com/askac/standterm/releases/download/desktop-v0.5.3-2.14.0/SHA256SUMS). 2. On Windows, run the installer and choose **Windows only**, **Windows + WSL** or **WSL only**. Native Windows mode needs 64-bit Windows Python; installing Windows Python does not satisfy WSL mode. On macOS, copy the app to a