diff --git a/README.md b/README.md index 0c0bf5a..1f52d75 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**: 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](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 32fac9e..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). @@ -27,21 +29,28 @@ 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 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. +- 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. +- 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. ### 4. Cross-module workflows @@ -58,11 +67,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 +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. -- Planned additions: Window Layout and File Shelf's Resize Image Copy action. +- 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/Semper/Modules/ModuleRegistry.swift b/Semper/Modules/ModuleRegistry.swift index ac3b789..9e59ceb 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: ["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/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..4ff3c9d --- /dev/null +++ b/Semper/WindowLayout/WindowLayoutModels.swift @@ -0,0 +1,92 @@ +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? + 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), + let fullBounds = display.fullScreenFrame, WorkspaceGeometry.valid(fullBounds), fullBounds.contains(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) + && !WorkspaceGeometry.excludedByDisplayBounds(target, on: [display]) ? target : nil + } +} + +enum WindowLayoutWindowRules { + static func issue( + standard: Bool?, minimized: Bool?, frame: CGRect?, displays: [WorkspaceDisplay], + movable: Bool?, resizable: Bool? + ) -> WorkspaceWindowIssue? { + if let issue = WorkspaceWindowRules.issue( + standard: standard, minimized: minimized, frame: frame, displays: displays, + movable: movable, resizable: resizable) + { + return issue + } + 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 new file mode 100644 index 0000000..e6daabc --- /dev/null +++ b/Semper/WindowLayout/WindowLayoutService.swift @@ -0,0 +1,310 @@ +import CoreGraphics +import Foundation +import Observation + +enum WindowLayoutError: LocalizedError { + case stopped, busy, noTarget, noRestore, placementReview, missingWindow, changedWindow, changedDisplays + case invalidPlacement, unsupported(WorkspaceWindowIssue), unverifiedWrite, fullHeightReadback, constrained + case 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 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 must be a standard window that allows moving and resizing." + case .unknownState: + "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 + } + 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 + } + } +} + +@Observable +@MainActor +final class WindowLayoutService { + struct PreviousPlacement: Equatable { + 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(set) 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 !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 + } + } + 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 = WindowLayoutGeometry.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 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, 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." + } + + 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 = 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, expectedDisplays: displays) + 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 observation.writeAttempted else { + throw WindowLayoutError.writeFailed(observation.failure ?? "The window action was refused before writing.") + } + guard let after = observation.after, WorkspaceGeometry.valid(after) else { + 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.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) } + guard reachedTarget else { + if observation.before == after { + throw WindowLayoutError.writeFailed("The app kept the window in its current placement.") + } + throw WindowLayoutError.constrained + } + } +} 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..ba4217b --- /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 result is outside automatic restore support or could not be verified.") + .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-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) + } + .padding(24) + } + .confirmationDialog("Keep this window placement?", isPresented: $confirmKeepCurrent) { + Button("Keep Current Placement", role: .destructive) { service.keepCurrentPlacement() } + } message: { + Text("This discards the preceding placement 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..506bc43 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 } @@ -138,16 +184,39 @@ actor AccessibilityWorkspaceBackend: WorkspaceWindowBackend { } 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) } 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 +232,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 { @@ -206,6 +277,7 @@ actor AccessibilityWorkspaceBackend: WorkspaceWindowBackend { var value: CFTypeRef? 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 } do { @@ -216,11 +288,21 @@ 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: + 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) + } 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/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/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..5e4dca3 --- /dev/null +++ b/SemperTests/WindowLayoutGeometryTests.swift @@ -0,0 +1,179 @@ +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), + 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)) + } + 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), + 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) + #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("Usable-area layouts remain eligible while full-height targets are conservatively refused") + func repeatedLayoutsRetainEligibility() throws { + 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] { + 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) == nil) + } + #expect( + WorkspaceWindowRules.issue( + standard: true, minimized: false, frame: arranged, displays: [display], + movable: true, resizable: true) == nil) + #expect(WindowLayoutGeometry.target(action, frame: original, display: autoHideDisplay) == nil) + } + #expect(WindowLayoutGeometry.target(.center, frame: original, display: autoHideDisplay) != nil) + } + + @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) == .manualAdjustmentRequired) + } + } + + @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) == .unsupported) + #expect( + WindowLayoutWindowRules.issue( + standard: true, minimized: true, frame: original, displays: [display], + movable: true, resizable: true) == .minimized) + #expect( + WindowLayoutWindowRules.issue( + standard: true, minimized: false, frame: original, displays: [display], + movable: false, resizable: true) == .unsupported) + #expect( + WindowLayoutWindowRules.issue( + standard: true, minimized: false, frame: original, displays: [display], + movable: true, resizable: 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) == .unknownState) + #expect( + WindowLayoutWindowRules.issue( + standard: true, minimized: nil, frame: original, displays: [display], + movable: true, resizable: true) == .unknownState) + #expect( + WindowLayoutWindowRules.issue( + standard: true, minimized: false, frame: nil, displays: [display], + movable: true, resizable: true) == .unknownState) + #expect( + WindowLayoutWindowRules.issue( + standard: true, minimized: false, frame: original, displays: [], + movable: true, resizable: true) == .unknownState) + #expect( + WindowLayoutWindowRules.issue( + standard: true, minimized: false, frame: original, displays: [display], + movable: nil, resizable: true) == .unknownState) + #expect( + WindowLayoutWindowRules.issue( + standard: true, minimized: false, frame: original, displays: [display], + 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 new file mode 100644 index 0000000..14f9a0f --- /dev/null +++ b/SemperTests/WindowLayoutServiceTests.swift @@ -0,0 +1,748 @@ +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 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? + 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 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 + } + + 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 setBeforeWriteGate(_ gate: WindowLayoutTestGate) { beforeWriteGate = 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), + fullScreenFrame: CGRect(x: 0, y: 0, width: 1000, height: 800)) + 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("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() + 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("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() + 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("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() + 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/product-status.md b/guide/product-status.md index e1eef21..a0d8932 100644 --- a/guide/product-status.md +++ b/guide/product-status.md @@ -1,21 +1,24 @@ # 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 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: 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**: present in the staged source snapshot. - This state alone does not establish inclusion on `main` or in a public release. +- **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`. ## Modules @@ -26,32 +29,53 @@ 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) | | 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. +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 | 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 | +| 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 + +Cleared source `0f25f65` adds left half, right half, maximize, center and +previous-placement restore with optional shortcuts and Home/search/pinned +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. 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 +[Window Layout guide](window-layout.md). ## Shared release gates diff --git a/guide/window-layout.md b/guide/window-layout.md new file mode 100644 index 0000000..67a8b81 --- /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 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. 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. + +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..48c392e 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", "UtilityLifecycle") +# 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,10 @@ 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") + (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: source = ROOT / "Semper" / module if not source.is_dir(): @@ -29,6 +35,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 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

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.

+
+ +

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.
  • diff --git a/website/sitemap.xml b/website/sitemap.xml index a7a1cc0..f6333a8 100644 --- a/website/sitemap.xml +++ b/website/sitemap.xml @@ -14,7 +14,7 @@ https://www.semper.systems/privacy.html - 2026-09-08 + 2026-09-09 https://www.semper.systems/terms.html