From 4677141c02716061dd168e4226ca682fc06b8ee1 Mon Sep 17 00:00:00 2001
From: Nihar <117209695+niharnm@users.noreply.github.com>
Date: Wed, 9 Sep 2026 09:08:17 -0700
Subject: [PATCH 1/7] Add manual Window Layout utility
---
Semper/Modules/ModuleRegistry.swift | 15 +
Semper/Modules/ShellUITestFixture.swift | 1 +
Semper/Modules/UtilityLifecycle.swift | 2 +-
Semper/Modules/UtilityRuntime.swift | 92 ++-
Semper/Modules/UtilityShellView.swift | 18 +
Semper/Shortcuts/ShortcutAction.swift | 34 +-
Semper/Shortcuts/ShortcutsRegistry.swift | 2 +-
Semper/Utilities/MutationAdmissionGate.swift | 5 +
Semper/Views/Settings/Tabs/ShortcutsTab.swift | 5 +
Semper/WindowLayout/WindowLayoutModels.swift | 90 +++
Semper/WindowLayout/WindowLayoutService.swift | 302 ++++++++++
.../WindowLayoutTargetTracker.swift | 64 +++
Semper/WindowLayout/WindowLayoutView.swift | 51 ++
Semper/Workspace/WorkspaceService.swift | 6 +-
Semper/Workspace/WorkspaceWindowBackend.swift | 94 +++-
SemperTests/MutationAdmissionGateTests.swift | 26 +
SemperTests/ShellUITestFixtureTests.swift | 1 +
SemperTests/UtilityRuntimeTests.swift | 26 +-
SemperTests/WindowLayoutGeometryTests.swift | 183 ++++++
SemperTests/WindowLayoutServiceTests.swift | 530 ++++++++++++++++++
.../WorkspaceShortcutIsolationTests.swift | 19 +
guide/window-layout.md | 25 +
scripts/test-direct-utilities.py | 9 +-
23 files changed, 1571 insertions(+), 29 deletions(-)
create mode 100644 Semper/WindowLayout/WindowLayoutModels.swift
create mode 100644 Semper/WindowLayout/WindowLayoutService.swift
create mode 100644 Semper/WindowLayout/WindowLayoutTargetTracker.swift
create mode 100644 Semper/WindowLayout/WindowLayoutView.swift
create mode 100644 SemperTests/WindowLayoutGeometryTests.swift
create mode 100644 SemperTests/WindowLayoutServiceTests.swift
create mode 100644 guide/window-layout.md
diff --git a/Semper/Modules/ModuleRegistry.swift b/Semper/Modules/ModuleRegistry.swift
index ac3b789..6aecba4 100644
--- a/Semper/Modules/ModuleRegistry.swift
+++ b/Semper/Modules/ModuleRegistry.swift
@@ -6,6 +6,7 @@ enum UtilityModuleID: String, CaseIterable, Codable, Hashable, Identifiable, Sen
case awake
case displays
case workspace
+ case windowLayout = "window-layout"
case shelf
case storage
case scenes
@@ -65,6 +66,20 @@ struct UtilityModuleDescriptor: Identifiable, Equatable, Sendable {
.init(
id: .workspace, title: "Workspace Restore", summary: "Save and restore app window positions.",
symbolName: "macwindow.on.rectangle"),
+ .init(
+ id: .windowLayout, title: "Window Layout", summary: "Arrange the frontmost app window.",
+ symbolName: "rectangle.split.2x1",
+ disclosure: .init(
+ permissionReasons: [
+ .init(name: "Accessibility", reason: "Reads and arranges a window only when you invoke an action.")
+ ],
+ runningBackgroundPolicy:
+ "Remembers the last active app while running. Window reads and changes happen only on request; pausing stops observation and drains pending work.",
+ localDataPolicy:
+ "Window identity and the previous placement stay in memory. Pausing preserves them; removing the module or quitting clears them. No window titles are collected.",
+ conflicts: ["Finish active Workspace Restore work and end Away before arranging windows."],
+ hardwareRequirements: ["Only standard windows with verified windowed state and move/resize support are supported."]
+ )),
.init(
id: .shelf, title: "File Shelf", summary: "Keep references to files close at hand.", symbolName: "tray.fill"
),
diff --git a/Semper/Modules/ShellUITestFixture.swift b/Semper/Modules/ShellUITestFixture.swift
index 8087af6..aeb6521 100644
--- a/Semper/Modules/ShellUITestFixture.swift
+++ b/Semper/Modules/ShellUITestFixture.swift
@@ -59,6 +59,7 @@
soundFactory: { _, _ in try probe.refuse(.sound) },
awakeFactory: { try probe.refuse(.awake) },
workspaceFactory: { try probe.refuse(.workspace) },
+ windowLayoutFactory: { _ in try probe.refuse(.windowLayout) },
shelfFactory: { try probe.refuse(.shelf) },
storageFactory: { try probe.refuse(.storage) },
sceneLibraryStore: FileSceneLibraryStore(directory: sceneDirectory),
diff --git a/Semper/Modules/UtilityLifecycle.swift b/Semper/Modules/UtilityLifecycle.swift
index 684e1f0..602329b 100644
--- a/Semper/Modules/UtilityLifecycle.swift
+++ b/Semper/Modules/UtilityLifecycle.swift
@@ -189,7 +189,7 @@ final class UtilityLifecycle {
for task in stops { _ = await task.result }
// Composed sessions restore before their underlying services stop.
let order: [UtilityModuleID] = [
- .away, .presentation, .scenes, .workspace, .shelf, .storage, .displays, .sound, .awake,
+ .away, .presentation, .scenes, .windowLayout, .workspace, .shelf, .storage, .displays, .sound, .awake,
]
var retainedServices: [UtilityModuleID: String] = [:]
for id in order {
diff --git a/Semper/Modules/UtilityRuntime.swift b/Semper/Modules/UtilityRuntime.swift
index 67cab51..18d515a 100644
--- a/Semper/Modules/UtilityRuntime.swift
+++ b/Semper/Modules/UtilityRuntime.swift
@@ -32,6 +32,8 @@ final class UtilityRuntime {
private(set) var workspace: WorkspaceService?
private(set) var workspaceWorkflowRequest: WorkspaceWorkflowRequest?
private(set) var workspaceShortcutConflict: String?
+ private(set) var windowLayout: WindowLayoutService?
+ private(set) var windowLayoutShortcutConflicts: [ShortcutAction: String] = [:]
private(set) var shelf: ShelfService?
private(set) var storage: SafeEjectService?
private(set) var scenes: SceneManager?
@@ -57,6 +59,7 @@ final class UtilityRuntime {
@MainActor (AudioEngine.SharedDDCController?, MutationAdmissionGate) throws -> DisplayControlService
@ObservationIgnored private let awakeFactory: @MainActor () throws -> AwakeService
@ObservationIgnored private let workspaceFactory: @MainActor () throws -> WorkspaceService
+ @ObservationIgnored private let windowLayoutFactory: @MainActor (MutationAdmissionGate) throws -> WindowLayoutService
@ObservationIgnored private let shelfFactory: @MainActor () throws -> ShelfService
@ObservationIgnored private let storageFactory: @MainActor () throws -> SafeEjectService
@ObservationIgnored private let sceneLibraryStore: (any SceneLibraryStoring)?
@@ -145,6 +148,9 @@ final class UtilityRuntime {
soundFactory: (@MainActor (SettingsManager, AudioEngine.SharedDDCController?) throws -> SoundRuntime)? = nil,
awakeFactory: (@MainActor () throws -> AwakeService)? = nil,
workspaceFactory: @escaping @MainActor () throws -> WorkspaceService = { WorkspaceService() },
+ windowLayoutFactory: @escaping @MainActor (MutationAdmissionGate) throws -> WindowLayoutService = {
+ WindowLayoutService(mutationAdmission: $0)
+ },
shelfFactory: @escaping @MainActor () throws -> ShelfService = { ShelfService() },
storageFactory: @escaping @MainActor () throws -> SafeEjectService = { SafeEjectService() },
sceneLibraryStore: (any SceneLibraryStoring)? = nil,
@@ -168,6 +174,7 @@ final class UtilityRuntime {
#endif
}
self.workspaceFactory = workspaceFactory
+ self.windowLayoutFactory = windowLayoutFactory
self.shelfFactory = shelfFactory
self.storageFactory = storageFactory
self.sceneLibraryStore = sceneLibraryStore
@@ -231,6 +238,11 @@ final class UtilityRuntime {
recordShellShortcut(shortcut, action: .toggleAwayMode)
}
+ func recordWindowLayoutShortcut(_ shortcut: KeyboardShortcuts.Shortcut?, action: ShortcutAction) {
+ guard action.windowLayoutAction != nil else { return }
+ recordShellShortcut(shortcut, action: action)
+ }
+
private func recordShellShortcut(_ shortcut: KeyboardShortcuts.Shortcut?, action: ShortcutAction) {
guard !shutdownRequested else { return }
let recorded = shortcut.map(ShortcutCodable.from)
@@ -251,8 +263,7 @@ final class UtilityRuntime {
let candidates =
recording
? ShortcutAction.allCases
- : ShortcutAction.soundActions
- + (action == .toggleAwayMode ? [.restoreWorkspace] : [])
+ : ShortcutAction.soundActions + ShortcutAction.shellActions.prefix { $0 != action }
if let owner = candidates.first(where: { $0 != action && $0.assignedShortcut(in: settings) == shortcut }) {
return "Already used by \(owner.displayName)."
}
@@ -267,11 +278,14 @@ final class UtilityRuntime {
}
private func setShellShortcutConflict(_ conflict: String?, action: ShortcutAction) {
- if action == .restoreWorkspace { workspaceShortcutConflict = conflict } else { awayShortcutConflict = conflict }
+ if action == .restoreWorkspace { workspaceShortcutConflict = conflict }
+ else if action == .toggleAwayMode { awayShortcutConflict = conflict }
+ else if action.windowLayoutAction != nil { windowLayoutShortcutConflicts[action] = conflict }
}
private func shellShortcutModule(_ action: ShortcutAction) -> UtilityModuleID {
- action == .restoreWorkspace ? .workspace : .away
+ if action.windowLayoutAction != nil { return .windowLayout }
+ return action == .restoreWorkspace ? .workspace : .away
}
private func shellShortcutAvailable(_ action: ShortcutAction) -> Bool {
@@ -362,7 +376,8 @@ final class UtilityRuntime {
guard !Task.isCancelled, self.shellShortcutRegistrations[action] == generation,
self.shellShortcutAvailable(action)
else { return .cancelled }
- let command = action == .restoreWorkspace ? WorkspaceCommand.restore.rawValue : "away.toggle"
+ let command = action.windowLayoutAction?.rawValue
+ ?? (action == .restoreWorkspace ? WorkspaceCommand.restore.rawValue : "away.toggle")
let result = await self.commands.execute(.init(rawValue: command))
guard !self.shutdownRequested else { return .cancelled }
switch result {
@@ -383,6 +398,11 @@ final class UtilityRuntime {
await performShellShortcut(.toggleAwayMode)
}
+ func performWindowLayoutShortcut(_ action: ShortcutAction) async -> UtilityCommandResult {
+ guard action.windowLayoutAction != nil else { return .cancelled }
+ return await performShellShortcut(action)
+ }
+
private func performShellShortcut(_ action: ShortcutAction) async -> UtilityCommandResult {
guard let task = beginShellShortcut(action) else { return .cancelled }
return await withTaskCancellationHandler {
@@ -569,7 +589,7 @@ final class UtilityRuntime {
defer {
stoppingModules.remove(module)
observeStatus(for: module)
- if [.workspace, .away].contains(module) { syncShellShortcuts() }
+ if [.workspace, .away, .windowLayout].contains(module) { syncShellShortcuts() }
}
for action in ShortcutAction.shellActions where shellShortcutModule(action) == module {
stopShellShortcut(action)
@@ -594,7 +614,7 @@ final class UtilityRuntime {
defer {
stoppingModules.remove(module)
observeStatus(for: module)
- if [.workspace, .away].contains(module) { syncShellShortcuts() }
+ if [.workspace, .away, .windowLayout].contains(module) { syncShellShortcuts() }
}
for action in ShortcutAction.shellActions where shellShortcutModule(action) == module {
stopShellShortcut(action)
@@ -640,6 +660,10 @@ final class UtilityRuntime {
let count = workspace.arrangements.count
return "\(count) \(count == 1 ? "arrangement" : "arrangements"), "
+ (workspace.canUndo ? "undo available" : "no restore to undo")
+ case .windowLayout:
+ guard let windowLayout else { return "Open Window Layout to arrange a window." }
+ if let message = windowLayout.message { return message }
+ return windowLayout.canRestore ? "Previous window placement available" : "Ready for a window action"
case .shelf:
guard let shelf else { return "Open File Shelf to collect items." }
return "\(shelf.items.count) \(shelf.items.count == 1 ? "item" : "items") on the shelf"
@@ -897,6 +921,24 @@ final class UtilityRuntime {
self.workspace = nil
}
}))
+ try lifecycle.register(
+ .windowLayout,
+ binding: UtilityServiceBinding(
+ start: { [weak self] in
+ guard let self else { throw CancellationError() }
+ if self.windowLayout == nil {
+ self.windowLayout = try self.windowLayoutFactory(self.mutationAdmission)
+ }
+ self.windowLayout?.start()
+ },
+ stop: { [weak self] reason in
+ guard let self, let service = self.windowLayout else { return }
+ if reason == .pause { await service.pause() }
+ else {
+ await service.shutdown()
+ self.windowLayout = nil
+ }
+ }))
try lifecycle.register(
.shelf,
binding: UtilityServiceBinding(
@@ -1047,7 +1089,7 @@ final class UtilityRuntime {
return sound.audioCommands.dispatch(command, context: context)
})
for module in registry.modules
- where [.sound, .awake, .workspace, .scenes, .displays, .presentation, .away].contains(module.id) {
+ where [.sound, .awake, .workspace, .windowLayout, .scenes, .displays, .presentation, .away].contains(module.id) {
actions.append(
UtilityActionHandler(
descriptor: .init(
@@ -1132,6 +1174,29 @@ final class UtilityRuntime {
}
}))
}
+ for action in WindowLayoutAction.allCases {
+ actions.append(
+ UtilityActionHandler(
+ descriptor: .init(
+ id: .init(rawValue: action.rawValue), module: .windowLayout, title: action.title,
+ keywords: ["window", "layout", "arrange", "snap", "position"], symbolName: action.symbolName),
+ disabledReason: { [weak self] in
+ if self?.windowLayout?.isBusy == true { return "A window action is still running." }
+ if self?.windowLayout?.requiresPlacementReview == true {
+ return "Review the unverified window placement in Window Layout."
+ }
+ if action == .restore, self?.windowLayout?.canRestore != true {
+ return "No previous window placement is available."
+ }
+ return nil
+ },
+ perform: { [weak self] in
+ guard let self else { throw CancellationError() }
+ try await self.start(.windowLayout)
+ guard let service = self.windowLayout else { throw CancellationError() }
+ try await service.perform(action)
+ }))
+ }
let shelfMetadata = ShelfModuleRegistration()
for command in [ShelfCommand.open, .clear] {
actions.append(
@@ -1509,6 +1574,17 @@ final class UtilityRuntime {
}
return .init(runtime: runtime, permission: permission)
}
+ case .windowLayout:
+ guard let service = windowLayout else { return }
+ statusObserver.observe(module: module) {
+ let state: ModuleRuntimeState
+ if service.isBusy { state = .active }
+ else if service.requiresPlacementReview { state = .limited(reason: service.message ?? "Review the window placement.") }
+ else if service.permission == .denied || service.permission == .revoked {
+ state = .limited(reason: "Accessibility access is unavailable.")
+ } else { state = .ready }
+ return .init(runtime: state, permission: service.permission)
+ }
case .shelf:
guard let shelf else { return }
statusObserver.observe(module: module) {
diff --git a/Semper/Modules/UtilityShellView.swift b/Semper/Modules/UtilityShellView.swift
index 9c4ce72..752f0fe 100644
--- a/Semper/Modules/UtilityShellView.swift
+++ b/Semper/Modules/UtilityShellView.swift
@@ -299,6 +299,13 @@ struct UtilityShellView: View {
} else {
startModule(id)
}
+ case .windowLayout:
+ if let service = runtime.windowLayout {
+ WindowLayoutView(service: service, commands: runtime.commands)
+ .disabled(runtime.lifecycle.stopping.contains(id) || runtime.lifecycle.isShuttingDown)
+ } else {
+ startModule(id)
+ }
case .shelf:
if let shelf = runtime.shelf {
ShelfDetailView(service: shelf)
@@ -411,6 +418,17 @@ struct UtilitySettingsView: View {
if let conflict = runtime.awayShortcutConflict {
Text(conflict).font(.caption).foregroundStyle(.orange)
}
+ Text("Window Layout").font(.headline)
+ Text("Optional shortcuts arrange the frontmost app window. Add and resume Window Layout before using them.")
+ .font(.caption).foregroundStyle(.secondary)
+ ForEach(ShortcutAction.windowLayoutActions, id: \.self) { action in
+ KeyboardShortcuts.Recorder(
+ action.displayName, name: action.keyboardShortcutName,
+ onChange: { runtime.recordWindowLayoutShortcut($0, action: action) })
+ if let conflict = runtime.windowLayoutShortcutConflicts[action] {
+ Text(conflict).font(.caption).foregroundStyle(.orange)
+ }
+ }
if let sound = runtime.usableSound {
ShortcutsTab(
settings: runtime.settings, accessibility: sound.accessibility,
diff --git a/Semper/Shortcuts/ShortcutAction.swift b/Semper/Shortcuts/ShortcutAction.swift
index a6df576..cc3fe30 100644
--- a/Semper/Shortcuts/ShortcutAction.swift
+++ b/Semper/Shortcuts/ShortcutAction.swift
@@ -14,10 +14,29 @@ enum ShortcutAction: String, CaseIterable, Codable, Sendable {
case targetAppVolumeDown = "frontmostAppVolumeDown"
case targetAppMuteToggle = "frontmostAppMuteToggle"
case restoreWorkspace
+ case windowLeftHalf
+ case windowRightHalf
+ case windowMaximize
+ case windowCenter
+ case windowRestore
static var soundActions: [Self] { allCases.filter { !shellActions.contains($0) } }
- static let shellActions: [Self] = [.restoreWorkspace, .toggleAwayMode]
+ static let windowLayoutActions: [Self] = [
+ .windowLeftHalf, .windowRightHalf, .windowMaximize, .windowCenter, .windowRestore,
+ ]
+ static let shellActions: [Self] = [.restoreWorkspace, .toggleAwayMode] + windowLayoutActions
+
+ var windowLayoutAction: WindowLayoutAction? {
+ switch self {
+ case .windowLeftHalf: .leftHalf
+ case .windowRightHalf: .rightHalf
+ case .windowMaximize: .maximize
+ case .windowCenter: .center
+ case .windowRestore: .restore
+ default: nil
+ }
+ }
var displayName: String {
switch self {
@@ -27,6 +46,11 @@ enum ShortcutAction: String, CaseIterable, Codable, Sendable {
case .targetAppVolumeDown: "App Volume Down"
case .targetAppMuteToggle: "App Mute"
case .restoreWorkspace: "Restore workspace"
+ case .windowLeftHalf: "Window Left Half"
+ case .windowRightHalf: "Window Right Half"
+ case .windowMaximize: "Maximize Window"
+ case .windowCenter: "Center Window"
+ case .windowRestore: "Restore Previous Window Placement"
}
}
@@ -36,7 +60,8 @@ enum ShortcutAction: String, CaseIterable, Codable, Sendable {
var supportsRepeat: Bool {
switch self {
case .targetAppVolumeUp, .targetAppVolumeDown: true
- case .togglePopup, .toggleAwayMode, .targetAppMuteToggle, .restoreWorkspace: false
+ case .togglePopup, .toggleAwayMode, .targetAppMuteToggle, .restoreWorkspace,
+ .windowLeftHalf, .windowRightHalf, .windowMaximize, .windowCenter, .windowRestore: false
}
}
@@ -49,6 +74,11 @@ enum ShortcutAction: String, CaseIterable, Codable, Sendable {
case .targetAppVolumeDown: KeyboardShortcuts.Name("frontmost-app-volume-down")
case .targetAppMuteToggle: KeyboardShortcuts.Name("frontmost-app-mute-toggle")
case .restoreWorkspace: KeyboardShortcuts.Name("workspace-restore")
+ case .windowLeftHalf: KeyboardShortcuts.Name("window-layout-left-half")
+ case .windowRightHalf: KeyboardShortcuts.Name("window-layout-right-half")
+ case .windowMaximize: KeyboardShortcuts.Name("window-layout-maximize")
+ case .windowCenter: KeyboardShortcuts.Name("window-layout-center")
+ case .windowRestore: KeyboardShortcuts.Name("window-layout-restore")
}
}
diff --git a/Semper/Shortcuts/ShortcutsRegistry.swift b/Semper/Shortcuts/ShortcutsRegistry.swift
index c239a26..99fcd44 100644
--- a/Semper/Shortcuts/ShortcutsRegistry.swift
+++ b/Semper/Shortcuts/ShortcutsRegistry.swift
@@ -160,7 +160,7 @@ final class ShortcutsRegistry {
return adjustTargetVolume(direction: -1)
case .targetAppMuteToggle:
return toggleTargetMute()
- case .restoreWorkspace:
+ case .restoreWorkspace, .windowLeftHalf, .windowRightHalf, .windowMaximize, .windowCenter, .windowRestore:
return false
}
}
diff --git a/Semper/Utilities/MutationAdmissionGate.swift b/Semper/Utilities/MutationAdmissionGate.swift
index b31a2d6..7ce9728 100644
--- a/Semper/Utilities/MutationAdmissionGate.swift
+++ b/Semper/Utilities/MutationAdmissionGate.swift
@@ -7,6 +7,8 @@ nonisolated enum MutationAdmissionOwner: Hashable, Sendable {
case presentation
case manual
case manualDisplay
+ case manualWindow
+ case workspaceWindow
}
nonisolated enum MutationAdmissionMode: Sendable {
@@ -69,6 +71,9 @@ final class MutationAdmissionGate {
switch (owner, permit.owner) {
case (.scene, .manualDisplay), (.manualDisplay, .scene):
permit.owner
+ case (.manualWindow, .workspaceWindow), (.workspaceWindow, .manualWindow),
+ (.manualWindow, .manualWindow), (.workspaceWindow, .workspaceWindow):
+ permit.owner
default:
nil
}
diff --git a/Semper/Views/Settings/Tabs/ShortcutsTab.swift b/Semper/Views/Settings/Tabs/ShortcutsTab.swift
index 513147f..32732be 100644
--- a/Semper/Views/Settings/Tabs/ShortcutsTab.swift
+++ b/Semper/Views/Settings/Tabs/ShortcutsTab.swift
@@ -317,6 +317,8 @@ struct ShortcutsTab: View {
case .targetAppVolumeDown: "speaker.wave.1.fill"
case .targetAppMuteToggle: "speaker.slash.fill"
case .restoreWorkspace: "macwindow.on.rectangle"
+ case .windowLeftHalf, .windowRightHalf, .windowMaximize, .windowCenter, .windowRestore:
+ "rectangle.split.2x1"
}
}
@@ -345,6 +347,9 @@ struct ShortcutsTab: View {
case .targetAppVolumeDown: "Lower the selected target app's volume"
case .targetAppMuteToggle: "Mute or unmute the selected target app"
case .restoreWorkspace: "Prepare a fresh workspace restore preview"
+ case .windowLeftHalf, .windowRightHalf, .windowMaximize, .windowCenter:
+ "Arrange the frontmost app window"
+ case .windowRestore: "Restore the immediately preceding window placement"
}
}
diff --git a/Semper/WindowLayout/WindowLayoutModels.swift b/Semper/WindowLayout/WindowLayoutModels.swift
new file mode 100644
index 0000000..cd6e549
--- /dev/null
+++ b/Semper/WindowLayout/WindowLayoutModels.swift
@@ -0,0 +1,90 @@
+import ApplicationServices
+import CoreGraphics
+import Foundation
+
+enum WindowLayoutAction: String, CaseIterable, Identifiable, Sendable {
+ case leftHalf = "window-layout.left-half"
+ case rightHalf = "window-layout.right-half"
+ case maximize = "window-layout.maximize"
+ case center = "window-layout.center"
+ case restore = "window-layout.restore"
+
+ var id: String { rawValue }
+
+ var title: String {
+ switch self {
+ case .leftHalf: "Left Half"
+ case .rightHalf: "Right Half"
+ case .maximize: "Maximize"
+ case .center: "Center"
+ case .restore: "Restore Previous Placement"
+ }
+ }
+
+ var symbolName: String {
+ switch self {
+ case .leftHalf: "rectangle.lefthalf.filled"
+ case .rightHalf: "rectangle.righthalf.filled"
+ case .maximize: "arrow.up.left.and.arrow.down.right"
+ case .center: "rectangle.center.inset.filled"
+ case .restore: "arrow.uturn.backward"
+ }
+ }
+}
+
+protocol WindowLayoutWindowBackend: WorkspaceWindowBackend {
+ func focusedWindow(in application: WorkspaceApplication) async throws -> WorkspaceWindowSnapshot?
+}
+
+enum WindowLayoutGeometry {
+ static func target(_ action: WindowLayoutAction, frame: CGRect, display: WorkspaceDisplay) -> CGRect? {
+ let bounds = display.visibleFrame
+ guard WorkspaceGeometry.valid(frame), WorkspaceGeometry.valid(bounds) else { return nil }
+ let target: CGRect
+ switch action {
+ case .leftHalf:
+ target = CGRect(x: bounds.minX, y: bounds.minY, width: bounds.width / 2, height: bounds.height)
+ case .rightHalf:
+ target = CGRect(x: bounds.midX, y: bounds.minY, width: bounds.width / 2, height: bounds.height)
+ case .maximize:
+ target = bounds
+ case .center:
+ guard frame.width <= bounds.width, frame.height <= bounds.height else { return nil }
+ target = CGRect(
+ x: bounds.minX + (bounds.width - frame.width) / 2,
+ y: bounds.minY + (bounds.height - frame.height) / 2,
+ width: frame.width, height: frame.height)
+ case .restore:
+ return nil
+ }
+ return WorkspaceGeometry.valid(target) && bounds.contains(target) ? target : nil
+ }
+}
+
+enum WindowLayoutWindowRules {
+ static func fullscreenState(buttonSubrole: String?) -> Bool? {
+ switch buttonSubrole {
+ case kAXFullScreenButtonSubrole: false
+ case kAXZoomButtonSubrole: true
+ default: nil
+ }
+ }
+
+ static func issue(
+ standard: Bool?, minimized: Bool?, frame: CGRect?, displays: [WorkspaceDisplay],
+ movable: Bool?, resizable: Bool?, fullscreen: Bool?
+ ) -> WorkspaceWindowIssue? {
+ // A verified windowed layout can span the display height; Restore keeps its geometry exclusion.
+ let layoutDisplays = displays.map {
+ WorkspaceDisplay(id: $0.id, name: $0.name, visibleFrame: $0.visibleFrame)
+ }
+ if let issue = WorkspaceWindowRules.issue(
+ standard: standard, minimized: minimized, frame: frame, displays: layoutDisplays,
+ movable: movable, resizable: resizable)
+ {
+ return issue
+ }
+ guard let fullscreen else { return .unknownState }
+ return fullscreen ? .unsupported : nil
+ }
+}
diff --git a/Semper/WindowLayout/WindowLayoutService.swift b/Semper/WindowLayout/WindowLayoutService.swift
new file mode 100644
index 0000000..0b134f0
--- /dev/null
+++ b/Semper/WindowLayout/WindowLayoutService.swift
@@ -0,0 +1,302 @@
+import CoreGraphics
+import Foundation
+import Observation
+
+enum WindowLayoutError: LocalizedError {
+ case stopped, busy, noTarget, noRestore, placementReview, missingWindow, changedWindow, changedDisplays
+ case invalidPlacement, unsupported(WorkspaceWindowIssue), unverifiedWrite, constrained, writeFailed(String)
+
+ var errorDescription: String? {
+ switch self {
+ case .stopped: "Add and enable Window Layout before arranging a window."
+ case .busy: "Wait for the current window action to finish, or cancel it."
+ case .noTarget: "Select a window in another app, then return to Semper and try again."
+ case .noRestore: "There is no previous placement to restore in this session."
+ case .placementReview:
+ "Check the window in its original app, then choose Keep Current Placement before another action."
+ case .missingWindow: "The original app or window is no longer available. No replacement window was chosen."
+ case .changedWindow: "The window changed since the last action. Its later placement was preserved."
+ case .changedDisplays: "The displays changed. Check the window and try a new layout action."
+ case .invalidPlacement: "This placement does not fit an available display's usable area."
+ case .unsupported(let issue):
+ switch issue {
+ case .unsupported:
+ "This window does not support layout actions. Fullscreen and nonstandard windows are excluded."
+ case .unknownState:
+ "The app did not provide a verifiable window state, including whether it is fullscreen."
+ case .minimized: "Unminimize the window in its app before arranging it."
+ default: issue.message
+ }
+ case .unverifiedWrite:
+ "The window change could not be verified. Check the window in its original app, then choose Keep Current Placement."
+ case .constrained: "The app constrained the placement. The observed change can be restored."
+ case .writeFailed(let reason): reason
+ }
+ }
+}
+
+@Observable
+@MainActor
+final class WindowLayoutService {
+ private struct PreviousPlacement {
+ let windowID: WorkspaceWindowID
+ let before: CGRect
+ let after: CGRect
+ let displays: [WorkspaceDisplay]
+ }
+
+ private(set) var isRunning = false
+ private(set) var isBusy = false
+ private(set) var permission: ModulePermissionState = .notDetermined
+ private(set) var message: String?
+ private(set) var requiresPlacementReview = false
+ var canRestore: Bool { isRunning && !isBusy && previousPlacement != nil && !requiresPlacementReview }
+
+ private let backend: any WindowLayoutWindowBackend
+ private let mutationAdmission: MutationAdmissionGate
+ private let targetApplication: @MainActor () -> WorkspaceApplication?
+ private let targetTracker: WindowLayoutTargetTracker?
+ private var previousPlacement: PreviousPlacement?
+ private var operation: Task?
+ private var pauseTask: Task?
+ private var shutdownTask: Task?
+ private var prompted = false
+ private var isStopping = false
+ private var isShuttingDown = false
+
+ init(
+ backend: any WindowLayoutWindowBackend = AccessibilityWorkspaceBackend(),
+ mutationAdmission: MutationAdmissionGate,
+ targetApplication: (@MainActor () -> WorkspaceApplication?)? = nil
+ ) {
+ self.backend = backend
+ self.mutationAdmission = mutationAdmission
+ if let targetApplication {
+ self.targetApplication = targetApplication
+ targetTracker = nil
+ } else {
+ let tracker = WindowLayoutTargetTracker()
+ targetTracker = tracker
+ self.targetApplication = { tracker.targetApplication() }
+ }
+ }
+
+ isolated deinit {
+ targetTracker?.stop()
+ operation?.cancel()
+ }
+
+ func start() {
+ guard !isRunning, !isStopping, !isShuttingDown, operation == nil else { return }
+ isRunning = true
+ targetTracker?.start()
+ }
+
+ func pause() async {
+ if let pauseTask {
+ await pauseTask.value
+ return
+ }
+ isStopping = true
+ isRunning = false
+ targetTracker?.stop()
+ let pending = operation
+ pending?.cancel()
+ let task = Task { @MainActor in
+ _ = await pending?.result
+ self.pauseTask = nil
+ self.isStopping = false
+ }
+ pauseTask = task
+ await task.value
+ }
+
+ func shutdown() async {
+ if let shutdownTask {
+ await shutdownTask.value
+ return
+ }
+ isShuttingDown = true
+ let task = Task { @MainActor in
+ await self.pause()
+ await self.backend.shutdown()
+ self.previousPlacement = nil
+ self.requiresPlacementReview = false
+ self.message = nil
+ self.shutdownTask = nil
+ self.isShuttingDown = false
+ }
+ shutdownTask = task
+ await task.value
+ }
+
+ func cancel() { operation?.cancel() }
+
+ func keepCurrentPlacement() {
+ guard isRunning, !isBusy, !isStopping, !isShuttingDown else { return }
+ previousPlacement = nil
+ requiresPlacementReview = false
+ message = "Current placement kept. The preceding placement was forgotten."
+ }
+
+ func perform(_ action: WindowLayoutAction) async throws {
+ do {
+ guard isRunning, !isStopping, !isShuttingDown else { throw WindowLayoutError.stopped }
+ guard operation == nil else { throw WindowLayoutError.busy }
+ guard !requiresPlacementReview else { throw WindowLayoutError.placementReview }
+ let previous = previousPlacement
+ let application: WorkspaceApplication?
+ if action == .restore {
+ guard let previous else { throw WindowLayoutError.noRestore }
+ application = previous.windowID.application
+ } else {
+ application = targetApplication()
+ guard application != nil else { throw WindowLayoutError.noTarget }
+ }
+ isBusy = true
+ message = nil
+ let task = Task { @MainActor in
+ defer {
+ self.operation = nil
+ self.isBusy = false
+ }
+ let permit = try self.mutationAdmission.acquire(owner: .manualWindow, mode: .shared)
+ defer { self.mutationAdmission.release(permit) }
+ try await self.requirePermission()
+ if action == .restore, let previous {
+ try await self.restore(previous)
+ } else if let application {
+ try await self.arrange(action, application: application)
+ }
+ }
+ operation = task
+ try await withTaskCancellationHandler {
+ try await task.value
+ } onCancel: {
+ task.cancel()
+ }
+ } catch {
+ if error is CancellationError {
+ if !requiresPlacementReview {
+ message = previousPlacement == nil
+ ? "Window action cancelled. No observed change is available to restore."
+ : "Window action cancelled. The observed change remains available to restore."
+ }
+ } else if error is MutationAdmissionError {
+ message = "Finish the active window action or end Away Mode, then try again."
+ } else {
+ if let workspaceError = error as? WorkspaceError, case .permission = workspaceError {
+ permission = permission == .granted || permission == .revoked ? .revoked : .denied
+ }
+ message = error.localizedDescription
+ }
+ throw error
+ }
+ }
+
+ private func requirePermission() async throws {
+ try Task.checkCancellation()
+ var granted = await backend.permission(prompt: false)
+ try Task.checkCancellation()
+ if !granted, !prompted {
+ prompted = true
+ granted = await backend.permission(prompt: true)
+ try Task.checkCancellation()
+ }
+ if granted {
+ permission = .granted
+ } else {
+ permission = permission == .granted || permission == .revoked ? .revoked : .denied
+ throw WorkspaceError.permission
+ }
+ }
+
+ private func arrange(_ action: WindowLayoutAction, application: WorkspaceApplication) async throws {
+ let displays = topologyIdentity(await backend.displays())
+ guard let snapshot = try await backend.focusedWindow(in: application),
+ snapshot.application == application
+ else { throw WindowLayoutError.missingWindow }
+ let frame = try supportedFrame(snapshot)
+ guard let windowID = snapshot.id, windowID.application == application else {
+ throw WindowLayoutError.missingWindow
+ }
+ guard let display = WorkspaceGeometry.display(for: frame, in: displays),
+ let target = WindowLayoutGeometry.target(action, frame: frame, display: display)
+ else { throw WindowLayoutError.invalidPlacement }
+ guard let current = try await backend.current(windowID), current.id == windowID,
+ current.application == application
+ else { throw WindowLayoutError.missingWindow }
+ guard try supportedFrame(current) == frame else { throw WindowLayoutError.changedWindow }
+ guard topologyIdentity(await backend.displays()) == displays else { throw WindowLayoutError.changedDisplays }
+ try Task.checkCancellation()
+ if WorkspaceGeometry.approximatelyEqual(frame, target) {
+ message = "The window is already in this placement."
+ return
+ }
+ let observation = try await backend.move(windowID, to: target, expected: frame)
+ try record(observation, windowID: windowID, target: target, displays: displays, restoring: nil)
+ message = "\(action.title) applied and verified. Restore returns to the preceding placement."
+ }
+
+ private func restore(_ previous: PreviousPlacement) async throws {
+ guard let current = try await backend.current(previous.windowID), current.id == previous.windowID,
+ current.application == previous.windowID.application
+ else { throw WindowLayoutError.missingWindow }
+ let frame = try supportedFrame(current)
+ guard frame == previous.after else { throw WindowLayoutError.changedWindow }
+ let displays = topologyIdentity(await backend.displays())
+ guard displays == previous.displays else { throw WindowLayoutError.changedDisplays }
+ guard displays.contains(where: { $0.visibleFrame.contains(previous.before) }) else {
+ throw WindowLayoutError.invalidPlacement
+ }
+ try Task.checkCancellation()
+ let observation = try await backend.move(previous.windowID, to: previous.before, expected: previous.after)
+ try record(
+ observation, windowID: previous.windowID, target: previous.before,
+ displays: displays, restoring: previous)
+ message = "Previous placement restored and verified."
+ }
+
+ private func supportedFrame(_ snapshot: WorkspaceWindowSnapshot) throws -> CGRect {
+ if let issue = snapshot.issue { throw WindowLayoutError.unsupported(issue) }
+ guard let frame = snapshot.frame, WorkspaceGeometry.valid(frame) else {
+ throw WindowLayoutError.unsupported(.unknownState)
+ }
+ return frame
+ }
+
+ private func record(
+ _ observation: WorkspaceMoveObservation, windowID: WorkspaceWindowID, target: CGRect,
+ displays: [WorkspaceDisplay], restoring: PreviousPlacement?
+ ) throws {
+ guard let after = observation.after, WorkspaceGeometry.valid(after) else {
+ if observation.writeAttempted {
+ previousPlacement = nil
+ requiresPlacementReview = true
+ message = WindowLayoutError.unverifiedWrite.localizedDescription
+ throw WindowLayoutError.unverifiedWrite
+ }
+ throw WindowLayoutError.writeFailed(observation.failure ?? "The app did not return its current window frame.")
+ }
+ let reachedTarget = WorkspaceGeometry.approximatelyEqual(after, target)
+ if observation.writeAttempted, observation.before != after {
+ previousPlacement = PreviousPlacement(
+ windowID: windowID, before: restoring?.before ?? observation.before, after: after, displays: displays)
+ }
+ if restoring != nil, reachedTarget { previousPlacement = nil }
+ try Task.checkCancellation()
+ if let failure = observation.failure { throw WindowLayoutError.writeFailed(failure) }
+ guard reachedTarget else {
+ if observation.before == after {
+ throw WindowLayoutError.writeFailed("The app kept the window in its current placement.")
+ }
+ throw WindowLayoutError.constrained
+ }
+ }
+
+ private func topologyIdentity(_ displays: [WorkspaceDisplay]) -> [WorkspaceDisplay] {
+ displays.map {
+ WorkspaceDisplay(id: $0.id, name: "", visibleFrame: $0.visibleFrame, fullScreenFrame: $0.fullScreenFrame)
+ }.sorted { $0.id < $1.id }
+ }
+}
diff --git a/Semper/WindowLayout/WindowLayoutTargetTracker.swift b/Semper/WindowLayout/WindowLayoutTargetTracker.swift
new file mode 100644
index 0000000..927e304
--- /dev/null
+++ b/Semper/WindowLayout/WindowLayoutTargetTracker.swift
@@ -0,0 +1,64 @@
+import AppKit
+
+@MainActor
+final class WindowLayoutTargetTracker {
+ private var observer: (any NSObjectProtocol)?
+ private(set) var lastApplication: WorkspaceApplication?
+ private var generation = UUID()
+
+ isolated deinit { stop() }
+
+ func start() {
+ guard observer == nil else { return }
+ capture(NSWorkspace.shared.frontmostApplication)
+ let generation = generation
+ observer = NSWorkspace.shared.notificationCenter.addObserver(
+ forName: NSWorkspace.didActivateApplicationNotification, object: nil, queue: .main
+ ) { [weak self] notification in
+ let application = notification.userInfo?[NSWorkspace.applicationUserInfoKey] as? NSRunningApplication
+ MainActor.assumeIsolated {
+ guard let self, self.generation == generation, self.observer != nil else { return }
+ self.capture(application)
+ }
+ }
+ }
+
+ func targetApplication() -> WorkspaceApplication? {
+ guard observer != nil else { return nil }
+ capture(NSWorkspace.shared.frontmostApplication)
+ return lastApplication
+ }
+
+ func stop() {
+ generation = UUID()
+ if let observer { NSWorkspace.shared.notificationCenter.removeObserver(observer) }
+ observer = nil
+ lastApplication = nil
+ }
+
+ private func capture(_ app: NSRunningApplication?) {
+ guard let app else {
+ recordActivation(nil, isSemper: false)
+ return
+ }
+ if app.processIdentifier == ProcessInfo.processInfo.processIdentifier
+ || (Bundle.main.bundleIdentifier.map { app.bundleIdentifier == $0 } ?? false)
+ {
+ recordActivation(nil, isSemper: true)
+ return
+ }
+ guard app.activationPolicy == .regular, !app.isTerminated, let bundleID = app.bundleIdentifier,
+ let launchDate = app.launchDate
+ else {
+ recordActivation(nil, isSemper: false)
+ return
+ }
+ recordActivation(WorkspaceApplication(
+ pid: app.processIdentifier, bundleID: bundleID,
+ name: app.localizedName ?? bundleID, launchDate: launchDate), isSemper: false)
+ }
+
+ func recordActivation(_ application: WorkspaceApplication?, isSemper: Bool) {
+ if !isSemper { lastApplication = application }
+ }
+}
diff --git a/Semper/WindowLayout/WindowLayoutView.swift b/Semper/WindowLayout/WindowLayoutView.swift
new file mode 100644
index 0000000..b0af61f
--- /dev/null
+++ b/Semper/WindowLayout/WindowLayoutView.swift
@@ -0,0 +1,51 @@
+import SwiftUI
+
+struct WindowLayoutView: View {
+ @Bindable var service: WindowLayoutService
+ let commands: UtilityCommandCenter
+ @State private var confirmKeepCurrent = false
+
+ var body: some View {
+ ScrollView {
+ VStack(alignment: .leading, spacing: 18) {
+ Text("Window Layout").font(.title2.weight(.semibold))
+ Text("Arrange the frontmost app window. When Semper is in front, actions use the last app active while this module was running.")
+ .foregroundStyle(.secondary)
+ if let message = service.message {
+ Text(message).textSelection(.enabled)
+ .foregroundStyle(service.requiresPlacementReview ? .orange : .secondary)
+ }
+ if service.isBusy {
+ HStack {
+ ProgressView().controlSize(.small)
+ Text("Waiting for the window…")
+ Button("Cancel", action: service.cancel).keyboardShortcut(.cancelAction)
+ }
+ }
+ if service.requiresPlacementReview {
+ Text("Check the affected window before continuing. Its last change could not be verified, so automatic restore is unavailable.")
+ .font(.callout)
+ Button("Keep Current Placement…") { confirmKeepCurrent = true }
+ .disabled(service.isBusy || !service.isRunning)
+ }
+ UtilityActionList(
+ commands: commands,
+ actions: WindowLayoutAction.allCases.compactMap {
+ commands.registry.action(for: .init(rawValue: $0.rawValue))
+ })
+ Text("Halves and Maximize use the display area available around the Dock and menu bar. Center keeps the current size. Restore returns the last changed window to its immediately preceding placement and skips later manual changes.")
+ .font(.caption).foregroundStyle(.secondary)
+ Text("Full-screen, minimized, unsupported, and unverified windows stay unchanged. Choose another app and return here if no target is available. You can assign optional shortcuts in Settings.")
+ .font(.caption).foregroundStyle(.secondary)
+ Text("Pausing retains the previous placement. Removing Window Layout or quitting clears that session history.")
+ .font(.caption).foregroundStyle(.secondary)
+ }
+ .padding(24)
+ }
+ .confirmationDialog("Keep this window placement?", isPresented: $confirmKeepCurrent) {
+ Button("Keep Current Placement", role: .destructive) { service.keepCurrentPlacement() }
+ } message: {
+ Text("This discards the unverified change record. Arrange the window manually if needed before continuing.")
+ }
+ }
+}
diff --git a/Semper/Workspace/WorkspaceService.swift b/Semper/Workspace/WorkspaceService.swift
index 5ca8d6f..54255f8 100644
--- a/Semper/Workspace/WorkspaceService.swift
+++ b/Semper/Workspace/WorkspaceService.swift
@@ -905,7 +905,7 @@ final class WorkspaceService {
ownerToken == nil || reservedPlanID == planID
else { return receipt(planID: planID, reversing: reversing, steps: initial, issue: .busy) }
let permit: MutationAdmissionPermit?
- do { permit = try mutationAdmission?.acquire(owner: .manual, mode: .shared) } catch {
+ do { permit = try mutationAdmission?.acquire(owner: .workspaceWindow, mode: .shared) } catch {
return receipt(planID: planID, reversing: reversing, steps: initial, issue: .mutationsBlocked)
}
defer { if let permit { mutationAdmission?.release(permit) } }
@@ -964,8 +964,8 @@ final class WorkspaceService {
return
}
let permit: MutationAdmissionPermit?
- do { permit = mutatesWindows ? try mutationAdmission?.acquire(owner: .manual, mode: .shared) : nil } catch {
- errorMessage = "End Away Mode before moving windows."
+ do { permit = mutatesWindows ? try mutationAdmission?.acquire(owner: .workspaceWindow, mode: .shared) : nil } catch {
+ errorMessage = "Finish the active window action or end Away before moving windows."
return
}
defer { if let permit { mutationAdmission?.release(permit) } }
diff --git a/Semper/Workspace/WorkspaceWindowBackend.swift b/Semper/Workspace/WorkspaceWindowBackend.swift
index 876eec6..77c3111 100644
--- a/Semper/Workspace/WorkspaceWindowBackend.swift
+++ b/Semper/Workspace/WorkspaceWindowBackend.swift
@@ -12,11 +12,16 @@ protocol WorkspaceWindowBackend: Sendable {
func shutdown() async
}
-actor AccessibilityWorkspaceBackend: WorkspaceWindowBackend {
+actor AccessibilityWorkspaceBackend: WindowLayoutWindowBackend {
+ private enum HandlePolicy {
+ case workspaceRestore, windowLayout
+ }
+
private struct Handle {
let element: AXUIElement
let application: WorkspaceApplication
let ordinal: Int
+ let policy: HandlePolicy
}
private var handles: [WorkspaceWindowID: Handle] = [:]
private let messageTimeout: Float = 0.15
@@ -107,7 +112,8 @@ actor AccessibilityWorkspaceBackend: WorkspaceWindowBackend {
AXUIElementSetMessagingTimeout(window, messageTimeout)
let id =
handles.first(where: {
- $0.value.application == application && CFEqual($0.value.element, window)
+ $0.value.policy == .workspaceRestore && $0.value.application == application
+ && CFEqual($0.value.element, window)
})?.key ?? WorkspaceWindowID(application: application, token: UUID())
guard handles[id] != nil || handles.count < 2_000 else {
snapshots.append(
@@ -115,7 +121,8 @@ actor AccessibilityWorkspaceBackend: WorkspaceWindowBackend {
id: nil, application: application, ordinal: index + 1, frame: nil, issue: .unavailable))
continue
}
- handles[id] = Handle(element: window, application: application, ordinal: index + 1)
+ handles[id] = Handle(
+ element: window, application: application, ordinal: index + 1, policy: .workspaceRestore)
snapshots.append(try snapshot(id, deadline: deadline))
if snapshots.count >= 200 { return snapshots }
}
@@ -128,6 +135,45 @@ actor AccessibilityWorkspaceBackend: WorkspaceWindowBackend {
return snapshots
}
+ func focusedWindow(in application: WorkspaceApplication) async throws -> WorkspaceWindowSnapshot? {
+ try Task.checkCancellation()
+ guard AXIsProcessTrusted() else { throw WorkspaceError.permission }
+ _ = await displays()
+ guard await isSameProcess(application) else { return nil }
+ let deadline = ContinuousClock.now.advanced(by: .seconds(2))
+ let element = AXUIElementCreateApplication(application.pid)
+ AXUIElementSetMessagingTimeout(element, messageTimeout)
+ var value: CFTypeRef?
+ let result = AXUIElementCopyAttributeValue(element, kAXFocusedWindowAttribute as CFString, &value)
+ guard result != .noValue else { return nil }
+ guard result == .success, let value, CFGetTypeID(value) == AXUIElementGetTypeID() else {
+ return .init(
+ id: nil, application: application, ordinal: 1, frame: nil,
+ issue: result == .cannotComplete ? .timedOut : .unavailable)
+ }
+ let window = value as! AXUIElement
+ var pid: pid_t = 0
+ guard AXUIElementGetPid(window, &pid) == .success, pid == application.pid else {
+ return .init(id: nil, application: application, ordinal: 1, frame: nil, issue: .ambiguousIdentity)
+ }
+ AXUIElementSetMessagingTimeout(window, messageTimeout)
+ let id = handles.first(where: {
+ $0.value.policy == .windowLayout && $0.value.application == application
+ && CFEqual($0.value.element, window)
+ })?.key ?? WorkspaceWindowID(application: application, token: UUID())
+ guard handles[id] != nil || handles.count < 2_000 else {
+ return .init(id: nil, application: application, ordinal: 1, frame: nil, issue: .unavailable)
+ }
+ handles[id] = Handle(element: window, application: application, ordinal: 1, policy: .windowLayout)
+ let state = try snapshot(id, deadline: deadline)
+ guard await isSameProcess(application) else {
+ handles[id] = nil
+ return nil
+ }
+ try Task.checkCancellation()
+ return state
+ }
+
func current(_ id: WorkspaceWindowID) async throws -> WorkspaceWindowSnapshot? {
try Task.checkCancellation()
guard AXIsProcessTrusted() else { throw WorkspaceError.permission }
@@ -147,7 +193,10 @@ actor AccessibilityWorkspaceBackend: WorkspaceWindowBackend {
guard before == expected else {
return .init(
before: before, after: before,
- failure: "The window changed after preview. Preview again before restoring.", writeAttempted: false)
+ failure: handle.policy == .windowLayout
+ ? "The window changed before the layout was applied. Try again."
+ : "The window changed after preview. Preview again before restoring.",
+ writeAttempted: false)
}
var position = frame.origin
var size = frame.size
@@ -163,7 +212,9 @@ actor AccessibilityWorkspaceBackend: WorkspaceWindowBackend {
(kAXSizeAttribute, sizeValue), (kAXPositionAttribute, positionValue), (kAXSizeAttribute, sizeValue),
] {
guard !Task.isCancelled else {
- failure = "Restore cancelled after the last observed change."
+ failure = handle.policy == .windowLayout
+ ? "Window change cancelled after the last observed change."
+ : "Restore cancelled after the last observed change."
break
}
guard AXIsProcessTrusted() else {
@@ -200,12 +251,13 @@ actor AccessibilityWorkspaceBackend: WorkspaceWindowBackend {
private func snapshot(_ id: WorkspaceWindowID, deadline: ContinuousClock.Instant) throws -> WorkspaceWindowSnapshot
{
guard let handle = handles[id] else { throw WorkspaceError.missing }
- func value(_ name: String) throws -> CFTypeRef? {
+ func value(_ name: String, from element: AXUIElement? = nil) throws -> CFTypeRef? {
try Task.checkCancellation()
guard ContinuousClock.now < deadline else { throw WorkspaceWindowReadError.timeout }
var value: CFTypeRef?
- let result = AXUIElementCopyAttributeValue(handle.element, name as CFString, &value)
+ let result = AXUIElementCopyAttributeValue(element ?? handle.element, name as CFString, &value)
if result == .invalidUIElement { throw WorkspaceError.missing }
+ if result == .cannotComplete && handle.policy == .windowLayout { throw WorkspaceWindowReadError.timeout }
return result == .success ? value : nil
}
do {
@@ -216,11 +268,29 @@ actor AccessibilityWorkspaceBackend: WorkspaceWindowBackend {
var resizable = DarwinBoolean(false)
let moveResult = AXUIElementIsAttributeSettable(handle.element, kAXPositionAttribute as CFString, &movable)
let sizeResult = AXUIElementIsAttributeSettable(handle.element, kAXSizeAttribute as CFString, &resizable)
- let issue = WorkspaceWindowRules.issue(
- standard: role.map { $0 == kAXStandardWindowSubrole }, minimized: minimized, frame: frame,
- displays: currentDisplays,
- movable: moveResult == .success ? movable.boolValue : nil,
- resizable: sizeResult == .success ? resizable.boolValue : nil)
+ let issue: WorkspaceWindowIssue?
+ switch handle.policy {
+ case .workspaceRestore:
+ issue = WorkspaceWindowRules.issue(
+ standard: role.map { $0 == kAXStandardWindowSubrole }, minimized: minimized, frame: frame,
+ displays: currentDisplays,
+ movable: moveResult == .success ? movable.boolValue : nil,
+ resizable: sizeResult == .success ? resizable.boolValue : nil)
+ case .windowLayout:
+ var fullscreen: Bool?
+ if let button = try value(kAXFullScreenButtonAttribute), CFGetTypeID(button) == AXUIElementGetTypeID() {
+ let buttonElement = button as! AXUIElement
+ AXUIElementSetMessagingTimeout(buttonElement, messageTimeout)
+ fullscreen = WindowLayoutWindowRules.fullscreenState(
+ buttonSubrole: try value(kAXSubroleAttribute, from: buttonElement) as? String)
+ }
+ issue = WindowLayoutWindowRules.issue(
+ standard: role.map { $0 == kAXStandardWindowSubrole }, minimized: minimized, frame: frame,
+ displays: currentDisplays,
+ movable: moveResult == .success ? movable.boolValue : nil,
+ resizable: sizeResult == .success ? resizable.boolValue : nil,
+ fullscreen: fullscreen)
+ }
return .init(id: id, application: handle.application, ordinal: handle.ordinal, frame: frame, issue: issue)
} catch is CancellationError { throw CancellationError() } catch {
return .init(
diff --git a/SemperTests/MutationAdmissionGateTests.swift b/SemperTests/MutationAdmissionGateTests.swift
index f644762..8f36dfa 100644
--- a/SemperTests/MutationAdmissionGateTests.swift
+++ b/SemperTests/MutationAdmissionGateTests.swift
@@ -83,6 +83,32 @@ struct MutationAdmissionGateTests {
#expect(firstGate.activeSharedPermitCount == 0)
}
+ @Test("Window actions serialize with Workspace writes while lifecycle permits remain available")
+ func windowWriteAdmission() throws {
+ let gate = MutationAdmissionGate()
+ let layout = try gate.acquire(owner: .manualWindow, mode: .shared)
+ let lifecycle = try gate.acquire(owner: .manual, mode: .shared)
+ let presentation = try gate.acquire(owner: .presentation, mode: .shared)
+ for owner in [MutationAdmissionOwner.manualWindow, .workspaceWindow] {
+ #expect(throws: MutationAdmissionError.sharedPermitsActive(owners: [.manualWindow])) {
+ try gate.acquire(owner: owner, mode: .shared)
+ }
+ }
+ #expect(gate.release(layout))
+ let workspace = try gate.acquire(owner: .workspaceWindow, mode: .shared)
+ #expect(throws: MutationAdmissionError.sharedPermitsActive(owners: [.workspaceWindow])) {
+ try gate.acquire(owner: .manualWindow, mode: .shared)
+ }
+ #expect(gate.release(workspace))
+ #expect(gate.release(lifecycle))
+ #expect(gate.release(presentation))
+ let away = try gate.acquire(owner: .awayMode, mode: .exclusive)
+ #expect(throws: MutationAdmissionError.exclusivePermitActive(owner: .awayMode)) {
+ try gate.acquire(owner: .manualWindow, mode: .shared)
+ }
+ #expect(gate.release(away))
+ }
+
@Test("A shared permit remains held across suspension")
func sharedPermitRemainsHeldAcrossSuspension() async throws {
let gate = MutationAdmissionGate()
diff --git a/SemperTests/ShellUITestFixtureTests.swift b/SemperTests/ShellUITestFixtureTests.swift
index bd47f1b..52215be 100644
--- a/SemperTests/ShellUITestFixtureTests.swift
+++ b/SemperTests/ShellUITestFixtureTests.swift
@@ -229,6 +229,7 @@
private func expectServicesAbsent(_ runtime: UtilityRuntime) {
#expect(runtime.sound == nil && runtime.awake == nil && runtime.workspace == nil)
+ #expect(runtime.windowLayout == nil)
#expect(runtime.shelf == nil && runtime.storage == nil && runtime.displays == nil)
#expect(runtime.away == nil && runtime.scenes == nil && runtime.presentation == nil)
#expect(runtime.sceneShortcuts == nil && runtime.onOpenDetail == nil)
diff --git a/SemperTests/UtilityRuntimeTests.swift b/SemperTests/UtilityRuntimeTests.swift
index 7ad7f66..97bb600 100644
--- a/SemperTests/UtilityRuntimeTests.swift
+++ b/SemperTests/UtilityRuntimeTests.swift
@@ -36,7 +36,7 @@ struct UtilityRuntimeTests {
@Test(
"Adding, pausing, removing, and adding again never starts a dormant module",
- arguments: [UtilityModuleID.sound, .awake, .workspace, .shelf, .storage])
+ arguments: [UtilityModuleID.sound, .awake, .workspace, .windowLayout, .shelf, .storage])
func moduleManagementIsDormant(module: UtilityModuleID) async throws {
try await withRuntime(addedModules: []) { runtime, probe, _ in
try runtime.registry.add(module)
@@ -79,6 +79,28 @@ struct UtilityRuntimeTests {
}
}
+ @Test("Window Layout search and pinned actions follow module presence without startup")
+ func windowLayoutActionsAreDormant() async throws {
+ try await withRuntime(addedModules: []) { runtime, _, _ in
+ let actions = Set(WindowLayoutAction.allCases.map { UtilityActionID(rawValue: $0.rawValue) })
+ #expect(runtime.registry.search("layout").isEmpty)
+ try runtime.registry.add(.windowLayout)
+ #expect(actions.isSubset(of: Set(runtime.registry.search("layout").map(\.id))))
+ let left = UtilityActionID(rawValue: WindowLayoutAction.leftHalf.rawValue)
+ try runtime.registry.setFavorite(true, for: left)
+ #expect(runtime.registry.favoriteActions.map(\.id) == [left])
+ #expect(runtime.windowLayout == nil)
+ #expect(runtime.commands.disabledReason(for: left) == nil)
+ try await runtime.pause(.windowLayout)
+ #expect(runtime.commands.disabledReason(for: left) != nil)
+ try runtime.registry.resume(.windowLayout)
+ #expect(runtime.commands.disabledReason(for: left) == nil)
+ try await runtime.remove(.windowLayout)
+ #expect(runtime.registry.search("layout").isEmpty)
+ #expect(runtime.windowLayout == nil)
+ }
+ }
+
@Test("Home and Modules render without constructing services or attaching shell actions")
func dormantViewsRender() async throws {
let directory = FileManager.default.temporaryDirectory
@@ -175,6 +197,7 @@ struct UtilityRuntimeTests {
soundFactory: probe.makeSound,
awakeFactory: { try probe.unexpectedCreation(.awake) },
workspaceFactory: { try probe.unexpectedCreation(.workspace) },
+ windowLayoutFactory: { _ in try probe.unexpectedCreation(.windowLayout) },
shelfFactory: { try probe.unexpectedCreation(.shelf) },
storageFactory: { try probe.unexpectedCreation(.storage) }
)
@@ -189,6 +212,7 @@ struct UtilityRuntimeTests {
#expect(runtime.sound == nil)
#expect(runtime.awake == nil)
#expect(runtime.workspace == nil)
+ #expect(runtime.windowLayout == nil)
#expect(runtime.shelf == nil)
#expect(runtime.storage == nil)
#expect(probe.creationCount == 0)
diff --git a/SemperTests/WindowLayoutGeometryTests.swift b/SemperTests/WindowLayoutGeometryTests.swift
new file mode 100644
index 0000000..a0b89dd
--- /dev/null
+++ b/SemperTests/WindowLayoutGeometryTests.swift
@@ -0,0 +1,183 @@
+import ApplicationServices
+import CoreGraphics
+import Testing
+
+@testable import Semper
+
+@Suite("Window Layout geometry and eligibility")
+struct WindowLayoutGeometryTests {
+ let original = CGRect(x: 70, y: 100, width: 400, height: 300)
+ let display = WorkspaceDisplay(
+ id: "main", name: "Display", visibleFrame: CGRect(x: 0, y: 25, width: 1001, height: 675),
+ fullScreenFrame: CGRect(x: 0, y: 0, width: 1001, height: 750))
+
+ @Test("Halves partition odd usable widths without rounding beyond the display")
+ func halves() throws {
+ let left = try #require(WindowLayoutGeometry.target(.leftHalf, frame: original, display: display))
+ let right = try #require(WindowLayoutGeometry.target(.rightHalf, frame: original, display: display))
+ #expect(left == CGRect(x: 0, y: 25, width: 500.5, height: 675))
+ #expect(left.maxX == right.minX)
+ #expect(right.maxX == display.visibleFrame.maxX)
+ #expect(left.union(right) == display.visibleFrame)
+ #expect(display.visibleFrame.contains(left))
+ #expect(display.visibleFrame.contains(right))
+ }
+
+ @Test("Fractional display origins remain inside usable bounds")
+ func fractionalBounds() throws {
+ let display = WorkspaceDisplay(
+ id: "fractional", name: "Display", visibleFrame: CGRect(x: -999.75, y: 24.25, width: 999.5, height: 675.5))
+ for action in [WindowLayoutAction.leftHalf, .rightHalf, .maximize, .center] {
+ let target = try #require(WindowLayoutGeometry.target(action, frame: original, display: display))
+ #expect(display.visibleFrame.contains(target))
+ }
+ let left = try #require(WindowLayoutGeometry.target(.leftHalf, frame: original, display: display))
+ let right = try #require(WindowLayoutGeometry.target(.rightHalf, frame: original, display: display))
+ #expect(left.maxX == right.minX)
+ #expect(left.union(right) == display.visibleFrame)
+ }
+
+ @Test("Maximize uses the usable display instead of fullscreen bounds")
+ func maximize() {
+ #expect(WindowLayoutGeometry.target(.maximize, frame: original, display: display) == display.visibleFrame)
+ #expect(WindowLayoutGeometry.target(.maximize, frame: original, display: display) != display.fullScreenFrame)
+ }
+
+ @Test("Center preserves window dimensions on a display left of the primary display")
+ func center() throws {
+ let display = WorkspaceDisplay(
+ id: "left", name: "Display", visibleFrame: CGRect(x: -1500, y: -200, width: 1200, height: 900))
+ let centered = try #require(WindowLayoutGeometry.target(.center, frame: original, display: display))
+ #expect(centered.size == original.size)
+ #expect(centered.midX == display.visibleFrame.midX)
+ #expect(centered.midY == display.visibleFrame.midY)
+ }
+
+ @Test("Center refuses an oversized window without resizing it")
+ func oversizedCenter() {
+ for frame in [
+ CGRect(x: 0, y: 0, width: 1002, height: 300),
+ CGRect(x: 0, y: 0, width: 400, height: 676),
+ ] {
+ #expect(WindowLayoutGeometry.target(.center, frame: frame, display: display) == nil)
+ }
+ #expect(
+ WindowLayoutGeometry.target(.center, frame: display.visibleFrame, display: display) == display.visibleFrame)
+ }
+
+ @Test("Invalid frames or displays never produce a movement target")
+ func invalidGeometry() {
+ let invalidFrames = [
+ CGRect.zero, CGRect.null, CGRect.infinite,
+ CGRect(x: 0, y: 0, width: CGFloat.nan, height: 100),
+ CGRect(x: 0, y: 0, width: 100_000, height: 100),
+ ]
+ for frame in invalidFrames {
+ for action in WindowLayoutAction.allCases {
+ #expect(WindowLayoutGeometry.target(action, frame: frame, display: display) == nil)
+ #expect(
+ WindowLayoutGeometry.target(
+ action, frame: original,
+ display: WorkspaceDisplay(id: "invalid", name: "Display", visibleFrame: frame)) == nil)
+ }
+ }
+ }
+
+ @Test("Restore has no calculated target and requires the recorded previous placement")
+ func restoreNeedsHistory() {
+ #expect(WindowLayoutGeometry.target(.restore, frame: original, display: display) == nil)
+ }
+
+ @Test("Only recognized fullscreen button subroles establish fullscreen state")
+ func fullscreenButtonState() {
+ #expect(WindowLayoutWindowRules.fullscreenState(buttonSubrole: kAXFullScreenButtonSubrole) == false)
+ #expect(WindowLayoutWindowRules.fullscreenState(buttonSubrole: kAXZoomButtonSubrole) == true)
+ #expect(WindowLayoutWindowRules.fullscreenState(buttonSubrole: nil) == nil)
+ #expect(WindowLayoutWindowRules.fullscreenState(buttonSubrole: kAXCloseButtonSubrole) == nil)
+ #expect(WindowLayoutWindowRules.fullscreenState(buttonSubrole: "vendor-button") == nil)
+ }
+
+ @Test("Verified windowed full-height layouts remain eligible for center and restoration")
+ func repeatedLayoutsRetainEligibility() throws {
+ let display = WorkspaceDisplay(
+ id: "auto-hide", name: "Display", visibleFrame: CGRect(x: 0, y: 0, width: 1000, height: 700),
+ fullScreenFrame: CGRect(x: 0, y: 0, width: 1000, height: 700))
+ for action in [WindowLayoutAction.leftHalf, .rightHalf, .maximize] {
+ let arranged = try #require(WindowLayoutGeometry.target(action, frame: original, display: display))
+ let centered = try #require(WindowLayoutGeometry.target(.center, frame: arranged, display: display))
+ for frame in [arranged, centered, original] {
+ #expect(
+ WindowLayoutWindowRules.issue(
+ standard: true, minimized: false, frame: frame, displays: [display],
+ movable: true, resizable: true, fullscreen: false) == nil)
+ }
+ #expect(
+ WorkspaceWindowRules.issue(
+ standard: true, minimized: false, frame: arranged, displays: [display],
+ movable: true, resizable: true) == .manualAdjustmentRequired)
+ }
+ #expect(display.fullScreenFrame == display.visibleFrame)
+ }
+
+ @Test("True and unknown fullscreen states are refused for ordinary and full-height frames")
+ func refusedFullscreen() {
+ for frame in [original, display.visibleFrame] {
+ #expect(
+ WindowLayoutWindowRules.issue(
+ standard: true, minimized: false, frame: frame, displays: [display],
+ movable: true, resizable: true, fullscreen: true) == .unsupported)
+ #expect(
+ WindowLayoutWindowRules.issue(
+ standard: true, minimized: false, frame: frame, displays: [display],
+ movable: true, resizable: true, fullscreen: nil) == .unknownState)
+ }
+ }
+
+ @Test("Layout retains unsupported and minimized Workspace boundaries")
+ func unsupportedStates() {
+ #expect(
+ WindowLayoutWindowRules.issue(
+ standard: false, minimized: false, frame: original, displays: [display],
+ movable: true, resizable: true, fullscreen: false) == .unsupported)
+ #expect(
+ WindowLayoutWindowRules.issue(
+ standard: true, minimized: true, frame: original, displays: [display],
+ movable: true, resizable: true, fullscreen: false) == .minimized)
+ #expect(
+ WindowLayoutWindowRules.issue(
+ standard: true, minimized: false, frame: original, displays: [display],
+ movable: false, resizable: true, fullscreen: false) == .unsupported)
+ #expect(
+ WindowLayoutWindowRules.issue(
+ standard: true, minimized: false, frame: original, displays: [display],
+ movable: true, resizable: false, fullscreen: false) == .unsupported)
+ }
+
+ @Test("Missing role, minimized, frame, display and capability reads remain unknown")
+ func unknownStates() {
+ #expect(
+ WindowLayoutWindowRules.issue(
+ standard: nil, minimized: false, frame: original, displays: [display],
+ movable: true, resizable: true, fullscreen: false) == .unknownState)
+ #expect(
+ WindowLayoutWindowRules.issue(
+ standard: true, minimized: nil, frame: original, displays: [display],
+ movable: true, resizable: true, fullscreen: false) == .unknownState)
+ #expect(
+ WindowLayoutWindowRules.issue(
+ standard: true, minimized: false, frame: nil, displays: [display],
+ movable: true, resizable: true, fullscreen: false) == .unknownState)
+ #expect(
+ WindowLayoutWindowRules.issue(
+ standard: true, minimized: false, frame: original, displays: [],
+ movable: true, resizable: true, fullscreen: false) == .unknownState)
+ #expect(
+ WindowLayoutWindowRules.issue(
+ standard: true, minimized: false, frame: original, displays: [display],
+ movable: nil, resizable: true, fullscreen: false) == .unknownState)
+ #expect(
+ WindowLayoutWindowRules.issue(
+ standard: true, minimized: false, frame: original, displays: [display],
+ movable: true, resizable: nil, fullscreen: false) == .unknownState)
+ }
+}
diff --git a/SemperTests/WindowLayoutServiceTests.swift b/SemperTests/WindowLayoutServiceTests.swift
new file mode 100644
index 0000000..e96a945
--- /dev/null
+++ b/SemperTests/WindowLayoutServiceTests.swift
@@ -0,0 +1,530 @@
+import CoreGraphics
+import Foundation
+import Testing
+
+@testable import Semper
+
+private actor WindowLayoutTestGate {
+ private var entered = false
+ private var released = false
+ private var entryWaiters: [UUID: CheckedContinuation] = [:]
+ private var releaseWaiter: CheckedContinuation?
+
+ func hold() async {
+ entered = true
+ for waiter in entryWaiters.values { waiter.resume() }
+ entryWaiters = [:]
+ if !released { await withCheckedContinuation { releaseWaiter = $0 } }
+ }
+
+ func waitUntilEntered() async throws {
+ let id = UUID()
+ try await withTaskCancellationHandler { () async throws -> Void in
+ try Task.checkCancellation()
+ if entered { return }
+ if released { throw CancellationError() }
+ try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in
+ if Task.isCancelled {
+ continuation.resume(throwing: CancellationError())
+ } else {
+ entryWaiters[id] = continuation
+ }
+ }
+ } onCancel: {
+ Task { await self.cancelEntryWaiter(id) }
+ }
+ }
+
+ func release() {
+ released = true
+ for waiter in entryWaiters.values { waiter.resume(throwing: CancellationError()) }
+ entryWaiters = [:]
+ releaseWaiter?.resume()
+ releaseWaiter = nil
+ }
+
+ private func cancelEntryWaiter(_ id: UUID) {
+ entryWaiters.removeValue(forKey: id)?.resume(throwing: CancellationError())
+ }
+}
+
+private actor WindowLayoutTestBackend: WindowLayoutWindowBackend {
+ let application: WorkspaceApplication
+ let windowID: WorkspaceWindowID
+ var state: WorkspaceWindowSnapshot?
+ var screens: [WorkspaceDisplay]
+ var allowed = true
+ var permissionPrompts: [Bool] = []
+ var focusedApplications: [WorkspaceApplication] = []
+ var requestedFrames: [CGRect] = []
+ var applicationScanCount = 0
+ var shutdownCalls = 0
+ var displayReads = 0
+ var changedTopology: [WorkspaceDisplay]?
+ var focusGate: WindowLayoutTestGate?
+ var writeGate: WindowLayoutTestGate?
+ var forcedFrame: CGRect?
+ var frameBeforeWrite: CGRect?
+ var missingReadback = false
+ var failureAfterWrite: String?
+ var processExists = true
+
+ init(application: WorkspaceApplication, windowID: WorkspaceWindowID, frame: CGRect, screen: WorkspaceDisplay) {
+ self.application = application
+ self.windowID = windowID
+ state = .init(id: windowID, application: application, ordinal: 1, frame: frame, issue: nil)
+ screens = [screen]
+ }
+
+ func permission(prompt: Bool) -> Bool {
+ permissionPrompts.append(prompt)
+ return allowed
+ }
+
+ func applications() -> [WorkspaceApplication] {
+ applicationScanCount += 1
+ return processExists ? [application] : []
+ }
+
+ func displays() -> [WorkspaceDisplay] {
+ displayReads += 1
+ if displayReads > 1, let changedTopology { return changedTopology }
+ return screens
+ }
+
+ func windows(in applications: [WorkspaceApplication]) -> [WorkspaceWindowSnapshot] {
+ guard applications.contains(application), let state else { return [] }
+ return [state]
+ }
+
+ func focusedWindow(in application: WorkspaceApplication) async throws -> WorkspaceWindowSnapshot? {
+ focusedApplications.append(application)
+ if let focusGate { await focusGate.hold() }
+ try Task.checkCancellation()
+ return processExists && application == self.application ? state : nil
+ }
+
+ func current(_ id: WorkspaceWindowID) throws -> WorkspaceWindowSnapshot? {
+ guard allowed else { throw WorkspaceError.permission }
+ return processExists && id == windowID ? state : nil
+ }
+
+ func move(_ id: WorkspaceWindowID, to frame: CGRect, expected: CGRect) async throws -> WorkspaceMoveObservation {
+ try Task.checkCancellation()
+ if let frameBeforeWrite { setFrame(frameBeforeWrite) }
+ guard let state = try current(id), let before = state.frame else { throw WorkspaceError.missing }
+ guard before == expected, state.issue == nil else {
+ return .init(before: before, after: before, failure: "The window changed before writing.", writeAttempted: false)
+ }
+ requestedFrames.append(frame)
+ let after = forcedFrame ?? frame
+ setFrame(after)
+ if let writeGate { await writeGate.hold() }
+ return .init(before: before, after: missingReadback ? nil : after, failure: failureAfterWrite, writeAttempted: true)
+ }
+
+ func shutdown() {
+ shutdownCalls += 1
+ state = nil
+ }
+
+ func setPermission(_ value: Bool) { allowed = value }
+ func setMissingReadback(_ value: Bool) { missingReadback = value }
+ func setForcedFrame(_ value: CGRect?) { forcedFrame = value }
+ func setFailure(_ value: String?) { failureAfterWrite = value }
+ func setFrameBeforeWrite(_ value: CGRect?) { frameBeforeWrite = value }
+ func setFocusGate(_ gate: WindowLayoutTestGate) { focusGate = gate }
+ func setWriteGate(_ gate: WindowLayoutTestGate) { writeGate = gate }
+ func setChangedTopology(_ value: [WorkspaceDisplay]) { changedTopology = value }
+ func setScreens(_ value: [WorkspaceDisplay]) { screens = value }
+ func setProcessExists(_ value: Bool) { processExists = value }
+ func setState(_ value: WorkspaceWindowSnapshot?) { state = value }
+ func setFrame(_ frame: CGRect) {
+ guard let state else { return }
+ self.state = .init(
+ id: state.id, application: state.application, ordinal: state.ordinal, frame: frame, issue: state.issue)
+ }
+ func setIssue(_ issue: WorkspaceWindowIssue?) {
+ guard let state else { return }
+ self.state = .init(
+ id: state.id, application: state.application, ordinal: state.ordinal, frame: state.frame, issue: issue)
+ }
+}
+
+@Suite("Window Layout service", .serialized, .timeLimit(.minutes(1)))
+@MainActor
+struct WindowLayoutServiceTests {
+ let app = WorkspaceApplication(
+ pid: 432, bundleID: "test.layout", name: "Layout Test", launchDate: Date(timeIntervalSince1970: 42))
+ let screen = WorkspaceDisplay(
+ id: "layout-display", name: "Display", visibleFrame: CGRect(x: 0, y: 25, width: 1000, height: 700))
+ let original = CGRect(x: 100, y: 100, width: 400, height: 300)
+
+ private func fixture(
+ gate: MutationAdmissionGate? = nil,
+ target: (@MainActor () -> WorkspaceApplication?)? = nil
+ ) -> (WindowLayoutService, WindowLayoutTestBackend, MutationAdmissionGate) {
+ let gate = gate ?? MutationAdmissionGate()
+ let backend = WindowLayoutTestBackend(
+ application: app, windowID: .init(application: app, token: UUID()), frame: original, screen: screen)
+ let app = app
+ let service = WindowLayoutService(
+ backend: backend, mutationAdmission: gate, targetApplication: target ?? { app })
+ service.start()
+ return (service, backend, gate)
+ }
+
+ private func withHeldOperation(
+ gate: WindowLayoutTestGate,
+ operation: @escaping @MainActor () async throws -> Void,
+ body: @MainActor (Task) async throws -> Void
+ ) async throws {
+ let task = Task { try await operation() }
+ do {
+ try await withTaskCancellationHandler {
+ try await gate.waitUntilEntered()
+ try await body(task)
+ } onCancel: {
+ task.cancel()
+ Task { await gate.release() }
+ }
+ } catch {
+ task.cancel()
+ await gate.release()
+ _ = await task.result
+ throw error
+ }
+ task.cancel()
+ await gate.release()
+ _ = await task.result
+ }
+
+ @Test("Cancelling before gate arrival does not wait for an operation to arrive")
+ func cancellationBeforeGateArrival() async {
+ let gate = WindowLayoutTestGate()
+ let waiting = Task { try await gate.waitUntilEntered() }
+ waiting.cancel()
+ await #expect(throws: CancellationError.self) { try await waiting.value }
+ await gate.release()
+ }
+
+ @Test("Start and pause make no permission request, app scan or window write")
+ func noStartupPermission() async throws {
+ let (service, backend, _) = fixture()
+ #expect(service.permission == .notDetermined)
+ #expect(await backend.permissionPrompts.isEmpty)
+ #expect(await backend.applicationScanCount == 0)
+ await service.pause()
+ await #expect(throws: WindowLayoutError.self) { try await service.perform(.leftHalf) }
+ #expect(await backend.permissionPrompts.isEmpty)
+ #expect(await backend.requestedFrames.isEmpty)
+ #expect(!service.isRunning && !service.isBusy)
+ }
+
+ @Test("Only an explicit action prompts once, and denial does not write")
+ func deniedPermission() async {
+ let (service, backend, gate) = fixture()
+ await backend.setPermission(false)
+ await #expect(throws: WorkspaceError.self) { try await service.perform(.leftHalf) }
+ await #expect(throws: WorkspaceError.self) { try await service.perform(.center) }
+ #expect(service.permission == .denied)
+ #expect(await backend.permissionPrompts == [false, true, false])
+ #expect(await backend.requestedFrames.isEmpty)
+ #expect(gate.activeSharedPermitCount == 0)
+ }
+
+ @Test("Each action rechecks permission and reports later revocation")
+ func permissionRevocation() async throws {
+ let (service, backend, _) = fixture()
+ try await service.perform(.leftHalf)
+ await backend.setPermission(false)
+ await #expect(throws: WorkspaceError.self) { try await service.perform(.restore) }
+ #expect(service.permission == .revoked)
+ #expect(await backend.requestedFrames.count == 1)
+ }
+
+ @Test("Missing target asks for another app without prompting or scanning")
+ func noTarget() async {
+ let (service, backend, _) = fixture(target: { nil })
+ await #expect(throws: WindowLayoutError.self) { try await service.perform(.leftHalf) }
+ #expect(service.message?.contains("Select a window in another app") == true)
+ #expect(await backend.permissionPrompts.isEmpty)
+ #expect(await backend.applicationScanCount == 0)
+ }
+
+ @Test("Target identity is captured before asynchronous reads")
+ func capturesTargetBeforeAwait() async throws {
+ var target: WorkspaceApplication? = app
+ let (service, backend, _) = fixture(target: { target })
+ let gate = WindowLayoutTestGate()
+ await backend.setFocusGate(gate)
+ try await withHeldOperation(gate: gate, operation: { try await service.perform(.leftHalf) }) { task in
+ target = WorkspaceApplication(pid: 433, bundleID: "test.other", name: "Other", launchDate: Date())
+ await gate.release()
+ try await task.value
+ }
+ #expect(await backend.focusedApplications == [app])
+ #expect(await backend.applicationScanCount == 0)
+ }
+
+ @Test("Halves and maximize remain eligible for center and preceding-placement restore", arguments: [
+ WindowLayoutAction.leftHalf, .rightHalf, .maximize,
+ ])
+ func chainedLayouts(_ first: WindowLayoutAction) async throws {
+ let (service, backend, _) = fixture()
+ try await service.perform(first)
+ let firstFrame = try #require(await backend.state?.frame)
+ try await service.perform(.center)
+ let centered = try #require(await backend.state?.frame)
+ try await service.perform(.restore)
+ #expect(await backend.state?.frame == (centered == firstFrame ? original : firstFrame))
+ #expect(!service.canRestore)
+ #expect(!service.requiresPlacementReview)
+ }
+
+ @Test("Restore returns to only the immediately preceding verified placement")
+ func singleStepHistory() async throws {
+ let (service, backend, _) = fixture()
+ try await service.perform(.leftHalf)
+ let half = await backend.state?.frame
+ try await service.perform(.rightHalf)
+ try await service.perform(.restore)
+ #expect(await backend.state?.frame == half)
+ #expect(!service.canRestore)
+ await #expect(throws: WindowLayoutError.self) { try await service.perform(.restore) }
+ }
+
+ @Test("Unsupported, minimized and unknown windows are refused without writes", arguments: [
+ WorkspaceWindowIssue.unsupported, .minimized, .unknownState, .unavailable, .manualAdjustmentRequired,
+ ])
+ func unsupportedStates(_ issue: WorkspaceWindowIssue) async {
+ let (service, backend, _) = fixture()
+ await backend.setIssue(issue)
+ await #expect(throws: WindowLayoutError.self) { try await service.perform(.leftHalf) }
+ #expect(await backend.requestedFrames.isEmpty)
+ #expect(service.message != nil)
+ }
+
+ @Test("Focused-window failures keep their reason even without an identity", arguments: [
+ WorkspaceWindowIssue.timedOut, .ambiguousIdentity,
+ ])
+ func failureWithoutIdentity(_ issue: WorkspaceWindowIssue) async {
+ let (service, backend, _) = fixture()
+ await backend.setState(.init(id: nil, application: app, ordinal: 1, frame: nil, issue: issue))
+ await #expect(throws: WindowLayoutError.self) { try await service.perform(.leftHalf) }
+ #expect(service.message == issue.message)
+ #expect(await backend.requestedFrames.isEmpty)
+ }
+
+ @Test("Missing, replaced and relaunched windows are not restored", arguments: [0, 1, 2])
+ func staleIdentity(_ kind: Int) async throws {
+ let (service, backend, _) = fixture()
+ try await service.perform(.leftHalf)
+ let frame = await backend.state?.frame
+ if kind == 0 {
+ await backend.setState(nil)
+ } else if kind == 1 {
+ let replacement = WorkspaceWindowID(application: app, token: UUID())
+ await backend.setState(.init(id: replacement, application: app, ordinal: 1, frame: frame, issue: nil))
+ } else {
+ await backend.setProcessExists(false)
+ }
+ await #expect(throws: WindowLayoutError.self) { try await service.perform(.restore) }
+ #expect(await backend.requestedFrames.count == 1)
+ #expect(service.message?.contains("no longer available") == true)
+ }
+
+ @Test("Later external movement is preserved")
+ func externalMovement() async throws {
+ let (service, backend, _) = fixture()
+ try await service.perform(.leftHalf)
+ let external = CGRect(x: 222, y: 144, width: 333, height: 444)
+ await backend.setFrame(external)
+ await #expect(throws: WindowLayoutError.self) { try await service.perform(.restore) }
+ #expect(await backend.state?.frame == external)
+ #expect(await backend.requestedFrames.count == 1)
+ #expect(service.message?.contains("preserved") == true)
+ }
+
+ @Test("Backend expected-frame guard preserves a change immediately before writing")
+ func changedBeforeWrite() async {
+ let (service, backend, _) = fixture()
+ let external = CGRect(x: 250, y: 130, width: 500, height: 350)
+ await backend.setFrameBeforeWrite(external)
+ await #expect(throws: WindowLayoutError.self) { try await service.perform(.leftHalf) }
+ #expect(await backend.state?.frame == external)
+ #expect(await backend.requestedFrames.isEmpty)
+ #expect(!service.canRestore)
+ }
+
+ @Test("Topology changes before a write are refused")
+ func changedTopologyBeforeWrite() async {
+ let (service, backend, _) = fixture()
+ await backend.setChangedTopology([])
+ await #expect(throws: WindowLayoutError.self) { try await service.perform(.leftHalf) }
+ #expect(await backend.requestedFrames.isEmpty)
+ #expect(service.message?.contains("displays changed") == true)
+ }
+
+ @Test("Restore refuses a changed display arrangement")
+ func changedTopologyBeforeRestore() async throws {
+ let (service, backend, _) = fixture()
+ try await service.perform(.leftHalf)
+ await backend.setScreens([.init(id: screen.id, name: screen.name, visibleFrame: original)])
+ await #expect(throws: WindowLayoutError.self) { try await service.perform(.restore) }
+ #expect(await backend.requestedFrames.count == 1)
+ }
+
+ @Test("A previous placement outside usable displays is not restored")
+ func offscreenPreviousPlacement() async throws {
+ let (service, backend, _) = fixture()
+ await backend.setFrame(CGRect(x: -100, y: 100, width: 400, height: 300))
+ try await service.perform(.leftHalf)
+ await #expect(throws: WindowLayoutError.self) { try await service.perform(.restore) }
+ #expect(await backend.requestedFrames.count == 1)
+ #expect(service.message?.contains("does not fit") == true)
+ }
+
+ @Test("Constrained and partially failed writes retain the observed result for restore", arguments: [false, true])
+ func observedPartialChange(_ failed: Bool) async throws {
+ let (service, backend, _) = fixture()
+ await backend.setForcedFrame(CGRect(x: 0, y: 25, width: 600, height: 700))
+ if failed { await backend.setFailure("The app rejected its final size write.") }
+ await #expect(throws: WindowLayoutError.self) { try await service.perform(.leftHalf) }
+ #expect(service.canRestore)
+ await backend.setForcedFrame(nil)
+ await backend.setFailure(nil)
+ try await service.perform(.restore)
+ #expect(await backend.state?.frame == original)
+ #expect(!service.canRestore)
+ }
+
+ @Test("A constrained restore remains retryable against its observed partial result")
+ func constrainedRestore() async throws {
+ let (service, backend, _) = fixture()
+ try await service.perform(.leftHalf)
+ await backend.setForcedFrame(CGRect(x: 100, y: 100, width: 450, height: 300))
+ await #expect(throws: WindowLayoutError.self) { try await service.perform(.restore) }
+ #expect(service.canRestore)
+ await backend.setForcedFrame(nil)
+ try await service.perform(.restore)
+ #expect(await backend.state?.frame == original)
+ }
+
+ @Test("Missing readback requires acknowledgement and survives pause")
+ func unverifiedWriteReview() async throws {
+ let (service, backend, _) = fixture()
+ await backend.setMissingReadback(true)
+ await #expect(throws: WindowLayoutError.self) { try await service.perform(.leftHalf) }
+ #expect(service.requiresPlacementReview && !service.canRestore)
+ await #expect(throws: WindowLayoutError.self) { try await service.perform(.center) }
+ await service.pause()
+ service.keepCurrentPlacement()
+ #expect(service.requiresPlacementReview)
+ service.start()
+ service.keepCurrentPlacement()
+ #expect(!service.requiresPlacementReview && !service.canRestore)
+ await backend.setMissingReadback(false)
+ try await service.perform(.center)
+ #expect(await backend.requestedFrames.count == 2)
+ }
+
+ @Test("Pause preserves preceding placement and shutdown forgets it after draining")
+ func pauseAndRemoval() async throws {
+ let (service, backend, gate) = fixture()
+ try await service.perform(.leftHalf)
+ await service.pause()
+ #expect(!service.canRestore)
+ #expect(await backend.shutdownCalls == 0)
+ service.start()
+ #expect(service.canRestore)
+ try await service.perform(.restore)
+ try await service.perform(.rightHalf)
+ await service.shutdown()
+ #expect(!service.canRestore && !service.requiresPlacementReview && !service.isRunning)
+ #expect(await backend.shutdownCalls == 1)
+ #expect(gate.activeSharedPermitCount == 0)
+ }
+
+ @Test("Away and Workspace admission block layouts before permission work", arguments: [true, false])
+ func admissionBlocked(_ away: Bool) async throws {
+ let (service, backend, gate) = fixture()
+ let permit = try gate.acquire(owner: away ? .awayMode : .workspaceWindow, mode: away ? .exclusive : .shared)
+ await #expect(throws: MutationAdmissionError.self) { try await service.perform(.leftHalf) }
+ #expect(await backend.permissionPrompts.isEmpty)
+ #expect(await backend.requestedFrames.isEmpty)
+ #expect(!service.isBusy)
+ #expect(gate.release(permit))
+ try await service.perform(.leftHalf)
+ }
+
+ @Test("Cancellation before the write makes no change and releases admission")
+ func cancelBeforeWrite() async throws {
+ let (service, backend, gate) = fixture()
+ let focusGate = WindowLayoutTestGate()
+ await backend.setFocusGate(focusGate)
+ try await withHeldOperation(gate: focusGate, operation: { try await service.perform(.leftHalf) }) { task in
+ service.cancel()
+ await focusGate.release()
+ await #expect(throws: CancellationError.self) { try await task.value }
+ }
+ #expect(await backend.requestedFrames.isEmpty)
+ #expect(gate.activeSharedPermitCount == 0)
+ #expect(!service.isBusy)
+ }
+
+ @Test("Pause drains an in-flight write before releasing admission and preserves observed undo")
+ func drainAfterWrite() async throws {
+ let (service, backend, gate) = fixture()
+ let writeGate = WindowLayoutTestGate()
+ await backend.setWriteGate(writeGate)
+ try await withHeldOperation(gate: writeGate, operation: { try await service.perform(.leftHalf) }) { task in
+ #expect(gate.activeSharedPermitCount == 1)
+ #expect(throws: MutationAdmissionError.self) { try gate.acquire(owner: .workspaceWindow, mode: .shared) }
+ let lifecycle = try gate.acquire(owner: .manual, mode: .shared)
+ defer { gate.release(lifecycle) }
+ let release = Task { @MainActor in
+ #expect(!service.isRunning && service.isBusy)
+ #expect(gate.activeSharedPermitCount == 2)
+ await writeGate.release()
+ }
+ await service.pause()
+ await release.value
+ await #expect(throws: CancellationError.self) { try await task.value }
+ #expect(!service.isBusy)
+ #expect(gate.activeSharedPermitCount == 1)
+ }
+ #expect(gate.activeSharedPermitCount == 0)
+ service.start()
+ #expect(service.canRestore)
+ try await service.perform(.restore)
+ #expect(await backend.state?.frame == original)
+ }
+
+ @Test("Caller cancellation cancels the managed task and retains partial change")
+ func callerCancellation() async throws {
+ let (service, backend, gate) = fixture()
+ let writeGate = WindowLayoutTestGate()
+ await backend.setWriteGate(writeGate)
+ try await withHeldOperation(gate: writeGate, operation: { try await service.perform(.leftHalf) }) { task in
+ task.cancel()
+ await writeGate.release()
+ await #expect(throws: CancellationError.self) { try await task.value }
+ }
+ #expect(service.canRestore)
+ #expect(gate.activeSharedPermitCount == 0)
+ }
+
+ @Test("An unsupported frontmost app clears the previous target, while Semper preserves it")
+ func targetTracking() {
+ let tracker = WindowLayoutTargetTracker()
+ tracker.recordActivation(app, isSemper: false)
+ tracker.recordActivation(nil, isSemper: true)
+ #expect(tracker.lastApplication == app)
+ tracker.recordActivation(nil, isSemper: false)
+ #expect(tracker.lastApplication == nil)
+ tracker.recordActivation(app, isSemper: false)
+ tracker.stop()
+ #expect(tracker.lastApplication == nil)
+ }
+}
diff --git a/SemperTests/WorkspaceShortcutIsolationTests.swift b/SemperTests/WorkspaceShortcutIsolationTests.swift
index 856cefa..46eb916 100644
--- a/SemperTests/WorkspaceShortcutIsolationTests.swift
+++ b/SemperTests/WorkspaceShortcutIsolationTests.swift
@@ -8,6 +8,25 @@ import Testing
@MainActor
@Suite("Workspace shortcut isolation", .serialized)
struct WorkspaceShortcutIsolationTests {
+ @Test("Window shortcuts remain shell-owned when Sound clears its shortcuts", arguments: ShortcutAction.windowLayoutActions)
+ func soundDoesNotOwnWindowLayout(_ action: ShortcutAction) throws {
+ try withSynchronousSettings { settings in
+ let chord = KeyboardShortcuts.Shortcut(.l, modifiers: [.control, .option])
+ settings.appSettings.customShortcuts[action.rawValue] = ShortcutCodable.from(chord)
+ KeyboardShortcuts.setShortcut(chord, for: action.keyboardShortcutName)
+ KeyboardShortcuts.onKeyDown(for: action.keyboardShortcutName) {}
+ defer { KeyboardShortcuts.removeHandler(for: action.keyboardShortcutName) }
+ let sound = makeRegistry(settings)
+ defer { sound.stop() }
+ sound.start()
+ #expect(!sound.dispatch(action))
+ #expect(!action.supportsRepeat)
+ sound.clearAllShortcuts()
+ #expect(settings.appSettings.customShortcuts[action.rawValue] == ShortcutCodable.from(chord))
+ #expect(KeyboardShortcuts.isEnabled(for: action.keyboardShortcutName))
+ }
+ }
+
@Test("Sound registration and Clear All leave Workspace owned by the shell")
func soundDoesNotOwnWorkspace() throws {
try withSynchronousSettings { settings in
diff --git a/guide/window-layout.md b/guide/window-layout.md
new file mode 100644
index 0000000..da0f8bc
--- /dev/null
+++ b/guide/window-layout.md
@@ -0,0 +1,25 @@
+# Window Layout
+
+Window Layout arranges one standard app window at a time. Add it in Modules and open it from Home or the sidebar. Adding the module does not request Accessibility access or read windows. The first layout action requests access if needed.
+
+Select a window in another app, then invoke an action from Semper or an optional shortcut:
+
+| Action | Result |
+| --- | --- |
+| Left Half | Fills the left half of the current display's usable area. |
+| Right Half | Fills the right half of that area. |
+| Maximize | Fills the area available around the Dock and menu bar without entering full screen. |
+| Center | Centers the window without changing its size. Oversized windows are refused. |
+| Restore Previous Placement | Returns the last changed window to its immediately preceding placement. |
+
+Actions are searchable from Home and can be pinned there. Settings > Shortcuts provides optional bindings for all five actions. No shortcut is assigned by default. Sound's shortcut reset does not remove these bindings.
+
+When Semper is frontmost, Window Layout uses the last eligible app active while the module was running. If none is known, select another app and return to Semper. The module reads only that app's focused window and never substitutes a different window when the original is missing.
+
+Only standard, nonminimized windows whose windowed state and move/resize support can be checked are eligible. Full-screen windows and unknown states are refused. Window Layout has its own eligibility check for ordinary windows occupying a display's height; Workspace Restore keeps its existing conservative rule.
+
+Every change checks the resulting frame. If an app limits the requested size, the result says so and retains the observed change for restore. Restore skips windows moved since the previous action, missing windows, and changed display arrangements. If a write cannot be verified, check the window manually and confirm Keep Current Placement before another action.
+
+Cancel stops additional writes and waits for the latest operation to finish. Verified partial changes remain available to restore. Pause stops app observation and drains work while preserving the previous placement. Removing the module or quitting clears its window handles and previous-placement record. Window titles are not collected; only module and shortcut preferences persist.
+
+Window Layout and Workspace Restore cannot write window positions concurrently. Presentation keeps its own recovery ownership; later manual changes are preserved by that recovery. Away prevents window changes while its curtain is active.
diff --git a/scripts/test-direct-utilities.py b/scripts/test-direct-utilities.py
index b15a677..38bad1c 100644
--- a/scripts/test-direct-utilities.py
+++ b/scripts/test-direct-utilities.py
@@ -9,7 +9,9 @@
ROOT = pathlib.Path(__file__).resolve().parents[1]
MODULES = ("Workspace", "Shelf", "Storage")
-TEST_PREFIXES = ("Workspace", "Shelf", "SafeEject")
+TEST_PREFIXES = ("Workspace", "Shelf", "SafeEject", "WindowLayout", "MutationAdmissionGate")
+# Shell shortcut tests use the app's package dependencies and run through Xcode.
+APP_TESTS = {"WorkspaceShortcutIsolationTests.swift"}
with tempfile.TemporaryDirectory(prefix="semper-direct-utilities-") as directory:
@@ -19,6 +21,9 @@
sources.mkdir(parents=True)
tests.mkdir(parents=True)
(sources / "MutationAdmissionGate.swift").symlink_to(ROOT / "Semper/Utilities/MutationAdmissionGate.swift")
+ (sources / "ModuleRegistry.swift").symlink_to(ROOT / "Semper/Modules/ModuleRegistry.swift")
+ for name in ("WindowLayoutModels.swift", "WindowLayoutService.swift", "WindowLayoutTargetTracker.swift"):
+ (sources / name).symlink_to(ROOT / "Semper/WindowLayout" / name)
for module in MODULES:
source = ROOT / "Semper" / module
if not source.is_dir():
@@ -29,6 +34,8 @@
if not matches:
raise SystemExit(f"Missing tests: {prefix}")
for source in matches:
+ if source.name in APP_TESTS:
+ continue
(tests / source.name).symlink_to(source)
(package / "Package.swift").write_text(
"""// swift-tools-version: 6.0
From e1949e3c3a2a3ee8e9b29f5167b64b2c587fe17a Mon Sep 17 00:00:00 2001
From: Nihar <117209695+niharnm@users.noreply.github.com>
Date: Wed, 9 Sep 2026 09:22:40 -0700
Subject: [PATCH 2/7] Guard Window Layout topology and full-height boundaries
---
Semper/Modules/ModuleRegistry.swift | 2 +-
Semper/WindowLayout/WindowLayoutModels.swift | 40 +++----
Semper/WindowLayout/WindowLayoutService.swift | 27 +++--
Semper/WindowLayout/WindowLayoutView.swift | 2 +-
Semper/Workspace/WorkspaceWindowBackend.swift | 34 ++++--
SemperTests/UtilityLifecycleTests.swift | 2 +-
SemperTests/WindowLayoutGeometryTests.swift | 66 ++++++------
SemperTests/WindowLayoutServiceTests.swift | 101 +++++++++++++++++-
guide/window-layout.md | 4 +-
scripts/test-direct-utilities.py | 3 +-
10 files changed, 195 insertions(+), 86 deletions(-)
diff --git a/Semper/Modules/ModuleRegistry.swift b/Semper/Modules/ModuleRegistry.swift
index 6aecba4..9e59ceb 100644
--- a/Semper/Modules/ModuleRegistry.swift
+++ b/Semper/Modules/ModuleRegistry.swift
@@ -78,7 +78,7 @@ struct UtilityModuleDescriptor: Identifiable, Equatable, Sendable {
localDataPolicy:
"Window identity and the previous placement stay in memory. Pausing preserves them; removing the module or quitting clears them. No window titles are collected.",
conflicts: ["Finish active Workspace Restore work and end Away before arranging windows."],
- hardwareRequirements: ["Only standard windows with verified windowed state and move/resize support are supported."]
+ hardwareRequirements: ["Requires standard windows with readable geometry and move/resize support. Full-height windows and targets are conservatively refused."]
)),
.init(
id: .shelf, title: "File Shelf", summary: "Keep references to files close at hand.", symbolName: "tray.fill"
diff --git a/Semper/WindowLayout/WindowLayoutModels.swift b/Semper/WindowLayout/WindowLayoutModels.swift
index cd6e549..4ff3c9d 100644
--- a/Semper/WindowLayout/WindowLayoutModels.swift
+++ b/Semper/WindowLayout/WindowLayoutModels.swift
@@ -1,4 +1,3 @@
-import ApplicationServices
import CoreGraphics
import Foundation
@@ -34,12 +33,23 @@ enum WindowLayoutAction: String, CaseIterable, Identifiable, Sendable {
protocol WindowLayoutWindowBackend: WorkspaceWindowBackend {
func focusedWindow(in application: WorkspaceApplication) async throws -> WorkspaceWindowSnapshot?
+ func move(
+ _ id: WorkspaceWindowID, to frame: CGRect, expected: CGRect, expectedDisplays: [WorkspaceDisplay]
+ ) async throws -> WorkspaceMoveObservation
}
enum WindowLayoutGeometry {
+ static func topologyIdentity(_ displays: [WorkspaceDisplay]) -> [WorkspaceDisplay] {
+ displays.map {
+ WorkspaceDisplay(id: $0.id, name: "", visibleFrame: $0.visibleFrame, fullScreenFrame: $0.fullScreenFrame)
+ }.sorted { $0.id < $1.id }
+ }
+
static func target(_ action: WindowLayoutAction, frame: CGRect, display: WorkspaceDisplay) -> CGRect? {
let bounds = display.visibleFrame
- guard WorkspaceGeometry.valid(frame), WorkspaceGeometry.valid(bounds) else { return nil }
+ guard WorkspaceGeometry.valid(frame), WorkspaceGeometry.valid(bounds),
+ let fullBounds = display.fullScreenFrame, WorkspaceGeometry.valid(fullBounds), fullBounds.contains(bounds)
+ else { return nil }
let target: CGRect
switch action {
case .leftHalf:
@@ -57,34 +67,26 @@ enum WindowLayoutGeometry {
case .restore:
return nil
}
- return WorkspaceGeometry.valid(target) && bounds.contains(target) ? target : nil
+ return WorkspaceGeometry.valid(target) && bounds.contains(target)
+ && !WorkspaceGeometry.excludedByDisplayBounds(target, on: [display]) ? target : nil
}
}
enum WindowLayoutWindowRules {
- static func fullscreenState(buttonSubrole: String?) -> Bool? {
- switch buttonSubrole {
- case kAXFullScreenButtonSubrole: false
- case kAXZoomButtonSubrole: true
- default: nil
- }
- }
-
static func issue(
standard: Bool?, minimized: Bool?, frame: CGRect?, displays: [WorkspaceDisplay],
- movable: Bool?, resizable: Bool?, fullscreen: Bool?
+ movable: Bool?, resizable: Bool?
) -> WorkspaceWindowIssue? {
- // A verified windowed layout can span the display height; Restore keeps its geometry exclusion.
- let layoutDisplays = displays.map {
- WorkspaceDisplay(id: $0.id, name: $0.name, visibleFrame: $0.visibleFrame)
- }
if let issue = WorkspaceWindowRules.issue(
- standard: standard, minimized: minimized, frame: frame, displays: layoutDisplays,
+ standard: standard, minimized: minimized, frame: frame, displays: displays,
movable: movable, resizable: resizable)
{
return issue
}
- guard let fullscreen else { return .unknownState }
- return fullscreen ? .unsupported : nil
+ guard displays.allSatisfy({ display in
+ guard let fullBounds = display.fullScreenFrame else { return false }
+ return WorkspaceGeometry.valid(fullBounds) && fullBounds.contains(display.visibleFrame)
+ }) else { return .unknownState }
+ return nil
}
}
diff --git a/Semper/WindowLayout/WindowLayoutService.swift b/Semper/WindowLayout/WindowLayoutService.swift
index 0b134f0..fc713fe 100644
--- a/Semper/WindowLayout/WindowLayoutService.swift
+++ b/Semper/WindowLayout/WindowLayoutService.swift
@@ -17,13 +17,14 @@ enum WindowLayoutError: LocalizedError {
case .missingWindow: "The original app or window is no longer available. No replacement window was chosen."
case .changedWindow: "The window changed since the last action. Its later placement was preserved."
case .changedDisplays: "The displays changed. Check the window and try a new layout action."
- case .invalidPlacement: "This placement does not fit an available display's usable area."
+ case .invalidPlacement:
+ "This placement does not fit the usable display area or reaches the display's full height. Try Center with a smaller window."
case .unsupported(let issue):
switch issue {
case .unsupported:
- "This window does not support layout actions. Fullscreen and nonstandard windows are excluded."
+ "This must be a standard window that allows moving and resizing."
case .unknownState:
- "The app did not provide a verifiable window state, including whether it is fullscreen."
+ "The app did not provide readable window geometry or move and resize capabilities."
case .minimized: "Unminimize the window in its app before arranging it."
default: issue.message
}
@@ -212,7 +213,7 @@ final class WindowLayoutService {
}
private func arrange(_ action: WindowLayoutAction, application: WorkspaceApplication) async throws {
- let displays = topologyIdentity(await backend.displays())
+ let displays = WindowLayoutGeometry.topologyIdentity(await backend.displays())
guard let snapshot = try await backend.focusedWindow(in: application),
snapshot.application == application
else { throw WindowLayoutError.missingWindow }
@@ -227,13 +228,16 @@ final class WindowLayoutService {
current.application == application
else { throw WindowLayoutError.missingWindow }
guard try supportedFrame(current) == frame else { throw WindowLayoutError.changedWindow }
- guard topologyIdentity(await backend.displays()) == displays else { throw WindowLayoutError.changedDisplays }
+ guard WindowLayoutGeometry.topologyIdentity(await backend.displays()) == displays else {
+ throw WindowLayoutError.changedDisplays
+ }
try Task.checkCancellation()
if WorkspaceGeometry.approximatelyEqual(frame, target) {
message = "The window is already in this placement."
return
}
- let observation = try await backend.move(windowID, to: target, expected: frame)
+ let observation = try await backend.move(
+ windowID, to: target, expected: frame, expectedDisplays: displays)
try record(observation, windowID: windowID, target: target, displays: displays, restoring: nil)
message = "\(action.title) applied and verified. Restore returns to the preceding placement."
}
@@ -244,13 +248,14 @@ final class WindowLayoutService {
else { throw WindowLayoutError.missingWindow }
let frame = try supportedFrame(current)
guard frame == previous.after else { throw WindowLayoutError.changedWindow }
- let displays = topologyIdentity(await backend.displays())
+ let displays = WindowLayoutGeometry.topologyIdentity(await backend.displays())
guard displays == previous.displays else { throw WindowLayoutError.changedDisplays }
guard displays.contains(where: { $0.visibleFrame.contains(previous.before) }) else {
throw WindowLayoutError.invalidPlacement
}
try Task.checkCancellation()
- let observation = try await backend.move(previous.windowID, to: previous.before, expected: previous.after)
+ let observation = try await backend.move(
+ previous.windowID, to: previous.before, expected: previous.after, expectedDisplays: displays)
try record(
observation, windowID: previous.windowID, target: previous.before,
displays: displays, restoring: previous)
@@ -293,10 +298,4 @@ final class WindowLayoutService {
throw WindowLayoutError.constrained
}
}
-
- private func topologyIdentity(_ displays: [WorkspaceDisplay]) -> [WorkspaceDisplay] {
- displays.map {
- WorkspaceDisplay(id: $0.id, name: "", visibleFrame: $0.visibleFrame, fullScreenFrame: $0.fullScreenFrame)
- }.sorted { $0.id < $1.id }
- }
}
diff --git a/Semper/WindowLayout/WindowLayoutView.swift b/Semper/WindowLayout/WindowLayoutView.swift
index b0af61f..6c0a698 100644
--- a/Semper/WindowLayout/WindowLayoutView.swift
+++ b/Semper/WindowLayout/WindowLayoutView.swift
@@ -35,7 +35,7 @@ struct WindowLayoutView: View {
})
Text("Halves and Maximize use the display area available around the Dock and menu bar. Center keeps the current size. Restore returns the last changed window to its immediately preceding placement and skips later manual changes.")
.font(.caption).foregroundStyle(.secondary)
- Text("Full-screen, minimized, unsupported, and unverified windows stay unchanged. Choose another app and return here if no target is available. You can assign optional shortcuts in Settings.")
+ Text("Full-height windows and targets are conservatively refused. This can limit halves and Maximize when the menu bar and Dock auto-hide. Minimized, unsupported, and unreadable windows also stay unchanged. You can assign optional shortcuts in Settings.")
.font(.caption).foregroundStyle(.secondary)
Text("Pausing retains the previous placement. Removing Window Layout or quitting clears that session history.")
.font(.caption).foregroundStyle(.secondary)
diff --git a/Semper/Workspace/WorkspaceWindowBackend.swift b/Semper/Workspace/WorkspaceWindowBackend.swift
index 77c3111..506bc43 100644
--- a/Semper/Workspace/WorkspaceWindowBackend.swift
+++ b/Semper/Workspace/WorkspaceWindowBackend.swift
@@ -184,9 +184,29 @@ actor AccessibilityWorkspaceBackend: WindowLayoutWindowBackend {
}
func move(_ id: WorkspaceWindowID, to frame: CGRect, expected: CGRect) async throws -> WorkspaceMoveObservation {
+ try await moveWindow(id, to: frame, expected: expected, expectedDisplays: nil)
+ }
+
+ func move(
+ _ id: WorkspaceWindowID, to frame: CGRect, expected: CGRect, expectedDisplays: [WorkspaceDisplay]
+ ) async throws -> WorkspaceMoveObservation {
+ try await moveWindow(id, to: frame, expected: expected, expectedDisplays: expectedDisplays)
+ }
+
+ private func moveWindow(
+ _ id: WorkspaceWindowID, to frame: CGRect, expected: CGRect, expectedDisplays: [WorkspaceDisplay]?
+ ) async throws -> WorkspaceMoveObservation {
guard WorkspaceGeometry.valid(frame), let state = try await current(id), let before = state.frame,
let handle = handles[id]
else { throw WorkspaceError.missing }
+ if let expectedDisplays,
+ WindowLayoutGeometry.topologyIdentity(currentDisplays) != WindowLayoutGeometry.topologyIdentity(expectedDisplays)
+ {
+ return .init(
+ before: before, after: before,
+ failure: "The displays changed before the window layout was applied. Check the window and try again.",
+ writeAttempted: false)
+ }
guard state.issue == nil else {
return .init(before: before, after: before, failure: state.issue?.message, writeAttempted: false)
}
@@ -251,11 +271,11 @@ actor AccessibilityWorkspaceBackend: WindowLayoutWindowBackend {
private func snapshot(_ id: WorkspaceWindowID, deadline: ContinuousClock.Instant) throws -> WorkspaceWindowSnapshot
{
guard let handle = handles[id] else { throw WorkspaceError.missing }
- func value(_ name: String, from element: AXUIElement? = nil) throws -> CFTypeRef? {
+ func value(_ name: String) throws -> CFTypeRef? {
try Task.checkCancellation()
guard ContinuousClock.now < deadline else { throw WorkspaceWindowReadError.timeout }
var value: CFTypeRef?
- let result = AXUIElementCopyAttributeValue(element ?? handle.element, name as CFString, &value)
+ let result = AXUIElementCopyAttributeValue(handle.element, name as CFString, &value)
if result == .invalidUIElement { throw WorkspaceError.missing }
if result == .cannotComplete && handle.policy == .windowLayout { throw WorkspaceWindowReadError.timeout }
return result == .success ? value : nil
@@ -277,19 +297,11 @@ actor AccessibilityWorkspaceBackend: WindowLayoutWindowBackend {
movable: moveResult == .success ? movable.boolValue : nil,
resizable: sizeResult == .success ? resizable.boolValue : nil)
case .windowLayout:
- var fullscreen: Bool?
- if let button = try value(kAXFullScreenButtonAttribute), CFGetTypeID(button) == AXUIElementGetTypeID() {
- let buttonElement = button as! AXUIElement
- AXUIElementSetMessagingTimeout(buttonElement, messageTimeout)
- fullscreen = WindowLayoutWindowRules.fullscreenState(
- buttonSubrole: try value(kAXSubroleAttribute, from: buttonElement) as? String)
- }
issue = WindowLayoutWindowRules.issue(
standard: role.map { $0 == kAXStandardWindowSubrole }, minimized: minimized, frame: frame,
displays: currentDisplays,
movable: moveResult == .success ? movable.boolValue : nil,
- resizable: sizeResult == .success ? resizable.boolValue : nil,
- fullscreen: fullscreen)
+ resizable: sizeResult == .success ? resizable.boolValue : nil)
}
return .init(id: id, application: handle.application, ordinal: handle.ordinal, frame: frame, issue: issue)
} catch is CancellationError { throw CancellationError() } catch {
diff --git a/SemperTests/UtilityLifecycleTests.swift b/SemperTests/UtilityLifecycleTests.swift
index abb9c81..fc1fa1a 100644
--- a/SemperTests/UtilityLifecycleTests.swift
+++ b/SemperTests/UtilityLifecycleTests.swift
@@ -499,7 +499,7 @@ struct UtilityLifecycleTests {
await first.value
await second.value
await lifecycle.shutdown()
- #expect(order == [.away, .presentation, .scenes, .workspace, .shelf, .storage, .displays, .sound, .awake])
+ #expect(order == [.away, .presentation, .scenes, .windowLayout, .workspace, .shelf, .storage, .displays, .sound, .awake])
await #expect(throws: UtilityLifecycleError.self) { try await lifecycle.start(.awake) }
await #expect(throws: UtilityLifecycleError.self) { try await lifecycle.pause(.awake) }
await #expect(throws: UtilityLifecycleError.self) { try await lifecycle.remove(.awake) }
diff --git a/SemperTests/WindowLayoutGeometryTests.swift b/SemperTests/WindowLayoutGeometryTests.swift
index a0b89dd..5e4dca3 100644
--- a/SemperTests/WindowLayoutGeometryTests.swift
+++ b/SemperTests/WindowLayoutGeometryTests.swift
@@ -1,4 +1,3 @@
-import ApplicationServices
import CoreGraphics
import Testing
@@ -26,7 +25,8 @@ struct WindowLayoutGeometryTests {
@Test("Fractional display origins remain inside usable bounds")
func fractionalBounds() throws {
let display = WorkspaceDisplay(
- id: "fractional", name: "Display", visibleFrame: CGRect(x: -999.75, y: 24.25, width: 999.5, height: 675.5))
+ id: "fractional", name: "Display", visibleFrame: CGRect(x: -999.75, y: 24.25, width: 999.5, height: 675.5),
+ fullScreenFrame: CGRect(x: -999.75, y: 0, width: 999.5, height: 750))
for action in [WindowLayoutAction.leftHalf, .rightHalf, .maximize, .center] {
let target = try #require(WindowLayoutGeometry.target(action, frame: original, display: display))
#expect(display.visibleFrame.contains(target))
@@ -46,7 +46,8 @@ struct WindowLayoutGeometryTests {
@Test("Center preserves window dimensions on a display left of the primary display")
func center() throws {
let display = WorkspaceDisplay(
- id: "left", name: "Display", visibleFrame: CGRect(x: -1500, y: -200, width: 1200, height: 900))
+ id: "left", name: "Display", visibleFrame: CGRect(x: -1500, y: -200, width: 1200, height: 900),
+ fullScreenFrame: CGRect(x: -1500, y: -225, width: 1200, height: 1000))
let centered = try #require(WindowLayoutGeometry.target(.center, frame: original, display: display))
#expect(centered.size == original.size)
#expect(centered.midX == display.visibleFrame.midX)
@@ -88,18 +89,9 @@ struct WindowLayoutGeometryTests {
#expect(WindowLayoutGeometry.target(.restore, frame: original, display: display) == nil)
}
- @Test("Only recognized fullscreen button subroles establish fullscreen state")
- func fullscreenButtonState() {
- #expect(WindowLayoutWindowRules.fullscreenState(buttonSubrole: kAXFullScreenButtonSubrole) == false)
- #expect(WindowLayoutWindowRules.fullscreenState(buttonSubrole: kAXZoomButtonSubrole) == true)
- #expect(WindowLayoutWindowRules.fullscreenState(buttonSubrole: nil) == nil)
- #expect(WindowLayoutWindowRules.fullscreenState(buttonSubrole: kAXCloseButtonSubrole) == nil)
- #expect(WindowLayoutWindowRules.fullscreenState(buttonSubrole: "vendor-button") == nil)
- }
-
- @Test("Verified windowed full-height layouts remain eligible for center and restoration")
+ @Test("Usable-area layouts remain eligible while full-height targets are conservatively refused")
func repeatedLayoutsRetainEligibility() throws {
- let display = WorkspaceDisplay(
+ let autoHideDisplay = WorkspaceDisplay(
id: "auto-hide", name: "Display", visibleFrame: CGRect(x: 0, y: 0, width: 1000, height: 700),
fullScreenFrame: CGRect(x: 0, y: 0, width: 1000, height: 700))
for action in [WindowLayoutAction.leftHalf, .rightHalf, .maximize] {
@@ -109,27 +101,25 @@ struct WindowLayoutGeometryTests {
#expect(
WindowLayoutWindowRules.issue(
standard: true, minimized: false, frame: frame, displays: [display],
- movable: true, resizable: true, fullscreen: false) == nil)
+ movable: true, resizable: true) == nil)
}
#expect(
WorkspaceWindowRules.issue(
standard: true, minimized: false, frame: arranged, displays: [display],
- movable: true, resizable: true) == .manualAdjustmentRequired)
+ movable: true, resizable: true) == nil)
+ #expect(WindowLayoutGeometry.target(action, frame: original, display: autoHideDisplay) == nil)
}
- #expect(display.fullScreenFrame == display.visibleFrame)
+ #expect(WindowLayoutGeometry.target(.center, frame: original, display: autoHideDisplay) != nil)
}
- @Test("True and unknown fullscreen states are refused for ordinary and full-height frames")
- func refusedFullscreen() {
- for frame in [original, display.visibleFrame] {
- #expect(
- WindowLayoutWindowRules.issue(
- standard: true, minimized: false, frame: frame, displays: [display],
- movable: true, resizable: true, fullscreen: true) == .unsupported)
+ @Test("Full-height windows retain the existing conservative exclusion regardless of width")
+ func refusedFullHeight() throws {
+ let fullBounds = try #require(display.fullScreenFrame)
+ for frame in [fullBounds, CGRect(x: 0, y: 0, width: 400, height: fullBounds.height)] {
#expect(
WindowLayoutWindowRules.issue(
standard: true, minimized: false, frame: frame, displays: [display],
- movable: true, resizable: true, fullscreen: nil) == .unknownState)
+ movable: true, resizable: true) == .manualAdjustmentRequired)
}
}
@@ -138,19 +128,19 @@ struct WindowLayoutGeometryTests {
#expect(
WindowLayoutWindowRules.issue(
standard: false, minimized: false, frame: original, displays: [display],
- movable: true, resizable: true, fullscreen: false) == .unsupported)
+ movable: true, resizable: true) == .unsupported)
#expect(
WindowLayoutWindowRules.issue(
standard: true, minimized: true, frame: original, displays: [display],
- movable: true, resizable: true, fullscreen: false) == .minimized)
+ movable: true, resizable: true) == .minimized)
#expect(
WindowLayoutWindowRules.issue(
standard: true, minimized: false, frame: original, displays: [display],
- movable: false, resizable: true, fullscreen: false) == .unsupported)
+ movable: false, resizable: true) == .unsupported)
#expect(
WindowLayoutWindowRules.issue(
standard: true, minimized: false, frame: original, displays: [display],
- movable: true, resizable: false, fullscreen: false) == .unsupported)
+ movable: true, resizable: false) == .unsupported)
}
@Test("Missing role, minimized, frame, display and capability reads remain unknown")
@@ -158,26 +148,32 @@ struct WindowLayoutGeometryTests {
#expect(
WindowLayoutWindowRules.issue(
standard: nil, minimized: false, frame: original, displays: [display],
- movable: true, resizable: true, fullscreen: false) == .unknownState)
+ movable: true, resizable: true) == .unknownState)
#expect(
WindowLayoutWindowRules.issue(
standard: true, minimized: nil, frame: original, displays: [display],
- movable: true, resizable: true, fullscreen: false) == .unknownState)
+ movable: true, resizable: true) == .unknownState)
#expect(
WindowLayoutWindowRules.issue(
standard: true, minimized: false, frame: nil, displays: [display],
- movable: true, resizable: true, fullscreen: false) == .unknownState)
+ movable: true, resizable: true) == .unknownState)
#expect(
WindowLayoutWindowRules.issue(
standard: true, minimized: false, frame: original, displays: [],
- movable: true, resizable: true, fullscreen: false) == .unknownState)
+ movable: true, resizable: true) == .unknownState)
#expect(
WindowLayoutWindowRules.issue(
standard: true, minimized: false, frame: original, displays: [display],
- movable: nil, resizable: true, fullscreen: false) == .unknownState)
+ movable: nil, resizable: true) == .unknownState)
#expect(
WindowLayoutWindowRules.issue(
standard: true, minimized: false, frame: original, displays: [display],
- movable: true, resizable: nil, fullscreen: false) == .unknownState)
+ movable: true, resizable: nil) == .unknownState)
+ let unknownDisplay = WorkspaceDisplay(id: "unknown", name: "Display", visibleFrame: display.visibleFrame)
+ #expect(
+ WindowLayoutWindowRules.issue(
+ standard: true, minimized: false, frame: original, displays: [unknownDisplay],
+ movable: true, resizable: true) == .unknownState)
+ #expect(WindowLayoutGeometry.target(.leftHalf, frame: original, display: unknownDisplay) == nil)
}
}
diff --git a/SemperTests/WindowLayoutServiceTests.swift b/SemperTests/WindowLayoutServiceTests.swift
index e96a945..6db30ef 100644
--- a/SemperTests/WindowLayoutServiceTests.swift
+++ b/SemperTests/WindowLayoutServiceTests.swift
@@ -57,11 +57,13 @@ private actor WindowLayoutTestBackend: WindowLayoutWindowBackend {
var permissionPrompts: [Bool] = []
var focusedApplications: [WorkspaceApplication] = []
var requestedFrames: [CGRect] = []
+ var expectedDisplaySnapshots: [[WorkspaceDisplay]] = []
var applicationScanCount = 0
var shutdownCalls = 0
var displayReads = 0
var changedTopology: [WorkspaceDisplay]?
var focusGate: WindowLayoutTestGate?
+ var beforeWriteGate: WindowLayoutTestGate?
var writeGate: WindowLayoutTestGate?
var forcedFrame: CGRect?
var frameBeforeWrite: CGRect?
@@ -123,6 +125,24 @@ private actor WindowLayoutTestBackend: WindowLayoutWindowBackend {
return .init(before: before, after: missingReadback ? nil : after, failure: failureAfterWrite, writeAttempted: true)
}
+ func move(
+ _ id: WorkspaceWindowID, to frame: CGRect, expected: CGRect, expectedDisplays: [WorkspaceDisplay]
+ ) async throws -> WorkspaceMoveObservation {
+ expectedDisplaySnapshots.append(expectedDisplays)
+ if let beforeWriteGate { await beforeWriteGate.hold() }
+ try Task.checkCancellation()
+ guard let state = try current(id), let before = state.frame else { throw WorkspaceError.missing }
+ guard WindowLayoutGeometry.topologyIdentity(displays())
+ == WindowLayoutGeometry.topologyIdentity(expectedDisplays)
+ else {
+ return .init(
+ before: before, after: before,
+ failure: "The displays changed before the window layout was applied. Check the window and try again.",
+ writeAttempted: false)
+ }
+ return try await move(id, to: frame, expected: expected)
+ }
+
func shutdown() {
shutdownCalls += 1
state = nil
@@ -134,6 +154,7 @@ private actor WindowLayoutTestBackend: WindowLayoutWindowBackend {
func setFailure(_ value: String?) { failureAfterWrite = value }
func setFrameBeforeWrite(_ value: CGRect?) { frameBeforeWrite = value }
func setFocusGate(_ gate: WindowLayoutTestGate) { focusGate = gate }
+ func setBeforeWriteGate(_ gate: WindowLayoutTestGate) { beforeWriteGate = gate }
func setWriteGate(_ gate: WindowLayoutTestGate) { writeGate = gate }
func setChangedTopology(_ value: [WorkspaceDisplay]) { changedTopology = value }
func setScreens(_ value: [WorkspaceDisplay]) { screens = value }
@@ -157,7 +178,8 @@ struct WindowLayoutServiceTests {
let app = WorkspaceApplication(
pid: 432, bundleID: "test.layout", name: "Layout Test", launchDate: Date(timeIntervalSince1970: 42))
let screen = WorkspaceDisplay(
- id: "layout-display", name: "Display", visibleFrame: CGRect(x: 0, y: 25, width: 1000, height: 700))
+ id: "layout-display", name: "Display", visibleFrame: CGRect(x: 0, y: 25, width: 1000, height: 700),
+ fullScreenFrame: CGRect(x: 0, y: 0, width: 1000, height: 800))
let original = CGRect(x: 100, y: 100, width: 400, height: 300)
private func fixture(
@@ -375,6 +397,83 @@ struct WindowLayoutServiceTests {
#expect(await backend.requestedFrames.count == 1)
}
+ @Test("Display changes during the backend's final suspension prevent layout and restore writes",
+ arguments: [false, true], [0, 1, 2])
+ func changedTopologyAtWriteBoundary(_ restoring: Bool, _ changedField: Int) async throws {
+ let (service, backend, _) = fixture()
+ if restoring { try await service.perform(.leftHalf) }
+ let unchangedFrame = try #require(await backend.state?.frame)
+ let initialWriteCount = await backend.requestedFrames.count
+ let beforeWrite = WindowLayoutTestGate()
+ await backend.setBeforeWriteGate(beforeWrite)
+ let action: WindowLayoutAction = restoring ? .restore : .leftHalf
+ try await withHeldOperation(gate: beforeWrite, operation: { try await service.perform(action) }) { task in
+ let changed = WorkspaceDisplay(
+ id: changedField == 0 ? "replaced-display" : screen.id, name: screen.name,
+ visibleFrame: changedField == 1
+ ? CGRect(x: 40, y: 25, width: 960, height: 700) : screen.visibleFrame,
+ fullScreenFrame: changedField == 2
+ ? CGRect(x: 0, y: 0, width: 1000, height: 850) : screen.fullScreenFrame)
+ await backend.setScreens([changed])
+ #expect(await backend.state?.frame == unchangedFrame)
+ await beforeWrite.release()
+ await #expect(throws: WindowLayoutError.self) { try await task.value }
+ }
+ #expect(await backend.requestedFrames.count == initialWriteCount)
+ #expect(await backend.state?.frame == unchangedFrame)
+ #expect(await backend.expectedDisplaySnapshots.last == WindowLayoutGeometry.topologyIdentity([screen]))
+ #expect(service.message?.contains("displays changed before") == true)
+ #expect(service.canRestore == restoring)
+ }
+
+ @Test("Display names and enumeration order may change without blocking layout or restore",
+ arguments: [false, true])
+ func displayNamesAndOrderAtWriteBoundary(_ restoring: Bool) async throws {
+ let (service, backend, _) = fixture()
+ let other = WorkspaceDisplay(
+ id: "other-display", name: "Other", visibleFrame: CGRect(x: 1000, y: 25, width: 1000, height: 700),
+ fullScreenFrame: CGRect(x: 1000, y: 0, width: 1000, height: 800))
+ await backend.setScreens([screen, other])
+ if restoring { try await service.perform(.leftHalf) }
+ let beforeWrite = WindowLayoutTestGate()
+ await backend.setBeforeWriteGate(beforeWrite)
+ let action: WindowLayoutAction = restoring ? .restore : .leftHalf
+ try await withHeldOperation(gate: beforeWrite, operation: { try await service.perform(action) }) { task in
+ await backend.setScreens([
+ .init(id: other.id, name: "Renamed other", visibleFrame: other.visibleFrame,
+ fullScreenFrame: other.fullScreenFrame),
+ .init(id: screen.id, name: "Renamed display", visibleFrame: screen.visibleFrame,
+ fullScreenFrame: screen.fullScreenFrame),
+ ])
+ await beforeWrite.release()
+ try await task.value
+ }
+ let halfFrame = try #require(WindowLayoutGeometry.target(.leftHalf, frame: original, display: screen))
+ let expectedFrame = restoring ? original : halfFrame
+ #expect(await backend.state?.frame == expectedFrame)
+ #expect(await backend.requestedFrames.count == (restoring ? 2 : 1))
+ #expect(await backend.expectedDisplaySnapshots.last == WindowLayoutGeometry.topologyIdentity([screen, other]))
+ }
+
+ @Test("Auto-hidden system bars refuse full-height targets while a smaller window can still center",
+ arguments: [WindowLayoutAction.leftHalf, .rightHalf, .maximize])
+ func autoHiddenBarsConservativeLimit(_ action: WindowLayoutAction) async throws {
+ let (service, backend, _) = fixture()
+ let fullFrame = CGRect(x: 0, y: 0, width: 1000, height: 800)
+ await backend.setScreens([
+ .init(id: screen.id, name: screen.name, visibleFrame: fullFrame, fullScreenFrame: fullFrame),
+ ])
+ await #expect(throws: WindowLayoutError.self) { try await service.perform(action) }
+ #expect(await backend.requestedFrames.isEmpty)
+ #expect(await backend.state?.frame == original)
+ #expect(service.message == WindowLayoutError.invalidPlacement.localizedDescription)
+ try await service.perform(.center)
+ #expect(await backend.requestedFrames.count == 1)
+ #expect(await backend.state?.frame == CGRect(x: 300, y: 250, width: 400, height: 300))
+ try await service.perform(.restore)
+ #expect(await backend.state?.frame == original)
+ }
+
@Test("A previous placement outside usable displays is not restored")
func offscreenPreviousPlacement() async throws {
let (service, backend, _) = fixture()
diff --git a/guide/window-layout.md b/guide/window-layout.md
index da0f8bc..6779247 100644
--- a/guide/window-layout.md
+++ b/guide/window-layout.md
@@ -16,9 +16,9 @@ Actions are searchable from Home and can be pinned there. Settings > Shortcuts p
When Semper is frontmost, Window Layout uses the last eligible app active while the module was running. If none is known, select another app and return to Semper. The module reads only that app's focused window and never substitutes a different window when the original is missing.
-Only standard, nonminimized windows whose windowed state and move/resize support can be checked are eligible. Full-screen windows and unknown states are refused. Window Layout has its own eligibility check for ordinary windows occupying a display's height; Workspace Restore keeps its existing conservative rule.
+Only standard, nonminimized windows with readable geometry and move/resize support are eligible. Window Layout retains Workspace Restore's conservative full-height exclusion. It does not infer fullscreen state from a button role or use an undocumented fullscreen attribute. Full-height windows are refused even when they are ordinary windows. Targets that would enter the excluded area are also refused, so halves and Maximize can be unavailable when the menu bar and Dock both auto-hide; Center remains available for smaller windows. Missing display bounds are refused.
-Every change checks the resulting frame. If an app limits the requested size, the result says so and retains the observed change for restore. Restore skips windows moved since the previous action, missing windows, and changed display arrangements. If a write cannot be verified, check the window manually and confirm Keep Current Placement before another action.
+Every change checks the resulting frame. The backend checks the expected display arrangement again after its final asynchronous window refresh and before writing. If an app limits the requested size, the result says so and retains the observed change for restore. Restore skips windows moved since the previous action, missing windows, and changed display arrangements. If a write cannot be verified, check the window manually and confirm Keep Current Placement before another action.
Cancel stops additional writes and waits for the latest operation to finish. Verified partial changes remain available to restore. Pause stops app observation and drains work while preserving the previous placement. Removing the module or quitting clears its window handles and previous-placement record. Window titles are not collected; only module and shortcut preferences persist.
diff --git a/scripts/test-direct-utilities.py b/scripts/test-direct-utilities.py
index 38bad1c..48c392e 100644
--- a/scripts/test-direct-utilities.py
+++ b/scripts/test-direct-utilities.py
@@ -9,7 +9,7 @@
ROOT = pathlib.Path(__file__).resolve().parents[1]
MODULES = ("Workspace", "Shelf", "Storage")
-TEST_PREFIXES = ("Workspace", "Shelf", "SafeEject", "WindowLayout", "MutationAdmissionGate")
+TEST_PREFIXES = ("Workspace", "Shelf", "SafeEject", "WindowLayout", "MutationAdmissionGate", "UtilityLifecycle")
# Shell shortcut tests use the app's package dependencies and run through Xcode.
APP_TESTS = {"WorkspaceShortcutIsolationTests.swift"}
@@ -22,6 +22,7 @@
tests.mkdir(parents=True)
(sources / "MutationAdmissionGate.swift").symlink_to(ROOT / "Semper/Utilities/MutationAdmissionGate.swift")
(sources / "ModuleRegistry.swift").symlink_to(ROOT / "Semper/Modules/ModuleRegistry.swift")
+ (sources / "UtilityLifecycle.swift").symlink_to(ROOT / "Semper/Modules/UtilityLifecycle.swift")
for name in ("WindowLayoutModels.swift", "WindowLayoutService.swift", "WindowLayoutTargetTracker.swift"):
(sources / name).symlink_to(ROOT / "Semper/WindowLayout" / name)
for module in MODULES:
From b8ce5bd7f402d117f8b1eec2a5bf06fd68a3bcc3 Mon Sep 17 00:00:00 2001
From: Nihar <117209695+niharnm@users.noreply.github.com>
Date: Wed, 9 Sep 2026 09:32:56 -0700
Subject: [PATCH 3/7] Require review for unsupported window layout readback
---
Semper/WindowLayout/WindowLayoutService.swift | 35 +++++----
Semper/WindowLayout/WindowLayoutView.swift | 4 +-
SemperTests/WindowLayoutServiceTests.swift | 75 +++++++++++++++++++
guide/window-layout.md | 4 +-
4 files changed, 101 insertions(+), 17 deletions(-)
diff --git a/Semper/WindowLayout/WindowLayoutService.swift b/Semper/WindowLayout/WindowLayoutService.swift
index fc713fe..5c50531 100644
--- a/Semper/WindowLayout/WindowLayoutService.swift
+++ b/Semper/WindowLayout/WindowLayoutService.swift
@@ -4,7 +4,8 @@ import Observation
enum WindowLayoutError: LocalizedError {
case stopped, busy, noTarget, noRestore, placementReview, missingWindow, changedWindow, changedDisplays
- case invalidPlacement, unsupported(WorkspaceWindowIssue), unverifiedWrite, constrained, writeFailed(String)
+ case invalidPlacement, unsupported(WorkspaceWindowIssue), unverifiedWrite, fullHeightReadback, constrained
+ case writeFailed(String)
var errorDescription: String? {
switch self {
@@ -30,6 +31,8 @@ enum WindowLayoutError: LocalizedError {
}
case .unverifiedWrite:
"The window change could not be verified. Check the window in its original app, then choose Keep Current Placement."
+ case .fullHeightReadback:
+ "The app returned a full-height window that cannot be restored automatically. Check or adjust the window in its app, then choose Keep Current Placement."
case .constrained: "The app constrained the placement. The observed change can be restored."
case .writeFailed(let reason): reason
}
@@ -39,7 +42,7 @@ enum WindowLayoutError: LocalizedError {
@Observable
@MainActor
final class WindowLayoutService {
- private struct PreviousPlacement {
+ struct PreviousPlacement: Equatable {
let windowID: WorkspaceWindowID
let before: CGRect
let after: CGRect
@@ -57,7 +60,7 @@ final class WindowLayoutService {
private let mutationAdmission: MutationAdmissionGate
private let targetApplication: @MainActor () -> WorkspaceApplication?
private let targetTracker: WindowLayoutTargetTracker?
- private var previousPlacement: PreviousPlacement?
+ private(set) var previousPlacement: PreviousPlacement?
private var operation: Task?
private var pauseTask: Task?
private var shutdownTask: Task?
@@ -177,19 +180,19 @@ final class WindowLayoutService {
task.cancel()
}
} catch {
- if error is CancellationError {
- if !requiresPlacementReview {
+ if !requiresPlacementReview {
+ if error is CancellationError {
message = previousPlacement == nil
? "Window action cancelled. No observed change is available to restore."
: "Window action cancelled. The observed change remains available to restore."
+ } else if error is MutationAdmissionError {
+ message = "Finish the active window action or end Away Mode, then try again."
+ } else {
+ if let workspaceError = error as? WorkspaceError, case .permission = workspaceError {
+ permission = permission == .granted || permission == .revoked ? .revoked : .denied
+ }
+ message = error.localizedDescription
}
- } else if error is MutationAdmissionError {
- message = "Finish the active window action or end Away Mode, then try again."
- } else {
- if let workspaceError = error as? WorkspaceError, case .permission = workspaceError {
- permission = permission == .granted || permission == .revoked ? .revoked : .denied
- }
- message = error.localizedDescription
}
throw error
}
@@ -284,10 +287,16 @@ final class WindowLayoutService {
throw WindowLayoutError.writeFailed(observation.failure ?? "The app did not return its current window frame.")
}
let reachedTarget = WorkspaceGeometry.approximatelyEqual(after, target)
- if observation.writeAttempted, observation.before != after {
+ let excludedFrame = WorkspaceGeometry.excludedByDisplayBounds(after, on: displays)
+ if (observation.writeAttempted && observation.before != after) || excludedFrame {
previousPlacement = PreviousPlacement(
windowID: windowID, before: restoring?.before ?? observation.before, after: after, displays: displays)
}
+ if excludedFrame {
+ requiresPlacementReview = true
+ message = WindowLayoutError.fullHeightReadback.localizedDescription
+ throw WindowLayoutError.fullHeightReadback
+ }
if restoring != nil, reachedTarget { previousPlacement = nil }
try Task.checkCancellation()
if let failure = observation.failure { throw WindowLayoutError.writeFailed(failure) }
diff --git a/Semper/WindowLayout/WindowLayoutView.swift b/Semper/WindowLayout/WindowLayoutView.swift
index 6c0a698..ba4217b 100644
--- a/Semper/WindowLayout/WindowLayoutView.swift
+++ b/Semper/WindowLayout/WindowLayoutView.swift
@@ -23,7 +23,7 @@ struct WindowLayoutView: View {
}
}
if service.requiresPlacementReview {
- Text("Check the affected window before continuing. Its last change could not be verified, so automatic restore is unavailable.")
+ Text("Check the affected window before continuing. Its last result is outside automatic restore support or could not be verified.")
.font(.callout)
Button("Keep Current Placement…") { confirmKeepCurrent = true }
.disabled(service.isBusy || !service.isRunning)
@@ -45,7 +45,7 @@ struct WindowLayoutView: View {
.confirmationDialog("Keep this window placement?", isPresented: $confirmKeepCurrent) {
Button("Keep Current Placement", role: .destructive) { service.keepCurrentPlacement() }
} message: {
- Text("This discards the unverified change record. Arrange the window manually if needed before continuing.")
+ Text("This discards the preceding placement record. Arrange the window manually if needed before continuing.")
}
}
}
diff --git a/SemperTests/WindowLayoutServiceTests.swift b/SemperTests/WindowLayoutServiceTests.swift
index 6db30ef..efab44a 100644
--- a/SemperTests/WindowLayoutServiceTests.swift
+++ b/SemperTests/WindowLayoutServiceTests.swift
@@ -510,6 +510,81 @@ struct WindowLayoutServiceTests {
#expect(await backend.state?.frame == original)
}
+ @Test("Full-height readback retains evidence for manual review without advertising automatic restore",
+ arguments: [false, true])
+ func fullHeightReadbackRequiresReview(_ restoring: Bool) async throws {
+ let (service, backend, _) = fixture()
+ if restoring { try await service.perform(.leftHalf) }
+ let fullHeight = try #require(screen.fullScreenFrame)
+ await backend.setForcedFrame(fullHeight)
+ await #expect(throws: WindowLayoutError.self) {
+ try await service.perform(restoring ? .restore : .leftHalf)
+ }
+ let evidence = try #require(service.previousPlacement)
+ #expect(evidence.before == original)
+ #expect(evidence.after == fullHeight)
+ #expect(evidence.windowID == backend.windowID)
+ #expect(evidence.displays == WindowLayoutGeometry.topologyIdentity([screen]))
+ #expect(service.requiresPlacementReview && !service.canRestore)
+ #expect(service.message == WindowLayoutError.fullHeightReadback.localizedDescription)
+ let writesBeforeReview = await backend.requestedFrames.count
+ await #expect(throws: WindowLayoutError.self) { try await service.perform(.restore) }
+ #expect(await backend.requestedFrames.count == writesBeforeReview)
+ #expect(service.previousPlacement == evidence)
+ #expect(service.message == WindowLayoutError.fullHeightReadback.localizedDescription)
+
+ await service.pause()
+ service.keepCurrentPlacement()
+ #expect(service.requiresPlacementReview && !service.canRestore)
+ #expect(service.previousPlacement == evidence)
+ service.start()
+ #expect(service.requiresPlacementReview && !service.canRestore)
+ #expect(service.previousPlacement == evidence)
+ #expect(service.message == WindowLayoutError.fullHeightReadback.localizedDescription)
+
+ let adjusted = CGRect(x: 70, y: 80, width: 450, height: 350)
+ await backend.setFrame(adjusted)
+ await backend.setForcedFrame(nil)
+ await #expect(throws: WindowLayoutError.self) { try await service.perform(.center) }
+ #expect(await backend.requestedFrames.count == writesBeforeReview)
+ #expect(service.previousPlacement == evidence)
+ service.keepCurrentPlacement()
+ #expect(!service.requiresPlacementReview && service.previousPlacement == nil)
+ try await service.perform(.center)
+ let centered = try #require(await backend.state?.frame)
+ #expect(service.canRestore && !service.requiresPlacementReview)
+ #expect(service.previousPlacement?.before == adjusted)
+
+ await backend.setForcedFrame(CGRect(x: 500, y: 25, width: 420, height: 690))
+ await #expect(throws: WindowLayoutError.self) { try await service.perform(.rightHalf) }
+ #expect(service.canRestore && !service.requiresPlacementReview)
+ #expect(service.message == WindowLayoutError.constrained.localizedDescription)
+ await backend.setForcedFrame(nil)
+ try await service.perform(.restore)
+ #expect(await backend.state?.frame == centered)
+ #expect(!service.canRestore)
+ }
+
+ @Test("Full-height review reason takes precedence over cancellation and backend failure",
+ arguments: [false, true])
+ func fullHeightOutcomePrecedence(_ cancelled: Bool) async throws {
+ let (service, backend, gate) = fixture()
+ let fullHeight = try #require(screen.fullScreenFrame)
+ await backend.setForcedFrame(fullHeight)
+ await backend.setFailure("The app rejected the final size write.")
+ let writeGate = WindowLayoutTestGate()
+ await backend.setWriteGate(writeGate)
+ try await withHeldOperation(gate: writeGate, operation: { try await service.perform(.leftHalf) }) { task in
+ if cancelled { service.cancel() }
+ await writeGate.release()
+ await #expect(throws: WindowLayoutError.self) { try await task.value }
+ }
+ #expect(service.requiresPlacementReview && !service.canRestore)
+ #expect(service.previousPlacement?.after == fullHeight)
+ #expect(service.message == WindowLayoutError.fullHeightReadback.localizedDescription)
+ #expect(gate.activeSharedPermitCount == 0)
+ }
+
@Test("Missing readback requires acknowledgement and survives pause")
func unverifiedWriteReview() async throws {
let (service, backend, _) = fixture()
diff --git a/guide/window-layout.md b/guide/window-layout.md
index 6779247..f29fc1b 100644
--- a/guide/window-layout.md
+++ b/guide/window-layout.md
@@ -18,8 +18,8 @@ When Semper is frontmost, Window Layout uses the last eligible app active while
Only standard, nonminimized windows with readable geometry and move/resize support are eligible. Window Layout retains Workspace Restore's conservative full-height exclusion. It does not infer fullscreen state from a button role or use an undocumented fullscreen attribute. Full-height windows are refused even when they are ordinary windows. Targets that would enter the excluded area are also refused, so halves and Maximize can be unavailable when the menu bar and Dock both auto-hide; Center remains available for smaller windows. Missing display bounds are refused.
-Every change checks the resulting frame. The backend checks the expected display arrangement again after its final asynchronous window refresh and before writing. If an app limits the requested size, the result says so and retains the observed change for restore. Restore skips windows moved since the previous action, missing windows, and changed display arrangements. If a write cannot be verified, check the window manually and confirm Keep Current Placement before another action.
+Every change checks the resulting frame. The backend checks the expected display arrangement again after its final asynchronous window refresh and before writing. If an app limits the requested size to another supported frame, the result says so and retains the observed change for restore. If the app instead returns an excluded full-height frame, automatic restore is unavailable. The known before/after placement stays in memory for manual review. Restore skips windows moved since the previous action, missing windows, and changed display arrangements. After an excluded result or an unverifiable write, check or adjust the window manually and confirm Keep Current Placement before another action. That confirmation discards the preceding placement record; the next action checks eligibility again.
-Cancel stops additional writes and waits for the latest operation to finish. Verified partial changes remain available to restore. Pause stops app observation and drains work while preserving the previous placement. Removing the module or quitting clears its window handles and previous-placement record. Window titles are not collected; only module and shortcut preferences persist.
+Cancel stops additional writes and waits for the latest operation to finish. Supported, verified partial changes remain available to restore. Pause stops app observation and drains work while preserving the previous placement and any required manual review. Removing the module or quitting clears its window handles and previous-placement record. Window titles are not collected; only module and shortcut preferences persist.
Window Layout and Workspace Restore cannot write window positions concurrently. Presentation keeps its own recovery ownership; later manual changes are preserved by that recovery. Away prevents window changes while its curtain is active.
From 3b131bfd28549fad5e440586c7d7e70ab2b7716b Mon Sep 17 00:00:00 2001
From: Nihar <117209695+niharnm@users.noreply.github.com>
Date: Wed, 9 Sep 2026 09:50:54 -0700
Subject: [PATCH 4/7] Refresh utility review status and compatibility limits
---
README.md | 7 +++++-
ROADMAP.md | 42 +++++++++++++++++++++---------------
guide/product-status.md | 48 +++++++++++++++++++++++++++--------------
3 files changed, 63 insertions(+), 34 deletions(-)
diff --git a/README.md b/README.md
index 0c0bf5a..9a43b15 100644
--- a/README.md
+++ b/README.md
@@ -13,7 +13,7 @@
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.
+The current download, v1.0.0, contains Sound. Source builds include additional utilities in development toward the next release. The [product status guide](guide/product-status.md) distinguishes integrated features from work still under review.
[semper.systems](https://www.semper.systems/)
@@ -59,6 +59,7 @@ not download a Semper DMG from an unofficial source.
## Architecture Highlights
- **Independent Utilities**: Home provides module summaries, attention items, up to four pinned actions, search, and recent action outcomes for the current session. Add, pause, or remove modules individually; adding a module starts no service and requests no permission. Detailed controls open in a native window.
+- **Manual Window Layout, under review**: Five optional actions arrange eligible windows into halves, maximize, center, or restore the preceding placement. Full-height windows and targets are refused, which can limit halves and maximize when both the Dock and menu bar auto-hide. Source integration and native acceptance are tracked in the [product status guide](guide/product-status.md#window-layout).
- **Local Awake Sessions**: Public IOKit power assertions prevent idle system sleep, optionally keep the display on, and keep timed user sessions separate from Scene requests.
- **Authenticated Away Curtain**: One opaque panel covers each display, ordinary input is filtered, and local widgets can show time, battery, Away duration, and awake-request state.
- **Swift 6 & Core Audio TCC Taps**: Built using modern Swift 6 strict concurrency (`@MainActor`, `Sendable`) and low-latency CoreAudio process taps.
@@ -80,6 +81,7 @@ not download a Semper DMG from an unofficial source.
- Sound requires Screen & System Audio Recording permission for CoreAudio process taps.
- Microphone permission is used only for input-device monitoring.
- Accessibility permission is optional for system media-key control.
+- Workspace Restore and Window Layout actions require Accessibility access to read and move app windows.
Away is a Semper privacy curtain, not the macOS Lock Screen or an operating-system security boundary. Force Quit, Semper failure, restart, administrator or Accessibility control, remote access, authorized capture software, and display-change timing can expose the desktop. Its awake request does not prevent lid-close sleep, manual Sleep, or forced low-power sleep.
@@ -124,6 +126,9 @@ in your keychain so macOS can recognize later source updates as the same app.
- [Product Status](guide/product-status.md)
- [Module Shell](guide/module-shell.md)
- [Direct Utilities: Workspace Restore, File Shelf, Safe Eject](guide/direct-utilities.md)
+- [Choose Files in File Shelf](guide/shelf-file-selection.md)
+- [Presentation Controls](guide/presentation-controls.md)
+- [Window Layout Status](guide/product-status.md#window-layout)
- [Awake Sessions](guide/awake-sessions.md)
- [URL Schemes](guide/url-schemes.md)
- [App Shortcuts](guide/app-shortcuts.md)
diff --git a/ROADMAP.md b/ROADMAP.md
index 32fac9e..bebdc0b 100644
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -27,21 +27,26 @@ state lives in the [product status guide](guide/product-status.md).
- 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.
+- Complete native acceptance of the integrated File Shelf picker and
+ Presentation preparation/start cancellation, including keyboard routing,
+ focus, dismissal, and recovery.
+
+### 3. Window Layout acceptance and compatibility
+
+The implementation is under review in [PR #106](https://github.com/niharnm/Semper/pull/106).
+It becomes the tenth integrated module only when its source lands on `main`.
+
+- Verify all five manual commands, optional shortcuts, Home/search/pinned
+ actions, intended-window selection, and later manual changes on real apps.
+- Keep the conservative full-height exclusion explicit. Ordinary full-height
+ windows and targets are refused; halves and maximize can be unavailable when
+ both Dock and menu bar auto-hide. Smaller-window center and restore still
+ require eligible geometry.
+- An excluded or unreadable result requires manual review. Verify that its
+ recovery message survives cancellation and pause until acknowledged.
+- Resolve full-height compatibility through verified behavior before broad
+ support claims. Preserve Workspace Restore's protections. No automatic tiling
+ or persisted window history is included.
### 4. Cross-module workflows
@@ -58,11 +63,14 @@ The next planned increment. It is not implemented today.
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
+- Finish review of File Shelf's **Resize a 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.
+- Preserve pending cleanup through lifecycle changes, bound expiry retries,
+ verify file ownership before removal, and report the actual saved path.
+ Image-copy integration waits for the corrected implementation and tests.
- 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
@@ -119,7 +127,7 @@ discussion and include a hardware test plan.
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.
+- Under review, not integrated: Window Layout and File Shelf's Resize a Copy.
- Hardware-dependent: process taps, device routing, DDC, Bluetooth call mode,
media keys, Accessibility window operations, volume ejection, and permission
behavior.
diff --git a/guide/product-status.md b/guide/product-status.md
index e1eef21..2eb0184 100644
--- a/guide/product-status.md
+++ b/guide/product-status.md
@@ -4,18 +4,17 @@ 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.
+Snapshot: `main` at `6afe10d`, 2026-09-09, including the interaction fixes from
+[PR #108](https://github.com/niharnm/Semper/pull/108). 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.
+- **In review**: proposed source outside `main`. Passing source checks alone
+ does not establish integration, native acceptance, or public release.
- **Planned**: agreed scope with no implementation on `main`.
## Modules
@@ -32,26 +31,43 @@ containing Sound only.
| 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
+## Integrated interaction fixes
-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.
+These fixes are included in the `main` snapshot above. 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) |
+| Presentation preparation/start cancellation | Integrated | Visible cancellation and Escape during preparation/start, pending-work drainage, recovery and retry controls | [Presentation controls](presentation-controls.md) |
+| File Shelf Choose Files | Integrated | 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.
+These changes are outside 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 |
+| Window Layout | In review, [PR #106](https://github.com/niharnm/Semper/pull/106) | Final source clearance and integration; native window, shortcut and recovery checks remain open |
+| File Shelf Resize a Copy | In review, [PR #109](https://github.com/niharnm/Semper/pull/109) | Correct pending lifecycle/expiry, cleanup ownership and saved-path findings; verify the final implementation before integration and native acceptance |
+
+### Window Layout
+
+Reviewed source `b8ce5bd` adds left half, right half, maximize, center and
+previous-placement restore with optional shortcuts and Home/search/pinned
+actions. It becomes the tenth integrated module only after its source is
+merged into `main`; it is not included in the nine-module snapshot above.
+
+Full-height current windows and targets are refused even for ordinary windowed
+apps. Halves and maximize can therefore be unavailable when both the Dock and
+menu bar auto-hide. Eligible smaller windows can use center and restore. If an
+app returns an excluded or unreadable result, automatic restore is unavailable
+and manual review is required. An excluded full-height result retains its known
+before/after placement. The review requirement survives pause; acknowledgement,
+module removal or quitting clears it.
+
+Passing source tests does not establish native focus, keyboard, VoiceOver,
+permission, real-window or hardware acceptance. See the
+[reviewed Window Layout guide](https://github.com/niharnm/Semper/blob/b8ce5bd7f402d117f8b1eec2a5bf06fd68a3bcc3/guide/window-layout.md).
## Shared release gates
From 0f25f65ce4e4082c24a4d71aea86a15992adbf15 Mon Sep 17 00:00:00 2001
From: Nihar <117209695+niharnm@users.noreply.github.com>
Date: Wed, 9 Sep 2026 09:55:22 -0700
Subject: [PATCH 5/7] Preserve window placement receipts when writes are
refused
---
Semper/WindowLayout/WindowLayoutService.swift | 16 +++----
SemperTests/WindowLayoutServiceTests.swift | 44 +++++++++++++++++++
guide/window-layout.md | 2 +-
3 files changed, 53 insertions(+), 9 deletions(-)
diff --git a/Semper/WindowLayout/WindowLayoutService.swift b/Semper/WindowLayout/WindowLayoutService.swift
index 5c50531..e6daabc 100644
--- a/Semper/WindowLayout/WindowLayoutService.swift
+++ b/Semper/WindowLayout/WindowLayoutService.swift
@@ -277,18 +277,18 @@ final class WindowLayoutService {
_ observation: WorkspaceMoveObservation, windowID: WorkspaceWindowID, target: CGRect,
displays: [WorkspaceDisplay], restoring: PreviousPlacement?
) throws {
+ guard observation.writeAttempted else {
+ throw WindowLayoutError.writeFailed(observation.failure ?? "The window action was refused before writing.")
+ }
guard let after = observation.after, WorkspaceGeometry.valid(after) else {
- if observation.writeAttempted {
- previousPlacement = nil
- requiresPlacementReview = true
- message = WindowLayoutError.unverifiedWrite.localizedDescription
- throw WindowLayoutError.unverifiedWrite
- }
- throw WindowLayoutError.writeFailed(observation.failure ?? "The app did not return its current window frame.")
+ previousPlacement = nil
+ requiresPlacementReview = true
+ message = WindowLayoutError.unverifiedWrite.localizedDescription
+ throw WindowLayoutError.unverifiedWrite
}
let reachedTarget = WorkspaceGeometry.approximatelyEqual(after, target)
let excludedFrame = WorkspaceGeometry.excludedByDisplayBounds(after, on: displays)
- if (observation.writeAttempted && observation.before != after) || excludedFrame {
+ if observation.before != after || excludedFrame {
previousPlacement = PreviousPlacement(
windowID: windowID, before: restoring?.before ?? observation.before, after: after, displays: displays)
}
diff --git a/SemperTests/WindowLayoutServiceTests.swift b/SemperTests/WindowLayoutServiceTests.swift
index efab44a..14f9a0f 100644
--- a/SemperTests/WindowLayoutServiceTests.swift
+++ b/SemperTests/WindowLayoutServiceTests.swift
@@ -379,6 +379,50 @@ struct WindowLayoutServiceTests {
#expect(!service.canRestore)
}
+ @Test("No-write refusals preserve the preceding receipt even for excluded or reached-target frames",
+ arguments: [false, true], [false, true])
+ func noWriteRefusalPreservesReceipt(_ restoring: Bool, _ fullHeight: Bool) async throws {
+ let (service, backend, _) = fixture()
+ try await service.perform(.leftHalf)
+ let previous = try #require(service.previousPlacement)
+ let action: WindowLayoutAction = restoring ? .restore : .rightHalf
+ let target = restoring ? previous.before : try #require(
+ WindowLayoutGeometry.target(action, frame: previous.after, display: screen))
+ let external = fullHeight ? try #require(screen.fullScreenFrame) : target
+ let beforeWrite = WindowLayoutTestGate()
+ await backend.setBeforeWriteGate(beforeWrite)
+ let initialWrites = await backend.requestedFrames.count
+ try await withHeldOperation(gate: beforeWrite, operation: { try await service.perform(action) }) { task in
+ await backend.setFrame(external)
+ await beforeWrite.release()
+ await #expect(throws: WindowLayoutError.self) { try await task.value }
+ }
+ #expect(await backend.state?.frame == external)
+ #expect(await backend.requestedFrames.count == initialWrites)
+ #expect(service.previousPlacement == previous)
+ #expect(service.canRestore && !service.requiresPlacementReview)
+ #expect(service.message == "The window changed before writing.")
+ }
+
+ @Test("No-write refusals do not create a receipt or require review", arguments: [false, true])
+ func noWriteRefusalWithoutReceipt(_ fullHeight: Bool) async throws {
+ let (service, backend, _) = fixture()
+ let target = try #require(WindowLayoutGeometry.target(.leftHalf, frame: original, display: screen))
+ let external = fullHeight ? try #require(screen.fullScreenFrame) : target
+ let beforeWrite = WindowLayoutTestGate()
+ await backend.setBeforeWriteGate(beforeWrite)
+ try await withHeldOperation(gate: beforeWrite, operation: { try await service.perform(.leftHalf) }) { task in
+ await backend.setFrame(external)
+ await beforeWrite.release()
+ await #expect(throws: WindowLayoutError.self) { try await task.value }
+ }
+ #expect(await backend.state?.frame == external)
+ #expect(await backend.requestedFrames.isEmpty)
+ #expect(service.previousPlacement == nil)
+ #expect(!service.canRestore && !service.requiresPlacementReview)
+ #expect(service.message == "The window changed before writing.")
+ }
+
@Test("Topology changes before a write are refused")
func changedTopologyBeforeWrite() async {
let (service, backend, _) = fixture()
diff --git a/guide/window-layout.md b/guide/window-layout.md
index f29fc1b..67a8b81 100644
--- a/guide/window-layout.md
+++ b/guide/window-layout.md
@@ -18,7 +18,7 @@ When Semper is frontmost, Window Layout uses the last eligible app active while
Only standard, nonminimized windows with readable geometry and move/resize support are eligible. Window Layout retains Workspace Restore's conservative full-height exclusion. It does not infer fullscreen state from a button role or use an undocumented fullscreen attribute. Full-height windows are refused even when they are ordinary windows. Targets that would enter the excluded area are also refused, so halves and Maximize can be unavailable when the menu bar and Dock both auto-hide; Center remains available for smaller windows. Missing display bounds are refused.
-Every change checks the resulting frame. The backend checks the expected display arrangement again after its final asynchronous window refresh and before writing. If an app limits the requested size to another supported frame, the result says so and retains the observed change for restore. If the app instead returns an excluded full-height frame, automatic restore is unavailable. The known before/after placement stays in memory for manual review. Restore skips windows moved since the previous action, missing windows, and changed display arrangements. After an excluded result or an unverifiable write, check or adjust the window manually and confirm Keep Current Placement before another action. That confirmation discards the preceding placement record; the next action checks eligibility again.
+Every change checks the resulting frame. The backend checks the expected display arrangement again after its final asynchronous window refresh and before writing. A refusal before any write preserves the preceding placement record and reports the refusal, even if an external change already reached the requested target. If an app limits an attempted write to another supported frame, the result says so and retains the observed change for restore. If an attempted write instead returns an excluded full-height frame, automatic restore is unavailable. The known before/after placement stays in memory for manual review. Restore skips windows moved since the previous action, missing windows, and changed display arrangements. After an excluded post-write result or an unverifiable write, check or adjust the window manually and confirm Keep Current Placement before another action. That confirmation discards the preceding placement record; the next action checks eligibility again.
Cancel stops additional writes and waits for the latest operation to finish. Supported, verified partial changes remain available to restore. Pause stops app observation and drains work while preserving the previous placement and any required manual review. Removing the module or quitting clears its window handles and previous-placement record. Window titles are not collected; only module and shortcut preferences persist.
From 2f1f2962bd60568ef74da134b941874aa983ec36 Mon Sep 17 00:00:00 2001
From: Nihar <117209695+niharnm@users.noreply.github.com>
Date: Wed, 9 Sep 2026 11:28:57 -0700
Subject: [PATCH 6/7] Align product status with staged Window Layout source
---
README.md | 4 ++--
ROADMAP.md | 23 +++++++++++++---------
guide/product-status.md | 42 ++++++++++++++++++++++++-----------------
3 files changed, 41 insertions(+), 28 deletions(-)
diff --git a/README.md b/README.md
index 9a43b15..1f52d75 100644
--- a/README.md
+++ b/README.md
@@ -59,7 +59,7 @@ not download a Semper DMG from an unofficial source.
## Architecture Highlights
- **Independent Utilities**: Home provides module summaries, attention items, up to four pinned actions, search, and recent action outcomes for the current session. Add, pause, or remove modules individually; adding a module starts no service and requests no permission. Detailed controls open in a native window.
-- **Manual Window Layout, under review**: Five optional actions arrange eligible windows into halves, maximize, center, or restore the preceding placement. Full-height windows and targets are refused, which can limit halves and maximize when both the Dock and menu bar auto-hide. Source integration and native acceptance are tracked in the [product status guide](guide/product-status.md#window-layout).
+- **Manual Window Layout**: Five optional actions arrange eligible windows into halves, maximize, center, or restore the preceding placement. Full-height windows and targets are refused, which can limit halves and maximize when both the Dock and menu bar auto-hide. Source integration and native acceptance are tracked in the [product status guide](guide/product-status.md#window-layout).
- **Local Awake Sessions**: Public IOKit power assertions prevent idle system sleep, optionally keep the display on, and keep timed user sessions separate from Scene requests.
- **Authenticated Away Curtain**: One opaque panel covers each display, ordinary input is filtered, and local widgets can show time, battery, Away duration, and awake-request state.
- **Swift 6 & Core Audio TCC Taps**: Built using modern Swift 6 strict concurrency (`@MainActor`, `Sendable`) and low-latency CoreAudio process taps.
@@ -128,7 +128,7 @@ in your keychain so macOS can recognize later source updates as the same app.
- [Direct Utilities: Workspace Restore, File Shelf, Safe Eject](guide/direct-utilities.md)
- [Choose Files in File Shelf](guide/shelf-file-selection.md)
- [Presentation Controls](guide/presentation-controls.md)
-- [Window Layout Status](guide/product-status.md#window-layout)
+- [Window Layout](guide/window-layout.md)
- [Awake Sessions](guide/awake-sessions.md)
- [URL Schemes](guide/url-schemes.md)
- [App Shortcuts](guide/app-shortcuts.md)
diff --git a/ROADMAP.md b/ROADMAP.md
index bebdc0b..11dbbc8 100644
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -1,9 +1,11 @@
# Semper roadmap
-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
+Semper's source in this change set contains ten utility modules: Sound, Awake,
+Displays, Workspace Restore, Window Layout, File Shelf, Safe Eject, Scenes,
+Away, and Presentation. The baseline has nine integrated modules. Window Layout
+becomes the tenth integrated module when this change set merges into `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).
@@ -33,8 +35,9 @@ state lives in the [product status guide](guide/product-status.md).
### 3. Window Layout acceptance and compatibility
-The implementation is under review in [PR #106](https://github.com/niharnm/Semper/pull/106).
-It becomes the tenth integrated module only when its source lands on `main`.
+The cleared implementation from [PR #106](https://github.com/niharnm/Semper/pull/106)
+is included in this change set. Integration requires its merge to `main`;
+native acceptance remains open. See the [Window Layout guide](guide/window-layout.md).
- Verify all five manual commands, optional shortcuts, Home/search/pinned
actions, intended-window selection, and later manual changes on real apps.
@@ -42,8 +45,9 @@ It becomes the tenth integrated module only when its source lands on `main`.
windows and targets are refused; halves and maximize can be unavailable when
both Dock and menu bar auto-hide. Smaller-window center and restore still
require eligible geometry.
-- An excluded or unreadable result requires manual review. Verify that its
- recovery message survives cancellation and pause until acknowledged.
+- After an attempted write, an excluded or unreadable result requires manual
+ review. Verify that its recovery message survives cancellation and pause
+ until acknowledged.
- Resolve full-height compatibility through verified behavior before broad
support claims. Preserve Workspace Restore's protections. No automatic tiling
or persisted window history is included.
@@ -127,7 +131,8 @@ discussion and include a hardware test plan.
Homebrew are current.
- Integrated on `main` and in no download yet: Awake, Displays, Workspace
Restore, File Shelf, Safe Eject, Scenes, Away, and Presentation.
-- Under review, not integrated: Window Layout and File Shelf's Resize a Copy.
+- Implemented in this change set, awaiting integration: Window Layout.
+- Under review, not integrated: File Shelf's Resize a Copy.
- Hardware-dependent: process taps, device routing, DDC, Bluetooth call mode,
media keys, Accessibility window operations, volume ejection, and permission
behavior.
diff --git a/guide/product-status.md b/guide/product-status.md
index 2eb0184..a0d8932 100644
--- a/guide/product-status.md
+++ b/guide/product-status.md
@@ -1,18 +1,22 @@
# 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
+Semper's source in this change set contains ten utility modules. This page
+records 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 `6afe10d`, 2026-09-09, including the interaction fixes from
-[PR #108](https://github.com/niharnm/Semper/pull/108). Latest downloadable
-release: v1.0.0, published 2026-08-26, containing Sound only.
+Snapshot: baseline `main` at `6afe10d`, 2026-09-09, including the interaction
+fixes from [PR #108](https://github.com/niharnm/Semper/pull/108). Cleared Window
+Layout source `0f25f65` is staged with that baseline at `e621a11`; its integration
+requires this full change set to merge into `main`. 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**: included in the staged source snapshot.
+ It becomes integrated only when this change set merges into `main`.
- **In review**: proposed source outside `main`. Passing source checks alone
does not establish integration, native acceptance, or public release.
- **Planned**: agreed scope with no implementation on `main`.
@@ -25,6 +29,7 @@ release: v1.0.0, published 2026-08-26, containing Sound only.
| 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) |
+| Window Layout | Place one eligible window or restore its preceding placement | Implemented in this change set | Source merge and shared gates; full-height/auto-hide limits, focus, shortcuts, constrained windows and recovery need native verification | [Source](../Semper/WindowLayout), [guide](window-layout.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) |
@@ -43,31 +48,34 @@ the shared release gates remain separate.
## Next increments
-These changes are outside the `main` snapshot above and are not released.
+This proposed feature is outside both the staged source and baseline `main`
+snapshots above and is not released.
| Increment | State | Acceptance before integration |
| --- | --- | --- |
-| Window Layout | In review, [PR #106](https://github.com/niharnm/Semper/pull/106) | Final source clearance and integration; native window, shortcut and recovery checks remain open |
| File Shelf Resize a Copy | In review, [PR #109](https://github.com/niharnm/Semper/pull/109) | Correct pending lifecycle/expiry, cleanup ownership and saved-path findings; verify the final implementation before integration and native acceptance |
-### Window Layout
+## Window Layout
-Reviewed source `b8ce5bd` adds left half, right half, maximize, center and
+Cleared source `0f25f65` adds left half, right half, maximize, center and
previous-placement restore with optional shortcuts and Home/search/pinned
-actions. It becomes the tenth integrated module only after its source is
-merged into `main`; it is not included in the nine-module snapshot above.
+actions. It is included in the staged source snapshot and becomes the tenth
+integrated module when this change set merges into `main`. The baseline main
+snapshot contains nine modules.
Full-height current windows and targets are refused even for ordinary windowed
apps. Halves and maximize can therefore be unavailable when both the Dock and
-menu bar auto-hide. Eligible smaller windows can use center and restore. If an
-app returns an excluded or unreadable result, automatic restore is unavailable
-and manual review is required. An excluded full-height result retains its known
-before/after placement. The review requirement survives pause; acknowledgement,
-module removal or quitting clears it.
+menu bar auto-hide. Eligible smaller windows can use center and restore. After
+an attempted write, an excluded or unreadable result requires manual review
+instead of automatic restore. An excluded full-height readback from that write
+retains its known before/after placement. A refusal without a write preserves
+the preceding placement record instead.
+The review requirement survives pause; acknowledgement, module removal or
+quitting clears it.
Passing source tests does not establish native focus, keyboard, VoiceOver,
permission, real-window or hardware acceptance. See the
-[reviewed Window Layout guide](https://github.com/niharnm/Semper/blob/b8ce5bd7f402d117f8b1eec2a5bf06fd68a3bcc3/guide/window-layout.md).
+[Window Layout guide](window-layout.md).
## Shared release gates
From e40963c0d76f328451ace8923146a945f5d1b674 Mon Sep 17 00:00:00 2001
From: Nihar <117209695+niharnm@users.noreply.github.com>
Date: Wed, 9 Sep 2026 12:35:41 -0700
Subject: [PATCH 7/7] Describe Window Layout in source builds
---
website/about.html | 11 ++++++++---
website/index.html | 28 +++++++++++++++++++++++++---
website/llms.txt | 14 +++++++++++---
website/privacy.html | 8 +++++---
website/sitemap.xml | 2 +-
5 files changed, 50 insertions(+), 13 deletions(-)
diff --git a/website/about.html b/website/about.html
index e6f17cd..e7b097a 100644
--- a/website/about.html
+++ b/website/about.html
@@ -206,13 +206,18 @@
Modules in development
Awake, Displays, Workspace Restore, File Shelf, Safe Eject,
- Scenes, Away, and Presentation are in development.
+ Scenes, Away, Presentation, and Window Layout are in development.
None is included in the published v1.0.0 download. Follow the
module overview for scope and limits.
-
Source integration of the expanded modules is complete.
+
Source builds include ten modules.
Checks for a public release are still in progress. The
source-build module guide
explains Home, adding modules, and opening their controls.
+
Window Layout provides five manual placement actions for eligible app windows.
+ Full-height windows and targets are refused, including ordinary windows.
+ Halves and Maximize can be unavailable when both the Dock and menu bar auto-hide.
+ The Window Layout guide
+ covers optional shortcuts, eligibility, and results that require manual review.
Away is an app privacy curtain, not a macOS session lock.
Awake and Away request idle wakefulness; they do not guarantee
that every job continues running.
@@ -240,7 +245,7 @@
Privacy and permissions
for Sound's media-key control.
- In source builds, Workspace Restore requires Accessibility
+ In source builds, Workspace Restore and Window Layout require Accessibility
permission to read and move selected windows. Opening Home or
adding a module does not start Sound or request its audio access.
diff --git a/website/index.html b/website/index.html
index 754e6ad..4579754 100644
--- a/website/index.html
+++ b/website/index.html
@@ -133,7 +133,7 @@
"name": "What is Semper?",
"acceptedAnswer": {
"@type": "Answer",
- "text": "Semper brings Mac utilities together in one native app. The v1.0.0 download provides per-app sound controls. Awake, Displays, Workspace Restore, File Shelf, Safe Eject, Scenes, Away, and Presentation are in development. These additional modules are not included in that download."
+ "text": "Semper brings Mac utilities together in one native app. The v1.0.0 download provides per-app sound controls. Awake, Displays, Workspace Restore, File Shelf, Safe Eject, Scenes, Away, Presentation, and Window Layout are in development. These additional modules are not included in that download."
}
},
{
@@ -503,7 +503,7 @@
Small controls. Useful every day.
Each module has a clear job. Sound is available today.
- Source integration of the expanded modules is complete.
+ Source builds include ten modules.
Checks for a public release are still in progress.
@@ -610,6 +611,27 @@
Small controls. Useful every day.
Its scope depends on the controls each module can support and restore.
+
+ 10
+
Window Layout
In development
+
+
Arrange one eligible app window with Left Half, Right Half,
+ Maximize, Center, or Restore Previous Placement.
+ Full-height windows and targets are excluded.
+ Scope and limits
+
Only standard, nonminimized windows with readable geometry and move/resize support qualify.
+ The first layout action requests Accessibility access if needed.
+ Actions support Home search, pins, and optional shortcuts, unassigned by default.
+
Maximize does not enter full screen. Ordinary full-height windows are also refused.
+ Halves and Maximize can be unavailable when both the Dock and menu bar auto-hide.
+ Restore skips later manual changes, missing windows, and changed display arrangements.
+
After an excluded post-write result or an unverifiable write, review the window
+ and confirm Keep Current Placement before another action.
+ This discards the preceding placement record.
+ Read the Window Layout guide.
+
+
+
The v1.0.0 download includes Sound only. Every other module listed here is in development.
Availability changes only when a packaged release includes them.
@@ -1235,7 +1257,7 @@
What is Semper?
Semper brings Mac utilities together in one native app.
The v1.0.0 download provides per-app sound controls.
Awake, Displays, Workspace Restore,
- File Shelf, Safe Eject, Scenes, Away, and Presentation are in
+ File Shelf, Safe Eject, Scenes, Away, Presentation, and Window Layout are in
development. These additional modules are not
included in that download.
diff --git a/website/llms.txt b/website/llms.txt
index ef14145..3b3fa22 100644
--- a/website/llms.txt
+++ b/website/llms.txt
@@ -14,8 +14,8 @@ Repository notices: https://github.com/niharnm/Semper/blob/main/NOTICE.md
- Source code, tests, and build instructions are public.
- The published download checked September 9, 2026 is v1.0.0, with Sound capabilities.
- Release artifact: https://github.com/niharnm/Semper/releases/tag/v1.0.0
-- Awake, Displays, Workspace Restore, File Shelf, Safe Eject, Scenes, Away, and Presentation are in development. None of these additional modules is included in v1.0.0.
-- Source integration of the expanded modules is complete. Checks for a public release are still in progress.
+- Awake, Displays, Workspace Restore, File Shelf, Safe Eject, Scenes, Away, Presentation, and Window Layout are in development. None of these additional modules is included in v1.0.0.
+- Source builds include ten modules. Checks for a public release are still in progress.
- Semper currently requires macOS 15.4 or later.
- Semper is available to macOS users and to developers who want to build from source.
- Do not describe Semper as available for Windows, Linux, iOS, or the Mac App Store.
@@ -39,7 +39,7 @@ Repository notices: https://github.com/niharnm/Semper/blob/main/NOTICE.md
- Screen & System Audio Recording permission is required for Core Audio process taps.
- Microphone permission is used only for input-device monitoring.
- Accessibility permission is optional for Sound's media-key control.
-- In source builds, Workspace Restore requires Accessibility permission to read and move selected windows.
+- In source builds, Workspace Restore and Window Layout require Accessibility permission to read and move selected windows. Window Layout requests access on the first layout action if needed.
- In a source build, open Semper from its menu bar icon. From Home, choose Sound, then Open Sound to start its audio controls. Browsing Home or adding a module does not start Sound.
## Important limits
@@ -52,6 +52,13 @@ Repository notices: https://github.com/niharnm/Semper/blob/main/NOTICE.md
- No controlled head-to-head performance results are published on the site.
- Comparisons: https://www.semper.systems/#compare
+## Window Layout in source builds
+
+- Five manual actions for one eligible standard app window: Left Half, Right Half, Maximize, Center, and Restore Previous Placement.
+- Actions support Home search, pins, and optional shortcuts. No shortcut is assigned by default. Maximize does not enter full screen.
+- Only nonminimized windows with readable geometry and move/resize support qualify. Full-height windows and targets are refused, including ordinary windows. Halves and Maximize can be unavailable when both the Dock and menu bar auto-hide.
+- Restore skips later manual changes, missing windows, and changed display arrangements. After an excluded post-write result or an unverifiable write, review the window and confirm Keep Current Placement before another action. This discards the preceding placement record.
+
## Contributions
- Semper is actively looking for contributors.
@@ -67,6 +74,7 @@ Repository notices: https://github.com/niharnm/Semper/blob/main/NOTICE.md
- Product: https://www.semper.systems/
- Module overview and availability: https://www.semper.systems/#modules
- Source-build module guide: https://github.com/niharnm/Semper/blob/main/guide/module-shell.md
+- Window Layout source guide: https://github.com/niharnm/Semper/blob/main/guide/window-layout.md
- Mac per-app volume guide: https://www.semper.systems/mac-volume-mixer.html
- Project fact sheet: https://www.semper.systems/about.html
- Privacy: https://www.semper.systems/privacy.html
diff --git a/website/privacy.html b/website/privacy.html
index c4b39cd..5c04c1f 100644
--- a/website/privacy.html
+++ b/website/privacy.html
@@ -67,7 +67,7 @@
Your audio stays on your Mac.
Updated
-
+
@@ -185,8 +185,10 @@
macOS permissions
Bluetooth audio devices.
- Accessibility: optional, and used to intercept
- the system media keys for Semper's volume controls.
+ Accessibility: optional for Sound's media-key
+ volume controls. In source builds, Workspace Restore and Window
+ Layout require it to read and move selected windows. Window Layout
+ requests access on the first layout action if needed.