diff --git a/CHANGELOG.md b/CHANGELOG.md index 7712548..bd00679 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,12 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) - Improved portal visual editor - Configuration profile import/export +## [1.1.0] - 2026-08-31 + +### Added +- Window layout snapshots and lock-screen recovery for standard application windows. +- Display-canvas previews with switchable automatic and manually saved layouts. + ## [1.0.1] - 2026-07-24 ### Fixed diff --git a/MousePortal/AppDelegate.swift b/MousePortal/AppDelegate.swift index 2fc31dd..b44c7d9 100644 --- a/MousePortal/AppDelegate.swift +++ b/MousePortal/AppDelegate.swift @@ -12,6 +12,7 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate { private var hotkeyService: HotkeyService? private var portalService: PortalService? private let permissionService = PermissionService.shared + private let windowLayoutService = WindowLayoutService.shared private var hotkeyConfigStore = HotkeyConfigStore.shared private var cancellables: Set = [] @@ -54,6 +55,13 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate { self?.updateAccessibilityStatusItem() } .store(in: &cancellables) + + windowLayoutService.$isEnabled + .receive(on: RunLoop.main) + .sink { [weak self] _ in + self?.setupMenu() + } + .store(in: &cancellables) } func applicationDidBecomeActive(_ notification: Notification) { @@ -121,6 +129,14 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate { portalItem.state = (portalService?.isRunning ?? false) ? .on : .off menu.addItem(portalItem) + let windowRecoveryItem = NSMenuItem( + title: L("window_recovery.enabled"), + action: #selector(toggleWindowRecovery), + keyEquivalent: "" + ) + windowRecoveryItem.state = windowLayoutService.isEnabled ? .on : .off + menu.addItem(windowRecoveryItem) + menu.addItem(NSMenuItem.separator()) let accessibilityItem = NSMenuItem(title: accessibilityStatusTitle, action: nil, keyEquivalent: "") @@ -159,6 +175,7 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate { // 传送门默认启动 portalService?.start() + windowLayoutService.startMonitoring() } private func terminateOtherInstancesIfNeeded() { @@ -214,6 +231,16 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate { setupMenu() } + @objc private func toggleWindowRecovery() { + if !windowLayoutService.isEnabled && !permissionService.checkAccessibility() { + permissionService.requestAccessibility() + return + } + + windowLayoutService.setEnabled(!windowLayoutService.isEnabled) + setupMenu() + } + // 传递 SwiftUI 环境的 openWindow,可用于直接打开特定 Window var openWindowAction: OpenWindowAction? @@ -248,6 +275,7 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate { @objc private func quitApp() { hotkeyService?.stop() portalService?.stop() + windowLayoutService.stopMonitoring() NSApplication.shared.terminate(nil) } diff --git a/MousePortal/AppVersion.swift b/MousePortal/AppVersion.swift index ca62d2e..56f483e 100644 --- a/MousePortal/AppVersion.swift +++ b/MousePortal/AppVersion.swift @@ -1,8 +1,8 @@ import Foundation enum AppVersion { - static let current = "1.0.1" - static let build = "2" + static let current = "1.1.0" + static let build = "20" static var displayVersion: String { if let version = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String, diff --git a/MousePortal/ContentView.swift b/MousePortal/ContentView.swift index 6e21a6f..f07e43b 100644 --- a/MousePortal/ContentView.swift +++ b/MousePortal/ContentView.swift @@ -6,6 +6,49 @@ private enum HomeSurface { static let separator = Color.primary.opacity(0.08) } +private struct SidebarResizeHandle: View { + @Binding var width: Double + let onResizeEnded: (Double) -> Void + @State private var dragStartWidth: Double? + @State private var isHovered = false + @State private var isDragging = false + + var body: some View { + Rectangle() + .fill(isHovered || isDragging ? Color.accentColor.opacity(0.28) : HomeSurface.separator) + .frame(width: 1) + .overlay { + Color.clear + .frame(width: 7) + .contentShape(Rectangle()) + .gesture( + DragGesture(minimumDistance: 0, coordinateSpace: .global) + .onChanged { value in + if dragStartWidth == nil { + dragStartWidth = width + isDragging = true + NSCursor.resizeLeftRight.set() + } + let initialWidth = dragStartWidth ?? width + width = min(max(initialWidth + Double(value.translation.width), 220), 360) + } + .onEnded { _ in + dragStartWidth = nil + isDragging = false + onResizeEnded(width) + (isHovered ? NSCursor.resizeLeftRight : NSCursor.arrow).set() + } + ) + .onHover { hovering in + isHovered = hovering + if !isDragging { + (hovering ? NSCursor.resizeLeftRight : NSCursor.arrow).set() + } + } + } + } +} + struct ContentView: View { @ObservedObject private var displayService = DisplayService.shared @ObservedObject private var portalService = PortalService.shared @@ -13,6 +56,7 @@ struct ContentView: View { @ObservedObject private var permissionService = PermissionService.shared @ObservedObject private var hotkeyConfigStore = HotkeyConfigStore.shared @ObservedObject private var layoutService = DisplayLayoutService.shared + @ObservedObject private var windowLayoutService = WindowLayoutService.shared @ObservedObject private var languageService = LanguageService.shared @State private var selectedLayoutID: UUID? @@ -22,16 +66,22 @@ struct ContentView: View { @State private var renamingDisplayKey: DisplayLayoutKey? @State private var displayRenameText = "" @State private var editingPortal: PortalPair? + @State private var selectedWindowSnapshotID: UUID? + @State private var showsWindowLayout = false + @State private var renamingWindowSnapshot: WindowLayoutSnapshot? + @State private var windowSnapshotRenameText = "" + @State private var sidebarWidth = + (UserDefaults.standard.object(forKey: "mainSidebarWidth") as? NSNumber)?.doubleValue ?? 220.0 // 绘制状态 @State private var drawingSession = PortalDrawingSession() private let portalColors: [PortalColor] = PortalColor.allCases - private let topBarHeight: CGFloat = 64 + private let topBarHeight: CGFloat = 92 @State private var nextColorIndex = 0 var body: some View { - HSplitView { + HStack(spacing: 0) { // 左侧:显示器排列列表 VStack(alignment: .leading, spacing: 0) { // 标题栏 @@ -43,7 +93,9 @@ struct ContentView: View { Image(systemName: "trash") } .buttonStyle(.borderless) - .disabled(!layoutService.hasUnusedLayouts) + .disabled(!layoutService.hasUnusedLayouts( + preserving: savedWindowLayoutSignatures + )) .help(L("layout.cleanup_unused")) .accessibilityLabel(L("layout.cleanup_unused")) } @@ -51,38 +103,77 @@ struct ContentView: View { .frame(height: topBarHeight) // 排列列表 - List(selection: $selectedLayoutID) { + List { ForEach(layoutService.layouts) { layout in - LayoutRowView( - layout: layout, - isSelected: selectedLayoutID == layout.id, - isCurrent: layoutService.currentLayoutID == layout.id, - canDelete: LayoutSidebarRules.canDeleteLayout( - layoutCount: layoutService.layouts.count, - currentLayoutID: layoutService.currentLayoutID, - targetLayoutID: layout.id, - isLocked: layout.isLocked - ), - deleteHelpText: LayoutSidebarRules.deleteDisabledHelpText( - layoutCount: layoutService.layouts.count, - currentLayoutID: layoutService.currentLayoutID, - targetLayoutID: layout.id, - isLocked: layout.isLocked - ), - onToggleLock: { - layoutService.toggleLayoutLock(layout) - }, - onRename: { - openRenameSheet(for: layout) - }, - onDelete: { - requestDeleteLayoutFromSidebar(layout) + let savedSnapshots = windowLayoutService.snapshots(matching: layout.signature) + let snapshots = windowLayoutService.isEnabled + ? savedSnapshots + : [] + + VStack(alignment: .leading, spacing: 3) { + LayoutRowView( + layout: layout, + isSelected: selectedLayoutID == layout.id, + isCurrent: layoutService.currentLayoutID == layout.id, + canDelete: LayoutSidebarRules.canDeleteLayout( + layoutCount: layoutService.layouts.count, + currentLayoutID: layoutService.currentLayoutID, + targetLayoutID: layout.id, + windowSnapshotCount: savedSnapshots.count, + isLocked: layout.isLocked + ), + deleteHelpText: LayoutSidebarRules.deleteDisabledHelpText( + layoutCount: layoutService.layouts.count, + currentLayoutID: layoutService.currentLayoutID, + targetLayoutID: layout.id, + windowSnapshotCount: savedSnapshots.count, + isLocked: layout.isLocked + ), + onToggleLock: { + layoutService.toggleLayoutLock(layout) + }, + onRename: { + openRenameSheet(for: layout) + }, + onDelete: { + requestDeleteLayoutFromSidebar(layout) + }, + onSelect: { + selectedLayoutID = layout.id + } + ) + + ForEach(snapshots) { snapshot in + WindowLayoutSnapshotSidebarRow( + title: windowSnapshotTitle(for: snapshot, in: snapshots), + snapshot: snapshot, + isSelected: selectedWindowSnapshotID == snapshot.id, + onSelect: { + selectWindowSnapshot(snapshot, in: layout) + }, + onPromote: { + promoteWindowSnapshot(snapshot, in: layout) + }, + onRestore: { + restoreWindowSnapshot(snapshot, in: layout) + }, + onRename: { + openRenameWindowSnapshot( + snapshot, + in: layout, + matching: snapshots + ) + }, + onDelete: { + deleteWindowSnapshot(snapshot) + } + ) } - ) - .tag(layout.id) + } + .listRowInsets(EdgeInsets(top: 3, leading: 0, bottom: 3, trailing: 0)) } } - .listStyle(.sidebar) + .listStyle(.plain) .scrollContentBackground(.hidden) Divider() @@ -94,79 +185,115 @@ struct ContentView: View { .foregroundColor(.secondary) .padding(8) } - .frame(minWidth: 180, maxWidth: 250) + .frame(width: CGFloat(min(max(sidebarWidth, 220), 360))) .background(HomeSurface.background.overlay(HomeSurface.sidebarOverlay)) + SidebarResizeHandle(width: $sidebarWidth) { finalWidth in + UserDefaults.standard.set(finalWidth, forKey: "mainSidebarWidth") + } + // 右侧:主内容区域 VStack(spacing: 0) { // 顶部工具栏 - HStack { - // 左侧:排列信息和绘制按钮 - VStack(alignment: .leading, spacing: 2) { - Text(selectedLayout?.displayName ?? L("layout.none")) - .font(.headline) - Text(selectedLayout?.layoutDescription ?? "") - .font(.caption) - .foregroundColor(.secondary) - } + VStack(spacing: 8) { + HStack(alignment: .center, spacing: 12) { + // 左侧:排列信息和绘制按钮 + VStack(alignment: .leading, spacing: 2) { + Text(selectedLayout?.displayName ?? L("layout.none")) + .font(.headline) + Text(selectedLayout?.layoutDescription ?? "") + .font(.caption) + .foregroundColor(.secondary) + } - Spacer() + Spacer(minLength: 12) - // 绘制模式指示 - if drawingSession.isDrawingMode { - Text(drawingSession.stepInstructions) - .font(.callout) - .foregroundColor(drawingSession.hasValidationError ? .red : .secondary) + // 绘制模式指示 + if drawingSession.isDrawingMode { + Text(drawingSession.stepInstructions) + .font(.callout) + .foregroundColor(drawingSession.hasValidationError ? .red : .secondary) - Button(action: { - drawingSession.cancelDrawing() - }) { - Label(L("button.cancel"), systemImage: "xmark.circle") - } - .buttonStyle(.borderedProminent) - .tint(.red) - } else if isCurrentLayout { - Button(action: { - drawingSession.startDrawing() - }) { - Label(L("portal.add"), systemImage: "plus.circle") + Button(action: { + drawingSession.cancelDrawing() + }) { + Label(L("button.cancel"), systemImage: "xmark.circle") + } + .buttonStyle(.borderedProminent) + .tint(.red) + } else if isCurrentLayout { + Button(action: saveCurrentWindowLayout) { + Label(L("window_recovery.save"), systemImage: "macwindow.badge.plus") + } + .buttonStyle(.bordered) + .disabled(!windowLayoutService.isEnabled) + .help(saveWindowLayoutHelp) + + Button(action: { + drawingSession.startDrawing() + }) { + Label(L("portal.add"), systemImage: "plus.circle") + } + .buttonStyle(.borderedProminent) } - .buttonStyle(.borderedProminent) - } - // 状态指示 - HStack(spacing: 12) { - StatusIndicator( - isEnabled: hotkeyService.isRunning, - label: L("status.hotkey") - ) - - StatusIndicator( - isEnabled: portalService.isRunning, - label: L("status.portal") - ) - } + // 状态指示 + HStack(spacing: 12) { + StatusIndicator( + isEnabled: hotkeyService.isRunning, + label: L("status.hotkey") + ) - if #available(macOS 14.0, *) { - SettingsLink { - Image(systemName: "gear") + StatusIndicator( + isEnabled: portalService.isRunning, + label: L("status.portal") + ) } - .buttonStyle(.bordered) - } else { - Button(action: { - AppDelegate.shared?.openSettings() - }) { - Image(systemName: "gear") + + if #available(macOS 14.0, *) { + SettingsLink { + Image(systemName: "gear") + } + .buttonStyle(.bordered) + } else { + Button(action: { + AppDelegate.shared?.openSettings() + }) { + Image(systemName: "gear") + } + .buttonStyle(.bordered) } - .buttonStyle(.bordered) + + Button(action: { displayService.fetchDisplays() }) { + Image(systemName: "arrow.clockwise") + } + .help(L("button.refresh")) } - Button(action: { displayService.fetchDisplays() }) { - Image(systemName: "arrow.clockwise") + HStack(spacing: 16) { + Spacer() + + Toggle(L("window_layout.show"), isOn: Binding( + get: { showsWindowLayout }, + set: { isShown in + showsWindowLayout = isShown + if isShown { + synchronizeWindowSnapshotSelection() + } + } + )) + .toggleStyle(.switch) + .fixedSize() + .disabled(!windowLayoutService.isEnabled || matchingWindowSnapshots.isEmpty) + .help(windowLayoutToggleHelp) + + Toggle(L("settings.enable_hotkeys"), isOn: hotkeyJumpEnabledBinding) + .toggleStyle(.switch) + .fixedSize() } - .help(L("button.refresh")) } - .padding() + .padding(.horizontal) + .padding(.vertical, 8) .frame(height: topBarHeight) // 主画布区域 - 显示器布局 + 传送门绘制 @@ -177,6 +304,13 @@ struct ContentView: View { portals: selectedPortals, drawingSession: drawingSession, tempPortalColor: portalColors[nextColorIndex % portalColors.count], + windowSnapshot: showsWindowLayout ? selectedWindowSnapshot : nil, + hotkeyConfigs: isCurrentLayout && hotkeyConfigStore.globalEnabled + ? $hotkeyConfigStore.configs + : nil, + onHotkeySave: { + hotkeyConfigStore.save() + }, onDisplayRename: { display in openRenameDisplaySheet(for: display) }, @@ -234,62 +368,23 @@ struct ContentView: View { } .listStyle(.inset) .scrollContentBackground(.hidden) - .frame(maxHeight: 150) + .scrollIndicators(selectedPortals.count > 4 ? .visible : .hidden) + .frame(height: portalListHeight) } Divider() .overlay(HomeSurface.separator) - // 底部:图例和快捷键设置 - VStack(spacing: 12) { - // 图例 - HStack(spacing: 20) { - LegendItem(color: .blue, label: languageService.localizedString("legend.main_display")) - LegendItem(color: .gray, label: languageService.localizedString("legend.external_display")) - LegendItem(color: .green, label: languageService.localizedString("legend.shared_edge"), isLine: true) - PortalColorsLegend( - portals: selectedPortals, - label: languageService.localizedString("legend.portal") - ) - Spacer() - } - - Divider() - .overlay(HomeSurface.separator) - - // 快捷键设置区域 - HStack { - Text(L("settings.enable_hotkeys")) - .font(.headline) - - Toggle("", isOn: Binding( - get: { hotkeyConfigStore.globalEnabled }, - set: { newValue in - if newValue && !permissionService.isAccessibilityGranted { - permissionService.requestAccessibility() - } else { - hotkeyConfigStore.globalEnabled = newValue - hotkeyConfigStore.save() - if newValue { - hotkeyService.start() - } else { - hotkeyService.stop() - } - } - } - )) - .toggleStyle(.switch) - .labelsHidden() - - Spacer() - - // 显示各屏幕快捷键 - ForEach(hotkeyConfigStore.configs.indices, id: \.self) { index in - HotkeyBadge(config: $hotkeyConfigStore.configs[index], onSave: { - hotkeyConfigStore.save() - }) - } - } + // 底部图例 + HStack(spacing: 20) { + LegendItem(color: .blue, label: languageService.localizedString("legend.main_display")) + LegendItem(color: .gray, label: languageService.localizedString("legend.external_display")) + LegendItem(color: .green, label: languageService.localizedString("legend.shared_edge"), isLine: true) + PortalColorsLegend( + portals: selectedPortals, + label: languageService.localizedString("legend.portal") + ) + Spacer() } .padding() } @@ -301,27 +396,51 @@ struct ContentView: View { displayService.fetchDisplays() // 只在首次加载时设置 selectedLayoutID if selectedLayoutID == nil { - let layout = layoutService.matchOrCreateLayout(for: displayService.displays) + let layout = layoutService.matchOrCreateLayout( + for: displayService.displays, + preserving: savedWindowLayoutSignatures + ) selectedLayoutID = layout.id hotkeyConfigStore.initializeDefaults(from: layout.displaysApplyingCustomNames(to: displayService.displays)) } else { // 确保 currentLayoutID 正确更新 - let layout = layoutService.matchOrCreateLayout(for: displayService.displays) + let layout = layoutService.matchOrCreateLayout( + for: displayService.displays, + preserving: savedWindowLayoutSignatures + ) hotkeyConfigStore.initializeDefaults(from: layout.displaysApplyingCustomNames(to: displayService.displays)) } checkPermissionOnLaunch() + synchronizeWindowSnapshotSelection() } .onReceive(NotificationCenter.default.publisher(for: NSApplication.didChangeScreenParametersNotification)) { _ in displayService.fetchDisplays() hotkeyConfigStore.initializeDefaults(from: displayService.displays) // 使用稳定性检测,避免唤醒时产生大量中间状态的排列 - layoutService.handleDisplayChange(displays: displayService.displays) { layout in + layoutService.handleDisplayChange( + displays: displayService.displays, + preserving: savedWindowLayoutSignatures + ) { layout in hotkeyConfigStore.initializeDefaults(from: layout.displaysApplyingCustomNames(to: displayService.displays)) } } .onReceive(NotificationCenter.default.publisher(for: .languageDidChange)) { _ in refreshHotkeyDisplayNames() } + .onReceive(windowLayoutService.$snapshots) { _ in + synchronizeWindowSnapshotSelection() + } + .onReceive(windowLayoutService.$isEnabled) { isEnabled in + if isEnabled { + synchronizeWindowSnapshotSelection() + } else { + selectedWindowSnapshotID = nil + showsWindowLayout = false + } + } + .onChange(of: selectedLayoutID) { _ in + synchronizeWindowSnapshotSelection() + } .alert( "", isPresented: Binding( @@ -379,6 +498,21 @@ struct ContentView: View { } ) } + .sheet(item: $renamingWindowSnapshot) { snapshot in + RenameWindowLayoutSheet( + name: $windowSnapshotRenameText, + onSave: { + _ = windowLayoutService.renameSnapshot( + id: snapshot.id, + to: windowSnapshotRenameText + ) + renamingWindowSnapshot = nil + }, + onCancel: { + renamingWindowSnapshot = nil + } + ) + } .sheet(isPresented: isNamingPortalPresented) { PortalNameSheet( portalName: $drawingSession.portalName, @@ -411,6 +545,56 @@ struct ContentView: View { selectedLayout?.portals ?? [] } + private var portalListHeight: CGFloat { + CGFloat(min(selectedPortals.count, 4)) * 50 + 8 + } + + private var matchingWindowSnapshots: [WindowLayoutSnapshot] { + guard let signature = selectedLayout?.signature else { return [] } + return windowLayoutService.snapshots(matching: signature) + } + + private var selectedWindowSnapshot: WindowLayoutSnapshot? { + guard let selectedWindowSnapshotID else { return nil } + return matchingWindowSnapshots.first { $0.id == selectedWindowSnapshotID } + } + + private var windowLayoutToggleHelp: String { + if !windowLayoutService.isEnabled { + return L("window_recovery.enable_in_settings") + } + if matchingWindowSnapshots.isEmpty { + return L("window_layout.no_saved") + } + return L("window_layout.show_help") + } + + private var saveWindowLayoutHelp: String { + windowLayoutService.isEnabled + ? L("window_recovery.save_help") + : L("window_recovery.enable_in_settings") + } + + private var hotkeyJumpEnabledBinding: Binding { + Binding( + get: { hotkeyConfigStore.globalEnabled }, + set: { isEnabled in + if isEnabled && !permissionService.checkAccessibility() { + permissionService.requestAccessibility() + return + } + + hotkeyConfigStore.globalEnabled = isEnabled + hotkeyConfigStore.save() + if isEnabled { + hotkeyService.start() + } else { + hotkeyService.stop() + } + } + ) + } + /// 用于显示的显示器列表(当前排列用实时数据,历史排列用快照) private var displaysForView: [DisplayInfo] { if isCurrentLayout { @@ -545,6 +729,93 @@ struct ContentView: View { layoutService.addPortal(portal) } + private func saveCurrentWindowLayout() { + guard windowLayoutService.isEnabled else { return } + guard permissionService.checkAccessibility() else { + permissionService.requestAccessibility() + return + } + guard let snapshot = windowLayoutService.saveCurrentLayout() else { return } + selectedWindowSnapshotID = snapshot.id + showsWindowLayout = true + windowLayoutService.refreshAvailableApplications() + } + + private func selectWindowSnapshot(_ snapshot: WindowLayoutSnapshot, in layout: DisplayLayout) { + selectedLayoutID = layout.id + selectedWindowSnapshotID = snapshot.id + showsWindowLayout = true + } + + private func promoteWindowSnapshot(_ snapshot: WindowLayoutSnapshot, in layout: DisplayLayout) { + selectedLayoutID = layout.id + selectedWindowSnapshotID = snapshot.id + _ = windowLayoutService.promoteAutomaticSnapshot(id: snapshot.id) + } + + private func restoreWindowSnapshot(_ snapshot: WindowLayoutSnapshot, in layout: DisplayLayout) { + guard permissionService.checkAccessibility() else { + permissionService.requestAccessibility() + return + } + + selectWindowSnapshot(snapshot, in: layout) + _ = windowLayoutService.restoreSavedLayout(snapshotID: snapshot.id) + } + + private func deleteWindowSnapshot(_ snapshot: WindowLayoutSnapshot) { + _ = windowLayoutService.deleteSnapshot(id: snapshot.id) + } + + private func windowSnapshotTitle( + for snapshot: WindowLayoutSnapshot, + in snapshots: [WindowLayoutSnapshot] + ) -> String { + if let name = snapshot.name, + !name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + return name + } + + if snapshot.kind == .automatic { + return L("window_layout.automatic_before_lock") + } + + let manualSnapshots = snapshots.filter { $0.kind == .manual } + let index = manualSnapshots.firstIndex(where: { $0.id == snapshot.id }) ?? 0 + return L("window_layout.saved %lld", index + 1) + } + + private func openRenameWindowSnapshot( + _ snapshot: WindowLayoutSnapshot, + in layout: DisplayLayout, + matching snapshots: [WindowLayoutSnapshot] + ) { + selectedLayoutID = layout.id + selectedWindowSnapshotID = snapshot.id + windowSnapshotRenameText = windowSnapshotTitle(for: snapshot, in: snapshots) + renamingWindowSnapshot = snapshot + } + + private func synchronizeWindowSnapshotSelection() { + guard windowLayoutService.isEnabled else { + selectedWindowSnapshotID = nil + showsWindowLayout = false + return + } + + let snapshots = matchingWindowSnapshots + guard !snapshots.isEmpty else { + selectedWindowSnapshotID = nil + showsWindowLayout = false + return + } + + if !snapshots.contains(where: { $0.id == selectedWindowSnapshotID }) { + selectedWindowSnapshotID = snapshots.first(where: { $0.kind == .automatic })?.id + ?? snapshots.last?.id + } + } + private func openRenameSheet(for layout: DisplayLayout) { renameText = layout.displayName selectedLayoutID = layout.id @@ -575,11 +846,15 @@ struct ContentView: View { } private func requestDeleteLayoutFromSidebar(_ layout: DisplayLayout) { + let windowSnapshotCount = windowLayoutService + .snapshots(matching: layout.signature) + .count switch LayoutSidebarRules.deletionAction( layoutCount: layoutService.layouts.count, currentLayoutID: layoutService.currentLayoutID, targetLayoutID: layout.id, portalCount: layout.portals.count, + windowSnapshotCount: windowSnapshotCount, isLocked: layout.isLocked ) { case .unavailable: @@ -599,7 +874,10 @@ struct ContentView: View { currentSelectionID: selectedLayoutID, orderedLayoutIDs: layoutService.layouts.map(\.id) ) - layoutService.deleteLayout(layout) + layoutService.deleteLayout( + layout, + preserving: savedWindowLayoutSignatures + ) if selectedLayoutID == layout.id { selectedLayoutID = remainingSelection @@ -611,11 +889,17 @@ struct ContentView: View { } private func cleanUpUnusedLayouts() { - let removedIDs = layoutService.cleanUpUnusedLayouts() + let removedIDs = layoutService.cleanUpUnusedLayouts( + preserving: savedWindowLayoutSignatures + ) guard let selectedLayoutID, removedIDs.contains(selectedLayoutID) else { return } self.selectedLayoutID = layoutService.currentLayoutID ?? layoutService.layouts.first?.id } + private var savedWindowLayoutSignatures: Set { + Set(windowLayoutService.snapshots.map(\.displayLayoutSignature)) + } + private func refreshHotkeyDisplayNames() { guard let layout = layoutService.currentLayout else { return } hotkeyConfigStore.initializeDefaults(from: layout.displaysApplyingCustomNames(to: displayService.displays)) @@ -635,12 +919,15 @@ struct ContentView: View { struct HotkeyBadge: View { @Binding var config: HotkeyConfig let onSave: () -> Void + var showsDisplayName = true var body: some View { HStack(spacing: 4) { - Text(config.displayName) - .font(.caption) - .foregroundColor(.secondary) + if showsDisplayName { + Text(config.displayName) + .font(.caption) + .foregroundColor(.secondary) + } Text(config.shortcutString.isEmpty ? L("hotkey.not_set") : config.shortcutString) .font(.system(.caption, design: .monospaced)) @@ -660,10 +947,107 @@ struct HotkeyBadge: View { .scaleEffect(0.7) .labelsHidden() } - .padding(.horizontal, 8) + } +} + +/// 显示器排列下的二级窗口布局项。 +private struct WindowLayoutSnapshotSidebarRow: View { + let title: String + let snapshot: WindowLayoutSnapshot + let isSelected: Bool + let onSelect: () -> Void + let onPromote: () -> Void + let onRestore: () -> Void + let onRename: () -> Void + let onDelete: () -> Void + + var body: some View { + HStack(spacing: 6) { + Image(systemName: snapshot.kind == .automatic ? "lock.fill" : "macwindow") + .frame(width: 14) + + Text(snapshot.kind == .automatic + ? L("window_layout.kind.automatic") + : L("window_layout.kind.manual")) + .font(.system(size: 9, weight: .semibold)) + .lineLimit(1) + .fixedSize(horizontal: true, vertical: false) + .layoutPriority(2) + .foregroundColor(snapshot.kind == .automatic ? .orange : .accentColor) + .padding(.horizontal, 5) + .padding(.vertical, 2) + .background( + Capsule() + .fill((snapshot.kind == .automatic ? Color.orange : Color.accentColor).opacity(0.12)) + ) + + Text(title) + .lineLimit(1) + .fontWeight(isSelected ? .semibold : .regular) + .layoutPriority(1) + + Spacer(minLength: 2) + + if snapshot.kind == .automatic { + SnapshotActionButton( + systemImage: "arrow.up.circle", + accessibilityLabel: L("window_layout.promote_to_manual"), + tint: .orange, + action: onPromote + ) + } + + SnapshotActionButton( + systemImage: "play.fill", + accessibilityLabel: L("window_layout.apply"), + tint: .accentColor, + action: onRestore + ) + + SnapshotActionButton( + systemImage: "pencil", + accessibilityLabel: L("layout.rename"), + tint: .primary, + action: onRename + ) + + SnapshotActionButton( + systemImage: "trash", + accessibilityLabel: L("layout.delete"), + tint: .red, + action: onDelete + ) + } + .font(.caption) + .foregroundColor(isSelected ? .accentColor : .secondary) .padding(.vertical, 4) - .background(Color.secondary.opacity(0.1)) - .cornerRadius(6) + .frame(maxWidth: .infinity, alignment: .leading) + .contentShape(Rectangle()) + .onTapGesture(perform: onSelect) + .help(snapshot.capturedAt.formatted(date: .abbreviated, time: .shortened)) + } +} + +private struct SnapshotActionButton: View { + let systemImage: String + let accessibilityLabel: String + let tint: Color + let action: () -> Void + + var body: some View { + Button(action: action) { + Image(systemName: systemImage) + .font(.system(size: 10, weight: .semibold)) + .foregroundColor(tint) + .frame(width: 22, height: 22) + .background( + RoundedRectangle(cornerRadius: 6) + .fill(tint.opacity(0.1)) + ) + } + .buttonStyle(.plain) + .help(accessibilityLabel) + .accessibilityLabel(accessibilityLabel) } } @@ -754,6 +1138,7 @@ struct LayoutRowView: View { let onToggleLock: () -> Void let onRename: () -> Void let onDelete: () -> Void + let onSelect: () -> Void var body: some View { HStack { @@ -781,15 +1166,6 @@ struct LayoutRowView: View { Spacer() - if !layout.portals.isEmpty { - Text("\(layout.portals.count)") - .font(.caption) - .padding(.horizontal, 6) - .padding(.vertical, 2) - .background(Color.orange.opacity(0.2)) - .cornerRadius(4) - } - HStack(spacing: 6) { RowActionButton( systemImage: layout.isLocked ? "lock.fill" : "lock.open", @@ -814,8 +1190,14 @@ struct LayoutRowView: View { ) } } - .padding(.vertical, 4) + .padding(.horizontal, 6) + .padding(.vertical, 5) + .background( + RoundedRectangle(cornerRadius: 8) + .fill(isSelected ? Color.accentColor.opacity(0.16) : Color.clear) + ) .contentShape(Rectangle()) + .onTapGesture(perform: onSelect) } } @@ -948,6 +1330,40 @@ struct RenameLayoutSheet: View { } } +/// 重命名窗口布局对话框 +private struct RenameWindowLayoutSheet: View { + @Binding var name: String + let onSave: () -> Void + let onCancel: () -> Void + + private var canSave: Bool { + !name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + } + + var body: some View { + VStack(spacing: 20) { + Text(L("window_layout.rename_title")) + .font(.headline) + + FocusableTextField(text: $name, placeholder: L("window_layout.name_placeholder")) { + if canSave { + onSave() + } + } + .frame(width: 250, height: 24) + + HStack { + Button(L("button.cancel"), action: onCancel) + Button(L("button.save"), action: onSave) + .disabled(!canSave) + .buttonStyle(.borderedProminent) + } + } + .padding() + .frame(width: 320, height: 150) + } +} + /// 重命名显示器对话框 struct RenameDisplaySheet: View { @Binding var name: String diff --git a/MousePortal/Models/DisplayLayout.swift b/MousePortal/Models/DisplayLayout.swift index 08e10cc..6a9a858 100644 --- a/MousePortal/Models/DisplayLayout.swift +++ b/MousePortal/Models/DisplayLayout.swift @@ -343,28 +343,38 @@ class DisplayLayoutService: ObservableObject { } var hasUnusedLayouts: Bool { - layouts.contains(where: isUnusedLayout) + hasUnusedLayouts(preserving: []) } - /// 仅保留用户明确需要的排列:当前排列、已锁定排列、含传送门排列 + func hasUnusedLayouts(preserving displayLayoutSignatures: Set) -> Bool { + layouts.contains { isUnusedLayout($0, preserving: displayLayoutSignatures) } + } + + /// 仅保留用户明确需要的排列:当前排列、已锁定排列、含传送门或窗口快照的排列 @discardableResult - func cleanUpUnusedLayouts() -> [UUID] { - let removedIDs = pruneLayouts() + func cleanUpUnusedLayouts(preserving displayLayoutSignatures: Set = []) -> [UUID] { + let removedIDs = pruneLayouts(preserving: displayLayoutSignatures) if !removedIDs.isEmpty { save() } return removedIDs } - private func isUnusedLayout(_ layout: DisplayLayout) -> Bool { + private func isUnusedLayout( + _ layout: DisplayLayout, + preserving displayLayoutSignatures: Set + ) -> Bool { let isCurrent = layout.id == currentLayoutID let hasPortals = !layout.portals.isEmpty - return !isCurrent && !layout.isLocked && !hasPortals + let hasWindowSnapshots = displayLayoutSignatures.contains(layout.signature) + return !isCurrent && !layout.isLocked && !hasPortals && !hasWindowSnapshots } @discardableResult - private func pruneLayouts() -> [UUID] { - let removedIDs = layouts.filter(isUnusedLayout).map(\.id) + private func pruneLayouts(preserving displayLayoutSignatures: Set) -> [UUID] { + let removedIDs = layouts + .filter { isUnusedLayout($0, preserving: displayLayoutSignatures) } + .map(\.id) layouts.removeAll { removedIDs.contains($0.id) } return removedIDs } @@ -386,7 +396,11 @@ class DisplayLayoutService: ObservableObject { /// 处理显示器变化(带稳定性检测) /// - Parameter displays: 当前检测到的显示器列表 /// - Parameter completion: 当排列稳定后的回调,返回匹配或创建的 Layout - func handleDisplayChange(displays: [DisplayInfo], completion: ((DisplayLayout) -> Void)? = nil) { + func handleDisplayChange( + displays: [DisplayInfo], + preserving displayLayoutSignatures: Set = [], + completion: ((DisplayLayout) -> Void)? = nil + ) { let newSignature = generateSignature(for: displays) // 如果签名与上次相同,增加稳定计数 @@ -405,7 +419,10 @@ class DisplayLayoutService: ObservableObject { // 检查是否达到稳定阈值 if stableCount >= requiredStableCount { // 已稳定,执行匹配或创建 - let layout = matchOrCreateLayout(for: displays) + let layout = matchOrCreateLayout( + for: displays, + preserving: displayLayoutSignatures + ) resetStabilityState() completion?(layout) } else { @@ -414,7 +431,10 @@ class DisplayLayoutService: ObservableObject { guard let self = self else { return } // 超时后,如果有待处理的显示器,强制处理 if !self.pendingDisplays.isEmpty { - let layout = self.matchOrCreateLayout(for: self.pendingDisplays) + let layout = self.matchOrCreateLayout( + for: self.pendingDisplays, + preserving: displayLayoutSignatures + ) self.resetStabilityState() completion?(layout) } @@ -432,11 +452,14 @@ class DisplayLayoutService: ObservableObject { } /// 根据当前显示器自动匹配或创建配置(内部方法,不带稳定性检测) - func matchOrCreateLayout(for displays: [DisplayInfo]) -> DisplayLayout { + func matchOrCreateLayout( + for displays: [DisplayInfo], + preserving displayLayoutSignatures: Set = [] + ) -> DisplayLayout { // 尝试匹配现有配置 if let matchedIndex = layouts.firstIndex(where: { $0.matches(displays: displays) }) { currentLayoutID = layouts[matchedIndex].id - pruneLayouts() + pruneLayouts(preserving: displayLayoutSignatures) save() return layouts[matchedIndex] } @@ -449,7 +472,7 @@ class DisplayLayoutService: ObservableObject { ) layouts.append(layout) currentLayoutID = layout.id - pruneLayouts() + pruneLayouts(preserving: displayLayoutSignatures) save() return layouts.first(where: { $0.id == layout.id }) ?? layout @@ -474,12 +497,16 @@ class DisplayLayoutService: ObservableObject { } /// 删除排列 - func deleteLayout(_ layout: DisplayLayout) { + func deleteLayout( + _ layout: DisplayLayout, + preserving displayLayoutSignatures: Set = [] + ) { guard let existingLayout = layouts.first(where: { $0.id == layout.id }) else { return } guard LayoutSidebarRules.canDeleteLayout( layoutCount: layouts.count, currentLayoutID: currentLayoutID, targetLayoutID: existingLayout.id, + windowSnapshotCount: displayLayoutSignatures.contains(existingLayout.signature) ? 1 : 0, isLocked: existingLayout.isLocked ) else { return diff --git a/MousePortal/Models/WindowLayoutSnapshot.swift b/MousePortal/Models/WindowLayoutSnapshot.swift new file mode 100644 index 0000000..a327725 --- /dev/null +++ b/MousePortal/Models/WindowLayoutSnapshot.swift @@ -0,0 +1,143 @@ +import Foundation +import CoreGraphics + +/// 可持久化的矩形,避免直接依赖 CoreGraphics 的编码实现。 +struct WindowRect: Codable, Equatable { + let x: Double + let y: Double + let width: Double + let height: Double + + init(_ rect: CGRect) { + x = rect.origin.x + y = rect.origin.y + width = rect.width + height = rect.height + } + + var cgRect: CGRect { + CGRect(x: x, y: y, width: width, height: height) + } +} + +/// 显示器的稳定身份,不使用唤醒后可能变化的 CGDirectDisplayID。 +struct WindowDisplayIdentity: Codable, Hashable, Equatable { + let rawValue: String +} + +/// 保存窗口时对应的显示器信息。 +struct WindowDisplaySnapshot: Codable, Equatable { + let identity: WindowDisplayIdentity + let frame: WindowRect + let visibleFrame: WindowRect + let isMain: Bool + + var topologyComponent: String { + let rect = frame + return "\(identity.rawValue)@\(Int(rect.x)),\(Int(rect.y)),\(Int(rect.width))x\(Int(rect.height))\(isMain ? "M" : "")" + } +} + +/// 单个普通窗口在其所属显示器内的相对位置。 +struct WindowPlacement: Codable, Equatable { + let bundleIdentifier: String + let applicationName: String + let windowTitle: String? + let documentURL: String? + let windowIdentifier: String? + let role: String + let subrole: String + let windowIndex: Int + let displayIdentity: WindowDisplayIdentity + let relativeX: Double + let relativeY: Double + let width: Double + let height: Double +} + +enum WindowLayoutSnapshotKind: String, Codable, Equatable { + case manual + case automatic +} + +/// 一次完整的窗口布局快照。 +struct WindowLayoutSnapshot: Codable, Equatable, Identifiable { + let id: UUID + var kind: WindowLayoutSnapshotKind + var name: String? + let capturedAt: Date + let displays: [WindowDisplaySnapshot] + let windows: [WindowPlacement] + + init( + id: UUID = UUID(), + kind: WindowLayoutSnapshotKind = .manual, + name: String? = nil, + capturedAt: Date, + displays: [WindowDisplaySnapshot], + windows: [WindowPlacement] + ) { + self.id = id + self.kind = kind + self.name = name + self.capturedAt = capturedAt + self.displays = displays + self.windows = windows + } + + enum CodingKeys: String, CodingKey { + case id + case kind + case name + case capturedAt + case displays + case windows + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + id = try container.decodeIfPresent(UUID.self, forKey: .id) ?? UUID() + kind = try container.decodeIfPresent(WindowLayoutSnapshotKind.self, forKey: .kind) ?? .manual + name = try container.decodeIfPresent(String.self, forKey: .name) + capturedAt = try container.decode(Date.self, forKey: .capturedAt) + displays = try container.decode([WindowDisplaySnapshot].self, forKey: .displays) + windows = try container.decode([WindowPlacement].self, forKey: .windows) + } + + var topologySignature: String { + displays + .map(\.topologyComponent) + .sorted() + .joined(separator: "|") + } + + var displayLayoutSignature: String { + displays + .sorted { ($0.frame.x, $0.frame.y) < ($1.frame.x, $1.frame.y) } + .map { display in + let frame = display.frame + return "\(Int(frame.width))x\(Int(frame.height))@\(Int(frame.x)),\(Int(frame.y))\(display.isMain ? "M" : "")" + } + .joined(separator: "|") + } +} + +enum WindowRecoveryState: String, Equatable { + case normal + case snapshotFrozen + case waitingForDisplays + case restoring +} + +struct WindowRestoreResult: Equatable { + let restoredCount: Int + let skippedCount: Int + let failedCount: Int +} + +struct WindowApplicationOption: Identifiable, Equatable { + let bundleIdentifier: String + let name: String + + var id: String { bundleIdentifier } +} diff --git a/MousePortal/Resources/ar.lproj/Localizable.strings b/MousePortal/Resources/ar.lproj/Localizable.strings index c8a1fbd..78d7104 100644 --- a/MousePortal/Resources/ar.lproj/Localizable.strings +++ b/MousePortal/Resources/ar.lproj/Localizable.strings @@ -151,5 +151,31 @@ "layout.delete_disabled_locked" = "Locked layouts cannot be deleted"; "layout.delete_disabled_current" = "Current layout cannot be deleted"; "layout.delete_disabled_last_remaining" = "At least one layout must remain"; +"layout.delete_disabled_window_snapshots" = "احذف تخطيطات النوافذ المحفوظة أولاً"; "portal.edit" = "Edit"; "portal.delete" = "Delete"; + +/* Window Recovery */ +"settings.window_recovery" = "استعادة النوافذ"; +"window_recovery.enabled" = "تمكين استعادة النوافذ"; +"window_recovery.remember_before_sleep" = "تذكّر النوافذ قبل الإسبات أو القفل"; +"window_recovery.automatic" = "الاستعادة تلقائياً بعد تنبيه الشاشات"; +"window_recovery.behavior" = "الاستعادة بعد التنبيه"; +"window_recovery.save" = "حفظ التخطيط الحالي"; +"window_recovery.ignored_apps" = "التطبيقات المتجاهلة"; +"window_recovery.search_apps" = "البحث عن التطبيقات"; +"window_recovery.ignored_apps_description" = "فعّل تطبيقاً لاستبعاده من اللقطات المستقبلية."; +"window_recovery.limitations" = "تُدعم فقط النوافذ القياسية غير المصغّرة وغير المعروضة بملء الشاشة. لا تتم استعادة Split View أو مجموعات Stage Manager أو Spaces."; +"window_layout.show" = "إظهار تخطيط النوافذ"; +"window_layout.show_help" = "عرض النوافذ المحفوظة فوق ترتيب الشاشات."; +"window_layout.no_saved" = "لا يوجد تخطيط نوافذ محفوظ لترتيب الشاشات هذا."; +"window_layout.automatic_before_lock" = "قبل القفل"; +"window_layout.saved %lld" = "التخطيط %lld"; +"window_layout.kind.automatic" = "تلقائي"; +"window_layout.kind.manual" = "يدوي"; +"window_layout.promote_to_manual" = "تحويل إلى تخطيط يدوي"; +"window_layout.apply" = "التبديل إلى هذا التخطيط"; +"window_layout.rename_title" = "إعادة تسمية تخطيط النوافذ"; +"window_layout.name_placeholder" = "أدخل اسم تخطيط النوافذ"; +"window_recovery.enable_in_settings" = "فعّل استعادة النوافذ أولاً من الإعدادات."; +"window_recovery.save_help" = "حفظ مواضع النوافذ الحالية."; diff --git a/MousePortal/Resources/de.lproj/Localizable.strings b/MousePortal/Resources/de.lproj/Localizable.strings index 5b5f026..486e023 100644 --- a/MousePortal/Resources/de.lproj/Localizable.strings +++ b/MousePortal/Resources/de.lproj/Localizable.strings @@ -149,5 +149,31 @@ "layout.delete_disabled_locked" = "Locked layouts cannot be deleted"; "layout.delete_disabled_current" = "Current layout cannot be deleted"; "layout.delete_disabled_last_remaining" = "At least one layout must remain"; +"layout.delete_disabled_window_snapshots" = "Löschen Sie zuerst die gespeicherten Fensterlayouts"; "portal.edit" = "Edit"; "portal.delete" = "Delete"; + +/* Window Recovery */ +"settings.window_recovery" = "Fensterwiederherstellung"; +"window_recovery.enabled" = "Fensterwiederherstellung aktivieren"; +"window_recovery.remember_before_sleep" = "Fenster vor Ruhezustand oder Sperren merken"; +"window_recovery.automatic" = "Nach dem Aufwachen der Displays automatisch wiederherstellen"; +"window_recovery.behavior" = "Wiederherstellung nach Ruhezustand"; +"window_recovery.save" = "Aktuelles Layout sichern"; +"window_recovery.ignored_apps" = "Ignorierte Apps"; +"window_recovery.search_apps" = "Apps suchen"; +"window_recovery.ignored_apps_description" = "Aktivieren Sie eine App, um sie aus zukünftigen Schnappschüssen auszuschließen."; +"window_recovery.limitations" = "Nur normale, nicht minimierte Fenster ohne Vollbild werden unterstützt. Split View, Stage Manager-Gruppen und Spaces werden nicht wiederhergestellt."; +"window_layout.show" = "Fensterlayout anzeigen"; +"window_layout.show_help" = "Gespeicherte Fenster über der Monitoranordnung anzeigen."; +"window_layout.no_saved" = "Für diese Monitoranordnung ist kein Fensterlayout gespeichert."; +"window_layout.automatic_before_lock" = "Vor Sperren"; +"window_layout.saved %lld" = "Layout %lld"; +"window_layout.kind.automatic" = "Auto"; +"window_layout.kind.manual" = "Manuell"; +"window_layout.promote_to_manual" = "In manuelles Layout umwandeln"; +"window_layout.apply" = "Zu diesem Layout wechseln"; +"window_layout.rename_title" = "Fensterlayout umbenennen"; +"window_layout.name_placeholder" = "Name des Fensterlayouts eingeben"; +"window_recovery.enable_in_settings" = "Aktiviere zuerst die Fensterwiederherstellung in den Einstellungen."; +"window_recovery.save_help" = "Aktuelle Fensterpositionen speichern."; diff --git a/MousePortal/Resources/en.lproj/Localizable.strings b/MousePortal/Resources/en.lproj/Localizable.strings index 6c5c338..5aab23f 100644 --- a/MousePortal/Resources/en.lproj/Localizable.strings +++ b/MousePortal/Resources/en.lproj/Localizable.strings @@ -149,5 +149,31 @@ "layout.delete_disabled_locked" = "Locked layouts cannot be deleted"; "layout.delete_disabled_current" = "Current layout cannot be deleted"; "layout.delete_disabled_last_remaining" = "At least one layout must remain"; +"layout.delete_disabled_window_snapshots" = "Delete the saved window layouts first"; "portal.edit" = "Edit"; "portal.delete" = "Delete"; + +/* Window Recovery */ +"settings.window_recovery" = "Window Recovery"; +"window_recovery.enabled" = "Enable Window Recovery"; +"window_recovery.remember_before_sleep" = "Remember windows before sleep or lock"; +"window_recovery.automatic" = "Restore automatically after displays wake"; +"window_recovery.behavior" = "Wake Recovery"; +"window_recovery.save" = "Save Current Layout"; +"window_recovery.ignored_apps" = "Ignored Applications"; +"window_recovery.search_apps" = "Search applications"; +"window_recovery.ignored_apps_description" = "Turn on an app to exclude it from future snapshots."; +"window_recovery.limitations" = "Only standard, non-minimized, non-full-screen windows are supported. Split View, Stage Manager groups, and Spaces are not restored."; +"window_layout.show" = "Show Window Layout"; +"window_layout.show_help" = "Overlay saved windows on the display arrangement."; +"window_layout.no_saved" = "No saved window layout for this display arrangement."; +"window_layout.automatic_before_lock" = "Before Lock"; +"window_layout.saved %lld" = "Layout %lld"; +"window_layout.kind.automatic" = "Auto"; +"window_layout.kind.manual" = "Manual"; +"window_layout.promote_to_manual" = "Promote to Manual Layout"; +"window_layout.apply" = "Switch to This Layout"; +"window_layout.rename_title" = "Rename Window Layout"; +"window_layout.name_placeholder" = "Enter window layout name"; +"window_recovery.enable_in_settings" = "Enable Window Recovery in Settings first."; +"window_recovery.save_help" = "Save the current window positions."; diff --git a/MousePortal/Resources/es.lproj/Localizable.strings b/MousePortal/Resources/es.lproj/Localizable.strings index 5dfd18f..f1e5fcb 100644 --- a/MousePortal/Resources/es.lproj/Localizable.strings +++ b/MousePortal/Resources/es.lproj/Localizable.strings @@ -149,5 +149,31 @@ "layout.delete_disabled_locked" = "Locked layouts cannot be deleted"; "layout.delete_disabled_current" = "Current layout cannot be deleted"; "layout.delete_disabled_last_remaining" = "At least one layout must remain"; +"layout.delete_disabled_window_snapshots" = "Elimina primero los diseños de ventanas guardados"; "portal.edit" = "Edit"; "portal.delete" = "Delete"; + +/* Window Recovery */ +"settings.window_recovery" = "Recuperación de ventanas"; +"window_recovery.enabled" = "Activar la restauración de ventanas"; +"window_recovery.remember_before_sleep" = "Recordar ventanas antes del reposo o bloqueo"; +"window_recovery.automatic" = "Restaurar automáticamente al activarse las pantallas"; +"window_recovery.behavior" = "Recuperación al despertar"; +"window_recovery.save" = "Guardar diseño actual"; +"window_recovery.ignored_apps" = "Aplicaciones ignoradas"; +"window_recovery.search_apps" = "Buscar aplicaciones"; +"window_recovery.ignored_apps_description" = "Activa una aplicación para excluirla de futuras instantáneas."; +"window_recovery.limitations" = "Solo se admiten ventanas estándar, no minimizadas y sin pantalla completa. No se restauran Split View, grupos de Stage Manager ni Spaces."; +"window_layout.show" = "Mostrar disposición de ventanas"; +"window_layout.show_help" = "Superpone las ventanas guardadas en la disposición de pantallas."; +"window_layout.no_saved" = "No hay una disposición de ventanas guardada para estas pantallas."; +"window_layout.automatic_before_lock" = "Antes del bloqueo"; +"window_layout.saved %lld" = "Disposición %lld"; +"window_layout.kind.automatic" = "Automático"; +"window_layout.kind.manual" = "Manual"; +"window_layout.promote_to_manual" = "Convertir en disposición manual"; +"window_layout.apply" = "Cambiar a esta disposición"; +"window_layout.rename_title" = "Renombrar disposición de ventanas"; +"window_layout.name_placeholder" = "Introduce el nombre de la disposición"; +"window_recovery.enable_in_settings" = "Activa primero la restauración de ventanas en Ajustes."; +"window_recovery.save_help" = "Guarda las posiciones actuales de las ventanas."; diff --git a/MousePortal/Resources/fr.lproj/Localizable.strings b/MousePortal/Resources/fr.lproj/Localizable.strings index d6e3761..684038b 100644 --- a/MousePortal/Resources/fr.lproj/Localizable.strings +++ b/MousePortal/Resources/fr.lproj/Localizable.strings @@ -149,5 +149,31 @@ "layout.delete_disabled_locked" = "Locked layouts cannot be deleted"; "layout.delete_disabled_current" = "Current layout cannot be deleted"; "layout.delete_disabled_last_remaining" = "At least one layout must remain"; +"layout.delete_disabled_window_snapshots" = "Supprimez d’abord les dispositions de fenêtres enregistrées"; "portal.edit" = "Edit"; "portal.delete" = "Delete"; + +/* Window Recovery */ +"settings.window_recovery" = "Récupération des fenêtres"; +"window_recovery.enabled" = "Activer la restauration des fenêtres"; +"window_recovery.remember_before_sleep" = "Mémoriser les fenêtres avant la veille ou le verrouillage"; +"window_recovery.automatic" = "Restaurer automatiquement au réveil des écrans"; +"window_recovery.behavior" = "Récupération au réveil"; +"window_recovery.save" = "Enregistrer la disposition actuelle"; +"window_recovery.ignored_apps" = "Applications ignorées"; +"window_recovery.search_apps" = "Rechercher des applications"; +"window_recovery.ignored_apps_description" = "Activez une application pour l’exclure des prochains instantanés."; +"window_recovery.limitations" = "Seules les fenêtres standard, non réduites et hors plein écran sont prises en charge. Split View, les groupes Stage Manager et les Spaces ne sont pas restaurés."; +"window_layout.show" = "Afficher la disposition des fenêtres"; +"window_layout.show_help" = "Superposer les fenêtres enregistrées à la disposition des écrans."; +"window_layout.no_saved" = "Aucune disposition de fenêtres enregistrée pour cette disposition d’écrans."; +"window_layout.automatic_before_lock" = "Avant verrouillage"; +"window_layout.saved %lld" = "Disposition %lld"; +"window_layout.kind.automatic" = "Auto"; +"window_layout.kind.manual" = "Manuel"; +"window_layout.promote_to_manual" = "Convertir en disposition manuelle"; +"window_layout.apply" = "Passer à cette disposition"; +"window_layout.rename_title" = "Renommer la disposition des fenêtres"; +"window_layout.name_placeholder" = "Saisissez le nom de la disposition"; +"window_recovery.enable_in_settings" = "Activez d’abord la restauration des fenêtres dans Réglages."; +"window_recovery.save_help" = "Enregistrer les positions actuelles des fenêtres."; diff --git a/MousePortal/Resources/hi.lproj/Localizable.strings b/MousePortal/Resources/hi.lproj/Localizable.strings index 28b663d..2f1d94f 100644 --- a/MousePortal/Resources/hi.lproj/Localizable.strings +++ b/MousePortal/Resources/hi.lproj/Localizable.strings @@ -151,5 +151,31 @@ "layout.delete_disabled_locked" = "Locked layouts cannot be deleted"; "layout.delete_disabled_current" = "Current layout cannot be deleted"; "layout.delete_disabled_last_remaining" = "At least one layout must remain"; +"layout.delete_disabled_window_snapshots" = "पहले सहेजे गए विंडो लेआउट हटाएँ"; "portal.edit" = "Edit"; "portal.delete" = "Delete"; + +/* Window Recovery */ +"settings.window_recovery" = "विंडो पुनर्प्राप्ति"; +"window_recovery.enabled" = "विंडो पुनर्प्राप्ति सक्षम करें"; +"window_recovery.remember_before_sleep" = "स्लीप या लॉक से पहले विंडो याद रखें"; +"window_recovery.automatic" = "डिस्प्ले जागने के बाद अपने आप पुनर्स्थापित करें"; +"window_recovery.behavior" = "जागने पर पुनर्प्राप्ति"; +"window_recovery.save" = "वर्तमान लेआउट सहेजें"; +"window_recovery.ignored_apps" = "अनदेखे ऐप"; +"window_recovery.search_apps" = "ऐप खोजें"; +"window_recovery.ignored_apps_description" = "किसी ऐप को भविष्य के स्नैपशॉट से बाहर रखने के लिए उसे चालू करें।"; +"window_recovery.limitations" = "केवल सामान्य, गैर-न्यूनतम और गैर-फ़ुल-स्क्रीन विंडो समर्थित हैं। Split View, Stage Manager समूह और Spaces पुनर्स्थापित नहीं होते।"; +"window_layout.show" = "विंडो लेआउट दिखाएँ"; +"window_layout.show_help" = "सहेजी गई विंडो को डिस्प्ले व्यवस्था पर दिखाएँ।"; +"window_layout.no_saved" = "इस डिस्प्ले व्यवस्था के लिए कोई विंडो लेआउट सहेजा नहीं गया है।"; +"window_layout.automatic_before_lock" = "लॉक से पहले"; +"window_layout.saved %lld" = "लेआउट %lld"; +"window_layout.kind.automatic" = "स्वचालित"; +"window_layout.kind.manual" = "मैन्युअल"; +"window_layout.promote_to_manual" = "मैन्युअल लेआउट में बदलें"; +"window_layout.apply" = "इस लेआउट पर स्विच करें"; +"window_layout.rename_title" = "विंडो लेआउट का नाम बदलें"; +"window_layout.name_placeholder" = "विंडो लेआउट नाम दर्ज करें"; +"window_recovery.enable_in_settings" = "पहले सेटिंग्स में विंडो रिकवरी चालू करें।"; +"window_recovery.save_help" = "मौजूदा विंडो स्थितियाँ सहेजें।"; diff --git a/MousePortal/Resources/id.lproj/Localizable.strings b/MousePortal/Resources/id.lproj/Localizable.strings index a0bd480..e29831f 100644 --- a/MousePortal/Resources/id.lproj/Localizable.strings +++ b/MousePortal/Resources/id.lproj/Localizable.strings @@ -151,5 +151,31 @@ "layout.delete_disabled_locked" = "Locked layouts cannot be deleted"; "layout.delete_disabled_current" = "Current layout cannot be deleted"; "layout.delete_disabled_last_remaining" = "At least one layout must remain"; +"layout.delete_disabled_window_snapshots" = "Hapus tata letak jendela tersimpan terlebih dahulu"; "portal.edit" = "Edit"; "portal.delete" = "Delete"; + +/* Window Recovery */ +"settings.window_recovery" = "Pemulihan jendela"; +"window_recovery.enabled" = "Aktifkan pemulihan jendela"; +"window_recovery.remember_before_sleep" = "Ingat jendela sebelum tidur atau terkunci"; +"window_recovery.automatic" = "Pulihkan otomatis setelah layar aktif"; +"window_recovery.behavior" = "Pemulihan setelah aktif"; +"window_recovery.save" = "Simpan tata letak saat ini"; +"window_recovery.ignored_apps" = "Aplikasi yang diabaikan"; +"window_recovery.search_apps" = "Cari aplikasi"; +"window_recovery.ignored_apps_description" = "Aktifkan aplikasi untuk mengecualikannya dari cuplikan berikutnya."; +"window_recovery.limitations" = "Hanya jendela standar yang tidak diminimalkan dan tidak layar penuh yang didukung. Split View, grup Stage Manager, dan Spaces tidak dipulihkan."; +"window_layout.show" = "Tampilkan tata letak jendela"; +"window_layout.show_help" = "Tampilkan jendela tersimpan di atas susunan layar."; +"window_layout.no_saved" = "Belum ada tata letak jendela tersimpan untuk susunan layar ini."; +"window_layout.automatic_before_lock" = "Sebelum terkunci"; +"window_layout.saved %lld" = "Tata letak %lld"; +"window_layout.kind.automatic" = "Otomatis"; +"window_layout.kind.manual" = "Manual"; +"window_layout.promote_to_manual" = "Jadikan tata letak manual"; +"window_layout.apply" = "Beralih ke tata letak ini"; +"window_layout.rename_title" = "Ubah nama tata letak jendela"; +"window_layout.name_placeholder" = "Masukkan nama tata letak jendela"; +"window_recovery.enable_in_settings" = "Aktifkan Pemulihan Jendela di Pengaturan terlebih dahulu."; +"window_recovery.save_help" = "Simpan posisi jendela saat ini."; diff --git a/MousePortal/Resources/it.lproj/Localizable.strings b/MousePortal/Resources/it.lproj/Localizable.strings index aedfd1c..150e5eb 100644 --- a/MousePortal/Resources/it.lproj/Localizable.strings +++ b/MousePortal/Resources/it.lproj/Localizable.strings @@ -151,5 +151,31 @@ "layout.delete_disabled_locked" = "Locked layouts cannot be deleted"; "layout.delete_disabled_current" = "Current layout cannot be deleted"; "layout.delete_disabled_last_remaining" = "At least one layout must remain"; +"layout.delete_disabled_window_snapshots" = "Elimina prima i layout delle finestre salvati"; "portal.edit" = "Edit"; "portal.delete" = "Delete"; + +/* Window Recovery */ +"settings.window_recovery" = "Ripristino finestre"; +"window_recovery.enabled" = "Abilita ripristino finestre"; +"window_recovery.remember_before_sleep" = "Ricorda le finestre prima dello stop o del blocco"; +"window_recovery.automatic" = "Ripristina automaticamente al riavvio degli schermi"; +"window_recovery.behavior" = "Ripristino al riavvio"; +"window_recovery.save" = "Salva disposizione attuale"; +"window_recovery.ignored_apps" = "App ignorate"; +"window_recovery.search_apps" = "Cerca app"; +"window_recovery.ignored_apps_description" = "Attiva un’app per escluderla dalle istantanee future."; +"window_recovery.limitations" = "Sono supportate solo finestre standard, non ridotte a icona e non a schermo intero. Split View, gruppi Stage Manager e Spaces non vengono ripristinati."; +"window_layout.show" = "Mostra disposizione finestre"; +"window_layout.show_help" = "Sovrapponi le finestre salvate alla disposizione degli schermi."; +"window_layout.no_saved" = "Nessuna disposizione di finestre salvata per questi schermi."; +"window_layout.automatic_before_lock" = "Prima del blocco"; +"window_layout.saved %lld" = "Disposizione %lld"; +"window_layout.kind.automatic" = "Automatica"; +"window_layout.kind.manual" = "Manuale"; +"window_layout.promote_to_manual" = "Converti in disposizione manuale"; +"window_layout.apply" = "Passa a questa disposizione"; +"window_layout.rename_title" = "Rinomina disposizione finestre"; +"window_layout.name_placeholder" = "Inserisci il nome della disposizione"; +"window_recovery.enable_in_settings" = "Abilita prima il ripristino finestre nelle Impostazioni."; +"window_recovery.save_help" = "Salva le posizioni attuali delle finestre."; diff --git a/MousePortal/Resources/ja.lproj/Localizable.strings b/MousePortal/Resources/ja.lproj/Localizable.strings index 1d3ce80..3451913 100644 --- a/MousePortal/Resources/ja.lproj/Localizable.strings +++ b/MousePortal/Resources/ja.lproj/Localizable.strings @@ -149,5 +149,31 @@ "layout.delete_disabled_locked" = "Locked layouts cannot be deleted"; "layout.delete_disabled_current" = "Current layout cannot be deleted"; "layout.delete_disabled_last_remaining" = "At least one layout must remain"; +"layout.delete_disabled_window_snapshots" = "先に保存済みのウインドウレイアウトを削除してください"; "portal.edit" = "Edit"; "portal.delete" = "Delete"; + +/* Window Recovery */ +"settings.window_recovery" = "ウインドウ復元"; +"window_recovery.enabled" = "ウインドウ復元を有効にする"; +"window_recovery.remember_before_sleep" = "スリープまたはロック前にウインドウを記憶"; +"window_recovery.automatic" = "ディスプレイ復帰後に自動復元"; +"window_recovery.behavior" = "スリープ解除時の復元"; +"window_recovery.save" = "現在の配置を保存"; +"window_recovery.ignored_apps" = "除外するアプリ"; +"window_recovery.search_apps" = "アプリを検索"; +"window_recovery.ignored_apps_description" = "オンにしたアプリは今後のスナップショットから除外されます。"; +"window_recovery.limitations" = "標準の非最小化・非フルスクリーンウインドウのみ対応します。Split View、Stage Manager グループ、Spaces は復元されません。"; +"window_layout.show" = "ウインドウ配置を表示"; +"window_layout.show_help" = "保存したウインドウをディスプレイ配置に重ねて表示します。"; +"window_layout.no_saved" = "このディスプレイ配置には保存済みのウインドウ配置がありません。"; +"window_layout.automatic_before_lock" = "ロック前"; +"window_layout.saved %lld" = "配置 %lld"; +"window_layout.kind.automatic" = "自動"; +"window_layout.kind.manual" = "手動"; +"window_layout.promote_to_manual" = "手動配置に変更"; +"window_layout.apply" = "この配置に切り替える"; +"window_layout.rename_title" = "ウインドウ配置の名前を変更"; +"window_layout.name_placeholder" = "ウインドウ配置名を入力"; +"window_recovery.enable_in_settings" = "先に設定でウインドウ復元を有効にしてください。"; +"window_recovery.save_help" = "現在のウインドウ位置を保存します。"; diff --git a/MousePortal/Resources/ko.lproj/Localizable.strings b/MousePortal/Resources/ko.lproj/Localizable.strings index 3fa82e1..79633ee 100644 --- a/MousePortal/Resources/ko.lproj/Localizable.strings +++ b/MousePortal/Resources/ko.lproj/Localizable.strings @@ -149,5 +149,31 @@ "layout.delete_disabled_locked" = "Locked layouts cannot be deleted"; "layout.delete_disabled_current" = "Current layout cannot be deleted"; "layout.delete_disabled_last_remaining" = "At least one layout must remain"; +"layout.delete_disabled_window_snapshots" = "저장된 창 레이아웃을 먼저 삭제하세요"; "portal.edit" = "Edit"; "portal.delete" = "Delete"; + +/* Window Recovery */ +"settings.window_recovery" = "윈도우 복구"; +"window_recovery.enabled" = "윈도우 복구 활성화"; +"window_recovery.remember_before_sleep" = "잠자기 또는 잠금 전에 윈도우 기억"; +"window_recovery.automatic" = "디스플레이가 깨어난 후 자동 복원"; +"window_recovery.behavior" = "깨우기 복구"; +"window_recovery.save" = "현재 레이아웃 저장"; +"window_recovery.ignored_apps" = "무시할 앱"; +"window_recovery.search_apps" = "앱 검색"; +"window_recovery.ignored_apps_description" = "앱을 켜면 이후 스냅샷에서 제외됩니다."; +"window_recovery.limitations" = "표준형이며 최소화 또는 전체 화면이 아닌 윈도우만 지원합니다. Split View, Stage Manager 그룹 및 Spaces는 복원하지 않습니다."; +"window_layout.show" = "윈도우 배치 표시"; +"window_layout.show_help" = "저장된 윈도우를 디스플레이 배열 위에 표시합니다."; +"window_layout.no_saved" = "이 디스플레이 배열에 저장된 윈도우 배치가 없습니다."; +"window_layout.automatic_before_lock" = "잠금 전"; +"window_layout.saved %lld" = "배치 %lld"; +"window_layout.kind.automatic" = "자동"; +"window_layout.kind.manual" = "수동"; +"window_layout.promote_to_manual" = "수동 배치로 전환"; +"window_layout.apply" = "이 배치로 전환"; +"window_layout.rename_title" = "윈도우 배치 이름 변경"; +"window_layout.name_placeholder" = "윈도우 배치 이름 입력"; +"window_recovery.enable_in_settings" = "먼저 설정에서 윈도우 복원을 켜세요."; +"window_recovery.save_help" = "현재 윈도우 위치를 저장합니다."; diff --git a/MousePortal/Resources/ms.lproj/Localizable.strings b/MousePortal/Resources/ms.lproj/Localizable.strings index 04ef990..993439f 100644 --- a/MousePortal/Resources/ms.lproj/Localizable.strings +++ b/MousePortal/Resources/ms.lproj/Localizable.strings @@ -151,5 +151,31 @@ "layout.delete_disabled_locked" = "Locked layouts cannot be deleted"; "layout.delete_disabled_current" = "Current layout cannot be deleted"; "layout.delete_disabled_last_remaining" = "At least one layout must remain"; +"layout.delete_disabled_window_snapshots" = "Padam susun atur tetingkap yang disimpan dahulu"; "portal.edit" = "Edit"; "portal.delete" = "Delete"; + +/* Window Recovery */ +"settings.window_recovery" = "Pemulihan tetingkap"; +"window_recovery.enabled" = "Dayakan pemulihan tetingkap"; +"window_recovery.remember_before_sleep" = "Ingat tetingkap sebelum tidur atau dikunci"; +"window_recovery.automatic" = "Pulihkan secara automatik selepas paparan bangun"; +"window_recovery.behavior" = "Pemulihan selepas bangun"; +"window_recovery.save" = "Simpan susun atur semasa"; +"window_recovery.ignored_apps" = "Aplikasi diabaikan"; +"window_recovery.search_apps" = "Cari aplikasi"; +"window_recovery.ignored_apps_description" = "Hidupkan aplikasi untuk mengecualikannya daripada petikan akan datang."; +"window_recovery.limitations" = "Hanya tetingkap standard yang tidak diminimumkan dan bukan skrin penuh disokong. Split View, kumpulan Stage Manager dan Spaces tidak dipulihkan."; +"window_layout.show" = "Tunjukkan susun atur tetingkap"; +"window_layout.show_help" = "Paparkan tetingkap tersimpan di atas susunan paparan."; +"window_layout.no_saved" = "Tiada susun atur tetingkap tersimpan untuk susunan paparan ini."; +"window_layout.automatic_before_lock" = "Sebelum dikunci"; +"window_layout.saved %lld" = "Susun atur %lld"; +"window_layout.kind.automatic" = "Automatik"; +"window_layout.kind.manual" = "Manual"; +"window_layout.promote_to_manual" = "Jadikan susun atur manual"; +"window_layout.apply" = "Tukar ke susun atur ini"; +"window_layout.rename_title" = "Namakan semula susun atur tetingkap"; +"window_layout.name_placeholder" = "Masukkan nama susun atur tetingkap"; +"window_recovery.enable_in_settings" = "Hidupkan Pemulihan Tetingkap dalam Tetapan dahulu."; +"window_recovery.save_help" = "Simpan kedudukan tetingkap semasa."; diff --git a/MousePortal/Resources/nl.lproj/Localizable.strings b/MousePortal/Resources/nl.lproj/Localizable.strings index f466ba9..44a5c6e 100644 --- a/MousePortal/Resources/nl.lproj/Localizable.strings +++ b/MousePortal/Resources/nl.lproj/Localizable.strings @@ -151,5 +151,31 @@ "layout.delete_disabled_locked" = "Locked layouts cannot be deleted"; "layout.delete_disabled_current" = "Current layout cannot be deleted"; "layout.delete_disabled_last_remaining" = "At least one layout must remain"; +"layout.delete_disabled_window_snapshots" = "Verwijder eerst de opgeslagen vensterindelingen"; "portal.edit" = "Edit"; "portal.delete" = "Delete"; + +/* Window Recovery */ +"settings.window_recovery" = "Vensterherstel"; +"window_recovery.enabled" = "Vensterherstel inschakelen"; +"window_recovery.remember_before_sleep" = "Vensters onthouden voor sluimerstand of vergrendeling"; +"window_recovery.automatic" = "Automatisch herstellen nadat beeldschermen ontwaken"; +"window_recovery.behavior" = "Herstel na ontwaken"; +"window_recovery.save" = "Huidige indeling bewaren"; +"window_recovery.ignored_apps" = "Genegeerde apps"; +"window_recovery.search_apps" = "Apps zoeken"; +"window_recovery.ignored_apps_description" = "Schakel een app in om deze uit toekomstige momentopnamen uit te sluiten."; +"window_recovery.limitations" = "Alleen standaardvensters die niet geminimaliseerd of schermvullend zijn worden ondersteund. Split View, Stage Manager-groepen en Spaces worden niet hersteld."; +"window_layout.show" = "Vensterindeling tonen"; +"window_layout.show_help" = "Toon opgeslagen vensters over de beeldschermindeling."; +"window_layout.no_saved" = "Geen vensterindeling opgeslagen voor deze beeldschermindeling."; +"window_layout.automatic_before_lock" = "Vóór vergrendelen"; +"window_layout.saved %lld" = "Indeling %lld"; +"window_layout.kind.automatic" = "Auto"; +"window_layout.kind.manual" = "Handmatig"; +"window_layout.promote_to_manual" = "Omzetten naar handmatige indeling"; +"window_layout.apply" = "Naar deze indeling schakelen"; +"window_layout.rename_title" = "Vensterindeling hernoemen"; +"window_layout.name_placeholder" = "Voer de naam van de vensterindeling in"; +"window_recovery.enable_in_settings" = "Schakel eerst Vensterherstel in bij Instellingen."; +"window_recovery.save_help" = "Sla de huidige vensterposities op."; diff --git a/MousePortal/Resources/pl.lproj/Localizable.strings b/MousePortal/Resources/pl.lproj/Localizable.strings index 9e6f57c..1f1bbc0 100644 --- a/MousePortal/Resources/pl.lproj/Localizable.strings +++ b/MousePortal/Resources/pl.lproj/Localizable.strings @@ -151,5 +151,31 @@ "layout.delete_disabled_locked" = "Locked layouts cannot be deleted"; "layout.delete_disabled_current" = "Current layout cannot be deleted"; "layout.delete_disabled_last_remaining" = "At least one layout must remain"; +"layout.delete_disabled_window_snapshots" = "Najpierw usuń zapisane układy okien"; "portal.edit" = "Edit"; "portal.delete" = "Delete"; + +/* Window Recovery */ +"settings.window_recovery" = "Przywracanie okien"; +"window_recovery.enabled" = "Włącz przywracanie okien"; +"window_recovery.remember_before_sleep" = "Zapamiętaj okna przed uśpieniem lub blokadą"; +"window_recovery.automatic" = "Przywracaj automatycznie po wybudzeniu ekranów"; +"window_recovery.behavior" = "Przywracanie po wybudzeniu"; +"window_recovery.save" = "Zapisz bieżący układ"; +"window_recovery.ignored_apps" = "Ignorowane aplikacje"; +"window_recovery.search_apps" = "Szukaj aplikacji"; +"window_recovery.ignored_apps_description" = "Włącz aplikację, aby wykluczyć ją z przyszłych migawek."; +"window_recovery.limitations" = "Obsługiwane są tylko standardowe, niezminimalizowane i niepełnoekranowe okna. Split View, grupy Stage Manager i Spaces nie są przywracane."; +"window_layout.show" = "Pokaż układ okien"; +"window_layout.show_help" = "Nałóż zapisane okna na układ monitorów."; +"window_layout.no_saved" = "Brak zapisanego układu okien dla tego układu monitorów."; +"window_layout.automatic_before_lock" = "Przed blokadą"; +"window_layout.saved %lld" = "Układ %lld"; +"window_layout.kind.automatic" = "Auto"; +"window_layout.kind.manual" = "Ręczny"; +"window_layout.promote_to_manual" = "Zmień na układ ręczny"; +"window_layout.apply" = "Przełącz na ten układ"; +"window_layout.rename_title" = "Zmień nazwę układu okien"; +"window_layout.name_placeholder" = "Wpisz nazwę układu okien"; +"window_recovery.enable_in_settings" = "Najpierw włącz przywracanie okien w Ustawieniach."; +"window_recovery.save_help" = "Zapisz bieżące położenie okien."; diff --git a/MousePortal/Resources/pt-BR.lproj/Localizable.strings b/MousePortal/Resources/pt-BR.lproj/Localizable.strings index 042e84b..adc6847 100644 --- a/MousePortal/Resources/pt-BR.lproj/Localizable.strings +++ b/MousePortal/Resources/pt-BR.lproj/Localizable.strings @@ -149,5 +149,31 @@ "layout.delete_disabled_locked" = "Locked layouts cannot be deleted"; "layout.delete_disabled_current" = "Current layout cannot be deleted"; "layout.delete_disabled_last_remaining" = "At least one layout must remain"; +"layout.delete_disabled_window_snapshots" = "Exclua primeiro os layouts de janela salvos"; "portal.edit" = "Edit"; "portal.delete" = "Delete"; + +/* Window Recovery */ +"settings.window_recovery" = "Recuperação de janelas"; +"window_recovery.enabled" = "Ativar restauração de janelas"; +"window_recovery.remember_before_sleep" = "Lembrar janelas antes de repousar ou bloquear"; +"window_recovery.automatic" = "Restaurar automaticamente quando as telas despertarem"; +"window_recovery.behavior" = "Recuperação ao despertar"; +"window_recovery.save" = "Salvar layout atual"; +"window_recovery.ignored_apps" = "Aplicativos ignorados"; +"window_recovery.search_apps" = "Buscar aplicativos"; +"window_recovery.ignored_apps_description" = "Ative um aplicativo para excluí-lo dos próximos instantâneos."; +"window_recovery.limitations" = "Somente janelas padrão, não minimizadas e fora da tela cheia são compatíveis. Split View, grupos do Stage Manager e Spaces não são restaurados."; +"window_layout.show" = "Mostrar layout das janelas"; +"window_layout.show_help" = "Sobrepõe as janelas salvas ao arranjo dos monitores."; +"window_layout.no_saved" = "Não há layout de janelas salvo para este arranjo de monitores."; +"window_layout.automatic_before_lock" = "Antes do bloqueio"; +"window_layout.saved %lld" = "Layout %lld"; +"window_layout.kind.automatic" = "Automático"; +"window_layout.kind.manual" = "Manual"; +"window_layout.promote_to_manual" = "Converter em layout manual"; +"window_layout.apply" = "Alternar para este layout"; +"window_layout.rename_title" = "Renomear layout das janelas"; +"window_layout.name_placeholder" = "Digite o nome do layout"; +"window_recovery.enable_in_settings" = "Ative primeiro a restauração de janelas nos Ajustes."; +"window_recovery.save_help" = "Salva as posições atuais das janelas."; diff --git a/MousePortal/Resources/ru.lproj/Localizable.strings b/MousePortal/Resources/ru.lproj/Localizable.strings index de886f4..cf8c3fb 100644 --- a/MousePortal/Resources/ru.lproj/Localizable.strings +++ b/MousePortal/Resources/ru.lproj/Localizable.strings @@ -149,5 +149,31 @@ "layout.delete_disabled_locked" = "Locked layouts cannot be deleted"; "layout.delete_disabled_current" = "Current layout cannot be deleted"; "layout.delete_disabled_last_remaining" = "At least one layout must remain"; +"layout.delete_disabled_window_snapshots" = "Сначала удалите сохранённые раскладки окон"; "portal.edit" = "Edit"; "portal.delete" = "Delete"; + +/* Window Recovery */ +"settings.window_recovery" = "Восстановление окон"; +"window_recovery.enabled" = "Включить восстановление окон"; +"window_recovery.remember_before_sleep" = "Запоминать окна перед сном или блокировкой"; +"window_recovery.automatic" = "Автоматически восстанавливать после пробуждения дисплеев"; +"window_recovery.behavior" = "Восстановление после сна"; +"window_recovery.save" = "Сохранить текущее расположение"; +"window_recovery.ignored_apps" = "Игнорируемые приложения"; +"window_recovery.search_apps" = "Поиск приложений"; +"window_recovery.ignored_apps_description" = "Включите приложение, чтобы исключить его из будущих снимков."; +"window_recovery.limitations" = "Поддерживаются только обычные, не свёрнутые и не полноэкранные окна. Split View, группы Stage Manager и Spaces не восстанавливаются."; +"window_layout.show" = "Показать расположение окон"; +"window_layout.show_help" = "Показывать сохранённые окна поверх схемы дисплеев."; +"window_layout.no_saved" = "Для этой схемы дисплеев нет сохранённого расположения окон."; +"window_layout.automatic_before_lock" = "Перед блокировкой"; +"window_layout.saved %lld" = "Схема %lld"; +"window_layout.kind.automatic" = "Авто"; +"window_layout.kind.manual" = "Вручную"; +"window_layout.promote_to_manual" = "Сделать ручной схемой"; +"window_layout.apply" = "Переключиться на эту схему"; +"window_layout.rename_title" = "Переименовать схему окон"; +"window_layout.name_placeholder" = "Введите название схемы окон"; +"window_recovery.enable_in_settings" = "Сначала включите восстановление окон в настройках."; +"window_recovery.save_help" = "Сохранить текущие позиции окон."; diff --git a/MousePortal/Resources/th.lproj/Localizable.strings b/MousePortal/Resources/th.lproj/Localizable.strings index d021a15..d5f701e 100644 --- a/MousePortal/Resources/th.lproj/Localizable.strings +++ b/MousePortal/Resources/th.lproj/Localizable.strings @@ -151,5 +151,31 @@ "layout.delete_disabled_locked" = "Locked layouts cannot be deleted"; "layout.delete_disabled_current" = "Current layout cannot be deleted"; "layout.delete_disabled_last_remaining" = "At least one layout must remain"; +"layout.delete_disabled_window_snapshots" = "ลบเค้าโครงหน้าต่างที่บันทึกไว้ก่อน"; "portal.edit" = "Edit"; "portal.delete" = "Delete"; + +/* Window Recovery */ +"settings.window_recovery" = "การกู้คืนหน้าต่าง"; +"window_recovery.enabled" = "เปิดใช้การกู้คืนหน้าต่าง"; +"window_recovery.remember_before_sleep" = "จำหน้าต่างก่อนพักเครื่องหรือล็อก"; +"window_recovery.automatic" = "คืนค่าอัตโนมัติหลังจอภาพตื่น"; +"window_recovery.behavior" = "การกู้คืนหลังตื่น"; +"window_recovery.save" = "บันทึกเค้าโครงปัจจุบัน"; +"window_recovery.ignored_apps" = "แอปที่ละเว้น"; +"window_recovery.search_apps" = "ค้นหาแอป"; +"window_recovery.ignored_apps_description" = "เปิดสวิตช์แอปเพื่อไม่รวมแอปนั้นจากสแนปช็อตในอนาคต"; +"window_recovery.limitations" = "รองรับเฉพาะหน้าต่างมาตรฐานที่ไม่ย่อและไม่เต็มหน้าจอ ไม่คืนค่า Split View กลุ่ม Stage Manager และ Spaces"; +"window_layout.show" = "แสดงเค้าโครงหน้าต่าง"; +"window_layout.show_help" = "แสดงหน้าต่างที่บันทึกไว้ซ้อนบนการจัดเรียงจอภาพ"; +"window_layout.no_saved" = "ไม่มีเค้าโครงหน้าต่างที่บันทึกไว้สำหรับการจัดเรียงจอภาพนี้"; +"window_layout.automatic_before_lock" = "ก่อนล็อก"; +"window_layout.saved %lld" = "เค้าโครง %lld"; +"window_layout.kind.automatic" = "อัตโนมัติ"; +"window_layout.kind.manual" = "กำหนดเอง"; +"window_layout.promote_to_manual" = "เปลี่ยนเป็นเค้าโครงแบบกำหนดเอง"; +"window_layout.apply" = "สลับไปยังเค้าโครงนี้"; +"window_layout.rename_title" = "เปลี่ยนชื่อเค้าโครงหน้าต่าง"; +"window_layout.name_placeholder" = "ป้อนชื่อเค้าโครงหน้าต่าง"; +"window_recovery.enable_in_settings" = "โปรดเปิดการคืนค่าหน้าต่างในการตั้งค่าก่อน"; +"window_recovery.save_help" = "บันทึกตำแหน่งหน้าต่างปัจจุบัน"; diff --git a/MousePortal/Resources/tr.lproj/Localizable.strings b/MousePortal/Resources/tr.lproj/Localizable.strings index 60820e8..1c0f188 100644 --- a/MousePortal/Resources/tr.lproj/Localizable.strings +++ b/MousePortal/Resources/tr.lproj/Localizable.strings @@ -151,5 +151,31 @@ "layout.delete_disabled_locked" = "Locked layouts cannot be deleted"; "layout.delete_disabled_current" = "Current layout cannot be deleted"; "layout.delete_disabled_last_remaining" = "At least one layout must remain"; +"layout.delete_disabled_window_snapshots" = "Önce kaydedilmiş pencere düzenlerini silin"; "portal.edit" = "Edit"; "portal.delete" = "Delete"; + +/* Window Recovery */ +"settings.window_recovery" = "Pencere kurtarma"; +"window_recovery.enabled" = "Pencere kurtarmayı etkinleştir"; +"window_recovery.remember_before_sleep" = "Uyku veya kilitlemeden önce pencereleri hatırla"; +"window_recovery.automatic" = "Ekranlar uyandığında otomatik geri yükle"; +"window_recovery.behavior" = "Uyanma sonrası kurtarma"; +"window_recovery.save" = "Geçerli düzeni kaydet"; +"window_recovery.ignored_apps" = "Yoksayılan uygulamalar"; +"window_recovery.search_apps" = "Uygulama ara"; +"window_recovery.ignored_apps_description" = "Bir uygulamayı gelecekteki anlık görüntülerden çıkarmak için açın."; +"window_recovery.limitations" = "Yalnızca standart, küçültülmemiş ve tam ekran olmayan pencereler desteklenir. Split View, Stage Manager grupları ve Spaces geri yüklenmez."; +"window_layout.show" = "Pencere düzenini göster"; +"window_layout.show_help" = "Kaydedilen pencereleri ekran düzeninin üzerinde gösterir."; +"window_layout.no_saved" = "Bu ekran düzeni için kaydedilmiş pencere düzeni yok."; +"window_layout.automatic_before_lock" = "Kilitleme öncesi"; +"window_layout.saved %lld" = "Düzen %lld"; +"window_layout.kind.automatic" = "Otomatik"; +"window_layout.kind.manual" = "Manuel"; +"window_layout.promote_to_manual" = "Manuel düzene yükselt"; +"window_layout.apply" = "Bu düzene geç"; +"window_layout.rename_title" = "Pencere düzenini yeniden adlandır"; +"window_layout.name_placeholder" = "Pencere düzeni adını girin"; +"window_recovery.enable_in_settings" = "Önce Ayarlar’dan Pencere Kurtarma’yı açın."; +"window_recovery.save_help" = "Geçerli pencere konumlarını kaydeder."; diff --git a/MousePortal/Resources/vi.lproj/Localizable.strings b/MousePortal/Resources/vi.lproj/Localizable.strings index 36273ef..e540fee 100644 --- a/MousePortal/Resources/vi.lproj/Localizable.strings +++ b/MousePortal/Resources/vi.lproj/Localizable.strings @@ -151,5 +151,31 @@ "layout.delete_disabled_locked" = "Locked layouts cannot be deleted"; "layout.delete_disabled_current" = "Current layout cannot be deleted"; "layout.delete_disabled_last_remaining" = "At least one layout must remain"; +"layout.delete_disabled_window_snapshots" = "Hãy xóa các bố cục cửa sổ đã lưu trước"; "portal.edit" = "Edit"; "portal.delete" = "Delete"; + +/* Window Recovery */ +"settings.window_recovery" = "Khôi phục cửa sổ"; +"window_recovery.enabled" = "Bật khôi phục cửa sổ"; +"window_recovery.remember_before_sleep" = "Ghi nhớ cửa sổ trước khi ngủ hoặc khóa"; +"window_recovery.automatic" = "Tự động khôi phục sau khi màn hình thức dậy"; +"window_recovery.behavior" = "Khôi phục khi thức dậy"; +"window_recovery.save" = "Lưu bố cục hiện tại"; +"window_recovery.ignored_apps" = "Ứng dụng bị bỏ qua"; +"window_recovery.search_apps" = "Tìm ứng dụng"; +"window_recovery.ignored_apps_description" = "Bật một ứng dụng để loại ứng dụng đó khỏi các ảnh chụp nhanh sau này."; +"window_recovery.limitations" = "Chỉ hỗ trợ cửa sổ tiêu chuẩn, không thu nhỏ và không toàn màn hình. Split View, nhóm Stage Manager và Spaces không được khôi phục."; +"window_layout.show" = "Hiển thị bố cục cửa sổ"; +"window_layout.show_help" = "Hiển thị các cửa sổ đã lưu trên sơ đồ màn hình."; +"window_layout.no_saved" = "Chưa có bố cục cửa sổ được lưu cho sơ đồ màn hình này."; +"window_layout.automatic_before_lock" = "Trước khi khóa"; +"window_layout.saved %lld" = "Bố cục %lld"; +"window_layout.kind.automatic" = "Tự động"; +"window_layout.kind.manual" = "Thủ công"; +"window_layout.promote_to_manual" = "Chuyển thành bố cục thủ công"; +"window_layout.apply" = "Chuyển sang bố cục này"; +"window_layout.rename_title" = "Đổi tên bố cục cửa sổ"; +"window_layout.name_placeholder" = "Nhập tên bố cục cửa sổ"; +"window_recovery.enable_in_settings" = "Trước tiên hãy bật Khôi phục cửa sổ trong Cài đặt."; +"window_recovery.save_help" = "Lưu vị trí cửa sổ hiện tại."; diff --git a/MousePortal/Resources/zh-Hans.lproj/Localizable.strings b/MousePortal/Resources/zh-Hans.lproj/Localizable.strings index 8961e57..7a54470 100644 --- a/MousePortal/Resources/zh-Hans.lproj/Localizable.strings +++ b/MousePortal/Resources/zh-Hans.lproj/Localizable.strings @@ -149,5 +149,31 @@ "layout.delete_disabled_locked" = "已锁定的排列不能删除"; "layout.delete_disabled_current" = "当前排列不能删除"; "layout.delete_disabled_last_remaining" = "至少要保留一个排列"; +"layout.delete_disabled_window_snapshots" = "请先删除该排列下保存的窗口布局"; "portal.edit" = "编辑"; "portal.delete" = "删除"; + +/* Window Recovery */ +"settings.window_recovery" = "窗口恢复"; +"window_recovery.enabled" = "启用窗口恢复"; +"window_recovery.remember_before_sleep" = "睡眠或锁屏前记住窗口"; +"window_recovery.automatic" = "显示器唤醒后自动恢复"; +"window_recovery.behavior" = "唤醒恢复"; +"window_recovery.save" = "保存当前布局"; +"window_recovery.ignored_apps" = "忽略的应用"; +"window_recovery.search_apps" = "搜索应用"; +"window_recovery.ignored_apps_description" = "打开应用右侧开关,可在之后的快照中排除它。"; +"window_recovery.limitations" = "仅支持普通、非最小化、非全屏窗口;不恢复 Split View、Stage Manager 分组和 Spaces。"; +"window_layout.show" = "显示窗口布局"; +"window_layout.show_help" = "在显示器排列上叠加显示已保存的窗口。"; +"window_layout.no_saved" = "这个显示器排列还没有保存窗口布局。"; +"window_layout.automatic_before_lock" = "锁屏前布局"; +"window_layout.saved %lld" = "布局 %lld"; +"window_layout.kind.automatic" = "自动"; +"window_layout.kind.manual" = "手动"; +"window_layout.promote_to_manual" = "提升为手动布局"; +"window_layout.apply" = "切换到这个布局"; +"window_layout.rename_title" = "重命名窗口布局"; +"window_layout.name_placeholder" = "输入窗口布局名称"; +"window_recovery.enable_in_settings" = "请先在设置中打开窗口恢复。"; +"window_recovery.save_help" = "保存当前窗口位置。"; diff --git a/MousePortal/Resources/zh-Hant.lproj/Localizable.strings b/MousePortal/Resources/zh-Hant.lproj/Localizable.strings index de3c008..6aa3e71 100644 --- a/MousePortal/Resources/zh-Hant.lproj/Localizable.strings +++ b/MousePortal/Resources/zh-Hant.lproj/Localizable.strings @@ -149,5 +149,31 @@ "layout.delete_disabled_locked" = "已鎖定的排列無法刪除"; "layout.delete_disabled_current" = "目前排列無法刪除"; "layout.delete_disabled_last_remaining" = "至少要保留一個排列"; +"layout.delete_disabled_window_snapshots" = "請先刪除該排列下已儲存的視窗佈局"; "portal.edit" = "編輯"; "portal.delete" = "刪除"; + +/* Window Recovery */ +"settings.window_recovery" = "視窗還原"; +"window_recovery.enabled" = "啟用視窗還原"; +"window_recovery.remember_before_sleep" = "睡眠或鎖定前記住視窗"; +"window_recovery.automatic" = "顯示器喚醒後自動還原"; +"window_recovery.behavior" = "喚醒還原"; +"window_recovery.save" = "儲存目前佈局"; +"window_recovery.ignored_apps" = "忽略的應用程式"; +"window_recovery.search_apps" = "搜尋應用程式"; +"window_recovery.ignored_apps_description" = "開啟應用程式右側開關,可在之後的快照中排除它。"; +"window_recovery.limitations" = "僅支援一般、非最小化、非全螢幕視窗;不還原 Split View、幕前管理和 Spaces。"; +"window_layout.show" = "顯示視窗佈局"; +"window_layout.show_help" = "在顯示器排列上疊加顯示已儲存的視窗。"; +"window_layout.no_saved" = "這個顯示器排列尚未儲存視窗佈局。"; +"window_layout.automatic_before_lock" = "鎖定前佈局"; +"window_layout.saved %lld" = "佈局 %lld"; +"window_layout.kind.automatic" = "自動"; +"window_layout.kind.manual" = "手動"; +"window_layout.promote_to_manual" = "提升為手動佈局"; +"window_layout.apply" = "切換到這個佈局"; +"window_layout.rename_title" = "重新命名視窗佈局"; +"window_layout.name_placeholder" = "輸入視窗佈局名稱"; +"window_recovery.enable_in_settings" = "請先在設定中開啟視窗還原。"; +"window_recovery.save_help" = "儲存目前的視窗位置。"; diff --git a/MousePortal/Services/WindowLayoutEngine.swift b/MousePortal/Services/WindowLayoutEngine.swift new file mode 100644 index 0000000..8012d1b --- /dev/null +++ b/MousePortal/Services/WindowLayoutEngine.swift @@ -0,0 +1,147 @@ +import Foundation +import CoreGraphics + +struct WindowMatchCandidate: Equatable { + let windowTitle: String? + let documentURL: String? + let windowIdentifier: String? + let role: String + let subrole: String + let windowIndex: Int + let frame: CGRect +} + +enum WindowLayoutEngine { + static func savedFrame( + for placement: WindowPlacement, + on display: WindowDisplaySnapshot + ) -> CGRect { + let displayFrame = display.frame.cgRect + return CGRect( + x: displayFrame.minX + placement.relativeX * displayFrame.width, + y: displayFrame.minY + placement.relativeY * displayFrame.height, + width: placement.width, + height: placement.height + ) + } + + static func display( + containing windowFrame: CGRect, + from displays: [WindowDisplaySnapshot] + ) -> WindowDisplaySnapshot? { + displays.max { lhs, rhs in + intersectionArea(windowFrame, lhs.frame.cgRect) < intersectionArea(windowFrame, rhs.frame.cgRect) + }.flatMap { display in + intersectionArea(windowFrame, display.frame.cgRect) > 0 ? display : nil + } + } + + static func targetFrame( + for placement: WindowPlacement, + on display: WindowDisplaySnapshot + ) -> CGRect { + let displayFrame = display.frame.cgRect + let visibleFrame = display.visibleFrame.cgRect + + let width = min(max(placement.width, 1), visibleFrame.width) + let height = min(max(placement.height, 1), visibleFrame.height) + let desiredX = displayFrame.minX + placement.relativeX * displayFrame.width + let desiredY = displayFrame.minY + placement.relativeY * displayFrame.height + let x = min(max(desiredX, visibleFrame.minX), visibleFrame.maxX - width) + let y = min(max(desiredY, visibleFrame.minY), visibleFrame.maxY - height) + + return CGRect(x: x, y: y, width: width, height: height) + } + + static func framesMatch( + _ actual: CGRect?, + target: CGRect, + tolerance: CGFloat = 2 + ) -> Bool { + guard let actual else { return false } + + return abs(actual.minX - target.minX) <= tolerance && + abs(actual.minY - target.minY) <= tolerance && + abs(actual.width - target.width) <= tolerance && + abs(actual.height - target.height) <= tolerance + } + + static func bestMatchIndex( + for placement: WindowPlacement, + candidates: [WindowMatchCandidate], + excluding usedIndices: Set, + preferredIndex: Int? = nil + ) -> Int? { + if let preferredIndex, + candidates.indices.contains(preferredIndex), + !usedIndices.contains(preferredIndex), + candidates[preferredIndex].role == placement.role, + candidates[preferredIndex].subrole == placement.subrole { + return preferredIndex + } + + return candidates.indices + .filter { !usedIndices.contains($0) } + .filter { + candidates[$0].role == placement.role && + candidates[$0].subrole == placement.subrole && + hasMeaningfulIdentityMatch(placement, candidates[$0]) + } + .max { lhs, rhs in + matchScore(placement, candidates[lhs]) < matchScore(placement, candidates[rhs]) + } + } + + private static func hasMeaningfulIdentityMatch( + _ placement: WindowPlacement, + _ candidate: WindowMatchCandidate + ) -> Bool { + exactNonEmptyMatch(placement.documentURL, candidate.documentURL) || + exactNonEmptyMatch(placement.windowIdentifier, candidate.windowIdentifier) || + exactNonEmptyMatch(placement.windowTitle, candidate.windowTitle) + } + + private static func exactNonEmptyMatch(_ saved: String?, _ current: String?) -> Bool { + guard let saved, + !saved.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { + return false + } + return saved == current + } + + private static func matchScore(_ placement: WindowPlacement, _ candidate: WindowMatchCandidate) -> Double { + var score = 0.0 + + if let documentURL = placement.documentURL, + documentURL == candidate.documentURL { + score += 1_000 + } + + if let identifier = placement.windowIdentifier, + identifier == candidate.windowIdentifier { + score += 600 + } + + if let title = placement.windowTitle, + title == candidate.windowTitle { + score += 300 + } + + if placement.windowIndex == candidate.windowIndex { + score += 120 + } + + let widthDifference = abs(placement.width - candidate.frame.width) + let heightDifference = abs(placement.height - candidate.frame.height) + let sizeDifference = widthDifference + heightDifference + score += max(0, 80 - sizeDifference / 20) + + return score + } + + private static func intersectionArea(_ lhs: CGRect, _ rhs: CGRect) -> CGFloat { + let intersection = lhs.intersection(rhs) + guard !intersection.isNull else { return 0 } + return intersection.width * intersection.height + } +} diff --git a/MousePortal/Services/WindowLayoutService.swift b/MousePortal/Services/WindowLayoutService.swift new file mode 100644 index 0000000..c6a44fc --- /dev/null +++ b/MousePortal/Services/WindowLayoutService.swift @@ -0,0 +1,835 @@ +import Foundation +import AppKit +import ApplicationServices +import ColorSync + +protocol WindowLayoutSystemProviding { + func currentDisplays() -> [WindowDisplaySnapshot] + func captureWindows( + displays: [WindowDisplaySnapshot], + ignoring bundleIdentifiers: Set, + runtimeSnapshotID: UUID? + ) -> [WindowPlacement] + func restoreWindows( + from snapshot: WindowLayoutSnapshot, + currentDisplays: [WindowDisplaySnapshot] + ) -> WindowRestoreResult + func runningApplications() -> [WindowApplicationOption] + func logDisplayState(event: String) +} + +final class SystemWindowLayoutProvider: WindowLayoutSystemProviding { + private struct AccessibleWindow { + let element: AXUIElement + let candidate: WindowMatchCandidate + } + + private var retainedSnapshotID: UUID? + private var retainedWindows: [AXUIElement] = [] + + func currentDisplays() -> [WindowDisplaySnapshot] { + activeDisplayIDs().map { displayID in + let frame = CGDisplayBounds(displayID) + return WindowDisplaySnapshot( + identity: displayIdentity(for: displayID), + frame: WindowRect(frame), + visibleFrame: WindowRect(visibleFrame(for: displayID, displayFrame: frame)), + isMain: displayID == CGMainDisplayID() + ) + } + } + + func captureWindows( + displays: [WindowDisplaySnapshot], + ignoring bundleIdentifiers: Set, + runtimeSnapshotID: UUID? + ) -> [WindowPlacement] { + let ownBundleIdentifier = Bundle.main.bundleIdentifier + var placements: [WindowPlacement] = [] + var capturedWindows: [AXUIElement] = [] + + for application in restorableApplications() { + guard let bundleIdentifier = application.bundleIdentifier, + bundleIdentifier != ownBundleIdentifier, + !bundleIdentifiers.contains(bundleIdentifier) else { + continue + } + + let windows = accessibleWindows(for: application) + for window in windows { + guard let display = WindowLayoutEngine.display( + containing: window.candidate.frame, + from: displays + ) else { + continue + } + + let displayFrame = display.frame.cgRect + let windowFrame = window.candidate.frame + placements.append(WindowPlacement( + bundleIdentifier: bundleIdentifier, + applicationName: application.localizedName ?? bundleIdentifier, + windowTitle: window.candidate.windowTitle, + documentURL: window.candidate.documentURL, + windowIdentifier: window.candidate.windowIdentifier, + role: window.candidate.role, + subrole: window.candidate.subrole, + windowIndex: window.candidate.windowIndex, + displayIdentity: display.identity, + relativeX: (windowFrame.minX - displayFrame.minX) / displayFrame.width, + relativeY: (windowFrame.minY - displayFrame.minY) / displayFrame.height, + width: windowFrame.width, + height: windowFrame.height + )) + if runtimeSnapshotID != nil { + capturedWindows.append(window.element) + } + } + } + + if let runtimeSnapshotID { + retainedSnapshotID = runtimeSnapshotID + retainedWindows = capturedWindows + } + + return placements + } + + func restoreWindows( + from snapshot: WindowLayoutSnapshot, + currentDisplays: [WindowDisplaySnapshot] + ) -> WindowRestoreResult { + let displaysByIdentity = Dictionary( + currentDisplays.map { ($0.identity, $0) }, + uniquingKeysWith: { first, _ in first } + ) + let applicationsByBundleIdentifier = restorableApplications().reduce( + into: [String: [NSRunningApplication]]() + ) { applicationsByIdentifier, application in + guard let bundleIdentifier = application.bundleIdentifier else { return } + applicationsByIdentifier[bundleIdentifier, default: []].append(application) + } + + var restoredCount = 0 + var skippedCount = 0 + var failedCount = 0 + var restoreAttempts: [( + element: AXUIElement, + targetFrame: CGRect, + initialApplySucceeded: Bool + )] = [] + + let runtimeWindows = retainedSnapshotID == snapshot.id ? retainedWindows : [] + let placementsByBundleIdentifier = Dictionary( + grouping: snapshot.windows.enumerated(), + by: { $0.element.bundleIdentifier } + ) + + for (bundleIdentifier, indexedPlacements) in placementsByBundleIdentifier { + guard let applications = applicationsByBundleIdentifier[bundleIdentifier] else { + skippedCount += indexedPlacements.count + continue + } + + let windows = applications.flatMap { accessibleWindows(for: $0) } + let candidates = windows.map(\.candidate) + var usedIndices = Set() + + for (placementIndex, placement) in indexedPlacements { + let preferredIndex = runtimeWindows.indices.contains(placementIndex) ? windows.indices.first { index in + !usedIndices.contains(index) && CFEqual(runtimeWindows[placementIndex], windows[index].element) + } : nil + + guard let display = displaysByIdentity[placement.displayIdentity], + let matchIndex = WindowLayoutEngine.bestMatchIndex( + for: placement, + candidates: candidates, + excluding: usedIndices, + preferredIndex: preferredIndex + ) else { + skippedCount += 1 + continue + } + + usedIndices.insert(matchIndex) + let targetFrame = WindowLayoutEngine.targetFrame(for: placement, on: display) + let window = windows[matchIndex].element + restoreAttempts.append(( + element: window, + targetFrame: targetFrame, + initialApplySucceeded: applyFrame(targetFrame, to: window) + )) + } + } + + if !restoreAttempts.isEmpty { + RunLoop.current.run(until: Date().addingTimeInterval(0.03)) + + var retried = false + for attempt in restoreAttempts { + let actualFrame = frame(for: attempt.element) + guard !attempt.initialApplySucceeded || + !WindowLayoutEngine.framesMatch(actualFrame, target: attempt.targetFrame) else { + continue + } + + _ = applyFrame(attempt.targetFrame, to: attempt.element) + retried = true + } + + if retried { + RunLoop.current.run(until: Date().addingTimeInterval(0.03)) + } + + for attempt in restoreAttempts { + if WindowLayoutEngine.framesMatch( + frame(for: attempt.element), + target: attempt.targetFrame + ) { + restoredCount += 1 + } else { + failedCount += 1 + } + } + } + + return WindowRestoreResult( + restoredCount: restoredCount, + skippedCount: skippedCount, + failedCount: failedCount + ) + } + + func runningApplications() -> [WindowApplicationOption] { + let ownBundleIdentifier = Bundle.main.bundleIdentifier + return restorableApplications() + .compactMap { application -> WindowApplicationOption? in + guard let bundleIdentifier = application.bundleIdentifier, + bundleIdentifier != ownBundleIdentifier else { + return nil + } + return WindowApplicationOption( + bundleIdentifier: bundleIdentifier, + name: application.localizedName ?? bundleIdentifier + ) + } + .reduce(into: [String: WindowApplicationOption]()) { result, option in + result[option.bundleIdentifier] = option + } + .values + .sorted { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending } + } + + func logDisplayState(event: String) { + let activeIDs = Set(activeDisplayIDs()) + let onlineIDs = onlineDisplayIDs() + let details = onlineIDs.map { displayID in + let frame = CGDisplayBounds(displayID) + let identity = displayIdentity(for: displayID).rawValue + return "id=\(displayID) identity=\(identity) online=\(CGDisplayIsOnline(displayID) != 0) active=\(activeIDs.contains(displayID)) asleep=\(CGDisplayIsAsleep(displayID) != 0) main=\(displayID == CGMainDisplayID()) frame=\(Int(frame.minX)),\(Int(frame.minY)),\(Int(frame.width))x\(Int(frame.height))" + } + print("窗口恢复显示器状态 [\(event)] online=\(onlineIDs.count) active=\(activeIDs.count) \(details.joined(separator: " | "))") + } + + private func restorableApplications() -> [NSRunningApplication] { + NSWorkspace.shared.runningApplications.filter { + !$0.isTerminated && $0.activationPolicy == .regular + } + } + + private func accessibleWindows(for application: NSRunningApplication) -> [AccessibleWindow] { + let applicationElement = AXUIElementCreateApplication(application.processIdentifier) + guard let values = attribute(kAXWindowsAttribute as CFString, from: applicationElement) as? [AXUIElement] else { + return [] + } + + return values.enumerated().compactMap { index, window in + guard stringAttribute(kAXRoleAttribute as CFString, from: window) == (kAXWindowRole as String), + stringAttribute(kAXSubroleAttribute as CFString, from: window) == (kAXStandardWindowSubrole as String), + boolAttribute(kAXMinimizedAttribute as CFString, from: window) != true, + boolAttribute("AXFullScreen" as CFString, from: window) != true, + let frame = frame(for: window) else { + return nil + } + + return AccessibleWindow( + element: window, + candidate: WindowMatchCandidate( + windowTitle: stringAttribute(kAXTitleAttribute as CFString, from: window), + documentURL: documentAttribute(from: window), + windowIdentifier: stringAttribute(kAXIdentifierAttribute as CFString, from: window), + role: kAXWindowRole as String, + subrole: kAXStandardWindowSubrole as String, + windowIndex: index, + frame: frame + ) + ) + } + } + + private func frame(for element: AXUIElement) -> CGRect? { + guard let positionValue = attribute(kAXPositionAttribute as CFString, from: element), + let sizeValue = attribute(kAXSizeAttribute as CFString, from: element), + CFGetTypeID(positionValue) == AXValueGetTypeID(), + CFGetTypeID(sizeValue) == AXValueGetTypeID() else { + return nil + } + + var position = CGPoint.zero + var size = CGSize.zero + guard AXValueGetValue(positionValue as! AXValue, .cgPoint, &position), + AXValueGetValue(sizeValue as! AXValue, .cgSize, &size), + size.width > 0, + size.height > 0 else { + return nil + } + return CGRect(origin: position, size: size) + } + + private func attribute(_ name: CFString, from element: AXUIElement) -> CFTypeRef? { + var value: CFTypeRef? + guard AXUIElementCopyAttributeValue(element, name, &value) == .success else { + return nil + } + return value + } + + private func stringAttribute(_ name: CFString, from element: AXUIElement) -> String? { + attribute(name, from: element) as? String + } + + private func boolAttribute(_ name: CFString, from element: AXUIElement) -> Bool? { + attribute(name, from: element) as? Bool + } + + private func documentAttribute(from element: AXUIElement) -> String? { + let value = attribute(kAXDocumentAttribute as CFString, from: element) + if let url = value as? URL { + return url.absoluteString + } + return value as? String + } + + private func setPosition(_ position: CGPoint, for element: AXUIElement) -> Bool { + var position = position + guard let value = AXValueCreate(.cgPoint, &position) else { return false } + return AXUIElementSetAttributeValue(element, kAXPositionAttribute as CFString, value) == .success + } + + private func applyFrame(_ frame: CGRect, to element: AXUIElement) -> Bool { + let sizeSucceeded = setSize(frame.size, for: element) + let positionSucceeded = setPosition(frame.origin, for: element) + return sizeSucceeded && positionSucceeded + } + + private func setSize(_ size: CGSize, for element: AXUIElement) -> Bool { + var size = size + guard let value = AXValueCreate(.cgSize, &size) else { return false } + return AXUIElementSetAttributeValue(element, kAXSizeAttribute as CFString, value) == .success + } + + private func visibleFrame(for displayID: CGDirectDisplayID, displayFrame: CGRect) -> CGRect { + guard let screen = NSScreen.screens.first(where: { screen in + (screen.deviceDescription[NSDeviceDescriptionKey("NSScreenNumber")] as? NSNumber)?.uint32Value == displayID + }) else { + return displayFrame + } + + let screenFrame = screen.frame + let appKitVisibleFrame = screen.visibleFrame + return CGRect( + x: displayFrame.minX + (appKitVisibleFrame.minX - screenFrame.minX), + y: displayFrame.minY + (screenFrame.maxY - appKitVisibleFrame.maxY), + width: appKitVisibleFrame.width, + height: appKitVisibleFrame.height + ) + } + + private func displayIdentity(for displayID: CGDirectDisplayID) -> WindowDisplayIdentity { + if let uuid = CGDisplayCreateUUIDFromDisplayID(displayID) { + return WindowDisplayIdentity(rawValue: CFUUIDCreateString(nil, uuid.takeRetainedValue()) as String) + } + + return WindowDisplayIdentity( + rawValue: "\(CGDisplayVendorNumber(displayID))-\(CGDisplayModelNumber(displayID))-\(CGDisplaySerialNumber(displayID))-\(CGDisplayUnitNumber(displayID))" + ) + } + + private func activeDisplayIDs() -> [CGDirectDisplayID] { + displayIDs(using: CGGetActiveDisplayList) + } + + private func onlineDisplayIDs() -> [CGDirectDisplayID] { + displayIDs(using: CGGetOnlineDisplayList) + } + + private func displayIDs( + using query: (UInt32, UnsafeMutablePointer?, UnsafeMutablePointer) -> CGError + ) -> [CGDirectDisplayID] { + var count: UInt32 = 0 + guard query(0, nil, &count) == .success, count > 0 else { return [] } + + var identifiers = [CGDirectDisplayID](repeating: 0, count: Int(count)) + guard query(count, &identifiers, &count) == .success else { return [] } + return Array(identifiers.prefix(Int(count))) + } +} + +@MainActor +final class WindowLayoutService: ObservableObject { + static let shared = WindowLayoutService() + + @Published private(set) var snapshots: [WindowLayoutSnapshot] + @Published private(set) var state: WindowRecoveryState = .normal + @Published private(set) var lastRestoreResult: WindowRestoreResult? + @Published private(set) var availableApplications: [WindowApplicationOption] = [] + @Published private(set) var isEnabled: Bool + @Published private(set) var rememberBeforeSleepOrLockEnabled: Bool + @Published private(set) var automaticRestoreEnabled: Bool + @Published private(set) var ignoredBundleIdentifiers: Set + + private let system: WindowLayoutSystemProviding + private let defaults: UserDefaults + private let accessibilityIsTrusted: () -> Bool + private let legacySnapshotKey = "windowLayoutSnapshot" + private let snapshotsKey = "windowLayoutSnapshots" + private let enabledKey = "windowRecoveryEnabled" + private let rememberBeforeSleepOrLockKey = "windowRecoveryRememberBeforeSleepOrLock" + private let automaticRestoreKey = "windowRecoveryAutomaticRestore" + private let ignoredApplicationsKey = "windowRecoveryIgnoredApplications" + private let requiredStableSampleCount = 3 + private let stabilityCheckInterval: TimeInterval = 0.5 + private let stabilityTimeout: TimeInterval = 8 + private let finalSettlingDelay: TimeInterval = 0.4 + + private var isMonitoring = false + private var sessionIsActive = true + private var topologyIsReady = false + private var stableSampleCount = 0 + private var lastTopologySignature: String? + private var waitStartedAt: Date? + private var stabilityTimer: Timer? + private var recoverySnapshotID: UUID? + + init( + system: WindowLayoutSystemProviding = SystemWindowLayoutProvider(), + defaults: UserDefaults = .standard, + accessibilityIsTrusted: @escaping () -> Bool = AXIsProcessTrusted + ) { + self.system = system + self.defaults = defaults + self.accessibilityIsTrusted = accessibilityIsTrusted + self.isEnabled = defaults.bool(forKey: enabledKey) + let savedRememberSetting = defaults.object(forKey: rememberBeforeSleepOrLockKey) as? Bool + self.rememberBeforeSleepOrLockEnabled = savedRememberSetting ?? true + self.automaticRestoreEnabled = defaults.bool(forKey: automaticRestoreKey) + self.ignoredBundleIdentifiers = Set(defaults.stringArray(forKey: ignoredApplicationsKey) ?? []) + + if savedRememberSetting == nil { + defaults.set(true, forKey: rememberBeforeSleepOrLockKey) + } + + if let data = defaults.data(forKey: snapshotsKey), + let savedSnapshots = try? JSONDecoder().decode([WindowLayoutSnapshot].self, from: data) { + snapshots = savedSnapshots + } else if let data = defaults.data(forKey: legacySnapshotKey), + let legacySnapshot = try? JSONDecoder().decode(WindowLayoutSnapshot.self, from: data) { + snapshots = [legacySnapshot] + if let migratedData = try? JSONEncoder().encode(snapshots) { + defaults.set(migratedData, forKey: snapshotsKey) + } + } else { + snapshots = [] + } + } + + var snapshot: WindowLayoutSnapshot? { + snapshots.max { $0.capturedAt < $1.capturedAt } + } + + func snapshots(matching displayLayoutSignature: String) -> [WindowLayoutSnapshot] { + snapshots + .filter { $0.displayLayoutSignature == displayLayoutSignature } + .sorted { lhs, rhs in + if lhs.kind != rhs.kind { + return lhs.kind == .automatic + } + return lhs.capturedAt < rhs.capturedAt + } + } + + func startMonitoring() { + guard !isMonitoring else { return } + isMonitoring = true + + let workspaceCenter = NSWorkspace.shared.notificationCenter + workspaceCenter.addObserver(self, selector: #selector(sessionDidResignActive), name: NSWorkspace.sessionDidResignActiveNotification, object: nil) + workspaceCenter.addObserver(self, selector: #selector(sessionDidBecomeActive), name: NSWorkspace.sessionDidBecomeActiveNotification, object: nil) + workspaceCenter.addObserver(self, selector: #selector(systemWillSleep), name: NSWorkspace.willSleepNotification, object: nil) + workspaceCenter.addObserver(self, selector: #selector(systemDidWake), name: NSWorkspace.didWakeNotification, object: nil) + NotificationCenter.default.addObserver( + self, + selector: #selector(screenParametersDidChange), + name: NSApplication.didChangeScreenParametersNotification, + object: nil + ) + } + + func stopMonitoring() { + guard isMonitoring else { return } + isMonitoring = false + stabilityTimer?.invalidate() + stabilityTimer = nil + NSWorkspace.shared.notificationCenter.removeObserver(self) + NotificationCenter.default.removeObserver(self) + } + + func setEnabled(_ enabled: Bool) { + guard enabled != isEnabled else { return } + isEnabled = enabled + defaults.set(enabled, forKey: enabledKey) + + if enabled && rememberBeforeSleepOrLockEnabled { + _ = saveAutomaticLayout() + } else { + resetRecoveryState() + } + } + + func setRememberBeforeSleepOrLockEnabled(_ enabled: Bool) { + guard enabled != rememberBeforeSleepOrLockEnabled else { return } + rememberBeforeSleepOrLockEnabled = enabled + defaults.set(enabled, forKey: rememberBeforeSleepOrLockKey) + + if enabled && isEnabled { + _ = saveAutomaticLayout() + } else { + resetRecoveryState() + } + } + + func setAutomaticRestoreEnabled(_ enabled: Bool) { + guard enabled != automaticRestoreEnabled else { return } + automaticRestoreEnabled = enabled + defaults.set(enabled, forKey: automaticRestoreKey) + + if !enabled { + resetRecoveryState() + } + } + + func setApplicationIgnored(_ bundleIdentifier: String, ignored: Bool) { + if ignored { + ignoredBundleIdentifiers.insert(bundleIdentifier) + } else { + ignoredBundleIdentifiers.remove(bundleIdentifier) + } + defaults.set(Array(ignoredBundleIdentifiers).sorted(), forKey: ignoredApplicationsKey) + refreshAvailableApplications() + } + + func refreshAvailableApplications() { + let previousApplications = Dictionary( + uniqueKeysWithValues: availableApplications.map { ($0.bundleIdentifier, $0) } + ) + let savedApplicationNames = snapshots + .flatMap(\.windows) + .reduce(into: [String: String]()) { names, placement in + if names[placement.bundleIdentifier] == nil { + names[placement.bundleIdentifier] = placement.applicationName + } + } + var applicationsByIdentifier = Dictionary( + uniqueKeysWithValues: system.runningApplications().map { ($0.bundleIdentifier, $0) } + ) + + for bundleIdentifier in ignoredBundleIdentifiers + where applicationsByIdentifier[bundleIdentifier] == nil { + applicationsByIdentifier[bundleIdentifier] = WindowApplicationOption( + bundleIdentifier: bundleIdentifier, + name: previousApplications[bundleIdentifier]?.name + ?? savedApplicationNames[bundleIdentifier] + ?? bundleIdentifier + ) + } + + availableApplications = applicationsByIdentifier.values.sorted { + $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending + } + } + + @discardableResult + func saveCurrentLayout() -> WindowLayoutSnapshot? { + guard let newSnapshot = captureSnapshot(kind: .manual) else { return nil } + snapshots.append(newSnapshot) + persistSnapshots() + lastRestoreResult = nil + resetRecoveryState() + return newSnapshot + } + + @discardableResult + func promoteAutomaticSnapshot(id: UUID) -> Bool { + guard let index = snapshots.firstIndex(where: { $0.id == id }), + snapshots[index].kind == .automatic else { + return false + } + + snapshots[index].kind = .manual + persistSnapshots() + return true + } + + @discardableResult + func renameSnapshot(id: UUID, to name: String) -> Bool { + let trimmedName = name.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmedName.isEmpty, + let index = snapshots.firstIndex(where: { $0.id == id }) else { + return false + } + + snapshots[index].name = trimmedName + persistSnapshots() + return true + } + + @discardableResult + func deleteSnapshot(id: UUID) -> Bool { + guard snapshots.contains(where: { $0.id == id }) else { return false } + + if recoverySnapshotID == id { + resetRecoveryState() + } + snapshots.removeAll { $0.id == id } + persistSnapshots() + return true + } + + @discardableResult + func restoreSavedLayout(snapshotID: UUID? = nil) -> WindowRestoreResult? { + guard accessibilityIsTrusted() else { return nil } + let targetSnapshot = snapshotID.flatMap { id in + snapshots.first { $0.id == id } + } ?? recoverySnapshot ?? snapshot + guard let targetSnapshot else { return nil } + let currentDisplays = system.currentDisplays() + let expectedIdentities = Set(targetSnapshot.displays.map(\.identity)) + let currentIdentities = Set(currentDisplays.map(\.identity)) + guard expectedIdentities.isSubset(of: currentIdentities) else { return nil } + + state = .restoring + let result = system.restoreWindows(from: targetSnapshot, currentDisplays: currentDisplays) + lastRestoreResult = result + resetRecoveryState() + return result + } + + @objc private func sessionDidResignActive() { + sessionIsActive = false + freezeSnapshotIfNeeded(event: "sessionDidResignActive") + } + + @objc private func sessionDidBecomeActive() { + sessionIsActive = true + guard automaticRestoreEnabled else { + resetRecoveryState() + return + } + system.logDisplayState(event: "sessionDidBecomeActive") + + if topologyIsReady { + completeStableTopologyWait() + } else if state == .snapshotFrozen { + beginWaitingForDisplays() + } + } + + @objc private func systemWillSleep() { + freezeSnapshotIfNeeded(event: "willSleep") + } + + @objc private func systemDidWake() { + handleWake(event: "didWake") + } + + @objc private func screenParametersDidChange() { + guard lockRecoveryEnabled else { return } + guard automaticRestoreEnabled else { + resetRecoveryState() + return + } + system.logDisplayState(event: "didChangeScreenParameters") + + if state == .snapshotFrozen && sessionIsActive { + beginWaitingForDisplays() + } else if state == .waitingForDisplays { + sampleDisplayTopology() + } + } + + private func freezeSnapshotIfNeeded(event: String) { + guard lockRecoveryEnabled else { return } + system.logDisplayState(event: event) + guard state == .normal else { return } + guard let automaticSnapshot = saveAutomaticLayout() else { return } + recoverySnapshotID = automaticSnapshot.id + state = .snapshotFrozen + } + + private func handleWake(event: String) { + guard lockRecoveryEnabled else { return } + guard automaticRestoreEnabled else { + resetRecoveryState() + return + } + system.logDisplayState(event: event) + guard recoverySnapshot != nil, + state == .snapshotFrozen || state == .waitingForDisplays else { + return + } + beginWaitingForDisplays() + } + + private func beginWaitingForDisplays() { + guard automaticRestoreEnabled else { + resetRecoveryState() + return + } + guard recoverySnapshot != nil else { return } + state = .waitingForDisplays + topologyIsReady = false + stableSampleCount = 0 + lastTopologySignature = nil + waitStartedAt = Date() + stabilityTimer?.invalidate() + sampleDisplayTopology() + stabilityTimer = Timer.scheduledTimer( + withTimeInterval: stabilityCheckInterval, + repeats: true + ) { [weak self] _ in + guard let service = self else { return } + Task { @MainActor [service] in + service.sampleDisplayTopology() + } + } + } + + private func sampleDisplayTopology() { + guard state == .waitingForDisplays, let snapshot = recoverySnapshot else { return } + let currentDisplays = system.currentDisplays() + let currentSignature = topologySignature(for: currentDisplays) + let expectedIdentities = Set(snapshot.displays.map(\.identity)) + let currentIdentities = Set(currentDisplays.map(\.identity)) + let isExpectedTopology = expectedIdentities == currentIdentities && + currentSignature == snapshot.topologySignature + + if isExpectedTopology && currentSignature == lastTopologySignature { + stableSampleCount += 1 + } else if isExpectedTopology { + lastTopologySignature = currentSignature + stableSampleCount = 1 + } else { + lastTopologySignature = nil + stableSampleCount = 0 + } + + if stableSampleCount >= requiredStableSampleCount { + stabilityTimer?.invalidate() + stabilityTimer = nil + topologyIsReady = true + DispatchQueue.main.asyncAfter(deadline: .now() + finalSettlingDelay) { [weak self] in + self?.completeStableTopologyWait() + } + return + } + + if let waitStartedAt, + Date().timeIntervalSince(waitStartedAt) >= stabilityTimeout { + stabilityTimer?.invalidate() + stabilityTimer = nil + state = .snapshotFrozen + print("窗口恢复等待显示器超时,保留冻结快照并等待下一次显示器变化") + } + } + + private func completeStableTopologyWait() { + guard state == .waitingForDisplays, + automaticRestoreEnabled, + topologyIsReady, + sessionIsActive, + let snapshot = recoverySnapshot else { + return + } + + topologyIsReady = false + guard !snapshot.windows.isEmpty else { + state = .normal + return + } + _ = restoreSavedLayout() + } + + private func topologySignature(for displays: [WindowDisplaySnapshot]) -> String { + displays + .map(\.topologyComponent) + .sorted() + .joined(separator: "|") + } + + private var recoverySnapshot: WindowLayoutSnapshot? { + guard let recoverySnapshotID else { return nil } + return snapshots.first { $0.id == recoverySnapshotID } + } + + private var lockRecoveryEnabled: Bool { + isEnabled && rememberBeforeSleepOrLockEnabled + } + + private func captureSnapshot(kind: WindowLayoutSnapshotKind) -> WindowLayoutSnapshot? { + guard accessibilityIsTrusted() else { return nil } + let displays = system.currentDisplays() + guard !displays.isEmpty else { return nil } + let snapshotID = UUID() + + return WindowLayoutSnapshot( + id: snapshotID, + kind: kind, + capturedAt: Date(), + displays: displays, + windows: system.captureWindows( + displays: displays, + ignoring: ignoredBundleIdentifiers, + runtimeSnapshotID: kind == .automatic ? snapshotID : nil + ) + ) + } + + @discardableResult + private func saveAutomaticLayout() -> WindowLayoutSnapshot? { + guard let newSnapshot = captureSnapshot(kind: .automatic) else { return nil } + snapshots.removeAll { + $0.kind == .automatic && + $0.displayLayoutSignature == newSnapshot.displayLayoutSignature + } + snapshots.append(newSnapshot) + persistSnapshots() + return newSnapshot + } + + private func persistSnapshots() { + if let data = try? JSONEncoder().encode(snapshots) { + defaults.set(data, forKey: snapshotsKey) + } + } + + private func resetRecoveryState() { + stabilityTimer?.invalidate() + stabilityTimer = nil + topologyIsReady = false + stableSampleCount = 0 + lastTopologySignature = nil + waitStartedAt = nil + recoverySnapshotID = nil + state = .normal + } +} diff --git a/MousePortal/Utils/AppUIRules.swift b/MousePortal/Utils/AppUIRules.swift index 0a6288c..c076fed 100644 --- a/MousePortal/Utils/AppUIRules.swift +++ b/MousePortal/Utils/AppUIRules.swift @@ -12,11 +12,13 @@ enum LayoutSidebarRules { currentLayoutID: UUID?, targetLayoutID: UUID, portalCount: Int, + windowSnapshotCount: Int = 0, isLocked: Bool ) -> LayoutDeletionAction { guard layoutCount > 1 else { return .unavailable } guard currentLayoutID != targetLayoutID else { return .unavailable } guard !isLocked else { return .unavailable } + guard windowSnapshotCount == 0 else { return .unavailable } return portalCount == 0 ? .deleteImmediately : .requireConfirmation } @@ -24,6 +26,7 @@ enum LayoutSidebarRules { layoutCount: Int, currentLayoutID: UUID?, targetLayoutID: UUID, + windowSnapshotCount: Int = 0, isLocked: Bool ) -> Bool { deletionAction( @@ -31,6 +34,7 @@ enum LayoutSidebarRules { currentLayoutID: currentLayoutID, targetLayoutID: targetLayoutID, portalCount: 0, + windowSnapshotCount: windowSnapshotCount, isLocked: isLocked ) != .unavailable } @@ -39,6 +43,7 @@ enum LayoutSidebarRules { layoutCount: Int, currentLayoutID: UUID?, targetLayoutID: UUID, + windowSnapshotCount: Int = 0, isLocked: Bool ) -> String { if isLocked { @@ -50,6 +55,9 @@ enum LayoutSidebarRules { if layoutCount <= 1 { return L("layout.delete_disabled_last_remaining") } + if windowSnapshotCount > 0 { + return L("layout.delete_disabled_window_snapshots") + } return L("layout.delete") } @@ -83,3 +91,18 @@ enum AccessibilityPermissionPresentation { L("menu.accessibility_status %@", statusText(isGranted: isGranted)) } } + +enum WindowApplicationSearch { + static func filtered( + _ applications: [WindowApplicationOption], + query: String + ) -> [WindowApplicationOption] { + let query = query.trimmingCharacters(in: .whitespacesAndNewlines) + guard !query.isEmpty else { return applications } + + return applications.filter { + $0.name.localizedCaseInsensitiveContains(query) || + $0.bundleIdentifier.localizedCaseInsensitiveContains(query) + } + } +} diff --git a/MousePortal/Views/DisplayLayoutView.swift b/MousePortal/Views/DisplayLayoutView.swift index 0cc4c42..25d453b 100644 --- a/MousePortal/Views/DisplayLayoutView.swift +++ b/MousePortal/Views/DisplayLayoutView.swift @@ -54,6 +54,8 @@ struct DisplayLayoutView: View { /// 单个显示器矩形视图 struct DisplayRectView: View { let display: DisplayInfo + var hotkeyConfig: Binding? = nil + var onHotkeySave: (() -> Void)? = nil var onRename: (() -> Void)? = nil @State private var isRenameButtonHovered = false @@ -96,6 +98,15 @@ struct DisplayRectView: View { Text("\(display.width) × \(display.height)") .font(.caption) .foregroundColor(.secondary) + + if let hotkeyConfig { + HotkeyBadge( + config: hotkeyConfig, + onSave: onHotkeySave ?? {}, + showsDisplayName: false + ) + .padding(.top, 2) + } } .padding(.horizontal, 12) .padding(.vertical, 10) diff --git a/MousePortal/Views/PortalCanvasView.swift b/MousePortal/Views/PortalCanvasView.swift index 4a357cd..b833648 100644 --- a/MousePortal/Views/PortalCanvasView.swift +++ b/MousePortal/Views/PortalCanvasView.swift @@ -7,6 +7,9 @@ struct PortalCanvasView: View { let portals: [PortalPair] let drawingSession: PortalDrawingSession let tempPortalColor: PortalColor + var windowSnapshot: WindowLayoutSnapshot? = nil + var hotkeyConfigs: Binding<[HotkeyConfig]>? = nil + var onHotkeySave: (() -> Void)? = nil var selectedPortalID: UUID? var onPortalTap: ((PortalPair) -> Void)? = nil var onCanvasTap: (() -> Void)? = nil @@ -23,6 +26,8 @@ struct PortalCanvasView: View { ForEach(displays) { display in DisplayRectView( display: display, + hotkeyConfig: hotkeyConfigBinding(for: display), + onHotkeySave: onHotkeySave, onRename: onDisplayRename.map { rename in { rename(display) } } @@ -37,6 +42,22 @@ struct PortalCanvasView: View { ) } + if let windowSnapshot { + ForEach(Array(windowSnapshot.windows.enumerated()), id: \.offset) { _, placement in + if let display = windowSnapshot.displays.first(where: { + $0.identity == placement.displayIdentity + }) { + WindowPlacementOverlayView( + placement: placement, + frame: WindowLayoutEngine.savedFrame(for: placement, on: display), + totalBounds: totalBounds, + scale: scale, + offset: offset + ) + } + } + } + ForEach(sharedEdges) { edge in Path { path in let startX = (edge.start.x - totalBounds.minX) * scale + offset.x @@ -124,6 +145,52 @@ struct PortalCanvasView: View { } return nil } + + private func hotkeyConfigBinding(for display: DisplayInfo) -> Binding? { + guard let hotkeyConfigs, + let index = hotkeyConfigs.wrappedValue.firstIndex(where: { config in + if let layoutKey = config.displayLayoutKey { + return layoutKey == display.layoutKey + } + return config.legacyDisplayID == display.id + }) else { + return nil + } + return hotkeyConfigs[index] + } +} + +private struct WindowPlacementOverlayView: View { + let placement: WindowPlacement + let frame: CGRect + let totalBounds: CGRect + let scale: CGFloat + let offset: CGPoint + + var body: some View { + ZStack(alignment: .topLeading) { + RoundedRectangle(cornerRadius: 4) + .fill(Color.accentColor.opacity(0.18)) + RoundedRectangle(cornerRadius: 4) + .stroke(Color.accentColor.opacity(0.7), lineWidth: 1) + Text(placement.applicationName) + .font(.system(size: 9, weight: .medium)) + .lineLimit(1) + .foregroundColor(.primary.opacity(0.75)) + .padding(4) + } + .frame( + width: max(frame.width * scale, 2), + height: max(frame.height * scale, 2) + ) + .clipped() + .position( + x: (frame.midX - totalBounds.minX) * scale + offset.x, + y: (frame.midY - totalBounds.minY) * scale + offset.y + ) + .allowsHitTesting(false) + .accessibilityHidden(true) + } } struct PortalPairOverlayView: View { diff --git a/MousePortal/Views/SettingsView.swift b/MousePortal/Views/SettingsView.swift index f21b7a0..3082653 100644 --- a/MousePortal/Views/SettingsView.swift +++ b/MousePortal/Views/SettingsView.swift @@ -20,12 +20,103 @@ struct SettingsView: View { .tabItem { Label(L("settings.portals"), systemImage: "arrow.left.arrow.right") } + + WindowRecoverySettingsView() + .tabItem { + Label(L("settings.window_recovery"), systemImage: "macwindow.on.rectangle") + } } .frame(width: 550, height: 450) .id(languageService.refreshID) } } +/// 普通应用窗口布局保存与恢复。 +struct WindowRecoverySettingsView: View { + @ObservedObject private var service = WindowLayoutService.shared + @ObservedObject private var permissionService = PermissionService.shared + @State private var applicationSearchText = "" + + private var filteredApplications: [WindowApplicationOption] { + WindowApplicationSearch.filtered( + service.availableApplications, + query: applicationSearchText + ) + } + + var body: some View { + Form { + Section { + VStack(alignment: .leading, spacing: 5) { + Toggle(L("window_recovery.enabled"), isOn: Binding( + get: { service.isEnabled }, + set: { enabled in + if enabled && !permissionService.checkAccessibility() { + permissionService.requestAccessibility() + } else { + service.setEnabled(enabled) + AppDelegate.shared?.refreshMenu() + } + } + )) + + Text(L("window_recovery.limitations")) + .font(.caption) + .foregroundColor(.secondary) + } + + Toggle(L("window_recovery.remember_before_sleep"), isOn: Binding( + get: { service.rememberBeforeSleepOrLockEnabled }, + set: { service.setRememberBeforeSleepOrLockEnabled($0) } + )) + .disabled(!service.isEnabled) + + Toggle(L("window_recovery.automatic"), isOn: Binding( + get: { service.automaticRestoreEnabled }, + set: { service.setAutomaticRestoreEnabled($0) } + )) + .disabled(!service.isEnabled || !service.rememberBeforeSleepOrLockEnabled) + } header: { + Text(L("window_recovery.behavior")) + } + + if !service.availableApplications.isEmpty { + Section { + TextField( + L("window_recovery.search_apps"), + text: $applicationSearchText + ) + .textFieldStyle(.roundedBorder) + + ForEach(filteredApplications) { application in + Toggle(application.name, isOn: Binding( + get: { + service.ignoredBundleIdentifiers.contains(application.bundleIdentifier) + }, + set: { ignored in + service.setApplicationIgnored( + application.bundleIdentifier, + ignored: ignored + ) + } + )) + } + } header: { + Text(L("window_recovery.ignored_apps")) + } footer: { + Text(L("window_recovery.ignored_apps_description")) + } + } + + } + .formStyle(.grouped) + .onAppear { + _ = permissionService.checkAccessibility() + service.refreshAvailableApplications() + } + } +} + /// 通用设置 struct GeneralSettingsView: View { @ObservedObject private var permissionService = PermissionService.shared diff --git a/MousePortalTests/Models/DisplayLayoutTests.swift b/MousePortalTests/Models/DisplayLayoutTests.swift index a36694b..076066a 100644 --- a/MousePortalTests/Models/DisplayLayoutTests.swift +++ b/MousePortalTests/Models/DisplayLayoutTests.swift @@ -371,6 +371,22 @@ final class DisplayLayoutTests: XCTestCase { XCTAssertEqual(service.currentLayoutID, current.id) } + func testDeleteLayoutPreservesLayoutWithWindowSnapshots() { + let service = DisplayLayoutService.shared + let current = DisplayLayout(name: "Current", displays: sampleSingleDisplay()) + let withWindowSnapshot = DisplayLayout(name: "Saved Windows", displays: sampleDualDisplays()) + service.layouts = [current, withWindowSnapshot] + service.currentLayoutID = current.id + service.save() + + service.deleteLayout( + withWindowSnapshot, + preserving: Set([withWindowSnapshot.signature]) + ) + + XCTAssertEqual(service.layouts.map(\.id), [current.id, withWindowSnapshot.id]) + } + func testDeleteLockedLayoutIsIgnored() { let service = DisplayLayoutService.shared var locked = DisplayLayout(name: "Locked", displays: sampleSingleDisplay()) @@ -457,6 +473,51 @@ final class DisplayLayoutTests: XCTestCase { XCTAssertEqual(service.layouts.map(\.id), [current.id]) } + func testCleanUpUnusedLayoutsPreservesLayoutsWithWindowSnapshots() { + let service = DisplayLayoutService.shared + let current = DisplayLayout(name: "Current", displays: sampleSingleDisplay()) + let withWindowSnapshot = DisplayLayout(name: "Saved Windows", displays: sampleDualDisplays()) + let unused = DisplayLayout( + name: "Unused", + displays: [ + DisplayInfo( + id: 3, + frame: CGRect(x: 0, y: 0, width: 2560, height: 1440), + isMain: true, + name: "Other" + ) + ] + ) + let preservedSignatures = Set([withWindowSnapshot.signature]) + service.layouts = [current, withWindowSnapshot, unused] + service.currentLayoutID = current.id + service.save() + + XCTAssertTrue(service.hasUnusedLayouts(preserving: preservedSignatures)) + + let removedIDs = service.cleanUpUnusedLayouts(preserving: preservedSignatures) + + XCTAssertEqual(removedIDs, [unused.id]) + XCTAssertEqual(service.layouts.map(\.id), [current.id, withWindowSnapshot.id]) + XCTAssertFalse(service.hasUnusedLayouts(preserving: preservedSignatures)) + } + + func testMatchOrCreateLayoutPreservesPreviousLayoutWithWindowSnapshots() { + let service = DisplayLayoutService.shared + let withWindowSnapshot = DisplayLayout(name: "Saved Windows", displays: sampleDualDisplays()) + service.layouts = [withWindowSnapshot] + service.currentLayoutID = withWindowSnapshot.id + service.save() + + let current = service.matchOrCreateLayout( + for: sampleSingleDisplay(), + preserving: Set([withWindowSnapshot.signature]) + ) + + XCTAssertNotEqual(current.id, withWindowSnapshot.id) + XCTAssertEqual(service.layouts.map(\.id), [withWindowSnapshot.id, current.id]) + } + func testDecodeLegacyLayoutWithoutLockStateDefaultsToUnlocked() throws { let json = """ { diff --git a/MousePortalTests/MousePortalTests.swift b/MousePortalTests/MousePortalTests.swift index d566593..9dd99fa 100644 --- a/MousePortalTests/MousePortalTests.swift +++ b/MousePortalTests/MousePortalTests.swift @@ -5,4 +5,9 @@ final class MousePortalTests: XCTestCase { func testAppVersionWindowTitleIsNotEmpty() { XCTAssertFalse(AppVersion.windowTitle.isEmpty) } + + func testAppVersionIsOneOneZero() { + XCTAssertEqual(AppVersion.current, "1.1.0") + XCTAssertEqual(AppVersion.build, "20") + } } diff --git a/MousePortalTests/Services/WindowLayoutEngineTests.swift b/MousePortalTests/Services/WindowLayoutEngineTests.swift new file mode 100644 index 0000000..fc85acb --- /dev/null +++ b/MousePortalTests/Services/WindowLayoutEngineTests.swift @@ -0,0 +1,299 @@ +import XCTest +@testable import MousePortal + +final class WindowLayoutEngineTests: XCTestCase { + func testDisplaySelectionUsesLargestWindowIntersection() { + let left = display(identity: "left", frame: CGRect(x: 0, y: 0, width: 1000, height: 800)) + let right = display(identity: "right", frame: CGRect(x: 1000, y: 0, width: 1000, height: 800)) + + let selected = WindowLayoutEngine.display( + containing: CGRect(x: 900, y: 100, width: 600, height: 500), + from: [left, right] + ) + + XCTAssertEqual(selected?.identity.rawValue, "right") + } + + func testDisplaySelectionReturnsNilForOffscreenWindow() { + let display = display(identity: "main", frame: CGRect(x: 0, y: 0, width: 1000, height: 800)) + + XCTAssertNil(WindowLayoutEngine.display( + containing: CGRect(x: 2000, y: 0, width: 300, height: 300), + from: [display] + )) + } + + func testTargetFrameUsesRelativePositionOnMovedDisplay() { + let targetDisplay = display( + identity: "external", + frame: CGRect(x: -1920, y: 0, width: 1920, height: 1080) + ) + let placement = placement( + displayIdentity: targetDisplay.identity, + relativeX: 0.25, + relativeY: 0.1, + width: 800, + height: 600 + ) + + let frame = WindowLayoutEngine.targetFrame(for: placement, on: targetDisplay) + + XCTAssertEqual(frame.origin.x, -1440, accuracy: 0.001) + XCTAssertEqual(frame.origin.y, 108, accuracy: 0.001) + XCTAssertEqual(frame.size.width, 800) + XCTAssertEqual(frame.size.height, 600) + } + + func testSavedFramePreservesUnclampedWindowPositionForPreview() { + let savedDisplay = display( + identity: "external", + frame: CGRect(x: 1920, y: -200, width: 1600, height: 1000) + ) + let placement = placement( + displayIdentity: savedDisplay.identity, + relativeX: 0.25, + relativeY: 0.1, + width: 900, + height: 700 + ) + + let frame = WindowLayoutEngine.savedFrame(for: placement, on: savedDisplay) + + XCTAssertEqual(frame, CGRect(x: 2320, y: -100, width: 900, height: 700)) + } + + func testTargetFrameClampsWindowToVisibleArea() { + let targetDisplay = WindowDisplaySnapshot( + identity: WindowDisplayIdentity(rawValue: "main"), + frame: WindowRect(CGRect(x: 0, y: 0, width: 1200, height: 900)), + visibleFrame: WindowRect(CGRect(x: 0, y: 24, width: 1200, height: 826)), + isMain: true + ) + let placement = placement( + displayIdentity: targetDisplay.identity, + relativeX: 0.9, + relativeY: -0.2, + width: 500, + height: 1000 + ) + + let frame = WindowLayoutEngine.targetFrame(for: placement, on: targetDisplay) + + XCTAssertEqual(frame, CGRect(x: 700, y: 24, width: 500, height: 826)) + } + + func testFrameMatchingAcceptsAccessibilityRoundingWithinTolerance() { + let target = CGRect(x: 100, y: 200, width: 800, height: 600) + let actual = CGRect(x: 101, y: 199, width: 799, height: 601) + + XCTAssertTrue(WindowLayoutEngine.framesMatch(actual, target: target)) + } + + func testFrameMatchingRejectsIncorrectSize() { + let target = CGRect(x: 100, y: 200, width: 800, height: 600) + let actual = CGRect(x: 100, y: 200, width: 810, height: 600) + + XCTAssertFalse(WindowLayoutEngine.framesMatch(actual, target: target)) + } + + func testFrameMatchingRejectsIncorrectPosition() { + let target = CGRect(x: 100, y: 200, width: 800, height: 600) + let actual = CGRect(x: 110, y: 200, width: 800, height: 600) + + XCTAssertFalse(WindowLayoutEngine.framesMatch(actual, target: target)) + } + + func testFrameMatchingRejectsMissingReadback() { + let target = CGRect(x: 100, y: 200, width: 800, height: 600) + + XCTAssertFalse(WindowLayoutEngine.framesMatch(nil, target: target)) + } + + func testWindowMatchingPrefersDocumentOverPreviousIndex() { + let placement = placement( + documentURL: "file:///project/README.md", + windowIndex: 0 + ) + let candidates = [ + candidate(documentURL: "file:///project/Other.md", windowIndex: 0), + candidate(documentURL: "file:///project/README.md", windowIndex: 1) + ] + + XCTAssertEqual( + WindowLayoutEngine.bestMatchIndex(for: placement, candidates: candidates, excluding: []), + 1 + ) + } + + func testWindowMatchingPrefersRetainedRuntimeWindow() { + let placement = placement(windowIndex: 0) + let candidates = [candidate(windowIndex: 0), candidate(windowIndex: 1)] + + XCTAssertEqual( + WindowLayoutEngine.bestMatchIndex( + for: placement, + candidates: candidates, + excluding: [], + preferredIndex: 1 + ), + 1 + ) + } + + func testWindowMatchingFallsBackWhenRetainedRuntimeWindowWasAlreadyUsed() { + let placement = placement(windowIndex: 0) + let candidates = [candidate(windowIndex: 0), candidate(windowIndex: 1)] + + XCTAssertEqual( + WindowLayoutEngine.bestMatchIndex( + for: placement, + candidates: candidates, + excluding: [1], + preferredIndex: 1 + ), + 0 + ) + } + + func testWindowMatchingDoesNotReuseCandidate() { + let placement = placement(windowIndex: 0) + let candidates = [candidate(windowIndex: 0), candidate(windowIndex: 1)] + + XCTAssertEqual( + WindowLayoutEngine.bestMatchIndex(for: placement, candidates: candidates, excluding: [0]), + 1 + ) + } + + func testWindowMatchingRejectsNonstandardWindow() { + let placement = placement() + let candidates = [candidate(subrole: "AXDialog")] + + XCTAssertNil(WindowLayoutEngine.bestMatchIndex( + for: placement, + candidates: candidates, + excluding: [] + )) + } + + func testWindowMatchingRejectsUnrelatedFallbackWindow() { + let placement = placement( + documentURL: "file:///project/README.md", + windowIndex: 0 + ) + let candidates = [WindowMatchCandidate( + windowTitle: "Other", + documentURL: "file:///project/Other.md", + windowIdentifier: "other-window", + role: "AXWindow", + subrole: "AXStandardWindow", + windowIndex: 0, + frame: CGRect(x: 0, y: 0, width: 800, height: 600) + )] + + XCTAssertNil(WindowLayoutEngine.bestMatchIndex( + for: placement, + candidates: candidates, + excluding: [] + )) + } + + func testWindowMatchingAllowsRetainedRuntimeWindowWithoutMetadataMatch() { + let placement = placement(documentURL: "file:///project/README.md") + let candidates = [WindowMatchCandidate( + windowTitle: "Other", + documentURL: "file:///project/Other.md", + windowIdentifier: "other-window", + role: "AXWindow", + subrole: "AXStandardWindow", + windowIndex: 1, + frame: CGRect(x: 300, y: 200, width: 500, height: 400) + )] + + XCTAssertEqual(WindowLayoutEngine.bestMatchIndex( + for: placement, + candidates: candidates, + excluding: [], + preferredIndex: 0 + ), 0) + } + + func testSnapshotCodableRoundTripPreservesStableDisplayIdentity() throws { + let targetDisplay = display(identity: "uuid-123", frame: CGRect(x: 0, y: 0, width: 1920, height: 1080)) + let snapshot = WindowLayoutSnapshot( + capturedAt: Date(timeIntervalSince1970: 1234), + displays: [targetDisplay], + windows: [placement(displayIdentity: targetDisplay.identity)] + ) + + let data = try JSONEncoder().encode(snapshot) + let decoded = try JSONDecoder().decode(WindowLayoutSnapshot.self, from: data) + + XCTAssertEqual(decoded, snapshot) + XCTAssertEqual(decoded.windows.first?.displayIdentity.rawValue, "uuid-123") + } + + func testSnapshotDisplayLayoutSignatureMatchesDisplayLayoutFormat() { + let snapshot = WindowLayoutSnapshot( + capturedAt: Date(), + displays: [ + display(identity: "external", frame: CGRect(x: -1600, y: 0, width: 1600, height: 900)), + display(identity: "main", frame: CGRect(x: 0, y: 0, width: 1920, height: 1080)) + ], + windows: [] + ) + + XCTAssertEqual(snapshot.displayLayoutSignature, "1600x900@-1600,0|1920x1080@0,0M") + } + + private func display(identity: String, frame: CGRect) -> WindowDisplaySnapshot { + WindowDisplaySnapshot( + identity: WindowDisplayIdentity(rawValue: identity), + frame: WindowRect(frame), + visibleFrame: WindowRect(frame), + isMain: identity == "main" + ) + } + + private func placement( + documentURL: String? = nil, + windowIndex: Int = 0, + displayIdentity: WindowDisplayIdentity = WindowDisplayIdentity(rawValue: "main"), + relativeX: Double = 0, + relativeY: Double = 0, + width: Double = 800, + height: Double = 600 + ) -> WindowPlacement { + WindowPlacement( + bundleIdentifier: "com.example.Editor", + applicationName: "Editor", + windowTitle: "README", + documentURL: documentURL, + windowIdentifier: nil, + role: "AXWindow", + subrole: "AXStandardWindow", + windowIndex: windowIndex, + displayIdentity: displayIdentity, + relativeX: relativeX, + relativeY: relativeY, + width: width, + height: height + ) + } + + private func candidate( + documentURL: String? = nil, + windowIndex: Int = 0, + subrole: String = "AXStandardWindow" + ) -> WindowMatchCandidate { + WindowMatchCandidate( + windowTitle: "README", + documentURL: documentURL, + windowIdentifier: nil, + role: "AXWindow", + subrole: subrole, + windowIndex: windowIndex, + frame: CGRect(x: 0, y: 0, width: 800, height: 600) + ) + } +} diff --git a/MousePortalTests/Services/WindowLayoutServiceTests.swift b/MousePortalTests/Services/WindowLayoutServiceTests.swift new file mode 100644 index 0000000..1a78da4 --- /dev/null +++ b/MousePortalTests/Services/WindowLayoutServiceTests.swift @@ -0,0 +1,526 @@ +import XCTest +import AppKit +@testable import MousePortal + +@MainActor +final class WindowLayoutServiceTests: XCTestCase { + private var defaults: UserDefaults! + private var suiteName: String! + + override func setUp() { + super.setUp() + suiteName = "WindowLayoutServiceTests.\(UUID().uuidString)" + defaults = UserDefaults(suiteName: suiteName) + } + + override func tearDown() { + defaults.removePersistentDomain(forName: suiteName) + defaults = nil + suiteName = nil + super.tearDown() + } + + func testSaveCurrentLayoutCapturesAndPersistsSnapshot() { + let system = MockWindowLayoutSystemProvider() + system.displays = [display(identity: "main")] + system.windows = [placement(displayIdentity: WindowDisplayIdentity(rawValue: "main"))] + let service = makeService(system: system) + + let snapshot = service.saveCurrentLayout() + let reloadedService = makeService(system: system) + + XCTAssertEqual(snapshot?.windows.count, 1) + XCTAssertEqual(reloadedService.snapshot, snapshot) + } + + func testManualSavesAppendMultipleSnapshots() { + let system = MockWindowLayoutSystemProvider() + system.displays = [display(identity: "main")] + let service = makeService(system: system) + + system.windows = [placement(displayIdentity: WindowDisplayIdentity(rawValue: "main"))] + let first = service.saveCurrentLayout() + system.windows = [] + let second = service.saveCurrentLayout() + let reloadedService = makeService(system: system) + + XCTAssertEqual(service.snapshots.map(\.id), [first?.id, second?.id].compactMap { $0 }) + XCTAssertEqual(reloadedService.snapshots, service.snapshots) + XCTAssertEqual(service.snapshots.map(\.kind), [.manual, .manual]) + } + + func testManualSaveInvalidatesPendingRecoveryTimer() { + let system = MockWindowLayoutSystemProvider() + system.displays = [display(identity: "main")] + system.windows = [placement(displayIdentity: WindowDisplayIdentity(rawValue: "main"))] + let service = makeService(system: system) + service.setEnabled(true) + service.setAutomaticRestoreEnabled(true) + service.startMonitoring() + defer { service.stopMonitoring() } + + NSWorkspace.shared.notificationCenter.post( + name: NSWorkspace.sessionDidResignActiveNotification, + object: nil + ) + system.displays = [] + NSWorkspace.shared.notificationCenter.post( + name: NSWorkspace.sessionDidBecomeActiveNotification, + object: nil + ) + + XCTAssertEqual(service.state, .waitingForDisplays) + let recoveryTimer = privateTimer(named: "stabilityTimer", in: service) + XCTAssertEqual(recoveryTimer?.isValid, true) + + system.displays = [display(identity: "main")] + _ = service.saveCurrentLayout() + + XCTAssertEqual(service.state, .normal) + XCTAssertEqual(recoveryTimer?.isValid, false) + } + + func testExplicitRestoreInvalidatesPendingRecoveryTimer() { + let system = MockWindowLayoutSystemProvider() + system.displays = [display(identity: "main")] + system.windows = [placement(displayIdentity: WindowDisplayIdentity(rawValue: "main"))] + let service = makeService(system: system) + service.setEnabled(true) + service.setAutomaticRestoreEnabled(true) + service.startMonitoring() + defer { service.stopMonitoring() } + + NSWorkspace.shared.notificationCenter.post( + name: NSWorkspace.sessionDidResignActiveNotification, + object: nil + ) + let snapshotID = service.snapshot!.id + system.displays = [] + NSWorkspace.shared.notificationCenter.post( + name: NSWorkspace.sessionDidBecomeActiveNotification, + object: nil + ) + + XCTAssertEqual(service.state, .waitingForDisplays) + let recoveryTimer = privateTimer(named: "stabilityTimer", in: service) + XCTAssertEqual(recoveryTimer?.isValid, true) + + system.displays = [display(identity: "main")] + XCTAssertNotNil(service.restoreSavedLayout(snapshotID: snapshotID)) + + XCTAssertEqual(service.state, .normal) + XCTAssertEqual(recoveryTimer?.isValid, false) + } + + func testAutomaticSnapshotReplacesPreviousSnapshotForSameDisplayLayout() { + let system = MockWindowLayoutSystemProvider() + system.displays = [display(identity: "main")] + let service = makeService(system: system) + service.setEnabled(true) + let firstAutomaticID = service.snapshots.first?.id + service.startMonitoring() + defer { service.stopMonitoring() } + + NSWorkspace.shared.notificationCenter.post( + name: NSWorkspace.sessionDidResignActiveNotification, + object: nil + ) + + let automaticSnapshots = service.snapshots.filter { $0.kind == .automatic } + XCTAssertEqual(automaticSnapshots.count, 1) + XCTAssertNotEqual(automaticSnapshots.first?.id, firstAutomaticID) + XCTAssertEqual(system.captureRuntimeSnapshotIDs.last!, automaticSnapshots.first?.id) + } + + func testRememberBeforeSleepOrLockPersistsIndependentlyFromGlobalSwitch() { + let system = MockWindowLayoutSystemProvider() + system.displays = [display(identity: "main")] + let service = makeService(system: system) + + XCTAssertTrue(service.rememberBeforeSleepOrLockEnabled) + service.setEnabled(true) + service.setRememberBeforeSleepOrLockEnabled(false) + + let reloadedService = makeService(system: system) + XCTAssertTrue(reloadedService.isEnabled) + XCTAssertFalse(reloadedService.rememberBeforeSleepOrLockEnabled) + } + + func testSessionResignDoesNotCaptureWhenRememberBeforeSleepOrLockIsDisabled() { + let system = MockWindowLayoutSystemProvider() + system.displays = [display(identity: "main")] + let service = makeService(system: system) + service.setEnabled(true) + service.setRememberBeforeSleepOrLockEnabled(false) + let captureCallCount = system.captureCallCount + service.startMonitoring() + defer { service.stopMonitoring() } + + NSWorkspace.shared.notificationCenter.post( + name: NSWorkspace.sessionDidResignActiveNotification, + object: nil + ) + + XCTAssertTrue(service.isEnabled) + XCTAssertEqual(service.state, .normal) + XCTAssertEqual(system.captureCallCount, captureCallCount) + } + + func testWakeDoesNothingWhenAutomaticRestoreIsDisabled() { + let system = MockWindowLayoutSystemProvider() + system.displays = [display(identity: "main")] + system.windows = [placement(displayIdentity: WindowDisplayIdentity(rawValue: "main"))] + let service = makeService(system: system) + service.setEnabled(true) + service.startMonitoring() + defer { service.stopMonitoring() } + + NSWorkspace.shared.notificationCenter.post( + name: NSWorkspace.sessionDidResignActiveNotification, + object: nil + ) + XCTAssertEqual(service.state, .snapshotFrozen) + let displayCallCount = system.currentDisplayCallCount + + NSWorkspace.shared.notificationCenter.post( + name: NSWorkspace.sessionDidBecomeActiveNotification, + object: nil + ) + NSWorkspace.shared.notificationCenter.post( + name: NSWorkspace.didWakeNotification, + object: nil + ) + + XCTAssertEqual(service.state, .normal) + XCTAssertEqual(system.restoreCallCount, 0) + XCTAssertEqual(system.currentDisplayCallCount, displayCallCount) + } + + func testManualSnapshotDoesNotRetainRuntimeWindowIdentity() { + let system = MockWindowLayoutSystemProvider() + system.displays = [display(identity: "main")] + let service = makeService(system: system) + + _ = service.saveCurrentLayout() + + XCTAssertNil(system.captureRuntimeSnapshotIDs.last!) + } + + func testMatchingSnapshotsSortAutomaticBeforeManualSnapshots() { + let system = MockWindowLayoutSystemProvider() + system.displays = [display(identity: "main")] + let service = makeService(system: system) + service.setEnabled(true) + _ = service.saveCurrentLayout() + let signature = service.snapshots[0].displayLayoutSignature + + let matchingSnapshots = service.snapshots(matching: signature) + + XCTAssertEqual(matchingSnapshots.map(\.kind), [.automatic, .manual]) + } + + func testPromoteAutomaticSnapshotMakesItManualAndPersists() { + let system = MockWindowLayoutSystemProvider() + system.displays = [display(identity: "main")] + let service = makeService(system: system) + service.setEnabled(true) + let snapshotID = service.snapshots.first!.id + + XCTAssertTrue(service.promoteAutomaticSnapshot(id: snapshotID)) + XCTAssertEqual(service.snapshots.first?.kind, .manual) + XCTAssertEqual(makeService(system: system).snapshots.first?.kind, .manual) + } + + func testRenameSnapshotTrimsNameAndPersists() { + let system = MockWindowLayoutSystemProvider() + system.displays = [display(identity: "main")] + let service = makeService(system: system) + let snapshot = service.saveCurrentLayout()! + + XCTAssertTrue(service.renameSnapshot(id: snapshot.id, to: " Work ")) + XCTAssertEqual(service.snapshots.first?.name, "Work") + XCTAssertEqual(makeService(system: system).snapshots.first?.name, "Work") + XCTAssertFalse(service.renameSnapshot(id: snapshot.id, to: " ")) + } + + func testDeleteSnapshotRemovesItAndPersists() { + let system = MockWindowLayoutSystemProvider() + system.displays = [display(identity: "main")] + let service = makeService(system: system) + let first = service.saveCurrentLayout()! + let second = service.saveCurrentLayout()! + + XCTAssertTrue(service.deleteSnapshot(id: first.id)) + XCTAssertEqual(service.snapshots.map(\.id), [second.id]) + XCTAssertEqual(makeService(system: system).snapshots.map(\.id), [second.id]) + } + + func testLegacySingleSnapshotMigratesToSnapshotCollection() throws { + let system = MockWindowLayoutSystemProvider() + let savedDisplay = display(identity: "main") + let legacySnapshot = LegacyWindowLayoutSnapshot( + capturedAt: Date(timeIntervalSince1970: 1234), + displays: [savedDisplay], + windows: [placement(displayIdentity: savedDisplay.identity)] + ) + defaults.set(try JSONEncoder().encode(legacySnapshot), forKey: "windowLayoutSnapshot") + + let service = makeService(system: system) + let reloadedService = makeService(system: system) + + XCTAssertEqual(service.snapshots.count, 1) + XCTAssertEqual(service.snapshots.first?.kind, .manual) + XCTAssertEqual(service.snapshots.first?.capturedAt, legacySnapshot.capturedAt) + XCTAssertEqual(reloadedService.snapshots, service.snapshots) + } + + func testSaveCurrentLayoutPassesIgnoredApplicationsToProvider() { + let system = MockWindowLayoutSystemProvider() + system.displays = [display(identity: "main")] + let service = makeService(system: system) + service.setApplicationIgnored("com.example.Chat", ignored: true) + + _ = service.saveCurrentLayout() + + XCTAssertEqual(system.lastIgnoredBundleIdentifiers, ["com.example.Chat"]) + } + + func testRefreshAvailableApplicationsIncludesUnavailableIgnoredApplications() { + defaults.set(["com.example.Chat"], forKey: "windowRecoveryIgnoredApplications") + let system = MockWindowLayoutSystemProvider() + let service = makeService(system: system) + + service.refreshAvailableApplications() + + XCTAssertEqual( + service.availableApplications, + [WindowApplicationOption(bundleIdentifier: "com.example.Chat", name: "com.example.Chat")] + ) + } + + func testUnignoringUnavailableApplicationRemovesItFromOptions() { + defaults.set(["com.example.Chat"], forKey: "windowRecoveryIgnoredApplications") + let system = MockWindowLayoutSystemProvider() + let service = makeService(system: system) + service.refreshAvailableApplications() + + service.setApplicationIgnored("com.example.Chat", ignored: false) + + XCTAssertTrue(service.availableApplications.isEmpty) + } + + func testRestoreUsesCurrentDisplayWithMatchingStableIdentity() { + let system = MockWindowLayoutSystemProvider() + system.displays = [display(identity: "external")] + system.windows = [placement(displayIdentity: WindowDisplayIdentity(rawValue: "external"))] + system.restoreResult = WindowRestoreResult(restoredCount: 1, skippedCount: 0, failedCount: 0) + let service = makeService(system: system) + _ = service.saveCurrentLayout() + + let result = service.restoreSavedLayout() + + XCTAssertEqual(result, system.restoreResult) + XCTAssertEqual(system.restoreCallCount, 1) + XCTAssertEqual(service.state, .normal) + } + + func testRestoreCanTargetSpecificSavedSnapshot() { + let system = MockWindowLayoutSystemProvider() + system.displays = [display(identity: "main")] + system.windows = [placement(displayIdentity: WindowDisplayIdentity(rawValue: "main"))] + let service = makeService(system: system) + let first = service.saveCurrentLayout() + _ = service.saveCurrentLayout() + + _ = service.restoreSavedLayout(snapshotID: first?.id) + + XCTAssertEqual(system.restoredSnapshotID, first?.id) + } + + func testRestoreDoesNotRunUntilEverySavedDisplayReturns() { + let system = MockWindowLayoutSystemProvider() + system.displays = [display(identity: "main"), display(identity: "external")] + system.windows = [placement(displayIdentity: WindowDisplayIdentity(rawValue: "external"))] + let service = makeService(system: system) + _ = service.saveCurrentLayout() + system.displays = [display(identity: "main")] + + XCTAssertNil(service.restoreSavedLayout()) + XCTAssertEqual(system.restoreCallCount, 0) + } + + func testSaveAndRestoreRequireAccessibilityTrust() { + let system = MockWindowLayoutSystemProvider() + system.displays = [display(identity: "main")] + let service = WindowLayoutService( + system: system, + defaults: defaults, + accessibilityIsTrusted: { false } + ) + + XCTAssertNil(service.saveCurrentLayout()) + XCTAssertNil(service.restoreSavedLayout()) + } + + func testDisplayOnlySleepAndWakeDoNotEnterRecoveryFlow() { + let system = MockWindowLayoutSystemProvider() + system.displays = [display(identity: "main")] + system.windows = [placement(displayIdentity: WindowDisplayIdentity(rawValue: "main"))] + let service = makeService(system: system) + service.setEnabled(true) + service.setAutomaticRestoreEnabled(true) + let originalSnapshot = service.snapshot + let captureCallCount = system.captureCallCount + let displayCallCount = system.currentDisplayCallCount + system.windows = [] + service.startMonitoring() + defer { service.stopMonitoring() } + + NSWorkspace.shared.notificationCenter.post( + name: NSWorkspace.screensDidSleepNotification, + object: nil + ) + NSWorkspace.shared.notificationCenter.post( + name: NSWorkspace.screensDidWakeNotification, + object: nil + ) + + XCTAssertEqual(service.state, .normal) + XCTAssertEqual(service.snapshot, originalSnapshot) + XCTAssertEqual(system.captureCallCount, captureCallCount) + XCTAssertEqual(system.currentDisplayCallCount, displayCallCount) + XCTAssertEqual(system.restoreCallCount, 0) + } + + func testSessionResignCapturesLatestSnapshotBeforeFreezing() { + let system = MockWindowLayoutSystemProvider() + system.displays = [display(identity: "main")] + system.windows = [] + let service = makeService(system: system) + service.setEnabled(true) + system.windows = [placement(displayIdentity: WindowDisplayIdentity(rawValue: "main"))] + service.startMonitoring() + defer { service.stopMonitoring() } + + NSWorkspace.shared.notificationCenter.post( + name: NSWorkspace.sessionDidResignActiveNotification, + object: nil + ) + + XCTAssertEqual(service.state, .snapshotFrozen) + XCTAssertEqual(service.snapshot?.windows.count, 1) + XCTAssertEqual(system.captureCallCount, 2) + } + + func testSystemSleepCapturesLatestSnapshotBeforeFreezing() { + let system = MockWindowLayoutSystemProvider() + system.displays = [display(identity: "main")] + system.windows = [] + let service = makeService(system: system) + service.setEnabled(true) + system.windows = [placement(displayIdentity: WindowDisplayIdentity(rawValue: "main"))] + service.startMonitoring() + defer { service.stopMonitoring() } + + NSWorkspace.shared.notificationCenter.post( + name: NSWorkspace.willSleepNotification, + object: nil + ) + + XCTAssertEqual(service.state, .snapshotFrozen) + XCTAssertEqual(service.snapshot?.windows.count, 1) + XCTAssertEqual(system.captureCallCount, 2) + } + + private func makeService(system: MockWindowLayoutSystemProvider) -> WindowLayoutService { + WindowLayoutService( + system: system, + defaults: defaults, + accessibilityIsTrusted: { true } + ) + } + + private func display(identity: String) -> WindowDisplaySnapshot { + let frame = CGRect(x: 0, y: 0, width: 1920, height: 1080) + return WindowDisplaySnapshot( + identity: WindowDisplayIdentity(rawValue: identity), + frame: WindowRect(frame), + visibleFrame: WindowRect(frame), + isMain: identity == "main" + ) + } + + private func placement(displayIdentity: WindowDisplayIdentity) -> WindowPlacement { + WindowPlacement( + bundleIdentifier: "com.example.Editor", + applicationName: "Editor", + windowTitle: "README", + documentURL: "file:///README.md", + windowIdentifier: nil, + role: "AXWindow", + subrole: "AXStandardWindow", + windowIndex: 0, + displayIdentity: displayIdentity, + relativeX: 0.1, + relativeY: 0.1, + width: 800, + height: 600 + ) + } + + private func privateTimer(named name: String, in service: WindowLayoutService) -> Timer? { + Mirror(reflecting: service).children + .first { $0.label == name }? + .value as? Timer + } +} + +private final class MockWindowLayoutSystemProvider: WindowLayoutSystemProviding { + var displays: [WindowDisplaySnapshot] = [] + var windows: [WindowPlacement] = [] + var applications: [WindowApplicationOption] = [] + var restoreResult = WindowRestoreResult(restoredCount: 0, skippedCount: 0, failedCount: 0) + private(set) var lastIgnoredBundleIdentifiers: Set = [] + private(set) var restoreCallCount = 0 + private(set) var captureCallCount = 0 + private(set) var currentDisplayCallCount = 0 + private(set) var restoredSnapshotID: UUID? + private(set) var captureRuntimeSnapshotIDs: [UUID?] = [] + + func currentDisplays() -> [WindowDisplaySnapshot] { + currentDisplayCallCount += 1 + return displays + } + + func captureWindows( + displays: [WindowDisplaySnapshot], + ignoring bundleIdentifiers: Set, + runtimeSnapshotID: UUID? + ) -> [WindowPlacement] { + captureCallCount += 1 + lastIgnoredBundleIdentifiers = bundleIdentifiers + captureRuntimeSnapshotIDs.append(runtimeSnapshotID) + return windows + } + + func restoreWindows( + from snapshot: WindowLayoutSnapshot, + currentDisplays: [WindowDisplaySnapshot] + ) -> WindowRestoreResult { + restoreCallCount += 1 + restoredSnapshotID = snapshot.id + return restoreResult + } + + func runningApplications() -> [WindowApplicationOption] { + applications + } + + func logDisplayState(event: String) {} +} + +private struct LegacyWindowLayoutSnapshot: Codable { + let capturedAt: Date + let displays: [WindowDisplaySnapshot] + let windows: [WindowPlacement] +} diff --git a/MousePortalTests/Utils/AppUIRulesTests.swift b/MousePortalTests/Utils/AppUIRulesTests.swift index 3e67417..e3816f6 100644 --- a/MousePortalTests/Utils/AppUIRulesTests.swift +++ b/MousePortalTests/Utils/AppUIRulesTests.swift @@ -61,6 +61,42 @@ final class AppUIRulesTests: XCTestCase { XCTAssertFalse(canDelete) } + func testDeletionIsUnavailableWhenLayoutHasWindowSnapshots() { + let currentID = UUID() + let targetID = UUID() + + XCTAssertEqual( + LayoutSidebarRules.deletionAction( + layoutCount: 2, + currentLayoutID: currentID, + targetLayoutID: targetID, + portalCount: 0, + windowSnapshotCount: 1, + isLocked: false + ), + .unavailable + ) + XCTAssertFalse( + LayoutSidebarRules.canDeleteLayout( + layoutCount: 2, + currentLayoutID: currentID, + targetLayoutID: targetID, + windowSnapshotCount: 1, + isLocked: false + ) + ) + XCTAssertEqual( + LayoutSidebarRules.deleteDisabledHelpText( + layoutCount: 2, + currentLayoutID: currentID, + targetLayoutID: targetID, + windowSnapshotCount: 1, + isLocked: false + ), + L("layout.delete_disabled_window_snapshots") + ) + } + func testDeleteDisabledHelpTextPrefersLockedReason() { let targetID = UUID() @@ -136,4 +172,31 @@ final class AppUIRulesTests: XCTestCase { L("menu.accessibility_status %@", L("settings.permission_not_granted")) ) } + + func testWindowApplicationSearchMatchesNameAndBundleIdentifier() { + let applications = [ + WindowApplicationOption(bundleIdentifier: "com.example.Editor", name: "Code Editor"), + WindowApplicationOption(bundleIdentifier: "com.example.Chat", name: "Messenger") + ] + + XCTAssertEqual( + WindowApplicationSearch.filtered(applications, query: "EDITOR"), + [applications[0]] + ) + XCTAssertEqual( + WindowApplicationSearch.filtered(applications, query: "example.chat"), + [applications[1]] + ) + } + + func testWindowApplicationSearchTreatsWhitespaceAsEmpty() { + let applications = [ + WindowApplicationOption(bundleIdentifier: "com.example.Editor", name: "Editor") + ] + + XCTAssertEqual( + WindowApplicationSearch.filtered(applications, query: " "), + applications + ) + } } diff --git a/PRIVACY.md b/PRIVACY.md index d94003f..c127e49 100644 --- a/PRIVACY.md +++ b/PRIVACY.md @@ -1,6 +1,6 @@ # Privacy -Last updated: 2026-06-14 +Last updated: 2026-08-31 MousePortal is designed as a local macOS utility for managing cursor movement across multiple displays. @@ -11,6 +11,7 @@ MousePortal stores its settings locally on your Mac, including: - Display layout names and display arrangement metadata. - Hotkey configuration. - Portal configuration. +- Optional window recovery snapshots, including application identifiers, window titles, document URLs, window positions, and window sizes. - Configuration profiles. - App preferences such as launch-at-login and menu bar visibility. @@ -29,5 +30,7 @@ MousePortal asks for macOS Accessibility permission because its core features ne - Listen for configured global hotkeys. - Monitor mouse movement for portal triggers. - Move the cursor between displays. +- Read the position and size of standard application windows when window recovery is enabled. +- Restore those windows to their saved positions and sizes after displays wake. -MousePortal uses these permissions only for the cursor and hotkey features described in the app. +Window recovery is opt-in. Its snapshots stay on the Mac and are not sent to the project maintainers or any backend service. diff --git a/README.md b/README.md index 3497a04..8a3919c 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@

- Version + Version License diff --git a/README.zh.md b/README.zh.md index 748ddb5..fe558f8 100644 --- a/README.zh.md +++ b/README.zh.md @@ -6,7 +6,7 @@

- Version + Version License