diff --git a/apps/desktop/scripts/neovim-terminal/launch.ps1 b/apps/desktop/scripts/neovim-terminal/launch.ps1 new file mode 100644 index 000000000000..345da00350e1 --- /dev/null +++ b/apps/desktop/scripts/neovim-terminal/launch.ps1 @@ -0,0 +1,24 @@ +param( + [Parameter(Mandatory=$true)][string]$Runtime, + [Parameter(Mandatory=$true)][string]$Token +) + +$ErrorActionPreference = 'Stop' +try { + if ($Token -cnotmatch '^[A-Za-z0-9_-]+$' -or $Token.Length -gt 21846) { + throw 'Invalid terminal-editor payload.' + } + # The packaged Electron executable supplies Node; no separate Windows runtime is required. + $env:ELECTRON_RUN_AS_NODE = '1' + # Start-Process waits for Electron's GUI-subsystem executable without + # PowerShell competing with the child for console input. + $helper = Join-Path $PSScriptRoot 'session.mjs' + $child = Start-Process -FilePath $Runtime -ArgumentList @(('"' + $helper + '"'), $Token) -NoNewWindow -Wait -PassThru + if ($child.ExitCode -ne 0) { + throw "The editor session exited with code $($child.ExitCode)." + } +} catch { + Write-Host $_ -ForegroundColor Red + Read-Host 'Press Enter to close this window' + exit 1 +} diff --git a/apps/desktop/scripts/neovim-terminal/session.mjs b/apps/desktop/scripts/neovim-terminal/session.mjs new file mode 100644 index 000000000000..79e08543778f --- /dev/null +++ b/apps/desktop/scripts/neovim-terminal/session.mjs @@ -0,0 +1,133 @@ +import * as NodeCrypto from "node:crypto"; +import * as NodeFSP from "node:fs/promises"; +import * as NodePath from "node:path"; +import * as NodeURL from "node:url"; +import * as NodeOS from "node:os"; +import { + decodeRequest, + encodeRequest, + findNeovim, + neovimArgs, + quotePosix, + run, + sshArgs, + wslArgs, +} from "./transport.mjs"; + +const sourceDirectory = NodePath.dirname(NodeURL.fileURLToPath(import.meta.url)); +let sources; +async function loadSources() { + sources ??= Promise.all( + ["session.mjs", "transport.mjs"].map(async (name) => [ + name, + await NodeFSP.readFile(NodePath.join(sourceDirectory, name), "utf8"), + ]), + ); + return Object.fromEntries(await sources); +} + +/** Source and payload use staging stdin; the final invocation contains only a private script path. */ +export async function stagingProgram(request, _node, directory, stagingUser) { + const files = await loadSources(); + files.request = encodeRequest(request); + return `import * as fs from 'node:fs/promises'; +import * as os from 'node:os'; +const directory = ${JSON.stringify(directory)}; +const files = ${JSON.stringify(files)}; +const expectedUser = ${JSON.stringify(stagingUser ?? null)}; +if (expectedUser && os.userInfo().username !== expectedUser) throw new Error('The WSL account changed. Reconnect before opening Neovim.'); +const quote = value => "'" + value.replaceAll("'", "'\\\\''") + "'"; +files['launch.sh'] = '#!/bin/bash\\nexec ' + quote(process.execPath) + ' ' + quote(directory + '/session.mjs') + ' --staged ' + quote(directory) + '\\n'; +// Consumed launchers are removed before editing; only abandoned preparation remains here. +let cleaned = 0; +for (const name of await fs.readdir('/tmp')) { + if (cleaned >= 64) break; + if (!/^t3code-neovim-[a-f0-9-]{36}$/.test(name)) continue; + const candidate = '/tmp/' + name; + try { + const stat = await fs.lstat(candidate); + if (stat.isDirectory() && !stat.isSymbolicLink() && stat.uid === process.getuid() && Date.now() - stat.mtimeMs > 86400000) { + await fs.rm(candidate, { recursive: true, force: true }); + cleaned++; + } + } catch { /* Another launcher may have consumed the directory. */ } +} +await fs.mkdir(directory, { mode: 0o700 }); +try { + for (const [name, data] of Object.entries(files)) await fs.writeFile(directory + '/' + name, data, { mode: 0o600, flag: 'wx' }); +} catch (error) { await fs.rm(directory, { recursive: true, force: true }); throw error; } +`; +} + +function bootstrap(program, node) { + return `set -eu\n${node ? `node_path=${quotePosix(node)}` : "node_path=$(command -v node) || { echo 'Node.js is missing from this account’s login PATH.' >&2; exit 72; }"}\n"$node_path" --input-type=module <<'T3_NEOVIM_STAGE'\n${program}\nT3_NEOVIM_STAGE\n`; +} + +export async function launchSession(request) { + const route = request.route; + if (route.kind === "native") { + if (request.expectedAccount && NodeOS.userInfo().username !== request.expectedAccount) + throw new Error("The target account changed. Reconnect and check Neovim again."); + const executable = await findNeovim(request.executable); + await run(executable, await neovimArgs(request.target), { cwd: request.workspace }); + return; + } + const directory = `/tmp/t3code-neovim-${NodeCrypto.randomUUID()}`; + if (route.kind === "wsl" || route.kind === "wsl-ssh") { + const next = { + ...request, + platform: "linux", + route: + route.kind === "wsl" + ? { kind: "native" } + : { + kind: "ssh", + host: route.host, + sshUser: route.sshUser, + port: route.port, + remoteNode: route.remoteNode, + }, + }; + const prefix = wslArgs(route); + await run("wsl.exe", [...prefix, "/bin/bash", "-l", "-s"], { + input: bootstrap(await stagingProgram(next, route.node, directory, route.user), route.node), + timeout: 30_000, + }); + await run("wsl.exe", [...prefix, "/bin/bash", "-l", `${directory}/launch.sh`]); + return; + } + // oxlint-disable-next-line t3code/no-global-process-runtime -- Standalone packaged helper has no Effect runtime. + const ssh = process.platform === "win32" ? "ssh.exe" : "ssh"; + const next = { ...request, platform: "linux", route: { kind: "native" } }; + // SSH authenticates on the terminal; the preparation program has a separate stdin pipe. + await run(ssh, [...sshArgs(route, false), "/bin/bash -l -s"], { + input: bootstrap(await stagingProgram(next, route.remoteNode, directory), route.remoteNode), + }); + await run(ssh, [...sshArgs(route, true), `/bin/bash -l ${quotePosix(`${directory}/launch.sh`)}`]); +} + +async function main() { + delete process.env.ELECTRON_RUN_AS_NODE; + let token; + if (process.argv[2] === "--staged") { + const directory = process.argv[3]; + if (!/^\/tmp\/t3code-neovim-[a-f0-9-]{36}$/u.test(directory)) + throw new Error("Invalid staging directory."); + try { + token = await NodeFSP.readFile(`${directory}/request`, "utf8"); + await loadSources(); + } finally { + await NodeFSP.rm(directory, { recursive: true, force: true }); + } + } else token = process.argv[2]; + await launchSession(decodeRequest(token)); +} +if ( + process.argv[1] && + NodeURL.pathToFileURL(NodePath.resolve(process.argv[1])).href === import.meta.url +) { + main().catch((error) => { + console.error(`Neovim could not complete the session: ${error.message}`); + process.exitCode = 1; + }); +} diff --git a/apps/desktop/scripts/neovim-terminal/spike.mjs b/apps/desktop/scripts/neovim-terminal/spike.mjs new file mode 100644 index 000000000000..402bb5af4266 --- /dev/null +++ b/apps/desktop/scripts/neovim-terminal/spike.mjs @@ -0,0 +1,47 @@ +import * as NodeChildProcess from "node:child_process"; +import * as NodeFSP from "node:fs/promises"; +import * as NodePath from "node:path"; +import * as NodeURL from "node:url"; +import { encodeRequest, windowsTerminalArgs } from "./transport.mjs"; + +// Invoke using the packaged Electron executable with ELECTRON_RUN_AS_NODE=1. +// This harness intentionally has no product IPC or editor-preference entry point. +// oxlint-disable-next-line t3code/no-global-process-runtime -- Standalone packaged helper has no Effect runtime. +if (process.platform !== "win32" || !process.versions.electron) { + throw new Error("Run this spike with the packaged Windows Electron runtime."); +} +const request = JSON.parse(await NodeFSP.readFile(process.argv[2], "utf8")); +const powershell = NodePath.join( + process.env.SystemRoot, + "System32", + "WindowsPowerShell", + "v1.0", + "powershell.exe", +); +const bootstrap = NodePath.join( + NodePath.dirname(NodeURL.fileURLToPath(import.meta.url)), + "launch.ps1", +); +const terminal = NodePath.join(process.env.LOCALAPPDATA, "Microsoft", "WindowsApps", "wt.exe"); +await Promise.all([ + NodeFSP.access(terminal), + NodeFSP.access(powershell), + NodeFSP.access(bootstrap), +]); +const args = windowsTerminalArgs({ + powershell, + bootstrap, + runtime: process.execPath, + token: encodeRequest(request), +}); +const child = NodeChildProcess.spawn(terminal, args, { + detached: true, + stdio: "ignore", + shell: false, +}); +await new Promise((resolve, reject) => { + child.once("error", reject); + child.once("spawn", resolve); +}); +child.unref(); +console.log("Windows Terminal accepted the launch. This does not confirm SSH or Neovim readiness."); diff --git a/apps/desktop/scripts/neovim-terminal/transport.d.mts b/apps/desktop/scripts/neovim-terminal/transport.d.mts new file mode 100644 index 000000000000..f74c1b4a9752 --- /dev/null +++ b/apps/desktop/scripts/neovim-terminal/transport.d.mts @@ -0,0 +1,52 @@ +import type { EditorOpenTarget } from "@t3tools/contracts"; +export type TerminalRoute = + | { kind: "native" } + | { kind: "wsl"; distro: string; user: string; node?: string } + | { kind: "ssh"; host: string; sshUser?: string; port?: number; remoteNode?: string } + | { + kind: "wsl-ssh"; + distro: string; + user: string; + node?: string; + host: string; + sshUser?: string; + port?: number; + remoteNode?: string; + }; +export interface LaunchPayload { + version: 1; + id: string; + platform: string; + route: TerminalRoute; + workspace: string; + target: EditorOpenTarget; + executable?: string; + expectedAccount?: string; +} +export function encodeRequest(input: LaunchPayload): string; +export function decodeRequest(token: string): LaunchPayload; +export function quotePosix(value: string): string; +export function windowsTerminalArgs(input: { + powershell: string; + bootstrap: string; + runtime: string; + token: string; +}): string[]; +export function sshArgs( + route: Extract, + tty: boolean, +): string[]; +export function wslArgs(route: Extract): string[]; +export function run( + command: string, + args: readonly string[], + options?: { + input?: string; + timeout?: number; + cwd?: string; + capture?: boolean; + env?: NodeJS.ProcessEnv; + }, +): Promise; +export function findNeovim(override?: string, environment?: NodeJS.ProcessEnv): Promise; +export function neovimArgs(target: EditorOpenTarget): Promise; diff --git a/apps/desktop/scripts/neovim-terminal/transport.mjs b/apps/desktop/scripts/neovim-terminal/transport.mjs new file mode 100644 index 000000000000..324010e75e2a --- /dev/null +++ b/apps/desktop/scripts/neovim-terminal/transport.mjs @@ -0,0 +1,261 @@ +import * as NodeChildProcess from "node:child_process"; +import * as NodeFS from "node:fs"; +import * as NodeFSP from "node:fs/promises"; +import * as NodePath from "node:path"; + +const MAX_PAYLOAD_BYTES = 16_384; +const MAX_POSITION = 2_147_483_647; + +function text(value, name) { + if (typeof value !== "string" || !value.length || value.includes("\0")) { + throw new Error(`Invalid ${name}.`); + } + return value; +} + +function absolute(value, name, windows = false) { + text(value, name); + if (!(windows ? NodePath.win32 : NodePath.posix).isAbsolute(value)) { + throw new Error(`${name} must be an absolute path.`); + } + return value; +} + +function account(value, name) { + text(value, name); + if (value.startsWith("-") || /[\r\n]/u.test(value)) throw new Error(`Invalid ${name}.`); + return value; +} + +/** Validate desktop-resolved routes at the standalone helper boundary. */ +export function validateRequest(input) { + if (!input || typeof input !== "object" || input.version !== 1) { + throw new Error("Unsupported terminal-editor payload version."); + } + if (!/^[a-f0-9-]{36}$/u.test(input.id)) throw new Error("Invalid request ID."); + const route = input.route; + if (!route || !["native", "wsl", "ssh", "wsl-ssh"].includes(route.kind)) { + throw new Error("Unsupported terminal-editor route."); + } + if (route.kind === "wsl" || route.kind === "wsl-ssh") { + account(route.distro, "WSL distro"); + account(route.user, "WSL user"); + if (route.node !== undefined) absolute(route.node, "WSL Node executable"); + } + if (route.kind === "ssh" || route.kind === "wsl-ssh") { + account(route.host, "SSH host or alias"); + if (route.sshUser !== undefined) account(route.sshUser, "SSH user"); + if ( + route.port !== undefined && + (!Number.isInteger(route.port) || route.port < 1 || route.port > 65535) + ) { + throw new Error("Invalid SSH port."); + } + if (route.remoteNode !== undefined) absolute(route.remoteNode, "remote Node executable"); + } + const windows = route.kind === "native" && input.platform === "win32"; + absolute(input.workspace, "workspace", windows); + if (!input.target || !["file", "directory"].includes(input.target.kind)) { + throw new Error("Invalid editor target."); + } + absolute(input.target.path, "target path", windows); + for (const key of ["line", "column"]) { + const value = input.target[key]; + if ( + value !== undefined && + (input.target.kind !== "file" || + !Number.isInteger(value) || + value < 1 || + value > MAX_POSITION) + ) { + throw new Error(`Invalid ${key}.`); + } + } + if ( + input.target.columnEncoding !== undefined && + !["utf-8", "utf-16"].includes(input.target.columnEncoding) + ) { + throw new Error("Unsupported column encoding."); + } + if (input.executable !== undefined) absolute(input.executable, "Neovim executable", windows); + if (input.expectedAccount !== undefined) account(input.expectedAccount, "expected account"); + return input; +} + +export function encodeRequest(input) { + const bytes = Buffer.from(JSON.stringify(validateRequest(input))); + if (bytes.length > MAX_PAYLOAD_BYTES) throw new Error("Terminal-editor payload is too large."); + return bytes.toString("base64url"); +} + +export function decodeRequest(token) { + if ( + typeof token !== "string" || + token.length > Math.ceil((MAX_PAYLOAD_BYTES * 4) / 3) || + !/^[A-Za-z0-9_-]+$/u.test(token) + ) { + throw new Error("Invalid terminal-editor token."); + } + const bytes = Buffer.from(token, "base64url"); + if (bytes.toString("base64url") !== token) throw new Error("Noncanonical terminal-editor token."); + return validateRequest(JSON.parse(bytes.toString("utf8"))); +} + +export function quotePosix(value) { + return `'${text(value, "shell argument").replaceAll("'", "'\\''")}'`; +} + +/** Only installation paths and an encoded token cross Windows Terminal's command grammar. */ +export function windowsTerminalArgs({ powershell, bootstrap, runtime, token }) { + decodeRequest(token); + for (const value of [powershell, bootstrap, runtime]) { + absolute(value, "Windows helper path", true); + if (/[;"\r\n]/u.test(value)) { + throw new Error( + "This Windows Terminal adapter cannot represent the helper installation path.", + ); + } + } + return [ + "-w", + "new", + "new-tab", + powershell, + "-NoLogo", + "-NoProfile", + "-ExecutionPolicy", + "Bypass", + "-File", + bootstrap, + "-Runtime", + runtime, + "-Token", + token, + ]; +} + +export function sshArgs(route, tty) { + const args = tty ? ["-t"] : []; + if (route.sshUser !== undefined) args.push("-l", route.sshUser); + if (route.port !== undefined) args.push("-p", String(route.port)); + // A saved alias retains its normal ProxyJump, agent and known-hosts configuration. + args.push("--", route.host); + return args; +} + +export function wslArgs(route) { + return ["--distribution", route.distro, "--user", route.user, "--exec"]; +} + +/** Staging owns a separate stdin pipe; the editor process always inherits the terminal. */ +export function run(command, args, { input, timeout, cwd, capture = false, env } = {}) { + return new Promise((resolve, reject) => { + // Packaged Electron can inherit a pipe as fd 0 from PowerShell even in a + // terminal window. Open the console explicitly for the interactive child. + const consoleInput = + // oxlint-disable-next-line t3code/no-global-process-runtime -- Standalone packaged helper owns its platform-specific console handles. + input === undefined && process.platform === "win32" + ? NodeFS.openSync("\\\\.\\CONIN$", "r") + : undefined; + let child; + try { + child = NodeChildProcess.spawn(command, args, { + shell: false, + cwd, + env, + stdio: [ + input === undefined ? (consoleInput ?? "inherit") : "pipe", + capture ? "pipe" : "inherit", + capture ? "pipe" : "inherit", + ], + }); + } finally { + if (consoleInput !== undefined) NodeFS.closeSync(consoleInput); + } + let output = ""; + let timer; + let killTimer; + let failure; + const stop = (error) => { + if (failure) return; + failure = error; + child.kill(); + killTimer = setTimeout(() => child.kill("SIGKILL"), 1_000); + killTimer.unref(); + }; + if (timeout) timer = setTimeout(() => stop(new Error(`${command} timed out.`)), timeout); + if (capture) { + for (const stream of [child.stdout, child.stderr]) { + stream.on("data", (chunk) => { + if (failure) return; + output += chunk.toString(); + if (output.length > 65_536) stop(new Error("Probe output exceeded its limit.")); + }); + } + } + child.on("error", (error) => { + clearTimeout(timer); + clearTimeout(killTimer); + reject(error); + }); + child.on("close", (code, signal) => { + clearTimeout(timer); + clearTimeout(killTimer); + if (failure) reject(failure); + else if (code !== 0) + reject( + new Error(`${command} exited with ${signal ?? code}.${output ? `\n${output}` : ""}`), + ); + else resolve(output); + }); + if (input !== undefined) { + child.stdin.on("error", (error) => { + if (input.length > 0) failure ??= error; + }); + child.stdin.end(input); + } + }); +} + +export async function findNeovim(override, environment = process.env) { + const candidates = override + ? [override] + : (environment.PATH ?? "") + .split(NodePath.delimiter) + .filter(Boolean) + // oxlint-disable-next-line t3code/no-global-process-runtime -- Standalone packaged helper has no Effect runtime. + .map((dir) => NodePath.resolve(dir, process.platform === "win32" ? "nvim.exe" : "nvim")); + for (const candidate of candidates) { + try { + await NodeFSP.access(candidate, NodeFS.constants.X_OK); + } catch { + continue; + } + const version = await run(candidate, ["--version"], { + input: "", + timeout: 10_000, + capture: true, + }); + if (!version.startsWith("NVIM v")) throw new Error("The executable is not Neovim."); + return candidate; + } + throw new Error("Neovim was not found in the selected account's login PATH."); +} + +export async function neovimArgs(target) { + const args = []; + if (target.kind === "file" && (target.line !== undefined || target.column !== undefined)) { + const line = target.line ?? 1; + let column = target.column ?? 1; + if (target.columnEncoding === "utf-16" && column > 1) { + const content = await NodeFSP.readFile(target.path, "utf8"); + const sourceLine = content.split("\n", line)[line - 1] ?? ""; + const prefix = sourceLine.slice(0, column - 1); + if (/[\uD800-\uDBFF]$/u.test(prefix)) + throw new Error("Column splits a UTF-16 surrogate pair."); + column = Buffer.byteLength(prefix, "utf8") + 1; + } + args.push(`+call cursor(${line},${column})`); + } + return [...args, "--", target.path]; +} diff --git a/apps/desktop/scripts/neovim-terminal/transport.test.mjs b/apps/desktop/scripts/neovim-terminal/transport.test.mjs new file mode 100644 index 000000000000..12385d8103c0 --- /dev/null +++ b/apps/desktop/scripts/neovim-terminal/transport.test.mjs @@ -0,0 +1,245 @@ +import * as NodeAssert from "node:assert/strict"; +import * as NodeCrypto from "node:crypto"; +import * as NodeFSP from "node:fs/promises"; +import * as NodeOS from "node:os"; +import * as NodePath from "node:path"; +import { test } from "vite-plus/test"; +import * as NodeURL from "node:url"; +import { stagingProgram } from "./session.mjs"; +import { + decodeRequest, + encodeRequest, + neovimArgs, + quotePosix, + run, + sshArgs, + windowsTerminalArgs, + wslArgs, +} from "./transport.mjs"; + +const request = (overrides = {}) => ({ + version: 1, + id: NodeCrypto.randomUUID(), + platform: "linux", + route: { kind: "native" }, + workspace: "/home/test/worktree", + target: { + kind: "file", + path: "/home/test/worktree/file.txt", + line: 2, + column: 3, + columnEncoding: "utf-8", + }, + ...overrides, +}); + +test("target bytes remain data across token and Windows Terminal grammar", () => { + for (const name of [ + "spaces here", + "single'quote", + 'double"quote', + "semi;new-tab", + "$(touch nope)", + "`touch nope`", + "雪😀", + "literal\nnewline", + "colon:12:34", + "-leading-dash", + ]) { + const input = request({ target: { kind: "file", path: `/tmp/${name}` } }); + const token = encodeRequest(input); + NodeAssert.deepEqual(decodeRequest(token), input); + NodeAssert.match(token, /^[A-Za-z0-9_-]+$/u); + const args = windowsTerminalArgs({ + powershell: "C:\\Windows\\powershell.exe", + bootstrap: "C:\\Program Files\\T3\\launch.ps1", + runtime: "C:\\Program Files\\T3\\T3.exe", + token, + }); + NodeAssert.deepEqual(args.slice(0, 3), ["-w", "new", "new-tab"]); + NodeAssert.equal(args.at(-1), token); + NodeAssert.equal(args.filter((value) => value.includes(";")).length, 0); + } +}); + +test("malformed, oversized and unsupported launch payloads fail before spawning", () => { + for (const change of [ + { version: 2 }, + { route: { kind: "command", command: "anything" } }, + { workspace: "relative" }, + { target: { kind: "file", path: "/tmp/bad\0path" } }, + { target: { kind: "file", path: "/tmp/file", line: -1 } }, + { target: { kind: "file", path: "/tmp/file", column: 1.5 } }, + { target: { kind: "file", path: "/tmp/file", column: 2 ** 32 } }, + { target: { kind: "file", path: "/tmp/file", columnEncoding: "unknown" } }, + { target: { kind: "file", path: `/tmp/${"a".repeat(20_000)}` } }, + { route: { kind: "wsl", distro: "Ubuntu", node: "/usr/bin/node" } }, + { route: { kind: "ssh", host: "-oProxyCommand=bad", remoteNode: "/usr/bin/node" } }, + ]) + NodeAssert.throws(() => encodeRequest(request(change))); + for (const token of ["", "a;new-tab", "AA==", "a".repeat(30_000)]) + NodeAssert.throws(() => decodeRequest(token)); + NodeAssert.throws( + () => + windowsTerminalArgs({ + powershell: "C:\\Windows\\powershell.exe", + bootstrap: "C:\\bad;dir\\launch.ps1", + runtime: "C:\\T3.exe", + token: encodeRequest(request()), + }), + /installation path/u, + ); +}); + +test("WSL binds distro and account; SSH preserves saved alias, port and user", () => { + const route = { + kind: "wsl-ssh", + distro: "Ubuntu Work", + user: "alice", + node: "/usr/bin/node", + host: "work-alias", + sshUser: "bob", + port: 2222, + remoteNode: "/usr/bin/node", + }; + NodeAssert.deepEqual(wslArgs(route), [ + "--distribution", + "Ubuntu Work", + "--user", + "alice", + "--exec", + ]); + NodeAssert.deepEqual(sshArgs(route, true), ["-t", "-l", "bob", "-p", "2222", "--", "work-alias"]); + NodeAssert.deepEqual(sshArgs(route, false), ["-l", "bob", "-p", "2222", "--", "work-alias"]); +}); + +test("UTF-16 columns become Neovim byte columns without splitting surrogate pairs", async () => { + const directory = await NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "t3-neovim-column-")); + try { + const file = NodePath.join(directory, "multibyte.txt"); + await NodeFSP.writeFile(file, "first\na雪😀z\n"); + NodeAssert.deepEqual( + await neovimArgs({ kind: "file", path: file, line: 2, column: 5, columnEncoding: "utf-16" }), + ["+call cursor(2,9)", "--", file], + ); + await NodeAssert.rejects( + neovimArgs({ kind: "file", path: file, line: 2, column: 4, columnEncoding: "utf-16" }), + /surrogate pair/u, + ); + NodeAssert.deepEqual(await neovimArgs({ kind: "file", path: file, line: 2, column: 9 }), [ + "+call cursor(2,9)", + "--", + file, + ]); + } finally { + await NodeFSP.rm(directory, { recursive: true, force: true }); + } +}); + +// oxlint-disable-next-line t3code/no-global-process-runtime -- Standalone packaged helper has no Effect runtime. +test.skipIf(process.platform === "win32")( + "staged session preserves hostile filenames, cwd, PATH and editor stdin", + async () => { + const directory = await NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "t3-neovim-session-")); + const staged = `/tmp/t3code-neovim-${NodeCrypto.randomUUID()}`; + try { + const recorder = NodePath.join(directory, "nvim"); + const result = NodePath.join(directory, "received.json"); + await NodeFSP.writeFile( + recorder, + `#!${process.execPath}\nconst fs = require('node:fs');\nif (process.argv[2] === '--version') { console.log('NVIM v0.11.0'); process.exit(0); }\nfs.writeFileSync(${JSON.stringify(result)}, JSON.stringify({ args: process.argv.slice(2), cwd: process.cwd(), path: process.env.PATH, stdin: fs.readFileSync(0, 'utf8') }));\n`, + { mode: 0o700 }, + ); + const target = NodePath.join(directory, "-雪 '\";$(touch nope)`touch nope`\n:12:34"); + const input = request({ + workspace: directory, + executable: recorder, + target: { kind: "file", path: target, line: 3, column: 4 }, + }); + const source = await stagingProgram(input, process.execPath, staged); + await run(process.execPath, ["--input-type=module"], { input: source }); + const stagedToken = await NodeFSP.readFile(`${staged}/request`, "utf8"); + NodeAssert.deepEqual(decodeRequest(stagedToken), input); + await run("/bin/bash", ["-l", `${staged}/launch.sh`], { input: "editor keyboard input\n" }); + const received = JSON.parse(await NodeFSP.readFile(result, "utf8")); + NodeAssert.deepEqual(received.args, ["+call cursor(3,4)", "--", target]); + NodeAssert.equal(received.cwd, directory); + NodeAssert.equal(received.stdin, "editor keyboard input\n"); + NodeAssert.ok(received.path.length > 0); + await NodeAssert.rejects(NodeFSP.readFile(`${staged}/request`), { code: "ENOENT" }); + await NodeAssert.rejects(NodeFSP.readFile(NodePath.join(directory, "nope")), { + code: "ENOENT", + }); + } finally { + await NodeFSP.rm(directory, { recursive: true, force: true }); + await NodeFSP.rm(staged, { recursive: true, force: true }); + } + }, +); + +// oxlint-disable-next-line t3code/no-global-process-runtime -- Standalone packaged helper has no Effect runtime. +test.skipIf(process.platform === "win32")( + "POSIX quoting round-trips shell metacharacters as one argument", + async () => { + const value = "a'b\";$(false)`false`\n雪"; + const output = await run("/bin/sh", ["-c", `printf '%s' ${quotePosix(value)}`], { + input: "", + capture: true, + }); + NodeAssert.equal(output, value); + }, +); + +test("spawn and child failures are returned to the terminal wrapper", async () => { + await NodeAssert.rejects(run("t3-neovim-nonexistent-executable", [], { input: "" }), /ENOENT/u); + await NodeAssert.rejects(run(process.execPath, ["-e", "process.exit(17)"], { input: "" }), /17/u); + const session = NodeURL.fileURLToPath(new URL("session.mjs", import.meta.url)); + await NodeAssert.rejects( + run(process.execPath, [session, "bad-token"], { input: "", capture: true }), + /could not complete/u, + ); +}); + +test.skipIf(!process.env.T3CODE_TEST_NVIM)( + "Neovim opens the exact file and multibyte cursor position", + async () => { + const directory = await NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "t3-neovim-real-")); + try { + const file = NodePath.join(directory, "-雪 ' ; $() :12:34.txt"); + const result = NodePath.join(directory, "cursor.json"); + await NodeFSP.writeFile(file, "first\na雪😀z\n"); + const args = await neovimArgs({ + kind: "file", + path: file, + line: 2, + column: 5, + columnEncoding: "utf-16", + }); + const record = `call writefile([json_encode({'path': expand('%:p'), 'line': line('.'), 'column': col('.')})], '${result.replaceAll("'", "''")}')`; + await run( + process.env.T3CODE_TEST_NVIM, + [ + "--headless", + "-u", + "NONE", + "-i", + "NONE", + ...args.slice(0, -2), + "-c", + record, + "-c", + "qa!", + ...args.slice(-2), + ], + { input: "", timeout: 10_000, capture: true }, + ); + NodeAssert.deepEqual(JSON.parse(await NodeFSP.readFile(result, "utf8")), { + path: file, + line: 2, + column: 9, + }); + } finally { + await NodeFSP.rm(directory, { recursive: true, force: true }); + } + }, +); diff --git a/apps/desktop/src/backend/DesktopBackendConfiguration.test.ts b/apps/desktop/src/backend/DesktopBackendConfiguration.test.ts index accfdf70b3a3..cf07ae1863ab 100644 --- a/apps/desktop/src/backend/DesktopBackendConfiguration.test.ts +++ b/apps/desktop/src/backend/DesktopBackendConfiguration.test.ts @@ -677,7 +677,7 @@ describe("DesktopBackendConfiguration", () => { isAvailable: true, distros: [{ name: "Ubuntu", isDefault: true, version: 2 }], windowsToWslPath: () => Option.some(linuxAppRoot), - ensureNodePty: () => ({ ok: true, nodePath, resolvedPath }), + ensureNodePty: () => ({ ok: true, nodePath, resolvedPath, runningUser: "alice" }), getDistroIp: () => Option.some("172.27.0.99"), }), ), @@ -694,10 +694,14 @@ describe("DesktopBackendConfiguration", () => { ), ); + assert.equal(config.runningUser, "alice"); + assert.equal(config.wslNodePath, nodePath); assert.equal(config.bootstrapDelivery, "stdin"); assert.deepEqual(config.args, [ "-d", "Ubuntu", + "--user", + "alice", "--exec", "env", "PATH=/home/test user's/.nvm/versions/node/v22.0.0/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:/home/test user/bin:/opt/test's tools/bin:/usr/bin:/bin", diff --git a/apps/desktop/src/backend/DesktopBackendConfiguration.ts b/apps/desktop/src/backend/DesktopBackendConfiguration.ts index 4c43070b5f97..ee8b219c4d9c 100644 --- a/apps/desktop/src/backend/DesktopBackendConfiguration.ts +++ b/apps/desktop/src/backend/DesktopBackendConfiguration.ts @@ -213,6 +213,7 @@ interface SharedBootstrapInput { interface WslPreflightSuccess { readonly _tag: "Ready"; readonly runningDistro: string; + readonly runningUser?: string; readonly windowsEntryPath: string; readonly linuxEntryPath: string; // Absolute path to the node binary the preflight validated after the shared @@ -375,6 +376,7 @@ const runWslPreflight = Effect.fn("desktop.backendConfiguration.wslPreflight")(f windowsEntryPath: environment.backendEntryPath, linuxEntryPath: `${runtime.linuxAppRoot}/apps/server/dist/bin.mjs`, nodePath: stagedNodePty.nodePath, + ...(stagedNodePty.runningUser ? { runningUser: stagedNodePty.runningUser } : {}), resolvedPath: stagedNodePty.resolvedPath, runtimeId: input.runtimeArchive.runtimeId, } as const; @@ -431,6 +433,7 @@ const runWslPreflight = Effect.fn("desktop.backendConfiguration.wslPreflight")(f windowsEntryPath: mounted.windowsEntryPath, linuxEntryPath: `${mounted.linuxAppRoot}/apps/server/dist/bin.mjs`, nodePath: nodePtyResult.nodePath, + ...(nodePtyResult.runningUser ? { runningUser: nodePtyResult.runningUser } : {}), resolvedPath: nodePtyResult.resolvedPath, } as const; }); @@ -639,7 +642,11 @@ const resolveWslStartConfig = Effect.fn("desktop.backendConfiguration.resolveWsl : Option.getOrElse(distroIp, () => "127.0.0.1"); const httpBaseUrl = new URL(`http://${rendererHost}:${input.port}`); - const distroArgs = distroForConfig ? ["-d", distroForConfig] : []; + const runningUser = preflight._tag === "Ready" ? preflight.runningUser : undefined; + const distroArgs = [ + ...(distroForConfig ? ["-d", distroForConfig] : []), + ...(runningUser ? ["--user", runningUser] : []), + ]; const forwardedEnv: Record = {}; const forwardedEnvNames: string[] = []; for (const name of WSL_FORWARDED_ENV_NAMES) { @@ -681,6 +688,7 @@ const resolveWslStartConfig = Effect.fn("desktop.backendConfiguration.resolveWsl httpBaseUrl, captureOutput: true, ...(runningDistro !== null ? { runningDistro } : {}), + ...(runningUser ? { runningUser } : {}), }; // Forward the dev-server URL as an explicit CLI flag so the WSL backend's @@ -721,6 +729,7 @@ const resolveWslStartConfig = Effect.fn("desktop.backendConfiguration.resolveWsl return { ...baseConfig, + wslNodePath: preflight.nodePath, args: [ ...distroArgs, "--exec", diff --git a/apps/desktop/src/backend/DesktopBackendManager.ts b/apps/desktop/src/backend/DesktopBackendManager.ts index 436c0c08e4ed..4b7eecb681dd 100644 --- a/apps/desktop/src/backend/DesktopBackendManager.ts +++ b/apps/desktop/src/backend/DesktopBackendManager.ts @@ -100,6 +100,8 @@ export interface DesktopBackendStartConfig extends BackendProcessContext { // Present for a WSL run after the configured/default distro has been // resolved to the concrete distro passed to wsl.exe. readonly runningDistro?: string; + readonly runningUser?: string; + readonly wslNodePath?: string; // Present only when this run launched from a staged WSL-local runtime. // Once HTTP readiness succeeds, the manager uses it to retain this cache // plus the newest previous cache and prune older versions. diff --git a/apps/desktop/src/editors/terminalEditorRuntime.test.ts b/apps/desktop/src/editors/terminalEditorRuntime.test.ts new file mode 100644 index 000000000000..469c4018350e --- /dev/null +++ b/apps/desktop/src/editors/terminalEditorRuntime.test.ts @@ -0,0 +1,145 @@ +// @effect-diagnostics nodeBuiltinImport:off - Private filesystem fixtures exercise persistence at the external launcher boundary. +import * as NodeFSP from "node:fs/promises"; +import * as NodeOS from "node:os"; +import * as NodePath from "node:path"; +import { afterEach, expect, it, vi } from "vite-plus/test"; +import type { TerminalEditorOpenRequest } from "@t3tools/contracts"; +import { TerminalEditorRuntime, type EditorRouteDescriptor } from "./terminalEditorRuntime.ts"; +import { parseNeovimProbe, neovimProbeScript } from "./terminalProbe.ts"; +import { run } from "../../scripts/neovim-terminal/transport.mjs"; + +const directories: string[] = []; +afterEach(async () => { + await Promise.all( + directories + .splice(0) + .map((directory) => NodeFSP.rm(directory, { recursive: true, force: true })), + ); +}); +const descriptor: EditorRouteDescriptor = { + route: { kind: "wsl", distro: "Ubuntu", user: "alice" }, + identity: "primary-alice", + generation: "pid-1", +}; +const ready = { + account: "alice", + executable: "/usr/bin/nvim", + version: "NVIM v0.11.0", + node: "/usr/bin/node", +}; +async function harness(probe = vi.fn(async () => ready)) { + const stateDir = await NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "t3-terminal-editor-")); + directories.push(stateDir); + const launch = vi.fn(async () => {}); + const runtime = new TerminalEditorRuntime( + { + platform: "win32", + environment: {}, + stateDir, + runtime: "C:\\T3\\T3.exe", + helperDir: "C:\\T3\\resources\\neovim-terminal", + }, + { probe, discoverTerminal: async () => "C:\\WindowsApps\\wt.exe", launch }, + ); + return { runtime, launch, probe, stateDir }; +} +function request(routeGeneration: string): TerminalEditorOpenRequest { + return { + requestId: "19541794-037e-4ac1-bccb-ed6fc9f2bcaa", + connection: { kind: "primary" }, + connectionGeneration: "1", + routeGeneration, + editor: "neovim", + workspacePath: "/worktree", + target: { kind: "file", path: "/other/file.txt", line: 4, column: 3 }, + }; +} + +it("opens one window for duplicate delivery and rejects request ID reuse for another target", async () => { + const { runtime, launch } = await harness(); + const capability = await runtime.probe(descriptor, "1"); + const input = request(capability.routeGeneration); + const results = await Promise.all([ + runtime.open(descriptor, input), + runtime.open(descriptor, input), + ]); + expect(results).toEqual([{ status: "accepted" }, { status: "accepted" }]); + expect(launch).toHaveBeenCalledTimes(1); + expect((await runtime.open(descriptor, { ...input, workspacePath: "/different" })).status).toBe( + "failed", + ); + expect(launch).toHaveBeenCalledTimes(1); +}); +it("does not replay an accepted launch after a reconnect", async () => { + const { runtime, launch } = await harness(); + const capability = await runtime.probe(descriptor, "1"); + const input = request(capability.routeGeneration); + await runtime.open(descriptor, input); + await runtime.open({ ...descriptor, generation: "new-pid" }, input); + expect(launch).toHaveBeenCalledTimes(1); +}); +it("rejects a stale probe after backend replacement before spawning a window", async () => { + const { runtime, launch } = await harness(); + const capability = await runtime.probe(descriptor, "1"); + const result = await runtime.open( + { ...descriptor, generation: "pid-2" }, + request(capability.routeGeneration), + ); + expect(result).toMatchObject({ status: "failed", reason: "stale-route" }); + expect(launch).not.toHaveBeenCalled(); +}); +it("keeps SSH authentication distinct from a timeout and recovers on rescan", async () => { + const probe = vi + .fn(async () => ready) + .mockRejectedValueOnce(new Error("Permission denied (publickey).")); + const { runtime } = await harness(probe); + const ssh: EditorRouteDescriptor = { ...descriptor, route: { kind: "ssh", host: "work-alias" } }; + expect(await runtime.probe(ssh, "1")).toMatchObject({ + state: "check-on-open", + reason: "authentication-required", + }); + expect(await runtime.probe(ssh, "1")).toMatchObject({ state: "check-on-open" }); + expect(probe).toHaveBeenCalledTimes(1); + probe.mockRejectedValueOnce(new Error("ssh timed out.")); + expect(await runtime.probe(ssh, "1", true)).toMatchObject({ + state: "unavailable", + reason: "timeout", + }); + expect(await runtime.probe(ssh, "1", true)).toMatchObject({ state: "available" }); +}); +it("scopes executable overrides by route and invalidates old launch generations", async () => { + const { runtime } = await harness(); + const previous = await runtime.probe(descriptor, "1"); + await runtime.save(descriptor, { + connection: { kind: "primary" }, + terminal: "windows-terminal", + executableOverride: "/home/alice/nvim", + }); + expect(await runtime.probe(descriptor, "1")).toMatchObject({ + executableOverride: "/home/alice/nvim", + }); + expect(await runtime.probe({ ...descriptor, identity: "primary-bob" }, "1")).toMatchObject({ + executableOverride: null, + }); + expect(await runtime.open(descriptor, request(previous.routeGeneration))).toMatchObject({ + status: "failed", + reason: "stale-route", + }); + await runtime.save(descriptor, { + connection: { kind: "primary" }, + terminal: "automatic", + executableOverride: null, + }); + expect(await runtime.probe(descriptor, "1")).toMatchObject({ executableOverride: null }); +}); +it("reads framed discovery despite banners and distinguishes a missing binary", async () => { + const result = await run("/bin/bash", ["-l", "-s"], { + input: neovimProbeScript("/t3-neovim-does-not-exist"), + timeout: 15_000, + capture: true, + }); + expect(parseNeovimProbe(`login banner\n${result}\nlogout banner`)).toMatchObject({ + missing: true, + }); + expect(() => parseNeovimProbe("only a startup banner")).toThrow(/login script/u); +}); diff --git a/apps/desktop/src/editors/terminalEditorRuntime.ts b/apps/desktop/src/editors/terminalEditorRuntime.ts new file mode 100644 index 000000000000..2ec428c09fd0 --- /dev/null +++ b/apps/desktop/src/editors/terminalEditorRuntime.ts @@ -0,0 +1,417 @@ +// @effect-diagnostics nodeBuiltinImport:off - This adapter owns detached external terminal processes and private atomic preference files outside Effect subprocess scopes. +import * as NodeChildProcess from "node:child_process"; +import * as NodeCrypto from "node:crypto"; +import * as NodeFSP from "node:fs/promises"; +import * as NodePath from "node:path"; +import * as NodeOS from "node:os"; +import * as Schema from "effect/Schema"; +import type { + TerminalEditorCapability, + TerminalEditorOpenRequest, + TerminalEditorLaunchResult, + TerminalEditorSettingsInput, + TerminalEditorReason, +} from "@t3tools/contracts"; +import { + encodeRequest, + findNeovim, + run, + windowsTerminalArgs, + type TerminalRoute, +} from "../../scripts/neovim-terminal/transport.mjs"; +import { probePosixRoute, type ProbeResult } from "./terminalProbe.ts"; + +export interface EditorRouteDescriptor { + route: TerminalRoute; + identity: string; + generation: string; +} +export const routeHash = (value: unknown) => + NodeCrypto.createHash("sha256").update(JSON.stringify(value)).digest("hex"); +const Preferences = Schema.Struct({ + terminal: Schema.Literals(["automatic", "windows-terminal"]), + overrides: Schema.Record(Schema.String, Schema.String), +}); +const readPreferences = Schema.decodeUnknownSync(Preferences); + +export function classifyProbeFailure(message: string): TerminalEditorReason { + if (/T3NEOVIM_RUNTIME_MISSING/u.test(message)) return "missing-runtime"; + if (/timed out|timeout|ETIMEDOUT/iu.test(message)) return "timeout"; + if ( + /Permission denied|Host key verification failed|read_passphrase|sign_and_send_pubkey|no tty|authentication/iu.test( + message, + ) + ) + return "authentication-required"; + return "probe-error"; +} + +interface TerminalEditorOperations { + discoverTerminal?: () => Promise; + probe?: (descriptor: EditorRouteDescriptor, override: string | null) => Promise; + launch?: (terminal: string, args: readonly string[]) => Promise; +} + +export class TerminalEditorRuntime { + private readonly operations: TerminalEditorOperations; + private preferences: Promise | undefined; + private writes: Promise = Promise.resolve(); + private terminal: Promise | undefined; + private probes = new Map< + string, + { expires: number; result: Promise } + >(); + private launches = new Map< + string, + { fingerprint: string; result: Promise } + >(); + private readonly options: { + platform: NodeJS.Platform; + environment: NodeJS.ProcessEnv; + stateDir: string; + helperDir: string; + runtime: string; + }; + constructor( + options: { + platform: NodeJS.Platform; + environment: NodeJS.ProcessEnv; + stateDir: string; + helperDir: string; + runtime: string; + }, + operations: TerminalEditorOperations = {}, + ) { + this.options = options; + this.operations = operations; + } + + private settings() { + this.preferences ??= NodeFSP.readFile( + NodePath.join(this.options.stateDir, "terminal-editors.json"), + "utf8", + ).then( + (text) => readPreferences(JSON.parse(text)), + (error: NodeJS.ErrnoException) => { + if (error.code !== "ENOENT") throw error; + return { terminal: "automatic", overrides: {} }; + }, + ); + return this.preferences; + } + async save(descriptor: EditorRouteDescriptor, input: TerminalEditorSettingsInput) { + if (input.executableOverride !== null) { + const paths = + descriptor.route.kind === "native" && this.options.platform === "win32" + ? NodePath.win32 + : NodePath.posix; + if (!paths.isAbsolute(input.executableOverride)) + throw new Error("Enter an absolute executable path."); + } + const update = this.writes.then(async () => { + const current = await this.settings(); + const overrides = { ...current.overrides }; + if (input.executableOverride === null) delete overrides[descriptor.identity]; + else overrides[descriptor.identity] = input.executableOverride; + const next = { terminal: input.terminal, overrides }; + await NodeFSP.mkdir(this.options.stateDir, { recursive: true }); + const file = NodePath.join(this.options.stateDir, "terminal-editors.json"); + const temporary = `${file}.${NodeCrypto.randomUUID()}`; + try { + await NodeFSP.writeFile(temporary, JSON.stringify(next), { mode: 0o600, flag: "wx" }); + await NodeFSP.rename(temporary, file); + } finally { + await NodeFSP.rm(temporary, { force: true }); + } + this.preferences = Promise.resolve(next); + this.probes.clear(); + this.terminal = undefined; + }); + this.writes = update.catch(() => {}); + return update; + } + + private discoverTerminal() { + this.terminal ??= (async () => { + if (this.operations.discoverTerminal) return this.operations.discoverTerminal(); + if (this.options.platform !== "win32") return null; + const environment = this.options.environment; + const candidates = [ + NodePath.win32.join(environment.LOCALAPPDATA ?? "", "Microsoft", "WindowsApps", "wt.exe"), + ]; + for (const dir of (environment.PATH ?? "").split(";").filter(Boolean)) + candidates.push(NodePath.win32.join(dir, "wt.exe")); + // Installed-app discovery covers disabled App Execution Aliases and desktop PATH differences. + const powershell = this.powershell(); + try { + const output = await run( + powershell, + [ + "-NoProfile", + "-NonInteractive", + "-Command", + "Get-AppxPackage -Name Microsoft.WindowsTerminal | ForEach-Object { Join-Path $_.InstallLocation 'WindowsTerminal.exe' }", + ], + { input: "", timeout: 10_000, capture: true }, + ); + candidates.push( + ...output + .split(/\r?\n/u) + .map((line) => line.trim()) + .filter(Boolean), + ); + } catch { + /* Alias and PATH candidates remain usable when AppX discovery is unavailable. */ + } + for (const candidate of candidates) { + if (!NodePath.win32.isAbsolute(candidate)) continue; + try { + await NodeFSP.access(candidate); + return candidate; + } catch { + continue; + } + } + return null; + })(); + return this.terminal; + } + private powershell() { + return NodePath.win32.join( + this.options.environment.SystemRoot ?? "C:\\Windows", + "System32", + "WindowsPowerShell", + "v1.0", + "powershell.exe", + ); + } + + async probe( + descriptor: EditorRouteDescriptor, + connectionGeneration: string, + rescan = false, + ): Promise { + if (rescan) { + this.probes.clear(); + this.terminal = undefined; + } + const settings = await this.settings(); + const override = settings.overrides[descriptor.identity] ?? null; + const key = routeHash([ + descriptor.identity, + descriptor.generation, + connectionGeneration, + override, + settings.terminal, + ]); + const existing = this.probes.get(key); + if (existing && existing.expires > performance.now()) return existing.result; + const result = this.probeUncached(descriptor, key, override, settings.terminal); + const entry = { expires: Infinity, result }; + this.probes.set(key, entry); + if (this.probes.size > 64) this.probes.delete(this.probes.keys().next().value!); + void result.then( + (value) => { + entry.expires = performance.now() + (value.state === "available" ? 60_000 : 5_000); + }, + () => { + if (this.probes.get(key) === entry) this.probes.delete(key); + }, + ); + return result; + } + + private async probeUncached( + descriptor: EditorRouteDescriptor, + generation: string, + override: string | null, + terminalPreference: "automatic" | "windows-terminal", + ): Promise { + const base = { + routeGeneration: generation, + preferenceKey: descriptor.identity, + terminals: [] as { id: "windows-terminal"; label: string }[], + selectedTerminal: null as "windows-terminal" | null, + executableOverride: override, + terminalPreference, + }; + const unavailable = ( + reason: TerminalEditorReason, + message: string, + ): TerminalEditorCapability => ({ ...base, state: "unavailable", reason, message }); + if (this.options.platform !== "win32") + return unavailable( + "unsupported-platform", + "Neovim (Terminal) currently requires the Windows desktop app. Linux terminal adapters are not available yet.", + ); + if (!(await this.discoverTerminal())) + return unavailable( + "missing-terminal", + "Install Windows Terminal, then Rescan in Settings → Editors.", + ); + base.terminals = [{ id: "windows-terminal", label: "Windows Terminal" }]; + base.selectedTerminal = "windows-terminal"; + try { + let probe: ProbeResult; + if (this.operations.probe) probe = await this.operations.probe(descriptor, override); + else if (descriptor.route.kind === "native") { + let executable: string; + try { + executable = await findNeovim(override ?? undefined, this.options.environment); + } catch (error) { + if (error instanceof Error && error.message.includes("not found")) + return unavailable( + "missing-neovim", + "Neovim was not found on this desktop. Install it or set its executable in Settings → Editors, then Rescan.", + ); + throw error; + } + const version = await run(executable, ["--version"], { + input: "", + timeout: 10_000, + capture: true, + }); + probe = { + executable, + version: version.split(/\r?\n/u)[0] ?? "", + account: NodeOS.userInfo().username, + node: this.options.runtime, + }; + } else { + probe = await probePosixRoute(descriptor.route, override, this.options.platform); + if (descriptor.route.kind === "wsl" && probe.account !== descriptor.route.user) + return unavailable( + "account-mismatch", + "The WSL account changed. Reconnect the environment before opening Neovim.", + ); + } + if (probe.missing) + return unavailable( + "missing-neovim", + "Neovim was not found in this environment's login PATH. Install it or set its executable in Settings → Editors, then Rescan.", + ); + return { + ...base, + state: "available", + message: "Opens Neovim in a new Windows Terminal window.", + account: probe.account, + ...(probe.executable ? { executable: probe.executable } : {}), + ...(probe.version ? { version: probe.version } : {}), + }; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + const reason = classifyProbeFailure(message); + if ( + reason === "authentication-required" && + (descriptor.route.kind === "ssh" || descriptor.route.kind === "wsl-ssh") + ) + return { + ...base, + state: "check-on-open", + reason, + message: "Check on open — SSH sign-in required. Authenticate in the new terminal window.", + }; + return unavailable( + reason, + reason === "missing-runtime" + ? "The selected account's login shell cannot find Node.js for the editor helper. Reconnect after fixing that account's Node.js PATH." + : `Could not check Neovim: ${message.slice(0, 500)}`, + ); + } + } + + open( + descriptor: EditorRouteDescriptor, + input: TerminalEditorOpenRequest, + ): Promise { + const fingerprint = routeHash(input); + const existing = this.launches.get(input.requestId); + if (existing) + return existing.fingerprint === fingerprint + ? existing.result + : Promise.resolve({ + status: "failed", + reason: "launch-failed", + message: "This launch request ID was already used for a different target.", + }); + if (this.launches.size >= 10_000) + return Promise.resolve({ + status: "failed", + reason: "launch-failed", + message: "Restart the desktop app to open more terminal editor sessions.", + }); + const result = this.openOnce(descriptor, input).catch( + (error: unknown): TerminalEditorLaunchResult => ({ + status: "failed", + reason: "launch-failed", + message: error instanceof Error ? error.message : String(error), + }), + ); + this.launches.set(input.requestId, { fingerprint, result }); + return result; + } + private async openOnce( + descriptor: EditorRouteDescriptor, + input: TerminalEditorOpenRequest, + ): Promise { + const capability = await this.probe(descriptor, input.connectionGeneration); + if (capability.routeGeneration !== input.routeGeneration) + return { + status: "failed", + reason: "stale-route", + message: "The environment or editor settings changed. Rescan and open again.", + }; + if (capability.state !== "available" && capability.state !== "check-on-open") + return { + status: "failed", + reason: capability.reason ?? "route-unavailable", + message: capability.message, + }; + const terminal = await this.discoverTerminal(); + if (!terminal) + return { + status: "failed", + reason: "missing-terminal", + message: "Windows Terminal is no longer available. Rescan in Settings → Editors.", + }; + const bootstrap = NodePath.join(this.options.helperDir, "launch.ps1"); + if (!this.operations.launch) await NodeFSP.access(bootstrap); + const token = encodeRequest({ + version: 1, + id: input.requestId, + platform: this.options.platform, + route: descriptor.route, + workspace: input.workspacePath, + target: input.target, + ...((capability.executable ?? capability.executableOverride) + ? { executable: (capability.executable ?? capability.executableOverride)! } + : {}), + ...(capability.account ? { expectedAccount: capability.account } : {}), + }); + const args = windowsTerminalArgs({ + powershell: this.powershell(), + bootstrap, + runtime: this.options.runtime, + token, + }); + try { + if (this.operations.launch) await this.operations.launch(terminal, args); + else + await new Promise((resolve, reject) => { + const child = NodeChildProcess.spawn(terminal, args, { + detached: true, + stdio: "ignore", + shell: false, + }); + child.once("error", reject); + child.once("spawn", resolve); + child.unref(); + }); + } catch (error) { + this.probes.clear(); + this.terminal = undefined; + throw error; + } + return { status: "accepted" }; + } +} diff --git a/apps/desktop/src/editors/terminalProbe.ts b/apps/desktop/src/editors/terminalProbe.ts new file mode 100644 index 000000000000..ce413c4817ad --- /dev/null +++ b/apps/desktop/src/editors/terminalProbe.ts @@ -0,0 +1,98 @@ +import * as NodeBuffer from "node:buffer"; +import * as Schema from "effect/Schema"; +import { + quotePosix, + run, + sshArgs, + wslArgs, + type TerminalRoute, +} from "../../scripts/neovim-terminal/transport.mjs"; + +export const ProbeResult = Schema.Struct({ + account: Schema.String, + node: Schema.String, + executable: Schema.optionalKey(Schema.String), + version: Schema.optionalKey(Schema.String), + missing: Schema.optionalKey(Schema.Boolean), +}); +export type ProbeResult = typeof ProbeResult.Type; +const decodeProbeResult = Schema.decodeUnknownSync(ProbeResult); + +// A fixed program decodes the override as data and probes without loading Neovim config. +export function neovimProbeScript(override: string | null) { + const token = NodeBuffer.Buffer.from(JSON.stringify(override)).toString("base64"); + return `set -eu +node_path=$(command -v node) || { echo T3NEOVIM_RUNTIME_MISSING >&2; exit 72; } +"$node_path" --input-type=module <<'T3_NEOVIM_PROBE' +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import * as cp from 'node:child_process'; +const override = JSON.parse(Buffer.from('${token}', 'base64').toString('utf8')); +const candidates = override ? [override] : (process.env.PATH || '').split(path.delimiter).filter(Boolean).map(p => path.resolve(p, 'nvim')); +const executable = candidates.find(p => { try { fs.accessSync(p, fs.constants.X_OK); return true; } catch { return false; } }); +let result = { account: os.userInfo().username, node: process.execPath, missing: true }; +if (executable) { + const probe = cp.spawnSync(executable, ['--version'], { timeout: 8000, maxBuffer: 65536, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }); + if (probe.error || probe.status !== 0) throw new Error('Neovim version probe failed: ' + (probe.error?.message || probe.stderr)); + const version = probe.stdout.split(/\\r?\\n/)[0]; + if (!version.startsWith('NVIM v')) throw new Error('The executable is not Neovim.'); + result = { account: os.userInfo().username, node: process.execPath, executable, version }; +} +console.log('T3NEOVIM:' + Buffer.from(JSON.stringify(result)).toString('base64')); +T3_NEOVIM_PROBE +`; +} + +export function parseNeovimProbe(output: string): ProbeResult { + const frame = output.split(/\r?\n/u).findLast((line) => line.startsWith("T3NEOVIM:")); + if (!frame) throw new Error("The login script did not return a Neovim probe response."); + const value: unknown = JSON.parse( + NodeBuffer.Buffer.from(frame.slice(9), "base64").toString("utf8"), + ); + return decodeProbeResult(value); +} + +export async function probePosixRoute( + route: Exclude, + override: string | null, + platform: NodeJS.Platform, +) { + const script = neovimProbeScript(override); + if (route.kind === "wsl") { + return parseNeovimProbe( + await run("wsl.exe", [...wslArgs(route), "/bin/bash", "-l", "-s"], { + input: script, + capture: true, + timeout: 20_000, + }), + ); + } + const args = [ + "-o", + "BatchMode=yes", + "-o", + "ConnectTimeout=8", + "-o", + "ConnectionAttempts=1", + ...sshArgs(route, false), + "/bin/bash -l -s", + ]; + if (route.kind === "ssh") { + return parseNeovimProbe( + await run(platform === "win32" ? "ssh.exe" : "ssh", args, { + input: script, + capture: true, + timeout: 20_000, + }), + ); + } + const command = `exec ssh ${args.map(quotePosix).join(" ")} <<'T3_NEOVIM_REMOTE'\n${script}\nT3_NEOVIM_REMOTE\n`; + return parseNeovimProbe( + await run("wsl.exe", [...wslArgs(route), "/bin/bash", "-l", "-s"], { + input: command, + capture: true, + timeout: 25_000, + }), + ); +} diff --git a/apps/desktop/src/ipc/DesktopIpcHandlers.ts b/apps/desktop/src/ipc/DesktopIpcHandlers.ts index 22e9556a2928..f4fc382b2011 100644 --- a/apps/desktop/src/ipc/DesktopIpcHandlers.ts +++ b/apps/desktop/src/ipc/DesktopIpcHandlers.ts @@ -1,3 +1,8 @@ +import { + probeTerminalEditor, + openTerminalEditor, + setTerminalEditorSettings, +} from "./methods/terminalEditors.ts"; import * as Effect from "effect/Effect"; import * as DesktopIpc from "./DesktopIpc.ts"; @@ -104,6 +109,9 @@ export const installDesktopIpcHandlers = Effect.fn("desktop.ipc.installHandlers" yield* ipc.handle(openExternal); yield* ipc.handle(openSystemSettings); yield* ipc.handle(probeRemoteEditors); + yield* ipc.handle(probeTerminalEditor); + yield* ipc.handle(openTerminalEditor); + yield* ipc.handle(setTerminalEditorSettings); yield* ipc.handle(getUpdateState); yield* ipc.handle(setUpdateChannel); yield* ipc.handle(downloadUpdate); diff --git a/apps/desktop/src/ipc/channels.ts b/apps/desktop/src/ipc/channels.ts index 9cd82ae5c199..61686a341bf5 100644 --- a/apps/desktop/src/ipc/channels.ts +++ b/apps/desktop/src/ipc/channels.ts @@ -91,3 +91,7 @@ export const PREVIEW_RECORDING_SAVE_CHANNEL = "desktop:preview-recording-save"; export const PREVIEW_RECORDING_FRAME_CHANNEL = "desktop:preview-recording-frame"; export const PREVIEW_STATE_CHANGE_CHANNEL = "desktop:preview-state-change"; export const PREVIEW_POINTER_EVENT_CHANNEL = "desktop:preview-pointer-event"; + +export const PROBE_TERMINAL_EDITOR_CHANNEL = "desktop:probe-terminal-editor"; +export const OPEN_TERMINAL_EDITOR_CHANNEL = "desktop:open-terminal-editor"; +export const SET_TERMINAL_EDITOR_SETTINGS_CHANNEL = "desktop:set-terminal-editor-settings"; diff --git a/apps/desktop/src/ipc/methods/terminalEditors.test.ts b/apps/desktop/src/ipc/methods/terminalEditors.test.ts new file mode 100644 index 000000000000..b0ce441e699d --- /dev/null +++ b/apps/desktop/src/ipc/methods/terminalEditors.test.ts @@ -0,0 +1,186 @@ +import { assert, it } from "@effect/vitest"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { EnvironmentId, type PersistedSavedEnvironmentRecord } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import { layer as environmentLayer } from "../../app/DesktopEnvironment.ts"; +import * as DesktopConfig from "../../app/DesktopConfig.ts"; +import * as Pool from "../../backend/DesktopBackendPool.ts"; +import type { + DesktopBackendInstance, + DesktopBackendStartConfig, +} from "../../backend/DesktopBackendManager.ts"; +import * as Settings from "../../settings/DesktopAppSettings.ts"; +import * as Saved from "../../settings/DesktopSavedEnvironments.ts"; +import { resolveEditorRoute } from "./terminalEditors.ts"; + +const config: DesktopBackendStartConfig = { + executablePath: "wsl.exe", + args: [], + entryPath: "/app/bin.mjs", + cwd: "/app", + env: {}, + extendEnv: false, + bootstrap: { + mode: "desktop", + noBrowser: true, + port: 3774, + host: "127.0.0.1", + desktopBootstrapToken: "test", + tailscaleServeEnabled: false, + tailscaleServePort: 443, + }, + bootstrapDelivery: "stdin", + httpBaseUrl: new URL("http://127.0.0.1:3774"), + captureOutput: true, + preflightFailure: Option.none(), + runningDistro: "Ubuntu", + runningUser: "alice", + wslNodePath: "/usr/bin/node", +}; +function instance(id: string, value = config, ready = true): DesktopBackendInstance { + return { + id: Pool.BackendInstanceId(id), + label: Effect.succeed(id), + start: Effect.void, + stop: () => Effect.void, + currentConfig: Effect.succeed(Option.some(value)), + snapshot: Effect.succeed({ + desiredRunning: true, + ready, + activePid: Option.some(123), + restartAttempt: 0, + restartScheduled: false, + }), + waitForReady: () => Effect.succeed(ready), + }; +} +const saved: PersistedSavedEnvironmentRecord = { + environmentId: EnvironmentId.make("saved-test"), + label: "Test", + httpBaseUrl: "http://127.0.0.1:12345", + wsBaseUrl: "ws://127.0.0.1:12345", + createdAt: "2026-01-01T00:00:00Z", + lastConnectedAt: null, + desktopSsh: { + alias: "work", + hostname: "work.test", + username: "remote-user", + port: 2222, + runner: { kind: "wsl", distro: "Ubuntu", user: "alice" }, + }, +}; +const environment = environmentLayer({ + dirname: "/repo/apps/desktop/src", + homeDirectory: "/tmp/terminal-route-test", + platform: "win32", + processArch: "x64", + appVersion: "1.2.3", + appPath: "/repo", + isPackaged: true, + resourcesPath: "/missing/resources", + runningUnderArm64Translation: false, +}).pipe( + Layer.provide( + Layer.mergeAll( + NodeServices.layer, + DesktopConfig.layerTest({ T3CODE_HOME: "/tmp/terminal-route-test" }), + ), + ), +); +function harness( + instances: DesktopBackendInstance[], + records = [saved], + settings = { + ...Settings.DEFAULT_DESKTOP_SETTINGS, + wslBackendEnabled: true, + wslOnly: false, + sshRunner: "wsl" as const, + wslDistro: null, + }, +) { + return Layer.mergeAll( + environment, + Pool.layerTest(instances), + Saved.layerTest({ records }), + Settings.layerTest(settings), + ); +} +it.effect( + "resolves a WSL-only primary from its running account and preserves its Linux runtime", + () => + Effect.gen(function* () { + const descriptor = yield* resolveEditorRoute({ kind: "primary" }); + assert.deepEqual(descriptor.route, { + kind: "wsl", + distro: "Ubuntu", + user: "alice", + node: "/usr/bin/node", + }); + }).pipe(Effect.provide(harness([instance("primary")]))), +); +it.effect("uses the configured WSL instance instead of the first pool entry for saved SSH", () => + Effect.gen(function* () { + const descriptor = yield* resolveEditorRoute({ + kind: "saved", + environmentId: saved.environmentId, + }); + assert.deepEqual(descriptor.route, { + kind: "wsl-ssh", + distro: "Ubuntu", + user: "alice", + host: "work", + sshUser: "remote-user", + port: 2222, + }); + }).pipe( + Effect.provide( + harness([ + instance("wsl:other", { ...config, runningDistro: "Debian", runningUser: "bob" }), + instance("wsl:default"), + ]), + ), + ), +); +it.effect("rejects a changed credential account instead of silently using it", () => + Effect.gen(function* () { + const failure = yield* resolveEditorRoute({ + kind: "saved", + environmentId: saved.environmentId, + }).pipe(Effect.flip); + assert.equal(failure._tag, "TerminalEditorRouteError"); + assert.include(failure.message, "account"); + }).pipe(Effect.provide(harness([instance("wsl:default", { ...config, runningUser: "bob" })]))), +); +it.effect("does not infer local execution from a forwarded loopback URL", () => + Effect.gen(function* () { + const failure = yield* resolveEditorRoute({ + kind: "saved", + environmentId: saved.environmentId, + }).pipe(Effect.flip); + assert.include(failure.message, "saved SSH environment"); + }).pipe( + Effect.provide( + harness( + [instance("primary")], + [ + { + environmentId: saved.environmentId, + label: saved.label, + httpBaseUrl: saved.httpBaseUrl, + wsBaseUrl: saved.wsBaseUrl, + createdAt: saved.createdAt, + lastConnectedAt: null, + }, + ], + ), + ), + ), +); +it.effect("rejects a disconnected desktop backend", () => + Effect.gen(function* () { + const failure = yield* resolveEditorRoute({ kind: "primary" }).pipe(Effect.flip); + assert.include(failure.message, "Connect"); + }).pipe(Effect.provide(harness([instance("primary", config, false)]))), +); diff --git a/apps/desktop/src/ipc/methods/terminalEditors.ts b/apps/desktop/src/ipc/methods/terminalEditors.ts new file mode 100644 index 000000000000..958cb42a9f85 --- /dev/null +++ b/apps/desktop/src/ipc/methods/terminalEditors.ts @@ -0,0 +1,266 @@ +import { + TerminalEditorCapability, + TerminalEditorProbeInput, + TerminalEditorOpenRequest, + TerminalEditorLaunchResult, + TerminalEditorSettingsInput, + type DesktopEditorConnectionRef, + TerminalEditorReason, +} from "@t3tools/contracts"; +import { HostProcessEnvironment } from "@t3tools/shared/hostProcess"; +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; +import { DesktopEnvironment } from "../../app/DesktopEnvironment.ts"; +import { + DesktopBackendPool, + BackendInstanceId, + PRIMARY_INSTANCE_ID, +} from "../../backend/DesktopBackendPool.ts"; +import { DesktopSavedEnvironments } from "../../settings/DesktopSavedEnvironments.ts"; +import { WSL_INSTANCE_ID_PREFIX } from "../../wsl/DesktopWslBackend.ts"; +import { DesktopAppSettings } from "../../settings/DesktopAppSettings.ts"; +import { matchesSshRunner, selectSshRunner } from "../../ssh/DesktopSshRunner.ts"; +import { + TerminalEditorRuntime, + routeHash, + type EditorRouteDescriptor, +} from "../../editors/terminalEditorRuntime.ts"; +import * as DesktopIpc from "../DesktopIpc.ts"; +import * as IpcChannels from "../channels.ts"; + +export class TerminalEditorRouteError extends Schema.TaggedErrorClass()( + "TerminalEditorRouteError", + { reason: Schema.String, message: Schema.String }, +) {} +const unavailable = (reason: TerminalEditorReason, message: string) => + Effect.fail(new TerminalEditorRouteError({ reason, message })); +const Runtimes = Context.Reference>( + "desktop/terminalEditorRuntimes", + { defaultValue: () => new Map() }, +); +const runtime = Effect.gen(function* () { + const environment = yield* DesktopEnvironment; + const hostEnvironment = yield* HostProcessEnvironment; + const instances = yield* Runtimes; + let value = instances.get(environment.stateDir); + if (!value) { + value = new TerminalEditorRuntime({ + platform: environment.platform, + environment: hostEnvironment, + stateDir: environment.stateDir, + runtime: process.execPath, + helperDir: environment.isPackaged + ? environment.path.join(environment.resourcesPath, "neovim-terminal") + : environment.path.join(environment.appRoot, "apps/desktop/scripts/neovim-terminal"), + }); + instances.set(environment.stateDir, value); + } + return value; +}); + +export const resolveEditorRoute = Effect.fn("desktop.editors.resolveRoute")(function* ( + connection: DesktopEditorConnectionRef, +): Effect.fn.Return< + EditorRouteDescriptor, + | TerminalEditorRouteError + | import("../../settings/DesktopSavedEnvironments.ts").DesktopSavedEnvironmentsReadRegistryError, + DesktopBackendPool | DesktopEnvironment | DesktopSavedEnvironments | DesktopAppSettings +> { + const pool = yield* DesktopBackendPool; + const environment = yield* DesktopEnvironment; + if (connection.kind !== "saved") { + const instance = yield* pool.get( + connection.kind === "primary" ? PRIMARY_INSTANCE_ID : BackendInstanceId(connection.backendId), + ); + if (Option.isNone(instance)) + return yield* unavailable( + "route-unavailable", + "The desktop environment no longer exists. Select a connected environment.", + ); + const config = yield* instance.value.currentConfig; + const snapshot = yield* instance.value.snapshot; + if (Option.isNone(config) || !snapshot.ready) + return yield* unavailable("disconnected", "Connect this environment before checking Neovim."); + const value = config.value; + if (value.executablePath.toLowerCase() === "wsl.exe") { + if (!value.runningDistro || !value.runningUser) + return yield* unavailable( + "account-mismatch", + "Reconnect the WSL backend to capture its exact distro and account.", + ); + const route = { + kind: "wsl", + distro: value.runningDistro, + user: value.runningUser, + ...(value.wslNodePath ? { node: value.wslNodePath } : {}), + } as const; + return { + route, + identity: routeHash([connection, route.distro, route.user]), + generation: routeHash([route, Option.getOrNull(snapshot.activePid)]), + }; + } + return { + route: { kind: "native" }, + identity: routeHash([connection, "native"]), + generation: routeHash([connection, Option.getOrNull(snapshot.activePid)]), + }; + } + const registry = yield* DesktopSavedEnvironments; + const record = (yield* registry.getRegistry).find( + (record) => record.environmentId === connection.environmentId, + ); + if (!record?.desktopSsh) + return yield* unavailable( + "ssh-association-required", + "Add and select this host as a saved SSH environment in Settings → Connections to open Neovim on it.", + ); + const target = record.desktopSsh; + const settings = yield* (yield* DesktopAppSettings).get; + const common = { + host: target.alias || target.hostname, + ...(target.username ? { sshUser: target.username } : {}), + ...(target.port ? { port: target.port } : {}), + }; + if (selectSshRunner(environment.platform, settings) === "wsl") { + const instance = yield* pool.get( + settings.wslOnly + ? PRIMARY_INSTANCE_ID + : BackendInstanceId(`${WSL_INSTANCE_ID_PREFIX}${settings.wslDistro ?? "default"}`), + ); + const currentConfig = Option.isSome(instance) + ? yield* instance.value.currentConfig + : Option.none(); + const config = Option.getOrUndefined(currentConfig); + if ( + !config?.runningDistro || + !config.runningUser || + target.runner?.kind !== "wsl" || + !target.runner.user + ) + return yield* unavailable( + "runner-mismatch", + "Reconnect or add this SSH environment in Settings → Connections to bind its WSL credential account.", + ); + if ( + !matchesSshRunner(target.runner, { + kind: "wsl", + distro: config.runningDistro, + user: config.runningUser, + }) + ) + return yield* unavailable( + "runner-mismatch", + "Restore this environment's SSH runner, WSL distro and account in Settings → Connections.", + ); + const route = { + kind: "wsl-ssh", + distro: target.runner.distro, + user: target.runner.user, + ...common, + } as const; + return { + route, + identity: routeHash([connection, target]), + generation: routeHash([ + target, + record.lastConnectedAt, + record.httpBaseUrl, + settings.sshRunner, + settings.wslOnly, + settings.wslDistro, + config.runningUser, + ]), + }; + } + if ( + !matchesSshRunner( + target.runner, + environment.platform === "win32" ? { kind: "windows" } : undefined, + ) + ) + return yield* unavailable( + "runner-mismatch", + "Restore this environment's SSH runner in Settings → Connections. Neovim will not switch credential stores.", + ); + return { + route: { kind: "ssh", ...common }, + identity: routeHash([connection, target]), + generation: routeHash([target, record.lastConnectedAt, record.httpBaseUrl]), + }; +}); + +const promise = (operation: () => Promise) => + Effect.tryPromise({ + try: operation, + catch: (cause) => + new TerminalEditorRouteError({ + reason: "probe-error", + message: cause instanceof Error ? cause.message : String(cause), + }), + }); +const isRouteError = Schema.is(TerminalEditorRouteError); +const isReason = Schema.is(TerminalEditorReason); +const reasonOf = (error: unknown): TerminalEditorReason => + isRouteError(error) && isReason(error.reason) ? error.reason : "route-unavailable"; +const messageOf = (error: unknown) => (error instanceof Error ? error.message : String(error)); + +export const probeTerminalEditor = DesktopIpc.makeIpcMethod({ + channel: IpcChannels.PROBE_TERMINAL_EDITOR_CHANNEL, + payload: TerminalEditorProbeInput, + result: TerminalEditorCapability, + handler: Effect.fn("desktop.editors.probe")( + function* (input) { + const descriptor = yield* resolveEditorRoute(input.connection); + const service = yield* runtime; + return yield* promise(() => + service.probe(descriptor, input.connectionGeneration, input.rescan), + ); + }, + Effect.catch((error) => + Effect.succeed({ + state: "unavailable" as const, + reason: reasonOf(error), + message: messageOf(error), + routeGeneration: "", + preferenceKey: "", + terminals: [], + selectedTerminal: null, + executableOverride: null, + }), + ), + ), +}); +export const openTerminalEditor = DesktopIpc.makeIpcMethod({ + channel: IpcChannels.OPEN_TERMINAL_EDITOR_CHANNEL, + payload: TerminalEditorOpenRequest, + result: TerminalEditorLaunchResult, + handler: Effect.fn("desktop.editors.open")( + function* (input) { + const service = yield* runtime; + const before = yield* resolveEditorRoute(input.connection); + yield* promise(() => service.probe(before, input.connectionGeneration)); + const current = yield* resolveEditorRoute(input.connection); + return yield* promise(() => service.open(current, input)); + }, + Effect.catch((error) => + Effect.succeed({ + status: "failed" as const, + reason: reasonOf(error), + message: messageOf(error), + }), + ), + ), +}); +export const setTerminalEditorSettings = DesktopIpc.makeIpcMethod({ + channel: IpcChannels.SET_TERMINAL_EDITOR_SETTINGS_CHANNEL, + payload: TerminalEditorSettingsInput, + result: Schema.Void, + handler: Effect.fn("desktop.editors.settings")(function* (input) { + const descriptor = yield* resolveEditorRoute(input.connection); + const service = yield* runtime; + yield* promise(() => service.save(descriptor, input)); + }), +}); diff --git a/apps/desktop/src/preload.ts b/apps/desktop/src/preload.ts index 92bb599fdf3e..557b8e31e242 100644 --- a/apps/desktop/src/preload.ts +++ b/apps/desktop/src/preload.ts @@ -119,6 +119,12 @@ contextBridge.exposeInMainWorld("desktopBridge", { openExternal: (url: string) => ipcRenderer.invoke(IpcChannels.OPEN_EXTERNAL_CHANNEL, url), openSystemSettings: (pane: string) => ipcRenderer.invoke(IpcChannels.OPEN_SYSTEM_SETTINGS_CHANNEL, pane), + probeTerminalEditor: (input) => + ipcRenderer.invoke(IpcChannels.PROBE_TERMINAL_EDITOR_CHANNEL, input), + openTerminalEditor: (input) => + ipcRenderer.invoke(IpcChannels.OPEN_TERMINAL_EDITOR_CHANNEL, input), + setTerminalEditorSettings: (input) => + ipcRenderer.invoke(IpcChannels.SET_TERMINAL_EDITOR_SETTINGS_CHANNEL, input), probeRemoteEditors: () => ipcRenderer.invoke(IpcChannels.PROBE_REMOTE_EDITORS_CHANNEL, undefined), onMenuAction: (listener) => { const wrappedListener = (_event: Electron.IpcRendererEvent, action: unknown) => { diff --git a/apps/desktop/src/ssh/DesktopSshEnvironment.ts b/apps/desktop/src/ssh/DesktopSshEnvironment.ts index 9ca02a243fc3..216a71451f39 100644 --- a/apps/desktop/src/ssh/DesktopSshEnvironment.ts +++ b/apps/desktop/src/ssh/DesktopSshEnvironment.ts @@ -147,7 +147,7 @@ export function prepareTargetForSshRunner( exitCode: null, stderr: "", message: - "This saved SSH environment uses a different SSH runner or WSL distro. Restore its runner in Settings → Connections, or remove and add the environment again.", + "This saved SSH environment uses a different SSH runner, WSL distro or account. Restore its runner in Settings → Connections, or remove and add the environment again.", }), ); } diff --git a/apps/desktop/src/ssh/DesktopSshRunner.test.ts b/apps/desktop/src/ssh/DesktopSshRunner.test.ts index 2b967d03b772..96ea7d365954 100644 --- a/apps/desktop/src/ssh/DesktopSshRunner.test.ts +++ b/apps/desktop/src/ssh/DesktopSshRunner.test.ts @@ -15,7 +15,11 @@ describe("desktop SSH runner selection", () => { Effect.succeed( ChildProcessSpawner.makeHandle({ pid: ChildProcessSpawner.ProcessId(123), - stdout: Stream.make(new TextEncoder().encode("agent-unavailable")), + stdout: Stream.make( + new TextEncoder().encode( + "T3SSH-USER:alice\nT3SSH-HOME:/home/alice\nagent-unavailable", + ), + ), stderr: Stream.make( new TextEncoder().encode( exitCode === 0 ? "OpenSSH_test" : "ssh: command not found", @@ -64,8 +68,10 @@ describe("desktop SSH runner selection", () => { } }); it("rejects saved environments when credentials would move to another runner or distro", () => { - const wsl = { kind: "wsl", distro: "Debian" } as const; + const wsl = { kind: "wsl", distro: "Debian", user: "alice" } as const; assert.isTrue(matchesSshRunner(wsl, { ...wsl })); + assert.isFalse(matchesSshRunner(wsl, { ...wsl, user: "bob" })); + assert.isFalse(matchesSshRunner({ kind: "wsl", distro: "Debian" }, wsl)); assert.isFalse(matchesSshRunner(wsl, { kind: "windows" })); assert.isFalse(matchesSshRunner(wsl, { kind: "wsl", distro: "Ubuntu" })); assert.isTrue(matchesSshRunner(undefined, { kind: "windows" })); diff --git a/apps/desktop/src/ssh/DesktopSshRunner.ts b/apps/desktop/src/ssh/DesktopSshRunner.ts index 7bd9d92ab01b..743d05559946 100644 --- a/apps/desktop/src/ssh/DesktopSshRunner.ts +++ b/apps/desktop/src/ssh/DesktopSshRunner.ts @@ -34,7 +34,7 @@ export const preflightWslSsh = Effect.fn("desktop.ssh.preflightWsl")(function* ( "--exec", "bash", "-lc", - 'ssh -V >&2 || exit; if [ -n "${SSH_AUTH_SOCK:-}" ] && [ -S "$SSH_AUTH_SOCK" ]; then printf agent-ready; else printf agent-unavailable; fi', + 'printf "\\nT3SSH-USER:%s\\nT3SSH-HOME:%s\\n" "$(id -un)" "$HOME"; ssh -V >&2 || exit; if [ -n "${SSH_AUTH_SOCK:-}" ] && [ -S "$SSH_AUTH_SOCK" ]; then printf agent-ready; else printf agent-unavailable; fi', ], { stdin: "ignore" }, ), @@ -52,8 +52,24 @@ export const preflightWslSsh = Effect.fn("desktop.ssh.preflightWsl")(function* ( }); yield* Effect.logDebug("ssh.wsl.preflight", { distro, - agentAvailable: stdout.trim() === "agent-ready", + agentAvailable: stdout.trim().endsWith("agent-ready"), }); + const user = stdout + .split(/\r?\n/u) + .find((line) => line.startsWith("T3SSH-USER:")) + ?.slice(11); + const homeDir = stdout + .split(/\r?\n/u) + .find((line) => line.startsWith("T3SSH-HOME:")) + ?.slice(11); + if (!user || !homeDir) + return yield* new SshCommandError({ + command: ["wsl.exe"], + exitCode: null, + stderr: "", + message: "Could not bind the WSL SSH account. Reconnect in Settings → Connections.", + }); + return { user, homeDir }; }), ).pipe( Effect.timeoutOrElse({ @@ -119,12 +135,13 @@ export const resolveDesktopSshRunner = Effect.fn("desktop.ssh.resolveRunner")(fu message: `Could not resolve SSH home or network address via WSL (${distro}).`, }); } - yield* preflightWslSsh(distro); + const account = yield* preflightWslSsh(distro); const addresses = yield* yield* HostProcessAddresses; return { kind: "wsl", distro, - homeDir: home.value, + homeDir: account.homeDir, + user: account.user, tunnelHost: addresses.has(ip.value) ? "127.0.0.1" : ip.value, } as const; }); @@ -134,7 +151,7 @@ export function sshRunnerIdentity( platform: NodeJS.Platform, ): DesktopSshEnvironmentTarget["runner"] { return runner.kind === "wsl" - ? { kind: "wsl", distro: runner.distro } + ? { kind: "wsl", distro: runner.distro, ...(runner.user ? { user: runner.user } : {}) } : platform === "win32" ? { kind: "windows" } : undefined; @@ -147,6 +164,7 @@ export function matchesSshRunner( if (!saved) return current?.kind !== "wsl"; return ( saved.kind === current?.kind && - (saved.kind !== "wsl" || (current.kind === "wsl" && saved.distro === current.distro)) + (saved.kind !== "wsl" || + (current.kind === "wsl" && saved.distro === current.distro && saved.user === current.user)) ); } diff --git a/apps/desktop/src/wsl/DesktopWslEnvironment.ts b/apps/desktop/src/wsl/DesktopWslEnvironment.ts index b0c9f5ffe44b..3e364fa18f4e 100644 --- a/apps/desktop/src/wsl/DesktopWslEnvironment.ts +++ b/apps/desktop/src/wsl/DesktopWslEnvironment.ts @@ -58,6 +58,7 @@ export type EnsureWslNodePtyResult = readonly ok: true; readonly nodePath: string; readonly resolvedPath: string; + readonly runningUser?: string; } | { readonly ok: false; @@ -501,6 +502,7 @@ const NODE_PTY_PROBE_SCRIPT = ( ) => `printf 'nodePath:%s\\n' "$(command -v node 2>/dev/null)" printf 'nodeVersion:%s\\n' "$(node -p 'process.versions.node' 2>/dev/null)" printf 'resolvedPath:%s\\n' "$PATH" +printf 'runningUser:%s\\n' "$(id -un)" cd ${shellQuote(linuxServerDir)} && node <<'NODE' >/dev/null 2>&1 // The WSL Node can't read inside app.asar, so confirm what the server needs is // unpacked on the real filesystem before reporting the backend healthy. Exit 3 @@ -679,6 +681,11 @@ const ensureNodePtyImpl = ( ); const nodePath = parseNodePath(probe.stdout); const resolvedPath = parseResolvedPath(probe.stdout); + const runningUser = probe.stdout + .split(/\r?\n/u) + .find((line) => line.startsWith("runningUser:")) + ?.slice(12) + .trim(); const transportFailureReason = formatWslShellTransportFailureReason(probe.transportFailure); if (transportFailureReason !== null) { @@ -754,7 +761,7 @@ const ensureNodePtyImpl = ( fatal: true, } as const; } - return { ok: true, nodePath, resolvedPath } as const; + return { ok: true, nodePath, resolvedPath, ...(runningUser ? { runningUser } : {}) } as const; } if (options.allowBuild !== true) { @@ -835,7 +842,8 @@ const ensureNodePtyImpl = ( retryLimit: BUILD_TRANSPORT_RETRY_LIMIT, } as const; } - if (build.exitCode === 0) return { ok: true, nodePath, resolvedPath } as const; + if (build.exitCode === 0) + return { ok: true, nodePath, resolvedPath, ...(runningUser ? { runningUser } : {}) } as const; const trimmedTail = `${build.stdout}${build.stderr}`.trim().slice(-500); return { ok: false, diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index deaa68ebdaee..a94d9a31ad65 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -2039,9 +2039,9 @@ function useChatMarkdownState({ ); const projects = useProjects(); const availableEditors = serverConfig?.availableEditors ?? []; - const [preferredEditor] = usePreferredEditor(availableEditors); + const [preferredEditor] = usePreferredEditor(availableEditors, environmentId); const preferredEditorMenuLabel = openInEditorMenuLabel(preferredEditor); - const openInPreferredEditor = useOpenInPreferredEditor(environmentId, availableEditors); + const openInPreferredEditor = useOpenInPreferredEditor(environmentId, availableEditors, cwd); const openInEditor = useAtomCommand(shellEnvironment.openInEditor, { reportFailure: false, }); diff --git a/apps/web/src/components/DiffPanel.tsx b/apps/web/src/components/DiffPanel.tsx index 87f5f1ba56f8..871bd450e4f8 100644 --- a/apps/web/src/components/DiffPanel.tsx +++ b/apps/web/src/components/DiffPanel.tsx @@ -157,6 +157,7 @@ export default function DiffPanel({ const openInPreferredEditor = useOpenInPreferredEditor( activeThread?.environmentId ?? null, serverConfig?.availableEditors ?? [], + activeCwd, ); const getDiffFileContents = useAtomCommand(reviewEnvironment.diffFileContents); const gitStatusQuery = useEnvironmentQuery( diff --git a/apps/web/src/components/GitActionsControl.tsx b/apps/web/src/components/GitActionsControl.tsx index b7fc811d5127..f14522efc3ce 100644 --- a/apps/web/src/components/GitActionsControl.tsx +++ b/apps/web/src/components/GitActionsControl.tsx @@ -995,6 +995,7 @@ export default function GitActionsControl({ const openInPreferredEditor = useOpenInPreferredEditor( activeEnvironmentId, serverConfig?.availableEditors ?? [], + gitCwd, ); const threadToastData = useMemo( () => (activeThreadRef ? { threadRef: activeThreadRef } : undefined), diff --git a/apps/web/src/components/ThreadTerminalDrawer.tsx b/apps/web/src/components/ThreadTerminalDrawer.tsx index 89cdc3649acd..086fcfd3ed95 100644 --- a/apps/web/src/components/ThreadTerminalDrawer.tsx +++ b/apps/web/src/components/ThreadTerminalDrawer.tsx @@ -334,6 +334,7 @@ export function TerminalViewport({ const openInPreferredEditor = useOpenInPreferredEditor( environmentId, serverConfig?.availableEditors ?? [], + cwd, ); const openTerminalPath = useEffectEvent((target: string) => openInPreferredEditor(target)); const openPreview = useAtomCommand(previewEnvironment.open, { diff --git a/apps/web/src/components/chat/OpenInPicker.tsx b/apps/web/src/components/chat/OpenInPicker.tsx index b9bf831c14d9..959f38c0788a 100644 --- a/apps/web/src/components/chat/OpenInPicker.tsx +++ b/apps/web/src/components/chat/OpenInPicker.tsx @@ -1,21 +1,16 @@ import { - buildRemoteOpenUrl, EditorId, + type EditorChoice, type EnvironmentId, type ResolvedKeybindingsConfig, } from "@t3tools/contracts"; import { memo, useCallback, useEffect, useMemo } from "react"; import { isOpenFavoriteEditorShortcut, shortcutLabelForCommand } from "../../keybindings"; -import { usePreferredEditor } from "../../editorPreferences"; +import { useEditorDispatch } from "../../editorPreferences"; import { editorLabelForPlatform } from "../../editorLabels"; -import { - openRemoteEditorUrl, - useRemoteCapableEditors, - useRemoteOpenHint, - useRemoteOpenState, -} from "../../remoteOpen"; +import { useRemoteOpenHint } from "../../remoteOpen"; import { useEnvironment } from "../../state/environments"; -import { ChevronDownIcon, FolderClosedIcon } from "lucide-react"; +import { ChevronDownIcon, FolderClosedIcon, TerminalIcon } from "lucide-react"; import { Button } from "../ui/button"; import { Group, GroupSeparator } from "../ui/group"; import { Menu, MenuItem, MenuPopup, MenuShortcut, MenuTrigger } from "../ui/menu"; @@ -45,8 +40,9 @@ import { WebStormIcon, } from "../JetBrainsIcons"; import { cn } from "~/lib/utils"; -import { shellEnvironment } from "~/state/shell"; -import { useAtomCommand } from "~/state/use-atom-command"; +import { toastManager } from "../ui/toast"; +import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime"; +import { Link } from "@tanstack/react-router"; type OpenInOption = { label: string; @@ -178,6 +174,7 @@ export const OpenInPicker = memo(function OpenInPicker({ keybindings, availableEditors, openInCwd, + workspacePath, compact = false, enableShortcut = true, }: { @@ -185,65 +182,47 @@ export const OpenInPicker = memo(function OpenInPicker({ keybindings: ResolvedKeybindingsConfig; availableEditors: ReadonlyArray; openInCwd: string | null; + workspacePath?: string; compact?: boolean; enableShortcut?: boolean; }) { - const openInEditorMutation = useAtomCommand(shellEnvironment.openInEditor, "open in editor"); - const remote = useRemoteOpenState(environmentId); - const remoteCapableEditors = useRemoteCapableEditors(); + const dispatch = useEditorDispatch( + environmentId, + availableEditors, + workspacePath ?? (compact ? undefined : openInCwd), + ); + const remote = dispatch.remote.state; const [remoteHintSeen, markRemoteHintSeen] = useRemoteOpenHint(); const environmentLabel = useEnvironment(environmentId)?.label ?? "this machine"; - // Remote mode ignores the server's PATH probe: what matters is what runs on - // the viewing machine, which only the desktop app can probe. - const effectiveEditors = remote.mode === "local-exec" ? availableEditors : remoteCapableEditors; - const [preferredEditor, setPreferredEditor] = usePreferredEditor(effectiveEditors); + const preferredEditor = dispatch.choice; + const terminal = dispatch.terminal.capability; + const terminalVisible = + terminal.state === "available" || + terminal.state === "check-on-open" || + preferredEditor?.kind === "terminal"; const options = useMemo( - () => resolveOptions(navigator.platform, effectiveEditors), - [effectiveEditors], + () => resolveOptions(navigator.platform, dispatch.effectiveEditors), + [dispatch.effectiveEditors], ); - const primaryOption = options.find(({ value }) => value === preferredEditor) ?? null; - + const primaryOption = options.find(({ value }) => value === preferredEditor?.editor) ?? null; const openInEditor = useCallback( - (editorId: EditorId | null) => { - if (!openInCwd) return; - const editor = editorId ?? preferredEditor; - if (!editor) return; - if (remote.mode === "remote-unavailable") return; - if (remote.mode === "remote-links") { - const url = buildRemoteOpenUrl({ - editor, - host: remote.host.host, - absolutePath: openInCwd, - }); - if (url === undefined) return; - // Only record hint-seen/preferred when the shell actually accepted - // the URL (an older desktop build can refuse the editor scheme). - void openRemoteEditorUrl(url).then((opened) => { - if (!opened) return; - markRemoteHintSeen(); - setPreferredEditor(editor); + async (editor: EditorChoice | null, explicit = false) => { + if (!openInCwd || !editor) return; + if (explicit) dispatch.select(editor); + const result = await dispatch.open( + { kind: compact ? "file" : "directory", path: openInCwd }, + editor, + ); + if (result._tag === "Failure") { + const error = squashAtomCommandFailure(result); + toastManager.add({ + type: "error", + title: "Unable to open editor", + description: error instanceof Error ? error.message : "The editor could not be opened.", }); - return; - } - const result = openInEditorMutation({ - environmentId, - input: { - cwd: openInCwd, - editor, - }, - }); - setPreferredEditor(editor); - return result; + } else if (remote.mode === "remote-links") markRemoteHintSeen(); }, - [ - environmentId, - markRemoteHintSeen, - openInCwd, - openInEditorMutation, - preferredEditor, - remote, - setPreferredEditor, - ], + [compact, dispatch, markRemoteHintSeen, openInCwd, remote.mode], ); const openFavoriteEditorShortcutLabel = useMemo( @@ -272,9 +251,17 @@ export const OpenInPicker = memo(function OpenInPicker({ className="ps-[8.5px]" size="xs" variant="outline" - disabled={!preferredEditor || !openInCwd || remote.mode === "remote-unavailable"} + disabled={ + !preferredEditor || + !openInCwd || + (preferredEditor.kind === "gui" && remote.mode === "remote-unavailable") + } + title={preferredEditor?.kind === "terminal" ? terminal.message : undefined} onClick={() => openInEditor(preferredEditor)} > + {preferredEditor?.kind === "terminal" && ( +