diff --git a/.github/workflows/mac-packaged-smoke.yml b/.github/workflows/mac-packaged-smoke.yml new file mode 100644 index 0000000..beabc3a --- /dev/null +++ b/.github/workflows/mac-packaged-smoke.yml @@ -0,0 +1,122 @@ +name: macOS packaged smoke + +# PR-only unsigned bundle gate for native-shell behavior that cargo tests cannot +# exercise: packaged WebKit IPC/CSP, native menus, single-instance activation, +# and macOS close/quit shortcuts. It deliberately uses no signing, updater, or +# notarization secrets; Developer ID and App Store Connect remain release-only. +on: + pull_request: + paths: + - "src-tauri/tauri.conf.json" + - "src-tauri/tauri.smoke.conf.json" + - "src-tauri/Cargo.toml" + - "src-tauri/Cargo.lock" + - "src-tauri/capabilities/**" + - "src-tauri/src/**" + - "src/**" + - "index.html" + - "vite.config.ts" + - "package.json" + - "package-lock.json" + - "scripts/macos-packaged-smoke.sh" + - ".github/workflows/mac-packaged-smoke.yml" + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: mac-packaged-smoke-${{ github.ref }} + cancel-in-progress: true + +jobs: + app: + name: ${{ matrix.name }} + timeout-minutes: 30 + strategy: + fail-fast: false + matrix: + include: + - name: arm64 / 简体中文 + platform: macos-15 + target: aarch64-apple-darwin + arch: arm64 + lang: zh-CN + apple_language: zh-Hans + - name: x64 / English + platform: macos-15-intel + target: x86_64-apple-darwin + arch: x86_64 + lang: en + apple_language: en + runs-on: ${{ matrix.platform }} + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + persist-credentials: false + + - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6 + with: + node-version: 20 + cache: npm + + - uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable + with: + targets: ${{ matrix.target }} + + - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2 + with: + workspaces: src-tauri -> target + + - run: npm ci + + - name: Build isolated unsigned app bundle + run: | + npm run tauri build -- \ + --config src-tauri/tauri.smoke.conf.json \ + --bundles app \ + --target "${{ matrix.target }}" \ + --no-sign \ + --ci + + - name: Locate app bundle + id: artifact + run: | + APP="$(find "src-tauri/target/${{ matrix.target }}/release/bundle/macos" -maxdepth 1 -name '*.app' -print -quit)" + if [ -z "$APP" ]; then + echo "::error::[bundle] no .app bundle produced" + exit 1 + fi + echo "app=$APP" >> "$GITHUB_OUTPUT" + + - name: Packaged native-shell lifecycle smoke + run: | + bash scripts/macos-packaged-smoke.sh \ + "${{ steps.artifact.outputs.app }}" \ + --expected-arch "${{ matrix.arch }}" \ + --expected-lang "${{ matrix.lang }}" \ + --apple-language "${{ matrix.apple_language }}" + + - name: Collect smoke logs + if: ${{ always() }} + run: | + mkdir -p "$RUNNER_TEMP/macos-smoke-logs" + find "$HOME/Library/Logs/io.github.wangnov.codexappmanager.smoke" \ + -maxdepth 1 -type f -name '*.log' \ + -exec cp {} "$RUNNER_TEMP/macos-smoke-logs/" \; 2>/dev/null || true + + - name: Upload smoke logs + if: ${{ always() }} + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: macos-smoke-logs-${{ matrix.target }} + path: ${{ runner.temp }}/macos-smoke-logs/*.log + if-no-files-found: warn + + - name: Upload app bundle on failure + if: ${{ failure() && steps.artifact.outcome == 'success' }} + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: macos-smoke-app-${{ matrix.target }} + path: ${{ steps.artifact.outputs.app }} + if-no-files-found: error diff --git a/docs/macos-packaged-smoke.md b/docs/macos-packaged-smoke.md new file mode 100644 index 0000000..a172075 --- /dev/null +++ b/docs/macos-packaged-smoke.md @@ -0,0 +1,96 @@ +# macOS native shell and packaged smoke + +## Language policy + +- The product name is always **Codex App Manager** in the bundle metadata, + title, app-menu heading, About metadata, and Quit label. +- Native command labels follow the language selected inside the app. The + frontend sends that language only after both quit-event listeners are live; + changing the app language rebuilds the native menu without restarting. +- The custom Quit item keeps `Cmd+Q`. Edit actions remain Tauri/macOS + predefined items with their standard selectors and shortcuts. The frameless + window uses explicit `Cmd+M`/`Cmd+W` menu items that call Tauri's minimize and + close APIs, because AppKit's default Window responders do not target this + undecorated WebView window reliably. +- Unsupported or malformed language tags fall back to English. The Rust table + covers the same 11 locale codes as the WebView catalogue. + +## Startup and activation contract + +The main window is packaged as initially hidden. `QuitConfirm` registers +`app://confirm-quit` and `app://quit-blocked`, then waits for the backend token +injected into that document. Every `PageLoad Started` creates a new random token +and generation; `PageLoad Finished` delivers the current token to the loaded +document. `frontend_ready` must return the exact generation and token, so an IPC +call left over from an older document cannot show the window or drain events +into replacement listeners that are not live yet. After an accepted handshake, +the backend localizes the menu, fixes the title, shows/focuses the window, and +drains startup close/quit events in order. Repeated events are coalesced by kind +while the frontend is not ready. + +If readiness takes more than 10 seconds, the backend logs an error, reveals the +window, and enters a degraded native-dialog mode without discarding queued +events. Pending and later Close/Cmd+Q decisions are shown serially with native +dialogs instead of being sent to missing WebView listeners. A native Quit +confirmation re-reads the current operation phase immediately before exiting; +committing/finishing remains blocked even if the phase changed while the dialog +was open. Degraded delivery stays latched for that generation even if its +handshake arrives late; only a subsequent `PageLoad Started` can begin a fresh +frontend-ready generation. The frontend keeps retrying a failed listener +registration or readiness IPC call with bounded exponential backoff and always +reads the latest document token. A partially registered listener set is removed +before retrying. Failed unminimize/show/focus and event delivery attempts are +logged; the app requests informational Dock attention as a fallback. A ready +second launch always attempts unminimize, show, and focus independently so one +failed step does not suppress the others; before readiness, activation is queued +without showing the hidden window. + +## Repeatable PR matrix + +The `macOS packaged smoke` workflow builds an isolated bundle identifier +(`io.github.wangnov.codexappmanager.smoke`) and never reads release secrets. +The harness also creates a private `0700` child of the system temporary +directory. Rust accepts that data-directory override only when its exact leaf +matches the sanitized smoke run ID; a missing, partial, symlinked, broad-access, +or non-temporary override fails closed instead of falling back to real manager +settings, provenance, operation locks, staging, or recovery state. + +| Runner | Target | Per-app Apple language | Expected app language | Runtime coverage | +| --- | --- | --- | --- | --- | +| `macos-15` | `aarch64-apple-darwin` | `zh-Hans` | `zh-CN` | arm64, CJK menu | +| `macos-15-intel` | `x86_64-apple-darwin` | `en` | `en` | Intel x64, Latin menu | + +Each row checks: + +1. isolated bundle ID, product metadata, executable, and Mach-O architecture; +2. per-document readiness-token injection plus frontend-ready IPC (which also + proves the packaged WebView loaded under the configured CSP) and the expected + localized native menu; +3. one live process after a second launch, including restoration from both + minimized and hidden states; +4. `Cmd+W` close interception and visible confirmation-event delivery; +5. both the `Cmd+Q` accelerator and clicking the native Quit item route through + `cam-quit` instead of bypassing the phase-aware confirmation guard. + +The test accepts unsigned/ad-hoc bundles. Developer ID signing, updater signing, +notarization, and stapling remain exclusively in the tag-driven release job. + +## Local command + +```sh +npm run tauri build -- \ + --config src-tauri/tauri.smoke.conf.json \ + --bundles app \ + --target aarch64-apple-darwin \ + --no-sign + +bash scripts/macos-packaged-smoke.sh \ + "src-tauri/target/aarch64-apple-darwin/release/bundle/macos/Codex App Manager.app" \ + --expected-arch arm64 \ + --expected-lang en \ + --apple-language en +``` + +The local smoke needs macOS Accessibility permission for the terminal host +because it verifies the real menu and sends `Cmd+M`, `Cmd+H`, `Cmd+W`, and +`Cmd+Q` through System Events. diff --git a/scripts/macos-packaged-smoke.sh b/scripts/macos-packaged-smoke.sh new file mode 100755 index 0000000..c32c12e --- /dev/null +++ b/scripts/macos-packaged-smoke.sh @@ -0,0 +1,444 @@ +#!/usr/bin/env bash +# Unsigned macOS .app lifecycle smoke. +# +# The app must be built with src-tauri/tauri.smoke.conf.json so its bundle ID, +# WebKit data, settings, operation lock, and logs are isolated from a real user +# install. No signing or notarization credentials are read by this script. + +set -euo pipefail + +usage() { + echo "usage: $0 APP --expected-arch ARCH --expected-lang LANG --apple-language TAG" >&2 + exit 2 +} + +[[ $# -ge 1 ]] || usage +APP=$1 +shift + +EXPECTED_ARCH="" +EXPECTED_LANG="" +APPLE_LANGUAGE="" +while [[ $# -gt 0 ]]; do + case "$1" in + --expected-arch) + [[ $# -ge 2 ]] || usage + EXPECTED_ARCH=$2 + shift 2 + ;; + --expected-lang) + [[ $# -ge 2 ]] || usage + EXPECTED_LANG=$2 + shift 2 + ;; + --apple-language) + [[ $# -ge 2 ]] || usage + APPLE_LANGUAGE=$2 + shift 2 + ;; + *) usage ;; + esac +done +[[ -n "$EXPECTED_ARCH" && -n "$EXPECTED_LANG" && -n "$APPLE_LANGUAGE" ]] || usage + +stage() { + if [[ "${GITHUB_ACTIONS:-}" == "true" ]]; then + echo "::group::[$1] $2" + else + echo "[$1] $2" + fi +} + +end_stage() { + if [[ "${GITHUB_ACTIONS:-}" == "true" ]]; then + echo "::endgroup::" + fi +} + +fail() { + if [[ "${GITHUB_ACTIONS:-}" == "true" ]]; then + echo "::error::[$1] $2" >&2 + else + echo "[$1] ERROR: $2" >&2 + fi + exit 1 +} + +plist() { + /usr/libexec/PlistBuddy -c "Print :$2" "$1/Contents/Info.plist" +} + +menu_copy() { + case "$EXPECTED_LANG" in + en) EDIT_MENU="Edit"; WINDOW_MENU="Window"; QUIT_ITEM="Quit Codex App Manager" ;; + zh-CN) EDIT_MENU="编辑"; WINDOW_MENU="窗口"; QUIT_ITEM="退出 Codex App Manager" ;; + zh-TW) EDIT_MENU="編輯"; WINDOW_MENU="視窗"; QUIT_ITEM="結束 Codex App Manager" ;; + ja) EDIT_MENU="編集"; WINDOW_MENU="ウインドウ"; QUIT_ITEM="Codex App Managerを終了" ;; + ko) EDIT_MENU="편집"; WINDOW_MENU="윈도우"; QUIT_ITEM="Codex App Manager 종료" ;; + fr) EDIT_MENU="Édition"; WINDOW_MENU="Fenêtre"; QUIT_ITEM="Quitter Codex App Manager" ;; + de) EDIT_MENU="Bearbeiten"; WINDOW_MENU="Fenster"; QUIT_ITEM="Codex App Manager beenden" ;; + es) EDIT_MENU="Edición"; WINDOW_MENU="Ventana"; QUIT_ITEM="Salir de Codex App Manager" ;; + pt-BR) EDIT_MENU="Editar"; WINDOW_MENU="Janela"; QUIT_ITEM="Encerrar Codex App Manager" ;; + ru) EDIT_MENU="Правка"; WINDOW_MENU="Окно"; QUIT_ITEM="Завершить Codex App Manager" ;; + ar) EDIT_MENU="تحرير"; WINDOW_MENU="نافذة"; QUIT_ITEM="إنهاء Codex App Manager" ;; + *) fail "locale" "unsupported expected language: $EXPECTED_LANG" ;; + esac +} + +exact_pids() { + pgrep -f "$BINARY" 2>/dev/null || true +} + +process_count() { + local pids + pids=$(exact_pids) + if [[ -z "$pids" ]]; then + echo 0 + else + echo "$pids" | wc -l | tr -d ' ' + fi +} + +wait_for_process() { + local deadline=$((SECONDS + 20)) + while (( SECONDS < deadline )); do + local pids + pids=$(exact_pids) + if [[ -n "$pids" ]]; then + echo "$pids" | head -1 + return 0 + fi + sleep 0.25 + done + return 1 +} + +log_since_run() { + [[ -f "$LOG_FILE" ]] || return 0 + awk -v marker="packaged smoke run id=$RUN_ID" ' + index($0, marker) { seen = 1 } + seen { print } + ' "$LOG_FILE" +} + +wait_for_log() { + local needle=$1 + local deadline=$((SECONDS + 25)) + while (( SECONDS < deadline )); do + if log_since_run | grep -Fq "$needle"; then + return 0 + fi + sleep 0.25 + done + return 1 +} + +wait_for_log_count() { + local needle=$1 + local expected=$2 + local deadline=$((SECONDS + 25)) + while (( SECONDS < deadline )); do + local count + count=$(log_since_run | grep -Fc "$needle" || true) + if (( count >= expected )); then + return 0 + fi + sleep 0.25 + done + return 1 +} + +wait_for_absence() { + local path=$1 + local deadline=$((SECONDS + 15)) + while (( SECONDS < deadline )); do + if [[ ! -e "$path" && ! -L "$path" ]]; then + return 0 + fi + sleep 0.25 + done + return 1 +} + +send_command_shortcut() { + local pid=$1 + local key=$2 + osascript - "$pid" "$key" <<'APPLESCRIPT' +on run argv + set targetPid to (item 1 of argv) as integer + set keyName to item 2 of argv + tell application "System Events" + tell first application process whose unix id is targetPid + set frontmost to true + delay 0.5 + keystroke keyName using command down + end tell + end tell +end run +APPLESCRIPT +} + +send_escape() { + local pid=$1 + osascript - "$pid" <<'APPLESCRIPT' +on run argv + set targetPid to (item 1 of argv) as integer + tell application "System Events" + tell first application process whose unix id is targetPid + set frontmost to true + delay 0.5 + key code 53 + end tell + end tell +end run +APPLESCRIPT +} + +window_minimized() { + local pid=$1 + osascript - "$pid" <<'APPLESCRIPT' +on run argv + set targetPid to (item 1 of argv) as integer + tell application "System Events" + tell first application process whose unix id is targetPid + return value of attribute "AXMinimized" of window 1 + end tell + end tell +end run +APPLESCRIPT +} + +process_visible() { + local pid=$1 + osascript - "$pid" <<'APPLESCRIPT' +on run argv + set targetPid to (item 1 of argv) as integer + tell application "System Events" + return visible of first application process whose unix id is targetPid + end tell +end run +APPLESCRIPT +} + +assert_native_menu() { + local pid=$1 + osascript - "$pid" "Codex App Manager" "$EDIT_MENU" "$WINDOW_MENU" "$QUIT_ITEM" <<'APPLESCRIPT' +on run argv + set targetPid to (item 1 of argv) as integer + set productName to item 2 of argv + set editName to item 3 of argv + set windowName to item 4 of argv + set quitName to item 5 of argv + tell application "System Events" + tell first application process whose unix id is targetPid + if not (exists menu bar item productName of menu bar 1) then error "missing app menu: " & productName + if not (exists menu bar item editName of menu bar 1) then error "missing Edit menu: " & editName + if not (exists menu bar item windowName of menu bar 1) then error "missing Window menu: " & windowName + if not (exists menu item quitName of menu 1 of menu bar item productName of menu bar 1) then error "missing Quit item: " & quitName + end tell + end tell +end run +APPLESCRIPT +} + +click_quit_menu() { + local pid=$1 + osascript - "$pid" "Codex App Manager" "$QUIT_ITEM" >/dev/null <<'APPLESCRIPT' +on run argv + set targetPid to (item 1 of argv) as integer + set productName to item 2 of argv + set quitName to item 3 of argv + tell application "System Events" + tell first application process whose unix id is targetPid + set frontmost to true + delay 0.5 + click menu item quitName of menu 1 of menu bar item productName of menu bar 1 + end tell + end tell +end run +APPLESCRIPT +} + +wait_for_window_state() { + local pid=$1 + local probe=$2 + local expected=$3 + local deadline=$((SECONDS + 12)) + while (( SECONDS < deadline )); do + local actual="" + if [[ "$probe" == "minimized" ]]; then + actual=$(window_minimized "$pid" 2>/dev/null || true) + else + actual=$(process_visible "$pid" 2>/dev/null || true) + fi + if [[ "$actual" == "$expected" ]]; then + return 0 + fi + sleep 0.25 + done + return 1 +} + +launch_app() { + local posix_locale + case "$EXPECTED_LANG" in + zh-CN) posix_locale="zh_CN.UTF-8" ;; + zh-TW) posix_locale="zh_TW.UTF-8" ;; + ja) posix_locale="ja_JP.UTF-8" ;; + ko) posix_locale="ko_KR.UTF-8" ;; + fr) posix_locale="fr_FR.UTF-8" ;; + de) posix_locale="de_DE.UTF-8" ;; + es) posix_locale="es_ES.UTF-8" ;; + pt-BR) posix_locale="pt_BR.UTF-8" ;; + ru) posix_locale="ru_RU.UTF-8" ;; + ar) posix_locale="ar_SA.UTF-8" ;; + *) posix_locale="en_US.UTF-8" ;; + esac + open -n -F \ + --env "CAM_PACKAGED_SMOKE_RUN=$RUN_ID" \ + --env "CAM_PACKAGED_SMOKE_DATA_DIR=$SMOKE_DATA_DIR" \ + --env "LANG=$posix_locale" \ + --env "LC_ALL=$posix_locale" \ + "$APP" +} + +[[ -d "$APP" ]] || fail "bundle" "app bundle not found: $APP" +BUNDLE_ID=$(plist "$APP" CFBundleIdentifier) +[[ "$BUNDLE_ID" == "io.github.wangnov.codexappmanager.smoke" ]] || + fail "bundle" "refusing to smoke non-isolated bundle id: $BUNDLE_ID" +PRODUCT_NAME=$(plist "$APP" CFBundleName) +[[ "$PRODUCT_NAME" == "Codex App Manager" ]] || + fail "bundle" "unexpected product name: $PRODUCT_NAME" +EXECUTABLE=$(plist "$APP" CFBundleExecutable) +BINARY="$APP/Contents/MacOS/$EXECUTABLE" +[[ -x "$BINARY" ]] || fail "bundle" "main executable missing: $BINARY" +LOG_FILE="$HOME/Library/Logs/$BUNDLE_ID/codex-app-manager.log" +RUN_ID="$(date +%s)-$$-${RANDOM:-0}" +SMOKE_TEMP_ROOT=$(cd "${TMPDIR:-/tmp}" && pwd -P) +SMOKE_DATA_DIR="$SMOKE_TEMP_ROOT/codex-app-manager-smoke-$RUN_ID" +if [[ -e "$SMOKE_DATA_DIR" || -L "$SMOKE_DATA_DIR" ]]; then + fail "isolation" "refusing to reuse smoke data directory: $SMOKE_DATA_DIR" +fi +mkdir -m 700 "$SMOKE_DATA_DIR" +STALE_STAGING_SENTINEL="$SMOKE_DATA_DIR/staging/update-smoke-stale" +mkdir -p "$STALE_STAGING_SENTINEL" +touch -t 200001010000 "$STALE_STAGING_SENTINEL" +PREF_BACKUP=$(mktemp "${TMPDIR:-/tmp}/cam-smoke-prefs.XXXXXX.plist") +HAD_PREFS=0 +if defaults export "$BUNDLE_ID" "$PREF_BACKUP" >/dev/null 2>&1; then + HAD_PREFS=1 +fi +menu_copy + +cleanup() { + local pids + pids=$(exact_pids) + if [[ -n "$pids" ]]; then + echo "$pids" | xargs kill 2>/dev/null || true + sleep 1 + pids=$(exact_pids) + if [[ -n "$pids" ]]; then + echo "$pids" | xargs kill -9 2>/dev/null || true + fi + fi + if (( HAD_PREFS == 1 )); then + defaults import "$BUNDLE_ID" "$PREF_BACKUP" >/dev/null 2>&1 || true + else + defaults delete "$BUNDLE_ID" >/dev/null 2>&1 || true + fi + if [[ "$SMOKE_DATA_DIR" == "$SMOKE_TEMP_ROOT/codex-app-manager-smoke-$RUN_ID" ]]; then + rm -rf -- "$SMOKE_DATA_DIR" + if [[ -e "$SMOKE_DATA_DIR" || -L "$SMOKE_DATA_DIR" ]]; then + echo "[cleanup] ERROR: isolated smoke data directory was not removed" >&2 + return 1 + fi + fi + rm -f "$PREF_BACKUP" +} +trap cleanup EXIT + +if pgrep -x "$EXECUTABLE" >/dev/null 2>&1; then + fail "preflight" "another $EXECUTABLE process is running; stop it before the isolated smoke" +fi + +stage "bundle" "Validate isolated unsigned .app and architecture" +ARCHES=$(lipo -archs "$BINARY") +case " $ARCHES " in + *" $EXPECTED_ARCH "*) ;; + *) fail "bundle" "expected arch $EXPECTED_ARCH, found: $ARCHES" ;; +esac +echo "bundle=$APP" +echo "bundle_id=$BUNDLE_ID executable=$EXECUTABLE arches=$ARCHES" +if codesign --verify --deep --strict "$APP" >/dev/null 2>&1; then + echo "signature=ad-hoc-or-present (no identity required by smoke)" +else + echo "signature=unsigned (expected and allowed for PR smoke)" +fi +end_stage + +stage "locale" "Set isolated app preference AppleLanguages=$APPLE_LANGUAGE" +defaults write "$BUNDLE_ID" AppleLanguages -array "$APPLE_LANGUAGE" +echo "expected_app_language=$EXPECTED_LANG edit=$EDIT_MENU window=$WINDOW_MENU quit=$QUIT_ITEM" +end_stage + +stage "launch" "Launch packaged app and prove frontend-ready IPC/CSP handshake" +launch_app +PID=$(wait_for_process) || fail "launch" "packaged process did not start" +wait_for_log "packaged smoke run id=$RUN_ID data_dir_isolated=true" || + fail "launch" "isolated smoke marker missing from app log: $LOG_FILE" +[[ -f "$SMOKE_DATA_DIR/operation.lock" ]] || + fail "launch" "operation lock was not created in isolated data directory" +wait_for_absence "$STALE_STAGING_SENTINEL" || + fail "launch" "startup cleanup did not use the isolated staging directory" +wait_for_log "frontend readiness token injected generation=1" || + fail "launch" "backend did not bind a readiness token to the packaged document" +wait_for_log "frontend ready lang=$EXPECTED_LANG" || + fail "launch" "frontend-ready IPC did not report expected language $EXPECTED_LANG" +wait_for_log "native menu installed lang=$EXPECTED_LANG" || + fail "launch" "native menu did not switch to expected language $EXPECTED_LANG" +[[ "$(process_count)" == "1" ]] || fail "launch" "expected one main process" +echo "pid=$PID log=$LOG_FILE" +end_stage + +stage "menu" "Verify localized native menu structure and custom Quit item" +assert_native_menu "$PID" || fail "menu" "native menu inspection failed (Accessibility/UI scripting unavailable or labels incorrect)" +end_stage + +stage "single-instance" "Recover the main window from minimized and hidden states" +send_command_shortcut "$PID" "m" || fail "single-instance" "Cmd+M injection failed" +wait_for_window_state "$PID" minimized true || fail "single-instance" "window did not minimize" +launch_app +wait_for_log_count "single-instance activation requested" 1 || fail "single-instance" "second launch did not reach activation callback" +wait_for_window_state "$PID" minimized false || fail "single-instance" "second launch did not unminimize the window" +[[ "$(process_count)" == "1" ]] || fail "single-instance" "second launch left more than one process" + +send_command_shortcut "$PID" "h" || fail "single-instance" "Cmd+H injection failed" +wait_for_window_state "$PID" visible false || fail "single-instance" "application did not hide" +launch_app +wait_for_log_count "single-instance activation requested" 2 || fail "single-instance" "hidden-state relaunch did not reach activation callback" +wait_for_window_state "$PID" visible true || fail "single-instance" "second launch did not reveal the hidden application" +[[ "$(process_count)" == "1" ]] || fail "single-instance" "hidden-state relaunch left more than one process" +end_stage + +stage "close" "Exercise Cmd+W close interception after frontend readiness" +send_command_shortcut "$PID" "w" || fail "close" "Cmd+W injection failed" +wait_for_log "window close requested label=main" || fail "close" "window CloseRequested was not observed" +wait_for_log_count "shell event emitted kind=confirm-quit" 1 || fail "close" "close confirmation event was not delivered" +kill -0 "$PID" 2>/dev/null || fail "close" "process exited instead of waiting for close confirmation" +send_escape "$PID" || fail "close" "could not dismiss close confirmation" +end_stage + +stage "quit" "Exercise Cmd+Q accelerator and the native menu Quit item" +send_command_shortcut "$PID" "q" || fail "quit" "Cmd+Q injection failed" +wait_for_log_count "menu quit requested id=cam-quit" 1 || fail "quit" "Cmd+Q did not route through custom menu handler" +wait_for_log_count "shell event emitted kind=confirm-quit" 2 || fail "quit" "Cmd+Q did not deliver a confirmation event" +kill -0 "$PID" 2>/dev/null || fail "quit" "Cmd+Q bypassed the confirmation guard" +send_escape "$PID" || fail "quit" "could not dismiss Cmd+Q confirmation" + +click_quit_menu "$PID" || fail "quit" "native Quit menu click failed" +wait_for_log_count "menu quit requested id=cam-quit" 2 || fail "quit" "menu click did not route through custom menu handler" +wait_for_log_count "shell event emitted kind=confirm-quit" 3 || fail "quit" "menu Quit did not deliver a confirmation event" +kill -0 "$PID" 2>/dev/null || fail "quit" "menu Quit bypassed the confirmation guard" +end_stage + +echo "macOS packaged smoke passed: bundle → locale/menu → IPC/CSP → single-instance restore → close → Cmd+Q/menu Quit" diff --git a/src-tauri/src/app/mod.rs b/src-tauri/src/app/mod.rs index cf29106..9d23135 100644 --- a/src-tauri/src/app/mod.rs +++ b/src-tauri/src/app/mod.rs @@ -12,6 +12,7 @@ pub mod oplock; pub mod paths; pub mod provenance; pub mod settings_store; +pub mod shell; pub mod staging; pub mod url_guard; pub mod win_update; diff --git a/src-tauri/src/app/paths.rs b/src-tauri/src/app/paths.rs index 79cbf09..df63527 100644 --- a/src-tauri/src/app/paths.rs +++ b/src-tauri/src/app/paths.rs @@ -1,9 +1,121 @@ -use std::path::PathBuf; +use std::ffi::{OsStr, OsString}; +use std::path::{Path, PathBuf}; + +const SMOKE_RUN_ENV: &str = "CAM_PACKAGED_SMOKE_RUN"; +const SMOKE_DATA_DIR_ENV: &str = "CAM_PACKAGED_SMOKE_DATA_DIR"; +const SMOKE_DATA_DIR_PREFIX: &str = "codex-app-manager-smoke-"; + +#[derive(Debug, PartialEq, Eq)] +enum SmokeDataDir { + Absent, + Valid { run_id: String, path: PathBuf }, + Invalid, +} + +fn valid_smoke_run_id(run_id: &str) -> bool { + !run_id.is_empty() + && run_id.len() <= 64 + && run_id + .chars() + .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_')) +} + +fn validate_smoke_data_dir( + run_id: Option, + requested: Option, + temp_dir: &Path, +) -> SmokeDataDir { + let (run_id, requested) = match (run_id, requested) { + (None, None) => return SmokeDataDir::Absent, + (Some(run_id), Some(requested)) => (run_id, requested), + (None, Some(_)) | (Some(_), None) => return SmokeDataDir::Invalid, + }; + let Some(run_id) = run_id.to_str() else { + return SmokeDataDir::Invalid; + }; + if !valid_smoke_run_id(run_id) { + return SmokeDataDir::Invalid; + } + + let requested = PathBuf::from(requested); + let expected_leaf = format!("{SMOKE_DATA_DIR_PREFIX}{run_id}"); + if !requested.is_absolute() || requested.file_name() != Some(OsStr::new(&expected_leaf)) { + return SmokeDataDir::Invalid; + } + + let Ok(metadata) = std::fs::symlink_metadata(&requested) else { + return SmokeDataDir::Invalid; + }; + if !metadata.is_dir() || metadata.file_type().is_symlink() { + return SmokeDataDir::Invalid; + } + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + if metadata.permissions().mode() & 0o077 != 0 { + return SmokeDataDir::Invalid; + } + } + + let (Ok(temp_dir), Ok(requested)) = (temp_dir.canonicalize(), requested.canonicalize()) else { + return SmokeDataDir::Invalid; + }; + if requested.parent() != Some(temp_dir.as_path()) { + return SmokeDataDir::Invalid; + } + + SmokeDataDir::Valid { + run_id: run_id.to_string(), + path: requested, + } +} + +fn smoke_data_dir_from_env() -> SmokeDataDir { + validate_smoke_data_dir( + std::env::var_os(SMOKE_RUN_ENV), + std::env::var_os(SMOKE_DATA_DIR_ENV), + &std::env::temp_dir(), + ) +} + +fn select_data_dir(smoke: SmokeDataDir, production: Option) -> Option { + match smoke { + SmokeDataDir::Valid { path, .. } => Some(path), + // If either smoke marker is present but the pair is invalid, fail + // closed. Falling back to the production directory would let a broken + // smoke harness read settings or run recovery against real user data. + SmokeDataDir::Invalid => None, + SmokeDataDir::Absent => production, + } +} + +fn select_staging_root(smoke: SmokeDataDir, temp_dir: &Path, process_id: u32) -> PathBuf { + match smoke { + SmokeDataDir::Valid { path, .. } => path.join("staging"), + SmokeDataDir::Invalid => temp_dir + .join(format!("codex-app-manager-smoke-invalid-{process_id}")) + .join("staging"), + SmokeDataDir::Absent => temp_dir.join("codex-app-manager").join("staging"), + } +} /// Manager data directory shared by settings, provenance, and operation locks. pub fn data_dir() -> Option { - directories::ProjectDirs::from("io.github", "wangnov", "codexappmanager") - .map(|dirs| dirs.data_dir().to_path_buf()) + let production = directories::ProjectDirs::from("io.github", "wangnov", "codexappmanager") + .map(|dirs| dirs.data_dir().to_path_buf()); + select_data_dir(smoke_data_dir_from_env(), production) +} + +pub fn packaged_smoke_run_id() -> Option { + match smoke_data_dir_from_env() { + SmokeDataDir::Valid { run_id, .. } => Some(run_id), + SmokeDataDir::Absent | SmokeDataDir::Invalid => None, + } +} + +pub fn staging_root() -> PathBuf { + let temp_dir = std::env::temp_dir(); + select_staging_root(smoke_data_dir_from_env(), &temp_dir, std::process::id()) } pub fn settings_path() -> Option { @@ -17,3 +129,129 @@ pub fn provenance_path() -> Option { pub fn codex_home_dir() -> Option { directories::UserDirs::new().map(|dirs| dirs.home_dir().join(".codex")) } + +#[cfg(test)] +mod tests { + use super::{ + select_data_dir, select_staging_root, validate_smoke_data_dir, SmokeDataDir, + SMOKE_DATA_DIR_PREFIX, + }; + + fn test_run_id() -> String { + format!("test-{}", uuid::Uuid::new_v4()) + } + + #[test] + fn smoke_data_dir_requires_both_markers_and_an_exact_private_temp_child() { + let temp_dir = std::env::temp_dir().canonicalize().unwrap(); + let run_id = test_run_id(); + let path = temp_dir.join(format!("{SMOKE_DATA_DIR_PREFIX}{run_id}")); + std::fs::create_dir(&path).unwrap(); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o700)).unwrap(); + } + + assert_eq!( + validate_smoke_data_dir( + Some(run_id.clone().into()), + Some(path.clone().into_os_string()), + &temp_dir, + ), + SmokeDataDir::Valid { + run_id: run_id.clone(), + path: path.canonicalize().unwrap(), + } + ); + assert_eq!( + validate_smoke_data_dir(Some(run_id.clone().into()), None, &temp_dir), + SmokeDataDir::Invalid + ); + assert_eq!( + validate_smoke_data_dir(None, Some(path.clone().into_os_string()), &temp_dir), + SmokeDataDir::Invalid + ); + assert_eq!( + validate_smoke_data_dir(None, None, &temp_dir), + SmokeDataDir::Absent + ); + assert_eq!( + validate_smoke_data_dir( + Some("../invalid".into()), + Some(path.clone().into_os_string()), + &temp_dir, + ), + SmokeDataDir::Invalid + ); + assert_eq!( + validate_smoke_data_dir( + Some(run_id.into()), + Some(temp_dir.join("wrong-leaf").into_os_string()), + &temp_dir, + ), + SmokeDataDir::Invalid + ); + + std::fs::remove_dir(&path).unwrap(); + } + + #[test] + fn invalid_smoke_override_never_falls_back_to_production_data() { + let production = std::env::temp_dir().join("real-manager-data"); + assert_eq!( + select_data_dir(SmokeDataDir::Invalid, Some(production.clone())), + None + ); + assert_eq!( + select_data_dir(SmokeDataDir::Absent, Some(production.clone())), + Some(production) + ); + + let temp_dir = std::env::temp_dir(); + let production_staging = temp_dir.join("codex-app-manager").join("staging"); + let invalid_staging = select_staging_root(SmokeDataDir::Invalid, &temp_dir, 1234); + assert_ne!(invalid_staging, production_staging); + assert_eq!( + invalid_staging, + temp_dir + .join("codex-app-manager-smoke-invalid-1234") + .join("staging") + ); + } + + #[cfg(unix)] + #[test] + fn smoke_data_dir_rejects_symlinks_and_group_access() { + use std::os::unix::fs::{symlink, PermissionsExt}; + + let temp_dir = std::env::temp_dir().canonicalize().unwrap(); + let target_run = test_run_id(); + let target = temp_dir.join(format!("{SMOKE_DATA_DIR_PREFIX}{target_run}")); + std::fs::create_dir(&target).unwrap(); + std::fs::set_permissions(&target, std::fs::Permissions::from_mode(0o750)).unwrap(); + assert_eq!( + validate_smoke_data_dir( + Some(target_run.into()), + Some(target.clone().into_os_string()), + &temp_dir, + ), + SmokeDataDir::Invalid + ); + + let link_run = test_run_id(); + let link = temp_dir.join(format!("{SMOKE_DATA_DIR_PREFIX}{link_run}")); + symlink(&target, &link).unwrap(); + assert_eq!( + validate_smoke_data_dir( + Some(link_run.into()), + Some(link.clone().into_os_string()), + &temp_dir, + ), + SmokeDataDir::Invalid + ); + + std::fs::remove_file(link).unwrap(); + std::fs::remove_dir(target).unwrap(); + } +} diff --git a/src-tauri/src/app/shell.rs b/src-tauri/src/app/shell.rs new file mode 100644 index 0000000..0a059f7 --- /dev/null +++ b/src-tauri/src/app/shell.rs @@ -0,0 +1,783 @@ +use std::sync::Mutex; + +use crate::app::op_phase::QuitPolicy; + +pub const PRODUCT_NAME: &str = "Codex App Manager"; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum NativeLocale { + En, + ZhCn, + ZhTw, + Ja, + Ko, + Fr, + De, + Es, + PtBr, + Ru, + Ar, +} + +impl NativeLocale { + pub fn from_tag(tag: &str) -> Self { + match tag.trim().to_ascii_lowercase().as_str() { + "zh-cn" | "zh-hans" => Self::ZhCn, + "zh-tw" | "zh-hant" => Self::ZhTw, + "ja" => Self::Ja, + "ko" => Self::Ko, + "fr" => Self::Fr, + "de" => Self::De, + "es" => Self::Es, + "pt-br" => Self::PtBr, + "ru" => Self::Ru, + "ar" => Self::Ar, + _ => Self::En, + } + } + + pub fn code(self) -> &'static str { + match self { + Self::En => "en", + Self::ZhCn => "zh-CN", + Self::ZhTw => "zh-TW", + Self::Ja => "ja", + Self::Ko => "ko", + Self::Fr => "fr", + Self::De => "de", + Self::Es => "es", + Self::PtBr => "pt-BR", + Self::Ru => "ru", + Self::Ar => "ar", + } + } + + pub fn menu(self) -> NativeMenuCopy { + match self { + Self::En => NativeMenuCopy { + about: "About Codex App Manager", + services: "Services", + hide: "Hide Codex App Manager", + hide_others: "Hide Others", + show_all: "Show All", + quit: "Quit Codex App Manager", + edit: "Edit", + undo: "Undo", + redo: "Redo", + cut: "Cut", + copy: "Copy", + paste: "Paste", + select_all: "Select All", + window: "Window", + minimize: "Minimize", + close_window: "Close Window", + }, + Self::ZhCn => NativeMenuCopy { + about: "关于 Codex App Manager", + services: "服务", + hide: "隐藏 Codex App Manager", + hide_others: "隐藏其他", + show_all: "全部显示", + quit: "退出 Codex App Manager", + edit: "编辑", + undo: "撤销", + redo: "重做", + cut: "剪切", + copy: "拷贝", + paste: "粘贴", + select_all: "全选", + window: "窗口", + minimize: "最小化", + close_window: "关闭窗口", + }, + Self::ZhTw => NativeMenuCopy { + about: "關於 Codex App Manager", + services: "服務", + hide: "隱藏 Codex App Manager", + hide_others: "隱藏其他", + show_all: "全部顯示", + quit: "結束 Codex App Manager", + edit: "編輯", + undo: "還原", + redo: "重做", + cut: "剪下", + copy: "複製", + paste: "貼上", + select_all: "全選", + window: "視窗", + minimize: "最小化", + close_window: "關閉視窗", + }, + Self::Ja => NativeMenuCopy { + about: "Codex App Managerについて", + services: "サービス", + hide: "Codex App Managerを隠す", + hide_others: "ほかを隠す", + show_all: "すべてを表示", + quit: "Codex App Managerを終了", + edit: "編集", + undo: "取り消す", + redo: "やり直す", + cut: "カット", + copy: "コピー", + paste: "ペースト", + select_all: "すべてを選択", + window: "ウインドウ", + minimize: "しまう", + close_window: "ウインドウを閉じる", + }, + Self::Ko => NativeMenuCopy { + about: "Codex App Manager에 관하여", + services: "서비스", + hide: "Codex App Manager 가리기", + hide_others: "기타 가리기", + show_all: "모두 보기", + quit: "Codex App Manager 종료", + edit: "편집", + undo: "실행 취소", + redo: "실행 복귀", + cut: "오려두기", + copy: "복사", + paste: "붙여넣기", + select_all: "모두 선택", + window: "윈도우", + minimize: "최소화", + close_window: "윈도우 닫기", + }, + Self::Fr => NativeMenuCopy { + about: "À propos de Codex App Manager", + services: "Services", + hide: "Masquer Codex App Manager", + hide_others: "Masquer les autres", + show_all: "Tout afficher", + quit: "Quitter Codex App Manager", + edit: "Édition", + undo: "Annuler", + redo: "Rétablir", + cut: "Couper", + copy: "Copier", + paste: "Coller", + select_all: "Tout sélectionner", + window: "Fenêtre", + minimize: "Placer dans le Dock", + close_window: "Fermer la fenêtre", + }, + Self::De => NativeMenuCopy { + about: "Über Codex App Manager", + services: "Dienste", + hide: "Codex App Manager ausblenden", + hide_others: "Andere ausblenden", + show_all: "Alle einblenden", + quit: "Codex App Manager beenden", + edit: "Bearbeiten", + undo: "Widerrufen", + redo: "Wiederholen", + cut: "Ausschneiden", + copy: "Kopieren", + paste: "Einsetzen", + select_all: "Alles auswählen", + window: "Fenster", + minimize: "Im Dock ablegen", + close_window: "Fenster schließen", + }, + Self::Es => NativeMenuCopy { + about: "Acerca de Codex App Manager", + services: "Servicios", + hide: "Ocultar Codex App Manager", + hide_others: "Ocultar otros", + show_all: "Mostrar todo", + quit: "Salir de Codex App Manager", + edit: "Edición", + undo: "Deshacer", + redo: "Rehacer", + cut: "Cortar", + copy: "Copiar", + paste: "Pegar", + select_all: "Seleccionar todo", + window: "Ventana", + minimize: "Minimizar", + close_window: "Cerrar ventana", + }, + Self::PtBr => NativeMenuCopy { + about: "Sobre o Codex App Manager", + services: "Serviços", + hide: "Ocultar Codex App Manager", + hide_others: "Ocultar Outros", + show_all: "Mostrar Tudo", + quit: "Encerrar Codex App Manager", + edit: "Editar", + undo: "Desfazer", + redo: "Refazer", + cut: "Cortar", + copy: "Copiar", + paste: "Colar", + select_all: "Selecionar Tudo", + window: "Janela", + minimize: "Minimizar", + close_window: "Fechar Janela", + }, + Self::Ru => NativeMenuCopy { + about: "О программе Codex App Manager", + services: "Службы", + hide: "Скрыть Codex App Manager", + hide_others: "Скрыть остальные", + show_all: "Показать все", + quit: "Завершить Codex App Manager", + edit: "Правка", + undo: "Отменить", + redo: "Повторить", + cut: "Вырезать", + copy: "Копировать", + paste: "Вставить", + select_all: "Выбрать все", + window: "Окно", + minimize: "Свернуть", + close_window: "Закрыть окно", + }, + Self::Ar => NativeMenuCopy { + about: "حول Codex App Manager", + services: "الخدمات", + hide: "إخفاء Codex App Manager", + hide_others: "إخفاء الآخرين", + show_all: "إظهار الكل", + quit: "إنهاء Codex App Manager", + edit: "تحرير", + undo: "تراجع", + redo: "إعادة", + cut: "قص", + copy: "نسخ", + paste: "لصق", + select_all: "تحديد الكل", + window: "نافذة", + minimize: "تصغير", + close_window: "إغلاق النافذة", + }, + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct NativeMenuCopy { + pub about: &'static str, + pub services: &'static str, + pub hide: &'static str, + pub hide_others: &'static str, + pub show_all: &'static str, + pub quit: &'static str, + pub edit: &'static str, + pub undo: &'static str, + pub redo: &'static str, + pub cut: &'static str, + pub copy: &'static str, + pub paste: &'static str, + pub select_all: &'static str, + pub window: &'static str, + pub minimize: &'static str, + pub close_window: &'static str, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ShellEvent { + ConfirmQuit, + QuitBlocked(QuitPolicy), +} + +impl ShellEvent { + pub fn kind(&self) -> &'static str { + match self { + Self::ConfirmQuit => "confirm-quit", + Self::QuitBlocked(_) => "quit-blocked", + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ShellDispatch { + Emit(ShellEvent), + Native(ShellEvent), + Queued { pending: usize }, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct FrontendLoad { + pub generation: u64, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct FrontendToken { + pub generation: u64, + pub token: String, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct FrontendReady { + pub generation: u64, + pub first_ready: bool, + pub degraded: bool, + pub activation_pending: bool, + pub pending: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum FrontendReadyResult { + Accepted(FrontendReady), + Stale { current_generation: u64 }, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct FrontendDegraded { + pub activation_pending: bool, + pub next_native_event: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum FrontendFailureResult { + Accepted(FrontendDegraded), + Stale { current_generation: u64 }, +} + +#[derive(Debug, Default)] +struct FrontendGateInner { + ready: bool, + degraded: bool, + generation: u64, + token: Option, + activation_pending: bool, + native_dialog_active: bool, + pending: Vec, +} + +#[derive(Debug, Default)] +pub struct FrontendGate { + inner: Mutex, +} + +impl FrontendGate { + fn queue_event(inner: &mut FrontendGateInner, event: ShellEvent) { + // Startup can receive repeated Cmd+Q/close requests before React has + // registered both listeners. Keep only the latest event of each kind: + // the queue remains bounded while no user intent is silently lost. + if let Some(index) = inner + .pending + .iter() + .position(|queued| queued.kind() == event.kind()) + { + inner.pending.remove(index); + } + // Re-append replacements so different event kinds retain the order of + // their latest occurrence, not the order in which each kind first + // appeared. + inner.pending.push(event); + } + + fn take_next_native(inner: &mut FrontendGateInner) -> Option { + if !inner.degraded || inner.native_dialog_active || inner.pending.is_empty() { + return None; + } + inner.native_dialog_active = true; + Some(inner.pending.remove(0)) + } + + fn enter_degraded(inner: &mut FrontendGateInner) -> FrontendDegraded { + inner.ready = false; + inner.degraded = true; + let activation_pending = std::mem::take(&mut inner.activation_pending); + let next_native_event = Self::take_next_native(inner); + FrontendDegraded { + activation_pending, + next_native_event, + } + } + + pub fn route(&self, event: ShellEvent) -> ShellDispatch { + let mut inner = self + .inner + .lock() + .unwrap_or_else(|poison| poison.into_inner()); + if inner.ready { + return ShellDispatch::Emit(event); + } + + Self::queue_event(&mut inner, event); + if let Some(event) = Self::take_next_native(&mut inner) { + ShellDispatch::Native(event) + } else { + ShellDispatch::Queued { + pending: inner.pending.len(), + } + } + } + + pub fn mark_loading(&self) -> FrontendLoad { + let mut inner = self + .inner + .lock() + .unwrap_or_else(|poison| poison.into_inner()); + inner.generation = inner.generation.wrapping_add(1); + inner.ready = false; + inner.degraded = false; + inner.token = Some(uuid::Uuid::new_v4().to_string()); + FrontendLoad { + generation: inner.generation, + } + } + + pub fn current_token(&self) -> Option { + let inner = self + .inner + .lock() + .unwrap_or_else(|poison| poison.into_inner()); + inner.token.as_ref().map(|token| FrontendToken { + generation: inner.generation, + token: token.clone(), + }) + } + + pub fn request_activation(&self) -> bool { + let mut inner = self + .inner + .lock() + .unwrap_or_else(|poison| poison.into_inner()); + if inner.ready || inner.degraded { + true + } else { + inner.activation_pending = true; + false + } + } + + /// Whether the current renderer generation can safely own window + /// presentation. A timed-out generation is also presentable because native + /// degraded mode must be able to surface its fallback dialogs. + pub fn can_present_window(&self) -> bool { + let inner = self + .inner + .lock() + .unwrap_or_else(|poison| poison.into_inner()); + inner.ready || inner.degraded + } + + pub fn mark_ready(&self, generation: u64, token: &str) -> FrontendReadyResult { + let mut inner = self + .inner + .lock() + .unwrap_or_else(|poison| poison.into_inner()); + if inner.generation != generation || inner.token.as_deref() != Some(token) { + return FrontendReadyResult::Stale { + current_generation: inner.generation, + }; + } + // Once this document has timed out, keep native delivery latched until + // the next PageLoad Started creates a fresh generation. A late WebView + // handshake may update locale/title, but must not steal queued quit + // decisions back from the native fallback while a dialog is active. + if inner.degraded { + return FrontendReadyResult::Accepted(FrontendReady { + generation: inner.generation, + first_ready: false, + degraded: true, + activation_pending: false, + pending: Vec::new(), + }); + } + let first_ready = !inner.ready; + inner.ready = true; + let activation_pending = std::mem::take(&mut inner.activation_pending); + FrontendReadyResult::Accepted(FrontendReady { + generation: inner.generation, + first_ready, + degraded: false, + activation_pending, + pending: std::mem::take(&mut inner.pending), + }) + } + + pub fn mark_degraded(&self, generation: u64) -> Option { + let mut inner = self + .inner + .lock() + .unwrap_or_else(|poison| poison.into_inner()); + if inner.generation != generation || inner.ready || inner.degraded { + return None; + } + Some(Self::enter_degraded(&mut inner)) + } + + /// A renderer that previously completed the ready handshake can still lose + /// its quit listeners when the root React boundary replaces the app tree. + /// Authenticate that exact document before latching native delivery; a + /// delayed failure report from an older document must not degrade a reload. + pub fn mark_failed(&self, generation: u64, token: &str) -> FrontendFailureResult { + let mut inner = self + .inner + .lock() + .unwrap_or_else(|poison| poison.into_inner()); + if inner.generation != generation || inner.token.as_deref() != Some(token) { + return FrontendFailureResult::Stale { + current_generation: inner.generation, + }; + } + if inner.degraded { + return FrontendFailureResult::Accepted(FrontendDegraded { + activation_pending: false, + next_native_event: None, + }); + } + FrontendFailureResult::Accepted(Self::enter_degraded(&mut inner)) + } + + pub fn native_dialog_finished(&self) -> Option { + let mut inner = self + .inner + .lock() + .unwrap_or_else(|poison| poison.into_inner()); + inner.native_dialog_active = false; + Self::take_next_native(&mut inner) + } + + pub fn is_waiting_for(&self, generation: u64) -> bool { + let inner = self + .inner + .lock() + .unwrap_or_else(|poison| poison.into_inner()); + inner.generation == generation && !inner.ready && !inner.degraded + } +} + +#[cfg(test)] +mod tests { + use super::{ + FrontendFailureResult, FrontendGate, FrontendReadyResult, NativeLocale, ShellDispatch, + ShellEvent, + }; + use crate::app::op_phase::{OperationPhase, QuitPolicy}; + + fn blocked(reason: &str) -> ShellEvent { + ShellEvent::QuitBlocked(QuitPolicy::Block { + phase: OperationPhase::Committing, + reason_code: "committing".to_string(), + reason: reason.to_string(), + kind: Some("update".to_string()), + }) + } + + fn start_load(gate: &FrontendGate) -> (u64, String) { + let load = gate.mark_loading(); + let token = gate.current_token().expect("load token"); + assert_eq!(token.generation, load.generation); + (load.generation, token.token) + } + + #[test] + fn native_locale_maps_all_supported_tags_and_falls_back_to_english() { + let cases = [ + ("en", NativeLocale::En), + ("zh-CN", NativeLocale::ZhCn), + ("zh-TW", NativeLocale::ZhTw), + ("ja", NativeLocale::Ja), + ("ko", NativeLocale::Ko), + ("fr", NativeLocale::Fr), + ("de", NativeLocale::De), + ("es", NativeLocale::Es), + ("pt-BR", NativeLocale::PtBr), + ("ru", NativeLocale::Ru), + ("ar", NativeLocale::Ar), + ]; + for (tag, locale) in cases { + assert_eq!(NativeLocale::from_tag(tag), locale, "{tag}"); + let menu = locale.menu(); + assert!(!menu.edit.is_empty(), "{tag}:edit"); + assert!(!menu.window.is_empty(), "{tag}:window"); + assert!(menu.quit.contains("Codex App Manager"), "{tag}:quit"); + } + assert_eq!(NativeLocale::from_tag("unsupported"), NativeLocale::En); + } + + #[test] + fn startup_events_are_coalesced_then_drained_atomically() { + let gate = FrontendGate::default(); + let (_, token) = start_load(&gate); + assert_eq!( + gate.route(ShellEvent::ConfirmQuit), + ShellDispatch::Queued { pending: 1 } + ); + assert_eq!( + gate.route(blocked("old")), + ShellDispatch::Queued { pending: 2 } + ); + assert_eq!( + gate.route(blocked("latest")), + ShellDispatch::Queued { pending: 2 } + ); + + let FrontendReadyResult::Accepted(ready) = gate.mark_ready(1, &token) else { + panic!("current token must be accepted"); + }; + assert!(ready.first_ready); + assert!(!ready.degraded); + assert!(!ready.activation_pending); + assert_eq!(ready.pending.len(), 2); + assert!(matches!(ready.pending[0], ShellEvent::ConfirmQuit)); + assert!(matches!( + &ready.pending[1], + ShellEvent::QuitBlocked(QuitPolicy::Block { reason, .. }) if reason == "latest" + )); + assert!(!gate.is_waiting_for(1)); + + let gate = FrontendGate::default(); + let (_, token) = start_load(&gate); + gate.route(blocked("old")); + gate.route(ShellEvent::ConfirmQuit); + gate.route(blocked("latest")); + let FrontendReadyResult::Accepted(ready) = gate.mark_ready(1, &token) else { + panic!("current token must be accepted"); + }; + assert!(matches!(ready.pending[0], ShellEvent::ConfirmQuit)); + assert!(matches!( + &ready.pending[1], + ShellEvent::QuitBlocked(QuitPolicy::Block { reason, .. }) if reason == "latest" + )); + } + + #[test] + fn events_emit_immediately_after_frontend_is_ready() { + let gate = FrontendGate::default(); + assert!(!gate.can_present_window()); + let (_, token) = start_load(&gate); + assert!(matches!( + gate.mark_ready(1, &token), + FrontendReadyResult::Accepted(ref ready) if ready.first_ready && ready.pending.is_empty() + )); + assert!(gate.can_present_window()); + assert!(matches!( + gate.mark_ready(1, &token), + FrontendReadyResult::Accepted(ref ready) if !ready.first_ready && ready.pending.is_empty() + )); + assert_eq!( + gate.route(ShellEvent::ConfirmQuit), + ShellDispatch::Emit(ShellEvent::ConfirmQuit) + ); + } + + #[test] + fn authenticated_root_failure_switches_ready_delivery_to_native_fallback() { + let gate = FrontendGate::default(); + let (generation, token) = start_load(&gate); + assert!(matches!( + gate.mark_ready(generation, &token), + FrontendReadyResult::Accepted(ref ready) if ready.first_ready + )); + assert_eq!( + gate.route(ShellEvent::ConfirmQuit), + ShellDispatch::Emit(ShellEvent::ConfirmQuit) + ); + + assert_eq!( + gate.mark_failed(generation, "wrong-token"), + FrontendFailureResult::Stale { + current_generation: generation + } + ); + assert_eq!( + gate.route(ShellEvent::ConfirmQuit), + ShellDispatch::Emit(ShellEvent::ConfirmQuit) + ); + + assert!(matches!( + gate.mark_failed(generation, &token), + FrontendFailureResult::Accepted(ref degraded) + if !degraded.activation_pending && degraded.next_native_event.is_none() + )); + assert_eq!( + gate.route(ShellEvent::ConfirmQuit), + ShellDispatch::Native(ShellEvent::ConfirmQuit) + ); + assert!(gate.can_present_window()); + assert!(matches!( + gate.mark_ready(generation, &token), + FrontendReadyResult::Accepted(ref ready) if ready.degraded && !ready.first_ready + )); + } + + #[test] + fn stale_document_token_cannot_ready_a_new_renderer_generation() { + let gate = FrontendGate::default(); + let (_, stale_token) = start_load(&gate); + let (generation, current_token) = start_load(&gate); + assert!(gate.is_waiting_for(generation)); + assert!(!gate.request_activation()); + + assert_eq!( + gate.mark_ready(generation - 1, &stale_token), + FrontendReadyResult::Stale { + current_generation: generation + } + ); + assert!(gate.is_waiting_for(generation)); + assert_eq!( + gate.mark_ready(generation, "wrong-token"), + FrontendReadyResult::Stale { + current_generation: generation + } + ); + assert!(gate.is_waiting_for(generation)); + + let FrontendReadyResult::Accepted(ready) = gate.mark_ready(generation, ¤t_token) + else { + panic!("current token must be accepted"); + }; + assert!(ready.first_ready); + assert!(ready.activation_pending); + assert!(ready.pending.is_empty()); + assert!(!gate.is_waiting_for(generation)); + assert!(gate.request_activation()); + } + + #[test] + fn timeout_switches_to_serial_native_delivery_without_dropping_events() { + let gate = FrontendGate::default(); + let (generation, token) = start_load(&gate); + gate.route(ShellEvent::ConfirmQuit); + assert!(!gate.request_activation()); + + let degraded = gate + .mark_degraded(generation) + .expect("current load degrades"); + assert!(gate.can_present_window()); + assert!(degraded.activation_pending); + assert!(matches!( + degraded.next_native_event, + Some(ShellEvent::ConfirmQuit) + )); + assert!(!gate.is_waiting_for(generation)); + assert!(gate.request_activation()); + + assert_eq!( + gate.route(blocked("critical")), + ShellDispatch::Queued { pending: 1 } + ); + let FrontendReadyResult::Accepted(late_ready) = gate.mark_ready(generation, &token) else { + panic!("the current document token is still authentic"); + }; + assert!(late_ready.degraded); + assert!(!late_ready.first_ready); + assert!(late_ready.pending.is_empty()); + assert_eq!( + gate.route(ShellEvent::ConfirmQuit), + ShellDispatch::Queued { pending: 2 } + ); + assert!(matches!( + gate.native_dialog_finished(), + Some(ShellEvent::QuitBlocked(QuitPolicy::Block { reason, .. })) if reason == "critical" + )); + assert!(matches!( + gate.native_dialog_finished(), + Some(ShellEvent::ConfirmQuit) + )); + assert_eq!(gate.native_dialog_finished(), None); + assert_eq!( + gate.route(ShellEvent::ConfirmQuit), + ShellDispatch::Native(ShellEvent::ConfirmQuit) + ); + } +} diff --git a/src-tauri/src/app/staging.rs b/src-tauri/src/app/staging.rs index 74cc8db..6f86df9 100644 --- a/src-tauri/src/app/staging.rs +++ b/src-tauri/src/app/staging.rs @@ -39,9 +39,7 @@ pub struct CleanupSummary { } pub fn staging_root() -> PathBuf { - std::env::temp_dir() - .join("codex-app-manager") - .join("staging") + crate::app::paths::staging_root() } pub fn create_unique_staging(prefix: &str) -> Result { diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index ad16d40..ea3ec2b 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -1263,10 +1263,13 @@ pub fn confirm_quit(app: tauri::AppHandle, state: State<'_, ManagerState>) -> Re phase.as_str(), kind ); - let _ = app.emit("app://quit-blocked", &policy); + crate::dispatch_shell_event( + &app, + crate::app::shell::ShellEvent::QuitBlocked(policy.clone()), + ); return Err(AppError::Busy(reason.clone()).into()); } - app.exit(0); + crate::exit_after_confirm(&app); Ok(()) } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index c871825..fce323f 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -8,9 +8,18 @@ mod state; use std::sync::atomic::Ordering; -use tauri::{Emitter, Manager, RunEvent, WindowEvent}; +use tauri::webview::PageLoadEvent; +use tauri::{Emitter, Manager, RunEvent, UserAttentionType, WindowEvent}; +use tauri_plugin_dialog::{DialogExt, MessageDialogButtons, MessageDialogKind}; use crate::app::op_phase::QuitPolicy; +use crate::app::shell::{ + FrontendFailureResult, FrontendReadyResult, FrontendToken, NativeLocale, ShellDispatch, + ShellEvent, PRODUCT_NAME, +}; + +const FRONTEND_READY_GLOBAL: &str = "__CAM_FRONTEND_READY__"; +const FRONTEND_READY_EVENT: &str = "cam:frontend-readiness"; /// The "ask before closing" setting, read fresh from disk so a toggle in /// Settings takes effect immediately (no restart). @@ -19,7 +28,12 @@ fn confirm_close_enabled() -> bool { } /// Unified quit/close policy: phase-aware + confirm_close setting. -fn quit_policy_for(app: &tauri::AppHandle) -> QuitPolicy { +/// +/// When `confirmed` is true, the operation manager linearizes cancellation and +/// force-exit preparation with the active phase before allowing the process to +/// terminate. This is shared by the renderer and native-fallback confirmation +/// paths so neither can race a worker entering `Committing`. +fn prepare_quit_policy_for(app: &tauri::AppHandle, confirmed: bool) -> QuitPolicy { let state = app.state::(); let force = state.force_quit.load(Ordering::SeqCst); if force { @@ -27,7 +41,7 @@ fn quit_policy_for(app: &tauri::AppHandle) -> QuitPolicy { } state .operations - .prepare_quit(confirm_close_enabled(), false, || { + .prepare_quit(confirm_close_enabled(), confirmed, || { // Arm both platform latches while the operation phase mutex is still // held. Only the active platform has work; the other store is harmless. let _ = crate::app::mac_update::cancel_macos_download(); @@ -36,13 +50,21 @@ fn quit_policy_for(app: &tauri::AppHandle) -> QuitPolicy { }) } +fn quit_policy_for(app: &tauri::AppHandle) -> QuitPolicy { + prepare_quit_policy_for(app, false) +} + +fn confirmed_quit_policy_for(app: &tauri::AppHandle) -> QuitPolicy { + prepare_quit_policy_for(app, true) +} + /// Apply a quit policy decision for window/menu/exit paths. /// Returns `true` when the caller should proceed to exit. fn apply_quit_policy(app: &tauri::AppHandle, policy: &QuitPolicy) -> bool { match policy { QuitPolicy::Allow => true, QuitPolicy::Confirm => { - let _ = app.emit("app://confirm-quit", ()); + dispatch_shell_event(app, ShellEvent::ConfirmQuit); false } QuitPolicy::Block { @@ -56,9 +78,178 @@ fn apply_quit_policy(app: &tauri::AppHandle, policy: &QuitPolicy) -> bool { phase.as_str(), kind ); - let _ = app.emit("app://quit-blocked", policy); + dispatch_shell_event(app, ShellEvent::QuitBlocked(policy.clone())); + false + } + } +} + +fn request_main_window_attention(app: &tauri::AppHandle, reason: &str) { + let Some(window) = app.get_webview_window("main") else { + log::error!("main window unavailable reason={reason}"); + return; + }; + match window.request_user_attention(Some(UserAttentionType::Informational)) { + Ok(()) => log::info!("main window requested user attention reason={reason}"), + Err(error) => { + log::warn!("main window request-attention failed reason={reason} error={error}") + } + } +} + +fn restore_main_window(app: &tauri::AppHandle, reason: &str) { + #[cfg(target_os = "windows")] + if !app + .state::() + .webview_safe_to_show + .load(Ordering::SeqCst) + { + log::info!("main window restore deferred reason={reason} WebView2 safety gate incomplete"); + return; + } + + let Some(window) = app.get_webview_window("main") else { + log::error!("main window restore failed reason={reason} error=window-missing"); + return; + }; + + let mut failed = false; + for (step, result) in [ + ("unminimize", window.unminimize()), + ("show", window.show()), + ("focus", window.set_focus()), + ] { + if let Err(error) = result { + failed = true; + log::warn!("main window restore step failed reason={reason} step={step} error={error}"); + } + } + + let focused = match window.is_focused() { + Ok(focused) => focused, + Err(error) => { + failed = true; + log::warn!("main window focus probe failed reason={reason} error={error}"); false } + }; + let degraded = failed || !focused; + if degraded { + request_main_window_attention(app, reason); + } + log::info!("main window restored reason={reason} focused={focused} degraded={degraded}"); +} + +fn emit_shell_event(app: &tauri::AppHandle, event: &ShellEvent) { + let result = match event { + ShellEvent::ConfirmQuit => app.emit("app://confirm-quit", ()), + ShellEvent::QuitBlocked(policy) => app.emit("app://quit-blocked", policy), + }; + match result { + Ok(()) => log::info!("shell event emitted kind={}", event.kind()), + Err(error) => { + log::warn!( + "shell event emit failed kind={} error={error}", + event.kind() + ); + request_main_window_attention(app, "shell-event-emit-failed"); + } + } +} + +pub(crate) fn exit_after_confirm(app: &tauri::AppHandle) { + let _ = codex_mac_engine::cancel_active_download(); + let _ = codex_win_engine::cancel_active_download(); + app.state::() + .force_quit + .store(true, Ordering::SeqCst); + app.exit(0); +} + +fn native_confirm_allows_exit(policy: &QuitPolicy) -> bool { + !matches!(policy, QuitPolicy::Block { .. }) +} + +fn finish_native_shell_dialog(app: tauri::AppHandle) { + let next = app + .state::() + .frontend + .native_dialog_finished(); + if let Some(event) = next { + show_native_shell_event(app, event); + } +} + +fn show_native_shell_event(app: tauri::AppHandle, event: ShellEvent) { + let kind = event.kind(); + log::warn!("shell event using native fallback kind={kind}"); + restore_main_window(&app, "native-shell-fallback"); + + match event { + ShellEvent::ConfirmQuit => { + let mut dialog = app + .dialog() + .message( + "The interface is not responding. Quit Codex App Manager safely?\n\n\ + 界面没有响应。是否安全退出 Codex App Manager?", + ) + .title(PRODUCT_NAME) + .kind(MessageDialogKind::Warning) + .buttons(MessageDialogButtons::OkCancelCustom( + "Quit / 退出".to_string(), + "Keep Open / 保持打开".to_string(), + )); + if let Some(window) = app.get_webview_window("main") { + dialog = dialog.parent(&window); + } + dialog.show(move |confirmed| { + if confirmed { + let policy = confirmed_quit_policy_for(&app); + if !native_confirm_allows_exit(&policy) { + log::warn!("native quit confirmation recheck blocked policy={policy:?}"); + dispatch_shell_event(&app, ShellEvent::QuitBlocked(policy)); + } else { + log::info!("native quit confirmation accepted"); + exit_after_confirm(&app); + return; + } + } else { + log::info!("native quit confirmation cancelled"); + } + finish_native_shell_dialog(app); + }); + } + ShellEvent::QuitBlocked(policy) => { + let reason = match &policy { + QuitPolicy::Block { reason, .. } => reason.as_str(), + _ => "A protected operation is still active.", + }; + let mut dialog = app + .dialog() + .message(format!( + "Codex App Manager must stay open until the protected step finishes.\n\ + {reason}\n\n受保护步骤完成前,Codex App Manager 必须保持打开。" + )) + .title(PRODUCT_NAME) + .kind(MessageDialogKind::Warning) + .buttons(MessageDialogButtons::OkCustom("OK / 知道了".to_string())); + if let Some(window) = app.get_webview_window("main") { + dialog = dialog.parent(&window); + } + dialog.show(move |_| finish_native_shell_dialog(app)); + } + } +} + +pub(crate) fn dispatch_shell_event(app: &tauri::AppHandle, event: ShellEvent) { + let kind = event.kind(); + match app.state::().frontend.route(event) { + ShellDispatch::Emit(event) => emit_shell_event(app, &event), + ShellDispatch::Native(event) => show_native_shell_event(app.clone(), event), + ShellDispatch::Queued { pending } => { + log::info!("shell event queued kind={kind} pending={pending} frontend_ready=false"); + request_main_window_attention(app, "shell-event-queued"); + } } } @@ -164,7 +355,6 @@ fn configure_windows_browser_accelerators( app: &tauri::App, window: &tauri::WebviewWindow, enabled: bool, - show_after_gate: bool, ) -> tauri::Result<()> { use webview2_com::Microsoft::Web::WebView2::Win32::ICoreWebView2Settings3; use windows_core::Interface; @@ -176,7 +366,6 @@ fn configure_windows_browser_accelerators( let label = window.label().to_string(); let callback_label = label.clone(); let app_handle = app.handle().clone(); - let gated_window = window.clone(); let callback_result = window.with_webview(move |platform_webview| { let result = unsafe { (|| -> windows_core::Result<()> { @@ -188,30 +377,20 @@ fn configure_windows_browser_accelerators( }; match result { Ok(()) => { - if !show_after_gate { - app_handle - .state::() - .webview_safe_to_show - .store(true, Ordering::SeqCst); + let frontend_presentable = { + let state = app_handle.state::(); + state.webview_safe_to_show.store(true, Ordering::SeqCst); + state.frontend.can_present_window() + }; + if frontend_presentable { + restore_main_window(&app_handle, "webview-safety-gate"); log::info!( - "disabled native WebView2 browser accelerators window={callback_label}" + "disabled native WebView2 browser accelerators and opened window={callback_label}" ); } else { - match gated_window.show() { - Ok(()) => { - app_handle - .state::() - .webview_safe_to_show - .store(true, Ordering::SeqCst); - log::info!( - "disabled native WebView2 browser accelerators and opened window={callback_label}" - ); - } - Err(error) => abort_for_unsafe_windows_webview( - &app_handle, - &format!("failed to show gated window={callback_label}: {error}"), - ), - } + log::info!( + "disabled native WebView2 browser accelerators window={callback_label}; waiting for frontend readiness" + ); } } Err(error) => abort_for_unsafe_windows_webview( @@ -248,8 +427,6 @@ fn build_main_window(app: &tauri::App) -> tauri::Result<()> { })? .clone(); - #[cfg(target_os = "windows")] - let configured_visible = config.visible; config.visible = initial_main_window_visibility(config.visible, cfg!(target_os = "windows"), cfg!(dev)); @@ -278,12 +455,7 @@ fn build_main_window(app: &tauri::App) -> tauri::Result<()> { }) .build()?; #[cfg(target_os = "windows")] - configure_windows_browser_accelerators( - app, - &window, - browser_accelerators_enabled(cfg!(dev)), - configured_visible, - )?; + configure_windows_browser_accelerators(app, &window, browser_accelerators_enabled(cfg!(dev)))?; #[cfg(not(target_os = "windows"))] let _ = window; Ok(()) @@ -295,60 +467,229 @@ fn build_main_window(app: &tauri::App) -> tauri::Result<()> { /// where we can confirm first. The Edit submenu is preserved so the standard /// copy/paste/select-all shortcuts keep working in text fields. #[cfg(target_os = "macos")] -fn install_macos_menu(app: &tauri::App) -> tauri::Result<()> { +fn install_macos_menu(app: &tauri::AppHandle, locale: NativeLocale) -> tauri::Result<()> { use tauri::menu::{AboutMetadata, MenuBuilder, MenuItemBuilder, SubmenuBuilder}; - let quit = MenuItemBuilder::with_id("cam-quit", "Quit Codex App 管理器") + let copy = locale.menu(); + let metadata = AboutMetadata { + name: Some(PRODUCT_NAME.to_string()), + version: Some(app.package_info().version.to_string()), + short_version: Some(app.package_info().version.to_string()), + copyright: app.config().bundle.copyright.clone(), + ..AboutMetadata::default() + }; + let quit = MenuItemBuilder::with_id("cam-quit", copy.quit) .accelerator("Cmd+Q") .build(app)?; - let app_menu = SubmenuBuilder::new(app, "Codex App 管理器") - .about(Some(AboutMetadata::default())) + let minimize = MenuItemBuilder::with_id("cam-minimize", copy.minimize) + .accelerator("Cmd+M") + .build(app)?; + let close = MenuItemBuilder::with_id("cam-close", copy.close_window) + .accelerator("Cmd+W") + .build(app)?; + let app_menu = SubmenuBuilder::new(app, PRODUCT_NAME) + .about_with_text(copy.about, Some(metadata)) .separator() - .services() + .services_with_text(copy.services) .separator() - .hide() - .hide_others() - .show_all() + .hide_with_text(copy.hide) + .hide_others_with_text(copy.hide_others) + .show_all_with_text(copy.show_all) .separator() .item(&quit) .build()?; - let edit_menu = SubmenuBuilder::new(app, "Edit") - .undo() - .redo() + let edit_menu = SubmenuBuilder::new(app, copy.edit) + .undo_with_text(copy.undo) + .redo_with_text(copy.redo) .separator() - .cut() - .copy() - .paste() - .select_all() + .cut_with_text(copy.cut) + .copy_with_text(copy.copy) + .paste_with_text(copy.paste) + .select_all_with_text(copy.select_all) .build()?; - let window_menu = SubmenuBuilder::new(app, "Window") - .minimize() - .close_window() + let window_menu = SubmenuBuilder::new(app, copy.window) + .items(&[&minimize, &close]) .build()?; let menu = MenuBuilder::new(app) .items(&[&app_menu, &edit_menu, &window_menu]) .build()?; app.set_menu(menu)?; + log::info!( + "native menu installed lang={} product={} about_version={}", + locale.code(), + PRODUCT_NAME, + app.package_info().version + ); Ok(()) } +/// The frontend calls this only after both quit listeners are registered. +/// The first handshake reveals the initially hidden window and drains any +/// close/quit decisions queued during WebView startup. Later calls only update +/// the native menu when the user changes the application language. +#[tauri::command] +fn frontend_ready( + app: tauri::AppHandle, + state: tauri::State<'_, state::ManagerState>, + lang: String, + generation: u64, + token: String, +) -> Result<(), String> { + let ready = match state.frontend.mark_ready(generation, &token) { + FrontendReadyResult::Accepted(ready) => ready, + FrontendReadyResult::Stale { current_generation } => { + log::warn!( + "stale frontend ready rejected lang={} generation={generation} current_generation={current_generation}", + NativeLocale::from_tag(&lang).code() + ); + return Err("stale frontend readiness token".to_string()); + } + }; + let locale = NativeLocale::from_tag(&lang); + #[cfg(target_os = "macos")] + if let Err(error) = install_macos_menu(&app, locale) { + log::warn!( + "native menu update failed lang={} error={error}", + locale.code() + ); + } + + if let Some(window) = app.get_webview_window("main") { + if let Err(error) = window.set_title(PRODUCT_NAME) { + log::warn!("main window title update failed error={error}"); + } + } else { + log::error!("frontend ready but main window is unavailable"); + } + + log::info!( + "frontend ready lang={} generation={} first_ready={} degraded={} activation_pending={} pending_events={}", + locale.code(), + ready.generation, + ready.first_ready, + ready.degraded, + ready.activation_pending, + ready.pending.len() + ); + if ready.first_ready { + let reason = if ready.activation_pending { + "frontend-ready-single-instance" + } else { + "frontend-ready" + }; + restore_main_window(&app, reason); + } + for event in ready.pending { + emit_shell_event(&app, &event); + } + Ok(()) +} + +/// The dependency-light root crash surface calls this as soon as it replaces +/// the normal React tree. The document token keeps a delayed report from an old +/// renderer from forcing a newly loaded interface into native fallback mode. +#[tauri::command] +fn frontend_failed( + app: tauri::AppHandle, + state: tauri::State<'_, state::ManagerState>, + generation: u64, + token: String, +) -> Result<(), String> { + let degraded = match state.frontend.mark_failed(generation, &token) { + FrontendFailureResult::Accepted(degraded) => degraded, + FrontendFailureResult::Stale { current_generation } => { + log::warn!( + "stale frontend failure rejected generation={generation} current_generation={current_generation}" + ); + return Err("stale frontend failure token".to_string()); + } + }; + log::error!( + "frontend root failed; native shell fallback enabled generation={generation} activation_pending={} native_event_pending={}", + degraded.activation_pending, + degraded.next_native_event.is_some() + ); + restore_main_window(&app, "frontend-root-failed"); + if degraded.activation_pending { + restore_main_window(&app, "frontend-root-failed-single-instance"); + } + if let Some(event) = degraded.next_native_event { + show_native_shell_event(app, event); + } + Ok(()) +} + +fn frontend_token_script(readiness: &FrontendToken) -> String { + let encoded = serde_json::to_string(&serde_json::json!({ + "generation": readiness.generation, + "token": readiness.token, + })) + .expect("frontend readiness is JSON serializable"); + format!( + "(() => {{ const readiness = Object.freeze({encoded}); \ + Object.defineProperty(window, '{FRONTEND_READY_GLOBAL}', \ + {{ value: readiness, configurable: true }}); \ + window.dispatchEvent(new CustomEvent('{FRONTEND_READY_EVENT}', \ + {{ detail: readiness }})); }})();" + ) +} + +fn schedule_frontend_ready_fallback(app: tauri::AppHandle, generation: u64) { + tauri::async_runtime::spawn_blocking(move || { + std::thread::sleep(std::time::Duration::from_secs(10)); + let Some(degraded) = app + .state::() + .frontend + .mark_degraded(generation) + else { + return; + }; + log::error!( + "frontend readiness timed out after 10s; entering native degraded mode generation={} activation_pending={} native_event_pending={}", + generation, + degraded.activation_pending, + degraded.next_native_event.is_some() + ); + restore_main_window(&app, "frontend-ready-timeout"); + if degraded.activation_pending { + restore_main_window(&app, "frontend-ready-timeout-single-instance"); + } + if let Some(event) = degraded.next_native_event { + show_native_shell_event(app, event); + } + }); +} + #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { tauri::Builder::default() - .plugin(tauri_plugin_single_instance::init(|app, _args, _cwd| { + .plugin(tauri_plugin_single_instance::init(|app, args, cwd| { + log::info!( + "single-instance activation requested args_count={} cwd_present={}", + args.len(), + !cwd.is_empty() + ); + let frontend_ready = app + .state::() + .frontend + .request_activation(); #[cfg(target_os = "windows")] if !app .state::() .webview_safe_to_show .load(Ordering::SeqCst) { - log::warn!("ignored second-instance focus before WebView2 safety gate completed"); + log::info!( + "single-instance activation queued WebView2 safety gate incomplete" + ); + request_main_window_attention(app, "single-instance-webview-gated"); return; } - if let Some(window) = app.get_webview_window("main") { - let _ = window.unminimize(); - let _ = window.show(); - let _ = window.set_focus(); + if frontend_ready { + restore_main_window(app, "single-instance"); + } else { + log::info!("single-instance activation queued frontend_ready=false"); + request_main_window_attention(app, "single-instance-queued"); } })) .plugin( @@ -392,6 +733,8 @@ pub fn run() { )) .manage(state::ManagerState::new()) .invoke_handler(tauri::generate_handler![ + frontend_ready, + frontend_failed, commands::mac_plan_update, commands::mac_stage_update, commands::mac_perform_update, @@ -447,13 +790,16 @@ pub fn run() { .setup(|app| { build_main_window(app)?; #[cfg(target_os = "macos")] - install_macos_menu(app)?; + install_macos_menu(app.handle(), NativeLocale::En)?; log::info!( "Codex App Manager v{} starting (os={}, arch={})", app.package_info().version, std::env::consts::OS, std::env::consts::ARCH ); + if let Some(run_id) = crate::app::paths::packaged_smoke_run_id() { + log::info!("packaged smoke run id={run_id} data_dir_isolated=true"); + } if let Some(logs_dir) = crate::app::logging::logs_dir(app.handle()) { tauri::async_runtime::spawn_blocking(move || { crate::app::logging::prune_old_logs( @@ -529,14 +875,76 @@ pub fn run() { } Ok(()) }) + .on_page_load(|webview, payload| { + if webview.label() != "main" { + return; + } + let app = webview.app_handle(); + match payload.event() { + PageLoadEvent::Started => { + let load = app + .state::() + .frontend + .mark_loading(); + log::info!("frontend page loading generation={}", load.generation); + schedule_frontend_ready_fallback(app.clone(), load.generation); + } + PageLoadEvent::Finished => { + let token = app + .state::() + .frontend + .current_token(); + let Some(token) = token else { + log::error!("frontend page finished without a readiness token"); + return; + }; + match webview.eval(frontend_token_script(&token)) { + Ok(()) => log::info!( + "frontend readiness token injected generation={}", + token.generation + ), + Err(error) => log::error!( + "frontend readiness token injection failed generation={} error={error}", + token.generation + ), + } + } + } + }) // Our custom macOS Quit item lands here (Cmd+Q). Same phase-aware policy // as window close / ExitRequested. .on_menu_event(|app, event| { - if event.id().0.as_str() == "cam-quit" { - let policy = quit_policy_for(app); - if apply_quit_policy(app, &policy) { - app.exit(0); + match event.id().0.as_str() { + "cam-quit" => { + log::info!("menu quit requested id=cam-quit"); + let policy = quit_policy_for(app); + if apply_quit_policy(app, &policy) { + app.exit(0); + } } + "cam-minimize" => { + log::info!("menu minimize requested id=cam-minimize"); + if let Some(window) = app.get_webview_window("main") { + if let Err(error) = window.minimize() { + log::warn!("menu minimize failed error={error}"); + request_main_window_attention(app, "menu-minimize-failed"); + } + } else { + log::error!("menu minimize failed error=window-missing"); + } + } + "cam-close" => { + log::info!("menu close requested id=cam-close"); + if let Some(window) = app.get_webview_window("main") { + if let Err(error) = window.close() { + log::warn!("menu close failed error={error}"); + request_main_window_attention(app, "menu-close-failed"); + } + } else { + log::error!("menu close failed error=window-missing"); + } + } + _ => {} } }) // A normal "open it when you need it" app — NOT a menu-bar resident. @@ -551,6 +959,7 @@ pub fn run() { .on_window_event(|window, event| { if let WindowEvent::CloseRequested { api, .. } = event { let app = window.app_handle(); + log::info!("window close requested label={}", window.label()); let policy = quit_policy_for(app); if apply_quit_policy(app, &policy) { app.exit(0); @@ -565,6 +974,7 @@ pub fn run() { // a window CloseRequested — gate it with the same phase-aware policy. .run(|app, event| { if let RunEvent::ExitRequested { api, .. } = event { + log::info!("application exit requested"); let policy = quit_policy_for(app); if !apply_quit_policy(app, &policy) { api.prevent_exit(); @@ -576,8 +986,38 @@ pub fn run() { #[cfg(test)] mod tests { use super::{ - browser_accelerators_enabled, initial_main_window_visibility, is_allowed_app_navigation, + browser_accelerators_enabled, frontend_token_script, initial_main_window_visibility, + is_allowed_app_navigation, native_confirm_allows_exit, FRONTEND_READY_EVENT, + FRONTEND_READY_GLOBAL, }; + use crate::app::op_phase::{OperationPhase, QuitPolicy}; + use crate::app::shell::FrontendToken; + + #[test] + fn readiness_token_script_uses_json_encoding_and_a_document_event() { + let script = frontend_token_script(&FrontendToken { + generation: 7, + token: "token-'\\-value".to_string(), + }); + assert!(script.contains(FRONTEND_READY_GLOBAL)); + assert!(script.contains(FRONTEND_READY_EVENT)); + assert!(script.contains(r#""generation":7"#)); + assert!(script.contains(r#"token-'\\-value"#)); + assert!(script.contains("configurable: true")); + assert!(script.contains("Object.freeze")); + } + + #[test] + fn native_confirmation_never_exits_a_protected_phase() { + assert!(native_confirm_allows_exit(&QuitPolicy::Allow)); + assert!(native_confirm_allows_exit(&QuitPolicy::Confirm)); + assert!(!native_confirm_allows_exit(&QuitPolicy::Block { + phase: OperationPhase::Committing, + reason_code: "committing".to_string(), + reason: "protected".to_string(), + kind: Some("update".to_string()), + })); + } #[test] fn native_browser_accelerators_are_release_only_disabled() { diff --git a/src-tauri/src/state.rs b/src-tauri/src/state.rs index 3a31df7..3fe3536 100644 --- a/src-tauri/src/state.rs +++ b/src-tauri/src/state.rs @@ -6,6 +6,7 @@ use crate::app::config_health::ConfigHealth; use crate::app::oplock::OperationManager; use crate::app::provenance::ProvenanceStore; use crate::app::settings_store::AppSettings as PersistedAppSettings; +use crate::app::shell::FrontendGate; use crate::domain::manifest::MirrorEndpoints; use crate::domain::settings::AppSettings; use crate::domain::target::Target; @@ -28,6 +29,7 @@ pub struct ManagerState { pub webview_gate_failed: AtomicBool, pub operations: OperationManager, pub config_health: Mutex, + pub frontend: FrontendGate, } #[cfg(any(target_os = "windows", test))] @@ -86,6 +88,7 @@ impl ManagerState { webview_gate_failed: AtomicBool::new(false), operations, config_health, + frontend: FrontendGate::default(), } } @@ -108,9 +111,7 @@ impl Default for ManagerState { #[cfg(test)] mod tests { - use super::{ - initial_webview_safe_to_show, webview_startup_gate, WebviewStartupGate, - }; + use super::{initial_webview_safe_to_show, webview_startup_gate, WebviewStartupGate}; #[test] fn windows_release_starts_with_the_webview_show_gate_closed() { @@ -121,7 +122,10 @@ mod tests { #[test] fn failure_dominates_the_windows_startup_gate_state() { assert_eq!(webview_startup_gate(false, false), WebviewStartupGate::Wait); - assert_eq!(webview_startup_gate(true, false), WebviewStartupGate::Proceed); + assert_eq!( + webview_startup_gate(true, false), + WebviewStartupGate::Proceed + ); assert_eq!(webview_startup_gate(false, true), WebviewStartupGate::Abort); assert_eq!(webview_startup_gate(true, true), WebviewStartupGate::Abort); } diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 5713c09..5910086 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -16,13 +16,13 @@ { "label": "main", "create": false, - "title": "Codex App 管理器", + "title": "Codex App Manager", "width": 400, "height": 640, "resizable": false, "decorations": false, "transparent": true, - "visible": true, + "visible": false, "center": true, "shadow": false } diff --git a/src-tauri/tauri.smoke.conf.json b/src-tauri/tauri.smoke.conf.json new file mode 100644 index 0000000..ac4cae9 --- /dev/null +++ b/src-tauri/tauri.smoke.conf.json @@ -0,0 +1,8 @@ +{ + "$schema": "https://schema.tauri.app/config/2", + "identifier": "io.github.wangnov.codexappmanager.smoke", + "bundle": { + "active": true, + "targets": ["app"] + } +} diff --git a/src/app/ErrorBoundary.test.tsx b/src/app/ErrorBoundary.test.tsx index d16073d..7ecb579 100644 --- a/src/app/ErrorBoundary.test.tsx +++ b/src/app/ErrorBoundary.test.tsx @@ -1,15 +1,18 @@ -import { render, screen, waitFor } from "@testing-library/react"; +import { act, render, screen, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { beforeEach, describe, expect, it, vi } from "vitest"; +import { listen } from "@tauri-apps/api/event"; import type { Diagnostics, OperationSnapshot } from "../shared/types"; import { managerApi } from "../services/managerApi"; -import { CATALOG } from "./i18n"; +import { CATALOG, I18nProvider } from "./i18n"; import { crashBodyForSnapshot, ErrorBoundary, type CrashStrings, } from "./ErrorBoundary"; +import { QuitConfirm } from "./components"; +import { ThemeProvider } from "./theme"; vi.mock("../services/managerApi", () => ({ errorMessage: (cause: unknown) => { @@ -24,6 +27,7 @@ vi.mock("../services/managerApi", () => ({ reportFrontendError: vi.fn(() => Promise.resolve()), getOperationSnapshot: vi.fn(() => Promise.resolve(null)), confirmQuit: vi.fn(() => Promise.resolve()), + frontendReady: vi.fn(() => Promise.resolve()), getSettings: vi.fn(() => Promise.resolve({ confirmClose: true, @@ -63,6 +67,8 @@ const getDiagnostics = vi.mocked(managerApi.getDiagnostics); const reportFrontendError = vi.mocked(managerApi.reportFrontendError); const getOperationSnapshot = vi.mocked(managerApi.getOperationSnapshot); const confirmQuit = vi.mocked(managerApi.confirmQuit); +const frontendReady = vi.mocked(managerApi.frontendReady); +const listenMock = vi.mocked(listen); function enCrashStrings(): CrashStrings { const en = CATALOG.en; @@ -83,12 +89,202 @@ function enCrashStrings(): CrashStrings { beforeEach(() => { localStorage.setItem("cam.lang", "en"); + Object.defineProperty(window, "__TAURI_INTERNALS__", { + configurable: true, + writable: true, + value: {}, + }); + Object.defineProperty(window, "__CAM_FRONTEND_READY__", { + configurable: true, + writable: true, + value: { generation: 1, token: "test-generation-token" }, + }); getDiagnostics.mockResolvedValue(diagnostics); getOperationSnapshot.mockResolvedValue(null); confirmQuit.mockResolvedValue(undefined); + frontendReady.mockResolvedValue(undefined); + listenMock.mockResolvedValue(() => {}); }); describe("ErrorBoundary", () => { + it("uses only the local close event in a browser preview", () => { + delete ( + window as typeof window & { __TAURI_INTERNALS__?: unknown } + ).__TAURI_INTERNALS__; + + render( + + + + + , + ); + + expect(listenMock).not.toHaveBeenCalled(); + expect(reportFrontendError).not.toHaveBeenCalled(); + expect(frontendReady).not.toHaveBeenCalled(); + }); + + it("announces frontend readiness only after both native quit listeners register", async () => { + render( + + + + + , + ); + + await waitFor(() => { + expect(listenMock).toHaveBeenCalledWith("app://confirm-quit", expect.any(Function)); + expect(listenMock).toHaveBeenCalledWith("app://quit-blocked", expect.any(Function)); + expect(frontendReady).toHaveBeenCalledWith("en", 1, "test-generation-token"); + }); + expect(listenMock.mock.invocationCallOrder[1]).toBeLessThan( + frontendReady.mock.invocationCallOrder[0], + ); + }); + + it("waits for the backend token before announcing the current document", async () => { + delete ( + window as typeof window & { __CAM_FRONTEND_READY__?: unknown } + ).__CAM_FRONTEND_READY__; + + render( + + + + + , + ); + + await waitFor(() => expect(listenMock).toHaveBeenCalledTimes(2)); + expect(frontendReady).not.toHaveBeenCalled(); + + Object.defineProperty(window, "__CAM_FRONTEND_READY__", { + configurable: true, + writable: true, + value: { generation: 2, token: "late-generation-token" }, + }); + window.dispatchEvent(new CustomEvent("cam:frontend-readiness")); + + await waitFor(() => + expect(frontendReady).toHaveBeenCalledWith("en", 2, "late-generation-token"), + ); + }); + + it("logs listener registration failure without falsely announcing readiness", async () => { + const consoleError = vi.spyOn(console, "error").mockImplementation(() => {}); + const consoleWarn = vi.spyOn(console, "warn").mockImplementation(() => {}); + listenMock.mockRejectedValue(new Error("listener unavailable")); + + render( + + + + + , + ); + + await waitFor(() => + expect(reportFrontendError).toHaveBeenCalledWith( + expect.objectContaining({ + kind: "native-shell-listeners", + message: "listener unavailable", + }), + ), + ); + expect(frontendReady).not.toHaveBeenCalled(); + consoleWarn.mockRestore(); + consoleError.mockRestore(); + }); + + it("retries partial listener registration and releases the orphaned listener once", async () => { + const consoleError = vi.spyOn(console, "error").mockImplementation(() => {}); + const orphanedUnlisten = vi.fn(); + listenMock + .mockResolvedValueOnce(orphanedUnlisten) + .mockRejectedValueOnce(new Error("second listener unavailable")); + + render( + + + + + , + ); + + await waitFor( + () => expect(frontendReady).toHaveBeenCalledWith("en", 1, "test-generation-token"), + { timeout: 2000 }, + ); + expect(orphanedUnlisten).toHaveBeenCalledTimes(1); + expect(reportFrontendError).toHaveBeenCalledWith( + expect.objectContaining({ + kind: "native-shell-listeners", + message: "second listener unavailable", + }), + ); + consoleError.mockRestore(); + }); + + it("retries a transient frontend-ready IPC failure until queued events can drain", async () => { + const consoleError = vi.spyOn(console, "error").mockImplementation(() => {}); + frontendReady.mockRejectedValueOnce(new Error("IPC warming up")).mockResolvedValue(undefined); + + render( + + + + + , + ); + + await waitFor(() => expect(frontendReady).toHaveBeenCalledTimes(2), { timeout: 2000 }); + expect(frontendReady).toHaveBeenNthCalledWith(1, "en", 1, "test-generation-token"); + expect(frontendReady).toHaveBeenNthCalledWith(2, "en", 1, "test-generation-token"); + expect(reportFrontendError).toHaveBeenCalledWith( + expect.objectContaining({ kind: "native-shell-ready", message: "IPC warming up" }), + ); + consoleError.mockRestore(); + }); + + it("reruns an in-flight handshake with the replacement document generation and token", async () => { + let resolveFirst: (() => void) | undefined; + frontendReady + .mockImplementationOnce( + () => + new Promise((resolve) => { + resolveFirst = resolve; + }), + ) + .mockResolvedValue(undefined); + + render( + + + + + , + ); + + await waitFor(() => + expect(frontendReady).toHaveBeenCalledWith("en", 1, "test-generation-token"), + ); + Object.defineProperty(window, "__CAM_FRONTEND_READY__", { + configurable: true, + writable: true, + value: { generation: 2, token: "replacement-token" }, + }); + await act(async () => { + window.dispatchEvent(new CustomEvent("cam:frontend-readiness")); + resolveFirst?.(); + }); + + await waitFor(() => + expect(frontendReady).toHaveBeenCalledWith("en", 2, "replacement-token"), + ); + }); + it("renders a crash screen and copies diagnostics with the JS error", async () => { const user = userEvent.setup(); const consoleError = vi.spyOn(console, "error").mockImplementation(() => {}); @@ -209,6 +405,9 @@ describe("ErrorBoundary", () => { it("uses the backend phase-aware quit command without depending on QuitConfirm", async () => { const user = userEvent.setup(); const consoleError = vi.spyOn(console, "error").mockImplementation(() => {}); + delete ( + window as typeof window & { __TAURI_INTERNALS__?: unknown } + ).__TAURI_INTERNALS__; render( diff --git a/src/app/RootCrashBoundary.test.tsx b/src/app/RootCrashBoundary.test.tsx index 94fe94a..93cbd84 100644 --- a/src/app/RootCrashBoundary.test.tsx +++ b/src/app/RootCrashBoundary.test.tsx @@ -5,6 +5,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { managerApi } from "../services/managerApi"; import { invokeRootBackend, + notifyRootFrontendFailure, operationRiskForSnapshot, RootCrashBoundary, } from "./RootCrashBoundary"; @@ -33,6 +34,13 @@ function StringBoom(): never { } beforeEach(() => { + delete ( + window as typeof window & { + __CAM_FRONTEND_READY__?: unknown; + __TAURI_INTERNALS__?: unknown; + } + ).__CAM_FRONTEND_READY__; + delete (window as typeof window & { __TAURI_INTERNALS__?: unknown }).__TAURI_INTERNALS__; localStorage.setItem("cam.lang", "en"); vi.mocked(managerApi.reportFrontendError).mockResolvedValue(undefined); vi.mocked(managerApi.getOperationSnapshot).mockResolvedValue(null); @@ -94,6 +102,31 @@ describe("RootCrashBoundary", () => { consoleError.mockRestore(); }); + it("authenticates the crashed document before switching native quit delivery", async () => { + const consoleError = vi.spyOn(console, "error").mockImplementation(() => {}); + const invoke = vi.fn().mockResolvedValue(undefined); + const host = window as typeof window & { + __CAM_FRONTEND_READY__?: { generation: number; token: string }; + __TAURI_INTERNALS__?: { invoke: typeof invoke }; + }; + host.__CAM_FRONTEND_READY__ = { generation: 7, token: "document-token" }; + host.__TAURI_INTERNALS__ = { invoke }; + + render( + + + , + ); + + await waitFor(() => + expect(invoke).toHaveBeenCalledWith("frontend_failed", { + generation: 7, + token: "document-token", + }), + ); + consoleError.mockRestore(); + }); + it("still renders recovery controls when local storage is unavailable", () => { const consoleError = vi.spyOn(console, "error").mockImplementation(() => {}); const getItem = vi.spyOn(localStorage, "getItem").mockImplementation(() => { @@ -147,4 +180,29 @@ describe("invokeRootBackend", () => { else host.__TAURI_INTERNALS__ = previous; } }); + + it("waits for a late document token and normalizes synchronous bridge errors", async () => { + const host = window as typeof window & { + __CAM_FRONTEND_READY__?: { generation: number; token: string }; + __TAURI_INTERNALS__?: { invoke: ReturnType }; + }; + const invoke = vi.fn((_command: string, _args?: Record): Promise => { + throw new Error("bridge threw"); + }); + host.__TAURI_INTERNALS__ = { invoke }; + + await expect(invokeRootBackend("frontend_failed", {})).rejects.toThrow("bridge threw"); + invoke.mockResolvedValue(undefined); + notifyRootFrontendFailure(); + expect(invoke).toHaveBeenCalledTimes(1); + + host.__CAM_FRONTEND_READY__ = { generation: 9, token: "late-token" }; + window.dispatchEvent(new Event("cam:frontend-readiness")); + await waitFor(() => + expect(invoke).toHaveBeenLastCalledWith("frontend_failed", { + generation: 9, + token: "late-token", + }), + ); + }); }); diff --git a/src/app/RootCrashBoundary.tsx b/src/app/RootCrashBoundary.tsx index 2aa8c58..a5b46f3 100644 --- a/src/app/RootCrashBoundary.tsx +++ b/src/app/RootCrashBoundary.tsx @@ -20,6 +20,28 @@ type RootTauriInternals = { invoke?: (command: string, args?: Record) => Promise; }; +type RootFrontendReadiness = { generation: number; token: string }; + +const FRONTEND_READY_EVENT = "cam:frontend-readiness"; + +function hasRootBackend(): boolean { + const internals = ( + window as typeof window & { __TAURI_INTERNALS__?: RootTauriInternals } + ).__TAURI_INTERNALS__; + return typeof internals?.invoke === "function"; +} + +function rootFrontendReadiness(): RootFrontendReadiness | null { + const readiness = ( + window as typeof window & { __CAM_FRONTEND_READY__?: unknown } + ).__CAM_FRONTEND_READY__; + if (!readiness || typeof readiness !== "object") return null; + const { generation, token } = readiness as Partial; + if (!Number.isSafeInteger(generation) || (generation ?? 0) <= 0) return null; + if (typeof token !== "string" || !token.trim()) return null; + return { generation: generation as number, token }; +} + function rootErrorMessage(value: unknown): string { if (value instanceof Error) return value.message; if (value && typeof value === "object" && "message" in value) { @@ -44,7 +66,32 @@ export function invokeRootBackend( if (typeof internals?.invoke !== "function") { return Promise.reject(new Error("Desktop backend unavailable.")); } - return internals.invoke(command, args); + try { + return Promise.resolve(internals.invoke(command, args)); + } catch (cause) { + return Promise.reject(cause); + } +} + +/** + * Tell the backend that the current renderer no longer owns native quit-event + * delivery. If Tauri injects the per-document token after React fails, wait for + * that event instead of sending an unauthenticated or stale state transition. + */ +export function notifyRootFrontendFailure(): void { + if (!hasRootBackend()) return; + const notify = () => { + const readiness = rootFrontendReadiness(); + if (!readiness) return false; + void invokeRootBackend("frontend_failed", readiness).catch(() => undefined); + return true; + }; + if (notify()) return; + const onReadiness = () => { + if (!notify()) return; + window.removeEventListener(FRONTEND_READY_EVENT, onReadiness); + }; + window.addEventListener(FRONTEND_READY_EVENT, onReadiness); } const COPY = { @@ -146,6 +193,7 @@ export class RootCrashBoundary extends Component<{ children: ReactNode }, State> stack: normalized.stack ?? null, componentStack: info.componentStack ?? null, }; + notifyRootFrontendFailure(); void import("../services/managerApi") .then(async ({ managerApi }) => { await managerApi diff --git a/src/app/components.tsx b/src/app/components.tsx index 5241694..cd50f11 100644 --- a/src/app/components.tsx +++ b/src/app/components.tsx @@ -11,12 +11,27 @@ import { import { getCurrentWindow } from "@tauri-apps/api/window"; import { listen } from "@tauri-apps/api/event"; -import { managerApi } from "../services/managerApi"; +import { errorMessage, managerApi } from "../services/managerApi"; import type { FailureSurface } from "./errorCopy"; import { Icon, type IconName, CodexMark } from "./icons"; import { useI18n } from "./i18n"; import { Sheet } from "./Sheet"; +const FRONTEND_READY_EVENT = "cam:frontend-readiness"; + +type FrontendReadiness = { generation: number; token: string }; + +function frontendReadiness(): FrontendReadiness | null { + const readiness = ( + window as typeof window & { __CAM_FRONTEND_READY__?: unknown } + ).__CAM_FRONTEND_READY__; + if (!readiness || typeof readiness !== "object") return null; + const { generation, token } = readiness as Partial; + if (!Number.isSafeInteger(generation) || (generation ?? 0) <= 0) return null; + if (typeof token !== "string" || !token.trim()) return null; + return { generation: generation as number, token }; +} + function isTauri(): boolean { return ( typeof window !== "undefined" && @@ -76,38 +91,87 @@ function MinimizeButton() { * When the backend is mid point-of-no-return install (`app://quit-blocked`), * a different sheet explains why quit is refused. */ export function QuitConfirm() { - const { t } = useI18n(); + const { t, lang } = useI18n(); const [open, setOpen] = useState(false); const [blockedCode, setBlockedCode] = useState(null); const [blockedFallback, setBlockedFallback] = useState(null); + const [listenersReady, setListenersReady] = useState(false); const titleId = useId(); const bodyId = useId(); useEffect(() => { - let unConfirm = () => {}; - let unBlocked = () => {}; - void listen("app://confirm-quit", () => { - setBlockedCode(null); - setBlockedFallback(null); - setOpen(true); - }) - .then((f) => (unConfirm = f)) - .catch(() => undefined); - void listen<{ reasonCode?: string; reason?: string }>("app://quit-blocked", (event) => { - const code = - typeof event.payload?.reasonCode === "string" && event.payload.reasonCode.trim() - ? event.payload.reasonCode - : "busy"; - const fallback = - typeof event.payload?.reason === "string" && event.payload.reason.trim() - ? event.payload.reason - : null; - setBlockedCode(code); - setBlockedFallback(fallback); - setOpen(true); - }) - .then((f) => (unBlocked = f)) - .catch(() => undefined); + let disposed = false; + let retryTimer: number | null = null; + let attempt = 0; + const registered = new Set<() => void>(); + const releaseListeners = (listeners = [...registered]) => { + listeners.forEach((unlisten) => { + if (!registered.delete(unlisten)) return; + unlisten(); + }); + }; + const registerListeners = async () => { + attempt += 1; + const currentAttempt: Array<() => void> = []; + try { + const confirmUnlisten = await listen("app://confirm-quit", () => { + setBlockedCode(null); + setBlockedFallback(null); + setOpen(true); + }); + registered.add(confirmUnlisten); + currentAttempt.push(confirmUnlisten); + if (disposed) { + releaseListeners(currentAttempt); + return; + } + + const blockedUnlisten = await listen<{ reasonCode?: string; reason?: string }>( + "app://quit-blocked", + (event) => { + const code = + typeof event.payload?.reasonCode === "string" && event.payload.reasonCode.trim() + ? event.payload.reasonCode + : "busy"; + const fallback = + typeof event.payload?.reason === "string" && event.payload.reason.trim() + ? event.payload.reason + : null; + setBlockedCode(code); + setBlockedFallback(fallback); + setOpen(true); + }, + ); + registered.add(blockedUnlisten); + currentAttempt.push(blockedUnlisten); + if (disposed) { + releaseListeners(currentAttempt); + return; + } + setListenersReady(true); + } catch (cause) { + releaseListeners(currentAttempt); + if (disposed) return; + const message = errorMessage(cause); + if (attempt === 1) { + console.error("[native-shell] quit listener registration failed", cause); + void managerApi.reportFrontendError({ + kind: "native-shell-listeners", + message, + stack: cause instanceof Error ? cause.stack ?? null : null, + componentStack: null, + }); + } else { + console.warn(`[native-shell] quit listener retry ${attempt} failed`, cause); + } + const delay = Math.min(5000, 250 * 2 ** Math.min(attempt - 1, 5)); + retryTimer = window.setTimeout(() => void registerListeners(), delay); + } + }; + // Browser previews have no Tauri event bridge. Their close-confirm path is + // the local cam:confirm-quit event below, so treating a missing bridge as a + // transient native failure would only create a permanent retry loop. + if (isTauri()) void registerListeners(); const onWeb = () => { setBlockedCode(null); setBlockedFallback(null); @@ -115,12 +179,82 @@ export function QuitConfirm() { }; window.addEventListener("cam:confirm-quit", onWeb); return () => { - unConfirm(); - unBlocked(); + disposed = true; + if (retryTimer != null) window.clearTimeout(retryTimer); + releaseListeners(); window.removeEventListener("cam:confirm-quit", onWeb); }; }, []); + useEffect(() => { + if (!listenersReady) return; + let cancelled = false; + let retryTimer: number | null = null; + let attempt = 0; + let attemptKey: string | null = null; + let inFlight = false; + let rerun = false; + const announceReady = async () => { + if (inFlight) { + rerun = true; + return; + } + const readiness = frontendReadiness(); + if (!readiness) return; + const key = `${readiness.generation}:${readiness.token}`; + if (attemptKey !== key) { + attemptKey = key; + attempt = 0; + } + attempt += 1; + inFlight = true; + try { + await managerApi.frontendReady(lang, readiness.generation, readiness.token); + } catch (cause) { + if (cancelled) return; + const message = errorMessage(cause); + if (attempt === 1) { + console.error("[native-shell] frontend-ready handshake failed; retrying", cause); + void managerApi.reportFrontendError({ + kind: "native-shell-ready", + message, + stack: cause instanceof Error ? cause.stack ?? null : null, + componentStack: null, + }); + } else { + console.warn(`[native-shell] frontend-ready retry ${attempt} failed`, cause); + } + const delay = Math.min(5000, 250 * 2 ** Math.min(attempt - 1, 5)); + retryTimer = window.setTimeout(() => void announceReady(), delay); + } finally { + inFlight = false; + if (rerun && !cancelled) { + rerun = false; + if (retryTimer != null) { + window.clearTimeout(retryTimer); + retryTimer = null; + } + void announceReady(); + } + } + }; + const onToken = () => { + if (cancelled) return; + if (retryTimer != null) { + window.clearTimeout(retryTimer); + retryTimer = null; + } + void announceReady(); + }; + window.addEventListener(FRONTEND_READY_EVENT, onToken); + void announceReady(); + return () => { + cancelled = true; + if (retryTimer != null) window.clearTimeout(retryTimer); + window.removeEventListener(FRONTEND_READY_EVENT, onToken); + }; + }, [lang, listenersReady]); + const blocked = blockedCode !== null; const blockedBody = !blocked ? t("close.confirm.body") diff --git a/src/services/managerApi.test.ts b/src/services/managerApi.test.ts index 329aeb3..3a0f545 100644 --- a/src/services/managerApi.test.ts +++ b/src/services/managerApi.test.ts @@ -124,6 +124,7 @@ describe("diagnostics API", () => { expect(diagnostics.os).toBe("browser"); await expect(managerApi.openLogsDir()).resolves.toBeUndefined(); await expect(managerApi.openCodexHome()).resolves.toBeUndefined(); + await expect(managerApi.frontendReady("en", 1, "browser-token")).resolves.toBeUndefined(); await expect( managerApi.reportFrontendError({ kind: "test", @@ -140,6 +141,19 @@ describe("diagnostics API", () => { consoleError.mockRestore(); }); + it("reports frontend readiness and application language through IPC", async () => { + window.__TAURI_INTERNALS__ = {}; + invokeMock.mockResolvedValue(undefined); + + await expect(managerApi.frontendReady("zh-TW", 7, "generation-token")).resolves.toBeUndefined(); + + expect(invokeMock).toHaveBeenCalledWith("frontend_ready", { + lang: "zh-TW", + generation: 7, + token: "generation-token", + }); + }); + it("invokes diagnostics commands inside Tauri", async () => { window.__TAURI_INTERNALS__ = {}; const diagnostics = { diff --git a/src/services/managerApi.ts b/src/services/managerApi.ts index edae1d3..2801968 100644 --- a/src/services/managerApi.ts +++ b/src/services/managerApi.ts @@ -694,6 +694,12 @@ export const managerApi = { console.error("[frontend]", payload, cause); }); }, + frontendReady(lang: string, generation: number, token: string): Promise { + if (!hasTauriRuntime()) { + return Promise.resolve(); + } + return invoke("frontend_ready", { lang, generation, token }); + }, // Settings (update source + general). The backend persists them so the source // choice actually drives which appcast the update flow reads.