waterplane 0.1.16 → 0.1.18

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 (235) hide show
  1. package/CHANGELOG.md +28 -0
  2. package/NOTICES.md +18 -7
  3. package/README.md +43 -0
  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/sessioncost/costwords.d.ts +7 -2
  36. package/node_modules/@waterplane/shared/dist/sessioncost/costwords.js +6 -3
  37. package/node_modules/@waterplane/shared/dist/sessioncost/costwords.js.map +1 -1
  38. package/node_modules/@waterplane/shared/dist/waiting.d.ts +64 -0
  39. package/node_modules/@waterplane/shared/dist/waiting.js +86 -0
  40. package/node_modules/@waterplane/shared/dist/waiting.js.map +1 -0
  41. package/node_modules/@waterplane/shared/dist/wire/messages/hostcore.d.ts +3 -0
  42. package/node_modules/@waterplane/shared/dist/wire/notify.d.ts +2 -0
  43. package/node_modules/@waterplane/shared/dist/wire/notify.js +2 -0
  44. package/node_modules/@waterplane/shared/dist/wire/notify.js.map +1 -1
  45. package/node_modules/@waterplane/shared/dist/wire/repo.d.ts +25 -1
  46. package/node_modules/@waterplane/shared/dist/wire/repo.js.map +1 -1
  47. package/node_modules/@waterplane/shared/dist/wire/sysstat.d.ts +7 -0
  48. package/node_modules/@waterplane/shared/dist/wire/telemetry.d.ts +3 -0
  49. package/node_modules/@waterplane/shared/package.json +3 -0
  50. package/npm-shrinkwrap.json +30 -2
  51. package/package.json +2 -1
  52. package/packages/extensions/desktop/helper/Capture.swift +245 -0
  53. package/packages/extensions/desktop/helper/H264.swift +226 -0
  54. package/packages/extensions/desktop/helper/Info.plist +20 -0
  55. package/packages/extensions/desktop/helper/Input.swift +195 -0
  56. package/packages/extensions/desktop/helper/Jpeg.swift +81 -0
  57. package/packages/extensions/desktop/helper/PROTOCOL.md +366 -0
  58. package/packages/extensions/desktop/helper/Params.swift +133 -0
  59. package/packages/extensions/desktop/helper/Probe.swift +119 -0
  60. package/packages/extensions/desktop/helper/Protocol.swift +224 -0
  61. package/packages/extensions/desktop/helper/Request.swift +51 -0
  62. package/packages/extensions/desktop/helper/Responsibility.swift +133 -0
  63. package/packages/extensions/desktop/helper/Server.swift +334 -0
  64. package/packages/extensions/desktop/helper/Synthetic.swift +281 -0
  65. package/packages/extensions/desktop/helper/build.sh +53 -0
  66. package/packages/extensions/desktop/helper/main.swift +79 -0
  67. package/packages/extensions/desktop/helper/selftest-driver +626 -0
  68. package/packages/extensions/desktop/helper/selftest.sh +16 -0
  69. package/packages/extensions/desktop/helper-win/Capture.cs +299 -0
  70. package/packages/extensions/desktop/helper-win/H264.cs +340 -0
  71. package/packages/extensions/desktop/helper-win/Input.cs +257 -0
  72. package/packages/extensions/desktop/helper-win/Jpeg.cs +75 -0
  73. package/packages/extensions/desktop/helper-win/Main.cs +118 -0
  74. package/packages/extensions/desktop/helper-win/Native.cs +276 -0
  75. package/packages/extensions/desktop/helper-win/Params.cs +138 -0
  76. package/packages/extensions/desktop/helper-win/Protocol.cs +206 -0
  77. package/packages/extensions/desktop/helper-win/Server.cs +304 -0
  78. package/packages/extensions/desktop/helper-win/Synthetic.cs +88 -0
  79. package/packages/extensions/desktop/helper-win/build.sh +57 -0
  80. package/packages/extensions/desktop/helper-win/selftest.sh +36 -0
  81. package/packages/extensions/desktop/waterplane-plugin.json +24 -0
  82. package/packages/host/dist/alwayson.js +1 -1
  83. package/packages/host/dist/alwayson.js.map +1 -1
  84. package/packages/host/dist/attentionstore.d.ts +4 -0
  85. package/packages/host/dist/attentionstore.js +17 -0
  86. package/packages/host/dist/attentionstore.js.map +1 -1
  87. package/packages/host/dist/auth.d.ts +28 -0
  88. package/packages/host/dist/auth.js +65 -2
  89. package/packages/host/dist/auth.js.map +1 -1
  90. package/packages/host/dist/cli/pair.d.ts +4 -0
  91. package/packages/host/dist/cli/pair.js +18 -5
  92. package/packages/host/dist/cli/pair.js.map +1 -1
  93. package/packages/host/dist/der.d.ts +19 -0
  94. package/packages/host/dist/der.js +51 -0
  95. package/packages/host/dist/der.js.map +1 -0
  96. package/packages/host/dist/doctor.d.ts +2 -0
  97. package/packages/host/dist/doctor.js +10 -2
  98. package/packages/host/dist/doctor.js.map +1 -1
  99. package/packages/host/dist/extensions/desktop/host/auto.d.mts +71 -0
  100. package/packages/host/dist/extensions/desktop/host/auto.mjs +114 -0
  101. package/packages/host/dist/extensions/desktop/host/auto.mjs.map +1 -0
  102. package/packages/host/dist/extensions/desktop/host/desktop.d.mts +51 -0
  103. package/packages/host/dist/extensions/desktop/host/desktop.mjs +113 -0
  104. package/packages/host/dist/extensions/desktop/host/desktop.mjs.map +1 -0
  105. package/packages/host/dist/extensions/desktop/host/helperbin.d.mts +71 -0
  106. package/packages/host/dist/extensions/desktop/host/helperbin.mjs +160 -0
  107. package/packages/host/dist/extensions/desktop/host/helperbin.mjs.map +1 -0
  108. package/packages/host/dist/extensions/desktop/host/index.d.mts +4 -0
  109. package/packages/host/dist/extensions/desktop/host/index.mjs +88 -0
  110. package/packages/host/dist/extensions/desktop/host/index.mjs.map +1 -0
  111. package/packages/host/dist/extensions/desktop/host/knobs.d.mts +52 -0
  112. package/packages/host/dist/extensions/desktop/host/knobs.mjs +81 -0
  113. package/packages/host/dist/extensions/desktop/host/knobs.mjs.map +1 -0
  114. package/packages/host/dist/extensions/desktop/host/probe.d.mts +57 -0
  115. package/packages/host/dist/extensions/desktop/host/probe.mjs +119 -0
  116. package/packages/host/dist/extensions/desktop/host/probe.mjs.map +1 -0
  117. package/packages/host/dist/extensions/desktop/host/records.d.mts +40 -0
  118. package/packages/host/dist/extensions/desktop/host/records.mjs +101 -0
  119. package/packages/host/dist/extensions/desktop/host/records.mjs.map +1 -0
  120. package/packages/host/dist/extensions/desktop/host/session.d.mts +86 -0
  121. package/packages/host/dist/extensions/desktop/host/session.mjs +318 -0
  122. package/packages/host/dist/extensions/desktop/host/session.mjs.map +1 -0
  123. package/packages/host/dist/extensions/desktop/host/setup.d.mts +28 -0
  124. package/packages/host/dist/extensions/desktop/host/setup.mjs +217 -0
  125. package/packages/host/dist/extensions/desktop/host/setup.mjs.map +1 -0
  126. package/packages/host/dist/extensions/desktop/host/status.d.mts +27 -0
  127. package/packages/host/dist/extensions/desktop/host/status.mjs +62 -0
  128. package/packages/host/dist/extensions/desktop/host/status.mjs.map +1 -0
  129. package/packages/host/dist/extensions/desktop/shared/wire.d.mts +206 -0
  130. package/packages/host/dist/extensions/desktop/shared/wire.mjs +174 -0
  131. package/packages/host/dist/extensions/desktop/shared/wire.mjs.map +1 -0
  132. package/packages/host/dist/git.d.ts +23 -6
  133. package/packages/host/dist/git.js +54 -26
  134. package/packages/host/dist/git.js.map +1 -1
  135. package/packages/host/dist/gitfetchfail.d.ts +24 -0
  136. package/packages/host/dist/gitfetchfail.js +62 -0
  137. package/packages/host/dist/gitfetchfail.js.map +1 -0
  138. package/packages/host/dist/gitlog.d.ts +4 -0
  139. package/packages/host/dist/gitlog.js +8 -3
  140. package/packages/host/dist/gitlog.js.map +1 -1
  141. package/packages/host/dist/gitnotify.d.ts +7 -1
  142. package/packages/host/dist/gitnotify.js +61 -1
  143. package/packages/host/dist/gitnotify.js.map +1 -1
  144. package/packages/host/dist/host.js +5 -1
  145. package/packages/host/dist/host.js.map +1 -1
  146. package/packages/host/dist/hoststore.js +4 -4
  147. package/packages/host/dist/hoststore.js.map +1 -1
  148. package/packages/host/dist/lantls.d.ts +145 -0
  149. package/packages/host/dist/lantls.js +337 -0
  150. package/packages/host/dist/lantls.js.map +1 -0
  151. package/packages/host/dist/main.js +7 -2
  152. package/packages/host/dist/main.js.map +1 -1
  153. package/packages/host/dist/net.d.ts +7 -0
  154. package/packages/host/dist/net.js +9 -0
  155. package/packages/host/dist/net.js.map +1 -1
  156. package/packages/host/dist/origin.d.ts +4 -0
  157. package/packages/host/dist/origin.js +6 -0
  158. package/packages/host/dist/origin.js.map +1 -1
  159. package/packages/host/dist/pair.d.ts +54 -5
  160. package/packages/host/dist/pair.js +139 -34
  161. package/packages/host/dist/pair.js.map +1 -1
  162. package/packages/host/dist/pairpake.d.ts +58 -0
  163. package/packages/host/dist/pairpake.js +208 -0
  164. package/packages/host/dist/pairpake.js.map +1 -0
  165. package/packages/host/dist/passive.d.ts +2 -0
  166. package/packages/host/dist/passive.js +4 -0
  167. package/packages/host/dist/passive.js.map +1 -1
  168. package/packages/host/dist/peers/client.d.ts +3 -1
  169. package/packages/host/dist/peers/client.js +4 -0
  170. package/packages/host/dist/peers/client.js.map +1 -1
  171. package/packages/host/dist/peers/routes.d.ts +6 -1
  172. package/packages/host/dist/peers/routes.js +39 -0
  173. package/packages/host/dist/peers/routes.js.map +1 -1
  174. package/packages/host/dist/plugins/pluginapi.js +13 -0
  175. package/packages/host/dist/plugins/pluginapi.js.map +1 -1
  176. package/packages/host/dist/plugins/pluginregistry.d.ts +6 -0
  177. package/packages/host/dist/plugins/pluginregistry.js.map +1 -1
  178. package/packages/host/dist/prepushcheck.d.ts +34 -0
  179. package/packages/host/dist/prepushcheck.js +157 -0
  180. package/packages/host/dist/prepushcheck.js.map +1 -0
  181. package/packages/host/dist/prs.js +2 -1
  182. package/packages/host/dist/prs.js.map +1 -1
  183. package/packages/host/dist/routes/apigitcheck.d.ts +2 -0
  184. package/packages/host/dist/routes/apigitcheck.js +29 -0
  185. package/packages/host/dist/routes/apigitcheck.js.map +1 -0
  186. package/packages/host/dist/routes/apihost.js +2 -0
  187. package/packages/host/dist/routes/apihost.js.map +1 -1
  188. package/packages/host/dist/routes/apiio.js +2 -1
  189. package/packages/host/dist/routes/apiio.js.map +1 -1
  190. package/packages/host/dist/routes/apirepofiles.js +2 -1
  191. package/packages/host/dist/routes/apirepofiles.js.map +1 -1
  192. package/packages/host/dist/routes/apisystem.js +21 -3
  193. package/packages/host/dist/routes/apisystem.js.map +1 -1
  194. package/packages/host/dist/server/http.js +31 -5
  195. package/packages/host/dist/server/http.js.map +1 -1
  196. package/packages/host/dist/server/plugins.d.ts +13 -0
  197. package/packages/host/dist/server/plugins.js +26 -0
  198. package/packages/host/dist/server/plugins.js.map +1 -1
  199. package/packages/host/dist/server/wsops.js +3 -1
  200. package/packages/host/dist/server/wsops.js.map +1 -1
  201. package/packages/host/dist/sysstat.js +10 -1
  202. package/packages/host/dist/sysstat.js.map +1 -1
  203. package/packages/host/dist/updatekey.d.ts +22 -0
  204. package/packages/host/dist/updatekey.js +140 -0
  205. package/packages/host/dist/updatekey.js.map +1 -0
  206. package/packages/host/dist/webbundle.d.ts +15 -6
  207. package/packages/host/dist/webbundle.js +44 -7
  208. package/packages/host/dist/webbundle.js.map +1 -1
  209. package/packages/host/dist/wincpu.d.ts +13 -0
  210. package/packages/host/dist/wincpu.js +101 -0
  211. package/packages/host/dist/wincpu.js.map +1 -0
  212. package/packages/host/package.json +2 -1
  213. package/packages/web/dist/assets/demo-BLBaz4G_.js +26 -0
  214. package/packages/web/dist/assets/icons-D8RlmSLR.js +1 -0
  215. package/packages/web/dist/assets/index-BrvcQFBj.js +170 -0
  216. package/packages/web/dist/assets/index-Dx3VHnr3.css +1 -0
  217. package/packages/web/dist/assets/noble-DV-r0OkE.js +1 -0
  218. package/packages/web/dist/assets/{react-CoSyT_xY.js → react-CjmvHigY.js} +2 -2
  219. package/packages/web/dist/assets/rolldown-runtime-4tVxZq34.js +1 -0
  220. package/packages/web/dist/assets/shared-BsWIJLE-.js +51 -0
  221. package/packages/web/dist/assets/sheets-C7CXUU6R.js +250 -0
  222. package/packages/web/dist/assets/xterm-BHsKrDzh.js +16 -0
  223. package/packages/web/dist/build-id.txt +1 -1
  224. package/packages/web/dist/buildstamp.json +1 -1
  225. package/packages/web/dist/index.html +9 -9
  226. package/packages/web/dist/sw.js +2 -2
  227. package/release.json +7 -3
  228. package/packages/web/dist/assets/demo-DkCtg-SJ.js +0 -26
  229. package/packages/web/dist/assets/icons-B48F-4nA.js +0 -1
  230. package/packages/web/dist/assets/index-CpdyS6Xe.css +0 -1
  231. package/packages/web/dist/assets/index-EYc28CfH.js +0 -170
  232. package/packages/web/dist/assets/rolldown-runtime-BpQH8Ho1.js +0 -1
  233. package/packages/web/dist/assets/shared-DjRRLGfw.js +0 -50
  234. package/packages/web/dist/assets/sheets-ByWNPa9p.js +0 -248
  235. package/packages/web/dist/assets/xterm-C8zLTNGk.js +0 -16
