diff --git a/scarf/Scarf iOS/Assets.xcassets/AccentColor.colorset/Contents.json b/scarf/Scarf iOS/Assets.xcassets/AccentColor.colorset/Contents.json
new file mode 100644
index 0000000..eb87897
--- /dev/null
+++ b/scarf/Scarf iOS/Assets.xcassets/AccentColor.colorset/Contents.json
@@ -0,0 +1,11 @@
+{
+ "colors" : [
+ {
+ "idiom" : "universal"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/scarf/Scarf iOS/Assets.xcassets/AppIcon.appiconset/Contents.json b/scarf/Scarf iOS/Assets.xcassets/AppIcon.appiconset/Contents.json
new file mode 100644
index 0000000..2305880
--- /dev/null
+++ b/scarf/Scarf iOS/Assets.xcassets/AppIcon.appiconset/Contents.json
@@ -0,0 +1,35 @@
+{
+ "images" : [
+ {
+ "idiom" : "universal",
+ "platform" : "ios",
+ "size" : "1024x1024"
+ },
+ {
+ "appearances" : [
+ {
+ "appearance" : "luminosity",
+ "value" : "dark"
+ }
+ ],
+ "idiom" : "universal",
+ "platform" : "ios",
+ "size" : "1024x1024"
+ },
+ {
+ "appearances" : [
+ {
+ "appearance" : "luminosity",
+ "value" : "tinted"
+ }
+ ],
+ "idiom" : "universal",
+ "platform" : "ios",
+ "size" : "1024x1024"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/scarf/Scarf iOS/Assets.xcassets/Contents.json b/scarf/Scarf iOS/Assets.xcassets/Contents.json
new file mode 100644
index 0000000..73c0059
--- /dev/null
+++ b/scarf/Scarf iOS/Assets.xcassets/Contents.json
@@ -0,0 +1,6 @@
+{
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/scarf/Scarf iOS/ContentView.swift b/scarf/Scarf iOS/ContentView.swift
new file mode 100644
index 0000000..530b220
--- /dev/null
+++ b/scarf/Scarf iOS/ContentView.swift
@@ -0,0 +1,61 @@
+//
+// ContentView.swift
+// Scarf iOS
+//
+// Created by Alan Wizemann on 4/23/26.
+//
+
+import SwiftUI
+import SwiftData
+
+struct ContentView: View {
+ @Environment(\.modelContext) private var modelContext
+ @Query private var items: [Item]
+
+ var body: some View {
+ NavigationSplitView {
+ List {
+ ForEach(items) { item in
+ NavigationLink {
+ Text("Item at \(item.timestamp, format: Date.FormatStyle(date: .numeric, time: .standard))")
+ } label: {
+ Text(item.timestamp, format: Date.FormatStyle(date: .numeric, time: .standard))
+ }
+ }
+ .onDelete(perform: deleteItems)
+ }
+ .toolbar {
+ ToolbarItem(placement: .navigationBarTrailing) {
+ EditButton()
+ }
+ ToolbarItem {
+ Button(action: addItem) {
+ Label("Add Item", systemImage: "plus")
+ }
+ }
+ }
+ } detail: {
+ Text("Select an item")
+ }
+ }
+
+ private func addItem() {
+ withAnimation {
+ let newItem = Item(timestamp: Date())
+ modelContext.insert(newItem)
+ }
+ }
+
+ private func deleteItems(offsets: IndexSet) {
+ withAnimation {
+ for index in offsets {
+ modelContext.delete(items[index])
+ }
+ }
+ }
+}
+
+#Preview {
+ ContentView()
+ .modelContainer(for: Item.self, inMemory: true)
+}
diff --git a/scarf/Scarf iOS/Info.plist b/scarf/Scarf iOS/Info.plist
new file mode 100644
index 0000000..ca9a074
--- /dev/null
+++ b/scarf/Scarf iOS/Info.plist
@@ -0,0 +1,10 @@
+
+
+
+
+ UIBackgroundModes
+
+ remote-notification
+
+
+
diff --git a/scarf/Scarf iOS/Item.swift b/scarf/Scarf iOS/Item.swift
new file mode 100644
index 0000000..c30c186
--- /dev/null
+++ b/scarf/Scarf iOS/Item.swift
@@ -0,0 +1,18 @@
+//
+// Item.swift
+// Scarf iOS
+//
+// Created by Alan Wizemann on 4/23/26.
+//
+
+import Foundation
+import SwiftData
+
+@Model
+final class Item {
+ var timestamp: Date
+
+ init(timestamp: Date) {
+ self.timestamp = timestamp
+ }
+}
diff --git a/scarf/Scarf iOS/Scarf_iOS.entitlements b/scarf/Scarf iOS/Scarf_iOS.entitlements
new file mode 100644
index 0000000..9e0940e
--- /dev/null
+++ b/scarf/Scarf iOS/Scarf_iOS.entitlements
@@ -0,0 +1,14 @@
+
+
+
+
+ aps-environment
+ development
+ com.apple.developer.icloud-container-identifiers
+
+ com.apple.developer.icloud-services
+
+ CloudKit
+
+
+
diff --git a/scarf/Scarf iOS/Scarf_iOSApp.swift b/scarf/Scarf iOS/Scarf_iOSApp.swift
new file mode 100644
index 0000000..92c8f4e
--- /dev/null
+++ b/scarf/Scarf iOS/Scarf_iOSApp.swift
@@ -0,0 +1,32 @@
+//
+// Scarf_iOSApp.swift
+// Scarf iOS
+//
+// Created by Alan Wizemann on 4/23/26.
+//
+
+import SwiftUI
+import SwiftData
+
+@main
+struct Scarf_iOSApp: App {
+ var sharedModelContainer: ModelContainer = {
+ let schema = Schema([
+ Item.self,
+ ])
+ let modelConfiguration = ModelConfiguration(schema: schema, isStoredInMemoryOnly: false)
+
+ do {
+ return try ModelContainer(for: schema, configurations: [modelConfiguration])
+ } catch {
+ fatalError("Could not create ModelContainer: \(error)")
+ }
+ }()
+
+ var body: some Scene {
+ WindowGroup {
+ ContentView()
+ }
+ .modelContainer(sharedModelContainer)
+ }
+}
diff --git a/scarf/Scarf iOSTests/Scarf_iOSTests.swift b/scarf/Scarf iOSTests/Scarf_iOSTests.swift
new file mode 100644
index 0000000..167b35e
--- /dev/null
+++ b/scarf/Scarf iOSTests/Scarf_iOSTests.swift
@@ -0,0 +1,17 @@
+//
+// Scarf_iOSTests.swift
+// Scarf iOSTests
+//
+// Created by Alan Wizemann on 4/23/26.
+//
+
+import Testing
+@testable import Scarf_iOS
+
+struct Scarf_iOSTests {
+
+ @Test func example() async throws {
+ // Write your test here and use APIs like `#expect(...)` to check expected conditions.
+ }
+
+}
diff --git a/scarf/Scarf iOSUITests/Scarf_iOSUITests.swift b/scarf/Scarf iOSUITests/Scarf_iOSUITests.swift
new file mode 100644
index 0000000..e80e38a
--- /dev/null
+++ b/scarf/Scarf iOSUITests/Scarf_iOSUITests.swift
@@ -0,0 +1,41 @@
+//
+// Scarf_iOSUITests.swift
+// Scarf iOSUITests
+//
+// Created by Alan Wizemann on 4/23/26.
+//
+
+import XCTest
+
+final class Scarf_iOSUITests: XCTestCase {
+
+ override func setUpWithError() throws {
+ // Put setup code here. This method is called before the invocation of each test method in the class.
+
+ // In UI tests it is usually best to stop immediately when a failure occurs.
+ continueAfterFailure = false
+
+ // In UI tests it’s important to set the initial state - such as interface orientation - required for your tests before they run. The setUp method is a good place to do this.
+ }
+
+ override func tearDownWithError() throws {
+ // Put teardown code here. This method is called after the invocation of each test method in the class.
+ }
+
+ @MainActor
+ func testExample() throws {
+ // UI tests must launch the application that they test.
+ let app = XCUIApplication()
+ app.launch()
+
+ // Use XCTAssert and related functions to verify your tests produce the correct results.
+ }
+
+ @MainActor
+ func testLaunchPerformance() throws {
+ // This measures how long it takes to launch your application.
+ measure(metrics: [XCTApplicationLaunchMetric()]) {
+ XCUIApplication().launch()
+ }
+ }
+}
diff --git a/scarf/Scarf iOSUITests/Scarf_iOSUITestsLaunchTests.swift b/scarf/Scarf iOSUITests/Scarf_iOSUITestsLaunchTests.swift
new file mode 100644
index 0000000..0b1db7d
--- /dev/null
+++ b/scarf/Scarf iOSUITests/Scarf_iOSUITestsLaunchTests.swift
@@ -0,0 +1,33 @@
+//
+// Scarf_iOSUITestsLaunchTests.swift
+// Scarf iOSUITests
+//
+// Created by Alan Wizemann on 4/23/26.
+//
+
+import XCTest
+
+final class Scarf_iOSUITestsLaunchTests: XCTestCase {
+
+ override class var runsForEachTargetApplicationUIConfiguration: Bool {
+ true
+ }
+
+ override func setUpWithError() throws {
+ continueAfterFailure = false
+ }
+
+ @MainActor
+ func testLaunch() throws {
+ let app = XCUIApplication()
+ app.launch()
+
+ // Insert steps here to perform after app launch but before taking a screenshot,
+ // such as logging into a test account or navigating somewhere in the app
+
+ let attachment = XCTAttachment(screenshot: app.screenshot())
+ attachment.name = "Launch Screen"
+ attachment.lifetime = .keepAlways
+ add(attachment)
+ }
+}
diff --git a/scarf/scarf.xcodeproj/project.pbxproj b/scarf/scarf.xcodeproj/project.pbxproj
index c02a4fb..1a3de01 100644
--- a/scarf/scarf.xcodeproj/project.pbxproj
+++ b/scarf/scarf.xcodeproj/project.pbxproj
@@ -12,6 +12,20 @@
/* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */
+ 4EAC233A2F99930100654F42 /* PBXContainerItemProxy */ = {
+ isa = PBXContainerItemProxy;
+ containerPortal = 534959382F7B83B600BD31AD /* Project object */;
+ proxyType = 1;
+ remoteGlobalIDString = 4EAC23282F99930000654F42;
+ remoteInfo = "Scarf iOS";
+ };
+ 4EAC23442F99930100654F42 /* PBXContainerItemProxy */ = {
+ isa = PBXContainerItemProxy;
+ containerPortal = 534959382F7B83B600BD31AD /* Project object */;
+ proxyType = 1;
+ remoteGlobalIDString = 4EAC23282F99930000654F42;
+ remoteInfo = "Scarf iOS";
+ };
534959502F7B83B700BD31AD /* PBXContainerItemProxy */ = {
isa = PBXContainerItemProxy;
containerPortal = 534959382F7B83B600BD31AD /* Project object */;
@@ -29,12 +43,22 @@
/* End PBXContainerItemProxy section */
/* Begin PBXFileReference section */
+ 4EAC23292F99930000654F42 /* scarf mobile.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "scarf mobile.app"; sourceTree = BUILT_PRODUCTS_DIR; };
+ 4EAC23392F99930100654F42 /* Scarf iOSTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = "Scarf iOSTests.xctest"; sourceTree = BUILT_PRODUCTS_DIR; };
+ 4EAC23432F99930100654F42 /* Scarf iOSUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = "Scarf iOSUITests.xctest"; sourceTree = BUILT_PRODUCTS_DIR; };
534959402F7B83B600BD31AD /* scarf.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = scarf.app; sourceTree = BUILT_PRODUCTS_DIR; };
5349594F2F7B83B700BD31AD /* scarfTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = scarfTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
534959592F7B83B700BD31AD /* scarfUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = scarfUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
/* End PBXFileReference section */
/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
+ 4EAC234B2F99930100654F42 /* Exceptions for "Scarf iOS" folder in "scarf mobile" target */ = {
+ isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
+ membershipExceptions = (
+ Info.plist,
+ );
+ target = 4EAC23282F99930000654F42 /* scarf mobile */;
+ };
534959AA2F7B83B600BD31AD /* Exceptions for "scarf" folder in "scarf" target */ = {
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
membershipExceptions = (
@@ -45,6 +69,24 @@
/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */
/* Begin PBXFileSystemSynchronizedRootGroup section */
+ 4EAC232A2F99930000654F42 /* Scarf iOS */ = {
+ isa = PBXFileSystemSynchronizedRootGroup;
+ exceptions = (
+ 4EAC234B2F99930100654F42 /* Exceptions for "Scarf iOS" folder in "scarf mobile" target */,
+ );
+ path = "Scarf iOS";
+ sourceTree = "";
+ };
+ 4EAC233C2F99930100654F42 /* Scarf iOSTests */ = {
+ isa = PBXFileSystemSynchronizedRootGroup;
+ path = "Scarf iOSTests";
+ sourceTree = "";
+ };
+ 4EAC23462F99930100654F42 /* Scarf iOSUITests */ = {
+ isa = PBXFileSystemSynchronizedRootGroup;
+ path = "Scarf iOSUITests";
+ sourceTree = "";
+ };
534959422F7B83B600BD31AD /* scarf */ = {
isa = PBXFileSystemSynchronizedRootGroup;
exceptions = (
@@ -66,6 +108,27 @@
/* End PBXFileSystemSynchronizedRootGroup section */
/* Begin PBXFrameworksBuildPhase section */
+ 4EAC23262F99930000654F42 /* Frameworks */ = {
+ isa = PBXFrameworksBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+ 4EAC23362F99930100654F42 /* Frameworks */ = {
+ isa = PBXFrameworksBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+ 4EAC23402F99930100654F42 /* Frameworks */ = {
+ isa = PBXFrameworksBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
5349593D2F7B83B600BD31AD /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
@@ -98,6 +161,9 @@
534959422F7B83B600BD31AD /* scarf */,
534959522F7B83B700BD31AD /* scarfTests */,
5349595C2F7B83B700BD31AD /* scarfUITests */,
+ 4EAC232A2F99930000654F42 /* Scarf iOS */,
+ 4EAC233C2F99930100654F42 /* Scarf iOSTests */,
+ 4EAC23462F99930100654F42 /* Scarf iOSUITests */,
534959412F7B83B600BD31AD /* Products */,
);
sourceTree = "";
@@ -108,6 +174,9 @@
534959402F7B83B600BD31AD /* scarf.app */,
5349594F2F7B83B700BD31AD /* scarfTests.xctest */,
534959592F7B83B700BD31AD /* scarfUITests.xctest */,
+ 4EAC23292F99930000654F42 /* scarf mobile.app */,
+ 4EAC23392F99930100654F42 /* Scarf iOSTests.xctest */,
+ 4EAC23432F99930100654F42 /* Scarf iOSUITests.xctest */,
);
name = Products;
sourceTree = "";
@@ -115,6 +184,74 @@
/* End PBXGroup section */
/* Begin PBXNativeTarget section */
+ 4EAC23282F99930000654F42 /* scarf mobile */ = {
+ isa = PBXNativeTarget;
+ buildConfigurationList = 4EAC234C2F99930100654F42 /* Build configuration list for PBXNativeTarget "scarf mobile" */;
+ buildPhases = (
+ 4EAC23252F99930000654F42 /* Sources */,
+ 4EAC23262F99930000654F42 /* Frameworks */,
+ 4EAC23272F99930000654F42 /* Resources */,
+ );
+ buildRules = (
+ );
+ dependencies = (
+ );
+ fileSystemSynchronizedGroups = (
+ 4EAC232A2F99930000654F42 /* Scarf iOS */,
+ );
+ name = "scarf mobile";
+ packageProductDependencies = (
+ );
+ productName = "Scarf iOS";
+ productReference = 4EAC23292F99930000654F42 /* scarf mobile.app */;
+ productType = "com.apple.product-type.application";
+ };
+ 4EAC23382F99930100654F42 /* Scarf iOSTests */ = {
+ isa = PBXNativeTarget;
+ buildConfigurationList = 4EAC234F2F99930100654F42 /* Build configuration list for PBXNativeTarget "Scarf iOSTests" */;
+ buildPhases = (
+ 4EAC23352F99930100654F42 /* Sources */,
+ 4EAC23362F99930100654F42 /* Frameworks */,
+ 4EAC23372F99930100654F42 /* Resources */,
+ );
+ buildRules = (
+ );
+ dependencies = (
+ 4EAC233B2F99930100654F42 /* PBXTargetDependency */,
+ );
+ fileSystemSynchronizedGroups = (
+ 4EAC233C2F99930100654F42 /* Scarf iOSTests */,
+ );
+ name = "Scarf iOSTests";
+ packageProductDependencies = (
+ );
+ productName = "Scarf iOSTests";
+ productReference = 4EAC23392F99930100654F42 /* Scarf iOSTests.xctest */;
+ productType = "com.apple.product-type.bundle.unit-test";
+ };
+ 4EAC23422F99930100654F42 /* Scarf iOSUITests */ = {
+ isa = PBXNativeTarget;
+ buildConfigurationList = 4EAC23522F99930100654F42 /* Build configuration list for PBXNativeTarget "Scarf iOSUITests" */;
+ buildPhases = (
+ 4EAC233F2F99930100654F42 /* Sources */,
+ 4EAC23402F99930100654F42 /* Frameworks */,
+ 4EAC23412F99930100654F42 /* Resources */,
+ );
+ buildRules = (
+ );
+ dependencies = (
+ 4EAC23452F99930100654F42 /* PBXTargetDependency */,
+ );
+ fileSystemSynchronizedGroups = (
+ 4EAC23462F99930100654F42 /* Scarf iOSUITests */,
+ );
+ name = "Scarf iOSUITests";
+ packageProductDependencies = (
+ );
+ productName = "Scarf iOSUITests";
+ productReference = 4EAC23432F99930100654F42 /* Scarf iOSUITests.xctest */;
+ productType = "com.apple.product-type.bundle.ui-testing";
+ };
5349593F2F7B83B600BD31AD /* scarf */ = {
isa = PBXNativeTarget;
buildConfigurationList = 534959632F7B83B700BD31AD /* Build configuration list for PBXNativeTarget "scarf" */;
@@ -192,9 +329,20 @@
isa = PBXProject;
attributes = {
BuildIndependentTargetsInParallel = 1;
- LastSwiftUpdateCheck = 2630;
+ LastSwiftUpdateCheck = 2620;
LastUpgradeCheck = 2630;
TargetAttributes = {
+ 4EAC23282F99930000654F42 = {
+ CreatedOnToolsVersion = 26.2;
+ };
+ 4EAC23382F99930100654F42 = {
+ CreatedOnToolsVersion = 26.2;
+ TestTargetID = 4EAC23282F99930000654F42;
+ };
+ 4EAC23422F99930100654F42 = {
+ CreatedOnToolsVersion = 26.2;
+ TestTargetID = 4EAC23282F99930000654F42;
+ };
5349593F2F7B83B600BD31AD = {
CreatedOnToolsVersion = 26.3;
};
@@ -235,11 +383,35 @@
5349593F2F7B83B600BD31AD /* scarf */,
5349594E2F7B83B700BD31AD /* scarfTests */,
534959582F7B83B700BD31AD /* scarfUITests */,
+ 4EAC23282F99930000654F42 /* scarf mobile */,
+ 4EAC23382F99930100654F42 /* Scarf iOSTests */,
+ 4EAC23422F99930100654F42 /* Scarf iOSUITests */,
);
};
/* End PBXProject section */
/* Begin PBXResourcesBuildPhase section */
+ 4EAC23272F99930000654F42 /* Resources */ = {
+ isa = PBXResourcesBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+ 4EAC23372F99930100654F42 /* Resources */ = {
+ isa = PBXResourcesBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+ 4EAC23412F99930100654F42 /* Resources */ = {
+ isa = PBXResourcesBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
5349593E2F7B83B600BD31AD /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
@@ -264,6 +436,27 @@
/* End PBXResourcesBuildPhase section */
/* Begin PBXSourcesBuildPhase section */
+ 4EAC23252F99930000654F42 /* Sources */ = {
+ isa = PBXSourcesBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+ 4EAC23352F99930100654F42 /* Sources */ = {
+ isa = PBXSourcesBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+ 4EAC233F2F99930100654F42 /* Sources */ = {
+ isa = PBXSourcesBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
5349593C2F7B83B600BD31AD /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
@@ -288,6 +481,16 @@
/* End PBXSourcesBuildPhase section */
/* Begin PBXTargetDependency section */
+ 4EAC233B2F99930100654F42 /* PBXTargetDependency */ = {
+ isa = PBXTargetDependency;
+ target = 4EAC23282F99930000654F42 /* scarf mobile */;
+ targetProxy = 4EAC233A2F99930100654F42 /* PBXContainerItemProxy */;
+ };
+ 4EAC23452F99930100654F42 /* PBXTargetDependency */ = {
+ isa = PBXTargetDependency;
+ target = 4EAC23282F99930000654F42 /* scarf mobile */;
+ targetProxy = 4EAC23442F99930100654F42 /* PBXContainerItemProxy */;
+ };
534959512F7B83B700BD31AD /* PBXTargetDependency */ = {
isa = PBXTargetDependency;
target = 5349593F2F7B83B600BD31AD /* scarf */;
@@ -301,6 +504,175 @@
/* End PBXTargetDependency section */
/* Begin XCBuildConfiguration section */
+ 4EAC234D2F99930100654F42 /* Debug */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
+ ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
+ CODE_SIGN_ENTITLEMENTS = "Scarf iOS/Scarf_iOS.entitlements";
+ CODE_SIGN_STYLE = Automatic;
+ CURRENT_PROJECT_VERSION = 1;
+ DEVELOPMENT_TEAM = 3Q6X2L86C4;
+ ENABLE_PREVIEWS = YES;
+ GENERATE_INFOPLIST_FILE = YES;
+ INFOPLIST_FILE = "Scarf iOS/Info.plist";
+ INFOPLIST_KEY_CFBundleDisplayName = "Scarf Mobile";
+ INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.developer-tools";
+ INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
+ INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
+ INFOPLIST_KEY_UILaunchScreen_Generation = YES;
+ INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
+ INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
+ IPHONEOS_DEPLOYMENT_TARGET = 18.6;
+ LD_RUNPATH_SEARCH_PATHS = (
+ "$(inherited)",
+ "@executable_path/Frameworks",
+ );
+ MARKETING_VERSION = 1.0;
+ PRODUCT_BUNDLE_IDENTIFIER = "com.scarf-mobile.app";
+ PRODUCT_NAME = "$(TARGET_NAME)";
+ SDKROOT = iphoneos;
+ STRING_CATALOG_GENERATE_SYMBOLS = YES;
+ SWIFT_APPROACHABLE_CONCURRENCY = YES;
+ SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor;
+ SWIFT_EMIT_LOC_STRINGS = YES;
+ SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
+ SWIFT_VERSION = 5.0;
+ TARGETED_DEVICE_FAMILY = "1,2";
+ };
+ name = Debug;
+ };
+ 4EAC234E2F99930100654F42 /* Release */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
+ ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
+ CODE_SIGN_ENTITLEMENTS = "Scarf iOS/Scarf_iOS.entitlements";
+ CODE_SIGN_STYLE = Automatic;
+ CURRENT_PROJECT_VERSION = 1;
+ DEVELOPMENT_TEAM = 3Q6X2L86C4;
+ ENABLE_PREVIEWS = YES;
+ GENERATE_INFOPLIST_FILE = YES;
+ INFOPLIST_FILE = "Scarf iOS/Info.plist";
+ INFOPLIST_KEY_CFBundleDisplayName = "Scarf Mobile";
+ INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.developer-tools";
+ INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
+ INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
+ INFOPLIST_KEY_UILaunchScreen_Generation = YES;
+ INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
+ INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
+ IPHONEOS_DEPLOYMENT_TARGET = 18.6;
+ LD_RUNPATH_SEARCH_PATHS = (
+ "$(inherited)",
+ "@executable_path/Frameworks",
+ );
+ MARKETING_VERSION = 1.0;
+ PRODUCT_BUNDLE_IDENTIFIER = "com.scarf-mobile.app";
+ PRODUCT_NAME = "$(TARGET_NAME)";
+ SDKROOT = iphoneos;
+ STRING_CATALOG_GENERATE_SYMBOLS = YES;
+ SWIFT_APPROACHABLE_CONCURRENCY = YES;
+ SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor;
+ SWIFT_EMIT_LOC_STRINGS = YES;
+ SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
+ SWIFT_VERSION = 5.0;
+ TARGETED_DEVICE_FAMILY = "1,2";
+ VALIDATE_PRODUCT = YES;
+ };
+ name = Release;
+ };
+ 4EAC23502F99930100654F42 /* Debug */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ BUNDLE_LOADER = "$(TEST_HOST)";
+ CODE_SIGN_STYLE = Automatic;
+ CURRENT_PROJECT_VERSION = 1;
+ DEVELOPMENT_TEAM = 3Q6X2L86C4;
+ GENERATE_INFOPLIST_FILE = YES;
+ IPHONEOS_DEPLOYMENT_TARGET = 26.2;
+ MARKETING_VERSION = 1.0;
+ PRODUCT_BUNDLE_IDENTIFIER = "alanwizemann.Scarf-iOSTests";
+ PRODUCT_NAME = "$(TARGET_NAME)";
+ SDKROOT = iphoneos;
+ STRING_CATALOG_GENERATE_SYMBOLS = NO;
+ SWIFT_APPROACHABLE_CONCURRENCY = YES;
+ SWIFT_EMIT_LOC_STRINGS = NO;
+ SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
+ SWIFT_VERSION = 5.0;
+ TARGETED_DEVICE_FAMILY = "1,2";
+ TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Scarf iOS.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Scarf iOS";
+ };
+ name = Debug;
+ };
+ 4EAC23512F99930100654F42 /* Release */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ BUNDLE_LOADER = "$(TEST_HOST)";
+ CODE_SIGN_STYLE = Automatic;
+ CURRENT_PROJECT_VERSION = 1;
+ DEVELOPMENT_TEAM = 3Q6X2L86C4;
+ GENERATE_INFOPLIST_FILE = YES;
+ IPHONEOS_DEPLOYMENT_TARGET = 26.2;
+ MARKETING_VERSION = 1.0;
+ PRODUCT_BUNDLE_IDENTIFIER = "alanwizemann.Scarf-iOSTests";
+ PRODUCT_NAME = "$(TARGET_NAME)";
+ SDKROOT = iphoneos;
+ STRING_CATALOG_GENERATE_SYMBOLS = NO;
+ SWIFT_APPROACHABLE_CONCURRENCY = YES;
+ SWIFT_EMIT_LOC_STRINGS = NO;
+ SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
+ SWIFT_VERSION = 5.0;
+ TARGETED_DEVICE_FAMILY = "1,2";
+ TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Scarf iOS.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Scarf iOS";
+ VALIDATE_PRODUCT = YES;
+ };
+ name = Release;
+ };
+ 4EAC23532F99930100654F42 /* Debug */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ CODE_SIGN_STYLE = Automatic;
+ CURRENT_PROJECT_VERSION = 1;
+ DEVELOPMENT_TEAM = 3Q6X2L86C4;
+ GENERATE_INFOPLIST_FILE = YES;
+ IPHONEOS_DEPLOYMENT_TARGET = 26.2;
+ MARKETING_VERSION = 1.0;
+ PRODUCT_BUNDLE_IDENTIFIER = "alanwizemann.Scarf-iOSUITests";
+ PRODUCT_NAME = "$(TARGET_NAME)";
+ SDKROOT = iphoneos;
+ STRING_CATALOG_GENERATE_SYMBOLS = NO;
+ SWIFT_APPROACHABLE_CONCURRENCY = YES;
+ SWIFT_EMIT_LOC_STRINGS = NO;
+ SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
+ SWIFT_VERSION = 5.0;
+ TARGETED_DEVICE_FAMILY = "1,2";
+ TEST_TARGET_NAME = "Scarf iOS";
+ };
+ name = Debug;
+ };
+ 4EAC23542F99930100654F42 /* Release */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ CODE_SIGN_STYLE = Automatic;
+ CURRENT_PROJECT_VERSION = 1;
+ DEVELOPMENT_TEAM = 3Q6X2L86C4;
+ GENERATE_INFOPLIST_FILE = YES;
+ IPHONEOS_DEPLOYMENT_TARGET = 26.2;
+ MARKETING_VERSION = 1.0;
+ PRODUCT_BUNDLE_IDENTIFIER = "alanwizemann.Scarf-iOSUITests";
+ PRODUCT_NAME = "$(TARGET_NAME)";
+ SDKROOT = iphoneos;
+ STRING_CATALOG_GENERATE_SYMBOLS = NO;
+ SWIFT_APPROACHABLE_CONCURRENCY = YES;
+ SWIFT_EMIT_LOC_STRINGS = NO;
+ SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
+ SWIFT_VERSION = 5.0;
+ TARGETED_DEVICE_FAMILY = "1,2";
+ TEST_TARGET_NAME = "Scarf iOS";
+ VALIDATE_PRODUCT = YES;
+ };
+ name = Release;
+ };
534959612F7B83B700BD31AD /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
@@ -444,6 +816,7 @@
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = NO;
INFOPLIST_FILE = scarf/Info.plist;
+ INFOPLIST_KEY_CFBundleDisplayName = Scarf;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/../Frameworks",
@@ -479,6 +852,7 @@
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = NO;
INFOPLIST_FILE = scarf/Info.plist;
+ INFOPLIST_KEY_CFBundleDisplayName = Scarf;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/../Frameworks",
@@ -584,6 +958,33 @@
/* End XCBuildConfiguration section */
/* Begin XCConfigurationList section */
+ 4EAC234C2F99930100654F42 /* Build configuration list for PBXNativeTarget "scarf mobile" */ = {
+ isa = XCConfigurationList;
+ buildConfigurations = (
+ 4EAC234D2F99930100654F42 /* Debug */,
+ 4EAC234E2F99930100654F42 /* Release */,
+ );
+ defaultConfigurationIsVisible = 0;
+ defaultConfigurationName = Release;
+ };
+ 4EAC234F2F99930100654F42 /* Build configuration list for PBXNativeTarget "Scarf iOSTests" */ = {
+ isa = XCConfigurationList;
+ buildConfigurations = (
+ 4EAC23502F99930100654F42 /* Debug */,
+ 4EAC23512F99930100654F42 /* Release */,
+ );
+ defaultConfigurationIsVisible = 0;
+ defaultConfigurationName = Release;
+ };
+ 4EAC23522F99930100654F42 /* Build configuration list for PBXNativeTarget "Scarf iOSUITests" */ = {
+ isa = XCConfigurationList;
+ buildConfigurations = (
+ 4EAC23532F99930100654F42 /* Debug */,
+ 4EAC23542F99930100654F42 /* Release */,
+ );
+ defaultConfigurationIsVisible = 0;
+ defaultConfigurationName = Release;
+ };
5349593B2F7B83B600BD31AD /* Build configuration list for PBXProject "scarf" */ = {
isa = XCConfigurationList;
buildConfigurations = (
diff --git a/scarf/scarf/Features/Projects/Views/ProjectsView.swift b/scarf/scarf/Features/Projects/Views/ProjectsView.swift
index 5ba71c4..c01bf91 100644
--- a/scarf/scarf/Features/Projects/Views/ProjectsView.swift
+++ b/scarf/scarf/Features/Projects/Views/ProjectsView.swift
@@ -26,6 +26,7 @@ struct ProjectsView: View {
@State private var showingInstallURLPrompt = false
@State private var installURLInput = ""
@State private var showingUninstallSheet = false
+ @State private var configEditorProject: ProjectEntry?
private let uninstaller: ProjectTemplateUninstaller
@@ -36,6 +37,14 @@ struct ProjectsView: View {
self.uninstaller = ProjectTemplateUninstaller(context: context)
}
+ /// True when the given project has a cached manifest (i.e. was
+ /// installed from a schemaful template). Cheap — just a file
+ /// existence check via the transport.
+ private func isConfigurable(_ project: ProjectEntry) -> Bool {
+ let path = ProjectConfigService.manifestCachePath(for: project)
+ return serverContext.makeTransport().fileExists(path)
+ }
+
@State private var selectedTab: DashboardTab = .dashboard
var body: some View {
diff --git a/scarf/scarf/Features/Templates/ViewModels/TemplateConfigEditorViewModel.swift b/scarf/scarf/Features/Templates/ViewModels/TemplateConfigEditorViewModel.swift
new file mode 100644
index 0000000..cec6bbf
--- /dev/null
+++ b/scarf/scarf/Features/Templates/ViewModels/TemplateConfigEditorViewModel.swift
@@ -0,0 +1,118 @@
+import Foundation
+import Observation
+import os
+
+/// Drives the post-install "Configuration" button on the project
+/// dashboard. Loads `/.scarf/manifest.json` + `config.json`,
+/// hands a `TemplateConfigViewModel` seeded with current values to the
+/// sheet, then writes the edited values back on commit.
+///
+/// Smaller surface than `TemplateInstallerViewModel` — no unzipping,
+/// no parent-dir picking, no cron CLI. Just: read → edit → save.
+@Observable
+@MainActor
+final class TemplateConfigEditorViewModel {
+ private static let logger = Logger(subsystem: "com.scarf", category: "TemplateConfigEditorViewModel")
+
+ enum Stage: Sendable {
+ case idle
+ case loading
+ /// Manifest + config loaded; the sheet is displaying the form.
+ case editing
+ case saving
+ case succeeded
+ case failed(String)
+ /// Project wasn't installed from a schemaful template — no
+ /// manifest cache on disk. The dashboard button is hidden in
+ /// this case so we shouldn't hit this stage normally.
+ case notConfigurable
+ }
+
+ let context: ServerContext
+ let project: ProjectEntry
+ private let configService: ProjectConfigService
+
+ init(context: ServerContext, project: ProjectEntry) {
+ self.context = context
+ self.project = project
+ self.configService = ProjectConfigService(context: context)
+ }
+
+ var stage: Stage = .idle
+ var manifest: ProjectTemplateManifest?
+ var currentValues: [String: TemplateConfigValue] = [:]
+
+ /// Non-nil while `.editing`; used to construct the sheet's VM.
+ var formViewModel: TemplateConfigViewModel?
+
+ /// Load the cached manifest + current config values, then move to
+ /// `.editing` so the sheet can render the form.
+ func begin() {
+ stage = .loading
+ let service = configService
+ let project = project
+ Task.detached { [weak self] in
+ do {
+ guard let cachedManifest = try service.loadCachedManifest(project: project),
+ let schema = cachedManifest.config,
+ !schema.isEmpty else {
+ await MainActor.run { [weak self] in
+ self?.stage = .notConfigurable
+ }
+ return
+ }
+ let configFile = try service.load(project: project)
+ await MainActor.run { [weak self] in
+ guard let self else { return }
+ self.manifest = cachedManifest
+ self.currentValues = configFile?.values ?? [:]
+ self.formViewModel = TemplateConfigViewModel(
+ schema: schema,
+ templateId: cachedManifest.id,
+ templateSlug: cachedManifest.slug,
+ initialValues: self.currentValues,
+ mode: .edit(project: project)
+ )
+ self.stage = .editing
+ }
+ } catch {
+ Self.logger.error("couldn't load config for \(project.path, privacy: .public): \(error.localizedDescription, privacy: .public)")
+ await MainActor.run { [weak self] in
+ self?.stage = .failed(error.localizedDescription)
+ }
+ }
+ }
+ }
+
+ /// Called when the sheet's commit succeeded. Persists the edited
+ /// values to `/.scarf/config.json`. Secrets are already
+ /// in the Keychain — the VM's commit step wrote them.
+ func save(values: [String: TemplateConfigValue]) {
+ guard let manifest else { return }
+ stage = .saving
+ let service = configService
+ let project = project
+ Task.detached { [weak self] in
+ do {
+ try service.save(
+ project: project,
+ templateId: manifest.id,
+ values: values
+ )
+ await MainActor.run { [weak self] in
+ self?.stage = .succeeded
+ }
+ } catch {
+ Self.logger.error("couldn't save config for \(project.path, privacy: .public): \(error.localizedDescription, privacy: .public)")
+ await MainActor.run { [weak self] in
+ self?.stage = .failed(error.localizedDescription)
+ }
+ }
+ }
+ }
+
+ func cancel() {
+ stage = .idle
+ formViewModel = nil
+ }
+}
diff --git a/scarf/scarf/Features/Templates/ViewModels/TemplateConfigViewModel.swift b/scarf/scarf/Features/Templates/ViewModels/TemplateConfigViewModel.swift
new file mode 100644
index 0000000..9a26bc9
--- /dev/null
+++ b/scarf/scarf/Features/Templates/ViewModels/TemplateConfigViewModel.swift
@@ -0,0 +1,198 @@
+import Foundation
+import Observation
+import os
+
+/// Drives the configure form for template install + post-install editing.
+///
+/// **Timing of secret storage.** The VM keeps freshly-entered secret bytes
+/// in-memory (`pendingSecrets`) until the user clicks the commit button.
+/// Only then does `commit()` push each secret through
+/// `ProjectConfigService.storeSecret` and get back a `keychainRef` URI.
+/// This means cancelling the sheet never leaves an orphan Keychain
+/// entry behind — the form is transactional from the user's POV.
+///
+/// **Validation.** Runs via `ProjectConfigService.validateValues` every
+/// time the user attempts to commit. Per-field errors are tracked in
+/// `errors` so the sheet can surface them inline with the offending field.
+/// No live validation on every keystroke — that creates a messy
+/// "error appears the moment you start typing" UX.
+@Observable
+@MainActor
+final class TemplateConfigViewModel {
+ private static let logger = Logger(subsystem: "com.scarf", category: "TemplateConfigViewModel")
+
+ enum Mode: Sendable {
+ /// User is filling in values for the first time as part of the
+ /// install flow. Secrets will be written to the Keychain when
+ /// `commit` succeeds.
+ case install
+ /// User is editing values for an already-installed project.
+ /// Existing keychain refs are preserved for fields the user
+ /// doesn't touch; only secrets the user actually changes get
+ /// re-written to the Keychain.
+ case edit(project: ProjectEntry)
+ }
+
+ let schema: TemplateConfigSchema
+ let templateId: String
+ let templateSlug: String
+ let mode: Mode
+ private let configService: ProjectConfigService
+
+ /// Current form values, keyed by field key. Non-secret values live
+ /// here directly; secret fields either hold a `.keychainRef(...)`
+ /// (existing, untouched in edit mode) or nothing at all (user
+ /// hasn't entered a secret yet, or they just cleared it).
+ var values: [String: TemplateConfigValue] = [:]
+
+ /// Raw secret bytes waiting to be written to the Keychain on
+ /// `commit()`. Indexed by field key. `values[key]` stays as its
+ /// current `.keychainRef(...)` (for edit mode) or missing (for
+ /// install mode) until commit swaps it for the freshly-written
+ /// ref URI.
+ var pendingSecrets: [String: Data] = [:]
+
+ /// One error per field with a problem. Populated by `commit()` on
+ /// validation failure; the sheet surfaces the message inline below
+ /// the offending control.
+ var errors: [String: String] = [:]
+
+ init(
+ schema: TemplateConfigSchema,
+ templateId: String,
+ templateSlug: String,
+ initialValues: [String: TemplateConfigValue] = [:],
+ mode: Mode,
+ configService: ProjectConfigService = ProjectConfigService()
+ ) {
+ self.schema = schema
+ self.templateId = templateId
+ self.templateSlug = templateSlug
+ self.mode = mode
+ self.configService = configService
+ self.values = Self.applyDefaults(schema: schema, initial: initialValues)
+ }
+
+ // MARK: - Field setters (the sheet calls these as controls change)
+
+ func setString(_ key: String, _ value: String) {
+ values[key] = .string(value)
+ errors.removeValue(forKey: key)
+ }
+
+ func setNumber(_ key: String, _ value: Double) {
+ values[key] = .number(value)
+ errors.removeValue(forKey: key)
+ }
+
+ func setBool(_ key: String, _ value: Bool) {
+ values[key] = .bool(value)
+ errors.removeValue(forKey: key)
+ }
+
+ func setList(_ key: String, _ items: [String]) {
+ values[key] = .list(items)
+ errors.removeValue(forKey: key)
+ }
+
+ /// Stage a new secret value. Doesn't hit the Keychain until
+ /// `commit()`. An empty `value` clears both the pending secret and
+ /// the field's stored keychainRef — only valid in edit mode, where
+ /// "empty" means "I want to remove this secret."
+ func setSecret(_ key: String, _ value: String) {
+ if value.isEmpty {
+ pendingSecrets.removeValue(forKey: key)
+ values.removeValue(forKey: key)
+ } else {
+ pendingSecrets[key] = Data(value.utf8)
+ // Keep any existing ref around; the sheet can display
+ // "(changed)" while the ref is still the old one. commit()
+ // overwrites on disk.
+ }
+ errors.removeValue(forKey: key)
+ }
+
+ // MARK: - Commit
+
+ /// Validate, persist secrets to the Keychain, and hand back the
+ /// final values dictionary. On validation failure, `errors` is
+ /// populated and the method returns `nil` without touching the
+ /// Keychain — the form is transactional.
+ ///
+ /// In install mode, `project` is required (secrets need a path
+ /// hash for their Keychain account). In edit mode it falls out of
+ /// the `.edit(project:)` associated value.
+ func commit(project: ProjectEntry? = nil) -> [String: TemplateConfigValue]? {
+ // Build the value set we're about to validate. For secrets
+ // that have a pending update, we treat them as present (we'll
+ // write them in a moment); for secrets already stored as
+ // keychainRef, we treat them as present too. Only a completely
+ // empty secret field is "missing."
+ var candidate = values
+ for key in pendingSecrets.keys {
+ // The field is about to have a fresh keychainRef — for
+ // validation purposes, use a placeholder ref so the type
+ // check passes. The real ref replaces it below.
+ candidate[key] = .keychainRef("pending://\(key)")
+ }
+ let validationErrors = ProjectConfigService.validateValues(candidate, against: schema)
+ guard validationErrors.isEmpty else {
+ var byField: [String: String] = [:]
+ for err in validationErrors {
+ guard let key = err.fieldKey else { continue }
+ byField[key] = err.message
+ }
+ self.errors = byField
+ return nil
+ }
+
+ // Validation passed — write the pending secrets to the Keychain.
+ let targetProject: ProjectEntry
+ switch mode {
+ case .install:
+ guard let project else {
+ Self.logger.error("commit(project:) called in install mode without a project")
+ return nil
+ }
+ targetProject = project
+ case .edit(let proj):
+ targetProject = proj
+ }
+
+ for (key, secret) in pendingSecrets {
+ do {
+ let ref = try configService.storeSecret(
+ templateSlug: templateSlug,
+ fieldKey: key,
+ project: targetProject,
+ secret: secret
+ )
+ values[key] = ref
+ } catch {
+ Self.logger.error("failed to store secret for \(key, privacy: .public): \(error.localizedDescription, privacy: .public)")
+ errors[key] = "Couldn't save secret to the Keychain: \(error.localizedDescription)"
+ return nil
+ }
+ }
+ pendingSecrets.removeAll()
+ errors.removeAll()
+ return values
+ }
+
+ // MARK: - Helpers
+
+ /// Seed the form with any author-supplied defaults for fields that
+ /// don't already have an initial value (from a saved config.json).
+ nonisolated private static func applyDefaults(
+ schema: TemplateConfigSchema,
+ initial: [String: TemplateConfigValue]
+ ) -> [String: TemplateConfigValue] {
+ var out = initial
+ for field in schema.fields where out[field.key] == nil {
+ if let def = field.defaultValue {
+ out[field.key] = def
+ }
+ }
+ return out
+ }
+}
diff --git a/scarf/scarf/Features/Templates/ViewModels/TemplateInstallerViewModel.swift b/scarf/scarf/Features/Templates/ViewModels/TemplateInstallerViewModel.swift
index 6995e04..4b2e476 100644
--- a/scarf/scarf/Features/Templates/ViewModels/TemplateInstallerViewModel.swift
+++ b/scarf/scarf/Features/Templates/ViewModels/TemplateInstallerViewModel.swift
@@ -18,6 +18,10 @@ final class TemplateInstallerViewModel {
case fetching(sourceDescription: String)
case inspecting
case awaitingParentDirectory
+ /// Template declared a non-empty config schema; the sheet
+ /// presents `TemplateConfigSheet` before continuing to the
+ /// preview. Schema-less templates skip this stage entirely.
+ case awaitingConfig
case planned
case installing
case succeeded(installed: ProjectEntry)
@@ -139,14 +143,20 @@ final class TemplateInstallerViewModel {
guard let inspection else { return }
chosenParentDirectory = parentDir
let service = templateService
- let context = context
Task.detached { [weak self] in
do {
let plan = try service.buildPlan(inspection: inspection, parentDir: parentDir)
- _ = context
await MainActor.run { [weak self] in
- self?.plan = plan
- self?.stage = .planned
+ guard let self else { return }
+ self.plan = plan
+ // If the template declares a non-empty config
+ // schema, insert the configure step before the
+ // preview sheet. Otherwise go straight to .planned.
+ if let schema = plan.configSchema, !schema.isEmpty {
+ self.stage = .awaitingConfig
+ } else {
+ self.stage = .planned
+ }
}
} catch {
await MainActor.run { [weak self] in
@@ -156,6 +166,26 @@ final class TemplateInstallerViewModel {
}
}
+ /// Called by `TemplateInstallSheet` once the user has filled in
+ /// the configure form and `TemplateConfigViewModel.commit()`
+ /// succeeded. Stashes the values in the plan and advances to the
+ /// preview stage (`.planned`). Secrets in `values` are already
+ /// `.keychainRef(...)` — the VM's commit step wrote them to the
+ /// Keychain.
+ func submitConfig(values: [String: TemplateConfigValue]) {
+ guard var plan else { return }
+ plan.configValues = values
+ self.plan = plan
+ stage = .planned
+ }
+
+ /// Called when the user cancels out of the configure step without
+ /// committing. Returns to `.awaitingParentDirectory` so they can
+ /// try again (or dismiss the whole sheet).
+ func cancelConfig() {
+ stage = .awaitingParentDirectory
+ }
+
func confirmInstall() {
guard let plan else { return }
stage = .installing
diff --git a/scarf/scarf/Features/Templates/Views/TemplateConfigSheet.swift b/scarf/scarf/Features/Templates/Views/TemplateConfigSheet.swift
new file mode 100644
index 0000000..105b9d9
--- /dev/null
+++ b/scarf/scarf/Features/Templates/Views/TemplateConfigSheet.swift
@@ -0,0 +1,384 @@
+import SwiftUI
+
+/// The configure form rendered for template install + post-install
+/// editing. One row per schema field; controls dispatch by field type.
+/// Commit button returns the finalized values via `onCommit` — in
+/// install mode the caller stashes them in the install plan; in edit
+/// mode the caller writes them straight to `/.scarf/config.json`.
+struct TemplateConfigSheet: View {
+ @Environment(\.dismiss) private var dismiss
+
+ @State var viewModel: TemplateConfigViewModel
+ let title: LocalizedStringKey
+ let commitLabel: LocalizedStringKey
+ /// In install mode the caller passes the planned `ProjectEntry`
+ /// (project dir path is the unique key for the Keychain secret).
+ /// In edit mode the VM already holds the project; pass `nil` here.
+ let project: ProjectEntry?
+ let onCommit: ([String: TemplateConfigValue]) -> Void
+ let onCancel: () -> Void
+
+ var body: some View {
+ VStack(spacing: 0) {
+ header
+ Divider()
+ ScrollView {
+ VStack(alignment: .leading, spacing: 18) {
+ if viewModel.schema.fields.isEmpty {
+ ContentUnavailableView(
+ "No fields",
+ systemImage: "slider.horizontal.3",
+ description: Text("This template has no configuration fields.")
+ )
+ .frame(maxWidth: .infinity, minHeight: 120)
+ } else {
+ ForEach(viewModel.schema.fields) { field in
+ fieldRow(field)
+ }
+ }
+ if let rec = viewModel.schema.modelRecommendation {
+ modelRecommendation(rec)
+ }
+ }
+ .padding(20)
+ }
+ Divider()
+ footer
+ }
+ .frame(minWidth: 560, minHeight: 480)
+ }
+
+ // MARK: - Header / footer
+
+ @ViewBuilder
+ private var header: some View {
+ HStack {
+ VStack(alignment: .leading, spacing: 2) {
+ Text(title).font(.title2.bold())
+ Text(viewModel.templateId)
+ .font(.caption.monospaced())
+ .foregroundStyle(.secondary)
+ }
+ Spacer()
+ }
+ .padding(16)
+ }
+
+ @ViewBuilder
+ private var footer: some View {
+ HStack {
+ Button("Cancel") {
+ onCancel()
+ dismiss()
+ }
+ .keyboardShortcut(.cancelAction)
+ Spacer()
+ Button(commitLabel) {
+ if let finalized = viewModel.commit(project: project) {
+ onCommit(finalized)
+ dismiss()
+ }
+ }
+ .keyboardShortcut(.defaultAction)
+ .buttonStyle(.borderedProminent)
+ }
+ .padding(16)
+ }
+
+ // MARK: - Field rows
+
+ @ViewBuilder
+ private func fieldRow(_ field: TemplateConfigField) -> some View {
+ VStack(alignment: .leading, spacing: 6) {
+ HStack(alignment: .firstTextBaseline, spacing: 4) {
+ Text(field.label).font(.headline)
+ if field.required {
+ Text("*")
+ .font(.headline)
+ .foregroundStyle(.red)
+ }
+ Spacer()
+ Text(field.type.rawValue)
+ .font(.caption2.monospaced())
+ .foregroundStyle(.secondary)
+ }
+ if let description = field.description, !description.isEmpty {
+ Text(description)
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ .fixedSize(horizontal: false, vertical: true)
+ }
+ control(for: field)
+ if let err = viewModel.errors[field.key] {
+ Label(err, systemImage: "exclamationmark.triangle.fill")
+ .font(.caption)
+ .foregroundStyle(.red)
+ }
+ }
+ .padding(12)
+ .background(
+ RoundedRectangle(cornerRadius: 8)
+ .fill(.background.secondary)
+ )
+ }
+
+ @ViewBuilder
+ private func control(for field: TemplateConfigField) -> some View {
+ switch field.type {
+ case .string:
+ StringControl(
+ value: stringBinding(for: field),
+ placeholder: field.placeholder
+ )
+ case .text:
+ TextControl(value: stringBinding(for: field))
+ case .number:
+ NumberControl(value: numberBinding(for: field))
+ case .bool:
+ BoolControl(label: field.label, value: boolBinding(for: field))
+ case .enum:
+ EnumControl(
+ options: field.options ?? [],
+ value: stringBinding(for: field)
+ )
+ case .list:
+ ListControl(items: listBinding(for: field))
+ case .secret:
+ SecretControl(
+ fieldKey: field.key,
+ placeholder: field.placeholder,
+ viewModel: viewModel
+ )
+ }
+ }
+
+ // MARK: - Model recommendation panel
+
+ private func modelRecommendation(_ rec: TemplateModelRecommendation) -> some View {
+ VStack(alignment: .leading, spacing: 6) {
+ Label("Recommended model", systemImage: "lightbulb")
+ .font(.caption.bold())
+ .foregroundStyle(.secondary)
+ Text(rec.preferred).font(.body.monospaced())
+ if let rationale = rec.rationale, !rationale.isEmpty {
+ Text(rationale)
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ .fixedSize(horizontal: false, vertical: true)
+ }
+ if let alts = rec.alternatives, !alts.isEmpty {
+ Text("Also works: \(alts.joined(separator: ", "))")
+ .font(.caption2)
+ .foregroundStyle(.secondary)
+ }
+ Text("Scarf doesn't auto-switch your active model. Change it in Settings if you'd like.")
+ .font(.caption2)
+ .foregroundStyle(.tertiary)
+ }
+ .padding(12)
+ .background(
+ RoundedRectangle(cornerRadius: 8)
+ .fill(Color.accentColor.opacity(0.08))
+ )
+ }
+
+ // MARK: - Binding helpers (threading the VM through typed lenses)
+
+ private func stringBinding(for field: TemplateConfigField) -> Binding {
+ Binding(
+ get: {
+ if case .string(let s) = viewModel.values[field.key] { return s }
+ return ""
+ },
+ set: { viewModel.setString(field.key, $0) }
+ )
+ }
+
+ private func numberBinding(for field: TemplateConfigField) -> Binding {
+ Binding(
+ get: {
+ if case .number(let n) = viewModel.values[field.key] { return n }
+ return 0
+ },
+ set: { viewModel.setNumber(field.key, $0) }
+ )
+ }
+
+ private func boolBinding(for field: TemplateConfigField) -> Binding {
+ Binding(
+ get: {
+ if case .bool(let b) = viewModel.values[field.key] { return b }
+ return false
+ },
+ set: { viewModel.setBool(field.key, $0) }
+ )
+ }
+
+ private func listBinding(for field: TemplateConfigField) -> Binding<[String]> {
+ Binding(
+ get: {
+ if case .list(let items) = viewModel.values[field.key] { return items }
+ return []
+ },
+ set: { viewModel.setList(field.key, $0) }
+ )
+ }
+}
+
+// MARK: - Field controls
+
+private struct StringControl: View {
+ @Binding var value: String
+ let placeholder: String?
+ var body: some View {
+ TextField(placeholder ?? "", text: $value)
+ .textFieldStyle(.roundedBorder)
+ }
+}
+
+private struct TextControl: View {
+ @Binding var value: String
+ var body: some View {
+ TextEditor(text: $value)
+ .font(.body.monospaced())
+ .frame(minHeight: 80, maxHeight: 160)
+ .overlay(
+ RoundedRectangle(cornerRadius: 6)
+ .stroke(.secondary.opacity(0.3))
+ )
+ }
+}
+
+private struct NumberControl: View {
+ @Binding var value: Double
+ var body: some View {
+ TextField("", value: $value, format: .number)
+ .textFieldStyle(.roundedBorder)
+ }
+}
+
+private struct BoolControl: View {
+ let label: String
+ @Binding var value: Bool
+ var body: some View {
+ Toggle(isOn: $value) {
+ Text(value ? "Enabled" : "Disabled")
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ }
+ }
+}
+
+private struct EnumControl: View {
+ let options: [TemplateConfigField.EnumOption]
+ @Binding var value: String
+ var body: some View {
+ // Segmented for ≤ 4 options, dropdown otherwise — fits Scarf's
+ // existing settings UI.
+ if options.count <= 4 {
+ Picker("", selection: $value) {
+ ForEach(options) { opt in
+ Text(opt.label).tag(opt.value)
+ }
+ }
+ .pickerStyle(.segmented)
+ .labelsHidden()
+ } else {
+ Picker("", selection: $value) {
+ ForEach(options) { opt in
+ Text(opt.label).tag(opt.value)
+ }
+ }
+ .labelsHidden()
+ }
+ }
+}
+
+/// Variable-length list of string values. Each row is a text field
+/// with an inline remove button; a + button adds a trailing row.
+private struct ListControl: View {
+ @Binding var items: [String]
+ var body: some View {
+ VStack(alignment: .leading, spacing: 4) {
+ ForEach(items.indices, id: \.self) { i in
+ HStack(spacing: 6) {
+ TextField("", text: Binding(
+ get: { i < items.count ? items[i] : "" },
+ set: { newValue in
+ guard i < items.count else { return }
+ items[i] = newValue
+ }
+ ))
+ .textFieldStyle(.roundedBorder)
+ Button {
+ guard i < items.count else { return }
+ items.remove(at: i)
+ } label: {
+ Image(systemName: "minus.circle")
+ }
+ .buttonStyle(.borderless)
+ .disabled(items.count <= 1)
+ }
+ }
+ Button {
+ items.append("")
+ } label: {
+ Label("Add", systemImage: "plus.circle")
+ .font(.caption)
+ }
+ .buttonStyle(.borderless)
+ }
+ }
+}
+
+/// Secret fields never echo the previously-stored value back. Instead
+/// we render "(unchanged)" when a Keychain ref already exists and let
+/// the user type over it if they want to replace. Empty input in edit
+/// mode signals "remove this secret entirely."
+private struct SecretControl: View {
+ let fieldKey: String
+ let placeholder: String?
+ @Bindable var viewModel: TemplateConfigViewModel
+
+ @State private var typedValue: String = ""
+ @State private var isRevealed: Bool = false
+
+ private var hasStoredRef: Bool {
+ if case .keychainRef = viewModel.values[fieldKey] { return true }
+ return false
+ }
+
+ var body: some View {
+ VStack(alignment: .leading, spacing: 4) {
+ HStack(spacing: 6) {
+ Group {
+ if isRevealed {
+ TextField(placeholder ?? "", text: $typedValue)
+ } else {
+ SecureField(placeholder ?? "", text: $typedValue)
+ }
+ }
+ .textFieldStyle(.roundedBorder)
+ .onChange(of: typedValue) { _, new in
+ viewModel.setSecret(fieldKey, new)
+ }
+ Button {
+ isRevealed.toggle()
+ } label: {
+ Image(systemName: isRevealed ? "eye.slash" : "eye")
+ }
+ .buttonStyle(.borderless)
+ .help(isRevealed ? "Hide" : "Show while typing")
+ }
+ if hasStoredRef && typedValue.isEmpty {
+ Text("Saved in Keychain — leave empty to keep the stored value.")
+ .font(.caption2)
+ .foregroundStyle(.secondary)
+ } else if !typedValue.isEmpty {
+ Text("Will be saved to the Keychain on commit.")
+ .font(.caption2)
+ .foregroundStyle(.secondary)
+ }
+ }
+ }
+}
diff --git a/scarf/scarf/Features/Templates/Views/TemplateInstallSheet.swift b/scarf/scarf/Features/Templates/Views/TemplateInstallSheet.swift
index eae2d13..98f9a64 100644
--- a/scarf/scarf/Features/Templates/Views/TemplateInstallSheet.swift
+++ b/scarf/scarf/Features/Templates/Views/TemplateInstallSheet.swift
@@ -21,6 +21,8 @@ struct TemplateInstallSheet: View {
progress("Inspecting template…")
case .awaitingParentDirectory:
pickParentView
+ case .awaitingConfig:
+ configureView
case .planned:
if let plan = viewModel.plan {
plannedView(plan: plan)
@@ -85,6 +87,39 @@ struct TemplateInstallSheet: View {
}
}
+ /// Configure step for schemaful templates. Inlines
+ /// `TemplateConfigSheet` into the install flow rather than pushing
+ /// a second sheet on top — keeps the user in one window. The
+ /// nested VM is created freshly each time `.awaitingConfig` is
+ /// entered so a Cancel + retry doesn't carry stale form state.
+ @ViewBuilder
+ private var configureView: some View {
+ if let plan = viewModel.plan,
+ let schema = plan.configSchema,
+ let manifest = viewModel.inspection?.manifest {
+ TemplateConfigSheet(
+ viewModel: TemplateConfigViewModel(
+ schema: schema,
+ templateId: manifest.id,
+ templateSlug: manifest.slug,
+ initialValues: plan.configValues,
+ mode: .install
+ ),
+ title: "Configure \(manifest.name)",
+ commitLabel: "Continue",
+ project: ProjectEntry(name: plan.projectRegistryName, path: plan.projectDir),
+ onCommit: { values in
+ viewModel.submitConfig(values: values)
+ },
+ onCancel: {
+ viewModel.cancelConfig()
+ }
+ )
+ } else {
+ progress("Preparing…")
+ }
+ }
+
private func plannedView(plan: TemplateInstallPlan) -> some View {
VStack(alignment: .leading, spacing: 0) {
manifestHeader(plan.manifest)
@@ -102,6 +137,9 @@ struct TemplateInstallSheet: View {
if plan.memoryAppendix != nil {
memorySection(plan: plan)
}
+ if let schema = plan.configSchema, !schema.isEmpty {
+ configurationSection(plan: plan, schema: schema)
+ }
readmeSection
}
.padding(.vertical)
@@ -213,6 +251,50 @@ struct TemplateInstallSheet: View {
}
}
+ /// Configuration values the user entered in the configure step.
+ /// Secrets display masked so the preview never echoes a freshly
+ /// typed API key back on screen.
+ private func configurationSection(plan: TemplateInstallPlan, schema: TemplateConfigSchema) -> some View {
+ section(title: "Configuration", subtitle: "written to \(plan.projectDir)/.scarf/config.json") {
+ VStack(alignment: .leading, spacing: 4) {
+ ForEach(schema.fields) { field in
+ HStack(alignment: .firstTextBaseline, spacing: 8) {
+ Text(field.key)
+ .font(.caption.monospaced())
+ .foregroundStyle(.secondary)
+ .frame(minWidth: 120, alignment: .leading)
+ Text(displayValue(for: field, in: plan.configValues))
+ .font(.caption)
+ .lineLimit(1)
+ .truncationMode(.tail)
+ }
+ }
+ }
+ }
+ }
+
+ /// One-line display form for a value in the preview. Secrets are
+ /// always masked; lists show a count + first entry; strings are
+ /// truncated by `.lineLimit(1)` at the view level.
+ private func displayValue(
+ for field: TemplateConfigField,
+ in values: [String: TemplateConfigValue]
+ ) -> String {
+ switch field.type {
+ case .secret:
+ return values[field.key] == nil ? "(not set)" : "••••••• (Keychain)"
+ case .list:
+ if case .list(let items) = values[field.key] {
+ if items.isEmpty { return "(none)" }
+ if items.count == 1 { return items[0] }
+ return "\(items[0]) + \(items.count - 1) more"
+ }
+ return "(none)"
+ default:
+ return values[field.key]?.displayString ?? "(not set)"
+ }
+ }
+
private var readmeSection: some View {
Group {
// The body is preloaded in the VM off MainActor when inspection