diff --git a/README.md b/README.md index c15e0a9..f2bd42a 100644 --- a/README.md +++ b/README.md @@ -128,6 +128,7 @@ Choose the operating system and processor of the computer you will use. | | Download | Needs | |---|---|---| | **macOS** (Apple Silicon) | [Frame-Control-mac-arm64.dmg](https://github.com/saphid/frame-control/releases/latest/download/Frame-Control-mac-arm64.dmg) | Nothing extra | +| **macOS** (Intel, v0.4.39+) | [Frame-Control-mac-x64.dmg](https://github.com/saphid/frame-control/releases/latest/download/Frame-Control-mac-x64.dmg) | Nothing extra | | **Windows** 10 / 11 (x64) | [Frame-Control-Setup-x64.exe](https://github.com/saphid/frame-control/releases/latest/download/Frame-Control-Setup-x64.exe) · [portable .zip](https://github.com/saphid/frame-control/releases/latest/download/Frame-Control-win-x64.zip) | OpenSSH Client (normally installed) | | **Linux** (x64) | [AppImage](https://github.com/saphid/frame-control/releases/latest/download/Frame-Control-linux-x86_64.AppImage) · [.deb](https://github.com/saphid/frame-control/releases/latest/download/Frame-Control-linux-amd64.deb) | `ssh` (most desktops have it) | | **Linux** (arm64) | [AppImage](https://github.com/saphid/frame-control/releases/latest/download/Frame-Control-linux-arm64.AppImage) · [.deb](https://github.com/saphid/frame-control/releases/latest/download/Frame-Control-linux-arm64.deb) | `ssh`, and `adb` for Android apps (`sudo apt install adb`) | @@ -150,8 +151,8 @@ The [source](https://github.com/saphid/frame-control) and [release build workflo 日本語: [5 ステップの接続ガイド](docs/connection-faq.md#日本語-5-ステップで接続)を参照してください。v0.4.26 以降は公式リリースの SHA256SUMS とファイルの SHA-256 値を照合してください。Windows は未署名、macOS は開発者 ID による署名・公証がありません。 -**Which build?** On a Mac, check Apple menu → About This Mac: the download is -for Apple Silicon (M-series); no Intel Mac build is published. On Windows, +**Which build?** On a Mac, check Apple menu → About This Mac: use `mac-arm64` +for Apple Silicon (M-series), or `mac-x64` for Intel (available from v0.4.39). On Windows, check Settings → System → About → System type: the release is x64, not a native Windows ARM build. On Linux, `uname -m` reports `x86_64` (x64) or `aarch64` (arm64). Use `.deb` on Debian/Ubuntu, or AppImage on other distributions. @@ -178,20 +179,25 @@ notarization. The DMG is unsigned. First verify the download against the `SHA256SUMS` from the same release, then open the DMG and drag **Frame Control** to **Applications**. -Check your processor before opening: v0.4.36 and earlier publish only +Check your processor before opening: v0.4.38 and earlier publish only `mac-arm64` for Apple Silicon. On an Intel Mac, this build cannot run; removing quarantine or installing Rosetta does not make an ARM app run on Intel. On a real Intel MacBook Air with macOS 15.7.7, opening v0.4.35 and v0.4.36 reported: **“The application cannot be opened because it has an incorrect executable format.”** This is an architecture failure, not an observed -Gatekeeper warning. See the [real-Mac test record](docs/evidence/mac-desktop-2026-10-26.md). +Gatekeeper warning. The official **v0.4.39 Intel DMG** was then verified, +installed and launched on that Air. Both CLI and T3 browser downloads lacked +quarantine metadata; no Gatekeeper dialog appeared, while `spctl` rejected the +ad hoc signed app. This does not verify Safari/Chrome warnings or Open Anyway. +See the [released Intel test](docs/evidence/mac-v0439-2026-10-26.md) and +[earlier architecture failure](docs/evidence/mac-desktop-2026-10-26.md). On a compatible Mac, if macOS blocks an unverified developer, try opening the app once, then go to **System Settings → Privacy & Security → Open Anyway** and confirm **Open**. Only do this for a download you verified and trust. [Apple's opening instructions](https://support.apple.com/102445) describe the current security controls. The exact warning and this override flow have not -yet been verified here on Apple Silicon. +yet been verified here on either Mac architecture. If a trusted, verified copy still reports “damaged” and offers no Open Anyway option, the Terminal fallback removes only this app's download quarantine diff --git a/docs/current-support-matrix.md b/docs/current-support-matrix.md index 80bf0d3..0a4ef1b 100644 --- a/docs/current-support-matrix.md +++ b/docs/current-support-matrix.md @@ -4,14 +4,14 @@ Release baseline: Frame Control **v0.4.39**, 26 October 2026. Rows also identify | Feature | Real Frame + physical PC tested | VM only | Untested or limited | Last check and proof | |---|---|---|---|---| -| Mac desktop release — tested on a real Mac | Physical MacBook Air (MacBookAir9,1), Intel Core i3, macOS 15.7.7 (24G720): v0.4.35/v0.4.36 DMGs verified and installed; both fail to launch because they are ARM64-only. Separate Intel candidate launched: UI, native preference read/write (restored), bundled Python logs, latest-version check and offline OBS Start/Stop checked; released UI browsed 116 Decky store entries | No | Candidate results are not released-build acceptance. Native Open at Login state, Gatekeeper override, real OBS/Frame stream, native settings across restart, headset transfer and wearer interaction remain unverified | 26 Oct, v0.4.35/v0.4.36 and Intel candidate `6ea9eb2`: [real-Mac evidence](evidence/mac-desktop-2026-10-26.md) | +| Mac desktop release — tested on a real Mac | Physical MacBook Air (MacBookAir9,1), Intel Core i3, macOS 15.7.7 (24G720): official v0.4.39 x64 DMG verified, installed and launched. Native UI, preference persistence across restart (original file restored), bundled Python logs, update check, offline OBS Start/Stop and 116-entry Decky store checked. A 40-byte relay upload/readback/delete passed under coordinator/headset locks | No | No quarantined Safari/Chrome download or Gatekeeper override test; native Open at Login/menu automation unavailable. Direct frame.local had no route and the app refused a stale SSH identity. Relay transfer is not native Mac transfer acceptance. Real OBS/Framey VR unverified. Quit exposed a server cleanup race; fix #284 has physical-Mac regression coverage, not a released fix | 26 Oct, official v0.4.39 tag `fd0b0a9`: [Intel release evidence](evidence/mac-v0439-2026-10-26.md); [earlier ARM64 failure](evidence/mac-desktop-2026-10-26.md) | | Core title smoke test | Linux lxso2 installed and removed reserved native/Proton titles on the real Frame; all 13 steps passed. ARM64 and Windows titles launched | No | The x86_64 title could not launch because Steam Linux Runtime 4.0 was not installed. The headset was unworn; visible rendering and controller input were not checked. Other feature paths retain their separate limits below | 26 Oct, v0.4.38 exact tag `d87b863`, Frame OS 0.5.6 build `20261022.6153909`: [latest release smoke](evidence/v0438-release-smoke-2026-10-26.md); [earlier detailed results](evidence/round2-smoke-2026-10-25.json) | | Windows desktop in the headset | No physical Windows PC test | Windows 11 VM sent a whole desktop to a real Frame; this used an intermediate fix, not the final native DLL | Physical GPU, capture and controller input; individual windows unavailable in bundled DXGI build | 24 Oct, v0.4.10 installer + candidate fix: [VM log](evidence/windows-vm-2026-10-24.md), [PC guide](pc-in-headset.md) | | Linux desktop in the headset | No | No | Native Linux PC capture is disabled; lxso2 lacked a desktop portal and PipeWire session. The separate Steam `-pipewire` helper is not Frame Control's PC capture | 24 Oct, v0.4.17: [host audit](evidence/linux-pc-host-lxso2-2026-10-24.md), [Steam helper PR #163](https://github.com/saphid/frame-control/pull/163) | | Mac desktop in the headset | A physical Mac sent its screen/window to a real Frame | No | Current v0.4.26 Mac installer and worn-headset controller input have not been repeated | 28 Sep, pre-v0.4 / Frame OS 0.4.1: [Mac guide and measurement](streaming.md#b-show-the-macs-desktop-inside-the-frame) | | OBS headset source on Linux | Real Frame to OBS 30.0.2 on a Linux host: 187.367 s, 720p30 H.264/AAC stereo, 5,621 frames, no encoder skips; room passthrough appeared in the recording | No | Synthetic tone was measured, not real game audio or audible listening. Default audio led by about 0.43 s in this setup; a measured +480 ms OBS offset removed residual skew at test resolution. Source-image latency was 1.431 s in one trial; other hosts and long sessions are unverified | 25 Oct, v0.4.30 package plus PR #219 runtime: [validation](evidence/obs-validation-2026-10-25.md) | | OBS headset source on Windows | No physical Windows PC test | Windows 11 VM helper fixture passed, but a native bridge to the real Frame sent no packets | In-app OBS controls remain hidden; native Windows OBS and firewall path are unverified | 25 Oct, PR #219: [validation](evidence/obs-validation-2026-10-25.md), [OBS guide](obs-headset-source.md) | -| OBS headset source on macOS | No | No | Helper and OBS Media Source not tested on a Mac with a Frame | 25 Oct, v0.4.22: [OBS PR #174](https://github.com/saphid/frame-control/pull/174), [OBS guide](obs-headset-source.md) | +| OBS headset source on macOS | Physical Intel Air: official v0.4.39 native Start/Stop returned to idle offline | No | Real headset stream, OBS receiver and audio/video remain unverified; the app rejected its stale SSH identity | 26 Oct, v0.4.39: [Mac evidence](evidence/mac-v0439-2026-10-26.md), [OBS guide](obs-headset-source.md) | | OBS headset source on a SteamOS / Steam Machine PC | No SteamOS PC or Flatpak OBS runtime test | No SteamOS host VM test | v0.4.27 AppImage launched on Ubuntu x86_64 under Xvfb; the helper sent H.264/AAC from a real Frame to a local UDP receiver. SteamOS read-only root, desktop UI and Flatpak OBS remain unverified | 25 Oct, v0.4.27: [host test](evidence/obs-steamos-host-2026-10-25.md), [OBS guide](obs-headset-source.md#steamos--steam-machine-pc-host) | | Wired connection doctor | No physical PC/Frame cable test of the doctor | No | Synthetic route/SSH tests; a separate Mac USB-C address observation does not test the doctor or game stream | 25 Oct, v0.4.23: [PR #178](https://github.com/saphid/frame-control/pull/178), [wired guide](wired-connection.md) | | Windows Wireless Adapter doctor | No physical Steam Wireless Adapter/dropout test | Windows 11 VM ran read-only checks without the adapter or WLAN | Adapter identification, dropouts and recovery on hardware | 24–25 Oct, v0.4.20–0.4.25: [VM log](evidence/windows-wireless-2026-10-24.md), [doctor guide](windows-wireless-doctor.md), [comparison PR #184](https://github.com/saphid/frame-control/pull/184) | diff --git a/docs/evidence/mac-desktop-2026-10-26.md b/docs/evidence/mac-desktop-2026-10-26.md index 3614cd1..0645bdd 100644 --- a/docs/evidence/mac-desktop-2026-10-26.md +++ b/docs/evidence/mac-desktop-2026-10-26.md @@ -5,6 +5,10 @@ Host: **MacBook Air (MacBookAir9,1), Intel Core i3 1.1 GHz, macOS 15.7.7 server with display/automation restrictions. No Apple Silicon runtime result is inferred from this test. +**Follow-up:** the official v0.4.39 Intel installer later launched on this Air. +See the [released-build test](mac-v0439-2026-10-26.md); the results below remain +the earlier ARM64 failure and separate candidate test. + ## Released installers: verified, installed, cannot run on Intel GitHub's latest release was v0.4.35 at the start; v0.4.36 became latest during diff --git a/docs/evidence/mac-v0439-2026-10-26.md b/docs/evidence/mac-v0439-2026-10-26.md new file mode 100644 index 0000000..21fcc81 --- /dev/null +++ b/docs/evidence/mac-v0439-2026-10-26.md @@ -0,0 +1,102 @@ +# Official Intel Mac release test, 26 October 2026 + +Physical **MacBookAir9,1, Intel Core i3 1.1 GHz, macOS 15.7.7 (24G720)**. +The earlier ARM64 failure led to [#262](https://github.com/saphid/frame-control/pull/262), +now merged. This test uses the published **v0.4.39**, tag `fd0b0a9`, rather +than the locally built candidate. [Earlier results](mac-desktop-2026-10-26.md) +remain a separate historical record. + +## Download, installation and Gatekeeper observations + +Downloaded `Frame-Control-mac-x64.dmg` and its same-release `SHA256SUMS` +with `gh release download`. `shasum -a 256 -c SHA256SUMS --ignore-missing` +passed for the downloaded asset: + +```text +f47b5a188b5090f97a72467394d4fa65ccc73fe3169a6276e2a32cab5c8019d8 Frame-Control-mac-x64.dmg +``` + +Also clicked that asset in T3's Chromium browser. Its 169,230,398-byte download +had the same hash. **Neither download had `com.apple.quarantine`.** T3's +browser is not evidence of Safari/Chrome download metadata. + +Mounted the verified CLI DMG read-only, preserved the task's earlier ARM64 +installation, and copied `Frame Control.app` into `/Applications` with `ditto` +(the command-line equivalent of dragging it there). The executable is x86_64; +`codesign -dv` reports ad hoc signing. `open "/Applications/Frame Control.app"` +returned success and the app started its bundled Python server. + +**No Gatekeeper warning dialog was observed.** `spctl --assess --type execute +-vv "/Applications/Frame Control.app"` returned exit 3 and exactly: + +```text +/Applications/Frame Control.app: rejected +``` + +Opening this unquarantined copy needed no override. This does not establish +the warning or opening steps for a quarantined browser download. The README +keeps Apple's Open Anyway guidance, explicitly unverified here. No quarantine +attribute was removed and no global security setting was changed. System +Events inspection timed out; an AppleEvent quit request returned “User +cancelled” (-128). Native console/menu automation remains unavailable. + +## Official native app results + +After the normal launch, a second run disabled telemetry, used a task-local +server data directory and enabled a temporary loopback renderer debug port. +The app bundle was not patched. The following checks used its real renderer +and `window.frameApp` IPC bridge; they are not physical mouse/menu tests. + +| Check | Result and limit | +|---|---| +| UI | Apps, Games, Tools, Settings, Privacy & updates, Capture and Decky views rendered with the expected headings. The activity log showed the stale SSH identity warning. [Native Apps screenshot](mac-v0439-native-2026-10-26.png) | +| Settings | Initial native preferences were `{}`. `mvSeparate="0"` saved and read back, including after a real app restart. The original preferences file was restored byte-for-byte in `finally` (or removed if originally absent) | +| Open at Login | Still opt-in in source; no login item was enabled. Actual menu checkbox and OS login behavior remain unverified because native automation was unavailable | +| Logs | `~/Library/Logs/Frame Control/server.log` recorded startup with the installed app's `Contents/Resources/python/bin/python3` and `ui/server.py`. Show Server Log menu interaction remains unverified | +| Update check | Native check returned current/latest `0.4.39`, status `none`, error `null`. No update installed | +| OBS | Native Start on port 15123 returned `starting`; Stop returned `stopping`, then `idle`. This was offline: no OBS receiver or real headset audio/video | +| Decky store | Native Load store returned 116 entries; 40 initial rows rendered. Search found ProtonDB Badges 1.3.4 and its compatibility text. Install remained disabled; no plugin/loader installed | +| Framey preview | No standalone local Framey preview control found. No VR runtime result claimed | + +Closing the app through `window.close()` stopped its processes and closed +the debug port. **The final shutdown log exposed a bug:** stdin EOF and the +app's SIGTERM can overlap, raising `KeyboardInterrupt` while cleanup is +installing its ignore handler. Cleanup can then stop before releasing staging +or SSH resources. [#284](https://github.com/saphid/frame-control/pull/284) +changes SIGTERM to request HTTP shutdown without raising into cleanup. + +The regression test injects SIGTERM at that exact cleanup boundary. On this +physical Mac with the official app's bundled Python 3.12, it failed before +the fix with exit -2 and the observed traceback. After the fix, all **45 +server integration tests** passed, including owned staging-file cleanup. +This validates source from the fix PR, not a fixed published app. + +## Headset relay and small transfer + +`frame.local` resolved but TCP 22 returned **No route to host**. The native +app refused a stale SSH identity; no pin was replaced. The trusted lxso2 +coordinator relay could reach the Frame. Both shared locks were absent. + +The relay test atomically created `/tmp/frame-test.lock` on the coordinator +and the headset, each with this job's owner marker. It then: + +- Read battery **99%, Not charging** before and after; storage total + **996,984,774,656 bytes**, free **769,077,035,008 bytes**. +- Listed **7** Steam screenshot JPEGs matching + `userdata/*/760/remote/*/screenshots/*.jpg`; no image was copied. This narrower + layout-specific count differs from the earlier broad JPEG search. +- Sent a **40-byte** test payload from this Mac through lxso2, created one + reserved file in the Frame user's home using exclusive creation, and read + it back. SHA-256 matched + `457485db13f80d819d5301d337502c3df050c6b3eb6202be7aac85d2af8e0b92`. +- Deleted that exact test file and released only the matching owned locks. + A separate SSH read verified the file and both locks were absent. + +This proves a bounded relay transfer, not native Mac app file transfer or +direct `frame.local` connectivity. No existing headset file, app, service, +power setting or SteamVR state was changed. The headset was not wiped or +rebooted. Real OBS and wearer/controller interaction remain unverified. + +The DMG was ejected; the native app, debug port and task browser tab were +closed; the desk hold was released. The official app remains installed for +reproduction. No social post was made; PR #113 was left alone. diff --git a/docs/evidence/mac-v0439-native-2026-10-26.png b/docs/evidence/mac-v0439-native-2026-10-26.png new file mode 100644 index 0000000..4c39d96 Binary files /dev/null and b/docs/evidence/mac-v0439-native-2026-10-26.png differ diff --git a/docs/frame-plugin-author.md b/docs/frame-plugin-author.md index c2e235e..65f0c1c 100644 --- a/docs/frame-plugin-author.md +++ b/docs/frame-plugin-author.md @@ -66,7 +66,7 @@ none of these frontend controls has passed a worn-headset check yet. | `Tabs`, `SidebarNavigation`, spinners, dialog/menu wrappers, progress bars | Local tab navigation and ordinary HTML controls; no Steam navigation or controller focus tree | Host tests and catalog frontend smoke checks in local Chromium | | `SteamClient` subscriptions and `DFL.Router.MainRunningApp` | Explicit inert registration methods return disposable handles; no events or attached running game. Mutating Steam/power methods are unavailable. | Host contract tests; these are safe fallbacks, not live Steam functionality | | `DialogCheckbox`, `DialogButtonPrimary` | Native checkbox and button controls in the panel | Host unit test; Storage Cleaner empty-data browser check | -| Optional `settings`, `frameyQuickActions` | Separate settings tab; up to eight actions with result text | Host unit test; these fields are Framey additions | +| Optional `settings`, `frameyQuickActions` | Separate settings tab when content also exists; settings-only plugins have no empty Panel tab; up to eight actions with result text | Host unit test; these fields are Framey additions | | `serverAPI.call` / `callable` | Calls only that plugin's Decky Python backend via the headset service | Fake Frame e2e; real ARM64 Notebook save/reopen across backend restart and Simple Timer settings/recents persistence via local Chromium. No worn-VR claim. | | API v1/v2 `addEventListener` / `removeEventListener` | Plugin-scoped real backend events while panel is open; bounded queue, about one-second polling, subscription expiry | Real ARM64 Simple Timer countdown, start/cancel and completion toast via local Chromium; scope/bounds/reconnect tests. No closed-panel notifications. | | `toaster.toast` | Visible toast inside the plugin panel; plain text or React nodes for title, body, icon, logo and subtext; `dismiss` and optional `onClick` | Host unit test; no Steam system notification or worn VR check | diff --git a/docs/framey-loader.md b/docs/framey-loader.md index 211f7d0..cc3a084 100644 --- a/docs/framey-loader.md +++ b/docs/framey-loader.md @@ -185,7 +185,8 @@ and the fake Frame flow before asking users to install it. An API v2 plugin's normal `content` is its default VR panel. Framey also shows an optional `settings` React element under a **Settings** tab and an optional -`titleView` above it. For one-tap headset controls, return +`titleView` above it. A plugin with only `settings` opens that view directly; +Framey shows tabs only when both views exist. For one-tap headset controls, return `frameyQuickActions: [{label: 'Refresh data', onClick: refreshData}]` from the plugin initializer. Framey displays up to eight actions and reports a pending, success, or failure state. These descriptor fields are Framey additions; Decky diff --git a/tests/page/framey_actions.mjs b/tests/page/framey_actions.mjs index 5a2f177..d215fef 100644 --- a/tests/page/framey_actions.mjs +++ b/tests/page/framey_actions.mjs @@ -105,7 +105,12 @@ assert.equal(nodes(tree,'header').length,1); await buttons[2].props.onClick(); assert.equal(called,1); assert.deepEqual(states.slice(-3),[0,'Refresh data…','Fresh',null].slice(-3)); -assert.equal(nodes(window.__surface({plugin:{settings:{type:'settings'}}}),'nav').length,1); +const settingsOnly=window.__surface({plugin:{settings:{type:'settings'}}}); +assert.equal(nodes(settingsOnly,'nav').length,0); +assert.equal(nodes(settingsOnly,'settings').length,1); +const panelOnly=window.__surface({plugin:{content:{type:'content'}}}); +assert.equal(nodes(panelOnly,'nav').length,0); +assert.equal(nodes(panelOnly,'content').length,1); // API v2 backend calls remain request-scoped and errors return to the caller. const api=window.__DECKY_SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED_deckyLoaderAPIInit.connect(2,'Test'); diff --git a/tests/test_server.py b/tests/test_server.py index 97696be..fde9435 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -426,10 +426,27 @@ class OneServer(unittest.TestCase): # Stopped as the app stops it: by closing stdin (the only way on Windows, # where terminate() is a hard kill) and, elsewhere, by SIGTERM too. - for stop in ["stdin"] + (["sigterm"] if os.name != "nt" else []): + for stop in ["stdin"] + (["sigterm", "stdin+late-sigterm"] if os.name != "nt" else []): with self.subTest(stop=stop): left_by_dead = [staged(tmp, "frame-vr-", dead.pid), staged(index_dir, ".download-", dead.pid)] - proc = subprocess.Popen([sys.executable, str(ROOT / "ui" / "server.py"), "--port", "0", "--exit-on-eof"], + command = [sys.executable, str(ROOT / "ui" / "server.py"), "--port", "0", "--exit-on-eof"] + if stop == "stdin+late-sigterm": + # Deliver the app's second stop mechanism exactly as cleanup + # replaces the signal handler: the race observed on a real Mac. + wrapper = ( + "import os, runpy, signal, sys; from pathlib import Path\n" + "original = signal.signal\n" + "def install(sig, handler):\n" + " if sig == signal.SIGTERM and handler == signal.SIG_IGN:\n" + " os.kill(os.getpid(), signal.SIGTERM)\n" + " return original(sig, handler)\n" + "signal.signal = install\n" + "sys.argv = sys.argv[1:]\n" + "sys.path.insert(0, str(Path(sys.argv[0]).parent))\n" + "runpy.run_path(sys.argv[0], run_name='__main__')\n" + ) + command = [sys.executable, "-c", wrapper, *command[1:]] + proc = subprocess.Popen(command, env=env, stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True) try: @@ -437,7 +454,7 @@ class OneServer(unittest.TestCase): self.assertEqual([d for d in left_by_dead if d.exists()], []) in_flight = [staged(tmp, "frame-vr-", proc.pid), staged(tmp, "frame-agent-", proc.pid), staged(index_dir, ".download-", proc.pid)] - if stop == "stdin": + if stop.startswith("stdin"): proc.stdin.close() else: proc.terminate() diff --git a/ui/framey-host.js b/ui/framey-host.js index 79cac81..b6f58a0 100644 --- a/ui/framey-host.js +++ b/ui/framey-host.js @@ -435,7 +435,7 @@ return R.createElement(R.Fragment, null, plugin.titleView && R.createElement('header', null, plugin.titleView), route && R.createElement('button', {type:'button', onClick:()=>navigate(null)}, 'Back to plugin'), - plugin.settings && R.createElement('nav', {'aria-label':'Plugin views'}, + plugin.content && plugin.settings && R.createElement('nav', {'aria-label':'Plugin views'}, R.createElement('button', {type:'button', 'aria-current':view === 'panel' ? 'page' : undefined, onClick:()=>setView('panel')}, 'Panel'), R.createElement('button', {type:'button', 'aria-current':view === 'settings' ? 'page' : undefined, diff --git a/ui/server.py b/ui/server.py index d5f2a0a..3a2ed6f 100755 --- a/ui/server.py +++ b/ui/server.py @@ -4182,8 +4182,12 @@ def main(): LINK.start() if not PRIVATE: # one worker per user, in the app's server threading.Thread(target=autosync.run, args=(_stop_sync,), daemon=True).start() + def request_shutdown(*_): + # shutdown() must run outside the serve_forever thread. Do not raise + # from SIGTERM: the app can send it after stdin EOF has begun cleanup. + threading.Thread(target=httpd.shutdown, daemon=True).start() if not frame_host.WINDOWS: - signal.signal(signal.SIGTERM, lambda *_: (_ for _ in ()).throw(KeyboardInterrupt)) + signal.signal(signal.SIGTERM, request_shutdown) if args.exit_on_eof: def watch_stdin(): # os.read, not sys.stdin.buffer.read: a buffered read holds stdin's lock, @@ -4191,7 +4195,7 @@ def main(): # when it can't take that lock back from this thread. while os.read(0, 4096): pass - threading.Thread(target=httpd.shutdown, daemon=True).start() + request_shutdown() threading.Thread(target=watch_stdin, daemon=True).start() try: # The real port, which --port 0 leaves to the system (the iPhone app reads it from here). @@ -4200,8 +4204,7 @@ def main(): except KeyboardInterrupt: pass finally: - # The app both closes stdin and sends SIGTERM on quit; a second signal - # mid-cleanup would abort it and leave the SSH master running. + # Cleanup is already underway; later stop signals need no more workers. if not frame_host.WINDOWS: signal.signal(signal.SIGTERM, signal.SIG_IGN) _power.shutdown()