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 deccc68..7ed0985 100644 --- a/README.md +++ b/README.md @@ -9,41 +9,48 @@ 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](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. 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 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 @@ -277,6 +284,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. @@ -296,8 +323,34 @@ 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 **🤖 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 **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 (preview)** selects English or Traditional +Chinese (Taiwan) for the browser interface, including access and device recovery, +Agent permissions and approvals, connection/profile/route editors, settings, +tunnels, Files, and terminal popup/PiP controls. Terminal content, copyable +commands, plugin labels, technical validation errors and raw diagnostics retain +their original text. Native Desktop menus and windows are outside this coverage. +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. +**Save preferences** stores the General and Appearance preference fields. +Other settings have separate actions. **Reset preferences** immediately restores +all browser preference defaults and reloads the page, losing unsaved edits and +stopping this page's temporary SSH tunnels. Saved SSH profiles, history and keys, +and the Agent panel position remain stored. +Server runtime settings use individual **Apply** buttons. Their changes are shared +across the Core instance and last until Core restarts. Connection defaults do not +alter existing connections. +The [copy and translation table](docs/ui_copy_review.tsv) and +[localization plan](docs/agent_ui_review_plan.md) describe the review workflow; +the [browser acceptance record](docs/browser_ui_acceptance.md) defines the +tested scope and retained raw data. This development build also enables an **experimental IME positioning PoC**: the composition overlay follows its starting input line during terminal redraws. @@ -319,12 +372,25 @@ Useful launcher options: STANDTERM_HOST=127.0.0.1 STANDTERM_PORT=5000 ./run.sh ``` -## Browser-managed SSH Sessions And Keys +## Browser-managed SSH Profiles And Keys 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 -browser and never store passwords. In Direct connect, **Save session** saves the +SSH targets. Use **Settings > SSH profiles** to create, update, reorder, or +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 profile** 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 connection profile** 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 only successful connections and does not implicitly save a profile or private key. @@ -379,7 +445,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 +546,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. @@ -501,6 +571,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. @@ -517,13 +590,25 @@ that the backend canonicalizes, validates, and rechecks before publish. Downloads, copies, and file actions accept regular files only; symbolic links and other non-regular entries are rejected. -**Settings > General > Import & Export** transfers browser preferences, SSH -profiles and order, SSH history, and persistent UI layout in a versioned JSON -envelope containing a Base64 ZIP archive. Import merges profiles by stable ID, -appends new IDs, and deduplicates history. A local keyed profile keeps its local -host, port, and username so import cannot silently rebind its key. SSH keys, key -IDs, passwords, browser authorization identity, access tokens, and runtime -diagnostics are never included or changed by import. +**Settings > General > Import & Export** transfers browser preferences, Agent +panel position, SSH profiles/routes and history in a versioned JSON envelope +containing a Base64 ZIP archive. Import applies the valid preferences and panel +position supplied by the file, retaining values that were not supplied. Imported +SSH profiles and nodes receive new IDs; existing profiles and their key bindings +remain unchanged. Importing the same file again creates additional profiles. +History appends imported entries after existing entries and retains the first +six, without deduplication. + +SSH keys, key bindings, passwords, browser authorization identity, access tokens +and runtime diagnostics are excluded from exports. Imported browser-key routes +need a key selected again; existing browser key records remain intact. Successful +import reloads the page to apply preferences and stops this page's temporary SSH +tunnels. The confirmation describes these effects before any import writes. + +SSH settings are committed before browser preferences. If a later preference +write fails, the error reports that SSH settings were already saved. Check the +stored settings before importing again to avoid duplicate profiles; the import +does not roll back or retry automatically. ## Browser Authorization And HTTPS @@ -589,8 +674,8 @@ To enable recovery: select **Arm existing passkey** before relying on recovery for that live process. -When the session cookie is missing, select **Recover live session with device** -on the Access Required page or in the in-app recovery prompt. Recovery succeeds +When the session cookie is missing, select **Verify with device** +on the Access Required page or in the **Restore StandTerm access** dialog. Recovery succeeds only while that session remains active in the same `app.py` process. A backend restart, expired session, closed terminal bridge, or disconnected remote host cannot be reconstructed by the passkey. @@ -622,6 +707,56 @@ 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 +to and from the target. Remote rows identify the requested listener address; +they do not verify the SSH server's actual listening interfaces. +**Stop** stops forwarding and closes its 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. +If a request times out, its result is unconfirmed. Refresh status before deciding +whether to retry; the UI does not automatically resend start or stop requests. +Stopping does not wait for the SSH server to confirm remote listener removal. + +| 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 @@ -634,7 +769,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 @@ -642,11 +777,16 @@ command and asks the agent to run `hello` for each intended tab. Local token minting is not required: Start creates separate grants for Agent-enabled tabs. **Check Tunnel** checks the remote listener, helper bundle, and connection to -this Core instance again. The verification timestamp confirms that path works; +this Core instance again. A failed check stops the tunnel and revokes its grants. +The verification timestamp records the last successful check; each tab separately shows **waiting for agent** until Core receives an authenticated request through this tunnel. **Last authenticated request** is a historical timestamp, not a continuous connection indicator. **Refresh Status** reloads the current grants and activity without renewing their tokens. +On an active tunnel, **Start / Renew Access** updates grants without repeating +those checks or reinstalling helpers. It replaces invalid grants; valid tokens +keep their existing expiry. Use **Check Tunnel** to verify the path +again. Closing the dialog keeps the tunnel running. The SSH host needs Python 3.9+, SFTP, remote forwarding, and a way to inspect its listener bindings. Core supports Linux `/proc/net`, FreeBSD `netstat` JSON, @@ -660,7 +800,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. @@ -686,18 +826,19 @@ 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 **🤖 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. @@ -802,7 +943,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/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 b926f8b..1b1b521 100644 --- a/app.py +++ b/app.py @@ -23,7 +23,9 @@ 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 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 @@ -323,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' @@ -411,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' @@ -514,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' @@ -2518,6 +2523,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 = {} @@ -3634,6 +3641,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: @@ -4378,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 @@ -4386,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']: @@ -4413,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)) @@ -4427,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: @@ -5551,7 +5561,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 +5579,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) @@ -5618,7 +5630,7 @@ def build_access_required_response(): - StandTerm Access Required + StandTerm access required -

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,26 +36,28 @@ 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 }, - { 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))); + 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..3ef5963 100644 --- a/desktop/diagnostics.cjs +++ b/desktop/diagnostics.cjs @@ -2,9 +2,10 @@ 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']); + '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', @@ -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/electron-builder.cjs b/desktop/electron-builder.cjs index a3fc6fd..3144456 100644 --- a/desktop/electron-builder.cjs +++ b/desktop/electron-builder.cjs @@ -10,7 +10,7 @@ module.exports = { // Keep development app copies out of macOS Spotlight application results. directories: { output: process.platform === 'darwin' ? 'out.noindex' : 'out', buildResources: '.' }, asar: true, - files: ['*.cjs', '*.html', 'recorder.js', 'toolbar.js', 'toolbar.css', 'package.json', 'release-identity.json', 'README.md', 'LICENSE', + files: ['*.cjs', '*.html', 'recorder.js', 'toolbar.js', 'toolbar.css', 'messages.js', 'i18n.js', 'package.json', 'release-identity.json', 'README.md', 'LICENSE', 'test/capture-smoke.cjs', 'test/floating-smoke.cjs', 'test/external-links-smoke.cjs', 'test/toolbar-smoke.cjs', '!electron-builder.cjs', '!stage-windows.cjs', '!build-icon.cjs', '!release-identity.cjs'], extraResources: [{ from: 'bundle', to: 'bundle' }], mac: { 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/i18n.js b/desktop/i18n.js new file mode 100644 index 0000000..17b1033 --- /dev/null +++ b/desktop/i18n.js @@ -0,0 +1,29 @@ +(function (root, factory) { + if (typeof module === 'object' && module.exports) module.exports = factory(require('./messages.js')); + else root.StandTermDesktopI18n = factory(root.StandTermDesktopMessages); +})(globalThis, function (messages) { + 'use strict'; + + const normalizeLocale = value => value === 'zh-TW' ? 'zh-TW' : 'en'; + const own = (object, key) => Object.prototype.hasOwnProperty.call(object, key); + function create(requestedLocale) { + const locale = normalizeLocale(requestedLocale); + const english = messages.en || {}; + const translated = messages[locale] || {}; + 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) { + for (const element of root.querySelectorAll('[data-i18n]')) element.textContent = t(element.dataset.i18n); + for (const attribute of ['title', 'aria-label']) { + for (const element of root.querySelectorAll(`[data-i18n-${attribute}]`)) { + element.setAttribute(attribute, t(element.getAttribute(`data-i18n-${attribute}`))); + } + } + } + return { locale, t, apply }; + } + return { normalizeLocale, create }; +}); 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/language.cjs b/desktop/language.cjs new file mode 100644 index 0000000..aac7113 --- /dev/null +++ b/desktop/language.cjs @@ -0,0 +1,39 @@ +'use strict'; + +const fs = require('node:fs'); +const path = require('node:path'); +const { randomUUID } = require('node:crypto'); +const { create, normalizeLocale } = require('./i18n.js'); + +function createLanguage(file) { + let selected = 'en'; + try { + if (fs.statSync(file).size <= 16384) { + const data = JSON.parse(fs.readFileSync(file, 'utf8')); + if (data?.version === 1) selected = normalizeLocale(data.locale); + } + } catch { /* Missing or invalid preferences use English. */ } + 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 { + 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 { + // 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 { get locale() { return active.locale; }, t: (...args) => active.t(...args), sync }; +} + +module.exports = { createLanguage }; 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 091b61b..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'); @@ -25,6 +25,9 @@ const { createUiCommands } = require('./ui-commands.cjs'); 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) { @@ -78,10 +81,14 @@ else if (app.isPackaged) { fs.mkdirSync(profile, { recursive: true }); app.setPath('userData', profile); } +const language = createLanguage(path.join(app.getPath('userData'), 'language.json')); +const { t } = language; let child; let desktopSession; const expectedBackendExits = new WeakSet(); let win; +let windowState; +let startupWindow; let tray; let capture; let captureTitle = ''; @@ -100,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 @@ -228,21 +241,28 @@ function createTray() { const icon = nativeImage.createFromBitmap(pixels, { width: size, height: size }); tray = new Tray(icon); tray.setToolTip('StandTerm Desktop'); - tray.setContextMenu(Menu.buildFromTemplate([ - { label: 'Open StandTerm', click: showWindow }, - { type: 'separator' }, - { label: 'Quit StandTerm', click: () => app.quit() }, - ])); + 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() }, + ])); +} + 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()); - 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(); }, }); @@ -258,7 +278,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); } @@ -267,13 +287,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 }), @@ -308,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. @@ -321,18 +341,28 @@ 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, } }); const contents = coreView.webContents; - const commands = createUiCommands(win, contents, handoff.origin); + 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}`); }; 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), @@ -340,15 +370,15 @@ async function start() { if (error) console.error(message); } } : {}), }); - toolbar = installToolbar(win, coreView, capture, commands); - installContextPaste(win, contents, handoff.origin, toolbar.notify); + toolbar = installToolbar(win, coreView, capture, commands, language.locale); + 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), 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, @@ -360,106 +390,114 @@ async function start() { updateTitle(); }); const connectionInfo = agentConnectionInfo({ origin: handoff.origin, instanceId: handoff.instance_id, mode }); - const coreVersion = handoff.core_version || 'Unknown (older Core)'; - 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.`; + 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(), + 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: [ - commands.item('settings'), - ...(coreManager ? [{ label: 'Core source (Advanced)...', click: () => { - void coreManager.showManager().catch(error => dialog.showErrorBox('Core management unavailable', error.message)); - } }] : []), - { label: '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: 'About StandTerm Desktop', click: () => dialog.showMessageBox(win, { - type: 'info', title: 'About StandTerm Desktop', message: `StandTerm Desktop ${app.getVersion()}`, - detail: aboutDetails, buttons: ['OK'], noLink: true, - }) }, - { label: 'Show window', click: showWindow }, - ...(process.platform === 'darwin' ? [{ role: 'services' }, { type: 'separator' }, { role: 'hide' }, { role: 'hideOthers' }, { role: 'unhide' }, { type: 'separator' }] : []), - { label: 'Quit StandTerm', accelerator: 'CommandOrControl+Q', click: () => app.quit() }, - ] }, - { id: 'edit', role: 'editMenu' }, - agentMenu({ - uiItems: [commands.item('agentPanel'), commands.item('pauseAgent'), { type: 'separator' }], - showHelp: () => dialog.showMessageBox(win, { - 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. ' - + '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.', - buttons: ['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: 'View', submenu: [{ role: 'resetZoom' }, { role: 'zoomIn' }, { role: 'zoomOut' }, { role: 'togglefullscreen' }, - { type: 'separator' }, - ...capture.menu().submenu, - ] }, - diagnosticsMenu({ origin: handoff.origin, mode, instanceId: handoff.instance_id, - 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.' }); + { 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: 'Could not open the diagnostics folder.', 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 }); - 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: '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; + void dialog.showMessageBox(owner, { + 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); + }).catch(() => diagnostics.write('download_notice_failed')); }); - installFloatingWindows(win, handoff.origin, openExternal, contents); contents.on('will-navigate', (event, url) => { if (!allowedNavigation(url, handoff.origin)) event.preventDefault(); }); @@ -468,11 +506,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; }); @@ -501,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) { @@ -513,8 +556,6 @@ async function start() { } console.log('Desktop smoke: authenticated terminal, sandbox and navigation checks passed.'); app.quit(); - } else { - showWindow(); } } @@ -525,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 }); @@ -535,12 +577,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; } } @@ -563,11 +605,13 @@ 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(); + windowState?.save(); + closeStartupWindow(); await finishBackendAndBrowser(); if (restartRequest) { if (restartRequest.action) await coreStore.queue(restartRequest.action); @@ -578,7 +622,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); }); @@ -591,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 new file mode 100644 index 0000000..be2552a --- /dev/null +++ b/desktop/messages.js @@ -0,0 +1,600 @@ +// Generated by scripts/build_ui_messages.py. Edit docs/desktop_ui_copy_review.tsv. +(function (root) { + '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.", + "desktop.agent.help_permissions": "Select the tab where your agent runs. In Agent Panel, enable access and choose permissions for each tab it may operate.", + "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.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.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", + "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.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.menu.about": "About StandTerm Desktop", + "desktop.menu.agent_panel": "Show / hide Agent Panel", + "desktop.menu.capture_settings": "Capture Settings...", + "desktop.menu.close_all": "Close all terminal tabs...", + "desktop.menu.close_tab": "Close terminal tab", + "desktop.menu.core_source": "Core source (Advanced)...", + "desktop.menu.files": "Files...", + "desktop.menu.new_tab": "New terminal tab", + "desktop.menu.open": "Open StandTerm", + "desktop.menu.pause_agent": "Pause Agent for current terminal", + "desktop.menu.pip": "Open terminal in PiP", + "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", + "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.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", + "desktop.toolbar.capture_scope": "Capture the terminal view", + "desktop.toolbar.clipboard_actions": "Clipboard actions", + "desktop.toolbar.copy_hint": "Copy selected text (not Ctrl+C)", + "desktop.toolbar.copy_label": "Copy selected text", + "desktop.toolbar.menu_agent": "Agent", + "desktop.toolbar.menu_diagnostics": "Diagnostics", + "desktop.toolbar.menu_edit": "Edit", + "desktop.toolbar.menu_standterm": "StandTerm", + "desktop.toolbar.menu_view": "View", + "desktop.toolbar.paste": "Paste clipboard text", + "desktop.toolbar.record_pause": "Pause recording", + "desktop.toolbar.record_resume": "Resume recording", + "desktop.toolbar.record_start": "Start recording (WebM, no audio)", + "desktop.toolbar.record_stop": "Stop and save recording", + "desktop.toolbar.screenshot_copy": "Copy screenshot to clipboard", + "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", + "desktop.agent.help_permissions": "\u9078\u53d6 Agent \u6240\u5728\u7684\u5206\u9801\u3002\u5728 Agent \u9762\u677f\uff08Agent Panel\uff09\u4e2d\uff0c\u9010\u4e00\u70ba Agent \u53ef\u64cd\u4f5c\u7684\u5206\u9801\u555f\u7528\u5b58\u53d6\u4e26\u9078\u64c7\u6b0a\u9650\u3002", + "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.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.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", + "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.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.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", + "desktop.menu.close_all": "\u95dc\u9589\u6240\u6709\u7d42\u7aef\u5206\u9801\u2026", + "desktop.menu.close_tab": "\u95dc\u9589\u7d42\u7aef\u5206\u9801", + "desktop.menu.core_source": "Core \u4f86\u6e90\uff08\u9032\u968e\uff09\u2026", + "desktop.menu.files": "\u6a94\u6848\u2026", + "desktop.menu.new_tab": "\u65b0\u589e\u7d42\u7aef\u5206\u9801", + "desktop.menu.open": "\u958b\u555f StandTerm", + "desktop.menu.pause_agent": "\u66ab\u505c\u76ee\u524d\u7d42\u7aef\u7684 Agent", + "desktop.menu.pip": "\u4ee5\u5b50\u6bcd\u756b\u9762\u958b\u555f\u7d42\u7aef", + "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", + "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.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", + "desktop.toolbar.capture_scope": "\u64f7\u53d6\u7d42\u7aef\u756b\u9762", + "desktop.toolbar.clipboard_actions": "\u526a\u8cbc\u7c3f\u64cd\u4f5c", + "desktop.toolbar.copy_hint": "\u8907\u88fd\u9078\u53d6\u6587\u5b57\uff08\u4e0d\u9001\u51fa Ctrl+C\uff09", + "desktop.toolbar.copy_label": "\u8907\u88fd\u9078\u53d6\u6587\u5b57", + "desktop.toolbar.menu_agent": "Agent", + "desktop.toolbar.menu_diagnostics": "\u8a3a\u65b7", + "desktop.toolbar.menu_edit": "\u7de8\u8f2f", + "desktop.toolbar.menu_standterm": "StandTerm", + "desktop.toolbar.menu_view": "\u6aa2\u8996", + "desktop.toolbar.paste": "\u8cbc\u4e0a\u526a\u8cbc\u7c3f\u6587\u5b57", + "desktop.toolbar.record_pause": "\u66ab\u505c\u9304\u5f71", + "desktop.toolbar.record_resume": "\u7e7c\u7e8c\u9304\u5f71", + "desktop.toolbar.record_start": "\u958b\u59cb\u9304\u5f71\uff08WebM\uff0c\u7121\u97f3\u8a0a\uff09", + "desktop.toolbar.record_stop": "\u505c\u6b62\u4e26\u5132\u5b58\u9304\u5f71", + "desktop.toolbar.screenshot_copy": "\u8907\u88fd\u622a\u5716\u81f3\u526a\u8cbc\u7c3f", + "desktop.toolbar.screenshot_save": "\u5132\u5b58\u622a\u5716\uff08PNG\uff09" + } +}; + if (typeof module === 'object' && module.exports) module.exports = messages; + else root.StandTermDesktopMessages = messages; +})(globalThis); diff --git a/desktop/package-lock.json b/desktop/package-lock.json index b74f890..318d752 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.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "standterm-desktop-evaluation", - "version": "0.5.1", + "version": "0.5.3", "license": "MIT", "devDependencies": { "electron": "44.2.0", diff --git a/desktop/package.json b/desktop/package.json index 8a67e5d..7c2378b 100644 --- a/desktop/package.json +++ b/desktop/package.json @@ -1,6 +1,6 @@ { "name": "standterm-desktop-evaluation", - "version": "0.5.1", + "version": "0.5.3", "private": true, "productName": "StandTerm Desktop", "author": "ASKA C.", 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/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/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/stage-windows.cjs b/desktop/stage-windows.cjs index ff73109..e06fd6a 100644 --- a/desktop/stage-windows.cjs +++ b/desktop/stage-windows.cjs @@ -35,6 +35,8 @@ const shellFiles = [ 'test/external-links-smoke.cjs', '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/agent-menu.test.cjs b/desktop/test/agent-menu.test.cjs index 4d4f09f..94d18a4 100644 --- a/desktop/test/agent-menu.test.cjs +++ b/desktop/test/agent-menu.test.cjs @@ -3,6 +3,7 @@ const test = require('node:test'); const assert = require('node:assert/strict'); const { agentMenu } = require('../agent-menu.cjs'); +const { create } = require('../i18n.js'); test('Agent menu delegates terminal authority to Core and offers guidance', () => { const calls = []; @@ -19,3 +20,14 @@ test('Agent menu delegates terminal authority to Core and offers guidance', () = for (const item of menu.submenu) if (item.click) item.click(); assert.deepEqual(calls, ['panel', 'pause', 'help']); }); + +test('translated Agent menu labels preserve command identities and help callback', () => { + let called = 0; + const { t } = create('zh-TW'); + const menu = agentMenu({ t, showHelp: () => called++ }); + assert.equal(menu.id, 'agent-menu'); + assert.equal(menu.submenu[0].id, 'agent-help'); + assert.equal(menu.submenu[0].label, t('desktop.agent.getting_started')); + menu.submenu[0].click(); + assert.equal(called, 1); +}); 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/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/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/context-paste.test.cjs b/desktop/test/context-paste.test.cjs index 7ab953a..a3b4e92 100644 --- a/desktop/test/context-paste.test.cjs +++ b/desktop/test/context-paste.test.cjs @@ -7,16 +7,17 @@ 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 }; 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); @@ -25,22 +26,56 @@ function fixture() { 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: () => { 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, + 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(); @@ -102,3 +137,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/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/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..a1c1f2b 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,110 @@ 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; } + setTitle(title) { this.title = title; } + 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/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/desktop/test/i18n.test.cjs b/desktop/test/i18n.test.cjs new file mode 100644 index 0000000..0066a7f --- /dev/null +++ b/desktop/test/i18n.test.cjs @@ -0,0 +1,28 @@ +'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, normalizeLocale } = require('../i18n.js'); +const messages = require('../messages.js'); + +test('the Desktop catalog contains reviewed bilingual messages and normalizes locales', () => { + assert.deepEqual(Object.keys(messages.en).sort(), Object.keys(messages['zh-TW']).sort()); + for (const value of [undefined, null, {}, 'zh-CN', 'constructor', '../zh-TW']) assert.equal(normalizeLocale(value), 'en'); + assert.equal(normalizeLocale('zh-TW'), 'zh-TW'); + assert.equal(create('en').t('desktop.menu.new_tab'), 'New terminal tab'); + assert.notEqual(create('zh-TW').t('desktop.menu.new_tab'), create('en').t('desktop.menu.new_tab')); + assert.equal(create('en').t('constructor'), 'constructor'); + assert.equal(create('zh-TW').t('desktop.missing'), 'desktop.missing'); +}); + +test('missing translations fall back to English and substitution remains literal display data', () => { + const context = { StandTermDesktopMessages: { en: { 'test.literal': '{value} / {missing}' }, 'zh-TW': {} } }; + vm.runInNewContext(fs.readFileSync(path.join(__dirname, '..', 'i18n.js'), 'utf8'), context); + const t = context.StandTermDesktopI18n.create('zh-TW').t; + const value = '$& {missing}'; + assert.equal(t('test.literal', { value }), value + ' / {missing}'); + assert.equal(t('test.literal', Object.create({ value: 'inherited' })), '{value} / {missing}'); +}); 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/language.test.cjs b/desktop/test/language.test.cjs new file mode 100644 index 0000000..871b0d5 --- /dev/null +++ b/desktop/test/language.test.cjs @@ -0,0 +1,67 @@ +'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 { createLanguage } = require('../language.cjs'); +const { create } = require('../i18n.js'); + +function fixture() { + const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'standterm-language-')); + return { directory, file: path.join(directory, 'language.json') }; +} + +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']); +}); + +test('missing, malformed and unsupported preferences fall back to English', () => { + const f = fixture(); + assert.equal(createLanguage(f.file).locale, 'en'); + for (const value of ['invalid', 'null', '{"version":2,"locale":"zh-TW"}', + '{"version":1,"locale":"zh-CN"}', '{"version":1,"locale":{}}', + '{"version":1,"locale":"zh-TW","padding":"' + 'x'.repeat(16384) + '"}']) { + fs.writeFileSync(f.file, value); + assert.equal(createLanguage(f.file).locale, 'en'); + } +}); + +test('invalid and older-Core values leave the active language and cache untouched', () => { + const f = fixture(), language = createLanguage(f.file); + 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('first English snapshot creates a cache and repeated snapshots do not rewrite it', () => { + const f = fixture(), language = createLanguage(f.file); + 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 cache writes still follow Core without repeated I/O or temporary files', () => { + const f = fixture(), language = createLanguage(f.file); + fs.mkdirSync(f.file); + 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']); + assert.equal(createLanguage(f.file).locale, 'en'); +}); 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 9991a60..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)) { @@ -34,7 +38,7 @@ for (const name of names) { for (const file of ['main.cjs', 'agent-menu.cjs', 'browser-session.cjs', 'diagnostics.cjs', 'diagnostics-window.cjs', 'external-links.cjs', 'floating-windows.cjs', 'test/external-links-smoke.cjs', 'browser-access.cjs', 'context-paste.cjs', 'capture-settings.cjs', 'ui-commands.cjs', 'toolbar.cjs', 'toolbar-preload.cjs', - 'toolbar.html', 'toolbar.js', 'toolbar.css', 'test/toolbar-smoke.cjs', 'release-identity.json']) { + 'toolbar.html', 'toolbar.js', 'toolbar.css', 'language.cjs', 'messages.js', 'i18n.js', 'test/toolbar-smoke.cjs', 'release-identity.json']) { assert.ok(names.includes('/' + file), `Missing ${file}`); } const metadata = JSON.parse(asar.extractFile(archive, 'package.json')); 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/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..58ae5dc --- /dev/null +++ b/desktop/test/setup-i18n-fixture.cjs @@ -0,0 +1,101 @@ +'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 { pathToFileURL } = require('node:url'); +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.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); } + } + 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/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/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/toolbar-i18n-browser-smoke.py b/desktop/test/toolbar-i18n-browser-smoke.py new file mode 100644 index 0000000..723cfdd --- /dev/null +++ b/desktop/test/toolbar-i18n-browser-smoke.py @@ -0,0 +1,83 @@ +"""Check the real toolbar DOM with mocked native IPC, without starting Core.""" + +import json +import os +from pathlib import Path + + +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(): + with sync_playwright() as playwright: + browser = playwright.chromium.launch(headless=True) + try: + for locale in ('en', 'zh-TW'): + page = browser.new_page(viewport={'width': 640, 'height': 100}) + errors = [] + page.on('pageerror', lambda error: errors.append(str(error))) + page.add_init_script(""" + window.fixture = {calls: [], result: true, fail: false}; + window.desktopToolbar = { + onState: callback => { window.fixture.receive = callback; }, + invoke: action => { + window.fixture.calls.push(action); + return window.fixture.fail ? Promise.reject(new Error('Private fixture error')) + : Promise.resolve(window.fixture.result); + }, + }; + """) + page.goto((ROOT / 'desktop' / 'toolbar.html').as_uri()) + original_svg = page.locator('#capture-tools').evaluate('el => [...el.querySelectorAll("svg")].map(svg => svg.outerHTML)') + page.evaluate("locale => fixture.receive({locale, mac:false, state:'idle'})", locale) + assert page.locator('html').get_attribute('lang') == locale + assert page.evaluate("""locale => { + const t = StandTermDesktopI18n.create(locale).t; + return [...document.querySelectorAll('[data-i18n]')].every(el => + el.textContent === t(el.dataset.i18n)) && ['title', 'aria-label'].every(attribute => + [...document.querySelectorAll(`[data-i18n-${attribute}]`)].every(el => + el.getAttribute(attribute) === t(el.getAttribute(`data-i18n-${attribute}`)))); + }""", locale) + assert page.locator('#capture-tools').evaluate('el => [...el.querySelectorAll("svg")].map(svg => svg.outerHTML)') == original_svg + assert page.evaluate("""() => [...document.querySelectorAll('#menus button, #edit-tools button, #capture-tools button')] + .filter(el => !el.hidden).every(el => { + const box = el.getBoundingClientRect(); return box.width > 0 && box.x >= 0 && box.right <= innerWidth; + })""") + for state, key in [('recording', 'record_pause'), ('paused', 'record_resume')]: + 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() == 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) + page.wait_for_function('text => document.getElementById("notice").textContent === text', arg=expected) + page.evaluate('fixture.fail = true') + page.click('[data-menu="standterm"]') + expected = page.evaluate("locale => StandTermDesktopI18n.create(locale).t('desktop.toolbar.action_unconfirmed')", locale) + page.wait_for_function('text => document.getElementById("notice").textContent === text', arg=expected) + assert page.evaluate('fixture.calls') == ['ready', 'screenshot-file', 'menu:standterm'] + page.evaluate("fixture.receive({mac:true, notice:'Literal & {value}', noticeId:1})") + assert not page.locator('#menus').is_visible() + assert page.locator('#notice').text_content() == 'Literal & {value}' + assert page.locator('#notice b').count() == 0 + page.evaluate("fixture.receive({locale:'invalid', mac:false, state:'idle'})") + assert page.locator('html').get_attribute('lang') == 'en' + assert page.locator('[data-menu="edit"]').text_content() == 'Edit' + assert not errors, errors + page.close() + print(json.dumps({'locale': locale, 'toolbar_dom': 'passed', 'native_ipc': 'mocked'})) + finally: + browser.close() + + +if __name__ == '__main__': + run() diff --git a/desktop/test/toolbar-notice.test.cjs b/desktop/test/toolbar-notice.test.cjs index e4ca5ca..e302626 100644 --- a/desktop/test/toolbar-notice.test.cjs +++ b/desktop/test/toolbar-notice.test.cjs @@ -6,7 +6,7 @@ const fs = require('node:fs'); const path = require('node:path'); const vm = require('node:vm'); -function fixture() { +function fixture(action = 'screenshot-file') { const elements = new Map(); for (const [, id] of fs.readFileSync(path.join(__dirname, '..', 'toolbar.html'), 'utf8').matchAll(/id="([^"]+)"/g)) { const classes = new Set(); @@ -15,17 +15,24 @@ function fixture() { contains: name => classes.has(name), toggle: (name, on) => on ? classes.add(name) : classes.delete(name) }, setAttribute: () => {}, addEventListener: (_type, handler) => { elements.get(id).click = handler; } }); } + elements.get('save').dataset = action.startsWith('menu:') ? { menu: action.slice(5) } : { action }; const timers = new Map(); - let time = 0, next = 0, receive, fail = false; + const calls = []; + let time = 0, next = 0, receive, fail = false, result = true; const source = fs.readFileSync(path.join(__dirname, '..', 'toolbar.js'), 'utf8'); vm.runInNewContext(source, { - document: { getElementById: id => elements.get(id), querySelectorAll: () => [elements.get('save')] }, - window: { desktopToolbar: { onState: callback => { receive = callback; }, - invoke: () => fail ? Promise.reject(new Error('Fixture failure')) : Promise.resolve(true) } }, + document: { documentElement: {}, getElementById: id => elements.get(id), + querySelectorAll: selector => selector === '[data-action], [data-menu]' ? [elements.get('save')] : [] }, + window: { StandTermDesktopI18n: require('../i18n.js'), desktopToolbar: { onState: callback => { receive = callback; }, + invoke: action => { + calls.push(action); + return fail ? Promise.reject(new Error('Fixture failure')) : Promise.resolve(result); + } } }, setTimeout: (callback, delay) => { timers.set(++next, { callback, at: time + delay }); return next; }, clearTimeout: id => timers.delete(id), }); - return { element: id => elements.get(id), receive, fail: () => { fail = true; }, + return { element: id => elements.get(id), receive, calls, fail: () => { fail = true; }, + result: value => { result = value; }, tick: ms => { const end = time + ms; for (;;) { @@ -63,11 +70,48 @@ test('a new error cancels pending fade cleanup and remains visible for ten secon f.tick(100); assert.equal(f.element('notice-area').classList.contains('visible'), false); }); -test('failed toolbar actions use the same timed error presentation', async () => { - const f = fixture(); f.fail(); f.element('save').click(); await Promise.resolve(); - assert.match(f.element('notice').textContent, /Action unavailable/); - assert.ok(f.element('notice-area').classList.contains('error')); - f.tick(10350); assert.equal(f.element('notice').textContent, ''); +test('explicitly rejected toolbar actions show an error without replaying the action', async () => { + for (const action of ['screenshot-file', 'menu:standterm']) { + const f = fixture(action); f.result(false); f.element('save').click(); + await new Promise(setImmediate); + assert.equal(f.element('notice').textContent, 'Action unavailable in the current window state.'); + assert.ok(f.element('notice-area').classList.contains('error')); + f.tick(10350); assert.equal(f.element('notice').textContent, ''); + assert.deepEqual(f.calls, ['ready', action]); + } +}); + +test('failed toolbar invocations report an unknown result without replaying the action', async () => { + for (const action of ['screenshot-file', 'menu:standterm']) { + const f = fixture(action); f.fail(); f.element('save').click(); + await new Promise(setImmediate); + assert.equal(f.element('notice').textContent, 'Could not confirm the action result. Check the current state.'); + assert.ok(f.element('notice-area').classList.contains('error')); + f.tick(10350); assert.equal(f.element('notice').textContent, ''); + assert.deepEqual(f.calls, ['ready', action]); + } +}); + +test('other toolbar results preserve operation feedback without inventing success', async () => { + for (const result of [true, undefined, null, 0, '']) { + const f = fixture(); f.result(result); f.element('save').click(); + f.receive({ notice: 'Could not save.', noticeId: 1, error: true }); + await new Promise(setImmediate); + assert.equal(f.element('notice').textContent, 'Could not save.'); + assert.ok(f.element('notice-area').classList.contains('error')); + assert.deepEqual(f.calls, ['ready', 'screenshot-file']); + } +}); + +test('accepted requests leave completion notices to the operation', async () => { + const f = fixture(); f.element('save').click(); + await new Promise(setImmediate); + assert.equal(f.element('notice').textContent, ''); + f.receive({ notice: 'Screenshot saved.', noticeId: 1 }); + f.element('save').click(); + await new Promise(setImmediate); + assert.equal(f.element('notice').textContent, 'Screenshot saved.'); + assert.deepEqual(f.calls, ['ready', 'screenshot-file', 'screenshot-file']); }); test('macOS keeps the system menu layout while notices use the shared renderer', () => { 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/toolbar.test.cjs b/desktop/test/toolbar.test.cjs index c856110..be6ba31 100644 --- a/desktop/test/toolbar.test.cjs +++ b/desktop/test/toolbar.test.cjs @@ -7,11 +7,12 @@ const path = require('node:path'); const vm = require('node:vm'); const { EventEmitter } = require('node:events'); -function fixture(platform = 'win32') { +function fixture(platform = 'win32', locale = 'en') { let handler, requestFilter, permission, device, popup = 0, calls = 0, focused; const edits = []; + const sent = []; const contents = new EventEmitter(); - Object.assign(contents, { mainFrame: {}, isDestroyed: () => false, send: () => {}, setWindowOpenHandler: () => {}, + Object.assign(contents, { mainFrame: {}, isDestroyed: () => false, send: (_channel, value) => sent.push(value), setWindowOpenHandler: () => {}, session: { setPermissionRequestHandler: fn => { permission = fn; }, setPermissionCheckHandler: () => {}, setDevicePermissionHandler: fn => { device = fn; }, webRequest: { onBeforeRequest: fn => { requestFilter = fn; } } } }); const win = new EventEmitter(); @@ -24,18 +25,18 @@ function fixture(platform = 'win32') { ipcMain: { handle: (_name, fn) => { handler = fn; }, removeHandler: () => { handler = null; } }, Menu: { getApplicationMenu: () => ({ getMenuItemById: () => ({ submenu: { popup: () => { popup++; } } }) }) } }; vm.runInNewContext(fs.readFileSync(path.join(__dirname, '..', 'toolbar.cjs'), 'utf8'), { - require: name => name === 'electron' ? electron : require(name), module: api, + require: name => name === 'electron' ? electron : require(name.startsWith('.') ? path.join(__dirname, '..', name) : name), module: api, __dirname: path.join(__dirname, '..'), process: { platform }, }); const capture = { screenshot: async () => { calls++; }, start: async () => { calls++; }, togglePause: async () => { calls++; }, stop: async () => { calls++; } }; const toolbar = api.exports.installToolbar(win, core, capture, { refresh: async () => {}, edit: action => { edits.push(action); return true; }, - }); + }, locale); contents.mainFrame.url = toolbar.url; const event = { sender: contents, senderFrame: contents.mainFrame }; return { invoke: (action, source = event) => handler(source, action), event, win, core, contents, toolbar, - unfocus: () => { focused = null; }, calls: () => calls, popup: () => popup, edits, + unfocus: () => { focused = null; }, calls: () => calls, popup: () => popup, edits, sent, filter: url => new Promise(resolve => requestFilter({ url }, resolve)), permission: () => new Promise(resolve => permission(contents, 'media', resolve)), device: () => device() }; } @@ -71,7 +72,9 @@ test('toolbar resource and permission boundaries are identical on Windows and ma for (const platform of ['win32', 'darwin']) { const f = fixture(platform); assert.equal((await f.filter(f.toolbar.url)).cancel, false); - for (const url of ['https://example.com/', 'http://127.0.0.1:5000/', 'file:///private.txt']) { + for (const file of ['messages.js', 'i18n.js']) assert.equal((await f.filter(new URL(file, f.toolbar.url).href)).cancel, false); + for (const url of ['https://example.com/', 'http://127.0.0.1:5000/', 'file:///private.txt', + new URL('language.cjs', f.toolbar.url).href, new URL('messages.js?locale=zh-TW', f.toolbar.url).href]) { assert.equal((await f.filter(url)).cancel, true); } assert.equal(await f.permission(), false); @@ -82,3 +85,13 @@ test('toolbar resource and permission boundaries are identical on Windows and ma assert.equal(await f.invoke('record'), false); } }); + +test('toolbar readiness sends only a normalized locale alongside existing state', async () => { + for (const [locale, expected] of [['en', 'en'], ['zh-TW', 'zh-TW'], ['../private', 'en']]) { + const f = fixture('win32', locale); + assert.equal(await f.invoke('ready'), true); + assert.equal(f.sent[0].locale, expected); + assert.equal(f.sent[0].state, 'idle'); + assert.equal(f.calls(), 0); + } +}); diff --git a/desktop/test/ui-commands.test.cjs b/desktop/test/ui-commands.test.cjs index 26e1228..1f95d04 100644 --- a/desktop/test/ui-commands.test.cjs +++ b/desktop/test/ui-commands.test.cjs @@ -12,11 +12,12 @@ 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'), { - require: name => name === 'electron' ? { BrowserWindow: { getFocusedWindow: () => state.focused ? win : null } } : { allowedNavigation }, + require: name => name === 'electron' ? { BrowserWindow: { getFocusedWindow: () => state.focused ? win : null } } + : name === './policy.cjs' ? { allowedNavigation } : require(path.join(__dirname, '..', name)), module: api, setInterval: () => 0, clearInterval: () => {}, }); const commands = api.exports.createUiCommands(win, contents, 'http://127.0.0.1:64487'); @@ -33,3 +34,87 @@ test('native clipboard commands preserve the Core editing target and reject stal assert.equal(commands.edit('paste'), false); assert.equal(calls.length, 4); }); + +test('localized menu labels preserve typed commands, target IDs and focus checks', async () => { + for (const locale of ['en', 'zh-TW']) { + const calls = []; + let focused = true; + const win = new EventEmitter(); + Object.assign(win, { isDestroyed: () => false }); + 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; + } }; + const api = { exports: {} }; + const i18n = require('../i18n.js'); + vm.runInNewContext(fs.readFileSync(path.join(__dirname, '..', 'ui-commands.cjs'), 'utf8'), { + require: name => name === 'electron' ? { BrowserWindow: { getFocusedWindow: () => focused ? win : null } } + : name === './policy.cjs' ? { allowedNavigation } : i18n, + module: api, setInterval: () => 0, clearInterval: () => {}, + }); + const { t } = i18n.create(locale); + const commands = api.exports.createUiCommands(win, contents, 'http://127.0.0.1:64487', t); + const item = commands.item('newTab'); + assert.equal(item.id, 'ui-newTab'); + assert.equal(item.label, t('desktop.menu.new_tab')); + assert.equal(await item.click(), true); + assert.deepEqual(calls, ['window.standtermUi?.run("newTab", "terminal-1")']); + assert.equal(await commands.run(item.label), false); + focused = false; + assert.equal(await item.click(), false); + 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/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/toolbar.cjs b/desktop/toolbar.cjs index 7b09968..57e9e73 100644 --- a/desktop/toolbar.cjs +++ b/desktop/toolbar.cjs @@ -3,15 +3,16 @@ const { ipcMain, Menu, BrowserWindow } = require('electron'); const path = require('node:path'); const { pathToFileURL } = require('node:url'); +const { normalizeLocale } = require('./i18n.js'); const TOOLBAR_HEIGHT = 36; const TOOLBAR_URL = pathToFileURL(path.join(__dirname, 'toolbar.html')).href; -const TOOLBAR_ASSETS = ['toolbar.html', 'toolbar.js', 'toolbar.css'].map(file => pathToFileURL(path.join(__dirname, file)).href); +const TOOLBAR_ASSETS = ['toolbar.html', 'toolbar.js', 'toolbar.css', 'messages.js', 'i18n.js'].map(file => pathToFileURL(path.join(__dirname, file)).href); const TOOLBAR_MENUS = ['standterm', 'edit', 'agent-menu', 'view', 'diagnostics']; -function installToolbar(win, coreView, capture, commands) { +function installToolbar(win, coreView, capture, commands, locale = 'en') { const contents = win.webContents; - let state = { mac: process.platform === 'darwin', state: 'idle', label: '' }; + let state = { mac: process.platform === 'darwin', state: 'idle', label: '', locale: normalizeLocale(locale) }; let noticeId = 0; const send = value => { state = { ...state, ...value }; 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/desktop/toolbar.html b/desktop/toolbar.html index 0a21472..6367d7b 100644 --- a/desktop/toolbar.html +++ b/desktop/toolbar.html @@ -7,27 +7,29 @@ -