diff --git a/docs/architecture/runtime-integrations.md b/docs/architecture/runtime-integrations.md index 5c5881a..39de2fe 100644 --- a/docs/architecture/runtime-integrations.md +++ b/docs/architecture/runtime-integrations.md @@ -115,7 +115,7 @@ Primary file: `idx0/Apps/T3Code/T3CodeRuntime.swift` Key capabilities: - Manifest-driven clone/build/run flow (`t3-build-manifest.json`) -- Build reuse when artifacts and build record match pinned commit +- Latest-source tracking with build reuse when artifacts match the current upstream commit - Session snapshot directories under app support - Runtime state surfaced to tile UI (`idle`, `building`, `live`, `failed`, etc.) @@ -125,7 +125,7 @@ Primary file: `idx0/Apps/VSCode/VSCodeRuntime.swift` Key capabilities: -- Manifest-driven code-server runtime install (`openvscode-build-manifest.json`) +- Latest release resolution for code-server with bundled-manifest fallback (`openvscode-build-manifest.json`) - Platform-specific artifact resolution + SHA validation - Reusable runtime install record - Per-session user-data/extensions directories with profile seeding @@ -138,7 +138,7 @@ Primary file: `idx0/Apps/Excalidraw/ExcalidrawRuntime.swift` Key capabilities: - Manifest-driven clone/build/run flow (`excalidraw-build-manifest.json`) -- Build reuse when artifacts and build record match pinned commit +- Latest-source tracking with build reuse when artifacts match the current upstream commit - Session-stable origin mapping via persisted loopback port assignment - Local static serving into `WKWebView` with retryable startup behavior - Runtime state surfaced to tile UI (`preparingSource`, `building`, `live`, etc.) diff --git a/idx0/Apps/Excalidraw/ExcalidrawRuntime.swift b/idx0/Apps/Excalidraw/ExcalidrawRuntime.swift index 9c3c98d..e3b7bb6 100644 --- a/idx0/Apps/Excalidraw/ExcalidrawRuntime.swift +++ b/idx0/Apps/Excalidraw/ExcalidrawRuntime.swift @@ -4,1166 +4,1282 @@ import Foundation import WebKit struct ExcalidrawBuildManifest: Codable, Equatable { - static let canonicalRepositoryURL = "https://github.com/excalidraw/excalidraw.git" - static let canonicalInstallCommand = "yarn install --frozen-lockfile" - static let canonicalBuildCommand = "yarn --cwd excalidraw-app build" - static let canonicalEntrypoint = "excalidraw-app/build/index.html" - - let repositoryURL: String - let pinnedCommit: String - let installCommand: String - let buildCommand: String - let entrypoint: String - let requiredArtifacts: [String] - - static let `default` = ExcalidrawBuildManifest( - repositoryURL: canonicalRepositoryURL, - pinnedCommit: "d6f0f34fe91a7fab25106f2b31b074c132815d36", - installCommand: canonicalInstallCommand, - buildCommand: canonicalBuildCommand, - entrypoint: canonicalEntrypoint, - requiredArtifacts: [ - canonicalEntrypoint - ] - ) - - static func loadFromBundle(_ bundle: Bundle = .main) -> ExcalidrawBuildManifest { - guard let url = bundle.url(forResource: "excalidraw-build-manifest", withExtension: "json"), - let data = try? Data(contentsOf: url), - let decoded = try? JSONDecoder().decode(ExcalidrawBuildManifest.self, from: data) - else { - return .default - } - return decoded + static let canonicalRepositoryURL = "https://github.com/excalidraw/excalidraw.git" + static let canonicalInstallCommand = "yarn install --frozen-lockfile" + static let canonicalBuildCommand = "yarn --cwd excalidraw-app build" + static let canonicalEntrypoint = "excalidraw-app/build/index.html" + + let repositoryURL: String + let pinnedCommit: String + let installCommand: String + let buildCommand: String + let entrypoint: String + let requiredArtifacts: [String] + + static let `default` = ExcalidrawBuildManifest( + repositoryURL: canonicalRepositoryURL, + pinnedCommit: "HEAD", + installCommand: canonicalInstallCommand, + buildCommand: canonicalBuildCommand, + entrypoint: canonicalEntrypoint, + requiredArtifacts: [ + canonicalEntrypoint, + ] + ) + + static func loadFromBundle(_ bundle: Bundle = .main) -> ExcalidrawBuildManifest { + guard let url = bundle.url(forResource: "excalidraw-build-manifest", withExtension: "json"), + let data = try? Data(contentsOf: url), + let decoded = try? JSONDecoder().decode(ExcalidrawBuildManifest.self, from: data) + else { + return .default } + return decoded + } } struct ExcalidrawRuntimePaths { - let rootDirectory: URL - let sourceDirectory: URL - let buildRecordPath: URL - let buildLogPath: URL - let buildLockPath: URL - let originsRecordPath: URL - let sessionsDirectory: URL - let sessionDirectory: URL - let runtimeLogPath: URL - - init( - sessionID: UUID, - rootDirectoryOverride: URL? = nil, - fileManager: FileManager = .default - ) { - let idx0Root: URL - if let rootDirectoryOverride { - idx0Root = rootDirectoryOverride - } else { - let appSupportRoot = fileManager.urls(for: .applicationSupportDirectory, in: .userDomainMask).first - ?? URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true) - idx0Root = appSupportRoot - .appendingPathComponent("idx0", isDirectory: true) - .appendingPathComponent("excalidraw", isDirectory: true) - } - - rootDirectory = idx0Root - sourceDirectory = idx0Root.appendingPathComponent("source", isDirectory: true) - buildRecordPath = idx0Root.appendingPathComponent("manifest.json", isDirectory: false) - buildLogPath = idx0Root - .appendingPathComponent("logs", isDirectory: true) - .appendingPathComponent("build.log", isDirectory: false) - buildLockPath = idx0Root.appendingPathComponent("build.lock", isDirectory: false) - originsRecordPath = idx0Root.appendingPathComponent("session-origins.json", isDirectory: false) - sessionsDirectory = idx0Root.appendingPathComponent("sessions", isDirectory: true) - sessionDirectory = sessionsDirectory.appendingPathComponent(sessionID.uuidString, isDirectory: true) - runtimeLogPath = sessionDirectory.appendingPathComponent("runtime.log", isDirectory: false) + let rootDirectory: URL + let sourceDirectory: URL + let buildRecordPath: URL + let buildLogPath: URL + let buildLockPath: URL + let originsRecordPath: URL + let sessionsDirectory: URL + let sessionDirectory: URL + let runtimeLogPath: URL + + init( + sessionID: UUID, + rootDirectoryOverride: URL? = nil, + fileManager: FileManager = .default + ) { + let idx0Root: URL + if let rootDirectoryOverride { + idx0Root = rootDirectoryOverride + } else { + let appSupportRoot = fileManager.urls(for: .applicationSupportDirectory, in: .userDomainMask).first + ?? URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true) + idx0Root = appSupportRoot + .appendingPathComponent("idx0", isDirectory: true) + .appendingPathComponent("excalidraw", isDirectory: true) } - func ensureBaseDirectories(fileManager: FileManager = .default) throws { - try fileManager.createDirectory(at: rootDirectory, withIntermediateDirectories: true) - try fileManager.createDirectory(at: sourceDirectory.deletingLastPathComponent(), withIntermediateDirectories: true) - try fileManager.createDirectory(at: buildLogPath.deletingLastPathComponent(), withIntermediateDirectories: true) - try fileManager.createDirectory(at: sessionsDirectory, withIntermediateDirectories: true) - try fileManager.createDirectory(at: sessionDirectory, withIntermediateDirectories: true) - } - - func removeSessionArtifacts(fileManager: FileManager = .default) { - try? fileManager.removeItem(at: sessionDirectory) - } + rootDirectory = idx0Root + sourceDirectory = idx0Root.appendingPathComponent("source", isDirectory: true) + buildRecordPath = idx0Root.appendingPathComponent("manifest.json", isDirectory: false) + buildLogPath = idx0Root + .appendingPathComponent("logs", isDirectory: true) + .appendingPathComponent("build.log", isDirectory: false) + buildLockPath = idx0Root.appendingPathComponent("build.lock", isDirectory: false) + originsRecordPath = idx0Root.appendingPathComponent("session-origins.json", isDirectory: false) + sessionsDirectory = idx0Root.appendingPathComponent("sessions", isDirectory: true) + sessionDirectory = sessionsDirectory.appendingPathComponent(sessionID.uuidString, isDirectory: true) + runtimeLogPath = sessionDirectory.appendingPathComponent("runtime.log", isDirectory: false) + } + + func ensureBaseDirectories(fileManager: FileManager = .default) throws { + try fileManager.createDirectory(at: rootDirectory, withIntermediateDirectories: true) + try fileManager.createDirectory(at: sourceDirectory.deletingLastPathComponent(), withIntermediateDirectories: true) + try fileManager.createDirectory(at: buildLogPath.deletingLastPathComponent(), withIntermediateDirectories: true) + try fileManager.createDirectory(at: sessionsDirectory, withIntermediateDirectories: true) + try fileManager.createDirectory(at: sessionDirectory, withIntermediateDirectories: true) + } + + func removeSessionArtifacts(fileManager: FileManager = .default) { + try? fileManager.removeItem(at: sessionDirectory) + } } enum ExcalidrawTileRuntimeState: Equatable { - case idle - case preparingSource - case building - case starting - case live(urlString: String) - case failed(message: String, logPath: String?) - - var displayMessage: String { - switch self { - case .idle: - return "Ready" - case .preparingSource: - return "Preparing Excalidraw source..." - case .building: - return "Building Excalidraw..." - case .starting: - return "Starting Excalidraw..." - case .live: - return "Live" - case .failed(let message, _): - return message - } + case idle + case preparingSource + case building + case starting + case live(urlString: String) + case failed(message: String, logPath: String?) + + var displayMessage: String { + switch self { + case .idle: + "Ready" + case .preparingSource: + "Preparing Excalidraw source..." + case .building: + "Building Excalidraw..." + case .starting: + "Starting Excalidraw..." + case .live: + "Live" + case let .failed(message, _): + message } + } } enum ExcalidrawRuntimeError: LocalizedError { - case missingTool(String) - case missingYarnPackageManager(nodePath: String) - case commandFailed(command: String, code: Int32, stderr: String?) - case missingArtifact(String) - case startupTimeout - case processExitedBeforeReady - case cancelled - - var errorDescription: String? { - switch self { - case .missingTool(let tool): - switch tool { - case "git": - return "Excalidraw needs Git to fetch its source, but `git` was not found." - case "node": - return "Excalidraw needs Node.js to build, but `node` was not found." - case "yarn": - return "Excalidraw needs Yarn to build, but `yarn` was not found. Run `corepack enable` or install Yarn, then retry." - default: - return "Missing required tool: \(tool)" - } - case .missingYarnPackageManager(let nodePath): - return """ - Excalidraw found Node.js at \(nodePath), but could not find `yarn` or `corepack`. - Run `corepack enable` for that Node installation, or install Yarn, then retry. - """ - case .commandFailed(let command, let code, let stderr): - if let stderr, !stderr.isEmpty { - return "Command failed (\(code)): \(command)\n\(stderr)" - } - return "Command failed (\(code)): \(command)" - case .missingArtifact(let artifact): - return "Build artifact missing: \(artifact)" - case .startupTimeout: - return "Excalidraw did not become ready in time." - case .processExitedBeforeReady: - return "Excalidraw process exited before it became ready." - case .cancelled: - return "Operation cancelled." - } + case missingTool(String) + case missingYarnPackageManager(nodePath: String) + case commandFailed(command: String, code: Int32, stderr: String?) + case missingArtifact(String) + case startupTimeout + case processExitedBeforeReady + case cancelled + + var errorDescription: String? { + switch self { + case let .missingTool(tool): + switch tool { + case "git": + return "Excalidraw needs Git to fetch its source, but `git` was not found." + case "node": + return "Excalidraw needs Node.js to build, but `node` was not found." + case "yarn": + return "Excalidraw needs Yarn to build, but `yarn` was not found. Run `corepack enable` or install Yarn, then retry." + default: + return "Missing required tool: \(tool)" + } + case let .missingYarnPackageManager(nodePath): + return """ + Excalidraw found Node.js at \(nodePath), but could not find `yarn` or `corepack`. + Run `corepack enable` for that Node installation, or install Yarn, then retry. + """ + case let .commandFailed(command, code, stderr): + if let stderr, !stderr.isEmpty { + return "Command failed (\(code)): \(command)\n\(stderr)" + } + return "Command failed (\(code)): \(command)" + case let .missingArtifact(artifact): + return "Build artifact missing: \(artifact)" + case .startupTimeout: + return "Excalidraw did not become ready in time." + case .processExitedBeforeReady: + return "Excalidraw process exited before it became ready." + case .cancelled: + return "Operation cancelled." } + } } private struct ExcalidrawBuildRecord: Codable { - let pinnedCommit: String - let entrypoint: String - let builtAt: Date + let sourceCommit: String + let entrypoint: String + let builtAt: Date + + private enum CodingKeys: String, CodingKey { + case sourceCommit + case pinnedCommit + case entrypoint + case builtAt + } + + init(sourceCommit: String, entrypoint: String, builtAt: Date) { + self.sourceCommit = sourceCommit + self.entrypoint = entrypoint + self.builtAt = builtAt + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + entrypoint = try container.decode(String.self, forKey: .entrypoint) + builtAt = try container.decode(Date.self, forKey: .builtAt) + if let decodedSourceCommit = try container.decodeIfPresent(String.self, forKey: .sourceCommit) { + sourceCommit = decodedSourceCommit + } else { + sourceCommit = try container.decode(String.self, forKey: .pinnedCommit) + } + } + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(sourceCommit, forKey: .sourceCommit) + // Preserve compatibility with previously persisted build records. + try container.encode(sourceCommit, forKey: .pinnedCommit) + try container.encode(entrypoint, forKey: .entrypoint) + try container.encode(builtAt, forKey: .builtAt) + } } private struct ExcalidrawShellTool { - let executablePath: String - let shellCommand: String + let executablePath: String + let shellCommand: String } @MainActor final class ExcalidrawBuildCoordinator { - private let processRunner: any ProcessRunnerProtocol - private let fileManager: FileManager - private var buildTask: Task? - - init(processRunner: any ProcessRunnerProtocol = ProcessRunner(), fileManager: FileManager = .default) { - self.processRunner = processRunner - self.fileManager = fileManager - } - - func ensureBuilt( - manifest: ExcalidrawBuildManifest, - paths: ExcalidrawRuntimePaths, - onStateUpdate: ((ExcalidrawTileRuntimeState) -> Void)? = nil - ) async throws -> URL { - if let entrypoint = try? reusableEntrypointIfAvailable(manifest: manifest, paths: paths) { - return entrypoint - } - - if let existingTask = buildTask { - return try await existingTask.value - } - - let task = Task { [weak self] () -> URL in - guard let self else { throw ExcalidrawRuntimeError.cancelled } - return try await self.performBuild(manifest: manifest, paths: paths, onStateUpdate: onStateUpdate) - } - - buildTask = task - do { - let url = try await task.value - buildTask = nil - return url - } catch { - buildTask = nil - throw error - } + private let processRunner: any ProcessRunnerProtocol + private let fileManager: FileManager + private var buildTask: Task? + + init(processRunner: any ProcessRunnerProtocol = ProcessRunner(), fileManager: FileManager = .default) { + self.processRunner = processRunner + self.fileManager = fileManager + } + + func ensureBuilt( + manifest: ExcalidrawBuildManifest, + paths: ExcalidrawRuntimePaths, + onStateUpdate: ((ExcalidrawTileRuntimeState) -> Void)? = nil + ) async throws -> URL { + if let existingTask = buildTask { + return try await existingTask.value } - private func reusableEntrypointIfAvailable(manifest: ExcalidrawBuildManifest, paths: ExcalidrawRuntimePaths) throws -> URL { - guard fileManager.fileExists(atPath: paths.buildRecordPath.path) else { - throw ExcalidrawRuntimeError.missingArtifact(paths.buildRecordPath.path) - } - - let data = try Data(contentsOf: paths.buildRecordPath) - let record = try JSONDecoder().decode(ExcalidrawBuildRecord.self, from: data) - - guard record.pinnedCommit == manifest.pinnedCommit else { - throw ExcalidrawRuntimeError.missingArtifact(manifest.pinnedCommit) - } + let task = Task { [weak self] () -> URL in + guard let self else { throw ExcalidrawRuntimeError.cancelled } + return try await performBuild(manifest: manifest, paths: paths, onStateUpdate: onStateUpdate) + } - for artifact in manifest.requiredArtifacts { - let artifactURL = paths.sourceDirectory.appendingPathComponent(artifact, isDirectory: false) - guard fileManager.fileExists(atPath: artifactURL.path) else { - throw ExcalidrawRuntimeError.missingArtifact(artifact) - } - } + buildTask = task + do { + let url = try await task.value + buildTask = nil + return url + } catch { + buildTask = nil + throw error + } + } + + private func reusableEntrypointIfAvailable( + sourceCommit: String, + manifest: ExcalidrawBuildManifest, + paths: ExcalidrawRuntimePaths + ) throws -> URL { + guard fileManager.fileExists(atPath: paths.buildRecordPath.path) else { + throw ExcalidrawRuntimeError.missingArtifact(paths.buildRecordPath.path) + } - let entrypointURL = paths.sourceDirectory.appendingPathComponent(record.entrypoint, isDirectory: false) - guard fileManager.fileExists(atPath: entrypointURL.path) else { - throw ExcalidrawRuntimeError.missingArtifact(entrypointURL.path) - } + let data = try Data(contentsOf: paths.buildRecordPath) + let record = try JSONDecoder().decode(ExcalidrawBuildRecord.self, from: data) - return entrypointURL + guard record.sourceCommit == sourceCommit else { + throw ExcalidrawRuntimeError.missingArtifact(sourceCommit) } - private func performBuild( - manifest: ExcalidrawBuildManifest, - paths: ExcalidrawRuntimePaths, - onStateUpdate: ((ExcalidrawTileRuntimeState) -> Void)? - ) async throws -> URL { - try paths.ensureBaseDirectories(fileManager: fileManager) + for artifact in manifest.requiredArtifacts { + let artifactURL = paths.sourceDirectory.appendingPathComponent(artifact, isDirectory: false) + guard fileManager.fileExists(atPath: artifactURL.path) else { + throw ExcalidrawRuntimeError.missingArtifact(artifact) + } + } - onStateUpdate?(.preparingSource) - appendBuildLog(paths: paths, line: "== build start \(Date())") + let entrypointURL = paths.sourceDirectory.appendingPathComponent(record.entrypoint, isDirectory: false) + guard fileManager.fileExists(atPath: entrypointURL.path) else { + throw ExcalidrawRuntimeError.missingArtifact(entrypointURL.path) + } - try "pid=\(ProcessInfo.processInfo.processIdentifier)\n".write( - to: paths.buildLockPath, - atomically: true, - encoding: .utf8 - ) - defer { try? fileManager.removeItem(at: paths.buildLockPath) } + return entrypointURL + } - let resolvedGitPath = try await ensureToolAvailable("git", paths: paths) - let resolvedNodePath = try await ensureToolAvailable("node", paths: paths) - let resolvedYarnTool = try await resolveYarnTool(paths: paths, resolvedNodePath: resolvedNodePath) - let preferredToolDirectories = uniqueParentDirectories( - for: [resolvedGitPath, resolvedNodePath, resolvedYarnTool.executablePath] - ) + private func performBuild( + manifest: ExcalidrawBuildManifest, + paths: ExcalidrawRuntimePaths, + onStateUpdate: ((ExcalidrawTileRuntimeState) -> Void)? + ) async throws -> URL { + try paths.ensureBaseDirectories(fileManager: fileManager) - if fileManager.fileExists(atPath: paths.sourceDirectory.appendingPathComponent(".git", isDirectory: true).path) { - appendBuildLog(paths: paths, line: "Refreshing existing repository") - try await runChecked( - executable: resolvedGitPath, - arguments: ["-C", paths.sourceDirectory.path, "fetch", "--all", "--tags"], - currentDirectory: paths.sourceDirectory.path, - paths: paths - ) - } else { - appendBuildLog(paths: paths, line: "Cloning repository") - try await runChecked( - executable: resolvedGitPath, - arguments: ["clone", manifest.repositoryURL, paths.sourceDirectory.path], - currentDirectory: paths.rootDirectory.path, - paths: paths - ) - } + onStateUpdate?(.preparingSource) + appendBuildLog(paths: paths, line: "== build start \(Date())") - try await runChecked( - executable: resolvedGitPath, - arguments: ["-C", paths.sourceDirectory.path, "checkout", manifest.pinnedCommit], - currentDirectory: paths.sourceDirectory.path, - paths: paths - ) + try "pid=\(ProcessInfo.processInfo.processIdentifier)\n".write( + to: paths.buildLockPath, + atomically: true, + encoding: .utf8 + ) + defer { try? fileManager.removeItem(at: paths.buildLockPath) } - onStateUpdate?(.building) + let resolvedGitPath = try await ensureToolAvailable("git", paths: paths) - let installCommand = nonInteractiveShellCommand( - replacingLeadingToolInvocation( - in: manifest.installCommand, - tool: "yarn", - replacement: resolvedYarnTool.shellCommand - ), - preferredToolDirectories: preferredToolDirectories - ) + if fileManager.fileExists(atPath: paths.sourceDirectory.appendingPathComponent(".git", isDirectory: true).path) { + appendBuildLog(paths: paths, line: "Refreshing existing repository") + do { try await runChecked( - executable: "/bin/zsh", - arguments: ["-lc", installCommand], - currentDirectory: paths.sourceDirectory.path, - paths: paths + executable: resolvedGitPath, + arguments: ["-C", paths.sourceDirectory.path, "fetch", "--all", "--tags"], + currentDirectory: paths.sourceDirectory.path, + paths: paths ) - - let buildCommand = nonInteractiveShellCommand( - replacingLeadingToolInvocation( - in: manifest.buildCommand, - tool: "yarn", - replacement: resolvedYarnTool.shellCommand - ), - preferredToolDirectories: preferredToolDirectories + } catch { + appendBuildLog( + paths: paths, + line: "Fetch failed; continuing with locally cached source: \(error.localizedDescription)" ) - try await runChecked( - executable: "/bin/zsh", - arguments: ["-lc", buildCommand], - currentDirectory: paths.sourceDirectory.path, - paths: paths - ) - - if let firstMissingArtifact = missingRequiredArtifacts(manifest: manifest, paths: paths).first { - throw ExcalidrawRuntimeError.missingArtifact(firstMissingArtifact) - } + } + } else { + appendBuildLog(paths: paths, line: "Cloning repository") + try await runChecked( + executable: resolvedGitPath, + arguments: ["clone", manifest.repositoryURL, paths.sourceDirectory.path], + currentDirectory: paths.rootDirectory.path, + paths: paths + ) + } - let record = ExcalidrawBuildRecord( - pinnedCommit: manifest.pinnedCommit, - entrypoint: manifest.entrypoint, - builtAt: Date() - ) - let recordData = try JSONEncoder().encode(record) - try fileManager.createDirectory(at: paths.buildRecordPath.deletingLastPathComponent(), withIntermediateDirectories: true) - try recordData.write(to: paths.buildRecordPath, options: .atomic) + let resolvedSourceCommit = try await resolveLatestSourceCommit( + resolvedGitPath: resolvedGitPath, + manifest: manifest, + paths: paths + ) + if let entrypoint = try? reusableEntrypointIfAvailable( + sourceCommit: resolvedSourceCommit, + manifest: manifest, + paths: paths + ) { + appendBuildLog(paths: paths, line: "Reusing existing build for source commit \(resolvedSourceCommit)") + return entrypoint + } - appendBuildLog(paths: paths, line: "== build complete \(Date())") + let resolvedNodePath = try await ensureToolAvailable("node", paths: paths) + let resolvedYarnTool = try await resolveYarnTool(paths: paths, resolvedNodePath: resolvedNodePath) + let preferredToolDirectories = uniqueParentDirectories( + for: [resolvedGitPath, resolvedNodePath, resolvedYarnTool.executablePath] + ) - let entrypointURL = paths.sourceDirectory.appendingPathComponent(manifest.entrypoint, isDirectory: false) - guard fileManager.fileExists(atPath: entrypointURL.path) else { - throw ExcalidrawRuntimeError.missingArtifact(manifest.entrypoint) - } + try await runChecked( + executable: resolvedGitPath, + arguments: ["-C", paths.sourceDirectory.path, "checkout", "--force", resolvedSourceCommit], + currentDirectory: paths.sourceDirectory.path, + paths: paths + ) - return entrypointURL - } - - private func ensureToolAvailable(_ tool: String, paths: ExcalidrawRuntimePaths) async throws -> String { - let probes: [(executable: String, arguments: [String], display: String)] = [ - ("/usr/bin/which", [tool], "which \(tool)"), - ("/bin/zsh", ["-lc", "whence -p \(tool)"], "zsh -lc 'whence -p \(tool)'"), - ("/bin/zsh", ["-ilc", "whence -p \(tool)"], "zsh -ilc 'whence -p \(tool)'") - ] - - for probe in probes { - let result = try await processRunner.run( - executable: probe.executable, - arguments: probe.arguments, - currentDirectory: nil - ) - - appendBuildLog(paths: paths, line: "$ \(probe.display)") - if !result.stdout.isEmpty { - appendBuildLog(paths: paths, line: result.stdout) - } - if !result.stderr.isEmpty { - appendBuildLog(paths: paths, line: result.stderr) - } - - if result.exitCode == 0, - let resolvedPath = firstExecutablePath(from: result.stdout) { - appendBuildLog(paths: paths, line: "Resolved \(tool) -> \(resolvedPath)") - return resolvedPath - } - } + onStateUpdate?(.building) - throw ExcalidrawRuntimeError.missingTool(tool) - } - - private func resolveYarnTool( - paths: ExcalidrawRuntimePaths, - resolvedNodePath: String - ) async throws -> ExcalidrawShellTool { - do { - let resolvedYarnPath = try await ensureToolAvailable("yarn", paths: paths) - return ExcalidrawShellTool( - executablePath: resolvedYarnPath, - shellCommand: shellQuotedExecutable(resolvedYarnPath) - ) - } catch let error as ExcalidrawRuntimeError { - switch error { - case .missingTool(let tool) where tool == "yarn": - appendBuildLog(paths: paths, line: "Yarn executable not found; trying corepack fallback") - break - default: - throw error - } - } + let installCommand = nonInteractiveShellCommand( + replacingLeadingToolInvocation( + in: manifest.installCommand, + tool: "yarn", + replacement: resolvedYarnTool.shellCommand + ), + preferredToolDirectories: preferredToolDirectories + ) + try await runChecked( + executable: "/bin/zsh", + arguments: ["-lc", installCommand], + currentDirectory: paths.sourceDirectory.path, + paths: paths + ) - if let adjacentCorepackPath = adjacentExecutable( - named: "corepack", - nextTo: resolvedNodePath - ) { - appendBuildLog(paths: paths, line: "Resolved yarn via adjacent corepack -> \(adjacentCorepackPath)") - return ExcalidrawShellTool( - executablePath: adjacentCorepackPath, - shellCommand: "\(shellQuotedExecutable(adjacentCorepackPath)) yarn" - ) - } + let buildCommand = nonInteractiveShellCommand( + replacingLeadingToolInvocation( + in: manifest.buildCommand, + tool: "yarn", + replacement: resolvedYarnTool.shellCommand + ), + preferredToolDirectories: preferredToolDirectories + ) + try await runChecked( + executable: "/bin/zsh", + arguments: ["-lc", buildCommand], + currentDirectory: paths.sourceDirectory.path, + paths: paths + ) - do { - let resolvedCorepackPath = try await ensureToolAvailable("corepack", paths: paths) - appendBuildLog(paths: paths, line: "Resolved yarn via corepack -> \(resolvedCorepackPath)") - return ExcalidrawShellTool( - executablePath: resolvedCorepackPath, - shellCommand: "\(shellQuotedExecutable(resolvedCorepackPath)) yarn" - ) - } catch let error as ExcalidrawRuntimeError { - switch error { - case .missingTool(let tool) where tool == "corepack": - appendBuildLog( - paths: paths, - line: "Corepack was not found after Yarn lookup failed; Excalidraw cannot run package manager commands" - ) - throw ExcalidrawRuntimeError.missingYarnPackageManager(nodePath: resolvedNodePath) - default: - throw error - } - } + if let firstMissingArtifact = missingRequiredArtifacts(manifest: manifest, paths: paths).first { + throw ExcalidrawRuntimeError.missingArtifact(firstMissingArtifact) } - private func firstExecutablePath(from output: String) -> String? { - let candidates = output - .split(whereSeparator: \.isNewline) - .map { String($0).trimmingCharacters(in: .whitespacesAndNewlines) } - .filter { !$0.isEmpty } + let record = ExcalidrawBuildRecord( + sourceCommit: resolvedSourceCommit, + entrypoint: manifest.entrypoint, + builtAt: Date() + ) + let recordData = try JSONEncoder().encode(record) + try fileManager.createDirectory(at: paths.buildRecordPath.deletingLastPathComponent(), withIntermediateDirectories: true) + try recordData.write(to: paths.buildRecordPath, options: .atomic) - return candidates.first(where: { $0.hasPrefix("/") }) - } + appendBuildLog(paths: paths, line: "== build complete \(Date())") - private func adjacentExecutable( - named executable: String, - nextTo resolvedPath: String - ) -> String? { - let candidate = URL(fileURLWithPath: resolvedPath, isDirectory: false) - .deletingLastPathComponent() - .appendingPathComponent(executable, isDirectory: false) - .path - guard fileManager.isExecutableFile(atPath: candidate) else { - return nil - } - return candidate + let entrypointURL = paths.sourceDirectory.appendingPathComponent(manifest.entrypoint, isDirectory: false) + guard fileManager.fileExists(atPath: entrypointURL.path) else { + throw ExcalidrawRuntimeError.missingArtifact(manifest.entrypoint) } - private func uniqueParentDirectories(for resolvedToolPaths: [String]) -> [String] { - var seen: Set = [] - var directories: [String] = [] - - for path in resolvedToolPaths { - let parentDirectory = URL(fileURLWithPath: path).deletingLastPathComponent().path - guard !parentDirectory.isEmpty, !seen.contains(parentDirectory) else { continue } - seen.insert(parentDirectory) - directories.append(parentDirectory) - } + return entrypointURL + } + + private func resolveLatestSourceCommit( + resolvedGitPath: String, + manifest: ExcalidrawBuildManifest, + paths: ExcalidrawRuntimePaths + ) async throws -> String { + for candidateRef in [ + "origin/HEAD", + "origin/main", + "HEAD", + manifest.pinnedCommit, + ] { + guard let resolved = try await revParse( + resolvedGitPath: resolvedGitPath, + ref: candidateRef, + paths: paths + ) else { + continue + } + appendBuildLog(paths: paths, line: "Resolved source revision \(candidateRef) -> \(resolved)") + return resolved + } - return directories + throw ExcalidrawRuntimeError.missingArtifact("unable to resolve source revision") + } + + private func revParse( + resolvedGitPath: String, + ref: String, + paths: ExcalidrawRuntimePaths + ) async throws -> String? { + let result = try await runLogged( + executable: resolvedGitPath, + arguments: ["-C", paths.sourceDirectory.path, "rev-parse", ref], + currentDirectory: paths.sourceDirectory.path, + paths: paths + ) + guard result.exitCode == 0 else { + return nil } - private func replacingLeadingToolInvocation( - in command: String, - tool: String, - replacement: String - ) -> String { - if command == tool { - return replacement - } + return result.stdout + .split(whereSeparator: \.isNewline) + .map { String($0).trimmingCharacters(in: .whitespacesAndNewlines) } + .first(where: { !$0.isEmpty }) + } + + private func ensureToolAvailable(_ tool: String, paths: ExcalidrawRuntimePaths) async throws -> String { + let probes: [(executable: String, arguments: [String], display: String)] = [ + ("/usr/bin/which", [tool], "which \(tool)"), + ("/bin/zsh", ["-lc", "whence -p \(tool)"], "zsh -lc 'whence -p \(tool)'"), + ("/bin/zsh", ["-ilc", "whence -p \(tool)"], "zsh -ilc 'whence -p \(tool)'"), + ] + + for probe in probes { + let result = try await processRunner.run( + executable: probe.executable, + arguments: probe.arguments, + currentDirectory: nil + ) + + appendBuildLog(paths: paths, line: "$ \(probe.display)") + if !result.stdout.isEmpty { + appendBuildLog(paths: paths, line: result.stdout) + } + if !result.stderr.isEmpty { + appendBuildLog(paths: paths, line: result.stderr) + } + + if result.exitCode == 0, + let resolvedPath = firstExecutablePath(from: result.stdout) + { + appendBuildLog(paths: paths, line: "Resolved \(tool) -> \(resolvedPath)") + return resolvedPath + } + } - let toolPrefix = "\(tool) " - guard command.hasPrefix(toolPrefix) else { - return command - } + throw ExcalidrawRuntimeError.missingTool(tool) + } + + private func resolveYarnTool( + paths: ExcalidrawRuntimePaths, + resolvedNodePath: String + ) async throws -> ExcalidrawShellTool { + do { + let resolvedYarnPath = try await ensureToolAvailable("yarn", paths: paths) + return ExcalidrawShellTool( + executablePath: resolvedYarnPath, + shellCommand: shellQuotedExecutable(resolvedYarnPath) + ) + } catch let error as ExcalidrawRuntimeError { + switch error { + case let .missingTool(tool) where tool == "yarn": + appendBuildLog(paths: paths, line: "Yarn executable not found; trying corepack fallback") + default: + throw error + } + } - return replacement + command.dropFirst(tool.count) + if let adjacentCorepackPath = adjacentExecutable( + named: "corepack", + nextTo: resolvedNodePath + ) { + appendBuildLog(paths: paths, line: "Resolved yarn via adjacent corepack -> \(adjacentCorepackPath)") + return ExcalidrawShellTool( + executablePath: adjacentCorepackPath, + shellCommand: "\(shellQuotedExecutable(adjacentCorepackPath)) yarn" + ) } - private func nonInteractiveShellCommand( - _ command: String, - preferredToolDirectories: [String] - ) -> String { - var parts = [ - "export CI=1", - "export COREPACK_ENABLE_DOWNLOAD_PROMPT=0" - ] + do { + let resolvedCorepackPath = try await ensureToolAvailable("corepack", paths: paths) + appendBuildLog(paths: paths, line: "Resolved yarn via corepack -> \(resolvedCorepackPath)") + return ExcalidrawShellTool( + executablePath: resolvedCorepackPath, + shellCommand: "\(shellQuotedExecutable(resolvedCorepackPath)) yarn" + ) + } catch let error as ExcalidrawRuntimeError { + switch error { + case let .missingTool(tool) where tool == "corepack": + appendBuildLog( + paths: paths, + line: "Corepack was not found after Yarn lookup failed; Excalidraw cannot run package manager commands" + ) + throw ExcalidrawRuntimeError.missingYarnPackageManager(nodePath: resolvedNodePath) + default: + throw error + } + } + } + + private func firstExecutablePath(from output: String) -> String? { + let candidates = output + .split(whereSeparator: \.isNewline) + .map { String($0).trimmingCharacters(in: .whitespacesAndNewlines) } + .filter { !$0.isEmpty } + + return candidates.first(where: { $0.hasPrefix("/") }) + } + + private func adjacentExecutable( + named executable: String, + nextTo resolvedPath: String + ) -> String? { + let candidate = URL(fileURLWithPath: resolvedPath, isDirectory: false) + .deletingLastPathComponent() + .appendingPathComponent(executable, isDirectory: false) + .path + guard fileManager.isExecutableFile(atPath: candidate) else { + return nil + } + return candidate + } + + private func uniqueParentDirectories(for resolvedToolPaths: [String]) -> [String] { + var seen: Set = [] + var directories: [String] = [] + + for path in resolvedToolPaths { + let parentDirectory = URL(fileURLWithPath: path).deletingLastPathComponent().path + guard !parentDirectory.isEmpty, !seen.contains(parentDirectory) else { continue } + seen.insert(parentDirectory) + directories.append(parentDirectory) + } - if !preferredToolDirectories.isEmpty { - let joinedDirectories = preferredToolDirectories.joined(separator: ":") - parts.append("export PATH='\(shellEscapeSingleQuoted(joinedDirectories))':\"$PATH\"") - } + return directories + } - parts.append(command) - return parts.joined(separator: "; ") + private func replacingLeadingToolInvocation( + in command: String, + tool: String, + replacement: String + ) -> String { + if command == tool { + return replacement } - private func shellQuotedExecutable(_ value: String) -> String { - "'\(shellEscapeSingleQuoted(value))'" + let toolPrefix = "\(tool) " + guard command.hasPrefix(toolPrefix) else { + return command } - private func shellEscapeSingleQuoted(_ value: String) -> String { - value.replacingOccurrences(of: "'", with: "'\\''") + return replacement + command.dropFirst(tool.count) + } + + private func nonInteractiveShellCommand( + _ command: String, + preferredToolDirectories: [String] + ) -> String { + var parts = [ + "export CI=1", + "export COREPACK_ENABLE_DOWNLOAD_PROMPT=0", + ] + + if !preferredToolDirectories.isEmpty { + let joinedDirectories = preferredToolDirectories.joined(separator: ":") + parts.append("export PATH='\(shellEscapeSingleQuoted(joinedDirectories))':\"$PATH\"") } - private func runChecked( - executable: String, - arguments: [String], - currentDirectory: String?, - paths: ExcalidrawRuntimePaths - ) async throws { - let command = ([executable] + arguments).joined(separator: " ") - appendBuildLog(paths: paths, line: "$ \(command)") - - let result = try await processRunner.run( - executable: executable, - arguments: arguments, - currentDirectory: currentDirectory - ) - - if !result.stdout.isEmpty { - appendBuildLog(paths: paths, line: result.stdout) - } - if !result.stderr.isEmpty { - appendBuildLog(paths: paths, line: result.stderr) - } - - guard result.exitCode == 0 else { - throw ExcalidrawRuntimeError.commandFailed( - command: command, - code: result.exitCode, - stderr: result.stderr.isEmpty ? nil : result.stderr - ) - } + parts.append(command) + return parts.joined(separator: "; ") + } + + private func shellQuotedExecutable(_ value: String) -> String { + "'\(shellEscapeSingleQuoted(value))'" + } + + private func shellEscapeSingleQuoted(_ value: String) -> String { + value.replacingOccurrences(of: "'", with: "'\\''") + } + + private func runChecked( + executable: String, + arguments: [String], + currentDirectory: String?, + paths: ExcalidrawRuntimePaths + ) async throws { + let result = try await runLogged( + executable: executable, + arguments: arguments, + currentDirectory: currentDirectory, + paths: paths + ) + guard result.exitCode == 0 else { + let command = ([executable] + arguments).joined(separator: " ") + throw ExcalidrawRuntimeError.commandFailed( + command: command, + code: result.exitCode, + stderr: result.stderr.isEmpty ? nil : result.stderr + ) } + } + + private func runLogged( + executable: String, + arguments: [String], + currentDirectory: String?, + paths: ExcalidrawRuntimePaths + ) async throws -> ProcessResult { + let command = ([executable] + arguments).joined(separator: " ") + appendBuildLog(paths: paths, line: "$ \(command)") + + let result = try await processRunner.run( + executable: executable, + arguments: arguments, + currentDirectory: currentDirectory + ) - private func appendBuildLog(paths: ExcalidrawRuntimePaths, line: String) { - let timestamp = ISO8601DateFormatter().string(from: Date()) - let logLine = "[\(timestamp)] \(line)\n" - - do { - try fileManager.createDirectory(at: paths.buildLogPath.deletingLastPathComponent(), withIntermediateDirectories: true) - if !fileManager.fileExists(atPath: paths.buildLogPath.path) { - try logLine.write(to: paths.buildLogPath, atomically: true, encoding: .utf8) - return - } - - let handle = try FileHandle(forWritingTo: paths.buildLogPath) - defer { try? handle.close() } - try handle.seekToEnd() - if let data = logLine.data(using: .utf8) { - try handle.write(contentsOf: data) - } - } catch { - Logger.error("Failed to append Excalidraw build log: \(error.localizedDescription)") - } + if !result.stdout.isEmpty { + appendBuildLog(paths: paths, line: result.stdout) + } + if !result.stderr.isEmpty { + appendBuildLog(paths: paths, line: result.stderr) } + return result + } + + private func appendBuildLog(paths: ExcalidrawRuntimePaths, line: String) { + let timestamp = ISO8601DateFormatter().string(from: Date()) + let logLine = "[\(timestamp)] \(line)\n" + + do { + try fileManager.createDirectory(at: paths.buildLogPath.deletingLastPathComponent(), withIntermediateDirectories: true) + if !fileManager.fileExists(atPath: paths.buildLogPath.path) { + try logLine.write(to: paths.buildLogPath, atomically: true, encoding: .utf8) + return + } + + let handle = try FileHandle(forWritingTo: paths.buildLogPath) + defer { try? handle.close() } + try handle.seekToEnd() + if let data = logLine.data(using: .utf8) { + try handle.write(contentsOf: data) + } + } catch { + Logger.error("Failed to append Excalidraw build log: \(error.localizedDescription)") + } + } - private func missingRequiredArtifacts(manifest: ExcalidrawBuildManifest, paths: ExcalidrawRuntimePaths) -> [String] { - manifest.requiredArtifacts.filter { artifact in - let artifactURL = paths.sourceDirectory.appendingPathComponent(artifact, isDirectory: false) - return !fileManager.fileExists(atPath: artifactURL.path) - } + private func missingRequiredArtifacts(manifest: ExcalidrawBuildManifest, paths: ExcalidrawRuntimePaths) -> [String] { + manifest.requiredArtifacts.filter { artifact in + let artifactURL = paths.sourceDirectory.appendingPathComponent(artifact, isDirectory: false) + return !fileManager.fileExists(atPath: artifactURL.path) } + } } private struct ExcalidrawSessionOriginRecord: Codable { - var portsBySessionID: [String: Int] = [:] + var portsBySessionID: [String: Int] = [:] } @MainActor final class ExcalidrawSessionOriginStore { - private let recordURL: URL - private let fileManager: FileManager - private let portBase: Int - private let portSpan: Int - - init( - recordURL: URL, - fileManager: FileManager = .default, - portBase: Int = 46_000, - portSpan: Int = 10_000 - ) { - self.recordURL = recordURL - self.fileManager = fileManager - self.portBase = portBase - self.portSpan = max(256, portSpan) + private let recordURL: URL + private let fileManager: FileManager + private let portBase: Int + private let portSpan: Int + + init( + recordURL: URL, + fileManager: FileManager = .default, + portBase: Int = 46000, + portSpan: Int = 10000 + ) { + self.recordURL = recordURL + self.fileManager = fileManager + self.portBase = portBase + self.portSpan = max(256, portSpan) + } + + func preferredPort(for sessionID: UUID) -> Int { + let record = loadRecord() + if let existing = record.portsBySessionID[sessionID.uuidString], isValidPort(existing) { + return existing } - - func preferredPort(for sessionID: UUID) -> Int { - let record = loadRecord() - if let existing = record.portsBySessionID[sessionID.uuidString], isValidPort(existing) { - return existing - } - return deterministicPort(for: sessionID) - } - - func persistPort(_ port: Int, for sessionID: UUID) { - guard isValidPort(port) else { return } - var record = loadRecord() - record.portsBySessionID[sessionID.uuidString] = port - saveRecord(record) - } - - func removePort(for sessionID: UUID) { - var record = loadRecord() - record.portsBySessionID.removeValue(forKey: sessionID.uuidString) - saveRecord(record) + return deterministicPort(for: sessionID) + } + + func persistPort(_ port: Int, for sessionID: UUID) { + guard isValidPort(port) else { return } + var record = loadRecord() + record.portsBySessionID[sessionID.uuidString] = port + saveRecord(record) + } + + func removePort(for sessionID: UUID) { + var record = loadRecord() + record.portsBySessionID.removeValue(forKey: sessionID.uuidString) + saveRecord(record) + } + + private func loadRecord() -> ExcalidrawSessionOriginRecord { + guard fileManager.fileExists(atPath: recordURL.path), + let data = try? Data(contentsOf: recordURL), + let decoded = try? JSONDecoder().decode(ExcalidrawSessionOriginRecord.self, from: data) + else { + return ExcalidrawSessionOriginRecord() } - - private func loadRecord() -> ExcalidrawSessionOriginRecord { - guard fileManager.fileExists(atPath: recordURL.path), - let data = try? Data(contentsOf: recordURL), - let decoded = try? JSONDecoder().decode(ExcalidrawSessionOriginRecord.self, from: data) - else { - return ExcalidrawSessionOriginRecord() - } - return decoded - } - - private func saveRecord(_ record: ExcalidrawSessionOriginRecord) { - do { - try fileManager.createDirectory(at: recordURL.deletingLastPathComponent(), withIntermediateDirectories: true) - let encoder = JSONEncoder() - encoder.outputFormatting = [.prettyPrinted, .sortedKeys] - let data = try encoder.encode(record) - try data.write(to: recordURL, options: .atomic) - } catch { - Logger.error("Failed to persist Excalidraw session origin record: \(error.localizedDescription)") - } + return decoded + } + + private func saveRecord(_ record: ExcalidrawSessionOriginRecord) { + do { + try fileManager.createDirectory(at: recordURL.deletingLastPathComponent(), withIntermediateDirectories: true) + let encoder = JSONEncoder() + encoder.outputFormatting = [.prettyPrinted, .sortedKeys] + let data = try encoder.encode(record) + try data.write(to: recordURL, options: .atomic) + } catch { + Logger.error("Failed to persist Excalidraw session origin record: \(error.localizedDescription)") } - - private func deterministicPort(for sessionID: UUID) -> Int { - let hash = fnv1a64(sessionID.uuidString) - return portBase + Int(hash % UInt64(portSpan)) + } + + private func deterministicPort(for sessionID: UUID) -> Int { + let hash = fnv1a64(sessionID.uuidString) + return portBase + Int(hash % UInt64(portSpan)) + } + + private func fnv1a64(_ value: String) -> UInt64 { + let prime: UInt64 = 1_099_511_628_211 + var hash: UInt64 = 14_695_981_039_346_656_037 + for byte in value.utf8 { + hash ^= UInt64(byte) + hash &*= prime } + return hash + } - private func fnv1a64(_ value: String) -> UInt64 { - let prime: UInt64 = 1_099_511_628_211 - var hash: UInt64 = 14_695_981_039_346_656_037 - for byte in value.utf8 { - hash ^= UInt64(byte) - hash &*= prime - } - return hash - } - - private func isValidPort(_ port: Int) -> Bool { - (1025...65535).contains(port) - } + private func isValidPort(_ port: Int) -> Bool { + (1025 ... 65535).contains(port) + } } @MainActor final class ExcalidrawTileController: ObservableObject, NiriAppTileRuntimeControlling { - @Published private(set) var state: ExcalidrawTileRuntimeState = .idle - - let sessionID: UUID - let itemID: UUID - let webView: WKWebView - - private let launchDirectoryProvider: () -> String? - private let buildCoordinator: ExcalidrawBuildCoordinator - private let originStore: ExcalidrawSessionOriginStore - private let manifestProvider: () -> ExcalidrawBuildManifest - private let paths: ExcalidrawRuntimePaths - - private let readinessIntervalNanoseconds: UInt64 = 250_000_000 - private let readinessTimeoutSeconds: TimeInterval = 20 - private let maxAutomaticRestarts = 3 - private let defaultZoom: CGFloat = 1.0 - private let minimumZoom: CGFloat = 0.5 - private let maximumZoom: CGFloat = 3.0 - - private var startTask: Task? - private var process: Process? - private var stdoutPipe: Pipe? - private var stderrPipe: Pipe? - private var logHandle: FileHandle? - private var userStopped = false - private var automaticRestartCount = 0 - - init( - sessionID: UUID, - itemID: UUID, - launchDirectoryProvider: @escaping () -> String?, - buildCoordinator: ExcalidrawBuildCoordinator, - originStore: ExcalidrawSessionOriginStore? = nil, - manifestProvider: @escaping () -> ExcalidrawBuildManifest = { ExcalidrawBuildManifest.loadFromBundle() }, - rootDirectoryOverride: URL? = nil - ) { - self.sessionID = sessionID - self.itemID = itemID - self.launchDirectoryProvider = launchDirectoryProvider - self.buildCoordinator = buildCoordinator - self.paths = ExcalidrawRuntimePaths(sessionID: sessionID, rootDirectoryOverride: rootDirectoryOverride) - self.originStore = originStore ?? ExcalidrawSessionOriginStore(recordURL: paths.originsRecordPath) - self.manifestProvider = manifestProvider - - let configuration = WKWebViewConfiguration() - configuration.defaultWebpagePreferences.allowsContentJavaScript = true - configuration.websiteDataStore = .default() - webView = WKWebView(frame: .zero, configuration: configuration) - webView.pageZoom = defaultZoom - } - - func ensureStarted() { - guard startTask == nil else { return } - switch state { - case .preparingSource, .building, .starting, .live: - return - case .idle, .failed: - break - } - - userStopped = false - startTask = Task { [weak self] in - guard let self else { return } - await self.runStartupSequence() - self.startTask = nil - } - } - - func retry() { - stop() - automaticRestartCount = 0 - state = .idle - ensureStarted() - } - - func stop() { - userStopped = true - startTask?.cancel() - startTask = nil - terminateProcess() - state = .idle - } - - func openLogsInFinder() { - let url: URL - if case .failed(_, let logPath) = state, - let logPath, - !logPath.isEmpty { - url = URL(fileURLWithPath: logPath, isDirectory: false) - } else { - url = paths.runtimeLogPath - } - guard FileManager.default.fileExists(atPath: url.path) else { return } - NSWorkspace.shared.activateFileViewerSelecting([url]) + @Published private(set) var state: ExcalidrawTileRuntimeState = .idle + + let sessionID: UUID + let itemID: UUID + let webView: WKWebView + + private let launchDirectoryProvider: () -> String? + private let buildCoordinator: ExcalidrawBuildCoordinator + private let originStore: ExcalidrawSessionOriginStore + private let manifestProvider: () -> ExcalidrawBuildManifest + private let paths: ExcalidrawRuntimePaths + + private let readinessIntervalNanoseconds: UInt64 = 250_000_000 + private let readinessTimeoutSeconds: TimeInterval = 20 + private let maxAutomaticRestarts = 3 + private let defaultZoom: CGFloat = 1.0 + private let minimumZoom: CGFloat = 0.5 + private let maximumZoom: CGFloat = 3.0 + + private var startTask: Task? + private var process: Process? + private var stdoutPipe: Pipe? + private var stderrPipe: Pipe? + private var logHandle: FileHandle? + private var userStopped = false + private var automaticRestartCount = 0 + + init( + sessionID: UUID, + itemID: UUID, + launchDirectoryProvider: @escaping () -> String?, + buildCoordinator: ExcalidrawBuildCoordinator, + originStore: ExcalidrawSessionOriginStore? = nil, + manifestProvider: @escaping () -> ExcalidrawBuildManifest = { ExcalidrawBuildManifest.loadFromBundle() }, + rootDirectoryOverride: URL? = nil + ) { + self.sessionID = sessionID + self.itemID = itemID + self.launchDirectoryProvider = launchDirectoryProvider + self.buildCoordinator = buildCoordinator + paths = ExcalidrawRuntimePaths(sessionID: sessionID, rootDirectoryOverride: rootDirectoryOverride) + self.originStore = originStore ?? ExcalidrawSessionOriginStore(recordURL: paths.originsRecordPath) + self.manifestProvider = manifestProvider + + let configuration = WKWebViewConfiguration() + configuration.defaultWebpagePreferences.allowsContentJavaScript = true + configuration.websiteDataStore = .default() + webView = WKWebView(frame: .zero, configuration: configuration) + webView.pageZoom = defaultZoom + } + + func ensureStarted() { + guard startTask == nil else { return } + switch state { + case .preparingSource, .building, .starting, .live: + return + case .idle, .failed: + break } - var runtimeLogPath: String { - paths.runtimeLogPath.path + userStopped = false + startTask = Task { [weak self] in + guard let self else { return } + await runStartupSequence() + startTask = nil } - - @discardableResult - func adjustZoom(by delta: CGFloat) -> Bool { - let current = webView.pageZoom - let next = max(minimumZoom, min(maximumZoom, current + delta)) - webView.pageZoom = next - return true + } + + func retry() { + stop() + automaticRestartCount = 0 + state = .idle + ensureStarted() + } + + func stop() { + userStopped = true + startTask?.cancel() + startTask = nil + terminateProcess() + state = .idle + } + + func openLogsInFinder() { + let url: URL = if case let .failed(_, logPath) = state, + let logPath, + !logPath.isEmpty + { + URL(fileURLWithPath: logPath, isDirectory: false) + } else { + paths.runtimeLogPath } - - private func runStartupSequence() async { - let manifest = manifestProvider() - - while !Task.isCancelled { - do { - try await startupAttempt(manifest: manifest) - automaticRestartCount = 0 - return - } catch { - if userStopped || Task.isCancelled { - return - } - - let description = (error as? LocalizedError)?.errorDescription ?? error.localizedDescription - appendRuntimeLog("startup attempt failed: \(description)") - - if !isRetryableStartupError(error) { - state = .failed(message: description, logPath: logPathForError(error)) - return - } - - guard automaticRestartCount < maxAutomaticRestarts else { - state = .failed(message: description, logPath: logPathForError(error)) - return - } - - automaticRestartCount += 1 - let backoff = min(pow(2, Double(automaticRestartCount - 1)) * 0.5, 10) - state = .starting - try? await Task.sleep(nanoseconds: UInt64(backoff * 1_000_000_000)) - } + guard FileManager.default.fileExists(atPath: url.path) else { return } + NSWorkspace.shared.activateFileViewerSelecting([url]) + } + + var runtimeLogPath: String { + paths.runtimeLogPath.path + } + + @discardableResult + func adjustZoom(by delta: CGFloat) -> Bool { + let current = webView.pageZoom + let next = max(minimumZoom, min(maximumZoom, current + delta)) + webView.pageZoom = next + return true + } + + private func runStartupSequence() async { + let manifest = manifestProvider() + + while !Task.isCancelled { + do { + try await startupAttempt(manifest: manifest) + automaticRestartCount = 0 + return + } catch { + if userStopped || Task.isCancelled { + return } - } - private func startupAttempt(manifest: ExcalidrawBuildManifest) async throws { - try paths.ensureBaseDirectories() + let description = (error as? LocalizedError)?.errorDescription ?? error.localizedDescription + appendRuntimeLog("startup attempt failed: \(description)") - let entrypointURL = try await buildCoordinator.ensureBuilt(manifest: manifest, paths: paths) { [weak self] newState in - self?.state = newState + if !isRetryableStartupError(error) { + state = .failed(message: description, logPath: logPathForError(error)) + return } - if userStopped || Task.isCancelled { - throw ExcalidrawRuntimeError.cancelled + guard automaticRestartCount < maxAutomaticRestarts else { + state = .failed(message: description, logPath: logPathForError(error)) + return } - let preferredPort = originStore.preferredPort(for: sessionID) - let port = try reserveLoopbackPort(preferredPort: preferredPort) - originStore.persistPort(port, for: sessionID) - + automaticRestartCount += 1 + let backoff = min(pow(2, Double(automaticRestartCount - 1)) * 0.5, 10) state = .starting + try? await Task.sleep(nanoseconds: UInt64(backoff * 1_000_000_000)) + } + } + } - let webRootURL = entrypointURL.deletingLastPathComponent() - try launchProcess( - webRootURL: webRootURL, - port: port, - launchDirectory: launchDirectoryProvider() ?? FileManager.default.homeDirectoryForCurrentUser.path - ) - - let ready = await waitForServerReady(port: port) - guard ready else { - let exitedBeforeReady = (process?.isRunning == false) - terminateProcess() - if userStopped || Task.isCancelled { - throw ExcalidrawRuntimeError.cancelled - } - if exitedBeforeReady { - throw ExcalidrawRuntimeError.processExitedBeforeReady - } - throw ExcalidrawRuntimeError.startupTimeout - } + private func startupAttempt(manifest: ExcalidrawBuildManifest) async throws { + try paths.ensureBaseDirectories() - if userStopped || Task.isCancelled { - terminateProcess() - throw ExcalidrawRuntimeError.cancelled - } + let entrypointURL = try await buildCoordinator.ensureBuilt(manifest: manifest, paths: paths) { [weak self] newState in + self?.state = newState + } - let url = URL(string: "http://127.0.0.1:\(port)/")! - webView.load(URLRequest(url: url)) - state = .live(urlString: url.absoluteString) - appendRuntimeLog("runtime live at \(url.absoluteString)") + if userStopped || Task.isCancelled { + throw ExcalidrawRuntimeError.cancelled } - private func reserveLoopbackPort(preferredPort: Int) throws -> Int { - if let reserved = try reserveSpecificLoopbackPort(preferredPort) { - return reserved - } + let preferredPort = originStore.preferredPort(for: sessionID) + let port = try reserveLoopbackPort(preferredPort: preferredPort) + originStore.persistPort(port, for: sessionID) - for offset in 1...32 { - if let candidate = try reserveSpecificLoopbackPort(preferredPort + offset) { - return candidate - } - if let candidate = try reserveSpecificLoopbackPort(preferredPort - offset) { - return candidate - } - } + state = .starting - if let fallback = try reserveSpecificLoopbackPort(0) { - return fallback - } + let webRootURL = entrypointURL.deletingLastPathComponent() + try launchProcess( + webRootURL: webRootURL, + port: port, + launchDirectory: launchDirectoryProvider() ?? FileManager.default.homeDirectoryForCurrentUser.path + ) - throw ExcalidrawRuntimeError.startupTimeout + let ready = await waitForServerReady(port: port) + guard ready else { + let exitedBeforeReady = (process?.isRunning == false) + terminateProcess() + if userStopped || Task.isCancelled { + throw ExcalidrawRuntimeError.cancelled + } + if exitedBeforeReady { + throw ExcalidrawRuntimeError.processExitedBeforeReady + } + throw ExcalidrawRuntimeError.startupTimeout } - private func reserveSpecificLoopbackPort(_ requestedPort: Int) throws -> Int? { - guard requestedPort == 0 || (1025...65535).contains(requestedPort) else { - return nil - } + if userStopped || Task.isCancelled { + terminateProcess() + throw ExcalidrawRuntimeError.cancelled + } - let socketFD = socket(AF_INET, SOCK_STREAM, 0) - guard socketFD >= 0 else { return nil } - defer { close(socketFD) } + let url = URL(string: "http://127.0.0.1:\(port)/")! + webView.load(URLRequest(url: url)) + state = .live(urlString: url.absoluteString) + appendRuntimeLog("runtime live at \(url.absoluteString)") + } - var address = sockaddr_in() - address.sin_len = UInt8(MemoryLayout.size) - address.sin_family = sa_family_t(AF_INET) - address.sin_port = in_port_t(requestedPort).bigEndian - address.sin_addr = in_addr(s_addr: inet_addr("127.0.0.1")) + private func reserveLoopbackPort(preferredPort: Int) throws -> Int { + if let reserved = try reserveSpecificLoopbackPort(preferredPort) { + return reserved + } - let bindResult = withUnsafePointer(to: &address) { - $0.withMemoryRebound(to: sockaddr.self, capacity: 1) { - bind(socketFD, $0, socklen_t(MemoryLayout.size)) - } - } + for offset in 1 ... 32 { + if let candidate = try reserveSpecificLoopbackPort(preferredPort + offset) { + return candidate + } + if let candidate = try reserveSpecificLoopbackPort(preferredPort - offset) { + return candidate + } + } - guard bindResult == 0 else { - return nil - } + if let fallback = try reserveSpecificLoopbackPort(0) { + return fallback + } - var assignedAddress = sockaddr_in() - var length = socklen_t(MemoryLayout.size) + throw ExcalidrawRuntimeError.startupTimeout + } - let nameResult = withUnsafeMutablePointer(to: &assignedAddress) { - $0.withMemoryRebound(to: sockaddr.self, capacity: 1) { - getsockname(socketFD, $0, &length) - } - } + private func reserveSpecificLoopbackPort(_ requestedPort: Int) throws -> Int? { + guard requestedPort == 0 || (1025 ... 65535).contains(requestedPort) else { + return nil + } - guard nameResult == 0 else { - throw ExcalidrawRuntimeError.startupTimeout - } + let socketFD = socket(AF_INET, SOCK_STREAM, 0) + guard socketFD >= 0 else { return nil } + defer { close(socketFD) } + + var address = sockaddr_in() + address.sin_len = UInt8(MemoryLayout.size) + address.sin_family = sa_family_t(AF_INET) + address.sin_port = in_port_t(requestedPort).bigEndian + address.sin_addr = in_addr(s_addr: inet_addr("127.0.0.1")) - return Int(UInt16(bigEndian: assignedAddress.sin_port)) + let bindResult = withUnsafePointer(to: &address) { + $0.withMemoryRebound(to: sockaddr.self, capacity: 1) { + bind(socketFD, $0, socklen_t(MemoryLayout.size)) + } } - private func launchProcess( - webRootURL: URL, - port: Int, - launchDirectory: String - ) throws { - terminateProcess() + guard bindResult == 0 else { + return nil + } - try FileManager.default.createDirectory(at: paths.runtimeLogPath.deletingLastPathComponent(), withIntermediateDirectories: true) - if !FileManager.default.fileExists(atPath: paths.runtimeLogPath.path) { - FileManager.default.createFile(atPath: paths.runtimeLogPath.path, contents: nil) - } + var assignedAddress = sockaddr_in() + var length = socklen_t(MemoryLayout.size) - let handle = try FileHandle(forWritingTo: paths.runtimeLogPath) - try handle.seekToEnd() - logHandle = handle + let nameResult = withUnsafeMutablePointer(to: &assignedAddress) { + $0.withMemoryRebound(to: sockaddr.self, capacity: 1) { + getsockname(socketFD, $0, &length) + } + } - let process = Process() - - if let pythonPath = resolveRuntimeExecutablePath("python3") { - process.executableURL = URL(fileURLWithPath: pythonPath) - process.arguments = ["-m", "http.server", String(port), "--bind", "127.0.0.1"] - appendRuntimeLog("resolved python executable: \(pythonPath)") - } else if FileManager.default.isExecutableFile(atPath: "/usr/bin/python3") { - process.executableURL = URL(fileURLWithPath: "/usr/bin/python3") - process.arguments = ["-m", "http.server", String(port), "--bind", "127.0.0.1"] - appendRuntimeLog("python resolution fallback: using /usr/bin/python3") - } else { - process.executableURL = URL(fileURLWithPath: "/usr/bin/env") - process.arguments = ["python3", "-m", "http.server", String(port), "--bind", "127.0.0.1"] - appendRuntimeLog("python resolution fallback: using /usr/bin/env python3") - } + guard nameResult == 0 else { + throw ExcalidrawRuntimeError.startupTimeout + } - process.currentDirectoryURL = webRootURL + return Int(UInt16(bigEndian: assignedAddress.sin_port)) + } - var env = ProcessInfo.processInfo.environment - env["PWD"] = launchDirectory - process.environment = env + private func launchProcess( + webRootURL: URL, + port: Int, + launchDirectory: String + ) throws { + terminateProcess() - let stdout = Pipe() - let stderr = Pipe() - process.standardOutput = stdout - process.standardError = stderr + try FileManager.default.createDirectory(at: paths.runtimeLogPath.deletingLastPathComponent(), withIntermediateDirectories: true) + if !FileManager.default.fileExists(atPath: paths.runtimeLogPath.path) { + FileManager.default.createFile(atPath: paths.runtimeLogPath.path, contents: nil) + } - stdout.fileHandleForReading.readabilityHandler = { [weak self] handle in - let data = handle.availableData - guard !data.isEmpty else { return } - Task { @MainActor [weak self] in - self?.appendLogData(data) - } - } + let handle = try FileHandle(forWritingTo: paths.runtimeLogPath) + try handle.seekToEnd() + logHandle = handle + + let process = Process() + + if let pythonPath = resolveRuntimeExecutablePath("python3") { + process.executableURL = URL(fileURLWithPath: pythonPath) + process.arguments = ["-m", "http.server", String(port), "--bind", "127.0.0.1"] + appendRuntimeLog("resolved python executable: \(pythonPath)") + } else if FileManager.default.isExecutableFile(atPath: "/usr/bin/python3") { + process.executableURL = URL(fileURLWithPath: "/usr/bin/python3") + process.arguments = ["-m", "http.server", String(port), "--bind", "127.0.0.1"] + appendRuntimeLog("python resolution fallback: using /usr/bin/python3") + } else { + process.executableURL = URL(fileURLWithPath: "/usr/bin/env") + process.arguments = ["python3", "-m", "http.server", String(port), "--bind", "127.0.0.1"] + appendRuntimeLog("python resolution fallback: using /usr/bin/env python3") + } - stderr.fileHandleForReading.readabilityHandler = { [weak self] handle in - let data = handle.availableData - guard !data.isEmpty else { return } - Task { @MainActor [weak self] in - self?.appendLogData(data) - } - } + process.currentDirectoryURL = webRootURL - process.terminationHandler = { [weak self] terminated in - Task { @MainActor [weak self] in - self?.handleProcessExit(terminated) - } - } + var env = ProcessInfo.processInfo.environment + env["PWD"] = launchDirectory + process.environment = env - try process.run() + let stdout = Pipe() + let stderr = Pipe() + process.standardOutput = stdout + process.standardError = stderr - self.process = process - self.stdoutPipe = stdout - self.stderrPipe = stderr + stdout.fileHandleForReading.readabilityHandler = { [weak self] handle in + let data = handle.availableData + guard !data.isEmpty else { return } + Task { @MainActor [weak self] in + self?.appendLogData(data) + } + } - appendRuntimeLog("spawned process pid=\(process.processIdentifier) port=\(port)") + stderr.fileHandleForReading.readabilityHandler = { [weak self] handle in + let data = handle.availableData + guard !data.isEmpty else { return } + Task { @MainActor [weak self] in + self?.appendLogData(data) + } } - private func resolveRuntimeExecutablePath(_ executable: String) -> String? { - guard executable.range(of: #"^[A-Za-z0-9._+-]+$"#, options: .regularExpression) != nil else { - return nil - } + process.terminationHandler = { [weak self] terminated in + Task { @MainActor [weak self] in + self?.handleProcessExit(terminated) + } + } - let probes: [(String, [String])] = [ - ("/usr/bin/which", [executable]), - ("/bin/zsh", ["-lc", "whence -p \(executable)"]), - ("/bin/zsh", ["-ilc", "whence -p \(executable)"]) - ] + try process.run() - for probe in probes { - if let resolved = runRuntimeProbe(executable: probe.0, arguments: probe.1) { - return resolved - } - } + self.process = process + stdoutPipe = stdout + stderrPipe = stderr - return nil + appendRuntimeLog("spawned process pid=\(process.processIdentifier) port=\(port)") + } + + private func resolveRuntimeExecutablePath(_ executable: String) -> String? { + guard executable.range(of: #"^[A-Za-z0-9._+-]+$"#, options: .regularExpression) != nil else { + return nil } - private func runRuntimeProbe(executable: String, arguments: [String]) -> String? { - let process = Process() - process.executableURL = URL(fileURLWithPath: executable) - process.arguments = arguments - process.environment = ProcessInfo.processInfo.environment + let probes: [(String, [String])] = [ + ("/usr/bin/which", [executable]), + ("/bin/zsh", ["-lc", "whence -p \(executable)"]), + ("/bin/zsh", ["-ilc", "whence -p \(executable)"]), + ] - let stdout = Pipe() - process.standardOutput = stdout - process.standardError = Pipe() + for probe in probes { + if let resolved = runRuntimeProbe(executable: probe.0, arguments: probe.1) { + return resolved + } + } - do { - try process.run() - process.waitUntilExit() - } catch { - return nil - } + return nil + } - guard process.terminationStatus == 0 else { - return nil - } + private func runRuntimeProbe(executable: String, arguments: [String]) -> String? { + let process = Process() + process.executableURL = URL(fileURLWithPath: executable) + process.arguments = arguments + process.environment = ProcessInfo.processInfo.environment - let output = String(decoding: stdout.fileHandleForReading.readDataToEndOfFile(), as: UTF8.self) - let candidates = output - .split(whereSeparator: \.isNewline) - .map { String($0).trimmingCharacters(in: .whitespacesAndNewlines) } - .filter { !$0.isEmpty && $0.hasPrefix("/") } + let stdout = Pipe() + process.standardOutput = stdout + process.standardError = Pipe() - for candidate in candidates where FileManager.default.isExecutableFile(atPath: candidate) { - return candidate - } + do { + try process.run() + process.waitUntilExit() + } catch { + return nil + } - return nil + guard process.terminationStatus == 0 else { + return nil } - private func waitForServerReady(port: Int) async -> Bool { - let url = URL(string: "http://127.0.0.1:\(port)/")! - let deadline = Date().addingTimeInterval(readinessTimeoutSeconds) + let output = String(decoding: stdout.fileHandleForReading.readDataToEndOfFile(), as: UTF8.self) + let candidates = output + .split(whereSeparator: \.isNewline) + .map { String($0).trimmingCharacters(in: .whitespacesAndNewlines) } + .filter { !$0.isEmpty && $0.hasPrefix("/") } - while Date() < deadline { - if Task.isCancelled || userStopped { - return false - } + for candidate in candidates where FileManager.default.isExecutableFile(atPath: candidate) { + return candidate + } - if process?.isRunning == false { - return false - } + return nil + } - if await probeServer(url: url) { - return true - } + private func waitForServerReady(port: Int) async -> Bool { + let url = URL(string: "http://127.0.0.1:\(port)/")! + let deadline = Date().addingTimeInterval(readinessTimeoutSeconds) - try? await Task.sleep(nanoseconds: readinessIntervalNanoseconds) - } + while Date() < deadline { + if Task.isCancelled || userStopped { + return false + } + if process?.isRunning == false { return false + } + + if await probeServer(url: url) { + return true + } + + try? await Task.sleep(nanoseconds: readinessIntervalNanoseconds) } - private func probeServer(url: URL) async -> Bool { - var request = URLRequest(url: url) - request.timeoutInterval = 1 + return false + } - do { - let (_, response) = try await URLSession.shared.data(for: request) - return response is HTTPURLResponse - } catch { - return false - } + private func probeServer(url: URL) async -> Bool { + var request = URLRequest(url: url) + request.timeoutInterval = 1 + + do { + let (_, response) = try await URLSession.shared.data(for: request) + return response is HTTPURLResponse + } catch { + return false } + } - private func handleProcessExit(_ terminatedProcess: Process) { - appendRuntimeLog( - "process exited status=\(terminatedProcess.terminationStatus) reason=\(terminatedProcess.terminationReason.rawValue)" - ) + private func handleProcessExit(_ terminatedProcess: Process) { + appendRuntimeLog( + "process exited status=\(terminatedProcess.terminationStatus) reason=\(terminatedProcess.terminationReason.rawValue)" + ) - terminateProcess() + terminateProcess() - guard !userStopped else { return } + guard !userStopped else { return } - if case .failed = state { - return - } + if case .failed = state { + return + } - if startTask == nil { - state = .starting - startTask = Task { [weak self] in - guard let self else { return } - await self.runStartupSequence() - self.startTask = nil - } - } + if startTask == nil { + state = .starting + startTask = Task { [weak self] in + guard let self else { return } + await runStartupSequence() + startTask = nil + } } + } - private func terminateProcess() { - stdoutPipe?.fileHandleForReading.readabilityHandler = nil - stderrPipe?.fileHandleForReading.readabilityHandler = nil - stdoutPipe = nil - stderrPipe = nil + private func terminateProcess() { + stdoutPipe?.fileHandleForReading.readabilityHandler = nil + stderrPipe?.fileHandleForReading.readabilityHandler = nil + stdoutPipe = nil + stderrPipe = nil - if let process, process.isRunning { - process.terminate() - } - self.process = nil + if let process, process.isRunning { + process.terminate() + } + process = nil - if let handle = logHandle { - try? handle.close() - } - logHandle = nil + if let handle = logHandle { + try? handle.close() + } + logHandle = nil + } + + private func appendLogData(_ data: Data) { + guard let logHandle else { return } + do { + try logHandle.seekToEnd() + try logHandle.write(contentsOf: data) + } catch { + Logger.error("Failed writing Excalidraw runtime log data: \(error.localizedDescription)") } + } + + private func appendRuntimeLog(_ line: String) { + let timestamp = ISO8601DateFormatter().string(from: Date()) + guard let data = "[\(timestamp)] \(line)\n".data(using: .utf8) else { return } - private func appendLogData(_ data: Data) { - guard let logHandle else { return } - do { - try logHandle.seekToEnd() - try logHandle.write(contentsOf: data) - } catch { - Logger.error("Failed writing Excalidraw runtime log data: \(error.localizedDescription)") + if logHandle == nil { + do { + try FileManager.default.createDirectory(at: paths.runtimeLogPath.deletingLastPathComponent(), withIntermediateDirectories: true) + if !FileManager.default.fileExists(atPath: paths.runtimeLogPath.path) { + FileManager.default.createFile(atPath: paths.runtimeLogPath.path, contents: nil) } + let handle = try FileHandle(forWritingTo: paths.runtimeLogPath) + try handle.seekToEnd() + logHandle = handle + } catch { + Logger.error("Failed opening Excalidraw runtime log: \(error.localizedDescription)") + return + } } - private func appendRuntimeLog(_ line: String) { - let timestamp = ISO8601DateFormatter().string(from: Date()) - guard let data = "[\(timestamp)] \(line)\n".data(using: .utf8) else { return } - - if logHandle == nil { - do { - try FileManager.default.createDirectory(at: paths.runtimeLogPath.deletingLastPathComponent(), withIntermediateDirectories: true) - if !FileManager.default.fileExists(atPath: paths.runtimeLogPath.path) { - FileManager.default.createFile(atPath: paths.runtimeLogPath.path, contents: nil) - } - let handle = try FileHandle(forWritingTo: paths.runtimeLogPath) - try handle.seekToEnd() - logHandle = handle - } catch { - Logger.error("Failed opening Excalidraw runtime log: \(error.localizedDescription)") - return - } - } + appendLogData(data) + } - appendLogData(data) + private func isRetryableStartupError(_ error: Error) -> Bool { + guard let runtimeError = error as? ExcalidrawRuntimeError else { + return false } - - private func isRetryableStartupError(_ error: Error) -> Bool { - guard let runtimeError = error as? ExcalidrawRuntimeError else { - return false - } - switch runtimeError { - case .startupTimeout, .processExitedBeforeReady: - return true - case .missingTool, .missingYarnPackageManager, .commandFailed, .missingArtifact, .cancelled: - return false - } + switch runtimeError { + case .startupTimeout, .processExitedBeforeReady: + return true + case .missingTool, .missingYarnPackageManager, .commandFailed, .missingArtifact, .cancelled: + return false } + } - private func logPathForError(_ error: Error) -> String { - guard let runtimeError = error as? ExcalidrawRuntimeError else { - return paths.runtimeLogPath.path - } - switch runtimeError { - case .missingTool, .missingYarnPackageManager, .commandFailed, .missingArtifact: - return paths.buildLogPath.path - case .startupTimeout, .processExitedBeforeReady, .cancelled: - return paths.runtimeLogPath.path - } + private func logPathForError(_ error: Error) -> String { + guard let runtimeError = error as? ExcalidrawRuntimeError else { + return paths.runtimeLogPath.path + } + switch runtimeError { + case .missingTool, .missingYarnPackageManager, .commandFailed, .missingArtifact: + return paths.buildLogPath.path + case .startupTimeout, .processExitedBeforeReady, .cancelled: + return paths.runtimeLogPath.path } + } } diff --git a/idx0/Apps/T3Code/T3CodeRuntime.swift b/idx0/Apps/T3Code/T3CodeRuntime.swift index 8fb58d1..c8867e7 100644 --- a/idx0/Apps/T3Code/T3CodeRuntime.swift +++ b/idx0/Apps/T3Code/T3CodeRuntime.swift @@ -4,1144 +4,1313 @@ import Foundation import WebKit struct T3BuildManifest: Codable, Equatable { - static let canonicalRepositoryURL = "https://github.com/pingdotgg/t3code.git" - static let canonicalBuildCommand = "bun run --cwd apps/web build && bun run --cwd apps/server build" - static let canonicalEntrypoint = "apps/server/dist/index.mjs" - static let canonicalClientArtifact = "apps/server/dist/client/index.html" - - let repositoryURL: String - let pinnedCommit: String - let installCommand: String - let buildCommand: String - let entrypoint: String - let requiredArtifacts: [String] - - static let `default` = T3BuildManifest( - repositoryURL: canonicalRepositoryURL, - pinnedCommit: "2a237c20019a", - installCommand: "bun install --frozen-lockfile", - buildCommand: canonicalBuildCommand, - entrypoint: canonicalEntrypoint, - requiredArtifacts: [ - canonicalEntrypoint, - canonicalClientArtifact - ] - ) - - static func loadFromBundle(_ bundle: Bundle = .main) -> T3BuildManifest { - guard let url = bundle.url(forResource: "t3-build-manifest", withExtension: "json"), - let data = try? Data(contentsOf: url), - let decoded = try? JSONDecoder().decode(T3BuildManifest.self, from: data) - else { - return .default - } - return decoded.normalized() + static let canonicalRepositoryURL = "https://github.com/pingdotgg/t3code.git" + static let canonicalBuildCommand = "bun run --cwd apps/web build && bun run --cwd apps/server build" + static let canonicalEntrypoint = "apps/server/dist/index.mjs" + static let canonicalClientArtifact = "apps/server/dist/client/index.html" + + let repositoryURL: String + let pinnedCommit: String + let installCommand: String + let buildCommand: String + let entrypoint: String + let requiredArtifacts: [String] + + static let `default` = T3BuildManifest( + repositoryURL: canonicalRepositoryURL, + pinnedCommit: "HEAD", + installCommand: "bun install --frozen-lockfile", + buildCommand: canonicalBuildCommand, + entrypoint: canonicalEntrypoint, + requiredArtifacts: [ + canonicalEntrypoint, + canonicalClientArtifact, + ] + ) + + static func loadFromBundle(_ bundle: Bundle = .main) -> T3BuildManifest { + guard let url = bundle.url(forResource: "t3-build-manifest", withExtension: "json"), + let data = try? Data(contentsOf: url), + let decoded = try? JSONDecoder().decode(T3BuildManifest.self, from: data) + else { + return .default } + return decoded.normalized() + } - func normalized() -> T3BuildManifest { - var normalizedRepositoryURL = repositoryURL.trimmingCharacters(in: .whitespacesAndNewlines) - if normalizedRepositoryURL.contains("t3dotgg/t3.chat") { - normalizedRepositoryURL = Self.canonicalRepositoryURL - } - - var normalizedBuildCommand = buildCommand.trimmingCharacters(in: .whitespacesAndNewlines) - if normalizedBuildCommand == "bun run --cwd apps/server build" { - normalizedBuildCommand = Self.canonicalBuildCommand - } + func normalized() -> T3BuildManifest { + var normalizedRepositoryURL = repositoryURL.trimmingCharacters(in: .whitespacesAndNewlines) + if normalizedRepositoryURL.contains("t3dotgg/t3.chat") { + normalizedRepositoryURL = Self.canonicalRepositoryURL + } - var normalizedEntrypoint = entrypoint.trimmingCharacters(in: .whitespacesAndNewlines) - if normalizedEntrypoint == "apps/server/dist/index.cjs" { - normalizedEntrypoint = Self.canonicalEntrypoint - } + var normalizedBuildCommand = buildCommand.trimmingCharacters(in: .whitespacesAndNewlines) + if normalizedBuildCommand == "bun run --cwd apps/server build" { + normalizedBuildCommand = Self.canonicalBuildCommand + } - let oldEntrypoint = "apps/server/dist/index.cjs" - var normalizedRequiredArtifacts = requiredArtifacts.map { artifact in - let trimmed = artifact.trimmingCharacters(in: .whitespacesAndNewlines) - return trimmed == oldEntrypoint ? Self.canonicalEntrypoint : trimmed - } + var normalizedEntrypoint = entrypoint.trimmingCharacters(in: .whitespacesAndNewlines) + if normalizedEntrypoint == "apps/server/dist/index.cjs" { + normalizedEntrypoint = Self.canonicalEntrypoint + } - if !normalizedRequiredArtifacts.contains(Self.canonicalEntrypoint) { - normalizedRequiredArtifacts.insert(Self.canonicalEntrypoint, at: 0) - } + let oldEntrypoint = "apps/server/dist/index.cjs" + var normalizedRequiredArtifacts = requiredArtifacts.map { artifact in + let trimmed = artifact.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed == oldEntrypoint ? Self.canonicalEntrypoint : trimmed + } - if !normalizedRequiredArtifacts.contains(Self.canonicalClientArtifact) { - normalizedRequiredArtifacts.append(Self.canonicalClientArtifact) - } + if !normalizedRequiredArtifacts.contains(Self.canonicalEntrypoint) { + normalizedRequiredArtifacts.insert(Self.canonicalEntrypoint, at: 0) + } - if normalizedRepositoryURL == repositoryURL && - normalizedBuildCommand == buildCommand && - normalizedEntrypoint == entrypoint && - normalizedRequiredArtifacts == requiredArtifacts { - return self - } + if !normalizedRequiredArtifacts.contains(Self.canonicalClientArtifact) { + normalizedRequiredArtifacts.append(Self.canonicalClientArtifact) + } - return T3BuildManifest( - repositoryURL: normalizedRepositoryURL, - pinnedCommit: pinnedCommit, - installCommand: installCommand, - buildCommand: normalizedBuildCommand, - entrypoint: normalizedEntrypoint, - requiredArtifacts: normalizedRequiredArtifacts - ) + if normalizedRepositoryURL == repositoryURL, + normalizedBuildCommand == buildCommand, + normalizedEntrypoint == entrypoint, + normalizedRequiredArtifacts == requiredArtifacts + { + return self } + + return T3BuildManifest( + repositoryURL: normalizedRepositoryURL, + pinnedCommit: pinnedCommit, + installCommand: installCommand, + buildCommand: normalizedBuildCommand, + entrypoint: normalizedEntrypoint, + requiredArtifacts: normalizedRequiredArtifacts + ) + } } struct T3RuntimePaths { - let rootDirectory: URL - let sourceDirectory: URL - let buildRecordPath: URL - let buildLogPath: URL - let buildLockPath: URL - let sessionsDirectory: URL - let sessionDirectory: URL - let sessionStateDirectory: URL - let runtimeLogPath: URL - - init( - sessionID: UUID, - rootDirectoryOverride: URL? = nil, - fileManager: FileManager = .default - ) { - let idx0Root: URL - if let rootDirectoryOverride { - idx0Root = rootDirectoryOverride - } else { - let appSupportRoot = fileManager.urls(for: .applicationSupportDirectory, in: .userDomainMask).first - ?? URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true) - idx0Root = appSupportRoot - .appendingPathComponent("idx0", isDirectory: true) - .appendingPathComponent("t3code", isDirectory: true) - } - - rootDirectory = idx0Root - sourceDirectory = idx0Root.appendingPathComponent("source", isDirectory: true) - buildRecordPath = idx0Root.appendingPathComponent("manifest.json", isDirectory: false) - buildLogPath = idx0Root.appendingPathComponent("logs", isDirectory: true).appendingPathComponent("build.log", isDirectory: false) - buildLockPath = idx0Root.appendingPathComponent("build.lock", isDirectory: false) - sessionsDirectory = idx0Root.appendingPathComponent("sessions", isDirectory: true) - sessionDirectory = sessionsDirectory.appendingPathComponent(sessionID.uuidString, isDirectory: true) - sessionStateDirectory = sessionDirectory.appendingPathComponent("state", isDirectory: true) - runtimeLogPath = sessionDirectory.appendingPathComponent("runtime.log", isDirectory: false) + let rootDirectory: URL + let sourceDirectory: URL + let buildRecordPath: URL + let buildLogPath: URL + let buildLockPath: URL + let sessionsDirectory: URL + let sessionDirectory: URL + let sessionStateDirectory: URL + let runtimeLogPath: URL + + init( + sessionID: UUID, + rootDirectoryOverride: URL? = nil, + fileManager: FileManager = .default + ) { + let idx0Root: URL + if let rootDirectoryOverride { + idx0Root = rootDirectoryOverride + } else { + let appSupportRoot = fileManager.urls(for: .applicationSupportDirectory, in: .userDomainMask).first + ?? URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true) + idx0Root = appSupportRoot + .appendingPathComponent("idx0", isDirectory: true) + .appendingPathComponent("t3code", isDirectory: true) } - func ensureBaseDirectories(fileManager: FileManager = .default) throws { - try fileManager.createDirectory(at: rootDirectory, withIntermediateDirectories: true) - try fileManager.createDirectory(at: sourceDirectory.deletingLastPathComponent(), withIntermediateDirectories: true) - try fileManager.createDirectory(at: buildLogPath.deletingLastPathComponent(), withIntermediateDirectories: true) - try fileManager.createDirectory(at: sessionsDirectory, withIntermediateDirectories: true) - try fileManager.createDirectory(at: sessionDirectory, withIntermediateDirectories: true) - } + rootDirectory = idx0Root + sourceDirectory = idx0Root.appendingPathComponent("source", isDirectory: true) + buildRecordPath = idx0Root.appendingPathComponent("manifest.json", isDirectory: false) + buildLogPath = idx0Root.appendingPathComponent("logs", isDirectory: true).appendingPathComponent("build.log", isDirectory: false) + buildLockPath = idx0Root.appendingPathComponent("build.lock", isDirectory: false) + sessionsDirectory = idx0Root.appendingPathComponent("sessions", isDirectory: true) + sessionDirectory = sessionsDirectory.appendingPathComponent(sessionID.uuidString, isDirectory: true) + sessionStateDirectory = sessionDirectory.appendingPathComponent("state", isDirectory: true) + runtimeLogPath = sessionDirectory.appendingPathComponent("runtime.log", isDirectory: false) + } + + func ensureBaseDirectories(fileManager: FileManager = .default) throws { + try fileManager.createDirectory(at: rootDirectory, withIntermediateDirectories: true) + try fileManager.createDirectory(at: sourceDirectory.deletingLastPathComponent(), withIntermediateDirectories: true) + try fileManager.createDirectory(at: buildLogPath.deletingLastPathComponent(), withIntermediateDirectories: true) + try fileManager.createDirectory(at: sessionsDirectory, withIntermediateDirectories: true) + try fileManager.createDirectory(at: sessionDirectory, withIntermediateDirectories: true) + } } enum T3TileRuntimeState: Equatable { - case idle - case preparingSource - case building - case starting - case live(urlString: String) - case failed(message: String, logPath: String?) - - var displayMessage: String { - switch self { - case .idle: - return "Ready" - case .preparingSource: - return "Preparing T3 Code source..." - case .building: - return "Building T3 Code..." - case .starting: - return "Starting T3 Code..." - case .live: - return "Live" - case .failed(let message, _): - return message - } + case idle + case preparingSource + case building + case starting + case live(urlString: String) + case failed(message: String, logPath: String?) + + var displayMessage: String { + switch self { + case .idle: + "Ready" + case .preparingSource: + "Preparing T3 Code source..." + case .building: + "Building T3 Code..." + case .starting: + "Starting T3 Code..." + case .live: + "Live" + case let .failed(message, _): + message } + } } enum T3RuntimeError: LocalizedError { - case missingTool(String) - case commandFailed(command: String, code: Int32, stderr: String?) - case missingArtifact(String) - case startupTimeout - case processExitedBeforeReady - case cancelled - - var errorDescription: String? { - switch self { - case .missingTool(let tool): - return "Missing required tool: \(tool)" - case .commandFailed(let command, let code, let stderr): - if let stderr, !stderr.isEmpty { - return "Command failed (\(code)): \(command)\n\(stderr)" - } - return "Command failed (\(code)): \(command)" - case .missingArtifact(let artifact): - return "Build artifact missing: \(artifact)" - case .startupTimeout: - return "T3 Code did not become ready in time." - case .processExitedBeforeReady: - return "T3 Code process exited before it became ready." - case .cancelled: - return "Operation cancelled." - } + case missingTool(String) + case commandFailed(command: String, code: Int32, stderr: String?) + case missingArtifact(String) + case startupTimeout + case processExitedBeforeReady + case cancelled + + var errorDescription: String? { + switch self { + case let .missingTool(tool): + return "Missing required tool: \(tool)" + case let .commandFailed(command, code, stderr): + if let stderr, !stderr.isEmpty { + return "Command failed (\(code)): \(command)\n\(stderr)" + } + return "Command failed (\(code)): \(command)" + case let .missingArtifact(artifact): + return "Build artifact missing: \(artifact)" + case .startupTimeout: + return "T3 Code did not become ready in time." + case .processExitedBeforeReady: + return "T3 Code process exited before it became ready." + case .cancelled: + return "Operation cancelled." } + } } private struct T3BuildRecord: Codable { - let pinnedCommit: String - let entrypoint: String - let builtAt: Date + let sourceCommit: String + let entrypoint: String + let builtAt: Date + + private enum CodingKeys: String, CodingKey { + case sourceCommit + case pinnedCommit + case entrypoint + case builtAt + } + + init(sourceCommit: String, entrypoint: String, builtAt: Date) { + self.sourceCommit = sourceCommit + self.entrypoint = entrypoint + self.builtAt = builtAt + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + entrypoint = try container.decode(String.self, forKey: .entrypoint) + builtAt = try container.decode(Date.self, forKey: .builtAt) + if let decodedSourceCommit = try container.decodeIfPresent(String.self, forKey: .sourceCommit) { + sourceCommit = decodedSourceCommit + } else { + sourceCommit = try container.decode(String.self, forKey: .pinnedCommit) + } + } + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(sourceCommit, forKey: .sourceCommit) + // Preserve compatibility with previously persisted build records. + try container.encode(sourceCommit, forKey: .pinnedCommit) + try container.encode(entrypoint, forKey: .entrypoint) + try container.encode(builtAt, forKey: .builtAt) + } } @MainActor final class T3BuildCoordinator { - private let processRunner: ProcessRunnerProtocol - private let fileManager: FileManager - private var buildTask: Task? - - init(processRunner: ProcessRunnerProtocol = ProcessRunner(), fileManager: FileManager = .default) { - self.processRunner = processRunner - self.fileManager = fileManager - } - - func ensureBuilt( - manifest: T3BuildManifest, - paths: T3RuntimePaths, - onStateUpdate: ((T3TileRuntimeState) -> Void)? = nil - ) async throws -> URL { - if let entrypoint = try? reusableEntrypointIfAvailable(manifest: manifest, paths: paths) { - return entrypoint - } - - if let existingTask = buildTask { - return try await existingTask.value - } + private let processRunner: ProcessRunnerProtocol + private let fileManager: FileManager + private var buildTask: Task? + + init(processRunner: ProcessRunnerProtocol = ProcessRunner(), fileManager: FileManager = .default) { + self.processRunner = processRunner + self.fileManager = fileManager + } + + func ensureBuilt( + manifest: T3BuildManifest, + paths: T3RuntimePaths, + onStateUpdate: ((T3TileRuntimeState) -> Void)? = nil + ) async throws -> URL { + if let existingTask = buildTask { + return try await existingTask.value + } - let task = Task { [weak self] () -> URL in - guard let self else { throw T3RuntimeError.cancelled } - return try await self.performBuild(manifest: manifest, paths: paths, onStateUpdate: onStateUpdate) - } + let task = Task { [weak self] () -> URL in + guard let self else { throw T3RuntimeError.cancelled } + return try await performBuild(manifest: manifest, paths: paths, onStateUpdate: onStateUpdate) + } - buildTask = task - do { - let url = try await task.value - buildTask = nil - return url - } catch { - buildTask = nil - throw error - } + buildTask = task + do { + let url = try await task.value + buildTask = nil + return url + } catch { + buildTask = nil + throw error + } + } + + private func reusableEntrypointIfAvailable( + sourceCommit: String, + manifest: T3BuildManifest, + paths: T3RuntimePaths + ) throws -> URL { + guard fileManager.fileExists(atPath: paths.buildRecordPath.path) else { + throw T3RuntimeError.missingArtifact(paths.buildRecordPath.path) } - private func reusableEntrypointIfAvailable(manifest: T3BuildManifest, paths: T3RuntimePaths) throws -> URL { - guard fileManager.fileExists(atPath: paths.buildRecordPath.path) else { - throw T3RuntimeError.missingArtifact(paths.buildRecordPath.path) - } + let data = try Data(contentsOf: paths.buildRecordPath) + let record = try JSONDecoder().decode(T3BuildRecord.self, from: data) - let data = try Data(contentsOf: paths.buildRecordPath) - let record = try JSONDecoder().decode(T3BuildRecord.self, from: data) + guard record.sourceCommit == sourceCommit else { + throw T3RuntimeError.missingArtifact(sourceCommit) + } - guard record.pinnedCommit == manifest.pinnedCommit else { - throw T3RuntimeError.missingArtifact(manifest.pinnedCommit) - } + for artifact in manifest.requiredArtifacts { + let artifactURL = paths.sourceDirectory.appendingPathComponent(artifact, isDirectory: false) + guard fileManager.fileExists(atPath: artifactURL.path) else { + throw T3RuntimeError.missingArtifact(artifact) + } + } - for artifact in manifest.requiredArtifacts { - let artifactURL = paths.sourceDirectory.appendingPathComponent(artifact, isDirectory: false) - guard fileManager.fileExists(atPath: artifactURL.path) else { - throw T3RuntimeError.missingArtifact(artifact) - } - } + let entrypointURL = paths.sourceDirectory.appendingPathComponent(record.entrypoint, isDirectory: false) + guard fileManager.fileExists(atPath: entrypointURL.path) else { + throw T3RuntimeError.missingArtifact(entrypointURL.path) + } - let entrypointURL = paths.sourceDirectory.appendingPathComponent(record.entrypoint, isDirectory: false) - guard fileManager.fileExists(atPath: entrypointURL.path) else { - throw T3RuntimeError.missingArtifact(entrypointURL.path) - } + return entrypointURL + } - return entrypointURL - } + private func performBuild( + manifest: T3BuildManifest, + paths: T3RuntimePaths, + onStateUpdate: ((T3TileRuntimeState) -> Void)? + ) async throws -> URL { + try paths.ensureBaseDirectories(fileManager: fileManager) - private func performBuild( - manifest: T3BuildManifest, - paths: T3RuntimePaths, - onStateUpdate: ((T3TileRuntimeState) -> Void)? - ) async throws -> URL { - try paths.ensureBaseDirectories(fileManager: fileManager) + onStateUpdate?(.preparingSource) + appendBuildLog(paths: paths, line: "== build start \(Date())") - onStateUpdate?(.preparingSource) - appendBuildLog(paths: paths, line: "== build start \(Date())") + try "pid=\(ProcessInfo.processInfo.processIdentifier)\n".write( + to: paths.buildLockPath, + atomically: true, + encoding: .utf8 + ) + defer { try? fileManager.removeItem(at: paths.buildLockPath) } - try "pid=\(ProcessInfo.processInfo.processIdentifier)\n".write( - to: paths.buildLockPath, - atomically: true, - encoding: .utf8 - ) - defer { try? fileManager.removeItem(at: paths.buildLockPath) } - - try await ensureToolAvailable("git", paths: paths) - try await ensureToolAvailable("node", paths: paths) - try await ensureToolAvailable("bun", paths: paths) - - if fileManager.fileExists(atPath: paths.sourceDirectory.appendingPathComponent(".git", isDirectory: true).path) { - appendBuildLog(paths: paths, line: "Refreshing existing repository") - try await runChecked( - executable: "/usr/bin/git", - arguments: ["-C", paths.sourceDirectory.path, "fetch", "--all", "--tags"], - currentDirectory: paths.sourceDirectory.path, - paths: paths - ) - } else { - appendBuildLog(paths: paths, line: "Cloning repository") - try await runChecked( - executable: "/usr/bin/git", - arguments: ["clone", manifest.repositoryURL, paths.sourceDirectory.path], - currentDirectory: paths.rootDirectory.path, - paths: paths - ) - } + try await ensureToolAvailable("git", paths: paths) + if fileManager.fileExists(atPath: paths.sourceDirectory.appendingPathComponent(".git", isDirectory: true).path) { + appendBuildLog(paths: paths, line: "Refreshing existing repository") + do { try await runChecked( - executable: "/usr/bin/git", - arguments: ["-C", paths.sourceDirectory.path, "checkout", manifest.pinnedCommit], - currentDirectory: paths.sourceDirectory.path, - paths: paths + executable: "/usr/bin/git", + arguments: ["-C", paths.sourceDirectory.path, "fetch", "--all", "--tags"], + currentDirectory: paths.sourceDirectory.path, + paths: paths + ) + } catch { + appendBuildLog( + paths: paths, + line: "Fetch failed; continuing with locally cached source: \(error.localizedDescription)" ) + } + } else { + appendBuildLog(paths: paths, line: "Cloning repository") + try await runChecked( + executable: "/usr/bin/git", + arguments: ["clone", manifest.repositoryURL, paths.sourceDirectory.path], + currentDirectory: paths.rootDirectory.path, + paths: paths + ) + } - onStateUpdate?(.building) + let resolvedSourceCommit = try await resolveLatestSourceCommit(manifest: manifest, paths: paths) + if let entrypoint = try? reusableEntrypointIfAvailable( + sourceCommit: resolvedSourceCommit, + manifest: manifest, + paths: paths + ) { + appendBuildLog(paths: paths, line: "Reusing existing build for source commit \(resolvedSourceCommit)") + return entrypoint + } - try await runChecked( - executable: "/bin/zsh", - arguments: ["-ilc", manifest.installCommand], - currentDirectory: paths.sourceDirectory.path, - paths: paths - ) + try await ensureToolAvailable("node", paths: paths) + try await ensureToolAvailable("bun", paths: paths) - try await runChecked( - executable: "/bin/zsh", - arguments: ["-ilc", manifest.buildCommand], - currentDirectory: paths.sourceDirectory.path, - paths: paths - ) + try await runChecked( + executable: "/usr/bin/git", + arguments: ["-C", paths.sourceDirectory.path, "checkout", "--force", resolvedSourceCommit], + currentDirectory: paths.sourceDirectory.path, + paths: paths + ) - var missingArtifacts = missingRequiredArtifacts(manifest: manifest, paths: paths) - if !missingArtifacts.isEmpty { - // Older manifests only built the server. If client artifacts are missing, - // run a canonical full build (web + server) before failing. - let needsClientBundle = missingArtifacts.contains("apps/server/dist/client/index.html") - if needsClientBundle { - appendBuildLog(paths: paths, line: "Client bundle missing after build; running canonical full build") - try await runChecked( - executable: "/bin/zsh", - arguments: ["-ilc", T3BuildManifest.canonicalBuildCommand], - currentDirectory: paths.sourceDirectory.path, - paths: paths - ) - missingArtifacts = missingRequiredArtifacts(manifest: manifest, paths: paths) - } - } + onStateUpdate?(.building) - if let firstMissingArtifact = missingArtifacts.first { - throw T3RuntimeError.missingArtifact(firstMissingArtifact) - } + try await runChecked( + executable: "/bin/zsh", + arguments: ["-ilc", manifest.installCommand], + currentDirectory: paths.sourceDirectory.path, + paths: paths + ) - let record = T3BuildRecord( - pinnedCommit: manifest.pinnedCommit, - entrypoint: manifest.entrypoint, - builtAt: Date() + try await runChecked( + executable: "/bin/zsh", + arguments: ["-ilc", manifest.buildCommand], + currentDirectory: paths.sourceDirectory.path, + paths: paths + ) + + var missingArtifacts = missingRequiredArtifacts(manifest: manifest, paths: paths) + if !missingArtifacts.isEmpty { + // Older manifests only built the server. If client artifacts are missing, + // run a canonical full build (web + server) before failing. + let needsClientBundle = missingArtifacts.contains("apps/server/dist/client/index.html") + if needsClientBundle { + appendBuildLog(paths: paths, line: "Client bundle missing after build; running canonical full build") + try await runChecked( + executable: "/bin/zsh", + arguments: ["-ilc", T3BuildManifest.canonicalBuildCommand], + currentDirectory: paths.sourceDirectory.path, + paths: paths ) - let recordData = try JSONEncoder().encode(record) - try fileManager.createDirectory(at: paths.buildRecordPath.deletingLastPathComponent(), withIntermediateDirectories: true) - try recordData.write(to: paths.buildRecordPath, options: .atomic) + missingArtifacts = missingRequiredArtifacts(manifest: manifest, paths: paths) + } + } - appendBuildLog(paths: paths, line: "== build complete \(Date())") + if let firstMissingArtifact = missingArtifacts.first { + throw T3RuntimeError.missingArtifact(firstMissingArtifact) + } - let entrypointURL = paths.sourceDirectory.appendingPathComponent(manifest.entrypoint, isDirectory: false) - guard fileManager.fileExists(atPath: entrypointURL.path) else { - throw T3RuntimeError.missingArtifact(manifest.entrypoint) - } + let record = T3BuildRecord( + sourceCommit: resolvedSourceCommit, + entrypoint: manifest.entrypoint, + builtAt: Date() + ) + let recordData = try JSONEncoder().encode(record) + try fileManager.createDirectory(at: paths.buildRecordPath.deletingLastPathComponent(), withIntermediateDirectories: true) + try recordData.write(to: paths.buildRecordPath, options: .atomic) - return entrypointURL - } - - private func ensureToolAvailable(_ tool: String, paths: T3RuntimePaths) async throws { - let probes: [(executable: String, arguments: [String], display: String)] = [ - ("/usr/bin/which", [tool], "which \(tool)"), - ("/bin/zsh", ["-lc", "whence -p \(tool)"], "zsh -lc 'whence -p \(tool)'"), - ("/bin/zsh", ["-ilc", "whence -p \(tool)"], "zsh -ilc 'whence -p \(tool)'") - ] - - for probe in probes { - let result = try await processRunner.run( - executable: probe.executable, - arguments: probe.arguments, - currentDirectory: nil - ) - - appendBuildLog(paths: paths, line: "$ \(probe.display)") - if !result.stdout.isEmpty { - appendBuildLog(paths: paths, line: result.stdout) - } - if !result.stderr.isEmpty { - appendBuildLog(paths: paths, line: result.stderr) - } - - if result.exitCode == 0, - let resolvedPath = firstExecutablePath(from: result.stdout) { - appendBuildLog(paths: paths, line: "Resolved \(tool) -> \(resolvedPath)") - return - } - } + appendBuildLog(paths: paths, line: "== build complete \(Date())") - throw T3RuntimeError.missingTool(tool) + let entrypointURL = paths.sourceDirectory.appendingPathComponent(manifest.entrypoint, isDirectory: false) + guard fileManager.fileExists(atPath: entrypointURL.path) else { + throw T3RuntimeError.missingArtifact(manifest.entrypoint) } - private func firstExecutablePath(from output: String) -> String? { - let candidates = output - .split(whereSeparator: \.isNewline) - .map { String($0).trimmingCharacters(in: .whitespacesAndNewlines) } - .filter { !$0.isEmpty } - - return candidates.first(where: { $0.hasPrefix("/") }) + return entrypointURL + } + + private func resolveLatestSourceCommit(manifest: T3BuildManifest, paths: T3RuntimePaths) async throws -> String { + for candidateRef in [ + "origin/HEAD", + "origin/main", + "HEAD", + manifest.pinnedCommit, + ] { + guard let resolved = try await revParse(candidateRef, paths: paths) else { + continue + } + appendBuildLog(paths: paths, line: "Resolved source revision \(candidateRef) -> \(resolved)") + return resolved } - private func runChecked( - executable: String, - arguments: [String], - currentDirectory: String?, - paths: T3RuntimePaths - ) async throws { - let command = ([executable] + arguments).joined(separator: " ") - appendBuildLog(paths: paths, line: "$ \(command)") + throw T3RuntimeError.missingArtifact("unable to resolve source revision") + } - let result = try await processRunner.run( - executable: executable, - arguments: arguments, - currentDirectory: currentDirectory - ) + private func revParse(_ ref: String, paths: T3RuntimePaths) async throws -> String? { + let result = try await runLogged( + executable: "/usr/bin/git", + arguments: ["-C", paths.sourceDirectory.path, "rev-parse", ref], + currentDirectory: paths.sourceDirectory.path, + paths: paths + ) + guard result.exitCode == 0 else { + return nil + } - if !result.stdout.isEmpty { - appendBuildLog(paths: paths, line: result.stdout) - } - if !result.stderr.isEmpty { - appendBuildLog(paths: paths, line: result.stderr) - } + return result.stdout + .split(whereSeparator: \.isNewline) + .map { String($0).trimmingCharacters(in: .whitespacesAndNewlines) } + .first(where: { !$0.isEmpty }) + } + + private func ensureToolAvailable(_ tool: String, paths: T3RuntimePaths) async throws { + let probes: [(executable: String, arguments: [String], display: String)] = [ + ("/usr/bin/which", [tool], "which \(tool)"), + ("/bin/zsh", ["-lc", "whence -p \(tool)"], "zsh -lc 'whence -p \(tool)'"), + ("/bin/zsh", ["-ilc", "whence -p \(tool)"], "zsh -ilc 'whence -p \(tool)'"), + ] - guard result.exitCode == 0 else { - throw T3RuntimeError.commandFailed( - command: command, - code: result.exitCode, - stderr: result.stderr.isEmpty ? nil : result.stderr - ) - } + for probe in probes { + let result = try await processRunner.run( + executable: probe.executable, + arguments: probe.arguments, + currentDirectory: nil + ) + + appendBuildLog(paths: paths, line: "$ \(probe.display)") + if !result.stdout.isEmpty { + appendBuildLog(paths: paths, line: result.stdout) + } + if !result.stderr.isEmpty { + appendBuildLog(paths: paths, line: result.stderr) + } + + if result.exitCode == 0, + let resolvedPath = firstExecutablePath(from: result.stdout) + { + appendBuildLog(paths: paths, line: "Resolved \(tool) -> \(resolvedPath)") + return + } } - private func appendBuildLog(paths: T3RuntimePaths, line: String) { - let timestamp = ISO8601DateFormatter().string(from: Date()) - let logLine = "[\(timestamp)] \(line)\n" - - do { - try fileManager.createDirectory(at: paths.buildLogPath.deletingLastPathComponent(), withIntermediateDirectories: true) - if !fileManager.fileExists(atPath: paths.buildLogPath.path) { - try logLine.write(to: paths.buildLogPath, atomically: true, encoding: .utf8) - return - } - - let handle = try FileHandle(forWritingTo: paths.buildLogPath) - defer { try? handle.close() } - try handle.seekToEnd() - if let data = logLine.data(using: .utf8) { - try handle.write(contentsOf: data) - } - } catch { - Logger.error("Failed to append T3 build log: \(error.localizedDescription)") - } + throw T3RuntimeError.missingTool(tool) + } + + private func firstExecutablePath(from output: String) -> String? { + let candidates = output + .split(whereSeparator: \.isNewline) + .map { String($0).trimmingCharacters(in: .whitespacesAndNewlines) } + .filter { !$0.isEmpty } + + return candidates.first(where: { $0.hasPrefix("/") }) + } + + private func runChecked( + executable: String, + arguments: [String], + currentDirectory: String?, + paths: T3RuntimePaths + ) async throws { + let result = try await runLogged( + executable: executable, + arguments: arguments, + currentDirectory: currentDirectory, + paths: paths + ) + guard result.exitCode == 0 else { + let command = ([executable] + arguments).joined(separator: " ") + throw T3RuntimeError.commandFailed( + command: command, + code: result.exitCode, + stderr: result.stderr.isEmpty ? nil : result.stderr + ) } + } + + private func runLogged( + executable: String, + arguments: [String], + currentDirectory: String?, + paths: T3RuntimePaths + ) async throws -> ProcessResult { + let command = ([executable] + arguments).joined(separator: " ") + appendBuildLog(paths: paths, line: "$ \(command)") + + let result = try await processRunner.run( + executable: executable, + arguments: arguments, + currentDirectory: currentDirectory + ) - private func missingRequiredArtifacts(manifest: T3BuildManifest, paths: T3RuntimePaths) -> [String] { - manifest.requiredArtifacts.filter { artifact in - let artifactURL = paths.sourceDirectory.appendingPathComponent(artifact, isDirectory: false) - return !fileManager.fileExists(atPath: artifactURL.path) - } + if !result.stdout.isEmpty { + appendBuildLog(paths: paths, line: result.stdout) + } + if !result.stderr.isEmpty { + appendBuildLog(paths: paths, line: result.stderr) + } + return result + } + + private func appendBuildLog(paths: T3RuntimePaths, line: String) { + let timestamp = ISO8601DateFormatter().string(from: Date()) + let logLine = "[\(timestamp)] \(line)\n" + + do { + try fileManager.createDirectory(at: paths.buildLogPath.deletingLastPathComponent(), withIntermediateDirectories: true) + if !fileManager.fileExists(atPath: paths.buildLogPath.path) { + try logLine.write(to: paths.buildLogPath, atomically: true, encoding: .utf8) + return + } + + let handle = try FileHandle(forWritingTo: paths.buildLogPath) + defer { try? handle.close() } + try handle.seekToEnd() + if let data = logLine.data(using: .utf8) { + try handle.write(contentsOf: data) + } + } catch { + Logger.error("Failed to append T3 build log: \(error.localizedDescription)") + } + } + + private func missingRequiredArtifacts(manifest: T3BuildManifest, paths: T3RuntimePaths) -> [String] { + manifest.requiredArtifacts.filter { artifact in + let artifactURL = paths.sourceDirectory.appendingPathComponent(artifact, isDirectory: false) + return !fileManager.fileExists(atPath: artifactURL.path) } + } } @MainActor final class T3StateSnapshotManager { - private let fileManager: FileManager - private let skippedSnapshotEntries: Set = [ - "logs", - "state.sqlite-shm", - "state.sqlite-wal" - ] - - init(fileManager: FileManager = .default) { - self.fileManager = fileManager + private let fileManager: FileManager + private let skippedSnapshotEntries: Set = [ + "logs", + "state.sqlite-shm", + "state.sqlite-wal", + ] + + init(fileManager: FileManager = .default) { + self.fileManager = fileManager + } + + func prepareSessionSnapshot(paths: T3RuntimePaths) throws -> URL { + try paths.ensureBaseDirectories(fileManager: fileManager) + + if fileManager.fileExists(atPath: paths.sessionStateDirectory.path) { + pruneTransientSnapshotArtifacts(in: paths.sessionStateDirectory) + return paths.sessionStateDirectory } - func prepareSessionSnapshot(paths: T3RuntimePaths) throws -> URL { - try paths.ensureBaseDirectories(fileManager: fileManager) - - if fileManager.fileExists(atPath: paths.sessionStateDirectory.path) { - pruneTransientSnapshotArtifacts(in: paths.sessionStateDirectory) - return paths.sessionStateDirectory - } + let baseStatePath = NSString(string: "~/.t3/userdata").expandingTildeInPath + let baseStateURL = URL(fileURLWithPath: baseStatePath, isDirectory: true) - let baseStatePath = NSString(string: "~/.t3/userdata").expandingTildeInPath - let baseStateURL = URL(fileURLWithPath: baseStatePath, isDirectory: true) + if fileManager.fileExists(atPath: baseStateURL.path) { + try copyDirectoryContents(from: baseStateURL, to: paths.sessionStateDirectory) + } else { + try fileManager.createDirectory(at: paths.sessionStateDirectory, withIntermediateDirectories: true) + } - if fileManager.fileExists(atPath: baseStateURL.path) { - try copyDirectoryContents(from: baseStateURL, to: paths.sessionStateDirectory) - } else { - try fileManager.createDirectory(at: paths.sessionStateDirectory, withIntermediateDirectories: true) - } + pruneTransientSnapshotArtifacts(in: paths.sessionStateDirectory) - pruneTransientSnapshotArtifacts(in: paths.sessionStateDirectory) + return paths.sessionStateDirectory + } - return paths.sessionStateDirectory - } + func removeSessionSnapshot(paths: T3RuntimePaths) { + try? fileManager.removeItem(at: paths.sessionDirectory) + } - func removeSessionSnapshot(paths: T3RuntimePaths) { - try? fileManager.removeItem(at: paths.sessionDirectory) + private func copyDirectoryContents(from source: URL, to destination: URL) throws { + if fileManager.fileExists(atPath: destination.path) { + try fileManager.removeItem(at: destination) } - private func copyDirectoryContents(from source: URL, to destination: URL) throws { - if fileManager.fileExists(atPath: destination.path) { - try fileManager.removeItem(at: destination) - } - - try fileManager.createDirectory(at: destination, withIntermediateDirectories: true) - let contents = try fileManager.contentsOfDirectory( - at: source, - includingPropertiesForKeys: nil, - options: [] - ) + try fileManager.createDirectory(at: destination, withIntermediateDirectories: true) + let contents = try fileManager.contentsOfDirectory( + at: source, + includingPropertiesForKeys: nil, + options: [] + ) - for item in contents where !skippedSnapshotEntries.contains(item.lastPathComponent) { - var isDirectory: ObjCBool = false - fileManager.fileExists(atPath: item.path, isDirectory: &isDirectory) - let destinationItem = destination.appendingPathComponent( - item.lastPathComponent, - isDirectory: isDirectory.boolValue - ) - try fileManager.copyItem(at: item, to: destinationItem) - } + for item in contents where !skippedSnapshotEntries.contains(item.lastPathComponent) { + var isDirectory: ObjCBool = false + fileManager.fileExists(atPath: item.path, isDirectory: &isDirectory) + let destinationItem = destination.appendingPathComponent( + item.lastPathComponent, + isDirectory: isDirectory.boolValue + ) + try fileManager.copyItem(at: item, to: destinationItem) } + } - private func pruneTransientSnapshotArtifacts(in stateDirectory: URL) { - let logsDirectory = stateDirectory.appendingPathComponent("logs", isDirectory: true) - if fileManager.fileExists(atPath: logsDirectory.path) { - try? fileManager.removeItem(at: logsDirectory) - } + private func pruneTransientSnapshotArtifacts(in stateDirectory: URL) { + let logsDirectory = stateDirectory.appendingPathComponent("logs", isDirectory: true) + if fileManager.fileExists(atPath: logsDirectory.path) { + try? fileManager.removeItem(at: logsDirectory) + } - for transientFilename in ["state.sqlite-shm", "state.sqlite-wal"] { - let transientURL = stateDirectory.appendingPathComponent(transientFilename, isDirectory: false) - if fileManager.fileExists(atPath: transientURL.path) { - try? fileManager.removeItem(at: transientURL) - } - } + for transientFilename in ["state.sqlite-shm", "state.sqlite-wal"] { + let transientURL = stateDirectory.appendingPathComponent(transientFilename, isDirectory: false) + if fileManager.fileExists(atPath: transientURL.path) { + try? fileManager.removeItem(at: transientURL) + } } + } } @MainActor final class T3TileController: ObservableObject, NiriAppTileRuntimeControlling { - @Published private(set) var state: T3TileRuntimeState = .idle - - let sessionID: UUID - let itemID: UUID - let webView: WKWebView - - private let launchDirectoryProvider: () -> String? - private let buildCoordinator: T3BuildCoordinator - private let snapshotManager: T3StateSnapshotManager - private let manifestProvider: () -> T3BuildManifest - private let paths: T3RuntimePaths - - private let readinessIntervalNanoseconds: UInt64 = 250_000_000 - private let readinessTimeoutSeconds: TimeInterval = 20 - private let maxAutomaticRestarts = 3 - private let minimumZoom: CGFloat = 0.5 - private let maximumZoom: CGFloat = 3.0 - private let maxWebContentReloadAttempts = 2 - - private var startTask: Task? - private var process: Process? - private var stdoutPipe: Pipe? - private var stderrPipe: Pipe? - private var logHandle: FileHandle? - private var webViewDelegate: EmbeddedWebViewDelegate? - private var webContentTerminationCount = 0 - private var userStopped = false - private var automaticRestartCount = 0 - - init( - sessionID: UUID, - itemID: UUID, - launchDirectoryProvider: @escaping () -> String?, - buildCoordinator: T3BuildCoordinator, - snapshotManager: T3StateSnapshotManager, - manifestProvider: @escaping () -> T3BuildManifest = { T3BuildManifest.loadFromBundle() } - ) { - self.sessionID = sessionID - self.itemID = itemID - self.launchDirectoryProvider = launchDirectoryProvider - self.buildCoordinator = buildCoordinator - self.snapshotManager = snapshotManager - self.manifestProvider = manifestProvider - self.paths = T3RuntimePaths(sessionID: sessionID) - - let configuration = WKWebViewConfiguration() - configuration.defaultWebpagePreferences.allowsContentJavaScript = true - configuration.websiteDataStore = .default() - webView = WKWebView(frame: .zero, configuration: configuration) - - let delegate = EmbeddedWebViewDelegate(logLabel: "T3Code[\(sessionID.uuidString)]") { [weak self] view in - self?.handleWebContentTermination(view) - } - webView.navigationDelegate = delegate - webViewDelegate = delegate + @Published private(set) var state: T3TileRuntimeState = .idle + + let sessionID: UUID + let itemID: UUID + let webView: WKWebView + + private let launchDirectoryProvider: () -> String? + private let buildCoordinator: T3BuildCoordinator + private let snapshotManager: T3StateSnapshotManager + private let manifestProvider: () -> T3BuildManifest + private let paths: T3RuntimePaths + + private let readinessIntervalNanoseconds: UInt64 = 250_000_000 + private let readinessTimeoutSeconds: TimeInterval = 20 + private let maxAutomaticRestarts = 3 + private let minimumZoom: CGFloat = 0.5 + private let maximumZoom: CGFloat = 3.0 + private let maxWebContentReloadAttempts = 2 + + private var startTask: Task? + private var process: Process? + private var stdoutPipe: Pipe? + private var stderrPipe: Pipe? + private var logHandle: FileHandle? + private var webViewDelegate: EmbeddedWebViewDelegate? + private var webContentTerminationCount = 0 + private var userStopped = false + private var automaticRestartCount = 0 + private var resolvedBaseDirectoryFlag = false + private var cachedBaseDirectoryFlag: String? + + init( + sessionID: UUID, + itemID: UUID, + launchDirectoryProvider: @escaping () -> String?, + buildCoordinator: T3BuildCoordinator, + snapshotManager: T3StateSnapshotManager, + manifestProvider: @escaping () -> T3BuildManifest = { T3BuildManifest.loadFromBundle() } + ) { + self.sessionID = sessionID + self.itemID = itemID + self.launchDirectoryProvider = launchDirectoryProvider + self.buildCoordinator = buildCoordinator + self.snapshotManager = snapshotManager + self.manifestProvider = manifestProvider + paths = T3RuntimePaths(sessionID: sessionID) + + let configuration = WKWebViewConfiguration() + configuration.defaultWebpagePreferences.allowsContentJavaScript = true + configuration.websiteDataStore = .default() + webView = WKWebView(frame: .zero, configuration: configuration) + + let delegate = EmbeddedWebViewDelegate(logLabel: "T3Code[\(sessionID.uuidString)]") { [weak self] view in + self?.handleWebContentTermination(view) + } + webView.navigationDelegate = delegate + webViewDelegate = delegate + } + + func ensureStarted() { + guard startTask == nil else { return } + switch state { + case .preparingSource, .building, .starting, .live: + return + case .idle, .failed: + break } - func ensureStarted() { - guard startTask == nil else { return } - switch state { - case .preparingSource, .building, .starting, .live: - return - case .idle, .failed: - break + userStopped = false + startTask = Task { [weak self] in + guard let self else { return } + await runStartupSequence() + startTask = nil + } + } + + func retry() { + stop() + automaticRestartCount = 0 + state = .idle + ensureStarted() + } + + func stop() { + userStopped = true + startTask?.cancel() + startTask = nil + terminateProcess() + webContentTerminationCount = 0 + state = .idle + } + + func openLogsInFinder() { + let url = paths.runtimeLogPath + guard FileManager.default.fileExists(atPath: url.path) else { return } + NSWorkspace.shared.activateFileViewerSelecting([url]) + } + + var runtimeLogPath: String { + paths.runtimeLogPath.path + } + + @discardableResult + func adjustZoom(by delta: CGFloat) -> Bool { + let current = webView.pageZoom + let next = max(minimumZoom, min(maximumZoom, current + delta)) + webView.pageZoom = next + return true + } + + private func runStartupSequence() async { + let manifest = manifestProvider() + + while !Task.isCancelled { + do { + try await startupAttempt(manifest: manifest) + automaticRestartCount = 0 + return + } catch { + if userStopped || Task.isCancelled { + return } - userStopped = false - startTask = Task { [weak self] in - guard let self else { return } - await self.runStartupSequence() - self.startTask = nil + let description = (error as? LocalizedError)?.errorDescription ?? error.localizedDescription + appendRuntimeLog("startup attempt failed: \(description)") + + if !isRetryableStartupError(error) { + state = .failed(message: description, logPath: logPathForError(error)) + return } - } - func retry() { - stop() - automaticRestartCount = 0 - state = .idle - ensureStarted() - } + guard automaticRestartCount < maxAutomaticRestarts else { + state = .failed(message: description, logPath: logPathForError(error)) + return + } - func stop() { - userStopped = true - startTask?.cancel() - startTask = nil - terminateProcess() - webContentTerminationCount = 0 - state = .idle + automaticRestartCount += 1 + let backoff = min(pow(2, Double(automaticRestartCount - 1)) * 0.5, 10) + state = .starting + try? await Task.sleep(nanoseconds: UInt64(backoff * 1_000_000_000)) + } } + } - func openLogsInFinder() { - let url = paths.runtimeLogPath - guard FileManager.default.fileExists(atPath: url.path) else { return } - NSWorkspace.shared.activateFileViewerSelecting([url]) - } + private func startupAttempt(manifest: T3BuildManifest) async throws { + try paths.ensureBaseDirectories() - var runtimeLogPath: String { - paths.runtimeLogPath.path + let entrypointURL = try await buildCoordinator.ensureBuilt(manifest: manifest, paths: paths) { [weak self] newState in + self?.state = newState } - @discardableResult - func adjustZoom(by delta: CGFloat) -> Bool { - let current = webView.pageZoom - let next = max(minimumZoom, min(maximumZoom, current + delta)) - webView.pageZoom = next - return true + if userStopped || Task.isCancelled { + throw T3RuntimeError.cancelled } - private func runStartupSequence() async { - let manifest = manifestProvider() - - while !Task.isCancelled { - do { - try await startupAttempt(manifest: manifest) - automaticRestartCount = 0 - return - } catch { - if userStopped || Task.isCancelled { - return - } - - let description = (error as? LocalizedError)?.errorDescription ?? error.localizedDescription - appendRuntimeLog("startup attempt failed: \(description)") - - if !isRetryableStartupError(error) { - state = .failed(message: description, logPath: logPathForError(error)) - return - } - - guard automaticRestartCount < maxAutomaticRestarts else { - state = .failed(message: description, logPath: logPathForError(error)) - return - } - - automaticRestartCount += 1 - let backoff = min(pow(2, Double(automaticRestartCount - 1)) * 0.5, 10) - state = .starting - try? await Task.sleep(nanoseconds: UInt64(backoff * 1_000_000_000)) - } - } - } + let stateDirectory = try snapshotManager.prepareSessionSnapshot(paths: paths) + let port = try reserveLoopbackPort() + let launchEntrypointURL = resolveLaunchEntrypoint(from: entrypointURL) - private func startupAttempt(manifest: T3BuildManifest) async throws { - try paths.ensureBaseDirectories() + state = .starting + try launchProcess( + entrypointURL: launchEntrypointURL, + port: port, + stateDirectory: stateDirectory, + workingDirectory: launchDirectoryProvider() ?? FileManager.default.homeDirectoryForCurrentUser.path + ) - let entrypointURL = try await buildCoordinator.ensureBuilt(manifest: manifest, paths: paths) { [weak self] newState in - self?.state = newState - } + let ready = await waitForServerReady(port: port) + guard ready else { + terminateProcess() + if userStopped || Task.isCancelled { + throw T3RuntimeError.cancelled + } + if process?.isRunning == false { + throw T3RuntimeError.processExitedBeforeReady + } + throw T3RuntimeError.startupTimeout + } - if userStopped || Task.isCancelled { - throw T3RuntimeError.cancelled - } + if userStopped || Task.isCancelled { + terminateProcess() + throw T3RuntimeError.cancelled + } - let stateDirectory = try snapshotManager.prepareSessionSnapshot(paths: paths) - let port = try reserveLoopbackPort() - let launchEntrypointURL = resolveLaunchEntrypoint(from: entrypointURL) + let url = URL(string: "http://127.0.0.1:\(port)")! + webContentTerminationCount = 0 + webView.load(URLRequest(url: url)) + state = .live(urlString: url.absoluteString) + appendRuntimeLog("runtime live at \(url.absoluteString)") + } - state = .starting - try launchProcess( - entrypointURL: launchEntrypointURL, - port: port, - stateDirectory: stateDirectory, - workingDirectory: launchDirectoryProvider() ?? FileManager.default.homeDirectoryForCurrentUser.path - ) + private func resolveLaunchEntrypoint(from entrypointURL: URL) -> URL { + guard entrypointURL.pathExtension == "cjs" else { + return entrypointURL + } - let ready = await waitForServerReady(port: port) - guard ready else { - terminateProcess() - if userStopped || Task.isCancelled { - throw T3RuntimeError.cancelled - } - if process?.isRunning == false { - throw T3RuntimeError.processExitedBeforeReady - } - throw T3RuntimeError.startupTimeout - } + let mjsEntrypointURL = entrypointURL + .deletingPathExtension() + .appendingPathExtension("mjs") - if userStopped || Task.isCancelled { - terminateProcess() - throw T3RuntimeError.cancelled - } + guard FileManager.default.fileExists(atPath: mjsEntrypointURL.path) else { + return entrypointURL + } - let url = URL(string: "http://127.0.0.1:\(port)")! - webContentTerminationCount = 0 - webView.load(URLRequest(url: url)) - state = .live(urlString: url.absoluteString) - appendRuntimeLog("runtime live at \(url.absoluteString)") + appendRuntimeLog("using ESM entrypoint fallback: \(mjsEntrypointURL.path)") + return mjsEntrypointURL + } + + private func reserveLoopbackPort() throws -> Int { + let socketFD = socket(AF_INET, SOCK_STREAM, 0) + guard socketFD >= 0 else { + throw T3RuntimeError.startupTimeout + } + defer { close(socketFD) } + + var address = sockaddr_in() + address.sin_len = UInt8(MemoryLayout.size) + address.sin_family = sa_family_t(AF_INET) + address.sin_port = in_port_t(0).bigEndian + address.sin_addr = in_addr(s_addr: inet_addr("127.0.0.1")) + + let bindResult = withUnsafePointer(to: &address) { + $0.withMemoryRebound(to: sockaddr.self, capacity: 1) { + bind(socketFD, $0, socklen_t(MemoryLayout.size)) + } } - private func resolveLaunchEntrypoint(from entrypointURL: URL) -> URL { - guard entrypointURL.pathExtension == "cjs" else { - return entrypointURL - } + guard bindResult == 0 else { + throw T3RuntimeError.startupTimeout + } - let mjsEntrypointURL = entrypointURL - .deletingPathExtension() - .appendingPathExtension("mjs") + var assignedAddress = sockaddr_in() + var length = socklen_t(MemoryLayout.size) - guard FileManager.default.fileExists(atPath: mjsEntrypointURL.path) else { - return entrypointURL - } + let nameResult = withUnsafeMutablePointer(to: &assignedAddress) { + $0.withMemoryRebound(to: sockaddr.self, capacity: 1) { + getsockname(socketFD, $0, &length) + } + } - appendRuntimeLog("using ESM entrypoint fallback: \(mjsEntrypointURL.path)") - return mjsEntrypointURL + guard nameResult == 0 else { + throw T3RuntimeError.startupTimeout } - private func reserveLoopbackPort() throws -> Int { - let socketFD = socket(AF_INET, SOCK_STREAM, 0) - guard socketFD >= 0 else { - throw T3RuntimeError.startupTimeout - } - defer { close(socketFD) } - - var address = sockaddr_in() - address.sin_len = UInt8(MemoryLayout.size) - address.sin_family = sa_family_t(AF_INET) - address.sin_port = in_port_t(0).bigEndian - address.sin_addr = in_addr(s_addr: inet_addr("127.0.0.1")) - - let bindResult = withUnsafePointer(to: &address) { - $0.withMemoryRebound(to: sockaddr.self, capacity: 1) { - bind(socketFD, $0, socklen_t(MemoryLayout.size)) - } - } + return Int(UInt16(bigEndian: assignedAddress.sin_port)) + } - guard bindResult == 0 else { - throw T3RuntimeError.startupTimeout - } + private func launchProcess( + entrypointURL: URL, + port: Int, + stateDirectory: URL, + workingDirectory: String + ) throws { + terminateProcess() - var assignedAddress = sockaddr_in() - var length = socklen_t(MemoryLayout.size) + try FileManager.default.createDirectory(at: paths.runtimeLogPath.deletingLastPathComponent(), withIntermediateDirectories: true) + if !FileManager.default.fileExists(atPath: paths.runtimeLogPath.path) { + FileManager.default.createFile(atPath: paths.runtimeLogPath.path, contents: nil) + } - let nameResult = withUnsafeMutablePointer(to: &assignedAddress) { - $0.withMemoryRebound(to: sockaddr.self, capacity: 1) { - getsockname(socketFD, $0, &length) - } - } + let handle = try FileHandle(forWritingTo: paths.runtimeLogPath) + try handle.seekToEnd() + logHandle = handle - guard nameResult == 0 else { - throw T3RuntimeError.startupTimeout - } + let process = Process() + process.currentDirectoryURL = URL(fileURLWithPath: workingDirectory, isDirectory: true) + + let runtimeArgumentsPrefix = [ + entrypointURL.path, + "--mode", "web", + "--host", "127.0.0.1", + "--port", String(port), + ] - return Int(UInt16(bigEndian: assignedAddress.sin_port)) + var runtimePathDirectories: [String] = [] + let nodeExecutablePath = resolveRuntimeExecutablePath("node") + if let nodeExecutablePath { + process.executableURL = URL(fileURLWithPath: nodeExecutablePath) + appendRuntimeLog("resolved node executable: \(nodeExecutablePath)") + runtimePathDirectories.append( + URL(fileURLWithPath: nodeExecutablePath, isDirectory: false) + .deletingLastPathComponent() + .path + ) + } else { + process.executableURL = URL(fileURLWithPath: "/usr/bin/env") + appendRuntimeLog("node resolution fallback: using /usr/bin/env node") } - private func launchProcess( - entrypointURL: URL, - port: Int, - stateDirectory: URL, - workingDirectory: String - ) throws { - terminateProcess() + var runtimeArguments = runtimeArgumentsPrefix + if let baseDirectoryFlag = resolveBaseDirectoryFlag(entrypointURL: entrypointURL, nodeExecutablePath: nodeExecutablePath) { + runtimeArguments.append(contentsOf: [baseDirectoryFlag, stateDirectory.path]) + } + runtimeArguments.append(contentsOf: [ + "--no-browser", + "--auto-bootstrap-project-from-cwd", + ]) + + if nodeExecutablePath != nil { + process.arguments = runtimeArguments + } else { + process.arguments = ["node"] + runtimeArguments + } - try FileManager.default.createDirectory(at: paths.runtimeLogPath.deletingLastPathComponent(), withIntermediateDirectories: true) - if !FileManager.default.fileExists(atPath: paths.runtimeLogPath.path) { - FileManager.default.createFile(atPath: paths.runtimeLogPath.path, contents: nil) - } + if let codexExecutable = resolveRuntimeExecutablePath("codex") { + appendRuntimeLog("resolved codex executable: \(codexExecutable)") + runtimePathDirectories.append( + URL(fileURLWithPath: codexExecutable, isDirectory: false) + .deletingLastPathComponent() + .path + ) + } else { + appendRuntimeLog("codex executable not resolved during launch; relying on inherited PATH") + } - let handle = try FileHandle(forWritingTo: paths.runtimeLogPath) - try handle.seekToEnd() - logHandle = handle + var env = ProcessInfo.processInfo.environment + env["T3CODE_MODE"] = "web" + env["T3CODE_HOST"] = "127.0.0.1" + env["T3CODE_PORT"] = String(port) + env["T3CODE_HOME"] = stateDirectory.path + env["T3CODE_STATE_DIR"] = stateDirectory.path + env["T3CODE_NO_BROWSER"] = "1" + if !runtimePathDirectories.isEmpty { + env["PATH"] = mergedPath(prepending: runtimePathDirectories, existingPath: env["PATH"]) + } - let process = Process() - process.currentDirectoryURL = URL(fileURLWithPath: workingDirectory, isDirectory: true) - - let runtimeArguments = [ - entrypointURL.path, - "--mode", "web", - "--host", "127.0.0.1", - "--port", String(port), - "--state-dir", stateDirectory.path, - "--no-browser", - "--auto-bootstrap-project-from-cwd" - ] - - var runtimePathDirectories: [String] = [] - if let nodeExecutable = resolveRuntimeExecutablePath("node") { - process.executableURL = URL(fileURLWithPath: nodeExecutable) - process.arguments = runtimeArguments - appendRuntimeLog("resolved node executable: \(nodeExecutable)") - runtimePathDirectories.append( - URL(fileURLWithPath: nodeExecutable, isDirectory: false) - .deletingLastPathComponent() - .path - ) - } else { - process.executableURL = URL(fileURLWithPath: "/usr/bin/env") - process.arguments = ["node"] + runtimeArguments - appendRuntimeLog("node resolution fallback: using /usr/bin/env node") - } + if let isolatedZdotDir = prepareIsolatedZdotDir() { + env["ZDOTDIR"] = isolatedZdotDir.path + appendRuntimeLog("using isolated ZDOTDIR: \(isolatedZdotDir.path)") + } - if let codexExecutable = resolveRuntimeExecutablePath("codex") { - appendRuntimeLog("resolved codex executable: \(codexExecutable)") - runtimePathDirectories.append( - URL(fileURLWithPath: codexExecutable, isDirectory: false) - .deletingLastPathComponent() - .path - ) - } else { - appendRuntimeLog("codex executable not resolved during launch; relying on inherited PATH") - } + process.environment = env - var env = ProcessInfo.processInfo.environment - env["T3CODE_MODE"] = "web" - env["T3CODE_HOST"] = "127.0.0.1" - env["T3CODE_PORT"] = String(port) - env["T3CODE_STATE_DIR"] = stateDirectory.path - env["T3CODE_NO_BROWSER"] = "1" - if !runtimePathDirectories.isEmpty { - env["PATH"] = mergedPath(prepending: runtimePathDirectories, existingPath: env["PATH"]) - } + let stdout = Pipe() + let stderr = Pipe() + process.standardOutput = stdout + process.standardError = stderr - if let isolatedZdotDir = prepareIsolatedZdotDir() { - env["ZDOTDIR"] = isolatedZdotDir.path - appendRuntimeLog("using isolated ZDOTDIR: \(isolatedZdotDir.path)") - } + stdout.fileHandleForReading.readabilityHandler = { [weak self] handle in + let data = handle.availableData + guard !data.isEmpty else { return } + Task { @MainActor [weak self] in + self?.appendLogData(data) + } + } - process.environment = env + stderr.fileHandleForReading.readabilityHandler = { [weak self] handle in + let data = handle.availableData + guard !data.isEmpty else { return } + Task { @MainActor [weak self] in + self?.appendLogData(data) + } + } - let stdout = Pipe() - let stderr = Pipe() - process.standardOutput = stdout - process.standardError = stderr + process.terminationHandler = { [weak self] terminated in + Task { @MainActor [weak self] in + self?.handleProcessExit(terminated) + } + } - stdout.fileHandleForReading.readabilityHandler = { [weak self] handle in - let data = handle.availableData - guard !data.isEmpty else { return } - Task { @MainActor [weak self] in - self?.appendLogData(data) - } - } + try process.run() - stderr.fileHandleForReading.readabilityHandler = { [weak self] handle in - let data = handle.availableData - guard !data.isEmpty else { return } - Task { @MainActor [weak self] in - self?.appendLogData(data) - } - } + self.process = process + stdoutPipe = stdout + stderrPipe = stderr - process.terminationHandler = { [weak self] terminated in - Task { @MainActor [weak self] in - self?.handleProcessExit(terminated) - } - } + appendRuntimeLog("spawned process pid=\(process.processIdentifier) port=\(port)") + } - try process.run() + private func resolveRuntimeExecutablePath(_ executable: String) -> String? { + guard executable.range(of: #"^[A-Za-z0-9._+-]+$"#, options: .regularExpression) != nil else { + return nil + } - self.process = process - self.stdoutPipe = stdout - self.stderrPipe = stderr + let probes: [(String, [String])] = [ + ("/usr/bin/which", [executable]), + ("/bin/zsh", ["-lc", "whence -p \(executable)"]), + ("/bin/zsh", ["-ilc", "whence -p \(executable)"]), + ] - appendRuntimeLog("spawned process pid=\(process.processIdentifier) port=\(port)") + for probe in probes { + if let resolved = runRuntimeProbe(executable: probe.0, arguments: probe.1) { + return resolved + } } - private func resolveRuntimeExecutablePath(_ executable: String) -> String? { - guard executable.range(of: #"^[A-Za-z0-9._+-]+$"#, options: .regularExpression) != nil else { - return nil - } + return nil + } - let probes: [(String, [String])] = [ - ("/usr/bin/which", [executable]), - ("/bin/zsh", ["-lc", "whence -p \(executable)"]), - ("/bin/zsh", ["-ilc", "whence -p \(executable)"]) - ] + private func runRuntimeProbe(executable: String, arguments: [String]) -> String? { + let process = Process() + process.executableURL = URL(fileURLWithPath: executable) + process.arguments = arguments + process.environment = ProcessInfo.processInfo.environment - for probe in probes { - if let resolved = runRuntimeProbe(executable: probe.0, arguments: probe.1) { - return resolved - } - } + let stdout = Pipe() + process.standardOutput = stdout + process.standardError = Pipe() - return nil + do { + try process.run() + process.waitUntilExit() + } catch { + return nil } - private func runRuntimeProbe(executable: String, arguments: [String]) -> String? { - let process = Process() - process.executableURL = URL(fileURLWithPath: executable) - process.arguments = arguments - process.environment = ProcessInfo.processInfo.environment + guard process.terminationStatus == 0 else { + return nil + } - let stdout = Pipe() - process.standardOutput = stdout - process.standardError = Pipe() + let output = String(decoding: stdout.fileHandleForReading.readDataToEndOfFile(), as: UTF8.self) + let candidates = output + .split(whereSeparator: \.isNewline) + .map { String($0).trimmingCharacters(in: .whitespacesAndNewlines) } + .filter { !$0.isEmpty && $0.hasPrefix("/") } - do { - try process.run() - process.waitUntilExit() - } catch { - return nil - } + for candidate in candidates where FileManager.default.isExecutableFile(atPath: candidate) { + return candidate + } - guard process.terminationStatus == 0 else { - return nil - } + return nil + } + + static func preferredBaseDirectoryFlag(fromHelpText helpText: String) -> String? { + let normalizedHelpText = helpText.lowercased() + for candidate in ["--home-dir", "--base-dir", "--state-dir"] where normalizedHelpText.contains(candidate) { + return candidate + } + return nil + } - let output = String(decoding: stdout.fileHandleForReading.readDataToEndOfFile(), as: UTF8.self) - let candidates = output - .split(whereSeparator: \.isNewline) - .map { String($0).trimmingCharacters(in: .whitespacesAndNewlines) } - .filter { !$0.isEmpty && $0.hasPrefix("/") } + private func resolveBaseDirectoryFlag(entrypointURL: URL, nodeExecutablePath: String?) -> String? { + if resolvedBaseDirectoryFlag { + return cachedBaseDirectoryFlag + } + resolvedBaseDirectoryFlag = true + + let process = Process() + if let nodeExecutablePath { + process.executableURL = URL(fileURLWithPath: nodeExecutablePath) + process.arguments = [entrypointURL.path, "--help"] + } else { + process.executableURL = URL(fileURLWithPath: "/usr/bin/env") + process.arguments = ["node", entrypointURL.path, "--help"] + } + process.environment = ProcessInfo.processInfo.environment + + let stdout = Pipe() + let stderr = Pipe() + process.standardOutput = stdout + process.standardError = stderr + + do { + try process.run() + process.waitUntilExit() + } catch { + appendRuntimeLog("failed to probe t3 CLI flags: \(error.localizedDescription)") + return nil + } - for candidate in candidates where FileManager.default.isExecutableFile(atPath: candidate) { - return candidate - } + let helpText = + String(decoding: stdout.fileHandleForReading.readDataToEndOfFile(), as: UTF8.self) + + String(decoding: stderr.fileHandleForReading.readDataToEndOfFile(), as: UTF8.self) + let selectedFlag = Self.preferredBaseDirectoryFlag(fromHelpText: helpText) + cachedBaseDirectoryFlag = selectedFlag - return nil + if let selectedFlag { + appendRuntimeLog("resolved runtime base directory flag: \(selectedFlag)") + } else { + appendRuntimeLog("runtime CLI help did not expose a known base directory flag; relying on environment variables") } - private func mergedPath(prepending directories: [String], existingPath: String?) -> String { - var combined: [String] = directories - if let existingPath { - combined.append(contentsOf: existingPath.split(separator: ":").map(String.init)) - } + return selectedFlag + } - var seen: Set = [] - var unique: [String] = [] - for directory in combined { - guard !directory.isEmpty else { continue } - if seen.insert(directory).inserted { - unique.append(directory) - } - } - return unique.joined(separator: ":") + private func mergedPath(prepending directories: [String], existingPath: String?) -> String { + var combined: [String] = directories + if let existingPath { + combined.append(contentsOf: existingPath.split(separator: ":").map(String.init)) } - private func prepareIsolatedZdotDir() -> URL? { - let zdotDir = paths.sessionDirectory.appendingPathComponent("codex-zdotdir", isDirectory: true) + var seen: Set = [] + var unique: [String] = [] + for directory in combined { + guard !directory.isEmpty else { continue } + if seen.insert(directory).inserted { + unique.append(directory) + } + } + return unique.joined(separator: ":") + } - do { - try FileManager.default.createDirectory(at: zdotDir, withIntermediateDirectories: true) + private func prepareIsolatedZdotDir() -> URL? { + let zdotDir = paths.sessionDirectory.appendingPathComponent("codex-zdotdir", isDirectory: true) - // Keep login/non-login shells quiet and deterministic for Codex shell snapshots. - for filename in [".zshenv", ".zprofile", ".zshrc", ".zlogin"] { - let fileURL = zdotDir.appendingPathComponent(filename, isDirectory: false) - if !FileManager.default.fileExists(atPath: fileURL.path) { - try "".write(to: fileURL, atomically: true, encoding: .utf8) - } - } + do { + try FileManager.default.createDirectory(at: zdotDir, withIntermediateDirectories: true) - return zdotDir - } catch { - appendRuntimeLog("failed to prepare isolated ZDOTDIR: \(error.localizedDescription)") - return nil + // Keep login/non-login shells quiet and deterministic for Codex shell snapshots. + for filename in [".zshenv", ".zprofile", ".zshrc", ".zlogin"] { + let fileURL = zdotDir.appendingPathComponent(filename, isDirectory: false) + if !FileManager.default.fileExists(atPath: fileURL.path) { + try "".write(to: fileURL, atomically: true, encoding: .utf8) } - } + } - private func waitForServerReady(port: Int) async -> Bool { - let url = URL(string: "http://127.0.0.1:\(port)/")! - let deadline = Date().addingTimeInterval(readinessTimeoutSeconds) + return zdotDir + } catch { + appendRuntimeLog("failed to prepare isolated ZDOTDIR: \(error.localizedDescription)") + return nil + } + } - while Date() < deadline { - if Task.isCancelled || userStopped { - return false - } + private func waitForServerReady(port: Int) async -> Bool { + let url = URL(string: "http://127.0.0.1:\(port)/")! + let deadline = Date().addingTimeInterval(readinessTimeoutSeconds) - if process?.isRunning == false { - return false - } + while Date() < deadline { + if Task.isCancelled || userStopped { + return false + } - if await probeServer(url: url) { - return true - } + if process?.isRunning == false { + return false + } - try? await Task.sleep(nanoseconds: readinessIntervalNanoseconds) - } + if await probeServer(url: url) { + return true + } - return false + try? await Task.sleep(nanoseconds: readinessIntervalNanoseconds) } - private func probeServer(url: URL) async -> Bool { - var request = URLRequest(url: url) - request.timeoutInterval = 1 + return false + } - do { - let (_, response) = try await URLSession.shared.data(for: request) - return response is HTTPURLResponse - } catch { - return false - } - } + private func probeServer(url: URL) async -> Bool { + var request = URLRequest(url: url) + request.timeoutInterval = 1 - private func handleProcessExit(_ terminatedProcess: Process) { - appendRuntimeLog( - "process exited status=\(terminatedProcess.terminationStatus) reason=\(terminatedProcess.terminationReason.rawValue)" - ) + do { + let (_, response) = try await URLSession.shared.data(for: request) + return response is HTTPURLResponse + } catch { + return false + } + } - terminateProcess() + private func handleProcessExit(_ terminatedProcess: Process) { + appendRuntimeLog( + "process exited status=\(terminatedProcess.terminationStatus) reason=\(terminatedProcess.terminationReason.rawValue)" + ) - guard !userStopped else { return } + terminateProcess() - if case .failed = state { - return - } + guard !userStopped else { return } - if startTask == nil { - state = .starting - startTask = Task { [weak self] in - guard let self else { return } - await self.runStartupSequence() - self.startTask = nil - } - } + if case .failed = state { + return } - private func terminateProcess() { - stdoutPipe?.fileHandleForReading.readabilityHandler = nil - stderrPipe?.fileHandleForReading.readabilityHandler = nil - stdoutPipe = nil - stderrPipe = nil + if startTask == nil { + state = .starting + startTask = Task { [weak self] in + guard let self else { return } + await runStartupSequence() + startTask = nil + } + } + } - if let process, process.isRunning { - process.terminate() - } - self.process = nil + private func terminateProcess() { + stdoutPipe?.fileHandleForReading.readabilityHandler = nil + stderrPipe?.fileHandleForReading.readabilityHandler = nil + stdoutPipe = nil + stderrPipe = nil - if let handle = logHandle { - try? handle.close() - } - logHandle = nil + if let process, process.isRunning { + process.terminate() } + process = nil - private func appendLogData(_ data: Data) { - guard let logHandle else { return } - do { - try logHandle.seekToEnd() - try logHandle.write(contentsOf: data) - } catch { - Logger.error("Failed writing T3 runtime log data: \(error.localizedDescription)") - } + if let handle = logHandle { + try? handle.close() } + logHandle = nil + } + + private func appendLogData(_ data: Data) { + guard let logHandle else { return } + do { + try logHandle.seekToEnd() + try logHandle.write(contentsOf: data) + } catch { + Logger.error("Failed writing T3 runtime log data: \(error.localizedDescription)") + } + } - private func appendRuntimeLog(_ line: String) { - let timestamp = ISO8601DateFormatter().string(from: Date()) - guard let data = "[\(timestamp)] \(line)\n".data(using: .utf8) else { return } - - if logHandle == nil { - do { - try FileManager.default.createDirectory(at: paths.runtimeLogPath.deletingLastPathComponent(), withIntermediateDirectories: true) - if !FileManager.default.fileExists(atPath: paths.runtimeLogPath.path) { - FileManager.default.createFile(atPath: paths.runtimeLogPath.path, contents: nil) - } - let handle = try FileHandle(forWritingTo: paths.runtimeLogPath) - try handle.seekToEnd() - logHandle = handle - } catch { - Logger.error("Failed opening T3 runtime log: \(error.localizedDescription)") - return - } - } + private func appendRuntimeLog(_ line: String) { + let timestamp = ISO8601DateFormatter().string(from: Date()) + guard let data = "[\(timestamp)] \(line)\n".data(using: .utf8) else { return } - appendLogData(data) + if logHandle == nil { + do { + try FileManager.default.createDirectory(at: paths.runtimeLogPath.deletingLastPathComponent(), withIntermediateDirectories: true) + if !FileManager.default.fileExists(atPath: paths.runtimeLogPath.path) { + FileManager.default.createFile(atPath: paths.runtimeLogPath.path, contents: nil) + } + let handle = try FileHandle(forWritingTo: paths.runtimeLogPath) + try handle.seekToEnd() + logHandle = handle + } catch { + Logger.error("Failed opening T3 runtime log: \(error.localizedDescription)") + return + } } - private func handleWebContentTermination(_ webView: WKWebView) { - webContentTerminationCount += 1 - appendRuntimeLog("web content terminated count=\(webContentTerminationCount)") + appendLogData(data) + } - guard case .live(let urlString) = state, - let url = URL(string: urlString) else { - return - } + private func handleWebContentTermination(_ webView: WKWebView) { + webContentTerminationCount += 1 + appendRuntimeLog("web content terminated count=\(webContentTerminationCount)") - if webContentTerminationCount <= maxWebContentReloadAttempts { - appendRuntimeLog("reloading embedded content after WebContent termination") - webView.load(URLRequest(url: url)) - return - } + guard case let .live(urlString) = state, + let url = URL(string: urlString) + else { + return + } - appendRuntimeLog("web content termination retry budget exhausted; terminating runtime process") - state = .failed( - message: "Embedded browser process crashed repeatedly. Open logs for details.", - logPath: paths.runtimeLogPath.path - ) - terminateProcess() + if webContentTerminationCount <= maxWebContentReloadAttempts { + appendRuntimeLog("reloading embedded content after WebContent termination") + webView.load(URLRequest(url: url)) + return } - private func isRetryableStartupError(_ error: Error) -> Bool { - guard let runtimeError = error as? T3RuntimeError else { - return false - } - switch runtimeError { - case .startupTimeout, .processExitedBeforeReady: - return true - case .missingTool, .commandFailed, .missingArtifact, .cancelled: - return false - } + appendRuntimeLog("web content termination retry budget exhausted; terminating runtime process") + state = .failed( + message: "Embedded browser process crashed repeatedly. Open logs for details.", + logPath: paths.runtimeLogPath.path + ) + terminateProcess() + } + + private func isRetryableStartupError(_ error: Error) -> Bool { + guard let runtimeError = error as? T3RuntimeError else { + return false + } + switch runtimeError { + case .startupTimeout, .processExitedBeforeReady: + return true + case .missingTool, .commandFailed, .missingArtifact, .cancelled: + return false } + } - private func logPathForError(_ error: Error) -> String { - guard let runtimeError = error as? T3RuntimeError else { - return paths.runtimeLogPath.path - } - switch runtimeError { - case .missingTool, .commandFailed, .missingArtifact: - return paths.buildLogPath.path - case .startupTimeout, .processExitedBeforeReady, .cancelled: - return paths.runtimeLogPath.path - } + private func logPathForError(_ error: Error) -> String { + guard let runtimeError = error as? T3RuntimeError else { + return paths.runtimeLogPath.path + } + switch runtimeError { + case .missingTool, .commandFailed, .missingArtifact: + return paths.buildLogPath.path + case .startupTimeout, .processExitedBeforeReady, .cancelled: + return paths.runtimeLogPath.path } + } } diff --git a/idx0/Apps/VSCode/VSCodeRuntime.swift b/idx0/Apps/VSCode/VSCodeRuntime.swift index 2075d95..b35bb39 100644 --- a/idx0/Apps/VSCode/VSCodeRuntime.swift +++ b/idx0/Apps/VSCode/VSCodeRuntime.swift @@ -4,1207 +4,1366 @@ import Darwin import Foundation import WebKit -struct VSCodeArtifactManifest: Codable, Equatable, Sendable { - let platform: String - let downloadURL: String - let sha256: String - let extractDirectoryName: String +struct VSCodeArtifactManifest: Codable, Equatable { + let platform: String + let downloadURL: String + let sha256: String + let extractDirectoryName: String } -struct VSCodeBuildManifest: Codable, Equatable, Sendable { - static let defaultVersion = "4.112.0" - - let runtimeName: String - let version: String - let executableRelativePath: String - let artifacts: [VSCodeArtifactManifest] - - static let `default` = VSCodeBuildManifest( - runtimeName: "code-server", - version: defaultVersion, - executableRelativePath: "bin/code-server", - artifacts: [ - VSCodeArtifactManifest( - platform: "macos-arm64", - downloadURL: "https://github.com/coder/code-server/releases/download/v4.112.0/code-server-4.112.0-macos-arm64.tar.gz", - sha256: "1a0a3cfbd7b5c946c1bbdf56a2b0a92b2995f5da316ca5f599e5ec782c00fb71", - extractDirectoryName: "code-server-4.112.0-macos-arm64" - ), - VSCodeArtifactManifest( - platform: "macos-amd64", - downloadURL: "https://github.com/coder/code-server/releases/download/v4.112.0/code-server-4.112.0-macos-amd64.tar.gz", - sha256: "f1ad6c133ae6e46904af4d81a55f415382c6b7eb83df8383deaea90c9b7fd58a", - extractDirectoryName: "code-server-4.112.0-macos-amd64" - ) - ] - ) +struct VSCodeBuildManifest: Codable, Equatable { + static let defaultVersion = "4.112.0" + + let runtimeName: String + let version: String + let executableRelativePath: String + let artifacts: [VSCodeArtifactManifest] + + static let `default` = VSCodeBuildManifest( + runtimeName: "code-server", + version: defaultVersion, + executableRelativePath: "bin/code-server", + artifacts: [ + VSCodeArtifactManifest( + platform: "macos-arm64", + downloadURL: "https://github.com/coder/code-server/releases/download/v4.112.0/code-server-4.112.0-macos-arm64.tar.gz", + sha256: "1a0a3cfbd7b5c946c1bbdf56a2b0a92b2995f5da316ca5f599e5ec782c00fb71", + extractDirectoryName: "code-server-4.112.0-macos-arm64" + ), + VSCodeArtifactManifest( + platform: "macos-amd64", + downloadURL: "https://github.com/coder/code-server/releases/download/v4.112.0/code-server-4.112.0-macos-amd64.tar.gz", + sha256: "f1ad6c133ae6e46904af4d81a55f415382c6b7eb83df8383deaea90c9b7fd58a", + extractDirectoryName: "code-server-4.112.0-macos-amd64" + ), + ] + ) + + static func loadFromBundle(_ bundle: Bundle = .main) -> VSCodeBuildManifest { + guard let url = bundle.url(forResource: "openvscode-build-manifest", withExtension: "json"), + let data = try? Data(contentsOf: url), + let decoded = try? JSONDecoder().decode(VSCodeBuildManifest.self, from: data) + else { + return .default + } + return decoded + } + + func artifact(forCurrentPlatform platformOverride: String? = nil) -> VSCodeArtifactManifest? { + let platform = platformOverride ?? Self.currentPlatformIdentifier() + return artifacts.first(where: { $0.platform == platform }) + } + + static func currentPlatformIdentifier() -> String { + #if arch(arm64) + return "macos-arm64" + #elseif arch(x86_64) + return "macos-amd64" + #else + return "macos-unsupported" + #endif + } +} - static func loadFromBundle(_ bundle: Bundle = .main) -> VSCodeBuildManifest { - guard let url = bundle.url(forResource: "openvscode-build-manifest", withExtension: "json"), - let data = try? Data(contentsOf: url), - let decoded = try? JSONDecoder().decode(VSCodeBuildManifest.self, from: data) - else { - return .default - } - return decoded +private struct GitHubReleaseAsset: Decodable { + let name: String + let browserDownloadURL: String + + private enum CodingKeys: String, CodingKey { + case name + case browserDownloadURL = "browser_download_url" + } +} + +private struct GitHubRelease: Decodable { + let tagName: String + let assets: [GitHubReleaseAsset] + + private enum CodingKeys: String, CodingKey { + case tagName = "tag_name" + case assets + } +} + +private enum VSCodeLatestManifestResolutionError: LocalizedError { + case invalidResponse(statusCode: Int) + case missingArchiveAsset(platform: String) + + var errorDescription: String? { + switch self { + case let .invalidResponse(statusCode): + "GitHub latest release request failed with status \(statusCode)" + case let .missingArchiveAsset(platform): + "Latest release is missing a code-server archive for \(platform)" } + } +} - func artifact(forCurrentPlatform platformOverride: String? = nil) -> VSCodeArtifactManifest? { - let platform = platformOverride ?? Self.currentPlatformIdentifier() - return artifacts.first(where: { $0.platform == platform }) +actor VSCodeLatestManifestResolver { + private let session: URLSession + private let decoder: JSONDecoder + + init(session: URLSession = .shared, decoder: JSONDecoder = JSONDecoder()) { + self.session = session + self.decoder = decoder + } + + func resolveLatestManifest(fallback: VSCodeBuildManifest) async -> VSCodeBuildManifest { + do { + return try await fetchLatestManifest(fallback: fallback) + } catch { + Logger.error("Failed to resolve latest VS Code runtime manifest; using bundled manifest: \(error.localizedDescription)") + return fallback } + } - static func currentPlatformIdentifier() -> String { -#if arch(arm64) - return "macos-arm64" -#elseif arch(x86_64) - return "macos-amd64" -#else - return "macos-unsupported" -#endif + private func fetchLatestManifest(fallback: VSCodeBuildManifest) async throws -> VSCodeBuildManifest { + guard let releaseURL = URL(string: "https://api.github.com/repos/coder/code-server/releases/latest") else { + return fallback } -} -struct VSCodeRuntimePaths: Sendable { - let rootDirectory: URL - let runtimeDirectory: URL - let runtimeVersionsDirectory: URL - let runtimeInstallRecordPath: URL - let downloadsDirectory: URL - let provisionLogPath: URL - let profilesDirectory: URL - let sessionsDirectory: URL - let sessionDirectory: URL - let sessionUserDataDirectory: URL - let sessionExtensionsDirectory: URL - let runtimeLogPath: URL - - init( - sessionID: UUID, - rootDirectoryOverride: URL? = nil, - fileManager: FileManager = .default - ) { - let idx0Root: URL - if let rootDirectoryOverride { - idx0Root = rootDirectoryOverride - } else { - let appSupportRoot = fileManager.urls(for: .applicationSupportDirectory, in: .userDomainMask).first - ?? URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true) - idx0Root = appSupportRoot - .appendingPathComponent("idx0", isDirectory: true) - .appendingPathComponent("openvscode", isDirectory: true) - } + var request = URLRequest(url: releaseURL) + request.timeoutInterval = 10 + request.setValue("application/vnd.github+json", forHTTPHeaderField: "Accept") + request.setValue("idx0-runtime", forHTTPHeaderField: "User-Agent") - rootDirectory = idx0Root - runtimeDirectory = idx0Root.appendingPathComponent("runtime", isDirectory: true) - runtimeVersionsDirectory = runtimeDirectory.appendingPathComponent("versions", isDirectory: true) - runtimeInstallRecordPath = runtimeDirectory.appendingPathComponent("install-record.json", isDirectory: false) - downloadsDirectory = runtimeDirectory.appendingPathComponent("downloads", isDirectory: true) - provisionLogPath = idx0Root - .appendingPathComponent("logs", isDirectory: true) - .appendingPathComponent("provision.log", isDirectory: false) - profilesDirectory = idx0Root.appendingPathComponent("profiles", isDirectory: true) - - sessionsDirectory = idx0Root.appendingPathComponent("sessions", isDirectory: true) - sessionDirectory = sessionsDirectory.appendingPathComponent(sessionID.uuidString, isDirectory: true) - sessionUserDataDirectory = sessionDirectory.appendingPathComponent("user-data", isDirectory: true) - sessionExtensionsDirectory = sessionDirectory.appendingPathComponent("extensions", isDirectory: true) - runtimeLogPath = sessionDirectory.appendingPathComponent("runtime.log", isDirectory: false) - } - - func ensureBaseDirectories(fileManager: FileManager = .default) throws { - try fileManager.createDirectory(at: rootDirectory, withIntermediateDirectories: true) - try fileManager.createDirectory(at: runtimeVersionsDirectory, withIntermediateDirectories: true) - try fileManager.createDirectory(at: downloadsDirectory, withIntermediateDirectories: true) - try fileManager.createDirectory(at: provisionLogPath.deletingLastPathComponent(), withIntermediateDirectories: true) - try fileManager.createDirectory(at: profilesDirectory, withIntermediateDirectories: true) - try fileManager.createDirectory(at: sessionsDirectory, withIntermediateDirectories: true) - try fileManager.createDirectory(at: sessionDirectory, withIntermediateDirectories: true) + let (releaseData, releaseResponse) = try await session.data(for: request) + let statusCode = (releaseResponse as? HTTPURLResponse)?.statusCode ?? -1 + guard (200 ..< 300).contains(statusCode) else { + throw VSCodeLatestManifestResolutionError.invalidResponse(statusCode: statusCode) } -} -enum VSCodeTileRuntimeState: Equatable, Sendable { - case idle - case provisioning - case downloading - case extracting - case starting - case live(urlString: String) - case failed(message: String, logPath: String?) - - var displayMessage: String { - switch self { - case .idle: - return "Ready" - case .provisioning: - return "Preparing VS Code runtime..." - case .downloading: - return "Downloading VS Code runtime..." - case .extracting: - return "Installing VS Code runtime..." - case .starting: - return "Starting VS Code..." - case .live: - return "Live" - case .failed(let message, _): - return message - } + let release = try decoder.decode(GitHubRelease.self, from: releaseData) + let platform = VSCodeBuildManifest.currentPlatformIdentifier() + let suffix = "-\(platform).tar.gz" + guard let archiveAsset = release.assets.first(where: { asset in + asset.name.hasPrefix("code-server-") && asset.name.hasSuffix(suffix) + }) else { + throw VSCodeLatestManifestResolutionError.missingArchiveAsset(platform: platform) } -} -enum VSCodeRuntimeError: LocalizedError, Sendable { - case unsupportedPlatform(String) - case invalidDownloadURL(String) - case downloadFailed(String) - case checksumMismatch(expected: String, actual: String) - case missingExecutable(String) - case commandFailed(command: String, code: Int32, stderr: String?) - case startupTimeout - case processExitedBeforeReady - case cancelled - - var errorDescription: String? { - switch self { - case .unsupportedPlatform(let platform): - return "VS Code runtime is not available for platform: \(platform)" - case .invalidDownloadURL(let raw): - return "Invalid runtime download URL: \(raw)" - case .downloadFailed(let description): - return "Runtime download failed: \(description)" - case .checksumMismatch(let expected, let actual): - return "Downloaded runtime checksum mismatch. Expected \(expected), got \(actual)." - case .missingExecutable(let path): - return "Runtime executable missing: \(path)" - case .commandFailed(let command, let code, let stderr): - if let stderr, !stderr.isEmpty { - return "Command failed (\(code)): \(command)\n\(stderr)" - } - return "Command failed (\(code)): \(command)" - case .startupTimeout: - return "VS Code did not become ready in time." - case .processExitedBeforeReady: - return "VS Code process exited before it became ready." - case .cancelled: - return "Operation cancelled." - } + let extractDirectoryName = archiveAsset.name.replacingOccurrences(of: ".tar.gz", with: "") + let resolvedVersion = extractDirectoryName + .replacingOccurrences(of: "code-server-", with: "") + .replacingOccurrences(of: "-\(platform)", with: "") + let resolvedChecksum = await resolveChecksum( + release: release, + archiveAssetName: archiveAsset.name + ) ?? "" + + return VSCodeBuildManifest( + runtimeName: fallback.runtimeName, + version: resolvedVersion, + executableRelativePath: fallback.executableRelativePath, + artifacts: [ + VSCodeArtifactManifest( + platform: platform, + downloadURL: archiveAsset.browserDownloadURL, + sha256: resolvedChecksum, + extractDirectoryName: extractDirectoryName + ), + ] + ) + } + + private func resolveChecksum( + release: GitHubRelease, + archiveAssetName: String + ) async -> String? { + let checksumAssetNames = ["SHA256SUMS", "SHA256SUMS.txt"] + guard let checksumAsset = release.assets.first(where: { checksumAssetNames.contains($0.name) }), + let checksumURL = URL(string: checksumAsset.browserDownloadURL) + else { + return nil + } + + do { + let (checksumData, response) = try await session.data(from: checksumURL) + let statusCode = (response as? HTTPURLResponse)?.statusCode ?? -1 + guard (200 ..< 300).contains(statusCode), + let checksumBody = String(data: checksumData, encoding: .utf8) + else { + return nil + } + return parseChecksum(checksumBody, archiveAssetName: archiveAssetName) + } catch { + return nil } + } + + private func parseChecksum(_ body: String, archiveAssetName: String) -> String? { + for rawLine in body.split(whereSeparator: \.isNewline) { + let line = rawLine.trimmingCharacters(in: .whitespacesAndNewlines) + guard !line.isEmpty else { continue } + + let tokens = line + .split(whereSeparator: \.isWhitespace) + .map(String.init) + guard tokens.count >= 2 else { continue } + + let hash = tokens[0].lowercased() + var filename = tokens[tokens.count - 1] + if filename.hasPrefix("*") || filename.hasPrefix("./") { + filename = String(filename.drop(while: { $0 == "*" || $0 == "." || $0 == "/" })) + } + + if filename == archiveAssetName { + return hash + } + } + return nil + } } -private struct VSCodeInstallRecord: Codable, Sendable { - let runtimeName: String - let version: String - let platform: String - let sha256: String - let runtimeDirectoryName: String - let executableRelativePath: String - let installedAt: Date +struct VSCodeRuntimePaths { + let rootDirectory: URL + let runtimeDirectory: URL + let runtimeVersionsDirectory: URL + let runtimeInstallRecordPath: URL + let downloadsDirectory: URL + let provisionLogPath: URL + let profilesDirectory: URL + let sessionsDirectory: URL + let sessionDirectory: URL + let sessionUserDataDirectory: URL + let sessionExtensionsDirectory: URL + let runtimeLogPath: URL + + init( + sessionID: UUID, + rootDirectoryOverride: URL? = nil, + fileManager: FileManager = .default + ) { + let idx0Root: URL + if let rootDirectoryOverride { + idx0Root = rootDirectoryOverride + } else { + let appSupportRoot = fileManager.urls(for: .applicationSupportDirectory, in: .userDomainMask).first + ?? URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true) + idx0Root = appSupportRoot + .appendingPathComponent("idx0", isDirectory: true) + .appendingPathComponent("openvscode", isDirectory: true) + } + + rootDirectory = idx0Root + runtimeDirectory = idx0Root.appendingPathComponent("runtime", isDirectory: true) + runtimeVersionsDirectory = runtimeDirectory.appendingPathComponent("versions", isDirectory: true) + runtimeInstallRecordPath = runtimeDirectory.appendingPathComponent("install-record.json", isDirectory: false) + downloadsDirectory = runtimeDirectory.appendingPathComponent("downloads", isDirectory: true) + provisionLogPath = idx0Root + .appendingPathComponent("logs", isDirectory: true) + .appendingPathComponent("provision.log", isDirectory: false) + profilesDirectory = idx0Root.appendingPathComponent("profiles", isDirectory: true) + + sessionsDirectory = idx0Root.appendingPathComponent("sessions", isDirectory: true) + sessionDirectory = sessionsDirectory.appendingPathComponent(sessionID.uuidString, isDirectory: true) + sessionUserDataDirectory = sessionDirectory.appendingPathComponent("user-data", isDirectory: true) + sessionExtensionsDirectory = sessionDirectory.appendingPathComponent("extensions", isDirectory: true) + runtimeLogPath = sessionDirectory.appendingPathComponent("runtime.log", isDirectory: false) + } + + func ensureBaseDirectories(fileManager: FileManager = .default) throws { + try fileManager.createDirectory(at: rootDirectory, withIntermediateDirectories: true) + try fileManager.createDirectory(at: runtimeVersionsDirectory, withIntermediateDirectories: true) + try fileManager.createDirectory(at: downloadsDirectory, withIntermediateDirectories: true) + try fileManager.createDirectory(at: provisionLogPath.deletingLastPathComponent(), withIntermediateDirectories: true) + try fileManager.createDirectory(at: profilesDirectory, withIntermediateDirectories: true) + try fileManager.createDirectory(at: sessionsDirectory, withIntermediateDirectories: true) + try fileManager.createDirectory(at: sessionDirectory, withIntermediateDirectories: true) + } } -actor OpenVSCodeProvisioner { - private let processRunner: any ProcessRunnerProtocol - private let fileManager: FileManager - private var installTask: Task? +enum VSCodeTileRuntimeState: Equatable { + case idle + case provisioning + case downloading + case extracting + case starting + case live(urlString: String) + case failed(message: String, logPath: String?) + + var displayMessage: String { + switch self { + case .idle: + "Ready" + case .provisioning: + "Preparing VS Code runtime..." + case .downloading: + "Downloading VS Code runtime..." + case .extracting: + "Installing VS Code runtime..." + case .starting: + "Starting VS Code..." + case .live: + "Live" + case let .failed(message, _): + message + } + } +} - init(processRunner: any ProcessRunnerProtocol = ProcessRunner(), fileManager: FileManager = .default) { - self.processRunner = processRunner - self.fileManager = fileManager +enum VSCodeRuntimeError: LocalizedError { + case unsupportedPlatform(String) + case invalidDownloadURL(String) + case downloadFailed(String) + case checksumMismatch(expected: String, actual: String) + case missingExecutable(String) + case commandFailed(command: String, code: Int32, stderr: String?) + case startupTimeout + case processExitedBeforeReady + case cancelled + + var errorDescription: String? { + switch self { + case let .unsupportedPlatform(platform): + return "VS Code runtime is not available for platform: \(platform)" + case let .invalidDownloadURL(raw): + return "Invalid runtime download URL: \(raw)" + case let .downloadFailed(description): + return "Runtime download failed: \(description)" + case let .checksumMismatch(expected, actual): + return "Downloaded runtime checksum mismatch. Expected \(expected), got \(actual)." + case let .missingExecutable(path): + return "Runtime executable missing: \(path)" + case let .commandFailed(command, code, stderr): + if let stderr, !stderr.isEmpty { + return "Command failed (\(code)): \(command)\n\(stderr)" + } + return "Command failed (\(code)): \(command)" + case .startupTimeout: + return "VS Code did not become ready in time." + case .processExitedBeforeReady: + return "VS Code process exited before it became ready." + case .cancelled: + return "Operation cancelled." } + } +} - func ensureRuntimeInstalled( - manifest: VSCodeBuildManifest, - paths: VSCodeRuntimePaths - ) async throws -> URL { - if let existing = try? reusableRuntimeIfAvailable(manifest: manifest, paths: paths) { - return existing - } +private struct VSCodeInstallRecord: Codable { + let runtimeName: String + let version: String + let platform: String + let sha256: String + let runtimeDirectoryName: String + let executableRelativePath: String + let installedAt: Date +} - if let existingTask = installTask { - return try await existingTask.value - } +actor OpenVSCodeProvisioner { + private let processRunner: any ProcessRunnerProtocol + private let fileManager: FileManager + private var installTask: Task? + + init(processRunner: any ProcessRunnerProtocol = ProcessRunner(), fileManager: FileManager = .default) { + self.processRunner = processRunner + self.fileManager = fileManager + } + + func ensureRuntimeInstalled( + manifest: VSCodeBuildManifest, + paths: VSCodeRuntimePaths + ) async throws -> URL { + if let existing = try? reusableRuntimeIfAvailable(manifest: manifest, paths: paths) { + return existing + } - let task = Task { [weak self] () -> URL in - guard let self else { throw VSCodeRuntimeError.cancelled } - return try await self.performInstall(manifest: manifest, paths: paths) - } + if let existingTask = installTask { + return try await existingTask.value + } - installTask = task - do { - let installed = try await task.value - installTask = nil - return installed - } catch { - installTask = nil - throw error - } + let task = Task { [weak self] () -> URL in + guard let self else { throw VSCodeRuntimeError.cancelled } + return try await performInstall(manifest: manifest, paths: paths) } - private func reusableRuntimeIfAvailable(manifest: VSCodeBuildManifest, paths: VSCodeRuntimePaths) throws -> URL { - guard fileManager.fileExists(atPath: paths.runtimeInstallRecordPath.path) else { - throw VSCodeRuntimeError.missingExecutable(paths.runtimeInstallRecordPath.path) - } + installTask = task + do { + let installed = try await task.value + installTask = nil + return installed + } catch { + installTask = nil + throw error + } + } - let data = try Data(contentsOf: paths.runtimeInstallRecordPath) - let record = try JSONDecoder().decode(VSCodeInstallRecord.self, from: data) - let currentPlatform = VSCodeBuildManifest.currentPlatformIdentifier() - - guard record.runtimeName == manifest.runtimeName, - record.version == manifest.version, - record.platform == currentPlatform, - let artifact = manifest.artifact(forCurrentPlatform: currentPlatform), - record.sha256.lowercased() == artifact.sha256.lowercased() - else { - throw VSCodeRuntimeError.missingExecutable("runtime manifest mismatch") - } + private func reusableRuntimeIfAvailable(manifest: VSCodeBuildManifest, paths: VSCodeRuntimePaths) throws -> URL { + guard fileManager.fileExists(atPath: paths.runtimeInstallRecordPath.path) else { + throw VSCodeRuntimeError.missingExecutable(paths.runtimeInstallRecordPath.path) + } - let runtimeDirectory = paths.runtimeVersionsDirectory.appendingPathComponent(record.runtimeDirectoryName, isDirectory: true) - let executableURL = runtimeDirectory.appendingPathComponent(record.executableRelativePath, isDirectory: false) - guard fileManager.isExecutableFile(atPath: executableURL.path) else { - throw VSCodeRuntimeError.missingExecutable(executableURL.path) - } + let data = try Data(contentsOf: paths.runtimeInstallRecordPath) + let record = try JSONDecoder().decode(VSCodeInstallRecord.self, from: data) + let currentPlatform = VSCodeBuildManifest.currentPlatformIdentifier() - return runtimeDirectory + guard record.runtimeName == manifest.runtimeName, + record.version == manifest.version, + record.platform == currentPlatform, + let artifact = manifest.artifact(forCurrentPlatform: currentPlatform) + else { + throw VSCodeRuntimeError.missingExecutable("runtime manifest mismatch") } - private func performInstall( - manifest: VSCodeBuildManifest, - paths: VSCodeRuntimePaths - ) async throws -> URL { - try paths.ensureBaseDirectories(fileManager: fileManager) - - let currentPlatform = VSCodeBuildManifest.currentPlatformIdentifier() - guard let artifact = manifest.artifact(forCurrentPlatform: currentPlatform) else { - throw VSCodeRuntimeError.unsupportedPlatform(currentPlatform) - } + let expectedSHA = artifact.sha256.lowercased() + if !expectedSHA.isEmpty, record.sha256.lowercased() != expectedSHA { + throw VSCodeRuntimeError.missingExecutable("runtime checksum mismatch") + } - guard let downloadURL = URL(string: artifact.downloadURL) else { - throw VSCodeRuntimeError.invalidDownloadURL(artifact.downloadURL) - } + let runtimeDirectory = paths.runtimeVersionsDirectory.appendingPathComponent(record.runtimeDirectoryName, isDirectory: true) + let executableURL = runtimeDirectory.appendingPathComponent(record.executableRelativePath, isDirectory: false) + guard fileManager.isExecutableFile(atPath: executableURL.path) else { + throw VSCodeRuntimeError.missingExecutable(executableURL.path) + } - appendProvisionLog(paths: paths, line: "== provision start \(Date())") - appendProvisionLog(paths: paths, line: "platform=\(currentPlatform) url=\(downloadURL.absoluteString)") + return runtimeDirectory + } - let archiveURL = paths.downloadsDirectory.appendingPathComponent("\(artifact.extractDirectoryName).tar.gz", isDirectory: false) - let extractTarget = paths.runtimeVersionsDirectory - let runtimeDirectory = extractTarget.appendingPathComponent(artifact.extractDirectoryName, isDirectory: true) + private func performInstall( + manifest: VSCodeBuildManifest, + paths: VSCodeRuntimePaths + ) async throws -> URL { + try paths.ensureBaseDirectories(fileManager: fileManager) - if fileManager.fileExists(atPath: archiveURL.path) { - try? fileManager.removeItem(at: archiveURL) - } + let currentPlatform = VSCodeBuildManifest.currentPlatformIdentifier() + guard let artifact = manifest.artifact(forCurrentPlatform: currentPlatform) else { + throw VSCodeRuntimeError.unsupportedPlatform(currentPlatform) + } - do { - let (temporaryURL, response) = try await URLSession.shared.download(from: downloadURL) - guard let status = (response as? HTTPURLResponse)?.statusCode, (200..<300).contains(status) else { - throw VSCodeRuntimeError.downloadFailed("non-2xx response") - } - try fileManager.moveItem(at: temporaryURL, to: archiveURL) - } catch { - throw VSCodeRuntimeError.downloadFailed(error.localizedDescription) - } + guard let downloadURL = URL(string: artifact.downloadURL) else { + throw VSCodeRuntimeError.invalidDownloadURL(artifact.downloadURL) + } - let actualSHA = try sha256(forFileAt: archiveURL) - let expectedSHA = artifact.sha256.lowercased() - guard actualSHA == expectedSHA else { - try? fileManager.removeItem(at: archiveURL) - throw VSCodeRuntimeError.checksumMismatch(expected: expectedSHA, actual: actualSHA) - } + appendProvisionLog(paths: paths, line: "== provision start \(Date())") + appendProvisionLog(paths: paths, line: "platform=\(currentPlatform) url=\(downloadURL.absoluteString)") - if fileManager.fileExists(atPath: runtimeDirectory.path) { - try? fileManager.removeItem(at: runtimeDirectory) - } + let archiveURL = paths.downloadsDirectory.appendingPathComponent("\(artifact.extractDirectoryName).tar.gz", isDirectory: false) + let extractTarget = paths.runtimeVersionsDirectory + let runtimeDirectory = extractTarget.appendingPathComponent(artifact.extractDirectoryName, isDirectory: true) - try await runChecked( - executable: "/usr/bin/tar", - arguments: ["-xzf", archiveURL.path, "-C", extractTarget.path], - currentDirectory: extractTarget.path, - paths: paths - ) + if fileManager.fileExists(atPath: archiveURL.path) { + try? fileManager.removeItem(at: archiveURL) + } - let executableURL = runtimeDirectory.appendingPathComponent(manifest.executableRelativePath, isDirectory: false) - guard fileManager.isExecutableFile(atPath: executableURL.path) else { - throw VSCodeRuntimeError.missingExecutable(executableURL.path) - } + do { + let (temporaryURL, response) = try await URLSession.shared.download(from: downloadURL) + guard let status = (response as? HTTPURLResponse)?.statusCode, (200 ..< 300).contains(status) else { + throw VSCodeRuntimeError.downloadFailed("non-2xx response") + } + try fileManager.moveItem(at: temporaryURL, to: archiveURL) + } catch { + throw VSCodeRuntimeError.downloadFailed(error.localizedDescription) + } - let record = VSCodeInstallRecord( - runtimeName: manifest.runtimeName, - version: manifest.version, - platform: currentPlatform, - sha256: expectedSHA, - runtimeDirectoryName: artifact.extractDirectoryName, - executableRelativePath: manifest.executableRelativePath, - installedAt: Date() - ) - let recordData = try JSONEncoder().encode(record) - try recordData.write(to: paths.runtimeInstallRecordPath, options: .atomic) + let actualSHA = try sha256(forFileAt: archiveURL) + let expectedSHA = artifact.sha256.lowercased() + if !expectedSHA.isEmpty, actualSHA != expectedSHA { + try? fileManager.removeItem(at: archiveURL) + throw VSCodeRuntimeError.checksumMismatch(expected: expectedSHA, actual: actualSHA) + } - appendProvisionLog(paths: paths, line: "== provision complete \(Date())") - return runtimeDirectory + if fileManager.fileExists(atPath: runtimeDirectory.path) { + try? fileManager.removeItem(at: runtimeDirectory) } - private func runChecked( - executable: String, - arguments: [String], - currentDirectory: String?, - paths: VSCodeRuntimePaths - ) async throws { - let command = ([executable] + arguments).joined(separator: " ") - appendProvisionLog(paths: paths, line: "$ \(command)") + try await runChecked( + executable: "/usr/bin/tar", + arguments: ["-xzf", archiveURL.path, "-C", extractTarget.path], + currentDirectory: extractTarget.path, + paths: paths + ) - let result = try await processRunner.run( - executable: executable, - arguments: arguments, - currentDirectory: currentDirectory - ) + let executableURL = runtimeDirectory.appendingPathComponent(manifest.executableRelativePath, isDirectory: false) + guard fileManager.isExecutableFile(atPath: executableURL.path) else { + throw VSCodeRuntimeError.missingExecutable(executableURL.path) + } - if !result.stdout.isEmpty { - appendProvisionLog(paths: paths, line: result.stdout) - } - if !result.stderr.isEmpty { - appendProvisionLog(paths: paths, line: result.stderr) - } + let record = VSCodeInstallRecord( + runtimeName: manifest.runtimeName, + version: manifest.version, + platform: currentPlatform, + sha256: expectedSHA.isEmpty ? actualSHA : expectedSHA, + runtimeDirectoryName: artifact.extractDirectoryName, + executableRelativePath: manifest.executableRelativePath, + installedAt: Date() + ) + let recordData = try JSONEncoder().encode(record) + try recordData.write(to: paths.runtimeInstallRecordPath, options: .atomic) + + appendProvisionLog(paths: paths, line: "== provision complete \(Date())") + return runtimeDirectory + } + + private func runChecked( + executable: String, + arguments: [String], + currentDirectory: String?, + paths: VSCodeRuntimePaths + ) async throws { + let command = ([executable] + arguments).joined(separator: " ") + appendProvisionLog(paths: paths, line: "$ \(command)") + + let result = try await processRunner.run( + executable: executable, + arguments: arguments, + currentDirectory: currentDirectory + ) - guard result.exitCode == 0 else { - throw VSCodeRuntimeError.commandFailed( - command: command, - code: result.exitCode, - stderr: result.stderr.isEmpty ? nil : result.stderr - ) - } + if !result.stdout.isEmpty { + appendProvisionLog(paths: paths, line: result.stdout) + } + if !result.stderr.isEmpty { + appendProvisionLog(paths: paths, line: result.stderr) } - private func appendProvisionLog(paths: VSCodeRuntimePaths, line: String) { - let timestamp = ISO8601DateFormatter().string(from: Date()) - let logLine = "[\(timestamp)] \(line)\n" - - do { - try fileManager.createDirectory(at: paths.provisionLogPath.deletingLastPathComponent(), withIntermediateDirectories: true) - if !fileManager.fileExists(atPath: paths.provisionLogPath.path) { - try logLine.write(to: paths.provisionLogPath, atomically: true, encoding: .utf8) - return - } - let handle = try FileHandle(forWritingTo: paths.provisionLogPath) - defer { try? handle.close() } - try handle.seekToEnd() - if let data = logLine.data(using: .utf8) { - try handle.write(contentsOf: data) - } - } catch { - Logger.error("Failed to append VS Code provision log: \(error.localizedDescription)") - } + guard result.exitCode == 0 else { + throw VSCodeRuntimeError.commandFailed( + command: command, + code: result.exitCode, + stderr: result.stderr.isEmpty ? nil : result.stderr + ) } + } + + private func appendProvisionLog(paths: VSCodeRuntimePaths, line: String) { + let timestamp = ISO8601DateFormatter().string(from: Date()) + let logLine = "[\(timestamp)] \(line)\n" + + do { + try fileManager.createDirectory(at: paths.provisionLogPath.deletingLastPathComponent(), withIntermediateDirectories: true) + if !fileManager.fileExists(atPath: paths.provisionLogPath.path) { + try logLine.write(to: paths.provisionLogPath, atomically: true, encoding: .utf8) + return + } + let handle = try FileHandle(forWritingTo: paths.provisionLogPath) + defer { try? handle.close() } + try handle.seekToEnd() + if let data = logLine.data(using: .utf8) { + try handle.write(contentsOf: data) + } + } catch { + Logger.error("Failed to append VS Code provision log: \(error.localizedDescription)") + } + } - private func sha256(forFileAt url: URL) throws -> String { - let data = try Data(contentsOf: url) - var digest = [UInt8](repeating: 0, count: Int(CC_SHA256_DIGEST_LENGTH)) - data.withUnsafeBytes { bytes in - _ = CC_SHA256(bytes.baseAddress, CC_LONG(data.count), &digest) - } - return digest.map { String(format: "%02x", $0) }.joined() + private func sha256(forFileAt url: URL) throws -> String { + let data = try Data(contentsOf: url) + var digest = [UInt8](repeating: 0, count: Int(CC_SHA256_DIGEST_LENGTH)) + data.withUnsafeBytes { bytes in + _ = CC_SHA256(bytes.baseAddress, CC_LONG(data.count), &digest) } + return digest.map { String(format: "%02x", $0) }.joined() + } } @MainActor final class VSCodeStateSnapshotManager { - private let fileManager: FileManager - - init(fileManager: FileManager = .default) { - self.fileManager = fileManager - } + private let fileManager: FileManager + + init(fileManager: FileManager = .default) { + self.fileManager = fileManager + } + + func prepareSessionState( + paths: VSCodeRuntimePaths, + profileSeedPath: String + ) throws -> (userDataDir: URL, extensionsDir: URL) { + try paths.ensureBaseDirectories(fileManager: fileManager) + + let profileID = stableProfileID(seedPath: profileSeedPath) + let profileDirectory = paths.profilesDirectory.appendingPathComponent(profileID, isDirectory: true) + let profileUserDataDirectory = profileDirectory.appendingPathComponent("user-data", isDirectory: true) + let profileExtensionsDirectory = profileDirectory.appendingPathComponent("extensions", isDirectory: true) + + try fileManager.createDirectory(at: profileDirectory, withIntermediateDirectories: true) + try migrateLegacySessionStateIfNeeded( + from: paths.sessionUserDataDirectory, + to: profileUserDataDirectory + ) + try migrateLegacySessionStateIfNeeded( + from: paths.sessionExtensionsDirectory, + to: profileExtensionsDirectory + ) - func prepareSessionState( - paths: VSCodeRuntimePaths, - profileSeedPath: String - ) throws -> (userDataDir: URL, extensionsDir: URL) { - try paths.ensureBaseDirectories(fileManager: fileManager) + try fileManager.createDirectory(at: profileUserDataDirectory, withIntermediateDirectories: true) + try fileManager.createDirectory(at: profileExtensionsDirectory, withIntermediateDirectories: true) - let profileID = stableProfileID(seedPath: profileSeedPath) - let profileDirectory = paths.profilesDirectory.appendingPathComponent(profileID, isDirectory: true) - let profileUserDataDirectory = profileDirectory.appendingPathComponent("user-data", isDirectory: true) - let profileExtensionsDirectory = profileDirectory.appendingPathComponent("extensions", isDirectory: true) + let userDirectory = profileUserDataDirectory.appendingPathComponent("User", isDirectory: true) + try fileManager.createDirectory(at: userDirectory, withIntermediateDirectories: true) - try fileManager.createDirectory(at: profileDirectory, withIntermediateDirectories: true) - try migrateLegacySessionStateIfNeeded( - from: paths.sessionUserDataDirectory, - to: profileUserDataDirectory - ) - try migrateLegacySessionStateIfNeeded( - from: paths.sessionExtensionsDirectory, - to: profileExtensionsDirectory - ) + let settingsPath = userDirectory.appendingPathComponent("settings.json", isDirectory: false) + try upsertSessionSettings(at: settingsPath) - try fileManager.createDirectory(at: profileUserDataDirectory, withIntermediateDirectories: true) - try fileManager.createDirectory(at: profileExtensionsDirectory, withIntermediateDirectories: true) + return (profileUserDataDirectory, profileExtensionsDirectory) + } - let userDirectory = profileUserDataDirectory.appendingPathComponent("User", isDirectory: true) - try fileManager.createDirectory(at: userDirectory, withIntermediateDirectories: true) + func removeSessionState(paths: VSCodeRuntimePaths) { + // Keep profile-backed user data and extensions so trust/theme/zoom persist. + try? fileManager.removeItem(at: paths.sessionDirectory) + } - let settingsPath = userDirectory.appendingPathComponent("settings.json", isDirectory: false) - try upsertSessionSettings(at: settingsPath) - - return (profileUserDataDirectory, profileExtensionsDirectory) + private func migrateLegacySessionStateIfNeeded(from legacyPath: URL, to profilePath: URL) throws { + guard fileManager.fileExists(atPath: legacyPath.path), + !fileManager.fileExists(atPath: profilePath.path) + else { + return } - - func removeSessionState(paths: VSCodeRuntimePaths) { - // Keep profile-backed user data and extensions so trust/theme/zoom persist. - try? fileManager.removeItem(at: paths.sessionDirectory) + try fileManager.copyItem(at: legacyPath, to: profilePath) + } + + private func stableProfileID(seedPath: String) -> String { + let expanded = NSString(string: seedPath).expandingTildeInPath + let canonicalPath = URL(fileURLWithPath: expanded, isDirectory: true).standardizedFileURL.path + let hash = sha256Hex(canonicalPath).prefix(16) + let name = sanitizeFilenameComponent(URL(fileURLWithPath: canonicalPath).lastPathComponent) + if name.isEmpty { + return String(hash) } - - private func migrateLegacySessionStateIfNeeded(from legacyPath: URL, to profilePath: URL) throws { - guard fileManager.fileExists(atPath: legacyPath.path), - !fileManager.fileExists(atPath: profilePath.path) - else { - return - } - try fileManager.copyItem(at: legacyPath, to: profilePath) + return "\(name)-\(hash)" + } + + private func sha256Hex(_ value: String) -> String { + let data = Data(value.utf8) + var digest = [UInt8](repeating: 0, count: Int(CC_SHA256_DIGEST_LENGTH)) + data.withUnsafeBytes { bytes in + _ = CC_SHA256(bytes.baseAddress, CC_LONG(data.count), &digest) } + return digest.map { String(format: "%02x", $0) }.joined() + } - private func stableProfileID(seedPath: String) -> String { - let expanded = NSString(string: seedPath).expandingTildeInPath - let canonicalPath = URL(fileURLWithPath: expanded, isDirectory: true).standardizedFileURL.path - let hash = sha256Hex(canonicalPath).prefix(16) - let name = sanitizeFilenameComponent(URL(fileURLWithPath: canonicalPath).lastPathComponent) - if name.isEmpty { - return String(hash) - } - return "\(name)-\(hash)" + private func sanitizeFilenameComponent(_ value: String) -> String { + let allowed = CharacterSet.alphanumerics.union(CharacterSet(charactersIn: "-_.")) + let mappedScalars = value.unicodeScalars.map { scalar -> UnicodeScalar in + allowed.contains(scalar) ? scalar : "-" } - - private func sha256Hex(_ value: String) -> String { - let data = Data(value.utf8) - var digest = [UInt8](repeating: 0, count: Int(CC_SHA256_DIGEST_LENGTH)) - data.withUnsafeBytes { bytes in - _ = CC_SHA256(bytes.baseAddress, CC_LONG(data.count), &digest) - } - return digest.map { String(format: "%02x", $0) }.joined() + let mapped = String(String.UnicodeScalarView(mappedScalars)) + let trimmed = mapped.trimmingCharacters(in: CharacterSet(charactersIn: "-_.")) + return trimmed.isEmpty ? "workspace" : trimmed + } + + private func upsertSessionSettings(at settingsPath: URL) throws { + var root = try loadSettingsJSON(from: settingsPath) + if root["telemetry.telemetryLevel"] == nil { + root["telemetry.telemetryLevel"] = "off" } - - private func sanitizeFilenameComponent(_ value: String) -> String { - let allowed = CharacterSet.alphanumerics.union(CharacterSet(charactersIn: "-_.")) - let mappedScalars = value.unicodeScalars.map { scalar -> UnicodeScalar in - allowed.contains(scalar) ? scalar : "-" - } - let mapped = String(String.UnicodeScalarView(mappedScalars)) - let trimmed = mapped.trimmingCharacters(in: CharacterSet(charactersIn: "-_.")) - return trimmed.isEmpty ? "workspace" : trimmed + if root["extensions.autoCheckUpdates"] == nil { + root["extensions.autoCheckUpdates"] = false } - - private func upsertSessionSettings(at settingsPath: URL) throws { - var root = try loadSettingsJSON(from: settingsPath) - if root["telemetry.telemetryLevel"] == nil { - root["telemetry.telemetryLevel"] = "off" - } - if root["extensions.autoCheckUpdates"] == nil { - root["extensions.autoCheckUpdates"] = false - } - if root["extensions.autoUpdate"] == nil { - root["extensions.autoUpdate"] = false - } - if root["update.mode"] == nil { - root["update.mode"] = "none" - } - if root["security.workspace.trust.enabled"] == nil { - root["security.workspace.trust.enabled"] = false - } - // OpenVSCode + recent Python extension can fail to start Jedi LSP due to - // position-encoding incompatibilities; default to no language server. - if root["python.languageServer"] == nil { - root["python.languageServer"] = "None" - } - - // Force disable web "debug by link" launch flow in embedded VS Code. - root["debug.javascript.debugByLinkOptions"] = "off" - - let data = try JSONSerialization.data(withJSONObject: root, options: [.prettyPrinted, .sortedKeys]) - try data.write(to: settingsPath, options: .atomic) + if root["extensions.autoUpdate"] == nil { + root["extensions.autoUpdate"] = false + } + if root["update.mode"] == nil { + root["update.mode"] = "none" + } + if root["security.workspace.trust.enabled"] == nil { + root["security.workspace.trust.enabled"] = false + } + // OpenVSCode + recent Python extension can fail to start Jedi LSP due to + // position-encoding incompatibilities; default to no language server. + if root["python.languageServer"] == nil { + root["python.languageServer"] = "None" } - private func loadSettingsJSON(from settingsPath: URL) throws -> [String: Any] { - guard fileManager.fileExists(atPath: settingsPath.path) else { - return [:] - } + // Force disable web "debug by link" launch flow in embedded VS Code. + root["debug.javascript.debugByLinkOptions"] = "off" - let raw = try String(contentsOf: settingsPath, encoding: .utf8) - let cleaned = stripJSONComments(from: raw) - guard let data = cleaned.data(using: .utf8) else { - return [:] - } + let data = try JSONSerialization.data(withJSONObject: root, options: [.prettyPrinted, .sortedKeys]) + try data.write(to: settingsPath, options: .atomic) + } - guard !data.isEmpty else { - return [:] - } + private func loadSettingsJSON(from settingsPath: URL) throws -> [String: Any] { + guard fileManager.fileExists(atPath: settingsPath.path) else { + return [:] + } - do { - let json = try JSONSerialization.jsonObject(with: data, options: []) - return json as? [String: Any] ?? [:] - } catch { - return [:] - } + let raw = try String(contentsOf: settingsPath, encoding: .utf8) + let cleaned = stripJSONComments(from: raw) + guard let data = cleaned.data(using: .utf8) else { + return [:] } - private func stripJSONComments(from text: String) -> String { - var output = String() - var index = text.startIndex - var isInString = false - var isEscaped = false - - while index < text.endIndex { - let character = text[index] - - if isInString { - output.append(character) - if isEscaped { - isEscaped = false - } else if character == "\\" { - isEscaped = true - } else if character == "\"" { - isInString = false - } - index = text.index(after: index) - continue - } + guard !data.isEmpty else { + return [:] + } - if character == "\"" { - isInString = true - output.append(character) - index = text.index(after: index) - continue + do { + let json = try JSONSerialization.jsonObject(with: data, options: []) + return json as? [String: Any] ?? [:] + } catch { + return [:] + } + } + + private func stripJSONComments(from text: String) -> String { + var output = String() + var index = text.startIndex + var isInString = false + var isEscaped = false + + while index < text.endIndex { + let character = text[index] + + if isInString { + output.append(character) + if isEscaped { + isEscaped = false + } else if character == "\\" { + isEscaped = true + } else if character == "\"" { + isInString = false + } + index = text.index(after: index) + continue + } + + if character == "\"" { + isInString = true + output.append(character) + index = text.index(after: index) + continue + } + + if character == "/" { + let next = text.index(after: index) + if next < text.endIndex { + let nextChar = text[next] + if nextChar == "/" { + index = text.index(after: next) + while index < text.endIndex, text[index] != "\n" { + index = text.index(after: index) } - - if character == "/" { - let next = text.index(after: index) - if next < text.endIndex { - let nextChar = text[next] - if nextChar == "/" { - index = text.index(after: next) - while index < text.endIndex, text[index] != "\n" { - index = text.index(after: index) - } - continue - } - if nextChar == "*" { - index = text.index(after: next) - while index < text.endIndex { - let candidateEnd = text.index(after: index) - if text[index] == "*", candidateEnd < text.endIndex, text[candidateEnd] == "/" { - index = text.index(after: candidateEnd) - break - } - index = text.index(after: index) - } - continue - } - } + continue + } + if nextChar == "*" { + index = text.index(after: next) + while index < text.endIndex { + let candidateEnd = text.index(after: index) + if text[index] == "*", candidateEnd < text.endIndex, text[candidateEnd] == "/" { + index = text.index(after: candidateEnd) + break + } + index = text.index(after: index) } - - output.append(character) - index = text.index(after: index) + continue + } } + } - return output + output.append(character) + index = text.index(after: index) } + + return output + } } @MainActor final class VSCodeTileController: ObservableObject, NiriAppTileRuntimeControlling { - @Published private(set) var state: VSCodeTileRuntimeState = .idle - - let sessionID: UUID - let itemID: UUID - let webView: WKWebView - - private let launchDirectoryProvider: () -> String? - private let profileSeedPathProvider: () -> String? - private let provisioner: OpenVSCodeProvisioner - private let snapshotManager: VSCodeStateSnapshotManager - private let processRunner: any ProcessRunnerProtocol - private let manifestProvider: () -> VSCodeBuildManifest - private let paths: VSCodeRuntimePaths - private let userDefaults: UserDefaults - private let zoomDefaultsKey: String - - private let readinessIntervalNanoseconds: UInt64 = 250_000_000 - private let readinessTimeoutSeconds: TimeInterval = 20 - private let maxAutomaticRestarts = 3 - private let requiredExtensionIDs = ["ms-python.python"] - private let minimumZoom: CGFloat = 0.5 - private let maximumZoom: CGFloat = 3.0 - private let maxWebContentReloadAttempts = 2 - - private var startTask: Task? - private var process: Process? - private var stdoutPipe: Pipe? - private var stderrPipe: Pipe? - private var logHandle: FileHandle? - private var webViewDelegate: EmbeddedWebViewDelegate? - private var webContentTerminationCount = 0 - private var userStopped = false - private var automaticRestartCount = 0 - - init( - sessionID: UUID, - itemID: UUID, - launchDirectoryProvider: @escaping () -> String?, - profileSeedPathProvider: @escaping () -> String?, - provisioner: OpenVSCodeProvisioner, - snapshotManager: VSCodeStateSnapshotManager, - processRunner: any ProcessRunnerProtocol = ProcessRunner(), - manifestProvider: @escaping () -> VSCodeBuildManifest = { VSCodeBuildManifest.loadFromBundle() }, - userDefaults: UserDefaults = .standard - ) { - self.sessionID = sessionID - self.itemID = itemID - self.launchDirectoryProvider = launchDirectoryProvider - self.profileSeedPathProvider = profileSeedPathProvider - self.provisioner = provisioner - self.snapshotManager = snapshotManager - self.processRunner = processRunner - self.manifestProvider = manifestProvider - self.paths = VSCodeRuntimePaths(sessionID: sessionID) - self.userDefaults = userDefaults - - let configuration = WKWebViewConfiguration() - configuration.defaultWebpagePreferences.allowsContentJavaScript = true - configuration.websiteDataStore = .default() - webView = WKWebView(frame: .zero, configuration: configuration) - - let zoomSeedPath = profileSeedPathProvider() ?? FileManager.default.homeDirectoryForCurrentUser.path - zoomDefaultsKey = Self.zoomDefaultsKey(for: zoomSeedPath) - webView.pageZoom = loadPersistedZoom() - - let delegate = EmbeddedWebViewDelegate(logLabel: "VSCode[\(sessionID.uuidString)]") { [weak self] view in - self?.handleWebContentTermination(view) - } - webView.navigationDelegate = delegate - webViewDelegate = delegate + @Published private(set) var state: VSCodeTileRuntimeState = .idle + + let sessionID: UUID + let itemID: UUID + let webView: WKWebView + + private let launchDirectoryProvider: () -> String? + private let profileSeedPathProvider: () -> String? + private let provisioner: OpenVSCodeProvisioner + private let latestManifestResolver: VSCodeLatestManifestResolver + private let snapshotManager: VSCodeStateSnapshotManager + private let processRunner: any ProcessRunnerProtocol + private let manifestProvider: () -> VSCodeBuildManifest + private let paths: VSCodeRuntimePaths + private let userDefaults: UserDefaults + private let zoomDefaultsKey: String + + private let readinessIntervalNanoseconds: UInt64 = 250_000_000 + private let readinessTimeoutSeconds: TimeInterval = 20 + private let maxAutomaticRestarts = 3 + private let requiredExtensionIDs = ["ms-python.python"] + private let minimumZoom: CGFloat = 0.5 + private let maximumZoom: CGFloat = 3.0 + private let maxWebContentReloadAttempts = 2 + + private var startTask: Task? + private var process: Process? + private var stdoutPipe: Pipe? + private var stderrPipe: Pipe? + private var logHandle: FileHandle? + private var webViewDelegate: EmbeddedWebViewDelegate? + private var webContentTerminationCount = 0 + private var userStopped = false + private var automaticRestartCount = 0 + + init( + sessionID: UUID, + itemID: UUID, + launchDirectoryProvider: @escaping () -> String?, + profileSeedPathProvider: @escaping () -> String?, + provisioner: OpenVSCodeProvisioner, + latestManifestResolver: VSCodeLatestManifestResolver = VSCodeLatestManifestResolver(), + snapshotManager: VSCodeStateSnapshotManager, + processRunner: any ProcessRunnerProtocol = ProcessRunner(), + manifestProvider: @escaping () -> VSCodeBuildManifest = { VSCodeBuildManifest.loadFromBundle() }, + userDefaults: UserDefaults = .standard + ) { + self.sessionID = sessionID + self.itemID = itemID + self.launchDirectoryProvider = launchDirectoryProvider + self.profileSeedPathProvider = profileSeedPathProvider + self.provisioner = provisioner + self.latestManifestResolver = latestManifestResolver + self.snapshotManager = snapshotManager + self.processRunner = processRunner + self.manifestProvider = manifestProvider + paths = VSCodeRuntimePaths(sessionID: sessionID) + self.userDefaults = userDefaults + + let configuration = WKWebViewConfiguration() + configuration.defaultWebpagePreferences.allowsContentJavaScript = true + configuration.websiteDataStore = .default() + webView = WKWebView(frame: .zero, configuration: configuration) + + let zoomSeedPath = profileSeedPathProvider() ?? FileManager.default.homeDirectoryForCurrentUser.path + zoomDefaultsKey = Self.zoomDefaultsKey(for: zoomSeedPath) + webView.pageZoom = loadPersistedZoom() + + let delegate = EmbeddedWebViewDelegate(logLabel: "VSCode[\(sessionID.uuidString)]") { [weak self] view in + self?.handleWebContentTermination(view) } - - func ensureStarted() { - guard startTask == nil else { return } - switch state { - case .provisioning, .downloading, .extracting, .starting, .live: - return - case .idle, .failed: - break - } - - userStopped = false - startTask = Task { [weak self] in - guard let self else { return } - await self.runStartupSequence() - self.startTask = nil - } + webView.navigationDelegate = delegate + webViewDelegate = delegate + } + + func ensureStarted() { + guard startTask == nil else { return } + switch state { + case .provisioning, .downloading, .extracting, .starting, .live: + return + case .idle, .failed: + break } - func retry() { - stop() - automaticRestartCount = 0 - state = .idle - ensureStarted() - } - - func stop() { - userStopped = true - startTask?.cancel() - startTask = nil - terminateProcess() - webContentTerminationCount = 0 - state = .idle - } - - func openLogsInFinder() { - let url = paths.runtimeLogPath - guard FileManager.default.fileExists(atPath: url.path) else { return } - NSWorkspace.shared.activateFileViewerSelecting([url]) - } - - var runtimeLogPath: String { - paths.runtimeLogPath.path - } - - @discardableResult - func adjustZoom(by delta: CGFloat) -> Bool { - let current = webView.pageZoom - let next = max(minimumZoom, min(maximumZoom, current + delta)) - webView.pageZoom = next - persistZoom(next) - return true - } - - private func runStartupSequence() async { - let manifest = manifestProvider() - - while !Task.isCancelled { - do { - try await startupAttempt(manifest: manifest) - automaticRestartCount = 0 - return - } catch { - if userStopped || Task.isCancelled { - return - } - - let description = (error as? LocalizedError)?.errorDescription ?? error.localizedDescription - appendRuntimeLog("startup attempt failed: \(description)") - - if !isRetryableStartupError(error) { - state = .failed(message: description, logPath: logPathForError(error)) - return - } - - guard automaticRestartCount < maxAutomaticRestarts else { - state = .failed(message: description, logPath: logPathForError(error)) - return - } - - automaticRestartCount += 1 - let backoff = min(pow(2, Double(automaticRestartCount - 1)) * 0.5, 10) - state = .starting - try? await Task.sleep(nanoseconds: UInt64(backoff * 1_000_000_000)) - } - } + userStopped = false + startTask = Task { [weak self] in + guard let self else { return } + await runStartupSequence() + startTask = nil } - - private func startupAttempt(manifest: VSCodeBuildManifest) async throws { - try paths.ensureBaseDirectories() - - state = .provisioning - let runtimeDirectory = try await provisioner.ensureRuntimeInstalled( - manifest: manifest, - paths: paths + } + + func retry() { + stop() + automaticRestartCount = 0 + state = .idle + ensureStarted() + } + + func stop() { + userStopped = true + startTask?.cancel() + startTask = nil + terminateProcess() + webContentTerminationCount = 0 + state = .idle + } + + func openLogsInFinder() { + let url = paths.runtimeLogPath + guard FileManager.default.fileExists(atPath: url.path) else { return } + NSWorkspace.shared.activateFileViewerSelecting([url]) + } + + var runtimeLogPath: String { + paths.runtimeLogPath.path + } + + @discardableResult + func adjustZoom(by delta: CGFloat) -> Bool { + let current = webView.pageZoom + let next = max(minimumZoom, min(maximumZoom, current + delta)) + webView.pageZoom = next + persistZoom(next) + return true + } + + private func runStartupSequence() async { + while !Task.isCancelled { + do { + let manifest = await latestManifestResolver.resolveLatestManifest( + fallback: manifestProvider() ) - + try await startupAttempt(manifest: manifest) + automaticRestartCount = 0 + return + } catch { if userStopped || Task.isCancelled { - throw VSCodeRuntimeError.cancelled + return } - let launchDirectory = launchDirectoryProvider() ?? FileManager.default.homeDirectoryForCurrentUser.path - let profileSeedPath = profileSeedPathProvider() ?? launchDirectory - let stateDirs = try snapshotManager.prepareSessionState( - paths: paths, - profileSeedPath: profileSeedPath - ) - let port = try reserveLoopbackPort() + let description = (error as? LocalizedError)?.errorDescription ?? error.localizedDescription + appendRuntimeLog("startup attempt failed: \(description)") - let executableURL = runtimeDirectory.appendingPathComponent(manifest.executableRelativePath, isDirectory: false) - guard FileManager.default.isExecutableFile(atPath: executableURL.path) else { - throw VSCodeRuntimeError.missingExecutable(executableURL.path) + if !isRetryableStartupError(error) { + state = .failed(message: description, logPath: logPathForError(error)) + return } - await ensureRequiredExtensionsInstalled( - executableURL: executableURL, - userDataDir: stateDirs.userDataDir, - extensionsDir: stateDirs.extensionsDir - ) + guard automaticRestartCount < maxAutomaticRestarts else { + state = .failed(message: description, logPath: logPathForError(error)) + return + } + automaticRestartCount += 1 + let backoff = min(pow(2, Double(automaticRestartCount - 1)) * 0.5, 10) state = .starting - try launchProcess( - executableURL: executableURL, - port: port, - userDataDir: stateDirs.userDataDir, - extensionsDir: stateDirs.extensionsDir, - launchDirectory: launchDirectory - ) + try? await Task.sleep(nanoseconds: UInt64(backoff * 1_000_000_000)) + } + } + } - let ready = await waitForServerReady(port: port) - guard ready else { - terminateProcess() - if userStopped || Task.isCancelled { - throw VSCodeRuntimeError.cancelled - } - if process?.isRunning == false { - throw VSCodeRuntimeError.processExitedBeforeReady - } - throw VSCodeRuntimeError.startupTimeout - } + private func startupAttempt(manifest: VSCodeBuildManifest) async throws { + try paths.ensureBaseDirectories() - if userStopped || Task.isCancelled { - terminateProcess() - throw VSCodeRuntimeError.cancelled - } + state = .provisioning + let runtimeDirectory = try await provisioner.ensureRuntimeInstalled( + manifest: manifest, + paths: paths + ) - let url = URL(string: "http://127.0.0.1:\(port)")! - webContentTerminationCount = 0 - webView.load(URLRequest(url: url)) - state = .live(urlString: url.absoluteString) - appendRuntimeLog("runtime live at \(url.absoluteString)") + if userStopped || Task.isCancelled { + throw VSCodeRuntimeError.cancelled } - private func reserveLoopbackPort() throws -> Int { - let socketFD = socket(AF_INET, SOCK_STREAM, 0) - guard socketFD >= 0 else { - throw VSCodeRuntimeError.startupTimeout - } - defer { close(socketFD) } + let launchDirectory = launchDirectoryProvider() ?? FileManager.default.homeDirectoryForCurrentUser.path + let profileSeedPath = profileSeedPathProvider() ?? launchDirectory + let stateDirs = try snapshotManager.prepareSessionState( + paths: paths, + profileSeedPath: profileSeedPath + ) + let port = try reserveLoopbackPort() - var address = sockaddr_in() - address.sin_len = UInt8(MemoryLayout.size) - address.sin_family = sa_family_t(AF_INET) - address.sin_port = in_port_t(0).bigEndian - address.sin_addr = in_addr(s_addr: inet_addr("127.0.0.1")) + let executableURL = runtimeDirectory.appendingPathComponent(manifest.executableRelativePath, isDirectory: false) + guard FileManager.default.isExecutableFile(atPath: executableURL.path) else { + throw VSCodeRuntimeError.missingExecutable(executableURL.path) + } - let bindResult = withUnsafePointer(to: &address) { - $0.withMemoryRebound(to: sockaddr.self, capacity: 1) { - bind(socketFD, $0, socklen_t(MemoryLayout.size)) - } - } - guard bindResult == 0 else { - throw VSCodeRuntimeError.startupTimeout - } + await ensureRequiredExtensionsInstalled( + executableURL: executableURL, + userDataDir: stateDirs.userDataDir, + extensionsDir: stateDirs.extensionsDir + ) - var assignedAddress = sockaddr_in() - var length = socklen_t(MemoryLayout.size) + state = .starting + try launchProcess( + executableURL: executableURL, + port: port, + userDataDir: stateDirs.userDataDir, + extensionsDir: stateDirs.extensionsDir, + launchDirectory: launchDirectory + ) - let nameResult = withUnsafeMutablePointer(to: &assignedAddress) { - $0.withMemoryRebound(to: sockaddr.self, capacity: 1) { - getsockname(socketFD, $0, &length) - } - } - guard nameResult == 0 else { - throw VSCodeRuntimeError.startupTimeout - } + let ready = await waitForServerReady(port: port) + guard ready else { + terminateProcess() + if userStopped || Task.isCancelled { + throw VSCodeRuntimeError.cancelled + } + if process?.isRunning == false { + throw VSCodeRuntimeError.processExitedBeforeReady + } + throw VSCodeRuntimeError.startupTimeout + } - return Int(UInt16(bigEndian: assignedAddress.sin_port)) + if userStopped || Task.isCancelled { + terminateProcess() + throw VSCodeRuntimeError.cancelled } - private func launchProcess( - executableURL: URL, - port: Int, - userDataDir: URL, - extensionsDir: URL, - launchDirectory: String - ) throws { - terminateProcess() + let url = URL(string: "http://127.0.0.1:\(port)")! + webContentTerminationCount = 0 + webView.load(URLRequest(url: url)) + state = .live(urlString: url.absoluteString) + appendRuntimeLog("runtime live at \(url.absoluteString)") + } + + private func reserveLoopbackPort() throws -> Int { + let socketFD = socket(AF_INET, SOCK_STREAM, 0) + guard socketFD >= 0 else { + throw VSCodeRuntimeError.startupTimeout + } + defer { close(socketFD) } + + var address = sockaddr_in() + address.sin_len = UInt8(MemoryLayout.size) + address.sin_family = sa_family_t(AF_INET) + address.sin_port = in_port_t(0).bigEndian + address.sin_addr = in_addr(s_addr: inet_addr("127.0.0.1")) + + let bindResult = withUnsafePointer(to: &address) { + $0.withMemoryRebound(to: sockaddr.self, capacity: 1) { + bind(socketFD, $0, socklen_t(MemoryLayout.size)) + } + } + guard bindResult == 0 else { + throw VSCodeRuntimeError.startupTimeout + } - try FileManager.default.createDirectory(at: paths.runtimeLogPath.deletingLastPathComponent(), withIntermediateDirectories: true) - if !FileManager.default.fileExists(atPath: paths.runtimeLogPath.path) { - FileManager.default.createFile(atPath: paths.runtimeLogPath.path, contents: nil) - } + var assignedAddress = sockaddr_in() + var length = socklen_t(MemoryLayout.size) - let handle = try FileHandle(forWritingTo: paths.runtimeLogPath) - try handle.seekToEnd() - logHandle = handle + let nameResult = withUnsafeMutablePointer(to: &assignedAddress) { + $0.withMemoryRebound(to: sockaddr.self, capacity: 1) { + getsockname(socketFD, $0, &length) + } + } + guard nameResult == 0 else { + throw VSCodeRuntimeError.startupTimeout + } - let process = Process() - process.executableURL = executableURL - process.currentDirectoryURL = URL(fileURLWithPath: launchDirectory, isDirectory: true) - process.arguments = [ - "--host", "127.0.0.1", - "--port", String(port), - "--auth", "none", - "--user-data-dir", userDataDir.path, - "--extensions-dir", extensionsDir.path, - launchDirectory - ] - - let stdout = Pipe() - let stderr = Pipe() - process.standardOutput = stdout - process.standardError = stderr - - stdout.fileHandleForReading.readabilityHandler = { [weak self] handle in - let data = handle.availableData - guard !data.isEmpty else { return } - Task { @MainActor [weak self] in - self?.appendLogData(data) - } - } + return Int(UInt16(bigEndian: assignedAddress.sin_port)) + } + + private func launchProcess( + executableURL: URL, + port: Int, + userDataDir: URL, + extensionsDir: URL, + launchDirectory: String + ) throws { + terminateProcess() + + try FileManager.default.createDirectory(at: paths.runtimeLogPath.deletingLastPathComponent(), withIntermediateDirectories: true) + if !FileManager.default.fileExists(atPath: paths.runtimeLogPath.path) { + FileManager.default.createFile(atPath: paths.runtimeLogPath.path, contents: nil) + } - stderr.fileHandleForReading.readabilityHandler = { [weak self] handle in - let data = handle.availableData - guard !data.isEmpty else { return } - Task { @MainActor [weak self] in - self?.appendLogData(data) - } - } + let handle = try FileHandle(forWritingTo: paths.runtimeLogPath) + try handle.seekToEnd() + logHandle = handle + + let process = Process() + process.executableURL = executableURL + process.currentDirectoryURL = URL(fileURLWithPath: launchDirectory, isDirectory: true) + process.arguments = [ + "--host", "127.0.0.1", + "--port", String(port), + "--auth", "none", + "--user-data-dir", userDataDir.path, + "--extensions-dir", extensionsDir.path, + launchDirectory, + ] + + let stdout = Pipe() + let stderr = Pipe() + process.standardOutput = stdout + process.standardError = stderr + + stdout.fileHandleForReading.readabilityHandler = { [weak self] handle in + let data = handle.availableData + guard !data.isEmpty else { return } + Task { @MainActor [weak self] in + self?.appendLogData(data) + } + } - process.terminationHandler = { [weak self] terminated in - Task { @MainActor [weak self] in - self?.handleProcessExit(terminated) - } - } + stderr.fileHandleForReading.readabilityHandler = { [weak self] handle in + let data = handle.availableData + guard !data.isEmpty else { return } + Task { @MainActor [weak self] in + self?.appendLogData(data) + } + } - try process.run() + process.terminationHandler = { [weak self] terminated in + Task { @MainActor [weak self] in + self?.handleProcessExit(terminated) + } + } - self.process = process - self.stdoutPipe = stdout - self.stderrPipe = stderr + try process.run() - appendRuntimeLog("spawned process pid=\(process.processIdentifier) port=\(port)") - } + self.process = process + stdoutPipe = stdout + stderrPipe = stderr - private func waitForServerReady(port: Int) async -> Bool { - let url = URL(string: "http://127.0.0.1:\(port)/")! - let deadline = Date().addingTimeInterval(readinessTimeoutSeconds) + appendRuntimeLog("spawned process pid=\(process.processIdentifier) port=\(port)") + } - while Date() < deadline { - if Task.isCancelled || userStopped { - return false - } - if process?.isRunning == false { - return false - } - if await probeServer(url: url) { - return true - } - try? await Task.sleep(nanoseconds: readinessIntervalNanoseconds) - } + private func waitForServerReady(port: Int) async -> Bool { + let url = URL(string: "http://127.0.0.1:\(port)/")! + let deadline = Date().addingTimeInterval(readinessTimeoutSeconds) + while Date() < deadline { + if Task.isCancelled || userStopped { return false + } + if process?.isRunning == false { + return false + } + if await probeServer(url: url) { + return true + } + try? await Task.sleep(nanoseconds: readinessIntervalNanoseconds) } - private func probeServer(url: URL) async -> Bool { - var request = URLRequest(url: url) - request.timeoutInterval = 1 - do { - let (_, response) = try await URLSession.shared.data(for: request) - return response is HTTPURLResponse - } catch { - return false - } + return false + } + + private func probeServer(url: URL) async -> Bool { + var request = URLRequest(url: url) + request.timeoutInterval = 1 + do { + let (_, response) = try await URLSession.shared.data(for: request) + return response is HTTPURLResponse + } catch { + return false + } + } + + private func ensureRequiredExtensionsInstalled( + executableURL: URL, + userDataDir: URL, + extensionsDir: URL + ) async { + guard !requiredExtensionIDs.isEmpty else { return } + guard !userStopped, !Task.isCancelled else { return } + + let installed: Set + do { + installed = try await listInstalledExtensions( + executableURL: executableURL, + userDataDir: userDataDir, + extensionsDir: extensionsDir + ) + } catch { + appendRuntimeLog("failed to list installed VS Code extensions: \(error.localizedDescription)") + return } - private func ensureRequiredExtensionsInstalled( - executableURL: URL, - userDataDir: URL, - extensionsDir: URL - ) async { - guard !requiredExtensionIDs.isEmpty else { return } - guard !userStopped, !Task.isCancelled else { return } - - let installed: Set - do { - installed = try await listInstalledExtensions( - executableURL: executableURL, - userDataDir: userDataDir, - extensionsDir: extensionsDir - ) - } catch { - appendRuntimeLog("failed to list installed VS Code extensions: \(error.localizedDescription)") - return - } - - let missing = requiredExtensionIDs.filter { id in - !installed.contains(id.lowercased()) - } - guard !missing.isEmpty else { return } - - appendRuntimeLog("installing required VS Code extensions: \(missing.joined(separator: ", "))") - for extensionID in missing { - guard !userStopped, !Task.isCancelled else { return } - do { - let result = try await processRunner.run( - executable: executableURL.path, - arguments: [ - "--user-data-dir", userDataDir.path, - "--extensions-dir", extensionsDir.path, - "--install-extension", extensionID, - "--force" - ], - currentDirectory: nil - ) - if result.exitCode == 0 { - appendRuntimeLog("extension installed: \(extensionID)") - } else { - let details = result.stderr.isEmpty ? result.stdout : result.stderr - appendRuntimeLog("extension install failed for \(extensionID): \(details)") - } - } catch { - appendRuntimeLog("extension install error for \(extensionID): \(error.localizedDescription)") - } - } + let missing = requiredExtensionIDs.filter { id in + !installed.contains(id.lowercased()) } + guard !missing.isEmpty else { return } - private func listInstalledExtensions( - executableURL: URL, - userDataDir: URL, - extensionsDir: URL - ) async throws -> Set { + appendRuntimeLog("installing required VS Code extensions: \(missing.joined(separator: ", "))") + for extensionID in missing { + guard !userStopped, !Task.isCancelled else { return } + do { let result = try await processRunner.run( - executable: executableURL.path, - arguments: [ - "--user-data-dir", userDataDir.path, - "--extensions-dir", extensionsDir.path, - "--list-extensions" - ], - currentDirectory: nil + executable: executableURL.path, + arguments: [ + "--user-data-dir", userDataDir.path, + "--extensions-dir", extensionsDir.path, + "--install-extension", extensionID, + "--force", + ], + currentDirectory: nil ) - - guard result.exitCode == 0 else { - let details = result.stderr.isEmpty ? result.stdout : result.stderr - throw VSCodeRuntimeError.commandFailed( - command: "\(executableURL.path) --list-extensions", - code: result.exitCode, - stderr: details - ) + if result.exitCode == 0 { + appendRuntimeLog("extension installed: \(extensionID)") + } else { + let details = result.stderr.isEmpty ? result.stdout : result.stderr + appendRuntimeLog("extension install failed for \(extensionID): \(details)") } + } catch { + appendRuntimeLog("extension install error for \(extensionID): \(error.localizedDescription)") + } + } + } + + private func listInstalledExtensions( + executableURL: URL, + userDataDir: URL, + extensionsDir: URL + ) async throws -> Set { + let result = try await processRunner.run( + executable: executableURL.path, + arguments: [ + "--user-data-dir", userDataDir.path, + "--extensions-dir", extensionsDir.path, + "--list-extensions", + ], + currentDirectory: nil + ) - let ids = result.stdout - .split(whereSeparator: \.isNewline) - .map { line in - line.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() - } - .filter { !$0.isEmpty } - return Set(ids) + guard result.exitCode == 0 else { + let details = result.stderr.isEmpty ? result.stdout : result.stderr + throw VSCodeRuntimeError.commandFailed( + command: "\(executableURL.path) --list-extensions", + code: result.exitCode, + stderr: details + ) } - private func handleProcessExit(_ terminatedProcess: Process) { - appendRuntimeLog( - "process exited status=\(terminatedProcess.terminationStatus) reason=\(terminatedProcess.terminationReason.rawValue)" - ) + let ids = result.stdout + .split(whereSeparator: \.isNewline) + .map { line in + line.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + } + .filter { !$0.isEmpty } + return Set(ids) + } + + private func handleProcessExit(_ terminatedProcess: Process) { + appendRuntimeLog( + "process exited status=\(terminatedProcess.terminationStatus) reason=\(terminatedProcess.terminationReason.rawValue)" + ) - terminateProcess() + terminateProcess() - guard !userStopped else { return } - if case .failed = state { return } + guard !userStopped else { return } + if case .failed = state { return } - if startTask == nil { - state = .starting - startTask = Task { [weak self] in - guard let self else { return } - await self.runStartupSequence() - self.startTask = nil - } - } + if startTask == nil { + state = .starting + startTask = Task { [weak self] in + guard let self else { return } + await runStartupSequence() + startTask = nil + } } + } - private func terminateProcess() { - stdoutPipe?.fileHandleForReading.readabilityHandler = nil - stderrPipe?.fileHandleForReading.readabilityHandler = nil - stdoutPipe = nil - stderrPipe = nil - - if let process, process.isRunning { - process.terminate() - } - self.process = nil + private func terminateProcess() { + stdoutPipe?.fileHandleForReading.readabilityHandler = nil + stderrPipe?.fileHandleForReading.readabilityHandler = nil + stdoutPipe = nil + stderrPipe = nil - if let handle = logHandle { - try? handle.close() - } - logHandle = nil + if let process, process.isRunning { + process.terminate() } + process = nil - private func appendLogData(_ data: Data) { - guard let logHandle else { return } - do { - try logHandle.seekToEnd() - try logHandle.write(contentsOf: data) - } catch { - Logger.error("Failed writing VS Code runtime log data: \(error.localizedDescription)") - } + if let handle = logHandle { + try? handle.close() + } + logHandle = nil + } + + private func appendLogData(_ data: Data) { + guard let logHandle else { return } + do { + try logHandle.seekToEnd() + try logHandle.write(contentsOf: data) + } catch { + Logger.error("Failed writing VS Code runtime log data: \(error.localizedDescription)") } + } - private func appendRuntimeLog(_ line: String) { - let timestamp = ISO8601DateFormatter().string(from: Date()) - guard let data = "[\(timestamp)] \(line)\n".data(using: .utf8) else { return } - - if logHandle == nil { - do { - try FileManager.default.createDirectory(at: paths.runtimeLogPath.deletingLastPathComponent(), withIntermediateDirectories: true) - if !FileManager.default.fileExists(atPath: paths.runtimeLogPath.path) { - FileManager.default.createFile(atPath: paths.runtimeLogPath.path, contents: nil) - } - let handle = try FileHandle(forWritingTo: paths.runtimeLogPath) - try handle.seekToEnd() - logHandle = handle - } catch { - Logger.error("Failed opening VS Code runtime log: \(error.localizedDescription)") - return - } - } + private func appendRuntimeLog(_ line: String) { + let timestamp = ISO8601DateFormatter().string(from: Date()) + guard let data = "[\(timestamp)] \(line)\n".data(using: .utf8) else { return } - appendLogData(data) + if logHandle == nil { + do { + try FileManager.default.createDirectory(at: paths.runtimeLogPath.deletingLastPathComponent(), withIntermediateDirectories: true) + if !FileManager.default.fileExists(atPath: paths.runtimeLogPath.path) { + FileManager.default.createFile(atPath: paths.runtimeLogPath.path, contents: nil) + } + let handle = try FileHandle(forWritingTo: paths.runtimeLogPath) + try handle.seekToEnd() + logHandle = handle + } catch { + Logger.error("Failed opening VS Code runtime log: \(error.localizedDescription)") + return + } } - private func handleWebContentTermination(_ webView: WKWebView) { - webContentTerminationCount += 1 - appendRuntimeLog("web content terminated count=\(webContentTerminationCount)") + appendLogData(data) + } - guard case .live(let urlString) = state, - let url = URL(string: urlString) else { - return - } + private func handleWebContentTermination(_ webView: WKWebView) { + webContentTerminationCount += 1 + appendRuntimeLog("web content terminated count=\(webContentTerminationCount)") - if webContentTerminationCount <= maxWebContentReloadAttempts { - appendRuntimeLog("reloading embedded content after WebContent termination") - webView.load(URLRequest(url: url)) - return - } + guard case let .live(urlString) = state, + let url = URL(string: urlString) + else { + return + } - appendRuntimeLog("web content termination retry budget exhausted; terminating runtime process") - state = .failed( - message: "Embedded browser process crashed repeatedly. Open logs for details.", - logPath: paths.runtimeLogPath.path - ) - terminateProcess() + if webContentTerminationCount <= maxWebContentReloadAttempts { + appendRuntimeLog("reloading embedded content after WebContent termination") + webView.load(URLRequest(url: url)) + return } - private func loadPersistedZoom() -> CGFloat { - guard userDefaults.object(forKey: zoomDefaultsKey) != nil else { - return 1.0 - } + appendRuntimeLog("web content termination retry budget exhausted; terminating runtime process") + state = .failed( + message: "Embedded browser process crashed repeatedly. Open logs for details.", + logPath: paths.runtimeLogPath.path + ) + terminateProcess() + } - let stored = CGFloat(userDefaults.double(forKey: zoomDefaultsKey)) - guard stored.isFinite else { - return 1.0 - } - return max(minimumZoom, min(maximumZoom, stored)) + private func loadPersistedZoom() -> CGFloat { + guard userDefaults.object(forKey: zoomDefaultsKey) != nil else { + return 1.0 } - private func persistZoom(_ zoom: CGFloat) { - userDefaults.set(Double(zoom), forKey: zoomDefaultsKey) + let stored = CGFloat(userDefaults.double(forKey: zoomDefaultsKey)) + guard stored.isFinite else { + return 1.0 } - - private static func zoomDefaultsKey(for seedPath: String) -> String { - let expanded = NSString(string: seedPath).expandingTildeInPath - let canonicalPath = URL(fileURLWithPath: expanded, isDirectory: true).standardizedFileURL.path - return "idx0.vscode.zoom.\(sha256Hex(canonicalPath))" + return max(minimumZoom, min(maximumZoom, stored)) + } + + private func persistZoom(_ zoom: CGFloat) { + userDefaults.set(Double(zoom), forKey: zoomDefaultsKey) + } + + private static func zoomDefaultsKey(for seedPath: String) -> String { + let expanded = NSString(string: seedPath).expandingTildeInPath + let canonicalPath = URL(fileURLWithPath: expanded, isDirectory: true).standardizedFileURL.path + return "idx0.vscode.zoom.\(sha256Hex(canonicalPath))" + } + + private static func sha256Hex(_ value: String) -> String { + let data = Data(value.utf8) + var digest = [UInt8](repeating: 0, count: Int(CC_SHA256_DIGEST_LENGTH)) + data.withUnsafeBytes { bytes in + _ = CC_SHA256(bytes.baseAddress, CC_LONG(data.count), &digest) } + return digest.map { String(format: "%02x", $0) }.joined() + } - private static func sha256Hex(_ value: String) -> String { - let data = Data(value.utf8) - var digest = [UInt8](repeating: 0, count: Int(CC_SHA256_DIGEST_LENGTH)) - data.withUnsafeBytes { bytes in - _ = CC_SHA256(bytes.baseAddress, CC_LONG(data.count), &digest) - } - return digest.map { String(format: "%02x", $0) }.joined() + private func isRetryableStartupError(_ error: Error) -> Bool { + guard let runtimeError = error as? VSCodeRuntimeError else { + return false } - private func isRetryableStartupError(_ error: Error) -> Bool { - guard let runtimeError = error as? VSCodeRuntimeError else { - return false - } - - switch runtimeError { - case .startupTimeout, .processExitedBeforeReady: - return true - case .unsupportedPlatform, - .invalidDownloadURL, - .downloadFailed, - .checksumMismatch, - .missingExecutable, - .commandFailed, - .cancelled: - return false - } + switch runtimeError { + case .startupTimeout, .processExitedBeforeReady: + return true + case .unsupportedPlatform, + .invalidDownloadURL, + .downloadFailed, + .checksumMismatch, + .missingExecutable, + .commandFailed, + .cancelled: + return false } + } - private func logPathForError(_ error: Error) -> String { - guard let runtimeError = error as? VSCodeRuntimeError else { - return paths.runtimeLogPath.path - } + private func logPathForError(_ error: Error) -> String { + guard let runtimeError = error as? VSCodeRuntimeError else { + return paths.runtimeLogPath.path + } - switch runtimeError { - case .unsupportedPlatform, - .invalidDownloadURL, - .downloadFailed, - .checksumMismatch, - .missingExecutable, - .commandFailed: - return paths.provisionLogPath.path - case .startupTimeout, - .processExitedBeforeReady, - .cancelled: - return paths.runtimeLogPath.path - } + switch runtimeError { + case .unsupportedPlatform, + .invalidDownloadURL, + .downloadFailed, + .checksumMismatch, + .missingExecutable, + .commandFailed: + return paths.provisionLogPath.path + case .startupTimeout, + .processExitedBeforeReady, + .cancelled: + return paths.runtimeLogPath.path } + } } diff --git a/idx0/Resources/excalidraw-build-manifest.json b/idx0/Resources/excalidraw-build-manifest.json index 67c23e1..0be14d1 100644 --- a/idx0/Resources/excalidraw-build-manifest.json +++ b/idx0/Resources/excalidraw-build-manifest.json @@ -1,6 +1,6 @@ { "repositoryURL": "https://github.com/excalidraw/excalidraw.git", - "pinnedCommit": "d6f0f34fe91a7fab25106f2b31b074c132815d36", + "pinnedCommit": "HEAD", "installCommand": "yarn install --frozen-lockfile", "buildCommand": "yarn --cwd excalidraw-app build", "entrypoint": "excalidraw-app/build/index.html", diff --git a/idx0/Resources/t3-build-manifest.json b/idx0/Resources/t3-build-manifest.json index 4ccf9de..c38f927 100644 --- a/idx0/Resources/t3-build-manifest.json +++ b/idx0/Resources/t3-build-manifest.json @@ -1,6 +1,6 @@ { "repositoryURL": "https://github.com/pingdotgg/t3code.git", - "pinnedCommit": "2a237c20019a", + "pinnedCommit": "HEAD", "installCommand": "bun install --frozen-lockfile", "buildCommand": "bun run --cwd apps/web build && bun run --cwd apps/server build", "entrypoint": "apps/server/dist/index.mjs", diff --git a/idx0Tests/Apps/Excalidraw/ExcalidrawRuntimeTests.swift b/idx0Tests/Apps/Excalidraw/ExcalidrawRuntimeTests.swift index c282355..0acaa31 100644 --- a/idx0Tests/Apps/Excalidraw/ExcalidrawRuntimeTests.swift +++ b/idx0Tests/Apps/Excalidraw/ExcalidrawRuntimeTests.swift @@ -1,481 +1,523 @@ import Foundation -import XCTest @testable import idx0 +import XCTest @MainActor final class ExcalidrawRuntimeTests: XCTestCase { - func testRuntimePathsEnsureBaseDirectoriesCreatesSessionDirectory() throws { - let root = temporaryExcalidrawRoot() - defer { try? FileManager.default.removeItem(at: root) } - - let sessionID = UUID() - let paths = ExcalidrawRuntimePaths(sessionID: sessionID, rootDirectoryOverride: root) - - try paths.ensureBaseDirectories() + func testRuntimePathsEnsureBaseDirectoriesCreatesSessionDirectory() throws { + let root = temporaryExcalidrawRoot() + defer { try? FileManager.default.removeItem(at: root) } + + let sessionID = UUID() + let paths = ExcalidrawRuntimePaths(sessionID: sessionID, rootDirectoryOverride: root) + + try paths.ensureBaseDirectories() + + var isDirectory: ObjCBool = false + XCTAssertTrue(FileManager.default.fileExists(atPath: paths.sessionDirectory.path, isDirectory: &isDirectory)) + XCTAssertTrue(isDirectory.boolValue) + } + + func testRemoveSessionArtifactsDeletesSessionDirectory() throws { + let root = temporaryExcalidrawRoot() + defer { try? FileManager.default.removeItem(at: root) } + + let sessionID = UUID() + let paths = ExcalidrawRuntimePaths(sessionID: sessionID, rootDirectoryOverride: root) + try paths.ensureBaseDirectories() + + let marker = paths.sessionDirectory.appendingPathComponent("marker.txt", isDirectory: false) + try "marker".write(to: marker, atomically: true, encoding: .utf8) + + XCTAssertTrue(FileManager.default.fileExists(atPath: marker.path)) + paths.removeSessionArtifacts() + XCTAssertFalse(FileManager.default.fileExists(atPath: paths.sessionDirectory.path)) + } + + func testExcalidrawTileRuntimeStateDisplayMessagesAreStable() { + XCTAssertEqual(ExcalidrawTileRuntimeState.idle.displayMessage, "Ready") + XCTAssertEqual(ExcalidrawTileRuntimeState.preparingSource.displayMessage, "Preparing Excalidraw source...") + XCTAssertEqual(ExcalidrawTileRuntimeState.building.displayMessage, "Building Excalidraw...") + XCTAssertEqual(ExcalidrawTileRuntimeState.starting.displayMessage, "Starting Excalidraw...") + XCTAssertEqual(ExcalidrawTileRuntimeState.live(urlString: "http://127.0.0.1:9999").displayMessage, "Live") + } + + func testBuildCoordinatorFailsWhenYarnMissing() async throws { + let root = temporaryExcalidrawRoot() + defer { try? FileManager.default.removeItem(at: root) } + + let paths = ExcalidrawRuntimePaths(sessionID: UUID(), rootDirectoryOverride: root) + let binDirectory = root.appendingPathComponent("bin", isDirectory: true) + try FileManager.default.createDirectory(at: binDirectory, withIntermediateDirectories: true) + let nodePath = binDirectory.appendingPathComponent("node", isDirectory: false) + try writeExecutable("#!/bin/sh\nexit 0\n", to: nodePath) + + let runner = StubExcalidrawProcessRunner { executable, arguments, _ in + if executable == "/usr/bin/which", let tool = arguments.first { + switch tool { + case "git": + return ProcessResult(exitCode: 0, stdout: "/usr/bin/git", stderr: "") + case "node": + return ProcessResult(exitCode: 0, stdout: nodePath.path, stderr: "") + case "yarn", "corepack": + return ProcessResult(exitCode: 1, stdout: "", stderr: "not found") + default: + return ProcessResult(exitCode: 0, stdout: "", stderr: "") + } + } - var isDirectory: ObjCBool = false - XCTAssertTrue(FileManager.default.fileExists(atPath: paths.sessionDirectory.path, isDirectory: &isDirectory)) - XCTAssertTrue(isDirectory.boolValue) + if executable == "/usr/bin/git", arguments.first == "clone" { + try FileManager.default.createDirectory(at: paths.sourceDirectory, withIntermediateDirectories: true) + try FileManager.default.createDirectory( + at: paths.sourceDirectory.appendingPathComponent(".git", isDirectory: true), + withIntermediateDirectories: true + ) + return ProcessResult(exitCode: 0, stdout: "", stderr: "") + } + + if executable == "/usr/bin/git", arguments.contains("rev-parse") { + return ProcessResult(exitCode: 0, stdout: "abc123\n", stderr: "") + } + + if executable == "/bin/zsh", + arguments == ["-lc", "whence -p yarn"] || arguments == ["-ilc", "whence -p yarn"] + { + return ProcessResult(exitCode: 1, stdout: "", stderr: "not found") + } + + if executable == "/bin/zsh", + arguments == ["-lc", "whence -p corepack"] || arguments == ["-ilc", "whence -p corepack"] + { + return ProcessResult(exitCode: 1, stdout: "", stderr: "not found") + } + + return ProcessResult(exitCode: 0, stdout: "", stderr: "") } - - func testRemoveSessionArtifactsDeletesSessionDirectory() throws { - let root = temporaryExcalidrawRoot() - defer { try? FileManager.default.removeItem(at: root) } - - let sessionID = UUID() - let paths = ExcalidrawRuntimePaths(sessionID: sessionID, rootDirectoryOverride: root) - try paths.ensureBaseDirectories() - - let marker = paths.sessionDirectory.appendingPathComponent("marker.txt", isDirectory: false) - try "marker".write(to: marker, atomically: true, encoding: .utf8) - - XCTAssertTrue(FileManager.default.fileExists(atPath: marker.path)) - paths.removeSessionArtifacts() - XCTAssertFalse(FileManager.default.fileExists(atPath: paths.sessionDirectory.path)) + let coordinator = ExcalidrawBuildCoordinator(processRunner: runner, fileManager: .default) + + do { + _ = try await coordinator.ensureBuilt(manifest: .default, paths: paths) + XCTFail("Expected missing-tool error") + } catch let error as ExcalidrawRuntimeError { + guard case let .missingYarnPackageManager(resolvedNodePath) = error else { + XCTFail("Unexpected Excalidraw runtime error: \(error)") + return + } + XCTAssertEqual(resolvedNodePath, nodePath.path) + XCTAssertEqual( + error.errorDescription, + """ + Excalidraw found Node.js at \(nodePath.path), but could not find `yarn` or `corepack`. + Run `corepack enable` for that Node installation, or install Yarn, then retry. + """ + ) } - - func testExcalidrawTileRuntimeStateDisplayMessagesAreStable() { - XCTAssertEqual(ExcalidrawTileRuntimeState.idle.displayMessage, "Ready") - XCTAssertEqual(ExcalidrawTileRuntimeState.preparingSource.displayMessage, "Preparing Excalidraw source...") - XCTAssertEqual(ExcalidrawTileRuntimeState.building.displayMessage, "Building Excalidraw...") - XCTAssertEqual(ExcalidrawTileRuntimeState.starting.displayMessage, "Starting Excalidraw...") - XCTAssertEqual(ExcalidrawTileRuntimeState.live(urlString: "http://127.0.0.1:9999").displayMessage, "Live") + } + + func testBuildCoordinatorReusesExistingArtifactsWithoutInvokingNodeOrYarnChecks() async throws { + let root = temporaryExcalidrawRoot() + defer { try? FileManager.default.removeItem(at: root) } + + let paths = ExcalidrawRuntimePaths(sessionID: UUID(), rootDirectoryOverride: root) + try paths.ensureBaseDirectories() + try FileManager.default.createDirectory(at: paths.sourceDirectory, withIntermediateDirectories: true) + try FileManager.default.createDirectory( + at: paths.sourceDirectory.appendingPathComponent(".git", isDirectory: true), + withIntermediateDirectories: true + ) + + let manifest = ExcalidrawBuildManifest( + repositoryURL: "https://example.com/unused.git", + pinnedCommit: "abc123", + installCommand: "unused", + buildCommand: "unused", + entrypoint: "excalidraw-app/build/index.html", + requiredArtifacts: ["excalidraw-app/build/index.html"] + ) + + for artifact in manifest.requiredArtifacts { + let artifactURL = paths.sourceDirectory.appendingPathComponent(artifact, isDirectory: false) + try FileManager.default.createDirectory(at: artifactURL.deletingLastPathComponent(), withIntermediateDirectories: true) + try Data().write(to: artifactURL) } - func testBuildCoordinatorFailsWhenYarnMissing() async throws { - let root = temporaryExcalidrawRoot() - defer { try? FileManager.default.removeItem(at: root) } - - let paths = ExcalidrawRuntimePaths(sessionID: UUID(), rootDirectoryOverride: root) - let binDirectory = root.appendingPathComponent("bin", isDirectory: true) - try FileManager.default.createDirectory(at: binDirectory, withIntermediateDirectories: true) - let nodePath = binDirectory.appendingPathComponent("node", isDirectory: false) - try writeExecutable("#!/bin/sh\nexit 0\n", to: nodePath) - - let runner = StubExcalidrawProcessRunner { executable, arguments, _ in - if executable == "/usr/bin/which", let tool = arguments.first { - switch tool { - case "git": - return ProcessResult(exitCode: 0, stdout: "/usr/bin/git", stderr: "") - case "node": - return ProcessResult(exitCode: 0, stdout: nodePath.path, stderr: "") - case "yarn", "corepack": - return ProcessResult(exitCode: 1, stdout: "", stderr: "not found") - default: - return ProcessResult(exitCode: 0, stdout: "", stderr: "") - } - } - - if executable == "/bin/zsh", - arguments == ["-lc", "whence -p yarn"] || arguments == ["-ilc", "whence -p yarn"] { - return ProcessResult(exitCode: 1, stdout: "", stderr: "not found") - } - - if executable == "/bin/zsh", - arguments == ["-lc", "whence -p corepack"] || arguments == ["-ilc", "whence -p corepack"] { - return ProcessResult(exitCode: 1, stdout: "", stderr: "not found") - } - - return ProcessResult(exitCode: 0, stdout: "", stderr: "") - } - let coordinator = ExcalidrawBuildCoordinator(processRunner: runner, fileManager: .default) - - do { - _ = try await coordinator.ensureBuilt(manifest: .default, paths: paths) - XCTFail("Expected missing-tool error") - } catch let error as ExcalidrawRuntimeError { - guard case .missingYarnPackageManager(let resolvedNodePath) = error else { - XCTFail("Unexpected Excalidraw runtime error: \(error)") - return - } - XCTAssertEqual(resolvedNodePath, nodePath.path) - XCTAssertEqual( - error.errorDescription, - """ - Excalidraw found Node.js at \(nodePath.path), but could not find `yarn` or `corepack`. - Run `corepack enable` for that Node installation, or install Yarn, then retry. - """ - ) - } + struct BuildRecordMirror: Codable { + let sourceCommit: String + let entrypoint: String + let builtAt: Date } - func testBuildCoordinatorReusesExistingArtifactsWithoutInvokingRunner() async throws { - let root = temporaryExcalidrawRoot() - defer { try? FileManager.default.removeItem(at: root) } - - let paths = ExcalidrawRuntimePaths(sessionID: UUID(), rootDirectoryOverride: root) - try paths.ensureBaseDirectories() - try FileManager.default.createDirectory(at: paths.sourceDirectory, withIntermediateDirectories: true) - - let manifest = ExcalidrawBuildManifest( - repositoryURL: "https://example.com/unused.git", - pinnedCommit: "abc123", - installCommand: "unused", - buildCommand: "unused", - entrypoint: "excalidraw-app/build/index.html", - requiredArtifacts: ["excalidraw-app/build/index.html"] - ) - - for artifact in manifest.requiredArtifacts { - let artifactURL = paths.sourceDirectory.appendingPathComponent(artifact, isDirectory: false) - try FileManager.default.createDirectory(at: artifactURL.deletingLastPathComponent(), withIntermediateDirectories: true) - try Data().write(to: artifactURL) - } - - struct BuildRecordMirror: Codable { - let pinnedCommit: String - let entrypoint: String - let builtAt: Date + let resolvedSourceCommit = "abc123" + let record = BuildRecordMirror( + sourceCommit: resolvedSourceCommit, + entrypoint: manifest.entrypoint, + builtAt: Date() + ) + let recordData = try JSONEncoder().encode(record) + try recordData.write(to: paths.buildRecordPath, options: .atomic) + + actor InvocationRecorder { + var values: [(String, [String])] = [] + + func append(_ value: (String, [String])) { + values.append(value) + } + + func all() -> [(String, [String])] { + values + } + } + let recorder = InvocationRecorder() + let runner = StubExcalidrawProcessRunner { executable, arguments, _ in + await recorder.append((executable, arguments)) + if executable == "/usr/bin/which", let tool = arguments.first { + if tool == "node" || tool == "yarn" { + XCTFail("Node/Yarn checks should be skipped when latest build artifacts are reusable") } + return ProcessResult(exitCode: 0, stdout: "/usr/bin/\(tool)\n", stderr: "") + } + if executable == "/usr/bin/git", arguments.contains("rev-parse") { + return ProcessResult(exitCode: 0, stdout: "\(resolvedSourceCommit)\n", stderr: "") + } + return ProcessResult(exitCode: 0, stdout: "", stderr: "") + } + let coordinator = ExcalidrawBuildCoordinator(processRunner: runner, fileManager: .default) + + let entrypoint = try await coordinator.ensureBuilt(manifest: manifest, paths: paths) + let invocations = await recorder.all() + XCTAssertEqual( + entrypoint.path, + paths.sourceDirectory.appendingPathComponent(manifest.entrypoint, isDirectory: false).path + ) + XCTAssertTrue(invocations.contains(where: { $0.0 == "/usr/bin/git" && $0.1.contains("fetch") })) + XCTAssertFalse(invocations.contains(where: { $0.0 == "/usr/bin/git" && $0.1.contains("checkout") })) + } + + func testBuildCoordinatorUsesNonInteractiveShellForYarnCommands() async throws { + let root = temporaryExcalidrawRoot() + defer { try? FileManager.default.removeItem(at: root) } + + let paths = ExcalidrawRuntimePaths(sessionID: UUID(), rootDirectoryOverride: root) + let manifest = ExcalidrawBuildManifest.default + + actor InvocationRecorder { + var values: [(String, [String], String?)] = [] + + func append(_ value: (String, [String], String?)) { + values.append(value) + } + + func all() -> [(String, [String], String?)] { + values + } + } - let record = BuildRecordMirror( - pinnedCommit: manifest.pinnedCommit, - entrypoint: manifest.entrypoint, - builtAt: Date() - ) - let recordData = try JSONEncoder().encode(record) - try recordData.write(to: paths.buildRecordPath, options: .atomic) - - actor Counter { - var value = 0 + let recorder = InvocationRecorder() - func increment() { - value += 1 - } + let runner = StubExcalidrawProcessRunner { executable, arguments, currentDirectory in + await recorder.append((executable, arguments, currentDirectory)) - func current() -> Int { - value - } - } - let counter = Counter() - let runner = StubExcalidrawProcessRunner { _, _, _ in - await counter.increment() - return ProcessResult(exitCode: 0, stdout: "", stderr: "") - } - let coordinator = ExcalidrawBuildCoordinator(processRunner: runner, fileManager: .default) + if executable == "/usr/bin/which", let tool = arguments.first { + return ProcessResult(exitCode: 0, stdout: "/usr/bin/\(tool)", stderr: "") + } - let entrypoint = try await coordinator.ensureBuilt(manifest: manifest, paths: paths) - let invocationCount = await counter.current() - XCTAssertEqual( - entrypoint.path, - paths.sourceDirectory.appendingPathComponent(manifest.entrypoint, isDirectory: false).path + if executable == "/usr/bin/git", arguments.first == "clone" { + try FileManager.default.createDirectory(at: paths.sourceDirectory, withIntermediateDirectories: true) + try FileManager.default.createDirectory( + at: paths.sourceDirectory.appendingPathComponent(".git", isDirectory: true), + withIntermediateDirectories: true ) - XCTAssertEqual(invocationCount, 0) + } + + if executable == "/usr/bin/git", arguments.contains("rev-parse") { + return ProcessResult(exitCode: 0, stdout: "abc123\n", stderr: "") + } + + if executable == "/bin/zsh", + arguments.first == "-lc", + arguments.count == 2, + arguments[1].contains("--cwd excalidraw-app build") + { + let artifact = paths.sourceDirectory.appendingPathComponent(manifest.entrypoint, isDirectory: false) + try FileManager.default.createDirectory(at: artifact.deletingLastPathComponent(), withIntermediateDirectories: true) + try Data().write(to: artifact) + } + + return ProcessResult(exitCode: 0, stdout: "", stderr: "") } - func testBuildCoordinatorUsesNonInteractiveShellForYarnCommands() async throws { - let root = temporaryExcalidrawRoot() - defer { try? FileManager.default.removeItem(at: root) } - - let paths = ExcalidrawRuntimePaths(sessionID: UUID(), rootDirectoryOverride: root) - let manifest = ExcalidrawBuildManifest.default - - actor InvocationRecorder { - var values: [(String, [String], String?)] = [] - - func append(_ value: (String, [String], String?)) { - values.append(value) - } - - func all() -> [(String, [String], String?)] { - values - } - } - - let recorder = InvocationRecorder() - - let runner = StubExcalidrawProcessRunner { executable, arguments, currentDirectory in - await recorder.append((executable, arguments, currentDirectory)) - - if executable == "/usr/bin/which", let tool = arguments.first { - return ProcessResult(exitCode: 0, stdout: "/usr/bin/\(tool)", stderr: "") - } - - if executable == "/usr/bin/git", arguments.first == "clone" { - try FileManager.default.createDirectory(at: paths.sourceDirectory, withIntermediateDirectories: true) - try FileManager.default.createDirectory( - at: paths.sourceDirectory.appendingPathComponent(".git", isDirectory: true), - withIntermediateDirectories: true - ) - } - - if executable == "/bin/zsh", - arguments.first == "-lc", - arguments.count == 2, - arguments[1].contains("--cwd excalidraw-app build") { - let artifact = paths.sourceDirectory.appendingPathComponent(manifest.entrypoint, isDirectory: false) - try FileManager.default.createDirectory(at: artifact.deletingLastPathComponent(), withIntermediateDirectories: true) - try Data().write(to: artifact) - } - - return ProcessResult(exitCode: 0, stdout: "", stderr: "") - } - - let coordinator = ExcalidrawBuildCoordinator(processRunner: runner, fileManager: .default) - _ = try await coordinator.ensureBuilt(manifest: manifest, paths: paths) - - let invocations = await recorder.all() - let shellInvocations = invocations.filter { $0.0 == "/bin/zsh" } - - XCTAssertEqual(shellInvocations.count, 2) - XCTAssertTrue(shellInvocations.allSatisfy { invocation in invocation.1.first == "-lc" }) - XCTAssertTrue(shellInvocations.allSatisfy { invocation in - invocation.1.count == 2 && - invocation.1[1].contains("COREPACK_ENABLE_DOWNLOAD_PROMPT=0") && - invocation.1[1].contains("export CI=1") - }) - XCTAssertTrue(shellInvocations.contains(where: { - $0.1[1].contains("/usr/bin/yarn") && - $0.1[1].contains("install --frozen-lockfile") - })) - XCTAssertTrue(shellInvocations.contains(where: { - $0.1[1].contains("/usr/bin/yarn") && - $0.1[1].contains("--cwd excalidraw-app build") - })) + let coordinator = ExcalidrawBuildCoordinator(processRunner: runner, fileManager: .default) + _ = try await coordinator.ensureBuilt(manifest: manifest, paths: paths) + + let invocations = await recorder.all() + let shellInvocations = invocations.filter { $0.0 == "/bin/zsh" } + + XCTAssertEqual(shellInvocations.count, 2) + XCTAssertTrue(shellInvocations.allSatisfy { invocation in invocation.1.first == "-lc" }) + XCTAssertTrue(shellInvocations.allSatisfy { invocation in + invocation.1.count == 2 && + invocation.1[1].contains("COREPACK_ENABLE_DOWNLOAD_PROMPT=0") && + invocation.1[1].contains("export CI=1") + }) + XCTAssertTrue(shellInvocations.contains(where: { + $0.1[1].contains("/usr/bin/yarn") && + $0.1[1].contains("install --frozen-lockfile") + })) + XCTAssertTrue(shellInvocations.contains(where: { + $0.1[1].contains("/usr/bin/yarn") && + $0.1[1].contains("--cwd excalidraw-app build") + })) + } + + func testBuildCoordinatorUsesResolvedGitPathForGitCommands() async throws { + let root = temporaryExcalidrawRoot() + defer { try? FileManager.default.removeItem(at: root) } + + let paths = ExcalidrawRuntimePaths(sessionID: UUID(), rootDirectoryOverride: root) + let manifest = ExcalidrawBuildManifest.default + let resolvedGitPath = "/opt/homebrew/bin/git" + + actor InvocationRecorder { + var values: [(String, [String], String?)] = [] + + func append(_ value: (String, [String], String?)) { + values.append(value) + } + + func all() -> [(String, [String], String?)] { + values + } } - func testBuildCoordinatorUsesResolvedGitPathForGitCommands() async throws { - let root = temporaryExcalidrawRoot() - defer { try? FileManager.default.removeItem(at: root) } - - let paths = ExcalidrawRuntimePaths(sessionID: UUID(), rootDirectoryOverride: root) - let manifest = ExcalidrawBuildManifest.default - let resolvedGitPath = "/opt/homebrew/bin/git" + let recorder = InvocationRecorder() - actor InvocationRecorder { - var values: [(String, [String], String?)] = [] + let runner = StubExcalidrawProcessRunner { executable, arguments, currentDirectory in + await recorder.append((executable, arguments, currentDirectory)) - func append(_ value: (String, [String], String?)) { - values.append(value) - } - - func all() -> [(String, [String], String?)] { - values - } - } - - let recorder = InvocationRecorder() - - let runner = StubExcalidrawProcessRunner { executable, arguments, currentDirectory in - await recorder.append((executable, arguments, currentDirectory)) - - if executable == "/usr/bin/which", let tool = arguments.first { - switch tool { - case "git": - return ProcessResult(exitCode: 0, stdout: "\(resolvedGitPath)\n", stderr: "") - case "node", "yarn": - return ProcessResult(exitCode: 0, stdout: "/usr/bin/\(tool)\n", stderr: "") - default: - break - } - } - - if executable == resolvedGitPath, arguments.first == "clone" { - try FileManager.default.createDirectory(at: paths.sourceDirectory, withIntermediateDirectories: true) - try FileManager.default.createDirectory( - at: paths.sourceDirectory.appendingPathComponent(".git", isDirectory: true), - withIntermediateDirectories: true - ) - } - - if executable == "/bin/zsh", - arguments.first == "-lc", - arguments.count == 2, - arguments[1].contains("--cwd excalidraw-app build") { - let artifact = paths.sourceDirectory.appendingPathComponent(manifest.entrypoint, isDirectory: false) - try FileManager.default.createDirectory(at: artifact.deletingLastPathComponent(), withIntermediateDirectories: true) - try Data().write(to: artifact) - } - - return ProcessResult(exitCode: 0, stdout: "", stderr: "") + if executable == "/usr/bin/which", let tool = arguments.first { + switch tool { + case "git": + return ProcessResult(exitCode: 0, stdout: "\(resolvedGitPath)\n", stderr: "") + case "node", "yarn": + return ProcessResult(exitCode: 0, stdout: "/usr/bin/\(tool)\n", stderr: "") + default: + break } + } - let coordinator = ExcalidrawBuildCoordinator(processRunner: runner, fileManager: .default) - _ = try await coordinator.ensureBuilt(manifest: manifest, paths: paths) - - let invocations = await recorder.all() - XCTAssertTrue(invocations.contains(where: { $0.0 == resolvedGitPath && $0.1.first == "clone" })) - XCTAssertTrue(invocations.contains(where: { $0.0 == resolvedGitPath && $0.1.contains("checkout") })) - XCTAssertFalse(invocations.contains(where: { $0.0 == "/usr/bin/git" })) + if executable == resolvedGitPath, arguments.first == "clone" { + try FileManager.default.createDirectory(at: paths.sourceDirectory, withIntermediateDirectories: true) + try FileManager.default.createDirectory( + at: paths.sourceDirectory.appendingPathComponent(".git", isDirectory: true), + withIntermediateDirectories: true + ) + } + + if executable == resolvedGitPath, arguments.contains("rev-parse") { + return ProcessResult(exitCode: 0, stdout: "abc123\n", stderr: "") + } + + if executable == "/bin/zsh", + arguments.first == "-lc", + arguments.count == 2, + arguments[1].contains("--cwd excalidraw-app build") + { + let artifact = paths.sourceDirectory.appendingPathComponent(manifest.entrypoint, isDirectory: false) + try FileManager.default.createDirectory(at: artifact.deletingLastPathComponent(), withIntermediateDirectories: true) + try Data().write(to: artifact) + } + + return ProcessResult(exitCode: 0, stdout: "", stderr: "") } - func testBuildCoordinatorFallsBackToAdjacentCorepackWhenYarnIsMissing() async throws { - let root = temporaryExcalidrawRoot() - defer { try? FileManager.default.removeItem(at: root) } - - let binDirectory = root.appendingPathComponent("node-bin", isDirectory: true) - try FileManager.default.createDirectory(at: binDirectory, withIntermediateDirectories: true) - - let nodePath = binDirectory.appendingPathComponent("node", isDirectory: false) - let corepackPath = binDirectory.appendingPathComponent("corepack", isDirectory: false) - try writeExecutable("#!/bin/sh\nexit 0\n", to: nodePath) - try writeExecutable("#!/bin/sh\nexit 0\n", to: corepackPath) + let coordinator = ExcalidrawBuildCoordinator(processRunner: runner, fileManager: .default) + _ = try await coordinator.ensureBuilt(manifest: manifest, paths: paths) - let paths = ExcalidrawRuntimePaths(sessionID: UUID(), rootDirectoryOverride: root) - let manifest = ExcalidrawBuildManifest.default + let invocations = await recorder.all() + XCTAssertTrue(invocations.contains(where: { $0.0 == resolvedGitPath && $0.1.first == "clone" })) + XCTAssertTrue(invocations.contains(where: { $0.0 == resolvedGitPath && $0.1.contains("checkout") })) + XCTAssertFalse(invocations.contains(where: { $0.0 == "/usr/bin/git" })) + } - actor InvocationRecorder { - var values: [(String, [String], String?)] = [] + func testBuildCoordinatorFallsBackToAdjacentCorepackWhenYarnIsMissing() async throws { + let root = temporaryExcalidrawRoot() + defer { try? FileManager.default.removeItem(at: root) } - func append(_ value: (String, [String], String?)) { - values.append(value) - } + let binDirectory = root.appendingPathComponent("node-bin", isDirectory: true) + try FileManager.default.createDirectory(at: binDirectory, withIntermediateDirectories: true) - func all() -> [(String, [String], String?)] { - values - } - } + let nodePath = binDirectory.appendingPathComponent("node", isDirectory: false) + let corepackPath = binDirectory.appendingPathComponent("corepack", isDirectory: false) + try writeExecutable("#!/bin/sh\nexit 0\n", to: nodePath) + try writeExecutable("#!/bin/sh\nexit 0\n", to: corepackPath) - let recorder = InvocationRecorder() - - let runner = StubExcalidrawProcessRunner { executable, arguments, currentDirectory in - await recorder.append((executable, arguments, currentDirectory)) - - if executable == "/usr/bin/which", let tool = arguments.first { - switch tool { - case "git": - return ProcessResult(exitCode: 0, stdout: "/usr/bin/git\n", stderr: "") - case "node", "yarn": - return ProcessResult(exitCode: 1, stdout: "", stderr: "not found") - default: - return ProcessResult(exitCode: 1, stdout: "", stderr: "") - } - } - - if executable == "/bin/zsh", arguments == ["-lc", "whence -p node"] { - return ProcessResult(exitCode: 1, stdout: "", stderr: "") - } - - if executable == "/bin/zsh", arguments == ["-ilc", "whence -p node"] { - return ProcessResult( - exitCode: 0, - stdout: """ - Dotfiles have changed remotely and locally: - M zsh/.zshrc - Seems unixorn/autoupdate-antigen.zshplugin is already installed! - \(nodePath.path) - """, - stderr: "" - ) - } - - if executable == "/bin/zsh", - arguments == ["-lc", "whence -p yarn"] || arguments == ["-ilc", "whence -p yarn"] { - return ProcessResult( - exitCode: 1, - stdout: """ - Dotfiles have changed remotely and locally: - M zsh/.zshrc - Seems unixorn/autoupdate-antigen.zshplugin is already installed! - """, - stderr: "" - ) - } - - if executable == "/usr/bin/git", arguments.first == "clone" { - try FileManager.default.createDirectory(at: paths.sourceDirectory, withIntermediateDirectories: true) - try FileManager.default.createDirectory( - at: paths.sourceDirectory.appendingPathComponent(".git", isDirectory: true), - withIntermediateDirectories: true - ) - } - - if executable == "/bin/zsh", - arguments.first == "-lc", - arguments.count == 2, - arguments[1].contains("yarn --cwd excalidraw-app build") { - let artifact = paths.sourceDirectory.appendingPathComponent(manifest.entrypoint, isDirectory: false) - try FileManager.default.createDirectory(at: artifact.deletingLastPathComponent(), withIntermediateDirectories: true) - try Data().write(to: artifact) - } - - return ProcessResult(exitCode: 0, stdout: "", stderr: "") - } + let paths = ExcalidrawRuntimePaths(sessionID: UUID(), rootDirectoryOverride: root) + let manifest = ExcalidrawBuildManifest.default - let coordinator = ExcalidrawBuildCoordinator(processRunner: runner, fileManager: .default) - _ = try await coordinator.ensureBuilt(manifest: manifest, paths: paths) + actor InvocationRecorder { + var values: [(String, [String], String?)] = [] - let invocations = await recorder.all() - let shellInvocations = invocations.filter { $0.0 == "/bin/zsh" && $0.1.first == "-lc" } + func append(_ value: (String, [String], String?)) { + values.append(value) + } - XCTAssertTrue( - shellInvocations.contains(where: { - $0.1.count == 2 && - $0.1[1].contains(corepackPath.path) && - $0.1[1].contains("yarn install --frozen-lockfile") - }) - ) - XCTAssertTrue( - shellInvocations.contains(where: { - $0.1.count == 2 && - $0.1[1].contains(corepackPath.path) && - $0.1[1].contains("yarn --cwd excalidraw-app build") - }) - ) + func all() -> [(String, [String], String?)] { + values + } } - func testSessionOriginStorePersistsPreferredPort() { - let root = temporaryExcalidrawRoot() - defer { try? FileManager.default.removeItem(at: root) } + let recorder = InvocationRecorder() - let sessionID = UUID() - let paths = ExcalidrawRuntimePaths(sessionID: sessionID, rootDirectoryOverride: root) - let store = ExcalidrawSessionOriginStore(recordURL: paths.originsRecordPath, portBase: 47_000, portSpan: 3_000) + let runner = StubExcalidrawProcessRunner { executable, arguments, currentDirectory in + await recorder.append((executable, arguments, currentDirectory)) - let preferred = store.preferredPort(for: sessionID) - XCTAssertTrue((47_000..<50_000).contains(preferred)) - - store.persistPort(55_555, for: sessionID) - - let reloaded = ExcalidrawSessionOriginStore(recordURL: paths.originsRecordPath, portBase: 47_000, portSpan: 3_000) - XCTAssertEqual(reloaded.preferredPort(for: sessionID), 55_555) - } - - func testSessionOriginStoreRemovePortPrunesSessionEntry() throws { - let root = temporaryExcalidrawRoot() - defer { try? FileManager.default.removeItem(at: root) } - - struct OriginRecordMirror: Codable { - var portsBySessionID: [String: Int] + if executable == "/usr/bin/which", let tool = arguments.first { + switch tool { + case "git": + return ProcessResult(exitCode: 0, stdout: "/usr/bin/git\n", stderr: "") + case "node", "yarn": + return ProcessResult(exitCode: 1, stdout: "", stderr: "not found") + default: + return ProcessResult(exitCode: 1, stdout: "", stderr: "") } + } + + if executable == "/bin/zsh", arguments == ["-lc", "whence -p node"] { + return ProcessResult(exitCode: 1, stdout: "", stderr: "") + } + + if executable == "/bin/zsh", arguments == ["-ilc", "whence -p node"] { + return ProcessResult( + exitCode: 0, + stdout: """ + Dotfiles have changed remotely and locally: + M zsh/.zshrc + Seems unixorn/autoupdate-antigen.zshplugin is already installed! + \(nodePath.path) + """, + stderr: "" + ) + } + + if executable == "/bin/zsh", + arguments == ["-lc", "whence -p yarn"] || arguments == ["-ilc", "whence -p yarn"] + { + return ProcessResult( + exitCode: 1, + stdout: """ + Dotfiles have changed remotely and locally: + M zsh/.zshrc + Seems unixorn/autoupdate-antigen.zshplugin is already installed! + """, + stderr: "" + ) + } - let firstSessionID = UUID() - let secondSessionID = UUID() - let paths = ExcalidrawRuntimePaths(sessionID: firstSessionID, rootDirectoryOverride: root) - let store = ExcalidrawSessionOriginStore(recordURL: paths.originsRecordPath, portBase: 47_000, portSpan: 3_000) - - store.persistPort(55_001, for: firstSessionID) - store.persistPort(55_002, for: secondSessionID) - store.removePort(for: firstSessionID) - - let data = try Data(contentsOf: paths.originsRecordPath) - let record = try JSONDecoder().decode(OriginRecordMirror.self, from: data) - - XCTAssertNil(record.portsBySessionID[firstSessionID.uuidString]) - XCTAssertEqual(record.portsBySessionID[secondSessionID.uuidString], 55_002) + if executable == "/usr/bin/git", arguments.first == "clone" { + try FileManager.default.createDirectory(at: paths.sourceDirectory, withIntermediateDirectories: true) + try FileManager.default.createDirectory( + at: paths.sourceDirectory.appendingPathComponent(".git", isDirectory: true), + withIntermediateDirectories: true + ) + } + + if executable == "/usr/bin/git", arguments.contains("rev-parse") { + return ProcessResult(exitCode: 0, stdout: "abc123\n", stderr: "") + } + + if executable == "/bin/zsh", + arguments.first == "-lc", + arguments.count == 2, + arguments[1].contains("yarn --cwd excalidraw-app build") + { + let artifact = paths.sourceDirectory.appendingPathComponent(manifest.entrypoint, isDirectory: false) + try FileManager.default.createDirectory(at: artifact.deletingLastPathComponent(), withIntermediateDirectories: true) + try Data().write(to: artifact) + } + + return ProcessResult(exitCode: 0, stdout: "", stderr: "") } - private func temporaryExcalidrawRoot() -> URL { - let root = FileManager.default.temporaryDirectory - .appendingPathComponent("idx0-excalidraw-runtime-tests-\(UUID().uuidString)", isDirectory: true) - try? FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) - return root + let coordinator = ExcalidrawBuildCoordinator(processRunner: runner, fileManager: .default) + _ = try await coordinator.ensureBuilt(manifest: manifest, paths: paths) + + let invocations = await recorder.all() + let shellInvocations = invocations.filter { $0.0 == "/bin/zsh" && $0.1.first == "-lc" } + + XCTAssertTrue( + shellInvocations.contains(where: { + $0.1.count == 2 && + $0.1[1].contains(corepackPath.path) && + $0.1[1].contains("yarn install --frozen-lockfile") + }) + ) + XCTAssertTrue( + shellInvocations.contains(where: { + $0.1.count == 2 && + $0.1[1].contains(corepackPath.path) && + $0.1[1].contains("yarn --cwd excalidraw-app build") + }) + ) + } + + func testSessionOriginStorePersistsPreferredPort() { + let root = temporaryExcalidrawRoot() + defer { try? FileManager.default.removeItem(at: root) } + + let sessionID = UUID() + let paths = ExcalidrawRuntimePaths(sessionID: sessionID, rootDirectoryOverride: root) + let store = ExcalidrawSessionOriginStore(recordURL: paths.originsRecordPath, portBase: 47000, portSpan: 3000) + + let preferred = store.preferredPort(for: sessionID) + XCTAssertTrue((47000 ..< 50000).contains(preferred)) + + store.persistPort(55555, for: sessionID) + + let reloaded = ExcalidrawSessionOriginStore(recordURL: paths.originsRecordPath, portBase: 47000, portSpan: 3000) + XCTAssertEqual(reloaded.preferredPort(for: sessionID), 55555) + } + + func testSessionOriginStoreRemovePortPrunesSessionEntry() throws { + let root = temporaryExcalidrawRoot() + defer { try? FileManager.default.removeItem(at: root) } + + struct OriginRecordMirror: Codable { + var portsBySessionID: [String: Int] } - private func writeExecutable(_ content: String, to path: URL) throws { - try content.write(to: path, atomically: true, encoding: .utf8) - try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: path.path) - } + let firstSessionID = UUID() + let secondSessionID = UUID() + let paths = ExcalidrawRuntimePaths(sessionID: firstSessionID, rootDirectoryOverride: root) + let store = ExcalidrawSessionOriginStore(recordURL: paths.originsRecordPath, portBase: 47000, portSpan: 3000) + + store.persistPort(55001, for: firstSessionID) + store.persistPort(55002, for: secondSessionID) + store.removePort(for: firstSessionID) + + let data = try Data(contentsOf: paths.originsRecordPath) + let record = try JSONDecoder().decode(OriginRecordMirror.self, from: data) + + XCTAssertNil(record.portsBySessionID[firstSessionID.uuidString]) + XCTAssertEqual(record.portsBySessionID[secondSessionID.uuidString], 55002) + } + + private func temporaryExcalidrawRoot() -> URL { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("idx0-excalidraw-runtime-tests-\(UUID().uuidString)", isDirectory: true) + try? FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + return root + } + + private func writeExecutable(_ content: String, to path: URL) throws { + try content.write(to: path, atomically: true, encoding: .utf8) + try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: path.path) + } } private struct StubExcalidrawProcessRunner: ProcessRunnerProtocol { - let block: @Sendable (String, [String], String?) async throws -> ProcessResult - - init(block: @escaping @Sendable (String, [String], String?) async throws -> ProcessResult) { - self.block = block - } + let block: @Sendable (String, [String], String?) async throws -> ProcessResult - func run(executable: String, arguments: [String], currentDirectory: String?) async throws -> ProcessResult { - try await block(executable, arguments, currentDirectory) - } + func run(executable: String, arguments: [String], currentDirectory: String?) async throws -> ProcessResult { + try await block(executable, arguments, currentDirectory) + } } diff --git a/idx0Tests/Apps/T3Code/T3CodeRuntimeTests.swift b/idx0Tests/Apps/T3Code/T3CodeRuntimeTests.swift index 8a25cc5..0e4e64b 100644 --- a/idx0Tests/Apps/T3Code/T3CodeRuntimeTests.swift +++ b/idx0Tests/Apps/T3Code/T3CodeRuntimeTests.swift @@ -1,186 +1,248 @@ import Foundation -import XCTest @testable import idx0 +import XCTest @MainActor final class T3CodeRuntimeTests: XCTestCase { - func testPrepareSessionSnapshotCreatesStateDirectory() throws { - let root = temporaryT3Root() - defer { try? FileManager.default.removeItem(at: root) } - - let sessionID = UUID() - let paths = T3RuntimePaths(sessionID: sessionID, rootDirectoryOverride: root) - let manager = T3StateSnapshotManager() - - defer { - manager.removeSessionSnapshot(paths: paths) - try? FileManager.default.removeItem(at: root) - } + func testPrepareSessionSnapshotCreatesStateDirectory() throws { + let root = temporaryT3Root() + defer { try? FileManager.default.removeItem(at: root) } - let stateURL = try manager.prepareSessionSnapshot(paths: paths) - var isDirectory: ObjCBool = false - let exists = FileManager.default.fileExists(atPath: stateURL.path, isDirectory: &isDirectory) - - XCTAssertTrue(exists) - XCTAssertTrue(isDirectory.boolValue) - XCTAssertEqual(stateURL.path, paths.sessionStateDirectory.path) - } - - func testRemoveSessionSnapshotDeletesSessionDirectory() throws { - let root = temporaryT3Root() - defer { try? FileManager.default.removeItem(at: root) } - - let sessionID = UUID() - let paths = T3RuntimePaths(sessionID: sessionID, rootDirectoryOverride: root) - let manager = T3StateSnapshotManager() - try paths.ensureBaseDirectories() - try FileManager.default.createDirectory(at: paths.sessionStateDirectory, withIntermediateDirectories: true) - let marker = paths.sessionStateDirectory.appendingPathComponent("marker.txt", isDirectory: false) - try "marker".write(to: marker, atomically: true, encoding: .utf8) - - XCTAssertTrue(FileManager.default.fileExists(atPath: marker.path)) - manager.removeSessionSnapshot(paths: paths) - XCTAssertFalse(FileManager.default.fileExists(atPath: paths.sessionDirectory.path)) - } + let sessionID = UUID() + let paths = T3RuntimePaths(sessionID: sessionID, rootDirectoryOverride: root) + let manager = T3StateSnapshotManager() - func testT3TileRuntimeStateDisplayMessagesAreStable() { - XCTAssertEqual(T3TileRuntimeState.idle.displayMessage, "Ready") - XCTAssertEqual(T3TileRuntimeState.preparingSource.displayMessage, "Preparing T3 Code source...") - XCTAssertEqual(T3TileRuntimeState.building.displayMessage, "Building T3 Code...") - XCTAssertEqual(T3TileRuntimeState.starting.displayMessage, "Starting T3 Code...") - XCTAssertEqual(T3TileRuntimeState.live(urlString: "http://127.0.0.1:9999").displayMessage, "Live") + defer { + manager.removeSessionSnapshot(paths: paths) + try? FileManager.default.removeItem(at: root) } - func testManifestNormalizesLegacyRepositoryAndBuildCommand() { - let legacy = T3BuildManifest( - repositoryURL: "https://github.com/t3dotgg/t3.chat.git", - pinnedCommit: "abc123", - installCommand: "bun install --frozen-lockfile", - buildCommand: "bun run --cwd apps/server build", - entrypoint: "apps/server/dist/index.cjs", - requiredArtifacts: [ - "apps/server/dist/index.cjs", - "apps/server/dist/client/index.html" - ] - ) - - let normalized = legacy.normalized() - XCTAssertEqual(normalized.repositoryURL, T3BuildManifest.canonicalRepositoryURL) - XCTAssertEqual(normalized.buildCommand, T3BuildManifest.canonicalBuildCommand) - XCTAssertEqual(normalized.entrypoint, T3BuildManifest.canonicalEntrypoint) - XCTAssertTrue(normalized.requiredArtifacts.contains(T3BuildManifest.canonicalEntrypoint)) - XCTAssertFalse(normalized.requiredArtifacts.contains("apps/server/dist/index.cjs")) - } - - func testBuildCoordinatorFailsWhenBunMissing() async throws { - let root = temporaryT3Root() - defer { try? FileManager.default.removeItem(at: root) } - - let paths = T3RuntimePaths(sessionID: UUID(), rootDirectoryOverride: root) - let runner = StubProcessRunner { executable, arguments, _ in - guard executable == "/usr/bin/which", let tool = arguments.first else { - return ProcessResult(exitCode: 0, stdout: "", stderr: "") - } - - if tool == "bun" { - return ProcessResult(exitCode: 1, stdout: "", stderr: "not found") - } - return ProcessResult(exitCode: 0, stdout: "/usr/bin/\(tool)", stderr: "") + let stateURL = try manager.prepareSessionSnapshot(paths: paths) + var isDirectory: ObjCBool = false + let exists = FileManager.default.fileExists(atPath: stateURL.path, isDirectory: &isDirectory) + + XCTAssertTrue(exists) + XCTAssertTrue(isDirectory.boolValue) + XCTAssertEqual(stateURL.path, paths.sessionStateDirectory.path) + } + + func testRemoveSessionSnapshotDeletesSessionDirectory() throws { + let root = temporaryT3Root() + defer { try? FileManager.default.removeItem(at: root) } + + let sessionID = UUID() + let paths = T3RuntimePaths(sessionID: sessionID, rootDirectoryOverride: root) + let manager = T3StateSnapshotManager() + try paths.ensureBaseDirectories() + try FileManager.default.createDirectory(at: paths.sessionStateDirectory, withIntermediateDirectories: true) + let marker = paths.sessionStateDirectory.appendingPathComponent("marker.txt", isDirectory: false) + try "marker".write(to: marker, atomically: true, encoding: .utf8) + + XCTAssertTrue(FileManager.default.fileExists(atPath: marker.path)) + manager.removeSessionSnapshot(paths: paths) + XCTAssertFalse(FileManager.default.fileExists(atPath: paths.sessionDirectory.path)) + } + + func testT3TileRuntimeStateDisplayMessagesAreStable() { + XCTAssertEqual(T3TileRuntimeState.idle.displayMessage, "Ready") + XCTAssertEqual(T3TileRuntimeState.preparingSource.displayMessage, "Preparing T3 Code source...") + XCTAssertEqual(T3TileRuntimeState.building.displayMessage, "Building T3 Code...") + XCTAssertEqual(T3TileRuntimeState.starting.displayMessage, "Starting T3 Code...") + XCTAssertEqual(T3TileRuntimeState.live(urlString: "http://127.0.0.1:9999").displayMessage, "Live") + } + + func testManifestNormalizesLegacyRepositoryAndBuildCommand() { + let legacy = T3BuildManifest( + repositoryURL: "https://github.com/t3dotgg/t3.chat.git", + pinnedCommit: "abc123", + installCommand: "bun install --frozen-lockfile", + buildCommand: "bun run --cwd apps/server build", + entrypoint: "apps/server/dist/index.cjs", + requiredArtifacts: [ + "apps/server/dist/index.cjs", + "apps/server/dist/client/index.html", + ] + ) + + let normalized = legacy.normalized() + XCTAssertEqual(normalized.repositoryURL, T3BuildManifest.canonicalRepositoryURL) + XCTAssertEqual(normalized.buildCommand, T3BuildManifest.canonicalBuildCommand) + XCTAssertEqual(normalized.entrypoint, T3BuildManifest.canonicalEntrypoint) + XCTAssertTrue(normalized.requiredArtifacts.contains(T3BuildManifest.canonicalEntrypoint)) + XCTAssertFalse(normalized.requiredArtifacts.contains("apps/server/dist/index.cjs")) + } + + func testPreferredBaseDirectoryFlagPrefersHomeDir() { + let helpText = """ + FLAGS + --state-dir string + --home-dir string + """ + + XCTAssertEqual( + T3TileController.preferredBaseDirectoryFlag(fromHelpText: helpText), + "--home-dir" + ) + } + + func testPreferredBaseDirectoryFlagFallsBackToStateDir() { + let helpText = """ + FLAGS + --state-dir string + """ + + XCTAssertEqual( + T3TileController.preferredBaseDirectoryFlag(fromHelpText: helpText), + "--state-dir" + ) + } + + func testPreferredBaseDirectoryFlagReturnsNilWhenNoKnownFlagPresent() { + let helpText = """ + FLAGS + --port integer + --host string + """ + + XCTAssertNil(T3TileController.preferredBaseDirectoryFlag(fromHelpText: helpText)) + } + + func testBuildCoordinatorFailsWhenBunMissing() async throws { + let root = temporaryT3Root() + defer { try? FileManager.default.removeItem(at: root) } + + let paths = T3RuntimePaths(sessionID: UUID(), rootDirectoryOverride: root) + let runner = StubProcessRunner { executable, arguments, _ in + if executable == "/usr/bin/which", let tool = arguments.first { + if tool == "bun" { + return ProcessResult(exitCode: 1, stdout: "", stderr: "not found") } - let coordinator = T3BuildCoordinator(processRunner: runner, fileManager: .default) - - do { - _ = try await coordinator.ensureBuilt(manifest: .default, paths: paths) - XCTFail("Expected missing-tool error") - } catch let error as T3RuntimeError { - guard case .missingTool(let name) = error else { - XCTFail("Unexpected T3 runtime error: \(error)") - return - } - XCTAssertEqual(name, "bun") - } - } + return ProcessResult(exitCode: 0, stdout: "/usr/bin/\(tool)", stderr: "") + } - func testBuildCoordinatorReusesExistingArtifactsWithoutInvokingRunner() async throws { - let root = temporaryT3Root() - defer { try? FileManager.default.removeItem(at: root) } - - let paths = T3RuntimePaths(sessionID: UUID(), rootDirectoryOverride: root) - try paths.ensureBaseDirectories() + if executable == "/usr/bin/git", arguments.first == "clone" { try FileManager.default.createDirectory(at: paths.sourceDirectory, withIntermediateDirectories: true) - - let manifest = T3BuildManifest( - repositoryURL: "https://example.com/unused.git", - pinnedCommit: "abc123", - installCommand: "unused", - buildCommand: "unused", - entrypoint: "apps/server/dist/index.mjs", - requiredArtifacts: [ - "apps/server/dist/index.mjs", - "apps/server/dist/client/index.html" - ] + try FileManager.default.createDirectory( + at: paths.sourceDirectory.appendingPathComponent(".git", isDirectory: true), + withIntermediateDirectories: true ) + return ProcessResult(exitCode: 0, stdout: "", stderr: "") + } - for artifact in manifest.requiredArtifacts { - let artifactURL = paths.sourceDirectory.appendingPathComponent(artifact, isDirectory: false) - try FileManager.default.createDirectory(at: artifactURL.deletingLastPathComponent(), withIntermediateDirectories: true) - try Data().write(to: artifactURL) - } - - struct BuildRecordMirror: Codable { - let pinnedCommit: String - let entrypoint: String - let builtAt: Date - } + if executable == "/usr/bin/git", arguments.contains("rev-parse") { + return ProcessResult(exitCode: 0, stdout: "abc123\n", stderr: "") + } - let record = BuildRecordMirror( - pinnedCommit: manifest.pinnedCommit, - entrypoint: manifest.entrypoint, - builtAt: Date() - ) - let recordData = try JSONEncoder().encode(record) - try recordData.write(to: paths.buildRecordPath, options: .atomic) + return ProcessResult(exitCode: 0, stdout: "", stderr: "") + } + let coordinator = T3BuildCoordinator(processRunner: runner, fileManager: .default) + + do { + _ = try await coordinator.ensureBuilt(manifest: .default, paths: paths) + XCTFail("Expected missing-tool error") + } catch let error as T3RuntimeError { + guard case let .missingTool(name) = error else { + XCTFail("Unexpected T3 runtime error: \(error)") + return + } + XCTAssertEqual(name, "bun") + } + } + + func testBuildCoordinatorReusesExistingArtifactsWithoutInvokingNodeOrBunChecks() async throws { + let root = temporaryT3Root() + defer { try? FileManager.default.removeItem(at: root) } + + let paths = T3RuntimePaths(sessionID: UUID(), rootDirectoryOverride: root) + try paths.ensureBaseDirectories() + try FileManager.default.createDirectory(at: paths.sourceDirectory, withIntermediateDirectories: true) + try FileManager.default.createDirectory( + at: paths.sourceDirectory.appendingPathComponent(".git", isDirectory: true), + withIntermediateDirectories: true + ) + + let manifest = T3BuildManifest( + repositoryURL: "https://example.com/unused.git", + pinnedCommit: "abc123", + installCommand: "unused", + buildCommand: "unused", + entrypoint: "apps/server/dist/index.mjs", + requiredArtifacts: [ + "apps/server/dist/index.mjs", + "apps/server/dist/client/index.html", + ] + ) + + for artifact in manifest.requiredArtifacts { + let artifactURL = paths.sourceDirectory.appendingPathComponent(artifact, isDirectory: false) + try FileManager.default.createDirectory(at: artifactURL.deletingLastPathComponent(), withIntermediateDirectories: true) + try Data().write(to: artifactURL) + } - actor Counter { - var value = 0 + struct BuildRecordMirror: Codable { + let sourceCommit: String + let entrypoint: String + let builtAt: Date + } - func increment() { - value += 1 - } + let resolvedSourceCommit = "abc123" + let record = BuildRecordMirror( + sourceCommit: resolvedSourceCommit, + entrypoint: manifest.entrypoint, + builtAt: Date() + ) + let recordData = try JSONEncoder().encode(record) + try recordData.write(to: paths.buildRecordPath, options: .atomic) + + actor InvocationRecorder { + var values: [(String, [String])] = [] + + func append(_ value: (String, [String])) { + values.append(value) + } + + func all() -> [(String, [String])] { + values + } + } - func current() -> Int { - value - } - } - let counter = Counter() - let runner = StubProcessRunner { _, _, _ in - await counter.increment() - return ProcessResult(exitCode: 0, stdout: "", stderr: "") + let recorder = InvocationRecorder() + let runner = StubProcessRunner { executable, arguments, _ in + await recorder.append((executable, arguments)) + if executable == "/usr/bin/which", let tool = arguments.first { + if tool == "node" || tool == "bun" { + XCTFail("Node/Bun checks should be skipped when latest build artifacts are reusable") } - let coordinator = T3BuildCoordinator(processRunner: runner, fileManager: .default) + return ProcessResult(exitCode: 0, stdout: "/usr/bin/\(tool)\n", stderr: "") + } - let entrypoint = try await coordinator.ensureBuilt(manifest: manifest, paths: paths) - let invocationCount = await counter.current() - XCTAssertEqual(entrypoint.path, paths.sourceDirectory.appendingPathComponent(manifest.entrypoint, isDirectory: false).path) - XCTAssertEqual(invocationCount, 0) - } + if executable == "/usr/bin/git", arguments.contains("rev-parse") { + return ProcessResult(exitCode: 0, stdout: "\(resolvedSourceCommit)\n", stderr: "") + } - private func temporaryT3Root() -> URL { - let root = FileManager.default.temporaryDirectory - .appendingPathComponent("idx0-t3-runtime-tests-\(UUID().uuidString)", isDirectory: true) - try? FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) - return root + return ProcessResult(exitCode: 0, stdout: "", stderr: "") } + let coordinator = T3BuildCoordinator(processRunner: runner, fileManager: .default) + + let entrypoint = try await coordinator.ensureBuilt(manifest: manifest, paths: paths) + let invocations = await recorder.all() + XCTAssertEqual(entrypoint.path, paths.sourceDirectory.appendingPathComponent(manifest.entrypoint, isDirectory: false).path) + XCTAssertTrue(invocations.contains(where: { $0.0 == "/usr/bin/git" && $0.1.contains("fetch") })) + XCTAssertFalse(invocations.contains(where: { $0.0 == "/usr/bin/git" && $0.1.contains("checkout") })) + } + + private func temporaryT3Root() -> URL { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("idx0-t3-runtime-tests-\(UUID().uuidString)", isDirectory: true) + try? FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + return root + } } private struct StubProcessRunner: ProcessRunnerProtocol { - let block: @Sendable (String, [String], String?) async throws -> ProcessResult - - init(block: @escaping @Sendable (String, [String], String?) async throws -> ProcessResult) { - self.block = block - } + let block: @Sendable (String, [String], String?) async throws -> ProcessResult - func run(executable: String, arguments: [String], currentDirectory: String?) async throws -> ProcessResult { - try await block(executable, arguments, currentDirectory) - } + func run(executable: String, arguments: [String], currentDirectory: String?) async throws -> ProcessResult { + try await block(executable, arguments, currentDirectory) + } }