From b289a839441a2f8b31aea8d31aa765b7eb88f55f Mon Sep 17 00:00:00 2001 From: Alan Wizemann Date: Thu, 23 Apr 2026 01:42:25 +0200 Subject: [PATCH] iOS Target Set Up xcode mobile target creation --- .../AccentColor.colorset/Contents.json | 11 + .../AppIcon.appiconset/Contents.json | 35 ++ scarf/Scarf iOS/Assets.xcassets/Contents.json | 6 + scarf/Scarf iOS/ContentView.swift | 61 +++ scarf/Scarf iOS/Info.plist | 10 + scarf/Scarf iOS/Item.swift | 18 + scarf/Scarf iOS/Scarf_iOS.entitlements | 14 + scarf/Scarf iOS/Scarf_iOSApp.swift | 32 ++ scarf/Scarf iOSTests/Scarf_iOSTests.swift | 17 + scarf/Scarf iOSUITests/Scarf_iOSUITests.swift | 41 ++ .../Scarf_iOSUITestsLaunchTests.swift | 33 ++ scarf/scarf.xcodeproj/project.pbxproj | 403 +++++++++++++++++- .../Projects/Views/ProjectsView.swift | 9 + .../TemplateConfigEditorViewModel.swift | 118 +++++ .../ViewModels/TemplateConfigViewModel.swift | 198 +++++++++ .../TemplateInstallerViewModel.swift | 38 +- .../Templates/Views/TemplateConfigSheet.swift | 384 +++++++++++++++++ .../Views/TemplateInstallSheet.swift | 82 ++++ 18 files changed, 1505 insertions(+), 5 deletions(-) create mode 100644 scarf/Scarf iOS/Assets.xcassets/AccentColor.colorset/Contents.json create mode 100644 scarf/Scarf iOS/Assets.xcassets/AppIcon.appiconset/Contents.json create mode 100644 scarf/Scarf iOS/Assets.xcassets/Contents.json create mode 100644 scarf/Scarf iOS/ContentView.swift create mode 100644 scarf/Scarf iOS/Info.plist create mode 100644 scarf/Scarf iOS/Item.swift create mode 100644 scarf/Scarf iOS/Scarf_iOS.entitlements create mode 100644 scarf/Scarf iOS/Scarf_iOSApp.swift create mode 100644 scarf/Scarf iOSTests/Scarf_iOSTests.swift create mode 100644 scarf/Scarf iOSUITests/Scarf_iOSUITests.swift create mode 100644 scarf/Scarf iOSUITests/Scarf_iOSUITestsLaunchTests.swift create mode 100644 scarf/scarf/Features/Templates/ViewModels/TemplateConfigEditorViewModel.swift create mode 100644 scarf/scarf/Features/Templates/ViewModels/TemplateConfigViewModel.swift create mode 100644 scarf/scarf/Features/Templates/Views/TemplateConfigSheet.swift 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