Skip to content

refactor(brand): rename McBopomofo fork to Bopomix (混打注音) and import McBopomofo user data - #4

Merged
lmanchu merged 16 commits into
masterfrom
rename-bopomix
Sep 13, 2026
Merged

lmanchu merged 16 commits into
masterfrom
rename-bopomix

Conversation

@lmanchu

@lmanchu lmanchu commented Sep 13, 2026 •

Copy link
Copy Markdown
Owner

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.

  • Bundle id io.github.lmanchu.inputmethod.bopomix (installer io.github.lmanchu.inputmethod.bopomix.BopomixInstaller, tests io.github.lmanchu.inputmethod.bopomix.tests — the old test id was a stale com.cerence.*); input-mode ids io.github.lmanchu.inputmethod.bopomix.{Bopomofo,PlainBopomofo}; IMK connection Bopomix_1_Connection; controller class BopomixInputMethodController (including the four NSClassFromString lookups); Application Support folder Bopomix.
  • Xcode project, targets, schemes, test bundle (BopomixTests/), installer constants, CI workflows, Source/Data/Makefile _install, and the eval tooling (mixime-eval → bopomix-eval) renamed with git mv so history follows.
  • All user-visible strings in Base/en/zh-Hant, both MainMenu xibs, the installer window, the About-panel links, the installer licence screen. Update check now points at updates/Info.plist in this repo.
  • Deliberately kept: the C++ McBopomofo namespace, McBopomofoLM*, CMake target names, every The McBopomofo Authors header, the org.openvanilla.mcbopomofo.sorted data pragma, and the dictionary data. They are upstream's engine and attribution, and keeping them makes rebasing on upstream tractable. Original copyright is retained in LICENSE.txt, both Info.plists, all InfoPlist.strings, and the installer licence; a Bopomix modifications line is added alongside.

Bundle id note (b9e32df): the id carries an inputmethod component on purpose. macOS Text Input Sources silently ignores TISRegisterInputSource for an input method whose bundle identifier lacks it; io.github.lmanchu.bopomix registered without error and never appeared in the input source list, io.github.lmanchu.inputmethod.bopomix registered and enabled both modes immediately (macOS 26.6, 2026-09-13). Documented in AGENTS.md.

Migration (Source/LegacyMigration.swift)

On the first launch that finds no marker, before populateDefaults() and never under XCTest:

  • Preferences: every key of org.openvanilla.inputmethod.McBopomofo that the new domain does not already have is copied, except UseCustomUserPhraseLocation / CustomUserPhraseLocation (two input methods must not share one live folder), AddPhraseHookPath when it points inside McBopomofo.app, and NextUpdateCheckDate.
  • User data: the legacy folder (the custom location if one was set, else ~/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.txt merges 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.
  • Two markers (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 through NotifierController (localised, en / zh-Hant).

Tests

  • 255 XCTest + 87 Swift Testing (was 203 + 87); 52 of the new ones cover the migration (pure functions and temp-directory folder merges, no UserDefaults.standard, no real ~/Library).
  • Engine ctest 207/207, bopomix-eval builds.
  • tools/eval/check_plist_unchanged.sh now guards the new domain and folder. Three test-suite leaks into a fresh preferences domain were found and closed on the way (PreferenceSandbox nil-snapshot branch, launch-time writes in AppDelegate, 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 .serialized parent suite for PreferencesTests / AssociatedPhrasesTests (pre-existing theoretical race on ChineseConversionEnabled, 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 Bopomix scheme, copy Bopomix.app to ~/Library/Input Methods/, run Bopomix.app/Contents/MacOS/Bopomix install --all, then pick 混打注音 in System Settings ▸ Keyboard ▸ Input Sources. The first launch imports the McBopomofo settings and latin-user.txt; the old McBopomofo.app and its data are left untouched.

🤖 Generated with Claude Code

lmanchu and others added 15 commits September 13, 2026 08:58
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>
@lmanchu lmanchu changed the title refactor(brand): rename McBopomofo fork to Bopomix and import McBopomofo user data refactor(brand): rename McBopomofo fork to Bopomix (混打注音) and import McBopomofo user data Sep 13, 2026
…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>
@lmanchu
lmanchu merged commit 5429851 into master Sep 13, 2026
1 of 10 checks passed
@lmanchu
lmanchu deleted the rename-bopomix branch September 13, 2026 22:34
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant