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.
Files changed (280) hide show
  1. package/CHANGELOG.md +41 -0
  2. package/NOTICES.md +18 -7
  3. package/README.md +46 -2
  4. package/SECURITY.md +196 -12
  5. package/node_modules/@waterplane/shared/dist/desktopfiles.d.ts +24 -2
  6. package/node_modules/@waterplane/shared/dist/desktopfiles.js +73 -3
  7. package/node_modules/@waterplane/shared/dist/desktopfiles.js.map +1 -1
  8. package/node_modules/@waterplane/shared/dist/hosts.d.ts +31 -0
  9. package/node_modules/@waterplane/shared/dist/hosts.js +46 -1
  10. package/node_modules/@waterplane/shared/dist/hosts.js.map +1 -1
  11. package/node_modules/@waterplane/shared/dist/hosturl.d.ts +55 -0
  12. package/node_modules/@waterplane/shared/dist/hosturl.js +85 -0
  13. package/node_modules/@waterplane/shared/dist/hosturl.js.map +1 -1
  14. package/node_modules/@waterplane/shared/dist/index.d.ts +4 -0
  15. package/node_modules/@waterplane/shared/dist/index.js +4 -0
  16. package/node_modules/@waterplane/shared/dist/index.js.map +1 -1
  17. package/node_modules/@waterplane/shared/dist/installrow.d.ts +6 -1
  18. package/node_modules/@waterplane/shared/dist/installrow.js +20 -1
  19. package/node_modules/@waterplane/shared/dist/installrow.js.map +1 -1
  20. package/node_modules/@waterplane/shared/dist/lanroute.d.ts +5 -0
  21. package/node_modules/@waterplane/shared/dist/lanroute.js.map +1 -1
  22. package/node_modules/@waterplane/shared/dist/pairing.d.ts +96 -2
  23. package/node_modules/@waterplane/shared/dist/pairing.js +71 -3
  24. package/node_modules/@waterplane/shared/dist/pairing.js.map +1 -1
  25. package/node_modules/@waterplane/shared/dist/pake.d.ts +131 -0
  26. package/node_modules/@waterplane/shared/dist/pake.js +272 -0
  27. package/node_modules/@waterplane/shared/dist/pake.js.map +1 -0
  28. package/node_modules/@waterplane/shared/dist/peer.d.ts +63 -0
  29. package/node_modules/@waterplane/shared/dist/peer.js +41 -0
  30. package/node_modules/@waterplane/shared/dist/peer.js.map +1 -1
  31. package/node_modules/@waterplane/shared/dist/plugin/api.d.ts +15 -0
  32. package/node_modules/@waterplane/shared/dist/plugin/version.d.ts +5 -1
  33. package/node_modules/@waterplane/shared/dist/plugin/version.js +5 -1
  34. package/node_modules/@waterplane/shared/dist/plugin/version.js.map +1 -1
  35. package/node_modules/@waterplane/shared/dist/repotwins.d.ts +47 -0
  36. package/node_modules/@waterplane/shared/dist/repotwins.js +80 -0
  37. package/node_modules/@waterplane/shared/dist/repotwins.js.map +1 -1
  38. package/node_modules/@waterplane/shared/dist/sessioncost/costwords.d.ts +7 -2
  39. package/node_modules/@waterplane/shared/dist/sessioncost/costwords.js +6 -3
  40. package/node_modules/@waterplane/shared/dist/sessioncost/costwords.js.map +1 -1
  41. package/node_modules/@waterplane/shared/dist/waiting.d.ts +64 -0
  42. package/node_modules/@waterplane/shared/dist/waiting.js +86 -0
  43. package/node_modules/@waterplane/shared/dist/waiting.js.map +1 -0
  44. package/node_modules/@waterplane/shared/dist/wire/messages/hostcore.d.ts +3 -0
  45. package/node_modules/@waterplane/shared/dist/wire/notify.d.ts +2 -0
  46. package/node_modules/@waterplane/shared/dist/wire/notify.js +2 -0
  47. package/node_modules/@waterplane/shared/dist/wire/notify.js.map +1 -1
  48. package/node_modules/@waterplane/shared/dist/wire/repo.d.ts +30 -1
  49. package/node_modules/@waterplane/shared/dist/wire/repo.js.map +1 -1
  50. package/node_modules/@waterplane/shared/dist/wire/session/context.d.ts +4 -0
  51. package/node_modules/@waterplane/shared/dist/wire/session/context.js.map +1 -1
  52. package/node_modules/@waterplane/shared/dist/wire/sysstat.d.ts +7 -0
  53. package/node_modules/@waterplane/shared/dist/wire/telemetry.d.ts +3 -0
  54. package/node_modules/@waterplane/shared/package.json +3 -0
  55. package/npm-shrinkwrap.json +30 -2
  56. package/package.json +2 -1
  57. package/packages/extensions/desktop/helper/Capture.swift +245 -0
  58. package/packages/extensions/desktop/helper/H264.swift +226 -0
  59. package/packages/extensions/desktop/helper/Info.plist +20 -0
  60. package/packages/extensions/desktop/helper/Input.swift +195 -0
  61. package/packages/extensions/desktop/helper/Jpeg.swift +81 -0
  62. package/packages/extensions/desktop/helper/PROTOCOL.md +366 -0
  63. package/packages/extensions/desktop/helper/Params.swift +133 -0
  64. package/packages/extensions/desktop/helper/Probe.swift +119 -0
  65. package/packages/extensions/desktop/helper/Protocol.swift +224 -0
  66. package/packages/extensions/desktop/helper/Request.swift +51 -0
  67. package/packages/extensions/desktop/helper/Responsibility.swift +133 -0
  68. package/packages/extensions/desktop/helper/Server.swift +334 -0
  69. package/packages/extensions/desktop/helper/Synthetic.swift +281 -0
  70. package/packages/extensions/desktop/helper/build.sh +53 -0
  71. package/packages/extensions/desktop/helper/main.swift +79 -0
  72. package/packages/extensions/desktop/helper/selftest-driver +626 -0
  73. package/packages/extensions/desktop/helper/selftest.sh +16 -0
  74. package/packages/extensions/desktop/helper-win/Capture.cs +299 -0
  75. package/packages/extensions/desktop/helper-win/H264.cs +340 -0
  76. package/packages/extensions/desktop/helper-win/Input.cs +257 -0
  77. package/packages/extensions/desktop/helper-win/Jpeg.cs +75 -0
  78. package/packages/extensions/desktop/helper-win/Main.cs +118 -0
  79. package/packages/extensions/desktop/helper-win/Native.cs +276 -0
  80. package/packages/extensions/desktop/helper-win/Params.cs +138 -0
  81. package/packages/extensions/desktop/helper-win/Protocol.cs +206 -0
  82. package/packages/extensions/desktop/helper-win/Server.cs +304 -0
  83. package/packages/extensions/desktop/helper-win/Synthetic.cs +88 -0
  84. package/packages/extensions/desktop/helper-win/build.sh +57 -0
  85. package/packages/extensions/desktop/helper-win/selftest.sh +36 -0
  86. package/packages/extensions/desktop/waterplane-plugin.json +24 -0
  87. package/packages/host/dist/agentlogs.d.ts +18 -0
  88. package/packages/host/dist/agentlogs.js +58 -0
  89. package/packages/host/dist/agentlogs.js.map +1 -0
  90. package/packages/host/dist/agents.d.ts +5 -0
  91. package/packages/host/dist/agents.js +3 -2
  92. package/packages/host/dist/agents.js.map +1 -1
  93. package/packages/host/dist/alwayson.js +1 -1
  94. package/packages/host/dist/alwayson.js.map +1 -1
  95. package/packages/host/dist/answergate.js +3 -2
  96. package/packages/host/dist/answergate.js.map +1 -1
  97. package/packages/host/dist/attentionstore.d.ts +4 -0
  98. package/packages/host/dist/attentionstore.js +17 -0
  99. package/packages/host/dist/attentionstore.js.map +1 -1
  100. package/packages/host/dist/auth.d.ts +28 -0
  101. package/packages/host/dist/auth.js +65 -2
  102. package/packages/host/dist/auth.js.map +1 -1
  103. package/packages/host/dist/cli/pair.d.ts +4 -0
  104. package/packages/host/dist/cli/pair.js +18 -5
  105. package/packages/host/dist/cli/pair.js.map +1 -1
  106. package/packages/host/dist/composer.d.ts +6 -0
  107. package/packages/host/dist/composer.js +7 -1
  108. package/packages/host/dist/composer.js.map +1 -1
  109. package/packages/host/dist/ctxlog/ctxagents.d.ts +0 -29
  110. package/packages/host/dist/ctxlog/ctxagents.js +41 -8
  111. package/packages/host/dist/ctxlog/ctxagents.js.map +1 -1
  112. package/packages/host/dist/der.d.ts +19 -0
  113. package/packages/host/dist/der.js +51 -0
  114. package/packages/host/dist/der.js.map +1 -0
  115. package/packages/host/dist/doctor.d.ts +2 -0
  116. package/packages/host/dist/doctor.js +10 -2
  117. package/packages/host/dist/doctor.js.map +1 -1
  118. package/packages/host/dist/extensions/desktop/host/auto.d.mts +71 -0
  119. package/packages/host/dist/extensions/desktop/host/auto.mjs +114 -0
  120. package/packages/host/dist/extensions/desktop/host/auto.mjs.map +1 -0
  121. package/packages/host/dist/extensions/desktop/host/desktop.d.mts +51 -0
  122. package/packages/host/dist/extensions/desktop/host/desktop.mjs +113 -0
  123. package/packages/host/dist/extensions/desktop/host/desktop.mjs.map +1 -0
  124. package/packages/host/dist/extensions/desktop/host/helperbin.d.mts +71 -0
  125. package/packages/host/dist/extensions/desktop/host/helperbin.mjs +160 -0
  126. package/packages/host/dist/extensions/desktop/host/helperbin.mjs.map +1 -0
  127. package/packages/host/dist/extensions/desktop/host/index.d.mts +4 -0
  128. package/packages/host/dist/extensions/desktop/host/index.mjs +88 -0
  129. package/packages/host/dist/extensions/desktop/host/index.mjs.map +1 -0
  130. package/packages/host/dist/extensions/desktop/host/knobs.d.mts +52 -0
  131. package/packages/host/dist/extensions/desktop/host/knobs.mjs +81 -0
  132. package/packages/host/dist/extensions/desktop/host/knobs.mjs.map +1 -0
  133. package/packages/host/dist/extensions/desktop/host/probe.d.mts +57 -0
  134. package/packages/host/dist/extensions/desktop/host/probe.mjs +119 -0
  135. package/packages/host/dist/extensions/desktop/host/probe.mjs.map +1 -0
  136. package/packages/host/dist/extensions/desktop/host/records.d.mts +40 -0
  137. package/packages/host/dist/extensions/desktop/host/records.mjs +101 -0
  138. package/packages/host/dist/extensions/desktop/host/records.mjs.map +1 -0
  139. package/packages/host/dist/extensions/desktop/host/session.d.mts +86 -0
  140. package/packages/host/dist/extensions/desktop/host/session.mjs +318 -0
  141. package/packages/host/dist/extensions/desktop/host/session.mjs.map +1 -0
  142. package/packages/host/dist/extensions/desktop/host/setup.d.mts +28 -0
  143. package/packages/host/dist/extensions/desktop/host/setup.mjs +217 -0
  144. package/packages/host/dist/extensions/desktop/host/setup.mjs.map +1 -0
  145. package/packages/host/dist/extensions/desktop/host/status.d.mts +27 -0
  146. package/packages/host/dist/extensions/desktop/host/status.mjs +62 -0
  147. package/packages/host/dist/extensions/desktop/host/status.mjs.map +1 -0
  148. package/packages/host/dist/extensions/desktop/shared/wire.d.mts +206 -0
  149. package/packages/host/dist/extensions/desktop/shared/wire.mjs +174 -0
  150. package/packages/host/dist/extensions/desktop/shared/wire.mjs.map +1 -0
  151. package/packages/host/dist/git.d.ts +23 -6
  152. package/packages/host/dist/git.js +54 -26
  153. package/packages/host/dist/git.js.map +1 -1
  154. package/packages/host/dist/gitfetchfail.d.ts +24 -0
  155. package/packages/host/dist/gitfetchfail.js +62 -0
  156. package/packages/host/dist/gitfetchfail.js.map +1 -0
  157. package/packages/host/dist/gitlog.d.ts +4 -0
  158. package/packages/host/dist/gitlog.js +8 -3
  159. package/packages/host/dist/gitlog.js.map +1 -1
  160. package/packages/host/dist/gitnotify.d.ts +7 -1
  161. package/packages/host/dist/gitnotify.js +61 -1
  162. package/packages/host/dist/gitnotify.js.map +1 -1
  163. package/packages/host/dist/healthwatch.js +1 -1
  164. package/packages/host/dist/healthwatch.js.map +1 -1
  165. package/packages/host/dist/host.js +5 -1
  166. package/packages/host/dist/host.js.map +1 -1
  167. package/packages/host/dist/hoststore.js +4 -4
  168. package/packages/host/dist/hoststore.js.map +1 -1
  169. package/packages/host/dist/lantls.d.ts +145 -0
  170. package/packages/host/dist/lantls.js +337 -0
  171. package/packages/host/dist/lantls.js.map +1 -0
  172. package/packages/host/dist/main.js +10 -2
  173. package/packages/host/dist/main.js.map +1 -1
  174. package/packages/host/dist/net.d.ts +7 -0
  175. package/packages/host/dist/net.js +9 -0
  176. package/packages/host/dist/net.js.map +1 -1
  177. package/packages/host/dist/origin.d.ts +4 -0
  178. package/packages/host/dist/origin.js +6 -0
  179. package/packages/host/dist/origin.js.map +1 -1
  180. package/packages/host/dist/pair.d.ts +54 -5
  181. package/packages/host/dist/pair.js +139 -34
  182. package/packages/host/dist/pair.js.map +1 -1
  183. package/packages/host/dist/pairpake.d.ts +58 -0
  184. package/packages/host/dist/pairpake.js +208 -0
  185. package/packages/host/dist/pairpake.js.map +1 -0
  186. package/packages/host/dist/passive.d.ts +2 -0
  187. package/packages/host/dist/passive.js +4 -0
  188. package/packages/host/dist/passive.js.map +1 -1
  189. package/packages/host/dist/peers/client.d.ts +3 -1
  190. package/packages/host/dist/peers/client.js +4 -0
  191. package/packages/host/dist/peers/client.js.map +1 -1
  192. package/packages/host/dist/peers/routes.d.ts +6 -1
  193. package/packages/host/dist/peers/routes.js +39 -0
  194. package/packages/host/dist/peers/routes.js.map +1 -1
  195. package/packages/host/dist/permmode.js +3 -3
  196. package/packages/host/dist/plugins/pluginapi.js +13 -0
  197. package/packages/host/dist/plugins/pluginapi.js.map +1 -1
  198. package/packages/host/dist/plugins/pluginregistry.d.ts +6 -0
  199. package/packages/host/dist/plugins/pluginregistry.js.map +1 -1
  200. package/packages/host/dist/prepushcheck.d.ts +34 -0
  201. package/packages/host/dist/prepushcheck.js +157 -0
  202. package/packages/host/dist/prepushcheck.js.map +1 -0
  203. package/packages/host/dist/prs.js +2 -1
  204. package/packages/host/dist/prs.js.map +1 -1
  205. package/packages/host/dist/reponames.d.ts +26 -0
  206. package/packages/host/dist/reponames.js +119 -0
  207. package/packages/host/dist/reponames.js.map +1 -0
  208. package/packages/host/dist/repos.js +13 -0
  209. package/packages/host/dist/repos.js.map +1 -1
  210. package/packages/host/dist/routes/apigitcheck.d.ts +2 -0
  211. package/packages/host/dist/routes/apigitcheck.js +29 -0
  212. package/packages/host/dist/routes/apigitcheck.js.map +1 -0
  213. package/packages/host/dist/routes/apihost.js +28 -1
  214. package/packages/host/dist/routes/apihost.js.map +1 -1
  215. package/packages/host/dist/routes/apiio.js +2 -1
  216. package/packages/host/dist/routes/apiio.js.map +1 -1
  217. package/packages/host/dist/routes/apirepofiles.js +2 -1
  218. package/packages/host/dist/routes/apirepofiles.js.map +1 -1
  219. package/packages/host/dist/routes/apisystem.js +21 -3
  220. package/packages/host/dist/routes/apisystem.js.map +1 -1
  221. package/packages/host/dist/server/http.js +31 -5
  222. package/packages/host/dist/server/http.js.map +1 -1
  223. package/packages/host/dist/server/intentEvents.js +3 -2
  224. package/packages/host/dist/server/intentEvents.js.map +1 -1
  225. package/packages/host/dist/server/plugins.d.ts +13 -0
  226. package/packages/host/dist/server/plugins.js +26 -0
  227. package/packages/host/dist/server/plugins.js.map +1 -1
  228. package/packages/host/dist/server/sessionEvents.js +10 -9
  229. package/packages/host/dist/server/sessionEvents.js.map +1 -1
  230. package/packages/host/dist/server/wsops.js +3 -1
  231. package/packages/host/dist/server/wsops.js.map +1 -1
  232. package/packages/host/dist/sessionSearch.js +3 -1
  233. package/packages/host/dist/sessionSearch.js.map +1 -1
  234. package/packages/host/dist/sessions/apiretry.d.ts +14 -0
  235. package/packages/host/dist/sessions/apiretry.js +28 -2
  236. package/packages/host/dist/sessions/apiretry.js.map +1 -1
  237. package/packages/host/dist/sessions/core.d.ts +5 -0
  238. package/packages/host/dist/sysstat.js +10 -1
  239. package/packages/host/dist/sysstat.js.map +1 -1
  240. package/packages/host/dist/tokenscan.d.ts +4 -2
  241. package/packages/host/dist/tokenscan.js +5 -3
  242. package/packages/host/dist/tokenscan.js.map +1 -1
  243. package/packages/host/dist/transcript/transcriptparse.js +5 -2
  244. package/packages/host/dist/transcript/transcriptparse.js.map +1 -1
  245. package/packages/host/dist/turnagents.d.ts +8 -1
  246. package/packages/host/dist/turnagents.js +49 -16
  247. package/packages/host/dist/turnagents.js.map +1 -1
  248. package/packages/host/dist/updatekey.d.ts +22 -0
  249. package/packages/host/dist/updatekey.js +140 -0
  250. package/packages/host/dist/updatekey.js.map +1 -0
  251. package/packages/host/dist/webbundle.d.ts +15 -6
  252. package/packages/host/dist/webbundle.js +44 -7
  253. package/packages/host/dist/webbundle.js.map +1 -1
  254. package/packages/host/dist/wincpu.d.ts +13 -0
  255. package/packages/host/dist/wincpu.js +101 -0
  256. package/packages/host/dist/wincpu.js.map +1 -0
  257. package/packages/host/package.json +2 -1
  258. package/packages/web/dist/assets/demo-iVbE6mpm.js +26 -0
  259. package/packages/web/dist/assets/icons-D8RlmSLR.js +1 -0
  260. package/packages/web/dist/assets/index-DiZysQBa.css +1 -0
  261. package/packages/web/dist/assets/index-NA--kuEl.js +170 -0
  262. package/packages/web/dist/assets/noble-DV-r0OkE.js +1 -0
  263. package/packages/web/dist/assets/{react-CoSyT_xY.js → react-CjmvHigY.js} +2 -2
  264. package/packages/web/dist/assets/rolldown-runtime-4tVxZq34.js +1 -0
  265. package/packages/web/dist/assets/shared-CsHyApn-.js +51 -0
  266. package/packages/web/dist/assets/sheets-CDv71Sfe.js +256 -0
  267. package/packages/web/dist/assets/xterm-BHsKrDzh.js +16 -0
  268. package/packages/web/dist/build-id.txt +1 -1
  269. package/packages/web/dist/buildstamp.json +1 -1
  270. package/packages/web/dist/index.html +9 -9
  271. package/packages/web/dist/sw.js +2 -2
  272. package/release.json +7 -3
  273. package/packages/web/dist/assets/demo-DkCtg-SJ.js +0 -26
  274. package/packages/web/dist/assets/icons-B48F-4nA.js +0 -1
  275. package/packages/web/dist/assets/index-BYRdfW3T.js +0 -170
  276. package/packages/web/dist/assets/index-DcZrEQNl.css +0 -1
  277. package/packages/web/dist/assets/rolldown-runtime-BpQH8Ho1.js +0 -1
  278. package/packages/web/dist/assets/shared-DjRRLGfw.js +0 -50
  279. package/packages/web/dist/assets/sheets-BGN7Cs73.js +0 -248
  280. 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
+ }