Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
122 changes: 122 additions & 0 deletions .github/workflows/mac-packaged-smoke.yml
Original file line number Diff line number Diff line change
@@ -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
96 changes: 96 additions & 0 deletions docs/macos-packaged-smoke.md
Original file line number Diff line number Diff line change
@@ -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.
Loading
Loading