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
|
@@ -59,6 +59,11 @@ export interface GitStatus {
|
|
|
59
59
|
upstream?: string;
|
|
60
60
|
ahead: number;
|
|
61
61
|
behind: number;
|
|
62
|
+
/** When this worktree last fetched (FETCH_HEAD's mtime, epoch ms) — how old `behind` is: git
|
|
63
|
+
* counts against the remote-tracking ref, which only a fetch moves, so a repo nobody fetched
|
|
64
|
+
* for a month read ↓0 while 30 commits waited (2026-11-03). ABSENT = never fetched from this
|
|
65
|
+
* worktree, no upstream, or an older host. The phone cuts staleness off THIS, never off `behind`. */
|
|
66
|
+
fetchedAt?: number;
|
|
62
67
|
files: GitFile[];
|
|
63
68
|
clean: boolean;
|
|
64
69
|
/** What the remote's own rules demand of the default branch. ABSENT = direct
|
|
@@ -72,6 +77,14 @@ export interface GitCommit {
|
|
|
72
77
|
author: string;
|
|
73
78
|
at: number;
|
|
74
79
|
subject: string;
|
|
80
|
+
/** Outgoing commits only: what the pre-push checks last said about exactly this commit. */
|
|
81
|
+
check?: GitCheck;
|
|
82
|
+
}
|
|
83
|
+
/** One run of the pre-push hook over one commit (host `prepushcheck.ts`). Information only: the
|
|
84
|
+
* real push still runs the hook. */
|
|
85
|
+
export interface GitCheck {
|
|
86
|
+
ok: boolean;
|
|
87
|
+
at: number;
|
|
75
88
|
}
|
|
76
89
|
/**
|
|
77
90
|
* The branch's commits against its upstream, in three disjoint lists:
|
|
@@ -91,6 +104,12 @@ export interface GitLog {
|
|
|
91
104
|
outgoingTotal: number;
|
|
92
105
|
shared: GitCommit[];
|
|
93
106
|
fetchedAt?: number;
|
|
107
|
+
/** There is a pre-push hook to run; absent = none. `testsCommit`: it tests the commit it is
|
|
108
|
+
* given, so any outgoing commit can be checked and pushed up to. Otherwise it tests the working
|
|
109
|
+
* tree and only the newest commit can be checked. */
|
|
110
|
+
prePush?: {
|
|
111
|
+
testsCommit: boolean;
|
|
112
|
+
};
|
|
94
113
|
}
|
|
95
114
|
/**
|
|
96
115
|
* The push the desktop is running (or last ran) in one worktree. A push runs
|
|
@@ -111,6 +130,8 @@ export interface GitPushState {
|
|
|
111
130
|
};
|
|
112
131
|
/** What the push has printed so far — absent from a host that does not stream it. */
|
|
113
132
|
progress?: GitPushProgress;
|
|
133
|
+
/** This job runs the hook alone and pushes nothing. */
|
|
134
|
+
check?: boolean;
|
|
114
135
|
}
|
|
115
136
|
/**
|
|
116
137
|
* A push's output as it streams, read by the host (`pushprogress.ts`): the
|
|
@@ -180,6 +201,9 @@ export interface RepoTarget {
|
|
|
180
201
|
* the branch has no upstream (nothing to count against), never 0 for that. */
|
|
181
202
|
ahead?: number;
|
|
182
203
|
behind?: number;
|
|
204
|
+
/** When this worktree last fetched (FETCH_HEAD's mtime) — how old `behind` is; same field and
|
|
205
|
+
* rule as `GitStatus.fetchedAt`. ABSENT = never fetched here, no upstream, or an older host. */
|
|
206
|
+
fetchedAt?: number;
|
|
183
207
|
/** Whether the repo has an `origin` remote — where the git view pushes. `false`
|
|
184
208
|
* = local only, nothing to push to (the launcher says so). ABSENT = not known
|
|
185
209
|
* (an older host, or a count not taken yet) — never drawn as "no remote". */
|
|
@@ -187,7 +211,7 @@ export interface RepoTarget {
|
|
|
187
211
|
/** A git job the desktop is running in this worktree right now — a push (its
|
|
188
212
|
* pre-push checks take minutes) or a pull — so the launcher's git button can
|
|
189
213
|
* spin while it runs. ABSENT = none running (or an older host). */
|
|
190
|
-
gitRunning?: "push" | "pull";
|
|
214
|
+
gitRunning?: "push" | "pull" | "check";
|
|
191
215
|
/** The last pull or push of a verb here FAILED and nobody has handled it —
|
|
192
216
|
* derived on the host from its attention store with `needsYou`, so this
|
|
193
217
|
* chip, the bell and the feed row are one predicate. `id` is the feed
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"repo.js","sourceRoot":"","sources":["../../src/wire/repo.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"repo.js","sourceRoot":"","sources":["../../src/wire/repo.ts"],"names":[],"mappings":"AAycA;sEACsE;AACtE,MAAM,CAAC,MAAM,eAAe,GAAsB,CAAC,gBAAgB,EAAE,cAAc,EAAE,aAAa,EAAE,UAAU,EAAE,mBAAmB,CAAC,CAAC;AAErI;;;;;;;;;;;;;;;;;GAiBG;AACH,MAAM,UAAU,cAAc,CAAC,CAAkB;IAC/C,OAAO,CAAC,CAAC,KAAK,KAAK,MAAM,IAAI,CAAC,CAAC,MAAM,KAAK,MAAM,IAAI,CAAC,CAAC,CAAC,QAAQ,IAAI,CAAC,CAAC,CAAC,OAAO,IAAI,CAAC,CAAC,CAAC,QAAQ,CAAC;AAC/F,CAAC;AAED;;;;;;GAMG;AACH,MAAM,UAAU,eAAe,CAAC,CAAkB;IAChD,OAAO,CAAC,CAAC,KAAK,KAAK,MAAM,IAAI,CAAC,CAAC,MAAM,KAAK,MAAM,IAAI,CAAC,CAAC,CAAC,CAAC,QAAQ,IAAI,CAAC,CAAC,CAAC,OAAO,IAAI,CAAC,CAAC,CAAC,QAAQ,CAAC;AAChG,CAAC"}
|
|
@@ -61,6 +61,13 @@ export interface CpuBusy {
|
|
|
61
61
|
/** How long the split covers, ms. A number, because a "37%" over four
|
|
62
62
|
* seconds and over four minutes are different claims. */
|
|
63
63
|
spanMs: number;
|
|
64
|
+
/** Where the reading came from, when not the host OS itself: `windows` =
|
|
65
|
+
* the Windows-wide figure read through WSL interop; `wsl-vm` = only the
|
|
66
|
+
* WSL2 VM's own load (interop unavailable), so it under-reports the PC. */
|
|
67
|
+
source?: "windows" | "wsl-vm";
|
|
68
|
+
/** `false` when only the total is known: `userPct` then holds total busy,
|
|
69
|
+
* `sysPct` is 0 and `cores` is empty. Absent means a real split. */
|
|
70
|
+
splitKnown?: false;
|
|
64
71
|
}
|
|
65
72
|
/**
|
|
66
73
|
* Where the memory went.
|
|
@@ -238,6 +238,9 @@ export interface HostStat {
|
|
|
238
238
|
loadavg: [number, number, number];
|
|
239
239
|
/** 1-min load as a percent of cores (loadavg[0] / cores * 100), rounded. */
|
|
240
240
|
cpuPct: number;
|
|
241
|
+
/** `windows` when `cpuPct` is the Windows-wide figure read through WSL interop
|
|
242
|
+
* (the load average inside the VM under-reports the PC); absent otherwise. */
|
|
243
|
+
cpuSource?: "windows";
|
|
241
244
|
/** Total / free physical memory, bytes. */
|
|
242
245
|
memTotal: number;
|
|
243
246
|
memFree: number;
|
package/npm-shrinkwrap.json
CHANGED
|
@@ -1,14 +1,15 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "waterplane",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.18",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "waterplane",
|
|
9
|
-
"version": "0.1.
|
|
9
|
+
"version": "0.1.18",
|
|
10
10
|
"license": "MIT",
|
|
11
11
|
"dependencies": {
|
|
12
|
+
"@noble/curves": "2.4.0",
|
|
12
13
|
"@xterm/headless": "5.5.0",
|
|
13
14
|
"node-pty": "1.2.0-beta.15",
|
|
14
15
|
"web-push": "3.6.7",
|
|
@@ -29,6 +30,33 @@
|
|
|
29
30
|
"version": "0.1.4",
|
|
30
31
|
"inBundle": true
|
|
31
32
|
},
|
|
33
|
+
"node_modules/@noble/curves": {
|
|
34
|
+
"version": "2.4.0",
|
|
35
|
+
"resolved": "https://registry.npmjs.org/@noble/curves/-/curves-2.4.0.tgz",
|
|
36
|
+
"integrity": "sha512-P4/62zrgfH33CneE3Dn4WhJVA22YUU0eR51wKIan4NVRvwsA0YnPTwWGpNbpuacSujmSFLvyzpyuR30+fbq2Ew==",
|
|
37
|
+
"license": "MIT",
|
|
38
|
+
"dependencies": {
|
|
39
|
+
"@noble/hashes": "2.4.0"
|
|
40
|
+
},
|
|
41
|
+
"engines": {
|
|
42
|
+
"node": ">= 20.19.0"
|
|
43
|
+
},
|
|
44
|
+
"funding": {
|
|
45
|
+
"url": "https://paulmillr.com/funding/"
|
|
46
|
+
}
|
|
47
|
+
},
|
|
48
|
+
"node_modules/@noble/hashes": {
|
|
49
|
+
"version": "2.4.0",
|
|
50
|
+
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.4.0.tgz",
|
|
51
|
+
"integrity": "sha512-X5XaVWZIBCT7HHZGm5I7ZQXDwLG+bGXuSrMQAW+7Zvl87h1kmc1ZB1VSRJcpUfoUrGQp4Fkoxm5kZ+Ms+aW+eA==",
|
|
52
|
+
"license": "MIT",
|
|
53
|
+
"engines": {
|
|
54
|
+
"node": ">= 20.19.0"
|
|
55
|
+
},
|
|
56
|
+
"funding": {
|
|
57
|
+
"url": "https://paulmillr.com/funding/"
|
|
58
|
+
}
|
|
59
|
+
},
|
|
32
60
|
"node_modules/@xterm/headless": {
|
|
33
61
|
"version": "5.5.0",
|
|
34
62
|
"resolved": "https://registry.npmjs.org/@xterm/headless/-/headless-5.5.0.tgz",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "waterplane",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.18",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "waterplane — phone-native remote control for Claude Code on your PC",
|
|
6
6
|
"license": "MIT",
|
|
@@ -30,6 +30,7 @@
|
|
|
30
30
|
"node": ">=22.12"
|
|
31
31
|
},
|
|
32
32
|
"dependencies": {
|
|
33
|
+
"@noble/curves": "2.4.0",
|
|
33
34
|
"@xterm/headless": "5.5.0",
|
|
34
35
|
"node-pty": "1.2.0-beta.15",
|
|
35
36
|
"web-push": "3.6.7",
|
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
// Capture: ScreenCaptureKit on one display, one window, or the frontmost
|
|
2
|
+
// app's window (re-aimed at ≤1 Hz). SCK delivers a frame only when pixels
|
|
3
|
+
// change (SCFrameStatus.complete); a still screen sends .idle and costs nothing.
|
|
4
|
+
//
|
|
5
|
+
// SAFETY: nothing in this file runs unless CGPreflightScreenCaptureAccess()
|
|
6
|
+
// already says yes, so no code path here can raise the Screen Recording
|
|
7
|
+
// prompt. The server checks too; `start()` checks again.
|
|
8
|
+
|
|
9
|
+
import CoreGraphics
|
|
10
|
+
import CoreMedia
|
|
11
|
+
import Foundation
|
|
12
|
+
@preconcurrency import ScreenCaptureKit
|
|
13
|
+
|
|
14
|
+
private struct CaptureError: Error {
|
|
15
|
+
let sentence: String
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
final class ScreenSource: NSObject, FrameSource, SCStreamOutput, SCStreamDelegate, @unchecked Sendable {
|
|
19
|
+
private let q: DispatchQueue
|
|
20
|
+
private var p: Params
|
|
21
|
+
private let cb: SourceCallbacks
|
|
22
|
+
private var stream: SCStream?
|
|
23
|
+
private var geo: Geometry?
|
|
24
|
+
private var windowID: CGWindowID?
|
|
25
|
+
private var followTimer: DispatchSourceTimer?
|
|
26
|
+
private var stopped = false
|
|
27
|
+
private var retargeting = false
|
|
28
|
+
|
|
29
|
+
init(queue: DispatchQueue, params: Params, callbacks: SourceCallbacks) {
|
|
30
|
+
q = queue
|
|
31
|
+
p = params
|
|
32
|
+
cb = callbacks
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// MARK: lifecycle (all on q)
|
|
36
|
+
|
|
37
|
+
func start() {
|
|
38
|
+
guard screenRecordingGranted() else {
|
|
39
|
+
cb.onError(screenRecordingSentence)
|
|
40
|
+
return
|
|
41
|
+
}
|
|
42
|
+
let want = p
|
|
43
|
+
Task {
|
|
44
|
+
do {
|
|
45
|
+
let content = try await SCShareableContent.excludingDesktopWindows(false, onScreenWindowsOnly: true)
|
|
46
|
+
let (filter, g, wid) = try self.resolve(content, want)
|
|
47
|
+
let s = SCStream(filter: filter, configuration: self.config(g, want), delegate: self)
|
|
48
|
+
try s.addStreamOutput(self, type: .screen, sampleHandlerQueue: self.q)
|
|
49
|
+
try await s.startCapture()
|
|
50
|
+
self.q.async {
|
|
51
|
+
if self.stopped {
|
|
52
|
+
s.stopCapture { _ in }
|
|
53
|
+
return
|
|
54
|
+
}
|
|
55
|
+
self.stream = s
|
|
56
|
+
self.windowID = wid
|
|
57
|
+
self.setGeometry(g)
|
|
58
|
+
if self.p != want { self.update(self.p) } // params moved on while we were starting
|
|
59
|
+
if wid != nil { self.startFollowing() }
|
|
60
|
+
}
|
|
61
|
+
} catch {
|
|
62
|
+
self.q.async { self.cb.onError(Self.sentence(for: error)) }
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
func update(_ np: Params) {
|
|
68
|
+
let old = p
|
|
69
|
+
p = np
|
|
70
|
+
guard let s = stream, !retargeting else { return }
|
|
71
|
+
if case .window(let a) = old.source, case .window(let b) = np.source, a != b {
|
|
72
|
+
retarget(to: b)
|
|
73
|
+
return
|
|
74
|
+
}
|
|
75
|
+
guard let g = geo else { return }
|
|
76
|
+
let ng = geometry(srcWidth: g.srcWidth, srcHeight: g.srcHeight, bounds: g.globalBounds)
|
|
77
|
+
s.updateConfiguration(config(ng, np)) { err in
|
|
78
|
+
if let err { self.q.async { self.cb.onError("capture could not change settings: \(err.localizedDescription)") } }
|
|
79
|
+
}
|
|
80
|
+
setGeometry(ng)
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
func stop() {
|
|
84
|
+
stopped = true
|
|
85
|
+
followTimer?.cancel()
|
|
86
|
+
followTimer = nil
|
|
87
|
+
stream?.stopCapture { _ in }
|
|
88
|
+
stream = nil
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// MARK: SCK callbacks
|
|
92
|
+
|
|
93
|
+
func stream(_ stream: SCStream, didOutputSampleBuffer sb: CMSampleBuffer, of type: SCStreamOutputType) {
|
|
94
|
+
guard type == .screen, sb.isValid,
|
|
95
|
+
let atts = CMSampleBufferGetSampleAttachmentsArray(sb, createIfNecessary: false) as? [[SCStreamFrameInfo: Any]],
|
|
96
|
+
let raw = atts.first?[.status] as? Int, let status = SCFrameStatus(rawValue: raw)
|
|
97
|
+
else { return }
|
|
98
|
+
switch status {
|
|
99
|
+
case .complete:
|
|
100
|
+
guard let pb = CMSampleBufferGetImageBuffer(sb) else { return }
|
|
101
|
+
let t = CMSampleBufferGetPresentationTimeStamp(sb)
|
|
102
|
+
let us = t.isValid && t.seconds > 0 ? UInt64(t.seconds * 1e6) : nowMicros()
|
|
103
|
+
cb.onFrame(pb, us)
|
|
104
|
+
case .idle:
|
|
105
|
+
cb.onIdle()
|
|
106
|
+
default:
|
|
107
|
+
break
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
func stream(_ stream: SCStream, didStopWithError error: Error) {
|
|
112
|
+
q.async {
|
|
113
|
+
if self.stopped { return }
|
|
114
|
+
self.cb.onError("capture stopped: " + Self.sentence(for: error))
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// MARK: geometry
|
|
119
|
+
|
|
120
|
+
private func geometry(srcWidth: Int, srcHeight: Int, bounds: CGRect) -> Geometry {
|
|
121
|
+
let (w, h) = outputSize(srcW: srcWidth, srcH: srcHeight, crop: p.crop, maxWidth: p.maxWidth, codec: p.codec)
|
|
122
|
+
return Geometry(srcWidth: srcWidth, srcHeight: srcHeight, globalBounds: bounds, outWidth: w, outHeight: h)
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
private func setGeometry(_ g: Geometry) {
|
|
126
|
+
if g != geo {
|
|
127
|
+
geo = g
|
|
128
|
+
cb.onGeometry(g)
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
private func resolve(_ content: SCShareableContent, _ want: Params) throws -> (SCContentFilter, Geometry, CGWindowID?) {
|
|
133
|
+
switch want.source {
|
|
134
|
+
case .display(let id):
|
|
135
|
+
let did = id ?? CGMainDisplayID()
|
|
136
|
+
guard let d = content.displays.first(where: { $0.displayID == did }) else {
|
|
137
|
+
throw CaptureError(sentence: "Display \(did) is not active on the Mac.")
|
|
138
|
+
}
|
|
139
|
+
let mode = CGDisplayCopyDisplayMode(did)
|
|
140
|
+
let filter = SCContentFilter(display: d, excludingWindows: [])
|
|
141
|
+
let g = geometry(srcWidth: mode?.pixelWidth ?? d.width, srcHeight: mode?.pixelHeight ?? d.height,
|
|
142
|
+
bounds: CGDisplayBounds(did))
|
|
143
|
+
return (filter, g, nil)
|
|
144
|
+
case .window(let wid):
|
|
145
|
+
return try windowFilter(content, wid)
|
|
146
|
+
case .front:
|
|
147
|
+
guard let (wid, _) = windowBounds(nil) else { throw CaptureError(sentence: "No window is in front on the Mac.") }
|
|
148
|
+
return try windowFilter(content, wid)
|
|
149
|
+
case .synthetic:
|
|
150
|
+
throw CaptureError(sentence: "internal: the synthetic source is not a screen capture")
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
private func windowFilter(_ content: SCShareableContent, _ wid: CGWindowID) throws -> (SCContentFilter, Geometry, CGWindowID?) {
|
|
155
|
+
guard let w = content.windows.first(where: { $0.windowID == wid }) else {
|
|
156
|
+
throw CaptureError(sentence: "Window \(wid) is not on screen any more.")
|
|
157
|
+
}
|
|
158
|
+
let filter = SCContentFilter(desktopIndependentWindow: w)
|
|
159
|
+
let scale = Double(filter.pointPixelScale)
|
|
160
|
+
let bounds = windowBounds(wid)?.1 ?? w.frame
|
|
161
|
+
let g = geometry(srcWidth: Int(bounds.width * scale), srcHeight: Int(bounds.height * scale), bounds: bounds)
|
|
162
|
+
return (filter, g, wid)
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
private func config(_ g: Geometry, _ p: Params) -> SCStreamConfiguration {
|
|
166
|
+
let c = SCStreamConfiguration()
|
|
167
|
+
c.width = g.outWidth
|
|
168
|
+
c.height = g.outHeight
|
|
169
|
+
c.minimumFrameInterval = CMTime(value: 1, timescale: CMTimeScale(p.fps))
|
|
170
|
+
c.queueDepth = 3
|
|
171
|
+
c.showsCursor = p.cursor
|
|
172
|
+
c.capturesAudio = false
|
|
173
|
+
c.pixelFormat = pixelFormat(for: p.codec)
|
|
174
|
+
if p.codec == .h264 { c.colorMatrix = CGDisplayStream.yCbCrMatrix_ITU_R_709_2 }
|
|
175
|
+
// sourceRect is in points of the source (display-local or window-local).
|
|
176
|
+
let pw = g.globalBounds.width, ph = g.globalBounds.height
|
|
177
|
+
c.sourceRect = CGRect(x: p.crop.x * pw, y: p.crop.y * ph, width: p.crop.w * pw, height: p.crop.h * ph)
|
|
178
|
+
c.scalesToFit = true
|
|
179
|
+
return c
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// MARK: following a window / the front window (≤1 Hz)
|
|
183
|
+
|
|
184
|
+
private func startFollowing() {
|
|
185
|
+
followTimer?.cancel()
|
|
186
|
+
let t = DispatchSource.makeTimerSource(queue: q)
|
|
187
|
+
t.schedule(deadline: .now() + 1, repeating: 1, leeway: .milliseconds(100))
|
|
188
|
+
t.setEventHandler { [weak self] in self?.follow() }
|
|
189
|
+
t.resume()
|
|
190
|
+
followTimer = t
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
private func follow() {
|
|
194
|
+
guard stream != nil, !retargeting, let g = geo else { return }
|
|
195
|
+
if case .front = p.source, let (wid, _) = windowBounds(nil), wid != windowID {
|
|
196
|
+
retarget(to: wid)
|
|
197
|
+
return
|
|
198
|
+
}
|
|
199
|
+
guard let wid = windowID else { return }
|
|
200
|
+
guard let (_, rect) = windowBounds(wid) else {
|
|
201
|
+
if case .front = p.source { return } // the next poll picks the new front window
|
|
202
|
+
cb.onError("The captured window was closed.")
|
|
203
|
+
stop()
|
|
204
|
+
return
|
|
205
|
+
}
|
|
206
|
+
if rect != g.globalBounds {
|
|
207
|
+
let scale = g.globalBounds.width > 0 ? Double(g.srcWidth) / g.globalBounds.width : 2
|
|
208
|
+
let ng = geometry(srcWidth: Int(rect.width * scale), srcHeight: Int(rect.height * scale), bounds: rect)
|
|
209
|
+
stream?.updateConfiguration(config(ng, p)) { _ in }
|
|
210
|
+
setGeometry(ng)
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
private func retarget(to wid: CGWindowID) {
|
|
215
|
+
guard let s = stream else { return }
|
|
216
|
+
retargeting = true
|
|
217
|
+
let want = p
|
|
218
|
+
Task {
|
|
219
|
+
do {
|
|
220
|
+
let content = try await SCShareableContent.excludingDesktopWindows(false, onScreenWindowsOnly: true)
|
|
221
|
+
let (filter, g, _) = try self.windowFilter(content, wid)
|
|
222
|
+
try await s.updateContentFilter(filter)
|
|
223
|
+
try await s.updateConfiguration(self.config(g, want))
|
|
224
|
+
self.q.async {
|
|
225
|
+
self.retargeting = false
|
|
226
|
+
self.windowID = wid
|
|
227
|
+
self.setGeometry(g)
|
|
228
|
+
log("capture: now following window \(wid)")
|
|
229
|
+
}
|
|
230
|
+
} catch {
|
|
231
|
+
self.q.async {
|
|
232
|
+
self.retargeting = false
|
|
233
|
+
self.cb.onError(Self.sentence(for: error))
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
private static func sentence(for error: Error) -> String {
|
|
240
|
+
if let e = error as? CaptureError { return e.sentence }
|
|
241
|
+
let ns = error as NSError
|
|
242
|
+
if ns.domain == SCStreamErrorDomain && ns.code == SCStreamError.userDeclined.rawValue { return screenRecordingSentence }
|
|
243
|
+
return ns.localizedDescription
|
|
244
|
+
}
|
|
245
|
+
}
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
// H.264 through VideoToolbox: hardware, real-time, no B-frames, Annex-B out
|
|
2
|
+
// with SPS+PPS in front of every keyframe, so any record that carries the key
|
|
3
|
+
// flag is a complete entry point for a decoder that just joined.
|
|
4
|
+
|
|
5
|
+
import CoreMedia
|
|
6
|
+
import CoreVideo
|
|
7
|
+
import Foundation
|
|
8
|
+
import VideoToolbox
|
|
9
|
+
|
|
10
|
+
struct EncodedFrame {
|
|
11
|
+
let data: Data
|
|
12
|
+
let key: Bool
|
|
13
|
+
let pts: UInt64
|
|
14
|
+
/// Submit → output, µs. For JPEG this is the whole synchronous encode.
|
|
15
|
+
let encodeMicros: UInt64
|
|
16
|
+
let kind: RecordKind
|
|
17
|
+
/// `avc1.PPCCLL` from the SPS, on H.264 keyframes only.
|
|
18
|
+
let avc: String?
|
|
19
|
+
/// The region the frame was submitted for, carried through the encoder so late output still
|
|
20
|
+
/// says which config produced it.
|
|
21
|
+
let region: Region
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
protocol FrameEncoder: AnyObject {
|
|
25
|
+
var width: Int { get }
|
|
26
|
+
var height: Int { get }
|
|
27
|
+
var codec: Codec { get }
|
|
28
|
+
/// Frames submitted and not yet out. The server drops new frames past a few.
|
|
29
|
+
var inFlight: Int { get }
|
|
30
|
+
func encode(_ pb: CVPixelBuffer, pts: UInt64, forceKey: Bool, region: Region)
|
|
31
|
+
/// Same size and codec, new knobs (bitrate, fps, keyframe interval, quality…).
|
|
32
|
+
func update(_ p: Params)
|
|
33
|
+
func setBitrate(_ kbps: Int)
|
|
34
|
+
func invalidate()
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
final class H264Encoder: FrameEncoder {
|
|
38
|
+
let width: Int
|
|
39
|
+
let height: Int
|
|
40
|
+
let codec = Codec.h264
|
|
41
|
+
private(set) var profile = "main"
|
|
42
|
+
private(set) var hardware = false
|
|
43
|
+
private var session: VTCompressionSession?
|
|
44
|
+
private let sink: (EncodedFrame) -> Void
|
|
45
|
+
private let lock = NSLock()
|
|
46
|
+
private var submitted: [UInt64: UInt64] = [:]
|
|
47
|
+
private var pending = 0
|
|
48
|
+
private var params: Params
|
|
49
|
+
|
|
50
|
+
var inFlight: Int {
|
|
51
|
+
lock.lock()
|
|
52
|
+
defer { lock.unlock() }
|
|
53
|
+
return pending
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
init?(width: Int, height: Int, params: Params, sink: @escaping (EncodedFrame) -> Void) {
|
|
57
|
+
self.width = width
|
|
58
|
+
self.height = height
|
|
59
|
+
self.params = params
|
|
60
|
+
self.sink = sink
|
|
61
|
+
let spec: [String: Any] = [
|
|
62
|
+
kVTVideoEncoderSpecification_EnableHardwareAcceleratedVideoEncoder as String: true
|
|
63
|
+
]
|
|
64
|
+
var s: VTCompressionSession?
|
|
65
|
+
let st = VTCompressionSessionCreate(
|
|
66
|
+
allocator: nil, width: Int32(width), height: Int32(height), codecType: kCMVideoCodecType_H264,
|
|
67
|
+
encoderSpecification: spec as CFDictionary, imageBufferAttributes: nil, compressedDataAllocator: nil,
|
|
68
|
+
outputCallback: nil, refcon: nil, compressionSessionOut: &s)
|
|
69
|
+
guard st == noErr, let s else {
|
|
70
|
+
log("h264: VTCompressionSessionCreate \(width)x\(height) failed (\(st))")
|
|
71
|
+
return nil
|
|
72
|
+
}
|
|
73
|
+
session = s
|
|
74
|
+
set(kVTCompressionPropertyKey_RealTime, kCFBooleanTrue)
|
|
75
|
+
set(kVTCompressionPropertyKey_AllowFrameReordering, kCFBooleanFalse)
|
|
76
|
+
if set(kVTCompressionPropertyKey_ProfileLevel, kVTProfileLevel_H264_Main_AutoLevel, quiet: true) != noErr {
|
|
77
|
+
set(kVTCompressionPropertyKey_ProfileLevel, kVTProfileLevel_H264_Baseline_AutoLevel)
|
|
78
|
+
profile = "baseline"
|
|
79
|
+
} else {
|
|
80
|
+
set(kVTCompressionPropertyKey_H264EntropyMode, kVTH264EntropyMode_CABAC, quiet: true)
|
|
81
|
+
}
|
|
82
|
+
set(kVTCompressionPropertyKey_MaxFrameDelayCount, 0 as NSNumber, quiet: true)
|
|
83
|
+
set(kVTCompressionPropertyKey_PrioritizeEncodingSpeedOverQuality, kCFBooleanTrue, quiet: true)
|
|
84
|
+
applyRate(params.bitrateKbps)
|
|
85
|
+
applyTiming(params)
|
|
86
|
+
var hw: Unmanaged<CFTypeRef>?
|
|
87
|
+
if VTSessionCopyProperty(s, key: kVTCompressionPropertyKey_UsingHardwareAcceleratedVideoEncoder,
|
|
88
|
+
allocator: nil, valueOut: &hw) == noErr, let b = hw?.takeRetainedValue() as? Bool
|
|
89
|
+
{
|
|
90
|
+
hardware = b
|
|
91
|
+
}
|
|
92
|
+
VTCompressionSessionPrepareToEncodeFrames(s)
|
|
93
|
+
log("h264: \(width)x\(height) \(profile) \(hardware ? "hardware" : "software") \(params.bitrateKbps) kbps \(params.fps) fps")
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
deinit { invalidate() }
|
|
97
|
+
|
|
98
|
+
@discardableResult
|
|
99
|
+
private func set(_ k: CFString, _ v: CFTypeRef?, quiet: Bool = false) -> OSStatus {
|
|
100
|
+
guard let session else { return -1 }
|
|
101
|
+
let st = VTSessionSetProperty(session, key: k, value: v)
|
|
102
|
+
if st != noErr && !quiet { log("h264: setting \(k) failed (\(st))") }
|
|
103
|
+
return st
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
private func applyRate(_ kbps: Int) {
|
|
107
|
+
let bps = kbps * 1000
|
|
108
|
+
set(kVTCompressionPropertyKey_AverageBitRate, bps as NSNumber)
|
|
109
|
+
// Hard ceiling: 1.5× the average over any 1 s window.
|
|
110
|
+
let limits = [NSNumber(value: Double(bps) / 8 * 1.5), NSNumber(value: 1.0)] as CFArray
|
|
111
|
+
set(kVTCompressionPropertyKey_DataRateLimits, limits)
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
private func applyTiming(_ p: Params) {
|
|
115
|
+
set(kVTCompressionPropertyKey_ExpectedFrameRate, p.fps as NSNumber)
|
|
116
|
+
if p.keyframeEverySec > 0 {
|
|
117
|
+
set(kVTCompressionPropertyKey_MaxKeyFrameInterval, max(1, Int((p.keyframeEverySec * Double(p.fps)).rounded())) as NSNumber)
|
|
118
|
+
set(kVTCompressionPropertyKey_MaxKeyFrameIntervalDuration, p.keyframeEverySec as NSNumber)
|
|
119
|
+
} else {
|
|
120
|
+
// On demand only: an interval nobody reaches (a day of frames at 60 fps).
|
|
121
|
+
set(kVTCompressionPropertyKey_MaxKeyFrameInterval, 5_184_000 as NSNumber)
|
|
122
|
+
set(kVTCompressionPropertyKey_MaxKeyFrameIntervalDuration, 86_400 as NSNumber)
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
func update(_ p: Params) {
|
|
127
|
+
if p.bitrateKbps != params.bitrateKbps { applyRate(p.bitrateKbps) }
|
|
128
|
+
if p.fps != params.fps || p.keyframeEverySec != params.keyframeEverySec { applyTiming(p) }
|
|
129
|
+
params = p
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
func setBitrate(_ kbps: Int) {
|
|
133
|
+
params.bitrateKbps = kbps
|
|
134
|
+
applyRate(kbps)
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
func invalidate() {
|
|
138
|
+
if let s = session { VTCompressionSessionInvalidate(s) }
|
|
139
|
+
session = nil
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
func encode(_ pb: CVPixelBuffer, pts: UInt64, forceKey: Bool, region: Region) {
|
|
143
|
+
guard let s = session else { return }
|
|
144
|
+
let t = CMTime(value: CMTimeValue(pts), timescale: 1_000_000)
|
|
145
|
+
let props: CFDictionary? =
|
|
146
|
+
forceKey ? [kVTEncodeFrameOptionKey_ForceKeyFrame as String: true] as CFDictionary : nil
|
|
147
|
+
lock.lock()
|
|
148
|
+
submitted[pts] = nowMicros()
|
|
149
|
+
pending += 1
|
|
150
|
+
lock.unlock()
|
|
151
|
+
let st = VTCompressionSessionEncodeFrame(
|
|
152
|
+
s, imageBuffer: pb, presentationTimeStamp: t, duration: .invalid, frameProperties: props, infoFlagsOut: nil
|
|
153
|
+
) { [weak self] status, flags, sb in
|
|
154
|
+
self?.output(status, flags, sb, pts, region)
|
|
155
|
+
}
|
|
156
|
+
if st != noErr {
|
|
157
|
+
log("h264: encode failed (\(st))")
|
|
158
|
+
lock.lock()
|
|
159
|
+
submitted[pts] = nil
|
|
160
|
+
pending -= 1
|
|
161
|
+
lock.unlock()
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
private func output(_ status: OSStatus, _ flags: VTEncodeInfoFlags, _ sb: CMSampleBuffer?, _ pts: UInt64, _ region: Region) {
|
|
166
|
+
lock.lock()
|
|
167
|
+
let sub = submitted.removeValue(forKey: pts)
|
|
168
|
+
pending -= 1
|
|
169
|
+
lock.unlock()
|
|
170
|
+
guard status == noErr, let sb, let bb = CMSampleBufferGetDataBuffer(sb) else {
|
|
171
|
+
if status != noErr { log("h264: encoder returned \(status)") }
|
|
172
|
+
return
|
|
173
|
+
}
|
|
174
|
+
var key = true
|
|
175
|
+
if let arr = CMSampleBufferGetSampleAttachmentsArray(sb, createIfNecessary: false) as? [[CFString: Any]],
|
|
176
|
+
let a = arr.first, let notSync = a[kCMSampleAttachmentKey_NotSync] as? Bool
|
|
177
|
+
{
|
|
178
|
+
key = !notSync
|
|
179
|
+
}
|
|
180
|
+
let total = CMBlockBufferGetDataLength(bb)
|
|
181
|
+
var avcc = Data(count: total)
|
|
182
|
+
let copied = avcc.withUnsafeMutableBytes { CMBlockBufferCopyDataBytes(bb, atOffset: 0, dataLength: total, destination: $0.baseAddress!) }
|
|
183
|
+
guard copied == kCMBlockBufferNoErr else { return }
|
|
184
|
+
|
|
185
|
+
let startCode: [UInt8] = [0, 0, 0, 1]
|
|
186
|
+
var out = Data(capacity: total + 64)
|
|
187
|
+
var nalLen: Int32 = 4
|
|
188
|
+
var avc: String?
|
|
189
|
+
if let fd = CMSampleBufferGetFormatDescription(sb) {
|
|
190
|
+
var count = 0
|
|
191
|
+
CMVideoFormatDescriptionGetH264ParameterSetAtIndex(
|
|
192
|
+
fd, parameterSetIndex: 0, parameterSetPointerOut: nil, parameterSetSizeOut: nil,
|
|
193
|
+
parameterSetCountOut: &count, nalUnitHeaderLengthOut: &nalLen)
|
|
194
|
+
if key {
|
|
195
|
+
for i in 0..<count {
|
|
196
|
+
var p: UnsafePointer<UInt8>?
|
|
197
|
+
var n = 0
|
|
198
|
+
guard CMVideoFormatDescriptionGetH264ParameterSetAtIndex(
|
|
199
|
+
fd, parameterSetIndex: i, parameterSetPointerOut: &p, parameterSetSizeOut: &n,
|
|
200
|
+
parameterSetCountOut: nil, nalUnitHeaderLengthOut: nil) == noErr, let p
|
|
201
|
+
else { continue }
|
|
202
|
+
out.append(contentsOf: startCode)
|
|
203
|
+
out.append(p, count: n)
|
|
204
|
+
if i == 0 && n >= 4 { avc = String(format: "avc1.%02x%02x%02x", p[1], p[2], p[3]) }
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
// AVCC length prefixes → Annex-B start codes.
|
|
209
|
+
let L = Int(nalLen)
|
|
210
|
+
avcc.withUnsafeBytes { raw in
|
|
211
|
+
let b = raw.bindMemory(to: UInt8.self)
|
|
212
|
+
var off = 0
|
|
213
|
+
while off + L <= total {
|
|
214
|
+
var len = 0
|
|
215
|
+
for i in 0..<L { len = (len << 8) | Int(b[off + i]) }
|
|
216
|
+
off += L
|
|
217
|
+
guard len > 0, off + len <= total else { break }
|
|
218
|
+
out.append(contentsOf: startCode)
|
|
219
|
+
out.append(b.baseAddress! + off, count: len)
|
|
220
|
+
off += len
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
let enc = sub.map { nowMicros() &- $0 } ?? 0
|
|
224
|
+
sink(EncodedFrame(data: out, key: key, pts: pts, encodeMicros: enc, kind: .h264, avc: avc, region: region))
|
|
225
|
+
}
|
|
226
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
<?xml version="1.0" encoding="UTF-8"?>
|
|
2
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
3
|
+
<plist version="1.0">
|
|
4
|
+
<dict>
|
|
5
|
+
<key>CFBundleIdentifier</key>
|
|
6
|
+
<string>@WP_DESKTOP_ID@</string>
|
|
7
|
+
<key>CFBundleName</key>
|
|
8
|
+
<string>wp-desktop</string>
|
|
9
|
+
<key>CFBundleDisplayName</key>
|
|
10
|
+
<string>@WP_DESKTOP_NAME@</string>
|
|
11
|
+
<key>CFBundleShortVersionString</key>
|
|
12
|
+
<string>0.1.0</string>
|
|
13
|
+
<key>CFBundleVersion</key>
|
|
14
|
+
<string>1</string>
|
|
15
|
+
<key>CFBundleInfoDictionaryVersion</key>
|
|
16
|
+
<string>6.0</string>
|
|
17
|
+
<key>LSMinimumSystemVersion</key>
|
|
18
|
+
<string>14.0</string>
|
|
19
|
+
</dict>
|
|
20
|
+
</plist>
|