Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 9 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,8 +44,15 @@ macOS system language is irrelevant to what the user must see. Consequences:

The README ships in **every `SupportedLanguage`**. English is the authoritative
source and lives at the repo root (`README.md` — the **only** README there);
the translations live in `docs/README/`, kept in sync. The docs under `docs/`
(`DESIGN.md`, `RELEASING.md`) are **English-only — do not translate them.**
the translations live in `docs/README/`, kept in sync. The **URL Scheme API**
reference ships in every language too: English authoritative at
`docs/URL-Scheme-API/README.md`, translations at
`docs/URL-Scheme-API/README.<code>.md` (same `<code>` naming and language-switcher
convention as the README, switcher links by bare sibling filename; the H1 and all
`##`/`###` headings stay English, and every `lockime://` token, parameter, error
code, URL, and JSON key/value stays byte-for-byte identical). The remaining docs
under `docs/` (`DESIGN.md`, `RELEASING.md`) are **English-only — do not translate
them.**

- **Naming:** translations are `docs/README/README.<code>.md` with **region**
codes for Chinese (`zh-CN`, `zh-TW` — *not* the script codes
Expand Down
18 changes: 18 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,24 @@ Either way, the app keeps itself up to date via Sparkle.
- **Tiny download** — the whole app ships in a `.dmg` under 3 MB.
- **No system permissions for core locking** — an optional Accessibility-gated
enhanced mode unlocks finer-grained per-URL and focused-field rules.
- **Automation** — a `lockime://` URL scheme lets other apps, scripts, and
Shortcuts drive LockIME (see below).

## Automation

LockIME exposes a `lockime://` URL scheme so other apps, scripts, Shortcuts, and
launchers can drive it — toggle locking, retarget the input source, manage
rules, and read state back with [x-callback-url](https://x-callback-url.com)
callbacks. It is off by default — turn it on in **Settings ▸ General ▸
Automation**.

```sh
open "lockime://lock"
open "lockime://lock-to-source?id=com.apple.keylayout.ABC"
open "lockime://set-app-rule?bundle=com.apple.Terminal&mode=lock&source=com.apple.keylayout.ABC"
```

Full reference: **[URL Scheme API](docs/URL-Scheme-API/README.md)**.

## Design

Expand Down
355 changes: 355 additions & 0 deletions Sources/LockIME/API/URLCommandHandler.swift

Large diffs are not rendered by default.

22 changes: 22 additions & 0 deletions Sources/LockIME/AppDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,10 @@ import AppKit
final class AppDelegate: NSObject, NSApplicationDelegate {
let appState = AppState()

/// Dispatches incoming `lockime://` URLs. Created lazily off `appState` so it
/// shares the one live state the engine and UI are bound to.
private lazy var urlHandler = URLCommandHandler(state: appState)

func applicationDidFinishLaunching(_ notification: Notification) {
#if DEBUG
// Headless self-test of the Accessibility grant UX. Skips engine startup
Expand All @@ -24,4 +28,22 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
func applicationWillTerminate(_ notification: Notification) {
appState.stop()
}

/// Handle `lockime://` (and the Debug `lockime-dev://`) URL-scheme commands.
/// LaunchServices delivers only the schemes the app registered in its
/// `CFBundleURLTypes`, so each URL is one of ours; the parser keys off the
/// command token, not the scheme. Multiple URLs may arrive in one event.
func application(_ application: NSApplication, open urls: [URL]) {
// On a URL-triggered COLD launch, AppKit can call this before
// `applicationDidFinishLaunching`, i.e. before `appState.start()` has
// loaded the persisted config. Running a command against the unloaded
// (empty .default) state would let `commit()` overwrite the user's saved
// rules. `start()` is idempotent (guards on `engine == nil`), so calling
// it here guarantees the config is loaded first; the later
// `applicationDidFinishLaunching` call becomes a no-op.
appState.start()
for url in urls {
urlHandler.handle(url)
}
}
}
114 changes: 110 additions & 4 deletions Sources/LockIME/AppState.swift
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,14 @@ final class AppState {
private(set) var loginItemState: LoginItemState = .unknown
private(set) var accessibilityGranted: Bool = false

/// Whether the `lockime://` URL-scheme API is allowed to act. **Off by
/// default** — the user must opt in (Settings ▸ General ▸ Automation) before
/// any external command takes effect. Stored in its own `UserDefaults` key,
/// deliberately *not* part of `LockConfiguration`, so it is per-device and
/// never travels through config export/import.
private(set) var apiEnabled: Bool = false
@ObservationIgnored private static let apiEnabledKey = "apiEnabled"

/// The configured global toggle-lock shortcut, mirrored as observable state
/// so the menu-bar header re-renders the moment the user binds or clears it
/// in Settings (a plain `getShortcut` read isn't tracked by `@Observable`).
Expand Down Expand Up @@ -93,9 +101,37 @@ final class AppState {

init() {
languagePreference = .load()
apiEnabled = UserDefaults.standard.bool(forKey: Self.apiEnabledKey) // absent ⇒ false (opt-in)
ThirdPartyBundleLocalization.apply(language: languagePreference.effectiveLanguage)
}

/// Opt the `lockime://` URL-scheme API in or out. Persisted immediately so the
/// choice survives relaunch; takes effect for the next incoming command.
func setAPIEnabled(_ enabled: Bool) {
apiEnabled = enabled
UserDefaults.standard.set(enabled, forKey: Self.apiEnabledKey)
}

/// GitHub URL of the URL-scheme API reference, in the app's current language
/// (mirrors the `docs/URL-Scheme-API/README.<code>.md` naming). Points at
/// `main`, so it resolves once this work lands there.
var apiDocumentationURL: URL {
let file: String
switch languagePreference.effectiveLanguage {
case .english: file = "README.md"
case .simplifiedChinese: file = "README.zh-CN.md"
case .traditionalChinese: file = "README.zh-TW.md"
case .japanese: file = "README.ja.md"
case .french: file = "README.fr.md"
case .german: file = "README.de.md"
case .spanish: file = "README.es.md"
case .portuguese: file = "README.pt.md"
case .russian: file = "README.ru.md"
}
// A constant, URL-safe GitHub address — not user-facing copy.
return URL(string: "https://github.com/oomol-lab/LockIME/blob/main/docs/URL-Scheme-API/\(file)")!
}

func setLanguagePreference(_ preference: LanguagePreference) {
languagePreference = preference
preference.save()
Expand Down Expand Up @@ -276,18 +312,27 @@ final class AppState {
/// scoped to that app. Does nothing when the frontmost app has no rule of
/// its own, and never lands on "none" (it pins the rule to a valid source).
func cycleFrontmostAppSource(_ direction: CycleDirection) {
guard let bundleID = frontmostApplicationBundleID,
var rule = config.rule(for: bundleID)
else { return }
guard let bundleID = frontmostApplicationBundleID else { return }
_ = cycleAppSource(bundleID: bundleID, direction: direction)
}

/// Cycle a *specific* app's rule to the previous/next input source. Shared by
/// the frontmost-app hotkey and the `lockime://cycle-app-source` URL command.
/// Returns `false` (a no-op) when that app has no rule or there is nowhere to
/// cycle, so the API can report `rule_not_found`.
@discardableResult
func cycleAppSource(bundleID: String, direction: CycleDirection) -> Bool {
guard var rule = config.rule(for: bundleID) else { return false }
let reference = rule.lockedSourceID ?? engine?.currentSourceID()
guard let next = SourceCycler.step(
from: reference, in: availableSources.map(\.id), direction: direction
) else { return }
) else { return false }
// Cycling pins a source; keep a `.switched` rule a switch (don't demote
// it to a continuous lock), and turn a non-pinning rule into a lock.
if !rule.mode.pinsSource { rule.mode = .locked }
rule.lockedSourceID = next
upsertRule(rule)
return true
}

/// Remove the rule bound to the frontmost app. Does nothing when that app
Expand Down Expand Up @@ -336,6 +381,67 @@ final class AppState {
commit()
}

// MARK: - URL-scheme API support

/// Live current input-source id (the engine's view), for status queries.
var currentSourceID: InputSourceID? { engine?.currentSourceID() }

/// Live launch-at-login state (read fresh from `SMAppService`, never cached),
/// for the `set-launch-at-login` toggle and the status query.
var launchAtLoginActive: Bool { loginItem.isEnabled }

/// Recent activation-log entries (newest first, within the 24h window) for
/// the `lockime://list-log` query.
func recentActivationLog(limit: Int = 200) -> [ActivationLogEntry] {
logStore.recent(limit: limit)
}

/// The bundle ID of the app the user is currently looking at, read fresh from
/// `NSWorkspace`. A global URL command doesn't steal focus, so this is the app
/// a frontmost-scoped command (`cycle-app-source`, `remove-frontmost-app-rule`)
/// should target — the same source the engine resolves rules against.
var liveFrontmostBundleID: String? { frontmostApplicationBundleID }

/// Resolve an API source selector to a canonical id, requiring it to name a
/// currently-installed selectable source (so the API can report
/// `unknown_source` rather than silently configuring an unusable target).
func resolveSourceID(_ selector: SourceSelector) -> InputSourceID? {
switch selector {
case .id(let id):
return availableSources.first { $0.id == id }?.id
case .name(let name):
return availableSources.first {
$0.localizedName.compare(name, options: .caseInsensitive) == .orderedSame
}?.id
}
}

/// The installed display name for a source id, if any.
func sourceDisplayName(for id: InputSourceID) -> String? {
availableSources.first { $0.id == id }?.localizedName
}

/// Perform a transient one-shot switch (no standing lock) for the
/// `lockime://switch-source` command. An active continuous lock still wins.
func switchSourceOnce(_ id: InputSourceID) {
engine?.switchSourceOnce(id)
}

/// Remove every per-app rule in one commit (`lockime://clear-app-rules`).
func clearAppRules() {
guard !config.appRules.isEmpty else { return }
config.appRules.removeAll()
commit()
}

/// Remove every per-URL rule in one commit (`lockime://clear-url-rules`).
func clearURLRules() {
guard !config.urlRules.isEmpty else { return }
config.urlRules.removeAll()
commit()
}


/// Reconcile the cached flag with the live trust state, reacting to either
/// transition. The user may grant while the polling watcher is stopped (e.g.
/// after closing the window mid-flow) or entirely out-of-band in System
Expand Down
13 changes: 13 additions & 0 deletions Sources/LockIME/Info.plist
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,19 @@
<string>$(LOCKIME_BUNDLE_DISPLAY_NAME)</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleURLName</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleTypeRole</key>
<string>Editor</string>
<key>CFBundleURLSchemes</key>
<array>
<string>$(LOCKIME_URL_SCHEME)</string>
</array>
</dict>
</array>
<key>LSApplicationCategoryType</key>
<string>public.app-category.utilities</string>
<key>CFBundleShortVersionString</key>
Expand Down
100 changes: 100 additions & 0 deletions Sources/LockIME/Localizable.xcstrings
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,106 @@
"sourceLanguage": "en",
"version": "1.0",
"strings": {
"API command": {
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "API-Befehl"
}
},
"es": {
"stringUnit": {
"state": "translated",
"value": "Comando de API"
}
},
"fr": {
"stringUnit": {
"state": "translated",
"value": "Commande API"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "API コマンド"
}
},
"pt": {
"stringUnit": {
"state": "translated",
"value": "Comando de API"
}
},
"ru": {
"stringUnit": {
"state": "translated",
"value": "Команда API"
}
},
"zh-Hans": {
"stringUnit": {
"state": "translated",
"value": "API 命令"
}
},
"zh-Hant": {
"stringUnit": {
"state": "translated",
"value": "API 指令"
}
}
}
},
"API documentation": {
"localizations": {
"de": { "stringUnit": { "state": "translated", "value": "API-Dokumentation" } },
"es": { "stringUnit": { "state": "translated", "value": "Documentación de la API" } },
"fr": { "stringUnit": { "state": "translated", "value": "Documentation de l’API" } },
"ja": { "stringUnit": { "state": "translated", "value": "API ドキュメント" } },
"pt": { "stringUnit": { "state": "translated", "value": "Documentação da API" } },
"ru": { "stringUnit": { "state": "translated", "value": "Документация API" } },
"zh-Hans": { "stringUnit": { "state": "translated", "value": "API 文档" } },
"zh-Hant": { "stringUnit": { "state": "translated", "value": "API 文件" } }
}
},
"Automation": {
"localizations": {
"de": { "stringUnit": { "state": "translated", "value": "Automatisierung" } },
"es": { "stringUnit": { "state": "translated", "value": "Automatización" } },
"fr": { "stringUnit": { "state": "translated", "value": "Automatisation" } },
"ja": { "stringUnit": { "state": "translated", "value": "自動化" } },
"pt": { "stringUnit": { "state": "translated", "value": "Automação" } },
"ru": { "stringUnit": { "state": "translated", "value": "Автоматизация" } },
"zh-Hans": { "stringUnit": { "state": "translated", "value": "自动化" } },
"zh-Hant": { "stringUnit": { "state": "translated", "value": "自動化" } }
}
},
"URL Scheme API": {
"localizations": {
"de": { "stringUnit": { "state": "translated", "value": "URL Scheme API" } },
"es": { "stringUnit": { "state": "translated", "value": "URL Scheme API" } },
"fr": { "stringUnit": { "state": "translated", "value": "URL Scheme API" } },
"ja": { "stringUnit": { "state": "translated", "value": "URL Scheme API" } },
"pt": { "stringUnit": { "state": "translated", "value": "URL Scheme API" } },
"ru": { "stringUnit": { "state": "translated", "value": "URL Scheme API" } },
"zh-Hans": { "stringUnit": { "state": "translated", "value": "URL Scheme API" } },
"zh-Hant": { "stringUnit": { "state": "translated", "value": "URL Scheme API" } }
}
},
"When on, other apps and scripts can control LockIME with `lockime://` URL commands." : {
"localizations": {
"de": { "stringUnit": { "state": "translated", "value": "Wenn aktiviert, können andere Apps und Skripte LockIME mit `lockime://`-URL-Befehlen steuern." } },
"es": { "stringUnit": { "state": "translated", "value": "Cuando está activado, otras apps y scripts pueden controlar LockIME con comandos URL `lockime://`." } },
"fr": { "stringUnit": { "state": "translated", "value": "Une fois activé, les autres apps et scripts peuvent contrôler LockIME avec des commandes URL `lockime://`." } },
"ja": { "stringUnit": { "state": "translated", "value": "オンにすると、他のアプリやスクリプトが `lockime://` URL コマンドで LockIME を操作できます。" } },
"pt": { "stringUnit": { "state": "translated", "value": "Quando ativado, outros apps e scripts podem controlar o LockIME com comandos URL `lockime://`." } },
"ru": { "stringUnit": { "state": "translated", "value": "Когда включено, другие приложения и скрипты могут управлять LockIME с помощью URL-команд `lockime://`." } },
"zh-Hans": { "stringUnit": { "state": "translated", "value": "开启后,其他应用和脚本可以通过 `lockime://` URL 命令控制 LockIME。" } },
"zh-Hant": { "stringUnit": { "state": "translated", "value": "開啟後,其他應用程式與指令稿可以透過 `lockime://` URL 指令控制 LockIME。" } }
}
},
"Switch to": {
"localizations": {
"zh-Hans": {
Expand Down
1 change: 1 addition & 0 deletions Sources/LockIME/UI/Settings/ActivationLogPane.swift
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,7 @@ struct ActivationLogPane: View {
case .lockEngaged: "Lock engaged"
case .configChanged: "Settings changed"
case .startupApplied: "Lock restored"
case .apiCommand: "API command"
case nil: LocalizedStringKey(raw)
}
}
Expand Down
2 changes: 1 addition & 1 deletion Sources/LockIME/UI/Settings/BackupSettingsPane.swift
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ struct BackupSettingsPane: View {
@State private var exportFailed = false
@State private var receipt: ImportOutcome?

private static let log = Logger(subsystem: "com.oomol.LockIME", category: "backup")
private static let log = Logger(subsystem: LogSubsystem.current, category: "backup")

var body: some View {
Form {
Expand Down
13 changes: 13 additions & 0 deletions Sources/LockIME/UI/Settings/GeneralSettingsPane.swift
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,19 @@ struct GeneralSettingsPane: View {
} header: {
Text("Language")
}

Section {
let apiBinding = Binding(
get: { state.apiEnabled },
set: { state.setAPIEnabled($0) }
)
Toggle("URL Scheme API", isOn: apiBinding)
Link("API documentation", destination: state.apiDocumentationURL)
} header: {
Text("Automation")
} footer: {
SectionFooter("When on, other apps and scripts can control LockIME with `lockime://` URL commands.")
}
}
.formStyle(.grouped)
.navigationTitle(state.loc("General"))
Expand Down
Loading
Loading