diff --git a/.github/fork/upstream-tag b/.github/fork/upstream-tag index 5eb2260dec54a..c9f3da21d7d7f 100644 --- a/.github/fork/upstream-tag +++ b/.github/fork/upstream-tag @@ -1 +1 @@ -v0.1.10-nightly.20261114.4669 +v0.1.10-nightly.20261114.4682 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 993968245c988..c1bd673dab60f 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -903,34 +903,12 @@ jobs: fi # electron-updater reads one manifest per platform and channel and picks - # the file entry whose name carries the running arch, so the per-arch - # manifests the build jobs wrote are merged back into that one file. - - name: Merge macOS updater manifests + # the file entry whose name carries the running arch. Require every + # platform's two build jobs to have produced matching metadata before + # merging it, so a successful release can never strand one architecture. + - name: Merge desktop updater manifests if: needs.preflight.outputs.release_channel != 'preview' - run: | - shopt -s nullglob - for x64_manifest in release-assets/*-mac-x64.yml; do - arm64_manifest="${x64_manifest%-x64.yml}.yml" - if [[ -f "$arm64_manifest" ]]; then - node scripts/merge-update-manifests.ts --platform mac "$arm64_manifest" "$x64_manifest" - rm -f "$x64_manifest" - fi - done - - - name: Merge Windows updater manifests - if: needs.preflight.outputs.release_channel != 'preview' - run: | - shopt -s nullglob - for x64_manifest in release-assets/*-win-x64.yml; do - arm64_manifest="${x64_manifest%-x64.yml}-arm64.yml" - merged_manifest="${x64_manifest%-win-x64.yml}.yml" - if [[ -f "$arm64_manifest" ]]; then - node scripts/merge-update-manifests.ts --platform win "$x64_manifest" "$arm64_manifest" "$merged_manifest" - rm -f "$x64_manifest" "$arm64_manifest" - else - mv "$x64_manifest" "$merged_manifest" - fi - done + run: node scripts/merge-release-update-manifests.ts release-assets # Updater manifests and blockmaps are what electron-updater consumes. # They are only listed for channels an updater is meant to follow. diff --git a/apps/desktop/src/backend/DesktopBackendConfiguration.test.ts b/apps/desktop/src/backend/DesktopBackendConfiguration.test.ts index 29dbbf0abcc26..70f7ed87d2214 100644 --- a/apps/desktop/src/backend/DesktopBackendConfiguration.test.ts +++ b/apps/desktop/src/backend/DesktopBackendConfiguration.test.ts @@ -373,7 +373,7 @@ describe("DesktopBackendConfiguration", () => { ); assert.equal(config.runningDistro, "Ubuntu"); - assert.deepEqual(config.args.slice(0, 2), ["-d", "Ubuntu"]); + assert.deepEqual(config.args.slice(0, 5), ["-d", "Ubuntu", "--cd", "~", "--exec"]); assert.deepEqual(observedDistros, ["Ubuntu", "Ubuntu", "Ubuntu"]); assert.isTrue(Option.isNone(config.preflightFailure)); }).pipe(Effect.scoped, Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))), @@ -436,6 +436,8 @@ describe("DesktopBackendConfiguration", () => { assert.deepEqual(config.args, [ "-d", "Ubuntu", + "--cd", + "~", "--exec", "env", `PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:${resolvedPath}`, @@ -651,6 +653,8 @@ describe("DesktopBackendConfiguration", () => { assert.deepEqual(config.args, [ "-d", "Ubuntu", + "--cd", + "~", "--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 42c6d0db6e224..5f499e6f4ee37 100644 --- a/apps/desktop/src/backend/DesktopBackendConfiguration.ts +++ b/apps/desktop/src/backend/DesktopBackendConfiguration.ts @@ -739,6 +739,9 @@ const resolveWslStartConfig = Effect.fn("desktop.backendConfiguration.resolveWsl ...baseConfig, args: [ ...distroArgs, + // WSL otherwise inherits the Windows-mounted desktop working directory. + "--cd", + "~", "--exec", "env", `PATH=${launchPath}`, diff --git a/apps/desktop/src/electron/ElectronMenu.ts b/apps/desktop/src/electron/ElectronMenu.ts index 4ff3df6bf10f0..540326346edf8 100644 --- a/apps/desktop/src/electron/ElectronMenu.ts +++ b/apps/desktop/src/electron/ElectronMenu.ts @@ -1,4 +1,4 @@ -import type { ContextMenuItem } from "@t3tools/contracts"; +import type { ContextMenuItem, ContextMenuSource } from "@t3tools/contracts"; import * as HostProcess from "@t3tools/shared/HostProcess"; import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; @@ -17,6 +17,7 @@ export interface ElectronMenuContextInput { readonly window: Electron.BrowserWindow; readonly items: readonly ContextMenuItem[]; readonly position: Option.Option; + readonly source: Option.Option; } export interface ElectronMenuTemplateInput { @@ -259,7 +260,11 @@ export const make = Effect.gen(function* () { callback: () => complete(Option.none()), }), }); - menu.popup(popupOptions); + menu.popup( + Option.isSome(input.source) + ? { ...popupOptions, sourceType: input.source.value } + : popupOptions, + ); } catch (cause) { if (completed) { return; diff --git a/apps/desktop/src/ipc/DesktopIpcHandlers.ts b/apps/desktop/src/ipc/DesktopIpcHandlers.ts index 9f2f68be61520..57d7101cf9ec7 100644 --- a/apps/desktop/src/ipc/DesktopIpcHandlers.ts +++ b/apps/desktop/src/ipc/DesktopIpcHandlers.ts @@ -50,6 +50,7 @@ import { probeRemoteEditors, revealWindow, pickFolder, + toLocalBackendPath, pickProjectFavicon, pickThemeFiles, setTheme, @@ -145,6 +146,7 @@ export const installDesktopIpcHandlers = Effect.fn("desktop.ipc.installHandlers" yield* ipc.handle(setWslOnly); yield* ipc.handle(pickFolder); + yield* ipc.handle(toLocalBackendPath); yield* ipc.handle(pickProjectFavicon); yield* ipc.handle(pickThemeFiles); yield* ipc.handle(setTheme); diff --git a/apps/desktop/src/ipc/channels.ts b/apps/desktop/src/ipc/channels.ts index 0e422f9bc7356..b7bb5e579572f 100644 --- a/apps/desktop/src/ipc/channels.ts +++ b/apps/desktop/src/ipc/channels.ts @@ -1,4 +1,5 @@ export const PICK_FOLDER_CHANNEL = "desktop:pick-folder"; +export const TO_LOCAL_BACKEND_PATH_CHANNEL = "desktop:to-local-backend-path"; export const SET_NOTIFICATION_BADGE_CHANNEL = "desktop:set-notification-badge"; export const PICK_PROJECT_FAVICON_CHANNEL = "desktop:pick-project-favicon"; export const PICK_THEME_FILES_CHANNEL = "desktop:pick-theme-files"; diff --git a/apps/desktop/src/ipc/methods/window.test.ts b/apps/desktop/src/ipc/methods/window.test.ts index 376f85d2baf9d..fbeda6d2f2dcd 100644 --- a/apps/desktop/src/ipc/methods/window.test.ts +++ b/apps/desktop/src/ipc/methods/window.test.ts @@ -25,6 +25,7 @@ import * as DesktopBackendManager from "../../backend/DesktopBackendManager.ts"; import * as DesktopBackendPool from "../../backend/DesktopBackendPool.ts"; import * as DesktopAppSettings from "../../settings/DesktopAppSettings.ts"; import * as DesktopManagedPolicy from "../../app/DesktopManagedPolicy.ts"; +import * as DesktopWslEnvironment from "../../wsl/DesktopWslEnvironment.ts"; import * as ElectronDialog from "../../electron/ElectronDialog.ts"; import * as ElectronWindow from "../../electron/ElectronWindow.ts"; import * as IpcChannels from "../channels.ts"; @@ -36,6 +37,7 @@ import { probeRemoteEditors, readMedia, syncHtmlFullscreen, + toLocalBackendPath, } from "./window.ts"; const readyWslConfig: DesktopBackendManager.DesktopBackendStartConfig = { @@ -185,6 +187,47 @@ describe("getLocalEnvironmentBootstraps", () => { }); }); +describe("toLocalBackendPath", () => { + const { runningDistro: _, ...windowsConfig } = readyWslConfig; + it.effect("converts paths through wslpath only for backends running in WSL", () => + Effect.gen(function* () { + const convert = (backendId: string, path = "C:\\Users\\me\\project") => + toLocalBackendPath.handler({ backendId, path }); + assert.strictEqual(yield* convert("primary"), "C:\\Users\\me\\project"); + assert.strictEqual(yield* convert("wsl:default"), "/drives/c/Users/me/project"); + assert.strictEqual(yield* convert("wsl:missing"), null); + assert.strictEqual( + yield* convert("wsl:default", "\\\\wsl.localhost\\ubuntu\\home\\me\\repo"), + "/home/me/repo", + ); + assert.strictEqual(yield* convert("wsl:default", "\\\\wsl$\\Debian\\home\\me\\repo"), null); + assert.strictEqual( + yield* convert("primary", "\\\\wsl$\\Debian\\home\\me\\repo"), + "\\\\wsl$\\Debian\\home\\me\\repo", + ); + }).pipe( + Effect.provide( + Layer.mergeAll( + DesktopBackendPool.layerTest([ + { + ...defaultWslInstance, + id: DesktopBackendManager.BackendInstanceId("primary"), + currentConfig: Effect.succeedSome(windowsConfig), + }, + defaultWslInstance, + ]), + DesktopWslEnvironment.layerTest({ + windowsToWslPath: (distro, windowsPath) => + distro === "Ubuntu" && windowsPath === "C:\\Users\\me\\project" + ? Option.some("/drives/c/Users/me/project") + : Option.none(), + }), + ), + ), + ), + ); +}); + describe("installRelayTelemetryEnabled", () => { // The renderer reads this over sync IPC, so the answer must not wait on the filesystem. const relayTelemetryEnabled = ( diff --git a/apps/desktop/src/ipc/methods/window.ts b/apps/desktop/src/ipc/methods/window.ts index 53a1138d71a99..cee92e1ead38c 100644 --- a/apps/desktop/src/ipc/methods/window.ts +++ b/apps/desktop/src/ipc/methods/window.ts @@ -1,5 +1,6 @@ import { ContextMenuItemSchema, + ContextMenuSource, DesktopAppBrandingSchema, DesktopEnvironmentBootstrapSchema, DesktopMediaReadSchema, @@ -25,6 +26,7 @@ import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; import * as DesktopBackendConfiguration from "../../backend/DesktopBackendConfiguration.ts"; +import * as DesktopBackendManager from "../../backend/DesktopBackendManager.ts"; import * as DesktopBackendPool from "../../backend/DesktopBackendPool.ts"; import * as DesktopLocalEnvironmentAuth from "../../backend/DesktopLocalEnvironmentAuth.ts"; import * as DesktopEnvironment from "../../app/DesktopEnvironment.ts"; @@ -58,6 +60,7 @@ const ContextMenuPosition = Schema.Struct({ const ContextMenuInput = Schema.Struct({ items: Schema.Array(ContextMenuItemSchema), position: Schema.optionalKey(ContextMenuPosition), + source: Schema.optionalKey(ContextMenuSource), }); function toWebSocketBaseUrl(httpBaseUrl: URL): string { @@ -204,6 +207,20 @@ export const getLocalEnvironmentBearerToken = DesktopIpc.makeIpcMethod({ }), }); +// Ask the distro: its automount root is configurable, so /mnt/ is only the default. +const windowsPathToWsl = Effect.fn("desktop.ipc.window.windowsPathToWsl")(function* ( + distro: string | null, + windowsPath: string, +) { + const linuxUncPath = wslUncPathToLinuxPath(windowsPath); + if (linuxUncPath !== null) return Option.some(linuxUncPath); + const wslEnvironment = yield* DesktopWslEnvironment.DesktopWslEnvironment; + return yield* wslEnvironment.windowsToWslPath( + extractDistroFromUncPath(windowsPath) ?? distro, + windowsPath, + ); +}); + export const pickFolder = DesktopIpc.makeIpcMethod({ channel: IpcChannels.PICK_FOLDER_CHANNEL, payload: Schema.UndefinedOr(PickFolderOptionsSchema), @@ -250,20 +267,29 @@ export const pickFolder = DesktopIpc.makeIpcMethod({ if (!useWsl) { return selectedPath.value; } - - const linuxUncPath = wslUncPathToLinuxPath(selectedPath.value); - if (linuxUncPath !== null) { - return linuxUncPath; - } - - const converted = yield* wslEnvironment.windowsToWslPath( - extractDistroFromUncPath(selectedPath.value) ?? wslDistro, - selectedPath.value, - ); + const converted = yield* windowsPathToWsl(wslDistro, selectedPath.value); return Option.getOrElse(converted, () => selectedPath.value); }), }); +export const toLocalBackendPath = DesktopIpc.makeIpcMethod({ + channel: IpcChannels.TO_LOCAL_BACKEND_PATH_CHANNEL, + payload: Schema.Struct({ backendId: Schema.String, path: Schema.String }), + result: Schema.NullOr(Schema.String), + handler: Effect.fn("desktop.ipc.window.toLocalBackendPath")(function* ({ backendId, path }) { + const pool = yield* DesktopBackendPool.DesktopBackendPool; + const instance = yield* pool.get(DesktopBackendManager.BackendInstanceId(backendId)); + if (Option.isNone(instance)) return null; + const config = yield* instance.value.currentConfig; + if (Option.isNone(config) || Option.isSome(config.value.preflightFailure)) return null; + const { runningDistro } = config.value; + if (runningDistro === undefined) return path; + const uncDistro = extractDistroFromUncPath(path); + if (uncDistro !== null && uncDistro.toLowerCase() !== runningDistro.toLowerCase()) return null; + return Option.getOrNull(yield* windowsPathToWsl(runningDistro, path)); + }), +}); + export const pickProjectFavicon = DesktopIpc.makeIpcMethod({ channel: IpcChannels.PICK_PROJECT_FAVICON_CHANNEL, payload: Schema.UndefinedOr(Schema.String), @@ -316,6 +342,7 @@ export const showContextMenu = DesktopIpc.makeIpcMethod({ window: window.value, items: input.items, position: Option.fromNullishOr(input.position), + source: Option.fromNullishOr(input.source), }); return Option.getOrNull(selectedItemId); }), diff --git a/apps/desktop/src/preload.ts b/apps/desktop/src/preload.ts index 6414743ac3b2f..e0ac5f2161051 100644 --- a/apps/desktop/src/preload.ts +++ b/apps/desktop/src/preload.ts @@ -238,14 +238,17 @@ contextBridge.exposeInMainWorld("desktopBridge", { setWslDistro: (distro) => ipcRenderer.invoke(IpcChannels.SET_WSL_DISTRO_CHANNEL, distro), setWslOnly: (enabled) => ipcRenderer.invoke(IpcChannels.SET_WSL_ONLY_CHANNEL, enabled), pickFolder: (options) => ipcRenderer.invoke(IpcChannels.PICK_FOLDER_CHANNEL, options), + toLocalBackendPath: (backendId, path) => + ipcRenderer.invoke(IpcChannels.TO_LOCAL_BACKEND_PATH_CHANNEL, { backendId, path }), pickProjectFavicon: (initialPath) => ipcRenderer.invoke(IpcChannels.PICK_PROJECT_FAVICON_CHANNEL, initialPath), pickThemeFiles: () => ipcRenderer.invoke(IpcChannels.PICK_THEME_FILES_CHANNEL, undefined), setTheme: (theme) => ipcRenderer.invoke(IpcChannels.SET_THEME_CHANNEL, theme), - showContextMenu: (items, position) => + showContextMenu: (items, position, source) => ipcRenderer.invoke(IpcChannels.CONTEXT_MENU_CHANNEL, { items, ...(position === undefined ? {} : { position }), + ...(source === undefined ? {} : { source }), }), receiveProviderAuthCallback: (url: string) => ipcRenderer.invoke(IpcChannels.RECEIVE_PROVIDER_AUTH_CALLBACK_CHANNEL, url), diff --git a/apps/desktop/src/shell/DesktopShellEnvironment.test.ts b/apps/desktop/src/shell/DesktopShellEnvironment.test.ts index c00e2ea7c65a2..88c45f37cc467 100644 --- a/apps/desktop/src/shell/DesktopShellEnvironment.test.ts +++ b/apps/desktop/src/shell/DesktopShellEnvironment.test.ts @@ -580,6 +580,39 @@ describe("DesktopShellEnvironment", () => { }), ); + it.effect.each([ + { + name: "hydrates both Android SDK names when neither is inherited", + inherited: {}, + expected: { ANDROID_HOME: "/shell/android-home", ANDROID_SDK_ROOT: "/shell/android-root" }, + }, + { + // The device host reads ANDROID_HOME before ANDROID_SDK_ROOT, so a shell + // ANDROID_HOME next to an inherited ANDROID_SDK_ROOT would change the SDK. + name: "keeps an inherited ANDROID_SDK_ROOT selected over a login-shell ANDROID_HOME", + inherited: { ANDROID_SDK_ROOT: "/inherited/android-sdk" }, + expected: { ANDROID_HOME: undefined, ANDROID_SDK_ROOT: "/inherited/android-sdk" }, + }, + ])("$name", ({ inherited, expected }) => + Effect.gen(function* () { + const env: NodeJS.ProcessEnv = { SHELL: "/bin/zsh", PATH: "/usr/bin", ...inherited }; + + yield* runShellEnvironment({ + env, + platform: "darwin", + handler: () => + envOutput({ + PATH: "/usr/bin", + ANDROID_HOME: "/shell/android-home", + ANDROID_SDK_ROOT: "/shell/android-root", + }), + }); + + assert.equal(env.ANDROID_HOME, expected.ANDROID_HOME); + assert.equal(env.ANDROID_SDK_ROOT, expected.ANDROID_SDK_ROOT); + }), + ); + it.effect("keeps mise's PATH record with the login shell PATH", () => Effect.gen(function* () { const env: NodeJS.ProcessEnv = { diff --git a/apps/desktop/src/shell/DesktopShellEnvironment.ts b/apps/desktop/src/shell/DesktopShellEnvironment.ts index f0983ca622490..299b0902b57a5 100644 --- a/apps/desktop/src/shell/DesktopShellEnvironment.ts +++ b/apps/desktop/src/shell/DesktopShellEnvironment.ts @@ -88,6 +88,8 @@ export class DesktopShellEnvironment extends Context.Service< const LOGIN_SHELL_ENV_NAMES = [ "PATH", + "ANDROID_HOME", + "ANDROID_SDK_ROOT", "DBUS_SESSION_BUS_ADDRESS", "DISPLAY", "LANG", @@ -114,6 +116,7 @@ const LOGIN_SHELL_ENV_NAMES = [ ] as const; const WINDOWS_PROFILE_ENV_NAMES = ["PATH", "FNM_DIR", "FNM_MULTISHELL_PATH"] as const; const LOCALE_ENV_NAMES = ["LANG", "LC_ALL", "LC_CTYPE"] as const; +const ANDROID_SDK_ENV_NAMES = ["ANDROID_HOME", "ANDROID_SDK_ROOT"] as const; const FALLBACK_LC_CTYPE = "en_US.UTF-8"; const WINDOWS_SHELL_CANDIDATES = ["pwsh.exe", "powershell.exe"] as const; const LOGIN_SHELL_TIMEOUT = Duration.seconds(5); @@ -528,6 +531,17 @@ const installPosixEnvironment = Effect.fn("desktop.shellEnvironment.installPosix } } + // The device host picks the Android SDK from ANDROID_HOME before ANDROID_SDK_ROOT, + // so a profile-only ANDROID_HOME must not shadow an inherited ANDROID_SDK_ROOT. + if (ANDROID_SDK_ENV_NAMES.every((name) => Option.isNone(trimNonEmpty(config.env[name])))) { + for (const name of ANDROID_SDK_ENV_NAMES) { + const value = trimNonEmpty(shellEnvironment[name]); + if (Option.isSome(value)) { + config.env[name] = value.value; + } + } + } + // Hydrate locale variables together; inherited LC_ALL can override LANG or LC_CTYPE. if ( config.platform === "darwin" && diff --git a/apps/desktop/src/wsl/DesktopWslEnvironment.test.ts b/apps/desktop/src/wsl/DesktopWslEnvironment.test.ts index e2fd195b1955c..0787956a72b76 100644 --- a/apps/desktop/src/wsl/DesktopWslEnvironment.test.ts +++ b/apps/desktop/src/wsl/DesktopWslEnvironment.test.ts @@ -4,8 +4,11 @@ import { assert, describe, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; import * as Path from "effect/Path"; import * as Redacted from "effect/Redacted"; +import * as Sink from "effect/Sink"; +import * as Stream from "effect/Stream"; import { ChildProcess, ChildProcessSpawner } from "effect/process"; import { afterAll, expect } from "vite-plus/test"; import * as NodeChildProcess from "node:child_process"; @@ -24,6 +27,7 @@ import { buildWslRuntimeProbeScript, parseResolvedPath, parseWslRuntimeRoot, + windowsToWslPathImpl, } from "./DesktopWslEnvironment.ts"; // The install script only fails the way this file cares about when a real shell @@ -552,3 +556,48 @@ describe("makeIsAvailable", () => { }), ); }); + +describe("windowsToWslPathImpl", () => { + it.effect("runs wslpath without a shell so folder names stay one literal argument", () => + Effect.gen(function* () { + const spawned: Array = []; + const stdout = Stream.make(new TextEncoder().encode("/mnt/c/a$b & c;(d)\n")); + const spawner = ChildProcessSpawner.make((command) => { + spawned.push(command); + return Effect.succeed( + ChildProcessSpawner.makeHandle({ + pid: ChildProcessSpawner.ProcessId(1), + stdout, + stderr: Stream.empty, + all: stdout, + exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(0)), + isRunning: Effect.succeed(false), + kill: () => Effect.void, + stdin: Sink.drain, + getInputFd: () => Sink.drain, + getOutputFd: () => Stream.empty, + unref: Effect.succeed(Effect.void), + }), + ); + }); + + const converted = yield* windowsToWslPathImpl("Ubuntu", "C:\\a$b & c;(d)").pipe( + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), + ); + + assert.deepStrictEqual(converted, Option.some("/mnt/c/a$b & c;(d)")); + assert.lengthOf(spawned, 1); + const [command] = spawned; + assert(command?._tag === "StandardCommand"); + assert.strictEqual(command.command, "wsl.exe"); + assert.deepStrictEqual(command.args, [ + "-d", + "Ubuntu", + "--exec", + "wslpath", + "-u", + "C:/a$b & c;(d)", + ]); + }), + ); +}); diff --git a/apps/desktop/src/wsl/DesktopWslEnvironment.ts b/apps/desktop/src/wsl/DesktopWslEnvironment.ts index fd557c01489a2..4e5e5b423f20e 100644 --- a/apps/desktop/src/wsl/DesktopWslEnvironment.ts +++ b/apps/desktop/src/wsl/DesktopWslEnvironment.ts @@ -1035,7 +1035,7 @@ const preWarmImpl = ( }), ).pipe(Effect.timeoutOption(PRE_WARM_TIMEOUT), Effect.ignore); -const windowsToWslPathImpl = ( +export const windowsToWslPathImpl = ( distro: string | null, windowsPath: string, ): Effect.Effect, never, ChildProcessSpawner.ChildProcessSpawner> => { @@ -1046,7 +1046,8 @@ const windowsToWslPathImpl = ( const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; const command = ChildProcess.make( "wsl.exe", - [...buildDistroArgs(distro), "--", "wslpath", "-u", normalized], + // --exec skips the distro shell, which would expand `$`, `&` or `;` in folder names. + [...buildDistroArgs(distro), "--exec", "wslpath", "-u", normalized], { stdin: "ignore", stdout: "pipe", diff --git a/apps/marketing/package.json b/apps/marketing/package.json index 04f6830066a1e..8e447d16b2c0e 100644 --- a/apps/marketing/package.json +++ b/apps/marketing/package.json @@ -7,7 +7,8 @@ "stage:install-scripts": "node scripts/stage-install-scripts.mjs", "dev": "node scripts/stage-install-scripts.mjs && astro dev", "build": "node scripts/stage-install-scripts.mjs && astro build", - "preview": "astro preview" + "preview": "astro preview", + "test": "vp test run" }, "dependencies": { "@fontsource-variable/inter": "^5.3.0", diff --git a/apps/marketing/src/i18n/de.ts b/apps/marketing/src/i18n/de.ts index 648b35beebe07..96925adb27069 100644 --- a/apps/marketing/src/i18n/de.ts +++ b/apps/marketing/src/i18n/de.ts @@ -27,6 +27,8 @@ export const de: Messages = { downloadForMac: "Für macOS herunterladen", downloadForWindows: "Für Windows herunterladen", downloadForLinux: "Für Linux herunterladen", + downloadForIos: "Für iOS herunterladen", + downloadForAndroid: "Für Android herunterladen", sourceLink: "Klau unseren Code (legal)", alsoOnPhone: "Auch auf deinem Handy:", screenshotAlt: "T3 Code führt Subagents von Claude, Codex, Muse und Gemini in einem Thread aus", diff --git a/apps/marketing/src/i18n/en.ts b/apps/marketing/src/i18n/en.ts index 1769d81184c5b..1a36424a1bfde 100644 --- a/apps/marketing/src/i18n/en.ts +++ b/apps/marketing/src/i18n/en.ts @@ -27,6 +27,8 @@ export const en = { downloadForMac: "Download for macOS", downloadForWindows: "Download for Windows", downloadForLinux: "Download for Linux", + downloadForIos: "Download for iOS", + downloadForAndroid: "Download for Android", sourceLink: "Steal our code (legally)", alsoOnPhone: "Also on your phone:", screenshotAlt: "T3 Code running Claude, Codex, Muse, and Gemini subagents in one thread", diff --git a/apps/marketing/src/i18n/es.ts b/apps/marketing/src/i18n/es.ts index cb4c28e9871fd..90fcf79bd9429 100644 --- a/apps/marketing/src/i18n/es.ts +++ b/apps/marketing/src/i18n/es.ts @@ -28,6 +28,8 @@ export const es: Messages = { downloadForMac: "Descargar para macOS", downloadForWindows: "Descargar para Windows", downloadForLinux: "Descargar para Linux", + downloadForIos: "Descargar para iOS", + downloadForAndroid: "Descargar para Android", sourceLink: "Roba nuestro código (legalmente)", alsoOnPhone: "También en tu teléfono:", screenshotAlt: "T3 Code ejecutando subagentes de Claude, Codex, Muse y Gemini en un mismo hilo", diff --git a/apps/marketing/src/i18n/fr.ts b/apps/marketing/src/i18n/fr.ts index 8fd918efc815b..3d76747d6c833 100644 --- a/apps/marketing/src/i18n/fr.ts +++ b/apps/marketing/src/i18n/fr.ts @@ -27,6 +27,8 @@ export const fr: Messages = { downloadForMac: "Télécharger pour macOS", downloadForWindows: "Télécharger pour Windows", downloadForLinux: "Télécharger pour Linux", + downloadForIos: "Télécharger pour iOS", + downloadForAndroid: "Télécharger pour Android", sourceLink: "Volez notre code (légalement)", alsoOnPhone: "Aussi sur votre téléphone :", screenshotAlt: diff --git a/apps/marketing/src/i18n/ja.ts b/apps/marketing/src/i18n/ja.ts index 55117b609ac08..9f9fdba51129d 100644 --- a/apps/marketing/src/i18n/ja.ts +++ b/apps/marketing/src/i18n/ja.ts @@ -28,6 +28,8 @@ export const ja: Messages = { downloadForMac: "macOS 版をダウンロード", downloadForWindows: "Windows 版をダウンロード", downloadForLinux: "Linux 版をダウンロード", + downloadForIos: "iOS 版をダウンロード", + downloadForAndroid: "Android 版をダウンロード", sourceLink: "コードを盗もう(合法的に)", alsoOnPhone: "スマホでも使えます:", screenshotAlt: diff --git a/apps/marketing/src/i18n/ko.ts b/apps/marketing/src/i18n/ko.ts index f2c09c0af06ac..e2f4d4e6f7c7a 100644 --- a/apps/marketing/src/i18n/ko.ts +++ b/apps/marketing/src/i18n/ko.ts @@ -27,6 +27,8 @@ export const ko: Messages = { downloadForMac: "macOS용 다운로드", downloadForWindows: "Windows용 다운로드", downloadForLinux: "Linux용 다운로드", + downloadForIos: "iOS용 다운로드", + downloadForAndroid: "Android용 다운로드", sourceLink: "코드 훔쳐 가기(합법적으로)", alsoOnPhone: "휴대폰에서도:", screenshotAlt: "하나의 스레드에서 Claude, Codex, Muse, Gemini 서브에이전트를 실행 중인 T3 Code", diff --git a/apps/marketing/src/i18n/pt-BR.ts b/apps/marketing/src/i18n/pt-BR.ts index 33841e839c527..1947884d33516 100644 --- a/apps/marketing/src/i18n/pt-BR.ts +++ b/apps/marketing/src/i18n/pt-BR.ts @@ -27,6 +27,8 @@ export const ptBR: Messages = { downloadForMac: "Baixar para macOS", downloadForWindows: "Baixar para Windows", downloadForLinux: "Baixar para Linux", + downloadForIos: "Baixar para iOS", + downloadForAndroid: "Baixar para Android", sourceLink: "Roube nosso código (legalmente)", alsoOnPhone: "Também no seu celular:", screenshotAlt: diff --git a/apps/marketing/src/i18n/ru.ts b/apps/marketing/src/i18n/ru.ts index 8fdeefdb9fadd..e0d357813bc6e 100644 --- a/apps/marketing/src/i18n/ru.ts +++ b/apps/marketing/src/i18n/ru.ts @@ -27,6 +27,8 @@ export const ru: Messages = { downloadForMac: "Скачать для macOS", downloadForWindows: "Скачать для Windows", downloadForLinux: "Скачать для Linux", + downloadForIos: "Скачать для iOS", + downloadForAndroid: "Скачать для Android", sourceLink: "Украдите наш код (легально)", alsoOnPhone: "Есть и для телефона:", screenshotAlt: "T3 Code запускает субагентов Claude, Codex, Muse и Gemini в одном треде", diff --git a/apps/marketing/src/i18n/zh.ts b/apps/marketing/src/i18n/zh.ts index bae711530051a..1c554b193c401 100644 --- a/apps/marketing/src/i18n/zh.ts +++ b/apps/marketing/src/i18n/zh.ts @@ -27,6 +27,8 @@ export const zh: Messages = { downloadForMac: "下载 macOS 版", downloadForWindows: "下载 Windows 版", downloadForLinux: "下载 Linux 版", + downloadForIos: "下载 iOS 版", + downloadForAndroid: "下载 Android 版", sourceLink: "来偷我们的代码(合法的)", alsoOnPhone: "手机上也能用:", screenshotAlt: "T3 Code 在同一个线程中运行 Claude、Codex、Muse 和 Gemini 子智能体", diff --git a/apps/marketing/src/layouts/Layout.astro b/apps/marketing/src/layouts/Layout.astro index 0f0a864101743..fa7fa30bc0b18 100644 --- a/apps/marketing/src/layouts/Layout.astro +++ b/apps/marketing/src/layouts/Layout.astro @@ -7,8 +7,8 @@ import { ANDROID_PLAY_STORE_URL, GITHUB_REPOSITORY_URL, IOS_APP_STORE_URL, - MARKETING_STATS, } from "../lib/site"; +import { MARKETING_STATS } from "../lib/stats"; import { type JsonLd, jsonLdGraph, organizationJsonLd, websiteJsonLd } from "../lib/seo"; import { messagesFor } from "../i18n"; import { diff --git a/apps/marketing/src/lib/platform.test.ts b/apps/marketing/src/lib/platform.test.ts new file mode 100644 index 0000000000000..d35abb2ac63aa --- /dev/null +++ b/apps/marketing/src/lib/platform.test.ts @@ -0,0 +1,45 @@ +import { expect, it } from "vite-plus/test"; + +import { detectPlatform, getMobileStoreUrl } from "./platform"; +import { ANDROID_PLAY_STORE_URL, IOS_APP_STORE_URL } from "./site"; + +it.each([ + { + // Android user agents also contain "Linux". + name: "Android", + userAgent: + "Mozilla/5.0 (Linux; Android 15; Pixel 9) AppleWebKit/537.36 Chrome/140.0.0.0 Mobile Safari/537.36", + maxTouchPoints: 5, + os: "android", + storeUrl: ANDROID_PLAY_STORE_URL, + }, + { + name: "iPhone", + userAgent: + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_6 like Mac OS X) AppleWebKit/605.1.15 Version/18.6 Mobile/15E148 Safari/604.1", + maxTouchPoints: 5, + os: "ios", + storeUrl: IOS_APP_STORE_URL, + }, + { + // iPad Safari sends a desktop Mac user agent by default. + name: "iPad with a desktop user agent", + userAgent: + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15) AppleWebKit/605.1.15 Version/18.6 Safari/605.1.15", + maxTouchPoints: 5, + os: "ios", + storeUrl: IOS_APP_STORE_URL, + }, + { + name: "macOS", + userAgent: + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 Version/18.6 Safari/605.1.15", + maxTouchPoints: 0, + os: "mac", + storeUrl: null, + }, +])("$name visitors get the $os download", ({ userAgent, maxTouchPoints, os, storeUrl }) => { + const platform = detectPlatform(userAgent, maxTouchPoints); + expect(platform?.os).toBe(os); + expect(platform && getMobileStoreUrl(platform)).toBe(storeUrl); +}); diff --git a/apps/marketing/src/lib/platform.ts b/apps/marketing/src/lib/platform.ts new file mode 100644 index 0000000000000..be17e789a6bb2 --- /dev/null +++ b/apps/marketing/src/lib/platform.ts @@ -0,0 +1,22 @@ +import { ANDROID_PLAY_STORE_URL, IOS_APP_STORE_URL } from "./site"; + +export type Platform = { os: "mac" | "win" | "linux" | "ios" | "android" }; + +export function detectPlatform(userAgent: string, maxTouchPoints: number): Platform | null { + // Mobile checks come first: Android user agents contain "Linux" and iOS + // user agents contain "Mac OS X". + if (/Android/i.test(userAgent)) return { os: "android" }; + if (/iPhone|iPad|iPod/i.test(userAgent)) return { os: "ios" }; + if (/Win/i.test(userAgent)) return { os: "win" }; + // iPadOS Safari sends a Mac user agent by default; only the touch screen + // tells it apart from a Mac. + if (/Mac/i.test(userAgent)) return { os: maxTouchPoints > 1 ? "ios" : "mac" }; + if (/Linux/i.test(userAgent)) return { os: "linux" }; + return null; +} + +export function getMobileStoreUrl(platform: Platform): string | null { + if (platform.os === "ios") return IOS_APP_STORE_URL; + if (platform.os === "android") return ANDROID_PLAY_STORE_URL; + return null; +} diff --git a/apps/marketing/src/lib/site.ts b/apps/marketing/src/lib/site.ts index c4cc8aca59f22..319f6dd800bcb 100644 --- a/apps/marketing/src/lib/site.ts +++ b/apps/marketing/src/lib/site.ts @@ -5,26 +5,3 @@ export const IOS_APP_STORE_URL = export const ANDROID_PLAY_STORE_URL = "https://play.google.com/store/apps/details?id=com.t3tools.t3code"; - -// Runs at build time, so each deploy picks up the current count. -async function fetchGithubStars(): Promise { - const token = process.env.GITHUB_TOKEN; - try { - const response = await fetch("https://api.github.com/repos/pingdotgg/t3code", { - headers: token ? { Authorization: `Bearer ${token}` } : {}, - }); - if (!response.ok) throw new Error(`GitHub API responded ${response.status}`); - const { stargazers_count: stars } = (await response.json()) as { stargazers_count: number }; - return stars < 1000 ? String(stars) : `${Math.floor(stars / 1000)}k+`; - } catch (error) { - // A deploy must not ship a made-up count; offline dev should still render. - if (import.meta.env.PROD) throw new Error("Could not fetch GitHub stars", { cause: error }); - console.warn("Could not fetch GitHub stars", error); - return "?"; - } -} - -export const MARKETING_STATS = { - githubStars: await fetchGithubStars(), - users: "500,000", -} as const; diff --git a/apps/marketing/src/lib/stats.ts b/apps/marketing/src/lib/stats.ts new file mode 100644 index 0000000000000..d291f420b06d1 --- /dev/null +++ b/apps/marketing/src/lib/stats.ts @@ -0,0 +1,22 @@ +// Runs at build time, so each deploy picks up the current count. +async function fetchGithubStars(): Promise { + const token = process.env.GITHUB_TOKEN; + try { + const response = await fetch("https://api.github.com/repos/pingdotgg/t3code", { + headers: token ? { Authorization: `Bearer ${token}` } : {}, + }); + if (!response.ok) throw new Error(`GitHub API responded ${response.status}`); + const { stargazers_count: stars } = (await response.json()) as { stargazers_count: number }; + return stars < 1000 ? String(stars) : `${Math.floor(stars / 1000)}k+`; + } catch (error) { + // A deploy must not ship a made-up count; offline dev should still render. + if (import.meta.env.PROD) throw new Error("Could not fetch GitHub stars", { cause: error }); + console.warn("Could not fetch GitHub stars", error); + return "?"; + } +} + +export const MARKETING_STATS = { + githubStars: await fetchGithubStars(), + users: "500,000", +} as const; diff --git a/apps/marketing/src/pages/95.astro b/apps/marketing/src/pages/95.astro index d6ab25cf867d7..3a9d67f509df9 100644 --- a/apps/marketing/src/pages/95.astro +++ b/apps/marketing/src/pages/95.astro @@ -1,7 +1,8 @@ --- import RetroIcon from "../components/RetroIcon.astro"; import RetroBox from "../components/RetroBox.astro"; -import { GITHUB_REPOSITORY_URL, MARKETING_STATS } from "../lib/site"; +import { GITHUB_REPOSITORY_URL } from "../lib/site"; +import { MARKETING_STATS } from "../lib/stats"; import "../styles/retro.css"; const agents = [ diff --git a/apps/marketing/src/pages/[...locale]/index.astro b/apps/marketing/src/pages/[...locale]/index.astro index 5ae3842180587..3d8d44568d448 100644 --- a/apps/marketing/src/pages/[...locale]/index.astro +++ b/apps/marketing/src/pages/[...locale]/index.astro @@ -6,8 +6,8 @@ import { ANDROID_PLAY_STORE_URL, GITHUB_REPOSITORY_URL, IOS_APP_STORE_URL, - MARKETING_STATS, } from "../../lib/site"; +import { MARKETING_STATS } from "../../lib/stats"; import { tweets } from "../../lib/tweets"; import { softwareJsonLd } from "../../lib/seo"; import { messagesFor } from "../../i18n"; @@ -83,6 +83,8 @@ const mobileEndorsementRows = [ data-label-mac={t.downloadForMac} data-label-win={t.downloadForWindows} data-label-linux={t.downloadForLinux} + data-label-ios={t.downloadForIos} + data-label-android={t.downloadForAndroid} > @@ -413,16 +415,7 @@ const mobileEndorsementRows = [