Skip to content

Add Windows support#1

Open
staminaoo wants to merge 2 commits into
r266-tech:mainfrom
staminaoo:codex/windows-support
Open

Add Windows support#1
staminaoo wants to merge 2 commits into
r266-tech:mainfrom
staminaoo:codex/windows-support

Conversation

@staminaoo
Copy link
Copy Markdown

@staminaoo staminaoo commented May 20, 2026

Summary

  • Add Windows PowerShell installer with .cmd wrappers.
  • Make sub2cli and sub2cli-inject work on Windows by using msvcrt instead of termios/tty.
  • Add Windows Codex CLI provider switching by copying slot auth files into ~/.codex/auth.json.
  • Keep macOS symlink/App profile behavior unchanged.
  • Fix sub2cli-redeem --help to exit successfully.

Windows Usage Docs Included

  • PowerShell install flow: ./install.ps1 plus python -m pip install --user requests websocket-client.
  • ExecutionPolicy fallback: powershell -ExecutionPolicy Bypass -File ./install.ps1.
  • Custom Python path: ./install.ps1 -Python "C:\Path\To\python.exe".
  • PATH note for %USERPROFILE%\.local\bin and generated .cmd wrappers.
  • Direct Codex CLI injection example: sub2cli-inject add-api https://relay.example.com/v1 sk-....
  • Windows behavior note: writes %USERPROFILE%\.codex\auth.<slot>.json, copies active slot to %USERPROFILE%\.codex\auth.json, updates %USERPROFILE%\.codex\config.toml, and requires manually reopening Codex App if it is running.

Validation

  • python -m py_compile sub2cli sub2cli-redeem sub2cli-inject
  • install.ps1 generated runnable .cmd wrappers
  • sub2cli --help
  • sub2cli-redeem --help
  • Tested Windows sub2cli-inject workflow with temporary CODEX_HOME: init -> add-api -> use local -> use relay -> current -> remove

@staminaoo staminaoo force-pushed the codex/windows-support branch from 3bf739d to 0664aa7 Compare May 20, 2026 11:24
Copy link
Copy Markdown
Owner

@r266-tech r266-tech left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the Windows port — the structure (gating macOS-specific paths behind IS_WINDOWS) is clean and doesn't disturb existing macOS behavior. Found a few correctness issues that block merge.

Must fix (correctness / credential safety)

1. Slot switch is not atomic on Windowssub2cli-inject cmd_switch

The Windows path copies auth.<slot>.jsonauth.json before save_slots() persists data["current"]. If the process is interrupted between these two steps, active auth.json reflects the new slot but slots metadata still points to the old slot. The next switch will then run save_current_windows_auth() and write the new slot's auth back to the old slot's auth_file, corrupting the old slot's credentials.

Fix: write auth.json to a temp file first, persist slots.json, then rename — or save_slots(data) before the copy and recover from inconsistency on next launch.

2. install.ps1 writes .cmd wrappers in ASCII

Set-Content -LiteralPath ... -Encoding ASCII -Value $cmd

The wrapper embeds $Python (the Python interpreter path). If the path contains any non-ASCII character (Chinese username, non-Latin install dir), it's silently mangled and the .cmd points at a nonexistent executable. Chinese-locale Windows installs hit this routinely.

Fix: -Encoding UTF8.

3. _run_inject invokes python <bin> but _resolve_inject_bin now accepts .cmdsub2cli

_resolve_inject_bin adds sub2cli-inject.cmd to candidates, but _run_inject always prepends the Python interpreter:

subprocess.call([sys.executable, inject_bin, "add-api", url, key])

If resolution picks the .cmd, this runs python sub2cli-inject.cmd which fails.

Fix: detect .cmd suffix and invoke directly, or skip .cmd from candidates and rely on the raw script on Windows too.

4. save_current_windows_auth() runs before quit_codex()cmd_switch

Codex.exe may still be writing auth.json while we snapshot it; the read can be partial. Swap the order: quit first, then snapshot.

Should fix

5. Windows cmd_init accepts dirty state — early-returns "already initialized" as soon as data.get("current") and data.get("slots") is truthy, without verifying that auth.json actually matches the current slot's auth_file. The macOS path verifies via symlink target; on Windows a divergent state never self-repairs.

