waterplane 0.1.17 → 0.1.19
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +41 -0
- package/NOTICES.md +18 -7
- package/README.md +46 -2
- package/SECURITY.md +196 -12
- package/node_modules/@waterplane/shared/dist/desktopfiles.d.ts +24 -2
- package/node_modules/@waterplane/shared/dist/desktopfiles.js +73 -3
- package/node_modules/@waterplane/shared/dist/desktopfiles.js.map +1 -1
- package/node_modules/@waterplane/shared/dist/hosts.d.ts +31 -0
- package/node_modules/@waterplane/shared/dist/hosts.js +46 -1
- package/node_modules/@waterplane/shared/dist/hosts.js.map +1 -1
- package/node_modules/@waterplane/shared/dist/hosturl.d.ts +55 -0
- package/node_modules/@waterplane/shared/dist/hosturl.js +85 -0
- package/node_modules/@waterplane/shared/dist/hosturl.js.map +1 -1
- package/node_modules/@waterplane/shared/dist/index.d.ts +4 -0
- package/node_modules/@waterplane/shared/dist/index.js +4 -0
- package/node_modules/@waterplane/shared/dist/index.js.map +1 -1
- package/node_modules/@waterplane/shared/dist/installrow.d.ts +6 -1
- package/node_modules/@waterplane/shared/dist/installrow.js +20 -1
- package/node_modules/@waterplane/shared/dist/installrow.js.map +1 -1
- package/node_modules/@waterplane/shared/dist/lanroute.d.ts +5 -0
- package/node_modules/@waterplane/shared/dist/lanroute.js.map +1 -1
- package/node_modules/@waterplane/shared/dist/pairing.d.ts +96 -2
- package/node_modules/@waterplane/shared/dist/pairing.js +71 -3
- package/node_modules/@waterplane/shared/dist/pairing.js.map +1 -1
- package/node_modules/@waterplane/shared/dist/pake.d.ts +131 -0
- package/node_modules/@waterplane/shared/dist/pake.js +272 -0
- package/node_modules/@waterplane/shared/dist/pake.js.map +1 -0
- package/node_modules/@waterplane/shared/dist/peer.d.ts +63 -0
- package/node_modules/@waterplane/shared/dist/peer.js +41 -0
- package/node_modules/@waterplane/shared/dist/peer.js.map +1 -1
- package/node_modules/@waterplane/shared/dist/plugin/api.d.ts +15 -0
- package/node_modules/@waterplane/shared/dist/plugin/version.d.ts +5 -1
- package/node_modules/@waterplane/shared/dist/plugin/version.js +5 -1
- package/node_modules/@waterplane/shared/dist/plugin/version.js.map +1 -1
- package/node_modules/@waterplane/shared/dist/repotwins.d.ts +47 -0
- package/node_modules/@waterplane/shared/dist/repotwins.js +80 -0
- package/node_modules/@waterplane/shared/dist/repotwins.js.map +1 -1
- package/node_modules/@waterplane/shared/dist/sessioncost/costwords.d.ts +7 -2
- package/node_modules/@waterplane/shared/dist/sessioncost/costwords.js +6 -3
- package/node_modules/@waterplane/shared/dist/sessioncost/costwords.js.map +1 -1
- package/node_modules/@waterplane/shared/dist/waiting.d.ts +64 -0
- package/node_modules/@waterplane/shared/dist/waiting.js +86 -0
- package/node_modules/@waterplane/shared/dist/waiting.js.map +1 -0
- package/node_modules/@waterplane/shared/dist/wire/messages/hostcore.d.ts +3 -0
- package/node_modules/@waterplane/shared/dist/wire/notify.d.ts +2 -0
- package/node_modules/@waterplane/shared/dist/wire/notify.js +2 -0
- package/node_modules/@waterplane/shared/dist/wire/notify.js.map +1 -1
- package/node_modules/@waterplane/shared/dist/wire/repo.d.ts +30 -1
- package/node_modules/@waterplane/shared/dist/wire/repo.js.map +1 -1
- package/node_modules/@waterplane/shared/dist/wire/session/context.d.ts +4 -0
- package/node_modules/@waterplane/shared/dist/wire/session/context.js.map +1 -1
- package/node_modules/@waterplane/shared/dist/wire/sysstat.d.ts +7 -0
- package/node_modules/@waterplane/shared/dist/wire/telemetry.d.ts +3 -0
- package/node_modules/@waterplane/shared/package.json +3 -0
- package/npm-shrinkwrap.json +30 -2
- package/package.json +2 -1
- package/packages/extensions/desktop/helper/Capture.swift +245 -0
- package/packages/extensions/desktop/helper/H264.swift +226 -0
- package/packages/extensions/desktop/helper/Info.plist +20 -0
- package/packages/extensions/desktop/helper/Input.swift +195 -0
- package/packages/extensions/desktop/helper/Jpeg.swift +81 -0
- package/packages/extensions/desktop/helper/PROTOCOL.md +366 -0
- package/packages/extensions/desktop/helper/Params.swift +133 -0
- package/packages/extensions/desktop/helper/Probe.swift +119 -0
- package/packages/extensions/desktop/helper/Protocol.swift +224 -0
- package/packages/extensions/desktop/helper/Request.swift +51 -0
- package/packages/extensions/desktop/helper/Responsibility.swift +133 -0
- package/packages/extensions/desktop/helper/Server.swift +334 -0
- package/packages/extensions/desktop/helper/Synthetic.swift +281 -0
- package/packages/extensions/desktop/helper/build.sh +53 -0
- package/packages/extensions/desktop/helper/main.swift +79 -0
- package/packages/extensions/desktop/helper/selftest-driver +626 -0
- package/packages/extensions/desktop/helper/selftest.sh +16 -0
- package/packages/extensions/desktop/helper-win/Capture.cs +299 -0
- package/packages/extensions/desktop/helper-win/H264.cs +340 -0
- package/packages/extensions/desktop/helper-win/Input.cs +257 -0
- package/packages/extensions/desktop/helper-win/Jpeg.cs +75 -0
- package/packages/extensions/desktop/helper-win/Main.cs +118 -0
- package/packages/extensions/desktop/helper-win/Native.cs +276 -0
- package/packages/extensions/desktop/helper-win/Params.cs +138 -0
- package/packages/extensions/desktop/helper-win/Protocol.cs +206 -0
- package/packages/extensions/desktop/helper-win/Server.cs +304 -0
- package/packages/extensions/desktop/helper-win/Synthetic.cs +88 -0
- package/packages/extensions/desktop/helper-win/build.sh +57 -0
- package/packages/extensions/desktop/helper-win/selftest.sh +36 -0
- package/packages/extensions/desktop/waterplane-plugin.json +24 -0
- package/packages/host/dist/agentlogs.d.ts +18 -0
- package/packages/host/dist/agentlogs.js +58 -0
- package/packages/host/dist/agentlogs.js.map +1 -0
- package/packages/host/dist/agents.d.ts +5 -0
- package/packages/host/dist/agents.js +3 -2
- package/packages/host/dist/agents.js.map +1 -1
- package/packages/host/dist/alwayson.js +1 -1
- package/packages/host/dist/alwayson.js.map +1 -1
- package/packages/host/dist/answergate.js +3 -2
- package/packages/host/dist/answergate.js.map +1 -1
- package/packages/host/dist/attentionstore.d.ts +4 -0
- package/packages/host/dist/attentionstore.js +17 -0
- package/packages/host/dist/attentionstore.js.map +1 -1
- package/packages/host/dist/auth.d.ts +28 -0
- package/packages/host/dist/auth.js +65 -2
- package/packages/host/dist/auth.js.map +1 -1
- package/packages/host/dist/cli/pair.d.ts +4 -0
- package/packages/host/dist/cli/pair.js +18 -5
- package/packages/host/dist/cli/pair.js.map +1 -1
- package/packages/host/dist/composer.d.ts +6 -0
- package/packages/host/dist/composer.js +7 -1
- package/packages/host/dist/composer.js.map +1 -1
- package/packages/host/dist/ctxlog/ctxagents.d.ts +0 -29
- package/packages/host/dist/ctxlog/ctxagents.js +41 -8
- package/packages/host/dist/ctxlog/ctxagents.js.map +1 -1
- package/packages/host/dist/der.d.ts +19 -0
- package/packages/host/dist/der.js +51 -0
- package/packages/host/dist/der.js.map +1 -0
- package/packages/host/dist/doctor.d.ts +2 -0
- package/packages/host/dist/doctor.js +10 -2
- package/packages/host/dist/doctor.js.map +1 -1
- package/packages/host/dist/extensions/desktop/host/auto.d.mts +71 -0
- package/packages/host/dist/extensions/desktop/host/auto.mjs +114 -0
- package/packages/host/dist/extensions/desktop/host/auto.mjs.map +1 -0
- package/packages/host/dist/extensions/desktop/host/desktop.d.mts +51 -0
- package/packages/host/dist/extensions/desktop/host/desktop.mjs +113 -0
- package/packages/host/dist/extensions/desktop/host/desktop.mjs.map +1 -0
- package/packages/host/dist/extensions/desktop/host/helperbin.d.mts +71 -0
- package/packages/host/dist/extensions/desktop/host/helperbin.mjs +160 -0
- package/packages/host/dist/extensions/desktop/host/helperbin.mjs.map +1 -0
- package/packages/host/dist/extensions/desktop/host/index.d.mts +4 -0
- package/packages/host/dist/extensions/desktop/host/index.mjs +88 -0
- package/packages/host/dist/extensions/desktop/host/index.mjs.map +1 -0
- package/packages/host/dist/extensions/desktop/host/knobs.d.mts +52 -0
- package/packages/host/dist/extensions/desktop/host/knobs.mjs +81 -0
- package/packages/host/dist/extensions/desktop/host/knobs.mjs.map +1 -0
- package/packages/host/dist/extensions/desktop/host/probe.d.mts +57 -0
- package/packages/host/dist/extensions/desktop/host/probe.mjs +119 -0
- package/packages/host/dist/extensions/desktop/host/probe.mjs.map +1 -0
- package/packages/host/dist/extensions/desktop/host/records.d.mts +40 -0
- package/packages/host/dist/extensions/desktop/host/records.mjs +101 -0
- package/packages/host/dist/extensions/desktop/host/records.mjs.map +1 -0
- package/packages/host/dist/extensions/desktop/host/session.d.mts +86 -0
- package/packages/host/dist/extensions/desktop/host/session.mjs +318 -0
- package/packages/host/dist/extensions/desktop/host/session.mjs.map +1 -0
- package/packages/host/dist/extensions/desktop/host/setup.d.mts +28 -0
- package/packages/host/dist/extensions/desktop/host/setup.mjs +217 -0
- package/packages/host/dist/extensions/desktop/host/setup.mjs.map +1 -0
- package/packages/host/dist/extensions/desktop/host/status.d.mts +27 -0
- package/packages/host/dist/extensions/desktop/host/status.mjs +62 -0
- package/packages/host/dist/extensions/desktop/host/status.mjs.map +1 -0
- package/packages/host/dist/extensions/desktop/shared/wire.d.mts +206 -0
- package/packages/host/dist/extensions/desktop/shared/wire.mjs +174 -0
- package/packages/host/dist/extensions/desktop/shared/wire.mjs.map +1 -0
- package/packages/host/dist/git.d.ts +23 -6
- package/packages/host/dist/git.js +54 -26
- package/packages/host/dist/git.js.map +1 -1
- package/packages/host/dist/gitfetchfail.d.ts +24 -0
- package/packages/host/dist/gitfetchfail.js +62 -0
- package/packages/host/dist/gitfetchfail.js.map +1 -0
- package/packages/host/dist/gitlog.d.ts +4 -0
- package/packages/host/dist/gitlog.js +8 -3
- package/packages/host/dist/gitlog.js.map +1 -1
- package/packages/host/dist/gitnotify.d.ts +7 -1
- package/packages/host/dist/gitnotify.js +61 -1
- package/packages/host/dist/gitnotify.js.map +1 -1
- package/packages/host/dist/healthwatch.js +1 -1
- package/packages/host/dist/healthwatch.js.map +1 -1
- package/packages/host/dist/host.js +5 -1
- package/packages/host/dist/host.js.map +1 -1
- package/packages/host/dist/hoststore.js +4 -4
- package/packages/host/dist/hoststore.js.map +1 -1
- package/packages/host/dist/lantls.d.ts +145 -0
- package/packages/host/dist/lantls.js +337 -0
- package/packages/host/dist/lantls.js.map +1 -0
- package/packages/host/dist/main.js +10 -2
- package/packages/host/dist/main.js.map +1 -1
- package/packages/host/dist/net.d.ts +7 -0
- package/packages/host/dist/net.js +9 -0
- package/packages/host/dist/net.js.map +1 -1
- package/packages/host/dist/origin.d.ts +4 -0
- package/packages/host/dist/origin.js +6 -0
- package/packages/host/dist/origin.js.map +1 -1
- package/packages/host/dist/pair.d.ts +54 -5
- package/packages/host/dist/pair.js +139 -34
- package/packages/host/dist/pair.js.map +1 -1
- package/packages/host/dist/pairpake.d.ts +58 -0
- package/packages/host/dist/pairpake.js +208 -0
- package/packages/host/dist/pairpake.js.map +1 -0
- package/packages/host/dist/passive.d.ts +2 -0
- package/packages/host/dist/passive.js +4 -0
- package/packages/host/dist/passive.js.map +1 -1
- package/packages/host/dist/peers/client.d.ts +3 -1
- package/packages/host/dist/peers/client.js +4 -0
- package/packages/host/dist/peers/client.js.map +1 -1
- package/packages/host/dist/peers/routes.d.ts +6 -1
- package/packages/host/dist/peers/routes.js +39 -0
- package/packages/host/dist/peers/routes.js.map +1 -1
- package/packages/host/dist/permmode.js +3 -3
- package/packages/host/dist/plugins/pluginapi.js +13 -0
- package/packages/host/dist/plugins/pluginapi.js.map +1 -1
- package/packages/host/dist/plugins/pluginregistry.d.ts +6 -0
- package/packages/host/dist/plugins/pluginregistry.js.map +1 -1
- package/packages/host/dist/prepushcheck.d.ts +34 -0
- package/packages/host/dist/prepushcheck.js +157 -0
- package/packages/host/dist/prepushcheck.js.map +1 -0
- package/packages/host/dist/prs.js +2 -1
- package/packages/host/dist/prs.js.map +1 -1
- package/packages/host/dist/reponames.d.ts +26 -0
- package/packages/host/dist/reponames.js +119 -0
- package/packages/host/dist/reponames.js.map +1 -0
- package/packages/host/dist/repos.js +13 -0
- package/packages/host/dist/repos.js.map +1 -1
- package/packages/host/dist/routes/apigitcheck.d.ts +2 -0
- package/packages/host/dist/routes/apigitcheck.js +29 -0
- package/packages/host/dist/routes/apigitcheck.js.map +1 -0
- package/packages/host/dist/routes/apihost.js +28 -1
- package/packages/host/dist/routes/apihost.js.map +1 -1
- package/packages/host/dist/routes/apiio.js +2 -1
- package/packages/host/dist/routes/apiio.js.map +1 -1
- package/packages/host/dist/routes/apirepofiles.js +2 -1
- package/packages/host/dist/routes/apirepofiles.js.map +1 -1
- package/packages/host/dist/routes/apisystem.js +21 -3
- package/packages/host/dist/routes/apisystem.js.map +1 -1
- package/packages/host/dist/server/http.js +31 -5
- package/packages/host/dist/server/http.js.map +1 -1
- package/packages/host/dist/server/intentEvents.js +3 -2
- package/packages/host/dist/server/intentEvents.js.map +1 -1
- package/packages/host/dist/server/plugins.d.ts +13 -0
- package/packages/host/dist/server/plugins.js +26 -0
- package/packages/host/dist/server/plugins.js.map +1 -1
- package/packages/host/dist/server/sessionEvents.js +10 -9
- package/packages/host/dist/server/sessionEvents.js.map +1 -1
- package/packages/host/dist/server/wsops.js +3 -1
- package/packages/host/dist/server/wsops.js.map +1 -1
- package/packages/host/dist/sessionSearch.js +3 -1
- package/packages/host/dist/sessionSearch.js.map +1 -1
- package/packages/host/dist/sessions/apiretry.d.ts +14 -0
- package/packages/host/dist/sessions/apiretry.js +28 -2
- package/packages/host/dist/sessions/apiretry.js.map +1 -1
- package/packages/host/dist/sessions/core.d.ts +5 -0
- package/packages/host/dist/sysstat.js +10 -1
- package/packages/host/dist/sysstat.js.map +1 -1
- package/packages/host/dist/tokenscan.d.ts +4 -2
- package/packages/host/dist/tokenscan.js +5 -3
- package/packages/host/dist/tokenscan.js.map +1 -1
- package/packages/host/dist/transcript/transcriptparse.js +5 -2
- package/packages/host/dist/transcript/transcriptparse.js.map +1 -1
- package/packages/host/dist/turnagents.d.ts +8 -1
- package/packages/host/dist/turnagents.js +49 -16
- package/packages/host/dist/turnagents.js.map +1 -1
- package/packages/host/dist/updatekey.d.ts +22 -0
- package/packages/host/dist/updatekey.js +140 -0
- package/packages/host/dist/updatekey.js.map +1 -0
- package/packages/host/dist/webbundle.d.ts +15 -6
- package/packages/host/dist/webbundle.js +44 -7
- package/packages/host/dist/webbundle.js.map +1 -1
- package/packages/host/dist/wincpu.d.ts +13 -0
- package/packages/host/dist/wincpu.js +101 -0
- package/packages/host/dist/wincpu.js.map +1 -0
- package/packages/host/package.json +2 -1
- package/packages/web/dist/assets/demo-iVbE6mpm.js +26 -0
- package/packages/web/dist/assets/icons-D8RlmSLR.js +1 -0
- package/packages/web/dist/assets/index-DiZysQBa.css +1 -0
- package/packages/web/dist/assets/index-NA--kuEl.js +170 -0
- package/packages/web/dist/assets/noble-DV-r0OkE.js +1 -0
- package/packages/web/dist/assets/{react-CoSyT_xY.js → react-CjmvHigY.js} +2 -2
- package/packages/web/dist/assets/rolldown-runtime-4tVxZq34.js +1 -0
- package/packages/web/dist/assets/shared-CsHyApn-.js +51 -0
- package/packages/web/dist/assets/sheets-CDv71Sfe.js +256 -0
- package/packages/web/dist/assets/xterm-BHsKrDzh.js +16 -0
- package/packages/web/dist/build-id.txt +1 -1
- package/packages/web/dist/buildstamp.json +1 -1
- package/packages/web/dist/index.html +9 -9
- package/packages/web/dist/sw.js +2 -2
- package/release.json +7 -3
- package/packages/web/dist/assets/demo-DkCtg-SJ.js +0 -26
- package/packages/web/dist/assets/icons-B48F-4nA.js +0 -1
- package/packages/web/dist/assets/index-BYRdfW3T.js +0 -170
- package/packages/web/dist/assets/index-DcZrEQNl.css +0 -1
- package/packages/web/dist/assets/rolldown-runtime-BpQH8Ho1.js +0 -1
- package/packages/web/dist/assets/shared-DjRRLGfw.js +0 -50
- package/packages/web/dist/assets/sheets-BGN7Cs73.js +0 -248
- package/packages/web/dist/assets/xterm-C8zLTNGk.js +0 -16
|
@@ -0,0 +1,366 @@
|
|
|
1
|
+
# wp-desktop process protocol
|
|
2
|
+
|
|
3
|
+
This is the contract between `wp-desktop` (this directory, Swift) and the desktop extension's host half,
|
|
4
|
+
which spawns it. Change both sides together. Plan: `docs/analysis/desktop-stream-plan.md` §1.
|
|
5
|
+
|
|
6
|
+
## Build and placement
|
|
7
|
+
|
|
8
|
+
- `build.sh OUT` compiles every `*.swift` here with `swiftc -O -target arm64-apple-macos14`, embeds
|
|
9
|
+
`Info.plist` (a template, filled into a scratch file), and ad-hoc signs. It writes `OUT.srchash`
|
|
10
|
+
next to the binary.
|
|
11
|
+
- The signing identifier and display name are not spelled in these files: the host (`helperbin.mts`)
|
|
12
|
+
derives them from the brand table and passes `WP_DESKTOP_ID` (the app id's prefix plus `.wp-desktop`)
|
|
13
|
+
and `WP_DESKTOP_NAME` in the environment, for `--hash` and for the build alike. They are part of
|
|
14
|
+
the hash. The identifier is the same on every build: macOS keys the grant on it. Without them
|
|
15
|
+
(a hand build, `selftest.sh`) the identifier is `local.wp-desktop`.
|
|
16
|
+
- `build.sh --hash` prints the source hash. **Rebuild only when it changes.** A TCC grant is bound to the
|
|
17
|
+
code signature, and an ad-hoc signature is the cdhash, so every rebuild means the owner grants Screen
|
|
18
|
+
Recording and Accessibility again.
|
|
19
|
+
- Install at a stable path: `<state dir>/bin/wp-desktop`. TCC lists a non-bundled binary by its path.
|
|
20
|
+
- If there is no `swiftc` or SDK, `build.sh` exits 3 with the sentence `xcode-select --install`.
|
|
21
|
+
- `selftest.sh` builds into `$TMPDIR`, then exercises the synthetic source and dry-run input. It never
|
|
22
|
+
touches the screen and never posts an event.
|
|
23
|
+
|
|
24
|
+
## Commands (argv)
|
|
25
|
+
|
|
26
|
+
| argv | does |
|
|
27
|
+
|---|---|
|
|
28
|
+
| `wp-desktop serve [--source synthetic] [--dry-input]` | the stream (below) |
|
|
29
|
+
| `wp-desktop probe` | one JSON line on stdout, then exit |
|
|
30
|
+
| `wp-desktop windows` | one JSON line: `{windows:[{id,pid,owner,name?,bounds:{x,y,w,h}}]}`. Metadata only; titles appear only where the system hands them out. Never prompts. Not part of `probe`. |
|
|
31
|
+
| `wp-desktop whoami` | `{pid, ppid, responsiblePid, responsibleSelf, disclaimed, path}` (`disclaimed` is `""`, `"exec"` or `"child"`). Diagnoses TCC attribution. |
|
|
32
|
+
| `wp-desktop request [screen\|accessibility]` | **ASKS macOS for the grant** (below), then one JSON line. Both kinds when none is named. The only command that can raise a dialog. |
|
|
33
|
+
| `wp-desktop version` | `0.1.0` |
|
|
34
|
+
|
|
35
|
+
Test-only flags:
|
|
36
|
+
- `--dry-request` (with `request`): read the current grant, call neither request API.
|
|
37
|
+
- `--no-capture`: behave as if Screen Recording were not granted.
|
|
38
|
+
- `--no-disclaim`: skip the re-exec described next.
|
|
39
|
+
- `--force-disclaim` and `--force-disclaim-child`: take the re-exec or child path even when already responsible for itself.
|
|
40
|
+
|
|
41
|
+
### Responsibility (who TCC asks about)
|
|
42
|
+
|
|
43
|
+
`serve`, `probe` and `whoami` first check `responsibility_get_pid_responsible_for_pid(getpid())`.
|
|
44
|
+
If the helper is not responsible for itself, it **re-execs itself in place**: `posix_spawn` with
|
|
45
|
+
`POSIX_SPAWN_SETEXEC` plus `responsibility_spawnattrs_setdisclaim(attr, 1)`, and
|
|
46
|
+
`WP_DESKTOP_DISCLAIMED=exec` in the environment. The pid, stdin, stdout and stderr are unchanged, so
|
|
47
|
+
the host notices nothing. Afterwards TCC judges `wp-desktop` itself, not node.
|
|
48
|
+
|
|
49
|
+
If the re-exec'd image is *still* not responsible for itself, the helper falls back to a disclaimed
|
|
50
|
+
**child** (`WP_DESKTOP_DISCLAIMED=child`):
|
|
51
|
+
- The child shares the same stdio fds.
|
|
52
|
+
- The pid the host holds becomes a thin parent. It forwards SIGTERM, SIGINT and SIGHUP, and exits with
|
|
53
|
+
the child's status.
|
|
54
|
+
- The child exits when stdin hits EOF or when the parent goes away.
|
|
55
|
+
- Killing the pid the host spawned still ends everything. The selftest checks this.
|
|
56
|
+
|
|
57
|
+
- Set `WP_DESKTOP_NO_DISCLAIM=1` to opt out.
|
|
58
|
+
- `probe`, `whoami` and every status record carry `responsibleSelf`. If it is false, the grant would be
|
|
59
|
+
judged for the parent (node), and the setup row should say so.
|
|
60
|
+
- Spawn it plainly from node. No shim is needed.
|
|
61
|
+
|
|
62
|
+
## `probe`
|
|
63
|
+
|
|
64
|
+
```json
|
|
65
|
+
{"screenRecording":false,"accessibility":false,"os":"macOS 26.6.2","helperVersion":"0.1.0",
|
|
66
|
+
"pid":123,"responsiblePid":123,"responsibleSelf":true,
|
|
67
|
+
"displays":[{"id":4,"width":3840,"height":2160,"scale":2,"main":true,
|
|
68
|
+
"bounds":{"x":0,"y":0,"w":1920,"h":1080}}]}
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
- `width` and `height` are pixels.
|
|
72
|
+
- `bounds` is global points, top-left origin (CGEvent space).
|
|
73
|
+
- `screenRecording` comes from `CGPreflightScreenCaptureAccess()`.
|
|
74
|
+
- `accessibility` comes from `AXIsProcessTrustedWithOptions(prompt: false)`.
|
|
75
|
+
- **Neither ever prompts.** Neither does anything else in `probe`.
|
|
76
|
+
|
|
77
|
+
## `request`
|
|
78
|
+
|
|
79
|
+
```
|
|
80
|
+
wp-desktop request screen
|
|
81
|
+
{"accessibility":…,"dry":false,"responsibleSelf":true,"screen":{"already":false,"asked":true,"granted":false}}
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
- Runs through the same responsibility-disclaim path as `serve` and `probe`, so the dialog, and the row
|
|
85
|
+
it adds to System Settings, name `wp-desktop`, not node.
|
|
86
|
+
- `screen` calls `CGRequestScreenCaptureAccess()`; `accessibility` calls
|
|
87
|
+
`AXIsProcessTrustedWithOptions(prompt: true)`. With neither named, both, in that order.
|
|
88
|
+
- It **returns at once**. Both calls only start the dialog, which the system shows on the desktop's
|
|
89
|
+
screen and the person answers there. `granted` is therefore usually `false` right after `asked:true`:
|
|
90
|
+
the answer is read later with `probe`.
|
|
91
|
+
- Per kind: `already` is the grant before the call; `asked` is false when it was already granted (no
|
|
92
|
+
request API was called), and in a `--dry-request`.
|
|
93
|
+
- macOS shows the Screen Recording dialog once per binary (per signature), so a repeat may show nothing.
|
|
94
|
+
The host remembers that it asked for this build and opens the System Settings pane instead on the
|
|
95
|
+
next tap. Accessibility prompts on every call.
|
|
96
|
+
- An unknown name exits 2 with one stderr line.
|
|
97
|
+
- The host runs it only from a person's tap on a setup row (`setup.mts`), never on its own, never from
|
|
98
|
+
a peer or a job. `selftest.sh` only ever runs `--dry-request`.
|
|
99
|
+
|
|
100
|
+
## `serve`
|
|
101
|
+
|
|
102
|
+
### stdin: newline-delimited JSON
|
|
103
|
+
|
|
104
|
+
| line | effect |
|
|
105
|
+
|---|---|
|
|
106
|
+
| `{"t":"config", …params}` | The first one starts capture; later ones re-steer it. See "Params" below. Emits a status record. |
|
|
107
|
+
| `{"t":"keyframe"}` | Requests an IDR. The next frame is a key. On a still screen, the last frame is re-encoded as a key at once (≈3 ms), because no new frame would come. |
|
|
108
|
+
| `{"t":"bitrate","kbps":n}` | Cheap live change (clamped 50–20000). There is no new session and no keyframe. Emits a status record. |
|
|
109
|
+
| `{"t":"input","ev":{…}}` | Injects one event. See "Input" below. |
|
|
110
|
+
| `{"t":"stop"}` | Stops capture and encoder, emits `state:"stopped"`, and stays alive. A later `config` starts again. |
|
|
111
|
+
| `{"t":"probe"}` | Emits the probe object as a JSON record (`t:"probe"`). |
|
|
112
|
+
|
|
113
|
+
Invalid lines are ignored with **one stderr line each, never a crash**: bad JSON, unknown `t`, bad fields,
|
|
114
|
+
or lines over 1 MB.
|
|
115
|
+
|
|
116
|
+
**EOF on stdin means a clean exit within 1 s.** The selftest measures 3–9 ms. The host dying closes the
|
|
117
|
+
pipe, so a capture never outlives the host. Two backstops:
|
|
118
|
+
- being re-parented (`getppid()` changed) means exit within 1 s;
|
|
119
|
+
- a failed stdout write (EPIPE) means exit.
|
|
120
|
+
|
|
121
|
+
While a **source is running** (the capture or the synthetic pattern started, state `starting` or
|
|
122
|
+
`running`), `serve` holds a `latencyCritical | userInitiated | idleSystemSleepDisabled` activity:
|
|
123
|
+
- Without it, App Nap coalesces timers. A 66 ms frame timer was measured firing every ~180 ms.
|
|
124
|
+
- The Mac does not idle-sleep while a stream is up. The display may still sleep.
|
|
125
|
+
- It is **not** held before the first `config`, in state `error` (Screen Recording not granted: the
|
|
126
|
+
helper stays alive so a new `config` can retry) or after `stop`: a phone left on that sentence must not
|
|
127
|
+
keep the Mac awake. It is begun when a source starts, ended in the same teardown that ends the source,
|
|
128
|
+
and logged (`activity: held` / `activity: released` on stderr). `pmset -g assertions` (read-only) lists it
|
|
129
|
+
as `PreventUserIdleSystemSleep named: "live desktop stream"` against the helper's pid; the selftest
|
|
130
|
+
checks that.
|
|
131
|
+
|
|
132
|
+
### stdout: binary records
|
|
133
|
+
|
|
134
|
+
```
|
|
135
|
+
u32le length of the rest (= 10 + payload length)
|
|
136
|
+
u8 kind 1 = H.264 (Annex-B) 2 = JPEG 4 = JSON (status / stats / probe)
|
|
137
|
+
u8 flags bit 0 = key
|
|
138
|
+
u64le ptsMicros
|
|
139
|
+
payload
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
`ptsMicros` is `CLOCK_UPTIME_RAW` in µs at capture.
|
|
143
|
+
- That is the same clock as `mach_absolute_time`, as SCK's sample times, and as node's
|
|
144
|
+
`process.hrtime.bigint()/1000n` on macOS.
|
|
145
|
+
- So the host can measure capture→send latency directly.
|
|
146
|
+
- It increases strictly within one process.
|
|
147
|
+
|
|
148
|
+
**H.264 (kind 1)**
|
|
149
|
+
- One access unit per record, in Annex-B form (4-byte start codes).
|
|
150
|
+
- A record with the key flag begins with SPS and PPS, so it is a complete entry point. It is an IDR.
|
|
151
|
+
- There are no B-frames. Decode order equals presentation order.
|
|
152
|
+
- Profile: Main, AutoLevel (Baseline if Main is refused). The status record's `avc` names the codec
|
|
153
|
+
string for WebCodecs, e.g. `avc1.4d0020`. The client can also read it from SPS bytes 1–3.
|
|
154
|
+
- Droppable: every non-key record. After the host drops one, it must send `{"t":"keyframe"}` and drop
|
|
155
|
+
until a key arrives.
|
|
156
|
+
- **The size changes only with a key.** A crop or `maxWidth` change, or a moved window, creates a new
|
|
157
|
+
encoder session. Its first record is a key with the new SPS. The status record names the new size.
|
|
158
|
+
- **A new region starts on a key of that region.** A `config` that changes `crop` or `source` (even at
|
|
159
|
+
the same output size) forgets the last frame and keys the next captured one, so a `keyframe` asked
|
|
160
|
+
right after it can never re-encode the previous region.
|
|
161
|
+
- **Which key that is, the stream says (see "Regions").** The phone does not guess it as "the next key
|
|
162
|
+
after I asked for a crop": a key asked for by the core itself, or a `keyframeEverySec` key already in
|
|
163
|
+
the encoder when the config landed, can come first and still be of the old region.
|
|
164
|
+
|
|
165
|
+
**JPEG (kind 2):** One baseline JFIF per record, always flagged key, always droppable.
|
|
166
|
+
|
|
167
|
+
**JSON (kind 4).** Dispatch on `t`.
|
|
168
|
+
|
|
169
|
+
`t:"status"`: one on start (once the source size is known), one on every config or bitrate change, one
|
|
170
|
+
on any error, and one when the H.264 codec string first becomes known.
|
|
171
|
+
|
|
172
|
+
```json
|
|
173
|
+
{"t":"status","state":"running","width":1280,"height":800,"srcWidth":2560,"srcHeight":1600,
|
|
174
|
+
"fps":15,"codec":"h264","bitrateKbps":800,"source":"display","avc":"avc1.4d0020",
|
|
175
|
+
"screenRecording":true,"accessibility":false,"responsibleSelf":true,"helperVersion":"0.1.0",
|
|
176
|
+
"crop":{"x":0,"y":0,"w":1,"h":1},"regionSeq":1,
|
|
177
|
+
"reason":"…only when there is something to say…"}
|
|
178
|
+
```
|
|
179
|
+
|
|
180
|
+
- `state` is one of:
|
|
181
|
+
- `"starting"`: a screen source is resolving.
|
|
182
|
+
- `"running"`
|
|
183
|
+
- `"error"`: capture is torn down. The helper stays alive, so a new `config` retries.
|
|
184
|
+
- `"stopped"`
|
|
185
|
+
- `width` and `height` are the encoded size.
|
|
186
|
+
- `srcWidth` and `srcHeight` are the full source in pixels, not the crop.
|
|
187
|
+
- `reason` is a sentence for a person, never a code. Examples:
|
|
188
|
+
- Screen Recording not granted: *"Screen Recording is not granted to wp-desktop. On the Mac, open
|
|
189
|
+
System Settings → Privacy & Security → Screen Recording, turn on wp-desktop, then open the desktop
|
|
190
|
+
view again."* This comes with `state:"error"`, and no capture API is called.
|
|
191
|
+
- Accessibility missing when an input arrives: a `running` status with a sentence naming
|
|
192
|
+
System Settings → Privacy & Security → Accessibility. Events are dropped. The sentence repeats at
|
|
193
|
+
most once per 10 s.
|
|
194
|
+
- A display not active, a window closed, the encoder refusing a size.
|
|
195
|
+
|
|
196
|
+
#### Regions
|
|
197
|
+
|
|
198
|
+
A **region** is what a run of frames shows: the crop (and the source) they were encoded for. Every
|
|
199
|
+
`config` that changes the crop or the source, and every source restart, starts a new one, numbered by
|
|
200
|
+
`regionSeq` (1, 2, 3, ...; it only grows within one `serve`).
|
|
201
|
+
|
|
202
|
+
- Each frame carries the region that was current when it was **submitted** to the encoder, so
|
|
203
|
+
VideoToolbox's late output still says which config made it.
|
|
204
|
+
- The writer of stdout (one lock) holds the region the OUTPUT is in. **The first frame of a new region
|
|
205
|
+
is preceded, in the same locked write, by a `status` record naming it.** Every `status` carries
|
|
206
|
+
the output's region as `crop` (normalised 0..1 of the full source; the whole source is
|
|
207
|
+
`{"x":0,"y":0,"w":1,"h":1}`) and `regionSeq`. A status written for another reason (a bitrate change)
|
|
208
|
+
therefore names the region of the frames that came before it, never a config the encoder has not
|
|
209
|
+
reached yet. Before the first frame the fields are absent.
|
|
210
|
+
- So on this one ordered stream, a status naming region N is followed by region N's frames and by no
|
|
211
|
+
earlier region's, and a frame is of the region the newest status named. Nothing is claimed about a
|
|
212
|
+
region's frames the host skips.
|
|
213
|
+
- The host passes `crop` and `regionSeq` through to `DesktopStatus` (checked, absent when bad). The core
|
|
214
|
+
sends `stream.status` and binary frames on the one WebSocket, each synchronously at the call, so
|
|
215
|
+
they reach the phone in the order the host read them (`pluginstreams.ts`'s `send` and `status`, and the
|
|
216
|
+
session handling each record as it is read). The phone remembers the newest named region and
|
|
217
|
+
applies it at the next KEY (`web/view.ts`, `regionOnStatus` / `regionOnFrame`).
|
|
218
|
+
- Not covered: a frame ScreenCaptureKit captured under the old `sourceRect` and delivers after a live
|
|
219
|
+
crop change is submitted under the new region (the helper cannot tell). Its next key puts that
|
|
220
|
+
right. The synthetic source has no such lag.
|
|
221
|
+
- A status the core refuses while the view is still opening is retried 50 ms later; a crop change in
|
|
222
|
+
that window can be named late, and the next key puts it right.
|
|
223
|
+
|
|
224
|
+
`t:"stats"`: every 2 s while running. The same numbers go to stderr as a `stats …` line.
|
|
225
|
+
|
|
226
|
+
```json
|
|
227
|
+
{"t":"stats","fps":15.0,"kbps":668,"keys":0,"encodeMsAvg":2.8,"encodeMsMax":3.7,
|
|
228
|
+
"captured":30,"dropped":0,"periodMs":2001,"idle":false}
|
|
229
|
+
```
|
|
230
|
+
|
|
231
|
+
- `encodeMs*` measures submit → encoded output, inside the helper.
|
|
232
|
+
- `dropped` counts frames skipped because the encoder had more than 2 in flight.
|
|
233
|
+
- `idle: true` means the source reported a still screen in this period.
|
|
234
|
+
|
|
235
|
+
### Params (the `config` object)
|
|
236
|
+
|
|
237
|
+
**A config is a full params object.** A field left out takes its **default**, not its previous value.
|
|
238
|
+
The helper clamps every field.
|
|
239
|
+
|
|
240
|
+
| field | type | default | clamp / meaning |
|
|
241
|
+
|---|---|---|---|
|
|
242
|
+
| `codec` | `"h264"` \| `"jpeg"` | `"h264"` | a change restarts the source (pixel format 420v ↔ BGRA) |
|
|
243
|
+
| `source` | `{kind:"display",id?}` \| `{kind:"window",id}` \| `{kind:"front"}` \| `{kind:"synthetic",idle?}` | main display | `id` = CGDirectDisplayID / CGWindowID. `front` follows the frontmost window, polled at 1 Hz. `synthetic` is the test pattern; `idle:true` stands still 3 s of every 6 s. |
|
|
244
|
+
| `crop` | `{x,y,w,h}` normalised 0..1 of the full source | full | w,h ≥ 0.02. Becomes SCK `sourceRect`. Live. |
|
|
245
|
+
| `maxWidth` | px of encoded output | 1280 | 0 = native. Otherwise 64–8192. Never upscales; keeps aspect; even numbers. H.264 caps either side at 4096. |
|
|
246
|
+
| `fps` | 1–60 | 15 | SCK `minimumFrameInterval`. Live. |
|
|
247
|
+
| `bitrateKbps` | 50–20000 | 1000 | AverageBitRate, with DataRateLimits = 1.5× over 1 s. Live. |
|
|
248
|
+
| `jpegQuality` | 1–100 | 50 | Live. |
|
|
249
|
+
| `grayscale` | bool | false | JPEG only: one-component JPEG. Live. |
|
|
250
|
+
| `keyframeEverySec` | ≥ 0 | 0 | 0 = keyframes only on demand (and at session start). Live. |
|
|
251
|
+
| `cursor` | bool | true | SCK `showsCursor`. Live. |
|
|
252
|
+
|
|
253
|
+
What restarts what:
|
|
254
|
+
- A change of codec, source kind, display id or synthetic mode restarts the source.
|
|
255
|
+
- Another window id is re-aimed live (`updateContentFilter`).
|
|
256
|
+
- Everything else goes through `updateConfiguration` or a property set on the encoder.
|
|
257
|
+
|
|
258
|
+
`--source synthetic` on argv forces the synthetic source whatever `config` says. Tests use it.
|
|
259
|
+
|
|
260
|
+
### Input (`{"t":"input","ev":…}`)
|
|
261
|
+
|
|
262
|
+
**Coordinates.** `x` and `y` are **normalised 0..1 of the current source's full bounds**: the whole
|
|
263
|
+
display or window, **not the crop**.
|
|
264
|
+
- The helper maps them to global display points.
|
|
265
|
+
- Values within 0.05 outside 0..1 are clamped. Values further out are refused.
|
|
266
|
+
|
|
267
|
+
**Shapes:**
|
|
268
|
+
|
|
269
|
+
| ev | posts |
|
|
270
|
+
|---|---|
|
|
271
|
+
| `{k:"move",x,y}` | mouseMoved, or `*MouseDragged` while a button is held from `down` |
|
|
272
|
+
| `{k:"down",b,x,y}` / `{k:"up",b,x,y}` | `b`: `"l"`, `"r"` or `"m"` (default `"l"`). clickState 1. |
|
|
273
|
+
| `{k:"click",b,x,y,n}` | move, then `n` (1–3) down/up pairs with clickState 1…n. `n:2` is a double click. |
|
|
274
|
+
| `{k:"scroll",x,y,dx,dy}` | move, then `CGEventCreateScrollWheelEvent2`, pixel units (points). **DOM sign**: `dy > 0` scrolls the content down, as in `WheelEvent.deltaY`. The phone speaks the opposite (`dy > 0` = fingers moved down, in source pixels): the host's `session.mts` (`helperScroll`) negates it and divides by the display scale. |
|
|
275
|
+
| `{k:"key",key,mods}` | keyDown and keyUp (see below) |
|
|
276
|
+
| `{k:"text",s}` | `CGEventKeyboardSetUnicodeString` in chunks of ≤ 20 UTF-16 units, never splitting a surrogate pair. Up to 4096 units per event. |
|
|
277
|
+
|
|
278
|
+
**`key` names**, with the US layout:
|
|
279
|
+
- `Escape`, `Tab`, `Return`/`Enter`, `Backspace`, `Delete` (forward delete)
|
|
280
|
+
- `ArrowUp`/`Down`/`Left`/`Right`, `Home`, `End`, `PageUp`, `PageDown`
|
|
281
|
+
- `Space`, `F1`–`F12`
|
|
282
|
+
- any single printable US-layout character. Upper-case letters and shifted symbols imply Shift.
|
|
283
|
+
|
|
284
|
+
**`mods`** is any of `cmd`, `alt`, `ctrl`, `shift`, set as event flags. Every shape accepts it.
|
|
285
|
+
|
|
286
|
+
**Refusals.**
|
|
287
|
+
- An invalid event is ignored with one stderr line and never crashes the helper.
|
|
288
|
+
- Without Accessibility, events are dropped and a status record carries the sentence.
|
|
289
|
+
- **Secure input** (password fields, the login window) silently swallows synthetic keys. The helper
|
|
290
|
+
cannot see this. The view must not claim "typed".
|
|
291
|
+
|
|
292
|
+
**`--dry-input`** builds every CGEvent exactly as it would post it, then logs it instead of posting:
|
|
293
|
+
|
|
294
|
+
```
|
|
295
|
+
wp-desktop: dry-input: leftMouseDown x=480.0 y=270.0 flags=0x0 button=l clicks=1
|
|
296
|
+
wp-desktop: dry-input: scrollWheel x=960.0 y=540.0 flags=0x0 wheel=-120,0
|
|
297
|
+
wp-desktop: dry-input: keyDown x=… y=… flags=0x100000 key=0x08
|
|
298
|
+
```
|
|
299
|
+
|
|
300
|
+
## Measured (selftest, synthetic source, M-series Mac, macOS 26.6)
|
|
301
|
+
|
|
302
|
+
The synthetic pattern is a worst-ish case:
|
|
303
|
+
- a 2560×1600 virtual desktop
|
|
304
|
+
- an editor scrolling 3 lines/s
|
|
305
|
+
- a terminal panel whose glyphs change every frame
|
|
306
|
+
|
|
307
|
+
A still real screen costs ~0. In the `idle` phase, no records are sent.
|
|
308
|
+
|
|
309
|
+
| preset | params | fps achieved | rate before the live change | after a live `bitrate` to 40% | encode ms/frame |
|
|
310
|
+
|---|---|---|---|---|---|
|
|
311
|
+
| Data saver | h264 640×400, 8 fps, 150 kbps | 8.1 | ~81 kbps (under the cap) | 60 kbps | 1.8 |
|
|
312
|
+
| Balanced | h264 1280×800, 15 fps, 800 kbps | 15.2 | ~517 kbps (under the cap) | 326 kbps | 3.1 |
|
|
313
|
+
| Sharp text | h264 1920×1200, 30 fps, 3000 kbps | 30.2 | ~3030 kbps (at the cap) | 1384 kbps | 5.3 |
|
|
314
|
+
| Stills | jpeg 1280×800, 2 fps, q40 | 2.0 | ~2.6 Mbps (164 KB/frame) | grayscale: 152 KB/frame | 3.6 |
|
|
315
|
+
|
|
316
|
+
More measurements:
|
|
317
|
+
- A keyframe request is answered by the next frame (≤ 1 frame later).
|
|
318
|
+
- On a still screen, a keyframe request is answered in 4 ms.
|
|
319
|
+
- EOF to exit takes 4–8 ms.
|
|
320
|
+
|
|
321
|
+
Stills are expensive on dense text: JPEG has no inter-frame coding. The grayscale gain is understated
|
|
322
|
+
here because the pattern is already almost colourless. Re-run with `selftest.sh`. Its last table
|
|
323
|
+
prints these numbers.
|
|
324
|
+
|
|
325
|
+
## Windows (`../helper-win/`, `wp-desktop.exe`)
|
|
326
|
+
|
|
327
|
+
The same contract, kept by a second helper in C#. The host picks it when it runs in WSL
|
|
328
|
+
(`detectDesktopOs`), spawns it through WSL's interop, and treats it exactly as the Mac's: same argv,
|
|
329
|
+
same stdin lines, same records, same status and stats fields. What differs:
|
|
330
|
+
|
|
331
|
+
- **Build.** `helper-win/build.sh OUT` (bash, in WSL) compiles every `*.cs` with the .NET Framework's
|
|
332
|
+
`csc.exe` (C# 5, `C:\Windows\Microsoft.NET\Framework64\v4.0.30319`), in a Windows temp directory,
|
|
333
|
+
and copies the result to `OUT` (`<state dir>/bin/wp-desktop.exe`). No interop, no `csc.exe`:
|
|
334
|
+
exit 3. `--hash` works anywhere. Windows keys no permission on the binary, so a rebuild forgets
|
|
335
|
+
nothing (the host sets no `rebuiltAt`). The code is C# 5 on purpose: that is the compiler every
|
|
336
|
+
Windows 10/11 has. Check a change with Mono: `mcs -langversion:5 -unsafe -platform:x64
|
|
337
|
+
-r:System.Drawing.dll -r:System.Web.Extensions.dll *.cs`.
|
|
338
|
+
- **Grants.** None exist. `screenRecording` means "this process can see the signed-in desktop": not
|
|
339
|
+
session 0 (a scheduled task set to run whether the user is logged on or not) and the input desktop
|
|
340
|
+
opens (not the lock screen or a UAC prompt). The host turns `false` into `available:false` with a
|
|
341
|
+
sentence, not a grant button. `accessibility` is always true; `responsibleSelf` always true;
|
|
342
|
+
`request` asks nothing and answers the same shape; `whoami` adds `session`.
|
|
343
|
+
- **Capture.** GDI: `StretchBlt` (HALFTONE) from the screen DC crops and scales in one call; the
|
|
344
|
+
cursor is drawn with `DrawIconEx`. Each grab is compared with the last, so a still screen sends
|
|
345
|
+
nothing (stats say `idle`). Per-monitor DPI aware: every size and coordinate is physical pixels.
|
|
346
|
+
A window source is the screen region of the window's frame (DWM extended bounds), so whatever is
|
|
347
|
+
in front of it shows too, which is also where a click lands. Window ids are HWNDs; display ids are
|
|
348
|
+
the n of `\\.\DISPLAYn`; `scale` is the monitor's DPI over 96. A grab refused by the secure desktop
|
|
349
|
+
is a `running` status with the sentence (at most every 10 s), and capture keeps trying.
|
|
350
|
+
- **H.264.** Media Foundation's software encoder MFT (`CLSID_MSH264EncoderMFT`), synchronous:
|
|
351
|
+
BGRA → NV12 (BT.601, limited) in the helper, Main profile (Baseline if refused), low-latency mode,
|
|
352
|
+
no B-frames, peak-constrained VBR with max = 1.5× mean, mean bitrate changed live, keys forced with
|
|
353
|
+
`AVEncVideoForceKeyFrame`. Annex-B; a key without SPS gets the sequence header prepended.
|
|
354
|
+
An N edition without the Media Feature Pack has no MF: the status says to use Stills.
|
|
355
|
+
- **JPEG.** GDI+. Grayscale is three equal channels, not a one-component file.
|
|
356
|
+
- **Input.** `SendInput`, absolute over the virtual desktop. `cmd` is the Windows key. Wheel: 3 wheel
|
|
357
|
+
units per point (120 a notch ≈ 40 points), DOM sign as above. Text is `KEYEVENTF_UNICODE`. Windows
|
|
358
|
+
drops synthetic input into an elevated window (UIPI) and on the secure desktop without a word.
|
|
359
|
+
`--dry-input` logs the same `dry-input: …` lines.
|
|
360
|
+
- **Keeping awake.** `SetThreadExecutionState(ES_SYSTEM_REQUIRED)` on the worker thread only while a
|
|
361
|
+
source runs; the same `activity: held/released` log lines.
|
|
362
|
+
- **Exit.** stdin EOF (≤ 0.5 s) and a failed stdout write. There is no re-parent check: through
|
|
363
|
+
interop the Windows parent is not the host.
|
|
364
|
+
- **Clock.** `ptsMicros` is QueryPerformanceCounter in µs: increasing within the process, but not
|
|
365
|
+
the host's clock (the host does not compare them).
|
|
366
|
+
- Not built for Windows: `--force-disclaim*`, `--no-disclaim` (nothing to disclaim).
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
// Params: the one parameter object a `config` command carries, clamped here so
|
|
2
|
+
// the rest of the helper never sees an out-of-range value.
|
|
3
|
+
|
|
4
|
+
import CoreGraphics
|
|
5
|
+
import Foundation
|
|
6
|
+
|
|
7
|
+
enum Codec: String {
|
|
8
|
+
case h264
|
|
9
|
+
case jpeg
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
enum SourceSpec: Equatable {
|
|
13
|
+
case display(CGDirectDisplayID?)
|
|
14
|
+
case window(CGWindowID)
|
|
15
|
+
case front
|
|
16
|
+
/// `idle` makes the pattern stand still for half of every 6 s cycle, which is
|
|
17
|
+
/// what SCK does on a still screen: no frames at all.
|
|
18
|
+
case synthetic(idle: Bool)
|
|
19
|
+
|
|
20
|
+
var kind: String {
|
|
21
|
+
switch self {
|
|
22
|
+
case .display: return "display"
|
|
23
|
+
case .window: return "window"
|
|
24
|
+
case .front: return "front"
|
|
25
|
+
case .synthetic: return "synthetic"
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/// Two specs that need a different capture object (display ↔ window ↔ front).
|
|
30
|
+
/// A change inside one kind (another window id) can be re-aimed live.
|
|
31
|
+
func sameKind(_ o: SourceSpec) -> Bool { kind == o.kind }
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/// Normalised 0..1 of the full source.
|
|
35
|
+
struct Crop: Equatable {
|
|
36
|
+
var x = 0.0, y = 0.0, w = 1.0, h = 1.0
|
|
37
|
+
var isFull: Bool { x == 0 && y == 0 && w == 1 && h == 1 }
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
struct Params: Equatable {
|
|
41
|
+
var codec: Codec = .h264
|
|
42
|
+
var source: SourceSpec = .display(nil)
|
|
43
|
+
var crop = Crop()
|
|
44
|
+
var maxWidth = 1280
|
|
45
|
+
var fps = 15
|
|
46
|
+
var bitrateKbps = 1000
|
|
47
|
+
var jpegQuality = 50
|
|
48
|
+
var grayscale = false
|
|
49
|
+
var keyframeEverySec = 0.0
|
|
50
|
+
var cursor = true
|
|
51
|
+
|
|
52
|
+
static func clampBitrate(_ v: Int) -> Int { min(20000, max(50, v)) }
|
|
53
|
+
|
|
54
|
+
/// A config is a FULL params object: a field left out takes its default,
|
|
55
|
+
/// not its previous value. Unknown fields are ignored; wrong types fall back
|
|
56
|
+
/// to the default with one warning each.
|
|
57
|
+
static func parse(_ o: [String: Any], warn: (String) -> Void) -> Params {
|
|
58
|
+
var p = Params()
|
|
59
|
+
func num(_ k: String) -> Double? {
|
|
60
|
+
guard let v = o[k] else { return nil }
|
|
61
|
+
if let n = v as? NSNumber, CFGetTypeID(n) != CFBooleanGetTypeID() { return n.doubleValue }
|
|
62
|
+
warn("param \(k): expected a number, ignored")
|
|
63
|
+
return nil
|
|
64
|
+
}
|
|
65
|
+
func bool(_ k: String) -> Bool? {
|
|
66
|
+
guard let v = o[k] else { return nil }
|
|
67
|
+
if let n = v as? NSNumber, CFGetTypeID(n) == CFBooleanGetTypeID() { return n.boolValue }
|
|
68
|
+
warn("param \(k): expected true/false, ignored")
|
|
69
|
+
return nil
|
|
70
|
+
}
|
|
71
|
+
if let c = o["codec"] {
|
|
72
|
+
if let s = c as? String, let k = Codec(rawValue: s) { p.codec = k } else { warn("param codec: expected \"h264\" or \"jpeg\", ignored") }
|
|
73
|
+
}
|
|
74
|
+
if let s = o["source"] {
|
|
75
|
+
if let d = s as? [String: Any], let k = d["kind"] as? String {
|
|
76
|
+
let id = (d["id"] as? NSNumber)?.uint32Value
|
|
77
|
+
switch k {
|
|
78
|
+
case "display": p.source = .display(id)
|
|
79
|
+
case "window":
|
|
80
|
+
if let id { p.source = .window(id) } else { warn("param source: a window needs an id, using the main display") }
|
|
81
|
+
case "front": p.source = .front
|
|
82
|
+
case "synthetic": p.source = .synthetic(idle: (d["idle"] as? Bool) ?? false)
|
|
83
|
+
default: warn("param source: unknown kind \(k), using the main display")
|
|
84
|
+
}
|
|
85
|
+
} else { warn("param source: expected {kind:…}, using the main display") }
|
|
86
|
+
}
|
|
87
|
+
if let c = o["crop"] {
|
|
88
|
+
if let d = c as? [String: Any] {
|
|
89
|
+
func f(_ k: String, _ def: Double) -> Double { ((d[k] as? NSNumber)?.doubleValue).map { $0.isFinite ? $0 : def } ?? def }
|
|
90
|
+
var x = min(0.98, max(0, f("x", 0))), y = min(0.98, max(0, f("y", 0)))
|
|
91
|
+
var w = min(1, max(0.02, f("w", 1))), h = min(1, max(0.02, f("h", 1)))
|
|
92
|
+
if x + w > 1 { w = 1 - x }
|
|
93
|
+
if y + h > 1 { h = 1 - y }
|
|
94
|
+
if w < 0.02 { x = 0.98; w = 0.02 }
|
|
95
|
+
if h < 0.02 { y = 0.98; h = 0.02 }
|
|
96
|
+
p.crop = Crop(x: x, y: y, w: w, h: h)
|
|
97
|
+
} else { warn("param crop: expected {x,y,w,h}, ignored") }
|
|
98
|
+
}
|
|
99
|
+
if let v = num("maxWidth") { p.maxWidth = v <= 0 ? 0 : min(8192, max(64, Int(v))) }
|
|
100
|
+
if let v = num("fps") { p.fps = min(60, max(1, Int(v.rounded()))) }
|
|
101
|
+
if let v = num("bitrateKbps") { p.bitrateKbps = clampBitrate(Int(v)) }
|
|
102
|
+
if let v = num("jpegQuality") { p.jpegQuality = min(100, max(1, Int(v))) }
|
|
103
|
+
if let v = bool("grayscale") { p.grayscale = v }
|
|
104
|
+
if let v = num("keyframeEverySec") { p.keyframeEverySec = v <= 0 ? 0 : min(3600, v) }
|
|
105
|
+
if let v = bool("cursor") { p.cursor = v }
|
|
106
|
+
return p
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/// What one source looks like right now, and what we encode out of it.
|
|
111
|
+
struct Geometry: Equatable {
|
|
112
|
+
/// Full source size in pixels (display or window, NOT the crop).
|
|
113
|
+
var srcWidth: Int
|
|
114
|
+
var srcHeight: Int
|
|
115
|
+
/// Full source bounds in global display points (top-left origin, CGEvent space).
|
|
116
|
+
var globalBounds: CGRect
|
|
117
|
+
/// Encoded output size, even numbers.
|
|
118
|
+
var outWidth: Int
|
|
119
|
+
var outHeight: Int
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/// Output size for a crop of a source: keep aspect, never upscale, even numbers.
|
|
123
|
+
func outputSize(srcW: Int, srcH: Int, crop: Crop, maxWidth: Int, codec: Codec) -> (Int, Int) {
|
|
124
|
+
let cw = max(2.0, Double(srcW) * crop.w), ch = max(2.0, Double(srcH) * crop.h)
|
|
125
|
+
var w = maxWidth == 0 ? cw : min(cw, Double(maxWidth))
|
|
126
|
+
var h = w * ch / cw
|
|
127
|
+
// The H.264 hardware encoder tops out at 4096 on either side.
|
|
128
|
+
let cap = codec == .h264 ? 4096.0 : 8192.0
|
|
129
|
+
if h > cap { w = w * cap / h; h = cap }
|
|
130
|
+
if w > cap { h = h * cap / w; w = cap }
|
|
131
|
+
func even(_ v: Double) -> Int { max(16, Int(v / 2) * 2) }
|
|
132
|
+
return (even(w), even(h))
|
|
133
|
+
}
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
// Probe: what this Mac offers and what this binary is allowed, without ever
|
|
2
|
+
// asking. Nothing here can raise a permission dialog:
|
|
3
|
+
// - CGPreflightScreenCaptureAccess() only reads the Screen Recording grant.
|
|
4
|
+
// - AXIsProcessTrustedWithOptions with prompt=false only reads Accessibility.
|
|
5
|
+
// - CGGetActiveDisplayList / display modes need no grant at all.
|
|
6
|
+
// Both grants are checked for the RESPONSIBLE process (see Responsibility.swift).
|
|
7
|
+
|
|
8
|
+
import ApplicationServices
|
|
9
|
+
import CoreGraphics
|
|
10
|
+
import Foundation
|
|
11
|
+
|
|
12
|
+
/// `--no-capture` (selftest): behave as if Screen Recording were not granted,
|
|
13
|
+
/// so a test can exercise the refusal path without any chance of capturing.
|
|
14
|
+
var captureForbidden = false
|
|
15
|
+
|
|
16
|
+
func screenRecordingGranted() -> Bool {
|
|
17
|
+
if captureForbidden { return false }
|
|
18
|
+
return CGPreflightScreenCaptureAccess()
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
func accessibilityTrusted() -> Bool {
|
|
22
|
+
let key = kAXTrustedCheckOptionPrompt.takeUnretainedValue() as String
|
|
23
|
+
return AXIsProcessTrustedWithOptions([key: false] as CFDictionary)
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
let screenRecordingSentence =
|
|
27
|
+
"Screen Recording is not granted to wp-desktop. On the Mac, open System Settings → Privacy & Security → "
|
|
28
|
+
+ "Screen Recording, turn on wp-desktop, then open the desktop view again."
|
|
29
|
+
|
|
30
|
+
let accessibilitySentence =
|
|
31
|
+
"Control needs Accessibility for wp-desktop. On the Mac, open System Settings → Privacy & Security → "
|
|
32
|
+
+ "Accessibility and turn on wp-desktop; until then taps and keys are dropped."
|
|
33
|
+
|
|
34
|
+
struct DisplayInfo {
|
|
35
|
+
let id: CGDirectDisplayID
|
|
36
|
+
let pixelWidth: Int
|
|
37
|
+
let pixelHeight: Int
|
|
38
|
+
let bounds: CGRect // global points, top-left origin
|
|
39
|
+
let main: Bool
|
|
40
|
+
var scale: Double { bounds.width > 0 ? Double(pixelWidth) / Double(bounds.width) : 1 }
|
|
41
|
+
|
|
42
|
+
var json: [String: Any] {
|
|
43
|
+
[
|
|
44
|
+
"id": Int(id), "width": pixelWidth, "height": pixelHeight, "scale": scale, "main": main,
|
|
45
|
+
"bounds": ["x": bounds.minX, "y": bounds.minY, "w": bounds.width, "h": bounds.height],
|
|
46
|
+
]
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
func activeDisplays() -> [DisplayInfo] {
|
|
51
|
+
var count: UInt32 = 0
|
|
52
|
+
guard CGGetActiveDisplayList(0, nil, &count) == .success, count > 0 else { return [] }
|
|
53
|
+
var ids = [CGDirectDisplayID](repeating: 0, count: Int(count))
|
|
54
|
+
guard CGGetActiveDisplayList(count, &ids, &count) == .success else { return [] }
|
|
55
|
+
let main = CGMainDisplayID()
|
|
56
|
+
return ids.prefix(Int(count)).map { id in
|
|
57
|
+
let mode = CGDisplayCopyDisplayMode(id)
|
|
58
|
+
let pw = mode?.pixelWidth ?? CGDisplayPixelsWide(id)
|
|
59
|
+
let ph = mode?.pixelHeight ?? CGDisplayPixelsHigh(id)
|
|
60
|
+
return DisplayInfo(id: id, pixelWidth: pw, pixelHeight: ph, bounds: CGDisplayBounds(id), main: id == main)
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
func osString() -> String {
|
|
65
|
+
let v = ProcessInfo.processInfo.operatingSystemVersion
|
|
66
|
+
return "macOS \(v.majorVersion).\(v.minorVersion).\(v.patchVersion)"
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
func probeObject() -> [String: Any] {
|
|
70
|
+
let r = responsibility()
|
|
71
|
+
return [
|
|
72
|
+
"t": "probe",
|
|
73
|
+
"screenRecording": screenRecordingGranted(),
|
|
74
|
+
"accessibility": accessibilityTrusted(),
|
|
75
|
+
"displays": activeDisplays().map { $0.json },
|
|
76
|
+
"os": osString(),
|
|
77
|
+
"helperVersion": helperVersion,
|
|
78
|
+
"pid": Int(r.pid),
|
|
79
|
+
"responsiblePid": Int(r.responsible),
|
|
80
|
+
"responsibleSelf": r.pid == r.responsible,
|
|
81
|
+
]
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/// On-screen windows, metadata only (owner, bounds; the title only where the
|
|
85
|
+
/// system hands it out). CGWindowListCopyWindowInfo never prompts. Not part of
|
|
86
|
+
/// the default probe.
|
|
87
|
+
func windowList() -> [[String: Any]] {
|
|
88
|
+
guard let arr = CGWindowListCopyWindowInfo([.optionOnScreenOnly, .excludeDesktopElements], kCGNullWindowID)
|
|
89
|
+
as? [[String: Any]]
|
|
90
|
+
else { return [] }
|
|
91
|
+
return arr.compactMap { w in
|
|
92
|
+
guard let id = w[kCGWindowNumber as String] as? Int, let layer = w[kCGWindowLayer as String] as? Int, layer == 0,
|
|
93
|
+
let b = w[kCGWindowBounds as String] as? [String: Any], let rect = CGRect(dictionaryRepresentation: b as CFDictionary)
|
|
94
|
+
else { return nil }
|
|
95
|
+
var o: [String: Any] = [
|
|
96
|
+
"id": id, "pid": w[kCGWindowOwnerPID as String] as? Int ?? 0,
|
|
97
|
+
"owner": w[kCGWindowOwnerName as String] as? String ?? "",
|
|
98
|
+
"bounds": ["x": rect.minX, "y": rect.minY, "w": rect.width, "h": rect.height],
|
|
99
|
+
]
|
|
100
|
+
if let n = w[kCGWindowName as String] as? String, !n.isEmpty { o["name"] = n }
|
|
101
|
+
return o
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/// Global bounds (points) of one window, or of the frontmost normal window.
|
|
106
|
+
func windowBounds(_ id: CGWindowID?) -> (CGWindowID, CGRect)? {
|
|
107
|
+
let opts: CGWindowListOption = id == nil ? [.optionOnScreenOnly, .excludeDesktopElements] : [.optionIncludingWindow]
|
|
108
|
+
guard let arr = CGWindowListCopyWindowInfo(opts, id ?? kCGNullWindowID) as? [[String: Any]] else { return nil }
|
|
109
|
+
for w in arr {
|
|
110
|
+
guard let wid = w[kCGWindowNumber as String] as? Int, (w[kCGWindowLayer as String] as? Int) == 0,
|
|
111
|
+
let b = w[kCGWindowBounds as String] as? [String: Any],
|
|
112
|
+
let rect = CGRect(dictionaryRepresentation: b as CFDictionary), rect.width >= 40, rect.height >= 40
|
|
113
|
+
else { continue }
|
|
114
|
+
// Skip our own and the window server's helper windows when following the front.
|
|
115
|
+
if id == nil, let owner = w[kCGWindowOwnerName as String] as? String, owner == "Window Server" { continue }
|
|
116
|
+
return (CGWindowID(wid), rect)
|
|
117
|
+
}
|
|
118
|
+
return nil
|
|
119
|
+
}
|