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,334 @@
1
+ // Server: the `serve` pipeline. One serial queue owns all state; the source
2
+ // delivers frames on it, commands are applied on it, and only the H.264
3
+ // encoder's output callback (VideoToolbox's thread) writes from elsewhere,
4
+ // through Output's lock.
5
+
6
+ import CoreGraphics
7
+ import CoreVideo
8
+ import Foundation
9
+
10
+ final class Server {
11
+ let q = DispatchQueue(label: "wp-desktop.server", qos: .userInteractive)
12
+ private let dryInput: Bool
13
+ private let forceSynthetic: Bool
14
+ private let input: InputInjector
15
+ private var params: Params?
16
+ private var source: FrameSource?
17
+ private var encoder: FrameEncoder?
18
+ private var geo: Geometry?
19
+ private var state = "stopped"
20
+ private var inConfigure = false
21
+ private var lastFrame: CVPixelBuffer?
22
+ private var lastFrameAt: UInt64 = 0
23
+ private var lastPts: UInt64 = 0
24
+ private var forceKey = false
25
+ /// The region the NEXT submitted frame belongs to (see `Region`): bumped by every config that
26
+ /// moves the crop or the source, and by every source restart.
27
+ private var regionSeq = 0
28
+ private var region = Region(seq: 0, crop: Crop())
29
+ /// Held only while a source is running: App Nap off and no idle system sleep. A helper that sits
30
+ /// in "error" or "stopped" (Screen Recording not granted, the view left open on that sentence)
31
+ /// must not keep the Mac awake.
32
+ private var activity: NSObjectProtocol?
33
+ private var idle = false
34
+ private var axCache: (at: UInt64, ok: Bool) = (0, false)
35
+ private var axToldAt: UInt64 = 0
36
+ private var statsTimer: DispatchSourceTimer?
37
+ private var watchdog: DispatchSourceTimer?
38
+ private let parentAtStart = getppid()
39
+
40
+ // Written from the encoder's thread.
41
+ private let statsLock = NSLock()
42
+ private var frames = 0, keys = 0, bytes = 0, captured = 0, dropped = 0
43
+ private var encSum: UInt64 = 0, encMax: UInt64 = 0
44
+ private var statsSince = nowMicros()
45
+ private var avc: String?
46
+
47
+ init(dryInput: Bool, forceSynthetic: Bool) {
48
+ self.dryInput = dryInput
49
+ self.forceSynthetic = forceSynthetic
50
+ input = InputInjector(dry: dryInput)
51
+ }
52
+
53
+ func run() {
54
+ StdinReader(
55
+ onCommand: { [self] c in q.async { self.apply(c) } },
56
+ onEOF: { [self] in
57
+ log("stdin closed, exiting")
58
+ // Never leave a capture running: stop on the queue, and exit regardless after 0.5 s.
59
+ DispatchQueue.global().asyncAfter(deadline: .now() + 0.5) { exit(0) }
60
+ q.async {
61
+ self.source?.stop()
62
+ self.encoder?.invalidate()
63
+ exit(0)
64
+ }
65
+ }
66
+ ).start()
67
+ startTimers()
68
+ log("serve: ready (\(dryInput ? "dry input, " : "")\(forceSynthetic ? "synthetic source" : "screen source"))")
69
+ }
70
+
71
+ // MARK: commands
72
+
73
+ private func apply(_ c: Command) {
74
+ switch c {
75
+ case .config(let o): configure(o)
76
+ case .keyframe: requestKey()
77
+ case .bitrate(let k):
78
+ guard var p = params else { return }
79
+ p.bitrateKbps = Params.clampBitrate(k)
80
+ params = p
81
+ encoder?.setBitrate(p.bitrateKbps)
82
+ emitStatus()
83
+ case .input(let ev): inject(ev)
84
+ case .stop: stopAll(reason: nil)
85
+ case .probe: Output.shared.json(probeObject())
86
+ }
87
+ }
88
+
89
+ private func configure(_ o: [String: Any]) {
90
+ var np = Params.parse(o, warn: { log($0) })
91
+ if forceSynthetic, np.source.kind != "synthetic" { np.source = .synthetic(idle: false) }
92
+ let old = params
93
+ params = np
94
+ inConfigure = true
95
+ defer { inConfigure = false }
96
+ let restart: Bool = {
97
+ guard let old, source != nil, state != "error" else { return true }
98
+ if !old.source.sameKind(np.source) || old.codec != np.codec { return true }
99
+ if case .display(let a) = old.source, case .display(let b) = np.source, a != b { return true }
100
+ if case .synthetic(let a) = old.source, case .synthetic(let b) = np.source, a != b { return true }
101
+ return false
102
+ }()
103
+ if restart || old?.crop != np.crop || old?.source != np.source { regionSeq += 1 }
104
+ region = Region(seq: regionSeq, crop: np.crop)
105
+ if restart {
106
+ teardown()
107
+ if np.source.kind != "synthetic" && !screenRecordingGranted() {
108
+ fail(screenRecordingSentence)
109
+ return
110
+ }
111
+ state = "starting"
112
+ let cb = SourceCallbacks(
113
+ onGeometry: { [weak self] g in self?.geometryChanged(g) },
114
+ onFrame: { [weak self] pb, pts in self?.frame(pb, pts) },
115
+ onIdle: { [weak self] in self?.idle = true },
116
+ onError: { [weak self] s in self?.fail(s) })
117
+ let s: FrameSource =
118
+ np.source.kind == "synthetic"
119
+ ? SyntheticSource(queue: q, params: np, callbacks: cb)
120
+ : ScreenSource(queue: q, params: np, callbacks: cb)
121
+ source = s
122
+ s.start()
123
+ if state == "error" { return } // fail() already said why
124
+ holdActivity()
125
+ } else {
126
+ // A new region (crop, or another window) must start on a key OF THAT REGION: the phone
127
+ // treats the next key after a crop change as the new picture. Forget the old frame so a
128
+ // keyframe request cannot re-encode the previous region, and key the next captured one.
129
+ if old?.crop != np.crop || old?.source != np.source {
130
+ lastFrame = nil
131
+ forceKey = true
132
+ }
133
+ source?.update(np)
134
+ encoder?.update(np)
135
+ }
136
+ emitStatus() // "running" (synthetic knows its size at once) or "starting" (SCK resolving)
137
+ }
138
+
139
+ private func holdActivity() {
140
+ guard activity == nil else { return }
141
+ // A background CLI gets App Nap timer coalescing: a 66 ms timer fired at ~180 ms.
142
+ activity = ProcessInfo.processInfo.beginActivity(
143
+ options: [.userInitiated, .latencyCritical, .idleSystemSleepDisabled], reason: "live desktop stream")
144
+ log("activity: held")
145
+ }
146
+
147
+ private func releaseActivity() {
148
+ guard let a = activity else { return }
149
+ ProcessInfo.processInfo.endActivity(a)
150
+ activity = nil
151
+ log("activity: released")
152
+ }
153
+
154
+ private func teardown() {
155
+ releaseActivity()
156
+ source?.stop()
157
+ source = nil
158
+ encoder?.invalidate()
159
+ encoder = nil
160
+ geo = nil
161
+ lastFrame = nil
162
+ statsLock.lock()
163
+ avc = nil
164
+ statsLock.unlock()
165
+ }
166
+
167
+ private func stopAll(reason: String?) {
168
+ teardown()
169
+ state = "stopped"
170
+ emitStatus(reason: reason)
171
+ }
172
+
173
+ private func fail(_ sentence: String) {
174
+ teardown()
175
+ state = "error"
176
+ log("error: \(sentence)")
177
+ emitStatus(reason: sentence)
178
+ }
179
+
180
+ private func requestKey() {
181
+ guard let p = params, let enc = encoder else {
182
+ forceKey = true
183
+ return
184
+ }
185
+ // On a still screen no frame will come: re-encode the last one as an IDR now.
186
+ let interval = UInt64(1_000_000 / p.fps)
187
+ if let last = lastFrame, nowMicros() - lastFrameAt > 2 * interval {
188
+ lastPts = max(nowMicros(), lastPts + 1)
189
+ enc.encode(last, pts: lastPts, forceKey: true, region: region)
190
+ } else {
191
+ forceKey = true
192
+ }
193
+ }
194
+
195
+ private func inject(_ ev: [String: Any]) {
196
+ if !dryInput {
197
+ let now = nowMicros()
198
+ if now - axCache.at > 2_000_000 { axCache = (now, accessibilityTrusted()) }
199
+ if !axCache.ok {
200
+ if now - axToldAt > 10_000_000 {
201
+ axToldAt = now
202
+ emitStatus(reason: accessibilitySentence)
203
+ }
204
+ return
205
+ }
206
+ }
207
+ var bounds = geo?.globalBounds ?? CGDisplayBounds(CGMainDisplayID())
208
+ if bounds.width < 1 { bounds = CGRect(x: 0, y: 0, width: 1280, height: 800) }
209
+ if let why = input.handle(ev, bounds: bounds) { log("input ignored: \(why)") }
210
+ }
211
+
212
+ // MARK: source callbacks (on q)
213
+
214
+ private func geometryChanged(_ g: Geometry) {
215
+ geo = g
216
+ if state != "error" { state = "running" }
217
+ if !inConfigure { emitStatus() }
218
+ }
219
+
220
+ private func frame(_ pb: CVPixelBuffer, _ ptsIn: UInt64) {
221
+ guard let p = params else { return }
222
+ idle = false
223
+ statsLock.lock()
224
+ captured += 1
225
+ statsLock.unlock()
226
+ let w = CVPixelBufferGetWidth(pb), h = CVPixelBufferGetHeight(pb)
227
+ if encoder == nil || encoder!.width != w || encoder!.height != h || encoder!.codec != p.codec {
228
+ encoder?.invalidate()
229
+ let sink: (EncodedFrame) -> Void = { [weak self] f in self?.encoded(f) }
230
+ encoder =
231
+ p.codec == .h264
232
+ ? H264Encoder(width: w, height: h, params: p, sink: sink)
233
+ : JpegEncoder(width: w, height: h, params: p, sink: sink)
234
+ if encoder == nil {
235
+ fail("The H.264 encoder could not start at \(w)×\(h). Try JPEG or a smaller size.")
236
+ return
237
+ }
238
+ forceKey = true
239
+ }
240
+ guard let enc = encoder else { return }
241
+ if enc.inFlight > 2 && !forceKey {
242
+ statsLock.lock()
243
+ dropped += 1
244
+ statsLock.unlock()
245
+ return
246
+ }
247
+ let pts = max(ptsIn, lastPts + 1)
248
+ lastPts = pts
249
+ lastFrame = pb
250
+ lastFrameAt = nowMicros()
251
+ let k = forceKey
252
+ forceKey = false
253
+ enc.encode(pb, pts: pts, forceKey: k, region: region)
254
+ }
255
+
256
+ /// Encoder output: H.264 on VideoToolbox's thread, JPEG on q.
257
+ private func encoded(_ f: EncodedFrame) {
258
+ var newAvc = false
259
+ statsLock.lock()
260
+ frames += 1
261
+ bytes += f.data.count
262
+ if f.key { keys += 1 }
263
+ encSum += f.encodeMicros
264
+ encMax = max(encMax, f.encodeMicros)
265
+ if let a = f.avc, a != avc {
266
+ avc = a
267
+ newAvc = true
268
+ }
269
+ statsLock.unlock()
270
+ // The region the frame was SUBMITTED for: Output announces it (a status) before its first frame.
271
+ Output.shared.frame(f.kind, key: f.key, pts: f.pts, payload: f.data, region: f.region)
272
+ if newAvc { q.async { self.emitStatus() } }
273
+ }
274
+
275
+ // MARK: status + stats
276
+
277
+ private func emitStatus(reason: String? = nil) {
278
+ let p = params
279
+ var o: [String: Any] = [
280
+ "t": "status", "state": state,
281
+ "width": geo?.outWidth ?? 0, "height": geo?.outHeight ?? 0,
282
+ "srcWidth": geo?.srcWidth ?? 0, "srcHeight": geo?.srcHeight ?? 0,
283
+ "fps": p?.fps ?? 0, "codec": (p?.codec ?? .h264).rawValue,
284
+ "bitrateKbps": p?.bitrateKbps ?? 0, "source": p?.source.kind ?? "none",
285
+ "screenRecording": screenRecordingGranted(), "accessibility": accessibilityTrusted(),
286
+ "responsibleSelf": { let r = responsibility(); return r.pid == r.responsible }(),
287
+ "helperVersion": helperVersion,
288
+ ]
289
+ if let reason { o["reason"] = reason }
290
+ statsLock.lock()
291
+ if let avc, p?.codec == .h264 { o["avc"] = avc }
292
+ statsLock.unlock()
293
+ Output.shared.status(o)
294
+ log("status \(state) \(o["width"]!)x\(o["height"]!) \(o["codec"]!) \(o["fps"]!)fps\(reason.map { " — " + $0 } ?? "")")
295
+ }
296
+
297
+ private func startTimers() {
298
+ let t = DispatchSource.makeTimerSource(queue: q)
299
+ t.schedule(deadline: .now() + 2, repeating: 2)
300
+ t.setEventHandler { [weak self] in self?.reportStats() }
301
+ t.resume()
302
+ statsTimer = t
303
+ // The host dying closes stdin, which is the main exit path. Being re-parented is the backstop.
304
+ let w = DispatchSource.makeTimerSource(queue: .global())
305
+ w.schedule(deadline: .now() + 1, repeating: 1)
306
+ w.setEventHandler { [parentAtStart] in
307
+ if getppid() != parentAtStart {
308
+ log("parent went away, exiting")
309
+ exit(0)
310
+ }
311
+ }
312
+ w.resume()
313
+ watchdog = w
314
+ }
315
+
316
+ private func reportStats() {
317
+ let now = nowMicros()
318
+ statsLock.lock()
319
+ let span = Double(now - statsSince) / 1e6
320
+ let s = (frames, keys, bytes, captured, dropped, encSum, encMax)
321
+ frames = 0; keys = 0; bytes = 0; captured = 0; dropped = 0; encSum = 0; encMax = 0
322
+ statsSince = now
323
+ statsLock.unlock()
324
+ guard state == "running", span > 0, s.0 > 0 || s.3 > 0 else { return }
325
+ let fps = Double(s.0) / span, kbps = Double(s.2) * 8 / 1000 / span
326
+ let encAvg = s.0 > 0 ? Double(s.5) / Double(s.0) / 1000 : 0, encMaxMs = Double(s.6) / 1000
327
+ log(String(format: "stats fps=%.1f kbps=%.0f keys=%d enc=%.2fms max=%.2fms captured=%d dropped=%d", fps, kbps, s.1, encAvg, encMaxMs, s.3, s.4))
328
+ Output.shared.json([
329
+ "t": "stats", "fps": (fps * 10).rounded() / 10, "kbps": kbps.rounded(), "keys": s.1,
330
+ "encodeMsAvg": (encAvg * 100).rounded() / 100, "encodeMsMax": (encMaxMs * 100).rounded() / 100,
331
+ "captured": s.3, "dropped": s.4, "periodMs": Int(span * 1000), "idle": idle,
332
+ ])
333
+ }
334
+ }
@@ -0,0 +1,281 @@
1
+ // Synthetic source: a desktop-like test pattern rendered without any capture
2
+ // API, fed into the same encoder/output path as a real capture. Every test uses
3
+ // it, so nothing in a test can touch the real screen or ask for a permission.
4
+ //
5
+ // The pattern lives on a virtual 2560×1600 "desktop" so `crop` and `maxWidth`
6
+ // behave exactly as they do on a display:
7
+ // - a light, mostly static background and menu bar
8
+ // - an editor window whose text-like lines scroll ~3 lines per second
9
+ // - a dark terminal panel whose glyphs churn every frame (the part that costs bits)
10
+ // - a cursor-like square on a slow Lissajous path
11
+
12
+ import CoreGraphics
13
+ import CoreVideo
14
+ import Foundation
15
+
16
+ protocol FrameSource: AnyObject {
17
+ func start()
18
+ /// Same source kind, new params: re-steer without a restart where possible.
19
+ func update(_ p: Params)
20
+ func stop()
21
+ }
22
+
23
+ /// All callbacks arrive on the server's queue.
24
+ struct SourceCallbacks {
25
+ let onGeometry: (Geometry) -> Void
26
+ let onFrame: (CVPixelBuffer, UInt64) -> Void
27
+ let onIdle: () -> Void
28
+ let onError: (String) -> Void
29
+ }
30
+
31
+ func pixelFormat(for codec: Codec) -> OSType {
32
+ codec == .h264 ? kCVPixelFormatType_420YpCbCr8BiPlanarVideoRange : kCVPixelFormatType_32BGRA
33
+ }
34
+
35
+ @inline(__always) private func mix(_ a: Int, _ b: Int, _ c: Int) -> UInt32 {
36
+ var x = UInt32(truncatingIfNeeded: (a &* 73_856_093) ^ (b &* 19_349_663) ^ (c &* 83_492_791))
37
+ x ^= x >> 13
38
+ x = x &* 0x5bd1_e995
39
+ x ^= x >> 15
40
+ return x
41
+ }
42
+
43
+ final class SyntheticSource: FrameSource {
44
+ static let VW = 2560, VH = 1600
45
+ private let q: DispatchQueue
46
+ private var p: Params
47
+ private let cb: SourceCallbacks
48
+ private var timer: DispatchSourceTimer?
49
+ private var pool: CVPixelBufferPool?
50
+ private var poolKey: (Int, Int, OSType) = (0, 0, 0)
51
+ private var geo: Geometry?
52
+ private let t0 = nowMicros()
53
+ private var frameNo = 0
54
+ private var wasIdle = false
55
+ private var colMap = [Int32]()
56
+ private var row = [UInt8]()
57
+ private let yLut: [UInt8] = (0..<256).map { UInt8(16 + $0 * 219 / 255) }
58
+
59
+ init(queue: DispatchQueue, params: Params, callbacks: SourceCallbacks) {
60
+ q = queue
61
+ p = params
62
+ cb = callbacks
63
+ }
64
+
65
+ func start() {
66
+ recomputeGeometry()
67
+ startTimer()
68
+ }
69
+
70
+ func update(_ np: Params) {
71
+ let fpsChanged = np.fps != p.fps
72
+ p = np
73
+ recomputeGeometry()
74
+ if fpsChanged { startTimer() }
75
+ }
76
+
77
+ func stop() {
78
+ timer?.cancel()
79
+ timer = nil
80
+ }
81
+
82
+ private func recomputeGeometry() {
83
+ var bounds = CGDisplayBounds(CGMainDisplayID())
84
+ if bounds.width < 1 { bounds = CGRect(x: 0, y: 0, width: 1280, height: 800) }
85
+ let (w, h) = outputSize(srcW: Self.VW, srcH: Self.VH, crop: p.crop, maxWidth: p.maxWidth, codec: p.codec)
86
+ let g = Geometry(srcWidth: Self.VW, srcHeight: Self.VH, globalBounds: bounds, outWidth: w, outHeight: h)
87
+ if g != geo {
88
+ geo = g
89
+ cb.onGeometry(g)
90
+ }
91
+ }
92
+
93
+ private func startTimer() {
94
+ timer?.cancel()
95
+ let t = DispatchSource.makeTimerSource(flags: .strict, queue: q)
96
+ let interval = 1.0 / Double(p.fps)
97
+ t.schedule(deadline: .now() + interval, repeating: interval, leeway: .milliseconds(1))
98
+ t.setEventHandler { [weak self] in self?.tick() }
99
+ t.resume()
100
+ timer = t
101
+ }
102
+
103
+ private func tick() {
104
+ guard let g = geo else { return }
105
+ let now = nowMicros()
106
+ let t = Double(now - t0) / 1e6
107
+ if case .synthetic(let idle) = p.source, idle, t.truncatingRemainder(dividingBy: 6) >= 3 {
108
+ if !wasIdle { cb.onIdle() }
109
+ wasIdle = true
110
+ return
111
+ }
112
+ wasIdle = false
113
+ let fmt = pixelFormat(for: p.codec)
114
+ if pool == nil || poolKey != (g.outWidth, g.outHeight, fmt) {
115
+ pool = makePool(g.outWidth, g.outHeight, fmt)
116
+ poolKey = (g.outWidth, g.outHeight, fmt)
117
+ }
118
+ guard let pool else { return }
119
+ var pbOut: CVPixelBuffer?
120
+ guard CVPixelBufferPoolCreatePixelBuffer(nil, pool, &pbOut) == kCVReturnSuccess, let pb = pbOut else {
121
+ log("synthetic: no pixel buffer from the pool")
122
+ return
123
+ }
124
+ frameNo += 1
125
+ render(pb, g, t)
126
+ cb.onFrame(pb, now)
127
+ }
128
+
129
+ private func makePool(_ w: Int, _ h: Int, _ fmt: OSType) -> CVPixelBufferPool? {
130
+ let attrs: [String: Any] = [
131
+ kCVPixelBufferPixelFormatTypeKey as String: fmt,
132
+ kCVPixelBufferWidthKey as String: w,
133
+ kCVPixelBufferHeightKey as String: h,
134
+ kCVPixelBufferIOSurfacePropertiesKey as String: [String: Any](),
135
+ ]
136
+ var pool: CVPixelBufferPool?
137
+ CVPixelBufferPoolCreate(nil, nil, attrs as CFDictionary, &pool)
138
+ return pool
139
+ }
140
+
141
+ // MARK: rendering
142
+
143
+ private func render(_ pb: CVPixelBuffer, _ g: Geometry, _ t: Double) {
144
+ let W = g.outWidth, H = g.outHeight
145
+ let c = p.crop
146
+ let cx0 = c.x * Double(Self.VW), cw = c.w * Double(Self.VW)
147
+ let cy0 = c.y * Double(Self.VH), ch = c.h * Double(Self.VH)
148
+ if colMap.count != W {
149
+ colMap = [Int32](repeating: 0, count: W)
150
+ row = [UInt8](repeating: 0, count: W)
151
+ }
152
+ for x in 0..<W { colMap[x] = Int32(cx0 + (Double(x) + 0.5) * cw / Double(W)) }
153
+ let scroll = Int(t * 3 * 32) // 3 lines of 32 px per second
154
+ let curX = Int(1280 + 900 * sin(t * 0.7)), curY = Int(800 + 500 * sin(t * 1.1))
155
+ let frame = frameNo
156
+ let bgra = CVPixelBufferGetPixelFormatType(pb) == kCVPixelFormatType_32BGRA
157
+
158
+ CVPixelBufferLockBaseAddress(pb, [])
159
+ defer { CVPixelBufferUnlockBaseAddress(pb, []) }
160
+ let plane0 = bgra ? CVPixelBufferGetBaseAddress(pb)! : CVPixelBufferGetBaseAddressOfPlane(pb, 0)!
161
+ let stride0 = bgra ? CVPixelBufferGetBytesPerRow(pb) : CVPixelBufferGetBytesPerRowOfPlane(pb, 0)
162
+ let out = plane0.assumingMemoryBound(to: UInt8.self)
163
+
164
+ colMap.withUnsafeBufferPointer { cm in
165
+ row.withUnsafeMutableBufferPointer { rw in
166
+ for y in 0..<H {
167
+ let vy = Int(cy0 + (Double(y) + 0.5) * ch / Double(H))
168
+ fillRow(vy: vy, scroll: scroll, frame: frame, curX: curX, curY: curY, cm: cm, row: rw)
169
+ let dst = out + y * stride0
170
+ if bgra {
171
+ for x in 0..<W {
172
+ let v = rw[x]
173
+ dst[4 * x] = v
174
+ dst[4 * x + 1] = v
175
+ dst[4 * x + 2] = v
176
+ dst[4 * x + 3] = 255
177
+ }
178
+ } else {
179
+ yLut.withUnsafeBufferPointer { lut in for x in 0..<W { dst[x] = lut[Int(rw[x])] } }
180
+ }
181
+ }
182
+ }
183
+ }
184
+
185
+ // Colour: the cursor square is orange so the chroma path is exercised too.
186
+ let ox0 = Int((Double(curX) - cx0) * Double(W) / cw), ox1 = Int((Double(curX + 28) - cx0) * Double(W) / cw)
187
+ let oy0 = Int((Double(curY) - cy0) * Double(H) / ch), oy1 = Int((Double(curY + 28) - cy0) * Double(H) / ch)
188
+ if bgra {
189
+ for y in max(0, oy0)..<max(max(0, oy0), min(H, oy1)) {
190
+ let dst = out + y * stride0
191
+ for x in max(0, ox0)..<max(max(0, ox0), min(W, ox1)) {
192
+ dst[4 * x] = 20
193
+ dst[4 * x + 1] = 120
194
+ dst[4 * x + 2] = 240
195
+ }
196
+ }
197
+ } else {
198
+ let uv = CVPixelBufferGetBaseAddressOfPlane(pb, 1)!.assumingMemoryBound(to: UInt8.self)
199
+ let strideUV = CVPixelBufferGetBytesPerRowOfPlane(pb, 1)
200
+ let hw = (W + 1) / 2
201
+ for y in 0..<((H + 1) / 2) {
202
+ let dst = uv + y * strideUV
203
+ let inCur = 2 * y >= oy0 && 2 * y < oy1
204
+ for x in 0..<hw {
205
+ if inCur && 2 * x >= ox0 && 2 * x < ox1 {
206
+ dst[2 * x] = 70
207
+ dst[2 * x + 1] = 200
208
+ } else {
209
+ dst[2 * x] = 128
210
+ dst[2 * x + 1] = 128
211
+ }
212
+ }
213
+ }
214
+ }
215
+ }
216
+
217
+ /// One row of luma (0 = black, 255 = white) at virtual row `vy`.
218
+ @inline(__always)
219
+ private func fillRow(
220
+ vy: Int, scroll: Int, frame: Int, curX: Int, curY: Int,
221
+ cm: UnsafeBufferPointer<Int32>, row: UnsafeMutableBufferPointer<UInt8>
222
+ ) {
223
+ let W = row.count
224
+ if vy < 48 { // menu bar: static
225
+ for x in 0..<W { row[x] = 238 }
226
+ return
227
+ }
228
+ let base: UInt8 = 206 &+ UInt8((vy >> 6) & 1) &* 4
229
+ // Editor window rows.
230
+ let inEditor = vy >= 120 && vy < 1450
231
+ let edTitle = vy < 170
232
+ let ly = vy - 170 + scroll
233
+ let eLine = ly >> 5, eR = ly & 31
234
+ let eGlyphRow = !edTitle && eR >= 8 && eR < 26
235
+ let eGy = (eR - 8) / 3
236
+ let eHash = mix(eLine, 0, 7)
237
+ let eLen = 16 + Int(eHash % 72), eIndent = Int((eHash >> 8) % 4) * 2
238
+ // Terminal panel rows.
239
+ let inTerm = vy >= 300 && vy < 1100
240
+ let tTitle = vy < 340
241
+ let tl = vy - 340
242
+ let tLine = tl >> 5, tR = tl & 31
243
+ let tGlyphRow = !tTitle && tR >= 8 && tR < 26
244
+ let tGy = (tR - 8) / 3
245
+ let tHash = mix(tLine, frame, 11)
246
+ let tLen = 8 + Int(tHash % 32)
247
+ let inCursor = vy >= curY && vy < curY + 28
248
+
249
+ for x in 0..<W {
250
+ let vx = Int(cm[x])
251
+ var v = base
252
+ if inEditor && vx >= 200 && vx < 1700 {
253
+ if edTitle {
254
+ v = 214
255
+ } else {
256
+ v = 252
257
+ if eGlyphRow {
258
+ let col = (vx - 216) >> 4, cx = (vx - 216) & 15
259
+ if vx >= 216 && col >= eIndent && col < eIndent + eLen && cx < 12 && mix(eLine, col, 1) % 7 != 0 {
260
+ if (mix(eLine, col, eGy + 2) >> UInt32(cx >> 1)) & 1 == 1 { v = 28 }
261
+ }
262
+ }
263
+ }
264
+ } else if inTerm && vx >= 1800 && vx < 2450 {
265
+ if tTitle {
266
+ v = 64
267
+ } else {
268
+ v = 30
269
+ if tGlyphRow {
270
+ let col = (vx - 1812) >> 4, cx = (vx - 1812) & 15
271
+ if vx >= 1812 && col < tLen && cx < 12 && mix(tLine, col, frame) % 5 != 0 {
272
+ if (mix(tLine &+ frame, col, tGy + 2) >> UInt32(cx >> 1)) & 1 == 1 { v = 224 }
273
+ }
274
+ }
275
+ }
276
+ }
277
+ if inCursor && vx >= curX && vx < curX + 28 { v = 12 }
278
+ row[x] = v
279
+ }
280
+ }
281
+ }
@@ -0,0 +1,53 @@
1
+ #!/bin/bash
2
+ # Build wp-desktop. Usage:
3
+ # build.sh [OUT] compile to OUT (default: $OUT, else $TMPDIR/wp-desktop/wp-desktop), ad-hoc sign
4
+ # build.sh --hash print the source hash only (the host rebuilds only when it changes)
5
+ #
6
+ # A TCC grant is bound to the binary's code signature. An ad-hoc signature is
7
+ # its cdhash, so EVERY rebuild means granting Screen Recording and
8
+ # Accessibility again: rebuild only when --hash changes. The identifier is
9
+ # stable and the Info.plist is embedded so the System Settings row reads
10
+ # "wp-desktop", not a hash.
11
+ #
12
+ # The product's name is not spelled here: the host (helperbin.mts) reads it from
13
+ # the brand table and passes it in the environment, for --hash and for a build
14
+ # alike (the identifier is part of the hash):
15
+ # WP_DESKTOP_ID the code-signing identifier / CFBundleIdentifier (TCC keys the grant on it)
16
+ # WP_DESKTOP_NAME CFBundleDisplayName
17
+ # A hand build (selftest.sh) gets a neutral local identifier; the host then sees
18
+ # a different hash and builds its own.
19
+ set -euo pipefail
20
+ HERE="$(cd "$(dirname "$0")" && pwd)"
21
+ SOURCES=(main.swift Protocol.swift Params.swift Server.swift Capture.swift Synthetic.swift H264.swift Jpeg.swift Input.swift Probe.swift Request.swift Responsibility.swift)
22
+
23
+ ID="${WP_DESKTOP_ID:-local.wp-desktop}"
24
+ NAME="${WP_DESKTOP_NAME:-wp-desktop helper}"
25
+
26
+ srchash() {
27
+ { (cd "$HERE" && cat "${SOURCES[@]}" Info.plist build.sh); printf '%s\n%s\n' "$ID" "$NAME"; } | shasum -a 256 | cut -c1-16
28
+ }
29
+
30
+ if [ "${1:-}" = "--hash" ]; then
31
+ srchash
32
+ exit 0
33
+ fi
34
+
35
+ OUT="${1:-${OUT:-${TMPDIR:-/tmp}/wp-desktop/wp-desktop}}"
36
+ SWIFTC="${SWIFTC:-$(command -v swiftc || true)}"
37
+ SDK="$(xcrun --sdk macosx --show-sdk-path 2>/dev/null || true)"
38
+ if [ -z "$SWIFTC" ] || [ -z "$SDK" ]; then
39
+ echo "build.sh: no swiftc or macOS SDK. Install the command line tools: xcode-select --install" >&2
40
+ exit 3
41
+ fi
42
+ mkdir -p "$(dirname "$OUT")"
43
+ TMP_OUT="$OUT.build.$$"
44
+ PLIST="$OUT.plist.$$"
45
+ trap 'rm -f "$TMP_OUT" "$PLIST"' EXIT
46
+ sed -e "s|@WP_DESKTOP_ID@|$ID|" -e "s|@WP_DESKTOP_NAME@|$NAME|" "$HERE/Info.plist" > "$PLIST"
47
+ (cd "$HERE" && "$SWIFTC" -O -swift-version 5 -target arm64-apple-macos14 -sdk "$SDK" \
48
+ -Xlinker -sectcreate -Xlinker __TEXT -Xlinker __info_plist -Xlinker "$PLIST" \
49
+ "${SOURCES[@]}" -o "$TMP_OUT")
50
+ codesign -s - --force --identifier "$ID" "$TMP_OUT"
51
+ mv -f "$TMP_OUT" "$OUT"
52
+ srchash > "$OUT.srchash"
53
+ echo "built $OUT ($(cat "$OUT.srchash"))" >&2