Architecture Status & Debt
Verified current state, completed refactors, and tracked architectural improvements.
On this page (12 sections)
This file holds OpenClip's current-state notes — the places where the code has not yet
reached the target architecture. These change more often than the hard rules, so they are
tracked here rather than in AGENTS.md. Keep this file current when you touch any of these
areas; stale debt notes are worse than none.
Settings Migration (UserDefaults → SettingsStore)#
- The typed settings abstraction is
SettingsStore+SettingKey<T>(seeSources/Core/Settings/). New settings code must route through it. - AI-config
@AppStoragesurface remains (AIServiceManagerkeys), andcompletionCopyToClipboard/ popup theme still read via@AppStorage, but the theme keys (popupTheme,popupThemeColor) now referenceSettingKeydefinitions instead of raw literals. Migrating toSettingsStoreis ongoing — don't add new direct call sites. (startAtLoginwas consolidated ontoSettingKey.startAtLogin—LaunchAtLoginManagerpersists throughDefaultSettingsStore.) - Secrets live in SecretStore, not UserDefaults or macOS Keychain. Sensitive credentials
(the cloud AI API key and action secret options) use
SecretStore(~/.openclip/secrets.jsonwith 0600 POSIX permissions), replacing macOS Keychain to avoid code-signing ACL prompts.AIServiceManager.cloudAPIKeyis@Published, backed bySecretStore(accountaiCloudAPIKey); do not convert it back to@AppStorage. A one-time migration reads the oldUserDefaults"aiCloudAPIKey"key, then deletes it. isAppEnabledis consolidated ontoSettingKey.isAppEnabled— the status bar item and the Preferences toggle read/write it throughDefaultSettingsStore. It means "Appear Automatically" (its label in both places): it owns the selection monitor's passive auto-show and nothing else. It is applied inMacSelectionMonitor.deliverSelection(the mouse-release/keyboard path) as the global form of the per-apphotkeyOnlyrule; the explicit Hold Mouse to Trigger gesture delivers fromhandleMouseDownand is exempt, so off + hold = hold-only mode. The ⌥⌘C hotkey is an explicit request and is deliberately not gated on it.HotkeyManager.triggerAllowedgates on the real kill switches instead: Pause (pauseUntilTimestamp), app exclusion, per-appdisabled. Builtin store-backed actions (CalculateAction,CalendarAction,SearchAction) accept an injectedSettingsStoreviaBuiltinRegistry.makeCoreBuiltins(settingsStore:).- Menu bar visibility is store-backed and reversible.
SettingKey.showMenuBarIcondefaults to true.StatusBarControllerremoves/recreates itsNSStatusItemimmediately when the General-tab toggle changes, while reopening the running app presents Preferences so a hidden icon can be restored without terminating OpenClip. ActionConfigSheetis gone (dead code — zero presenting call sites; itsuseTextkeys were write-only). Removing it also dropped the only UI that wroteSettingKey.searchURL/SettingKey.calculateMode; the actions still read those keys (defaults apply). Search-engine configurability is restored via the built-in Search action's preset picker in the Preferences edit sheet (DynamicActionConfigView— Google / DuckDuckGo / Kagi / Brave / Bing / Ecosia / Custom), which writes the option-store keyaction.builtin.search.option.url(SearchEnginePresetin Core holds the curated catalog); calculate-result-mode still has no Preferences surface.ConfigurableActionkeeps onlypreferenceIconName(used bytableIcon/rowIconicon fallback).- Dynamic action option keys (
JavaScriptAction,AppleScriptAction): the target pattern isSettingKey<String>("action.<id>.option.<identifier>", defaultValue:)viaSettingsStore. The JS path already reads through the injectedoptionStore(OpenClipJSHostreads options read-only viaActionOptionReading);AppleScriptActiondoes not consume options today.
Action Seams Already Implemented#
- Coordinator composition is done.
ActionCoordinator.loadInitialState()wiresExtensionManagerto the registry viaonRegister/onUnregister; the manager never callsActionRegistry.shareddirectly. GUI-authored actions persist as manifest packages (viaCustomActionManifestWriter);custom_actions.json/CustomActionManagerare retired. - Shell runtimes share one executor.
ScriptActionscript files andCustomAction.shellScriptboth run throughShellProcessRunner(one watchdog;TimeoutFlag/OnceGatelive inShellProcessRunner.swift) and translate stdout JSON viaShellResultMapper;NSUserNotificationis gone (.notifyis handled by the effect door viaUNUserNotificationCenter). AppleScript joins the same executor viaAppleScriptRunner(osascript subprocess), so no code runsNSAppleScriptin-process anymore. Since the hang fix, the watchdog is a GCD timer (immune to Swift-concurrency-pool starvation) and pipe output is read via GCDreadabilityHandler(never a blockingreadToEnd(), so a stuck child can't permanently consume a cooperative thread), with stdin seeded and closed synchronously so a script reading stdin always sees EOF. Both pipe accumulators are drained before their buffers are read:waitUntilExit()returns when the direct child exits, so the readability handler can still be behind, or not have run at all. The drain is bounded — it waitsgrace(2 s) for EOF, reads what is pending, then closes the handle — so it captures output that reached the pipe before that deadline. A descendant that writes after the close still loses its output. - Delivery is resolved by
ActionResultDelivery, not per-runtime translation. Runtimes (OpenClipJSHost.run,ShellResultMapper, kind actions) return only raw results; implicitly returned text (JS string return, AppleScript output, shell stdout, text snippets) is emitted as.textand the paste-vs-copy/preview delivery decision (Select → Probe → Toast) is applied downstream from the action's author-declared output contract (outputandresultin manifest /ActionChrome), optional user per-action delivery override (ActionCustomizationManager), the universal secondary-click Clipboard Invariant (secondary click copies; or previews if primary is copy), and the unified paste availability. The flawed global settings (primaryClickBehavior/secondaryClickBehavior) are fully removed. The oldaftertranslator (the pre-refactorafterorchestration step and its adapter) is fully removed. Synchronous JavaScript (including the top-level synchronous phase of async actions) is bounded by JavaScriptCore's VM execution-time limit only when the run carries a budget (Request.timeout), so an explicit timeout unwindsevaluateScriptand releases its sync-evaluation gate slot. Idle promise waiting is bounded by theTimeoutFlagwatchdog under the same budget. Runs with no budget (the default for extension/custom actions) have no timer and hold their gate slot until they settle or are cancelled; Swift task cancellation remains cooperative. A fetch response that arrives after the evaluation ends is discarded (FetchTaskBox.isEnded); the host does not call the JavaScript VM for it (issue #40). Uncaught exceptions that escape native-to-JS callbacks after initial evaluation (issue #48) reject the promise bridge via a per-evaluationexceptionHandlerand surface as.toast(.error)without waiting for the idle watchdog. This does not detect detached unhandled promise rejections, and it does not change the retain-cycle residual below. Residual: retain cycles inJSNativeFetch(nativeFetchBlock→contextBox/context;jsonBlock→JSContextBox) andPromiseState(JSValue?→JSValue.context) keep the finishedJSContextalive — this is line-cited analysis, not empirically probed.PolicySessiondoes not invalidate itsURLSession. A guardedCFRunLoopPerformBlockcan still sit on the shared run loop and hold those references. Do not break a cycle unless the final release ofJSValue/JSContextis guaranteed to land on the JS thread; otherwise an off-thread release becomes a memory-safety hazard. - Custom Action Groups use canonical IDs with dynamic materialization and strict $\ge 2$ member invariant.
User-defined action groups are defined via
ActionGroupDef(Sources/Core/Actions/ActionGroupDef.swift), stored as JSON inSettingKey.actionGroups. Rather than rewriting action identifiers with virtual ID prefixes (e.g.vgroup.<id>.<actionID>), grouped actions retain their exact canonical IDs (builtin.copy,com.user.ext.action).ActionRegistrydynamically materializesCustomGroupAction(Sources/Core/Actions/CustomGroupAction.swift, conforming toActionandSubActionProviding) group rows, injecting them contiguously before their member actions inactions, whileSettingKey.actionOrderstrictly stores real, canonical IDs (excluding synthetic group headers and grouped AI presets).ActionCoordinatormanages the full group lifecycle (createGroup,updateGroup,ungroup,removeFromGroup,loadGroupDefs,pruneOrphans), automatically enforcing the strict $\ge 2$ member invariant: when members are uninstalled or removed, any group dropping below 2 members is immediately dissolved. Availability resolution inActionRegistry.availableActions(for:)maps canonical IDs to owning custom groups to hide member actions when their parent group is disabled or filtered out.
Extension JS Module Runtime#
- JS file scripts run in module mode (CommonJS). A
javascriptaction with"script"getsrequire/module/exports/__dirnameand can split across local files; resolution is Node-style and contained to the package directory (OpenClipModuleLoader+Constants.isPathSafe), with..//symlink escapes, absolute paths, and bare/Node-builtin specifiers rejected. Containment is checked on the final symlink-resolved candidate, including the appended.js/index.js(issue #39). Folder installs are not scanned for symlinks at install time — only.zipentries are (validateZipEntries) — andExtensionPackageHashResolverskips out-of-package symlink targets from the trust hash, so the loader is the last line of defense for file reads. Containment verification is bound directly to the open file descriptor viafcntl(F_GETPATH)before reading, preventing check-then-read races. InlinescriptCodeactions have no modules (byte-identical legacy behavior). - Third-party libraries live on the author side, not the host. npm deps are bundled by the
author with esbuild (
--platform=browser --target=es2020) intodist/main.js; the host loader is.js-only, so TypeScript works only through the bundle path (--with-npmscaffold). Node builtins are rejected at build time by the esbuild platform — they can never run. - The consent gate has landed; capability enforcement remains future work. Nothing runs until
a package is enabled (fail-closed trust states
seen/trusted/revokedbehind a single trust-model consent surface), and a tamper-watch auto-disables a trusted package whose content hash changed at a later load. Still future: JIT permission prompts, gatingfetch/keyPress/runShortcut, seedingManifestCapabilityGate, and a JS honesty-scan acting on mismatches. Extensions still execute in-process with the app's user context; module containment bounds file reads but is not a privilege boundary.
Presentation / Rule Holes#
- No
switch action.idfallback remains.ActionCustomizationManager.tableIcon()resolves viaConfigurableAction.preferenceIconName— the legacy block is gone. Keep it that way: never add id-string switches in presentation.
Action-Search Palette & Popup Growth#
Action bar hover chrome uses a rounded highlight in the system accent color softened 8% toward white, with no horizontal inset for interior text buttons and 2 scaled points for end text buttons; icon buttons retain 2 points for interiors and 3 for ends. All highlights are inset by 3 scaled points vertically, shared by the main and sub-action bars. Interior corners use a 7-point radius; outer edge corners use 10 points (both scaled). Foregrounds and full button hit areas stay unchanged; hovered buttons use a white foreground and the highlight snaps between them with no transition; search chrome is unchanged.
AI presets can leave AI Tools.
SettingKey.standaloneAIActionIDsrecords presets dragged to the Actions list's root. Their canonical IDs participate inaction.order, survive preset reconciliation, and appear directly in the popup. AI Tools excludes those presets from its sub-bar and scoped search. Dropping a preset onto AI Tools removes its explicit order and returns it to the group. Bar and palette clicks share the controller's AI execution path; the global AI switch and individual preset enablement still apply.AI Tools follows group visibility and membership. Its Actions-list and menu-bar toggles use
disabledActionIDs, independently of the global AI service switch. Hidden groups also hide their grouped presets from the bar and search, while standalone presets remain usable. The available-action catalog retains eligible AI children so hover sub-bars and scoped search resolve the same members, without rebuilding filtered-out presets from the AI singleton. Empty AI groups disappear from the popup and Actions list. Ungroup moves all members to the root, Remove from Group moves one, and a standalone preset's Add to Group > AI Tools menu restores the group even after its final member left. Add to Group captures all selected eligible actions, including multiple standalone AI presets. Dropping between its members also works. AI presets support selection-based Delete in the Actions list, removing the saved preset and clearing its shortcut, alias, appearance override, and standalone placement. Return on a single selected action or its Rename context menu edits its display name inline through the customization store; double-clicking a row opens its editor. The editor hero title also supports double-click renaming. A slow second click on a selected name also renames it. Enter or focus loss commits, and Escape cancels. The list chevron still opens the full editor.Actions-list drag destinations are explicit. A soft accent fill highlights only the destination group header, without an outline; root drops use the native full-width insertion line. The leading gutter at an expanded group’s bottom gap moves actions out; the indented side reorders inside. Multi-member drops reorder the whole selection using the displayed gap index. Returning AI presets uses the same insertion calculation. Moving the last member out preserves the root destination when its empty group disappears; dragging a group with selected children keeps its membership intact.
AI presets share the standard action editor. Their AI settings pages expose the same icon picker, Icon/Text display mode, search alias and per-action shortcut as other leaf actions, alongside an autosaved prompt field. Appearance and aliases use the existing stores keyed by canonical action ID, so moving a preset into or out of AI Tools preserves its customization. Text remains the default; Icon mode falls back to the AI symbol when no icon has been chosen. The new-preset form exposes appearance, alias and shortcut controls before creation. Its draft defaults to Text and only persists these settings when Add Action succeeds, after alias validation; cancelling does not register a shortcut or reserve an alias. AI output still streams into its result card, so the ordinary delivery picker is not shown.
Content-driven panel growth has no controller callback. The
NSHostingViewauto-resizes the panel window top-anchored when its SwiftUI content grows (e.g. entering search mode);onPreferenceChange/onContentSizeChangenever fires for this andsizingOptionshas no effect. The only reliable hook isPopupPanel.setFrame(PopupPanel.swift:42): whenpinBottomEdgeOnResizeis set it keeps the bottom edge fixed so results-above-the-field growth never shoves the popup. For the search palette the pin is one-shot (releasesBottomPinAfterGrowth): it covers the entry growth only. The palette captures its height from the initial result count, so filtering does not resize the card or move the field; longer result sets scroll within that height.exitSearch()re-arms the pin for the search→bar collapse after restoring the bar's bottom edge (Esc no longer jumps the popup). Both flags are cleared byshow(for:)andhide()before intentional placement.Search and content modes are the two key exceptions to the never-key rule.
PopupPanel.allowsKeyenablescanBecomeKey/canBecomeMainin both modes (PopupPanel.swift:19), routed through the sameenterKeyMode()/exitKeyMode()primitives (PopupWindowController.swift:196,206). A@FocusState-in-onAppear request is silently dropped on macOS, so search forces focus viafocusSearchField()on the next run-loop turn (PopupWindowController.swift:245);previousFrontmostAppis captured once per session (onshow(for:)/enterKeyMode, never re-captured mid-session) and re-activated onexitKeyMode/hide.Search and content modes suspend popup dismissal. The distance auto-dismiss and the key/scroll dismissals in
handleEventare skipped whilemodeStore.mode == .searchor.content(PopupWindowController.swift:591,612,622), so typing with the mouse elsewhere doesn't close the palette, and the result card stays open until it is collapsed or the popup hides.The floating bubble panel is gone; content renders inline. The second
PopupPanel(and itsshowBubble/hideBubble/bubbleBlocksDismissmachinery) was removed — all action/AI/status content renders inside the single panel via.contentmode (PopupModeStore) as a native SwiftUIResultCardView. The card is general, not AI-specific: any text-returning action lands there with its customization-resolved icon (DeliveryContext.actionIcon, snapshotted by the same perform paths asactionTitle); AI streaming deliveries pass no icon and keep the sparkles glyph.StatusBadgeModeland the old.info/.result/.menuemphasis model are gone, and the inline status banner is gone too: everyStatusFeedbackrenders as a floating toast (ToastPanelController) with no queue — a status shows over the card — andshowsLoadingactions (manifest"loading") use the early-close spinner toast.Search and result-card footers share separate theme-aware buttons.
PopupFooterButtonChromeuses a native.glassEffectrounded rectangle for the glass theme on macOS 26+, with a material/tint fallback; secondary actions are neutral and the primary action uses the accent color. In light mode, primary glass buttons use a full-opacity accent base and glass tint to avoid washing out the color beneath white labels. Buttons have a 12pt radius, compact targets, and a 6pt gap, with an 8pt trailing inset so the final button sits closer to the popup edge.The search header can move the popup. Dragging its magnifying glass or the clear strips above and below the text field uses the result card's panel-drag path; editing and selecting search text remains on the text field. The pre-search bar frame moves by the same delta so leaving search returns the bar to the dragged position. The trailing source chip shows whether the active action context is the selection or clipboard; Tab switches between them when a distinct clipboard text snapshot is available, and the visible chip can also be clicked.
Define has a single display picker; popover mode is a system Look Up popover, not inline content.
DefineActionexposes onedefinitionDisplaypicker (carddefault /popover/dictionary), replacing the oldopenInDictionaryAppboolean; a legacytruemigrates todictionaryonce at launch (BuiltinRegistry.makeCoreBuiltins→DefineAction.migrateLegacyDisplayOptionIfNeeded).cardresolves in-process and lands in the result card like any text result;dictionaryopens thex-dictionary:URL;popoverreturns the presenter-ownedActionResult.showDefinition(word)case, handled at the effect door byNSView.showDefinition(for:at:)anchored to the popup's content view. It has no card but is kept-open (dismissesPopup == false) so the popover keeps its anchor. Caveat:PopupPanelis a.nonactivatingPanel, so whether Look Up renders without anNSApp.activate()is unverified.File results preview inline by kind.
FileOutputKind(Core/Actions/FileOutputKind.swift) classifies aFileOutputPayloadintoimage/pdf/text/otherfrom its MIME type, falling back toUTTypeconformance on the filename extension, so path-only results (no MIME) classify correctly;isImageis nowkind == .image.ResultCardViewroutes on the kind: images keep the aspect-fittedNSImage/SVG preview, PDFs embedPDFKit'sPDFView, text-kind files load up to 100 KB and render inline monospace (an unreadable/binary file downgrades toother), and everything else (audio/video/office/archives/unknown) embedsQLPreviewViewwith a filename/size caption. The generic icon + metadata card (NSWorkspaceicon) stays the fallback for a failed image or binary-text load, so a file result is never blank. The temporary-output filename map inShellProcessRunner.extensionForMimeTypegained the generated-document/media MIME types (md, csv, html, xml, yaml, rtf, docx, xlsx, pptx, wav, m4a, mov, webm) plus aUTTypefallback instead of the old closed 11-entry list. ⌘S/⏎ save and ⌘C copies; Space no longer opens the Quick Look panel (the key is left to the focused preview) — the generic fallback card keeps a click-only Preview button. EmbeddedPDFView/QLPreviewViewpreviews render outside the card's outerScrollViewso their own scrolling isn't shadowed, PDF card sizing uses page 0's rotated crop box, and an unopenable PDF falls back to the generic card. Drag-out is scoped to the filename caption for embedded previews (leaving PDF text selection the page area) and stays whole-body for images and text files.MathEvaluatorreplaced crash-proneNSExpression.CalculateActionused to runNSExpression(format:), which throws an uncaught Objective-C exception on malformed selection text like+or1+(crash). The pure-SwiftMathEvaluator(Sources/Core/Actions/MathEvaluator.swift) returns nil (never traps) and properly supports%modulo. Regression coverage inTests/OpenClipTests/CalculateActionTests.swift.Search rows render icons strictly
[icon | text]. A.texticon in the icon column would duplicate the title, soPopupSearchView.rowIconfalls back toConfigurableAction.preferenceIconName; all fourActionIconcases render through the sharedActionIconView(Sources/OpenClip/UI/Icons/ActionIconView.swift), including Iconify-format symbols (prefix:name). The popup bar keeps its owniconView(for:)(PopupView.swift) because text icons there need natural width + horizontal padding, not a fixed frame.Popup sizing constants live in the App target.
PopupMetrics(Sources/OpenClip/UI/Popup/PopupMetrics.swift) holds the UI-only values —searchMaxRows(5),searchResultRowHeight(32),searchPeekRowFraction(0.5),popupMaxHeight(300, the shared height cap for the popup panel — lifted per-session viaPopupPanel.heightCapwhile the result card shows, since a user-resized card may be taller), the AI card bounds (aiCardMinWidth220 /aiCardIdealWidth320 /aiCardMaxWidth360 /aiCardMinHeight200 /aiCardMaxHeight280 — the max bounds only cap the content-driven default; the card's resize handles go up to the screen, seePopupResizeGeometry) plus placement/dismissal distances. The remembered card size is a popup preference declared in the App target (SettingKey+ResultCard.swift, next toSettingKey+MenuBar.swift) because it is pure presentation.Core/Selection/Constants.swiftkeeps only domain/runtime constants (timeouts, key codes, env vars, manifest keys).The Search all actions button is optional.
SettingKey.showSearchAllActionsdefaults to true and is exposed under Customize → Behavior. Hiding it removes its width reservation from popup pagination and updates the Appearance preview. The search hotkey stays available.
AI Providers#
- Apple Intelligence is gated by one availability source.
AppleIntelligenceAvailability(Sources/OpenClip/AI/AppleIntelligenceAvailability.swift) folds the#available(macOS 26.0, *)floor together withSystemLanguageModel.default.availability(deviceNotEligible,appleIntelligenceNotEnabled,modelNotReady) into oneStatus, and owns the copy for each. The Preferences status row (AIConfigureForm) and the provider's runtime errors both read it, so they can't disagree about why the feature is off. The Apple picker segment is still selectable on unsupported machines — it explains rather than disables. - The Apple provider uses guided generation, not tag scraping.
AppleIntelligenceResponse(@Generable, inAppleIntelligenceProvider.swift) declaresresult/title, so the framework constrains the model's output; the provider re-emits those fields viaAIRequestSupport.taggedResponseinto the<result>/<title>contract that the palette, result card, and save-as-tool flows already parse.systemPrompt(structuredResult:)swaps the tag instructions for schema-field wording, because asking for tags would make the model embed them inside the fields;taggedResponseadditionally runs values throughstripTagMarkupfor the same reason, since the palette's text-shaped prompts still say "inside <title> tags". - One name per response.
<title>carries the short name for the work — a task heading for the answer card, or a reusable action name when saving a palette instruction as an AI tool. Which style to produce is the flow's business and lives only in the prompt wording (PaletteAIPrompt.saveToolTaskPrompt); one generated name serves both the preset title and the card heading in the save flow. tokenCountis only used as a pre-flight guard. The provider counts tokens (macOS 26.4+) and compares againstcontextSizeonly when the input exceedstokenBudgetCheckThreshold(2000 chars), throwingrequestTooLargebefore paying for a doomed generation. Oversized selections are not auto-routed to another provider.- macOS 27 APIs are not yet adopted.
PrivateCloudComputeLanguageModel, the newLanguageModelprotocol, and the Vision-backed tools (OCRTool/BarcodeReaderTool) are macOS 27 / Xcode 27 only and are absent from the 26.5 SDK this repo builds against, so they cannot be referenced at all (#if canImport(FoundationModels)is already true on 26.x, so a symbol reference breaks the build rather than failing at runtime). Adding Private Cloud Compute and the model-abstraction refactor is deferred until the toolchain is upgraded.
Screen text capture#
The menu bar and the dedicated ⌥⌘O shortcut start a one-shot ScreenCaptureKit capture after a
per-screen crosshair drag. Vision performs recognition locally; captured text opens the ordinary
action bar beside the selector's mouse-release point using the standard popup gap and
SelectionSource.ocr. If the pointer moves more than eight points while recognition runs, the
popup follows its current location and drops the stale drag-direction hint; the original capture
region remains in selectionBounds. The selector closes synchronously on a valid release so the
screen returns to normal before capture and recognition begin. A one-second arrival grace avoids
distance-dismissal from cursor movement during recognition. Switching to another app cancels the
pending OCR delivery.
OCR sessions permit Copy and other actions that accept text. Extension requirements.input now
separates optional input, nonblank text, a live selection, and a confirmed editable selection;
legacy requiresSelection maps to text/optional. OCR satisfies text, while selection-bound
requirements reject OCR and clipboard sources. requiresPasteTarget describes destination support
independently from input source. At the result-delivery boundary, OCR cut/paste effects are converted
to Copy and simulated paste is dropped, including direct and loading action paths. Actions that
require a live or editable selection recheck the captured selection generation and target before
their effects run. This check does not sandbox scripts or prevent arbitrary side effects authored
inside them. Catalog manifests using the new fields must wait for the first app release that enforces
them; the current source version (1.7.3) is not a confirmed compatibility floor. The OCR flow writes
nothing to the pasteboard until the user chooses Copy. Screen Recording access is
requested by the system on
first capture; capture errors show System Settings guidance. Selection monitoring is suppressed
and pending OCR is cancelled through the capture request token, so stale OCR results cannot
replace a newer popup.
Unused / Latent#
ActionContext.modifiersis currently unused. No action reads it;PopupWindowControllerpassesmodifiers: []. The click intent itself is plumbed:ActionContext.isSecondaryClickis set from the run's resolved intent by the bar/sub-bar/palette perform paths (right-click or ⇧-click via the bar/sub-bar mouse intent; ⇧⏎ and the ⇧⏎ footer badge via the palette's ownreplaceflag, passed explicitly throughonWillPerformAction/onRunLoadingAction) and read byDefineActionto copy a definition headlessly. True modifier keys (⌘/⌥) still don't reach actions.- Paste delivery is now standardized but has a probe reliance. Leaf
.pasteresults are re-decided byActionResultDelivery(App target) per the rule in the dev-guide §5b: a secondary click uses the declaredsecondaryoutcome (else derives.copyfrom a.pasteprimary), and the unifiedPasteAvailabilityanswer (per-app rules win, AXPasteAvailabilityProbefills in) downgrades a chosen.pasteto.copywhen it says no; otherwise the requested paste is honored. The delivery inputs are snapshotted at perform time — before the dismissinghide()clears the session context — sodenyPasteholds even for pastes that dismiss the popup, and the AI card's Paste/Copy buttons are explicit requests (performCardEffect) that carry no delivery context and are never re-decided. The live probe (PasteAvailabilityProbe) needs Accessibility permission and walks the target's Edit ▸ Paste AX menu item; without AX it returns "unknown" and delivery falls back to copy (safe but means paste never happens for AX-less users). The same unified decision drivesmodeStore.canPaste, which hides the card's Paste button and the bar/search Paste + Cut (PasteRequiringAction) actions on a confirmed cannot-paste; unknown keeps them visible. The probe is started by the trigger sites in parallel with selection retrieval and applied before the first frame (probe-before-render, nothing cached), so a same-app focus-context change re-probes cleanly. The click-intent capture reads only ⇧ (not ⌘/⌥); the bar/sub-bar take it from mouse-down, while the palette resolves it itself (replacefor ⇧⏎ / the ⇧⏎ badge, else the captured mouse intent) and passes it explicitly into the delivery snapshot, and entering the palette resetspendingClickIntentso a keyboard run never inherits the right-click that opened a group's scoped palette. Since Task 4, each action's declaredAction.delivery(a distinct secondary outcome + per-clickprimaryToast/secondaryToast) is snapshotted alongside the click intent and fed intoresolve, and the returned tuple's toast is rendered directly — the manualisDowngradedToCopy/isCopyDefinitioninline toast detection was removed in favor of the resolved.toast. Since Task 8, a script-emitted.toast(or any result whosecontainsToastis true) suppresses the delivery companion toast entirely — one toast per run — andkeepVisible: truestops a toast's auto-dismiss and keeps the popup open (a.toastdismisses by default). Only the SwiftUI inline perform path snapshots via the newonWillPerformActionclosure; the completion-button paste path (PopupViewonResult(.paste(word))) routes throughdeliverResult, which clearspendingDeliveryright after its snapshot (single-use per perform), so a prior non-dismissing action's declared delivery can never leak onto a completion paste. The force-copy probe short-circuit skips the AX walk for a secondary click whose outcome is a copy; a declared.pastesecondary is the exception and still probes, so it is honored when the target can paste (and downgrades to copy when it cannot). Implicitly returned text (runtimes emit.text, never auto-dismissing) is resolved byActionResultDelivery.resolveusing the action's author output contract (outputandresultin manifest /ActionChrome), optional user per-action delivery override (ActionCustomizationManager), the universal secondary-click Clipboard Invariant, and unified AX paste availability: a paste outcome probes like any paste and downgrades to copy when the target can't paste, a copy outcome delivers a native copy with no toast, and a preview outcome keeps the popup open for the card render — dismissal for.textis decided by the controller'sshouldDismiss, notdismissesPopup. The loading re-show path (settleLoadingResult) re-creates the popup from the pre-early-close selection snapshot to present the card.- Target application and delivery context snapshotted before perform. Asynchronous actions snapshot
the target application, app policy, and declared delivery into an
inFlightDeliveryContext(or local task constant) before performing. If the user switches applications while an asynchronous action is executing,resolveDeliverydetects that the target application is no longer active and safely downgrades.pasteto.copywith a "Copied" toast to prevent pasting into the newly focused application. - Re-show binds to the current frontmost app. The loading-preview re-show
(
settleLoadingResult) re-creates the popup from the selection snapshot, buthide()clearspreviousFrontmostApp, so the re-show re-captures whatever is frontmost at settle time — if the user switched apps during a multi-second spinner, dismissal reactivates that app. The card's paste probe correctly targets the snapshotted app; only dismissal's reactivation is affected. - Sequences resolve item by item.
ActionResultDeliveryselects and probes each item of a.sequence. A declared secondary replaces the whole sequence once before that walk. The popup runs each remaining item after the previous item is complete.
- Target application and delivery context snapshotted before perform. Asynchronous actions snapshot
the target application, app policy, and declared delivery into an
- HotkeyManager.executor pattern (
HotkeyManager.swift:22): a latentTask { @MainActor ininside the shortcut callback could be hardened to an explicit executor; optional.
Concurrency#
- AX caller deadlines and worker lifetimes are separate. Inspect, menu press, paste probes, and copy-menu probes run on concurrent background queues with independent watchdogs. A deadline resumes the caller promptly, but the worker retains its concurrency permit until the synchronous AX work returns. At saturation, new work fails fast rather than adding abandoned threads. OS messaging timeouts and aggregate menu-walk deadlines still cannot forcibly terminate a synchronous call already executing.
- Subprocess pipe reads are non-blocking (hang fix).
ShellProcessRunnerpreviously read stdout/ stderr with blockingreadToEnd()tasks and aTask.sleepwatchdog — both can be starved, so a child (or grandchild) holding a pipe open could wedge the cooperative pool and hang the test suite indefinitely (observed mid-suite inScriptActionTests.testScriptExecution). The runner now uses a GCD timer watchdog + GCDreadabilityHandlerreads + synchronous stdin close. This claim covers only the pipe reads:process.waitUntilExit()still blocks its detached thread until the child exits — bounded only when the invocation carries an explicittimeout, which arms the watchdog; with none (the default) it runs until the child exits or the caller cancels. At timeout the watchdog starts descendant cleanup (the child, the process group when the child is the leader, and remaining descendants).waitUntilExit()waits only for the direct child; descendant SIGKILL is scheduled asynchronously and may finish after the wait returns. Descendants come from aproc_listchildpidswalk of the child's own subtree (not a scan of the whole process table), snapshotted with start times beforeterminate(). Two limits remain: a probe that reports 0 children is still read once, because a spurious 0 would silently drop a descendant; and a grandchild that reparented to launchd before the snapshot is unreachable by any pid walk.
Selection Retrieval#
- Clipboard operations share ownership. OpenSelection's pasteboard coordinator reserves the clipboard through preparation, activation, and the bounded delivery window. Posted captures drain independently of caller cancellation; permanent OpenClip copies queue behind committed operations. Snapshot inheritance and restoration check clipboard generations so intervening external writes survive. External apps cannot be locked: a write arriving beyond the configured capture/delivery window, or between an OS clipboard check and write, remains a platform limitation.
- Automatic copy is authorized separately from AX evidence. Weak evidence consults the Command-C menu state; only a confirmed disabled command refuses capture. Strong evidence bypasses the menu walk. Probes run off the main actor with independent caller deadlines and a cap held until workers exit. Capture checks cancellation, source PID, and overlays before posting. All hotkey retrieval paths bind authorization to their captured source PID.
- Selection reads return explainable outcomes. OpenSelection's
retrieveResponse,AutomaticCopyCapture.captureResponse, andPasteboardCopyEngine.captureResponsedistinguish empty selection, changed target, timeout, blocked copy, cancellation, policy refusal, worker saturation, and failure. Optional APIs remain compatibility projections. Core'sTextReadResponseandSelectionTriggerResponsecarry these through the bridge and hotkey/monitor paths. Clipboard fallback retains the original retrieval reason. Reports add an optionalreadStatusso older report JSON remains readable; outcome logs contain codes, never selected text or clipboard payloads. - Selection logging is summarized and correlated. Each retrieval emits one completion event with configured/winning strategies, reason, timings, retry counts and observed clipboard restoration. Poll-level details are trace-only. Task-local correlation follows capture and detached menu work; response IDs correlate monitor/hotkey delivery decisions. Reports retain structured metrics without duplicating the app's completion line. Phase measurements overlap; they are diagnostic boundaries, not additive latency components.
- Delayed paste checks observed selection freshness. Monitor invalidation advances a generation carried by selection contexts. Hotkey reads reject results after a generation changes, and popup delivery checks freshness before and after paste probing, downgrading stale paste results to copy. This covers observed mouse/keyboard field changes and app switches; programmatic focus changes without an observed gesture are not detected. It is not an AX focused-element identity guarantee, and arbitrary extension keystrokes remain outside this paste-result check.
- OpenSelection is published and pinned to 2.15.3.
project.ymlresolves the remote tag ata421effaebe473eb121fe0ebc469117a1de325e0; the local checkout is optional. Inspect and menu budgets use monotonic clocks. Inspect deliberately leaves the initial system-wide focused-app lookup at the process-default timeout to avoid mutating the process-global AX timeout. That call remains residual exposure; subsequent element reads are bounded. Shared-trace refresh can extend an overlapping retry worker's budget. - Selection-to-popup diagnostics carry the retrieval trace.
SelectionContext.traceIDsurvives context transformations and correlates blocked delivery, placement failures, recovery, and related occlusion/dismissal events. Routine successful presentation is quiet. Seedocs/logging.md. Window state is evidence, not proof of on-screen pixels; pre-retrieval gesture rejection may have no retrieval trace. - Popup fullscreen eligibility is explicit. The main panel uses
canJoinAllApplicationsalongsidecanJoinAllSpacesandfullScreenAuxiliaryto join other apps' fullscreen Spaces. At the first delayed visibility check, a visible non-key panel still off-Space gets one replacement panel with the existing hosting view and frame, only while its source PID remains frontmost. A separate presentation token invalidates checks on hide or replacement (including re-shows retaining an AI session). Resetting flags on the old window failed in live traces #102/#104/#106; the new window identity replaces that failed approach. Recovery is restricted to action mode. The content view and frame survive; recovery never activates OpenClip or makes the panel key. The second check logs unresolved placement. Automated tests verify the guards and bounded retry; actual fullscreen placement still requires WindowServer validation on the affected machine. - Hover over fullscreen rides the panels'
.activeAlwaystracking areas, not the event monitors. While OpenClip is inactive over another app's fullscreen Space, the global monitor receives no own-windowmouseMovedevents (they are not "global"), the local monitor gets none (the non-activating panel is not key and the app is not active), and SwiftUI.onHoverdoes not fire for an inactive non-key window — so hover highlights, tooltips, and the group sub-bar dwell all died in fullscreen.PopupPanel.ContentViewandSubBarPanel.ContentViewnow install.activeAlways+.mouseMovedtracking areas (AppKit delivers these regardless of app/key status) and forward toPopupWindowController.handleTrackingMouseMoved/handleTrackingMouseExited, which drive the sameupdatePopupHover/updateSubBarHoverpipeline. That path deliberately passestoggleClickThrough: false: with no monitor to notice pointer re-entry, togglingignoresMouseEventswould strand the panel under the cursor. The monitors remain the source of click/scroll/key dismissal; pointer-leaves-popup dismissal in fullscreen still relies only on the panel'smouseExited, not global distance. - Automatic reads are tied to the selection source process. The monitor uses the activated app
from the workspace notification and cancels pending reads and clears the cached selection on a
switch away from the source. Queued notifications for apps no longer frontmost, activation of
the source itself, and OpenClip's own popup activation are ignored. Delivery rechecks the source
PID and suppression after asynchronous retrieval, paste probing, editability probing, and inline
prewarming, so a late result cannot show over another app. The injected copy capture also checks
the original source PID before entering OpenSelection's capture, covering a switch that happens
during AX retrieval before the package snapshots the frontmost PID. Mouse and keyboard passive monitoring
both withhold copy fallback when automatic appearance is disabled or the app is
hotkeyOnly; native AX reads still populate the shortcut cache. - Window gestures are rejected before retrieval. OpenSelection's
SelectionGestureWindowsupplies the geometry snapshot; OpenClip's monitor records the top normal window under the initial press and compares that window's frame on release. A move, resize or missing window cancels retrieval even if the focused editor still exposes an old selection. Missing initial window metadata leaves ordinary selection detection intact; the copy authorization above still applies. This geometry check does not classify every custom control drag, or a window moved away and back to exactly its original frame within one gesture. - The coordinator runs a targeted strategy chain with native AX prioritization.
retrievalModepicks the entry point; retrieval runs that strategy and its fallbacks (native text controls fall back to keyboard copy unless strictly native; web areas cascadeax-web-area → keyboard-copy). Browsers resolve natively viaAXWebAreain <1 ms with deep ancestor search (depth 25 + window search) and fall back to keyboard copy with a 0.6 s timeout, completely removing the legacybrowser-scriptAppleScript subprocess churn and permission friction. - Web-area settle-retry exhaustion is untested. The
.axWebArearetry loop (webAreaSettleMaxRetries= 6, re-inspecting fresh each attempt) returnsnilwhen the text never appears, but the exhausted path has no dedicated test — the loop is exercised only through fixture snapshots inSelectionRetrievalCoordinatorTests. - Copy-path clipboard visibility caveat (mostly closed in OpenSelection 0.2.6). The
.menuCopy/.keyboardCopyengine restores the archived items synchronously in the same run-loop turn as the capture read (no grace sleep; exposure is microseconds), taggedorg.nspasteboard.TransientType+AutoGeneratedType+ConcealedTypeso clipboard managers skip it. A residual microsecond race remains for a clipboard manager pollingchangeCountbetween the synthetic copy landing and the restore;pasteboardRestoreDelayno longer gates the capture path (it only backs the paste-backSelectionReplacer). - Hold-trigger clipboard fallback is structurally gated, not cursor-gated. The
MacSelectionMonitorhold task inherits the clipboard only when the press point resolves to editable text viapressIsOverEditableText(and paste is not denied): an AX hit-test to a text control, or the focused editable element's frame covering the press (CodeMirror/Monaco's caret-anchored hidden textarea). The earlier I-beam-cursor signal was dropped — browsers render an I-beam over read-only selectable text, so it re-admitted the fallback over web articles. A "shared web area" widening was also rejected: it admitted any read-only page text while an input elsewhere held focus, which is the leak the gate exists to prevent. Residual limit: a custom-drawn editor exposing no AX text control at all (rare outside terminals, which are policy-excluded) will not fall back.SelectionEditabilityProberuns the lookup on a dedicated concurrent queue, with the press coordinates and source PID captured before dispatch. A watchdog returns false ataxReadTimeout; each AX message uses the remaining shared budget and no new read starts after the deadline. Actual workers are capped ataxMaxConcurrentInspectsand keep their permits until they exit, so timed-out workers cannot accumulate unbounded threads. Saturation fails closed (no clipboard fallback) while those workers finish. The main actor remains responsive throughout.
Test Isolation#
- Shared reset for the app singletons:
Tests/OpenClipTests/TestIsolation.swiftcentralizesTestIsolation.reset()— clearsActionRegistry.shared,ActionCustomizationManager.shared,RuleEngine.shared, andExtensionManager.shared(loaded actions,onRegister/onUnregistercallbacks, and factory). The singleton-touching test classes call it insetUp(), so the suite is order-independent.ActionCoordinator.sharedneeds no explicit reset: it mirrors the registry's@Publishedstate, whichActionRegistry.reset()clears. - Tests must wire what they read. A test that expects loaded extensions to land in the shared
registry must set
ExtensionManager.shared.onRegisteritself (seeGoldenExtensionPlatformTests.setUp) rather than relying on wiring left behind by an earlier test class. Keep usingTestIsolation.reset()rather than cross-class state. - Store-backed behavior tests via
MemorySettingsStore. The shared in-memory test double (Tests/OpenClipTests/MemorySettingsStore.swift) replacesUserDefaults.standardmutation inCalculateActionTests,ActionRegistryTests,GoldenExtensionPlatformTests, andActionCustomizationTests. Prefer it (orDefaultSettingsStore(userDefaults: suiteName)) over writing the real preferences domain. - Isolated in-memory test doubles and seams. Store-backed tests use
MemorySettingsStorerather than writing the real preferences domain, andSecretActionOptionStoreTestsredirects to a temporary file (SecretStore.setFileURLForTesting), eliminating live system pasteboard/keychain mutation during test runs. - Removed slow/flaky/environment-dependent tests: the Apple Intelligence live-model test
(
testAppleIntelligenceMatchesPresetPrompts) made real on-deviceLanguageModelSessioncalls;DebugLogEndToEndTestspolledOSLogStorewith multi-second sleeps;ScriptActionTestsduplicatedScriptActionExecutionTests; and interactive popup panel/toast/search UI tests requiring live window-server positioning were trimmed to optimize test execution speed and keep the unit test suite fast and deterministic. Core domain logic, validation, action execution, and isolation tests remain fully covered.
Logging#
- Single
Logsurface is in.Sources/Core/Log.swiftowns everyos.Loggercategory (settings,presentation,chrome,factory,coordinator,result-handler,shell,js,selection,extensions,ai,permissions,icons,updates); allprint()calls are gone. Seedocs/logging.mdfor the category table and filtering workflow. - Level budget is conservative. Most messages are
.notice/.error;.debugis used for defensive parses and transient network hiccups (filtered out by default in Console). chromecategory is reserved but unused — no popup-window-chrome code logs yet.RotatingFileLogSinkkeeps all non-Sendablestate on its serial queue, including itsDateFormatter; its@unchecked Sendabledepends on that rule (#45).
Popup appearance preview and pagination
The appearance preview uses one contextual sample (Calculate), text labels for Copy/Cut/Paste, and fixed SF Symbol sample actions, with a presenter that ignores saved action customizations. Its stable preview viewport pins the contextual island at a left inset as the main bar grows. Per-action contextual exclusions do not change the static sample. The main bar's Actions Per Page setting and width budget apply to the standard island independently of the contextual island.