tmux-ide 2.9.14 → 2.9.15
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 +1578 -1309
- package/package.json +1 -1
- package/packages/contracts/src/index.ts +1 -0
- package/packages/contracts/src/native-browser.ts +43 -0
- package/packages/daemon/dist/command-center/tmux-servers.js +6 -0
- package/packages/daemon/dist/lib/daemon-embed.js +15 -0
- package/packages/daemon/dist/lib/native-browser-rpc.js +110 -0
- package/packages/daemon/dist/lib/tmux-native-browser.js +80 -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/open-browser.js +17 -5
- package/packages/daemon/src/command-center/tmux-servers.ts +11 -0
- package/packages/daemon/src/lib/daemon-embed.ts +17 -0
- package/packages/daemon/src/lib/native-browser-rpc.ts +118 -0
- package/packages/daemon/src/lib/tmux-native-browser.ts +100 -0
- package/packages/daemon/src/lib/tmux-server-owner.ts +9 -0
- package/packages/daemon/src/open-browser.ts +32 -5
package/package.json
CHANGED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
|
|
3
|
+
const text = (limit: number) =>
|
|
4
|
+
z
|
|
5
|
+
.string()
|
|
6
|
+
.max(limit)
|
|
7
|
+
// Intentionally reject control characters in display text and navigation input.
|
|
8
|
+
// eslint-disable-next-line no-control-regex
|
|
9
|
+
.refine((value) => !/[\x00-\x1f\x7f]/u.test(value));
|
|
10
|
+
const identity = {
|
|
11
|
+
version: z.literal(1),
|
|
12
|
+
instance: z.uuid(),
|
|
13
|
+
pane: z
|
|
14
|
+
.string()
|
|
15
|
+
.regex(/^%[0-9]+$/u)
|
|
16
|
+
.max(32),
|
|
17
|
+
tab: z.number().int().positive().max(Number.MAX_SAFE_INTEGER),
|
|
18
|
+
revision: z.number().int().positive().max(Number.MAX_SAFE_INTEGER),
|
|
19
|
+
};
|
|
20
|
+
/** Display state is not authority: every command echoes the exact browser revision. */
|
|
21
|
+
export const NativeBrowserStateSchemaZ = z
|
|
22
|
+
.object({
|
|
23
|
+
...identity,
|
|
24
|
+
enabled: z.boolean(),
|
|
25
|
+
url: text(16_384),
|
|
26
|
+
title: text(512),
|
|
27
|
+
loading: z.boolean(),
|
|
28
|
+
canGoBack: z.boolean(),
|
|
29
|
+
canGoForward: z.boolean(),
|
|
30
|
+
})
|
|
31
|
+
.strict();
|
|
32
|
+
export const NativeBrowserCommandSchemaZ = z.discriminatedUnion("action", [
|
|
33
|
+
z.object({ ...identity, action: z.enum(["back", "forward", "reload", "stop"]) }).strict(),
|
|
34
|
+
z
|
|
35
|
+
.object({
|
|
36
|
+
...identity,
|
|
37
|
+
action: z.literal("navigate"),
|
|
38
|
+
text: text(16_384).refine((value) => value.trim().length > 0),
|
|
39
|
+
})
|
|
40
|
+
.strict(),
|
|
41
|
+
]);
|
|
42
|
+
export type NativeBrowserState = z.infer<typeof NativeBrowserStateSchemaZ>;
|
|
43
|
+
export type NativeBrowserCommand = z.infer<typeof NativeBrowserCommandSchemaZ>;
|
|
@@ -107,6 +107,12 @@ export function mountTmuxServerRoutes(app, options) {
|
|
|
107
107
|
owners: options.owners,
|
|
108
108
|
readBody: (request) => boundedJson(request, 8 * 1024),
|
|
109
109
|
});
|
|
110
|
+
app.post(`${scoped}/native-browser`, route(async (c) => {
|
|
111
|
+
const server = scope(c);
|
|
112
|
+
const input = await boundedJson(c.req.raw, 24 * 1024);
|
|
113
|
+
const state = await options.owners.withOwner(server, (owner) => owner.nativeBrowser(input, c.req.raw.signal));
|
|
114
|
+
return c.json({ version: 1, state });
|
|
115
|
+
}));
|
|
110
116
|
// Structured read only: this never acquires a channel or changes tmux geometry.
|
|
111
117
|
app.post(`${scoped}/split-layout/:workspaceName`, route(async (c) => {
|
|
112
118
|
const server = scope(c);
|
|
@@ -6,6 +6,7 @@ var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExte
|
|
|
6
6
|
}
|
|
7
7
|
return path;
|
|
8
8
|
};
|
|
9
|
+
import { createNativeBrowserResolver } from "./tmux-native-browser.js";
|
|
9
10
|
import { createNativeSurfaceContextResolver } from "./native-surface-context.js";
|
|
10
11
|
import { NativeSurfaceProviderRegistry } from "../terminal/graphics/native-surface-provider.js";
|
|
11
12
|
import { TmuxServerVersionSchemaZ } from "@tmux-ide/contracts";
|
|
@@ -1397,6 +1398,20 @@ async function startEmbeddedDaemonGeneration(opts) {
|
|
|
1397
1398
|
throw new Error("Default tmux editor context owner is unavailable");
|
|
1398
1399
|
},
|
|
1399
1400
|
}),
|
|
1401
|
+
nativeBrowser: createNativeBrowserResolver({
|
|
1402
|
+
generation: instanceId,
|
|
1403
|
+
run: fleetFactsTmuxRunner,
|
|
1404
|
+
registry: workspaceRegistry,
|
|
1405
|
+
catalog: createNativeTmuxServerCatalog(workspaceRegistry, fleetFactsTmuxRunner),
|
|
1406
|
+
inventory: terminalInventoryRuntime,
|
|
1407
|
+
assertOpen: () => {
|
|
1408
|
+
if (defaultEditorContextRetired ||
|
|
1409
|
+
serverOwners?.defaultRetired ||
|
|
1410
|
+
!initialNativeServerIdentity ||
|
|
1411
|
+
terminalInventoryRuntime?.lifecycleState() !== "ready")
|
|
1412
|
+
throw new Error("Default native browser owner is unavailable");
|
|
1413
|
+
},
|
|
1414
|
+
}),
|
|
1400
1415
|
catalog: createNativeTmuxServerCatalog(workspaceRegistry, fleetFactsTmuxRunner),
|
|
1401
1416
|
readWindowSplitLayout: async (workspaceName, input) => {
|
|
1402
1417
|
const target = WindowLinkTargetSchemaZ.parse(input);
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { lstat } from "node:fs/promises";
|
|
3
|
+
import { isAbsolute } from "node:path";
|
|
4
|
+
import { connect } from "node:net";
|
|
5
|
+
import { NativeBrowserCommandSchemaZ, NativeBrowserStateSchemaZ, } from "@tmux-ide/contracts";
|
|
6
|
+
import { z } from "zod";
|
|
7
|
+
export const NativeBrowserEndpointSchema = z
|
|
8
|
+
.object({
|
|
9
|
+
version: z.literal(1),
|
|
10
|
+
pid: z.number().int().positive(),
|
|
11
|
+
instance: z.uuid(),
|
|
12
|
+
pane: z
|
|
13
|
+
.string()
|
|
14
|
+
.regex(/^%[0-9]+$/u)
|
|
15
|
+
.max(32),
|
|
16
|
+
socket: z.string().min(1).max(1024).refine(isAbsolute),
|
|
17
|
+
})
|
|
18
|
+
.strict();
|
|
19
|
+
export class NativeBrowserUnavailable extends Error {
|
|
20
|
+
constructor() {
|
|
21
|
+
super("Native browser controls are unavailable");
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
const responseSchema = z.discriminatedUnion("ok", [
|
|
25
|
+
z
|
|
26
|
+
.object({ id: z.string(), ok: z.literal(true), data: NativeBrowserStateSchemaZ.nullable() })
|
|
27
|
+
.strict(),
|
|
28
|
+
z.object({ id: z.string(), ok: z.literal(false), error: z.string().max(4096) }).strict(),
|
|
29
|
+
]);
|
|
30
|
+
/** One request, one bounded response. Mutations are never retried or refreshed. */
|
|
31
|
+
export async function nativeBrowserRpc(endpoint, command, signal) {
|
|
32
|
+
const target = NativeBrowserEndpointSchema.parse(endpoint);
|
|
33
|
+
const payload = command ? NativeBrowserCommandSchemaZ.parse(command) : null;
|
|
34
|
+
if (payload && (payload.instance !== target.instance || payload.pane !== target.pane))
|
|
35
|
+
throw new NativeBrowserUnavailable();
|
|
36
|
+
signal.throwIfAborted();
|
|
37
|
+
const info = await lstat(target.socket);
|
|
38
|
+
if (!info.isSocket() || info.uid !== process.getuid?.() || (info.mode & 0o022) !== 0)
|
|
39
|
+
throw new NativeBrowserUnavailable();
|
|
40
|
+
try {
|
|
41
|
+
process.kill(target.pid, 0);
|
|
42
|
+
}
|
|
43
|
+
catch {
|
|
44
|
+
throw new NativeBrowserUnavailable();
|
|
45
|
+
}
|
|
46
|
+
signal.throwIfAborted();
|
|
47
|
+
const id = randomUUID();
|
|
48
|
+
const request = JSON.stringify({
|
|
49
|
+
id,
|
|
50
|
+
cmd: payload ? "native/1/command" : "native/1/state",
|
|
51
|
+
...(payload ? { payload } : {}),
|
|
52
|
+
}) + "\n";
|
|
53
|
+
return new Promise((resolve, reject) => {
|
|
54
|
+
const socket = connect(target.socket);
|
|
55
|
+
let finished = false;
|
|
56
|
+
let size = 0;
|
|
57
|
+
const chunks = [];
|
|
58
|
+
const finish = (error, state = null) => {
|
|
59
|
+
if (finished)
|
|
60
|
+
return;
|
|
61
|
+
finished = true;
|
|
62
|
+
clearTimeout(timer);
|
|
63
|
+
signal.removeEventListener("abort", abort);
|
|
64
|
+
socket.destroy();
|
|
65
|
+
if (error)
|
|
66
|
+
reject(error);
|
|
67
|
+
else
|
|
68
|
+
resolve(state);
|
|
69
|
+
};
|
|
70
|
+
const abort = () => finish(new NativeBrowserUnavailable());
|
|
71
|
+
const timer = setTimeout(abort, 1500);
|
|
72
|
+
signal.addEventListener("abort", abort, { once: true });
|
|
73
|
+
if (signal.aborted) {
|
|
74
|
+
abort();
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
socket.once("connect", () => socket.write(request));
|
|
78
|
+
socket.on("error", abort);
|
|
79
|
+
socket.once("end", () => {
|
|
80
|
+
if (!finished)
|
|
81
|
+
abort();
|
|
82
|
+
});
|
|
83
|
+
socket.on("data", (chunk) => {
|
|
84
|
+
size += chunk.length;
|
|
85
|
+
if (size > 128 * 1024) {
|
|
86
|
+
abort();
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
chunks.push(chunk);
|
|
90
|
+
if (!chunk.includes(10))
|
|
91
|
+
return;
|
|
92
|
+
try {
|
|
93
|
+
const bytes = Buffer.concat(chunks);
|
|
94
|
+
const newline = bytes.indexOf(10);
|
|
95
|
+
if (newline !== bytes.length - 1)
|
|
96
|
+
throw new NativeBrowserUnavailable();
|
|
97
|
+
const reply = responseSchema.parse(JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(bytes.subarray(0, newline))));
|
|
98
|
+
if (reply.id !== id || !reply.ok)
|
|
99
|
+
throw new NativeBrowserUnavailable();
|
|
100
|
+
if (reply.data &&
|
|
101
|
+
(reply.data.instance !== target.instance || reply.data.pane !== target.pane))
|
|
102
|
+
throw new NativeBrowserUnavailable();
|
|
103
|
+
finish(undefined, reply.data);
|
|
104
|
+
}
|
|
105
|
+
catch {
|
|
106
|
+
abort();
|
|
107
|
+
}
|
|
108
|
+
});
|
|
109
|
+
});
|
|
110
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { PaneEditorContextTargetSchemaZ, NativeBrowserCommandSchemaZ } from "@tmux-ide/contracts";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import { decodeTmuxArgument } from "../terminal/protocol/session-descriptor-discovery.js";
|
|
4
|
+
import { NativeBrowserEndpointSchema, NativeBrowserUnavailable, nativeBrowserRpc, } from "./native-browser-rpc.js";
|
|
5
|
+
export const NativeBrowserRequestSchema = z
|
|
6
|
+
.object({
|
|
7
|
+
target: PaneEditorContextTargetSchemaZ,
|
|
8
|
+
command: NativeBrowserCommandSchemaZ.nullable(),
|
|
9
|
+
})
|
|
10
|
+
.strict();
|
|
11
|
+
export function createNativeBrowserResolver(options) {
|
|
12
|
+
let readers = 0;
|
|
13
|
+
return async (input, cancellation) => {
|
|
14
|
+
const { target, command } = NativeBrowserRequestSchema.parse(input);
|
|
15
|
+
const signal = AbortSignal.any([cancellation, AbortSignal.timeout(2500)]);
|
|
16
|
+
const workspace = options.registry.get(target.workspaceName);
|
|
17
|
+
const assertCurrent = () => {
|
|
18
|
+
options.assertOpen();
|
|
19
|
+
signal.throwIfAborted();
|
|
20
|
+
if (target.generation !== options.generation ||
|
|
21
|
+
!workspace ||
|
|
22
|
+
options.registry.get(target.workspaceName) !== workspace)
|
|
23
|
+
throw new NativeBrowserUnavailable();
|
|
24
|
+
};
|
|
25
|
+
assertCurrent();
|
|
26
|
+
if (readers >= 8)
|
|
27
|
+
throw new NativeBrowserUnavailable();
|
|
28
|
+
readers++;
|
|
29
|
+
try {
|
|
30
|
+
const sessions = await options.catalog();
|
|
31
|
+
assertCurrent();
|
|
32
|
+
const matching = sessions.filter((s) => s.liveSessionId === target.liveSessionId);
|
|
33
|
+
if (matching.length !== 1 || matching[0].sessionName !== workspace.sessionName)
|
|
34
|
+
throw new NativeBrowserUnavailable();
|
|
35
|
+
const snapshot = await options.inventory.discoverTerminalInventory(signal);
|
|
36
|
+
assertCurrent();
|
|
37
|
+
if (snapshot.catalog.invalidRuntimeProof ||
|
|
38
|
+
snapshot.catalog.duplicateSemanticStamp ||
|
|
39
|
+
snapshot.catalog.duplicateRuntimePaneBinding)
|
|
40
|
+
throw new NativeBrowserUnavailable();
|
|
41
|
+
const matches = snapshot.panes.filter((p) => p.workspaceName === target.workspaceName &&
|
|
42
|
+
p.semanticPaneId === target.semanticPaneId &&
|
|
43
|
+
p.sessionName === workspace.sessionName);
|
|
44
|
+
if (matches.length !== 1)
|
|
45
|
+
throw new NativeBrowserUnavailable();
|
|
46
|
+
const pane = { ...matches[0] };
|
|
47
|
+
if (!pane.nativePaneBirthId || !/^%[0-9]+$/u.test(pane.runtimePaneId))
|
|
48
|
+
throw new NativeBrowserUnavailable();
|
|
49
|
+
const observe = async () => {
|
|
50
|
+
const raw = await options.run([
|
|
51
|
+
"display-message",
|
|
52
|
+
"-p",
|
|
53
|
+
"-t",
|
|
54
|
+
pane.runtimePaneId,
|
|
55
|
+
"#{qa:@tmux_ide_pane_id}\t#{qa:pane_birth_id}\t#{qa:@tmux_ide_browser_control}",
|
|
56
|
+
], signal);
|
|
57
|
+
assertCurrent();
|
|
58
|
+
if (raw.length > 8192)
|
|
59
|
+
throw new NativeBrowserUnavailable();
|
|
60
|
+
const [semantic, birth, endpoint] = raw.trimEnd().split("\t").map(decodeTmuxArgument);
|
|
61
|
+
if (semantic !== target.semanticPaneId || birth !== pane.nativePaneBirthId)
|
|
62
|
+
throw new NativeBrowserUnavailable();
|
|
63
|
+
const parsed = NativeBrowserEndpointSchema.parse(JSON.parse(endpoint ?? "null"));
|
|
64
|
+
if (parsed.pane !== pane.runtimePaneId)
|
|
65
|
+
throw new NativeBrowserUnavailable();
|
|
66
|
+
return parsed;
|
|
67
|
+
};
|
|
68
|
+
const endpoint = await observe();
|
|
69
|
+
const result = await nativeBrowserRpc(endpoint, command, signal);
|
|
70
|
+
assertCurrent();
|
|
71
|
+
const current = await observe();
|
|
72
|
+
if (JSON.stringify(current) !== JSON.stringify(endpoint))
|
|
73
|
+
throw new NativeBrowserUnavailable();
|
|
74
|
+
return result;
|
|
75
|
+
}
|
|
76
|
+
finally {
|
|
77
|
+
readers--;
|
|
78
|
+
}
|
|
79
|
+
};
|
|
80
|
+
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { createNativeBrowserResolver } from "./tmux-native-browser.js";
|
|
1
2
|
import { createNativeSurfaceContextResolver } from "./native-surface-context.js";
|
|
2
3
|
import { shellEscape } from "./shell.js";
|
|
3
4
|
import { createNativeSplitCapabilityProbe, createNativeSplitReadiness, } from "./native-split-capability.js";
|
|
@@ -505,6 +506,14 @@ export async function createNativeTmuxServerOwner(options) {
|
|
|
505
506
|
run: (args, signal) => generationRunAsync(args, signal),
|
|
506
507
|
}),
|
|
507
508
|
resolvePaneEditorContext,
|
|
509
|
+
nativeBrowser: createNativeBrowserResolver({
|
|
510
|
+
generation,
|
|
511
|
+
assertOpen,
|
|
512
|
+
registry: workspaceRegistry,
|
|
513
|
+
catalog,
|
|
514
|
+
inventory: terminalInventoryRuntime,
|
|
515
|
+
run: generationRunAsync,
|
|
516
|
+
}),
|
|
508
517
|
catalog,
|
|
509
518
|
readWindowSplitLayout: async (workspaceName, input) => {
|
|
510
519
|
assertOpen();
|
|
@@ -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.15</string>
|
|
25
25
|
<key>CFBundleVersion</key>
|
|
26
|
-
<string>2.9.
|
|
26
|
+
<string>2.9.15</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
|
+
g6ppKlYeW830G3DwzRm9o0xINW8=
|
|
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
|
+
lo9t3TSww0mSpC4nN+YoeeUgaXGcFCrv53qBXtKpzEY=
|
|
30
30
|
</data>
|
|
31
31
|
</dict>
|
|
32
32
|
</dict>
|
|
@@ -1,17 +1,29 @@
|
|
|
1
1
|
import { accessSync, constants, realpathSync, statSync } from "node:fs";
|
|
2
2
|
import { delimiter, isAbsolute, join, resolve } from "node:path";
|
|
3
|
+
import { homedir } from "node:os";
|
|
3
4
|
import { IdeError } from "./lib/errors.js";
|
|
4
5
|
import { resolveRuntimeNamespace, runtimeNamespaceEnvironment } from "./lib/runtime-namespace.js";
|
|
5
6
|
import { createPinnedWorkspaceTmuxRunner, resolveWorkspacePaneTmuxAuthority, } from "./lib/workspace-pane-creation.js";
|
|
6
7
|
/** Resolve once before creating a pane; ignore relative PATH entries. */
|
|
7
|
-
export function resolveTerminalBrowser(environment = process.env) {
|
|
8
|
+
export function resolveTerminalBrowser(environment = process.env, platform = process.platform) {
|
|
8
9
|
const configured = environment.TMUX_IDE_TERMINAL_BROWSER_BIN;
|
|
10
|
+
const home = environment.HOME ?? homedir();
|
|
11
|
+
// Resolve the managed alias on every invocation so an app update selects its
|
|
12
|
+
// matching browser. Native assets remain outside the universal npm package.
|
|
13
|
+
const bundled = platform === "darwin" && isAbsolute(home)
|
|
14
|
+
? [
|
|
15
|
+
join(home, "Library", "Application Support", "tmux-ide", "native-alpha", "TmuxIDE.app", "Contents", "Resources", "browser", "terminal-browser"),
|
|
16
|
+
]
|
|
17
|
+
: [];
|
|
9
18
|
const candidates = configured
|
|
10
19
|
? [configured]
|
|
11
|
-
:
|
|
12
|
-
|
|
13
|
-
.
|
|
14
|
-
|
|
20
|
+
: [
|
|
21
|
+
...bundled,
|
|
22
|
+
...(environment.PATH ?? "")
|
|
23
|
+
.split(delimiter)
|
|
24
|
+
.filter(isAbsolute)
|
|
25
|
+
.map((dir) => join(dir, "terminal-browser")),
|
|
26
|
+
];
|
|
15
27
|
for (const candidate of candidates) {
|
|
16
28
|
try {
|
|
17
29
|
if (!isAbsolute(candidate))
|
|
@@ -150,6 +150,17 @@ export function mountTmuxServerRoutes(app: Hono, options: TmuxServerRoutesOption
|
|
|
150
150
|
owners: options.owners,
|
|
151
151
|
readBody: (request) => boundedJson(request, 8 * 1024),
|
|
152
152
|
});
|
|
153
|
+
app.post(
|
|
154
|
+
`${scoped}/native-browser`,
|
|
155
|
+
route(async (c) => {
|
|
156
|
+
const server = scope(c);
|
|
157
|
+
const input = await boundedJson(c.req.raw, 24 * 1024);
|
|
158
|
+
const state = await options.owners.withOwner(server, (owner) =>
|
|
159
|
+
owner.nativeBrowser(input, c.req.raw.signal),
|
|
160
|
+
);
|
|
161
|
+
return c.json({ version: 1, state });
|
|
162
|
+
}),
|
|
163
|
+
);
|
|
153
164
|
// Structured read only: this never acquires a channel or changes tmux geometry.
|
|
154
165
|
app.post(
|
|
155
166
|
`${scoped}/split-layout/:workspaceName`,
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { createNativeBrowserResolver } from "./tmux-native-browser.ts";
|
|
1
2
|
import { createNativeSurfaceContextResolver } from "./native-surface-context.ts";
|
|
2
3
|
import { NativeSurfaceProviderRegistry } from "../terminal/graphics/native-surface-provider.ts";
|
|
3
4
|
import { TmuxServerVersionSchemaZ } from "@tmux-ide/contracts";
|
|
@@ -1937,6 +1938,22 @@ async function startEmbeddedDaemonGeneration(
|
|
|
1937
1938
|
throw new Error("Default tmux editor context owner is unavailable");
|
|
1938
1939
|
},
|
|
1939
1940
|
}),
|
|
1941
|
+
nativeBrowser: createNativeBrowserResolver({
|
|
1942
|
+
generation: instanceId,
|
|
1943
|
+
run: fleetFactsTmuxRunner,
|
|
1944
|
+
registry: workspaceRegistry,
|
|
1945
|
+
catalog: createNativeTmuxServerCatalog(workspaceRegistry, fleetFactsTmuxRunner),
|
|
1946
|
+
inventory: terminalInventoryRuntime,
|
|
1947
|
+
assertOpen: () => {
|
|
1948
|
+
if (
|
|
1949
|
+
defaultEditorContextRetired ||
|
|
1950
|
+
serverOwners?.defaultRetired ||
|
|
1951
|
+
!initialNativeServerIdentity ||
|
|
1952
|
+
terminalInventoryRuntime?.lifecycleState() !== "ready"
|
|
1953
|
+
)
|
|
1954
|
+
throw new Error("Default native browser owner is unavailable");
|
|
1955
|
+
},
|
|
1956
|
+
}),
|
|
1940
1957
|
catalog: createNativeTmuxServerCatalog(workspaceRegistry, fleetFactsTmuxRunner),
|
|
1941
1958
|
readWindowSplitLayout: async (workspaceName, input) => {
|
|
1942
1959
|
const target = WindowLinkTargetSchemaZ.parse(input);
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { lstat } from "node:fs/promises";
|
|
3
|
+
import { isAbsolute } from "node:path";
|
|
4
|
+
import { connect } from "node:net";
|
|
5
|
+
import {
|
|
6
|
+
NativeBrowserCommandSchemaZ,
|
|
7
|
+
NativeBrowserStateSchemaZ,
|
|
8
|
+
type NativeBrowserCommand,
|
|
9
|
+
type NativeBrowserState,
|
|
10
|
+
} from "@tmux-ide/contracts";
|
|
11
|
+
import { z } from "zod";
|
|
12
|
+
|
|
13
|
+
export const NativeBrowserEndpointSchema = z
|
|
14
|
+
.object({
|
|
15
|
+
version: z.literal(1),
|
|
16
|
+
pid: z.number().int().positive(),
|
|
17
|
+
instance: z.uuid(),
|
|
18
|
+
pane: z
|
|
19
|
+
.string()
|
|
20
|
+
.regex(/^%[0-9]+$/u)
|
|
21
|
+
.max(32),
|
|
22
|
+
socket: z.string().min(1).max(1024).refine(isAbsolute),
|
|
23
|
+
})
|
|
24
|
+
.strict();
|
|
25
|
+
export type NativeBrowserEndpoint = z.infer<typeof NativeBrowserEndpointSchema>;
|
|
26
|
+
export class NativeBrowserUnavailable extends Error {
|
|
27
|
+
constructor() {
|
|
28
|
+
super("Native browser controls are unavailable");
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
const responseSchema = z.discriminatedUnion("ok", [
|
|
32
|
+
z
|
|
33
|
+
.object({ id: z.string(), ok: z.literal(true), data: NativeBrowserStateSchemaZ.nullable() })
|
|
34
|
+
.strict(),
|
|
35
|
+
z.object({ id: z.string(), ok: z.literal(false), error: z.string().max(4096) }).strict(),
|
|
36
|
+
]);
|
|
37
|
+
|
|
38
|
+
/** One request, one bounded response. Mutations are never retried or refreshed. */
|
|
39
|
+
export async function nativeBrowserRpc(
|
|
40
|
+
endpoint: NativeBrowserEndpoint,
|
|
41
|
+
command: NativeBrowserCommand | null,
|
|
42
|
+
signal: AbortSignal,
|
|
43
|
+
): Promise<NativeBrowserState | null> {
|
|
44
|
+
const target = NativeBrowserEndpointSchema.parse(endpoint);
|
|
45
|
+
const payload = command ? NativeBrowserCommandSchemaZ.parse(command) : null;
|
|
46
|
+
if (payload && (payload.instance !== target.instance || payload.pane !== target.pane))
|
|
47
|
+
throw new NativeBrowserUnavailable();
|
|
48
|
+
signal.throwIfAborted();
|
|
49
|
+
const info = await lstat(target.socket);
|
|
50
|
+
if (!info.isSocket() || info.uid !== process.getuid?.() || (info.mode & 0o022) !== 0)
|
|
51
|
+
throw new NativeBrowserUnavailable();
|
|
52
|
+
try {
|
|
53
|
+
process.kill(target.pid, 0);
|
|
54
|
+
} catch {
|
|
55
|
+
throw new NativeBrowserUnavailable();
|
|
56
|
+
}
|
|
57
|
+
signal.throwIfAborted();
|
|
58
|
+
const id = randomUUID();
|
|
59
|
+
const request =
|
|
60
|
+
JSON.stringify({
|
|
61
|
+
id,
|
|
62
|
+
cmd: payload ? "native/1/command" : "native/1/state",
|
|
63
|
+
...(payload ? { payload } : {}),
|
|
64
|
+
}) + "\n";
|
|
65
|
+
return new Promise((resolve, reject) => {
|
|
66
|
+
const socket = connect(target.socket);
|
|
67
|
+
let finished = false;
|
|
68
|
+
let size = 0;
|
|
69
|
+
const chunks: Buffer[] = [];
|
|
70
|
+
const finish = (error?: Error, state: NativeBrowserState | null = null) => {
|
|
71
|
+
if (finished) return;
|
|
72
|
+
finished = true;
|
|
73
|
+
clearTimeout(timer);
|
|
74
|
+
signal.removeEventListener("abort", abort);
|
|
75
|
+
socket.destroy();
|
|
76
|
+
if (error) reject(error);
|
|
77
|
+
else resolve(state);
|
|
78
|
+
};
|
|
79
|
+
const abort = () => finish(new NativeBrowserUnavailable());
|
|
80
|
+
const timer = setTimeout(abort, 1500);
|
|
81
|
+
signal.addEventListener("abort", abort, { once: true });
|
|
82
|
+
if (signal.aborted) {
|
|
83
|
+
abort();
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
socket.once("connect", () => socket.write(request));
|
|
87
|
+
socket.on("error", abort);
|
|
88
|
+
socket.once("end", () => {
|
|
89
|
+
if (!finished) abort();
|
|
90
|
+
});
|
|
91
|
+
socket.on("data", (chunk: Buffer) => {
|
|
92
|
+
size += chunk.length;
|
|
93
|
+
if (size > 128 * 1024) {
|
|
94
|
+
abort();
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
chunks.push(chunk);
|
|
98
|
+
if (!chunk.includes(10)) return;
|
|
99
|
+
try {
|
|
100
|
+
const bytes = Buffer.concat(chunks);
|
|
101
|
+
const newline = bytes.indexOf(10);
|
|
102
|
+
if (newline !== bytes.length - 1) throw new NativeBrowserUnavailable();
|
|
103
|
+
const reply = responseSchema.parse(
|
|
104
|
+
JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(bytes.subarray(0, newline))),
|
|
105
|
+
);
|
|
106
|
+
if (reply.id !== id || !reply.ok) throw new NativeBrowserUnavailable();
|
|
107
|
+
if (
|
|
108
|
+
reply.data &&
|
|
109
|
+
(reply.data.instance !== target.instance || reply.data.pane !== target.pane)
|
|
110
|
+
)
|
|
111
|
+
throw new NativeBrowserUnavailable();
|
|
112
|
+
finish(undefined, reply.data);
|
|
113
|
+
} catch {
|
|
114
|
+
abort();
|
|
115
|
+
}
|
|
116
|
+
});
|
|
117
|
+
});
|
|
118
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { PaneEditorContextTargetSchemaZ, NativeBrowserCommandSchemaZ } from "@tmux-ide/contracts";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import type { LiveSessionSummary } from "../command-center/discovery.ts";
|
|
4
|
+
import type { WorkspaceTerminalInventoryRuntime } from "../terminal/attachments/native-runtime.ts";
|
|
5
|
+
import type { WorkspaceRegistry } from "./workspace-registry.ts";
|
|
6
|
+
import { decodeTmuxArgument } from "../terminal/protocol/session-descriptor-discovery.ts";
|
|
7
|
+
import {
|
|
8
|
+
NativeBrowserEndpointSchema,
|
|
9
|
+
NativeBrowserUnavailable,
|
|
10
|
+
nativeBrowserRpc,
|
|
11
|
+
} from "./native-browser-rpc.ts";
|
|
12
|
+
|
|
13
|
+
export const NativeBrowserRequestSchema = z
|
|
14
|
+
.object({
|
|
15
|
+
target: PaneEditorContextTargetSchemaZ,
|
|
16
|
+
command: NativeBrowserCommandSchemaZ.nullable(),
|
|
17
|
+
})
|
|
18
|
+
.strict();
|
|
19
|
+
export function createNativeBrowserResolver(options: {
|
|
20
|
+
generation: string;
|
|
21
|
+
assertOpen(): void;
|
|
22
|
+
registry: Pick<WorkspaceRegistry, "get">;
|
|
23
|
+
catalog(): Promise<LiveSessionSummary[]>;
|
|
24
|
+
inventory: Pick<WorkspaceTerminalInventoryRuntime, "discoverTerminalInventory">;
|
|
25
|
+
run(args: readonly string[], signal: AbortSignal): Promise<string>;
|
|
26
|
+
}) {
|
|
27
|
+
let readers = 0;
|
|
28
|
+
return async (input: unknown, cancellation: AbortSignal) => {
|
|
29
|
+
const { target, command } = NativeBrowserRequestSchema.parse(input);
|
|
30
|
+
const signal = AbortSignal.any([cancellation, AbortSignal.timeout(2500)]);
|
|
31
|
+
const workspace = options.registry.get(target.workspaceName);
|
|
32
|
+
const assertCurrent = () => {
|
|
33
|
+
options.assertOpen();
|
|
34
|
+
signal.throwIfAborted();
|
|
35
|
+
if (
|
|
36
|
+
target.generation !== options.generation ||
|
|
37
|
+
!workspace ||
|
|
38
|
+
options.registry.get(target.workspaceName) !== workspace
|
|
39
|
+
)
|
|
40
|
+
throw new NativeBrowserUnavailable();
|
|
41
|
+
};
|
|
42
|
+
assertCurrent();
|
|
43
|
+
if (readers >= 8) throw new NativeBrowserUnavailable();
|
|
44
|
+
readers++;
|
|
45
|
+
try {
|
|
46
|
+
const sessions = await options.catalog();
|
|
47
|
+
assertCurrent();
|
|
48
|
+
const matching = sessions.filter((s) => s.liveSessionId === target.liveSessionId);
|
|
49
|
+
if (matching.length !== 1 || matching[0]!.sessionName !== workspace!.sessionName)
|
|
50
|
+
throw new NativeBrowserUnavailable();
|
|
51
|
+
const snapshot = await options.inventory.discoverTerminalInventory(signal);
|
|
52
|
+
assertCurrent();
|
|
53
|
+
if (
|
|
54
|
+
snapshot.catalog.invalidRuntimeProof ||
|
|
55
|
+
snapshot.catalog.duplicateSemanticStamp ||
|
|
56
|
+
snapshot.catalog.duplicateRuntimePaneBinding
|
|
57
|
+
)
|
|
58
|
+
throw new NativeBrowserUnavailable();
|
|
59
|
+
const matches = snapshot.panes.filter(
|
|
60
|
+
(p) =>
|
|
61
|
+
p.workspaceName === target.workspaceName &&
|
|
62
|
+
p.semanticPaneId === target.semanticPaneId &&
|
|
63
|
+
p.sessionName === workspace!.sessionName,
|
|
64
|
+
);
|
|
65
|
+
if (matches.length !== 1) throw new NativeBrowserUnavailable();
|
|
66
|
+
const pane = { ...matches[0]! };
|
|
67
|
+
if (!pane.nativePaneBirthId || !/^%[0-9]+$/u.test(pane.runtimePaneId))
|
|
68
|
+
throw new NativeBrowserUnavailable();
|
|
69
|
+
const observe = async () => {
|
|
70
|
+
const raw = await options.run(
|
|
71
|
+
[
|
|
72
|
+
"display-message",
|
|
73
|
+
"-p",
|
|
74
|
+
"-t",
|
|
75
|
+
pane.runtimePaneId,
|
|
76
|
+
"#{qa:@tmux_ide_pane_id}\t#{qa:pane_birth_id}\t#{qa:@tmux_ide_browser_control}",
|
|
77
|
+
],
|
|
78
|
+
signal,
|
|
79
|
+
);
|
|
80
|
+
assertCurrent();
|
|
81
|
+
if (raw.length > 8192) throw new NativeBrowserUnavailable();
|
|
82
|
+
const [semantic, birth, endpoint] = raw.trimEnd().split("\t").map(decodeTmuxArgument);
|
|
83
|
+
if (semantic !== target.semanticPaneId || birth !== pane.nativePaneBirthId)
|
|
84
|
+
throw new NativeBrowserUnavailable();
|
|
85
|
+
const parsed = NativeBrowserEndpointSchema.parse(JSON.parse(endpoint ?? "null"));
|
|
86
|
+
if (parsed.pane !== pane.runtimePaneId) throw new NativeBrowserUnavailable();
|
|
87
|
+
return parsed;
|
|
88
|
+
};
|
|
89
|
+
const endpoint = await observe();
|
|
90
|
+
const result = await nativeBrowserRpc(endpoint, command, signal);
|
|
91
|
+
assertCurrent();
|
|
92
|
+
const current = await observe();
|
|
93
|
+
if (JSON.stringify(current) !== JSON.stringify(endpoint))
|
|
94
|
+
throw new NativeBrowserUnavailable();
|
|
95
|
+
return result;
|
|
96
|
+
} finally {
|
|
97
|
+
readers--;
|
|
98
|
+
}
|
|
99
|
+
};
|
|
100
|
+
}
|