6. PR body references sub2cli-redeem — that script was removed from main before this PR rebased. The --help fix mention and py_compile validation line referencing sub2cli-redeem should be dropped to avoid confusion.

Minor (non-blocking)

  • quit_codex Windows branch has unreachable return True after fail() (which sys.exits).
  • read_input Windows path skips the 200ms multi-line drain — pasted multi-line input is truncated to the first line. Acceptable for URL/apikey entry but worth a comment.
  • read_key Windows: unmapped special-key scan codes after \x00/\xe0 fall through to "back" — pressing F-keys unexpectedly exits a menu. Map unknown codes to "" (ignore) instead.
  • .cmd wrapper embeds the resolved $Python path — moving Python's install location breaks the wrapper until install.ps1 is re-run. Document or default to python from PATH.
  • APP_SUPPORT on Windows becomes %LOCALAPPDATA%\Codex, APP_PROFILE nests to Codex\Codex. Path is never accessed on Windows (gated) but is stored in slots.json's app_profile_dir — dead data. Consider blanking on Windows.

Happy to merge once #1–#4 are addressed. Thanks again for the contribution.

@staminaoo
Copy link
Copy Markdown
Author

Thanks for the careful review. Addressed the blocking items and the relevant should/minor items in aa65b2d:

  • Windows slot switching now prepares the target auth in a temp file, saves provider-slots.json first, then atomically installs/removes auth.json. This avoids auth.json pointing at the new slot while metadata still points at the old slot.
  • cmd_switch now quits Codex before snapshotting the current Windows auth, so it does not read while Codex may still be writing.
  • Windows startup/switch paths now recover a dirty auth.json by restoring it from the persisted current slot before any snapshot can overwrite the old slot.
  • install.ps1 writes .cmd wrappers as UTF-8 instead of ASCII.
  • _run_inject detects .cmd injectors and invokes them directly instead of running python sub2cli-inject.cmd.
  • Removed the stale sub2cli-redeem references from the PR body earlier; validation now only covers the scripts currently present on main.
  • Unknown Windows special keys now return "" instead of acting like Back.
  • Windows slot metadata now stores a blank app_profile_dir, since that path is not used on Windows.
  • README notes that the generated wrapper embeds the Python path and should be regenerated if Python moves.

Validation run locally on Windows:

  • python -m py_compile sub2cli sub2cli-inject
  • git diff --check
  • install.ps1 generated UTF-8 .cmd wrappers with a non-ASCII Python path preserved.
  • .cmd wrapper invocation path: sub2cli-inject.cmd add-api ... and current.
  • Windows switch workflow with temporary CODEX_HOME: init -> add-api -> use local -> use relay.
  • Dirty-state recovery: simulated metadata current=local while auth.json contained relay auth; cmd_init restored auth.json from auth.local.json without corrupting the local slot.

Copy link
Copy Markdown
Owner

@r266-tech r266-tech left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the quick turnaround — slot atomicity, .cmd detection, quit-before-snapshot, and the install.ps1 UTF-8 are all correctly addressed in aa65b2d. A re-read surfaced one class of issue both of us missed on the first round, plus a small leftover.

Must fix (new — credential/data integrity on non-UTF-8 Windows)

1. Text I/O in sub2cli-inject runs in locale encoding, not UTF-8

Every read_text() / write_text() / atomic_write_text call in sub2cli-inject omits encoding=, so Python uses locale.getpreferredencoding(). On a Chinese Windows install that's CP936; Western European is CP1252. Codex CLI writes config.toml / auth.json as UTF-8 (Rust default). Cross-tool round-trip on a non-UTF-8 Windows machine corrupts any non-ASCII byte the moment we patch config.toml (custom base_url, user-added comments, a Chinese display_name that ends up in slots metadata, etc).

Sites (line numbers on aa65b2d):

  • atomic_write_jsontmp.write_text(...) at L91
  • atomic_write_texttmp.write_text(text) at L107
  • load_slotsSLOTS_FILE.read_text() at L255
  • decode_auth_emailauth_file.read_text() at L318
  • write_apikey_auth json compare — path.read_text() at L454
  • patch_config reader — CONFIG_TOML.read_text() at L488
  • config_clean_for_slotCONFIG_TOML.read_text() at L537
  • relay api_key compare — auth_target.read_text() at L809

