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