diff --git a/apps/mobile/src/Stack.tsx b/apps/mobile/src/Stack.tsx index 9b8d9506a63c..8f475a27d1d2 100644 --- a/apps/mobile/src/Stack.tsx +++ b/apps/mobile/src/Stack.tsx @@ -96,6 +96,7 @@ import { SettingsAuthRouteScreen } from "./features/settings/SettingsAuthRouteSc import { SettingsEnvironmentDetailRouteScreen } from "./features/settings/SettingsEnvironmentDetailRouteScreen"; import { SettingsEnvironmentsRouteScreen } from "./features/settings/SettingsEnvironmentsRouteScreen"; import { SettingsFollowUpRouteScreen } from "./features/settings/SettingsFollowUpRouteScreen"; +import { SettingsCloudRouteScreen } from "./features/settings/SettingsCloudRouteScreen"; import { SettingsLanguageRouteScreen } from "./features/settings/SettingsLanguageRouteScreen"; import { SettingsEnvironmentMaintenanceRouteScreen, @@ -290,6 +291,11 @@ const SETTINGS_SCREENS = { linking: "settings/follow-ups", options: settingsPage("Chat"), }), + SettingsCloud: createNativeStackScreen({ + screen: SettingsCloudRouteScreen, + linking: "settings/cloud", + options: settingsPage("Cloud"), + }), SettingsScheduledTasks: createNativeStackScreen({ screen: SettingsScheduledTasksRouteScreen, linking: "settings/scheduled-tasks", diff --git a/apps/mobile/src/components/BoatIcon.tsx b/apps/mobile/src/components/BoatIcon.tsx new file mode 100644 index 000000000000..c5a65e050890 --- /dev/null +++ b/apps/mobile/src/components/BoatIcon.tsx @@ -0,0 +1,22 @@ +import Svg, { Path } from "react-native-svg"; +import { withUniwind } from "uniwind"; + +const ThemedPath = withUniwind(Path); + +/** The boat.dev mark. */ +export function BoatIcon(props: { readonly size: number; readonly colorClassName: string }) { + return ( + + + + ); +} diff --git a/apps/mobile/src/connection/onboarding.ts b/apps/mobile/src/connection/onboarding.ts index 54963759ed18..59623470905e 100644 --- a/apps/mobile/src/connection/onboarding.ts +++ b/apps/mobile/src/connection/onboarding.ts @@ -28,6 +28,25 @@ export const connectPairingUrl = createRuntimeCommand(connectionAtomRuntime, { ), }); +/** Pairs with a server from its address and a one-time code, such as a Boat cloud VM. */ +export const connectPairingCode = createRuntimeCommand(connectionAtomRuntime, { + label: "mobile:connection:connect-pairing-code", + scheduler: onboardingScheduler, + concurrency: { + mode: "singleFlight", + key: (input: { readonly host: string; readonly pairingCode: string }) => + JSON.stringify([input.host, input.pairingCode]), + }, + execute: (input: { + readonly host: string; + readonly pairingCode: string; + readonly expectedEnvironmentId: EnvironmentId; + }) => + ConnectionOnboarding.ConnectionOnboarding.pipe( + Effect.flatMap((onboarding) => onboarding.registerPairing(input)), + ), +}); + export const updateBearerConnection = createRuntimeCommand(connectionAtomRuntime, { label: "mobile:connection:update-bearer", scheduler: onboardingScheduler, diff --git a/apps/mobile/src/features/boatCloud/BoatCloudSteps.tsx b/apps/mobile/src/features/boatCloud/BoatCloudSteps.tsx new file mode 100644 index 000000000000..ee080939d0b2 --- /dev/null +++ b/apps/mobile/src/features/boatCloud/BoatCloudSteps.tsx @@ -0,0 +1,53 @@ +import type { BoatCloudStartStep, BoatCloudStepProgress } from "@t3tools/contracts"; +import { View } from "react-native"; + +import { SymbolView, type AppSymbolName } from "../../components/ui/AppSymbol"; +import { ListRowLayout } from "../../components/ui/ListRowLayout"; +import { cn } from "../../lib/cn"; + +export type StepState = BoatCloudStepProgress["state"]; + +// Static glyphs: a VM takes seconds to minutes, and a spinner would repaint the whole time. +const STEP_ICONS: Record = { + done: { name: "checkmark.circle.fill", tint: "accent-success-foreground" }, + failed: { name: "xmark.circle.fill", tint: "accent-danger-foreground" }, + active: { name: "circle.inset.filled", tint: "accent-primary-text" }, + pending: { name: "circle.dashed", tint: "accent-icon-muted" }, +}; + +export function StepGlyph(props: { readonly state: StepState }) { + const icon = STEP_ICONS[props.state]; + return ; +} + +const START_STEP_LABELS: Record = { + machine: "Find a VM", + server: "Start T3 Code", + repository: "Clone or update the repository", + connect: "Connect", + project: "Open the project", + thread: "Move the draft", +}; + +/** Each step of starting on Boat cloud, as the holder and this app report it. */ +export function BoatCloudStartSteps(props: { + readonly steps: ReadonlyArray; +}) { + return ( + + {props.steps.map((entry, index) => ( + 0 && "border-t border-border-subtle")} + accessibilityLabel={`${START_STEP_LABELS[entry.step]}, ${entry.state}`} + > + } + label={START_STEP_LABELS[entry.step]} + description={entry.detail ?? undefined} + /> + + ))} + + ); +} diff --git a/apps/mobile/src/features/boatCloud/boatCloud.ts b/apps/mobile/src/features/boatCloud/boatCloud.ts new file mode 100644 index 000000000000..8e28217a7715 --- /dev/null +++ b/apps/mobile/src/features/boatCloud/boatCloud.ts @@ -0,0 +1,355 @@ +import { useAtomValue } from "@effect/atom-react"; +import { + connectionCatalogDisplayUrl, + type EnvironmentConnectionPhase, +} from "@t3tools/client-runtime/connection"; +import { + isAtomCommandInterrupted, + runAtomCommand, + squashAtomCommandFailure, +} from "@t3tools/client-runtime/state/runtime"; +import type { EnvironmentProject } from "@t3tools/client-runtime/state/shell"; +import { + BOAT_CLOUD_START_STEPS, + type BoatCloudSession, + type BoatCloudStartStep, + type BoatCloudStatus, + type BoatCloudStepProgress, + type EnvironmentId, + isBoatCloudHttpBaseUrl, +} from "@t3tools/contracts"; +import { boatCloudProviderDrivers } from "@t3tools/shared/boatCloudVm"; +import * as Effect from "effect/Effect"; +import { AsyncResult, Atom } from "effect/reactivity"; +import { useEffect } from "react"; + +import * as Dialog from "../../components/ui/Dialog"; +import { connectPairingCode } from "../../connection/onboarding"; +import { uuidv4 } from "../../lib/uuid"; +import { appAtomRegistry } from "../../state/atom-registry"; +import { connectedEnvironmentIdsAtom, environmentPresentations } from "../../state/presentation"; +import { environmentProjects } from "../../state/projects"; +import { serverEnvironment } from "../../state/server"; +import { + ensurePhoneBoatCloudLoaded, + phoneBoatCloudAtom, + startOnPhone, + wakeOnPhone, +} from "./phoneBoatCloud"; + +/** + * Boat cloud VMs are ordinary environments. A holder of the Boat key hands out + * a VM, and this app pairs with it like any other machine. The holder is a + * connected machine when one has the key, else this phone. + */ + +const PROJECT_APPEAR_TIMEOUT_MS = 60_000; + +interface BoatCloudHolder { + readonly environmentId: EnvironmentId; + readonly status: BoatCloudStatus; +} + +/** Who starts a VM: a connected machine with the key, or this phone with its own. */ +type BoatCloudStarter = + | { readonly kind: "machine"; readonly environmentId: EnvironmentId } + | { readonly kind: "phone" }; + +/** Paired Boat VMs, by environment, with the URL each one is reached at. */ +export const boatCloudMachinesAtom = Atom.make((get) => { + const machines = new Map(); + for (const [environmentId, presentation] of get(environmentPresentations.presentationsAtom)) { + const url = connectionCatalogDisplayUrl(presentation.entry); + if (url !== null && isBoatCloudHttpBaseUrl(url)) machines.set(environmentId, url); + } + return machines; +}).pipe(Atom.withLabel("mobile-boat-cloud:machines")); + +/** Connected machines that answer for Boat cloud, without the VMs, which answer too. */ +export const boatCloudHoldersAtom = Atom.make((get): ReadonlyArray => { + const machines = get(boatCloudMachinesAtom); + return get(connectedEnvironmentIdsAtom).flatMap((environmentId) => { + if (machines.has(environmentId)) return []; + const status = AsyncResult.getOrElse( + get(serverEnvironment.boatCloudStatus({ environmentId, input: {} })), + () => null, + ); + return status ? [{ environmentId, status }] : []; + }); +}).pipe(Atom.withLabel("mobile-boat-cloud:holders")); + +function boatCloudReady(status: BoatCloudStatus | null | undefined) { + return status?.connected === true && status.setup.status === "ready"; +} + +/** + * The holder that should start a VM: the draft's own machine when it can, then + * any ready machine, which keeps session backups, then this phone. + */ +function boatCloudStarterFor( + holders: ReadonlyArray, + phone: BoatCloudStatus, + preferredEnvironmentId: EnvironmentId | null, +): BoatCloudStarter | null { + const ready = holders.filter((holder) => boatCloudReady(holder.status)); + const machine = + ready.find((holder) => holder.environmentId === preferredEnvironmentId) ?? ready[0]; + if (machine) return { kind: "machine", environmentId: machine.environmentId }; + return boatCloudReady(phone) ? { kind: "phone" } : null; +} + +export function useBoatCloudStarter(preferredEnvironmentId: EnvironmentId | null) { + const holders = useAtomValue(boatCloudHoldersAtom); + const phone = useAtomValue(phoneBoatCloudAtom); + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- reads this phone's Boat key from the keychain once per launch + useEffect(() => { + void ensurePhoneBoatCloudLoaded(); + }, []); + return boatCloudStarterFor(holders, phone, preferredEnvironmentId); +} + +/** The providers a new VM from this starter has sign-ins for, or null when unknown. */ +export function useBoatCloudProviderDrivers(preferredEnvironmentId: EnvironmentId | null) { + const holders = useAtomValue(boatCloudHoldersAtom); + const phone = useAtomValue(phoneBoatCloudAtom); + const starter = boatCloudStarterFor(holders, phone, preferredEnvironmentId); + const status = + starter?.kind === "machine" + ? holders.find((holder) => holder.environmentId === starter.environmentId)?.status + : starter?.kind === "phone" + ? phone + : null; + const chosen = status?.environments.find( + (environment) => environment.name === status.environment, + ); + return chosen?.agentCredentials ? boatCloudProviderDrivers(chosen.agentCredentials) : null; +} + +function errorMessage(error: unknown) { + return error instanceof Error ? error.message : "An error occurred."; +} + +/** Pairs this app with the VM unless it is already connected. Throws what failed. */ +async function attach( + session: Pick, +) { + if (appAtomRegistry.get(connectedEnvironmentIdsAtom).includes(session.environmentId)) return; + const paired = await runAtomCommand( + appAtomRegistry, + connectPairingCode, + { + host: session.httpBaseUrl, + pairingCode: session.pairingCode, + expectedEnvironmentId: session.environmentId, + }, + { label: connectPairingCode.label, reportFailure: false, reportDefect: true }, + ); + if (paired._tag === "Failure") throw squashAtomCommandFailure(paired); +} + +function waitForProject( + find: (projects: ReadonlyArray) => EnvironmentProject | undefined, +): Promise { + const atom = environmentProjects.projectsAtom; + const now = find(appAtomRegistry.get(atom)); + if (now) return Promise.resolve(now); + return new Promise((resolve) => { + const finish = (project: EnvironmentProject | null) => { + clearTimeout(timeout); + unsubscribe(); + resolve(project); + }; + const timeout = setTimeout(() => finish(null), PROJECT_APPEAR_TIMEOUT_MS); + const unsubscribe = appAtomRegistry.subscribe(atom, (projects) => { + const project = find(projects); + if (project) finish(project); + }); + }); +} + +const cancelled = new Error("Boat cloud start cancelled"); + +const HOLDER_STEPS = new Set(["machine", "server", "repository"]); + +const pendingBoatCloudSteps = (): ReadonlyArray => + BOAT_CLOUD_START_STEPS.map((step) => ({ step, state: "pending", detail: null })); + +/** Starts the VM through a connected machine, following its steps in that machine's status. */ +async function startOnMachine( + environmentId: EnvironmentId, + repositoryUrl: string, + baseBranch: string | null, + onHolderSteps: (steps: ReadonlyArray) => void, +): Promise { + const requestId = uuidv4(); + const statusAtom = serverEnvironment.boatCloudStatus({ environmentId, input: {} }); + const unsubscribe = appAtomRegistry.subscribe(statusAtom, (result) => { + const start = AsyncResult.getOrElse(result, () => null)?.starts.find( + (candidate) => candidate.requestId === requestId, + ); + if (start) onHolderSteps(start.steps.filter((step) => HOLDER_STEPS.has(step.step))); + }); + try { + const acquired = await runAtomCommand( + appAtomRegistry, + serverEnvironment.acquireBoatCloud, + { + environmentId, + input: { repositoryUrl, ...(baseBranch ? { baseBranch } : {}), requestId }, + }, + { label: serverEnvironment.acquireBoatCloud.label, reportFailure: false, reportDefect: true }, + ); + if (acquired._tag === "Failure") { + throw isAtomCommandInterrupted(acquired) ? cancelled : squashAtomCommandFailure(acquired); + } + return acquired.value; + } finally { + unsubscribe(); + } +} + +/** + * Gets a VM with the repository checked out and returns its project there, so + * a draft can move onto it, reporting every step on the way. The caller marks + * the last step, moving the thread, done. Null after telling the user what failed. + */ +export async function prepareBoatCloudProject(input: { + readonly starter: BoatCloudStarter; + readonly repositoryUrl: string; + readonly baseBranch: string | null; + readonly onProgress: (steps: ReadonlyArray) => void; +}): Promise { + let steps = pendingBoatCloudSteps(); + const set = (step: BoatCloudStartStep, patch: Partial) => { + steps = steps.map((entry) => (entry.step === step ? { ...entry, ...patch } : entry)); + input.onProgress(steps); + }; + const setHolderSteps = (holderSteps: ReadonlyArray) => { + for (const entry of holderSteps) set(entry.step, entry); + }; + let current: BoatCloudStartStep = "machine"; + try { + const session = + input.starter.kind === "machine" + ? await startOnMachine( + input.starter.environmentId, + input.repositoryUrl, + input.baseBranch, + setHolderSteps, + ) + : await startOnPhone({ + repositoryUrl: input.repositoryUrl, + baseBranch: input.baseBranch, + report: (step, state, detail) => Effect.sync(() => set(step, { state, detail })), + }); + for (const step of HOLDER_STEPS) set(step, { state: "done" }); + current = "connect"; + set("connect", { state: "active" }); + await attach(session); + set("connect", { state: "done" }); + current = "project"; + set("project", { state: "active" }); + const project = await waitForProject((projects) => + projects.find( + (candidate) => + candidate.environmentId === session.environmentId && + candidate.workspaceRoot === session.workspaceRoot, + ), + ); + if (!project) throw new Error("The Boat cloud machine did not open the project."); + set("project", { state: "done" }); + set("thread", { state: "active" }); + return project; + } catch (error) { + const failed = steps.find((entry) => entry.state === "active")?.step ?? current; + set(failed, { state: "failed" }); + if (error !== cancelled) Dialog.alert("Could not start on Boat cloud", errorMessage(error)); + return null; + } +} + +const wakesInFlight = new Map>(); + +/** Whether this app is turning a VM back on, so its thread shows "Waking" rather than "Sleeping". */ +const boatCloudWakingAtom = Atom.family((_environmentId: EnvironmentId | null) => + Atom.make(false).pipe(Atom.keepAlive), +); + +/** + * Turns a stopped VM back on. A machine that owns it can also restore it from + * its backup and hands out a new pairing; the phone reconnects to the same server. + */ +function wakeBoatCloudMachine(environmentId: EnvironmentId, httpBaseUrl: string): Promise { + const inFlight = wakesInFlight.get(environmentId); + if (inFlight) return inFlight; + const waking = boatCloudWakingAtom(environmentId); + appAtomRegistry.set(waking, true); + const wake = (async () => { + await ensurePhoneBoatCloudLoaded(); + const holders = appAtomRegistry.get(boatCloudHoldersAtom); + const phoneHasKey = appAtomRegistry.get(phoneBoatCloudAtom).connected; + // The machine that started the VM can restore it; any other machine with the key can wake it. + const owner = + holders.find((holder) => + holder.status.machines.some((machine) => machine.environmentId === environmentId), + ) ?? (phoneHasKey ? undefined : holders.find((holder) => holder.status.connected)); + if (owner) { + const woken = await runAtomCommand( + appAtomRegistry, + serverEnvironment.wakeBoatCloud, + { environmentId: owner.environmentId, input: { environmentId, httpBaseUrl } }, + { label: serverEnvironment.wakeBoatCloud.label, reportFailure: false, reportDefect: true }, + ); + if (woken._tag === "Failure") { + if (!isAtomCommandInterrupted(woken)) { + Dialog.alert( + "Could not wake the Boat cloud machine", + errorMessage(squashAtomCommandFailure(woken)), + ); + } + return; + } + // A restored VM has a new address, so pair again rather than wait for the old one. + await attach(woken.value).catch((error: unknown) => + Dialog.alert("Could not reconnect to Boat cloud", errorMessage(error)), + ); + return; + } + if (!phoneHasKey) return; + await wakeOnPhone(httpBaseUrl).catch((error: unknown) => + Dialog.alert("Could not wake the Boat cloud machine", errorMessage(error)), + ); + })().finally(() => { + wakesInFlight.delete(environmentId); + appAtomRegistry.set(waking, false); + }); + wakesInFlight.set(environmentId, wake); + return wake; +} + +export interface BoatCloudSleep { + readonly state: "sleeping" | "waking"; + readonly wake: () => void; +} + +/** + * A Boat VM that is not reachable is asleep: it stopped itself after its threads + * went quiet. A message sent meanwhile waits in the queue and wakes it, so its + * thread reads "Sleeping" rather than disconnected. + */ +export function useBoatCloudSleep( + environmentId: EnvironmentId | null, + phase: EnvironmentConnectionPhase | null, +): BoatCloudSleep | null { + const machines = useAtomValue(boatCloudMachinesAtom); + const waking = useAtomValue(boatCloudWakingAtom(environmentId)); + const httpBaseUrl = environmentId === null ? null : (machines.get(environmentId) ?? null); + if (environmentId === null || httpBaseUrl === null || phase === null || phase === "connected") { + return null; + } + // Retries cycle the phase, so only this app's own wake reads as waking. + return { + state: waking ? "waking" : "sleeping", + wake: () => void wakeBoatCloudMachine(environmentId, httpBaseUrl), + }; +} diff --git a/apps/mobile/src/features/boatCloud/phoneBoatCloud.ts b/apps/mobile/src/features/boatCloud/phoneBoatCloud.ts new file mode 100644 index 000000000000..f55075327b78 --- /dev/null +++ b/apps/mobile/src/features/boatCloud/phoneBoatCloud.ts @@ -0,0 +1,298 @@ +import { + BOAT_CLOUD_SNAPSHOT_NAME, + type BoatCloudEnvironmentAccess, + type BoatCloudStatus, +} from "@t3tools/contracts"; +import { BoatApiError, cloudEnvironments, makeBoatApi } from "@t3tools/shared/boatCloudApi"; +import { + type ReportStep, + buildSnapshot, + exec, + findSandboxByHttpBaseUrl, + startMachine, + wakeMachine, +} from "@t3tools/shared/boatCloudStart"; +import { + MACHINE_LABEL_FILTER, + VM_T3_PORT, + machineLabelOf, + machineStateOf, + npmInstallServerScript, + parseRepository, +} from "@t3tools/shared/boatCloudVm"; +import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; +import * as Result from "effect/Result"; +import { HttpClient } from "effect/http"; +import { Atom } from "effect/reactivity"; + +import * as Runtime from "../../lib/runtime"; +import { MobileSecureStorage } from "../../persistence/mobile-secure-storage"; +import { appAtomRegistry } from "../../state/atom-registry"; + +/** + * The phone as a Boat cloud holder: with the key in its own keychain it starts + * and wakes VMs without any machine connected. A machine holding the key does + * more (session backups, an idle sweep), so a connected one is used first. + */ + +const API_KEY_STORAGE_KEY = "t3code.boat-cloud.api-key"; +/** The Boat environment new VMs start on, by name, as a machine holder keeps it. */ +const ENVIRONMENT_STORAGE_KEY = "t3code.boat-cloud.environment"; + +const DISCONNECTED: BoatCloudStatus = { + connected: false, + account: null, + setup: { status: "idle", step: null, error: null }, + machines: [], + starts: [], + environment: null, + environments: [], + environmentsError: null, +}; + +export const phoneBoatCloudAtom = Atom.make(DISCONNECTED).pipe( + Atom.keepAlive, + Atom.withLabel("mobile-boat-cloud:phone"), +); + +const setStatus = (update: (status: BoatCloudStatus) => BoatCloudStatus) => + appAtomRegistry.update(phoneBoatCloudAtom, update); + +const notConnected = new BoatApiError({ + status: 0, + code: "not_connected", + message: "Connect Boat in Settings, Cloud first.", + retryable: false, +}); + +const phoneApi = Effect.gen(function* () { + const key = yield* MobileSecureStorage.use((storage) => storage.getItem(API_KEY_STORAGE_KEY)); + if (key === null) return yield* notConnected; + return makeBoatApi(yield* HttpClient.HttpClient, key); +}); + +const run = (effect: Effect.Effect) => + Runtime.runtime.runPromise(effect); + +const errorMessage = (error: unknown) => + error instanceof Error ? error.message : "Boat could not be reached."; + +// Bumped once the key is stored or removed, so a refresh that read the old key drops its result. +let keyGeneration = 0; + +/** Reads the key, the snapshot and the VMs from Boat. Leaves a setup in progress alone. */ +export function refreshPhoneBoatCloud(): Promise { + const generation = keyGeneration; + const stale = () => generation !== keyGeneration; + return run( + Effect.gen(function* () { + const api = yield* phoneApi; + const [account, snapshot, sandboxes, environments, profiles, stored] = yield* Effect.all( + [ + api.me(), + api.getSnapshotStatus(BOAT_CLOUD_SNAPSHOT_NAME), + api.listSandboxes(MACHINE_LABEL_FILTER), + api.listEnvironments().pipe(Effect.result), + // Without it the sign-ins are unknown, and every provider stays offered. + api.listCredentialProfiles().pipe(Effect.option), + MobileSecureStorage.use((storage) => storage.getItem(ENVIRONMENT_STORAGE_KEY)), + ], + { concurrency: "unbounded" }, + ); + if (stale()) return; + const listed = Result.isSuccess(environments) + ? cloudEnvironments(environments.success, Option.getOrNull(profiles)) + : []; + // With none chosen yet, Boat's default becomes the choice, so later VMs stay on it. + const environment = stored ?? listed.find((candidate) => candidate.isDefault)?.name ?? null; + if (stored === null && environment !== null) { + yield* MobileSecureStorage.use((storage) => + storage.setItem(ENVIRONMENT_STORAGE_KEY, environment), + ); + if (stale()) return; + } + setStatus((status) => ({ + ...status, + connected: true, + account, + setup: + status.setup.status === "running" + ? status.setup + : snapshot === "ready" + ? { status: "ready", step: null, error: null } + : status.setup.status === "failed" + ? status.setup + : { status: "idle", step: null, error: null }, + machines: sandboxes.map((sandbox) => ({ + sandboxId: sandbox.id, + environmentId: null, + label: machineLabelOf(sandbox.id), + httpBaseUrl: sandbox.subdomain + ? `https://${sandbox.subdomain}-${VM_T3_PORT}.on.boat.dev` + : null, + state: machineStateOf(sandbox.state), + repositories: [], + backedUpAt: null, + environment: sandbox.environment ?? null, + })), + environment, + environments: listed, + environmentsError: Result.isFailure(environments) ? environments.failure.message : null, + })); + }).pipe( + Effect.catchTags({ + BoatApiError: (error) => + Effect.sync(() => { + if (error.code === "not_connected" && !stale()) setStatus(() => DISCONNECTED); + }), + }), + ), + ).catch(() => undefined); +} + +let loaded: Promise | null = null; + +/** Loads the phone's Boat state once per launch; Settings, Cloud refreshes it on open. */ +export function ensurePhoneBoatCloudLoaded(): Promise { + loaded ??= refreshPhoneBoatCloud(); + return loaded; +} + +/** Checks the key with Boat, then keeps it in this phone's keychain. Null on success. */ +export async function connectPhoneBoatCloud(apiKey: string): Promise { + try { + await run( + Effect.gen(function* () { + yield* makeBoatApi(yield* HttpClient.HttpClient, apiKey).me(); + yield* MobileSecureStorage.use((storage) => storage.setItem(API_KEY_STORAGE_KEY, apiKey)); + }), + ); + } catch (error) { + return errorMessage(error); + } + keyGeneration += 1; + await refreshPhoneBoatCloud(); + return null; +} + +export async function disconnectPhoneBoatCloud(): Promise { + keyGeneration += 1; + await run( + MobileSecureStorage.use((storage) => + Effect.all([ + storage.removeItem(API_KEY_STORAGE_KEY), + storage.removeItem(ENVIRONMENT_STORAGE_KEY), + ]), + ), + ).catch(() => undefined); + setStatus(() => DISCONNECTED); +} + +/** Picks the environment for the phone's new VMs, and optionally changes what it passes. Null on success. */ +export async function configurePhoneBoatCloudEnvironment( + environmentId: string, + access?: BoatCloudEnvironmentAccess, +): Promise { + const chosen = appAtomRegistry + .get(phoneBoatCloudAtom) + .environments.find((environment) => environment.id === environmentId); + if (!chosen) return "Boat has no such environment."; + try { + await run( + Effect.gen(function* () { + const api = yield* phoneApi; + if (access) yield* api.updateEnvironment(environmentId, access); + yield* MobileSecureStorage.use((storage) => + storage.setItem(ENVIRONMENT_STORAGE_KEY, chosen.name), + ); + }), + ); + } catch (error) { + return errorMessage(error); + } + await refreshPhoneBoatCloud(); + return null; +} + +/** + * Builds the snapshot from the phone. The VM installs T3 Code from npm, since + * the phone has no server build to upload, and gets no skills. + */ +export async function runPhoneBoatCloudSetup(): Promise { + if (appAtomRegistry.get(phoneBoatCloudAtom).setup.status === "running") return; + setStatus((status) => ({ ...status, setup: { status: "running", step: null, error: null } })); + try { + await run( + Effect.gen(function* () { + const api = yield* phoneApi; + yield* buildSnapshot({ + api, + install: (sandboxId) => + exec(api, sandboxId, npmInstallServerScript("latest"), 600).pipe(Effect.asVoid), + setStep: (step) => + Effect.sync(() => + setStatus((status) => ({ + ...status, + setup: { status: "running", step, error: null }, + })), + ), + }); + }), + ); + setStatus((status) => ({ ...status, setup: { status: "ready", step: null, error: null } })); + } catch (error) { + setStatus((status) => ({ + ...status, + setup: { status: "failed", step: status.setup.step, error: errorMessage(error) }, + })); + } +} + +/** Gets a VM ready for the repository from the phone, reporting each step as it goes. */ +export function startOnPhone(input: { + readonly repositoryUrl: string; + readonly baseBranch: string | null; + readonly report: ReportStep; +}) { + const repository = parseRepository(input.repositoryUrl); + if (repository === null) { + return Promise.reject(new Error("Boat cloud cannot clone this project's remote.")); + } + return run( + Effect.gen(function* () { + const api = yield* phoneApi; + return yield* startMachine({ + api, + repository, + baseBranch: input.baseBranch, + environment: appAtomRegistry.get(phoneBoatCloudAtom).environment, + // The phone keeps no record of stopped VMs, so it resumes them only for load. + knownWorkspaceRoots: () => [], + report: input.report, + }); + }), + ).finally(() => void refreshPhoneBoatCloud()); +} + +/** + * Turns the VM behind a paired URL back on. Its identity lives on its disk, so + * the saved pairing reconnects without a new code. Fails when Boat deleted it. + */ +export function wakeOnPhone(httpBaseUrl: string) { + return run( + Effect.gen(function* () { + const api = yield* phoneApi; + const sandbox = yield* findSandboxByHttpBaseUrl(api, httpBaseUrl); + if (sandbox === null) { + return yield* new BoatApiError({ + status: 0, + code: "gone", + message: "Boat deleted this VM, and this phone keeps no copy of it.", + retryable: false, + }); + } + return yield* wakeMachine(api, sandbox.id); + }), + ); +} diff --git a/apps/mobile/src/features/connection/environmentSections.test.ts b/apps/mobile/src/features/connection/environmentSections.test.ts index eed95771b1a9..e7b6eff48de4 100644 --- a/apps/mobile/src/features/connection/environmentSections.test.ts +++ b/apps/mobile/src/features/connection/environmentSections.test.ts @@ -46,8 +46,14 @@ describe("mobile environment settings sections", () => { isRelayManaged: true, }); + const boatVm = connectedEnvironment({ + environmentId: "environment-boat", + isRelayManaged: false, + displayUrl: "https://box-node-1dca660d802d15b6-3773.on.boat.dev", + }); + const sections = splitEnvironmentSections({ - connectedEnvironments: [cloud, local], + connectedEnvironments: [cloud, local, boatVm], cloudEnvironments: [ cloudEnvironment("environment-cloud"), cloudEnvironment("environment-new"), diff --git a/apps/mobile/src/features/connection/environmentSections.ts b/apps/mobile/src/features/connection/environmentSections.ts index fc8fd958ae2e..996e59a1fe73 100644 --- a/apps/mobile/src/features/connection/environmentSections.ts +++ b/apps/mobile/src/features/connection/environmentSections.ts @@ -1,4 +1,4 @@ -import type { EnvironmentId } from "@t3tools/contracts"; +import { type EnvironmentId, isBoatCloudHttpBaseUrl } from "@t3tools/contracts"; import type { RelayClientEnvironmentRecord } from "@t3tools/contracts/relay"; import type { ConnectedEnvironmentSummary } from "../../state/remote-runtime-types"; @@ -30,8 +30,10 @@ export function splitEnvironmentSections(input: EnvironmentSectionsInput): Envir const savedEnvironmentIds = relayManagedEnvironmentIds(input.connectedEnvironments); return { + // Boat cloud VMs come and go with threads; Settings, Cloud lists them instead. localEnvironments: input.connectedEnvironments.filter( - (environment) => !environment.isRelayManaged, + (environment) => + !environment.isRelayManaged && !isBoatCloudHttpBaseUrl(environment.displayUrl), ), connectedCloudEnvironments: input.connectedEnvironments.filter( (environment) => environment.isRelayManaged, diff --git a/apps/mobile/src/features/keyboard/CommandPalette.tsx b/apps/mobile/src/features/keyboard/CommandPalette.tsx index 38b83e4c56ba..cf36195d085e 100644 --- a/apps/mobile/src/features/keyboard/CommandPalette.tsx +++ b/apps/mobile/src/features/keyboard/CommandPalette.tsx @@ -270,8 +270,14 @@ export function CommandPalette(props: { const serverConfigs = useServerConfigs(); const startTriage = useStartTriage(); const newTaskFlow = useNewTaskFlow(); - const draftEnvironmentId = - useActiveNewTask() === null ? null : (newTaskFlow.selectedProject?.environmentId ?? null); + const draftOpen = useActiveNewTask() !== null; + const draftEnvironmentId = draftOpen + ? (newTaskFlow.selectedProject?.environmentId ?? null) + : null; + // Offered on an open draft; its first send starts the VM. + const canStartBoatCloud = + draftOpen && newTaskFlow.boatCloud !== null && !newTaskFlow.boatCloud.selected; + const chooseBoatCloud = newTaskFlow.chooseBoatCloud; const triageEnvironmentId = pickTriageEnvironment({ // The route names the machine even before the thread shell loads. onScreen: activeThreadRef?.environmentId ?? draftEnvironmentId, @@ -347,6 +353,17 @@ export function CommandPalette(props: { run: () => openSettings(navigation, "SettingsArchive"), }, ]; + if (canStartBoatCloud) { + actions.unshift({ + key: "boatCloud", + kind: "action", + title: "Run on Boat cloud", + searchTerms: ["boat", "cloud", "vm", "remote", "new thread"], + run: () => { + chooseBoatCloud(); + }, + }); + } if (canTriage) { actions.push({ key: "triage", @@ -465,6 +482,7 @@ export function CommandPalette(props: { activeThread, activeThreadRef, environments.length, + canStartBoatCloud, canTriage, navigation, projects, @@ -472,6 +490,7 @@ export function CommandPalette(props: { savedConnectionsById, selectDetail, startTriage, + chooseBoatCloud, threads, triageEnvironmentId, ]); diff --git a/apps/mobile/src/features/settings/SettingsCloudRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsCloudRouteScreen.tsx new file mode 100644 index 000000000000..7bf4639bc5e7 --- /dev/null +++ b/apps/mobile/src/features/settings/SettingsCloudRouteScreen.tsx @@ -0,0 +1,349 @@ +import { useAtomValue } from "@effect/atom-react"; +import { + BOAT_CLOUD_REQUIRED_SCOPES, + BOAT_CLOUD_SETUP_STEPS, + type BoatCloudEnvironmentAccess, + type BoatCloudMachine, + type BoatCloudSetup, + type BoatCloudSetupStep, +} from "@t3tools/contracts"; +import { useEffect, useState } from "react"; +import { View } from "react-native"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; + +import { BoatIcon } from "../../components/BoatIcon"; +import { Button } from "../../components/ui/Button"; +import * as Dialog from "../../components/ui/Dialog"; +import { ListChoiceRow } from "../../components/ui/ListChoiceRow"; +import { ListRowLayout } from "../../components/ui/ListRowLayout"; +import { ScreenScrollView as ScrollView } from "../../components/ui/ScreenScrollView"; +import { SecretTextInput } from "../../components/ui/SecretTextInput"; +import { cn } from "../../lib/cn"; +import { serverEnvironment } from "../../state/server"; +import { useAtomCommand } from "../../state/use-atom-command"; +import { useEnvironments } from "../../state/environments"; +import { boatCloudHoldersAtom } from "../boatCloud/boatCloud"; +import { StepGlyph, type StepState } from "../boatCloud/BoatCloudSteps"; +import { + configurePhoneBoatCloudEnvironment, + connectPhoneBoatCloud, + disconnectPhoneBoatCloud, + phoneBoatCloudAtom, + refreshPhoneBoatCloud, + runPhoneBoatCloudSetup, +} from "../boatCloud/phoneBoatCloud"; +import { SettingsSection } from "./components/SettingsSection"; +import { SettingsScreen } from "./components/SettingsScreen"; +import { SettingsSwitchRow } from "./components/SettingsSwitchRow"; + +const STEP_LABELS: Record = { + "create-vm": "Create a VM", + "install-server": "Install T3 Code server", + "copy-skills": "Copy this machine's skills", + "save-snapshot": "Save the T3 Code snapshot", +}; + +const ACCESS_LABELS: Record = { + passGithub: "GitHub access", + passSandboxCredentials: "Boat credentials", + passSecrets: "Secrets", + passAgentsCredentials: "Agent sign-ins", +}; +const ACCESS_KEYS = Object.keys(ACCESS_LABELS) as ReadonlyArray; + +const MACHINE_STATE_LABELS: Record = { + running: "Running", + starting: "Starting", + stopped: "Stopped", + gone: "Saved copy", +}; + +function stepState(setup: BoatCloudSetup, index: number): StepState { + if (setup.status === "ready") return "done"; + if (setup.step === null || setup.status === "idle") return "pending"; + const current = BOAT_CLOUD_SETUP_STEPS.indexOf(setup.step); + if (index < current) return "done"; + if (index > current) return "pending"; + return setup.status === "failed" ? "failed" : "active"; +} + +function Separated(props: { readonly index: number; readonly children: React.ReactNode }) { + return ( + 0 && "border-t border-border-subtle")}>{props.children} + ); +} + +export function SettingsCloudRouteScreen() { + const insets = useSafeAreaInsets(); + const holders = useAtomValue(boatCloudHoldersAtom); + const phone = useAtomValue(phoneBoatCloudAtom); + const { environments } = useEnvironments(); + const [apiKey, setApiKey] = useState(""); + const [busy, setBusy] = useState(false); + const [keyError, setKeyError] = useState(null); + const connect = useAtomCommand(serverEnvironment.connectBoatCloud, "Connect Boat"); + const disconnect = useAtomCommand(serverEnvironment.disconnectBoatCloud, "Disconnect Boat"); + const runSetup = useAtomCommand(serverEnvironment.runBoatCloudSetup, "Set up Boat cloud"); + const configureEnvironment = useAtomCommand( + serverEnvironment.configureBoatCloudEnvironment, + "Change the Boat environment", + ); + const [environmentBusy, setEnvironmentBusy] = useState(false); + + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- reads this phone's Boat key and VMs from Boat when the page opens + useEffect(() => { + void refreshPhoneBoatCloud(); + }, []); + + // A machine builds the snapshot with its own server and skills, so one speaks first. + const machineHolder = holders.find((holder) => holder.status.connected) ?? null; + const machineSetupReady = machineHolder?.status.setup.status === "ready"; + const phoneSetupReady = phone.setup.status === "ready"; + + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- the phone reads the snapshot a machine just built from Boat, so it can start VMs once that machine disconnects + useEffect(() => { + if (machineSetupReady && !phoneSetupReady) void refreshPhoneBoatCloud(); + }, [machineSetupReady, phoneSetupReady]); + + const status = machineHolder?.status ?? (phone.connected ? phone : null); + const connected = status !== null; + const setup = status?.setup ?? null; + const machines = [ + ...new Map( + [...phone.machines, ...holders.flatMap((holder) => holder.status.machines)].map((m) => [ + m.sandboxId, + m, + ]), + ).values(), + ]; + const labelOf = (environmentId: string) => + environments.find((environment) => environment.environmentId === environmentId)?.label ?? + environmentId; + + const submitKey = async (value: string) => { + const key = value.trim(); + if (!key || busy) return; + setBusy(true); + setKeyError(null); + // The phone keeps the key so it can start VMs alone; every machine gets it too. + const phoneError = await connectPhoneBoatCloud(key); + if (phoneError === null) { + await Promise.all( + holders.map((holder) => + connect({ environmentId: holder.environmentId, input: { apiKey: key } }), + ), + ); + setApiKey(""); + } else { + setKeyError(phoneError); + } + setBusy(false); + }; + + const disconnectAll = () => { + void disconnectPhoneBoatCloud(); + for (const holder of holders) { + void disconnect({ environmentId: holder.environmentId, input: {} }); + } + }; + + const selectedEnvironment = status?.environments.find( + (environment) => environment.name === status.environment, + ); + // The phone and every machine start their own VMs, so each keeps the same choice. + const applyEnvironment = async (environmentId: string, access?: BoatCloudEnvironmentAccess) => { + setEnvironmentBusy(true); + const [phoneError] = await Promise.all([ + phone.connected ? configurePhoneBoatCloudEnvironment(environmentId, access) : null, + ...holders + .filter((holder) => holder.status.connected) + .map((holder) => + configureEnvironment({ + environmentId: holder.environmentId, + input: { environmentId, ...(access ? { access } : {}) }, + }), + ), + ]); + if (phoneError) Dialog.alert("Could not change this phone's Boat environment", phoneError); + setEnvironmentBusy(false); + }; + + const startSetup = () => { + if (machineHolder) { + void runSetup({ environmentId: machineHolder.environmentId, input: {} }); + } else { + void runPhoneBoatCloudSetup(); + } + }; + + return ( + + + + {connected ? ( + + } + label={status.account ?? "Connected"} + description="Connected" + /> + +