diff --git a/CLAUDE.md b/CLAUDE.md index b881dd2..cf345e1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -13,5 +13,12 @@ build/test/release commandはpackage manifest、Makefile、workflowから読む bundle置換監視を維持する。 - release appcastは専用`appcast` branchが配信正本。mainの`appcast.xml`は空seedであり履歴を追記しない。 - native appなのでDockerを使わない。release signing/notarizationとlocal ad-hoc buildを混同しない。 +- ユーザー向け文字列は必ず`L(_:)`(Sources/CmdIMESwift/Localization.swift)経由。`swift build`/`swift test`は + `Localizable.xcstrings`をコンパイルしない(Xcode専用機能)ため、`Sources/CmdIMESwift/Resources/.lproj/ + Localizable.strings`を`xcstringstool compile`で事前コンパイルして正本と一緒にcommitする。文字列を追加・変更したら + `xcrun xcstringstool compile Sources/CmdIMESwift/Resources/Localizable.xcstrings --output-directory + Sources/CmdIMESwift/Resources --serialization-format text`で再生成し、`LocalizationCatalogTests`のdriftチェックを + green にしてからcommitする。`scripts/package.sh`はSPMの`_.bundle`を`Contents/Resources/`へ + 手動でdittoする一文が要る — 標準の`swift build -c release`だけでは.appにリソースが同梱されない。 変更後はSwift test、build、必要ならevent tap/Accessibilityの実機確認を行う。コメントは周囲の密度と言語へ合わせる。 diff --git a/apps/cmd-ime-swift/Package.swift b/apps/cmd-ime-swift/Package.swift index 19aea5c..4fbab80 100644 --- a/apps/cmd-ime-swift/Package.swift +++ b/apps/cmd-ime-swift/Package.swift @@ -19,7 +19,8 @@ let package = Package( dependencies: [ .product(name: "Sparkle", package: "Sparkle") ], - path: "Sources/CmdIMESwift" + path: "Sources/CmdIMESwift", + resources: [.process("Resources")] ), .testTarget( name: "CmdIMESwiftTests", diff --git a/apps/cmd-ime-swift/Sources/CmdIMESwift/CmdIMEApp.swift b/apps/cmd-ime-swift/Sources/CmdIMESwift/CmdIMEApp.swift index be94380..14631e0 100644 --- a/apps/cmd-ime-swift/Sources/CmdIMESwift/CmdIMEApp.swift +++ b/apps/cmd-ime-swift/Sources/CmdIMESwift/CmdIMEApp.swift @@ -41,21 +41,21 @@ class AppDelegate: NSObject, NSApplicationDelegate, SPUUpdaterDelegate, let version = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "1.0.0" menu.addItem( - withTitle: "⌘IME \(version) — Preferences...", + withTitle: String(format: L("menu.preferencesTitle"), version), action: #selector(AppDelegate.openPreferencesSelector(_:)), keyEquivalent: "," ) menu.addItem(NSMenuItem.separator()) let updateItem = NSMenuItem( - title: "Check for Updates...", + title: L("menu.checkForUpdates"), action: #selector(SPUStandardUpdaterController.checkForUpdates(_:)), keyEquivalent: "" ) updateItem.target = updaterController menu.addItem(updateItem) menu.addItem(NSMenuItem.separator()) - menu.addItem(withTitle: "Restart", action: #selector(AppDelegate.restart(_:)), keyEquivalent: "") - menu.addItem(withTitle: "Quit", action: #selector(AppDelegate.quit(_:)), keyEquivalent: "q") + menu.addItem(withTitle: L("menu.restart"), action: #selector(AppDelegate.restart(_:)), keyEquivalent: "") + menu.addItem(withTitle: L("menu.quit"), action: #selector(AppDelegate.quit(_:)), keyEquivalent: "q") // A main menu lets the Preferences window honor ⌘W / ⌘Q and text-editing keys. NSApp.mainMenu = makeMainMenu() @@ -140,7 +140,7 @@ class AppDelegate: NSObject, NSApplicationDelegate, SPUUpdaterDelegate, let appMenu = NSMenu() appItem.submenu = appMenu let quitItem = appMenu.addItem( - withTitle: "Quit ⌘IME", + withTitle: L("menu.quitApp"), action: #selector(handleCommandQ(_:)), keyEquivalent: "q" ) @@ -150,26 +150,26 @@ class AppDelegate: NSObject, NSApplicationDelegate, SPUUpdaterDelegate, // text fields (key recorder, app pickers) get the usual shortcuts. let editItem = NSMenuItem() mainMenu.addItem(editItem) - let editMenu = NSMenu(title: "Edit") + let editMenu = NSMenu(title: L("menu.edit")) editItem.submenu = editMenu - editMenu.addItem(withTitle: "Cut", action: #selector(NSText.cut(_:)), keyEquivalent: "x") - editMenu.addItem(withTitle: "Copy", action: #selector(NSText.copy(_:)), keyEquivalent: "c") - editMenu.addItem(withTitle: "Paste", action: #selector(NSText.paste(_:)), keyEquivalent: "v") - editMenu.addItem(withTitle: "Select All", action: #selector(NSText.selectAll(_:)), keyEquivalent: "a") + editMenu.addItem(withTitle: L("menu.cut"), action: #selector(NSText.cut(_:)), keyEquivalent: "x") + editMenu.addItem(withTitle: L("menu.copy"), action: #selector(NSText.copy(_:)), keyEquivalent: "c") + editMenu.addItem(withTitle: L("menu.paste"), action: #selector(NSText.paste(_:)), keyEquivalent: "v") + editMenu.addItem(withTitle: L("menu.selectAll"), action: #selector(NSText.selectAll(_:)), keyEquivalent: "a") // Window menu — ⌘W closes the focused window (the agent stays in the menu // bar); ⌘M minimizes it. let windowItem = NSMenuItem() mainMenu.addItem(windowItem) - let windowMenu = NSMenu(title: "Window") + let windowMenu = NSMenu(title: L("menu.window")) windowItem.submenu = windowMenu windowMenu.addItem( - withTitle: "Close", + withTitle: L("menu.close"), action: #selector(NSWindow.performClose(_:)), keyEquivalent: "w" ) windowMenu.addItem( - withTitle: "Minimize", + withTitle: L("menu.minimize"), action: #selector(NSWindow.performMiniaturize(_:)), keyEquivalent: "m" ) @@ -215,8 +215,8 @@ class AppDelegate: NSObject, NSApplicationDelegate, SPUUpdaterDelegate, NSApp.dockTile.badgeLabel = "1" let content = UNMutableNotificationContent() - content.title = "⌘IME の新しいバージョンがあります" - content.body = "v\(update.displayVersionString) をインストールできます" + content.title = L("notification.updateAvailableTitle") + content.body = String(format: L("notification.updateAvailableBody"), update.displayVersionString) let request = UNNotificationRequest( identifier: Self.updateNotificationIdentifier, content: content, diff --git a/apps/cmd-ime-swift/Sources/CmdIMESwift/KeyEvent.swift b/apps/cmd-ime-swift/Sources/CmdIMESwift/KeyEvent.swift index de82ead..4ec3431 100644 --- a/apps/cmd-ime-swift/Sources/CmdIMESwift/KeyEvent.swift +++ b/apps/cmd-ime-swift/Sources/CmdIMESwift/KeyEvent.swift @@ -274,13 +274,11 @@ class KeyEvent: NSObject { private func presentTapFailureAlert() { DispatchQueue.main.async { let alert = NSAlert() - alert.messageText = "⌘IME could not start its keyboard listener" - alert.informativeText = - "Open System Settings → Privacy & Security → Accessibility, " + - "remove ⌘IME if listed, re-add it, then restart the app." + alert.messageText = L("alert.tapFailureTitle") + alert.informativeText = L("alert.tapFailureBody") alert.alertStyle = .warning - alert.addButton(withTitle: "Open System Settings") - alert.addButton(withTitle: "Quit") + alert.addButton(withTitle: L("alert.openSystemSettings")) + alert.addButton(withTitle: L("menu.quit")) if alert.runModal() == .alertFirstButtonReturn, let url = URL(string: "x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility") { NSWorkspace.shared.open(url) diff --git a/apps/cmd-ime-swift/Sources/CmdIMESwift/Localization.swift b/apps/cmd-ime-swift/Sources/CmdIMESwift/Localization.swift new file mode 100644 index 0000000..e0a1e3c --- /dev/null +++ b/apps/cmd-ime-swift/Sources/CmdIMESwift/Localization.swift @@ -0,0 +1,27 @@ +// +// Localization.swift +// ⌘IME +// +// Single entry point for every user-facing string. SPM never merges a +// target's resources into `Bundle.main` (unlike an Xcode app target), so a +// bundle-less `Text("...")`/`NSLocalizedString` lookup would silently +// return the raw key in a real .app build. This resolves explicitly against +// `Bundle.module`, and returns a plain `String` rather than a +// `LocalizedStringKey` so SwiftUI displays it verbatim instead of +// re-resolving it against `Bundle.main` a second time — the same reason +// callers must use the `StringProtocol` overloads of `Text`/`Toggle`/ +// `Button`/`Picker`/`Label` (not the `LocalizedStringKey` ones) when passing +// the result. Works identically under `swift build`/`swift test` and Xcode, +// since it's a plain runtime `String(localized:)` call, not dependent on +// Xcode's String Catalog symbol-generation build phase. +// + +import Foundation + +/// Looks up `key` in `Localizable.xcstrings` for the current locale. `key` is +/// a stable catalog identifier (e.g. "general.launchAtLogin"), not the +/// English display text, so English wording can change without touching +/// every other locale's key. +func L(_ key: String) -> String { + String(localized: String.LocalizationValue(key), bundle: .module) +} diff --git a/apps/cmd-ime-swift/Sources/CmdIMESwift/Resources/Localizable.xcstrings b/apps/cmd-ime-swift/Sources/CmdIMESwift/Resources/Localizable.xcstrings new file mode 100644 index 0000000..1b214fd --- /dev/null +++ b/apps/cmd-ime-swift/Sources/CmdIMESwift/Resources/Localizable.xcstrings @@ -0,0 +1,3163 @@ +{ + "sourceLanguage": "en", + "strings": { + "menu.preferencesTitle": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "⌘IME %@ — Preferences…" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "⌘IME %@ — 環境設定…" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "⌘IME %@ — 偏好设置…" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "⌘IME %@ — 偏好設定…" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "⌘IME %@ — 환경설정…" + } + }, + "vi": { + "stringUnit": { + "state": "translated", + "value": "⌘IME %@ — Tùy chỉnh…" + } + } + } + }, + "menu.checkForUpdates": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Check for Updates…" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "アップデートを確認…" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "检查更新…" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "檢查更新…" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "업데이트 확인…" + } + }, + "vi": { + "stringUnit": { + "state": "translated", + "value": "Kiểm tra cập nhật…" + } + } + } + }, + "menu.restart": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Restart" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "再起動" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "重新启动" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "重新啟動" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "다시 시작" + } + }, + "vi": { + "stringUnit": { + "state": "translated", + "value": "Khởi động lại" + } + } + } + }, + "menu.quit": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Quit" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "終了" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "退出" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "退出" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "종료" + } + }, + "vi": { + "stringUnit": { + "state": "translated", + "value": "Thoát" + } + } + } + }, + "menu.quitApp": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Quit ⌘IME" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "⌘IMEを終了" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "退出 ⌘IME" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "退出 ⌘IME" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "⌘IME 종료" + } + }, + "vi": { + "stringUnit": { + "state": "translated", + "value": "Thoát ⌘IME" + } + } + } + }, + "menu.edit": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Edit" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "編集" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "编辑" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "編輯" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "편집" + } + }, + "vi": { + "stringUnit": { + "state": "translated", + "value": "Chỉnh sửa" + } + } + } + }, + "menu.cut": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Cut" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "カット" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "剪切" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "剪下" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "잘라내기" + } + }, + "vi": { + "stringUnit": { + "state": "translated", + "value": "Cắt" + } + } + } + }, + "menu.copy": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Copy" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "コピー" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "拷贝" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "拷貝" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "복사하기" + } + }, + "vi": { + "stringUnit": { + "state": "translated", + "value": "Sao chép" + } + } + } + }, + "menu.paste": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Paste" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ペースト" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "粘贴" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "貼上" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "붙여넣기" + } + }, + "vi": { + "stringUnit": { + "state": "translated", + "value": "Dán" + } + } + } + }, + "menu.selectAll": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Select All" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "すべてを選択" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "全选" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "全選" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "모두 선택" + } + }, + "vi": { + "stringUnit": { + "state": "translated", + "value": "Chọn Tất Cả" + } + } + } + }, + "menu.window": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Window" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ウインドウ" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "窗口" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "視窗" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "윈도우" + } + }, + "vi": { + "stringUnit": { + "state": "translated", + "value": "Cửa sổ" + } + } + } + }, + "menu.close": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Close" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "閉じる" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "关闭" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "關閉" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "닫기" + } + }, + "vi": { + "stringUnit": { + "state": "translated", + "value": "Đóng" + } + } + } + }, + "menu.minimize": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Minimize" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "しまう" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "缩小" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "縮小" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "Dock에 넣기" + } + }, + "vi": { + "stringUnit": { + "state": "translated", + "value": "Thu nhỏ" + } + } + } + }, + "notification.updateAvailableTitle": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "⌘IME has an update available" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "⌘IMEの新しいバージョンがあります" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "⌘IME 有可用更新" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "⌘IME 有可用更新" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "⌘IME 업데이트가 있습니다" + } + }, + "vi": { + "stringUnit": { + "state": "translated", + "value": "⌘IME có bản cập nhật mới" + } + } + } + }, + "notification.updateAvailableBody": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Version %@ is ready to install" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "v%@ をインストールできます" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "版本 %@ 已可安装" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "版本 %@ 已可安裝" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "버전 %@을(를) 설치할 수 있습니다" + } + }, + "vi": { + "stringUnit": { + "state": "translated", + "value": "Phiên bản %@ đã sẵn sàng để cài đặt" + } + } + } + }, + "general.launchAtLogin": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Launch at Login" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ログイン時に開く" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "登录时打开" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "登入時打開" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "로그인 시 열기" + } + }, + "vi": { + "stringUnit": { + "state": "translated", + "value": "Mở khi đăng nhập" + } + } + } + }, + "general.showMenuBarIcon": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Show menu bar icon" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "メニューバーアイコンを表示" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "在菜单栏中显示图标" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "在選單列中顯示圖示" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "메뉴 막대 아이콘 보기" + } + }, + "vi": { + "stringUnit": { + "state": "translated", + "value": "Hiện biểu tượng trên thanh menu" + } + } + } + }, + "general.quitWithCmdQ": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Quit ⌘IME with ⌘Q" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "⌘Qで⌘IMEを終了" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "使用 ⌘Q 退出 ⌘IME" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "使用 ⌘Q 退出 ⌘IME" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "⌘Q로 ⌘IME 종료" + } + }, + "vi": { + "stringUnit": { + "state": "translated", + "value": "Thoát ⌘IME bằng ⌘Q" + } + } + } + }, + "general.quitWithCmdQFootnote": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "When off, ⌘Q just closes this window and ⌘IME keeps running in the menu bar. You can quit anytime from the menu bar icon." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "オフの場合、⌘Qはこのウインドウを閉じるだけで、⌘IMEはメニューバーで動作し続けます。メニューバーアイコンからいつでも終了できます。" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "关闭时,⌘Q 仅关闭此窗口,⌘IME 会继续在菜单栏中运行。您可以随时从菜单栏图标退出。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "關閉時,⌘Q 僅會關閉此視窗,⌘IME 會繼續在選單列中執行。您可以隨時從選單列圖示退出。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "꺼져 있으면 ⌘Q는 이 창만 닫고 ⌘IME는 메뉴 막대에서 계속 실행됩니다. 메뉴 막대 아이콘에서 언제든지 종료할 수 있습니다." + } + }, + "vi": { + "stringUnit": { + "state": "translated", + "value": "Khi tắt, ⌘Q chỉ đóng cửa sổ này và ⌘IME vẫn chạy trên thanh menu. Bạn có thể thoát bất cứ lúc nào từ biểu tượng trên thanh menu." + } + } + } + }, + "general.checkForUpdatesOnLaunch": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Check for updates on launch" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "起動時にアップデートを確認" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "启动时检查更新" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "啟動時檢查更新" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "시작할 때 업데이트 확인" + } + }, + "vi": { + "stringUnit": { + "state": "translated", + "value": "Kiểm tra cập nhật khi khởi động" + } + } + } + }, + "general.checkNow": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Check Now" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "今すぐ確認" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "立即检查" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "立即檢查" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "지금 확인" + } + }, + "vi": { + "stringUnit": { + "state": "translated", + "value": "Kiểm tra ngay" + } + } + } + }, + "general.versionFormat": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Version %@" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "バージョン %@" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "版本 %@" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "版本 %@" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "버전 %@" + } + }, + "vi": { + "stringUnit": { + "state": "translated", + "value": "Phiên bản %@" + } + } + } + }, + "general.inputSwitchingSection": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Input Switching" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "入力切り替え" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "输入切换" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "輸入切換" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "입력 전환" + } + }, + "vi": { + "stringUnit": { + "state": "translated", + "value": "Chuyển đổi nhập liệu" + } + } + } + }, + "general.modePickerLabel": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Mode" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "モード" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "模式" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "模式" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "모드" + } + }, + "vi": { + "stringUnit": { + "state": "translated", + "value": "Chế độ" + } + } + } + }, + "general.modeOff": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Off" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "オフ" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "关闭" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "關閉" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "끄기" + } + }, + "vi": { + "stringUnit": { + "state": "translated", + "value": "Tắt" + } + } + } + }, + "general.modePerApp": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Per app" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "アプリごと" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "按应用" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "依應用程式" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "앱별" + } + }, + "vi": { + "stringUnit": { + "state": "translated", + "value": "Theo ứng dụng" + } + } + } + }, + "general.modeSmart": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Smart" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "スマート" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "智能" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "智慧" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "스마트" + } + }, + "vi": { + "stringUnit": { + "state": "translated", + "value": "Thông minh" + } + } + } + }, + "general.modeOffDescription": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "No automatic switching. Input source stays as-is when you switch apps." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "自動切り替えは行いません。アプリを切り替えても入力ソースはそのまま維持されます。" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "不自动切换。切换应用时输入源保持不变。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "不會自動切換。切換應用程式時輸入來源會維持不變。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "자동으로 전환하지 않습니다. 앱을 전환해도 입력 소스가 그대로 유지됩니다." + } + }, + "vi": { + "stringUnit": { + "state": "translated", + "value": "Không tự động chuyển đổi. Nguồn nhập vẫn giữ nguyên khi bạn chuyển ứng dụng." + } + } + } + }, + "general.modePerAppDescription": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Remembers and restores the input source for each app when you switch." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "アプリごとに入力ソースを記憶し、切り替え時に復元します。" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "为每个应用记住并恢复输入源,切换时自动还原。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "為每個應用程式記住並還原輸入來源,切換時自動套用。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "앱마다 입력 소스를 기억했다가 전환할 때 복원합니다." + } + }, + "vi": { + "stringUnit": { + "state": "translated", + "value": "Ghi nhớ và khôi phục nguồn nhập cho từng ứng dụng khi bạn chuyển đổi." + } + } + } + }, + "general.modeSmartDescription": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Per-app memory plus auto-switch to alphanumeric in URL bars, phone, email, and ZIP fields. (Beta)" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "アプリごとの記憶に加え、URLバーや電話番号・メール・郵便番号欄では自動的に英数入力に切り替えます。(ベータ)" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "在按应用记忆的基础上,还会在网址栏、电话、邮箱和邮编等输入框中自动切换为英数输入。(测试版)" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "在依應用程式記憶的基礎上,也會在網址列、電話、電子郵件與郵遞區號欄位自動切換為英數輸入。(Beta)" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "앱별 기억 기능에 더해 URL 입력란, 전화번호, 이메일, 우편번호 필드에서는 자동으로 영숫자 입력으로 전환합니다. (베타)" + } + }, + "vi": { + "stringUnit": { + "state": "translated", + "value": "Ngoài ghi nhớ theo ứng dụng, còn tự động chuyển sang nhập chữ và số trong thanh URL, số điện thoại, email và mã bưu điện. (Beta)" + } + } + } + }, + "general.aboutSection": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "About" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "このアプリについて" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "关于" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "關於" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "정보" + } + }, + "vi": { + "stringUnit": { + "state": "translated", + "value": "Giới thiệu" + } + } + } + }, + "general.versionShortFormat": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "v%@" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "v%@" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "v%@" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "v%@" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "v%@" + } + }, + "vi": { + "stringUnit": { + "state": "translated", + "value": "v%@" + } + } + } + }, + "general.githubLink": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "GitHub" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "GitHub" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "GitHub" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "GitHub" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "GitHub" + } + }, + "vi": { + "stringUnit": { + "state": "translated", + "value": "GitHub" + } + } + } + }, + "general.issuesLink": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Issues" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "Issue" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "议题" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "議題" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "이슈" + } + }, + "vi": { + "stringUnit": { + "state": "translated", + "value": "Issues" + } + } + } + }, + "general.licenseLink": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "License" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ライセンス" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "许可证" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "授權條款" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "라이선스" + } + }, + "vi": { + "stringUnit": { + "state": "translated", + "value": "Giấy phép" + } + } + } + }, + "general.licenseFootnote": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "MIT License · Based on the original cmd-eikana by iMasanari" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "MITライセンス · iMasanariによるオリジナル cmd-eikana をベースにしています" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "MIT 许可证 · 基于 iMasanari 的原始项目 cmd-eikana" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "MIT 授權條款 · 基於 iMasanari 的原始專案 cmd-eikana" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "MIT 라이선스 · iMasanari의 원본 cmd-eikana를 기반으로 함" + } + }, + "vi": { + "stringUnit": { + "state": "translated", + "value": "Giấy phép MIT · Dựa trên dự án gốc cmd-eikana của iMasanari" + } + } + } + }, + "exclusions.description": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "⌘IME will not remap keys when these apps are frontmost." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "これらのアプリが最前面にあるとき、⌘IMEはキーの割り当てを行いません。" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "当这些应用位于最前面时,⌘IME 不会重新映射按键。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "當這些應用程式在最前面時,⌘IME 不會重新對應按鍵。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "이 앱들이 맨 앞에 있을 때는 ⌘IME가 키를 다시 매핑하지 않습니다." + } + }, + "vi": { + "stringUnit": { + "state": "translated", + "value": "⌘IME sẽ không ánh xạ lại phím khi các ứng dụng này đang ở phía trước." + } + } + } + }, + "exclusions.excludedHeader": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Excluded" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "除外中" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "已排除" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "已排除" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "제외됨" + } + }, + "vi": { + "stringUnit": { + "state": "translated", + "value": "Đã loại trừ" + } + } + } + }, + "exclusions.emptyExcluded": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "No excluded apps yet." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "まだ除外されたアプリはありません。" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "目前没有已排除的应用。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "目前沒有已排除的應用程式。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "아직 제외된 앱이 없습니다." + } + }, + "vi": { + "stringUnit": { + "state": "translated", + "value": "Chưa có ứng dụng nào bị loại trừ." + } + } + } + }, + "exclusions.recentHeader": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Recently active" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "最近使用したアプリ" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "最近使用" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "最近使用" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "최근 사용한 앱" + } + }, + "vi": { + "stringUnit": { + "state": "translated", + "value": "Gần đây đã dùng" + } + } + } + }, + "exclusions.addAppButton": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Add App…" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "アプリを追加…" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "添加应用…" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "加入應用程式…" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "앱 추가…" + } + }, + "vi": { + "stringUnit": { + "state": "translated", + "value": "Thêm ứng dụng…" + } + } + } + }, + "exclusions.addAppHelp": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Choose any installed app to exclude" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "除外するアプリを選択します" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "选择要排除的已安装应用" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "選擇要排除的已安裝應用程式" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "제외할 설치된 앱을 선택하세요" + } + }, + "vi": { + "stringUnit": { + "state": "translated", + "value": "Chọn một ứng dụng đã cài đặt để loại trừ" + } + } + } + }, + "exclusions.emptyRecent": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Switch to another app and come back to populate this list." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "他のアプリに切り替えてから戻ると、このリストに表示されます。" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "切换到其他应用后再返回,此列表将会自动填充。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "切換到其他應用程式後再返回,此清單就會自動顯示內容。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "다른 앱으로 전환했다가 돌아오면 이 목록이 채워집니다." + } + }, + "vi": { + "stringUnit": { + "state": "translated", + "value": "Chuyển sang ứng dụng khác rồi quay lại để danh sách này được điền đầy đủ." + } + } + } + }, + "exclusions.openPanelMessage": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Choose apps to exclude from ⌘IME key remapping" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "⌘IMEのキー割り当てから除外するアプリを選択してください" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "选择要从 ⌘IME 按键重映射中排除的应用" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "選擇要從 ⌘IME 按鍵重新對應中排除的應用程式" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "⌘IME 키 재매핑에서 제외할 앱을 선택하세요" + } + }, + "vi": { + "stringUnit": { + "state": "translated", + "value": "Chọn ứng dụng để loại trừ khỏi việc ánh xạ lại phím của ⌘IME" + } + } + } + }, + "exclusions.openPanelPrompt": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Add" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "追加" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "添加" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "加入" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "추가" + } + }, + "vi": { + "stringUnit": { + "state": "translated", + "value": "Thêm" + } + } + } + }, + "keyRecorder.placeholder": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Press a key…" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "キーを押してください…" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "请按下一个键…" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "請按下一個按鍵…" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "키를 누르세요…" + } + }, + "vi": { + "stringUnit": { + "state": "translated", + "value": "Nhấn một phím…" + } + } + } + }, + "keyRecorder.cancel": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Cancel" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "キャンセル" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "取消" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "取消" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "취소" + } + }, + "vi": { + "stringUnit": { + "state": "translated", + "value": "Hủy" + } + } + } + }, + "keyRecorder.save": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Save" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "保存" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "存储" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "儲存" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "저장" + } + }, + "vi": { + "stringUnit": { + "state": "translated", + "value": "Lưu" + } + } + } + }, + "shortcuts.description": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Key: the hotkey to intercept. Action: what happens when you press it." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "キー:割り込むホットキーです。アクション:そのキーを押したときの動作です。" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "按键:要拦截的快捷键。动作:按下该键时执行的操作。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "按鍵:要攔截的快捷鍵。動作:按下該鍵時執行的操作。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "키: 가로챌 단축키입니다. 동작: 해당 키를 눌렀을 때 실행할 작업입니다." + } + }, + "vi": { + "stringUnit": { + "state": "translated", + "value": "Phím: phím tắt cần chặn. Hành động: điều xảy ra khi bạn nhấn phím đó." + } + } + } + }, + "shortcuts.keyColumnHeader": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Key" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "キー" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "按键" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "按鍵" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "키" + } + }, + "vi": { + "stringUnit": { + "state": "translated", + "value": "Phím" + } + } + } + }, + "shortcuts.actionColumnHeader": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Action" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "アクション" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "动作" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "動作" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "동작" + } + }, + "vi": { + "stringUnit": { + "state": "translated", + "value": "Hành động" + } + } + } + }, + "shortcuts.shadowedHelp": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Shadowed by an earlier mapping with the same input" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "同じ入力を持つ前のマッピングによって無効化されています" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "被具有相同输入的更早映射所覆盖" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "被具有相同輸入的較早對應項目所覆蓋" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "동일한 입력을 가진 이전 매핑에 가려졌습니다" + } + }, + "vi": { + "stringUnit": { + "state": "translated", + "value": "Bị che khuất bởi ánh xạ trước đó có cùng đầu vào" + } + } + } + }, + "shortcuts.removeHelp": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Remove this mapping" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "このマッピングを削除します" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "移除此映射" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "移除此對應" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "이 매핑 제거" + } + }, + "vi": { + "stringUnit": { + "state": "translated", + "value": "Xóa ánh xạ này" + } + } + } + }, + "shortcuts.addButton": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Add" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "追加" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "添加" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "加入" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "추가" + } + }, + "vi": { + "stringUnit": { + "state": "translated", + "value": "Thêm" + } + } + } + }, + "shortcuts.inputPlaceholder": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Input" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "入力" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "输入" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "輸入" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "입력" + } + }, + "vi": { + "stringUnit": { + "state": "translated", + "value": "Đầu vào" + } + } + } + }, + "shortcuts.inputHelp": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Choose the hotkey to intercept" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "割り込むホットキーを選択します" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "选择要拦截的快捷键" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "選擇要攔截的快捷鍵" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "가로챌 단축키를 선택하세요" + } + }, + "vi": { + "stringUnit": { + "state": "translated", + "value": "Chọn phím tắt cần chặn" + } + } + } + }, + "shortcuts.actionHelp": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Choose what happens when this key is pressed" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "このキーを押したときの動作を選択します" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "选择按下此键时执行的操作" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "選擇按下此按鍵時執行的操作" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "이 키를 눌렀을 때 실행할 작업을 선택하세요" + } + }, + "vi": { + "stringUnit": { + "state": "translated", + "value": "Chọn điều xảy ra khi phím này được nhấn" + } + } + } + }, + "shortcuts.presetLeftCommand": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Left ⌘ (Left Command)" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "左⌘ (左Command)" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "左⌘ (左 Command)" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "左⌘ (左 Command)" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "왼쪽 ⌘ (왼쪽 Command)" + } + }, + "vi": { + "stringUnit": { + "state": "translated", + "value": "⌘ trái (Command trái)" + } + } + } + }, + "shortcuts.presetRightCommand": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Right ⌘ (Right Command)" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "右⌘ (右Command)" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "右⌘ (右 Command)" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "右⌘ (右 Command)" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "오른쪽 ⌘ (오른쪽 Command)" + } + }, + "vi": { + "stringUnit": { + "state": "translated", + "value": "⌘ phải (Command phải)" + } + } + } + }, + "shortcuts.presetEisu": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "英数 (Eisu / Alphanumeric)" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "英数 (Eisu / Alphanumeric)" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "英数 (Eisu / Alphanumeric)" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "英数 (Eisu / Alphanumeric)" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "英数 (Eisu / Alphanumeric)" + } + }, + "vi": { + "stringUnit": { + "state": "translated", + "value": "英数 (Eisu / Alphanumeric)" + } + } + } + }, + "shortcuts.presetKana": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "かな (Kana)" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "かな (Kana)" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "かな (Kana)" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "かな (Kana)" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "かな (Kana)" + } + }, + "vi": { + "stringUnit": { + "state": "translated", + "value": "かな (Kana)" + } + } + } + }, + "shortcuts.presetCapsLock": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "⇪ (Caps Lock)" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "⇪ (Caps Lock)" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "⇪ (大写锁定)" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "⇪ (大寫鎖定)" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "⇪ (Caps Lock)" + } + }, + "vi": { + "stringUnit": { + "state": "translated", + "value": "⇪ (Caps Lock)" + } + } + } + }, + "shortcuts.presetLeftShift": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Left ⇧ (Left Shift)" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "左⇧ (左Shift)" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "左⇧ (左 Shift)" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "左⇧ (左 Shift)" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "왼쪽 ⇧ (왼쪽 Shift)" + } + }, + "vi": { + "stringUnit": { + "state": "translated", + "value": "⇧ trái (Shift trái)" + } + } + } + }, + "shortcuts.presetRightShift": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Right ⇧ (Right Shift)" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "右⇧ (右Shift)" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "右⇧ (右 Shift)" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "右⇧ (右 Shift)" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "오른쪽 ⇧ (오른쪽 Shift)" + } + }, + "vi": { + "stringUnit": { + "state": "translated", + "value": "⇧ phải (Shift phải)" + } + } + } + }, + "shortcuts.presetLeftOption": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Left ⌥ (Left Option)" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "左⌥ (左Option)" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "左⌥ (左 Option)" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "左⌥ (左 Option)" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "왼쪽 ⌥ (왼쪽 Option)" + } + }, + "vi": { + "stringUnit": { + "state": "translated", + "value": "⌥ trái (Option trái)" + } + } + } + }, + "shortcuts.presetRightOption": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Right ⌥ (Right Option)" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "右⌥ (右Option)" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "右⌥ (右 Option)" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "右⌥ (右 Option)" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "오른쪽 ⌥ (오른쪽 Option)" + } + }, + "vi": { + "stringUnit": { + "state": "translated", + "value": "⌥ phải (Option phải)" + } + } + } + }, + "shortcuts.presetLeftControl": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Left ⌃ (Left Control)" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "左⌃ (左Control)" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "左⌃ (左 Control)" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "左⌃ (左 Control)" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "왼쪽 ⌃ (왼쪽 Control)" + } + }, + "vi": { + "stringUnit": { + "state": "translated", + "value": "⌃ trái (Control trái)" + } + } + } + }, + "shortcuts.presetRightControl": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Right ⌃ (Right Control)" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "右⌃ (右Control)" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "右⌃ (右 Control)" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "右⌃ (右 Control)" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "오른쪽 ⌃ (오른쪽 Control)" + } + }, + "vi": { + "stringUnit": { + "state": "translated", + "value": "⌃ phải (Control phải)" + } + } + } + }, + "shortcuts.actionSwitchToAlphanumeric": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Switch to Alphanumeric" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "英数に切り替え" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "切换到英数" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "切換到英數" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "영숫자로 전환" + } + }, + "vi": { + "stringUnit": { + "state": "translated", + "value": "Chuyển sang chữ và số" + } + } + } + }, + "shortcuts.actionSwitchToKana": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Switch to Kana" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "かなに切り替え" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "切换到假名" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "切換到假名" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "가나로 전환" + } + }, + "vi": { + "stringUnit": { + "state": "translated", + "value": "Chuyển sang Kana" + } + } + } + }, + "shortcuts.actionDisableKey": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Disable key" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "キーを無効化" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "禁用按键" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "停用按鍵" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "키 비활성화" + } + }, + "vi": { + "stringUnit": { + "state": "translated", + "value": "Vô hiệu hóa phím" + } + } + } + }, + "settingsTabs.general": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "General" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "一般" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "通用" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "一般" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "일반" + } + }, + "vi": { + "stringUnit": { + "state": "translated", + "value": "Chung" + } + } + } + }, + "settingsTabs.shortcuts": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Shortcuts" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ショートカット" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "快捷键" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "快捷鍵" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "단축키" + } + }, + "vi": { + "stringUnit": { + "state": "translated", + "value": "Phím tắt" + } + } + } + }, + "settingsTabs.exclusions": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Exclusions" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "除外設定" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "排除项" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "排除項目" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "제외 항목" + } + }, + "vi": { + "stringUnit": { + "state": "translated", + "value": "Loại trừ" + } + } + } + }, + "alert.tapFailureTitle": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "⌘IME could not start its keyboard listener" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "⌘IMEはキーボードリスナーを開始できませんでした" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "⌘IME 无法启动键盘监听" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "⌘IME 無法啟動鍵盤監聽" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "⌘IME가 키보드 리스너를 시작할 수 없습니다" + } + }, + "vi": { + "stringUnit": { + "state": "translated", + "value": "⌘IME không thể khởi động trình lắng nghe bàn phím" + } + } + } + }, + "alert.tapFailureBody": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Open System Settings → Privacy & Security → Accessibility, remove ⌘IME if listed, re-add it, then restart the app." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "システム設定 → プライバシーとセキュリティ → アクセシビリティを開き、⌘IMEが表示されている場合は削除してから再度追加し、アプリを再起動してください。" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "打开系统设置 → 隐私与安全性 → 辅助功能,如果列表中有 ⌘IME,请先移除再重新添加,然后重新启动应用。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "打開系統設定 → 隱私權與安全性 → 輔助使用,如果清單中有 ⌘IME,請先移除再重新加入,然後重新啟動應用程式。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "시스템 설정 → 개인정보 보호 및 보안 → 손쉬운 사용을 열고, 목록에 ⌘IME가 있으면 제거한 다음 다시 추가하고 앱을 재시작하세요." + } + }, + "vi": { + "stringUnit": { + "state": "translated", + "value": "Mở Cài đặt Hệ thống → Quyền riêng tư & Bảo mật → Trợ năng, xóa ⌘IME nếu có trong danh sách, thêm lại, rồi khởi động lại ứng dụng." + } + } + } + }, + "alert.openSystemSettings": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Open System Settings" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "システム設定を開く" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "打开系统设置" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "打開系統設定" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "시스템 설정 열기" + } + }, + "vi": { + "stringUnit": { + "state": "translated", + "value": "Mở Cài đặt Hệ thống" + } + } + } + } + }, + "version": "1.0" +} diff --git a/apps/cmd-ime-swift/Sources/CmdIMESwift/Resources/en.lproj/Localizable.strings b/apps/cmd-ime-swift/Sources/CmdIMESwift/Resources/en.lproj/Localizable.strings new file mode 100644 index 0000000..f706f04 --- /dev/null +++ b/apps/cmd-ime-swift/Sources/CmdIMESwift/Resources/en.lproj/Localizable.strings @@ -0,0 +1,160 @@ + + + + + alert.openSystemSettings + Open System Settings + alert.tapFailureBody + Open System Settings → Privacy & Security → Accessibility, remove ⌘IME if listed, re-add it, then restart the app. + alert.tapFailureTitle + ⌘IME could not start its keyboard listener + exclusions.addAppButton + Add App… + exclusions.addAppHelp + Choose any installed app to exclude + exclusions.description + ⌘IME will not remap keys when these apps are frontmost. + exclusions.emptyExcluded + No excluded apps yet. + exclusions.emptyRecent + Switch to another app and come back to populate this list. + exclusions.excludedHeader + Excluded + exclusions.openPanelMessage + Choose apps to exclude from ⌘IME key remapping + exclusions.openPanelPrompt + Add + exclusions.recentHeader + Recently active + general.aboutSection + About + general.checkForUpdatesOnLaunch + Check for updates on launch + general.checkNow + Check Now + general.githubLink + GitHub + general.inputSwitchingSection + Input Switching + general.issuesLink + Issues + general.launchAtLogin + Launch at Login + general.licenseFootnote + MIT License · Based on the original cmd-eikana by iMasanari + general.licenseLink + License + general.modeOff + Off + general.modeOffDescription + No automatic switching. Input source stays as-is when you switch apps. + general.modePerApp + Per app + general.modePerAppDescription + Remembers and restores the input source for each app when you switch. + general.modePickerLabel + Mode + general.modeSmart + Smart + general.modeSmartDescription + Per-app memory plus auto-switch to alphanumeric in URL bars, phone, email, and ZIP fields. (Beta) + general.quitWithCmdQ + Quit ⌘IME with ⌘Q + general.quitWithCmdQFootnote + When off, ⌘Q just closes this window and ⌘IME keeps running in the menu bar. You can quit anytime from the menu bar icon. + general.showMenuBarIcon + Show menu bar icon + general.versionFormat + Version %@ + general.versionShortFormat + v%@ + keyRecorder.cancel + Cancel + keyRecorder.placeholder + Press a key… + keyRecorder.save + Save + menu.checkForUpdates + Check for Updates… + menu.close + Close + menu.copy + Copy + menu.cut + Cut + menu.edit + Edit + menu.minimize + Minimize + menu.paste + Paste + menu.preferencesTitle + ⌘IME %@ — Preferences… + menu.quit + Quit + menu.quitApp + Quit ⌘IME + menu.restart + Restart + menu.selectAll + Select All + menu.window + Window + notification.updateAvailableBody + Version %@ is ready to install + notification.updateAvailableTitle + ⌘IME has an update available + settingsTabs.exclusions + Exclusions + settingsTabs.general + General + settingsTabs.shortcuts + Shortcuts + shortcuts.actionColumnHeader + Action + shortcuts.actionDisableKey + Disable key + shortcuts.actionHelp + Choose what happens when this key is pressed + shortcuts.actionSwitchToAlphanumeric + Switch to Alphanumeric + shortcuts.actionSwitchToKana + Switch to Kana + shortcuts.addButton + Add + shortcuts.description + Key: the hotkey to intercept. Action: what happens when you press it. + shortcuts.inputHelp + Choose the hotkey to intercept + shortcuts.inputPlaceholder + Input + shortcuts.keyColumnHeader + Key + shortcuts.presetCapsLock + ⇪ (Caps Lock) + shortcuts.presetEisu + 英数 (Eisu / Alphanumeric) + shortcuts.presetKana + かな (Kana) + shortcuts.presetLeftCommand + Left ⌘ (Left Command) + shortcuts.presetLeftControl + Left ⌃ (Left Control) + shortcuts.presetLeftOption + Left ⌥ (Left Option) + shortcuts.presetLeftShift + Left ⇧ (Left Shift) + shortcuts.presetRightCommand + Right ⌘ (Right Command) + shortcuts.presetRightControl + Right ⌃ (Right Control) + shortcuts.presetRightOption + Right ⌥ (Right Option) + shortcuts.presetRightShift + Right ⇧ (Right Shift) + shortcuts.removeHelp + Remove this mapping + shortcuts.shadowedHelp + Shadowed by an earlier mapping with the same input + + diff --git a/apps/cmd-ime-swift/Sources/CmdIMESwift/Resources/ja.lproj/Localizable.strings b/apps/cmd-ime-swift/Sources/CmdIMESwift/Resources/ja.lproj/Localizable.strings new file mode 100644 index 0000000..276dc7c --- /dev/null +++ b/apps/cmd-ime-swift/Sources/CmdIMESwift/Resources/ja.lproj/Localizable.strings @@ -0,0 +1,160 @@ + + + + + alert.openSystemSettings + システム設定を開く + alert.tapFailureBody + システム設定 → プライバシーとセキュリティ → アクセシビリティを開き、⌘IMEが表示されている場合は削除してから再度追加し、アプリを再起動してください。 + alert.tapFailureTitle + ⌘IMEはキーボードリスナーを開始できませんでした + exclusions.addAppButton + アプリを追加… + exclusions.addAppHelp + 除外するアプリを選択します + exclusions.description + これらのアプリが最前面にあるとき、⌘IMEはキーの割り当てを行いません。 + exclusions.emptyExcluded + まだ除外されたアプリはありません。 + exclusions.emptyRecent + 他のアプリに切り替えてから戻ると、このリストに表示されます。 + exclusions.excludedHeader + 除外中 + exclusions.openPanelMessage + ⌘IMEのキー割り当てから除外するアプリを選択してください + exclusions.openPanelPrompt + 追加 + exclusions.recentHeader + 最近使用したアプリ + general.aboutSection + このアプリについて + general.checkForUpdatesOnLaunch + 起動時にアップデートを確認 + general.checkNow + 今すぐ確認 + general.githubLink + GitHub + general.inputSwitchingSection + 入力切り替え + general.issuesLink + Issue + general.launchAtLogin + ログイン時に開く + general.licenseFootnote + MITライセンス · iMasanariによるオリジナル cmd-eikana をベースにしています + general.licenseLink + ライセンス + general.modeOff + オフ + general.modeOffDescription + 自動切り替えは行いません。アプリを切り替えても入力ソースはそのまま維持されます。 + general.modePerApp + アプリごと + general.modePerAppDescription + アプリごとに入力ソースを記憶し、切り替え時に復元します。 + general.modePickerLabel + モード + general.modeSmart + スマート + general.modeSmartDescription + アプリごとの記憶に加え、URLバーや電話番号・メール・郵便番号欄では自動的に英数入力に切り替えます。(ベータ) + general.quitWithCmdQ + ⌘Qで⌘IMEを終了 + general.quitWithCmdQFootnote + オフの場合、⌘Qはこのウインドウを閉じるだけで、⌘IMEはメニューバーで動作し続けます。メニューバーアイコンからいつでも終了できます。 + general.showMenuBarIcon + メニューバーアイコンを表示 + general.versionFormat + バージョン %@ + general.versionShortFormat + v%@ + keyRecorder.cancel + キャンセル + keyRecorder.placeholder + キーを押してください… + keyRecorder.save + 保存 + menu.checkForUpdates + アップデートを確認… + menu.close + 閉じる + menu.copy + コピー + menu.cut + カット + menu.edit + 編集 + menu.minimize + しまう + menu.paste + ペースト + menu.preferencesTitle + ⌘IME %@ — 環境設定… + menu.quit + 終了 + menu.quitApp + ⌘IMEを終了 + menu.restart + 再起動 + menu.selectAll + すべてを選択 + menu.window + ウインドウ + notification.updateAvailableBody + v%@ をインストールできます + notification.updateAvailableTitle + ⌘IMEの新しいバージョンがあります + settingsTabs.exclusions + 除外設定 + settingsTabs.general + 一般 + settingsTabs.shortcuts + ショートカット + shortcuts.actionColumnHeader + アクション + shortcuts.actionDisableKey + キーを無効化 + shortcuts.actionHelp + このキーを押したときの動作を選択します + shortcuts.actionSwitchToAlphanumeric + 英数に切り替え + shortcuts.actionSwitchToKana + かなに切り替え + shortcuts.addButton + 追加 + shortcuts.description + キー:割り込むホットキーです。アクション:そのキーを押したときの動作です。 + shortcuts.inputHelp + 割り込むホットキーを選択します + shortcuts.inputPlaceholder + 入力 + shortcuts.keyColumnHeader + キー + shortcuts.presetCapsLock + ⇪ (Caps Lock) + shortcuts.presetEisu + 英数 (Eisu / Alphanumeric) + shortcuts.presetKana + かな (Kana) + shortcuts.presetLeftCommand + 左⌘ (左Command) + shortcuts.presetLeftControl + 左⌃ (左Control) + shortcuts.presetLeftOption + 左⌥ (左Option) + shortcuts.presetLeftShift + 左⇧ (左Shift) + shortcuts.presetRightCommand + 右⌘ (右Command) + shortcuts.presetRightControl + 右⌃ (右Control) + shortcuts.presetRightOption + 右⌥ (右Option) + shortcuts.presetRightShift + 右⇧ (右Shift) + shortcuts.removeHelp + このマッピングを削除します + shortcuts.shadowedHelp + 同じ入力を持つ前のマッピングによって無効化されています + + diff --git a/apps/cmd-ime-swift/Sources/CmdIMESwift/Resources/ko.lproj/Localizable.strings b/apps/cmd-ime-swift/Sources/CmdIMESwift/Resources/ko.lproj/Localizable.strings new file mode 100644 index 0000000..98cde3a --- /dev/null +++ b/apps/cmd-ime-swift/Sources/CmdIMESwift/Resources/ko.lproj/Localizable.strings @@ -0,0 +1,160 @@ + + + + + alert.openSystemSettings + 시스템 설정 열기 + alert.tapFailureBody + 시스템 설정 → 개인정보 보호 및 보안 → 손쉬운 사용을 열고, 목록에 ⌘IME가 있으면 제거한 다음 다시 추가하고 앱을 재시작하세요. + alert.tapFailureTitle + ⌘IME가 키보드 리스너를 시작할 수 없습니다 + exclusions.addAppButton + 앱 추가… + exclusions.addAppHelp + 제외할 설치된 앱을 선택하세요 + exclusions.description + 이 앱들이 맨 앞에 있을 때는 ⌘IME가 키를 다시 매핑하지 않습니다. + exclusions.emptyExcluded + 아직 제외된 앱이 없습니다. + exclusions.emptyRecent + 다른 앱으로 전환했다가 돌아오면 이 목록이 채워집니다. + exclusions.excludedHeader + 제외됨 + exclusions.openPanelMessage + ⌘IME 키 재매핑에서 제외할 앱을 선택하세요 + exclusions.openPanelPrompt + 추가 + exclusions.recentHeader + 최근 사용한 앱 + general.aboutSection + 정보 + general.checkForUpdatesOnLaunch + 시작할 때 업데이트 확인 + general.checkNow + 지금 확인 + general.githubLink + GitHub + general.inputSwitchingSection + 입력 전환 + general.issuesLink + 이슈 + general.launchAtLogin + 로그인 시 열기 + general.licenseFootnote + MIT 라이선스 · iMasanari의 원본 cmd-eikana를 기반으로 함 + general.licenseLink + 라이선스 + general.modeOff + 끄기 + general.modeOffDescription + 자동으로 전환하지 않습니다. 앱을 전환해도 입력 소스가 그대로 유지됩니다. + general.modePerApp + 앱별 + general.modePerAppDescription + 앱마다 입력 소스를 기억했다가 전환할 때 복원합니다. + general.modePickerLabel + 모드 + general.modeSmart + 스마트 + general.modeSmartDescription + 앱별 기억 기능에 더해 URL 입력란, 전화번호, 이메일, 우편번호 필드에서는 자동으로 영숫자 입력으로 전환합니다. (베타) + general.quitWithCmdQ + ⌘Q로 ⌘IME 종료 + general.quitWithCmdQFootnote + 꺼져 있으면 ⌘Q는 이 창만 닫고 ⌘IME는 메뉴 막대에서 계속 실행됩니다. 메뉴 막대 아이콘에서 언제든지 종료할 수 있습니다. + general.showMenuBarIcon + 메뉴 막대 아이콘 보기 + general.versionFormat + 버전 %@ + general.versionShortFormat + v%@ + keyRecorder.cancel + 취소 + keyRecorder.placeholder + 키를 누르세요… + keyRecorder.save + 저장 + menu.checkForUpdates + 업데이트 확인… + menu.close + 닫기 + menu.copy + 복사하기 + menu.cut + 잘라내기 + menu.edit + 편집 + menu.minimize + Dock에 넣기 + menu.paste + 붙여넣기 + menu.preferencesTitle + ⌘IME %@ — 환경설정… + menu.quit + 종료 + menu.quitApp + ⌘IME 종료 + menu.restart + 다시 시작 + menu.selectAll + 모두 선택 + menu.window + 윈도우 + notification.updateAvailableBody + 버전 %@을(를) 설치할 수 있습니다 + notification.updateAvailableTitle + ⌘IME 업데이트가 있습니다 + settingsTabs.exclusions + 제외 항목 + settingsTabs.general + 일반 + settingsTabs.shortcuts + 단축키 + shortcuts.actionColumnHeader + 동작 + shortcuts.actionDisableKey + 키 비활성화 + shortcuts.actionHelp + 이 키를 눌렀을 때 실행할 작업을 선택하세요 + shortcuts.actionSwitchToAlphanumeric + 영숫자로 전환 + shortcuts.actionSwitchToKana + 가나로 전환 + shortcuts.addButton + 추가 + shortcuts.description + 키: 가로챌 단축키입니다. 동작: 해당 키를 눌렀을 때 실행할 작업입니다. + shortcuts.inputHelp + 가로챌 단축키를 선택하세요 + shortcuts.inputPlaceholder + 입력 + shortcuts.keyColumnHeader + + shortcuts.presetCapsLock + ⇪ (Caps Lock) + shortcuts.presetEisu + 英数 (Eisu / Alphanumeric) + shortcuts.presetKana + かな (Kana) + shortcuts.presetLeftCommand + 왼쪽 ⌘ (왼쪽 Command) + shortcuts.presetLeftControl + 왼쪽 ⌃ (왼쪽 Control) + shortcuts.presetLeftOption + 왼쪽 ⌥ (왼쪽 Option) + shortcuts.presetLeftShift + 왼쪽 ⇧ (왼쪽 Shift) + shortcuts.presetRightCommand + 오른쪽 ⌘ (오른쪽 Command) + shortcuts.presetRightControl + 오른쪽 ⌃ (오른쪽 Control) + shortcuts.presetRightOption + 오른쪽 ⌥ (오른쪽 Option) + shortcuts.presetRightShift + 오른쪽 ⇧ (오른쪽 Shift) + shortcuts.removeHelp + 이 매핑 제거 + shortcuts.shadowedHelp + 동일한 입력을 가진 이전 매핑에 가려졌습니다 + + diff --git a/apps/cmd-ime-swift/Sources/CmdIMESwift/Resources/vi.lproj/Localizable.strings b/apps/cmd-ime-swift/Sources/CmdIMESwift/Resources/vi.lproj/Localizable.strings new file mode 100644 index 0000000..70a253b --- /dev/null +++ b/apps/cmd-ime-swift/Sources/CmdIMESwift/Resources/vi.lproj/Localizable.strings @@ -0,0 +1,160 @@ + + + + + alert.openSystemSettings + Mở Cài đặt Hệ thống + alert.tapFailureBody + Mở Cài đặt Hệ thống → Quyền riêng tư & Bảo mật → Trợ năng, xóa ⌘IME nếu có trong danh sách, thêm lại, rồi khởi động lại ứng dụng. + alert.tapFailureTitle + ⌘IME không thể khởi động trình lắng nghe bàn phím + exclusions.addAppButton + Thêm ứng dụng… + exclusions.addAppHelp + Chọn một ứng dụng đã cài đặt để loại trừ + exclusions.description + ⌘IME sẽ không ánh xạ lại phím khi các ứng dụng này đang ở phía trước. + exclusions.emptyExcluded + Chưa có ứng dụng nào bị loại trừ. + exclusions.emptyRecent + Chuyển sang ứng dụng khác rồi quay lại để danh sách này được điền đầy đủ. + exclusions.excludedHeader + Đã loại trừ + exclusions.openPanelMessage + Chọn ứng dụng để loại trừ khỏi việc ánh xạ lại phím của ⌘IME + exclusions.openPanelPrompt + Thêm + exclusions.recentHeader + Gần đây đã dùng + general.aboutSection + Giới thiệu + general.checkForUpdatesOnLaunch + Kiểm tra cập nhật khi khởi động + general.checkNow + Kiểm tra ngay + general.githubLink + GitHub + general.inputSwitchingSection + Chuyển đổi nhập liệu + general.issuesLink + Issues + general.launchAtLogin + Mở khi đăng nhập + general.licenseFootnote + Giấy phép MIT · Dựa trên dự án gốc cmd-eikana của iMasanari + general.licenseLink + Giấy phép + general.modeOff + Tắt + general.modeOffDescription + Không tự động chuyển đổi. Nguồn nhập vẫn giữ nguyên khi bạn chuyển ứng dụng. + general.modePerApp + Theo ứng dụng + general.modePerAppDescription + Ghi nhớ và khôi phục nguồn nhập cho từng ứng dụng khi bạn chuyển đổi. + general.modePickerLabel + Chế độ + general.modeSmart + Thông minh + general.modeSmartDescription + Ngoài ghi nhớ theo ứng dụng, còn tự động chuyển sang nhập chữ và số trong thanh URL, số điện thoại, email và mã bưu điện. (Beta) + general.quitWithCmdQ + Thoát ⌘IME bằng ⌘Q + general.quitWithCmdQFootnote + Khi tắt, ⌘Q chỉ đóng cửa sổ này và ⌘IME vẫn chạy trên thanh menu. Bạn có thể thoát bất cứ lúc nào từ biểu tượng trên thanh menu. + general.showMenuBarIcon + Hiện biểu tượng trên thanh menu + general.versionFormat + Phiên bản %@ + general.versionShortFormat + v%@ + keyRecorder.cancel + Hủy + keyRecorder.placeholder + Nhấn một phím… + keyRecorder.save + Lưu + menu.checkForUpdates + Kiểm tra cập nhật… + menu.close + Đóng + menu.copy + Sao chép + menu.cut + Cắt + menu.edit + Chỉnh sửa + menu.minimize + Thu nhỏ + menu.paste + Dán + menu.preferencesTitle + ⌘IME %@ — Tùy chỉnh… + menu.quit + Thoát + menu.quitApp + Thoát ⌘IME + menu.restart + Khởi động lại + menu.selectAll + Chọn Tất Cả + menu.window + Cửa sổ + notification.updateAvailableBody + Phiên bản %@ đã sẵn sàng để cài đặt + notification.updateAvailableTitle + ⌘IME có bản cập nhật mới + settingsTabs.exclusions + Loại trừ + settingsTabs.general + Chung + settingsTabs.shortcuts + Phím tắt + shortcuts.actionColumnHeader + Hành động + shortcuts.actionDisableKey + Vô hiệu hóa phím + shortcuts.actionHelp + Chọn điều xảy ra khi phím này được nhấn + shortcuts.actionSwitchToAlphanumeric + Chuyển sang chữ và số + shortcuts.actionSwitchToKana + Chuyển sang Kana + shortcuts.addButton + Thêm + shortcuts.description + Phím: phím tắt cần chặn. Hành động: điều xảy ra khi bạn nhấn phím đó. + shortcuts.inputHelp + Chọn phím tắt cần chặn + shortcuts.inputPlaceholder + Đầu vào + shortcuts.keyColumnHeader + Phím + shortcuts.presetCapsLock + ⇪ (Caps Lock) + shortcuts.presetEisu + 英数 (Eisu / Alphanumeric) + shortcuts.presetKana + かな (Kana) + shortcuts.presetLeftCommand + ⌘ trái (Command trái) + shortcuts.presetLeftControl + ⌃ trái (Control trái) + shortcuts.presetLeftOption + ⌥ trái (Option trái) + shortcuts.presetLeftShift + ⇧ trái (Shift trái) + shortcuts.presetRightCommand + ⌘ phải (Command phải) + shortcuts.presetRightControl + ⌃ phải (Control phải) + shortcuts.presetRightOption + ⌥ phải (Option phải) + shortcuts.presetRightShift + ⇧ phải (Shift phải) + shortcuts.removeHelp + Xóa ánh xạ này + shortcuts.shadowedHelp + Bị che khuất bởi ánh xạ trước đó có cùng đầu vào + + diff --git a/apps/cmd-ime-swift/Sources/CmdIMESwift/Resources/zh-Hans.lproj/Localizable.strings b/apps/cmd-ime-swift/Sources/CmdIMESwift/Resources/zh-Hans.lproj/Localizable.strings new file mode 100644 index 0000000..87c8cd9 --- /dev/null +++ b/apps/cmd-ime-swift/Sources/CmdIMESwift/Resources/zh-Hans.lproj/Localizable.strings @@ -0,0 +1,160 @@ + + + + + alert.openSystemSettings + 打开系统设置 + alert.tapFailureBody + 打开系统设置 → 隐私与安全性 → 辅助功能,如果列表中有 ⌘IME,请先移除再重新添加,然后重新启动应用。 + alert.tapFailureTitle + ⌘IME 无法启动键盘监听 + exclusions.addAppButton + 添加应用… + exclusions.addAppHelp + 选择要排除的已安装应用 + exclusions.description + 当这些应用位于最前面时,⌘IME 不会重新映射按键。 + exclusions.emptyExcluded + 目前没有已排除的应用。 + exclusions.emptyRecent + 切换到其他应用后再返回,此列表将会自动填充。 + exclusions.excludedHeader + 已排除 + exclusions.openPanelMessage + 选择要从 ⌘IME 按键重映射中排除的应用 + exclusions.openPanelPrompt + 添加 + exclusions.recentHeader + 最近使用 + general.aboutSection + 关于 + general.checkForUpdatesOnLaunch + 启动时检查更新 + general.checkNow + 立即检查 + general.githubLink + GitHub + general.inputSwitchingSection + 输入切换 + general.issuesLink + 议题 + general.launchAtLogin + 登录时打开 + general.licenseFootnote + MIT 许可证 · 基于 iMasanari 的原始项目 cmd-eikana + general.licenseLink + 许可证 + general.modeOff + 关闭 + general.modeOffDescription + 不自动切换。切换应用时输入源保持不变。 + general.modePerApp + 按应用 + general.modePerAppDescription + 为每个应用记住并恢复输入源,切换时自动还原。 + general.modePickerLabel + 模式 + general.modeSmart + 智能 + general.modeSmartDescription + 在按应用记忆的基础上,还会在网址栏、电话、邮箱和邮编等输入框中自动切换为英数输入。(测试版) + general.quitWithCmdQ + 使用 ⌘Q 退出 ⌘IME + general.quitWithCmdQFootnote + 关闭时,⌘Q 仅关闭此窗口,⌘IME 会继续在菜单栏中运行。您可以随时从菜单栏图标退出。 + general.showMenuBarIcon + 在菜单栏中显示图标 + general.versionFormat + 版本 %@ + general.versionShortFormat + v%@ + keyRecorder.cancel + 取消 + keyRecorder.placeholder + 请按下一个键… + keyRecorder.save + 存储 + menu.checkForUpdates + 检查更新… + menu.close + 关闭 + menu.copy + 拷贝 + menu.cut + 剪切 + menu.edit + 编辑 + menu.minimize + 缩小 + menu.paste + 粘贴 + menu.preferencesTitle + ⌘IME %@ — 偏好设置… + menu.quit + 退出 + menu.quitApp + 退出 ⌘IME + menu.restart + 重新启动 + menu.selectAll + 全选 + menu.window + 窗口 + notification.updateAvailableBody + 版本 %@ 已可安装 + notification.updateAvailableTitle + ⌘IME 有可用更新 + settingsTabs.exclusions + 排除项 + settingsTabs.general + 通用 + settingsTabs.shortcuts + 快捷键 + shortcuts.actionColumnHeader + 动作 + shortcuts.actionDisableKey + 禁用按键 + shortcuts.actionHelp + 选择按下此键时执行的操作 + shortcuts.actionSwitchToAlphanumeric + 切换到英数 + shortcuts.actionSwitchToKana + 切换到假名 + shortcuts.addButton + 添加 + shortcuts.description + 按键:要拦截的快捷键。动作:按下该键时执行的操作。 + shortcuts.inputHelp + 选择要拦截的快捷键 + shortcuts.inputPlaceholder + 输入 + shortcuts.keyColumnHeader + 按键 + shortcuts.presetCapsLock + ⇪ (大写锁定) + shortcuts.presetEisu + 英数 (Eisu / Alphanumeric) + shortcuts.presetKana + かな (Kana) + shortcuts.presetLeftCommand + 左⌘ (左 Command) + shortcuts.presetLeftControl + 左⌃ (左 Control) + shortcuts.presetLeftOption + 左⌥ (左 Option) + shortcuts.presetLeftShift + 左⇧ (左 Shift) + shortcuts.presetRightCommand + 右⌘ (右 Command) + shortcuts.presetRightControl + 右⌃ (右 Control) + shortcuts.presetRightOption + 右⌥ (右 Option) + shortcuts.presetRightShift + 右⇧ (右 Shift) + shortcuts.removeHelp + 移除此映射 + shortcuts.shadowedHelp + 被具有相同输入的更早映射所覆盖 + + diff --git a/apps/cmd-ime-swift/Sources/CmdIMESwift/Resources/zh-Hant.lproj/Localizable.strings b/apps/cmd-ime-swift/Sources/CmdIMESwift/Resources/zh-Hant.lproj/Localizable.strings new file mode 100644 index 0000000..753ca4e --- /dev/null +++ b/apps/cmd-ime-swift/Sources/CmdIMESwift/Resources/zh-Hant.lproj/Localizable.strings @@ -0,0 +1,160 @@ + + + + + alert.openSystemSettings + 打開系統設定 + alert.tapFailureBody + 打開系統設定 → 隱私權與安全性 → 輔助使用,如果清單中有 ⌘IME,請先移除再重新加入,然後重新啟動應用程式。 + alert.tapFailureTitle + ⌘IME 無法啟動鍵盤監聽 + exclusions.addAppButton + 加入應用程式… + exclusions.addAppHelp + 選擇要排除的已安裝應用程式 + exclusions.description + 當這些應用程式在最前面時,⌘IME 不會重新對應按鍵。 + exclusions.emptyExcluded + 目前沒有已排除的應用程式。 + exclusions.emptyRecent + 切換到其他應用程式後再返回,此清單就會自動顯示內容。 + exclusions.excludedHeader + 已排除 + exclusions.openPanelMessage + 選擇要從 ⌘IME 按鍵重新對應中排除的應用程式 + exclusions.openPanelPrompt + 加入 + exclusions.recentHeader + 最近使用 + general.aboutSection + 關於 + general.checkForUpdatesOnLaunch + 啟動時檢查更新 + general.checkNow + 立即檢查 + general.githubLink + GitHub + general.inputSwitchingSection + 輸入切換 + general.issuesLink + 議題 + general.launchAtLogin + 登入時打開 + general.licenseFootnote + MIT 授權條款 · 基於 iMasanari 的原始專案 cmd-eikana + general.licenseLink + 授權條款 + general.modeOff + 關閉 + general.modeOffDescription + 不會自動切換。切換應用程式時輸入來源會維持不變。 + general.modePerApp + 依應用程式 + general.modePerAppDescription + 為每個應用程式記住並還原輸入來源,切換時自動套用。 + general.modePickerLabel + 模式 + general.modeSmart + 智慧 + general.modeSmartDescription + 在依應用程式記憶的基礎上,也會在網址列、電話、電子郵件與郵遞區號欄位自動切換為英數輸入。(Beta) + general.quitWithCmdQ + 使用 ⌘Q 退出 ⌘IME + general.quitWithCmdQFootnote + 關閉時,⌘Q 僅會關閉此視窗,⌘IME 會繼續在選單列中執行。您可以隨時從選單列圖示退出。 + general.showMenuBarIcon + 在選單列中顯示圖示 + general.versionFormat + 版本 %@ + general.versionShortFormat + v%@ + keyRecorder.cancel + 取消 + keyRecorder.placeholder + 請按下一個按鍵… + keyRecorder.save + 儲存 + menu.checkForUpdates + 檢查更新… + menu.close + 關閉 + menu.copy + 拷貝 + menu.cut + 剪下 + menu.edit + 編輯 + menu.minimize + 縮小 + menu.paste + 貼上 + menu.preferencesTitle + ⌘IME %@ — 偏好設定… + menu.quit + 退出 + menu.quitApp + 退出 ⌘IME + menu.restart + 重新啟動 + menu.selectAll + 全選 + menu.window + 視窗 + notification.updateAvailableBody + 版本 %@ 已可安裝 + notification.updateAvailableTitle + ⌘IME 有可用更新 + settingsTabs.exclusions + 排除項目 + settingsTabs.general + 一般 + settingsTabs.shortcuts + 快捷鍵 + shortcuts.actionColumnHeader + 動作 + shortcuts.actionDisableKey + 停用按鍵 + shortcuts.actionHelp + 選擇按下此按鍵時執行的操作 + shortcuts.actionSwitchToAlphanumeric + 切換到英數 + shortcuts.actionSwitchToKana + 切換到假名 + shortcuts.addButton + 加入 + shortcuts.description + 按鍵:要攔截的快捷鍵。動作:按下該鍵時執行的操作。 + shortcuts.inputHelp + 選擇要攔截的快捷鍵 + shortcuts.inputPlaceholder + 輸入 + shortcuts.keyColumnHeader + 按鍵 + shortcuts.presetCapsLock + ⇪ (大寫鎖定) + shortcuts.presetEisu + 英数 (Eisu / Alphanumeric) + shortcuts.presetKana + かな (Kana) + shortcuts.presetLeftCommand + 左⌘ (左 Command) + shortcuts.presetLeftControl + 左⌃ (左 Control) + shortcuts.presetLeftOption + 左⌥ (左 Option) + shortcuts.presetLeftShift + 左⇧ (左 Shift) + shortcuts.presetRightCommand + 右⌘ (右 Command) + shortcuts.presetRightControl + 右⌃ (右 Control) + shortcuts.presetRightOption + 右⌥ (右 Option) + shortcuts.presetRightShift + 右⇧ (右 Shift) + shortcuts.removeHelp + 移除此對應 + shortcuts.shadowedHelp + 被具有相同輸入的較早對應項目所覆蓋 + + diff --git a/apps/cmd-ime-swift/Sources/CmdIMESwift/Settings/AppSettings.swift b/apps/cmd-ime-swift/Sources/CmdIMESwift/Settings/AppSettings.swift index 06aa7f1..3ffb97f 100644 --- a/apps/cmd-ime-swift/Sources/CmdIMESwift/Settings/AppSettings.swift +++ b/apps/cmd-ime-swift/Sources/CmdIMESwift/Settings/AppSettings.swift @@ -37,6 +37,7 @@ final class AppSettings: ObservableObject { } private let defaults: UserDefaults + private let loginItemService: LoginItemService @Published var launchAtStartup: Bool @Published var showMenuBarIcon: Bool @@ -49,8 +50,9 @@ final class AppSettings: ObservableObject { private var cancellables: Set = [] private var isApplyingExternalUpdate = false - init(defaults: UserDefaults = .standard) { + init(defaults: UserDefaults = .standard, loginItemService: LoginItemService = SMAppService.mainApp) { self.defaults = defaults + self.loginItemService = loginItemService Self.migrateLegacyKeys(in: defaults) @@ -60,7 +62,7 @@ final class AppSettings: ObservableObject { self.quitOnCommandQ = (defaults.object(forKey: Keys.quitOnCommandQ) as? Int ?? 0) != 0 let stored = (defaults.object(forKey: Keys.launchAtStartup) as? Int ?? 0) != 0 - let serviceEnabled = SMAppService.mainApp.status == .enabled + let serviceEnabled = loginItemService.status == .enabled self.launchAtStartup = stored || serviceEnabled self.keyMappings = Self.loadKeyMappings(from: defaults) @@ -74,13 +76,13 @@ final class AppSettings: ObservableObject { /// Call once on app launch to reconcile any drift between SMAppService /// (the OS-side login item registry) and our stored toggle. func bootstrap() { - let serviceEnabled = SMAppService.mainApp.status == .enabled + let serviceEnabled = loginItemService.status == .enabled if launchAtStartup && !serviceEnabled { // Stored intent is "on" but the OS is not enabled. Which way to // resolve this depends on *why* the service isn't enabled, so // switch on the actual status instead of treating every // non-enabled state the same: - switch SMAppService.mainApp.status { + switch loginItemService.status { case .requiresApproval: // The item IS registered with SMAppService, but the user // disabled it (or hasn't approved it yet) in System @@ -105,7 +107,7 @@ final class AppSettings: ObservableObject { // SMAppService registration. Honor the stored intent and // register now — this restores the pre-fix behavior for // this specific case only. - setLaunchAtStartup(true) + setLaunchAtStartup(true, service: loginItemService) } } else if !launchAtStartup && serviceEnabled { // OS already registered us (e.g. enabled in System Settings) but @@ -163,7 +165,7 @@ final class AppSettings: ObservableObject { .sink { [weak self] newValue in guard let self = self, !self.isApplyingExternalUpdate else { return } self.defaults.set(newValue ? 1 : 0, forKey: Keys.launchAtStartup) - if !setLaunchAtStartup(newValue) { + if !setLaunchAtStartup(newValue, service: self.loginItemService) { self.isApplyingExternalUpdate = true self.launchAtStartup = !newValue self.defaults.set(!newValue ? 1 : 0, forKey: Keys.launchAtStartup) diff --git a/apps/cmd-ime-swift/Sources/CmdIMESwift/Settings/ExclusionsSettingsView.swift b/apps/cmd-ime-swift/Sources/CmdIMESwift/Settings/ExclusionsSettingsView.swift index 7330076..cf517ab 100644 --- a/apps/cmd-ime-swift/Sources/CmdIMESwift/Settings/ExclusionsSettingsView.swift +++ b/apps/cmd-ime-swift/Sources/CmdIMESwift/Settings/ExclusionsSettingsView.swift @@ -14,13 +14,13 @@ struct ExclusionsSettingsView: View { var body: some View { VStack(alignment: .leading, spacing: 16) { - Text("⌘IME will not remap keys when these apps are frontmost.") + Text(L("exclusions.description")) .font(.callout) .foregroundStyle(.secondary) - sectionHeader("Excluded") + sectionHeader(L("exclusions.excludedHeader")) if settings.exclusionApps.isEmpty { - emptyRow("No excluded apps yet.") + emptyRow(L("exclusions.emptyExcluded")) } else { List { ForEach(settings.exclusionApps) { app in @@ -47,15 +47,15 @@ struct ExclusionsSettingsView: View { } HStack { - sectionHeader("Recently active") + sectionHeader(L("exclusions.recentHeader")) Spacer() - Button("Add App…") { browseForApp() } + Button(L("exclusions.addAppButton")) { browseForApp() } .buttonStyle(.borderless) .font(.callout) - .help("Choose any installed app to exclude") + .help(Text(L("exclusions.addAppHelp"))) } if recentApps.isEmpty { - emptyRow("Switch to another app and come back to populate this list.") + emptyRow(L("exclusions.emptyRecent")) } else { List { ForEach(recentApps, id: \.id) { app in @@ -92,8 +92,8 @@ struct ExclusionsSettingsView: View { panel.canChooseDirectories = false panel.canChooseFiles = true panel.allowedContentTypes = [UTType(filenameExtension: "app") ?? .data] - panel.message = "Choose apps to exclude from ⌘IME key remapping" - panel.prompt = "Add" + panel.message = L("exclusions.openPanelMessage") + panel.prompt = L("exclusions.openPanelPrompt") if panel.runModal() == .OK { for url in panel.urls { guard let bundle = Bundle(url: url), diff --git a/apps/cmd-ime-swift/Sources/CmdIMESwift/Settings/GeneralSettingsView.swift b/apps/cmd-ime-swift/Sources/CmdIMESwift/Settings/GeneralSettingsView.swift index 1869e5a..2fcbba5 100644 --- a/apps/cmd-ime-swift/Sources/CmdIMESwift/Settings/GeneralSettingsView.swift +++ b/apps/cmd-ime-swift/Sources/CmdIMESwift/Settings/GeneralSettingsView.swift @@ -15,59 +15,60 @@ struct GeneralSettingsView: View { var body: some View { Form { Section { - Toggle("Launch at login", isOn: $settings.launchAtStartup) - Toggle("Show menu bar icon", isOn: $settings.showMenuBarIcon) - Toggle("Quit ⌘IME with ⌘Q", isOn: $settings.quitOnCommandQ) - Text("When off, ⌘Q just closes this window and ⌘IME keeps running " - + "in the menu bar. You can quit anytime from the menu bar icon.") + Toggle(L("general.launchAtLogin"), isOn: $settings.launchAtStartup) + Toggle(L("general.showMenuBarIcon"), isOn: $settings.showMenuBarIcon) + Toggle(L("general.quitWithCmdQ"), isOn: $settings.quitOnCommandQ) + Text(L("general.quitWithCmdQFootnote")) .font(.caption) .foregroundStyle(.secondary) } Section { - Toggle("Check for updates on launch", isOn: $settings.checkUpdateAtLaunch) + Toggle(L("general.checkForUpdatesOnLaunch"), isOn: $settings.checkUpdateAtLaunch) HStack { - Button("Check Now") { + Button(L("general.checkNow")) { (NSApp.delegate as? AppDelegate)?.updaterController.updater.checkForUpdates() } Spacer() - Text("Version \(version)").foregroundStyle(.secondary) + Text(String(format: L("general.versionFormat"), version)).foregroundStyle(.secondary) } } - Section("Input Switching") { - Picker("Mode", selection: $settings.switchingMode) { - Text("Off").tag(AppSettings.SwitchingMode.global) - Text("Per app").tag(AppSettings.SwitchingMode.perApp) - Text("Smart").tag(AppSettings.SwitchingMode.smart) + Section(L("general.inputSwitchingSection")) { + Picker(L("general.modePickerLabel"), selection: $settings.switchingMode) { + Text(L("general.modeOff")).tag(AppSettings.SwitchingMode.global) + Text(L("general.modePerApp")).tag(AppSettings.SwitchingMode.perApp) + Text(L("general.modeSmart")).tag(AppSettings.SwitchingMode.smart) } .pickerStyle(.segmented) Group { switch settings.switchingMode { case .global: - Text("No automatic switching. Input source stays as-is when you switch apps.") + Text(L("general.modeOffDescription")) case .perApp: - Text("Remembers and restores the input source for each app when you switch.") + Text(L("general.modePerAppDescription")) case .smart: - Text("Per-app memory plus auto-switch to alphanumeric in URL bars, phone, email, and ZIP fields. (Beta)") + Text(L("general.modeSmartDescription")) } } .font(.caption) .foregroundStyle(.secondary) } - Section("About") { + Section(L("general.aboutSection")) { HStack(spacing: 8) { Text("⌘IME").fontWeight(.semibold) - Text("v\(version)").foregroundStyle(.secondary) + Text(String(format: L("general.versionShortFormat"), version)).foregroundStyle(.secondary) Spacer() - Link("GitHub", destination: URL(string: "https://github.com/agiletec-inc/cmd-ime")!) + Link(L("general.githubLink"), destination: URL(string: "https://github.com/agiletec-inc/cmd-ime")!) Text("·").foregroundStyle(.secondary) - Link("Issues", destination: URL(string: "https://github.com/agiletec-inc/cmd-ime/issues")!) + Link(L("general.issuesLink"), + destination: URL(string: "https://github.com/agiletec-inc/cmd-ime/issues")!) Text("·").foregroundStyle(.secondary) - Link("License", destination: URL(string: "https://github.com/agiletec-inc/cmd-ime/blob/main/LICENSE")!) + Link(L("general.licenseLink"), + destination: URL(string: "https://github.com/agiletec-inc/cmd-ime/blob/main/LICENSE")!) } - Text("MIT License · Based on the original cmd-eikana by iMasanari") + Text(L("general.licenseFootnote")) .font(.caption) .foregroundStyle(.secondary) } diff --git a/apps/cmd-ime-swift/Sources/CmdIMESwift/Settings/KeyRecorderSheet.swift b/apps/cmd-ime-swift/Sources/CmdIMESwift/Settings/KeyRecorderSheet.swift index 1608d7c..dcd8152 100644 --- a/apps/cmd-ime-swift/Sources/CmdIMESwift/Settings/KeyRecorderSheet.swift +++ b/apps/cmd-ime-swift/Sources/CmdIMESwift/Settings/KeyRecorderSheet.swift @@ -39,7 +39,7 @@ struct KeyRecorderSheet: View { VStack(spacing: 20) { Text(title).font(.headline) - Text(captured.toString().isEmpty ? "Press a key…" : captured.toString()) + Text(captured.toString().isEmpty ? L("keyRecorder.placeholder") : captured.toString()) .font(.system(size: 32, weight: .medium, design: .rounded)) .frame(maxWidth: .infinity, minHeight: 80) .background( @@ -48,10 +48,10 @@ struct KeyRecorderSheet: View { ) HStack { - Button("Cancel") { dismiss() } + Button(L("keyRecorder.cancel")) { dismiss() } .keyboardShortcut(.cancelAction) Spacer() - Button("Save") { + Button(L("keyRecorder.save")) { onCommit(captured) dismiss() } diff --git a/apps/cmd-ime-swift/Sources/CmdIMESwift/Settings/SettingsView.swift b/apps/cmd-ime-swift/Sources/CmdIMESwift/Settings/SettingsView.swift index 35bf1d8..f25f4f0 100644 --- a/apps/cmd-ime-swift/Sources/CmdIMESwift/Settings/SettingsView.swift +++ b/apps/cmd-ime-swift/Sources/CmdIMESwift/Settings/SettingsView.swift @@ -15,9 +15,9 @@ struct SettingsView: View { var label: String { switch self { - case .general: return "General" - case .shortcuts: return "Shortcuts" - case .exclusions: return "Exclusions" + case .general: return L("settingsTabs.general") + case .shortcuts: return L("settingsTabs.shortcuts") + case .exclusions: return L("settingsTabs.exclusions") } } diff --git a/apps/cmd-ime-swift/Sources/CmdIMESwift/Settings/ShortcutsSettingsView.swift b/apps/cmd-ime-swift/Sources/CmdIMESwift/Settings/ShortcutsSettingsView.swift index f2f57f4..b1a8dd9 100644 --- a/apps/cmd-ime-swift/Sources/CmdIMESwift/Settings/ShortcutsSettingsView.swift +++ b/apps/cmd-ime-swift/Sources/CmdIMESwift/Settings/ShortcutsSettingsView.swift @@ -10,41 +10,41 @@ struct ShortcutsSettingsView: View { // Common input keys — includes IME-only keys that can't be recorded on English keyboards. private static let inputPresets: [(label: String, shortcut: KeyboardShortcut)] = [ - ("Left ⌘ (Left Command)", KeyboardShortcut(keyCode: 55)), - ("Right ⌘ (Right Command)", KeyboardShortcut(keyCode: 54)), - ("英数 (Eisu / Alphanumeric)", KeyboardShortcut(keyCode: 102)), - ("かな (Kana)", KeyboardShortcut(keyCode: 104)), - ("⇪ (Caps Lock)", KeyboardShortcut(keyCode: 57)), - ("Left ⇧ (Left Shift)", KeyboardShortcut(keyCode: 56)), - ("Right ⇧ (Right Shift)", KeyboardShortcut(keyCode: 60)), - ("Left ⌥ (Left Option)", KeyboardShortcut(keyCode: 58)), - ("Right ⌥ (Right Option)", KeyboardShortcut(keyCode: 61)), - ("Left ⌃ (Left Control)", KeyboardShortcut(keyCode: 59)), - ("Right ⌃ (Right Control)", KeyboardShortcut(keyCode: 62)), + (L("shortcuts.presetLeftCommand"), KeyboardShortcut(keyCode: 55)), + (L("shortcuts.presetRightCommand"), KeyboardShortcut(keyCode: 54)), + (L("shortcuts.presetEisu"), KeyboardShortcut(keyCode: 102)), + (L("shortcuts.presetKana"), KeyboardShortcut(keyCode: 104)), + (L("shortcuts.presetCapsLock"), KeyboardShortcut(keyCode: 57)), + (L("shortcuts.presetLeftShift"), KeyboardShortcut(keyCode: 56)), + (L("shortcuts.presetRightShift"), KeyboardShortcut(keyCode: 60)), + (L("shortcuts.presetLeftOption"), KeyboardShortcut(keyCode: 58)), + (L("shortcuts.presetRightOption"), KeyboardShortcut(keyCode: 61)), + (L("shortcuts.presetLeftControl"), KeyboardShortcut(keyCode: 59)), + (L("shortcuts.presetRightControl"), KeyboardShortcut(keyCode: 62)), ] private static let actionPresets: [(label: String, shortcut: KeyboardShortcut)] = [ - ("Switch to Alphanumeric", KeyboardShortcut(keyCode: 102)), - ("Switch to Kana", KeyboardShortcut(keyCode: 104)), - ("Disable key", KeyboardShortcut(keyCode: 999)), + (L("shortcuts.actionSwitchToAlphanumeric"), KeyboardShortcut(keyCode: 102)), + (L("shortcuts.actionSwitchToKana"), KeyboardShortcut(keyCode: 104)), + (L("shortcuts.actionDisableKey"), KeyboardShortcut(keyCode: 999)), ] var body: some View { VStack(spacing: 8) { - Text("Key: the hotkey to intercept. Action: what happens when you press it.") + Text(L("shortcuts.description")) .font(.caption) .foregroundStyle(.secondary) .frame(maxWidth: .infinity, alignment: .leading) List { HStack(spacing: 12) { - Text("Key") + Text(L("shortcuts.keyColumnHeader")) .font(.caption.weight(.semibold)) .foregroundStyle(.secondary) .frame(minWidth: 120, alignment: .leading) .padding(.horizontal, 8) Spacer().frame(width: 16) - Text("Action") + Text(L("shortcuts.actionColumnHeader")) .font(.caption.weight(.semibold)) .foregroundStyle(.secondary) .frame(minWidth: 120, alignment: .leading) @@ -62,7 +62,7 @@ struct ShortcutsSettingsView: View { if Self.isShadowed(settings.keyMappings, at: index) { Image(systemName: "exclamationmark.triangle") .foregroundStyle(.orange) - .help("Shadowed by an earlier mapping with the same input") + .help(Text(L("shortcuts.shadowedHelp"))) } Spacer() Button(role: .destructive) { @@ -71,7 +71,7 @@ struct ShortcutsSettingsView: View { Image(systemName: "minus.circle") } .buttonStyle(.borderless) - .help("Remove this mapping") + .help(Text(L("shortcuts.removeHelp"))) } .padding(.vertical, 4) } @@ -83,7 +83,7 @@ struct ShortcutsSettingsView: View { Button { settings.addKeyMapping() } label: { - Label("Add", systemImage: "plus") + Label(L("shortcuts.addButton"), systemImage: "plus") } Spacer() } @@ -106,7 +106,7 @@ struct ShortcutsSettingsView: View { } } label: { HStack(spacing: 6) { - Text(label.isEmpty ? "Input" : label) + Text(label.isEmpty ? L("shortcuts.inputPlaceholder") : label) .frame(maxWidth: .infinity, alignment: .leading) .foregroundStyle(label.isEmpty ? Color.secondary : Color.primary) Image(systemName: "chevron.up.chevron.down") @@ -116,7 +116,7 @@ struct ShortcutsSettingsView: View { .cellStyle() } .menuStyle(.borderlessButton) - .help("Choose the hotkey to intercept") + .help(Text(L("shortcuts.inputHelp"))) } @ViewBuilder @@ -145,12 +145,12 @@ struct ShortcutsSettingsView: View { .cellStyle() } .menuStyle(.borderlessButton) - .help("Choose what happens when this key is pressed") + .help(Text(L("shortcuts.actionHelp"))) } private func actionLabel(for shortcut: KeyboardShortcut) -> String { Self.actionPresets.first(where: { $0.shortcut.keyCode == shortcut.keyCode })?.label - ?? (shortcut.toString().isEmpty ? "Action" : shortcut.toString()) + ?? (shortcut.toString().isEmpty ? L("shortcuts.actionColumnHeader") : shortcut.toString()) } /// True when `mappings[index]` is enabled and an earlier enabled row has diff --git a/apps/cmd-ime-swift/Sources/CmdIMESwift/toggleLaunchAtStartup.swift b/apps/cmd-ime-swift/Sources/CmdIMESwift/toggleLaunchAtStartup.swift index 21b3a3c..ba58598 100644 --- a/apps/cmd-ime-swift/Sources/CmdIMESwift/toggleLaunchAtStartup.swift +++ b/apps/cmd-ime-swift/Sources/CmdIMESwift/toggleLaunchAtStartup.swift @@ -10,9 +10,20 @@ import Cocoa import ServiceManagement +/// Seam over `SMAppService.mainApp` so tests can inject a fake instead of +/// hitting the real OS-level login item registry. `SMAppService` already has +/// matching `status`/`register()`/`unregister()` members, so it conforms for +/// free — see the extension below. +protocol LoginItemService { + var status: SMAppService.Status { get } + func register() throws + func unregister() throws +} + +extension SMAppService: LoginItemService {} + @discardableResult -func setLaunchAtStartup(_ enabled: Bool) -> Bool { - let service = SMAppService.mainApp +func setLaunchAtStartup(_ enabled: Bool, service: LoginItemService = SMAppService.mainApp) -> Bool { do { if enabled { guard service.status != .enabled else { return true } diff --git a/apps/cmd-ime-swift/Tests/CmdIMESwiftTests/AppSettingsTests.swift b/apps/cmd-ime-swift/Tests/CmdIMESwiftTests/AppSettingsTests.swift index 7e2cfd6..fdbf2dc 100644 --- a/apps/cmd-ime-swift/Tests/CmdIMESwiftTests/AppSettingsTests.swift +++ b/apps/cmd-ime-swift/Tests/CmdIMESwiftTests/AppSettingsTests.swift @@ -156,22 +156,38 @@ final class AppSettingsTests: XCTestCase { } func testBootstrapWithStoredTrueAndServiceNotFoundRegistersInstead() { - // SMAppService.Status isn't injectable here, so this only exercises - // whatever status the real API reports for the `swift test` process - // (an unsigned, unregistered binary — not a proper .app bundle). - // That is reliably `.notFound` (confirmed empirically), which is the - // "never registered" branch: bootstrap() must honor the stored - // intent and attempt to register, NOT flip the toggle off. The - // `.requiresApproval` branch (registered but user-disabled) isn't - // reachable from this test process and has no automated coverage. + // Never-registered case (.notFound, the real status a bare + // `swift test` process reported before this test used a fake — see + // FakeLoginItemService): bootstrap() must honor the stored intent + // and attempt to register, NOT flip the toggle off. + let fakeService = FakeLoginItemService(status: .notFound) defaults.set(1, forKey: "launchAtStartup") - let settings = AppSettings(defaults: defaults) + let settings = AppSettings(defaults: defaults, loginItemService: fakeService) XCTAssertTrue(settings.launchAtStartup, "precondition: stored value loaded as on") settings.bootstrap() XCTAssertTrue(settings.launchAtStartup, "never-registered case must honor stored intent, not follow OS off") XCTAssertEqual(defaults.object(forKey: "launchAtStartup") as? Int, 1) + XCTAssertEqual(fakeService.registerCallCount, 1, "bootstrap() must register the never-registered case") + } + + func testBootstrapWithStoredTrueAndRequiresApprovalFollowsOSStateInstead() { + // Registered but user-disabled (or not yet approved) in System + // Settings -> Login Items: bootstrap() must defer to the OS state, + // not force re-registration behind the user's back. This branch was + // previously unreachable from a real `swift test` process (see + // git history) and had no automated coverage. + let fakeService = FakeLoginItemService(status: .requiresApproval) + defaults.set(1, forKey: "launchAtStartup") + let settings = AppSettings(defaults: defaults, loginItemService: fakeService) + XCTAssertTrue(settings.launchAtStartup, "precondition: stored value loaded as on") + + settings.bootstrap() + + XCTAssertFalse(settings.launchAtStartup, "requiresApproval case must follow the OS state, not stored intent") + XCTAssertEqual(defaults.object(forKey: "launchAtStartup") as? Int, 0) + XCTAssertEqual(fakeService.registerCallCount, 0, "must not force re-registration behind the user's back") } func testExclusionMutationsPropagateToGlobals() { diff --git a/apps/cmd-ime-swift/Tests/CmdIMESwiftTests/FakeLoginItemService.swift b/apps/cmd-ime-swift/Tests/CmdIMESwiftTests/FakeLoginItemService.swift new file mode 100644 index 0000000..4a31ed0 --- /dev/null +++ b/apps/cmd-ime-swift/Tests/CmdIMESwiftTests/FakeLoginItemService.swift @@ -0,0 +1,32 @@ +import ServiceManagement +@testable import CmdIMESwift + +/// In-memory stand-in for `SMAppService.mainApp` (see LoginItemService in +/// toggleLaunchAtStartup.swift). Tests must never touch the real OS-level +/// login item registry: a prior bug had a test call the real API, which +/// registered the `xctest` test-runner binary itself as a login item on the +/// developer's Mac (see CLAUDE.md and AppSettingsTests.swift for the +/// incident this class exists to prevent). +final class FakeLoginItemService: LoginItemService { + var status: SMAppService.Status + private(set) var registerCallCount = 0 + private(set) var unregisterCallCount = 0 + var registerError: Error? + var unregisterError: Error? + + init(status: SMAppService.Status = .notRegistered) { + self.status = status + } + + func register() throws { + registerCallCount += 1 + if let registerError { throw registerError } + status = .enabled + } + + func unregister() throws { + unregisterCallCount += 1 + if let unregisterError { throw unregisterError } + status = .notRegistered + } +} diff --git a/apps/cmd-ime-swift/Tests/CmdIMESwiftTests/LocalizationCatalogTests.swift b/apps/cmd-ime-swift/Tests/CmdIMESwiftTests/LocalizationCatalogTests.swift new file mode 100644 index 0000000..243b75a --- /dev/null +++ b/apps/cmd-ime-swift/Tests/CmdIMESwiftTests/LocalizationCatalogTests.swift @@ -0,0 +1,149 @@ +import XCTest +@testable import CmdIMESwift + +/// Verifies `Localizable.xcstrings` (the source of truth, shipped alongside the +/// compiled resources for reference) and its compiled `.lproj/ +/// Localizable.strings` tables (what `L(_:)` actually looks up at runtime via +/// `Bundle.module`, see Localization.swift) stay complete and in sync. +/// +/// `String(localized:bundle:locale:)`'s `locale:` parameter does not override +/// `Bundle.module`'s resolved localization on this toolchain — it always +/// follows `Bundle.preferredLocalizations` (confirmed empirically: passing +/// `locale: Locale(identifier: "en")` on a Japanese-locale machine still +/// returned the Japanese value). So this reads each locale's compiled table +/// directly via `Bundle.module.path(forResource:forLocalization:)` instead, +/// which does select per locale correctly, and is exactly what ships in the +/// app. +final class LocalizationCatalogTests: XCTestCase { + static let supportedLocales = ["en", "ja", "zh-Hans", "zh-Hant", "ko", "vi"] + + /// Sanity check on the actual production entry point (`L(_:)`): it must + /// resolve to a real translated string, not silently fall back to + /// returning the raw key (which is what a missing/unbundled resource + /// looks like, and what the earlier Bundle.main-vs-Bundle.module bug + /// this file's mechanism replaces would have produced). + func testLHelperResolvesAKnownKeyToATranslatedValue() { + let resolved = L("general.launchAtLogin") + XCTAssertNotEqual(resolved, "general.launchAtLogin", "L(_:) must not return the raw catalog key") + XCTAssertFalse(resolved.isEmpty) + } + + func testAllCatalogKeysResolveInEverySupportedLocale() throws { + let sourceKeys = try loadSourceCatalogKeys() + XCTAssertFalse(sourceKeys.isEmpty, "the source catalog should not be empty") + + for locale in Self.supportedLocales { + let table = try loadCompiledTable(for: locale) + for key in sourceKeys.sorted() { + guard let value = table[key] else { + XCTFail("\(locale): missing translation for key \"\(key)\"") + continue + } + XCTAssertFalse(value.isEmpty, "\(locale): empty translation for key \"\(key)\"") + } + } + } + + func testCompiledTablesHaveNoKeysBeyondTheSourceCatalog() throws { + // Catches stale keys left in a compiled table after a key was renamed + // or removed from the source catalog but the compiled output wasn't + // regenerated (see README/CLAUDE.md for the regeneration command). + let sourceKeys = try loadSourceCatalogKeys() + + for locale in Self.supportedLocales { + let table = try loadCompiledTable(for: locale) + let extraKeys = Set(table.keys).subtracting(sourceKeys) + XCTAssertTrue(extraKeys.isEmpty, + "\(locale): compiled table has stale keys not in the source catalog: \(extraKeys)") + } + } + + func testCompiledTablesMatchAFreshCompileOfTheSourceCatalog() throws { + // Detects drift: someone edited Localizable.xcstrings without + // re-running xcstringstool compile to refresh the committed + // Resources/.lproj output (see CLAUDE.md for the command). + guard let xcstringsToolPath = findXcstringsTool() else { + throw XCTSkip("xcstringstool not available in this environment") + } + guard let sourceURL = Bundle.module.url(forResource: "Localizable", withExtension: "xcstrings") else { + throw XCTSkip("Localizable.xcstrings not bundled") + } + + let tempDir = FileManager.default.temporaryDirectory + .appendingPathComponent("cmdime-xcstrings-drift-\(UUID().uuidString)") + try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: tempDir) } + + let process = Process() + process.executableURL = URL(fileURLWithPath: xcstringsToolPath) + process.arguments = [ + "compile", sourceURL.path, + "--output-directory", tempDir.path, + "--serialization-format", "text" + ] + try process.run() + process.waitUntilExit() + guard process.terminationStatus == 0 else { + throw XCTSkip("xcstringstool compile failed in this environment (status \(process.terminationStatus))") + } + + for locale in Self.supportedLocales { + let freshPath = tempDir.appendingPathComponent("\(locale).lproj/Localizable.strings").path + guard let freshDict = NSDictionary(contentsOfFile: freshPath) as? [String: String] else { + XCTFail("\(locale): fresh compile did not produce a readable table") + continue + } + let committed = try loadCompiledTable(for: locale) + XCTAssertEqual(committed, freshDict, + "\(locale): committed Resources/\(locale).lproj/Localizable.strings is stale — " + + "re-run xcstringstool compile on Localizable.xcstrings") + } + } + + // MARK: - Helpers + + private func loadSourceCatalogKeys() throws -> Set { + guard let url = Bundle.module.url(forResource: "Localizable", withExtension: "xcstrings") else { + throw XCTSkip("Localizable.xcstrings not bundled") + } + let data = try Data(contentsOf: url) + guard let json = try JSONSerialization.jsonObject(with: data) as? [String: Any], + let strings = json["strings"] as? [String: Any] else { + XCTFail("Localizable.xcstrings did not parse as expected") + return [] + } + return Set(strings.keys) + } + + private func loadCompiledTable(for locale: String) throws -> [String: String] { + guard let path = Bundle.module.path( + forResource: "Localizable", ofType: "strings", inDirectory: nil, forLocalization: locale + ) else { + XCTFail("\(locale): no compiled Localizable.strings found") + return [:] + } + guard let dict = NSDictionary(contentsOfFile: path) as? [String: String] else { + XCTFail("\(locale): Localizable.strings did not parse as a string table") + return [:] + } + return dict + } + + private func findXcstringsTool() -> String? { + let process = Process() + process.executableURL = URL(fileURLWithPath: "/usr/bin/xcrun") + process.arguments = ["--find", "xcstringstool"] + let pipe = Pipe() + process.standardOutput = pipe + do { + try process.run() + } catch { + return nil + } + process.waitUntilExit() + guard process.terminationStatus == 0 else { return nil } + let output = String(data: pipe.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8) ?? "" + let path = output.trimmingCharacters(in: .whitespacesAndNewlines) + return path.isEmpty ? nil : path + } +} diff --git a/apps/cmd-ime-swift/Tests/CmdIMESwiftTests/SMAppServiceTestIsolationTests.swift b/apps/cmd-ime-swift/Tests/CmdIMESwiftTests/SMAppServiceTestIsolationTests.swift new file mode 100644 index 0000000..6ac1059 --- /dev/null +++ b/apps/cmd-ime-swift/Tests/CmdIMESwiftTests/SMAppServiceTestIsolationTests.swift @@ -0,0 +1,31 @@ +import XCTest + +/// Regression guard for the incident where a test called the real +/// `SMAppService.mainApp` API and registered the `xctest` test-runner binary +/// itself as a login item on the developer's Mac (bootstrap() -> +/// setLaunchAtStartup(true) -> SMAppService.mainApp.register()). Every test +/// must go through the injectable `LoginItemService` seam (see +/// toggleLaunchAtStartup.swift / FakeLoginItemService.swift) instead. +final class SMAppServiceTestIsolationTests: XCTestCase { + func testNoTestFileReferencesTheRealSMAppServiceMainApp() throws { + let testsDirectory = URL(fileURLWithPath: #filePath).deletingLastPathComponent() + let contents = try FileManager.default.contentsOfDirectory( + at: testsDirectory, includingPropertiesForKeys: nil + ) + let swiftFiles = contents.filter { $0.pathExtension == "swift" } + XCTAssertFalse(swiftFiles.isEmpty, "sanity check: the test directory listing must not be empty") + + for file in swiftFiles { + // FakeLoginItemService itself is a legitimate SMAppService.Status + // consumer (it stands in for the real type's status enum), and + // this file's own doc comment names the hazard string it scans + // for — neither is an actual live call. + guard file.lastPathComponent != "FakeLoginItemService.swift", + file.lastPathComponent != "SMAppServiceTestIsolationTests.swift" else { continue } + let contents = try String(contentsOf: file, encoding: .utf8) + XCTAssertFalse(contents.contains("SMAppService.mainApp"), + "\(file.lastPathComponent) references the real SMAppService.mainApp — " + + "inject FakeLoginItemService instead (see AppSettingsTests.swift)") + } + } +} diff --git a/apps/cmd-ime-swift/scripts/package.sh b/apps/cmd-ime-swift/scripts/package.sh index 0825788..72b1959 100755 --- a/apps/cmd-ime-swift/scripts/package.sh +++ b/apps/cmd-ime-swift/scripts/package.sh @@ -166,6 +166,16 @@ if [[ -d "$SPARKLE_FRAMEWORK" ]]; then ditto "$SPARKLE_FRAMEWORK" "$FRAMEWORKS_DIR/Sparkle.framework" fi +# SPM emits a separate _.bundle for `resources:` (Localizable.xcstrings +# and its compiled .lproj tables) — swift build never merges it into Contents/Resources on +# its own, so `Bundle.module` lookups (see Localization.swift) would silently fall back to +# raw keys in a real .app if this bundle isn't carried over here. +SWIFT_RESOURCE_BUNDLE="$BUILD_DIR/$TRIPLE/release/${BIN_NAME}_${BIN_NAME}.bundle" +if [[ -d "$SWIFT_RESOURCE_BUNDLE" ]]; then + echo ">> Copying ${BIN_NAME}_${BIN_NAME}.bundle" + ditto "$SWIFT_RESOURCE_BUNDLE" "$RESOURCES_DIR/${BIN_NAME}_${BIN_NAME}.bundle" +fi + if [[ -f "$ICON_SOURCE" ]]; then ditto "$ICON_SOURCE" "$RESOURCES_DIR/AppIcon.icns" ICON_NAME="AppIcon.icns"