diff --git a/Makefile b/Makefile index 722c29a..5a27e6a 100644 --- a/Makefile +++ b/Makefile @@ -4,7 +4,9 @@ bundle: ./scripts/build.sh run: bundle - open build/FreeDock.app + @killall FreeDock >/dev/null 2>&1 || true + @sleep 0.5 + @open -n "$(CURDIR)/build/FreeDock.app" test: swift test diff --git a/README.md b/README.md index 11568f0..00e63fc 100644 --- a/README.md +++ b/README.md @@ -21,7 +21,7 @@ FreeDock in action

-FreeDock lets you create unlimited floating docks on any macOS screen. Pin your most-used apps, organize them by project, and keep them accessible with a single click — all without touching the system Dock. +FreeDock lets you create unlimited floating docks on any macOS screen. Pin your most-used apps, files, and folders, organize them by project, and keep them accessible with a single click — all without touching the system Dock. --- @@ -49,15 +49,32 @@ FreeDock lets you create unlimited floating docks on any macOS screen. Pin your ## Features - **Unlimited docks** — Create as many docks as you need, on any screen -- **Drag & drop** — Drag `.app` files from Finder directly onto a dock -- **Reorder** — Drag icons within a dock to rearrange them +- **Dock profiles** — Switch between separate Work, Focus, or Personal dock setups +- **Per-display placement** — Assign each dock to a monitor and restore its relative position after reconnecting +- **Quick Launch** — Press `⌘⇧Space`, type to search the nearest dock, choose with arrows or Tab, and press Return to open +- **Global shortcuts** — Show or hide the active profile with `⌃⌥Space`, or switch profiles with `⌃⌥1…9` +- **Live per-dock preferences** — Tune opacity, blur, shadows, magnification, spacing, orientation, indicators, and auto-hide +- **macOS Dock import** — Append pinned apps to any FreeDock dock without changing Apple’s Dock +- **Apps, files, and folders** — Pin applications, documents, and folders directly from Finder +- **Folder stacks** — Browse live folder contents in an automatic, grid, or list view; sort by name, modification date, or kind, and optionally show hidden files +- **Smart stacks** — Add Recent Files and Downloads without pinning fixed paths; Recent Files tracks successful document opens through FreeDock, while Downloads resolves for the current user +- **Reorder** — Drag apps, documents, folders, and separators into any arrangement +- **Cross-dock organization** — Drag an item to another dock to move it, or hold Option while dropping to copy it - **Horizontal or vertical** — Choose the orientation that fits your workflow - **Running app indicators** — See which apps are currently open at a glance -- **Click to launch or switch** — Single-click to open an app or bring it to focus +- **Native window switching** — Preview a running app’s windows by title and choose one to bring it forward +- **Native item controls** — Right-click to show, hide, or quit apps; choose how stacks are displayed and sorted; or open a document with another compatible app +- **Open files with apps** — Drop compatible Finder files or folders directly onto a pinned application +- **Open in one click** — Launch apps, open documents, or browse folder stacks - **Lock positions** — Prevent accidental dock movement +- **Edge auto-hide** — Dock to the nearest screen edge and let it slide away until needed - **Persistent config** — All docks are saved to `~/.config/freedock.json` - **Native & lightweight** — Built with SwiftUI and AppKit, minimal resource usage +## Free and open source, permanently + +FreeDock is MIT-licensed and all features are available to everyone. The goal is a thoughtful, native alternative to the system Dock—not a free trial or a feature-gated shell. See the [project roadmap](docs/ROADMAP.md) for the next improvements and ways to contribute. + ## Screenshots

@@ -86,38 +103,158 @@ make run > **Note:** `swift run` launches the binary directly, bypassing the `.app` bundle. The `LSUIElement` setting (menu-bar agent mode) won't be respected this way. Always use `make run`, `./scripts/build.sh`, or open the `.app` bundle. +> **Development permissions:** Local builds are ad-hoc signed, so macOS may ask you to grant Accessibility or Screen Recording again after the executable changes. Properly signed release builds keep a stable permission identity. + **Requirements:** macOS 12+, Xcode 15+ ## Usage 1. Click the **grid icon** (⫷) in your menu bar 2. Select **New Dock → Horizontal** or **Vertical** -3. Drag `.app` files from Finder into the dock -4. Click any app to launch it or bring it to focus -5. Drag the empty space around the dock to reposition it -6. Right-click an app to remove it or access more options +3. Drag applications, documents, or folders from Finder into the dock +4. Click an app or document to open it, or click a folder to browse its live contents +5. Add a **Recent Files** or **Downloads** smart stack from the dock’s context menu or Preferences +6. Right-click a stack to choose automatic, grid, or list view; change sorting; or show hidden files +7. Drag pinned items to reorder or move them between docks; hold Option while dropping to copy instead +8. Open **Preferences → Permissions** (or right-click a running app and choose **Enable Window Switching…**) to request Accessibility access from macOS +9. From the same Permissions section, optionally enable Screen Recording for live window thumbnails; macOS may require FreeDock to be reopened after approval +10. Hover over the app or choose **Show Windows…** to see its windows across desktops, then select one to bring it forward +11. Right-click an app to bring it forward, hide it, or quit it; right-click a document to choose **Open With** +12. Drop compatible Finder files onto a pinned app to open them with that app, or use **Open Files with…** from its right-click menu +13. Press `⌘⇧Space` from any app to search the nearest dock without reaching for the pointer **Pro tip:** Use **Lock Dock Positions** from the menu bar to prevent accidental moves. +**Recent Files privacy:** This stack records only documents that FreeDock successfully opens. It does not read the system-wide recent-items list. Up to 50 paths are stored locally in `~/.config/freedock.json`, and you can clear the history from the stack or its right-click menu. + +**Window switching privacy:** Discovery and focus happen locally using macOS Accessibility and WindowServer APIs. Optional window thumbnails require Screen Recording access, are kept in a small memory-only cache, and are never written to disk. Without that permission, the same cards remain available with app icons and window titles. A thumbnail shows each window’s currently selected tab; inactive tabs are not separate macOS windows and cannot be captured generically without switching them. Like every FreeDock feature, window switching remains free and open source. + +**Window switcher keyboard controls:** Right-click a running app and choose **Show Windows…**, then use the arrow keys, Tab, or Shift-Tab to move between cards. Press Return, keypad Enter, or Space to activate the selected window, or Escape to close the switcher. + +**Customization:** Choose **Preferences…** from the FreeDock menu to manage window permissions, switch or manage profiles, create and organize docks, add files, folders, or smart stacks, assign docks to displays, import pinned apps from the macOS Dock, adjust behavior and appearance, or copy and reset dock settings. Changes are applied and saved immediately. + +**Shortcuts:** Press `⌘⇧Space` for Quick Launch. Type to narrow the pinned items in the dock nearest your pointer, use the arrow keys or Tab and Shift-Tab to move the selection, press Return to open it, or Escape to close. Press `⌃⌥Space` to show or hide every dock in the active profile. The first nine profiles are available globally with `⌃⌥1` through `⌃⌥9`. + ## Configuration -Docks are saved to `~/.config/freedock.json`. The file is human-readable and editable — changes take effect the next time FreeDock launches. +Docks, profiles, and FreeDock’s local recent-document history are saved to `~/.config/freedock.json`. The file is human-readable and editable — changes take effect the next time FreeDock launches. Format version 4 adds Recent Files and Downloads smart stacks plus a bounded `recentFiles` history. Version 3 introduced typed application, document, folder, and separator items. Older application-only and top-level dock configurations migrate automatically. ```json { - "docks" : [ + "formatVersion": 4, + "activeProfileID": "11111111-1111-1111-1111-111111111111", + "recentFiles": [ + { + "path": "/Users/example/Documents/Project Brief.pdf", + "displayName": "Project Brief.pdf", + "lastOpenedAt": 806976000 + } + ], + "profiles": [ { - "name" : "Dock 1", - "orientation" : "horizontal", - "iconSize" : 48, - "items" : [ - { "appPath" : "/Applications/Safari.app", "label" : "Safari" } + "id": "11111111-1111-1111-1111-111111111111", + "name": "Work", + "docks": [ + { + "id": "22222222-2222-2222-2222-222222222222", + "name": "Main", + "position": [100, 100], + "displayPlacement": { + "displayID": "33333333-3333-3333-3333-333333333333", + "displayName": "Built-in Retina Display", + "normalizedCenter": [0.5, 0.1], + "edge": "bottom" + }, + "orientation": "horizontal", + "iconSize": 48, + "magnificationEnabled": true, + "magnification": 1.3, + "itemSpacing": 3, + "appearance": "glass", + "surfaceOpacity": 1, + "blurStyle": "regular", + "shadowStrength": 1, + "cornerRadius": 18, + "showRunningIndicators": true, + "autoHideWhenDocked": true, + "autoHideDelay": 1, + "items": [ + { + "id": "44444444-4444-4444-4444-444444444444", + "kind": "application", + "path": "/Applications/Safari.app", + "appPath": "/Applications/Safari.app", + "label": "Safari", + "isSeparator": false + }, + { + "id": "55555555-5555-5555-5555-555555555555", + "kind": "document", + "path": "/Users/example/Documents/Project Brief.pdf", + "appPath": "/Users/example/Documents/Project Brief.pdf", + "label": "Project Brief.pdf", + "isSeparator": false + }, + { + "id": "66666666-6666-6666-6666-666666666666", + "kind": "folder", + "path": "/Users/example/Downloads", + "appPath": "/Users/example/Downloads", + "label": "Downloads", + "folderOptions": { + "presentation": "automatic", + "sortOrder": "dateModified", + "showHiddenFiles": false + }, + "isSeparator": false + }, + { + "id": "77777777-7777-7777-7777-777777777777", + "kind": "folder", + "path": "", + "appPath": "", + "label": "Recent Files", + "smartStackSource": "recentFiles", + "folderOptions": { + "presentation": "list", + "sortOrder": "recentlyOpened", + "showHiddenFiles": false + }, + "isSeparator": false + }, + { + "id": "88888888-8888-8888-8888-888888888888", + "kind": "folder", + "path": "", + "appPath": "", + "label": "Downloads", + "smartStackSource": "downloads", + "folderOptions": { + "presentation": "automatic", + "sortOrder": "dateModified", + "showHiddenFiles": false + }, + "isSeparator": false + }, + { + "id": "99999999-9999-9999-9999-999999999999", + "kind": "separator", + "path": "", + "appPath": "", + "isSeparator": true + } + ] + } ] } ] } ``` +FreeDock also writes the active profile’s docks to a top-level compatibility field so older builds can still open the current setup. Typed items retain the legacy `appPath` and `isSeparator` fields for the same reason; `kind` and `path` are the version 3 fields to use when editing the file. + +Smart stacks are folder-style items with an empty path and a `smartStackSource`. A Downloads stack resolves the current user’s Downloads directory dynamically instead of saving it as a fixed path. Only one stack for each smart source can be added to a dock. + ## Contributing Contributions are welcome! Please read `CONTRIBUTING.md` before opening a PR. diff --git a/Sources/FreeDock/AppDelegate.swift b/Sources/FreeDock/AppDelegate.swift index b91bf75..300a498 100644 --- a/Sources/FreeDock/AppDelegate.swift +++ b/Sources/FreeDock/AppDelegate.swift @@ -5,18 +5,69 @@ import SwiftUI class AppDelegate: NSObject, NSApplicationDelegate { private var statusItem: NSStatusItem? private var dockPanels: [UUID: DockPanel] = [:] + private let shortcutManager = GlobalShortcutManager() private let configManager = ConfigManager( configPath: FileManager.default.homeDirectoryForCurrentUser .appendingPathComponent(".config/freedock.json") ) + private let itemDragCoordinator = DockItemDragCoordinator() private var _lockPositions = false private var dockStates: [UUID: DockState] = [:] + private var preferencesStore: DockPreferencesStore? + private var preferencesWindowController: PreferencesWindowController? + private var menuRefreshWorkItem: DispatchWorkItem? + private var dockResizeWorkItems: [UUID: DispatchWorkItem] = [:] + private var liveDockResizeWorkItems: [UUID: DispatchWorkItem] = [:] + private var dockResizeFinishWorkItems: [UUID: DispatchWorkItem] = [:] + private var activeDockResizeIDs: Set = [] + private var pendingAutoHideUpdates: [UUID: (enabled: Bool, orientation: Orientation)] = [:] + private var screenParametersWorkItem: DispatchWorkItem? + private var runtimeDisplayStates: [UUID: RuntimeDisplayState] = [:] + private var folderStackController: FolderStackPanelController? + private let windowPreviewController = + WindowPreviewPanelController() + private var quickLaunchSession: QuickLaunchSession? + private var quickLaunchResizeWorkItem: DispatchWorkItem? + private var hideRestoredDocksAfterFolderStack = false + private var itemDragInteractionTokens: [UUID: UUID] = [:] + + private struct QuickLaunchSession { + let dockID: UUID + let interactionToken: UUID + let restingFrame: NSRect + let previousApplication: NSRunningApplication? + let restoredHiddenProfile: Bool + } + + private struct RuntimeDisplayState { + var effectiveDisplayID: UUID? + var isUsingFallback: Bool + } + private struct IconSizeSelection { let dockID: UUID let size: Double } + private struct ApplicationFileOpenRequest { + let applicationURL: URL + let fileURLs: [URL] + let applicationName: String + } + + private enum ApplicationFileOpenPreparation { + case ready(ApplicationFileOpenRequest) + case rejected(title: String, message: String) + } + func applicationDidFinishLaunching(_: Notification) { + itemDragCoordinator.onSessionBegan = { [weak self] _ in + self?.beginDockItemDragInteraction() + } + itemDragCoordinator.onSessionEnded = { [weak self] _ in + self?.endDockItemDragInteraction() + } + statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength) if let image = NSImage(systemSymbolName: "square.grid.3x3", accessibilityDescription: "FreeDock") { statusItem?.button?.image = image @@ -30,21 +81,103 @@ class AppDelegate: NSObject, NSApplicationDelegate { NSApp.applicationIconImage = icon } - rebuildMenu() restoreDocks() - if configManager.config.docks.isEmpty { + if !configManager.loadedFromDisk, configManager.config.docks.isEmpty { createInitialSeededDock() } + rebuildMenu() NSApp.activate(ignoringOtherApps: true) } func applicationWillTerminate(_: Notification) { + screenParametersWorkItem?.cancel() + itemDragCoordinator.cancel() + windowPreviewController.close(resetNativeController: true) + endQuickLaunch(reactivatePreviousApplication: false) + closeFolderStack() saveAllPositions() configManager.saveImmediately() } + func applicationDidChangeScreenParameters(_: Notification) { + windowPreviewController.close(resetNativeController: false) + endQuickLaunch(reactivatePreviousApplication: false) + closeFolderStack() + for panel in dockPanels.values { + panel.revealImmediately() + } + screenParametersWorkItem?.cancel() + let work = DispatchWorkItem { [weak self] in + self?.reconcileDisplayTopology() + } + screenParametersWorkItem = work + DispatchQueue.main.asyncAfter(deadline: .now() + 0.18, execute: work) + } + @objc func rebuildMenu() { let menu = NSMenu() + + let profileMenu = NSMenu() + for (index, profile) in configManager.config.profiles.enumerated() { + let profileItem = NSMenuItem( + title: profile.name, + action: #selector(switchProfile(_:)), + keyEquivalent: index < 9 ? "\(index + 1)" : "" + ) + profileItem.representedObject = profile.id + profileItem.state = profile.id == configManager.config.activeProfileID ? .on : .off + if index < 9 { + profileItem.keyEquivalentModifierMask = [.control, .option] + } + profileMenu.addItem(profileItem) + } + profileMenu.addItem(.separator()) + profileMenu.addItem(NSMenuItem( + title: "New Profile…", + action: #selector(createProfile), + keyEquivalent: "" + )) + profileMenu.addItem(NSMenuItem( + title: "Rename Current Profile…", + action: #selector(renameCurrentProfile), + keyEquivalent: "" + )) + let deleteProfileItem = NSMenuItem( + title: "Delete Current Profile…", + action: #selector(deleteCurrentProfile), + keyEquivalent: "" + ) + deleteProfileItem.isEnabled = configManager.config.profiles.count > 1 + profileMenu.addItem(deleteProfileItem) + + let profileRoot = NSMenuItem( + title: "Profile: \(configManager.config.activeProfileName)", + action: nil, + keyEquivalent: "" + ) + profileRoot.submenu = profileMenu + menu.addItem(profileRoot) + + let toggleProfileItem = NSMenuItem( + title: dockPanels.isEmpty ? "Show Active Profile" : "Hide Active Profile", + action: #selector(toggleActiveProfileDocks), + keyEquivalent: " " + ) + toggleProfileItem.keyEquivalentModifierMask = [.control, .option] + toggleProfileItem.isEnabled = !configManager.config.docks.isEmpty + menu.addItem(toggleProfileItem) + + let quickLaunchItem = NSMenuItem( + title: "Quick Launch…", + action: #selector(toggleQuickLaunch), + keyEquivalent: " " + ) + quickLaunchItem.keyEquivalentModifierMask = [.command, .shift] + quickLaunchItem.isEnabled = !configManager.config.docks.isEmpty + quickLaunchItem.target = self + menu.addItem(quickLaunchItem) + menu.addItem(.separator()) + let newSub = NSMenu() newSub.addItem(NSMenuItem(title: "Horizontal", action: #selector(newHorizontalDock), keyEquivalent: "")) newSub.addItem(NSMenuItem(title: "Vertical", action: #selector(newVerticalDock), keyEquivalent: "")) @@ -67,6 +200,24 @@ class AppDelegate: NSObject, NSApplicationDelegate { dockMenu.addItem(.separator()) + let autoHideItem = NSMenuItem( + title: "Auto-Hide at Screen Edge", + action: #selector(toggleDockAutoHide(_:)), + keyEquivalent: "" + ) + autoHideItem.representedObject = dock.id + autoHideItem.state = dock.autoHideWhenDocked ? .on : .off + dockMenu.addItem(autoHideItem) + + let dockToEdgeItem = NSMenuItem( + title: "Dock to Nearest Screen Edge", + action: #selector(dockToNearestEdge(_:)), + keyEquivalent: "" + ) + dockToEdgeItem.representedObject = dock.id + dockToEdgeItem.isEnabled = dockPanels[dock.id] != nil + dockMenu.addItem(dockToEdgeItem) + let toggle = NSMenuItem( title: dockPanels[dock.id] != nil ? "Hide Dock" : "Show Dock", action: #selector(toggleDock(_:)), @@ -115,6 +266,16 @@ class AppDelegate: NSObject, NSApplicationDelegate { removeItem.isEnabled = !configManager.config.docks.isEmpty menu.addItem(removeItem) + menu.addItem(.separator()) + let preferencesItem = NSMenuItem( + title: "Preferences…", + action: #selector(showPreferences), + keyEquivalent: "," + ) + preferencesItem.keyEquivalentModifierMask = [.command] + preferencesItem.target = self + menu.addItem(preferencesItem) + menu.addItem(.separator()) let lockItem = NSMenuItem(title: "Lock Dock Positions", action: #selector(toggleLockPositions), keyEquivalent: "l") lockItem.state = _lockPositions ? .on : .off @@ -122,232 +283,2645 @@ class AppDelegate: NSObject, NSApplicationDelegate { menu.addItem(.separator()) menu.addItem(NSMenuItem(title: "Quit FreeDock", action: #selector(NSApplication.terminate(_:)), keyEquivalent: "q")) statusItem?.menu = menu + configureGlobalShortcuts() + refreshPreferencesSnapshot() } - private let snapDistance: CGFloat = 15 + @objc private func showPreferences() { + if preferencesStore == nil { + let store = DockPreferencesStore( + profiles: configManager.config.profiles, + activeProfileID: configManager.config.activeProfileID, + displays: DockDisplayManager.connectedDisplays, + onChange: { [weak self] dockID, change in + self?.applyDockPreference(change, to: dockID) + }, + onManagementAction: { [weak self] action in + self?.handleDockManagementAction(action) + }, + permissionSnapshot: { [weak self] in + guard let self else { return .checking } + return PreferencesPermissionSnapshot( + accessibilityGranted: + self.windowPreviewController + .isAccessibilityTrusted, + screenRecordingGranted: + self.windowPreviewController + .isScreenCaptureTrusted + ) + }, + onPermissionAction: { [weak self] permission in + self?.handlePermissionAction(permission) + }, + onOpenPermissionSettings: { + [weak self] permission in + self?.openPermissionSettings(permission) + } + ) + preferencesStore = store + preferencesWindowController = PreferencesWindowController(store: store) + } - private func snapFrame(_ frame: NSRect) -> NSRect { - guard let screen = NSScreen.main else { return frame } + refreshPreferencesSnapshot() + preferencesWindowController?.show() + } - let visible = screen.visibleFrame - var snapped = frame + private func refreshPreferencesSnapshot() { + preferencesStore?.reload( + profiles: configManager.config.profiles, + activeProfileID: configManager.config.activeProfileID, + displays: DockDisplayManager.connectedDisplays + ) + preferencesStore?.refreshPermissions() + } - if abs(frame.minX - visible.minX) < snapDistance { - snapped.origin.x = visible.minX + private func handlePermissionAction( + _ permission: PreferencesPermissionKind + ) { + switch permission { + case .accessibility: + if windowPreviewController.isAccessibilityTrusted { + openPermissionSettings(permission) + } else { + windowPreviewController.requestAccessibilityAccess() + } + case .screenRecording: + if windowPreviewController.isScreenCaptureTrusted { + openPermissionSettings(permission) + } else { + windowPreviewController.requestScreenCaptureAccess() + } } - if abs(frame.maxX - visible.maxX) < snapDistance { - snapped.origin.x = visible.maxX - frame.width + preferencesStore?.refreshPermissions() + } + + private func openPermissionSettings( + _ permission: PreferencesPermissionKind + ) { + let privacyAnchor: String + switch permission { + case .accessibility: + privacyAnchor = "Privacy_Accessibility" + case .screenRecording: + privacyAnchor = "Privacy_ScreenCapture" } - if abs(frame.minY - visible.minY) < snapDistance { - snapped.origin.y = visible.minY + let workspace = NSWorkspace.shared + if let privacyURL = URL( + string: + "x-apple.systempreferences:com.apple.preference.security?\(privacyAnchor)" + ), + workspace.open(privacyURL) + { + return } - if abs(frame.maxY - visible.maxY) < snapDistance { - snapped.origin.y = visible.maxY - frame.height + if let settingsURL = workspace.urlForApplication( + withBundleIdentifier: "com.apple.systempreferences" + ), + workspace.open(settingsURL) + { + return } - return snapped + let alert = NSAlert() + alert.alertStyle = .informational + alert.messageText = "Open Privacy Settings" + alert.informativeText = """ + Open System Settings → Privacy & Security and select \(permission.title). On macOS 12, open System Preferences → Security & Privacy → Privacy. + """ + alert.addButton(withTitle: "OK") + alert.runModal() } - @objc private func newHorizontalDock() { - createDock(orientation: .horizontal) - } + private func handleDockManagementAction(_ action: DockManagementAction) { + switch action { + case let .activateProfile(profileID): + activateProfile(profileID) + case .createProfile: + createProfile() + case .renameActiveProfile: + renameCurrentProfile() + case .deleteActiveProfile: + deleteCurrentProfile() + case let .createDock(orientation): + createDock(orientation: orientation) + case let .renameDock(dockID): + promptToRenameDock(dockID) + case let .duplicateDock(dockID): + duplicateDock(dockID) + case let .deleteDock(dockID): + confirmAndDeleteDock(dockID) + case let .setDockDisplay(dockID, displayID): + setPreferredDisplay(displayID, for: dockID) + case let .addDockItems(dockID): + chooseDockItems(for: dockID) + case let .addSmartStack(dockID, source): + addSmartStack(source, to: dockID) + case .clearRecentFiles: + confirmAndClearRecentFiles() + case let .importSystemDockApps(dockID): + confirmAndImportSystemDockApps(into: dockID) + case let .resetDockSettings(dockID): + confirmAndResetDockSettings(dockID) + case let .copyDockSettingsToAll(dockID): + confirmAndCopyDockSettingsToAll(dockID) + } - @objc private func newVerticalDock() { - createDock(orientation: .vertical) + DispatchQueue.main.async { [weak self] in + self?.preferencesWindowController?.show() + } } - private var defaultDockPosition: CGPoint { - let visibleFrame = (NSScreen.main ?? NSScreen.screens.first)?.visibleFrame - ?? NSRect(x: 100, y: 100, width: 1200, height: 800) - return CGPoint(x: visibleFrame.midX - 150, y: visibleFrame.midY - 30) + private func scheduleMenuRefresh() { + menuRefreshWorkItem?.cancel() + let work = DispatchWorkItem { [weak self] in + self?.rebuildMenu() + } + menuRefreshWorkItem = work + DispatchQueue.main.asyncAfter(deadline: .now() + 0.18, execute: work) } - private func createDock(orientation: Orientation) { - let dock = DockConfig(name: "Dock \(configManager.config.docks.count + 1)", position: defaultDockPosition, orientation: orientation) - configManager.config.docks.append(dock) - showDock(dock) - configManager.save() - rebuildMenu() - } + private func setPreferredDisplay(_ displayID: UUID?, for dockID: UUID) { + guard let index = configManager.config.docks.firstIndex(where: { $0.id == dockID }) else { + return + } - private func createInitialSeededDock() { - let dock = DockConfig(name: "Dock 1", position: defaultDockPosition, orientation: .horizontal, items: seededDockItems()) - configManager.config.docks.append(dock) - showDock(dock) - configManager.save() - rebuildMenu() - } + var dock = configManager.config.docks[index] + if displayID == nil { + dock.displayPlacement = nil + if let panel = dockPanels[dockID] { + let frame = panel.frameForPersistence + dock.position = frame.origin + let descriptor = DockDisplayGeometry.bestDisplay( + for: frame, + among: DockDisplayManager.connectedDisplays + ) + runtimeDisplayStates[dockID] = RuntimeDisplayState( + effectiveDisplayID: descriptor?.id, + isUsingFallback: false + ) + } + configManager.config.docks[index] = dock + preferencesStore?.replaceDock(dock) + configManager.save() + refreshPreferencesSnapshot() + return + } - private func seededDockItems() -> [DockItem] { - [ - "/Applications/Safari.app", - "/Applications/Google Chrome.app", - "/Applications/Calculator.app", - "/Applications/Notes.app", - "/System/Applications/Utilities/Terminal.app", - ] - .filter { FileManager.default.fileExists(atPath: $0) } - .prefix(4) - .map { path in DockItem(appPath: path, label: AppInfo.resolve(from: path).displayName) } - } + guard let displayID, + let targetScreen = DockDisplayManager.screen(withID: displayID), + let targetDisplay = DockDisplayManager.descriptor(for: targetScreen) + else { + return + } - @objc private func toggleDock(_ sender: NSMenuItem) { - guard let id = sender.representedObject as? UUID else { return } - if let panel = dockPanels[id] { - panel.orderOut(nil) - dockPanels.removeValue(forKey: id) - } else if let config = configManager.config.docks.first(where: { $0.id == id }) { - showDock(config) + let existingPlacement = dock.displayPlacement + let panel = dockPanels[dockID] + let currentFrame = panel?.frameForPersistence + let currentDisplay = currentFrame.flatMap { + DockDisplayGeometry.bestDisplay( + for: $0, + among: DockDisplayManager.connectedDisplays + ) + } + let normalizedCenter = if let currentFrame, let currentDisplay { + DockDisplayGeometry.normalizedCenter( + of: currentFrame, + in: currentDisplay.visibleFrame + ) + } else { + existingPlacement?.normalizedCenter ?? CGPoint(x: 0.5, y: 0.5) + } + let edge: DockScreenEdge? = if dock.autoHideWhenDocked, + let currentFrame, + let currentDisplay + { + DockDisplayGeometry.nearestEdge( + of: currentFrame, + in: currentDisplay.visibleFrame, + orientation: dock.orientation + ) + } else { + dock.autoHideWhenDocked ? existingPlacement?.edge : nil } - rebuildMenu() - } - @objc private func deleteDock(_ sender: NSMenuItem) { - guard let id = sender.representedObject as? UUID else { return } - dockPanels[id]?.close() - dockPanels.removeValue(forKey: id) - configManager.config.docks.removeAll(where: { $0.id == id }) + dock.displayPlacement = DockDisplayPlacement( + displayID: displayID, + displayName: targetDisplay.label, + normalizedCenter: normalizedCenter, + edge: edge + ) + configManager.config.docks[index] = dock + + if let panel { + placePanel( + panel, + using: dock, + on: targetScreen, + display: targetDisplay, + isFallback: false, + animate: true + ) + _ = mirrorPanelPosition(panel, on: targetDisplay) + dock = configManager.config.docks[index] + } + + preferencesStore?.replaceDock(dock) configManager.save() - rebuildMenu() + refreshPreferencesSnapshot() } - @objc private func toggleLockPositions() { - _lockPositions.toggle() - rebuildMenu() + private func reconcileDisplayTopology() { + TooltipManager.shared.hide() + let docks = configManager.config.docks + var configChanged = false + + for dock in docks { + guard let panel = dockPanels[dock.id], + let resolution = displayResolution(for: dock, panelFrame: panel.frameForPersistence) + else { + continue + } + + placePanel( + panel, + using: dock, + on: resolution.screen, + display: resolution.display, + isFallback: resolution.isFallback, + animate: false + ) + if !resolution.isFallback { + configChanged = mirrorPanelPosition( + panel, + on: resolution.display + ) || configChanged + } + } + + if configChanged { + configManager.save() + } + refreshPreferencesSnapshot() } - @objc private func renameDock(_ sender: NSMenuItem) { - guard let id = sender.representedObject as? UUID, - let idx = configManager.config.docks.firstIndex(where: { $0.id == id }) else { return } + private func displayResolution( + for dock: DockConfig, + panelFrame: CGRect + ) -> (screen: NSScreen, display: DockDisplayDescriptor, isFallback: Bool)? { + guard let resolution = DockDisplayGeometry.resolveDisplay( + for: dock.displayPlacement, + panelFrame: panelFrame, + among: DockDisplayManager.connectedDisplays + ), + let screen = DockDisplayManager.screen(withID: resolution.display.id) + else { + return nil + } + return (screen, resolution.display, resolution.isFallback) + } - let alert = NSAlert() - alert.icon = NSApp.applicationIconImage - alert.messageText = "Rename Dock" - alert.informativeText = "Enter a new name for the dock:" + private func placePanel( + _ panel: DockPanel, + using dock: DockConfig, + on screen: NSScreen, + display: DockDisplayDescriptor, + isFallback: Bool, + animate: Bool + ) { + panel.revealImmediately() - let field = NSTextField(frame: NSRect(x: 0, y: 0, width: 200, height: 24)) - field.stringValue = configManager.config.docks[idx].name - alert.accessoryView = field + var targetFrame: CGRect + let placement = dock.displayPlacement?.respecting( + orientation: dock.orientation, + autoHideWhenDocked: dock.autoHideWhenDocked + ) + if var placement { + let preferredEdge = placement.edge + placement.edge = nil + targetFrame = DockDisplayGeometry.frame( + size: panel.frame.size, + placement: placement, + in: display.visibleFrame + ) - alert.addButton(withTitle: "OK") - alert.addButton(withTitle: "Cancel") + if dock.autoHideWhenDocked { + let exposedEdges = DockDisplayGeometry.exposedEdges( + for: targetFrame, + on: display, + among: DockDisplayManager.connectedDisplays + ) + let edge = if let preferredEdge, + exposedEdges.contains(preferredEdge) + { + preferredEdge + } else { + DockDisplayGeometry.preferredDockingEdge( + of: targetFrame, + on: display, + among: DockDisplayManager.connectedDisplays, + orientation: dock.orientation + ) + } + if let edge { + targetFrame = DockDisplayGeometry.dockedFrame( + targetFrame, + at: edge, + in: display.visibleFrame + ) + } + } + } else { + targetFrame = panel.frameForPersistence + targetFrame.origin = dock.position + targetFrame = DockDisplayGeometry.clamped( + targetFrame, + to: display.visibleFrame + ) + } - if alert.runModal() == .alertFirstButtonReturn { - let newName = field.stringValue.trimmingCharacters(in: .whitespacesAndNewlines) + if dock.autoHideWhenDocked, placement == nil { + targetFrame = frameDockedToNearestEdge( + targetFrame, + orientation: dock.orientation, + on: screen + ) + } - guard !newName.isEmpty else { return } + panel.setFrame(targetFrame, display: true, animate: animate) + runtimeDisplayStates[dock.id] = RuntimeDisplayState( + effectiveDisplayID: display.id, + isUsingFallback: isFallback + ) - configManager.config.docks[idx].name = newName - configManager.save() - rebuildMenu() + if animate { + DispatchQueue.main.asyncAfter( + deadline: .now() + 0.35 + ) { [weak self, weak panel] in + guard let self, let panel else { return } + self.updateAutoHideGeometry(for: panel, on: display) + if dock.autoHideWhenDocked { + panel.scheduleAutoHide() + } + } + } else { + updateAutoHideGeometry(for: panel, on: display) + if dock.autoHideWhenDocked { + DispatchQueue.main.asyncAfter( + deadline: .now() + 0.12 + ) { [weak panel] in + panel?.scheduleAutoHide() + } + } } } - @objc private func changeIconSize(_ sender: NSMenuItem) { - guard let selection = sender.representedObject as? IconSizeSelection, - let index = configManager.config.docks.firstIndex(where: { $0.id == selection.dockID }) - else { return } + @discardableResult + private func mirrorPanelPosition( + _ panel: DockPanel, + on display: DockDisplayDescriptor + ) -> Bool { + guard runtimeDisplayStates[panel.dockID]?.isUsingFallback != true, + let index = configManager.config.docks.firstIndex(where: { + $0.id == panel.dockID + }) + else { + return false + } - configManager.config.docks[index].iconSize = selection.size - configManager.save() + var dock = configManager.config.docks[index] + dock.position = panel.frameForPersistence.origin + if var placement = dock.displayPlacement, + placement.displayID == display.id + { + placement.displayName = display.label + dock.displayPlacement = placement + } - dockStates[selection.dockID]?.iconSize = selection.size - dockPanels[selection.dockID]?.resizeToFitContent() - rebuildMenu() + guard dock != configManager.config.docks[index] else { return false } + configManager.config.docks[index] = dock + preferencesStore?.replaceDock(dock) + return true } - private func showDock(_ config: DockConfig) { - let rect = NSRect(origin: config.position, - size: NSSize(width: 400, height: 70)) - let panel = DockPanel(dockID: config.id, contentRect: rect) - panel.clampToVisibleFrame() + @discardableResult + private func persistPanelPlacement( + _ panel: DockPanel, + userInitiated: Bool + ) -> Bool { + guard let index = configManager.config.docks.firstIndex(where: { + $0.id == panel.dockID + }) else { + return false + } - let dockID = config.id - let state = DockState(iconSize: config.iconSize) - dockStates[config.id] = state // ← store it + let runtimeState = runtimeDisplayStates[panel.dockID] + if runtimeState?.isUsingFallback == true, !userInitiated { + return false + } - let content = DockContentView( - panel: panel, - items: Binding( - get: { self.configManager.config.docks.first(where: { $0.id == dockID })?.items ?? [] }, - set: { newItems in - guard let idx = self.configManager.config.docks.firstIndex(where: { $0.id == dockID }) else { return } - self.configManager.config.docks[idx].items = newItems - } - ), - orientation: config.orientation, - state: state, - onItemsChanged: { _ in - self.configManager.save() - DispatchQueue.main.async { panel.resizeToFitContent() } - }, - onAppLaunch: { item in NSWorkspace.shared.open(URL(fileURLWithPath: item.appPath)) } - ) + let frame = panel.frameForPersistence + guard let display = DockDisplayGeometry.bestDisplay( + for: frame, + among: DockDisplayManager.connectedDisplays + ) else { + return false + } - panel.dockOrientation = config.orientation - panel.setContentView(content) - panel.dockDelegate = self - panel.makeKeyAndOrderFront(nil) - panel.orderFrontRegardless() - dockPanels[config.id] = panel - } + var dock = configManager.config.docks[index] + if let placement = dock.displayPlacement, + !userInitiated, + placement.displayID != display.id + { + return false + } - private func restoreDocks() { - for dock in configManager.config.docks { - showDock(dock) + dock.position = frame.origin + if dock.displayPlacement != nil { + dock.displayPlacement = DockDisplayPlacement( + displayID: display.id, + displayName: display.label, + normalizedCenter: DockDisplayGeometry.normalizedCenter( + of: frame, + in: display.visibleFrame + ), + edge: dock.autoHideWhenDocked + ? DockDisplayGeometry.nearestEdge( + of: frame, + in: display.visibleFrame, + orientation: dock.orientation + ) + : nil + ) } + + guard dock != configManager.config.docks[index] else { return false } + configManager.config.docks[index] = dock + preferencesStore?.replaceDock(dock) + runtimeDisplayStates[panel.dockID] = RuntimeDisplayState( + effectiveDisplayID: display.id, + isUsingFallback: false + ) + return true } - private func saveAllPositions() { - for (id, panel) in dockPanels { - guard let idx = configManager.config.docks.firstIndex(where: { $0.id == id }) else { continue } - configManager.config.docks[idx].position = panel.frame.origin + private func effectiveScreen(for panel: DockPanel) -> NSScreen? { + if let displayID = runtimeDisplayStates[panel.dockID]?.effectiveDisplayID, + let screen = DockDisplayManager.screen(withID: displayID) + { + return screen } + return DockDisplayManager.screen(containing: panel.frameForPersistence) } -} -extension AppDelegate: DockPanelDelegate { - var lockPositions: Bool { - get { _lockPositions } - set { _lockPositions = newValue } + private func updateAutoHideGeometry( + for panel: DockPanel, + on preferredDisplay: DockDisplayDescriptor? = nil + ) { + let displays = DockDisplayManager.connectedDisplays + let display = preferredDisplay ?? DockDisplayGeometry.bestDisplay( + for: panel.frameForPersistence, + among: displays + ) + guard let display else { + panel.allowedAutoHideEdges = [] + panel.updateAutoHideEdge() + return + } + + panel.allowedAutoHideEdges = Set( + DockDisplayGeometry.exposedEdges( + for: panel.frameForPersistence, + on: display, + among: displays + ).filter { + $0.isCompatible(with: panel.dockOrientation) + } + ) + panel.updateAutoHideEdge() } - func dockPanelDidMove(_ panel: DockPanel) { - let snapped = snapFrame(panel.frame) + private let snapDistance: CGFloat = 15 - if snapped.origin != panel.frame.origin { - panel.setFrame(snapped, display: true, animate: true) + private func snapFrame( + _ frame: NSRect, + on preferredScreen: NSScreen? = nil + ) -> NSRect { + guard let screen = preferredScreen ?? DockDisplayManager.screen(containing: frame) else { + return frame + } + + let visible = screen.visibleFrame + var snapped = frame + + if abs(frame.minX - visible.minX) < snapDistance { + snapped.origin.x = visible.minX } - guard let idx = configManager.config.docks.firstIndex(where: { $0.id == panel.dockID }) else { return } + if abs(frame.maxX - visible.maxX) < snapDistance { + snapped.origin.x = visible.maxX - frame.width + } - configManager.config.docks[idx].position = snapped.origin - configManager.save() - } + if abs(frame.minY - visible.minY) < snapDistance { + snapped.origin.y = visible.minY + } - func currentIconSize(for panel: DockPanel) -> Double { - dockStates[panel.dockID]?.iconSize ?? 48 - } + if abs(frame.maxY - visible.maxY) < snapDistance { + snapped.origin.y = visible.maxY - frame.height + } - func dockPanelDidResize(_ panel: DockPanel, proposedIconSize: Double) { - guard let idx = configManager.config.docks.firstIndex(where: { $0.id == panel.dockID }) else { return } + return snapped + } - // Update reactive state → SwiftUI reflows content - dockStates[panel.dockID]?.iconSize = proposedIconSize - configManager.config.docks[idx].iconSize = proposedIconSize + private func frameDockedToNearestEdge( + _ frame: NSRect, + orientation: Orientation, + on preferredScreen: NSScreen? = nil + ) -> NSRect { + guard let screen = preferredScreen + ?? DockDisplayManager.screen(containing: frame), + let display = DockDisplayManager.descriptor(for: screen) + else { + return frame + } - // Fit panel to the new content size - DispatchQueue.main.async { - panel.resizeToFitContent() + guard let edge = DockDisplayGeometry.preferredDockingEdge( + of: frame, + on: display, + among: DockDisplayManager.connectedDisplays, + orientation: orientation + ) else { + return DockDisplayGeometry.clamped( + frame, + to: display.visibleFrame + ) } + + return DockDisplayGeometry.dockedFrame( + frame, + at: edge, + in: display.visibleFrame + ) } - func dockPanelDidFinishResize(_ panel: DockPanel) { - guard let idx = configManager.config.docks.firstIndex(where: { $0.id == panel.dockID }) else { return } - configManager.config.docks[idx].position = panel.frame.origin - configManager.save() + @objc private func switchProfile(_ sender: NSMenuItem) { + guard let profileID = sender.representedObject as? UUID else { return } + activateProfile(profileID) + } + + private func activateProfile(_ profileID: UUID) { + guard profileID != configManager.config.activeProfileID, + configManager.config.profiles.contains(where: { $0.id == profileID }) + else { return } + + saveAllPositions() + closeAllDockPanels() + configManager.config.activeProfileID = profileID + restoreDocks() + configManager.save() + rebuildMenu() + } + + @objc private func toggleActiveProfileDocks() { + if dockPanels.isEmpty { + restoreDocks() + } else { + saveAllPositions() + closeAllDockPanels() + } + configManager.save() + rebuildMenu() + } + + private func configureGlobalShortcuts() { + shortcutManager.reset() + shortcutManager.register(id: 1, keyCode: GlobalShortcutManager.showHideKeyCode) { [weak self] in + self?.toggleActiveProfileDocks() + } + let didRegisterQuickLaunch = shortcutManager.register( + id: 100, + keyCode: GlobalShortcutManager.quickLaunchKeyCode, + modifiers: GlobalShortcutManager.quickLaunchModifiers + ) { [weak self] in + self?.toggleQuickLaunch() + } + if !didRegisterQuickLaunch { + NSLog("FreeDock could not register the global Quick Launch shortcut Command-Shift-Space.") + } + + for (index, profile) in configManager.config.profiles.prefix(9).enumerated() { + shortcutManager.register( + id: UInt32(index + 2), + keyCode: GlobalShortcutManager.profileKeyCodes[index] + ) { [weak self] in + self?.activateProfile(profile.id) + } + } + } + + @objc private func toggleQuickLaunch() { + windowPreviewController.close(resetNativeController: false) + if quickLaunchSession != nil { + endQuickLaunch(reactivatePreviousApplication: true) + return + } + + closeFolderStack() + TooltipManager.shared.hide() + + let restoredHiddenProfile = dockPanels.isEmpty + if restoredHiddenProfile, !configManager.config.docks.isEmpty { + restoreDocks() + scheduleMenuRefresh() + } + + guard let panel = quickLaunchTargetPanel(), + let state = dockStates[panel.dockID] + else { + NSSound.beep() + return + } + + let frontmostApplication = NSWorkspace.shared.frontmostApplication + let previousApplication = frontmostApplication?.processIdentifier + == ProcessInfo.processInfo.processIdentifier + ? nil + : frontmostApplication + let interactionToken = panel.beginTransientInteraction() + panel.setPositionLocked(true) + quickLaunchSession = QuickLaunchSession( + dockID: panel.dockID, + interactionToken: interactionToken, + restingFrame: panel.frameForPersistence, + previousApplication: previousApplication, + restoredHiddenProfile: restoredHiddenProfile + ) + + panel.setQuickLaunchKeyMode(true) + state.presentQuickLaunch() + NSApp.activate(ignoringOtherApps: true) + panel.makeKeyAndOrderFront(nil) + panel.orderFrontRegardless() + resizeAfterQuickLaunchTransition(panel) + } + + private func quickLaunchTargetPanel() -> DockPanel? { + let orderedDocks = configManager.config.docks + if let keyPanel = orderedDocks.lazy.compactMap({ + self.dockPanels[$0.id] + }).first(where: \.isKeyWindow) { + return keyPanel + } + + let pointer = NSEvent.mouseLocation + let pointerScreen = NSScreen.screens.first { + $0.frame.contains(pointer) + } + let candidates = orderedDocks.enumerated().compactMap { + order, dock -> QuickLaunchDockCandidate? in + guard let panel = dockPanels[dock.id] else { return nil } + return QuickLaunchDockCandidate( + dockID: dock.id, + frame: panel.frameForPersistence, + configOrder: order + ) + } + guard let targetID = QuickLaunchDockRouter.targetDockID( + for: pointer, + pointerScreenFrame: pointerScreen?.frame, + candidates: candidates + ) else { + return nil + } + return dockPanels[targetID] + } + + @discardableResult + private func endQuickLaunch( + ifDockID dockID: UUID? = nil, + reactivatePreviousApplication: Bool, + restoreHiddenProfile: Bool = true + ) -> Bool { + guard let session = quickLaunchSession, + dockID == nil || session.dockID == dockID + else { + return false + } + + quickLaunchSession = nil + quickLaunchResizeWorkItem?.cancel() + quickLaunchResizeWorkItem = nil + + let panel = dockPanels[session.dockID] + dockStates[session.dockID]?.dismissQuickLaunch() + panel?.setFrame(session.restingFrame, display: true) + panel?.endTransientInteraction(session.interactionToken) + panel?.setPositionLocked(_lockPositions) + if panel?.isKeyWindow == true { + panel?.resignKey() + } + panel?.setQuickLaunchKeyMode(false) + if session.restoredHiddenProfile, restoreHiddenProfile { + hideDocksRestoredForQuickLaunch() + } else if let panel { + resizeAfterQuickLaunchTransition(panel) + } + + if reactivatePreviousApplication, + let previousApplication = session.previousApplication, + !previousApplication.isTerminated + { + DispatchQueue.main.async { + previousApplication.activate( + options: [.activateIgnoringOtherApps] + ) + } + } + return session.restoredHiddenProfile + } + + private func resizeAfterQuickLaunchTransition(_ panel: DockPanel) { + quickLaunchResizeWorkItem?.cancel() + let dockID = panel.dockID + let work = DispatchWorkItem { [weak self, weak panel] in + guard let self else { return } + self.quickLaunchResizeWorkItem = nil + guard let panel, + self.dockPanels[dockID] === panel + else { + return + } + panel.resizeToFitContent() + self.updateAutoHideGeometry(for: panel) + } + quickLaunchResizeWorkItem = work + DispatchQueue.main.asyncAfter( + deadline: .now() + (1.0 / 60.0), + execute: work + ) + } + + private func hideDocksRestoredForQuickLaunch() { + hideRestoredDocksAfterFolderStack = false + quickLaunchResizeWorkItem?.cancel() + quickLaunchResizeWorkItem = nil + guard !dockPanels.isEmpty else { return } + closeAllDockPanels() + scheduleMenuRefresh() + } + + @objc private func createProfile() { + guard let name = promptForProfileName( + title: "New Profile", + message: "Create a separate set of docks for a workflow.", + initialValue: suggestedProfileName() + ) else { return } + + saveAllPositions() + closeAllDockPanels() + + let placement = defaultDockPlacement + let starterDock = DockConfig( + name: "Dock 1", + position: placement.position, + displayPlacement: placement.displayPlacement, + orientation: .horizontal, + autoHideWhenDocked: false + ) + let profile = DockProfile(name: name, docks: [starterDock]) + configManager.config.profiles.append(profile) + configManager.config.activeProfileID = profile.id + restoreDocks() + configManager.save() + rebuildMenu() + } + + @objc private func renameCurrentProfile() { + let profileID = configManager.config.activeProfileID + guard let index = configManager.config.profiles.firstIndex(where: { $0.id == profileID }), + let name = promptForProfileName( + title: "Rename Profile", + message: "Choose a name for this dock profile.", + initialValue: configManager.config.profiles[index].name, + excluding: profileID + ) + else { return } + + configManager.config.profiles[index].name = name + configManager.save() + rebuildMenu() + } + + @objc private func deleteCurrentProfile() { + guard configManager.config.profiles.count > 1, + let index = configManager.config.profiles.firstIndex(where: { + $0.id == configManager.config.activeProfileID + }) + else { return } + + let profile = configManager.config.profiles[index] + let alert = NSAlert() + alert.icon = NSApp.applicationIconImage + alert.alertStyle = .warning + alert.messageText = "Delete “\(profile.name)”?" + alert.informativeText = profile.docks.isEmpty + ? "This profile is empty." + : "This permanently removes \(profile.docks.count) dock\(profile.docks.count == 1 ? "" : "s") from the profile." + alert.addButton(withTitle: "Cancel") + let deleteButton = alert.addButton(withTitle: "Delete") + deleteButton.hasDestructiveAction = true + guard alert.runModal() == .alertSecondButtonReturn else { return } + + saveAllPositions() + closeAllDockPanels() + configManager.config.profiles.remove(at: index) + let nextIndex = min(index, configManager.config.profiles.count - 1) + configManager.config.activeProfileID = configManager.config.profiles[nextIndex].id + restoreDocks() + configManager.save() + rebuildMenu() + } + + private func promptForProfileName( + title: String, + message: String, + initialValue: String, + excluding excludedID: UUID? = nil + ) -> String? { + var proposedValue = initialValue + + while true { + let alert = NSAlert() + alert.icon = NSApp.applicationIconImage + alert.messageText = title + alert.informativeText = message + + let field = NSTextField(frame: NSRect(x: 0, y: 0, width: 240, height: 24)) + field.stringValue = proposedValue + field.selectText(nil) + alert.accessoryView = field + alert.addButton(withTitle: "Save") + alert.addButton(withTitle: "Cancel") + + guard alert.runModal() == .alertFirstButtonReturn else { return nil } + proposedValue = field.stringValue.trimmingCharacters(in: .whitespacesAndNewlines) + + if proposedValue.isEmpty { + showProfileNameError("Profile names cannot be empty.") + continue + } + + let isDuplicate = configManager.config.profiles.contains { + $0.id != excludedID + && $0.name.compare(proposedValue, options: [.caseInsensitive, .diacriticInsensitive]) == .orderedSame + } + if isDuplicate { + showProfileNameError("A profile named “\(proposedValue)” already exists.") + continue + } + + return proposedValue + } + } + + private func showProfileNameError(_ message: String) { + let alert = NSAlert() + alert.alertStyle = .informational + alert.messageText = "Choose Another Name" + alert.informativeText = message + alert.addButton(withTitle: "OK") + alert.runModal() + } + + private func suggestedProfileName() -> String { + let base = "Profile" + var number = configManager.config.profiles.count + 1 + var candidate = "\(base) \(number)" + let existing = Set(configManager.config.profiles.map { $0.name.lowercased() }) + + while existing.contains(candidate.lowercased()) { + number += 1 + candidate = "\(base) \(number)" + } + return candidate + } + + @objc private func newHorizontalDock() { + createDock(orientation: .horizontal) + } + + @objc private func newVerticalDock() { + createDock(orientation: .vertical) + } + + private var defaultDockPlacement: ( + position: CGPoint, + displayPlacement: DockDisplayPlacement? + ) { + let mouseLocation = NSEvent.mouseLocation + let screen = NSScreen.screens.first(where: { + $0.frame.contains(mouseLocation) + }) ?? DockDisplayManager.primaryScreen + let visibleFrame = screen?.visibleFrame + ?? NSRect(x: 100, y: 100, width: 1200, height: 800) + let position = CGPoint( + x: visibleFrame.midX - 150, + y: visibleFrame.midY - 30 + ) + let displayPlacement = screen + .flatMap(DockDisplayManager.descriptor(for:)) + .map { + DockDisplayPlacement( + displayID: $0.id, + displayName: $0.label + ) + } + return (position, displayPlacement) + } + + private func createDock(orientation: Orientation) { + let placement = defaultDockPlacement + let dock = DockConfig( + name: suggestedDockName(), + position: placement.position, + displayPlacement: placement.displayPlacement, + orientation: orientation + ) + configManager.config.docks.append(dock) + showDock(dock) + configManager.save() + rebuildMenu() + preferencesStore?.selectedDockID = dock.id + } + + private func suggestedDockName() -> String { + let existing = Set(configManager.config.docks.map { $0.name.lowercased() }) + var number = 1 + var candidate = "Dock \(number)" + + while existing.contains(candidate.lowercased()) { + number += 1 + candidate = "Dock \(number)" + } + return candidate + } + + private func suggestedDuplicateName(for sourceName: String) -> String { + let existing = Set(configManager.config.docks.map { $0.name.lowercased() }) + let base = "\(sourceName) Copy" + var candidate = base + var number = 2 + + while existing.contains(candidate.lowercased()) { + candidate = "\(base) \(number)" + number += 1 + } + return candidate + } + + private func createInitialSeededDock() { + let placement = defaultDockPlacement + let dock = DockConfig( + name: "Dock 1", + position: placement.position, + displayPlacement: placement.displayPlacement, + orientation: .horizontal, + items: seededDockItems() + ) + configManager.config.docks.append(dock) + showDock(dock) + configManager.save() + rebuildMenu() + } + + private func seededDockItems() -> [DockItem] { + [ + "/Applications/Safari.app", + "/Applications/Google Chrome.app", + "/Applications/Calculator.app", + "/Applications/Notes.app", + "/System/Applications/Utilities/Terminal.app", + ] + .filter { FileManager.default.fileExists(atPath: $0) } + .prefix(4) + .map { path in DockItem(appPath: path, label: AppInfo.resolve(from: path).displayName) } + } + + @objc private func toggleDock(_ sender: NSMenuItem) { + guard let id = sender.representedObject as? UUID else { return } + if let panel = dockPanels[id] { + endQuickLaunch( + ifDockID: id, + reactivatePreviousApplication: false + ) + closeFolderStack(for: id) + cancelDockResizeWork(for: id) + pendingAutoHideUpdates.removeValue(forKey: id) + panel.tearDown() + dockPanels.removeValue(forKey: id) + dockStates.removeValue(forKey: id) + runtimeDisplayStates.removeValue(forKey: id) + } else if let config = configManager.config.docks.first(where: { $0.id == id }) { + showDock(config) + } + rebuildMenu() + } + + @objc private func deleteDock(_ sender: NSMenuItem) { + guard let id = sender.representedObject as? UUID else { return } + confirmAndDeleteDock(id) + } + + private func confirmAndDeleteDock(_ id: UUID) { + guard let dock = configManager.config.docks.first(where: { $0.id == id }) else { return } + + let alert = NSAlert() + alert.icon = NSApp.applicationIconImage + alert.alertStyle = .warning + alert.messageText = "Delete “\(dock.name)”?" + alert.informativeText = "This removes the dock from the current profile. The pinned applications, files, and folders themselves will not be deleted." + alert.addButton(withTitle: "Cancel") + let deleteButton = alert.addButton(withTitle: "Delete") + deleteButton.hasDestructiveAction = true + guard alert.runModal() == .alertSecondButtonReturn else { return } + + performDeleteDock(id) + } + + private func performDeleteDock(_ id: UUID) { + endQuickLaunch( + ifDockID: id, + reactivatePreviousApplication: false + ) + closeFolderStack(for: id) + cancelDockResizeWork(for: id) + pendingAutoHideUpdates.removeValue(forKey: id) + dockPanels[id]?.tearDown() + dockPanels.removeValue(forKey: id) + dockStates.removeValue(forKey: id) + runtimeDisplayStates.removeValue(forKey: id) + configManager.config.docks.removeAll(where: { $0.id == id }) + configManager.save() + rebuildMenu() + } + + @objc private func toggleLockPositions() { + _lockPositions.toggle() + for panel in dockPanels.values { + panel.setPositionLocked(_lockPositions) + } + rebuildMenu() + } + + @objc private func toggleDockAutoHide(_ sender: NSMenuItem) { + guard let id = sender.representedObject as? UUID, + let dock = configManager.config.docks.first(where: { $0.id == id }) + else { return } + + applyDockPreference( + .autoHideWhenDocked(!dock.autoHideWhenDocked), + to: id + ) + } + + @objc private func dockToNearestEdge(_ sender: NSMenuItem) { + guard let id = sender.representedObject as? UUID else { return } + dockPanelToNearestEdge(id) + } + + private func dockPanelToNearestEdge(_ id: UUID) { + guard let index = configManager.config.docks.firstIndex(where: { $0.id == id }), + let panel = dockPanels[id] + else { return } + + panel.revealImmediately() + let screen = effectiveScreen(for: panel) + let dockedFrame = frameDockedToNearestEdge( + panel.frameForPersistence, + orientation: configManager.config.docks[index].orientation, + on: screen + ) + panel.setFrame(dockedFrame, display: true, animate: true) + _ = persistPanelPlacement(panel, userInitiated: false) + configManager.save() + refreshPreferencesSnapshot() + + DispatchQueue.main.asyncAfter(deadline: .now() + 0.35) { + [weak self, weak panel] in + guard let self, let panel else { return } + self.updateAutoHideGeometry(for: panel) + panel.scheduleAutoHide() + } + } + + @objc private func renameDock(_ sender: NSMenuItem) { + guard let id = sender.representedObject as? UUID else { return } + promptToRenameDock(id) + } + + private func promptToRenameDock(_ id: UUID) { + guard let idx = configManager.config.docks.firstIndex(where: { $0.id == id }) else { return } + var proposedValue = configManager.config.docks[idx].name + + while true { + let alert = NSAlert() + alert.icon = NSApp.applicationIconImage + alert.messageText = "Rename Dock" + alert.informativeText = "Enter a new name for the dock:" + + let field = NSTextField(frame: NSRect(x: 0, y: 0, width: 220, height: 24)) + field.stringValue = proposedValue + field.selectText(nil) + alert.accessoryView = field + + alert.addButton(withTitle: "Save") + alert.addButton(withTitle: "Cancel") + guard alert.runModal() == .alertFirstButtonReturn else { return } + + proposedValue = field.stringValue.trimmingCharacters(in: .whitespacesAndNewlines) + if proposedValue.isEmpty { + showDockNameError("Dock names cannot be empty.") + continue + } + + let isDuplicate = configManager.config.docks.contains { + $0.id != id + && $0.name.compare( + proposedValue, + options: [.caseInsensitive, .diacriticInsensitive] + ) == .orderedSame + } + if isDuplicate { + showDockNameError("A dock named “\(proposedValue)” already exists in this profile.") + continue + } + + guard let currentIndex = configManager.config.docks.firstIndex(where: { $0.id == id }) else { return } + configManager.config.docks[currentIndex].name = proposedValue + dockStates[id]?.name = proposedValue + configManager.save() + rebuildMenu() + return + } + } + + private func showDockNameError(_ message: String) { + let alert = NSAlert() + alert.alertStyle = .informational + alert.messageText = "Choose Another Dock Name" + alert.informativeText = message + alert.addButton(withTitle: "OK") + alert.runModal() + } + + private func duplicateDock(_ id: UUID) { + saveAllPositions() + guard let sourceIndex = configManager.config.docks.firstIndex(where: { $0.id == id }) else { return } + let source = configManager.config.docks[sourceIndex] + + var duplicatedPosition = source.position + if source.orientation == .horizontal { + duplicatedPosition.x += 32 + } else { + duplicatedPosition.y -= 32 + } + + var duplicate = source.duplicated( + name: suggestedDuplicateName(for: source.name), + position: duplicatedPosition + ) + if let placement = duplicate.displayPlacement, + let resolution = DockDisplayGeometry.resolveDisplay( + for: placement, + panelFrame: dockPanels[id]?.frameForPersistence + ?? CGRect(origin: source.position, size: .zero), + among: DockDisplayManager.connectedDisplays + ) + { + let delta = source.orientation == .horizontal + ? CGPoint(x: 32, y: 0) + : CGPoint(x: 0, y: -32) + duplicate.displayPlacement = DockDisplayGeometry.offset( + placement, + by: delta, + in: resolution.display.visibleFrame + ) + } + + configManager.config.docks.insert(duplicate, at: sourceIndex + 1) + showDock(duplicate) + configManager.save() + rebuildMenu() + preferencesStore?.selectedDockID = duplicate.id + } + + private func chooseDockItems(for dockID: UUID) { + guard configManager.config.docks.contains(where: { $0.id == dockID }) else { + return + } + + windowPreviewController.close(resetNativeController: false) + closeFolderStack() + let sourceDock = dockPanels[dockID] + let interactionToken = sourceDock?.beginTransientInteraction() + let openPanel = NSOpenPanel() + openPanel.title = "Add Items to FreeDock" + openPanel.message = "Choose applications, documents, or folders to pin." + openPanel.prompt = "Add" + openPanel.canChooseFiles = true + openPanel.canChooseDirectories = true + openPanel.allowsMultipleSelection = true + openPanel.resolvesAliases = true + openPanel.treatsFilePackagesAsDirectories = false + + let completion: (NSApplication.ModalResponse) -> Void = { + [weak self, weak sourceDock] response in + if let interactionToken { + sourceDock?.endTransientInteraction(interactionToken) + } + guard response == .OK, let self else { return } + + let currentItems = self.configManager.config.docks.first(where: { + $0.id == dockID + })?.items ?? [] + let plan = DockItemPlanner.planAdding( + urls: openPanel.urls, + to: currentItems + ) + guard plan.addedCount > 0 else { + NSSound.beep() + return + } + self.replaceDockItems(plan.items, for: dockID) + } + + if let preferencesWindow = preferencesWindowController?.window, + preferencesWindow.isVisible + { + openPanel.beginSheetModal( + for: preferencesWindow, + completionHandler: completion + ) + } else { + openPanel.begin(completionHandler: completion) + } + } + + private func addSmartStack( + _ source: SmartStackSource, + to dockID: UUID + ) { + guard let dock = configManager.config.docks.first(where: { + $0.id == dockID + }) else { + return + } + + let plan = DockItemPlanner.planAdding( + smartStack: source, + to: dock.items + ) + guard plan.addedCount > 0 else { + NSSound.beep() + return + } + replaceDockItems(plan.items, for: dockID) + } + + private func replaceDockItems( + _ items: [DockItem], + for dockID: UUID, + preservingOpenFolderStack: Bool = false + ) { + guard let index = configManager.config.docks.firstIndex(where: { + $0.id == dockID + }) else { + return + } + + let previous = configManager.config.docks[index] + guard previous.items != items else { return } + var updated = previous + updated.items = items + configManager.config.docks[index] = updated + + if let controller = folderStackController, + controller.dockID == dockID + { + let matchingItem = items.first { $0.id == controller.itemID } + if !preservingOpenFolderStack || matchingItem?.kind != .folder { + closeFolderStack() + } + } + + preferencesStore?.replaceDock(updated) + reconcileDockRuntime(from: previous, to: updated) + configManager.save() + refreshPreferencesSnapshot() + } + + private func transferDockItem( + _ session: DockItemDragSession, + to targetDockID: UUID, + destination: DockItemTransferDestination, + operation: DockItemTransferOperation + ) -> Bool { + guard itemDragCoordinator.activeSession == session, + session.profileID == configManager.config.activeProfileID, + dockPanels[session.sourceDockID] != nil, + dockPanels[targetDockID] != nil + else { + itemDragCoordinator.finish(sessionID: session.id) + return false + } + + var docks = configManager.config.docks + guard let sourceIndex = docks.firstIndex(where: { + $0.id == session.sourceDockID + }), + let targetIndex = docks.firstIndex(where: { + $0.id == targetDockID + }) + else { + itemDragCoordinator.finish(sessionID: session.id) + return false + } + + let effectiveOperation: DockItemTransferOperation = + session.sourceDockID == targetDockID ? .move : operation + let plan = DockItemTransferPlanner.planTransfer( + itemID: session.itemID, + from: docks[sourceIndex], + to: docks[targetIndex], + operation: effectiveOperation, + destination: destination + ) + + switch plan.outcome { + case .rejected: + NSSound.beep() + itemDragCoordinator.finish(sessionID: session.id) + return false + case .unchanged: + itemDragCoordinator.finish(sessionID: session.id) + return true + case .moved, .copied: + break + } + + var changes: [(previous: DockConfig, updated: DockConfig)] = [] + if sourceIndex == targetIndex { + let previous = docks[sourceIndex] + var updated = previous + updated.items = plan.sourceItems + docks[sourceIndex] = updated + changes.append((previous, updated)) + } else { + let previousSource = docks[sourceIndex] + let previousTarget = docks[targetIndex] + var updatedSource = previousSource + var updatedTarget = previousTarget + updatedSource.items = plan.sourceItems + updatedTarget.items = plan.targetItems + docks[sourceIndex] = updatedSource + docks[targetIndex] = updatedTarget + if previousSource != updatedSource { + changes.append((previousSource, updatedSource)) + } + if previousTarget != updatedTarget { + changes.append((previousTarget, updatedTarget)) + } + } + + guard !changes.isEmpty else { + itemDragCoordinator.finish(sessionID: session.id) + return true + } + + // Make both sides visible atomically before panels or Preferences + // reconcile the transfer. + configManager.config.docks = docks + + if let controller = folderStackController, + changes.contains(where: { + $0.updated.id == controller.dockID + }) + { + closeFolderStack() + } + + for change in changes { + preferencesStore?.replaceDock(change.updated) + reconcileDockRuntime( + from: change.previous, + to: change.updated + ) + } + configManager.save() + refreshPreferencesSnapshot() + itemDragCoordinator.publishContentChange() + itemDragCoordinator.finish(sessionID: session.id) + return true + } + + private func activateDockItem( + _ item: DockItem, + sourceRect: CGRect, + in panel: DockPanel + ) { + guard item.kind != .separator else { return } + + windowPreviewController.close(resetNativeController: false) + if item.kind == .folder { + if let controller = folderStackController, + controller.dockID == panel.dockID, + controller.itemID == item.id + { + closeFolderStack() + return + } + + closeFolderStack() + let dockID = panel.dockID + let itemID = item.id + let controller = FolderStackPanelController( + dockID: dockID, + item: item, + sourceRect: sourceRect, + sourceDock: panel, + recentFiles: configManager.config.recentFiles, + onOpenURL: { [weak self] url in + self?.openThroughFreeDock(url) + }, + onClearRecentFiles: { [weak self] in + self?.confirmAndClearRecentFiles() + }, + onOptionsChanged: { [weak self] options in + self?.updateFolderStackOptions( + options, + for: itemID, + in: dockID + ) + }, + onDidClose: { [weak self] in + guard let self, + self.folderStackController?.dockID == dockID, + self.folderStackController?.itemID == itemID + else { + return + } + self.folderStackController = nil + if self.hideRestoredDocksAfterFolderStack { + self.hideDocksRestoredForQuickLaunch() + } + } + ) + folderStackController = controller + controller.show() + return + } + + closeFolderStack() + guard FileManager.default.fileExists(atPath: item.path) else { + NSSound.beep() + return + } + openThroughFreeDock( + URL(fileURLWithPath: item.path), + knownKind: item.kind + ) + } + + private func openThroughFreeDock( + _ url: URL, + knownKind: DockItemKind? = nil + ) { + guard url.isFileURL, + FileManager.default.fileExists(atPath: url.path) + else { + NSSound.beep() + return + } + + guard NSWorkspace.shared.open(url) else { + NSSound.beep() + return + } + + let resolvedKind = knownKind ?? DockItem.pinnedItem(at: url)?.kind + guard resolvedKind == .document else { return } + recordRecentDocument(url) + } + + private func openThroughFreeDock( + _ url: URL, + withApplicationAt applicationURL: URL + ) { + _ = openFilesThroughFreeDock( + [url], + withApplicationAt: applicationURL + ) + } + + @discardableResult + private func openFilesThroughFreeDock( + _ urls: [URL], + withApplicationAt applicationURL: URL, + applicationName: String? = nil, + presentsValidationErrors: Bool = true + ) -> Bool { + let preparation = prepareApplicationFileOpen( + urls, + withApplicationAt: applicationURL, + applicationName: applicationName + ) + guard case let .ready(request) = preparation else { + if case let .rejected(title, message) = preparation { + if presentsValidationErrors { + showApplicationFileOpenError( + title: title, + message: message + ) + } else { + NSSound.beep() + } + } + return false + } + + let configuration = NSWorkspace.OpenConfiguration() + configuration.activates = true + configuration.allowsRunningApplicationSubstitution = false + NSWorkspace.shared.open( + request.fileURLs, + withApplicationAt: request.applicationURL, + configuration: configuration + ) { [weak self] _, error in + Task { @MainActor [weak self] in + guard let self else { return } + if let error { + self.showApplicationFileOpenError( + title: "Couldn’t Open with \(request.applicationName)", + message: error.localizedDescription + ) + return + } + self.recordRecentDocuments(request.fileURLs) + } + } + return true + } + + private func canOpenFilesThroughFreeDock( + _ urls: [URL], + withApplicationAt applicationURL: URL, + applicationName: String? = nil + ) -> Bool { + if case .ready = prepareApplicationFileOpen( + urls, + withApplicationAt: applicationURL, + applicationName: applicationName + ) { + return true + } + return false + } + + private func prepareApplicationFileOpen( + _ urls: [URL], + withApplicationAt applicationURL: URL, + applicationName: String? + ) -> ApplicationFileOpenPreparation { + let outcome = DockApplicationFileDropPlanner.plan( + applicationURL: applicationURL, + droppedURLs: urls + ) + guard let plan = outcome.plan, + FileManager.default.fileExists( + atPath: plan.applicationURL.path + ), + DockItem.pinnedItem(at: plan.applicationURL)?.kind + == .application + else { + return .rejected( + title: "Application Isn’t Available", + message: "FreeDock could not find the selected application." + ) + } + + let fileURLs = canonicalUniqueURLs(plan.fileURLs) + guard !fileURLs.isEmpty, + fileURLs.allSatisfy({ + FileManager.default.fileExists(atPath: $0.path) + }) + else { + return .rejected( + title: "Items Aren’t Available", + message: "One or more selected items could not be found." + ) + } + + let resolvedApplicationName = applicationName + ?? applicationDisplayName(at: plan.applicationURL) + guard fileURLs.allSatisfy({ + application(plan.applicationURL, canOpen: $0) + }) else { + let itemCount = fileURLs.count + return .rejected( + title: "\(resolvedApplicationName) Can’t Open \(itemCount == 1 ? "This Item" : "These Items")", + message: "The entire selection was cancelled. Move the \(itemCount == 1 ? "item" : "items") to an empty area of the dock if you want to pin \(itemCount == 1 ? "it" : "them") instead." + ) + } + + return .ready( + ApplicationFileOpenRequest( + applicationURL: plan.applicationURL, + fileURLs: fileURLs, + applicationName: resolvedApplicationName + ) + ) + } + + private func application( + _ applicationURL: URL, + canOpen fileURL: URL + ) -> Bool { + let applicationIdentity = canonicalFileIdentity( + applicationURL + ) + return NSWorkspace.shared + .urlsForApplications(toOpen: fileURL) + .contains { + canonicalFileIdentity($0) == applicationIdentity + } + } + + private func canonicalUniqueURLs(_ urls: [URL]) -> [URL] { + var seenIdentities = Set() + return urls.filter { + seenIdentities.insert( + canonicalFileIdentity($0) + ).inserted + } + } + + private func canonicalFileIdentity(_ url: URL) -> String { + url.standardizedFileURL + .resolvingSymlinksInPath() + .path + } + + private func applicationDisplayName(at applicationURL: URL) -> String { + let displayName = FileManager.default.displayName( + atPath: applicationURL.path + ) + return displayName.hasSuffix(".app") + ? (displayName as NSString).deletingPathExtension + : displayName + } + + private func showApplicationFileOpenError( + title: String, + message: String + ) { + let alert = NSAlert() + alert.icon = NSApp.applicationIconImage + alert.alertStyle = .informational + alert.messageText = title + alert.informativeText = message + alert.addButton(withTitle: "OK") + NSApp.activate(ignoringOtherApps: true) + alert.runModal() + } + + private func recordRecentDocument(_ url: URL) { + recordRecentDocuments([url]) + } + + private func recordRecentDocuments(_ urls: [URL]) { + var records = configManager.config.recentFiles + var didRecord = false + let openedAt = Date() + + for (index, url) in urls.enumerated() { + let plan = RecentFileHistoryPlanner.planRecording( + url: url, + displayName: FileManager.default.displayName( + atPath: url.path + ), + openedAt: openedAt.addingTimeInterval( + -Double(index) / 1_000 + ), + records: records + ) + guard plan.didRecord else { continue } + records = plan.records + didRecord = true + } + + guard didRecord else { return } + configManager.config.recentFiles = records + configManager.save() + } + + private func chooseFilesToOpen( + with item: DockItem, + from sourceDock: DockPanel + ) { + guard item.kind == .application, + let applicationURL = item.fileURL, + FileManager.default.fileExists( + atPath: applicationURL.path + ) + else { + NSSound.beep() + return + } + + let interactionToken = sourceDock.beginTransientInteraction() + let openPanel = NSOpenPanel() + let applicationName = item.label + ?? applicationDisplayName(at: applicationURL) + openPanel.title = "Open with \(applicationName)" + openPanel.message = "Choose files or folders for \(applicationName) to open." + openPanel.prompt = "Open" + openPanel.canChooseFiles = true + openPanel.canChooseDirectories = true + openPanel.allowsMultipleSelection = true + openPanel.resolvesAliases = true + openPanel.treatsFilePackagesAsDirectories = false + openPanel.begin { [weak self, weak sourceDock] response in + sourceDock?.endTransientInteraction(interactionToken) + guard response == .OK, let self else { return } + _ = self.openFilesThroughFreeDock( + openPanel.urls, + withApplicationAt: applicationURL, + applicationName: applicationName + ) + } + } + + private func updateFolderStackOptions( + _ options: FolderStackOptions, + for itemID: UUID, + in dockID: UUID + ) { + guard let dock = configManager.config.docks.first(where: { + $0.id == dockID + }), + let itemIndex = dock.items.firstIndex( + where: { $0.id == itemID && $0.kind == .folder } + ) + else { + return + } + + guard dock.items[itemIndex].folderOptions != options else { return } + var items = dock.items + items[itemIndex].folderOptions = options + replaceDockItems( + items, + for: dockID, + preservingOpenFolderStack: true + ) + } + + private func closeFolderStack(for dockID: UUID? = nil) { + guard let controller = folderStackController, + dockID == nil || controller.dockID == dockID + else { + return + } + let shouldHideRestoredDocks = + hideRestoredDocksAfterFolderStack + hideRestoredDocksAfterFolderStack = false + folderStackController = nil + controller.close() + if shouldHideRestoredDocks { + hideDocksRestoredForQuickLaunch() + } + } + + private func confirmAndClearRecentFiles() { + guard !configManager.config.recentFiles.isEmpty else { + let alert = NSAlert() + alert.icon = NSApp.applicationIconImage + alert.messageText = "No Recent Files" + alert.informativeText = "Open a document through FreeDock and it will appear in the Recent Files stack." + alert.addButton(withTitle: "OK") + alert.runModal() + return + } + + let alert = NSAlert() + alert.icon = NSApp.applicationIconImage + alert.alertStyle = .warning + alert.messageText = "Clear Recent Files history?" + alert.informativeText = "This clears FreeDock’s local history only. Your documents will not be deleted or changed." + alert.addButton(withTitle: "Cancel") + let clearButton = alert.addButton(withTitle: "Clear History") + clearButton.hasDestructiveAction = true + guard alert.runModal() == .alertSecondButtonReturn else { return } + + closeFolderStack() + configManager.config.recentFiles.removeAll() + configManager.save() + } + + private func confirmAndImportSystemDockApps(into id: UUID) { + guard let dockIndex = configManager.config.docks.firstIndex(where: { $0.id == id }) else { + return + } + + let loadResult: SystemDockLoadResult + do { + loadResult = try SystemDockImporter.loadPinnedApps() + } catch { + showSystemDockImportNotice( + title: "Couldn’t Read the macOS Dock", + message: error.localizedDescription + ) + return + } + + guard !loadResult.apps.isEmpty else { + let unavailableDetail = loadResult.unavailableCount > 0 + ? " \(loadResult.unavailableCount) pinned app\(loadResult.unavailableCount == 1 ? " is" : "s are") no longer available." + : "" + showSystemDockImportNotice( + title: "No Apps Found", + message: "FreeDock couldn’t find any installed apps pinned in the macOS Dock.\(unavailableDetail)" + ) + return + } + + let dock = configManager.config.docks[dockIndex] + let plan = SystemDockImporter.planImport( + apps: loadResult.apps, + existingItems: dock.items + ) + guard !plan.items.isEmpty else { + let unavailableDetail = loadResult.unavailableCount > 0 + ? " \(loadResult.unavailableCount) unavailable app\(loadResult.unavailableCount == 1 ? " was" : "s were") skipped." + : "" + showSystemDockImportNotice( + title: "Already Up to Date", + message: "Every available app from the macOS Dock is already in “\(dock.name)”.\(unavailableDetail)" + ) + return + } + + let count = plan.items.count + let alert = NSAlert() + alert.icon = NSApp.applicationIconImage + alert.messageText = "Import \(count) app\(count == 1 ? "" : "s") into “\(dock.name)”?" + var details = "The apps will be added after the dock’s existing items. Nothing will be replaced, and the macOS Dock will not be changed." + if plan.skippedCount > 0 { + details += " \(plan.skippedCount) app\(plan.skippedCount == 1 ? " was" : "s were") skipped because \(plan.skippedCount == 1 ? "it is" : "they are") already present." + } + if loadResult.unavailableCount > 0 { + details += " \(loadResult.unavailableCount) unavailable app\(loadResult.unavailableCount == 1 ? " was" : "s were") skipped." + } + alert.informativeText = details + alert.addButton(withTitle: "Cancel") + alert.addButton(withTitle: "Import") + guard alert.runModal() == .alertSecondButtonReturn else { return } + + let previous = dock + var updated = dock + updated.items.append(contentsOf: plan.items) + configManager.config.docks[dockIndex] = updated + preferencesStore?.replaceDock(updated) + reconcileDockRuntime(from: previous, to: updated) + configManager.save() + refreshPreferencesSnapshot() + + var resultMessage = "Added \(count) app\(count == 1 ? "" : "s") to “\(dock.name)”." + if plan.skippedCount > 0 { + resultMessage += " \(plan.skippedCount) app\(plan.skippedCount == 1 ? " was" : "s were") already present." + } + if loadResult.unavailableCount > 0 { + resultMessage += " \(loadResult.unavailableCount) unavailable app\(loadResult.unavailableCount == 1 ? " was" : "s were") skipped." + } + let completedMessage = resultMessage + DispatchQueue.main.asyncAfter(deadline: .now() + 0.12) { [weak self] in + self?.showSystemDockImportNotice( + title: "Import Complete", + message: completedMessage + ) + } + } + + private func showSystemDockImportNotice(title: String, message: String) { + let alert = NSAlert() + alert.icon = NSApp.applicationIconImage + alert.messageText = title + alert.informativeText = message + alert.addButton(withTitle: "OK") + alert.runModal() + } + + private func confirmAndResetDockSettings(_ id: UUID) { + guard let dock = configManager.config.docks.first(where: { $0.id == id }) else { return } + + let alert = NSAlert() + alert.icon = NSApp.applicationIconImage + alert.alertStyle = .warning + alert.messageText = "Reset settings for “\(dock.name)”?" + alert.informativeText = "Appearance, icon layout, orientation, indicators, and auto-hide will return to their defaults. The dock’s name, pinned items, and saved position will stay unchanged." + alert.addButton(withTitle: "Cancel") + let resetButton = alert.addButton(withTitle: "Reset") + resetButton.hasDestructiveAction = true + guard alert.runModal() == .alertSecondButtonReturn else { return } + + applyDockSettings(DockConfig.defaultSettings, to: [id]) + } + + private func confirmAndCopyDockSettingsToAll(_ sourceID: UUID) { + let docks = configManager.config.docks + guard let source = docks.first(where: { $0.id == sourceID }) else { return } + let targetIDs = docks.filter { $0.id != sourceID }.map(\.id) + guard !targetIDs.isEmpty else { return } + + let alert = NSAlert() + alert.icon = NSApp.applicationIconImage + alert.messageText = "Copy “\(source.name)” settings?" + alert.informativeText = "This replaces the visible settings on \(targetIDs.count) other dock\(targetIDs.count == 1 ? "" : "s") in the current profile. Dock names, pinned items, and saved positions will stay unchanged." + alert.addButton(withTitle: "Cancel") + alert.addButton(withTitle: "Copy") + guard alert.runModal() == .alertSecondButtonReturn else { return } + + applyDockSettings(source.settings, to: Set(targetIDs)) + } + + private func applyDockSettings(_ settings: DockSettings, to targetIDs: Set) { + var docks = configManager.config.docks + var transitions: [(previous: DockConfig, updated: DockConfig)] = [] + + for index in docks.indices where targetIDs.contains(docks[index].id) { + let previous = docks[index] + docks[index].apply(settings: settings) + if docks[index] != previous { + transitions.append((previous, docks[index])) + } + } + + guard !transitions.isEmpty else { return } + configManager.config.docks = docks + + for transition in transitions { + reconcileDockRuntime(from: transition.previous, to: transition.updated) + } + + configManager.save() + rebuildMenu() + } + + @objc private func changeIconSize(_ sender: NSMenuItem) { + guard let selection = sender.representedObject as? IconSizeSelection else { return } + + applyDockPreference(.iconSize(selection.size), to: selection.dockID) + } + + private func applyDockPreference(_ change: DockPreferenceChange, to dockID: UUID) { + var docks = configManager.config.docks + guard let index = docks.firstIndex(where: { $0.id == dockID }) else { return } + + let previous = docks[index] + docks[index].apply(change) + let updated = docks[index] + configManager.config.docks = docks + preferencesStore?.replaceDock(updated) + reconcileDockRuntime(from: previous, to: updated) + + configManager.save() + refreshPreferencesSnapshot() + + switch change { + case .iconSize, .autoHideWhenDocked: + scheduleMenuRefresh() + case .orientation, .magnificationEnabled, .magnification, + .itemSpacing, .appearance, .surfaceOpacity, .blurStyle, + .cornerRadius, .shadowStrength, .showRunningIndicators, + .autoHideDelay: + break + } + } + + private func reconcileDockRuntime(from previous: DockConfig, to updated: DockConfig) { + let dockID = updated.id + guard previous != updated else { return } + + if previous.orientation != updated.orientation { + rebuildVisibleDock(dockID, using: updated) + return + } + + dockStates[dockID]?.apply(updated) + if let panel = dockPanels[dockID] { + panel.autoHideDelay = updated.autoHideDelay + } + + let geometryChanged = previous.iconSize != updated.iconSize + || previous.magnificationEnabled != updated.magnificationEnabled + || previous.magnification != updated.magnification + || previous.itemSpacing != updated.itemSpacing + || previous.items != updated.items + let autoHideChanged = previous.autoHideWhenDocked != updated.autoHideWhenDocked + + if geometryChanged { + if autoHideChanged, dockPanels[dockID] != nil { + pendingAutoHideUpdates[dockID] = ( + enabled: updated.autoHideWhenDocked, + orientation: updated.orientation + ) + } + scheduleDockResize(dockID) + } else if autoHideChanged { + updateAutoHideRuntime( + for: dockID, + enabled: updated.autoHideWhenDocked, + orientation: updated.orientation + ) + } else if previous.autoHideDelay != updated.autoHideDelay { + dockPanels[dockID]?.scheduleAutoHide() + } + } + + private func scheduleDockResize(_ dockID: UUID) { + guard let panel = dockPanels[dockID] else { return } + + panel.revealImmediately() + dockResizeWorkItems[dockID]?.cancel() + + let work = DispatchWorkItem { [weak self, weak panel] in + guard let self else { return } + guard let panel else { + self.pendingAutoHideUpdates.removeValue(forKey: dockID) + self.dockResizeWorkItems.removeValue(forKey: dockID) + return + } + panel.resizeToFitContent() + + if self.pendingAutoHideUpdates[dockID] == nil { + self.updateAutoHideGeometry(for: panel) + if panel.autoHideWhenDocked { + panel.scheduleAutoHide() + } + } + + _ = self.persistPanelPlacement(panel, userInitiated: false) + self.configManager.save() + self.dockResizeWorkItems.removeValue(forKey: dockID) + + if let pending = self.pendingAutoHideUpdates.removeValue(forKey: dockID) { + self.updateAutoHideRuntime( + for: dockID, + enabled: pending.enabled, + orientation: pending.orientation + ) + } + } + dockResizeWorkItems[dockID] = work + DispatchQueue.main.asyncAfter(deadline: .now() + 0.035, execute: work) + } + + private func cancelDockResizeWork(for dockID: UUID) { + dockResizeWorkItems[dockID]?.cancel() + dockResizeWorkItems.removeValue(forKey: dockID) + liveDockResizeWorkItems[dockID]?.cancel() + liveDockResizeWorkItems.removeValue(forKey: dockID) + dockResizeFinishWorkItems[dockID]?.cancel() + dockResizeFinishWorkItems.removeValue(forKey: dockID) + activeDockResizeIDs.remove(dockID) + } + + private func updateAutoHideRuntime( + for dockID: UUID, + enabled: Bool, + orientation: Orientation + ) { + guard let panel = dockPanels[dockID] else { return } + + if !enabled { + panel.revealImmediately() + } + panel.autoHideWhenDocked = enabled + guard enabled else { + _ = persistPanelPlacement(panel, userInitiated: false) + configManager.save() + return + } + + panel.revealImmediately() + let screen = effectiveScreen(for: panel) + let dockedFrame = frameDockedToNearestEdge( + panel.frameForPersistence, + orientation: orientation, + on: screen + ) + panel.setFrame(dockedFrame, display: true, animate: true) + _ = persistPanelPlacement(panel, userInitiated: false) + configManager.save() + + DispatchQueue.main.asyncAfter(deadline: .now() + 0.35) { + [weak self, weak panel] in + guard let self, let panel else { return } + self.updateAutoHideGeometry(for: panel) + panel.scheduleAutoHide() + } + } + + private func rebuildVisibleDock(_ dockID: UUID, using config: DockConfig) { + guard let panel = dockPanels[dockID] else { return } + + windowPreviewController.close( + forDockID: dockID, + resetNativeController: false + ) + endQuickLaunch( + ifDockID: dockID, + reactivatePreviousApplication: false + ) + closeFolderStack(for: dockID) + cancelDockResizeWork(for: dockID) + pendingAutoHideUpdates.removeValue(forKey: dockID) + _ = persistPanelPlacement(panel, userInitiated: false) + panel.revealImmediately() + panel.tearDown() + dockPanels.removeValue(forKey: dockID) + dockStates.removeValue(forKey: dockID) + runtimeDisplayStates.removeValue(forKey: dockID) + + let rebuiltConfig = configManager.config.docks.first(where: { + $0.id == dockID + }) ?? config + showDock(rebuiltConfig) + } + + private func showDock(_ config: DockConfig) { + let rect = NSRect(origin: config.position, + size: NSSize(width: 400, height: 70)) + let panel = DockPanel(dockID: config.id, contentRect: rect) + + let dockID = config.id + let state = DockState(config: config) + dockStates[config.id] = state + + let content = DockContentView( + panel: panel, + profileID: configManager.config.activeProfileID, + items: Binding( + get: { self.configManager.config.docks.first(where: { $0.id == dockID })?.items ?? [] }, + set: { newItems in + self.replaceDockItems(newItems, for: dockID) + } + ), + orientation: config.orientation, + state: state, + itemDragCoordinator: itemDragCoordinator, + onItemActivation: { [weak self, weak panel] item, sourceRect in + guard let self, let panel else { return } + let restoredHiddenProfile = self.endQuickLaunch( + ifDockID: dockID, + reactivatePreviousApplication: false, + restoreHiddenProfile: false + ) + if restoredHiddenProfile, item.kind == .folder { + self.hideRestoredDocksAfterFolderStack = true + } + self.activateDockItem( + item, + sourceRect: sourceRect, + in: panel + ) + if restoredHiddenProfile + && ( + item.kind != .folder + || self.folderStackController == nil + ) + { + self.hideDocksRestoredForQuickLaunch() + } + }, + onItemTransfer: { [weak self] session, destination, operation in + guard let self else { return false } + return self.transferDockItem( + session, + to: dockID, + destination: destination, + operation: operation + ) + }, + onQuickLaunchDismiss: { [weak self] in + self?.endQuickLaunch( + ifDockID: dockID, + reactivatePreviousApplication: true + ) + }, + onAddItemsRequested: { [weak self] in + self?.chooseDockItems(for: dockID) + }, + onAddSmartStackRequested: { [weak self] source in + self?.addSmartStack(source, to: dockID) + }, + onFolderOptionsChanged: { [weak self] itemID, options in + self?.updateFolderStackOptions( + options, + for: itemID, + in: dockID + ) + }, + hasRecentFiles: { [weak self] in + !(self?.configManager.config.recentFiles.isEmpty ?? true) + }, + onClearRecentFilesRequested: { [weak self] in + self?.confirmAndClearRecentFiles() + }, + onOpenDocumentWithApplication: { + [weak self] item, applicationURL in + self?.openThroughFreeDock( + URL(fileURLWithPath: item.path), + withApplicationAt: applicationURL + ) + }, + onOpenFilesWithApplication: { + [weak self] item, fileURLs in + guard let self, + let applicationURL = item.fileURL + else { + return false + } + return self.openFilesThroughFreeDock( + fileURLs, + withApplicationAt: applicationURL, + applicationName: item.label, + presentsValidationErrors: false + ) + }, + onCanOpenFilesWithApplication: { + [weak self] item, fileURLs in + guard let self, + let applicationURL = item.fileURL + else { + return false + } + return self.canOpenFilesThroughFreeDock( + fileURLs, + withApplicationAt: applicationURL, + applicationName: item.label + ) + }, + onChooseFilesForApplication: { + [weak self, weak panel] item in + guard let self, let panel else { return } + self.chooseFilesToOpen( + with: item, + from: panel + ) + }, + onApplicationHoverChanged: { + [weak self, weak panel] item, sourceRect, hovering in + guard let self, let panel else { return } + if hovering { + self.windowPreviewController.hoverBegan( + item: item, + presentation: + DockItemPresentation.resolve(item), + sourceRect: sourceRect, + sourceDock: panel + ) + } else { + self.windowPreviewController.hoverEnded( + itemID: item.id + ) + } + }, + onShowApplicationWindows: { + [weak self, weak panel] item, sourceRect in + guard let self, let panel else { return } + self.windowPreviewController.showExplicit( + item: item, + presentation: + DockItemPresentation.resolve(item), + sourceRect: sourceRect, + sourceDock: panel + ) + }, + onEnableWindowThumbnails: { [weak self] in + self?.windowPreviewController + .enableWindowThumbnails() + }, + isWindowPreviewAccessibilityTrusted: { + [weak self] in + self?.windowPreviewController + .isAccessibilityTrusted ?? false + }, + isWindowPreviewScreenCaptureTrusted: { + [weak self] in + self?.windowPreviewController + .isScreenCaptureTrusted ?? false + }, + onDismissWindowPreview: { [weak self] in + self?.windowPreviewController.close( + forDockID: dockID, + resetNativeController: false + ) + } + ) + + panel.dockOrientation = config.orientation + panel.autoHideDelay = config.autoHideDelay + panel.autoHideWhenDocked = config.autoHideWhenDocked + panel.setContentView(content) + panel.dockDelegate = self + panel.setPositionLocked(_lockPositions) + dockPanels[config.id] = panel + + if let resolution = displayResolution( + for: config, + panelFrame: panel.frameForPersistence + ) { + placePanel( + panel, + using: config, + on: resolution.screen, + display: resolution.display, + isFallback: resolution.isFallback, + animate: false + ) + if !resolution.isFallback { + _ = mirrorPanelPosition(panel, on: resolution.display) + } + } else { + panel.clampToVisibleFrame(on: DockDisplayManager.primaryScreen) + } + + panel.orderFrontRegardless() + configManager.save() + } + + private func restoreDocks() { + for dock in configManager.config.docks { + showDock(dock) + } + } + + private func beginDockItemDragInteraction() { + endDockItemDragInteraction() + for (dockID, panel) in dockPanels { + itemDragInteractionTokens[dockID] = + panel.beginTransientInteraction() + } + } + + private func endDockItemDragInteraction() { + let tokens = itemDragInteractionTokens + itemDragInteractionTokens.removeAll() + for (dockID, token) in tokens { + dockPanels[dockID]?.endTransientInteraction(token) + } + } + + private func saveAllPositions() { + endQuickLaunch(reactivatePreviousApplication: true) + for panel in dockPanels.values { + guard let display = DockDisplayGeometry.bestDisplay( + for: panel.frameForPersistence, + among: DockDisplayManager.connectedDisplays + ) else { + continue + } + _ = mirrorPanelPosition(panel, on: display) + } + } + + private func closeAllDockPanels() { + TooltipManager.shared.hide() + windowPreviewController.close(resetNativeController: true) + itemDragCoordinator.cancel() + hideRestoredDocksAfterFolderStack = false + endQuickLaunch(reactivatePreviousApplication: false) + closeFolderStack() + for work in dockResizeWorkItems.values { + work.cancel() + } + dockResizeWorkItems.removeAll() + for work in liveDockResizeWorkItems.values { + work.cancel() + } + liveDockResizeWorkItems.removeAll() + for work in dockResizeFinishWorkItems.values { + work.cancel() + } + dockResizeFinishWorkItems.removeAll() + activeDockResizeIDs.removeAll() + pendingAutoHideUpdates.removeAll() + for panel in dockPanels.values { + panel.tearDown() + } + dockPanels.removeAll() + dockStates.removeAll() + runtimeDisplayStates.removeAll() + } +} + +extension AppDelegate: DockPanelDelegate { + var lockPositions: Bool { + get { _lockPositions } + set { _lockPositions = newValue } + } + + func dockPanelDidMove(_ panel: DockPanel) { + windowPreviewController.close( + forDockID: panel.dockID, + resetNativeController: false + ) + closeFolderStack(for: panel.dockID) + let screen = DockDisplayManager.screen(containing: panel.frame) + let snapped = panel.autoHideWhenDocked + ? frameDockedToNearestEdge( + panel.frame, + orientation: panel.dockOrientation, + on: screen + ) + : snapFrame(panel.frame, on: screen) + + if snapped.origin != panel.frame.origin { + panel.setFrame(snapped, display: true, animate: true) + } + + if let screen, + let display = DockDisplayManager.descriptor(for: screen) + { + runtimeDisplayStates[panel.dockID] = RuntimeDisplayState( + effectiveDisplayID: display.id, + isUsingFallback: false + ) + updateAutoHideGeometry(for: panel, on: display) + } else { + updateAutoHideGeometry(for: panel) + } + _ = persistPanelPlacement(panel, userInitiated: true) + configManager.save() + refreshPreferencesSnapshot() + } + + func dockPanelDidResignKey(_ panel: DockPanel) { + endQuickLaunch( + ifDockID: panel.dockID, + reactivatePreviousApplication: false + ) + } + + func dockPanelMenuDidBeginTracking(_ panel: DockPanel) { + windowPreviewController.close( + forDockID: panel.dockID, + resetNativeController: false + ) + } + + func currentIconSize(for panel: DockPanel) -> Double { + dockStates[panel.dockID]?.iconSize ?? 48 + } + + func dockPanelDidResize(_ panel: DockPanel, proposedIconSize: Double) { + let dockID = panel.dockID + guard configManager.config.docks.contains(where: { + $0.id == dockID + }), + let state = dockStates[dockID] + else { + return + } + + if activeDockResizeIDs.insert(dockID).inserted { + windowPreviewController.close( + forDockID: dockID, + resetNativeController: false + ) + closeFolderStack(for: dockID) + panel.revealImmediately() + + // A handle drag owns geometry until mouse-up. Letting a delayed + // Preferences resize fire in the middle of it makes the panel jump + // and can persist a transient origin. + dockResizeWorkItems[dockID]?.cancel() + dockResizeWorkItems.removeValue(forKey: dockID) + dockResizeFinishWorkItems[dockID]?.cancel() + dockResizeFinishWorkItems.removeValue(forKey: dockID) + } + + let liveIconSize = DockConfig.clamp( + proposedIconSize, + to: DockConfig.iconSizeRange + ) + guard state.iconSize != liveIconSize else { return } + + // Only publish the property which drives the live layout. The normal + // preference pipeline republishes every DockState property, refreshes + // Preferences, schedules persistence, and starts a competing 35 ms + // resize for every pointer event. + state.iconSize = liveIconSize + scheduleLiveDockResize(panel) + } + + func dockPanelDidFinishResize(_ panel: DockPanel) { + let dockID = panel.dockID + let wasLiveResize = activeDockResizeIDs.remove(dockID) != nil + liveDockResizeWorkItems[dockID]?.cancel() + liveDockResizeWorkItems.removeValue(forKey: dockID) + + guard wasLiveResize else { + finishDockResize(panel) + return + } + + commitLiveDockResizeIconSize(for: dockID) + + // Give SwiftUI one display tick to commit the final icon size before + // measuring the hosting view, then persist exactly once. + let work = DispatchWorkItem { [weak self, weak panel] in + guard let self, let panel, + !self.activeDockResizeIDs.contains(dockID) + else { + return + } + self.dockResizeFinishWorkItems.removeValue(forKey: dockID) + panel.resizeToFitContent() + self.finishDockResize(panel) + } + dockResizeFinishWorkItems[dockID] = work + DispatchQueue.main.asyncAfter( + deadline: .now() + (1.0 / 60.0), + execute: work + ) + } + + private func commitLiveDockResizeIconSize(for dockID: UUID) { + guard let index = configManager.config.docks.firstIndex(where: { + $0.id == dockID + }), + let state = dockStates[dockID] + else { + return + } + + var updated = configManager.config.docks[index] + updated.apply(.iconSize( + DockResizeGestureMath.committedIconSize(state.iconSize) + )) + configManager.config.docks[index] = updated + state.iconSize = updated.iconSize + } + + private func scheduleLiveDockResize(_ panel: DockPanel) { + let dockID = panel.dockID + guard liveDockResizeWorkItems[dockID] == nil else { return } + + let work = DispatchWorkItem { [weak self, weak panel] in + guard let self else { return } + self.liveDockResizeWorkItems.removeValue(forKey: dockID) + guard self.activeDockResizeIDs.contains(dockID), + let panel + else { + return + } + panel.resizeToFitContent() + } + liveDockResizeWorkItems[dockID] = work + DispatchQueue.main.asyncAfter( + deadline: .now() + (1.0 / 60.0), + execute: work + ) + } + + private func finishDockResize(_ panel: DockPanel) { + let dockID = panel.dockID + closeFolderStack(for: dockID) + let snapped = snapFrame( + panel.frame, + on: effectiveScreen(for: panel) + ) + if snapped.origin != panel.frame.origin { + panel.setFrame(snapped, display: true, animate: true) + } + updateAutoHideGeometry(for: panel) + _ = persistPanelPlacement(panel, userInitiated: false) + configManager.save() + refreshPreferencesSnapshot() + scheduleMenuRefresh() + + if let pending = pendingAutoHideUpdates.removeValue(forKey: dockID) { + updateAutoHideRuntime( + for: dockID, + enabled: pending.enabled, + orientation: pending.orientation + ) + } else if panel.autoHideWhenDocked { + panel.scheduleAutoHide() + } } } diff --git a/Sources/FreeDock/Managers/ConfigManager.swift b/Sources/FreeDock/Managers/ConfigManager.swift index dba0304..05da85d 100644 --- a/Sources/FreeDock/Managers/ConfigManager.swift +++ b/Sources/FreeDock/Managers/ConfigManager.swift @@ -3,6 +3,7 @@ import OSLog class ConfigManager { let configPath: URL + let loadedFromDisk: Bool private let backupPath: URL private let queue = DispatchQueue(label: "com.freedock.config", qos: .utility) private var saveWorkItem: DispatchWorkItem? @@ -13,7 +14,15 @@ class ConfigManager { self.configPath = configPath self.backupPath = configPath.deletingLastPathComponent() .appendingPathComponent(configPath.lastPathComponent + ".bak") - self.config = Self.load(from: configPath) + if let data = try? Data(contentsOf: configPath), + let decoded = try? JSONDecoder().decode(AppConfig.self, from: data) + { + self.config = decoded + self.loadedFromDisk = true + } else { + self.config = AppConfig() + self.loadedFromDisk = false + } } static func load(from path: URL) -> AppConfig { @@ -25,17 +34,19 @@ class ConfigManager { func save() { saveWorkItem?.cancel() - let workItem = DispatchWorkItem { [weak self] in self?._save() } + let snapshot = config + let workItem = DispatchWorkItem { [weak self] in self?._save(snapshot) } saveWorkItem = workItem queue.asyncAfter(deadline: .now() + 0.5, execute: workItem) } func saveImmediately() { saveWorkItem?.cancel() - queue.sync { [weak self] in self?._save() } + let snapshot = config + queue.sync { [weak self] in self?._save(snapshot) } } - private func _save() { + private func _save(_ config: AppConfig) { do { let encoder = JSONEncoder() encoder.outputFormatting = [.prettyPrinted, .sortedKeys] diff --git a/Sources/FreeDock/Managers/DockApplicationWindowController.swift b/Sources/FreeDock/Managers/DockApplicationWindowController.swift new file mode 100644 index 0000000..14e9601 --- /dev/null +++ b/Sources/FreeDock/Managers/DockApplicationWindowController.swift @@ -0,0 +1,951 @@ +import AppKit +import ApplicationServices +import Foundation + +@MainActor +final class DockApplicationWindowController { + private struct ApplicationIdentity: Hashable { + let processIdentifier: pid_t + let launchDate: Date? + } + + private struct ApplicationRecord { + let id: UUID + let identity: ApplicationIdentity + let bundleIdentifier: String + let application: NSRunningApplication + } + + private struct CaptureIdentity: Hashable { + let processIdentifier: pid_t + let windowID: CGWindowID + } + + private let nativeStore = DockApplicationWindowNativeStore() + private let captureController = DockWindowCaptureController() + private var applications: [UUID: ApplicationRecord] = [:] + private var windowApplicationIDs: [DockApplicationWindow.ID: UUID] = + [:] + private var captureWindowUUIDs: [CaptureIdentity: UUID] = [:] + private var captureWindowsByItemID: + [DockApplicationWindow.ID: DockWindowServerWindow] = [:] + private var captureOnlyWindowIDs = + Set() + private var queryGeneration = UUID() + + var isAccessibilityTrusted: Bool { + AXIsProcessTrusted() + } + + var isScreenCaptureTrusted: Bool { + captureController.isScreenCaptureTrusted + } + + /// Requests permission only from an explicit user action. + /// + /// A false return commonly means System Settings has opened and the user + /// has not granted access yet; hover paths must call only the status check. + @discardableResult + func requestAccessibilityAccess() -> Bool { + let options = [ + "AXTrustedCheckOptionPrompt": true, + ] as CFDictionary + return AXIsProcessTrustedWithOptions(options) + } + + @discardableResult + func requestScreenCaptureAccess() -> Bool { + captureController.requestScreenCaptureAccess() + } + + func loadWindows( + bundleIdentifier: String, + applicationName: String + ) async -> DockApplicationWindowQuery { + guard isAccessibilityTrusted else { + clearRetainedState() + await nativeStore.reset() + return DockApplicationWindowQuery( + status: .permissionRequired, + windows: [] + ) + } + guard !bundleIdentifier.isEmpty else { + clearRetainedState() + await nativeStore.reset() + return DockApplicationWindowQuery( + status: .applicationNotRunning, + windows: [] + ) + } + + let runningApplications = NSRunningApplication + .runningApplications( + withBundleIdentifier: bundleIdentifier + ) + .filter { + $0.bundleIdentifier == bundleIdentifier + && $0.activationPolicy != .prohibited + && !$0.isTerminated + } + guard !runningApplications.isEmpty else { + clearRetainedState() + await nativeStore.reset() + return DockApplicationWindowQuery( + status: .applicationNotRunning, + windows: [] + ) + } + + let previousByIdentity = Dictionary( + uniqueKeysWithValues: applications.values.map { + ($0.identity, $0.id) + } + ) + var currentApplications: [UUID: ApplicationRecord] = [:] + let descriptors = runningApplications.map { application in + let identity = ApplicationIdentity( + processIdentifier: application.processIdentifier, + launchDate: application.launchDate + ) + let instanceID = previousByIdentity[identity] ?? UUID() + currentApplications[instanceID] = ApplicationRecord( + id: instanceID, + identity: identity, + bundleIdentifier: bundleIdentifier, + application: application + ) + return DockApplicationInstanceDescriptor( + id: instanceID, + processIdentifier: application.processIdentifier, + applicationName: + application.localizedName ?? applicationName, + isHidden: application.isHidden, + isActive: application.isActive + ) + } + applications = currentApplications + let captureWindows = captureController.windows( + for: Set( + runningApplications.map(\.processIdentifier) + ) + ) + + let generation = UUID() + queryGeneration = generation + let nativeResult = await nativeStore.query( + applications: descriptors + ) + guard queryGeneration == generation else { + return DockApplicationWindowQuery( + status: .temporarilyUnavailable, + windows: [] + ) + } + + var windows = DockApplicationWindowPlanner.windows( + from: nativeResult.candidates, + captureWindows: captureWindows + ) + appendCaptureOnlyWindows( + to: &windows, + captureWindows: captureWindows, + candidates: nativeResult.candidates, + applicationDescriptors: descriptors + ) + updateCaptureState( + windows: windows, + captureWindows: captureWindows + ) + windowApplicationIDs = Dictionary( + uniqueKeysWithValues: windows.map { + ($0.id, $0.applicationInstanceID) + } + ) + return DockApplicationWindowQuery( + status: nativeResult.status, + windows: windows + ) + } + + func thumbnail( + for window: DockApplicationWindow, + maximumPixelSize: CGSize + ) async -> NSImage? { + guard let capture = + captureWindowsByItemID[window.id], + capture.windowID == window.captureWindowID + else { + return nil + } + return await captureController.thumbnail( + processIdentifier: capture.processIdentifier, + for: capture.windowID, + frame: capture.frame, + maximumPixelSize: maximumPixelSize + ) + } + + func focusWindow( + id windowID: DockApplicationWindow.ID + ) async -> DockApplicationWindowFocusResult { + guard isAccessibilityTrusted else { + return .permissionRequired + } + guard let instanceID = windowApplicationIDs[windowID], + let record = applications[instanceID] + else { + return .staleWindow + } + guard isCurrent(record) else { + await discard(instanceID: instanceID) + return .applicationNotRunning + } + + if captureOnlyWindowIDs.contains(windowID) { + if record.application.isHidden { + _ = record.application.unhide() + } + let didActivate: Bool + if #available(macOS 14.0, *) { + didActivate = record.application.activate(options: []) + } else { + didActivate = record.application.activate( + options: [.activateIgnoringOtherApps] + ) + } + guard didActivate else { + return .activationRejected + } + + if let capture = captureWindowsByItemID[windowID] { + let descriptor = DockApplicationInstanceDescriptor( + id: instanceID, + processIdentifier: + record.identity.processIdentifier, + applicationName: + record.application.localizedName + ?? "Application", + isHidden: record.application.isHidden, + isActive: record.application.isActive + ) + let exactFocus = + await nativeStore.focusCaptureWindow( + application: descriptor, + captureWindow: capture + ) + if exactFocus == .accepted { + return .accepted + } + } + return .accepted + } + + if record.application.isHidden, + !record.application.unhide() + { + return .activationRejected + } + + let preparation = await nativeStore.prepareFocus( + windowID: windowID, + applicationInstanceID: instanceID + ) + guard preparation == .accepted else { + return preparation.focusResult + } + guard isCurrent(record) else { + await discard(instanceID: instanceID) + return .applicationNotRunning + } + + let didActivate: Bool + if #available(macOS 14.0, *) { + didActivate = record.application.activate(options: []) + } else { + didActivate = record.application.activate( + options: [.activateIgnoringOtherApps] + ) + } + guard didActivate else { + return .activationRejected + } + + let raiseResult = await nativeStore.raise( + windowID: windowID, + applicationInstanceID: instanceID + ) + return raiseResult.focusResult + } + + func reset() { + clearRetainedState() + captureController.reset() + } + + func clearThumbnailCache() { + captureController.clearThumbnailCache() + } + + private func isCurrent( + _ record: ApplicationRecord + ) -> Bool { + let application = record.application + return !application.isTerminated + && application.processIdentifier + == record.identity.processIdentifier + && application.launchDate == record.identity.launchDate + && application.bundleIdentifier == record.bundleIdentifier + } + + private func discard(instanceID: UUID) async { + applications.removeValue(forKey: instanceID) + windowApplicationIDs = windowApplicationIDs.filter { + $0.value != instanceID + } + await nativeStore.discard( + applicationInstanceID: instanceID + ) + } + + private func clearRetainedState() { + queryGeneration = UUID() + applications.removeAll() + windowApplicationIDs.removeAll() + captureWindowUUIDs.removeAll() + captureWindowsByItemID.removeAll() + captureOnlyWindowIDs.removeAll() + } + + private func appendCaptureOnlyWindows( + to windows: inout [DockApplicationWindow], + captureWindows: [DockWindowServerWindow], + candidates: [DockApplicationWindowCandidate], + applicationDescriptors: + [DockApplicationInstanceDescriptor] + ) { + let descriptorsByPID = Dictionary( + uniqueKeysWithValues: applicationDescriptors.map { + ($0.processIdentifier, $0) + } + ) + let currentUUIDs = Dictionary( + uniqueKeysWithValues: captureWindows.map { capture in + let identity = CaptureIdentity( + processIdentifier: + capture.processIdentifier, + windowID: capture.windowID + ) + return ( + identity, + captureWindowUUIDs[identity] ?? UUID() + ) + } + ) + let captureOnlyWindows = + DockApplicationWindowPlanner + .unrepresentedCaptureWindows( + candidates: candidates, + captureWindows: captureWindows + ) + for capture in captureOnlyWindows { + guard let application = + descriptorsByPID[capture.processIdentifier] + else { + continue + } + let identity = CaptureIdentity( + processIdentifier: capture.processIdentifier, + windowID: capture.windowID + ) + guard let id = currentUUIDs[identity] else { + continue + } + + windows.append( + DockApplicationWindow( + id: id, + applicationInstanceID: application.id, + applicationName: application.applicationName, + title: capture.title?.trimmedNonEmpty + ?? "Untitled \(application.applicationName) Window", + captureWindowID: capture.windowID, + isMinimized: false, + isApplicationHidden: application.isHidden, + isOnScreen: capture.isOnScreen, + canFocusExactly: false, + isMain: false, + isFocused: false + ) + ) + } + captureWindowUUIDs = currentUUIDs + } + + private func updateCaptureState( + windows: [DockApplicationWindow], + captureWindows: [DockWindowServerWindow] + ) { + let captureByID = Dictionary( + uniqueKeysWithValues: captureWindows.map { + ($0.windowID, $0) + } + ) + captureWindowsByItemID = Dictionary( + uniqueKeysWithValues: windows.compactMap { window in + guard let captureID = window.captureWindowID, + let capture = captureByID[captureID] + else { + return nil + } + return (window.id, capture) + } + ) + captureOnlyWindowIDs = Set( + windows.filter { !$0.canFocusExactly }.map(\.id) + ) + } +} + +private struct DockApplicationInstanceDescriptor: Sendable { + let id: UUID + let processIdentifier: pid_t + let applicationName: String + let isHidden: Bool + let isActive: Bool +} + +private struct DockNativeWindowQueryResult: Sendable { + let status: DockApplicationWindowQueryStatus + let candidates: [DockApplicationWindowCandidate] +} + +private enum DockNativeWindowOperationResult: Equatable, Sendable { + case accepted + case permissionRequired + case staleWindow + case temporarilyUnavailable + case unsupported + + var focusResult: DockApplicationWindowFocusResult { + switch self { + case .accepted: return .accepted + case .permissionRequired: return .permissionRequired + case .staleWindow: return .staleWindow + case .temporarilyUnavailable: return .temporarilyUnavailable + case .unsupported: return .unsupported + } + } +} + +private actor DockApplicationWindowNativeStore { + private struct NativeWindowRecord { + let id: UUID + let applicationInstanceID: UUID + let applicationElement: AXUIElement + let windowElement: AXUIElement + let isMinimized: Bool + let candidate: DockApplicationWindowCandidate + } + + private var records: [UUID: NativeWindowRecord] = [:] + + func query( + applications: [DockApplicationInstanceDescriptor], + replacingOtherApplications: Bool = true + ) -> DockNativeWindowQueryResult { + guard AXIsProcessTrusted() else { + records.removeAll() + return DockNativeWindowQueryResult( + status: .permissionRequired, + candidates: [] + ) + } + + let currentInstanceIDs = Set(applications.map(\.id)) + if replacingOtherApplications { + records = records.filter { + currentInstanceIDs.contains( + $0.value.applicationInstanceID + ) + } + } + + var candidates: [DockApplicationWindowCandidate] = [] + var refreshedRecords: [UUID: NativeWindowRecord] = + replacingOtherApplications + ? [:] + : records.filter { + !currentInstanceIDs.contains( + $0.value.applicationInstanceID + ) + } + var successfulQueryCount = 0 + var temporaryFailureCount = 0 + var retainedCandidateCount = 0 + var globalSourceOrder = 0 + + for application in applications { + let applicationElement = AXUIElementCreateApplication( + application.processIdentifier + ) + AXUIElementSetMessagingTimeout( + applicationElement, + 0.25 + ) + + var rawWindows: CFTypeRef? + let queryError = AXUIElementCopyAttributeValue( + applicationElement, + kAXWindowsAttribute as CFString, + &rawWindows + ) + switch queryError { + case .success: + successfulQueryCount += 1 + case .apiDisabled: + records.removeAll() + return DockNativeWindowQueryResult( + status: .permissionRequired, + candidates: [] + ) + case .cannotComplete: + temporaryFailureCount += 1 + let previousRecords = records.values.filter { + $0.applicationInstanceID == application.id + } + let previousByID = Dictionary( + uniqueKeysWithValues: previousRecords.map { + ($0.id, $0) + } + ) + let retainedCandidates = + DockApplicationWindowPlanner.retainedCandidates( + from: previousRecords.map(\.candidate), + applicationInstanceID: application.id, + applicationName: application.applicationName, + isApplicationHidden: application.isHidden, + isApplicationActive: application.isActive, + startingSourceOrder: globalSourceOrder + ) + for candidate in retainedCandidates { + guard let record = previousByID[candidate.id] else { + continue + } + refreshedRecords[record.id] = NativeWindowRecord( + id: record.id, + applicationInstanceID: + record.applicationInstanceID, + applicationElement: + record.applicationElement, + windowElement: record.windowElement, + isMinimized: record.isMinimized, + candidate: candidate + ) + candidates.append(candidate) + } + retainedCandidateCount += retainedCandidates.count + globalSourceOrder += max( + 1, + retainedCandidates.count + ) + continue + default: + continue + } + + var windows = rawWindows as? [AXUIElement] ?? [] + if windows.isEmpty { + let children: [AXUIElement] = copyAttribute( + applicationElement, + kAXChildrenAttribute as CFString + ) ?? [] + windows = children.filter { + copyString( + $0, + kAXRoleAttribute as CFString + ) == (kAXWindowRole as String) + } + } + let focusedWindow: AXUIElement? = copyAttribute( + applicationElement, + kAXFocusedWindowAttribute as CFString + ) + let previous = records.values.filter { + $0.applicationInstanceID == application.id + } + var usedPreviousIDs = Set() + + for (sourceOrder, windowElement) in windows.enumerated() { + AXUIElementSetMessagingTimeout( + windowElement, + 0.25 + ) + guard copyString( + windowElement, + kAXRoleAttribute as CFString + ) == (kAXWindowRole as String) + else { + continue + } + + let id: UUID + if let match = previous.first(where: { + !usedPreviousIDs.contains($0.id) + && CFEqual( + $0.windowElement, + windowElement + ) + }) { + id = match.id + usedPreviousIDs.insert(match.id) + } else { + id = UUID() + } + + let isMinimized = copyBool( + windowElement, + kAXMinimizedAttribute as CFString + ) ?? false + let isMain = copyBool( + windowElement, + kAXMainAttribute as CFString + ) ?? false + let frame: CGRect? + if let position = copyPoint( + windowElement, + kAXPositionAttribute as CFString + ), + let size = copySize( + windowElement, + kAXSizeAttribute as CFString + ) + { + frame = CGRect( + origin: position, + size: size + ) + } else { + frame = nil + } + let candidate = DockApplicationWindowCandidate( + id: id, + applicationInstanceID: application.id, + processIdentifier: + application.processIdentifier, + applicationName: + application.applicationName, + title: copyString( + windowElement, + kAXTitleAttribute as CFString + ), + documentPath: copyString( + windowElement, + kAXDocumentAttribute as CFString + ), + frame: frame, + role: role( + for: copyString( + windowElement, + kAXSubroleAttribute as CFString + ) + ), + isMinimized: isMinimized, + isApplicationHidden: + application.isHidden, + isApplicationActive: + application.isActive, + isMain: isMain, + isFocused: focusedWindow.map { + CFEqual($0, windowElement) + } ?? false, + sourceOrder: + globalSourceOrder + sourceOrder + ) + let record = NativeWindowRecord( + id: id, + applicationInstanceID: application.id, + applicationElement: applicationElement, + windowElement: windowElement, + isMinimized: isMinimized, + candidate: candidate + ) + refreshedRecords[id] = record + candidates.append(candidate) + } + globalSourceOrder += max(1, windows.count) + } + + records = refreshedRecords + let status = + DockApplicationWindowPlanner.nativeQueryStatus( + successfulQueryCount: successfulQueryCount, + temporaryFailureCount: temporaryFailureCount, + retainedCandidateCount: retainedCandidateCount + ) + return DockNativeWindowQueryResult( + status: status, + candidates: candidates + ) + } + + func focusCaptureWindow( + application: DockApplicationInstanceDescriptor, + captureWindow: DockWindowServerWindow + ) -> DockNativeWindowOperationResult { + let refreshed = query( + applications: [application], + replacingOtherApplications: false + ) + guard refreshed.status == .ready else { + return refreshed.status == .permissionRequired + ? .permissionRequired + : .temporarilyUnavailable + } + let match = DockApplicationWindowPlanner + .captureMatches( + candidates: DockApplicationWindowPlanner + .selectedCandidates( + from: refreshed.candidates + ), + captureWindows: [captureWindow] + ) + .first + guard let windowID = match?.accessibilityWindowID else { + return .unsupported + } + let preparation = prepareFocus( + windowID: windowID, + applicationInstanceID: application.id + ) + guard preparation == .accepted else { + return preparation + } + return raise( + windowID: windowID, + applicationInstanceID: application.id + ) + } + + func prepareFocus( + windowID: UUID, + applicationInstanceID: UUID + ) -> DockNativeWindowOperationResult { + guard AXIsProcessTrusted() else { + return .permissionRequired + } + guard let record = records[windowID], + record.applicationInstanceID + == applicationInstanceID + else { + return .staleWindow + } + + let isMinimized = copyBool( + record.windowElement, + kAXMinimizedAttribute as CFString + ) ?? record.isMinimized + if isMinimized { + let result = AXUIElementSetAttributeValue( + record.windowElement, + kAXMinimizedAttribute as CFString, + kCFBooleanFalse + ) + guard isAccepted(result, allowingUnsupported: false) + else { + return operationResult(for: result) + } + } + + let makeMainResult = AXUIElementSetAttributeValue( + record.windowElement, + kAXMainAttribute as CFString, + kCFBooleanTrue + ) + guard isAccepted( + makeMainResult, + allowingUnsupported: true + ) else { + return operationResult(for: makeMainResult) + } + + let focusResult = AXUIElementSetAttributeValue( + record.applicationElement, + kAXFocusedWindowAttribute as CFString, + record.windowElement + ) + guard isAccepted( + focusResult, + allowingUnsupported: true + ) else { + return operationResult(for: focusResult) + } + return .accepted + } + + func raise( + windowID: UUID, + applicationInstanceID: UUID + ) -> DockNativeWindowOperationResult { + guard AXIsProcessTrusted() else { + return .permissionRequired + } + guard let record = records[windowID], + record.applicationInstanceID + == applicationInstanceID + else { + return .staleWindow + } + let result = AXUIElementPerformAction( + record.windowElement, + kAXRaiseAction as CFString + ) + return result == .success + ? .accepted + : operationResult(for: result) + } + + func discard(applicationInstanceID: UUID) { + records = records.filter { + $0.value.applicationInstanceID + != applicationInstanceID + } + } + + func reset() { + records.removeAll() + } + + private func role( + for subrole: String? + ) -> DockApplicationWindowRole { + switch subrole { + case kAXStandardWindowSubrole: + return .standard + case kAXDialogSubrole: + return .dialog + case kAXSystemDialogSubrole: + return .systemDialog + default: + return .other + } + } + + private func copyAttribute( + _ element: AXUIElement, + _ attribute: CFString + ) -> Value? { + var value: CFTypeRef? + guard AXUIElementCopyAttributeValue( + element, + attribute, + &value + ) == .success else { + return nil + } + return value as? Value + } + + private func copyString( + _ element: AXUIElement, + _ attribute: CFString + ) -> String? { + copyAttribute(element, attribute) + } + + private func copyBool( + _ element: AXUIElement, + _ attribute: CFString + ) -> Bool? { + if let value: Bool = copyAttribute( + element, + attribute + ) { + return value + } + if let number: NSNumber = copyAttribute( + element, + attribute + ) { + return number.boolValue + } + return nil + } + + private func copyPoint( + _ element: AXUIElement, + _ attribute: CFString + ) -> CGPoint? { + guard let value: AXValue = copyAttribute( + element, + attribute + ) else { + return nil + } + var point = CGPoint.zero + guard AXValueGetValue(value, .cgPoint, &point) else { + return nil + } + return point + } + + private func copySize( + _ element: AXUIElement, + _ attribute: CFString + ) -> CGSize? { + guard let value: AXValue = copyAttribute( + element, + attribute + ) else { + return nil + } + var size = CGSize.zero + guard AXValueGetValue(value, .cgSize, &size) else { + return nil + } + return size + } + + private func isAccepted( + _ error: AXError, + allowingUnsupported: Bool + ) -> Bool { + error == .success + || ( + allowingUnsupported + && ( + error == .attributeUnsupported + || error == .notImplemented + ) + ) + } + + private func operationResult( + for error: AXError + ) -> DockNativeWindowOperationResult { + switch error { + case .success: + return .accepted + case .apiDisabled: + return .permissionRequired + case .invalidUIElement: + return .staleWindow + case .actionUnsupported, .attributeUnsupported, + .notImplemented: + return .unsupported + case .cannotComplete: + return .temporarilyUnavailable + default: + return .temporarilyUnavailable + } + } +} + +private extension String { + var trimmedNonEmpty: String? { + let value = trimmingCharacters( + in: .whitespacesAndNewlines + ) + return value.isEmpty ? nil : value + } +} diff --git a/Sources/FreeDock/Managers/DockWindowCaptureController.swift b/Sources/FreeDock/Managers/DockWindowCaptureController.swift new file mode 100644 index 0000000..fc321f9 --- /dev/null +++ b/Sources/FreeDock/Managers/DockWindowCaptureController.swift @@ -0,0 +1,381 @@ +import AppKit +import CoreGraphics +import Foundation +@preconcurrency import ScreenCaptureKit + +@MainActor +final class DockWindowCaptureController { + private struct ThumbnailCacheEntry { + let image: NSImage + let capturedAt: Date + } + + private struct ThumbnailCacheKey: Hashable { + let processIdentifier: pid_t + let windowID: CGWindowID + let pixelWidth: Int + let pixelHeight: Int + } + + private let cacheLifetime: TimeInterval = 5 + private let shareableContentLifetime: TimeInterval = 1 + private var thumbnailCache: [ThumbnailCacheKey: ThumbnailCacheEntry] = [:] + private var shareableWindowStore: AnyObject? + + var isScreenCaptureTrusted: Bool { + CGPreflightScreenCaptureAccess() + } + + /// Requests permission only after a direct user action. + @discardableResult + func requestScreenCaptureAccess() -> Bool { + CGRequestScreenCaptureAccess() + } + + func windows( + for processIdentifiers: Set + ) -> [DockWindowServerWindow] { + guard !processIdentifiers.isEmpty, + let rawWindows = CGWindowListCopyWindowInfo( + [.excludeDesktopElements], + kCGNullWindowID + ) as? [[String: Any]] + else { + return [] + } + + return DockWindowServerWindowExtractor.windows( + from: rawWindows, + matching: processIdentifiers + ) + } + + func thumbnail( + processIdentifier: pid_t, + for windowID: CGWindowID, + frame: CGRect, + maximumPixelSize: CGSize + ) async -> NSImage? { + guard !Task.isCancelled else { return nil } + guard isScreenCaptureTrusted else { + clearThumbnailCache() + return nil + } + + let pixelSize = DockWindowThumbnailGeometry.pixelSize( + for: frame.size, + fitting: maximumPixelSize + ) + guard pixelSize.width > 0, pixelSize.height > 0 else { + return nil + } + let cacheKey = ThumbnailCacheKey( + processIdentifier: processIdentifier, + windowID: windowID, + pixelWidth: Int(pixelSize.width), + pixelHeight: Int(pixelSize.height) + ) + if let cached = thumbnailCache[cacheKey], + Date().timeIntervalSince(cached.capturedAt) + < cacheLifetime + { + return cached.image + } + + let capturedImage: CGImage? + if #available(macOS 14.0, *) { + capturedImage = await modernThumbnail( + for: windowID, + pixelSize: pixelSize + ) + } else { + capturedImage = legacyThumbnail( + for: windowID, + pixelSize: pixelSize + ) + } + guard !Task.isCancelled else { return nil } + guard let capturedImage else { + thumbnailCache.removeValue(forKey: cacheKey) + return nil + } + + let thumbnail = NSImage( + cgImage: capturedImage, + size: NSSize( + width: capturedImage.width, + height: capturedImage.height + ) + ) + thumbnailCache[cacheKey] = ThumbnailCacheEntry( + image: thumbnail, + capturedAt: Date() + ) + trimCache() + return thumbnail + } + + func clearThumbnailCache() { + thumbnailCache.removeAll() + } + + func reset() { + clearThumbnailCache() + shareableWindowStore = nil + } + + private func trimCache() { + guard thumbnailCache.count > 24 else { return } + let oldest = thumbnailCache.sorted { + $0.value.capturedAt < $1.value.capturedAt + } + for entry in oldest.prefix( + thumbnailCache.count - 24 + ) { + thumbnailCache.removeValue(forKey: entry.key) + } + } + + @available(macOS 14.0, *) + private func modernThumbnail( + for windowID: CGWindowID, + pixelSize: CGSize + ) async -> CGImage? { + let store: DockShareableWindowStore + if let existing = + shareableWindowStore as? DockShareableWindowStore + { + store = existing + } else { + let created = DockShareableWindowStore( + cacheLifetime: shareableContentLifetime + ) + shareableWindowStore = created + store = created + } + guard let window = await store.window(withID: windowID) + else { + return nil + } + + let configuration = SCStreamConfiguration() + configuration.width = Int(pixelSize.width) + configuration.height = Int(pixelSize.height) + configuration.showsCursor = false + configuration.scalesToFit = true + configuration.ignoreShadowsSingleWindow = true + configuration.ignoreGlobalClipSingleWindow = true + + let filter = SCContentFilter( + desktopIndependentWindow: window + ) + return try? await SCScreenshotManager.captureImage( + contentFilter: filter, + configuration: configuration + ) + } + + @available(macOS, introduced: 10.15, obsoleted: 14.0) + private func legacyThumbnail( + for windowID: CGWindowID, + pixelSize: CGSize + ) -> CGImage? { + guard + let image = CGWindowListCreateImage( + .null, + [.optionIncludingWindow], + windowID, + [.boundsIgnoreFraming, .bestResolution] + ) + else { + return nil + } + return downscaled(image, to: pixelSize) + } + + private func downscaled( + _ image: CGImage, + to pixelSize: CGSize + ) -> CGImage? { + let width = max(1, Int(pixelSize.width)) + let height = max(1, Int(pixelSize.height)) + guard + let context = CGContext( + data: nil, + width: width, + height: height, + bitsPerComponent: 8, + bytesPerRow: 0, + space: CGColorSpaceCreateDeviceRGB(), + bitmapInfo: + CGImageAlphaInfo.premultipliedLast.rawValue + ) + else { + return nil + } + context.interpolationQuality = .high + context.draw( + image, + in: CGRect( + x: 0, + y: 0, + width: width, + height: height + ) + ) + return context.makeImage() + } + +} + +enum DockWindowServerWindowExtractor { + static func windows( + from rawWindows: [[String: Any]], + matching processIdentifiers: Set + ) -> [DockWindowServerWindow] { + rawWindows.enumerated().compactMap { + sourceOrder, + values in + guard + let processNumber = + values[kCGWindowOwnerPID as String] + as? NSNumber, + processIdentifiers.contains( + pid_t(processNumber.int32Value) + ), + let windowNumber = + values[kCGWindowNumber as String] + as? NSNumber, + let layerNumber = + values[kCGWindowLayer as String] + as? NSNumber, + layerNumber.intValue == 0, + hasDiscoverableSharingState( + values[kCGWindowSharingState as String] + ), + let frame = windowFrame( + values[kCGWindowBounds as String] + ), + frame.width >= 80, + frame.height >= 50, + (values[kCGWindowAlpha as String] + as? NSNumber)?.doubleValue ?? 1 > 0.01 + else { + return nil + } + + return DockWindowServerWindow( + windowID: CGWindowID( + windowNumber.uint32Value + ), + processIdentifier: pid_t( + processNumber.int32Value + ), + title: values[kCGWindowName as String] + as? String, + frame: frame, + isOnScreen: (values[kCGWindowIsOnscreen as String] + as? NSNumber)?.boolValue ?? false, + sourceOrder: sourceOrder + ) + } + } + + private static func hasDiscoverableSharingState( + _ value: Any? + ) -> Bool { + // Discovery and pixel capture have separate permission paths. Only + // an explicit `.none` is authoritative; a missing value must not hide + // an otherwise valid metadata-only window card. + guard let value else { + return true + } + guard let sharingState = + (value as? NSNumber)?.uint32Value + else { + return false + } + return sharingState + != CGWindowSharingType.none.rawValue + } + + private static func windowFrame(_ value: Any?) -> CGRect? { + guard let dictionary = value as? NSDictionary else { + return nil + } + var frame = CGRect.zero + guard + CGRectMakeWithDictionaryRepresentation( + dictionary, + &frame + ) + else { + return nil + } + return frame + } +} + +@available(macOS 12.3, *) +@MainActor +private final class DockShareableWindowStore { + private let cacheLifetime: TimeInterval + private var windows: [CGWindowID: SCWindow] = [:] + private var loadedAt: Date? + + init(cacheLifetime: TimeInterval) { + self.cacheLifetime = cacheLifetime + } + + func window(withID windowID: CGWindowID) async -> SCWindow? { + guard !Task.isCancelled else { return nil } + if let loadedAt, + Date().timeIntervalSince(loadedAt) < cacheLifetime, + let window = windows[windowID] + { + return window + } + + do { + let content = + try await SCShareableContent + .excludingDesktopWindows( + true, + onScreenWindowsOnly: false + ) + guard !Task.isCancelled else { return nil } + windows = Dictionary( + uniqueKeysWithValues: content.windows + .filter { $0.windowLayer == 0 } + .map { ($0.windowID, $0) } + ) + loadedAt = Date() + return windows[windowID] + } catch { + return nil + } + } +} + +enum DockWindowThumbnailGeometry { + static func pixelSize( + for source: CGSize, + fitting maximum: CGSize + ) -> CGSize { + guard source.width > 0, + source.height > 0, + maximum.width > 0, + maximum.height > 0 + else { + return .zero + } + let scale = min( + maximum.width / source.width, + maximum.height / source.height + ) + return CGSize( + width: max(1, floor(source.width * scale)), + height: max(1, floor(source.height * scale)) + ) + } +} diff --git a/Sources/FreeDock/Managers/GlobalShortcutManager.swift b/Sources/FreeDock/Managers/GlobalShortcutManager.swift new file mode 100644 index 0000000..00e0db7 --- /dev/null +++ b/Sources/FreeDock/Managers/GlobalShortcutManager.swift @@ -0,0 +1,115 @@ +import Carbon.HIToolbox +import Foundation + +final class GlobalShortcutManager: @unchecked Sendable { + static let standardModifiers = UInt32(controlKey) | UInt32(optionKey) + static let showHideKeyCode = UInt32(kVK_Space) + static let quickLaunchModifiers = UInt32(cmdKey) | UInt32(shiftKey) + static let quickLaunchKeyCode = UInt32(kVK_Space) + static let profileKeyCodes: [UInt32] = [ + UInt32(kVK_ANSI_1), + UInt32(kVK_ANSI_2), + UInt32(kVK_ANSI_3), + UInt32(kVK_ANSI_4), + UInt32(kVK_ANSI_5), + UInt32(kVK_ANSI_6), + UInt32(kVK_ANSI_7), + UInt32(kVK_ANSI_8), + UInt32(kVK_ANSI_9), + ] + + private static let signature: OSType = 0x46444F43 // "FDOC" + private var eventHandler: EventHandlerRef? + private var hotKeyReferences: [EventHotKeyRef] = [] + private var actions: [UInt32: @MainActor () -> Void] = [:] + + @MainActor + init() { + installEventHandler() + } + + @MainActor + func reset() { + for reference in hotKeyReferences { + UnregisterEventHotKey(reference) + } + hotKeyReferences.removeAll() + actions.removeAll() + } + + @discardableResult + @MainActor + func register( + id: UInt32, + keyCode: UInt32, + modifiers: UInt32 = GlobalShortcutManager.standardModifiers, + action: @escaping @MainActor () -> Void + ) -> Bool { + guard eventHandler != nil else { return false } + + var reference: EventHotKeyRef? + let hotKeyID = EventHotKeyID(signature: Self.signature, id: id) + let status = RegisterEventHotKey( + keyCode, + modifiers, + hotKeyID, + GetApplicationEventTarget(), + 0, + &reference + ) + + guard status == noErr, let reference else { return false } + hotKeyReferences.append(reference) + actions[id] = action + return true + } + + @MainActor + private func installEventHandler() { + guard eventHandler == nil else { return } + + var eventType = EventTypeSpec( + eventClass: OSType(kEventClassKeyboard), + eventKind: UInt32(kEventHotKeyPressed) + ) + InstallEventHandler( + GetApplicationEventTarget(), + Self.handleEvent, + 1, + &eventType, + Unmanaged.passUnretained(self).toOpaque(), + &eventHandler + ) + } + + @MainActor + private func performAction(for id: UInt32) { + actions[id]?() + } + + private static let handleEvent: EventHandlerUPP = { _, event, userData in + guard let event, let userData else { return OSStatus(eventNotHandledErr) } + + var hotKeyID = EventHotKeyID() + let status = GetEventParameter( + event, + EventParamName(kEventParamDirectObject), + EventParamType(typeEventHotKeyID), + nil, + MemoryLayout.size, + nil, + &hotKeyID + ) + guard status == noErr, hotKeyID.signature == signature else { + return OSStatus(eventNotHandledErr) + } + + let manager = Unmanaged + .fromOpaque(userData) + .takeUnretainedValue() + Task { @MainActor in + manager.performAction(for: hotKeyID.id) + } + return noErr + } +} diff --git a/Sources/FreeDock/Managers/RunningAppMonitor.swift b/Sources/FreeDock/Managers/RunningAppMonitor.swift index 9abfbe2..b3f741c 100644 --- a/Sources/FreeDock/Managers/RunningAppMonitor.swift +++ b/Sources/FreeDock/Managers/RunningAppMonitor.swift @@ -5,20 +5,49 @@ import Combine class RunningAppMonitor: ObservableObject { static let shared = RunningAppMonitor() - @Published var runningBundleIDs: Set = [] + @Published private(set) var runningBundleIDs: Set = [] + private var runningApplicationsObservation: AnyCancellable? + + private static let applicationChangeNotifications: [Notification.Name] = [ + NSWorkspace.didLaunchApplicationNotification, + NSWorkspace.didTerminateApplicationNotification, + NSWorkspace.didHideApplicationNotification, + NSWorkspace.didUnhideApplicationNotification, + NSWorkspace.didActivateApplicationNotification, + NSWorkspace.didDeactivateApplicationNotification, + ] private init() { refresh() - let nc = NSWorkspace.shared.notificationCenter - nc.addObserver(self, selector: #selector(refresh), - name: NSWorkspace.didLaunchApplicationNotification, object: nil) - nc.addObserver(self, selector: #selector(refresh), - name: NSWorkspace.didTerminateApplicationNotification, object: nil) + let notificationCenter = NSWorkspace.shared.notificationCenter + for name in Self.applicationChangeNotifications { + notificationCenter.addObserver( + self, + selector: #selector(applicationDidChange(_:)), + name: name, + object: nil + ) + } + runningApplicationsObservation = NSWorkspace.shared + .publisher(for: \.runningApplications) + .sink { [weak self] _ in + Task { @MainActor [weak self] in + self?.refresh() + } + } } - @objc func refresh() { + func refresh() { + // Assign unconditionally so @Published emits for visibility and focus + // changes even when the set of running bundle IDs stays the same. runningBundleIDs = Set( - NSWorkspace.shared.runningApplications.compactMap { $0.bundleIdentifier } + NSWorkspace.shared.runningApplications + .filter { $0.activationPolicy != .prohibited } + .compactMap(\.bundleIdentifier) ) } + + @objc private func applicationDidChange(_ notification: Notification) { + refresh() + } } diff --git a/Sources/FreeDock/Managers/SystemDockImporter.swift b/Sources/FreeDock/Managers/SystemDockImporter.swift new file mode 100644 index 0000000..cb9e3a5 --- /dev/null +++ b/Sources/FreeDock/Managers/SystemDockImporter.swift @@ -0,0 +1,266 @@ +import Cocoa +import CoreFoundation + +struct SystemDockApp: Equatable, Sendable { + let path: String + let label: String? + let bundleIdentifier: String? +} + +struct SystemDockImportPlan: Equatable, Sendable { + let items: [DockItem] + let skippedCount: Int +} + +struct SystemDockLoadResult: Equatable, Sendable { + let apps: [SystemDockApp] + let unavailableCount: Int +} + +enum SystemDockImportError: LocalizedError, Equatable { + case preferencesUnavailable + case invalidPreferencesFormat + + var errorDescription: String? { + switch self { + case .preferencesUnavailable: + return "FreeDock couldn’t read the macOS Dock preferences." + case .invalidPreferencesFormat: + return "The macOS Dock preferences use an unsupported format." + } + } +} + +enum SystemDockImporter { + private static let preferencesDomain = "com.apple.dock" + private static let persistentAppsKey = "persistent-apps" + + static func loadPinnedApps() throws -> SystemDockLoadResult { + let rawValue = try currentPersistentAppsValue() + return availableApps( + from: try parsePersistentApps(rawValue), + installedPathForApp: installedPath(for:) + ) + } + + static func availableApps( + from apps: [SystemDockApp], + installedPathForApp: (SystemDockApp) -> String?, + bundleIdentifierForPath: (String) -> String? = AppInfo.resolveBundleID(from:) + ) -> SystemDockLoadResult { + var availableApps: [SystemDockApp] = [] + var unavailableCount = 0 + + for app in apps { + guard let installedPath = installedPathForApp(app) else { + unavailableCount += 1 + continue + } + let path = standardizedPath(installedPath) + let label = normalizedLabel(app.label) + ?? AppInfo.resolve(from: path).displayName + + availableApps.append(SystemDockApp( + path: path, + label: label, + bundleIdentifier: normalizedBundleIdentifier(bundleIdentifierForPath(path)) + ?? normalizedBundleIdentifier(app.bundleIdentifier) + )) + } + + return SystemDockLoadResult( + apps: availableApps, + unavailableCount: unavailableCount + ) + } + + static func parsePersistentApps(_ value: Any) throws -> [SystemDockApp] { + guard let tiles = value as? [Any] else { + throw SystemDockImportError.invalidPreferencesFormat + } + + return tiles.compactMap { value in + guard let tile = value as? [String: Any], + let tileData = tile["tile-data"] as? [String: Any], + let fileData = tileData["file-data"] as? [String: Any], + let url = fileURL(from: fileData["_CFURLString"]), + url.pathExtension.caseInsensitiveCompare("app") == .orderedSame + else { + return nil + } + + return SystemDockApp( + path: standardizedPath(url.path), + label: tileData["file-label"] as? String, + bundleIdentifier: tileData["bundle-identifier"] as? String + ) + } + } + + static func planImport( + apps: [SystemDockApp], + existingItems: [DockItem], + bundleIdentifierForPath: (String) -> String? = AppInfo.resolveBundleID(from:) + ) -> SystemDockImportPlan { + var seenPaths = Set( + existingItems + .filter { !$0.isSeparator } + .map { pathIdentity($0.appPath) } + ) + var seenBundleIdentifiers = Set( + existingItems + .filter { $0.kind == .application } + .compactMap { bundleIdentifierForPath($0.path) } + .compactMap(normalizedBundleIdentifier) + ) + var importedItems: [DockItem] = [] + var skippedCount = 0 + + for app in apps { + let path = standardizedPath(app.path) + let pathKey = pathIdentity(path) + let bundleIdentifier = normalizedBundleIdentifier(app.bundleIdentifier) + ?? normalizedBundleIdentifier(bundleIdentifierForPath(path)) + + if seenPaths.contains(pathKey) + || bundleIdentifier.map(seenBundleIdentifiers.contains) == true + { + skippedCount += 1 + continue + } + + let label = normalizedLabel(app.label) + ?? URL(fileURLWithPath: path).deletingPathExtension().lastPathComponent + importedItems.append( + DockItem( + kind: .application, + path: path, + label: label + ) + ) + seenPaths.insert(pathKey) + if let bundleIdentifier { + seenBundleIdentifiers.insert(bundleIdentifier) + } + } + + return SystemDockImportPlan( + items: importedItems, + skippedCount: skippedCount + ) + } + + private static func currentPersistentAppsValue() throws -> Any { + if let value = CFPreferencesCopyAppValue( + persistentAppsKey as CFString, + preferencesDomain as CFString + ) { + return value + } + + let preferencesURL = FileManager.default.homeDirectoryForCurrentUser + .appendingPathComponent("Library/Preferences") + .appendingPathComponent("\(preferencesDomain).plist") + + guard let data = try? Data(contentsOf: preferencesURL), + let propertyList = try? PropertyListSerialization.propertyList( + from: data, + options: [], + format: nil + ), + let dictionary = propertyList as? [String: Any], + let value = dictionary[persistentAppsKey] + else { + throw SystemDockImportError.preferencesUnavailable + } + + return value + } + + private static func fileURL(from value: Any?) -> URL? { + if let url = value as? URL, url.isFileURL { + return url + } + + guard let string = value as? String else { return nil } + if let url = URL(string: string), url.isFileURL { + return url + } + if string.hasPrefix("/") { + return URL(fileURLWithPath: string) + } + return nil + } + + static func resolveInstalledPath( + for app: SystemDockApp, + fallbackURLForBundleIdentifier: (String) -> URL?, + applicationIsValid: (String) -> Bool + ) -> String? { + var candidates = [app.path] + if let bundleIdentifier = normalizedBundleIdentifier(app.bundleIdentifier), + let fallbackURL = fallbackURLForBundleIdentifier(bundleIdentifier) + { + candidates.append(fallbackURL.path) + } + + return candidates.lazy + .map(standardizedPath) + .first(where: applicationIsValid) + } + + static func isValidApplication(atPath path: String) -> Bool { + let url = URL(fileURLWithPath: path) + guard url.pathExtension.caseInsensitiveCompare("app") == .orderedSame else { + return false + } + + var isDirectory: ObjCBool = false + guard FileManager.default.fileExists( + atPath: path, + isDirectory: &isDirectory + ), + isDirectory.boolValue, + let bundle = Bundle(path: path), + let executableURL = bundle.executableURL + else { + return false + } + + return FileManager.default.isExecutableFile(atPath: executableURL.path) + } + + private static func installedPath(for app: SystemDockApp) -> String? { + resolveInstalledPath( + for: app, + fallbackURLForBundleIdentifier: { + NSWorkspace.shared.urlForApplication(withBundleIdentifier: $0) + }, + applicationIsValid: isValidApplication(atPath:) + ) + } + + private static func standardizedPath(_ path: String) -> String { + URL(fileURLWithPath: path) + .standardizedFileURL + .path + } + + private static func pathIdentity(_ path: String) -> String { + URL(fileURLWithPath: path) + .standardizedFileURL + .resolvingSymlinksInPath() + .path + } + + private static func normalizedBundleIdentifier(_ value: String?) -> String? { + guard let value = normalizedLabel(value) else { return nil } + return value.lowercased() + } + + private static func normalizedLabel(_ value: String?) -> String? { + guard let value else { return nil } + let normalized = value.trimmingCharacters(in: .whitespacesAndNewlines) + return normalized.isEmpty ? nil : normalized + } +} diff --git a/Sources/FreeDock/Managers/TooltipManager.swift b/Sources/FreeDock/Managers/TooltipManager.swift index a08c659..80c02c3 100644 --- a/Sources/FreeDock/Managers/TooltipManager.swift +++ b/Sources/FreeDock/Managers/TooltipManager.swift @@ -34,14 +34,18 @@ final class TooltipManager { p.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary] let host = NSHostingView(rootView: Text(text) - .font(.caption) + .font(.system(size: 12, weight: .medium)) .lineLimit(1) .fixedSize() - .padding(.horizontal, 10) - .padding(.vertical, 5) + .padding(.horizontal, 11) + .padding(.vertical, 6) .background(.ultraThinMaterial) - .clipShape(RoundedRectangle(cornerRadius: 7)) - .shadow(radius: 5)) + .clipShape(RoundedRectangle(cornerRadius: 9, style: .continuous)) + .overlay { + RoundedRectangle(cornerRadius: 9, style: .continuous) + .stroke(.white.opacity(0.25), lineWidth: 1) + } + .shadow(color: .black.opacity(0.22), radius: 10, y: 4)) let size = host.fittingSize p.contentView = host let origin: NSPoint diff --git a/Sources/FreeDock/Models/AppConfig.swift b/Sources/FreeDock/Models/AppConfig.swift index ae2b042..bbbb51d 100644 --- a/Sources/FreeDock/Models/AppConfig.swift +++ b/Sources/FreeDock/Models/AppConfig.swift @@ -1,9 +1,109 @@ import Foundation struct AppConfig: Codable, Sendable { - var docks: [DockConfig] + static let currentFormatVersion = 4 - init(docks: [DockConfig] = []) { - self.docks = docks + var profiles: [DockProfile] + var activeProfileID: UUID + var recentFiles: [RecentFileRecord] + + init( + docks: [DockConfig] = [], + recentFiles: [RecentFileRecord] = [] + ) { + let profile = DockProfile(name: "Default", docks: docks) + profiles = [profile] + activeProfileID = profile.id + self.recentFiles = RecentFileHistoryPlanner.normalized( + recentFiles, + limit: RecentFileHistoryPlanner.maximumLimit + ) + } + + init( + profiles: [DockProfile], + activeProfileID: UUID? = nil, + recentFiles: [RecentFileRecord] = [] + ) { + let availableProfiles = profiles.isEmpty ? [DockProfile(name: "Default")] : profiles + self.profiles = availableProfiles + if let activeProfileID, + availableProfiles.contains(where: { $0.id == activeProfileID }) + { + self.activeProfileID = activeProfileID + } else { + self.activeProfileID = availableProfiles[0].id + } + self.recentFiles = RecentFileHistoryPlanner.normalized( + recentFiles, + limit: RecentFileHistoryPlanner.maximumLimit + ) + } + + var docks: [DockConfig] { + get { + profiles.first(where: { $0.id == activeProfileID })?.docks ?? [] + } + set { + guard let index = profiles.firstIndex(where: { $0.id == activeProfileID }) else { + let profile = DockProfile(name: "Default", docks: newValue) + profiles = [profile] + activeProfileID = profile.id + return + } + profiles[index].docks = newValue + } + } + + var activeProfileName: String { + profiles.first(where: { $0.id == activeProfileID })?.name ?? "Default" + } + + private enum CodingKeys: String, CodingKey { + case formatVersion + case profiles + case activeProfileID + case docks + case recentFiles + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + recentFiles = RecentFileHistoryPlanner.normalized( + (try? container.decode( + [RecentFileRecord].self, + forKey: .recentFiles + )) ?? [], + limit: RecentFileHistoryPlanner.maximumLimit + ) + + if let decodedProfiles = try container.decodeIfPresent([DockProfile].self, forKey: .profiles), + !decodedProfiles.isEmpty + { + profiles = decodedProfiles + let requestedID = try container.decodeIfPresent(UUID.self, forKey: .activeProfileID) + if let requestedID, + decodedProfiles.contains(where: { $0.id == requestedID }) + { + activeProfileID = requestedID + } else { + activeProfileID = decodedProfiles[0].id + } + return + } + + let legacyDocks = try container.decodeIfPresent([DockConfig].self, forKey: .docks) ?? [] + let migratedProfile = DockProfile(name: "Default", docks: legacyDocks) + profiles = [migratedProfile] + activeProfileID = migratedProfile.id + } + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(Self.currentFormatVersion, forKey: .formatVersion) + try container.encode(profiles, forKey: .profiles) + try container.encode(activeProfileID, forKey: .activeProfileID) + try container.encode(docks, forKey: .docks) + try container.encode(recentFiles, forKey: .recentFiles) } } diff --git a/Sources/FreeDock/Models/DockAppearance.swift b/Sources/FreeDock/Models/DockAppearance.swift new file mode 100644 index 0000000..db4dea9 --- /dev/null +++ b/Sources/FreeDock/Models/DockAppearance.swift @@ -0,0 +1,11 @@ +import Foundation + +enum DockAppearance: String, Codable, CaseIterable, Equatable, Sendable { + case glass + case light + case dark + + var displayName: String { + rawValue.capitalized + } +} diff --git a/Sources/FreeDock/Models/DockApplicationAction.swift b/Sources/FreeDock/Models/DockApplicationAction.swift new file mode 100644 index 0000000..442b351 --- /dev/null +++ b/Sources/FreeDock/Models/DockApplicationAction.swift @@ -0,0 +1,283 @@ +import AppKit +import Foundation + +/// Safe requests FreeDock can send to an already-running application. +/// +/// Force Quit is intentionally absent. A normal quit request gives the +/// application an opportunity to ask about unsaved work. +enum DockApplicationAction: Equatable, Sendable { + case activate + case show + case hide + case quit +} + +/// The aggregate state of every regular application instance with a bundle ID. +struct DockApplicationActionState: Equatable, Sendable { + let isRunning: Bool + let isHidden: Bool + let isFrontmost: Bool + let instanceCount: Int + let hiddenInstanceCount: Int + + var visibleInstanceCount: Int { + instanceCount - hiddenInstanceCount + } + + fileprivate init(instances: [DockApplicationInstanceState]) { + instanceCount = instances.count + hiddenInstanceCount = instances.count(where: \.isHidden) + isRunning = !instances.isEmpty + isHidden = !instances.isEmpty + && hiddenInstanceCount == instances.count + isFrontmost = instances.contains(where: \.isFrontmost) + } +} + +/// A summary of the native requests accepted by AppKit. +/// +/// AppKit accepting a request does not guarantee the target application has +/// already completed it. The running-application state updates on a later main +/// run-loop turn. +struct DockApplicationActionResult: Equatable, Sendable { + let action: DockApplicationAction + let stateBeforeAction: DockApplicationActionState + let attemptedRequestCount: Int + let acceptedRequestCount: Int + + var foundRunningApplication: Bool { + stateBeforeAction.isRunning + } + + var sentRequest: Bool { + attemptedRequestCount > 0 + } + + var allRequestsAccepted: Bool { + attemptedRequestCount == acceptedRequestCount + } +} + +struct DockApplicationInstanceState: Equatable, Sendable { + let isHidden: Bool + let isFrontmost: Bool +} + +enum DockApplicationActionRequest: Equatable, Sendable { + case activate(instanceIndex: Int) + case show(instanceIndex: Int) + case hide(instanceIndex: Int) + case quit(instanceIndex: Int) +} + +/// Pure aggregate-state and request planning for application actions. +enum DockApplicationActionPlanner { + static func state( + for instances: [DockApplicationInstanceState] + ) -> DockApplicationActionState { + DockApplicationActionState(instances: instances) + } + + static func requests( + for action: DockApplicationAction, + instances: [DockApplicationInstanceState] + ) -> [DockApplicationActionRequest] { + guard !instances.isEmpty else { return [] } + + switch action { + case .activate: + return [ + .activate( + instanceIndex: preferredActivationIndex(in: instances) + ), + ] + + case .show: + var requests: [DockApplicationActionRequest] = + instances.indices.compactMap { index in + instances[index].isHidden + ? .show(instanceIndex: index) + : nil + } + requests.append( + .activate( + instanceIndex: preferredActivationIndex(in: instances) + ) + ) + return requests + + case .hide: + return instances.indices.compactMap { index in + instances[index].isHidden + ? nil + : .hide(instanceIndex: index) + } + + case .quit: + return instances.indices.map { + .quit(instanceIndex: $0) + } + } + } + + private static func preferredActivationIndex( + in instances: [DockApplicationInstanceState] + ) -> Int { + instances.firstIndex(where: \.isFrontmost) + ?? instances.firstIndex(where: { !$0.isHidden }) + ?? instances.startIndex + } +} + +/// The small native surface used by `DockApplicationActionController`. +/// +/// Keeping this protocol separate from `NSRunningApplication` makes the +/// controller safe to test without manipulating applications on the system. +@MainActor +protocol DockRunningApplicationInstance: AnyObject { + var bundleIdentifier: String? { get } + var activationPolicy: NSApplication.ActivationPolicy { get } + var isTerminated: Bool { get } + var isHidden: Bool { get } + var isActive: Bool { get } + + @discardableResult + func requestDockActivation() -> Bool + + @discardableResult + func requestDockShow() -> Bool + + @discardableResult + func requestDockHide() -> Bool + + @discardableResult + func requestDockQuit() -> Bool +} + +extension NSRunningApplication: DockRunningApplicationInstance { + func requestDockActivation() -> Bool { + if #available(macOS 14.0, *) { + activate(options: [.activateAllWindows]) + } else { + activate( + options: [ + .activateAllWindows, + .activateIgnoringOtherApps, + ] + ) + } + } + + func requestDockShow() -> Bool { + unhide() + } + + func requestDockHide() -> Bool { + hide() + } + + func requestDockQuit() -> Bool { + terminate() + } +} + +/// Resolves and controls every regular running instance of an application. +@MainActor +final class DockApplicationActionController { + typealias RunningApplicationProvider = @MainActor ( + _ bundleIdentifier: String + ) -> [any DockRunningApplicationInstance] + + private let runningApplications: RunningApplicationProvider + + convenience init() { + self.init { bundleIdentifier in + NSRunningApplication.runningApplications( + withBundleIdentifier: bundleIdentifier + ) + .map { $0 as any DockRunningApplicationInstance } + } + } + + init( + runningApplications: @escaping RunningApplicationProvider + ) { + self.runningApplications = runningApplications + } + + func state( + forBundleIdentifier bundleIdentifier: String + ) -> DockApplicationActionState { + let targets = matchingApplications( + bundleIdentifier: bundleIdentifier + ) + return DockApplicationActionPlanner.state( + for: targets.map(Self.snapshot) + ) + } + + @discardableResult + func perform( + _ action: DockApplicationAction, + forBundleIdentifier bundleIdentifier: String + ) -> DockApplicationActionResult { + let targets = matchingApplications( + bundleIdentifier: bundleIdentifier + ) + let instanceStates = targets.map(Self.snapshot) + let state = DockApplicationActionPlanner.state( + for: instanceStates + ) + let requests = DockApplicationActionPlanner.requests( + for: action, + instances: instanceStates + ) + let acceptedCount = requests.count { request in + execute(request, on: targets) + } + + return DockApplicationActionResult( + action: action, + stateBeforeAction: state, + attemptedRequestCount: requests.count, + acceptedRequestCount: acceptedCount + ) + } + + private func matchingApplications( + bundleIdentifier: String + ) -> [any DockRunningApplicationInstance] { + guard !bundleIdentifier.isEmpty else { return [] } + + return runningApplications(bundleIdentifier).filter { + $0.bundleIdentifier == bundleIdentifier + && $0.activationPolicy != .prohibited + && !$0.isTerminated + } + } + + private static func snapshot( + _ application: any DockRunningApplicationInstance + ) -> DockApplicationInstanceState { + DockApplicationInstanceState( + isHidden: application.isHidden, + isFrontmost: application.isActive + ) + } + + private func execute( + _ request: DockApplicationActionRequest, + on applications: [any DockRunningApplicationInstance] + ) -> Bool { + switch request { + case let .activate(index): + return applications[index].requestDockActivation() + case let .show(index): + return applications[index].requestDockShow() + case let .hide(index): + return applications[index].requestDockHide() + case let .quit(index): + return applications[index].requestDockQuit() + } + } +} diff --git a/Sources/FreeDock/Models/DockApplicationFileDropPlanner.swift b/Sources/FreeDock/Models/DockApplicationFileDropPlanner.swift new file mode 100644 index 0000000..5f5d5c4 --- /dev/null +++ b/Sources/FreeDock/Models/DockApplicationFileDropPlanner.swift @@ -0,0 +1,149 @@ +import Foundation + +/// A URL-level validation failure while planning a Finder drop onto an app. +/// +/// The planner deliberately performs lexical validation only. The integration +/// layer remains responsible for authoritatively classifying the target as an +/// application and checking that paths still exist immediately before asking +/// AppKit to open them. +enum DockApplicationFileDropURLRejection: Error, Equatable, Sendable { + case notFileURL + case nonLocalFileURL + case missingPath + case relativePath +} + +/// Why a Finder drop cannot be opened with its pinned application. +enum DockApplicationFileDropRejection: Equatable, Sendable { + case missingApplicationURL + case invalidApplicationURL(DockApplicationFileDropURLRejection) + case emptyDrop + case invalidDroppedURL( + index: Int, + reason: DockApplicationFileDropURLRejection + ) +} + +/// A validated request to open files with one exact application target. +struct DockApplicationFileDropPlan: Equatable, Sendable { + let applicationURL: URL + let fileURLs: [URL] + + /// Original payload indices omitted because an equivalent earlier URL won. + let duplicateInputIndices: [Int] + + var removedDuplicateCount: Int { + duplicateInputIndices.count + } +} + +enum DockApplicationFileDropOutcome: Equatable, Sendable { + case ready(DockApplicationFileDropPlan) + case rejected(DockApplicationFileDropRejection) + + var plan: DockApplicationFileDropPlan? { + guard case let .ready(plan) = self else { return nil } + return plan + } + + var rejection: DockApplicationFileDropRejection? { + guard case let .rejected(rejection) = self else { return nil } + return rejection + } + + var isReady: Bool { + plan != nil + } +} + +/// Pure validation and stable normalization for Finder files dropped on an app. +enum DockApplicationFileDropPlanner { + /// Builds an atomic request suitable for + /// `NSWorkspace.open(_:withApplicationAt:configuration:)`. + /// + /// The application and every dropped value must be absolute local file + /// URLs. The integration layer must first establish that the target really + /// is an application; the planner intentionally does not require a `.app` + /// suffix because a valid app can be reached through an extensionless + /// symlink. Input order is retained while lexical path duplicates are + /// removed after their first occurrence. Symlinks are intentionally not + /// resolved so planning never reads the file system. + static func plan( + applicationURL: URL?, + droppedURLs: [URL] + ) -> DockApplicationFileDropOutcome { + guard let applicationURL else { + return .rejected(.missingApplicationURL) + } + + let normalizedApplicationURL: URL + switch normalizeLocalFileURL(applicationURL) { + case let .success(url): + normalizedApplicationURL = url + case let .failure(reason): + return .rejected(.invalidApplicationURL(reason)) + } + + guard !droppedURLs.isEmpty else { + return .rejected(.emptyDrop) + } + + var seenPaths = Set() + var normalizedURLs: [URL] = [] + var duplicateIndices: [Int] = [] + normalizedURLs.reserveCapacity(droppedURLs.count) + + for (index, inputURL) in droppedURLs.enumerated() { + let normalizedURL: URL + switch normalizeLocalFileURL(inputURL) { + case let .success(url): + normalizedURL = url + case let .failure(reason): + return .rejected( + .invalidDroppedURL(index: index, reason: reason) + ) + } + + if seenPaths.insert(normalizedURL.path).inserted { + normalizedURLs.append(normalizedURL) + } else { + duplicateIndices.append(index) + } + } + + return .ready( + DockApplicationFileDropPlan( + applicationURL: normalizedApplicationURL, + fileURLs: normalizedURLs, + duplicateInputIndices: duplicateIndices + ) + ) + } + + private static func normalizeLocalFileURL( + _ url: URL + ) -> Result { + guard url.isFileURL else { + return .failure(.notFileURL) + } + + if let host = url.host, + !host.isEmpty, + host.caseInsensitiveCompare("localhost") != .orderedSame + { + return .failure(.nonLocalFileURL) + } + + let path = url.path + guard !path.isEmpty else { + return .failure(.missingPath) + } + guard path.hasPrefix("/") else { + return .failure(.relativePath) + } + + return .success( + URL(fileURLWithPath: path).standardizedFileURL + ) + } +} diff --git a/Sources/FreeDock/Models/DockApplicationWindow.swift b/Sources/FreeDock/Models/DockApplicationWindow.swift new file mode 100644 index 0000000..b196133 --- /dev/null +++ b/Sources/FreeDock/Models/DockApplicationWindow.swift @@ -0,0 +1,436 @@ +import CoreGraphics +import Foundation + +struct DockApplicationWindow: Identifiable, Equatable, Sendable { + let id: UUID + let applicationInstanceID: UUID + let applicationName: String + let title: String + let captureWindowID: CGWindowID? + let isMinimized: Bool + let isApplicationHidden: Bool + let isOnScreen: Bool? + let canFocusExactly: Bool + let isMain: Bool + let isFocused: Bool +} + +enum DockApplicationWindowQueryStatus: Equatable, Sendable { + case ready + case permissionRequired + case applicationNotRunning + case temporarilyUnavailable +} + +struct DockApplicationWindowQuery: Equatable, Sendable { + let status: DockApplicationWindowQueryStatus + let windows: [DockApplicationWindow] +} + +enum DockApplicationWindowFocusResult: Equatable, Sendable { + case accepted + case permissionRequired + case staleWindow + case applicationNotRunning + case temporarilyUnavailable + case unsupported + case activationRejected +} + +enum DockApplicationWindowRole: Equatable, Sendable { + case standard + case dialog + case systemDialog + case other +} + +struct DockApplicationWindowCandidate: Equatable, Sendable { + let id: UUID + let applicationInstanceID: UUID + let processIdentifier: pid_t + let applicationName: String + let title: String? + let documentPath: String? + let frame: CGRect? + let role: DockApplicationWindowRole + let isMinimized: Bool + let isApplicationHidden: Bool + let isApplicationActive: Bool + let isMain: Bool + let isFocused: Bool + let sourceOrder: Int +} + +struct DockWindowServerWindow: Equatable, Sendable { + let windowID: CGWindowID + let processIdentifier: pid_t + let title: String? + let frame: CGRect + let isOnScreen: Bool + let sourceOrder: Int +} + +enum DockApplicationWindowFocusStep: Equatable, Sendable { + case unhideApplication + case restoreWindow + case makeMain + case setFocusedWindow + case activateApplication + case raiseWindow +} + +enum DockApplicationWindowPlanner { + struct Match: Equatable, Hashable, Sendable { + let accessibilityWindowID: UUID + let captureWindowID: CGWindowID + } + + static func windows( + from candidates: [DockApplicationWindowCandidate], + captureWindows: [DockWindowServerWindow] = [] + ) -> [DockApplicationWindow] { + let selected = selectedCandidates(from: candidates) + let matches = captureMatches( + candidates: selected, + captureWindows: captureWindows + ) + let captureIDByAccessibilityID = Dictionary( + uniqueKeysWithValues: matches.map { + ($0.accessibilityWindowID, $0.captureWindowID) + } + ) + let captureByID = Dictionary( + uniqueKeysWithValues: captureWindows.map { + ($0.windowID, $0) + } + ) + + return selected + .sorted(by: orderedBefore) + .map { candidate in + let captureID = + captureIDByAccessibilityID[candidate.id] + let capture = captureID.flatMap { + captureByID[$0] + } + return DockApplicationWindow( + id: candidate.id, + applicationInstanceID: + candidate.applicationInstanceID, + applicationName: candidate.applicationName, + title: resolvedTitle(for: candidate), + captureWindowID: captureID, + isMinimized: candidate.isMinimized, + isApplicationHidden: + candidate.isApplicationHidden, + isOnScreen: capture?.isOnScreen, + canFocusExactly: true, + isMain: candidate.isMain, + isFocused: candidate.isFocused + ) + } + } + + static func selectedCandidates( + from candidates: [DockApplicationWindowCandidate] + ) -> [DockApplicationWindowCandidate] { + candidates.filter { $0.role != .systemDialog } + } + + static func captureMatches( + candidates: [DockApplicationWindowCandidate], + captureWindows: [DockWindowServerWindow] + ) -> [Match] { + var unusedCaptureIDs = Set( + captureWindows.map(\.windowID) + ) + var matches: [Match] = [] + + for candidate in candidates.sorted(by: orderedBefore) { + let available = captureWindows.filter { + unusedCaptureIDs.contains($0.windowID) + && $0.processIdentifier + == candidate.processIdentifier + } + guard let capture = bestCaptureMatch( + for: candidate, + among: available, + allCandidates: candidates + ) else { + continue + } + unusedCaptureIDs.remove(capture.windowID) + matches.append( + Match( + accessibilityWindowID: candidate.id, + captureWindowID: capture.windowID + ) + ) + } + return matches + } + + static func unrepresentedCaptureWindows( + candidates: [DockApplicationWindowCandidate], + captureWindows: [DockWindowServerWindow] + ) -> [DockWindowServerWindow] { + let matches = captureMatches( + candidates: selectedCandidates(from: candidates), + captureWindows: captureWindows + ) + let matchedCandidateIDs = Set( + matches.map(\.accessibilityWindowID) + ) + let matchedCaptureIDs = Set( + matches.map(\.captureWindowID) + ) + var remainingCandidates = candidates.filter { + !matchedCandidateIDs.contains($0.id) + } + var result: [DockWindowServerWindow] = [] + + for capture in captureWindows where + !matchedCaptureIDs.contains(capture.windowID) + { + if let index = representationIndex( + for: capture, + in: remainingCandidates + ) { + remainingCandidates.remove(at: index) + } else { + result.append(capture) + } + } + return result + } + + static func retainedCandidates( + from candidates: [DockApplicationWindowCandidate], + applicationInstanceID: UUID, + applicationName: String, + isApplicationHidden: Bool, + isApplicationActive: Bool, + startingSourceOrder: Int + ) -> [DockApplicationWindowCandidate] { + candidates + .filter { + $0.applicationInstanceID == applicationInstanceID + } + .sorted { + if $0.sourceOrder != $1.sourceOrder { + return $0.sourceOrder < $1.sourceOrder + } + return $0.id.uuidString < $1.id.uuidString + } + .enumerated() + .map { offset, candidate in + DockApplicationWindowCandidate( + id: candidate.id, + applicationInstanceID: + candidate.applicationInstanceID, + processIdentifier: candidate.processIdentifier, + applicationName: applicationName, + title: candidate.title, + documentPath: candidate.documentPath, + frame: candidate.frame, + role: candidate.role, + isMinimized: candidate.isMinimized, + isApplicationHidden: isApplicationHidden, + isApplicationActive: isApplicationActive, + isMain: candidate.isMain, + isFocused: candidate.isFocused, + sourceOrder: startingSourceOrder + offset + ) + } + } + + static func nativeQueryStatus( + successfulQueryCount: Int, + temporaryFailureCount: Int, + retainedCandidateCount: Int + ) -> DockApplicationWindowQueryStatus { + if successfulQueryCount > 0 || retainedCandidateCount > 0 { + return .ready + } + return temporaryFailureCount > 0 + ? .temporarilyUnavailable + : .ready + } + + static func focusSteps( + for window: DockApplicationWindow + ) -> [DockApplicationWindowFocusStep] { + var steps: [DockApplicationWindowFocusStep] = [] + if window.isApplicationHidden { + steps.append(.unhideApplication) + } + if window.isMinimized { + steps.append(.restoreWindow) + } + steps.append(contentsOf: [ + .makeMain, + .setFocusedWindow, + .activateApplication, + .raiseWindow, + ]) + return steps + } + + private static func orderedBefore( + _ lhs: DockApplicationWindowCandidate, + _ rhs: DockApplicationWindowCandidate + ) -> Bool { + let lhsRank = ( + lhs.isFocused ? 0 : 1, + lhs.isMain ? 0 : 1, + lhs.isApplicationActive ? 0 : 1, + lhs.isMinimized ? 1 : 0, + lhs.sourceOrder + ) + let rhsRank = ( + rhs.isFocused ? 0 : 1, + rhs.isMain ? 0 : 1, + rhs.isApplicationActive ? 0 : 1, + rhs.isMinimized ? 1 : 0, + rhs.sourceOrder + ) + return lhsRank < rhsRank + } + + private static func bestCaptureMatch( + for candidate: DockApplicationWindowCandidate, + among windows: [DockWindowServerWindow], + allCandidates: [DockApplicationWindowCandidate] + ) -> DockWindowServerWindow? { + guard !windows.isEmpty else { return nil } + + let frameMatches = candidate.frame.map { frame in + windows.filter { + frameDistance(frame, $0.frame) <= 12 + } + } ?? [] + let candidateTitle = normalizedTitle(candidate.title) + let titleMatches = windows.filter { + candidateTitle != nil + && normalizedTitle($0.title) == candidateTitle + } + + if let match = uniqueIntersection( + frameMatches, + titleMatches + ) { + return match + } + if candidate.isFocused || candidate.isMain { + let onScreenFrameMatches = frameMatches.filter( + \.isOnScreen + ) + if onScreenFrameMatches.count == 1 { + return onScreenFrameMatches[0] + } + } + if frameMatches.count == 1 { + return frameMatches[0] + } + if titleMatches.count == 1 { + let sameTitleCandidateCount = allCandidates.filter { + $0.processIdentifier == candidate.processIdentifier + && normalizedTitle($0.title) == candidateTitle + }.count + if sameTitleCandidateCount == 1 { + return titleMatches[0] + } + } + + return nil + } + + private static func uniqueIntersection( + _ lhs: [DockWindowServerWindow], + _ rhs: [DockWindowServerWindow] + ) -> DockWindowServerWindow? { + let rhsIDs = Set(rhs.map(\.windowID)) + let matches = lhs.filter { + rhsIDs.contains($0.windowID) + } + return matches.count == 1 ? matches[0] : nil + } + + private static func representationIndex( + for capture: DockWindowServerWindow, + in candidates: [DockApplicationWindowCandidate] + ) -> Int? { + let sameProcessIndices = candidates.indices.filter { + candidates[$0].processIdentifier + == capture.processIdentifier + } + if let index = sameProcessIndices.first(where: { + candidates[$0].frame.map { + frameDistance($0, capture.frame) <= 12 + } ?? false + }) { + return index + } + if let index = sameProcessIndices.first(where: { + let candidateTitle = normalizedTitle( + candidates[$0].title + ) + return candidateTitle != nil + && candidateTitle + == normalizedTitle(capture.title) + }) { + return index + } + return nil + } + + private static func frameDistance( + _ lhs: CGRect, + _ rhs: CGRect + ) -> CGFloat { + abs(lhs.minX - rhs.minX) + + abs(lhs.minY - rhs.minY) + + abs(lhs.width - rhs.width) + + abs(lhs.height - rhs.height) + } + + private static func normalizedTitle( + _ title: String? + ) -> String? { + title? + .trimmingCharacters(in: .whitespacesAndNewlines) + .folding( + options: [.caseInsensitive, .diacriticInsensitive], + locale: .current + ) + .nilIfEmpty + } + + private static func resolvedTitle( + for candidate: DockApplicationWindowCandidate + ) -> String { + if let title = candidate.title?.trimmedNonEmpty { + return title + } + if let path = candidate.documentPath?.trimmedNonEmpty { + let name = URL(fileURLWithPath: path).lastPathComponent + if let name = name.trimmedNonEmpty { + return name + } + } + return "Untitled \(candidate.applicationName) Window" + } +} + +private extension String { + var trimmedNonEmpty: String? { + let value = trimmingCharacters( + in: .whitespacesAndNewlines + ) + return value.isEmpty ? nil : value + } + + var nilIfEmpty: String? { + isEmpty ? nil : self + } +} diff --git a/Sources/FreeDock/Models/DockBlurStyle.swift b/Sources/FreeDock/Models/DockBlurStyle.swift new file mode 100644 index 0000000..d71633a --- /dev/null +++ b/Sources/FreeDock/Models/DockBlurStyle.swift @@ -0,0 +1,11 @@ +import Foundation + +enum DockBlurStyle: String, Codable, CaseIterable, Equatable, Sendable { + case light + case regular + case strong + + var displayName: String { + rawValue.capitalized + } +} diff --git a/Sources/FreeDock/Models/DockConfig.swift b/Sources/FreeDock/Models/DockConfig.swift index 81f167a..648d87d 100644 --- a/Sources/FreeDock/Models/DockConfig.swift +++ b/Sources/FreeDock/Models/DockConfig.swift @@ -1,21 +1,219 @@ import Foundation +struct DockSettings: Equatable, Sendable { + var orientation: Orientation + var iconSize: Double + var magnificationEnabled: Bool + var magnification: Double + var itemSpacing: Double + var appearance: DockAppearance + var surfaceOpacity: Double + var blurStyle: DockBlurStyle + var cornerRadius: Double + var shadowStrength: Double + var showRunningIndicators: Bool + var autoHideWhenDocked: Bool + var autoHideDelay: Double + + init(_ config: DockConfig) { + orientation = config.orientation + iconSize = config.iconSize + magnificationEnabled = config.magnificationEnabled + magnification = config.magnification + itemSpacing = config.itemSpacing + appearance = config.appearance + surfaceOpacity = config.surfaceOpacity + blurStyle = config.blurStyle + cornerRadius = config.cornerRadius + shadowStrength = config.shadowStrength + showRunningIndicators = config.showRunningIndicators + autoHideWhenDocked = config.autoHideWhenDocked + autoHideDelay = config.autoHideDelay + } +} + struct DockConfig: Codable, Identifiable, Equatable, Sendable { + static let iconSizeRange = 16.0 ... 128.0 + static let magnificationRange = 1.0 ... 1.75 + static let itemSpacingRange = 0.0 ... 24.0 + static let surfaceOpacityRange = 0.30 ... 1.0 + static let cornerRadiusRange = 8.0 ... 32.0 + static let shadowStrengthRange = 0.0 ... 2.0 + static let autoHideDelayRange = 0.1 ... 5.0 + var id: UUID var name: String var position: CGPoint + var displayPlacement: DockDisplayPlacement? var orientation: Orientation var iconSize: Double var items: [DockItem] + var autoHideWhenDocked: Bool + var magnificationEnabled: Bool + var magnification: Double + var itemSpacing: Double + var appearance: DockAppearance + var surfaceOpacity: Double + var blurStyle: DockBlurStyle + var cornerRadius: Double + var shadowStrength: Double + var showRunningIndicators: Bool + var autoHideDelay: Double init(id: UUID = UUID(), name: String, position: CGPoint = .zero, + displayPlacement: DockDisplayPlacement? = nil, orientation: Orientation = .horizontal, iconSize: Double = 48, - items: [DockItem] = []) { + items: [DockItem] = [], autoHideWhenDocked: Bool = true, + magnificationEnabled: Bool = true, magnification: Double = 1.30, + itemSpacing: Double = 3, appearance: DockAppearance = .glass, + surfaceOpacity: Double = 1, blurStyle: DockBlurStyle = .regular, + cornerRadius: Double = 18, shadowStrength: Double = 1, + showRunningIndicators: Bool = true, autoHideDelay: Double = 1) { self.id = id self.name = name self.position = position + self.displayPlacement = displayPlacement self.orientation = orientation - self.iconSize = iconSize + self.iconSize = Self.clamp(iconSize, to: Self.iconSizeRange) self.items = items + self.autoHideWhenDocked = autoHideWhenDocked + self.magnificationEnabled = magnificationEnabled + self.magnification = Self.clamp(magnification, to: Self.magnificationRange) + self.itemSpacing = Self.clamp(itemSpacing, to: Self.itemSpacingRange) + self.appearance = appearance + self.surfaceOpacity = Self.clamp(surfaceOpacity, to: Self.surfaceOpacityRange) + self.blurStyle = blurStyle + self.cornerRadius = Self.clamp(cornerRadius, to: Self.cornerRadiusRange) + self.shadowStrength = Self.clamp(shadowStrength, to: Self.shadowStrengthRange) + self.showRunningIndicators = showRunningIndicators + self.autoHideDelay = Self.clamp(autoHideDelay, to: Self.autoHideDelayRange) + normalizeDisplayPlacementEdge() + } + + static func clamp(_ value: Double, to range: ClosedRange) -> Double { + min(max(value, range.lowerBound), range.upperBound) + } + + static var defaultSettings: DockSettings { + DockSettings(DockConfig(name: "Defaults")) + } + + var settings: DockSettings { + DockSettings(self) + } + + mutating func apply(settings: DockSettings) { + orientation = settings.orientation + iconSize = Self.clamp(settings.iconSize, to: Self.iconSizeRange) + magnificationEnabled = settings.magnificationEnabled + magnification = Self.clamp(settings.magnification, to: Self.magnificationRange) + itemSpacing = Self.clamp(settings.itemSpacing, to: Self.itemSpacingRange) + appearance = settings.appearance + surfaceOpacity = Self.clamp(settings.surfaceOpacity, to: Self.surfaceOpacityRange) + blurStyle = settings.blurStyle + cornerRadius = Self.clamp(settings.cornerRadius, to: Self.cornerRadiusRange) + shadowStrength = Self.clamp(settings.shadowStrength, to: Self.shadowStrengthRange) + showRunningIndicators = settings.showRunningIndicators + autoHideWhenDocked = settings.autoHideWhenDocked + autoHideDelay = Self.clamp(settings.autoHideDelay, to: Self.autoHideDelayRange) + normalizeDisplayPlacementEdge() + } + + mutating func normalizeDisplayPlacementEdge() { + displayPlacement = displayPlacement?.respecting( + orientation: orientation, + autoHideWhenDocked: autoHideWhenDocked + ) + } + + func duplicated(name: String, position: CGPoint) -> DockConfig { + DockConfig( + name: name, + position: position, + displayPlacement: displayPlacement, + orientation: orientation, + iconSize: iconSize, + items: items.map { $0.duplicated() }, + autoHideWhenDocked: autoHideWhenDocked, + magnificationEnabled: magnificationEnabled, + magnification: magnification, + itemSpacing: itemSpacing, + appearance: appearance, + surfaceOpacity: surfaceOpacity, + blurStyle: blurStyle, + cornerRadius: cornerRadius, + shadowStrength: shadowStrength, + showRunningIndicators: showRunningIndicators, + autoHideDelay: autoHideDelay + ) + } + + private enum CodingKeys: String, CodingKey { + case id + case name + case position + case displayPlacement + case orientation + case iconSize + case items + case autoHideWhenDocked + case magnificationEnabled + case magnification + case itemSpacing + case appearance + case surfaceOpacity + case blurStyle + case cornerRadius + case shadowStrength + case showRunningIndicators + case autoHideDelay + } + + /// Config files created before edge auto-hide existed keep the new default behavior. + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + id = try container.decode(UUID.self, forKey: .id) + name = try container.decode(String.self, forKey: .name) + position = try container.decode(CGPoint.self, forKey: .position) + displayPlacement = try? container.decode( + DockDisplayPlacement.self, + forKey: .displayPlacement + ) + orientation = try container.decode(Orientation.self, forKey: .orientation) + iconSize = Self.clamp( + try container.decode(Double.self, forKey: .iconSize), + to: Self.iconSizeRange + ) + items = try container.decode([DockItem].self, forKey: .items) + autoHideWhenDocked = try container.decodeIfPresent(Bool.self, forKey: .autoHideWhenDocked) ?? true + magnificationEnabled = try container.decodeIfPresent(Bool.self, forKey: .magnificationEnabled) ?? true + magnification = Self.clamp( + try container.decodeIfPresent(Double.self, forKey: .magnification) ?? 1.30, + to: Self.magnificationRange + ) + itemSpacing = Self.clamp( + try container.decodeIfPresent(Double.self, forKey: .itemSpacing) ?? 3, + to: Self.itemSpacingRange + ) + appearance = (try? container.decode(DockAppearance.self, forKey: .appearance)) ?? .glass + surfaceOpacity = Self.clamp( + try container.decodeIfPresent(Double.self, forKey: .surfaceOpacity) ?? 1, + to: Self.surfaceOpacityRange + ) + blurStyle = (try? container.decode(DockBlurStyle.self, forKey: .blurStyle)) ?? .regular + cornerRadius = Self.clamp( + try container.decodeIfPresent(Double.self, forKey: .cornerRadius) ?? 18, + to: Self.cornerRadiusRange + ) + shadowStrength = Self.clamp( + try container.decodeIfPresent(Double.self, forKey: .shadowStrength) ?? 1, + to: Self.shadowStrengthRange + ) + showRunningIndicators = try container.decodeIfPresent(Bool.self, forKey: .showRunningIndicators) ?? true + autoHideDelay = Self.clamp( + try container.decodeIfPresent(Double.self, forKey: .autoHideDelay) ?? 1, + to: Self.autoHideDelayRange + ) + normalizeDisplayPlacementEdge() } } diff --git a/Sources/FreeDock/Models/DockDisplayPlacement.swift b/Sources/FreeDock/Models/DockDisplayPlacement.swift new file mode 100644 index 0000000..20b1853 --- /dev/null +++ b/Sources/FreeDock/Models/DockDisplayPlacement.swift @@ -0,0 +1,579 @@ +import Cocoa +import ColorSync +import Foundation + +enum DockScreenEdge: String, CaseIterable, Codable, Equatable, Hashable, Sendable { + case left + case right + case top + case bottom + + func isCompatible(with orientation: Orientation) -> Bool { + switch (self, orientation) { + case (.top, .horizontal), (.bottom, .horizontal), + (.left, .vertical), (.right, .vertical): + return true + default: + return false + } + } +} + +struct DockDisplayPlacement: Codable, Equatable, Sendable { + var displayID: UUID + var displayName: String? + var normalizedCenter: CGPoint + var edge: DockScreenEdge? + + init( + displayID: UUID, + displayName: String? = nil, + normalizedCenter: CGPoint = CGPoint(x: 0.5, y: 0.5), + edge: DockScreenEdge? = nil + ) { + self.displayID = displayID + self.displayName = displayName + self.normalizedCenter = DockDisplayGeometry.clamped(normalizedCenter) + self.edge = edge + } + + private enum CodingKeys: String, CodingKey { + case displayID + case displayName + case normalizedCenter + case edge + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + displayID = try container.decode(UUID.self, forKey: .displayID) + displayName = try? container.decode(String.self, forKey: .displayName) + normalizedCenter = DockDisplayGeometry.clamped( + (try? container.decode(CGPoint.self, forKey: .normalizedCenter)) + ?? CGPoint(x: 0.5, y: 0.5) + ) + edge = try? container.decode(DockScreenEdge.self, forKey: .edge) + } + + func respecting( + orientation: Orientation, + autoHideWhenDocked: Bool + ) -> DockDisplayPlacement { + guard autoHideWhenDocked, + edge?.isCompatible(with: orientation) != false + else { + var placement = self + placement.edge = nil + return placement + } + return self + } +} + +struct DockDisplayDescriptor: Identifiable, Equatable, Sendable { + let id: UUID + let name: String + let label: String + let frame: CGRect + let visibleFrame: CGRect + let isPrimary: Bool + + init( + id: UUID, + name: String, + frame: CGRect, + visibleFrame: CGRect, + isPrimary: Bool, + label: String? = nil + ) { + self.id = id + self.name = name + self.label = label ?? name + self.frame = frame + self.visibleFrame = visibleFrame + self.isPrimary = isPrimary + } + + func withLabel(_ label: String) -> DockDisplayDescriptor { + DockDisplayDescriptor( + id: id, + name: name, + frame: frame, + visibleFrame: visibleFrame, + isPrimary: isPrimary, + label: label + ) + } +} + +struct DockDisplayResolution: Equatable, Sendable { + let display: DockDisplayDescriptor + let isFallback: Bool +} + +enum DockDisplayGeometry { + static let autoHideRevealThickness: CGFloat = 6 + + static func clamped(_ point: CGPoint) -> CGPoint { + CGPoint( + x: normalized(point.x), + y: normalized(point.y) + ) + } + + static func normalizedCenter(of frame: CGRect, in visibleFrame: CGRect) -> CGPoint { + guard visibleFrame.width > 0, visibleFrame.height > 0 else { + return CGPoint(x: 0.5, y: 0.5) + } + + return clamped(CGPoint( + x: (frame.midX - visibleFrame.minX) / visibleFrame.width, + y: (frame.midY - visibleFrame.minY) / visibleFrame.height + )) + } + + static func frame( + size: CGSize, + placement: DockDisplayPlacement, + in visibleFrame: CGRect + ) -> CGRect { + let center = CGPoint( + x: visibleFrame.minX + visibleFrame.width * placement.normalizedCenter.x, + y: visibleFrame.minY + visibleFrame.height * placement.normalizedCenter.y + ) + var frame = CGRect( + x: center.x - size.width / 2, + y: center.y - size.height / 2, + width: size.width, + height: size.height + ) + frame = clamped(frame, to: visibleFrame) + + switch placement.edge { + case .left: + frame.origin.x = visibleFrame.minX + case .right: + frame.origin.x = max(visibleFrame.minX, visibleFrame.maxX - frame.width) + case .bottom: + frame.origin.y = visibleFrame.minY + case .top: + frame.origin.y = max(visibleFrame.minY, visibleFrame.maxY - frame.height) + case nil: + break + } + + return frame + } + + static func offset( + _ placement: DockDisplayPlacement, + by delta: CGPoint, + in visibleFrame: CGRect + ) -> DockDisplayPlacement { + guard visibleFrame.width > 0, visibleFrame.height > 0 else { + return placement + } + + var offsetPlacement = placement + offsetPlacement.normalizedCenter = clamped(CGPoint( + x: placement.normalizedCenter.x + delta.x / visibleFrame.width, + y: placement.normalizedCenter.y + delta.y / visibleFrame.height + )) + return offsetPlacement + } + + static func clamped(_ frame: CGRect, to visibleFrame: CGRect) -> CGRect { + var frame = frame + let maximumX = max(visibleFrame.minX, visibleFrame.maxX - frame.width) + let maximumY = max(visibleFrame.minY, visibleFrame.maxY - frame.height) + frame.origin.x = min(max(frame.origin.x, visibleFrame.minX), maximumX) + frame.origin.y = min(max(frame.origin.y, visibleFrame.minY), maximumY) + return frame + } + + static func nearestEdge( + of frame: CGRect, + in visibleFrame: CGRect, + orientation: Orientation + ) -> DockScreenEdge { + if orientation == .horizontal { + return abs(frame.minY - visibleFrame.minY) + <= abs(frame.maxY - visibleFrame.maxY) ? .bottom : .top + } + return abs(frame.minX - visibleFrame.minX) + <= abs(frame.maxX - visibleFrame.maxX) ? .left : .right + } + + static func preferredDockingEdge( + of frame: CGRect, + on display: DockDisplayDescriptor, + among displays: [DockDisplayDescriptor], + orientation: Orientation + ) -> DockScreenEdge? { + let exposed = exposedEdges( + for: frame, + on: display, + among: displays + ) + let compatible = compatibleEdges(for: orientation).filter { + exposed.contains($0) + } + + return compatible.min { + distance( + from: frame, + to: $0, + in: display.visibleFrame + ) < distance( + from: frame, + to: $1, + in: display.visibleFrame + ) + } + } + + static func exposedEdges( + for dockFrame: CGRect, + on display: DockDisplayDescriptor, + among displays: [DockDisplayDescriptor] + ) -> Set { + var exposed = Set(DockScreenEdge.allCases) + + for edge in DockScreenEdge.allCases { + let restingFrame = dockedFrame( + dockFrame, + at: edge, + in: display.visibleFrame + ) + let hiddenFrame = autoHiddenFrame( + restingFrame, + at: edge, + in: display.visibleFrame + ) + if displays.contains(where: { + $0.id != display.id + && intersectionArea(hiddenFrame, $0.frame) > 1 + }) { + exposed.remove(edge) + } + } + + return exposed + } + + static func dockedFrame( + _ frame: CGRect, + at edge: DockScreenEdge, + in visibleFrame: CGRect + ) -> CGRect { + var docked = clamped(frame, to: visibleFrame) + switch edge { + case .left: + docked.origin.x = visibleFrame.minX + case .right: + docked.origin.x = max( + visibleFrame.minX, + visibleFrame.maxX - docked.width + ) + case .bottom: + docked.origin.y = visibleFrame.minY + case .top: + docked.origin.y = max( + visibleFrame.minY, + visibleFrame.maxY - docked.height + ) + } + return docked + } + + static func bestDisplay( + for frame: CGRect, + among displays: [DockDisplayDescriptor] + ) -> DockDisplayDescriptor? { + guard !displays.isEmpty else { return nil } + + let center = CGPoint(x: frame.midX, y: frame.midY) + let ranked = displays.map { display in + DisplayRank( + display: display, + intersectionArea: intersectionArea(frame, display.frame), + containsCenter: display.frame.contains(center), + squaredDistance: squaredDistance(from: center, to: display.frame) + ) + } + return ranked.sorted(by: isBetterRank).first?.display + } + + static func resolveDisplay( + for placement: DockDisplayPlacement?, + panelFrame: CGRect, + among displays: [DockDisplayDescriptor] + ) -> DockDisplayResolution? { + guard !displays.isEmpty else { return nil } + + if let placement { + if let desired = displays.first(where: { + $0.id == placement.displayID + }) { + return DockDisplayResolution( + display: desired, + isFallback: false + ) + } + + guard let fallback = stablePrimary(in: displays) else { + return nil + } + return DockDisplayResolution( + display: fallback, + isFallback: true + ) + } + + guard let automatic = bestDisplay(for: panelFrame, among: displays) else { + return nil + } + return DockDisplayResolution(display: automatic, isFallback: false) + } + + static func uniquelyLabeled( + _ displays: [DockDisplayDescriptor] + ) -> [DockDisplayDescriptor] { + guard displays.count > 1 else { return displays } + + let primary = stablePrimary(in: displays) + let groups = Dictionary(grouping: displays) { + $0.name.folding( + options: [.caseInsensitive, .diacriticInsensitive], + locale: .current + ) + } + var labels: [UUID: String] = [:] + + for group in groups.values { + guard group.count > 1 else { + if let display = group.first { + labels[display.id] = display.name + } + continue + } + + let sorted = group.sorted(by: spatiallyPrecedes) + let candidates = sorted.map { display -> (DockDisplayDescriptor, String) in + if display.isPrimary { + return (display, display.name) + } + let suffix = relativePosition(of: display, to: primary) + return (display, "\(display.name) — \(suffix)") + } + let candidateCounts = Dictionary(grouping: candidates, by: \.1) + .mapValues(\.count) + var usedCounts: [String: Int] = [:] + + for (display, candidate) in candidates { + guard candidateCounts[candidate, default: 0] > 1 else { + labels[display.id] = candidate + continue + } + usedCounts[candidate, default: 0] += 1 + labels[display.id] = "\(candidate) \(usedCounts[candidate]!)" + } + } + + return displays.sorted(by: spatiallyPrecedes).map { + $0.withLabel(labels[$0.id] ?? $0.name) + } + } + + private struct DisplayRank { + let display: DockDisplayDescriptor + let intersectionArea: CGFloat + let containsCenter: Bool + let squaredDistance: CGFloat + } + + private static func isBetterRank( + _ lhs: DisplayRank, + _ rhs: DisplayRank + ) -> Bool { + if lhs.intersectionArea != rhs.intersectionArea { + return lhs.intersectionArea > rhs.intersectionArea + } + if lhs.intersectionArea > 0, + lhs.containsCenter != rhs.containsCenter + { + return lhs.containsCenter + } + if lhs.intersectionArea == 0, + lhs.squaredDistance != rhs.squaredDistance + { + return lhs.squaredDistance < rhs.squaredDistance + } + if lhs.display.isPrimary != rhs.display.isPrimary { + return lhs.display.isPrimary + } + return lhs.display.id.uuidString < rhs.display.id.uuidString + } + + private static func stablePrimary( + in displays: [DockDisplayDescriptor] + ) -> DockDisplayDescriptor? { + displays.filter(\.isPrimary).min { + $0.id.uuidString < $1.id.uuidString + } ?? displays.min { + $0.id.uuidString < $1.id.uuidString + } + } + + private static func compatibleEdges( + for orientation: Orientation + ) -> [DockScreenEdge] { + orientation == .horizontal ? [.bottom, .top] : [.left, .right] + } + + private static func distance( + from frame: CGRect, + to edge: DockScreenEdge, + in visibleFrame: CGRect + ) -> CGFloat { + switch edge { + case .left: + return abs(frame.minX - visibleFrame.minX) + case .right: + return abs(frame.maxX - visibleFrame.maxX) + case .bottom: + return abs(frame.minY - visibleFrame.minY) + case .top: + return abs(frame.maxY - visibleFrame.maxY) + } + } + + private static func autoHiddenFrame( + _ frame: CGRect, + at edge: DockScreenEdge, + in visibleFrame: CGRect + ) -> CGRect { + var hidden = frame + switch edge { + case .left: + hidden.origin.x = visibleFrame.minX + - frame.width + + autoHideRevealThickness + case .right: + hidden.origin.x = visibleFrame.maxX + - autoHideRevealThickness + case .bottom: + hidden.origin.y = visibleFrame.minY + - frame.height + + autoHideRevealThickness + case .top: + hidden.origin.y = visibleFrame.maxY + - autoHideRevealThickness + } + return hidden + } + + private static func spatiallyPrecedes( + _ lhs: DockDisplayDescriptor, + _ rhs: DockDisplayDescriptor + ) -> Bool { + if lhs.isPrimary != rhs.isPrimary { + return lhs.isPrimary + } + if lhs.frame.midX != rhs.frame.midX { + return lhs.frame.midX < rhs.frame.midX + } + if lhs.frame.midY != rhs.frame.midY { + return lhs.frame.midY > rhs.frame.midY + } + return lhs.id.uuidString < rhs.id.uuidString + } + + private static func relativePosition( + of display: DockDisplayDescriptor, + to primary: DockDisplayDescriptor? + ) -> String { + guard let primary else { return "Display" } + let deltaX = display.frame.midX - primary.frame.midX + let deltaY = display.frame.midY - primary.frame.midY + + if abs(deltaX) >= abs(deltaY), deltaX != 0 { + return deltaX < 0 ? "Left" : "Right" + } + if deltaY != 0 { + return deltaY < 0 ? "Below" : "Above" + } + return "Display" + } + + private static func normalized(_ value: CGFloat) -> CGFloat { + guard value.isFinite else { return 0.5 } + return min(max(value, 0), 1) + } + + private static func intersectionArea(_ lhs: CGRect, _ rhs: CGRect) -> CGFloat { + let intersection = lhs.intersection(rhs) + guard !intersection.isNull else { return 0 } + return max(0, intersection.width) * max(0, intersection.height) + } + + private static func squaredDistance(from point: CGPoint, to rect: CGRect) -> CGFloat { + let deltaX = max(rect.minX - point.x, 0, point.x - rect.maxX) + let deltaY = max(rect.minY - point.y, 0, point.y - rect.maxY) + return deltaX * deltaX + deltaY * deltaY + } +} + +@MainActor +enum DockDisplayManager { + static var connectedDisplays: [DockDisplayDescriptor] { + let screens = NSScreen.screens + let displays: [DockDisplayDescriptor] = screens.enumerated().compactMap { + index, screen -> DockDisplayDescriptor? in + guard let id = identifier(for: screen) else { return nil } + return DockDisplayDescriptor( + id: id, + name: screen.localizedName, + frame: screen.frame, + visibleFrame: screen.visibleFrame, + isPrimary: index == 0 + ) + } + return DockDisplayGeometry.uniquelyLabeled(displays) + } + + static var primaryScreen: NSScreen? { + NSScreen.screens.first ?? NSScreen.main + } + + static func identifier(for screen: NSScreen) -> UUID? { + let key = NSDeviceDescriptionKey("NSScreenNumber") + guard let number = screen.deviceDescription[key] as? NSNumber, + let createdUUID = CGDisplayCreateUUIDFromDisplayID(number.uint32Value), + let value = CFUUIDCreateString(nil, createdUUID.takeRetainedValue()) + else { + return nil + } + + return UUID(uuidString: value as String) + } + + static func screen(withID id: UUID) -> NSScreen? { + NSScreen.screens.first { identifier(for: $0) == id } + } + + static func descriptor(for screen: NSScreen) -> DockDisplayDescriptor? { + guard let id = identifier(for: screen) else { return nil } + return connectedDisplays.first { $0.id == id } + } + + static func screen(containing frame: CGRect) -> NSScreen? { + guard let descriptor = DockDisplayGeometry.bestDisplay( + for: frame, + among: connectedDisplays + ) else { + return primaryScreen + } + return screen(withID: descriptor.id) ?? primaryScreen + } +} diff --git a/Sources/FreeDock/Models/DockExternalFileDropClaimState.swift b/Sources/FreeDock/Models/DockExternalFileDropClaimState.swift new file mode 100644 index 0000000..63f72e6 --- /dev/null +++ b/Sources/FreeDock/Models/DockExternalFileDropClaimState.swift @@ -0,0 +1,97 @@ +import Foundation + +/// Pure state for coordinating nested Finder drop targets in a dock. +/// +/// An application cell sits inside the dock's parent pin target. App targeting +/// and claimed app drops suppress that parent so one Finder payload cannot be +/// handled twice. A claim's suppression lasts only through the current nested +/// drop-dispatch turn and is independent of asynchronous provider loading. +struct DockExternalFileDropClaimState: Equatable, Sendable { + private struct ActiveOperation: Equatable, Sendable { + let id: UUID + } + + private(set) var applicationTargetItemID: UUID? + private var activeOperation: ActiveOperation? + private(set) var dispatchSuppressionOperationID: UUID? + + init() {} + + var activeOperationID: UUID? { + activeOperation?.id + } + + var isOperationInProgress: Bool { + activeOperation != nil + } + + var isAwaitingDispatchRelease: Bool { + dispatchSuppressionOperationID != nil + } + + var shouldSuppressParentPinDrop: Bool { + applicationTargetItemID != nil + || dispatchSuppressionOperationID != nil + } + + mutating func applicationTargetEntered(itemID: UUID) { + applicationTargetItemID = itemID + } + + mutating func applicationTargetExited(itemID: UUID) { + guard applicationTargetItemID == itemID else { return } + applicationTargetItemID = nil + } + + /// Starts an ordinary parent-target load without claiming it for an app. + @discardableResult + mutating func beginOperation( + operationID: UUID = UUID() + ) -> UUID { + activeOperation = ActiveOperation(id: operationID) + return operationID + } + + /// Claims a Finder payload for one app and starts its asynchronous load. + @discardableResult + mutating func claimApplicationDrop( + itemID: UUID, + operationID: UUID = UUID() + ) -> UUID { + applicationTargetItemID = itemID + activeOperation = ActiveOperation(id: operationID) + dispatchSuppressionOperationID = operationID + return operationID + } + + /// Finishes only the current operation. + @discardableResult + mutating func finishOperation(_ operationID: UUID) -> Bool { + guard let operation = activeOperation, + operation.id == operationID + else { + return false + } + + activeOperation = nil + return true + } + + /// Releases a claim on the next dispatch turn, without ending its load. + @discardableResult + mutating func releaseDispatchSuppression( + for operationID: UUID + ) -> Bool { + guard dispatchSuppressionOperationID == operationID else { + return false + } + dispatchSuppressionOperationID = nil + return true + } + + mutating func reset() { + applicationTargetItemID = nil + activeOperation = nil + dispatchSuppressionOperationID = nil + } +} diff --git a/Sources/FreeDock/Models/DockItem.swift b/Sources/FreeDock/Models/DockItem.swift index 0512c24..bf18715 100644 --- a/Sources/FreeDock/Models/DockItem.swift +++ b/Sources/FreeDock/Models/DockItem.swift @@ -1,21 +1,364 @@ import Foundation +import UniformTypeIdentifiers + +enum DockItemKind: String, Codable, Equatable, Sendable { + case application + case document + case folder + case separator +} + +enum SmartStackSource: String, Codable, CaseIterable, Equatable, Hashable, Sendable { + case recentFiles + case downloads + + var defaultLabel: String { + switch self { + case .recentFiles: return "Recent Files" + case .downloads: return "Downloads" + } + } + + var defaultOptions: FolderStackOptions { + switch self { + case .recentFiles: + return FolderStackOptions( + presentation: .list, + sortOrder: .recentlyOpened + ) + case .downloads: + return FolderStackOptions( + presentation: .automatic, + sortOrder: .dateModified + ) + } + } +} + +struct FolderStackOptions: Codable, Equatable, Hashable, Sendable { + enum Presentation: String, Codable, CaseIterable, Equatable, Hashable, Sendable { + case automatic + case grid + case list + } + + enum SortOrder: String, Codable, CaseIterable, Equatable, Hashable, Sendable { + case name + case dateModified + case kind + case recentlyOpened + } + + var presentation: Presentation + var sortOrder: SortOrder + var showHiddenFiles: Bool + + init( + presentation: Presentation = .automatic, + sortOrder: SortOrder = .name, + showHiddenFiles: Bool = false + ) { + self.presentation = presentation + self.sortOrder = sortOrder + self.showHiddenFiles = showHiddenFiles + } + + private enum CodingKeys: String, CodingKey { + case presentation + case sortOrder + case showHiddenFiles + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + presentation = + (try? container.decode(Presentation.self, forKey: .presentation)) + ?? .automatic + sortOrder = + (try? container.decode(SortOrder.self, forKey: .sortOrder)) + ?? .name + showHiddenFiles = + (try? container.decode(Bool.self, forKey: .showHiddenFiles)) + ?? false + } +} struct DockItem: Codable, Identifiable, Equatable, Sendable { var id: UUID - var appPath: String + var path: String var label: String? - var isSeparator: Bool + var kind: DockItemKind + var folderOptions: FolderStackOptions? + var smartStackSource: SmartStackSource? - init(id: UUID = UUID(), appPath: String, label: String? = nil) { + /// Source compatibility for code and configs created before typed dock items. + var appPath: String { + get { path } + set { path = newValue } + } + + /// The legacy value remains encoded so older FreeDock builds can still load a config. + var isSeparator: Bool { + kind == .separator + } + + init( + id: UUID = UUID(), + kind: DockItemKind, + path: String, + label: String? = nil, + folderOptions: FolderStackOptions? = nil, + smartStackSource: SmartStackSource? = nil + ) { self.id = id - self.appPath = appPath - self.label = label - isSeparator = false + self.kind = smartStackSource == nil ? kind : .folder + self.path = kind == .separator || smartStackSource != nil ? "" : path + self.label = label ?? smartStackSource?.defaultLabel + self.smartStackSource = smartStackSource + self.folderOptions = self.kind == .folder + ? ( + folderOptions + ?? smartStackSource?.defaultOptions + ?? FolderStackOptions() + ) + : nil + } + + /// Legacy initializer retained for application-only call sites. + init(id: UUID = UUID(), appPath: String, label: String? = nil) { + self.init( + id: id, + kind: .application, + path: appPath, + label: label + ) + } + + static func application( + at url: URL, + id: UUID = UUID(), + label: String? = nil + ) -> DockItem { + DockItem( + id: id, + kind: .application, + path: url.standardizedFileURL.path, + label: label ?? defaultLabel(for: url, kind: .application) + ) + } + + static func document( + at url: URL, + id: UUID = UUID(), + label: String? = nil + ) -> DockItem { + DockItem( + id: id, + kind: .document, + path: url.standardizedFileURL.path, + label: label ?? defaultLabel(for: url, kind: .document) + ) + } + + static func folder( + at url: URL, + id: UUID = UUID(), + label: String? = nil, + options: FolderStackOptions = FolderStackOptions() + ) -> DockItem { + DockItem( + id: id, + kind: .folder, + path: url.standardizedFileURL.path, + label: label ?? defaultLabel(for: url, kind: .folder), + folderOptions: options + ) + } + + static func smartStack( + _ source: SmartStackSource, + id: UUID = UUID(), + label: String? = nil, + options: FolderStackOptions? = nil + ) -> DockItem { + DockItem( + id: id, + kind: .folder, + path: "", + label: label, + folderOptions: options, + smartStackSource: source + ) + } + + static func recentFilesStack( + id: UUID = UUID(), + label: String? = nil, + options: FolderStackOptions? = nil + ) -> DockItem { + smartStack( + .recentFiles, + id: id, + label: label, + options: options + ) + } + + static func downloadsStack( + id: UUID = UUID(), + label: String? = nil, + options: FolderStackOptions? = nil + ) -> DockItem { + smartStack( + .downloads, + id: id, + label: label, + options: options + ) + } + + static func separator(id: UUID = UUID()) -> DockItem { + DockItem(id: id, kind: .separator, path: "") + } + + /// Classifies a file-system URL for pinning without throwing for stale or invalid drops. + static func pinnedItem(at url: URL) -> DockItem? { + guard url.isFileURL else { return nil } + + let standardizedURL = url.standardizedFileURL + let classificationURL = standardizedURL.resolvingSymlinksInPath() + var isDirectory: ObjCBool = false + guard FileManager.default.fileExists( + atPath: classificationURL.path, + isDirectory: &isDirectory + ) else { + return nil + } + + let values = try? classificationURL.resourceValues( + forKeys: [ + .contentTypeKey, + .isDirectoryKey, + .isPackageKey, + .localizedNameKey, + ] + ) + let contentType = values?.contentType + let directory = values?.isDirectory ?? isDirectory.boolValue + let isApplication = + directory + && ( + standardizedURL.pathExtension.caseInsensitiveCompare("app") + == .orderedSame + || classificationURL.pathExtension.caseInsensitiveCompare("app") + == .orderedSame + || contentType?.conforms(to: .application) == true + ) + + if isApplication { + return application(at: standardizedURL) + } + + let package = values?.isPackage ?? false + if directory, !package { + return folder(at: standardizedURL) + } + + return document(at: standardizedURL) + } + + func duplicated() -> DockItem { + DockItem( + kind: kind, + path: path, + label: label, + folderOptions: folderOptions, + smartStackSource: smartStackSource + ) + } + + var fileURL: URL? { + guard kind != .separator, + smartStackSource == nil, + !path.isEmpty + else { + return nil + } + return URL(fileURLWithPath: path) + } + + private static func defaultLabel(for url: URL, kind: DockItemKind) -> String { + let localizedName = (try? url.resourceValues(forKeys: [.localizedNameKey]))? + .localizedName + let fallbackName = url.lastPathComponent + let name = localizedName.flatMap { $0.isEmpty ? nil : $0 } + ?? fallbackName + + if kind == .application { + return (name as NSString).deletingPathExtension + } + return name + } + + private enum CodingKeys: String, CodingKey { + case id + case path + case appPath + case label + case kind + case folderOptions + case smartStackSource + case isSeparator + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + id = try container.decode(UUID.self, forKey: .id) + + let legacySeparator = + (try? container.decode(Bool.self, forKey: .isSeparator)) + ?? false + let decodedKind = try? container.decode(DockItemKind.self, forKey: .kind) + smartStackSource = try? container.decode( + SmartStackSource.self, + forKey: .smartStackSource + ) + kind = legacySeparator + ? .separator + : (smartStackSource == nil ? (decodedKind ?? .application) : .folder) + + let decodedPath = + (try? container.decode(String.self, forKey: .path)) + ?? (try? container.decode(String.self, forKey: .appPath)) + ?? "" + path = kind == .separator || smartStackSource != nil ? "" : decodedPath + label = + (try? container.decode(String.self, forKey: .label)) + ?? smartStackSource?.defaultLabel + + if kind == .folder { + folderOptions = + (try? container.decode(FolderStackOptions.self, forKey: .folderOptions)) + ?? smartStackSource?.defaultOptions + ?? FolderStackOptions() + } else { + folderOptions = nil + smartStackSource = nil + } } - static func separator() -> DockItem { - var item = DockItem(appPath: "") - item.isSeparator = true - return item + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(id, forKey: .id) + try container.encode(path, forKey: .path) + try container.encode(path, forKey: .appPath) + try container.encodeIfPresent(label, forKey: .label) + try container.encode(kind, forKey: .kind) + try container.encodeIfPresent(folderOptions, forKey: .folderOptions) + try container.encodeIfPresent( + smartStackSource, + forKey: .smartStackSource + ) + try container.encode(isSeparator, forKey: .isSeparator) } } diff --git a/Sources/FreeDock/Models/DockItemDragCoordinator.swift b/Sources/FreeDock/Models/DockItemDragCoordinator.swift new file mode 100644 index 0000000..7399c2e --- /dev/null +++ b/Sources/FreeDock/Models/DockItemDragCoordinator.swift @@ -0,0 +1,170 @@ +import Cocoa +import Combine +import Foundation +import UniformTypeIdentifiers + +extension UTType { + static let freeDockItem = UTType( + exportedAs: "com.freedock.dock-item", + conformingTo: .data + ) +} + +struct DockItemDragSession: Codable, Equatable, Sendable { + let id: UUID + let profileID: UUID + let sourceDockID: UUID + let itemID: UUID + + init( + id: UUID = UUID(), + profileID: UUID, + sourceDockID: UUID, + itemID: UUID + ) { + self.id = id + self.profileID = profileID + self.sourceDockID = sourceDockID + self.itemID = itemID + } +} + +@MainActor +final class DockItemDragCoordinator: ObservableObject { + @Published private(set) var activeSession: DockItemDragSession? + @Published private(set) var contentRevision: UInt = 0 + + var onSessionBegan: (@MainActor (DockItemDragSession) -> Void)? + var onSessionEnded: (@MainActor (DockItemDragSession) -> Void)? + + private var localMouseUpMonitor: Any? + private var globalMouseUpMonitor: Any? + private var deferredFinishWorkItem: DispatchWorkItem? + private var expiryWorkItem: DispatchWorkItem? + + func beginDrag( + profileID: UUID, + sourceDockID: UUID, + itemID: UUID + ) -> NSItemProvider { + cancel() + + let session = DockItemDragSession( + profileID: profileID, + sourceDockID: sourceDockID, + itemID: itemID + ) + activeSession = session + installEndMonitors(for: session.id) + onSessionBegan?(session) + + let provider = NSItemProvider() + let data = try? JSONEncoder().encode(session) + provider.registerDataRepresentation( + forTypeIdentifier: UTType.freeDockItem.identifier, + visibility: .ownProcess + ) { completion in + completion(data, nil) + return nil + } + return provider + } + + func loadSession( + from provider: NSItemProvider, + completion: @escaping @MainActor (DockItemDragSession?) -> Void + ) { + provider.loadDataRepresentation( + forTypeIdentifier: UTType.freeDockItem.identifier + ) { data, _ in + let session = data.flatMap { + try? JSONDecoder().decode( + DockItemDragSession.self, + from: $0 + ) + } + DispatchQueue.main.async { + completion(session) + } + } + } + + func finish(sessionID: UUID) { + guard activeSession?.id == sessionID else { return } + endSession() + } + + func cancel() { + endSession() + } + + func publishContentChange() { + contentRevision &+= 1 + } + + func isDragging(itemID: UUID, from dockID: UUID) -> Bool { + activeSession?.itemID == itemID + && activeSession?.sourceDockID == dockID + } + + private func installEndMonitors(for sessionID: UUID) { + localMouseUpMonitor = NSEvent.addLocalMonitorForEvents( + matching: [.leftMouseUp] + ) { [weak self] event in + self?.scheduleDeferredFinish(for: sessionID) + return event + } + + globalMouseUpMonitor = NSEvent.addGlobalMonitorForEvents( + matching: [.leftMouseUp] + ) { [weak self] _ in + DispatchQueue.main.async { + self?.scheduleDeferredFinish(for: sessionID) + } + } + + let expiry = DispatchWorkItem { [weak self] in + self?.finish(sessionID: sessionID) + } + expiryWorkItem = expiry + DispatchQueue.main.asyncAfter( + deadline: .now() + 120, + execute: expiry + ) + } + + private func scheduleDeferredFinish(for sessionID: UUID) { + guard activeSession?.id == sessionID else { return } + deferredFinishWorkItem?.cancel() + let work = DispatchWorkItem { [weak self] in + self?.finish(sessionID: sessionID) + } + deferredFinishWorkItem = work + DispatchQueue.main.asyncAfter( + deadline: .now() + 0.6, + execute: work + ) + } + + private func endSession() { + let endingSession = activeSession + deferredFinishWorkItem?.cancel() + deferredFinishWorkItem = nil + expiryWorkItem?.cancel() + expiryWorkItem = nil + + if let localMouseUpMonitor { + NSEvent.removeMonitor(localMouseUpMonitor) + self.localMouseUpMonitor = nil + } + if let globalMouseUpMonitor { + NSEvent.removeMonitor(globalMouseUpMonitor) + self.globalMouseUpMonitor = nil + } + + activeSession = nil + if let endingSession { + onSessionEnded?(endingSession) + } + } +} diff --git a/Sources/FreeDock/Models/DockItemPlanner.swift b/Sources/FreeDock/Models/DockItemPlanner.swift new file mode 100644 index 0000000..ef228da --- /dev/null +++ b/Sources/FreeDock/Models/DockItemPlanner.swift @@ -0,0 +1,109 @@ +import Foundation + +struct DockItemAdditionPlan: Equatable, Sendable { + let items: [DockItem] + let addedCount: Int + let skippedCount: Int +} + +enum DockItemPlanner { + /// Produces one ordered update, preserving Finder's URL order and removing aliases + /// or repeated paths that resolve to an item already in the dock. + static func planAdding( + urls: [URL], + to existingItems: [DockItem], + downloadsURL: URL? = nil + ) -> DockItemAdditionPlan { + var items = existingItems + var seenPaths = Set( + existingItems.compactMap { item in + item.fileURL.map(pathIdentity(for:)) + } + ) + if existingItems.contains(where: { + $0.smartStackSource == .downloads + }), + let resolvedDownloadsURL = resolvedDownloadsURL(downloadsURL) + { + seenPaths.insert(pathIdentity(for: resolvedDownloadsURL)) + } + var addedCount = 0 + var skippedCount = 0 + + for url in urls { + guard let item = DockItem.pinnedItem(at: url), + let itemURL = item.fileURL + else { + skippedCount += 1 + continue + } + + let identity = pathIdentity(for: itemURL) + guard seenPaths.insert(identity).inserted else { + skippedCount += 1 + continue + } + + items.append(item) + addedCount += 1 + } + + return DockItemAdditionPlan( + items: items, + addedCount: addedCount, + skippedCount: skippedCount + ) + } + + static func planAdding( + smartStack source: SmartStackSource, + to existingItems: [DockItem], + downloadsURL: URL? = nil + ) -> DockItemAdditionPlan { + let hasSameSource = existingItems.contains { + $0.smartStackSource == source + } + let duplicatesDownloadsFolder: Bool + if source == .downloads, + let resolvedDownloadsURL = resolvedDownloadsURL(downloadsURL) + { + let downloadsIdentity = pathIdentity(for: resolvedDownloadsURL) + duplicatesDownloadsFolder = existingItems.contains { item in + item.smartStackSource == nil + && item.kind == .folder + && item.fileURL.map(pathIdentity(for:)) + == downloadsIdentity + } + } else { + duplicatesDownloadsFolder = false + } + + guard !hasSameSource, !duplicatesDownloadsFolder else { + return DockItemAdditionPlan( + items: existingItems, + addedCount: 0, + skippedCount: 1 + ) + } + + return DockItemAdditionPlan( + items: existingItems + [.smartStack(source)], + addedCount: 1, + skippedCount: 0 + ) + } + + private static func pathIdentity(for url: URL) -> String { + url.standardizedFileURL + .resolvingSymlinksInPath() + .path + } + + private static func resolvedDownloadsURL(_ override: URL?) -> URL? { + override?.standardizedFileURL + ?? FileManager.default.urls( + for: .downloadsDirectory, + in: .userDomainMask + ).first?.standardizedFileURL + } +} diff --git a/Sources/FreeDock/Models/DockItemPresentation.swift b/Sources/FreeDock/Models/DockItemPresentation.swift new file mode 100644 index 0000000..993909a --- /dev/null +++ b/Sources/FreeDock/Models/DockItemPresentation.swift @@ -0,0 +1,159 @@ +import Cocoa + +@MainActor +struct DockItemPresentation { + let displayName: String + let icon: NSImage + let bundleID: String? + let isAvailable: Bool + let kindDescription: String + let badgeSymbolName: String? + + static func resolve(_ item: DockItem) -> DockItemPresentation { + if let source = item.smartStackSource { + return resolveSmartStack(item, source: source) + } + + let path = item.path + let isAvailable = !path.isEmpty + && FileManager.default.fileExists(atPath: path) + let fallbackName = URL(fileURLWithPath: path).lastPathComponent + let resolvedName = item.label.flatMap(\.nonEmpty) + ?? (path.isEmpty + ? item.kind.displayName + : FileManager.default.displayName(atPath: path)) + .nonEmpty + ?? fallbackName + .nonEmpty + ?? item.kind.displayName + let displayName = item.kind == .application + ? (resolvedName as NSString).deletingPathExtension + : resolvedName + + let icon: NSImage + if isAvailable { + icon = (NSWorkspace.shared.icon(forFile: path).copy() as? NSImage) + ?? NSWorkspace.shared.icon(forFile: path) + } else { + icon = NSImage( + systemSymbolName: item.kind.fallbackSymbolName, + accessibilityDescription: displayName + ) ?? NSImage() + } + icon.size = NSSize(width: 64, height: 64) + + let bundleID = item.kind == .application + ? AppInfo.resolveBundleID(from: path) + : nil + return DockItemPresentation( + displayName: displayName, + icon: icon, + bundleID: bundleID, + isAvailable: isAvailable, + kindDescription: item.kind.displayName.lowercased(), + badgeSymbolName: item.kind == .folder + ? "square.grid.2x2.fill" + : nil + ) + } + + private static func resolveSmartStack( + _ item: DockItem, + source: SmartStackSource + ) -> DockItemPresentation { + let displayName = item.label.flatMap(\.nonEmpty) + ?? source.defaultLabel + + switch source { + case .recentFiles: + let icon = systemIcon( + preferredName: "clock.fill", + fallbackName: "clock", + accessibilityDescription: displayName + ) + icon.size = NSSize(width: 64, height: 64) + return DockItemPresentation( + displayName: displayName, + icon: icon, + bundleID: nil, + isAvailable: true, + kindDescription: "recent files stack", + badgeSymbolName: "clock.fill" + ) + + case .downloads: + let downloadsURL = FileManager.default.urls( + for: .downloadsDirectory, + in: .userDomainMask + ).first?.standardizedFileURL + let isAvailable = downloadsURL.map { + FileManager.default.fileExists(atPath: $0.path) + } ?? false + let icon: NSImage + if let downloadsURL, isAvailable { + let workspaceIcon = NSWorkspace.shared.icon( + forFile: downloadsURL.path + ) + icon = (workspaceIcon.copy() as? NSImage) + ?? workspaceIcon + } else { + icon = systemIcon( + preferredName: "arrow.down.circle.fill", + fallbackName: "folder", + accessibilityDescription: displayName + ) + } + icon.size = NSSize(width: 64, height: 64) + return DockItemPresentation( + displayName: displayName, + icon: icon, + bundleID: nil, + isAvailable: isAvailable, + kindDescription: "downloads stack", + badgeSymbolName: "arrow.down" + ) + } + } + + private static func systemIcon( + preferredName: String, + fallbackName: String, + accessibilityDescription: String + ) -> NSImage { + NSImage( + systemSymbolName: preferredName, + accessibilityDescription: accessibilityDescription + ) + ?? NSImage( + systemSymbolName: fallbackName, + accessibilityDescription: accessibilityDescription + ) + ?? NSImage() + } +} + +private extension String { + var nonEmpty: String? { + trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? nil : self + } +} + +extension DockItemKind { + var displayName: String { + switch self { + case .application: return "Application" + case .document: return "Document" + case .folder: return "Folder" + case .separator: return "Separator" + } + } + + var fallbackSymbolName: String { + switch self { + case .application: return "app" + case .document: return "doc" + case .folder: return "folder" + case .separator: return "minus" + } + } +} diff --git a/Sources/FreeDock/Models/DockItemTransferPlanner.swift b/Sources/FreeDock/Models/DockItemTransferPlanner.swift new file mode 100644 index 0000000..0b3e9c6 --- /dev/null +++ b/Sources/FreeDock/Models/DockItemTransferPlanner.swift @@ -0,0 +1,294 @@ +import Foundation + +enum DockItemTransferOperation: Equatable, Sendable { + case move + case copy +} + +enum DockItemTransferDestination: Equatable, Sendable { + /// Places the transferred item in the target item's current slot. + /// + /// For a cross-dock transfer this inserts immediately before the target. + /// For a same-dock move this matches the existing reorder behavior: the + /// moved item finishes at the target item's pre-move index. + case item(UUID) + case trailing +} + +enum DockItemTransferRejection: Equatable, Sendable { + case sourceItemNotFound + case targetItemNotFound(UUID) + case duplicateItem(existingItemID: UUID) + case inconsistentSameDockItems +} + +enum DockItemTransferOutcome: Equatable, Sendable { + case moved(itemID: UUID) + case copied(itemID: UUID) + case unchanged + case rejected(DockItemTransferRejection) +} + +struct DockItemTransferPlan: Equatable, Sendable { + let sourceItems: [DockItem] + let targetItems: [DockItem] + let outcome: DockItemTransferOutcome + + var didTransfer: Bool { + switch outcome { + case .moved, .copied: + return true + case .unchanged, .rejected: + return false + } + } +} + +enum DockItemTransferPlanner { + /// Plans an atomic item transfer without mutating either dock. + /// + /// Moves retain the item's identity. Copies receive a fresh identity while + /// retaining every other stored value. A cross-dock transfer is rejected + /// without changing either dock when the destination already contains the + /// same file-system item or smart stack. Separators intentionally remain + /// repeatable. + static func planTransfer( + itemID: UUID, + from sourceDock: DockConfig, + to targetDock: DockConfig, + operation: DockItemTransferOperation = .move, + destination: DockItemTransferDestination = .trailing, + downloadsURL: URL? = nil + ) -> DockItemTransferPlan { + guard let sourceIndex = sourceDock.items.firstIndex(where: { + $0.id == itemID + }) else { + return rejectedPlan( + sourceItems: sourceDock.items, + targetItems: targetDock.items, + reason: .sourceItemNotFound + ) + } + + if sourceDock.id == targetDock.id { + guard sourceDock.items == targetDock.items else { + return rejectedPlan( + sourceItems: sourceDock.items, + targetItems: targetDock.items, + reason: .inconsistentSameDockItems + ) + } + return planWithinDock( + sourceIndex: sourceIndex, + items: sourceDock.items, + destination: destination + ) + } + + let sourceItem = sourceDock.items[sourceIndex] + guard let insertionIndex = insertionIndex( + for: destination, + in: targetDock.items + ) else { + return rejectedPlan( + sourceItems: sourceDock.items, + targetItems: targetDock.items, + reason: missingTargetReason(for: destination) + ) + } + + if let duplicate = duplicate( + of: sourceItem, + in: targetDock.items, + operation: operation, + downloadsURL: resolvedDownloadsURL(downloadsURL) + ) { + return rejectedPlan( + sourceItems: sourceDock.items, + targetItems: targetDock.items, + reason: .duplicateItem(existingItemID: duplicate.id) + ) + } + + var sourceItems = sourceDock.items + var targetItems = targetDock.items + + switch operation { + case .move: + sourceItems.remove(at: sourceIndex) + targetItems.insert(sourceItem, at: insertionIndex) + return DockItemTransferPlan( + sourceItems: sourceItems, + targetItems: targetItems, + outcome: .moved(itemID: sourceItem.id) + ) + + case .copy: + let copiedItem = copyWithFreshIdentity( + sourceItem, + avoiding: sourceDock.items + targetDock.items + ) + targetItems.insert(copiedItem, at: insertionIndex) + return DockItemTransferPlan( + sourceItems: sourceItems, + targetItems: targetItems, + outcome: .copied(itemID: copiedItem.id) + ) + } + } + + private static func planWithinDock( + sourceIndex: Int, + items: [DockItem], + destination: DockItemTransferDestination + ) -> DockItemTransferPlan { + guard let targetIndex = insertionIndex( + for: destination, + in: items + ) else { + return rejectedPlan( + sourceItems: items, + targetItems: items, + reason: missingTargetReason(for: destination) + ) + } + + let finalIndex: Int + switch destination { + case .item: + finalIndex = targetIndex + case .trailing: + finalIndex = items.index(before: items.endIndex) + } + + guard sourceIndex != finalIndex else { + return DockItemTransferPlan( + sourceItems: items, + targetItems: items, + outcome: .unchanged + ) + } + + // Option only changes the operation between docks. Within one dock it + // keeps the familiar reorder behavior instead of creating duplicates. + var reorderedItems = items + let movedItem = reorderedItems.remove(at: sourceIndex) + reorderedItems.insert( + movedItem, + at: min(finalIndex, reorderedItems.endIndex) + ) + return DockItemTransferPlan( + sourceItems: reorderedItems, + targetItems: reorderedItems, + outcome: .moved(itemID: movedItem.id) + ) + } + + private static func insertionIndex( + for destination: DockItemTransferDestination, + in items: [DockItem] + ) -> Int? { + switch destination { + case let .item(targetItemID): + return items.firstIndex { $0.id == targetItemID } + case .trailing: + return items.endIndex + } + } + + private static func missingTargetReason( + for destination: DockItemTransferDestination + ) -> DockItemTransferRejection { + switch destination { + case let .item(targetItemID): + return .targetItemNotFound(targetItemID) + case .trailing: + preconditionFailure("A trailing destination always has an index") + } + } + + private static func duplicate( + of item: DockItem, + in items: [DockItem], + operation: DockItemTransferOperation, + downloadsURL: URL? + ) -> DockItem? { + if operation == .move, + let identicalItem = items.first(where: { $0.id == item.id }) + { + return identicalItem + } + + guard let identity = contentIdentity( + for: item, + downloadsURL: downloadsURL + ) else { + return nil + } + return items.first { + contentIdentity( + for: $0, + downloadsURL: downloadsURL + ) == identity + } + } + + private static func contentIdentity( + for item: DockItem, + downloadsURL: URL? + ) -> DockItemContentIdentity? { + if let source = item.smartStackSource { + if source == .downloads, let downloadsURL { + return .file(pathIdentity(for: downloadsURL)) + } + return .smartStack(source) + } + if let url = item.fileURL { + return .file(pathIdentity(for: url)) + } + return nil + } + + private static func pathIdentity(for url: URL) -> String { + url.standardizedFileURL + .resolvingSymlinksInPath() + .path + } + + private static func resolvedDownloadsURL(_ override: URL?) -> URL? { + override?.standardizedFileURL + ?? FileManager.default.urls( + for: .downloadsDirectory, + in: .userDomainMask + ).first?.standardizedFileURL + } + + private static func copyWithFreshIdentity( + _ item: DockItem, + avoiding existingItems: [DockItem] + ) -> DockItem { + let existingIDs = Set(existingItems.map(\.id)) + var copiedItem = item + repeat { + copiedItem.id = UUID() + } while existingIDs.contains(copiedItem.id) + return copiedItem + } + + private static func rejectedPlan( + sourceItems: [DockItem], + targetItems: [DockItem], + reason: DockItemTransferRejection + ) -> DockItemTransferPlan { + DockItemTransferPlan( + sourceItems: sourceItems, + targetItems: targetItems, + outcome: .rejected(reason) + ) + } +} + +private enum DockItemContentIdentity: Equatable { + case file(String) + case smartStack(SmartStackSource) +} diff --git a/Sources/FreeDock/Models/DockPanelResizeGeometry.swift b/Sources/FreeDock/Models/DockPanelResizeGeometry.swift new file mode 100644 index 0000000..ebca638 --- /dev/null +++ b/Sources/FreeDock/Models/DockPanelResizeGeometry.swift @@ -0,0 +1,54 @@ +import Foundation + +enum DockPanelResizeGeometry { + static func dockedEdge( + of frame: CGRect, + in visibleFrame: CGRect, + orientation: Orientation, + tolerance: CGFloat + ) -> DockScreenEdge? { + let candidates: [(edge: DockScreenEdge, distance: CGFloat)] + + if orientation == .horizontal { + candidates = [ + (.bottom, abs(frame.minY - visibleFrame.minY)), + (.top, abs(frame.maxY - visibleFrame.maxY)), + ] + } else { + candidates = [ + (.left, abs(frame.minX - visibleFrame.minX)), + (.right, abs(frame.maxX - visibleFrame.maxX)), + ] + } + + return candidates + .filter { $0.distance <= tolerance } + .min { $0.distance < $1.distance }? + .edge + } + + static func anchoredFrame( + from referenceFrame: CGRect, + size: CGSize, + orientation: Orientation, + dockedEdge: DockScreenEdge? + ) -> CGRect { + var frame = CGRect(origin: referenceFrame.origin, size: size) + + if dockedEdge == .right { + frame.origin.x = referenceFrame.maxX - size.width + } + + if orientation == .horizontal { + if dockedEdge == .top { + frame.origin.y = referenceFrame.maxY - size.height + } + } else if dockedEdge == .bottom { + frame.origin.y = referenceFrame.minY + } else { + frame.origin.y = referenceFrame.maxY - size.height + } + + return frame + } +} diff --git a/Sources/FreeDock/Models/DockPreferencesStore.swift b/Sources/FreeDock/Models/DockPreferencesStore.swift new file mode 100644 index 0000000..046f224 --- /dev/null +++ b/Sources/FreeDock/Models/DockPreferencesStore.swift @@ -0,0 +1,203 @@ +import SwiftUI + +enum DockPreferenceChange: Equatable { + case orientation(Orientation) + case iconSize(Double) + case magnificationEnabled(Bool) + case magnification(Double) + case itemSpacing(Double) + case appearance(DockAppearance) + case surfaceOpacity(Double) + case blurStyle(DockBlurStyle) + case cornerRadius(Double) + case shadowStrength(Double) + case showRunningIndicators(Bool) + case autoHideWhenDocked(Bool) + case autoHideDelay(Double) +} + +enum DockManagementAction: Equatable { + case activateProfile(UUID) + case createProfile + case renameActiveProfile + case deleteActiveProfile + case createDock(Orientation) + case renameDock(UUID) + case duplicateDock(UUID) + case deleteDock(UUID) + case setDockDisplay(UUID, UUID?) + case addDockItems(UUID) + case addSmartStack(UUID, SmartStackSource) + case clearRecentFiles + case importSystemDockApps(UUID) + case resetDockSettings(UUID) + case copyDockSettingsToAll(UUID) +} + +extension DockConfig { + mutating func apply(_ change: DockPreferenceChange) { + switch change { + case let .orientation(value): + orientation = value + case let .iconSize(value): + iconSize = Self.clamp(value, to: Self.iconSizeRange) + case let .magnificationEnabled(value): + magnificationEnabled = value + case let .magnification(value): + magnification = Self.clamp(value, to: Self.magnificationRange) + case let .itemSpacing(value): + itemSpacing = Self.clamp(value, to: Self.itemSpacingRange) + case let .appearance(value): + appearance = value + case let .surfaceOpacity(value): + surfaceOpacity = Self.clamp(value, to: Self.surfaceOpacityRange) + case let .blurStyle(value): + blurStyle = value + case let .cornerRadius(value): + cornerRadius = Self.clamp(value, to: Self.cornerRadiusRange) + case let .shadowStrength(value): + shadowStrength = Self.clamp(value, to: Self.shadowStrengthRange) + case let .showRunningIndicators(value): + showRunningIndicators = value + case let .autoHideWhenDocked(value): + autoHideWhenDocked = value + case let .autoHideDelay(value): + autoHideDelay = Self.clamp(value, to: Self.autoHideDelayRange) + } + normalizeDisplayPlacementEdge() + } +} + +@MainActor +final class DockPreferencesStore: ObservableObject { + @Published private(set) var profiles: [DockProfile] + @Published private(set) var activeProfileID: UUID + @Published private(set) var docks: [DockConfig] + @Published private(set) var displays: [DockDisplayDescriptor] + @Published private(set) var permissionState: PreferencesPermissionState + @Published var selectedDockID: UUID? + + private let onChange: (UUID, DockPreferenceChange) -> Void + private let onManagementAction: (DockManagementAction) -> Void + private let permissionSnapshot: () -> PreferencesPermissionSnapshot + private let onPermissionAction: (PreferencesPermissionKind) -> Void + private let onOpenPermissionSettings: (PreferencesPermissionKind) -> Void + + init( + profiles: [DockProfile], + activeProfileID: UUID, + displays: [DockDisplayDescriptor] = [], + permissionState: PreferencesPermissionState = PreferencesPermissionState(), + onChange: @escaping (UUID, DockPreferenceChange) -> Void, + onManagementAction: @escaping (DockManagementAction) -> Void, + permissionSnapshot: @escaping () -> PreferencesPermissionSnapshot = { + .checking + }, + onPermissionAction: @escaping (PreferencesPermissionKind) -> Void = { + _ in + }, + onOpenPermissionSettings: @escaping (PreferencesPermissionKind) -> Void = { + _ in + } + ) { + self.profiles = profiles + self.activeProfileID = activeProfileID + let activeDocks = profiles.first(where: { $0.id == activeProfileID })?.docks ?? [] + docks = activeDocks + self.displays = displays + self.permissionState = permissionState + selectedDockID = activeDocks.first?.id + self.onChange = onChange + self.onManagementAction = onManagementAction + self.permissionSnapshot = permissionSnapshot + self.onPermissionAction = onPermissionAction + self.onOpenPermissionSettings = onOpenPermissionSettings + } + + var activeProfileName: String { + profiles.first(where: { $0.id == activeProfileID })?.name ?? "Default" + } + + var canDeleteActiveProfile: Bool { + profiles.count > 1 + } + + var selectedDock: DockConfig? { + guard let selectedDockID else { return nil } + return docks.first(where: { $0.id == selectedDockID }) + } + + var canCopySelectedDockSettings: Bool { + selectedDockID != nil && docks.count > 1 + } + + func reload( + profiles: [DockProfile], + activeProfileID: UUID, + displays: [DockDisplayDescriptor]? = nil + ) { + let profileChanged = activeProfileID != self.activeProfileID + self.profiles = profiles + self.activeProfileID = activeProfileID + if let displays { + self.displays = displays + } + let activeDocks = profiles.first(where: { $0.id == activeProfileID })?.docks ?? [] + docks = activeDocks + + if profileChanged || !activeDocks.contains(where: { $0.id == selectedDockID }) { + selectedDockID = activeDocks.first?.id + } + } + + func replaceDock(_ dock: DockConfig) { + guard let index = docks.firstIndex(where: { $0.id == dock.id }) else { return } + docks[index] = dock + } + + func updateSelected(_ change: DockPreferenceChange) { + guard let selectedDockID, + let index = docks.firstIndex(where: { $0.id == selectedDockID }) + else { return } + + docks[index].apply(change) + onChange(selectedDockID, change) + } + + func perform(_ action: DockManagementAction) { + onManagementAction(action) + } + + func refreshPermissions() { + var updatedState = permissionState + guard updatedState.refresh(with: permissionSnapshot()) != .unchanged else { + return + } + permissionState = updatedState + } + + func performPermissionAction(_ permission: PreferencesPermissionKind) { + onPermissionAction(permission) + refreshPermissions() + } + + func openPermissionSettings(_ permission: PreferencesPermissionKind) { + onOpenPermissionSettings(permission) + } + + func isDisplayConnected(_ id: UUID) -> Bool { + displays.contains { $0.id == id } + } + + func displayLabel(for dock: DockConfig) -> String { + guard let placement = dock.displayPlacement else { return "Automatic" } + if let display = displays.first(where: { $0.id == placement.displayID }) { + return displayLabel(for: display) + } + return "\(placement.displayName ?? "Display") — Not Connected" + } + + func displayLabel(for display: DockDisplayDescriptor) -> String { + display.isPrimary ? "\(display.label) (Main)" : display.label + } +} diff --git a/Sources/FreeDock/Models/DockProfile.swift b/Sources/FreeDock/Models/DockProfile.swift new file mode 100644 index 0000000..e7a4fab --- /dev/null +++ b/Sources/FreeDock/Models/DockProfile.swift @@ -0,0 +1,13 @@ +import Foundation + +struct DockProfile: Codable, Identifiable, Equatable, Sendable { + var id: UUID + var name: String + var docks: [DockConfig] + + init(id: UUID = UUID(), name: String, docks: [DockConfig] = []) { + self.id = id + self.name = name + self.docks = docks + } +} diff --git a/Sources/FreeDock/Models/DockState.swift b/Sources/FreeDock/Models/DockState.swift index aa75758..d81c4c3 100644 --- a/Sources/FreeDock/Models/DockState.swift +++ b/Sources/FreeDock/Models/DockState.swift @@ -2,9 +2,54 @@ import SwiftUI @MainActor class DockState: ObservableObject { + @Published var name: String @Published var iconSize: Double + @Published var magnificationEnabled: Bool + @Published var magnification: Double + @Published var itemSpacing: Double + @Published var appearance: DockAppearance + @Published var surfaceOpacity: Double + @Published var blurStyle: DockBlurStyle + @Published var cornerRadius: Double + @Published var shadowStrength: Double + @Published var showRunningIndicators: Bool + @Published private(set) var isQuickLaunchPresented = false + @Published private(set) var quickLaunchFocusGeneration = 0 - init(iconSize: Double) { - self.iconSize = iconSize + init(config: DockConfig) { + name = config.name + iconSize = config.iconSize + magnificationEnabled = config.magnificationEnabled + magnification = config.magnification + itemSpacing = config.itemSpacing + appearance = config.appearance + surfaceOpacity = config.surfaceOpacity + blurStyle = config.blurStyle + cornerRadius = config.cornerRadius + shadowStrength = config.shadowStrength + showRunningIndicators = config.showRunningIndicators + } + + func apply(_ config: DockConfig) { + name = config.name + iconSize = config.iconSize + magnificationEnabled = config.magnificationEnabled + magnification = config.magnification + itemSpacing = config.itemSpacing + appearance = config.appearance + surfaceOpacity = config.surfaceOpacity + blurStyle = config.blurStyle + cornerRadius = config.cornerRadius + shadowStrength = config.shadowStrength + showRunningIndicators = config.showRunningIndicators + } + + func presentQuickLaunch() { + isQuickLaunchPresented = true + quickLaunchFocusGeneration &+= 1 + } + + func dismissQuickLaunch() { + isQuickLaunchPresented = false } } diff --git a/Sources/FreeDock/Models/FolderStackLoader.swift b/Sources/FreeDock/Models/FolderStackLoader.swift new file mode 100644 index 0000000..a311895 --- /dev/null +++ b/Sources/FreeDock/Models/FolderStackLoader.swift @@ -0,0 +1,287 @@ +import Foundation +import UniformTypeIdentifiers + +struct FolderStackEntry: Identifiable, Equatable, Sendable { + var id: URL { url } + + let url: URL + let name: String + let isFolder: Bool + let modificationDate: Date? + let recentlyOpenedAt: Date? + let kindDescription: String +} + +struct FolderStackSnapshot: Equatable, Sendable { + let entries: [FolderStackEntry] + let totalCount: Int + let isTruncated: Bool +} + +enum FolderStackLoaderError: LocalizedError, Equatable { + case notFolder(URL) + case missingFolderPath + case downloadsUnavailable + + var errorDescription: String? { + switch self { + case let .notFolder(url): + return "\(url.lastPathComponent) is not a readable folder." + case .missingFolderPath: + return "The folder stack has no folder path." + case .downloadsUnavailable: + return "The Downloads folder is unavailable." + } + } +} + +enum FolderStackLoader { + static let maximumItemCount = 200 + + /// Unified entry point for normal folders and source-driven smart stacks. + static func load( + item: DockItem, + recentFiles: [RecentFileRecord] = [], + downloadsURL: URL? = nil, + limit: Int = maximumItemCount + ) throws -> FolderStackSnapshot { + let options = item.folderOptions + ?? item.smartStackSource?.defaultOptions + ?? FolderStackOptions() + + switch item.smartStackSource { + case .recentFiles: + return load( + recentFiles: recentFiles, + options: options, + limit: limit + ) + + case .downloads: + guard let downloadsURL = resolvedDownloadsURL(downloadsURL) else { + throw FolderStackLoaderError.downloadsUnavailable + } + return try load( + folderURL: downloadsURL, + options: options, + limit: limit + ) + + case nil: + guard let folderURL = item.fileURL else { + throw FolderStackLoaderError.missingFolderPath + } + return try load( + folderURL: folderURL, + options: options, + limit: limit + ) + } + } + + /// Loads a fresh snapshot every time so stacks track changes on disk. + static func load( + folderURL: URL, + options: FolderStackOptions, + limit: Int = maximumItemCount + ) throws -> FolderStackSnapshot { + guard folderURL.isFileURL else { + throw FolderStackLoaderError.notFolder(folderURL) + } + + let standardizedURL = folderURL.standardizedFileURL + let folderValues = try? standardizedURL.resourceValues( + forKeys: [.contentTypeKey, .isDirectoryKey, .isPackageKey] + ) + let isApplication = + standardizedURL.pathExtension.caseInsensitiveCompare("app") + == .orderedSame + || folderValues?.contentType?.conforms(to: .application) == true + guard folderValues?.isDirectory == true, + folderValues?.isPackage != true, + !isApplication + else { + throw FolderStackLoaderError.notFolder(standardizedURL) + } + + let enumerationOptions: FileManager.DirectoryEnumerationOptions = + options.showHiddenFiles ? [] : [.skipsHiddenFiles] + let resourceKeys: [URLResourceKey] = [ + .contentTypeKey, + .isDirectoryKey, + .isPackageKey, + .localizedNameKey, + .contentModificationDateKey, + ] + let childURLs = try FileManager.default.contentsOfDirectory( + at: standardizedURL, + includingPropertiesForKeys: resourceKeys, + options: enumerationOptions + ) + let sortedEntries = childURLs + .map { entry(for: $0) } + .sorted { lhs, rhs in + ordered(lhs, before: rhs, by: options.sortOrder) + } + + let effectiveLimit = min(max(limit, 0), maximumItemCount) + let visibleEntries = Array(sortedEntries.prefix(effectiveLimit)) + return FolderStackSnapshot( + entries: visibleEntries, + totalCount: sortedEntries.count, + isTruncated: sortedEntries.count > visibleEntries.count + ) + } + + static func load( + recentFiles: [RecentFileRecord], + options: FolderStackOptions = SmartStackSource.recentFiles.defaultOptions, + limit: Int = maximumItemCount + ) -> FolderStackSnapshot { + let records = RecentFileHistoryPlanner.normalized( + recentFiles, + limit: RecentFileHistoryPlanner.maximumLimit + ) + let entries = records.compactMap { record -> FolderStackEntry? in + guard let url = record.fileURL, + let item = DockItem.pinnedItem(at: url), + item.kind == .document, + options.showHiddenFiles || !isHidden(url) + else { + return nil + } + return entry( + for: url, + nameOverride: record.displayName, + recentlyOpenedAt: record.lastOpenedAt + ) + } + .sorted { lhs, rhs in + ordered(lhs, before: rhs, by: options.sortOrder) + } + + let effectiveLimit = min(max(limit, 0), maximumItemCount) + let visibleEntries = Array(entries.prefix(effectiveLimit)) + return FolderStackSnapshot( + entries: visibleEntries, + totalCount: entries.count, + isTruncated: entries.count > visibleEntries.count + ) + } + + private static func entry( + for url: URL, + nameOverride: String? = nil, + recentlyOpenedAt: Date? = nil + ) -> FolderStackEntry { + let values = try? url.resourceValues( + forKeys: [ + .contentTypeKey, + .isDirectoryKey, + .isPackageKey, + .localizedNameKey, + .contentModificationDateKey, + ] + ) + let isPackage = values?.isPackage ?? false + let isDirectory = values?.isDirectory ?? false + let contentType = values?.contentType + let isApplication = + isDirectory + && ( + url.pathExtension.caseInsensitiveCompare("app") == .orderedSame + || contentType?.conforms(to: .application) == true + ) + let isFolder = isDirectory && !isPackage && !isApplication + let kindDescription: String + if isFolder { + kindDescription = "Folder" + } else if isApplication { + kindDescription = "Application" + } else { + kindDescription = contentType?.localizedDescription + ?? (isPackage ? "Package" : "Document") + } + + return FolderStackEntry( + url: url.standardizedFileURL, + name: normalizedName(nameOverride) + ?? values?.localizedName + ?? url.lastPathComponent, + isFolder: isFolder, + modificationDate: values?.contentModificationDate, + recentlyOpenedAt: recentlyOpenedAt, + kindDescription: kindDescription + ) + } + + private static func ordered( + _ lhs: FolderStackEntry, + before rhs: FolderStackEntry, + by sortOrder: FolderStackOptions.SortOrder + ) -> Bool { + switch sortOrder { + case .name: + return orderedByName(lhs, before: rhs) + + case .dateModified: + let lhsDate = lhs.modificationDate ?? .distantPast + let rhsDate = rhs.modificationDate ?? .distantPast + if lhsDate != rhsDate { + return lhsDate > rhsDate + } + return orderedByName(lhs, before: rhs) + + case .kind: + let comparison = lhs.kindDescription.localizedStandardCompare( + rhs.kindDescription + ) + if comparison != .orderedSame { + return comparison == .orderedAscending + } + return orderedByName(lhs, before: rhs) + + case .recentlyOpened: + let lhsDate = lhs.recentlyOpenedAt + ?? lhs.modificationDate + ?? .distantPast + let rhsDate = rhs.recentlyOpenedAt + ?? rhs.modificationDate + ?? .distantPast + if lhsDate != rhsDate { + return lhsDate > rhsDate + } + return orderedByName(lhs, before: rhs) + } + } + + private static func orderedByName( + _ lhs: FolderStackEntry, + before rhs: FolderStackEntry + ) -> Bool { + let comparison = lhs.name.localizedStandardCompare(rhs.name) + if comparison != .orderedSame { + return comparison == .orderedAscending + } + return lhs.url.path < rhs.url.path + } + + private static func isHidden(_ url: URL) -> Bool { + let values = try? url.resourceValues(forKeys: [.isHiddenKey]) + return values?.isHidden == true || url.lastPathComponent.hasPrefix(".") + } + + private static func normalizedName(_ value: String?) -> String? { + guard let value else { return nil } + let normalized = value.trimmingCharacters(in: .whitespacesAndNewlines) + return normalized.isEmpty ? nil : normalized + } + + private static func resolvedDownloadsURL(_ override: URL?) -> URL? { + override?.standardizedFileURL + ?? FileManager.default.urls( + for: .downloadsDirectory, + in: .userDomainMask + ).first?.standardizedFileURL + } +} diff --git a/Sources/FreeDock/Models/FolderStackPanelGeometry.swift b/Sources/FreeDock/Models/FolderStackPanelGeometry.swift new file mode 100644 index 0000000..64a3645 --- /dev/null +++ b/Sources/FreeDock/Models/FolderStackPanelGeometry.swift @@ -0,0 +1,193 @@ +import Foundation + +enum FolderStackPanelSide: Equatable, Sendable { + case above + case below + case left + case right + + var opposite: FolderStackPanelSide { + switch self { + case .above: return .below + case .below: return .above + case .left: return .right + case .right: return .left + } + } +} + +struct FolderStackPanelPlacement: Equatable, Sendable { + let frame: CGRect + let side: FolderStackPanelSide +} + +enum FolderStackPanelGeometry { + private static let minimumUsableHeight: CGFloat = 180 + private static let minimumUsableWidth: CGFloat = 260 + + static func placement( + size: CGSize, + sourceRect: CGRect, + dockFrame: CGRect, + visibleFrame: CGRect, + orientation: Orientation, + gap: CGFloat = 10, + inset: CGFloat = 12 + ) -> FolderStackPanelPlacement { + let availableFrame = visibleFrame.insetBy(dx: inset, dy: inset) + let boundedSize = CGSize( + width: min(size.width, max(1, availableFrame.width)), + height: min(size.height, max(1, availableFrame.height)) + ) + let preferredSide: FolderStackPanelSide + if orientation == .horizontal { + preferredSide = dockFrame.midY <= visibleFrame.midY + ? .above + : .below + } else { + preferredSide = dockFrame.midX <= visibleFrame.midX + ? .right + : .left + } + + let preferredCapacity = availableExtent( + on: preferredSide, + from: sourceRect, + in: availableFrame, + gap: gap + ) + let oppositeCapacity = availableExtent( + on: preferredSide.opposite, + from: sourceRect, + in: availableFrame, + gap: gap + ) + let requestedExtent = preferredSide == .above + || preferredSide == .below + ? boundedSize.height + : boundedSize.width + let side = preferredCapacity < requestedExtent + && oppositeCapacity > preferredCapacity + ? preferredSide.opposite + : preferredSide + let sideCapacity = availableExtent( + on: side, + from: sourceRect, + in: availableFrame, + gap: gap + ) + let minimumUsableExtent: CGFloat + switch side { + case .above, .below: + minimumUsableExtent = min( + boundedSize.height, + minimumUsableHeight + ) + case .left, .right: + minimumUsableExtent = min( + boundedSize.width, + minimumUsableWidth + ) + } + let shouldFitBesideSource = sideCapacity >= minimumUsableExtent + let fittedSize: CGSize + switch side { + case .above, .below: + fittedSize = CGSize( + width: boundedSize.width, + height: shouldFitBesideSource + ? min(boundedSize.height, max(1, sideCapacity)) + : boundedSize.height + ) + case .left, .right: + fittedSize = CGSize( + width: shouldFitBesideSource + ? min(boundedSize.width, max(1, sideCapacity)) + : boundedSize.width, + height: boundedSize.height + ) + } + let frame = clamped( + candidateFrame( + size: fittedSize, + sourceRect: sourceRect, + side: side, + gap: gap + ), + to: availableFrame + ) + return FolderStackPanelPlacement(frame: frame, side: side) + } + + private static func candidateFrame( + size: CGSize, + sourceRect: CGRect, + side: FolderStackPanelSide, + gap: CGFloat + ) -> CGRect { + switch side { + case .above: + return CGRect( + x: sourceRect.midX - size.width / 2, + y: sourceRect.maxY + gap, + width: size.width, + height: size.height + ) + case .below: + return CGRect( + x: sourceRect.midX - size.width / 2, + y: sourceRect.minY - gap - size.height, + width: size.width, + height: size.height + ) + case .left: + return CGRect( + x: sourceRect.minX - gap - size.width, + y: sourceRect.midY - size.height / 2, + width: size.width, + height: size.height + ) + case .right: + return CGRect( + x: sourceRect.maxX + gap, + y: sourceRect.midY - size.height / 2, + width: size.width, + height: size.height + ) + } + } + + private static func availableExtent( + on side: FolderStackPanelSide, + from sourceRect: CGRect, + in bounds: CGRect, + gap: CGFloat + ) -> CGFloat { + switch side { + case .above: + return max(0, bounds.maxY - sourceRect.maxY - gap) + case .below: + return max(0, sourceRect.minY - gap - bounds.minY) + case .left: + return max(0, sourceRect.minX - gap - bounds.minX) + case .right: + return max(0, bounds.maxX - sourceRect.maxX - gap) + } + } + + private static func clamped( + _ frame: CGRect, + to bounds: CGRect + ) -> CGRect { + var frame = frame + frame.origin.x = min( + max(frame.minX, bounds.minX), + max(bounds.minX, bounds.maxX - frame.width) + ) + frame.origin.y = min( + max(frame.minY, bounds.minY), + max(bounds.minY, bounds.maxY - frame.height) + ) + return frame + } +} diff --git a/Sources/FreeDock/Models/PreferencesPermissionState.swift b/Sources/FreeDock/Models/PreferencesPermissionState.swift new file mode 100644 index 0000000..6bb7f07 --- /dev/null +++ b/Sources/FreeDock/Models/PreferencesPermissionState.swift @@ -0,0 +1,195 @@ +import Foundation + +enum PreferencesPermissionKind: + String, CaseIterable, Hashable, Identifiable, Sendable +{ + case accessibility + case screenRecording + + var id: Self { self } + + var title: String { + switch self { + case .accessibility: + return "Accessibility" + case .screenRecording: + return "Screen Recording" + } + } +} + +enum PreferencesPermissionStatus: Equatable, Sendable { + case checking + case notGranted + case granted +} + +struct PreferencesPermissionSnapshot: Equatable, Sendable { + let accessibility: PreferencesPermissionStatus + let screenRecording: PreferencesPermissionStatus + + static let checking = PreferencesPermissionSnapshot( + accessibility: .checking, + screenRecording: .checking + ) + + init( + accessibility: PreferencesPermissionStatus, + screenRecording: PreferencesPermissionStatus + ) { + self.accessibility = accessibility + self.screenRecording = screenRecording + } + + init( + accessibilityGranted: Bool, + screenRecordingGranted: Bool + ) { + accessibility = + accessibilityGranted ? .granted : .notGranted + screenRecording = + screenRecordingGranted ? .granted : .notGranted + } + + func status( + for permission: PreferencesPermissionKind + ) -> PreferencesPermissionStatus { + switch permission { + case .accessibility: + return accessibility + case .screenRecording: + return screenRecording + } + } +} + +struct PreferencesPermissionPresentation: + Equatable, Identifiable, Sendable +{ + let permission: PreferencesPermissionKind + let status: PreferencesPermissionStatus + let statusLabel: String + let detail: String + let actionTitle: String + let isActionEnabled: Bool + + var id: PreferencesPermissionKind { permission } + var title: String { permission.title } + + init( + permission: PreferencesPermissionKind, + status: PreferencesPermissionStatus + ) { + self.permission = permission + self.status = status + + switch status { + case .checking: + statusLabel = "Checking…" + actionTitle = "Checking…" + isActionEnabled = false + case .notGranted: + statusLabel = "Not Granted" + actionTitle = Self.enableActionTitle(for: permission) + isActionEnabled = true + case .granted: + statusLabel = "Granted" + actionTitle = Self.manageActionTitle(for: permission) + isActionEnabled = true + } + detail = Self.detail(for: permission, status: status) + } + + private static func enableActionTitle( + for permission: PreferencesPermissionKind + ) -> String { + switch permission { + case .accessibility: + return "Enable Accessibility…" + case .screenRecording: + return "Enable Screen Recording…" + } + } + + private static func manageActionTitle( + for permission: PreferencesPermissionKind + ) -> String { + switch permission { + case .accessibility: + return "Manage Accessibility…" + case .screenRecording: + return "Manage Screen Recording…" + } + } + + private static func detail( + for permission: PreferencesPermissionKind, + status: PreferencesPermissionStatus + ) -> String { + switch (permission, status) { + case (.accessibility, .checking): + return "Checking whether FreeDock can discover and focus app windows." + case (.accessibility, .notGranted): + return + "Required for window switching. FreeDock uses Accessibility only to discover and focus app windows." + case (.accessibility, .granted): + return "Window switching is enabled. FreeDock can discover and focus app windows." + case (.screenRecording, .checking): + return "Checking whether FreeDock can show live window thumbnails." + case (.screenRecording, .notGranted): + return "Optional. Enable it for live thumbnails; window switching still works without it." + case (.screenRecording, .granted): + return "Live window thumbnails are enabled." + } + } +} + +enum PreferencesPermissionRefreshResult: Equatable, Sendable { + case unchanged + case changed(Set) +} + +struct PreferencesPermissionState: Equatable, Sendable { + private(set) var snapshot: PreferencesPermissionSnapshot + + init( + snapshot: PreferencesPermissionSnapshot = .checking + ) { + self.snapshot = snapshot + } + + var presentations: [PreferencesPermissionPresentation] { + PreferencesPermissionKind.allCases.map { + presentation(for: $0) + } + } + + func presentation( + for permission: PreferencesPermissionKind + ) -> PreferencesPermissionPresentation { + PreferencesPermissionPresentation( + permission: permission, + status: snapshot.status(for: permission) + ) + } + + @discardableResult + mutating func refresh( + with newSnapshot: PreferencesPermissionSnapshot + ) -> PreferencesPermissionRefreshResult { + var changedPermissions = + Set() + for permission in PreferencesPermissionKind.allCases + where snapshot.status(for: permission) + != newSnapshot.status(for: permission) + { + changedPermissions.insert(permission) + } + + guard !changedPermissions.isEmpty else { + return .unchanged + } + snapshot = newSnapshot + return .changed(changedPermissions) + } +} diff --git a/Sources/FreeDock/Models/QuickLaunchDockRouter.swift b/Sources/FreeDock/Models/QuickLaunchDockRouter.swift new file mode 100644 index 0000000..90fd54f --- /dev/null +++ b/Sources/FreeDock/Models/QuickLaunchDockRouter.swift @@ -0,0 +1,112 @@ +import CoreGraphics +import Foundation + +struct QuickLaunchDockCandidate: Equatable, Sendable { + let dockID: UUID + let frame: CGRect + let configOrder: Int +} + +enum QuickLaunchDockRouter { + static func targetDockID( + for pointer: CGPoint, + pointerScreenFrame: CGRect?, + candidates: [QuickLaunchDockCandidate] + ) -> UUID? { + let indexedCandidates = candidates.enumerated().map { + IndexedCandidate(inputOrder: $0.offset, candidate: $0.element) + } + guard !indexedCandidates.isEmpty else { + return nil + } + + let screenCandidates: [IndexedCandidate] + if let pointerScreenFrame, + let screenFrame = finiteStandardized(pointerScreenFrame) + { + screenCandidates = indexedCandidates.filter { + isAssociated($0.candidate.frame, with: screenFrame) + } + } else { + screenCandidates = [] + } + + guard !screenCandidates.isEmpty else { + return indexedCandidates.min(by: configOrderPrecedes)? + .candidate.dockID + } + + return screenCandidates.min { + let leftDistance = squaredDistance( + from: pointer, + to: $0.candidate.frame + ) + let rightDistance = squaredDistance( + from: pointer, + to: $1.candidate.frame + ) + if leftDistance != rightDistance { + return leftDistance < rightDistance + } + return configOrderPrecedes($0, $1) + }?.candidate.dockID + } + + private struct IndexedCandidate { + let inputOrder: Int + let candidate: QuickLaunchDockCandidate + } + + private static func isAssociated( + _ candidateFrame: CGRect, + with screenFrame: CGRect + ) -> Bool { + guard let candidateFrame = finiteStandardized(candidateFrame) else { + return false + } + let center = CGPoint( + x: candidateFrame.midX, + y: candidateFrame.midY + ) + return candidateFrame.intersects(screenFrame) + || screenFrame.contains(center) + } + + private static func squaredDistance( + from point: CGPoint, + to frame: CGRect + ) -> CGFloat { + guard point.x.isFinite, + point.y.isFinite, + let frame = finiteStandardized(frame) + else { + return .infinity + } + + let deltaX = max(frame.minX - point.x, 0, point.x - frame.maxX) + let deltaY = max(frame.minY - point.y, 0, point.y - frame.maxY) + return deltaX * deltaX + deltaY * deltaY + } + + private static func finiteStandardized(_ frame: CGRect) -> CGRect? { + let frame = frame.standardized + guard frame.origin.x.isFinite, + frame.origin.y.isFinite, + frame.width.isFinite, + frame.height.isFinite + else { + return nil + } + return frame + } + + private static func configOrderPrecedes( + _ lhs: IndexedCandidate, + _ rhs: IndexedCandidate + ) -> Bool { + if lhs.candidate.configOrder != rhs.candidate.configOrder { + return lhs.candidate.configOrder < rhs.candidate.configOrder + } + return lhs.inputOrder < rhs.inputOrder + } +} diff --git a/Sources/FreeDock/Models/QuickLaunchSearch.swift b/Sources/FreeDock/Models/QuickLaunchSearch.swift new file mode 100644 index 0000000..1c8b08b --- /dev/null +++ b/Sources/FreeDock/Models/QuickLaunchSearch.swift @@ -0,0 +1,288 @@ +import Foundation + +enum QuickLaunchMatchKind: Int, Equatable, Sendable { + case exact + case prefix + case wordPrefix + case substring +} + +struct QuickLaunchSearchResult: Identifiable, Equatable, Sendable { + let item: DockItem + let displayLabel: String + let dockIndex: Int + let matchKind: QuickLaunchMatchKind? + + var id: DockItem.ID { + item.id + } +} + +enum QuickLaunchSearch { + static func results( + in items: [DockItem], + matching query: String + ) -> [QuickLaunchSearchResult] { + let normalizedQuery = normalize(query) + let candidates = items.enumerated().compactMap { index, item in + candidate(for: item, at: index) + } + + guard !normalizedQuery.isEmpty else { + return candidates.map { + QuickLaunchSearchResult( + item: $0.item, + displayLabel: $0.displayLabel, + dockIndex: $0.dockIndex, + matchKind: nil + ) + } + } + + var buckets = Array( + repeating: [QuickLaunchSearchResult](), + count: QuickLaunchMatchKind.substring.rawValue + 1 + ) + + for candidate in candidates { + guard let matchKind = bestMatch( + for: normalizedQuery, + in: candidate.searchableFields + ) else { + continue + } + + buckets[matchKind.rawValue].append( + QuickLaunchSearchResult( + item: candidate.item, + displayLabel: candidate.displayLabel, + dockIndex: candidate.dockIndex, + matchKind: matchKind + ) + ) + } + + return buckets.flatMap { $0 } + } + + static func displayLabel(for item: DockItem) -> String { + if let label = trimmedNonEmpty(item.label) { + return label + } + if let smartStackSource = item.smartStackSource { + return smartStackSource.defaultLabel + } + + let component = URL(fileURLWithPath: item.path).lastPathComponent + guard !component.isEmpty else { + return "" + } + if item.kind == .application { + return (component as NSString).deletingPathExtension + } + return component + } + + private struct Candidate { + let item: DockItem + let displayLabel: String + let dockIndex: Int + let searchableFields: [String] + } + + private static func candidate( + for item: DockItem, + at index: Int + ) -> Candidate? { + guard !item.isSeparator else { + return nil + } + + let normalizedPath = normalize(item.path) + guard item.smartStackSource != nil || !normalizedPath.isEmpty else { + return nil + } + + let displayLabel = displayLabel(for: item) + let normalizedLabel = normalize(displayLabel) + let fields = [normalizedLabel, normalizedPath].filter { !$0.isEmpty } + + guard !fields.isEmpty else { + return nil + } + return Candidate( + item: item, + displayLabel: displayLabel, + dockIndex: index, + searchableFields: fields + ) + } + + private static func bestMatch( + for query: String, + in fields: [String] + ) -> QuickLaunchMatchKind? { + let phraseMatch = bestFieldMatch(for: query, in: fields) + let tokens = query.split(separator: " ").map(String.init) + var tokenAggregate = QuickLaunchMatchKind.exact + + for token in tokens { + guard let tokenMatch = bestFieldMatch(for: token, in: fields) else { + return nil + } + if tokenMatch.rawValue > tokenAggregate.rawValue { + tokenAggregate = tokenMatch + } + } + + guard let phraseMatch else { + return tokenAggregate + } + return phraseMatch.rawValue < tokenAggregate.rawValue + ? phraseMatch + : tokenAggregate + } + + private static func bestFieldMatch( + for query: String, + in fields: [String] + ) -> QuickLaunchMatchKind? { + var best: QuickLaunchMatchKind? + + for field in fields { + let matchKind: QuickLaunchMatchKind? + if field == query { + matchKind = .exact + } else if field.hasPrefix(query) { + matchKind = .prefix + } else if hasWordPrefix(query, in: field) { + matchKind = .wordPrefix + } else if field.contains(query) { + matchKind = .substring + } else { + matchKind = nil + } + + guard let matchKind else { + continue + } + if best == nil || matchKind.rawValue < best!.rawValue { + best = matchKind + } + } + return best + } + + private static func hasWordPrefix( + _ query: String, + in field: String + ) -> Bool { + var index = field.startIndex + while index < field.endIndex { + let isBoundary: Bool + if index == field.startIndex { + isBoundary = true + } else { + let previousIndex = field.index(before: index) + isBoundary = !isWordCharacter(field[previousIndex]) + } + + if isBoundary, field[index...].hasPrefix(query) { + return true + } + field.formIndex(after: &index) + } + return false + } + + private static func isWordCharacter(_ character: Character) -> Bool { + character.isLetter || character.isNumber + } + + private static func normalize(_ value: String) -> String { + let locale = Locale(identifier: "en_US_POSIX") + return value + .trimmingCharacters(in: .whitespacesAndNewlines) + .split(whereSeparator: \.isWhitespace) + .joined(separator: " ") + .folding( + options: [.caseInsensitive, .diacriticInsensitive, .widthInsensitive], + locale: locale + ) + .lowercased(with: locale) + } + + private static func trimmedNonEmpty(_ value: String?) -> String? { + guard let trimmed = value?.trimmingCharacters( + in: .whitespacesAndNewlines + ), !trimmed.isEmpty else { + return nil + } + return trimmed + } +} + +enum QuickLaunchNavigationDirection: Sendable { + case next + case previous +} + +struct QuickLaunchSelection: Equatable, Sendable { + private(set) var selectedItemID: DockItem.ID? + + init(selectedItemID: DockItem.ID? = nil) { + self.selectedItemID = selectedItemID + } + + mutating func reconcile(with results: [QuickLaunchSearchResult]) { + guard !results.isEmpty else { + selectedItemID = nil + return + } + if let selectedItemID, + results.contains(where: { $0.id == selectedItemID }) + { + return + } + selectedItemID = results[0].id + } + + mutating func move( + _ direction: QuickLaunchNavigationDirection, + in results: [QuickLaunchSearchResult] + ) { + guard !results.isEmpty else { + selectedItemID = nil + return + } + + guard let selectedItemID, + let currentIndex = results.firstIndex( + where: { $0.id == selectedItemID } + ) + else { + self.selectedItemID = direction == .next + ? results[0].id + : results[results.count - 1].id + return + } + + let nextIndex: Int + switch direction { + case .next: + nextIndex = (currentIndex + 1) % results.count + case .previous: + nextIndex = (currentIndex - 1 + results.count) % results.count + } + self.selectedItemID = results[nextIndex].id + } + + func selectedResult( + in results: [QuickLaunchSearchResult] + ) -> QuickLaunchSearchResult? { + guard let selectedItemID else { + return nil + } + return results.first { $0.id == selectedItemID } + } +} diff --git a/Sources/FreeDock/Models/RecentFileHistory.swift b/Sources/FreeDock/Models/RecentFileHistory.swift new file mode 100644 index 0000000..182beea --- /dev/null +++ b/Sources/FreeDock/Models/RecentFileHistory.swift @@ -0,0 +1,142 @@ +import Foundation + +struct RecentFileRecord: Codable, Identifiable, Equatable, Hashable, Sendable { + var id: String { path } + + var path: String + var displayName: String? + var lastOpenedAt: Date + + init( + path: String, + displayName: String? = nil, + lastOpenedAt: Date + ) { + self.path = path + self.displayName = displayName + self.lastOpenedAt = lastOpenedAt + } + + var fileURL: URL? { + guard path.hasPrefix("/") else { return nil } + return URL(fileURLWithPath: path) + } + + private enum CodingKeys: String, CodingKey { + case path + case displayName + case lastOpenedAt + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + path = (try? container.decode(String.self, forKey: .path)) ?? "" + displayName = try? container.decode( + String.self, + forKey: .displayName + ) + lastOpenedAt = + (try? container.decode(Date.self, forKey: .lastOpenedAt)) + ?? .distantPast + } +} + +struct RecentFileHistoryPlan: Equatable, Sendable { + let records: [RecentFileRecord] + let didRecord: Bool +} + +enum RecentFileHistoryPlanner { + static let defaultLimit = 50 + static let maximumLimit = 100 + + /// Records only existing local documents. Callers decide which FreeDock open + /// actions should count; applications, folders, and remote URLs are rejected. + static func planRecording( + url: URL, + displayName: String? = nil, + openedAt: Date = Date(), + records: [RecentFileRecord], + limit: Int = defaultLimit + ) -> RecentFileHistoryPlan { + let effectiveLimit = clampedLimit(limit) + let existingRecords = normalized(records, limit: effectiveLimit) + guard effectiveLimit > 0, + url.isFileURL, + let item = DockItem.pinnedItem(at: url), + item.kind == .document + else { + return RecentFileHistoryPlan( + records: existingRecords, + didRecord: false + ) + } + + let standardizedURL = url.standardizedFileURL + let record = RecentFileRecord( + path: standardizedURL.path, + displayName: normalizedName(displayName) ?? item.label, + lastOpenedAt: openedAt + ) + let updatedRecords = normalized( + [record] + records, + limit: effectiveLimit + ) + let didRecord = updatedRecords.contains { + pathIdentity(for: $0.path) == pathIdentity(for: record.path) + && $0.lastOpenedAt == openedAt + } + return RecentFileHistoryPlan( + records: updatedRecords, + didRecord: didRecord + ) + } + + /// Produces a deterministic newest-first, canonical-path-unique history. + static func normalized( + _ records: [RecentFileRecord], + limit: Int = defaultLimit + ) -> [RecentFileRecord] { + let effectiveLimit = clampedLimit(limit) + guard effectiveLimit > 0 else { return [] } + + let sortedRecords = records + .filter { $0.fileURL != nil } + .sorted { lhs, rhs in + if lhs.lastOpenedAt != rhs.lastOpenedAt { + return lhs.lastOpenedAt > rhs.lastOpenedAt + } + return lhs.path < rhs.path + } + var seenPaths = Set() + var result: [RecentFileRecord] = [] + result.reserveCapacity(min(sortedRecords.count, effectiveLimit)) + + for record in sortedRecords { + let identity = pathIdentity(for: record.path) + guard seenPaths.insert(identity).inserted else { continue } + result.append(record) + if result.count == effectiveLimit { + break + } + } + return result + } + + static func pathIdentity(for path: String) -> String { + URL(fileURLWithPath: path) + .standardizedFileURL + .resolvingSymlinksInPath() + .path + } + + private static func clampedLimit(_ limit: Int) -> Int { + min(max(limit, 0), maximumLimit) + } + + private static func normalizedName(_ value: String?) -> String? { + guard let value else { return nil } + let normalized = value.trimmingCharacters(in: .whitespacesAndNewlines) + return normalized.isEmpty ? nil : normalized + } +} diff --git a/Sources/FreeDock/Models/WindowPreviewHoverState.swift b/Sources/FreeDock/Models/WindowPreviewHoverState.swift new file mode 100644 index 0000000..ff0194d --- /dev/null +++ b/Sources/FreeDock/Models/WindowPreviewHoverState.swift @@ -0,0 +1,221 @@ +import Foundation + +struct WindowPreviewHoverSession: Equatable, Sendable { + let id: UUID + let itemID: DockItem.ID + var anchor: CGRect +} + +struct WindowPreviewHoverStart: Equatable, Sendable { + let session: WindowPreviewHoverSession + let openDelayToken: UUID + let shouldDiscover: Bool +} + +/// Pure hover coordination for an asynchronously loaded window-preview panel. +/// +/// Discovery starts immediately, but presentation waits for both a successful +/// result and the open delay. Session and timer tokens keep late callbacks from +/// reopening or closing a preview after the pointer has moved to another item. +struct WindowPreviewHoverState: Equatable, Sendable { + enum Phase: Equatable, Sendable { + case idle + case pending + case loading + case presented + } + + private(set) var phase: Phase = .idle + private(set) var session: WindowPreviewHoverSession? + private(set) var openDelayToken: UUID? + private(set) var closeDelayToken: UUID? + private(set) var isPointerOverIcon = false + private(set) var isPointerOverPanel = false + private(set) var discoveredWindowCount: Int? + private var didOpenDelayElapse = false + + var isPresented: Bool { + phase == .presented + } + + mutating func beginIconHover( + itemID: DockItem.ID, + anchor: CGRect, + sessionID: UUID = UUID(), + openToken: UUID = UUID() + ) -> WindowPreviewHoverStart { + if var current = session, current.itemID == itemID { + current.anchor = anchor + session = current + isPointerOverIcon = true + closeDelayToken = nil + return WindowPreviewHoverStart( + session: current, + openDelayToken: openDelayToken ?? openToken, + shouldDiscover: false + ) + } + + let next = WindowPreviewHoverSession( + id: sessionID, + itemID: itemID, + anchor: anchor + ) + phase = .pending + session = next + openDelayToken = openToken + closeDelayToken = nil + isPointerOverIcon = true + isPointerOverPanel = false + discoveredWindowCount = nil + didOpenDelayElapse = false + return WindowPreviewHoverStart( + session: next, + openDelayToken: openToken, + shouldDiscover: true + ) + } + + @discardableResult + mutating func markDiscoveryStarted( + sessionID: UUID + ) -> Bool { + guard session?.id == sessionID, + phase == .pending + else { + return false + } + phase = .loading + return true + } + + /// Returns true exactly when this result makes the preview presentable. + @discardableResult + mutating func completeDiscovery( + sessionID: UUID, + windowCount: Int + ) -> Bool { + guard session?.id == sessionID, + phase == .pending || phase == .loading + else { + return false + } + discoveredWindowCount = max(0, windowCount) + return reconcilePresentation() + } + + /// Returns true exactly when this timer makes the preview presentable. + @discardableResult + mutating func openDelayElapsed( + token: UUID + ) -> Bool { + guard token == openDelayToken, + phase == .pending || phase == .loading + else { + return false + } + didOpenDelayElapse = true + openDelayToken = nil + return reconcilePresentation() + } + + /// Leaving before presentation cancels immediately. A presented preview + /// gets a close token so the pointer can cross the icon-to-panel gap. + mutating func endIconHover( + closeToken: UUID = UUID() + ) -> UUID? { + isPointerOverIcon = false + guard phase == .presented else { + reset() + return nil + } + guard !isPointerOverPanel else { return nil } + closeDelayToken = closeToken + return closeToken + } + + @discardableResult + mutating func panelEntered( + sessionID: UUID + ) -> Bool { + guard session?.id == sessionID, + phase == .presented + else { + return false + } + isPointerOverPanel = true + closeDelayToken = nil + return true + } + + mutating func panelExited( + sessionID: UUID, + closeToken: UUID = UUID() + ) -> UUID? { + guard session?.id == sessionID, + phase == .presented + else { + return nil + } + isPointerOverPanel = false + guard !isPointerOverIcon else { return nil } + closeDelayToken = closeToken + return closeToken + } + + /// Returns true only when the current preview should actually close. + @discardableResult + mutating func closeDelayElapsed( + token: UUID + ) -> Bool { + guard token == closeDelayToken, + phase == .presented, + !isPointerOverIcon, + !isPointerOverPanel + else { + return false + } + reset() + return true + } + + @discardableResult + mutating func updateAnchor( + itemID: DockItem.ID, + anchor: CGRect + ) -> Bool { + guard var current = session, + current.itemID == itemID + else { + return false + } + current.anchor = anchor + session = current + return true + } + + mutating func reset() { + phase = .idle + session = nil + openDelayToken = nil + closeDelayToken = nil + isPointerOverIcon = false + isPointerOverPanel = false + discoveredWindowCount = nil + didOpenDelayElapse = false + } + + private mutating func reconcilePresentation() -> Bool { + guard didOpenDelayElapse, + let discoveredWindowCount + else { + return false + } + guard discoveredWindowCount > 0 else { + reset() + return false + } + phase = .presented + return true + } +} diff --git a/Sources/FreeDock/Models/WindowPreviewKeyboardSelection.swift b/Sources/FreeDock/Models/WindowPreviewKeyboardSelection.swift new file mode 100644 index 0000000..4409e8b --- /dev/null +++ b/Sources/FreeDock/Models/WindowPreviewKeyboardSelection.swift @@ -0,0 +1,70 @@ +import Foundation + +struct WindowPreviewKeyboardSelection: Equatable, Sendable { + private(set) var selectedWindowID: DockApplicationWindow.ID? + private(set) var showsFocusIndicator = false + + init(windows: [DockApplicationWindow] = []) { + selectedWindowID = Self.preferredWindowID(in: windows) + } + + var returnTargetID: DockApplicationWindow.ID? { + selectedWindowID + } + + mutating func update(windows: [DockApplicationWindow]) { + guard !windows.isEmpty else { + selectedWindowID = nil + showsFocusIndicator = false + return + } + if let selectedWindowID, + windows.contains(where: { $0.id == selectedWindowID }) + { + return + } + selectedWindowID = Self.preferredWindowID(in: windows) + } + + mutating func selectNext(in windows: [DockApplicationWindow]) { + move(by: 1, in: windows) + } + + mutating func selectPrevious( + in windows: [DockApplicationWindow] + ) { + move(by: -1, in: windows) + } + + private mutating func move( + by offset: Int, + in windows: [DockApplicationWindow] + ) { + guard !windows.isEmpty else { + selectedWindowID = nil + showsFocusIndicator = false + return + } + + if let selectedWindowID, + let index = windows.firstIndex( + where: { $0.id == selectedWindowID } + ) + { + let count = windows.count + let destination = (index + offset + count) % count + self.selectedWindowID = windows[destination].id + } else { + selectedWindowID = Self.preferredWindowID(in: windows) + } + showsFocusIndicator = true + } + + private static func preferredWindowID( + in windows: [DockApplicationWindow] + ) -> DockApplicationWindow.ID? { + windows.first(where: \.isFocused)?.id + ?? windows.first(where: \.isMain)?.id + ?? windows.first?.id + } +} diff --git a/Sources/FreeDock/Models/WindowPreviewPanelGeometry.swift b/Sources/FreeDock/Models/WindowPreviewPanelGeometry.swift new file mode 100644 index 0000000..3468abf --- /dev/null +++ b/Sources/FreeDock/Models/WindowPreviewPanelGeometry.swift @@ -0,0 +1,301 @@ +import Foundation + +enum WindowPreviewPanelSide: Equatable, Sendable { + case above + case below + case left + case right + + var opposite: WindowPreviewPanelSide { + switch self { + case .above: return .below + case .below: return .above + case .left: return .right + case .right: return .left + } + } +} + +struct WindowPreviewPanelPlacement: Equatable, Sendable { + let frame: CGRect + let side: WindowPreviewPanelSide +} + +enum WindowPreviewPanelGeometry { + private static let minimumUsableSize = CGSize( + width: 260, + height: 140 + ) + + static func placement( + size requestedSize: CGSize, + sourceRect: CGRect, + dockFrame: CGRect, + visibleFrame: CGRect, + orientation: Orientation, + dockedEdge: DockScreenEdge? = nil, + gap: CGFloat = 8, + inset: CGFloat = 8 + ) -> WindowPreviewPanelPlacement { + let visibleFrame = visibleFrame.standardized + let availableFrame = insetFrame( + visibleFrame, + by: max(0, inset) + ) + let preferredSide = preferredSide( + dockFrame: dockFrame, + visibleFrame: visibleFrame, + orientation: orientation, + dockedEdge: dockedEdge + ) + let anchorRect = resolvedSourceRect( + sourceRect, + dockFrame: dockFrame, + side: preferredSide + ) + let boundedSize = CGSize( + width: min( + max(1, finite(requestedSize.width, fallback: 1)), + max(1, availableFrame.width) + ), + height: min( + max(1, finite(requestedSize.height, fallback: 1)), + max(1, availableFrame.height) + ) + ) + let gap = max(0, gap) + let preferredCapacity = availableExtent( + on: preferredSide, + from: anchorRect, + in: availableFrame, + gap: gap + ) + let oppositeCapacity = availableExtent( + on: preferredSide.opposite, + from: anchorRect, + in: availableFrame, + gap: gap + ) + let requestedExtent = extent( + of: boundedSize, + on: preferredSide + ) + let side = preferredCapacity < requestedExtent + && oppositeCapacity > preferredCapacity + ? preferredSide.opposite + : preferredSide + let fittedSize = fittedSize( + boundedSize, + on: side, + capacity: availableExtent( + on: side, + from: anchorRect, + in: availableFrame, + gap: gap + ) + ) + let frame = clamped( + candidateFrame( + size: fittedSize, + sourceRect: anchorRect, + side: side, + gap: gap + ), + to: availableFrame + ) + + return WindowPreviewPanelPlacement( + frame: frame, + side: side + ) + } + + private static func preferredSide( + dockFrame: CGRect, + visibleFrame: CGRect, + orientation: Orientation, + dockedEdge: DockScreenEdge? + ) -> WindowPreviewPanelSide { + if let dockedEdge { + switch dockedEdge { + case .bottom: return .above + case .top: return .below + case .left: return .right + case .right: return .left + } + } + + if orientation == .horizontal { + return dockFrame.midY <= visibleFrame.midY + ? .above + : .below + } + return dockFrame.midX <= visibleFrame.midX + ? .right + : .left + } + + private static func resolvedSourceRect( + _ sourceRect: CGRect, + dockFrame: CGRect, + side: WindowPreviewPanelSide + ) -> CGRect { + let standardizedSource = sourceRect.standardized + guard !standardizedSource.isEmpty, + standardizedSource.origin.x.isFinite, + standardizedSource.origin.y.isFinite, + standardizedSource.width.isFinite, + standardizedSource.height.isFinite + else { + let point: CGPoint + switch side { + case .above: + point = CGPoint(x: dockFrame.midX, y: dockFrame.maxY) + case .below: + point = CGPoint(x: dockFrame.midX, y: dockFrame.minY) + case .left: + point = CGPoint(x: dockFrame.minX, y: dockFrame.midY) + case .right: + point = CGPoint(x: dockFrame.maxX, y: dockFrame.midY) + } + return CGRect(origin: point, size: .zero) + } + return standardizedSource + } + + private static func fittedSize( + _ size: CGSize, + on side: WindowPreviewPanelSide, + capacity: CGFloat + ) -> CGSize { + var result = size + switch side { + case .above, .below: + let minimumExtent = min( + size.height, + minimumUsableSize.height + ) + if capacity >= minimumExtent { + result.height = min(size.height, max(1, capacity)) + } + case .left, .right: + let minimumExtent = min( + size.width, + minimumUsableSize.width + ) + if capacity >= minimumExtent { + result.width = min(size.width, max(1, capacity)) + } + } + return result + } + + private static func extent( + of size: CGSize, + on side: WindowPreviewPanelSide + ) -> CGFloat { + switch side { + case .above, .below: + return size.height + case .left, .right: + return size.width + } + } + + private static func candidateFrame( + size: CGSize, + sourceRect: CGRect, + side: WindowPreviewPanelSide, + gap: CGFloat + ) -> CGRect { + switch side { + case .above: + return CGRect( + x: sourceRect.midX - size.width / 2, + y: sourceRect.maxY + gap, + width: size.width, + height: size.height + ) + case .below: + return CGRect( + x: sourceRect.midX - size.width / 2, + y: sourceRect.minY - gap - size.height, + width: size.width, + height: size.height + ) + case .left: + return CGRect( + x: sourceRect.minX - gap - size.width, + y: sourceRect.midY - size.height / 2, + width: size.width, + height: size.height + ) + case .right: + return CGRect( + x: sourceRect.maxX + gap, + y: sourceRect.midY - size.height / 2, + width: size.width, + height: size.height + ) + } + } + + private static func availableExtent( + on side: WindowPreviewPanelSide, + from sourceRect: CGRect, + in bounds: CGRect, + gap: CGFloat + ) -> CGFloat { + switch side { + case .above: + return max(0, bounds.maxY - sourceRect.maxY - gap) + case .below: + return max(0, sourceRect.minY - gap - bounds.minY) + case .left: + return max(0, sourceRect.minX - gap - bounds.minX) + case .right: + return max(0, bounds.maxX - sourceRect.maxX - gap) + } + } + + private static func insetFrame( + _ frame: CGRect, + by requestedInset: CGFloat + ) -> CGRect { + let horizontalInset = min( + requestedInset, + max(0, (frame.width - 1) / 2) + ) + let verticalInset = min( + requestedInset, + max(0, (frame.height - 1) / 2) + ) + return frame.insetBy( + dx: horizontalInset, + dy: verticalInset + ) + } + + private static func clamped( + _ frame: CGRect, + to bounds: CGRect + ) -> CGRect { + var frame = frame + frame.origin.x = min( + max(frame.minX, bounds.minX), + max(bounds.minX, bounds.maxX - frame.width) + ) + frame.origin.y = min( + max(frame.minY, bounds.minY), + max(bounds.minY, bounds.maxY - frame.height) + ) + return frame + } + + private static func finite( + _ value: CGFloat, + fallback: CGFloat + ) -> CGFloat { + value.isFinite ? value : fallback + } +} diff --git a/Sources/FreeDock/Views/DockContentView.swift b/Sources/FreeDock/Views/DockContentView.swift index ec095fc..d32e021 100644 --- a/Sources/FreeDock/Views/DockContentView.swift +++ b/Sources/FreeDock/Views/DockContentView.swift @@ -2,12 +2,58 @@ import SwiftUI import UniformTypeIdentifiers struct DockContentView: View { + private static let dockItemType = UTType.freeDockItem + let panel: DockPanel + let profileID: UUID @Binding var items: [DockItem] let orientation: Orientation @ObservedObject var state: DockState - let onItemsChanged: @MainActor ([DockItem]) -> Void - let onAppLaunch: @MainActor (DockItem) -> Void + @ObservedObject var itemDragCoordinator: DockItemDragCoordinator + let onItemActivation: @MainActor (DockItem, NSRect) -> Void + let onItemTransfer: @MainActor ( + DockItemDragSession, + DockItemTransferDestination, + DockItemTransferOperation + ) -> Bool + let onQuickLaunchDismiss: @MainActor () -> Void + let onAddItemsRequested: @MainActor () -> Void + let onAddSmartStackRequested: @MainActor (SmartStackSource) -> Void + let onFolderOptionsChanged: @MainActor ( + DockItem.ID, + FolderStackOptions + ) -> Void + let hasRecentFiles: @MainActor () -> Bool + let onClearRecentFilesRequested: @MainActor () -> Void + let onOpenDocumentWithApplication: @MainActor ( + DockItem, + URL + ) -> Void + let onOpenFilesWithApplication: @MainActor ( + DockItem, + [URL] + ) -> Bool + let onCanOpenFilesWithApplication: @MainActor ( + DockItem, + [URL] + ) -> Bool + let onChooseFilesForApplication: @MainActor (DockItem) -> Void + let onApplicationHoverChanged: @MainActor ( + DockItem, + NSRect, + Bool + ) -> Void + let onShowApplicationWindows: @MainActor ( + DockItem, + NSRect + ) -> Void + let onEnableWindowThumbnails: @MainActor () -> Void + let isWindowPreviewAccessibilityTrusted: + @MainActor () -> Bool + let isWindowPreviewScreenCaptureTrusted: + @MainActor () -> Bool + let onDismissWindowPreview: @MainActor () -> Void + @Environment(\.accessibilityReduceMotion) private var reduceMotion private var iconSize: Double { state.iconSize @@ -15,202 +61,638 @@ struct DockContentView: View { @State private var isTargeted = false @State private var dropPulse = false - @State private var draggedItem: DockItem? @State private var displayedItems: [DockItem]? @State private var hoveredItem: UUID? @State private var dropTargetItem: UUID? + @State private var applicationFileDropTargetItem: UUID? + @State private var applicationFileDropPresentation: + ApplicationFileDropPresentation? + @State private var applicationFileDropURLs: [URL]? + @State private var applicationFileDropPreflightID: UUID? @State private var trailingTargeted = false + @State private var externalFileDropClaimState = + DockExternalFileDropClaimState() + @State private var externalFileDropInteractionToken: UUID? + @State private var externalFileDropExpiryWorkItem: DispatchWorkItem? + @State private var quickLaunchQuery = "" + @State private var quickLaunchSelection = QuickLaunchSelection() private var currentItems: [DockItem] { displayedItems ?? items } + private var resizableItemCount: Int { + DockResizeGestureMath.resizableItemCount(in: currentItems) + } + + private var quickLaunchResults: [QuickLaunchSearchResult] { + QuickLaunchSearch.results( + in: currentItems, + matching: quickLaunchQuery + ) + } + + private var quickLaunchResultsInDockOrder: [QuickLaunchSearchResult] { + quickLaunchResults.sorted { $0.dockIndex < $1.dockIndex } + } + + private var matchingItemIDs: Set { + Set(quickLaunchResults.map(\.id)) + } + + private var selectedQuickLaunchItemID: DockItem.ID? { + quickLaunchSelection.selectedItemID + } + + private var selectedQuickLaunchResult: QuickLaunchSearchResult? { + quickLaunchSelection.selectedResult(in: quickLaunchResults) + } + + private var surfaceCornerRadius: CGFloat { + CGFloat(state.cornerRadius) + } + + private var configuredMagnificationHeadroom: CGFloat { + guard state.magnificationEnabled else { return 10 } + return max(10, iconSize * (state.magnification - 1 + 0.04)) + } + + private var magnificationHeadroom: CGFloat { + state.isQuickLaunchPresented + ? 10 + : configuredMagnificationHeadroom + } + + private var chromeColor: Color { + switch state.appearance { + case .glass: + return .primary + case .light: + return .black + case .dark: + return .white + } + } + var body: some View { - if orientation == .horizontal { - HStack(spacing: 0) { - DockDragHandleRepresentable( - panel: panel, - orientation: orientation - ) - .frame(width: 24) - HStack(spacing: 0) { content } - .padding(4) - DockResizeHandleRepresentable( - panel: panel, - orientation: orientation - ) - .frame(width: 12) - } + dockLayout .contentShape(Rectangle()) .onHover { hovering in - if !hovering { - hoveredItem = nil + if !hovering { hoveredItem = nil } + } + .onDrop(of: [.fileURL], isTargeted: $isTargeted) { providers in + guard !state.isQuickLaunchPresented else { return false } + return handleFileDrop(providers) + } + .onChange(of: isTargeted) { targeted in + updateDropPulse(targeted) + updateExternalFileDropTargeting(targeted) + } + .onAppear { + displayedItems = items + if state.isQuickLaunchPresented { + resetQuickLaunch() } } - .background(RoundedRectangle(cornerRadius: 14).fill(.ultraThinMaterial).shadow(color: .black.opacity(0.12), radius: 4, x: 0, y: 2)) - .overlay(dropZoneHighlight) - .onDrop(of: [.fileURL, .plainText], isTargeted: $isTargeted) { providers in - if providers.first?.hasItemConformingToTypeIdentifier(UTType.fileURL.identifier) == true { - return handleFileDrop(providers) + .onChange(of: items) { + displayedItems = $0 + reconcileQuickLaunchSelection() + } + .onChange(of: itemDragCoordinator.contentRevision) { _ in + withAnimation(.spring( + response: 0.3, + dampingFraction: 0.74 + )) { + displayedItems = items } - return false + reconcileQuickLaunchSelection() + } + .onChange(of: state.quickLaunchFocusGeneration) { _ in + resetQuickLaunch() + } + .onChange(of: quickLaunchQuery) { _ in + reconcileQuickLaunchSelection() + } + .onChange(of: hoveredItem) { itemID in + updateQuickLaunchSelectionFromHover(itemID) + } + .onDisappear { + onDismissWindowPreview() + tearDownExternalFileDropInteraction() } - .onChange(of: isTargeted) { targeted in updateDropPulse(targeted) } - .onAppear { displayedItems = items } - .onChange(of: items) { displayedItems = $0 } + .accessibilityElement(children: .contain) + .accessibilityLabel("\(state.name) dock") .contextMenu { + Button("Add Files or Folders…") { + onAddItemsRequested() + } + Menu("Add Stack") { + Button("Recent Files") { + onAddSmartStackRequested(.recentFiles) + } + .disabled(!canAddSmartStack(.recentFiles)) + + Button("Downloads") { + onAddSmartStackRequested(.downloads) + } + .disabled(!canAddSmartStack(.downloads)) + } + Divider() Button("Add Separator") { var updated = displayedItems ?? items updated.append(.separator()) commitItems(updated) } } - } else { - VStack(spacing: 0) { - DockDragHandleRepresentable( - panel: panel, - orientation: orientation - ) - .frame(height: 24) - .padding(.top, 12) - VStack(spacing: 0) { content } - .padding(4) - DockResizeHandleRepresentable( - panel: panel, - orientation: orientation - ) - .frame(height: 12) + } + + @ViewBuilder + private var dockLayout: some View { + if state.isQuickLaunchPresented { + quickLaunchDockLayout + } else if orientation == .horizontal { + HStack(spacing: state.itemSpacing) { + dockMoveHandle + content } - .contentShape(Rectangle()) - .onHover { hovering in - if !hovering { - hoveredItem = nil + .padding(.horizontal, 8) + .padding(.vertical, 9) + .background(dockSurface) + .overlay(dropZoneHighlight) + .overlay(alignment: .trailing) { + if resizableItemCount > 0 { + DockResizeHandleRepresentable( + panel: panel, + orientation: orientation, + resizableItemCount: resizableItemCount + ) + .frame(width: 18) + } } + .padding(.top, magnificationHeadroom) + } else { + VStack(spacing: state.itemSpacing) { + dockMoveHandle + content } - .background(RoundedRectangle(cornerRadius: 14).fill(.ultraThinMaterial).shadow(color: .black.opacity(0.12), radius: 4, x: 0, y: 2)) - .overlay(dropZoneHighlight) - .onDrop(of: [.fileURL, .plainText], isTargeted: $isTargeted) { providers in - if providers.first?.hasItemConformingToTypeIdentifier(UTType.fileURL.identifier) == true { - return handleFileDrop(providers) + .padding(.horizontal, 9) + .padding(.vertical, 8) + .background(dockSurface) + .overlay(dropZoneHighlight) + .overlay(alignment: .bottom) { + if resizableItemCount > 0 { + DockResizeHandleRepresentable( + panel: panel, + orientation: orientation, + resizableItemCount: resizableItemCount + ) + .frame(height: 18) + } + } + .padding(.horizontal, magnificationHeadroom * 0.52) + } + } + + @ViewBuilder + private var dockMoveHandle: some View { + if orientation == .horizontal { + DockDragHandleRepresentable( + panel: panel, + orientation: orientation + ) + .frame(width: 16, height: iconSize + 11) + .help("Drag to move this dock") + .accessibilityLabel("Move \(state.name) dock") + } else { + DockDragHandleRepresentable( + panel: panel, + orientation: orientation + ) + .frame(width: iconSize + 11, height: 16) + .help("Drag to move this dock") + .accessibilityLabel("Move \(state.name) dock") + } + } + + @ViewBuilder + private var quickLaunchDockLayout: some View { + if orientation == .horizontal { + if panel.dockedEdgeForLayout == .top { + VStack(alignment: .leading, spacing: 7) { + quickLaunchItemsSurface + quickLaunchSearchPanel + } + .padding(.top, configuredMagnificationHeadroom) + .padding(.bottom, 10) + } else { + VStack(alignment: .leading, spacing: 7) { + quickLaunchSearchPanel + quickLaunchItemsSurface } - return false + .padding(.top, 10) } - .onChange(of: isTargeted) { targeted in updateDropPulse(targeted) } - .onAppear { displayedItems = items } - .onChange(of: items) { displayedItems = $0 } - .contextMenu { - Button("Add Separator") { - var updated = displayedItems ?? items - updated.append(.separator()) - commitItems(updated) + } else if panel.dockedEdgeForLayout == .right { + HStack(alignment: .top, spacing: 7) { + quickLaunchSearchPanel + quickLaunchItemsSurface + } + .padding( + .horizontal, + configuredMagnificationHeadroom * 0.52 + ) + } else { + HStack(alignment: .top, spacing: 7) { + quickLaunchItemsSurface + quickLaunchSearchPanel + } + .padding( + .horizontal, + configuredMagnificationHeadroom * 0.52 + ) + } + } + + private var quickLaunchSearchPanel: some View { + quickLaunchSearchBar + .padding(6) + .background(dockSurface) + } + + @ViewBuilder + private var quickLaunchItemsSurface: some View { + if orientation == .horizontal { + quickLaunchItems + .padding(.horizontal, 8) + .padding(.vertical, 9) + .background(dockSurface) + } else { + quickLaunchItems + .padding(.horizontal, 9) + .padding(.vertical, 8) + .background(dockSurface) + } + } + + private var quickLaunchItems: some View { + ZStack { + if orientation == .horizontal { + HStack(spacing: state.itemSpacing) { content } + } else { + VStack(spacing: state.itemSpacing) { content } + } + + if quickLaunchResults.isEmpty, !currentItems.isEmpty { + Text( + quickLaunchQuery.isEmpty + ? "No launchable items" + : "No matching items" + ) + .font(.system(size: 11, weight: .semibold)) + .foregroundStyle(chromeColor.opacity(0.76)) + .padding(.horizontal, 10) + .padding(.vertical, 5) + .background( + Capsule() + .fill(chromeColor.opacity(0.10)) + ) + .accessibilityHidden(true) + } + } + } + + private var quickLaunchSearchBar: some View { + VStack(spacing: 3) { + HStack(spacing: 7) { + QuickLaunchSearchField( + text: $quickLaunchQuery, + focusGeneration: state.quickLaunchFocusGeneration, + placeholder: "Search this dock", + accessibilityLabel: "Search \(state.name)", + accessibilityHelp: "Type to filter. Use arrow keys to choose, Return to open, and Escape to close.", + accessibilityStatus: quickLaunchAccessibilityValue, + onCommand: handleQuickLaunchCommand + ) + .frame(height: 22) + + Text("\(quickLaunchResults.count)") + .font(.system( + size: 10, + weight: .semibold, + design: .rounded + )) + .foregroundStyle(chromeColor.opacity(0.55)) + .lineLimit(1) + } + + HStack(spacing: 6) { + Text(quickLaunchSelectionLabel) + .lineLimit(1) + .truncationMode(.middle) + Spacer(minLength: 6) + if selectedQuickLaunchResult != nil { + Text("\(quickLaunchSelectionPositionText) ↩ Open") + .lineLimit(1) } } + .font(.system(size: 10, weight: .medium)) + .foregroundStyle(chromeColor.opacity(0.58)) + .accessibilityHidden(true) } + .frame( + width: 228, + height: 43 + ) + .padding(.horizontal, 9) + .background( + RoundedRectangle(cornerRadius: 10, style: .continuous) + .fill(chromeColor.opacity(0.075)) + ) + .overlay { + RoundedRectangle(cornerRadius: 10, style: .continuous) + .stroke(chromeColor.opacity(0.13), lineWidth: 0.75) + .allowsHitTesting(false) + } + } + + private var quickLaunchSelectionLabel: String { + if let selectedQuickLaunchResult { + return selectedQuickLaunchResult.displayLabel + } + return quickLaunchQuery.isEmpty + ? "No launchable items" + : "No matching items" + } + + private var quickLaunchSelectionPositionText: String { + guard let selectedQuickLaunchResult, + let position = quickLaunchResultPosition( + for: selectedQuickLaunchResult.id + ) + else { + return "" + } + return "\(position)/\(quickLaunchResults.count)" } @ViewBuilder private var content: some View { if currentItems.isEmpty { - Text("Drag apps here") - .foregroundColor(.secondary) - .font(.caption) - .padding(.horizontal, 12) - .padding(.vertical, 6) + Label("Drop apps, files, or folders", systemImage: "plus.circle.fill") + .font(.system(size: 12, weight: .semibold)) + .foregroundStyle(chromeColor.opacity(0.65)) + .padding(.horizontal, 13) + .padding(.vertical, 9) + .background { + RoundedRectangle(cornerRadius: 9, style: .continuous) + .fill( + Color.accentColor.opacity( + trailingTargeted ? 0.12 : 0 + ) + ) + } + .overlay { + RoundedRectangle(cornerRadius: 9, style: .continuous) + .stroke( + Color.accentColor.opacity( + trailingTargeted ? 0.7 : 0 + ), + lineWidth: 1 + ) + } + .contentShape(Rectangle()) + .onDrop( + of: [Self.dockItemType], + delegate: itemDropDelegate( + destination: .trailing, + isTargeted: $trailingTargeted + ) + ) } else { ForEach(Array(currentItems.enumerated()), id: \.element.id) { index, item in if item.isSeparator { - DockSeparatorView(orientation: orientation, iconSize: iconSize) - .contentShape(Rectangle()) - .contextMenu { - Button("Remove Separator", role: .destructive) { - var updated = displayedItems ?? items - updated.removeAll { $0.id == item.id } - commitItems(updated) - } - } - .onDrag { - draggedItem = item - return NSItemProvider(object: item.id.uuidString as NSString) - } - .onDrop(of: [.plainText], isTargeted: nil) { providers in - handleReorder(providers, targetItem: item) - } + separatorCell(item) } else { - DockItemView( - item: item, - iconSize: iconSize, - scale: scale(for: index, in: currentItems), - onLaunch: { onAppLaunch(item) }, - onRemove: { removeItem(item) }, - hoveredItem: $hoveredItem, - orientation: orientation - ) + dockItemCell(item, at: index) + } + } + + if !state.isQuickLaunchPresented { + Color.clear .frame( - width: iconSize + 10, - height: iconSize + 10 - ) - .opacity(draggedItem?.id == item.id ? 0.4 : 1.0) - .overlay( - RoundedRectangle(cornerRadius: 8) - .stroke(Color.accentColor.opacity(dropTargetItem == item.id ? 0.8 : 0), lineWidth: 2) + width: orientation == .horizontal ? 8 : iconSize + 6, + height: orientation == .horizontal ? iconSize + 6 : 8 ) - .onDrag { - draggedItem = item - return NSItemProvider(object: item.id.uuidString as NSString) - } preview: { - Image(nsImage: AppInfo.resolve(from: item.appPath).icon) - .resizable() - .frame(width: iconSize, height: iconSize) - .opacity(0.85) + .padding(orientation == .horizontal ? .leading : .top, 4) + .contentShape(Rectangle()) + .overlay { + trailingDropIndicator + .allowsHitTesting(false) } - .onDrop(of: [.plainText], isTargeted: Binding( - get: { dropTargetItem == item.id }, - set: { dropTargetItem = $0 ? item.id : nil } - )) { providers in - handleReorder(providers, targetItem: item) - } - } + .onDrop( + of: [Self.dockItemType], + delegate: itemDropDelegate( + destination: .trailing, + isTargeted: $trailingTargeted + ) + ) } + } + } - Color.clear - .frame( - width: orientation == .horizontal ? 16 : iconSize + 4, - height: orientation == .horizontal ? iconSize + 4 : 16 + @ViewBuilder + private func separatorCell(_ item: DockItem) -> some View { + let separator = DockSeparatorView( + orientation: orientation, + iconSize: iconSize, + color: chromeColor + ) + .contentShape(Rectangle()) + .opacity( + state.isQuickLaunchPresented ? 0.18 : opacity(for: item) + ) + .allowsHitTesting(!state.isQuickLaunchPresented) + .accessibilityHidden(state.isQuickLaunchPresented) + .overlay { + dockItemDropIndicator(for: item) + .allowsHitTesting(false) + } + .contextMenu { + Button("Remove Separator", role: .destructive) { + var updated = displayedItems ?? items + updated.removeAll { $0.id == item.id } + commitItems(updated) + } + } + + separator + .onDrag { + dragProvider(for: item) + } + .onDrop( + of: [Self.dockItemType], + delegate: itemDropDelegate( + destination: .item(item.id), + isTargeted: dropTargetBinding(for: item.id) ) - .padding(orientation == .horizontal ? .leading : .top, 4) - .contentShape(Rectangle()) - .overlay( - RoundedRectangle(cornerRadius: 4) - .stroke(Color.accentColor.opacity(trailingTargeted ? 0.5 : 0), lineWidth: 1.5) + ) + } + + @ViewBuilder + private func dockItemCell( + _ item: DockItem, + at index: Int + ) -> some View { + let cell = DockItemView( + item: item, + iconSize: iconSize, + scale: scale(for: index, in: currentItems), + onActivate: { screenRect in + onItemActivation(item, screenRect) + }, + onRemove: { removeItem(item) }, + onFolderOptionsChanged: { options in + onFolderOptionsChanged(item.id, options) + }, + hasRecentFiles: hasRecentFiles, + onClearRecentFilesRequested: onClearRecentFilesRequested, + onOpenDocumentWithApplication: { applicationURL in + onOpenDocumentWithApplication(item, applicationURL) + }, + onChooseFilesForApplication: { + onChooseFilesForApplication(item) + }, + onApplicationHoverChanged: { hovering, screenRect in + onApplicationHoverChanged( + item, + screenRect, + hovering ) - .onDrop(of: [.plainText], isTargeted: $trailingTargeted) { _ in - var updatedItems = displayedItems ?? items - guard let dragged = draggedItem, - let fromIdx = updatedItems.firstIndex(where: { $0.id == dragged.id }) - else { return false } - withAnimation(.spring(response: 0.3, dampingFraction: 0.7)) { - updatedItems.move( - fromOffsets: IndexSet(integer: fromIdx), - toOffset: updatedItems.endIndex + }, + onShowApplicationWindows: { screenRect in + onShowApplicationWindows(item, screenRect) + }, + onEnableWindowThumbnails: + onEnableWindowThumbnails, + isWindowPreviewAccessibilityTrusted: + isWindowPreviewAccessibilityTrusted, + isWindowPreviewScreenCaptureTrusted: + isWindowPreviewScreenCaptureTrusted, + hoveredItem: $hoveredItem, + orientation: orientation, + showRunningIndicator: state.showRunningIndicators, + indicatorColor: chromeColor, + isQuickLaunchSelected: state.isQuickLaunchPresented + && selectedQuickLaunchItemID == item.id, + quickLaunchResultPosition: quickLaunchResultPosition( + for: item.id + ), + quickLaunchResultCount: quickLaunchResults.count + ) + .frame( + width: iconSize + 9, + height: iconSize + 11 + ) + .opacity(opacity(for: item)) + .allowsHitTesting( + !state.isQuickLaunchPresented + || matchingItemIDs.contains(item.id) + ) + .accessibilityHidden( + state.isQuickLaunchPresented + && !matchingItemIDs.contains(item.id) + ) + .overlay { + dockItemOverlay(for: item) + .allowsHitTesting(false) + } + .scaleEffect( + selectedQuickLaunchItemID == item.id + && state.isQuickLaunchPresented + && !reduceMotion + ? 1.03 + : 1 + ) + .animation( + reduceMotion ? nil : .easeOut(duration: 0.12), + value: selectedQuickLaunchItemID + ) + + let interactiveCell = cell + .onDrag { + dragProvider(for: item) + } preview: { + Image( + nsImage: DockItemPresentation.resolve(item).icon + ) + .resizable() + .frame(width: iconSize, height: iconSize) + .opacity(0.85) + } + .onDrop( + of: [Self.dockItemType], + delegate: itemDropDelegate( + destination: .item(item.id), + isTargeted: dropTargetBinding(for: item.id) + ) + ) + + applicationFileDropTarget(interactiveCell, for: item) + } + + @ViewBuilder + private func applicationFileDropTarget( + _ content: Content, + for item: DockItem + ) -> some View { + if canTargetApplicationFileDrop(item) { + content.onDrop( + of: [.fileURL], + delegate: DockApplicationFileDropDelegate( + isEnabled: itemDragCoordinator.activeSession == nil + && !externalFileDropClaimState + .isOperationInProgress, + isRejected: applicationFileDropTargetItem == item.id + && applicationFileDropPresentation == .rejected, + onEntered: { providers in + beginApplicationFileDropPreflight( + providers, + onto: item + ) + }, + onExited: { + endApplicationFileDropPreflight( + itemID: item.id + ) + }, + onClaim: { + claimApplicationFileDrop(itemID: item.id) + }, + onPerform: { providers, operationID in + handleApplicationFileDrop( + providers, + onto: item, + operationID: operationID ) } - draggedItem = nil - commitItems(updatedItems) - return true - } + ) + ) + } else { + content } } struct DockSeparatorView: View { let orientation: Orientation let iconSize: Double + let color: Color + var body: some View { ZStack { - Rectangle() - .fill(Color.white.opacity(0.15)) + Capsule() + .fill(color.opacity(0.16)) .frame( - width: orientation == .horizontal ? 2 : iconSize - (iconSize * 0.1), - height: orientation == .horizontal ? iconSize - (iconSize * 0.1) : 2 + width: orientation == .horizontal ? 1 : iconSize * 0.54, + height: orientation == .horizontal ? iconSize * 0.54 : 1 ) } .frame( @@ -221,31 +703,373 @@ struct DockContentView: View { } private func scale(for index: Int, in items: [DockItem]) -> CGFloat { - guard let hoveredItem, + guard !state.isQuickLaunchPresented, + state.magnificationEnabled, + let hoveredItem, let hoveredIndex = items.firstIndex(where: { $0.id == hoveredItem }) else { return 1.0 } + let peak = CGFloat(state.magnification) + let delta = peak - 1 + switch abs(index - hoveredIndex) { - case 0: return 1.20 - case 1: return 1.10 - case 2: return 1.05 + case 0: return peak + case 1: return 1 + delta * 0.5 + case 2: return 1 + delta * 0.2 default: return 1.0 } } + private func opacity(for item: DockItem) -> Double { + if state.isQuickLaunchPresented { + return matchingItemIDs.contains(item.id) ? 1 : 0.27 + } + return itemDragCoordinator.isDragging( + itemID: item.id, + from: panel.dockID + ) ? 0.4 : 1 + } + + @ViewBuilder + private func dockItemOverlay(for item: DockItem) -> some View { + let shape = RoundedRectangle(cornerRadius: 12, style: .continuous) + if applicationFileDropTargetItem == item.id, + applicationFileDropPresentation != .pin + { + let isRejected = applicationFileDropPresentation == .rejected + let isChecking = applicationFileDropPresentation == .checking + let highlightColor: Color = isRejected + ? .red + : (isChecking ? .gray : .accentColor) + let badgeSymbol = isRejected + ? "xmark" + : (isChecking ? "ellipsis" : "arrow.up.forward") + + shape + .fill(highlightColor.opacity(0.18)) + .padding(1) + shape + .strokeBorder(highlightColor, lineWidth: 2) + .padding(1.5) + .shadow( + color: highlightColor.opacity(0.32), + radius: 6 + ) + Image(systemName: badgeSymbol) + .font(.system(size: 8, weight: .bold)) + .foregroundStyle(.white) + .frame(width: 16, height: 16) + .background(highlightColor, in: Circle()) + .overlay { + Circle() + .strokeBorder(.white.opacity(0.55), lineWidth: 0.75) + } + .frame( + maxWidth: .infinity, + maxHeight: .infinity, + alignment: .bottomTrailing + ) + .padding(2) + .accessibilityHidden(true) + } else if state.isQuickLaunchPresented, + selectedQuickLaunchItemID == item.id + { + shape + .fill(Color.accentColor.opacity(0.16)) + .padding(1) + shape + .strokeBorder(Color.white.opacity(0.24), lineWidth: 0.75) + .padding(1.5) + shape + .strokeBorder(Color.accentColor.opacity(0.68), lineWidth: 1) + .padding(2.5) + .shadow( + color: Color.accentColor.opacity(0.18), + radius: 5 + ) + } else { + dockItemDropIndicator(for: item) + } + } + + @ViewBuilder + private func dockItemDropIndicator(for item: DockItem) -> some View { + if dropTargetItem == item.id { + if orientation == .horizontal { + Capsule() + .fill(Color.accentColor) + .frame( + width: 3, + height: max(22, iconSize * 0.64) + ) + .frame( + maxWidth: .infinity, + maxHeight: .infinity, + alignment: dropsAfter(item) ? .trailing : .leading + ) + .shadow( + color: Color.accentColor.opacity(0.35), + radius: 3 + ) + } else { + Capsule() + .fill(Color.accentColor) + .frame( + width: max(22, iconSize * 0.64), + height: 3 + ) + .frame( + maxWidth: .infinity, + maxHeight: .infinity, + alignment: dropsAfter(item) ? .bottom : .top + ) + .shadow( + color: Color.accentColor.opacity(0.35), + radius: 3 + ) + } + } + } + + @ViewBuilder + private var trailingDropIndicator: some View { + if trailingTargeted { + if orientation == .horizontal { + Capsule() + .fill(Color.accentColor) + .frame( + width: 3, + height: max(22, iconSize * 0.64) + ) + } else { + Capsule() + .fill(Color.accentColor) + .frame( + width: max(22, iconSize * 0.64), + height: 3 + ) + } + } + } + + private func dropsAfter(_ targetItem: DockItem) -> Bool { + guard let session = itemDragCoordinator.activeSession, + session.sourceDockID == panel.dockID, + let sourceIndex = currentItems.firstIndex(where: { + $0.id == session.itemID + }), + let targetIndex = currentItems.firstIndex(where: { + $0.id == targetItem.id + }) + else { + return false + } + return sourceIndex < targetIndex + } + + private func quickLaunchResultPosition( + for itemID: DockItem.ID + ) -> Int? { + quickLaunchResultsInDockOrder.firstIndex { + $0.id == itemID + }.map { $0 + 1 } + } + + private var quickLaunchAccessibilityValue: String { + let queryDescription = quickLaunchQuery.isEmpty + ? "All dock items" + : "Query \(quickLaunchQuery)" + guard let selectedResult = quickLaunchSelection.selectedResult( + in: quickLaunchResults + ), let position = quickLaunchResultPosition(for: selectedResult.id) + else { + return "\(queryDescription). No matches." + } + return "\(queryDescription). \(selectedResult.displayLabel), selected, \(position) of \(quickLaunchResults.count)." + } + + private func resetQuickLaunch() { + quickLaunchQuery = "" + hoveredItem = nil + var selection = QuickLaunchSelection() + selection.reconcile(with: QuickLaunchSearch.results( + in: currentItems, + matching: "" + )) + quickLaunchSelection = selection + } + + private func reconcileQuickLaunchSelection() { + guard state.isQuickLaunchPresented else { return } + var selection = quickLaunchSelection + selection.reconcile(with: quickLaunchResults) + quickLaunchSelection = selection + } + + private func updateQuickLaunchSelectionFromHover( + _ itemID: DockItem.ID? + ) { + guard state.isQuickLaunchPresented, + let itemID, + matchingItemIDs.contains(itemID) + else { + return + } + quickLaunchSelection = QuickLaunchSelection( + selectedItemID: itemID + ) + } + + private func handleQuickLaunchCommand( + _ command: QuickLaunchSearchField.Command + ) { + guard state.isQuickLaunchPresented else { return } + + switch command { + case .moveLeft, .moveUp, .previous: + moveQuickLaunchSelection(.previous) + case .moveRight, .moveDown, .next: + moveQuickLaunchSelection(.next) + case .activate: + activateQuickLaunchSelection() + case .dismiss: + onQuickLaunchDismiss() + } + } + + private func moveQuickLaunchSelection( + _ direction: QuickLaunchNavigationDirection + ) { + var selection = quickLaunchSelection + selection.move(direction, in: quickLaunchResultsInDockOrder) + quickLaunchSelection = selection + } + + private func activateQuickLaunchSelection() { + guard let result = quickLaunchSelection.selectedResult( + in: quickLaunchResults + ) else { + NSSound.beep() + return + } + onItemActivation( + result.item, + .zero + ) + } + private var dropZoneHighlight: some View { - RoundedRectangle(cornerRadius: 14) - .stroke(isTargeted ? Color.accentColor.opacity(dropPulse ? 1.0 : 0.55) : Color.clear, lineWidth: 3) - .shadow(color: isTargeted ? Color.accentColor.opacity(dropPulse ? 0.35 : 0.12) : Color.clear, radius: dropPulse ? 7 : 2) + RoundedRectangle(cornerRadius: surfaceCornerRadius) + .stroke( + showsDockWideFileDropHighlight + ? Color.accentColor.opacity(dropPulse ? 1.0 : 0.55) + : Color.clear, + lineWidth: 2 + ) + .shadow( + color: showsDockWideFileDropHighlight + ? Color.accentColor.opacity(dropPulse ? 0.35 : 0.12) + : Color.clear, + radius: dropPulse ? 7 : 2 + ) + .allowsHitTesting(false) + } + + private var showsDockWideFileDropHighlight: Bool { + guard !externalFileDropClaimState.isOperationInProgress + else { + return false + } + if applicationFileDropTargetItem != nil { + return applicationFileDropPresentation == .pin + } + return isTargeted + } + + private var dockSurface: some View { + let shape = RoundedRectangle(cornerRadius: surfaceCornerRadius, style: .continuous) + return ZStack { + glassMaterial(for: shape) + .opacity(state.appearance == .glass ? state.surfaceOpacity : 0) + + shape + .fill(Color(red: 0.96, green: 0.96, blue: 0.97).opacity(0.94)) + .opacity(state.appearance == .light ? state.surfaceOpacity : 0) + + shape + .fill(Color(red: 0.075, green: 0.078, blue: 0.09).opacity(0.92)) + .opacity(state.appearance == .dark ? state.surfaceOpacity : 0) + + shape.fill( + LinearGradient( + colors: [.white.opacity(0.18), .white.opacity(0.045), .black.opacity(0.035)], + startPoint: .top, + endPoint: .bottom + ) + ) + .opacity(state.appearance == .glass ? state.surfaceOpacity : 0) + } + .overlay { + shape.strokeBorder( + LinearGradient( + colors: surfaceBorderColors, + startPoint: .top, + endPoint: .bottom + ), + lineWidth: 0.75 + ) + .opacity(state.surfaceOpacity) + } + .shadow( + color: .black.opacity( + (state.appearance == .dark ? 0.32 : 0.22) * state.shadowStrength + ), + radius: 16, + x: 0, + y: 8 + ) + .shadow( + color: .black.opacity(0.10 * state.shadowStrength), + radius: 2, + x: 0, + y: 1 + ) + } + + @ViewBuilder + private func glassMaterial(for shape: RoundedRectangle) -> some View { + switch state.blurStyle { + case .light: + shape.fill(.ultraThinMaterial) + case .regular: + shape.fill(.regularMaterial) + case .strong: + shape.fill(.thickMaterial) + } + } + + private var surfaceBorderColors: [Color] { + switch state.appearance { + case .glass: + return [.white.opacity(0.30), .white.opacity(0.10), .black.opacity(0.08)] + case .light: + return [.white.opacity(0.90), .white.opacity(0.42), .black.opacity(0.16)] + case .dark: + return [.white.opacity(0.25), .white.opacity(0.10), .black.opacity(0.28)] + } } private func updateDropPulse(_ targeted: Bool) { if targeted { - dropPulse = false - withAnimation(.easeInOut(duration: 0.75).repeatForever(autoreverses: true)) { + if reduceMotion { dropPulse = true + } else { + dropPulse = false + withAnimation(.easeInOut(duration: 0.75).repeatForever(autoreverses: true)) { + dropPulse = true + } } } else { withAnimation(.easeOut(duration: 0.12)) { @@ -255,50 +1079,643 @@ struct DockContentView: View { } private func handleFileDrop(_ providers: [NSItemProvider]) -> Bool { - let relevant = providers.filter { $0.hasItemConformingToTypeIdentifier(UTType.fileURL.identifier) } + if externalFileDropClaimState.shouldSuppressParentPinDrop { + return true + } + guard !externalFileDropClaimState.isOperationInProgress else { + NSSound.beep() + return false + } + + let relevant = providers.filter { + $0.hasItemConformingToTypeIdentifier( + UTType.fileURL.identifier + ) + } guard !relevant.isEmpty else { return false } - for provider in relevant { - provider.loadItem(forTypeIdentifier: UTType.fileURL.identifier, options: nil) { item, _ in - guard let data = item as? Data, let url = URL(dataRepresentation: data, relativeTo: nil), url.path.hasSuffix(".app"), FileManager.default.fileExists(atPath: url.path) else { return } - let info = AppInfo.resolve(from: url.path) - let newItem = DockItem(appPath: url.path, label: info.displayName) - DispatchQueue.main.async { - var updatedItems = displayedItems ?? items - updatedItems.append(newItem) - commitItems(updatedItems) - } + + let operationID = beginExternalFileDropOperation() + loadFileDropProviders(relevant) { result in + guard externalFileDropClaimState.activeOperationID + == operationID + else { + return } + if result.failedInputIndices.isEmpty { + addDroppedItems(result.urls) + } else { + NSSound.beep() + } + finishExternalFileDropOperation(operationID) } return true } - private func handleReorder(_: [NSItemProvider], targetItem: DockItem) -> Bool { - var updatedItems = displayedItems ?? items - guard - let dragged = draggedItem, - let fromIdx = updatedItems.firstIndex(where: { $0.id == dragged.id }), - let toIdx = updatedItems.firstIndex(where: { $0.id == targetItem.id }), - fromIdx != toIdx - else { return false } - withAnimation(.spring(response: 0.3, dampingFraction: 0.7)) { - updatedItems.move(fromOffsets: IndexSet(integer: fromIdx), toOffset: toIdx > fromIdx ? toIdx + 1 : toIdx) - } - draggedItem = nil - commitItems(updatedItems) + private func handleApplicationFileDrop( + _ providers: [NSItemProvider], + onto item: DockItem, + operationID: UUID + ) -> Bool { + let relevant = providers.filter { + $0.hasItemConformingToTypeIdentifier( + UTType.fileURL.identifier + ) + } + guard !relevant.isEmpty else { + NSSound.beep() + finishExternalFileDropOperation(operationID) + return true + } + + guard applicationFileDropPresentation != .rejected else { + NSSound.beep() + finishExternalFileDropOperation(operationID) + return true + } + + if let applicationFileDropURLs { + performApplicationFileDrop( + applicationFileDropURLs, + onto: item + ) + finishExternalFileDropOperation(operationID) + return true + } + + loadFileDropProviders(relevant) { result in + guard externalFileDropClaimState.activeOperationID + == operationID + else { + return + } + if result.failedInputIndices.isEmpty { + performApplicationFileDrop( + result.urls, + onto: item + ) + } else { + NSSound.beep() + } + finishExternalFileDropOperation(operationID) + } return true } + private func performApplicationFileDrop( + _ urls: [URL], + onto item: DockItem + ) { + if urls.contains(where: shouldPinAsDockItem) { + addDroppedItems(urls) + return + } + + guard onCanOpenFilesWithApplication(item, urls) else { + NSSound.beep() + return + } + _ = onOpenFilesWithApplication(item, urls) + } + + private func shouldPinAsDockItem(_ url: URL) -> Bool { + url.pathExtension.caseInsensitiveCompare("app") + == .orderedSame + || DockItem.pinnedItem(at: url)?.kind == .application + } + + private func loadFileDropProviders( + _ providers: [NSItemProvider], + completion: @escaping @MainActor ( + OrderedFileDropLoadResult + ) -> Void + ) { + let accumulator = OrderedFileDropAccumulator( + count: providers.count + ) + for (index, provider) in providers.enumerated() { + provider.loadDataRepresentation( + forTypeIdentifier: UTType.fileURL.identifier + ) { data, _ in + let url = data.flatMap { + URL(dataRepresentation: $0, relativeTo: nil) + } + guard let result = accumulator.store( + url, + at: index + ) else { + return + } + DispatchQueue.main.async { + completion(result) + } + } + } + } + + private func addDroppedItems(_ urls: [URL]) { + let plan = DockItemPlanner.planAdding( + urls: urls, + to: displayedItems ?? items + ) + guard plan.addedCount > 0 else { return } + commitItems(plan.items) + } + + private func canTargetApplicationFileDrop( + _ item: DockItem + ) -> Bool { + !state.isQuickLaunchPresented + && item.kind == .application + && item.fileURL != nil + } + + private func beginApplicationFileDropPreflight( + _ providers: [NSItemProvider], + onto item: DockItem + ) { + onDismissWindowPreview() + guard !externalFileDropClaimState.isOperationInProgress + else { + return + } + + let preflightID = UUID() + applicationFileDropTargetItem = item.id + applicationFileDropPresentation = .checking + applicationFileDropURLs = nil + applicationFileDropPreflightID = preflightID + externalFileDropClaimState.applicationTargetEntered( + itemID: item.id + ) + TooltipManager.shared.hide() + beginExternalFileDropInteraction() + + let relevant = providers.filter { + $0.hasItemConformingToTypeIdentifier( + UTType.fileURL.identifier + ) + } + guard !relevant.isEmpty else { + setApplicationFileDropPresentation( + .rejected, + for: item, + itemCount: providers.count + ) + return + } + + loadFileDropProviders(relevant) { result in + guard applicationFileDropPreflightID == preflightID, + applicationFileDropTargetItem == item.id, + !externalFileDropClaimState.isOperationInProgress + else { + return + } + + guard result.failedInputIndices.isEmpty, + !result.urls.isEmpty + else { + applicationFileDropURLs = nil + setApplicationFileDropPresentation( + .rejected, + for: item, + itemCount: relevant.count + ) + return + } + + applicationFileDropURLs = result.urls + if result.urls.contains(where: shouldPinAsDockItem) { + let plan = DockItemPlanner.planAdding( + urls: result.urls, + to: displayedItems ?? items + ) + setApplicationFileDropPresentation( + plan.addedCount > 0 ? .pin : .rejected, + for: item, + itemCount: result.urls.count + ) + } else { + setApplicationFileDropPresentation( + onCanOpenFilesWithApplication(item, result.urls) + ? .open + : .rejected, + for: item, + itemCount: result.urls.count + ) + } + } + } + + private func setApplicationFileDropPresentation( + _ presentation: ApplicationFileDropPresentation, + for item: DockItem, + itemCount: Int + ) { + applicationFileDropPresentation = presentation + guard presentation != .checking else { return } + + let applicationName = item.label + ?? item.fileURL.map { + FileManager.default.displayName(atPath: $0.path) + } + ?? "this application" + let count = max(1, itemCount) + let noun = count == 1 ? "item" : "items" + let announcement: String + switch presentation { + case .checking: + return + case .open: + announcement = + "\(applicationName) can open \(count) \(noun)." + case .pin: + announcement = + "Add \(count) \(noun) to \(state.name)." + case .rejected: + announcement = + "Drop unavailable for \(applicationName)." + } + + NSAccessibility.post( + element: panel, + notification: .announcementRequested, + userInfo: [ + .announcement: announcement, + .priority: + NSAccessibilityPriorityLevel.medium.rawValue, + ] + ) + } + + private func endApplicationFileDropPreflight( + itemID: DockItem.ID + ) { + externalFileDropClaimState.applicationTargetExited( + itemID: itemID + ) + guard applicationFileDropTargetItem == itemID, + !externalFileDropClaimState.isOperationInProgress + else { + return + } + + clearApplicationFileDropPresentation() + endExternalFileDropInteractionIfPossible() + } + + private func clearApplicationFileDropPresentation() { + applicationFileDropTargetItem = nil + applicationFileDropPresentation = nil + applicationFileDropURLs = nil + applicationFileDropPreflightID = nil + } + + private func claimApplicationFileDrop( + itemID: DockItem.ID + ) -> UUID { + applicationFileDropTargetItem = itemID + applicationFileDropPreflightID = nil + return beginExternalFileDropOperation( + claimingApplicationItemID: itemID + ) + } + + private func updateExternalFileDropTargeting(_ targeted: Bool) { + if targeted { + onDismissWindowPreview() + beginExternalFileDropInteraction() + } else { + endExternalFileDropInteractionIfPossible() + } + } + + private func beginExternalFileDropOperation( + claimingApplicationItemID: DockItem.ID? = nil + ) -> UUID { + let operationID = UUID() + if let claimingApplicationItemID { + externalFileDropClaimState.claimApplicationDrop( + itemID: claimingApplicationItemID, + operationID: operationID + ) + externalFileDropClaimState.applicationTargetExited( + itemID: claimingApplicationItemID + ) + DispatchQueue.main.async { + _ = externalFileDropClaimState + .releaseDispatchSuppression(for: operationID) + endExternalFileDropInteractionIfPossible() + } + } else { + externalFileDropClaimState.beginOperation( + operationID: operationID + ) + } + beginExternalFileDropInteraction() + externalFileDropExpiryWorkItem?.cancel() + let work = DispatchWorkItem { + guard externalFileDropClaimState.activeOperationID + == operationID + else { + return + } + NSSound.beep() + finishExternalFileDropOperation(operationID) + } + externalFileDropExpiryWorkItem = work + DispatchQueue.main.asyncAfter( + deadline: .now() + 30, + execute: work + ) + return operationID + } + + private func finishExternalFileDropOperation( + _ operationID: UUID + ) { + guard externalFileDropClaimState.finishOperation( + operationID + ) else { + return + } + externalFileDropExpiryWorkItem?.cancel() + externalFileDropExpiryWorkItem = nil + clearApplicationFileDropPresentation() + endExternalFileDropInteractionIfPossible() + } + + private func beginExternalFileDropInteraction() { + guard externalFileDropInteractionToken == nil else { return } + externalFileDropInteractionToken = + panel.beginTransientInteraction() + } + + private func endExternalFileDropInteractionIfPossible() { + guard !isTargeted, + applicationFileDropTargetItem == nil, + !externalFileDropClaimState.isOperationInProgress, + !externalFileDropClaimState + .shouldSuppressParentPinDrop, + let token = externalFileDropInteractionToken + else { + return + } + externalFileDropInteractionToken = nil + panel.endTransientInteraction(token) + } + + private func tearDownExternalFileDropInteraction() { + externalFileDropExpiryWorkItem?.cancel() + externalFileDropExpiryWorkItem = nil + externalFileDropClaimState.reset() + clearApplicationFileDropPresentation() + guard let token = externalFileDropInteractionToken else { + return + } + externalFileDropInteractionToken = nil + panel.endTransientInteraction(token) + } + + private func dragProvider(for item: DockItem) -> NSItemProvider { + guard !state.isQuickLaunchPresented else { + return NSItemProvider() + } + onDismissWindowPreview() + TooltipManager.shared.hide() + return itemDragCoordinator.beginDrag( + profileID: profileID, + sourceDockID: panel.dockID, + itemID: item.id + ) + } + + private func itemDropDelegate( + destination: DockItemTransferDestination, + isTargeted: Binding + ) -> DockItemDropDelegate { + DockItemDropDelegate( + profileID: profileID, + targetDockID: panel.dockID, + destination: destination, + coordinator: itemDragCoordinator, + isEnabled: !state.isQuickLaunchPresented, + isTargeted: isTargeted, + onTransfer: onItemTransfer + ) + } + + private func dropTargetBinding( + for itemID: DockItem.ID + ) -> Binding { + Binding( + get: { dropTargetItem == itemID }, + set: { targeted in + if targeted { + dropTargetItem = itemID + } else if dropTargetItem == itemID { + dropTargetItem = nil + } + } + ) + } + private func removeItem(_ item: DockItem) { var updatedItems = displayedItems ?? items updatedItems.removeAll(where: { $0.id == item.id }) withAnimation { displayedItems = updatedItems } items = updatedItems - onItemsChanged(updatedItems) } private func commitItems(_ updatedItems: [DockItem]) { displayedItems = updatedItems items = updatedItems - onItemsChanged(updatedItems) + } + + private func canAddSmartStack(_ source: SmartStackSource) -> Bool { + DockItemPlanner.planAdding( + smartStack: source, + to: currentItems + ).addedCount > 0 + } +} + +@MainActor +private struct DockItemDropDelegate: DropDelegate { + let profileID: UUID + let targetDockID: UUID + let destination: DockItemTransferDestination + let coordinator: DockItemDragCoordinator + let isEnabled: Bool + @Binding var isTargeted: Bool + let onTransfer: @MainActor ( + DockItemDragSession, + DockItemTransferDestination, + DockItemTransferOperation + ) -> Bool + + func validateDrop(info: DropInfo) -> Bool { + canAccept(info) + } + + func dropEntered(info: DropInfo) { + isTargeted = canAccept(info) + } + + func dropUpdated(info: DropInfo) -> DropProposal? { + guard canAccept(info), + let session = coordinator.activeSession + else { + return DropProposal(operation: .forbidden) + } + return DropProposal( + operation: transferOperation(for: session) == .copy + ? .copy + : .move + ) + } + + func dropExited(info _: DropInfo) { + isTargeted = false + } + + func performDrop(info: DropInfo) -> Bool { + guard canAccept(info), + let expectedSession = coordinator.activeSession, + let provider = info.itemProviders( + for: [UTType.freeDockItem] + ).first + else { + isTargeted = false + return false + } + + let operation = transferOperation(for: expectedSession) + isTargeted = false + coordinator.loadSession(from: provider) { payload in + guard let payload, + payload == expectedSession, + coordinator.activeSession == expectedSession + else { + coordinator.cancel() + return + } + _ = onTransfer(payload, destination, operation) + } + return true + } + + private func canAccept(_ info: DropInfo) -> Bool { + isEnabled + && info.hasItemsConforming(to: [UTType.freeDockItem]) + && coordinator.activeSession?.profileID == profileID + } + + private func transferOperation( + for session: DockItemDragSession + ) -> DockItemTransferOperation { + guard session.sourceDockID != targetDockID else { + return .move + } + return NSEvent.modifierFlags.contains(.option) ? .copy : .move + } +} + +private struct DockApplicationFileDropDelegate: DropDelegate { + let isEnabled: Bool + let isRejected: Bool + let onEntered: @MainActor ([NSItemProvider]) -> Void + let onExited: @MainActor () -> Void + let onClaim: @MainActor () -> UUID + let onPerform: @MainActor ([NSItemProvider], UUID) -> Bool + + func validateDrop(info: DropInfo) -> Bool { + canAccept(info) + } + + func dropEntered(info: DropInfo) { + guard canAccept(info) else { return } + onEntered(info.itemProviders(for: [.fileURL])) + } + + func dropExited(info _: DropInfo) { + onExited() + } + + func dropUpdated(info: DropInfo) -> DropProposal? { + guard canAccept(info), !isRejected else { + return DropProposal(operation: .forbidden) + } + return DropProposal(operation: .copy) + } + + func performDrop(info: DropInfo) -> Bool { + guard canAccept(info) else { + onExited() + return false + } + + let providers = info.itemProviders(for: [.fileURL]) + guard !providers.isEmpty else { + onExited() + return false + } + + let operationID = onClaim() + return onPerform(providers, operationID) + } + + private func canAccept(_ info: DropInfo) -> Bool { + isEnabled + && info.hasItemsConforming(to: [.fileURL]) + } +} + +private enum ApplicationFileDropPresentation: Equatable { + case checking + case open + case pin + case rejected +} + +private struct OrderedFileDropLoadResult: Sendable { + let urls: [URL] + let failedInputIndices: [Int] +} + +private final class OrderedFileDropAccumulator: @unchecked Sendable { + private let lock = NSLock() + private var results: [URL?] + private var completedIndices = Set() + private var remaining: Int + + init(count: Int) { + results = Array(repeating: nil, count: count) + remaining = count + } + + func store( + _ url: URL?, + at index: Int + ) -> OrderedFileDropLoadResult? { + lock.lock() + defer { lock.unlock() } + + guard remaining > 0, + results.indices.contains(index), + completedIndices.insert(index).inserted + else { + return nil + } + results[index] = url + remaining -= 1 + guard remaining == 0 else { return nil } + + return OrderedFileDropLoadResult( + urls: results.compactMap { $0 }, + failedInputIndices: results.indices.filter { + results[$0] == nil + } + ) } } diff --git a/Sources/FreeDock/Views/DockItemView.swift b/Sources/FreeDock/Views/DockItemView.swift index a561b56..94ced77 100644 --- a/Sources/FreeDock/Views/DockItemView.swift +++ b/Sources/FreeDock/Views/DockItemView.swift @@ -1,97 +1,598 @@ import Cocoa import SwiftUI +enum DockApplicationHoverTooltip { + static func text( + displayName: String, + isWindowSwitchingEnabled: Bool, + isThumbnailCaptureEnabled _: Bool + ) -> String { + guard isWindowSwitchingEnabled else { + return "Right-click to enable window switching" + } + // Thumbnail permission affects only card artwork. Once switching is + // enabled, use the normal Dock-style app label and let the preview + // panel explain its own thumbnail state. + return displayName + } +} + struct DockItemView: View { let item: DockItem let iconSize: Double let scale: CGFloat - let onLaunch: () -> Void + let onActivate: (NSRect) -> Void let onRemove: () -> Void + let onFolderOptionsChanged: (FolderStackOptions) -> Void + let hasRecentFiles: () -> Bool + let onClearRecentFilesRequested: () -> Void + let onOpenDocumentWithApplication: (URL) -> Void + let onChooseFilesForApplication: () -> Void + let onApplicationHoverChanged: (Bool, NSRect) -> Void + let onShowApplicationWindows: (NSRect) -> Void + let onEnableWindowThumbnails: () -> Void + let isWindowPreviewAccessibilityTrusted: () -> Bool + let isWindowPreviewScreenCaptureTrusted: () -> Bool - @State private var bundleID: String? = nil - @State private var appInfo: AppInfo? = nil @ObservedObject private var monitor = RunningAppMonitor.shared + @Environment(\.accessibilityReduceMotion) private var reduceMotion + @State private var presentation: DockItemPresentation? @State private var isHovering = false @Binding var hoveredItem: UUID? @State private var screenRect: NSRect = .zero @State private var bouncing = false let orientation: Orientation + let showRunningIndicator: Bool + let indicatorColor: Color + let isQuickLaunchSelected: Bool + let quickLaunchResultPosition: Int? + let quickLaunchResultCount: Int + + private var resolvedPresentation: DockItemPresentation { + presentation ?? DockItemPresentation.resolve(item) + } private var isRunning: Bool { - guard let bid = bundleID else { return false } - return monitor.runningBundleIDs.contains(bid) + guard item.kind == .application, + let bundleID = resolvedPresentation.bundleID + else { + return false + } + return monitor.runningBundleIDs.contains(bundleID) + } + + private var magnificationAnchor: UnitPoint { + orientation == .horizontal ? .bottom : .center + } + + private var hoverOffset: CGSize { + orientation == .horizontal + ? CGSize(width: 0, height: isHovering ? -2 : 0) + : .zero } var body: some View { - ZStack { - VStack(spacing: 1) { - Image(nsImage: (appInfo ?? AppInfo.resolve(from: item.appPath)).icon) + Button(action: activate) { + ZStack { + Image(nsImage: resolvedPresentation.icon) .resizable() .frame(width: iconSize, height: iconSize) - .shadow(color: .black.opacity(0.1), radius: 1, x: 0, y: 1) - - Circle() - .fill(Color.white) - .frame(width: 5, height: 5) - .shadow(color: .white.opacity(0.8), radius: 3) - .opacity(isRunning ? 1 : 0) - .scaleEffect(isRunning ? 1 : 0.3) - .animation(.spring(response: 0.4, dampingFraction: 0.6), value: isRunning) + .saturation(resolvedPresentation.isAvailable ? 1 : 0.15) + .opacity(resolvedPresentation.isAvailable ? 1 : 0.58) + .shadow( + color: .black.opacity(isHovering ? 0.25 : 0.14), + radius: isHovering ? 8 : 3, + x: 0, + y: 3 + ) + .overlay(alignment: orientation == .horizontal ? .bottom : .leading) { + runningIndicator + } + .overlay(alignment: .bottomTrailing) { + itemBadge + } } + .contentShape(Rectangle()) } - .background(ScreenRectReader { screenRect = $0 }) - .onHover { hovering in - if hovering, let label = item.label { - isHovering = true - hoveredItem = item.id - NSCursor.pointingHand.push() - TooltipManager.shared.show(label, at: screenRect, orientation: orientation) - } else { - isHovering = false - if hoveredItem == item.id { - hoveredItem = nil - } - NSCursor.pop() - TooltipManager.shared.hide() + .buttonStyle(.plain) + .focusable(false) + .padding(4) + .scaleEffect(scale, anchor: magnificationAnchor) + .offset(y: bouncing && orientation == .horizontal ? -5 : 0) + .offset(x: bouncing && orientation == .vertical ? 5 : 0) + .animation( + reduceMotion + ? nil + : .interpolatingSpring(stiffness: 300, damping: 4), + value: bouncing + ) + .animation( + reduceMotion + ? nil + : .spring(response: 0.25, dampingFraction: 0.7), + value: scale + ) + .offset(hoverOffset) + // Hover belongs to this fixed cell, not the magnified icon. Otherwise + // the icon can move out from under the pointer and cancel its own + // preview timer while it animates. + .frame( + width: iconSize + 9, + height: iconSize + 11 + ) + .contentShape(Rectangle()) + .background(ScreenRectReader { updatedScreenRect in + guard screenRect != updatedScreenRect else { return } + screenRect = updatedScreenRect + if isHovering, + item.kind == .application, + isRunning + { + onApplicationHoverChanged(true, updatedScreenRect) } + }) + .onHover(perform: updateHover) + .animation( + reduceMotion ? nil : .easeOut(duration: 0.15), + value: isHovering + ) + .contextMenu { itemContextMenu } + .accessibilityLabel(resolvedPresentation.displayName) + .accessibilityValue(accessibilityValue) + .accessibilityHint(accessibilityHint) + .accessibilityAddTraits( + isQuickLaunchSelected ? .isSelected : [] + ) + .onAppear(perform: refreshPresentation) + .onChange(of: item) { _ in refreshPresentation() } + } + + @ViewBuilder + private var runningIndicator: some View { + Circle() + .fill(indicatorColor.opacity(0.78)) + .frame(width: 4, height: 4) + .shadow(color: .black.opacity(0.16), radius: 1, y: 1) + .offset( + orientation == .horizontal + ? CGSize(width: 0, height: 7) + : CGSize(width: -7, height: 0) + ) + .opacity(isRunning && showRunningIndicator ? 1 : 0) + .scaleEffect(isRunning && showRunningIndicator ? 1 : 0.35) + .animation( + reduceMotion + ? nil + : .spring(response: 0.35, dampingFraction: 0.65), + value: isRunning && showRunningIndicator + ) + .accessibilityHidden(true) + } + + @ViewBuilder + private var itemBadge: some View { + if !resolvedPresentation.isAvailable { + Image(systemName: "questionmark") + .font(.system(size: max(9, iconSize * 0.2), weight: .bold)) + .foregroundStyle(.white) + .padding(max(3, iconSize * 0.055)) + .background(.secondary.opacity(0.78), in: Circle()) + .offset(x: 2, y: 2) + .accessibilityHidden(true) + } else if let badgeSymbolName = resolvedPresentation.badgeSymbolName { + Image(systemName: badgeSymbolName) + .font(.system(size: max(8, iconSize * 0.18), weight: .bold)) + .foregroundStyle(.white) + .padding(max(3, iconSize * 0.06)) + .background(.black.opacity(0.62), in: Circle()) + .overlay(Circle().stroke(.white.opacity(0.32), lineWidth: 0.5)) + .offset(x: 2, y: 2) + .accessibilityHidden(true) } + } - .animation(.easeOut(duration: 0.15), value: isHovering) - .padding(4) - .scaleEffect(scale) - .offset(y: bouncing ? -4 : 0) - .animation(.interpolatingSpring(stiffness: 300, damping: 4), value: bouncing) - .animation(.spring(response: 0.25, dampingFraction: 0.7), value: scale) - .offset(y: isHovering ? -2 : 0) - .shadow(radius: isHovering ? 5 : 2) - .onTapGesture { - bounce() - onLaunch() - } - .contextMenu { + @ViewBuilder + private var itemContextMenu: some View { + if item.smartStackSource == .recentFiles { + recentFilesMenu + } else if item.smartStackSource == .downloads { + downloadsMenu + } else if item.kind == .folder { + folderMenu + } else if item.kind == .application { + applicationMenu + } else { + documentMenu + } + + if item.fileURL != nil, item.kind != .folder { + Divider() Button("Show in Finder") { showInFinder() } + .disabled(!resolvedPresentation.isAvailable) Button("Copy Path") { copyPath() } - Divider() - Button("Remove from Dock", role: .destructive) { onRemove() } } - .onAppear { - if appInfo == nil { - appInfo = AppInfo.resolve(from: item.appPath) + Divider() + Button("Remove from Dock", role: .destructive) { onRemove() } + } + + @ViewBuilder + private var applicationMenu: some View { + if let bundleID = resolvedPresentation.bundleID { + let state = DockApplicationActionController() + .state(forBundleIdentifier: bundleID) + if state.isRunning { + if state.isHidden { + Button("Show \(resolvedPresentation.displayName)") { + performApplicationAction(.show, bundleID: bundleID) + } + } else { + Button("Bring \(resolvedPresentation.displayName) to Front") { + performApplicationAction(.activate, bundleID: bundleID) + } + } + + if state.visibleInstanceCount > 0 { + Button( + state.instanceCount > 1 + ? "Hide All Instances" + : "Hide \(resolvedPresentation.displayName)" + ) { + performApplicationAction(.hide, bundleID: bundleID) + } + } + + Button( + state.instanceCount > 1 + ? "Quit All Instances" + : "Quit \(resolvedPresentation.displayName)" + ) { + performApplicationAction(.quit, bundleID: bundleID) + } + } else { + Button("Open") { activate() } + .disabled(!resolvedPresentation.isAvailable) + } + } else { + Button("Open") { activate() } + .disabled(!resolvedPresentation.isAvailable) + } + + let hasWindowPreviewAccess = + isWindowPreviewAccessibilityTrusted() + if hasWindowPreviewAccess { + Button("Show Windows…") { + onShowApplicationWindows(screenRect) + } + .disabled( + !resolvedPresentation.isAvailable + || !isRunning + ) + + if !isWindowPreviewScreenCaptureTrusted() { + Button("Enable Window Thumbnails…") { + onEnableWindowThumbnails() + } + .disabled(!resolvedPresentation.isAvailable) + } + } else { + Button("Enable Window Switching…") { + onShowApplicationWindows(screenRect) + } + .disabled(!resolvedPresentation.isAvailable) + } + + Divider() + Button( + "Open Files with \(resolvedPresentation.displayName)…", + action: onChooseFilesForApplication + ) + .disabled(!resolvedPresentation.isAvailable) + } + + @ViewBuilder + private var documentMenu: some View { + Button("Open") { activate() } + .disabled(!resolvedPresentation.isAvailable) + + let applications = openWithApplications + Menu("Open With") { + ForEach(applications, id: \.url) { application in + Button { + onOpenDocumentWithApplication(application.url) + } label: { + Label { + Text(application.name) + } icon: { + Image(nsImage: application.icon) + } + } + } + } + .disabled(applications.isEmpty) + } + + @ViewBuilder + private var folderMenu: some View { + Button("Show Contents") { activate() } + .disabled(!resolvedPresentation.isAvailable) + Button("Open in Finder") { + NSWorkspace.shared.open(URL(fileURLWithPath: item.path)) + } + .disabled(!resolvedPresentation.isAvailable) + + stackOptionsMenu + + Divider() + Button("Copy Path") { copyPath() } + } + + @ViewBuilder + private var downloadsMenu: some View { + Button("Show Downloads") { activate() } + .disabled(downloadsURL == nil) + Button("Open Downloads in Finder") { + guard let downloadsURL else { return } + NSWorkspace.shared.open(downloadsURL) + } + .disabled(downloadsURL == nil) + + stackOptionsMenu + } + + @ViewBuilder + private var recentFilesMenu: some View { + Button("Show Recent Files") { activate() } + stackOptionsMenu + Divider() + Button( + "Clear Recent Files…", + role: .destructive, + action: onClearRecentFilesRequested + ) + .disabled(!hasRecentFiles()) + } + + @ViewBuilder + private var stackOptionsMenu: some View { + Menu("View As") { + presentationToggle("Automatic", value: .automatic) + presentationToggle("Grid", value: .grid) + presentationToggle("List", value: .list) + } + + Menu("Sort By") { + if item.smartStackSource == .recentFiles { + sortOrderToggle("Recently Opened", value: .recentlyOpened) } - if bundleID == nil { - bundleID = AppInfo.resolveBundleID(from: item.appPath) + sortOrderToggle("Name", value: .name) + sortOrderToggle("Date Modified", value: .dateModified) + sortOrderToggle("Kind", value: .kind) + } + + if item.smartStackSource != .recentFiles { + Toggle("Show Hidden Files", isOn: showHiddenFilesBinding) + } + } + + private func presentationToggle( + _ title: String, + value: FolderStackOptions.Presentation + ) -> some View { + Toggle( + title, + isOn: Binding( + get: { resolvedFolderOptions.presentation == value }, + set: { selected in + guard selected else { return } + updateFolderPresentation(value) + } + ) + ) + } + + private func sortOrderToggle( + _ title: String, + value: FolderStackOptions.SortOrder + ) -> some View { + Toggle( + title, + isOn: Binding( + get: { resolvedFolderOptions.sortOrder == value }, + set: { selected in + guard selected else { return } + updateFolderSortOrder(value) + } + ) + ) + } + + private var downloadsURL: URL? { + guard item.smartStackSource == .downloads, + let url = FileManager.default.urls( + for: .downloadsDirectory, + in: .userDomainMask + ).first?.standardizedFileURL, + FileManager.default.fileExists(atPath: url.path) + else { + return nil + } + return url + } + + private var resolvedFolderOptions: FolderStackOptions { + var options = item.folderOptions + ?? item.smartStackSource?.defaultOptions + ?? FolderStackOptions() + if item.smartStackSource == .recentFiles { + options.showHiddenFiles = false + } else if options.sortOrder == .recentlyOpened { + options.sortOrder = item.smartStackSource?.defaultOptions.sortOrder + ?? .name + } + return options + } + + private var showHiddenFilesBinding: Binding { + Binding( + get: { resolvedFolderOptions.showHiddenFiles }, + set: { showHiddenFiles in + var options = resolvedFolderOptions + options.showHiddenFiles = showHiddenFiles + onFolderOptionsChanged(options) } + ) + } + + private var openWithApplications: [OpenWithApplication] { + guard item.kind == .document, + resolvedPresentation.isAvailable, + let documentURL = item.fileURL + else { + return [] } + + let defaultApplicationKey = NSWorkspace.shared + .urlForApplication(toOpen: documentURL)? + .standardizedFileURL + .resolvingSymlinksInPath() + .path + var seenPaths = Set() + var applications = NSWorkspace.shared + .urlsForApplications(toOpen: documentURL) + .compactMap { url -> OpenWithApplication? in + let standardizedURL = url.standardizedFileURL + let key = standardizedURL + .resolvingSymlinksInPath() + .path + guard seenPaths.insert(key).inserted else { return nil } + let displayName = FileManager.default.displayName( + atPath: standardizedURL.path + ) + let menuName = displayName.hasSuffix(".app") + ? (displayName as NSString).deletingPathExtension + : displayName + return OpenWithApplication( + url: standardizedURL, + name: menuName, + icon: NSWorkspace.shared.icon(forFile: standardizedURL.path), + isDefault: key == defaultApplicationKey + ) + } + applications.sort { + if $0.isDefault != $1.isDefault { + return $0.isDefault + } + return $0.name.localizedStandardCompare($1.name) + == .orderedAscending + } + return applications + } + + private var accessibilityValue: String { + var parts = [resolvedPresentation.kindDescription] + if isRunning { + parts.append("running") + } + if !resolvedPresentation.isAvailable { + parts.append("not available") + } + if isQuickLaunchSelected, + let quickLaunchResultPosition, + quickLaunchResultCount > 0 + { + parts.append("selected") + parts.append( + "\(quickLaunchResultPosition) of \(quickLaunchResultCount)" + ) + } + return parts.joined(separator: ", ") + } + + private var accessibilityHint: String { + if item.smartStackSource == .recentFiles { + return "Shows documents opened through FreeDock." + } + if item.smartStackSource == .downloads { + return "Shows items in the Downloads folder." + } + + switch item.kind { + case .folder: + return "Shows this folder’s contents." + case .application: + return "Opens the application. Use the context menu to show open windows or choose files, or drop compatible files onto it." + case .document: + return "Opens the document in its default application." + case .separator: + return "" + } + } + + private func activate() { + TooltipManager.shared.hide() + if item.kind == .application { + onApplicationHoverChanged(false, screenRect) + } + bounce() + onActivate(screenRect) + } + + private func updateHover(_ hovering: Bool) { + if hovering { + isHovering = true + hoveredItem = item.id + NSCursor.pointingHand.push() + TooltipManager.shared.show( + windowPreviewTooltipText, + at: screenRect, + orientation: orientation + ) + } else { + isHovering = false + if hoveredItem == item.id { + hoveredItem = nil + } + NSCursor.pop() + TooltipManager.shared.hide() + } + + if item.kind == .application { + onApplicationHoverChanged( + hovering && isRunning, + screenRect + ) + } + } + + private var windowPreviewTooltipText: String { + guard item.kind == .application, + isRunning + else { + return resolvedPresentation.displayName + } + if !isWindowPreviewAccessibilityTrusted() { + return DockApplicationHoverTooltip.text( + displayName: resolvedPresentation.displayName, + isWindowSwitchingEnabled: false, + isThumbnailCaptureEnabled: false + ) + } + return DockApplicationHoverTooltip.text( + displayName: resolvedPresentation.displayName, + isWindowSwitchingEnabled: true, + isThumbnailCaptureEnabled: + isWindowPreviewScreenCaptureTrusted() + ) + } + + private func refreshPresentation() { + presentation = DockItemPresentation.resolve(item) } private func bounce() { - guard !bouncing else { return } + guard !reduceMotion, !bouncing else { return } bouncing = true - withAnimation(.interpolatingSpring(stiffness: 300, damping: 8)) { - bouncing = true - } - // 3 bounces worth of time then reset DispatchQueue.main.asyncAfter(deadline: .now() + 0.6) { withAnimation(.spring()) { bouncing = false @@ -99,12 +600,52 @@ struct DockItemView: View { } } + private func performApplicationAction( + _ action: DockApplicationAction, + bundleID: String + ) { + let result = DockApplicationActionController().perform( + action, + forBundleIdentifier: bundleID + ) + if !result.sentRequest || !result.allRequestsAccepted { + NSSound.beep() + } + } + + private func updateFolderPresentation( + _ presentation: FolderStackOptions.Presentation + ) { + var options = resolvedFolderOptions + options.presentation = presentation + onFolderOptionsChanged(options) + } + + private func updateFolderSortOrder( + _ sortOrder: FolderStackOptions.SortOrder + ) { + var options = resolvedFolderOptions + options.sortOrder = sortOrder + onFolderOptionsChanged(options) + } + private func showInFinder() { - NSWorkspace.shared.activateFileViewerSelecting([URL(fileURLWithPath: item.appPath)]) + guard let fileURL = item.fileURL else { return } + NSWorkspace.shared.activateFileViewerSelecting([ + fileURL, + ]) } private func copyPath() { + guard let fileURL = item.fileURL else { return } NSPasteboard.general.clearContents() - NSPasteboard.general.setString(item.appPath, forType: .string) + NSPasteboard.general.setString(fileURL.path, forType: .string) } } + +private struct OpenWithApplication { + let url: URL + let name: String + let icon: NSImage + let isDefault: Bool +} diff --git a/Sources/FreeDock/Views/DockPreferencesView.swift b/Sources/FreeDock/Views/DockPreferencesView.swift new file mode 100644 index 0000000..bd2ddb3 --- /dev/null +++ b/Sources/FreeDock/Views/DockPreferencesView.swift @@ -0,0 +1,966 @@ +import AppKit +import SwiftUI + +struct DockPreferencesView: View { + @ObservedObject var store: DockPreferencesStore + + var body: some View { + HSplitView { + sidebar + .frame(minWidth: 205, idealWidth: 225, maxWidth: 265) + + detail + .frame(minWidth: 480, maxWidth: .infinity, maxHeight: .infinity) + } + .frame(minWidth: 700, minHeight: 500) + .background(Color(nsColor: .windowBackgroundColor)) + .onAppear { + store.refreshPermissions() + } + .onReceive( + NotificationCenter.default.publisher( + for: NSApplication.didBecomeActiveNotification + ) + ) { _ in + store.refreshPermissions() + } + } + + private var sidebar: some View { + VStack(alignment: .leading, spacing: 0) { + profileHeader + + Divider() + + dockBrowser + + Divider() + + sidebarToolbar + } + } + + private var profileHeader: some View { + VStack(alignment: .leading, spacing: 7) { + Text("PROFILE") + .font(.caption2.weight(.semibold)) + .foregroundStyle(.secondary) + + HStack(spacing: 7) { + Menu { + ForEach(store.profiles) { profile in + Button { + store.perform(.activateProfile(profile.id)) + } label: { + if profile.id == store.activeProfileID { + Label(profile.name, systemImage: "checkmark") + } else { + Text(profile.name) + } + } + } + + Divider() + + Button { + store.perform(.createProfile) + } label: { + Label("New Profile…", systemImage: "plus") + } + } label: { + HStack(spacing: 8) { + Image(systemName: "person.2.fill") + .foregroundStyle(.secondary) + Text(store.activeProfileName) + .font(.headline) + .lineLimit(1) + Spacer(minLength: 4) + Image(systemName: "chevron.up.chevron.down") + .font(.caption2.weight(.semibold)) + .foregroundStyle(.secondary) + } + .contentShape(Rectangle()) + } + .menuStyle(.borderlessButton) + .accessibilityLabel("Choose Profile") + + Menu { + Button { + store.perform(.renameActiveProfile) + } label: { + Label("Rename Profile…", systemImage: "pencil") + } + + Divider() + + Button(role: .destructive) { + store.perform(.deleteActiveProfile) + } label: { + Label("Delete Profile…", systemImage: "trash") + } + .disabled(!store.canDeleteActiveProfile) + } label: { + Image(systemName: "ellipsis.circle") + .font(.system(size: 15)) + .frame(width: 22, height: 22) + .contentShape(Rectangle()) + } + .menuStyle(.borderlessButton) + .fixedSize() + .help("Manage Current Profile") + .accessibilityLabel("Manage Current Profile") + } + } + .padding(.horizontal, 14) + .padding(.top, 15) + .padding(.bottom, 12) + } + + @ViewBuilder + private var dockBrowser: some View { + if store.docks.isEmpty { + VStack(spacing: 8) { + Image(systemName: "dock.rectangle") + .font(.system(size: 24)) + .foregroundStyle(.secondary) + Text("No docks in this profile") + .font(.caption) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + Text("Use + below to create one.") + .font(.caption2) + .foregroundStyle(.tertiary) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .padding() + } else { + List(selection: $store.selectedDockID) { + ForEach(store.docks) { dock in + Label { + VStack(alignment: .leading, spacing: 2) { + Text(dock.name) + .lineLimit(1) + .truncationMode(.tail) + .help(dock.name) + Text(dockSubtitle(for: dock)) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(1) + .truncationMode(.tail) + .help(dockSubtitle(for: dock)) + } + } icon: { + Image(systemName: dock.orientation == .horizontal + ? "rectangle.bottomthird.inset.filled" + : "rectangle.leadingthird.inset.filled") + } + .tag(dock.id) + .contextMenu { + dockActions(for: dock.id) + } + } + } + .listStyle(.sidebar) + } + } + + private var sidebarToolbar: some View { + HStack(spacing: 4) { + Menu { + Button { + store.perform(.createDock(.horizontal)) + } label: { + Label("Horizontal Dock", systemImage: "rectangle.split.3x1") + } + + Button { + store.perform(.createDock(.vertical)) + } label: { + Label("Vertical Dock", systemImage: "rectangle.split.1x2") + } + } label: { + Image(systemName: "plus") + .font(.system(size: 13, weight: .semibold)) + .frame(width: 24, height: 22) + .contentShape(Rectangle()) + } + .menuStyle(.borderlessButton) + .fixedSize() + .help("New Dock") + .accessibilityLabel("New Dock") + + Spacer() + + if let dockID = store.selectedDockID { + Menu { + dockActions(for: dockID) + } label: { + Image(systemName: "ellipsis") + .font(.system(size: 13, weight: .semibold)) + .frame(width: 24, height: 22) + .contentShape(Rectangle()) + } + .menuStyle(.borderlessButton) + .fixedSize() + .help("Manage Selected Dock") + .accessibilityLabel("Manage Selected Dock") + } + } + .padding(.horizontal, 9) + .padding(.vertical, 6) + } + + @ViewBuilder + private func dockActions(for dockID: UUID) -> some View { + Button { + store.perform(.renameDock(dockID)) + } label: { + Label("Rename Dock…", systemImage: "pencil") + } + + Button { + store.perform(.duplicateDock(dockID)) + } label: { + Label("Duplicate Dock", systemImage: "plus.square.on.square") + } + + Divider() + + Button(role: .destructive) { + store.perform(.deleteDock(dockID)) + } label: { + Label("Delete Dock…", systemImage: "trash") + } + } + + @ViewBuilder + private var detail: some View { + if let dock = store.selectedDock { + ScrollView { + VStack(alignment: .leading, spacing: 20) { + VStack(alignment: .leading, spacing: 5) { + Text(dock.name) + .font(.system(size: 25, weight: .semibold)) + Text("Changes apply to this dock immediately.") + .font(.callout) + .foregroundStyle(.secondary) + } + + permissionsSection + + settingsSection( + title: "Appearance", + symbol: "paintbrush.fill" + ) { + settingRow("Style") { + Picker("Style", selection: appearanceBinding) { + ForEach(DockAppearance.allCases, id: \.self) { appearance in + Text(appearance.displayName).tag(appearance) + } + } + .labelsHidden() + .pickerStyle(.segmented) + .frame(width: 250) + } + + Divider() + + sliderRow( + title: "Opacity", + value: surfaceOpacityBinding, + range: DockConfig.surfaceOpacityRange, + step: 0.05, + valueText: String(format: "%.0f%%", dock.surfaceOpacity * 100) + ) + + Divider() + + settingRow("Glass blur") { + Picker("Glass blur", selection: blurStyleBinding) { + ForEach(DockBlurStyle.allCases, id: \.self) { style in + Text(style.displayName).tag(style) + } + } + .labelsHidden() + .pickerStyle(.segmented) + .frame(width: 250) + } + .disabled(dock.appearance != .glass) + .opacity(dock.appearance == .glass ? 1 : 0.48) + + Divider() + + sliderRow( + title: "Corner radius", + value: cornerRadiusBinding, + range: DockConfig.cornerRadiusRange, + step: 1, + valueText: "\(Int(dock.cornerRadius)) px" + ) + + Divider() + + sliderRow( + title: "Shadow", + value: shadowStrengthBinding, + range: DockConfig.shadowStrengthRange, + step: 0.05, + valueText: String(format: "%.0f%%", dock.shadowStrength * 100) + ) + } + + settingsSection( + title: "Icons & Layout", + symbol: "square.grid.3x3.fill" + ) { + sliderRow( + title: "Icon size", + value: iconSizeBinding, + range: DockConfig.iconSizeRange, + step: 1, + valueText: "\(Int(dock.iconSize)) px" + ) + + Divider() + + settingRow("Magnification") { + Toggle("", isOn: magnificationEnabledBinding) + .labelsHidden() + .toggleStyle(.switch) + .accessibilityLabel("Magnification") + } + + Divider() + + sliderRow( + title: "Magnification scale", + value: magnificationBinding, + range: DockConfig.magnificationRange, + step: 0.05, + valueText: String(format: "%.2f×", dock.magnification) + ) + .disabled(!dock.magnificationEnabled) + .opacity(dock.magnificationEnabled ? 1 : 0.48) + + Divider() + + sliderRow( + title: "Item spacing", + value: itemSpacingBinding, + range: DockConfig.itemSpacingRange, + step: 1, + valueText: "\(Int(dock.itemSpacing)) px" + ) + + Divider() + + settingRow("Running indicators") { + Toggle("", isOn: runningIndicatorsBinding) + .labelsHidden() + .toggleStyle(.switch) + .accessibilityLabel("Running indicators") + } + } + + settingsSection( + title: "Placement", + symbol: "display.2" + ) { + VStack(alignment: .leading, spacing: 5) { + settingRow("Display") { + Picker( + "Display", + selection: displaySelectionBinding(for: dock) + ) { + Text("Automatic").tag(UUID?.none) + + ForEach(store.displays) { display in + Text(store.displayLabel(for: display)) + .tag(Optional(display.id)) + } + + if let placement = dock.displayPlacement, + !store.isDisplayConnected(placement.displayID) + { + Label( + "\(placement.displayName ?? "Display") — Not Connected", + systemImage: "exclamationmark.triangle.fill" + ) + .tag(Optional(placement.displayID)) + .disabled(true) + } + } + .labelsHidden() + .pickerStyle(.menu) + .frame(width: 250) + } + + Text(displayHelpText(for: dock)) + .font(.caption) + .foregroundStyle( + isPreferredDisplayUnavailable(for: dock) + ? Color.orange + : Color.secondary + ) + } + + Divider() + + settingRow("Orientation") { + Picker("Orientation", selection: orientationBinding) { + Label("Horizontal", systemImage: "rectangle.split.3x1").tag(Orientation.horizontal) + Label("Vertical", systemImage: "rectangle.split.1x2").tag(Orientation.vertical) + } + .labelsHidden() + .pickerStyle(.segmented) + .frame(width: 250) + } + } + + settingsSection( + title: "Behavior", + symbol: "cursorarrow.motionlines" + ) { + settingRow("Auto-hide at screen edge") { + Toggle("", isOn: autoHideBinding) + .labelsHidden() + .toggleStyle(.switch) + .accessibilityLabel("Auto-hide at screen edge") + } + + Divider() + + sliderRow( + title: "Hide delay", + value: autoHideDelayBinding, + range: DockConfig.autoHideDelayRange, + step: 0.1, + valueText: String(format: "%.1f s", dock.autoHideDelay) + ) + .disabled(!dock.autoHideWhenDocked) + .opacity(dock.autoHideWhenDocked ? 1 : 0.48) + } + + settingsSection( + title: "Content", + symbol: "square.grid.3x3.fill" + ) { + let canAddRecentFilesStack = + DockItemPlanner.planAdding( + smartStack: .recentFiles, + to: dock.items + ).addedCount > 0 + let canAddDownloadsStack = + DockItemPlanner.planAdding( + smartStack: .downloads, + to: dock.items + ).addedCount > 0 + + actionRow( + title: "Recent Files stack", + description: "Shows documents opened through FreeDock. History stays local on this Mac.", + buttonTitle: canAddRecentFilesStack ? "Add" : "Added", + isDisabled: !canAddRecentFilesStack + ) { + store.perform( + .addSmartStack(dock.id, .recentFiles) + ) + } + + Divider() + + actionRow( + title: "Downloads stack", + description: "Keeps your current Downloads folder one click away and updates automatically.", + buttonTitle: canAddDownloadsStack ? "Add" : "Added", + isDisabled: !canAddDownloadsStack + ) { + store.perform( + .addSmartStack(dock.id, .downloads) + ) + } + + Divider() + + actionRow( + title: "Add files or folders", + description: "Pin applications and documents, or add a folder stack that stays in sync with Finder.", + buttonTitle: "Add…" + ) { + store.perform(.addDockItems(dock.id)) + } + + Divider() + + actionRow( + title: "Import from macOS Dock", + description: "Append pinned apps that aren’t already here. Existing apps and separators stay unchanged.", + buttonTitle: "Import…" + ) { + store.perform(.importSystemDockApps(dock.id)) + } + + Divider() + + actionRow( + title: "Clear Recent Files history", + description: "Removes FreeDock’s local recent-file list without deleting any files.", + buttonTitle: "Clear…" + ) { + store.perform(.clearRecentFiles) + } + } + + settingsSection( + title: "Reuse & Reset", + symbol: "arrow.triangle.2.circlepath" + ) { + actionRow( + title: "Reset to defaults", + description: "Restore this dock’s standard appearance and behavior.", + buttonTitle: "Reset…" + ) { + store.perform(.resetDockSettings(dock.id)) + } + + Divider() + + actionRow( + title: "Copy to other docks", + description: "Apply these settings to every other dock in this profile.", + buttonTitle: "Copy…", + isDisabled: !store.canCopySelectedDockSettings + ) { + store.perform(.copyDockSettingsToAll(dock.id)) + } + } + } + .padding(28) + .frame(maxWidth: 760, alignment: .leading) + } + } else { + ScrollView { + VStack(alignment: .leading, spacing: 28) { + VStack(spacing: 12) { + Image(systemName: "slider.horizontal.3") + .font(.system(size: 38, weight: .light)) + .foregroundStyle(.secondary) + Text("Select a Dock") + .font(.title2.weight(.semibold)) + Text( + "Choose a dock in the sidebar to customize it." + ) + .foregroundStyle(.secondary) + } + .frame( + maxWidth: .infinity, + minHeight: 210, + alignment: .center + ) + + permissionsSection + } + .padding(28) + .frame(maxWidth: 760, alignment: .leading) + } + } + } + + private var permissionsSection: some View { + settingsSection( + title: "Permissions", + symbol: "lock.shield.fill" + ) { + Text( + "Window discovery and thumbnails stay on this Mac. FreeDock never uploads window titles or images, and thumbnail images are cleared from memory when the preview closes." + ) + .font(.caption) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + .frame(maxWidth: .infinity, alignment: .leading) + + Divider() + + let presentations = + store.permissionState.presentations + ForEach(presentations) { presentation in + permissionRow(presentation) + + if presentation.id != presentations.last?.id { + Divider() + } + } + } + } + + private func permissionRow( + _ presentation: PreferencesPermissionPresentation + ) -> some View { + HStack(alignment: .top, spacing: 13) { + Image( + systemName: permissionSymbol( + for: presentation.permission + ) + ) + .font(.system(size: 14, weight: .semibold)) + .foregroundStyle(.secondary) + .frame(width: 28, height: 28) + .background( + Color.primary.opacity(0.055), + in: RoundedRectangle( + cornerRadius: 7, + style: .continuous + ) + ) + .accessibilityHidden(true) + + VStack(alignment: .leading, spacing: 3) { + Text( + permissionFeatureTitle( + for: presentation.permission + ) + ) + .fontWeight(.medium) + + Text("macOS permission: \(presentation.title)") + .font(.caption2) + .foregroundStyle(.secondary) + + Text( + permissionDescription( + for: presentation.permission + ) + ) + .font(.caption) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + .padding(.top, 2) + } + .frame(maxWidth: .infinity, alignment: .leading) + + VStack(alignment: .trailing, spacing: 8) { + permissionStatus( + presentation + ) + + Button(presentation.actionTitle) { + store.performPermissionAction( + presentation.permission + ) + } + .controlSize(.small) + .disabled(!presentation.isActionEnabled) + .accessibilityLabel( + "\(presentation.actionTitle) for FreeDock" + ) + + if presentation.status == .notGranted { + Button("Open System Settings…") { + store.openPermissionSettings( + presentation.permission + ) + } + .buttonStyle(.link) + .controlSize(.small) + .accessibilityLabel( + "Open \(presentation.title) settings" + ) + } + } + .frame(minWidth: 190, alignment: .trailing) + } + } + + private func permissionStatus( + _ presentation: PreferencesPermissionPresentation + ) -> some View { + let color = permissionStatusColor( + presentation.status + ) + return Label( + permissionStatusLabel(presentation.status), + systemImage: permissionStatusSymbol( + presentation.status + ) + ) + .font(.caption.weight(.semibold)) + .foregroundStyle(color) + .padding(.horizontal, 8) + .padding(.vertical, 4) + .background(color.opacity(0.11), in: Capsule()) + .accessibilityLabel( + "\(presentation.title): \(permissionStatusLabel(presentation.status))" + ) + } + + private func permissionFeatureTitle( + for permission: PreferencesPermissionKind + ) -> String { + switch permission { + case .accessibility: + return "Window switching" + case .screenRecording: + return "Window thumbnails" + } + } + + private func permissionSymbol( + for permission: PreferencesPermissionKind + ) -> String { + switch permission { + case .accessibility: + return "rectangle.stack.fill" + case .screenRecording: + return "rectangle.on.rectangle" + } + } + + private func permissionDescription( + for permission: PreferencesPermissionKind + ) -> String { + switch permission { + case .accessibility: + return "Required for window switching. Lets FreeDock find and bring forward an app’s windows across Desktops. It does not read what you type." + case .screenRecording: + return "Optional. Shows the current content of each window, including windows on other Desktops. Window switching still works without it. Each browser window reflects its selected tab; inactive tabs are not captured separately. macOS may require FreeDock to reopen after approval." + } + } + + private func permissionStatusLabel( + _ status: PreferencesPermissionStatus + ) -> String { + switch status { + case .checking: + return "Checking…" + case .notGranted: + return "Needs Access" + case .granted: + return "Granted" + } + } + + private func permissionStatusSymbol( + _ status: PreferencesPermissionStatus + ) -> String { + switch status { + case .checking: + return "ellipsis.circle.fill" + case .notGranted: + return "exclamationmark.circle.fill" + case .granted: + return "checkmark.circle.fill" + } + } + + private func permissionStatusColor( + _ status: PreferencesPermissionStatus + ) -> Color { + switch status { + case .checking: + return .secondary + case .notGranted: + return .orange + case .granted: + return .green + } + } + + private func settingsSection( + title: String, + symbol: String, + @ViewBuilder content: () -> Content + ) -> some View { + VStack(alignment: .leading, spacing: 10) { + Label(title, systemImage: symbol) + .font(.headline) + .foregroundStyle(.primary) + + VStack(spacing: 13) { + content() + } + .padding(15) + .background( + RoundedRectangle(cornerRadius: 12, style: .continuous) + .fill(Color(nsColor: .controlBackgroundColor)) + ) + .overlay( + RoundedRectangle(cornerRadius: 12, style: .continuous) + .strokeBorder(Color.primary.opacity(0.09), lineWidth: 1) + ) + } + } + + private func settingRow( + _ title: String, + @ViewBuilder control: () -> Content + ) -> some View { + HStack(spacing: 20) { + Text(title) + .frame(maxWidth: .infinity, alignment: .leading) + control() + } + } + + private func sliderRow( + title: String, + value: Binding, + range: ClosedRange, + step: Double, + valueText: String + ) -> some View { + HStack(spacing: 14) { + Text(title) + .frame(width: 115, alignment: .leading) + Slider(value: value, in: range, step: step) + .accessibilityLabel(title) + .accessibilityValue(valueText) + Text(valueText) + .font(.system(.body, design: .monospaced)) + .foregroundStyle(.secondary) + .frame(width: 58, alignment: .trailing) + } + } + + private func actionRow( + title: String, + description: String, + buttonTitle: String, + isDisabled: Bool = false, + action: @escaping () -> Void + ) -> some View { + HStack(spacing: 18) { + VStack(alignment: .leading, spacing: 3) { + Text(title) + Text(description) + .font(.caption) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + .frame(maxWidth: .infinity, alignment: .leading) + + Button(buttonTitle, action: action) + .frame(width: 76) + .disabled(isDisabled) + } + } + + private var orientationBinding: Binding { + Binding( + get: { store.selectedDock?.orientation ?? .horizontal }, + set: { store.updateSelected(.orientation($0)) } + ) + } + + private func displaySelectionBinding(for dock: DockConfig) -> Binding { + Binding( + get: { store.selectedDock?.displayPlacement?.displayID }, + set: { store.perform(.setDockDisplay(dock.id, $0)) } + ) + } + + private func isPreferredDisplayUnavailable(for dock: DockConfig) -> Bool { + guard let displayID = dock.displayPlacement?.displayID else { return false } + return !store.isDisplayConnected(displayID) + } + + private func dockSubtitle(for dock: DockConfig) -> String { + let orientation = dock.orientation == .horizontal + ? "Horizontal" + : "Vertical" + return "\(orientation) · \(store.displayLabel(for: dock))" + } + + private func displayHelpText(for dock: DockConfig) -> String { + if isPreferredDisplayUnavailable(for: dock) { + return "Temporarily shown on the main display. It will return when this display reconnects." + } + if dock.displayPlacement == nil { + return "Uses the display where this dock is currently positioned." + } + return "Keeps this dock on the selected display." + } + + private var iconSizeBinding: Binding { + Binding( + get: { store.selectedDock?.iconSize ?? 48 }, + set: { store.updateSelected(.iconSize($0)) } + ) + } + + private var magnificationBinding: Binding { + Binding( + get: { store.selectedDock?.magnification ?? 1.30 }, + set: { store.updateSelected(.magnification($0)) } + ) + } + + private var magnificationEnabledBinding: Binding { + Binding( + get: { store.selectedDock?.magnificationEnabled ?? true }, + set: { store.updateSelected(.magnificationEnabled($0)) } + ) + } + + private var itemSpacingBinding: Binding { + Binding( + get: { store.selectedDock?.itemSpacing ?? 3 }, + set: { store.updateSelected(.itemSpacing($0)) } + ) + } + + private var appearanceBinding: Binding { + Binding( + get: { store.selectedDock?.appearance ?? .glass }, + set: { store.updateSelected(.appearance($0)) } + ) + } + + private var surfaceOpacityBinding: Binding { + Binding( + get: { store.selectedDock?.surfaceOpacity ?? 1 }, + set: { store.updateSelected(.surfaceOpacity($0)) } + ) + } + + private var blurStyleBinding: Binding { + Binding( + get: { store.selectedDock?.blurStyle ?? .regular }, + set: { store.updateSelected(.blurStyle($0)) } + ) + } + + private var cornerRadiusBinding: Binding { + Binding( + get: { store.selectedDock?.cornerRadius ?? 18 }, + set: { store.updateSelected(.cornerRadius($0)) } + ) + } + + private var shadowStrengthBinding: Binding { + Binding( + get: { store.selectedDock?.shadowStrength ?? 1 }, + set: { store.updateSelected(.shadowStrength($0)) } + ) + } + + private var runningIndicatorsBinding: Binding { + Binding( + get: { store.selectedDock?.showRunningIndicators ?? true }, + set: { store.updateSelected(.showRunningIndicators($0)) } + ) + } + + private var autoHideBinding: Binding { + Binding( + get: { store.selectedDock?.autoHideWhenDocked ?? true }, + set: { store.updateSelected(.autoHideWhenDocked($0)) } + ) + } + + private var autoHideDelayBinding: Binding { + Binding( + get: { store.selectedDock?.autoHideDelay ?? 1 }, + set: { store.updateSelected(.autoHideDelay($0)) } + ) + } +} diff --git a/Sources/FreeDock/Views/DockResizeHandleRepresentable.swift b/Sources/FreeDock/Views/DockResizeHandleRepresentable.swift index fc79b1e..c7e5e4e 100644 --- a/Sources/FreeDock/Views/DockResizeHandleRepresentable.swift +++ b/Sources/FreeDock/Views/DockResizeHandleRepresentable.swift @@ -5,16 +5,19 @@ import SwiftUI struct DockResizeHandleRepresentable: NSViewRepresentable { let panel: DockPanel let orientation: Orientation + let resizableItemCount: Int func makeNSView(context _: Context) -> DockResizeHandleView { let view = DockResizeHandleView(frame: .zero) view.dockPanel = panel view.orientation = orientation + view.resizableItemCount = resizableItemCount return view } func updateNSView(_ view: DockResizeHandleView, context _: Context) { view.dockPanel = panel view.orientation = orientation + view.resizableItemCount = resizableItemCount } } diff --git a/Sources/FreeDock/Views/FolderStackView.swift b/Sources/FreeDock/Views/FolderStackView.swift new file mode 100644 index 0000000..6d1cb9b --- /dev/null +++ b/Sources/FreeDock/Views/FolderStackView.swift @@ -0,0 +1,725 @@ +import Cocoa +import SwiftUI + +struct FolderStackView: View { + let item: DockItem + let presentation: DockItemPresentation + let recentFiles: [RecentFileRecord] + let onOpenURL: (URL) -> Void + let onOpenContainer: (() -> Void)? + let onClearRecentFiles: (() -> Void)? + let onOptionsChanged: (FolderStackOptions) -> Void + let onClose: () -> Void + + @Environment(\.accessibilityReduceTransparency) + private var reduceTransparency + @State private var options: FolderStackOptions + @State private var snapshot: FolderStackSnapshot? + @State private var errorMessage: String? + @State private var isLoading = true + @State private var reloadID = UUID() + + init( + item: DockItem, + presentation: DockItemPresentation, + recentFiles: [RecentFileRecord], + onOpenURL: @escaping (URL) -> Void, + onOpenContainer: (() -> Void)?, + onClearRecentFiles: (() -> Void)?, + onOptionsChanged: @escaping (FolderStackOptions) -> Void, + onClose: @escaping () -> Void + ) { + self.item = item + self.presentation = presentation + self.recentFiles = recentFiles + self.onOpenURL = onOpenURL + self.onOpenContainer = onOpenContainer + self.onClearRecentFiles = onClearRecentFiles + self.onOptionsChanged = onOptionsChanged + self.onClose = onClose + _options = State(initialValue: Self.normalizedOptions(for: item)) + } + + private var loadKey: LoadKey { + LoadKey( + itemID: item.id, + path: item.path, + source: item.smartStackSource, + recentFiles: recentFiles, + options: options, + reloadID: reloadID + ) + } + + private var resolvedPresentation: FolderStackOptions.Presentation { + guard options.presentation == .automatic else { + return options.presentation + } + return (snapshot?.totalCount ?? 0) <= 24 ? .grid : .list + } + + var body: some View { + GeometryReader { geometry in + let layout = LayoutMetrics(availableSize: geometry.size) + + VStack(spacing: 0) { + header(layout: layout) + Divider().opacity(0.55) + content(layout: layout) + .frame(maxWidth: .infinity, maxHeight: .infinity) + if showsFooter { + Divider().opacity(0.55) + footer(layout: layout) + } + } + .background(stackBackground) + .clipShape( + RoundedRectangle( + cornerRadius: layout.cornerRadius, + style: .continuous + ) + ) + .overlay { + RoundedRectangle( + cornerRadius: layout.cornerRadius, + style: .continuous + ) + .strokeBorder(.white.opacity(0.18), lineWidth: 0.75) + } + .shadow(color: .black.opacity(0.26), radius: 22, y: 10) + .padding(layout.outerPadding) + .frame( + width: geometry.size.width, + height: geometry.size.height + ) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .task(id: loadKey) { + await loadContents() + } + .onChange(of: options) { updated in + onOptionsChanged(updated) + } + .onAppear { + if item.folderOptions != options { + onOptionsChanged(options) + } + } + .onExitCommand(perform: onClose) + .accessibilityElement(children: .contain) + .accessibilityLabel( + "\(presentation.displayName), \(presentation.kindDescription)" + ) + } + + private var displayName: String { + presentation.displayName + } + + private func header(layout: LayoutMetrics) -> some View { + HStack(spacing: layout.headerSpacing) { + if !layout.isVeryNarrow { + Image(nsImage: presentation.icon) + .resizable() + .interpolation(.high) + .frame( + width: layout.headerIconSize, + height: layout.headerIconSize + ) + .accessibilityHidden(true) + } + + VStack(alignment: .leading, spacing: 2) { + Text(displayName) + .font(.system(size: 15, weight: .semibold)) + .lineLimit(1) + .truncationMode(.middle) + .help(displayName) + + Text(headerDetail) + .font(.caption) + .foregroundStyle(.secondary) + } + + Spacer(minLength: 12) + + if !layout.isNarrow { + Button { + reloadID = UUID() + } label: { + Image(systemName: "arrow.clockwise") + } + .buttonStyle(.borderless) + .help("Refresh \(displayName)") + .accessibilityLabel("Refresh \(displayName)") + } + + optionsMenu(includesRefresh: layout.isNarrow) + + Button(action: onClose) { + Image(systemName: "xmark.circle.fill") + .symbolRenderingMode(.hierarchical) + } + .buttonStyle(.borderless) + .help("Close") + .accessibilityLabel("Close \(displayName)") + } + .padding(.horizontal, layout.horizontalChromePadding) + .padding(.vertical, layout.headerVerticalPadding) + } + + private var headerDetail: String { + if isLoading { + return "Loading…" + } + guard let snapshot else { + return errorMessage == nil ? chrome.idleDetail : "Unavailable" + } + let count = snapshot.totalCount + return "\(count) item\(count == 1 ? "" : "s")" + } + + private func optionsMenu(includesRefresh: Bool) -> some View { + Menu { + if includesRefresh { + Button { + reloadID = UUID() + } label: { + Label("Refresh", systemImage: "arrow.clockwise") + } + Divider() + } + + Section("View") { + optionButton( + "Automatic", + selected: options.presentation == .automatic + ) { + options.presentation = .automatic + } + optionButton( + "Grid", + selected: options.presentation == .grid + ) { + options.presentation = .grid + } + optionButton( + "List", + selected: options.presentation == .list + ) { + options.presentation = .list + } + } + + Section("Sort By") { + if item.smartStackSource == .recentFiles { + optionButton( + "Recently Opened", + selected: options.sortOrder == .recentlyOpened + ) { + options.sortOrder = .recentlyOpened + } + } + optionButton( + "Name", + selected: options.sortOrder == .name + ) { + options.sortOrder = .name + } + optionButton( + "Date Modified", + selected: options.sortOrder == .dateModified + ) { + options.sortOrder = .dateModified + } + optionButton( + "Kind", + selected: options.sortOrder == .kind + ) { + options.sortOrder = .kind + } + } + + if item.smartStackSource != .recentFiles { + Divider() + + Toggle( + "Show Hidden Files", + isOn: Binding( + get: { options.showHiddenFiles }, + set: { options.showHiddenFiles = $0 } + ) + ) + } + } label: { + Image(systemName: "slider.horizontal.3") + } + .menuStyle(.borderlessButton) + .fixedSize() + .help("Stack options") + .accessibilityLabel("\(displayName) stack options") + } + + private func optionButton( + _ title: String, + selected: Bool, + action: @escaping () -> Void + ) -> some View { + Button(action: action) { + if selected { + Label(title, systemImage: "checkmark") + } else { + Text(title) + } + } + } + + @ViewBuilder + private func content(layout: LayoutMetrics) -> some View { + if isLoading { + VStack(spacing: 12) { + ProgressView() + .controlSize(.small) + Text("Loading \(displayName)…") + .font(.callout) + .foregroundStyle(.secondary) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .accessibilityElement(children: .combine) + } else if let errorMessage { + VStack(spacing: 12) { + Image(systemName: chrome.errorSymbolName) + .font(.system(size: 32)) + .foregroundStyle(.secondary) + Text(chrome.errorTitle) + .font(.headline) + Text(errorMessage) + .font(.callout) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + .frame(maxWidth: 280) + Button("Try Again") { + reloadID = UUID() + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else if let snapshot, snapshot.entries.isEmpty { + VStack(spacing: 10) { + Image(systemName: chrome.emptySymbolName) + .font(.system(size: 32)) + .foregroundStyle(.secondary) + Text(chrome.emptyTitle) + .font(.headline) + Text(chrome.emptyMessage) + .font(.callout) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else if let snapshot { + switch resolvedPresentation { + case .automatic, .grid: + grid(entries: snapshot.entries, layout: layout) + case .list: + list(entries: snapshot.entries, layout: layout) + } + } + } + + private func grid( + entries: [FolderStackEntry], + layout: LayoutMetrics + ) -> some View { + ScrollView { + LazyVGrid( + columns: [ + GridItem( + .adaptive( + minimum: layout.gridMinimumWidth, + maximum: layout.gridMaximumWidth + ), + spacing: layout.gridColumnSpacing + ), + ], + spacing: layout.gridRowSpacing + ) { + ForEach(entries, id: \.url) { entry in + Button { + onOpenURL(entry.url) + } label: { + VStack(spacing: 6) { + FolderStackFileIcon( + url: entry.url, + size: layout.gridIconSize + ) + Text(entry.name) + .font(.system(size: 11)) + .foregroundStyle(.primary) + .multilineTextAlignment(.center) + .lineLimit(2) + .truncationMode(.middle) + .frame(maxWidth: layout.gridLabelWidth) + } + .frame( + maxWidth: .infinity, + minHeight: layout.gridItemHeight + ) + .contentShape(Rectangle()) + } + .buttonStyle(FolderStackItemButtonStyle()) + .help(entry.name) + .accessibilityLabel(entry.name) + .accessibilityValue(entry.kindDescription) + .accessibilityHint( + entry.isFolder + ? "Opens this folder in Finder." + : "Opens this item." + ) + } + } + .padding(layout.contentPadding) + } + } + + private func list( + entries: [FolderStackEntry], + layout: LayoutMetrics + ) -> some View { + ScrollView { + LazyVStack(spacing: 3) { + ForEach(entries, id: \.url) { entry in + Button { + onOpenURL(entry.url) + } label: { + HStack(spacing: 11) { + FolderStackFileIcon(url: entry.url, size: 30) + VStack(alignment: .leading, spacing: 2) { + Text(entry.name) + .foregroundStyle(.primary) + .lineLimit(1) + .truncationMode(.middle) + Text(entry.kindDescription) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(1) + } + Spacer() + if entry.isFolder { + Image(systemName: "arrow.up.forward.app") + .font(.caption) + .foregroundStyle(.tertiary) + } + } + .padding(.horizontal, 10) + .padding(.vertical, 7) + .contentShape(Rectangle()) + } + .buttonStyle(FolderStackItemButtonStyle()) + .accessibilityLabel(entry.name) + .accessibilityValue(entry.kindDescription) + } + } + .padding(layout.listPadding) + } + } + + private func footer(layout: LayoutMetrics) -> some View { + HStack { + if snapshot?.isTruncated == true { + if layout.isVeryNarrow { + Image(systemName: "ellipsis.circle") + .foregroundStyle(.secondary) + .help(truncatedItemsDescription) + .accessibilityLabel(truncatedItemsDescription) + } else { + Label( + truncatedItemsDescription, + systemImage: "ellipsis.circle" + ) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(1) + } + } + + Spacer() + + if let onOpenContainer { + Button(action: onOpenContainer) { + if layout.isVeryNarrow { + Image(systemName: "folder") + } else { + Text("Open in Finder") + } + } + .buttonStyle(.borderless) + .font(.callout.weight(.medium)) + .help("Open in Finder") + .accessibilityLabel("Open in Finder") + } else if let onClearRecentFiles { + Button(action: onClearRecentFiles) { + if layout.isVeryNarrow { + Image(systemName: "trash") + } else { + Text("Clear History") + } + } + .buttonStyle(.borderless) + .font(.callout.weight(.medium)) + .help("Clear Recent Files history") + .accessibilityLabel("Clear Recent Files history") + .disabled(recentFiles.isEmpty) + } + } + .padding(.horizontal, layout.horizontalChromePadding) + .padding(.vertical, layout.footerVerticalPadding) + } + + private var truncatedItemsDescription: String { + "Showing the first \(snapshot?.entries.count ?? 0) items" + } + + private var showsFooter: Bool { + snapshot?.isTruncated == true + || onOpenContainer != nil + || onClearRecentFiles != nil + } + + private var chrome: StackChrome { + switch item.smartStackSource { + case .recentFiles: + return StackChrome( + idleDetail: "Recent Files", + errorTitle: "Recent Files Unavailable", + errorSymbolName: "clock", + emptyTitle: "No Recent Files Yet", + emptyMessage: + "Documents you open through FreeDock will appear here.", + emptySymbolName: "clock" + ) + + case .downloads: + return StackChrome( + idleDetail: "Downloads", + errorTitle: "Downloads Unavailable", + errorSymbolName: "arrow.down.circle", + emptyTitle: "No Downloads", + emptyMessage: "Files you download will appear here.", + emptySymbolName: "arrow.down.circle" + ) + + case nil: + return StackChrome( + idleDetail: "Folder", + errorTitle: "Folder Unavailable", + errorSymbolName: "folder.badge.questionmark", + emptyTitle: "Folder Is Empty", + emptyMessage: + "Items added to this folder will appear here.", + emptySymbolName: "folder" + ) + } + } + + @ViewBuilder + private var stackBackground: some View { + if reduceTransparency { + Color(nsColor: .windowBackgroundColor) + } else { + Rectangle().fill(.regularMaterial) + } + } + + private func loadContents() async { + isLoading = true + errorMessage = nil + + var configuredItem = item + configuredItem.folderOptions = options + let loadItem = configuredItem + let recentFiles = recentFiles + let outcome = await Task.detached(priority: .userInitiated) { + do { + return FolderStackLoadOutcome.loaded( + try FolderStackLoader.load( + item: loadItem, + recentFiles: recentFiles, + limit: 200 + ) + ) + } catch { + return FolderStackLoadOutcome.failed( + error.localizedDescription + ) + } + }.value + + guard !Task.isCancelled else { return } + switch outcome { + case let .loaded(snapshot): + self.snapshot = snapshot + errorMessage = nil + case let .failed(message): + snapshot = nil + errorMessage = message + } + isLoading = false + } + + private static func normalizedOptions( + for item: DockItem + ) -> FolderStackOptions { + var options = item.folderOptions + ?? item.smartStackSource?.defaultOptions + ?? FolderStackOptions() + if item.smartStackSource == .recentFiles { + options.showHiddenFiles = false + } else if options.sortOrder == .recentlyOpened { + options.sortOrder = item.smartStackSource?.defaultOptions.sortOrder + ?? .name + } + return options + } + + private struct LoadKey: Hashable { + let itemID: UUID + let path: String + let source: SmartStackSource? + let recentFiles: [RecentFileRecord] + let options: FolderStackOptions + let reloadID: UUID + } + + private struct StackChrome { + let idleDetail: String + let errorTitle: String + let errorSymbolName: String + let emptyTitle: String + let emptyMessage: String + let emptySymbolName: String + } + + private struct LayoutMetrics { + let isNarrow: Bool + let isVeryNarrow: Bool + let isShort: Bool + + init(availableSize: CGSize) { + isNarrow = availableSize.width < 400 + isVeryNarrow = availableSize.width < 280 + isShort = availableSize.height < 340 + } + + var outerPadding: CGFloat { + isNarrow || isShort ? 8 : 16 + } + + var cornerRadius: CGFloat { + isNarrow || isShort ? 14 : 18 + } + + var horizontalChromePadding: CGFloat { + isNarrow ? 10 : 18 + } + + var headerVerticalPadding: CGFloat { + isShort ? 8 : 14 + } + + var footerVerticalPadding: CGFloat { + isShort ? 7 : 11 + } + + var headerSpacing: CGFloat { + isNarrow ? 8 : 12 + } + + var headerIconSize: CGFloat { + isNarrow ? 30 : 36 + } + + var gridMinimumWidth: CGFloat { + isNarrow ? 58 : 72 + } + + var gridMaximumWidth: CGFloat { + isNarrow ? 72 : 88 + } + + var gridColumnSpacing: CGFloat { + isNarrow ? 7 : 10 + } + + var gridRowSpacing: CGFloat { + isShort ? 8 : 13 + } + + var gridIconSize: CGFloat { + isNarrow || isShort ? 40 : 48 + } + + var gridLabelWidth: CGFloat { + isNarrow ? 66 : 82 + } + + var gridItemHeight: CGFloat { + isNarrow || isShort ? 68 : 78 + } + + var contentPadding: CGFloat { + isNarrow || isShort ? 8 : 16 + } + + var listPadding: CGFloat { + isNarrow || isShort ? 6 : 10 + } + } +} + +private enum FolderStackLoadOutcome: Sendable { + case loaded(FolderStackSnapshot) + case failed(String) +} + +private struct FolderStackFileIcon: View { + let url: URL + let size: CGFloat + @State private var icon: NSImage? + + var body: some View { + Image(nsImage: icon ?? placeholder) + .resizable() + .interpolation(.high) + .frame(width: size, height: size) + .onAppear { + guard icon == nil else { return } + let workspaceIcon = NSWorkspace.shared.icon(forFile: url.path) + let resolved = (workspaceIcon.copy() as? NSImage) + ?? workspaceIcon + resolved.size = NSSize(width: size, height: size) + icon = resolved + } + .accessibilityHidden(true) + } + + private var placeholder: NSImage { + NSImage( + systemSymbolName: "doc", + accessibilityDescription: nil + ) ?? NSImage() + } +} + +private struct FolderStackItemButtonStyle: ButtonStyle { + func makeBody(configuration: Configuration) -> some View { + configuration.label + .background( + RoundedRectangle(cornerRadius: 10, style: .continuous) + .fill( + configuration.isPressed + ? Color.accentColor.opacity(0.16) + : Color.clear + ) + ) + } +} diff --git a/Sources/FreeDock/Views/QuickLaunchSearchField.swift b/Sources/FreeDock/Views/QuickLaunchSearchField.swift new file mode 100644 index 0000000..f6ef976 --- /dev/null +++ b/Sources/FreeDock/Views/QuickLaunchSearchField.swift @@ -0,0 +1,154 @@ +import AppKit +import SwiftUI + +/// A native search field for Quick Launch that keeps text entry and input +/// methods under AppKit while forwarding launcher-specific key commands. +struct QuickLaunchSearchField: NSViewRepresentable { + enum Command: Equatable, Sendable { + case moveLeft + case moveRight + case moveUp + case moveDown + case next + case previous + case activate + case dismiss + } + + @Binding var text: String + let focusGeneration: Int + let placeholder: String + let accessibilityLabel: String + let accessibilityHelp: String + let accessibilityStatus: String + let onCommand: @MainActor (Command) -> Void + + func makeCoordinator() -> Coordinator { + Coordinator(self) + } + + func makeNSView(context: Context) -> QuickLaunchNativeSearchField { + let searchField = QuickLaunchNativeSearchField() + searchField.delegate = context.coordinator + searchField.sendsSearchStringImmediately = true + searchField.sendsWholeSearchString = false + searchField.font = .systemFont(ofSize: 13, weight: .medium) + searchField.focusRingType = .none + searchField.isBezeled = false + searchField.isBordered = false + searchField.drawsBackground = false + searchField.usesSingleLineMode = true + searchField.lineBreakMode = .byTruncatingTail + searchField.placeholderString = placeholder + searchField.setAccessibilityLabel(accessibilityLabel) + searchField.setAccessibilityHelp(combinedAccessibilityHelp) + return searchField + } + + func updateNSView( + _ searchField: QuickLaunchNativeSearchField, + context: Context + ) { + context.coordinator.parent = self + + let isComposingText = + (searchField.currentEditor() as? NSTextView)?.hasMarkedText() + ?? false + if searchField.stringValue != text, !isComposingText { + searchField.stringValue = text + } + + if searchField.placeholderString != placeholder { + searchField.placeholderString = placeholder + } + searchField.setAccessibilityLabel(accessibilityLabel) + searchField.setAccessibilityHelp(combinedAccessibilityHelp) + + if context.coordinator.lastHandledFocusGeneration != focusGeneration { + context.coordinator.lastHandledFocusGeneration = focusGeneration + searchField.requestFocus() + } + } + + private var combinedAccessibilityHelp: String { + "\(accessibilityHelp) \(accessibilityStatus)" + } + + final class Coordinator: NSObject, NSSearchFieldDelegate { + var parent: QuickLaunchSearchField + var lastHandledFocusGeneration: Int? + + init(_ parent: QuickLaunchSearchField) { + self.parent = parent + } + + func controlTextDidChange(_ notification: Notification) { + guard let searchField = notification.object as? NSSearchField else { + return + } + let newValue = searchField.stringValue + if parent.text != newValue { + parent.text = newValue + } + } + + func control( + _: NSControl, + textView: NSTextView, + doCommandBy commandSelector: Selector + ) -> Bool { + // Arrow, Return, Tab, and Escape may be part of an active input + // method composition. Let AppKit finish or cancel that composition + // before treating the same key as a launcher command. + guard !textView.hasMarkedText() else { return false } + + let command: Command? + switch NSStringFromSelector(commandSelector) { + case "moveLeft:": + command = .moveLeft + case "moveRight:": + command = .moveRight + case "moveUp:": + command = .moveUp + case "moveDown:": + command = .moveDown + case "insertTab:": + command = .next + case "insertBacktab:": + command = .previous + case "insertNewline:", "insertNewlineIgnoringFieldEditor:": + command = .activate + case "cancelOperation:": + command = .dismiss + default: + command = nil + } + + guard let command else { return false } + parent.onCommand(command) + return true + } + } +} + +final class QuickLaunchNativeSearchField: NSSearchField { + private var hasPendingFocusRequest = false + + override var mouseDownCanMoveWindow: Bool { false } + + func requestFocus() { + hasPendingFocusRequest = true + fulfillFocusRequestIfPossible() + } + + override func viewDidMoveToWindow() { + super.viewDidMoveToWindow() + fulfillFocusRequestIfPossible() + } + + private func fulfillFocusRequestIfPossible() { + guard hasPendingFocusRequest, let window else { return } + guard window.makeFirstResponder(self) else { return } + hasPendingFocusRequest = false + } +} diff --git a/Sources/FreeDock/Views/WindowPreviewView.swift b/Sources/FreeDock/Views/WindowPreviewView.swift new file mode 100644 index 0000000..4a70b5c --- /dev/null +++ b/Sources/FreeDock/Views/WindowPreviewView.swift @@ -0,0 +1,527 @@ +import AppKit +import SwiftUI + +enum WindowPreviewThumbnailState: Equatable, Sendable { + case permissionRequired + case loading + case available + case unavailable + + static func resolve( + hasThumbnail: Bool, + isCaptureEnabled: Bool, + isLoading: Bool, + isUnavailable: Bool, + hasCaptureWindow: Bool + ) -> WindowPreviewThumbnailState { + guard isCaptureEnabled else { + return .permissionRequired + } + if hasThumbnail { + return .available + } + if isLoading { + return .loading + } + if isUnavailable || !hasCaptureWindow { + return .unavailable + } + // Capture begins immediately after the panel is presented. Treat the + // brief interval before its task is registered as loading rather than + // showing an unexplained app-icon placeholder. + return .loading + } +} + +struct WindowPreviewView: View { + let applicationName: String + let applicationIcon: NSImage + let windows: [DockApplicationWindow] + let thumbnails: [DockApplicationWindow.ID: NSImage] + let thumbnailLoadingIDs: Set + let thumbnailUnavailableIDs: Set + let isThumbnailCaptureEnabled: Bool + let keyboardSelectedWindowID: DockApplicationWindow.ID? + let onWindowSelected: (DockApplicationWindow.ID) -> Void + let onEnableWindowThumbnails: () -> Void + let onHoverChanged: (Bool) -> Void + let onClose: () -> Void + + @Environment(\.accessibilityReduceTransparency) + private var reduceTransparency + @Environment(\.accessibilityReduceMotion) + private var reduceMotion + + var body: some View { + VStack(alignment: .leading, spacing: 9) { + header + + ScrollViewReader { scrollProxy in + ScrollView( + .horizontal, + showsIndicators: windows.count > 3 + ) { + HStack(spacing: 8) { + ForEach(windows) { window in + windowCard(window) + .id(window.id) + } + } + .padding(.bottom, windows.count > 3 ? 5 : 0) + } + .onAppear { + scrollToKeyboardSelection( + with: scrollProxy + ) + } + .onChange(of: keyboardSelectedWindowID) { _ in + scrollToKeyboardSelection( + with: scrollProxy + ) + } + .onChange(of: windows.map(\.id)) { _ in + scrollToKeyboardSelection( + with: scrollProxy + ) + } + } + } + .padding(10) + .background(panelBackground) + .clipShape( + RoundedRectangle( + cornerRadius: 14, + style: .continuous + ) + ) + .overlay { + RoundedRectangle( + cornerRadius: 14, + style: .continuous + ) + .strokeBorder(.white.opacity(0.22), lineWidth: 0.75) + } + .shadow(color: .black.opacity(0.28), radius: 18, y: 8) + .contentShape(Rectangle()) + .onHover(perform: onHoverChanged) + .accessibilityElement(children: .contain) + .accessibilityLabel("\(applicationName) windows") + } + + private var header: some View { + HStack(spacing: 7) { + Image(nsImage: applicationIcon) + .resizable() + .frame(width: 20, height: 20) + .accessibilityHidden(true) + + Text(applicationName) + .font(.system(size: 12, weight: .semibold)) + .lineLimit(1) + .help(applicationName) + + Text("\(windows.count)") + .font(.system(size: 10, weight: .semibold, design: .rounded)) + .foregroundStyle(.secondary) + .padding(.horizontal, 6) + .padding(.vertical, 2) + .background(.primary.opacity(0.07), in: Capsule()) + .accessibilityLabel( + "\(windows.count) \(windows.count == 1 ? "window" : "windows")" + ) + + Spacer(minLength: 8) + + if !isThumbnailCaptureEnabled { + Button(action: onEnableWindowThumbnails) { + Image(systemName: "rectangle.on.rectangle") + .font(.system(size: 10, weight: .medium)) + .frame(width: 20, height: 20) + .background( + Color.accentColor.opacity(0.14), + in: Circle() + ) + } + .buttonStyle(.plain) + .help( + "Enable Window Thumbnails using Screen Recording. Each thumbnail shows the window’s current content." + ) + .accessibilityLabel("Enable Window Thumbnails") + .accessibilityHint( + "Requests macOS Screen Recording permission." + ) + } + + Button(action: onClose) { + Image(systemName: "xmark") + .font(.system(size: 9, weight: .bold)) + .frame(width: 18, height: 18) + .background(.primary.opacity(0.08), in: Circle()) + } + .buttonStyle(.plain) + .help("Close window previews") + .accessibilityLabel("Close window previews") + } + .foregroundStyle(.primary) + } + + private func windowCard( + _ window: DockApplicationWindow + ) -> some View { + let thumbnailState = thumbnailState(for: window) + return Button { + onWindowSelected(window.id) + } label: { + VStack(alignment: .leading, spacing: 6) { + ZStack { + RoundedRectangle(cornerRadius: 9, style: .continuous) + .fill(.black.opacity(0.18)) + + if thumbnailState == .available, + let thumbnail = thumbnails[window.id] + { + Image(nsImage: thumbnail) + .resizable() + .aspectRatio(contentMode: .fit) + .frame( + maxWidth: .infinity, + maxHeight: .infinity + ) + .background(.black.opacity(0.2)) + .accessibilityHidden(true) + } else { + placeholder(for: thumbnailState) + } + + if window.isMinimized + || window.isApplicationHidden + || window.isOnScreen == false + { + statusBadge(for: window) + .frame( + maxWidth: .infinity, + maxHeight: .infinity, + alignment: .bottomTrailing + ) + .padding(7) + } + + if window.isFocused || window.isMain { + currentWindowBadge + .frame( + maxWidth: .infinity, + maxHeight: .infinity, + alignment: .topLeading + ) + .padding(7) + } + + if !window.canFocusExactly { + fallbackFocusBadge + .frame( + maxWidth: .infinity, + maxHeight: .infinity, + alignment: .bottomLeading + ) + .padding(7) + } + } + .frame(width: 166, height: 96) + .clipShape( + RoundedRectangle( + cornerRadius: 9, + style: .continuous + ) + ) + .overlay { + RoundedRectangle( + cornerRadius: 9, + style: .continuous + ) + .strokeBorder( + Color.primary.opacity(0.12), + lineWidth: 0.75 + ) + } + + Text(window.title) + .font(.system(size: 11, weight: .medium)) + .foregroundStyle(.primary) + .lineLimit(2) + .multilineTextAlignment(.leading) + .frame(width: 166, alignment: .leading) + } + .padding(4) + .contentShape( + RoundedRectangle(cornerRadius: 11, style: .continuous) + ) + .background { + RoundedRectangle( + cornerRadius: 11, + style: .continuous + ) + .fill( + isKeyboardSelected(window) + ? Color.primary.opacity(0.09) + : .clear + ) + } + .overlay { + RoundedRectangle( + cornerRadius: 11, + style: .continuous + ) + .strokeBorder( + isKeyboardSelected(window) + ? Color.primary.opacity(0.30) + : .clear, + lineWidth: 1 + ) + } + } + .buttonStyle(.plain) + .focusable(false) + .help(windowHelp(window)) + .accessibilityLabel(window.title) + .accessibilityValue(windowAccessibilityValue(window)) + .accessibilityHint(windowAccessibilityHint(window)) + .accessibilityAddTraits( + isKeyboardSelected(window) ? .isSelected : [] + ) + } + + private func statusBadge( + for window: DockApplicationWindow + ) -> some View { + Label( + windowStatusLabel(window), + systemImage: windowStatusIcon(window) + ) + .font(.system(size: 9, weight: .semibold)) + .foregroundStyle(.white) + .padding(.horizontal, 6) + .padding(.vertical, 4) + .background(.black.opacity(0.72), in: Capsule()) + .accessibilityHidden(true) + } + + private var currentWindowBadge: some View { + Label("Current", systemImage: "checkmark") + .font(.system(size: 9, weight: .semibold)) + .foregroundStyle(.white) + .padding(.horizontal, 6) + .padding(.vertical, 4) + .background(.black.opacity(0.72), in: Capsule()) + .accessibilityHidden(true) + } + + private var fallbackFocusBadge: some View { + Text("App only") + .font(.system(size: 9, weight: .semibold)) + .foregroundStyle(.white) + .padding(.horizontal, 6) + .padding(.vertical, 4) + .background(.black.opacity(0.72), in: Capsule()) + .accessibilityHidden(true) + } + + private func placeholder( + for state: WindowPreviewThumbnailState + ) -> some View { + ZStack { + LinearGradient( + colors: [ + .primary.opacity(0.10), + .primary.opacity(0.035), + ], + startPoint: .top, + endPoint: .bottom + ) + + VStack(spacing: 7) { + Image(nsImage: applicationIcon) + .resizable() + .frame(width: 38, height: 38) + .shadow( + color: .black.opacity(0.15), + radius: 4, + y: 2 + ) + + placeholderStatus(for: state) + } + } + .accessibilityHidden(true) + } + + @ViewBuilder + private func placeholderStatus( + for state: WindowPreviewThumbnailState + ) -> some View { + switch state { + case .permissionRequired: + Label( + "Thumbnail not enabled", + systemImage: "eye.slash" + ) + .font(.system(size: 9, weight: .medium)) + .foregroundStyle(.secondary) + case .loading: + HStack(spacing: 5) { + ProgressView() + .controlSize(.mini) + Text("Loading thumbnail…") + } + .font(.system(size: 9, weight: .medium)) + .foregroundStyle(.secondary) + case .unavailable: + Label( + "Thumbnail unavailable", + systemImage: "eye.slash" + ) + .font(.system(size: 9, weight: .medium)) + .foregroundStyle(.secondary) + case .available: + EmptyView() + } + } + + private func thumbnailState( + for window: DockApplicationWindow + ) -> WindowPreviewThumbnailState { + WindowPreviewThumbnailState.resolve( + hasThumbnail: thumbnails[window.id] != nil, + isCaptureEnabled: isThumbnailCaptureEnabled, + isLoading: thumbnailLoadingIDs.contains(window.id), + isUnavailable: + thumbnailUnavailableIDs.contains(window.id), + hasCaptureWindow: window.captureWindowID != nil + ) + } + + private func windowStatusLabel( + _ window: DockApplicationWindow + ) -> String { + if window.isMinimized { + return "Minimized" + } + if window.isApplicationHidden { + return "Hidden" + } + return "Not visible" + } + + private func windowStatusIcon( + _ window: DockApplicationWindow + ) -> String { + if window.isMinimized { + return "minus" + } + if window.isApplicationHidden { + return "eye.slash.fill" + } + return "rectangle.on.rectangle" + } + + private func windowHelp( + _ window: DockApplicationWindow + ) -> String { + if window.canFocusExactly { + return "Bring “\(window.title)” to front" + } + return "Activate \(window.applicationName). macOS did not expose this window for exact switching." + } + + private func windowAccessibilityHint( + _ window: DockApplicationWindow + ) -> String { + if isKeyboardSelected(window) { + return window.canFocusExactly + ? "Press Return to bring this window to front." + : "Press Return to activate the application." + } + return window.canFocusExactly + ? "Brings this window to front." + : "Activates the application; exact window switching is unavailable." + } + + private func windowAccessibilityValue( + _ window: DockApplicationWindow + ) -> String { + var values: [String] = [] + if window.isFocused { + values.append("focused") + } else if window.isMain { + values.append("main") + } + if isKeyboardSelected(window) { + values.append("keyboard selected") + } + if window.isMinimized { + values.append("minimized") + } + if window.isApplicationHidden { + values.append("application hidden") + } + if window.isOnScreen == false, + !window.isMinimized, + !window.isApplicationHidden + { + values.append("another Desktop or off-screen") + } + switch thumbnailState(for: window) { + case .available: + values.append("window thumbnail") + case .permissionRequired: + values.append("preview permission not enabled") + case .loading: + values.append("window thumbnail loading") + case .unavailable: + values.append("window thumbnail unavailable") + } + if !window.canFocusExactly { + values.append("application activation only") + } + return values.joined(separator: ", ") + } + + private func isKeyboardSelected( + _ window: DockApplicationWindow + ) -> Bool { + keyboardSelectedWindowID == window.id + } + + private func scrollToKeyboardSelection( + with proxy: ScrollViewProxy + ) { + guard let keyboardSelectedWindowID, + windows.contains(where: { + $0.id == keyboardSelectedWindowID + }) + else { + return + } + if reduceMotion { + proxy.scrollTo( + keyboardSelectedWindowID, + anchor: .center + ) + } else { + withAnimation(.easeOut(duration: 0.12)) { + proxy.scrollTo( + keyboardSelectedWindowID, + anchor: .center + ) + } + } + } + + @ViewBuilder + private var panelBackground: some View { + if reduceTransparency { + Color(nsColor: .windowBackgroundColor) + } else { + Rectangle().fill(.ultraThinMaterial) + } + } +} diff --git a/Sources/FreeDock/Windows/DockContainerView.swift b/Sources/FreeDock/Windows/DockContainerView.swift index a8a1214..c8279e7 100644 --- a/Sources/FreeDock/Windows/DockContainerView.swift +++ b/Sources/FreeDock/Windows/DockContainerView.swift @@ -1,9 +1,94 @@ import Cocoa +enum DockRevealEdge { + case left + case right + case top + case bottom +} + class DockContainerView: NSView { weak var dockPanel: DockPanel? + private(set) var isPointerInside = false private var trackingArea: NSTrackingArea? + private let revealIndicatorLayer = CALayer() + private var revealEdge: DockRevealEdge? + + override var mouseDownCanMoveWindow: Bool { false } + + override init(frame frameRect: NSRect) { + super.init(frame: frameRect) + wantsLayer = true + revealIndicatorLayer.opacity = 0 + revealIndicatorLayer.borderWidth = 0.5 + revealIndicatorLayer.shadowOpacity = 0.16 + revealIndicatorLayer.shadowRadius = 3 + revealIndicatorLayer.shadowOffset = CGSize(width: 0, height: 1) + revealIndicatorLayer.zPosition = 1_000 + layer?.addSublayer(revealIndicatorLayer) + updateRevealAppearance() + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + wantsLayer = true + revealIndicatorLayer.zPosition = 1_000 + layer?.addSublayer(revealIndicatorLayer) + updateRevealAppearance() + } + + override func layout() { + super.layout() + layoutRevealIndicator() + } + + override func viewDidChangeEffectiveAppearance() { + super.viewDidChangeEffectiveAppearance() + updateRevealAppearance() + } + + func showRevealIndicator(at edge: DockRevealEdge) { + revealEdge = edge + layoutRevealIndicator() + revealIndicatorLayer.opacity = 1 + } + + func hideRevealIndicator() { + revealIndicatorLayer.opacity = 0 + revealEdge = nil + } + + private func layoutRevealIndicator() { + guard let revealEdge else { return } + + let thickness: CGFloat = 4 + let length: CGFloat = 42 + let frame: NSRect + + switch revealEdge { + case .left: + frame = NSRect(x: bounds.maxX - thickness - 1, y: bounds.midY - length / 2, width: thickness, height: length) + case .right: + frame = NSRect(x: 1, y: bounds.midY - length / 2, width: thickness, height: length) + case .bottom: + frame = NSRect(x: bounds.midX - length / 2, y: bounds.maxY - thickness - 1, width: length, height: thickness) + case .top: + frame = NSRect(x: bounds.midX - length / 2, y: 1, width: length, height: thickness) + } + + CATransaction.begin() + CATransaction.setDisableActions(true) + revealIndicatorLayer.frame = frame + revealIndicatorLayer.cornerRadius = thickness / 2 + CATransaction.commit() + } + + private func updateRevealAppearance() { + revealIndicatorLayer.backgroundColor = NSColor.labelColor.withAlphaComponent(0.34).cgColor + revealIndicatorLayer.borderColor = NSColor.white.withAlphaComponent(0.18).cgColor + revealIndicatorLayer.shadowColor = NSColor.black.cgColor + } override func updateTrackingAreas() { if let trackingArea { @@ -15,6 +100,7 @@ class DockContainerView: NSView { options: [ .mouseEnteredAndExited, .activeAlways, + .enabledDuringMouseDrag, .inVisibleRect, ], owner: self, @@ -26,10 +112,12 @@ class DockContainerView: NSView { } override func mouseEntered(with _: NSEvent) { + isPointerInside = true dockPanel?.cancelAutoHide() } override func mouseExited(with _: NSEvent) { + isPointerInside = false dockPanel?.scheduleAutoHide() } } diff --git a/Sources/FreeDock/Windows/DockDragHandleView.swift b/Sources/FreeDock/Windows/DockDragHandleView.swift index 30240f6..941ed9e 100644 --- a/Sources/FreeDock/Windows/DockDragHandleView.swift +++ b/Sources/FreeDock/Windows/DockDragHandleView.swift @@ -10,31 +10,17 @@ class DockDragHandleView: NSView { private var tracking: NSTrackingArea? private var stripRect: NSRect { - let edgeInset: CGFloat = 8 - if orientation == .horizontal { - let hitWidth: CGFloat = 24 - return NSRect( - x: edgeInset, - y: edgeInset, - width: hitWidth, - height: max(0, bounds.height - edgeInset * 2) - ) + return bounds.insetBy(dx: 3, dy: 8) } - let hitHeight: CGFloat = 24 - return NSRect( - x: edgeInset, - y: edgeInset, - width: max(0, bounds.width - edgeInset * 2), - height: hitHeight - ) + return bounds.insetBy(dx: 8, dy: 3) } override init(frame: NSRect) { super.init(frame: frame) wantsLayer = true - highlightLayer.cornerRadius = 8 + highlightLayer.cornerRadius = 3 highlightLayer.borderWidth = 1 layer?.addSublayer(highlightLayer) updateAppearance(isHovered: false) @@ -61,21 +47,21 @@ class DockDragHandleView: NSView { override func layout() { super.layout() - let pillW: CGFloat = 5 - let pillH = CGFloat(22) + let pillW: CGFloat = 3 + let pillH = CGFloat(18) if orientation == .horizontal { highlightLayer.frame = NSRect( - x: (stripRect.width - pillW) / 2 + stripRect.minX, - y: (bounds.height - pillH) / 2, + x: stripRect.midX - pillW / 2, + y: stripRect.midY - pillH / 2, width: pillW, height: pillH ) highlightLayer.cornerRadius = pillW / 2 } else { highlightLayer.frame = NSRect( - x: (bounds.width - pillH) / 2, - y: (stripRect.height - pillW) / 2 + stripRect.minY, + x: stripRect.midX - pillH / 2, + y: stripRect.midY - pillW / 2, width: pillH, height: pillW ) @@ -115,11 +101,11 @@ class DockDragHandleView: NSView { private func updateAppearance(isHovered: Bool) { let locked = dockPanel?.dockDelegate?.lockPositions ?? false - highlightLayer.backgroundColor = NSColor.white - .withAlphaComponent(isHovered ? 0.55 : 0.25) + highlightLayer.backgroundColor = NSColor.labelColor + .withAlphaComponent(isHovered ? 0.38 : 0) .cgColor highlightLayer.borderColor = NSColor.white - .withAlphaComponent(isHovered ? 0.70 : 0.35) + .withAlphaComponent(isHovered ? 0.24 : 0) .cgColor alphaValue = locked ? 0.4 : 1.0 } diff --git a/Sources/FreeDock/Windows/DockPanel.swift b/Sources/FreeDock/Windows/DockPanel.swift index 587562e..42ac2a2 100644 --- a/Sources/FreeDock/Windows/DockPanel.swift +++ b/Sources/FreeDock/Windows/DockPanel.swift @@ -2,23 +2,73 @@ import Cocoa import OSLog import SwiftUI -class DockPanel: NSPanel { +private final class DockHostingView: NSHostingView { + override var mouseDownCanMoveWindow: Bool { false } + + override func acceptsFirstMouse(for _: NSEvent?) -> Bool { + true + } +} + +class DockPanel: NSPanel, NSWindowDelegate { let dockID: UUID var dockOrientation: Orientation = .horizontal + var autoHideDelay: TimeInterval = 1 + var allowedAutoHideEdges = Set(DockScreenEdge.allCases) + var autoHideWhenDocked = true { + didSet { + guard oldValue != autoHideWhenDocked else { return } + if autoHideWhenDocked { + updateAutoHideEdge() + } else { + autoHideEdge = nil + cancelAutoHide() + shownFrame = nil + } + } + } weak var dockDelegate: DockPanelDelegate? private weak var hostingView: NSView? private var hideWorkItem: DispatchWorkItem? + private var revealCompletionWorkItem: DispatchWorkItem? + private var autoHideEdge: DockScreenEdge? + private var shownFrame: NSRect? + private var isAutoHidden = false + private var isAutoHideRevealInProgress = false + private var isUserMovingWindow = false + private var moveCompletionWorkItem: DispatchWorkItem? + private var transientInteractionTokens = Set() + private var menuInteractionTokens: [ObjectIdentifier: UUID] = [:] + private var resizeInteractionTokens = Set() + private var resizeReferenceFrame: NSRect? + private var resizeReferenceEdge: DockScreenEdge? + private var quickLaunchKeyModeEnabled = false + + private let edgeTolerance: CGFloat = 2 + private let revealThickness = DockDisplayGeometry.autoHideRevealThickness + + override var canBecomeKey: Bool { quickLaunchKeyModeEnabled } + override var canBecomeMain: Bool { false } + + /// The frame users expect to restore to, even while an edge-docked panel is hidden. + var frameForPersistence: NSRect { + shownFrame ?? frame + } + + var dockedEdgeForLayout: DockScreenEdge? { + resizeAnchorEdge(for: frameForPersistence) + } private func enforcedSize(for intrinsicSize: NSSize) -> NSSize { if dockOrientation == .vertical { return NSSize( - width: max(intrinsicSize.width, 24), - height: max(intrinsicSize.height, 320) + width: max(intrinsicSize.width, 58), + height: max(intrinsicSize.height, 86) ) } return NSSize( - width: max(intrinsicSize.width, 320), - height: max(intrinsicSize.height, 24) + width: max(intrinsicSize.width, 86), + height: max(intrinsicSize.height, 58) ) } @@ -37,23 +87,79 @@ class DockPanel: NSPanel { level = .floating isOpaque = false backgroundColor = .clear - hasShadow = true + // The shelf draws its own shape-aware shadow; a window shadow would outline + // the transparent magnification headroom as a second, rectangular border. + hasShadow = false collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary] isMovableByWindowBackground = false titleVisibility = .hidden titlebarAppearsTransparent = true isReleasedWhenClosed = false + delegate = self + + let notificationCenter = NotificationCenter.default + notificationCenter.addObserver( + self, + selector: #selector(menuDidBeginTracking(_:)), + name: NSMenu.didBeginTrackingNotification, + object: nil + ) + notificationCenter.addObserver( + self, + selector: #selector(menuDidEndTracking(_:)), + name: NSMenu.didEndTrackingNotification, + object: nil + ) + } + + override func constrainFrameRect(_ frameRect: NSRect, to screen: NSScreen?) -> NSRect { + isAutoHidden ? frameRect : super.constrainFrameRect(frameRect, to: screen) + } + + func windowWillMove(_: Notification) { + guard !isAutoHidden, resizeInteractionTokens.isEmpty else { return } + isUserMovingWindow = true + hideWorkItem?.cancel() + } + + func windowDidMove(_: Notification) { + guard isUserMovingWindow, resizeInteractionTokens.isEmpty else { return } + moveCompletionWorkItem?.cancel() + + let work = DispatchWorkItem { [weak self] in + guard let self else { return } + self.isUserMovingWindow = false + self.dockDelegate?.dockPanelDidMove(self) + } + moveCompletionWorkItem = work + DispatchQueue.main.asyncAfter(deadline: .now() + 0.12, execute: work) + } + + func windowDidResignKey(_: Notification) { + dockDelegate?.dockPanelDidResignKey(self) + } + + func setQuickLaunchKeyMode(_ enabled: Bool) { + quickLaunchKeyModeEnabled = enabled + } + + func setPositionLocked(_: Bool) { + // Moving is owned exclusively by DockDragHandleView so AppKit never + // mistakes an icon reorder gesture for a background-window drag. + isMovableByWindowBackground = false } func setContentView(_ view: V) { let container = DockContainerView(frame: NSRect(origin: .zero, size: NSSize(width: 400, height: 70))) container.dockPanel = self - let hosting = NSHostingView(rootView: view) + let hosting = DockHostingView(rootView: view) hosting.frame = container.bounds hosting.autoresizingMask = [.width, .height] hosting.wantsLayer = true - hosting.layer?.cornerRadius = 14 + // Corner treatment belongs to the shelf itself, not the larger transparent + // hosting view that also reserves room for icon magnification. + hosting.layer?.cornerRadius = 0 container.addSubview(hosting) hostingView = hosting contentView = container @@ -69,46 +175,316 @@ class DockPanel: NSPanel { let hosting = hostingView else { return } + let isInteractiveResize = !resizeInteractionTokens.isEmpty + let originalFrame = isInteractiveResize + ? (resizeReferenceFrame ?? frame) + : frame + let preservedEdge = isInteractiveResize + ? resizeReferenceEdge + : resizeAnchorEdge(for: originalFrame) hosting.invalidateIntrinsicContentSize() let intrinsicSize = hosting.fittingSize let enforcedSize = enforcedSize(for: intrinsicSize) + let anchoredFrame = DockPanelResizeGeometry.anchoredFrame( + from: originalFrame, + size: enforcedSize, + orientation: dockOrientation, + dockedEdge: preservedEdge + ) + + setFrame(anchoredFrame, display: true) container.setFrameSize(enforcedSize) hosting.frame = container.bounds - setContentSize(enforcedSize) + if shownFrame != nil { + shownFrame = anchoredFrame + } } /// Prevent docks from landing off-screen (e.g., after monitor disconnect) - func clampToVisibleFrame() { - guard let screen = NSScreen.main else { return } + func clampToVisibleFrame(on preferredScreen: NSScreen? = nil) { + guard let screen = preferredScreen ?? activeScreen ?? NSScreen.screens.first else { + return + } let vf = screen.visibleFrame - var f = frame - f.origin.x = min(max(f.origin.x, vf.minX), vf.maxX - f.width) - f.origin.y = min(max(f.origin.y, vf.minY), vf.maxY - f.height) - setFrame(f, display: true) + setFrame(DockDisplayGeometry.clamped(frame, to: vf), display: true) + updateAutoHideEdge() + } + + /// Enables slide-away auto-hide only when this dock rests on a screen edge. + func updateAutoHideEdge() { + guard autoHideWhenDocked, + !isAutoHidden, + let visibleFrame = activeScreen?.visibleFrame + else { return } + + let currentFrame = frame + shownFrame = currentFrame + + let candidates: [(DockScreenEdge, CGFloat)] = [ + (.left, abs(currentFrame.minX - visibleFrame.minX)), + (.right, abs(currentFrame.maxX - visibleFrame.maxX)), + (.bottom, abs(currentFrame.minY - visibleFrame.minY)), + (.top, abs(currentFrame.maxY - visibleFrame.maxY)), + ].filter { allowedAutoHideEdges.contains($0.0) } + + autoHideEdge = candidates.min { $0.1 < $1.1 }.flatMap { + $0.1 <= edgeTolerance ? $0.0 : nil + } } func scheduleAutoHide() { hideWorkItem?.cancel() + guard autoHideWhenDocked, + transientInteractionTokens.isEmpty, + let edge = autoHideEdge, + let visibleFrame = activeScreen?.visibleFrame, + let restingFrame = shownFrame + else { return } + let work = DispatchWorkItem { [weak self] in guard let self else { return } + guard !self.isAutoHidden else { return } + self.revealCompletionWorkItem?.cancel() + self.revealCompletionWorkItem = nil + self.isAutoHideRevealInProgress = false + self.isAutoHidden = true + self.dockContainer?.showRevealIndicator(at: self.revealEdge(for: edge)) + self.hostingView?.alphaValue = 0 + self.hostingView?.isHidden = true NSAnimationContext.runAnimationGroup { context in - context.duration = 0.20 - self.animator().alphaValue = 0.40 + context.duration = 0.24 + context.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut) + self.animator().setFrame( + self.hiddenFrame(for: restingFrame, at: edge, in: visibleFrame), + display: true + ) } } hideWorkItem = work - DispatchQueue.main.asyncAfter(deadline: .now() + 1.0, execute: work) + DispatchQueue.main.asyncAfter( + deadline: .now() + max(0.1, autoHideDelay), + execute: work + ) } func cancelAutoHide() { hideWorkItem?.cancel() + guard isAutoHidden, let restingFrame = shownFrame else { return } + isAutoHidden = false + isAutoHideRevealInProgress = true + revealCompletionWorkItem?.cancel() + hostingView?.isHidden = false + hostingView?.alphaValue = 0 NSAnimationContext.runAnimationGroup { context in - context.duration = 0.40 - self.animator().alphaValue = 1.0 + context.duration = 0.28 + context.timingFunction = CAMediaTimingFunction(name: .easeOut) + self.hostingView?.animator().alphaValue = 1 + self.animator().setFrame(restingFrame, display: true) + } + + let work = DispatchWorkItem { [weak self] in + guard let self else { return } + self.isAutoHideRevealInProgress = false + self.revealCompletionWorkItem = nil + self.dockContainer?.hideRevealIndicator() } + revealCompletionWorkItem = work + DispatchQueue.main.asyncAfter(deadline: .now() + 0.3, execute: work) + } + + func revealImmediately() { + hideWorkItem?.cancel() + guard isAutoHidden || isAutoHideRevealInProgress, + let restingFrame = shownFrame + else { return } + + revealCompletionWorkItem?.cancel() + revealCompletionWorkItem = nil + isAutoHideRevealInProgress = false + isAutoHidden = false + hostingView?.isHidden = false + hostingView?.alphaValue = 1 + dockContainer?.hideRevealIndicator() + setFrame(restingFrame, display: true) + } + + /// Keeps the dock visible while a child interaction, such as a folder stack, + /// is open. Tokens allow independent interactions to overlap safely. + func beginTransientInteraction() -> UUID { + let token = UUID() + transientInteractionTokens.insert(token) + revealImmediately() + hideWorkItem?.cancel() + return token + } + + func endTransientInteraction(_ token: UUID) { + guard transientInteractionTokens.remove(token) != nil, + transientInteractionTokens.isEmpty + else { + return + } + updateAutoHideEdge() + if !pointerIsOverDock { + scheduleAutoHide() + } + } + + @objc private func menuDidBeginTracking(_ notification: Notification) { + guard let menu = notification.object as? NSMenu else { return } + let menuID = ObjectIdentifier(menu) + guard menuInteractionTokens[menuID] == nil else { return } + + let menuAlreadyBelongsToDock = !menuInteractionTokens.isEmpty + guard menuAlreadyBelongsToDock || pointerIsOverDock else { return } + + dockDelegate?.dockPanelMenuDidBeginTracking(self) + menuInteractionTokens[menuID] = beginTransientInteraction() + } + + @objc private func menuDidEndTracking(_ notification: Notification) { + guard let menu = notification.object as? NSMenu, + let token = menuInteractionTokens.removeValue( + forKey: ObjectIdentifier(menu) + ) + else { + return + } + endTransientInteraction(token) + } + + /// Holds the panel steady for the duration of an interactive resize. + /// + /// The token makes cleanup safe when AppKit interrupts a drag because the + /// view is removed, the mouse-up arrives elsewhere, or the panel closes. + func beginResizeInteraction() -> UUID { + let token = UUID() + let isFirstResizeInteraction = resizeInteractionTokens.isEmpty + resizeInteractionTokens.insert(token) + transientInteractionTokens.insert(token) + + guard isFirstResizeInteraction else { return token } + + moveCompletionWorkItem?.cancel() + moveCompletionWorkItem = nil + isUserMovingWindow = false + hideWorkItem?.cancel() + revealImmediately() + isMovableByWindowBackground = false + resizeReferenceFrame = frame + resizeReferenceEdge = resizeAnchorEdge(for: frame) + return token + } + + func endResizeInteraction(_ token: UUID) { + guard resizeInteractionTokens.remove(token) != nil else { return } + transientInteractionTokens.remove(token) + guard resizeInteractionTokens.isEmpty else { return } + + resizeReferenceFrame = nil + resizeReferenceEdge = nil + if let dockDelegate { + setPositionLocked(dockDelegate.lockPositions) + } + updateAutoHideEdge() + if transientInteractionTokens.isEmpty { + scheduleAutoHide() + } + } + + func tearDown() { + hideWorkItem?.cancel() + revealCompletionWorkItem?.cancel() + moveCompletionWorkItem?.cancel() + NotificationCenter.default.removeObserver( + self, + name: NSMenu.didBeginTrackingNotification, + object: nil + ) + NotificationCenter.default.removeObserver( + self, + name: NSMenu.didEndTrackingNotification, + object: nil + ) + menuInteractionTokens.removeAll() + transientInteractionTokens.removeAll() + resizeInteractionTokens.removeAll() + resizeReferenceFrame = nil + resizeReferenceEdge = nil + quickLaunchKeyModeEnabled = false + isUserMovingWindow = false + isAutoHidden = false + isAutoHideRevealInProgress = false + dockContainer?.hideRevealIndicator() + hostingView?.isHidden = false + hostingView?.alphaValue = 1 + dockDelegate = nil + delegate = nil + orderOut(nil) + contentView = nil + close() + } + + private var dockContainer: DockContainerView? { + contentView as? DockContainerView + } + + private var pointerIsOverDock: Bool { + dockContainer?.isPointerInside == true + || frame.contains(NSEvent.mouseLocation) + } + + private func revealEdge(for edge: DockScreenEdge) -> DockRevealEdge { + switch edge { + case .left: return .left + case .right: return .right + case .top: return .top + case .bottom: return .bottom + } + } + + private var activeScreen: NSScreen? { + let center = NSPoint(x: frame.midX, y: frame.midY) + return NSScreen.screens.first(where: { $0.frame.contains(center) }) + ?? screen + ?? NSScreen.main + } + + private func resizeAnchorEdge(for frame: NSRect) -> DockScreenEdge? { + if let autoHideEdge { + return autoHideEdge + } + guard let visibleFrame = activeScreen?.visibleFrame else { return nil } + return DockPanelResizeGeometry.dockedEdge( + of: frame, + in: visibleFrame, + orientation: dockOrientation, + tolerance: edgeTolerance + ) + } + + private func hiddenFrame( + for frame: NSRect, + at edge: DockScreenEdge, + in visibleFrame: NSRect + ) -> NSRect { + var hidden = frame + + switch edge { + case .left: + hidden.origin.x = visibleFrame.minX - frame.width + revealThickness + case .right: + hidden.origin.x = visibleFrame.maxX - revealThickness + case .bottom: + hidden.origin.y = visibleFrame.minY - frame.height + revealThickness + case .top: + hidden.origin.y = visibleFrame.maxY - revealThickness + } + + return hidden } } diff --git a/Sources/FreeDock/Windows/DockPanelDelegate.swift b/Sources/FreeDock/Windows/DockPanelDelegate.swift index 9cae1c1..e2bb712 100644 --- a/Sources/FreeDock/Windows/DockPanelDelegate.swift +++ b/Sources/FreeDock/Windows/DockPanelDelegate.swift @@ -4,6 +4,8 @@ import Foundation protocol DockPanelDelegate: AnyObject { var lockPositions: Bool { get } func dockPanelDidMove(_ panel: DockPanel) + func dockPanelMenuDidBeginTracking(_ panel: DockPanel) + func dockPanelDidResignKey(_ panel: DockPanel) func dockPanelDidResize(_ panel: DockPanel, proposedIconSize: Double) func dockPanelDidFinishResize(_ panel: DockPanel) func currentIconSize(for panel: DockPanel) -> Double diff --git a/Sources/FreeDock/Windows/DockResizeHandleView.swift b/Sources/FreeDock/Windows/DockResizeHandleView.swift index 55b486d..0e6a48b 100644 --- a/Sources/FreeDock/Windows/DockResizeHandleView.swift +++ b/Sources/FreeDock/Windows/DockResizeHandleView.swift @@ -1,14 +1,84 @@ import Cocoa +enum DockResizeGestureMath { + static let minimumDragDistance: CGFloat = 2 + static var iconSizeRange: ClosedRange { + DockConfig.iconSizeRange + } + + static func resizableItemCount(in items: [DockItem]) -> Int { + items.reduce(into: 0) { count, item in + if !item.isSeparator { + count += 1 + } + } + } + + static func sensitivity(forResizableItemCount itemCount: Int) -> Double { + 1 / Double(max(1, itemCount)) + } + + static func axisDelta( + from start: NSPoint, + to current: NSPoint, + orientation: Orientation + ) -> CGFloat { + switch orientation { + case .horizontal: + return current.x - start.x + case .vertical: + // The vertical handle sits at the bottom of the dock. In AppKit's + // screen coordinates, dragging it downward decreases y. + return start.y - current.y + } + } + + static func proposedIconSize( + startingAt iconSize: Double, + from start: NSPoint, + to current: NSPoint, + orientation: Orientation, + resizableItemCount: Int + ) -> Double { + let delta = axisDelta( + from: start, + to: current, + orientation: orientation + ) + let iconPointsPerDragPoint = sensitivity( + forResizableItemCount: resizableItemCount + ) + return (iconSize + Double(delta) * iconPointsPerDragPoint) + .clamped(to: iconSizeRange) + } + + static func committedIconSize(_ iconSize: Double) -> Double { + iconSize.rounded().clamped(to: iconSizeRange) + } +} + class DockResizeHandleView: NSView { weak var dockPanel: DockPanel? - var orientation: Orientation = .horizontal + var resizableItemCount = 0 + var orientation: Orientation = .horizontal { + didSet { + guard orientation != oldValue else { return } + window?.invalidateCursorRects(for: self) + needsDisplay = true + } + } private var dragStartLocation: NSPoint = .zero private var dragStartIconSize: Double = 48 + private var dragResizableItemCount = 0 + private var lastProposedIconSize: Double? private var isHovered = false private var isResizing = false + private var didResize = false private var trackingArea: NSTrackingArea? + private var resizeInteractionToken: UUID? + + override var mouseDownCanMoveWindow: Bool { false } override init(frame: NSRect) { super.init(frame: frame) @@ -21,6 +91,14 @@ class DockResizeHandleView: NSView { nil } + override func viewWillMove(toWindow newWindow: NSWindow?) { + if newWindow == nil { + finishResize(notifyDelegate: true) + NSCursor.arrow.set() + } + super.viewWillMove(toWindow: newWindow) + } + override func updateTrackingAreas() { super.updateTrackingAreas() updateTrackingArea() @@ -33,7 +111,7 @@ class DockResizeHandleView: NSView { let area = NSTrackingArea( rect: bounds, - options: [.mouseEnteredAndExited, .activeInKeyWindow, .inVisibleRect], + options: [.mouseEnteredAndExited, .activeAlways, .inVisibleRect], owner: self, userInfo: nil ) @@ -47,38 +125,38 @@ class DockResizeHandleView: NSView { let accent = NSColor.controlAccentColor let visible = isHovered || isResizing - // Keep the handle quiet at rest, then reveal a soft hit-area on hover. - if visible { - let backgroundRect = orientation == .horizontal - ? NSRect(x: bounds.midX - 5, y: bounds.midY - 18, width: 10, height: 36) - : NSRect(x: bounds.midX - 18, y: bounds.midY - 5, width: 36, height: 10) - accent.withAlphaComponent(isResizing ? 0.22 : 0.12).setFill() - NSBezierPath( - roundedRect: backgroundRect, - xRadius: backgroundRect.width / 2, - yRadius: backgroundRect.height / 2 - ).fill() - } + guard visible else { return } + + let handleLength = orientation == .horizontal + ? min(30, max(18, bounds.height - 16)) + : min(30, max(18, bounds.width - 16)) + let backgroundRect = orientation == .horizontal + ? NSRect(x: bounds.midX - 6, y: bounds.midY - handleLength / 2, width: 12, height: handleLength) + : NSRect(x: bounds.midX - handleLength / 2, y: bounds.midY - 6, width: handleLength, height: 12) + + (isResizing ? accent : NSColor.controlBackgroundColor) + .withAlphaComponent(isResizing ? 0.20 : 0.42) + .setFill() + NSBezierPath( + roundedRect: backgroundRect, + xRadius: 6, + yRadius: 6 + ).fill() - let dotColor = isResizing - ? accent - : (visible ? accent.withAlphaComponent(0.9) : NSColor.secondaryLabelColor.withAlphaComponent(0.6)) - dotColor.setFill() - - let dotRadius: CGFloat = visible ? 1.7 : 1.35 - for offset in [-6.0, 0.0, 6.0] { - let center = orientation == .horizontal - ? NSPoint(x: bounds.midX, y: bounds.midY + offset) - : NSPoint(x: bounds.midX + offset, y: bounds.midY) - NSBezierPath( - ovalIn: NSRect( - x: center.x - dotRadius, - y: center.y - dotRadius, - width: dotRadius * 2, - height: dotRadius * 2 - ) - ).fill() + let marks = NSBezierPath() + for offset in [-2.25, 2.25] { + if orientation == .horizontal { + marks.move(to: NSPoint(x: bounds.midX + offset, y: bounds.midY - 5)) + marks.line(to: NSPoint(x: bounds.midX + offset, y: bounds.midY + 5)) + } else { + marks.move(to: NSPoint(x: bounds.midX - 5, y: bounds.midY + offset)) + marks.line(to: NSPoint(x: bounds.midX + 5, y: bounds.midY + offset)) + } } + marks.lineWidth = 1.4 + marks.lineCapStyle = .round + (isResizing ? accent : NSColor.secondaryLabelColor.withAlphaComponent(0.72)).setStroke() + marks.stroke() } override func mouseEntered(with _: NSEvent) { @@ -100,30 +178,102 @@ class DockResizeHandleView: NSView { true } - override func mouseDown(with _: NSEvent) { + override func mouseDown(with event: NSEvent) { guard let panel = dockPanel else { return } - dragStartLocation = NSEvent.mouseLocation - // Snapshot the current icon size at drag start + + finishResize(notifyDelegate: true) + dragStartLocation = screenLocation(for: event) dragStartIconSize = panel.dockDelegate?.currentIconSize(for: panel) ?? 48 + dragResizableItemCount = resizableItemCount + lastProposedIconSize = dragStartIconSize + didResize = false isResizing = true + resizeInteractionToken = panel.beginResizeInteraction() + resizeCursor.set() needsDisplay = true } - override func mouseDragged(with _: NSEvent) { - guard let panel = dockPanel else { return } - let current = NSEvent.mouseLocation - let delta = orientation == .horizontal - ? current.x - dragStartLocation.x - : -(current.y - dragStartLocation.y) // drag down = smaller for vertical + override func mouseDragged(with event: NSEvent) { + guard isResizing, let panel = dockPanel else { return } + resizeCursor.set() + let current = screenLocation(for: event) + let delta = DockResizeGestureMath.axisDelta( + from: dragStartLocation, + to: current, + orientation: orientation + ) + guard didResize + || abs(delta) >= DockResizeGestureMath.minimumDragDistance + else { + return + } - let newSize = (dragStartIconSize + delta * 0.4).clamped(to: 16 ... 128) + let newSize = DockResizeGestureMath.proposedIconSize( + startingAt: dragStartIconSize, + from: dragStartLocation, + to: current, + orientation: orientation, + resizableItemCount: dragResizableItemCount + ) + guard lastProposedIconSize != newSize else { return } + + didResize = true + lastProposedIconSize = newSize panel.dockDelegate?.dockPanelDidResize(panel, proposedIconSize: newSize) } override func mouseUp(with _: NSEvent) { - guard let panel = dockPanel else { return } - panel.dockDelegate?.dockPanelDidFinishResize(panel) + finishResize(notifyDelegate: true) + } + + private func finishResize(notifyDelegate: Bool) { + guard isResizing + || resizeInteractionToken != nil + else { + return + } + + let panel = dockPanel + let shouldNotifyDelegate = notifyDelegate && didResize + isResizing = false + didResize = false + lastProposedIconSize = nil + + if shouldNotifyDelegate, let panel { + panel.dockDelegate?.dockPanelDidFinishResize(panel) + } + if let resizeInteractionToken { + panel?.endResizeInteraction(resizeInteractionToken) + self.resizeInteractionToken = nil + } + + updateCursorAfterResize() needsDisplay = true } + + private func screenLocation(for event: NSEvent?) -> NSPoint { + guard let event, let eventWindow = event.window else { + return NSEvent.mouseLocation + } + return eventWindow.convertPoint(toScreen: event.locationInWindow) + } + + private var resizeCursor: NSCursor { + orientation == .horizontal ? .resizeLeftRight : .resizeUpDown + } + + private func updateCursorAfterResize() { + window?.invalidateCursorRects(for: self) + guard let window else { + NSCursor.arrow.set() + return + } + + let windowPoint = window.convertPoint( + fromScreen: NSEvent.mouseLocation + ) + let localPoint = convert(windowPoint, from: nil) + (bounds.contains(localPoint) ? resizeCursor : .arrow).set() + } } diff --git a/Sources/FreeDock/Windows/FolderStackPanelController.swift b/Sources/FreeDock/Windows/FolderStackPanelController.swift new file mode 100644 index 0000000..e523779 --- /dev/null +++ b/Sources/FreeDock/Windows/FolderStackPanelController.swift @@ -0,0 +1,284 @@ +import Cocoa +import SwiftUI + +@MainActor +final class FolderStackPanelController: NSObject, NSWindowDelegate { + let dockID: UUID + let itemID: UUID + + private let item: DockItem + private let recentFiles: [RecentFileRecord] + private var sourceRect: CGRect + private weak var sourceDock: DockPanel? + private let panel: FolderStackPanel + private let onOpenURL: (URL) -> Void + private let onClearRecentFiles: () -> Void + private let onOptionsChanged: (FolderStackOptions) -> Void + private let onDidClose: () -> Void + private var autoHideToken: UUID? + private var localEventMonitor: Any? + private var globalEventMonitor: Any? + private var isClosed = false + + init( + dockID: UUID, + item: DockItem, + sourceRect: CGRect, + sourceDock: DockPanel, + recentFiles: [RecentFileRecord], + onOpenURL: @escaping (URL) -> Void, + onClearRecentFiles: @escaping () -> Void, + onOptionsChanged: @escaping (FolderStackOptions) -> Void, + onDidClose: @escaping () -> Void + ) { + self.dockID = dockID + itemID = item.id + self.item = item + self.recentFiles = recentFiles + self.sourceRect = sourceRect + self.sourceDock = sourceDock + self.onOpenURL = onOpenURL + self.onClearRecentFiles = onClearRecentFiles + self.onOptionsChanged = onOptionsChanged + self.onDidClose = onDidClose + panel = FolderStackPanel( + contentRect: CGRect(x: 0, y: 0, width: 492, height: 424) + ) + super.init() + panel.delegate = self + } + + func show() { + guard !isClosed, let sourceDock else { return } + + TooltipManager.shared.hide() + autoHideToken = sourceDock.beginTransientInteraction() + + let anchorRect = sourceRect.isEmpty + ? CGRect( + x: sourceDock.frame.midX - 24, + y: sourceDock.frame.midY - 24, + width: 48, + height: 48 + ) + : sourceRect + sourceRect = anchorRect + let screen = NSScreen.screens.first { + $0.frame.contains(CGPoint(x: anchorRect.midX, y: anchorRect.midY)) + } ?? sourceDock.screen ?? NSScreen.main + guard let screen else { + close() + return + } + + let placement = FolderStackPanelGeometry.placement( + size: panel.frame.size, + sourceRect: anchorRect, + dockFrame: sourceDock.frameForPersistence, + visibleFrame: screen.visibleFrame, + orientation: sourceDock.dockOrientation + ) + panel.setFrame(placement.frame, display: false) + + // The placement geometry can make the panel smaller on compact displays. + // Pin the hosting view to an AppKit container instead of relying on its + // 492×424 ideal size so SwiftUI always receives the actual panel size. + let contentContainer = NSView( + frame: CGRect(origin: .zero, size: placement.frame.size) + ) + let hostingView = NSHostingView(rootView: stackView) + hostingView.translatesAutoresizingMaskIntoConstraints = false + contentContainer.addSubview(hostingView) + NSLayoutConstraint.activate([ + hostingView.leadingAnchor.constraint( + equalTo: contentContainer.leadingAnchor + ), + hostingView.trailingAnchor.constraint( + equalTo: contentContainer.trailingAnchor + ), + hostingView.topAnchor.constraint( + equalTo: contentContainer.topAnchor + ), + hostingView.bottomAnchor.constraint( + equalTo: contentContainer.bottomAnchor + ), + ]) + panel.contentView = contentContainer + sourceDock.addChildWindow(panel, ordered: .above) + installEventMonitors() + + panel.alphaValue = 0 + panel.makeKeyAndOrderFront(nil) + NSAnimationContext.runAnimationGroup { context in + context.duration = 0.14 + context.timingFunction = CAMediaTimingFunction(name: .easeOut) + panel.animator().alphaValue = 1 + } + } + + func close() { + guard !isClosed else { return } + isClosed = true + + removeEventMonitors() + if let sourceDock { + sourceDock.removeChildWindow(panel) + } + panel.delegate = nil + panel.orderOut(nil) + panel.contentView = nil + + if let autoHideToken { + sourceDock?.endTransientInteraction(autoHideToken) + self.autoHideToken = nil + } + onDidClose() + } + + func windowWillClose(_: Notification) { + close() + } + + private var stackView: some View { + FolderStackView( + item: item, + presentation: DockItemPresentation.resolve(item), + recentFiles: recentFiles, + onOpenURL: { [weak self] url in + self?.requestOpen(url) + }, + onOpenContainer: openContainerAction, + onClearRecentFiles: clearRecentFilesAction, + onOptionsChanged: { [weak self] options in + self?.onOptionsChanged(options) + }, + onClose: { [weak self] in + self?.close() + } + ) + } + + private var openContainerAction: (() -> Void)? { + guard containerURL != nil else { return nil } + return { [weak self] in + self?.requestOpenContainer() + } + } + + private var clearRecentFilesAction: (() -> Void)? { + guard item.smartStackSource == .recentFiles else { return nil } + return { [weak self] in + self?.requestClearRecentFiles() + } + } + + private var containerURL: URL? { + let candidate: URL? + switch item.smartStackSource { + case .recentFiles: + candidate = nil + case .downloads: + candidate = FileManager.default.urls( + for: .downloadsDirectory, + in: .userDomainMask + ).first?.standardizedFileURL + case nil: + candidate = item.fileURL + } + + guard let candidate, + FileManager.default.fileExists(atPath: candidate.path) + else { + return nil + } + return candidate + } + + private func requestOpenContainer() { + guard let containerURL else { return } + requestOpen(containerURL) + } + + private func requestOpen(_ url: URL) { + let action = onOpenURL + close() + action(url) + } + + private func requestClearRecentFiles() { + let action = onClearRecentFiles + close() + action() + } + + private func installEventMonitors() { + localEventMonitor = NSEvent.addLocalMonitorForEvents( + matching: [.keyDown, .leftMouseDown, .rightMouseDown] + ) { [weak self] event in + guard let self else { return event } + + if event.type == .keyDown, event.keyCode == 53 { + self.close() + return nil + } + guard event.type == .leftMouseDown + || event.type == .rightMouseDown, + event.window !== self.panel + else { + return event + } + + let point = event.window.map { + $0.convertPoint(toScreen: event.locationInWindow) + } ?? NSEvent.mouseLocation + if self.sourceRect.contains(point) { + return event + } + self.close() + return event + } + + globalEventMonitor = NSEvent.addGlobalMonitorForEvents( + matching: [.leftMouseDown, .rightMouseDown] + ) { [weak self] _ in + Task { @MainActor in + self?.close() + } + } + } + + private func removeEventMonitors() { + if let localEventMonitor { + NSEvent.removeMonitor(localEventMonitor) + self.localEventMonitor = nil + } + if let globalEventMonitor { + NSEvent.removeMonitor(globalEventMonitor) + self.globalEventMonitor = nil + } + } +} + +final class FolderStackPanel: NSPanel { + init(contentRect: CGRect) { + super.init( + contentRect: contentRect, + styleMask: [.borderless, .fullSizeContentView], + backing: .buffered, + defer: false + ) + isFloatingPanel = true + becomesKeyOnlyIfNeeded = false + hidesOnDeactivate = false + level = .popUpMenu + isOpaque = false + backgroundColor = .clear + hasShadow = false + isMovable = false + isReleasedWhenClosed = false + collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary] + } + + override var canBecomeKey: Bool { true } + override var canBecomeMain: Bool { false } +} diff --git a/Sources/FreeDock/Windows/PreferencesWindowController.swift b/Sources/FreeDock/Windows/PreferencesWindowController.swift new file mode 100644 index 0000000..be512fc --- /dev/null +++ b/Sources/FreeDock/Windows/PreferencesWindowController.swift @@ -0,0 +1,84 @@ +import Cocoa +import SwiftUI + +@MainActor +final class PreferencesWindowController: NSWindowController, NSWindowDelegate { + private let store: DockPreferencesStore + private var permissionRefreshTimer: Timer? + + init(store: DockPreferencesStore) { + self.store = store + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 780, height: 570), + styleMask: [.titled, .closable, .miniaturizable, .resizable], + backing: .buffered, + defer: false + ) + window.title = "FreeDock Preferences" + window.minSize = NSSize(width: 700, height: 500) + window.isReleasedWhenClosed = false + window.contentViewController = NSHostingController( + rootView: DockPreferencesView(store: store) + ) + window.center() + window.setFrameAutosaveName("FreeDockPreferencesWindow") + super.init(window: window) + window.delegate = self + } + + @available(*, unavailable) + required init?(coder _: NSCoder) { + fatalError("init(coder:) is unavailable") + } + + func show() { + NSApp.activate(ignoringOtherApps: true) + showWindow(nil) + window?.deminiaturize(nil) + window?.makeKeyAndOrderFront(nil) + store.refreshPermissions() + startPermissionRefreshTimer() + } + + func windowWillClose(_: Notification) { + stopPermissionRefreshTimer() + } + + func windowDidMiniaturize(_: Notification) { + stopPermissionRefreshTimer() + } + + func windowDidDeminiaturize(_: Notification) { + store.refreshPermissions() + startPermissionRefreshTimer() + } + + private func startPermissionRefreshTimer() { + guard permissionRefreshTimer == nil, + window?.isVisible == true, + window?.isMiniaturized == false + else { + return + } + + let timer = Timer( + timeInterval: 0.9, + target: self, + selector: #selector(refreshPermissionStatus), + userInfo: nil, + repeats: true + ) + timer.tolerance = 0.15 + RunLoop.main.add(timer, forMode: .common) + permissionRefreshTimer = timer + } + + private func stopPermissionRefreshTimer() { + permissionRefreshTimer?.invalidate() + permissionRefreshTimer = nil + } + + @objc private func refreshPermissionStatus() { + store.refreshPermissions() + } +} diff --git a/Sources/FreeDock/Windows/WindowPreviewPanelController.swift b/Sources/FreeDock/Windows/WindowPreviewPanelController.swift new file mode 100644 index 0000000..c4e7903 --- /dev/null +++ b/Sources/FreeDock/Windows/WindowPreviewPanelController.swift @@ -0,0 +1,987 @@ +import AppKit +import Carbon.HIToolbox +import SwiftUI + +enum WindowPreviewPanelQueryDecision: Equatable, Sendable { + case accept + case retry(afterNanoseconds: UInt64) + case close +} + +enum WindowPreviewPanelQueryPolicy { + static func decision( + for status: DockApplicationWindowQueryStatus, + windowCount: Int, + retryAttempt: Int + ) -> WindowPreviewPanelQueryDecision { + switch status { + case .ready: + return windowCount > 0 ? .accept : .close + case .temporarilyUnavailable: + let attempt = min(max(0, retryAttempt), 3) + let delay = UInt64(250_000_000) << attempt + return .retry( + afterNanoseconds: min(delay, 1_500_000_000) + ) + case .permissionRequired, .applicationNotRunning: + return .close + } + } +} + +enum WindowPreviewPanelSizing { + static let cardStride: CGFloat = 182 + static let horizontalPadding: CGFloat = 20 + static let height: CGFloat = 194 + + static func preferredSize(windowCount: Int) -> CGSize { + let visibleCardCount = min(max(1, windowCount), 3) + return CGSize( + width: + CGFloat(visibleCardCount) * cardStride + + horizontalPadding, + height: height + ) + } +} + +enum WindowPreviewPanelEventPolicy { + static func shouldCloseForGlobalClick( + monitoredSessionID: UUID?, + currentSessionID: UUID? + ) -> Bool { + guard let monitoredSessionID else { return false } + return currentSessionID == monitoredSessionID + } +} + +enum WindowPreviewKeyboardAction: Equatable, Sendable { + case previous + case next + case activate + case close +} + +enum WindowPreviewKeyboardEventPolicy { + static func action( + keyCode: UInt16, + modifierFlags: NSEvent.ModifierFlags + ) -> WindowPreviewKeyboardAction? { + if keyCode == UInt16(kVK_Escape) { + return .close + } + + let disallowedModifiers: + NSEvent.ModifierFlags = [.command, .control, .option] + guard modifierFlags + .intersection(disallowedModifiers) + .isEmpty + else { + return nil + } + + switch Int(keyCode) { + case kVK_LeftArrow, kVK_UpArrow: + return .previous + case kVK_RightArrow, kVK_DownArrow: + return .next + case kVK_Tab: + return modifierFlags.contains(.shift) + ? .previous + : .next + case kVK_Return, kVK_ANSI_KeypadEnter, kVK_Space: + return .activate + default: + return nil + } + } +} + +@MainActor +final class WindowPreviewPanelController { + private struct Target { + let itemID: DockItem.ID + let bundleIdentifier: String + let applicationName: String + let applicationIcon: NSImage + var sourceRect: CGRect + } + + private let applicationWindows: DockApplicationWindowController + private let panel = WindowPreviewPanel() + private var hoverState = WindowPreviewHoverState() + private var target: Target? + private weak var sourceDock: DockPanel? + private var windows: [DockApplicationWindow] = [] + private var thumbnails: + [DockApplicationWindow.ID: NSImage] = [:] + private var thumbnailLoadingIDs = + Set() + private var thumbnailUnavailableIDs = + Set() + private var openWorkItem: DispatchWorkItem? + private var closeWorkItem: DispatchWorkItem? + private var refreshTask: Task? + private var loadTask: Task? + private var thumbnailTask: Task? + private var hostingView: + WindowPreviewHostingView? + private var autoHideToken: UUID? + private var localEventMonitor: Any? + private var globalEventMonitor: Any? + private var isExplicitPresentation = false + private var keyboardSelection = + WindowPreviewKeyboardSelection() + + init( + applicationWindows: DockApplicationWindowController + = DockApplicationWindowController() + ) { + self.applicationWindows = applicationWindows + } + + var activeItemID: DockItem.ID? { + target?.itemID + } + + var sourceDockID: UUID? { + sourceDock?.dockID + } + + var isAccessibilityTrusted: Bool { + applicationWindows.isAccessibilityTrusted + } + + var isScreenCaptureTrusted: Bool { + applicationWindows.isScreenCaptureTrusted + } + + @discardableResult + func requestAccessibilityAccess() -> Bool { + applicationWindows.requestAccessibilityAccess() + } + + @discardableResult + func requestScreenCaptureAccess() -> Bool { + applicationWindows.requestScreenCaptureAccess() + } + + func enableWindowThumbnails() { + let granted = requestScreenCaptureAccess() + guard granted + || applicationWindows.isScreenCaptureTrusted + else { + showScreenCaptureInstructions() + return + } + guard let sessionID = hoverState.session?.id else { + return + } + loadThumbnails(for: sessionID) + } + + private func showScreenCaptureInstructions() { + let alert = NSAlert() + alert.alertStyle = .informational + alert.messageText = "Enable Window Thumbnails" + alert.informativeText = """ + Allow FreeDock in System Settings → Privacy & Security → Screen Recording. macOS may ask you to reopen FreeDock after approval. Window switching continues to work without thumbnails. + """ + alert.addButton(withTitle: "OK") + alert.runModal() + } + + func hoverBegan( + item: DockItem, + presentation: DockItemPresentation, + sourceRect: CGRect, + sourceDock: DockPanel + ) { + guard applicationWindows.isAccessibilityTrusted, + item.kind == .application, + presentation.isAvailable, + let bundleIdentifier = presentation.bundleID, + !bundleIdentifier.isEmpty, + !isExplicitPresentation + else { + return + } + + if hoverState.session?.itemID == item.id { + target?.sourceRect = sourceRect + _ = hoverState.beginIconHover( + itemID: item.id, + anchor: sourceRect + ) + if panel.isVisible { + positionPanel() + } + return + } + + let retargetingVisiblePanel = panel.isVisible + close(resetNativeController: false) + target = Target( + itemID: item.id, + bundleIdentifier: bundleIdentifier, + applicationName: presentation.displayName, + applicationIcon: presentation.icon, + sourceRect: sourceRect + ) + self.sourceDock = sourceDock + + let start = hoverState.beginIconHover( + itemID: item.id, + anchor: sourceRect + ) + guard start.shouldDiscover else { return } + _ = hoverState.markDiscoveryStarted( + sessionID: start.session.id + ) + scheduleOpen( + token: start.openDelayToken, + after: retargetingVisiblePanel ? 0.12 : 0.35 + ) + loadWindows( + for: start.session.id, + target: target + ) + } + + func hoverEnded(itemID: DockItem.ID) { + guard !isExplicitPresentation, + hoverState.session?.itemID == itemID + else { + return + } + guard let token = hoverState.endIconHover() else { + close(resetNativeController: false) + return + } + scheduleClose(token: token) + } + + func showExplicit( + item: DockItem, + presentation: DockItemPresentation, + sourceRect: CGRect, + sourceDock: DockPanel + ) { + guard item.kind == .application, + presentation.isAvailable, + let bundleIdentifier = presentation.bundleID, + !bundleIdentifier.isEmpty + else { + NSSound.beep() + return + } + + guard applicationWindows.isAccessibilityTrusted else { + _ = applicationWindows.requestAccessibilityAccess() + return + } + + close(resetNativeController: false) + isExplicitPresentation = true + target = Target( + itemID: item.id, + bundleIdentifier: bundleIdentifier, + applicationName: presentation.displayName, + applicationIcon: presentation.icon, + sourceRect: sourceRect + ) + self.sourceDock = sourceDock + + let start = hoverState.beginIconHover( + itemID: item.id, + anchor: sourceRect + ) + _ = hoverState.markDiscoveryStarted( + sessionID: start.session.id + ) + _ = hoverState.openDelayElapsed( + token: start.openDelayToken + ) + loadWindows( + for: start.session.id, + target: target + ) + } + + func close( + forDockID dockID: UUID? = nil, + resetNativeController: Bool = false + ) { + if let dockID, sourceDock?.dockID != dockID { + return + } + close(resetNativeController: resetNativeController) + } + + private func close(resetNativeController: Bool) { + openWorkItem?.cancel() + openWorkItem = nil + closeWorkItem?.cancel() + closeWorkItem = nil + refreshTask?.cancel() + refreshTask = nil + loadTask?.cancel() + loadTask = nil + thumbnailTask?.cancel() + thumbnailTask = nil + applicationWindows.clearThumbnailCache() + removeEventMonitors() + + if let sourceDock { + sourceDock.removeChildWindow(panel) + } + panel.orderOut(nil) + panel.contentView = nil + hostingView = nil + panel.allowsKey = false + + if let autoHideToken { + sourceDock?.endTransientInteraction(autoHideToken) + self.autoHideToken = nil + } + + hoverState.reset() + target = nil + sourceDock = nil + windows = [] + thumbnails = [:] + thumbnailLoadingIDs = [] + thumbnailUnavailableIDs = [] + isExplicitPresentation = false + keyboardSelection = WindowPreviewKeyboardSelection() + if resetNativeController { + applicationWindows.reset() + } + } + + private func scheduleOpen( + token: UUID, + after delay: TimeInterval + ) { + openWorkItem?.cancel() + let work = DispatchWorkItem { [weak self] in + Task { @MainActor [weak self] in + guard let self else { return } + self.openWorkItem = nil + if self.hoverState.openDelayElapsed( + token: token + ) { + self.presentPanel() + } else if self.hoverState.session == nil { + self.close(resetNativeController: false) + } + } + } + openWorkItem = work + DispatchQueue.main.asyncAfter( + deadline: .now() + delay, + execute: work + ) + } + + private func loadWindows( + for sessionID: UUID, + target: Target?, + retryCount: Int = 0 + ) { + guard let target else { return } + loadTask?.cancel() + loadTask = Task { @MainActor [weak self] in + guard let self else { return } + let query = await self.applicationWindows.loadWindows( + bundleIdentifier: target.bundleIdentifier, + applicationName: target.applicationName + ) + guard !Task.isCancelled, + self.hoverState.session?.id == sessionID, + self.target?.itemID == target.itemID + else { + return + } + + switch WindowPreviewPanelQueryPolicy.decision( + for: query.status, + windowCount: query.windows.count, + retryAttempt: retryCount + ) { + case .accept: + self.windows = query.windows + if self.isExplicitPresentation { + self.keyboardSelection.update( + windows: query.windows + ) + } + let shouldPresent = + self.hoverState.completeDiscovery( + sessionID: sessionID, + windowCount: query.windows.count + ) + if shouldPresent { + self.presentPanel() + } else if self.hoverState.session == nil { + self.close(resetNativeController: false) + } + case let .retry(delay): + try? await Task.sleep(nanoseconds: delay) + guard !Task.isCancelled, + self.hoverState.session?.id == sessionID, + self.target?.itemID == target.itemID + else { + return + } + self.loadWindows( + for: sessionID, + target: target, + retryCount: retryCount + 1 + ) + case .close: + self.close(resetNativeController: false) + } + } + } + + private func presentPanel() { + guard hoverState.isPresented, + let target, + let sourceDock, + !windows.isEmpty + else { + return + } + + TooltipManager.shared.hide() + if autoHideToken == nil { + autoHideToken = sourceDock.beginTransientInteraction() + } + configurePanelContent( + target: target, + sessionID: hoverState.session?.id + ) + positionPanel() + if panel.parent !== sourceDock { + sourceDock.addChildWindow(panel, ordered: .above) + } + installEventMonitors() + + panel.allowsKey = isExplicitPresentation + panel.alphaValue = 0 + if isExplicitPresentation { + panel.makeKeyAndOrderFront(nil) + } else { + panel.orderFront(nil) + } + + if NSWorkspace.shared + .accessibilityDisplayShouldReduceMotion + { + panel.alphaValue = 1 + } else { + NSAnimationContext.runAnimationGroup { context in + context.duration = 0.12 + context.timingFunction = CAMediaTimingFunction( + name: .easeOut + ) + panel.animator().alphaValue = 1 + } + } + scheduleRefresh() + if let sessionID = hoverState.session?.id { + loadThumbnails(for: sessionID) + } + } + + private func configurePanelContent( + target: Target, + sessionID: UUID? + ) { + let view = WindowPreviewView( + applicationName: target.applicationName, + applicationIcon: target.applicationIcon, + windows: windows, + thumbnails: thumbnails, + thumbnailLoadingIDs: thumbnailLoadingIDs, + thumbnailUnavailableIDs: + thumbnailUnavailableIDs, + isThumbnailCaptureEnabled: + applicationWindows.isScreenCaptureTrusted, + keyboardSelectedWindowID: + visibleKeyboardSelectionID, + onWindowSelected: { [weak self] windowID in + self?.selectWindow(windowID) + }, + onEnableWindowThumbnails: { [weak self] in + self?.enableWindowThumbnails() + }, + onHoverChanged: { [weak self] hovering in + guard let self, let sessionID else { return } + self.panelHoverChanged( + hovering, + sessionID: sessionID + ) + }, + onClose: { [weak self] in + self?.close(resetNativeController: false) + } + ) + if let hostingView { + hostingView.rootView = view + hostingView.frame = CGRect( + origin: .zero, + size: preferredPanelSize + ) + } else { + let hostingView = WindowPreviewHostingView( + rootView: view + ) + hostingView.frame = CGRect( + origin: .zero, + size: preferredPanelSize + ) + self.hostingView = hostingView + panel.contentView = hostingView + } + } + + private func loadThumbnails(for sessionID: UUID) { + thumbnailTask?.cancel() + guard let target else { return } + guard applicationWindows.isScreenCaptureTrusted else { + thumbnails = [:] + thumbnailLoadingIDs = [] + thumbnailUnavailableIDs = [] + if panel.isVisible { + configurePanelContent( + target: target, + sessionID: sessionID + ) + } + return + } + + let requestedWindows = windows + thumbnailLoadingIDs = Set( + requestedWindows + .filter { + $0.captureWindowID != nil + && thumbnails[$0.id] == nil + } + .map(\.id) + ) + thumbnailUnavailableIDs.subtract( + thumbnailLoadingIDs + ) + thumbnailUnavailableIDs.formUnion( + requestedWindows + .filter { $0.captureWindowID == nil } + .map(\.id) + ) + if panel.isVisible { + configurePanelContent( + target: target, + sessionID: sessionID + ) + } + thumbnailTask = Task { @MainActor [weak self] in + guard let self else { return } + for window in requestedWindows { + guard !Task.isCancelled, + self.hoverState.session?.id == sessionID, + self.target?.itemID == target.itemID + else { + return + } + let thumbnail = + await self.applicationWindows.thumbnail( + for: window, + maximumPixelSize: CGSize( + width: 332, + height: 192 + ) + ) + guard !Task.isCancelled, + self.hoverState.session?.id == sessionID, + self.target?.itemID == target.itemID + else { + return + } + self.thumbnailLoadingIDs.remove(window.id) + if let thumbnail { + self.thumbnails[window.id] = thumbnail + self.thumbnailUnavailableIDs.remove( + window.id + ) + } else { + self.thumbnails.removeValue( + forKey: window.id + ) + self.thumbnailUnavailableIDs.insert( + window.id + ) + } + self.configurePanelContent( + target: target, + sessionID: sessionID + ) + } + self.thumbnailTask = nil + } + } + + private func panelHoverChanged( + _ hovering: Bool, + sessionID: UUID + ) { + guard !isExplicitPresentation else { return } + if hovering { + closeWorkItem?.cancel() + closeWorkItem = nil + _ = hoverState.panelEntered( + sessionID: sessionID + ) + } else if let token = hoverState.panelExited( + sessionID: sessionID + ) { + scheduleClose(token: token) + } + } + + private func scheduleClose(token: UUID) { + closeWorkItem?.cancel() + let work = DispatchWorkItem { [weak self] in + Task { @MainActor [weak self] in + guard let self else { return } + self.closeWorkItem = nil + if self.hoverState.closeDelayElapsed( + token: token + ) { + self.close(resetNativeController: false) + } + } + } + closeWorkItem = work + DispatchQueue.main.asyncAfter( + deadline: .now() + 0.32, + execute: work + ) + } + + private func positionPanel() { + guard let target, let sourceDock else { return } + let sourceRect = target.sourceRect.isEmpty + ? CGRect( + x: sourceDock.frame.midX - 24, + y: sourceDock.frame.midY - 24, + width: 48, + height: 48 + ) + : target.sourceRect + let screen = NSScreen.screens.first { + $0.frame.contains( + CGPoint(x: sourceRect.midX, y: sourceRect.midY) + ) + } ?? sourceDock.screen ?? NSScreen.main + guard let screen else { return } + + let placement = WindowPreviewPanelGeometry.placement( + size: preferredPanelSize, + sourceRect: sourceRect, + dockFrame: sourceDock.frameForPersistence, + visibleFrame: screen.visibleFrame, + orientation: sourceDock.dockOrientation, + dockedEdge: sourceDock.dockedEdgeForLayout + ) + panel.setFrame(placement.frame, display: false) + } + + private var preferredPanelSize: CGSize { + WindowPreviewPanelSizing.preferredSize( + windowCount: windows.count + ) + } + + private func selectWindow( + _ windowID: DockApplicationWindow.ID + ) { + let controller = applicationWindows + close(resetNativeController: false) + Task { @MainActor in + let result = await controller.focusWindow(id: windowID) + guard result == .accepted else { + NSSound.beep() + return + } + } + } + + private var visibleKeyboardSelectionID: + DockApplicationWindow.ID? + { + guard isExplicitPresentation, + keyboardSelection.showsFocusIndicator + else { + return nil + } + return keyboardSelection.selectedWindowID + } + + private func handleKeyboardEvent( + _ event: NSEvent, + monitoredSessionID: UUID? + ) -> Bool { + guard isExplicitPresentation, + panel.isVisible, + panel.isKeyWindow, + event.window === panel, + let monitoredSessionID, + hoverState.session?.id == monitoredSessionID, + let action = + WindowPreviewKeyboardEventPolicy.action( + keyCode: event.keyCode, + modifierFlags: event.modifierFlags + ) + else { + return false + } + + switch action { + case .previous: + keyboardSelection.selectPrevious(in: windows) + refreshKeyboardSelection() + case .next: + keyboardSelection.selectNext(in: windows) + refreshKeyboardSelection() + case .activate: + guard let selectedID = + keyboardSelection.returnTargetID, + windows.contains(where: { + $0.id == selectedID + }) + else { + NSSound.beep() + return true + } + selectWindow(selectedID) + case .close: + close(resetNativeController: false) + } + return true + } + + private func refreshKeyboardSelection() { + guard let target, + let sessionID = hoverState.session?.id + else { + return + } + configurePanelContent( + target: target, + sessionID: sessionID + ) + } + + private func scheduleRefresh() { + refreshTask?.cancel() + guard panel.isVisible, + let target, + let sessionID = hoverState.session?.id + else { + return + } + + refreshTask = Task { @MainActor [weak self] in + try? await Task.sleep(nanoseconds: 1_500_000_000) + guard !Task.isCancelled, + let self, + self.panel.isVisible, + self.target?.itemID == target.itemID, + self.hoverState.session?.id == sessionID + else { + return + } + + let query = await self.applicationWindows.loadWindows( + bundleIdentifier: target.bundleIdentifier, + applicationName: target.applicationName + ) + guard !Task.isCancelled, + self.target?.itemID == target.itemID, + self.hoverState.session?.id == sessionID + else { + return + } + self.refreshTask = nil + + if query.status == .temporarilyUnavailable { + self.scheduleRefresh() + return + } + guard query.status == .ready else { + self.close(resetNativeController: false) + return + } + guard !query.windows.isEmpty else { + self.close(resetNativeController: false) + return + } + + if self.isExplicitPresentation { + self.keyboardSelection.update( + windows: query.windows + ) + } + if self.windows != query.windows { + let previousCaptureIDs = Dictionary( + uniqueKeysWithValues: self.windows.map { + ($0.id, $0.captureWindowID) + } + ) + self.windows = query.windows + let newCaptureIDs = Dictionary( + uniqueKeysWithValues: query.windows.map { + ($0.id, $0.captureWindowID) + } + ) + let currentIDs = Set(query.windows.map(\.id)) + self.thumbnails = self.thumbnails.filter { + currentIDs.contains($0.key) + && previousCaptureIDs[$0.key] + == newCaptureIDs[$0.key] + } + self.thumbnailLoadingIDs.formIntersection( + currentIDs + ) + self.thumbnailUnavailableIDs + .formIntersection(currentIDs) + self.configurePanelContent( + target: target, + sessionID: sessionID + ) + self.positionPanel() + self.loadThumbnails(for: sessionID) + } else if self.thumbnailTask == nil { + self.loadThumbnails(for: sessionID) + } + self.scheduleRefresh() + } + } + + private func installEventMonitors() { + guard localEventMonitor == nil, + globalEventMonitor == nil + else { + return + } + + let monitoredSessionID = hoverState.session?.id + localEventMonitor = NSEvent.addLocalMonitorForEvents( + matching: [ + .keyDown, + .leftMouseDown, + .rightMouseDown, + ] + ) { [weak self] event in + guard let self else { return event } + if event.type == .keyDown { + return self.handleKeyboardEvent( + event, + monitoredSessionID: monitoredSessionID + ) ? nil : event + } + guard event.type == .leftMouseDown + || event.type == .rightMouseDown, + event.window !== self.panel + else { + return event + } + let point = event.window.map { + $0.convertPoint(toScreen: event.locationInWindow) + } ?? NSEvent.mouseLocation + if self.target?.sourceRect.contains(point) == true { + return event + } + self.close(resetNativeController: false) + return event + } + + globalEventMonitor = NSEvent.addGlobalMonitorForEvents( + matching: [.leftMouseDown, .rightMouseDown] + ) { [weak self] _ in + Task { @MainActor [weak self] in + guard let self, + WindowPreviewPanelEventPolicy + .shouldCloseForGlobalClick( + monitoredSessionID: + monitoredSessionID, + currentSessionID: + self.hoverState.session?.id + ) + else { + return + } + self.close(resetNativeController: false) + } + } + } + + private func removeEventMonitors() { + if let localEventMonitor { + NSEvent.removeMonitor(localEventMonitor) + self.localEventMonitor = nil + } + if let globalEventMonitor { + NSEvent.removeMonitor(globalEventMonitor) + self.globalEventMonitor = nil + } + } +} + +private final class WindowPreviewHostingView: + NSHostingView +{ + override func acceptsFirstMouse(for _: NSEvent?) -> Bool { + true + } +} + +private final class WindowPreviewPanel: NSPanel { + var allowsKey = false + + init() { + super.init( + contentRect: CGRect( + x: 0, + y: 0, + width: 384, + height: WindowPreviewPanelSizing.height + ), + styleMask: [ + .borderless, + .nonactivatingPanel, + .fullSizeContentView, + ], + backing: .buffered, + defer: false + ) + isFloatingPanel = true + becomesKeyOnlyIfNeeded = true + hidesOnDeactivate = false + level = .popUpMenu + isOpaque = false + backgroundColor = .clear + hasShadow = false + isMovable = false + isReleasedWhenClosed = false + collectionBehavior = [ + .canJoinAllSpaces, + .fullScreenAuxiliary, + ] + } + + override var canBecomeKey: Bool { allowsKey } + override var canBecomeMain: Bool { false } +} diff --git a/Tests/FreeDockTests/ConfigManagerTests.swift b/Tests/FreeDockTests/ConfigManagerTests.swift index 5982463..a73f624 100644 --- a/Tests/FreeDockTests/ConfigManagerTests.swift +++ b/Tests/FreeDockTests/ConfigManagerTests.swift @@ -10,11 +10,13 @@ func saveAndLoad() throws { let path = tempDir.appendingPathComponent("freedock.json") let mgr = ConfigManager(configPath: path) + #expect(!mgr.loadedFromDisk) mgr.config = AppConfig(docks: [DockConfig(name: "Saved")]) mgr.save() mgr.saveImmediately() let loader = ConfigManager(configPath: path) + #expect(loader.loadedFromDisk) #expect(loader.config.docks.count == 1) #expect(loader.config.docks[0].name == "Saved") } @@ -43,5 +45,6 @@ func backupCreated() throws { func missingFile() { let path = FileManager.default.temporaryDirectory.appendingPathComponent("fd-nonexistent-\(UUID().uuidString).json") let mgr = ConfigManager(configPath: path) + #expect(!mgr.loadedFromDisk) #expect(mgr.config.docks.isEmpty) } diff --git a/Tests/FreeDockTests/DockApplicationActionTests.swift b/Tests/FreeDockTests/DockApplicationActionTests.swift new file mode 100644 index 0000000..7405e28 --- /dev/null +++ b/Tests/FreeDockTests/DockApplicationActionTests.swift @@ -0,0 +1,375 @@ +import AppKit +import Testing +@testable import FreeDock + +@Suite("Dock application actions") +struct DockApplicationActionPlannerTests { + @Test("Empty instance state is not running") + func emptyState() { + let state = DockApplicationActionPlanner.state(for: []) + + #expect(!state.isRunning) + #expect(!state.isHidden) + #expect(!state.isFrontmost) + #expect(state.instanceCount == 0) + #expect(state.hiddenInstanceCount == 0) + #expect(state.visibleInstanceCount == 0) + } + + @Test("Aggregate state covers every matching instance") + func aggregateState() { + let state = DockApplicationActionPlanner.state( + for: [ + instance(hidden: true), + instance(hidden: true, frontmost: true), + ] + ) + + #expect(state.isRunning) + #expect(state.isHidden) + #expect(state.isFrontmost) + #expect(state.instanceCount == 2) + #expect(state.hiddenInstanceCount == 2) + #expect(state.visibleInstanceCount == 0) + } + + @Test("Mixed visibility is not reported as fully hidden") + func mixedVisibilityState() { + let state = DockApplicationActionPlanner.state( + for: [ + instance(hidden: true), + instance(hidden: false), + instance(hidden: false), + ] + ) + + #expect(state.isRunning) + #expect(!state.isHidden) + #expect(state.instanceCount == 3) + #expect(state.hiddenInstanceCount == 1) + #expect(state.visibleInstanceCount == 2) + } + + @Test("Activate prefers the frontmost instance") + func activateFrontmost() { + let requests = DockApplicationActionPlanner.requests( + for: .activate, + instances: [ + instance(hidden: false), + instance(hidden: false, frontmost: true), + instance(hidden: false), + ] + ) + + #expect(requests == [.activate(instanceIndex: 1)]) + } + + @Test("Activate prefers a visible instance then stable input order") + func activateVisibleFallback() { + let visibleRequests = DockApplicationActionPlanner.requests( + for: .activate, + instances: [ + instance(hidden: true), + instance(hidden: false), + instance(hidden: false), + ] + ) + let hiddenRequests = DockApplicationActionPlanner.requests( + for: .activate, + instances: [ + instance(hidden: true), + instance(hidden: true), + ] + ) + + #expect(visibleRequests == [.activate(instanceIndex: 1)]) + #expect(hiddenRequests == [.activate(instanceIndex: 0)]) + } + + @Test("Show unhides every hidden instance then activates one") + func showAllHiddenInstances() { + let requests = DockApplicationActionPlanner.requests( + for: .show, + instances: [ + instance(hidden: true), + instance(hidden: false), + instance(hidden: true), + ] + ) + + #expect( + requests == [ + .show(instanceIndex: 0), + .show(instanceIndex: 2), + .activate(instanceIndex: 1), + ] + ) + } + + @Test("Hide skips instances that are already hidden") + func hideVisibleInstances() { + let requests = DockApplicationActionPlanner.requests( + for: .hide, + instances: [ + instance(hidden: true), + instance(hidden: false), + instance(hidden: false), + ] + ) + + #expect( + requests == [ + .hide(instanceIndex: 1), + .hide(instanceIndex: 2), + ] + ) + } + + @Test("Quit requests normal termination for every instance") + func quitAllInstances() { + let requests = DockApplicationActionPlanner.requests( + for: .quit, + instances: [ + instance(hidden: true), + instance(hidden: false), + instance(hidden: false), + ] + ) + + #expect( + requests == [ + .quit(instanceIndex: 0), + .quit(instanceIndex: 1), + .quit(instanceIndex: 2), + ] + ) + } + + private func instance( + hidden: Bool, + frontmost: Bool = false + ) -> DockApplicationInstanceState { + DockApplicationInstanceState( + isHidden: hidden, + isFrontmost: frontmost + ) + } +} + +@Suite("Dock application action controller") +@MainActor +struct DockApplicationActionControllerTests { + @Test("Resolution keeps live user-facing exact bundle matches") + func filtersApplications() { + let firstMatch = FakeRunningApplication( + bundleIdentifier: "com.example.Editor" + ) + let secondMatch = FakeRunningApplication( + bundleIdentifier: "com.example.Editor", + isHidden: true + ) + let wrongBundle = FakeRunningApplication( + bundleIdentifier: "com.example.Other" + ) + let accessory = FakeRunningApplication( + bundleIdentifier: "com.example.Editor", + activationPolicy: .accessory + ) + let prohibited = FakeRunningApplication( + bundleIdentifier: "com.example.Editor", + activationPolicy: .prohibited + ) + let terminated = FakeRunningApplication( + bundleIdentifier: "com.example.Editor", + isTerminated: true + ) + var requestedBundleIdentifier: String? + let controller = DockApplicationActionController { + requestedBundleIdentifier = $0 + return [ + firstMatch, + wrongBundle, + accessory, + secondMatch, + prohibited, + terminated, + ] + } + + let state = controller.state( + forBundleIdentifier: "com.example.Editor" + ) + + #expect(requestedBundleIdentifier == "com.example.Editor") + #expect(state.isRunning) + #expect(!state.isHidden) + #expect(state.instanceCount == 3) + #expect(state.hiddenInstanceCount == 1) + } + + @Test("Empty bundle identifier does not query the provider") + func emptyBundleIdentifier() { + var providerCallCount = 0 + let controller = DockApplicationActionController { _ in + providerCallCount += 1 + return [] + } + + let state = controller.state(forBundleIdentifier: "") + let result = controller.perform( + .quit, + forBundleIdentifier: "" + ) + + #expect(providerCallCount == 0) + #expect(!state.isRunning) + #expect(!result.foundRunningApplication) + #expect(!result.sentRequest) + #expect(result.allRequestsAccepted) + } + + @Test("Show unhides all hidden matches and activates one") + func showAction() { + let first = FakeRunningApplication( + bundleIdentifier: "com.example.Editor", + isHidden: true + ) + let second = FakeRunningApplication( + bundleIdentifier: "com.example.Editor", + isHidden: false + ) + let third = FakeRunningApplication( + bundleIdentifier: "com.example.Editor", + isHidden: true + ) + let controller = controller(applications: [first, second, third]) + + let result = controller.perform( + .show, + forBundleIdentifier: "com.example.Editor" + ) + + #expect(first.requests == [.show]) + #expect(second.requests == [.activate]) + #expect(third.requests == [.show]) + #expect(result.attemptedRequestCount == 3) + #expect(result.acceptedRequestCount == 3) + #expect(result.allRequestsAccepted) + } + + @Test("Hide and quit affect all applicable matching instances") + func hideAndQuitActions() { + let first = FakeRunningApplication( + bundleIdentifier: "com.example.Editor" + ) + let second = FakeRunningApplication( + bundleIdentifier: "com.example.Editor", + isHidden: true + ) + let controller = controller(applications: [first, second]) + + let hideResult = controller.perform( + .hide, + forBundleIdentifier: "com.example.Editor" + ) + let quitResult = controller.perform( + .quit, + forBundleIdentifier: "com.example.Editor" + ) + + #expect(first.requests == [.hide, .quit]) + #expect(second.requests == [.quit]) + #expect(hideResult.attemptedRequestCount == 1) + #expect(quitResult.attemptedRequestCount == 2) + #expect(quitResult.acceptedRequestCount == 2) + } + + @Test("Rejected native requests are represented in the result") + func rejectedRequest() { + let application = FakeRunningApplication( + bundleIdentifier: "com.example.Editor", + acceptedRequests: [] + ) + let controller = controller(applications: [application]) + + let result = controller.perform( + .activate, + forBundleIdentifier: "com.example.Editor" + ) + + #expect(result.foundRunningApplication) + #expect(result.sentRequest) + #expect(result.attemptedRequestCount == 1) + #expect(result.acceptedRequestCount == 0) + #expect(!result.allRequestsAccepted) + #expect(application.requests == [.activate]) + } + + private func controller( + applications: [FakeRunningApplication] + ) -> DockApplicationActionController { + DockApplicationActionController { _ in applications } + } +} + +@MainActor +private final class FakeRunningApplication: + DockRunningApplicationInstance +{ + enum Request: Equatable, Hashable { + case activate + case show + case hide + case quit + } + + let bundleIdentifier: String? + let activationPolicy: NSApplication.ActivationPolicy + let isTerminated: Bool + let isHidden: Bool + let isActive: Bool + let acceptedRequests: Set + private(set) var requests: [Request] = [] + + init( + bundleIdentifier: String?, + activationPolicy: NSApplication.ActivationPolicy = .regular, + isTerminated: Bool = false, + isHidden: Bool = false, + isActive: Bool = false, + acceptedRequests: Set = [ + .activate, + .show, + .hide, + .quit, + ] + ) { + self.bundleIdentifier = bundleIdentifier + self.activationPolicy = activationPolicy + self.isTerminated = isTerminated + self.isHidden = isHidden + self.isActive = isActive + self.acceptedRequests = acceptedRequests + } + + func requestDockActivation() -> Bool { + record(.activate) + } + + func requestDockShow() -> Bool { + record(.show) + } + + func requestDockHide() -> Bool { + record(.hide) + } + + func requestDockQuit() -> Bool { + record(.quit) + } + + private func record(_ request: Request) -> Bool { + requests.append(request) + return acceptedRequests.contains(request) + } +} diff --git a/Tests/FreeDockTests/DockApplicationFileDropPlannerTests.swift b/Tests/FreeDockTests/DockApplicationFileDropPlannerTests.swift new file mode 100644 index 0000000..bfec69b --- /dev/null +++ b/Tests/FreeDockTests/DockApplicationFileDropPlannerTests.swift @@ -0,0 +1,223 @@ +import Foundation +import Testing +@testable import FreeDock + +@Suite("Dock application file drop planner") +struct DockApplicationFileDropPlannerTests { + @Test("Ready plans normalize URLs and preserve first-seen order") + func readyPlan() throws { + let applicationURL = URL( + fileURLWithPath: "/Applications/../Applications/Editor.APP" + ) + let firstURL = URL( + fileURLWithPath: "/tmp/FreeDock Drop/First.txt" + ) + let secondURL = URL( + fileURLWithPath: "/tmp/FreeDock Drop/Nested/../Second.txt" + ) + + let outcome = DockApplicationFileDropPlanner.plan( + applicationURL: applicationURL, + droppedURLs: [firstURL, secondURL] + ) + let plan = try #require(outcome.plan) + + #expect(outcome.isReady) + #expect(outcome.rejection == nil) + #expect( + plan.applicationURL.path == "/Applications/Editor.APP" + ) + #expect( + plan.fileURLs.map(\.path) == [ + "/tmp/FreeDock Drop/First.txt", + "/tmp/FreeDock Drop/Second.txt", + ] + ) + #expect(plan.duplicateInputIndices.isEmpty) + #expect(plan.removedDuplicateCount == 0) + } + + @Test("Lexical duplicates are removed stably and reported") + func stableDuplicateRemoval() throws { + let first = URL(fileURLWithPath: "/tmp/Drop/Document.txt") + let sameWithTraversal = URL( + fileURLWithPath: "/tmp/Drop/Nested/../Document.txt" + ) + let distinctCase = URL( + fileURLWithPath: "/tmp/Drop/document.txt" + ) + + let outcome = DockApplicationFileDropPlanner.plan( + applicationURL: appURL, + droppedURLs: [ + first, + sameWithTraversal, + distinctCase, + first, + ] + ) + let plan = try #require(outcome.plan) + + #expect( + plan.fileURLs.map(\.path) == [ + "/tmp/Drop/Document.txt", + "/tmp/Drop/document.txt", + ] + ) + #expect(plan.duplicateInputIndices == [1, 3]) + #expect(plan.removedDuplicateCount == 2) + } + + @Test("Missing and invalid application URLs have precise rejections") + func invalidApplicationURLs() { + let missing = DockApplicationFileDropPlanner.plan( + applicationURL: nil, + droppedURLs: [documentURL] + ) + let webURL = DockApplicationFileDropPlanner.plan( + applicationURL: URL(string: "https://example.com/Editor.app"), + droppedURLs: [documentURL] + ) + let remoteFileURL = DockApplicationFileDropPlanner.plan( + applicationURL: URL( + string: "file://files.example.com/Applications/Editor.app" + ), + droppedURLs: [documentURL] + ) + let relativeFileURL = DockApplicationFileDropPlanner.plan( + applicationURL: URL(string: "file:Editor")!, + droppedURLs: [documentURL] + ) + + #expect(missing.rejection == .missingApplicationURL) + #expect( + webURL.rejection + == .invalidApplicationURL(.notFileURL) + ) + #expect( + remoteFileURL.rejection + == .invalidApplicationURL(.nonLocalFileURL) + ) + #expect( + relativeFileURL.rejection + == .invalidApplicationURL(.relativePath) + ) + } + + @Test("Extensionless application targets remain lexically valid") + func extensionlessApplicationTarget() throws { + let extensionlessTarget = URL( + fileURLWithPath: "/Applications/Editor Alias" + ) + let outcome = DockApplicationFileDropPlanner.plan( + applicationURL: extensionlessTarget, + droppedURLs: [documentURL] + ) + let plan = try #require(outcome.plan) + + #expect(outcome.isReady) + #expect(plan.applicationURL == extensionlessTarget) + } + + @Test("Folders and packages remain valid application payloads") + func foldersAndPackages() throws { + let folderURL = URL(fileURLWithPath: "/tmp/Project Folder") + let packageURL = URL(fileURLWithPath: "/tmp/Example.playground") + + let outcome = DockApplicationFileDropPlanner.plan( + applicationURL: appURL, + droppedURLs: [folderURL, packageURL] + ) + let plan = try #require(outcome.plan) + + #expect(plan.fileURLs == [folderURL, packageURL]) + } + + @Test("An empty Finder payload is rejected") + func emptyDrop() { + let outcome = DockApplicationFileDropPlanner.plan( + applicationURL: appURL, + droppedURLs: [] + ) + + #expect(outcome.rejection == .emptyDrop) + } + + @Test("Invalid dropped URLs identify their original payload index") + func invalidDroppedURL() { + let webURL = URL(string: "https://example.com/Document.txt")! + let outcome = DockApplicationFileDropPlanner.plan( + applicationURL: appURL, + droppedURLs: [documentURL, webURL] + ) + + #expect( + outcome.rejection + == .invalidDroppedURL(index: 1, reason: .notFileURL) + ) + #expect(!outcome.isReady) + #expect(outcome.plan == nil) + } + + @Test("Remote Finder file URLs are not treated as local paths") + func remoteDroppedURL() { + let remoteFileURL = URL( + string: "file://files.example.com/Shared/Document.txt" + )! + let outcome = DockApplicationFileDropPlanner.plan( + applicationURL: appURL, + droppedURLs: [remoteFileURL] + ) + + #expect( + outcome.rejection + == .invalidDroppedURL( + index: 0, + reason: .nonLocalFileURL + ) + ) + } + + @Test("Missing and relative file paths are rejected precisely") + func malformedFilePaths() { + let missingPath = DockApplicationFileDropPlanner.plan( + applicationURL: appURL, + droppedURLs: [URL(string: "file:")!] + ) + let relativePath = DockApplicationFileDropPlanner.plan( + applicationURL: appURL, + droppedURLs: [URL(string: "file:Document.txt")!] + ) + + #expect( + missingPath.rejection + == .invalidDroppedURL(index: 0, reason: .missingPath) + ) + #expect( + relativePath.rejection + == .invalidDroppedURL(index: 0, reason: .relativePath) + ) + } + + @Test("Localhost file URLs normalize to ordinary local URLs") + func localhostFileURL() throws { + let localhostURL = URL( + string: "file://localhost/tmp/Document.txt" + )! + let outcome = DockApplicationFileDropPlanner.plan( + applicationURL: appURL, + droppedURLs: [localhostURL] + ) + let plan = try #require(outcome.plan) + + #expect(plan.fileURLs == [documentURL]) + } + + private var appURL: URL { + URL(fileURLWithPath: "/Applications/Editor.app") + } + + private var documentURL: URL { + URL(fileURLWithPath: "/tmp/Document.txt") + } +} diff --git a/Tests/FreeDockTests/DockApplicationHoverTooltipTests.swift b/Tests/FreeDockTests/DockApplicationHoverTooltipTests.swift new file mode 100644 index 0000000..1e01b65 --- /dev/null +++ b/Tests/FreeDockTests/DockApplicationHoverTooltipTests.swift @@ -0,0 +1,38 @@ +import Testing +@testable import FreeDock + +@Suite("Dock application hover tooltip") +struct DockApplicationHoverTooltipTests { + @Test("Enabled window switching uses the normal application name") + func enabledSwitchingUsesApplicationName() { + #expect( + DockApplicationHoverTooltip.text( + displayName: "Firefox", + isWindowSwitchingEnabled: true, + isThumbnailCaptureEnabled: true + ) == "Firefox" + ) + } + + @Test("Missing thumbnail permission does not look like a window error") + func missingThumbnailsStillUseApplicationName() { + #expect( + DockApplicationHoverTooltip.text( + displayName: "Firefox", + isWindowSwitchingEnabled: true, + isThumbnailCaptureEnabled: false + ) == "Firefox" + ) + } + + @Test("Missing switching permission keeps the setup instruction") + func missingSwitchingPermissionShowsInstruction() { + #expect( + DockApplicationHoverTooltip.text( + displayName: "Firefox", + isWindowSwitchingEnabled: false, + isThumbnailCaptureEnabled: false + ) == "Right-click to enable window switching" + ) + } +} diff --git a/Tests/FreeDockTests/DockApplicationWindowTests.swift b/Tests/FreeDockTests/DockApplicationWindowTests.swift new file mode 100644 index 0000000..b249232 --- /dev/null +++ b/Tests/FreeDockTests/DockApplicationWindowTests.swift @@ -0,0 +1,757 @@ +import CoreGraphics +import Foundation +import Testing +@testable import FreeDock + +@Suite("Dock application windows") +struct DockApplicationWindowTests { + @Test("All useful top-level windows remain while system dialogs do not") + func allUsefulWindowsRemain() { + let instanceID = UUID() + let dialogID = UUID() + let standardID = UUID() + let windows = DockApplicationWindowPlanner.windows( + from: [ + candidate( + id: dialogID, + instanceID: instanceID, + title: "Composition Surface", + role: .dialog + ), + candidate( + id: standardID, + instanceID: instanceID, + title: "Project", + role: .standard + ), + candidate( + id: UUID(), + instanceID: instanceID, + title: "Voice Controls", + role: .systemDialog + ), + ] + ) + + #expect(Set(windows.map(\.id)) == [dialogID, standardID]) + } + + @Test("Dialogs remain available when an instance has no standard window") + func dialogFallback() { + let instanceID = UUID() + let dialogID = UUID() + let windows = DockApplicationWindowPlanner.windows( + from: [ + candidate( + id: dialogID, + instanceID: instanceID, + title: "Preferences", + role: .dialog + ), + candidate( + id: UUID(), + instanceID: instanceID, + title: "System", + role: .systemDialog + ), + ] + ) + + #expect(windows.map(\.id) == [dialogID]) + } + + @Test("Custom window subroles remain available as a final fallback") + func customRoleFallback() { + let instanceID = UUID() + let customID = UUID() + let windows = DockApplicationWindowPlanner.windows( + from: [ + candidate( + id: customID, + instanceID: instanceID, + title: "Custom Window", + role: .other + ), + candidate( + id: UUID(), + instanceID: instanceID, + title: "System", + role: .systemDialog + ), + ] + ) + + #expect(windows.map(\.id) == [customID]) + } + + @Test("Duplicate titles and separate instances retain exact identities") + func duplicateTitlesRemainSeparate() { + let firstID = UUID() + let secondID = UUID() + let windows = DockApplicationWindowPlanner.windows( + from: [ + candidate( + id: firstID, + instanceID: UUID(), + title: "Untitled" + ), + candidate( + id: secondID, + instanceID: UUID(), + title: "Untitled" + ), + ] + ) + + #expect(Set(windows.map(\.id)) == [firstID, secondID]) + } + + @Test("Titles fall back to document names and then an untitled label") + func titleFallbacks() { + let documentID = UUID() + let untitledID = UUID() + let windows = DockApplicationWindowPlanner.windows( + from: [ + candidate( + id: documentID, + title: " ", + documentPath: "/tmp/Project Notes.txt" + ), + candidate( + id: untitledID, + title: nil, + documentPath: nil + ), + ] + ) + let byID = Dictionary( + uniqueKeysWithValues: windows.map { ($0.id, $0) } + ) + + #expect(byID[documentID]?.title == "Project Notes.txt") + #expect( + byID[untitledID]?.title + == "Untitled Example Window" + ) + } + + @Test("Focused and main windows lead while minimized windows sort last") + func nativeOrdering() { + let focusedID = UUID() + let mainID = UUID() + let ordinaryID = UUID() + let minimizedID = UUID() + let instanceID = UUID() + let windows = DockApplicationWindowPlanner.windows( + from: [ + candidate( + id: minimizedID, + instanceID: instanceID, + title: "Minimized", + isMinimized: true, + sourceOrder: 0 + ), + candidate( + id: ordinaryID, + instanceID: instanceID, + title: "Ordinary", + sourceOrder: 1 + ), + candidate( + id: mainID, + instanceID: instanceID, + title: "Main", + isMain: true, + sourceOrder: 2 + ), + candidate( + id: focusedID, + instanceID: instanceID, + title: "Focused", + isFocused: true, + sourceOrder: 3 + ), + ] + ) + + #expect( + windows.map(\.id) + == [focusedID, mainID, ordinaryID, minimizedID] + ) + } + + @Test("Focus planning restores hidden and minimized targets first") + func focusPlanning() throws { + let window = try #require( + DockApplicationWindowPlanner.windows( + from: [ + candidate( + isMinimized: true, + isHidden: true + ), + ] + ).first + ) + + #expect( + DockApplicationWindowPlanner.focusSteps(for: window) + == [ + .unhideApplication, + .restoreWindow, + .makeMain, + .setFocusedWindow, + .activateApplication, + .raiseWindow, + ] + ) + } + + @Test("Capture matching uses PID, title, and frame") + func captureMatching() { + let candidateID = UUID() + let candidate = candidate( + id: candidateID, + processIdentifier: 42, + title: "Project", + frame: CGRect(x: 20, y: 30, width: 900, height: 700) + ) + let matches = DockApplicationWindowPlanner.captureMatches( + candidates: [candidate], + captureWindows: [ + serverWindow( + id: 7, + processIdentifier: 99, + title: "Project", + frame: candidate.frame! + ), + serverWindow( + id: 8, + processIdentifier: 42, + title: "Project", + frame: candidate.frame! + ), + ] + ) + + #expect( + matches == [ + DockApplicationWindowPlanner.Match( + accessibilityWindowID: candidateID, + captureWindowID: 8 + ), + ] + ) + } + + @Test("Explicit one-to-one frame and title evidence still matches") + func explicitOneToOneEvidenceMatches() { + let sharedFrame = CGRect( + x: 20, + y: 30, + width: 900, + height: 700 + ) + let frameCandidate = candidate( + title: nil, + frame: sharedFrame + ) + let titleCandidate = candidate( + title: "Project", + frame: nil + ) + + #expect( + DockApplicationWindowPlanner.captureMatches( + candidates: [frameCandidate], + captureWindows: [ + serverWindow( + id: 9, + title: nil, + frame: sharedFrame + ), + ] + ).map(\.captureWindowID) == [9] + ) + #expect( + DockApplicationWindowPlanner.captureMatches( + candidates: [titleCandidate], + captureWindows: [ + serverWindow( + id: 10, + title: "Project", + frame: CGRect( + x: 1_200, + y: 80, + width: 640, + height: 480 + ) + ), + ] + ).map(\.captureWindowID) == [10] + ) + } + + @Test("A nonmatching offscreen capture remains independently discoverable") + func nonmatchingOffscreenCaptureRemains() { + let current = candidate( + title: "Current Window", + frame: CGRect( + x: 20, + y: 30, + width: 900, + height: 700 + ), + isFocused: true + ) + let otherDesktop = serverWindow( + id: 11, + title: "Other Desktop", + frame: CGRect( + x: -1_200, + y: 80, + width: 640, + height: 480 + ), + isOnScreen: false + ) + + #expect( + DockApplicationWindowPlanner.captureMatches( + candidates: [current], + captureWindows: [otherDesktop] + ).isEmpty + ) + #expect( + DockApplicationWindowPlanner + .unrepresentedCaptureWindows( + candidates: [current], + captureWindows: [otherDesktop] + ) + .map(\.windowID) == [11] + ) + } + + @Test("Blank metadata is not representation evidence") + func blankMetadataDoesNotDeduplicate() { + let blankCandidate = candidate( + title: nil, + frame: nil + ) + let blankCapture = serverWindow( + id: 12, + title: nil, + isOnScreen: false + ) + + #expect( + DockApplicationWindowPlanner.captureMatches( + candidates: [blankCandidate], + captureWindows: [blankCapture] + ).isEmpty + ) + #expect( + DockApplicationWindowPlanner + .unrepresentedCaptureWindows( + candidates: [blankCandidate], + captureWindows: [blankCapture] + ) + .map(\.windowID) == [12] + ) + } + + @Test("Focused and main AX windows consume the on-screen equal frame") + func currentWindowWinsEqualFrameAmbiguity() { + let sharedFrame = CGRect( + x: 0, + y: 0, + width: 1_440, + height: 900 + ) + let currentCapture = serverWindow( + id: 13, + title: nil, + frame: sharedFrame, + isOnScreen: true + ) + let otherDesktopCapture = serverWindow( + id: 14, + title: nil, + frame: sharedFrame, + isOnScreen: false + ) + let candidates = [ + candidate( + title: nil, + frame: sharedFrame, + isFocused: true + ), + candidate( + title: nil, + frame: sharedFrame, + isMain: true + ), + ] + let captureOrders = [ + [otherDesktopCapture, currentCapture], + [currentCapture, otherDesktopCapture], + ] + + for candidate in candidates { + for captures in captureOrders { + #expect( + DockApplicationWindowPlanner.captureMatches( + candidates: [candidate], + captureWindows: captures + ).map(\.captureWindowID) == [13] + ) + #expect( + DockApplicationWindowPlanner + .unrepresentedCaptureWindows( + candidates: [candidate], + captureWindows: captures + ) + .map(\.windowID) == [14] + ) + } + } + } + + @Test("Duplicate titles are disambiguated by frame") + func duplicateTitleCaptureMatching() { + let firstID = UUID() + let secondID = UUID() + let matches = DockApplicationWindowPlanner.captureMatches( + candidates: [ + candidate( + id: firstID, + title: "Untitled", + frame: CGRect( + x: -1200, + y: 20, + width: 800, + height: 600 + ) + ), + candidate( + id: secondID, + title: "Untitled", + frame: CGRect( + x: 40, + y: 60, + width: 1000, + height: 700 + ) + ), + ], + captureWindows: [ + serverWindow( + id: 10, + title: "Untitled", + frame: CGRect( + x: 41, + y: 60, + width: 1000, + height: 700 + ) + ), + serverWindow( + id: 11, + title: "Untitled", + frame: CGRect( + x: -1200, + y: 20, + width: 800, + height: 600 + ) + ), + ] + ) + + #expect( + Set(matches) == Set([ + DockApplicationWindowPlanner.Match( + accessibilityWindowID: firstID, + captureWindowID: 11 + ), + DockApplicationWindowPlanner.Match( + accessibilityWindowID: secondID, + captureWindowID: 10 + ), + ]) + ) + } + + @Test("Ambiguous duplicate titles do not receive the wrong capture") + func ambiguousCaptureDoesNotMatch() { + let matches = DockApplicationWindowPlanner.captureMatches( + candidates: [ + candidate(title: "Untitled", frame: nil), + candidate(title: "Untitled", frame: nil), + ], + captureWindows: [ + serverWindow(id: 20, title: "Untitled"), + serverWindow(id: 21, title: "Untitled"), + ] + ) + + #expect(matches.isEmpty) + } + + @Test("Ambiguous catalogs preserve extra WindowServer windows once") + func ambiguousCatalogPreservesExtraWindows() { + let candidates = [ + candidate(title: "Untitled", frame: nil), + candidate(title: "Untitled", frame: nil), + ] + let captures = [ + serverWindow(id: 30, title: "Untitled"), + serverWindow(id: 31, title: "Untitled"), + serverWindow(id: 32, title: "Untitled"), + ] + + let unrepresented = + DockApplicationWindowPlanner + .unrepresentedCaptureWindows( + candidates: candidates, + captureWindows: captures + ) + + #expect(unrepresented.map(\.windowID) == [32]) + } + + @Test("Duplicate frames consume accessible windows only once") + func duplicateFramesPreserveExtraWindows() { + let sharedFrame = CGRect( + x: 24, + y: 36, + width: 900, + height: 700 + ) + let candidates = [ + candidate(title: nil, frame: sharedFrame), + candidate(title: nil, frame: sharedFrame), + ] + let captures = [ + serverWindow(id: 33, title: nil, frame: sharedFrame), + serverWindow(id: 34, title: nil, frame: sharedFrame), + serverWindow(id: 35, title: nil, frame: sharedFrame), + ] + + let unrepresented = + DockApplicationWindowPlanner + .unrepresentedCaptureWindows( + candidates: candidates, + captureWindows: captures + ) + + #expect(unrepresented.map(\.windowID) == [35]) + } + + @Test("Representation matching remains isolated by process") + func representationMatchingUsesProcessIdentifier() { + let candidates = [ + candidate( + processIdentifier: 42, + title: "Untitled", + frame: nil + ), + ] + let captures = [ + serverWindow( + id: 36, + processIdentifier: 99, + title: "Untitled" + ), + serverWindow( + id: 37, + processIdentifier: 42, + title: "Untitled" + ), + ] + + let unrepresented = + DockApplicationWindowPlanner + .unrepresentedCaptureWindows( + candidates: candidates, + captureWindows: captures + ) + + #expect(unrepresented.map(\.windowID) == [36]) + } + + @Test("Excluded system dialogs are not reintroduced by capture fallback") + func systemDialogsStayExcluded() { + let frame = CGRect( + x: 40, + y: 50, + width: 420, + height: 260 + ) + let unrepresented = + DockApplicationWindowPlanner + .unrepresentedCaptureWindows( + candidates: [ + candidate( + title: "System Dialog", + frame: frame, + role: .systemDialog + ), + ], + captureWindows: [ + serverWindow( + id: 40, + title: "System Dialog", + frame: frame + ), + ] + ) + + #expect(unrepresented.isEmpty) + } + + @Test("Genuinely missing accessible windows remain discoverable") + func missingAccessibleWindowRemains() { + let unrepresented = + DockApplicationWindowPlanner + .unrepresentedCaptureWindows( + candidates: [ + candidate(title: "First"), + ], + captureWindows: [ + serverWindow(id: 50, title: "First"), + serverWindow(id: 51, title: "Second"), + ] + ) + + #expect(unrepresented.map(\.windowID) == [51]) + } + + @Test("Transient snapshots retain identity and rebase dynamic state") + func transientSnapshotsRemainStable() { + let instanceID = UUID() + let firstID = UUID() + let secondID = UUID() + let retained = + DockApplicationWindowPlanner.retainedCandidates( + from: [ + candidate( + id: firstID, + instanceID: instanceID, + title: "Later", + isHidden: false, + isActive: false, + sourceOrder: 12 + ), + candidate( + id: secondID, + instanceID: instanceID, + title: "Earlier", + isHidden: false, + isActive: false, + sourceOrder: 4 + ), + candidate( + instanceID: UUID(), + title: "Different process", + sourceOrder: 0 + ), + ], + applicationInstanceID: instanceID, + applicationName: "Renamed Example", + isApplicationHidden: true, + isApplicationActive: true, + startingSourceOrder: 7 + ) + + #expect(retained.map(\.id) == [secondID, firstID]) + #expect(retained.map(\.sourceOrder) == [7, 8]) + #expect( + retained.allSatisfy { + $0.applicationName == "Renamed Example" + && $0.isApplicationHidden + && $0.isApplicationActive + } + ) + #expect(retained.map(\.title) == ["Earlier", "Later"]) + } + + @Test("A retained snapshot keeps transient queries displayable") + func retainedSnapshotMakesQueryReady() { + #expect( + DockApplicationWindowPlanner.nativeQueryStatus( + successfulQueryCount: 0, + temporaryFailureCount: 1, + retainedCandidateCount: 2 + ) == .ready + ) + #expect( + DockApplicationWindowPlanner.nativeQueryStatus( + successfulQueryCount: 0, + temporaryFailureCount: 1, + retainedCandidateCount: 0 + ) == .temporarilyUnavailable + ) + #expect( + DockApplicationWindowPlanner.nativeQueryStatus( + successfulQueryCount: 1, + temporaryFailureCount: 1, + retainedCandidateCount: 0 + ) == .ready + ) + } + + private func candidate( + id: UUID = UUID(), + instanceID: UUID = UUID(), + processIdentifier: pid_t = 42, + title: String? = "Window", + documentPath: String? = nil, + frame: CGRect? = nil, + role: DockApplicationWindowRole = .standard, + isMinimized: Bool = false, + isHidden: Bool = false, + isActive: Bool = false, + isMain: Bool = false, + isFocused: Bool = false, + sourceOrder: Int = 0 + ) -> DockApplicationWindowCandidate { + DockApplicationWindowCandidate( + id: id, + applicationInstanceID: instanceID, + processIdentifier: processIdentifier, + applicationName: "Example", + title: title, + documentPath: documentPath, + frame: frame, + role: role, + isMinimized: isMinimized, + isApplicationHidden: isHidden, + isApplicationActive: isActive, + isMain: isMain, + isFocused: isFocused, + sourceOrder: sourceOrder + ) + } + + private func serverWindow( + id: CGWindowID, + processIdentifier: pid_t = 42, + title: String? = "Window", + frame: CGRect = CGRect( + x: 0, + y: 0, + width: 800, + height: 600 + ), + isOnScreen: Bool = true + ) -> DockWindowServerWindow { + DockWindowServerWindow( + windowID: id, + processIdentifier: processIdentifier, + title: title, + frame: frame, + isOnScreen: isOnScreen, + sourceOrder: Int(id) + ) + } +} diff --git a/Tests/FreeDockTests/DockDisplayPlacementTests.swift b/Tests/FreeDockTests/DockDisplayPlacementTests.swift new file mode 100644 index 0000000..ca9ce57 --- /dev/null +++ b/Tests/FreeDockTests/DockDisplayPlacementTests.swift @@ -0,0 +1,429 @@ +import Foundation +import Testing +@testable import FreeDock + +@Test("Display placement normalizes negative-origin monitor coordinates") +func displayPlacementNormalizesNegativeOrigin() { + let visibleFrame = CGRect(x: -1920, y: 0, width: 1920, height: 1050) + let dockFrame = CGRect(x: -1510, y: 120, width: 420, height: 86) + let center = DockDisplayGeometry.normalizedCenter( + of: dockFrame, + in: visibleFrame + ) + let placement = DockDisplayPlacement( + displayID: UUID(), + normalizedCenter: center + ) + + let restored = DockDisplayGeometry.frame( + size: dockFrame.size, + placement: placement, + in: visibleFrame + ) + + #expect(abs(restored.origin.x - dockFrame.origin.x) < 0.001) + #expect(abs(restored.origin.y - dockFrame.origin.y) < 0.001) +} + +@Test("Display placement survives resolution and arrangement changes") +func displayPlacementSurvivesResolutionChange() { + let originalDisplay = CGRect(x: 0, y: 0, width: 1920, height: 1050) + let originalDock = CGRect(x: 1140, y: 650, width: 360, height: 80) + let placement = DockDisplayPlacement( + displayID: UUID(), + normalizedCenter: DockDisplayGeometry.normalizedCenter( + of: originalDock, + in: originalDisplay + ) + ) + let changedDisplay = CGRect(x: -2560, y: 1080, width: 2560, height: 1415) + + let restored = DockDisplayGeometry.frame( + size: originalDock.size, + placement: placement, + in: changedDisplay + ) + let restoredCenter = DockDisplayGeometry.normalizedCenter( + of: restored, + in: changedDisplay + ) + + #expect(abs(restoredCenter.x - placement.normalizedCenter.x) < 0.001) + #expect(abs(restoredCenter.y - placement.normalizedCenter.y) < 0.001) +} + +@Test("Display placement restores exact edges and clamps oversized docks") +func displayPlacementRestoresEdgesAndOversizedFrames() { + let visibleFrame = CGRect(x: 100, y: 200, width: 800, height: 600) + let placement = DockDisplayPlacement( + displayID: UUID(), + normalizedCenter: CGPoint(x: 0.75, y: 0.25), + edge: .top + ) + + let edgeFrame = DockDisplayGeometry.frame( + size: CGSize(width: 300, height: 70), + placement: placement, + in: visibleFrame + ) + #expect(edgeFrame.maxY == visibleFrame.maxY) + + let oversizedFrame = DockDisplayGeometry.frame( + size: CGSize(width: 1200, height: 900), + placement: placement, + in: visibleFrame + ) + #expect(oversizedFrame.origin.x == visibleFrame.minX) + #expect(oversizedFrame.origin.y == visibleFrame.minY) +} + +@Test("Display resolution favors intersection then nearest screen") +func displayGeometrySelectsBestScreen() { + let left = DockDisplayDescriptor( + id: UUID(), + name: "Left", + frame: CGRect(x: -1200, y: 0, width: 1200, height: 900), + visibleFrame: CGRect(x: -1200, y: 0, width: 1200, height: 875), + isPrimary: false + ) + let right = DockDisplayDescriptor( + id: UUID(), + name: "Right", + frame: CGRect(x: 200, y: 0, width: 1400, height: 1000), + visibleFrame: CGRect(x: 200, y: 0, width: 1400, height: 975), + isPrimary: true + ) + + let mostlyLeft = CGRect(x: -300, y: 100, width: 600, height: 100) + #expect( + DockDisplayGeometry.bestDisplay( + for: mostlyLeft, + among: [left, right] + )?.id == left.id + ) + + let inGapNearRight = CGRect(x: 140, y: 300, width: 20, height: 20) + #expect( + DockDisplayGeometry.bestDisplay( + for: inGapNearRight, + among: [left, right] + )?.id == right.id + ) +} + +@Test("Display placement decoding clamps coordinates and tolerates future edges") +func displayPlacementDecodingIsTolerant() throws { + let displayID = UUID() + let data = """ + { + "displayID": "\(displayID.uuidString)", + "displayName": "External", + "normalizedCenter": [4, -2], + "edge": "future-edge" + } + """.data(using: .utf8)! + + let placement = try JSONDecoder().decode( + DockDisplayPlacement.self, + from: data + ) + + #expect(placement.displayID == displayID) + #expect(placement.normalizedCenter == CGPoint(x: 1, y: 0)) + #expect(placement.edge == nil) +} + +@Test("Display placement keeps identity when optional metadata is malformed") +func displayPlacementKeepsIdentityWithMalformedMetadata() throws { + let displayID = UUID() + let data = """ + { + "displayID": "\(displayID.uuidString)", + "displayName": ["not", "a", "name"], + "normalizedCenter": "not-a-point", + "edge": "right" + } + """.data(using: .utf8)! + + let placement = try JSONDecoder().decode( + DockDisplayPlacement.self, + from: data + ) + + #expect(placement.displayID == displayID) + #expect(placement.displayName == nil) + #expect(placement.normalizedCenter == CGPoint(x: 0.5, y: 0.5)) + #expect(placement.edge == .right) +} + +@Test("Malformed display placement does not break older dock data") +func malformedDisplayPlacementFallsBackToAutomatic() throws { + let data = """ + { + "id": "F8CCF00C-3001-4D86-B572-1B5E4B5DBFEA", + "name": "Legacy Dock", + "position": [40, 80], + "displayPlacement": { + "displayID": "not-a-uuid", + "normalizedCenter": [0.5, 0.5] + }, + "orientation": "horizontal", + "iconSize": 48, + "items": [] + } + """.data(using: .utf8)! + + let dock = try JSONDecoder().decode(DockConfig.self, from: data) + #expect(dock.displayPlacement == nil) +} + +@Test("Display edges are sanitized when behavior or orientation changes") +func displayEdgesAreSanitized() { + let placement = DockDisplayPlacement( + displayID: UUID(), + edge: .bottom + ) + var dock = DockConfig( + name: "Dock", + displayPlacement: placement, + orientation: .horizontal, + autoHideWhenDocked: true + ) + + dock.apply(.orientation(.vertical)) + #expect(dock.displayPlacement?.edge == nil) + + dock.displayPlacement = DockDisplayPlacement( + displayID: placement.displayID, + edge: .left + ) + dock.apply(.autoHideWhenDocked(false)) + #expect(dock.displayPlacement?.edge == nil) + + let handEdited = DockConfig( + name: "Hand Edited", + displayPlacement: DockDisplayPlacement( + displayID: placement.displayID, + edge: .right + ), + orientation: .vertical, + autoHideWhenDocked: false + ) + #expect(handEdited.displayPlacement?.edge == nil) +} + +@Test("Duplicate placement offsets normalized center without fabricated dock size") +func duplicatePlacementOffsetsNormalizedCenter() { + let displayID = UUID() + let placement = DockDisplayPlacement( + displayID: displayID, + displayName: "External", + normalizedCenter: CGPoint(x: 0.4, y: 0.6), + edge: .bottom + ) + let visibleFrame = CGRect(x: -1600, y: 0, width: 1600, height: 900) + + let duplicate = DockDisplayGeometry.offset( + placement, + by: CGPoint(x: 32, y: 0), + in: visibleFrame + ) + + #expect(abs(duplicate.normalizedCenter.x - 0.42) < 0.001) + #expect(duplicate.normalizedCenter.y == placement.normalizedCenter.y) + #expect(duplicate.displayID == displayID) + #expect(duplicate.displayName == placement.displayName) + #expect(duplicate.edge == placement.edge) +} + +@Test("Display resolution uses UUID with duplicate names and stable fallback") +func displayResolutionUsesUUIDAndStableFallback() { + let primaryID = UUID() + let desiredID = UUID() + let primary = DockDisplayDescriptor( + id: primaryID, + name: "Studio Display", + frame: CGRect(x: 0, y: 0, width: 1200, height: 900), + visibleFrame: CGRect(x: 0, y: 0, width: 1200, height: 875), + isPrimary: true + ) + let desired = DockDisplayDescriptor( + id: desiredID, + name: "Studio Display", + frame: CGRect(x: 1200, y: 0, width: 1200, height: 900), + visibleFrame: CGRect(x: 1200, y: 0, width: 1200, height: 875), + isPrimary: false + ) + let placement = DockDisplayPlacement( + displayID: desiredID, + displayName: "Studio Display", + normalizedCenter: CGPoint(x: 0.75, y: 0.25) + ) + let frameOnPrimary = CGRect(x: 100, y: 100, width: 300, height: 80) + + for displays in [[primary, desired], [desired, primary]] { + let resolution = DockDisplayGeometry.resolveDisplay( + for: placement, + panelFrame: frameOnPrimary, + among: displays + ) + #expect(resolution?.display.id == desiredID) + #expect(resolution?.isFallback == false) + } + + let missingPlacement = DockDisplayPlacement( + displayID: UUID(), + displayName: "Disconnected", + normalizedCenter: placement.normalizedCenter + ) + let fallback = DockDisplayGeometry.resolveDisplay( + for: missingPlacement, + panelFrame: desired.frame, + among: [desired, primary] + ) + #expect(fallback?.display.id == primaryID) + #expect(fallback?.isFallback == true) + #expect(missingPlacement.displayName == "Disconnected") + #expect(missingPlacement.normalizedCenter == placement.normalizedCenter) +} + +@Test("Display selection tie-breaking is independent of catalog order") +func displaySelectionTieBreakingIsStable() { + let primary = DockDisplayDescriptor( + id: UUID(), + name: "Left", + frame: CGRect(x: 0, y: 0, width: 1000, height: 800), + visibleFrame: CGRect(x: 0, y: 0, width: 1000, height: 775), + isPrimary: true + ) + let right = DockDisplayDescriptor( + id: UUID(), + name: "Right", + frame: CGRect(x: 1000, y: 0, width: 1000, height: 800), + visibleFrame: CGRect(x: 1000, y: 0, width: 1000, height: 775), + isPrimary: false + ) + let equallySplit = CGRect(x: 800, y: 200, width: 400, height: 100) + + #expect( + DockDisplayGeometry.bestDisplay( + for: equallySplit, + among: [primary, right] + )?.id == right.id + ) + #expect( + DockDisplayGeometry.bestDisplay( + for: equallySplit, + among: [right, primary] + )?.id == right.id + ) +} + +@Test("Duplicate display names receive deterministic spatial labels") +func duplicateDisplayNamesReceiveSpatialLabels() { + let primary = DockDisplayDescriptor( + id: UUID(), + name: "Studio Display", + frame: CGRect(x: 0, y: 0, width: 1200, height: 900), + visibleFrame: CGRect(x: 0, y: 0, width: 1200, height: 875), + isPrimary: true + ) + let right = DockDisplayDescriptor( + id: UUID(), + name: "Studio Display", + frame: CGRect(x: 1200, y: 0, width: 1200, height: 900), + visibleFrame: CGRect(x: 1200, y: 0, width: 1200, height: 875), + isPrimary: false + ) + + let labeled = DockDisplayGeometry.uniquelyLabeled([right, primary]) + #expect(labeled.first(where: { $0.id == primary.id })?.label == "Studio Display") + #expect(labeled.first(where: { $0.id == right.id })?.label == "Studio Display — Right") +} + +@Test("Auto-hide avoids internal monitor seams") +func autoHideAvoidsInternalMonitorSeams() { + let left = DockDisplayDescriptor( + id: UUID(), + name: "Left", + frame: CGRect(x: 0, y: 0, width: 1000, height: 800), + visibleFrame: CGRect(x: 0, y: 0, width: 1000, height: 775), + isPrimary: true + ) + let right = DockDisplayDescriptor( + id: UUID(), + name: "Right", + frame: CGRect(x: 1000, y: 0, width: 1000, height: 800), + visibleFrame: CGRect(x: 1000, y: 0, width: 1000, height: 775), + isPrimary: false + ) + let dock = CGRect(x: 920, y: 250, width: 80, height: 300) + + let exposed = DockDisplayGeometry.exposedEdges( + for: dock, + on: left, + among: [left, right] + ) + #expect(!exposed.contains(.right)) + #expect(exposed.contains(.left)) + #expect( + DockDisplayGeometry.preferredDockingEdge( + of: dock, + on: left, + among: [left, right], + orientation: .vertical + ) == .left + ) +} + +@Test("Docking clamps oversized docks safely") +func dockingClampsOversizedDocks() { + let visibleFrame = CGRect(x: 100, y: 200, width: 800, height: 600) + let oversized = CGRect(x: -500, y: 750, width: 1200, height: 100) + + let docked = DockDisplayGeometry.dockedFrame( + oversized, + at: .top, + in: visibleFrame + ) + + #expect(docked.origin.x == visibleFrame.minX) + #expect(docked.maxY == visibleFrame.maxY) +} + +@Test("Temporary clamping does not replace the durable normalized center") +func temporaryClampingPreservesDurableCenter() { + let placement = DockDisplayPlacement( + displayID: UUID(), + normalizedCenter: CGPoint(x: 0.8, y: 0.5) + ) + let small = CGRect(x: 0, y: 0, width: 500, height: 400) + let large = CGRect(x: 0, y: 0, width: 2000, height: 1200) + let size = CGSize(width: 400, height: 80) + + let temporarilyClamped = DockDisplayGeometry.frame( + size: size, + placement: placement, + in: small + ) + let restored = DockDisplayGeometry.frame( + size: size, + placement: placement, + in: large + ) + + #expect( + DockDisplayGeometry.normalizedCenter( + of: temporarilyClamped, + in: small + ).x != placement.normalizedCenter.x + ) + #expect( + abs( + DockDisplayGeometry.normalizedCenter( + of: restored, + in: large + ).x - placement.normalizedCenter.x + ) < 0.001 + ) +} diff --git a/Tests/FreeDockTests/DockExternalFileDropClaimStateTests.swift b/Tests/FreeDockTests/DockExternalFileDropClaimStateTests.swift new file mode 100644 index 0000000..b475589 --- /dev/null +++ b/Tests/FreeDockTests/DockExternalFileDropClaimStateTests.swift @@ -0,0 +1,188 @@ +import Foundation +import Testing +@testable import FreeDock + +@Suite("Dock external file drop claim state") +struct DockExternalFileDropClaimStateTests { + @Test("Application targeting suppresses the parent pin target") + func applicationTargeting() { + let firstItemID = UUID() + let secondItemID = UUID() + var state = DockExternalFileDropClaimState() + + #expect(!state.shouldSuppressParentPinDrop) + + state.applicationTargetEntered(itemID: firstItemID) + #expect(state.applicationTargetItemID == firstItemID) + #expect(state.shouldSuppressParentPinDrop) + + state.applicationTargetEntered(itemID: secondItemID) + state.applicationTargetExited(itemID: firstItemID) + #expect(state.applicationTargetItemID == secondItemID) + #expect(state.shouldSuppressParentPinDrop) + + state.applicationTargetExited(itemID: secondItemID) + #expect(state.applicationTargetItemID == nil) + #expect(!state.shouldSuppressParentPinDrop) + } + + @Test("Ordinary parent operations do not claim the parent target") + func ordinaryOperation() { + let operationID = UUID() + var state = DockExternalFileDropClaimState() + + let returnedID = state.beginOperation( + operationID: operationID + ) + + #expect(returnedID == operationID) + #expect(state.activeOperationID == operationID) + #expect(state.isOperationInProgress) + #expect(!state.shouldSuppressParentPinDrop) + + let didFinish = state.finishOperation(operationID) + #expect(didFinish) + #expect(state.activeOperationID == nil) + #expect(!state.isOperationInProgress) + #expect(!state.isAwaitingDispatchRelease) + } + + @Test("Dispatch suppression clears while async loading stays active") + func dispatchSuppressionRelease() { + let itemID = UUID() + let operationID = UUID() + var state = DockExternalFileDropClaimState() + + let returnedID = state.claimApplicationDrop( + itemID: itemID, + operationID: operationID + ) + state.applicationTargetExited(itemID: itemID) + + #expect(returnedID == operationID) + #expect(state.applicationTargetItemID == nil) + #expect(state.activeOperationID == operationID) + #expect(state.isOperationInProgress) + #expect( + state.dispatchSuppressionOperationID == operationID + ) + #expect(state.shouldSuppressParentPinDrop) + + let didRelease = state.releaseDispatchSuppression( + for: operationID + ) + #expect(didRelease) + #expect(state.activeOperationID == operationID) + #expect(state.isOperationInProgress) + #expect(!state.isAwaitingDispatchRelease) + #expect(!state.shouldSuppressParentPinDrop) + + let didFinish = state.finishOperation(operationID) + #expect(didFinish) + #expect(!state.isOperationInProgress) + #expect(!state.shouldSuppressParentPinDrop) + } + + @Test("A stale finish cannot clear a newer operation") + func staleFinish() { + let firstOperationID = UUID() + let secondOperationID = UUID() + let secondItemID = UUID() + var state = DockExternalFileDropClaimState() + + state.beginOperation(operationID: firstOperationID) + state.claimApplicationDrop( + itemID: secondItemID, + operationID: secondOperationID + ) + + let didFinishFirst = state.finishOperation( + firstOperationID + ) + #expect(!didFinishFirst) + #expect(state.activeOperationID == secondOperationID) + #expect(state.applicationTargetItemID == secondItemID) + #expect(state.shouldSuppressParentPinDrop) + } + + @Test("Finishing does not create or extend suppression") + func finishDoesNotCreateSuppression() { + let itemID = UUID() + let operationID = UUID() + var state = DockExternalFileDropClaimState() + state.claimApplicationDrop( + itemID: itemID, + operationID: operationID + ) + state.applicationTargetExited(itemID: itemID) + let didRelease = state.releaseDispatchSuppression( + for: operationID + ) + #expect(didRelease) + #expect(!state.shouldSuppressParentPinDrop) + + let didFinish = state.finishOperation(operationID) + #expect(didFinish) + #expect(state.activeOperationID == nil) + #expect(state.applicationTargetItemID == nil) + #expect(state.dispatchSuppressionOperationID == nil) + #expect(!state.isAwaitingDispatchRelease) + #expect(!state.shouldSuppressParentPinDrop) + } + + @Test("A stale dispatch release cannot release a newer claim") + func staleDispatchRelease() { + let firstOperationID = UUID() + let secondOperationID = UUID() + let firstItemID = UUID() + let secondItemID = UUID() + var state = DockExternalFileDropClaimState() + + state.claimApplicationDrop( + itemID: firstItemID, + operationID: firstOperationID + ) + state.applicationTargetExited(itemID: firstItemID) + + state.claimApplicationDrop( + itemID: secondItemID, + operationID: secondOperationID + ) + state.applicationTargetExited(itemID: secondItemID) + let didReleaseFirst = state.releaseDispatchSuppression( + for: firstOperationID + ) + #expect(!didReleaseFirst) + #expect(state.activeOperationID == secondOperationID) + #expect( + state.dispatchSuppressionOperationID + == secondOperationID + ) + #expect(state.shouldSuppressParentPinDrop) + + let didReleaseSecond = state.releaseDispatchSuppression( + for: secondOperationID + ) + #expect(didReleaseSecond) + #expect(state.activeOperationID == secondOperationID) + #expect(!state.shouldSuppressParentPinDrop) + } + + @Test("Reset clears targeting, loading, and dispatch suppression") + func reset() { + let operationID = UUID() + var state = DockExternalFileDropClaimState() + state.claimApplicationDrop( + itemID: UUID(), + operationID: operationID + ) + + state.reset() + + #expect(state == DockExternalFileDropClaimState()) + #expect(state.applicationTargetItemID == nil) + #expect(state.activeOperationID == nil) + #expect(state.dispatchSuppressionOperationID == nil) + #expect(!state.shouldSuppressParentPinDrop) + } +} diff --git a/Tests/FreeDockTests/DockItemDragCoordinatorTests.swift b/Tests/FreeDockTests/DockItemDragCoordinatorTests.swift new file mode 100644 index 0000000..b5eb815 --- /dev/null +++ b/Tests/FreeDockTests/DockItemDragCoordinatorTests.swift @@ -0,0 +1,75 @@ +import Foundation +import Testing +import UniformTypeIdentifiers +@testable import FreeDock + +@Suite("Dock item drag coordinator") +@MainActor +struct DockItemDragCoordinatorTests { + @Test("Private drag providers round-trip the complete session") + func providerRoundTrip() async { + let profileID = UUID() + let sourceDockID = UUID() + let itemID = UUID() + let coordinator = DockItemDragCoordinator() + let provider = coordinator.beginDrag( + profileID: profileID, + sourceDockID: sourceDockID, + itemID: itemID + ) + + #expect( + provider.hasItemConformingToTypeIdentifier( + UTType.freeDockItem.identifier + ) + ) + let decoded = await withCheckedContinuation { continuation in + coordinator.loadSession(from: provider) { + continuation.resume(returning: $0) + } + } + + #expect(decoded == coordinator.activeSession) + #expect(decoded?.profileID == profileID) + #expect(decoded?.sourceDockID == sourceDockID) + #expect(decoded?.itemID == itemID) + coordinator.cancel() + #expect(coordinator.activeSession == nil) + } + + @Test("Stale completion cannot end a newer drag") + func staleFinishIgnored() { + let coordinator = DockItemDragCoordinator() + _ = coordinator.beginDrag( + profileID: UUID(), + sourceDockID: UUID(), + itemID: UUID() + ) + let firstSessionID = coordinator.activeSession?.id + + _ = coordinator.beginDrag( + profileID: UUID(), + sourceDockID: UUID(), + itemID: UUID() + ) + let secondSessionID = coordinator.activeSession?.id + if let firstSessionID { + coordinator.finish(sessionID: firstSessionID) + } + + #expect(coordinator.activeSession?.id == secondSessionID) + if let secondSessionID { + coordinator.finish(sessionID: secondSessionID) + } + #expect(coordinator.activeSession == nil) + } + + @Test("Content revisions are monotonic") + func contentRevision() { + let coordinator = DockItemDragCoordinator() + let initial = coordinator.contentRevision + coordinator.publishContentChange() + coordinator.publishContentChange() + #expect(coordinator.contentRevision == initial + 2) + } +} diff --git a/Tests/FreeDockTests/DockItemModelTests.swift b/Tests/FreeDockTests/DockItemModelTests.swift new file mode 100644 index 0000000..653d44a --- /dev/null +++ b/Tests/FreeDockTests/DockItemModelTests.swift @@ -0,0 +1,240 @@ +import Foundation +import Testing +@testable import FreeDock + +@Test("Legacy dock items decode as typed applications and separators") +func dockItemLegacyMigration() throws { + let appID = UUID() + let appData = """ + { + "id": "\(appID.uuidString)", + "appPath": "/Applications/Safari.app", + "label": "Safari", + "isSeparator": false + } + """.data(using: .utf8)! + let app = try JSONDecoder().decode(DockItem.self, from: appData) + + #expect(app.id == appID) + #expect(app.kind == .application) + #expect(app.path == "/Applications/Safari.app") + #expect(app.appPath == app.path) + #expect(!app.isSeparator) + #expect(app.folderOptions == nil) + + let separatorData = """ + { + "id": "\(UUID().uuidString)", + "appPath": "/a/legacy/value/that/must/be/ignored", + "isSeparator": true + } + """.data(using: .utf8)! + let separator = try JSONDecoder().decode(DockItem.self, from: separatorData) + + #expect(separator.kind == .separator) + #expect(separator.path.isEmpty) + #expect(separator.isSeparator) +} + +@Test("Typed dock items round-trip while retaining legacy wire keys") +func typedDockItemsRoundTrip() throws { + let options = FolderStackOptions( + presentation: .list, + sortOrder: .dateModified, + showHiddenFiles: true + ) + let items = [ + DockItem( + kind: .application, + path: "/Applications/FreeDock.app", + label: "FreeDock" + ), + DockItem( + kind: .document, + path: "/Users/test/Notes.txt", + label: "Notes" + ), + DockItem( + kind: .folder, + path: "/Users/test/Downloads", + label: "Downloads", + folderOptions: options + ), + DockItem.separator(), + ] + + let data = try JSONEncoder().encode(items) + let decoded = try JSONDecoder().decode([DockItem].self, from: data) + let objects = try #require( + JSONSerialization.jsonObject(with: data) as? [[String: Any]] + ) + + #expect(decoded == items) + #expect(decoded[2].folderOptions == options) + #expect(objects.allSatisfy { $0["path"] != nil }) + #expect(objects.allSatisfy { $0["appPath"] != nil }) + #expect(objects.allSatisfy { $0["isSeparator"] != nil }) + #expect(objects[2]["kind"] as? String == "folder") +} + +@Test("Unknown item and folder option values migrate tolerantly") +func dockItemUnknownValueFallbacks() throws { + let unknownKindData = """ + { + "id": "\(UUID().uuidString)", + "path": "/tmp/example", + "appPath": "/tmp/stale", + "kind": "future-smart-stack", + "isSeparator": false + } + """.data(using: .utf8)! + let unknownKind = try JSONDecoder().decode( + DockItem.self, + from: unknownKindData + ) + + #expect(unknownKind.kind == .application) + #expect(unknownKind.path == "/tmp/example") + + let folderData = """ + { + "id": "\(UUID().uuidString)", + "path": "/tmp/folder", + "kind": "folder", + "isSeparator": false, + "folderOptions": { + "presentation": "future-carousel", + "sortOrder": "future-relevance", + "showHiddenFiles": "not-a-boolean" + } + } + """.data(using: .utf8)! + let folder = try JSONDecoder().decode(DockItem.self, from: folderData) + + #expect(folder.kind == .folder) + #expect(folder.folderOptions == FolderStackOptions()) +} + +@Test("Pinning URLs classifies applications, folders, and documents") +func dockItemURLClassification() throws { + let temporaryDirectory = try makeDockItemTemporaryDirectory() + defer { try? FileManager.default.removeItem(at: temporaryDirectory) } + + let applicationURL = temporaryDirectory.appendingPathComponent("Example.app") + let folderURL = temporaryDirectory.appendingPathComponent("Projects") + let documentURL = temporaryDirectory.appendingPathComponent("Notes.txt") + try FileManager.default.createDirectory( + at: applicationURL, + withIntermediateDirectories: true + ) + try FileManager.default.createDirectory( + at: folderURL, + withIntermediateDirectories: true + ) + try Data("notes".utf8).write(to: documentURL) + + let application = try #require(DockItem.pinnedItem(at: applicationURL)) + let folder = try #require(DockItem.pinnedItem(at: folderURL)) + let document = try #require(DockItem.pinnedItem(at: documentURL)) + + #expect(application.kind == .application) + #expect(application.label == "Example") + #expect(folder.kind == .folder) + #expect(folder.folderOptions == FolderStackOptions()) + #expect(document.kind == .document) + #expect( + DockItem.pinnedItem( + at: temporaryDirectory.appendingPathComponent("Missing") + ) == nil + ) + #expect(DockItem.pinnedItem(at: URL(string: "https://example.com")!) == nil) +} + +@Test("Dock duplication preserves typed item metadata with fresh IDs") +func typedDockItemDuplication() { + let options = FolderStackOptions( + presentation: .grid, + sortOrder: .kind, + showHiddenFiles: true + ) + let original = DockConfig( + name: "Files", + items: [ + DockItem(kind: .document, path: "/tmp/Brief.pdf", label: "Brief"), + DockItem( + kind: .folder, + path: "/tmp/Project", + label: "Project", + folderOptions: options + ), + .separator(), + ] + ) + + let duplicate = original.duplicated(name: "Files Copy", position: .zero) + + #expect(duplicate.items.map(\.id) != original.items.map(\.id)) + #expect(duplicate.items.map(\.kind) == original.items.map(\.kind)) + #expect(duplicate.items.map(\.path) == original.items.map(\.path)) + #expect(duplicate.items.map(\.label) == original.items.map(\.label)) + #expect( + duplicate.items.map(\.folderOptions) + == original.items.map(\.folderOptions) + ) +} + +@Test("Dock item planner preserves URL order and skips repeated identities") +func dockItemPlannerOrderAndUniqueness() throws { + let temporaryDirectory = try makeDockItemTemporaryDirectory() + defer { try? FileManager.default.removeItem(at: temporaryDirectory) } + + let firstURL = temporaryDirectory.appendingPathComponent("First.txt") + let secondURL = temporaryDirectory.appendingPathComponent("Second.txt") + let folderURL = temporaryDirectory.appendingPathComponent("Folder") + let firstAliasURL = temporaryDirectory.appendingPathComponent("First Alias.txt") + let missingURL = temporaryDirectory.appendingPathComponent("Missing.txt") + try Data("first".utf8).write(to: firstURL) + try Data("second".utf8).write(to: secondURL) + try FileManager.default.createDirectory( + at: folderURL, + withIntermediateDirectories: true + ) + try FileManager.default.createSymbolicLink( + at: firstAliasURL, + withDestinationURL: firstURL + ) + + let existing = [ + try #require(DockItem.pinnedItem(at: firstURL)), + DockItem.separator(), + ] + let plan = DockItemPlanner.planAdding( + urls: [ + secondURL, + folderURL, + secondURL, + firstAliasURL, + missingURL, + ], + to: existing + ) + + #expect(plan.addedCount == 2) + #expect(plan.skippedCount == 3) + #expect(plan.items.count == existing.count + 2) + #expect(plan.items.suffix(2).map(\.path) == [ + secondURL.standardizedFileURL.path, + folderURL.standardizedFileURL.path, + ]) + #expect(plan.items.suffix(2).map(\.kind) == [.document, .folder]) +} + +private func makeDockItemTemporaryDirectory() throws -> URL { + let url = FileManager.default.temporaryDirectory + .appendingPathComponent("freedock-items-\(UUID().uuidString)") + try FileManager.default.createDirectory( + at: url, + withIntermediateDirectories: true + ) + return url +} diff --git a/Tests/FreeDockTests/DockItemTransferPersistenceTests.swift b/Tests/FreeDockTests/DockItemTransferPersistenceTests.swift new file mode 100644 index 0000000..f701d8f --- /dev/null +++ b/Tests/FreeDockTests/DockItemTransferPersistenceTests.swift @@ -0,0 +1,284 @@ +import Foundation +import Testing +@testable import FreeDock + +@Suite("Dock item transfer persistence") +struct DockItemTransferPersistenceTests { + @Test("Cross-dock move persists in the active profile only") + func movePersistsInActiveProfile() throws { + let retainedItem = DockItem.document( + at: URL(fileURLWithPath: "/tmp/FreeDock Retained.txt") + ) + let transferredItem = DockItem( + kind: .folder, + path: "/tmp/FreeDock Project", + label: "Open-source Project", + folderOptions: FolderStackOptions( + presentation: .grid, + sortOrder: .dateModified, + showHiddenFiles: true + ) + ) + let targetItem = DockItem( + kind: .application, + path: "/Applications/Target.app", + label: "Target" + ) + let sourceDock = DockConfig( + name: "Active Source", + orientation: .vertical, + iconSize: 54, + items: [retainedItem, transferredItem] + ) + let targetDock = DockConfig( + name: "Active Target", + iconSize: 62, + items: [targetItem] + ) + let activeProfile = DockProfile( + name: "Active", + docks: [sourceDock, targetDock] + ) + let inactiveProfile = inactiveFixtureProfile() + let originalConfig = AppConfig( + profiles: [activeProfile, inactiveProfile], + activeProfileID: activeProfile.id + ) + + let (updatedConfig, plan) = try applyingTransfer( + to: originalConfig, + itemID: transferredItem.id, + sourceDockID: sourceDock.id, + targetDockID: targetDock.id, + operation: .move + ) + let reloaded = try saveAndReload(updatedConfig) + + #expect(plan.outcome == .moved(itemID: transferredItem.id)) + #expect(reloaded.activeProfileID == activeProfile.id) + let reloadedActive = try #require( + reloaded.profiles.first { $0.id == activeProfile.id } + ) + let reloadedSource = try #require( + reloadedActive.docks.first { $0.id == sourceDock.id } + ) + let reloadedTarget = try #require( + reloadedActive.docks.first { $0.id == targetDock.id } + ) + #expect(reloadedSource.items == [retainedItem]) + #expect(reloadedTarget.items == [targetItem, transferredItem]) + #expect(reloadedSource.settings == sourceDock.settings) + #expect(reloadedTarget.settings == targetDock.settings) + #expect( + reloaded.profiles.first { $0.id == inactiveProfile.id } + == inactiveProfile + ) + } + + @Test("Option-copy persists fresh identity and complete item metadata") + func copyPersistsFreshIdentityAndMetadata() throws { + let transferredItem = DockItem.smartStack( + .recentFiles, + label: "Everything I Opened", + options: FolderStackOptions( + presentation: .list, + sortOrder: .recentlyOpened, + showHiddenFiles: true + ) + ) + let targetItem = DockItem.separator() + let sourceDock = DockConfig( + name: "Active Source", + items: [transferredItem] + ) + let targetDock = DockConfig( + name: "Active Target", + items: [targetItem] + ) + let activeProfile = DockProfile( + name: "Active", + docks: [sourceDock, targetDock] + ) + let inactiveProfile = inactiveFixtureProfile() + let originalConfig = AppConfig( + profiles: [inactiveProfile, activeProfile], + activeProfileID: activeProfile.id + ) + + let (updatedConfig, plan) = try applyingTransfer( + to: originalConfig, + itemID: transferredItem.id, + sourceDockID: sourceDock.id, + targetDockID: targetDock.id, + operation: .copy + ) + let copiedItem = try #require(plan.targetItems.last) + let reloaded = try saveAndReload(updatedConfig) + let reloadedActive = try #require( + reloaded.profiles.first { $0.id == activeProfile.id } + ) + let reloadedSource = try #require( + reloadedActive.docks.first { $0.id == sourceDock.id } + ) + let reloadedTarget = try #require( + reloadedActive.docks.first { $0.id == targetDock.id } + ) + + #expect(copiedItem.id != transferredItem.id) + var expectedCopy = transferredItem + expectedCopy.id = copiedItem.id + #expect(copiedItem == expectedCopy) + #expect(plan.outcome == .copied(itemID: copiedItem.id)) + #expect(reloadedSource.items == sourceDock.items) + #expect(reloadedTarget.items == [targetItem, expectedCopy]) + #expect( + reloaded.profiles.first { $0.id == inactiveProfile.id } + == inactiveProfile + ) + } + + @Test("Canonical paths reject a symlink duplicate atomically") + func symlinkDuplicateIsRejected() throws { + let temporaryDirectory = FileManager.default.temporaryDirectory + .appendingPathComponent( + "freedock-transfer-symlink-\(UUID().uuidString)" + ) + try FileManager.default.createDirectory( + at: temporaryDirectory, + withIntermediateDirectories: true + ) + defer { + try? FileManager.default.removeItem(at: temporaryDirectory) + } + + let realFolder = temporaryDirectory.appendingPathComponent("Real") + let linkedFolder = temporaryDirectory.appendingPathComponent("Alias") + try FileManager.default.createDirectory( + at: realFolder, + withIntermediateDirectories: false + ) + try FileManager.default.createSymbolicLink( + at: linkedFolder, + withDestinationURL: realFolder + ) + + let sourceItem = DockItem.folder(at: linkedFolder) + let targetItem = DockItem.folder(at: realFolder) + let sourceDock = DockConfig( + name: "Source", + items: [sourceItem] + ) + let targetDock = DockConfig( + name: "Target", + items: [targetItem] + ) + + let plan = DockItemTransferPlanner.planTransfer( + itemID: sourceItem.id, + from: sourceDock, + to: targetDock + ) + + #expect( + plan.outcome + == .rejected( + .duplicateItem(existingItemID: targetItem.id) + ) + ) + #expect(plan.sourceItems == sourceDock.items) + #expect(plan.targetItems == targetDock.items) + } + + private func applyingTransfer( + to config: AppConfig, + itemID: UUID, + sourceDockID: UUID, + targetDockID: UUID, + operation: DockItemTransferOperation + ) throws -> (AppConfig, DockItemTransferPlan) { + var updatedConfig = config + var activeDocks = updatedConfig.docks + guard let sourceIndex = activeDocks.firstIndex(where: { + $0.id == sourceDockID + }) else { + throw TransferFixtureError.missingSourceDock + } + guard let targetIndex = activeDocks.firstIndex(where: { + $0.id == targetDockID + }) else { + throw TransferFixtureError.missingTargetDock + } + + let plan = DockItemTransferPlanner.planTransfer( + itemID: itemID, + from: activeDocks[sourceIndex], + to: activeDocks[targetIndex], + operation: operation + ) + guard plan.didTransfer else { + throw TransferFixtureError.transferRejected + } + + activeDocks[sourceIndex].items = plan.sourceItems + activeDocks[targetIndex].items = plan.targetItems + updatedConfig.docks = activeDocks + return (updatedConfig, plan) + } + + private func saveAndReload(_ config: AppConfig) throws -> AppConfig { + let temporaryDirectory = FileManager.default.temporaryDirectory + .appendingPathComponent( + "freedock-transfer-persistence-\(UUID().uuidString)" + ) + try FileManager.default.createDirectory( + at: temporaryDirectory, + withIntermediateDirectories: true + ) + defer { + try? FileManager.default.removeItem(at: temporaryDirectory) + } + + let configPath = temporaryDirectory.appendingPathComponent( + "freedock.json" + ) + let manager = ConfigManager(configPath: configPath) + manager.config = config + manager.saveImmediately() + + let reloadedManager = ConfigManager(configPath: configPath) + guard reloadedManager.loadedFromDisk else { + throw TransferFixtureError.configDidNotReload + } + return reloadedManager.config + } + + private func inactiveFixtureProfile() -> DockProfile { + DockProfile( + name: "Inactive", + docks: [ + DockConfig( + name: "Inactive Dock", + orientation: .vertical, + iconSize: 41, + items: [ + DockItem( + kind: .application, + path: "/Applications/Inactive.app", + label: "Leave Me Alone" + ), + DockItem.downloadsStack( + label: "Inactive Downloads" + ), + ] + ), + ] + ) + } +} + +private enum TransferFixtureError: Error { + case missingSourceDock + case missingTargetDock + case transferRejected + case configDidNotReload +} diff --git a/Tests/FreeDockTests/DockItemTransferPlannerTests.swift b/Tests/FreeDockTests/DockItemTransferPlannerTests.swift new file mode 100644 index 0000000..c7387dd --- /dev/null +++ b/Tests/FreeDockTests/DockItemTransferPlannerTests.swift @@ -0,0 +1,276 @@ +import Foundation +import Testing +@testable import FreeDock + +@Suite("Dock item transfer planner") +struct DockItemTransferPlannerTests { + @Test("Cross-dock moves default to retaining identity and metadata") + func crossDockMove() throws { + let options = FolderStackOptions( + presentation: .grid, + sortOrder: .dateModified, + showHiddenFiles: true + ) + let before = DockItem.document( + at: URL(fileURLWithPath: "/tmp/Before.txt") + ) + let transferred = DockItem( + kind: .folder, + path: "/tmp/Project", + label: "Custom Project", + folderOptions: options + ) + let after = DockItem.document( + at: URL(fileURLWithPath: "/tmp/After.txt") + ) + let source = DockConfig( + name: "Source", + items: [.separator(), transferred] + ) + let target = DockConfig( + name: "Target", + items: [before, after] + ) + + let plan = DockItemTransferPlanner.planTransfer( + itemID: transferred.id, + from: source, + to: target, + destination: .item(after.id) + ) + + #expect(plan.sourceItems == [source.items[0]]) + #expect(plan.targetItems == [before, transferred, after]) + #expect(plan.outcome == .moved(itemID: transferred.id)) + #expect(plan.didTransfer) + } + + @Test("Option-copy appends an exact value copy with a fresh identity") + func crossDockCopy() throws { + let options = FolderStackOptions( + presentation: .list, + sortOrder: .recentlyOpened, + showHiddenFiles: true + ) + let transferred = DockItem.smartStack( + .recentFiles, + label: "My Recents", + options: options + ) + let source = DockConfig(name: "Source", items: [transferred]) + let target = DockConfig(name: "Target", items: [.separator()]) + + let plan = DockItemTransferPlanner.planTransfer( + itemID: transferred.id, + from: source, + to: target, + operation: .copy, + destination: .trailing + ) + let copied = try #require(plan.targetItems.last) + + #expect(plan.sourceItems == source.items) + #expect(plan.targetItems.dropLast() == target.items[...]) + #expect(copied.id != transferred.id) + var expectedCopy = transferred + expectedCopy.id = copied.id + #expect(copied == expectedCopy) + #expect(plan.outcome == .copied(itemID: copied.id)) + #expect(plan.didTransfer) + } + + @Test("Logical duplicates reject atomically instead of losing source metadata") + func duplicateMoveRejected() { + let sourceItem = DockItem( + kind: .application, + path: "/Applications/Example.app", + label: "Source Name" + ) + let existing = DockItem( + kind: .application, + path: "/Applications/Example.app", + label: "Target Name" + ) + let source = DockConfig(name: "Source", items: [sourceItem]) + let target = DockConfig(name: "Target", items: [existing]) + + let plan = DockItemTransferPlanner.planTransfer( + itemID: sourceItem.id, + from: source, + to: target + ) + + #expect(plan.sourceItems == source.items) + #expect(plan.targetItems == target.items) + #expect( + plan.outcome + == .rejected(.duplicateItem(existingItemID: existing.id)) + ) + #expect(!plan.didTransfer) + } + + @Test("Smart-stack duplicates reject copies while separators stay repeatable") + func semanticDuplicateRules() throws { + let stack = DockItem.recentFilesStack(label: "Source Recents") + let existingStack = DockItem.recentFilesStack(label: "Target Recents") + let source = DockConfig( + name: "Source", + items: [stack, .separator()] + ) + let target = DockConfig(name: "Target", items: [existingStack]) + + let rejected = DockItemTransferPlanner.planTransfer( + itemID: stack.id, + from: source, + to: target, + operation: .copy + ) + #expect( + rejected.outcome + == .rejected( + .duplicateItem(existingItemID: existingStack.id) + ) + ) + + let separator = source.items[1] + let copiedSeparator = DockItemTransferPlanner.planTransfer( + itemID: separator.id, + from: source, + to: target, + operation: .copy + ) + let appended = try #require(copiedSeparator.targetItems.last) + #expect(appended.kind == .separator) + #expect(appended.id != separator.id) + #expect(copiedSeparator.didTransfer) + } + + @Test("Same-dock forward and backward moves retain target-slot behavior") + func sameDockTargetSlotReorder() { + let first = DockItem(appPath: "/Applications/First.app") + let second = DockItem(appPath: "/Applications/Second.app") + let third = DockItem(appPath: "/Applications/Third.app") + let fourth = DockItem(appPath: "/Applications/Fourth.app") + let dock = DockConfig( + name: "One Dock", + items: [first, second, third, fourth] + ) + + let forward = DockItemTransferPlanner.planTransfer( + itemID: first.id, + from: dock, + to: dock, + destination: .item(third.id) + ) + #expect(forward.sourceItems == [second, third, first, fourth]) + #expect(forward.targetItems == forward.sourceItems) + + let backward = DockItemTransferPlanner.planTransfer( + itemID: fourth.id, + from: dock, + to: dock, + destination: .item(second.id) + ) + #expect(backward.sourceItems == [first, fourth, second, third]) + #expect(backward.targetItems == backward.sourceItems) + } + + @Test("Same-dock trailing moves and self-drops avoid regressions") + func sameDockTrailingAndUnchanged() { + let first = DockItem(appPath: "/Applications/First.app") + let second = DockItem(appPath: "/Applications/Second.app") + let third = DockItem(appPath: "/Applications/Third.app") + let dock = DockConfig( + name: "One Dock", + items: [first, second, third] + ) + + let trailing = DockItemTransferPlanner.planTransfer( + itemID: first.id, + from: dock, + to: dock + ) + #expect(trailing.sourceItems == [second, third, first]) + #expect(trailing.outcome == .moved(itemID: first.id)) + + let selfDrop = DockItemTransferPlanner.planTransfer( + itemID: second.id, + from: dock, + to: dock, + destination: .item(second.id) + ) + #expect(selfDrop.sourceItems == dock.items) + #expect(selfDrop.targetItems == dock.items) + #expect(selfDrop.outcome == .unchanged) + + let optionReorder = DockItemTransferPlanner.planTransfer( + itemID: first.id, + from: dock, + to: dock, + operation: .copy, + destination: .item(third.id) + ) + #expect(optionReorder.sourceItems == [second, third, first]) + #expect(optionReorder.outcome == .moved(itemID: first.id)) + } + + @Test("Downloads stacks and the real Downloads folder conflict") + func downloadsIdentityConflict() { + let downloadsURL = URL(fileURLWithPath: "/tmp/FreeDock Downloads") + let stack = DockItem.downloadsStack() + let folder = DockItem.folder(at: downloadsURL) + + let stackToFolder = DockItemTransferPlanner.planTransfer( + itemID: stack.id, + from: DockConfig(name: "Source", items: [stack]), + to: DockConfig(name: "Target", items: [folder]), + downloadsURL: downloadsURL + ) + #expect( + stackToFolder.outcome + == .rejected(.duplicateItem(existingItemID: folder.id)) + ) + + let folderToStack = DockItemTransferPlanner.planTransfer( + itemID: folder.id, + from: DockConfig(name: "Source", items: [folder]), + to: DockConfig(name: "Target", items: [stack]), + downloadsURL: downloadsURL + ) + #expect( + folderToStack.outcome + == .rejected(.duplicateItem(existingItemID: stack.id)) + ) + } + + @Test("Missing targets and inconsistent same-dock snapshots are rejected") + func invalidRequestsRejected() { + let item = DockItem(appPath: "/Applications/Example.app") + let source = DockConfig(name: "Source", items: [item]) + let target = DockConfig(name: "Target") + let missingID = UUID() + + let missingTarget = DockItemTransferPlanner.planTransfer( + itemID: item.id, + from: source, + to: target, + destination: .item(missingID) + ) + #expect( + missingTarget.outcome + == .rejected(.targetItemNotFound(missingID)) + ) + + var staleSnapshot = source + staleSnapshot.items = [] + let inconsistent = DockItemTransferPlanner.planTransfer( + itemID: item.id, + from: source, + to: staleSnapshot + ) + #expect( + inconsistent.outcome + == .rejected(.inconsistentSameDockItems) + ) + } +} diff --git a/Tests/FreeDockTests/DockPanelResizeGeometryTests.swift b/Tests/FreeDockTests/DockPanelResizeGeometryTests.swift new file mode 100644 index 0000000..c146a65 --- /dev/null +++ b/Tests/FreeDockTests/DockPanelResizeGeometryTests.swift @@ -0,0 +1,161 @@ +import Foundation +import Testing +@testable import FreeDock + +@Test("Resize edge detection follows the dock orientation without auto-hide") +func resizeEdgeDetectionFollowsOrientation() { + let visibleFrame = CGRect(x: -1440, y: -120, width: 1440, height: 900) + let horizontalDock = CGRect(x: -980, y: 708, width: 520, height: 72) + let verticalDock = CGRect(x: -1440, y: 160, width: 72, height: 420) + + #expect(DockPanelResizeGeometry.dockedEdge( + of: horizontalDock, + in: visibleFrame, + orientation: .horizontal, + tolerance: 2 + ) == .top) + #expect(DockPanelResizeGeometry.dockedEdge( + of: verticalDock, + in: visibleFrame, + orientation: .vertical, + tolerance: 2 + ) == .left) +} + +@Test("Resize edge detection ignores incompatible screen edges") +func resizeEdgeDetectionIgnoresIncompatibleEdges() { + let visibleFrame = CGRect(x: 0, y: 0, width: 1440, height: 900) + let horizontalDockAtLeft = CGRect(x: 0, y: 300, width: 520, height: 72) + let verticalDockAtTop = CGRect(x: 700, y: 480, width: 72, height: 420) + + #expect(DockPanelResizeGeometry.dockedEdge( + of: horizontalDockAtLeft, + in: visibleFrame, + orientation: .horizontal, + tolerance: 2 + ) == nil) + #expect(DockPanelResizeGeometry.dockedEdge( + of: verticalDockAtTop, + in: visibleFrame, + orientation: .vertical, + tolerance: 2 + ) == nil) +} + +@Test("Resize edge detection respects its tolerance") +func resizeEdgeDetectionRespectsTolerance() { + let visibleFrame = CGRect(x: 0, y: 0, width: 1440, height: 900) + let nearlyBottomDock = CGRect(x: 460, y: 2, width: 520, height: 72) + + #expect(DockPanelResizeGeometry.dockedEdge( + of: nearlyBottomDock, + in: visibleFrame, + orientation: .horizontal, + tolerance: 2 + ) == .bottom) + #expect(DockPanelResizeGeometry.dockedEdge( + of: nearlyBottomDock, + in: visibleFrame, + orientation: .horizontal, + tolerance: 1 + ) == nil) +} + +@Test("Composed resize geometry preserves a physically snapped edge") +func composedResizeGeometryPreservesPhysicalEdge() { + let visibleFrame = CGRect(x: 0, y: 0, width: 1440, height: 900) + let reference = CGRect(x: 460, y: 828, width: 520, height: 72) + let edge = DockPanelResizeGeometry.dockedEdge( + of: reference, + in: visibleFrame, + orientation: .horizontal, + tolerance: 2 + ) + let resized = DockPanelResizeGeometry.anchoredFrame( + from: reference, + size: CGSize(width: 760, height: 108), + orientation: .horizontal, + dockedEdge: edge + ) + + #expect(edge == .top) + #expect(resized.maxY == visibleFrame.maxY) +} + +@Test("Horizontal dock resizing keeps the trailing handle's opposite corner fixed") +func horizontalResizeKeepsOppositeCornerFixed() { + let reference = CGRect(x: 120, y: 40, width: 400, height: 70) + let resized = DockPanelResizeGeometry.anchoredFrame( + from: reference, + size: CGSize(width: 560, height: 96), + orientation: .horizontal, + dockedEdge: .bottom + ) + + #expect(resized.minX == reference.minX) + #expect(resized.minY == reference.minY) +} + +@Test("Top horizontal docks grow down while preserving the screen edge") +func topHorizontalResizePreservesTopEdge() { + let reference = CGRect(x: 120, y: 790, width: 400, height: 70) + let resized = DockPanelResizeGeometry.anchoredFrame( + from: reference, + size: CGSize(width: 560, height: 96), + orientation: .horizontal, + dockedEdge: .top + ) + + #expect(resized.minX == reference.minX) + #expect(resized.maxY == reference.maxY) +} + +@Test("Right vertical docks preserve the right edge and top resize anchor") +func rightVerticalResizePreservesDockAndHandleAnchors() { + let reference = CGRect(x: 1370, y: 250, width: 70, height: 400) + let resized = DockPanelResizeGeometry.anchoredFrame( + from: reference, + size: CGSize(width: 96, height: 560), + orientation: .vertical, + dockedEdge: .right + ) + + #expect(resized.maxX == reference.maxX) + #expect(resized.maxY == reference.maxY) +} + +@Test("Left vertical docks preserve the left edge and top resize anchor") +func leftVerticalResizePreservesDockAndHandleAnchors() { + let reference = CGRect(x: 0, y: 250, width: 70, height: 400) + let resized = DockPanelResizeGeometry.anchoredFrame( + from: reference, + size: CGSize(width: 96, height: 560), + orientation: .vertical, + dockedEdge: .left + ) + + #expect(resized.minX == reference.minX) + #expect(resized.maxY == reference.maxY) +} + +@Test("Repeated live resize frames use one stable reference") +func repeatedResizeUsesStableReference() { + let reference = CGRect(x: 1370, y: 250, width: 70, height: 400) + let first = DockPanelResizeGeometry.anchoredFrame( + from: reference, + size: CGSize(width: 80, height: 440), + orientation: .vertical, + dockedEdge: .right + ) + let second = DockPanelResizeGeometry.anchoredFrame( + from: reference, + size: CGSize(width: 96, height: 560), + orientation: .vertical, + dockedEdge: .right + ) + + #expect(first.maxX == second.maxX) + #expect(first.maxY == second.maxY) + #expect(second.maxX == reference.maxX) + #expect(second.maxY == reference.maxY) +} diff --git a/Tests/FreeDockTests/DockPanelTests.swift b/Tests/FreeDockTests/DockPanelTests.swift new file mode 100644 index 0000000..1342a2b --- /dev/null +++ b/Tests/FreeDockTests/DockPanelTests.swift @@ -0,0 +1,53 @@ +import Cocoa +import SwiftUI +import Testing +@testable import FreeDock + +@MainActor +@Test("Dock panel accepts key focus only during Quick Launch") +func dockPanelScopesKeyFocusToQuickLaunch() { + _ = NSApplication.shared + let panel = DockPanel( + dockID: UUID(), + contentRect: NSRect(x: 0, y: 0, width: 240, height: 80) + ) + defer { panel.tearDown() } + + #expect(!panel.canBecomeKey) + #expect(!panel.isMovableByWindowBackground) + + panel.setQuickLaunchKeyMode(true) + #expect(panel.canBecomeKey) + + panel.setQuickLaunchKeyMode(false) + #expect(!panel.canBecomeKey) + + panel.setPositionLocked(false) + #expect(!panel.isMovableByWindowBackground) + + panel.setContentView(EmptyView()) + #expect(panel.contentView?.mouseDownCanMoveWindow == false) + #expect(panel.contentView?.subviews.first?.mouseDownCanMoveWindow == false) + #expect(panel.contentView?.subviews.first?.acceptsFirstMouse(for: nil) == true) +} + +@MainActor +@Test("Hidden dock reveal tracking remains active during Finder drags") +func hiddenDockRevealTrackingSupportsExternalDrags() throws { + _ = NSApplication.shared + let container = DockContainerView( + frame: NSRect(x: 0, y: 0, width: 240, height: 80) + ) + container.updateTrackingAreas() + + let trackingArea = try #require( + container.trackingAreas.first { + $0.owner === container + } + ) + let options = trackingArea.options + #expect(options.contains(.mouseEnteredAndExited)) + #expect(options.contains(.activeAlways)) + #expect(options.contains(.enabledDuringMouseDrag)) + #expect(options.contains(.inVisibleRect)) +} diff --git a/Tests/FreeDockTests/DockPreferencesPermissionIntegrationTests.swift b/Tests/FreeDockTests/DockPreferencesPermissionIntegrationTests.swift new file mode 100644 index 0000000..efe2f8a --- /dev/null +++ b/Tests/FreeDockTests/DockPreferencesPermissionIntegrationTests.swift @@ -0,0 +1,57 @@ +import Testing + +@testable import FreeDock + +@MainActor +@Test("Permission polling stays passive and explicit actions are routed") +func permissionPollingAndActions() { + let profile = DockProfile(name: "Default", docks: []) + var snapshot = PreferencesPermissionSnapshot( + accessibilityGranted: false, + screenRecordingGranted: true + ) + var requestedPermissions: [PreferencesPermissionKind] = [] + var openedSettings: [PreferencesPermissionKind] = [] + let store = DockPreferencesStore( + profiles: [profile], + activeProfileID: profile.id, + onChange: { _, _ in }, + onManagementAction: { _ in }, + permissionSnapshot: { snapshot }, + onPermissionAction: { + requestedPermissions.append($0) + }, + onOpenPermissionSettings: { + openedSettings.append($0) + } + ) + + #expect(store.permissionState.snapshot == .checking) + + store.refreshPermissions() + + #expect( + store.permissionState.snapshot + == PreferencesPermissionSnapshot( + accessibilityGranted: false, + screenRecordingGranted: true + ) + ) + #expect(requestedPermissions.isEmpty) + #expect(openedSettings.isEmpty) + + snapshot = PreferencesPermissionSnapshot( + accessibilityGranted: true, + screenRecordingGranted: true + ) + store.refreshPermissions() + + #expect(store.permissionState.snapshot.accessibility == .granted) + #expect(requestedPermissions.isEmpty) + + store.performPermissionAction(.accessibility) + store.openPermissionSettings(.screenRecording) + + #expect(requestedPermissions == [.accessibility]) + #expect(openedSettings == [.screenRecording]) +} diff --git a/Tests/FreeDockTests/DockResizeGestureMathTests.swift b/Tests/FreeDockTests/DockResizeGestureMathTests.swift new file mode 100644 index 0000000..86fde40 --- /dev/null +++ b/Tests/FreeDockTests/DockResizeGestureMathTests.swift @@ -0,0 +1,130 @@ +import AppKit +import Testing +@testable import FreeDock + +@Test("Horizontal resize follows screen x and ignores y") +func horizontalResizeGestureMath() { + let start = NSPoint(x: 100, y: 200) + + #expect( + DockResizeGestureMath.axisDelta( + from: start, + to: NSPoint(x: 120, y: 80), + orientation: .horizontal + ) == 20 + ) + #expect( + DockResizeGestureMath.proposedIconSize( + startingAt: 48, + from: start, + to: NSPoint(x: 120, y: 80), + orientation: .horizontal, + resizableItemCount: 4 + ) == 53 + ) +} + +@Test("Vertical resize grows downward and shrinks upward") +func verticalResizeGestureMath() { + let start = NSPoint(x: 100, y: 200) + + #expect( + DockResizeGestureMath.proposedIconSize( + startingAt: 48, + from: start, + to: NSPoint(x: 500, y: 180), + orientation: .vertical, + resizableItemCount: 4 + ) == 53 + ) + #expect( + DockResizeGestureMath.proposedIconSize( + startingAt: 48, + from: start, + to: NSPoint(x: -500, y: 220), + orientation: .vertical, + resizableItemCount: 4 + ) == 43 + ) +} + +@Test("Resize gesture clamps icon sizes at supported bounds") +func resizeGestureMathClampsBounds() { + let start = NSPoint(x: 0, y: 0) + + #expect(DockResizeGestureMath.iconSizeRange == DockConfig.iconSizeRange) + #expect( + DockResizeGestureMath.proposedIconSize( + startingAt: 48, + from: start, + to: NSPoint(x: 10_000, y: 0), + orientation: .horizontal, + resizableItemCount: 4 + ) == DockResizeGestureMath.iconSizeRange.upperBound + ) + #expect( + DockResizeGestureMath.proposedIconSize( + startingAt: 48, + from: start, + to: NSPoint(x: -10_000, y: 0), + orientation: .horizontal, + resizableItemCount: 4 + ) == DockResizeGestureMath.iconSizeRange.lowerBound + ) +} + +@Test("Resize stays smooth live and commits whole-point icon sizes") +func resizeGestureMathCommitsWholePointIconSize() { + let start = NSPoint(x: 100, y: 100) + + let liveSize = DockResizeGestureMath.proposedIconSize( + startingAt: 36.2125, + from: start, + to: NSPoint(x: 103, y: 100), + orientation: .horizontal, + resizableItemCount: 3 + ) + + #expect(liveSize == 37.2125) + #expect(DockResizeGestureMath.committedIconSize(liveSize) == 37) + #expect( + DockResizeGestureMath.proposedIconSize( + startingAt: 40, + from: start, + to: NSPoint(x: 100, y: 103), + orientation: .vertical, + resizableItemCount: 3 + ) == 39 + ) +} + +@Test("Resize sensitivity keeps the handle aligned across dock sizes") +func resizeSensitivityTracksItemCount() { + #expect(DockResizeGestureMath.sensitivity(forResizableItemCount: 0) == 1) + #expect(DockResizeGestureMath.sensitivity(forResizableItemCount: 1) == 1) + #expect(DockResizeGestureMath.sensitivity(forResizableItemCount: 4) == 0.25) + #expect(DockResizeGestureMath.sensitivity(forResizableItemCount: 10) == 0.1) + + let start = NSPoint(x: 100, y: 100) + let resized = DockResizeGestureMath.proposedIconSize( + startingAt: 48, + from: start, + to: NSPoint(x: 105, y: 100), + orientation: .horizontal, + resizableItemCount: 10 + ) + + #expect((resized - 48) * 10 == 5) +} + +@Test("Separators do not affect resize sensitivity") +func separatorsDoNotAffectResizeSensitivity() { + let items = [ + DockItem(appPath: "/Applications/One.app"), + .separator(), + DockItem(appPath: "/Applications/Two.app"), + .separator(), + ] + + #expect(DockResizeGestureMath.resizableItemCount(in: items) == 2) +} diff --git a/Tests/FreeDockTests/DockWindowServerWindowExtractorTests.swift b/Tests/FreeDockTests/DockWindowServerWindowExtractorTests.swift new file mode 100644 index 0000000..0b440e5 --- /dev/null +++ b/Tests/FreeDockTests/DockWindowServerWindowExtractorTests.swift @@ -0,0 +1,132 @@ +import CoreGraphics +import Foundation +import Testing + +@testable import FreeDock + +@Suite("Dock WindowServer window extraction") +struct DockWindowServerWindowExtractorTests { + @Test("Unshareable surfaces are rejected") + func unshareableSurfacesAreRejected() { + let windows = DockWindowServerWindowExtractor.windows( + from: [ + rawWindow( + id: 10, + sharingState: CGWindowSharingType.none + ), + rawWindow( + id: 11, + sharingState: .readOnly + ), + rawWindow( + id: 12, + sharingState: .readWrite + ), + rawWindow(id: 13, sharingState: nil), + ], + matching: [42] + ) + + #expect(windows.map(\.windowID) == [11, 12, 13]) + #expect(windows.map(\.sourceOrder) == [1, 2, 3]) + } + + @Test("Offscreen windows without sharing metadata remain discoverable") + func offscreenWindowIsRetained() throws { + let frame = CGRect( + x: -1_200, + y: 80, + width: 900, + height: 700 + ) + let window = try #require( + DockWindowServerWindowExtractor.windows( + from: [ + rawWindow( + id: 20, + title: "Other Desktop", + frame: frame, + sharingState: nil, + isOnScreen: false + ) + ], + matching: [42] + ).first + ) + + #expect(window.windowID == 20) + #expect(window.processIdentifier == 42) + #expect(window.title == "Other Desktop") + #expect(window.frame == frame) + #expect(!window.isOnScreen) + } + + @Test("Helper and malformed surfaces are rejected") + func helperSurfacesAreRejected() { + var malformed = rawWindow(id: 35) + malformed.removeValue( + forKey: kCGWindowBounds as String + ) + var malformedSharingState = rawWindow(id: 36) + malformedSharingState[ + kCGWindowSharingState as String + ] = "invalid" + + let windows = DockWindowServerWindowExtractor.windows( + from: [ + rawWindow(id: 30), + rawWindow(id: 31, processIdentifier: 99), + rawWindow(id: 32, layer: 1), + rawWindow( + id: 33, + frame: CGRect( + x: 0, + y: 0, + width: 79, + height: 49 + ) + ), + rawWindow(id: 34, alpha: 0.01), + malformed, + malformedSharingState, + ], + matching: [42] + ) + + #expect(windows.map(\.windowID) == [30]) + } + + private func rawWindow( + id: CGWindowID, + processIdentifier: pid_t = 42, + title: String = "Window", + frame: CGRect = CGRect( + x: 10, + y: 20, + width: 800, + height: 600 + ), + layer: Int = 0, + sharingState: CGWindowSharingType? = .readOnly, + alpha: Double = 1, + isOnScreen: Bool = true + ) -> [String: Any] { + var values: [String: Any] = [ + kCGWindowOwnerPID as String: + NSNumber(value: processIdentifier), + kCGWindowNumber as String: NSNumber(value: id), + kCGWindowLayer as String: NSNumber(value: layer), + kCGWindowBounds as String: + CGRectCreateDictionaryRepresentation(frame), + kCGWindowName as String: title, + kCGWindowAlpha as String: NSNumber(value: alpha), + kCGWindowIsOnscreen as String: + NSNumber(value: isOnScreen), + ] + if let sharingState { + values[kCGWindowSharingState as String] = + NSNumber(value: sharingState.rawValue) + } + return values + } +} diff --git a/Tests/FreeDockTests/DockWindowThumbnailGeometryTests.swift b/Tests/FreeDockTests/DockWindowThumbnailGeometryTests.swift new file mode 100644 index 0000000..773804c --- /dev/null +++ b/Tests/FreeDockTests/DockWindowThumbnailGeometryTests.swift @@ -0,0 +1,42 @@ +import Foundation +import Testing +@testable import FreeDock + +@Suite("Dock window thumbnail geometry") +struct DockWindowThumbnailGeometryTests { + @Test("Wide windows preserve aspect ratio") + func wideWindow() { + let size = DockWindowThumbnailGeometry.pixelSize( + for: CGSize(width: 1600, height: 900), + fitting: CGSize(width: 332, height: 192) + ) + + #expect(size == CGSize(width: 332, height: 186)) + } + + @Test("Tall windows preserve aspect ratio") + func tallWindow() { + let size = DockWindowThumbnailGeometry.pixelSize( + for: CGSize(width: 600, height: 1200), + fitting: CGSize(width: 332, height: 192) + ) + + #expect(size == CGSize(width: 96, height: 192)) + } + + @Test("Invalid sizes do not request a capture") + func invalidSize() { + #expect( + DockWindowThumbnailGeometry.pixelSize( + for: .zero, + fitting: CGSize(width: 332, height: 192) + ) == .zero + ) + #expect( + DockWindowThumbnailGeometry.pixelSize( + for: CGSize(width: 800, height: 600), + fitting: .zero + ) == .zero + ) + } +} diff --git a/Tests/FreeDockTests/FolderStackLoaderTests.swift b/Tests/FreeDockTests/FolderStackLoaderTests.swift new file mode 100644 index 0000000..8e1fa34 --- /dev/null +++ b/Tests/FreeDockTests/FolderStackLoaderTests.swift @@ -0,0 +1,226 @@ +import Foundation +import Testing +@testable import FreeDock + +@Test("Folder stacks filter hidden files and treat packages as leaf entries") +func folderStackFilteringAndPackages() throws { + let temporaryDirectory = try makeFolderStackTemporaryDirectory() + defer { try? FileManager.default.removeItem(at: temporaryDirectory) } + + let applicationURL = temporaryDirectory.appendingPathComponent("Example.app") + let folderURL = temporaryDirectory.appendingPathComponent("Projects") + let documentURL = temporaryDirectory.appendingPathComponent("Notes.txt") + let hiddenURL = temporaryDirectory.appendingPathComponent(".secret.txt") + try FileManager.default.createDirectory( + at: applicationURL, + withIntermediateDirectories: true + ) + try FileManager.default.createDirectory( + at: folderURL, + withIntermediateDirectories: true + ) + try Data("notes".utf8).write(to: documentURL) + try Data("secret".utf8).write(to: hiddenURL) + + let defaultSnapshot = try FolderStackLoader.load( + folderURL: temporaryDirectory, + options: FolderStackOptions() + ) + #expect(defaultSnapshot.totalCount == 3) + #expect(!defaultSnapshot.isTruncated) + #expect(!defaultSnapshot.entries.map(\.name).contains(".secret.txt")) + #expect( + defaultSnapshot.entries.first( + where: { $0.url.lastPathComponent == "Projects" } + )?.isFolder == true + ) + #expect( + defaultSnapshot.entries.first( + where: { $0.url.lastPathComponent == "Example.app" } + )?.isFolder == false + ) + #expect( + defaultSnapshot.entries.first( + where: { $0.url.lastPathComponent == "Example.app" } + )?.kindDescription == "Application" + ) + + let hiddenSnapshot = try FolderStackLoader.load( + folderURL: temporaryDirectory, + options: FolderStackOptions(showHiddenFiles: true) + ) + #expect(hiddenSnapshot.totalCount == 4) + #expect(hiddenSnapshot.entries.map(\.name).contains(".secret.txt")) +} + +@Test("Folder stacks apply deterministic name and modification-date sorting") +func folderStackSorting() throws { + let temporaryDirectory = try makeFolderStackTemporaryDirectory() + defer { try? FileManager.default.removeItem(at: temporaryDirectory) } + + let alphaURL = temporaryDirectory.appendingPathComponent("Alpha.txt") + let betaURL = temporaryDirectory.appendingPathComponent("beta.txt") + let newestURL = temporaryDirectory.appendingPathComponent("Newest.txt") + try Data("a".utf8).write(to: alphaURL) + try Data("b".utf8).write(to: betaURL) + try Data("new".utf8).write(to: newestURL) + + let oldDate = Date(timeIntervalSince1970: 1_000) + let middleDate = Date(timeIntervalSince1970: 2_000) + let newDate = Date(timeIntervalSince1970: 3_000) + try FileManager.default.setAttributes( + [.modificationDate: oldDate], + ofItemAtPath: alphaURL.path + ) + try FileManager.default.setAttributes( + [.modificationDate: middleDate], + ofItemAtPath: betaURL.path + ) + try FileManager.default.setAttributes( + [.modificationDate: newDate], + ofItemAtPath: newestURL.path + ) + + let nameSnapshot = try FolderStackLoader.load( + folderURL: temporaryDirectory, + options: FolderStackOptions(sortOrder: .name) + ) + #expect(nameSnapshot.entries.map(\.name) == [ + "Alpha.txt", + "beta.txt", + "Newest.txt", + ]) + + let dateSnapshot = try FolderStackLoader.load( + folderURL: temporaryDirectory, + options: FolderStackOptions(sortOrder: .dateModified) + ) + #expect(dateSnapshot.entries.map(\.name) == [ + "Newest.txt", + "beta.txt", + "Alpha.txt", + ]) +} + +@Test("Folder stack kind sorting uses kind then name") +func folderStackKindSorting() throws { + let temporaryDirectory = try makeFolderStackTemporaryDirectory() + defer { try? FileManager.default.removeItem(at: temporaryDirectory) } + + let applicationURL = temporaryDirectory.appendingPathComponent("Zulu.app") + let folderURL = temporaryDirectory.appendingPathComponent("Alpha Folder") + let documentURL = temporaryDirectory.appendingPathComponent("Middle.txt") + try FileManager.default.createDirectory( + at: applicationURL, + withIntermediateDirectories: true + ) + try FileManager.default.createDirectory( + at: folderURL, + withIntermediateDirectories: true + ) + try Data("middle".utf8).write(to: documentURL) + + let snapshot = try FolderStackLoader.load( + folderURL: temporaryDirectory, + options: FolderStackOptions(sortOrder: .kind) + ) + let descriptions = snapshot.entries.map(\.kindDescription) + + #expect(descriptions.first == "Application") + #expect(descriptions.dropFirst().first == "Folder") + #expect( + zip(descriptions, descriptions.dropFirst()).allSatisfy { + $0.0.localizedStandardCompare($0.1) != .orderedDescending + } + ) +} + +@Test("Folder stacks honor requested limits and the global 200-item cap") +func folderStackLimitAndCap() throws { + let temporaryDirectory = try makeFolderStackTemporaryDirectory() + defer { try? FileManager.default.removeItem(at: temporaryDirectory) } + + for index in 0 ..< 205 { + let url = temporaryDirectory.appendingPathComponent( + String(format: "Item-%03d.txt", index) + ) + try Data().write(to: url) + } + + let limited = try FolderStackLoader.load( + folderURL: temporaryDirectory, + options: FolderStackOptions(), + limit: 3 + ) + #expect(limited.entries.count == 3) + #expect(limited.totalCount == 205) + #expect(limited.isTruncated) + + let capped = try FolderStackLoader.load( + folderURL: temporaryDirectory, + options: FolderStackOptions(), + limit: 1_000 + ) + #expect(capped.entries.count == FolderStackLoader.maximumItemCount) + #expect(capped.totalCount == 205) + #expect(capped.isTruncated) +} + +@Test("Folder stack snapshots are live") +func folderStackSnapshotsAreLive() throws { + let temporaryDirectory = try makeFolderStackTemporaryDirectory() + defer { try? FileManager.default.removeItem(at: temporaryDirectory) } + + let first = try FolderStackLoader.load( + folderURL: temporaryDirectory, + options: FolderStackOptions() + ) + #expect(first.totalCount == 0) + + try Data("new".utf8).write( + to: temporaryDirectory.appendingPathComponent("New.txt") + ) + let second = try FolderStackLoader.load( + folderURL: temporaryDirectory, + options: FolderStackOptions() + ) + #expect(second.totalCount == 1) + #expect(second.entries.first?.name == "New.txt") +} + +@Test("Folder stack loader rejects documents and application packages") +func folderStackRejectsNonFolders() throws { + let temporaryDirectory = try makeFolderStackTemporaryDirectory() + defer { try? FileManager.default.removeItem(at: temporaryDirectory) } + + let documentURL = temporaryDirectory.appendingPathComponent("Notes.txt") + let applicationURL = temporaryDirectory.appendingPathComponent("Example.app") + try Data("notes".utf8).write(to: documentURL) + try FileManager.default.createDirectory( + at: applicationURL, + withIntermediateDirectories: true + ) + + #expect(throws: FolderStackLoaderError.self) { + try FolderStackLoader.load( + folderURL: documentURL, + options: FolderStackOptions() + ) + } + #expect(throws: FolderStackLoaderError.self) { + try FolderStackLoader.load( + folderURL: applicationURL, + options: FolderStackOptions() + ) + } +} + +private func makeFolderStackTemporaryDirectory() throws -> URL { + let url = FileManager.default.temporaryDirectory + .appendingPathComponent("freedock-stack-\(UUID().uuidString)") + try FileManager.default.createDirectory( + at: url, + withIntermediateDirectories: true + ) + return url +} diff --git a/Tests/FreeDockTests/FolderStackPanelGeometryTests.swift b/Tests/FreeDockTests/FolderStackPanelGeometryTests.swift new file mode 100644 index 0000000..c9052e5 --- /dev/null +++ b/Tests/FreeDockTests/FolderStackPanelGeometryTests.swift @@ -0,0 +1,97 @@ +import Foundation +import Testing +@testable import FreeDock + +@Test("Bottom horizontal docks place folder stacks above the source") +func folderStackPanelAppearsAboveBottomDock() { + let visibleFrame = CGRect(x: 0, y: 0, width: 1440, height: 875) + let dockFrame = CGRect(x: 500, y: 0, width: 440, height: 80) + let sourceRect = CGRect(x: 620, y: 18, width: 56, height: 56) + + let placement = FolderStackPanelGeometry.placement( + size: CGSize(width: 492, height: 424), + sourceRect: sourceRect, + dockFrame: dockFrame, + visibleFrame: visibleFrame, + orientation: .horizontal + ) + + #expect(placement.side == .above) + #expect(placement.frame.minY > sourceRect.maxY) + #expect(visibleFrame.insetBy(dx: 12, dy: 12).contains(placement.frame)) +} + +@Test("Folder stack panels flip when the preferred side lacks room") +func folderStackPanelFlipsToAvailableSide() { + let visibleFrame = CGRect(x: 0, y: 0, width: 1000, height: 700) + let dockFrame = CGRect(x: 300, y: 310, width: 400, height: 80) + let sourceRect = CGRect(x: 470, y: 330, width: 60, height: 60) + + let placement = FolderStackPanelGeometry.placement( + size: CGSize(width: 460, height: 340), + sourceRect: sourceRect, + dockFrame: dockFrame, + visibleFrame: visibleFrame, + orientation: .horizontal + ) + + #expect(placement.side == .below) + #expect(placement.frame.maxY < sourceRect.minY) +} + +@Test("Vertical dock stacks open inward from the screen edge") +func folderStackPanelOpensInwardFromVerticalDock() { + let visibleFrame = CGRect(x: -1200, y: 0, width: 1200, height: 900) + let dockFrame = CGRect(x: -1200, y: 220, width: 78, height: 420) + let sourceRect = CGRect(x: -1188, y: 360, width: 56, height: 56) + + let placement = FolderStackPanelGeometry.placement( + size: CGSize(width: 492, height: 424), + sourceRect: sourceRect, + dockFrame: dockFrame, + visibleFrame: visibleFrame, + orientation: .vertical + ) + + #expect(placement.side == .right) + #expect(placement.frame.minX > sourceRect.maxX) +} + +@Test("Folder stack panels stay inside small negative-origin displays") +func folderStackPanelClampsToSmallDisplay() { + let visibleFrame = CGRect(x: -900, y: -500, width: 700, height: 500) + let dockFrame = CGRect(x: -500, y: -80, width: 280, height: 70) + let sourceRect = CGRect(x: -270, y: -65, width: 48, height: 48) + + let placement = FolderStackPanelGeometry.placement( + size: CGSize(width: 900, height: 700), + sourceRect: sourceRect, + dockFrame: dockFrame, + visibleFrame: visibleFrame, + orientation: .horizontal + ) + let available = visibleFrame.insetBy(dx: 12, dy: 12) + + #expect(placement.frame.width == available.width) + #expect(available.contains(placement.frame)) + #expect(!placement.frame.intersects(sourceRect)) +} + +@Test("Folder stack panels keep a usable size when neither side has room") +func folderStackPanelKeepsUsableFallbackSize() { + let visibleFrame = CGRect(x: 0, y: 0, width: 600, height: 400) + let dockFrame = CGRect(x: 200, y: 180, width: 200, height: 80) + let sourceRect = CGRect(x: 270, y: 190, width: 60, height: 60) + + let placement = FolderStackPanelGeometry.placement( + size: CGSize(width: 460, height: 300), + sourceRect: sourceRect, + dockFrame: dockFrame, + visibleFrame: visibleFrame, + orientation: .horizontal + ) + let available = visibleFrame.insetBy(dx: 12, dy: 12) + + #expect(available.contains(placement.frame)) + #expect(placement.frame.height == 300) +} diff --git a/Tests/FreeDockTests/ModelTests.swift b/Tests/FreeDockTests/ModelTests.swift index 1bbabc2..ff5461a 100644 --- a/Tests/FreeDockTests/ModelTests.swift +++ b/Tests/FreeDockTests/ModelTests.swift @@ -14,8 +14,20 @@ func dockItemRoundTrip() throws { @Test("DockConfig round-trips all fields") func dockConfigRoundTrip() throws { let item = DockItem(appPath: "/App.app") + let displayID = UUID() + let displayPlacement = DockDisplayPlacement( + displayID: displayID, + displayName: "Studio Display", + normalizedCenter: CGPoint(x: 0.25, y: 0.75) + ) let config = DockConfig(id: UUID(), name: "Test", position: CGPoint(x: 100, y: 200), - orientation: .vertical, iconSize: 64, items: [item]) + displayPlacement: displayPlacement, + orientation: .vertical, iconSize: 64, items: [item], + autoHideWhenDocked: false, magnificationEnabled: false, + magnification: 1.55, itemSpacing: 9, appearance: .dark, + surfaceOpacity: 0.65, blurStyle: .strong, + cornerRadius: 24, shadowStrength: 1.6, + showRunningIndicators: false, autoHideDelay: 2.4) let data = try JSONEncoder().encode(config) let decoded = try JSONDecoder().decode(DockConfig.self, from: data) #expect(decoded.name == "Test") @@ -23,6 +35,188 @@ func dockConfigRoundTrip() throws { #expect(decoded.iconSize == 64) #expect(decoded.items.count == 1) #expect(decoded.position == CGPoint(x: 100, y: 200)) + #expect(decoded.displayPlacement == displayPlacement) + #expect(!decoded.autoHideWhenDocked) + #expect(!decoded.magnificationEnabled) + #expect(decoded.magnification == 1.55) + #expect(decoded.itemSpacing == 9) + #expect(decoded.appearance == .dark) + #expect(decoded.surfaceOpacity == 0.65) + #expect(decoded.blurStyle == .strong) + #expect(decoded.cornerRadius == 24) + #expect(decoded.shadowStrength == 1.6) + #expect(!decoded.showRunningIndicators) + #expect(decoded.autoHideDelay == 2.4) +} + +@Test("DockConfig defaults edge auto-hide when loading an older config") +func dockConfigMigratesAutoHidePreference() throws { + let data = """ + { + "id": "F8CCF00C-3001-4D86-B572-1B5E4B5DBFEA", + "name": "Legacy Dock", + "position": [40, 80], + "orientation": "horizontal", + "iconSize": 48, + "items": [] + } + """.data(using: .utf8)! + + let decoded = try JSONDecoder().decode(DockConfig.self, from: data) + #expect(decoded.displayPlacement == nil) + #expect(decoded.autoHideWhenDocked) + #expect(decoded.magnificationEnabled) + #expect(decoded.magnification == 1.30) + #expect(decoded.itemSpacing == 3) + #expect(decoded.appearance == .glass) + #expect(decoded.surfaceOpacity == 1) + #expect(decoded.blurStyle == .regular) + #expect(decoded.cornerRadius == 18) + #expect(decoded.shadowStrength == 1) + #expect(decoded.showRunningIndicators) + #expect(decoded.autoHideDelay == 1) +} + +@Test("DockConfig normalizes out-of-range preferences") +func dockConfigNormalizesPreferences() { + let config = DockConfig( + name: "Extreme", + iconSize: 500, + magnification: 0.2, + itemSpacing: -4, + surfaceOpacity: 0, + cornerRadius: 100, + shadowStrength: 9, + autoHideDelay: 0 + ) + + #expect(config.iconSize == DockConfig.iconSizeRange.upperBound) + #expect(config.magnification == DockConfig.magnificationRange.lowerBound) + #expect(config.itemSpacing == DockConfig.itemSpacingRange.lowerBound) + #expect(config.surfaceOpacity == DockConfig.surfaceOpacityRange.lowerBound) + #expect(config.cornerRadius == DockConfig.cornerRadiusRange.upperBound) + #expect(config.shadowStrength == DockConfig.shadowStrengthRange.upperBound) + #expect(config.autoHideDelay == DockConfig.autoHideDelayRange.lowerBound) +} + +@Test("Duplicating a dock preserves settings with fresh identities") +func dockConfigDuplicate() { + let displayPlacement = DockDisplayPlacement( + displayID: UUID(), + displayName: "External Display", + normalizedCenter: CGPoint(x: 0.7, y: 0.3) + ) + let original = DockConfig( + name: "Original", + position: CGPoint(x: 10, y: 20), + displayPlacement: displayPlacement, + orientation: .vertical, + iconSize: 72, + items: [ + DockItem(appPath: "/Applications/Test.app", label: "Test"), + .separator(), + ], + autoHideWhenDocked: false, + magnificationEnabled: false, + magnification: 1.5, + itemSpacing: 8, + appearance: .dark, + surfaceOpacity: 0.55, + blurStyle: .light, + cornerRadius: 22, + shadowStrength: 1.8, + showRunningIndicators: false, + autoHideDelay: 2 + ) + + let duplicate = original.duplicated( + name: "Original Copy", + position: CGPoint(x: 42, y: 20) + ) + + #expect(duplicate.id != original.id) + #expect(duplicate.name == "Original Copy") + #expect(duplicate.position == CGPoint(x: 42, y: 20)) + #expect(duplicate.displayPlacement == displayPlacement) + #expect(duplicate.orientation == original.orientation) + #expect(duplicate.iconSize == original.iconSize) + #expect(duplicate.autoHideWhenDocked == original.autoHideWhenDocked) + #expect(duplicate.magnificationEnabled == original.magnificationEnabled) + #expect(duplicate.magnification == original.magnification) + #expect(duplicate.itemSpacing == original.itemSpacing) + #expect(duplicate.appearance == original.appearance) + #expect(duplicate.surfaceOpacity == original.surfaceOpacity) + #expect(duplicate.blurStyle == original.blurStyle) + #expect(duplicate.cornerRadius == original.cornerRadius) + #expect(duplicate.shadowStrength == original.shadowStrength) + #expect(duplicate.showRunningIndicators == original.showRunningIndicators) + #expect(duplicate.autoHideDelay == original.autoHideDelay) + #expect(duplicate.items.map(\.id) != original.items.map(\.id)) + #expect(duplicate.items.map(\.appPath) == original.items.map(\.appPath)) + #expect(duplicate.items.map(\.isSeparator) == original.items.map(\.isSeparator)) +} + +@Test("Applying dock settings preserves dock content and placement") +func dockConfigApplySettings() { + let source = DockConfig( + name: "Source", + orientation: .vertical, + iconSize: 88, + autoHideWhenDocked: false, + magnificationEnabled: false, + magnification: 1.6, + itemSpacing: 12, + appearance: .light, + surfaceOpacity: 0.7, + blurStyle: .strong, + cornerRadius: 26, + shadowStrength: 1.4, + showRunningIndicators: false, + autoHideDelay: 3 + ) + var target = DockConfig( + name: "Target", + position: CGPoint(x: 80, y: 120), + displayPlacement: DockDisplayPlacement( + displayID: UUID(), + displayName: "Target Display", + normalizedCenter: CGPoint(x: 0.2, y: 0.8) + ), + items: [DockItem(appPath: "/Applications/Target.app")] + ) + let originalID = target.id + let originalItems = target.items + let originalPosition = target.position + let originalDisplayPlacement = target.displayPlacement + + target.apply(settings: source.settings) + + #expect(target.id == originalID) + #expect(target.name == "Target") + #expect(target.items == originalItems) + #expect(target.position == originalPosition) + #expect(target.displayPlacement == originalDisplayPlacement) + #expect(target.settings == source.settings) +} + +@Test("Unknown dock appearance falls back to glass") +func dockConfigUnknownAppearanceFallback() throws { + let data = """ + { + "id": "F8CCF00C-3001-4D86-B572-1B5E4B5DBFEA", + "name": "Future Dock", + "position": [40, 80], + "orientation": "horizontal", + "iconSize": 48, + "items": [], + "appearance": "future-material", + "blurStyle": "future-blur" + } + """.data(using: .utf8)! + + let decoded = try JSONDecoder().decode(DockConfig.self, from: data) + #expect(decoded.appearance == .glass) + #expect(decoded.blurStyle == .regular) } @Test("AppConfig holds multiple docks") @@ -36,6 +230,116 @@ func appConfigMultiple() throws { #expect(decoded.docks.count == 2) } +@Test("Legacy top-level docks migrate into a default profile") +func appConfigMigratesLegacyDocks() throws { + let dockID = UUID() + let legacy = """ + { + "docks": [ + { + "id": "\(dockID.uuidString)", + "name": "Legacy", + "position": [12, 34], + "orientation": "vertical", + "iconSize": 64, + "items": [], + "autoHideWhenDocked": false + } + ] + } + """.data(using: .utf8)! + + let decoded = try JSONDecoder().decode(AppConfig.self, from: legacy) + #expect(decoded.profiles.count == 1) + #expect(decoded.activeProfileName == "Default") + #expect(decoded.docks.count == 1) + #expect(decoded.docks[0].id == dockID) + #expect(decoded.docks[0].name == "Legacy") +} + +@Test("Profiles round-trip with the active profile preserved") +func appConfigProfilesRoundTrip() throws { + let work = DockProfile(name: "Work", docks: [DockConfig(name: "Work Dock")]) + let personal = DockProfile(name: "Personal", docks: [DockConfig(name: "Personal Dock")]) + let config = AppConfig(profiles: [work, personal], activeProfileID: personal.id) + + let data = try JSONEncoder().encode(config) + let decoded = try JSONDecoder().decode(AppConfig.self, from: data) + + #expect(decoded.profiles == [work, personal]) + #expect(decoded.activeProfileID == personal.id) + #expect(decoded.docks.first?.name == "Personal Dock") +} + +@Test("Invalid active profile falls back to the first profile") +func appConfigInvalidActiveProfileFallback() throws { + let first = DockProfile(name: "First") + let second = DockProfile(name: "Second") + let config = AppConfig(profiles: [first, second], activeProfileID: first.id) + let encoded = try JSONEncoder().encode(config) + var object = try #require(JSONSerialization.jsonObject(with: encoded) as? [String: Any]) + object["activeProfileID"] = UUID().uuidString + let data = try JSONSerialization.data(withJSONObject: object) + + let decoded = try JSONDecoder().decode(AppConfig.self, from: data) + #expect(decoded.activeProfileID == first.id) +} + +@Test("Empty profile collections normalize to a default profile") +func appConfigNormalizesEmptyProfiles() throws { + let data = """ + { + "formatVersion": 2, + "profiles": [], + "activeProfileID": "\(UUID().uuidString)" + } + """.data(using: .utf8)! + + let decoded = try JSONDecoder().decode(AppConfig.self, from: data) + #expect(decoded.profiles.count == 1) + #expect(decoded.activeProfileName == "Default") + #expect(decoded.docks.isEmpty) +} + +@Test("Profile data takes precedence over the compatibility dock mirror") +func appConfigProfilesWinOverLegacyMirror() throws { + let profileDock = DockConfig(name: "Profile Dock") + let profile = DockProfile(name: "Current", docks: [profileDock]) + let config = AppConfig(profiles: [profile], activeProfileID: profile.id) + let encoded = try JSONEncoder().encode(config) + var object = try #require(JSONSerialization.jsonObject(with: encoded) as? [String: Any]) + let mirrorData = try JSONEncoder().encode([DockConfig(name: "Stale Mirror")]) + object["docks"] = try JSONSerialization.jsonObject(with: mirrorData) + let data = try JSONSerialization.data(withJSONObject: object) + + let decoded = try JSONDecoder().decode(AppConfig.self, from: data) + #expect(decoded.docks.first?.name == "Profile Dock") +} + +@Test("Mutating docks only changes the active profile") +func appConfigActiveDockWriteback() { + let first = DockProfile(name: "First", docks: [DockConfig(name: "A")]) + let second = DockProfile(name: "Second", docks: [DockConfig(name: "B")]) + var config = AppConfig(profiles: [first, second], activeProfileID: second.id) + + config.docks[0].name = "Updated" + + #expect(config.profiles[0].docks[0].name == "A") + #expect(config.profiles[1].docks[0].name == "Updated") +} + +@Test("Encoded profiles include an active-dock compatibility mirror") +func appConfigEncodesCompatibilityMirror() throws { + let profile = DockProfile(name: "Work", docks: [DockConfig(name: "Visible")]) + let config = AppConfig(profiles: [profile], activeProfileID: profile.id) + let data = try JSONEncoder().encode(config) + let object = try #require(JSONSerialization.jsonObject(with: data) as? [String: Any]) + let mirroredDocks = try #require(object["docks"] as? [[String: Any]]) + + #expect(object["formatVersion"] as? Int == AppConfig.currentFormatVersion) + #expect(mirroredDocks.first?["name"] as? String == "Visible") +} + @Test("Empty config is valid") func emptyConfig() throws { let config = AppConfig(docks: []) @@ -49,3 +353,181 @@ func orientationRawValues() { #expect(Orientation.horizontal.rawValue == "horizontal") #expect(Orientation.vertical.rawValue == "vertical") } + +@Test("Global shortcuts provide unique keys for nine profiles") +func globalShortcutProfileKeys() { + #expect(GlobalShortcutManager.profileKeyCodes.count == 9) + #expect(Set(GlobalShortcutManager.profileKeyCodes).count == 9) + #expect(GlobalShortcutManager.standardModifiers != 0) + #expect(GlobalShortcutManager.quickLaunchModifiers != 0) + #expect( + GlobalShortcutManager.quickLaunchModifiers + != GlobalShortcutManager.standardModifiers + ) +} + +@MainActor +@Test("Dock state applies advanced appearance changes") +func dockStateAppliesAdvancedAppearance() { + let state = DockState(config: DockConfig(name: "Initial")) + let updated = DockConfig( + name: "Updated", + magnificationEnabled: false, + magnification: 1.65, + appearance: .dark, + surfaceOpacity: 0.55, + blurStyle: .strong, + cornerRadius: 28, + shadowStrength: 1.75 + ) + + state.apply(updated) + + #expect(state.name == "Updated") + #expect(!state.magnificationEnabled) + #expect(state.magnification == 1.65) + #expect(state.appearance == .dark) + #expect(state.surfaceOpacity == 0.55) + #expect(state.blurStyle == .strong) + #expect(state.cornerRadius == 28) + #expect(state.shadowStrength == 1.75) +} + +@MainActor +@Test("Dock state keeps Quick Launch transient and focusable") +func dockStateQuickLaunchSession() { + let state = DockState(config: DockConfig(name: "Searchable")) + let initialGeneration = state.quickLaunchFocusGeneration + + state.presentQuickLaunch() + + #expect(state.isQuickLaunchPresented) + #expect(state.quickLaunchFocusGeneration == initialGeneration + 1) + + state.apply(DockConfig(name: "Renamed")) + + #expect(state.name == "Renamed") + #expect(state.isQuickLaunchPresented) + + state.dismissQuickLaunch() + + #expect(!state.isQuickLaunchPresented) +} + +@MainActor +@Test("Preferences keep selection by dock ID and clamp live changes") +func preferencesStoreSelectionAndUpdates() { + let first = DockConfig(name: "First") + let second = DockConfig(name: "Second") + var received: (UUID, DockPreferenceChange)? + var managementAction: DockManagementAction? + let profileID = UUID() + let displayID = UUID() + let displays = [ + DockDisplayDescriptor( + id: displayID, + name: "Studio Display", + frame: CGRect(x: 0, y: 0, width: 1920, height: 1080), + visibleFrame: CGRect(x: 0, y: 0, width: 1920, height: 1055), + isPrimary: true + ), + ] + let workProfile = DockProfile(id: profileID, name: "Work", docks: [first, second]) + let store = DockPreferencesStore( + profiles: [workProfile], + activeProfileID: profileID, + displays: displays + ) { dockID, change in + received = (dockID, change) + } onManagementAction: { action in + managementAction = action + } + #expect(store.activeProfileName == "Work") + #expect(!store.canDeleteActiveProfile) + #expect(store.canCopySelectedDockSettings) + #expect(store.displays == displays) + + store.selectedDockID = second.id + let renamedSecond = DockConfig( + id: second.id, + name: "Renamed", + displayPlacement: DockDisplayPlacement( + displayID: displayID, + displayName: "Studio Display" + ) + ) + let refreshedProfiles = [ + DockProfile( + id: profileID, + name: "Work", + docks: [ + DockConfig(id: first.id, name: "First"), + renamedSecond, + ] + ), + ] + store.reload( + profiles: refreshedProfiles, + activeProfileID: profileID + ) + #expect(store.selectedDockID == second.id) + #expect(store.displayLabel(for: renamedSecond) == "Studio Display (Main)") + + store.reload( + profiles: refreshedProfiles, + activeProfileID: profileID, + displays: [] + ) + #expect(store.selectedDockID == second.id) + #expect(store.displayLabel(for: renamedSecond) == "Studio Display — Not Connected") + + store.reload( + profiles: refreshedProfiles, + activeProfileID: profileID, + displays: displays + ) + #expect(store.selectedDockID == second.id) + #expect(store.displayLabel(for: renamedSecond) == "Studio Display (Main)") + + store.updateSelected(.magnification(9)) + #expect(store.selectedDock?.magnification == DockConfig.magnificationRange.upperBound) + #expect(received?.0 == second.id) + #expect(received?.1 == .magnification(9)) + + store.updateSelected(.magnificationEnabled(false)) + store.updateSelected(.surfaceOpacity(0)) + store.updateSelected(.blurStyle(.strong)) + store.updateSelected(.shadowStrength(9)) + #expect(store.selectedDock?.magnificationEnabled == false) + #expect(store.selectedDock?.surfaceOpacity == DockConfig.surfaceOpacityRange.lowerBound) + #expect(store.selectedDock?.blurStyle == .strong) + #expect(store.selectedDock?.shadowStrength == DockConfig.shadowStrengthRange.upperBound) + #expect(received?.1 == .shadowStrength(9)) + + store.perform(.addDockItems(second.id)) + #expect(managementAction == .addDockItems(second.id)) + store.perform(.addSmartStack(second.id, .recentFiles)) + #expect(managementAction == .addSmartStack(second.id, .recentFiles)) + store.perform(.addSmartStack(second.id, .downloads)) + #expect(managementAction == .addSmartStack(second.id, .downloads)) + store.perform(.clearRecentFiles) + #expect(managementAction == .clearRecentFiles) + store.perform(.importSystemDockApps(second.id)) + #expect(managementAction == .importSystemDockApps(second.id)) + store.perform(.setDockDisplay(second.id, displayID)) + #expect(managementAction == .setDockDisplay(second.id, displayID)) + + let replacement = DockConfig(name: "Personal Dock") + let personalProfile = DockProfile(name: "Personal", docks: [replacement]) + store.reload( + profiles: [workProfile, personalProfile], + activeProfileID: personalProfile.id + ) + #expect(store.selectedDockID == replacement.id) + #expect(store.activeProfileName == "Personal") + #expect(store.canDeleteActiveProfile) + #expect(!store.canCopySelectedDockSettings) + + store.perform(.copyDockSettingsToAll(replacement.id)) + #expect(managementAction == .copyDockSettingsToAll(replacement.id)) +} diff --git a/Tests/FreeDockTests/PreferencesPermissionStateTests.swift b/Tests/FreeDockTests/PreferencesPermissionStateTests.swift new file mode 100644 index 0000000..1b3d951 --- /dev/null +++ b/Tests/FreeDockTests/PreferencesPermissionStateTests.swift @@ -0,0 +1,166 @@ +import Testing + +@testable import FreeDock + +@Suite("Preferences permission state") +struct PreferencesPermissionStateTests { + @Test("Initial rows are checking and ordered consistently") + func initialCheckingRows() { + let state = PreferencesPermissionState() + + #expect( + state.presentations.map(\.permission) + == [.accessibility, .screenRecording] + ) + #expect( + state.presentations.allSatisfy { + $0.status == .checking + && $0.statusLabel == "Checking…" + && $0.actionTitle == "Checking…" + && !$0.isActionEnabled + } + ) + } + + @Test("Not-granted rows explain required and optional access") + func notGrantedPresentation() { + let state = PreferencesPermissionState( + snapshot: PreferencesPermissionSnapshot( + accessibilityGranted: false, + screenRecordingGranted: false + ) + ) + let accessibility = state.presentation( + for: .accessibility + ) + let screenRecording = state.presentation( + for: .screenRecording + ) + + #expect(accessibility.title == "Accessibility") + #expect(accessibility.statusLabel == "Not Granted") + #expect( + accessibility.actionTitle + == "Enable Accessibility…" + ) + #expect(accessibility.detail.contains("Required")) + #expect(accessibility.isActionEnabled) + + #expect(screenRecording.title == "Screen Recording") + #expect(screenRecording.statusLabel == "Not Granted") + #expect( + screenRecording.actionTitle + == "Enable Screen Recording…" + ) + #expect(screenRecording.detail.contains("Optional")) + #expect( + screenRecording.detail.contains( + "window switching still works" + ) + ) + #expect(screenRecording.isActionEnabled) + } + + @Test("Granted rows expose management actions") + func grantedPresentation() { + let state = PreferencesPermissionState( + snapshot: PreferencesPermissionSnapshot( + accessibilityGranted: true, + screenRecordingGranted: true + ) + ) + + #expect( + state.presentation(for: .accessibility).statusLabel + == "Granted" + ) + #expect( + state.presentation(for: .accessibility).actionTitle + == "Manage Accessibility…" + ) + #expect( + state.presentation(for: .screenRecording).statusLabel + == "Granted" + ) + #expect( + state.presentation(for: .screenRecording).actionTitle + == "Manage Screen Recording…" + ) + } + + @Test("Periodic refresh reports only actual status changes") + func periodicRefreshChanges() { + var state = PreferencesPermissionState() + let denied = PreferencesPermissionSnapshot( + accessibilityGranted: false, + screenRecordingGranted: false + ) + + #expect( + state.refresh(with: denied) + == .changed([.accessibility, .screenRecording]) + ) + let stableState = state + #expect(state.refresh(with: denied) == .unchanged) + #expect(state == stableState) + + #expect( + state.refresh( + with: PreferencesPermissionSnapshot( + accessibilityGranted: true, + screenRecordingGranted: false + ) + ) == .changed([.accessibility]) + ) + #expect( + state.refresh( + with: PreferencesPermissionSnapshot( + accessibilityGranted: true, + screenRecordingGranted: true + ) + ) == .changed([.screenRecording]) + ) + } + + @Test("Refresh observes permission revocation") + func refreshObservesRevocation() { + var state = PreferencesPermissionState( + snapshot: PreferencesPermissionSnapshot( + accessibilityGranted: true, + screenRecordingGranted: true + ) + ) + + let result = state.refresh( + with: PreferencesPermissionSnapshot( + accessibilityGranted: false, + screenRecordingGranted: true + ) + ) + + #expect(result == .changed([.accessibility])) + #expect( + state.snapshot.accessibility == .notGranted + ) + #expect( + state.snapshot.screenRecording == .granted + ) + } + + @Test("Snapshot boolean initializer keeps permissions independent") + func booleanSnapshot() { + let snapshot = PreferencesPermissionSnapshot( + accessibilityGranted: false, + screenRecordingGranted: true + ) + + #expect( + snapshot.status(for: .accessibility) + == .notGranted + ) + #expect( + snapshot.status(for: .screenRecording) + == .granted + ) + } +} diff --git a/Tests/FreeDockTests/QuickLaunchDockRouterTests.swift b/Tests/FreeDockTests/QuickLaunchDockRouterTests.swift new file mode 100644 index 0000000..aa4d15d --- /dev/null +++ b/Tests/FreeDockTests/QuickLaunchDockRouterTests.swift @@ -0,0 +1,207 @@ +import CoreGraphics +import Foundation +import Testing +@testable import FreeDock + +@Test("Quick launch routing returns nil without dock candidates") +func quickLaunchRoutingEmptyCandidates() { + let selected = QuickLaunchDockRouter.targetDockID( + for: CGPoint(x: 50, y: 50), + pointerScreenFrame: CGRect(x: 0, y: 0, width: 100, height: 100), + candidates: [] + ) + + #expect(selected == nil) +} + +@Test("Quick launch routing only compares docks on the pointer screen") +func quickLaunchRoutingPrefersPointerScreen() { + let otherScreenDock = quickLaunchDockCandidate( + 1, + frame: CGRect(x: 90, y: 40, width: 8, height: 20), + configOrder: 0 + ) + let pointerScreenDock = quickLaunchDockCandidate( + 2, + frame: CGRect(x: 180, y: 40, width: 15, height: 20), + configOrder: 1 + ) + + let selected = QuickLaunchDockRouter.targetDockID( + for: CGPoint(x: 101, y: 50), + pointerScreenFrame: CGRect(x: 100, y: 0, width: 100, height: 100), + candidates: [otherScreenDock, pointerScreenDock] + ) + + #expect(selected == pointerScreenDock.dockID) +} + +@Test("Quick launch routing uses point-to-rectangle distance") +func quickLaunchRoutingDistance() { + let diagonalDock = quickLaunchDockCandidate( + 1, + frame: CGRect(x: 100, y: 100, width: 10, height: 10), + configOrder: 0 + ) + let verticallyCloserDock = quickLaunchDockCandidate( + 2, + frame: CGRect(x: 0, y: 100, width: 10, height: 10), + configOrder: 1 + ) + + let selected = QuickLaunchDockRouter.targetDockID( + for: .zero, + pointerScreenFrame: CGRect(x: 0, y: 0, width: 200, height: 200), + candidates: [diagonalDock, verticallyCloserDock] + ) + + #expect(selected == verticallyCloserDock.dockID) +} + +@Test("A pointer inside a dock has zero routing distance") +func quickLaunchRoutingInsideDock() { + let nearbyDock = quickLaunchDockCandidate( + 1, + frame: CGRect(x: 70, y: 70, width: 10, height: 10), + configOrder: 0 + ) + let containingDock = quickLaunchDockCandidate( + 2, + frame: CGRect(x: 40, y: 40, width: 20, height: 20), + configOrder: 1 + ) + + let selected = QuickLaunchDockRouter.targetDockID( + for: CGPoint(x: 50, y: 50), + pointerScreenFrame: CGRect(x: 0, y: 0, width: 100, height: 100), + candidates: [nearbyDock, containingDock] + ) + + #expect(selected == containingDock.dockID) +} + +@Test("Equal routing distances use stable configuration order") +func quickLaunchRoutingConfigOrderTieBreak() { + let laterDock = quickLaunchDockCandidate( + 1, + frame: CGRect(x: 60, y: 40, width: 10, height: 20), + configOrder: 8 + ) + let earlierDock = quickLaunchDockCandidate( + 2, + frame: CGRect(x: 30, y: 40, width: 10, height: 20), + configOrder: 2 + ) + + let selected = QuickLaunchDockRouter.targetDockID( + for: CGPoint(x: 50, y: 50), + pointerScreenFrame: CGRect(x: 0, y: 0, width: 100, height: 100), + candidates: [laterDock, earlierDock] + ) + + #expect(selected == earlierDock.dockID) +} + +@Test("Zero-size dock frames can associate by their center point") +func quickLaunchRoutingCenterAssociation() { + let pointDock = quickLaunchDockCandidate( + 1, + frame: CGRect(x: 50, y: 50, width: 0, height: 0), + configOrder: 1 + ) + let offscreenFallback = quickLaunchDockCandidate( + 2, + frame: CGRect(x: 200, y: 200, width: 10, height: 10), + configOrder: 0 + ) + + let selected = QuickLaunchDockRouter.targetDockID( + for: CGPoint(x: 50, y: 50), + pointerScreenFrame: CGRect(x: 0, y: 0, width: 100, height: 100), + candidates: [offscreenFallback, pointDock] + ) + + #expect(selected == pointDock.dockID) +} + +@Test("No pointer-screen match falls back to first configuration order") +func quickLaunchRoutingNoScreenMatchFallback() { + let geometricallyCloser = quickLaunchDockCandidate( + 1, + frame: CGRect(x: 90, y: 40, width: 5, height: 20), + configOrder: 7 + ) + let firstConfigured = quickLaunchDockCandidate( + 2, + frame: CGRect(x: -200, y: 40, width: 10, height: 20), + configOrder: 1 + ) + + let selected = QuickLaunchDockRouter.targetDockID( + for: CGPoint(x: 101, y: 50), + pointerScreenFrame: CGRect(x: 100, y: 0, width: 100, height: 100), + candidates: [geometricallyCloser, firstConfigured] + ) + + #expect(selected == firstConfigured.dockID) +} + +@Test("Missing pointer screen falls back deterministically") +func quickLaunchRoutingMissingScreenFallback() { + let laterDock = quickLaunchDockCandidate( + 1, + frame: CGRect(x: 0, y: 0, width: 10, height: 10), + configOrder: 3 + ) + let firstDock = quickLaunchDockCandidate( + 2, + frame: CGRect(x: 20, y: 0, width: 10, height: 10), + configOrder: 0 + ) + + let selected = QuickLaunchDockRouter.targetDockID( + for: .zero, + pointerScreenFrame: nil, + candidates: [laterDock, firstDock] + ) + + #expect(selected == firstDock.dockID) +} + +@Test("Duplicate configuration order preserves candidate input order") +func quickLaunchRoutingDuplicateConfigOrder() { + let firstInput = quickLaunchDockCandidate( + 1, + frame: CGRect(x: 40, y: 40, width: 20, height: 20), + configOrder: 0 + ) + let secondInput = quickLaunchDockCandidate( + 2, + frame: CGRect(x: 40, y: 40, width: 20, height: 20), + configOrder: 0 + ) + + let selected = QuickLaunchDockRouter.targetDockID( + for: CGPoint(x: 50, y: 50), + pointerScreenFrame: CGRect(x: 0, y: 0, width: 100, height: 100), + candidates: [firstInput, secondInput] + ) + + #expect(selected == firstInput.dockID) +} + +private func quickLaunchDockCandidate( + _ id: Int, + frame: CGRect, + configOrder: Int +) -> QuickLaunchDockCandidate { + QuickLaunchDockCandidate( + dockID: quickLaunchDockID(id), + frame: frame, + configOrder: configOrder + ) +} + +private func quickLaunchDockID(_ value: Int) -> UUID { + UUID(uuidString: String(format: "10000000-0000-0000-0000-%012d", value))! +} diff --git a/Tests/FreeDockTests/QuickLaunchSearchTests.swift b/Tests/FreeDockTests/QuickLaunchSearchTests.swift new file mode 100644 index 0000000..905dc18 --- /dev/null +++ b/Tests/FreeDockTests/QuickLaunchSearchTests.swift @@ -0,0 +1,332 @@ +import Foundation +import Testing +@testable import FreeDock + +@Test("Empty quick launch query keeps launchable dock order") +func quickLaunchEmptyQuery() { + let first = quickLaunchItem( + 1, + kind: .application, + path: "/Applications/First.app", + label: "First" + ) + let separator = DockItem.separator(id: quickLaunchID(2)) + let stack = DockItem.recentFilesStack(id: quickLaunchID(3)) + let malformed = quickLaunchItem( + 4, + kind: .document, + path: "", + label: "Missing target" + ) + let last = quickLaunchItem( + 5, + kind: .document, + path: "/tmp/Last.pdf", + label: "Last" + ) + + let results = QuickLaunchSearch.results( + in: [first, separator, stack, malformed, last], + matching: " " + ) + + #expect(results.map(\.id) == [first.id, stack.id, last.id]) + #expect(results.map(\.dockIndex) == [0, 2, 4]) + #expect(results.allSatisfy { $0.matchKind == nil }) +} + +@Test("Quick launch ranks match quality and preserves dock order within tiers") +func quickLaunchRanking() { + let substring = quickLaunchItem(1, label: "XalphaY") + let prefixFirst = quickLaunchItem(2, label: "Alphabet") + let wordPrefixFirst = quickLaunchItem(3, label: "Project Alpha") + let exactLabel = quickLaunchItem(4, label: "Alpha") + let prefixSecond = quickLaunchItem(5, label: "Alpha Notes") + let wordPrefixSecond = quickLaunchItem(6, label: "Open Alpha") + let exactPath = quickLaunchItem( + 7, + path: "alpha", + label: "Different label" + ) + + let results = QuickLaunchSearch.results( + in: [ + substring, + prefixFirst, + wordPrefixFirst, + exactLabel, + prefixSecond, + wordPrefixSecond, + exactPath, + ], + matching: "alpha" + ) + + #expect( + results.map(\.id) == [ + exactLabel.id, + exactPath.id, + prefixFirst.id, + prefixSecond.id, + wordPrefixFirst.id, + wordPrefixSecond.id, + substring.id, + ] + ) + #expect( + results.map(\.matchKind) == [ + .exact, + .exact, + .prefix, + .prefix, + .wordPrefix, + .wordPrefix, + .substring, + ] + ) +} + +@Test("Quick launch searches labels and paths without filesystem resolution") +func quickLaunchSearchFields() { + let safari = quickLaunchItem( + 1, + kind: .application, + path: "/Applications/Safari.app", + label: nil + ) + let report = quickLaunchItem( + 2, + kind: .document, + path: "/Users/example/Documents/Annual Report.pdf", + label: nil + ) + let custom = quickLaunchItem( + 3, + path: "/Applications/Utility.app", + label: "Launch Tool" + ) + + let safariResults = QuickLaunchSearch.results( + in: [safari, report, custom], + matching: "Safari" + ) + let pathResults = QuickLaunchSearch.results( + in: [safari, report, custom], + matching: "documents" + ) + let labelResults = QuickLaunchSearch.results( + in: [safari, report, custom], + matching: "launch" + ) + + #expect(safariResults.map(\.id) == [safari.id]) + #expect(safariResults.first?.displayLabel == "Safari") + #expect(safariResults.first?.matchKind == .exact) + #expect(pathResults.map(\.id) == [report.id]) + #expect(pathResults.first?.matchKind == .wordPrefix) + #expect(labelResults.map(\.id) == [custom.id]) + #expect(labelResults.first?.matchKind == .prefix) +} + +@Test("Quick launch matching is case, width, diacritic, and whitespace tolerant") +func quickLaunchNormalizedMatching() { + let resume = quickLaunchItem(1, label: " Résumé Writer ") + let fullWidth = quickLaunchItem(2, label: "Finder") + + let resumeResults = QuickLaunchSearch.results( + in: [resume, fullWidth], + matching: "resume writer" + ) + let finderResults = QuickLaunchSearch.results( + in: [resume, fullWidth], + matching: "finder" + ) + + #expect(resumeResults.map(\.id) == [resume.id]) + #expect(resumeResults.first?.matchKind == .exact) + #expect(finderResults.map(\.id) == [fullWidth.id]) + #expect(finderResults.first?.matchKind == .exact) +} + +@Test("Quick launch tokens match across labels and paths in any order") +func quickLaunchTokenizedFieldMatching() { + let annualPDF = quickLaunchItem( + 1, + kind: .document, + path: "/Users/example/Archive/Board-Packet.PDF", + label: "Annual Report" + ) + + let forwardResults = QuickLaunchSearch.results( + in: [annualPDF], + matching: "annual pdf" + ) + let reverseResults = QuickLaunchSearch.results( + in: [annualPDF], + matching: " PDF ANNUAL " + ) + + #expect(forwardResults.map(\.id) == [annualPDF.id]) + #expect(reverseResults.map(\.id) == [annualPDF.id]) + #expect(forwardResults.first?.matchKind == .wordPrefix) + #expect(reverseResults.first?.matchKind == .wordPrefix) +} + +@Test("Every quick launch token must match the same candidate") +func quickLaunchRequiresEveryToken() { + let annualOnly = quickLaunchItem( + 1, + kind: .document, + path: "/Users/example/Annual Notes.txt", + label: "Annual Notes" + ) + let pdfOnly = quickLaunchItem( + 2, + kind: .document, + path: "/Users/example/Archive.pdf", + label: "Quarterly Archive" + ) + + let results = QuickLaunchSearch.results( + in: [annualOnly, pdfOnly], + matching: "annual pdf" + ) + + #expect(results.isEmpty) +} + +@Test("Tokenized quick launch ranking uses the weakest token match") +func quickLaunchTokenizedRanking() { + let weaker = quickLaunchItem(1, label: "XalphaY Beta") + let strongerFirst = quickLaunchItem(2, label: "Alpha Project Beta") + let strongerSecond = quickLaunchItem(3, label: "Alpha Beta Notes") + + let results = QuickLaunchSearch.results( + in: [weaker, strongerFirst, strongerSecond], + matching: "beta alpha" + ) + + #expect( + results.map(\.id) == [ + strongerFirst.id, + strongerSecond.id, + weaker.id, + ] + ) + #expect( + results.map(\.matchKind) == [ + .wordPrefix, + .wordPrefix, + .substring, + ] + ) +} + +@Test("Quick launch returns no results for an unmatched query") +func quickLaunchNoMatch() { + let results = QuickLaunchSearch.results( + in: [ + quickLaunchItem(1, label: "Safari"), + quickLaunchItem(2, label: "Notes"), + ], + matching: "Terminal" + ) + + #expect(results.isEmpty) +} + +@Test("Quick launch selection moves in both directions with wraparound") +func quickLaunchSelectionWraparound() { + let results = QuickLaunchSearch.results( + in: [ + quickLaunchItem(1, label: "First"), + quickLaunchItem(2, label: "Second"), + quickLaunchItem(3, label: "Third"), + ], + matching: "" + ) + var selection = QuickLaunchSelection() + + selection.reconcile(with: results) + #expect(selection.selectedItemID == results[0].id) + + selection.move(.next, in: results) + #expect(selection.selectedItemID == results[1].id) + selection.move(.next, in: results) + #expect(selection.selectedItemID == results[2].id) + selection.move(.next, in: results) + #expect(selection.selectedItemID == results[0].id) + + selection.move(.previous, in: results) + #expect(selection.selectedItemID == results[2].id) +} + +@Test("Quick launch selection reconciles safely when results change") +func quickLaunchSelectionReconciliation() { + let allResults = QuickLaunchSearch.results( + in: [ + quickLaunchItem(1, label: "First"), + quickLaunchItem(2, label: "Second"), + quickLaunchItem(3, label: "Third"), + ], + matching: "" + ) + var selection = QuickLaunchSelection( + selectedItemID: allResults[1].id + ) + + let retainedResults = [allResults[1], allResults[2]] + selection.reconcile(with: retainedResults) + #expect(selection.selectedItemID == allResults[1].id) + #expect(selection.selectedResult(in: retainedResults) == allResults[1]) + + let replacementResults = [allResults[2]] + selection.reconcile(with: replacementResults) + #expect(selection.selectedItemID == allResults[2].id) + + selection.reconcile(with: []) + #expect(selection.selectedItemID == nil) + #expect(selection.selectedResult(in: []) == nil) +} + +@Test("Quick launch navigation recovers from an absent selection") +func quickLaunchMissingSelectionNavigation() { + let results = QuickLaunchSearch.results( + in: [ + quickLaunchItem(1, label: "First"), + quickLaunchItem(2, label: "Second"), + ], + matching: "" + ) + var selection = QuickLaunchSelection( + selectedItemID: quickLaunchID(99) + ) + + selection.move(.previous, in: results) + #expect(selection.selectedItemID == results[1].id) + + selection = QuickLaunchSelection(selectedItemID: quickLaunchID(99)) + selection.move(.next, in: results) + #expect(selection.selectedItemID == results[0].id) + + selection.move(.next, in: []) + #expect(selection.selectedItemID == nil) +} + +private func quickLaunchItem( + _ id: Int, + kind: DockItemKind = .application, + path: String? = nil, + label: String? +) -> DockItem { + DockItem( + id: quickLaunchID(id), + kind: kind, + path: path ?? "/tmp/\(id).app", + label: label + ) +} + +private func quickLaunchID(_ value: Int) -> UUID { + UUID(uuidString: String(format: "00000000-0000-0000-0000-%012d", value))! +} diff --git a/Tests/FreeDockTests/SmartStackModelTests.swift b/Tests/FreeDockTests/SmartStackModelTests.swift new file mode 100644 index 0000000..34f5630 --- /dev/null +++ b/Tests/FreeDockTests/SmartStackModelTests.swift @@ -0,0 +1,396 @@ +import Foundation +import Testing +@testable import FreeDock + +@Test("Smart stack factories use source-specific defaults and dynamic paths") +func smartStackFactoryDefaults() { + let recent = DockItem.recentFilesStack() + let downloads = DockItem.downloadsStack() + let normalFolder = DockItem( + kind: .folder, + path: "/Users/example/Projects", + label: "Projects" + ) + + #expect(recent.kind == .folder) + #expect(recent.smartStackSource == .recentFiles) + #expect(recent.path.isEmpty) + #expect(recent.fileURL == nil) + #expect(recent.label == "Recent Files") + #expect(recent.folderOptions == FolderStackOptions( + presentation: .list, + sortOrder: .recentlyOpened + )) + + #expect(downloads.kind == .folder) + #expect(downloads.smartStackSource == .downloads) + #expect(downloads.path.isEmpty) + #expect(downloads.label == "Downloads") + #expect(downloads.folderOptions == FolderStackOptions( + presentation: .automatic, + sortOrder: .dateModified + )) + + #expect(normalFolder.smartStackSource == nil) + #expect(normalFolder.path == "/Users/example/Projects") + #expect(normalFolder.folderOptions == FolderStackOptions()) +} + +@Test("Smart stacks round-trip compatibly and duplicate with fresh identities") +func smartStackCodableAndDuplication() throws { + let options = FolderStackOptions( + presentation: .grid, + sortOrder: .kind, + showHiddenFiles: true + ) + let original = DockItem.downloadsStack( + label: "Incoming", + options: options + ) + let data = try JSONEncoder().encode(original) + let object = try #require( + JSONSerialization.jsonObject(with: data) as? [String: Any] + ) + let decoded = try JSONDecoder().decode(DockItem.self, from: data) + let duplicate = original.duplicated() + + #expect(decoded == original) + #expect(object["kind"] as? String == "folder") + #expect(object["smartStackSource"] as? String == "downloads") + #expect(object["path"] as? String == "") + #expect(object["appPath"] as? String == "") + #expect(object["isSeparator"] as? Bool == false) + #expect(duplicate.id != original.id) + #expect(duplicate.smartStackSource == original.smartStackSource) + #expect(duplicate.folderOptions == options) + #expect(duplicate.label == "Incoming") +} + +@Test("Legacy and unknown folder sources remain normal folders") +func smartStackSourceMigrationFallback() throws { + let legacyData = """ + { + "id": "\(UUID().uuidString)", + "path": "/Users/example/Projects", + "appPath": "/Users/example/Projects", + "kind": "folder", + "isSeparator": false + } + """.data(using: .utf8)! + let legacy = try JSONDecoder().decode(DockItem.self, from: legacyData) + + #expect(legacy.kind == .folder) + #expect(legacy.smartStackSource == nil) + #expect(legacy.path == "/Users/example/Projects") + #expect(legacy.folderOptions == FolderStackOptions()) + + let unknownData = """ + { + "id": "\(UUID().uuidString)", + "path": "/Users/example/Archive", + "appPath": "/Users/example/Archive", + "kind": "folder", + "smartStackSource": "future-cloud-files", + "isSeparator": false + } + """.data(using: .utf8)! + let unknown = try JSONDecoder().decode(DockItem.self, from: unknownData) + + #expect(unknown.smartStackSource == nil) + #expect(unknown.path == "/Users/example/Archive") +} + +@Test("App config persists bounded recent history and migrates older data") +func appConfigRecentFilePersistence() throws { + let records = (0 ..< 110).map { index in + RecentFileRecord( + path: "/tmp/Recent-\(index).txt", + displayName: "Recent \(index)", + lastOpenedAt: Date(timeIntervalSince1970: Double(index)) + ) + } + let config = AppConfig(docks: [], recentFiles: records) + let data = try JSONEncoder().encode(config) + let object = try #require( + JSONSerialization.jsonObject(with: data) as? [String: Any] + ) + let decoded = try JSONDecoder().decode(AppConfig.self, from: data) + + #expect(config.recentFiles.count == RecentFileHistoryPlanner.maximumLimit) + #expect(decoded.recentFiles == config.recentFiles) + #expect(object["formatVersion"] as? Int == 4) + #expect((object["recentFiles"] as? [[String: Any]])?.count == 100) + + let legacyData = """ + { + "formatVersion": 3, + "docks": [] + } + """.data(using: .utf8)! + let legacy = try JSONDecoder().decode(AppConfig.self, from: legacyData) + #expect(legacy.recentFiles.isEmpty) +} + +@Test("Recent history accepts only existing local documents") +func recentFileHistoryLocalDocumentsOnly() throws { + let temporaryDirectory = try makeSmartStackTemporaryDirectory() + defer { try? FileManager.default.removeItem(at: temporaryDirectory) } + + let documentURL = temporaryDirectory.appendingPathComponent("Notes.txt") + let folderURL = temporaryDirectory.appendingPathComponent("Folder") + let applicationURL = temporaryDirectory.appendingPathComponent("Example.app") + try Data("notes".utf8).write(to: documentURL) + try FileManager.default.createDirectory( + at: folderURL, + withIntermediateDirectories: true + ) + try FileManager.default.createDirectory( + at: applicationURL, + withIntermediateDirectories: true + ) + + let accepted = RecentFileHistoryPlanner.planRecording( + url: documentURL, + displayName: " My Notes ", + openedAt: Date(timeIntervalSince1970: 10), + records: [] + ) + #expect(accepted.didRecord) + #expect(accepted.records.count == 1) + #expect(accepted.records.first?.displayName == "My Notes") + + for rejectedURL in [ + folderURL, + applicationURL, + temporaryDirectory.appendingPathComponent("Missing.txt"), + URL(string: "https://example.com/file.txt")!, + ] { + let rejected = RecentFileHistoryPlanner.planRecording( + url: rejectedURL, + records: accepted.records + ) + #expect(!rejected.didRecord) + #expect(rejected.records == accepted.records) + } +} + +@Test("Recent history deduplicates canonical paths and moves reopened files") +func recentFileHistoryCanonicalDeduplication() throws { + let temporaryDirectory = try makeSmartStackTemporaryDirectory() + defer { try? FileManager.default.removeItem(at: temporaryDirectory) } + + let targetURL = temporaryDirectory.appendingPathComponent("Document.txt") + let aliasURL = temporaryDirectory.appendingPathComponent("Document Alias.txt") + try Data("document".utf8).write(to: targetURL) + try FileManager.default.createSymbolicLink( + at: aliasURL, + withDestinationURL: targetURL + ) + let firstDate = Date(timeIntervalSince1970: 10) + let secondDate = Date(timeIntervalSince1970: 20) + + let first = RecentFileHistoryPlanner.planRecording( + url: targetURL, + openedAt: firstDate, + records: [] + ) + let second = RecentFileHistoryPlanner.planRecording( + url: aliasURL, + openedAt: secondDate, + records: first.records + ) + + #expect(second.didRecord) + #expect(second.records.count == 1) + #expect(second.records.first?.path == aliasURL.standardizedFileURL.path) + #expect(second.records.first?.lastOpenedAt == secondDate) +} + +@Test("Recent history defaults to 50 and never exceeds 100 records") +func recentFileHistoryCapsAndOrdering() { + let records = (0 ..< 120).map { index in + RecentFileRecord( + path: "/tmp/History-\(index).txt", + lastOpenedAt: Date(timeIntervalSince1970: Double(index)) + ) + } + + let defaultHistory = RecentFileHistoryPlanner.normalized(records) + let hardCappedHistory = RecentFileHistoryPlanner.normalized( + records, + limit: 1_000 + ) + + #expect(defaultHistory.count == 50) + #expect(defaultHistory.first?.path == "/tmp/History-119.txt") + #expect(defaultHistory.last?.path == "/tmp/History-70.txt") + #expect(hardCappedHistory.count == 100) + #expect(hardCappedHistory.last?.path == "/tmp/History-20.txt") +} + +@Test("Smart stack planner rejects source and Downloads-folder duplicates per dock") +func smartStackAdditionPlanning() throws { + let temporaryDirectory = try makeSmartStackTemporaryDirectory() + defer { try? FileManager.default.removeItem(at: temporaryDirectory) } + let downloadsURL = temporaryDirectory.appendingPathComponent("Downloads") + let downloadsAliasURL = temporaryDirectory + .appendingPathComponent("Downloads Alias") + try FileManager.default.createDirectory( + at: downloadsURL, + withIntermediateDirectories: true + ) + try FileManager.default.createSymbolicLink( + at: downloadsAliasURL, + withDestinationURL: downloadsURL + ) + + let addRecent = DockItemPlanner.planAdding( + smartStack: .recentFiles, + to: [], + downloadsURL: downloadsURL + ) + let repeatRecent = DockItemPlanner.planAdding( + smartStack: .recentFiles, + to: addRecent.items, + downloadsURL: downloadsURL + ) + #expect(addRecent.addedCount == 1) + #expect(addRecent.items.last?.smartStackSource == .recentFiles) + #expect(repeatRecent.addedCount == 0) + #expect(repeatRecent.skippedCount == 1) + + let normalDownloads = try #require( + DockItem.pinnedItem(at: downloadsAliasURL) + ) + let smartAfterFolder = DockItemPlanner.planAdding( + smartStack: .downloads, + to: [normalDownloads], + downloadsURL: downloadsURL + ) + #expect(smartAfterFolder.addedCount == 0) + #expect(smartAfterFolder.skippedCount == 1) + + let normalAfterSmart = DockItemPlanner.planAdding( + urls: [downloadsAliasURL], + to: [.downloadsStack()], + downloadsURL: downloadsURL + ) + #expect(normalAfterSmart.addedCount == 0) + #expect(normalAfterSmart.skippedCount == 1) + + let sameSourceInAnotherDock = DockItemPlanner.planAdding( + smartStack: .downloads, + to: [], + downloadsURL: downloadsURL + ) + #expect(sameSourceInAnotherDock.addedCount == 1) +} + +@Test("Recent smart stack snapshots omit stale and hidden records") +func recentSmartStackSnapshot() throws { + let temporaryDirectory = try makeSmartStackTemporaryDirectory() + defer { try? FileManager.default.removeItem(at: temporaryDirectory) } + + let olderURL = temporaryDirectory.appendingPathComponent("Older.txt") + let newerURL = temporaryDirectory.appendingPathComponent("Newer.txt") + let hiddenURL = temporaryDirectory.appendingPathComponent(".Hidden.txt") + let missingURL = temporaryDirectory.appendingPathComponent("Missing.txt") + try Data("older".utf8).write(to: olderURL) + try Data("newer".utf8).write(to: newerURL) + try Data("hidden".utf8).write(to: hiddenURL) + let olderDate = Date(timeIntervalSince1970: 10) + let newerDate = Date(timeIntervalSince1970: 20) + let hiddenDate = Date(timeIntervalSince1970: 30) + let records = [ + RecentFileRecord( + path: olderURL.path, + displayName: "Older Custom", + lastOpenedAt: olderDate + ), + RecentFileRecord( + path: newerURL.path, + lastOpenedAt: newerDate + ), + RecentFileRecord( + path: hiddenURL.path, + lastOpenedAt: hiddenDate + ), + RecentFileRecord( + path: missingURL.path, + lastOpenedAt: Date(timeIntervalSince1970: 40) + ), + ] + + let snapshot = try FolderStackLoader.load( + item: .recentFilesStack(), + recentFiles: records + ) + + #expect(snapshot.totalCount == 2) + #expect(snapshot.entries.map(\.url.lastPathComponent) == [ + "Newer.txt", + "Older.txt", + ]) + #expect(snapshot.entries.map(\.recentlyOpenedAt) == [ + newerDate, + olderDate, + ]) + #expect(snapshot.entries.last?.name == "Older Custom") + + let showingHidden = try FolderStackLoader.load( + item: .recentFilesStack( + options: FolderStackOptions( + presentation: .list, + sortOrder: .recentlyOpened, + showHiddenFiles: true + ) + ), + recentFiles: records + ) + #expect(showingHidden.totalCount == 3) + #expect(showingHidden.entries.first?.url.lastPathComponent == ".Hidden.txt") +} + +@Test("Downloads smart stacks resolve dynamically and preserve folder loading") +func downloadsSmartStackSnapshot() throws { + let downloadsURL = try makeSmartStackTemporaryDirectory() + defer { try? FileManager.default.removeItem(at: downloadsURL) } + + let olderURL = downloadsURL.appendingPathComponent("Older.txt") + let newerURL = downloadsURL.appendingPathComponent("Newer.txt") + try Data("older".utf8).write(to: olderURL) + try Data("newer".utf8).write(to: newerURL) + try FileManager.default.setAttributes( + [.modificationDate: Date(timeIntervalSince1970: 10)], + ofItemAtPath: olderURL.path + ) + try FileManager.default.setAttributes( + [.modificationDate: Date(timeIntervalSince1970: 20)], + ofItemAtPath: newerURL.path + ) + + let downloads = try FolderStackLoader.load( + item: .downloadsStack(), + downloadsURL: downloadsURL + ) + #expect(downloads.entries.map(\.name) == ["Newer.txt", "Older.txt"]) + #expect(downloads.entries.allSatisfy { $0.recentlyOpenedAt == nil }) + + let normalFolder = DockItem.folder(at: downloadsURL) + let unifiedFolderSnapshot = try FolderStackLoader.load(item: normalFolder) + let originalFolderSnapshot = try FolderStackLoader.load( + folderURL: downloadsURL, + options: FolderStackOptions() + ) + #expect(unifiedFolderSnapshot == originalFolderSnapshot) +} + +private func makeSmartStackTemporaryDirectory() throws -> URL { + let url = FileManager.default.temporaryDirectory + .appendingPathComponent("freedock-smart-stack-\(UUID().uuidString)") + try FileManager.default.createDirectory( + at: url, + withIntermediateDirectories: true + ) + return url +} diff --git a/Tests/FreeDockTests/SystemDockImporterTests.swift b/Tests/FreeDockTests/SystemDockImporterTests.swift new file mode 100644 index 0000000..eaa9e69 --- /dev/null +++ b/Tests/FreeDockTests/SystemDockImporterTests.swift @@ -0,0 +1,407 @@ +import Foundation +import Testing +@testable import FreeDock + +@Test("System Dock parser preserves app order and decodes file URLs") +func systemDockParserPreservesOrder() throws { + let persistentApps: [Any] = [ + systemDockTile( + url: "file:///Applications/First.app/", + label: "First", + bundleIdentifier: "com.example.first" + ), + systemDockTile( + url: "file:///System/Applications/System%20Settings.app/", + label: "System Settings", + bundleIdentifier: "com.apple.systempreferences" + ), + systemDockTile( + url: "/Applications/Legacy.app", + label: "Legacy", + bundleIdentifier: nil + ), + ] + + let apps = try SystemDockImporter.parsePersistentApps(persistentApps) + + #expect(apps.map(\.path) == [ + "/Applications/First.app", + "/System/Applications/System Settings.app", + "/Applications/Legacy.app", + ]) + #expect(apps.map(\.label) == ["First", "System Settings", "Legacy"]) + #expect(apps.map(\.bundleIdentifier) == [ + "com.example.first", + "com.apple.systempreferences", + nil, + ]) +} + +@Test("System Dock parser skips malformed and non-app tiles independently") +func systemDockParserSkipsUnsupportedTiles() throws { + let persistentApps: [Any] = [ + ["tile-data": ["file-label": "Missing file data"]], + systemDockTile( + url: "https://example.com/NotAnApp.app", + label: "Web", + bundleIdentifier: nil + ), + systemDockTile( + url: "file:///Users/example/Downloads/", + label: "Downloads", + bundleIdentifier: nil + ), + systemDockTile( + url: "file:///Applications/Valid.app/", + label: "Valid", + bundleIdentifier: "com.example.valid" + ), + ] + + let apps = try SystemDockImporter.parsePersistentApps(persistentApps) + + #expect(apps.count == 1) + #expect(apps.first?.path == "/Applications/Valid.app") +} + +@Test("System Dock parser distinguishes an empty Dock from an invalid format") +func systemDockParserValidatesTopLevelFormat() throws { + #expect(try SystemDockImporter.parsePersistentApps([]).isEmpty) + + var receivedError: SystemDockImportError? + do { + _ = try SystemDockImporter.parsePersistentApps(["persistent-apps": "invalid"]) + } catch let error as SystemDockImportError { + receivedError = error + } catch { + Issue.record("Unexpected parser error: \(error)") + } + + #expect(receivedError == .invalidPreferencesFormat) +} + +@Test("System Dock availability keeps valid apps and counts unavailable entries") +func systemDockAvailability() { + let apps = [ + SystemDockApp( + path: "/Applications/Available.app", + label: "Available", + bundleIdentifier: "com.example.available" + ), + SystemDockApp( + path: "/Applications/Missing.app", + label: "Missing", + bundleIdentifier: "com.example.missing" + ), + ] + + let result = SystemDockImporter.availableApps( + from: apps, + installedPathForApp: { app in + app.bundleIdentifier == "com.example.available" + ? "/Resolved/Available.app" + : nil + } + ) + + #expect(result.apps == [ + SystemDockApp( + path: "/Resolved/Available.app", + label: "Available", + bundleIdentifier: "com.example.available" + ), + ]) + #expect(result.unavailableCount == 1) +} + +@Test("System Dock availability trusts the installed bundle over stale metadata") +func systemDockAvailabilityUsesInstalledBundleIdentifier() { + let app = SystemDockApp( + path: "/Applications/Replaced.app", + label: "Replaced", + bundleIdentifier: "com.example.stale" + ) + + let result = SystemDockImporter.availableApps( + from: [app], + installedPathForApp: { $0.path }, + bundleIdentifierForPath: { _ in "com.example.current" } + ) + + #expect(result.apps.first?.bundleIdentifier == "com.example.current") +} + +@Test("System Dock path resolution prefers the exact pinned app") +func systemDockPathResolutionPrefersPinnedPath() { + let pinnedPath = "/Applications/Pinned Beta.app" + let fallbackPath = "/Applications/Pinned.app" + let app = SystemDockApp( + path: pinnedPath, + label: "Pinned Beta", + bundleIdentifier: "com.example.pinned" + ) + + let resolvedPinnedPath = SystemDockImporter.resolveInstalledPath( + for: app, + fallbackURLForBundleIdentifier: { _ in + URL(fileURLWithPath: fallbackPath) + }, + applicationIsValid: { $0 == pinnedPath || $0 == fallbackPath } + ) + #expect(resolvedPinnedPath == pinnedPath) + + let resolvedFallbackPath = SystemDockImporter.resolveInstalledPath( + for: app, + fallbackURLForBundleIdentifier: { _ in + URL(fileURLWithPath: fallbackPath) + }, + applicationIsValid: { $0 == fallbackPath } + ) + #expect(resolvedFallbackPath == fallbackPath) +} + +@Test("System Dock app validation requires a launchable bundle") +func systemDockApplicationValidation() throws { + let temporaryDirectory = FileManager.default.temporaryDirectory + .appendingPathComponent("freedock-import-\(UUID().uuidString)") + try FileManager.default.createDirectory( + at: temporaryDirectory, + withIntermediateDirectories: true + ) + defer { try? FileManager.default.removeItem(at: temporaryDirectory) } + + let directoryOnly = temporaryDirectory.appendingPathComponent("Directory.app") + try FileManager.default.createDirectory( + at: directoryOnly, + withIntermediateDirectories: true + ) + #expect(!SystemDockImporter.isValidApplication(atPath: directoryOnly.path)) + + let missingExecutable = temporaryDirectory.appendingPathComponent("Missing.app") + try createTestApplication(at: missingExecutable, includeExecutable: false) + #expect(!SystemDockImporter.isValidApplication(atPath: missingExecutable.path)) + + let validApplication = temporaryDirectory.appendingPathComponent("Valid.app") + try createTestApplication(at: validApplication, includeExecutable: true) + #expect(SystemDockImporter.isValidApplication(atPath: validApplication.path)) +} + +@Test("System Dock imports preserve stable symlink paths") +func systemDockImportPreservesSymlinkPath() throws { + let temporaryDirectory = FileManager.default.temporaryDirectory + .appendingPathComponent("freedock-symlink-\(UUID().uuidString)") + try FileManager.default.createDirectory( + at: temporaryDirectory, + withIntermediateDirectories: true + ) + defer { try? FileManager.default.removeItem(at: temporaryDirectory) } + + let targetURL = temporaryDirectory.appendingPathComponent("Physical.app") + let symlinkURL = temporaryDirectory.appendingPathComponent("Stable.app") + try FileManager.default.createDirectory( + at: targetURL, + withIntermediateDirectories: true + ) + try FileManager.default.createSymbolicLink( + at: symlinkURL, + withDestinationURL: targetURL + ) + + let app = SystemDockApp( + path: symlinkURL.path, + label: "Stable", + bundleIdentifier: nil + ) + let firstPlan = SystemDockImporter.planImport( + apps: [app], + existingItems: [], + bundleIdentifierForPath: { _ in nil } + ) + #expect(firstPlan.items.first?.appPath == symlinkURL.path) + #expect(firstPlan.items.first?.kind == .application) + + let secondPlan = SystemDockImporter.planImport( + apps: [app], + existingItems: [DockItem(appPath: targetURL.path)], + bundleIdentifierForPath: { _ in nil } + ) + #expect(secondPlan.items.isEmpty) +} + +@Test("System Dock import appends only missing apps and is idempotent") +func systemDockImportPlanDeduplicates() { + let existingApp = DockItem( + appPath: "/Applications/Alpha.app", + label: "Alpha" + ) + let separator = DockItem.separator() + let existingItems = [existingApp, separator] + let apps = [ + SystemDockApp( + path: "/Applications/Alpha.app", + label: "Alpha", + bundleIdentifier: "com.example.alpha" + ), + SystemDockApp( + path: "/System/Volumes/Alternate/Alpha.app", + label: "Alpha Alternate", + bundleIdentifier: "com.example.alpha" + ), + SystemDockApp( + path: "/Applications/Beta.app", + label: "Beta", + bundleIdentifier: "com.example.beta" + ), + SystemDockApp( + path: "/Applications/Beta.app/", + label: "Beta Duplicate", + bundleIdentifier: "com.example.beta" + ), + SystemDockApp( + path: "/Applications/Gamma.app", + label: "Gamma", + bundleIdentifier: nil + ), + ] + let bundleIdentifierForPath: (String) -> String? = { path in + if path.localizedCaseInsensitiveContains("Alpha") { + return "com.example.alpha" + } + if path.localizedCaseInsensitiveContains("Beta") { + return "com.example.beta" + } + return nil + } + + let plan = SystemDockImporter.planImport( + apps: apps, + existingItems: existingItems, + bundleIdentifierForPath: bundleIdentifierForPath + ) + let mergedItems = existingItems + plan.items + + #expect(plan.items.map(\.appPath) == [ + "/Applications/Beta.app", + "/Applications/Gamma.app", + ]) + #expect(plan.items.map(\.label) == ["Beta", "Gamma"]) + #expect(plan.items.allSatisfy { $0.kind == .application }) + #expect(plan.items.allSatisfy { $0.folderOptions == nil }) + #expect(plan.skippedCount == 3) + #expect(mergedItems[0] == existingApp) + #expect(mergedItems[1] == separator) + + let secondPlan = SystemDockImporter.planImport( + apps: apps, + existingItems: mergedItems, + bundleIdentifierForPath: bundleIdentifierForPath + ) + #expect(secondPlan.items.isEmpty) + #expect(secondPlan.skippedCount == apps.count) +} + +@Test("System Dock path deduplication preserves case-distinct apps") +func systemDockImportPreservesCaseDistinctPaths() { + let apps = [ + SystemDockApp( + path: "/Volumes/CaseSensitive/Foo.app", + label: "Foo", + bundleIdentifier: nil + ), + SystemDockApp( + path: "/Volumes/CaseSensitive/foo.app", + label: "foo", + bundleIdentifier: nil + ), + ] + + let plan = SystemDockImporter.planImport( + apps: apps, + existingItems: [], + bundleIdentifierForPath: { _ in nil } + ) + + #expect(plan.items.count == 2) + #expect(plan.items.allSatisfy { $0.kind == .application }) +} + +@Test("System Dock bundle deduplication only considers application items") +func systemDockImportIgnoresNonApplicationBundleIdentities() { + let documentPath = "/Users/example/Documents/Reference.pdf" + let existingDocument = DockItem( + kind: .document, + path: documentPath, + label: "Reference" + ) + let app = SystemDockApp( + path: "/Applications/Reference.app", + label: "Reference", + bundleIdentifier: "com.example.reference" + ) + var resolvedPaths: [String] = [] + + let plan = SystemDockImporter.planImport( + apps: [app], + existingItems: [existingDocument], + bundleIdentifierForPath: { path in + resolvedPaths.append(path) + return path == documentPath ? "com.example.reference" : nil + } + ) + + #expect(resolvedPaths.isEmpty) + #expect(plan.skippedCount == 0) + #expect(plan.items.count == 1) + #expect(plan.items.first?.kind == .application) + #expect(plan.items.first?.path == "/Applications/Reference.app") +} + +private func systemDockTile( + url: String, + label: String, + bundleIdentifier: String? +) -> [String: Any] { + var tileData: [String: Any] = [ + "file-data": ["_CFURLString": url], + "file-label": label, + ] + if let bundleIdentifier { + tileData["bundle-identifier"] = bundleIdentifier + } + return ["tile-data": tileData] +} + +private func createTestApplication( + at applicationURL: URL, + includeExecutable: Bool +) throws { + let contentsURL = applicationURL.appendingPathComponent("Contents") + let executableDirectory = contentsURL.appendingPathComponent("MacOS") + try FileManager.default.createDirectory( + at: executableDirectory, + withIntermediateDirectories: true + ) + + let propertyList: [String: Any] = [ + "CFBundleIdentifier": "com.example.\(UUID().uuidString.lowercased())", + "CFBundleExecutable": "TestExecutable", + "CFBundlePackageType": "APPL", + ] + let propertyListData = try PropertyListSerialization.data( + fromPropertyList: propertyList, + format: .xml, + options: 0 + ) + try propertyListData.write( + to: contentsURL.appendingPathComponent("Info.plist"), + options: .atomic + ) + + guard includeExecutable else { return } + let executableURL = executableDirectory.appendingPathComponent("TestExecutable") + try Data("#!/bin/sh\n".utf8).write(to: executableURL, options: .atomic) + try FileManager.default.setAttributes( + [.posixPermissions: 0o755], + ofItemAtPath: executableURL.path + ) +} diff --git a/Tests/FreeDockTests/WindowPreviewHoverStateTests.swift b/Tests/FreeDockTests/WindowPreviewHoverStateTests.swift new file mode 100644 index 0000000..60d6518 --- /dev/null +++ b/Tests/FreeDockTests/WindowPreviewHoverStateTests.swift @@ -0,0 +1,242 @@ +import Foundation +import Testing +@testable import FreeDock + +@Suite("Window preview hover state") +struct WindowPreviewHoverStateTests { + @Test("Discovery and delay converge in either order") + func discoveryAndDelayConverge() { + let itemID = UUID() + let sessionID = UUID() + let openToken = UUID() + var resultFirst = WindowPreviewHoverState() + let start = resultFirst.beginIconHover( + itemID: itemID, + anchor: anchor, + sessionID: sessionID, + openToken: openToken + ) + #expect(start.shouldDiscover) + let didStartDiscovery = resultFirst.markDiscoveryStarted( + sessionID: sessionID + ) + #expect(didStartDiscovery) + let didPresentFromResult = resultFirst.completeDiscovery( + sessionID: sessionID, + windowCount: 2 + ) + #expect(!didPresentFromResult) + let didPresentFromDelay = resultFirst.openDelayElapsed( + token: openToken + ) + #expect(didPresentFromDelay) + #expect(resultFirst.isPresented) + + var delayFirst = WindowPreviewHoverState() + _ = delayFirst.beginIconHover( + itemID: itemID, + anchor: anchor, + sessionID: sessionID, + openToken: openToken + ) + let didPresentBeforeResult = delayFirst.openDelayElapsed( + token: openToken + ) + #expect(!didPresentBeforeResult) + let didPresentAfterResult = delayFirst.completeDiscovery( + sessionID: sessionID, + windowCount: 2 + ) + #expect(didPresentAfterResult) + #expect(delayFirst.isPresented) + } + + @Test("Leaving before the open delay prevents presentation") + func earlyExitCancels() { + let sessionID = UUID() + let openToken = UUID() + var state = WindowPreviewHoverState() + _ = state.beginIconHover( + itemID: UUID(), + anchor: anchor, + sessionID: sessionID, + openToken: openToken + ) + _ = state.completeDiscovery( + sessionID: sessionID, + windowCount: 1 + ) + + let closeToken = state.endIconHover() + #expect(closeToken == nil) + #expect(state.phase == .idle) + let didPresent = state.openDelayElapsed(token: openToken) + #expect(!didPresent) + } + + @Test("Rapid retarget rejects the previous discovery and timer") + func rapidRetarget() { + let firstSessionID = UUID() + let firstToken = UUID() + let secondSessionID = UUID() + let secondToken = UUID() + let secondItemID = UUID() + var state = WindowPreviewHoverState() + _ = state.beginIconHover( + itemID: UUID(), + anchor: anchor, + sessionID: firstSessionID, + openToken: firstToken + ) + _ = state.beginIconHover( + itemID: secondItemID, + anchor: anchor.offsetBy(dx: 40, dy: 0), + sessionID: secondSessionID, + openToken: secondToken + ) + + let acceptedOldDiscovery = state.completeDiscovery( + sessionID: firstSessionID, + windowCount: 3 + ) + #expect(!acceptedOldDiscovery) + let acceptedOldTimer = state.openDelayElapsed( + token: firstToken + ) + #expect(!acceptedOldTimer) + #expect(state.session?.itemID == secondItemID) + #expect(state.session?.id == secondSessionID) + } + + @Test("Panel entry bridges the close grace period") + func panelEntryBridgesGap() throws { + let sessionID = UUID() + var state = presentedState(sessionID: sessionID) + let proposedCloseToken = state.endIconHover() + let closeToken = try #require(proposedCloseToken) + + let didEnterPanel = state.panelEntered( + sessionID: sessionID + ) + #expect(didEnterPanel) + let didClose = state.closeDelayElapsed(token: closeToken) + #expect(!didClose) + #expect(state.isPresented) + } + + @Test("A stale close cannot dismiss a re-entered session") + func staleCloseAfterReentry() throws { + let sessionID = UUID() + let itemID = UUID() + var state = presentedState( + sessionID: sessionID, + itemID: itemID + ) + let proposedCloseToken = state.endIconHover() + let closeToken = try #require(proposedCloseToken) + let restart = state.beginIconHover( + itemID: itemID, + anchor: anchor.offsetBy(dx: 2, dy: 3) + ) + + #expect(!restart.shouldDiscover) + let didClose = state.closeDelayElapsed(token: closeToken) + #expect(!didClose) + #expect(state.isPresented) + #expect( + state.session?.anchor + == anchor.offsetBy(dx: 2, dy: 3) + ) + } + + @Test("Panel exit closes only after its current grace token") + func panelExitClose() throws { + let sessionID = UUID() + var state = presentedState(sessionID: sessionID) + _ = state.endIconHover() + let didEnterPanel = state.panelEntered( + sessionID: sessionID + ) + #expect(didEnterPanel) + let proposedCloseToken = state.panelExited( + sessionID: sessionID + ) + let closeToken = try #require(proposedCloseToken) + + let acceptedStaleClose = state.closeDelayElapsed( + token: UUID() + ) + #expect(!acceptedStaleClose) + let didClose = state.closeDelayElapsed(token: closeToken) + #expect(didClose) + #expect(state.phase == .idle) + } + + @Test("Empty discovery never presents") + func emptyDiscovery() { + let sessionID = UUID() + let openToken = UUID() + var state = WindowPreviewHoverState() + _ = state.beginIconHover( + itemID: UUID(), + anchor: anchor, + sessionID: sessionID, + openToken: openToken + ) + _ = state.openDelayElapsed(token: openToken) + + let didPresent = state.completeDiscovery( + sessionID: sessionID, + windowCount: 0 + ) + #expect(!didPresent) + #expect(state.phase == .idle) + } + + @Test("External reset invalidates every callback") + func externalReset() { + let sessionID = UUID() + let openToken = UUID() + var state = WindowPreviewHoverState() + _ = state.beginIconHover( + itemID: UUID(), + anchor: anchor, + sessionID: sessionID, + openToken: openToken + ) + state.reset() + + let acceptedDiscovery = state.completeDiscovery( + sessionID: sessionID, + windowCount: 1 + ) + #expect(!acceptedDiscovery) + let acceptedTimer = state.openDelayElapsed(token: openToken) + #expect(!acceptedTimer) + #expect(state == WindowPreviewHoverState()) + } + + private var anchor: CGRect { + CGRect(x: 100, y: 20, width: 48, height: 48) + } + + private func presentedState( + sessionID: UUID, + itemID: UUID = UUID() + ) -> WindowPreviewHoverState { + let openToken = UUID() + var state = WindowPreviewHoverState() + _ = state.beginIconHover( + itemID: itemID, + anchor: anchor, + sessionID: sessionID, + openToken: openToken + ) + _ = state.completeDiscovery( + sessionID: sessionID, + windowCount: 1 + ) + _ = state.openDelayElapsed(token: openToken) + return state + } +} diff --git a/Tests/FreeDockTests/WindowPreviewKeyboardEventPolicyTests.swift b/Tests/FreeDockTests/WindowPreviewKeyboardEventPolicyTests.swift new file mode 100644 index 0000000..dbc60fa --- /dev/null +++ b/Tests/FreeDockTests/WindowPreviewKeyboardEventPolicyTests.swift @@ -0,0 +1,71 @@ +import AppKit +import Carbon.HIToolbox +import Testing + +@testable import FreeDock + +@Suite("Window preview keyboard event policy") +struct WindowPreviewKeyboardEventPolicyTests { + @Test("Arrow and Tab keys navigate in both directions") + func navigationKeys() { + #expect(action(kVK_LeftArrow) == .previous) + #expect(action(kVK_UpArrow) == .previous) + #expect(action(kVK_RightArrow) == .next) + #expect(action(kVK_DownArrow) == .next) + #expect(action(kVK_Tab) == .next) + #expect( + action(kVK_Tab, modifiers: [.shift]) + == .previous + ) + } + + @Test("Return, keypad Enter, and Space activate") + func activationKeys() { + #expect(action(kVK_Return) == .activate) + #expect(action(kVK_ANSI_KeypadEnter) == .activate) + #expect(action(kVK_Space) == .activate) + } + + @Test("Escape closes even when a modifier is held") + func escapeCloses() { + #expect(action(kVK_Escape) == .close) + #expect( + action(kVK_Escape, modifiers: [.command]) + == .close + ) + } + + @Test("Command, Control, and Option shortcuts pass through") + func systemShortcutsPassThrough() { + for modifiers: NSEvent.ModifierFlags in [ + [.command], + [.control], + [.option], + [.control, .option], + ] { + #expect( + action(kVK_RightArrow, modifiers: modifiers) + == nil + ) + #expect( + action(kVK_Return, modifiers: modifiers) + == nil + ) + } + } + + @Test("Unrelated keys pass through") + func unrelatedKeysPassThrough() { + #expect(action(kVK_ANSI_A) == nil) + } + + private func action( + _ keyCode: Int, + modifiers: NSEvent.ModifierFlags = [] + ) -> WindowPreviewKeyboardAction? { + WindowPreviewKeyboardEventPolicy.action( + keyCode: UInt16(keyCode), + modifierFlags: modifiers + ) + } +} diff --git a/Tests/FreeDockTests/WindowPreviewKeyboardSelectionTests.swift b/Tests/FreeDockTests/WindowPreviewKeyboardSelectionTests.swift new file mode 100644 index 0000000..42a6876 --- /dev/null +++ b/Tests/FreeDockTests/WindowPreviewKeyboardSelectionTests.swift @@ -0,0 +1,162 @@ +import Foundation +import Testing + +@testable import FreeDock + +@Suite("Window preview keyboard selection") +struct WindowPreviewKeyboardSelectionTests { + @Test("Initial selection prefers focused, then main, then first") + func initialPreference() { + let first = window() + let main = window(isMain: true) + let focused = window(isFocused: true) + + #expect( + WindowPreviewKeyboardSelection( + windows: [first, main, focused] + ).selectedWindowID == focused.id + ) + #expect( + WindowPreviewKeyboardSelection( + windows: [first, main] + ).selectedWindowID == main.id + ) + #expect( + WindowPreviewKeyboardSelection( + windows: [first] + ).selectedWindowID == first.id + ) + #expect( + WindowPreviewKeyboardSelection().selectedWindowID == nil + ) + } + + @Test("Refresh and reorder preserve the selected identity") + func refreshPreservesIdentity() { + let first = window(isFocused: true) + let second = window() + let third = window() + var selection = WindowPreviewKeyboardSelection( + windows: [first, second, third] + ) + selection.selectNext(in: [first, second, third]) + + selection.update(windows: [third, second, first]) + + #expect(selection.selectedWindowID == second.id) + #expect(selection.returnTargetID == second.id) + #expect(selection.showsFocusIndicator) + } + + @Test("A removed selection recovers using current window preference") + func removedSelectionRecovers() { + let first = window(isFocused: true) + let removed = window() + let fallbackFirst = window() + let fallbackMain = window(isMain: true) + var selection = WindowPreviewKeyboardSelection( + windows: [first, removed] + ) + selection.selectNext(in: [first, removed]) + #expect(selection.selectedWindowID == removed.id) + + selection.update( + windows: [fallbackFirst, fallbackMain, first] + ) + + #expect(selection.selectedWindowID == first.id) + #expect(selection.showsFocusIndicator) + + selection.update(windows: [fallbackFirst, fallbackMain]) + #expect(selection.selectedWindowID == fallbackMain.id) + #expect(selection.showsFocusIndicator) + } + + @Test("Next selection wraps at both ends") + func nextWraps() { + let first = window(isFocused: true) + let second = window() + let third = window() + let windows = [first, second, third] + var selection = WindowPreviewKeyboardSelection( + windows: windows + ) + + selection.selectNext(in: windows) + #expect(selection.selectedWindowID == second.id) + selection.selectNext(in: windows) + #expect(selection.selectedWindowID == third.id) + selection.selectNext(in: windows) + #expect(selection.selectedWindowID == first.id) + } + + @Test("Previous selection wraps at both ends") + func previousWraps() { + let first = window(isFocused: true) + let second = window() + let third = window() + let windows = [first, second, third] + var selection = WindowPreviewKeyboardSelection( + windows: windows + ) + + selection.selectPrevious(in: windows) + #expect(selection.selectedWindowID == third.id) + selection.selectPrevious(in: windows) + #expect(selection.selectedWindowID == second.id) + } + + @Test("Focus indicator stays hidden until keyboard navigation") + func focusIndicatorVisibility() { + let first = window(isFocused: true) + let second = window() + var selection = WindowPreviewKeyboardSelection( + windows: [first, second] + ) + + #expect(selection.selectedWindowID == first.id) + #expect(!selection.showsFocusIndicator) + selection.update(windows: [second, first]) + #expect(!selection.showsFocusIndicator) + + selection.selectNext(in: [second, first]) + #expect(selection.showsFocusIndicator) + #expect(selection.returnTargetID == second.id) + } + + @Test("Empty refresh clears selection and keyboard focus") + func emptyRefreshClearsSelection() { + let only = window(isFocused: true) + var selection = WindowPreviewKeyboardSelection( + windows: [only] + ) + selection.selectNext(in: [only]) + #expect(selection.showsFocusIndicator) + + selection.update(windows: []) + + #expect(selection.selectedWindowID == nil) + #expect(selection.returnTargetID == nil) + #expect(!selection.showsFocusIndicator) + } + + private func window( + id: UUID = UUID(), + isMain: Bool = false, + isFocused: Bool = false + ) -> DockApplicationWindow { + DockApplicationWindow( + id: id, + applicationInstanceID: UUID(), + applicationName: "Example", + title: "Window", + captureWindowID: nil, + isMinimized: false, + isApplicationHidden: false, + isOnScreen: true, + canFocusExactly: true, + isMain: isMain, + isFocused: isFocused + ) + } +} diff --git a/Tests/FreeDockTests/WindowPreviewPanelGeometryTests.swift b/Tests/FreeDockTests/WindowPreviewPanelGeometryTests.swift new file mode 100644 index 0000000..16437cd --- /dev/null +++ b/Tests/FreeDockTests/WindowPreviewPanelGeometryTests.swift @@ -0,0 +1,181 @@ +import Foundation +import Testing +@testable import FreeDock + +@Test("Window previews open inward from every docked screen edge") +func windowPreviewPanelOpensInwardFromDockedEdges() { + let visibleFrame = CGRect(x: 0, y: 0, width: 1440, height: 900) + let size = CGSize(width: 320, height: 210) + + let bottom = WindowPreviewPanelGeometry.placement( + size: size, + sourceRect: CGRect(x: 690, y: 14, width: 60, height: 60), + dockFrame: CGRect(x: 520, y: 0, width: 400, height: 82), + visibleFrame: visibleFrame, + orientation: .horizontal, + dockedEdge: .bottom + ) + #expect(bottom.side == .above) + #expect(bottom.frame.minY >= 82) + + let top = WindowPreviewPanelGeometry.placement( + size: size, + sourceRect: CGRect(x: 690, y: 826, width: 60, height: 60), + dockFrame: CGRect(x: 520, y: 818, width: 400, height: 82), + visibleFrame: visibleFrame, + orientation: .horizontal, + dockedEdge: .top + ) + #expect(top.side == .below) + #expect(top.frame.maxY <= 826) + + let left = WindowPreviewPanelGeometry.placement( + size: size, + sourceRect: CGRect(x: 14, y: 420, width: 60, height: 60), + dockFrame: CGRect(x: 0, y: 250, width: 82, height: 400), + visibleFrame: visibleFrame, + orientation: .vertical, + dockedEdge: .left + ) + #expect(left.side == .right) + #expect(left.frame.minX >= 82) + + let right = WindowPreviewPanelGeometry.placement( + size: size, + sourceRect: CGRect(x: 1366, y: 420, width: 60, height: 60), + dockFrame: CGRect(x: 1358, y: 250, width: 82, height: 400), + visibleFrame: visibleFrame, + orientation: .vertical, + dockedEdge: .right + ) + #expect(right.side == .left) + #expect(right.frame.maxX <= 1366) +} + +@Test("Window previews flip when the inward side has less capacity") +func windowPreviewPanelFlipsToRoomierSide() { + let visibleFrame = CGRect(x: 0, y: 0, width: 1000, height: 700) + let sourceRect = CGRect(x: 470, y: 610, width: 60, height: 60) + + let placement = WindowPreviewPanelGeometry.placement( + size: CGSize(width: 420, height: 260), + sourceRect: sourceRect, + dockFrame: CGRect(x: 300, y: 590, width: 400, height: 90), + visibleFrame: visibleFrame, + orientation: .horizontal, + dockedEdge: .bottom + ) + + #expect(placement.side == .below) + #expect(placement.frame.maxY <= sourceRect.minY - 8) +} + +@Test("Window previews stay bounded on negative-origin displays") +func windowPreviewPanelHandlesNegativeDisplayOrigins() { + let visibleFrame = CGRect( + x: -1600, + y: -900, + width: 1600, + height: 900 + ) + let availableFrame = visibleFrame.insetBy(dx: 8, dy: 8) + let sourceRect = CGRect(x: -1586, y: -490, width: 58, height: 58) + + let placement = WindowPreviewPanelGeometry.placement( + size: CGSize(width: 500, height: 420), + sourceRect: sourceRect, + dockFrame: CGRect(x: -1600, y: -620, width: 82, height: 360), + visibleFrame: visibleFrame, + orientation: .vertical, + dockedEdge: .left + ) + + #expect(placement.side == .right) + #expect(availableFrame.contains(placement.frame)) + #expect(placement.frame.minX >= sourceRect.maxX + 8) +} + +@Test("Oversized window previews are bounded to the visible frame") +func oversizedWindowPreviewPanelIsBounded() { + let visibleFrame = CGRect(x: 50, y: 30, width: 420, height: 280) + let availableFrame = visibleFrame.insetBy(dx: 8, dy: 8) + + let placement = WindowPreviewPanelGeometry.placement( + size: CGSize(width: 900, height: 700), + sourceRect: CGRect(x: 225, y: 35, width: 70, height: 55), + dockFrame: CGRect(x: 120, y: 30, width: 280, height: 65), + visibleFrame: visibleFrame, + orientation: .horizontal, + dockedEdge: .bottom + ) + + #expect(placement.frame.width == availableFrame.width) + #expect(placement.frame.height <= availableFrame.height) + #expect(availableFrame.contains(placement.frame)) +} + +@Test("Compact previews shrink to avoid covering their source") +func compactWindowPreviewPanelAvoidsSource() { + let visibleFrame = CGRect(x: 0, y: 0, width: 360, height: 240) + let availableFrame = visibleFrame.insetBy(dx: 8, dy: 8) + let sourceRect = CGRect(x: 150, y: 10, width: 60, height: 45) + + let placement = WindowPreviewPanelGeometry.placement( + size: CGSize(width: 500, height: 300), + sourceRect: sourceRect, + dockFrame: CGRect(x: 70, y: 0, width: 220, height: 60), + visibleFrame: visibleFrame, + orientation: .horizontal, + dockedEdge: .bottom + ) + + #expect(placement.side == .above) + #expect(placement.frame.width == availableFrame.width) + #expect(placement.frame.minY >= sourceRect.maxY + 8) + #expect(!placement.frame.intersects(sourceRect)) + #expect(availableFrame.contains(placement.frame)) +} + +@Test("Zero source rectangles anchor previews at the inward dock boundary") +func zeroSourceWindowPreviewUsesDockBoundary() { + let visibleFrame = CGRect(x: 0, y: 0, width: 1200, height: 800) + let dockFrame = CGRect(x: 400, y: 0, width: 400, height: 74) + + let placement = WindowPreviewPanelGeometry.placement( + size: CGSize(width: 320, height: 210), + sourceRect: .zero, + dockFrame: dockFrame, + visibleFrame: visibleFrame, + orientation: .horizontal, + dockedEdge: .bottom + ) + + #expect(placement.side == .above) + #expect(placement.frame.minY == dockFrame.maxY + 8) + #expect(placement.frame.midX == dockFrame.midX) +} + +@Test("Floating docks infer preview direction from their orientation and position") +func floatingWindowPreviewInfersDirection() { + let visibleFrame = CGRect(x: 0, y: 0, width: 1200, height: 800) + + let horizontal = WindowPreviewPanelGeometry.placement( + size: CGSize(width: 320, height: 210), + sourceRect: CGRect(x: 570, y: 150, width: 60, height: 60), + dockFrame: CGRect(x: 400, y: 140, width: 400, height: 80), + visibleFrame: visibleFrame, + orientation: .horizontal, + dockedEdge: nil + ) + #expect(horizontal.side == .above) + + let vertical = WindowPreviewPanelGeometry.placement( + size: CGSize(width: 320, height: 210), + sourceRect: CGRect(x: 1020, y: 370, width: 60, height: 60), + dockFrame: CGRect(x: 1010, y: 220, width: 80, height: 360), + visibleFrame: visibleFrame, + orientation: .vertical, + dockedEdge: nil + ) + #expect(vertical.side == .left) +} diff --git a/Tests/FreeDockTests/WindowPreviewPanelStateTests.swift b/Tests/FreeDockTests/WindowPreviewPanelStateTests.swift new file mode 100644 index 0000000..573090d --- /dev/null +++ b/Tests/FreeDockTests/WindowPreviewPanelStateTests.swift @@ -0,0 +1,176 @@ +import Foundation +import Testing +@testable import FreeDock + +@Suite("Window preview panel state") +struct WindowPreviewPanelStateTests { + @Test("Ready queries with cards are accepted") + func readyQueryIsAccepted() { + #expect( + WindowPreviewPanelQueryPolicy.decision( + for: .ready, + windowCount: 2, + retryAttempt: 0 + ) == .accept + ) + } + + @Test("Transient queries retry with bounded backoff") + func transientQueryRetries() { + #expect( + WindowPreviewPanelQueryPolicy.decision( + for: .temporarilyUnavailable, + windowCount: 0, + retryAttempt: 0 + ) == .retry(afterNanoseconds: 250_000_000) + ) + #expect( + WindowPreviewPanelQueryPolicy.decision( + for: .temporarilyUnavailable, + windowCount: 0, + retryAttempt: 1 + ) == .retry(afterNanoseconds: 500_000_000) + ) + #expect( + WindowPreviewPanelQueryPolicy.decision( + for: .temporarilyUnavailable, + windowCount: 0, + retryAttempt: 2 + ) == .retry(afterNanoseconds: 1_000_000_000) + ) + #expect( + WindowPreviewPanelQueryPolicy.decision( + for: .temporarilyUnavailable, + windowCount: 0, + retryAttempt: 20 + ) == .retry(afterNanoseconds: 1_500_000_000) + ) + } + + @Test("Terminal and empty queries close") + func terminalQueriesClose() { + #expect( + WindowPreviewPanelQueryPolicy.decision( + for: .ready, + windowCount: 0, + retryAttempt: 0 + ) == .close + ) + #expect( + WindowPreviewPanelQueryPolicy.decision( + for: .permissionRequired, + windowCount: 2, + retryAttempt: 0 + ) == .close + ) + #expect( + WindowPreviewPanelQueryPolicy.decision( + for: .applicationNotRunning, + windowCount: 2, + retryAttempt: 0 + ) == .close + ) + } + + @Test("A queued global click only closes its original session") + func globalClickIsSessionSafe() { + let monitoredSessionID = UUID() + + #expect( + WindowPreviewPanelEventPolicy + .shouldCloseForGlobalClick( + monitoredSessionID: monitoredSessionID, + currentSessionID: monitoredSessionID + ) + ) + #expect( + !WindowPreviewPanelEventPolicy + .shouldCloseForGlobalClick( + monitoredSessionID: monitoredSessionID, + currentSessionID: UUID() + ) + ) + #expect( + !WindowPreviewPanelEventPolicy + .shouldCloseForGlobalClick( + monitoredSessionID: monitoredSessionID, + currentSessionID: nil + ) + ) + #expect( + !WindowPreviewPanelEventPolicy + .shouldCloseForGlobalClick( + monitoredSessionID: nil, + currentSessionID: monitoredSessionID + ) + ) + } + + @Test("Panel sizing fits card labels and caps visible columns") + func preferredPanelSizing() { + let empty = WindowPreviewPanelSizing.preferredSize( + windowCount: 0 + ) + let single = WindowPreviewPanelSizing.preferredSize( + windowCount: 1 + ) + let three = WindowPreviewPanelSizing.preferredSize( + windowCount: 3 + ) + let many = WindowPreviewPanelSizing.preferredSize( + windowCount: 12 + ) + + #expect(empty == single) + #expect(single == CGSize(width: 202, height: 194)) + #expect(three == CGSize(width: 566, height: 194)) + #expect(many == three) + } + + @Test("Thumbnail states distinguish permission, progress, and failure") + func thumbnailStates() { + #expect( + thumbnailState( + hasThumbnail: true, + isCaptureEnabled: false + ) == .permissionRequired + ) + #expect( + thumbnailState( + hasThumbnail: true + ) == .available + ) + #expect( + thumbnailState( + isLoading: true + ) == .loading + ) + #expect( + thumbnailState( + isUnavailable: true + ) == .unavailable + ) + #expect( + thumbnailState( + hasCaptureWindow: false + ) == .unavailable + ) + #expect(thumbnailState() == .loading) + } + + private func thumbnailState( + hasThumbnail: Bool = false, + isCaptureEnabled: Bool = true, + isLoading: Bool = false, + isUnavailable: Bool = false, + hasCaptureWindow: Bool = true + ) -> WindowPreviewThumbnailState { + WindowPreviewThumbnailState.resolve( + hasThumbnail: hasThumbnail, + isCaptureEnabled: isCaptureEnabled, + isLoading: isLoading, + isUnavailable: isUnavailable, + hasCaptureWindow: hasCaptureWindow + ) + } +} diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md new file mode 100644 index 0000000..7808cfe --- /dev/null +++ b/docs/ROADMAP.md @@ -0,0 +1,34 @@ +# FreeDock roadmap + +FreeDock is free and MIT-licensed. This roadmap is a guide for building a better native macOS dock with the community; none of these capabilities are planned as paid-only features. + +## Current foundation + +- Floating horizontal and vertical docks +- Drag-and-drop pinning and mixed reordering for applications, documents, folders, and separators +- Cross-dock item moves and Option-copy with metadata-preserving duplicate protection +- Live folder stacks with automatic, grid, and list browsing; configurable sorting and hidden-file visibility +- Recent Files and Downloads smart stacks, with a local, clearable 50-document history and dynamic Downloads resolution +- Native magnification, running-app indicators, tooltips, and launch animation +- Cross-desktop window discovery and switching for running apps, with title/icon fallbacks and optional memory-only window thumbnails using Screen Recording access +- Native context menus for app lifecycle actions, document Open With, and direct stack configuration +- Direct Finder file and folder drops onto pinned applications +- Edge snapping and slide-away auto-hide, configurable per dock +- Persisted dock positions and size +- Saved dock profiles for activities such as Work, Design, and Focus +- Global shortcuts to reveal docks and switch profiles +- Quick Launch search and keyboard navigation for the nearest dock +- A dedicated preferences window for per-dock behavior, appearance, and live Accessibility and Screen Recording status +- Optional, append-only import of pinned apps from the macOS Dock +- Stable per-display dock placement with disconnect and reconnect recovery + +## Quality bar + +- VoiceOver, keyboard navigation, and reduced-motion support +- Predictable multi-display behavior and safe config migrations +- Signed, notarized releases and an opt-in update path +- Small, focused issues that welcome first-time contributors + +## How to help + +Read [CONTRIBUTING.md](../CONTRIBUTING.md), open a focused issue or pull request, and keep macOS-native behavior and accessibility in mind. User feedback about day-to-day workflows is especially valuable. diff --git a/scripts/build.sh b/scripts/build.sh index 8d8926d..6207232 100755 --- a/scripts/build.sh +++ b/scripts/build.sh @@ -53,11 +53,31 @@ cat > "$APP_BUNDLE/Contents/Info.plist" <1 CFBundleShortVersionString 1.0.0 + NSScreenCaptureUsageDescription + FreeDock uses Screen Recording only to create temporary window previews while the dock switcher is open. + UTExportedTypeDeclarations + + + UTTypeConformsTo + + public.data + + UTTypeDescription + FreeDock dock item drag + UTTypeIdentifier + com.freedock.dock-item + + LSUIElement EOF +# SwiftPM ad-hoc signs the standalone executable. Copying it into a bundle with +# resources changes its signing context, so sign the completed app bundle before +# Launch Services opens it. +codesign --force --sign - "$APP_BUNDLE" + echo "==> App bundle created at $APP_BUNDLE" echo " Open with: open \"$APP_BUNDLE\""