diff --git a/Sources/Cutmark/AgentSetup.swift b/Sources/Cutmark/AgentSetup.swift index ef07e18..4827dc3 100644 --- a/Sources/Cutmark/AgentSetup.swift +++ b/Sources/Cutmark/AgentSetup.swift @@ -7,7 +7,7 @@ import Foundation /// app moved. It changes only Cutmark-owned MCP entries. The skills the build ships in /// Resources/skills go to ~/.claude/skills (Claude Code, /// Cursor) and ~/.agents/skills (Codex, pi, opencode, Gemini CLI). It touches only the `cutmark` -/// server and the skill folders it ships. In the background, once per launch. +/// server and installs missing skill folders. Existing skill folders remain user-owned. enum AgentSetup { static func run() { let resources = Bundle.main.bundleURL.appending(path: "Contents/Resources") @@ -23,15 +23,104 @@ enum AgentSetup { } } - /// Copies each shipped skill folder over the user's copy of the same name. Only these names. - nonisolated static func copySkills(from dir: URL, to target: URL = FileManager.default.homeDirectoryForCurrentUser.appending(path: ".claude/skills")) { + nonisolated struct SkillSyncResult { + var installed: [String] = [] + var preserved: [String] = [] + var failed: [String: String] = [:] + } + + /// Install a missing skill once. Existing same-name folders can be customized or from an + /// older version with no provenance, so upgrading them requires a deliberate manual action. + @discardableResult + nonisolated static func copySkills(from dir: URL, + to target: URL = FileManager.default.homeDirectoryForCurrentUser.appending(path: ".claude/skills")) -> SkillSyncResult { + let fm = FileManager.default + var result = SkillSyncResult() + do { + let sources = try fm.contentsOfDirectory(at: dir, includingPropertiesForKeys: nil) + guard !sources.isEmpty else { return result } + try requireSafeTargetPath(target) + try fm.createDirectory(at: target, withIntermediateDirectories: true) + for source in sources.sorted(by: { $0.lastPathComponent < $1.lastPathComponent }) { + guard fm.fileExists(atPath: source.appending(path: "SKILL.md").path) else { continue } + let name = source.lastPathComponent + let destination = target.appending(path: name) + if existsIncludingSymlinks(destination) { + result.preserved.append(name) + continue + } + do { + try requireSourceTree(source) + let stage = target.appending(path: ".cutmark-stage-\(UUID().uuidString)") + defer { try? fm.removeItem(at: stage) } + try fm.copyItem(at: source, to: stage) + try requireSourceTree(stage) + try requireSafeTargetPath(target) + // Another process may have installed this skill while the copy was staged. + if existsIncludingSymlinks(destination) { + result.preserved.append(name) + continue + } + // Rename is atomic on this volume and never replaces an existing directory. + try fm.moveItem(at: stage, to: destination) + result.installed.append(name) + } catch { + result.failed[name] = error.localizedDescription + } + } + } catch { + result.failed["setup"] = error.localizedDescription + } + return result + } + + nonisolated private static func existsIncludingSymlinks(_ url: URL) -> Bool { + let fm = FileManager.default + return fm.fileExists(atPath: url.path) + || (try? fm.destinationOfSymbolicLink(atPath: url.path)) != nil + } + + /// Reject symlinked directories or files inside a bundled skill before copying them. + nonisolated private static func requireSourceTree(_ root: URL) throws { + let fm = FileManager.default + func visit(_ path: URL) throws { + let values = try path.resourceValues(forKeys: [.isDirectoryKey, .isRegularFileKey, .isSymbolicLinkKey]) + guard values.isSymbolicLink != true else { throw CocoaError(.fileReadCorruptFile) } + if values.isDirectory == true { + for child in try fm.contentsOfDirectory(at: path, includingPropertiesForKeys: nil) { + try visit(child) + } + } else if values.isRegularFile != true { + throw CocoaError(.fileReadCorruptFile) + } + } + try visit(root) + let skill = root.appending(path: "SKILL.md") + guard (try skill.resourceValues(forKeys: [.isRegularFileKey])).isRegularFile == true else { + throw CocoaError(.fileReadCorruptFile) + } + } + + /// Do not write through a redirected ~/.claude, ~/.agents or skills ancestor. + /// Stop at the home/temp root to allow the macOS /var -> /private/var system alias. + nonisolated private static func requireSafeTargetPath(_ target: URL) throws { let fm = FileManager.default - guard let skills = try? fm.contentsOfDirectory(at: dir, includingPropertiesForKeys: nil) else { return } - try? fm.createDirectory(at: target, withIntermediateDirectories: true) - for s in skills where fm.fileExists(atPath: s.appending(path: "SKILL.md").path) { - let dest = target.appending(path: s.lastPathComponent) - try? fm.removeItem(at: dest) - try? fm.copyItem(at: s, to: dest) + let boundaries = Set([fm.homeDirectoryForCurrentUser, fm.temporaryDirectory] + .map { $0.standardizedFileURL.resolvingSymlinksInPath().path }) + var cursor = target.standardizedFileURL + while cursor.path != "/" { + if let values = try? cursor.resourceValues(forKeys: [.isDirectoryKey, .isSymbolicLinkKey]) { + guard values.isSymbolicLink != true, + values.isDirectory == true || !fm.fileExists(atPath: cursor.path) else { + throw CocoaError(.fileReadCorruptFile) + } + } else if fm.fileExists(atPath: cursor.path) { + throw CocoaError(.fileReadCorruptFile) + } + if boundaries.contains(cursor.resolvingSymlinksInPath().path) { break } + let parent = cursor.deletingLastPathComponent() + if parent.path == cursor.path { break } + cursor = parent } } diff --git a/Sources/Cutmark/AppModel.swift b/Sources/Cutmark/AppModel.swift index a455b7e..541de57 100644 --- a/Sources/Cutmark/AppModel.swift +++ b/Sources/Cutmark/AppModel.swift @@ -316,7 +316,9 @@ final class AppModel { } if let doc = library.current { _ = chats.chat(doc.url); wire(doc); shown = doc.url } screen.refreshDisplays() - Task { await camera.boot() } + // SwiftPM's test host has no camera/microphone usage descriptions. Snapshot and model + // tests don't need capture permissions, and TCC aborts the host if they are requested. + if !Self.testing { Task { await camera.boot() } } if !Self.testing { Store.markRecording(library.root, false) // a marker a crash left behind Task { [weak self] in await self?.recoverInterruptedRecordings() } diff --git a/Sources/Cutmark/Onboarding.swift b/Sources/Cutmark/Onboarding.swift index 4de6f4a..af8f1ff 100644 --- a/Sources/Cutmark/Onboarding.swift +++ b/Sources/Cutmark/Onboarding.swift @@ -454,22 +454,45 @@ private struct StartPage: View { init(flow: Onboarding) { self.flow = flow } var body: some View { + Group { + if setup.needed { + // Keep the first-run actions reachable at smaller window sizes and larger text. + ScrollView(.vertical) { content } + .scrollIndicators(.hidden) + } else { + content + } + } + // The cursor waits in the field, so typing the idea is the first thing that works. + .onAppear { if Onboarding.canWrite { DispatchQueue.main.async { typing = true } } } + .onChange(of: Onboarding.canWrite) { _, ready in if ready { typing = true } } + .background(alignment: .top) { + RadialGradient(colors: [Theme.accent.opacity(0.18), .clear], center: .top, startRadius: 0, endRadius: 380) + .frame(height: 300).allowsHitTesting(false) + } + } + + private var content: some View { VStack(spacing: 0) { - Spacer(minLength: 24) + Spacer(minLength: setup.needed ? 12 : 24) VStack(spacing: 8) { - Text("Make your first video.").font(Theme.display(36)) - Text("Tell Cutmark your idea. It writes the script into a new session, and you read it on camera.") + Text("Make your first video.").font(Theme.display(setup.needed ? 30 : 36)) + Text(setup.needed + ? "Choose an agent for scripts and editing, or start recording now." + : "Tell Cutmark your idea. It writes the script into a new session, and you read it on camera.") .font(Theme.sans(14.5)).foregroundStyle(Theme.muted).multilineTextAlignment(.center) .frame(maxWidth: 580) - Text(Onboarding.sessionLine) - .font(Theme.sans(12.5)).foregroundStyle(Theme.faint).multilineTextAlignment(.center) - .frame(maxWidth: 580) + if !setup.needed { + Text(Onboarding.sessionLine) + .font(Theme.sans(12.5)).foregroundStyle(Theme.faint).multilineTextAlignment(.center) + .frame(maxWidth: 580) + } } .arrive(0) - .padding(.bottom, 28) + .padding(.bottom, setup.needed ? 16 : 28) VStack(alignment: .leading, spacing: 14) { if setup.needed { connect } - ideaBox + if Onboarding.canWrite { ideaBox } } .frame(width: 660) .arrive(1) @@ -478,7 +501,7 @@ private struct StartPage: View { Text("or").font(Theme.sans(12)).foregroundStyle(Theme.faint) Rule().frame(width: 60) } - .padding(.vertical, 14) + .padding(.vertical, setup.needed ? 8 : 14) .arrive(2) Button { flow.recordNow(app: app) } label: { HStack(spacing: 8) { @@ -496,18 +519,11 @@ private struct StartPage: View { } } .buttonStyle(AccentButtonStyle(kind: .quiet)) - .padding(.top, 4) + .padding(.top, setup.needed ? 0 : 4) .arrive(2) Spacer(minLength: 0) } - .frame(maxWidth: .infinity).padding(.bottom, 20) - // The cursor waits in the field, so typing the idea is the first thing that works. - .onAppear { if Onboarding.canWrite { DispatchQueue.main.async { typing = true } } } - .onChange(of: Onboarding.canWrite) { _, ready in if ready { typing = true } } - .background(alignment: .top) { - RadialGradient(colors: [Theme.accent.opacity(0.18), .clear], center: .top, startRadius: 0, endRadius: 380) - .frame(height: 300).allowsHitTesting(false) - } + .frame(maxWidth: .infinity).padding(.bottom, setup.needed ? 12 : 20) } private func number(_ n: Int, done: Bool = false) -> some View { @@ -527,10 +543,29 @@ private struct StartPage: View { number(1, done: Onboarding.canWrite) Text("Connect Cutmark to your agent").font(Theme.sans(13.5, .semibold)) Spacer() - Text("About 2 minutes").font(Theme.sans(11.5)).foregroundStyle(Theme.faint) + if setup.agent == .ok { + Text("About 2 minutes").font(Theme.sans(11.5)).foregroundStyle(Theme.faint) + } else { + CutmarkMenuButton(help: "Choose which AI agent to install") { + HStack(spacing: 5) { + Text(setup.installChoice?.name ?? "Choose agent") + Image(systemName: "chevron.down").font(.system(size: 9, weight: .bold)) + } + .font(Theme.sans(12, .medium)) + .foregroundStyle(Theme.ink) + } content: { + for kind in AgentKind.allCases { + MenuButton(kind.name, checked: setup.installChoice == kind) { setup.chooseAgent(kind) } + } + } + .accessibilityLabel("Choose AI agent to install") + } } HStack(spacing: 8) { - item(setup.agent == .ok ? setup.target.name : "Claude Code", setup.agent, "Install", setup.installAgent) + item(setup.agent == .ok ? setup.target.name : + setup.installChoice.map { "Install \($0.name)" } ?? "Select an agent above", + setup.agent, "Install", setup.installAgent, + enabled: setup.canInstallAgent) item("Sign in", setup.agent == .ok ? setup.signedIn : .missing, "Sign in", setup.signIn, enabled: setup.agent == .ok) } diff --git a/Sources/Cutmark/Setup.swift b/Sources/Cutmark/Setup.swift index ce6de45..548a92f 100644 --- a/Sources/Cutmark/Setup.swift +++ b/Sources/Cutmark/Setup.swift @@ -27,6 +27,8 @@ final class Setup { var target: AgentKind = .claude /// First-run choice only. Nil means the user has not picked a provider yet. var installChoice: AgentKind? + /// The installer is an explicit action for the chosen provider, never the default chat agent. + var canInstallAgent: Bool { agent != .ok && installChoice != nil } var ffmpeg: Status = .unknown var devTools: Status = .unknown private var checking = false @@ -112,7 +114,7 @@ final class Setup { /// Opens the explicitly chosen provider's supported installer in Terminal. The app does not run /// an installer until the user chooses a provider and presses Install. func installAgent() { - guard agent != .ok, let kind = installChoice else { return } + guard canInstallAgent, let kind = installChoice else { return } let install = kind.installation let text = """ #!/bin/bash diff --git a/Tests/CutmarkTests/AgentSetupTests.swift b/Tests/CutmarkTests/AgentSetupTests.swift index ab12568..c50d62d 100644 --- a/Tests/CutmarkTests/AgentSetupTests.swift +++ b/Tests/CutmarkTests/AgentSetupTests.swift @@ -3,17 +3,197 @@ import Testing @testable import Cutmark struct AgentSetupTests { - @Test func copiesOnlyShippedSkills() throws { - let fm = FileManager.default - let tmp = fm.temporaryDirectory.appending(path: UUID().uuidString) - let src = tmp.appending(path: "src"), dst = tmp.appending(path: "dst") - try fm.createDirectory(at: src.appending(path: "cutmark-storyboard"), withIntermediateDirectories: true) - try "new".write(to: src.appending(path: "cutmark-storyboard/SKILL.md"), atomically: true, encoding: .utf8) - try fm.createDirectory(at: dst.appending(path: "mine"), withIntermediateDirectories: true) - try "keep".write(to: dst.appending(path: "mine/SKILL.md"), atomically: true, encoding: .utf8) - AgentSetup.copySkills(from: src, to: dst) - #expect(try String(contentsOf: dst.appending(path: "cutmark-storyboard/SKILL.md"), encoding: .utf8) == "new") - #expect(try String(contentsOf: dst.appending(path: "mine/SKILL.md"), encoding: .utf8) == "keep") - try? fm.removeItem(at: tmp) + private let fm = FileManager.default + + private func fixture() throws -> (root: URL, source: URL, target: URL) { + let root = fm.temporaryDirectory.appending(path: "cutmark-agent-setup-\(UUID().uuidString)") + let source = root.appending(path: "source") + let target = root.appending(path: "target") + try fm.createDirectory(at: source.appending(path: "cutmark-storyboard"), withIntermediateDirectories: true) + try write("v1", to: source.appending(path: "cutmark-storyboard/SKILL.md")) + return (root, source, target) + } + + private func write(_ value: String, to path: URL) throws { + try fm.createDirectory(at: path.deletingLastPathComponent(), withIntermediateDirectories: true) + try value.write(to: path, atomically: true, encoding: .utf8) + } + + private func read(_ path: URL) throws -> String { + try String(contentsOf: path, encoding: .utf8) + } + + @Test func installsOnlyShippedSkillsAndKeepsUnrelatedFolders() throws { + let f = try fixture() + defer { try? fm.removeItem(at: f.root) } + try write("keep", to: f.target.appending(path: "mine/SKILL.md")) + + let first = AgentSetup.copySkills(from: f.source, to: f.target) + #expect(first.installed == ["cutmark-storyboard"]) + #expect(first.failed.isEmpty) + #expect(try read(f.target.appending(path: "cutmark-storyboard/SKILL.md")) == "v1") + #expect(try read(f.target.appending(path: "mine/SKILL.md")) == "keep") + + let repeated = AgentSetup.copySkills(from: f.source, to: f.target) + #expect(repeated.preserved == ["cutmark-storyboard"]) + #expect(repeated.failed.isEmpty) + } + + @Test func bundledUpdateDoesNotReplaceEvenAnUntouchedInstalledSkill() throws { + let f = try fixture() + defer { try? fm.removeItem(at: f.root) } + _ = AgentSetup.copySkills(from: f.source, to: f.target) + + try write("v2", to: f.source.appending(path: "cutmark-storyboard/SKILL.md")) + try write("new reference", to: f.source.appending(path: "cutmark-storyboard/references/example.md")) + let preserved = AgentSetup.copySkills(from: f.source, to: f.target) + #expect(preserved.preserved == ["cutmark-storyboard"]) + #expect(preserved.failed.isEmpty) + #expect(try read(f.target.appending(path: "cutmark-storyboard/SKILL.md")) == "v1") + #expect(!fm.fileExists(atPath: f.target.appending(path: "cutmark-storyboard/references/example.md").path)) + + let repeated = AgentSetup.copySkills(from: f.source, to: f.target) + #expect(repeated.preserved == ["cutmark-storyboard"]) + #expect(repeated.failed.isEmpty) + } + + @Test func preservesEditedManagedSkillAndAddedUserFiles() throws { + let f = try fixture() + defer { try? fm.removeItem(at: f.root) } + _ = AgentSetup.copySkills(from: f.source, to: f.target) + + try write("user edit", to: f.target.appending(path: "cutmark-storyboard/SKILL.md")) + try write("my notes", to: f.target.appending(path: "cutmark-storyboard/notes.md")) + try write("v2", to: f.source.appending(path: "cutmark-storyboard/SKILL.md")) + let preserved = AgentSetup.copySkills(from: f.source, to: f.target) + #expect(preserved.preserved == ["cutmark-storyboard"]) + #expect(preserved.failed.isEmpty) + #expect(try read(f.target.appending(path: "cutmark-storyboard/SKILL.md")) == "user edit") + #expect(try read(f.target.appending(path: "cutmark-storyboard/notes.md")) == "my notes") + } + + @Test func addingOnlyAnExtraFilePreventsReplacement() throws { + let f = try fixture() + defer { try? fm.removeItem(at: f.root) } + _ = AgentSetup.copySkills(from: f.source, to: f.target) + try write("personal reference", to: f.target.appending(path: "cutmark-storyboard/references/custom.md")) + try write("v2", to: f.source.appending(path: "cutmark-storyboard/SKILL.md")) + + let result = AgentSetup.copySkills(from: f.source, to: f.target) + #expect(result.preserved == ["cutmark-storyboard"]) + #expect(try read(f.target.appending(path: "cutmark-storyboard/SKILL.md")) == "v1") + #expect(try read(f.target.appending(path: "cutmark-storyboard/references/custom.md")) == "personal reference") + } + + @Test func preservesLegacyUntrackedFolderEvenWhenSameAsShipped() throws { + let f = try fixture() + defer { try? fm.removeItem(at: f.root) } + try write("v1", to: f.target.appending(path: "cutmark-storyboard/SKILL.md")) + let old = AgentSetup.copySkills(from: f.source, to: f.target) + #expect(old.preserved == ["cutmark-storyboard"]) + #expect(old.failed.isEmpty) + + try write("v2", to: f.source.appending(path: "cutmark-storyboard/SKILL.md")) + let next = AgentSetup.copySkills(from: f.source, to: f.target) + #expect(next.preserved == ["cutmark-storyboard"]) + #expect(try read(f.target.appending(path: "cutmark-storyboard/SKILL.md")) == "v1") + #expect(!fm.fileExists(atPath: f.target.appending(path: "cutmark-storyboard/.cutmark-managed-skill.json").path)) + } + + @Test func preservesFolderWithInvalidReceipt() throws { + let f = try fixture() + defer { try? fm.removeItem(at: f.root) } + _ = AgentSetup.copySkills(from: f.source, to: f.target) + try write("invalid", to: f.target.appending(path: "cutmark-storyboard/.cutmark-managed-skill.json")) + try write("v2", to: f.source.appending(path: "cutmark-storyboard/SKILL.md")) + + let result = AgentSetup.copySkills(from: f.source, to: f.target) + #expect(result.preserved == ["cutmark-storyboard"]) + #expect(try read(f.target.appending(path: "cutmark-storyboard/SKILL.md")) == "v1") + } + + @Test func rejectsSymlinkedSkillSourceAndLeavesUserFilesAlone() throws { + let f = try fixture() + defer { try? fm.removeItem(at: f.root) } + try write("private", to: f.root.appending(path: "private.md")) + try fm.removeItem(at: f.source.appending(path: "cutmark-storyboard/SKILL.md")) + try fm.createSymbolicLink(at: f.source.appending(path: "cutmark-storyboard/SKILL.md"), + withDestinationURL: f.root.appending(path: "private.md")) + + let result = AgentSetup.copySkills(from: f.source, to: f.target) + #expect(result.failed["cutmark-storyboard"] != nil) + #expect(!fm.fileExists(atPath: f.target.appending(path: "cutmark-storyboard").path)) + #expect(try read(f.root.appending(path: "private.md")) == "private") + } + + @Test func existingSymlinkTargetCannotBeReplaced() throws { + let f = try fixture() + defer { try? fm.removeItem(at: f.root) } + try write("user custom", to: f.root.appending(path: "other/SKILL.md")) + try fm.createDirectory(at: f.target, withIntermediateDirectories: true) + try fm.createSymbolicLink(at: f.target.appending(path: "cutmark-storyboard"), + withDestinationURL: f.root.appending(path: "other")) + + let result = AgentSetup.copySkills(from: f.source, to: f.target) + #expect(result.preserved == ["cutmark-storyboard"]) + #expect(try read(f.root.appending(path: "other/SKILL.md")) == "user custom") + #expect((try f.target.appending(path: "cutmark-storyboard").resourceValues(forKeys: [.isSymbolicLinkKey])).isSymbolicLink == true) + } + + @Test func danglingSymlinkIsNeverReplaced() throws { + let f = try fixture() + defer { try? fm.removeItem(at: f.root) } + try fm.createDirectory(at: f.target, withIntermediateDirectories: true) + let dangling = f.target.appending(path: "cutmark-storyboard") + try fm.createSymbolicLink(at: dangling, withDestinationURL: f.root.appending(path: "missing")) + + let result = AgentSetup.copySkills(from: f.source, to: f.target) + #expect(result.preserved == ["cutmark-storyboard"]) + #expect((try dangling.resourceValues(forKeys: [.isSymbolicLinkKey])).isSymbolicLink == true) + } + + @Test func symlinkedAncestorCannotRedirectSkillInstall() throws { + let f = try fixture() + defer { try? fm.removeItem(at: f.root) } + let external = f.root.appending(path: "external") + try fm.createDirectory(at: external, withIntermediateDirectories: true) + try fm.createSymbolicLink(at: f.root.appending(path: ".agents"), withDestinationURL: external) + + let redirected = f.root.appending(path: ".agents/skills") + let blocked = AgentSetup.copySkills(from: f.source, to: redirected) + #expect(blocked.failed["setup"] != nil) + #expect(!fm.fileExists(atPath: external.appending(path: "skills").path)) + + // The link may also be two levels above the skills root. + try fm.createDirectory(at: f.root.appending(path: "real-config"), withIntermediateDirectories: true) + try fm.createSymbolicLink(at: f.root.appending(path: "config"), + withDestinationURL: f.root.appending(path: "real-config")) + let nested = AgentSetup.copySkills(from: f.source, to: f.root.appending(path: "config/agents/skills")) + #expect(nested.failed["setup"] != nil) + #expect(!fm.fileExists(atPath: f.root.appending(path: "real-config/agents").path)) + } + + @Test func reportsNonDirectoryTargetWithoutChangingIt() throws { + let f = try fixture() + defer { try? fm.removeItem(at: f.root) } + try write("some other file", to: f.target) + let result = AgentSetup.copySkills(from: f.source, to: f.target) + #expect(result.failed["setup"] != nil) + #expect(try read(f.target) == "some other file") + } + + @Test func supportsIndependentAgentDestinations() throws { + let f = try fixture() + defer { try? fm.removeItem(at: f.root) } + let claude = f.root.appending(path: ".claude/skills") + let agents = f.root.appending(path: ".agents/skills") + #expect(AgentSetup.copySkills(from: f.source, to: claude).installed == ["cutmark-storyboard"]) + #expect(AgentSetup.copySkills(from: f.source, to: agents).installed == ["cutmark-storyboard"]) + try write("local tweak", to: claude.appending(path: "cutmark-storyboard/SKILL.md")) + try write("v2", to: f.source.appending(path: "cutmark-storyboard/SKILL.md")) + #expect(AgentSetup.copySkills(from: f.source, to: claude).preserved == ["cutmark-storyboard"]) + #expect(AgentSetup.copySkills(from: f.source, to: agents).preserved == ["cutmark-storyboard"]) + #expect(try read(claude.appending(path: "cutmark-storyboard/SKILL.md")) == "local tweak") + #expect(try read(agents.appending(path: "cutmark-storyboard/SKILL.md")) == "v1") } } diff --git a/Tests/CutmarkTests/OnboardingShots.swift b/Tests/CutmarkTests/OnboardingShots.swift index 79dc879..621f88f 100644 --- a/Tests/CutmarkTests/OnboardingShots.swift +++ b/Tests/CutmarkTests/OnboardingShots.swift @@ -94,10 +94,16 @@ let onboardingDir = ProcessInfo.processInfo.environment["CUTMARK_ONBOARDING"] Look.shared.palette = .graphite defer { Look.shared.reset() } let d = UserDefaults.standard + let previousChatOpen = d.object(forKey: "chatOpen") d.set(false, forKey: "chatOpen") + defer { + if let previousChatOpen { d.set(previousChatOpen, forKey: "chatOpen") } + else { d.removeObject(forKey: "chatOpen") } + } let empty = FileManager.default.temporaryDirectory.appending(path: "cutmark-onboarding-\(UUID().uuidString)") try FileManager.default.createDirectory(at: empty, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: empty) } let app = AppModel() app.library.setRoot(empty) let flow = Onboarding.shared @@ -107,6 +113,17 @@ let onboardingDir = ProcessInfo.processInfo.environment["CUTMARK_ONBOARDING"] .overlay { OnboardingLayer().environment(app) } } let setup = Setup.shared + let previousAgent = setup.agent + let previousSignedIn = setup.signedIn + let previousFFmpeg = setup.ffmpeg + let previousDevTools = setup.devTools + let previousTarget = setup.target + let previousChoice = setup.installChoice + defer { + setup.agent = previousAgent; setup.signedIn = previousSignedIn + setup.ffmpeg = previousFFmpeg; setup.devTools = previousDevTools + setup.target = previousTarget; setup.installChoice = previousChoice + } for dark in [true, false] { let tag = dark ? "" : "-light" for p in 0..