From 80a6d73444ca32cabbbfea09a395fc9322f0ecea Mon Sep 17 00:00:00 2001 From: Nihar <117209695+niharnm@users.noreply.github.com> Date: Wed, 9 Sep 2026 13:19:01 -0700 Subject: [PATCH 1/2] Reclaim stale Window Layout handles with bounded checks --- Semper/Workspace/WorkspaceWindowBackend.swift | 88 +++--- .../WorkspaceWindowHandleStore.swift | 76 +++++ .../WorkspaceWindowHandleStoreTests.swift | 288 ++++++++++++++++++ 3 files changed, 413 insertions(+), 39 deletions(-) create mode 100644 Semper/Workspace/WorkspaceWindowHandleStore.swift create mode 100644 SemperTests/WorkspaceWindowHandleStoreTests.swift diff --git a/Semper/Workspace/WorkspaceWindowBackend.swift b/Semper/Workspace/WorkspaceWindowBackend.swift index 506bc43..7583f2b 100644 --- a/Semper/Workspace/WorkspaceWindowBackend.swift +++ b/Semper/Workspace/WorkspaceWindowBackend.swift @@ -13,17 +13,7 @@ protocol WorkspaceWindowBackend: Sendable { } 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 var handles = WorkspaceWindowHandleStore() private let messageTimeout: Float = 0.15 private var currentDisplays: [WorkspaceDisplay] = [] @@ -80,8 +70,7 @@ actor AccessibilityWorkspaceBackend: WindowLayoutWindowBackend { func windows(in applications: [WorkspaceApplication]) async throws -> [WorkspaceWindowSnapshot] { guard AXIsProcessTrusted() else { throw WorkspaceError.permission } _ = await displays() - let running = await self.applications() - handles = handles.filter { running.contains($0.value.application) } + await pruneTerminatedProcesses() var snapshots: [WorkspaceWindowSnapshot] = [] for application in applications.prefix(30) { try Task.checkCancellation() @@ -99,9 +88,7 @@ actor AccessibilityWorkspaceBackend: WindowLayoutWindowBackend { issue: error == .cannotComplete ? .timedOut : .unavailable)) continue } - handles = handles.filter { entry in - entry.value.application != application || elements.contains { CFEqual(entry.value.element, $0) } - } + handles.retainWorkspaceWindows(in: application, elements: elements, equal: CFEqual) for (index, window) in elements.enumerated() { try Task.checkCancellation() if ContinuousClock.now >= deadline { @@ -110,19 +97,16 @@ actor AccessibilityWorkspaceBackend: WindowLayoutWindowBackend { break } AXUIElementSetMessagingTimeout(window, messageTimeout) - let id = - handles.first(where: { - $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 { + guard + let id = handles.retain( + element: window, application: application, ordinal: index + 1, + policy: .workspaceRestore, equal: CFEqual) + else { snapshots.append( .init( id: nil, application: application, ordinal: index + 1, frame: nil, issue: .unavailable)) continue } - 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 } } @@ -139,6 +123,7 @@ actor AccessibilityWorkspaceBackend: WindowLayoutWindowBackend { try Task.checkCancellation() guard AXIsProcessTrusted() else { throw WorkspaceError.permission } _ = await displays() + await pruneTerminatedProcesses() guard await isSameProcess(application) else { return nil } let deadline = ContinuousClock.now.advanced(by: .seconds(2)) let element = AXUIElementCreateApplication(application.pid) @@ -157,17 +142,24 @@ actor AccessibilityWorkspaceBackend: WindowLayoutWindowBackend { 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 { + for _ in 0.. WorkspaceWindowSnapshot? { try Task.checkCancellation() guard AXIsProcessTrusted() else { throw WorkspaceError.permission } - guard handles[id] != nil, await isSameProcess(id.application) else { return nil } + guard handles[id] != nil else { return nil } + let sameProcess = await Self.processMatches(id.application) + guard sameProcess == true else { + if sameProcess == false { handles.removeProcesses([id.application]) } + return nil + } _ = await displays() let state = try snapshot(id, deadline: ContinuousClock.now.advanced(by: .seconds(2))) return state.issue == .unavailable ? nil : state @@ -261,11 +258,21 @@ actor AccessibilityWorkspaceBackend: WindowLayoutWindowBackend { } private func isSameProcess(_ application: WorkspaceApplication) async -> Bool { - await MainActor.run { - guard let app = NSRunningApplication(processIdentifier: application.pid) else { return false } - return !app.isTerminated && app.bundleIdentifier == application.bundleID - && app.launchDate == application.launchDate - } + await Self.processMatches(application) == true + } + + private func pruneTerminatedProcesses() async { + let known = Set(handles.entries.values.map(\.application)) + let stale = await MainActor.run { known.filter { Self.processMatches($0) == false } } + handles.removeProcesses(Array(stale)) + } + + @MainActor + private static func processMatches(_ application: WorkspaceApplication) -> Bool? { + guard let app = NSRunningApplication(processIdentifier: application.pid) else { return false } + guard !app.isTerminated else { return false } + guard let bundleID = app.bundleIdentifier, let launchDate = app.launchDate else { return nil } + return bundleID == application.bundleID && launchDate == application.launchDate } private func snapshot(_ id: WorkspaceWindowID, deadline: ContinuousClock.Instant) throws -> WorkspaceWindowSnapshot @@ -276,7 +283,10 @@ actor AccessibilityWorkspaceBackend: WindowLayoutWindowBackend { guard ContinuousClock.now < deadline else { throw WorkspaceWindowReadError.timeout } var value: CFTypeRef? let result = AXUIElementCopyAttributeValue(handle.element, name as CFString, &value) - if result == .invalidUIElement { throw WorkspaceError.missing } + if result == .invalidUIElement { + handles.recordProbe(result, for: id) + throw WorkspaceError.missing + } if result == .cannotComplete && handle.policy == .windowLayout { throw WorkspaceWindowReadError.timeout } return result == .success ? value : nil } diff --git a/Semper/Workspace/WorkspaceWindowHandleStore.swift b/Semper/Workspace/WorkspaceWindowHandleStore.swift new file mode 100644 index 0000000..3ff0134 --- /dev/null +++ b/Semper/Workspace/WorkspaceWindowHandleStore.swift @@ -0,0 +1,76 @@ +import ApplicationServices +import Foundation + +nonisolated struct WorkspaceWindowHandleStore { + enum Policy { case workspaceRestore, windowLayout } + + struct Handle { + let element: Element + let application: WorkspaceApplication + let ordinal: Int + let policy: Policy + } + + private(set) var entries: [WorkspaceWindowID: Handle] = [:] + private var probeOrder: [WorkspaceWindowID] = [] + var windowLayoutCount: Int { probeOrder.count } + + subscript(id: WorkspaceWindowID) -> Handle? { entries[id] } + + mutating func retain( + element: Element, application: WorkspaceApplication, ordinal: Int, policy: Policy, + equal: (Element, Element) -> Bool + ) -> WorkspaceWindowID? { + if let id = entries.first(where: { + $0.value.policy == policy && $0.value.application == application && equal($0.value.element, element) + })?.key { + entries[id] = Handle(element: element, application: application, ordinal: ordinal, policy: policy) + return id + } + guard entries.count < 2_000 else { return nil } + let id = WorkspaceWindowID(application: application, token: UUID()) + entries[id] = Handle(element: element, application: application, ordinal: ordinal, policy: policy) + if policy == .windowLayout { probeOrder.append(id) } + return id + } + + mutating func remove(_ id: WorkspaceWindowID) { + entries[id] = nil + probeOrder.removeAll { $0 == id } + } + + mutating func removeProcesses(_ stale: [WorkspaceApplication]) { + entries = entries.filter { entry in + !stale.contains { + $0.pid == entry.value.application.pid && $0.bundleID == entry.value.application.bundleID + && $0.launchDate == entry.value.application.launchDate + } + } + probeOrder.removeAll { entries[$0] == nil } + } + + mutating func retainWorkspaceWindows( + in application: WorkspaceApplication, elements: [Element], equal: (Element, Element) -> Bool + ) { + entries = entries.filter { entry in + entry.value.policy != .workspaceRestore || entry.value.application != application + || elements.contains { equal(entry.value.element, $0) } + } + } + + mutating func nextWindowLayoutProbeCandidates(limit: Int = 4) -> [(id: WorkspaceWindowID, element: Element)] { + let ids = Array(probeOrder.prefix(limit)) + probeOrder.removeFirst(ids.count) + probeOrder.append(contentsOf: ids) + return ids.compactMap { id in entries[id].map { (id, $0.element) } } + } + + mutating func recordProbe(_ result: AXError, for id: WorkspaceWindowID) { + if result == .invalidUIElement { remove(id) } + } + + mutating func removeAll() { + entries.removeAll() + probeOrder.removeAll() + } +} diff --git a/SemperTests/WorkspaceWindowHandleStoreTests.swift b/SemperTests/WorkspaceWindowHandleStoreTests.swift new file mode 100644 index 0000000..7b07205 --- /dev/null +++ b/SemperTests/WorkspaceWindowHandleStoreTests.swift @@ -0,0 +1,288 @@ +import ApplicationServices +import Foundation +import Testing + +@testable import Semper + +@Suite("Workspace window handle store", .serialized, .timeLimit(.minutes(1))) +@MainActor +struct WorkspaceWindowHandleStoreTests { + let app = WorkspaceApplication( + pid: 704, bundleID: "test.handle-store", name: "Handle Test", launchDate: Date(timeIntervalSince1970: 40)) + + private func requireID(_ id: WorkspaceWindowID?) throws -> WorkspaceWindowID { + try #require(id) + } + + @Test("Store-only baseline reaches the cap while bounded focused maintenance admits 4000 windows") + func focusedChurnReplay() throws { + var baseline = WorkspaceWindowHandleStore() + var baselineAdmitted = 0 + var baselineRefused = 0 + for element in 0..<4_000 { + let id = baseline.retain( + element: element, application: app, ordinal: 1, policy: .windowLayout, equal: ==) + if id == nil { baselineRefused += 1 } else { baselineAdmitted += 1 } + } + #expect(baselineAdmitted == 2_000) + #expect(baselineRefused == 2_000) + #expect(baseline.entries.count == 2_000) + + var maintained = WorkspaceWindowHandleStore() + let activeElements: Set = [-3, -2, -1] + var receiptIDs: [Int: WorkspaceWindowID] = [:] + for element in activeElements.sorted() { + receiptIDs[element] = try requireID( + maintained.retain( + element: element, application: app, ordinal: 1, policy: .windowLayout, equal: ==)) + } + var admittedIDs: Set = [] + var peakRetainedCount = maintained.entries.count + var maxProbeCount = 0 + for element in 0..<4_000 { + let candidates = maintained.nextWindowLayoutProbeCandidates() + maxProbeCount = max(maxProbeCount, candidates.count) + for candidate in candidates { + maintained.recordProbe( + activeElements.contains(candidate.element) ? .success : .invalidUIElement, for: candidate.id) + } + let id = try requireID( + maintained.retain( + element: element, application: app, ordinal: 1, policy: .windowLayout, equal: ==)) + admittedIDs.insert(id) + peakRetainedCount = max(peakRetainedCount, maintained.entries.count) + } + #expect(admittedIDs.count == 4_000) + #expect(maxProbeCount <= 4) + #expect(peakRetainedCount == activeElements.count + 1) + for (element, id) in receiptIDs { + #expect(maintained[id]?.element == element) + #expect( + maintained.retain( + element: element, application: app, ordinal: 1, policy: .windowLayout, equal: ==) == id) + } + #expect(maintained.entries.count == activeElements.count + 1) + } + + @Test( + "Transient probe results preserve live receipt identity", + arguments: [ + AXError.success, .cannotComplete, .apiDisabled, .noValue, .failure, .attributeUnsupported, + ]) + func transientProbeResults(_ result: AXError) throws { + var store = WorkspaceWindowHandleStore() + let id = try requireID( + store.retain( + element: 11, application: app, ordinal: 2, policy: .windowLayout, equal: ==)) + store.recordProbe(result, for: id) + #expect(store[id]?.element == 11) + #expect(store[id]?.ordinal == 2) + #expect(store.entries.count == 1) + #expect(store.nextWindowLayoutProbeCandidates().map(\.id) == [id]) + #expect(store.retain(element: 11, application: app, ordinal: 3, policy: .windowLayout, equal: ==) == id) + #expect(store[id]?.ordinal == 3) + #expect(store.windowLayoutCount == 1) + #expect(store.nextWindowLayoutProbeCandidates().map(\.id) == [id]) + } + + @Test("Only a confirmed invalid element is removed, and stale results cannot remove its replacement") + func invalidProbeAndRecreation() throws { + var store = WorkspaceWindowHandleStore() + let oldID = try requireID( + store.retain( + element: 12, application: app, ordinal: 1, policy: .windowLayout, equal: ==)) + let survivor = try requireID( + store.retain( + element: 13, application: app, ordinal: 2, policy: .windowLayout, equal: ==)) + store.recordProbe(.invalidUIElement, for: oldID) + #expect(store[oldID] == nil) + #expect(store[survivor] != nil) + #expect(store.nextWindowLayoutProbeCandidates().map(\.id) == [survivor]) + let replacement = try requireID( + store.retain( + element: 12, application: app, ordinal: 1, policy: .windowLayout, equal: ==)) + #expect(replacement != oldID) + store.recordProbe(.invalidUIElement, for: oldID) + #expect(store[replacement]?.element == 12) + #expect(store[survivor]?.element == 13) + } + + @Test("Probe batches are bounded and fair while a partial pass resumes at the next unprobed handle") + func boundedProbeRotation() throws { + var store = WorkspaceWindowHandleStore() + let workspaceID = try requireID( + store.retain( + element: -1, application: app, ordinal: 1, policy: .workspaceRestore, equal: ==)) + var ids: [WorkspaceWindowID] = [] + for element in 0..<13 { + ids.append( + try requireID( + store.retain( + element: element, application: app, ordinal: element + 1, policy: .windowLayout, equal: ==))) + } + #expect(store.windowLayoutCount == 13) + #expect(store.nextWindowLayoutProbeCandidates(limit: 0).isEmpty) + #expect(store.nextWindowLayoutProbeCandidates(limit: 1).map(\.id) == [ids[0]]) + #expect(store.nextWindowLayoutProbeCandidates().map(\.id) == Array(ids[1...4])) + var seen: Set = [] + var largestBatch = 0 + for _ in 0..<4 { + let candidates = store.nextWindowLayoutProbeCandidates() + largestBatch = max(largestBatch, candidates.count) + #expect(Set(candidates.map(\.id)).count == candidates.count) + seen.formUnion(candidates.map(\.id)) + } + #expect(largestBatch == 4) + #expect(seen == Set(ids)) + #expect(!seen.contains(workspaceID)) + store.remove(ids[5]) + let remaining = store.nextWindowLayoutProbeCandidates(limit: 100) + #expect(remaining.count == 12) + #expect(Set(remaining.map(\.id)) == Set(ids.filter { $0 != ids[5] })) + } + + @Test("The shared cap preserves existing identities across both policies and refuses new valid handles") + func sharedCap() throws { + var store = WorkspaceWindowHandleStore() + var layoutIDs: [WorkspaceWindowID] = [] + var workspaceIDs: [WorkspaceWindowID] = [] + for element in 0..<1_000 { + workspaceIDs.append( + try requireID( + store.retain( + element: element, application: app, ordinal: element + 1, policy: .workspaceRestore, equal: ==)) + ) + layoutIDs.append( + try requireID( + store.retain( + element: element, application: app, ordinal: element + 1, policy: .windowLayout, equal: ==))) + } + #expect(store.entries.count == 2_000) + #expect(store.windowLayoutCount == 1_000) + #expect( + store.retain( + element: 1_001, application: app, ordinal: 1, policy: .windowLayout, equal: ==) == nil) + #expect( + store.retain( + element: 1_001, application: app, ordinal: 1, policy: .workspaceRestore, equal: ==) == nil) + #expect( + store.retain( + element: 0, application: app, ordinal: 99, policy: .windowLayout, equal: ==) == layoutIDs[0]) + #expect( + store.retain( + element: 0, application: app, ordinal: 88, policy: .workspaceRestore, equal: ==) == workspaceIDs[0]) + #expect(store[layoutIDs[0]]?.ordinal == 99) + #expect(store[workspaceIDs[0]]?.ordinal == 88) + #expect(store.entries.count == 2_000) + store.remove(layoutIDs[999]) + let replacement = try requireID( + store.retain( + element: 1_001, application: app, ordinal: 1, policy: .windowLayout, equal: ==)) + #expect(store[replacement]?.element == 1_001) + #expect(store[workspaceIDs[999]]?.element == 999) + #expect(store.entries.count == 2_000) + } + + @Test("Workspace enumeration prunes only its own missing handles for the selected app") + func policyAndEnumerationIsolation() throws { + var store = WorkspaceWindowHandleStore() + let otherApp = WorkspaceApplication( + pid: 705, bundleID: "test.other-handles", name: "Other", launchDate: app.launchDate) + let workspace = try requireID( + store.retain( + element: 7, application: app, ordinal: 1, policy: .workspaceRestore, equal: ==)) + let layout = try requireID( + store.retain( + element: 7, application: app, ordinal: 1, policy: .windowLayout, equal: ==)) + let workspaceSurvivor = try requireID( + store.retain( + element: 8, application: app, ordinal: 2, policy: .workspaceRestore, equal: ==)) + let otherWorkspace = try requireID( + store.retain( + element: 7, application: otherApp, ordinal: 1, policy: .workspaceRestore, equal: ==)) + let otherLayout = try requireID( + store.retain( + element: 7, application: otherApp, ordinal: 1, policy: .windowLayout, equal: ==)) + #expect(workspace != layout) + #expect(layout != otherLayout) + #expect(store[workspace]?.policy == .workspaceRestore) + #expect(store[layout]?.policy == .windowLayout) + store.retainWorkspaceWindows(in: app, elements: [8], equal: ==) + #expect(store[workspace] == nil) + #expect(store[workspaceSurvivor]?.element == 8) + #expect(store[layout]?.element == 7) + #expect(store[otherWorkspace]?.element == 7) + #expect(store[otherLayout]?.element == 7) + store.retainWorkspaceWindows(in: app, elements: [], equal: ==) + #expect(store[workspaceSurvivor] == nil) + #expect(Set(store.nextWindowLayoutProbeCandidates().map(\.id)) == [layout, otherLayout]) + #expect(store[otherWorkspace] != nil) + } + + @Test("Explicit stale processes are removed by PID, bundle and launch date without deleting newer or live handles") + func processIdentityPruning() throws { + var store = WorkspaceWindowHandleStore() + let newLaunch = WorkspaceApplication( + pid: app.pid, bundleID: app.bundleID, name: "New launch", + launchDate: app.launchDate.addingTimeInterval(60)) + let changedBundle = WorkspaceApplication( + pid: app.pid, bundleID: "test.reused-pid", name: "Reused PID", launchDate: app.launchDate) + let changedPID = WorkspaceApplication( + pid: 707, bundleID: app.bundleID, name: app.name, launchDate: app.launchDate) + let renamedLive = WorkspaceApplication( + pid: 706, bundleID: "test.live", name: "Renamed live app", launchDate: app.launchDate) + let oldLayout = try requireID( + store.retain( + element: 1, application: app, ordinal: 1, policy: .windowLayout, equal: ==)) + let oldWorkspace = try requireID( + store.retain( + element: 1, application: app, ordinal: 1, policy: .workspaceRestore, equal: ==)) + let newer = try requireID( + store.retain( + element: 1, application: newLaunch, ordinal: 1, policy: .windowLayout, equal: ==)) + let reused = try requireID( + store.retain( + element: 1, application: changedBundle, ordinal: 1, policy: .windowLayout, equal: ==)) + let differentProcess = try requireID( + store.retain( + element: 1, application: changedPID, ordinal: 1, policy: .windowLayout, equal: ==)) + let live = try requireID( + store.retain( + element: 1, application: renamedLive, ordinal: 1, policy: .windowLayout, equal: ==)) + let renamedStaleSnapshot = WorkspaceApplication( + pid: app.pid, bundleID: app.bundleID, name: "Old process with a different name", launchDate: app.launchDate) + store.removeProcesses([renamedStaleSnapshot]) + #expect(store[oldLayout] == nil) + #expect(store[oldWorkspace] == nil) + #expect(store[newer]?.application == newLaunch) + #expect(store[reused]?.application == changedBundle) + #expect(store[differentProcess]?.application == changedPID) + #expect(store[live]?.application == renamedLive) + #expect(Set(store.nextWindowLayoutProbeCandidates().map(\.id)) == [newer, reused, differentProcess, live]) + store.removeProcesses([]) + #expect(store.entries.count == 4) + } + + @Test("Shutdown empties both handle policies and resets the probe queue") + func shutdownClearsQueue() throws { + var store = WorkspaceWindowHandleStore() + let oldLayout = try requireID( + store.retain( + element: 4, application: app, ordinal: 1, policy: .windowLayout, equal: ==)) + _ = try requireID( + store.retain( + element: 5, application: app, ordinal: 1, policy: .workspaceRestore, equal: ==)) + #expect(store.nextWindowLayoutProbeCandidates(limit: 1).map(\.id) == [oldLayout]) + store.removeAll() + #expect(store.entries.isEmpty) + #expect(store.windowLayoutCount == 0) + #expect(store.nextWindowLayoutProbeCandidates().isEmpty) + let newLayout = try requireID( + store.retain( + element: 4, application: app, ordinal: 1, policy: .windowLayout, equal: ==)) + #expect(newLayout != oldLayout) + #expect(store.nextWindowLayoutProbeCandidates().map(\.id) == [newLayout]) + store.recordProbe(.invalidUIElement, for: oldLayout) + #expect(store[newLayout]?.element == 4) + } +} From 6ce2ad30068a71df9a246e707241164cdfda261e Mon Sep 17 00:00:00 2001 From: Nihar <117209695+niharnm@users.noreply.github.com> Date: Wed, 9 Sep 2026 13:25:52 -0700 Subject: [PATCH 2/2] Mark Shelf image copying integrated in product docs --- README.md | 2 +- ROADMAP.md | 11 ++++++----- guide/product-status.md | 24 +++++++++++------------- 3 files changed, 18 insertions(+), 19 deletions(-) diff --git a/README.md b/README.md index aa2999a..d7cac46 100644 --- a/README.md +++ b/README.md @@ -60,7 +60,7 @@ not download a Semper DMG from an unofficial source. - **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 Image Copies**: File Shelf's staged Resize a Copy action saves one local JPEG or PNG at up to 1,024 or 2,048 pixels on its longest edge without enlargement or overwriting a file. It removes descriptive metadata and requires a destination that supports macOS file cloning. See the [image-copy guide](guide/shelf-image-copy.md) for format, size, recovery, and native acceptance limits. +- **Local Image Copies**: File Shelf's Resize a Copy action saves one local JPEG or PNG at up to 1,024 or 2,048 pixels on its longest edge without enlargement or overwriting a file. It removes descriptive metadata and requires a destination that supports macOS file cloning. See the [image-copy guide](guide/shelf-image-copy.md) for format, size, recovery, and native acceptance limits. - **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. diff --git a/ROADMAP.md b/ROADMAP.md index 0c07ddb..036d129 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,8 +2,8 @@ Semper has ten utility modules integrated on `main`: Sound, Awake, Displays, Workspace Restore, Window Layout, File Shelf, Safe Eject, Scenes, Away, and -Presentation. Resize a Copy extends File Shelf in this change set and awaits -integration. The downloadable release is v1.0.0, which contains Sound only. +Presentation. File Shelf includes Resize a Copy. 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). @@ -66,8 +66,9 @@ Native acceptance remains open. See the [Window Layout guide](guide/window-layou restarts. - File Shelf and Safe Eject: behavior improvements from reproducible reports, keeping original files and volumes safe. -- Complete integration and native acceptance of File Shelf's **Resize a Copy**, - implemented in this change set. It resizes one local JPEG or PNG to a longest +- Complete native acceptance of File Shelf's **Resize a Copy**, integrated + through [PR #111](https://github.com/niharnm/Semper/pull/111). + It resizes one local JPEG or PNG to a longest edge of 1024 or 2048 pixels without enlargement or overwriting a file. Format, displayed orientation, color profile and PNG transparency are kept; descriptive metadata is removed. JPEG re-encoding can lose detail. @@ -133,7 +134,7 @@ discussion and include a hardware test plan. Homebrew are current. - Integrated on `main` and in no download yet: Awake, Displays, Workspace Restore, Window Layout, File Shelf, Safe Eject, Scenes, Away, and Presentation. -- Implemented in this change set, awaiting integration: File Shelf's Resize a Copy. +- File Shelf's Resize a Copy is integrated on `main` and is not in v1.0.0. - Hardware-dependent: process taps, device routing, DDC, Bluetooth call mode, media keys, Accessibility window operations, volume ejection, and permission behavior. diff --git a/guide/product-status.md b/guide/product-status.md index 200e2c1..d76e0b7 100644 --- a/guide/product-status.md +++ b/guide/product-status.md @@ -4,11 +4,10 @@ Semper has ten utility modules integrated on `main`. 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: baseline `main` at `f3e278d`, 2026-09-09, including Window Layout and -website alignment from [PR #110](https://github.com/niharnm/Semper/pull/110). -Cleared Resize a Copy source `2db9a4a` is staged with that baseline at `a575a87`; -its integration requires this full change set to merge into `main`. Latest -downloadable release: v1.0.0, published 2026-08-26, containing Sound only. +Snapshot: `main` at `ced1a2b`, 2026-09-09, including Resize a Copy and website +alignment from [PR #111](https://github.com/niharnm/Semper/pull/111), following +Window Layout in [PR #110](https://github.com/niharnm/Semper/pull/110). +Latest downloadable release: v1.0.0, published 2026-08-26, containing Sound only. ## States @@ -30,7 +29,7 @@ downloadable release: v1.0.0, published 2026-08-26, containing Sound only. | 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 | Integrated | 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) | +| File Shelf | Hold temporary items between apps and resize local image copies | Integrated | Shared gates, plus drop-source, missing-file, persistence and image-copy checks below | [Source](../Semper/Shelf), [guide](direct-utilities.md), [image copies](shelf-image-copy.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) | @@ -46,21 +45,20 @@ the shared release gates remain separate. | 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 +## Integrated File Shelf feature -This feature extends File Shelf in the staged source snapshot. It is outside -the baseline `main` snapshot and is not released. It adds no new module. +This feature extends File Shelf in the `main` snapshot above. It is not +released and adds no new module. | Increment | State | Remaining acceptance | | --- | --- | --- | -| File Shelf Resize a Copy | Implemented in this change set, [PR #109](https://github.com/niharnm/Semper/pull/109) | Combined verification and source merge; native Save, keyboard/VoiceOver, cancellation, recovery and destination compatibility remain separate release gates | +| File Shelf Resize a Copy | Integrated, [PR #111](https://github.com/niharnm/Semper/pull/111) | Native Save, keyboard/VoiceOver, cancellation, recovery, destination compatibility and shared release gates | ## Window Layout Source `0f25f65` adds left half, right half, maximize, center and previous-placement restore with optional shortcuts and Home/search/pinned -actions. It is integrated through PR #110 at `f3e278d`; both the baseline main -and staged source snapshots contain ten modules. +actions. It is integrated through PR #110 at `f3e278d`. 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 @@ -78,7 +76,7 @@ permission, real-window or hardware acceptance. See the ## File Shelf Resize a Copy -Cleared source `2db9a4a` is included in this change set. Select one fully +Source `2db9a4a` is integrated through PR #111 at `ced1a2b`. Select one fully downloaded local JPEG or PNG, review dimensions for a longest edge of 1,024 or 2,048 pixels, then save a separate copy. Images are not enlarged. The source format, displayed orientation, color profile and PNG transparency are kept;