Skip to content

Architecture

ChiFungHillmanChan edited this page Sep 16, 2026 · 4 revisions

Architecture

Layered split (V0.4 current)

SceneCore (Swift Package)      pure logic, unit-testable without Xcode
└── Sources/SceneCore/
    ├── AX/                    Accessibility API wrappers
    ├── Animation/             Clock, FrameInterpolator, AnimationRunner (V0.2)
    ├── Display/               NSScreen helpers
    ├── Interaction/           Carbon hotkey + AX drag-swap (V0.3)
    ├── Layout/                Slot, Layout, LayoutEngine, Plan, Geometry,
    │                            LayoutTemplate, CustomLayout, PresetSeeds, LayoutStore (V0.2)
    ├── Settings/              AnimationConfig, HotkeyBinding, DragSwapConfig,
    │                            SettingsStore, Cancellable
    └── Workspace/             Workspace, WorkspaceTrigger, WorkspaceSeeds,
                                 WorkspaceStore, FocusModeReference (V0.4)

SceneApp (Xcode target)        thin SwiftUI/AppKit shell + settings window
└── SceneApp/SceneApp/
    ├── Animation/             WindowAnimator (CVDisplayLink + AX bridge) (V0.2)
    ├── Interaction/           AXMoveObserverGroup, AXWindowLookup,
    │                            DragSwapAnimationSink (V0.3)
    ├── Settings/              SettingsWindowController + SettingsRoot + 5 tabs
    │                            (Workspaces / Layouts / Hotkeys / Interaction / About) +
    │                            LayoutEditorView + HotkeyCaptureView +
    │                            WorkspacesTab + WorkspaceEditorView +
    │                            WorkspaceTriggerEditor + AppPickerView +
    │                            LayoutPickerView + LayoutThumbnail (V0.4)
    ├── Stores/                LayoutStoreViewModel, SettingsStoreViewModel,
    │                            WorkspaceStoreViewModel (V0.4)
    ├── Workspace/             WorkspaceActivator, AppLauncher, FocusController
    │   └── Triggers/          MonitorTriggerWatcher, TimeTriggerScheduler,
    │                            CalendarTriggerWatcher, TriggerSupervisor (V0.4)
    ├── Resources/             Localizable.xcstrings (133 keys × 4 locales),
    │                            InfoPlist.xcstrings (V0.4)
    ├── SceneAppApp.swift      @main + MenuBarExtra
    ├── AppDelegate.swift
    ├── Coordinator.swift      orchestration layer
    ├── MenuBarContentView.swift
    ├── OnboardingView.swift + OnboardingWindowController.swift
    └── NotificationHelper.swift

Rule of thumb: anything you want a unit test for goes in SceneCore. Anything that touches NSApp, MenuBarExtra, NSWindow, CVDisplayLink, NSWorkspace, EventKit, or SwiftUI binding goes in SceneApp.

Why the split:

  • SceneCore is pure enough that swift test runs all 157 unit tests without a code-signed bundle — contributors without Apple Developer accounts can verify logic, including the V0.2 animation state machine, V0.3 drag-swap geometry, and V0.4 Workspace trigger encoding / store CRUD.
  • Xcode project stays small. project.pbxproj churn is minimal because PBXFileSystemSynchronizedRootGroup auto-includes new Swift files under SceneApp/SceneApp/.
  • V0.5 work (per-display layouts, pattern learning, AI / NL input) slots into existing boundaries without restructuring.

Framework neutrality (V0.2 invariant, still enforced)

Sources/SceneCore/ has no import Combine, no import SwiftUI, no ObservableObject. Stores expose a closure-based observation API:

// In SceneCore
public func onChange(_ handler: @escaping () -> Void) -> Cancellable

Cancellable is SceneCore's own lightweight token (closure-based, deinit calls cancel). SwiftUI binding lives in SceneApp/SceneApp/Stores/*ViewModel.swift adapters — LayoutStoreViewModel, SettingsStoreViewModel, and the V0.4 WorkspaceStoreViewModel all follow the same pattern:

@MainActor
final class LayoutStoreViewModel: ObservableObject {
    let store: LayoutStore
    @Published private(set) var layouts: [CustomLayout] = []
    private var token: Cancellable?

    init(store: LayoutStore) {
        self.store = store
        self.layouts = store.layouts
        self.token = store.onChange { [weak self] in
            Task { @MainActor in self?.layouts = self?.store.layouts ?? [] }
        }
    }
}

This isolation makes the SceneCore bundle reusable from non-SwiftUI hosts (CLI tool, future XPC service, tests) without dragging in Combine.

