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.
- package/CHANGELOG.md +28 -0
- package/NOTICES.md +18 -7
- package/README.md +43 -0
- package/SECURITY.md +196 -12
- package/node_modules/@waterplane/shared/dist/desktopfiles.d.ts +24 -2
- package/node_modules/@waterplane/shared/dist/desktopfiles.js +73 -3
- package/node_modules/@waterplane/shared/dist/desktopfiles.js.map +1 -1
- package/node_modules/@waterplane/shared/dist/hosts.d.ts +31 -0
- package/node_modules/@waterplane/shared/dist/hosts.js +46 -1
- package/node_modules/@waterplane/shared/dist/hosts.js.map +1 -1
- package/node_modules/@waterplane/shared/dist/hosturl.d.ts +55 -0
- package/node_modules/@waterplane/shared/dist/hosturl.js +85 -0
- package/node_modules/@waterplane/shared/dist/hosturl.js.map +1 -1
- package/node_modules/@waterplane/shared/dist/index.d.ts +4 -0
- package/node_modules/@waterplane/shared/dist/index.js +4 -0
- package/node_modules/@waterplane/shared/dist/index.js.map +1 -1
- package/node_modules/@waterplane/shared/dist/installrow.d.ts +6 -1
- package/node_modules/@waterplane/shared/dist/installrow.js +20 -1
- package/node_modules/@waterplane/shared/dist/installrow.js.map +1 -1
- package/node_modules/@waterplane/shared/dist/lanroute.d.ts +5 -0
- package/node_modules/@waterplane/shared/dist/lanroute.js.map +1 -1
- package/node_modules/@waterplane/shared/dist/pairing.d.ts +96 -2
- package/node_modules/@waterplane/shared/dist/pairing.js +71 -3
- package/node_modules/@waterplane/shared/dist/pairing.js.map +1 -1
- package/node_modules/@waterplane/shared/dist/pake.d.ts +131 -0
- package/node_modules/@waterplane/shared/dist/pake.js +272 -0
- package/node_modules/@waterplane/shared/dist/pake.js.map +1 -0
- package/node_modules/@waterplane/shared/dist/peer.d.ts +63 -0
- package/node_modules/@waterplane/shared/dist/peer.js +41 -0
- package/node_modules/@waterplane/shared/dist/peer.js.map +1 -1
- package/node_modules/@waterplane/shared/dist/plugin/api.d.ts +15 -0
- package/node_modules/@waterplane/shared/dist/plugin/version.d.ts +5 -1
- package/node_modules/@waterplane/shared/dist/plugin/version.js +5 -1
- package/node_modules/@waterplane/shared/dist/plugin/version.js.map +1 -1
- package/node_modules/@waterplane/shared/dist/sessioncost/costwords.d.ts +7 -2
- package/node_modules/@waterplane/shared/dist/sessioncost/costwords.js +6 -3
- package/node_modules/@waterplane/shared/dist/sessioncost/costwords.js.map +1 -1
- package/node_modules/@waterplane/shared/dist/waiting.d.ts +64 -0
- package/node_modules/@waterplane/shared/dist/waiting.js +86 -0
- package/node_modules/@waterplane/shared/dist/waiting.js.map +1 -0
- package/node_modules/@waterplane/shared/dist/wire/messages/hostcore.d.ts +3 -0
- package/node_modules/@waterplane/shared/dist/wire/notify.d.ts +2 -0
- package/node_modules/@waterplane/shared/dist/wire/notify.js +2 -0
- package/node_modules/@waterplane/shared/dist/wire/notify.js.map +1 -1
- package/node_modules/@waterplane/shared/dist/wire/repo.d.ts +25 -1
- package/node_modules/@waterplane/shared/dist/wire/repo.js.map +1 -1
- package/node_modules/@waterplane/shared/dist/wire/sysstat.d.ts +7 -0
- package/node_modules/@waterplane/shared/dist/wire/telemetry.d.ts +3 -0
- package/node_modules/@waterplane/shared/package.json +3 -0
- package/npm-shrinkwrap.json +30 -2
- package/package.json +2 -1
- package/packages/extensions/desktop/helper/Capture.swift +245 -0
- package/packages/extensions/desktop/helper/H264.swift +226 -0
- package/packages/extensions/desktop/helper/Info.plist +20 -0
- package/packages/extensions/desktop/helper/Input.swift +195 -0
- package/packages/extensions/desktop/helper/Jpeg.swift +81 -0
- package/packages/extensions/desktop/helper/PROTOCOL.md +366 -0
- package/packages/extensions/desktop/helper/Params.swift +133 -0
- package/packages/extensions/desktop/helper/Probe.swift +119 -0
- package/packages/extensions/desktop/helper/Protocol.swift +224 -0
- package/packages/extensions/desktop/helper/Request.swift +51 -0
- package/packages/extensions/desktop/helper/Responsibility.swift +133 -0
- package/packages/extensions/desktop/helper/Server.swift +334 -0
- package/packages/extensions/desktop/helper/Synthetic.swift +281 -0
- package/packages/extensions/desktop/helper/build.sh +53 -0
- package/packages/extensions/desktop/helper/main.swift +79 -0
- package/packages/extensions/desktop/helper/selftest-driver +626 -0
- package/packages/extensions/desktop/helper/selftest.sh +16 -0
- package/packages/extensions/desktop/helper-win/Capture.cs +299 -0
- package/packages/extensions/desktop/helper-win/H264.cs +340 -0
- package/packages/extensions/desktop/helper-win/Input.cs +257 -0
- package/packages/extensions/desktop/helper-win/Jpeg.cs +75 -0
- package/packages/extensions/desktop/helper-win/Main.cs +118 -0
- package/packages/extensions/desktop/helper-win/Native.cs +276 -0
- package/packages/extensions/desktop/helper-win/Params.cs +138 -0
- package/packages/extensions/desktop/helper-win/Protocol.cs +206 -0
- package/packages/extensions/desktop/helper-win/Server.cs +304 -0
- package/packages/extensions/desktop/helper-win/Synthetic.cs +88 -0
- package/packages/extensions/desktop/helper-win/build.sh +57 -0
- package/packages/extensions/desktop/helper-win/selftest.sh +36 -0
- package/packages/extensions/desktop/waterplane-plugin.json +24 -0
- package/packages/host/dist/alwayson.js +1 -1
- package/packages/host/dist/alwayson.js.map +1 -1
- package/packages/host/dist/attentionstore.d.ts +4 -0
- package/packages/host/dist/attentionstore.js +17 -0
- package/packages/host/dist/attentionstore.js.map +1 -1
- package/packages/host/dist/auth.d.ts +28 -0
- package/packages/host/dist/auth.js +65 -2
- package/packages/host/dist/auth.js.map +1 -1
- package/packages/host/dist/cli/pair.d.ts +4 -0
- package/packages/host/dist/cli/pair.js +18 -5
- package/packages/host/dist/cli/pair.js.map +1 -1
- package/packages/host/dist/der.d.ts +19 -0
- package/packages/host/dist/der.js +51 -0
- package/packages/host/dist/der.js.map +1 -0
- package/packages/host/dist/doctor.d.ts +2 -0
- package/packages/host/dist/doctor.js +10 -2
- package/packages/host/dist/doctor.js.map +1 -1
- package/packages/host/dist/extensions/desktop/host/auto.d.mts +71 -0
- package/packages/host/dist/extensions/desktop/host/auto.mjs +114 -0
- package/packages/host/dist/extensions/desktop/host/auto.mjs.map +1 -0
- package/packages/host/dist/extensions/desktop/host/desktop.d.mts +51 -0
- package/packages/host/dist/extensions/desktop/host/desktop.mjs +113 -0
- package/packages/host/dist/extensions/desktop/host/desktop.mjs.map +1 -0
- package/packages/host/dist/extensions/desktop/host/helperbin.d.mts +71 -0
- package/packages/host/dist/extensions/desktop/host/helperbin.mjs +160 -0
- package/packages/host/dist/extensions/desktop/host/helperbin.mjs.map +1 -0
- package/packages/host/dist/extensions/desktop/host/index.d.mts +4 -0
- package/packages/host/dist/extensions/desktop/host/index.mjs +88 -0
- package/packages/host/dist/extensions/desktop/host/index.mjs.map +1 -0
- package/packages/host/dist/extensions/desktop/host/knobs.d.mts +52 -0
- package/packages/host/dist/extensions/desktop/host/knobs.mjs +81 -0
- package/packages/host/dist/extensions/desktop/host/knobs.mjs.map +1 -0
- package/packages/host/dist/extensions/desktop/host/probe.d.mts +57 -0
- package/packages/host/dist/extensions/desktop/host/probe.mjs +119 -0
- package/packages/host/dist/extensions/desktop/host/probe.mjs.map +1 -0
- package/packages/host/dist/extensions/desktop/host/records.d.mts +40 -0
- package/packages/host/dist/extensions/desktop/host/records.mjs +101 -0
- package/packages/host/dist/extensions/desktop/host/records.mjs.map +1 -0
- package/packages/host/dist/extensions/desktop/host/session.d.mts +86 -0
- package/packages/host/dist/extensions/desktop/host/session.mjs +318 -0
- package/packages/host/dist/extensions/desktop/host/session.mjs.map +1 -0
- package/packages/host/dist/extensions/desktop/host/setup.d.mts +28 -0
- package/packages/host/dist/extensions/desktop/host/setup.mjs +217 -0
- package/packages/host/dist/extensions/desktop/host/setup.mjs.map +1 -0
- package/packages/host/dist/extensions/desktop/host/status.d.mts +27 -0
- package/packages/host/dist/extensions/desktop/host/status.mjs +62 -0
- package/packages/host/dist/extensions/desktop/host/status.mjs.map +1 -0
- package/packages/host/dist/extensions/desktop/shared/wire.d.mts +206 -0
- package/packages/host/dist/extensions/desktop/shared/wire.mjs +174 -0
- package/packages/host/dist/extensions/desktop/shared/wire.mjs.map +1 -0
- package/packages/host/dist/git.d.ts +23 -6
- package/packages/host/dist/git.js +54 -26
- package/packages/host/dist/git.js.map +1 -1
- package/packages/host/dist/gitfetchfail.d.ts +24 -0
- package/packages/host/dist/gitfetchfail.js +62 -0
- package/packages/host/dist/gitfetchfail.js.map +1 -0
- package/packages/host/dist/gitlog.d.ts +4 -0
- package/packages/host/dist/gitlog.js +8 -3
- package/packages/host/dist/gitlog.js.map +1 -1
- package/packages/host/dist/gitnotify.d.ts +7 -1
- package/packages/host/dist/gitnotify.js +61 -1
- package/packages/host/dist/gitnotify.js.map +1 -1
- package/packages/host/dist/host.js +5 -1
- package/packages/host/dist/host.js.map +1 -1
- package/packages/host/dist/hoststore.js +4 -4
- package/packages/host/dist/hoststore.js.map +1 -1
- package/packages/host/dist/lantls.d.ts +145 -0
- package/packages/host/dist/lantls.js +337 -0
- package/packages/host/dist/lantls.js.map +1 -0
- package/packages/host/dist/main.js +7 -2
- package/packages/host/dist/main.js.map +1 -1
- package/packages/host/dist/net.d.ts +7 -0
- package/packages/host/dist/net.js +9 -0
- package/packages/host/dist/net.js.map +1 -1
- package/packages/host/dist/origin.d.ts +4 -0
- package/packages/host/dist/origin.js +6 -0
- package/packages/host/dist/origin.js.map +1 -1
- package/packages/host/dist/pair.d.ts +54 -5
- package/packages/host/dist/pair.js +139 -34
- package/packages/host/dist/pair.js.map +1 -1
- package/packages/host/dist/pairpake.d.ts +58 -0
- package/packages/host/dist/pairpake.js +208 -0
- package/packages/host/dist/pairpake.js.map +1 -0
- package/packages/host/dist/passive.d.ts +2 -0
- package/packages/host/dist/passive.js +4 -0
- package/packages/host/dist/passive.js.map +1 -1
- package/packages/host/dist/peers/client.d.ts +3 -1
- package/packages/host/dist/peers/client.js +4 -0
- package/packages/host/dist/peers/client.js.map +1 -1
- package/packages/host/dist/peers/routes.d.ts +6 -1
- package/packages/host/dist/peers/routes.js +39 -0
- package/packages/host/dist/peers/routes.js.map +1 -1
- package/packages/host/dist/plugins/pluginapi.js +13 -0
- package/packages/host/dist/plugins/pluginapi.js.map +1 -1
- package/packages/host/dist/plugins/pluginregistry.d.ts +6 -0
- package/packages/host/dist/plugins/pluginregistry.js.map +1 -1
- package/packages/host/dist/prepushcheck.d.ts +34 -0
- package/packages/host/dist/prepushcheck.js +157 -0
- package/packages/host/dist/prepushcheck.js.map +1 -0
- package/packages/host/dist/prs.js +2 -1
- package/packages/host/dist/prs.js.map +1 -1
- package/packages/host/dist/routes/apigitcheck.d.ts +2 -0
- package/packages/host/dist/routes/apigitcheck.js +29 -0
- package/packages/host/dist/routes/apigitcheck.js.map +1 -0
- package/packages/host/dist/routes/apihost.js +2 -0
- package/packages/host/dist/routes/apihost.js.map +1 -1
- package/packages/host/dist/routes/apiio.js +2 -1
- package/packages/host/dist/routes/apiio.js.map +1 -1
- package/packages/host/dist/routes/apirepofiles.js +2 -1
- package/packages/host/dist/routes/apirepofiles.js.map +1 -1
- package/packages/host/dist/routes/apisystem.js +21 -3
- package/packages/host/dist/routes/apisystem.js.map +1 -1
- package/packages/host/dist/server/http.js +31 -5
- package/packages/host/dist/server/http.js.map +1 -1
- package/packages/host/dist/server/plugins.d.ts +13 -0
- package/packages/host/dist/server/plugins.js +26 -0
- package/packages/host/dist/server/plugins.js.map +1 -1
- package/packages/host/dist/server/wsops.js +3 -1
- package/packages/host/dist/server/wsops.js.map +1 -1
- package/packages/host/dist/sysstat.js +10 -1
- package/packages/host/dist/sysstat.js.map +1 -1
- package/packages/host/dist/updatekey.d.ts +22 -0
- package/packages/host/dist/updatekey.js +140 -0
- package/packages/host/dist/updatekey.js.map +1 -0
- package/packages/host/dist/webbundle.d.ts +15 -6
- package/packages/host/dist/webbundle.js +44 -7
- package/packages/host/dist/webbundle.js.map +1 -1
- package/packages/host/dist/wincpu.d.ts +13 -0
- package/packages/host/dist/wincpu.js +101 -0
- package/packages/host/dist/wincpu.js.map +1 -0
- package/packages/host/package.json +2 -1
- package/packages/web/dist/assets/demo-BLBaz4G_.js +26 -0
- package/packages/web/dist/assets/icons-D8RlmSLR.js +1 -0
- package/packages/web/dist/assets/index-BrvcQFBj.js +170 -0
- package/packages/web/dist/assets/index-Dx3VHnr3.css +1 -0
- package/packages/web/dist/assets/noble-DV-r0OkE.js +1 -0
- package/packages/web/dist/assets/{react-CoSyT_xY.js → react-CjmvHigY.js} +2 -2
- package/packages/web/dist/assets/rolldown-runtime-4tVxZq34.js +1 -0
- package/packages/web/dist/assets/shared-BsWIJLE-.js +51 -0
- package/packages/web/dist/assets/sheets-C7CXUU6R.js +250 -0
- package/packages/web/dist/assets/xterm-BHsKrDzh.js +16 -0
- package/packages/web/dist/build-id.txt +1 -1
- package/packages/web/dist/buildstamp.json +1 -1
- package/packages/web/dist/index.html +9 -9
- package/packages/web/dist/sw.js +2 -2
- package/release.json +7 -3
- package/packages/web/dist/assets/demo-DkCtg-SJ.js +0 -26
- package/packages/web/dist/assets/icons-B48F-4nA.js +0 -1
- package/packages/web/dist/assets/index-CpdyS6Xe.css +0 -1
- package/packages/web/dist/assets/index-EYc28CfH.js +0 -170
- package/packages/web/dist/assets/rolldown-runtime-BpQH8Ho1.js +0 -1
- package/packages/web/dist/assets/shared-DjRRLGfw.js +0 -50
- package/packages/web/dist/assets/sheets-ByWNPa9p.js +0 -248
- 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
|
+
}
|