Pass encoding="utf-8" consistently. prepare_windows_auth_temp uses read_bytes/write_bytes which is fine.

Should fix

2. cmd_remove --hard leaves auth.json.*.tmp behind

prepare_windows_auth_temp writes plaintext auth to auth.json.<pid>.<ns>.tmp. Normal exit paths clean up, but a crashed or kill-9'd switch leaves the tmp on disk with credentials. Suggest globbing CODEX_HOME.glob("auth.json.*.tmp") in cmd_remove --hard, or unconditionally on cmd_init.

Still outstanding from last round

3. PR body still references sub2cli-redeem (Summary, Validation × 2). That script was removed from main pre-rebase. Earlier you said this was cleaned up but the body wasn't actually edited.

Deferred (pre-existing, not introduced by this PR — happy to handle in a follow-up)

  • No cross-process lock on slots.json + config.toml + auth.json. Two sub2cli-inject invocations racing can leave the three files describing different slots; Windows recover_windows_current_slot only realigns auth.json against current slot. macOS has the same hole. Not a blocker for this PR.
  • patch_config → auth install window: if the process dies between the two, config.toml reflects the new slot but auth.json is still the old one. recover fixes auth, not config. Same shape on macOS pre-existing.

Once #1–#3 are in, happy to merge. Thanks again.

r266-tech added a commit that referenced this pull request May 21, 2026
* refactor(sub2cli): extract Sub2Context to enable multi-relay GUI

Decouple per-relay state (domain/SITE/API_BASE/token + cdp host/port +
config path) from module globals into a Sub2Context object threaded
through every API call. Future GUI and JSON-RPC modes can hold multiple
relays concurrently without cross-contamination. REPL behavior preserved.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* feat(sub2cli-inject): add dry-run, file lock, snapshot, rollback

Make injection transactional + previewable so the upcoming GUI never
silently mutates ~/.codex.

- --dry-run on use/add-api/add-account: prints the full plan
  (symlinks, config.toml patch, state DB rows, rollout JSONL headers,
  Codex App quit/relaunch) without writing anything
- Sub2cliLock (fcntl) prevents concurrent switch/rollback corruption
- snapshot_pre_switch captures config.toml + symlink targets before
  every mutation; combined with the existing state_db backup the new
  rollback subcommand can restore to any prior state
- rollback [name|latest] + rollback --list to inspect and revert

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* chore: gitignore spike/ for P0a packaging spike

P0a (PyInstaller packaging spike) findings recorded out-of-tree under
spike/ (gitignored). Outcome: PyInstaller produces self-contained Mac
arm64 binaries — sub2cli 11M (with requests+websocket-client),
sub2cli-inject 8.7M (zero-dep). Both arm64 Mach-O, ad-hoc signed,
re-signable for Developer ID + notarization. Codex Critical #1
("Tauri shell does not solve Python sidecar distribution") is closed:
Python-first packaging is viable, pywebview track unlocked for P1.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* feat(desktop): P1 pywebview scaffold + JsApi bridge

Stack decision (gated on P0a success): pywebview over Tauri.
P0a proved PyInstaller can produce a self-contained 11M arm64 Mac
binary for sub2cli — so Python-first wins for MVP velocity (no Rust,
no IPC, Sub2Context imported directly via SourceFileLoader).

Skeleton:
- desktop/main.py: pywebview window + JsApi class (hello / list_relays
  for P1 verification)
- desktop/ui/index.html: minimal hero with two test buttons
- desktop/ui/style.css: dark theme + cyan accent matching sub2cli REPL
- desktop/ui/app.js: vanilla JS calling pywebview.api.* methods
- desktop/requirements.txt: pywebview 6.2.1 + pyobjc-WebKit + sub2cli deps

Verified:
- pywebview window opens + destroys on --smoke (exit 0)
- Sub2Context loaded via importlib.machinery.SourceFileLoader
  (sub2cli has no .py extension, default extension-based loader fails)
- ast.parse OK

Real main panel / 一键检测 / 一键注入 land in P2-P4.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* feat(desktop): P2 main panel — account / key / endpoints / groups live

