From b2bcce933ba136771302d6bf3812b483950df4f1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juan=20Alberto=20L=C3=B3pez=20Cavallotti?= Date: Thu, 2 Apr 2026 21:43:13 -0700 Subject: [PATCH 01/43] Added app inspection and built modernization plan --- doc/enhancements.md | 645 ++++++++++++++ doc/features.md | 712 ++++++++++++++++ doc/migration.md | 1983 +++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 3340 insertions(+) create mode 100644 doc/enhancements.md create mode 100644 doc/features.md create mode 100644 doc/migration.md diff --git a/doc/enhancements.md b/doc/enhancements.md new file mode 100644 index 0000000..de6fa99 --- /dev/null +++ b/doc/enhancements.md @@ -0,0 +1,645 @@ +# ClipMenu — Architecture Enhancement Proposals + +This document proposes concrete modernisation improvements for ClipMenu. Each enhancement describes the current problem, the modern alternative, migration strategy, and trade-offs. Proposals are ordered from highest to lowest impact. + +All proposals target **macOS 13 Ventura+** as the minimum deployment target and **Swift 5.9+** as the implementation language, unless noted otherwise. + +--- + +## 1. Clipboard Monitoring — Replace Polling with Change Notification + +### Current Design + +`ClipsController` uses a repeating `NSTimer` (default 0.75 s, max 1.0 s) that reads `[NSPasteboard generalPasteboard].changeCount` on every tick. This wakes the process ~80 times per minute even when the clipboard is idle. + +### Problem + +- Wastes CPU and battery on idle systems. +- Introduces latency: a copy made 1 ms after a tick won't be captured for up to 750 ms. +- The hard cap of 1.0 s is an arbitrary workaround, not a design principle. + +### Proposed Solution + +Use `NSPasteboard`'s KVO support via `addObserver(_:forKeyPath:options:context:)` on the `changeCount` property. On macOS 10.8+ this fires synchronously when the pasteboard changes, with zero polling overhead. + +```swift +// ClipboardMonitor.swift +import AppKit +import Combine + +final class ClipboardMonitor { + private let pasteboard = NSPasteboard.general + private var observation: NSKeyValueObservation? + let clipChanged = PassthroughSubject() + + func start() { + observation = pasteboard.observe(\.changeCount, options: [.new]) { [weak self] pb, _ in + self?.clipChanged.send(pb) + } + } + + func stop() { + observation = nil + } +} +``` + +The `ClipboardMonitor` publishes on `clipChanged`. `ClipsService` (replacement for `ClipsController`) subscribes and processes the new content. + +### Trade-offs + +- `NSPasteboard.changeCount` KVO is documented as reliable on macOS 10.8+, which is well below the new minimum target. +- Eliminates timer drift: two rapid copies in quick succession won't be coalesced by a polling window. +- If Apple ever restricts KVO on `NSPasteboard` (not currently the case), the fallback is a 250 ms timer — still faster than the current default and only needed as a safety net. + +--- + +## 2. Persistence — Replace NSKeyedArchiver + Core Data with SwiftData + +### Current Design + +| Data | Mechanism | File | +|---|---|---| +| Clip history | `NSKeyedArchiver` | `clips.data` | +| Action nodes | `NSPropertyListSerialization` | `actions.plist` | +| Snippets | Core Data XML store | `Snippets.xml` | + +Three separate persistence stacks, each with their own save/load paths, error handling, and migration stories. + +### Problems + +- `clips.data` is a monolithic binary blob; a single corrupt byte loses the entire history. +- Core Data requires a heavyweight coordinator/context/store stack for a simple two-entity model. +- No incremental writes: every save rewrites the whole clip array. +- NSKeyedArchiver format changes require manual backward-compat decoding (already present in `Clip.initWithCoder:`). + +### Proposed Solution + +Adopt **SwiftData** (macOS 14+) as a single persistence layer for all three data sets. + +#### Model + +```swift +import SwiftData + +@Model +final class ClipEntry { + var createdAt: Date + var lastUsedAt: Date + var types: [String] // pasteboard type UTI strings + var stringValue: String? + var rtfData: Data? + var pdfData: Data? + var filenames: [String]? + var urlStrings: [String]? + var imageData: Data? // TIFF representation + + init(createdAt: Date = .now) { + self.createdAt = createdAt + self.lastUsedAt = createdAt + self.types = [] + } +} + +@Model +final class SnippetFolder { + var title: String + var isEnabled: Bool + var sortIndex: Int + @Relationship(deleteRule: .cascade) var snippets: [Snippet] = [] +} + +@Model +final class Snippet { + var title: String + var content: String + var isEnabled: Bool + var sortIndex: Int + var folder: SnippetFolder? +} + +@Model +final class ActionNode { + var title: String + var isLeaf: Bool + var sortIndex: Int + var actionType: String? // "builtin" | "javaScript" + var actionName: String? + var scriptPath: String? + @Relationship(deleteRule: .cascade) var children: [ActionNode] = [] +} +``` + +#### Container Setup + +```swift +let schema = Schema([ClipEntry.self, SnippetFolder.self, Snippet.self, ActionNode.self]) +let config = ModelConfiguration("ClipMenu", schema: schema) +let container = try ModelContainer(for: schema, configurations: config) +``` + +SwiftData automatically places the store at `~/Library/Application Support/ClipMenu/default.store` (SQLite). + +#### Migration from Legacy Formats + +Write a one-time migration routine that runs on first launch after the update: + +1. Check for `clips.data`; if present, unarchive via `NSKeyedUnarchiver`, insert each clip into the SwiftData context, delete the file. +2. Check for `Snippets.xml`; if present, load the Core Data store using the old coordinator, read all objects, insert into SwiftData, delete the file. +3. Check for `actions.plist`; parse it, insert `ActionNode` objects, delete the file. + +Mark migration complete in `UserDefaults` with a version key so it never runs twice. + +### Benefits + +- Single SQLite file; SQLite is append-friendly and recovers from partial writes. +- Free incremental writes — only changed rows are written. +- Built-in versioned schema migration via `MigrationPlan`. +- `@Query` descriptor in SwiftUI views replaces `sortedClips` computed property. +- Automatic background context for saves; no manual thread management. + +--- + +## 3. Concurrency — Replace NSThread/NSOperationQueue with Swift Concurrency + +### Current Design + +- Startup uses `NSOperationQueue` to run clip load, action load, and hotkey registration concurrently. +- Autosave detaches a raw `NSThread` via `NSThread.detachNewThreadSelector:`. +- The rest of the app is single-threaded on the main thread with no formal actor boundary. + +### Proposed Solution + +Replace with `async/await` and `Actor`s. + +```swift +// ClipsService.swift +actor ClipsService { + private var clips: [ClipEntry] = [] + private let modelContext: ModelContext + + func loadClips() async throws { + let descriptor = FetchDescriptor(sortBy: [SortDescriptor(\.lastUsedAt, order: .reverse)]) + clips = try modelContext.fetch(descriptor) + } + + func addClip(_ entry: ClipEntry) async { + modelContext.insert(entry) + try? modelContext.save() + await MainActor.run { NotificationCenter.default.post(name: .clipsDidChange, object: nil) } + } +} +``` + +Startup becomes a single `async` block: + +```swift +func applicationDidFinishLaunching(_ notification: Notification) { + Task { + async let clips: () = clipsService.loadClips() + async let actions: () = actionService.loadActions() + async let hotkeys: () = hotkeyService.register() + try await (clips, actions, hotkeys) + } +} +``` + +Autosave uses `Task.sleep` instead of `NSTimer`: + +```swift +func startAutosave(interval: Duration) { + Task.detached(priority: .background) { [weak self] in + while true { + try await Task.sleep(for: interval) + await self?.saveIfNeeded() + } + } +} +``` + +--- + +## 4. Global Hotkeys — Replace PTHotKeys (Carbon) with ShortcutRecorder or EventKit + +### Current Design + +PTHotKeys registers hotkeys via legacy Carbon `RegisterEventHotKey` APIs. These APIs are available but deprecated and rely on the Carbon event loop, which is not guaranteed in future OS versions. + +### Problems + +- Carbon dependency (`Carbon/Carbon.h`). +- `PTKeyCombo` serialises as raw key-code + modifier integer, which is keyboard-layout-dependent. +- No support for non-ASCII key descriptions in modifier display. + +### Proposed Solution + +Use `MASShortcut` or `KeyboardShortcuts` (by Sindre Sorhus), both of which use CGEventTap internally: + +```swift +// Using the KeyboardShortcuts package +import KeyboardShortcuts + +extension KeyboardShortcuts.Name { + static let openClipMenu = Self("openClipMenu", default: .init(.v, modifiers: [.command, .shift])) + static let openHistory = Self("openHistory", default: .init(.v, modifiers: [.command, .control])) + static let openSnippets = Self("openSnippets", default: .init(.b, modifiers: [.command, .shift])) +} + +// Registration (replaces _registerHotKeys) +KeyboardShortcuts.onKeyUp(for: .openClipMenu) { [weak self] in self?.openClipMenu() } +KeyboardShortcuts.onKeyUp(for: .openHistory) { [weak self] in self?.openHistory() } +KeyboardShortcuts.onKeyUp(for: .openSnippets) { [weak self] in self?.openSnippets() } +``` + +Preferences UI uses `KeyboardShortcuts.Recorder` (SwiftUI view) instead of ShortcutRecorder XIB controls. + +Hotkey configurations are stored automatically in `UserDefaults` under the shortcut name, in a layout-independent format. + +--- + +## 5. UI — Replace NSMenu/XIB with SwiftUI MenuBarExtra + +### Current Design + +`MenuController` manually builds `NSMenu` and all `NSMenuItem` objects in code, including custom attributed strings, thumbnails, icons, and submenus. XIB files define the preferences window. + +### Proposed Solution + +macOS 13 introduced `MenuBarExtra` — a SwiftUI scene that renders a menu or popover directly in the menu bar. + +```swift +@main +struct ClipMenuApp: App { + var body: some Scene { + MenuBarExtra("ClipMenu", systemImage: "doc.on.clipboard") { + ClipMenuView() + } + .menuBarExtraStyle(.menu) // or .window for a popover + } +} +``` + +The content view uses `@Query` to fetch clips directly from SwiftData with live updates: + +```swift +struct ClipMenuView: View { + @Query(sort: \ClipEntry.lastUsedAt, order: .reverse) var clips: [ClipEntry] + @Query var snippetFolders: [SnippetFolder] + + var body: some View { + ForEach(clips) { clip in + ClipMenuItem(clip: clip) + } + Divider() + SnippetsSection(folders: snippetFolders) + Divider() + ControlsSection() + } +} +``` + +The preferences window becomes a `Settings` scene: + +```swift +Settings { + PreferencesView() + .frame(width: 500) +} +``` + +### Trade-offs + +- `MenuBarExtra` with `.menuBarExtraStyle(.menu)` renders identically to `NSMenu` in appearance. +- Custom menu item views (thumbnails, icons) are fully supported via `Label` and custom `View`s inside the menu. +- Keyboard shortcuts for menu items use SwiftUI's `.keyboardShortcut(_:modifiers:)` modifier. +- XIB files are eliminated entirely. + +--- + +## 6. JavaScript Engine — Replace WebView with JavaScriptCore + +### Current Design + +`ActionController` creates a hidden `WebView`, loads an empty HTML string, and uses `WebScriptObject` to evaluate JavaScript actions. `WebView` is deprecated as of macOS 10.14. + +### Problems + +- `WebView` (WebKit legacy) is deprecated and may be removed. +- Spinning up a `WebView` for script execution is heavyweight. +- `WebScriptObject` bridge is fragile and undocumented in modern SDKs. +- External resource access must be manually blocked via the policy delegate. + +### Proposed Solution + +Use `JavaScriptCore.framework` directly: + +```swift +import JavaScriptCore + +final class ScriptEngine { + private let context = JSContext()! + + init() { + // Inject ClipMenu namespace + let clipMenuObj = JSValue(newObjectIn: context) + context["ClipMenu"] = clipMenuObj + + // ClipMenu.require(path) — load a library script + let require: @convention(block) (String) -> Void = { [weak self] path in + guard let source = try? String(contentsOfFile: path) else { return } + self?.context.evaluateScript(source) + } + clipMenuObj?.setObject(require, forKeyedSubscript: "require" as NSString) + + // Error handler + context.exceptionHandler = { _, exception in + print("JS error: \(exception?.toString() ?? "unknown")") + } + } + + func run(script: String, clipText: String, clip: ScriptableClip) -> String? { + context["clipText"] = clipText + context["clip"] = clip + let wrapped = "function __wrapper(clipText, clip) { \(script) }" + context.evaluateScript(wrapped) + let result = context.evaluateScript("__wrapper(clipText, clip)") + guard let str = result?.toString(), result?.isUndefined == false else { return nil } + return str + } +} +``` + +`ScriptableClip` exposes its API via `@objc` and `JSExport`: + +```swift +@objc protocol ScriptableClipExport: JSExport { + func setStringAttributes(_ attrs: [String: Any]) + func addStringAttributes(_ attrs: [String: Any]) + var text: String? { get } +} +``` + +### Benefits + +- No hidden web view, no HTML loading, no deprecated APIs. +- `JSContext` is lightweight and can be created per invocation or pooled. +- Direct Swift bridging via `JSExport` is type-safe and documented. +- No network policy delegate needed: `JSContext` has no network access by default. + +--- + +## 7. Application Exclusion — Replace ProcessSerialNumber with Modern Process APIs + +### Current Design + +`ClipsController._frontProcessIsInExcludeList` calls `GetFrontProcess(&psn)` and `ProcessInformationCopyDictionary` — both Carbon APIs deprecated in macOS 10.9. + +### Proposed Solution + +Use `NSWorkspace` notifications and `NSRunningApplication`: + +```swift +extension NSWorkspace { + var frontmostBundleIdentifier: String? { + frontmostApplication?.bundleIdentifier + } +} + +// In ClipboardMonitor, before processing a change: +func shouldRecord() -> Bool { + guard let frontId = NSWorkspace.shared.frontmostBundleIdentifier else { return true } + return !excludedBundleIDs.contains(frontId) +} +``` + +For the exclude-list panel, enumerate running apps: + +```swift +let runningApps = NSWorkspace.shared.runningApplications + .filter { $0.activationPolicy == .regular } + .map { ExcludeEntry(name: $0.localizedName ?? "", bundleID: $0.bundleIdentifier ?? "") } +``` + +--- + +## 8. Login Items — Replace LSSharedFileList with SMAppService + +### Current Design + +`NMLoginItems` uses `LSSharedFileListCreate`, `LSSharedFileListInsertItemURL`, and `LSSharedFileListItemRemove` — all deprecated in macOS 10.11. + +### Proposed Solution + +```swift +import ServiceManagement + +func setLaunchAtLogin(_ enabled: Bool) throws { + if enabled { + try SMAppService.mainApp.register() + } else { + try SMAppService.mainApp.unregister() + } +} + +var isRegisteredAsLoginItem: Bool { + SMAppService.mainApp.status == .enabled +} +``` + +`SMAppService` requires no special entitlements and works in sandboxed apps. Available on macOS 13+. + +--- + +## 9. Paste Mechanism — Replace CGEvent Synthesis with Accessibility API + +### Current Design + +`CMUtilities.paste()` posts synthetic `Cmd+V` keyboard events via `CGEventCreateKeyboardEvent` and `CGEventPost`. It must dynamically scan all 128 keycodes to find the `V` key for the current keyboard layout. + +### Problems + +- Requires Accessibility permission if running unsandboxed; blocked entirely in sandbox. +- The keycode scan runs on first use and caches the result, but is brittle on layout changes. +- No feedback if the target app doesn't support `Cmd+V`. + +### Proposed Solution + +Use `NSPasteboard` write + a `CGEvent`-based approach but with the `kCGEventSourceStateHIDSystemState` source, which is more reliable: + +```swift +func paste() { + let src = CGEventSource(stateID: .hidSystemState) + let vKeyCode: CGKeyCode = 9 // 'V' in ANSI; stored as a constant after one-time lookup + let keyDown = CGEvent(keyboardEventSource: src, virtualKey: vKeyCode, keyDown: true) + let keyUp = CGEvent(keyboardEventSource: src, virtualKey: vKeyCode, keyDown: false) + keyDown?.flags = .maskCommand + keyUp?.flags = .maskCommand + keyDown?.post(tap: .cghidEventTap) + keyUp?.post(tap: .cghidEventTap) +} +``` + +For a sandboxed alternative, write the content to the pasteboard and ask the user to press `Cmd+V` manually — or use `NSAppleScript` targeting the frontmost app: + +```swift +let script = NSAppleScript(source: "tell application \"System Events\" to keystroke \"v\" using command down") +script?.executeAndReturnError(nil) +``` + +(The AppleScript approach requires Automation permission for the target app.) + +--- + +## 10. Settings — Replace NSUserDefaults Manual Keys with @AppStorage / Property Wrappers + +### Current Design + +Over 40 preference keys are scattered across multiple files as `#define` string constants. Defaults are registered manually in `+[AppController initialize]` via a large `NSMutableDictionary`. Preference observation is wired by hand with KVO. + +### Proposed Solution + +Define a typed settings container: + +```swift +struct ClipMenuSettings { + @AppStorage("maxHistorySize") var maxHistorySize: Int = 20 + @AppStorage("pollingInterval") var pollingInterval: Double = 0.75 + @AppStorage("reorderOnPaste") var reorderOnPaste: Bool = true + @AppStorage("autosaveDelay") var autosaveDelay: Int = 1800 + @AppStorage("saveOnQuit") var saveOnQuit: Bool = true + @AppStorage("showStatusItem") var showStatusItem: Bool = true + @AppStorage("enableActions") var enableActions: Bool = true + @AppStorage("positionOfSnippets") var positionOfSnippets: SnippetPosition = .belowClips + // …etc +} +``` + +SwiftUI views bind directly to these with `@AppStorage` in the view, or observe via `Combine.Publisher`: + +```swift +NotificationCenter.default + .publisher(for: UserDefaults.didChangeNotification) + .map { _ in UserDefaults.standard.integer(forKey: "maxHistorySize") } + .removeDuplicates() + .sink { [weak self] newSize in self?.clipsService.applyMaxSize(newSize) } + .store(in: &cancellables) +``` + +Eliminates all manual KVO setup in `ClipsController` and `AppController`. + +--- + +## 11. Bundled Script Actions — Replace File-System Script Discovery with Swift Plugin Protocol + +### Current Design + +Action scripts are discovered by walking the file system (`script/action/` directories). The tree is rebuilt at startup and saved to `actions.plist`. Users add custom scripts by dropping files into an Application Support directory. + +### Problems + +- No type safety, no capability declaration per script. +- Discovery is fragile if the directory layout changes. +- No way to validate a script before it appears in the menu. + +### Proposed Solution + +Define a `ClipMenuAction` protocol that built-in Swift actions can conform to. Retain the JavaScript engine for user scripts but give it a formal registration API: + +```swift +protocol ClipMenuAction: Sendable { + var id: String { get } + var title: String { get } + var applicableTypes: [ClipType] { get } + func perform(on clip: ClipEntry) async throws -> ClipEntry? +} +``` + +Built-in actions become Swift structs: + +```swift +struct PasteAsPlainTextAction: ClipMenuAction { + let id = "pasteAsPlainText" + let title = NSLocalizedString("Paste as Plain Text", comment: "") + let applicableTypes: [ClipType] = [.string] + + func perform(on clip: ClipEntry) async throws -> ClipEntry? { + guard let text = clip.stringValue else { return nil } + return ClipEntry(string: text) + } +} +``` + +The JavaScript engine registers user scripts as actions conforming to the same protocol, with the script evaluated at invocation time. + +--- + +## 12. Architecture Pattern — Migrate from Singletons + KVO to MVVM + Combine + +### Current Design + +All coordination flows through mutable singletons (`AppController`, `ClipsController`, `MenuController`, `ActionController`). Communication uses KVO and `NSNotificationCenter`. There are no formal ownership boundaries. + +### Proposed Solution + +Adopt a layered MVVM architecture: + +``` +View Layer (SwiftUI) + ClipMenuView, PreferencesView, SnippetEditorView + ↕ @Observable / @Query + +ViewModel Layer + ClipMenuViewModel — mediates between views and services + SnippetEditorViewModel + +Service Layer (Actors) + ClipsService — pasteboard monitoring, clip CRUD + ActionService — action node tree, script execution + SnippetService — snippet CRUD + +Infrastructure Layer + ClipboardMonitor — NSPasteboard KVO → publisher + HotkeyService — KeyboardShortcuts registration + PasteService — CGEvent paste + SettingsStore — @AppStorage wrappers +``` + +Services communicate via `AsyncStream` or `Combine` publishers. The view layer never touches services directly; it goes through ViewModels. This makes each layer independently testable. + +### Testing Impact + +- `ClipsService` can be tested with an in-memory `ModelContainer` (`isStoredInMemoryOnly: true`). +- `ClipboardMonitor` can be injected with a mock that publishes synthetic changes. +- Action scripts can be run in isolation against a stub `JSContext`. +- No globals means tests run in parallel without interference. + +--- + +## Summary Table + +| # | Area | Current | Proposed | Min macOS | +|---|---|---|---|---| +| 1 | Clipboard monitoring | NSTimer polling (0.75 s) | NSPasteboard KVO | 10.8 | +| 2 | Clip persistence | NSKeyedArchiver | SwiftData (SQLite) | 14.0 | +| 2 | Snippet persistence | Core Data XML | SwiftData (shared container) | 14.0 | +| 3 | Concurrency | NSThread / NSOperationQueue | async/await + Actor | 13.0 | +| 4 | Global hotkeys | PTHotKeys (Carbon) | KeyboardShortcuts (CGEventTap) | 12.0 | +| 5 | UI | NSMenu + XIB | SwiftUI MenuBarExtra | 13.0 | +| 6 | JS engine | WebView (deprecated) | JavaScriptCore JSContext | 10.5 | +| 7 | App exclusion | GetFrontProcess (Carbon) | NSWorkspace.frontmostApplication | 10.7 | +| 8 | Login items | LSSharedFileList (deprecated) | SMAppService | 13.0 | +| 9 | Paste | CGEvent keycode scan | CGEvent with cached keycode | — | +| 10 | Settings | Manual NSUserDefaults + KVO | @AppStorage + Combine | 14.0 | +| 11 | Script actions | Filesystem walk + plist | Swift protocol + JS plugin | — | +| 12 | Architecture | Singletons + KVO | MVVM + Combine + Actors | 13.0 | + +### Recommended Phasing + +**Phase 1 — Drop in, no UI changes:** +Items 1, 6, 7, 8, 9 — replace deprecated/polling APIs. These are self-contained and do not require architecture changes. + +**Phase 2 — Data layer:** +Items 2, 3, 10 — replace persistence with SwiftData and adopt async/await. Write migration routines for existing user data. + +**Phase 3 — Full modernisation:** +Items 4, 5, 11, 12 — SwiftUI, hotkey package, protocol-based actions. This is a full rewrite of the UI and coordination layers. diff --git a/doc/features.md b/doc/features.md new file mode 100644 index 0000000..f87455b --- /dev/null +++ b/doc/features.md @@ -0,0 +1,712 @@ +# ClipMenu — Feature & Architecture Reference + +This document is intended to give an LLM (or a developer) enough context to understand, extend, or reconstruct the application from the existing source code. It covers every major feature, the data model, the component wiring, key algorithms, preference keys, and file formats. + +--- + +## Overview + +ClipMenu is a macOS clipboard manager written in Objective-C (manual reference counting, targeting Mac OS X 10.5+). It runs as a background process that never appears in the Dock. Its only persistent UI element is a status-bar icon. The user interacts through pop-up menus triggered by global hotkeys or by clicking the status item. + +Bundle ID: `com.naotaka.ClipMenu` +Version: 0.4.4a13 +License: MIT + +--- + +## High-Level Architecture + +``` +AppController (main controller — owns hotkeys, actions, preference panel) +├── ClipsController (singleton — pasteboard monitoring, clip storage) +├── MenuController (singleton — status item, all pop-up menus) +├── ActionController (singleton — JavaScript engine, built-in actions) +│ └── BuiltInActionController +└── SnippetsController (singleton — Core Data store for snippets) + └── SnippetEditorController +``` + +All singletons use `+initialize`/`+sharedInstance` with a module-level static variable. They are NOT thread-safe; all mutations run on the main thread except autosave which detaches a new thread via `NSThread detachNewThreadSelector:`. + +### Startup Sequence (`applicationDidFinishLaunching:`) + +1. Create an `NSOperationQueue` and enqueue three concurrent operations: + - `[ClipsController loadClips]` — deserialise `clips.data` + - `[ActionController loadActions]` — parse `actions.plist` + - `[AppController _registerHotKeys]` — register global hotkeys via PTHotKeyCenter +2. Prompt user to add login item (once, suppressed by `CMPrefSuppressAlertForLoginItemKey`). +3. Configure Sparkle updater (feed URL, auto-check flag, interval). +4. Wait for all operations to finish. + +### Shutdown Sequence (`applicationWillTerminate:`) + +1. If `CMPrefSaveHistoryOnQuitKey` is YES, call `[ClipsController saveClips]`; else call `[ClipsController removeClips]` (deletes the file). +2. Call `[ActionController saveActions]`. +3. Unregister all hotkeys. + +### KVO Wiring + +`AppController` observes: +- `CMPrefHotKeysKey` → re-registers all hotkeys +- `CMEnableAutomaticCheckPreReleaseKey` → switches Sparkle feed URL + +`ClipsController` observes: +- `CMPrefMaxHistorySizeKey` → trims history +- `CMPrefAutosaveDelayKey` → restarts autosave timer +- `CMPrefTimeIntervalKey` → restarts pasteboard polling timer +- `CMPrefStoreTypesKey` → reloads which types to capture +- `CMPrefExcludeAppsKey` → reloads exclude bundle-ID set + +`AppController` also observes `ClipsController.clips` (KVO key `"clips"`). Any change triggers `[MenuController updateStatusMenu]` so the menu stays current. + +--- + +## Data Model: `Clip` + +`Clip` (`Source/Clip.m`) is an `NSObject` that implements `NSCoding` and `NSCopying`. + +### Properties + +| Property | Type | NSCoder key | +|---|---|---| +| `types` | `NSArray *` (of pasteboard type strings) | `"types"` | +| `createdDate` | `NSDate *` | `"createdDate"` | +| `lastUsedDate` | `NSDate *` | `"lastUsedDate"` | +| `stringValue` | `NSString *` | `"stringValue"` | +| `RTFData` | `NSData *` | `"RTFData"` | +| `PDF` | `NSData *` | `"PDF"` | +| `filenames` | `NSArray *` (of `NSString`) | `"filenames"` | +| `URL` | `NSArray *` (of `NSString`) | `"URL"` | +| `image` | `NSImage *` | `"image"` | +| `thumbnail` | `NSImage *` (transient, not encoded) | — | + +**Backward-compat decoding:** `lastUsedDate` falls back to key `"lastAccessedDate"` (used in v0.4.2a2–a4). `RTFData` falls back to keys `"RTFD"` then `"RTF"`. + +### Identity / Equality + +`hash` is computed from the `types` array string, then XOR-ed with: +- Image: TIFF byte length +- Filenames: XOR of each filename's hash +- URL: XOR of each URL string hash +- PDF: byte length +- stringValue: `NSString` hash +- RTFData: byte length (if present) + +`isEqual:` delegates entirely to `hash` comparison. + +### Supported Pasteboard Types + +Defined in `+availableTypes` (order matters for `primaryPboardType`): + +``` +NSStringPboardType, NSRTFPboardType, NSRTFDPboardType, NSPDFPboardType, +NSFilenamesPboardType, NSURLPboardType, NSTIFFPboardType, NSPICTPboardType +``` + +Human-readable names from `+availableTypeNames`: +`String, RTF, RTFD, PDF, Filenames, URL, TIFF, PICT` + +Both lists are parallel arrays; `+availableTypeDictionary` returns a dict mapping pasteboard type → name. + +### Thumbnail + +`-thumbnailOfSize:(NSSize)size` scales the image to fit within the given bounding box while preserving aspect ratio. The result is cached (invalidated if requested size differs). Uses `bestRepresentationForRect:context:hints:` on 10.6+, falls back to a manual `NSBitmapImageRep` draw into a new `NSImage`. + +--- + +## ClipsController + +`Source/ClipsController.m` — singleton. + +### State + +- `clips` — `NSMutableSet` of `Clip` objects (KVO-observable) +- `storeTypes` — `NSDictionary` mapping type-name → `NSNumber(BOOL)` +- `excludeIdentifiers` — `NSSet` of bundle-identifier strings to ignore +- `maxClipsSize` — `NSUInteger` +- `cachedChangeCount` — `NSInteger`; mirrors `[NSPasteboard generalPasteboard].changeCount` +- `lastClipsUpdated`, `lastSaved` — `NSDate` used by autosave logic + +### Pasteboard Polling + +`_startPasteboardObservingTimer` creates a repeating `NSTimer` at the configured interval (max capped at 1.0 s). On each tick, `_updateClips:` runs: + +1. Read `[NSPasteboard generalPasteboard].changeCount`. If unchanged, return. +2. Cache the new change count. +3. Check `_frontProcessIsInExcludeList` via `GetFrontProcess` + `ProcessInformationCopyDictionary`. If excluded, return. +4. Call `_makeClipFromPasteboard:` to build a new `Clip`. +5. If the set already contains an equal clip (by hash), update its `lastUsedDate` and return. +6. Add the new clip via `mutableSetValueForKey:@"clips"` (triggers KVO). +7. Call `_trimHistorySize`. + +`_makeClipFromPasteboard:` first calls `_makeTypesFromPasteboard:` to build the list of types to store (filtered by `storeTypes`; TIFF and PICT are collapsed to TIFF). It then reads each type's data from the pasteboard. + +### History Trimming + +`_trimHistorySize` sorts clips by `sortedClips` (newest first), then removes the tail items that exceed `maxClipsSize`. + +### Sorted View + +`sortedClips` (dynamic property) sorts by `lastUsedDate` descending when `CMPrefReorderClipsAfterPasting` is YES, or by `createdDate` descending otherwise. + +### Persistence + +- **File:** `~/Library/Application Support/ClipMenu/clips.data` +- **Format:** `NSKeyedArchiver` archive of the `sortedClips` array +- **Save:** `saveClips` — writes via `NSKeyedArchiver archiveRootObject:toFile:` +- **Load:** `loadClips` — unarchives, sets `clips`, trims to max size +- **Delete:** `removeClips` — removes the file from disk (called on quit when save-on-quit is off) + +### Autosave + +A second repeating timer fires at `CMPrefAutosaveDelayKey` seconds (min 15 s). `_autosave` compares `lastSaved` vs `lastClipsUpdated`. If clips have been updated since last save, it detaches a new thread to call `_fireSaveClips`, which calls `saveClips` and updates `lastSaved` on success. On failure, it shows an alert and restarts the timer. + +### Export + +- `exportHistoryStringsAsSingleFile:path` — concatenates all text clips with a configurable separator (newline, CRLF, CR, tab, space, or empty). Uses `NSFileHandle` to append each clip. +- `exportHistoryStringsAsMultipleFiles:path` — writes each text clip to a numbered `.txt` file (e.g. `1.txt`, `2.txt`, …) in the given directory. + +Only clips containing `NSStringPboardType` are exported. Non-text clips are silently skipped. + +### Public Mutation API + +- `copyClipToPasteboard:(Clip *)clip` — writes all of the clip's types back to `[NSPasteboard generalPasteboard]` +- `copyClipToPasteboardAtIndex:(NSUInteger)index` — index into `sortedClips` +- `copyStringToPasteboard:(NSString *)aString` — writes a plain string +- `removeClip:(Clip *)` / `removeClipAtIndex:` — remove from the set (KVO-notified) +- `clearAll` — removes all objects (KVO-notified) + +--- + +## MenuController + +`Source/MenuController.m` — singleton. + +### Menu Types + +```objc +typedef enum { + CMPopUpMenuTypeMain, + CMPopUpMenuTypeHistory, + CMPopUpMenuTypeSnippets, + CMPopUpMenuTypeActions +} CMPopUpMenuType; +``` + +`popUpMenuForType:` constructs the appropriate menu and pops it up near the mouse cursor. + +### Status Item + +`createStatusItem` creates an `NSStatusItem` with icon `StatusMenuIcon.png` (normal) / `StatusMenuIcon_pressed.png` (highlighted). The menu is rebuilt on each open via `updateStatusMenu`. + +### Menu Construction + +`_buildClipMenu` produces the main clip menu: + +1. Add "Open Snippet Editor" and "Preferences" items. +2. Add a separator. +3. Optionally add snippets **above** clips (`CMPositionOfSnippetsAboveClips`). +4. Add clips via `_addClipsToMenu:`. +5. Optionally add snippets **below** clips (`CMPositionOfSnippetsBelowClips`). +6. Optionally add a "Clear History" item. + +`_addClipsToMenu:` iterates `[ClipsController sortedClips]`. For each clip it calls `_makeMenuItemForClip:withCount:andListNumber:`. + +**Inline vs. folder display:** +- `CMPrefNumberOfItemsPlaceInlineKey` clips appear directly in the menu. +- Remaining clips are grouped into submenus of size `CMPrefNumberOfItemsPlaceInsideFolderKey`, with a title like "11-20". +- If both prefs are 0, all clips appear inline. + +### Menu Item Construction (`_makeMenuItemForClip:`) + +1. Determine title: + - `trimTitle()` — strips leading/trailing whitespace, extracts first line, truncates to `CMPrefMaxMenuItemTitleLengthKey` chars with `"..."` suffix. + - If `CMPrefMenuItemsAreMarkedWithNumbersKey`, prefix with `". "` (number starts at 0 or 1 per `CMPrefMenuItemsTitleStartWithZeroKey`, wraps 10→0 when starting from 1). +2. If `CMPrefShowLabelsInMenuKey`, append the primary pasteboard type label. +3. For image clips, if `CMPrefShowImageInTheMenuKey`, use a thumbnail (`CMPrefThumbnailWidthKey` × `CMPrefThumbnailHeightKey`) as the menu item image. +4. If `CMPrefShowIconInTheMenuKey`, use `[Clip fileTypeIconForPboardType:]` scaled to `CMPrefMenuIconSizeKey` pixels as a leading icon. +5. If `CMPrefChangeFontSizeKey`, wrap the title in an `NSAttributedString` with the configured font size (auto: match icon size; manual: `CMPrefSelectedFontSizeKey`). +6. If `CMPrefShowToolTipOnMenuItemKey`, set a tooltip to the first `CMPrefMaxLengthOfToolTipKey` characters. +7. If `CMPrefAddNumericKeyEquivalentsKey`, set key equivalent to `"1"`…`"9"`, `"0"` for items 1–10. +8. Set `tag` = clip index in `sortedClips`. Set `action = @selector(selectMenuItem:)`, `target = AppController`. + +### Snippet Items + +Each enabled snippet in an enabled folder gets a menu item with `action = @selector(selectSnippetMenuItem:)` and `representedObject = NSManagedObject`. + +### Action Menu + +Built from `[ActionController actionNodes]` (a flat/tree list of `ActionNode`). Each leaf node becomes a menu item; container nodes become submenus. Items are filtered through `CMIsPerformableAction()` before being shown. `CMIsPerformableAction` checks the selected clip's types against the action's declared applicable types. + +### `trimTitle()` — title trimming function + +``` +1. NSString *strip — calls [clipString strip] (whitespace trim, defined in NSString+NaoAdditions) +2. getLineStart:end:contentsEnd:forRange: to find first line +3. If multi-line, truncate to contentsEnd +4. If length > maxMenuItemTitleLength, truncate to (max - 3) and append "..." +``` + +--- + +## AppController + +`Source/AppController.m` — loaded from MainMenu.xib. + +### Menu Item Selection + +**`selectMenuItem:sender`** (clips): +1. Call `_applyActionToTarget:sender`. If an action was applied, return. +2. Otherwise call `[ClipsController copyClipToPasteboardAtIndex:[sender tag]]`. +3. Call `[CMUtilities paste]`. + +**`selectSnippetMenuItem:sender`** (snippets): +1. Call `_applyActionToTarget:sender`. If an action was applied, return. +2. `copyStringToPasteboard:` the snippet's `content`. +3. `[CMUtilities paste]`. + +**`selectActionMenuItem:sender`**: +1. Get `NSDictionary *action` from `representedObject`. +2. Call `_invokeAction:`. + +### Modified-Click Detection (`_applyActionToTarget:`) + +Reads `[NSApp currentEvent]` modifier flags at the moment of selection: + +| Condition | Preference key | +|---|---| +| Right-click or Ctrl held | `CMPrefContorlClickBehaviorKey` | +| Shift held | `CMPrefShiftClickBehaviorKey` | +| Option held | `CMPrefOptionClickBehaviorKey` | +| Cmd held | `CMPrefCommandClickBehaviorKey` | + +The preference value is either: +- `kEmptyString` — no override +- `kPopUpActionMenu` — pop up the action selection menu +- `NSDictionary` (an action descriptor) — invoke that action directly + +### Action Invocation Flow + +`_invokeAction:action toIndex:index`: +- `index < 0` → target is `[ActionController selectedSnippet]` (a snippet `NSManagedObject`) +- `index >= 0` → target is `[ClipsController clipAtIndex:index]` +- Dispatches to `_invokeBuiltinAction:toTarget:` or `_invokeJavaScriptAction:atIndex:` + +`_invokeBuiltinAction:toTarget:` looks up the selector from `BuiltInActionController.actions[name]["actionName"]` and calls it via `performSelector:withObject:`. + +`_invokeJavaScriptAction:atIndex:`: +1. Gets the script path from the action dict. +2. Wraps the clip in a `ScriptableClip`. +3. Calls `[ActionController invokeScript:toClip:]`. +4. The result `Clip` is written back to the pasteboard. +5. `[CMUtilities paste]` is called. + +### Paste Implementation (`CMUtilities +paste`) + +Checks `CMPrefInputPasteCommandKey`. If YES, calls `postCommandV()`: +1. First call: scans all key codes 0–127 via `UCKeyTranslate` (TIS) to build a `string→keyCode` map and cache the keyCode for "V". +2. Creates `CGEventRef` key-down and key-up with `kCGEventFlagMaskCommand`. +3. Posts both to `kCGSessionEventTap`. + +### Process Management + +Before popping a menu, `keepCurrentFrontProcessAndActivate` records `GetFrontProcess(&frontPSN)` and brings ClipMenu to the front. After the menu closes (or after a paste), `restorePreviousFrontProcess` calls `SetFrontProcess(&frontPSN)` to give focus back to the original app. + +--- + +## ActionController + +`Source/ActionController.m` — singleton. + +### Initialization + +On `_init`, creates a hidden `WebView` (no frame, loads empty HTML string) used as the JavaScript execution engine. Also creates `ActionNodeFactory` and `BuiltInActionController`. + +The `WebView`'s policy delegate returns `[listener ignore]` for all navigation actions (prevents external resource access). + +### Action Node Tree + +Actions are stored as a flat `NSMutableArray` of `ActionNode` objects. Each node has: +- `title` — display name +- `isLeaf` — YES for executable actions, NO for folders/groups +- `children` — sub-nodes for folders +- `action` — `NSDictionary` for leaf nodes + +Action `NSDictionary` structure: +``` +{ + "type": "builtin" | "javaScript", + "name": "", // for builtins + "path": "