tmux-ide 2.9.12 → 2.9.13
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/bin/cli.js +3438 -2062
- package/package.json +1 -1
- package/packages/contracts/src/index.ts +2 -0
- package/packages/contracts/src/pane-stream.ts +17 -0
- package/packages/contracts/src/terminal-delivery.ts +2 -1
- package/packages/contracts/src/terminal-graphics-compressed.ts +66 -0
- package/packages/contracts/src/terminal-graphics.ts +5 -0
- package/packages/contracts/src/terminal-surfaces.ts +137 -0
- package/packages/core/src/terminal-delivery.ts +8 -2
- package/packages/core/src/terminal-replica.ts +33 -0
- package/packages/daemon/dist/command-center/resources/saved-machine-route.js +2 -0
- package/packages/daemon/dist/command-center/resources/saved-machine-watch.js +117 -0
- package/packages/daemon/dist/command-center/server.js +13 -0
- package/packages/daemon/dist/command-center/tmux-servers.js +9 -0
- package/packages/daemon/dist/lib/daemon-embed.js +32 -1
- package/packages/daemon/dist/lib/native-surface-context.js +96 -0
- package/packages/daemon/dist/lib/tmux-server-owner.js +9 -0
- package/packages/daemon/dist/native/TmuxIdeNotifier.app/Contents/Info.plist +2 -2
- package/packages/daemon/dist/native/TmuxIdeNotifier.app/Contents/MacOS/tmux-ide-notifier +0 -0
- package/packages/daemon/dist/native/TmuxIdeNotifier.app/Contents/Resources/Assets.car +0 -0
- package/packages/daemon/dist/native/TmuxIdeNotifier.app/Contents/_CodeSignature/CodeResources +2 -2
- package/packages/daemon/dist/server/native-surface-provider-upgrade.js +88 -0
- package/packages/daemon/dist/terminal/graphics/compress-chunk.js +40 -0
- package/packages/daemon/dist/terminal/graphics/native-surface-demand.js +99 -0
- package/packages/daemon/dist/terminal/graphics/native-surface-provider-channel.js +162 -0
- package/packages/daemon/dist/terminal/graphics/native-surface-provider.js +275 -0
- package/packages/daemon/dist/terminal/graphics/native-surface-viewer.js +146 -0
- package/packages/daemon/dist/terminal/mirror/mirror-service.js +3 -0
- package/packages/daemon/dist/terminal/mirror/session-channel.js +32 -9
- package/packages/daemon/dist/terminal/pane-stream/pane-stream-websocket.js +196 -3
- package/packages/daemon/dist/terminal/pane-stream/runtime.js +9 -0
- package/packages/daemon/src/command-center/resources/saved-machine-route.ts +4 -0
- package/packages/daemon/src/command-center/resources/saved-machine-watch.ts +132 -0
- package/packages/daemon/src/command-center/server.ts +18 -0
- package/packages/daemon/src/command-center/tmux-servers.ts +14 -0
- package/packages/daemon/src/lib/daemon-embed.ts +43 -1
- package/packages/daemon/src/lib/native-surface-context.ts +123 -0
- package/packages/daemon/src/lib/tmux-server-owner.ts +9 -0
- package/packages/daemon/src/server/native-surface-provider-upgrade.ts +103 -0
- package/packages/daemon/src/terminal/graphics/compress-chunk.ts +49 -0
- package/packages/daemon/src/terminal/graphics/native-surface-demand.ts +88 -0
- package/packages/daemon/src/terminal/graphics/native-surface-provider-channel.ts +161 -0
- package/packages/daemon/src/terminal/graphics/native-surface-provider.ts +308 -0
- package/packages/daemon/src/terminal/graphics/native-surface-viewer.ts +154 -0
- package/packages/daemon/src/terminal/mirror/mirror-service.ts +5 -0
- package/packages/daemon/src/terminal/mirror/session-channel.ts +31 -10
- package/packages/daemon/src/terminal/pane-stream/pane-stream-websocket.ts +229 -2
- package/packages/daemon/src/terminal/pane-stream/runtime.ts +11 -1
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { isAbsolute } from "node:path";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import { TerminalAttachmentSemanticPaneIdSchemaZ, WorkspaceCatalogLiveSessionIdSchemaZ, } from "@tmux-ide/contracts";
|
|
4
|
+
export const NativeSurfaceLocationSchema = z
|
|
5
|
+
.object({
|
|
6
|
+
socketPath: z
|
|
7
|
+
.string()
|
|
8
|
+
.min(1)
|
|
9
|
+
.max(4096)
|
|
10
|
+
.refine((p) => isAbsolute(p) && !/[\0\r\n\t]/u.test(p)),
|
|
11
|
+
serverPid: z
|
|
12
|
+
.string()
|
|
13
|
+
.max(16)
|
|
14
|
+
.regex(/^[1-9][0-9]*$/u),
|
|
15
|
+
sessionId: z
|
|
16
|
+
.string()
|
|
17
|
+
.max(32)
|
|
18
|
+
.regex(/^\$(?:0|[1-9][0-9]*)$/u),
|
|
19
|
+
runtimePaneId: z
|
|
20
|
+
.string()
|
|
21
|
+
.max(32)
|
|
22
|
+
.regex(/^%(?:0|[1-9][0-9]*)$/u),
|
|
23
|
+
})
|
|
24
|
+
.strict();
|
|
25
|
+
/** Resolve inherited tmux location only through the current generation's pinned owner. */
|
|
26
|
+
export function createNativeSurfaceContextResolver(options) {
|
|
27
|
+
const generation = z.uuid().parse(options.generation);
|
|
28
|
+
return async (input, cancellation) => {
|
|
29
|
+
const target = NativeSurfaceLocationSchema.parse(input);
|
|
30
|
+
const signal = AbortSignal.any([
|
|
31
|
+
AbortSignal.timeout(5000),
|
|
32
|
+
...(cancellation ? [cancellation] : []),
|
|
33
|
+
]);
|
|
34
|
+
const assertCurrent = () => {
|
|
35
|
+
options.assertOpen();
|
|
36
|
+
signal.throwIfAborted();
|
|
37
|
+
};
|
|
38
|
+
const unavailable = () => {
|
|
39
|
+
throw new Error("Native surface source unavailable");
|
|
40
|
+
};
|
|
41
|
+
const observe = async () => {
|
|
42
|
+
assertCurrent();
|
|
43
|
+
const proof = await options.run([
|
|
44
|
+
"display-message",
|
|
45
|
+
"-p",
|
|
46
|
+
"-t",
|
|
47
|
+
target.runtimePaneId,
|
|
48
|
+
"#{socket_path}\t#{pid}\t#{session_id}\t#{pane_id}",
|
|
49
|
+
], signal);
|
|
50
|
+
assertCurrent();
|
|
51
|
+
if (proof.trimEnd() !==
|
|
52
|
+
[target.socketPath, target.serverPid, target.sessionId, target.runtimePaneId].join("\t"))
|
|
53
|
+
unavailable();
|
|
54
|
+
const snapshot = await options.inventory.discoverTerminalInventory(signal);
|
|
55
|
+
assertCurrent();
|
|
56
|
+
if (snapshot.catalog.invalidRuntimeProof ||
|
|
57
|
+
snapshot.catalog.duplicateSemanticStamp ||
|
|
58
|
+
snapshot.catalog.duplicateRuntimePaneBinding)
|
|
59
|
+
unavailable();
|
|
60
|
+
const matches = snapshot.panes.filter((pane) => pane.runtimePaneId === target.runtimePaneId && pane.sessionId === target.sessionId);
|
|
61
|
+
if (matches.length !== 1)
|
|
62
|
+
return unavailable();
|
|
63
|
+
const pane = { ...matches[0] };
|
|
64
|
+
const semantic = TerminalAttachmentSemanticPaneIdSchemaZ.parse(pane.semanticPaneId);
|
|
65
|
+
const workspace = options.registry.get(pane.workspaceName);
|
|
66
|
+
if (!workspace || workspace.sessionName !== pane.sessionName)
|
|
67
|
+
return unavailable();
|
|
68
|
+
const sessions = (await options.catalog()).filter((row) => row.sessionName === pane.sessionName);
|
|
69
|
+
assertCurrent();
|
|
70
|
+
if (sessions.length !== 1 || options.registry.get(pane.workspaceName) !== workspace)
|
|
71
|
+
return unavailable();
|
|
72
|
+
const live = WorkspaceCatalogLiveSessionIdSchemaZ.parse(sessions[0].liveSessionId);
|
|
73
|
+
return {
|
|
74
|
+
context: {
|
|
75
|
+
generation,
|
|
76
|
+
workspaceName: pane.workspaceName,
|
|
77
|
+
liveSessionId: live,
|
|
78
|
+
pane: semantic,
|
|
79
|
+
},
|
|
80
|
+
identity: [
|
|
81
|
+
pane.sessionId,
|
|
82
|
+
pane.windowId,
|
|
83
|
+
pane.runtimePaneId,
|
|
84
|
+
pane.windowStamp ?? null,
|
|
85
|
+
pane.nativePaneBirthId ?? null,
|
|
86
|
+
],
|
|
87
|
+
};
|
|
88
|
+
};
|
|
89
|
+
const before = await observe();
|
|
90
|
+
const after = await observe();
|
|
91
|
+
if (JSON.stringify(before) !== JSON.stringify(after))
|
|
92
|
+
return unavailable();
|
|
93
|
+
assertCurrent();
|
|
94
|
+
return Object.freeze(after.context);
|
|
95
|
+
};
|
|
96
|
+
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { createNativeSurfaceContextResolver } from "./native-surface-context.js";
|
|
1
2
|
import { shellEscape } from "./shell.js";
|
|
2
3
|
import { createNativeSplitCapabilityProbe, createNativeSplitReadiness, } from "./native-split-capability.js";
|
|
3
4
|
import { executeCanonicalSplitMutation } from "./canonical-split-mutation.js";
|
|
@@ -495,6 +496,14 @@ export async function createNativeTmuxServerOwner(options) {
|
|
|
495
496
|
},
|
|
496
497
|
serverId: options.serverId,
|
|
497
498
|
generation,
|
|
499
|
+
resolveNativeSurfaceContext: createNativeSurfaceContextResolver({
|
|
500
|
+
generation,
|
|
501
|
+
assertOpen,
|
|
502
|
+
registry: workspaceRegistry,
|
|
503
|
+
catalog,
|
|
504
|
+
inventory: terminalInventoryRuntime,
|
|
505
|
+
run: (args, signal) => generationRunAsync(args, signal),
|
|
506
|
+
}),
|
|
498
507
|
resolvePaneEditorContext,
|
|
499
508
|
catalog,
|
|
500
509
|
readWindowSplitLayout: async (workspaceName, input) => {
|
|
@@ -21,9 +21,9 @@
|
|
|
21
21
|
<key>CFBundlePackageType</key>
|
|
22
22
|
<string>APPL</string>
|
|
23
23
|
<key>CFBundleShortVersionString</key>
|
|
24
|
-
<string>2.9.
|
|
24
|
+
<string>2.9.13</string>
|
|
25
25
|
<key>CFBundleVersion</key>
|
|
26
|
-
<string>2.9.
|
|
26
|
+
<string>2.9.13</string>
|
|
27
27
|
<key>LSMinimumSystemVersion</key>
|
|
28
28
|
<string>11.0</string>
|
|
29
29
|
<key>LSUIElement</key>
|
|
Binary file
|
|
Binary file
|
package/packages/daemon/dist/native/TmuxIdeNotifier.app/Contents/_CodeSignature/CodeResources
CHANGED
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
</data>
|
|
11
11
|
<key>Resources/Assets.car</key>
|
|
12
12
|
<data>
|
|
13
|
-
|
|
13
|
+
6Tstp1dls1WRUQD+/DDuIhts8/g=
|
|
14
14
|
</data>
|
|
15
15
|
</dict>
|
|
16
16
|
<key>files2</key>
|
|
@@ -26,7 +26,7 @@
|
|
|
26
26
|
<dict>
|
|
27
27
|
<key>hash2</key>
|
|
28
28
|
<data>
|
|
29
|
-
|
|
29
|
+
5CnAgYS5jeHfuf40WhEyRn9BbniU54houDrGdNEkNyI=
|
|
30
30
|
</data>
|
|
31
31
|
</dict>
|
|
32
32
|
</dict>
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { WebSocket, WebSocketServer } from "ws";
|
|
2
|
+
import { TERMINAL_SURFACE_MAX_CONTROL_BYTES } from "@tmux-ide/contracts";
|
|
3
|
+
import { ownerBearerMatches } from "../command-center/owner-authority.js";
|
|
4
|
+
import { NativeSurfaceProviderChannel } from "../terminal/graphics/native-surface-provider-channel.js";
|
|
5
|
+
export const NATIVE_SURFACE_PROVIDER_PATH = "/v1/terminal/native-surface-provider";
|
|
6
|
+
const PROTOCOL = "tmux-ide-native-surface-provider-v1";
|
|
7
|
+
/** Separate owner-authenticated producer authority. Loopback, PID and Origin do
|
|
8
|
+
* not grant access. At most one connection (including registration) is admitted.
|
|
9
|
+
* Native kernel proof remains required independently of this control channel. */
|
|
10
|
+
export function attachNativeSurfaceProvider(server, registry, ownerToken, isCurrent) {
|
|
11
|
+
const wss = new WebSocketServer({
|
|
12
|
+
noServer: true,
|
|
13
|
+
clientTracking: false,
|
|
14
|
+
perMessageDeflate: false,
|
|
15
|
+
maxPayload: TERMINAL_SURFACE_MAX_CONTROL_BYTES,
|
|
16
|
+
handleProtocols: (values) => (values.size === 1 && values.has(PROTOCOL) ? PROTOCOL : false),
|
|
17
|
+
});
|
|
18
|
+
let active = null;
|
|
19
|
+
let channel = null;
|
|
20
|
+
let closed = false;
|
|
21
|
+
const upgrade = (request, socket, head) => {
|
|
22
|
+
if (request.url?.split("?", 1)[0] !== NATIVE_SURFACE_PROVIDER_PATH)
|
|
23
|
+
return;
|
|
24
|
+
const headers = (name) => request.rawHeaders.filter((_, i) => i % 2 === 0).filter((key) => key.toLowerCase() === name)
|
|
25
|
+
.length;
|
|
26
|
+
const authorized = headers("authorization") === 1 &&
|
|
27
|
+
ownerBearerMatches(request.headers.authorization, ownerToken);
|
|
28
|
+
const status = !authorized
|
|
29
|
+
? 403
|
|
30
|
+
: closed || !isCurrent()
|
|
31
|
+
? 410
|
|
32
|
+
: active || registry.available
|
|
33
|
+
? 409
|
|
34
|
+
: request.url !== NATIVE_SURFACE_PROVIDER_PATH ||
|
|
35
|
+
headers("sec-websocket-protocol") !== 1 ||
|
|
36
|
+
request.headers["sec-websocket-protocol"] !== PROTOCOL
|
|
37
|
+
? 400
|
|
38
|
+
: 0;
|
|
39
|
+
if (status) {
|
|
40
|
+
socket.end(`HTTP/1.1 ${status} Rejected\r\nConnection: close\r\nContent-Length: 0\r\n\r\n`);
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
wss.handleUpgrade(request, socket, head, (ws) => {
|
|
44
|
+
active = ws;
|
|
45
|
+
const owner = new NativeSurfaceProviderChannel(registry, (message) => {
|
|
46
|
+
if (!isCurrent() ||
|
|
47
|
+
ws.readyState !== WebSocket.OPEN ||
|
|
48
|
+
ws.bufferedAmount + Buffer.byteLength(message) > 64 * 1024)
|
|
49
|
+
return false;
|
|
50
|
+
ws.send(message, (error) => {
|
|
51
|
+
if (error)
|
|
52
|
+
owner.close();
|
|
53
|
+
});
|
|
54
|
+
return true;
|
|
55
|
+
}, () => ws.terminate());
|
|
56
|
+
channel = owner;
|
|
57
|
+
const timer = setTimeout(() => owner.close(), 5000);
|
|
58
|
+
timer.unref();
|
|
59
|
+
ws.on("message", (data, binary) => {
|
|
60
|
+
if (binary || !isCurrent()) {
|
|
61
|
+
owner.close();
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
owner.receive(data.toString());
|
|
65
|
+
if (registry.available)
|
|
66
|
+
clearTimeout(timer);
|
|
67
|
+
});
|
|
68
|
+
ws.on("error", () => owner.close());
|
|
69
|
+
ws.on("close", () => {
|
|
70
|
+
clearTimeout(timer);
|
|
71
|
+
owner.close();
|
|
72
|
+
if (active === ws) {
|
|
73
|
+
active = null;
|
|
74
|
+
channel = null;
|
|
75
|
+
}
|
|
76
|
+
});
|
|
77
|
+
});
|
|
78
|
+
};
|
|
79
|
+
server.on("upgrade", upgrade);
|
|
80
|
+
return {
|
|
81
|
+
close: () => {
|
|
82
|
+
closed = true;
|
|
83
|
+
server.off("upgrade", upgrade);
|
|
84
|
+
channel?.close();
|
|
85
|
+
wss.close();
|
|
86
|
+
},
|
|
87
|
+
};
|
|
88
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { deflate } from "node:zlib";
|
|
2
|
+
import { encodeTerminalGraphicsCompressed, TERMINAL_GRAPHICS_BLOB_CHUNK_BYTES, } from "@tmux-ide/contracts";
|
|
3
|
+
// Across panes, windows and connections: no queued compression backlog and at
|
|
4
|
+
// most two chunk inputs/outputs retained by libuv. Never compress on the JS loop.
|
|
5
|
+
let active = 0;
|
|
6
|
+
// Prefer flat-color UI chunks. A bounded, spread-out sample avoids spending a
|
|
7
|
+
// libuv job on noisy frames. False negatives only choose the raw fallback.
|
|
8
|
+
function likelyCompressible(bytes) {
|
|
9
|
+
const counts = new Uint16Array(256);
|
|
10
|
+
const samples = 4096;
|
|
11
|
+
for (let i = 0; i < samples; i++) {
|
|
12
|
+
const value = bytes[Math.floor((i * (bytes.length - 1)) / (samples - 1))];
|
|
13
|
+
if (++counts[value] >= samples / 32)
|
|
14
|
+
return true;
|
|
15
|
+
}
|
|
16
|
+
return false;
|
|
17
|
+
}
|
|
18
|
+
export async function compressGraphicsChunk(header, bytes) {
|
|
19
|
+
if (active >= 2 ||
|
|
20
|
+
bytes.length < 4096 ||
|
|
21
|
+
bytes.length > TERMINAL_GRAPHICS_BLOB_CHUNK_BYTES ||
|
|
22
|
+
(header.blob.format !== "rgb" && header.blob.format !== "rgba"))
|
|
23
|
+
return null;
|
|
24
|
+
if (!likelyCompressible(bytes))
|
|
25
|
+
return null;
|
|
26
|
+
active++;
|
|
27
|
+
try {
|
|
28
|
+
const encoded = await new Promise((resolve) => {
|
|
29
|
+
deflate(bytes, { level: 1 }, (error, result) => resolve(error ? null : result));
|
|
30
|
+
});
|
|
31
|
+
// Savings must cover metadata and be material; incompressible images retain
|
|
32
|
+
// the original binary path. PNG/producer-zlib are never recompressed.
|
|
33
|
+
if (!encoded || encoded.length + 128 > bytes.length * 0.875)
|
|
34
|
+
return null;
|
|
35
|
+
return encodeTerminalGraphicsCompressed({ ...header, compression: "zlib", decodedBytes: bytes.length }, encoded);
|
|
36
|
+
}
|
|
37
|
+
finally {
|
|
38
|
+
active--;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
export class NativeSurfaceDemand {
|
|
2
|
+
#viewers = new Set();
|
|
3
|
+
#presence = new Map();
|
|
4
|
+
#listeners = new Set();
|
|
5
|
+
#overflow = 0;
|
|
6
|
+
#closed = false;
|
|
7
|
+
#queued = false;
|
|
8
|
+
presence(session, present) {
|
|
9
|
+
if (this.#closed)
|
|
10
|
+
return;
|
|
11
|
+
if (present === null)
|
|
12
|
+
this.#presence.delete(session);
|
|
13
|
+
else {
|
|
14
|
+
if (!this.#presence.has(session) && this.#presence.size >= 256) {
|
|
15
|
+
const oldest = this.#presence.keys().next().value;
|
|
16
|
+
if (oldest !== undefined)
|
|
17
|
+
this.#presence.delete(oldest);
|
|
18
|
+
}
|
|
19
|
+
this.#presence.set(session, present);
|
|
20
|
+
}
|
|
21
|
+
this.#notify();
|
|
22
|
+
}
|
|
23
|
+
viewer(session, panes) {
|
|
24
|
+
const viewer = { session, panes: new Set(panes), native: false };
|
|
25
|
+
const retained = !this.#closed && this.#viewers.size < 256 && panes.length <= 256;
|
|
26
|
+
if (retained)
|
|
27
|
+
this.#viewers.add(viewer);
|
|
28
|
+
else
|
|
29
|
+
this.#overflow++;
|
|
30
|
+
this.#notify();
|
|
31
|
+
let closed = false;
|
|
32
|
+
return {
|
|
33
|
+
native: (ready) => {
|
|
34
|
+
if (closed || viewer.native === ready)
|
|
35
|
+
return;
|
|
36
|
+
viewer.native = ready;
|
|
37
|
+
this.#notify();
|
|
38
|
+
},
|
|
39
|
+
close: () => {
|
|
40
|
+
if (closed)
|
|
41
|
+
return;
|
|
42
|
+
closed = true;
|
|
43
|
+
if (retained)
|
|
44
|
+
this.#viewers.delete(viewer);
|
|
45
|
+
else
|
|
46
|
+
this.#overflow--;
|
|
47
|
+
this.#notify();
|
|
48
|
+
},
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
required(pane) {
|
|
52
|
+
if (this.#closed || this.#overflow)
|
|
53
|
+
return true;
|
|
54
|
+
let known = false;
|
|
55
|
+
for (const viewer of this.#viewers) {
|
|
56
|
+
if (!viewer.panes.has(pane))
|
|
57
|
+
continue;
|
|
58
|
+
known = true;
|
|
59
|
+
if (!viewer.native || this.#presence.get(viewer.session) !== false)
|
|
60
|
+
return true;
|
|
61
|
+
}
|
|
62
|
+
return !known;
|
|
63
|
+
}
|
|
64
|
+
watch(listener) {
|
|
65
|
+
if (this.#closed || this.#listeners.size >= 256)
|
|
66
|
+
throw new Error("Surface demand unavailable");
|
|
67
|
+
this.#listeners.add(listener);
|
|
68
|
+
this.#notify();
|
|
69
|
+
return () => this.#listeners.delete(listener);
|
|
70
|
+
}
|
|
71
|
+
#notify() {
|
|
72
|
+
if (this.#queued)
|
|
73
|
+
return;
|
|
74
|
+
this.#queued = true;
|
|
75
|
+
queueMicrotask(() => {
|
|
76
|
+
this.#queued = false;
|
|
77
|
+
for (const listener of [...this.#listeners]) {
|
|
78
|
+
if (!this.#listeners.has(listener))
|
|
79
|
+
continue;
|
|
80
|
+
try {
|
|
81
|
+
listener();
|
|
82
|
+
}
|
|
83
|
+
catch {
|
|
84
|
+
this.#listeners.delete(listener);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
if (this.#closed)
|
|
88
|
+
this.#listeners.clear();
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
close() {
|
|
92
|
+
if (this.#closed)
|
|
93
|
+
return;
|
|
94
|
+
this.#closed = true;
|
|
95
|
+
this.#viewers.clear();
|
|
96
|
+
this.#presence.clear();
|
|
97
|
+
this.#notify();
|
|
98
|
+
}
|
|
99
|
+
}
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import { TerminalAttachmentSemanticPaneIdSchemaZ, TerminalSurfaceAuditTokenSchemaZ, TerminalSurfaceOfferSchemaZ, TERMINAL_SURFACE_MAX_CONTROL_BYTES, } from "@tmux-ide/contracts";
|
|
4
|
+
const Register = z
|
|
5
|
+
.object({
|
|
6
|
+
type: z.literal("register"),
|
|
7
|
+
version: z.literal(1),
|
|
8
|
+
generation: z.uuid(),
|
|
9
|
+
auditToken: TerminalSurfaceAuditTokenSchemaZ,
|
|
10
|
+
})
|
|
11
|
+
.strict();
|
|
12
|
+
const Offer = z
|
|
13
|
+
.object({
|
|
14
|
+
type: z.literal("offer"),
|
|
15
|
+
connection: z.uuid(),
|
|
16
|
+
offer: TerminalSurfaceOfferSchemaZ,
|
|
17
|
+
})
|
|
18
|
+
.strict();
|
|
19
|
+
const WatchDemand = z
|
|
20
|
+
.object({
|
|
21
|
+
type: z.literal("watch-demand"),
|
|
22
|
+
panes: z.array(TerminalAttachmentSemanticPaneIdSchemaZ).min(1).max(32),
|
|
23
|
+
})
|
|
24
|
+
.strict();
|
|
25
|
+
/** Only an authenticated owner connection may construct this channel. Kernel
|
|
26
|
+
* identity proof is still mandatory at the native receiver. No payload contains
|
|
27
|
+
* pixel storage, executable paths, or a reusable daemon credential. */
|
|
28
|
+
export class NativeSurfaceProviderChannel {
|
|
29
|
+
registry;
|
|
30
|
+
send;
|
|
31
|
+
disconnect;
|
|
32
|
+
#unregister = null;
|
|
33
|
+
#connections = new Map();
|
|
34
|
+
#closed = false;
|
|
35
|
+
#stopDemand = null;
|
|
36
|
+
#demandPanes = null;
|
|
37
|
+
#lastDemand = null;
|
|
38
|
+
constructor(registry, send, disconnect) {
|
|
39
|
+
this.registry = registry;
|
|
40
|
+
this.send = send;
|
|
41
|
+
this.disconnect = disconnect;
|
|
42
|
+
}
|
|
43
|
+
receive(text) {
|
|
44
|
+
if (this.#closed)
|
|
45
|
+
return;
|
|
46
|
+
if (Buffer.byteLength(text) > TERMINAL_SURFACE_MAX_CONTROL_BYTES) {
|
|
47
|
+
this.close();
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
let value;
|
|
51
|
+
try {
|
|
52
|
+
value = JSON.parse(text);
|
|
53
|
+
}
|
|
54
|
+
catch {
|
|
55
|
+
this.close();
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
if (!this.#unregister) {
|
|
59
|
+
const registration = Register.safeParse(value);
|
|
60
|
+
if (!registration.success || registration.data.generation !== this.registry.generation) {
|
|
61
|
+
this.close();
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
try {
|
|
65
|
+
this.#unregister = this.registry.register({
|
|
66
|
+
auditToken: registration.data.auditToken,
|
|
67
|
+
open: (context) => this.#open(context),
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
catch {
|
|
71
|
+
this.close();
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
this.#send({ type: "registered", version: 1, generation: this.registry.generation });
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
const watch = WatchDemand.safeParse(value);
|
|
78
|
+
if (watch.success) {
|
|
79
|
+
const panes = [...new Set(watch.data.panes)].sort();
|
|
80
|
+
if (this.#demandPanes) {
|
|
81
|
+
if (JSON.stringify(panes) !== JSON.stringify(this.#demandPanes))
|
|
82
|
+
this.close();
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
this.#demandPanes = panes;
|
|
86
|
+
try {
|
|
87
|
+
this.#stopDemand = this.registry.demand.watch(() => {
|
|
88
|
+
const required = panes.filter((pane) => this.registry.demand.required(pane));
|
|
89
|
+
const key = JSON.stringify(required);
|
|
90
|
+
if (key === this.#lastDemand)
|
|
91
|
+
return;
|
|
92
|
+
this.#lastDemand = key;
|
|
93
|
+
this.#send({ type: "kitty-demand", panes, required });
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
catch {
|
|
97
|
+
this.close();
|
|
98
|
+
}
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
const parsed = Offer.safeParse(value);
|
|
102
|
+
if (!parsed.success) {
|
|
103
|
+
this.close();
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
const { connection, offer } = parsed.data;
|
|
107
|
+
const context = this.#connections.get(connection);
|
|
108
|
+
if (!context) {
|
|
109
|
+
this.close();
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
const accepted = context.offer(offer);
|
|
113
|
+
// The producer must wait for accepted=true before transferring the matching
|
|
114
|
+
// native right. A dropped channel or rejected offer grants no transfer.
|
|
115
|
+
this.#send({ type: "offered", connection, sequence: offer.sequence, accepted });
|
|
116
|
+
}
|
|
117
|
+
#open(context) {
|
|
118
|
+
if (this.#closed || this.#connections.size >= 32)
|
|
119
|
+
throw new Error("Native provider unavailable");
|
|
120
|
+
const connection = randomUUID();
|
|
121
|
+
this.#connections.set(connection, context);
|
|
122
|
+
return {
|
|
123
|
+
start: () => {
|
|
124
|
+
this.#send({ type: "start", connection, receiver: context.receiver, panes: context.panes });
|
|
125
|
+
},
|
|
126
|
+
retire: (sequence) => {
|
|
127
|
+
this.#send({ type: "retired", connection, sequence });
|
|
128
|
+
},
|
|
129
|
+
fallback: () => {
|
|
130
|
+
this.#send({ type: "fallback", connection });
|
|
131
|
+
},
|
|
132
|
+
close: () => {
|
|
133
|
+
this.#connections.delete(connection);
|
|
134
|
+
this.#send({ type: "close", connection });
|
|
135
|
+
},
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
#send(value) {
|
|
139
|
+
if (this.#closed)
|
|
140
|
+
return;
|
|
141
|
+
const text = JSON.stringify(value);
|
|
142
|
+
try {
|
|
143
|
+
if (Buffer.byteLength(text) <= TERMINAL_SURFACE_MAX_CONTROL_BYTES && this.send(text))
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
catch {
|
|
147
|
+
/* A failed sink revokes all further native offers. */
|
|
148
|
+
}
|
|
149
|
+
this.close();
|
|
150
|
+
}
|
|
151
|
+
close() {
|
|
152
|
+
if (this.#closed)
|
|
153
|
+
return;
|
|
154
|
+
this.#closed = true;
|
|
155
|
+
this.#stopDemand?.();
|
|
156
|
+
this.#stopDemand = null;
|
|
157
|
+
this.#unregister?.();
|
|
158
|
+
this.#unregister = null;
|
|
159
|
+
this.#connections.clear();
|
|
160
|
+
this.disconnect();
|
|
161
|
+
}
|
|
162
|
+
}
|