diff --git a/apps/desktop/src/ipc/DesktopIpcHandlers.ts b/apps/desktop/src/ipc/DesktopIpcHandlers.ts index 304039fc4c44..3475bf16d894 100644 --- a/apps/desktop/src/ipc/DesktopIpcHandlers.ts +++ b/apps/desktop/src/ipc/DesktopIpcHandlers.ts @@ -13,6 +13,7 @@ import { setServerExposureMode, setTailscaleServeEnabled, } from "./methods/serverExposure.ts"; +import { importT3Settings, previewT3SettingsImport } from "./methods/lastCodeSettings.ts"; import { bootstrapSshBearerSession, disconnectSshEnvironment, @@ -88,6 +89,8 @@ export const installDesktopIpcHandlers = Effect.fn("desktop.ipc.installHandlers" yield* ipc.handle(getUpdateState); yield* ipc.handle(getLastCodeSettings); yield* ipc.handle(setShowAndInstallLocalNightlies); + yield* ipc.handle(previewT3SettingsImport); + yield* ipc.handle(importT3Settings); yield* ipc.handle(setUpdateChannel); yield* ipc.handle(downloadUpdate); yield* ipc.handle(installUpdate); diff --git a/apps/desktop/src/ipc/channels.ts b/apps/desktop/src/ipc/channels.ts index 73918a0bfb85..4b7d92a3c771 100644 --- a/apps/desktop/src/ipc/channels.ts +++ b/apps/desktop/src/ipc/channels.ts @@ -15,6 +15,8 @@ export const UPDATE_CHECK_CHANNEL = "desktop:update-check"; export const LASTCODE_SETTINGS_GET_CHANNEL = "desktop:lastcode-settings-get"; export const LASTCODE_SETTINGS_SET_LOCAL_NIGHTLIES_CHANNEL = "desktop:lastcode-settings-set-local-nightlies"; +export const LASTCODE_SETTINGS_IMPORT_PREVIEW_CHANNEL = "desktop:lastcode-settings-import-preview"; +export const LASTCODE_SETTINGS_IMPORT_CHANNEL = "desktop:lastcode-settings-import"; export const GET_APP_BRANDING_CHANNEL = "desktop:get-app-branding"; export const GET_LOCAL_ENVIRONMENT_BOOTSTRAPS_CHANNEL = "desktop:get-local-environment-bootstraps"; export const GET_LOCAL_ENVIRONMENT_BEARER_TOKEN_CHANNEL = diff --git a/apps/desktop/src/ipc/methods/lastCodeSettings.ts b/apps/desktop/src/ipc/methods/lastCodeSettings.ts new file mode 100644 index 000000000000..f84f6c6f0c78 --- /dev/null +++ b/apps/desktop/src/ipc/methods/lastCodeSettings.ts @@ -0,0 +1,75 @@ +import { + LastCodeSettingsImportPreviewSchema, + LastCodeSettingsImportResultSchema, +} from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; + +import * as DesktopEnvironment from "../../app/DesktopEnvironment.ts"; +import * as DesktopLifecycle from "../../app/DesktopLifecycle.ts"; +import * as DesktopAppSettings from "../../settings/DesktopAppSettings.ts"; +import { + importT3Settings as importT3SettingsFiles, + isT3SettingsImportSupported, + previewT3SettingsImport as previewT3SettingsImportFiles, + type LastCodeSettingsImportPaths, +} from "../../settings/LastCodeSettingsImport.ts"; +import * as IpcChannels from "../channels.ts"; +import * as DesktopIpc from "../DesktopIpc.ts"; + +function resolveImportPaths( + environment: DesktopEnvironment.DesktopEnvironment["Service"], +): LastCodeSettingsImportPaths { + return { + sourceDirectory: environment.path.join(environment.homeDirectory, ".t3", "userdata"), + destinationDirectory: environment.stateDir, + backupRootDirectory: environment.path.join(environment.baseDir, "settings-import-backups"), + }; +} + +const WSL_ONLY_IMPORT_MESSAGE = + "Import is unavailable while WSL-only mode is selected. Disable WSL-only mode before importing the Windows profile."; + +class LastCodeSettingsImportUnavailableError extends Schema.TaggedErrorClass()( + "LastCodeSettingsImportUnavailableError", + { reason: Schema.String }, +) { + override get message(): string { + return this.reason; + } +} + +export const previewT3SettingsImport = DesktopIpc.makeIpcMethod({ + channel: IpcChannels.LASTCODE_SETTINGS_IMPORT_PREVIEW_CHANNEL, + payload: Schema.Void, + result: LastCodeSettingsImportPreviewSchema, + handler: Effect.fn("desktop.ipc.lastCodeSettings.previewImport")(function* () { + const environment = yield* DesktopEnvironment.DesktopEnvironment; + const appSettings = yield* DesktopAppSettings.DesktopAppSettings; + const preview = yield* Effect.tryPromise(() => + previewT3SettingsImportFiles(resolveImportPaths(environment)), + ); + return !isT3SettingsImportSupported(environment.platform, (yield* appSettings.get).wslOnly) + ? { ...preview, canImport: false, message: WSL_ONLY_IMPORT_MESSAGE } + : preview; + }), +}); + +export const importT3Settings = DesktopIpc.makeIpcMethod({ + channel: IpcChannels.LASTCODE_SETTINGS_IMPORT_CHANNEL, + payload: Schema.Void, + result: LastCodeSettingsImportResultSchema, + handler: Effect.fn("desktop.ipc.lastCodeSettings.import")(function* () { + const environment = yield* DesktopEnvironment.DesktopEnvironment; + const appSettings = yield* DesktopAppSettings.DesktopAppSettings; + if (!isT3SettingsImportSupported(environment.platform, (yield* appSettings.get).wslOnly)) { + return yield* new LastCodeSettingsImportUnavailableError({ reason: WSL_ONLY_IMPORT_MESSAGE }); + } + const result = yield* Effect.tryPromise(() => + importT3SettingsFiles(resolveImportPaths(environment)), + ); + const lifecycle = yield* DesktopLifecycle.DesktopLifecycle; + yield* lifecycle.relaunch("t3-settings-imported"); + return result; + }), +}); diff --git a/apps/desktop/src/preload.ts b/apps/desktop/src/preload.ts index 448d256888e2..6356f3d914a8 100644 --- a/apps/desktop/src/preload.ts +++ b/apps/desktop/src/preload.ts @@ -133,6 +133,9 @@ contextBridge.exposeInMainWorld("desktopBridge", { getLastCodeSettings: () => ipcRenderer.invoke(IpcChannels.LASTCODE_SETTINGS_GET_CHANNEL), setShowAndInstallLocalNightlies: (enabled) => ipcRenderer.invoke(IpcChannels.LASTCODE_SETTINGS_SET_LOCAL_NIGHTLIES_CHANNEL, enabled), + previewT3SettingsImport: () => + ipcRenderer.invoke(IpcChannels.LASTCODE_SETTINGS_IMPORT_PREVIEW_CHANNEL), + importT3Settings: () => ipcRenderer.invoke(IpcChannels.LASTCODE_SETTINGS_IMPORT_CHANNEL), setUpdateChannel: (channel) => ipcRenderer.invoke(IpcChannels.UPDATE_SET_CHANNEL_CHANNEL, channel), checkForUpdate: () => ipcRenderer.invoke(IpcChannels.UPDATE_CHECK_CHANNEL), diff --git a/apps/desktop/src/settings/LastCodeSettingsImport.test.ts b/apps/desktop/src/settings/LastCodeSettingsImport.test.ts new file mode 100644 index 000000000000..f37b7952871e --- /dev/null +++ b/apps/desktop/src/settings/LastCodeSettingsImport.test.ts @@ -0,0 +1,303 @@ +// @effect-diagnostics nodeBuiltinImport:off -- These integration tests exercise the real atomic filesystem transaction in temporary directories. +import { assert, describe, it } from "@effect/vitest"; +import { + DEFAULT_CLIENT_SETTINGS, + DEFAULT_SERVER_SETTINGS, + ProviderInstanceId, + ServerSettings, +} from "@t3tools/contracts"; +import * as NodeFS from "node:fs"; +import * as NodePath from "node:path"; +import * as NodeOS from "node:os"; +import * as Schema from "effect/Schema"; + +const fs = NodeFS.promises; + +import { + importT3Settings, + isT3SettingsImportSupported, + previewT3SettingsImport, + type LastCodeSettingsImportPaths, +} from "./LastCodeSettingsImport.ts"; + +const encodeServerSettings = Schema.encodeSync(ServerSettings); + +async function makePaths(): Promise { + const root = await fs.mkdtemp(NodePath.join(NodeOS.tmpdir(), "lastcode-settings-import-")); + const paths = { + sourceDirectory: NodePath.join(root, "t3"), + destinationDirectory: NodePath.join(root, "lastcode"), + backupRootDirectory: NodePath.join(root, "backups"), + }; + await Promise.all([ + fs.mkdir(paths.sourceDirectory, { recursive: true }), + fs.mkdir(paths.destinationDirectory, { recursive: true }), + ]); + return paths; +} + +function json(value: unknown): string { + return `${JSON.stringify(value, null, 2)}\n`; +} + +function record(value: unknown): Record { + assert.isObject(value); + assert.isNotArray(value); + return value as Record; +} + +async function expectRejected(effect: () => Promise): Promise { + let rejected = false; + try { + await effect(); + } catch { + rejected = true; + } + assert.isTrue(rejected); +} + +describe("LastCodeSettingsImport", () => { + it("disables imports only for the Windows WSL-only profile", () => { + assert.isFalse(isT3SettingsImportSupported("win32", true)); + assert.isTrue(isT3SettingsImportSupported("win32", false)); + assert.isTrue(isT3SettingsImportSupported("darwin", true)); + assert.isTrue(isT3SettingsImportSupported("linux", true)); + }); + + it("previews missing and invalid categories without exposing file contents", async () => { + const paths = await makePaths(); + await fs.writeFile(NodePath.join(paths.sourceDirectory, "client-settings.json"), "not-json"); + await fs.writeFile( + NodePath.join(paths.sourceDirectory, "keybindings.json"), + "[\n // T3 Code accepts JSONC here.\n]\n", + ); + + const preview = await previewT3SettingsImport(paths); + + assert.equal(preview.canImport, true); + assert.deepEqual( + preview.categories.map(({ id, status }) => ({ id, status })), + [ + { id: "client-preferences", status: "invalid" }, + { id: "keybindings", status: "ready" }, + { id: "server-preferences", status: "missing" }, + ], + ); + }); + + it("imports allowlisted preferences while preserving LastCode-only state and secrets", async () => { + const paths = await makePaths(); + const codex = ProviderInstanceId.make("codex"); + const sourceCustom = ProviderInstanceId.make("source_custom"); + const lastCodeCustom = ProviderInstanceId.make("lastcode_custom"); + const sourceClient = { ...DEFAULT_CLIENT_SETTINGS, fontSizeInterface: 17 }; + sourceClient.favorites = [ + { provider: codex, model: "gpt-source" }, + { provider: sourceCustom, model: "source-model" }, + ]; + sourceClient.providerModelPreferences = { + [codex]: { hiddenModels: ["hidden-source"], modelOrder: ["gpt-source"] }, + [sourceCustom]: { hiddenModels: [], modelOrder: ["source-model"] }, + }; + const destinationClient = { + ...DEFAULT_CLIENT_SETTINGS, + favorites: [{ provider: lastCodeCustom, model: "lastcode-model" }], + providerModelPreferences: { + [lastCodeCustom]: { hiddenModels: [], modelOrder: ["lastcode-model"] }, + }, + }; + const sourceServer = record(structuredClone(encodeServerSettings(DEFAULT_SERVER_SETTINGS))); + const sourceProviders = record(sourceServer.providers); + const sourceOpenCode = record(sourceProviders.opencode); + const sourceCodex = record(sourceProviders.codex); + sourceServer.addProjectBaseDirectory = "/src/t3-projects"; + sourceOpenCode.serverUrl = "http://127.0.0.1:4096"; + sourceOpenCode.serverPassword = "source-secret"; + sourceCodex.launchArgs = "--source-secret token"; + sourceServer.textGenerationModelSelection = { + instanceId: "opencode", + model: "source-model", + options: [], + }; + sourceServer.sourceControlWriterModelSelection = { + instanceId: "opencode", + model: "source-writer", + options: [], + }; + sourceServer.providerInstances = { + codex: { + driver: "codex", + displayName: "T3 Codex", + accentColor: "#123456", + enabled: false, + config: { + binaryPath: "/opt/t3/codex", + homePath: "/Users/source/.codex", + launchArgs: "--source-instance-secret token", + customModels: ["source-model"], + }, + environment: [{ name: "TOKEN", value: "source-default-token", sensitive: true }], + }, + personal: { + driver: "codex", + environment: [{ name: "TOKEN", value: "source-token", sensitive: true }], + }, + }; + + const destinationServer = record( + structuredClone(encodeServerSettings(DEFAULT_SERVER_SETTINGS)), + ); + const destinationProviders = record(destinationServer.providers); + const destinationOpenCode = record(destinationProviders.opencode); + const destinationCodex = record(destinationProviders.codex); + destinationServer.addProjectBaseDirectory = "/src/lastcode-projects"; + destinationOpenCode.serverUrl = "http://127.0.0.1:7777"; + destinationOpenCode.serverPassword = "lastcode-secret"; + destinationCodex.launchArgs = "--lastcode-only"; + destinationServer.textGenerationModelSelection = { + instanceId: "codex", + model: "lastcode-model", + options: [], + }; + destinationServer.sourceControlWriterModelSelection = { + instanceId: "codex", + model: "lastcode-writer", + options: [], + }; + destinationServer.providerInstances = { + codex: { + driver: "codex", + enabled: true, + config: { + binaryPath: "/opt/lastcode/codex", + launchArgs: "--lastcode-instance-only", + }, + environment: [{ name: "TOKEN", value: "lastcode-default-token", sensitive: true }], + }, + lastcode: { + driver: "codex", + environment: [{ name: "TOKEN", value: "lastcode-token", sensitive: true }], + }, + }; + await Promise.all([ + fs.writeFile( + NodePath.join(paths.sourceDirectory, "client-settings.json"), + `// T3 Code accepts JSONC here.\n${json(sourceClient)}`, + ), + fs.writeFile(NodePath.join(paths.sourceDirectory, "keybindings.json"), "[\n // none\n]\n"), + fs.writeFile( + NodePath.join(paths.sourceDirectory, "settings.json"), + `// T3 Code accepts JSONC here.\n${json(sourceServer)}`, + ), + fs.writeFile( + NodePath.join(paths.destinationDirectory, "settings.json"), + `// LastCode accepts JSONC here too.\n${json(destinationServer)}`, + ), + fs.writeFile( + NodePath.join(paths.destinationDirectory, "client-settings.json"), + json(destinationClient), + ), + ]); + + const result = await importT3Settings(paths); + const importedClient = JSON.parse( + await fs.readFile(NodePath.join(paths.destinationDirectory, "client-settings.json"), "utf8"), + ) as Record; + const importedServer = record( + JSON.parse( + await fs.readFile(NodePath.join(paths.destinationDirectory, "settings.json"), "utf8"), + ) as unknown, + ); + assert.equal(importedClient.fontSizeInterface, 17); + assert.deepEqual(importedClient.favorites, [ + { provider: "lastcode_custom", model: "lastcode-model" }, + { provider: "codex", model: "gpt-source" }, + ]); + assert.deepEqual(importedClient.providerModelPreferences, { + lastcode_custom: { hiddenModels: [], modelOrder: ["lastcode-model"] }, + codex: { hiddenModels: ["hidden-source"], modelOrder: ["gpt-source"] }, + }); + assert.equal(importedServer.addProjectBaseDirectory, "/src/t3-projects"); + assert.deepEqual(importedServer.providers, destinationServer.providers); + assert.deepEqual(importedServer.providerInstances, destinationServer.providerInstances); + assert.deepEqual( + importedServer.textGenerationModelSelection, + destinationServer.textGenerationModelSelection, + ); + assert.deepEqual( + importedServer.sourceControlWriterModelSelection, + destinationServer.sourceControlWriterModelSelection, + ); + assert.deepEqual(result.imported, ["client-preferences", "keybindings", "server-preferences"]); + assert.include( + await fs.readFile(NodePath.join(result.backupDirectory, "settings.json"), "utf8"), + '"serverPassword": "lastcode-secret"', + ); + assert.equal( + JSON.parse(await fs.readFile(NodePath.join(result.backupDirectory, "manifest.json"), "utf8")) + .files.length, + 3, + ); + }); + + it("imports usable keybindings while omitting invalid entries", async () => { + const paths = await makePaths(); + const usable = Array.from({ length: 258 }, (_, index) => ({ + key: "mod+j", + command: "terminal.toggle", + when: `context${index}`, + })); + await fs.writeFile( + NodePath.join(paths.sourceDirectory, "keybindings.json"), + `[ + // Obsolete commands and malformed shortcuts are ignored by T3 Code. + { "key": "mod+x", "command": "removed.command" }, + { "key": "mod+shift+d+o", "command": "terminal.new" }, + ${usable.map((rule) => JSON.stringify(rule)).join(",\n ")}, + ]`, + ); + + const preview = await previewT3SettingsImport(paths); + assert.equal(preview.categories.find(({ id }) => id === "keybindings")?.status, "ready"); + + await importT3Settings(paths); + + assert.deepEqual( + JSON.parse( + await fs.readFile(NodePath.join(paths.destinationDirectory, "keybindings.json"), "utf8"), + ), + usable.slice(-256), + ); + }); + + it("refuses to import when the source and destination are the same directory", async () => { + const paths = await makePaths(); + const preview = await previewT3SettingsImport({ + ...paths, + destinationDirectory: paths.sourceDirectory, + }); + + assert.equal(preview.canImport, false); + assert.isTrue(preview.categories.every((category) => category.status === "invalid")); + }); + + it("validates every destination before replacing any file", async () => { + const paths = await makePaths(); + await Promise.all([ + fs.writeFile( + NodePath.join(paths.sourceDirectory, "client-settings.json"), + json(DEFAULT_CLIENT_SETTINGS), + ), + fs.writeFile( + NodePath.join(paths.sourceDirectory, "settings.json"), + json(encodeServerSettings(DEFAULT_SERVER_SETTINGS)), + ), + fs.writeFile(NodePath.join(paths.destinationDirectory, "settings.json"), "not-json"), + ]); + + await expectRejected(() => importT3Settings(paths)); + await expectRejected(() => + fs.readFile(NodePath.join(paths.destinationDirectory, "client-settings.json")), + ); + }); +}); diff --git a/apps/desktop/src/settings/LastCodeSettingsImport.ts b/apps/desktop/src/settings/LastCodeSettingsImport.ts new file mode 100644 index 000000000000..43266ca62377 --- /dev/null +++ b/apps/desktop/src/settings/LastCodeSettingsImport.ts @@ -0,0 +1,374 @@ +// @effect-diagnostics nodeBuiltinImport:off cryptoRandomUUID:off globalDate:off -- This adapter performs one bounded, transactional import against host profile files before the desktop process relaunches. +import { + ClientSettingsSchema, + KeybindingRule, + KeybindingsConfig, + MAX_KEYBINDINGS_COUNT, + ServerSettings, + type LastCodeSettingsImportCategory, + type LastCodeSettingsImportCategoryId, + type LastCodeSettingsImportPreview, + type LastCodeSettingsImportResult, +} from "@t3tools/contracts"; +import { compileResolvedKeybindingRule } from "@t3tools/shared/keybindings"; +import { fromLenientJson } from "@t3tools/shared/schemaJson"; +import * as NodeFS from "node:fs"; +import * as NodePath from "node:path"; +import * as Schema from "effect/Schema"; + +const fs = NodeFS.promises; +const fsConstants = NodeFS.constants; + +const ClientSettingsDocumentSchema = Schema.Struct({ settings: ClientSettingsSchema }); +const ClientSettingsJson = fromLenientJson(ClientSettingsSchema); +const LegacyClientSettingsDocumentJson = fromLenientJson(ClientSettingsDocumentSchema); +const RawKeybindingsJson = fromLenientJson(Schema.Array(Schema.Unknown)); +const KeybindingsJson = fromLenientJson(KeybindingsConfig); +const ServerSettingsJson = fromLenientJson(ServerSettings); + +const decodeClientSettingsJson = Schema.decodeUnknownSync(ClientSettingsJson); +const decodeLegacyClientSettingsDocumentJson = Schema.decodeUnknownSync( + LegacyClientSettingsDocumentJson, +); +const encodeClientSettingsJson = Schema.encodeSync(ClientSettingsJson); +const decodeRawKeybindingsJson = Schema.decodeUnknownSync(RawKeybindingsJson); +const decodeKeybindingRule = Schema.decodeUnknownSync(KeybindingRule); +const encodeKeybindingsJson = Schema.encodeSync(KeybindingsJson); +const decodeServerSettingsJson = Schema.decodeUnknownSync(ServerSettingsJson); +const encodeServerSettings = Schema.encodeSync(ServerSettings); + +const CATEGORY_DEFINITIONS: ReadonlyArray<{ + readonly id: LastCodeSettingsImportCategoryId; + readonly label: string; + readonly sourceFile: string; + readonly detail: string; +}> = [ + { + id: "client-preferences", + label: "Appearance and app preferences", + sourceFile: "client-settings.json", + detail: "Theme, fonts, editor, sidebar, confirmations, and model display preferences.", + }, + { + id: "keybindings", + label: "Keyboard shortcuts", + sourceFile: "keybindings.json", + detail: "Custom keybinding rules.", + }, + { + id: "server-preferences", + label: "Server behavior", + sourceFile: "settings.json", + detail: "Background behavior, Git fetch, thread defaults, and source-control writing.", + }, +]; + +export const LASTCODE_SETTINGS_IMPORT_EXCLUSIONS = [ + "Projects, threads, checkpoints, attachments, and databases", + "Provider configuration, credentials, instances, and model selections", + "Saved environments, connections, and machine identity", + "Desktop window state, network exposure, Tailscale, ports, and WSL runtime selection", + "Update channels, local-nightly settings, caches, logs, and browser storage", +] as const; + +const BUILT_IN_PROVIDER_INSTANCE_IDS = new Set([ + "codex", + "claudeAgent", + "cursor", + "grok", + "opencode", +]); + +const SAFE_SERVER_SETTING_KEYS = [ + "enableLegacyTokenStreaming", + "enableProviderUpdateChecks", + "backgroundActivity", + "automaticGitFetchInterval", + "providerHealthRefreshInterval", + "backgroundActivityProfile", + "defaultThreadEnvMode", + "newWorktreesStartFromOrigin", + "addProjectBaseDirectory", + "sourceControlWritingStyle", +] as const; + +type JsonRecord = Record; + +export interface LastCodeSettingsImportPaths { + readonly sourceDirectory: string; + readonly destinationDirectory: string; + readonly backupRootDirectory: string; +} + +export function isT3SettingsImportSupported(platform: NodeJS.Platform, wslOnly: boolean): boolean { + return platform !== "win32" || !wslOnly; +} + +interface PreparedWrite { + readonly id: LastCodeSettingsImportCategoryId; + readonly fileName: string; + readonly targetPath: string; + readonly content: string; + readonly previousContent: string | null; +} + +function asRecord(value: unknown, description: string): JsonRecord { + if (typeof value !== "object" || value === null || Array.isArray(value)) { + throw new Error(`${description} must be a JSON object.`); + } + return value as JsonRecord; +} + +function decodeSourceClientSettings(raw: string) { + try { + return decodeLegacyClientSettingsDocumentJson(raw).settings; + } catch { + return decodeClientSettingsJson(raw); + } +} + +function decodeUsableKeybindings(raw: string) { + const keybindings = []; + for (const entry of decodeRawKeybindingsJson(raw)) { + try { + const rule = decodeKeybindingRule(entry); + if (compileResolvedKeybindingRule(rule) !== null) keybindings.push(rule); + } catch { + // T3 Code ignores obsolete or malformed entries while retaining the rest of the file. + } + } + return keybindings.slice(-MAX_KEYBINDINGS_COUNT); +} + +function safeServerPreferences(raw: string): JsonRecord { + const encoded = asRecord( + encodeServerSettings(decodeServerSettingsJson(raw)), + "Encoded server settings", + ); + const selected: JsonRecord = {}; + for (const key of SAFE_SERVER_SETTING_KEYS) selected[key] = encoded[key]; + return selected; +} + +function validateSource(id: LastCodeSettingsImportCategoryId, raw: string): void { + switch (id) { + case "client-preferences": + decodeSourceClientSettings(raw); + return; + case "keybindings": + decodeUsableKeybindings(raw); + return; + case "server-preferences": + safeServerPreferences(raw); + return; + } +} + +async function readOptional(path: string): Promise { + try { + return await fs.readFile(path, "utf8"); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return null; + throw error; + } +} + +async function inspectCategory( + definition: (typeof CATEGORY_DEFINITIONS)[number], + sourceDirectory: string, +): Promise { + const raw = await readOptional(NodePath.join(sourceDirectory, definition.sourceFile)); + if (raw === null) return { ...definition, status: "missing" }; + try { + validateSource(definition.id, raw); + return { ...definition, status: "ready" }; + } catch { + return { ...definition, status: "invalid" }; + } +} + +export async function previewT3SettingsImport( + paths: LastCodeSettingsImportPaths, +): Promise { + const sourceDirectory = NodePath.resolve(paths.sourceDirectory); + const destinationDirectory = NodePath.resolve(paths.destinationDirectory); + const sameDirectory = sourceDirectory === destinationDirectory; + const categories = sameDirectory + ? CATEGORY_DEFINITIONS.map((definition) => ({ ...definition, status: "invalid" as const })) + : await Promise.all( + CATEGORY_DEFINITIONS.map((definition) => inspectCategory(definition, sourceDirectory)), + ); + return { + sourceDirectory, + destinationDirectory, + categories, + excluded: LASTCODE_SETTINGS_IMPORT_EXCLUSIONS, + canImport: !sameDirectory && categories.some((category) => category.status === "ready"), + message: sameDirectory ? "T3 Code and LastCode resolve to the same settings directory." : null, + }; +} + +function stringifyJson(value: unknown): string { + return `${JSON.stringify(value, null, 2)}\n`; +} + +function mergeClientSettings(sourceRaw: string, destinationRaw: string | null): string { + const source = decodeSourceClientSettings(sourceRaw); + const destination = + destinationRaw === null + ? decodeClientSettingsJson("{}") + : decodeSourceClientSettings(destinationRaw); + const isBuiltInProviderPreference = (provider: string) => + BUILT_IN_PROVIDER_INSTANCE_IDS.has(provider); + const favorites = [ + ...destination.favorites.filter(({ provider }) => !isBuiltInProviderPreference(provider)), + ...source.favorites.filter(({ provider }) => isBuiltInProviderPreference(provider)), + ]; + const providerModelPreferences = Object.fromEntries([ + ...Object.entries(destination.providerModelPreferences).filter( + ([provider]) => !isBuiltInProviderPreference(provider), + ), + ...Object.entries(source.providerModelPreferences).filter(([provider]) => + isBuiltInProviderPreference(provider), + ), + ]); + return `${encodeClientSettingsJson({ ...source, favorites, providerModelPreferences })}\n`; +} + +function mergeServerSettings(sourceRaw: string, destinationRaw: string | null): string { + const destination = + destinationRaw === null + ? asRecord(encodeServerSettings(decodeServerSettingsJson("{}")), "Server settings") + : asRecord(encodeServerSettings(decodeServerSettingsJson(destinationRaw)), "Server settings"); + const imported = safeServerPreferences(sourceRaw); + return stringifyJson({ ...destination, ...imported }); +} + +function buildImportedContent( + id: LastCodeSettingsImportCategoryId, + sourceRaw: string, + destinationRaw: string | null, +): string { + switch (id) { + case "client-preferences": + return mergeClientSettings(sourceRaw, destinationRaw); + case "keybindings": + return `${encodeKeybindingsJson(decodeUsableKeybindings(sourceRaw))}\n`; + case "server-preferences": + return mergeServerSettings(sourceRaw, destinationRaw); + } +} + +async function prepareWrites( + paths: LastCodeSettingsImportPaths, + categories: readonly LastCodeSettingsImportCategory[], +): Promise { + const writes: PreparedWrite[] = []; + for (const category of categories) { + if (category.status !== "ready") continue; + const sourcePath = NodePath.join(paths.sourceDirectory, category.sourceFile); + const targetPath = NodePath.join(paths.destinationDirectory, category.sourceFile); + const [sourceRaw, previousContent] = await Promise.all([ + fs.readFile(sourcePath, "utf8"), + readOptional(targetPath), + ]); + writes.push({ + id: category.id, + fileName: category.sourceFile, + targetPath, + content: buildImportedContent(category.id, sourceRaw, previousContent), + previousContent, + }); + } + return writes; +} + +async function replaceFileAtomically(targetPath: string, content: string): Promise { + const temporaryPath = `${targetPath}.${process.pid}.${crypto.randomUUID()}.tmp`; + try { + await fs.writeFile(temporaryPath, content, { encoding: "utf8", mode: 0o600 }); + await fs.rename(temporaryPath, targetPath); + } finally { + await fs.rm(temporaryPath, { force: true }).catch(() => undefined); + } +} + +async function restoreWrites(writes: readonly PreparedWrite[]): Promise { + const errors: unknown[] = []; + for (const write of writes.toReversed()) { + try { + if (write.previousContent === null) { + await fs.rm(write.targetPath, { force: true }); + } else { + await replaceFileAtomically(write.targetPath, write.previousContent); + } + } catch (error) { + errors.push(error); + } + } + if (errors.length > 0) throw new AggregateError(errors, "Could not roll back imported settings."); +} + +export async function importT3Settings( + paths: LastCodeSettingsImportPaths, +): Promise { + const preview = await previewT3SettingsImport(paths); + if (!preview.canImport) throw new Error("No valid T3 Code settings are available to import."); + + const writes = await prepareWrites(paths, preview.categories); + await fs.mkdir(paths.destinationDirectory, { recursive: true, mode: 0o700 }); + await fs.mkdir(paths.backupRootDirectory, { recursive: true, mode: 0o700 }); + const backupDirectory = NodePath.join( + paths.backupRootDirectory, + `${new Date().toISOString().replace(/[:.]/g, "-")}-${crypto.randomUUID().slice(0, 8)}`, + ); + await fs.mkdir(backupDirectory, { mode: 0o700 }); + + for (const write of writes) { + if (write.previousContent !== null) { + await fs.writeFile(NodePath.join(backupDirectory, write.fileName), write.previousContent, { + encoding: "utf8", + mode: 0o600, + }); + } + } + await fs.writeFile( + NodePath.join(backupDirectory, "manifest.json"), + stringifyJson({ + importedAt: new Date().toISOString(), + sourceDirectory: preview.sourceDirectory, + destinationDirectory: preview.destinationDirectory, + files: writes.map((write) => ({ + category: write.id, + file: write.fileName, + hadPreviousVersion: write.previousContent !== null, + })), + }), + { encoding: "utf8", mode: 0o600 }, + ); + + const replaced: PreparedWrite[] = []; + try { + for (const write of writes) { + await fs.access(NodePath.dirname(write.targetPath), fsConstants.W_OK); + await replaceFileAtomically(write.targetPath, write.content); + replaced.push(write); + } + } catch (error) { + try { + await restoreWrites(replaced); + } catch (rollbackError) { + // eslint-disable-next-line preserve-caught-error -- Both caught failures are explicit AggregateError members. + throw new AggregateError( + [error, rollbackError], + "Settings import and rollback both failed.", + { + cause: rollbackError, + }, + ); + } + throw error; + } + + return { imported: writes.map((write) => write.id), backupDirectory }; +} diff --git a/apps/web/src/components/settings/LastCodeSettings.tsx b/apps/web/src/components/settings/LastCodeSettings.tsx index b345354ff27e..f18445ae8c8b 100644 --- a/apps/web/src/components/settings/LastCodeSettings.tsx +++ b/apps/web/src/components/settings/LastCodeSettings.tsx @@ -1,9 +1,13 @@ -import type { DesktopLastCodeSettingsState } from "@t3tools/contracts"; -import { MoonStarIcon } from "lucide-react"; +import type { + DesktopLastCodeSettingsState, + LastCodeSettingsImportPreview, +} from "@t3tools/contracts"; +import { DownloadIcon, MoonStarIcon } from "lucide-react"; import { useCallback, useEffect, useState } from "react"; import { isElectron } from "../../env"; import { useDesktopUpdateState } from "../../state/desktopUpdate"; +import { Button } from "../ui/button"; import { Switch } from "../ui/switch"; import { stackedThreadToast, toastManager } from "../ui/toast"; import { searchableSetting } from "./settingsSearch"; @@ -12,7 +16,9 @@ import { SettingsPageContainer, SettingsRow, SettingsSection } from "./settingsL export function LastCodeSettingsPanel() { const updateState = useDesktopUpdateState(); const [settings, setSettings] = useState(null); + const [importPreview, setImportPreview] = useState(null); const [isSaving, setIsSaving] = useState(false); + const [isImporting, setIsImporting] = useState(false); useEffect(() => { const bridge = window.desktopBridge; @@ -31,6 +37,23 @@ export function LastCodeSettingsPanel() { }); }, []); + useEffect(() => { + const bridge = window.desktopBridge; + if (!bridge || typeof bridge.previewT3SettingsImport !== "function") return; + void bridge + .previewT3SettingsImport() + .then(setImportPreview) + .catch((error: unknown) => { + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Could not inspect T3 Code settings", + description: error instanceof Error ? error.message : "Settings preview failed.", + }), + ); + }); + }, []); + const setLocalNightlies = useCallback(async (enabled: boolean) => { const bridge = window.desktopBridge; if (!bridge || typeof bridge.setShowAndInstallLocalNightlies !== "function") return; @@ -50,6 +73,31 @@ export function LastCodeSettingsPanel() { } }, []); + const importSettings = useCallback(async () => { + const bridge = window.desktopBridge; + if (!bridge || typeof bridge.importT3Settings !== "function") return; + setIsImporting(true); + try { + const result = await bridge.importT3Settings(); + toastManager.add( + stackedThreadToast({ + type: "success", + title: "T3 Code settings imported", + description: `Backed up the previous LastCode settings to ${result.backupDirectory}. Restarting LastCode…`, + }), + ); + } catch (error) { + setIsImporting(false); + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Could not import T3 Code settings", + description: error instanceof Error ? error.message : "Settings import failed.", + }), + ); + } + }, []); + const localUpdateActive = updateState?.source === "lastcode-local" && (updateState.status === "downloading" || updateState.status === "downloaded"); @@ -81,6 +129,46 @@ export function LastCodeSettingsPanel() { } /> + }> + +

Source: {importPreview.sourceDirectory}

+ {importPreview.message ?

{importPreview.message}

: null} +
    + {importPreview.categories.map((category) => ( +
  • + {category.label}{" "} + + ({category.sourceFile}) — {category.status} + + {category.status === "ready" ? : {category.detail} : null} +
  • + ))} +
+

Not imported: {importPreview.excluded.join("; ")}.

+ + ) : ( + "Inspecting ~/.t3/userdata…" + ) + } + control={ + + } + /> +
); } diff --git a/apps/web/src/components/settings/settingsSearch.ts b/apps/web/src/components/settings/settingsSearch.ts index 5663e04f4d00..f48250ad9ff6 100644 --- a/apps/web/src/components/settings/settingsSearch.ts +++ b/apps/web/src/components/settings/settingsSearch.ts @@ -201,6 +201,11 @@ export const SETTINGS_SEARCH_ITEMS = [ title: "Show and install local nightlies", to: "/settings/lastcode", }, + { + id: "import-t3-settings", + title: "Import settings from T3 Code", + to: "/settings/lastcode", + }, { id: "archive", title: "Archived threads", diff --git a/docs/lastcode/README.md b/docs/lastcode/README.md index a1f6eeccf1e6..5ca078deafaa 100644 --- a/docs/lastcode/README.md +++ b/docs/lastcode/README.md @@ -21,6 +21,8 @@ directories. evaluation tags. - [Local nightly updates](local-nightly-updates.md): the opt-in in-app checkpoint build, staging, installation, safety boundaries, and logs. +- [Settings import](settings-import.md): the one-time, selective migration from + T3 Code into an independent LastCode profile, including exclusions and backups. ## Command Summary diff --git a/docs/lastcode/settings-import.md b/docs/lastcode/settings-import.md new file mode 100644 index 000000000000..8d67b1015bd1 --- /dev/null +++ b/docs/lastcode/settings-import.md @@ -0,0 +1,62 @@ +# Importing T3 Code Settings + +LastCode keeps its runtime profile independent from T3 Code. Settings → LastCode +offers a one-time **Import and restart** action so a new LastCode install can +start with familiar preferences without sharing mutable state between the two +applications. + +The preview reads the standard T3 Code profile at `~/.t3/userdata`. It shows the +status of each supported source file before enabling the import. Missing and +invalid files are skipped; at least one valid category is required. + +The import is conservatively unavailable whenever the Windows desktop has +WSL-only mode selected, including a temporary Windows fallback. Disable +WSL-only mode before importing the Windows profile. Normal Windows mode and +parallel WSL mode import the Windows primary profile as expected. + +## Imported categories + +| Category | Source and destination file | Imported data | +| ------------------------------ | --------------------------- | ----------------------------------------------------------------------------------------------------------- | +| Appearance and app preferences | `client-settings.json` | Theme, fonts, editor behavior, sidebar preferences, confirmations, favorites, and model display preferences | +| Keyboard shortcuts | `keybindings.json` | Valid custom keybinding rules | +| Server behavior | `settings.json` | Background behavior, Git fetch behavior, thread defaults, and source-control writing preferences | + +Provider configuration is deliberately left alone. LastCode keeps its existing +provider paths, toggles, instances, credentials, environment variables, and +server-side model selections. This keeps a one-time convenience import from +changing whether or how an agent provider runs. + +Favorites and model-display preferences for built-in providers are imported. +Source-only custom-provider references are omitted because their provider +instances are not copied; existing LastCode custom-provider preferences are +preserved. + +## Deliberate exclusions + +The importer never copies: + +- databases, projects, threads, checkpoints, attachments, or other workspace data; +- environment or machine identity; +- authentication tokens, provider-instance environment variables, or other secrets; +- saved environments or connection catalogs; +- desktop window state, server exposure mode, Tailscale configuration, ports, + or WSL runtime selection; +- update channels or LastCode's local-nightly opt-in; +- logs, caches, browser state, update artifacts, or release identity. + +These boundaries let T3 Code and LastCode run concurrently without either app +mutating the other's state. The import is a copy, not synchronization; later +changes in either application remain local to that application. + +## Backup and failure behavior + +Before replacing any LastCode file, the importer writes the previous version to +`~/.lastcode/settings-import-backups/-/`. The backup directory is +private to the local user and includes a manifest recording which categories +were imported and which destination files previously existed. + +Each replacement is written to a temporary sibling and atomically renamed. If +a later replacement fails, files already replaced in that operation are restored +to their pre-import contents. A successful import requests a normal LastCode +restart so both the desktop shell and its bundled server load the new settings. diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts index 5f29314c35d2..53e09ff00391 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -253,6 +253,65 @@ export const DesktopLastCodeSettingsStateSchema = Schema.Struct({ message: Schema.NullOr(Schema.String), }); +export const LastCodeSettingsImportCategoryIdSchema = Schema.Literals([ + "client-preferences", + "keybindings", + "server-preferences", +]); +export type LastCodeSettingsImportCategoryId = typeof LastCodeSettingsImportCategoryIdSchema.Type; + +export const LastCodeSettingsImportCategoryStatusSchema = Schema.Literals([ + "ready", + "missing", + "invalid", +]); +export type LastCodeSettingsImportCategoryStatus = + typeof LastCodeSettingsImportCategoryStatusSchema.Type; + +export interface LastCodeSettingsImportCategory { + id: LastCodeSettingsImportCategoryId; + label: string; + sourceFile: string; + status: LastCodeSettingsImportCategoryStatus; + detail: string; +} + +export const LastCodeSettingsImportCategorySchema = Schema.Struct({ + id: LastCodeSettingsImportCategoryIdSchema, + label: Schema.String, + sourceFile: Schema.String, + status: LastCodeSettingsImportCategoryStatusSchema, + detail: Schema.String, +}); + +export interface LastCodeSettingsImportPreview { + sourceDirectory: string; + destinationDirectory: string; + categories: readonly LastCodeSettingsImportCategory[]; + excluded: readonly string[]; + canImport: boolean; + message: string | null; +} + +export const LastCodeSettingsImportPreviewSchema = Schema.Struct({ + sourceDirectory: Schema.String, + destinationDirectory: Schema.String, + categories: Schema.Array(LastCodeSettingsImportCategorySchema), + excluded: Schema.Array(Schema.String), + canImport: Schema.Boolean, + message: Schema.NullOr(Schema.String), +}); + +export interface LastCodeSettingsImportResult { + imported: readonly LastCodeSettingsImportCategoryId[]; + backupDirectory: string; +} + +export const LastCodeSettingsImportResultSchema = Schema.Struct({ + imported: Schema.Array(LastCodeSettingsImportCategoryIdSchema), + backupDirectory: Schema.String, +}); + export interface DesktopUpdateActionResult { accepted: boolean; completed: boolean; @@ -1070,6 +1129,8 @@ export interface DesktopBridge { getUpdateState: () => Promise; getLastCodeSettings: () => Promise; setShowAndInstallLocalNightlies: (enabled: boolean) => Promise; + previewT3SettingsImport: () => Promise; + importT3Settings: () => Promise; setUpdateChannel: (channel: DesktopUpdateChannel) => Promise; checkForUpdate: () => Promise; downloadUpdate: () => Promise;