tmux-ide 2.9.13 → 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 +2054 -1555
- package/package.json +1 -1
- package/packages/contracts/src/index.ts +1 -0
- package/packages/contracts/src/native-browser.ts +43 -0
- package/packages/contracts/src/pane-stream.ts +7 -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 +186 -0
- package/packages/daemon/dist/terminal/mirror/session-channel.js +6 -0
- package/packages/daemon/dist/terminal/pane-stream/pane-stream-websocket.js +9 -3
- package/packages/daemon/dist/terminal/protocol/browser-icon.js +16 -0
- package/packages/daemon/dist/terminal/protocol/session-descriptor-discovery.js +5 -2
- 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 +244 -0
- package/packages/daemon/src/terminal/mirror/events.ts +1 -0
- package/packages/daemon/src/terminal/mirror/session-channel.ts +6 -0
- package/packages/daemon/src/terminal/pane-stream/pane-stream-websocket.ts +10 -4
- package/packages/daemon/src/terminal/protocol/browser-icon.ts +14 -0
- package/packages/daemon/src/terminal/protocol/session-descriptor-discovery.ts +7 -1
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { browserIcon } from "./browser-icon.js";
|
|
1
2
|
/**
|
|
2
3
|
* `qa` asks tmux to escape each value as a command argument. In particular,
|
|
3
4
|
* embedded tabs/newlines/backslashes become escapes, leaving our record tabs
|
|
@@ -27,6 +28,7 @@ export const SESSION_PANE_DESCRIPTOR_FORMAT = [
|
|
|
27
28
|
"#{session_windows}",
|
|
28
29
|
"#{pane_birth_id}",
|
|
29
30
|
"#{qa:@tmux_ide_name_source}",
|
|
31
|
+
"#{qa:@tmux_ide_browser_icon}",
|
|
30
32
|
].join("\t");
|
|
31
33
|
/** Decode the escapes emitted by tmux's `qa` format modifier. */
|
|
32
34
|
export function decodeTmuxArgument(value) {
|
|
@@ -100,9 +102,9 @@ export function parseSessionPaneDescriptorReply(lines) {
|
|
|
100
102
|
continue;
|
|
101
103
|
}
|
|
102
104
|
const encoded = utf8Line.split("\t");
|
|
103
|
-
if (encoded.length !== 21 && encoded.length !== 22)
|
|
105
|
+
if (encoded.length !== 21 && encoded.length !== 22 && encoded.length !== 23)
|
|
104
106
|
continue;
|
|
105
|
-
const [runtimePaneId = "", semanticPaneId = "", role = "", type = "", currentCommand = "", cwd = "", windowIndexRaw = "", windowName = "", windowId = "", title = "", runtimeSessionId = "", paneIndexRaw = "", name = "", missionStamp = "", paneActiveRaw = "", windowActiveRaw = "", semanticWindowId = "", sessionName = "", windowPaneCountRaw = "", sessionWindowCountRaw = "", nativePaneBirthId = "", nameSource = "",] = encoded.map(decodeTmuxArgument);
|
|
107
|
+
const [runtimePaneId = "", semanticPaneId = "", role = "", type = "", currentCommand = "", cwd = "", windowIndexRaw = "", windowName = "", windowId = "", title = "", runtimeSessionId = "", paneIndexRaw = "", name = "", missionStamp = "", paneActiveRaw = "", windowActiveRaw = "", semanticWindowId = "", sessionName = "", windowPaneCountRaw = "", sessionWindowCountRaw = "", nativePaneBirthId = "", nameSource = "", browserIconRaw = "",] = encoded.map(decodeTmuxArgument);
|
|
106
108
|
if (!/^%(?:0|[1-9][0-9]*)$/u.test(runtimePaneId) || runtimePaneId.length > 32)
|
|
107
109
|
continue;
|
|
108
110
|
if (!/^\$(?:0|[1-9][0-9]*)$/u.test(runtimeSessionId) || runtimeSessionId.length > 32)
|
|
@@ -139,6 +141,7 @@ export function parseSessionPaneDescriptorReply(lines) {
|
|
|
139
141
|
continue;
|
|
140
142
|
}
|
|
141
143
|
descriptors.push({
|
|
144
|
+
browserIcon: browserIcon(browserIconRaw),
|
|
142
145
|
sessionName,
|
|
143
146
|
runtimePaneId,
|
|
144
147
|
runtimeSessionId,
|
|
@@ -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
|
+
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { createNativeBrowserResolver } from "./tmux-native-browser.ts";
|
|
1
2
|
import { createNativeSurfaceContextResolver } from "./native-surface-context.ts";
|
|
2
3
|
import type { LocalImageOwner } from "../terminal/graphics/local-image-owner.ts";
|
|
3
4
|
import { shellEscape } from "./shell.ts";
|
|
@@ -619,6 +620,14 @@ export async function createNativeTmuxServerOwner(options: NativeTmuxServerOwner
|
|
|
619
620
|
run: (args, signal) => generationRunAsync(args, signal),
|
|
620
621
|
}),
|
|
621
622
|
resolvePaneEditorContext,
|
|
623
|
+
nativeBrowser: createNativeBrowserResolver({
|
|
624
|
+
generation,
|
|
625
|
+
assertOpen,
|
|
626
|
+
registry: workspaceRegistry,
|
|
627
|
+
catalog,
|
|
628
|
+
inventory: terminalInventoryRuntime,
|
|
629
|
+
run: generationRunAsync,
|
|
630
|
+
}),
|
|
622
631
|
catalog,
|
|
623
632
|
readWindowSplitLayout: async (workspaceName: string, input: WindowLinkTarget) => {
|
|
624
633
|
assertOpen();
|
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
import { accessSync, constants, realpathSync, statSync } from "node:fs";
|
|
2
|
+
import { delimiter, isAbsolute, join, resolve } from "node:path";
|
|
3
|
+
import { homedir } from "node:os";
|
|
4
|
+
import { IdeError } from "./lib/errors.ts";
|
|
5
|
+
import { resolveRuntimeNamespace, runtimeNamespaceEnvironment } from "./lib/runtime-namespace.ts";
|
|
6
|
+
import {
|
|
7
|
+
createPinnedWorkspaceTmuxRunner,
|
|
8
|
+
resolveWorkspacePaneTmuxAuthority,
|
|
9
|
+
} from "./lib/workspace-pane-creation.ts";
|
|
10
|
+
|
|
11
|
+
export interface OpenBrowserOptions {
|
|
12
|
+
session?: string;
|
|
13
|
+
pane?: string;
|
|
14
|
+
split?: string;
|
|
15
|
+
size?: string;
|
|
16
|
+
window?: boolean;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Resolve once before creating a pane; ignore relative PATH entries. */
|
|
20
|
+
export function resolveTerminalBrowser(
|
|
21
|
+
environment: NodeJS.ProcessEnv = process.env,
|
|
22
|
+
platform: NodeJS.Platform = process.platform,
|
|
23
|
+
): string {
|
|
24
|
+
const configured = environment.TMUX_IDE_TERMINAL_BROWSER_BIN;
|
|
25
|
+
const home = environment.HOME ?? homedir();
|
|
26
|
+
// Resolve the managed alias on every invocation so an app update selects its
|
|
27
|
+
// matching browser. Native assets remain outside the universal npm package.
|
|
28
|
+
const bundled =
|
|
29
|
+
platform === "darwin" && isAbsolute(home)
|
|
30
|
+
? [
|
|
31
|
+
join(
|
|
32
|
+
home,
|
|
33
|
+
"Library",
|
|
34
|
+
"Application Support",
|
|
35
|
+
"tmux-ide",
|
|
36
|
+
"native-alpha",
|
|
37
|
+
"TmuxIDE.app",
|
|
38
|
+
"Contents",
|
|
39
|
+
"Resources",
|
|
40
|
+
"browser",
|
|
41
|
+
"terminal-browser",
|
|
42
|
+
),
|
|
43
|
+
]
|
|
44
|
+
: [];
|
|
45
|
+
const candidates = configured
|
|
46
|
+
? [configured]
|
|
47
|
+
: [
|
|
48
|
+
...bundled,
|
|
49
|
+
...(environment.PATH ?? "")
|
|
50
|
+
.split(delimiter)
|
|
51
|
+
.filter(isAbsolute)
|
|
52
|
+
.map((dir) => join(dir, "terminal-browser")),
|
|
53
|
+
];
|
|
54
|
+
for (const candidate of candidates) {
|
|
55
|
+
try {
|
|
56
|
+
if (!isAbsolute(candidate)) continue;
|
|
57
|
+
accessSync(candidate, constants.X_OK);
|
|
58
|
+
const path = realpathSync(candidate);
|
|
59
|
+
if (statSync(path).isFile()) return path;
|
|
60
|
+
} catch {
|
|
61
|
+
/* Try the next executable. */
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
throw new IdeError(
|
|
65
|
+
configured
|
|
66
|
+
? "TMUX_IDE_TERMINAL_BROWSER_BIN must name an absolute executable browser launcher."
|
|
67
|
+
: "terminal-browser is not installed or is not on PATH. Install terminal-browser, or set TMUX_IDE_TERMINAL_BROWSER_BIN to its absolute launcher path.",
|
|
68
|
+
{ code: "BROWSER_UNAVAILABLE" },
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function hasControlCharacter(value: string): boolean {
|
|
73
|
+
return Array.from(value).some(
|
|
74
|
+
(character) => character.charCodeAt(0) < 32 || character.charCodeAt(0) === 127,
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function validateBrowserOpen(url: string | undefined, options: OpenBrowserOptions): void {
|
|
79
|
+
if (
|
|
80
|
+
url !== undefined &&
|
|
81
|
+
(!url || url.length > 16_384 || hasControlCharacter(url) || url.startsWith("-"))
|
|
82
|
+
)
|
|
83
|
+
throw new IdeError("Provide one URL, localhost address, or HTML file path.", {
|
|
84
|
+
code: "USAGE",
|
|
85
|
+
exitCode: 2,
|
|
86
|
+
});
|
|
87
|
+
if (options.split !== undefined && !["right", "down"].includes(options.split))
|
|
88
|
+
throw new IdeError("--split must be right or down.", { code: "USAGE", exitCode: 2 });
|
|
89
|
+
if (
|
|
90
|
+
options.size !== undefined &&
|
|
91
|
+
(!/^\d+$/u.test(options.size) || Number(options.size) < 10 || Number(options.size) > 90)
|
|
92
|
+
)
|
|
93
|
+
throw new IdeError("--size must be a percentage from 10 to 90.", {
|
|
94
|
+
code: "USAGE",
|
|
95
|
+
exitCode: 2,
|
|
96
|
+
});
|
|
97
|
+
if (options.window && (options.split !== undefined || options.size !== undefined))
|
|
98
|
+
throw new IdeError("--window cannot be combined with --split or --size.", {
|
|
99
|
+
code: "USAGE",
|
|
100
|
+
exitCode: 2,
|
|
101
|
+
});
|
|
102
|
+
if (options.pane !== undefined && !/^%\d+$/u.test(options.pane))
|
|
103
|
+
throw new IdeError("--pane must be an exact tmux pane ID, such as %3.", {
|
|
104
|
+
code: "USAGE",
|
|
105
|
+
exitCode: 2,
|
|
106
|
+
});
|
|
107
|
+
if (
|
|
108
|
+
options.session !== undefined &&
|
|
109
|
+
(!options.session || hasControlCharacter(options.session) || options.session.includes(":"))
|
|
110
|
+
)
|
|
111
|
+
throw new IdeError("--session must be an exact tmux session name.", {
|
|
112
|
+
code: "USAGE",
|
|
113
|
+
exitCode: 2,
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
type Run = (args: readonly string[]) => string;
|
|
118
|
+
export function createBrowserPane(
|
|
119
|
+
run: Run,
|
|
120
|
+
executable: string,
|
|
121
|
+
url: string | undefined,
|
|
122
|
+
options: OpenBrowserOptions,
|
|
123
|
+
environment: NodeJS.ProcessEnv,
|
|
124
|
+
cwd: string,
|
|
125
|
+
namespace: Readonly<Record<string, string>>,
|
|
126
|
+
): { pane: string; session: string; window: string; status: "created" } {
|
|
127
|
+
validateBrowserOpen(url, options);
|
|
128
|
+
const inheritedPane = /^%\d+$/u.test(environment.TMUX_PANE ?? "")
|
|
129
|
+
? environment.TMUX_PANE
|
|
130
|
+
: undefined;
|
|
131
|
+
const inheritedServer = /^(.*),(\d+),(\d+)$/u.exec(environment.TMUX ?? "");
|
|
132
|
+
const implicit = !options.pane && !options.session;
|
|
133
|
+
const target =
|
|
134
|
+
options.pane ??
|
|
135
|
+
(options.session
|
|
136
|
+
? `=${options.session}:`
|
|
137
|
+
: inheritedPane && inheritedServer
|
|
138
|
+
? `$${inheritedServer[3]}:.${inheritedPane}`
|
|
139
|
+
: undefined);
|
|
140
|
+
if (!target)
|
|
141
|
+
throw new IdeError(
|
|
142
|
+
"Run tmux-ide open inside a tmux pane, or select a session with --session NAME.",
|
|
143
|
+
{ code: "BROWSER_TARGET_REQUIRED", exitCode: 2 },
|
|
144
|
+
);
|
|
145
|
+
// Resolve IDs before mutation. No implicit last-used session or window target.
|
|
146
|
+
const identity = run([
|
|
147
|
+
"display-message",
|
|
148
|
+
"-p",
|
|
149
|
+
"-t",
|
|
150
|
+
target,
|
|
151
|
+
"#{session_id}\t#{window_id}\t#{pane_id}\t#{pid}",
|
|
152
|
+
])
|
|
153
|
+
.trim()
|
|
154
|
+
.split("\t");
|
|
155
|
+
const [session, window, sourcePane, serverPid] = identity;
|
|
156
|
+
if (
|
|
157
|
+
identity.length !== 4 ||
|
|
158
|
+
!/^\$\d+$/u.test(session ?? "") ||
|
|
159
|
+
!/^@\d+$/u.test(window ?? "") ||
|
|
160
|
+
!/^%\d+$/u.test(sourcePane ?? "")
|
|
161
|
+
)
|
|
162
|
+
throw new IdeError("The browser target is no longer available.", {
|
|
163
|
+
code: "BROWSER_TARGET_UNAVAILABLE",
|
|
164
|
+
});
|
|
165
|
+
if (implicit && serverPid !== inheritedServer?.[2])
|
|
166
|
+
throw new IdeError(
|
|
167
|
+
"The current pane belongs to a different tmux server. Select a session explicitly.",
|
|
168
|
+
{ code: "BROWSER_TARGET_MISMATCH", exitCode: 2 },
|
|
169
|
+
);
|
|
170
|
+
if (options.session && options.pane) {
|
|
171
|
+
const selected = run([
|
|
172
|
+
"display-message",
|
|
173
|
+
"-p",
|
|
174
|
+
"-t",
|
|
175
|
+
`=${options.session}:`,
|
|
176
|
+
"#{session_id}",
|
|
177
|
+
]).trim();
|
|
178
|
+
if (selected !== session)
|
|
179
|
+
throw new IdeError("The pane does not belong to the requested session.", {
|
|
180
|
+
code: "BROWSER_TARGET_MISMATCH",
|
|
181
|
+
exitCode: 2,
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
const args = options.window
|
|
185
|
+
? ["new-window", "-t", `${session}:`, "-n", "Browser"]
|
|
186
|
+
: [
|
|
187
|
+
"split-window",
|
|
188
|
+
options.split === "down" ? "-v" : "-h",
|
|
189
|
+
"-t",
|
|
190
|
+
`${session}:${window}.${sourcePane}`,
|
|
191
|
+
"-l",
|
|
192
|
+
`${options.size ?? "50"}%`,
|
|
193
|
+
];
|
|
194
|
+
args.push("-c", resolve(cwd), "-P", "-F", "#{session_id}\t#{window_id}\t#{pane_id}");
|
|
195
|
+
// tmux supplies the new pane's TMUX and TMUX_PANE. Never copy the caller's
|
|
196
|
+
// native-surface route, which belongs to a different pane.
|
|
197
|
+
const childEnvironment: Record<string, string> = { ...namespace };
|
|
198
|
+
for (const key of [
|
|
199
|
+
"PATH",
|
|
200
|
+
"HOME",
|
|
201
|
+
"XDG_CONFIG_HOME",
|
|
202
|
+
"XDG_DATA_HOME",
|
|
203
|
+
"XDG_STATE_HOME",
|
|
204
|
+
"XDG_CACHE_HOME",
|
|
205
|
+
"XDG_RUNTIME_DIR",
|
|
206
|
+
])
|
|
207
|
+
if (environment[key] !== undefined) childEnvironment[key] = environment[key];
|
|
208
|
+
for (const [key, value] of Object.entries(childEnvironment)) args.push("-e", `${key}=${value}`);
|
|
209
|
+
// Multiple command arguments use tmux's direct exec path, not shell parsing.
|
|
210
|
+
args.push(executable, "open", ...(url === undefined ? [] : [url]), "--no-merge");
|
|
211
|
+
const [createdSession, createdWindow, pane] = run(args).trim().split("\t");
|
|
212
|
+
if (
|
|
213
|
+
!/^%\d+$/u.test(pane ?? "") ||
|
|
214
|
+
createdSession !== session ||
|
|
215
|
+
!/^@\d+$/u.test(createdWindow ?? "")
|
|
216
|
+
)
|
|
217
|
+
throw new IdeError("tmux did not return the created browser pane ID.", {
|
|
218
|
+
code: "BROWSER_CREATE_FAILED",
|
|
219
|
+
});
|
|
220
|
+
return { pane: pane!, session: createdSession!, window: createdWindow!, status: "created" };
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
export function openBrowser(url: string | undefined, options: OpenBrowserOptions = {}) {
|
|
224
|
+
validateBrowserOpen(url, options);
|
|
225
|
+
const executable = resolveTerminalBrowser();
|
|
226
|
+
try {
|
|
227
|
+
const namespace = { ...runtimeNamespaceEnvironment(resolveRuntimeNamespace()) };
|
|
228
|
+
const authority = resolveWorkspacePaneTmuxAuthority();
|
|
229
|
+
namespace.TMUX_IDE_BROWSER_TMUX = authority.executablePath;
|
|
230
|
+
const run = createPinnedWorkspaceTmuxRunner(authority, {
|
|
231
|
+
timeoutMs: 10_000,
|
|
232
|
+
});
|
|
233
|
+
return createBrowserPane(run, executable, url, options, process.env, process.cwd(), namespace);
|
|
234
|
+
} catch (error) {
|
|
235
|
+
if (error instanceof IdeError) throw error;
|
|
236
|
+
throw new IdeError(
|
|
237
|
+
"Could not create the browser pane. Check that the target tmux session is running.",
|
|
238
|
+
{
|
|
239
|
+
code: "BROWSER_CREATE_FAILED",
|
|
240
|
+
cause: error instanceof Error ? error : undefined,
|
|
241
|
+
},
|
|
242
|
+
);
|
|
243
|
+
}
|
|
244
|
+
}
|
|
@@ -91,6 +91,7 @@ export type MirrorPaneEvent =
|
|
|
91
91
|
|
|
92
92
|
/** One visible pane rectangle inside a layout event, in window cells. */
|
|
93
93
|
export interface MirrorLayoutPane {
|
|
94
|
+
browserIcon?: string | null;
|
|
94
95
|
/** Null while the pane's semantic identity join is still unverified. */
|
|
95
96
|
semanticPaneId: string | null;
|
|
96
97
|
/** Daemon-authored presentation; clients never infer this from terminal bytes. */
|
|
@@ -749,6 +749,7 @@ export class SessionChannel {
|
|
|
749
749
|
async start(): Promise<void> {
|
|
750
750
|
await this.io.start();
|
|
751
751
|
await this.captureAttachedSessionIdentity();
|
|
752
|
+
this.io.send("refresh-client -B 'tmux-ide-browser-icon:%*:#{@tmux_ide_browser_icon}'");
|
|
752
753
|
// Changing border placement can resize PTYs without changing the layout
|
|
753
754
|
// string, so tmux emits no layout-change. Subscribe to this window option.
|
|
754
755
|
this.io.send("refresh-client -B 'tmux-ide-pane-borders:@*:#{pane-border-status}'");
|
|
@@ -2883,6 +2884,10 @@ export class SessionChannel {
|
|
|
2883
2884
|
name === "sessions-changed"
|
|
2884
2885
|
)
|
|
2885
2886
|
this.opts.onWindowTopologyChanged?.();
|
|
2887
|
+
if (name === "subscription-changed" && rest.startsWith("tmux-ide-browser-icon ")) {
|
|
2888
|
+
this.scheduleSync();
|
|
2889
|
+
return;
|
|
2890
|
+
}
|
|
2886
2891
|
if (name === "subscription-changed") {
|
|
2887
2892
|
const policy =
|
|
2888
2893
|
/^tmux-ide-scroll-on-clear\s+\$[0-9]+\s+@[0-9]+\s+[0-9]+\s+(%[0-9]+)\s+:\s+([01])\s*$/u.exec(
|
|
@@ -3299,6 +3304,7 @@ export class SessionChannel {
|
|
|
3299
3304
|
semanticPaneId: pane?.semanticId ?? null,
|
|
3300
3305
|
displayName: display?.name ?? null,
|
|
3301
3306
|
displayNameSource: display?.source ?? null,
|
|
3307
|
+
browserIcon: pane?.descriptor?.browserIcon ?? null,
|
|
3302
3308
|
left: leaf.left,
|
|
3303
3309
|
top: leaf.top,
|
|
3304
3310
|
width: leaf.width,
|
|
@@ -1515,8 +1515,11 @@ export class PaneStreamLiveConnection {
|
|
|
1515
1515
|
this.close(1011, "stream-unavailable");
|
|
1516
1516
|
return;
|
|
1517
1517
|
}
|
|
1518
|
-
|
|
1519
|
-
|
|
1518
|
+
// Layout authority covers the whole session, while content leases may select
|
|
1519
|
+
// only one pane (for example a native browser observing its own canvas).
|
|
1520
|
+
// Keep the full inventory fence without subscribing to unleased content.
|
|
1521
|
+
const expectedPaneIds = [...known].sort();
|
|
1522
|
+
if (this.#descriptor.panes.some((pane) => !known.has(pane))) {
|
|
1520
1523
|
this.#failTopologyChanged();
|
|
1521
1524
|
return;
|
|
1522
1525
|
}
|
|
@@ -1917,8 +1920,8 @@ export class PaneStreamLiveConnection {
|
|
|
1917
1920
|
// Renderers that cannot inflate see raw formats; their bytes are inflated
|
|
1918
1921
|
// when served.
|
|
1919
1922
|
const zlib =
|
|
1920
|
-
this.#descriptor.terminalDelivery?.protocolVersions.
|
|
1921
|
-
TERMINAL_GRAPHICS_ZLIB_CAPABILITY_VERSION,
|
|
1923
|
+
this.#descriptor.terminalDelivery?.protocolVersions.some(
|
|
1924
|
+
(version) => version === TERMINAL_GRAPHICS_ZLIB_CAPABILITY_VERSION || version === 117,
|
|
1922
1925
|
) === true;
|
|
1923
1926
|
channel.graphicsZlib = zlib;
|
|
1924
1927
|
channel.graphicsLocal =
|
|
@@ -2414,6 +2417,9 @@ export class PaneStreamLiveConnection {
|
|
|
2414
2417
|
pane: pane.semanticPaneId,
|
|
2415
2418
|
displayName: pane.displayName,
|
|
2416
2419
|
displayNameSource: pane.displayNameSource,
|
|
2420
|
+
...(this.#descriptor.terminalDelivery?.protocolVersions.includes(117)
|
|
2421
|
+
? { browserIcon: pane.browserIcon ?? null }
|
|
2422
|
+
: {}),
|
|
2417
2423
|
left: pane.left,
|
|
2418
2424
|
top: pane.top,
|
|
2419
2425
|
width: pane.width,
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/** Browser-provided 16×16 RGBA pixels, never a URL or filesystem path. */
|
|
2
|
+
export function browserIcon(value: string, alive = processAlive): string | null {
|
|
3
|
+
const match = /^([1-9][0-9]{0,9}):([0-9a-f]{2048}|)$/u.exec(value);
|
|
4
|
+
if (!match || !alive(Number(match[1]))) return null;
|
|
5
|
+
return match[2]!;
|
|
6
|
+
}
|
|
7
|
+
function processAlive(pid: number): boolean {
|
|
8
|
+
try {
|
|
9
|
+
process.kill(pid, 0);
|
|
10
|
+
return true;
|
|
11
|
+
} catch {
|
|
12
|
+
return false;
|
|
13
|
+
}
|
|
14
|
+
}
|