Animation engine split (V0.2)

The hard part — state machine, interruption, cancellation, frame math — lives in pure SceneCore and is deterministically testable. The thin AppKit bridge owns the display link.

AnimationRunner (SceneCore, pure)

  • start(tracks:, config:), tick(now:), interrupt(newTargets:, config:, now:), cancel()
  • Holds lastEmitted: [CGWindowID: CGRect] so interrupt mid-flight uses the currently-interpolated frame as the new start (no jump)
  • Writes via injected WindowFrameSink protocol; reads time via injected Clock
  • Unit-tested via MockClock + RecordingSink

WindowAnimator (SceneApp, thin bridge)

  • Implements WindowFrameSink via AXUIElementSetAttributeValue for position + size
  • Owns CVDisplayLink (60fps); each tick is dispatched to main and calls runner.tick(now: CACurrentMediaTime())
  • Manages display-link lifecycle (start, invalidate on cancel/finish, single-link guarantee on rapid retrigger)
Coordinator.applyLayout(custom)
  ↓
windows.count > 6 OR animation.disabled?
  ├─ yes → LayoutEngine.apply(plan, on:) — instant snap (no jank for many windows)
  └─ no  → WindowAnimator.animate(windows, placements, config)
            ↓
            AnimationRunner.start(tracks, config)
            CVDisplayLink @ 60fps
              ↓
              each tick:
                runner.tick(now: CACurrentMediaTime())
                  ↓ for each track: FrameInterpolator → sink.write(AX position + size)
              t >= 1: snap exact target → invalidate display link

V0.4 Workspace model

A Workspace bundles a layout, lists of apps to launch / quit, an optional Focus mode reference, an optional hotkey, and 0+ triggers.

public struct Workspace: Codable, Equatable, Identifiable, Sendable {
    public let id: UUID
    public var name: String
    public var layoutID: UUID                       // references CustomLayout.id
    public var appsToLaunch: [String]               // bundle IDs
    public var appsToQuit: [String]
    public var focusMode: FocusModeReference?       // Shortcuts name pair (on / off)
    public var hotkey: HotkeyBinding?
    public var triggers: [WorkspaceTrigger]
    public var isPresetSeed: Bool
    public var isModified: Bool
}

