mirror of
https://github.com/awizemann/scarf.git
synced 2026-05-10 10:36:35 +00:00
c800b93804
Shareable `.scarftemplate` bundle format lets users package a project's dashboard, cross-agent AGENTS.md, optional per-agent instruction shims, optional namespaced skills, optional tagged cron jobs, and an optional memory appendix into a single zip that anyone can install with one click. Core: - Bundle format + manifest schema v1 (template.json with contents claim cross-checked against zip entries to prevent hidden files). - ProjectTemplateService inspects + validates + builds an install plan. - ProjectTemplateInstaller executes plans with transport-routed I/O so the v1 local-only flow extends cleanly to remote ServerContexts later. - ProjectTemplateExporter builds bundles from existing projects with user-selected skills + cron jobs. - ProjectTemplateUninstaller reverses installs using template.lock.json. Only lock-tracked files are removed; user-added files are preserved. UI: - Templates menu in Projects toolbar: Install from File, Install from URL, Export as Template. - Preview-and-confirm sheets for install, uninstall, and export with full diff of what will be written/removed before anything runs. - Right-click context menu on project list + dashboard header button for uninstall (only shown when template.lock.json exists). Deep link + file associations: - scarf:// URL scheme registered; onOpenURL in scarfApp.swift routes scarf://install?url=https://... and file:// URLs for .scarftemplate files to the install sheet. - Custom UTType com.scarf.template registered so Finder shows the file with a Scarf icon and double-click opens the install preview. - Cold-launch race fix: .task picks up any URL staged on the router before the onChange observer was installed. Safety: - Never writes to config.yaml, auth.json, sessions, or credentials. - Cron jobs ship paused with a [tmpl:<id>] name prefix. - Skills install to a namespaced ~/.hermes/skills/templates/<slug>/ dir so they never collide with user-authored skills. - Memory appendix is wrapped in scarf-template:<id>:begin/end markers for clean removal during uninstall. - Download cap: 50 MB for URL-fetched templates, enforced on the actual on-disk file size after download so chunked transfers can't bypass it. Tests: 22 tests in 7 suites cover manifest parsing, claim verification, URL routing (scarf:// + file://), end-to-end install and uninstall against a minimal bundle (projects registry is snapshotted + restored), user-added file preservation, and exporter round-trip. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
88 lines
3.8 KiB
Swift
88 lines
3.8 KiB
Swift
import Foundation
|
|
import Observation
|
|
import os
|
|
|
|
/// Process-wide router for `scarf://install?url=…` URLs. The app delegate's
|
|
/// `onOpenURL` hands the URL in here; the Projects feature observes
|
|
/// `pendingInstallURL` and presents the install sheet when it flips non-nil.
|
|
///
|
|
/// Lives outside SwiftUI so a URL can arrive before any window exists (cold
|
|
/// launch from a browser link) and still be picked up by the first
|
|
/// `ProjectsView` that appears.
|
|
@Observable
|
|
@MainActor
|
|
final class TemplateURLRouter {
|
|
private static let logger = Logger(subsystem: "com.scarf", category: "TemplateURLRouter")
|
|
|
|
static let shared = TemplateURLRouter()
|
|
|
|
/// Non-nil when an install request is waiting to be handled. Can be
|
|
/// either a remote `https://…` URL (from a `scarf://install?url=…` deep
|
|
/// link) or a local `file://…` URL (from a Finder double-click on a
|
|
/// `.scarftemplate` file, or a drag onto the app icon). Observers read
|
|
/// this, dispatch by scheme, present the install sheet, then call
|
|
/// `consume` to clear it. Only one pending install at a time — if a
|
|
/// second arrives before the first is consumed, it replaces the first
|
|
/// (matches browser-link intuition where the latest click wins).
|
|
var pendingInstallURL: URL?
|
|
|
|
private init() {}
|
|
|
|
/// Parse and validate an inbound URL. Returns `true` if the URL was
|
|
/// recognized and staged for handling. Unknown schemes or malformed
|
|
/// payloads return `false` so the caller can log/ignore. Supports:
|
|
///
|
|
/// - `scarf://install?url=https://…` — remote template URL from a web link.
|
|
/// - `file:///…/foo.scarftemplate` — local file from a Finder
|
|
/// double-click or a drag onto the app icon.
|
|
@discardableResult
|
|
func handle(_ url: URL) -> Bool {
|
|
if url.isFileURL {
|
|
return handleFileURL(url)
|
|
}
|
|
if url.scheme?.lowercased() == "scarf" {
|
|
return handleScarfURL(url)
|
|
}
|
|
Self.logger.warning("Ignored URL with unknown scheme: \(url.absoluteString, privacy: .public)")
|
|
return false
|
|
}
|
|
|
|
private func handleFileURL(_ url: URL) -> Bool {
|
|
guard url.pathExtension.lowercased() == "scarftemplate" else {
|
|
Self.logger.warning("file:// URL handed to Scarf but not a .scarftemplate: \(url.absoluteString, privacy: .public)")
|
|
return false
|
|
}
|
|
pendingInstallURL = url
|
|
Self.logger.info("file:// install staged \(url.path, privacy: .public)")
|
|
return true
|
|
}
|
|
|
|
private func handleScarfURL(_ url: URL) -> Bool {
|
|
guard url.host?.lowercased() == "install" else {
|
|
Self.logger.warning("Ignored unknown scarf:// host: \(url.absoluteString, privacy: .public)")
|
|
return false
|
|
}
|
|
guard let components = URLComponents(url: url, resolvingAgainstBaseURL: false),
|
|
let raw = components.queryItems?.first(where: { $0.name == "url" })?.value,
|
|
let remote = URL(string: raw) else {
|
|
Self.logger.warning("scarf://install missing or invalid ?url=: \(url.absoluteString, privacy: .public)")
|
|
return false
|
|
}
|
|
// Refuse anything but https — defense-in-depth against a browser or
|
|
// mail client that would happily hand us a javascript: or http://
|
|
// URL pointing at something unexpected.
|
|
guard remote.scheme?.lowercased() == "https" else {
|
|
Self.logger.warning("scarf://install refused non-https url=\(remote.absoluteString, privacy: .public)")
|
|
return false
|
|
}
|
|
pendingInstallURL = remote
|
|
Self.logger.info("scarf://install staged \(remote.absoluteString, privacy: .public)")
|
|
return true
|
|
}
|
|
|
|
/// Called by the install sheet once it has picked up the URL.
|
|
func consume() {
|
|
pendingInstallURL = nil
|
|
}
|
|
}
|