Repository navigation
refactor(brand): rename McBopomofo fork to Bopomix (混打注音) and import McBopomofo user data - #4
Merged
Merged
Conversation
Renames the user-facing and runtime identity of this McBopomofo fork from McBopomofo / 小麥注音 to Bopomix / 混打注音, and moves the bundle identifier to io.github.lmanchu.bopomix. Covered: Xcode project/targets/schemes/product names, the test module, the Installer, Info.plist input-mode ids and connection/controller names, all Localizable/ServicesMenu/InfoPlist strings, menu XIBs, Credits.rtf links, the update feed (which pointed at openvanilla's and would have offered users an "upgrade" back to McBopomofo), CI workflows, Source/Data's _install target, and AGENTS.md. Upstream engine internals keep their names on purpose, to hold the rebase surface down: the McBopomofo C++ namespace, McBopomofoLM and its CMake targets, McBopomofoEmacsKey, the "org.openvanilla.mcbopomofo.sorted" data pragma, and every "The McBopomofo Authors" copyright header. LICENSE.txt keeps the original copyright and adds the fork's own line. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> 🤖 Generated with [Claude Code](https://claude.ai/code) via [Happy](https://happy.engineering) Co-Authored-By: Claude <noreply@anthropic.com> Co-Authored-By: Happy <yesreply@happy.engineering>
Renames the eval harness CLI target and its source file from mixime-eval / mixime_eval.cpp to bopomix-eval / bopomix_eval.cpp, and points the harness docs at the fork's own build tree instead of a sibling ~/Dev/McBopomofo checkout. check_plist_unchanged.sh now guards io.github.lmanchu.bopomix and ~/Library/Application Support/Bopomix. Both lines had to move together: leaving the domain on the old identifier would have had the script diffing a domain the suite no longer writes, and reporting PLIST_UNCHANGED no matter what the tests did. ~/Dev/mixime-private/ is left alone -- it is an out-of-repo corpus directory, not a build product. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> 🤖 Generated with [Claude Code](https://claude.ai/code) via [Happy](https://happy.engineering) Co-Authored-By: Claude <noreply@anthropic.com> Co-Authored-By: Happy <yesreply@happy.engineering>
Changing the bundle identifier makes macOS treat this as a brand new input method, so an in-place upgrade would have read as "my dictionary and all my settings are gone": the preferences live in a domain keyed off the identifier, and the user phrases live in ~/Library/Application Support/McBopomofo. On the first launch that finds no marker key, LegacyMigration copies both across. Non-destructive in both directions -- the legacy domain and folder are only read, since the original McBopomofo may still be installed and typing, and nothing already present under the new identity is overwritten (preferences fill in only missing keys, the folder copy is skipped once the new folder exists). Gated on !Preferences.isRunningUnderXCTest in main.swift, alongside populateDefaults(), for the same reason: the app bundle is also the test host, so this runs before PreferenceSandbox can snapshot. The tests cover the pure key-selection function and the folder copy against a temporary directory. They touch neither UserDefaults.standard nor anything under the real ~/Library. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> 🤖 Generated with [Claude Code](https://claude.ai/code) via [Happy](https://happy.engineering) Co-Authored-By: Claude <noreply@anthropic.com> Co-Authored-By: Happy <yesreply@happy.engineering>
…symlink-safe Review found the migration would hand Bopomix two things it must not inherit from McBopomofo. UseCustomUserPhraseLocation / CustomUserPhraseLocation: inheriting them points both input methods at one user-phrase folder, and they do not write it the same way -- McBopomofo's _removePhrase rewrites the file whole while Bopomix appends, so a user running both loses phrases to interleaving. The folder is copied instead. legacyUserDataFolder() reads those same two keys the way LanguageModelManager +dataFolderPath does (flag, then raw string, no tilde expansion) so someone who had pointed McBopomofo at Dropbox still gets their own words rather than an empty dictionary -- as a snapshot, with the sync folder re-pointed in Preferences later if they want it. AddPhraseHookPath (points into McBopomofo.app, a bundle that can be uninstalled; populateDefaults fills in the right one) and NextUpdateCheckDate (machine state, not a preference) round out keysNeverMigrated, alongside the marker key. copyUserData is now symlink-safe, since the legacy folder is exactly where users point sync setups: the source is resolved before it is read, anything at the destination -- including a dangling symlink -- means hands off, and the copy walks top-level regular files so the new folder is always real storage rather than a second name for the old. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> 🤖 Generated with [Claude Code](https://claude.ai/code) via [Happy](https://happy.engineering) Co-Authored-By: Claude <noreply@anthropic.com> Co-Authored-By: Happy <yesreply@happy.engineering>
…main A test run on a machine that had never launched Bopomix left five keys behind in the new domain. That is not just untidy: LegacyMigration skips any key the new domain already has a value for, so those five would never migrate from McBopomofo -- and every contributor's first run, and CI, would produce them. Three sources, three fixes. AppDelegate wrote four of them at launch, and the app bundle is also the XCTest host, so they landed before PreferenceSandbox could snapshot: the CheckUpdateAutomatically / BeepUponInputError backfills, the font check's one-shot flag, and the automatic update check's NextUpdateCheckDate. All four are now behind Preferences.isRunningUnderXCTest, matching main.swift's existing populateDefaults() gate. A forced check is untouched, so VersionUpdateTests still exercises VersionUpdateApi.check(forced:). PreferenceSandbox's no-domain-at-process-start branch deliberately did nothing, on the reasoning that wiping real settings is worse than leaving strays. With the launch-time writes gated, every key in a domain that did not exist at process start is one a test wrote, so the branch now removes the domain -- verified: persistentDomain(forName:) returns nil for an absent domain, so this restores absence as absence. The last key, ChineseConversionEnabled, came from AssociatedPhrasesTests restoring Preferences.chineseConversionEnabled in deinit -- the typed property reports false for a missing key, so writing it back created one. It is a swift-testing suite with no teardown block, and it runs unserialized against XCTest suites that set the same key, so snapshotting the raw value in its own initializer was not enough either: the reading depended on runner order, and the leak reproduced about half the time. PreferenceSandbox now exposes its shared once-only snapshot (captureNow / restore(key:)), taken by whichever suite starts first, before any of them writes. Also records in check_plist_unchanged.sh why the domain needs no ABSENT sentinel of its own: defaults export writes the same 42-byte empty bplist for a missing domain as for an empty one, verified byte-for-byte, so both diff clean. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> 🤖 Generated with [Claude Code](https://claude.ai/code) via [Happy](https://happy.engineering) Co-Authored-By: Claude <noreply@anthropic.com> Co-Authored-By: Happy <yesreply@happy.engineering>
…opyright The installer's licence pane still read "McBopomofo License Agreement". Retitles it and adds the fork's own copyright line under the original, which stays exactly as it was. Byte-level edit: the zh-Hant file carries Big5 \'xx escapes that a text round-trip would destroy, so only the ASCII runs were touched. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> 🤖 Generated with [Claude Code](https://claude.ai/code) via [Happy](https://happy.engineering) Co-Authored-By: Claude <noreply@anthropic.com> Co-Authored-By: Happy <yesreply@happy.engineering>
…not happen The marker was written unconditionally, so any failure of the folder half was permanent: next launch saw the marker and returned. Worse, a failed attempt left an empty destination directory behind, which every later attempt then read as "the user already has data here" -- so even clearing the marker by hand could not recover it. Four routes reached "the user's dictionary silently never arrives, and never can": an unmounted custom location, a stray Bopomix/ from an earlier dev build, a transient EPERM, and a mid-loop copy failure. Nothing of the user's was ever destroyed -- the legacy folder is only read -- but the import was lost for good. Split into two markers, because the halves fail independently: the preference merge cannot really fail, the folder copy depends on a volume being mounted and a directory being readable. LegacyMcBopomofoPrefsMigrated is set when the preferences are written; LegacyMcBopomofoUserDataMigrated only when the folder copy reports .copied or .notNeeded. copyUserData now returns that outcome instead of a Bool, refuses a destination that is a symlink or a non-directory, treats an existing *empty* directory as absent (that is what a failed attempt leaves), and on failure removes the directory only if this call created it. legacyUserDataFolder returns .customLocationUnavailable when the flag is on and the path is non-empty but not currently a directory, so an unmounted Dropbox waits for the next launch instead of quietly copying the stale default folder -- and its doc no longer claims to read the two keys exactly as +dataFolderPath does, since that returns the string whether or not anything is there. AddPhraseHookPath is now filtered on its value rather than blocked outright: only a path inside McBopomofo.app is dropped. A user who pointed the hook at their own script was having it silently replaced by this app's bundled one while AddPhraseHookEnabled migrated and kept the hook running. All of it now goes through run(), a pure function returning what to write, so the marker rules are tested against a temp directory rather than the live domain. migrateIfNeeded holds no logic of its own. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> 🤖 Generated with [Claude Code](https://claude.ai/code) via [Happy](https://happy.engineering) Co-Authored-By: Claude <noreply@anthropic.com> Co-Authored-By: Happy <yesreply@happy.engineering>
Four small things review found around the sandbox added last commit. PreferencesTests never called into PreferenceSandbox, yet it is the one suite the sandbox's own doc singles out as dangerous: its init() removeObject()s all 27 Preferences.allKeys. The shared snapshot is taken by whichever suite starts first, so if the runner ever begins with the swift-testing phase on a machine whose domain is populated, the snapshot would record the post-wipe state and the XCTest teardown would write that back -- destroying real settings. Latent today (Xcode runs XCTest first) and harmless while the domain is empty, but the invariant should hold by construction, so it now captures before it wipes. processStartSnapshot was static mutable state read and written from swift-testing tests, which run in parallel with a fresh suite instance each; it is now behind an NSLock, AssociatedPhrasesTests is .serialized, and install(on:)'s teardown reads through the same accessor rather than touching the variable directly. checkForUpdate(forced:) had its XCTest gate inside the `if !forced` branch while the NextUpdateCheckDate write sits below both branches, so a forced check under XCTest would still have written it. No test reaches that today -- VersionUpdateTests calls VersionUpdateApi.check directly -- but a future test of the menu action would, and as swift-testing it would have no teardown block to clean up after itself. The gate now covers the whole function. Also records in AGENTS.md that changing preferences in an installed Bopomix while the suite runs will be rolled back, since the sandbox restores the whole domain to its start-of-suite snapshot. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> 🤖 Generated with [Claude Code](https://claude.ai/code) via [Happy](https://happy.engineering) Co-Authored-By: Claude <noreply@anthropic.com> Co-Authored-By: Happy <yesreply@happy.engineering>
… line The zh-Hant licence pairs each English line with its Chinese translation. The new attribution line landed between the Mengjuei pair, in English only. Moved below the pair and given a Chinese counterpart, so the four lines read English/Chinese, English/Chinese. Byte-level edit again, with the Chinese encoded as the file's existing Big5 \'xx escapes. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> 🤖 Generated with [Claude Code](https://claude.ai/code) via [Happy](https://happy.engineering) Co-Authored-By: Claude <noreply@anthropic.com> Co-Authored-By: Happy <yesreply@happy.engineering>
…r nothing "Destination folder is non-empty" was the wrong test for "the user already has data here", because the app fills that folder with placeholders on its own. The first time anything touches the dictionary -- adding a phrase, opening a user file, typing English in mixed mode -- LanguageModelManager +checkIfUserLanguageModelFilesExist creates the folder and writes five templates into it. So the sequence "first launch with an unmounted custom location (correctly retryable), user types for a while, second launch" found a non-empty folder, reported .notNeeded, and burned the marker on a dictionary that never arrived. copyUserData now works per file. A destination file that is missing takes the legacy copy; one that is still an untouched template is replaced; one with the user's own content is kept and logged. All ten shipped template-*.txt were checked: every one is comments only in every localization, and ensureFileExists writes a zero-byte file when the resource is missing, so "empty, or nothing but # lines" identifies them without this file needing to know their contents -- which also survives rewording and new localizations. Anything unreadable or not valid UTF-8 counts as the user's, since guessing wrong that way costs them words. Rollback is now per file too: a failure removes what this call added and puts back a replaced template as the zero-byte file ensureFileExists would have written, which isUntouchedTemplate still recognises, so the next attempt simply replaces it again. Sources are processed in sorted order so a partial failure leaves the same state every time. Also: a legacy folder that is a symlink to a missing target is now .failed rather than .notNeeded -- the data exists, the volume is just not mounted, so it deserves a retry. And AddPhraseHookPath is matched on path components, case-insensitively, so ~/McBopomofo.app-scripts/hook.sh is kept while the in-bundle script is dropped; a non-string value is dropped rather than carried. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> 🤖 Generated with [Claude Code](https://claude.ai/code) via [Happy](https://happy.engineering) Co-Authored-By: Claude <noreply@anthropic.com> Co-Authored-By: Happy <yesreply@happy.engineering>
Both retryable outcomes -- a copy that failed, and a custom location that was not mounted -- correctly leave the marker unset so the next launch tries again, but until now the only trace was an NSLog line. Someone whose Dropbox was still materialising would see an empty dictionary and no reason for it. LegacyMigration leaves the reason in pendingUserNotice rather than showing anything itself: it runs from main.swift before NSApp.run(), where putting a window on screen is not safe. AppDelegate picks it up at the end of applicationDidFinishLaunching, shows it once through the existing NotifierController, and clears it. Silent on success, and gated on isRunningUnderXCTest like the rest of that method. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> 🤖 Generated with [Claude Code](https://claude.ai/code) via [Happy](https://happy.engineering) Co-Authored-By: Claude <noreply@anthropic.com> Co-Authored-By: Happy <yesreply@happy.engineering>
A run on an empty domain still left ChineseConversionEnabled behind, intermittently. PreferencesTests called PreferenceSandbox.captureNow() but then took its own snapshot with UserDefaults.object(forKey:) -- the value as of that instant -- and wrote it back in deinit. .serialized orders the tests inside a suite and says nothing about other suites, which swift-testing runs concurrently. So when AssociatedPhrasesTests had just set ChineseConversionEnabled = false in its own initializer, this suite photographed the false; later AssociatedPhrasesTests correctly removed the key again, and then this deinit put it back. Whichever deinit ran last decided the outcome, which is why the leak appeared on some runs and not others. deinit now goes through PreferenceSandbox.restore(key:) for each of Preferences.allKeys, which restores the process-start reading and expresses "absent then, absent now" as removeObject. makeSnapshot() / restore(from:) / snapshot had no other callers and are gone. BopomixTests has no other live-value restore (checked for object(forKey:), makeSnapshot, restore(from:)). Three consecutive runs from an empty domain now finish with the domain still empty and PLIST_UNCHANGED. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> 🤖 Generated with [Claude Code](https://claude.ai/code) via [Happy](https://happy.engineering) Co-Authored-By: Claude <noreply@anthropic.com> Co-Authored-By: Happy <yesreply@happy.engineering>
"Destination file has user content, keep it whole" threw away entire legacy phrase lists. The sequence: first launch stands down because the custom location is not mounted (correctly, marker unset); the user opens the dictionary, finds it empty, and types a phrase in by hand; that makes data.txt look user-owned; second launch skips it, merges the other four placeholders, reports success and burns the marker. Their whole McBopomofo dictionary was gone, silently, for good. Neither whole-file rule was right. Keeping the file loses the legacy list; overwriting it loses the phrase they just typed. So the merge is per line now: a file the destination lacks is copied, and one it has gains only the legacy lines missing from it, appended at the end. Existing content, its order and its comment header are untouched, and blank/# lines are never carried, which is what makes the merge idempotent -- the retry that .partial and .failed ask for re-appends nothing. latin-user.txt is the one exception: it is word or word\tcount, and LatinLexicon::persistUserWords keys on the lowercased text before the first tab. Deduplicating on the whole line there would produce two rows for one word with different counts, which is exactly what that merge exists to prevent, so the word alone is the key. Lines split on Character.isNewline rather than "\n": Swift makes \r\n a single Character that is not equal to "\n", so splitting on the literal hands a CRLF file back as one long line. Outcomes are now .copied(changed:) / .notNeeded / .partial(changed: skipped:) / .failed(problem); only the first two set the user-data marker. Because each file is handled independently and every write is atomic (Data.write(options: .atomic) lands through a sibling temp file and a rename), a failure part-way through leaves earlier files correctly merged -- so isUntouchedTemplate, the written list, the rollback and the zero-byte-template restore are all gone with the problem they existed for. AddPhraseHookPath now standardizes the path before matching components, so /a/McBopomofo.app/../b/hook.sh is kept, and a blank value is dropped. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> 🤖 Generated with [Claude Code](https://claude.ai/code) via [Happy](https://happy.engineering) Co-Authored-By: Claude <noreply@anthropic.com> Co-Authored-By: Happy <yesreply@happy.engineering>
The notice was one string with an English clause substituted into it, so a zh-Hant user got "custom location not available: /Volumes/..." spliced into a Chinese sentence. userNoticeReason now returns the problem rather than a sentence, and AppDelegate turns each case into its own localizable string. The partial case names the files that could not be read and where the originals still are, so the user knows what is missing and where to find it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> 🤖 Generated with [Claude Code](https://claude.ai/code) via [Happy](https://happy.engineering) Co-Authored-By: Claude <noreply@anthropic.com> Co-Authored-By: Happy <yesreply@happy.engineering>
Five review points from the last pass, all in the user-data merge. latin-user.txt counts. LatinLexicon::loadUserWordList *sums* the counts of duplicate rows and persistUserWords collapses them with std::max, so dropping an incoming line whose word the destination already knows threw away a higher count -- and appending a second row instead would have inflated the word's weight on every load, and again on every .partial retry. The existing row is now rewritten to the larger of the two counts, in place, which is what makes the retry idempotent. Rows the destination already duplicates are compared against their sum, since that is what the engine will see. Line splitting. Character.isNewline breaks at U+2028, U+000B and U+0085; the engine's getline(…, '\n') does not, so a record containing one was being cut in half. CRLF is folded to LF first (Swift makes \r\n a single Character that never equals "\n") and the split is on \n alone, with a trailing \r dropped per line, exactly as the engine reads these files. Placeholder destinations. A destination holding nothing but this app's comment header is now replaced byte for byte, without decoding, so a legacy file in Big5 or anything else still arrives instead of being skipped for ever while the user stares at an empty dictionary. A bytes-identical pair short-circuits ahead of that, which is what lets the replaced file converge on the next run rather than failing to decode and reporting a skip again. Notice frequency. migrateIfNeeded remembers the skipped file names in the new domain under LegacyMcBopomofoSkippedFiles and only raises a notice when that set changes, clearing the key on success. One permanently unreadable file no longer greets the user at every launch, but a different file failing is still news. run() stays pure: it returns the set, the comparison lives in the wrapper. And an unreadable legacy folder now reports .legacyFolderUnreadable rather than borrowing .destinationBlocked, with its own message. SkippedFile.reason stays free text on purpose; the comment says why, and typing it is a follow-up. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> 🤖 Generated with [Claude Code](https://claude.ai/code) via [Happy](https://happy.engineering) Co-Authored-By: Claude <noreply@anthropic.com> Co-Authored-By: Happy <yesreply@happy.engineering>
…rs Bopomix macOS Text Input Sources silently ignores TISRegisterInputSource for an input method whose bundle identifier has no `inputmethod` component: `io.github.lmanchu.bopomix` "registered" without error and never appeared in the input source list, while the same bundle with `io.github.lmanchu.inputmethod.bopomix` registered and enabled both modes immediately (verified on macOS 26.6, 2026-09-13; every third-party IME, McBopomofo included, carries that component). Prefix replaced everywhere it appears (bundle ids for the input method, installer and tests; TISInputSourceID; both input-mode keys in the plist, KeyHandler.mm and the three InfoPlist.strings; the preferences domain guarded by check_plist_unchanged.sh and PreferenceSandbox; docs). The constraint is documented in AGENTS.md. 🤖 Generated with [Claude Code](https://claude.ai/code) via [Happy](https://happy.engineering) Co-Authored-By: Claude <noreply@anthropic.com> Co-Authored-By: Happy <yesreply@happy.engineering>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What
Renames the product from McBopomofo / 小麥注音 to Bopomix / 混打注音 and gives it its own identity, plus a one-shot, non-destructive import of an existing McBopomofo user's settings and phrases.
io.github.lmanchu.inputmethod.bopomix(installerio.github.lmanchu.inputmethod.bopomix.BopomixInstaller, testsio.github.lmanchu.inputmethod.bopomix.tests— the old test id was a stalecom.cerence.*); input-mode idsio.github.lmanchu.inputmethod.bopomix.{Bopomofo,PlainBopomofo}; IMK connectionBopomix_1_Connection; controller classBopomixInputMethodController(including the fourNSClassFromStringlookups); Application Support folderBopomix.BopomixTests/), installer constants, CI workflows,Source/Data/Makefile_install, and the eval tooling (mixime-eval→bopomix-eval) renamed withgit mvso history follows.updates/Info.plistin this repo.McBopomofonamespace,McBopomofoLM*, CMake target names, everyThe McBopomofo Authorsheader, theorg.openvanilla.mcbopomofo.sorteddata pragma, and the dictionary data. They are upstream's engine and attribution, and keeping them makes rebasing on upstream tractable. Original copyright is retained inLICENSE.txt, both Info.plists, allInfoPlist.strings, and the installer licence; aBopomix modificationsline is added alongside.Migration (
Source/LegacyMigration.swift)On the first launch that finds no marker, before
populateDefaults()and never under XCTest:org.openvanilla.inputmethod.McBopomofothat the new domain does not already have is copied, exceptUseCustomUserPhraseLocation/CustomUserPhraseLocation(two input methods must not share one live folder),AddPhraseHookPathwhen it points insideMcBopomofo.app, andNextUpdateCheckDate.~/Library/Application Support/McBopomofo/) is merged line by line into~/Library/Application Support/Bopomix/— files the new folder lacks are copied, otherwise only the legacy lines the new file does not have are appended (latin-user.txtmerges by word and keeps the larger count). Nothing under the old identity is ever written; nothing already in the new folder is overwritten; each file is written atomically; the merge is idempotent, so a retry after a.partial/ unavailable-volume launch is safe.LegacyMcBopomofoPrefsMigrated,LegacyMcBopomofoUserDataMigrated); the user-data marker is only set on.copied/.notNeeded. Failures and unavailable custom locations retry at the next launch and surface once throughNotifierController(localised, en / zh-Hant).Tests
UserDefaults.standard, no real~/Library).ctest207/207,bopomix-evalbuilds.tools/eval/check_plist_unchanged.shnow guards the new domain and folder. Three test-suite leaks into a fresh preferences domain were found and closed on the way (PreferenceSandboxnil-snapshot branch, launch-time writes inAppDelegate, live-value snapshots in the swift-testing suites); the suite has been run repeatedly from an empty domain and leaves it empty.Review trail: five fresh-context review rounds (2 + 1 + 1 + 1 + 0 blocking) are summarised in the plan file; the last round found no blocking issues. Known follow-ups, not in this PR: typed skip reasons in
SkippedFile; a shared.serializedparent suite forPreferencesTests/AssociatedPhrasesTests(pre-existing theoretical race onChineseConversionEnabled, never observed); manual "Check for Updates" reports "up to date" on a 404 until the repo is public.Installing the Debug build for dogfooding
Build with the
Bopomixscheme, copyBopomix.appto~/Library/Input Methods/, runBopomix.app/Contents/MacOS/Bopomix install --all, then pick 混打注音 in System Settings ▸ Keyboard ▸ Input Sources. The first launch imports the McBopomofo settings andlatin-user.txt; the old McBopomofo.app and its data are left untouched.🤖 Generated with Claude Code