diff --git a/apps/server/src/cli/invocation.test.ts b/apps/server/src/cli/invocation.test.ts index ba3bc840e7448..08e4024d5bfe0 100644 --- a/apps/server/src/cli/invocation.test.ts +++ b/apps/server/src/cli/invocation.test.ts @@ -291,6 +291,43 @@ it.layer(NodeServices.layer)("manual server installation ownership", (it) => { }), ); + it.effect("names the launcher symlink that started a downloaded runtime", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fs.realPath(yield* fs.makeTempDirectoryScoped()); + // The install script's launcher names ~/.t3, which here links to a home elsewhere. + const executable = path.join(root, "data/t3/runtime/versions/1.0.0/t3"); + const launcher = path.join(root, "bin/t3"); + const copy = path.join(root, "bin/t3-copy"); + yield* fs.makeDirectory(path.dirname(executable), { recursive: true }); + yield* fs.makeDirectory(path.dirname(launcher), { recursive: true }); + yield* fs.writeFileString(executable, "", { mode: 0o755 }); + yield* fs.writeFileString(copy, "", { mode: 0o755 }); + yield* fs.symlink(path.join(root, "data/t3"), path.join(root, ".t3")); + yield* fs.symlink(path.join(root, ".t3/runtime/versions/1.0.0/t3"), launcher); + // Earlier PATH entries hold a directory and a non-executable file named t3. + yield* fs.makeDirectory(path.join(root, "shadow-dir/t3"), { recursive: true }); + yield* fs.makeDirectory(path.join(root, "shadow-file")); + yield* fs.writeFileString(path.join(root, "shadow-file/t3"), ""); + const resolve = (invokedAs: string) => + resolveServerInstallation.pipe( + Effect.provideService(HostProcess.ExecutablePath, executable), + Effect.provideService(HostProcess.IsExecutable, true), + Effect.provideService(HostProcess.InvokedAs, invokedAs), + Effect.provideService(HostProcess.WorkingDirectory, root), + Effect.provideService(HostProcess.Environment, { PATH: "shadow-dir:shadow-file:bin" }), + Effect.provideService(HostProcess.Platform, "linux"), + ); + const baseDir = path.join(root, ".t3"); + + expect(yield* resolve("t3")).toEqual({ kind: "t3-launcher", launcher, baseDir }); + expect(yield* resolve(launcher)).toEqual({ kind: "t3-launcher", launcher, baseDir }); + expect(yield* resolve(executable)).toBeNull(); + expect(yield* resolve(copy)).toBeNull(); + }), + ); + it.effect("leaves local, standalone, missing and unreadable installs unknown", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; diff --git a/apps/server/src/cli/invocation.ts b/apps/server/src/cli/invocation.ts index 2a1991fd3070b..459fe6dcfe94d 100644 --- a/apps/server/src/cli/invocation.ts +++ b/apps/server/src/cli/invocation.ts @@ -9,6 +9,9 @@ import * as HostProcess from "@t3tools/shared/HostProcess"; import { resolveCommandPath } from "@t3tools/shared/shell"; import packageJson from "../../package.json" with { type: "json" }; +import { resolveLauncherPath } from "../cloud/pinnedRuntime.ts"; + +const PINNED_RUNTIME_ENTRY = /^(.*)\/runtime\/versions\/[^/]+\/t3$/; export type CliRunner = "npx" | "pnpm dlx" | "bunx"; @@ -67,6 +70,21 @@ export const resolveServerInstallation = Effect.gen(function* () { const path = yield* Path.Path; const platform = yield* HostProcess.Platform; const entry = yield* fs.realPath(yield* resolveEntryPath); + const runtime = PINNED_RUNTIME_ENTRY.exec(entry); + if (runtime) { + const launcher = yield* resolveLauncherPath; + if (launcher === undefined) return null; + // Fails for a runtime started directly, as SSH launches do, or through a copy. + const link = yield* fs.readLink(launcher); + // Keep the home as the link spells it; `t3 update` only repoints links into that spelling. + const linked = PINNED_RUNTIME_ENTRY.exec(path.resolve(path.dirname(launcher), link)); + if (!linked || (yield* fs.realPath(linked[1] || "/")) !== (runtime[1] || "/")) return null; + return { + kind: "t3-launcher", + launcher, + baseDir: linked[1] || "/", + } satisfies ServerInstallation; + } const match = /^(.*)\/lib\/node_modules\/t3\/(?:dist\/bin\.mjs|bin\/t3\.mjs)$/.exec(entry); if (!match) { const runner = detectCliRunner(entry); diff --git a/apps/server/src/cli/uninstall.ts b/apps/server/src/cli/uninstall.ts index 5cf6f0c5e64cc..5d0361118e10a 100644 --- a/apps/server/src/cli/uninstall.ts +++ b/apps/server/src/cli/uninstall.ts @@ -16,10 +16,11 @@ import { generatedWindowsShimTarget, isVersionExecutable, pinnedRuntimeVersionsDir, + resolveLauncherPath, } from "../cloud/pinnedRuntime.ts"; import { projectLocationFlags, resolveCliAuthConfig } from "./config.ts"; import * as CliService from "./service.ts"; -import { findWindowsShim, resolveLauncherPath } from "./update.ts"; +import { findWindowsShim } from "./update.ts"; export class CliUninstallError extends Schema.TaggedError()( "CliUninstallError", diff --git a/apps/server/src/cli/update.test.ts b/apps/server/src/cli/update.test.ts index 98ec13d32808e..557dddef554a9 100644 --- a/apps/server/src/cli/update.test.ts +++ b/apps/server/src/cli/update.test.ts @@ -17,12 +17,15 @@ import { afterEach, vi } from "vite-plus/test"; import packageJson from "../../package.json" with { type: "json" }; import * as BootService from "../cloud/bootService.ts"; -import { pinnedRuntimePaths, prunePinnedRuntimes } from "../cloud/pinnedRuntime.ts"; +import { + pinnedRuntimePaths, + prunePinnedRuntimes, + resolveLauncherPath, +} from "../cloud/pinnedRuntime.ts"; import * as ManagedPolicy from "../policy/ManagedPolicy.ts"; import * as ProcessRunner from "../processRunner.ts"; import { repointLauncher, - resolveLauncherPath, resolveMirrorVersion, resolveNewestVersion, runUpdate, @@ -411,7 +414,7 @@ it.layer(NodeServices.layer)("t3 update launcher", (it) => { const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-update-" }); const launcher = path.join(root, "bin/t3"); yield* fs.makeDirectory(path.dirname(launcher), { recursive: true }); - yield* fs.writeFileString(launcher, ""); + yield* fs.writeFileString(launcher, "", { mode: 0o755 }); const bare = yield* resolveLauncherPath.pipe( Effect.provideService(HostProcess.InvokedAs, "t3"), diff --git a/apps/server/src/cli/update.ts b/apps/server/src/cli/update.ts index 53ff536e6d112..9a4fbc67b4ef0 100644 --- a/apps/server/src/cli/update.ts +++ b/apps/server/src/cli/update.ts @@ -32,6 +32,7 @@ import { pinnedRuntimeVersionsDir, prunePinnedRuntimes, recordPinnedRuntimeLauncher, + resolveLauncherPath, windowsShimTarget, } from "../cloud/pinnedRuntime.ts"; import { compareExactServiceVersions, isExactServiceVersion } from "../cloud/serviceProtocol.ts"; @@ -194,28 +195,6 @@ export const repointLauncher = Effect.fn("cli.update.repoint_launcher")(function return Option.some(input.launchedAs); }); -/** Node preserves argv0's spelling; resolve bare names on PATH to find the launcher symlink. */ -export const resolveLauncherPath = Effect.gen(function* () { - const path = yield* Path.Path; - const fs = yield* FileSystem.FileSystem; - const invokedAs = yield* HostProcess.InvokedAs; - const cwd = yield* HostProcess.WorkingDirectory; - const environment = yield* HostProcess.Environment; - const platform = yield* HostProcess.Platform; - if (invokedAs.includes("/") || invokedAs.includes("\\")) { - return path.resolve(cwd, invokedAs); - } - const delimiter = platform === "win32" ? ";" : ":"; - for (const directory of (environment["PATH"] ?? "").split(delimiter)) { - if (directory.length === 0) continue; - const candidate = path.join(directory, invokedAs); - if (yield* fs.exists(candidate).pipe(Effect.orElseSucceed(() => false))) { - return candidate; - } - } - return undefined; -}); - /** Windows executables see their own path, so find the installer shim by matching t3.cmd targets on PATH. */ export const findWindowsShim = Effect.fn("cli.update.find_windows_shim")(function* ( executablePath: string, diff --git a/apps/server/src/cloud/pinnedRuntime.ts b/apps/server/src/cloud/pinnedRuntime.ts index 669ae01c40f59..b698be2727752 100644 --- a/apps/server/src/cloud/pinnedRuntime.ts +++ b/apps/server/src/cloud/pinnedRuntime.ts @@ -447,6 +447,34 @@ export function launcherOwnsVersionsDir( return relative.length > 0 && !relative.startsWith("..") && !path.isAbsolute(relative); } +/** Node preserves argv0's spelling; resolve bare names on PATH to find the launcher symlink. */ +export const resolveLauncherPath = Effect.gen(function* () { + const path = yield* Path.Path; + const fs = yield* FileSystem.FileSystem; + const invokedAs = yield* HostProcess.InvokedAs; + const cwd = yield* HostProcess.WorkingDirectory; + const environment = yield* HostProcess.Environment; + const platform = yield* HostProcess.Platform; + if (invokedAs.includes("/") || invokedAs.includes("\\")) { + return path.resolve(cwd, invokedAs); + } + const delimiter = platform === "win32" ? ";" : ":"; + for (const directory of (environment["PATH"] ?? "").split(delimiter)) { + if (directory.length === 0) continue; + const candidate = path.resolve(cwd, directory, invokedAs); + const info = yield* fs.stat(candidate).pipe(Effect.option); + // Like the shell, pass over directories and non-executable files named the same. + if ( + Option.isSome(info) && + info.value.type === "File" && + (platform === "win32" || (info.value.mode & 0o111) !== 0) + ) { + return candidate; + } + } + return undefined; +}); + /** * `t3 update` repoints the launcher it was started through, which may be on neither PATH nor the * default bin dir. Unrecorded, the next prune would remove the runtime it now points at. diff --git a/apps/server/src/preview/ServerBrowser.test.ts b/apps/server/src/preview/ServerBrowser.test.ts index e1085b199a829..e408519e9ac1c 100644 --- a/apps/server/src/preview/ServerBrowser.test.ts +++ b/apps/server/src/preview/ServerBrowser.test.ts @@ -63,7 +63,7 @@ vi.mock("./ServerBrowserContexts.ts", () => ({ return context as unknown as BrowserContext; } async scratchPage() { - return makeEncoderPage() as unknown as Page; + return encoderPageOverride ?? (makeEncoderPage() as unknown as Page); } async connectDesktopPage(endpoint: string) { const context = makeContext(undefined, desktopPageUrl); @@ -377,6 +377,7 @@ function makeEncoderPage() { } const contexts: ReturnType[] = []; +let encoderPageOverride: Page | undefined; const encoderStreams: Array<{ frameRate: number | undefined; bitsPerSecond: number }> = []; const contextRequests: Array<{ profileId: string; isolated: boolean }> = []; let contextGate: PromiseWithResolvers | null = null; @@ -560,6 +561,7 @@ const openDesktopViewer = Effect.gen(function* () { beforeEach(() => { contexts.length = 0; + encoderPageOverride = undefined; encoderStreams.length = 0; contextRequests.length = 0; contextGate = null; @@ -936,6 +938,64 @@ it.live.each([ ).pipe(Effect.provide(layer)), ); +it.live.each([{ truncated: false }, { truncated: true }])( + "saves a large recording as a binary download and removes its staged file (truncated: $truncated)", + ({ truncated }) => + Effect.scoped( + Effect.gen(function* () { + const { broker, tabId } = yield* ready; + const fs = yield* FileSystem.FileSystem; + const bytes = 8 * 1024 * 1024; + const payload = Buffer.alloc(bytes, 0x5a); + let stagedPath: string | undefined; + const download = Promise.withResolvers<{ saveAs: (path: string) => Promise }>(); + const sandbox = NodeVM.createContext({ + __t3Recorder: { + frame: async () => true, + stop: async () => ({ mimeType: "video/webm", count: 1, bytes }), + chunk: async () => { + throw new Error("Large recording used Base64 export"); + }, + download: () => + download.resolve({ + saveAs: async (path) => { + stagedPath = path; + await NodeFSP.writeFile(path, truncated ? payload.subarray(1) : payload); + }, + }), + }, + }); + encoderPageOverride = { + ...makeContext().page, + evaluate: async (script: string | ((arg: unknown) => unknown), arg?: unknown) => + typeof script === "function" + ? NodeVM.runInContext(`(${script.toString()})`, sandbox)(arg) + : undefined, + waitForEvent: () => download.promise, + } as unknown as Page; + + yield* broker.invoke({ scope, tabId, operation: "recordingStart", input: {} }); + const stop = broker.invoke<{ path: string; sizeBytes: number }>({ + scope, + tabId, + operation: "recordingStop", + input: {}, + }); + if (truncated) { + expect(yield* stop.pipe(Effect.flip)).toMatchObject({ + _tag: "PreviewAutomationExecutionError", + }); + } else { + const artifact = yield* stop; + expect(artifact.sizeBytes).toBe(bytes); + expect(Buffer.from(yield* fs.readFile(artifact.path)).equals(payload)).toBe(true); + } + expect(stagedPath).toBeDefined(); + expect(yield* fs.exists(stagedPath!)).toBe(false); + }), + ).pipe(Effect.provide(layer)), +); + it.live.each([ { refreshFails: false, expected: "final frame" }, { refreshFails: true, expected: "frame" }, diff --git a/apps/server/src/preview/ServerBrowser.ts b/apps/server/src/preview/ServerBrowser.ts index 53d2a01e6f30e..68c489e9cb15c 100644 --- a/apps/server/src/preview/ServerBrowser.ts +++ b/apps/server/src/preview/ServerBrowser.ts @@ -101,6 +101,8 @@ const HOST_RECONNECT_DELAY = "1 second"; const DESKTOP_ATTACH_TIMEOUT = "10 seconds"; const VIEWER_OUTPUT_LIMIT = 64; const RECORDING_SCREENCAST = { format: "jpeg", quality: 90, everyNthFrame: 1 } as const; +const RECORDING_BINARY_EXPORT_MIN_BYTES = 8 * 1024 * 1024; +const RECORDING_DOWNLOAD_TIMEOUT_MS = 5_000; const decodeViewportSetting = Schema.decodeUnknownSync(PreviewViewportSettingSchema); const AGENT_CURSOR_MOVE_MS = 160; const AGENT_CURSOR_CLICK_LEAD_MS = 40; @@ -294,6 +296,7 @@ interface EncoderWindow { frame(data: string, width: number): Promise; stop(): Promise<{ mimeType: string | null; count: number; bytes: number }>; chunk(index: number): Promise; + download(): void; }; } @@ -1920,8 +1923,6 @@ const make = Effect.gen(function* () { message: "No recording is active for this tab.", }); } - let mimeType: string | null; - const chunks: Array = []; try { await recording.session.send("Page.stopScreencast").catch(constVoid); await Promise.all(recording.framesInFlight); @@ -1945,50 +1946,72 @@ const make = Effect.gen(function* () { .catch(constVoid); } } - const stopped = await recording.encoder.evaluate(() => + const { mimeType, count, bytes } = await recording.encoder.evaluate(() => (globalThis as unknown as EncoderWindow).__t3Recorder.stop(), ); - mimeType = stopped.mimeType; // Checked before the transfer so an oversized video never lands in this process. - if (stopped.bytes > PROVIDER_SEND_TURN_MAX_FILE_BYTES) { + if (bytes > PROVIDER_SEND_TURN_MAX_FILE_BYTES) { throw new ServerBrowserPage.ServerBrowserOperationError({ tag: "PreviewAutomationRecordingTooLargeError", message: "The recording is larger than the attachment limit.", }); } - for (let index = 0; index < stopped.count; index += 1) { - const chunk = await recording.encoder.evaluate( - (chunkIndex) => (globalThis as unknown as EncoderWindow).__t3Recorder.chunk(chunkIndex), - index, - ); - chunks.push(Buffer.from(chunk, "base64")); + if (!mimeType || bytes === 0) { + throw new ServerBrowserPage.ServerBrowserOperationError({ + tag: "PreviewAutomationExecutionError", + message: "The recording captured no frames.", + }); } + const extension = mimeType.startsWith("video/mp4") ? "mp4" : "webm"; + const pendingId = `${PENDING_ATTACHMENT_THREAD_SEGMENT}-${NodeCrypto.randomUUID()}-${extension}`; + const path = NodePath.join(config.attachmentsDir, `${pendingId}.${extension}`); + await NodeFSP.mkdir(config.attachmentsDir, { recursive: true }); + if (bytes >= RECORDING_BINARY_EXPORT_MIN_BYTES) { + const stagedPath = `${path}.partial`; + try { + const [download] = await Promise.all([ + recording.encoder.waitForEvent("download", { + timeout: RECORDING_DOWNLOAD_TIMEOUT_MS, + }), + recording.encoder.evaluate(() => + (globalThis as unknown as EncoderWindow).__t3Recorder.download(), + ), + ]); + await download.saveAs(stagedPath); + if ((await NodeFSP.stat(stagedPath)).size !== bytes) { + throw new Error("The recording download is incomplete."); + } + await NodeFSP.rename(stagedPath, path); + } finally { + await NodeFSP.rm(stagedPath, { force: true }).catch(constVoid); + } + } else { + const chunks: Array = []; + for (let index = 0; index < count; index += 1) { + const chunk = await recording.encoder.evaluate( + (chunkIndex) => + (globalThis as unknown as EncoderWindow).__t3Recorder.chunk(chunkIndex), + index, + ); + chunks.push(Buffer.from(chunk, "base64")); + } + const data = Buffer.concat(chunks); + if (data.byteLength !== bytes) throw new Error("The recording transfer is incomplete."); + await NodeFSP.writeFile(path, data); + } + return { + id: pendingId, + tabId: tab.tabId, + path, + mimeType: mimeType.split(";")[0]!, + sizeBytes: bytes, + createdAt: new Date().toISOString(), + uploadedAttachmentId: pendingId, + }; } finally { await recording.encoder.close().catch(constVoid); tab.recording = null; } - const data = Buffer.concat(chunks); - if (!mimeType || data.byteLength === 0) { - throw new ServerBrowserPage.ServerBrowserOperationError({ - tag: "PreviewAutomationExecutionError", - message: "The recording captured no frames.", - }); - } - - const extension = mimeType.startsWith("video/mp4") ? "mp4" : "webm"; - const pendingId = `${PENDING_ATTACHMENT_THREAD_SEGMENT}-${NodeCrypto.randomUUID()}-${extension}`; - const path = NodePath.join(config.attachmentsDir, `${pendingId}.${extension}`); - await NodeFSP.mkdir(config.attachmentsDir, { recursive: true }); - await NodeFSP.writeFile(path, data); - return { - id: pendingId, - tabId: tab.tabId, - path, - mimeType: mimeType.split(";")[0]!, - sizeBytes: data.byteLength, - createdAt: new Date().toISOString(), - uploadedAttachmentId: pendingId, - }; }); let pointerSequence = 0; diff --git a/apps/server/src/preview/ServerBrowserPage.ts b/apps/server/src/preview/ServerBrowserPage.ts index 29ba05c21b7ea..9df6c539014c5 100644 --- a/apps/server/src/preview/ServerBrowserPage.ts +++ b/apps/server/src/preview/ServerBrowserPage.ts @@ -1165,11 +1165,18 @@ export const recordingEncoderScript = (frameRate: BrowserRecordingFrameRate) => }, async chunk(index) { const bytes = new Uint8Array(await chunks[index].arrayBuffer()); + if (typeof bytes.toBase64 === "function") return bytes.toBase64(); let binary = ""; for (let offset = 0; offset < bytes.length; offset += 0x8000) { binary += String.fromCharCode(...bytes.subarray(offset, offset + 0x8000)); } return btoa(binary); }, + download() { + const link = document.createElement("a"); + link.href = URL.createObjectURL(new Blob(chunks, { type: recorder.mimeType })); + link.download = "recording"; + link.click(); + }, }; })()`; diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 7100dc0502ea5..e90fa22b61507 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -16,6 +16,7 @@ import * as NodeHttpServer from "@effect/platform-node/NodeHttpServer"; import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { EnvironmentHttpApi, type RepositoryIdentity } from "@t3tools/contracts"; +import * as HostProcess from "@t3tools/shared/HostProcess"; import * as Cause from "effect/Cause"; import * as Duration from "effect/Duration"; import * as Deferred from "effect/Deferred"; @@ -36,6 +37,7 @@ import { keepIdleConnections, rejectRequestsUntilServing, } from "./httpResponseErrorGuard.ts"; +import { resolveLauncherPath } from "./cloud/pinnedRuntime.ts"; import { fixPath } from "./os-jank.ts"; import * as Ws from "./ws.ts"; import * as AgentScopeLive from "./process/agentScope.ts"; @@ -883,6 +885,8 @@ const layerMakeServer = Layer.unwrap( const routesReady = yield* Deferred.make(); const layerLauncher = ServiceLauncherClient.layer; + // fixPath rewrites PATH, so pin the launcher the shell chose before it does. + const invokedAs = (yield* resolveLauncherPath) ?? (yield* HostProcess.InvokedAs); yield* fixPath({ shellEnvironmentPrepared: config.shellEnvironmentPrepared }); const layerHttpListening = Layer.effectDiscard( @@ -1296,6 +1300,7 @@ const layerMakeServer = Layer.unwrap( Layer.provide(VcsProcess.layer), Layer.provideMerge(AgentScopeLive.layer), Layer.provideMerge(layerPlatformServices), + Layer.provide(Layer.succeed(HostProcess.InvokedAs, invokedAs)), ); }), ).pipe(Layer.provide(ProcessRunner.layer)); diff --git a/apps/web/src/components/ServerUpdateAction.tsx b/apps/web/src/components/ServerUpdateAction.tsx index 14e0dd3c192cb..ef999e5bcdc31 100644 --- a/apps/web/src/components/ServerUpdateAction.tsx +++ b/apps/web/src/components/ServerUpdateAction.tsx @@ -276,17 +276,17 @@ export function ServerUpdateAction({ const canUpdate = useAtomValue(sessionStateAtom, canUpdateServer); const updatesDenial = managedUpdatesDenial(useManagedPolicy(environmentId)); const update = useServerUpdate(); + const updatesInstall = + installation?.kind === "npm-global" || installation?.kind === "t3-launcher"; const { copyToClipboard } = useCopyToClipboard<{ command: string }>({ - target: installation?.kind === "npm-global" ? "update command" : "relaunch command", + target: updatesInstall ? "update command" : "relaunch command", onCopy: ({ command }) => { toastManager.add({ type: "success", - title: - installation?.kind === "npm-global" ? "Update command copied" : "Relaunch command copied", - description: - installation?.kind === "npm-global" - ? `Run \`${command}\` on ${serverLabel}, then restart t3 with your usual options.` - : `Stop t3 on ${serverLabel}, then relaunch with \`${command}\` using the same subcommand and options. This does not update an installed t3 command.`, + title: updatesInstall ? "Update command copied" : "Relaunch command copied", + description: updatesInstall + ? `Run \`${command}\` on ${serverLabel}, then restart t3 with your usual options.` + : `Stop t3 on ${serverLabel}, then relaunch with \`${command}\` using the same subcommand and options. This does not update an installed t3 command.`, }); }, onError: (error) => { @@ -353,7 +353,7 @@ export function ServerUpdateAction({ selfUpdate === null ? manualServerUpdateCommand(targetVersion, installation) : null; const actionLabel = manualCommand !== null - ? installation?.kind === "npm-global" + ? updatesInstall ? "Copy update command" : "Copy relaunch command" : label; diff --git a/apps/web/src/components/files/ReadOnlySourcePreview.browser.test.tsx b/apps/web/src/components/files/ReadOnlySourcePreview.browser.test.tsx new file mode 100644 index 0000000000000..3547540e8d72a --- /dev/null +++ b/apps/web/src/components/files/ReadOnlySourcePreview.browser.test.tsx @@ -0,0 +1,67 @@ +import type { ReactNode } from "react"; +import { describe, expect, it, vi } from "vite-plus/test"; +import { userEvent } from "vite-plus/test/browser"; +import "~/index.css"; + +import { render } from "../../../test/render"; + +import { getSyntaxHighlighterPromise } from "~/lib/syntaxHighlighting"; + +// The pool starts its workers before showing any code, which can outlast a cold CI browser. +vi.mock(import("~/components/DiffWorkerPoolProvider"), () => ({ + DiffWorkerPoolProvider: ({ children }: { children?: ReactNode }) => <>{children}, +})); + +import ReadOnlySourcePreview from "./ReadOnlySourcePreview"; + +const LINE_COUNT = 301; +const text = `${Array.from({ length: LINE_COUNT }, (_, index) => `line ${index + 1}`).join("\n")}\n`; + +// The browser ignores a copy event's data unless the event is cancelled, and copies the selection itself. +function copiedText(target: Element) { + const clipboardData = new DataTransfer(); + const handled = !target.dispatchEvent( + new ClipboardEvent("copy", { clipboardData, bubbles: true, cancelable: true, composed: true }), + ); + return handled ? clipboardData.getData("text/plain") : null; +} + +describe("ReadOnlySourcePreview", () => { + it("copies the whole file after Select All, wherever the preview is scrolled", async () => { + // File paints nothing until the shared highlighter has loaded; once it has, mount renders synchronously. + await getSyntaxHighlighterPromise("text"); + await using screen = await render( +
+ +
, + ); + const root = screen.container.querySelector("diffs-container")!.shadowRoot!; + const rows = () => root.querySelectorAll("[data-line]"); + await vi.waitFor(() => expect(rows()[0]?.textContent).toBe("line 1")); + // Only rows near the viewport are mounted, so the browser's own copy would stop there. + expect(rows().length).toBeLessThan(LINE_COUNT); + + await userEvent.click(rows()[0]!); + await userEvent.keyboard("{ControlOrMeta>}a{/ControlOrMeta}"); + expect(copiedText(rows()[0]!)).toBe(text); + + const scroller = screen.container.querySelector(".file-preview-virtualizer")!; + scroller.scrollTop = scroller.scrollHeight; + await vi.waitFor(() => expect(rows()[0]?.textContent).not.toBe("line 1")); + expect(copiedText(rows()[0]!)).toBe(text); + + document.getSelection()!.removeAllRanges(); + await userEvent.click(rows()[rows().length - 1]!); + await userEvent.keyboard("{ControlOrMeta>}a{/ControlOrMeta}"); + expect(copiedText(rows()[0]!)).toBe(text); + + // A selection that stops inside the mounted rows at either end stays the browser's to copy. + const pre = root.querySelector("pre")!; + const firstText = rows()[0]!.querySelector("span")!.firstChild!; + const lastText = rows()[rows().length - 1]!.querySelector("span")!.firstChild!; + document.getSelection()!.setBaseAndExtent(pre, 0, lastText, 4); + expect(copiedText(rows()[0]!)).toBeNull(); + document.getSelection()!.setBaseAndExtent(firstText, 4, pre, pre.childNodes.length); + expect(copiedText(rows()[0]!)).toBeNull(); + }); +}); diff --git a/apps/web/src/components/files/ReadOnlySourcePreview.tsx b/apps/web/src/components/files/ReadOnlySourcePreview.tsx index 2fdfe04a238d6..453fc26ecc6ad 100644 --- a/apps/web/src/components/files/ReadOnlySourcePreview.tsx +++ b/apps/web/src/components/files/ReadOnlySourcePreview.tsx @@ -1,4 +1,5 @@ import { File, type FileOptions, Virtualizer } from "@pierre/diffs/react"; +import type { ClipboardEvent } from "react"; import { DiffWorkerPoolProvider } from "~/components/DiffWorkerPoolProvider"; import { useClientSettings } from "~/hooks/useSettings"; @@ -8,6 +9,31 @@ import { PREFERRED_HIGHLIGHTER } from "~/lib/syntaxHighlighting"; import { FILE_LINK_REVEAL_UNSAFE_CSS } from "./fileSurfaceChrome"; +/** Select All spans the file's shadow tree, where the virtualizer mounts only rows near the viewport. */ +function selectsEveryMountedRow(root: ShadowRoot) { + const rows = root.querySelectorAll("[data-line]"); + const first = rows[0]; + const last = rows[rows.length - 1]; + if (!first || !last) return false; + try { + const selected = document.getSelection()?.getComposedRanges({ shadowRoots: [root] })[0]; + if (!selected) return false; + if (!root.contains(selected.startContainer) || !root.contains(selected.endContainer)) { + return false; + } + const range = document.createRange(); + range.setStart(selected.startContainer, selected.startOffset); + range.setEnd(selected.endContainer, selected.endOffset); + // A dragged selection ends inside rows, so it never holds a row's own boundaries. + return ( + range.comparePoint(first, 0) === 0 && range.comparePoint(last, last.childNodes.length) === 0 + ); + } catch { + // Engines without composed ranges keep their own copy. + return false; + } +} + export default function ReadOnlySourcePreview(props: { readonly name: string; readonly text: string; @@ -19,31 +45,39 @@ export default function ReadOnlySourcePreview(props: { const resolvedTheme = useRenderedAppearance(); const defaultWordWrap = useClientSettings((settings) => settings.wordWrap); const wordWrap = props.wordWrap ?? defaultWordWrap; + const copyWholeFile = (event: ClipboardEvent) => { + const root = event.currentTarget.querySelector("diffs-container")?.shadowRoot; + if (!root || !selectsEveryMountedRow(root)) return; + event.clipboardData.setData("text/plain", props.text); + event.preventDefault(); + }; return ( - - - +
+ + + +
); } diff --git a/apps/web/src/versionSkew.test.ts b/apps/web/src/versionSkew.test.ts index 627b974177392..2b5f61063d600 100644 --- a/apps/web/src/versionSkew.test.ts +++ b/apps/web/src/versionSkew.test.ts @@ -20,13 +20,27 @@ const MISMATCH_HINT = "Version mismatch. Try syncing the client and server to the same T3 Code version."; describe("versionSkew", () => { - it("updates only the proven npm prefix and safely quotes its path", () => { + it("updates only the proven install and safely quotes its paths", () => { expect(manualServerUpdateCommand("0.0.45", { kind: "npm-global", prefix: "/opt/node" })).toBe( "npm install --global --prefix '/opt/node' t3@0.0.45", ); expect( manualServerUpdateCommand("0.0.45", { kind: "npm-global", prefix: "/opt/maria's node" }), ).toBe("npm install --global --prefix '/opt/maria'\\''s node' t3@0.0.45"); + expect( + manualServerUpdateCommand("0.0.45", { + kind: "t3-launcher", + launcher: "t3", + baseDir: "/home/u/.t3", + }), + ).toBe("'t3' update --base-dir '/home/u/.t3' 0.0.45"); + expect( + manualServerUpdateCommand("0.0.45", { + kind: "t3-launcher", + launcher: "/opt/maria's bin/t3", + baseDir: "/data/t3 home", + }), + ).toBe("'/opt/maria'\\''s bin/t3' update --base-dir '/data/t3 home' 0.0.45"); }); beforeEach(() => { branding.APP_VERSION = "0.0.34"; diff --git a/apps/web/src/versionSkew.ts b/apps/web/src/versionSkew.ts index 321e712f8a124..3b45703fb2588 100644 --- a/apps/web/src/versionSkew.ts +++ b/apps/web/src/versionSkew.ts @@ -99,9 +99,13 @@ export function manualServerUpdateCommand( targetVersion: string, installation?: ServerInstallation, ): string { + const quote = (value: string) => `'${value.replaceAll("'", "'\\''")}'`; if (installation?.kind === "npm-global") { - const prefix = `'${installation.prefix.replaceAll("'", "'\\''")}'`; - return `npm install --global --prefix ${prefix} t3@${targetVersion}`; + return `npm install --global --prefix ${quote(installation.prefix)} t3@${targetVersion}`; + } + if (installation?.kind === "t3-launcher") { + // An inherited T3CODE_HOME would otherwise send the update to a home the launcher is not in. + return `${quote(installation.launcher)} update --base-dir ${quote(installation.baseDir)} ${targetVersion}`; } const runner = installation?.kind === "pnpm-dlx" ? "pnpm dlx" : installation?.kind === "bunx" ? "bunx" : "npx"; diff --git a/docs/user/updating.md b/docs/user/updating.md index 8f4a72e8856b6..5c543dcd0bccf 100644 --- a/docs/user/updating.md +++ b/docs/user/updating.md @@ -103,7 +103,7 @@ The offered action depends on how the server runs: | -------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | **Update server** | Keep the client open while it installs and reconnects. Supported background services update remotely. For a desktop-hosted server, this also closes and relaunches the desktop app on the host. | | **Update the desktop app** | Update the desktop app on the machine running the server, then reopen it if needed. | -| **Copy update command** | Run the command on the named host to update the detected global npm install, then restart the server with your usual options. | +| **Copy update command** | Run the command on the named host to update the detected global npm install or installed `t3` command, then restart the server with your usual options. | | **Copy relaunch command** | Stop the command-line server on its host and relaunch with the copied command, keeping your usual subcommand and options. This does not update an installed `t3` command. | On the host, run: diff --git a/packages/contracts/src/environment.test.ts b/packages/contracts/src/environment.test.ts index 80f1156663a0b..1646684f1da02 100644 --- a/packages/contracts/src/environment.test.ts +++ b/packages/contracts/src/environment.test.ts @@ -16,7 +16,11 @@ const descriptor = { describe("ExecutionEnvironmentDescriptor", () => { it("decodes old, recognized and future manual installation descriptors", () => { expect(decodeDescriptor(descriptor).capabilities.serverInstallation).toBeUndefined(); - for (const installation of [{ kind: "npx" }, { kind: "npm-global", prefix: "/opt/node" }]) { + for (const installation of [ + { kind: "npx" }, + { kind: "npm-global", prefix: "/opt/node" }, + { kind: "t3-launcher", launcher: "/opt/bin/t3", baseDir: "/data/t3" }, + ]) { expect( decodeDescriptor({ ...descriptor, @@ -24,7 +28,18 @@ describe("ExecutionEnvironmentDescriptor", () => { }).capabilities.serverInstallation, ).toEqual(installation); } - for (const installation of [{ kind: "future-manager" }, { kind: "npm-global" }]) { + const spaced = { kind: "t3-launcher", launcher: "/opt/bin/t3 ", baseDir: " /data/t3 " }; + expect( + decodeDescriptor({ + ...descriptor, + capabilities: { ...descriptor.capabilities, serverInstallation: spaced }, + }).capabilities.serverInstallation, + ).toEqual(spaced); + for (const installation of [ + { kind: "future-manager" }, + { kind: "npm-global" }, + { kind: "t3-launcher", launcher: "t3" }, + ]) { expect( decodeDescriptor({ ...descriptor, diff --git a/packages/contracts/src/environment.ts b/packages/contracts/src/environment.ts index b234da08f3f9f..d2eaccc0cd4ec 100644 --- a/packages/contracts/src/environment.ts +++ b/packages/contracts/src/environment.ts @@ -61,9 +61,18 @@ export type ExecutionEnvironmentPlatform = typeof ExecutionEnvironmentPlatform.T export const ServerSelfUpdateMethod = Schema.Literals(["boot-service", "respawn", "desktop-app"]); export type ServerSelfUpdateMethod = typeof ServerSelfUpdateMethod.Type; +// Not trimmed: a path that differs from the installed one would update the wrong home. +const InstalledPath = Schema.String.check(Schema.isNonEmpty()); + export const ServerInstallation = Schema.Union([ Schema.Struct({ kind: Schema.Literals(["npx", "pnpm-dlx", "bunx"]) }), Schema.Struct({ kind: Schema.Literal("npm-global"), prefix: TrimmedNonEmptyString }), + // A launcher symlink into a downloaded runtime, as the install script and `t3 update` leave. + Schema.Struct({ + kind: Schema.Literal("t3-launcher"), + launcher: InstalledPath, + baseDir: InstalledPath, + }), ]); export type ServerInstallation = typeof ServerInstallation.Type;