Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
11 changes: 6 additions & 5 deletions ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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.
Expand Down
88 changes: 49 additions & 39 deletions Semper/Workspace/WorkspaceWindowBackend.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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<AXUIElement>()
private let messageTimeout: Float = 0.15
private var currentDisplays: [WorkspaceDisplay] = []

Expand Down Expand Up @@ -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()
Expand All @@ -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 {
Expand All @@ -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 }
}
Expand All @@ -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)
Expand All @@ -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..<min(4, handles.windowLayoutCount) {
try Task.checkCancellation()
guard ContinuousClock.now < deadline else { break }
guard let candidate = handles.nextWindowLayoutProbeCandidates(limit: 1).first else { break }
var role: CFTypeRef?
let result = AXUIElementCopyAttributeValue(candidate.element, kAXRoleAttribute as CFString, &role)
handles.recordProbe(result, for: candidate.id)
}
guard
let id = handles.retain(
element: window, application: application, ordinal: 1, policy: .windowLayout, equal: CFEqual)
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
let sameProcess = await Self.processMatches(application)
guard sameProcess == true else {
if sameProcess == false { handles.removeProcesses([application]) }
return nil
}
try Task.checkCancellation()
Expand All @@ -177,7 +169,12 @@ actor AccessibilityWorkspaceBackend: WindowLayoutWindowBackend {
func current(_ id: WorkspaceWindowID) async throws -> 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
Expand Down Expand Up @@ -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
Expand All @@ -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
}
Expand Down
76 changes: 76 additions & 0 deletions Semper/Workspace/WorkspaceWindowHandleStore.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import ApplicationServices
import Foundation

nonisolated struct WorkspaceWindowHandleStore<Element> {
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()
}
}
Loading
Loading