App scaffold & wizard shell (#1) #33

Merged
gronod merged 1 commits from feat/1-app-scaffold into milestone/m1-foundation 2026-09-08 18:30:28 +01:00
20 changed files with 753 additions and 1 deletions
+4 -1
View File
@@ -13,7 +13,10 @@ DerivedData/
Package.resolved Package.resolved
# Fetched Argyll sidecars (release artefacts, not git blobs — #127) # Fetched Argyll sidecars (release artefacts, not git blobs — #127)
Resources/argyll/ Vendor/Argyll/
# XcodeGen output (regenerate with `make gen`)
ICCery.xcodeproj/
# macOS # macOS
.DS_Store .DS_Store
+23
View File
@@ -0,0 +1,23 @@
SCHEME := ICCery
DEST := 'platform=macOS'
.PHONY: gen build test universal fetch-argyll clean
gen:
xcodegen generate
build: gen
xcodebuild build -scheme $(SCHEME) -destination $(DEST)
test: gen
xcodebuild build test -scheme $(SCHEME) -destination $(DEST)
universal: gen
xcodebuild build -scheme $(SCHEME) -destination $(DEST) \
ARCHS='arm64 x86_64' ONLY_ACTIVE_ARCH=NO
fetch-argyll:
scripts/fetch-argyll.sh
clean:
rm -rf ICCery.xcodeproj DerivedData Packages/ICCeryCore/.build
+13
View File
@@ -0,0 +1,13 @@
// swift-tools-version: 6.0
import PackageDescription
let package = Package(
name: "ICCeryCore",
platforms: [.macOS(.v14)],
products: [
.library(name: "ICCeryCore", targets: ["ICCeryCore"]),
],
targets: [
.target(name: "ICCeryCore"),
]
)
@@ -0,0 +1,51 @@
import Foundation
/// Well-known filesystem locations for the ICCery host process.
///
/// macOS paths (docs/02 §Persistence):
/// - App data: `~/Library/Application Support/<bundle-id>/`
/// - Log file: `~/Library/Logs/<bundle-id>/iccery.log`
/// - Bundled Argyll tools: `<bundle>/Contents/Resources/Argyll/`
public enum AppPaths {
/// `com.gronod.iccery2` read from the main bundle so tests can override.
public static var bundleIdentifier: String {
Bundle.main.bundleIdentifier ?? "com.gronod.iccery2"
}
/// `~/Library/Application Support/com.gronod.iccery2`
public static var appDataDir: URL {
FileManager.default
.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0]
.appendingPathComponent(bundleIdentifier, isDirectory: true)
}
/// `~/Library/Logs/com.gronod.iccery2`
public static var logDir: URL {
FileManager.default
.urls(for: .libraryDirectory, in: .userDomainMask)[0]
.appendingPathComponent("Logs", isDirectory: true)
.appendingPathComponent(bundleIdentifier, isDirectory: true)
}
/// `~/Library/Logs/com.gronod.iccery2/iccery.log`
public static var logFile: URL {
logDir.appendingPathComponent("iccery.log", isDirectory: false)
}
/// `<app>/Contents/Resources/Argyll` bundled sidecar root.
public static var bundledArgyllDir: URL {
Bundle.main.resourceURL?
.appendingPathComponent("Argyll", isDirectory: true)
?? URL(fileURLWithPath: "/nonexistent")
}
/// Creates the app data and log directories if missing.
@discardableResult
public static func ensureDirectories() throws -> (appData: URL, logs: URL) {
let fm = FileManager.default
try fm.createDirectory(at: appDataDir, withIntermediateDirectories: true)
try fm.createDirectory(at: logDir, withIntermediateDirectories: true)
return (appDataDir, logDir)
}
}
@@ -0,0 +1,45 @@
import Foundation
/// The five wizard stages plus Stage 0 (calibration), matching the v1
/// `data-stage` contract in docs/21. Stepper buttons 15 map to
/// `.generate` `.verifyInstall`; `.calibrate` lives outside the stepper.
public enum WizardStage: Int, CaseIterable, Sendable, Codable {
case calibrate = 0
case generate = 1
case layOutPrint = 2
case measure = 3
case buildProfile = 4
case verifyInstall = 5
/// Sidebar stepper position (15); `nil` for the out-of-band calibrate stage.
public var stepperIndex: Int? {
self == .calibrate ? nil : rawValue
}
public var title: String {
switch self {
case .calibrate: return "Printer Calibration"
case .generate: return "Generate Target"
case .layOutPrint: return "Lay Out & Print"
case .measure: return "Measure Chart"
case .buildProfile: return "Build Profile"
case .verifyInstall: return "Verify & Install"
}
}
public var symbolName: String {
switch self {
case .calibrate: return "slider.horizontal.3"
case .generate: return "square.grid.3x3"
case .layOutPrint: return "printer"
case .measure: return "eyedropper.halffull"
case .buildProfile: return "paintpalette"
case .verifyInstall: return "checkmark.seal"
}
}
/// Stages shown in the sidebar stepper, in order.
public static var stepperStages: [WizardStage] {
[.generate, .layOutPrint, .measure, .buildProfile, .verifyInstall]
}
}
@@ -0,0 +1,20 @@
{
"colors" : [
{
"color" : {
"color-space" : "srgb",
"components" : {
"alpha" : "1.000",
"blue" : "0.800",
"green" : "0.478",
"red" : "0.000"
}
},
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
@@ -0,0 +1,58 @@
{
"images" : [
{
"idiom" : "mac",
"scale" : "1x",
"size" : "16x16"
},
{
"idiom" : "mac",
"scale" : "2x",
"size" : "16x16"
},
{
"idiom" : "mac",
"scale" : "1x",
"size" : "32x32"
},
{
"idiom" : "mac",
"scale" : "2x",
"size" : "32x32"
},
{
"idiom" : "mac",
"scale" : "1x",
"size" : "128x128"
},
{
"idiom" : "mac",
"scale" : "2x",
"size" : "128x128"
},
{
"idiom" : "mac",
"scale" : "1x",
"size" : "256x256"
},
{
"idiom" : "mac",
"scale" : "2x",
"size" : "256x256"
},
{
"idiom" : "mac",
"scale" : "1x",
"size" : "512x512"
},
{
"idiom" : "mac",
"scale" : "2x",
"size" : "512x512"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
+6
View File
@@ -0,0 +1,6 @@
{
"info" : {
"author" : "xcode",
"version" : 1
}
}
@@ -0,0 +1,16 @@
{
"images" : [
{
"filename" : "ICCery-logo.svg",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
},
"properties" : {
"preserves-vector-representation" : true,
"template-rendering-intent" : "original"
}
}
@@ -0,0 +1,47 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 480 160" width="100%" height="100%">
<defs>
<clipPath id="coneClip">
<path d="M 50 82 L 110 82 L 80 142 Z" />
</clipPath>
<linearGradient id="textGrad" x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" stop-color="#00AEEF" />
<stop offset="100%" stop-color="#0066CC" />
</linearGradient>
</defs>
<!-- Drop Shadow -->
<ellipse cx="80" cy="145" rx="25" ry="5" fill="#1E293B" opacity="0.1" />
<!-- Ice Cream Cone (Waffle) -->
<path d="M 50 82 L 110 82 L 80 142 Z" fill="#FAD7A1" stroke="#E59866" stroke-width="2.5" stroke-linejoin="round"/>
<g clip-path="url(#coneClip)" stroke="#E59866" stroke-width="2">
<line x1="40" y1="80" x2="120" y2="160" />
<line x1="55" y1="80" x2="135" y2="160" />
<line x1="70" y1="80" x2="150" y2="160" />
<line x1="85" y1="80" x2="165" y2="160" />
<line x1="120" y1="80" x2="40" y2="160" />
<line x1="105" y1="80" x2="25" y2="160" />
<line x1="90" y1="80" x2="10" y2="160" />
<line x1="75" y1="80" x2="-5" y2="160" />
</g>
<!-- CMYK Scoops (C, M, Y) -->
<circle cx="63" cy="72" r="22" fill="#00BCEB" stroke="#ffffff" stroke-width="2.5"/>
<circle cx="97" cy="72" r="22" fill="#EC008C" stroke="#ffffff" stroke-width="2.5"/>
<circle cx="80" cy="48" r="22" fill="#FFED00" stroke="#ffffff" stroke-width="2.5"/>
<!-- Black (Key) Cherry -->
<path d="M 80 23 Q 92 12 96 16" fill="none" stroke="#1E293B" stroke-width="2" stroke-linecap="round"/>
<circle cx="80" cy="24" r="7" fill="#1E293B" stroke="#ffffff" stroke-width="1.5"/>
<!-- Highlights on scoops for 3D effect -->
<circle cx="58" cy="67" r="4" fill="#ffffff" opacity="0.35"/>
<circle cx="92" cy="67" r="4" fill="#ffffff" opacity="0.35"/>
<circle cx="75" cy="43" r="4" fill="#ffffff" opacity="0.45"/>
<circle cx="78" cy="22" r="1.5" fill="#ffffff" opacity="0.6"/>
<!-- Text: ICCery -->
<text font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif" font-size="58" font-weight="800" x="140" y="105" fill="#ffffff" letter-spacing="-1">ICC<tspan fill="url(#textGrad)">ery</tspan></text>
</svg>

After

Width:  |  Height:  |  Size: 2.2 KiB

+8
View File
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<!-- App Sandbox intentionally absent: ICCery must spawn Argyll tools,
read/write user-chosen working directories, and talk to lp/CUPS. -->
</dict>
</plist>
+33
View File
@@ -0,0 +1,33 @@
import AppKit
import SwiftUI
@main
struct ICCeryApp: App {
@NSApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate
@State private var model = WizardViewModel()
var body: some Scene {
// Single fixed window (docs/21 §Shell: 1280×800, min 1100×700).
Window("ICCery", id: "main") {
RootView(model: model)
.frame(minWidth: 1100, minHeight: 700)
.preferredColorScheme(.dark)
}
.defaultSize(width: 1280, height: 800)
.windowResizability(.contentMinSize)
.defaultPosition(.center)
}
}
/// AppDelegate: quit when the single window closes, and give later
/// milestones a hook to `killAll` Argyll children before teardown
/// (#147/#149 wired once ProcessManager exists in #2).
final class AppDelegate: NSObject, NSApplicationDelegate {
func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool {
true
}
func applicationWillTerminate(_ notification: Notification) {
// Issue #2+: ProcessManager.shared.killAll()
}
}
+62
View File
@@ -0,0 +1,62 @@
import SwiftUI
/// Banner notice model the v2 equivalent of `#wizardNotification`
/// (docs/21 §Banner).
struct Notice: Identifiable, Equatable {
enum Kind: Equatable {
case info, warning, error
var symbolName: String {
switch self {
case .info: return "info.circle"
case .warning: return "exclamationmark.triangle"
case .error: return "xmark.octagon"
}
}
var tint: Color {
switch self {
case .info: return Theme.accent
case .warning: return .orange
case .error: return .red
}
}
}
let id = UUID()
let kind: Kind
let text: String
/// Auto-dismiss interval; `nil` keeps the banner until closed.
var autoHideAfter: TimeInterval? = 6
}
struct NoticeBanner: View {
let notice: Notice
let onClose: () -> Void
var body: some View {
HStack(spacing: 10) {
Image(systemName: notice.kind.symbolName)
.foregroundStyle(notice.kind.tint)
Text(notice.text)
.font(.callout)
.foregroundStyle(Theme.text)
.lineLimit(3)
Spacer()
Button(action: onClose) {
Image(systemName: "xmark")
}
.buttonStyle(.plain)
.foregroundStyle(.secondary)
}
.padding(.horizontal, 12)
.padding(.vertical, 8)
.background(Theme.panel)
.overlay(
Rectangle()
.frame(height: 1)
.foregroundStyle(Theme.border),
alignment: .bottom
)
}
}
+48
View File
@@ -0,0 +1,48 @@
import SwiftUI
/// Root layout: 270 pt sidebar + main stage area with the notification
/// banner pinned to the top (docs/21 §Shell).
struct RootView: View {
@Bindable var model: WizardViewModel
@State private var showingSettings = false
@State private var showingAbout = false
var body: some View {
HStack(spacing: 0) {
SidebarView(
model: model,
onOpenSettings: { showingSettings = true },
onOpenAbout: { showingAbout = true }
)
Rectangle()
.fill(Theme.border)
.frame(width: 1)
VStack(spacing: 0) {
if let notice = model.notice {
NoticeBanner(notice: notice, onClose: model.dismissNotice)
}
StagePlaceholderView(stage: model.stage)
}
}
.frame(minWidth: 1100, minHeight: 700)
.background(Theme.background)
.sheet(isPresented: $showingSettings) {
// Full settings dialog lands in issue #5.
VStack(spacing: 12) {
Text("Settings").font(.headline)
Text("Implemented in issue #5.")
.foregroundStyle(.secondary)
Button("Close") { showingSettings = false }
}
.padding(24)
.frame(width: 420)
}
.alert("ICCery 2.0.0", isPresented: $showingAbout) {
Button("OK") {}
} message: {
Text("Native macOS printer profiling workstation.\nFull About dialog lands in issue #31.")
}
}
}
+105
View File
@@ -0,0 +1,105 @@
import SwiftUI
import ICCeryCore
/// 270 pt sidebar (docs/21 §Shell): logo, settings/about buttons, preset
/// select, Calibrate Printer + status chip, and the 15 stepper.
struct SidebarView: View {
@Bindable var model: WizardViewModel
var onOpenSettings: () -> Void
var onOpenAbout: () -> Void
var body: some View {
VStack(alignment: .leading, spacing: 0) {
HStack {
Image("ICCery-logo")
.resizable()
.scaledToFit()
.frame(height: 40)
Spacer()
Button(action: onOpenSettings) {
Image(systemName: "gearshape")
}
.buttonStyle(.plain)
.help("Settings")
Button(action: onOpenAbout) {
Image(systemName: "info.circle")
}
.buttonStyle(.plain)
.help("About ICCery")
}
.padding(12)
Divider().overlay(Theme.border)
// Preset select (`#presetSelect`). Preset engine lands in #11.
Picker("Preset", selection: .constant("none")) {
Text("No preset").tag("none")
}
.pickerStyle(.menu)
.padding(.horizontal, 12)
.padding(.vertical, 8)
// Calibrate Printer (`#btnCalibratePrinter`); `#calStatusChip`
// is hidden until the calibration library lands in #29.
Button(action: { model.enterCalibration() }) {
Label("Calibrate Printer", systemImage: "slider.horizontal.3")
.frame(maxWidth: .infinity)
}
.controlSize(.large)
.padding(.horizontal, 12)
Divider().overlay(Theme.border)
.padding(.vertical, 8)
// Stepper 15.
VStack(alignment: .leading, spacing: 2) {
ForEach(WizardStage.stepperStages, id: \.self) { stage in
StepperRow(
stage: stage,
isActive: model.stage == stage
) {
model.go(to: stage)
}
}
}
.padding(.horizontal, 6)
Spacer()
}
.frame(width: Theme.Metrics.sidebarWidth)
.background(Theme.panel)
}
}
private struct StepperRow: View {
let stage: WizardStage
let isActive: Bool
let action: () -> Void
var body: some View {
Button(action: action) {
HStack(spacing: 10) {
ZStack {
Circle()
.fill(isActive ? Theme.accent : Theme.border)
.frame(width: 26, height: 26)
Text("\(stage.stepperIndex ?? 0)")
.font(.callout.bold())
.foregroundStyle(isActive ? .white : Theme.text)
}
Label(stage.title, systemImage: stage.symbolName)
.font(.callout)
.foregroundStyle(isActive ? Theme.text : .secondary)
Spacer()
}
.padding(.horizontal, 8)
.padding(.vertical, 6)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.background(
RoundedRectangle(cornerRadius: Theme.Metrics.cornerMedium)
.fill(isActive ? Theme.accent.opacity(0.15) : .clear)
)
}
}
+24
View File
@@ -0,0 +1,24 @@
import SwiftUI
import ICCeryCore
/// Placeholder stage surface for M1. Real stage UIs arrive in M2M5
/// (issues #7#31); Stage 0 lands in M6 (issue #29).
struct StagePlaceholderView: View {
let stage: WizardStage
var body: some View {
VStack(spacing: 16) {
Image(systemName: stage.symbolName)
.font(.system(size: 44))
.foregroundStyle(Theme.accent)
Text(stage.title)
.font(.title2)
.foregroundStyle(Theme.text)
Text("This stage is not implemented yet — see the milestone plan.")
.font(.callout)
.foregroundStyle(.secondary)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(Theme.background)
}
}
+21
View File
@@ -0,0 +1,21 @@
import SwiftUI
/// Design tokens carried over from the v1 stylesheet (docs/21 §Design tokens).
enum Theme {
static let background = Color(red: 0x1e / 255, green: 0x1e / 255, blue: 0x1e / 255)
static let panel = Color(red: 0x25 / 255, green: 0x25 / 255, blue: 0x26 / 255)
static let text = Color(red: 0xd4 / 255, green: 0xd4 / 255, blue: 0xd4 / 255)
static let accent = Color(red: 0x00 / 255, green: 0x7a / 255, blue: 0xcc / 255)
static let border = Color(red: 0x33 / 255, green: 0x33 / 255, blue: 0x33 / 255)
/// v1 window/titlebar backing colour (docs/02 §Window contract).
static let windowChrome = Color(red: 0x1a / 255, green: 0x1a / 255, blue: 0x22 / 255)
enum Metrics {
static let sidebarWidth: CGFloat = 270
static let buttonSmall: CGFloat = 28
static let buttonMedium: CGFloat = 36
static let buttonLarge: CGFloat = 40
static let cornerSmall: CGFloat = 4
static let cornerMedium: CGFloat = 6
}
}
+62
View File
@@ -0,0 +1,62 @@
import Foundation
import Observation
import ICCeryCore
/// Wizard shell state (issue #1). Artefact gating, persistence and the
/// "open existing" flow land in issue #4.
@MainActor
@Observable
final class WizardViewModel {
/// Currently displayed stage.
var stage: WizardStage = .generate
/// Banner notice currently displayed (`#wizardNotification`).
var notice: Notice?
/// Target basename shared across stages (`targetBasename`).
var basename: String = ""
/// Working directory for all Argyll artefacts.
var workingDirectory: URL?
/// Printer queue selected in Stage 2; retained across stages.
var printerName: String?
private var noticeDismissTask: Task<Void, Never>?
/// `true` while Stage 0 (printer calibration) is shown instead of a
/// stepper stage.
var isCalibrating: Bool { stage == .calibrate }
func go(to stage: WizardStage) {
self.stage = stage
}
func enterCalibration() {
stage = .calibrate
}
func exitCalibration() {
stage = .generate
}
func showNotice(_ text: String, kind: Notice.Kind = .info, autoHideAfter: TimeInterval? = 6) {
noticeDismissTask?.cancel()
let notice = Notice(kind: kind, text: text, autoHideAfter: autoHideAfter)
self.notice = notice
if let delay = notice.autoHideAfter {
noticeDismissTask = Task { [weak self] in
try? await Task.sleep(for: .seconds(delay))
guard !Task.isCancelled else { return }
if self?.notice?.id == notice.id {
self?.notice = nil
}
}
}
}
func dismissNotice() {
noticeDismissTask?.cancel()
notice = nil
}
}
+27
View File
@@ -0,0 +1,27 @@
import Testing
import Foundation
@testable import ICCeryCore
@Suite("AppPaths")
struct AppPathsTests {
@Test func appDataDirUsesBundleID() {
#expect(AppPaths.appDataDir.path.contains("Library/Application Support/com.gronod.iccery2"))
}
@Test func logFileIsUnderLibraryLogs() {
#expect(AppPaths.logFile.lastPathComponent == "iccery.log")
#expect(AppPaths.logFile.path.contains("Library/Logs/com.gronod.iccery2"))
}
@Test func bundledArgyllDirIsInsideResources() {
#expect(AppPaths.bundledArgyllDir.lastPathComponent == "Argyll")
}
}
@Suite("WizardStage")
struct WizardStageTests {
@Test func stepperOrderIsOneThroughFive() {
#expect(WizardStage.stepperStages.map(\.stepperIndex) == [1, 2, 3, 4, 5])
#expect(WizardStage.calibrate.stepperIndex == nil)
}
}
+80
View File
@@ -0,0 +1,80 @@
name: ICCery
options:
bundleIdPrefix: com.gronod
deploymentTarget:
macOS: "14.0"
groupSortPosition: top
packages:
ICCeryCore:
path: Packages/ICCeryCore
targets:
ICCery:
type: application
platform: macOS
deploymentTarget: "14.0"
sources:
- path: Sources/ICCery
- path: Resources
excludes:
- ICCery.entitlements
dependencies:
- package: ICCeryCore
product: ICCeryCore
settings:
base:
PRODUCT_BUNDLE_IDENTIFIER: com.gronod.iccery2
PRODUCT_NAME: ICCery
PRODUCT_BUNDLE_PACKAGE_TYPE: APPL
GENERATE_INFOPLIST_FILE: YES
INFOPLIST_KEY_CFBundleDisplayName: ICCery
INFOPLIST_KEY_LSMinimumSystemVersion: "14.0"
INFOPLIST_KEY_NSPrincipalClass: NSApplication
INFOPLIST_KEY_NSHumanReadableCopyright: "Copyright © 2026 Gronod. AGPLv3."
MARKETING_VERSION: "2.0.0"
CURRENT_PROJECT_VERSION: "1"
ENABLE_HARDENED_RUNTIME: YES
CODE_SIGN_ENTITLEMENTS: Resources/ICCery.entitlements
CODE_SIGN_IDENTITY: "-"
CODE_SIGN_STYLE: Automatic
ENABLE_APP_SANDBOX: NO
ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME: AccentColor
SWIFT_VERSION: "6.0"
SWIFT_STRICT_CONCURRENCY: complete
MACOSX_DEPLOYMENT_TARGET: "14.0"
ARCHS: "$(ARCHS_STANDARD)"
ICCeryCoreTests:
type: bundle.unit-test
platform: macOS
deploymentTarget: "14.0"
sources:
- path: Tests/ICCeryCoreTests
dependencies:
- package: ICCeryCore
product: ICCeryCore
- target: ICCery
settings:
base:
BUNDLE_LOADER: "$(TEST_HOST)"
TEST_HOST: "$(BUILT_PRODUCTS_DIR)/ICCery.app/Contents/MacOS/ICCery"
GENERATE_INFOPLIST_FILE: YES
CODE_SIGN_IDENTITY: "-"
SWIFT_VERSION: "6.0"
MACOSX_DEPLOYMENT_TARGET: "14.0"
schemes:
ICCery:
build:
targets:
ICCery: all
ICCeryCoreTests: [test]
run:
config: Debug
test:
config: Debug
gatherCoverageData: false
targets:
- ICCeryCoreTests