public enum WorkspaceTrigger: Codable, Equatable, Sendable {
    case manual
    case monitorConnect(displayName: String)
    case monitorDisconnect(displayName: String)
    case timeOfDay(weekdayMask: WeekdayMask, hour: Int, minute: Int)
    case calendarEvent(keywordContains: String)
}
  • Bundle IDs, not paths. Stable across app moves; NSWorkspace.urlForApplication(withBundleIdentifier:) resolves on demand.
  • Focus via Shortcuts URL scheme (shortcuts://run-shortcut?name=…). The macOS Focus API is private; the Shortcuts URL is the documented automation path. User creates "Setpiece Coding Focus On/Off" shortcuts in Shortcuts.app; Workspace references them by name.
  • WorkspaceSeeds.all ships 4 seeds: Coding / Meeting / Reading / Streaming. Hotkeys ⌘⌥1–4. appsToLaunch / appsToQuit start empty (user-specific). Focus references left unset.
  • WorkspaceStore persists to ~/Library/Application Support/Scene/workspaces.json, mirroring LayoutStore: atomic write, closure observation, knownSeedUUIDs migration, block-save hotkey conflict.

V0.4 cross-store hotkey conflict

Workspaces and Layouts share a single chord namespace (pressing ⌘⌥1 can't mean two different things at once). Enforcement uses a two-phase setHotkeyConflictProbe setter pattern on both stores:

  1. Before committing an update, the editing store asks: "does my peer store already own this chord?"
  2. The peer returns an owner descriptor (name + kind = workspace or layout) or nil.
  3. If owned, the editing store throws .hotkeyConflict(existingResource:) and the UI surfaces the conflicting resource's name.

This means the user can never end up with an ambiguous binding, and the error message always names the specific resource (layout or workspace) that holds the chord.

V0.4 Workspace activation — 7 steps

WorkspaceActivator.activate(workspace:) orchestrates the full swap:

1. If there is a previously-active Workspace: FocusController.run(previous.focusMode.shortcutNameOff)
2. AppLauncher.quit(bundleIDs: workspace.appsToQuit) — gentle terminate
     5s grace period; survivors surface a "could not quit X — unsaved work?" notification
     NO force-terminate — protects unsaved work by design
3. AppLauncher.launch(bundleIDs: workspace.appsToLaunch) — parallel
4. Settle 1.5s (let new windows appear before layout math runs)
5. Coordinator.applyLayout(id: workspace.layoutID) — reuses V0.2 pipeline
6. FocusController.run(workspace.focusMode.shortcutNameOn) if non-nil
7. WorkspaceStore.setActive(workspace.id)  +  notification banner

Deactivating Workspace A (by activating B) does not re-run A's appsToQuit — quitting happens on activation only. Users who want guaranteed closure put the app in B's appsToQuit.

V0.4 Trigger architecture

TriggerSupervisor  (owned by Coordinator, lives in SceneApp/Workspace/Triggers/)
  ├→ MonitorTriggerWatcher
  │     NSScreen.didChangeNotification → diff old vs new screen list
  │     fires .monitorConnect(displayName) / .monitorDisconnect(displayName)
  │
  ├→ TimeTriggerScheduler
  │     Timer every 30s → DateComponents matching (weekdayMask + hour + minute)
  │     last-fire-minute debounce
  │
  └→ CalendarTriggerWatcher
        EKEventStore poll every 60s for events starting in next 5 min
        case-insensitive substring match on event title
        permission requested lazily (only when first .calendarEvent trigger is added)

TriggerSupervisor.evaluate(event):
  ├→ Lookup matching Workspaces — first match wins (ordered Workspaces list)
  ├→ Skip if matched Workspace.id == activeWorkspaceID (no thrash)
  ├→ Apply 30s cooldown per Workspace for auto-triggers; manual bypasses
  └→ Coordinator.applyWorkspace(id: matched.id)

First-match-wins ordering means the user controls priority by reordering Workspaces in the Workspaces tab. 30s cooldown prevents monitor bounce / clock jitter from re-firing a Workspace; manual hotkey / menu click bypasses the cooldown.

V0.4 multilingual UI

  • Tooling: Xcode 15+ String Catalog. Single Localizable.xcstrings (133 keys) + InfoPlist.xcstrings.
  • Locales: English (en), 繁體中文(港) (zh-HK) full parity at ship. 繁體中文(台) (zh-TW) + 日本語 (ja) partial — missing keys fall back to en.
  • Key conventions:
    • settings.tab.<name> — tab labels
    • settings.action.<verb> — buttons
    • settings.field.<name> — field labels
    • menu.<section>.<item> — menu bar items
    • onboarding.<section> — onboarding copy
    • error.<context> — error alerts
    • workspace.<concept> — Workspace UI
  • No in-app language picker. SwiftUI picks the user's system locale on launch. An in-app switcher is a V0.5+ candidate.
  • Cantonese house style: 粵語 not 書面語. Matches README.zh-HK.md.

Core flow (V0.4 unchanged from V0.2)

Coordinator.applyLayout(_ custom: CustomLayout) is the orchestration entry point. Every path (menu click, global hotkey, drag-swap, Workspace activation) ends up here.

applyLayout(custom)
  ↓
AXPermission.forceRecheck()        ← uses AXIsProcessTrustedWithOptions(prompt: false)
  ├─ denied → OnboardingWindowController.show(); STOP
  └─ granted ↓
ScreenResolver.activeScreen()      ← screen under the mouse
  ↓
AXWindowEnumerator.listVisibleWindows(on:)
  ↓ filters: current Space, layer==0, not minimized, not fullscreen, center on this screen
  ↓
LayoutEngine.plan(windows, visibleFrame, custom.toLayout())   ← PURE function
  ↓
shouldAnimate = settings.animation.enabled && windows.count <= 6
  ├─ yes → WindowAnimator.animate(...) + minimize overflow synchronously
  └─ no  → LayoutEngine.apply(plan, on: windows) all-instant

For a Workspace activation, the flow is the same from step "applyLayout" onward — WorkspaceActivator wraps it with the quit / launch / settle / Focus steps above.

V0.2 customization model

  • LayoutTemplate (enum, 11 cases): single, twoCol/threeCol, twoRow/threeRow, grid2x2/grid3x2, lShape{Left,Right,Top,Bottom}. Each defines expectedProportionsCount, slotCount, defaultProportions, and a pure slots(proportions:) -> [Slot].
  • CustomLayout (Codable): id: UUID, name, template, slotProportions, hotkey?, isPresetSeed, isModified. toLayout() builds a Layout value the engine can plan against.
  • PresetSeeds.all (V0.4: 10 seeds): V0.1's 7 layouts with stable UUIDs (11111111-000N-0000-0000-000000000N) plus V0.4's 3 vertical seeds (-0008 / -0009 / -000a). Default hotkeys are now ⌘⌃1–9,0 (moved off ⇧ to dodge macOS ⌘⇧3/4/5 screenshots). Slot equivalence with V0.1 is pinned by testSeedSlotsMatchV01Layouts — the safety net that lets routing swap from LayoutID to UUID without behavioral change.
  • LayoutStore: atomic JSON write to ~/Library/Application Support/Scene/layouts.json. applyFutureSeeds(...) adds new seeds only when their UUID is not in knownSeedUUIDs — the V0.4 vertical seeds are this mechanism's first production use, and existing V0.2 installs gain them automatically without losing any deletions of the original 7.

V0.2 / V0.4 settings model

  • AnimationConfig (V0.2): enabled, durationMs (clamped 100-500), easing. Default = enabled / 250ms / easeOut.
  • DragSwapConfig (V0.3): enabled, distanceThreshold (10-100pt). Default = enabled / 20pt.
  • HotkeyBinding: keyCode: UInt32, modifiers: Set<HotkeyModifier>. Includes carbonModifiers: UInt32 bridge for RegisterEventHotKey, displayString (⌘⌃A glyphs), conflicts(with:) for store-side enforcement.
  • SettingsStore: persists to ~/Library/Application Support/Scene/settings.json.

Settings window lifecycle

The app is menu-bar-only by default (NSApp.activationPolicy = .accessory). Opening Settings toggles to .regular (Dock icon appears); windowWillClose toggles back. SettingsWindowController is lazy on AppDelegate and reuses the same window across opens (window.isReleasedWhenClosed = false).

V0.4 tab order: Workspaces (new, first) → Layouts → Hotkeys → Interaction → About.

Permission boundary

LayoutEngine never sees .noPermission. Permission failures bubble up from AXWindowEnumerator.listVisibleWindows as EnumerationError.permissionDenied, caught by Coordinator, which reopens the onboarding window.

V0.2 fix carried forward: AXIsProcessTrusted() can return a stale false for the lifetime of the process when permission is granted while the app is running. AXPermission.forceRecheck() (uses AXIsProcessTrustedWithOptions(prompt: false)) fixes this for the poll loop and the onboarding "Check again" button.

V0.4 TCC cdhash quirk (new for developers): ad-hoc signed apps have their AX grant tied to the binary's cdhash. Every rebuild produces a new cdhash, so the toggle in System Settings → Privacy & Security → Accessibility looks on but the AX API reports denied. Fix: toggle OFF then ON again in System Settings, or run tccutil reset Accessibility com.hillman.SceneApp. See Troubleshooting for details.

EventKit permission (V0.4)

Lazy. Setpiece does not request Calendar access at launch. CalendarTriggerWatcher calls EKEventStore.requestAccess(to: .event) only when the user adds the first .calendarEvent trigger in the Workspace editor. The editor surfaces permission state (granted / denied / not determined) so users can re-grant without leaving Setpiece.

Coordinate convention: screen.visibleFrame everywhere

Every layout-touching function uses visibleFrame — the rect that excludes menu bar and Dock. Filter, plan, apply, animate, drag-swap all use the same rect so windows can never be planned outside the usable area. Never use screen.frame in layout math.

Module-name and type-name collisions

  1. SceneCore enum vs SceneCore module name: Sources/SceneCore/SceneCore.swift declares public enum SceneCore, which shadows the module name in member lookup. SceneCore.Cancellable resolves to the enum, not the module. Workaround: import class SceneCore.Cancellable.
  2. WindowRef ambiguity: AppKit transitively imports ApplicationServices, which has a QuickDraw WindowRef typedef. In SceneApp files, use import protocol SceneCore.WindowRef plus typealias SceneWindowRef = WindowRef.

Why Carbon for hotkeys

RegisterEventHotKey is deprecated but remains the only API that works for menu-bar apps that need global hotkeys. NSEvent.addGlobalMonitorForEvents can't consume events, so the keystroke would still reach the frontmost app. All known window managers (Rectangle, Magnet, EpycZones, etc.) use Carbon for the same reason.

Concurrency

  • Most of SceneCore is sync and non-actor-isolated (so it stays usable from any host).
  • AnimationRunner is class-based and not @MainActor — the SceneApp bridge ensures tick() is called on main.
  • DragSwapController is @MainActor (instantiates NSWindow for preview overlay).
  • Coordinator, WindowAnimator, WorkspaceActivator, AppLauncher, FocusController, and all three trigger watchers are @MainActor.
  • Files declaring @Published / ObservableObject without importing SwiftUI must import Combine explicitly.

Clone this wiki locally