diff --git a/apps/ade-cli/src/cli.ts b/apps/ade-cli/src/cli.ts index bdf3081c8..1b694c7a2 100644 --- a/apps/ade-cli/src/cli.ts +++ b/apps/ade-cli/src/cli.ts @@ -29,11 +29,24 @@ import { readMacosVmEnvOverride, } from "../../desktop/src/shared/automationAvailability"; import { parseLinearGraphQLInput } from "../../desktop/src/main/services/cto/linearGraphQLInput"; +import { browseProjectDirectories } from "../../desktop/src/main/services/projects/projectBrowserService"; +import { createProjectScaffoldService } from "../../desktop/src/main/services/projects/projectScaffoldService"; +import { resolveRepoRoot } from "../../desktop/src/main/services/projects/projectService"; +import type { Logger } from "../../desktop/src/main/services/logging/logger"; +import type { + CloneProjectInput, + CreateProjectInput, + ListMyGitHubReposInput, + ProjectBrowseInput, +} from "../../desktop/src/shared/types/core"; import { resolveMachineAdeLayout } from "./services/projects/machineLayout"; import { findAdeManagedWorktreeRoot, + normalizeProjectRootPath, realpathIfExists, } from "./services/projects/projectRoots"; +import { createHeadlessGitHubService } from "./headlessLinearServices"; +import type { SyncProjectCatalogProvider } from "./services/sync/syncHostService"; import { startJsonRpcServer, type JsonRpcHandler, @@ -52,6 +65,7 @@ import { } from "../../desktop/src/shared/cliLaunch"; import type { SyncMobileProjectSummary, + SyncProjectOpenRequestPayload, SyncProjectSwitchRequestPayload, SyncProjectSwitchResultPayload, } from "../../desktop/src/shared/types/sync"; @@ -64,6 +78,7 @@ import { normalizeAdeRuntimeRole, resolveAdeDefaultRole } from "./runtimeRoles"; import type { AdeRuntime } from "./bootstrap"; import { reseedBundledAdeSkillsForCli } from "./bootstrap"; import { EncryptedFileCredentialStore } from "./services/credentials/credentialStore"; +import { DEFAULT_SYNC_HOST_PORT } from "./services/sync/syncProtocol"; type JsonObject = Record; @@ -12954,6 +12969,7 @@ async function runServe( { createMultiProjectRpcRequestHandler }, { createSharedSyncListener }, { resolveMobileProjectIconDataUrl }, + { createBrainProjectActionsSyncHandler }, ] = await Promise.all([ import("./services/projects/machineLayout"), import("./services/projects/projectRegistry"), @@ -12961,6 +12977,7 @@ async function runServe( import("./multiProjectRpcServer"), import("./services/sync/sharedSyncListener"), import("../../desktop/src/main/services/projects/projectIconThumbnail"), + import("./services/sync/brainProjectActionsSyncHandler"), ]); const layout = resolveMachineAdeLayout(); @@ -13011,6 +13028,216 @@ async function runServe( isOpen: false, ...overrides, }); + let scopeRegistry: InstanceType; + const headlessProjectLogger: Logger = { + debug: () => {}, + info: () => {}, + warn: (event, meta) => + process.stderr.write(`${event} ${JSON.stringify(meta ?? {})}\n`), + error: (event, meta) => + process.stderr.write(`${event} ${JSON.stringify(meta ?? {})}\n`), + }; + const createHeadlessProjectScaffoldService = () => { + const githubService = createHeadlessGitHubService( + process.cwd(), + headlessProjectLogger, + ); + return createProjectScaffoldService({ + logger: headlessProjectLogger, + githubService, + }); + }; + const getHeadlessDefaultParentDir = (): string => { + const firstProjectRoot = projectRegistry.list()[0]?.rootPath; + if (firstProjectRoot) return path.dirname(firstProjectRoot); + return path.join(os.homedir(), "Projects"); + }; + const resolveHeadlessMobileProjectRoot = async ( + rootPath: string | null | undefined, + ): Promise => { + const requestedRoot = typeof rootPath === "string" ? rootPath.trim() : ""; + if (!requestedRoot) { + throw new Error("Project path is required."); + } + const resolvedRoot = path.resolve(requestedRoot); + if (!fs.existsSync(resolvedRoot)) { + throw new Error("Project is no longer available on this machine."); + } + try { + return normalizeProjectRootPath(await resolveRepoRoot(resolvedRoot)); + } catch { + throw new Error("Choose a Git repository folder."); + } + }; + const mobileProjectSummaryForHeadlessRecord = async ( + record: ProjectRecord, + overrides: Partial = {}, + ): Promise => { + const scope = await scopeRegistry.get(record.projectId); + const lanes = await scope.runtime.laneService + .list({ includeArchived: false, includeStatus: false }) + .catch(() => []); + const laneCount = lanes.length; + return toMobileProjectSummary(record, { + laneCount, + isOpen: true, + ...overrides, + }); + }; + const registerHeadlessMobileProject = async ( + rootPath: string, + ): Promise => { + const record = projectRegistry.add(rootPath); + return await mobileProjectSummaryForHeadlessRecord(record); + }; + const openHeadlessMobileProject = async ( + input: SyncProjectOpenRequestPayload, + ): Promise => { + const projectRoot = await resolveHeadlessMobileProjectRoot(input.rootPath); + return await registerHeadlessMobileProject(projectRoot); + }; + const createHeadlessMobileProject = async ( + input: CreateProjectInput, + ): Promise => { + const result = + await createHeadlessProjectScaffoldService().createLocalProject(input); + return await registerHeadlessMobileProject(result.rootPath); + }; + const cloneHeadlessMobileProject = async ( + input: CloneProjectInput, + ): Promise => { + const result = + await createHeadlessProjectScaffoldService().cloneRepository(input); + return await registerHeadlessMobileProject(result.rootPath); + }; + const machineProjectCatalogProvider: SyncProjectCatalogProvider = { + listProjects: async () => ({ + projects: projectRegistry + .list() + .map((record) => + toMobileProjectSummary(record, { + isAvailable: fs.existsSync(record.rootPath), + })), + }), + prepareProjectConnection: async ( + request: SyncProjectSwitchRequestPayload, + ): Promise => { + const requestedId = + typeof request.projectId === "string" + ? request.projectId.trim() + : ""; + const requestedRootPath = + typeof request.rootPath === "string" + ? path.resolve(request.rootPath) + : ""; + const record = + projectRegistry + .list() + .find( + (candidate) => + (requestedId.length > 0 && + candidate.projectId === requestedId) || + (requestedRootPath.length > 0 && + path.resolve(candidate.rootPath) === requestedRootPath), + ) ?? null; + const project = record + ? toMobileProjectSummary(record, { isOpen: true }) + : null; + if (!record) { + return { + ok: false, + message: "That project is not registered on this ADE machine.", + project, + }; + } + try { + // Prepare must not start the new project's sync host yet: the old host + // or the machine-wide fallback still owns the socket. Reply first, + // then completion activates the project host so the phone can + // reconnect cleanly when needed. + const scope = await scopeRegistry.get(record.projectId); + const syncService = scope?.runtime.syncService ?? null; + if (!scope || !syncService) { + return { + ok: false, + message: "Phone sync is not available for that project.", + project, + }; + } + const lanes = await scope.runtime.laneService + .list({ includeArchived: false, includeStatus: false }) + .catch(() => []); + const laneCount = lanes.length; + const readyProject = toMobileProjectSummary(record, { + isOpen: true, + laneCount, + }); + const activeScope = await scopeRegistry.resolveActiveSyncHost(); + const activeStatus = await activeScope?.runtime.syncService?.getStatus(); + const connectInfo = activeStatus?.pairingConnectInfo ?? null; + if (!connectInfo) { + return { + ok: true, + project: readyProject, + connection: null, + }; + } + return { + ok: true, + project: readyProject, + connection: { + authKind: "paired", + token: null, + pairedDeviceId: null, + hostIdentity: connectInfo.hostIdentity, + port: connectInfo.port, + addressCandidates: connectInfo.addressCandidates, + }, + }; + } catch (error) { + return { + ok: false, + message: + error instanceof Error + ? error.message + : "Unable to prepare phone sync for that project.", + project, + }; + } + }, + completeProjectConnection: async ( + request: SyncProjectSwitchRequestPayload, + result: SyncProjectSwitchResultPayload, + ): Promise => { + if (!result.ok) return; + const projectId = + typeof result.project?.id === "string" && result.project.id.trim() + ? result.project.id.trim() + : typeof request.projectId === "string" && + request.projectId.trim() + ? request.projectId.trim() + : null; + if (!projectId) return; + try { + projectRegistry.touch(projectId); + } catch { + // The mobile handoff already succeeded; a stale registry touch should + // not fail the sync protocol completion. + } + await scopeRegistry.switchSyncHost(projectId, { + deactivatePreviousHost: true, + }); + await scopeRegistry.deactivateInactiveSyncHosts(); + }, + browseDirectories: async (input: ProjectBrowseInput) => + browseProjectDirectories(input), + getDefaultParentDir: async () => getHeadlessDefaultParentDir(), + openProject: openHeadlessMobileProject, + createProject: createHeadlessMobileProject, + cloneProject: cloneHeadlessMobileProject, + listMyGitHubRepos: async (input: ListMyGitHubReposInput) => + createHeadlessProjectScaffoldService().listMyGitHubRepos(input), + }; // ONE websocket listener for the whole brain: every project scope's sync // host attaches to it instead of binding its own server, so the hosted // project can change without paired phones ever seeing a disconnect. @@ -13022,7 +13249,21 @@ async function runServe( }, }) : null; - let scopeRegistry: InstanceType; + const machineCredentialStore = new EncryptedFileCredentialStore({ + secretsDir: layout.secretsDir, + }); + sharedSyncListener?.setFallbackConnectionHandler( + createBrainProjectActionsSyncHandler({ + logger: headlessProjectLogger, + projectCatalogProvider: machineProjectCatalogProvider, + bootstrapCredentialStore: machineCredentialStore, + legacyBootstrapTokenPath: path.join(layout.secretsDir, "sync-bootstrap-token"), + pairingSecretsPath: path.join(layout.secretsDir, "sync-paired-devices.json"), + pinPath: path.join(layout.secretsDir, "sync-pin.json"), + localDeviceIdPath: path.join(layout.secretsDir, "sync-device-id"), + localSiteIdPath: path.join(layout.secretsDir, "sync-site-id"), + }), + ); scopeRegistry = new ProjectScopeRegistry(projectRegistry, { syncRuntime: { enabled: syncEnabled, @@ -13034,131 +13275,7 @@ async function runServe( appVersion: VERSION, localDeviceIdPath: path.join(layout.secretsDir, "sync-device-id"), phonePairingStateDir: layout.secretsDir, - projectCatalogProvider: { - listProjects: async () => ({ - projects: projectRegistry - .list() - .map((record) => toMobileProjectSummary(record)), - }), - prepareProjectConnection: async ( - request: SyncProjectSwitchRequestPayload, - ): Promise => { - const requestedId = - typeof request.projectId === "string" - ? request.projectId.trim() - : ""; - const requestedRootPath = - typeof request.rootPath === "string" - ? path.resolve(request.rootPath) - : ""; - const record = - projectRegistry - .list() - .find( - (candidate) => - (requestedId.length > 0 && - candidate.projectId === requestedId) || - (requestedRootPath.length > 0 && - path.resolve(candidate.rootPath) === requestedRootPath), - ) ?? null; - const project = record - ? toMobileProjectSummary(record, { isOpen: true }) - : null; - if (!record) { - return { - ok: false, - message: "That project is not registered on this ADE machine.", - project, - }; - } - try { - // Prepare must NOT start the new project's sync host: the old host - // still owns the sync port, so an early start either drifts to a - // new port (stranding the phone's saved address) or races the old - // listener. Open the project scope for metadata only, reply with - // the CURRENT stable port, and let completion — which runs after - // project_switch_result is flushed — stop the old host first and - // start the new one on that same port. - const scope = await scopeRegistry.get(record.projectId); - const syncService = scope?.runtime.syncService ?? null; - if (!scope || !syncService) { - return { - ok: false, - message: "Phone sync is not available for that project.", - project, - }; - } - const lanes = await scope.runtime.laneService - .list({ includeArchived: false, includeStatus: false }) - .catch(() => []); - const laneCount = lanes.length; - const readyProject = toMobileProjectSummary(record, { - isOpen: true, - laneCount, - }); - const activeScope = await scopeRegistry.resolveActiveSyncHost(); - const activeStatus = await activeScope?.runtime.syncService?.getStatus(); - const connectInfo = activeStatus?.pairingConnectInfo ?? null; - if (!connectInfo) { - return { - ok: false, - message: "Phone sync is not ready for that project yet.", - project: readyProject, - }; - } - return { - ok: true, - project: readyProject, - connection: { - authKind: "paired", - token: null, - pairedDeviceId: null, - hostIdentity: connectInfo.hostIdentity, - port: connectInfo.port, - addressCandidates: connectInfo.addressCandidates, - }, - }; - } catch (error) { - return { - ok: false, - message: - error instanceof Error - ? error.message - : "Unable to prepare phone sync for that project.", - project, - }; - } - }, - completeProjectConnection: async ( - request: SyncProjectSwitchRequestPayload, - result: SyncProjectSwitchResultPayload, - ): Promise => { - if (!result.ok) return; - const projectId = - typeof result.project?.id === "string" && result.project.id.trim() - ? result.project.id.trim() - : typeof request.projectId === "string" && - request.projectId.trim() - ? request.projectId.trim() - : null; - if (!projectId) return; - try { - projectRegistry.touch(projectId); - } catch { - // The mobile handoff already succeeded; a stale registry touch should - // not fail the sync protocol completion. - } - // The phone already holds project_switch_result with the CURRENT - // port. Stop the old host first so the new one binds that same - // port (deactivatePreviousHost runs before the new host starts; - // the preferred-port retry rides out the old socket's close), then - // retire any other stale hosts. - await scopeRegistry.switchSyncHost(projectId, { - deactivatePreviousHost: true, - }); - await scopeRegistry.deactivateInactiveSyncHosts(); - }, - }, + projectCatalogProvider: machineProjectCatalogProvider, }, }); const previousRole = process.env.ADE_DEFAULT_ROLE; @@ -13183,9 +13300,17 @@ async function runServe( disposeScopesOnDispose: false, onShutdown: finish, }); - const startSyncHost = () => (preferredSyncProjectId - ? scopeRegistry.switchSyncHost(preferredSyncProjectId) - : scopeRegistry.resolveActiveSyncHost()); + const startSyncHost = async () => { + if (preferredSyncProjectId) { + return await scopeRegistry.switchSyncHost(preferredSyncProjectId); + } + const activeScope = await scopeRegistry.resolveActiveSyncHost(); + if (activeScope) return activeScope; + if (sharedSyncListener) { + await sharedSyncListener.ensureListening([DEFAULT_SYNC_HOST_PORT]); + } + return null; + }; const disposeServeResources = async () => { await scopeRegistry.disposeAll(); if (sharedSyncListener) { diff --git a/apps/ade-cli/src/services/sync/brainProjectActionsSyncHandler.ts b/apps/ade-cli/src/services/sync/brainProjectActionsSyncHandler.ts new file mode 100644 index 000000000..4b8836f11 --- /dev/null +++ b/apps/ade-cli/src/services/sync/brainProjectActionsSyncHandler.ts @@ -0,0 +1,649 @@ +import fs from "node:fs"; +import path from "node:path"; +import { randomBytes, timingSafeEqual } from "node:crypto"; +import type { RawData, WebSocket } from "ws"; +import type { + CloneProjectInput, + CreateProjectInput, + ListMyGitHubReposInput, + ProjectBrowseInput, + SyncEnvelope, + SyncHelloPayload, + SyncMobileProjectSummary, + SyncPairingRequestPayload, + SyncPeerMetadata, + SyncProjectCatalogPayload, + SyncProjectOpenRequestPayload, + SyncProjectSwitchRequestPayload, +} from "../../../../desktop/src/shared/types"; +import type { Logger } from "../../../../desktop/src/main/services/logging/logger"; +import { nowIso } from "../../../../desktop/src/main/services/shared/utils"; +import type { SharedSyncListenerConnectionHandler } from "./sharedSyncListener"; +import { SYNC_HOST_BIND_LOOPBACK_ONLY } from "./sharedSyncListener"; +import type { SyncCredentialStore } from "../credentials/credentialStore"; +import { createSyncPairingStore } from "./syncPairingStore"; +import { createSyncPinStore } from "./syncPinStore"; +import { + DEFAULT_SYNC_COMPRESSION_THRESHOLD_BYTES, + encodeSyncEnvelope, + mapPlatform, + parseSyncEnvelope, + wsDataToText, +} from "./syncProtocol"; +import type { SyncProjectCatalogProvider } from "./syncHostService"; +import { resolveDeviceDisplayName } from "./deviceRegistryService"; + +type BrainProjectActionsSyncHandlerArgs = { + logger: Logger; + projectCatalogProvider: SyncProjectCatalogProvider; + bootstrapCredentialStore: SyncCredentialStore; + bootstrapTokenKey?: string; + legacyBootstrapTokenPath?: string | null; + pairingSecretsPath: string; + pinPath: string; + localDeviceIdPath: string; + localSiteIdPath: string; + heartbeatIntervalMs?: number; + pollIntervalMs?: number; +}; + +type BrainPeerState = { + ws: WebSocket; + authenticated: boolean; + metadata: SyncPeerMetadata | null; +}; + +const WS_OPEN = 1; +const BOOTSTRAP_TOKEN_KEY = "sync.bootstrapToken.v1"; +const PAIR_FAILURE_THRESHOLD = 5; +const PAIR_COOLDOWN_MS = 10 * 60_000; +const PAIR_FAILURE_WINDOW_MS = 10 * 60_000; + +type PairFailureEntry = { + count: number; + cooldownUntilMs: number; + updatedAtMs: number; +}; + +function ensureSecretFile(filePath: string, bytes: number): string { + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + if (!fs.existsSync(filePath)) { + fs.writeFileSync(filePath, `${randomBytes(bytes).toString("hex")}\n`, { + encoding: "utf8", + mode: 0o600, + }); + } + try { + fs.chmodSync(filePath, 0o600); + } catch { + // ignore chmod failures on platforms that do not support it + } + return fs.readFileSync(filePath, "utf8").trim(); +} + +function readLegacySecretFile(filePath: string | null | undefined): string | null { + if (!filePath) return null; + try { + const value = fs.readFileSync(filePath, "utf8").trim(); + return value.length > 0 ? value : null; + } catch { + return null; + } +} + +function ensureCredentialSecret( + store: SyncCredentialStore, + key: string, + bytes: number, + legacyPath?: string | null, +): string { + const existing = store.getSync(key)?.trim(); + if (existing) return existing; + const next = readLegacySecretFile(legacyPath) ?? randomBytes(bytes).toString("hex"); + store.setSync(key, next); + return next; +} + +function safeStringEquals(expected: string, actual: string): boolean { + const expectedBuffer = Buffer.from(expected, "utf8"); + const actualBuffer = Buffer.from(actual, "utf8"); + if (expectedBuffer.length !== actualBuffer.length) { + timingSafeEqual(expectedBuffer, Buffer.alloc(expectedBuffer.length)); + return false; + } + return timingSafeEqual(expectedBuffer, actualBuffer); +} + +function optionalString(value: unknown): string | null { + return typeof value === "string" && value.trim() ? value.trim() : null; +} + +function normalizePeerMetadata(value: unknown): SyncPeerMetadata | null { + if (!value || typeof value !== "object" || Array.isArray(value)) return null; + const record = value as Record; + const deviceId = optionalString(record.deviceId); + const deviceName = optionalString(record.deviceName); + const siteId = optionalString(record.siteId); + if (!deviceId || !deviceName || !siteId) return null; + const capabilities = Array.isArray(record.capabilities) + ? record.capabilities + .filter((capability): capability is string => typeof capability === "string") + .map((capability) => capability.trim()) + .filter(Boolean) + : []; + const dbVersionBySite: Record = {}; + if (record.dbVersionBySite && typeof record.dbVersionBySite === "object" && !Array.isArray(record.dbVersionBySite)) { + for (const [site, version] of Object.entries(record.dbVersionBySite as Record)) { + const normalizedSite = site.trim(); + const normalizedVersion = Number(version); + if (normalizedSite && Number.isFinite(normalizedVersion) && normalizedVersion >= 0) { + dbVersionBySite[normalizedSite] = Math.floor(normalizedVersion); + } + } + } + return { + deviceId, + deviceName, + platform: record.platform === "iOS" || record.platform === "macOS" || record.platform === "linux" || record.platform === "windows" + ? record.platform + : "unknown", + deviceType: record.deviceType === "phone" || record.deviceType === "desktop" || record.deviceType === "vps" + ? record.deviceType + : "unknown", + siteId, + dbVersion: Math.max(0, Math.floor(Number(record.dbVersion ?? 0) || 0)), + ...(Object.keys(dbVersionBySite).length > 0 ? { dbVersionBySite } : {}), + capabilities, + }; +} + +function parseHelloPayload(payload: unknown): SyncHelloPayload | null { + if (!payload || typeof payload !== "object" || Array.isArray(payload)) return null; + const record = payload as Record; + const peer = normalizePeerMetadata(record.peer); + if (!peer) return null; + const auth = record.auth as SyncHelloPayload["auth"] | undefined; + if (auth?.kind === "bootstrap" && optionalString(auth.token)) { + return { peer, auth: { kind: "bootstrap", token: auth.token } }; + } + if ( + auth?.kind === "paired" + && optionalString(auth.deviceId) + && optionalString(auth.secret) + ) { + return { + peer, + auth: { kind: "paired", deviceId: auth.deviceId, secret: auth.secret }, + }; + } + const token = optionalString(record.token); + if (token) return { peer, auth: { kind: "bootstrap", token } }; + return null; +} + +function parsePairingRequestPayload(payload: unknown): SyncPairingRequestPayload | null { + if (!payload || typeof payload !== "object" || Array.isArray(payload)) return null; + const record = payload as Record; + const code = optionalString(record.code); + const peer = normalizePeerMetadata(record.peer); + return code && peer ? { code, peer } : null; +} + +function send( + ws: WebSocket, + type: SyncEnvelope["type"], + payload: unknown, + requestId?: string | null, +): void { + if (ws.readyState !== WS_OPEN) return; + ws.send(encodeSyncEnvelope({ + type, + requestId, + payload, + compressionThresholdBytes: DEFAULT_SYNC_COMPRESSION_THRESHOLD_BYTES, + })); +} + +function projectActionsEnabled(provider: SyncProjectCatalogProvider): boolean { + return Boolean( + provider.browseDirectories + && provider.getDefaultParentDir + && provider.openProject + && provider.createProject + && provider.cloneProject + && provider.listMyGitHubRepos, + ); +} + +function createPairFailureTracker() { + const pairFailures = new Map(); + const globalPairFailures: PairFailureEntry = { + count: 0, + cooldownUntilMs: 0, + updatedAtMs: 0, + }; + + const reset = (entry: PairFailureEntry): void => { + entry.count = 0; + entry.cooldownUntilMs = 0; + entry.updatedAtMs = 0; + }; + const expired = (entry: PairFailureEntry, now: number): boolean => { + if (entry.updatedAtMs <= 0) return false; + return (entry.cooldownUntilMs > 0 && entry.cooldownUntilMs <= now) + || entry.updatedAtMs + PAIR_FAILURE_WINDOW_MS <= now; + }; + const prune = (now = Date.now()): void => { + for (const [ip, entry] of pairFailures) { + if (expired(entry, now)) { + pairFailures.delete(ip); + } + } + if (expired(globalPairFailures, now)) { + reset(globalPairFailures); + } + }; + const increment = (entry: PairFailureEntry, now: number): void => { + entry.count += 1; + entry.updatedAtMs = now; + if (entry.count >= PAIR_FAILURE_THRESHOLD) { + entry.cooldownUntilMs = now + PAIR_COOLDOWN_MS; + entry.count = 0; + } + }; + + return { + cooldownMsRemaining(ip: string | null): number { + const now = Date.now(); + prune(now); + const globalRemaining = Math.max(0, globalPairFailures.cooldownUntilMs - now); + const ipEntry = ip ? pairFailures.get(ip) ?? null : null; + const ipRemaining = ipEntry ? Math.max(0, ipEntry.cooldownUntilMs - now) : 0; + return Math.max(globalRemaining, ipRemaining); + }, + registerFailure(ip: string | null): void { + const now = Date.now(); + prune(now); + increment(globalPairFailures, now); + if (ip) { + const entry = pairFailures.get(ip) ?? { + count: 0, + cooldownUntilMs: 0, + updatedAtMs: now, + }; + increment(entry, now); + pairFailures.set(ip, entry); + } + }, + clearAfterSuccess(ip: string | null): void { + reset(globalPairFailures); + if (ip) pairFailures.delete(ip); + }, + }; +} + +async function projectCatalog(provider: SyncProjectCatalogProvider, logger: Logger): Promise { + try { + return await provider.listProjects(); + } catch (error) { + logger.warn("sync_brain.project_catalog_failed", { + error: error instanceof Error ? error.message : String(error), + }); + return { projects: [] }; + } +} + +export function createBrainProjectActionsSyncHandler( + args: BrainProjectActionsSyncHandlerArgs, +): SharedSyncListenerConnectionHandler { + const bootstrapToken = ensureCredentialSecret( + args.bootstrapCredentialStore, + args.bootstrapTokenKey ?? BOOTSTRAP_TOKEN_KEY, + 24, + args.legacyBootstrapTokenPath, + ); + const pinStore = createSyncPinStore({ filePath: args.pinPath }); + const pairingStore = createSyncPairingStore({ + filePath: args.pairingSecretsPath, + pinStore, + }); + const localDeviceId = ensureSecretFile(args.localDeviceIdPath, 16); + const localSiteId = ensureSecretFile(args.localSiteIdPath, 16); + const heartbeatIntervalMs = Math.max(5_000, Math.floor(args.heartbeatIntervalMs ?? 5_000)); + const pollIntervalMs = Math.max(100, Math.floor(args.pollIntervalMs ?? 1_500)); + const pairFailures = createPairFailureTracker(); + + const brainMetadata = (): SyncPeerMetadata => ({ + deviceId: localDeviceId, + deviceName: resolveDeviceDisplayName(), + platform: mapPlatform(process.platform), + deviceType: "desktop", + siteId: localSiteId, + dbVersion: 0, + }); + + const sendActionResult = async ( + peer: BrainPeerState, + requestId: string | null | undefined, + resultType: "project_open_result" | "project_create_result" | "project_clone_result", + unavailableMessage: string, + payload: TPayload, + action: ((payload: TPayload) => Promise) | undefined, + ): Promise => { + if (!action) { + send(peer.ws, resultType, { ok: false, message: unavailableMessage }, requestId); + return; + } + try { + const project = await action(payload); + send(peer.ws, resultType, { ok: true, project }, requestId); + send(peer.ws, "project_catalog", await projectCatalog(args.projectCatalogProvider, args.logger)); + } catch (error) { + send(peer.ws, resultType, { + ok: false, + message: error instanceof Error ? error.message : String(error), + }, requestId); + } + }; + + const handleAuthenticatedEnvelope = async (peer: BrainPeerState, envelope: ReturnType): Promise => { + switch (envelope.type) { + case "project_catalog_request": { + send(peer.ws, "project_catalog", await projectCatalog(args.projectCatalogProvider, args.logger), envelope.requestId); + break; + } + case "project_switch_request": { + let result = null as Awaited> | null; + let completionAttempted = false; + let resultSent = false; + try { + result = await args.projectCatalogProvider.prepareProjectConnection( + (envelope.payload ?? {}) as SyncProjectSwitchRequestPayload, + ); + send(peer.ws, "project_switch_result", result, envelope.requestId); + resultSent = true; + completionAttempted = true; + await args.projectCatalogProvider.completeProjectConnection?.( + (envelope.payload ?? {}) as SyncProjectSwitchRequestPayload, + result, + ); + } catch (error) { + args.logger.warn("sync_brain.project_switch_failed", { + message: error instanceof Error ? error.message : String(error), + }); + if (result && !completionAttempted) { + try { + await args.projectCatalogProvider.completeProjectConnection?.( + (envelope.payload ?? {}) as SyncProjectSwitchRequestPayload, + result, + ); + } catch { + // Best effort; the peer will retry selection if handoff fails. + } + } + if (!resultSent) { + send(peer.ws, "project_switch_result", { + ok: false, + message: error instanceof Error ? error.message : String(error), + }, envelope.requestId); + } + } + break; + } + case "project_browse_request": { + try { + const result = await args.projectCatalogProvider.browseDirectories?.( + (envelope.payload ?? {}) as ProjectBrowseInput, + ); + send(peer.ws, "project_browse_result", result + ? { ok: true, result } + : { ok: false, message: "Project browsing is not available from this machine." }, envelope.requestId); + } catch (error) { + send(peer.ws, "project_browse_result", { + ok: false, + message: error instanceof Error ? error.message : String(error), + }, envelope.requestId); + } + break; + } + case "project_default_parent_dir_request": { + try { + const parentDir = await args.projectCatalogProvider.getDefaultParentDir?.(); + send(peer.ws, "project_default_parent_dir", parentDir + ? { ok: true, parentDir } + : { ok: false, message: "Default project directory is not available from this machine." }, envelope.requestId); + } catch (error) { + send(peer.ws, "project_default_parent_dir", { + ok: false, + message: error instanceof Error ? error.message : String(error), + }, envelope.requestId); + } + break; + } + case "project_open_request": { + await sendActionResult( + peer, + envelope.requestId, + "project_open_result", + "Opening projects is not available from this machine.", + (envelope.payload ?? {}) as SyncProjectOpenRequestPayload, + args.projectCatalogProvider.openProject, + ); + break; + } + case "project_create_request": { + await sendActionResult( + peer, + envelope.requestId, + "project_create_result", + "Creating projects is not available from this machine.", + (envelope.payload ?? {}) as CreateProjectInput, + args.projectCatalogProvider.createProject, + ); + break; + } + case "project_clone_request": { + await sendActionResult( + peer, + envelope.requestId, + "project_clone_result", + "Cloning projects is not available from this machine.", + (envelope.payload ?? {}) as CloneProjectInput, + args.projectCatalogProvider.cloneProject, + ); + break; + } + case "project_list_my_github_repos_request": { + try { + const result = await args.projectCatalogProvider.listMyGitHubRepos?.( + (envelope.payload ?? {}) as ListMyGitHubReposInput, + ); + send(peer.ws, "project_list_my_github_repos_result", result + ? { ok: true, result } + : { ok: false, message: "GitHub repository listing is not available from this machine." }, envelope.requestId); + } catch (error) { + send(peer.ws, "project_list_my_github_repos_result", { + ok: false, + message: error instanceof Error ? error.message : String(error), + }, envelope.requestId); + } + break; + } + case "heartbeat": { + const payload = envelope.payload as { kind?: string; sentAt?: string } | null; + if (payload?.kind === "ping") { + send(peer.ws, "heartbeat", { + kind: "pong", + sentAt: payload.sentAt ?? nowIso(), + dbVersion: 0, + }, envelope.requestId); + } + break; + } + default: + args.logger.warn("sync_brain.unsupported_envelope", { + type: envelope.type, + peerDeviceId: peer.metadata?.deviceId ?? null, + }); + break; + } + }; + + return ({ ws, remoteAddress }) => { + const peer: BrainPeerState = { + ws, + authenticated: false, + metadata: null, + }; + ws.on("message", (data: RawData) => { + void (async () => { + let envelope: ReturnType; + try { + envelope = parseSyncEnvelope(wsDataToText(data)); + } catch (error) { + args.logger.warn("sync_brain.invalid_envelope", { + error: error instanceof Error ? error.message : String(error), + remoteAddress: remoteAddress ?? null, + }); + return; + } + + if (!peer.authenticated) { + if (envelope.type === "pairing_request") { + const payload = parsePairingRequestPayload(envelope.payload); + if (!payload) { + send(ws, "pairing_result", { + ok: false, + error: { code: "pairing_failed", message: "Invalid pairing request." }, + }, envelope.requestId); + try { + ws.close(4003, "Pairing failed"); + } catch { + // ignore close failures + } + return; + } + const cooldownMs = pairFailures.cooldownMsRemaining(remoteAddress ?? null); + if (cooldownMs > 0) { + const minutes = Math.ceil(cooldownMs / 60_000); + send(ws, "pairing_result", { + ok: false, + error: { + code: "pairing_failed", + message: `Too many failed PIN attempts. Try again in ${minutes} minute${minutes === 1 ? "" : "s"}.`, + }, + }, envelope.requestId); + try { + ws.close(4004, "Pairing cooldown"); + } catch { + // ignore close failures + } + return; + } + try { + const paired = pairingStore.pairPeer(payload.peer, payload.code); + pairFailures.clearAfterSuccess(remoteAddress ?? null); + send(ws, "pairing_result", { ok: true, deviceId: paired.deviceId, secret: paired.secret }, envelope.requestId); + } catch (error) { + const code = (error as { code?: string } | null)?.code === "pin_not_set" + ? "pin_not_set" + : (error as { code?: string } | null)?.code === "invalid_pin" + ? "invalid_pin" + : "pairing_failed"; + send(ws, "pairing_result", { + ok: false, + error: { + code, + message: error instanceof Error ? error.message : "Unable to pair this device.", + }, + }, envelope.requestId); + if (code === "invalid_pin" || code === "pairing_failed") { + pairFailures.registerFailure(remoteAddress ?? null); + } + try { + ws.close(4003, "Pairing failed"); + } catch { + // ignore close failures + } + } + return; + } + if (envelope.type !== "hello") { + send(ws, "hello_error", { + code: "invalid_hello", + message: "Authenticate with hello or pairing_request before sending other messages.", + }, envelope.requestId); + return; + } + const hello = parseHelloPayload(envelope.payload); + if (!hello) { + send(ws, "hello_error", { + code: "invalid_hello", + message: "Invalid hello payload.", + }, envelope.requestId); + return; + } + const auth = hello.auth; + const authFailed = auth?.kind === "paired" + ? auth.deviceId !== hello.peer.deviceId || !pairingStore.authenticate(auth.deviceId, auth.secret) + : !auth + || !safeStringEquals(bootstrapToken, auth.token) + || (!SYNC_HOST_BIND_LOOPBACK_ONLY && !pairingStore.hasPairingRecord(hello.peer.deviceId)); + if (authFailed) { + send(ws, "hello_error", { + code: "auth_failed", + message: "Sync authentication failed.", + }, envelope.requestId); + try { + ws.close(4003, "Authentication failed"); + } catch { + // ignore close failures + } + return; + } + peer.authenticated = true; + peer.metadata = hello.peer; + const catalog = await projectCatalog(args.projectCatalogProvider, args.logger); + const brain = brainMetadata(); + send(ws, "hello_ok", { + peer: hello.peer, + brain, + serverDbVersion: 0, + heartbeatIntervalMs, + pollIntervalMs, + projects: catalog.projects, + features: { + fileAccess: true, + terminalStreaming: true, + chatStreaming: { enabled: true }, + projectCatalog: { enabled: true }, + projectActions: { enabled: projectActionsEnabled(args.projectCatalogProvider) }, + changesetAck: { enabled: true }, + bootstrapAuth: true, + pairingAuth: { enabled: true, pinDigits: 6 }, + commandRouting: { + mode: "allowlisted", + supportedActions: [], + actions: [], + }, + }, + }, envelope.requestId); + return; + } + + await handleAuthenticatedEnvelope(peer, envelope); + })().catch((error) => { + args.logger.warn("sync_brain.envelope_failed", { + error: error instanceof Error ? error.message : String(error), + peerDeviceId: peer.metadata?.deviceId ?? null, + }); + }); + }); + ws.on("error", (error) => { + args.logger.warn("sync_brain.socket_error", { + error: error instanceof Error ? error.message : String(error), + peerDeviceId: peer.metadata?.deviceId ?? null, + }); + }); + }; +} diff --git a/apps/ade-cli/src/services/sync/sharedSyncListener.ts b/apps/ade-cli/src/services/sync/sharedSyncListener.ts index b19d79597..66b5704c6 100644 --- a/apps/ade-cli/src/services/sync/sharedSyncListener.ts +++ b/apps/ade-cli/src/services/sync/sharedSyncListener.ts @@ -93,6 +93,11 @@ export type SharedSyncListener = { * host service. */ setConnectionHandler(handler: SharedSyncListenerConnectionHandler): () => void; + /** + * Machine-wide handler used when no project sync host currently owns new + * sockets. Project hosts still take precedence once they attach. + */ + setFallbackConnectionHandler(handler: SharedSyncListenerConnectionHandler | null): () => void; /** Park live peer sockets for adoption by the next host service. */ depositPeers(snapshots: SyncPeerHandoffSnapshot[]): void; /** Claim every parked socket (deposited peers + connections that arrived handler-less). */ @@ -129,6 +134,8 @@ export function createSharedSyncListener(options: { let server: WebSocketServer | null = null; let listeningPromise: Promise | null = null; let handler: SharedSyncListenerConnectionHandler | null = null; + let fallbackHandler: SharedSyncListenerConnectionHandler | null = null; + let fallbackSuppressedUntilMs = 0; let closed = false; const parked = new Map(); @@ -252,8 +259,10 @@ export function createSharedSyncListener(options: { remoteAddress: request.socket.remoteAddress ?? null, remotePort: request.socket.remotePort ?? null, }; - if (handler) { - handler(connection); + const fallbackSuppressed = fallbackSuppressedUntilMs > Date.now(); + const activeHandler = handler ?? (fallbackSuppressed ? null : fallbackHandler); + if (activeHandler) { + activeHandler(connection); return; } // No host service owns the listener right now (mid project switch @@ -318,9 +327,20 @@ export function createSharedSyncListener(options: { setConnectionHandler(nextHandler: SharedSyncListenerConnectionHandler): () => void { handler = nextHandler; + fallbackSuppressedUntilMs = 0; return () => { if (handler === nextHandler) { handler = null; + fallbackSuppressedUntilMs = Date.now() + parkedPeerGraceMs; + } + }; + }, + + setFallbackConnectionHandler(nextHandler: SharedSyncListenerConnectionHandler | null): () => void { + fallbackHandler = nextHandler; + return () => { + if (fallbackHandler === nextHandler) { + fallbackHandler = null; } }; }, @@ -354,6 +374,7 @@ export function createSharedSyncListener(options: { if (closed) return; closed = true; handler = null; + fallbackHandler = null; if (listeningPromise) { await listeningPromise.catch(() => {}); } diff --git a/apps/ade-cli/src/services/sync/syncHostService.test.ts b/apps/ade-cli/src/services/sync/syncHostService.test.ts index 5056beda9..d9186c0b9 100644 --- a/apps/ade-cli/src/services/sync/syncHostService.test.ts +++ b/apps/ade-cli/src/services/sync/syncHostService.test.ts @@ -1,7 +1,7 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; -import { WebSocket } from "ws"; +import { WebSocket, WebSocketServer } from "ws"; import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { AgentChatEventEnvelope, @@ -21,9 +21,12 @@ import { resolveSyncHostInboundProjectScope, selectChangesetBatchChunk, } from "./syncHostService"; +import { createBrainProjectActionsSyncHandler } from "./brainProjectActionsSyncHandler"; import { buildChangesetBatchPayload } from "./changesetPump"; import { createSharedSyncListener } from "./sharedSyncListener"; +import { createSyncPinStore } from "./syncPinStore"; import { encodeSyncEnvelope, parseSyncEnvelope, wsDataToText, type ParsedSyncEnvelope } from "./syncProtocol"; +import { EncryptedFileCredentialStore } from "../credentials/credentialStore"; // The sync host now binds to all interfaces (0.0.0.0) by default so phones on // the LAN can reach it. These tests assert the LOOPBACK-only posture (no LAN @@ -181,6 +184,7 @@ describe("buildSyncHostHelloOkPayload", () => { pollIntervalMs: 400, projectCatalog: { projects: [project] }, projectCatalogEnabled: true, + projectActionsEnabled: false, remoteCommandSupportedActions: [remoteCommand.action], remoteCommandDescriptors: [remoteCommand], localCommandDescriptors: [localPresenceCommand], @@ -192,6 +196,7 @@ describe("buildSyncHostHelloOkPayload", () => { expect(payload.serverDbVersion).toBe(7); expect(payload.projects).toEqual([project]); expect(payload.features.projectCatalog).toEqual({ enabled: true }); + expect(payload.features.projectActions).toEqual({ enabled: false }); expect(payload.features.fileAccess).toBe(true); expect(payload.features.terminalStreaming).toBe(true); expect(payload.features.chatStreaming).toEqual({ enabled: true }); @@ -317,6 +322,213 @@ describe("buildChangesetBatchPayload", () => { }); }); +describe("brain project actions fallback handler", () => { + it("does not send a second switch result if post-response project completion fails", async () => { + const { projectRoot, cleanup } = createTempProjectRoot(); + const secretsDir = path.join(projectRoot, "secrets"); + fs.mkdirSync(secretsDir, { recursive: true }); + const credentialStore = new EncryptedFileCredentialStore({ + secretsDir, + keyMaterialProvider: () => null, + }); + credentialStore.setSync("test.bootstrap", "bootstrap-token"); + + const project = createDiscoveryProject({ + id: "project-1", + rootPath: projectRoot, + isOpen: true, + }); + const logger = createDiscoveryLogger(); + const handler = createBrainProjectActionsSyncHandler({ + logger, + projectCatalogProvider: { + listProjects: vi.fn(async () => ({ projects: [project] })), + prepareProjectConnection: vi.fn(async () => ({ + ok: true, + project, + connection: null, + })), + completeProjectConnection: vi.fn(async () => { + throw new Error("activation failed"); + }), + }, + bootstrapCredentialStore: credentialStore, + bootstrapTokenKey: "test.bootstrap", + pairingSecretsPath: path.join(secretsDir, "sync-paired-devices.json"), + pinPath: path.join(secretsDir, "sync-pin.json"), + localDeviceIdPath: path.join(secretsDir, "sync-device-id"), + localSiteIdPath: path.join(secretsDir, "sync-site-id"), + }); + const server = new WebSocketServer({ host: "127.0.0.1", port: 0 }); + server.on("connection", (ws, request) => { + handler({ + ws, + remoteAddress: request.socket.remoteAddress ?? null, + remotePort: request.socket.remotePort ?? null, + }); + }); + + let client: WebSocket | null = null; + try { + await new Promise((resolve, reject) => { + server.once("listening", () => resolve()); + server.once("error", reject); + }); + const address = server.address(); + expect(typeof address).toBe("object"); + const port = typeof address === "object" && address ? address.port : 0; + expect(port).toBeGreaterThan(0); + + client = new WebSocket(`ws://127.0.0.1:${port}`); + const { envelopes } = trackClientEnvelopes(client); + await new Promise((resolve, reject) => { + client!.once("open", () => resolve()); + client!.once("error", reject); + }); + sendHello(client, "bootstrap-token"); + await waitForValue( + () => envelopes.find((envelope) => envelope.type === "hello_ok"), + "fallback hello_ok", + ); + + client.send(encodeSyncEnvelope({ + type: "project_switch_request", + requestId: "switch-1", + payload: { projectId: "project-1" }, + })); + const result = await waitForEnvelope(envelopes, "project_switch_result", "switch-1"); + expect(result.payload).toMatchObject({ ok: true }); + + await new Promise((resolve) => setTimeout(resolve, 100)); + const switchResults = envelopes.filter( + (envelope) => envelope.type === "project_switch_result" && envelope.requestId === "switch-1", + ); + expect(switchResults).toHaveLength(1); + expect(logger.warn).toHaveBeenCalledWith( + "sync_brain.project_switch_failed", + expect.objectContaining({ message: "activation failed" }), + ); + } finally { + try { + client?.close(); + } catch { + // ignore + } + await new Promise((resolve) => server.close(() => resolve())); + cleanup(); + } + }); + + it("rate-limits failed PIN pairing attempts before a project host owns the listener", async () => { + const { projectRoot, cleanup } = createTempProjectRoot(); + const secretsDir = path.join(projectRoot, "secrets"); + fs.mkdirSync(secretsDir, { recursive: true }); + const pinPath = path.join(secretsDir, "sync-pin.json"); + createSyncPinStore({ filePath: pinPath }).setPin("428193"); + + const logger = createDiscoveryLogger(); + const bootstrapTokenPath = path.join(secretsDir, "sync-bootstrap-token"); + const handler = createBrainProjectActionsSyncHandler({ + logger, + projectCatalogProvider: { + listProjects: vi.fn(async () => ({ projects: [] })), + prepareProjectConnection: vi.fn(async () => ({ + ok: false, + message: "No hosted project is ready.", + })), + }, + bootstrapCredentialStore: new EncryptedFileCredentialStore({ + secretsDir, + keyMaterialProvider: () => null, + }), + pairingSecretsPath: path.join(secretsDir, "sync-paired-devices.json"), + pinPath, + localDeviceIdPath: path.join(secretsDir, "sync-device-id"), + localSiteIdPath: path.join(secretsDir, "sync-site-id"), + }); + expect(fs.existsSync(bootstrapTokenPath)).toBe(false); + expect(fs.existsSync(path.join(secretsDir, "credentials.json.enc"))).toBe(true); + const server = new WebSocketServer({ host: "127.0.0.1", port: 0 }); + server.on("connection", (ws, request) => { + handler({ + ws, + remoteAddress: request.socket.remoteAddress ?? null, + remotePort: request.socket.remotePort ?? null, + }); + }); + + try { + await new Promise((resolve, reject) => { + server.once("listening", () => resolve()); + server.once("error", reject); + }); + const address = server.address(); + expect(typeof address).toBe("object"); + const port = typeof address === "object" && address ? address.port : 0; + expect(port).toBeGreaterThan(0); + + const sendPairingRequest = async (requestId: string, code: string, deviceId: string) => { + const ws = new WebSocket(`ws://127.0.0.1:${port}`); + const envelopes: ParsedSyncEnvelope[] = []; + ws.on("message", (raw) => { + envelopes.push(parseSyncEnvelope(wsDataToText(raw))); + }); + await new Promise((resolve, reject) => { + ws.once("open", () => resolve()); + ws.once("error", reject); + }); + const closed = new Promise<{ code: number; reason: string }>((resolve) => { + ws.once("close", (closeCode, reason) => { + resolve({ code: closeCode, reason: reason.toString("utf8") }); + }); + }); + ws.send(encodeSyncEnvelope({ + type: "pairing_request", + requestId, + payload: { + code, + peer: { + deviceId, + deviceName: "Fallback iPhone", + platform: "iOS", + deviceType: "phone", + siteId: `${deviceId}-site`, + dbVersion: 0, + }, + }, + })); + const response = await waitForValue( + () => envelopes.find((envelope) => envelope.type === "pairing_result"), + `pairing_result ${requestId}`, + ); + return { + payload: response.payload as { + ok: boolean; + error?: { code?: string; message?: string }; + }, + closed: await closed, + }; + }; + + for (let attempt = 0; attempt < 5; attempt += 1) { + const failed = await sendPairingRequest(`bad-pin-${attempt}`, "000000", `ios-bad-${attempt}`); + expect(failed.payload.ok).toBe(false); + expect(failed.payload.error?.code).toBe("invalid_pin"); + expect(failed.closed.code).toBe(4003); + } + + const limited = await sendPairingRequest("bad-pin-cooldown", "000000", "ios-rate-limited"); + expect(limited.payload.ok).toBe(false); + expect(limited.payload.error?.code).toBe("pairing_failed"); + expect(limited.payload.error?.message).toMatch(/Too many failed PIN attempts/i); + expect(limited.closed.code).toBe(4004); + } finally { + await new Promise((resolve) => server.close(() => resolve())); + cleanup(); + } + }); +}); + function createDiscoveryLogger() { return { debug: vi.fn(), @@ -918,6 +1130,48 @@ describe("sync host handoff over a shared listener", () => { } }); + it("parks new connections during handler handoff instead of dispatching them to the fallback handler", async () => { + const listener = createSharedSyncListener({ bindHost: "127.0.0.1", parkedPeerGraceMs: 500 }); + const fallbackHandler = vi.fn((connection: { ws: WebSocket }) => { + connection.ws.close(4010, "Fallback claimed socket"); + }); + const primaryHandler = vi.fn((connection: { ws: WebSocket }) => { + connection.ws.close(4011, "Primary claimed socket"); + }); + let client: WebSocket | null = null; + + try { + const port = await listener.ensureListening([0]); + const detachPrimary = listener.setConnectionHandler(primaryHandler); + listener.setFallbackConnectionHandler(fallbackHandler); + detachPrimary(); + + client = new WebSocket(`ws://127.0.0.1:${port}`); + const { closeEvents } = trackClientEnvelopes(client); + await new Promise((resolve, reject) => { + client!.once("open", () => resolve()); + client!.once("error", reject); + }); + client.send("hello-during-handoff"); + await new Promise((resolve) => setTimeout(resolve, 50)); + + expect(primaryHandler).not.toHaveBeenCalled(); + expect(fallbackHandler).not.toHaveBeenCalled(); + const parkedPeers = listener.takePeers(); + expect(parkedPeers).toHaveLength(1); + expect(parkedPeers[0]?.bufferedMessages).toHaveLength(1); + expect(wsDataToText(parkedPeers[0]!.bufferedMessages![0]!.data)).toBe("hello-during-handoff"); + expect(closeEvents).toEqual([]); + } finally { + try { + client?.close(); + } catch { + // ignore + } + await listener.close(); + } + }); + it("closes parked peers with 4002 when no host adopts them in time", async () => { const listener = createSharedSyncListener({ bindHost: "127.0.0.1", parkedPeerGraceMs: 150 }); let client: WebSocket | null = null; diff --git a/apps/ade-cli/src/services/sync/syncHostService.ts b/apps/ade-cli/src/services/sync/syncHostService.ts index 954accc0c..31d8dcd7d 100644 --- a/apps/ade-cli/src/services/sync/syncHostService.ts +++ b/apps/ade-cli/src/services/sync/syncHostService.ts @@ -25,9 +25,11 @@ import type { SyncBrainStatusPayload, SyncChangesetAckPayload, SyncChangesetBatchPayload, + CloneProjectInput, SyncCommandAckPayload, SyncCommandPayload, SyncCommandResultPayload, + CreateProjectInput, SyncEnvelope, SyncChatEventPayload, SyncChatSubscribeSnapshotPayload, @@ -41,10 +43,15 @@ import type { SyncPairingRequestPayload, SyncPeerConnectionState, SyncPeerMetadata, + SyncProjectOpenRequestPayload, SyncProjectCatalogChunkPayload, SyncProjectCatalogPayload, SyncProjectSwitchRequestPayload, SyncProjectSwitchResultPayload, + ListMyGitHubReposInput, + ListMyGitHubReposResult, + ProjectBrowseInput, + ProjectBrowseResult, SyncRemoteCommandDescriptor, SyncTailnetDiscoveryStatus, SyncTerminalHistoryResponsePayload, @@ -333,6 +340,21 @@ function addMobileCommandWaiter(record: CachedMobileCommand, peer: PeerState, re record.waiters.push({ peer, requestId }); } +export type SyncProjectCatalogProvider = { + listProjects: () => Promise; + prepareProjectConnection: (args: SyncProjectSwitchRequestPayload) => Promise; + completeProjectConnection?: ( + args: SyncProjectSwitchRequestPayload, + result: SyncProjectSwitchResultPayload, + ) => Promise; + browseDirectories?: (args: ProjectBrowseInput) => Promise; + getDefaultParentDir?: () => Promise; + openProject?: (args: SyncProjectOpenRequestPayload) => Promise; + createProject?: (args: CreateProjectInput) => Promise; + cloneProject?: (args: CloneProjectInput) => Promise; + listMyGitHubRepos?: (args: ListMyGitHubReposInput) => Promise; +}; + type SyncHostServiceArgs = { db: AdeDb; logger: Logger; @@ -393,14 +415,7 @@ type SyncHostServiceArgs = { brainStatusIntervalMs?: number; compressionThresholdBytes?: number; deviceRegistryService?: DeviceRegistryService; - projectCatalogProvider?: { - listProjects: () => Promise; - prepareProjectConnection: (args: SyncProjectSwitchRequestPayload) => Promise; - completeProjectConnection?: ( - args: SyncProjectSwitchRequestPayload, - result: SyncProjectSwitchResultPayload, - ) => Promise; - }; + projectCatalogProvider?: SyncProjectCatalogProvider; onStateChanged?: () => void; notificationEventBus?: NotificationEventBus | null; remoteCommandService?: SyncRemoteCommandService; @@ -599,6 +614,7 @@ export function buildSyncHostHelloOkPayload(args: { pollIntervalMs: number; projectCatalog: SyncProjectCatalogPayload; projectCatalogEnabled: boolean; + projectActionsEnabled: boolean; remoteCommandSupportedActions: string[]; remoteCommandDescriptors: SyncRemoteCommandDescriptor[]; localCommandDescriptors: SyncRemoteCommandDescriptor[]; @@ -626,6 +642,9 @@ export function buildSyncHostHelloOkPayload(args: { projectCatalog: { enabled: args.projectCatalogEnabled, }, + projectActions: { + enabled: args.projectActionsEnabled, + }, changesetAck: { enabled: true, }, @@ -2460,6 +2479,115 @@ export function createSyncHostService(args: SyncHostServiceArgs) { } } + function broadcastProjectCatalogToConnectedPeers( + projectCatalog: SyncProjectCatalogPayload, + ): void { + if (bonjourPort != null) { + refreshLanDiscoveryProjects(bonjourPort, projectCatalog); + } + for (const peer of peers) { + if (!peer.authenticated || peer.ws.readyState !== WebSocket.OPEN) continue; + sendProjectCatalog(peer, projectCatalog); + } + } + + async function handleProjectBrowseRequest( + peer: PeerState, + requestId: string | null | undefined, + payload: ProjectBrowseInput | null, + ): Promise { + if (!args.projectCatalogProvider?.browseDirectories) { + sendRequired(peer, "project_browse_result", { + ok: false, + message: "Project browsing is not available from this machine.", + }, requestId); + return; + } + try { + const result = await args.projectCatalogProvider.browseDirectories(payload ?? {}); + sendRequired(peer, "project_browse_result", { ok: true, result }, requestId); + } catch (error) { + sendRequired(peer, "project_browse_result", { + ok: false, + message: error instanceof Error ? error.message : String(error), + }, requestId); + } + } + + async function handleProjectDefaultParentDirRequest( + peer: PeerState, + requestId: string | null | undefined, + ): Promise { + if (!args.projectCatalogProvider?.getDefaultParentDir) { + sendRequired(peer, "project_default_parent_dir", { + ok: false, + message: "Default project directory is not available from this machine.", + }, requestId); + return; + } + try { + const parentDir = await args.projectCatalogProvider.getDefaultParentDir(); + sendRequired(peer, "project_default_parent_dir", { ok: true, parentDir }, requestId); + } catch (error) { + sendRequired(peer, "project_default_parent_dir", { + ok: false, + message: error instanceof Error ? error.message : String(error), + }, requestId); + } + } + + async function handleProjectActionRequest( + peer: PeerState, + requestId: string | null | undefined, + resultType: "project_open_result" | "project_create_result" | "project_clone_result", + unavailableMessage: string, + payload: TPayload, + action: ((payload: TPayload) => Promise) | undefined, + ): Promise { + if (!action) { + sendRequired(peer, resultType, { + ok: false, + message: unavailableMessage, + }, requestId); + return; + } + try { + const project = await action(payload); + sendRequired(peer, resultType, { ok: true, project }, requestId); + if (args.projectCatalogProvider) { + broadcastProjectCatalogToConnectedPeers(await buildProjectCatalogPayload()); + } + } catch (error) { + sendRequired(peer, resultType, { + ok: false, + message: error instanceof Error ? error.message : String(error), + }, requestId); + } + } + + async function handleProjectListMyGitHubReposRequest( + peer: PeerState, + requestId: string | null | undefined, + payload: ListMyGitHubReposInput | null, + ): Promise { + if (!args.projectCatalogProvider?.listMyGitHubRepos) { + sendRequired(peer, "project_list_my_github_repos_result", { + ok: false, + message: "GitHub repository listing is not available from this machine.", + }, requestId); + return; + } + try { + const result = await args.projectCatalogProvider.listMyGitHubRepos(payload ?? {}); + sendRequired(peer, "project_list_my_github_repos_result", { ok: true, result }, requestId); + } catch (error) { + sendRequired(peer, "project_list_my_github_repos_result", { + ok: false, + message: error instanceof Error ? error.message : String(error), + }, requestId); + } + } + function buildBrainStatus(): SyncBrainStatusPayload { const brainMetadata = readBrainMetadata(); if (disposed) { @@ -3395,6 +3523,14 @@ export function createSyncHostService(args: SyncHostServiceArgs) { lastPort: peer.remotePort, }); const projectCatalog = await buildProjectCatalogPayload(); + const projectActionsEnabled = Boolean( + args.projectCatalogProvider?.browseDirectories + && args.projectCatalogProvider.getDefaultParentDir + && args.projectCatalogProvider.openProject + && args.projectCatalogProvider.createProject + && args.projectCatalogProvider.cloneProject + && args.projectCatalogProvider.listMyGitHubRepos, + ); send(peer.ws, "hello_ok", buildSyncHostHelloOkPayload({ peer: hello.peer, brain: readBrainMetadata(), @@ -3404,6 +3540,7 @@ export function createSyncHostService(args: SyncHostServiceArgs) { pollIntervalMs, projectCatalog, projectCatalogEnabled: Boolean(args.projectCatalogProvider), + projectActionsEnabled, remoteCommandSupportedActions: remoteCommandService.getSupportedActions(), remoteCommandDescriptors: remoteCommandService.getDescriptors(), localCommandDescriptors: localPresenceCommandDescriptors, @@ -3439,6 +3576,51 @@ export function createSyncHostService(args: SyncHostServiceArgs) { await handleProjectSwitchRequest(peer, envelope.requestId, envelope.payload as SyncProjectSwitchRequestPayload); break; } + case "project_browse_request": { + await handleProjectBrowseRequest(peer, envelope.requestId, envelope.payload as ProjectBrowseInput); + break; + } + case "project_default_parent_dir_request": { + await handleProjectDefaultParentDirRequest(peer, envelope.requestId); + break; + } + case "project_open_request": { + await handleProjectActionRequest( + peer, + envelope.requestId, + "project_open_result", + "Opening projects is not available from this machine.", + (envelope.payload ?? {}) as SyncProjectOpenRequestPayload, + args.projectCatalogProvider?.openProject, + ); + break; + } + case "project_create_request": { + await handleProjectActionRequest( + peer, + envelope.requestId, + "project_create_result", + "Creating projects is not available from this machine.", + (envelope.payload ?? {}) as CreateProjectInput, + args.projectCatalogProvider?.createProject, + ); + break; + } + case "project_clone_request": { + await handleProjectActionRequest( + peer, + envelope.requestId, + "project_clone_result", + "Cloning projects is not available from this machine.", + (envelope.payload ?? {}) as CloneProjectInput, + args.projectCatalogProvider?.cloneProject, + ); + break; + } + case "project_list_my_github_repos_request": { + await handleProjectListMyGitHubReposRequest(peer, envelope.requestId, envelope.payload as ListMyGitHubReposInput); + break; + } case "heartbeat": { const payload = envelope.payload as { kind?: string; sentAt?: string } | null; if (payload?.kind === "ping") { diff --git a/apps/ade-cli/src/services/sync/syncService.ts b/apps/ade-cli/src/services/sync/syncService.ts index 2b5bb5f62..87f7245fb 100644 --- a/apps/ade-cli/src/services/sync/syncService.ts +++ b/apps/ade-cli/src/services/sync/syncService.ts @@ -8,9 +8,6 @@ import type { SyncDeviceRuntimeState, SyncGetStatusArgs, SyncPairingConnectInfo, - SyncProjectCatalogPayload, - SyncProjectSwitchRequestPayload, - SyncProjectSwitchResultPayload, SyncRoleSnapshot, SyncTailnetDiscoveryStatus, SyncTransferBlocker, @@ -54,6 +51,7 @@ import { SYNC_TAILNET_DISCOVERY_SERVICE_NAME, SYNC_TAILNET_DISCOVERY_SERVICE_PORT, type SyncHostService, + type SyncProjectCatalogProvider, type SyncRuntimeKind, } from "./syncHostService"; import { createSyncPairingStore } from "./syncPairingStore"; @@ -134,14 +132,7 @@ type SyncServiceArgs = { * connected iOS peers. */ notificationEventBus?: NotificationEventBus | null; - projectCatalogProvider?: { - listProjects: () => Promise; - prepareProjectConnection: (args: SyncProjectSwitchRequestPayload) => Promise; - completeProjectConnection?: ( - args: SyncProjectSwitchRequestPayload, - result: SyncProjectSwitchResultPayload, - ) => Promise; - }; + projectCatalogProvider?: SyncProjectCatalogProvider; remoteCommandExecutor?: Pick; /** * Lazy accessor for the model picker store. iOS uses the `modelPicker.*` diff --git a/apps/desktop/src/main/main.ts b/apps/desktop/src/main/main.ts index 139dcc7db..688c6709c 100644 --- a/apps/desktop/src/main/main.ts +++ b/apps/desktop/src/main/main.ts @@ -83,6 +83,7 @@ import { upsertProjectRow, } from "./services/projects/projectService"; import { inspectRecentProject, type RecentProjectInspection } from "./services/projects/recentProjectSummary"; +import { browseProjectDirectories } from "./services/projects/projectBrowserService"; import { resolveMobileProjectIconDataUrl } from "./services/projects/projectIconThumbnail"; import { normalizeStartupProjectState, resolveStartupProject } from "./services/projects/startupProjectResolver"; import { createAdeProjectService } from "./services/projects/adeProjectService"; @@ -92,14 +93,19 @@ import { resolveAdeLayout } from "../shared/adeLayout"; import type { OpenProjectBinding, AppNavigationRequest, + CloneProjectInput, + CreateProjectInput, LaneDeleteProgress, LaneLinearIssue, LaneSummary, + ListMyGitHubReposInput, PortLease, PrEventPayload, + ProjectBrowseInput, ProjectInfo, PtyDataEvent, SyncMobileProjectSummary, + SyncProjectOpenRequestPayload, SyncPeerConnectionState, SyncProjectConnectionPayload, SyncProjectSwitchRequestPayload, @@ -3603,6 +3609,17 @@ app.whenReady().then(async () => { listProjects: listMobileSyncProjects, prepareProjectConnection: prepareMobileSyncProjectConnection, completeProjectConnection: completeMobileSyncProjectConnection, + browseDirectories: async (input: ProjectBrowseInput) => + browseProjectDirectories(input), + getDefaultParentDir: async () => + projectScaffoldService.getDefaultParentDir(readGlobalState(globalStatePath).recentProjects ?? []), + openProject: openMobileSyncProject, + createProject: (input: CreateProjectInput) => + createMobileSyncProject(input, projectScaffoldService), + cloneProject: (input: CloneProjectInput) => + cloneMobileSyncProject(input, projectScaffoldService), + listMyGitHubRepos: async (input: ListMyGitHubReposInput) => + projectScaffoldService.listMyGitHubRepos(input), }, onStatusChanged: (snapshot) => { const normalizedProjectRoot = normalizeProjectRoot(projectRoot); @@ -4982,6 +4999,59 @@ app.whenReady().then(async () => { return { projects }; } + function recentProjectInspectionForRoot(rootPath: string): RecentProjectInspection | null { + const normalizedRoot = normalizeProjectRoot(rootPath); + return (readGlobalState(globalStatePath).recentProjects ?? []) + .map(inspectRecentProject) + .find((entry) => normalizeProjectRoot(entry.summary.rootPath) === normalizedRoot) ?? null; + } + + async function resolveMobileSyncProjectRoot(rootPath: string | null | undefined): Promise { + const requestedRoot = typeof rootPath === "string" ? rootPath.trim() : ""; + if (!requestedRoot) { + throw new Error("Project path is required."); + } + if (!fs.existsSync(requestedRoot)) { + throw new Error("Project is no longer available on this machine."); + } + try { + return normalizeProjectRoot(await resolveRepoRoot(requestedRoot)); + } catch { + throw new Error("Choose a Git repository folder."); + } + } + + async function mobileProjectSummaryForRoot(rootPath: string | null | undefined): Promise { + const normalizedRoot = await resolveMobileSyncProjectRoot(rootPath); + const ctx = await ensureProjectContextForMobileSync(normalizedRoot); + return await mobileProjectSummaryForContext( + ctx, + recentProjectInspectionForRoot(normalizedRoot), + ); + } + + async function openMobileSyncProject( + input: SyncProjectOpenRequestPayload, + ): Promise { + return await mobileProjectSummaryForRoot(input.rootPath); + } + + async function createMobileSyncProject( + input: CreateProjectInput, + scaffoldService: ReturnType, + ): Promise { + const result = await scaffoldService.createLocalProject(input); + return await mobileProjectSummaryForRoot(result.rootPath); + } + + async function cloneMobileSyncProject( + input: CloneProjectInput, + scaffoldService: ReturnType, + ): Promise { + const result = await scaffoldService.cloneRepository(input); + return await mobileProjectSummaryForRoot(result.rootPath); + } + async function ensureProjectContextForMobileSync(projectRoot: string): Promise { const normalizedRoot = normalizeProjectRoot(projectRoot); const existing = projectContexts.get(normalizedRoot); diff --git a/apps/desktop/src/main/services/sync/syncHostService.test.ts b/apps/desktop/src/main/services/sync/syncHostService.test.ts index 3bfa1b6bd..0cdf58c96 100644 --- a/apps/desktop/src/main/services/sync/syncHostService.test.ts +++ b/apps/desktop/src/main/services/sync/syncHostService.test.ts @@ -694,6 +694,45 @@ describe.skipIf(!isCrsqliteAvailable())("syncHostService", () => { isCached: false, isOpen: false, }; + const browseResult = { + inputPath: "~/Projects", + resolvedPath: "/Users/admin/Projects", + directoryPath: "/Users/admin/Projects", + parentPath: "/Users/admin", + exactDirectoryPath: "/Users/admin/Projects", + openableProjectRoot: null, + entries: [ + { + name: "ADE", + fullPath: projectRoot, + isGitRepo: true, + }, + ], + }; + const openedProject = { + ...project, + id: "project-opened", + rootPath: path.join(projectRoot, "opened"), + displayName: "Opened", + isOpen: true, + isCached: true, + }; + const createdProject = { + ...project, + id: "project-created", + rootPath: path.join(projectRoot, "created"), + displayName: "Created", + isOpen: true, + isCached: true, + }; + const clonedProject = { + ...project, + id: "project-cloned", + rootPath: path.join(projectRoot, "cloned"), + displayName: "Cloned", + isOpen: true, + isCached: true, + }; const connection = { authKind: "bootstrap" as const, token: "project-bootstrap-token", @@ -715,6 +754,26 @@ describe.skipIf(!isCrsqliteAvailable())("syncHostService", () => { connection, })), completeProjectConnection: vi.fn(async () => {}), + browseDirectories: vi.fn(async () => browseResult), + getDefaultParentDir: vi.fn(async () => "/Users/admin/Projects"), + openProject: vi.fn(async () => openedProject), + createProject: vi.fn(async () => createdProject), + cloneProject: vi.fn(async () => clonedProject), + listMyGitHubRepos: vi.fn(async () => ({ + repos: [ + { + owner: "ade", + name: "mobile", + fullName: "ade/mobile", + isPrivate: true, + pushedAt: "2026-04-22T12:00:00.000Z", + defaultBranch: "main", + htmlUrl: "https://github.com/ade/mobile", + cloneUrl: "https://github.com/ade/mobile.git", + sshUrl: "git@github.com:ade/mobile.git", + }, + ], + })), }; const host = createSyncHostService({ @@ -773,13 +832,27 @@ describe.skipIf(!isCrsqliteAvailable())("syncHostService", () => { platform: "iOS", deviceType: "phone", }); + const observerClient = await connectClient({ + port, + token: host.getBootstrapToken(), + deviceId: "ios-phone-2", + deviceName: "Arul iPad", + siteId: "ios-site-2", + dbVersion: 0, + platform: "iOS", + deviceType: "phone", + }); const helloPayload = client.helloOk.payload as { projects?: unknown[]; - features: { projectCatalog?: { enabled: boolean } }; + features: { + projectCatalog?: { enabled: boolean }; + projectActions?: { enabled: boolean }; + }; }; expect(helloPayload.projects).toEqual([project]); expect(helloPayload.features.projectCatalog?.enabled).toBe(true); + expect(helloPayload.features.projectActions?.enabled).toBe(true); client.ws.send(encodeSyncEnvelope({ type: "project_catalog_request", @@ -816,6 +889,103 @@ describe.skipIf(!isCrsqliteAvailable())("syncHostService", () => { }, switchResult.payload); }); + client.ws.send(encodeSyncEnvelope({ + type: "project_browse_request", + requestId: "browse-1", + payload: { + partialPath: "~/Projects", + limit: 20, + }, + })); + const browse = await client.queue.next("project_browse_result"); + expect(browse.requestId).toBe("browse-1"); + expect(projectCatalogProvider.browseDirectories).toHaveBeenCalledWith({ + partialPath: "~/Projects", + limit: 20, + }); + expect(browse.payload).toEqual({ ok: true, result: browseResult }); + + client.ws.send(encodeSyncEnvelope({ + type: "project_default_parent_dir_request", + requestId: "parent-1", + payload: {}, + })); + const defaultParent = await client.queue.next("project_default_parent_dir"); + expect(defaultParent.requestId).toBe("parent-1"); + expect(defaultParent.payload).toEqual({ ok: true, parentDir: "/Users/admin/Projects" }); + + client.ws.send(encodeSyncEnvelope({ + type: "project_open_request", + requestId: "open-1", + payload: { rootPath: openedProject.rootPath }, + })); + const openResult = await client.queue.next("project_open_result"); + expect(openResult.requestId).toBe("open-1"); + expect(projectCatalogProvider.openProject).toHaveBeenCalledWith({ rootPath: openedProject.rootPath }); + expect(openResult.payload).toEqual({ ok: true, project: openedProject }); + expect((await client.queue.next("project_catalog")).payload).toEqual({ projects: [project] }); + expect((await observerClient.queue.next("project_catalog")).payload).toEqual({ projects: [project] }); + + client.ws.send(encodeSyncEnvelope({ + type: "project_create_request", + requestId: "create-1", + payload: { name: "Created", parentDir: "/Users/admin/Projects" }, + })); + const createResult = await client.queue.next("project_create_result"); + expect(createResult.requestId).toBe("create-1"); + expect(projectCatalogProvider.createProject).toHaveBeenCalledWith({ + name: "Created", + parentDir: "/Users/admin/Projects", + }); + expect(createResult.payload).toEqual({ ok: true, project: createdProject }); + await client.queue.next("project_catalog"); + + client.ws.send(encodeSyncEnvelope({ + type: "project_clone_request", + requestId: "clone-1", + payload: { + url: "https://github.com/ade/mobile.git", + name: "mobile", + parentDir: "/Users/admin/Projects", + }, + })); + const cloneResult = await client.queue.next("project_clone_result"); + expect(cloneResult.requestId).toBe("clone-1"); + expect(projectCatalogProvider.cloneProject).toHaveBeenCalledWith({ + url: "https://github.com/ade/mobile.git", + name: "mobile", + parentDir: "/Users/admin/Projects", + }); + expect(cloneResult.payload).toEqual({ ok: true, project: clonedProject }); + await client.queue.next("project_catalog"); + + client.ws.send(encodeSyncEnvelope({ + type: "project_list_my_github_repos_request", + requestId: "repos-1", + payload: { search: "mobile" }, + })); + const reposResult = await client.queue.next("project_list_my_github_repos_result"); + expect(reposResult.requestId).toBe("repos-1"); + expect(projectCatalogProvider.listMyGitHubRepos).toHaveBeenCalledWith({ search: "mobile" }); + expect(reposResult.payload).toEqual({ + ok: true, + result: { + repos: [ + { + owner: "ade", + name: "mobile", + fullName: "ade/mobile", + isPrivate: true, + pushedAt: "2026-04-22T12:00:00.000Z", + defaultBranch: "main", + htmlUrl: "https://github.com/ade/mobile", + cloneUrl: "https://github.com/ade/mobile.git", + sshUrl: "git@github.com:ade/mobile.git", + }, + ], + }, + }); + projectCatalogProvider.listProjects.mockResolvedValueOnce({ projects: [{ ...project, id: "project-row-2", isOpen: true }], }); @@ -826,6 +996,7 @@ describe.skipIf(!isCrsqliteAvailable())("syncHostService", () => { }); await client.close(); + await observerClient.close(); }); it("chunks oversized mobile project catalog responses", async () => { diff --git a/apps/desktop/src/shared/types/sync.ts b/apps/desktop/src/shared/types/sync.ts index 7035a448e..948396a77 100644 --- a/apps/desktop/src/shared/types/sync.ts +++ b/apps/desktop/src/shared/types/sync.ts @@ -1,4 +1,12 @@ import type { AgentChatEventEnvelope, AgentChatPermissionMode } from "./chat"; +import type { + CloneProjectInput, + CreateProjectInput, + ListMyGitHubReposInput, + ListMyGitHubReposResult, + ProjectBrowseInput, + ProjectBrowseResult, +} from "./core"; import type { PtySendToSessionResult, TerminalSessionSummary } from "./sessions"; export type SyncScalarBytes = { @@ -234,6 +242,9 @@ export type SyncFeatureFlags = { projectCatalog: { enabled: boolean; }; + projectActions: { + enabled: boolean; + }; changesetAck: { enabled: boolean; }; @@ -301,6 +312,34 @@ export type SyncProjectSwitchResultPayload = { connection?: SyncProjectConnectionPayload | null; }; +export type SyncProjectOpenRequestPayload = { + rootPath?: string | null; +}; + +export type SyncProjectBrowseResultPayload = { + ok: boolean; + message?: string | null; + result?: ProjectBrowseResult | null; +}; + +export type SyncProjectDefaultParentDirPayload = { + ok: boolean; + message?: string | null; + parentDir?: string | null; +}; + +export type SyncProjectActionResultPayload = { + ok: boolean; + message?: string | null; + project?: SyncMobileProjectSummary | null; +}; + +export type SyncProjectListMyGitHubReposResultPayload = { + ok: boolean; + message?: string | null; + result?: ListMyGitHubReposResult | null; +}; + export type SyncHelloAuth = | { kind: "bootstrap"; token: string } | { kind: "paired"; deviceId: string; secret: string }; @@ -1069,6 +1108,18 @@ export type SyncProjectCatalogEnvelope = SyncEnvelopeWithPayload<"project_catalo export type SyncProjectCatalogChunkEnvelope = SyncEnvelopeWithPayload<"project_catalog_chunk", SyncProjectCatalogChunkPayload>; export type SyncProjectSwitchRequestEnvelope = SyncEnvelopeWithPayload<"project_switch_request", SyncProjectSwitchRequestPayload>; export type SyncProjectSwitchResultEnvelope = SyncEnvelopeWithPayload<"project_switch_result", SyncProjectSwitchResultPayload>; +export type SyncProjectBrowseRequestEnvelope = SyncEnvelopeWithPayload<"project_browse_request", ProjectBrowseInput>; +export type SyncProjectBrowseResultEnvelope = SyncEnvelopeWithPayload<"project_browse_result", SyncProjectBrowseResultPayload>; +export type SyncProjectDefaultParentDirRequestEnvelope = SyncEnvelopeWithPayload<"project_default_parent_dir_request", Record>; +export type SyncProjectDefaultParentDirEnvelope = SyncEnvelopeWithPayload<"project_default_parent_dir", SyncProjectDefaultParentDirPayload>; +export type SyncProjectOpenRequestEnvelope = SyncEnvelopeWithPayload<"project_open_request", SyncProjectOpenRequestPayload>; +export type SyncProjectOpenResultEnvelope = SyncEnvelopeWithPayload<"project_open_result", SyncProjectActionResultPayload>; +export type SyncProjectCreateRequestEnvelope = SyncEnvelopeWithPayload<"project_create_request", CreateProjectInput>; +export type SyncProjectCreateResultEnvelope = SyncEnvelopeWithPayload<"project_create_result", SyncProjectActionResultPayload>; +export type SyncProjectCloneRequestEnvelope = SyncEnvelopeWithPayload<"project_clone_request", CloneProjectInput>; +export type SyncProjectCloneResultEnvelope = SyncEnvelopeWithPayload<"project_clone_result", SyncProjectActionResultPayload>; +export type SyncProjectListMyGitHubReposRequestEnvelope = SyncEnvelopeWithPayload<"project_list_my_github_repos_request", ListMyGitHubReposInput>; +export type SyncProjectListMyGitHubReposResultEnvelope = SyncEnvelopeWithPayload<"project_list_my_github_repos_result", SyncProjectListMyGitHubReposResultPayload>; export type SyncPairingRequestEnvelope = SyncEnvelopeWithPayload<"pairing_request", SyncPairingRequestPayload>; export type SyncPairingResultEnvelope = SyncEnvelopeWithPayload<"pairing_result", SyncPairingResultPayload>; export type SyncChangesetBatchEnvelope = SyncEnvelopeWithPayload<"changeset_batch", SyncChangesetBatchPayload>; @@ -1119,6 +1170,18 @@ export type SyncEnvelope = | SyncProjectCatalogChunkEnvelope | SyncProjectSwitchRequestEnvelope | SyncProjectSwitchResultEnvelope + | SyncProjectBrowseRequestEnvelope + | SyncProjectBrowseResultEnvelope + | SyncProjectDefaultParentDirRequestEnvelope + | SyncProjectDefaultParentDirEnvelope + | SyncProjectOpenRequestEnvelope + | SyncProjectOpenResultEnvelope + | SyncProjectCreateRequestEnvelope + | SyncProjectCreateResultEnvelope + | SyncProjectCloneRequestEnvelope + | SyncProjectCloneResultEnvelope + | SyncProjectListMyGitHubReposRequestEnvelope + | SyncProjectListMyGitHubReposResultEnvelope | SyncPairingRequestEnvelope | SyncPairingResultEnvelope | SyncChangesetBatchEnvelope diff --git a/apps/ios/ADE.xcodeproj/project.pbxproj b/apps/ios/ADE.xcodeproj/project.pbxproj index fa3f277ad..50e4ae4d0 100644 --- a/apps/ios/ADE.xcodeproj/project.pbxproj +++ b/apps/ios/ADE.xcodeproj/project.pbxproj @@ -56,6 +56,7 @@ 26C8992F8D661707E9360503 /* Database.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51942BAC0965C7D0CCE6E8B8 /* Database.swift */; }; 28CFE3D489EA1B208D231519 /* SyncService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 66B5024B0A05F3D9754101F1 /* SyncService.swift */; }; 4DC17C7C8E82D387043B01FD /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 73B17472FAC08845853BC6B4 /* ContentView.swift */; }; + B2B52EED21B04E9700000002 /* RemoteProjectAddSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = B2B52EED21B04E9700000001 /* RemoteProjectAddSheet.swift */; }; 56223CC3AF5A01B710CDC4CF /* WorkTabView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C6ECFA9D57E70E57A60E8AB /* WorkTabView.swift */; }; B10000000000000000000020 /* WorkBrowserHelpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = A10000000000000000000020 /* WorkBrowserHelpers.swift */; }; E10000000000000000000022 /* WorkRootScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = D10000000000000000000022 /* WorkRootScreen.swift */; }; @@ -348,6 +349,7 @@ 5EE4D463D21266B62B422D11 /* PRsTabView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = PRsTabView.swift; path = ADE/Views/PRsTabView.swift; sourceTree = ""; }; 66B5024B0A05F3D9754101F1 /* SyncService.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = SyncService.swift; path = ADE/Services/SyncService.swift; sourceTree = ""; }; 73B17472FAC08845853BC6B4 /* ContentView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = ContentView.swift; path = ADE/App/ContentView.swift; sourceTree = ""; }; + B2B52EED21B04E9700000001 /* RemoteProjectAddSheet.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = RemoteProjectAddSheet.swift; path = ADE/App/RemoteProjectAddSheet.swift; sourceTree = ""; }; 8943C47805A871A4E4A4BF68 /* Assets.xcassets */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = folder.assetcatalog; name = Assets.xcassets; path = ADE/Assets.xcassets; sourceTree = ""; }; 9270CF8A67F3FA79089F39C1 /* LanesTabView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = LanesTabView.swift; path = ADE/Views/LanesTabView.swift; sourceTree = ""; }; A10000000000000000000002 /* LaneAttachSheet.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = LaneAttachSheet.swift; path = ADE/Views/Lanes/LaneAttachSheet.swift; sourceTree = ""; }; @@ -700,6 +702,7 @@ children = ( C9411193AF56B236BA32EFF5 /* ADEApp.swift */, 73B17472FAC08845853BC6B4 /* ContentView.swift */, + B2B52EED21B04E9700000001 /* RemoteProjectAddSheet.swift */, AA5300000000000000000011 /* AppDelegate.swift */, AA5300000000000000000012 /* DeepLinkRouter.swift */, K20000000000000000000002 /* DeepLinkURLParsing.swift */, @@ -1040,6 +1043,7 @@ F2A1C9D8456E7B3C1D2E4F90 /* FilesCodeSupport.swift in Sources */, 1558E44876BBE931C02D5640 /* ADEApp.swift in Sources */, 4DC17C7C8E82D387043B01FD /* ContentView.swift in Sources */, + B2B52EED21B04E9700000002 /* RemoteProjectAddSheet.swift in Sources */, FBEEF09EFB4911FEAC6A7E87 /* RemoteModels.swift in Sources */, 26C8992F8D661707E9360503 /* Database.swift in Sources */, 0375D32BA5870617FA1758C6 /* KeychainService.swift in Sources */, diff --git a/apps/ios/ADE/App/ContentView.swift b/apps/ios/ADE/App/ContentView.swift index 164b4d0d9..72bbe8e53 100644 --- a/apps/ios/ADE/App/ContentView.swift +++ b/apps/ios/ADE/App/ContentView.swift @@ -149,6 +149,7 @@ struct ContentView: View { private struct ProjectHomeView: View { @EnvironmentObject private var syncService: SyncService + @State private var addProjectSheetPresented = false private var attachedMachineLabel: String { let trimmedHost = syncService.hostName?.trimmingCharacters(in: .whitespacesAndNewlines) @@ -210,6 +211,10 @@ private struct ProjectHomeView: View { .navigationTitle("") .navigationBarTitleDisplayMode(.inline) .toolbar(.hidden, for: .navigationBar) + .sheet(isPresented: $addProjectSheetPresented) { + RemoteProjectAddSheet() + .environmentObject(syncService) + } } } @@ -284,6 +289,12 @@ private struct ProjectHomeView: View { .foregroundStyle(ADEColor.textMuted) .tracking(0.8) + if syncService.canRunRemoteProjectActions { + ProjectHomeAddProjectRow { + addProjectSheetPresented = true + } + } + if !canShowProjectRows || syncService.projects.isEmpty { emptyProjects } else { @@ -379,6 +390,45 @@ private struct ProjectHomeView: View { } } +private struct ProjectHomeAddProjectRow: View { + let action: () -> Void + + var body: some View { + Button(action: action) { + HStack(spacing: 12) { + ZStack { + RoundedRectangle(cornerRadius: 7, style: .continuous) + .fill(ADEColor.accent.opacity(0.16)) + .frame(width: 38, height: 38) + Image(systemName: "plus") + .font(.system(size: 16, weight: .bold)) + .foregroundStyle(ADEColor.accent) + } + + Text("Add project") + .font(.system(.subheadline, design: .rounded).weight(.semibold)) + .foregroundStyle(ADEColor.textPrimary) + + Spacer(minLength: 8) + + Image(systemName: "chevron.right") + .font(.system(size: 13, weight: .semibold)) + .foregroundStyle(ADEColor.accent.opacity(0.70)) + } + .padding(12) + .frame(maxWidth: .infinity, alignment: .leading) + .background(ADEColor.cardBackground.opacity(0.72), in: RoundedRectangle(cornerRadius: 8, style: .continuous)) + .overlay( + RoundedRectangle(cornerRadius: 8, style: .continuous) + .stroke(ADEColor.accent.opacity(0.40), lineWidth: 1) + ) + } + .buttonStyle(.plain) + .accessibilityLabel("Add project") + .accessibilityHint("Open, create, or clone a project on the connected machine.") + } +} + private struct ProjectHomeIcon: View { let iconDataUrl: String? let isActive: Bool diff --git a/apps/ios/ADE/App/RemoteProjectAddSheet.swift b/apps/ios/ADE/App/RemoteProjectAddSheet.swift new file mode 100644 index 000000000..29c83f069 --- /dev/null +++ b/apps/ios/ADE/App/RemoteProjectAddSheet.swift @@ -0,0 +1,893 @@ +import SwiftUI + +private enum RemoteProjectAddScreen: Equatable { + case chooser + case open + case create + case clone + case parentPicker(ProjectParentPickerTarget) + case success(RemoteProjectActionOutcome) +} + +private enum ProjectParentPickerTarget: Equatable { + case create + case clone +} + +private struct RemoteProjectActionOutcome: Equatable { + var verb: String + var project: MobileProjectSummary +} + +private enum RemoteProjectChoiceIcon { + case system(String) + case asset(String) +} + +struct RemoteProjectAddSheet: View { + @EnvironmentObject private var syncService: SyncService + @Environment(\.dismiss) private var dismiss + + @State private var screen: RemoteProjectAddScreen = .chooser + @State private var createName = "" + @State private var createParentDir = "" + @State private var cloneUrl = "" + @State private var cloneName = "" + @State private var cloneParentDir = "" + @State private var cloneTab: ProjectCloneTab = .url + @State private var browsePath = "" + @State private var actionError: String? + @State private var isSubmitting = false + + private var title: String { + switch screen { + case .chooser: return "Add a project" + case .open: return "Open a project" + case .create: return "Create a new project" + case .clone: return "Clone from GitHub" + case .parentPicker: return "Choose parent directory" + case .success(let outcome): return "\(outcome.verb)!" + } + } + + var body: some View { + NavigationStack { + ZStack { + ADEColor.pageBackground.ignoresSafeArea() + ScrollView { + VStack(spacing: 16) { + switch screen { + case .chooser: + projectChooser + case .open: + RemoteProjectDirectoryBrowser( + mode: .open, + path: $browsePath, + isSubmitting: isSubmitting, + onCancel: { screen = .chooser }, + onChoose: { rootPath in + guard !isSubmitting else { return } + Task { await openProject(rootPath: rootPath) } + } + ) + case .create: + RemoteProjectCreateForm( + name: $createName, + parentDir: $createParentDir, + isSubmitting: isSubmitting, + onChooseParent: { screen = .parentPicker(.create) }, + onCancel: { screen = .chooser }, + onCreate: { Task { await createProject() } } + ) + case .clone: + RemoteProjectCloneForm( + tab: $cloneTab, + url: $cloneUrl, + name: $cloneName, + parentDir: $cloneParentDir, + isSubmitting: isSubmitting, + onChooseParent: { screen = .parentPicker(.clone) }, + onCancel: { screen = .chooser }, + onClone: { Task { await cloneProject() } } + ) + case .parentPicker(let target): + RemoteProjectDirectoryBrowser( + mode: .parent, + path: target == .create ? $createParentDir : $cloneParentDir, + onCancel: { screen = target == .create ? .create : .clone }, + onChoose: { directory in + switch target { + case .create: + createParentDir = directory + screen = .create + case .clone: + cloneParentDir = directory + screen = .clone + } + } + ) + case .success(let outcome): + RemoteProjectSuccessView( + outcome: outcome, + onStay: { dismiss() }, + onOpen: { + syncService.selectProject(outcome.project) + dismiss() + } + ) + } + + if let actionError { + InlineProjectNotice(message: actionError, tone: .danger) + } + } + .padding(20) + .frame(maxWidth: 560) + .frame(maxWidth: .infinity) + } + } + .navigationTitle(title) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .topBarLeading) { + if screen != .chooser { + Button { + goBack() + } label: { + Label("Back", systemImage: "chevron.left") + } + } + } + ToolbarItem(placement: .topBarTrailing) { + Button("Close") { + dismiss() + } + } + } + } + .presentationDetents([.large]) + .presentationDragIndicator(.visible) + .task { + await loadDefaultParentIfNeeded() + } + } + + private var projectChooser: some View { + VStack(spacing: 12) { + RemoteProjectChoiceCard( + icon: .system("folder"), + title: "Open", + subtitle: "a folder you have", + tint: Color(red: 0.376, green: 0.647, blue: 0.980) + ) { + actionError = nil + browsePath = createParentDir.isEmpty ? browsePath : createParentDir + screen = .open + } + + RemoteProjectChoiceCard( + icon: .system("sparkles"), + title: "Create", + subtitle: "a brand-new project", + tint: Color(red: 0.655, green: 0.545, blue: 0.980) + ) { + actionError = nil + screen = .create + } + + RemoteProjectChoiceCard( + icon: .asset("ProviderGitHub"), + title: "Clone", + subtitle: "from GitHub", + tint: Color(red: 0.204, green: 0.827, blue: 0.600) + ) { + actionError = nil + screen = .clone + } + } + } + + private func goBack() { + switch screen { + case .chooser: + break + case .open, .create, .clone, .success(_): + screen = .chooser + case .parentPicker(let target): + screen = target == .create ? .create : .clone + } + } + + private func loadDefaultParentIfNeeded() async { + guard createParentDir.isEmpty || cloneParentDir.isEmpty || browsePath.isEmpty else { return } + guard let parent = try? await syncService.machineProjectDefaultParentDir() else { return } + if createParentDir.isEmpty { createParentDir = parent } + if cloneParentDir.isEmpty { cloneParentDir = parent } + if browsePath.isEmpty { browsePath = parent } + } + + private func openProject(rootPath: String) async { + guard !isSubmitting else { return } + isSubmitting = true + defer { isSubmitting = false } + do { + let project = try await syncService.openMachineProject(rootPath: rootPath) + actionError = nil + screen = .success(RemoteProjectActionOutcome(verb: "Opened", project: project)) + } catch { + actionError = SyncUserFacingError.message(for: error) + screen = .open + } + } + + private func createProject() async { + guard !isSubmitting else { return } + isSubmitting = true + defer { isSubmitting = false } + do { + let project = try await syncService.createMachineProject(name: createName, parentDir: createParentDir) + actionError = nil + screen = .success(RemoteProjectActionOutcome(verb: "Created", project: project)) + } catch { + actionError = SyncUserFacingError.message(for: error) + } + } + + private func cloneProject() async { + guard !isSubmitting else { return } + isSubmitting = true + defer { isSubmitting = false } + do { + let project = try await syncService.cloneMachineProject(url: cloneUrl, name: cloneName, parentDir: cloneParentDir) + actionError = nil + screen = .success(RemoteProjectActionOutcome(verb: "Cloned", project: project)) + } catch { + actionError = SyncUserFacingError.message(for: error) + } + } +} + +private struct RemoteProjectChoiceCard: View { + let icon: RemoteProjectChoiceIcon + let title: String + let subtitle: String + let tint: Color + let action: () -> Void + + var body: some View { + Button(action: action) { + HStack(spacing: 16) { + ZStack { + RoundedRectangle(cornerRadius: 8, style: .continuous) + .fill( + LinearGradient( + colors: [tint.opacity(0.30), tint.opacity(0.09)], + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + ) + .overlay( + RoundedRectangle(cornerRadius: 8, style: .continuous) + .stroke(tint.opacity(0.32), lineWidth: 1) + ) + .frame(width: 58, height: 58) + iconView + } + VStack(alignment: .leading, spacing: 5) { + Text(title.uppercased()) + .font(.system(.headline, design: .rounded).weight(.bold)) + .tracking(3) + .foregroundStyle(ADEColor.textPrimary) + Text(subtitle) + .font(.system(.footnote, design: .rounded)) + .foregroundStyle(ADEColor.textMuted) + } + Spacer(minLength: 8) + Image(systemName: "chevron.right") + .font(.system(size: 13, weight: .semibold)) + .foregroundStyle(tint.opacity(0.75)) + } + .padding(18) + .frame(maxWidth: .infinity, minHeight: 112, alignment: .leading) + .background { + RoundedRectangle(cornerRadius: 8, style: .continuous) + .fill(ADEColor.cardBackground.opacity(0.76)) + .overlay { + LinearGradient( + colors: [tint.opacity(0.14), tint.opacity(0.035), .clear], + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + .clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous)) + } + } + .overlay( + RoundedRectangle(cornerRadius: 8, style: .continuous) + .stroke(tint.opacity(0.42), lineWidth: 1) + ) + } + .buttonStyle(.plain) + .accessibilityLabel("\(title), \(subtitle)") + } + + @ViewBuilder + private var iconView: some View { + switch icon { + case .system(let symbol): + Image(systemName: symbol) + .font(.system(size: 24, weight: .semibold)) + .foregroundStyle(tint) + case .asset(let name): + Image(name) + .renderingMode(.template) + .resizable() + .scaledToFit() + .frame(width: 28, height: 28) + .foregroundStyle(tint) + } + } +} + +private enum RemoteProjectDirectoryBrowserMode { + case open + case parent +} + +private struct RemoteProjectDirectoryBrowser: View { + @EnvironmentObject private var syncService: SyncService + + let mode: RemoteProjectDirectoryBrowserMode + @Binding var path: String + let isSubmitting: Bool = false + let onCancel: () -> Void + let onChoose: (String) -> Void + + @State private var result: MobileProjectBrowseResult? + @State private var selectedPath: String? + @State private var loading = false + @State private var errorMessage: String? + + private var targetPath: String? { + switch mode { + case .open: + return selectedPath ?? result?.openableProjectRoot + case .parent: + return result?.exactDirectoryPath ?? result?.directoryPath + } + } + + var body: some View { + VStack(spacing: 12) { + HStack(spacing: 8) { + Image(systemName: "magnifyingglass") + .foregroundStyle(ADEColor.textMuted) + TextField("~/Projects", text: $path) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + .font(.system(.callout, design: .monospaced)) + .foregroundStyle(ADEColor.textPrimary) + .submitLabel(.go) + .onChange(of: path) { _, _ in + clearBrowseState() + } + .onSubmit { + Task { await load() } + } + if loading { + ProgressView() + .controlSize(.small) + } + } + .padding(12) + .background(ADEColor.cardBackground.opacity(0.72), in: RoundedRectangle(cornerRadius: 8, style: .continuous)) + .overlay( + RoundedRectangle(cornerRadius: 8, style: .continuous) + .stroke(ADEColor.border.opacity(0.75), lineWidth: 1) + ) + + if let parentPath = result?.parentPath { + DirectoryBrowserRow( + name: "Go up", + path: parentPath, + isGitRepo: false, + selected: false, + symbol: "arrow.up" + ) { + path = parentPath + } + } + + LazyVStack(spacing: 8) { + ForEach(result?.entries ?? []) { entry in + DirectoryBrowserRow( + name: entry.name, + path: entry.fullPath, + isGitRepo: entry.isGitRepo, + selected: selectedPath == entry.fullPath, + symbol: entry.isGitRepo ? "arrow.triangle.branch" : "folder" + ) { + if mode == .open && entry.isGitRepo { + selectedPath = entry.fullPath + } else { + path = entry.fullPath + } + } + } + } + + if let errorMessage { + InlineProjectNotice(message: errorMessage, tone: .danger) + } + + HStack(spacing: 10) { + Button("Cancel", action: onCancel) + .buttonStyle(ProjectSecondaryButtonStyle()) + Button(mode == .open ? "Open project" : "Use directory") { + if let targetPath { + onChoose(targetPath) + } + } + .buttonStyle(ProjectPrimaryButtonStyle()) + .disabled(targetPath == nil || loading || isSubmitting) + } + } + .task(id: path) { + try? await Task.sleep(nanoseconds: 250_000_000) + guard !Task.isCancelled else { return } + await load() + } + } + + private func load() async { + let trimmed = path.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return } + loading = true + errorMessage = nil + defer { loading = false } + do { + let nextResult = try await syncService.browseMachineProjectDirectories(partialPath: trimmed) + guard path.trimmingCharacters(in: .whitespacesAndNewlines) == trimmed else { return } + result = nextResult + if mode == .open { + selectedPath = result?.openableProjectRoot + } + } catch { + guard path.trimmingCharacters(in: .whitespacesAndNewlines) == trimmed else { return } + errorMessage = SyncUserFacingError.message(for: error) + } + } + + private func clearBrowseState() { + result = nil + selectedPath = nil + errorMessage = nil + } +} + +private struct DirectoryBrowserRow: View { + let name: String + let path: String + let isGitRepo: Bool + let selected: Bool + let symbol: String + let action: () -> Void + + var body: some View { + Button(action: action) { + HStack(spacing: 12) { + Image(systemName: symbol) + .font(.system(size: 14, weight: .semibold)) + .foregroundStyle(isGitRepo ? ADEColor.accent : ADEColor.textSecondary) + .frame(width: 32, height: 32) + .background(ADEColor.recessedBackground, in: RoundedRectangle(cornerRadius: 8, style: .continuous)) + VStack(alignment: .leading, spacing: 3) { + Text(name) + .font(.system(.subheadline, design: .rounded).weight(.semibold)) + .foregroundStyle(ADEColor.textPrimary) + .lineLimit(1) + Text(path) + .font(.system(.caption2, design: .monospaced)) + .foregroundStyle(ADEColor.textMuted) + .lineLimit(1) + } + Spacer(minLength: 8) + if isGitRepo { + Text("repo") + .font(.system(.caption2, design: .rounded).weight(.bold)) + .foregroundStyle(ADEColor.accent) + .padding(.horizontal, 7) + .padding(.vertical, 4) + .background(ADEColor.accent.opacity(0.16), in: Capsule()) + } + } + .padding(12) + .frame(maxWidth: .infinity, alignment: .leading) + .background(ADEColor.cardBackground.opacity(selected ? 0.95 : 0.62), in: RoundedRectangle(cornerRadius: 8, style: .continuous)) + .overlay( + RoundedRectangle(cornerRadius: 8, style: .continuous) + .stroke(selected ? ADEColor.accent.opacity(0.70) : ADEColor.border.opacity(0.70), lineWidth: 1) + ) + } + .buttonStyle(.plain) + } +} + +private struct RemoteProjectCreateForm: View { + @Binding var name: String + @Binding var parentDir: String + let isSubmitting: Bool + let onChooseParent: () -> Void + let onCancel: () -> Void + let onCreate: () -> Void + + private var trimmedName: String { name.trimmingCharacters(in: .whitespacesAndNewlines) } + private var canCreate: Bool { + !trimmedName.isEmpty + && !trimmedName.hasPrefix(".") + && !trimmedName.contains("/") + && !trimmedName.contains("\\") + && !parentDir.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + } + + var body: some View { + VStack(alignment: .leading, spacing: 14) { + ProjectField(label: "PROJECT NAME") { + TextField("my-new-project", text: $name) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + } + ProjectField(label: "PARENT DIRECTORY") { + HStack(spacing: 8) { + TextField("/Users/admin/Projects", text: $parentDir) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + .font(.system(.callout, design: .monospaced)) + Button(action: onChooseParent) { + Image(systemName: "folder") + } + .buttonStyle(ProjectIconButtonStyle()) + } + } + if !parentDir.isEmpty && !trimmedName.isEmpty { + InlineProjectNotice(message: projectJoinPath(parentDir, trimmedName), tone: .muted) + } + HStack(spacing: 10) { + Button("Cancel", action: onCancel) + .buttonStyle(ProjectSecondaryButtonStyle()) + Button("Create", action: onCreate) + .buttonStyle(ProjectPrimaryButtonStyle()) + .disabled(!canCreate || isSubmitting) + } + .frame(maxWidth: .infinity, alignment: .trailing) + } + } +} + +private enum ProjectCloneTab: String, CaseIterable, Identifiable { + case url + case repos + + var id: String { rawValue } + var label: String { self == .url ? "URL" : "My repos" } +} + +private struct RemoteProjectCloneForm: View { + @EnvironmentObject private var syncService: SyncService + + @Binding var tab: ProjectCloneTab + @Binding var url: String + @Binding var name: String + @Binding var parentDir: String + let isSubmitting: Bool + let onChooseParent: () -> Void + let onCancel: () -> Void + let onClone: () -> Void + + @State private var repos: [MobileGitHubRepoSummary] = [] + @State private var repoSearch = "" + @State private var reposLoading = false + @State private var reposError: String? + + private var trimmedUrl: String { url.trimmingCharacters(in: .whitespacesAndNewlines) } + private var trimmedName: String { name.trimmingCharacters(in: .whitespacesAndNewlines) } + private var hasGitHubHost: Bool { + if trimmedUrl.lowercased().hasPrefix("git@github.com:") { + return true + } + guard let host = URL(string: trimmedUrl)?.host?.lowercased() else { return false } + return host == "github.com" || host.hasSuffix(".github.com") + } + private var canClone: Bool { + hasGitHubHost + && !trimmedName.isEmpty + && !parentDir.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + } + + var body: some View { + VStack(alignment: .leading, spacing: 14) { + Picker("Clone mode", selection: $tab) { + ForEach(ProjectCloneTab.allCases) { option in + Text(option.label).tag(option) + } + } + .pickerStyle(.segmented) + + if tab == .url { + urlFields + } else { + repoList + } + + ProjectField(label: "FOLDER NAME") { + TextField("repo", text: $name) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + } + ProjectField(label: "PARENT DIRECTORY") { + HStack(spacing: 8) { + TextField("/Users/admin/Projects", text: $parentDir) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + .font(.system(.callout, design: .monospaced)) + Button(action: onChooseParent) { + Image(systemName: "folder") + } + .buttonStyle(ProjectIconButtonStyle()) + } + } + if !parentDir.isEmpty && !trimmedName.isEmpty { + InlineProjectNotice(message: projectJoinPath(parentDir, trimmedName), tone: .muted) + } + HStack(spacing: 10) { + Button("Cancel", action: onCancel) + .buttonStyle(ProjectSecondaryButtonStyle()) + Button("Clone", action: onClone) + .buttonStyle(ProjectPrimaryButtonStyle()) + .disabled(!canClone || isSubmitting) + } + .frame(maxWidth: .infinity, alignment: .trailing) + } + } + + private var urlFields: some View { + ProjectField(label: "REPOSITORY URL") { + TextField("https://github.com/owner/repo", text: $url) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + .font(.system(.callout, design: .monospaced)) + .onSubmit { + fillNameFromUrlIfNeeded() + } + .onChange(of: url) { _, _ in + fillNameFromUrlIfNeeded() + } + } + } + + private var repoList: some View { + VStack(alignment: .leading, spacing: 10) { + ProjectField(label: "SEARCH REPOS") { + TextField("owner/repo", text: $repoSearch) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + .onSubmit { + Task { await loadRepos() } + } + } + if reposLoading { + ProgressView("Loading repositories...") + .font(.caption) + .foregroundStyle(ADEColor.textSecondary) + } + if let reposError { + InlineProjectNotice(message: reposError, tone: .danger) + } + LazyVStack(spacing: 8) { + ForEach(repos) { repo in + Button { + url = repo.cloneUrl + name = repo.name + tab = .url + } label: { + HStack(spacing: 10) { + Image(systemName: repo.isPrivate ? "lock" : "globe") + .foregroundStyle(ADEColor.accent) + .frame(width: 28, height: 28) + .background(ADEColor.recessedBackground, in: RoundedRectangle(cornerRadius: 7, style: .continuous)) + VStack(alignment: .leading, spacing: 3) { + Text(repo.fullName) + .font(.system(.subheadline, design: .rounded).weight(.semibold)) + .foregroundStyle(ADEColor.textPrimary) + .lineLimit(1) + Text(repo.defaultBranch) + .font(.system(.caption2, design: .monospaced)) + .foregroundStyle(ADEColor.textMuted) + } + Spacer() + Image(systemName: "chevron.right") + .foregroundStyle(ADEColor.textMuted) + } + .padding(10) + .background(ADEColor.cardBackground.opacity(0.62), in: RoundedRectangle(cornerRadius: 8, style: .continuous)) + .overlay( + RoundedRectangle(cornerRadius: 8, style: .continuous) + .stroke(ADEColor.border.opacity(0.65), lineWidth: 1) + ) + } + .buttonStyle(.plain) + } + } + } + .task(id: repoSearch) { + guard tab == .repos else { return } + try? await Task.sleep(nanoseconds: 300_000_000) + guard !Task.isCancelled else { return } + await loadRepos() + } + } + + private func loadRepos() async { + reposLoading = true + reposError = nil + do { + repos = try await syncService.listMachineGitHubRepos(search: repoSearch) + } catch { + reposError = SyncUserFacingError.message(for: error) + } + reposLoading = false + } + + private func fillNameFromUrlIfNeeded() { + guard name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty, + let derived = projectNameFromGitHubUrl(url) + else { return } + name = derived + } +} + +private struct RemoteProjectSuccessView: View { + let outcome: RemoteProjectActionOutcome + let onStay: () -> Void + let onOpen: () -> Void + + var body: some View { + VStack(spacing: 18) { + Image(systemName: "checkmark.circle.fill") + .font(.system(size: 44, weight: .semibold)) + .foregroundStyle(ADEColor.success) + VStack(spacing: 6) { + Text(outcome.project.displayName) + .font(.system(.title3, design: .rounded).weight(.bold)) + .foregroundStyle(ADEColor.textPrimary) + .multilineTextAlignment(.center) + if let rootPath = outcome.project.rootPath { + Text(rootPath) + .font(.system(.caption, design: .monospaced)) + .foregroundStyle(ADEColor.textMuted) + .lineLimit(2) + .multilineTextAlignment(.center) + } + } + HStack(spacing: 10) { + Button("Stay in projects", action: onStay) + .buttonStyle(ProjectSecondaryButtonStyle()) + Button("Open now", action: onOpen) + .buttonStyle(ProjectPrimaryButtonStyle()) + } + } + .padding(24) + .frame(maxWidth: .infinity) + .background(ADEColor.cardBackground.opacity(0.72), in: RoundedRectangle(cornerRadius: 8, style: .continuous)) + .overlay( + RoundedRectangle(cornerRadius: 8, style: .continuous) + .stroke(ADEColor.success.opacity(0.38), lineWidth: 1) + ) + } +} + +private struct ProjectField: View { + let label: String + let content: Content + + init(label: String, @ViewBuilder content: () -> Content) { + self.label = label + self.content = content() + } + + var body: some View { + VStack(alignment: .leading, spacing: 7) { + Text(label) + .font(.system(.caption2, design: .rounded).weight(.bold)) + .foregroundStyle(ADEColor.textMuted) + .tracking(0.8) + content + .padding(12) + .background(ADEColor.cardBackground.opacity(0.72), in: RoundedRectangle(cornerRadius: 8, style: .continuous)) + .overlay( + RoundedRectangle(cornerRadius: 8, style: .continuous) + .stroke(ADEColor.border.opacity(0.75), lineWidth: 1) + ) + } + } +} + +private enum InlineProjectNoticeTone { + case muted + case danger +} + +private struct InlineProjectNotice: View { + let message: String + let tone: InlineProjectNoticeTone + + var body: some View { + Text(message) + .font(.system(.caption, design: .monospaced)) + .foregroundStyle(tone == .danger ? ADEColor.danger : ADEColor.textMuted) + .lineLimit(3) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(10) + .background((tone == .danger ? ADEColor.danger : ADEColor.recessedBackground).opacity(0.12), in: RoundedRectangle(cornerRadius: 8, style: .continuous)) + } +} + +private struct ProjectPrimaryButtonStyle: ButtonStyle { + func makeBody(configuration: Configuration) -> some View { + configuration.label + .font(.system(.subheadline, design: .rounded).weight(.bold)) + .foregroundStyle(Color.white) + .padding(.horizontal, 16) + .frame(minHeight: 42) + .background(ADEColor.accent.opacity(configuration.isPressed ? 0.80 : 1), in: RoundedRectangle(cornerRadius: 8, style: .continuous)) + .opacity(configuration.isPressed ? 0.88 : 1) + } +} + +private struct ProjectSecondaryButtonStyle: ButtonStyle { + func makeBody(configuration: Configuration) -> some View { + configuration.label + .font(.system(.subheadline, design: .rounded).weight(.semibold)) + .foregroundStyle(ADEColor.textSecondary) + .padding(.horizontal, 16) + .frame(minHeight: 42) + .background(ADEColor.cardBackground.opacity(configuration.isPressed ? 0.90 : 0.62), in: RoundedRectangle(cornerRadius: 8, style: .continuous)) + .overlay( + RoundedRectangle(cornerRadius: 8, style: .continuous) + .stroke(ADEColor.border.opacity(0.75), lineWidth: 1) + ) + } +} + +private struct ProjectIconButtonStyle: ButtonStyle { + func makeBody(configuration: Configuration) -> some View { + configuration.label + .font(.system(size: 14, weight: .semibold)) + .foregroundStyle(ADEColor.textSecondary) + .frame(width: 38, height: 38) + .background(ADEColor.recessedBackground.opacity(configuration.isPressed ? 0.75 : 1), in: RoundedRectangle(cornerRadius: 8, style: .continuous)) + } +} + +private func projectJoinPath(_ parent: String, _ name: String) -> String { + let trimmed = parent.hasSuffix("/") ? String(parent.dropLast()) : parent + guard !name.isEmpty else { return trimmed } + return "\(trimmed)/\(name)" +} + +private func projectNameFromGitHubUrl(_ value: String) -> String? { + let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return nil } + let noGit = trimmed.hasSuffix(".git") ? String(trimmed.dropLast(4)) : trimmed + let separators = CharacterSet(charactersIn: "/:") + return noGit + .components(separatedBy: separators) + .last? + .trimmingCharacters(in: .whitespacesAndNewlines) + .nilIfEmpty +} + +private extension String { + var nilIfEmpty: String? { + isEmpty ? nil : self + } +} diff --git a/apps/ios/ADE/Models/RemoteModels.swift b/apps/ios/ADE/Models/RemoteModels.swift index 74398f2b3..836af0170 100644 --- a/apps/ios/ADE/Models/RemoteModels.swift +++ b/apps/ios/ADE/Models/RemoteModels.swift @@ -147,6 +147,64 @@ struct MobileProjectSwitchResultPayload: Codable, Equatable { var connection: MobileProjectConnectionPayload? } +struct MobileProjectBrowseEntry: Codable, Equatable, Identifiable { + var id: String { fullPath } + var name: String + var fullPath: String + var isGitRepo: Bool +} + +struct MobileProjectBrowseResult: Codable, Equatable { + var inputPath: String + var resolvedPath: String + var directoryPath: String + var parentPath: String? + var exactDirectoryPath: String? + var openableProjectRoot: String? + var entries: [MobileProjectBrowseEntry] +} + +struct MobileProjectBrowseResultPayload: Codable, Equatable { + var ok: Bool + var message: String? + var result: MobileProjectBrowseResult? +} + +struct MobileProjectDefaultParentDirPayload: Codable, Equatable { + var ok: Bool + var message: String? + var parentDir: String? +} + +struct MobileProjectActionResultPayload: Codable, Equatable { + var ok: Bool + var message: String? + var project: MobileProjectSummary? +} + +struct MobileGitHubRepoSummary: Codable, Equatable, Identifiable { + var id: String { fullName } + var owner: String + var name: String + var fullName: String + var isPrivate: Bool + var pushedAt: String? + var defaultBranch: String + var htmlUrl: String + var cloneUrl: String + var sshUrl: String +} + +struct MobileListMyGitHubReposResult: Codable, Equatable { + var repos: [MobileGitHubRepoSummary] +} + +struct MobileProjectListMyGitHubReposResultPayload: Codable, Equatable { + var ok: Bool + var message: String? + var result: MobileListMyGitHubReposResult? +} + struct DiscoveredSyncHost: Codable, Equatable, Identifiable { var id: String var serviceName: String diff --git a/apps/ios/ADE/Services/SyncService.swift b/apps/ios/ADE/Services/SyncService.swift index 249c3c766..388ecad08 100644 --- a/apps/ios/ADE/Services/SyncService.swift +++ b/apps/ios/ADE/Services/SyncService.swift @@ -1359,6 +1359,7 @@ final class SyncService: ObservableObject { private var remoteProjectCatalog: [MobileProjectSummary] = [] private var pendingProjectCatalogChunks: [String: [Int: [MobileProjectSummary]]] = [:] private var supportsProjectCatalog = false + private var supportsProjectActions = false private var supportsChatStreaming = false private var supportsChangesetAck = false private var projectSelectionTask: Task? @@ -1732,6 +1733,165 @@ final class SyncService: ObservableObject { } } + var canRunRemoteProjectActions: Bool { + supportsProjectActions && canSendLiveRequests() + } + + private func requireRemoteProjectActionsAvailable() throws { + guard supportsProjectActions else { + throw NSError(domain: "ADE", code: 31, userInfo: [ + NSLocalizedDescriptionKey: "This machine does not support mobile project management. Update ADE on the machine and reconnect." + ]) + } + guard canSendLiveRequests() else { + throw NSError(domain: "ADE", code: 14, userInfo: [ + NSLocalizedDescriptionKey: "The machine is offline." + ]) + } + } + + private func requestMachineProjectPayload( + type: String, + payload: [String: Any], + responseType: T.Type, + timeoutMessage: String, + timeoutNanoseconds: UInt64 = SyncRequestTimeout.defaultTimeoutNanoseconds + ) async throws -> T { + try requireRemoteProjectActionsAvailable() + let requestId = makeRequestId() + let raw = try await awaitResponse( + requestId: requestId, + disconnectOnTimeout: false, + timeoutMessage: timeoutMessage, + timeoutNanoseconds: timeoutNanoseconds + ) { + self.sendEnvelope(type: type, requestId: requestId, payload: payload) + } + return try decode(raw, as: responseType) + } + + func browseMachineProjectDirectories( + partialPath: String, + cwd: String? = nil, + limit: Int = 80 + ) async throws -> MobileProjectBrowseResult { + let boundedLimit = max(1, min(limit, 500)) + var payload: [String: Any] = [ + "partialPath": partialPath, + "limit": boundedLimit, + ] + if let cwd, !cwd.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + payload["cwd"] = cwd + } + let response = try await requestMachineProjectPayload( + type: "project_browse_request", + payload: payload, + responseType: MobileProjectBrowseResultPayload.self, + timeoutMessage: "Timed out browsing project folders." + ) + guard response.ok, let result = response.result else { + throw NSError(domain: "ADE", code: 32, userInfo: [ + NSLocalizedDescriptionKey: response.message ?? "The machine could not browse that folder." + ]) + } + return result + } + + func machineProjectDefaultParentDir() async throws -> String { + let response = try await requestMachineProjectPayload( + type: "project_default_parent_dir_request", + payload: [:], + responseType: MobileProjectDefaultParentDirPayload.self, + timeoutMessage: "Timed out reading the default project folder." + ) + guard response.ok, let parentDir = response.parentDir, !parentDir.isEmpty else { + throw NSError(domain: "ADE", code: 33, userInfo: [ + NSLocalizedDescriptionKey: response.message ?? "The machine did not provide a project folder." + ]) + } + return parentDir + } + + private func applyMachineProjectActionResponse( + _ response: MobileProjectActionResultPayload, + fallbackMessage: String + ) throws -> MobileProjectSummary { + guard response.ok, let project = response.project else { + throw NSError(domain: "ADE", code: 34, userInfo: [ + NSLocalizedDescriptionKey: response.message ?? fallbackMessage + ]) + } + remoteProjectCatalog.removeAll { existing in + existing.id == project.id + || (normalizedProjectRoot(existing.rootPath) != nil + && normalizedProjectRoot(existing.rootPath) == normalizedProjectRoot(project.rootPath)) + } + remoteProjectCatalog.append(project) + refreshProjectCatalog(preferRemoteSelection: true) + return project + } + + func openMachineProject(rootPath: String) async throws -> MobileProjectSummary { + let response = try await requestMachineProjectPayload( + type: "project_open_request", + payload: ["rootPath": rootPath], + responseType: MobileProjectActionResultPayload.self, + timeoutMessage: "Timed out opening that project.", + timeoutNanoseconds: 120_000_000_000 + ) + return try applyMachineProjectActionResponse(response, fallbackMessage: "The machine could not open that project.") + } + + func createMachineProject(name: String, parentDir: String) async throws -> MobileProjectSummary { + let response = try await requestMachineProjectPayload( + type: "project_create_request", + payload: ["name": name, "parentDir": parentDir], + responseType: MobileProjectActionResultPayload.self, + timeoutMessage: "Timed out creating that project.", + timeoutNanoseconds: 120_000_000_000 + ) + return try applyMachineProjectActionResponse(response, fallbackMessage: "The machine could not create that project.") + } + + func cloneMachineProject(url: String, name: String, parentDir: String) async throws -> MobileProjectSummary { + var payload: [String: Any] = [ + "url": url, + "parentDir": parentDir, + ] + let trimmedName = name.trimmingCharacters(in: .whitespacesAndNewlines) + if !trimmedName.isEmpty { + payload["name"] = trimmedName + } + let response = try await requestMachineProjectPayload( + type: "project_clone_request", + payload: payload, + responseType: MobileProjectActionResultPayload.self, + timeoutMessage: "The machine is still cloning that project. Try again in a moment.", + timeoutNanoseconds: 300_000_000_000 + ) + return try applyMachineProjectActionResponse(response, fallbackMessage: "The machine could not clone that project.") + } + + func listMachineGitHubRepos(search: String = "") async throws -> [MobileGitHubRepoSummary] { + var payload: [String: Any] = [:] + let trimmedSearch = search.trimmingCharacters(in: .whitespacesAndNewlines) + if !trimmedSearch.isEmpty { + payload["search"] = trimmedSearch + } + let response = try await requestMachineProjectPayload( + type: "project_list_my_github_repos_request", + payload: payload, + responseType: MobileProjectListMyGitHubReposResultPayload.self, + timeoutMessage: "Timed out loading GitHub repositories." + ) + guard response.ok, let result = response.result else { + throw NSError(domain: "ADE", code: 35, userInfo: [ + NSLocalizedDescriptionKey: response.message ?? "The machine could not load GitHub repositories." + ]) + } + return result.repos + } + private func switchToDesktopProject( _ project: MobileProjectSummary, rootPath: String, @@ -6992,57 +7152,22 @@ final class SyncService: ObservableObject { } let features = payload["features"] as? [String: Any] - supportsChatStreaming = { - if let chatStreaming = features?["chatStreaming"] as? [String: Any], - let enabled = chatStreaming["enabled"] as? Bool { - return enabled - } - if let value = features?["chatStreaming"] as? Bool { - return value - } - if let chatStreaming = features?["chat_streaming"] as? [String: Any], - let enabled = chatStreaming["enabled"] as? Bool { - return enabled - } - if let value = features?["chat_streaming"] as? Bool { - return value - } - return false - }() - supportsProjectCatalog = { - if let projectCatalog = features?["projectCatalog"] as? [String: Any], - let enabled = projectCatalog["enabled"] as? Bool { - return enabled - } - if let value = features?["projectCatalog"] as? Bool { - return value - } - if let projectCatalog = features?["project_catalog"] as? [String: Any], - let enabled = projectCatalog["enabled"] as? Bool { - return enabled - } - if let value = features?["project_catalog"] as? Bool { - return value - } - return false - }() - supportsChangesetAck = { - if let changesetAck = features?["changesetAck"] as? [String: Any], - let enabled = changesetAck["enabled"] as? Bool { - return enabled - } - if let value = features?["changesetAck"] as? Bool { - return value - } - if let changesetAck = features?["changeset_ack"] as? [String: Any], - let enabled = changesetAck["enabled"] as? Bool { - return enabled - } - if let value = features?["changeset_ack"] as? Bool { - return value + func featureEnabled(_ keys: String...) -> Bool { + for key in keys { + if let feature = features?[key] as? [String: Any], + let enabled = feature["enabled"] as? Bool { + return enabled + } + if let value = features?[key] as? Bool { + return value + } } return false - }() + } + supportsChatStreaming = featureEnabled("chatStreaming", "chat_streaming") + supportsProjectCatalog = featureEnabled("projectCatalog", "project_catalog") + supportsProjectActions = featureEnabled("projectActions", "project_actions") + supportsChangesetAck = featureEnabled("changesetAck", "changeset_ack") remoteProjectCatalog = [] pendingProjectCatalogChunks.removeAll() let commandDescriptors: [SyncRemoteCommandDescriptor] = { @@ -7286,6 +7411,13 @@ final class SyncService: ObservableObject { applyRemoteProjectCatalogChunk(chunk, requestId: requestId) case "project_switch_result": resolve(requestId: requestId, result: .success(payload)) + case "project_browse_result", + "project_default_parent_dir", + "project_open_result", + "project_create_result", + "project_clone_result", + "project_list_my_github_repos_result": + resolve(requestId: requestId, result: .success(payload)) case "hello_error": let code = ((payload as? [String: Any])?["code"] as? String) ?? "auth_failed" let message = ((payload as? [String: Any])?["message"] as? String) ?? "Authentication failed." diff --git a/apps/ios/ADE/Views/Work/WorkNewChatScreen.swift b/apps/ios/ADE/Views/Work/WorkNewChatScreen.swift index f2295c31a..5685c03b2 100644 --- a/apps/ios/ADE/Views/Work/WorkNewChatScreen.swift +++ b/apps/ios/ADE/Views/Work/WorkNewChatScreen.swift @@ -542,7 +542,10 @@ private func workCliInitialSessionTitle(provider: String, opener: String) -> Str func workCliPermissionMode(provider: String, runtimeMode: String) -> String? { let wire = workRuntimeWireFields(provider: provider, mode: runtimeMode) - return wire.permissionMode ?? (runtimeMode.isEmpty ? nil : runtimeMode) + guard let permissionMode = wire.permissionMode, !permissionMode.isEmpty else { + return nil + } + return permissionMode } private func workCliToolType(provider: String) -> String { diff --git a/apps/ios/ADETests/ADETests.swift b/apps/ios/ADETests/ADETests.swift index 11f851a5d..dbf3e332f 100644 --- a/apps/ios/ADETests/ADETests.swift +++ b/apps/ios/ADETests/ADETests.swift @@ -613,6 +613,12 @@ final class ADETests: XCTestCase { "pairing_request", "project_catalog_request", "project_switch_request", + "project_browse_request", + "project_default_parent_dir_request", + "project_open_request", + "project_create_request", + "project_clone_request", + "project_list_my_github_repos_request", "heartbeat", "register_push_token", "notification_prefs", diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 9c9dba13f..2cd8858a4 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -870,7 +870,7 @@ The sync subsystem is **owned by the ADE runtime** (`apps/ade-cli/src/services/s - **Sync API** (`AdeDb.sync`): `getSiteId`, `getDbVersion`, `exportChangesSince(version, { maxRows?, throughDbVersion? })`, `applyChanges(changes)`, `discardUnpublishedChangesForTables(tableNames)`. - **Bounded, snapshot-isolated exports**: `exportChangesSince` scans bounded `db_version` windows (the sync pump walks 250k-version windows per poll) inside a read transaction that pins the WAL snapshot — the `crsql_changes` vtab aborts on concurrent commits and a bare `LIMIT` cannot bound a vtab scan. Startup self-heals orphaned `__crsql_clock`/`__crsql_pks` shadow tables (base table dropped, shadows left behind), which otherwise abort every `crsql_changes` scan. - **Suppression**: `discardUnpublishedChangesForTables` writes a per-table, per-site high-water mark into the local-only `local_crr_change_suppressions` table. Subsequent `exportChangesSince` calls drop local-site rows for those tables at or below that mark, so a local wipe (e.g. clearing `devices` and `sync_cluster_state` when joining another host as a viewer) cannot leak back as DELETE rows. The viewer-join path follows the wipe with `syncPeerService.acknowledgeLocalDbVersion()` to advance the outbound cursor past the suppressed range. -- **Transport**: one brain-level WebSocket listener on port 8787 by default (preferred-port retry for ~3 s before falling back to a port scan, so restarts do not drift the port phones saved); JSON-framed changesets + zlib compression for large batches; encoded envelopes >720 KB are sliced into `envelope_chunk` frames for peers declaring the `chunkedEnvelopes` capability; 30s ping/pong. The same envelope channel carries project catalog and project-switch messages; on a hosted-project switch the new host service adopts the open sockets, so connected phones survive the swap. Phones keep per-host-DB sync cursors keyed by the `serverDbSiteId` from `hello_ok`, and the host filters high-churn tables the phone never reads (transcripts, operations, usage logs, automation runs) from phone changesets. +- **Transport**: one brain-level WebSocket listener on port 8787 by default (preferred-port retry for ~3 s before falling back to a port scan, so restarts do not drift the port phones saved); JSON-framed changesets + zlib compression for large batches; encoded envelopes >720 KB are sliced into `envelope_chunk` frames for peers declaring the `chunkedEnvelopes` capability; 30s ping/pong. The same envelope channel carries project catalog, project-switch, and runtime-scoped project-action messages (browse/open/create/clone/list GitHub repos/default parent directory); on a hosted-project switch the new host service adopts the open sockets, so connected phones survive the swap. A machine-wide fallback handler serves catalog/project actions when no project host owns the listener, while handoff-time reconnects still park for adoption by the next host. Phones keep per-host-DB sync cursors keyed by the `serverDbSiteId` from `hello_ok`, and the host filters high-churn tables the phone never reads (transcripts, operations, usage logs, automation runs) from phone changesets. ### 13.2 Device model @@ -882,7 +882,7 @@ The sync subsystem is **owned by the ADE runtime** (`apps/ade-cli/src/services/s - App launch reads pairing secret from iOS Keychain. - Opens WebSocket to host after racing all saved address candidates with concurrent TCP probes (happy eyeballs) — a dead LAN IP no longer delays the live Tailscale route. Sends local `db_version` plus the per-host-DB cursor map (`remoteDbVersionBySite`); host replies with its `serverDbSiteId` and sends catch-up changesets. -- `hello_ok` can include the host's mobile project catalog. The iOS app shows a native project home until an active project is selected, then drives `project_switch_request` / `project_switch_result`; the port stays stable across switches. +- `hello_ok` can include the host's mobile project catalog and project-action feature flag. The iOS app shows a native project home until an active project is selected, can browse/open/create/clone projects on the paired machine when project actions are available, then drives `project_switch_request` / `project_switch_result`; the port stays stable across switches. - Bidirectional sync continues; inbound processing (envelope parse, gunzip, chunk reassembly, changeset decode + apply) runs off the main actor. On disconnect: a fast exponential-backoff burst, then an indefinite ~30 s slow-heartbeat retry — the phone never permanently gives up. `reconnectIfPossible` is guarded against overlapping runs. - Chat streaming resumes by sequence: each `chat_event` carries a host-assigned per-session `seq` backed by a replay buffer; `chat_subscribe` passes `sinceSeq` so reconnects replay only the missed events. The subscribe ack also carries `turnActive` (live turn state from the agent chat service) so a phone subscribing mid-turn renders streaming/stop affordances immediately even when the byte-capped snapshot tail dropped the turn's start event. `chat.getTranscript` pages older history via an opaque cursor. - All reads are local and scoped to the active project id — the iOS tab is instant and offline-capable after the selected project's row has hydrated. diff --git a/docs/features/sync-and-multi-device/README.md b/docs/features/sync-and-multi-device/README.md index c3b9dc358..69d609a03 100644 --- a/docs/features/sync-and-multi-device/README.md +++ b/docs/features/sync-and-multi-device/README.md @@ -116,7 +116,7 @@ running sync authority). │ - SyncEnvelope: hello, pairing, changeset_batch, │ │ changeset_ack, heartbeat, file_request/response, │ │ terminal_*, chat_*, brain_status (legacy name), │ -│ project_catalog/project_switch, │ +│ project_catalog/project_switch/project actions, │ │ command / command_ack / command_result, │ │ envelope_chunk │ │ - JSON payloads; gzip+base64 above threshold (4 KB default) │ @@ -165,7 +165,9 @@ Canonical files (`apps/ade-cli/src/services/sync/`): paging via `terminal_history`, mobile terminal input/resize forwarding into subscribed PTYs, desktop-size restore after the last phone detaches, lane presence decoration, project catalog/switch envelopes, - per-IP pairing rate limiter, and the Tailscale Serve / mDNS + runtime-scoped project action envelopes (browse/open/create/clone/ + list GitHub repos/default parent directory), per-IP pairing rate + limiter, and the Tailscale Serve / mDNS publication paths. Runtime kind is one of `desktop-embedded`, `headless`, `remote-stdio`, `desktop`, `daemon`, or `remote`. @@ -181,7 +183,17 @@ Canonical files (`apps/ade-cli/src/services/sync/`): during the handoff window replayed — so phones survive project switches without reconnecting. Sockets left unowned park with buffered frames and close with code 4002 after a 30 s grace. A - self-owned server path remains for tests/standalone hosts. + machine-wide fallback handler may accept new sockets when no project + host owns the listener, but it is suppressed during the handoff grace + after a project host detaches so reconnecting phones still park for + adoption by the next project host. A self-owned server path remains + for tests/standalone hosts. +- `brainProjectActionsSyncHandler.ts` — machine-wide fallback sync + handler used by `ade serve` before any project host is active. It + authenticates the same PIN / paired-secret / bootstrap paths as the + per-project host, applies the same failed-PIN cooldown, and serves + project catalog plus runtime-scoped project actions so a phone can + add/open/create/clone a project even from the project-home state. - `changesetPump.ts` — batch-chunk selection for changeset fan-out. Splits an export into `changeset_batch` envelopes at ~256 KB / 250 rows while never splitting rows that share a `db_version` (the ack @@ -368,6 +380,16 @@ port across project switches. The phone flow: If the switch fails, the previous host is restored so the listener is never left unowned. +The project home can also manage machine projects without first binding +to a project DB. `project_browse_request`, +`project_default_parent_dir_request`, `project_open_request`, +`project_create_request`, `project_clone_request`, and +`project_list_my_github_repos_request` are runtime-scoped envelopes. +When a project host is active, `syncHostService` handles them; when no +project host owns the shared listener, `brainProjectActionsSyncHandler` +handles the same envelopes so the phone can add a first project on a +headless or freshly-started machine. + Project catalog snapshots are also chunked (`MAX_PROJECT_CATALOG_ENVELOPE_BYTES = 768 KB`, `maxProjectCatalogChunkBytes = 192 KB`) so a runtime with many projects @@ -592,6 +614,7 @@ payload. | Chat stream | Agent chat transcript events. Each `chat_event` carries a host-assigned per-session monotonic `seq` backed by a capped replay buffer (500 events / 2 MB per session, 64-session LRU). `chat_subscribe` accepts `sinceSeq`: gaps the buffer covers replay as ordinary events; uncoverable gaps fall back to a snapshot, and a non-resumed ack tells the client to drop its stale seq watermark (seq epochs restart at 1 on a new host). The ack also carries `turnActive` from the live agent chat service — snapshots are byte-capped tails, so a long turn's `status: started` event can fall outside the window and the flag is what lets a mid-turn subscriber render streaming/stop affordances without waiting on the changeset pump (a full ack without the flag tells the client to drop any latched hint) | iOS Work tab, controller chat | | Command routing | Send named actions (`chat.send`, `lanes.create`, `git.push`, `prs.getMobileSnapshot`, etc.) | Controller devices | | Project switching | `project_catalog` + `project_switch_request/result` for multi-project runtimes | iOS project home | +| Project actions | Runtime-scoped project browser plus open/create/clone/list-GitHub-repos/default-parent-dir envelopes. Available from the active project host or the machine-wide fallback handler before a project is selected | iOS project home | | Runtime status | Runtime broadcasts cluster/version status (`brain_status` is the legacy envelope name) | All devices | | Lane presence | Controllers call `lanes.presence.announce` / `lanes.presence.release`; the runtime decorates `LaneSummary.devicesOpen` for 60 s TTL | iOS Lanes tab; desktop runtime presence heartbeat | @@ -682,6 +705,7 @@ project scope split. | PIN-based phone pairing + per-device secrets | Implemented | | Live chat-event push from runtime | Implemented | | Mobile project catalog + project switch handoff | Implemented | +| Mobile project actions (browse/open/create/clone/list GitHub repos) | Implemented | | Brain-level shared listener (peers adopted across project switches) | Implemented | | Chunked envelopes (`envelope_chunk`, 720 KB frame budget) | Implemented | | Per-host-DB sync cursors (`serverDbSiteId` / `remoteDbVersionBySite`) | Implemented | diff --git a/docs/features/sync-and-multi-device/ios-companion.md b/docs/features/sync-and-multi-device/ios-companion.md index 6689142a3..11552ab5e 100644 --- a/docs/features/sync-and-multi-device/ios-companion.md +++ b/docs/features/sync-and-multi-device/ios-companion.md @@ -59,7 +59,12 @@ apps/ios/ │ │ │ # running-chat badge); the system tab │ │ │ # strip is hidden and individual screens │ │ │ # can hide the custom bar via -│ │ │ # `adeRootTabBarHidden()` +│ │ │ # `adeRootTabBarHidden()`; project +│ │ │ # home includes Add project when the +│ │ │ # runtime advertises projectActions +│ │ ├── RemoteProjectAddSheet.swift # Open/create/clone project flow +│ │ │ # backed by runtime-scoped +│ │ │ # project action envelopes │ │ ├── DeepLinkRouter.swift # ade:// URL handler. ade://session/, │ │ │ # ade://pr/, and ade://pr/// │ │ │ # flip tabs via .adeDeepLinkRequested. @@ -87,6 +92,7 @@ apps/ios/ │ │ # PIN pairing, lane presence, terminal │ │ # subscribe/unsubscribe + input/resize, │ │ # CLI launcher (startCliSession), chat push, +│ │ # machine project browse/open/create/clone, │ │ # lane reparent stack-base override payloads, │ │ # push-token registration, worktree discovery │ ├── Shared/ @@ -365,6 +371,12 @@ Implemented envelope types on iOS: | `pairing_request` / `pairing_result` | Phone → runtime / runtime → phone | 6-digit PIN pairing | | `project_catalog_request` / `project_catalog` | Phone → runtime / runtime → phone | Refresh recent/available machine projects | | `project_switch_request` / `project_switch_result` | Phone → runtime / runtime → phone | Prepare a sync connection for a selected machine project | +| `project_browse_request` / `project_browse_result` | Phone → runtime / runtime → phone | Browse machine directories for Open project / parent-directory picker | +| `project_default_parent_dir_request` / `project_default_parent_dir` | Phone → runtime / runtime → phone | Resolve the default parent directory for Create/Clone project forms | +| `project_open_request` / `project_open_result` | Phone → runtime / runtime → phone | Register/open an existing Git repository from the machine filesystem | +| `project_create_request` / `project_create_result` | Phone → runtime / runtime → phone | Create a new local Git project under a selected parent directory | +| `project_clone_request` / `project_clone_result` | Phone → runtime / runtime → phone | Clone a GitHub repository on the machine and register it in the project catalog | +| `project_list_my_github_repos_request` / `project_list_my_github_repos_result` | Phone → runtime / runtime → phone | List the runtime machine's authenticated GitHub repositories for the Clone flow | | `changeset_batch` | Bidirectional | cr-sqlite changeset batch | | `changeset_ack` | Bidirectional | Per-batch apply confirmation (or error code); the sender retransmits on timeout | | `command` | Phone → runtime | Execution request | @@ -902,7 +914,7 @@ reflected in the phone's UI on the next descriptor read. | WebSocket client | Implemented | | PIN pairing flow | Implemented | | QR pairing payload (v2, address candidates + port) | Implemented | -| Project home + machine project switching | Implemented | +| Project home + machine project switching | Implemented, including Add project actions for browsing/opening existing Git repos, creating local projects, and cloning GitHub repos on the paired machine | | Lanes tab | Implemented to live machine parity (with `devicesOpen`, multi-attach, stack canvas, stack-position/base-branch editing in Manage Lane, and template environment progress) | | Files tab | Implemented with `mobileReadOnly` workspace gate and capped search/quick-open result rendering | | Work tab | Implemented; live chat-event push from runtime, subscribed terminal input/resize control with `terminal_unsubscribe` on view disappear, in-app CLI session launcher (`work.startCliSession`), message-to-continue on ended agent CLI rows |