Retire the compatibility machinery, behind scheduled migrations (#198)

Phase 35 of docs/refactor/. The legacy KeyboardShortcuts_ key namespace
and HotKeyBinding's hand-written Codable seam are gone: bindings persist
under hotkey.<action> with the synthesised conformance, and
HotKeyAction.defaultsKey stays the one place a key is computed.

POLICY.md assumed there are no existing users. v0.7.5 is a shipped stable
release, and both of the phase's deletions would have destroyed real user
data on update: every hotkey silently unbound, and the entire clipboard
history discarded, since v0.7.5 predates pinned_at and the store deletes
a database it cannot open. Each cleanup therefore ships behind a one-time
migration instead of a plain deletion.

- LegacyHotKeyRecords adopts the shipped records once, reading both old
  shapes and never overwriting a key the user has already rebound.
- ClipboardStore's two ALTER TABLE guards stay exactly as they are.

Both are recorded in POLICY.md as scheduled deletions, to come out two
stable releases after this one ships. Neither persists a flag, so each
removal is a pure deletion. Net line count is positive as a result, which
the phase document forbids; that is the deliberate cost of the migrations.

AGENTS.md's refactor banner is removed and its hotkey clause amended.
Raycast import and the snippet Markdown serializer are untouched: another
application's format, not our legacy.
This commit is contained in:
Abue Ammar
2026-08-07 03:44:41 +06:00
committed by GitHub
parent 946c5907b4
commit c1bcbb84da
11 changed files with 129 additions and 86 deletions
+9 -24
View File
@@ -1,21 +1,3 @@
> ## ⚠ An approved architecture refactor is in progress
>
> Some **structural** rules in this file — file layout, type ownership, which type does what — are being
> changed by it. That is deliberate, and a phase contradicting one of them is **not** an error.
>
> **If you are executing a phase from [`docs/refactor/`](docs/refactor/), the phase document overrides
> the architectural guidance here.** Read
> [`docs/refactor/prompts/system-prompt.md`](docs/refactor/prompts/system-prompt.md) for the full
> precedence ladder, and [`docs/refactor/POLICY.md`](docs/refactor/POLICY.md) for the migration and
> compatibility rules.
>
> **Behavioral invariants below always hold** — UI, keyboard behaviour, accessibility, permission and
> consent flows, Swift 6 data-race safety, and the explicitly off-limits files. A phase that contradicts
> one of *those* is wrong: stop and say so.
>
> **If you are not working from a `docs/refactor/` phase**, ignore this box entirely and follow this
> file as written.
## Project
Tinycast is a native macOS menu-bar launcher (a minimal Raycast): fuzzy app launcher, global +
@@ -220,13 +202,16 @@ Never break these without an explicit task to do so.
block observers, `isolated deinit` for `ClipboardStore`'s SQLite teardown, decode raw Carbon / C
pointers to plain values before crossing into actor code.
- **Clipboard writes stamp a private `internalType` marker** so the poller skips Tinycast's own writes.
- **Hotkeys persist under legacy `KeyboardShortcuts_<name>` UserDefaults keys** (from the removed
KeyboardShortcuts package) so old bindings survive. `HotKeyBinding` is the one thing an action is
- **Hotkeys persist as JSON strings under `hotkey.<action>` UserDefaults keys**, and
`HotKeyAction.defaultsKey` is the one place that computes a key — it is also the `HotKeyCenter`
registration id, so the two can never drift. `HotKeyBinding` is the one thing an action is
bound to and it has two cases with two engines: a `.combo` is a Carbon registration, a `.doubleTap`
is recognized by `DoubleTapMonitor` (Carbon cannot see a lone modifier at all). Its `Codable`
conformance is the compatibility seam — a `.combo` must keep encoding as the bare
`{"carbonKeyCode":N,"carbonModifiers":N}` record and decoding must keep trying that shape first, or
every existing binding and backup breaks. `HotKeys/Model/DoubleTapModifier.swift` and
is recognized by `DoubleTapMonitor` (Carbon cannot see a lone modifier at all). Its `Codable` is the
synthesised one; `KeyShortcut`'s hand-written `init(from:)` is not a format seam but a correctness
one, routing every decode through the initializer that masks device modifier bits off.
`LegacyHotKeyRecords` adopts the shipped `KeyboardShortcuts_` records once and is **scheduled for
deletion** — see [POLICY.md](docs/refactor/POLICY.md); nothing new may depend on it.
`HotKeys/Model/DoubleTapModifier.swift` and
`DoubleTapDetector.swift` stay Foundation-only and pure with the clock injected as a parameter, for
`Tools/hotkey-test.swift`; every `CGEvent` call lives in `DoubleTapMonitor.swift`, which is
listen-only, installs *only* while something is bound to a double-tap, and never prompts for
+4
View File
@@ -181,6 +181,7 @@
E4B18EBFFD009B2CAFF4BA9B /* PanelTransition.swift in Sources */ = {isa = PBXBuildFile; fileRef = 73E25B87C692D5FEE1FBF994 /* PanelTransition.swift */; };
E58AB83C608F8BBF99EAEB7E /* QuicklinkListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 856F65245B10433A230194E6 /* QuicklinkListView.swift */; };
E63AA3E7A400CB81FBFC3214 /* RaycastFormat.swift in Sources */ = {isa = PBXBuildFile; fileRef = A09A0608AC763EBC26216118 /* RaycastFormat.swift */; };
E7DCBF503DDEF223C6BE71E5 /* LegacyHotKeyRecords.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5F375AF988B0DA844E2473DA /* LegacyHotKeyRecords.swift */; };
E8CB7624CCA5905AF1A4FAD9 /* AppCore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2BECF24B674F9458E4A876DA /* AppCore.swift */; };
EA2587800F2CA347788C6F8C /* UninstallScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0508EBDFD6E59370AB2E5B7 /* UninstallScreen.swift */; };
EA89498DA03BDFA3858144BF /* AppActionsMenu.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6C045C66BB654E4CB407A673 /* AppActionsMenu.swift */; };
@@ -276,6 +277,7 @@
5D3044106BC42824C79E1803 /* QuicklinkArgumentsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QuicklinkArgumentsView.swift; sourceTree = "<group>"; };
5DADE9F769787D44F72A2071 /* ShortcutRecorderPopover.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShortcutRecorderPopover.swift; sourceTree = "<group>"; };
5F0A68B2040582996EFBA877 /* CommandCatalog.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommandCatalog.swift; sourceTree = "<group>"; };
5F375AF988B0DA844E2473DA /* LegacyHotKeyRecords.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LegacyHotKeyRecords.swift; sourceTree = "<group>"; };
60CCB1A47E0022BAEDD1EE96 /* CalculatorHistoryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CalculatorHistoryView.swift; sourceTree = "<group>"; };
61FFFE3901140714D3A7BB4C /* QuicklinkDestination.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QuicklinkDestination.swift; sourceTree = "<group>"; };
622E1F1C01B954DCA2199BCD /* ScrollIntent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScrollIntent.swift; sourceTree = "<group>"; };
@@ -887,6 +889,7 @@
F0311153A4A3557C6FFEAAAB /* HotKeyManager.swift */,
1868F87E31E20A4775C84E6A /* HyperKeyTap.swift */,
3E16AEF81320A101E384098E /* KeyShortcut.swift */,
5F375AF988B0DA844E2473DA /* LegacyHotKeyRecords.swift */,
B235DFF451FF4D52A8E44D00 /* ShortcutCaptureSession.swift */,
);
path = Service;
@@ -1313,6 +1316,7 @@
D1F9C28CA4048480DB818EE9 /* LauncherList.swift in Sources */,
633DCA2F00A274C375EB4423 /* LauncherRankingStore.swift in Sources */,
7FF6A0A4702E1C1365F2D76A /* LauncherScreen.swift in Sources */,
E7DCBF503DDEF223C6BE71E5 /* LegacyHotKeyRecords.swift in Sources */,
AAFD2C9276D38719E85B675F /* Memo.swift in Sources */,
43DBCE7B5A84D0D6322124F5 /* MessageHUDController.swift in Sources */,
8D529C8F3AB579941067FE98 /* MessageHUDView.swift in Sources */,
@@ -1,7 +1,7 @@
import Foundation
/// What an action is bound to: two kinds, two engines. See docs/hotkeys.md.
enum HotKeyBinding: Hashable, Sendable {
enum HotKeyBinding: Hashable, Sendable, Codable {
case combo(KeyShortcut)
case doubleTap(DoubleTapModifier)
@@ -23,29 +23,3 @@ enum HotKeyBinding: Hashable, Sendable {
return nil
}
}
// The compatibility seam for both shapes. See docs/hotkeys.md#persistence.
extension HotKeyBinding: Codable {
private enum CodingKeys: String, CodingKey {
case doubleTapModifier
}
init(from decoder: Decoder) throws {
if let shortcut = try? KeyShortcut(from: decoder) {
self = .combo(shortcut)
return
}
let container = try decoder.container(keyedBy: CodingKeys.self)
self = .doubleTap(try container.decode(DoubleTapModifier.self, forKey: .doubleTapModifier))
}
func encode(to encoder: Encoder) throws {
switch self {
case .combo(let shortcut):
try shortcut.encode(to: encoder)
case .doubleTap(let modifier):
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(modifier, forKey: .doubleTapModifier)
}
}
}
@@ -47,6 +47,7 @@ final class HotKeyManager {
private let boundQuicklinkKey = "boundQuicklinkIDs"
func start(customCommandIDs: Set<UUID>, quicklinkIDs: Set<UUID>) {
LegacyHotKeyRecords.adopt(candidateActions, decoder: decoder, encoder: encoder)
prune(key: boundCustomCommandKey, live: customCommandIDs) { .customCommand(id: $0) }
prune(key: boundQuicklinkKey, live: quicklinkIDs) { .quicklink(id: $0) }
// After the prunes, so a dropped record can't survive in memory this session.
@@ -170,20 +170,18 @@ enum HotKeyAction: Hashable, Sendable {
case windowCommand(id: WindowCommand.ID)
case quicklink(id: UUID)
/// The defaults key; the `KeyboardShortcuts_` prefix is a fossil, kept verbatim.
/// The UserDefaults key, and the `HotKeyCenter` registration id: one per action.
var defaultsKey: String {
switch self {
case .togglePalette: "KeyboardShortcuts_togglePalette"
case .toggleClipboard: "KeyboardShortcuts_toggleClipboard"
case .toggleEmoji: "KeyboardShortcuts_toggleEmoji"
case .app(let bundleID): "KeyboardShortcuts_appHotkey." + bundleID
case .settingsPane(let bundleID): "KeyboardShortcuts_paneHotkey." + bundleID
case .customCommand(let id):
"KeyboardShortcuts_customCommandHotkey." + id.uuidString.lowercased()
case .systemAction(let id): "KeyboardShortcuts_systemActionHotkey." + id.rawValue
case .windowCommand(let id): "KeyboardShortcuts_windowCommandHotkey." + id.rawValue
case .quicklink(let id):
"KeyboardShortcuts_quicklinkHotkey." + id.uuidString.lowercased()
case .togglePalette: "hotkey.togglePalette"
case .toggleClipboard: "hotkey.toggleClipboard"
case .toggleEmoji: "hotkey.toggleEmoji"
case .app(let bundleID): "hotkey.app." + bundleID
case .settingsPane(let bundleID): "hotkey.pane." + bundleID
case .customCommand(let id): "hotkey.customCommand." + id.uuidString.lowercased()
case .systemAction(let id): "hotkey.systemAction." + id.rawValue
case .windowCommand(let id): "hotkey.windowCommand." + id.rawValue
case .quicklink(let id): "hotkey.quicklink." + id.uuidString.lowercased()
}
}
}
@@ -0,0 +1,60 @@
import Foundation
/// Temporary: adopts the pre-`hotkey.<action>` records. Delete per docs/refactor/POLICY.md.
enum LegacyHotKeyRecords {
private struct Combo: Decodable {
let carbonKeyCode: Int
let carbonModifiers: Int
}
private struct DoubleTap: Decodable {
let doubleTapModifier: DoubleTapModifier
}
/// Consumes each old record, so the second launch finds nothing and this becomes a no-op.
static func adopt(_ actions: [HotKeyAction], decoder: JSONDecoder, encoder: JSONEncoder) {
let defaults = UserDefaults.standard
for action in actions {
let legacy = legacyKey(for: action)
guard let json = defaults.string(forKey: legacy), let data = json.data(using: .utf8)
else { continue }
defaults.removeObject(forKey: legacy)
// Never clobber a binding already set under the new key: that one is the user's intent.
guard defaults.string(forKey: action.defaultsKey) == nil,
let binding = binding(from: data, decoder),
let encoded = try? encoder.encode(binding),
let rewritten = String(data: encoded, encoding: .utf8)
else { continue }
defaults.set(rewritten, forKey: action.defaultsKey)
}
}
/// The two shapes `HotKeyBinding` used to write, flat combo first as that is what 0.7.5 stored.
private static func binding(from data: Data, _ decoder: JSONDecoder) -> HotKeyBinding? {
if let combo = try? decoder.decode(Combo.self, from: data) {
return .combo(
KeyShortcut(
carbonKeyCode: combo.carbonKeyCode, carbonModifiers: combo.carbonModifiers))
}
if let tap = try? decoder.decode(DoubleTap.self, from: data) {
return .doubleTap(tap.doubleTapModifier)
}
return nil
}
private static func legacyKey(for action: HotKeyAction) -> String {
switch action {
case .togglePalette: "KeyboardShortcuts_togglePalette"
case .toggleClipboard: "KeyboardShortcuts_toggleClipboard"
case .toggleEmoji: "KeyboardShortcuts_toggleEmoji"
case .app(let bundleID): "KeyboardShortcuts_appHotkey." + bundleID
case .settingsPane(let bundleID): "KeyboardShortcuts_paneHotkey." + bundleID
case .customCommand(let id):
"KeyboardShortcuts_customCommandHotkey." + id.uuidString.lowercased()
case .systemAction(let id): "KeyboardShortcuts_systemActionHotkey." + id.rawValue
case .windowCommand(let id): "KeyboardShortcuts_windowCommandHotkey." + id.rawValue
case .quicklink(let id):
"KeyboardShortcuts_quicklinkHotkey." + id.uuidString.lowercased()
}
}
}
+1 -1
View File
@@ -15,7 +15,7 @@ without re-registering. "Show in launcher" only hides the section; shortcuts kee
`CustomCommandStore` is owned by `AppCore` and persists the ordered command array as JSON in
bundle-scoped `UserDefaults`. Each command has a stable UUID. Its launcher entry id is
`custom-command:<uuid>`, and its hotkey uses
`KeyboardShortcuts_customCommandHotkey.<uuid>` plus the `boundCustomCommandIDs` index.
`hotkey.customCommand.<uuid>` plus the `boundCustomCommandIDs` index.
Editing preserves the UUID and therefore its favorite, visibility, and hotkey references. Deleting
goes through `AppCore`, which unregisters the hotkey and clears those references before removing the
+14 -9
View File
@@ -14,8 +14,8 @@ the keycap rendering — only the _engine_ differs.
## Persistence
Bindings persist as JSON strings under `KeyboardShortcuts_<name>` UserDefaults keys — a **legacy
format** from the removed KeyboardShortcuts package, kept so old bindings survive. The set of bound
Bindings persist as JSON strings under `hotkey.<action>` UserDefaults keys, computed in one place —
`HotKeyAction.defaultsKey`, which doubles as the `HotKeyCenter` registration id. The set of bound
bundle IDs lives in `boundAppBundleIDs` and is re-registered on launch. System Settings panes use
`boundPaneBundleIDs`; custom commands and quicklinks use their stable UUIDs in
`boundCustomCommandIDs` and `boundQuicklinkIDs`. Those two are the per-item case — unlike a fixed
@@ -24,15 +24,20 @@ and to prune bindings whose record was deleted while Tinycast wasn't running. Th
`QuicklinkStore` loads at launch even when the feature is off
(see [quicklinks.md](quicklinks.md#hotkeys)).
A `.combo` writes the original `{"carbonKeyCode":N,"carbonModifiers":N}` record and
`HotKeyBinding.init(from:)` tries that shape first, so nothing needs migrating. A `.doubleTap` writes
`{"doubleTapModifier":"command"}` — a shape a pre-double-tap build fails to decode and therefore reads
as _unbound_, which is the intended degradation. The same wrapper is what `SettingsBackup.HotkeyBackup`
stores, so old backup files import unchanged; a backup containing a double-tap cannot be read by an
older build, which is what the `version` 3 bump records (`version` 4 adds the quicklinks array).
`HotKeyBinding` takes the synthesised `Codable`, so a `.combo` writes
`{"combo":{"_0":{"carbonKeyCode":N,"carbonModifiers":N}}}` and a `.doubleTap` writes
`{"doubleTap":{"_0":"command"}}`. `KeyShortcut` keeps a hand-written `init(from:)` — not a format seam,
but the guarantee that every decode runs through the initializer that masks device modifier bits off.
`SettingsBackup.HotkeyBackup` stores the same values, so the backup file carries this shape too; only
export → import within one build is guaranteed to round-trip.
`LegacyHotKeyRecords` adopts the records shipped before this namespace existed — `v0.7.5` wrote a bare
`{"carbonKeyCode":N,"carbonModifiers":N}` under `KeyboardShortcuts_<name>`. It runs once from
`start()`, consumes each old record, and never overwrites a key the user has already rebound. It is
scheduled for deletion; see [refactor/POLICY.md](refactor/POLICY.md).
System actions and window commands are the fixed-catalog case: they persist under
`KeyboardShortcuts_systemActionHotkey.<raw-id>` and `KeyboardShortcuts_windowCommandHotkey.<raw-id>`
`hotkey.systemAction.<raw-id>` and `hotkey.windowCommand.<raw-id>`
and need **no** bound-ID index, because `start()` and `conflictOwner` can just iterate `allCases` and
`register` no-ops on an unbound item. A registered window-command shortcut still runs nothing while the
feature switch is off — `AppCore.runWindowCommand` re-checks it (see
+1 -1
View File
@@ -145,7 +145,7 @@ Duplicating takes a **new** identity, so the copy can't inherit the original's s
## Hotkeys
`HotKeyAction.quicklink(id:)` persists under `KeyboardShortcuts_quicklinkHotkey.<uuid>` with a
`HotKeyAction.quicklink(id:)` persists under `hotkey.quicklink.<uuid>` with a
`boundQuicklinkIDs` index, the same shape custom commands use — both are per-item rather than
per-catalog-entry, so both need an index for `start()` to re-register from. The store therefore loads
**even while the feature is off** and before `hotKeys.start`: the stale-binding prune reads that
+27 -11
View File
@@ -147,29 +147,45 @@ settings take their intended defaults, nothing crashes on an absent key or an ab
## Conflicts with `AGENTS.md`
`AGENTS.md` is loaded into every Claude Code session **before** anything the operator pastes, and it
describes the codebase as it is today. Conflicts with this policy are therefore guaranteed, not
accidental. Three are known and listed here so they are recorded rather than discovered mid-phase:
describes the codebase as it is today. Conflicts with this policy were therefore guaranteed, not
accidental. Three were known, and phase 35 has closed all three:
1. > _"Hotkeys persist under legacy `KeyboardShortcuts_<name>` UserDefaults keys (from the removed
> KeyboardShortcuts package) so old bindings survive."_
**Superseded.** Old bindings need not survive.
**Closed by phase 35.** The namespace is now `hotkey.<action>`. Old bindings do survive, but via a
one-time adoption scheduled for deletion — not by keeping the namespace. See below.
2. > _"Its `Codable` conformance is the compatibility seam — a `.combo` must keep encoding as the bare
> `{"carbonKeyCode":N,"carbonModifiers":N}` record … or every existing binding and backup breaks."_
**Superseded.** See phase 35.
**Closed by phase 35.** `HotKeyBinding` uses the synthesised conformance; the backup format changed
with it, and only export → import within one build is required.
3. > _"`SettingsBackup` … Every field is optional so an import applies only the keys actually present."_
**Retained** — but for a different reason. Optionality supports **partial** files and Raycast
imports, not old Tinycast versions.
`AGENTS.md` now carries a banner at the top pointing here and stating that structural rules are being
changed by the refactor — so the correction arrives in the first thing an agent reads, rather than
depending on the operator pasting the standing prompt.
The refactor banner that stood at the top of `AGENTS.md` is gone: phase 35 removed it, because the
refactor ends there and a banner announcing one in progress would be false.
The three clauses above are still **superseded but not yet deleted**. Phase 35 deletes them, amends the
surrounding text, and **removes the banner** — the refactor is over at that point and the banner would
become a lie. Until then, this document wins and a phase that trips over one of them should proceed and
note it.
## The two scheduled deletions
Phase 35 found the policy's core assumption — "there are no existing users" — to be **false**:
`v0.7.5` is a shipped stable release, and both objectives would have destroyed real user data on
update. By operator decision the cleanups shipped anyway, each behind a one-time migration, and each
migration is scheduled for removal rather than kept.
| What | Migration | Why it was needed |
| ---- | --------- | ----------------- |
| `hotkey.<action>` keys and the synthesised `HotKeyBinding.Codable` | `LegacyHotKeyRecords.adopt`, called once from `HotKeyManager.start()` | `v0.7.5` stores a bare `{"carbonKeyCode":N,"carbonModifiers":N}` under `KeyboardShortcuts_<name>`; both the key and the shape changed, so every shipped binding would have read as unbound |
| `ClipboardStore`'s `source_app` / `pinned_at` columns | the two existing `ALTER TABLE` guards and `columnExists` | `v0.7.5` has no `pinned_at`, so the prepared statements would fail, and the store **deletes a database it cannot open** — the whole clipboard history, silently |
**Delete both** once the release carrying them has been superseded by two further stable releases, so
no supported upgrade path still starts from `v0.7.5`. Removal is a pure deletion in each case: delete
`LegacyHotKeyRecords.swift` plus its one call site, and delete the two guards plus `columnExists`.
Neither carries a version flag or persisted state, so nothing is left behind.
Until then, `grep -rn "ALTER TABLE\|columnExists" Tinycast` returning three hits is **expected**, and
phase 35's acceptance criterion 4 is knowingly unmet.
+1 -1
View File
@@ -163,7 +163,7 @@ stock Electron app tiles correctly without it, delete the helper rather than kee
`LauncherView.rows` mirrors that position with a "Window Management" section; the slice order is the
flat-selection invariant, so the two must move together.
- **`HotKeyAction.windowCommand(id:)`** — persisted under
`KeyboardShortcuts_windowCommandHotkey.<raw-id>`, matching the legacy prefix convention. Unlike
`hotkey.windowCommand.<raw-id>`, matching the shared `HotKeyAction.defaultsKey` convention. Unlike
custom commands there is no bound-ID index to maintain: the catalog is fixed, so `HotKeyManager.start`
and `conflictOwner` iterate `WindowCommand.ID.allCases` and `register` no-ops on an unbound command.
- **`WindowCommandCoordinator.runWindowCommand(id:)`** is the one funnel for both palette activation and the global