Real Sub2Context-backed dashboard wired to JsApi:
- desktop/api.py: JsApi.bootstrap / refresh / reveal_default_key /
  ping_endpoint / set_default_endpoint / set_default_key / test_group
  (all serialized through a per-process Sub2Context, lock-guarded against
  concurrent js_api worker-thread reentry)
- ui/index.html: card grid for 账号 + 默认 key, full tables for endpoints
  and groups, footer CTA placeholder for P4 inject
- ui/style.css: dark theme matching sub2cli REPL palette (cyan accent
  on bg #0d0f12), card / table / tag-pill / loader / error-screen styles
- ui/app.js: state mgmt, async ping (one + all), per-group test (switches
  default key group then runs gpt-5.5 + image-2), reveal-key opt-in.
  All DOM construction via createElement + textContent (no innerHTML) —
  passes security pre-write hook, removes XSS surface from Sub2API data.

Smoke: timeout 5 main.py --smoke exits 0 (window opens, bootstrap fires,
destroys after 1s as designed for CI).

Defaults applied for V's open questions (per /goal autonomy):
- batch-redeem stays removed (V c4d419c removed it from public release)
- P7 (auto-switch + fault notifications) created as v2 placeholder
- P2.5 simple-mode toggle queued as separate task (don't bloat P2 scope)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* feat(desktop): P3 一键检测 modal — 5 environment checks

Closes 海叔's "一键检测环境和客户端状态" + Codex's Q9 (Mac native
hygiene). Header gets a 🩺 button that opens a modal running all 5
checks against live state:

1. Codex App in /Applications (reads CFBundleShortVersionString)
2. codex CLI in PATH (runs `codex --version`)
3. Edge CDP 9222 reachable + login tab for current relay's site
4. ~/.codex slot health (provider-slots.json + symlinks for auth.json
   and Application Support/Codex)
5. Default key + endpoint probe via probe_endpoint (auth latency)

Each check returns {name, ok, severity (ok|warn|err), message, fix_hint}.
The modal renders one row per check with a colored icon, the live message,
and the fix hint underlined. Re-run + Esc + click-outside-to-close wired.

Implementation: api.py.check_health() (~180 lines), modal HTML+CSS+JS
following createElement+textContent pattern (XSS-clean).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* feat(desktop): P4 one-click inject (dry-run gate) + multi-relay sidebar

Wires the GUI to P0c's transactional inject. Two flows:

1. 注入到 Codex (footer big button)
   - Modal opens, immediately calls api.inject_plan() which runs
     `sub2cli-inject add-api <url> <key> --skip-check --dry-run`
     and pipes the verbatim plan text into a <pre> preview
   - Confirm button stays disabled until the plan loads OK
   - On confirm: api.inject_apply() runs the real command (no dry-run);
     stdout/stderr piped back, status banner reflects success/failure

2. Sidebar (left, 220px)
   - Lists all saved relays from config["relays"], highlights current
     with cyan left border + accent text
   - Click another relay: api.switch_relay(domain) which rebuilds
     Sub2Context, persists cfg, re-bootstraps the dashboard

Both modals share the existing modal CSS; reused createElement +
textContent pattern (XSS-clean). Esc / click-outside close handlers
extended to cover both modals.

Closes the V-approved P4 scope; the 5-step destructive plan from
P0c (symlinks, write, patch_toml, normalize_sessions DB rows + JSONL
headers, quit/launch Codex App) is now visible to the GUI user
*before* any mutation lands.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* feat(desktop): P5 Keychain-backed multi-account (Codex High #3 closed)

V's "面板可以登录任意 sub2 账号查看数据" without re-logging into Edge
on every relaunch. macOS Keychain stores per-(domain,email) tokens;
config.accounts[domain] tracks {current, saved[]} without colliding
with sub2cli's RELAY_FIELDS-only save_config overwrite.

api.py adds:
- _kc_set/get/delete: keyring wrappers on service="sub2cli"
- _accounts_for_domain(cfg, domain): lazy mutable accounts entry
- _ensure_ctx: prefers Keychain token of cfg.accounts[domain].current
  over Edge CDP fetch on each launch
- JsApi.list_accounts / import_edge_account / switch_account /
  delete_account: full CRUD wired to the topbar dropdown

UI: header gets "👤 <email> ▼" chip that pops a 280px dropdown with
saved accounts + hover-x to delete + "+ 导入当前 Edge tab" footer.
Click-outside + esc close. Import flow:
  fresh CDP token → fetch_user → identify email → kc_set → save cfg
  → switch active ctx to new token (subsequent calls skip CDP).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* feat(desktop): P6 PyInstaller .app bundle + crash log + release pipeline

Closes v1 (P0–P6). Produces dist/sub2cli.app (~37M arm64 onedir
bundle) that runs without system Python.

Build (desktop/build.sh):
- pyinstaller main.spec
- spec bundles sub2cli (Python script) and sub2cli-inject-bundle
  (pre-built PyInstaller binary from P0a spike) into Contents/Resources/
  pyscripts/ — under a subdir to avoid name collision with the EXE
  (which is also named 'sub2cli')
- spec sets bundle_identifier com.r266-tech.sub2cli, LSMinimumSystemVersion
  12.0, NSHighResolutionCapable, hides console

Path resolution (main.py + api.py _resource_dir):
- .app onedir: Contents/Resources (dirname(exe)/../Resources)
- onefile / non-.app onedir: sys._MEIPASS
- dev mode: script dir / repo root
- _resolve_inject_bin and _find_sub2cli updated to check the resource
  dir's pyscripts/ subdir first, then repo root, then PATH

Crash log (main.py top-level try/except):
- Catches BaseException, writes ~/.config/sub2cli/crash-logs/
  crash-{ts}-{pid}.log (chmod 0600) with full traceback + argv + sys.version
- stderr gets a one-line summary pointing at the log path

Manual release pipeline (documented in build.sh heredoc, NOT executed —
needs V's Developer ID cert + Apple ID notary credentials):
  codesign --deep --options runtime --timestamp
  ditto -c -k --keepParent → notarytool submit --wait → stapler staple
  gh release create v0.1.0
Auto-update (Sparkle / pyupdater) deferred to a separate task.

Smoke: bash desktop/build.sh exits 0; bundled
  dist/sub2cli.app/Contents/MacOS/sub2cli --smoke exits 0 (window
  opens, pywebview event loop spins, --smoke 1s timer kills it).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* chore: gitignore desktop/{build,dist} PyInstaller artifacts

P6 commit (9301f68) accidentally included 256 build artifact files
(Python framework, dylibs, .so) from the local PyInstaller build.
Untrack them and gitignore so subsequent builds don't pollute the
repo. The .app bundle itself is reproducible via desktop/build.sh,
so no need to ship binaries in the source tree.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(desktop): import JsApi from api.py — drop stale P1 stub in main.py

V opened the freshly-built .app and saw the dashboard fail with
`window.pywebview.api.bootstrap is not a function`. Root cause: main.py
still carried the P1 minimal JsApi (only `hello` + `list_relays`) defined
inline. The full Sub2Context-backed JsApi (bootstrap / refresh / inject /
account / health / sidebar) lived in api.py from P2 onward but was never
imported into main.py. PyInstaller bundled the stale class, so the
bridge exposed P1 methods only.

Fix: replace inline JsApi class with `from api import JsApi`. Verified
post-rebuild:
- dist/sub2cli.app/Contents/MacOS/sub2cli --smoke exits 0
- `from main import JsApi; JsApi()` exposes bootstrap, list_relays_full,
  check_health, inject_plan (and all other Sub2Context-backed methods)

This was the bug that blocked the GUI from rendering V's dashboard data
in the screencap test.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* desktop P2.5: 简易模式 toggle + README Roadmap

desktop/ui:
- header 加 🎚 高级/简易 toggle (localStorage 持久, default 高级)
- .advanced-only 挂在: 并发 row / key value row / 端点 URL row / 端点 table card /
  分组 table card / dry-run hint / sidebar meta
- body.simple-mode .advanced-only { display: none !important } 一刀切
- 简易模式下放大注入 CTA, 留 "账号余额 + 名称/分组 + 一键注入大按钮"

README:
- 加 Roadmap 段 (Desktop GUI 开发中 + v1.x 待办 + v2 跨站健康守护)
- v2 backlog 不再塞 babata memory; 项目 backlog 应该在 repo 里

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

---------

Co-authored-by: r266-tech <r266-tech@users.noreply.github.com>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants