diff --git a/README.md b/README.md
index 7c5ab30..0c0bf5a 100644
--- a/README.md
+++ b/README.md
@@ -11,7 +11,9 @@
-Native macOS utilities in one menu bar app. Sound provides independent app volume, output routing, ISO 226 equal-loudness compensation, and AutoEQ headphone correction. Awake requests that macOS prevent automatic idle sleep for a chosen duration. Away covers each display with a customizable privacy curtain and requires Mac authentication or a Semper PIN to exit. External Displays provides supported brightness, contrast, volume, and input controls, while Scenes group reversible settings.
+Control windows, files, displays, power, and sound from one native macOS menu bar app. Add the utilities you need, use them independently, or combine settings with Scenes and Presentation. Away provides an authenticated privacy curtain.
+
+The current download, v1.0.0, contains Sound. The other modules are integrated in this repository and are in development toward the next release. The [product status guide](guide/product-status.md) records each module's state.
[semper.systems](https://www.semper.systems/)
@@ -34,6 +36,10 @@ Semper is an open-source project founded and led by [**Nihar Manchikakapudi**](h
Open the disk image and drag **Semper** into **Applications**. Requires
macOS 15.4 or later.
+The current release is v1.0.0, published 2026-08-26, and contains the Sound
+utility. The other modules described above are not in this download; building
+from source runs current development code.
+
With [Homebrew](https://brew.sh):
```bash
@@ -115,6 +121,10 @@ in your keychain so macOS can recognize later source updates as the same app.
## Documentation & Guides
+- [Product Status](guide/product-status.md)
+- [Module Shell](guide/module-shell.md)
+- [Direct Utilities: Workspace Restore, File Shelf, Safe Eject](guide/direct-utilities.md)
+- [Awake Sessions](guide/awake-sessions.md)
- [URL Schemes](guide/url-schemes.md)
- [App Shortcuts](guide/app-shortcuts.md)
- [Experiments](guide/experiments.md)
diff --git a/ROADMAP.md b/ROADMAP.md
index 1627d01..32fac9e 100644
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -1,63 +1,111 @@
# Semper roadmap
-Semper publishes signed macOS releases. This roadmap describes the work needed
-to keep distribution dependable and grow the project without hiding
-experimental behavior.
+Semper is one menu bar app with nine utility modules: Sound, Awake, Displays,
+Workspace Restore, File Shelf, Safe Eject, Scenes, Away, and Presentation. All
+nine are integrated on `main`. The downloadable release is v1.0.0, which
+contains Sound only. This roadmap orders the work to deliver the whole suite as
+dependable signed releases without hiding experimental behavior. Per-module
+state lives in the [product status guide](guide/product-status.md).
## Current priorities
-### 1. Release maintenance
-
-- Test signed and notarized builds on a clean Mac before publication.
-- Verify install, permission, relaunch, update, and uninstall behavior for each
- release.
-- Publish accurate release notes and known device limits.
-- Keep the website, README, Homebrew cask, and update feed tied to the same
- release artifact.
-
-### 2. Modules and utility foundation
-
-- Keep Sound and Awake independent while they share the same menu bar shell.
-- Add new utilities only when they have a clear local use case and no hidden account requirement.
-- Keep module actions testable without live audio or power-management side effects.
-
-### 3. Audio reliability
-
-- Add focused tests around tap lifecycle, crossfades, output gating, and device
- reconnect behavior.
-- Collect reproducible reports for apps with custom audio engines.
-- Preserve real-time callback safety and resource teardown order.
-
-### 4. Device compatibility
-
-- Record verified behavior for built-in, Bluetooth, USB, HDMI, DisplayPort,
- DDC, aggregate, and virtual devices.
-- Improve handling for devices that report controls they do not actually
- support.
-- Document where software volume or ignored-app behavior is the correct
- fallback.
-
-### 5. Interface and accessibility
-
-- Audit keyboard navigation, focus order, VoiceOver labels, contrast, reduced
- motion, and visible capability states.
-- Keep the menu-bar popup and settings behavior consistent.
-- Add tests for state transitions that do not require live audio hardware.
-
-### 6. Contributor documentation
-
-- Keep starter issues small, testable, and unclaimed until someone begins.
-- Add architecture notes for high-risk audio paths.
-- Turn verified device reports and recurring support answers into guides.
+### 1. Ship the integrated multi-utility suite
+
+- Complete native acceptance of the integrated build: signed clean-Mac install,
+ real permission prompts, hardware behavior, accessibility, update, and
+ uninstall. The [status guide](guide/product-status.md) records the gates.
+- Publish the next release only when the website, README, release notes,
+ Homebrew cask, and update feed describe the same artifact.
+- Keep the released-versus-development boundary explicit everywhere.
+
+### 2. Shell, navigation, permissions, and lifecycle
+
+- Keep one predictable shell: Home summaries, action search, favorites, and
+ module add, pause, resume, and remove.
+- Adding a module must continue to start no service and request no permission.
+ First explicit use creates the runtime and states its permission reason.
+- Denied or revoked permissions, limited runtimes, and failed cleanup stay
+ visible with a recovery path. Quit drains composed sessions before the
+ services they use. See the [module shell guide](guide/module-shell.md).
+- Finish the current interaction gaps first: keyboard-accessible file selection
+ in File Shelf and cancellation while Presentation prepares or starts.
+
+### 3. Window Layout
+
+The next planned increment. It is not implemented today.
+
+- Manual placement commands: left half, right half, maximize to the usable
+ screen area, center, and restore the last placement.
+- Built in its own branch on the existing Workspace Restore window helpers.
+- Manual actions only: no automatic tiling and no window watching.
+- Preserve the intended window when the menu bar takes focus. Verify each
+ placement and keep later manual adjustments intact when restoring.
+- Test half and maximized windows through later center and restore actions
+ without weakening Workspace Restore's fullscreen protections.
+
+### 4. Cross-module workflows
+
+- Scenes and Presentation compose the other modules: preview before apply,
+ verified writes, reverse-order recovery, and visible partial failures.
+- Composition stays a feature, not a requirement. Every module must remain
+ useful on its own.
+
+### 5. Module depth guided by user jobs and reuse
+
+- Sound and Displays: grow verified device compatibility and document correct
+ fallbacks for devices that misreport controls.
+- Workspace Restore: arrangement reliability across displays, Spaces, and
+ restarts.
+- File Shelf and Safe Eject: behavior improvements from reproducible reports,
+ keeping original files and volumes safe.
+- After File Shelf's file picker, add **Resize Image Copy** for one selected
+ local JPEG or PNG. Offer 1024 or 2048 pixels on the longest edge without
+ enlargement, show output dimensions, and save a separate copy. Preserve
+ orientation, color and transparency, explain metadata handling, and support
+ cancellation. No batch processing, uploads, or original-file replacement.
+- Awake and Away: keep power assertions and the curtain testable and honest
+ about what they do not block.
+- A new utility needs a clear local user job, no account requirement, the
+ shared lifecycle and disclosure rules, and reuse of existing services where
+ reasonable.
+
+### 6. Quality, accessibility, and contributor documentation
+
+- Preserve real-time audio callback safety. Add focused tests around tap
+ lifecycle, crossfades, output gating, and device reconnect behavior.
+- Audit keyboard navigation, focus order, VoiceOver labels, contrast, and
+ reduced motion across all module surfaces.
+- Turn verified device reports and recurring support answers into guides, and
+ keep starter issues small, testable, and unclaimed until someone begins.
+
+## Reference utilities
+
+Focused specialist tools set the expectations each Semper module must meet:
+
+- [Rectangle](https://github.com/rxhanson/Rectangle) for window placement
+- [MonitorControl](https://github.com/MonitorControl/MonitorControl) for
+ external display control
+- [Amphetamine](https://apps.apple.com/us/app/amphetamine/id937984704) for
+ awake sessions
+- [Dropover](https://dropoverapp.com/) for a file shelf
+- [Vorssaint utilities](https://github.com/vorssaint/vorssaint-utils) for a
+ broad free modular suite
+- [FineTune](https://github.com/ronitsingh10/FineTune) for per-app audio with
+ AutoEQ and ISO 226 loudness compensation
+
+Semper has not benchmarked against these tools and claims no superiority. The
+case for Semper is one shell with shared lifecycle, disclosure, and recovery
+rules, and modules that can work together.
## Contribution levels
- **Starter:** documentation, pure-function tests, accessibility labels, and
isolated UI state.
- **Intermediate:** parser behavior, settings state, device classification,
- and failure recovery with tests.
-- **Advanced:** process taps, aggregate devices, HAL callback code, crossfades,
- DSP lifecycle, and output safety.
+ module lifecycle transitions, and failure recovery with tests.
+- **Advanced:** process taps, aggregate devices, HAL callback code, DSP
+ lifecycle, DDC transport, Accessibility window operations, Disk Arbitration,
+ power assertions, and curtain input filtering.
Use [good first issues](https://github.com/niharnm/Semper/contribute) for a
first pull request. Advanced work should begin with a GitHub issue or
@@ -66,11 +114,15 @@ discussion and include a hardware test plan.
## Current boundaries
- Supported platform: macOS 15.4 or later.
-- Available today: Sound controls, timed or indefinite Awake sessions, source
- builds, unit tests, the static website, and signed macOS releases through
- GitHub and Homebrew.
+- Downloadable today: v1.0.0, published 2026-08-26, with Sound. Source builds,
+ unit tests, the static website, and signed releases through GitHub and
+ Homebrew are current.
+- Integrated on `main` and in no download yet: Awake, Displays, Workspace
+ Restore, File Shelf, Safe Eject, Scenes, Away, and Presentation.
+- Planned additions: Window Layout and File Shelf's Resize Image Copy action.
- Hardware-dependent: process taps, device routing, DDC, Bluetooth call mode,
- media keys, and permission behavior.
+ media keys, Accessibility window operations, volume ejection, and permission
+ behavior.
- Release-dependent: automatic updates require a current signed feed, and broad
- device compatibility claims require verified hardware reports.
+ compatibility claims require verified hardware reports.
- Out of scope today: Windows, Linux, iOS, cloud accounts, and audio recording.
diff --git a/Semper/Presentation/PresentationController.swift b/Semper/Presentation/PresentationController.swift
index 516667e..4c481d2 100644
--- a/Semper/Presentation/PresentationController.swift
+++ b/Semper/Presentation/PresentationController.swift
@@ -102,6 +102,10 @@ final class PresentationController {
phase == .preview && !isBusy && (scenePreview?.canApply ?? true)
}
+ var canCancelOperation: Bool {
+ isBusy && (phase == .preparing || phase == .starting)
+ }
+
var retainedModules: Set {
guard reservation != nil else { return [] }
var result: Set = [.scenes]
@@ -143,7 +147,7 @@ final class PresentationController {
try Task.checkCancellation()
self.phase = .preview
} catch {
- self.message = error.localizedDescription
+ self.message = error is CancellationError ? "Presentation cancelled." : error.localizedDescription
await self.recoverAfterFailure()
throw error
}
@@ -177,6 +181,7 @@ final class PresentationController {
if let plan = draft.workspacePlan, let workspace = self.workspace {
let result = await workspace.apply(plan, ownerToken: token)
self.workspaceReceipt = result
+ try Task.checkCancellation()
guard result.outcome == .completed else { throw PresentationError.workspacePartial }
}
try Task.checkCancellation()
@@ -184,7 +189,7 @@ final class PresentationController {
self.phase = .active
self.message = "Presentation is active. Later manual changes will be preserved during restore."
} catch {
- self.message = error.localizedDescription
+ self.message = error is CancellationError ? "Presentation cancelled." : error.localizedDescription
await self.recoverAfterFailure()
throw error
}
diff --git a/Semper/Presentation/PresentationView.swift b/Semper/Presentation/PresentationView.swift
index f52c04f..30dd2e0 100644
--- a/Semper/Presentation/PresentationView.swift
+++ b/Semper/Presentation/PresentationView.swift
@@ -28,7 +28,15 @@ struct PresentationView: View {
.textSelection(.enabled)
}
if controller.isBusy {
- HStack { ProgressView().controlSize(.small); Text("Finishing the current operation…") }
+ HStack {
+ ProgressView().controlSize(.small)
+ Text("Finishing the current operation…")
+ if controller.canCancelOperation {
+ Button("Cancel Presentation") { run { try await controller.stop() } }
+ .keyboardShortcut(.cancelAction)
+ .help("Cancel preparation or startup and restore any applied changes.")
+ }
+ }
}
if controller.reservation == nil {
if runtime.scenes?.hasPendingRestore == true {
@@ -304,6 +312,7 @@ struct PresentationView: View {
private func run(_ action: @escaping @MainActor () async throws -> Void) {
Task { @MainActor in
do { errorMessage = nil; try await action() }
+ catch is CancellationError { errorMessage = nil }
catch { errorMessage = error.localizedDescription }
}
}
diff --git a/Semper/SemperApp.swift b/Semper/SemperApp.swift
index 22aca2a..cbbcbf8 100644
--- a/Semper/SemperApp.swift
+++ b/Semper/SemperApp.swift
@@ -276,6 +276,7 @@ struct SemperApp: App {
FluidMenuBarExtra("Semper", image: launchIconImage, isInserted: $showMenuBarExtra) {
if let runtime { UtilityShellView(runtime: runtime, compact: true) }
}
+ .commandsRemoved() // The library supplies its own empty Settings scene.
}
init() {
diff --git a/Semper/Shelf/ShelfFileSelection.swift b/Semper/Shelf/ShelfFileSelection.swift
new file mode 100644
index 0000000..7990d07
--- /dev/null
+++ b/Semper/Shelf/ShelfFileSelection.swift
@@ -0,0 +1,35 @@
+import AppKit
+
+@MainActor
+protocol ShelfFileChoosing: AnyObject {
+ func chooseFiles() async -> [URL]?
+ func cancel()
+}
+
+@MainActor
+final class NativeShelfFileChooser: ShelfFileChoosing {
+ private var panel: NSOpenPanel?
+
+ func chooseFiles() async -> [URL]? {
+ let panel = NSOpenPanel()
+ panel.canChooseFiles = true
+ panel.canChooseDirectories = true
+ panel.allowsMultipleSelection = true
+ panel.resolvesAliases = true
+ panel.prompt = "Add to Shelf"
+ panel.message = "Choose files or folders. Original items stay in place."
+ self.panel = panel
+ return await withCheckedContinuation { continuation in
+ panel.begin { response in
+ Task { @MainActor in
+ self.panel = nil
+ continuation.resume(returning: response == .OK ? panel.urls : nil)
+ }
+ }
+ }
+ }
+
+ func cancel() {
+ panel?.cancel(nil)
+ }
+}
diff --git a/Semper/Shelf/ShelfModels.swift b/Semper/Shelf/ShelfModels.swift
index d3c8713..602f46e 100644
--- a/Semper/Shelf/ShelfModels.swift
+++ b/Semper/Shelf/ShelfModels.swift
@@ -57,9 +57,9 @@ nonisolated enum ShelfFileState: Equatable, Sendable {
var message: String {
switch self {
case .available(let directory): directory ? "Folder reference" : "File reference"
- case .missing: "Original file is missing. Locate it in Finder and drop it again."
+ case .missing: "Original file is missing. Locate it in Finder and add it again."
case .cloudOnly: "Download this item in Finder, then refresh the shelf."
- case .inaccessible: "File access is unavailable. Drop the item again to grant access."
+ case .inaccessible: "File access is unavailable. Choose or drop the item again to grant access."
}
}
var isAvailable: Bool {
@@ -79,7 +79,7 @@ nonisolated enum ShelfFailure: Error, Equatable, LocalizedError, Sendable {
case .unsupported: "This drop has no supported file, image, link, or plain-text representation."
case .missing: "The original file is missing."
case .cloudOnly: "Download this item in Finder before using it."
- case .inaccessible: "The item cannot be read. Check access in Finder and drop it again."
+ case .inaccessible: "The item cannot be read. Check access in Finder, then choose or drop it again."
case .invalidStore: "Saved shelf data could not be read. It has been left untouched."
case .storeVersion: "This saved shelf uses a newer format. It has been left untouched."
case .storeWrite: "The shelf could not save its local data."
diff --git a/Semper/Shelf/ShelfService.swift b/Semper/Shelf/ShelfService.swift
index 5ecf1a4..af98d7f 100644
--- a/Semper/Shelf/ShelfService.swift
+++ b/Semper/Shelf/ShelfService.swift
@@ -20,6 +20,7 @@ final class ShelfService {
private(set) var persistenceEnabled = false
private(set) var defaultExpiry: ShelfExpiry = .quit
private(set) var importCount = 0
+ private(set) var isChoosingFiles = false
private(set) var message: String?
private(set) var storeNeedsReset = false
private var pendingImportCleanup: Set = []
@@ -27,6 +28,11 @@ final class ShelfService {
private var importCancellationCount = 0
var canClear: Bool { !items.isEmpty || !pendingImportCleanup.isEmpty || importCleanupNeedsRetry }
+ var canChooseFiles: Bool {
+ isRunning && !isStopping && !isClearing && !storeNeedsReset && !isChoosingFiles
+ && importCount == 0 && importCancellationCount == 0 && removingIDs.isEmpty
+ && pendingImportCleanup.isEmpty && !importCleanupNeedsRetry && items.count < ShelfLimits.items
+ }
let store: ShelfStore
@ObservationIgnored private let access: any ShelfFileAccess
@@ -39,7 +45,8 @@ final class ShelfService {
@ObservationIgnored private var generation = 0
@ObservationIgnored private var clearGeneration = 0
@ObservationIgnored private var clearTask: Task, Never>?
- @ObservationIgnored private var removingIDs: Set = []
+ private var removingIDs: Set = []
+ @ObservationIgnored private let fileChooser: any ShelfFileChoosing
@ObservationIgnored private let importer:
@MainActor (NSItemProvider, ShelfStore) async throws -> ShelfImportedPayload
@ObservationIgnored private var stopTask: Task?
@@ -48,12 +55,14 @@ final class ShelfService {
init(
store: ShelfStore = .standard, access: any ShelfFileAccess = NativeShelfFileAccess(),
now: @escaping @Sendable () -> Date = { Date() },
+ fileChooser: any ShelfFileChoosing = NativeShelfFileChooser(),
importer: @escaping @MainActor (NSItemProvider, ShelfStore) async throws -> ShelfImportedPayload =
ShelfDropImporter.load
) {
self.store = store
self.access = access
self.now = now
+ self.fileChooser = fileChooser
self.importer = importer
}
@@ -346,7 +355,48 @@ final class ShelfService {
checksums[id] = .cancelled
}
- func importDrops(_ providers: [NSItemProvider]) -> Bool {
+ @discardableResult
+ func chooseFiles() -> Bool {
+ guard removingIDs.isEmpty else {
+ message = "Wait for the item removal to finish before choosing files."
+ return false
+ }
+ guard admitImport(count: 1) else { return false }
+ let currentGeneration = generation
+ let id = UUID()
+ let task = Task { [weak self] in
+ guard let self else { return }
+ defer {
+ self.importTasks[id] = nil
+ self.importCount = 0
+ self.isChoosingFiles = false
+ }
+ guard !Task.isCancelled, self.isRunning, self.generation == currentGeneration else { return }
+ guard let urls = await self.fileChooser.chooseFiles() else { return }
+ guard !Task.isCancelled, self.isRunning, self.generation == currentGeneration else { return }
+ guard self.removingIDs.isEmpty else {
+ self.message = "Wait for the item removal to finish before choosing files."
+ return
+ }
+ guard urls.count <= ShelfLimits.items - self.items.count else {
+ self.report(ShelfFailure.full)
+ return
+ }
+ self.importCount = urls.count
+ self.isChoosingFiles = false
+ for url in urls {
+ guard !Task.isCancelled, self.isRunning, self.generation == currentGeneration else { return }
+ do { try self.acceptImported(.file(url)) } catch { self.report(error) }
+ self.importCount = max(0, self.importCount - 1)
+ if self.importCount > 0 { await Task.yield() }
+ }
+ }
+ importTasks[id] = task
+ isChoosingFiles = true
+ return true
+ }
+
+ private func admitImport(count: Int) -> Bool {
guard isRunning, !isClearing, !isStopping, !storeNeedsReset else {
report(ShelfFailure.stopped)
return false
@@ -359,15 +409,20 @@ final class ShelfService {
message = "Clear Shelf to retry temporary image cleanup before adding another drop."
return false
}
- guard providers.count <= ShelfLimits.items - items.count - importTasks.count else {
+ guard count <= ShelfLimits.items - items.count - importTasks.count else {
report(ShelfFailure.full)
return false
}
- let currentGeneration = generation
- guard importTasks.isEmpty else {
- message = "Wait for the current drop or cancel it before adding another."
+ guard importTasks.isEmpty, !isChoosingFiles else {
+ message = "Finish or cancel the current selection or import before adding more items."
return false
}
+ return true
+ }
+
+ func importDrops(_ providers: [NSItemProvider]) -> Bool {
+ guard admitImport(count: providers.count) else { return false }
+ let currentGeneration = generation
let id = UUID()
let task = Task { [weak self] in
guard let self else { return }
@@ -406,6 +461,7 @@ final class ShelfService {
}
let workers = importTasks
for task in workers.values { task.cancel() }
+ if isChoosingFiles { fileChooser.cancel() }
for (id, task) in workers {
await task.value
importTasks[id] = nil
diff --git a/Semper/Shelf/ShelfViews.swift b/Semper/Shelf/ShelfViews.swift
index 84a2d3f..8b9a60e 100644
--- a/Semper/Shelf/ShelfViews.swift
+++ b/Semper/Shelf/ShelfViews.swift
@@ -14,13 +14,16 @@ struct ShelfCompactView: View {
.font(.headline)
Spacer()
Text("\(service.items.count)").foregroundStyle(.secondary)
+ Button("Choose Files…") { service.chooseFiles() }
+ .keyboardShortcut("o", modifiers: .command)
+ .disabled(!service.canChooseFiles)
Button("Open", action: openDetail)
}
if !service.isRunning {
Text("File Shelf is paused.").foregroundStyle(.secondary)
Button("Start File Shelf") { service.start() }
} else if service.items.isEmpty {
- Text("Drop files, folders, links, images, or text here.")
+ Text("Choose files and folders, or drop files, links, images, or text here.")
.foregroundStyle(.secondary)
.frame(maxWidth: .infinity, minHeight: 54)
} else {
@@ -69,10 +72,13 @@ struct ShelfDetailView: View {
HStack {
VStack(alignment: .leading, spacing: 4) {
Text("File Shelf").font(.title2.weight(.semibold))
- Text("A temporary place for items you drop. Original files stay in place.")
+ Text("A temporary place for items you add. Original files stay in place.")
.font(.callout).foregroundStyle(.secondary)
}
Spacer()
+ Button("Choose Files…", systemImage: "folder.badge.plus") { service.chooseFiles() }
+ .keyboardShortcut("o", modifiers: .command)
+ .disabled(!service.canChooseFiles)
Button("Refresh", systemImage: "arrow.clockwise") { service.refresh() }.disabled(!service.isRunning)
Button("Clear Shelf", systemImage: "tray") { confirmClear = true }.disabled(!service.canClear)
}
@@ -126,9 +132,9 @@ struct ShelfDetailView: View {
Group {
if service.items.isEmpty {
ContentUnavailableView(
- "Drop items here", systemImage: "tray.and.arrow.down",
+ "Add items to your shelf", systemImage: "tray.and.arrow.down",
description: Text(
- "Files and folders are held by reference. Drop text or images to keep a temporary local copy."
+ "Choose files and folders or drop items here. Files stay in place. Dropped text and images use a temporary local copy."
))
} else {
ScrollView {
diff --git a/Semper/Views/Settings/Tabs/GeneralTab.swift b/Semper/Views/Settings/Tabs/GeneralTab.swift
index 72ba289..fdaf5f9 100644
--- a/Semper/Views/Settings/Tabs/GeneralTab.swift
+++ b/Semper/Views/Settings/Tabs/GeneralTab.swift
@@ -41,7 +41,7 @@ struct GeneralTab: View {
SettingsSection("Behavior", subtitle: "When Semper runs") {
SettingsRow(
"Launch at Login",
- description: "Keep the mixer ready after you sign in"
+ description: "Start Semper when you sign in"
) {
Toggle("", isOn: $settings.appSettings.launchAtLogin)
.toggleStyle(.switch)
diff --git a/SemperTests/PresentationControllerTests.swift b/SemperTests/PresentationControllerTests.swift
index 69c340a..847ce29 100644
--- a/SemperTests/PresentationControllerTests.swift
+++ b/SemperTests/PresentationControllerTests.swift
@@ -4,7 +4,7 @@ import IOKit.pwr_mgt
import Testing
@testable import Semper
-@Suite("Presentation controller")
+@Suite("Presentation controller", .timeLimit(.minutes(1)))
@MainActor
struct PresentationControllerTests {
@Test("Preparing a preview reserves ownership without writes or Awake assertions")
@@ -16,6 +16,7 @@ struct PresentationControllerTests {
#expect(fixture.controller.phase == .preview)
#expect(fixture.controller.canStart)
+ #expect(!fixture.controller.canCancelOperation)
#expect(fixture.trace.events == [.sceneReserve, .workspaceReserve, .scenePreview])
#expect(fixture.backend.activeIDs.isEmpty)
#expect(fixture.controller.deadline == nil)
@@ -32,12 +33,15 @@ struct PresentationControllerTests {
fixture.reserveGate = gate
let prepare = Task { try await fixture.prepare() }
#expect(await gate.waitUntilSuspended())
+ #expect(fixture.controller.canCancelOperation)
prepare.cancel()
gate.resume()
await #expect(throws: CancellationError.self) { try await prepare.value }
#expect(fixture.controller.phase == .idle)
+ #expect(!fixture.controller.canCancelOperation)
+ #expect(fixture.controller.message == "Presentation cancelled.")
#expect(fixture.controller.reservation == nil)
#expect(fixture.trace.events == [.sceneReserve, .scenePending, .sceneRelease])
#expect(fixture.backend.activeIDs.isEmpty)
@@ -69,6 +73,7 @@ struct PresentationControllerTests {
try await fixture.controller.start()
#expect(fixture.controller.phase == .active)
+ #expect(!fixture.controller.canCancelOperation)
#expect(fixture.receivedPreview == preview)
#expect(fixture.trace.events == [.awakeAcquire, .sceneApply, .workspaceApply])
#expect(fixture.backend.timeouts == [1800, 1800])
@@ -164,6 +169,130 @@ struct PresentationControllerTests {
#expect(fixture.backend.activeIDs.isEmpty)
}
+ @Test("The visible cancel action stops startup and waits for window recovery", arguments: [false, true])
+ func cancelActionDrainsStartupRecovery(cancelledReceipt: Bool) async throws {
+ let fixture = PresentationFixture()
+ defer { fixture.awake.shutdown() }
+ let applyGate = PresentationGate()
+ let reverseGate = PresentationGate()
+ fixture.workspace.applyGate = applyGate
+ fixture.workspace.reverseGate = reverseGate
+ if cancelledReceipt {
+ fixture.workspace.appliedReceipt = fixture.workspace.makeReceipt(
+ outcome: .cancelled, recovery: fixture.workspace.pendingRecovery)
+ }
+ try await fixture.prepare()
+ let work = PresentationTestWork(gates: [applyGate, reverseGate])
+ try await work.run {
+ let start = work.start { try await fixture.controller.start() }
+ try #require(await applyGate.waitUntilSuspended())
+ #expect(fixture.controller.canCancelOperation)
+ var stopFinished = false
+ let stop = work.start {
+ defer { stopFinished = true }
+ try await fixture.controller.stop()
+ }
+
+ try #require(await fixture.trace.waitFor(.workspaceApplyCancelled))
+ #expect(!stopFinished)
+ applyGate.resume()
+ try #require(await reverseGate.waitUntilSuspended())
+ #expect(fixture.controller.phase == .restoring)
+ #expect(!fixture.controller.canCancelOperation)
+ #expect(!stopFinished)
+ reverseGate.resume()
+ await #expect(throws: CancellationError.self) { try await start.value }
+ try await stop.value
+ }
+
+ #expect(fixture.workspace.reversedIDs == [fixture.workspace.appliedReceipt.operationID])
+ #expect(fixture.workspace.reverseWasCancelled == [false])
+ #expect(fixture.restoreWasCancelled == [false])
+ #expect(fixture.controller.phase == .idle)
+ #expect(!fixture.controller.canCancelOperation)
+ #expect(fixture.controller.reservation == nil)
+ #expect(fixture.backend.activeIDs.isEmpty)
+ #expect(fixture.controller.message == "Presentation cancelled.")
+ }
+
+ @Test("A cancelled window receipt keeps failed recovery visible and owned")
+ func cancelledReceiptRetainsFailedRecovery() async throws {
+ let fixture = PresentationFixture()
+ defer { fixture.awake.shutdown() }
+ let gate = PresentationGate()
+ fixture.workspace.applyGate = gate
+ fixture.workspace.appliedReceipt = fixture.workspace.makeReceipt(
+ outcome: .cancelled, recovery: fixture.workspace.pendingRecovery)
+ fixture.workspace.reverseResults = (0..<2).map { _ in
+ fixture.workspace.makeReceipt(outcome: .partial, recovery: fixture.workspace.pendingRecovery)
+ }
+ try await fixture.prepare()
+ let work = PresentationTestWork(gates: [gate])
+ try await work.run {
+ let start = work.start { try await fixture.controller.start() }
+ try #require(await gate.waitUntilSuspended())
+ let stop = work.start { try await fixture.controller.stop() }
+ try #require(await fixture.trace.waitFor(.workspaceApplyCancelled))
+ gate.resume()
+ await #expect(throws: CancellationError.self) { try await start.value }
+ await #expect(throws: PresentationError.self) { try await stop.value }
+ }
+
+ #expect(fixture.controller.phase == .recoveryRequired)
+ #expect(!fixture.controller.canCancelOperation)
+ #expect(fixture.controller.reservation == fixture.token)
+ #expect(fixture.controller.workspaceReceipt?.needsRecovery == true)
+ #expect(fixture.controller.message?.contains("Some windows could not be restored.") == true)
+ #expect(fixture.workspace.reverseWasCancelled == [false, false])
+ try await fixture.controller.stop()
+ }
+
+ @Test("Cancelling an arrival observer preserves delayed Workspace entry", arguments: [false, true])
+ func cancelledObserverBeforeDelayedWorkspaceArrival(observeTrace: Bool) async throws {
+ let fixture = PresentationFixture()
+ defer { fixture.awake.shutdown() }
+ let sceneGate = PresentationGate()
+ let workspaceGate = PresentationGate()
+ fixture.sceneApplyGate = sceneGate
+ fixture.workspace.applyGate = workspaceGate
+ try await fixture.prepare()
+ let work = PresentationTestWork(gates: [sceneGate, workspaceGate])
+ try await work.run {
+ let start = work.start { try await fixture.controller.start() }
+ try #require(await sceneGate.waitUntilSuspended())
+ #expect(!fixture.trace.events.contains(.workspaceApply))
+
+ let observing = PresentationSignal()
+ let observer = work.start {
+ observing.signal()
+ let arrived: Bool
+ if observeTrace {
+ arrived = await fixture.trace.waitFor(.workspaceApply)
+ } else {
+ arrived = await workspaceGate.waitUntilSuspended()
+ }
+ #expect(!arrived)
+ }
+ try #require(await observing.wait())
+ observer.cancel()
+ try await observer.value
+ #expect(!fixture.trace.events.contains(.workspaceApply))
+
+ sceneGate.resume()
+ try #require(await workspaceGate.waitUntilSuspended())
+ try #require(await fixture.trace.waitFor(.workspaceApply))
+ #expect(fixture.controller.canCancelOperation)
+ start.cancel()
+ try #require(await fixture.trace.waitFor(.workspaceApplyCancelled))
+ workspaceGate.resume()
+ await #expect(throws: CancellationError.self) { try await start.value }
+ }
+ #expect(fixture.workspace.reversedIDs == [fixture.workspace.appliedReceipt.operationID])
+ #expect(fixture.controller.phase == .idle)
+ #expect(fixture.controller.reservation == nil)
+ #expect(fixture.backend.activeIDs.isEmpty)
+ }
+
@Test("Expiry during a suspended start cancels it and waits for recovery")
func expiryDuringStart() async throws {
let fixture = PresentationFixture()
@@ -768,20 +897,24 @@ private final class PresentationWorkspaceFake: PresentationWorkspaceHandling {
@MainActor
private final class PresentationTrace {
- enum Event: Equatable {
+ enum Event: Hashable {
case sceneReserve, scenePreview, sceneApply, scenePending, sceneRestore, sceneKeep, sceneRelease
case workspaceReserve, workspaceApply, workspaceReverse, workspaceRelease, workspaceKeep
case awakeAcquire, awakeRelease, sceneApplyCancelled, workspaceApplyCancelled
}
- var events: [Event] = []
+ var events: [Event] = [] {
+ didSet {
+ for event in events { arrivals.removeValue(forKey: event)?.signal() }
+ }
+ }
+ private var arrivals: [Event: PresentationSignal] = [:]
func waitFor(_ event: Event) async -> Bool {
- for _ in 0..<100 {
- if events.contains(event) { return true }
- do { try await Task.sleep(for: .milliseconds(10)) }
- catch { return false }
- }
- return events.contains(event)
+ guard !Task.isCancelled else { return false }
+ if events.contains(event) { return true }
+ let arrival = arrivals[event] ?? PresentationSignal()
+ arrivals[event] = arrival
+ return await arrival.wait()
}
}
@@ -789,19 +922,18 @@ private final class PresentationTrace {
private final class PresentationGate {
private var continuation: CheckedContinuation?
private var resumed = false
+ private let arrival = PresentationSignal()
func suspend() async {
if resumed { return }
- await withCheckedContinuation { continuation = $0 }
+ await withCheckedContinuation {
+ continuation = $0
+ arrival.signal()
+ }
}
func waitUntilSuspended() async -> Bool {
- for _ in 0..<100 {
- if continuation != nil { return true }
- do { try await Task.sleep(for: .milliseconds(10)) }
- catch { return false }
- }
- return continuation != nil
+ await arrival.wait()
}
func resume() {
@@ -811,6 +943,71 @@ private final class PresentationGate {
}
}
+@MainActor
+private final class PresentationSignal {
+ private var signalled = false
+ private var waiters: [UUID: CheckedContinuation] = [:]
+
+ func signal() {
+ signalled = true
+ let pending = Array(waiters.values)
+ waiters.removeAll()
+ for waiter in pending { waiter.resume(returning: true) }
+ }
+
+ func wait() async -> Bool {
+ guard !Task.isCancelled else { return false }
+ if signalled { return true }
+ let id = UUID()
+ let arrived = await withTaskCancellationHandler {
+ await withCheckedContinuation { continuation in
+ if Task.isCancelled {
+ continuation.resume(returning: false)
+ } else if signalled {
+ continuation.resume(returning: true)
+ } else {
+ waiters[id] = continuation
+ }
+ }
+ } onCancel: {
+ Task { @MainActor in self.waiters.removeValue(forKey: id)?.resume(returning: false) }
+ }
+ return arrived && !Task.isCancelled
+ }
+}
+
+@MainActor
+private final class PresentationTestWork {
+ private let gates: [PresentationGate]
+ private var tasks: [Task] = []
+
+ init(gates: [PresentationGate]) {
+ self.gates = gates
+ }
+
+ func start(_ action: @escaping @MainActor () async throws -> Void) -> Task {
+ let task = Task { try await action() }
+ tasks.append(task)
+ return task
+ }
+
+ func run(_ body: @MainActor () async throws -> Void) async throws {
+ do {
+ try await body()
+ } catch {
+ await drain()
+ throw error
+ }
+ await drain()
+ }
+
+ private func drain() async {
+ for task in tasks { task.cancel() }
+ for gate in gates { gate.resume() }
+ for task in tasks { _ = await task.result }
+ }
+}
+
@MainActor
private final class PresentationClock {
var current = Date(timeIntervalSince1970: 1_700_000_000)
diff --git a/SemperTests/ShelfFileSelectionTests.swift b/SemperTests/ShelfFileSelectionTests.swift
new file mode 100644
index 0000000..1e7a3e5
--- /dev/null
+++ b/SemperTests/ShelfFileSelectionTests.swift
@@ -0,0 +1,408 @@
+import AppKit
+import Foundation
+import Observation
+import Testing
+
+@testable import Semper
+
+nonisolated private final class ShelfSelectionSignal: @unchecked Sendable {
+ private let lock = NSLock()
+ private var result: Bool?
+ private var continuation: CheckedContinuation?
+ var isResolved: Bool { lock.withLock { result != nil } }
+
+ func resolve(_ value: Bool) {
+ let waiting: CheckedContinuation? = lock.withLock {
+ guard result == nil else { return nil }
+ result = value
+ defer { continuation = nil }
+ return continuation
+ }
+ waiting?.resume(returning: value)
+ }
+
+ func wait() async -> Bool {
+ let received = await withTaskCancellationHandler {
+ await withCheckedContinuation { waiting in
+ let existing: Bool? = lock.withLock {
+ if let result { return result }
+ continuation = waiting
+ return nil
+ }
+ if let existing { waiting.resume(returning: existing) }
+ }
+ } onCancel: {
+ self.resolve(false)
+ }
+ return received && !Task.isCancelled
+ }
+}
+
+@MainActor
+private final class ShelfSelectionChooser: ShelfFileChoosing {
+ var selection: [URL]?
+ private(set) var calls = 0
+ let entered = ShelfSelectionSignal()
+ let cancelled = ShelfSelectionSignal()
+ let release = ShelfSelectionSignal()
+ private var heldSelection: Task?
+
+ func chooseFiles() async -> [URL]? {
+ let release = release
+ let heldSelection = Task.detached { await release.wait() }
+ self.heldSelection = heldSelection
+ calls += 1
+ entered.resolve(true)
+ guard await heldSelection.value else { return nil }
+ return selection
+ }
+
+ func cancel() { cancelled.resolve(true) }
+
+ func finish() async {
+ release.resolve(true)
+ _ = await heldSelection?.value
+ }
+}
+
+nonisolated private final class ShelfSelectionAccess: ShelfFileAccess, @unchecked Sendable {
+ private let lock = NSLock()
+ private var beginCount = 0
+ private var endCount = 0
+ private var bookmarkCount = 0
+ private var resolveCount = 0
+ let folder: URL
+ let overrideState: ShelfFileState?
+ let failBookmarks: Bool
+ var begins: Int { lock.withLock { beginCount } }
+ var balanced: Bool { lock.withLock { beginCount == endCount } }
+ var bookmarks: Int { lock.withLock { bookmarkCount } }
+ var resolutions: Int { lock.withLock { resolveCount } }
+
+ init(folder: URL, state: ShelfFileState?, failBookmarks: Bool) {
+ self.folder = folder
+ overrideState = state
+ self.failBookmarks = failBookmarks
+ }
+
+ func begin(_ url: URL) -> Bool {
+ lock.withLock { beginCount += 1 }
+ return true
+ }
+ func end(_ url: URL) { lock.withLock { endCount += 1 } }
+ func state(of url: URL) -> ShelfFileState { overrideState ?? .available(isDirectory: url == folder) }
+ func bookmark(for url: URL) throws -> Data {
+ lock.withLock { bookmarkCount += 1 }
+ if failBookmarks { throw ShelfFailure.inaccessible }
+ return Data(url.absoluteString.utf8)
+ }
+ func resolve(_ bookmark: Data) throws -> URL {
+ lock.withLock { resolveCount += 1 }
+ guard let string = String(data: bookmark, encoding: .utf8), let url = URL(string: string), url.isFileURL else {
+ throw ShelfFailure.inaccessible
+ }
+ return url
+ }
+}
+
+@MainActor
+private final class ShelfSelectionCompletion {
+ private let service: ShelfService
+ private let finished = ShelfSelectionSignal()
+ private var active = true
+
+ init(_ service: ShelfService) { self.service = service }
+
+ func wait() async -> Bool {
+ observe()
+ let result = await finished.wait()
+ active = false
+ return result
+ }
+
+ private func observe() {
+ guard active else { return }
+ let complete = withObservationTracking {
+ let choosing = service.isChoosingFiles
+ let count = service.importCount
+ return !choosing && count == 0
+ } onChange: { [weak self] in
+ Task { @MainActor [weak self] in self?.observe() }
+ }
+ if complete { finished.resolve(true) }
+ }
+}
+
+@Suite("Shelf Choose Files", .timeLimit(.minutes(1)))
+@MainActor
+struct ShelfFileSelectionTests {
+ private struct Fixture {
+ let root: URL
+ let file: URL
+ let secondFile: URL
+ let folder: URL
+ let store: ShelfStore
+ let access: ShelfSelectionAccess
+ let chooser: ShelfSelectionChooser
+ let service: ShelfService
+ let dropEntered: ShelfSelectionSignal
+ let dropRelease: ShelfSelectionSignal
+ }
+
+ private func withFixture(
+ state: ShelfFileState? = nil, failBookmarks: Bool = false,
+ body: (Fixture) async throws -> Void
+ ) async throws {
+ let root = FileManager.default.temporaryDirectory.appendingPathComponent("shelf-selection-\(UUID())")
+ try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true)
+ defer {
+ do { try FileManager.default.removeItem(at: root) } catch { Issue.record(error) }
+ }
+ let file = root.appendingPathComponent("original.txt")
+ let secondFile = root.appendingPathComponent("second.txt")
+ let folder = root.appendingPathComponent("Folder", isDirectory: true)
+ try Data("original bytes".utf8).write(to: file)
+ try Data("second bytes".utf8).write(to: secondFile)
+ try FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true)
+ let store = ShelfStore(root: root.appendingPathComponent("store", isDirectory: true))
+ let chooser = ShelfSelectionChooser()
+ let access = ShelfSelectionAccess(folder: folder, state: state, failBookmarks: failBookmarks)
+ let dropEntered = ShelfSelectionSignal()
+ let dropRelease = ShelfSelectionSignal()
+ let service = ShelfService(
+ store: store, access: access, fileChooser: chooser,
+ importer: { _, _ in
+ dropEntered.resolve(true)
+ guard await dropRelease.wait() else { throw ShelfFailure.cancelled }
+ return .text("Late drop")
+ })
+ let fixture = Fixture(
+ root: root, file: file, secondFile: secondFile, folder: folder, store: store,
+ access: access, chooser: chooser, service: service, dropEntered: dropEntered, dropRelease: dropRelease)
+ service.start()
+ do { try await body(fixture) } catch {
+ chooser.release.resolve(true)
+ dropRelease.resolve(true)
+ await service.shutdown()
+ await chooser.finish()
+ throw error
+ }
+ chooser.release.resolve(true)
+ dropRelease.resolve(true)
+ await service.shutdown()
+ await chooser.finish()
+ #expect(access.balanced)
+ #expect(try Data(contentsOf: file) == Data("original bytes".utf8))
+ #expect(try Data(contentsOf: secondFile) == Data("second bytes".utf8))
+ #expect(FileManager.default.fileExists(atPath: folder.path))
+ }
+
+ private func select(_ urls: [URL]?, in f: Fixture) async throws {
+ f.chooser.selection = urls
+ try #require(f.service.chooseFiles())
+ try #require(await f.chooser.entered.wait())
+ f.chooser.release.resolve(true)
+ try #require(await ShelfSelectionCompletion(f.service).wait())
+ }
+
+ @Test("A held picker survives cancellation until its explicit release")
+ func heldPickerRequiresExplicitRelease() async throws {
+ let chooser = ShelfSelectionChooser()
+ let expected = [URL(fileURLWithPath: "/selection-fixture.txt")]
+ chooser.selection = expected
+ let finished = ShelfSelectionSignal()
+ let request = Task {
+ let result = await chooser.chooseFiles()
+ finished.resolve(true)
+ return result
+ }
+ let entered = await chooser.entered.wait()
+ request.cancel()
+ chooser.cancel()
+ let cancellationObserved = await chooser.cancelled.wait()
+ #expect(entered)
+ #expect(cancellationObserved)
+ #expect(!chooser.release.isResolved)
+ #expect(!finished.isResolved)
+ chooser.release.resolve(true)
+ #expect(await request.value == expected)
+ await chooser.finish()
+ }
+
+ @Test("Selected files and folders remain references; optional bookmarks reload", arguments: [false, true])
+ func referencesAndPersistence(persistent: Bool) async throws {
+ try await withFixture { f in
+ if persistent {
+ f.service.setDefaultExpiry(.oneHour)
+ f.service.setPersistence(true)
+ try #require(f.service.persistenceEnabled)
+ }
+ let urls = [f.file, f.secondFile, f.folder]
+ try await select(urls, in: f)
+ #expect(f.service.items.map { f.service.fileURL(for: $0) } == urls.map(Optional.some))
+ #expect(
+ f.service.items.map { f.service.fileStates[$0.id] } == [
+ .available(isDirectory: false), .available(isDirectory: false), .available(isDirectory: true),
+ ])
+ #expect(f.access.begins == 3)
+ #expect(f.access.bookmarks == (persistent ? 3 : 0))
+ #expect(f.service.importCount == 0)
+ #expect(!f.service.isChoosingFiles)
+ #expect(f.service.canChooseFiles)
+ #expect(f.service.message == nil)
+ for item in f.service.items {
+ guard case .file(_, let bookmark) = item.payload else {
+ Issue.record("Selection must retain file references.")
+ continue
+ }
+ #expect((bookmark != nil) == persistent)
+ }
+ if persistent {
+ let saved = f.service.items
+ await f.service.shutdown()
+ let restored = ShelfService(store: f.store, access: f.access, fileChooser: ShelfSelectionChooser())
+ restored.start()
+ #expect(restored.items == saved)
+ #expect(f.access.resolutions == 3)
+ #expect(restored.items.map { restored.fileURL(for: $0) } == urls.map(Optional.some))
+ await restored.shutdown()
+ } else {
+ #expect(!FileManager.default.fileExists(atPath: f.store.manifest.path))
+ }
+ }
+ }
+
+ @Test("Cancelling the picker is silent and adds no references")
+ func silentCancellation() async throws {
+ try await withFixture { f in
+ try await select(nil, in: f)
+ #expect(f.service.items.isEmpty)
+ #expect(f.service.message == nil)
+ #expect(f.service.canChooseFiles)
+ #expect(f.access.begins == 0)
+ }
+ }
+
+ @Test("Capacity is checked before opening and before accepting the entire selection", arguments: [99, 100])
+ func capacity(count: Int) async throws {
+ try await withFixture { f in
+ for index in 0...success(())
+ case .shutdown:
+ await f.service.shutdown()
+ return Result.success(())
+ }
+ }
+ let cancelled = await f.chooser.cancelled.wait()
+ if !cancelled { f.chooser.release.resolve(true) }
+ #expect(cancelled)
+ #expect(!finished.isResolved)
+ #expect(!f.service.canChooseFiles)
+ #expect(!f.service.chooseFiles())
+ f.chooser.release.resolve(true)
+ try await stopping.value.get()
+ #expect(f.service.items.isEmpty)
+ #expect(f.service.importCount == 0)
+ #expect(!f.service.isChoosingFiles)
+ #expect(f.access.begins == 0)
+ #expect(f.service.isRunning == (action == .cancelImports || action == .clear))
+ }
+ }
+
+ @Test(
+ "Unavailable selected references use the same state and action rules as addFile",
+ arguments: [
+ ShelfFileState.missing, .cloudOnly, .inaccessible,
+ ])
+ func unavailableReferences(state: ShelfFileState) async throws {
+ try await withFixture(state: state) { f in
+ try await select([f.file], in: f)
+ let selected = try #require(f.service.items.first)
+ #expect(f.service.fileStates[selected.id] == state)
+ #expect(f.service.prepareFileAction(selected) == nil)
+ let selectedMessage = f.service.message
+ try f.service.addFile(f.file)
+ let direct = try #require(f.service.items.last)
+ #expect(selected.payload == direct.payload)
+ #expect(f.service.fileStates[direct.id] == state)
+ #expect(f.service.prepareFileAction(direct) == nil)
+ #expect(f.service.message == selectedMessage)
+ }
+ }
+
+ @Test("A selected file bookmark failure is reported and releases its acquired scope")
+ func bookmarkFailure() async throws {
+ try await withFixture(failBookmarks: true) { f in
+ f.service.setDefaultExpiry(.oneHour)
+ f.service.setPersistence(true)
+ try #require(f.service.persistenceEnabled)
+ try await select([f.file], in: f)
+ #expect(f.service.items.isEmpty)
+ #expect(f.service.message == ShelfFailure.inaccessible.localizedDescription)
+ #expect(f.access.begins == 1)
+ #expect(f.access.balanced)
+ #expect(try f.store.load()?.items.isEmpty == true)
+ }
+ }
+}
diff --git a/guide/presentation-controls.md b/guide/presentation-controls.md
new file mode 100644
index 0000000..fe0c491
--- /dev/null
+++ b/guide/presentation-controls.md
@@ -0,0 +1,12 @@
+# Presentation controls
+
+Add Presentation and Awake in Modules, then open Presentation from Home or the sidebar. Optional display, Sound, and window changes require their respective modules to be added and running.
+
+1. Choose a duration and the settings to apply.
+2. Select Preview Presentation and review the requested changes.
+3. Select Start Presentation to apply them and begin the timer.
+4. Select End and Restore when finished. Later manual changes stay in place.
+
+During preview preparation or startup, Cancel Presentation remains available beside the progress indicator. Escape invokes the same action while this control is present. Cancellation waits for pending work and restores any changes already applied. Recovery itself cannot be cancelled. If recovery fails, review the displayed results and use Retry Cleanup. Keep Current Setup requires confirmation and accepts the current state instead of restoring it.
+
+Edit Selection returns a completed preview to configuration. Normal active sessions retain End and Restore and Keep Current Setup. Window recovery lasts only for the current Semper session, so finish it before quitting.
diff --git a/guide/product-status.md b/guide/product-status.md
new file mode 100644
index 0000000..e1eef21
--- /dev/null
+++ b/guide/product-status.md
@@ -0,0 +1,91 @@
+# Semper product status
+
+Semper is one menu bar app with nine utility modules. This page is the shared
+record of what each module does, where it stands, and what remains before
+release. It changes in the same commit as the work that changes a status.
+
+Snapshot: `main` at db697a7, 2026-09-09. Interaction-fix source was staged at
+`8d6d3c0` on the same date; those fixes reach `main` only when that change set
+is merged. Latest downloadable release: v1.0.0, published 2026-08-26,
+containing Sound only.
+
+## States
+
+- **Released**: included in a published signed release users can download.
+- **Integrated**: merged on `main` in the shared shell with automated tests.
+ Not included in the public binary release; native acceptance remains separate.
+- **Implemented in this change set**: present in the staged source snapshot.
+ This state alone does not establish inclusion on `main` or in a public release.
+- **Planned**: agreed scope with no implementation on `main`.
+
+## Modules
+
+| Module | User job | State | Remaining before release | Evidence |
+| --- | --- | --- | --- | --- |
+| Sound | Control app and device audio: per-app volume, output routing, EQ, equal-loudness compensation | Released, v1.0.0 | Later Sound changes remain subject to the next release's shared acceptance gates | [Source](../Semper/Audio), [AutoEQ](autoeq.md), [audio safety](realtime-audio-safety.md) |
+| Awake | Keep the Mac awake for a chosen duration, with app and battery stop conditions | Integrated | Shared gates, plus assertion, expiry, and stop-condition checks on hardware | [Source](../Semper/Awake), [guide](awake-sessions.md) |
+| Displays | Read and set supported external display brightness, contrast, volume, and input | Integrated | Shared gates, plus DDC checks on real displays | [Source](../Semper/Displays), [guide](module-shell.md#displays) |
+| Workspace Restore | Return selected app windows to a saved arrangement | Integrated | Shared gates, plus Accessibility permission flows, multi-display, and Spaces checks | [Source](../Semper/Workspace), [guide](direct-utilities.md) |
+| File Shelf | Hold temporary files, links, images, and text between apps | Integrated | Shared gates, plus drop-source, missing-file, and persistence checks | [Source](../Semper/Shelf), [guide](direct-utilities.md) |
+| Safe Eject | Review removable volumes to eject and check each observed result | Integrated | Shared gates, plus disposable-drive single and batch eject checks | [Source](../Semper/Storage), [guide](direct-utilities.md) |
+| Scenes | Save and apply settings across utilities together, with a restore point | Integrated | Shared gates, plus capture, apply, and recovery checks on hardware | [Source](../Semper/Scenes), [guide](module-shell.md) |
+| Away | Cover every display with a privacy curtain that requires authentication to exit | Integrated | Shared gates, plus input-filter permission, authentication, and multi-display checks | [Source](../Semper/Away), [guide](module-shell.md#away) |
+| Presentation | Run a timed session that applies selected display, sound, and window targets | Integrated | Shared gates, plus a full session with reverse-order recovery on hardware | [Source](../Semper/Presentation), [guide](module-shell.md#presentation) |
+
+## Interaction fixes in this change set
+
+These fixes are implemented at the staged revision above. Their integration
+requires that change set to be merged into `main`; native acceptance and the
+shared release gates remain separate.
+
+| Fix | State | Remaining native verification | Guide |
+| --- | --- | --- | --- |
+| Presentation preparation/start cancellation | Implemented in this change set | Visible cancellation and Escape during preparation/start, pending-work drainage, recovery and retry controls | [Presentation controls](presentation-controls.md) |
+| File Shelf Choose Files | Implemented in this change set | Native picker focus, selection and cancellation, keyboard navigation and Command-O routing in compact and detail views | [File selection](shelf-file-selection.md) |
+
+## Next increments
+
+These changes are not included in this change set or the `main` snapshot above
+and are not released.
+
+| Increment | State | Acceptance before integration |
+| --- | --- | --- |
+| Window Layout | Planned | Manual halves, maximize, center and previous-placement restore using [Workspace helpers](../Semper/Workspace); verify target identity, constrained windows and later manual changes |
+| File Shelf Resize Image Copy | Planned | Separate local JPEG/PNG copy; correct dimensions, orientation, color and transparency; original unchanged; explicit metadata policy, save failures and cancellation |
+
+## Shared release gates
+
+Integrated modules pass automated tests that use injected fixtures. Those tests
+do not establish signed-install, permission, hardware, accessibility, update,
+or notarization readiness. See the
+[verification scope](module-shell.md#verification-scope) and the
+[remaining integrated checks](direct-utilities.md#remaining-integrated-checks).
+
+Before an integrated module is called released, one signed build must pass on
+hardware: clean-Mac install, real permission prompts including denial and
+revocation recovery, module add, pause, remove, and quit with active work,
+keyboard and VoiceOver access, updater behavior, and uninstall cleanup.
+
+Binary publication also requires completed signing and notarization checks,
+source-rights and third-party-notice review, and verified install, update and
+rollback behavior. These are release gates, not claims that they have passed.
+
+Before publication, the website, README, release notes, Homebrew cask, and
+update feed must refer to the same verified release artifact.
+
+## Update contract
+
+Status moves only with evidence, never by assumption:
+
+- A feature change lands with its implementation, tests, and guide updates in
+ the same change set.
+- Integrated requires the code merged on `main`. Released requires a published
+ signed artifact containing it. An acceptance item is marked done only after
+ the check was run and its result recorded.
+- The website, README, and release notes use the same verified feature states.
+ Planned and development work may appear before release when clearly labeled;
+ the download description lists only what its artifact contains.
+- Whoever changes a state updates the snapshot line above in the same change.
+
+Passing tests alone advance nothing, and a compiled build proves compilation,
+not behavior on hardware.
diff --git a/guide/shelf-file-selection.md b/guide/shelf-file-selection.md
new file mode 100644
index 0000000..710d7d4
--- /dev/null
+++ b/guide/shelf-file-selection.md
@@ -0,0 +1,15 @@
+# Add files to File Shelf
+
+Use **Choose Files…** in either the compact File Shelf card or its detail window. With that view active, **Command-O** opens the same native file picker. Start File Shelf first if it is paused.
+
+Select one or more files or folders, then choose **Add to Shelf**. File Shelf keeps references to those items. Originals stay in place, and selecting a folder does not copy its contents. The shelf holds up to 100 items; a selection that exceeds the remaining space is rejected before any of its items are added.
+
+Choose **Cancel** in the picker to leave the shelf unchanged. Once a selection starts importing, **Cancel Import** stops the remaining items; references already added stay on the shelf. Pause, Clear Shelf, and quitting also cancel pending selection and wait for it to finish. Clear Shelf removes shelf references, not the original files.
+
+macOS can refuse access to a selected item. File Shelf reports failures and shows the state of unavailable references. Check access in Finder, then choose the item again if needed. Cloud-only items are not downloaded by File Shelf; download them in Finder before using file actions, then refresh the shelf.
+
+**Keep shelf between launches** is off by default. When enabled, eligible file references use local bookmarks; the selected files are still not copied.
+
+## Native acceptance pending
+
+Automated logic tests cover selection, references, bookmarks, capacity, cancellation, and lifecycle draining. Native dialog focus, cancellation, keyboard navigation, and Command-O routing between compact and detail views still require the quality manager's desktop acceptance pass.