diff --git a/Makefile b/Makefile
index 5a27e6a..0cc705b 100644
--- a/Makefile
+++ b/Makefile
@@ -1,4 +1,4 @@
-.PHONY: bundle run test clean release
+.PHONY: bundle run test test-reliability clean release
bundle:
./scripts/build.sh
@@ -11,6 +11,9 @@ run: bundle
test:
swift test
+test-reliability:
+ swift test --filter ReliabilityInteractionTests
+
release:
./scripts/build.sh --release
diff --git a/README.md b/README.md
index 00e63fc..6c33609 100644
--- a/README.md
+++ b/README.md
@@ -1,99 +1,100 @@
-
+
FreeDock
- Multiple floating docks for macOS.
+ A flexible, native dock for macOS.
- Free. Open-source. Native.
+ Create multiple docks, organize them into profiles, and make your workspace fit the way you work.
-
+
-
+
-
+
-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.
-
----
-
-
-
-
-
-
-
-
-
+ Build FreeDock
+ ·
+ Roadmap
+ ·
+ Contribute
+ ·
+ Support the project
-
-
-
-
-
- If FreeDock saves you time, consider supporting my education. ☕
-
+FreeDock is a free and open-source alternative for people who want more than one macOS Dock. Keep a compact launcher on each display, separate work and personal apps into profiles, pin files and folders beside applications, or build a focused dock for one project.
----
-
-## Features
-
-- **Unlimited docks** — Create as many docks as you need, on any screen
-- **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
-- **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
+It is written in SwiftUI and AppKit, stores its configuration locally, and does not require an account or subscription.
+
+## Preview
+
+### Appearance that adapts per dock
-
-
-
+
+### Window switching across your workspace
+
-
+
-
+## Highlights
-## Installation
+### Docks that fit your workspace
-Download the latest `FreeDock.app` from the [Releases](https://github.com/Rubyherp/FreeDock/releases) page (when available), then drag it to your Applications folder.
+- Create unlimited horizontal or vertical docks.
+- Place docks on any connected display and restore their relative position after reconnecting.
+- Resize, magnify, lock, snap, and auto-hide each dock independently.
+- Customize blur, opacity, spacing, corners, shadows, indicators, and reusable appearance themes.
-### Build from source
+### More than an app launcher
+
+- Pin applications, documents, folders, separators, smart stacks, and Trash.
+- Reorder items within a dock or drag them between docks; hold Option to copy.
+- Browse live folder stacks in grid or list layouts.
+- Add dynamic Recent Files and Downloads stacks.
+- Drop Finder files onto compatible applications or Trash.
+
+### Profiles and fast navigation
+
+- Keep separate Work, Focus, Personal, or project-specific dock profiles.
+- Switch profiles manually, with global shortcuts, when an app becomes active, or when a display connects.
+- Use Quick Launch (`⌘⇧Space` by default) to search the nearest dock entirely from the keyboard.
+- Export or import a complete configuration or one portable profile.
+
+### Native macOS behavior
+
+- See running indicators and optional recent/running applications.
+- Preview and switch between an app’s windows across displays and Desktops.
+- Use native context menus for application, document, folder, and Trash actions.
+- Honor Reduce Motion, Increased Contrast, VoiceOver, and keyboard navigation.
+- Optionally launch FreeDock when you sign in.
+
+### Built for reliability
+
+- Save configuration atomically to `~/.config/freedock.json`.
+- Keep three rotating recovery generations and automatically recover from damaged configuration files.
+- Undo destructive dock, profile, theme, history, and item actions during the current session.
+- Run focused interaction regression tests for hide/reveal, resizing, reordering, Trash, and multi-display recovery.
+
+## Build from source
+
+Requirements:
+
+- macOS 12 or later
+- Xcode 16 or later with Swift 6
+- Apple Silicon or Intel Mac
```bash
git clone https://github.com/Rubyherp/FreeDock.git
@@ -101,172 +102,87 @@ cd FreeDock
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 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, 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
-{
- "formatVersion": 4,
- "activeProfileID": "11111111-1111-1111-1111-111111111111",
- "recentFiles": [
- {
- "path": "/Users/example/Documents/Project Brief.pdf",
- "displayName": "Project Brief.pdf",
- "lastOpenedAt": 806976000
- }
- ],
- "profiles": [
- {
- "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
- }
- ]
- }
- ]
- }
- ]
-}
+`make run` builds a proper menu-bar `.app` bundle at `build/FreeDock.app` and opens it. Avoid `swift run` for normal use: it launches the executable without the bundle metadata FreeDock needs for menu-bar agent behavior.
+
+Other useful commands:
+
+```bash
+make test # Run the complete test suite
+make test-reliability # Run high-risk interaction scenarios
+make bundle # Build without opening the app
+make release # Build an optimized, ad-hoc-signed app
+```
+
+## macOS security notice
+
+FreeDock’s local builds are ad-hoc signed because the project does not currently use a paid Apple Developer account. macOS may warn that the developer cannot be verified, and Accessibility or Screen Recording permission may need to be granted again after rebuilding the executable.
+
+This does not affect the source code or MIT license, but it is less convenient than a signed and notarized release. Review the source and build it locally if you prefer.
+
+## Getting started
+
+1. Run FreeDock and open its grid icon in the menu bar.
+2. Choose **New Dock → Horizontal** or **Vertical**.
+3. Drag applications, files, or folders from Finder into the dock.
+4. Right-click dock items for additional actions and customization.
+5. Open **Preferences** to configure profiles, displays, appearance, shortcuts, automation, backups, and permissions.
+6. Use **Lock Dock Positions** after arranging your workspace.
+
+The welcome guide appears on a fresh install and can be reopened from the FreeDock menu.
+
+## Permissions and privacy
+
+FreeDock works as a launcher without optional macOS permissions.
+
+| Permission | Used for | Required? |
+| --- | --- | --- |
+| Accessibility | Discovering and focusing application windows | Only for window switching |
+| Screen Recording | Creating live window thumbnails | No; title and app-icon cards remain available |
+
+Window discovery and focus happen locally. Thumbnails are held only in a small memory cache and are never written to disk. Recent Files records only documents successfully opened through FreeDock, stores at most 50 local paths, and can be cleared at any time.
+
+FreeDock does not include analytics, advertising, online accounts, or feature paywalls.
+
+## Configuration and recovery
+
+Profiles, docks, appearance themes, shortcuts, automation rules, custom item artwork, and local recent history are stored in the human-readable file:
+
+```text
+~/.config/freedock.json
+```
+
+Before replacing a valid configuration, FreeDock rotates these recovery files:
+
+```text
+freedock.json.bak
+freedock.json.bak.1
+freedock.json.bak.2
```
-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.
+If the primary file is damaged, FreeDock loads the newest valid recovery generation and repairs the primary file. A previous version can also be restored from **Preferences → Backup & Restore**.
-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.
+## Project status
+
+FreeDock is actively developed. The core dock, profile, organization, window-switching, appearance, backup, and accessibility features are implemented and covered by automated tests. See the [roadmap](docs/ROADMAP.md) for planned work and areas where contributors can help.
## Contributing
-Contributions are welcome! Please read `CONTRIBUTING.md` before opening a PR.
+Contributions, bug reports, design feedback, documentation improvements, and focused feature proposals are welcome. Start with [CONTRIBUTING.md](CONTRIBUTING.md), follow the [Code of Conduct](CODE_OF_CONDUCT.md), and use the issue templates when reporting a problem or suggesting a feature.
-## Security
+Please report security vulnerabilities privately using [SECURITY.md](SECURITY.md).
-To report a vulnerability, please follow `SECURITY.md` and avoid public issue disclosure.
+## Support FreeDock
-## Code of Conduct
+FreeDock is MIT-licensed and every feature remains free. If the project is useful to you, you can support its continued development and the creator’s education.
-Please read `CODE_OF_CONDUCT.md` before participating.
+
+
+
+
+
+ Buy me a coffee
+
## License
-MIT
+FreeDock is available under the [MIT License](LICENSE).
diff --git a/Sources/FreeDock/AppDelegate.swift b/Sources/FreeDock/AppDelegate.swift
index 300a498..cf44d18 100644
--- a/Sources/FreeDock/AppDelegate.swift
+++ b/Sources/FreeDock/AppDelegate.swift
@@ -1,11 +1,14 @@
import Cocoa
+import Carbon.HIToolbox
import SwiftUI
+import UniformTypeIdentifiers
@MainActor
class AppDelegate: NSObject, NSApplicationDelegate {
private var statusItem: NSStatusItem?
private var dockPanels: [UUID: DockPanel] = [:]
private let shortcutManager = GlobalShortcutManager()
+ private let actionUndoManager = UndoManager()
private let configManager = ConfigManager(
configPath: FileManager.default.homeDirectoryForCurrentUser
.appendingPathComponent(".config/freedock.json")
@@ -15,6 +18,7 @@ class AppDelegate: NSObject, NSApplicationDelegate {
private var dockStates: [UUID: DockState] = [:]
private var preferencesStore: DockPreferencesStore?
private var preferencesWindowController: PreferencesWindowController?
+ private var onboardingWindowController: OnboardingWindowController?
private var menuRefreshWorkItem: DispatchWorkItem?
private var dockResizeWorkItems: [UUID: DispatchWorkItem] = [:]
private var liveDockResizeWorkItems: [UUID: DispatchWorkItem] = [:]
@@ -22,6 +26,8 @@ class AppDelegate: NSObject, NSApplicationDelegate {
private var activeDockResizeIDs: Set = []
private var pendingAutoHideUpdates: [UUID: (enabled: Bool, orientation: Orientation)] = [:]
private var screenParametersWorkItem: DispatchWorkItem?
+ private var registeredShortcutSettings: GlobalShortcutSettings?
+ private var observesProfileAutomation = false
private var runtimeDisplayStates: [UUID: RuntimeDisplayState] = [:]
private var folderStackController: FolderStackPanelController?
private let windowPreviewController =
@@ -30,6 +36,8 @@ class AppDelegate: NSObject, NSApplicationDelegate {
private var quickLaunchResizeWorkItem: DispatchWorkItem?
private var hideRestoredDocksAfterFolderStack = false
private var itemDragInteractionTokens: [UUID: UUID] = [:]
+ private static let onboardingCompletionKey =
+ "FreeDockHasCompletedOnboardingV1"
private struct QuickLaunchSession {
let dockID: UUID
@@ -61,6 +69,12 @@ class AppDelegate: NSObject, NSApplicationDelegate {
}
func applicationDidFinishLaunching(_: Notification) {
+ let shouldPresentOnboarding = OnboardingLaunchPolicy.shouldPresent(
+ loadedConfigurationFromDisk: configManager.loadedFromDisk,
+ hasCompletedOnboarding: UserDefaults.standard.bool(
+ forKey: Self.onboardingCompletionKey
+ )
+ )
itemDragCoordinator.onSessionBegan = { [weak self] _ in
self?.beginDockItemDragInteraction()
}
@@ -81,12 +95,32 @@ class AppDelegate: NSObject, NSApplicationDelegate {
NSApp.applicationIconImage = icon
}
+ configureRecentApplicationTracking()
+ configureProfileAutomation()
+
+ let recoverySource = configManager.loadSource
+ if case .backup = recoverySource {
+ configManager.saveImmediately()
+ }
+
restoreDocks()
if !configManager.loadedFromDisk, configManager.config.docks.isEmpty {
createInitialSeededDock()
}
rebuildMenu()
+ DispatchQueue.main.async { [weak self] in
+ self?.evaluateConnectedDisplayAutomation()
+ }
NSApp.activate(ignoringOtherApps: true)
+ if shouldPresentOnboarding {
+ DispatchQueue.main.async { [weak self] in
+ self?.presentOnboarding()
+ }
+ } else if case let .backup(generation) = recoverySource {
+ DispatchQueue.main.async { [weak self] in
+ self?.showAutomaticRecoveryNotice(generation: generation)
+ }
+ }
}
func applicationWillTerminate(_: Notification) {
@@ -97,6 +131,12 @@ class AppDelegate: NSObject, NSApplicationDelegate {
closeFolderStack()
saveAllPositions()
configManager.saveImmediately()
+ NSWorkspace.shared.notificationCenter.removeObserver(self)
+ }
+
+ func applicationDidBecomeActive(_: Notification) {
+ windowPreviewController.reconcilePermissions()
+ preferencesStore?.refreshPermissions()
}
func applicationDidChangeScreenParameters(_: Notification) {
@@ -109,26 +149,32 @@ class AppDelegate: NSObject, NSApplicationDelegate {
screenParametersWorkItem?.cancel()
let work = DispatchWorkItem { [weak self] in
self?.reconcileDisplayTopology()
+ self?.evaluateConnectedDisplayAutomation()
}
screenParametersWorkItem = work
DispatchQueue.main.asyncAfter(deadline: .now() + 0.18, execute: work)
}
@objc func rebuildMenu() {
+ let performanceInterval = PerformanceTrace.begin("MenuRebuild")
+ defer { PerformanceTrace.end(performanceInterval) }
let menu = NSMenu()
let profileMenu = NSMenu()
- for (index, profile) in configManager.config.profiles.enumerated() {
+ for profile in configManager.config.profiles {
let profileItem = NSMenuItem(
title: profile.name,
action: #selector(switchProfile(_:)),
- keyEquivalent: index < 9 ? "\(index + 1)" : ""
+ keyEquivalent: ""
)
profileItem.representedObject = profile.id
profileItem.state = profile.id == configManager.config.activeProfileID ? .on : .off
- if index < 9 {
- profileItem.keyEquivalentModifierMask = [.control, .option]
- }
+ configureMenuShortcut(
+ profileItem,
+ shortcut: configManager.config.globalShortcuts.shortcut(
+ forProfile: profile.id
+ )
+ )
profileMenu.addItem(profileItem)
}
profileMenu.addItem(.separator())
@@ -161,18 +207,24 @@ class AppDelegate: NSObject, NSApplicationDelegate {
let toggleProfileItem = NSMenuItem(
title: dockPanels.isEmpty ? "Show Active Profile" : "Hide Active Profile",
action: #selector(toggleActiveProfileDocks),
- keyEquivalent: " "
+ keyEquivalent: ""
+ )
+ configureMenuShortcut(
+ toggleProfileItem,
+ shortcut: configManager.config.globalShortcuts.showHideDocks
)
- toggleProfileItem.keyEquivalentModifierMask = [.control, .option]
toggleProfileItem.isEnabled = !configManager.config.docks.isEmpty
menu.addItem(toggleProfileItem)
let quickLaunchItem = NSMenuItem(
title: "Quick Launch…",
action: #selector(toggleQuickLaunch),
- keyEquivalent: " "
+ keyEquivalent: ""
+ )
+ configureMenuShortcut(
+ quickLaunchItem,
+ shortcut: configManager.config.globalShortcuts.quickLaunch
)
- quickLaunchItem.keyEquivalentModifierMask = [.command, .shift]
quickLaunchItem.isEnabled = !configManager.config.docks.isEmpty
quickLaunchItem.target = self
menu.addItem(quickLaunchItem)
@@ -267,6 +319,20 @@ class AppDelegate: NSObject, NSApplicationDelegate {
menu.addItem(removeItem)
menu.addItem(.separator())
+ let welcomeItem = NSMenuItem(
+ title: "Welcome to FreeDock…",
+ action: #selector(showOnboarding),
+ keyEquivalent: ""
+ )
+ welcomeItem.target = self
+ menu.addItem(welcomeItem)
+ let supportItem = NSMenuItem(
+ title: "Support FreeDock…",
+ action: #selector(openSupportPage),
+ keyEquivalent: ""
+ )
+ supportItem.target = self
+ menu.addItem(supportItem)
let preferencesItem = NSMenuItem(
title: "Preferences…",
action: #selector(showPreferences),
@@ -276,6 +342,19 @@ class AppDelegate: NSObject, NSApplicationDelegate {
preferencesItem.target = self
menu.addItem(preferencesItem)
+ let undoTitle = actionUndoManager.canUndo
+ ? "Undo \(actionUndoManager.undoActionName)"
+ : "Undo"
+ let undoItem = NSMenuItem(
+ title: undoTitle,
+ action: #selector(performUndo),
+ keyEquivalent: "z"
+ )
+ undoItem.keyEquivalentModifierMask = [.command]
+ undoItem.isEnabled = actionUndoManager.canUndo
+ undoItem.target = self
+ menu.addItem(undoItem)
+
menu.addItem(.separator())
let lockItem = NSMenuItem(title: "Lock Dock Positions", action: #selector(toggleLockPositions), keyEquivalent: "l")
lockItem.state = _lockPositions ? .on : .off
@@ -283,16 +362,149 @@ class AppDelegate: NSObject, NSApplicationDelegate {
menu.addItem(.separator())
menu.addItem(NSMenuItem(title: "Quit FreeDock", action: #selector(NSApplication.terminate(_:)), keyEquivalent: "q"))
statusItem?.menu = menu
- configureGlobalShortcuts()
+ configureGlobalShortcutsIfNeeded()
refreshPreferencesSnapshot()
}
+ private func configureMenuShortcut(
+ _ item: NSMenuItem,
+ shortcut: GlobalShortcut?
+ ) {
+ guard let shortcut,
+ let keyName = GlobalShortcut.keyName(for: shortcut.keyCode),
+ let keyEquivalent = menuKeyEquivalent(for: keyName)
+ else { return }
+
+ item.keyEquivalent = keyEquivalent
+ var modifiers: NSEvent.ModifierFlags = []
+ if shortcut.modifiers & UInt32(cmdKey) != 0 {
+ modifiers.insert(.command)
+ }
+ if shortcut.modifiers & UInt32(optionKey) != 0 {
+ modifiers.insert(.option)
+ }
+ if shortcut.modifiers & UInt32(controlKey) != 0 {
+ modifiers.insert(.control)
+ }
+ if shortcut.modifiers & UInt32(shiftKey) != 0 {
+ modifiers.insert(.shift)
+ }
+ item.keyEquivalentModifierMask = modifiers
+ }
+
+ private func menuKeyEquivalent(for keyName: String) -> String? {
+ if keyName.count == 1 {
+ return keyName.lowercased()
+ }
+ if keyName.hasPrefix("F"),
+ let number = Int(keyName.dropFirst()),
+ (1...12).contains(number),
+ let scalar = UnicodeScalar(0xF704 + number - 1)
+ {
+ return String(Character(scalar))
+ }
+
+ let specialKeys: [String: UInt32] = [
+ "Space": 0x20,
+ "Return": 0x0D,
+ "Tab": 0x09,
+ "Delete": 0x08,
+ "Forward Delete": 0xF728,
+ "Escape": 0x1B,
+ "←": 0xF702,
+ "→": 0xF703,
+ "↑": 0xF700,
+ "↓": 0xF701,
+ "Home": 0xF729,
+ "End": 0xF72B,
+ "Page Up": 0xF72C,
+ "Page Down": 0xF72D,
+ ]
+ guard let value = specialKeys[keyName],
+ let scalar = UnicodeScalar(value)
+ else { return nil }
+ return String(Character(scalar))
+ }
+
+ @objc private func showOnboarding() {
+ presentOnboarding()
+ }
+
+ @objc private func openSupportPage() {
+ guard let url = URL(
+ string: "https://www.buymeacoffee.com/thksalot"
+ ) else { return }
+ NSWorkspace.shared.open(url)
+ }
+
+ @objc private func performUndo() {
+ actionUndoManager.undo()
+ rebuildMenu()
+ }
+
+ private func registerUndo(
+ _ actionName: String,
+ action: @escaping (AppDelegate) -> Void
+ ) {
+ actionUndoManager.registerUndo(withTarget: self) { target in
+ action(target)
+ }
+ actionUndoManager.setActionName(actionName)
+ }
+
+ private func presentOnboarding() {
+ if let onboardingWindowController {
+ onboardingWindowController.show()
+ return
+ }
+ let controller = OnboardingWindowController(
+ requestAccessibility: { [weak self] in
+ guard let self else { return false }
+ return self.windowPreviewController.requestAccessibilityAccess()
+ || self.windowPreviewController.isAccessibilityTrusted
+ },
+ requestScreenRecording: { [weak self] in
+ guard let self else { return false }
+ return self.windowPreviewController.requestScreenCaptureAccess()
+ || self.windowPreviewController.isScreenCaptureTrusted
+ },
+ onFinish: { [weak self] openPreferences in
+ self?.finishOnboarding(openPreferences: openPreferences)
+ },
+ onClosed: { [weak self] in
+ self?.markOnboardingCompleted()
+ self?.onboardingWindowController = nil
+ }
+ )
+ onboardingWindowController = controller
+ controller.show()
+ }
+
+ private func finishOnboarding(openPreferences: Bool) {
+ markOnboardingCompleted()
+ let controller = onboardingWindowController
+ onboardingWindowController = nil
+ controller?.close()
+ if openPreferences {
+ showPreferences()
+ }
+ }
+
+ private func markOnboardingCompleted() {
+ UserDefaults.standard.set(
+ true,
+ forKey: Self.onboardingCompletionKey
+ )
+ }
+
@objc private func showPreferences() {
if preferencesStore == nil {
let store = DockPreferencesStore(
profiles: configManager.config.profiles,
activeProfileID: configManager.config.activeProfileID,
displays: DockDisplayManager.connectedDisplays,
+ globalShortcuts: configManager.config.globalShortcuts,
+ themes: configManager.config.themes,
onChange: { [weak self] dockID, change in
self?.applyDockPreference(change, to: dockID)
},
@@ -316,6 +528,18 @@ class AppDelegate: NSObject, NSApplicationDelegate {
onOpenPermissionSettings: {
[weak self] permission in
self?.openPermissionSettings(permission)
+ },
+ launchAtLoginSnapshot: {
+ LaunchAtLoginController.state
+ },
+ onLaunchAtLoginChange: { enabled in
+ LaunchAtLoginController.setEnabled(enabled)
+ },
+ onOpenLoginItemsSettings: {
+ LaunchAtLoginController.openSystemSettings()
+ },
+ onGlobalShortcutsChange: { [weak self] settings in
+ self?.applyGlobalShortcuts(settings)
}
)
preferencesStore = store
@@ -330,9 +554,12 @@ class AppDelegate: NSObject, NSApplicationDelegate {
preferencesStore?.reload(
profiles: configManager.config.profiles,
activeProfileID: configManager.config.activeProfileID,
- displays: DockDisplayManager.connectedDisplays
+ displays: DockDisplayManager.connectedDisplays,
+ globalShortcuts: configManager.config.globalShortcuts,
+ themes: configManager.config.themes
)
preferencesStore?.refreshPermissions()
+ preferencesStore?.refreshLaunchAtLogin()
}
private func handlePermissionAction(
@@ -397,8 +624,32 @@ class AppDelegate: NSObject, NSApplicationDelegate {
private func handleDockManagementAction(_ action: DockManagementAction) {
switch action {
+ case .undo:
+ performUndo()
+ case .exportConfiguration:
+ exportConfiguration()
+ case .importConfiguration:
+ importConfiguration()
+ case .restoreLastWorkingConfiguration:
+ confirmAndRestoreLastWorkingConfiguration()
+ case .exportActiveProfile:
+ exportActiveProfile()
+ case .importProfile:
+ importProfile()
case let .activateProfile(profileID):
activateProfile(profileID)
+ case let .addProfileApplicationAutomation(profileID):
+ chooseProfileAutomationApplication(for: profileID)
+ case let .addProfileDisplayAutomation(profileID, displayID):
+ addProfileDisplayAutomation(displayID, to: profileID)
+ case let .setProfileAutomationEnabled(profileID, ruleID, enabled):
+ setProfileAutomationEnabled(
+ enabled,
+ profileID: profileID,
+ ruleID: ruleID
+ )
+ case let .deleteProfileAutomation(profileID, ruleID):
+ deleteProfileAutomation(profileID: profileID, ruleID: ruleID)
case .createProfile:
createProfile()
case .renameActiveProfile:
@@ -419,6 +670,8 @@ class AppDelegate: NSObject, NSApplicationDelegate {
chooseDockItems(for: dockID)
case let .addSmartStack(dockID, source):
addSmartStack(source, to: dockID)
+ case let .addTrash(dockID):
+ addTrash(to: dockID)
case .clearRecentFiles:
confirmAndClearRecentFiles()
case let .importSystemDockApps(dockID):
@@ -427,6 +680,12 @@ class AppDelegate: NSObject, NSApplicationDelegate {
confirmAndResetDockSettings(dockID)
case let .copyDockSettingsToAll(dockID):
confirmAndCopyDockSettingsToAll(dockID)
+ case let .saveDockTheme(dockID):
+ promptToSaveDockTheme(from: dockID)
+ case let .applyDockTheme(themeID, dockID):
+ applyDockTheme(themeID, to: dockID)
+ case let .deleteDockTheme(themeID):
+ confirmAndDeleteDockTheme(themeID)
}
DispatchQueue.main.async { [weak self] in
@@ -434,6 +693,279 @@ class AppDelegate: NSObject, NSApplicationDelegate {
}
}
+ private func exportConfiguration() {
+ saveAllPositions()
+
+ let panel = NSSavePanel()
+ panel.title = "Export FreeDock Configuration"
+ panel.prompt = "Export"
+ panel.nameFieldStringValue = "FreeDock Configuration.json"
+ panel.allowedContentTypes = [.json]
+ panel.canCreateDirectories = true
+
+ guard panel.runModal() == .OK, let url = panel.url else { return }
+
+ do {
+ let data = try ConfigFileCodec.encode(configManager.config)
+ try data.write(to: url, options: .atomic)
+ } catch {
+ showConfigurationFileError(
+ title: "Couldn’t Export Configuration",
+ error: error
+ )
+ }
+ }
+
+ private func importConfiguration() {
+ let panel = NSOpenPanel()
+ panel.title = "Import FreeDock Configuration"
+ panel.prompt = "Choose"
+ panel.allowedContentTypes = [.json]
+ panel.allowsMultipleSelection = false
+ panel.canChooseDirectories = false
+
+ guard panel.runModal() == .OK, let url = panel.url else { return }
+
+ let imported: AppConfig
+ do {
+ imported = try ConfigFileCodec.decode(Data(contentsOf: url))
+ } catch {
+ showConfigurationFileError(
+ title: "Couldn’t Import Configuration",
+ error: error
+ )
+ return
+ }
+
+ let profileCount = imported.profiles.count
+ let dockCount = imported.profiles.reduce(0) { $0 + $1.docks.count }
+ let alert = NSAlert()
+ alert.icon = NSApp.applicationIconImage
+ alert.alertStyle = .warning
+ alert.messageText = "Replace the current FreeDock configuration?"
+ alert.informativeText = "This file contains \(profileCount) profile\(profileCount == 1 ? "" : "s") and \(dockCount) dock\(dockCount == 1 ? "" : "s"). Your current configuration will be kept in freedock.json.bak."
+ alert.addButton(withTitle: "Cancel")
+ let importButton = alert.addButton(withTitle: "Import")
+ importButton.hasDestructiveAction = true
+ guard alert.runModal() == .alertSecondButtonReturn else { return }
+
+ saveAllPositions()
+ configManager.saveImmediately()
+ closeAllDockPanels()
+ configManager.config = imported
+ configureRecentApplicationTracking()
+ configManager.saveImmediately()
+ restoreDocks()
+ rebuildMenu()
+ }
+
+ private func confirmAndRestoreLastWorkingConfiguration() {
+ guard configManager.hasRecoverableBackup else {
+ let alert = NSAlert()
+ alert.icon = NSApp.applicationIconImage
+ alert.alertStyle = .informational
+ alert.messageText = "No Recovery Backup Available"
+ alert.informativeText = "FreeDock creates recovery history after configuration changes. There is not a valid earlier version to restore yet."
+ alert.addButton(withTitle: "OK")
+ alert.runModal()
+ return
+ }
+
+ let alert = NSAlert()
+ alert.icon = NSApp.applicationIconImage
+ alert.alertStyle = .warning
+ alert.messageText = "Restore the last working configuration?"
+ alert.informativeText = "Profiles, docks, and settings will return to the newest valid automatic backup."
+ alert.addButton(withTitle: "Cancel")
+ alert.addButton(withTitle: "Restore")
+ guard alert.runModal() == .alertSecondButtonReturn else { return }
+
+ saveAllPositions()
+ closeAllDockPanels()
+ guard configManager.restoreLatestBackup() else {
+ restoreDocks()
+ return
+ }
+ configureRecentApplicationTracking()
+ configureProfileAutomation()
+ restoreDocks()
+ rebuildMenu()
+ }
+
+ private func showAutomaticRecoveryNotice(generation: Int) {
+ let alert = NSAlert()
+ alert.icon = NSApp.applicationIconImage
+ alert.alertStyle = .informational
+ alert.messageText = "FreeDock Recovered Your Configuration"
+ let version = generation == 0
+ ? "the newest automatic backup"
+ : "an earlier automatic backup"
+ alert.informativeText = "The main configuration file could not be read, so FreeDock restored \(version). Your docks and profiles are safe, and the damaged file has been repaired."
+ alert.addButton(withTitle: "OK")
+ alert.runModal()
+ }
+
+ private func exportActiveProfile() {
+ saveAllPositions()
+ guard let profile = configManager.config.profiles.first(where: {
+ $0.id == configManager.config.activeProfileID
+ }) else { return }
+
+ let panel = NSSavePanel()
+ panel.title = "Export FreeDock Profile"
+ panel.prompt = "Export"
+ panel.nameFieldStringValue = "\(profile.name) FreeDock Profile.json"
+ panel.allowedContentTypes = [.json]
+ panel.canCreateDirectories = true
+ guard panel.runModal() == .OK, let url = panel.url else { return }
+
+ do {
+ let archive = DockProfileArchive(
+ profile: profile,
+ shortcut: configManager.config.globalShortcuts.shortcut(
+ forProfile: profile.id
+ )
+ )
+ try ProfileFileCodec.encode(archive).write(
+ to: url,
+ options: .atomic
+ )
+ } catch {
+ showConfigurationFileError(
+ title: "Couldn’t Export Profile",
+ error: error
+ )
+ }
+ }
+
+ private func importProfile() {
+ let panel = NSOpenPanel()
+ panel.title = "Import FreeDock Profile"
+ panel.prompt = "Import"
+ panel.allowedContentTypes = [.json]
+ panel.allowsMultipleSelection = false
+ panel.canChooseDirectories = false
+ guard panel.runModal() == .OK, let url = panel.url else { return }
+
+ let archive: DockProfileArchive
+ do {
+ archive = try ProfileFileCodec.decode(Data(contentsOf: url))
+ } catch {
+ showConfigurationFileError(
+ title: "Couldn’t Import Profile",
+ error: error
+ )
+ return
+ }
+
+ let name = uniqueImportedProfileName(archive.profile.name)
+ let imported = archive.materializedProfile(named: name)
+ saveAllPositions()
+ closeAllDockPanels()
+ configManager.config.profiles.append(imported)
+ configManager.config.globalShortcuts.reconcileProfiles(
+ configManager.config.profiles
+ )
+ if let shortcut = archive.shortcut {
+ var candidate = configManager.config.globalShortcuts
+ candidate.setProfileShortcut(shortcut, for: imported.id)
+ if candidate.validationError() == nil {
+ configManager.config.globalShortcuts = candidate
+ }
+ }
+ configManager.config.activeProfileID = imported.id
+ restoreDocks()
+ configManager.save()
+ rebuildMenu()
+ }
+
+ private func uniqueImportedProfileName(_ requestedName: String) -> String {
+ let trimmed = requestedName.trimmingCharacters(
+ in: .whitespacesAndNewlines
+ )
+ let base = trimmed.isEmpty ? "Imported Profile" : trimmed
+ let existing = Set(configManager.config.profiles.map {
+ $0.name.folding(
+ options: [.caseInsensitive, .diacriticInsensitive],
+ locale: .current
+ )
+ })
+ if !existing.contains(base.folding(
+ options: [.caseInsensitive, .diacriticInsensitive],
+ locale: .current
+ )) {
+ return base
+ }
+ var number = 2
+ while existing.contains("\(base) \(number)".folding(
+ options: [.caseInsensitive, .diacriticInsensitive],
+ locale: .current
+ )) {
+ number += 1
+ }
+ return "\(base) \(number)"
+ }
+
+ private func showConfigurationFileError(title: String, error: Error) {
+ let alert = NSAlert(error: error)
+ alert.icon = NSApp.applicationIconImage
+ alert.alertStyle = .warning
+ alert.messageText = title
+ alert.informativeText = error.localizedDescription
+ alert.runModal()
+ }
+
+ private func confirmAndEmptyTrash() {
+ guard !TrashMonitor.shared.isEmpty else { return }
+ let alert = NSAlert()
+ alert.icon = NSApp.applicationIconImage
+ alert.alertStyle = .warning
+ alert.messageText = "Empty Trash?"
+ alert.informativeText = "Items in Trash will be permanently deleted. This action cannot be undone."
+ alert.addButton(withTitle: "Cancel")
+ let emptyButton = alert.addButton(withTitle: "Empty Trash")
+ emptyButton.hasDestructiveAction = true
+ guard alert.runModal() == .alertSecondButtonReturn else { return }
+
+ Task { @MainActor in
+ let messages = await Task.detached(priority: .utility) {
+ TrashController.emptyTrash().map(\.localizedDescription)
+ }.value
+ TrashMonitor.shared.refresh()
+ guard let first = messages.first else { return }
+ showTrashError(
+ title: messages.count == 1
+ ? "An Item Couldn’t Be Deleted"
+ : "Some Items Couldn’t Be Deleted",
+ message: first
+ )
+ }
+ }
+
+ private func showTrashError(title: String, error: Error) {
+ showTrashError(title: title, message: error.localizedDescription)
+ }
+
+ private func showTrashError(title: String, message: String) {
+ let alert = NSAlert()
+ alert.icon = NSApp.applicationIconImage
+ alert.alertStyle = .warning
+ alert.messageText = title
+ alert.informativeText = message
+ alert.addButton(withTitle: "OK")
+ alert.runModal()
+ }
+
+ private func configureRecentApplicationTracking() {
+ RunningAppMonitor.shared.configureRecentApplications(
+ configManager.config.recentApplications
+ ) { [weak self] records in
+ guard let self else { return }
+ self.configManager.config.recentApplications = records
+ self.configManager.save()
+ }
+ }
+
private func scheduleMenuRefresh() {
menuRefreshWorkItem?.cancel()
let work = DispatchWorkItem { [weak self] in
@@ -884,6 +1416,141 @@ class AppDelegate: NSObject, NSApplicationDelegate {
rebuildMenu()
}
+ private func configureProfileAutomation() {
+ guard !observesProfileAutomation else { return }
+ observesProfileAutomation = true
+ NSWorkspace.shared.notificationCenter.addObserver(
+ self,
+ selector: #selector(applicationDidActivateForProfileAutomation(_:)),
+ name: NSWorkspace.didActivateApplicationNotification,
+ object: nil
+ )
+ }
+
+ @objc private func applicationDidActivateForProfileAutomation(
+ _ notification: Notification
+ ) {
+ guard let application = notification.userInfo?[
+ NSWorkspace.applicationUserInfoKey
+ ] as? NSRunningApplication,
+ application.processIdentifier
+ != ProcessInfo.processInfo.processIdentifier,
+ let bundleIdentifier = application.bundleIdentifier,
+ let profileID = ProfileAutomationPlanner.matchingProfileID(
+ triggerKind: .application,
+ value: bundleIdentifier,
+ profiles: configManager.config.profiles
+ )
+ else { return }
+ activateProfile(profileID)
+ }
+
+ private func evaluateConnectedDisplayAutomation() {
+ let displayIDs = DockDisplayManager.connectedDisplays.map {
+ $0.id.uuidString
+ }
+ for displayID in displayIDs {
+ if let profileID = ProfileAutomationPlanner.matchingProfileID(
+ triggerKind: .display,
+ value: displayID,
+ profiles: configManager.config.profiles
+ ) {
+ activateProfile(profileID)
+ return
+ }
+ }
+ }
+
+ private func chooseProfileAutomationApplication(for profileID: UUID) {
+ let panel = NSOpenPanel()
+ panel.title = "Choose an Application"
+ panel.prompt = "Use Application"
+ panel.allowedContentTypes = [.application]
+ panel.allowsMultipleSelection = false
+ panel.canChooseDirectories = false
+ panel.directoryURL = URL(fileURLWithPath: "/Applications")
+ guard panel.runModal() == .OK,
+ let url = panel.url,
+ let bundle = Bundle(url: url),
+ let bundleIdentifier = bundle.bundleIdentifier
+ else { return }
+
+ addProfileAutomationRule(
+ ProfileAutomationRule(
+ triggerKind: .application,
+ value: bundleIdentifier,
+ label: bundle.object(forInfoDictionaryKey: "CFBundleDisplayName")
+ as? String
+ ?? bundle.object(forInfoDictionaryKey: "CFBundleName")
+ as? String
+ ?? url.deletingPathExtension().lastPathComponent
+ ),
+ to: profileID
+ )
+ }
+
+ private func addProfileDisplayAutomation(
+ _ displayID: UUID,
+ to profileID: UUID
+ ) {
+ guard let display = DockDisplayManager.connectedDisplays.first(where: {
+ $0.id == displayID
+ }) else { return }
+ addProfileAutomationRule(
+ ProfileAutomationRule(
+ triggerKind: .display,
+ value: display.id.uuidString,
+ label: display.label
+ ),
+ to: profileID
+ )
+ }
+
+ private func addProfileAutomationRule(
+ _ rule: ProfileAutomationRule,
+ to profileID: UUID
+ ) {
+ guard let index = configManager.config.profiles.firstIndex(where: {
+ $0.id == profileID
+ }), !configManager.config.profiles[index].automationRules.contains(where: {
+ $0.triggerKind == rule.triggerKind
+ && $0.value.caseInsensitiveCompare(rule.value) == .orderedSame
+ }) else { return }
+ configManager.config.profiles[index].automationRules.append(rule)
+ configManager.save()
+ refreshPreferencesSnapshot()
+ }
+
+ private func setProfileAutomationEnabled(
+ _ enabled: Bool,
+ profileID: UUID,
+ ruleID: UUID
+ ) {
+ guard let profileIndex = configManager.config.profiles.firstIndex(where: {
+ $0.id == profileID
+ }), let ruleIndex = configManager.config.profiles[profileIndex]
+ .automationRules.firstIndex(where: { $0.id == ruleID })
+ else { return }
+ configManager.config.profiles[profileIndex]
+ .automationRules[ruleIndex].isEnabled = enabled
+ configManager.save()
+ refreshPreferencesSnapshot()
+ }
+
+ private func deleteProfileAutomation(
+ profileID: UUID,
+ ruleID: UUID
+ ) {
+ guard let profileIndex = configManager.config.profiles.firstIndex(where: {
+ $0.id == profileID
+ }) else { return }
+ configManager.config.profiles[profileIndex].automationRules.removeAll {
+ $0.id == ruleID
+ }
+ configManager.save()
+ refreshPreferencesSnapshot()
+ }
+
@objc private func toggleActiveProfileDocks() {
if dockPanels.isEmpty {
restoreDocks()
@@ -895,15 +1562,28 @@ class AppDelegate: NSObject, NSApplicationDelegate {
rebuildMenu()
}
- private func configureGlobalShortcuts() {
+ @discardableResult
+ private func configureGlobalShortcutsIfNeeded() -> Bool {
+ let settings = configManager.config.globalShortcuts
+ if registeredShortcutSettings == settings {
+ return true
+ }
+
shortcutManager.reset()
- shortcutManager.register(id: 1, keyCode: GlobalShortcutManager.showHideKeyCode) { [weak self] in
+ registeredShortcutSettings = nil
+ let showHide = settings.showHideDocks
+ let didRegisterShowHide = shortcutManager.register(
+ id: 1,
+ keyCode: showHide.keyCode,
+ modifiers: showHide.modifiers
+ ) { [weak self] in
self?.toggleActiveProfileDocks()
}
+ let quickLaunch = settings.quickLaunch
let didRegisterQuickLaunch = shortcutManager.register(
id: 100,
- keyCode: GlobalShortcutManager.quickLaunchKeyCode,
- modifiers: GlobalShortcutManager.quickLaunchModifiers
+ keyCode: quickLaunch.keyCode,
+ modifiers: quickLaunch.modifiers
) { [weak self] in
self?.toggleQuickLaunch()
}
@@ -911,14 +1591,48 @@ class AppDelegate: NSObject, NSApplicationDelegate {
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]
+ var didRegisterProfiles = true
+ for (index, profile) in configManager.config.profiles.enumerated() {
+ guard let shortcut = settings.shortcut(forProfile: profile.id) else {
+ continue
+ }
+ let registered = shortcutManager.register(
+ id: UInt32(index + 1_000),
+ keyCode: shortcut.keyCode,
+ modifiers: shortcut.modifiers
) { [weak self] in
self?.activateProfile(profile.id)
}
+ didRegisterProfiles = didRegisterProfiles && registered
}
+ let didRegister = didRegisterShowHide && didRegisterQuickLaunch
+ && didRegisterProfiles
+ if didRegister {
+ registeredShortcutSettings = settings
+ }
+ return didRegister
+ }
+
+ private func applyGlobalShortcuts(
+ _ settings: GlobalShortcutSettings
+ ) -> String? {
+ var settings = settings
+ settings.reconcileProfiles(configManager.config.profiles)
+ if let error = settings.validationError() {
+ return error
+ }
+
+ let previous = configManager.config.globalShortcuts
+ configManager.config.globalShortcuts = settings
+ guard configureGlobalShortcutsIfNeeded() else {
+ configManager.config.globalShortcuts = previous
+ configureGlobalShortcutsIfNeeded()
+ return "macOS could not register that shortcut. It may already be used by another app."
+ }
+
+ configManager.save()
+ rebuildMenu()
+ return nil
}
@objc private func toggleQuickLaunch() {
@@ -1092,6 +1806,9 @@ class AppDelegate: NSObject, NSApplicationDelegate {
)
let profile = DockProfile(name: name, docks: [starterDock])
configManager.config.profiles.append(profile)
+ configManager.config.globalShortcuts.reconcileProfiles(
+ configManager.config.profiles
+ )
configManager.config.activeProfileID = profile.id
restoreDocks()
configManager.save()
@@ -1134,9 +1851,20 @@ class AppDelegate: NSObject, NSApplicationDelegate {
deleteButton.hasDestructiveAction = true
guard alert.runModal() == .alertSecondButtonReturn else { return }
+ let previousActiveProfileID = configManager.config.activeProfileID
+ registerUndo("Delete Profile") { target in
+ target.restoreDeletedProfile(
+ profile,
+ at: index,
+ activeProfileID: previousActiveProfileID
+ )
+ }
saveAllPositions()
closeAllDockPanels()
configManager.config.profiles.remove(at: index)
+ configManager.config.globalShortcuts.reconcileProfiles(
+ configManager.config.profiles
+ )
let nextIndex = min(index, configManager.config.profiles.count - 1)
configManager.config.activeProfileID = configManager.config.profiles[nextIndex].id
restoreDocks()
@@ -1144,6 +1872,27 @@ class AppDelegate: NSObject, NSApplicationDelegate {
rebuildMenu()
}
+ private func restoreDeletedProfile(
+ _ profile: DockProfile,
+ at requestedIndex: Int,
+ activeProfileID: UUID
+ ) {
+ guard !configManager.config.profiles.contains(where: {
+ $0.id == profile.id
+ }) else { return }
+ saveAllPositions()
+ closeAllDockPanels()
+ let index = min(requestedIndex, configManager.config.profiles.count)
+ configManager.config.profiles.insert(profile, at: index)
+ configManager.config.globalShortcuts.reconcileProfiles(
+ configManager.config.profiles
+ )
+ configManager.config.activeProfileID = activeProfileID
+ restoreDocks()
+ configManager.save()
+ rebuildMenu()
+ }
+
private func promptForProfileName(
title: String,
message: String,
@@ -1335,7 +2084,10 @@ class AppDelegate: NSObject, NSApplicationDelegate {
}
private func confirmAndDeleteDock(_ id: UUID) {
- guard let dock = configManager.config.docks.first(where: { $0.id == id }) else { return }
+ guard let index = configManager.config.docks.firstIndex(where: {
+ $0.id == id
+ }) else { return }
+ let dock = configManager.config.docks[index]
let alert = NSAlert()
alert.icon = NSApp.applicationIconImage
@@ -1347,9 +2099,36 @@ class AppDelegate: NSObject, NSApplicationDelegate {
deleteButton.hasDestructiveAction = true
guard alert.runModal() == .alertSecondButtonReturn else { return }
+ let profileID = configManager.config.activeProfileID
+ registerUndo("Delete Dock") { target in
+ target.restoreDeletedDock(
+ dock,
+ at: index,
+ in: profileID
+ )
+ }
performDeleteDock(id)
}
+ private func restoreDeletedDock(
+ _ dock: DockConfig,
+ at requestedIndex: Int,
+ in profileID: UUID
+ ) {
+ guard let profileIndex = configManager.config.profiles.firstIndex(where: {
+ $0.id == profileID
+ }), !configManager.config.profiles[profileIndex].docks.contains(where: {
+ $0.id == dock.id
+ }) else { return }
+ let index = min(requestedIndex, configManager.config.profiles[profileIndex].docks.count)
+ configManager.config.profiles[profileIndex].docks.insert(dock, at: index)
+ if profileID == configManager.config.activeProfileID {
+ showDock(dock)
+ }
+ configManager.save()
+ rebuildMenu()
+ }
+
private func performDeleteDock(_ id: UUID) {
endQuickLaunch(
ifDockID: id,
@@ -1590,6 +2369,18 @@ class AppDelegate: NSObject, NSApplicationDelegate {
replaceDockItems(plan.items, for: dockID)
}
+ private func addTrash(to dockID: UUID) {
+ guard let dock = configManager.config.docks.first(where: {
+ $0.id == dockID
+ }) else { return }
+ let plan = DockItemPlanner.planAddingTrash(to: dock.items)
+ guard plan.addedCount > 0 else {
+ NSSound.beep()
+ return
+ }
+ replaceDockItems(plan.items, for: dockID)
+ }
+
private func replaceDockItems(
_ items: [DockItem],
for dockID: UUID,
@@ -1603,6 +2394,18 @@ class AppDelegate: NSObject, NSApplicationDelegate {
let previous = configManager.config.docks[index]
guard previous.items != items else { return }
+ let removedItemIDs = Set(previous.items.map(\.id))
+ .subtracting(items.map(\.id))
+ if !removedItemIDs.isEmpty {
+ let profileID = configManager.config.activeProfileID
+ registerUndo("Remove Dock Item") { target in
+ target.restoreDockItems(
+ previous.items,
+ dockID: dockID,
+ profileID: profileID
+ )
+ }
+ }
var updated = previous
updated.items = items
configManager.config.docks[index] = updated
@@ -1620,6 +2423,112 @@ class AppDelegate: NSObject, NSApplicationDelegate {
reconcileDockRuntime(from: previous, to: updated)
configManager.save()
refreshPreferencesSnapshot()
+ if !removedItemIDs.isEmpty {
+ rebuildMenu()
+ }
+ }
+
+ private func restoreDockItems(
+ _ items: [DockItem],
+ dockID: UUID,
+ profileID: UUID
+ ) {
+ guard let profileIndex = configManager.config.profiles.firstIndex(where: {
+ $0.id == profileID
+ }), let dockIndex = configManager.config.profiles[profileIndex]
+ .docks.firstIndex(where: { $0.id == dockID })
+ else { return }
+ let previous = configManager.config.profiles[profileIndex].docks[dockIndex]
+ var restored = previous
+ restored.items = items
+ configManager.config.profiles[profileIndex].docks[dockIndex] = restored
+ if profileID == configManager.config.activeProfileID {
+ preferencesStore?.replaceDock(restored)
+ reconcileDockRuntime(from: previous, to: restored)
+ }
+ configManager.save()
+ refreshPreferencesSnapshot()
+ }
+
+ private func promptToRenameDockItem(
+ _ item: DockItem,
+ in dockID: UUID
+ ) {
+ guard item.kind != .separator, item.kind != .trash else { return }
+ let alert = NSAlert()
+ alert.icon = NSApp.applicationIconImage
+ alert.messageText = "Rename Dock Item"
+ alert.informativeText = "Enter a custom label, or leave it empty to use the original name."
+ let field = NSTextField(
+ frame: NSRect(x: 0, y: 0, width: 240, height: 24)
+ )
+ field.stringValue = DockItemPresentation.resolve(item).displayName
+ field.selectText(nil)
+ alert.accessoryView = field
+ alert.addButton(withTitle: "Save")
+ alert.addButton(withTitle: "Cancel")
+ guard alert.runModal() == .alertFirstButtonReturn else { return }
+
+ var updatedItems = configManager.config.docks.first(where: {
+ $0.id == dockID
+ })?.items ?? []
+ guard let index = updatedItems.firstIndex(where: {
+ $0.id == item.id
+ }) else { return }
+ let label = field.stringValue.trimmingCharacters(
+ in: .whitespacesAndNewlines
+ )
+ updatedItems[index].label = label.isEmpty ? nil : label
+ replaceDockItems(updatedItems, for: dockID)
+ }
+
+ private func chooseCustomIcon(
+ for item: DockItem,
+ in dockID: UUID
+ ) {
+ guard item.kind != .separator, item.kind != .trash else { return }
+ let panel = NSOpenPanel()
+ panel.title = "Choose an Icon for \(DockItemPresentation.resolve(item).displayName)"
+ panel.prompt = "Choose Icon"
+ panel.allowedContentTypes = [.image]
+ panel.allowsMultipleSelection = false
+ panel.canChooseDirectories = false
+ panel.canChooseFiles = true
+ guard panel.runModal() == .OK,
+ let url = panel.url,
+ let iconData = DockItemIconEncoder.encodeImage(at: url)
+ else {
+ if panel.url != nil {
+ showCustomIconError()
+ }
+ return
+ }
+ updateCustomIcon(iconData, for: item.id, in: dockID)
+ }
+
+ private func updateCustomIcon(
+ _ data: Data?,
+ for itemID: DockItem.ID,
+ in dockID: UUID
+ ) {
+ var updatedItems = configManager.config.docks.first(where: {
+ $0.id == dockID
+ })?.items ?? []
+ guard let index = updatedItems.firstIndex(where: {
+ $0.id == itemID
+ }) else { return }
+ updatedItems[index].customIconData = data
+ replaceDockItems(updatedItems, for: dockID)
+ }
+
+ private func showCustomIconError() {
+ let alert = NSAlert()
+ alert.icon = NSApp.applicationIconImage
+ alert.alertStyle = .warning
+ alert.messageText = "Icon Couldn’t Be Used"
+ alert.informativeText = "Choose a valid image smaller than 10 MB. FreeDock stores a portable optimized copy in its configuration."
+ alert.addButton(withTitle: "OK")
+ alert.runModal()
}
private func transferDockItem(
@@ -1638,6 +2547,7 @@ class AppDelegate: NSObject, NSApplicationDelegate {
}
var docks = configManager.config.docks
+ let originalDocks = docks
guard let sourceIndex = docks.firstIndex(where: {
$0.id == session.sourceDockID
}),
@@ -1667,7 +2577,7 @@ class AppDelegate: NSObject, NSApplicationDelegate {
case .unchanged:
itemDragCoordinator.finish(sessionID: session.id)
return true
- case .moved, .copied:
+ case .moved, .copied, .removed:
break
}
@@ -1700,6 +2610,16 @@ class AppDelegate: NSObject, NSApplicationDelegate {
return true
}
+ if case .removed = plan.outcome {
+ let profileID = configManager.config.activeProfileID
+ registerUndo("Remove Dock Item") { target in
+ target.restoreProfileDocks(
+ originalDocks,
+ profileID: profileID
+ )
+ }
+ }
+
// Make both sides visible atomically before panels or Preferences
// reconcile the transfer.
configManager.config.docks = docks
@@ -1723,9 +2643,28 @@ class AppDelegate: NSObject, NSApplicationDelegate {
refreshPreferencesSnapshot()
itemDragCoordinator.publishContentChange()
itemDragCoordinator.finish(sessionID: session.id)
+ if case .removed = plan.outcome {
+ rebuildMenu()
+ }
return true
}
+ private func restoreProfileDocks(
+ _ docks: [DockConfig],
+ profileID: UUID
+ ) {
+ guard let profileIndex = configManager.config.profiles.firstIndex(where: {
+ $0.id == profileID
+ }) else { return }
+ configManager.config.profiles[profileIndex].docks = docks
+ if profileID == configManager.config.activeProfileID {
+ closeAllDockPanels()
+ restoreDocks()
+ }
+ configManager.save()
+ rebuildMenu()
+ }
+
private func activateDockItem(
_ item: DockItem,
sourceRect: CGRect,
@@ -1734,6 +2673,13 @@ class AppDelegate: NSObject, NSApplicationDelegate {
guard item.kind != .separator else { return }
windowPreviewController.close(resetNativeController: false)
+ if item.kind == .trash {
+ closeFolderStack()
+ guard let trashURL = TrashController.trashURL,
+ NSWorkspace.shared.open(trashURL)
+ else { NSSound.beep(); return }
+ return
+ }
if item.kind == .folder {
if let controller = folderStackController,
controller.dockID == panel.dockID,
@@ -2125,9 +3071,16 @@ class AppDelegate: NSObject, NSApplicationDelegate {
clearButton.hasDestructiveAction = true
guard alert.runModal() == .alertSecondButtonReturn else { return }
+ let recentFiles = configManager.config.recentFiles
+ registerUndo("Clear Recent Files") { target in
+ target.configManager.config.recentFiles = recentFiles
+ target.configManager.save()
+ target.refreshPreferencesSnapshot()
+ }
closeFolderStack()
configManager.config.recentFiles.removeAll()
configManager.save()
+ rebuildMenu()
}
private func confirmAndImportSystemDockApps(into id: UUID) {
@@ -2256,6 +3209,109 @@ class AppDelegate: NSObject, NSApplicationDelegate {
applyDockSettings(source.settings, to: Set(targetIDs))
}
+ private func promptToSaveDockTheme(from dockID: UUID) {
+ guard let dock = configManager.config.docks.first(where: {
+ $0.id == dockID
+ }) else { return }
+ var proposedValue = "\(dock.name) Style"
+
+ while true {
+ let alert = NSAlert()
+ alert.icon = NSApp.applicationIconImage
+ alert.messageText = "Save Appearance Theme"
+ alert.informativeText = "Save this dock’s style, opacity, blur, corners, and shadow for reuse on any dock or profile."
+ 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 }
+
+ proposedValue = field.stringValue.trimmingCharacters(
+ in: .whitespacesAndNewlines
+ )
+ if proposedValue.isEmpty {
+ showThemeError("Theme names cannot be empty.")
+ continue
+ }
+ let duplicate = configManager.config.themes.contains {
+ $0.name.compare(
+ proposedValue,
+ options: [.caseInsensitive, .diacriticInsensitive]
+ ) == .orderedSame
+ }
+ if duplicate {
+ showThemeError("A theme named “\(proposedValue)” already exists.")
+ continue
+ }
+
+ configManager.config.themes.append(
+ DockTheme(name: proposedValue, dock: dock)
+ )
+ configManager.save()
+ refreshPreferencesSnapshot()
+ return
+ }
+ }
+
+ private func applyDockTheme(_ themeID: UUID, to dockID: UUID) {
+ guard let theme = configManager.config.themes.first(where: {
+ $0.id == themeID
+ }),
+ let index = configManager.config.docks.firstIndex(where: {
+ $0.id == dockID
+ }) else { return }
+
+ let previous = configManager.config.docks[index]
+ var updated = previous
+ theme.apply(to: &updated)
+ guard updated != previous else { return }
+ configManager.config.docks[index] = updated
+ preferencesStore?.replaceDock(updated)
+ reconcileDockRuntime(from: previous, to: updated)
+ configManager.save()
+ refreshPreferencesSnapshot()
+ }
+
+ private func confirmAndDeleteDockTheme(_ themeID: UUID) {
+ guard let index = configManager.config.themes.firstIndex(where: {
+ $0.id == themeID
+ }) else { return }
+ let theme = configManager.config.themes[index]
+ let alert = NSAlert()
+ alert.icon = NSApp.applicationIconImage
+ alert.alertStyle = .warning
+ alert.messageText = "Delete “\(theme.name)”?"
+ alert.informativeText = "Docks already using this appearance will not change."
+ alert.addButton(withTitle: "Cancel")
+ let deleteButton = alert.addButton(withTitle: "Delete")
+ deleteButton.hasDestructiveAction = true
+ guard alert.runModal() == .alertSecondButtonReturn else { return }
+
+ registerUndo("Delete Theme") { target in
+ let insertionIndex = min(index, target.configManager.config.themes.count)
+ target.configManager.config.themes.insert(theme, at: insertionIndex)
+ target.configManager.save()
+ target.refreshPreferencesSnapshot()
+ }
+ configManager.config.themes.remove(at: index)
+ configManager.save()
+ rebuildMenu()
+ }
+
+ private func showThemeError(_ message: String) {
+ let alert = NSAlert()
+ alert.icon = NSApp.applicationIconImage
+ alert.alertStyle = .warning
+ alert.messageText = "Theme Not Saved"
+ alert.informativeText = message
+ alert.addButton(withTitle: "OK")
+ alert.runModal()
+ }
+
private func applyDockSettings(_ settings: DockSettings, to targetIDs: Set) {
var docks = configManager.config.docks
var transitions: [(previous: DockConfig, updated: DockConfig)] = []
@@ -2305,6 +3361,7 @@ class AppDelegate: NSObject, NSApplicationDelegate {
case .orientation, .magnificationEnabled, .magnification,
.itemSpacing, .appearance, .surfaceOpacity, .blurStyle,
.cornerRadius, .shadowStrength, .showRunningIndicators,
+ .showDynamicApplications, .dynamicApplicationLimit,
.autoHideDelay:
break
}
@@ -2329,6 +3386,10 @@ class AppDelegate: NSObject, NSApplicationDelegate {
|| previous.magnification != updated.magnification
|| previous.itemSpacing != updated.itemSpacing
|| previous.items != updated.items
+ || previous.showDynamicApplications
+ != updated.showDynamicApplications
+ || previous.dynamicApplicationLimit
+ != updated.dynamicApplicationLimit
let autoHideChanged = previous.autoHideWhenDocked != updated.autoHideWhenDocked
if geometryChanged {
@@ -2582,6 +3643,30 @@ class AppDelegate: NSObject, NSApplicationDelegate {
from: panel
)
},
+ onCanMoveFilesToTrash: { urls in
+ TrashController.canRecycle(urls)
+ },
+ onMoveFilesToTrash: { [weak self] urls in
+ TrashController.recycle(urls) { errorMessage in
+ guard let errorMessage else { return }
+ self?.showTrashError(
+ title: "Some Items Couldn’t Be Moved to Trash",
+ message: errorMessage
+ )
+ }
+ },
+ onEmptyTrashRequested: { [weak self] in
+ self?.confirmAndEmptyTrash()
+ },
+ onRenameItemRequested: { [weak self] item in
+ self?.promptToRenameDockItem(item, in: dockID)
+ },
+ onChooseCustomIconRequested: { [weak self] item in
+ self?.chooseCustomIcon(for: item, in: dockID)
+ },
+ onRestoreOriginalIconRequested: { [weak self] item in
+ self?.updateCustomIcon(nil, for: item.id, in: dockID)
+ },
onApplicationHoverChanged: {
[weak self, weak panel] item, sourceRect, hovering in
guard let self, let panel else { return }
@@ -2664,6 +3749,8 @@ class AppDelegate: NSObject, NSApplicationDelegate {
}
private func restoreDocks() {
+ let performanceInterval = PerformanceTrace.begin("DockRestore")
+ defer { PerformanceTrace.end(performanceInterval) }
for dock in configManager.config.docks {
showDock(dock)
}
diff --git a/Sources/FreeDock/Managers/ConfigManager.swift b/Sources/FreeDock/Managers/ConfigManager.swift
index 05da85d..3b563b8 100644
--- a/Sources/FreeDock/Managers/ConfigManager.swift
+++ b/Sources/FreeDock/Managers/ConfigManager.swift
@@ -1,10 +1,19 @@
import Foundation
import OSLog
-class ConfigManager {
+enum ConfigLoadSource: Equatable {
+ case primary
+ case backup(Int)
+ case fresh
+}
+
+final class ConfigManager {
+ static let backupGenerationCount = 3
+
let configPath: URL
let loadedFromDisk: Bool
- private let backupPath: URL
+ let loadSource: ConfigLoadSource
+ private let backupPaths: [URL]
private let queue = DispatchQueue(label: "com.freedock.config", qos: .utility)
private var saveWorkItem: DispatchWorkItem?
@@ -12,24 +21,29 @@ class ConfigManager {
init(configPath: URL) {
self.configPath = configPath
- self.backupPath = configPath.deletingLastPathComponent()
- .appendingPathComponent(configPath.lastPathComponent + ".bak")
- if let data = try? Data(contentsOf: configPath),
- let decoded = try? JSONDecoder().decode(AppConfig.self, from: data)
- {
- self.config = decoded
- self.loadedFromDisk = true
+ backupPaths = Self.makeBackupPaths(for: configPath)
+
+ let candidates: [(URL, ConfigLoadSource)] = [(configPath, .primary)]
+ + backupPaths.enumerated().map { ($0.element, .backup($0.offset)) }
+ if let loaded = candidates.lazy.compactMap({ url, source in
+ Self.decodeConfig(at: url).map { ($0, source) }
+ }).first {
+ config = loaded.0
+ loadSource = loaded.1
+ loadedFromDisk = true
} else {
- self.config = AppConfig()
- self.loadedFromDisk = false
+ config = AppConfig()
+ loadSource = .fresh
+ loadedFromDisk = false
}
}
static func load(from path: URL) -> AppConfig {
- guard let data = try? Data(contentsOf: path),
- let config = try? JSONDecoder().decode(AppConfig.self, from: data)
- else { return AppConfig() }
- return config
+ decodeConfig(at: path) ?? AppConfig()
+ }
+
+ var hasRecoverableBackup: Bool {
+ backupPaths.contains { Self.decodeConfig(at: $0) != nil }
}
func save() {
@@ -46,18 +60,73 @@ class ConfigManager {
queue.sync { [weak self] in self?._save(snapshot) }
}
+ @discardableResult
+ func restoreLatestBackup() -> Bool {
+ saveWorkItem?.cancel()
+ guard let restored = backupPaths.lazy.compactMap({
+ Self.decodeConfig(at: $0)
+ }).first else { return false }
+ config = restored
+ queue.sync { [weak self] in self?._save(restored) }
+ return true
+ }
+
private func _save(_ config: AppConfig) {
+ let performanceInterval = PerformanceTrace.begin("ConfigSave")
+ defer { PerformanceTrace.end(performanceInterval) }
do {
- let encoder = JSONEncoder()
- encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
- let data = try encoder.encode(config)
- if FileManager.default.fileExists(atPath: configPath.path) {
- try? FileManager.default.removeItem(at: backupPath)
- try FileManager.default.copyItem(at: configPath, to: backupPath)
+ let data = try ConfigFileCodec.encode(config)
+ try FileManager.default.createDirectory(
+ at: configPath.deletingLastPathComponent(),
+ withIntermediateDirectories: true
+ )
+ if Self.decodeConfig(at: configPath) != nil {
+ try rotateBackups()
}
try data.write(to: configPath, options: .atomic)
} catch {
- os_log(.error, "[FreeDock] Config save error: %{public}@", error.localizedDescription)
+ os_log(
+ .error,
+ "[FreeDock] Config save error: %{public}@",
+ error.localizedDescription
+ )
+ }
+ }
+
+ private func rotateBackups() throws {
+ let fileManager = FileManager.default
+ if let oldest = backupPaths.last,
+ fileManager.fileExists(atPath: oldest.path)
+ {
+ try fileManager.removeItem(at: oldest)
+ }
+ if backupPaths.count > 1 {
+ for index in stride(
+ from: backupPaths.count - 1,
+ through: 1,
+ by: -1
+ ) {
+ let source = backupPaths[index - 1]
+ let destination = backupPaths[index]
+ if fileManager.fileExists(atPath: source.path) {
+ try fileManager.moveItem(at: source, to: destination)
+ }
+ }
+ }
+ try fileManager.copyItem(at: configPath, to: backupPaths[0])
+ }
+
+ private static func decodeConfig(at url: URL) -> AppConfig? {
+ guard let data = try? Data(contentsOf: url) else { return nil }
+ return try? ConfigFileCodec.decode(data)
+ }
+
+ private static func makeBackupPaths(for configPath: URL) -> [URL] {
+ let directory = configPath.deletingLastPathComponent()
+ let name = configPath.lastPathComponent
+ return (0.. DockApplicationWindowQuery {
+ let performanceInterval = PerformanceTrace.begin(
+ "WindowDiscovery"
+ )
+ defer { PerformanceTrace.end(performanceInterval) }
+ return await loadWindowsMeasured(
+ bundleIdentifier: bundleIdentifier,
+ applicationName: applicationName
+ )
+ }
+
+ private func loadWindowsMeasured(
+ bundleIdentifier: String,
+ applicationName: String
) async -> DockApplicationWindowQuery {
guard isAccessibilityTrusted else {
clearRetainedState()
@@ -178,6 +192,10 @@ final class DockApplicationWindowController {
else {
return nil
}
+ let performanceInterval = PerformanceTrace.begin(
+ "WindowThumbnail"
+ )
+ defer { PerformanceTrace.end(performanceInterval) }
return await captureController.thumbnail(
processIdentifier: capture.processIdentifier,
for: capture.windowID,
diff --git a/Sources/FreeDock/Managers/DockItemIconEncoder.swift b/Sources/FreeDock/Managers/DockItemIconEncoder.swift
new file mode 100644
index 0000000..87c33d5
--- /dev/null
+++ b/Sources/FreeDock/Managers/DockItemIconEncoder.swift
@@ -0,0 +1,67 @@
+import AppKit
+
+@MainActor
+enum DockItemIconEncoder {
+ static let pixelSize = 256
+ static let maximumInputByteCount = 10 * 1_024 * 1_024
+
+ static func encodeImage(at url: URL) -> Data? {
+ guard url.isFileURL,
+ let fileSize = try? url.resourceValues(
+ forKeys: [.fileSizeKey]
+ ).fileSize,
+ fileSize <= maximumInputByteCount,
+ let source = NSImage(contentsOf: url),
+ source.size.width > 0,
+ source.size.height > 0,
+ let bitmap = NSBitmapImageRep(
+ bitmapDataPlanes: nil,
+ pixelsWide: pixelSize,
+ pixelsHigh: pixelSize,
+ bitsPerSample: 8,
+ samplesPerPixel: 4,
+ hasAlpha: true,
+ isPlanar: false,
+ colorSpaceName: .deviceRGB,
+ bytesPerRow: 0,
+ bitsPerPixel: 0
+ )
+ else { return nil }
+
+ let canvas = NSSize(width: pixelSize, height: pixelSize)
+ let scale = min(
+ canvas.width / source.size.width,
+ canvas.height / source.size.height
+ )
+ let drawSize = NSSize(
+ width: source.size.width * scale,
+ height: source.size.height * scale
+ )
+ let drawRect = NSRect(
+ x: (canvas.width - drawSize.width) / 2,
+ y: (canvas.height - drawSize.height) / 2,
+ width: drawSize.width,
+ height: drawSize.height
+ )
+
+ NSGraphicsContext.saveGraphicsState()
+ defer { NSGraphicsContext.restoreGraphicsState() }
+ guard let context = NSGraphicsContext(bitmapImageRep: bitmap) else {
+ return nil
+ }
+ NSGraphicsContext.current = context
+ context.imageInterpolation = .high
+ NSColor.clear.setFill()
+ NSRect(origin: .zero, size: canvas).fill()
+ source.draw(
+ in: drawRect,
+ from: .zero,
+ operation: .sourceOver,
+ fraction: 1,
+ respectFlipped: true,
+ hints: [.interpolation: NSImageInterpolation.high]
+ )
+ context.flushGraphics()
+ return bitmap.representation(using: .png, properties: [:])
+ }
+}
diff --git a/Sources/FreeDock/Managers/LaunchAtLoginController.swift b/Sources/FreeDock/Managers/LaunchAtLoginController.swift
new file mode 100644
index 0000000..f9ad533
--- /dev/null
+++ b/Sources/FreeDock/Managers/LaunchAtLoginController.swift
@@ -0,0 +1,47 @@
+import AppKit
+import ServiceManagement
+
+enum LaunchAtLoginController {
+ static var state: LaunchAtLoginState {
+ guard #available(macOS 13.0, *) else {
+ return LaunchAtLoginState(status: .unavailable)
+ }
+
+ switch SMAppService.mainApp.status {
+ case .enabled:
+ return LaunchAtLoginState(status: .enabled)
+ case .requiresApproval:
+ return LaunchAtLoginState(status: .requiresApproval)
+ case .notRegistered, .notFound:
+ return LaunchAtLoginState(status: .disabled)
+ @unknown default:
+ return LaunchAtLoginState(status: .disabled)
+ }
+ }
+
+ static func setEnabled(_ enabled: Bool) -> LaunchAtLoginState {
+ guard #available(macOS 13.0, *) else {
+ return LaunchAtLoginState(status: .unavailable)
+ }
+
+ do {
+ if enabled {
+ try SMAppService.mainApp.register()
+ } else {
+ try SMAppService.mainApp.unregister()
+ }
+ return state
+ } catch {
+ var current = state
+ current.errorMessage = error.localizedDescription
+ return current
+ }
+ }
+
+ static func openSystemSettings() {
+ guard let url = URL(
+ string: "x-apple.systempreferences:com.apple.LoginItems-Settings.extension"
+ ) else { return }
+ NSWorkspace.shared.open(url)
+ }
+}
diff --git a/Sources/FreeDock/Managers/RunningAppMonitor.swift b/Sources/FreeDock/Managers/RunningAppMonitor.swift
index b3f741c..bd6e9c7 100644
--- a/Sources/FreeDock/Managers/RunningAppMonitor.swift
+++ b/Sources/FreeDock/Managers/RunningAppMonitor.swift
@@ -6,7 +6,9 @@ class RunningAppMonitor: ObservableObject {
static let shared = RunningAppMonitor()
@Published private(set) var runningBundleIDs: Set = []
+ @Published private(set) var recentApplications: [RecentApplicationRecord] = []
private var runningApplicationsObservation: AnyCancellable?
+ private var onRecentApplicationsChanged: (([RecentApplicationRecord]) -> Void)?
private static let applicationChangeNotifications: [Notification.Name] = [
NSWorkspace.didLaunchApplicationNotification,
@@ -47,7 +49,58 @@ class RunningAppMonitor: ObservableObject {
)
}
+ func configureRecentApplications(
+ _ records: [RecentApplicationRecord],
+ onChange: @escaping ([RecentApplicationRecord]) -> Void
+ ) {
+ recentApplications = RecentApplicationHistoryPlanner.normalized(records)
+ onRecentApplicationsChanged = onChange
+ for application in NSWorkspace.shared.runningApplications
+ where application.activationPolicy == .regular
+ {
+ record(application, usedAt: .distantPast, preservesExistingDate: true)
+ }
+ }
+
@objc private func applicationDidChange(_ notification: Notification) {
refresh()
+ guard notification.name == NSWorkspace.didLaunchApplicationNotification
+ || notification.name == NSWorkspace.didActivateApplicationNotification,
+ let application = notification.userInfo?[
+ NSWorkspace.applicationUserInfoKey
+ ] as? NSRunningApplication
+ else { return }
+ record(application, usedAt: Date(), preservesExistingDate: false)
+ }
+
+ private func record(
+ _ application: NSRunningApplication,
+ usedAt: Date,
+ preservesExistingDate: Bool
+ ) {
+ guard application.activationPolicy == .regular,
+ application.processIdentifier
+ != ProcessInfo.processInfo.processIdentifier,
+ let bundleIdentifier = application.bundleIdentifier,
+ let url = application.bundleURL,
+ url.isFileURL
+ else { return }
+
+ let existing = recentApplications.first(where: {
+ $0.bundleIdentifier == bundleIdentifier
+ })
+ if preservesExistingDate, existing != nil { return }
+ let date = preservesExistingDate ? .distantPast : usedAt
+ let updated = RecentApplicationHistoryPlanner.recording(
+ bundleIdentifier: bundleIdentifier,
+ path: url.standardizedFileURL.path,
+ displayName: application.localizedName
+ ?? url.deletingPathExtension().lastPathComponent,
+ usedAt: date,
+ in: recentApplications
+ )
+ guard updated != recentApplications else { return }
+ recentApplications = updated
+ onRecentApplicationsChanged?(updated)
}
}
diff --git a/Sources/FreeDock/Managers/TrashController.swift b/Sources/FreeDock/Managers/TrashController.swift
new file mode 100644
index 0000000..8b8187f
--- /dev/null
+++ b/Sources/FreeDock/Managers/TrashController.swift
@@ -0,0 +1,92 @@
+import AppKit
+import Combine
+
+@MainActor
+final class TrashMonitor: ObservableObject {
+ static let shared = TrashMonitor()
+
+ @Published private(set) var isEmpty = true
+ private var timer: Timer?
+
+ private init() {
+ refresh()
+ timer = Timer.scheduledTimer(
+ withTimeInterval: 2,
+ repeats: true
+ ) { _ in
+ Task { @MainActor in
+ TrashMonitor.shared.refresh()
+ }
+ }
+ }
+
+ func refresh() {
+ guard let url = TrashController.trashURL,
+ let contents = try? FileManager.default.contentsOfDirectory(
+ at: url,
+ includingPropertiesForKeys: nil,
+ options: []
+ )
+ else {
+ isEmpty = true
+ return
+ }
+ isEmpty = contents.isEmpty
+ }
+}
+
+enum TrashController {
+ static var trashURL: URL? {
+ FileManager.default.urls(
+ for: .trashDirectory,
+ in: .userDomainMask
+ ).first?.standardizedFileURL
+ }
+
+ static func canRecycle(_ urls: [URL]) -> Bool {
+ guard !urls.isEmpty else { return false }
+ let trashPath = trashURL?.path
+ return urls.allSatisfy {
+ let path = $0.standardizedFileURL.path
+ return $0.isFileURL
+ && FileManager.default.fileExists(atPath: path)
+ && path != trashPath
+ && !(trashPath.map { path.hasPrefix($0 + "/") } ?? false)
+ }
+ }
+
+ @MainActor
+ static func recycle(
+ _ urls: [URL],
+ completion: @escaping @MainActor @Sendable (String?) -> Void
+ ) -> Bool {
+ guard canRecycle(urls) else { return false }
+ NSWorkspace.shared.recycle(urls) { _, error in
+ let errorMessage = error?.localizedDescription
+ Task { @MainActor in
+ TrashMonitor.shared.refresh()
+ completion(errorMessage)
+ }
+ }
+ return true
+ }
+
+ static func emptyTrash() -> [Error] {
+ guard let trashURL,
+ let contents = try? FileManager.default.contentsOfDirectory(
+ at: trashURL,
+ includingPropertiesForKeys: nil,
+ options: []
+ )
+ else { return [] }
+ var errors: [Error] = []
+ for url in contents {
+ do {
+ try FileManager.default.removeItem(at: url)
+ } catch {
+ errors.append(error)
+ }
+ }
+ return errors
+ }
+}
diff --git a/Sources/FreeDock/Models/AppConfig.swift b/Sources/FreeDock/Models/AppConfig.swift
index bbbb51d..09a1e5e 100644
--- a/Sources/FreeDock/Models/AppConfig.swift
+++ b/Sources/FreeDock/Models/AppConfig.swift
@@ -1,15 +1,21 @@
import Foundation
struct AppConfig: Codable, Sendable {
- static let currentFormatVersion = 4
+ static let currentFormatVersion = 10
var profiles: [DockProfile]
var activeProfileID: UUID
var recentFiles: [RecentFileRecord]
+ var globalShortcuts: GlobalShortcutSettings
+ var recentApplications: [RecentApplicationRecord]
+ var themes: [DockTheme]
init(
docks: [DockConfig] = [],
- recentFiles: [RecentFileRecord] = []
+ recentFiles: [RecentFileRecord] = [],
+ globalShortcuts: GlobalShortcutSettings = GlobalShortcutSettings(),
+ recentApplications: [RecentApplicationRecord] = [],
+ themes: [DockTheme] = []
) {
let profile = DockProfile(name: "Default", docks: docks)
profiles = [profile]
@@ -18,12 +24,21 @@ struct AppConfig: Codable, Sendable {
recentFiles,
limit: RecentFileHistoryPlanner.maximumLimit
)
+ self.globalShortcuts = globalShortcuts
+ self.globalShortcuts.reconcileProfiles(profiles)
+ self.recentApplications = RecentApplicationHistoryPlanner.normalized(
+ recentApplications
+ )
+ self.themes = themes
}
init(
profiles: [DockProfile],
activeProfileID: UUID? = nil,
- recentFiles: [RecentFileRecord] = []
+ recentFiles: [RecentFileRecord] = [],
+ globalShortcuts: GlobalShortcutSettings = GlobalShortcutSettings(),
+ recentApplications: [RecentApplicationRecord] = [],
+ themes: [DockTheme] = []
) {
let availableProfiles = profiles.isEmpty ? [DockProfile(name: "Default")] : profiles
self.profiles = availableProfiles
@@ -38,6 +53,12 @@ struct AppConfig: Codable, Sendable {
recentFiles,
limit: RecentFileHistoryPlanner.maximumLimit
)
+ self.globalShortcuts = globalShortcuts
+ self.globalShortcuts.reconcileProfiles(availableProfiles)
+ self.recentApplications = RecentApplicationHistoryPlanner.normalized(
+ recentApplications
+ )
+ self.themes = themes
}
var docks: [DockConfig] {
@@ -65,6 +86,9 @@ struct AppConfig: Codable, Sendable {
case activeProfileID
case docks
case recentFiles
+ case globalShortcuts
+ case recentApplications
+ case themes
}
init(from decoder: Decoder) throws {
@@ -76,6 +100,17 @@ struct AppConfig: Codable, Sendable {
)) ?? [],
limit: RecentFileHistoryPlanner.maximumLimit
)
+ globalShortcuts = (try? container.decode(
+ GlobalShortcutSettings.self,
+ forKey: .globalShortcuts
+ )) ?? GlobalShortcutSettings()
+ recentApplications = RecentApplicationHistoryPlanner.normalized(
+ (try? container.decode(
+ [RecentApplicationRecord].self,
+ forKey: .recentApplications
+ )) ?? []
+ )
+ themes = (try? container.decode([DockTheme].self, forKey: .themes)) ?? []
if let decodedProfiles = try container.decodeIfPresent([DockProfile].self, forKey: .profiles),
!decodedProfiles.isEmpty
@@ -89,6 +124,7 @@ struct AppConfig: Codable, Sendable {
} else {
activeProfileID = decodedProfiles[0].id
}
+ globalShortcuts.reconcileProfiles(profiles)
return
}
@@ -96,6 +132,7 @@ struct AppConfig: Codable, Sendable {
let migratedProfile = DockProfile(name: "Default", docks: legacyDocks)
profiles = [migratedProfile]
activeProfileID = migratedProfile.id
+ globalShortcuts.reconcileProfiles(profiles)
}
func encode(to encoder: Encoder) throws {
@@ -105,5 +142,8 @@ struct AppConfig: Codable, Sendable {
try container.encode(activeProfileID, forKey: .activeProfileID)
try container.encode(docks, forKey: .docks)
try container.encode(recentFiles, forKey: .recentFiles)
+ try container.encode(globalShortcuts, forKey: .globalShortcuts)
+ try container.encode(recentApplications, forKey: .recentApplications)
+ try container.encode(themes, forKey: .themes)
}
}
diff --git a/Sources/FreeDock/Models/ConfigFileCodec.swift b/Sources/FreeDock/Models/ConfigFileCodec.swift
new file mode 100644
index 0000000..5b00827
--- /dev/null
+++ b/Sources/FreeDock/Models/ConfigFileCodec.swift
@@ -0,0 +1,74 @@
+import Foundation
+
+enum ConfigFileCodecError: LocalizedError, Equatable {
+ case emptyFile
+ case fileTooLarge
+ case unsupportedVersion(Int)
+ case duplicateProfile
+ case duplicateDock
+ case duplicateTheme
+
+ var errorDescription: String? {
+ switch self {
+ case .emptyFile:
+ return "The selected file is empty."
+ case .fileTooLarge:
+ return "The selected file is too large to be a FreeDock configuration."
+ case let .unsupportedVersion(version):
+ return "This configuration uses format version \(version), which this version of FreeDock cannot import."
+ case .duplicateProfile:
+ return "The configuration contains duplicate profile identifiers."
+ case .duplicateDock:
+ return "The configuration contains duplicate dock identifiers."
+ case .duplicateTheme:
+ return "The configuration contains duplicate appearance theme identifiers."
+ }
+ }
+}
+
+enum ConfigFileCodec {
+ static let maximumByteCount = 5 * 1_024 * 1_024
+
+ private struct Header: Decodable {
+ let formatVersion: Int?
+ }
+
+ static func encode(_ config: AppConfig) throws -> Data {
+ let encoder = JSONEncoder()
+ encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
+ return try encoder.encode(config)
+ }
+
+ static func decode(_ data: Data) throws -> AppConfig {
+ guard !data.isEmpty else {
+ throw ConfigFileCodecError.emptyFile
+ }
+ guard data.count <= maximumByteCount else {
+ throw ConfigFileCodecError.fileTooLarge
+ }
+
+ let decoder = JSONDecoder()
+ let header = try decoder.decode(Header.self, from: data)
+ if let version = header.formatVersion,
+ version > AppConfig.currentFormatVersion
+ {
+ throw ConfigFileCodecError.unsupportedVersion(version)
+ }
+
+ let config = try decoder.decode(AppConfig.self, from: data)
+ let profileIDs = config.profiles.map(\.id)
+ guard Set(profileIDs).count == profileIDs.count else {
+ throw ConfigFileCodecError.duplicateProfile
+ }
+
+ let dockIDs = config.profiles.flatMap { $0.docks.map(\.id) }
+ guard Set(dockIDs).count == dockIDs.count else {
+ throw ConfigFileCodecError.duplicateDock
+ }
+ let themeIDs = config.themes.map(\.id)
+ guard Set(themeIDs).count == themeIDs.count else {
+ throw ConfigFileCodecError.duplicateTheme
+ }
+ return config
+ }
+}
diff --git a/Sources/FreeDock/Models/DockConfig.swift b/Sources/FreeDock/Models/DockConfig.swift
index 648d87d..3ed7d60 100644
--- a/Sources/FreeDock/Models/DockConfig.swift
+++ b/Sources/FreeDock/Models/DockConfig.swift
@@ -12,6 +12,8 @@ struct DockSettings: Equatable, Sendable {
var cornerRadius: Double
var shadowStrength: Double
var showRunningIndicators: Bool
+ var showDynamicApplications: Bool
+ var dynamicApplicationLimit: Int
var autoHideWhenDocked: Bool
var autoHideDelay: Double
@@ -27,6 +29,8 @@ struct DockSettings: Equatable, Sendable {
cornerRadius = config.cornerRadius
shadowStrength = config.shadowStrength
showRunningIndicators = config.showRunningIndicators
+ showDynamicApplications = config.showDynamicApplications
+ dynamicApplicationLimit = config.dynamicApplicationLimit
autoHideWhenDocked = config.autoHideWhenDocked
autoHideDelay = config.autoHideDelay
}
@@ -58,6 +62,8 @@ struct DockConfig: Codable, Identifiable, Equatable, Sendable {
var cornerRadius: Double
var shadowStrength: Double
var showRunningIndicators: Bool
+ var showDynamicApplications: Bool
+ var dynamicApplicationLimit: Int
var autoHideDelay: Double
init(id: UUID = UUID(), name: String, position: CGPoint = .zero,
@@ -68,7 +74,10 @@ struct DockConfig: Codable, Identifiable, Equatable, Sendable {
itemSpacing: Double = 3, appearance: DockAppearance = .glass,
surfaceOpacity: Double = 1, blurStyle: DockBlurStyle = .regular,
cornerRadius: Double = 18, shadowStrength: Double = 1,
- showRunningIndicators: Bool = true, autoHideDelay: Double = 1) {
+ showRunningIndicators: Bool = true,
+ showDynamicApplications: Bool = false,
+ dynamicApplicationLimit: Int = 5,
+ autoHideDelay: Double = 1) {
self.id = id
self.name = name
self.position = position
@@ -86,6 +95,8 @@ struct DockConfig: Codable, Identifiable, Equatable, Sendable {
self.cornerRadius = Self.clamp(cornerRadius, to: Self.cornerRadiusRange)
self.shadowStrength = Self.clamp(shadowStrength, to: Self.shadowStrengthRange)
self.showRunningIndicators = showRunningIndicators
+ self.showDynamicApplications = showDynamicApplications
+ self.dynamicApplicationLimit = min(max(dynamicApplicationLimit, 1), 10)
self.autoHideDelay = Self.clamp(autoHideDelay, to: Self.autoHideDelayRange)
normalizeDisplayPlacementEdge()
}
@@ -114,6 +125,8 @@ struct DockConfig: Codable, Identifiable, Equatable, Sendable {
cornerRadius = Self.clamp(settings.cornerRadius, to: Self.cornerRadiusRange)
shadowStrength = Self.clamp(settings.shadowStrength, to: Self.shadowStrengthRange)
showRunningIndicators = settings.showRunningIndicators
+ showDynamicApplications = settings.showDynamicApplications
+ dynamicApplicationLimit = min(max(settings.dynamicApplicationLimit, 1), 10)
autoHideWhenDocked = settings.autoHideWhenDocked
autoHideDelay = Self.clamp(settings.autoHideDelay, to: Self.autoHideDelayRange)
normalizeDisplayPlacementEdge()
@@ -144,6 +157,8 @@ struct DockConfig: Codable, Identifiable, Equatable, Sendable {
cornerRadius: cornerRadius,
shadowStrength: shadowStrength,
showRunningIndicators: showRunningIndicators,
+ showDynamicApplications: showDynamicApplications,
+ dynamicApplicationLimit: dynamicApplicationLimit,
autoHideDelay: autoHideDelay
)
}
@@ -166,6 +181,8 @@ struct DockConfig: Codable, Identifiable, Equatable, Sendable {
case cornerRadius
case shadowStrength
case showRunningIndicators
+ case showDynamicApplications
+ case dynamicApplicationLimit
case autoHideDelay
}
@@ -210,6 +227,17 @@ struct DockConfig: Codable, Identifiable, Equatable, Sendable {
to: Self.shadowStrengthRange
)
showRunningIndicators = try container.decodeIfPresent(Bool.self, forKey: .showRunningIndicators) ?? true
+ showDynamicApplications = try container.decodeIfPresent(
+ Bool.self,
+ forKey: .showDynamicApplications
+ ) ?? false
+ dynamicApplicationLimit = min(max(
+ try container.decodeIfPresent(
+ Int.self,
+ forKey: .dynamicApplicationLimit
+ ) ?? 5,
+ 1
+ ), 10)
autoHideDelay = Self.clamp(
try container.decodeIfPresent(Double.self, forKey: .autoHideDelay) ?? 1,
to: Self.autoHideDelayRange
diff --git a/Sources/FreeDock/Models/DockDisplayPlacement.swift b/Sources/FreeDock/Models/DockDisplayPlacement.swift
index 20b1853..9bb8121 100644
--- a/Sources/FreeDock/Models/DockDisplayPlacement.swift
+++ b/Sources/FreeDock/Models/DockDisplayPlacement.swift
@@ -245,7 +245,7 @@ enum DockDisplayGeometry {
at: edge,
in: display.visibleFrame
)
- let hiddenFrame = autoHiddenFrame(
+ let hiddenFrame = hiddenFrame(
restingFrame,
at: edge,
in: display.visibleFrame
@@ -448,7 +448,7 @@ enum DockDisplayGeometry {
}
}
- private static func autoHiddenFrame(
+ static func hiddenFrame(
_ frame: CGRect,
at edge: DockScreenEdge,
in visibleFrame: CGRect
diff --git a/Sources/FreeDock/Models/DockItem.swift b/Sources/FreeDock/Models/DockItem.swift
index bf18715..907c996 100644
--- a/Sources/FreeDock/Models/DockItem.swift
+++ b/Sources/FreeDock/Models/DockItem.swift
@@ -6,6 +6,7 @@ enum DockItemKind: String, Codable, Equatable, Sendable {
case document
case folder
case separator
+ case trash
}
enum SmartStackSource: String, Codable, CaseIterable, Equatable, Hashable, Sendable {
@@ -90,6 +91,7 @@ struct DockItem: Codable, Identifiable, Equatable, Sendable {
var kind: DockItemKind
var folderOptions: FolderStackOptions?
var smartStackSource: SmartStackSource?
+ var customIconData: Data?
/// Source compatibility for code and configs created before typed dock items.
var appPath: String {
@@ -108,13 +110,16 @@ struct DockItem: Codable, Identifiable, Equatable, Sendable {
path: String,
label: String? = nil,
folderOptions: FolderStackOptions? = nil,
- smartStackSource: SmartStackSource? = nil
+ smartStackSource: SmartStackSource? = nil,
+ customIconData: Data? = nil
) {
self.id = id
self.kind = smartStackSource == nil ? kind : .folder
- self.path = kind == .separator || smartStackSource != nil ? "" : path
+ self.path = kind == .separator || kind == .trash
+ || smartStackSource != nil ? "" : path
self.label = label ?? smartStackSource?.defaultLabel
self.smartStackSource = smartStackSource
+ self.customIconData = customIconData
self.folderOptions = self.kind == .folder
? (
folderOptions
@@ -221,6 +226,10 @@ struct DockItem: Codable, Identifiable, Equatable, Sendable {
DockItem(id: id, kind: .separator, path: "")
}
+ static func trash(id: UUID = UUID()) -> DockItem {
+ DockItem(id: id, kind: .trash, path: "", label: "Trash")
+ }
+
/// 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 }
@@ -273,12 +282,14 @@ struct DockItem: Codable, Identifiable, Equatable, Sendable {
path: path,
label: label,
folderOptions: folderOptions,
- smartStackSource: smartStackSource
+ smartStackSource: smartStackSource,
+ customIconData: customIconData
)
}
var fileURL: URL? {
guard kind != .separator,
+ kind != .trash,
smartStackSource == nil,
!path.isEmpty
else {
@@ -308,6 +319,7 @@ struct DockItem: Codable, Identifiable, Equatable, Sendable {
case kind
case folderOptions
case smartStackSource
+ case customIconData
case isSeparator
}
@@ -331,10 +343,16 @@ struct DockItem: Codable, Identifiable, Equatable, Sendable {
(try? container.decode(String.self, forKey: .path))
?? (try? container.decode(String.self, forKey: .appPath))
?? ""
- path = kind == .separator || smartStackSource != nil ? "" : decodedPath
+ path = kind == .separator || kind == .trash || smartStackSource != nil
+ ? ""
+ : decodedPath
label =
(try? container.decode(String.self, forKey: .label))
?? smartStackSource?.defaultLabel
+ customIconData = try? container.decode(
+ Data.self,
+ forKey: .customIconData
+ )
if kind == .folder {
folderOptions =
@@ -359,6 +377,7 @@ struct DockItem: Codable, Identifiable, Equatable, Sendable {
smartStackSource,
forKey: .smartStackSource
)
+ try container.encodeIfPresent(customIconData, forKey: .customIconData)
try container.encode(isSeparator, forKey: .isSeparator)
}
}
diff --git a/Sources/FreeDock/Models/DockItemPlanner.swift b/Sources/FreeDock/Models/DockItemPlanner.swift
index ef228da..2e8dd59 100644
--- a/Sources/FreeDock/Models/DockItemPlanner.swift
+++ b/Sources/FreeDock/Models/DockItemPlanner.swift
@@ -7,6 +7,23 @@ struct DockItemAdditionPlan: Equatable, Sendable {
}
enum DockItemPlanner {
+ static func planAddingTrash(
+ to existingItems: [DockItem]
+ ) -> DockItemAdditionPlan {
+ guard !existingItems.contains(where: { $0.kind == .trash }) else {
+ return DockItemAdditionPlan(
+ items: existingItems,
+ addedCount: 0,
+ skippedCount: 1
+ )
+ }
+ return DockItemAdditionPlan(
+ items: existingItems + [.trash()],
+ addedCount: 1,
+ skippedCount: 0
+ )
+ }
+
/// 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(
diff --git a/Sources/FreeDock/Models/DockItemPresentation.swift b/Sources/FreeDock/Models/DockItemPresentation.swift
index 993909a..24a50d6 100644
--- a/Sources/FreeDock/Models/DockItemPresentation.swift
+++ b/Sources/FreeDock/Models/DockItemPresentation.swift
@@ -10,6 +10,27 @@ struct DockItemPresentation {
let badgeSymbolName: String?
static func resolve(_ item: DockItem) -> DockItemPresentation {
+ if item.kind == .trash {
+ let isEmpty = TrashMonitor.shared.isEmpty
+ let image = NSImage(
+ named: isEmpty
+ ? NSImage.trashEmptyName
+ : NSImage.trashFullName
+ ) ?? systemIcon(
+ preferredName: "trash.fill",
+ fallbackName: "trash",
+ accessibilityDescription: "Trash"
+ )
+ image.size = NSSize(width: 64, height: 64)
+ return DockItemPresentation(
+ displayName: item.label.flatMap(\.nonEmpty) ?? "Trash",
+ icon: image,
+ bundleID: nil,
+ isAvailable: TrashController.trashURL != nil,
+ kindDescription: isEmpty ? "empty trash" : "trash containing items",
+ badgeSymbolName: nil
+ )
+ }
if let source = item.smartStackSource {
return resolveSmartStack(item, source: source)
}
@@ -31,7 +52,9 @@ struct DockItemPresentation {
: resolvedName
let icon: NSImage
- if isAvailable {
+ if let customIcon = customIcon(for: item) {
+ icon = customIcon
+ } else if isAvailable {
icon = (NSWorkspace.shared.icon(forFile: path).copy() as? NSImage)
?? NSWorkspace.shared.icon(forFile: path)
} else {
@@ -66,11 +89,11 @@ struct DockItemPresentation {
switch source {
case .recentFiles:
- let icon = systemIcon(
- preferredName: "clock.fill",
- fallbackName: "clock",
- accessibilityDescription: displayName
- )
+ let icon = customIcon(for: item) ?? systemIcon(
+ preferredName: "clock.fill",
+ fallbackName: "clock",
+ accessibilityDescription: displayName
+ )
icon.size = NSSize(width: 64, height: 64)
return DockItemPresentation(
displayName: displayName,
@@ -90,7 +113,9 @@ struct DockItemPresentation {
FileManager.default.fileExists(atPath: $0.path)
} ?? false
let icon: NSImage
- if let downloadsURL, isAvailable {
+ if let customIcon = customIcon(for: item) {
+ icon = customIcon
+ } else if let downloadsURL, isAvailable {
let workspaceIcon = NSWorkspace.shared.icon(
forFile: downloadsURL.path
)
@@ -130,6 +155,13 @@ struct DockItemPresentation {
)
?? NSImage()
}
+
+ private static func customIcon(for item: DockItem) -> NSImage? {
+ guard let data = item.customIconData,
+ let image = NSImage(data: data)
+ else { return nil }
+ return (image.copy() as? NSImage) ?? image
+ }
}
private extension String {
@@ -145,6 +177,7 @@ extension DockItemKind {
case .document: return "Document"
case .folder: return "Folder"
case .separator: return "Separator"
+ case .trash: return "Trash"
}
}
@@ -154,6 +187,7 @@ extension DockItemKind {
case .document: return "doc"
case .folder: return "folder"
case .separator: return "minus"
+ case .trash: return "trash"
}
}
}
diff --git a/Sources/FreeDock/Models/DockItemTransferPlanner.swift b/Sources/FreeDock/Models/DockItemTransferPlanner.swift
index 0b3e9c6..4b62ebf 100644
--- a/Sources/FreeDock/Models/DockItemTransferPlanner.swift
+++ b/Sources/FreeDock/Models/DockItemTransferPlanner.swift
@@ -12,6 +12,8 @@ enum DockItemTransferDestination: Equatable, Sendable {
/// 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)
+ /// Removes the source item from its dock while retaining the target Trash.
+ case trash(UUID)
case trailing
}
@@ -19,12 +21,15 @@ enum DockItemTransferRejection: Equatable, Sendable {
case sourceItemNotFound
case targetItemNotFound(UUID)
case duplicateItem(existingItemID: UUID)
+ case invalidTrashTarget(UUID)
+ case cannotRemoveTrash
case inconsistentSameDockItems
}
enum DockItemTransferOutcome: Equatable, Sendable {
case moved(itemID: UUID)
case copied(itemID: UUID)
+ case removed(itemID: UUID)
case unchanged
case rejected(DockItemTransferRejection)
}
@@ -36,7 +41,7 @@ struct DockItemTransferPlan: Equatable, Sendable {
var didTransfer: Bool {
switch outcome {
- case .moved, .copied:
+ case .moved, .copied, .removed:
return true
case .unchanged, .rejected:
return false
@@ -69,6 +74,37 @@ enum DockItemTransferPlanner {
reason: .sourceItemNotFound
)
}
+ let sourceItem = sourceDock.items[sourceIndex]
+
+ if case let .trash(targetTrashID) = destination {
+ guard targetDock.items.contains(where: {
+ $0.id == targetTrashID && $0.kind == .trash
+ }) else {
+ return rejectedPlan(
+ sourceItems: sourceDock.items,
+ targetItems: targetDock.items,
+ reason: .invalidTrashTarget(targetTrashID)
+ )
+ }
+ guard sourceItem.kind != .trash else {
+ return rejectedPlan(
+ sourceItems: sourceDock.items,
+ targetItems: targetDock.items,
+ reason: .cannotRemoveTrash
+ )
+ }
+
+ var sourceItems = sourceDock.items
+ sourceItems.remove(at: sourceIndex)
+ let targetItems = sourceDock.id == targetDock.id
+ ? sourceItems
+ : targetDock.items
+ return DockItemTransferPlan(
+ sourceItems: sourceItems,
+ targetItems: targetItems,
+ outcome: .removed(itemID: sourceItem.id)
+ )
+ }
if sourceDock.id == targetDock.id {
guard sourceDock.items == targetDock.items else {
@@ -85,7 +121,6 @@ enum DockItemTransferPlanner {
)
}
- let sourceItem = sourceDock.items[sourceIndex]
guard let insertionIndex = insertionIndex(
for: destination,
in: targetDock.items
@@ -157,6 +192,8 @@ enum DockItemTransferPlanner {
switch destination {
case .item:
finalIndex = targetIndex
+ case .trash:
+ preconditionFailure("Trash removal is planned before reordering")
case .trailing:
finalIndex = items.index(before: items.endIndex)
}
@@ -191,6 +228,8 @@ enum DockItemTransferPlanner {
switch destination {
case let .item(targetItemID):
return items.firstIndex { $0.id == targetItemID }
+ case .trash:
+ return nil
case .trailing:
return items.endIndex
}
@@ -202,6 +241,8 @@ enum DockItemTransferPlanner {
switch destination {
case let .item(targetItemID):
return .targetItemNotFound(targetItemID)
+ case let .trash(targetItemID):
+ return .invalidTrashTarget(targetItemID)
case .trailing:
preconditionFailure("A trailing destination always has an index")
}
@@ -237,6 +278,9 @@ enum DockItemTransferPlanner {
for item: DockItem,
downloadsURL: URL?
) -> DockItemContentIdentity? {
+ if item.kind == .trash {
+ return .trash
+ }
if let source = item.smartStackSource {
if source == .downloads, let downloadsURL {
return .file(pathIdentity(for: downloadsURL))
@@ -291,4 +335,5 @@ enum DockItemTransferPlanner {
private enum DockItemContentIdentity: Equatable {
case file(String)
case smartStack(SmartStackSource)
+ case trash
}
diff --git a/Sources/FreeDock/Models/DockMotionPolicy.swift b/Sources/FreeDock/Models/DockMotionPolicy.swift
new file mode 100644
index 0000000..1c32a4e
--- /dev/null
+++ b/Sources/FreeDock/Models/DockMotionPolicy.swift
@@ -0,0 +1,14 @@
+import Foundation
+
+enum DockMotionPolicy {
+ static func shouldAnimate(reduceMotion: Bool) -> Bool {
+ !reduceMotion
+ }
+
+ static func duration(
+ _ standardDuration: TimeInterval,
+ reduceMotion: Bool
+ ) -> TimeInterval {
+ reduceMotion ? 0 : standardDuration
+ }
+}
diff --git a/Sources/FreeDock/Models/DockPreferencesStore.swift b/Sources/FreeDock/Models/DockPreferencesStore.swift
index 046f224..5292a71 100644
--- a/Sources/FreeDock/Models/DockPreferencesStore.swift
+++ b/Sources/FreeDock/Models/DockPreferencesStore.swift
@@ -12,12 +12,24 @@ enum DockPreferenceChange: Equatable {
case cornerRadius(Double)
case shadowStrength(Double)
case showRunningIndicators(Bool)
+ case showDynamicApplications(Bool)
+ case dynamicApplicationLimit(Int)
case autoHideWhenDocked(Bool)
case autoHideDelay(Double)
}
enum DockManagementAction: Equatable {
+ case undo
+ case exportConfiguration
+ case importConfiguration
+ case restoreLastWorkingConfiguration
+ case exportActiveProfile
+ case importProfile
case activateProfile(UUID)
+ case addProfileApplicationAutomation(UUID)
+ case addProfileDisplayAutomation(profileID: UUID, displayID: UUID)
+ case setProfileAutomationEnabled(profileID: UUID, ruleID: UUID, enabled: Bool)
+ case deleteProfileAutomation(profileID: UUID, ruleID: UUID)
case createProfile
case renameActiveProfile
case deleteActiveProfile
@@ -28,10 +40,14 @@ enum DockManagementAction: Equatable {
case setDockDisplay(UUID, UUID?)
case addDockItems(UUID)
case addSmartStack(UUID, SmartStackSource)
+ case addTrash(UUID)
case clearRecentFiles
case importSystemDockApps(UUID)
case resetDockSettings(UUID)
case copyDockSettingsToAll(UUID)
+ case saveDockTheme(UUID)
+ case applyDockTheme(themeID: UUID, dockID: UUID)
+ case deleteDockTheme(UUID)
}
extension DockConfig {
@@ -59,6 +75,10 @@ extension DockConfig {
shadowStrength = Self.clamp(value, to: Self.shadowStrengthRange)
case let .showRunningIndicators(value):
showRunningIndicators = value
+ case let .showDynamicApplications(value):
+ showDynamicApplications = value
+ case let .dynamicApplicationLimit(value):
+ dynamicApplicationLimit = min(max(value, 1), 10)
case let .autoHideWhenDocked(value):
autoHideWhenDocked = value
case let .autoHideDelay(value):
@@ -75,19 +95,31 @@ final class DockPreferencesStore: ObservableObject {
@Published private(set) var docks: [DockConfig]
@Published private(set) var displays: [DockDisplayDescriptor]
@Published private(set) var permissionState: PreferencesPermissionState
+ @Published private(set) var launchAtLoginState: LaunchAtLoginState
+ @Published private(set) var globalShortcuts: GlobalShortcutSettings
+ @Published private(set) var themes: [DockTheme]
+ @Published private(set) var shortcutError: String?
@Published var selectedDockID: UUID?
+ @Published var settingsSearchText = ""
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
+ private let launchAtLoginSnapshot: () -> LaunchAtLoginState
+ private let onLaunchAtLoginChange: (Bool) -> LaunchAtLoginState
+ private let onOpenLoginItemsSettings: () -> Void
+ private let onGlobalShortcutsChange: (GlobalShortcutSettings) -> String?
init(
profiles: [DockProfile],
activeProfileID: UUID,
displays: [DockDisplayDescriptor] = [],
permissionState: PreferencesPermissionState = PreferencesPermissionState(),
+ launchAtLoginState: LaunchAtLoginState = LaunchAtLoginState(),
+ globalShortcuts: GlobalShortcutSettings = GlobalShortcutSettings(),
+ themes: [DockTheme] = [],
onChange: @escaping (UUID, DockPreferenceChange) -> Void,
onManagementAction: @escaping (DockManagementAction) -> Void,
permissionSnapshot: @escaping () -> PreferencesPermissionSnapshot = {
@@ -98,6 +130,19 @@ final class DockPreferencesStore: ObservableObject {
},
onOpenPermissionSettings: @escaping (PreferencesPermissionKind) -> Void = {
_ in
+ },
+ launchAtLoginSnapshot: @escaping () -> LaunchAtLoginState = {
+ LaunchAtLoginState()
+ },
+ onLaunchAtLoginChange: @escaping (Bool) -> LaunchAtLoginState = {
+ enabled in LaunchAtLoginState(status: enabled ? .enabled : .disabled)
+ },
+ onOpenLoginItemsSettings: @escaping () -> Void = {
+ },
+ onGlobalShortcutsChange: @escaping (
+ GlobalShortcutSettings
+ ) -> String? = { _ in
+ nil
}
) {
self.profiles = profiles
@@ -106,18 +151,31 @@ final class DockPreferencesStore: ObservableObject {
docks = activeDocks
self.displays = displays
self.permissionState = permissionState
+ self.launchAtLoginState = launchAtLoginState
+ self.globalShortcuts = globalShortcuts
+ self.themes = themes
+ shortcutError = nil
selectedDockID = activeDocks.first?.id
self.onChange = onChange
self.onManagementAction = onManagementAction
self.permissionSnapshot = permissionSnapshot
self.onPermissionAction = onPermissionAction
self.onOpenPermissionSettings = onOpenPermissionSettings
+ self.launchAtLoginSnapshot = launchAtLoginSnapshot
+ self.onLaunchAtLoginChange = onLaunchAtLoginChange
+ self.onOpenLoginItemsSettings = onOpenLoginItemsSettings
+ self.onGlobalShortcutsChange = onGlobalShortcutsChange
}
var activeProfileName: String {
profiles.first(where: { $0.id == activeProfileID })?.name ?? "Default"
}
+ var activeProfileAutomationRules: [ProfileAutomationRule] {
+ profiles.first(where: { $0.id == activeProfileID })?
+ .automationRules ?? []
+ }
+
var canDeleteActiveProfile: Bool {
profiles.count > 1
}
@@ -134,7 +192,9 @@ final class DockPreferencesStore: ObservableObject {
func reload(
profiles: [DockProfile],
activeProfileID: UUID,
- displays: [DockDisplayDescriptor]? = nil
+ displays: [DockDisplayDescriptor]? = nil,
+ globalShortcuts: GlobalShortcutSettings? = nil,
+ themes: [DockTheme]? = nil
) {
let profileChanged = activeProfileID != self.activeProfileID
self.profiles = profiles
@@ -142,6 +202,12 @@ final class DockPreferencesStore: ObservableObject {
if let displays {
self.displays = displays
}
+ if let globalShortcuts {
+ self.globalShortcuts = globalShortcuts
+ }
+ if let themes {
+ self.themes = themes
+ }
let activeDocks = profiles.first(where: { $0.id == activeProfileID })?.docks ?? []
docks = activeDocks
@@ -176,6 +242,67 @@ final class DockPreferencesStore: ObservableObject {
permissionState = updatedState
}
+ func refreshLaunchAtLogin() {
+ let snapshot = launchAtLoginSnapshot()
+ guard snapshot != launchAtLoginState else { return }
+ launchAtLoginState = snapshot
+ }
+
+ func setLaunchAtLoginEnabled(_ enabled: Bool) {
+ launchAtLoginState = onLaunchAtLoginChange(enabled)
+ }
+
+ func openLoginItemsSettings() {
+ onOpenLoginItemsSettings()
+ }
+
+ func updateGlobalShortcut(
+ _ shortcut: GlobalShortcut,
+ for action: GlobalShortcutAction
+ ) {
+ var candidate = globalShortcuts
+ candidate.set(shortcut, for: action)
+ if let error = candidate.validationError() {
+ shortcutError = error
+ return
+ }
+ if let error = onGlobalShortcutsChange(candidate) {
+ shortcutError = error
+ return
+ }
+ globalShortcuts = candidate
+ shortcutError = nil
+ }
+
+ func updateProfileShortcut(
+ _ shortcut: GlobalShortcut?,
+ for profileID: UUID
+ ) {
+ var candidate = globalShortcuts
+ candidate.setProfileShortcut(shortcut, for: profileID)
+ if let error = candidate.validationError() {
+ shortcutError = error
+ return
+ }
+ if let error = onGlobalShortcutsChange(candidate) {
+ shortcutError = error
+ return
+ }
+ globalShortcuts = candidate
+ shortcutError = nil
+ }
+
+ func resetGlobalShortcuts() {
+ var defaults = GlobalShortcutSettings()
+ defaults.reconcileProfiles(profiles)
+ if let error = onGlobalShortcutsChange(defaults) {
+ shortcutError = error
+ return
+ }
+ globalShortcuts = defaults
+ shortcutError = nil
+ }
+
func performPermissionAction(_ permission: PreferencesPermissionKind) {
onPermissionAction(permission)
refreshPermissions()
diff --git a/Sources/FreeDock/Models/DockProfile.swift b/Sources/FreeDock/Models/DockProfile.swift
index e7a4fab..468a9a7 100644
--- a/Sources/FreeDock/Models/DockProfile.swift
+++ b/Sources/FreeDock/Models/DockProfile.swift
@@ -1,13 +1,84 @@
import Foundation
+enum ProfileAutomationTriggerKind: String, Codable, CaseIterable, Sendable {
+ case application
+ case display
+}
+
+struct ProfileAutomationRule: Codable, Identifiable, Equatable, Sendable {
+ var id: UUID
+ var triggerKind: ProfileAutomationTriggerKind
+ var value: String
+ var label: String
+ var isEnabled: Bool
+
+ init(
+ id: UUID = UUID(),
+ triggerKind: ProfileAutomationTriggerKind,
+ value: String,
+ label: String,
+ isEnabled: Bool = true
+ ) {
+ self.id = id
+ self.triggerKind = triggerKind
+ self.value = value
+ self.label = label
+ self.isEnabled = isEnabled
+ }
+}
+
struct DockProfile: Codable, Identifiable, Equatable, Sendable {
var id: UUID
var name: String
var docks: [DockConfig]
+ var automationRules: [ProfileAutomationRule]
- init(id: UUID = UUID(), name: String, docks: [DockConfig] = []) {
+ init(
+ id: UUID = UUID(),
+ name: String,
+ docks: [DockConfig] = [],
+ automationRules: [ProfileAutomationRule] = []
+ ) {
self.id = id
self.name = name
self.docks = docks
+ self.automationRules = automationRules
+ }
+
+ private enum CodingKeys: String, CodingKey {
+ case id
+ case name
+ case docks
+ case automationRules
+ }
+
+ 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)
+ docks = try container.decodeIfPresent(
+ [DockConfig].self,
+ forKey: .docks
+ ) ?? []
+ automationRules = try container.decodeIfPresent(
+ [ProfileAutomationRule].self,
+ forKey: .automationRules
+ ) ?? []
+ }
+}
+
+enum ProfileAutomationPlanner {
+ static func matchingProfileID(
+ triggerKind: ProfileAutomationTriggerKind,
+ value: String,
+ profiles: [DockProfile]
+ ) -> UUID? {
+ profiles.first(where: { profile in
+ profile.automationRules.contains(where: { rule in
+ rule.isEnabled
+ && rule.triggerKind == triggerKind
+ && rule.value.caseInsensitiveCompare(value) == .orderedSame
+ })
+ })?.id
}
}
diff --git a/Sources/FreeDock/Models/DockState.swift b/Sources/FreeDock/Models/DockState.swift
index d81c4c3..7b7277a 100644
--- a/Sources/FreeDock/Models/DockState.swift
+++ b/Sources/FreeDock/Models/DockState.swift
@@ -13,6 +13,8 @@ class DockState: ObservableObject {
@Published var cornerRadius: Double
@Published var shadowStrength: Double
@Published var showRunningIndicators: Bool
+ @Published var showDynamicApplications: Bool
+ @Published var dynamicApplicationLimit: Int
@Published private(set) var isQuickLaunchPresented = false
@Published private(set) var quickLaunchFocusGeneration = 0
@@ -28,6 +30,8 @@ class DockState: ObservableObject {
cornerRadius = config.cornerRadius
shadowStrength = config.shadowStrength
showRunningIndicators = config.showRunningIndicators
+ showDynamicApplications = config.showDynamicApplications
+ dynamicApplicationLimit = config.dynamicApplicationLimit
}
func apply(_ config: DockConfig) {
@@ -42,6 +46,8 @@ class DockState: ObservableObject {
cornerRadius = config.cornerRadius
shadowStrength = config.shadowStrength
showRunningIndicators = config.showRunningIndicators
+ showDynamicApplications = config.showDynamicApplications
+ dynamicApplicationLimit = config.dynamicApplicationLimit
}
func presentQuickLaunch() {
diff --git a/Sources/FreeDock/Models/DockTheme.swift b/Sources/FreeDock/Models/DockTheme.swift
new file mode 100644
index 0000000..271480f
--- /dev/null
+++ b/Sources/FreeDock/Models/DockTheme.swift
@@ -0,0 +1,96 @@
+import Foundation
+
+struct DockTheme: Codable, Identifiable, Equatable, Sendable {
+ var id: UUID
+ var name: String
+ var appearance: DockAppearance
+ var surfaceOpacity: Double
+ var blurStyle: DockBlurStyle
+ var cornerRadius: Double
+ var shadowStrength: Double
+
+ init(
+ id: UUID = UUID(),
+ name: String,
+ appearance: DockAppearance,
+ surfaceOpacity: Double,
+ blurStyle: DockBlurStyle,
+ cornerRadius: Double,
+ shadowStrength: Double
+ ) {
+ self.id = id
+ self.name = name.trimmingCharacters(in: .whitespacesAndNewlines)
+ self.appearance = appearance
+ self.surfaceOpacity = DockConfig.clamp(
+ surfaceOpacity,
+ to: DockConfig.surfaceOpacityRange
+ )
+ self.blurStyle = blurStyle
+ self.cornerRadius = DockConfig.clamp(
+ cornerRadius,
+ to: DockConfig.cornerRadiusRange
+ )
+ self.shadowStrength = DockConfig.clamp(
+ shadowStrength,
+ to: DockConfig.shadowStrengthRange
+ )
+ }
+
+ init(id: UUID = UUID(), name: String, dock: DockConfig) {
+ self.init(
+ id: id,
+ name: name,
+ appearance: dock.appearance,
+ surfaceOpacity: dock.surfaceOpacity,
+ blurStyle: dock.blurStyle,
+ cornerRadius: dock.cornerRadius,
+ shadowStrength: dock.shadowStrength
+ )
+ }
+
+ func apply(to dock: inout DockConfig) {
+ dock.appearance = appearance
+ dock.surfaceOpacity = surfaceOpacity
+ dock.blurStyle = blurStyle
+ dock.cornerRadius = cornerRadius
+ dock.shadowStrength = shadowStrength
+ }
+
+ private enum CodingKeys: String, CodingKey {
+ case id
+ case name
+ case appearance
+ case surfaceOpacity
+ case blurStyle
+ case cornerRadius
+ case shadowStrength
+ }
+
+ init(from decoder: Decoder) throws {
+ let container = try decoder.container(keyedBy: CodingKeys.self)
+ self.init(
+ id: try container.decode(UUID.self, forKey: .id),
+ name: try container.decode(String.self, forKey: .name),
+ appearance: (try? container.decode(
+ DockAppearance.self,
+ forKey: .appearance
+ )) ?? .glass,
+ surfaceOpacity: try container.decodeIfPresent(
+ Double.self,
+ forKey: .surfaceOpacity
+ ) ?? 1,
+ blurStyle: (try? container.decode(
+ DockBlurStyle.self,
+ forKey: .blurStyle
+ )) ?? .regular,
+ cornerRadius: try container.decodeIfPresent(
+ Double.self,
+ forKey: .cornerRadius
+ ) ?? 18,
+ shadowStrength: try container.decodeIfPresent(
+ Double.self,
+ forKey: .shadowStrength
+ ) ?? 1
+ )
+ }
+}
diff --git a/Sources/FreeDock/Models/GlobalShortcut.swift b/Sources/FreeDock/Models/GlobalShortcut.swift
new file mode 100644
index 0000000..272999c
--- /dev/null
+++ b/Sources/FreeDock/Models/GlobalShortcut.swift
@@ -0,0 +1,240 @@
+import Carbon.HIToolbox
+import Foundation
+
+enum GlobalShortcutAction: String, Codable, CaseIterable, Sendable {
+ case showHideDocks
+ case quickLaunch
+
+ var title: String {
+ switch self {
+ case .showHideDocks:
+ return "Show or hide docks"
+ case .quickLaunch:
+ return "Quick Launch"
+ }
+ }
+}
+
+struct GlobalShortcut: Codable, Equatable, Hashable, Sendable {
+ static let supportedModifierMask =
+ UInt32(cmdKey) | UInt32(optionKey) | UInt32(controlKey) | UInt32(shiftKey)
+
+ static let defaultShowHide = GlobalShortcut(
+ keyCode: UInt32(kVK_Space),
+ modifiers: UInt32(controlKey) | UInt32(optionKey)
+ )
+ static let defaultQuickLaunch = GlobalShortcut(
+ keyCode: UInt32(kVK_Space),
+ modifiers: UInt32(cmdKey) | UInt32(shiftKey)
+ )
+
+ var keyCode: UInt32
+ var modifiers: UInt32
+
+ init(keyCode: UInt32, modifiers: UInt32) {
+ self.keyCode = keyCode
+ self.modifiers = modifiers & Self.supportedModifierMask
+ }
+
+ var isValid: Bool {
+ modifiers != 0 && Self.keyName(for: keyCode) != nil
+ }
+
+ var displayName: String {
+ var result = ""
+ if modifiers & UInt32(controlKey) != 0 { result += "⌃" }
+ if modifiers & UInt32(optionKey) != 0 { result += "⌥" }
+ if modifiers & UInt32(shiftKey) != 0 { result += "⇧" }
+ if modifiers & UInt32(cmdKey) != 0 { result += "⌘" }
+ result += Self.keyName(for: keyCode) ?? "Key \(keyCode)"
+ return result
+ }
+
+ static func keyName(for keyCode: UInt32) -> String? {
+ keyNames[keyCode]
+ }
+
+ private static let keyNames: [UInt32: String] = [
+ UInt32(kVK_Space): "Space",
+ UInt32(kVK_Return): "Return",
+ UInt32(kVK_Tab): "Tab",
+ UInt32(kVK_Delete): "Delete",
+ UInt32(kVK_ForwardDelete): "Forward Delete",
+ UInt32(kVK_Escape): "Escape",
+ UInt32(kVK_LeftArrow): "←",
+ UInt32(kVK_RightArrow): "→",
+ UInt32(kVK_UpArrow): "↑",
+ UInt32(kVK_DownArrow): "↓",
+ UInt32(kVK_Home): "Home",
+ UInt32(kVK_End): "End",
+ UInt32(kVK_PageUp): "Page Up",
+ UInt32(kVK_PageDown): "Page Down",
+ UInt32(kVK_F1): "F1", UInt32(kVK_F2): "F2",
+ UInt32(kVK_F3): "F3", UInt32(kVK_F4): "F4",
+ UInt32(kVK_F5): "F5", UInt32(kVK_F6): "F6",
+ UInt32(kVK_F7): "F7", UInt32(kVK_F8): "F8",
+ UInt32(kVK_F9): "F9", UInt32(kVK_F10): "F10",
+ UInt32(kVK_F11): "F11", UInt32(kVK_F12): "F12",
+ UInt32(kVK_ANSI_A): "A", UInt32(kVK_ANSI_B): "B",
+ UInt32(kVK_ANSI_C): "C", UInt32(kVK_ANSI_D): "D",
+ UInt32(kVK_ANSI_E): "E", UInt32(kVK_ANSI_F): "F",
+ UInt32(kVK_ANSI_G): "G", UInt32(kVK_ANSI_H): "H",
+ UInt32(kVK_ANSI_I): "I", UInt32(kVK_ANSI_J): "J",
+ UInt32(kVK_ANSI_K): "K", UInt32(kVK_ANSI_L): "L",
+ UInt32(kVK_ANSI_M): "M", UInt32(kVK_ANSI_N): "N",
+ UInt32(kVK_ANSI_O): "O", UInt32(kVK_ANSI_P): "P",
+ UInt32(kVK_ANSI_Q): "Q", UInt32(kVK_ANSI_R): "R",
+ UInt32(kVK_ANSI_S): "S", UInt32(kVK_ANSI_T): "T",
+ UInt32(kVK_ANSI_U): "U", UInt32(kVK_ANSI_V): "V",
+ UInt32(kVK_ANSI_W): "W", UInt32(kVK_ANSI_X): "X",
+ UInt32(kVK_ANSI_Y): "Y", UInt32(kVK_ANSI_Z): "Z",
+ UInt32(kVK_ANSI_0): "0", UInt32(kVK_ANSI_1): "1",
+ UInt32(kVK_ANSI_2): "2", UInt32(kVK_ANSI_3): "3",
+ UInt32(kVK_ANSI_4): "4", UInt32(kVK_ANSI_5): "5",
+ UInt32(kVK_ANSI_6): "6", UInt32(kVK_ANSI_7): "7",
+ UInt32(kVK_ANSI_8): "8", UInt32(kVK_ANSI_9): "9",
+ UInt32(kVK_ANSI_Minus): "-", UInt32(kVK_ANSI_Equal): "=",
+ UInt32(kVK_ANSI_LeftBracket): "[",
+ UInt32(kVK_ANSI_RightBracket): "]",
+ UInt32(kVK_ANSI_Backslash): "\\",
+ UInt32(kVK_ANSI_Semicolon): ";",
+ UInt32(kVK_ANSI_Quote): "'",
+ UInt32(kVK_ANSI_Comma): ",", UInt32(kVK_ANSI_Period): ".",
+ UInt32(kVK_ANSI_Slash): "/", UInt32(kVK_ANSI_Grave): "`",
+ ]
+}
+
+struct ProfileShortcutAssignment: Codable, Equatable, Sendable, Identifiable {
+ var profileID: UUID
+ var shortcut: GlobalShortcut?
+
+ var id: UUID { profileID }
+}
+
+struct GlobalShortcutSettings: Codable, Equatable, Sendable {
+ var showHideDocks: GlobalShortcut
+ var quickLaunch: GlobalShortcut
+ private(set) var profileShortcuts: [ProfileShortcutAssignment]
+
+ init(
+ showHideDocks: GlobalShortcut = .defaultShowHide,
+ quickLaunch: GlobalShortcut = .defaultQuickLaunch,
+ profileShortcuts: [ProfileShortcutAssignment] = []
+ ) {
+ self.showHideDocks = showHideDocks
+ self.quickLaunch = quickLaunch
+ self.profileShortcuts = profileShortcuts
+ }
+
+ private enum CodingKeys: String, CodingKey {
+ case showHideDocks
+ case quickLaunch
+ case profileShortcuts
+ }
+
+ init(from decoder: Decoder) throws {
+ let container = try decoder.container(keyedBy: CodingKeys.self)
+ showHideDocks = try container.decode(
+ GlobalShortcut.self,
+ forKey: .showHideDocks
+ )
+ quickLaunch = try container.decode(
+ GlobalShortcut.self,
+ forKey: .quickLaunch
+ )
+ profileShortcuts = try container.decodeIfPresent(
+ [ProfileShortcutAssignment].self,
+ forKey: .profileShortcuts
+ ) ?? []
+ }
+
+ func shortcut(for action: GlobalShortcutAction) -> GlobalShortcut {
+ switch action {
+ case .showHideDocks: return showHideDocks
+ case .quickLaunch: return quickLaunch
+ }
+ }
+
+ mutating func set(_ shortcut: GlobalShortcut, for action: GlobalShortcutAction) {
+ switch action {
+ case .showHideDocks: showHideDocks = shortcut
+ case .quickLaunch: quickLaunch = shortcut
+ }
+ }
+
+ func shortcut(forProfile profileID: UUID) -> GlobalShortcut? {
+ profileShortcuts.first(where: { $0.profileID == profileID })?.shortcut
+ }
+
+ mutating func setProfileShortcut(
+ _ shortcut: GlobalShortcut?,
+ for profileID: UUID
+ ) {
+ if let index = profileShortcuts.firstIndex(where: {
+ $0.profileID == profileID
+ }) {
+ profileShortcuts[index].shortcut = shortcut
+ } else {
+ profileShortcuts.append(ProfileShortcutAssignment(
+ profileID: profileID,
+ shortcut: shortcut
+ ))
+ }
+ }
+
+ mutating func reconcileProfiles(_ profiles: [DockProfile]) {
+ let profileIDs = Set(profiles.map(\.id))
+ var retained: [ProfileShortcutAssignment] = []
+ var seenIDs: Set = []
+ for assignment in profileShortcuts where
+ profileIDs.contains(assignment.profileID)
+ && seenIDs.insert(assignment.profileID).inserted
+ {
+ retained.append(assignment)
+ }
+
+ var used = Set(retained.compactMap(\.shortcut))
+ used.insert(showHideDocks)
+ used.insert(quickLaunch)
+ for profile in profiles where !seenIDs.contains(profile.id) {
+ let shortcut = Self.defaultProfileShortcuts.first(where: {
+ !used.contains($0)
+ })
+ retained.append(ProfileShortcutAssignment(
+ profileID: profile.id,
+ shortcut: shortcut
+ ))
+ if let shortcut { used.insert(shortcut) }
+ }
+ profileShortcuts = retained
+ }
+
+ func validationError() -> String? {
+ guard showHideDocks.isValid, quickLaunch.isValid else {
+ return "Shortcuts need a supported key and at least one modifier."
+ }
+ guard showHideDocks != quickLaunch else {
+ return "Show/Hide and Quick Launch cannot use the same shortcut."
+ }
+
+ var used: Set = [showHideDocks, quickLaunch]
+ for assignment in profileShortcuts {
+ guard let shortcut = assignment.shortcut else { continue }
+ guard shortcut.isValid else {
+ return "Shortcuts need a supported key and at least one modifier."
+ }
+ guard used.insert(shortcut).inserted else {
+ return "Each action and profile needs a unique shortcut."
+ }
+ }
+ return nil
+ }
+
+ static let defaultProfileShortcuts: [GlobalShortcut] =
+ GlobalShortcutManager.profileKeyCodes.map {
+ GlobalShortcut(
+ keyCode: $0,
+ modifiers: GlobalShortcutManager.standardModifiers
+ )
+ }
+}
diff --git a/Sources/FreeDock/Models/LaunchAtLoginState.swift b/Sources/FreeDock/Models/LaunchAtLoginState.swift
new file mode 100644
index 0000000..17713f0
--- /dev/null
+++ b/Sources/FreeDock/Models/LaunchAtLoginState.swift
@@ -0,0 +1,46 @@
+import Foundation
+
+enum LaunchAtLoginStatus: Equatable, Sendable {
+ case disabled
+ case enabled
+ case requiresApproval
+ case unavailable
+}
+
+struct LaunchAtLoginState: Equatable, Sendable {
+ var status: LaunchAtLoginStatus
+ var errorMessage: String?
+
+ init(
+ status: LaunchAtLoginStatus = .disabled,
+ errorMessage: String? = nil
+ ) {
+ self.status = status
+ self.errorMessage = errorMessage
+ }
+
+ var isEnabled: Bool {
+ status == .enabled || status == .requiresApproval
+ }
+
+ var canChange: Bool {
+ status != .unavailable
+ }
+
+ var needsApproval: Bool {
+ status == .requiresApproval
+ }
+
+ var statusLabel: String {
+ switch status {
+ case .disabled:
+ return "Off"
+ case .enabled:
+ return "On"
+ case .requiresApproval:
+ return "Needs Approval"
+ case .unavailable:
+ return "Requires macOS 13 or later"
+ }
+ }
+}
diff --git a/Sources/FreeDock/Models/OnboardingProgress.swift b/Sources/FreeDock/Models/OnboardingProgress.swift
new file mode 100644
index 0000000..033c473
--- /dev/null
+++ b/Sources/FreeDock/Models/OnboardingProgress.swift
@@ -0,0 +1,37 @@
+import Foundation
+
+enum OnboardingStep: Int, CaseIterable, Equatable, Sendable {
+ case welcome
+ case organize
+ case integrate
+}
+
+struct OnboardingProgress: Equatable, Sendable {
+ private(set) var step: OnboardingStep = .welcome
+
+ var canGoBack: Bool { step.rawValue > 0 }
+ var isLastStep: Bool { step == OnboardingStep.allCases.last }
+
+ mutating func advance() {
+ guard let next = OnboardingStep(rawValue: step.rawValue + 1) else {
+ return
+ }
+ step = next
+ }
+
+ mutating func goBack() {
+ guard let previous = OnboardingStep(rawValue: step.rawValue - 1) else {
+ return
+ }
+ step = previous
+ }
+}
+
+enum OnboardingLaunchPolicy {
+ static func shouldPresent(
+ loadedConfigurationFromDisk: Bool,
+ hasCompletedOnboarding: Bool
+ ) -> Bool {
+ !loadedConfigurationFromDisk && !hasCompletedOnboarding
+ }
+}
diff --git a/Sources/FreeDock/Models/PreferencesSearchMatcher.swift b/Sources/FreeDock/Models/PreferencesSearchMatcher.swift
new file mode 100644
index 0000000..ca34c09
--- /dev/null
+++ b/Sources/FreeDock/Models/PreferencesSearchMatcher.swift
@@ -0,0 +1,18 @@
+import Foundation
+
+enum PreferencesSearchMatcher {
+ static func matches(query: String, text: String) -> Bool {
+ let tokens = query
+ .folding(
+ options: [.caseInsensitive, .diacriticInsensitive, .widthInsensitive],
+ locale: .current
+ )
+ .split(whereSeparator: \.isWhitespace)
+ guard !tokens.isEmpty else { return true }
+ let candidate = text.folding(
+ options: [.caseInsensitive, .diacriticInsensitive, .widthInsensitive],
+ locale: .current
+ )
+ return tokens.allSatisfy { candidate.contains($0) }
+ }
+}
diff --git a/Sources/FreeDock/Models/ProfileFileCodec.swift b/Sources/FreeDock/Models/ProfileFileCodec.swift
new file mode 100644
index 0000000..f73bded
--- /dev/null
+++ b/Sources/FreeDock/Models/ProfileFileCodec.swift
@@ -0,0 +1,85 @@
+import Foundation
+
+struct DockProfileArchive: Codable, Equatable, Sendable {
+ static let currentFormatVersion = 1
+
+ var formatVersion: Int
+ var profile: DockProfile
+ var shortcut: GlobalShortcut?
+
+ init(profile: DockProfile, shortcut: GlobalShortcut?) {
+ formatVersion = Self.currentFormatVersion
+ self.profile = profile
+ self.shortcut = shortcut
+ }
+
+ func materializedProfile(named name: String) -> DockProfile {
+ DockProfile(
+ name: name,
+ docks: profile.docks.map {
+ $0.duplicated(name: $0.name, position: $0.position)
+ },
+ automationRules: profile.automationRules.map {
+ ProfileAutomationRule(
+ triggerKind: $0.triggerKind,
+ value: $0.value,
+ label: $0.label,
+ isEnabled: $0.isEnabled
+ )
+ }
+ )
+ }
+}
+
+enum ProfileFileCodecError: LocalizedError, Equatable {
+ case emptyFile
+ case fileTooLarge
+ case unsupportedVersion(Int)
+ case duplicateDock
+ case duplicateItem
+
+ var errorDescription: String? {
+ switch self {
+ case .emptyFile:
+ return "The selected profile file is empty."
+ case .fileTooLarge:
+ return "The selected profile file is too large."
+ case let .unsupportedVersion(version):
+ return "This profile uses unsupported format version \(version)."
+ case .duplicateDock:
+ return "The profile contains duplicate dock identifiers."
+ case .duplicateItem:
+ return "The profile contains duplicate dock-item identifiers."
+ }
+ }
+}
+
+enum ProfileFileCodec {
+ static let maximumByteCount = ConfigFileCodec.maximumByteCount
+
+ static func encode(_ archive: DockProfileArchive) throws -> Data {
+ let encoder = JSONEncoder()
+ encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
+ return try encoder.encode(archive)
+ }
+
+ static func decode(_ data: Data) throws -> DockProfileArchive {
+ guard !data.isEmpty else { throw ProfileFileCodecError.emptyFile }
+ guard data.count <= maximumByteCount else {
+ throw ProfileFileCodecError.fileTooLarge
+ }
+ let archive = try JSONDecoder().decode(DockProfileArchive.self, from: data)
+ guard archive.formatVersion <= DockProfileArchive.currentFormatVersion else {
+ throw ProfileFileCodecError.unsupportedVersion(archive.formatVersion)
+ }
+ let dockIDs = archive.profile.docks.map(\.id)
+ guard Set(dockIDs).count == dockIDs.count else {
+ throw ProfileFileCodecError.duplicateDock
+ }
+ let itemIDs = archive.profile.docks.flatMap { $0.items.map(\.id) }
+ guard Set(itemIDs).count == itemIDs.count else {
+ throw ProfileFileCodecError.duplicateItem
+ }
+ return archive
+ }
+}
diff --git a/Sources/FreeDock/Models/QuickLaunchSearch.swift b/Sources/FreeDock/Models/QuickLaunchSearch.swift
index 1c8b08b..99e8086 100644
--- a/Sources/FreeDock/Models/QuickLaunchSearch.swift
+++ b/Sources/FreeDock/Models/QuickLaunchSearch.swift
@@ -69,6 +69,9 @@ enum QuickLaunchSearch {
if let label = trimmedNonEmpty(item.label) {
return label
}
+ if item.kind == .trash {
+ return "Trash"
+ }
if let smartStackSource = item.smartStackSource {
return smartStackSource.defaultLabel
}
@@ -99,7 +102,10 @@ enum QuickLaunchSearch {
}
let normalizedPath = normalize(item.path)
- guard item.smartStackSource != nil || !normalizedPath.isEmpty else {
+ guard item.kind == .trash
+ || item.smartStackSource != nil
+ || !normalizedPath.isEmpty
+ else {
return nil
}
diff --git a/Sources/FreeDock/Models/RecentApplicationHistory.swift b/Sources/FreeDock/Models/RecentApplicationHistory.swift
new file mode 100644
index 0000000..e912421
--- /dev/null
+++ b/Sources/FreeDock/Models/RecentApplicationHistory.swift
@@ -0,0 +1,105 @@
+import Foundation
+
+struct RecentApplicationRecord: Codable, Equatable, Identifiable, Sendable {
+ var id: UUID
+ var bundleIdentifier: String
+ var path: String
+ var displayName: String
+ var lastUsedAt: Date
+
+ init(
+ id: UUID = UUID(),
+ bundleIdentifier: String,
+ path: String,
+ displayName: String,
+ lastUsedAt: Date
+ ) {
+ self.id = id
+ self.bundleIdentifier = bundleIdentifier
+ self.path = path
+ self.displayName = displayName
+ self.lastUsedAt = lastUsedAt
+ }
+}
+
+enum RecentApplicationHistoryPlanner {
+ static let maximumLimit = 50
+
+ static func recording(
+ bundleIdentifier: String,
+ path: String,
+ displayName: String,
+ usedAt: Date,
+ in records: [RecentApplicationRecord]
+ ) -> [RecentApplicationRecord] {
+ guard !bundleIdentifier.isEmpty, path.hasPrefix("/") else {
+ return normalized(records)
+ }
+ var updated = normalized(records)
+ let existingID = updated.first(where: {
+ $0.bundleIdentifier == bundleIdentifier
+ })?.id
+ updated.removeAll { $0.bundleIdentifier == bundleIdentifier }
+ updated.insert(
+ RecentApplicationRecord(
+ id: existingID ?? UUID(),
+ bundleIdentifier: bundleIdentifier,
+ path: path,
+ displayName: displayName,
+ lastUsedAt: usedAt
+ ),
+ at: 0
+ )
+ return Array(updated.prefix(maximumLimit))
+ }
+
+ static func normalized(
+ _ records: [RecentApplicationRecord]
+ ) -> [RecentApplicationRecord] {
+ var seen = Set()
+ return records
+ .sorted { $0.lastUsedAt > $1.lastUsedAt }
+ .filter {
+ !$0.bundleIdentifier.isEmpty
+ && $0.path.hasPrefix("/")
+ && seen.insert($0.bundleIdentifier).inserted
+ }
+ .prefix(maximumLimit)
+ .map { $0 }
+ }
+}
+
+enum DynamicApplicationSectionPlanner {
+ static func items(
+ records: [RecentApplicationRecord],
+ runningBundleIDs: Set,
+ pinnedItems: [DockItem],
+ limit: Int
+ ) -> [DockItem] {
+ let pinnedPaths = Set(
+ pinnedItems
+ .filter { $0.kind == .application }
+ .map { URL(fileURLWithPath: $0.path).standardizedFileURL.path }
+ )
+ let ordered = RecentApplicationHistoryPlanner.normalized(records)
+ .filter {
+ !pinnedPaths.contains(
+ URL(fileURLWithPath: $0.path).standardizedFileURL.path
+ )
+ }
+ .sorted { left, right in
+ let leftRunning = runningBundleIDs.contains(left.bundleIdentifier)
+ let rightRunning = runningBundleIDs.contains(right.bundleIdentifier)
+ if leftRunning != rightRunning { return leftRunning }
+ return left.lastUsedAt > right.lastUsedAt
+ }
+
+ return ordered.prefix(max(1, min(limit, 10))).map {
+ DockItem.application(
+ at: URL(fileURLWithPath: $0.path),
+ id: $0.id,
+ label: $0.displayName
+ )
+ }
+ }
+}
diff --git a/Sources/FreeDock/Utilities/PerformanceTrace.swift b/Sources/FreeDock/Utilities/PerformanceTrace.swift
new file mode 100644
index 0000000..463208e
--- /dev/null
+++ b/Sources/FreeDock/Utilities/PerformanceTrace.swift
@@ -0,0 +1,33 @@
+import Foundation
+import OSLog
+
+struct PerformanceTraceInterval {
+ fileprivate let name: StaticString
+ fileprivate let identifier: OSSignpostID
+}
+
+enum PerformanceTrace {
+ private static let log = OSLog(
+ subsystem: "com.freedock.app",
+ category: .pointsOfInterest
+ )
+
+ static func begin(_ name: StaticString) -> PerformanceTraceInterval {
+ let identifier = OSSignpostID(log: log)
+ os_signpost(.begin, log: log, name: name, signpostID: identifier)
+ return PerformanceTraceInterval(
+ name: name,
+ identifier: identifier
+ )
+ }
+
+ static func end(_ interval: PerformanceTraceInterval) {
+ os_signpost(
+ .end,
+ log: log,
+ name: interval.name,
+ signpostID: interval.identifier
+ )
+ }
+
+}
diff --git a/Sources/FreeDock/Views/DockContentView.swift b/Sources/FreeDock/Views/DockContentView.swift
index d32e021..8f12c74 100644
--- a/Sources/FreeDock/Views/DockContentView.swift
+++ b/Sources/FreeDock/Views/DockContentView.swift
@@ -10,6 +10,7 @@ struct DockContentView: View {
let orientation: Orientation
@ObservedObject var state: DockState
@ObservedObject var itemDragCoordinator: DockItemDragCoordinator
+ @ObservedObject private var applicationMonitor = RunningAppMonitor.shared
let onItemActivation: @MainActor (DockItem, NSRect) -> Void
let onItemTransfer: @MainActor (
DockItemDragSession,
@@ -38,6 +39,12 @@ struct DockContentView: View {
[URL]
) -> Bool
let onChooseFilesForApplication: @MainActor (DockItem) -> Void
+ let onCanMoveFilesToTrash: @MainActor ([URL]) -> Bool
+ let onMoveFilesToTrash: @MainActor ([URL]) -> Bool
+ let onEmptyTrashRequested: @MainActor () -> Void
+ let onRenameItemRequested: @MainActor (DockItem) -> Void
+ let onChooseCustomIconRequested: @MainActor (DockItem) -> Void
+ let onRestoreOriginalIconRequested: @MainActor (DockItem) -> Void
let onApplicationHoverChanged: @MainActor (
DockItem,
NSRect,
@@ -76,18 +83,23 @@ struct DockContentView: View {
@State private var externalFileDropExpiryWorkItem: DispatchWorkItem?
@State private var quickLaunchQuery = ""
@State private var quickLaunchSelection = QuickLaunchSelection()
+ @State private var dynamicApplicationItems: [DockItem] = []
private var currentItems: [DockItem] {
displayedItems ?? items
}
+ private var presentedItems: [DockItem] {
+ currentItems + dynamicApplicationItems
+ }
+
private var resizableItemCount: Int {
- DockResizeGestureMath.resizableItemCount(in: currentItems)
+ DockResizeGestureMath.resizableItemCount(in: presentedItems)
}
private var quickLaunchResults: [QuickLaunchSearchResult] {
QuickLaunchSearch.results(
- in: currentItems,
+ in: presentedItems,
matching: quickLaunchQuery
)
}
@@ -150,23 +162,41 @@ struct DockContentView: View {
}
.onAppear {
displayedItems = items
+ refreshDynamicApplications(pinnedItems: items)
if state.isQuickLaunchPresented {
resetQuickLaunch()
}
}
.onChange(of: items) {
displayedItems = $0
+ refreshDynamicApplications(pinnedItems: $0)
reconcileQuickLaunchSelection()
}
.onChange(of: itemDragCoordinator.contentRevision) { _ in
- withAnimation(.spring(
- response: 0.3,
- dampingFraction: 0.74
- )) {
+ if reduceMotion {
displayedItems = items
+ } else {
+ withAnimation(.spring(
+ response: 0.3,
+ dampingFraction: 0.74
+ )) {
+ displayedItems = items
+ }
}
reconcileQuickLaunchSelection()
}
+ .onChange(of: applicationMonitor.recentApplications) { _ in
+ refreshDynamicApplications()
+ }
+ .onChange(of: applicationMonitor.runningBundleIDs) { _ in
+ refreshDynamicApplications()
+ }
+ .onChange(of: state.showDynamicApplications) { _ in
+ refreshDynamicApplications()
+ }
+ .onChange(of: state.dynamicApplicationLimit) { _ in
+ refreshDynamicApplications()
+ }
.onChange(of: state.quickLaunchFocusGeneration) { _ in
resetQuickLaunch()
}
@@ -227,6 +257,15 @@ struct DockContentView: View {
resizableItemCount: resizableItemCount
)
.frame(width: 18)
+ .help("Drag to resize this dock")
+ .accessibilityElement(children: .ignore)
+ .accessibilityLabel("Resize \(state.name) dock")
+ .accessibilityValue(
+ "\(Int(iconSize.rounded())) pixel icons"
+ )
+ .accessibilityHint(
+ "Drag horizontally to change icon size."
+ )
}
}
.padding(.top, magnificationHeadroom)
@@ -247,6 +286,15 @@ struct DockContentView: View {
resizableItemCount: resizableItemCount
)
.frame(height: 18)
+ .help("Drag to resize this dock")
+ .accessibilityElement(children: .ignore)
+ .accessibilityLabel("Resize \(state.name) dock")
+ .accessibilityValue(
+ "\(Int(iconSize.rounded())) pixel icons"
+ )
+ .accessibilityHint(
+ "Drag vertically to change icon size."
+ )
}
}
.padding(.horizontal, magnificationHeadroom * 0.52)
@@ -262,7 +310,11 @@ struct DockContentView: View {
)
.frame(width: 16, height: iconSize + 11)
.help("Drag to move this dock")
+ .accessibilityElement(children: .ignore)
.accessibilityLabel("Move \(state.name) dock")
+ .accessibilityHint(
+ "Drag to reposition the dock on this display."
+ )
} else {
DockDragHandleRepresentable(
panel: panel,
@@ -270,7 +322,11 @@ struct DockContentView: View {
)
.frame(width: iconSize + 11, height: 16)
.help("Drag to move this dock")
+ .accessibilityElement(children: .ignore)
.accessibilityLabel("Move \(state.name) dock")
+ .accessibilityHint(
+ "Drag to reposition the dock on this display."
+ )
}
}
@@ -341,7 +397,7 @@ struct DockContentView: View {
VStack(spacing: state.itemSpacing) { content }
}
- if quickLaunchResults.isEmpty, !currentItems.isEmpty {
+ if quickLaunchResults.isEmpty, !presentedItems.isEmpty {
Text(
quickLaunchQuery.isEmpty
? "No launchable items"
@@ -436,7 +492,7 @@ struct DockContentView: View {
@ViewBuilder
private var content: some View {
- if currentItems.isEmpty {
+ if presentedItems.isEmpty {
Label("Drop apps, files, or folders", systemImage: "plus.circle.fill")
.font(.system(size: 12, weight: .semibold))
.foregroundStyle(chromeColor.opacity(0.65))
@@ -476,6 +532,28 @@ struct DockContentView: View {
}
}
+ if !dynamicApplicationItems.isEmpty {
+ if !currentItems.isEmpty {
+ DockSeparatorView(
+ orientation: orientation,
+ iconSize: iconSize,
+ color: chromeColor
+ )
+ .accessibilityLabel("Recent and running applications")
+ }
+
+ ForEach(
+ Array(dynamicApplicationItems.enumerated()),
+ id: \.element.id
+ ) { offset, item in
+ dockItemCell(
+ item,
+ at: currentItems.count + offset,
+ isTransientApplication: true
+ )
+ }
+ }
+
if !state.isQuickLaunchPresented {
Color.clear
.frame(
@@ -540,16 +618,24 @@ struct DockContentView: View {
@ViewBuilder
private func dockItemCell(
_ item: DockItem,
- at index: Int
+ at index: Int,
+ isTransientApplication: Bool = false
) -> some View {
let cell = DockItemView(
item: item,
iconSize: iconSize,
- scale: scale(for: index, in: currentItems),
+ scale: scale(for: index, in: presentedItems),
onActivate: { screenRect in
onItemActivation(item, screenRect)
},
onRemove: { removeItem(item) },
+ isTransientApplication: isTransientApplication,
+ onKeepInDock: {
+ guard isTransientApplication else { return }
+ var updated = displayedItems ?? items
+ updated.append(item)
+ commitItems(updated)
+ },
onFolderOptionsChanged: { options in
onFolderOptionsChanged(item.id, options)
},
@@ -561,6 +647,14 @@ struct DockContentView: View {
onChooseFilesForApplication: {
onChooseFilesForApplication(item)
},
+ onEmptyTrashRequested: onEmptyTrashRequested,
+ onRenameRequested: { onRenameItemRequested(item) },
+ onChooseCustomIconRequested: {
+ onChooseCustomIconRequested(item)
+ },
+ onRestoreOriginalIconRequested: {
+ onRestoreOriginalIconRequested(item)
+ },
onApplicationHoverChanged: { hovering, screenRect in
onApplicationHoverChanged(
item,
@@ -617,7 +711,21 @@ struct DockContentView: View {
value: selectedQuickLaunchItemID
)
- let interactiveCell = cell
+ if isTransientApplication {
+ applicationFileDropTarget(cell, for: item)
+ } else if item.kind == .trash {
+ applicationFileDropTarget(
+ cell.onDrop(
+ of: [Self.dockItemType],
+ delegate: itemDropDelegate(
+ destination: .trash(item.id),
+ isTargeted: dropTargetBinding(for: item.id)
+ )
+ ),
+ for: item
+ )
+ } else {
+ let interactiveCell = cell
.onDrag {
dragProvider(for: item)
} preview: {
@@ -636,7 +744,31 @@ struct DockContentView: View {
)
)
- applicationFileDropTarget(interactiveCell, for: item)
+ applicationFileDropTarget(interactiveCell, for: item)
+ }
+ }
+
+ private func refreshDynamicApplications(
+ pinnedItems: [DockItem]? = nil
+ ) {
+ let updated: [DockItem] = if state.showDynamicApplications {
+ DynamicApplicationSectionPlanner.items(
+ records: applicationMonitor.recentApplications,
+ runningBundleIDs: applicationMonitor.runningBundleIDs,
+ pinnedItems: pinnedItems ?? currentItems,
+ limit: state.dynamicApplicationLimit
+ )
+ } else {
+ []
+ }
+ let needsResize = updated.count != dynamicApplicationItems.count
+ dynamicApplicationItems = updated
+ if needsResize {
+ DispatchQueue.main.async {
+ panel.resizeToFitContent()
+ }
+ }
+ reconcileQuickLaunchSelection()
}
@ViewBuilder
@@ -745,7 +877,13 @@ struct DockContentView: View {
: (isChecking ? .gray : .accentColor)
let badgeSymbol = isRejected
? "xmark"
- : (isChecking ? "ellipsis" : "arrow.up.forward")
+ : (
+ isChecking
+ ? "ellipsis"
+ : (applicationFileDropPresentation == .trash
+ ? "trash.fill"
+ : "arrow.up.forward")
+ )
shape
.fill(highlightColor.opacity(0.18))
@@ -797,7 +935,31 @@ struct DockContentView: View {
@ViewBuilder
private func dockItemDropIndicator(for item: DockItem) -> some View {
if dropTargetItem == item.id {
- if orientation == .horizontal {
+ if item.kind == .trash {
+ let shape = RoundedRectangle(
+ cornerRadius: 12,
+ style: .continuous
+ )
+ shape
+ .fill(Color.red.opacity(0.18))
+ .padding(1)
+ shape
+ .strokeBorder(Color.red, lineWidth: 2)
+ .padding(1.5)
+ .shadow(color: Color.red.opacity(0.3), radius: 6)
+ Image(systemName: "minus")
+ .font(.system(size: 8, weight: .bold))
+ .foregroundStyle(.white)
+ .frame(width: 16, height: 16)
+ .background(Color.red, in: Circle())
+ .frame(
+ maxWidth: .infinity,
+ maxHeight: .infinity,
+ alignment: .bottomTrailing
+ )
+ .padding(2)
+ .accessibilityHidden(true)
+ } else if orientation == .horizontal {
Capsule()
.fill(Color.accentColor)
.frame(
@@ -1072,8 +1234,12 @@ struct DockContentView: View {
}
}
} else {
- withAnimation(.easeOut(duration: 0.12)) {
+ if reduceMotion {
dropPulse = false
+ } else {
+ withAnimation(.easeOut(duration: 0.12)) {
+ dropPulse = false
+ }
}
}
}
@@ -1165,6 +1331,10 @@ struct DockContentView: View {
_ urls: [URL],
onto item: DockItem
) {
+ if item.kind == .trash {
+ guard onMoveFilesToTrash(urls) else { NSSound.beep(); return }
+ return
+ }
if urls.contains(where: shouldPinAsDockItem) {
addDroppedItems(urls)
return
@@ -1225,8 +1395,10 @@ struct DockContentView: View {
_ item: DockItem
) -> Bool {
!state.isQuickLaunchPresented
- && item.kind == .application
- && item.fileURL != nil
+ && (
+ (item.kind == .application && item.fileURL != nil)
+ || item.kind == .trash
+ )
}
private func beginApplicationFileDropPreflight(
@@ -1285,7 +1457,15 @@ struct DockContentView: View {
}
applicationFileDropURLs = result.urls
- if result.urls.contains(where: shouldPinAsDockItem) {
+ if item.kind == .trash {
+ setApplicationFileDropPresentation(
+ onCanMoveFilesToTrash(result.urls)
+ ? .trash
+ : .rejected,
+ for: item,
+ itemCount: result.urls.count
+ )
+ } else if result.urls.contains(where: shouldPinAsDockItem) {
let plan = DockItemPlanner.planAdding(
urls: result.urls,
to: displayedItems ?? items
@@ -1335,6 +1515,8 @@ struct DockContentView: View {
case .rejected:
announcement =
"Drop unavailable for \(applicationName)."
+ case .trash:
+ announcement = "Move \(count) \(noun) to Trash."
}
NSAccessibility.post(
@@ -1523,7 +1705,11 @@ struct DockContentView: View {
private func removeItem(_ item: DockItem) {
var updatedItems = displayedItems ?? items
updatedItems.removeAll(where: { $0.id == item.id })
- withAnimation { displayedItems = updatedItems }
+ if reduceMotion {
+ displayedItems = updatedItems
+ } else {
+ withAnimation { displayedItems = updatedItems }
+ }
items = updatedItems
}
@@ -1675,6 +1861,7 @@ private enum ApplicationFileDropPresentation: Equatable {
case checking
case open
case pin
+ case trash
case rejected
}
diff --git a/Sources/FreeDock/Views/DockItemView.swift b/Sources/FreeDock/Views/DockItemView.swift
index 94ced77..4e28c87 100644
--- a/Sources/FreeDock/Views/DockItemView.swift
+++ b/Sources/FreeDock/Views/DockItemView.swift
@@ -17,17 +17,38 @@ enum DockApplicationHoverTooltip {
}
}
+private extension View {
+ @ViewBuilder
+ func optionalAccessibilityAction(
+ named name: String,
+ isEnabled: Bool,
+ action: @escaping () -> Void
+ ) -> some View {
+ if isEnabled {
+ accessibilityAction(named: Text(name), action)
+ } else {
+ self
+ }
+ }
+}
+
struct DockItemView: View {
let item: DockItem
let iconSize: Double
let scale: CGFloat
let onActivate: (NSRect) -> Void
let onRemove: () -> Void
+ let isTransientApplication: Bool
+ let onKeepInDock: () -> Void
let onFolderOptionsChanged: (FolderStackOptions) -> Void
let hasRecentFiles: () -> Bool
let onClearRecentFilesRequested: () -> Void
let onOpenDocumentWithApplication: (URL) -> Void
let onChooseFilesForApplication: () -> Void
+ let onEmptyTrashRequested: () -> Void
+ let onRenameRequested: () -> Void
+ let onChooseCustomIconRequested: () -> Void
+ let onRestoreOriginalIconRequested: () -> Void
let onApplicationHoverChanged: (Bool, NSRect) -> Void
let onShowApplicationWindows: (NSRect) -> Void
let onEnableWindowThumbnails: () -> Void
@@ -35,7 +56,11 @@ struct DockItemView: View {
let isWindowPreviewScreenCaptureTrusted: () -> Bool
@ObservedObject private var monitor = RunningAppMonitor.shared
+ @ObservedObject private var trashMonitor = TrashMonitor.shared
@Environment(\.accessibilityReduceMotion) private var reduceMotion
+ @Environment(\.accessibilityDifferentiateWithoutColor)
+ private var differentiateWithoutColor
+ @Environment(\.colorSchemeContrast) private var colorSchemeContrast
@State private var presentation: DockItemPresentation?
@State private var isHovering = false
@Binding var hoveredItem: UUID?
@@ -144,14 +169,48 @@ struct DockItemView: View {
.accessibilityAddTraits(
isQuickLaunchSelected ? .isSelected : []
)
+ .optionalAccessibilityAction(
+ named: "Show in Finder",
+ isEnabled: item.fileURL != nil,
+ action: showInFinder
+ )
+ .optionalAccessibilityAction(
+ named: "Rename",
+ isEnabled: !isTransientApplication
+ && item.kind != .trash
+ && item.kind != .separator,
+ action: onRenameRequested
+ )
+ .optionalAccessibilityAction(
+ named: "Remove from Dock",
+ isEnabled: !isTransientApplication && item.kind != .trash,
+ action: onRemove
+ )
+ .optionalAccessibilityAction(
+ named: "Keep in Dock",
+ isEnabled: isTransientApplication,
+ action: onKeepInDock
+ )
.onAppear(perform: refreshPresentation)
.onChange(of: item) { _ in refreshPresentation() }
+ .onChange(of: trashMonitor.isEmpty) { _ in
+ if item.kind == .trash { refreshPresentation() }
+ }
}
@ViewBuilder
private var runningIndicator: some View {
Circle()
.fill(indicatorColor.opacity(0.78))
+ .overlay(
+ Circle().stroke(
+ Color.primary,
+ lineWidth: differentiateWithoutColor
+ || colorSchemeContrast == .increased
+ ? 1.25
+ : 0
+ )
+ )
.frame(width: 4, height: 4)
.shadow(color: .black.opacity(0.16), radius: 1, y: 1)
.offset(
@@ -194,7 +253,9 @@ struct DockItemView: View {
@ViewBuilder
private var itemContextMenu: some View {
- if item.smartStackSource == .recentFiles {
+ if item.kind == .trash {
+ trashMenu
+ } else if item.smartStackSource == .recentFiles {
recentFilesMenu
} else if item.smartStackSource == .downloads {
downloadsMenu
@@ -212,8 +273,35 @@ struct DockItemView: View {
.disabled(!resolvedPresentation.isAvailable)
Button("Copy Path") { copyPath() }
}
+ if !isTransientApplication, item.kind != .trash {
+ Divider()
+ Button("Rename…", action: onRenameRequested)
+ Button("Choose Custom Icon…", action: onChooseCustomIconRequested)
+ if item.customIconData != nil {
+ Button(
+ "Restore Original Icon",
+ action: onRestoreOriginalIconRequested
+ )
+ }
+ }
+ Divider()
+ if isTransientApplication {
+ Button("Keep in Dock", action: onKeepInDock)
+ } else {
+ Button("Remove from Dock", role: .destructive) { onRemove() }
+ }
+ }
+
+ @ViewBuilder
+ private var trashMenu: some View {
+ Button("Open Trash") { activate() }
Divider()
- Button("Remove from Dock", role: .destructive) { onRemove() }
+ Button(
+ "Empty Trash…",
+ role: .destructive,
+ action: onEmptyTrashRequested
+ )
+ .disabled(trashMonitor.isEmpty)
}
@ViewBuilder
@@ -521,11 +609,13 @@ struct DockItemView: View {
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."
+ return "Opens the application. Use the context menu or VoiceOver Actions to show windows, choose files, rename, or remove it."
case .document:
return "Opens the document in its default application."
case .separator:
return ""
+ case .trash:
+ return "Opens Trash. Drop files here to move them to Trash, or use the context menu to empty it."
}
}
diff --git a/Sources/FreeDock/Views/DockPreferencesView.swift b/Sources/FreeDock/Views/DockPreferencesView.swift
index bd2ddb3..8ab3a78 100644
--- a/Sources/FreeDock/Views/DockPreferencesView.swift
+++ b/Sources/FreeDock/Views/DockPreferencesView.swift
@@ -1,8 +1,16 @@
import AppKit
import SwiftUI
+extension Notification.Name {
+ static let freeDockFocusSettingsSearch = Notification.Name(
+ "FreeDockFocusSettingsSearch"
+ )
+}
+
struct DockPreferencesView: View {
@ObservedObject var store: DockPreferencesStore
+ @Environment(\.colorSchemeContrast) private var colorSchemeContrast
+ @FocusState private var isSettingsSearchFocused: Bool
var body: some View {
HSplitView {
@@ -16,6 +24,7 @@ struct DockPreferencesView: View {
.background(Color(nsColor: .windowBackgroundColor))
.onAppear {
store.refreshPermissions()
+ store.refreshLaunchAtLogin()
}
.onReceive(
NotificationCenter.default.publisher(
@@ -23,6 +32,14 @@ struct DockPreferencesView: View {
)
) { _ in
store.refreshPermissions()
+ store.refreshLaunchAtLogin()
+ }
+ .onReceive(
+ NotificationCenter.default.publisher(
+ for: .freeDockFocusSettingsSearch
+ )
+ ) { _ in
+ isSettingsSearchFocused = true
}
}
@@ -30,6 +47,43 @@ struct DockPreferencesView: View {
VStack(alignment: .leading, spacing: 0) {
profileHeader
+ HStack(spacing: 6) {
+ Image(systemName: "magnifyingglass")
+ .foregroundStyle(.secondary)
+ .accessibilityHidden(true)
+ TextField(
+ "Search Settings",
+ text: $store.settingsSearchText
+ )
+ .textFieldStyle(.plain)
+ .focused($isSettingsSearchFocused)
+ .accessibilityLabel("Search FreeDock settings")
+
+ if !store.settingsSearchText.isEmpty {
+ Button {
+ store.settingsSearchText = ""
+ isSettingsSearchFocused = true
+ } label: {
+ Image(systemName: "xmark.circle.fill")
+ .foregroundStyle(.secondary)
+ }
+ .buttonStyle(.plain)
+ .accessibilityLabel("Clear settings search")
+ }
+ }
+ .padding(.horizontal, 9)
+ .frame(height: 28)
+ .background(
+ RoundedRectangle(cornerRadius: 7, style: .continuous)
+ .fill(Color(nsColor: .controlBackgroundColor))
+ )
+ .overlay(
+ RoundedRectangle(cornerRadius: 7, style: .continuous)
+ .strokeBorder(Color.primary.opacity(0.14), lineWidth: 1)
+ )
+ .padding(.horizontal, 12)
+ .padding(.bottom, 10)
+
Divider()
dockBrowser
@@ -99,6 +153,20 @@ struct DockPreferencesView: View {
Label("Delete Profile…", systemImage: "trash")
}
.disabled(!store.canDeleteActiveProfile)
+
+ Divider()
+
+ Button {
+ store.perform(.exportConfiguration)
+ } label: {
+ Label("Export Configuration…", systemImage: "square.and.arrow.up")
+ }
+
+ Button {
+ store.perform(.importConfiguration)
+ } label: {
+ Label("Import Configuration…", systemImage: "square.and.arrow.down")
+ }
} label: {
Image(systemName: "ellipsis.circle")
.font(.system(size: 15))
@@ -158,6 +226,17 @@ struct DockPreferencesView: View {
.contextMenu {
dockActions(for: dock.id)
}
+ .accessibilityElement(children: .combine)
+ .accessibilityLabel(dock.name)
+ .accessibilityValue(
+ dock.id == store.selectedDockID
+ ? "\(dockSubtitle(for: dock)), selected"
+ : dockSubtitle(for: dock)
+ )
+ .accessibilityHint("Selects this dock for editing.")
+ .accessibilityAddTraits(
+ dock.id == store.selectedDockID ? .isSelected : []
+ )
}
}
.listStyle(.sidebar)
@@ -238,6 +317,15 @@ struct DockPreferencesView: View {
if let dock = store.selectedDock {
ScrollView {
VStack(alignment: .leading, spacing: 20) {
+ if !store.settingsSearchText.isEmpty {
+ Label(
+ "Settings matching “\(store.settingsSearchText)”",
+ systemImage: "magnifyingglass"
+ )
+ .font(.callout)
+ .foregroundStyle(.secondary)
+ }
+
VStack(alignment: .leading, spacing: 5) {
Text(dock.name)
.font(.system(size: 25, weight: .semibold))
@@ -246,12 +334,78 @@ struct DockPreferencesView: View {
.foregroundStyle(.secondary)
}
+ generalSection
+
+ profileAutomationSection
+
permissionsSection
+ configurationSection
+
settingsSection(
title: "Appearance",
symbol: "paintbrush.fill"
) {
+ VStack(alignment: .leading, spacing: 5) {
+ settingRow("Saved themes") {
+ Menu {
+ ForEach(store.themes) { theme in
+ Button(theme.name) {
+ store.perform(
+ .applyDockTheme(
+ themeID: theme.id,
+ dockID: dock.id
+ )
+ )
+ }
+ }
+ } label: {
+ Text(
+ store.themes.isEmpty
+ ? "No Saved Themes"
+ : "Apply Theme"
+ )
+ .frame(minWidth: 108)
+ }
+ .disabled(store.themes.isEmpty)
+
+ Button {
+ store.perform(.saveDockTheme(dock.id))
+ } label: {
+ Image(systemName: "plus")
+ }
+ .help("Save the current appearance as a theme")
+ .accessibilityLabel("Save current appearance theme")
+
+ if !store.themes.isEmpty {
+ Menu {
+ ForEach(store.themes) { theme in
+ Button(
+ "Delete \(theme.name)…",
+ role: .destructive
+ ) {
+ store.perform(
+ .deleteDockTheme(theme.id)
+ )
+ }
+ }
+ } label: {
+ Image(systemName: "ellipsis.circle")
+ }
+ .help("Manage saved themes")
+ .accessibilityLabel("Manage saved themes")
+ }
+ }
+ Text(
+ "Themes reuse style, opacity, blur, corners, and shadow without changing dock contents or behavior."
+ )
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ .fixedSize(horizontal: false, vertical: true)
+ }
+
+ Divider()
+
settingRow("Style") {
Picker("Style", selection: appearanceBinding) {
ForEach(DockAppearance.allCases, id: \.self) { appearance in
@@ -455,6 +609,50 @@ struct DockPreferencesView: View {
smartStack: .downloads,
to: dock.items
).addedCount > 0
+ let canAddTrash = DockItemPlanner.planAddingTrash(
+ to: dock.items
+ ).addedCount > 0
+
+ VStack(alignment: .leading, spacing: 5) {
+ settingRow("Recent & running apps") {
+ Toggle("", isOn: dynamicApplicationsBinding)
+ .labelsHidden()
+ .toggleStyle(.switch)
+ .accessibilityLabel("Recent and running apps")
+ }
+ Text(
+ "Adds unpinned running apps first, followed by apps recently observed by FreeDock. History stays local."
+ )
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ .fixedSize(horizontal: false, vertical: true)
+ }
+
+ Divider()
+
+ settingRow("Maximum apps") {
+ Stepper(
+ "\(dock.dynamicApplicationLimit)",
+ value: dynamicApplicationLimitBinding,
+ in: 1 ... 10
+ )
+ .frame(width: 90)
+ }
+ .disabled(!dock.showDynamicApplications)
+ .opacity(dock.showDynamicApplications ? 1 : 0.48)
+
+ Divider()
+
+ actionRow(
+ title: "Trash",
+ description: "Open Trash, move dropped files into it, or empty it after confirmation.",
+ buttonTitle: canAddTrash ? "Add" : "Added",
+ isDisabled: !canAddTrash
+ ) {
+ store.perform(.addTrash(dock.id))
+ }
+
+ Divider()
actionRow(
title: "Recent Files stack",
@@ -541,6 +739,15 @@ struct DockPreferencesView: View {
} else {
ScrollView {
VStack(alignment: .leading, spacing: 28) {
+ if !store.settingsSearchText.isEmpty {
+ Label(
+ "Settings matching “\(store.settingsSearchText)”",
+ systemImage: "magnifyingglass"
+ )
+ .font(.callout)
+ .foregroundStyle(.secondary)
+ }
+
VStack(spacing: 12) {
Image(systemName: "slider.horizontal.3")
.font(.system(size: 38, weight: .light))
@@ -558,7 +765,13 @@ struct DockPreferencesView: View {
alignment: .center
)
+ generalSection
+
+ profileAutomationSection
+
permissionsSection
+
+ configurationSection
}
.padding(28)
.frame(maxWidth: 760, alignment: .leading)
@@ -566,6 +779,101 @@ struct DockPreferencesView: View {
}
}
+ private var profileAutomationSection: some View {
+ settingsSection(
+ title: "Profile Automation",
+ symbol: "bolt.fill"
+ ) {
+ VStack(alignment: .leading, spacing: 4) {
+ Text("Activate “\(store.activeProfileName)” automatically")
+ Text("Rules use public macOS app and display events and remain entirely on this Mac.")
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ .fixedSize(horizontal: false, vertical: true)
+ }
+ .frame(maxWidth: .infinity, alignment: .leading)
+
+ if store.activeProfileAutomationRules.isEmpty {
+ Text("No automation rules for this profile.")
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ .frame(maxWidth: .infinity, alignment: .leading)
+ } else {
+ ForEach(store.activeProfileAutomationRules) { rule in
+ Divider()
+ HStack(spacing: 10) {
+ Image(systemName: rule.triggerKind == .application
+ ? "app.fill"
+ : "display")
+ .foregroundStyle(.secondary)
+ .frame(width: 18)
+
+ VStack(alignment: .leading, spacing: 2) {
+ Text(rule.label)
+ .lineLimit(1)
+ Text(rule.triggerKind == .application
+ ? "When this app becomes active"
+ : "When this display connects")
+ .font(.caption2)
+ .foregroundStyle(.secondary)
+ }
+ .frame(maxWidth: .infinity, alignment: .leading)
+
+ Toggle("Enable \(rule.label)", isOn: Binding(
+ get: { rule.isEnabled },
+ set: { enabled in
+ store.perform(.setProfileAutomationEnabled(
+ profileID: store.activeProfileID,
+ ruleID: rule.id,
+ enabled: enabled
+ ))
+ }
+ ))
+ .labelsHidden()
+ .toggleStyle(.switch)
+ .controlSize(.small)
+
+ Button(role: .destructive) {
+ store.perform(.deleteProfileAutomation(
+ profileID: store.activeProfileID,
+ ruleID: rule.id
+ ))
+ } label: {
+ Image(systemName: "trash")
+ }
+ .buttonStyle(.borderless)
+ .accessibilityLabel("Delete automation for \(rule.label)")
+ }
+ }
+ }
+
+ Divider()
+
+ HStack {
+ Button("Add Application…") {
+ store.perform(.addProfileApplicationAutomation(
+ store.activeProfileID
+ ))
+ }
+
+ Menu("Add Display") {
+ ForEach(store.displays) { display in
+ Button(display.label) {
+ store.perform(.addProfileDisplayAutomation(
+ profileID: store.activeProfileID,
+ displayID: display.id
+ ))
+ }
+ }
+ }
+ .disabled(store.displays.isEmpty)
+
+ Spacer()
+ }
+ .controlSize(.small)
+ }
+ }
+
private var permissionsSection: some View {
settingsSection(
title: "Permissions",
@@ -593,6 +901,210 @@ struct DockPreferencesView: View {
}
}
+ private var generalSection: some View {
+ settingsSection(
+ title: "General",
+ symbol: "gearshape.fill"
+ ) {
+ HStack(alignment: .top, spacing: 18) {
+ VStack(alignment: .leading, spacing: 3) {
+ Text("Launch at login")
+ Text(
+ "Start FreeDock automatically after you sign in to this Mac."
+ )
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ .fixedSize(horizontal: false, vertical: true)
+
+ if let error = store.launchAtLoginState.errorMessage {
+ Text(error)
+ .font(.caption)
+ .foregroundStyle(.red)
+ .fixedSize(horizontal: false, vertical: true)
+ } else if store.launchAtLoginState.needsApproval {
+ Button("Open Login Items Settings…") {
+ store.openLoginItemsSettings()
+ }
+ .buttonStyle(.link)
+ .controlSize(.small)
+ }
+ }
+ .frame(maxWidth: .infinity, alignment: .leading)
+
+ VStack(alignment: .trailing, spacing: 5) {
+ Toggle("Launch at login", isOn: launchAtLoginBinding)
+ .labelsHidden()
+ .toggleStyle(.switch)
+ .disabled(!store.launchAtLoginState.canChange)
+ Text(store.launchAtLoginState.statusLabel)
+ .font(.caption2)
+ .foregroundStyle(
+ store.launchAtLoginState.needsApproval
+ ? Color.orange
+ : Color.secondary
+ )
+ }
+ }
+
+ Divider()
+
+ shortcutRow(for: .showHideDocks)
+
+ Divider()
+
+ shortcutRow(for: .quickLaunch)
+
+ Divider()
+
+ VStack(alignment: .leading, spacing: 10) {
+ VStack(alignment: .leading, spacing: 3) {
+ Text("Switch profiles")
+ Text("Assign a global shortcut to each workflow. Press Delete while recording to clear one.")
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ .fixedSize(horizontal: false, vertical: true)
+ }
+
+ ForEach(store.profiles) { profile in
+ profileShortcutRow(profile)
+ }
+ }
+
+ if let error = store.shortcutError {
+ Divider()
+
+ Label(error, systemImage: "exclamationmark.triangle.fill")
+ .font(.caption)
+ .foregroundStyle(.orange)
+ .frame(maxWidth: .infinity, alignment: .leading)
+ }
+
+ Divider()
+
+ HStack {
+ Text("Click a shortcut, then type a new key combination.")
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ Spacer()
+ Button("Restore Defaults") {
+ store.resetGlobalShortcuts()
+ }
+ .controlSize(.small)
+ }
+ }
+ }
+
+ private func shortcutRow(for action: GlobalShortcutAction) -> some View {
+ HStack(spacing: 18) {
+ VStack(alignment: .leading, spacing: 3) {
+ Text(action.title)
+ Text(shortcutDescription(for: action))
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ }
+ .frame(maxWidth: .infinity, alignment: .leading)
+
+ ShortcutRecorderButton(
+ shortcut: store.globalShortcuts.shortcut(for: action)
+ ) { shortcut in
+ store.updateGlobalShortcut(shortcut, for: action)
+ }
+ .frame(width: 135, height: 26)
+ }
+ }
+
+ private func shortcutDescription(
+ for action: GlobalShortcutAction
+ ) -> String {
+ switch action {
+ case .showHideDocks:
+ return "Toggle every dock in the active profile."
+ case .quickLaunch:
+ return "Search and open items from the nearest dock."
+ }
+ }
+
+ private func profileShortcutRow(_ profile: DockProfile) -> some View {
+ HStack(spacing: 18) {
+ HStack(spacing: 7) {
+ Image(systemName: profile.id == store.activeProfileID
+ ? "checkmark.circle.fill"
+ : "circle")
+ .foregroundStyle(profile.id == store.activeProfileID
+ ? Color.accentColor
+ : Color.secondary)
+ Text(profile.name)
+ .lineLimit(1)
+ }
+ .frame(maxWidth: .infinity, alignment: .leading)
+
+ ShortcutRecorderButton(
+ optionalShortcut: store.globalShortcuts.shortcut(
+ forProfile: profile.id
+ )
+ ) { shortcut in
+ store.updateProfileShortcut(shortcut, for: profile.id)
+ }
+ .frame(width: 135, height: 26)
+ }
+ .padding(.leading, 10)
+ }
+
+ private var configurationSection: some View {
+ settingsSection(
+ title: "Backup & Restore",
+ symbol: "externaldrive.fill"
+ ) {
+ actionRow(
+ title: "Export configuration",
+ description: "Save every profile, dock, pinned item, and recent-file entry as a portable JSON backup.",
+ buttonTitle: "Export…"
+ ) {
+ store.perform(.exportConfiguration)
+ }
+
+ Divider()
+
+ actionRow(
+ title: "Import configuration",
+ description: "Replace the current setup from a FreeDock JSON backup. Your existing setup is preserved in the automatic recovery history.",
+ buttonTitle: "Import…"
+ ) {
+ store.perform(.importConfiguration)
+ }
+
+ Divider()
+
+ actionRow(
+ title: "Restore last working configuration",
+ description: "Recover the newest valid automatic backup if a recent change or damaged file caused a problem.",
+ buttonTitle: "Restore…"
+ ) {
+ store.perform(.restoreLastWorkingConfiguration)
+ }
+
+ Divider()
+
+ actionRow(
+ title: "Export current profile",
+ description: "Share “\(store.activeProfileName)” with its docks, automation rules, custom artwork, and shortcut.",
+ buttonTitle: "Export…"
+ ) {
+ store.perform(.exportActiveProfile)
+ }
+
+ Divider()
+
+ actionRow(
+ title: "Import profile",
+ description: "Add one profile without replacing your existing profiles or local history.",
+ buttonTitle: "Import…"
+ ) {
+ store.perform(.importProfile)
+ }
+ }
+ }
+
private func permissionRow(
_ presentation: PreferencesPermissionPresentation
) -> some View {
@@ -770,23 +1282,68 @@ struct DockPreferencesView: View {
symbol: String,
@ViewBuilder content: () -> Content
) -> some View {
- VStack(alignment: .leading, spacing: 10) {
- Label(title, systemImage: symbol)
- .font(.headline)
- .foregroundStyle(.primary)
-
- VStack(spacing: 13) {
- content()
+ Group {
+ if PreferencesSearchMatcher.matches(
+ query: store.settingsSearchText,
+ text: title + " " + searchKeywords(for: title)
+ ) {
+ 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(
+ colorSchemeContrast == .increased
+ ? 0.32
+ : 0.09
+ ),
+ lineWidth: colorSchemeContrast == .increased
+ ? 1.5
+ : 1
+ )
+ )
+ }
+ .accessibilityElement(children: .contain)
+ .accessibilityLabel("\(title) settings")
}
- .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 searchKeywords(for sectionTitle: String) -> String {
+ switch sectionTitle {
+ case "General":
+ return "launch login shortcut keyboard quick launch show hide switch profiles"
+ case "Profile Automation":
+ return "workflow rules application app frontmost display monitor connect"
+ case "Permissions":
+ return "privacy accessibility screen recording thumbnails windows"
+ case "Backup & Restore":
+ return "configuration import export json backup restore"
+ case "Appearance":
+ return "theme style glass solid opacity blur corner radius shadow"
+ case "Icons & Layout":
+ return "icon size magnification zoom spacing running indicators"
+ case "Placement":
+ return "display monitor orientation horizontal vertical position"
+ case "Behavior":
+ return "auto hide delay edge reveal"
+ case "Content":
+ return "recent running apps trash files folders downloads stack import dock history"
+ case "Reuse & Reset":
+ return "defaults copy other docks reset"
+ default:
+ return ""
}
}
@@ -851,6 +1408,27 @@ struct DockPreferencesView: View {
)
}
+ private var launchAtLoginBinding: Binding {
+ Binding(
+ get: { store.launchAtLoginState.isEnabled },
+ set: { store.setLaunchAtLoginEnabled($0) }
+ )
+ }
+
+ private var dynamicApplicationsBinding: Binding {
+ Binding(
+ get: { store.selectedDock?.showDynamicApplications ?? false },
+ set: { store.updateSelected(.showDynamicApplications($0)) }
+ )
+ }
+
+ private var dynamicApplicationLimitBinding: Binding {
+ Binding(
+ get: { store.selectedDock?.dynamicApplicationLimit ?? 5 },
+ set: { store.updateSelected(.dynamicApplicationLimit($0)) }
+ )
+ }
+
private func displaySelectionBinding(for dock: DockConfig) -> Binding {
Binding(
get: { store.selectedDock?.displayPlacement?.displayID },
diff --git a/Sources/FreeDock/Views/OnboardingView.swift b/Sources/FreeDock/Views/OnboardingView.swift
new file mode 100644
index 0000000..d615d6a
--- /dev/null
+++ b/Sources/FreeDock/Views/OnboardingView.swift
@@ -0,0 +1,522 @@
+import AppKit
+import SwiftUI
+
+struct OnboardingView: View {
+ let requestAccessibility: @MainActor () -> Bool
+ let requestScreenRecording: @MainActor () -> Bool
+ let onFinish: @MainActor (Bool) -> Void
+
+ @State private var progress = OnboardingProgress()
+ @State private var accessibilityGranted = false
+ @State private var screenRecordingGranted = false
+
+ var body: some View {
+ ZStack {
+ atmosphericBackground
+
+ VStack(spacing: 0) {
+ header
+
+ stepContent
+ .frame(maxWidth: .infinity, maxHeight: .infinity)
+ .padding(.horizontal, 42)
+ .padding(.bottom, 24)
+
+ footer
+ }
+ }
+ .frame(minWidth: 680, minHeight: 520)
+ .animation(.easeInOut(duration: 0.24), value: progress.step)
+ }
+
+ private var accent: Color {
+ switch progress.step {
+ case .welcome: return Color(red: 0.22, green: 0.52, blue: 1)
+ case .organize: return Color(red: 0.55, green: 0.34, blue: 0.96)
+ case .integrate: return Color(red: 0.10, green: 0.68, blue: 0.58)
+ }
+ }
+
+ private var atmosphericBackground: some View {
+ ZStack {
+ Color(nsColor: .windowBackgroundColor)
+
+ LinearGradient(
+ colors: [
+ accent.opacity(0.16),
+ Color.clear,
+ Color(nsColor: .windowBackgroundColor),
+ ],
+ startPoint: .topLeading,
+ endPoint: .bottomTrailing
+ )
+
+ Circle()
+ .fill(accent.opacity(0.14))
+ .frame(width: 330, height: 330)
+ .blur(radius: 80)
+ .offset(x: 260, y: -210)
+
+ Circle()
+ .fill(Color.purple.opacity(0.08))
+ .frame(width: 280, height: 280)
+ .blur(radius: 90)
+ .offset(x: -290, y: 230)
+ }
+ .ignoresSafeArea()
+ }
+
+ private var header: some View {
+ VStack(spacing: 20) {
+ HStack(spacing: 9) {
+ Image(systemName: "square.grid.3x3.fill")
+ .font(.system(size: 15, weight: .semibold))
+ .foregroundStyle(accent)
+ Text("FreeDock")
+ .font(.system(size: 14, weight: .semibold))
+ Spacer()
+ Text("GETTING STARTED")
+ .font(.system(size: 10, weight: .bold))
+ .tracking(1.2)
+ .foregroundStyle(.secondary)
+ }
+
+ HStack(spacing: 6) {
+ ForEach(OnboardingStep.allCases, id: \.rawValue) { step in
+ let isCurrent = step == progress.step
+ let isComplete = step.rawValue < progress.step.rawValue
+ HStack(spacing: 6) {
+ ZStack {
+ Circle()
+ .fill(
+ isCurrent || isComplete
+ ? accent
+ : Color.secondary.opacity(0.16)
+ )
+ .frame(width: 22, height: 22)
+ if isComplete {
+ Image(systemName: "checkmark")
+ .font(.system(size: 9, weight: .bold))
+ .foregroundStyle(.white)
+ } else {
+ Text("\(step.rawValue + 1)")
+ .font(.system(size: 10, weight: .bold))
+ .foregroundStyle(
+ isCurrent ? .white : .secondary
+ )
+ }
+ }
+ Text(step.label)
+ .font(.system(size: 11, weight: .medium))
+ .foregroundStyle(
+ isCurrent ? Color.primary : Color.secondary
+ )
+ }
+ .accessibilityElement(children: .combine)
+ .accessibilityAddTraits(isCurrent ? .isSelected : [])
+
+ if step != OnboardingStep.allCases.last {
+ Capsule()
+ .fill(
+ isComplete
+ ? accent.opacity(0.72)
+ : Color.secondary.opacity(0.14)
+ )
+ .frame(maxWidth: 52, maxHeight: 2)
+ }
+ }
+ }
+ .padding(.horizontal, 18)
+ .padding(.vertical, 10)
+ .background(.ultraThinMaterial, in: Capsule())
+ .overlay {
+ Capsule()
+ .strokeBorder(Color.white.opacity(0.12), lineWidth: 0.6)
+ }
+ }
+ .padding(.horizontal, 28)
+ .padding(.top, 22)
+ .padding(.bottom, 20)
+ }
+
+ @ViewBuilder
+ private var stepContent: some View {
+ switch progress.step {
+ case .welcome:
+ welcomeStep.transition(stepTransition)
+ case .organize:
+ organizeStep.transition(stepTransition)
+ case .integrate:
+ integrateStep.transition(stepTransition)
+ }
+ }
+
+ private var stepTransition: AnyTransition {
+ .asymmetric(
+ insertion: .opacity.combined(with: .move(edge: .trailing)),
+ removal: .opacity.combined(with: .move(edge: .leading))
+ )
+ }
+
+ private var welcomeStep: some View {
+ VStack(spacing: 18) {
+ VStack(spacing: 7) {
+ Text("Your desktop, organized your way.")
+ .font(.system(size: 30, weight: .bold, design: .rounded))
+ .multilineTextAlignment(.center)
+ Text("A free, native macOS dock built around how you work.")
+ .font(.system(size: 16))
+ .foregroundStyle(.secondary)
+ }
+
+ dockPreview
+ .padding(.vertical, 7)
+
+ HStack(spacing: 20) {
+ compactBenefit(symbol: "rectangle.3.group", text: "Multiple docks")
+ compactBenefit(symbol: "display.2", text: "Every display")
+ compactBenefit(symbol: "lock.open.fill", text: "Free & open source")
+ }
+
+ Text("Your first dock is already waiting. Move it, resize it, or snap it to any screen edge.")
+ .font(.callout)
+ .foregroundStyle(.secondary)
+ .multilineTextAlignment(.center)
+ .frame(maxWidth: 500)
+ }
+ }
+
+ private var dockPreview: some View {
+ HStack(spacing: 13) {
+ previewIcon("terminal.fill", colors: [.black, .gray])
+ previewIcon("safari.fill", colors: [.blue, .cyan])
+ previewIcon("message.fill", colors: [.green, .mint])
+ previewIcon("folder.fill", colors: [.cyan, .blue])
+ Divider()
+ .frame(height: 38)
+ .overlay(Color.white.opacity(0.2))
+ previewIcon("trash.fill", colors: [.gray, .black])
+ }
+ .padding(.horizontal, 17)
+ .padding(.vertical, 13)
+ .background(.ultraThinMaterial, in: RoundedRectangle(
+ cornerRadius: 21,
+ style: .continuous
+ ))
+ .overlay {
+ RoundedRectangle(cornerRadius: 21, style: .continuous)
+ .strokeBorder(Color.white.opacity(0.22), lineWidth: 0.8)
+ }
+ .shadow(color: .black.opacity(0.18), radius: 20, y: 12)
+ .shadow(color: accent.opacity(0.18), radius: 28)
+ .accessibilityLabel("Example FreeDock with applications, a folder, and Trash")
+ }
+
+ private func previewIcon(_ symbol: String, colors: [Color]) -> some View {
+ Image(systemName: symbol)
+ .font(.system(size: 23, weight: .semibold))
+ .foregroundStyle(.white)
+ .frame(width: 48, height: 48)
+ .background(
+ LinearGradient(
+ colors: colors,
+ startPoint: .topLeading,
+ endPoint: .bottomTrailing
+ ),
+ in: RoundedRectangle(cornerRadius: 12, style: .continuous)
+ )
+ .overlay {
+ RoundedRectangle(cornerRadius: 12, style: .continuous)
+ .strokeBorder(Color.white.opacity(0.24), lineWidth: 0.7)
+ }
+ .shadow(color: .black.opacity(0.22), radius: 5, y: 3)
+ }
+
+ private func compactBenefit(symbol: String, text: String) -> some View {
+ Label(text, systemImage: symbol)
+ .font(.system(size: 12, weight: .medium))
+ .foregroundStyle(.secondary)
+ }
+
+ private var organizeStep: some View {
+ VStack(spacing: 21) {
+ stepTitle(
+ "Make it unmistakably yours.",
+ subtitle: "Everything important is one gesture or right-click away."
+ )
+ HStack(alignment: .top, spacing: 14) {
+ featureCard(
+ number: "01",
+ symbol: "plus.square.on.square",
+ title: "Drop anything",
+ text: "Drag apps, files, and folders straight from Finder."
+ )
+ featureCard(
+ number: "02",
+ symbol: "arrow.left.arrow.right",
+ title: "Arrange naturally",
+ text: "Reorder icons or move them between project docks."
+ )
+ featureCard(
+ number: "03",
+ symbol: "paintbrush.fill",
+ title: "Make it personal",
+ text: "Rename items, use custom icons, and save visual themes."
+ )
+ }
+
+ Label(
+ "Drop a pinned icon onto FreeDock’s Trash to remove the pin—the original stays safe.",
+ systemImage: "checkmark.shield.fill"
+ )
+ .font(.system(size: 12, weight: .medium))
+ .foregroundStyle(.secondary)
+ .padding(.horizontal, 15)
+ .padding(.vertical, 9)
+ .background(accent.opacity(0.09), in: Capsule())
+ }
+ }
+
+ private var integrateStep: some View {
+ VStack(spacing: 18) {
+ stepTitle(
+ "Powerful when you want it.",
+ subtitle: "The core dock needs no special access. These extras are optional."
+ )
+ HStack(alignment: .top, spacing: 14) {
+ VStack(spacing: 12) {
+ permissionRow(
+ symbol: "macwindow.on.rectangle",
+ title: "Window switching",
+ text: "Accessibility lets FreeDock discover and focus an app’s windows.",
+ granted: accessibilityGranted,
+ action: {
+ accessibilityGranted = requestAccessibility()
+ }
+ )
+ permissionRow(
+ symbol: "rectangle.inset.filled.and.person.filled",
+ title: "Live window thumbnails",
+ text: "Screen Recording adds previews. Titles still work without it.",
+ granted: screenRecordingGranted,
+ action: {
+ screenRecordingGranted = requestScreenRecording()
+ }
+ )
+ }
+ .frame(maxWidth: .infinity)
+
+ supportCard
+ .frame(width: 178)
+ }
+ HStack(spacing: 7) {
+ Image(systemName: "lock.shield.fill")
+ .foregroundStyle(accent)
+ Text("Permissions stay under macOS control and can be revoked anytime.")
+ }
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ }
+ }
+
+ private var supportCard: some View {
+ VStack(spacing: 8) {
+ if let supportQRCode {
+ Image(nsImage: supportQRCode)
+ .resizable()
+ .interpolation(.none)
+ .frame(width: 100, height: 100)
+ .padding(7)
+ .background(.white, in: RoundedRectangle(
+ cornerRadius: 12,
+ style: .continuous
+ ))
+ .accessibilityLabel("Buy Me a Coffee QR code")
+ } else {
+ Image(systemName: "cup.and.saucer.fill")
+ .font(.system(size: 34))
+ .foregroundStyle(accent)
+ .frame(width: 114, height: 114)
+ }
+
+ Text("Support FreeDock")
+ .font(.system(size: 13, weight: .semibold))
+ Text("Free forever. Tips support continued development.")
+ .font(.system(size: 10))
+ .foregroundStyle(.secondary)
+ .multilineTextAlignment(.center)
+ .fixedSize(horizontal: false, vertical: true)
+ Link(
+ "Buy Me a Coffee",
+ destination: Self.supportURL
+ )
+ .font(.system(size: 11, weight: .semibold))
+ }
+ .padding(12)
+ .background(.thinMaterial, in: RoundedRectangle(
+ cornerRadius: 15,
+ style: .continuous
+ ))
+ .overlay {
+ RoundedRectangle(cornerRadius: 15, style: .continuous)
+ .strokeBorder(Color.white.opacity(0.12), lineWidth: 0.7)
+ }
+ .accessibilityElement(children: .contain)
+ }
+
+ private var supportQRCode: NSImage? {
+ guard let url = Bundle.main.url(
+ forResource: "SupportQR",
+ withExtension: "png"
+ ) else { return nil }
+ return NSImage(contentsOf: url)
+ }
+
+ private static let supportURL = URL(
+ string: "https://www.buymeacoffee.com/thksalot"
+ )!
+
+ private func stepTitle(_ title: String, subtitle: String) -> some View {
+ VStack(spacing: 7) {
+ Text(title)
+ .font(.system(size: 27, weight: .bold, design: .rounded))
+ Text(subtitle)
+ .font(.system(size: 15))
+ .foregroundStyle(.secondary)
+ .multilineTextAlignment(.center)
+ }
+ }
+
+ private func featureCard(
+ number: String,
+ symbol: String,
+ title: String,
+ text: String
+ ) -> some View {
+ VStack(alignment: .leading, spacing: 11) {
+ HStack {
+ Image(systemName: symbol)
+ .font(.system(size: 21, weight: .semibold))
+ .foregroundStyle(accent)
+ Spacer()
+ Text(number)
+ .font(.system(size: 10, weight: .bold, design: .monospaced))
+ .foregroundStyle(.tertiary)
+ }
+ Text(title).font(.system(size: 15, weight: .semibold))
+ Text(text)
+ .font(.callout)
+ .foregroundStyle(.secondary)
+ .fixedSize(horizontal: false, vertical: true)
+ }
+ .frame(maxWidth: .infinity, minHeight: 130, alignment: .topLeading)
+ .padding(16)
+ .background(.thinMaterial, in: RoundedRectangle(
+ cornerRadius: 16,
+ style: .continuous
+ ))
+ .overlay {
+ RoundedRectangle(cornerRadius: 16, style: .continuous)
+ .strokeBorder(Color.white.opacity(0.12), lineWidth: 0.7)
+ }
+ .shadow(color: .black.opacity(0.07), radius: 10, y: 5)
+ }
+
+ private func permissionRow(
+ symbol: String,
+ title: String,
+ text: String,
+ granted: Bool,
+ action: @escaping () -> Void
+ ) -> some View {
+ HStack(spacing: 15) {
+ Image(systemName: symbol)
+ .font(.system(size: 21, weight: .medium))
+ .foregroundStyle(granted ? Color.green : accent)
+ .frame(width: 42, height: 42)
+ .background(
+ (granted ? Color.green : accent).opacity(0.12),
+ in: RoundedRectangle(cornerRadius: 11, style: .continuous)
+ )
+ VStack(alignment: .leading, spacing: 4) {
+ HStack(spacing: 7) {
+ Text(title).font(.headline)
+ if granted {
+ Image(systemName: "checkmark.circle.fill")
+ .foregroundStyle(.green)
+ }
+ }
+ Text(text)
+ .font(.callout)
+ .foregroundStyle(.secondary)
+ }
+ Spacer(minLength: 14)
+ Button(granted ? "Enabled" : "Enable", action: action)
+ .buttonStyle(.borderedProminent)
+ .tint(granted ? Color.gray : accent)
+ .disabled(granted)
+ .frame(minWidth: 78)
+ }
+ .padding(15)
+ .background(.thinMaterial, in: RoundedRectangle(
+ cornerRadius: 15,
+ style: .continuous
+ ))
+ .overlay {
+ RoundedRectangle(cornerRadius: 15, style: .continuous)
+ .strokeBorder(Color.white.opacity(0.12), lineWidth: 0.7)
+ }
+ }
+
+ private var footer: some View {
+ HStack(spacing: 11) {
+ Button {
+ progress.goBack()
+ } label: {
+ Label("Back", systemImage: "chevron.left")
+ }
+ .buttonStyle(.borderless)
+ .disabled(!progress.canGoBack)
+
+ Spacer()
+
+ if progress.isLastStep {
+ Button("Open Preferences") { onFinish(true) }
+ .buttonStyle(.bordered)
+ Button("Start Using FreeDock") { onFinish(false) }
+ .buttonStyle(.borderedProminent)
+ .tint(accent)
+ .keyboardShortcut(.defaultAction)
+ } else {
+ Button {
+ progress.advance()
+ } label: {
+ Label("Continue", systemImage: "arrow.right")
+ .labelStyle(.titleAndIcon)
+ }
+ .buttonStyle(.borderedProminent)
+ .tint(accent)
+ .keyboardShortcut(.defaultAction)
+ }
+ }
+ .controlSize(.large)
+ .padding(.horizontal, 26)
+ .padding(.vertical, 16)
+ .background(.ultraThinMaterial)
+ .overlay(alignment: .top) {
+ Rectangle()
+ .fill(Color.white.opacity(0.10))
+ .frame(height: 0.5)
+ }
+ }
+}
+
+private extension OnboardingStep {
+ var label: String {
+ switch self {
+ case .welcome: return "Welcome"
+ case .organize: return "Customize"
+ case .integrate: return "Connect"
+ }
+ }
+}
diff --git a/Sources/FreeDock/Views/ShortcutRecorderButton.swift b/Sources/FreeDock/Views/ShortcutRecorderButton.swift
new file mode 100644
index 0000000..90c0758
--- /dev/null
+++ b/Sources/FreeDock/Views/ShortcutRecorderButton.swift
@@ -0,0 +1,180 @@
+import AppKit
+import Carbon.HIToolbox
+import SwiftUI
+
+struct ShortcutRecorderButton: NSViewRepresentable {
+ let shortcut: GlobalShortcut?
+ let allowsClearing: Bool
+ let onChange: (GlobalShortcut?) -> Void
+
+ init(
+ shortcut: GlobalShortcut,
+ onChange: @escaping (GlobalShortcut) -> Void
+ ) {
+ self.shortcut = shortcut
+ allowsClearing = false
+ self.onChange = { value in
+ if let value { onChange(value) }
+ }
+ }
+
+ init(
+ optionalShortcut: GlobalShortcut?,
+ allowsClearing: Bool = true,
+ onChange: @escaping (GlobalShortcut?) -> Void
+ ) {
+ shortcut = optionalShortcut
+ self.allowsClearing = allowsClearing
+ self.onChange = onChange
+ }
+
+ func makeCoordinator() -> Coordinator {
+ Coordinator(onChange: onChange)
+ }
+
+ func makeNSView(context: Context) -> ShortcutRecorderNSButton {
+ let button = ShortcutRecorderNSButton()
+ button.onChange = context.coordinator.onChange
+ button.shortcut = shortcut
+ button.allowsClearing = allowsClearing
+ return button
+ }
+
+ func updateNSView(
+ _ button: ShortcutRecorderNSButton,
+ context: Context
+ ) {
+ context.coordinator.onChange = onChange
+ button.onChange = context.coordinator.onChange
+ button.shortcut = shortcut
+ button.allowsClearing = allowsClearing
+ }
+
+ final class Coordinator {
+ var onChange: (GlobalShortcut?) -> Void
+
+ init(onChange: @escaping (GlobalShortcut?) -> Void) {
+ self.onChange = onChange
+ }
+ }
+}
+
+final class ShortcutRecorderNSButton: NSButton {
+ var onChange: ((GlobalShortcut?) -> Void)?
+ var shortcut: GlobalShortcut? = .defaultShowHide {
+ didSet { updateTitle() }
+ }
+ var allowsClearing = false
+ private var isRecording = false
+ private var keyMonitor: Any?
+
+ override var acceptsFirstResponder: Bool { true }
+
+ override init(frame frameRect: NSRect) {
+ super.init(frame: frameRect)
+ bezelStyle = .rounded
+ setButtonType(.momentaryPushIn)
+ target = self
+ action = #selector(beginRecording)
+ setAccessibilityRole(.button)
+ updateTitle()
+ }
+
+ required init?(coder: NSCoder) {
+ super.init(coder: coder)
+ }
+
+ @objc private func beginRecording() {
+ isRecording = true
+ title = "Type Shortcut…"
+ setAccessibilityValue("Recording")
+ window?.makeFirstResponder(self)
+ installKeyMonitor()
+ }
+
+ override func keyDown(with event: NSEvent) {
+ guard isRecording else {
+ super.keyDown(with: event)
+ return
+ }
+ capture(event)
+ }
+
+ private func capture(_ event: NSEvent) {
+ if event.keyCode == UInt16(kVK_Escape) {
+ finishRecording()
+ return
+ }
+ if allowsClearing,
+ [UInt16(kVK_Delete), UInt16(kVK_ForwardDelete)]
+ .contains(event.keyCode)
+ {
+ onChange?(nil)
+ finishRecording()
+ return
+ }
+
+ let candidate = GlobalShortcut(
+ keyCode: UInt32(event.keyCode),
+ modifiers: Self.carbonModifiers(from: event.modifierFlags)
+ )
+ guard candidate.isValid else {
+ NSSound.beep()
+ return
+ }
+ onChange?(candidate)
+ finishRecording()
+ }
+
+ override func resignFirstResponder() -> Bool {
+ let result = super.resignFirstResponder()
+ if result { finishRecording() }
+ return result
+ }
+
+ override func viewDidMoveToWindow() {
+ super.viewDidMoveToWindow()
+ if window == nil { finishRecording() }
+ }
+
+ private func finishRecording() {
+ isRecording = false
+ if let keyMonitor {
+ NSEvent.removeMonitor(keyMonitor)
+ self.keyMonitor = nil
+ }
+ updateTitle()
+ }
+
+ private func installKeyMonitor() {
+ if let keyMonitor {
+ NSEvent.removeMonitor(keyMonitor)
+ }
+ keyMonitor = NSEvent.addLocalMonitorForEvents(
+ matching: .keyDown
+ ) { [weak self] event in
+ guard let self, self.isRecording else { return event }
+ self.capture(event)
+ return nil
+ }
+ }
+
+ private func updateTitle() {
+ guard !isRecording else { return }
+ title = shortcut?.displayName ?? "None"
+ setAccessibilityLabel("Record keyboard shortcut")
+ setAccessibilityValue(shortcut?.displayName ?? "No shortcut")
+ }
+
+ private static func carbonModifiers(
+ from flags: NSEvent.ModifierFlags
+ ) -> UInt32 {
+ let flags = flags.intersection(.deviceIndependentFlagsMask)
+ var result: UInt32 = 0
+ if flags.contains(.command) { result |= UInt32(cmdKey) }
+ if flags.contains(.option) { result |= UInt32(optionKey) }
+ if flags.contains(.control) { result |= UInt32(controlKey) }
+ if flags.contains(.shift) { result |= UInt32(shiftKey) }
+ return result
+ }
+}
diff --git a/Sources/FreeDock/Windows/DockPanel.swift b/Sources/FreeDock/Windows/DockPanel.swift
index 42ac2a2..30fec84 100644
--- a/Sources/FreeDock/Windows/DockPanel.swift
+++ b/Sources/FreeDock/Windows/DockPanel.swift
@@ -45,7 +45,10 @@ class DockPanel: NSPanel, NSWindowDelegate {
private var quickLaunchKeyModeEnabled = false
private let edgeTolerance: CGFloat = 2
- private let revealThickness = DockDisplayGeometry.autoHideRevealThickness
+
+ private var reduceMotion: Bool {
+ NSWorkspace.shared.accessibilityDisplayShouldReduceMotion
+ }
override var canBecomeKey: Bool { quickLaunchKeyModeEnabled }
override var canBecomeMain: Bool { false }
@@ -176,9 +179,12 @@ class DockPanel: NSPanel, NSWindowDelegate {
else { return }
let isInteractiveResize = !resizeInteractionTokens.isEmpty
+ let wasAutoHidden = isAutoHidden
+ let hiddenEdge = autoHideEdge
+ let hiddenVisibleFrame = activeScreen?.visibleFrame
let originalFrame = isInteractiveResize
? (resizeReferenceFrame ?? frame)
- : frame
+ : frameForPersistence
let preservedEdge = isInteractiveResize
? resizeReferenceEdge
: resizeAnchorEdge(for: originalFrame)
@@ -192,11 +198,26 @@ class DockPanel: NSPanel, NSWindowDelegate {
dockedEdge: preservedEdge
)
- setFrame(anchoredFrame, display: true)
container.setFrameSize(enforcedSize)
hosting.frame = container.bounds
- if shownFrame != nil {
+ if wasAutoHidden,
+ let hiddenEdge,
+ let hiddenVisibleFrame
+ {
shownFrame = anchoredFrame
+ setFrame(
+ DockDisplayGeometry.hiddenFrame(
+ anchoredFrame,
+ at: hiddenEdge,
+ in: hiddenVisibleFrame
+ ),
+ display: true
+ )
+ } else {
+ setFrame(anchoredFrame, display: true)
+ if shownFrame != nil {
+ shownFrame = anchoredFrame
+ }
}
}
@@ -253,13 +274,26 @@ class DockPanel: NSPanel, NSWindowDelegate {
self.dockContainer?.showRevealIndicator(at: self.revealEdge(for: edge))
self.hostingView?.alphaValue = 0
self.hostingView?.isHidden = true
- NSAnimationContext.runAnimationGroup { context in
- context.duration = 0.24
- context.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut)
- self.animator().setFrame(
- self.hiddenFrame(for: restingFrame, at: edge, in: visibleFrame),
- display: true
- )
+ let hiddenFrame = DockDisplayGeometry.hiddenFrame(
+ restingFrame,
+ at: edge,
+ in: visibleFrame
+ )
+ if DockMotionPolicy.shouldAnimate(
+ reduceMotion: self.reduceMotion
+ ) {
+ NSAnimationContext.runAnimationGroup { context in
+ context.duration = DockMotionPolicy.duration(
+ 0.24,
+ reduceMotion: self.reduceMotion
+ )
+ context.timingFunction = CAMediaTimingFunction(
+ name: .easeInEaseOut
+ )
+ self.animator().setFrame(hiddenFrame, display: true)
+ }
+ } else {
+ self.setFrame(hiddenFrame, display: true)
}
}
@@ -278,9 +312,20 @@ class DockPanel: NSPanel, NSWindowDelegate {
isAutoHideRevealInProgress = true
revealCompletionWorkItem?.cancel()
hostingView?.isHidden = false
+ if reduceMotion {
+ hostingView?.alphaValue = 1
+ setFrame(restingFrame, display: true)
+ isAutoHideRevealInProgress = false
+ dockContainer?.hideRevealIndicator()
+ return
+ }
+
hostingView?.alphaValue = 0
NSAnimationContext.runAnimationGroup { context in
- context.duration = 0.28
+ context.duration = DockMotionPolicy.duration(
+ 0.28,
+ reduceMotion: self.reduceMotion
+ )
context.timingFunction = CAMediaTimingFunction(name: .easeOut)
self.hostingView?.animator().alphaValue = 1
self.animator().setFrame(restingFrame, display: true)
@@ -467,24 +512,4 @@ class DockPanel: NSPanel, NSWindowDelegate {
)
}
- 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/FolderStackPanelController.swift b/Sources/FreeDock/Windows/FolderStackPanelController.swift
index e523779..27f0c35 100644
--- a/Sources/FreeDock/Windows/FolderStackPanelController.swift
+++ b/Sources/FreeDock/Windows/FolderStackPanelController.swift
@@ -107,12 +107,21 @@ final class FolderStackPanelController: NSObject, NSWindowDelegate {
sourceDock.addChildWindow(panel, ordered: .above)
installEventMonitors()
- panel.alphaValue = 0
+ let reduceMotion = NSWorkspace.shared
+ .accessibilityDisplayShouldReduceMotion
+ panel.alphaValue = reduceMotion ? 1 : 0
panel.makeKeyAndOrderFront(nil)
- NSAnimationContext.runAnimationGroup { context in
- context.duration = 0.14
- context.timingFunction = CAMediaTimingFunction(name: .easeOut)
- panel.animator().alphaValue = 1
+ if DockMotionPolicy.shouldAnimate(reduceMotion: reduceMotion) {
+ NSAnimationContext.runAnimationGroup { context in
+ context.duration = DockMotionPolicy.duration(
+ 0.14,
+ reduceMotion: reduceMotion
+ )
+ context.timingFunction = CAMediaTimingFunction(
+ name: .easeOut
+ )
+ panel.animator().alphaValue = 1
+ }
}
}
diff --git a/Sources/FreeDock/Windows/OnboardingWindowController.swift b/Sources/FreeDock/Windows/OnboardingWindowController.swift
new file mode 100644
index 0000000..aab44d3
--- /dev/null
+++ b/Sources/FreeDock/Windows/OnboardingWindowController.swift
@@ -0,0 +1,54 @@
+import Cocoa
+import SwiftUI
+
+@MainActor
+final class OnboardingWindowController: NSWindowController, NSWindowDelegate {
+ private let onClosed: () -> Void
+
+ init(
+ requestAccessibility: @escaping @MainActor () -> Bool,
+ requestScreenRecording: @escaping @MainActor () -> Bool,
+ onFinish: @escaping @MainActor (Bool) -> Void,
+ onClosed: @escaping () -> Void
+ ) {
+ self.onClosed = onClosed
+ let window = NSWindow(
+ contentRect: NSRect(x: 0, y: 0, width: 680, height: 520),
+ styleMask: [.titled, .closable, .fullSizeContentView],
+ backing: .buffered,
+ defer: false
+ )
+ window.title = "Welcome to FreeDock"
+ window.titleVisibility = .hidden
+ window.titlebarAppearsTransparent = true
+ window.isMovableByWindowBackground = true
+ window.backgroundColor = .clear
+ window.minSize = NSSize(width: 680, height: 520)
+ window.isReleasedWhenClosed = false
+ window.contentViewController = NSHostingController(
+ rootView: OnboardingView(
+ requestAccessibility: requestAccessibility,
+ requestScreenRecording: requestScreenRecording,
+ onFinish: onFinish
+ )
+ )
+ window.center()
+ 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?.makeKeyAndOrderFront(nil)
+ }
+
+ func windowWillClose(_: Notification) {
+ onClosed()
+ }
+}
diff --git a/Sources/FreeDock/Windows/PreferencesWindowController.swift b/Sources/FreeDock/Windows/PreferencesWindowController.swift
index be512fc..6bdc3f7 100644
--- a/Sources/FreeDock/Windows/PreferencesWindowController.swift
+++ b/Sources/FreeDock/Windows/PreferencesWindowController.swift
@@ -8,7 +8,7 @@ final class PreferencesWindowController: NSWindowController, NSWindowDelegate {
init(store: DockPreferencesStore) {
self.store = store
- let window = NSWindow(
+ let window = PreferencesWindow(
contentRect: NSRect(x: 0, y: 0, width: 780, height: 570),
styleMask: [.titled, .closable, .miniaturizable, .resizable],
backing: .buffered,
@@ -24,6 +24,15 @@ final class PreferencesWindowController: NSWindowController, NSWindowDelegate {
window.setFrameAutosaveName("FreeDockPreferencesWindow")
super.init(window: window)
window.delegate = self
+ window.onFind = {
+ NotificationCenter.default.post(
+ name: .freeDockFocusSettingsSearch,
+ object: nil
+ )
+ }
+ window.onUndo = { [weak self] in
+ self?.store.perform(.undo)
+ }
}
@available(*, unavailable)
@@ -81,4 +90,29 @@ final class PreferencesWindowController: NSWindowController, NSWindowDelegate {
@objc private func refreshPermissionStatus() {
store.refreshPermissions()
}
+
+}
+
+private final class PreferencesWindow: NSWindow {
+ var onFind: (() -> Void)?
+ var onUndo: (() -> Void)?
+
+ override func performKeyEquivalent(with event: NSEvent) -> Bool {
+ let modifiers = event.modifierFlags.intersection(
+ .deviceIndependentFlagsMask
+ )
+ if modifiers == .command,
+ event.charactersIgnoringModifiers?.lowercased() == "f"
+ {
+ onFind?()
+ return true
+ }
+ if modifiers == .command,
+ event.charactersIgnoringModifiers?.lowercased() == "z"
+ {
+ onUndo?()
+ return true
+ }
+ return super.performKeyEquivalent(with: event)
+ }
}
diff --git a/Sources/FreeDock/Windows/WindowPreviewPanelController.swift b/Sources/FreeDock/Windows/WindowPreviewPanelController.swift
index c4e7903..5c4316e 100644
--- a/Sources/FreeDock/Windows/WindowPreviewPanelController.swift
+++ b/Sources/FreeDock/Windows/WindowPreviewPanelController.swift
@@ -55,6 +55,22 @@ enum WindowPreviewPanelEventPolicy {
}
}
+enum WindowPreviewPermissionFallback: Equatable, Sendable {
+ case closePreview
+ case metadataOnly
+ case fullPreview
+}
+
+enum WindowPreviewPermissionPolicy {
+ static func fallback(
+ accessibilityTrusted: Bool,
+ screenCaptureTrusted: Bool
+ ) -> WindowPreviewPermissionFallback {
+ guard accessibilityTrusted else { return .closePreview }
+ return screenCaptureTrusted ? .fullPreview : .metadataOnly
+ }
+}
+
enum WindowPreviewKeyboardAction: Equatable, Sendable {
case previous
case next
@@ -180,6 +196,38 @@ final class WindowPreviewPanelController {
loadThumbnails(for: sessionID)
}
+ func reconcilePermissions() {
+ switch WindowPreviewPermissionPolicy.fallback(
+ accessibilityTrusted:
+ applicationWindows.isAccessibilityTrusted,
+ screenCaptureTrusted:
+ applicationWindows.isScreenCaptureTrusted
+ ) {
+ case .closePreview:
+ if hoverState.session != nil || panel.isVisible {
+ close(resetNativeController: true)
+ }
+ case .metadataOnly:
+ thumbnailTask?.cancel()
+ thumbnailTask = nil
+ applicationWindows.clearThumbnailCache()
+ thumbnails = [:]
+ thumbnailLoadingIDs = []
+ thumbnailUnavailableIDs = []
+ if panel.isVisible,
+ let target,
+ let sessionID = hoverState.session?.id
+ {
+ configurePanelContent(
+ target: target,
+ sessionID: sessionID
+ )
+ }
+ case .fullPreview:
+ break
+ }
+ }
+
private func showScreenCaptureInstructions() {
let alert = NSAlert()
alert.alertStyle = .informational
@@ -278,6 +326,9 @@ final class WindowPreviewPanelController {
guard applicationWindows.isAccessibilityTrusted else {
_ = applicationWindows.requestAccessibilityAccess()
+ if !applicationWindows.isAccessibilityTrusted {
+ showAccessibilityInstructions()
+ }
return
}
@@ -359,6 +410,22 @@ final class WindowPreviewPanelController {
}
}
+ private func showAccessibilityInstructions() {
+ let alert = NSAlert()
+ alert.alertStyle = .informational
+ alert.messageText = "Enable Window Switching"
+ alert.informativeText = "Allow FreeDock in System Settings → Privacy & Security → Accessibility, then use Show Windows again. The dock continues to launch apps without this permission."
+ alert.addButton(withTitle: "Open Settings")
+ alert.addButton(withTitle: "Not Now")
+ if alert.runModal() == .alertFirstButtonReturn,
+ let url = URL(
+ string: "x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility"
+ )
+ {
+ NSWorkspace.shared.open(url)
+ }
+ }
+
private func scheduleOpen(
token: UUID,
after delay: TimeInterval
@@ -711,6 +778,10 @@ final class WindowPreviewPanelController {
close(resetNativeController: false)
Task { @MainActor in
let result = await controller.focusWindow(id: windowID)
+ if result == .permissionRequired {
+ self.showAccessibilityInstructions()
+ return
+ }
guard result == .accepted else {
NSSound.beep()
return
@@ -785,6 +856,7 @@ final class WindowPreviewPanelController {
}
private func scheduleRefresh() {
+ reconcilePermissions()
refreshTask?.cancel()
guard panel.isVisible,
let target,
diff --git a/Tests/FreeDockTests/ConfigFileCodecTests.swift b/Tests/FreeDockTests/ConfigFileCodecTests.swift
new file mode 100644
index 0000000..8c882de
--- /dev/null
+++ b/Tests/FreeDockTests/ConfigFileCodecTests.swift
@@ -0,0 +1,131 @@
+import Foundation
+import Testing
+@testable import FreeDock
+
+@Suite("Configuration file codec")
+struct ConfigFileCodecTests {
+ @Test("Portable configuration preserves profiles, docks, and history")
+ func roundTrip() throws {
+ let firstDock = DockConfig(name: "Work")
+ let secondDock = DockConfig(name: "Reference")
+ let firstProfile = DockProfile(name: "Office", docks: [firstDock])
+ let secondProfile = DockProfile(name: "Research", docks: [secondDock])
+ let recentURL = URL(fileURLWithPath: "/tmp/FreeDock Notes.txt")
+ let record = RecentFileRecord(
+ path: recentURL.path,
+ displayName: "FreeDock Notes",
+ lastOpenedAt: Date(timeIntervalSince1970: 42)
+ )
+ let config = AppConfig(
+ profiles: [firstProfile, secondProfile],
+ activeProfileID: secondProfile.id,
+ recentFiles: [record],
+ themes: [DockTheme(name: "Work Glass", dock: firstDock)]
+ )
+
+ let decoded = try ConfigFileCodec.decode(
+ ConfigFileCodec.encode(config)
+ )
+
+ #expect(decoded.profiles.map { $0.name } == ["Office", "Research"])
+ #expect(
+ decoded.profiles.flatMap { $0.docks }.map { $0.name }
+ == ["Work", "Reference"]
+ )
+ #expect(decoded.activeProfileID == secondProfile.id)
+ #expect(decoded.recentFiles.compactMap { $0.fileURL } == [recentURL])
+ #expect(decoded.themes.map(\.name) == ["Work Glass"])
+ }
+
+ @Test("Future configuration formats are rejected")
+ func rejectsFutureVersion() throws {
+ let data = Data("""
+ {
+ "formatVersion": \(AppConfig.currentFormatVersion + 1),
+ "profiles": []
+ }
+ """.utf8)
+
+ #expect(throws: ConfigFileCodecError.unsupportedVersion(
+ AppConfig.currentFormatVersion + 1
+ )) {
+ try ConfigFileCodec.decode(data)
+ }
+ }
+
+ @Test("Empty and oversized files are rejected before decoding")
+ func rejectsInvalidSizes() {
+ #expect(throws: ConfigFileCodecError.emptyFile) {
+ try ConfigFileCodec.decode(Data())
+ }
+ #expect(throws: ConfigFileCodecError.fileTooLarge) {
+ try ConfigFileCodec.decode(
+ Data(count: ConfigFileCodec.maximumByteCount + 1)
+ )
+ }
+ }
+
+ @Test("Duplicate identifiers are rejected")
+ func rejectsDuplicateIdentifiers() throws {
+ let profileID = UUID()
+ let dockID = UUID()
+ let duplicateProfiles = AppConfig(
+ profiles: [
+ DockProfile(id: profileID, name: "One"),
+ DockProfile(id: profileID, name: "Two")
+ ]
+ )
+ #expect(throws: ConfigFileCodecError.duplicateProfile) {
+ try ConfigFileCodec.decode(
+ ConfigFileCodec.encode(duplicateProfiles)
+ )
+ }
+
+ let duplicateDocks = AppConfig(
+ profiles: [
+ DockProfile(
+ name: "One",
+ docks: [DockConfig(id: dockID, name: "A")]
+ ),
+ DockProfile(
+ name: "Two",
+ docks: [DockConfig(id: dockID, name: "B")]
+ )
+ ]
+ )
+ #expect(throws: ConfigFileCodecError.duplicateDock) {
+ try ConfigFileCodec.decode(
+ ConfigFileCodec.encode(duplicateDocks)
+ )
+ }
+
+ let themeID = UUID()
+ let duplicateThemes = AppConfig(
+ themes: [
+ DockTheme(
+ id: themeID,
+ name: "One",
+ appearance: .glass,
+ surfaceOpacity: 1,
+ blurStyle: .regular,
+ cornerRadius: 18,
+ shadowStrength: 1
+ ),
+ DockTheme(
+ id: themeID,
+ name: "Two",
+ appearance: .dark,
+ surfaceOpacity: 0.8,
+ blurStyle: .strong,
+ cornerRadius: 22,
+ shadowStrength: 1.5
+ ),
+ ]
+ )
+ #expect(throws: ConfigFileCodecError.duplicateTheme) {
+ try ConfigFileCodec.decode(
+ ConfigFileCodec.encode(duplicateThemes)
+ )
+ }
+ }
+}
diff --git a/Tests/FreeDockTests/ConfigManagerTests.swift b/Tests/FreeDockTests/ConfigManagerTests.swift
index a73f624..358ec02 100644
--- a/Tests/FreeDockTests/ConfigManagerTests.swift
+++ b/Tests/FreeDockTests/ConfigManagerTests.swift
@@ -48,3 +48,146 @@ func missingFile() {
#expect(!mgr.loadedFromDisk)
#expect(mgr.config.docks.isEmpty)
}
+
+@Test("ConfigManager keeps three rotating backup generations")
+func rotatingBackups() throws {
+ let tempDir = FileManager.default.temporaryDirectory
+ .appendingPathComponent("fd-test-\(UUID().uuidString)")
+ try FileManager.default.createDirectory(
+ at: tempDir,
+ withIntermediateDirectories: true
+ )
+ defer { try? FileManager.default.removeItem(at: tempDir) }
+
+ let path = tempDir.appendingPathComponent("freedock.json")
+ let manager = ConfigManager(configPath: path)
+ for version in 1...4 {
+ manager.config = AppConfig(docks: [
+ DockConfig(name: "v\(version)"),
+ ])
+ manager.saveImmediately()
+ }
+
+ let names = try [".bak", ".bak.1", ".bak.2"].map { suffix in
+ let data = try Data(contentsOf: URL(fileURLWithPath: path.path + suffix))
+ return try ConfigFileCodec.decode(data).docks[0].name
+ }
+ #expect(names == ["v3", "v2", "v1"])
+}
+
+@Test("ConfigManager recovers from the newest valid backup")
+func recoversFromBackup() throws {
+ let tempDir = FileManager.default.temporaryDirectory
+ .appendingPathComponent("fd-test-\(UUID().uuidString)")
+ try FileManager.default.createDirectory(
+ at: tempDir,
+ withIntermediateDirectories: true
+ )
+ defer { try? FileManager.default.removeItem(at: tempDir) }
+
+ let path = tempDir.appendingPathComponent("freedock.json")
+ let manager = ConfigManager(configPath: path)
+ manager.config = AppConfig(docks: [DockConfig(name: "Working")])
+ manager.saveImmediately()
+ manager.config = AppConfig(docks: [DockConfig(name: "Current")])
+ manager.saveImmediately()
+ try Data("not json".utf8).write(to: path)
+
+ let recovered = ConfigManager(configPath: path)
+ #expect(recovered.loadedFromDisk)
+ #expect(recovered.loadSource == .backup(0))
+ #expect(recovered.config.docks[0].name == "Working")
+}
+
+@Test("ConfigManager skips corrupt backup generations")
+func skipsCorruptBackups() throws {
+ let tempDir = FileManager.default.temporaryDirectory
+ .appendingPathComponent("fd-test-\(UUID().uuidString)")
+ try FileManager.default.createDirectory(
+ at: tempDir,
+ withIntermediateDirectories: true
+ )
+ defer { try? FileManager.default.removeItem(at: tempDir) }
+
+ let path = tempDir.appendingPathComponent("freedock.json")
+ let manager = ConfigManager(configPath: path)
+ for version in 1...3 {
+ manager.config = AppConfig(docks: [DockConfig(name: "v\(version)")])
+ manager.saveImmediately()
+ }
+ try Data("corrupt primary".utf8).write(to: path)
+ try Data("corrupt newest backup".utf8).write(
+ to: URL(fileURLWithPath: path.path + ".bak")
+ )
+
+ let recovered = ConfigManager(configPath: path)
+ #expect(recovered.loadSource == .backup(1))
+ #expect(recovered.config.docks[0].name == "v1")
+}
+
+@Test("Saving after corruption preserves the valid backup")
+func corruptPrimaryIsNotRotated() throws {
+ let tempDir = FileManager.default.temporaryDirectory
+ .appendingPathComponent("fd-test-\(UUID().uuidString)")
+ try FileManager.default.createDirectory(
+ at: tempDir,
+ withIntermediateDirectories: true
+ )
+ defer { try? FileManager.default.removeItem(at: tempDir) }
+
+ let path = tempDir.appendingPathComponent("freedock.json")
+ let manager = ConfigManager(configPath: path)
+ manager.config = AppConfig(docks: [DockConfig(name: "Safe")])
+ manager.saveImmediately()
+ manager.config = AppConfig(docks: [DockConfig(name: "Current")])
+ manager.saveImmediately()
+ try Data("corrupt".utf8).write(to: path)
+
+ let recovered = ConfigManager(configPath: path)
+ recovered.saveImmediately()
+
+ let backup = try ConfigFileCodec.decode(Data(
+ contentsOf: URL(fileURLWithPath: path.path + ".bak")
+ ))
+ #expect(backup.docks[0].name == "Safe")
+ #expect(ConfigManager.load(from: path).docks[0].name == "Safe")
+}
+
+@Test("Manual restore persists the latest working backup")
+func manualRestore() throws {
+ let tempDir = FileManager.default.temporaryDirectory
+ .appendingPathComponent("fd-test-\(UUID().uuidString)")
+ try FileManager.default.createDirectory(
+ at: tempDir,
+ withIntermediateDirectories: true
+ )
+ defer { try? FileManager.default.removeItem(at: tempDir) }
+
+ let path = tempDir.appendingPathComponent("freedock.json")
+ let manager = ConfigManager(configPath: path)
+ manager.config = AppConfig(docks: [DockConfig(name: "Previous")])
+ manager.saveImmediately()
+ manager.config = AppConfig(docks: [DockConfig(name: "Current")])
+ manager.saveImmediately()
+
+ #expect(manager.restoreLatestBackup())
+ #expect(manager.config.docks[0].name == "Previous")
+ #expect(ConfigManager.load(from: path).docks[0].name == "Previous")
+}
+
+@Test("ConfigManager creates a missing configuration directory")
+func createsMissingDirectory() {
+ let tempDir = FileManager.default.temporaryDirectory
+ .appendingPathComponent("fd-test-\(UUID().uuidString)")
+ defer { try? FileManager.default.removeItem(at: tempDir) }
+
+ let path = tempDir
+ .appendingPathComponent("nested")
+ .appendingPathComponent("freedock.json")
+ let manager = ConfigManager(configPath: path)
+ manager.config = AppConfig(docks: [DockConfig(name: "Saved")])
+ manager.saveImmediately()
+
+ #expect(FileManager.default.fileExists(atPath: path.path))
+ #expect(ConfigManager.load(from: path).docks[0].name == "Saved")
+}
diff --git a/Tests/FreeDockTests/DockItemIconEncoderTests.swift b/Tests/FreeDockTests/DockItemIconEncoderTests.swift
new file mode 100644
index 0000000..c2a3af2
--- /dev/null
+++ b/Tests/FreeDockTests/DockItemIconEncoderTests.swift
@@ -0,0 +1,35 @@
+import AppKit
+import Foundation
+import Testing
+@testable import FreeDock
+
+@MainActor
+@Test("Custom dock icons are normalized to portable square PNG data")
+func customDockIconEncoding() throws {
+ let source = try #require(NSBitmapImageRep(
+ bitmapDataPlanes: nil,
+ pixelsWide: 24,
+ pixelsHigh: 12,
+ bitsPerSample: 8,
+ samplesPerPixel: 4,
+ hasAlpha: true,
+ isPlanar: false,
+ colorSpaceName: .deviceRGB,
+ bytesPerRow: 0,
+ bitsPerPixel: 0
+ ))
+ let sourceData = try #require(
+ source.representation(using: .png, properties: [:])
+ )
+ let url = FileManager.default.temporaryDirectory
+ .appendingPathComponent("freedock-custom-icon-\(UUID().uuidString).png")
+ try sourceData.write(to: url)
+ defer { try? FileManager.default.removeItem(at: url) }
+
+ let encoded = try #require(DockItemIconEncoder.encodeImage(at: url))
+ let normalized = try #require(NSBitmapImageRep(data: encoded))
+
+ #expect(normalized.pixelsWide == DockItemIconEncoder.pixelSize)
+ #expect(normalized.pixelsHigh == DockItemIconEncoder.pixelSize)
+ #expect(NSImage(data: encoded) != nil)
+}
diff --git a/Tests/FreeDockTests/DockItemModelTests.swift b/Tests/FreeDockTests/DockItemModelTests.swift
index 653d44a..553f829 100644
--- a/Tests/FreeDockTests/DockItemModelTests.swift
+++ b/Tests/FreeDockTests/DockItemModelTests.swift
@@ -47,7 +47,8 @@ func typedDockItemsRoundTrip() throws {
DockItem(
kind: .application,
path: "/Applications/FreeDock.app",
- label: "FreeDock"
+ label: "FreeDock",
+ customIconData: Data([0, 1, 2, 3])
),
DockItem(
kind: .document,
@@ -75,6 +76,8 @@ func typedDockItemsRoundTrip() throws {
#expect(objects.allSatisfy { $0["appPath"] != nil })
#expect(objects.allSatisfy { $0["isSeparator"] != nil })
#expect(objects[2]["kind"] as? String == "folder")
+ #expect(decoded[0].customIconData == Data([0, 1, 2, 3]))
+ #expect(objects[0]["customIconData"] != nil)
}
@Test("Unknown item and folder option values migrate tolerantly")
@@ -160,7 +163,12 @@ func typedDockItemDuplication() {
let original = DockConfig(
name: "Files",
items: [
- DockItem(kind: .document, path: "/tmp/Brief.pdf", label: "Brief"),
+ DockItem(
+ kind: .document,
+ path: "/tmp/Brief.pdf",
+ label: "Brief",
+ customIconData: Data([4, 5, 6])
+ ),
DockItem(
kind: .folder,
path: "/tmp/Project",
@@ -177,6 +185,10 @@ func typedDockItemDuplication() {
#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(\.customIconData)
+ == original.items.map(\.customIconData)
+ )
#expect(
duplicate.items.map(\.folderOptions)
== original.items.map(\.folderOptions)
@@ -229,6 +241,35 @@ func dockItemPlannerOrderAndUniqueness() throws {
#expect(plan.items.suffix(2).map(\.kind) == [.document, .folder])
}
+@Test("Trash items round-trip without a filesystem path")
+func trashItemRoundTrip() throws {
+ let item = DockItem.trash()
+ let data = try JSONEncoder().encode(item)
+ let decoded = try JSONDecoder().decode(DockItem.self, from: data)
+
+ #expect(decoded == item)
+ #expect(decoded.kind == .trash)
+ #expect(decoded.path.isEmpty)
+ #expect(decoded.fileURL == nil)
+ #expect(decoded.label == "Trash")
+}
+
+@Test("Dock item planner allows one Trash item per dock")
+func dockItemPlannerTrashUniqueness() throws {
+ let existing = [DockItem.separator()]
+ let added = DockItemPlanner.planAddingTrash(to: existing)
+ let trash = try #require(added.items.last)
+
+ #expect(added.addedCount == 1)
+ #expect(added.skippedCount == 0)
+ #expect(trash.kind == .trash)
+
+ let duplicate = DockItemPlanner.planAddingTrash(to: added.items)
+ #expect(duplicate.items == added.items)
+ #expect(duplicate.addedCount == 0)
+ #expect(duplicate.skippedCount == 1)
+}
+
private func makeDockItemTemporaryDirectory() throws -> URL {
let url = FileManager.default.temporaryDirectory
.appendingPathComponent("freedock-items-\(UUID().uuidString)")
diff --git a/Tests/FreeDockTests/DockItemTransferPlannerTests.swift b/Tests/FreeDockTests/DockItemTransferPlannerTests.swift
index c7387dd..f648694 100644
--- a/Tests/FreeDockTests/DockItemTransferPlannerTests.swift
+++ b/Tests/FreeDockTests/DockItemTransferPlannerTests.swift
@@ -214,6 +214,73 @@ struct DockItemTransferPlannerTests {
#expect(optionReorder.outcome == .moved(itemID: first.id))
}
+ @Test("Dropping a pinned item on Trash removes only the pinned item")
+ func sameDockTrashRemoval() {
+ let app = DockItem(appPath: "/Applications/Example.app")
+ let trash = DockItem.trash()
+ let dock = DockConfig(name: "Dock", items: [app, trash])
+
+ let plan = DockItemTransferPlanner.planTransfer(
+ itemID: app.id,
+ from: dock,
+ to: dock,
+ destination: .trash(trash.id)
+ )
+
+ #expect(plan.sourceItems == [trash])
+ #expect(plan.targetItems == [trash])
+ #expect(plan.outcome == .removed(itemID: app.id))
+ #expect(plan.didTransfer)
+ }
+
+ @Test("Cross-dock Trash removal leaves the target dock unchanged")
+ func crossDockTrashRemoval() {
+ let document = DockItem.document(
+ at: URL(fileURLWithPath: "/tmp/Document.pdf")
+ )
+ let source = DockConfig(name: "Source", items: [document])
+ let trash = DockItem.trash()
+ let target = DockConfig(name: "Target", items: [trash])
+
+ let plan = DockItemTransferPlanner.planTransfer(
+ itemID: document.id,
+ from: source,
+ to: target,
+ operation: .copy,
+ destination: .trash(trash.id)
+ )
+
+ #expect(plan.sourceItems.isEmpty)
+ #expect(plan.targetItems == target.items)
+ #expect(plan.outcome == .removed(itemID: document.id))
+ }
+
+ @Test("Trash removal rejects invalid targets and the Trash itself")
+ func invalidTrashRemoval() {
+ let app = DockItem(appPath: "/Applications/Example.app")
+ let trash = DockItem.trash()
+ let dock = DockConfig(name: "Dock", items: [app, trash])
+ let missingID = UUID()
+
+ let missing = DockItemTransferPlanner.planTransfer(
+ itemID: app.id,
+ from: dock,
+ to: dock,
+ destination: .trash(missingID)
+ )
+ let selfRemoval = DockItemTransferPlanner.planTransfer(
+ itemID: trash.id,
+ from: dock,
+ to: dock,
+ destination: .trash(trash.id)
+ )
+
+ #expect(missing.outcome == .rejected(.invalidTrashTarget(missingID)))
+ #expect(selfRemoval.outcome == .rejected(.cannotRemoveTrash))
+ #expect(missing.sourceItems == dock.items)
+ #expect(selfRemoval.sourceItems == dock.items)
+ }
+
@Test("Downloads stacks and the real Downloads folder conflict")
func downloadsIdentityConflict() {
let downloadsURL = URL(fileURLWithPath: "/tmp/FreeDock Downloads")
diff --git a/Tests/FreeDockTests/DockMotionPolicyTests.swift b/Tests/FreeDockTests/DockMotionPolicyTests.swift
new file mode 100644
index 0000000..e3b8a7c
--- /dev/null
+++ b/Tests/FreeDockTests/DockMotionPolicyTests.swift
@@ -0,0 +1,28 @@
+import Testing
+
+@testable import FreeDock
+
+@Suite("Dock motion policy")
+struct DockMotionPolicyTests {
+ @Test("Standard motion retains animation timing")
+ func standardMotion() {
+ #expect(DockMotionPolicy.shouldAnimate(reduceMotion: false))
+ #expect(
+ DockMotionPolicy.duration(
+ 0.24,
+ reduceMotion: false
+ ) == 0.24
+ )
+ }
+
+ @Test("Reduced motion removes animation timing")
+ func reducedMotion() {
+ #expect(!DockMotionPolicy.shouldAnimate(reduceMotion: true))
+ #expect(
+ DockMotionPolicy.duration(
+ 0.24,
+ reduceMotion: true
+ ) == 0
+ )
+ }
+}
diff --git a/Tests/FreeDockTests/DockThemeTests.swift b/Tests/FreeDockTests/DockThemeTests.swift
new file mode 100644
index 0000000..1dbab4a
--- /dev/null
+++ b/Tests/FreeDockTests/DockThemeTests.swift
@@ -0,0 +1,72 @@
+import Foundation
+import Testing
+@testable import FreeDock
+
+@Suite("Saved appearance themes")
+struct DockThemeTests {
+ @Test("A theme captures and applies only visual appearance")
+ func capturesVisualAppearance() {
+ let source = DockConfig(
+ name: "Source",
+ orientation: .vertical,
+ iconSize: 72,
+ autoHideWhenDocked: false,
+ appearance: .dark,
+ surfaceOpacity: 0.55,
+ blurStyle: .strong,
+ cornerRadius: 27,
+ shadowStrength: 1.65
+ )
+ let theme = DockTheme(name: "Night", dock: source)
+ var target = DockConfig(
+ name: "Target",
+ orientation: .horizontal,
+ iconSize: 42,
+ items: [.separator()],
+ autoHideWhenDocked: true
+ )
+ let originalID = target.id
+ let originalItems = target.items
+
+ theme.apply(to: &target)
+
+ #expect(target.id == originalID)
+ #expect(target.items == originalItems)
+ #expect(target.orientation == .horizontal)
+ #expect(target.iconSize == 42)
+ #expect(target.autoHideWhenDocked)
+ #expect(target.appearance == .dark)
+ #expect(target.surfaceOpacity == 0.55)
+ #expect(target.blurStyle == .strong)
+ #expect(target.cornerRadius == 27)
+ #expect(target.shadowStrength == 1.65)
+ }
+
+ @Test("Themes round-trip and clamp malformed visual values")
+ func roundTripAndClamp() throws {
+ let themeID = UUID()
+ let data = Data("""
+ {
+ "id": "\(themeID.uuidString)",
+ "name": " Clear ",
+ "appearance": "light",
+ "surfaceOpacity": 0,
+ "blurStyle": "strong",
+ "cornerRadius": 100,
+ "shadowStrength": 9
+ }
+ """.utf8)
+ let theme = try JSONDecoder().decode(DockTheme.self, from: data)
+ let decoded = try JSONDecoder().decode(
+ DockTheme.self,
+ from: JSONEncoder().encode(theme)
+ )
+
+ #expect(decoded.id == themeID)
+ #expect(decoded.name == "Clear")
+ #expect(decoded.appearance == .light)
+ #expect(decoded.surfaceOpacity == DockConfig.surfaceOpacityRange.lowerBound)
+ #expect(decoded.cornerRadius == DockConfig.cornerRadiusRange.upperBound)
+ #expect(decoded.shadowStrength == DockConfig.shadowStrengthRange.upperBound)
+ }
+}
diff --git a/Tests/FreeDockTests/GlobalShortcutTests.swift b/Tests/FreeDockTests/GlobalShortcutTests.swift
new file mode 100644
index 0000000..c2885bf
--- /dev/null
+++ b/Tests/FreeDockTests/GlobalShortcutTests.swift
@@ -0,0 +1,112 @@
+import Carbon.HIToolbox
+import Foundation
+import Testing
+@testable import FreeDock
+
+@Suite("Configurable global shortcuts")
+struct GlobalShortcutTests {
+ @Test("Default shortcuts preserve existing behavior")
+ func defaults() {
+ let settings = GlobalShortcutSettings()
+ #expect(settings.showHideDocks.displayName == "⌃⌥Space")
+ #expect(settings.quickLaunch.displayName == "⇧⌘Space")
+ #expect(settings.validationError() == nil)
+ }
+
+ @Test("Shortcut settings round-trip in application config")
+ func configRoundTrip() throws {
+ let profile = DockProfile(name: "Work")
+ var settings = GlobalShortcutSettings(
+ showHideDocks: GlobalShortcut(
+ keyCode: UInt32(kVK_ANSI_D),
+ modifiers: UInt32(cmdKey) | UInt32(optionKey)
+ ),
+ quickLaunch: GlobalShortcut(
+ keyCode: UInt32(kVK_ANSI_F),
+ modifiers: UInt32(controlKey) | UInt32(shiftKey)
+ )
+ )
+ settings.reconcileProfiles([profile])
+ let config = AppConfig(profiles: [profile], globalShortcuts: settings)
+ let decoded = try JSONDecoder().decode(
+ AppConfig.self,
+ from: JSONEncoder().encode(config)
+ )
+ #expect(decoded.globalShortcuts == settings)
+ }
+
+ @Test("Older configs receive default shortcuts")
+ func legacyDefaults() throws {
+ let data = Data("""
+ { "formatVersion": 4, "docks": [] }
+ """.utf8)
+ let decoded = try JSONDecoder().decode(AppConfig.self, from: data)
+ #expect(decoded.globalShortcuts.showHideDocks == .defaultShowHide)
+ #expect(decoded.globalShortcuts.quickLaunch == .defaultQuickLaunch)
+ #expect(decoded.globalShortcuts.shortcut(
+ forProfile: decoded.activeProfileID
+ ) == GlobalShortcutSettings.defaultProfileShortcuts[0])
+ }
+
+ @Test("Duplicate and modifierless shortcuts are rejected")
+ func basicValidation() {
+ let duplicate = GlobalShortcutSettings(
+ showHideDocks: .defaultShowHide,
+ quickLaunch: .defaultShowHide
+ )
+ #expect(duplicate.validationError()?.contains("same") == true)
+
+ let modifierless = GlobalShortcut(
+ keyCode: UInt32(kVK_ANSI_A),
+ modifiers: 0
+ )
+ let invalid = GlobalShortcutSettings(
+ showHideDocks: modifierless
+ )
+ #expect(invalid.validationError()?.contains("modifier") == true)
+ }
+
+ @Test("Profile shortcuts cannot collide with another global action")
+ func profileCollision() {
+ let profileID = UUID()
+ let settings = GlobalShortcutSettings(
+ profileShortcuts: [ProfileShortcutAssignment(
+ profileID: profileID,
+ shortcut: .defaultShowHide
+ )]
+ )
+ #expect(settings.validationError()?.contains("unique") == true)
+ }
+
+ @Test("Profile shortcuts follow profile identity and can stay cleared")
+ func profileIdentityAndClearing() {
+ let work = DockProfile(name: "Work")
+ let personal = DockProfile(name: "Personal")
+ var settings = GlobalShortcutSettings()
+ settings.reconcileProfiles([work, personal])
+ let workShortcut = settings.shortcut(forProfile: work.id)
+ let personalShortcut = settings.shortcut(forProfile: personal.id)
+
+ settings.reconcileProfiles([personal, work])
+ #expect(settings.shortcut(forProfile: work.id) == workShortcut)
+ #expect(settings.shortcut(forProfile: personal.id) == personalShortcut)
+
+ settings.setProfileShortcut(nil, for: work.id)
+ settings.reconcileProfiles([personal, work])
+ #expect(settings.shortcut(forProfile: work.id) == nil)
+ }
+
+ @Test("New profiles receive the next unused default shortcut")
+ func newProfileDefault() {
+ let first = DockProfile(name: "First")
+ let second = DockProfile(name: "Second")
+ var settings = GlobalShortcutSettings()
+ settings.reconcileProfiles([first])
+ settings.reconcileProfiles([first, second])
+
+ #expect(settings.shortcut(forProfile: first.id)
+ == GlobalShortcutSettings.defaultProfileShortcuts[0])
+ #expect(settings.shortcut(forProfile: second.id)
+ == GlobalShortcutSettings.defaultProfileShortcuts[1])
+ }
+}
diff --git a/Tests/FreeDockTests/LaunchAtLoginStateTests.swift b/Tests/FreeDockTests/LaunchAtLoginStateTests.swift
new file mode 100644
index 0000000..c07b76d
--- /dev/null
+++ b/Tests/FreeDockTests/LaunchAtLoginStateTests.swift
@@ -0,0 +1,33 @@
+import Testing
+@testable import FreeDock
+
+@Suite("Launch at login preference state")
+struct LaunchAtLoginStateTests {
+ @Test("Enabled and approval states keep the toggle on")
+ func enabledStates() {
+ #expect(LaunchAtLoginState(status: .enabled).isEnabled)
+ #expect(LaunchAtLoginState(status: .requiresApproval).isEnabled)
+ #expect(!LaunchAtLoginState(status: .disabled).isEnabled)
+ }
+
+ @Test("Approval and availability produce useful presentation")
+ func presentation() {
+ let approval = LaunchAtLoginState(status: .requiresApproval)
+ #expect(approval.needsApproval)
+ #expect(approval.statusLabel == "Needs Approval")
+
+ let unavailable = LaunchAtLoginState(status: .unavailable)
+ #expect(!unavailable.canChange)
+ #expect(unavailable.statusLabel.contains("macOS 13"))
+ }
+
+ @Test("Errors do not discard the actual service status")
+ func errorState() {
+ let state = LaunchAtLoginState(
+ status: .disabled,
+ errorMessage: "Registration failed"
+ )
+ #expect(!state.isEnabled)
+ #expect(state.errorMessage == "Registration failed")
+ }
+}
diff --git a/Tests/FreeDockTests/OnboardingProgressTests.swift b/Tests/FreeDockTests/OnboardingProgressTests.swift
new file mode 100644
index 0000000..823c0f3
--- /dev/null
+++ b/Tests/FreeDockTests/OnboardingProgressTests.swift
@@ -0,0 +1,42 @@
+import Testing
+@testable import FreeDock
+
+@Suite("First-run onboarding")
+struct OnboardingProgressTests {
+ @Test("Progress advances, stops, and returns safely")
+ func navigation() {
+ var progress = OnboardingProgress()
+ #expect(progress.step == .welcome)
+ #expect(!progress.canGoBack)
+ #expect(!progress.isLastStep)
+
+ progress.goBack()
+ #expect(progress.step == .welcome)
+ progress.advance()
+ #expect(progress.step == .organize)
+ #expect(progress.canGoBack)
+ progress.advance()
+ #expect(progress.step == .integrate)
+ #expect(progress.isLastStep)
+ progress.advance()
+ #expect(progress.step == .integrate)
+ progress.goBack()
+ #expect(progress.step == .organize)
+ }
+
+ @Test("Automatic onboarding appears only for unfinished fresh installs")
+ func launchPolicy() {
+ #expect(OnboardingLaunchPolicy.shouldPresent(
+ loadedConfigurationFromDisk: false,
+ hasCompletedOnboarding: false
+ ))
+ #expect(!OnboardingLaunchPolicy.shouldPresent(
+ loadedConfigurationFromDisk: true,
+ hasCompletedOnboarding: false
+ ))
+ #expect(!OnboardingLaunchPolicy.shouldPresent(
+ loadedConfigurationFromDisk: false,
+ hasCompletedOnboarding: true
+ ))
+ }
+}
diff --git a/Tests/FreeDockTests/PreferencesSearchMatcherTests.swift b/Tests/FreeDockTests/PreferencesSearchMatcherTests.swift
new file mode 100644
index 0000000..6b23a52
--- /dev/null
+++ b/Tests/FreeDockTests/PreferencesSearchMatcherTests.swift
@@ -0,0 +1,27 @@
+import Testing
+
+@testable import FreeDock
+
+@Suite("Preferences search")
+struct PreferencesSearchMatcherTests {
+ @Test("Empty searches show every section")
+ func empty() {
+ #expect(PreferencesSearchMatcher.matches(query: "", text: "Appearance"))
+ }
+
+ @Test("Every token must match regardless of case or accents")
+ func tokenized() {
+ #expect(PreferencesSearchMatcher.matches(
+ query: "ICON magnification",
+ text: "Icons & Layout icon size magnification"
+ ))
+ #expect(PreferencesSearchMatcher.matches(
+ query: "resume",
+ text: "Résumé appearance"
+ ))
+ #expect(!PreferencesSearchMatcher.matches(
+ query: "icon privacy",
+ text: "Icons & Layout icon size"
+ ))
+ }
+}
diff --git a/Tests/FreeDockTests/ProfileAutomationTests.swift b/Tests/FreeDockTests/ProfileAutomationTests.swift
new file mode 100644
index 0000000..a091a17
--- /dev/null
+++ b/Tests/FreeDockTests/ProfileAutomationTests.swift
@@ -0,0 +1,72 @@
+import Foundation
+import Testing
+
+@testable import FreeDock
+
+@Suite("Profile automation")
+struct ProfileAutomationTests {
+ @Test("Application and display rules match their owning profile")
+ func matching() {
+ let work = DockProfile(
+ name: "Work",
+ automationRules: [
+ ProfileAutomationRule(
+ triggerKind: .application,
+ value: "com.apple.dt.Xcode",
+ label: "Xcode"
+ )
+ ]
+ )
+ let presentation = DockProfile(
+ name: "Presentation",
+ automationRules: [
+ ProfileAutomationRule(
+ triggerKind: .display,
+ value: "AAAAAAAA-BBBB-CCCC-DDDD-EEEEEEEEEEEE",
+ label: "Studio Display"
+ )
+ ]
+ )
+
+ #expect(ProfileAutomationPlanner.matchingProfileID(
+ triggerKind: .application,
+ value: "com.apple.dt.xcode",
+ profiles: [work, presentation]
+ ) == work.id)
+ #expect(ProfileAutomationPlanner.matchingProfileID(
+ triggerKind: .display,
+ value: "AAAAAAAA-BBBB-CCCC-DDDD-EEEEEEEEEEEE",
+ profiles: [work, presentation]
+ ) == presentation.id)
+ }
+
+ @Test("Disabled rules do not activate profiles")
+ func disabled() {
+ let profile = DockProfile(
+ name: "Work",
+ automationRules: [
+ ProfileAutomationRule(
+ triggerKind: .application,
+ value: "com.example.Editor",
+ label: "Editor",
+ isEnabled: false
+ )
+ ]
+ )
+ #expect(ProfileAutomationPlanner.matchingProfileID(
+ triggerKind: .application,
+ value: "com.example.Editor",
+ profiles: [profile]
+ ) == nil)
+ }
+
+ @Test("Older profiles decode with no automation rules")
+ func migration() throws {
+ let id = UUID()
+ let data = Data("""
+ { "id": "\(id.uuidString)", "name": "Legacy", "docks": [] }
+ """.utf8)
+ let profile = try JSONDecoder().decode(DockProfile.self, from: data)
+ #expect(profile.automationRules.isEmpty)
+ }
+}
diff --git a/Tests/FreeDockTests/ProfileFileCodecTests.swift b/Tests/FreeDockTests/ProfileFileCodecTests.swift
new file mode 100644
index 0000000..523a811
--- /dev/null
+++ b/Tests/FreeDockTests/ProfileFileCodecTests.swift
@@ -0,0 +1,70 @@
+import Carbon.HIToolbox
+import Foundation
+import Testing
+
+@testable import FreeDock
+
+@Suite("Individual profile files")
+struct ProfileFileCodecTests {
+ @Test("Archives preserve profile data and optional shortcuts")
+ func roundTrip() throws {
+ let profile = DockProfile(
+ name: "Work",
+ docks: [DockConfig(name: "Main")],
+ automationRules: [ProfileAutomationRule(
+ triggerKind: .application,
+ value: "com.example.Editor",
+ label: "Editor"
+ )]
+ )
+ let shortcut = GlobalShortcut(
+ keyCode: UInt32(kVK_ANSI_W),
+ modifiers: UInt32(controlKey) | UInt32(optionKey)
+ )
+ let archive = DockProfileArchive(profile: profile, shortcut: shortcut)
+ #expect(try ProfileFileCodec.decode(
+ ProfileFileCodec.encode(archive)
+ ) == archive)
+ }
+
+ @Test("Materialized profiles receive fresh nested identities")
+ func freshIdentities() {
+ let original = DockProfile(
+ name: "Work",
+ docks: [DockConfig(
+ name: "Main",
+ items: [DockItem.separator()]
+ )],
+ automationRules: [ProfileAutomationRule(
+ triggerKind: .display,
+ value: UUID().uuidString,
+ label: "Display"
+ )]
+ )
+ let imported = DockProfileArchive(
+ profile: original,
+ shortcut: nil
+ ).materializedProfile(named: "Work 2")
+
+ #expect(imported.id != original.id)
+ #expect(imported.docks[0].id != original.docks[0].id)
+ #expect(imported.docks[0].items[0].id != original.docks[0].items[0].id)
+ #expect(imported.automationRules[0].id != original.automationRules[0].id)
+ }
+
+ @Test("Future profile archive versions are rejected")
+ func futureVersion() throws {
+ let archive = DockProfileArchive(
+ profile: DockProfile(name: "Future"),
+ shortcut: nil
+ )
+ var object = try #require(JSONSerialization.jsonObject(
+ with: ProfileFileCodec.encode(archive)
+ ) as? [String: Any])
+ object["formatVersion"] = DockProfileArchive.currentFormatVersion + 1
+ let data = try JSONSerialization.data(withJSONObject: object)
+ #expect(throws: ProfileFileCodecError.unsupportedVersion(2)) {
+ try ProfileFileCodec.decode(data)
+ }
+ }
+}
diff --git a/Tests/FreeDockTests/QuickLaunchSearchTests.swift b/Tests/FreeDockTests/QuickLaunchSearchTests.swift
index 905dc18..fdb5dc1 100644
--- a/Tests/FreeDockTests/QuickLaunchSearchTests.swift
+++ b/Tests/FreeDockTests/QuickLaunchSearchTests.swift
@@ -128,6 +128,25 @@ func quickLaunchSearchFields() {
#expect(labelResults.first?.matchKind == .prefix)
}
+@Test("Quick Launch includes and searches the Trash item")
+func quickLaunchTrash() {
+ let trash = DockItem.trash(id: quickLaunchID(20))
+
+ let emptyResults = QuickLaunchSearch.results(
+ in: [trash],
+ matching: ""
+ )
+ let searchResults = QuickLaunchSearch.results(
+ in: [trash],
+ matching: "trash"
+ )
+
+ #expect(emptyResults.map(\.id) == [trash.id])
+ #expect(emptyResults.first?.displayLabel == "Trash")
+ #expect(searchResults.map(\.id) == [trash.id])
+ #expect(searchResults.first?.matchKind == .exact)
+}
+
@Test("Quick launch matching is case, width, diacritic, and whitespace tolerant")
func quickLaunchNormalizedMatching() {
let resume = quickLaunchItem(1, label: " Résumé Writer ")
diff --git a/Tests/FreeDockTests/RecentApplicationHistoryTests.swift b/Tests/FreeDockTests/RecentApplicationHistoryTests.swift
new file mode 100644
index 0000000..4686ea1
--- /dev/null
+++ b/Tests/FreeDockTests/RecentApplicationHistoryTests.swift
@@ -0,0 +1,69 @@
+import Foundation
+import Testing
+@testable import FreeDock
+
+@Suite("Recent and running applications")
+struct RecentApplicationHistoryTests {
+ private let first = RecentApplicationRecord(
+ id: UUID(uuidString: "00000000-0000-0000-0000-000000000001")!,
+ bundleIdentifier: "example.first",
+ path: "/Applications/First.app",
+ displayName: "First",
+ lastUsedAt: Date(timeIntervalSince1970: 10)
+ )
+ private let second = RecentApplicationRecord(
+ id: UUID(uuidString: "00000000-0000-0000-0000-000000000002")!,
+ bundleIdentifier: "example.second",
+ path: "/Applications/Second.app",
+ displayName: "Second",
+ lastUsedAt: Date(timeIntervalSince1970: 20)
+ )
+
+ @Test("Running applications lead recent applications")
+ func runningFirst() {
+ let items = DynamicApplicationSectionPlanner.items(
+ records: [second, first],
+ runningBundleIDs: [first.bundleIdentifier],
+ pinnedItems: [],
+ limit: 5
+ )
+ #expect(items.map { $0.id } == [first.id, second.id])
+ }
+
+ @Test("Pinned applications are excluded and limits are enforced")
+ func excludesPinned() {
+ let pinned = DockItem.application(
+ at: URL(fileURLWithPath: second.path)
+ )
+ let items = DynamicApplicationSectionPlanner.items(
+ records: [second, first],
+ runningBundleIDs: [],
+ pinnedItems: [pinned],
+ limit: 1
+ )
+ #expect(items.map { $0.id } == [first.id])
+ }
+
+ @Test("Recording reuses identity and moves an app to the front")
+ func recording() {
+ let updated = RecentApplicationHistoryPlanner.recording(
+ bundleIdentifier: first.bundleIdentifier,
+ path: first.path,
+ displayName: first.displayName,
+ usedAt: Date(timeIntervalSince1970: 30),
+ in: [second, first]
+ )
+ #expect(updated.first?.id == first.id)
+ #expect(updated.first?.lastUsedAt == Date(timeIntervalSince1970: 30))
+ }
+
+ @Test("Application history round-trips with config")
+ func configRoundTrip() throws {
+ let config = AppConfig(recentApplications: [first, second])
+ let decoded = try JSONDecoder().decode(
+ AppConfig.self,
+ from: JSONEncoder().encode(config)
+ )
+ #expect(decoded.recentApplications.map { $0.id } == [second.id, first.id])
+ }
+}
diff --git a/Tests/FreeDockTests/ReliabilityInteractionTests.swift b/Tests/FreeDockTests/ReliabilityInteractionTests.swift
new file mode 100644
index 0000000..6982729
--- /dev/null
+++ b/Tests/FreeDockTests/ReliabilityInteractionTests.swift
@@ -0,0 +1,130 @@
+import AppKit
+import Testing
+@testable import FreeDock
+
+@Suite("Reliability interaction scenarios")
+struct ReliabilityInteractionTests {
+ @Test("A vertical dock hides to a reveal strip and returns exactly")
+ func verticalHideRevealRoundTrip() {
+ let visibleFrame = CGRect(x: -1200, y: 40, width: 1200, height: 860)
+ let restingFrame = DockDisplayGeometry.dockedFrame(
+ CGRect(x: -80, y: 260, width: 80, height: 420),
+ at: .right,
+ in: visibleFrame
+ )
+
+ let hiddenFrame = DockDisplayGeometry.hiddenFrame(
+ restingFrame,
+ at: .right,
+ in: visibleFrame
+ )
+ let revealedFrame = restingFrame
+
+ #expect(
+ hiddenFrame.minX
+ == visibleFrame.maxX
+ - DockDisplayGeometry.autoHideRevealThickness
+ )
+ #expect(hiddenFrame.width == restingFrame.width)
+ #expect(revealedFrame == restingFrame)
+ }
+
+ @Test("A resize gesture scales without moving its docked anchor")
+ func resizeMaintainsAnchor() {
+ let visibleFrame = CGRect(x: 0, y: 0, width: 1440, height: 900)
+ let referenceFrame = CGRect(x: 1368, y: 250, width: 72, height: 400)
+ let start = NSPoint(x: 1400, y: 250)
+ let liveIconSize = DockResizeGestureMath.proposedIconSize(
+ startingAt: 48,
+ from: start,
+ to: NSPoint(x: 900, y: 210),
+ orientation: .vertical,
+ resizableItemCount: 5
+ )
+ let resizedFrame = DockPanelResizeGeometry.anchoredFrame(
+ from: referenceFrame,
+ size: CGSize(width: 80, height: 440),
+ orientation: .vertical,
+ dockedEdge: DockPanelResizeGeometry.dockedEdge(
+ of: referenceFrame,
+ in: visibleFrame,
+ orientation: .vertical,
+ tolerance: 2
+ )
+ )
+
+ #expect(liveIconSize == 56)
+ #expect(resizedFrame.maxX == referenceFrame.maxX)
+ #expect(resizedFrame.maxY == referenceFrame.maxY)
+ }
+
+ @Test("Reordering then dropping on Trash preserves the intended order")
+ func reorderThenRemove() throws {
+ let first = DockItem(appPath: "/Applications/First.app")
+ let second = DockItem(appPath: "/Applications/Second.app")
+ let third = DockItem(appPath: "/Applications/Third.app")
+ let trash = DockItem.trash()
+ var dock = DockConfig(
+ name: "Interaction Dock",
+ items: [first, second, third, trash]
+ )
+
+ let reorder = DockItemTransferPlanner.planTransfer(
+ itemID: third.id,
+ from: dock,
+ to: dock,
+ destination: .item(first.id)
+ )
+ #expect(reorder.outcome == .moved(itemID: third.id))
+ dock.items = reorder.targetItems
+ #expect(dock.items.map(\.id) == [third.id, first.id, second.id, trash.id])
+
+ let removal = DockItemTransferPlanner.planTransfer(
+ itemID: second.id,
+ from: dock,
+ to: dock,
+ destination: .trash(trash.id)
+ )
+ #expect(removal.outcome == .removed(itemID: second.id))
+ #expect(removal.targetItems.map(\.id) == [third.id, first.id, trash.id])
+ }
+
+ @Test("A disconnected display falls back and reconnects by identity")
+ func displayDisconnectReconnect() throws {
+ let primary = DockDisplayDescriptor(
+ id: UUID(),
+ name: "Built-in Display",
+ frame: CGRect(x: 0, y: 0, width: 1440, height: 900),
+ visibleFrame: CGRect(x: 0, y: 0, width: 1440, height: 875),
+ isPrimary: true
+ )
+ let external = DockDisplayDescriptor(
+ id: UUID(),
+ name: "External",
+ frame: CGRect(x: 1440, y: 0, width: 1920, height: 1080),
+ visibleFrame: CGRect(x: 1440, y: 0, width: 1920, height: 1055),
+ isPrimary: false
+ )
+ let placement = DockDisplayPlacement(
+ displayID: external.id,
+ displayName: external.name,
+ normalizedCenter: CGPoint(x: 0.7, y: 0.25)
+ )
+
+ let fallback = try #require(DockDisplayGeometry.resolveDisplay(
+ for: placement,
+ panelFrame: external.visibleFrame,
+ among: [primary]
+ ))
+ #expect(fallback.display.id == primary.id)
+ #expect(fallback.isFallback)
+
+ let reconnected = try #require(DockDisplayGeometry.resolveDisplay(
+ for: placement,
+ panelFrame: primary.visibleFrame,
+ among: [primary, external]
+ ))
+ #expect(reconnected.display.id == external.id)
+ #expect(!reconnected.isFallback)
+ }
+}
diff --git a/Tests/FreeDockTests/SmartStackModelTests.swift b/Tests/FreeDockTests/SmartStackModelTests.swift
index 34f5630..e797e85 100644
--- a/Tests/FreeDockTests/SmartStackModelTests.swift
+++ b/Tests/FreeDockTests/SmartStackModelTests.swift
@@ -118,7 +118,10 @@ func appConfigRecentFilePersistence() throws {
#expect(config.recentFiles.count == RecentFileHistoryPlanner.maximumLimit)
#expect(decoded.recentFiles == config.recentFiles)
- #expect(object["formatVersion"] as? Int == 4)
+ #expect(
+ object["formatVersion"] as? Int
+ == AppConfig.currentFormatVersion
+ )
#expect((object["recentFiles"] as? [[String: Any]])?.count == 100)
let legacyData = """
diff --git a/Tests/FreeDockTests/WindowPreviewPanelStateTests.swift b/Tests/FreeDockTests/WindowPreviewPanelStateTests.swift
index 573090d..e4f6472 100644
--- a/Tests/FreeDockTests/WindowPreviewPanelStateTests.swift
+++ b/Tests/FreeDockTests/WindowPreviewPanelStateTests.swift
@@ -4,6 +4,34 @@ import Testing
@Suite("Window preview panel state")
struct WindowPreviewPanelStateTests {
+ @Test("Permission loss chooses a safe preview fallback")
+ func permissionFallbacks() {
+ #expect(
+ WindowPreviewPermissionPolicy.fallback(
+ accessibilityTrusted: false,
+ screenCaptureTrusted: false
+ ) == .closePreview
+ )
+ #expect(
+ WindowPreviewPermissionPolicy.fallback(
+ accessibilityTrusted: false,
+ screenCaptureTrusted: true
+ ) == .closePreview
+ )
+ #expect(
+ WindowPreviewPermissionPolicy.fallback(
+ accessibilityTrusted: true,
+ screenCaptureTrusted: false
+ ) == .metadataOnly
+ )
+ #expect(
+ WindowPreviewPermissionPolicy.fallback(
+ accessibilityTrusted: true,
+ screenCaptureTrusted: true
+ ) == .fullPreview
+ )
+ }
+
@Test("Ready queries with cards are accepted")
func readyQueryIsAccepted() {
#expect(
diff --git a/docs/PERFORMANCE.md b/docs/PERFORMANCE.md
new file mode 100644
index 0000000..b6fd08e
--- /dev/null
+++ b/docs/PERFORMANCE.md
@@ -0,0 +1,21 @@
+# Performance auditing
+
+FreeDock exposes privacy-safe Points of Interest for its user-visible hot paths:
+
+- `DockRestore` — rebuilding the active profile's dock windows
+- `MenuRebuild` — recreating the menu-bar menu and preferences snapshot
+- `WindowDiscovery` — finding an application's windows across Spaces and displays
+- `WindowThumbnail` — loading one window preview image, including cache lookup
+- `ConfigSave` — encoding, backup rotation, and atomic configuration writing
+
+These intervals contain no application names, window titles, file paths, or other user data. Record the FreeDock process with Instruments' **Points of Interest** template, then perform the interaction under investigation. Compare warm and cold runs separately because window thumbnails use a short-lived cache.
+
+The menu refresh path deliberately keeps global shortcut registrations intact unless their settings change. This avoids unregistering and registering Carbon hot keys during unrelated dock, menu, or running-app updates.
+
+For a repeatable regression check before release, run:
+
+```sh
+make test-reliability
+```
+
+Then profile a dock restore, a menu open, and one cold followed by one warm window-preview session. Investigate regressions relative to the previous release rather than relying on machine-specific absolute timings.
diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md
index 7808cfe..35ae86b 100644
--- a/docs/ROADMAP.md
+++ b/docs/ROADMAP.md
@@ -9,22 +9,34 @@ FreeDock is free and MIT-licensed. This roadmap is a guide for building a better
- 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
+- A native Trash item with live empty/full state, Finder drops, and confirmed emptying
- Native magnification, running-app indicators, tooltips, and launch animation
+- Reusable appearance themes shared across docks and profiles
+- Per-item custom labels and portable optimized icon artwork
+- A re-openable first-run guide for dock basics and optional permissions
+- Optional per-dock recent/running application section with local-only history and pinning
+- Reduced-motion-aware dock, stack, reorder, and preview transitions with descriptive VoiceOver controls
- 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
+- Validated individual-profile import and export with fresh nested identities
+- Per-profile automation using frontmost-application and connected-display rules
+- Configurable global shortcuts to reveal docks, open Quick Launch, and switch individual profiles, with persistent profile-ID assignments and conflict detection
+- Native Launch at Login control with approval status in Preferences
- 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
+- Tokenized Preferences search across global and per-dock settings
+- Session undo for destructive profile, dock, item, theme, and recent-history actions
+- Validated JSON export and restore for portable configuration backups
- 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
+- VoiceOver actions and grouping, keyboard navigation, increased-contrast visuals, 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
diff --git a/docs/showcase-multiple-docks-sanitized.png b/docs/showcase-multiple-docks-sanitized.png
new file mode 100644
index 0000000..5542810
Binary files /dev/null and b/docs/showcase-multiple-docks-sanitized.png differ
diff --git a/docs/showcase-preferences-sanitized.png b/docs/showcase-preferences-sanitized.png
new file mode 100644
index 0000000..c3ff938
Binary files /dev/null and b/docs/showcase-preferences-sanitized.png differ
diff --git a/docs/showcase-window-switcher-sanitized.png b/docs/showcase-window-switcher-sanitized.png
new file mode 100644
index 0000000..568ce4b
Binary files /dev/null and b/docs/showcase-window-switcher-sanitized.png differ
diff --git a/scripts/build.sh b/scripts/build.sh
index 6207232..681e78e 100755
--- a/scripts/build.sh
+++ b/scripts/build.sh
@@ -8,6 +8,7 @@ PROJECT_DIR="$(cd "$(dirname "$0")/.." && pwd)"
BUILD_DIR="$PROJECT_DIR/.build"
APP_NAME="FreeDock"
ICON_SOURCE="$PROJECT_DIR/Resources/AppIcon.icns"
+SUPPORT_QR_SOURCE="$PROJECT_DIR/docs/qr-code.png"
echo "==> Building FreeDock..."
cd "$PROJECT_DIR"
@@ -34,6 +35,15 @@ else
echo "==> Warning: icon not found at $ICON_SOURCE, building without custom app icon"
fi
+# Bundle the support QR shown in onboarding. The documentation uses the same
+# source image so the app and README cannot drift apart.
+if [ -f "$SUPPORT_QR_SOURCE" ]; then
+ cp "$SUPPORT_QR_SOURCE" "$APP_BUNDLE/Contents/Resources/SupportQR.png"
+ echo "==> Support QR copied from $SUPPORT_QR_SOURCE"
+else
+ echo "==> Warning: support QR not found, building without onboarding QR"
+fi
+
# Generate Info.plist for the bundle
cat > "$APP_BUNDLE/Contents/Info.plist" <