@@ -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
+ }
@@ -0,0 +1,224 @@
1
+ // Protocol: the process contract (PROTOCOL.md). stdin = JSON lines, stdout =
2
+ // length-prefixed binary records, stderr = human log lines.
3
+
4
+ import Foundation
5
+
6
+ let helperVersion = "0.1.0"
7
+
8
+ enum RecordKind: UInt8 {
9
+ case h264 = 1
10
+ case jpeg = 2
11
+ case json = 4
12
+ }
13
+
14
+ enum RecordFlag {
15
+ static let key: UInt8 = 1
16
+ }
17
+
18
+ /// CLOCK_UPTIME_RAW in µs: the clock of mach_absolute_time, of CoreMedia's host
19
+ /// time clock (so SCK's sample times), and of node's process.hrtime on macOS.
20
+ @inline(__always) func nowMicros() -> UInt64 { clock_gettime_nsec_np(CLOCK_UPTIME_RAW) / 1000 }
21
+
22
+ private let logLock = NSLock()
23
+ func log(_ s: String) {
24
+ let line = "wp-desktop: \(s)\n"
25
+ logLock.lock()
26
+ defer { logLock.unlock() }
27
+ line.withCString { p in _ = Darwin.write(2, p, strlen(p)) }
28
+ }
29
+
30
+ /// Which region of the source a frame was encoded for. `seq` grows by one on every config that
31
+ /// changes the crop or the source, and on every source restart; frames carry the region that was
32
+ /// current when they were SUBMITTED, so the VideoToolbox thread's late output still says which
33
+ /// config produced it.
34
+ struct Region: Equatable {
35
+ let seq: Int
36
+ let crop: Crop
37
+ }
38
+
39
+ /// stdout. One lock so records from the encoder thread and the control queue
40
+ /// never interleave. A failed write means the reader is gone: exit.
41
+ ///
42
+ /// The lock also owns the region the OUTPUT is in (`announced`): a video record whose region is not
43
+ /// the announced one is preceded, under the same lock, by a status record naming the new region, and
44
+ /// every status written carries the announced region. So on this one ordered stream a status naming
45
+ /// region N is followed by that region's frames and by no earlier region's (PROTOCOL.md, "Regions").
46
+ final class Output {
47
+ static let shared = Output()
48
+ private let lock = NSLock()
49
+ private(set) var bytesOut: UInt64 = 0
50
+ private var announced: Region?
51
+ private var lastStatus: [String: Any]?
52
+
53
+ /// A non-video record that carries no region.
54
+ func record(_ kind: RecordKind, key: Bool, pts: UInt64, payload: Data) {
55
+ lock.lock()
56
+ defer { lock.unlock() }
57
+ put(kind, key: key, pts: pts, payload: payload)
58
+ }
59
+
60
+ /// A video record of `region`. The first of a new region announces it with a status record first.
61
+ func frame(_ kind: RecordKind, key: Bool, pts: UInt64, payload: Data, region: Region) {
62
+ lock.lock()
63
+ defer { lock.unlock() }
64
+ if announced?.seq != region.seq {
65
+ announced = region
66
+ var o = lastStatus ?? ["t": "status", "state": "running"]
67
+ stamp(&o)
68
+ putJSON(o)
69
+ }
70
+ put(kind, key: key, pts: pts, payload: payload)
71
+ }
72
+
73
+ /// A status record: remembered (the announcing status is a copy of the newest one) and stamped
74
+ /// with the region the output is in right now, which is not necessarily the newest config's.
75
+ func status(_ obj: [String: Any]) {
76
+ lock.lock()
77
+ defer { lock.unlock() }
78
+ lastStatus = obj
79
+ var o = obj
80
+ stamp(&o)
81
+ putJSON(o)
82
+ }
83
+
84
+ func json(_ obj: [String: Any]) {
85
+ lock.lock()
86
+ defer { lock.unlock() }
87
+ putJSON(obj)
88
+ }
89
+
90
+ private func stamp(_ o: inout [String: Any]) {
91
+ guard let r = announced else { return }
92
+ o["crop"] = ["x": r.crop.x, "y": r.crop.y, "w": r.crop.w, "h": r.crop.h]
93
+ o["regionSeq"] = r.seq
94
+ }
95
+
96
+ private func putJSON(_ obj: [String: Any]) {
97
+ guard JSONSerialization.isValidJSONObject(obj),
98
+ let d = try? JSONSerialization.data(withJSONObject: obj, options: [.sortedKeys])
99
+ else {
100
+ log("internal: status object is not JSON-serialisable")
101
+ return
102
+ }
103
+ put(.json, key: false, pts: nowMicros(), payload: d)
104
+ }
105
+
106
+ /// Caller holds the lock.
107
+ private func put(_ kind: RecordKind, key: Bool, pts: UInt64, payload: Data) {
108
+ var hdr = [UInt8](repeating: 0, count: 14)
109
+ let len = UInt32(10 + payload.count)
110
+ for i in 0..<4 { hdr[i] = UInt8(truncatingIfNeeded: len >> (8 * UInt32(i))) }
111
+ hdr[4] = kind.rawValue
112
+ hdr[5] = key ? RecordFlag.key : 0
113
+ for i in 0..<8 { hdr[6 + i] = UInt8(truncatingIfNeeded: pts >> (8 * UInt64(i))) }
114
+ hdr.withUnsafeBytes { writeAll($0.baseAddress!, $0.count) }
115
+ payload.withUnsafeBytes { if $0.count > 0 { writeAll($0.baseAddress!, $0.count) } }
116
+ bytesOut += UInt64(14 + payload.count)
117
+ }
118
+
119
+ private func writeAll(_ p: UnsafeRawPointer, _ n: Int) {
120
+ var off = 0
121
+ while off < n {
122
+ let r = Darwin.write(1, p + off, n - off)
123
+ if r > 0 { off += r; continue }
124
+ if r < 0 && errno == EINTR { continue }
125
+ // EPIPE (SIGPIPE is ignored) or anything else: nobody is reading.
126
+ log("stdout closed (\(String(cString: strerror(errno)))), exiting")
127
+ exit(0)
128
+ }
129
+ }
130
+ }
131
+
132
+ enum Command {
133
+ case config([String: Any])
134
+ case keyframe
135
+ case bitrate(Int)
136
+ case input([String: Any])
137
+ case stop
138
+ case probe
139
+ }
140
+
141
+ func parseCommand(_ line: Substring) -> Command? {
142
+ let trimmed = line.trimmingCharacters(in: .whitespaces)
143
+ if trimmed.isEmpty { return nil }
144
+ guard let obj = try? JSONSerialization.jsonObject(with: Data(trimmed.utf8)) as? [String: Any],
145
+ let t = obj["t"] as? String
146
+ else {
147
+ log("ignored a stdin line that is not a JSON object with \"t\": \(trimmed.prefix(120))")
148
+ return nil
149
+ }
150
+ switch t {
151
+ case "config": return .config(obj)
152
+ case "keyframe": return .keyframe
153
+ case "bitrate":
154
+ guard let k = (obj["kbps"] as? NSNumber)?.intValue else {
155
+ log("ignored bitrate without a numeric kbps")
156
+ return nil
157
+ }
158
+ return .bitrate(k)
159
+ case "input":
160
+ guard let ev = obj["ev"] as? [String: Any] else {
161
+ log("ignored input without an ev object")
162
+ return nil
163
+ }
164
+ return .input(ev)
165
+ case "stop": return .stop
166
+ case "probe": return .probe
167
+ default:
168
+ log("ignored unknown command t=\(t)")
169
+ return nil
170
+ }
171
+ }
172
+
173
+ /// Reads stdin on its own thread; EOF ⇒ `onEOF`. A line longer than 1 MB is
174
+ /// dropped whole (an input text chunk is never that long).
175
+ final class StdinReader {
176
+ private let onCommand: (Command) -> Void
177
+ private let onEOF: () -> Void
178
+
179
+ init(onCommand: @escaping (Command) -> Void, onEOF: @escaping () -> Void) {
180
+ self.onCommand = onCommand
181
+ self.onEOF = onEOF
182
+ }
183
+
184
+ func start() {
185
+ let t = Thread { [self] in self.loop() }
186
+ t.name = "stdin"
187
+ t.start()
188
+ }
189
+
190
+ private func loop() {
191
+ var buf = [UInt8]()
192
+ var chunk = [UInt8](repeating: 0, count: 64 * 1024)
193
+ var discarding = false
194
+ while true {
195
+ let n = chunk.withUnsafeMutableBytes { Darwin.read(0, $0.baseAddress!, $0.count) }
196
+ if n < 0 && errno == EINTR { continue }
197
+ if n <= 0 { break }
198
+ var start = 0
199
+ for i in 0..<n where chunk[i] == 0x0A {
200
+ if !discarding {
201
+ buf.append(contentsOf: chunk[start..<i])
202
+ if let s = String(bytes: buf, encoding: .utf8), let c = parseCommand(Substring(s)) { onCommand(c) }
203
+ }
204
+ buf.removeAll(keepingCapacity: true)
205
+ discarding = false
206
+ start = i + 1
207
+ }
208
+ if start < n && !discarding {
209
+ buf.append(contentsOf: chunk[start..<n])
210
+ if buf.count > 1 << 20 {
211
+ log("ignored a stdin line longer than 1 MB")
212
+ buf.removeAll()
213
+ discarding = true
214
+ }
215
+ }
216
+ }
217
+ onEOF()
218
+ }
219
+ }
220
+
221
+ func jsonLine(_ obj: [String: Any]) -> String {
222
+ guard let d = try? JSONSerialization.data(withJSONObject: obj, options: [.sortedKeys]) else { return "{}" }
223
+ return String(decoding: d, as: UTF8.self)
224
+ }
@@ -0,0 +1,51 @@
1
+ // Request: the only code in wp-desktop that asks macOS for a grant. Everything else only reads
2
+ // (Probe.swift: "Nothing here can raise a permission dialog").
3
+ //
4
+ // wp-desktop request [screen|accessibility] both when no name is given
5
+ //
6
+ // Run through the same responsibility-disclaim path as `serve` (main.swift), so the dialog and the
7
+ // row it adds in System Settings name wp-desktop, not node. It returns at once: both calls only
8
+ // START the dialog, which the system shows on the desktop's screen and the person answers there.
9
+ // The grant is read later by `probe`.
10
+ //
11
+ // - Screen Recording: CGRequestScreenCaptureAccess(). It lists the binary in the Screen & System
12
+ // Audio Recording pane and shows the dialog the first time; asked again for the same binary it
13
+ // may show nothing, which is why the host then opens the pane instead.
14
+ // - Accessibility: AXIsProcessTrustedWithOptions with the prompt option on.
15
+ //
16
+ // `--dry-request` reads the current state and calls neither request API (selftest, and anything
17
+ // else that must not risk a dialog).
18
+
19
+ import ApplicationServices
20
+ import CoreGraphics
21
+ import Foundation
22
+
23
+ /// The names `request` takes, in the order a bare `request` asks them.
24
+ let requestableKinds = ["screen", "accessibility"]
25
+
26
+ /// One kind's answer: whether it was already granted before the call, and whether it is now.
27
+ /// `asked` is false when it was already granted, or in a dry run: no request API was called.
28
+ func requestAccess(_ kind: String, dry: Bool) -> [String: Any] {
29
+ switch kind {
30
+ case "screen":
31
+ let before = CGPreflightScreenCaptureAccess()
32
+ if before || dry { return ["already": before, "granted": before, "asked": false] }
33
+ let granted = CGRequestScreenCaptureAccess()
34
+ return ["already": false, "granted": granted, "asked": true]
35
+ default:
36
+ let key = kAXTrustedCheckOptionPrompt.takeUnretainedValue() as String
37
+ let before = AXIsProcessTrustedWithOptions([key: false] as CFDictionary)
38
+ if before || dry { return ["already": before, "granted": before, "asked": false] }
39
+ let granted = AXIsProcessTrustedWithOptions([key: true] as CFDictionary)
40
+ return ["already": false, "granted": granted, "asked": true]
41
+ }
42
+ }
43
+
44
+ /// `request`'s stdout object; nil when `what` names nothing requestable.
45
+ func requestObject(_ what: String?, dry: Bool) -> [String: Any]? {
46
+ let kinds = what.map { [$0] } ?? requestableKinds
47
+ guard kinds.allSatisfy({ requestableKinds.contains($0) }) else { return nil }
48
+ var out: [String: Any] = ["dry": dry, "responsibleSelf": responsibility().pid == responsibility().responsible]
49
+ for k in kinds { out[k] = requestAccess(k, dry: dry) }
50
+ return out
51
+ }
@@ -0,0 +1,133 @@
1
+ // Responsibility: which process TCC charges for this helper's Screen Recording
2
+ // and Accessibility checks.
3
+ //
4
+ // macOS attributes a TCC request to the RESPONSIBLE process. A process launched
5
+ // by launchd is responsible for itself; a child spawned with plain fork/
6
+ // posix_spawn inherits its parent's. The host is node under a
7
+ // LaunchAgent (measured: every child of the live host reports node's pid as its
8
+ // responsible pid), so a plainly spawned wp-desktop would be judged as
9
+ // /opt/homebrew/bin/node: the grant would land on node (every node script gets
10
+ // it, and a Homebrew upgrade of node silently drops it).
11
+ //
12
+ // Fix, done here so the host needs nothing native:
13
+ // 1. If this process is not responsible for itself, re-exec the same binary in
14
+ // place (POSIX_SPAWN_SETEXEC keeps pid, stdin, stdout, stderr) with
15
+ // responsibility_spawnattrs_setdisclaim(attr, 1).
16
+ // 2. If the re-exec'd image is STILL not responsible for itself, fall back to
17
+ // a disclaimed child: posix_spawn without SETEXEC, the child inherits the
18
+ // same stdio fds, the parent forwards TERM/INT/HUP and exits with the
19
+ // child's status. The child exits when its parent goes (Server watchdog).
20
+ // The SPI lives in libSystem (macOS 10.14+); Chromium, LLDB and Qt Creator use
21
+ // it for the same reason. It is looked up with dlsym, so a missing symbol
22
+ // degrades to "not disclaimed", reported as responsibleSelf:false.
23
+ //
24
+ // WP_DESKTOP_DISCLAIMED records the stage: "exec" after step 1, "child" in step 2.
25
+
26
+ import Darwin
27
+ import Foundation
28
+
29
+ private typealias GetResponsible = @convention(c) (pid_t) -> pid_t
30
+ private typealias SetDisclaim = @convention(c) (UnsafeMutablePointer<posix_spawnattr_t?>, Int32) -> Int32
31
+
32
+ private let libSystem = dlopen(nil, RTLD_NOW)
33
+
34
+ func responsibility() -> (pid: pid_t, responsible: pid_t) {
35
+ let me = getpid()
36
+ guard let sym = dlsym(libSystem, "responsibility_get_pid_responsible_for_pid") else { return (me, -1) }
37
+ return (me, unsafeBitCast(sym, to: GetResponsible.self)(me))
38
+ }
39
+
40
+ func executablePath() -> String? {
41
+ var size: UInt32 = 0
42
+ _NSGetExecutablePath(nil, &size)
43
+ var buf = [CChar](repeating: 0, count: Int(size) + 1)
44
+ guard _NSGetExecutablePath(&buf, &size) == 0 else { return nil }
45
+ var real = [CChar](repeating: 0, count: Int(PATH_MAX))
46
+ guard realpath(buf, &real) != nil else { return String(cString: buf) }
47
+ return String(cString: real)
48
+ }
49
+
50
+ /// Re-exec (or, failing that, spawn a child) with responsibility disclaimed when
51
+ /// needed. Returns only in the process that should go on running the command.
52
+ /// `force` / `forceChild` take a path even when already responsible (selftest).
53
+ func disclaimResponsibilityIfNeeded(force: Bool, forceChild: Bool) {
54
+ let env = ProcessInfo.processInfo.environment
55
+ if env["WP_DESKTOP_NO_DISCLAIM"] == "1" { return }
56
+ let stage = env["WP_DESKTOP_DISCLAIMED"]
57
+ if stage == "child" { return }
58
+ let r = responsibility()
59
+ let isSelf = r.responsible == r.pid
60
+ if stage == "exec" {
61
+ if isSelf && !forceChild { return }
62
+ log("still judged for pid \(r.responsible) after re-exec; starting a disclaimed child instead")
63
+ runDisclaimedChild()
64
+ return
65
+ }
66
+ if forceChild {
67
+ runDisclaimedChild()
68
+ return
69
+ }
70
+ if isSelf && !force { return }
71
+ var attr: posix_spawnattr_t?
72
+ guard let path = executablePath(), disclaimAttr(&attr) else { return }
73
+ defer { posix_spawnattr_destroy(&attr) }
74
+ posix_spawnattr_setflags(&attr, Int16(POSIX_SPAWN_SETEXEC))
75
+ var pid: pid_t = 0
76
+ let rc = spawn(&pid, path, &attr, stage: "exec")
77
+ // Only reached on failure: SETEXEC replaces this image on success.
78
+ log("re-exec with disclaimed responsibility failed (\(String(cString: strerror(rc)))); trying a child")
79
+ runDisclaimedChild()
80
+ }
81
+
82
+ private func disclaimAttr(_ attr: inout posix_spawnattr_t?) -> Bool {
83
+ guard let set = dlsym(libSystem, "responsibility_spawnattrs_setdisclaim") else {
84
+ log("cannot disclaim responsibility on this system; permissions will be judged for pid \(responsibility().responsible)")
85
+ return false
86
+ }
87
+ posix_spawnattr_init(&attr)
88
+ guard unsafeBitCast(set, to: SetDisclaim.self)(&attr, 1) == 0 else {
89
+ log("responsibility_spawnattrs_setdisclaim failed; permissions will be judged for pid \(responsibility().responsible)")
90
+ return false
91
+ }
92
+ return true
93
+ }
94
+
95
+ private func spawn(_ pid: inout pid_t, _ path: String, _ attr: inout posix_spawnattr_t?, stage: String) -> Int32 {
96
+ var argv: [UnsafeMutablePointer<CChar>?] = CommandLine.arguments.map { strdup($0) }
97
+ argv[0] = strdup(path)
98
+ argv.append(nil)
99
+ var envp: [UnsafeMutablePointer<CChar>?] =
100
+ ProcessInfo.processInfo.environment.filter { $0.key != "WP_DESKTOP_DISCLAIMED" }.map { strdup("\($0.key)=\($0.value)") }
101
+ envp.append(strdup("WP_DESKTOP_DISCLAIMED=\(stage)"))
102
+ envp.append(nil)
103
+ return posix_spawn(&pid, path, nil, &attr, argv, envp)
104
+ }
105
+
106
+ /// Spawns the disclaimed child on the same stdio, then waits and mirrors its
107
+ /// exit. Returns only if the child could not be started (the caller then runs
108
+ /// the command itself, not disclaimed).
109
+ private func runDisclaimedChild() {
110
+ var attr: posix_spawnattr_t?
111
+ guard let path = executablePath(), disclaimAttr(&attr) else { return }
112
+ defer { posix_spawnattr_destroy(&attr) }
113
+ var pid: pid_t = 0
114
+ let rc = spawn(&pid, path, &attr, stage: "child")
115
+ guard rc == 0 else {
116
+ log("disclaimed child failed to start (\(String(cString: strerror(rc)))); running undisclaimed")
117
+ return
118
+ }
119
+ var sources = [DispatchSourceSignal]()
120
+ for sig in [SIGTERM, SIGINT, SIGHUP] {
121
+ signal(sig, SIG_IGN)
122
+ let s = DispatchSource.makeSignalSource(signal: sig, queue: .global())
123
+ s.setEventHandler { kill(pid, sig) }
124
+ s.resume()
125
+ sources.append(s)
126
+ }
127
+ // The child reads stdin and writes stdout; this process only waits.
128
+ var st: Int32 = 0
129
+ while waitpid(pid, &st, 0) < 0 && errno == EINTR {}
130
+ _ = sources
131
+ let sig = st & 0x7f
132
+ exit(sig == 0 ? (st >> 8) & 0xff : 128 + sig)
133
+ }