Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
107 changes: 98 additions & 9 deletions Sources/Cutmark/AgentSetup.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand All @@ -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
}
}

Expand Down
4 changes: 3 additions & 1 deletion Sources/Cutmark/AppModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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() }
Expand Down
75 changes: 55 additions & 20 deletions Sources/Cutmark/Onboarding.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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) {
Expand All @@ -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 {
Expand All @@ -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)
}
Expand Down
4 changes: 3 additions & 1 deletion Sources/Cutmark/Setup.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
Loading
Loading