Action Coordinator & Composition
The composition root orchestrating extension loaders, builtins, and the central action registry.
On this page (11 sections)
The ActionCoordinator serves as the Action Coordinator & Composition of OpenClip. It bridges domain managers (ExtensionManager, RuleEngine) with the central ActionRegistry catalog, resolving available actions based on user configuration, current text selection, and active application policy rules.
Architectural Responsibilities#
- Composition Seam: Initializes domain registries during
loadInitialState(). - Catalog Storage: Delegates raw action storage and sorting to
ActionRegistry. - Policy Context: App policies are resolved by the trigger sites (
RuleEngine.resolvePolicies) and attached to the selection context; delivery re-reads them from the snapshot (paste-vs-copy). - Action Reordering: Exposes reordering primitives (
moveActions(from:to:)) that mutate user preferences stored viaSettingsStore. - Custom Action Groups Lifecycle: Manages user-defined group CRUD operations and synchronization with
ActionRegistry.
Action Registration Mechanics#
Current reality (2026-08): the
onRegister/onUnregistercallback seam described below is implemented.ActionCoordinator.loadInitialState()wiresExtensionManagerto the registry via those callbacks; the manager never callsActionRegistry.shareddirectly. GUI-authored custom actions are single-action manifest packages (written byCustomActionManifestWriter) and load through the same extension scan —custom_actions.json/CustomActionManagerare retired.
Initial Loading Lifecycle#
@MainActor
public func loadInitialState(
extensionsDirectory: URL = Constants.extensionsDirectory,
rulesURL: URL = Constants.rulesFileURL,
dictionaryLookup: @escaping @Sendable (String) -> String? = { _ in nil }
) async {
// 1. Register Core Builtin Actions (Copy, Cut, Paste, Define, Search, Calculate)
let coreBuiltins = BuiltinRegistry.makeCoreBuiltins(
settingsStore: settingsStore,
dictionaryLookup: dictionaryLookup
)
registry.register(builtIns: coreBuiltins)
// 2. Load application rules and scan installed extensions (manifests, standalone scripts, snippets)
await ruleEngine.loadRules(from: rulesURL)
await extensionManager.loadExtensions(from: extensionsDirectory)
// 3. Load custom action groups
loadGroupDefs()
}Registration & Unregistration#
When extension packages are added, updated, or removed:
ExtensionManager.loadExtensions()unregisters previous extension actions and reports newly discovered ones through theonRegister/onUnregistercallbacks (wired to the registry byActionCoordinator.loadInitialState()).- Custom action group definitions (
ActionGroupDef) inSettingsStorepersist across extension unregistrations, reloads, and updates. Absent members are dynamically filtered out at runtime byCustomGroupAction.subActions(in:)without mutating the saved user configuration. - Neither Core domain manager touches
ActionRegistry.shareddirectly;ActionCoordinatoris the only type that does.
Action Registry & Ordering Policy#
The ActionRegistry is responsible for maintaining the in-memory array of registered actions and enforcing sorting order based on user preferences.
Dynamic Ordering Math & Custom Groups#
Actions are stored in an internal @Published array. Whenever new actions are registered or group definitions change, sortActions() evaluates their index against the user's saved preference array (SettingKey.actionOrder) using a three-tier ranking:
- Tier 0: Actions explicitly ordered by the user in
SettingKey.actionOrder. - Tier 1: Unordered built-in actions (stable insertion order).
- Tier 2: Unordered extensions and other actions (stable insertion order).
When ActionGroupDefs are present, ActionRegistry injects a synthetic CustomGroupAction header followed contiguously by all of its member actions (in their sorted relative order) upon the first member encounter in the base sort.
Group members maintain their canonical IDs (e.g. builtin.copy, com.user.ext.action) without virtual ID prefixing (vgroup.<id>.<actionID>).
When users drag to reorder actions in the Preferences UI, moveActions(from:to:) re-arranges the items in memory and immediately persists the updated list of action identifiers to SettingsStore, filtering out AI presets and synthetic CustomGroupAction headers so actionOrder only tracks canonical action IDs:
public func moveActions(from source: IndexSet, to destination: Int) {
// Reorder in-memory list ...
let newOrder = actions
.filter { !ActionIdentity.isAIPreset($0) && !($0 is CustomGroupAction) }
.map { $0.id }
settingsStore.set(.actionOrder, value: newOrder)
}Custom Action Groups & Lifecycle#
Custom action groups allow users to bundle multiple actions under a single expandable popup row.
Core Domain Models#
ActionGroupDef(Sources/Core/Actions/ActionGroupDef.swift): Pure Codable model representing the persistent definition (id,title,iconName,memberActionIDs), stored as JSON inSettingKey.actionGroups.CustomGroupAction(Sources/Core/Actions/CustomGroupAction.swift): Pure domain action conforming toActionandSubActionProviding. It definespopupBehavior = .showSubActionsand dynamically resolves its sub-actions from the catalog by matching canonical IDs inmemberActionIDs.
Group Membership Rules#
A custom group is a folder, not a fixed-size set, so the enforced rule is not a hard minimum of two members. What is enforced is that a group emptied by a mutation is dissolved, while a group that is empty or single-member by intent is kept:
createGroupaccepts zero or more eligible member IDs. A group created empty is kept on purpose — it is a folder the user intends to fill later. Duplicate/blank/ineligible IDs are filtered, not rejected.updateGroupstores exactly what the editor saves, including an empty or single-member group.- A group emptied by a mutation —
removeFromGroup,addToGroupmoving its last member into another group, ordeleteCustomActionremoving its last member — is dissolved.saveAndApplyGroupDefs(pruningEmptiedFrom:)distinguishes a group that had members before the mutation from one that was already empty, so only the former disappears. - Member IDs that no longer resolve to a registered action are filtered when the group is
materialized (
CustomGroupAction.subActions(in:)) and on mutating saves, but an existing member that is merely unregistered this session is retained, so the group survives an extension reload and heals when the action returns.loadGroupDefsdeliberately does not rewrite the saved configuration on boot, and there is nopruneOrphanspass.
Pinned by ActionCoordinatorGroupTests (testGroupMembershipContractIsFolderRulesNotAMinimumOfTwo,
testAGroupWithNoMembersIsKept, testCreateGroupWithSingleMember,
testUpdateGroupKeepsGroupWithFewerThanTwoMembers, testRemovingTheLastMemberRemovesTheGroup,
testUnregisterExtensionRetainsGroupMembershipInDefs).
Action Availability & Context Resolution#
When selected text is detected, ActionCoordinator.resolveActions(for:) converts the raw SelectionContext into an evaluated context using RuleEngine.resolvePolicies(for:).
Filtering Pipeline#
ActionRegistry.availableActions(for:) applies multiple policy filters before returning actions to the UI:
- Disabled Actions Check:
- Queries
SettingKey.disabledActionIDsfromSettingsStore.
- Disabled Package Check:
- Queries
SettingKey.disabledPackagesfromSettingsStore. Any action whoseaction.chrome.sourceis.extensionPkg(packageID:)with a disabled packageID is filtered out (whole-package disable).
- Action Capability Check:
- Evaluates
canPerform(action, in: context). Script actions check for non-empty text, URL templates evaluate optional regex pattern matches, and clipboard-fallback selections require live selection capabilities.
- Group Member Visibility Check:
- Extension Groups: Sub-actions whose IDs match an extension group prefix (
<groupID>.<subID>) are hidden if the parent group is disabled or filtered out. - Custom Groups: Member actions mapped via
customGroupMemberToGroupIDare hidden if their parentCustomGroupActionis disabled or filtered out.
public func availableActions(for context: ActionContext) -> [any Action] {
let disabledIDs = settingsStore.get(.disabledActionIDs)
let disabledPackages = settingsStore.get(.disabledPackages)
func passes(_ action: any Action) -> Bool {
if ActionIdentity.isAIPreset(action) { return false }
if action is GatedExtensionAction { return false }
guard canPerform(action, in: context) else { return false }
if disabledIDs.contains(action.id) { return false }
if let packageID = ActionIdentity.extensionPackageID(of: action), disabledPackages.contains(packageID) {
return false
}
return true
}
// Group sub-actions are only reachable through their group's sub-menu. A group whose
// row is disabled (or otherwise not visible) hides its sub-actions entirely, so a
// disabled group never leaks its sub-actions into the bar.
let groupRowIDs = actions
.filter { $0.chrome.popupBehavior == .showSubActions }
.map { $0.id }
let enabledGroupIDs = Set(
actions
.filter { $0.chrome.popupBehavior == .showSubActions }
.filter { passes($0) }
.map { $0.id }
)
// Custom groups: hide members of a disabled custom group.
// This parallels the prefix-based hiding above but uses the explicit
// memberActionIDs list since custom group members keep canonical IDs.
let customGroupMemberToGroupID: [String: String] = {
var map: [String: String] = [:]
for def in groupDefs {
for memberID in def.memberActionIDs {
map[memberID] = def.id
}
}
return map
}()
return actions.filter { action in
guard passes(action) else { return false }
if let groupID = groupRowIDs.first(where: { action.id.hasPrefix($0 + ".") }),
!enabledGroupIDs.contains(groupID) {
return false
}
if let owningGroupID = customGroupMemberToGroupID[action.id],
!enabledGroupIDs.contains(owningGroupID) {
return false
}
return true
}
}