diff --git a/AGENTS.md b/AGENTS.md index c5c1db8..97bece6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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..md` (same `` 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..md` with **region** codes for Chinese (`zh-CN`, `zh-TW` — *not* the script codes diff --git a/README.md b/README.md index 6af88f3..221f488 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/Sources/LockIME/API/URLCommandHandler.swift b/Sources/LockIME/API/URLCommandHandler.swift new file mode 100644 index 0000000..491a59c --- /dev/null +++ b/Sources/LockIME/API/URLCommandHandler.swift @@ -0,0 +1,355 @@ +import AppKit +import Foundation +import LockIMEKit +import OSLog + +/// Executes a parsed `lockime://` URL-scheme command against the live `AppState` +/// and reports the outcome through the optional x-callback-url targets. +/// +/// All control logic that can be tested without AppKit lives in the kit +/// (`URLCommandParser`, `CallbackURLBuilder`); this type is the thin AppKit glue +/// that maps a validated command onto `AppState` mutations / queries, builds the +/// JSON result payloads, and opens the success/error callbacks. +@MainActor +final class URLCommandHandler { + private let state: AppState + private static let log = Logger(subsystem: LogSubsystem.current, category: "URLAPI") + + init(state: AppState) { + self.state = state + } + + /// Entry point for `application(_:open:)`. Parses, executes, and replies. + func handle(_ url: URL) { + Self.log.info("URL command received: \(url.absoluteString, privacy: .public)") + // The API is opt-in: until the user enables it (Settings ▸ General ▸ + // Automation), no command — not even a query — takes effect. Reply with a + // stable `api_disabled` error so a caller can detect the gate. + guard state.apiEnabled else { + respondFailure(.apiDisabled, callback: URLCommandParser.callbackTargets(from: url)) + return + } + switch URLCommandParser.parse(url) { + case .success(let parsed): + switch perform(parsed.command) { + case .success(let payload): + respondSuccess(payload, callback: parsed.callback) + case .failure(let error): + respondFailure(error, callback: parsed.callback) + } + case .failure(let error): + // A parse failure still carries usable x-callback targets. + respondFailure(error, callback: URLCommandParser.callbackTargets(from: url)) + } + } + + // MARK: - Execution + + /// Carry out one command. Returns a JSON value (a `[String: Any]` object or + /// `[[String: Any]]` array) for query commands, `nil` for actions. + private func perform(_ command: URLCommand) -> Result { + switch command { + // Master lock + case .lock: + state.setMasterEnabled(true); return .success(nil) + case .unlock: + state.setMasterEnabled(false); return .success(nil) + case .toggleLock: + state.setMasterEnabled(!state.isLocked); return .success(nil) + + // Global source targeting + case .lockToSource(let selector): + return withResolved(selector) { state.lockToSource($0) } + case .setDefaultSource(let selector): + guard let selector else { state.setDefaultSource(nil); return .success(nil) } + return withResolved(selector) { state.setDefaultSource($0) } + case .cycleSource(let direction): + state.cycleGlobalSource(direction); return .success(nil) + case .switchSource(let selector): + return withResolved(selector) { state.switchSourceOnce($0) } + + // Per-app rules + case .setAppRule(let bundleID, let mode, let selector): + return performSetAppRule(bundleID: bundleID, mode: mode, selector: selector) + case .removeAppRule(let bundleID): + guard state.config.rule(for: bundleID) != nil else { return .failure(.ruleNotFound(bundleID)) } + state.removeRule(bundleID: bundleID); return .success(nil) + case .cycleAppSource(let bundleID, let direction): + guard let target = bundleID ?? state.liveFrontmostBundleID else { + return .failure(.ruleNotFound("frontmost")) + } + // Distinguish "no rule" from "rule exists but nowhere to cycle". + guard state.config.rule(for: target) != nil else { return .failure(.ruleNotFound(target)) } + guard state.cycleAppSource(bundleID: target, direction: direction) else { + return .failure(.notSupported("no other input source to cycle to")) + } + return .success(nil) + case .removeFrontmostAppRule: + guard let bundleID = state.liveFrontmostBundleID, state.config.rule(for: bundleID) != nil else { + return .failure(.ruleNotFound("frontmost")) + } + state.removeRule(bundleID: bundleID); return .success(nil) + case .clearAppRules: + state.clearAppRules(); return .success(nil) + + // Login item + case .setLaunchAtLogin(let flag): + state.setLaunchAtLogin(resolve(flag, current: state.launchAtLoginActive)); return .success(nil) + + // Enhanced mode + per-URL rules + case .setEnhancedMode(let flag): + state.setEnhancedMode(resolve(flag, current: state.config.enhancedModeEnabled)); return .success(nil) + case .setURLRule(let id, let host, let selector, let action): + return performSetURLRule(id: id, host: host, selector: selector, action: action) + case .removeURLRule(let selector): + return performRemoveURLRule(selector) + case .clearURLRules: + state.clearURLRules(); return .success(nil) + + // App + case .setLanguage(let language): + // nil means "follow the system language" (clear the override). + state.setLanguagePreference(language.map(LanguagePreference.specific) ?? .system) + return .success(nil) + case .quit: + // Defer past the synchronous reply so an x-success callback still + // fires before the app tears down. + Task { @MainActor in NSApp.terminate(nil) } + return .success(nil) + + // Queries + case .status: + return .success(statusPayload()) + case .currentSource: + if let id = state.currentSourceID { return .success(sourceDict(id)) } + return .success(["id": NSNull(), "name": state.currentSourceName]) + case .listSources: + return .success(state.availableSources.map(Self.sourceListItem)) + case .listAppRules: + return .success(state.config.appRules.map(appRuleDict)) + case .listURLRules: + return .success(state.config.urlRules.map(urlRuleDict)) + case .listLog: + return .success(state.recentActivationLog().map(Self.logEntryDict)) + case .getConfig: + return configPayload() + case .version: + return .success(versionPayload()) + case .ping: + var payload = versionPayload() + payload["ok"] = true + payload["app"] = "LockIME" + return .success(payload) + } + } + + // MARK: - Command helpers + + private func performSetAppRule( + bundleID: String, mode: AppRuleMode, selector: SourceSelector? + ) -> Result { + var sourceID: InputSourceID? + if mode.pinsSource { + guard let selector else { return .failure(.missingParameter("source")) } + switch resolve(selector) { + case .success(let id): sourceID = id + case .failure(let error): return .failure(error) + } + } + state.upsertRule(AppRule(bundleID: bundleID, mode: mode, lockedSourceID: sourceID)) + return .success(nil) + } + + private func performSetURLRule( + id: UUID?, host: String, selector: SourceSelector, action: RuleAction + ) -> Result { + switch resolve(selector) { + case .failure(let error): + return .failure(error) + case .success(let sourceID): + // With no explicit id, update an existing rule for the same host + // (case-insensitive) rather than appending a duplicate. + let ruleID = id + ?? state.config.urlRules.first { Self.sameHost($0.hostPattern, host) }?.id + ?? UUID() + state.upsertURLRule(URLRule(id: ruleID, hostPattern: host, lockedSourceID: sourceID, action: action)) + return .success(nil) + } + } + + private func performRemoveURLRule(_ selector: URLRuleSelector) -> Result { + switch selector { + case .id(let id): + guard state.config.urlRules.contains(where: { $0.id == id }) else { + return .failure(.ruleNotFound(id.uuidString)) + } + state.removeURLRule(id: id) + case .host(let host): + let matches = state.config.urlRules.filter { Self.sameHost($0.hostPattern, host) } + guard !matches.isEmpty else { return .failure(.ruleNotFound(host)) } + for rule in matches { state.removeURLRule(id: rule.id) } + } + return .success(nil) + } + + /// Resolve a source selector, run `body` on success, and report an empty + /// (action) result — collapsing the success/failure plumbing for the source- + /// targeting commands. + private func withResolved( + _ selector: SourceSelector, _ body: (InputSourceID) -> Void + ) -> Result { + switch resolve(selector) { + case .success(let id): body(id); return .success(nil) + case .failure(let error): return .failure(error) + } + } + + /// Resolve an API source selector to an installed source id, or a typed error. + private func resolve(_ selector: SourceSelector) -> Result { + guard !state.availableSources.isEmpty else { return .failure(.noInputSources) } + if let id = state.resolveSourceID(selector) { return .success(id) } + switch selector { + case .id(let id): return .failure(.unknownSource(id.rawValue)) + case .name(let name): return .failure(.unknownSource(name)) + } + } + + private func resolve(_ flag: FlagArg, current: Bool) -> Bool { + switch flag { + case .on: return true + case .off: return false + case .toggle: return !current + } + } + + private static func sameHost(_ a: String, _ b: String) -> Bool { + a.compare(b, options: .caseInsensitive) == .orderedSame + } + + // MARK: - Payloads + + private func statusPayload() -> [String: Any] { + var payload: [String: Any] = [ + "locked": state.isLocked, + "enhancedMode": state.config.enhancedModeEnabled, + "launchAtLogin": state.launchAtLoginActive, + "accessibilityGranted": state.accessibilityGranted, + "activationCount": state.activationCount, + "language": state.languagePreference.effectiveLanguage.localeIdentifier, + "version": Bundle.main.shortVersion, + "build": Bundle.main.buildVersion, + ] + if let id = state.currentSourceID { payload["currentSource"] = sourceDict(id) } + if let id = state.config.defaultSourceID { payload["defaultSource"] = sourceDict(id) } + if let frontmost = state.liveFrontmostBundleID { payload["frontmostApp"] = frontmost } + return payload + } + + private func versionPayload() -> [String: Any] { + ["version": Bundle.main.shortVersion, "build": Bundle.main.buildVersion] + } + + private func configPayload() -> Result { + guard let data = try? JSONEncoder().encode(state.config), + let object = try? JSONSerialization.jsonObject(with: data) else { + return .failure(.notSupported("configuration serialization")) + } + return .success(object) + } + + private func sourceDict(_ id: InputSourceID) -> [String: Any] { + var dict: [String: Any] = ["id": id.rawValue] + if let name = state.sourceDisplayName(for: id) { dict["name"] = name } + return dict + } + + private static func sourceListItem(_ source: InputSource) -> [String: Any] { + [ + "id": source.id.rawValue, + "name": source.localizedName, + "isCJKV": source.isCJKV, + "isEnabled": source.isEnabled, + "isSelectCapable": source.isSelectCapable, + ] + } + + private func appRuleDict(_ rule: AppRule) -> [String: Any] { + var dict: [String: Any] = ["bundleID": rule.bundleID, "mode": rule.mode.rawValue] + if let id = rule.lockedSourceID { dict["source"] = sourceDict(id) } + return dict + } + + private func urlRuleDict(_ rule: URLRule) -> [String: Any] { + [ + "id": rule.id.uuidString, + "host": rule.hostPattern, + "action": rule.action.rawValue, + "source": sourceDict(rule.lockedSourceID), + ] + } + + private static let iso8601 = ISO8601DateFormatter() + + private static func logEntryDict(_ entry: ActivationLogEntry) -> [String: Any] { + var dict: [String: Any] = [ + "timestamp": iso8601.string(from: entry.timestamp), + "inputSource": entry.inputSourceID, + "inputSourceName": entry.inputSourceName, + "reason": entry.reasonRaw, + "durationMs": entry.durationMs, + ] + if let from = entry.fromSourceName { dict["fromSourceName"] = from } + if let app = entry.triggeringAppName ?? entry.triggeringBundleID { dict["app"] = app } + if let bundle = entry.triggeringBundleID { dict["bundleID"] = bundle } + if let rule = entry.ruleSourceRaw { dict["ruleSource"] = rule } + if let host = entry.matchedHost { dict["matchedHost"] = host } + return dict + } + + // MARK: - Callbacks + + private func respondSuccess(_ payload: Any?, callback: CallbackTargets) { + guard let success = callback.success else { return } + let resultString = payload.flatMap(Self.jsonString(from:)) + if let url = CallbackURLBuilder.success(success, result: resultString) { + open(url) + } + } + + private func respondFailure(_ error: URLCommandError, callback: CallbackTargets) { + Self.log.error("URL command failed: \(error.code, privacy: .public) — \(error.message, privacy: .public)") + guard let errorURL = callback.error else { return } + if let url = CallbackURLBuilder.error(errorURL, code: error.code, message: error.message) { + open(url) + } + } + + /// The app's own registered URL scheme(s) — `lockime` (Release) / `lockime-dev` + /// (Debug) — read from the running bundle, lowercased. A reflected callback is + /// refused from re-entering these (see `CallbackURLPolicy`). + private static let ownSchemes: Set = { + let types = Bundle.main.object(forInfoDictionaryKey: "CFBundleURLTypes") as? [[String: Any]] ?? [] + let schemes = types.flatMap { ($0["CFBundleURLSchemes"] as? [String]) ?? [] } + return Set(schemes.map { $0.lowercased() }) + }() + + private func open(_ url: URL) { + // The callback target is reflected from the caller, so only open schemes + // that cannot turn LockIME into a confused-deputy: never a `file://` (which + // would launch an arbitrary local file/app) and never our own scheme (which + // would let a callback re-enter the API). See `CallbackURLPolicy`. + guard CallbackURLPolicy.allows(url, ownSchemes: Self.ownSchemes) else { + Self.log.error("Refused callback URL with disallowed scheme: \(url.scheme ?? "(none)", privacy: .public)") + return + } + NSWorkspace.shared.open(url) + } + + private static func jsonString(from value: Any) -> String? { + guard JSONSerialization.isValidJSONObject(value), + let data = try? JSONSerialization.data(withJSONObject: value, options: [.sortedKeys]) else { + return nil + } + return String(data: data, encoding: .utf8) + } +} diff --git a/Sources/LockIME/AppDelegate.swift b/Sources/LockIME/AppDelegate.swift index 9cd8cdc..b466405 100644 --- a/Sources/LockIME/AppDelegate.swift +++ b/Sources/LockIME/AppDelegate.swift @@ -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 @@ -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) + } + } } diff --git a/Sources/LockIME/AppState.swift b/Sources/LockIME/AppState.swift index 7bdb801..9c64a51 100644 --- a/Sources/LockIME/AppState.swift +++ b/Sources/LockIME/AppState.swift @@ -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`). @@ -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..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() @@ -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 @@ -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 diff --git a/Sources/LockIME/Info.plist b/Sources/LockIME/Info.plist index 1e95695..3bd86c4 100644 --- a/Sources/LockIME/Info.plist +++ b/Sources/LockIME/Info.plist @@ -28,6 +28,19 @@ $(LOCKIME_BUNDLE_DISPLAY_NAME) CFBundlePackageType APPL + CFBundleURLTypes + + + CFBundleURLName + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleTypeRole + Editor + CFBundleURLSchemes + + $(LOCKIME_URL_SCHEME) + + + LSApplicationCategoryType public.app-category.utilities CFBundleShortVersionString diff --git a/Sources/LockIME/Localizable.xcstrings b/Sources/LockIME/Localizable.xcstrings index 22de10a..aacde4f 100644 --- a/Sources/LockIME/Localizable.xcstrings +++ b/Sources/LockIME/Localizable.xcstrings @@ -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": { diff --git a/Sources/LockIME/UI/Settings/ActivationLogPane.swift b/Sources/LockIME/UI/Settings/ActivationLogPane.swift index e4d16bc..e37a8c1 100644 --- a/Sources/LockIME/UI/Settings/ActivationLogPane.swift +++ b/Sources/LockIME/UI/Settings/ActivationLogPane.swift @@ -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) } } diff --git a/Sources/LockIME/UI/Settings/BackupSettingsPane.swift b/Sources/LockIME/UI/Settings/BackupSettingsPane.swift index 9da5f76..ae6672d 100644 --- a/Sources/LockIME/UI/Settings/BackupSettingsPane.swift +++ b/Sources/LockIME/UI/Settings/BackupSettingsPane.swift @@ -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 { diff --git a/Sources/LockIME/UI/Settings/GeneralSettingsPane.swift b/Sources/LockIME/UI/Settings/GeneralSettingsPane.swift index b36e148..527022b 100644 --- a/Sources/LockIME/UI/Settings/GeneralSettingsPane.swift +++ b/Sources/LockIME/UI/Settings/GeneralSettingsPane.swift @@ -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")) diff --git a/Sources/LockIME/UI/SettingsRootView.swift b/Sources/LockIME/UI/SettingsRootView.swift index 7e3b6aa..265850f 100644 --- a/Sources/LockIME/UI/SettingsRootView.swift +++ b/Sources/LockIME/UI/SettingsRootView.swift @@ -31,7 +31,7 @@ struct SettingsRootView: View { } } .scenePadding() - .frame(minWidth: 680, idealWidth: 700, minHeight: 460) + .frame(minWidth: 680, idealWidth: 700, minHeight: 600) // The Settings *window* closing (not a tab switch — this root outlives // those) is the "abandon" signal for an in-flight Accessibility grant. .onDisappear { state.stopAccessibilityWatch() } diff --git a/Sources/LockIME/Updates/LockIMEUserDriver.swift b/Sources/LockIME/Updates/LockIMEUserDriver.swift index d727f8d..1d5aecc 100644 --- a/Sources/LockIME/Updates/LockIMEUserDriver.swift +++ b/Sources/LockIME/Updates/LockIMEUserDriver.swift @@ -7,7 +7,7 @@ import Sparkle /// onto our SwiftUI `UpdateViewModel`. (`SPUUserDriver` is `NS_SWIFT_UI_ACTOR`.) @MainActor final class LockIMEUserDriver: NSObject, SPUUserDriver { - private static let log = Logger(subsystem: "com.oomol.LockIME", category: "Updater") + private static let log = Logger(subsystem: LogSubsystem.current, category: "Updater") private let model: UpdateViewModel private var expectedLength: UInt64 = 0 diff --git a/Sources/LockIMEKit/API/URLCommand.swift b/Sources/LockIMEKit/API/URLCommand.swift new file mode 100644 index 0000000..c3a17c3 --- /dev/null +++ b/Sources/LockIMEKit/API/URLCommand.swift @@ -0,0 +1,531 @@ +import Foundation + +// MARK: - Selectors + +/// How an API caller names an input source: either by its canonical Text Input +/// Source identifier (`id`, e.g. `com.apple.keylayout.ABC`) — the stable, locale- +/// independent form — or by its localized display name (`name`), a convenience +/// resolved against the installed sources at execution time. +public enum SourceSelector: Equatable, Sendable { + case id(InputSourceID) + case name(String) +} + +/// How an API caller names a URL rule for removal: by its stable `id` (the UUID +/// surfaced via `list-url-rules`) or by its `host` pattern (the first match). +public enum URLRuleSelector: Equatable, Sendable { + case id(UUID) + case host(String) +} + +/// A tri-state boolean argument. `toggle` flips whatever the current value is and +/// can only be resolved at execution time (the parser can't read live state). +public enum FlagArg: Equatable, Sendable { + case on + case off + case toggle +} + +// MARK: - Commands + +/// A fully-parsed, syntactically-validated URL-scheme command. Carries only the +/// values present in the URL; whether a referenced source/rule/app actually +/// exists is a runtime question answered by the executor, not the parser. +public enum URLCommand: Equatable, Sendable { + // Master lock + case lock + case unlock + case toggleLock + + // Global source targeting + case lockToSource(SourceSelector) + case setDefaultSource(SourceSelector?) // nil clears the global default + case cycleSource(CycleDirection) + case switchSource(SourceSelector) // transient one-shot, no standing lock + + // Per-app rules + case setAppRule(bundleID: String, mode: AppRuleMode, source: SourceSelector?) + case removeAppRule(bundleID: String) + case cycleAppSource(bundleID: String?, direction: CycleDirection) // nil ⇒ frontmost + case removeFrontmostAppRule + case clearAppRules + + // Login item + case setLaunchAtLogin(FlagArg) + + // Enhanced mode + per-URL rules + case setEnhancedMode(FlagArg) + case setURLRule(id: UUID?, host: String, source: SourceSelector, action: RuleAction) + case removeURLRule(URLRuleSelector) + case clearURLRules + + // App + /// `nil` means "follow the system language" (clears the in-app override). + case setLanguage(SupportedLanguage?) + case quit + + // Queries (return data through the x-success callback) + case status + case currentSource + case listSources + case listAppRules + case listURLRules + case listLog + case getConfig + case version + case ping + + /// Whether this command produces a result payload meant for an x-success + /// callback (vs. a side-effecting action that just signals completion). + public var isQuery: Bool { + switch self { + case .status, .currentSource, .listSources, .listAppRules, + .listURLRules, .listLog, .getConfig, .version, .ping: + return true + default: + return false + } + } +} + +// MARK: - x-callback-url targets + +/// The [x-callback-url](https://x-callback-url.com) targets carried alongside a +/// command. Any command may include them; queries use `success` to return data. +public struct CallbackTargets: Equatable, Sendable { + /// `x-source` — a display name for the calling app (informational only). + public let source: String? + /// `x-success` — opened on success, with query results appended for queries. + public let success: URL? + /// `x-error` — opened on failure, with `errorCode`/`errorMessage` appended. + public let error: URL? + /// `x-cancel` — reserved; LockIME commands never cancel, so it is unused. + public let cancel: URL? + + public init(source: String? = nil, success: URL? = nil, error: URL? = nil, cancel: URL? = nil) { + self.source = source + self.success = success + self.error = error + self.cancel = cancel + } + + public static let none = CallbackTargets() +} + +/// A parsed command plus its callback targets. +public struct ParsedURLCommand: Equatable, Sendable { + public let command: URLCommand + public let callback: CallbackTargets + + public init(command: URLCommand, callback: CallbackTargets = .none) { + self.command = command + self.callback = callback + } +} + +// MARK: - Errors + +/// Why a command failed — at parse time (syntax) or execution time (live state). +/// Each case carries a stable machine `code` (for programmatic callers) and an +/// English human `message`. These are deliberately **not** localized: they cross +/// the process boundary into another app via the `x-error` callback and into the +/// unified log, where stable, machine-readable English is the contract. +public enum URLCommandError: Equatable, Sendable, Error { + case malformedURL + case notACommand + case unknownCommand(String) + case missingParameter(String) + case invalidParameter(name: String, value: String) + // Runtime (raised by the executor, not the parser): + case apiDisabled + case unknownSource(String) + case noInputSources + case ruleNotFound(String) + case notSupported(String) + + public var code: String { + switch self { + case .malformedURL: return "malformed_url" + case .notACommand: return "no_command" + case .unknownCommand: return "unknown_command" + case .missingParameter: return "missing_parameter" + case .invalidParameter: return "invalid_parameter" + case .apiDisabled: return "api_disabled" + case .unknownSource: return "unknown_source" + case .noInputSources: return "no_input_sources" + case .ruleNotFound: return "rule_not_found" + case .notSupported: return "not_supported" + } + } + + public var message: String { + switch self { + case .malformedURL: + return "The URL could not be parsed." + case .notACommand: + return "No command was specified." + case .unknownCommand(let name): + return "Unknown command \"\(name)\"." + case .missingParameter(let name): + return "Missing required parameter \"\(name)\"." + case .invalidParameter(let name, let value): + return "Invalid value \"\(value)\" for parameter \"\(name)\"." + case .apiDisabled: + return "The URL scheme API is disabled. Enable it in LockIME ▸ Settings ▸ General ▸ Automation." + case .unknownSource(let value): + return "No installed input source matches \"\(value)\"." + case .noInputSources: + return "No selectable input sources are installed." + case .ruleNotFound(let value): + return "No rule matches \"\(value)\"." + case .notSupported(let detail): + return "Operation not supported: \(detail)." + } + } +} + +// MARK: - Parser + +/// Pure parsing of a `lockime://` URL into a typed, validated command. The +/// concrete scheme string is irrelevant here — LaunchServices only delivers URLs +/// whose scheme the app registered (`lockime`, plus `lockime-dev` for Debug), so +/// the parser keys off the command token and parameters alone. +/// +/// Two URL shapes are accepted, both case-insensitive on the command token: +/// - bare: `lockime://?` +/// - x-callback: `lockime://x-callback-url/?` +/// +/// Parameter *names* are matched case-insensitively; parameter *values* +/// (bundle IDs, source IDs, host patterns) are preserved verbatim. +public enum URLCommandParser { + public static func parse(_ url: URL) -> Result { + guard let components = URLComponents(url: url, resolvingAgainstBaseURL: false) else { + return .failure(.malformedURL) + } + let params = queryParams(components) + let callback = callbackTargets(params) + + guard let token = commandToken(from: components) else { + return .failure(.notACommand) + } + return resolve(token: token, params: params) + .map { ParsedURLCommand(command: $0, callback: callback) } + } + + /// Extract just the x-callback-url targets, regardless of whether the command + /// itself parses. The executor needs these on the failure path so a malformed + /// or unknown command can still report back through `x-error`. + public static func callbackTargets(from url: URL) -> CallbackTargets { + guard let components = URLComponents(url: url, resolvingAgainstBaseURL: false) else { + return .none + } + return callbackTargets(queryParams(components)) + } + + private static func queryParams(_ components: URLComponents) -> [String: String] { + var params: [String: String] = [:] + for item in components.queryItems ?? [] { + params[item.name.lowercased()] = item.value ?? "" + } + return params + } + + private static func callbackTargets(_ params: [String: String]) -> CallbackTargets { + CallbackTargets( + source: params["x-source"], + success: params["x-success"].flatMap { URL(string: $0) }, + error: params["x-error"].flatMap { URL(string: $0) }, + cancel: params["x-cancel"].flatMap { URL(string: $0) } + ) + } + + /// The command identifier: the host, or the first path segment when the host + /// is the `x-callback-url` sentinel (or absent, e.g. `lockime:///status`). + private static func commandToken(from components: URLComponents) -> String? { + let host = components.host?.lowercased() + if let host, host != "x-callback-url", !host.isEmpty { + return host + } + let segment = components.path + .split(separator: "/", omittingEmptySubsequences: true) + .first + .map { $0.lowercased() } + if let segment, !segment.isEmpty { return segment } + return nil + } + + // swiftlint:disable:next cyclomatic_complexity function_body_length + private static func resolve(token: String, params: [String: String]) + -> Result + { + switch token { + case "lock": + return .success(.lock) + case "unlock": + return .success(.unlock) + case "toggle-lock", "toggle": + return .success(.toggleLock) + + case "lock-to-source": + return requiredSource(params).map { .lockToSource($0) } + case "set-default-source": + return .success(.setDefaultSource(optionalSource(params))) + case "cycle-source": + return direction(params).map { .cycleSource($0) } + case "switch-source": + return requiredSource(params).map { .switchSource($0) } + + case "set-app-rule": + return parseSetAppRule(params) + case "remove-app-rule": + return require(params, "bundle").map { .removeAppRule(bundleID: $0) } + case "cycle-app-source": + return direction(params).map { + .cycleAppSource(bundleID: nonEmpty(params["bundle"]), direction: $0) + } + case "remove-frontmost-app-rule": + return .success(.removeFrontmostAppRule) + case "clear-app-rules": + return .success(.clearAppRules) + + case "set-launch-at-login", "launch-at-login": + return flag(params, "enabled").map { .setLaunchAtLogin($0) } + + case "set-enhanced-mode": + return flag(params, "enabled").map { .setEnhancedMode($0) } + case "set-url-rule": + return parseSetURLRule(params) + case "remove-url-rule": + return parseRemoveURLRule(params) + case "clear-url-rules": + return .success(.clearURLRules) + + case "set-language": + return parseSetLanguage(params) + case "quit": + return .success(.quit) + + case "status": + return .success(.status) + case "current-source": + return .success(.currentSource) + case "list-sources", "sources": + return .success(.listSources) + case "list-app-rules", "app-rules": + return .success(.listAppRules) + case "list-url-rules", "url-rules": + return .success(.listURLRules) + case "list-log", "log", "recent-activations": + return .success(.listLog) + case "get-config", "config": + return .success(.getConfig) + case "version": + return .success(.version) + case "ping": + return .success(.ping) + + default: + return .failure(.unknownCommand(token)) + } + } + + // MARK: Sub-parsers + + private static func parseSetAppRule(_ params: [String: String]) -> Result { + switch require(params, "bundle") { + case .failure(let e): + return .failure(e) + case .success(let bundle): + let mode: AppRuleMode + switch (params["mode"] ?? "lock").lowercased() { + case "lock", "locked": mode = .locked + case "switch", "switched": mode = .switched + case "ignore", "ignored": mode = .ignored + case "default", "usedefault", "use-default": mode = .useDefault + case let other: return .failure(.invalidParameter(name: "mode", value: other)) + } + // A pinning mode needs a source; the deferring modes must not carry one. + if mode.pinsSource { + guard let selector = ruleSource(params) else { return .failure(.missingParameter("source")) } + return .success(.setAppRule(bundleID: bundle, mode: mode, source: selector)) + } + return .success(.setAppRule(bundleID: bundle, mode: mode, source: nil)) + } + } + + private static func parseSetURLRule(_ params: [String: String]) -> Result { + guard let host = nonEmpty(params["host"]) else { return .failure(.missingParameter("host")) } + guard let selector = ruleSource(params) else { return .failure(.missingParameter("source")) } + let action: RuleAction + switch (params["action"] ?? "lock").lowercased() { + case "lock": action = .lock + case "switch", "switchonce", "switch-once": action = .switchOnce + case let other: return .failure(.invalidParameter(name: "action", value: other)) + } + var ruleID: UUID? + if let raw = nonEmpty(params["id"]) { + guard let parsed = UUID(uuidString: raw) else { + return .failure(.invalidParameter(name: "id", value: raw)) + } + ruleID = parsed + } + return .success(.setURLRule(id: ruleID, host: host, source: selector, action: action)) + } + + private static func parseRemoveURLRule(_ params: [String: String]) -> Result { + if let raw = nonEmpty(params["id"]) { + guard let parsed = UUID(uuidString: raw) else { + return .failure(.invalidParameter(name: "id", value: raw)) + } + return .success(.removeURLRule(.id(parsed))) + } + if let host = nonEmpty(params["host"]) { + return .success(.removeURLRule(.host(host))) + } + return .failure(.missingParameter("id")) + } + + private static func parseSetLanguage(_ params: [String: String]) -> Result { + switch require(params, "code") { + case .failure(let e): + return .failure(e) + case .success(let raw): + // Sentinel to clear the override and follow the system language. + switch raw.lowercased() { + case "system", "auto", "follow": return .success(.setLanguage(nil)) + default: break + } + if let exact = SupportedLanguage(rawValue: raw) { return .success(.setLanguage(exact)) } + if let matched = SupportedLanguage.match(raw) { return .success(.setLanguage(matched)) } + return .failure(.invalidParameter(name: "code", value: raw)) + } + } + + // MARK: Param helpers + + private static func require(_ params: [String: String], _ name: String) + -> Result + { + if let value = nonEmpty(params[name]) { return .success(value) } + return .failure(.missingParameter(name)) + } + + /// A source selector from `source`/`id` (canonical TIS id) or + /// `name`/`source-name` (localized display name), or `nil` when none is + /// present (used to *clear* a target, e.g. `set-default-source`). + private static func optionalSource(_ params: [String: String]) -> SourceSelector? { + if let id = nonEmpty(params["source"]) ?? nonEmpty(params["id"]) { + return .id(InputSourceID(id)) + } + if let name = nonEmpty(params["name"]) ?? nonEmpty(params["source-name"]) { + return .name(name) + } + return nil + } + + /// Like `optionalSource`, but a missing source is a `missing_parameter("id")` + /// error. For the source-*targeting* commands, whose canonical key is `id`. + private static func requiredSource(_ params: [String: String]) + -> Result + { + if let selector = optionalSource(params) { return .success(selector) } + return .failure(.missingParameter("id")) + } + + /// Source selector for RULE commands (`set-app-rule`, `set-url-rule`). Unlike + /// `optionalSource`, this deliberately excludes the bare `id` key — on a URL + /// rule `id` names the *rule's own UUID*, not a source — so a rule source must + /// come from `source` (a TIS id) or `source-name` / `name` (a display name). + private static func ruleSource(_ params: [String: String]) -> SourceSelector? { + if let id = nonEmpty(params["source"]) { return .id(InputSourceID(id)) } + if let name = nonEmpty(params["source-name"]) ?? nonEmpty(params["name"]) { return .name(name) } + return nil + } + + private static func direction(_ params: [String: String]) -> Result { + switch (params["direction"] ?? "").lowercased() { + case "next", "forward", "down": return .success(.next) + case "previous", "prev", "back", "backward", "up": return .success(.previous) + case "": return .failure(.missingParameter("direction")) + case let other: return .failure(.invalidParameter(name: "direction", value: other)) + } + } + + private static func flag(_ params: [String: String], _ name: String) -> Result { + switch (params[name] ?? "").lowercased() { + case "true", "1", "on", "yes", "enable", "enabled": return .success(.on) + case "false", "0", "off", "no", "disable", "disabled": return .success(.off) + case "toggle": return .success(.toggle) + case "": return .failure(.missingParameter(name)) + case let other: return .failure(.invalidParameter(name: name, value: other)) + } + } + + private static func nonEmpty(_ value: String?) -> String? { + guard let value, !value.isEmpty else { return nil } + return value + } +} + +// MARK: - Callback URL construction + +/// Builds the `x-success` / `x-error` callback URLs by appending result/error +/// parameters to the caller-supplied base URL (per the x-callback-url contract). +public enum CallbackURLBuilder { + /// Append a `result` query item (a JSON string, percent-encoded) to a success + /// callback. `result` is `nil` for side-effecting actions that merely signal + /// completion. + public static func success(_ base: URL, result: String?) -> URL? { + let items = result.map { [URLQueryItem(name: "result", value: $0)] } ?? [] + return appending(items, to: base) + } + + /// Append the stable `errorCode` and human `errorMessage` query items to an + /// error callback. + public static func error(_ base: URL, code: String, message: String) -> URL? { + appending( + [URLQueryItem(name: "errorCode", value: code), + URLQueryItem(name: "errorMessage", value: message)], + to: base + ) + } + + private static func appending(_ items: [URLQueryItem], to base: URL) -> URL? { + guard !items.isEmpty else { return base } + guard var components = URLComponents(url: base, resolvingAgainstBaseURL: false) else { + return base + } + var existing = components.queryItems ?? [] + existing.append(contentsOf: items) + components.queryItems = existing + return components.url ?? base + } +} + +// MARK: - Callback safety policy + +/// Decides whether a caller-supplied `x-success` / `x-error` callback URL is safe +/// for LockIME to open. The callback is **reflected** — LockIME opens whatever URL +/// the caller put in the query — so an unrestricted open turns the app into a +/// confused-deputy: a web origin that can only get the browser to prompt for a +/// `lockime://` URL could otherwise launder an open of a `file://` URL (launching +/// a local file/app it cannot reach directly) through LockIME's process. +/// +/// The x-callback-url round-trip back into the *caller's own* app scheme is the +/// whole feature, so arbitrary custom schemes (and `http`/`https`) stay allowed; +/// only `file:` and the app's own scheme(s) are refused — the latter so a callback +/// can never re-enter the API (no reentrancy ping-pong). +public enum CallbackURLPolicy { + /// Schemes never allowed for a reflected callback, regardless of bundle. + public static let blockedSchemes: Set = ["file"] + + /// Whether `url` may be opened as a callback. `ownSchemes` are the app's own + /// registered URL schemes (lowercased); a callback into one of them is refused. + /// A schemeless (relative) URL is refused too — there is nothing safe to open. + public static func allows(_ url: URL, ownSchemes: Set) -> Bool { + guard let scheme = url.scheme?.lowercased() else { return false } + if blockedSchemes.contains(scheme) { return false } + if ownSchemes.contains(scheme) { return false } + return true + } +} diff --git a/Sources/LockIMEKit/LockEngine/InputSource.swift b/Sources/LockIMEKit/LockEngine/InputSource.swift index 04c2b28..b717ce4 100644 --- a/Sources/LockIMEKit/LockEngine/InputSource.swift +++ b/Sources/LockIMEKit/LockEngine/InputSource.swift @@ -56,6 +56,11 @@ public enum ActivationReason: String, Sendable, Codable, CaseIterable { /// The launch/restore apply (including after a Sparkle relaunch) enforced /// the locked source at startup because the live source already differed. case startupApplied + /// An external `lockime://switch-source` URL-scheme command forced a transient + /// switch. Logged at the moment the switch takes effect; a standing continuous + /// lock targeting a different source still wins and reverts it on the next + /// change (recorded separately as `.revertedSwitch`). + case apiCommand } /// A single enforcement event, emitted whenever the engine forces the source. diff --git a/Sources/LockIMEKit/LockEngine/LockController.swift b/Sources/LockIMEKit/LockEngine/LockController.swift index 375fcfe..f112a4c 100644 --- a/Sources/LockIMEKit/LockEngine/LockController.swift +++ b/Sources/LockIMEKit/LockEngine/LockController.swift @@ -108,6 +108,42 @@ public final class LockController { force(id, reason: reason, from: current) } + /// Perform a **transient** switch for an external command (the + /// `lockime://switch-source` URL API), independent of the standing lock. + /// + /// Unlike `switchOnce`, this does **not** clear or adopt the lock `target` or + /// its context. It also **clears** the suppression window (`settleUntil = 0`) + /// instead of extending it: if a continuous lock is active and targets a + /// different source, the change notification this switch raises must not be + /// shielded by a *prior* force's still-open settle window — otherwise the API + /// switch could stick. Cleared, the lock reverts it promptly and stays + /// authoritative (that revert is logged separately as `.revertedSwitch`). + /// No-ops when already on `id`. The switch itself is always logged/counted at + /// the moment it takes effect, even if a lock then reverts it. + public func commandSwitch(_ id: InputSourceID) { + guard let current = provider.currentSourceID(), current != id else { return } + let start = uptime() + let fromName = provider.source(for: current)?.localizedName ?? current.rawValue + guard provider.select(id) else { return } + // Clear any inherited suppression window so a standing lock's revert + // (driven by the change notification this select raises) is not muffled. + settleUntil = 0 + activationCount += 1 + let name = provider.source(for: id)?.localizedName ?? id.rawValue + let durationMs = max(0, (uptime() - start) * 1000) + // A command switch belongs to no rule, so it carries no app/rule context. + onActivation?( + ActivationEvent( + timestamp: clock(), + inputSource: id, + inputSourceName: name, + reason: .apiCommand, + durationMs: durationMs, + fromSourceName: fromName + ) + ) + } + /// Call when the system posts a "selected input source changed" notification. public func selectedSourceDidChange() { enforceIfNeeded(reason: .revertedSwitch) diff --git a/Sources/LockIMEKit/LockEngine/LockEngine.swift b/Sources/LockIMEKit/LockEngine/LockEngine.swift index a4e4b6e..dc8142c 100644 --- a/Sources/LockIMEKit/LockEngine/LockEngine.swift +++ b/Sources/LockIMEKit/LockEngine/LockEngine.swift @@ -139,6 +139,11 @@ public final class LockEngine { public func currentSourceID() -> InputSourceID? { provider.currentSourceID() } + /// Perform a transient one-shot switch to `id` for an external API command, + /// without installing a standing lock. An active continuous lock targeting a + /// different source will revert it (see `LockController.commandSwitch`). + public func switchSourceOnce(_ id: InputSourceID) { controller.commandSwitch(id) } + public func currentSourceName() -> String { guard let id = provider.currentSourceID() else { return "—" } return provider.source(for: id)?.localizedName ?? id.rawValue diff --git a/Sources/LockIMEKit/Logging/LogStore.swift b/Sources/LockIMEKit/Logging/LogStore.swift index d3b8bff..7ae7a05 100644 --- a/Sources/LockIMEKit/Logging/LogStore.swift +++ b/Sources/LockIMEKit/Logging/LogStore.swift @@ -7,7 +7,7 @@ import SwiftData public final class LogStore { public static let retention: TimeInterval = 24 * 60 * 60 - private static let log = Logger(subsystem: "com.oomol.LockIME", category: "LogStore") + private static let log = Logger(subsystem: LogSubsystem.current, category: "LogStore") static func defaultDirectory(for bundleIdentifier: String?) -> URL { if bundleIdentifier == "com.oomol.LockIME" { return URL.applicationSupportDirectory.appending(path: "LockIME", directoryHint: .isDirectory) @@ -71,4 +71,21 @@ public final class LogStore { public func count() -> Int { (try? container.mainContext.fetchCount(FetchDescriptor())) ?? 0 } + + /// The activation entries within the retention window, newest first — the + /// same set the Activation Log pane shows. Used by the `lockime://list-log` + /// query. `limit` caps the returned count (most recent kept) when set. + public func recent( + now: Date = .now, + retention: TimeInterval = LogStore.retention, + limit: Int? = nil + ) -> [ActivationLogEntry] { + let cutoff = now.addingTimeInterval(-retention) + var descriptor = FetchDescriptor( + predicate: #Predicate { $0.timestamp > cutoff }, + sortBy: [SortDescriptor(\.timestamp, order: .reverse)] + ) + if let limit { descriptor.fetchLimit = limit } + return (try? container.mainContext.fetch(descriptor)) ?? [] + } } diff --git a/Sources/LockIMEKit/Logging/LogSubsystem.swift b/Sources/LockIMEKit/Logging/LogSubsystem.swift new file mode 100644 index 0000000..f1e4222 --- /dev/null +++ b/Sources/LockIMEKit/Logging/LogSubsystem.swift @@ -0,0 +1,12 @@ +import Foundation + +/// The unified-logging subsystem for every LockIME `os.Logger`. +/// +/// It is the **running** bundle's identifier, so a Debug build logs under +/// `com.oomol.LockIME.dev` and a Release build under `com.oomol.LockIME` — +/// matching how the app is actually installed, instead of a baked-in release id. +/// Falls back to the release id only when there is no bundle identifier at all +/// (e.g. a bare test host). +public enum LogSubsystem { + public static let current = Bundle.main.bundleIdentifier ?? "com.oomol.LockIME" +} diff --git a/Tests/LockIMEKitTests/LockControllerTests.swift b/Tests/LockIMEKitTests/LockControllerTests.swift index b5444ba..dc7bf6b 100644 --- a/Tests/LockIMEKitTests/LockControllerTests.swift +++ b/Tests/LockIMEKitTests/LockControllerTests.swift @@ -27,6 +27,67 @@ struct LockControllerTests { return (controller, provider, uptime) } + @Test("commandSwitch with no active lock takes effect and sticks") + func commandSwitchNoLock() { + let (controller, provider, _) = make(current: us) // disabled (no lock) + controller.commandSwitch(abc) + #expect(provider.current == abc) + #expect(controller.activationCount == 1) + controller.selectedSourceDidChange() // nothing reverts while disabled + #expect(provider.current == abc) + } + + @Test("commandSwitch is a no-op when already on the source") + func commandSwitchAlreadyThere() { + let (controller, provider, _) = make(current: abc) + controller.commandSwitch(abc) + #expect(provider.selectCalls.isEmpty) + #expect(controller.activationCount == 0) + } + + @Test("commandSwitch yields to an active lock even inside the suppression window") + func commandSwitchYieldsToActiveLock() { + let (controller, provider, uptime) = make(current: us, enabled: true) + controller.setTarget(abc) // lock to abc: forces us→abc, opens the 0.30s settle window + #expect(provider.current == abc) + uptime.advance(by: 0.10) // still inside the window + controller.commandSwitch(pinyin) // transient API switch + #expect(provider.current == pinyin) // it took effect… + controller.selectedSourceDidChange() // …and the system posts the change + #expect(provider.current == abc) // the lock reverted it (cleared settle window) + #expect(controller.target == abc) // lock target untouched + } + + @Test("a failed commandSwitch select changes nothing and is not counted") + func commandSwitchFailedSelectNotCounted() { + let (controller, provider, _) = make(current: us) // disabled (no lock) + provider.selectSucceeds[abc] = false + var events: [ActivationEvent] = [] + controller.onActivation = { events.append($0) } + controller.commandSwitch(abc) + #expect(provider.selectCalls == [abc]) // attempted once… + #expect(provider.current == us) // …but it did not take + #expect(controller.activationCount == 0) + #expect(events.isEmpty) // no event on a failed switch + } + + @Test("commandSwitch emits one .apiCommand event with from-source and no rule context") + func commandSwitchEmitsApiCommandEvent() { + let (controller, _, _) = make(current: us) // disabled (no lock) + var events: [ActivationEvent] = [] + controller.onActivation = { events.append($0) } + controller.commandSwitch(abc) + #expect(events.count == 1) + #expect(events.first?.reason == .apiCommand) + #expect(events.first?.inputSource == abc) + #expect(events.first?.fromSourceName == us.rawValue) // stub name == id + // A command switch belongs to no rule, so it carries no app/rule context. + #expect(events.first?.triggeringBundleID == nil) + #expect(events.first?.ruleSource == nil) + #expect(events.first?.matchedHost == nil) + #expect((events.first?.durationMs ?? -1) >= 0) + } + @Test("disabled controller never forces a switch") func disabledDoesNothing() { let (controller, provider, _) = make(current: abc) diff --git a/Tests/LockIMEKitTests/LogStoreTests.swift b/Tests/LockIMEKitTests/LogStoreTests.swift index 2e008d0..6683b81 100644 --- a/Tests/LockIMEKitTests/LogStoreTests.swift +++ b/Tests/LockIMEKitTests/LogStoreTests.swift @@ -52,6 +52,23 @@ struct LogStoreTests { #expect(store.count() == 2) } + @Test("recent() returns within-window entries newest-first and honors limit") + func recentNewestFirst() throws { + let store = LogStore(inMemory: true) + let now = Date.now + store.record(event(ageHours: 0.1, now: now)) // newest, in window + store.record(event(ageHours: 1, now: now)) // in window + store.record(event(ageHours: 5, now: now)) // in window + store.record(event(ageHours: 30, now: now)) // older than 24h → excluded + + let recent = store.recent(now: now) + #expect(recent.count == 3) + let first = try #require(recent.first) + let last = try #require(recent.last) + #expect(first.timestamp > last.timestamp) // newest first + #expect(store.recent(now: now, limit: 2).count == 2) // limit caps + } + @Test("entries older than 24h are purged, newer kept") func purges() { let store = LogStore(inMemory: true) diff --git a/Tests/LockIMEKitTests/URLCommandParserTests.swift b/Tests/LockIMEKitTests/URLCommandParserTests.swift new file mode 100644 index 0000000..7fc67f0 --- /dev/null +++ b/Tests/LockIMEKitTests/URLCommandParserTests.swift @@ -0,0 +1,383 @@ +import Foundation +import Testing + +@testable import LockIMEKit + +/// Tests for the `lockime://` URL-scheme command parser and callback builder. +@Suite("URLCommandParser") +struct URLCommandParserTests { + private let abc: InputSourceID = "com.apple.keylayout.ABC" + + // MARK: Helpers + + private func parse(_ string: String) -> Result { + guard let url = URL(string: string) else { + return .failure(.malformedURL) + } + return URLCommandParser.parse(url) + } + + private func command(_ string: String) -> URLCommand? { + if case .success(let parsed) = parse(string) { return parsed.command } + return nil + } + + private func failure(_ string: String) -> URLCommandError? { + if case .failure(let error) = parse(string) { return error } + return nil + } + + // MARK: Master lock + + @Test("master-lock verbs and aliases parse") + func masterLock() { + #expect(command("lockime://lock") == .lock) + #expect(command("lockime://unlock") == .unlock) + #expect(command("lockime://toggle-lock") == .toggleLock) + #expect(command("lockime://toggle") == .toggleLock) + } + + @Test("the command token is case-insensitive") + func caseInsensitiveToken() { + #expect(command("lockime://LOCK") == .lock) + #expect(command("lockime://Toggle-Lock") == .toggleLock) + } + + // MARK: Global source targeting + + @Test("lock-to-source accepts id, name, and the source alias") + func lockToSource() { + #expect(command("lockime://lock-to-source?id=com.apple.keylayout.ABC") == .lockToSource(.id(abc))) + #expect(command("lockime://lock-to-source?source=com.apple.keylayout.ABC") == .lockToSource(.id(abc))) + #expect(command("lockime://lock-to-source?name=ABC") == .lockToSource(.name("ABC"))) + #expect(command("lockime://lock-to-source?source-name=ABC") == .lockToSource(.name("ABC"))) + } + + @Test("lock-to-source without a source is a missing-parameter error") + func lockToSourceMissing() { + #expect(failure("lockime://lock-to-source") == .missingParameter("id")) + } + + @Test("set-default-source with no selector clears the default") + func setDefaultSourceClear() { + #expect(command("lockime://set-default-source") == .setDefaultSource(nil)) + #expect(command("lockime://set-default-source?id=com.apple.keylayout.ABC") == .setDefaultSource(.id(abc))) + } + + @Test("cycle-source parses direction and its aliases") + func cycleSource() { + #expect(command("lockime://cycle-source?direction=next") == .cycleSource(.next)) + #expect(command("lockime://cycle-source?direction=previous") == .cycleSource(.previous)) + #expect(command("lockime://cycle-source?direction=prev") == .cycleSource(.previous)) + #expect(failure("lockime://cycle-source") == .missingParameter("direction")) + #expect(failure("lockime://cycle-source?direction=sideways") == .invalidParameter(name: "direction", value: "sideways")) + } + + @Test("switch-source requires a source") + func switchSource() { + #expect(command("lockime://switch-source?id=com.apple.keylayout.ABC") == .switchSource(.id(abc))) + #expect(failure("lockime://switch-source") == .missingParameter("id")) + } + + // MARK: App rules + + @Test("set-app-rule maps every mode keyword") + func setAppRuleModes() { + #expect(command("lockime://set-app-rule?bundle=com.foo.Bar&mode=lock&source=com.apple.keylayout.ABC") + == .setAppRule(bundleID: "com.foo.Bar", mode: .locked, source: .id(abc))) + #expect(command("lockime://set-app-rule?bundle=com.foo.Bar&mode=switch&source=com.apple.keylayout.ABC") + == .setAppRule(bundleID: "com.foo.Bar", mode: .switched, source: .id(abc))) + #expect(command("lockime://set-app-rule?bundle=com.foo.Bar&mode=ignore") + == .setAppRule(bundleID: "com.foo.Bar", mode: .ignored, source: nil)) + #expect(command("lockime://set-app-rule?bundle=com.foo.Bar&mode=default") + == .setAppRule(bundleID: "com.foo.Bar", mode: .useDefault, source: nil)) + } + + @Test("set-app-rule defaults to lock mode and then needs a source") + func setAppRuleDefaultMode() { + #expect(command("lockime://set-app-rule?bundle=com.foo.Bar&source=com.apple.keylayout.ABC") + == .setAppRule(bundleID: "com.foo.Bar", mode: .locked, source: .id(abc))) + #expect(failure("lockime://set-app-rule?bundle=com.foo.Bar") == .missingParameter("source")) + #expect(failure("lockime://set-app-rule?bundle=com.foo.Bar&mode=lock") == .missingParameter("source")) + } + + @Test("set-app-rule rejects an unknown mode and a missing bundle") + func setAppRuleErrors() { + #expect(failure("lockime://set-app-rule?bundle=com.foo.Bar&mode=spin") + == .invalidParameter(name: "mode", value: "spin")) + #expect(failure("lockime://set-app-rule?mode=ignore") == .missingParameter("bundle")) + } + + @Test("remove / cycle / clear app-rule commands parse") + func appRuleManagement() { + #expect(command("lockime://remove-app-rule?bundle=com.foo.Bar") == .removeAppRule(bundleID: "com.foo.Bar")) + #expect(failure("lockime://remove-app-rule") == .missingParameter("bundle")) + #expect(command("lockime://cycle-app-source?direction=next") + == .cycleAppSource(bundleID: nil, direction: .next)) + #expect(command("lockime://cycle-app-source?direction=next&bundle=com.foo.Bar") + == .cycleAppSource(bundleID: "com.foo.Bar", direction: .next)) + #expect(command("lockime://remove-frontmost-app-rule") == .removeFrontmostAppRule) + #expect(command("lockime://clear-app-rules") == .clearAppRules) + } + + // MARK: Enhanced mode + URL rules + + @Test("set-enhanced-mode parses the tri-state flag") + func enhancedModeFlag() { + #expect(command("lockime://set-enhanced-mode?enabled=true") == .setEnhancedMode(.on)) + #expect(command("lockime://set-enhanced-mode?enabled=on") == .setEnhancedMode(.on)) + #expect(command("lockime://set-enhanced-mode?enabled=1") == .setEnhancedMode(.on)) + #expect(command("lockime://set-enhanced-mode?enabled=false") == .setEnhancedMode(.off)) + #expect(command("lockime://set-enhanced-mode?enabled=toggle") == .setEnhancedMode(.toggle)) + #expect(failure("lockime://set-enhanced-mode") == .missingParameter("enabled")) + #expect(failure("lockime://set-enhanced-mode?enabled=maybe") == .invalidParameter(name: "enabled", value: "maybe")) + } + + @Test("set-url-rule parses host, source, action, and an optional id") + func setURLRule() { + #expect(command("lockime://set-url-rule?host=github.com&source=com.apple.keylayout.ABC") + == .setURLRule(id: nil, host: "github.com", source: .id(abc), action: .lock)) + #expect(command("lockime://set-url-rule?host=github.com&source=com.apple.keylayout.ABC&action=switch") + == .setURLRule(id: nil, host: "github.com", source: .id(abc), action: .switchOnce)) + + let uuid = UUID() + #expect(command("lockime://set-url-rule?host=github.com&source=com.apple.keylayout.ABC&id=\(uuid.uuidString)") + == .setURLRule(id: uuid, host: "github.com", source: .id(abc), action: .lock)) + } + + @Test("set-url-rule reports missing host/source and invalid id/action") + func setURLRuleErrors() { + #expect(failure("lockime://set-url-rule?source=com.apple.keylayout.ABC") == .missingParameter("host")) + #expect(failure("lockime://set-url-rule?host=github.com") == .missingParameter("source")) + // `id` is the rule UUID here, NOT a source selector: it must not satisfy + // the required source (the bare-`id` collision the review caught). + #expect(failure("lockime://set-url-rule?host=github.com&id=\(UUID().uuidString)") == .missingParameter("source")) + #expect(failure("lockime://set-url-rule?host=github.com&source=com.apple.keylayout.ABC&action=hop") + == .invalidParameter(name: "action", value: "hop")) + #expect(failure("lockime://set-url-rule?host=github.com&source=com.apple.keylayout.ABC&id=not-a-uuid") + == .invalidParameter(name: "id", value: "not-a-uuid")) + } + + @Test("remove-url-rule accepts an id or a host, else errors") + func removeURLRule() { + let uuid = UUID() + #expect(command("lockime://remove-url-rule?id=\(uuid.uuidString)") == .removeURLRule(.id(uuid))) + #expect(command("lockime://remove-url-rule?host=github.com") == .removeURLRule(.host("github.com"))) + #expect(failure("lockime://remove-url-rule") == .missingParameter("id")) + #expect(failure("lockime://remove-url-rule?id=nope") == .invalidParameter(name: "id", value: "nope")) + #expect(command("lockime://clear-url-rules") == .clearURLRules) + } + + // MARK: App / windows + + @Test("quit parses") + func quitParses() { + #expect(command("lockime://quit") == .quit) + } + + @Test("set-language resolves exact codes, lenient aliases, and the system sentinel") + func setLanguage() { + #expect(command("lockime://set-language?code=en") == .setLanguage(.english)) + #expect(command("lockime://set-language?code=zh-Hans") == .setLanguage(.simplifiedChinese)) + #expect(command("lockime://set-language?code=zh-CN") == .setLanguage(.simplifiedChinese)) + #expect(command("lockime://set-language?code=zh-TW") == .setLanguage(.traditionalChinese)) + #expect(command("lockime://set-language?code=fr-CA") == .setLanguage(.french)) + // The sentinel clears the override (follow the system language). + #expect(command("lockime://set-language?code=system") == .setLanguage(nil)) + #expect(command("lockime://set-language?code=auto") == .setLanguage(nil)) + #expect(failure("lockime://set-language?code=xx") == .invalidParameter(name: "code", value: "xx")) + #expect(failure("lockime://set-language") == .missingParameter("code")) + } + + @Test("set-launch-at-login parses the tri-state flag") + func launchAtLogin() { + #expect(command("lockime://set-launch-at-login?enabled=on") == .setLaunchAtLogin(.on)) + #expect(command("lockime://set-launch-at-login?enabled=off") == .setLaunchAtLogin(.off)) + #expect(command("lockime://set-launch-at-login?enabled=toggle") == .setLaunchAtLogin(.toggle)) + #expect(command("lockime://launch-at-login?enabled=true") == .setLaunchAtLogin(.on)) + #expect(failure("lockime://set-launch-at-login") == .missingParameter("enabled")) + } + + // MARK: Queries + + @Test("query commands and their aliases parse") + func queries() { + #expect(command("lockime://status") == .status) + #expect(command("lockime://current-source") == .currentSource) + #expect(command("lockime://list-sources") == .listSources) + #expect(command("lockime://sources") == .listSources) + #expect(command("lockime://list-app-rules") == .listAppRules) + #expect(command("lockime://app-rules") == .listAppRules) + #expect(command("lockime://list-url-rules") == .listURLRules) + #expect(command("lockime://list-log") == .listLog) + #expect(command("lockime://log") == .listLog) + #expect(command("lockime://recent-activations") == .listLog) + #expect(command("lockime://get-config") == .getConfig) + #expect(command("lockime://config") == .getConfig) + #expect(command("lockime://version") == .version) + #expect(command("lockime://ping") == .ping) + } + + @Test("isQuery flags only the read commands") + func isQueryFlag() { + #expect(URLCommand.status.isQuery) + #expect(URLCommand.listSources.isQuery) + #expect(URLCommand.listLog.isQuery) + #expect(URLCommand.ping.isQuery) + #expect(!URLCommand.lock.isQuery) + #expect(!URLCommand.setEnhancedMode(.on).isQuery) + #expect(!URLCommand.setLaunchAtLogin(.on).isQuery) + } + + // MARK: Unknown / malformed + + @Test("an unknown command and an empty command are reported") + func unknownCommands() { + #expect(failure("lockime://frobnicate") == .unknownCommand("frobnicate")) + #expect(failure("lockime://") == .notACommand) + } + + @Test("directional aliases up/down/forward/back resolve") + func directionAliases() { + #expect(command("lockime://cycle-source?direction=down") == .cycleSource(.next)) + #expect(command("lockime://cycle-source?direction=forward") == .cycleSource(.next)) + #expect(command("lockime://cycle-source?direction=up") == .cycleSource(.previous)) + #expect(command("lockime://cycle-source?direction=back") == .cycleSource(.previous)) + } + + @Test("the x-callback-url path form selects the command") + func xCallbackPathForm() { + #expect(command("lockime://x-callback-url/status") == .status) + #expect(command("lockime://x-callback-url/lock") == .lock) + // Triple-slash (empty host) also falls back to the path. + #expect(command("lockime:///status") == .status) + } + + // MARK: Callback target extraction + + @Test("x-callback targets are parsed off any command") + func callbackTargets() { + let success = "myapp%3A%2F%2Fok" // myapp://ok + let error = "myapp%3A%2F%2Ffail" // myapp://fail + let string = "lockime://status?x-source=Shortcuts&x-success=\(success)&x-error=\(error)" + guard case .success(let parsed) = parse(string) else { + Issue.record("expected a parsed command") + return + } + #expect(parsed.command == .status) + #expect(parsed.callback.source == "Shortcuts") + #expect(parsed.callback.success == URL(string: "myapp://ok")) + #expect(parsed.callback.error == URL(string: "myapp://fail")) + } + + @Test("callbackTargets(from:) works even when the command is invalid") + func callbackTargetsOnFailure() { + let url = URL(string: "lockime://frobnicate?x-error=myapp%3A%2F%2Ffail")! + let targets = URLCommandParser.callbackTargets(from: url) + #expect(targets.error == URL(string: "myapp://fail")) + #expect(targets.success == nil) + } + + @Test("parameter names are matched case-insensitively, values preserved") + func caseInsensitiveParamNames() { + #expect(command("lockime://lock-to-source?ID=com.apple.keylayout.ABC") == .lockToSource(.id(abc))) + // A bundle value keeps its original case. + #expect(command("lockime://remove-app-rule?Bundle=Com.Foo.Bar") == .removeAppRule(bundleID: "Com.Foo.Bar")) + } +} + +/// Tests for the x-callback-url result/error URL construction. +@Suite("CallbackURLBuilder") +struct CallbackURLBuilderTests { + private func queryItems(_ url: URL?) -> [String: String] { + guard let url, let components = URLComponents(url: url, resolvingAgainstBaseURL: false) else { return [:] } + var result: [String: String] = [:] + for item in components.queryItems ?? [] { result[item.name] = item.value } + return result + } + + @Test("success appends a result query item") + func successWithResult() { + let base = URL(string: "myapp://done")! + let url = CallbackURLBuilder.success(base, result: #"{"locked":true}"#) + #expect(queryItems(url)["result"] == #"{"locked":true}"#) + } + + @Test("success with no result leaves the base URL untouched") + func successWithoutResult() { + let base = URL(string: "myapp://done")! + #expect(CallbackURLBuilder.success(base, result: nil) == base) + } + + @Test("success preserves a pre-existing query") + func successPreservesQuery() { + let base = URL(string: "myapp://done?token=abc")! + let items = queryItems(CallbackURLBuilder.success(base, result: "{}")) + #expect(items["token"] == "abc") + #expect(items["result"] == "{}") + } + + @Test("error appends a stable code and message") + func errorAppends() { + let base = URL(string: "myapp://fail")! + let items = queryItems(CallbackURLBuilder.error(base, code: "unknown_source", message: "No installed input source matches \"x\".")) + #expect(items["errorCode"] == "unknown_source") + #expect(items["errorMessage"] == "No installed input source matches \"x\".") + } +} + +/// Tests for the reflected-callback scheme safety policy. +@Suite("CallbackURLPolicy") +struct CallbackURLPolicyTests { + private let own: Set = ["lockime", "lockime-dev"] + + @Test("allows the caller's own app scheme and http(s) callbacks (the round-trip is the feature)") + func allowsSafe() { + #expect(CallbackURLPolicy.allows(URL(string: "myapp://got-status")!, ownSchemes: own)) + #expect(CallbackURLPolicy.allows(URL(string: "https://example.com/cb")!, ownSchemes: own)) + #expect(CallbackURLPolicy.allows(URL(string: "shortcuts://run-shortcut?name=x")!, ownSchemes: own)) + } + + @Test("blocks file:// — no laundering an arbitrary local open through LockIME") + func blocksFile() { + #expect(!CallbackURLPolicy.allows(URL(string: "file:///Users/x/secret.pdf")!, ownSchemes: own)) + #expect(!CallbackURLPolicy.allows(URL(string: "FILE:///x")!, ownSchemes: own)) // scheme match is case-insensitive + } + + @Test("blocks the app's own scheme(s) — a callback can never re-enter the API") + func blocksOwnScheme() { + #expect(!CallbackURLPolicy.allows(URL(string: "lockime://clear-app-rules")!, ownSchemes: own)) + #expect(!CallbackURLPolicy.allows(URL(string: "lockime-dev://quit")!, ownSchemes: own)) + #expect(!CallbackURLPolicy.allows(URL(string: "LOCKIME://quit")!, ownSchemes: own)) // case-insensitive + } + + @Test("refuses a schemeless callback — nothing safe to open") + func refusesSchemeless() { + #expect(!CallbackURLPolicy.allows(URL(string: "got-status?x=1")!, ownSchemes: own)) + } +} + +/// Tests for the stable error code / message contract. +@Suite("URLCommandError") +struct URLCommandErrorTests { + @Test("each error exposes a stable machine code") + func codes() { + #expect(URLCommandError.malformedURL.code == "malformed_url") + #expect(URLCommandError.notACommand.code == "no_command") + #expect(URLCommandError.unknownCommand("x").code == "unknown_command") + #expect(URLCommandError.missingParameter("x").code == "missing_parameter") + #expect(URLCommandError.invalidParameter(name: "x", value: "y").code == "invalid_parameter") + #expect(URLCommandError.apiDisabled.code == "api_disabled") + #expect(URLCommandError.unknownSource("x").code == "unknown_source") + #expect(URLCommandError.noInputSources.code == "no_input_sources") + #expect(URLCommandError.ruleNotFound("x").code == "rule_not_found") + #expect(URLCommandError.notSupported("x").code == "not_supported") + } + + @Test("messages name the offending parameter") + func messages() { + #expect(URLCommandError.missingParameter("bundle").message.contains("bundle")) + #expect(URLCommandError.invalidParameter(name: "mode", value: "spin").message.contains("spin")) + #expect(URLCommandError.unknownSource("xyz").message.contains("xyz")) + } +} diff --git a/docs/DESIGN.md b/docs/DESIGN.md index 67ab6b6..182a7e4 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -164,7 +164,9 @@ build before relying on it.) ### 4.2 Settings window — top 7-tab `TabView`, widened No sidebar (`.sidebarAdaptable` breaks `ToolbarSpacer` on macOS). Frame -`minWidth 680, idealWidth 700, minHeight 460`, growable. `.scenePadding()` at +`minWidth 680, idealWidth 700, minHeight 600`, growable. (`minHeight` sized so the +tallest pane — General, now with the **Automation** ▸ URL Scheme API toggle — +fits without a scrollbar even at the minimum window height.) `.scenePadding()` at window level; panes own internal insets — verify no double-padding. Tab selection is bound to `AppState.settingsTab` (so a feature pane can route the user to **Permissions** for the single Accessibility grant); the root view's diff --git a/docs/README/README.de.md b/docs/README/README.de.md index 93ab32d..edb18a4 100644 --- a/docs/README/README.de.md +++ b/docs/README/README.de.md @@ -61,6 +61,19 @@ Oder lade die zu deinem Mac passende `.dmg`-Datei (`-arm64` für Apple silicon, - **Automatische Updates** — Stable- und Beta-Kanal via Sparkle, mit einem eigenen Update-Fenster. - **Winziger Download** — die gesamte App steckt in einer `.dmg` unter 3 MB. - **Keine Systemberechtigungen für das Kern-Sperren** — ein optionaler, über Accessibility freigeschalteter erweiterter Modus ermöglicht feinere Regeln pro URL und pro fokussiertem Feld. +- **Automatisierung** — ein `lockime://`-URL-Schema lässt andere Apps, Skripte und Kurzbefehle LockIME steuern (siehe unten). + +## Automation + +LockIME stellt ein `lockime://`-URL-Schema bereit, damit andere Apps, Skripte, Kurzbefehle und Launcher es steuern können — das Sperren umschalten, die Eingabequelle neu festlegen, Regeln verwalten und mit [x-callback-url](https://x-callback-url.com)-Rückrufen den Zustand auslesen. Sie ist standardmäßig aus — schalte sie unter **Einstellungen ▸ Allgemein ▸ Automatisierung** ein. + +```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" +``` + +Vollständige Referenz: **[URL Scheme API](../URL-Scheme-API/README.de.md)**. ## Design diff --git a/docs/README/README.es.md b/docs/README/README.es.md index 5a04b6a..4b7daf1 100644 --- a/docs/README/README.es.md +++ b/docs/README/README.es.md @@ -65,6 +65,19 @@ En cualquier caso, la aplicación se mantiene actualizada mediante Sparkle. - **Actualización automática** — canales stable y beta mediante Sparkle, con una ventana de actualización personalizada. - **Descarga diminuta** — toda la aplicación cabe en un `.dmg` de menos de 3 MB. - **Sin permisos del sistema para el bloqueo básico** — un modo mejorado opcional, protegido por Accessibility, desbloquea reglas más finas por URL y por campo con el foco. +- **Automatización** — un esquema de URL `lockime://` permite que otras aplicaciones, scripts y Shortcuts controlen LockIME (ver más abajo). + +## Automation + +LockIME expone un esquema de URL `lockime://` para que otras aplicaciones, scripts, Shortcuts y lanzadores puedan controlarlo: activar o desactivar el bloqueo, recambiar la fuente de entrada, gestionar reglas y leer el estado de vuelta con callbacks de [x-callback-url](https://x-callback-url.com). Está desactivada por defecto — actívala en **Ajustes ▸ General ▸ Automatización**. + +```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" +``` + +Referencia completa: **[URL Scheme API](../URL-Scheme-API/README.es.md)**. ## Design diff --git a/docs/README/README.fr.md b/docs/README/README.fr.md index c5311ec..26d0b7f 100644 --- a/docs/README/README.fr.md +++ b/docs/README/README.fr.md @@ -61,6 +61,19 @@ Ou téléchargez le `.dmg` correspondant à votre Mac (`-arm64` pour Apple silic - **Mise à jour automatique** — canaux stable et beta via Sparkle, avec une fenêtre de mise à jour personnalisée. - **Téléchargement minuscule** — toute l'application tient dans un `.dmg` de moins de 3 MB. - **Aucune permission système pour le verrouillage de base** — un mode renforcé optionnel, soumis à l'autorisation Accessibility, débloque des règles plus fines par URL et par champ ayant le focus. +- **Automatisation** — un schéma d'URL `lockime://` permet à d'autres applications, scripts et Shortcuts de piloter LockIME (voir ci-dessous). + +## Automation + +LockIME expose un schéma d'URL `lockime://` afin que d'autres applications, scripts, Shortcuts et lanceurs puissent le piloter — activer/désactiver le verrouillage, recibler la source de saisie, gérer les règles et relire l'état grâce aux rappels [x-callback-url](https://x-callback-url.com). Elle est désactivée par défaut — activez-la dans **Réglages ▸ Général ▸ Automatisation**. + +```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" +``` + +Référence complète : **[URL Scheme API](../URL-Scheme-API/README.fr.md)**. ## Design diff --git a/docs/README/README.ja.md b/docs/README/README.ja.md index 7b7b71a..f445313 100644 --- a/docs/README/README.ja.md +++ b/docs/README/README.ja.md @@ -61,6 +61,19 @@ brew install --cask oomol-lab/tap/lockime - **自動アップデート**——Sparkle による stable / beta の 2 チャンネルと、カスタムアップデートウィンドウ。 - **小さなダウンロード**——アプリ全体が 3 MB 未満の `.dmg` に収まります。 - **コアのロックにシステム権限は不要**——オプションの Accessibility 権限付き拡張モードで、より細かい URL ごと / フォーカス中フィールドごとのルールが使えます。 +- **自動化**——`lockime://` URL スキームにより、他のアプリ・スクリプト・ショートカットから LockIME を操作できます(下記参照)。 + +## Automation + +LockIME は `lockime://` URL スキームを公開しており、他のアプリ・スクリプト・ショートカット・ランチャーから操作できます——ロックの切り替え、入力ソースの再設定、ルールの管理、そして [x-callback-url](https://x-callback-url.com) コールバックによる状態の読み取りが可能です。デフォルトではオフです——**設定 ▸ 一般 ▸ 自動化**でオンにしてください。 + +```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" +``` + +詳細なリファレンス:**[URL Scheme API](../URL-Scheme-API/README.ja.md)**。 ## Design diff --git a/docs/README/README.pt.md b/docs/README/README.pt.md index 0745c93..b40f1af 100644 --- a/docs/README/README.pt.md +++ b/docs/README/README.pt.md @@ -65,6 +65,19 @@ De qualquer forma, o app se mantém atualizado sozinho via Sparkle. - **Atualização automática** — canais stable e beta via Sparkle, com uma janela de atualização personalizada. - **Download minúsculo** — o app inteiro cabe em um `.dmg` com menos de 3 MB. - **Sem permissões do sistema para o bloqueio básico** — um modo aprimorado opcional, condicionado à permissão de Accessibility, libera regras mais finas por URL e por campo em foco. +- **Automação** — um esquema de URL `lockime://` permite que outros apps, scripts e o Shortcuts controlem o LockIME (veja abaixo). + +## Automation + +O LockIME expõe um esquema de URL `lockime://` para que outros apps, scripts, o Shortcuts e launchers possam controlá-lo — ativar/desativar o bloqueio, redirecionar a fonte de entrada, gerenciar regras e ler o estado de volta com callbacks [x-callback-url](https://x-callback-url.com). Ela está desativada por padrão — ative-a em **Ajustes ▸ Geral ▸ Automação**. + +```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" +``` + +Referência completa: **[URL Scheme API](../URL-Scheme-API/README.pt.md)**. ## Design diff --git a/docs/README/README.ru.md b/docs/README/README.ru.md index 34f1b5f..b454cc4 100644 --- a/docs/README/README.ru.md +++ b/docs/README/README.ru.md @@ -61,6 +61,19 @@ brew install --cask oomol-lab/tap/lockime - **Автообновление** — каналы stable и beta через Sparkle, с собственным окном обновления. - **Крошечная загрузка** — всё приложение помещается в `.dmg` размером менее 3 MB. - **Базовая блокировка не требует системных разрешений** — дополнительный расширенный режим, защищённый разрешением Accessibility, открывает более тонкие правила для URL и поля с фокусом. +- **Автоматизация** — URL-схема `lockime://` позволяет другим приложениям, скриптам и Shortcuts управлять LockIME (см. ниже). + +## Automation + +LockIME предоставляет URL-схему `lockime://`, чтобы другие приложения, скрипты, Shortcuts и лаунчеры могли управлять им — включать и выключать блокировку, менять источник ввода, управлять правилами и считывать состояние через колбэки [x-callback-url](https://x-callback-url.com). По умолчанию она выключена — включите её в **Настройки ▸ Основные ▸ Автоматизация**. + +```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" +``` + +Полная справка: **[URL Scheme API](../URL-Scheme-API/README.ru.md)**. ## Design diff --git a/docs/README/README.zh-CN.md b/docs/README/README.zh-CN.md index 6fc63ef..92ab8b5 100644 --- a/docs/README/README.zh-CN.md +++ b/docs/README/README.zh-CN.md @@ -64,6 +64,19 @@ Mac 匹配的 `.dmg`(Apple silicon 选 `-arm64`,Intel 选 `-x86_64`)。 - **通过 Sparkle 自动更新**——stable 与 beta 两个通道,配有自定义更新窗口。 - **超小体积**——整个应用打包成不到 3 MB 的 `.dmg`。 - **核心锁定无需系统权限**——可选的、受 Accessibility 把关的增强模式可解锁更细粒度的按 URL / 聚焦字段规则。 +- **自动化**——通过 `lockime://` URL scheme,其他应用、脚本和 Shortcuts 都能驱动 LockIME(详见下文)。 + +## Automation + +LockIME 提供了 `lockime://` URL scheme,让其他应用、脚本、Shortcuts 和启动器都能驱动它——开关锁定、重新指定输入源、管理规则,并通过 [x-callback-url](https://x-callback-url.com) 回调读回状态。它默认关闭——请在**设置 ▸ 通用 ▸ 自动化**中开启它。 + +```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" +``` + +完整参考:**[URL Scheme API](../URL-Scheme-API/README.zh-CN.md)**。 ## Design diff --git a/docs/README/README.zh-TW.md b/docs/README/README.zh-TW.md index 8f37614..e179149 100644 --- a/docs/README/README.zh-TW.md +++ b/docs/README/README.zh-TW.md @@ -61,6 +61,19 @@ brew install --cask oomol-lab/tap/lockime - **透過 Sparkle 自動更新**——stable 與 beta 兩個頻道,配有自訂更新視窗。 - **超小體積**——整個應用程式打包成不到 3 MB 的 `.dmg`。 - **核心鎖定無需系統權限**——可選的、由 Accessibility 把關的增強模式可解鎖更細緻的依 URL / 聚焦欄位規則。 +- **自動化**——`lockime://` URL scheme 讓其他應用程式、指令稿與「捷徑」(Shortcuts)能驅動 LockIME(見下文)。 + +## Automation + +LockIME 提供 `lockime://` URL scheme,讓其他應用程式、指令稿、「捷徑」(Shortcuts)與啟動器都能驅動它——切換鎖定、重新指定輸入法、管理規則,並透過 [x-callback-url](https://x-callback-url.com) 回呼讀回狀態。它預設為關閉——請到 **設定 ▸ 一般 ▸ 自動化** 把它開啟。 + +```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" +``` + +完整參考:**[URL Scheme API](../URL-Scheme-API/README.zh-TW.md)**。 ## Design diff --git a/docs/URL-Scheme-API/README.de.md b/docs/URL-Scheme-API/README.de.md new file mode 100644 index 0000000..dc83cdb --- /dev/null +++ b/docs/URL-Scheme-API/README.de.md @@ -0,0 +1,260 @@ +# URL Scheme API + +[English](README.md) · [简体中文](README.zh-CN.md) · [繁體中文](README.zh-TW.md) · [日本語](README.ja.md) · [Français](README.fr.md) · **Deutsch** · [Español](README.es.md) · [Português](README.pt.md) · [Русский](README.ru.md) + +LockIME stellt ein `lockime://`-URL-Schema bereit, damit andere Apps, Skripte, +Kurzbefehle, Stream Deck, Alfred/Raycast, AppleScript — alles, was eine URL +öffnen kann — es steuern können: das Sperren umschalten, die Eingabequelle neu +festlegen, Regeln verwalten und den Zustand auslesen. + +Jeder Befehl ist eine URL, standardmäßig nach dem Prinzip „abschicken und vergessen", +mit optionalen [x-callback-url](https://x-callback-url.com)-Rückrufen für +Erfolg/Fehler und zur Rückgabe von Daten aus Abfragebefehlen. + +> **Zuerst aktivieren.** Die URL Scheme API ist **standardmäßig aus**. Schalte sie +> unter **LockIME ▸ Einstellungen ▸ Allgemein ▸ Automatisierung ▸ URL Scheme API** +> ein. Solange sie aus ist, gibt jeder Befehl den Fehler `api_disabled` zurück und +> nichts passiert. + +> **Sicherheitshinweis.** Einmal aktiviert, laufen Befehle **ohne Bestätigung pro +> Befehl** — jeder Prozess, der eine `lockime://`-URL öffnen kann (einschließlich +> einer Webseite), kann LockIME steuern. Jeder Befehl ist umkehrbar und keiner +> rührt deine Dateien an; das Schlimmste, was ein bösartiger Aufrufer tun kann, ist +> deine Eingabequellen-Sperre umzuschalten oder Regeln zu bearbeiten. Lass die API +> aus, wenn du sie nicht nutzt. + +--- + +## URL shape + +Zwei gleichwertige Formen werden akzeptiert: + +``` +lockime://?=&= +lockime://x-callback-url/?=&… +``` + +- Das **Befehlstoken** (``) ist nicht zwischen Groß- und Kleinschreibung + unterscheidend. +- **Parameternamen** sind nicht zwischen Groß- und Kleinschreibung + unterscheidend; **Parameterwerte** werden wörtlich übernommen (sodass Bundle-IDs + und Source-IDs ihre Schreibweise behalten). +- Werte, die reservierte Zeichen enthalten (`?`, `&`, `=`, `/`, Leerzeichen, …), + müssen immer **prozentkodiert** werden. Ein Anzeigename einer Quelle wie + `ABC – Extended` wird zu `name=ABC%20%E2%80%93%20Extended`. + +Das Präfix `x-callback-url/` ist optionaler Zucker für x-callback-url-Tools; die +folgenden Callback-Parameter funktionieren auch mit der bloßen Form. + +> **Entwicklungs-Builds.** Ein Debug-Build von LockIME registriert +> `lockime-dev://` statt `lockime://`, sodass ein lokaler Build niemals das Schema +> des installierten Release entführt. Alles andere ist identisch. + +--- + +## x-callback-url + +Jeder Befehl darf diese reservierten Parameter mitführen: + +| Parameter | Meaning | +|---|---| +| `x-success` | URL, die geöffnet wird, nachdem der Befehl erfolgreich war. Bei **Abfrage**befehlen wird das JSON-Ergebnis als `result=` (prozentkodiert) angehängt. | +| `x-error` | URL, die geöffnet wird, falls der Befehl fehlschlägt, mit angehängtem `errorCode=&errorMessage=`. | +| `x-source` | Ein Anzeigename für die aufrufende App (informativ; LockIME protokolliert ihn). | + +Aktionsbefehle lösen `x-success` ohne `result` aus. Abfragebefehle geben ihre +Nutzlast über `x-success` zurück; ohne eine `x-success`-URL hat eine Abfrage +schlicht keinen Ort, an den sie ihr Ergebnis schicken kann (sie läuft dennoch, +harmlos). + +Beispiel für einen Hin- und Rückweg — den Status abfragen und ihn in der eigenen +App empfangen: + +``` +lockime://status?x-success=myapp%3A%2F%2Fgot-status +``` + +Bei Erfolg öffnet LockIME: + +``` +myapp://got-status?result=%7B%22locked%22%3Atrue%2C…%7D +``` + +--- + +## Command reference + +### Master lock + +| Command | Parameters | Effect | +|---|---|---| +| `lock` | — | Die Hauptsperre **einschalten**. | +| `unlock` | — | Die Hauptsperre **ausschalten**. | +| `toggle-lock` *(alias `toggle`)* | — | Die Hauptsperre umschalten. | + +### Global input source + +Eine **Quelle** wird über `id` (die kanonische Text Input Source-Kennung, z. B. +`com.apple.keylayout.ABC`, wie von [`list-sources`](#queries) zurückgegeben) oder +über `name` (ihren lokalisierten Anzeigenamen, nicht zwischen Groß- und +Kleinschreibung unterscheidend) benannt. Sie muss eine derzeit installierte, +auswählbare Quelle benennen, sonst gibt der Befehl `unknown_source` zurück. + +| Command | Parameters | Effect | +|---|---|---| +| `lock-to-source` | `id` \| `name` | Die globale Standardquelle festlegen **und** das Sperren einschalten. | +| `set-default-source` | `id` \| `name` *(beide weglassen zum Löschen)* | Die globale Standardquelle festlegen (oder löschen), ohne den Ein/Aus-Zustand zu ändern. | +| `cycle-source` | `direction` = `next` \| `previous` | Das globale Ziel zur nächsten/vorherigen installierten Quelle (umlaufend) weiterschalten und das Sperren einschalten. | +| `switch-source` | `id` \| `name` | Schaltet die aktuelle Eingabequelle **einmalig**, sofort, um — eine kontinuierliche Sperre wird dabei **weder aktiviert noch geändert**. Ist bereits eine kontinuierliche Sperre aktiv, gewinnt sie und schaltet die Quelle auf ihr Ziel zurück. | + +`direction` akzeptiert auch die Aliasse `prev`, `forward`, `back`, `up`, `down`. + +### Per-app rules + +| Command | Parameters | Effect | +|---|---|---| +| `set-app-rule` | `bundle` *(erf.)*, `mode` = `lock` \| `switch` \| `ignore` \| `default` *(Standard `lock`)*, `source` \| `source-name` *(erf. für `lock`/`switch`)* | Die Regel für eine App erstellen oder ersetzen. `lock` erzwingt die Quelle kontinuierlich; `switch` wechselt bei Aktivierung einmalig und gibt dann frei; `ignore` deaktiviert das Sperren für diese App; `default` fällt auf die globale Standardquelle zurück. | +| `remove-app-rule` | `bundle` *(erf.)* | Die Regel für `bundle` löschen. `rule_not_found`, wenn keine vorhanden ist. | +| `cycle-app-source` | `direction` *(erf.)*, `bundle` *(optional; Standard = vorderste App)* | Die eigene Regel dieser App zur nächsten/vorherigen Quelle weiterschalten. Wirkungslos (`rule_not_found`), wenn die App keine Regel hat. | +| `remove-frontmost-app-rule` | — | Die Regel für die jeweils vorderste App löschen. | +| `clear-app-rules` | — | **Alle** Regeln pro App entfernen. | + +### General settings + +| Command | Parameters | Effect | +|---|---|---| +| `set-launch-at-login` *(alias `launch-at-login`)* | `enabled` = `true` \| `false` \| `toggle` | LockIME als Anmeldeobjekt registrieren/deregistrieren. | +| `set-language` | `code` = `en` \| `zh-Hans` \| `zh-Hant` \| `ja` \| `fr` \| `de` \| `es` \| `pt` \| `ru` \| `system` | Die in-App-Sprachüberschreibung festlegen; `system` (alias `auto`) löscht sie und folgt der macOS-Sprache. Nachsichtig: `zh-CN`→`zh-Hans`, `zh-TW`→`zh-Hant`, `fr-CA`→`fr`, … | + +### Enhanced mode & per-URL rules + +Regeln pro URL erfordern den optionalen, über Accessibility freigeschalteten +**erweiterten Modus**. + +| Command | Parameters | Effect | +|---|---|---| +| `set-enhanced-mode` | `enabled` = `true` \| `false` \| `toggle` | Den erweiterten Modus ein-/ausschalten (oder umschalten). | +| `set-url-rule` | `host` *(erf.)*, `source` \| `source-name` *(erf.)*, `action` = `lock` \| `switch` *(Standard `lock`)*, `id` *(optionale UUID)* | Eine Regel pro URL erstellen oder ersetzen. `host` ist ein Muster wie `github.com` (passt auf Subdomains) oder `*.example.com`. Ohne `id` wird eine bestehende Regel für denselben Host aktualisiert statt dupliziert. | +| `remove-url-rule` | `id` *(UUID)* \| `host` | Eine URL-Regel über ihre `id` (aus `list-url-rules`) oder über `host` löschen. | +| `clear-url-rules` | — | **Alle** Regeln pro URL entfernen. | + +### App + +| Command | Parameters | Effect | +|---|---|---| +| `quit` | — | LockIME beenden. | + +(Siehe auch [`set-language`](#general-settings) und [`set-launch-at-login`](#general-settings).) + +LockIME stellt bewusst **keine Befehle bereit, die seine Oberfläche öffnen** +(Settings, About, Update-Fenster): Die API dient der kopflosen Automatisierung, +nicht dem Steuern von Fenstern. + +### Queries + +Abfragebefehle geben eine JSON-Nutzlast über den `x-success`-Rückruf zurück +(siehe [x-callback-url](#x-callback-url)). + +| Command | Result | +|---|---| +| `status` | Der gesamte Zustand — siehe [unten](#status-payload). | +| `current-source` | `{ "id": "...", "name": "..." }` der aktiven Quelle. | +| `list-sources` *(alias `sources`)* | Array installierter Quellen: `{ "id", "name", "isCJKV", "isEnabled", "isSelectCapable" }`. | +| `list-app-rules` *(alias `app-rules`)* | Array von `{ "bundleID", "mode", "source"? }`. | +| `list-url-rules` *(alias `url-rules`)* | Array von `{ "id", "host", "action", "source" }`. | +| `list-log` *(aliases `log`, `recent-activations`)* | Die letzten 24 h an Zwangsumschaltungs-Einträgen, neueste zuerst: `{ "timestamp", "inputSource", "inputSourceName", "reason", "durationMs", "fromSourceName"?, "app"?, "bundleID"?, "ruleSource"?, "matchedHost"? }`. | +| `get-config` *(alias `config`)* | Das vollständige persistierte Konfigurationsobjekt. | +| `version` | `{ "version": "x.y.z", "build": "n" }`. | +| `ping` | `{ "ok": true, "app": "LockIME", "version": "x.y.z", "build": "n" }` — eine günstige Präsenz-/Versionssonde. | + +#### `status` payload + +```json +{ + "locked": true, + "enhancedMode": false, + "launchAtLogin": true, + "accessibilityGranted": true, + "activationCount": 42, + "language": "en", + "version": "1.2.0", + "build": "20260615", + "currentSource": { "id": "com.apple.keylayout.ABC", "name": "ABC" }, + "defaultSource": { "id": "com.apple.keylayout.ABC", "name": "ABC" }, + "frontmostApp": "com.apple.Safari" +} +``` + +`currentSource`, `defaultSource` und `frontmostApp` sind nur vorhanden, wenn sie +bekannt sind. + +--- + +## Errors + +Bei einem Fehlschlag (und wenn ein `x-error`-Rückruf vorhanden ist) hängt LockIME +einen stabilen maschinenlesbaren `errorCode` und eine menschenlesbare +`errorMessage` an. Der Fehlertext ist **bewusst englisch und stabil** — er gelangt +in deine App und in Protokolle, daher wird er niemals lokalisiert. + +| `errorCode` | When | +|---|---| +| `api_disabled` | Die API ist aus — aktiviere sie unter Einstellungen ▸ Allgemein ▸ Automatisierung. | +| `malformed_url` | Die URL konnte nicht geparst werden. | +| `no_command` | Es wurde kein Befehlstoken angegeben. | +| `unknown_command` | Das Befehlstoken wird nicht erkannt. | +| `missing_parameter` | Ein erforderlicher Parameter fehlt. | +| `invalid_parameter` | Ein Parameterwert liegt außerhalb des gültigen Bereichs (ungültiges `mode`, `action`, `direction`, `code` oder UUID). | +| `unknown_source` | Die `id`/`name` passt auf keine installierte auswählbare Quelle. | +| `no_input_sources` | Es sind keine auswählbaren Eingabequellen installiert. | +| `rule_not_found` | Die anvisierte Regel pro App/URL existiert nicht. | +| `not_supported` | Der Vorgang konnte nicht abgeschlossen werden (z. B. Konfigurations-Serialisierung). | + +--- + +## Examples + +**Shell / `open(1)`** + +```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" +open "lockime://set-url-rule?host=github.com&source=com.apple.keylayout.ABC&action=switch" +open "lockime://set-launch-at-login?enabled=on" +``` + +**AppleScript** + +```applescript +open location "lockime://toggle-lock" +``` + +**Shortcuts (macOS)** + +Füge eine **Open URLs**-Aktion mit `lockime://lock` hinzu oder **Get Contents of +URL** plus die x-callback-url-Form, um den Zustand auszulesen. + +**Status aus einem Skript lesen** (mithilfe einer Callback-Empfänger-App/-URL): + +```sh +open "lockime://status?x-success=myreceiver%3A%2F%2Fstatus" +``` + +--- + +## Notes & guarantees + +- **Idempotent und umkehrbar.** Einen Befehl erneut zu senden ist sicher; nichts + wird über die von dir erbetenen Regeländerungen hinaus zerstört. +- **Stiehlt niemals den Fokus.** Kein Befehl bringt LockIME in den Vordergrund + oder öffnet eines seiner Fenster — die API ist von Grund auf kopflos. +- **Sperren bleiben maßgeblich.** `switch-source` ist eine einmalige + Höflichkeitsumschaltung; eine bestehende kontinuierliche Sperre setzt ihre + Quelle erneut durch. +- **Die Quellidentität ist die `id`.** Anzeigenamen sind eine Bequemlichkeit und + hängen von der System-Locale ab; bevorzuge für stabile Automatisierung die `id` + (aus `list-sources`). +- **Backups schließen die API nicht ein.** Der Konfigurations-Export/-Import + (`.lockime`-Dateien) umfasst deine Regeln, nicht etwas API-Spezifisches — es gibt + keinen separaten API-Zustand, der mitgeführt werden müsste. diff --git a/docs/URL-Scheme-API/README.es.md b/docs/URL-Scheme-API/README.es.md new file mode 100644 index 0000000..c39afb3 --- /dev/null +++ b/docs/URL-Scheme-API/README.es.md @@ -0,0 +1,248 @@ +# URL Scheme API + +[English](README.md) · [简体中文](README.zh-CN.md) · [繁體中文](README.zh-TW.md) · [日本語](README.ja.md) · [Français](README.fr.md) · [Deutsch](README.de.md) · **Español** · [Português](README.pt.md) · [Русский](README.ru.md) + +LockIME expone un esquema de URL `lockime://` para que otras aplicaciones, scripts, Shortcuts, +Stream Deck, Alfred/Raycast, AppleScript — cualquier cosa que pueda abrir una URL — puedan +controlarlo: activar o desactivar el bloqueo, recambiar la fuente de entrada, gestionar reglas y leer +el estado de vuelta. + +Cada comando es una URL, fire-and-forget por defecto, con callbacks opcionales de +[x-callback-url](https://x-callback-url.com) para el éxito o el error y para +devolver datos desde los comandos de consulta. + +> **Actívala primero.** La URL Scheme API está **desactivada por defecto**. Actívala en +> **LockIME ▸ Ajustes ▸ General ▸ Automatización ▸ URL Scheme API**. Mientras esté desactivada, +> cada comando devuelve el error `api_disabled` y no ocurre nada. + +> **Nota de seguridad.** Una vez activada, los comandos se ejecutan **sin una confirmación +> por comando** — cualquier proceso que pueda abrir una URL `lockime://` (incluida una página +> web) puede controlar LockIME. Todos los comandos son reversibles y ninguno toca tus archivos; +> lo peor que puede hacer un llamador malintencionado es activar o desactivar el bloqueo de tu +> fuente de entrada o editar reglas. Mantén la API desactivada cuando no la estés usando. + +--- + +## URL shape + +Se aceptan dos formas equivalentes: + +``` +lockime://?=&= +lockime://x-callback-url/?=&… +``` + +- El **token de comando** (``) no distingue entre mayúsculas y minúsculas. +- Los **nombres de parámetro** no distinguen entre mayúsculas y minúsculas; los **valores de parámetro** se toman + literalmente (así los bundle IDs y los source IDs conservan sus mayúsculas y minúsculas). +- Codifica siempre con **percent-encode** los valores que contengan caracteres reservados + (`?`, `&`, `=`, `/`, espacios, …). Un nombre visible de fuente como `ABC – Extended` + se convierte en `name=ABC%20%E2%80%93%20Extended`. + +El prefijo `x-callback-url/` es azúcar opcional para las herramientas de x-callback-url; los +parámetros de callback de más abajo también funcionan en la forma simple. + +> **Compilaciones de desarrollo.** Una compilación Debug de LockIME registra `lockime-dev://` +> en lugar de `lockime://`, de modo que una compilación local nunca secuestra el esquema de la +> versión instalada. Todo lo demás es idéntico. + +--- + +## x-callback-url + +Cualquier comando puede llevar estos parámetros reservados: + +| Parameter | Meaning | +|---|---| +| `x-success` | URL que se abre después de que el comando tiene éxito. Para los comandos de **consulta** el resultado JSON se añade como `result=` (codificado con percent-encode). | +| `x-error` | URL que se abre si el comando falla, con `errorCode=&errorMessage=` añadido. | +| `x-source` | Un nombre visible de la aplicación que llama (informativo; LockIME lo registra). | + +Los comandos de acción disparan `x-success` sin `result`. Los comandos de consulta devuelven su +carga útil a través de `x-success`; sin una URL `x-success` una consulta simplemente no tiene +adónde enviar su resultado (igual se ejecuta, sin causar daño). + +Ejemplo de ida y vuelta — solicita el estado y recíbelo de vuelta en tu propia aplicación: + +``` +lockime://status?x-success=myapp%3A%2F%2Fgot-status +``` + +Si tiene éxito, LockIME abre: + +``` +myapp://got-status?result=%7B%22locked%22%3Atrue%2C…%7D +``` + +--- + +## Command reference + +### Master lock + +| Command | Parameters | Effect | +|---|---|---| +| `lock` | — | Activa el bloqueo maestro (**on**). | +| `unlock` | — | Desactiva el bloqueo maestro (**off**). | +| `toggle-lock` *(alias `toggle`)* | — | Invierte el bloqueo maestro. | + +### Global input source + +Una **fuente** se identifica por `id` (el identificador canónico de Text Input Source, p. ej. +`com.apple.keylayout.ABC`, tal como lo devuelve [`list-sources`](#queries)) o por +`name` (su nombre visible localizado, sin distinguir mayúsculas y minúsculas). Debe nombrar una fuente +instalada y seleccionable actualmente, o el comando devuelve `unknown_source`. + +| Command | Parameters | Effect | +|---|---|---| +| `lock-to-source` | `id` \| `name` | Establece la fuente predeterminada global **y** activa el bloqueo. | +| `set-default-source` | `id` \| `name` *(omite ambos para borrarla)* | Establece (o borra) la fuente predeterminada global sin cambiar el estado activado/desactivado. | +| `cycle-source` | `direction` = `next` \| `previous` | Avanza el objetivo global a la fuente instalada siguiente/anterior (con vuelta al inicio) y activa el bloqueo. | +| `switch-source` | `id` \| `name` | Cambia la fuente de entrada actual **una sola vez**, ahora mismo: **no** activa ni modifica ningún bloqueo continuo. Si ya hay un bloqueo continuo activo, este prevalece y devuelve la fuente a su objetivo. | + +`direction` también acepta los alias `prev`, `forward`, `back`, `up`, `down`. + +### Per-app rules + +| Command | Parameters | Effect | +|---|---|---| +| `set-app-rule` | `bundle` *(req)*, `mode` = `lock` \| `switch` \| `ignore` \| `default` *(default `lock`)*, `source` \| `source-name` *(req for `lock`/`switch`)* | Crea o reemplaza la regla de una aplicación. `lock` aplica la fuente de forma continua; `switch` cambia una vez al activarse y luego la suelta; `ignore` desactiva el bloqueo para esa aplicación; `default` recurre a la fuente predeterminada global. | +| `remove-app-rule` | `bundle` *(req)* | Elimina la regla de `bundle`. `rule_not_found` si no hay ninguna. | +| `cycle-app-source` | `direction` *(req)*, `bundle` *(optional; default = frontmost app)* | Avanza la propia regla de esa aplicación a la fuente siguiente/anterior. No hace nada (`rule_not_found`) si la aplicación no tiene regla. | +| `remove-frontmost-app-rule` | — | Elimina la regla de la aplicación que esté en primer plano. | +| `clear-app-rules` | — | Elimina **todas** las reglas por aplicación. | + +### General settings + +| Command | Parameters | Effect | +|---|---|---| +| `set-launch-at-login` *(alias `launch-at-login`)* | `enabled` = `true` \| `false` \| `toggle` | Registra/anula el registro de LockIME como elemento de inicio de sesión. | +| `set-language` | `code` = `en` \| `zh-Hans` \| `zh-Hant` \| `ja` \| `fr` \| `de` \| `es` \| `pt` \| `ru` \| `system` | Establece la anulación de idioma en la aplicación; `system` (alias `auto`) la borra y sigue el idioma de macOS. Indulgente: `zh-CN`→`zh-Hans`, `zh-TW`→`zh-Hant`, `fr-CA`→`fr`, … | + +### Enhanced mode & per-URL rules + +Las reglas por URL requieren el **modo mejorado** opcional protegido por Accessibility. + +| Command | Parameters | Effect | +|---|---|---| +| `set-enhanced-mode` | `enabled` = `true` \| `false` \| `toggle` | Activa o desactiva el modo mejorado (o lo invierte). | +| `set-url-rule` | `host` *(req)*, `source` \| `source-name` *(req)*, `action` = `lock` \| `switch` *(default `lock`)*, `id` *(optional UUID)* | Crea o reemplaza una regla por URL. `host` es un patrón como `github.com` (coincide con subdominios) o `*.example.com`. Sin `id`, se actualiza una regla existente del mismo host en lugar de duplicarla. | +| `remove-url-rule` | `id` *(UUID)* \| `host` | Elimina una regla de URL por su `id` (de `list-url-rules`) o por `host`. | +| `clear-url-rules` | — | Elimina **todas** las reglas por URL. | + +### App + +| Command | Parameters | Effect | +|---|---|---| +| `quit` | — | Cierra LockIME. | + +(Ver también [`set-language`](#general-settings) y [`set-launch-at-login`](#general-settings).) + +LockIME no expone deliberadamente **ningún comando que abra su interfaz** (Ajustes, Acerca de, +ventana de actualización): la API es para automatización sin interfaz, no para controlar ventanas. + +### Queries + +Los comandos de consulta devuelven una carga útil JSON a través del callback `x-success` (ver +[x-callback-url](#x-callback-url)). + +| Command | Result | +|---|---| +| `status` | El estado completo — ver [más abajo](#status-payload). | +| `current-source` | `{ "id": "...", "name": "..." }` de la fuente activa. | +| `list-sources` *(alias `sources`)* | Array de fuentes instaladas: `{ "id", "name", "isCJKV", "isEnabled", "isSelectCapable" }`. | +| `list-app-rules` *(alias `app-rules`)* | Array de `{ "bundleID", "mode", "source"? }`. | +| `list-url-rules` *(alias `url-rules`)* | Array de `{ "id", "host", "action", "source" }`. | +| `list-log` *(aliases `log`, `recent-activations`)* | Las últimas 24 h de entradas de cambio forzado, las más recientes primero: `{ "timestamp", "inputSource", "inputSourceName", "reason", "durationMs", "fromSourceName"?, "app"?, "bundleID"?, "ruleSource"?, "matchedHost"? }`. | +| `get-config` *(alias `config`)* | El objeto de configuración persistido completo. | +| `version` | `{ "version": "x.y.z", "build": "n" }`. | +| `ping` | `{ "ok": true, "app": "LockIME", "version": "x.y.z", "build": "n" }` — una sonda barata de presencia/versión. | + +#### `status` payload + +```json +{ + "locked": true, + "enhancedMode": false, + "launchAtLogin": true, + "accessibilityGranted": true, + "activationCount": 42, + "language": "en", + "version": "1.2.0", + "build": "20260615", + "currentSource": { "id": "com.apple.keylayout.ABC", "name": "ABC" }, + "defaultSource": { "id": "com.apple.keylayout.ABC", "name": "ABC" }, + "frontmostApp": "com.apple.Safari" +} +``` + +`currentSource`, `defaultSource` y `frontmostApp` están presentes solo cuando se conocen. + +--- + +## Errors + +En caso de fallo (y si hay un callback `x-error` presente) LockIME añade un `errorCode` +estable para máquinas y un `errorMessage` para humanos. El texto de error es **inglés y +estable** por diseño — cruza hacia tu aplicación y hacia los registros, por lo que nunca se +localiza. + +| `errorCode` | When | +|---|---| +| `api_disabled` | La API está desactivada — actívala en Ajustes ▸ General ▸ Automatización. | +| `malformed_url` | No se pudo analizar la URL. | +| `no_command` | No se proporcionó ningún token de comando. | +| `unknown_command` | El token de comando no se reconoce. | +| `missing_parameter` | Falta un parámetro obligatorio. | +| `invalid_parameter` | El valor de un parámetro está fuera de rango (`mode`, `action`, `direction`, `code` o UUID incorrecto). | +| `unknown_source` | El `id`/`name` no coincide con ninguna fuente instalada y seleccionable. | +| `no_input_sources` | No hay ninguna fuente de entrada seleccionable instalada. | +| `rule_not_found` | La regla por aplicación/URL indicada no existe. | +| `not_supported` | La operación no se pudo completar (p. ej. la serialización de la configuración). | + +--- + +## Examples + +**Shell / `open(1)`** + +```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" +open "lockime://set-url-rule?host=github.com&source=com.apple.keylayout.ABC&action=switch" +open "lockime://set-launch-at-login?enabled=on" +``` + +**AppleScript** + +```applescript +open location "lockime://toggle-lock" +``` + +**Shortcuts (macOS)** + +Añade una acción **Open URLs** con `lockime://lock`, o **Get Contents of URL** +más la forma de x-callback-url para leer el estado de vuelta. + +**Leer el estado desde un script** (usando una aplicación/URL receptora del callback): + +```sh +open "lockime://status?x-success=myreceiver%3A%2F%2Fstatus" +``` + +--- + +## Notes & guarantees + +- **Idempotente y reversible.** Reenviar un comando es seguro; no se destruye + nada más allá de las ediciones de reglas que solicites. +- **Nunca roba el foco.** Ningún comando trae LockIME al primer plano ni abre + ninguna de sus ventanas — la API es sin interfaz por diseño. +- **Los bloqueos siguen siendo la autoridad.** `switch-source` es un cambio de cortesía de + una sola vez; un bloqueo continuo en vigor volverá a imponer su fuente. +- **La identidad de la fuente es el `id`.** Los nombres visibles son una comodidad y dependen del + idioma del sistema; prefiere `id` (de `list-sources`) para una automatización estable. +- **Las copias de seguridad no incluyen la API.** La exportación/importación de la configuración (archivos `.lockime`) + cubre tus reglas, no nada específico de la API — no hay un estado de API separado + que transportar. diff --git a/docs/URL-Scheme-API/README.fr.md b/docs/URL-Scheme-API/README.fr.md new file mode 100644 index 0000000..a952a3a --- /dev/null +++ b/docs/URL-Scheme-API/README.fr.md @@ -0,0 +1,250 @@ +# URL Scheme API + +[English](README.md) · [简体中文](README.zh-CN.md) · [繁體中文](README.zh-TW.md) · [日本語](README.ja.md) · **Français** · [Deutsch](README.de.md) · [Español](README.es.md) · [Português](README.pt.md) · [Русский](README.ru.md) + +LockIME expose un schéma d'URL `lockime://` afin que d'autres applications, scripts, +Shortcuts, Stream Deck, Alfred/Raycast, AppleScript — tout ce qui peut ouvrir une URL — +puissent le piloter : activer/désactiver le verrouillage, recibler la source de saisie, +gérer les règles et relire l'état. + +Chaque commande est une URL, par défaut sans attente de réponse, avec des rappels +[x-callback-url](https://x-callback-url.com) optionnels pour le succès/l'erreur et pour +retourner des données depuis les commandes de requête. + +> **Activez-la d'abord.** L'API URL Scheme est **désactivée par défaut**. Activez-la dans +> **LockIME ▸ Réglages ▸ Général ▸ Automatisation ▸ URL Scheme API**. Tant qu'elle est +> désactivée, chaque commande renvoie l'erreur `api_disabled` et rien ne se passe. + +> **Note de sécurité.** Une fois activée, les commandes s'exécutent **sans confirmation +> par commande** — n'importe quel processus capable d'ouvrir une URL `lockime://` (y compris +> une page web) peut piloter LockIME. Chaque commande est réversible et aucune ne touche à +> vos fichiers ; le pire qu'un appelant malveillant puisse faire est de basculer votre verrou +> de source de saisie ou de modifier des règles. Laissez l'API désactivée lorsque vous ne +> l'utilisez pas. + +--- + +## URL shape + +Deux formes équivalentes sont acceptées : + +``` +lockime://?=&= +lockime://x-callback-url/?=&… +``` + +- Le **jeton de commande** (``) est insensible à la casse. +- Les **noms de paramètres** sont insensibles à la casse ; les **valeurs de paramètres** + sont prises telles quelles (les bundle IDs et source IDs conservent donc leur casse). +- Encodez **toujours en pourcentage** les valeurs contenant des caractères réservés + (`?`, `&`, `=`, `/`, espaces, …). Un nom d'affichage de source comme `ABC – Extended` + devient `name=ABC%20%E2%80%93%20Extended`. + +Le préfixe `x-callback-url/` est un sucre syntaxique optionnel pour l'outillage +x-callback-url ; les paramètres de rappel ci-dessous fonctionnent aussi sur la forme nue. + +> **Development builds.** Un build Debug de LockIME enregistre `lockime-dev://` +> au lieu de `lockime://`, de sorte qu'un build local ne détourne jamais le schéma +> de la version installée. Tout le reste est identique. + +--- + +## x-callback-url + +Toute commande peut porter ces paramètres réservés : + +| Parameter | Meaning | +|---|---| +| `x-success` | URL ouverte une fois la commande réussie. Pour les commandes de **requête**, le résultat JSON est ajouté sous la forme `result=` (encodé en pourcentage). | +| `x-error` | URL ouverte si la commande échoue, avec `errorCode=&errorMessage=` ajouté. | +| `x-source` | Un nom d'affichage pour l'application appelante (informatif ; LockIME le journalise). | + +Les commandes d'action déclenchent `x-success` sans `result`. Les commandes de requête +retournent leur charge utile via `x-success` ; sans URL `x-success`, une requête n'a +simplement nulle part où envoyer son résultat (elle s'exécute quand même, sans dommage). + +Exemple d'aller-retour — demander l'état et le recevoir dans votre propre application : + +``` +lockime://status?x-success=myapp%3A%2F%2Fgot-status +``` + +En cas de succès, LockIME ouvre : + +``` +myapp://got-status?result=%7B%22locked%22%3Atrue%2C…%7D +``` + +--- + +## Command reference + +### Master lock + +| Command | Parameters | Effect | +|---|---|---| +| `lock` | — | Activer le verrou principal (**on**). | +| `unlock` | — | Désactiver le verrou principal (**off**). | +| `toggle-lock` *(alias `toggle`)* | — | Inverser le verrou principal. | + +### Global input source + +Une **source** est désignée par `id` (l'identifiant canonique Text Input Source, p. ex. +`com.apple.keylayout.ABC`, tel que retourné par [`list-sources`](#queries)) ou par +`name` (son nom d'affichage localisé, insensible à la casse). Elle doit désigner une source +actuellement installée et sélectionnable, sinon la commande renvoie `unknown_source`. + +| Command | Parameters | Effect | +|---|---|---| +| `lock-to-source` | `id` \| `name` | Définir la source par défaut globale **et** activer le verrouillage. | +| `set-default-source` | `id` \| `name` *(omit both to clear)* | Définir (ou effacer) la source par défaut globale sans changer l'état activé/désactivé. | +| `cycle-source` | `direction` = `next` \| `previous` | Faire passer la cible globale à la source installée suivante/précédente (avec bouclage) et activer le verrouillage. | +| `switch-source` | `id` \| `name` | Change la source de saisie actuelle **une seule fois**, maintenant — cela n'**active ni ne modifie** aucun verrou continu. Si un verrou continu est déjà actif, il l'emporte et rétablit la source sur sa cible. | + +`direction` accepte aussi les alias `prev`, `forward`, `back`, `up`, `down`. + +### Per-app rules + +| Command | Parameters | Effect | +|---|---|---| +| `set-app-rule` | `bundle` *(req)*, `mode` = `lock` \| `switch` \| `ignore` \| `default` *(default `lock`)*, `source` \| `source-name` *(req for `lock`/`switch`)* | Créer ou remplacer la règle d'une application. `lock` impose continuellement la source ; `switch` bascule une fois à l'activation puis relâche ; `ignore` désactive le verrouillage pour cette application ; `default` revient à la valeur par défaut globale. | +| `remove-app-rule` | `bundle` *(req)* | Supprimer la règle pour `bundle`. `rule_not_found` s'il n'y en a aucune. | +| `cycle-app-source` | `direction` *(req)*, `bundle` *(optional; default = frontmost app)* | Faire passer la règle propre à cette application à la source suivante/précédente. Sans effet (`rule_not_found`) si l'application n'a pas de règle. | +| `remove-frontmost-app-rule` | — | Supprimer la règle de l'application qui est au premier plan. | +| `clear-app-rules` | — | Supprimer **toutes** les règles par application. | + +### General settings + +| Command | Parameters | Effect | +|---|---|---| +| `set-launch-at-login` *(alias `launch-at-login`)* | `enabled` = `true` \| `false` \| `toggle` | Enregistrer/désenregistrer LockIME comme élément de connexion. | +| `set-language` | `code` = `en` \| `zh-Hans` \| `zh-Hant` \| `ja` \| `fr` \| `de` \| `es` \| `pt` \| `ru` \| `system` | Définir le remplacement de langue dans l'application ; `system` (alias `auto`) l'efface et suit la langue de macOS. Tolérant : `zh-CN`→`zh-Hans`, `zh-TW`→`zh-Hant`, `fr-CA`→`fr`, … | + +### Enhanced mode & per-URL rules + +Les règles par URL nécessitent le **mode renforcé** optionnel, soumis à l'autorisation Accessibility. + +| Command | Parameters | Effect | +|---|---|---| +| `set-enhanced-mode` | `enabled` = `true` \| `false` \| `toggle` | Activer/désactiver le mode renforcé (ou l'inverser). | +| `set-url-rule` | `host` *(req)*, `source` \| `source-name` *(req)*, `action` = `lock` \| `switch` *(default `lock`)*, `id` *(optional UUID)* | Créer ou remplacer une règle par URL. `host` est un motif comme `github.com` (correspond aux sous-domaines) ou `*.example.com`. Sans `id`, une règle existante pour le même hôte est mise à jour plutôt que dupliquée. | +| `remove-url-rule` | `id` *(UUID)* \| `host` | Supprimer une règle d'URL par son `id` (issu de `list-url-rules`) ou par `host`. | +| `clear-url-rules` | — | Supprimer **toutes** les règles par URL. | + +### App + +| Command | Parameters | Effect | +|---|---|---| +| `quit` | — | Quitter LockIME. | + +(Voir aussi [`set-language`](#general-settings) et [`set-launch-at-login`](#general-settings).) + +LockIME n'expose délibérément **aucune commande qui ouvre son interface** (Réglages, À propos, +fenêtre de mise à jour) : l'API est destinée à l'automatisation sans interface, pas au pilotage de fenêtres. + +### Queries + +Les commandes de requête retournent une charge utile JSON via le rappel `x-success` (voir +[x-callback-url](#x-callback-url)). + +| Command | Result | +|---|---| +| `status` | L'état complet — voir [ci-dessous](#status-payload). | +| `current-source` | `{ "id": "...", "name": "..." }` de la source active. | +| `list-sources` *(alias `sources`)* | Tableau des sources installées : `{ "id", "name", "isCJKV", "isEnabled", "isSelectCapable" }`. | +| `list-app-rules` *(alias `app-rules`)* | Tableau de `{ "bundleID", "mode", "source"? }`. | +| `list-url-rules` *(alias `url-rules`)* | Tableau de `{ "id", "host", "action", "source" }`. | +| `list-log` *(aliases `log`, `recent-activations`)* | Les 24 dernières heures d'entrées de basculement forcé, du plus récent au plus ancien : `{ "timestamp", "inputSource", "inputSourceName", "reason", "durationMs", "fromSourceName"?, "app"?, "bundleID"?, "ruleSource"?, "matchedHost"? }`. | +| `get-config` *(alias `config`)* | L'objet de configuration persistée complet. | +| `version` | `{ "version": "x.y.z", "build": "n" }`. | +| `ping` | `{ "ok": true, "app": "LockIME", "version": "x.y.z", "build": "n" }` — une sonde de présence/version peu coûteuse. | + +#### `status` payload + +```json +{ + "locked": true, + "enhancedMode": false, + "launchAtLogin": true, + "accessibilityGranted": true, + "activationCount": 42, + "language": "en", + "version": "1.2.0", + "build": "20260615", + "currentSource": { "id": "com.apple.keylayout.ABC", "name": "ABC" }, + "defaultSource": { "id": "com.apple.keylayout.ABC", "name": "ABC" }, + "frontmostApp": "com.apple.Safari" +} +``` + +`currentSource`, `defaultSource` et `frontmostApp` ne sont présents que lorsqu'ils sont connus. + +--- + +## Errors + +En cas d'échec (et lorsqu'un rappel `x-error` est présent), LockIME ajoute un +`errorCode` machine stable et un `errorMessage` lisible par un humain. Le texte d'erreur est +**en anglais et stable** par conception — il franchit la frontière vers votre application et +vers les journaux, il n'est donc jamais localisé. + +| `errorCode` | When | +|---|---| +| `api_disabled` | L'API est désactivée — activez-la dans Réglages ▸ Général ▸ Automatisation. | +| `malformed_url` | L'URL n'a pas pu être analysée. | +| `no_command` | Aucun jeton de commande n'a été fourni. | +| `unknown_command` | Le jeton de commande n'est pas reconnu. | +| `missing_parameter` | Un paramètre requis est absent. | +| `invalid_parameter` | Une valeur de paramètre est hors plage (mauvais `mode`, `action`, `direction`, `code`, ou UUID). | +| `unknown_source` | L'`id`/`name` ne correspond à aucune source installée et sélectionnable. | +| `no_input_sources` | Aucune source de saisie sélectionnable n'est installée. | +| `rule_not_found` | La règle d'application/URL ciblée n'existe pas. | +| `not_supported` | L'opération n'a pas pu être menée à bien (p. ex. sérialisation de la configuration). | + +--- + +## Examples + +**Shell / `open(1)`** + +```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" +open "lockime://set-url-rule?host=github.com&source=com.apple.keylayout.ABC&action=switch" +open "lockime://set-launch-at-login?enabled=on" +``` + +**AppleScript** + +```applescript +open location "lockime://toggle-lock" +``` + +**Shortcuts (macOS)** + +Ajoutez une action **Open URLs** avec `lockime://lock`, ou **Get Contents of URL** +plus la forme x-callback-url pour relire l'état. + +**Lire l'état depuis un script** (à l'aide d'une application/URL réceptrice de rappel) : + +```sh +open "lockime://status?x-success=myreceiver%3A%2F%2Fstatus" +``` + +--- + +## Notes & guarantees + +- **Idempotent et réversible.** Renvoyer une commande est sans risque ; rien n'est + détruit au-delà des modifications de règles que vous demandez. +- **Ne vole jamais le focus.** Aucune commande ne met LockIME au premier plan ni n'ouvre + l'une de ses fenêtres — l'API est sans interface par conception. +- **Les verrous restent autoritaires.** `switch-source` est un basculement de courtoisie + unique ; un verrou continu en place réimposera sa source. +- **L'identité d'une source est son `id`.** Les noms d'affichage sont une commodité et + dépendent de la locale système ; préférez `id` (issu de `list-sources`) pour une + automatisation stable. +- **Les sauvegardes n'incluent pas l'API.** L'export/import de configuration (fichiers + `.lockime`) couvre vos règles, pas ce qui est spécifique à l'API — il n'y a pas d'état + d'API séparé à transporter. diff --git a/docs/URL-Scheme-API/README.ja.md b/docs/URL-Scheme-API/README.ja.md new file mode 100644 index 0000000..a737db9 --- /dev/null +++ b/docs/URL-Scheme-API/README.ja.md @@ -0,0 +1,248 @@ +# URL Scheme API + +[English](README.md) · [简体中文](README.zh-CN.md) · [繁體中文](README.zh-TW.md) · **日本語** · [Français](README.fr.md) · [Deutsch](README.de.md) · [Español](README.es.md) · [Português](README.pt.md) · [Русский](README.ru.md) + +LockIME は `lockime://` URL スキームを公開しており、他のアプリ・スクリプト・ショートカット・ +Stream Deck・Alfred/Raycast・AppleScript——URL を開けるものなら何でも——から操作できます: +ロックの切り替え、入力ソースの再設定、ルールの管理、そして状態の読み取りが可能です。 + +各コマンドは URL であり、デフォルトでは fire-and-forget(撃ちっぱなし)ですが、成功/エラー時、 +そしてクエリコマンドからのデータ返却のために、オプションで +[x-callback-url](https://x-callback-url.com) コールバックを利用できます。 + +> **まず有効化してください。** URL Scheme API は**デフォルトでオフ**です。 +> **LockIME ▸ 設定 ▸ 一般 ▸ 自動化 ▸ URL Scheme API** でオンにしてください。オフの +> 間は、すべてのコマンドが `api_disabled` エラーを返し、何も起こりません。 + +> **セキュリティに関する注意。** 有効化すると、コマンドは**コマンドごとの確認なしで** +> 実行されます——`lockime://` URL を開けるプロセスなら何でも(Web ページも含めて) +> LockIME を操作できます。すべてのコマンドは取り消し可能で、ファイルに触れるものは一つも +> ありません。悪意のある呼び出し元にできる最悪のことは、入力ソースのロックを切り替えたり、 +> ルールを編集したりすることだけです。使用していないときは API をオフにしておいてください。 + +--- + +## URL shape + +同等の 2 つの形式を受け付けます: + +``` +lockime://?=&= +lockime://x-callback-url/?=&… +``` + +- **コマンドトークン**(``)は大文字小文字を区別しません。 +- **パラメータ名**は大文字小文字を区別しません。**パラメータ値**はそのまま + 受け取られます(そのため bundle ID や source ID は大文字小文字が保持されます)。 +- 予約文字(`?`、`&`、`=`、`/`、スペースなど)を含む値は、常に + **パーセントエンコード**してください。`ABC – Extended` のようなソースの表示名は + `name=ABC%20%E2%80%93%20Extended` になります。 + +`x-callback-url/` プレフィックスは x-callback-url ツール向けのオプションの糖衣構文です。 +以下のコールバックパラメータは、プレフィックスなしの形式でも機能します。 + +> **Development builds.** LockIME の Debug ビルドは `lockime://` の代わりに +> `lockime-dev://` を登録します。そのため、ローカルビルドがインストール済みの +> リリースのスキームを乗っ取ることはありません。それ以外はすべて同一です。 + +--- + +## x-callback-url + +どのコマンドも、これらの予約パラメータを持つことができます: + +| Parameter | Meaning | +|---|---| +| `x-success` | コマンドの成功後に開く URL。**query** コマンドの場合は、JSON 結果が `result=`(パーセントエンコード済み)として付加されます。 | +| `x-error` | コマンドが失敗した場合に開く URL。`errorCode=&errorMessage=` が付加されます。 | +| `x-source` | 呼び出し元アプリの表示名(情報目的。LockIME はこれをログに記録します)。 | + +アクションコマンドは `result` なしで `x-success` を発火します。クエリコマンドはその +ペイロードを `x-success` 経由で返します。`x-success` URL がなければ、クエリは結果を +送る先がないだけです(それでも無害に実行されます)。 + +往復の例——ステータスを問い合わせ、それを自分のアプリへ受け取ります: + +``` +lockime://status?x-success=myapp%3A%2F%2Fgot-status +``` + +成功すると LockIME は次を開きます: + +``` +myapp://got-status?result=%7B%22locked%22%3Atrue%2C…%7D +``` + +--- + +## Command reference + +### Master lock + +| Command | Parameters | Effect | +|---|---|---| +| `lock` | — | マスターロックを**オン**にします。 | +| `unlock` | — | マスターロックを**オフ**にします。 | +| `toggle-lock` *(alias `toggle`)* | — | マスターロックを反転します。 | + +### Global input source + +**ソース**は `id`(正準的な Text Input Source 識別子。例: +`com.apple.keylayout.ABC`。[`list-sources`](#queries) が返すもの)または `name` +(ローカライズされた表示名。大文字小文字を区別しない)で指定します。現在 +インストール済みで選択可能なソースを指定する必要があり、そうでなければコマンドは +`unknown_source` を返します。 + +| Command | Parameters | Effect | +|---|---|---| +| `lock-to-source` | `id` \| `name` | グローバルのデフォルトソースを設定し、**かつ**ロックをオンにします。 | +| `set-default-source` | `id` \| `name` *(omit both to clear)* | オン/オフの状態を変えずに、グローバルのデフォルトソースを設定(またはクリア)します。 | +| `cycle-source` | `direction` = `next` \| `previous` | グローバルのターゲットをインストール済みの次/前のソースへ(循環して)進め、ロックをオンにします。 | +| `switch-source` | `id` \| `name` | 現在の入力ソースを今ここで**一度だけ**切り替えます——継続ロックを有効化したり変更したりは**しません**。すでに継続ロックがアクティブな場合は、そちらが優先され、入力ソースをロックの目標に戻します。 | + +`direction` はエイリアス `prev`、`forward`、`back`、`up`、`down` も受け付けます。 + +### Per-app rules + +| Command | Parameters | Effect | +|---|---|---| +| `set-app-rule` | `bundle` *(req)*, `mode` = `lock` \| `switch` \| `ignore` \| `default` *(default `lock`)*, `source` \| `source-name` *(req for `lock`/`switch`)* | アプリのルールを作成または置き換えます。`lock` はソースを継続的に強制します。`switch` はアクティブ化時に一度だけ切り替えてから解放します。`ignore` はそのアプリのロックを無効にします。`default` はグローバルのデフォルトに従います。 | +| `remove-app-rule` | `bundle` *(req)* | `bundle` のルールを削除します。ルールがなければ `rule_not_found`。 | +| `cycle-app-source` | `direction` *(req)*, `bundle` *(optional; default = frontmost app)* | そのアプリ自身のルールを次/前のソースへ進めます。アプリにルールがなければ何もしません(`rule_not_found`)。 | +| `remove-frontmost-app-rule` | — | 最前面にあるアプリのルールを削除します。 | +| `clear-app-rules` | — | **すべて**のアプリごとのルールを削除します。 | + +### General settings + +| Command | Parameters | Effect | +|---|---|---| +| `set-launch-at-login` *(alias `launch-at-login`)* | `enabled` = `true` \| `false` \| `toggle` | LockIME をログイン項目として登録/登録解除します。 | +| `set-language` | `code` = `en` \| `zh-Hans` \| `zh-Hant` \| `ja` \| `fr` \| `de` \| `es` \| `pt` \| `ru` \| `system` | アプリ内の言語オーバーライドを設定します。`system`(エイリアス `auto`)はそれをクリアし、macOS の言語に従います。寛容に解釈します:`zh-CN`→`zh-Hans`、`zh-TW`→`zh-Hant`、`fr-CA`→`fr`、… | + +### Enhanced mode & per-URL rules + +URL ごとのルールには、オプションの Accessibility ゲート付き**拡張モード**が必要です。 + +| Command | Parameters | Effect | +|---|---|---| +| `set-enhanced-mode` | `enabled` = `true` \| `false` \| `toggle` | 拡張モードをオン/オフ(または反転)します。 | +| `set-url-rule` | `host` *(req)*, `source` \| `source-name` *(req)*, `action` = `lock` \| `switch` *(default `lock`)*, `id` *(optional UUID)* | URL ごとのルールを作成または置き換えます。`host` は `github.com`(サブドメインにマッチ)や `*.example.com` のようなパターンです。`id` がなければ、同じ host の既存ルールが複製されずに更新されます。 | +| `remove-url-rule` | `id` *(UUID)* \| `host` | URL ルールを、その `id`(`list-url-rules` から)または `host` で削除します。 | +| `clear-url-rules` | — | **すべて**の URL ごとのルールを削除します。 | + +### App + +| Command | Parameters | Effect | +|---|---|---| +| `quit` | — | LockIME を終了します。 | + +([`set-language`](#general-settings) と [`set-launch-at-login`](#general-settings) も参照。) + +LockIME は設計上、**UI を開くコマンドを一切公開していません**(設定・About・ +アップデートウィンドウ):この API はヘッドレスな自動化のためのものであり、 +ウィンドウを操作するためのものではありません。 + +### Queries + +クエリコマンドは `x-success` コールバック経由で JSON ペイロードを返します +([x-callback-url](#x-callback-url) を参照)。 + +| Command | Result | +|---|---| +| `status` | 状態全体——[下記](#status-payload)を参照。 | +| `current-source` | ライブソースの `{ "id": "...", "name": "..." }`。 | +| `list-sources` *(alias `sources`)* | インストール済みソースの配列:`{ "id", "name", "isCJKV", "isEnabled", "isSelectCapable" }`。 | +| `list-app-rules` *(alias `app-rules`)* | `{ "bundleID", "mode", "source"? }` の配列。 | +| `list-url-rules` *(alias `url-rules`)* | `{ "id", "host", "action", "source" }` の配列。 | +| `list-log` *(aliases `log`, `recent-activations`)* | 直近 24 時間の強制切り替えエントリ。新しいものから順に:`{ "timestamp", "inputSource", "inputSourceName", "reason", "durationMs", "fromSourceName"?, "app"?, "bundleID"?, "ruleSource"?, "matchedHost"? }`。 | +| `get-config` *(alias `config`)* | 永続化された設定オブジェクト全体。 | +| `version` | `{ "version": "x.y.z", "build": "n" }`。 | +| `ping` | `{ "ok": true, "app": "LockIME", "version": "x.y.z", "build": "n" }`——軽量な存在確認/バージョンプローブ。 | + +#### `status` payload + +```json +{ + "locked": true, + "enhancedMode": false, + "launchAtLogin": true, + "accessibilityGranted": true, + "activationCount": 42, + "language": "en", + "version": "1.2.0", + "build": "20260615", + "currentSource": { "id": "com.apple.keylayout.ABC", "name": "ABC" }, + "defaultSource": { "id": "com.apple.keylayout.ABC", "name": "ABC" }, + "frontmostApp": "com.apple.Safari" +} +``` + +`currentSource`、`defaultSource`、`frontmostApp` は、判明している場合にのみ含まれます。 + +--- + +## Errors + +失敗時(かつ `x-error` コールバックが存在する場合)、LockIME は安定したマシン向けの +`errorCode` と人間向けの `errorMessage` を付加します。エラーテキストは設計上**英語かつ +安定**です——あなたのアプリやログへと渡るため、決してローカライズされません。 + +| `errorCode` | When | +|---|---| +| `api_disabled` | API がオフです——「設定 ▸ 一般 ▸ 自動化」で有効化してください。 | +| `malformed_url` | URL を解析できませんでした。 | +| `no_command` | コマンドトークンが指定されませんでした。 | +| `unknown_command` | コマンドトークンが認識されませんでした。 | +| `missing_parameter` | 必須パラメータが存在しません。 | +| `invalid_parameter` | パラメータ値が範囲外です(不正な `mode`、`action`、`direction`、`code`、または UUID)。 | +| `unknown_source` | `id`/`name` がインストール済みで選択可能なソースのいずれにもマッチしません。 | +| `no_input_sources` | 選択可能な入力ソースが一つもインストールされていません。 | +| `rule_not_found` | 対象のアプリ/URL ルールが存在しません。 | +| `not_supported` | 操作を完了できませんでした(例:設定のシリアライズ)。 | + +--- + +## Examples + +**Shell / `open(1)`** + +```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" +open "lockime://set-url-rule?host=github.com&source=com.apple.keylayout.ABC&action=switch" +open "lockime://set-launch-at-login?enabled=on" +``` + +**AppleScript** + +```applescript +open location "lockime://toggle-lock" +``` + +**Shortcuts (macOS)** + +**Open URLs** アクションに `lockime://lock` を指定するか、**Get Contents of URL** +と x-callback-url 形式を組み合わせて状態を読み取ります。 + +**スクリプトからステータスを読み取る**(コールバック受信アプリ/URL を使用): + +```sh +open "lockime://status?x-success=myreceiver%3A%2F%2Fstatus" +``` + +--- + +## Notes & guarantees + +- **冪等かつ取り消し可能。** コマンドの再送信は安全です。あなたが要求したルール編集を + 超えて破壊されるものはありません。 +- **フォーカスを決して奪いません。** LockIME を前面に持ってきたり、そのウィンドウの + いずれかを開いたりするコマンドはありません——この API は設計上ヘッドレスです。 +- **ロックは権威を保ちます。** `switch-source` は一度きりの好意的な切り替えです。 + 常駐する継続ロックは自身のソースを再主張します。 +- **ソースの同一性は `id`。** 表示名は便宜的なもので、システムロケールに依存します。 + 安定した自動化には `id`(`list-sources` から)を優先してください。 +- **バックアップに API は含まれません。** 設定のエクスポート/インポート(`.lockime` + ファイル)はルールを対象とし、API 固有のものは対象外です——別途持ち運ぶべき API + 状態は存在しません。 diff --git a/docs/URL-Scheme-API/README.md b/docs/URL-Scheme-API/README.md new file mode 100644 index 0000000..3c68a31 --- /dev/null +++ b/docs/URL-Scheme-API/README.md @@ -0,0 +1,248 @@ +# URL Scheme API + +**English** · [简体中文](README.zh-CN.md) · [繁體中文](README.zh-TW.md) · [日本語](README.ja.md) · [Français](README.fr.md) · [Deutsch](README.de.md) · [Español](README.es.md) · [Português](README.pt.md) · [Русский](README.ru.md) + +LockIME exposes a `lockime://` URL scheme so other apps, scripts, Shortcuts, +Stream Deck, Alfred/Raycast, AppleScript — anything that can open a URL — can +drive it: toggle locking, retarget the input source, manage rules, and read +state back. + +Each command is a URL, fire-and-forget by default, with optional +[x-callback-url](https://x-callback-url.com) callbacks for success/error and for +returning data from query commands. + +> **Enable it first.** The URL scheme API is **off by default**. Turn it on in +> **LockIME ▸ Settings ▸ General ▸ Automation ▸ URL Scheme API**. While it is off, +> every command returns the `api_disabled` error and nothing happens. + +> **Security note.** Once enabled, commands run **without a per-command +> confirmation** — any process that can open a `lockime://` URL (including a web +> page) can drive LockIME. Every command is reversible and none touch your files; +> the worst a rogue caller can do is toggle your input-source lock or edit rules. +> Leave the API off when you are not using it. + +--- + +## URL shape + +Two equivalent forms are accepted: + +``` +lockime://?=&= +lockime://x-callback-url/?=&… +``` + +- The **command token** (``) is case-insensitive. +- **Parameter names** are case-insensitive; **parameter values** are taken + verbatim (so bundle IDs and source IDs keep their case). +- Always **percent-encode** values that contain reserved characters + (`?`, `&`, `=`, `/`, spaces, …). A source display name like `ABC – Extended` + becomes `name=ABC%20%E2%80%93%20Extended`. + +The `x-callback-url/` prefix is optional sugar for x-callback-url tooling; the +callback parameters below work on the bare form too. + +> **Development builds.** A Debug build of LockIME registers `lockime-dev://` +> instead of `lockime://`, so a local build never hijacks the installed +> release's scheme. Everything else is identical. + +--- + +## x-callback-url + +Any command may carry these reserved parameters: + +| Parameter | Meaning | +|---|---| +| `x-success` | URL opened after the command succeeds. For **query** commands the JSON result is appended as `result=` (percent-encoded). | +| `x-error` | URL opened if the command fails, with `errorCode=&errorMessage=` appended. | +| `x-source` | A display name for the calling app (informational; LockIME logs it). | + +Action commands fire `x-success` with no `result`. Query commands return their +payload through `x-success`; without an `x-success` URL a query simply has +nowhere to send its result (it still runs, harmlessly). + +Example round-trip — ask for status and receive it back into your own app: + +``` +lockime://status?x-success=myapp%3A%2F%2Fgot-status +``` + +On success LockIME opens: + +``` +myapp://got-status?result=%7B%22locked%22%3Atrue%2C…%7D +``` + +--- + +## Command reference + +### Master lock + +| Command | Parameters | Effect | +|---|---|---| +| `lock` | — | Turn the master lock **on**. | +| `unlock` | — | Turn the master lock **off**. | +| `toggle-lock` *(alias `toggle`)* | — | Flip the master lock. | + +### Global input source + +A **source** is named by `id` (the canonical Text Input Source identifier, e.g. +`com.apple.keylayout.ABC`, as returned by [`list-sources`](#queries)) or by +`name` (its localized display name, case-insensitive). It must name a currently +installed, selectable source or the command returns `unknown_source`. + +| Command | Parameters | Effect | +|---|---|---| +| `lock-to-source` | `id` \| `name` | Set the global default source **and** turn locking on. | +| `set-default-source` | `id` \| `name` *(omit both to clear)* | Set (or clear) the global default source without changing the on/off state. | +| `cycle-source` | `direction` = `next` \| `previous` | Step the global target to the next/previous installed source (wrapping) and turn locking on. | +| `switch-source` | `id` \| `name` | Switch the current input source **once**, right now — it does **not** turn on or change a continuous lock. If a continuous lock is already active, it wins and switches the source back to its target. | + +`direction` also accepts the aliases `prev`, `forward`, `back`, `up`, `down`. + +### Per-app rules + +| Command | Parameters | Effect | +|---|---|---| +| `set-app-rule` | `bundle` *(req)*, `mode` = `lock` \| `switch` \| `ignore` \| `default` *(default `lock`)*, `source` \| `source-name` *(req for `lock`/`switch`)* | Create or replace the rule for an app. `lock` continuously enforces the source; `switch` switches once on activation then releases; `ignore` disables locking for that app; `default` falls back to the global default. | +| `remove-app-rule` | `bundle` *(req)* | Delete the rule for `bundle`. `rule_not_found` if there is none. | +| `cycle-app-source` | `direction` *(req)*, `bundle` *(optional; default = frontmost app)* | Step that app's own rule to the next/previous source. No-op (`rule_not_found`) if the app has no rule. | +| `remove-frontmost-app-rule` | — | Delete the rule for whichever app is frontmost. | +| `clear-app-rules` | — | Remove **all** per-app rules. | + +### General settings + +| Command | Parameters | Effect | +|---|---|---| +| `set-launch-at-login` *(alias `launch-at-login`)* | `enabled` = `true` \| `false` \| `toggle` | Register/unregister LockIME as a login item. | +| `set-language` | `code` = `en` \| `zh-Hans` \| `zh-Hant` \| `ja` \| `fr` \| `de` \| `es` \| `pt` \| `ru` \| `system` | Set the in-app language override; `system` (alias `auto`) clears it and follows the macOS language. Lenient: `zh-CN`→`zh-Hans`, `zh-TW`→`zh-Hant`, `fr-CA`→`fr`, … | + +### Enhanced mode & per-URL rules + +Per-URL rules require the optional Accessibility-gated **enhanced mode**. + +| Command | Parameters | Effect | +|---|---|---| +| `set-enhanced-mode` | `enabled` = `true` \| `false` \| `toggle` | Turn enhanced mode on/off (or flip it). | +| `set-url-rule` | `host` *(req)*, `source` \| `source-name` *(req)*, `action` = `lock` \| `switch` *(default `lock`)*, `id` *(optional UUID)* | Create or replace a per-URL rule. `host` is a pattern like `github.com` (matches subdomains) or `*.example.com`. Without `id`, an existing rule for the same host is updated rather than duplicated. | +| `remove-url-rule` | `id` *(UUID)* \| `host` | Delete a URL rule by its `id` (from `list-url-rules`) or by `host`. | +| `clear-url-rules` | — | Remove **all** per-URL rules. | + +### App + +| Command | Parameters | Effect | +|---|---|---| +| `quit` | — | Quit LockIME. | + +(See also [`set-language`](#general-settings) and [`set-launch-at-login`](#general-settings).) + +LockIME deliberately exposes **no commands that open its UI** (Settings, About, +update window): the API is for headless automation, not for driving windows. + +### Queries + +Query commands return a JSON payload through the `x-success` callback (see +[x-callback-url](#x-callback-url)). + +| Command | Result | +|---|---| +| `status` | The whole state — see [below](#status-payload). | +| `current-source` | `{ "id": "...", "name": "..." }` of the live source. | +| `list-sources` *(alias `sources`)* | Array of installed sources: `{ "id", "name", "isCJKV", "isEnabled", "isSelectCapable" }`. | +| `list-app-rules` *(alias `app-rules`)* | Array of `{ "bundleID", "mode", "source"? }`. | +| `list-url-rules` *(alias `url-rules`)* | Array of `{ "id", "host", "action", "source" }`. | +| `list-log` *(aliases `log`, `recent-activations`)* | The last 24 h of forced-switch entries, newest first: `{ "timestamp", "inputSource", "inputSourceName", "reason", "durationMs", "fromSourceName"?, "app"?, "bundleID"?, "ruleSource"?, "matchedHost"? }`. | +| `get-config` *(alias `config`)* | The full persisted configuration object. | +| `version` | `{ "version": "x.y.z", "build": "n" }`. | +| `ping` | `{ "ok": true, "app": "LockIME", "version": "x.y.z", "build": "n" }` — a cheap presence/version probe. | + +#### `status` payload + +```json +{ + "locked": true, + "enhancedMode": false, + "launchAtLogin": true, + "accessibilityGranted": true, + "activationCount": 42, + "language": "en", + "version": "1.2.0", + "build": "20260615", + "currentSource": { "id": "com.apple.keylayout.ABC", "name": "ABC" }, + "defaultSource": { "id": "com.apple.keylayout.ABC", "name": "ABC" }, + "frontmostApp": "com.apple.Safari" +} +``` + +`currentSource`, `defaultSource`, and `frontmostApp` are present only when known. + +--- + +## Errors + +On failure (and with an `x-error` callback present) LockIME appends a stable +machine `errorCode` and a human `errorMessage`. Error text is **English and +stable** by design — it crosses into your app and into logs, so it is never +localized. + +| `errorCode` | When | +|---|---| +| `api_disabled` | The API is off — enable it in Settings ▸ General ▸ Automation. | +| `malformed_url` | The URL could not be parsed. | +| `no_command` | No command token was supplied. | +| `unknown_command` | The command token is not recognized. | +| `missing_parameter` | A required parameter is absent. | +| `invalid_parameter` | A parameter value is out of range (bad `mode`, `action`, `direction`, `code`, or UUID). | +| `unknown_source` | The `id`/`name` matches no installed selectable source. | +| `no_input_sources` | No selectable input sources are installed. | +| `rule_not_found` | The targeted app/URL rule does not exist. | +| `not_supported` | The operation could not be completed (e.g. config serialization). | + +--- + +## Examples + +**Shell / `open(1)`** + +```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" +open "lockime://set-url-rule?host=github.com&source=com.apple.keylayout.ABC&action=switch" +open "lockime://set-launch-at-login?enabled=on" +``` + +**AppleScript** + +```applescript +open location "lockime://toggle-lock" +``` + +**Shortcuts (macOS)** + +Add an **Open URLs** action with `lockime://lock`, or **Get Contents of URL** +plus the x-callback-url form to read state back. + +**Read status from a script** (using a callback receiver app/URL): + +```sh +open "lockime://status?x-success=myreceiver%3A%2F%2Fstatus" +``` + +--- + +## Notes & guarantees + +- **Idempotent and reversible.** Re-sending a command is safe; nothing is + destroyed beyond rule edits you ask for. +- **Never steals focus.** No command brings LockIME to the foreground or opens + any of its windows — the API is headless by design. +- **Locks stay authoritative.** `switch-source` is a one-shot courtesy switch; a + standing continuous lock will re-assert its source. +- **Source identity is the `id`.** Display names are a convenience and depend on + the system locale; prefer `id` (from `list-sources`) for stable automation. +- **Backups don't include the API.** Config export/import (`.lockime` files) + covers your rules, not anything API-specific — there is no separate API state + to carry. diff --git a/docs/URL-Scheme-API/README.pt.md b/docs/URL-Scheme-API/README.pt.md new file mode 100644 index 0000000..aeb5e79 --- /dev/null +++ b/docs/URL-Scheme-API/README.pt.md @@ -0,0 +1,256 @@ +# URL Scheme API + +[English](README.md) · [简体中文](README.zh-CN.md) · [繁體中文](README.zh-TW.md) · [日本語](README.ja.md) · [Français](README.fr.md) · [Deutsch](README.de.md) · [Español](README.es.md) · **Português** · [Русский](README.ru.md) + +O LockIME expõe um esquema de URL `lockime://` para que outros apps, scripts, o +Shortcuts, o Stream Deck, o Alfred/Raycast, o AppleScript — qualquer coisa que +consiga abrir uma URL — possam controlá-lo: ativar/desativar o bloqueio, +redirecionar a fonte de entrada, gerenciar regras e ler o estado de volta. + +Cada comando é uma URL, do tipo dispare-e-esqueça por padrão, com callbacks +[x-callback-url](https://x-callback-url.com) opcionais para sucesso/erro e para +retornar dados dos comandos de consulta. + +> **Ative-a primeiro.** A URL Scheme API está **desativada por padrão**. Ative-a +> em **LockIME ▸ Ajustes ▸ Geral ▸ Automação ▸ URL Scheme API**. Enquanto estiver +> desativada, todo comando retorna o erro `api_disabled` e nada acontece. + +> **Nota de segurança.** Uma vez ativada, os comandos são executados **sem uma +> confirmação por comando** — qualquer processo que consiga abrir uma URL +> `lockime://` (incluindo uma página da web) pode controlar o LockIME. Todo +> comando é reversível e nenhum toca nos seus arquivos; o pior que um chamador +> mal-intencionado pode fazer é alternar o bloqueio da sua fonte de entrada ou +> editar regras. Deixe a API desativada quando não estiver usando-a. + +--- + +## URL shape + +São aceitas duas formas equivalentes: + +``` +lockime://?=&= +lockime://x-callback-url/?=&… +``` + +- O **token de comando** (``) não diferencia maiúsculas de minúsculas. +- Os **nomes dos parâmetros** não diferenciam maiúsculas de minúsculas; os + **valores dos parâmetros** são tomados literalmente (de modo que bundle IDs e + source IDs mantêm sua capitalização). +- Sempre **codifique em percent-encode** os valores que contenham caracteres + reservados (`?`, `&`, `=`, `/`, espaços, …). Um nome de exibição de fonte como + `ABC – Extended` torna-se `name=ABC%20%E2%80%93%20Extended`. + +O prefixo `x-callback-url/` é um açúcar opcional para ferramentas de +x-callback-url; os parâmetros de callback abaixo também funcionam na forma +simples. + +> **Builds de desenvolvimento.** Uma build de Debug do LockIME registra +> `lockime-dev://` em vez de `lockime://`, de modo que uma build local nunca +> sequestra o esquema do release instalado. Todo o resto é idêntico. + +--- + +## x-callback-url + +Qualquer comando pode carregar estes parâmetros reservados: + +| Parameter | Meaning | +|---|---| +| `x-success` | URL aberta após o comando ter sucesso. Para comandos de **consulta**, o resultado JSON é anexado como `result=` (em percent-encode). | +| `x-error` | URL aberta se o comando falhar, com `errorCode=&errorMessage=` anexado. | +| `x-source` | Um nome de exibição para o app chamador (informativo; o LockIME o registra). | + +Os comandos de ação disparam `x-success` sem `result`. Os comandos de consulta +retornam seu payload através de `x-success`; sem uma URL `x-success`, uma +consulta simplesmente não tem para onde enviar seu resultado (ela ainda é +executada, de forma inofensiva). + +Exemplo de ida e volta — peça o status e receba-o de volta no seu próprio app: + +``` +lockime://status?x-success=myapp%3A%2F%2Fgot-status +``` + +Em caso de sucesso, o LockIME abre: + +``` +myapp://got-status?result=%7B%22locked%22%3Atrue%2C…%7D +``` + +--- + +## Command reference + +### Master lock + +| Command | Parameters | Effect | +|---|---|---| +| `lock` | — | Liga o bloqueio mestre (**on**). | +| `unlock` | — | Desliga o bloqueio mestre (**off**). | +| `toggle-lock` *(alias `toggle`)* | — | Inverte o bloqueio mestre. | + +### Global input source + +Uma **fonte** é identificada por `id` (o identificador canônico de Text Input +Source, p. ex. `com.apple.keylayout.ABC`, conforme retornado por +[`list-sources`](#queries)) ou por `name` (seu nome de exibição localizado, sem +diferenciar maiúsculas de minúsculas). Ela deve nomear uma fonte atualmente +instalada e selecionável, ou o comando retorna `unknown_source`. + +| Command | Parameters | Effect | +|---|---|---| +| `lock-to-source` | `id` \| `name` | Define a fonte padrão global **e** liga o bloqueio. | +| `set-default-source` | `id` \| `name` *(omita ambos para limpar)* | Define (ou limpa) a fonte padrão global sem alterar o estado ligado/desligado. | +| `cycle-source` | `direction` = `next` \| `previous` | Avança o alvo global para a próxima/anterior fonte instalada (com retorno cíclico) e liga o bloqueio. | +| `switch-source` | `id` \| `name` | Alterna a fonte de entrada atual **uma vez**, agora — **não** ativa nem modifica nenhum bloqueio contínuo. Se um bloqueio contínuo já estiver ativo, ele prevalece e devolve a fonte ao seu alvo. | + +`direction` também aceita os apelidos `prev`, `forward`, `back`, `up`, `down`. + +### Per-app rules + +| Command | Parameters | Effect | +|---|---|---| +| `set-app-rule` | `bundle` *(obrigatório)*, `mode` = `lock` \| `switch` \| `ignore` \| `default` *(padrão `lock`)*, `source` \| `source-name` *(obrigatório para `lock`/`switch`)* | Cria ou substitui a regra de um app. `lock` impõe a fonte continuamente; `switch` alterna uma vez na ativação e depois libera; `ignore` desativa o bloqueio para esse app; `default` recorre ao padrão global. | +| `remove-app-rule` | `bundle` *(obrigatório)* | Exclui a regra de `bundle`. Retorna `rule_not_found` se não houver nenhuma. | +| `cycle-app-source` | `direction` *(obrigatório)*, `bundle` *(opcional; padrão = app em primeiro plano)* | Avança a própria regra desse app para a próxima/anterior fonte. Sem efeito (`rule_not_found`) se o app não tiver regra. | +| `remove-frontmost-app-rule` | — | Exclui a regra do app que estiver em primeiro plano. | +| `clear-app-rules` | — | Remove **todas** as regras por app. | + +### General settings + +| Command | Parameters | Effect | +|---|---|---| +| `set-launch-at-login` *(alias `launch-at-login`)* | `enabled` = `true` \| `false` \| `toggle` | Registra/cancela o registro do LockIME como item de login. | +| `set-language` | `code` = `en` \| `zh-Hans` \| `zh-Hant` \| `ja` \| `fr` \| `de` \| `es` \| `pt` \| `ru` \| `system` | Define a substituição de idioma no app; `system` (alias `auto`) a limpa e segue o idioma do macOS. Tolerante: `zh-CN`→`zh-Hans`, `zh-TW`→`zh-Hant`, `fr-CA`→`fr`, … | + +### Enhanced mode & per-URL rules + +As regras por URL exigem o **modo aprimorado** opcional, condicionado à +permissão de Accessibility. + +| Command | Parameters | Effect | +|---|---|---| +| `set-enhanced-mode` | `enabled` = `true` \| `false` \| `toggle` | Liga/desliga o modo aprimorado (ou o inverte). | +| `set-url-rule` | `host` *(obrigatório)*, `source` \| `source-name` *(obrigatório)*, `action` = `lock` \| `switch` *(padrão `lock`)*, `id` *(UUID opcional)* | Cria ou substitui uma regra por URL. `host` é um padrão como `github.com` (corresponde a subdomínios) ou `*.example.com`. Sem `id`, uma regra existente para o mesmo host é atualizada em vez de duplicada. | +| `remove-url-rule` | `id` *(UUID)* \| `host` | Exclui uma regra de URL pelo seu `id` (de `list-url-rules`) ou pelo `host`. | +| `clear-url-rules` | — | Remove **todas** as regras por URL. | + +### App + +| Command | Parameters | Effect | +|---|---|---| +| `quit` | — | Encerra o LockIME. | + +(Veja também [`set-language`](#general-settings) e [`set-launch-at-login`](#general-settings).) + +O LockIME deliberadamente não expõe **nenhum comando que abra sua interface** +(Ajustes, Sobre, janela de atualização): a API é para automação headless, não +para controlar janelas. + +### Queries + +Os comandos de consulta retornam um payload JSON através do callback +`x-success` (veja [x-callback-url](#x-callback-url)). + +| Command | Result | +|---|---| +| `status` | O estado completo — veja [abaixo](#status-payload). | +| `current-source` | `{ "id": "...", "name": "..." }` da fonte ao vivo. | +| `list-sources` *(alias `sources`)* | Array das fontes instaladas: `{ "id", "name", "isCJKV", "isEnabled", "isSelectCapable" }`. | +| `list-app-rules` *(alias `app-rules`)* | Array de `{ "bundleID", "mode", "source"? }`. | +| `list-url-rules` *(alias `url-rules`)* | Array de `{ "id", "host", "action", "source" }`. | +| `list-log` *(aliases `log`, `recent-activations`)* | As últimas 24 h de entradas de troca forçada, da mais recente para a mais antiga: `{ "timestamp", "inputSource", "inputSourceName", "reason", "durationMs", "fromSourceName"?, "app"?, "bundleID"?, "ruleSource"?, "matchedHost"? }`. | +| `get-config` *(alias `config`)* | O objeto de configuração persistida completo. | +| `version` | `{ "version": "x.y.z", "build": "n" }`. | +| `ping` | `{ "ok": true, "app": "LockIME", "version": "x.y.z", "build": "n" }` — uma sonda barata de presença/versão. | + +#### `status` payload + +```json +{ + "locked": true, + "enhancedMode": false, + "launchAtLogin": true, + "accessibilityGranted": true, + "activationCount": 42, + "language": "en", + "version": "1.2.0", + "build": "20260615", + "currentSource": { "id": "com.apple.keylayout.ABC", "name": "ABC" }, + "defaultSource": { "id": "com.apple.keylayout.ABC", "name": "ABC" }, + "frontmostApp": "com.apple.Safari" +} +``` + +`currentSource`, `defaultSource` e `frontmostApp` estão presentes apenas quando conhecidos. + +--- + +## Errors + +Em caso de falha (e com um callback `x-error` presente), o LockIME anexa um +`errorCode` de máquina estável e um `errorMessage` legível por humanos. O texto +de erro é **em inglês e estável** por design — ele atravessa para o seu app e +para os logs, portanto nunca é localizado. + +| `errorCode` | When | +|---|---| +| `api_disabled` | A API está desativada — ative-a em Ajustes ▸ Geral ▸ Automação. | +| `malformed_url` | A URL não pôde ser analisada. | +| `no_command` | Nenhum token de comando foi fornecido. | +| `unknown_command` | O token de comando não é reconhecido. | +| `missing_parameter` | Um parâmetro obrigatório está ausente. | +| `invalid_parameter` | O valor de um parâmetro está fora do intervalo (`mode`, `action`, `direction`, `code` inválido, ou UUID). | +| `unknown_source` | O `id`/`name` não corresponde a nenhuma fonte selecionável instalada. | +| `no_input_sources` | Nenhuma fonte de entrada selecionável está instalada. | +| `rule_not_found` | A regra de app/URL visada não existe. | +| `not_supported` | A operação não pôde ser concluída (p. ex. serialização da configuração). | + +--- + +## Examples + +**Shell / `open(1)`** + +```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" +open "lockime://set-url-rule?host=github.com&source=com.apple.keylayout.ABC&action=switch" +open "lockime://set-launch-at-login?enabled=on" +``` + +**AppleScript** + +```applescript +open location "lockime://toggle-lock" +``` + +**Shortcuts (macOS)** + +Adicione uma ação **Open URLs** com `lockime://lock`, ou **Get Contents of URL** +mais a forma x-callback-url para ler o estado de volta. + +**Ler o status a partir de um script** (usando um app/URL receptor de callback): + +```sh +open "lockime://status?x-success=myreceiver%3A%2F%2Fstatus" +``` + +--- + +## Notes & guarantees + +- **Idempotente e reversível.** Reenviar um comando é seguro; nada é destruído + além das edições de regra que você solicitar. +- **Nunca rouba o foco.** Nenhum comando traz o LockIME para o primeiro plano + nem abre qualquer uma de suas janelas — a API é headless por design. +- **Os bloqueios permanecem autoritativos.** `switch-source` é uma troca de + cortesia única; um bloqueio contínuo em vigor reafirmará sua fonte. +- **A identidade da fonte é o `id`.** Os nomes de exibição são uma conveniência + e dependem do idioma do sistema; prefira o `id` (de `list-sources`) para uma + automação estável. +- **Os backups não incluem a API.** A exportação/importação de configuração + (arquivos `.lockime`) cobre suas regras, não nada específico da API — não há + um estado de API separado a transportar. diff --git a/docs/URL-Scheme-API/README.ru.md b/docs/URL-Scheme-API/README.ru.md new file mode 100644 index 0000000..1f98b60 --- /dev/null +++ b/docs/URL-Scheme-API/README.ru.md @@ -0,0 +1,252 @@ +# URL Scheme API + +[English](README.md) · [简体中文](README.zh-CN.md) · [繁體中文](README.zh-TW.md) · [日本語](README.ja.md) · [Français](README.fr.md) · [Deutsch](README.de.md) · [Español](README.es.md) · [Português](README.pt.md) · **Русский** + +LockIME предоставляет URL-схему `lockime://`, чтобы другие приложения, скрипты, +Shortcuts, Stream Deck, Alfred/Raycast, AppleScript — всё, что может открыть URL, — +могли управлять им: включать и выключать блокировку, менять источник ввода, +управлять правилами и считывать состояние обратно. + +Каждая команда — это URL, по умолчанию работающий по принципу +«запустил и забыл», с опциональными колбэками +[x-callback-url](https://x-callback-url.com) для успеха/ошибки и для +возврата данных от команд-запросов. + +> **Сначала включите её.** URL Scheme API **по умолчанию выключен**. Включите его в +> **LockIME ▸ Настройки ▸ Основные ▸ Автоматизация ▸ URL Scheme API**. Пока он выключен, +> каждая команда возвращает ошибку `api_disabled` и ничего не происходит. + +> **Замечание о безопасности.** После включения команды выполняются **без +> подтверждения для каждой команды** — любой процесс, способный открыть URL `lockime://` +> (включая веб-страницу), может управлять LockIME. Каждая команда обратима и ни одна из них +> не трогает ваши файлы; самое худшее, что может сделать злонамеренный вызывающий, — +> переключить блокировку источника ввода или отредактировать правила. +> Оставляйте API выключенным, когда вы им не пользуетесь. + +--- + +## URL shape + +Принимаются две эквивалентные формы: + +``` +lockime://?=&= +lockime://x-callback-url/?=&… +``` + +- **Токен команды** (``) нечувствителен к регистру. +- **Имена параметров** нечувствительны к регистру; **значения параметров** берутся + дословно (поэтому идентификаторы bundle и источника сохраняют свой регистр). +- Всегда **кодируйте процентами** значения, содержащие зарезервированные символы + (`?`, `&`, `=`, `/`, пробелы, …). Отображаемое имя источника, например `ABC – Extended`, + превращается в `name=ABC%20%E2%80%93%20Extended`. + +Префикс `x-callback-url/` — это необязательный синтаксический сахар для инструментов +x-callback-url; параметры колбэков ниже работают и с краткой формой. + +> **Development builds.** Debug-сборка LockIME регистрирует `lockime-dev://` +> вместо `lockime://`, поэтому локальная сборка никогда не перехватывает схему +> установленного релиза. Всё остальное идентично. + +--- + +## x-callback-url + +Любая команда может нести эти зарезервированные параметры: + +| Parameter | Meaning | +|---|---| +| `x-success` | URL, открываемый после успешного выполнения команды. Для команд-**запросов** результат в формате JSON добавляется как `result=` (закодированный процентами). | +| `x-error` | URL, открываемый при сбое команды, с добавлением `errorCode=&errorMessage=`. | +| `x-source` | Отображаемое имя вызывающего приложения (информационное; LockIME записывает его в журнал). | + +Команды-действия запускают `x-success` без `result`. Команды-запросы возвращают свою +полезную нагрузку через `x-success`; без URL `x-success` запросу просто некуда +отправить свой результат (он всё равно выполняется, безвредно). + +Пример полного цикла — запросить статус и получить его обратно в собственное приложение: + +``` +lockime://status?x-success=myapp%3A%2F%2Fgot-status +``` + +При успехе LockIME открывает: + +``` +myapp://got-status?result=%7B%22locked%22%3Atrue%2C…%7D +``` + +--- + +## Command reference + +### Master lock + +| Command | Parameters | Effect | +|---|---|---| +| `lock` | — | **Включить** главную блокировку. | +| `unlock` | — | **Выключить** главную блокировку. | +| `toggle-lock` *(alias `toggle`)* | — | Переключить главную блокировку. | + +### Global input source + +**Источник** задаётся через `id` (канонический идентификатор Text Input Source, например +`com.apple.keylayout.ABC`, возвращаемый командой [`list-sources`](#queries)) или через +`name` (его локализованное отображаемое имя, нечувствительно к регистру). Он должен указывать +на установленный в данный момент, выбираемый источник, иначе команда возвращает `unknown_source`. + +| Command | Parameters | Effect | +|---|---|---| +| `lock-to-source` | `id` \| `name` | Задать глобальный источник по умолчанию **и** включить блокировку. | +| `set-default-source` | `id` \| `name` *(omit both to clear)* | Задать (или сбросить) глобальный источник по умолчанию, не меняя состояние вкл/выкл. | +| `cycle-source` | `direction` = `next` \| `previous` | Перейти к следующему/предыдущему установленному источнику в глобальной цели (по кругу) и включить блокировку. | +| `switch-source` | `id` \| `name` | Переключает текущий источник ввода **один раз**, прямо сейчас — это **не** включает и не изменяет непрерывную блокировку. Если непрерывная блокировка уже активна, она берёт верх и возвращает источник к своей цели. | + +`direction` также принимает псевдонимы `prev`, `forward`, `back`, `up`, `down`. + +### Per-app rules + +| Command | Parameters | Effect | +|---|---|---| +| `set-app-rule` | `bundle` *(req)*, `mode` = `lock` \| `switch` \| `ignore` \| `default` *(default `lock`)*, `source` \| `source-name` *(req for `lock`/`switch`)* | Создать или заменить правило для приложения. `lock` непрерывно применяет источник; `switch` переключает один раз при активации, затем отпускает; `ignore` отключает блокировку для этого приложения; `default` возвращается к глобальному значению по умолчанию. | +| `remove-app-rule` | `bundle` *(req)* | Удалить правило для `bundle`. `rule_not_found`, если его нет. | +| `cycle-app-source` | `direction` *(req)*, `bundle` *(optional; default = frontmost app)* | Перейти к следующему/предыдущему источнику в собственном правиле этого приложения. Ничего не делает (`rule_not_found`), если у приложения нет правила. | +| `remove-frontmost-app-rule` | — | Удалить правило для того приложения, которое сейчас на переднем плане. | +| `clear-app-rules` | — | Удалить **все** правила для приложений. | + +### General settings + +| Command | Parameters | Effect | +|---|---|---| +| `set-launch-at-login` *(alias `launch-at-login`)* | `enabled` = `true` \| `false` \| `toggle` | Зарегистрировать/снять регистрацию LockIME как объекта входа. | +| `set-language` | `code` = `en` \| `zh-Hans` \| `zh-Hant` \| `ja` \| `fr` \| `de` \| `es` \| `pt` \| `ru` \| `system` | Задать переопределение языка внутри приложения; `system` (псевдоним `auto`) сбрасывает его и следует за языком macOS. Снисходительно: `zh-CN`→`zh-Hans`, `zh-TW`→`zh-Hant`, `fr-CA`→`fr`, … | + +### Enhanced mode & per-URL rules + +Правила для URL требуют опционального **расширенного режима**, защищённого разрешением Accessibility. + +| Command | Parameters | Effect | +|---|---|---| +| `set-enhanced-mode` | `enabled` = `true` \| `false` \| `toggle` | Включить/выключить расширенный режим (или переключить его). | +| `set-url-rule` | `host` *(req)*, `source` \| `source-name` *(req)*, `action` = `lock` \| `switch` *(default `lock`)*, `id` *(optional UUID)* | Создать или заменить правило для URL. `host` — это шаблон, например `github.com` (совпадает с поддоменами) или `*.example.com`. Без `id` существующее правило для того же хоста обновляется, а не дублируется. | +| `remove-url-rule` | `id` *(UUID)* \| `host` | Удалить правило для URL по его `id` (из `list-url-rules`) или по `host`. | +| `clear-url-rules` | — | Удалить **все** правила для URL. | + +### App + +| Command | Parameters | Effect | +|---|---|---| +| `quit` | — | Завершить работу LockIME. | + +(См. также [`set-language`](#general-settings) и [`set-launch-at-login`](#general-settings).) + +LockIME намеренно не предоставляет **никаких команд, открывающих его интерфейс** +(Настройки, «О программе», окно обновлений): API предназначен для автоматизации +без участия интерфейса, а не для управления окнами. + +### Queries + +Команды-запросы возвращают полезную нагрузку в формате JSON через колбэк `x-success` (см. +[x-callback-url](#x-callback-url)). + +| Command | Result | +|---|---| +| `status` | Всё состояние — см. [ниже](#status-payload). | +| `current-source` | `{ "id": "...", "name": "..." }` активного источника. | +| `list-sources` *(alias `sources`)* | Массив установленных источников: `{ "id", "name", "isCJKV", "isEnabled", "isSelectCapable" }`. | +| `list-app-rules` *(alias `app-rules`)* | Массив `{ "bundleID", "mode", "source"? }`. | +| `list-url-rules` *(alias `url-rules`)* | Массив `{ "id", "host", "action", "source" }`. | +| `list-log` *(aliases `log`, `recent-activations`)* | Записи о принудительных переключениях за последние 24 ч, новейшие сначала: `{ "timestamp", "inputSource", "inputSourceName", "reason", "durationMs", "fromSourceName"?, "app"?, "bundleID"?, "ruleSource"?, "matchedHost"? }`. | +| `get-config` *(alias `config`)* | Полный сохранённый объект конфигурации. | +| `version` | `{ "version": "x.y.z", "build": "n" }`. | +| `ping` | `{ "ok": true, "app": "LockIME", "version": "x.y.z", "build": "n" }` — дешёвая проверка присутствия/версии. | + +#### `status` payload + +```json +{ + "locked": true, + "enhancedMode": false, + "launchAtLogin": true, + "accessibilityGranted": true, + "activationCount": 42, + "language": "en", + "version": "1.2.0", + "build": "20260615", + "currentSource": { "id": "com.apple.keylayout.ABC", "name": "ABC" }, + "defaultSource": { "id": "com.apple.keylayout.ABC", "name": "ABC" }, + "frontmostApp": "com.apple.Safari" +} +``` + +`currentSource`, `defaultSource` и `frontmostApp` присутствуют только когда известны. + +--- + +## Errors + +При сбое (и при наличии колбэка `x-error`) LockIME добавляет стабильный +машинный `errorCode` и человекочитаемое `errorMessage`. Текст ошибки **по замыслу +на английском и стабилен** — он переходит в ваше приложение и в журналы, поэтому никогда +не локализуется. + +| `errorCode` | When | +|---|---| +| `api_disabled` | API выключен — включите его в «Настройки ▸ Основные ▸ Автоматизация». | +| `malformed_url` | URL не удалось разобрать. | +| `no_command` | Токен команды не был передан. | +| `unknown_command` | Токен команды не распознан. | +| `missing_parameter` | Обязательный параметр отсутствует. | +| `invalid_parameter` | Значение параметра вне допустимого диапазона (неверный `mode`, `action`, `direction`, `code` или UUID). | +| `unknown_source` | `id`/`name` не совпадает ни с одним установленным выбираемым источником. | +| `no_input_sources` | Не установлено ни одного выбираемого источника ввода. | +| `rule_not_found` | Указанное правило для приложения/URL не существует. | +| `not_supported` | Операцию не удалось завершить (например, сериализацию конфигурации). | + +--- + +## Examples + +**Shell / `open(1)`** + +```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" +open "lockime://set-url-rule?host=github.com&source=com.apple.keylayout.ABC&action=switch" +open "lockime://set-launch-at-login?enabled=on" +``` + +**AppleScript** + +```applescript +open location "lockime://toggle-lock" +``` + +**Shortcuts (macOS)** + +Добавьте действие **Open URLs** с `lockime://lock` или **Get Contents of URL** +плюс форму x-callback-url, чтобы считать состояние обратно. + +**Read status from a script** (с использованием приложения/URL-приёмника колбэка): + +```sh +open "lockime://status?x-success=myreceiver%3A%2F%2Fstatus" +``` + +--- + +## Notes & guarantees + +- **Идемпотентно и обратимо.** Повторная отправка команды безопасна; ничего не + уничтожается, кроме тех изменений правил, о которых вы просите. +- **Никогда не перехватывает фокус.** Ни одна команда не выводит LockIME на + передний план и не открывает ни одно из его окон — API по замыслу работает без + участия интерфейса. +- **Блокировки остаются авторитетными.** `switch-source` — это разовое переключение + из вежливости; действующая непрерывная блокировка вновь навяжет свой источник. +- **Идентичность источника — это `id`.** Отображаемые имена — это удобство и зависят от + системной локали; для стабильной автоматизации предпочитайте `id` (из `list-sources`). +- **Резервные копии не включают API.** Экспорт/импорт конфигурации (файлы `.lockime`) + охватывает ваши правила, а не что-либо специфичное для API, — отдельного состояния API + для переноса не существует. diff --git a/docs/URL-Scheme-API/README.zh-CN.md b/docs/URL-Scheme-API/README.zh-CN.md new file mode 100644 index 0000000..f816167 --- /dev/null +++ b/docs/URL-Scheme-API/README.zh-CN.md @@ -0,0 +1,242 @@ +# URL Scheme API + +[English](README.md) · **简体中文** · [繁體中文](README.zh-TW.md) · [日本語](README.ja.md) · [Français](README.fr.md) · [Deutsch](README.de.md) · [Español](README.es.md) · [Português](README.pt.md) · [Русский](README.ru.md) + +LockIME 提供了一个 `lockime://` URL scheme,让其他应用、脚本、Shortcuts、 +Stream Deck、Alfred/Raycast、AppleScript——任何能打开 URL 的东西——都能驱动它: +开关锁定、重新指定输入源、管理规则,并读回状态。 + +每条命令都是一个 URL,默认是发出即不管(fire-and-forget),并可选地附带 +[x-callback-url](https://x-callback-url.com) 回调来处理成功/失败,以及从查询命令 +返回数据。 + +> **请先启用它。** URL Scheme API **默认关闭**。请在 **LockIME ▸ 设置 ▸ 通用 ▸ +> 自动化 ▸ URL Scheme API** 中开启它。在它关闭期间,每条命令都会返回 +> `api_disabled` 错误,且什么都不会发生。 + +> **安全提示。** 一旦启用,命令执行时**不会有逐条确认**——任何能打开 +> `lockime://` 链接的进程(包括一个网页)都能驱动 LockIME。每条命令都是可逆的, +> 且都不会触及你的文件;一个恶意调用者最多只能开关你的输入源锁定或编辑规则。 +> 不使用时请把这个 API 关闭。 + +--- + +## URL shape + +接受两种等价的形式: + +``` +lockime://?=&= +lockime://x-callback-url/?=&… +``` + +- **命令 token**(``)不区分大小写。 +- **参数名**不区分大小写;**参数值**则按原样照取(因此 bundle ID 和 source ID + 会保留其大小写)。 +- 对于包含保留字符(`?`、`&`、`=`、`/`、空格……)的值,请始终进行 + **百分号编码**。一个像 `ABC – Extended` 这样的输入源显示名会变成 + `name=ABC%20%E2%80%93%20Extended`。 + +`x-callback-url/` 前缀只是为 x-callback-url 工具准备的可选语法糖;下面的回调参数 +在裸形式上同样有效。 + +> **开发构建。** LockIME 的 Debug 构建注册的是 `lockime-dev://` 而非 +> `lockime://`,因此本地构建绝不会劫持已安装正式版的 scheme。其余一切都完全相同。 + +--- + +## x-callback-url + +任何命令都可以携带这些保留参数: + +| Parameter | Meaning | +|---|---| +| `x-success` | 命令成功后打开的 URL。对于**查询**命令,JSON 结果会以 `result=`(经过百分号编码)的形式追加在后面。 | +| `x-error` | 命令失败时打开的 URL,并在后面追加 `errorCode=&errorMessage=`。 | +| `x-source` | 调用方应用的显示名(仅供参考;LockIME 会记录它)。 | + +动作命令触发 `x-success` 时不带 `result`。查询命令通过 `x-success` 返回其负载; +若没有 `x-success` URL,查询就根本没有地方发送其结果(它仍会运行,且无害)。 + +往返示例——请求状态并把它接收回你自己的应用: + +``` +lockime://status?x-success=myapp%3A%2F%2Fgot-status +``` + +成功时 LockIME 会打开: + +``` +myapp://got-status?result=%7B%22locked%22%3Atrue%2C…%7D +``` + +--- + +## Command reference + +### Master lock + +| Command | Parameters | Effect | +|---|---|---| +| `lock` | — | **开启**主锁定。 | +| `unlock` | — | **关闭**主锁定。 | +| `toggle-lock` *(alias `toggle`)* | — | 翻转主锁定。 | + +### Global input source + +一个**输入源**由 `id`(规范的 Text Input Source 标识符,例如 +`com.apple.keylayout.ABC`,由 [`list-sources`](#queries) 返回)或 `name` +(其本地化显示名,不区分大小写)来指定。它必须指向一个当前已安装、可选用的 +输入源,否则命令会返回 `unknown_source`。 + +| Command | Parameters | Effect | +|---|---|---| +| `lock-to-source` | `id` \| `name` | 设置全局默认输入源**并**开启锁定。 | +| `set-default-source` | `id` \| `name` *(omit both to clear)* | 设置(或清除)全局默认输入源,而不改变开/关状态。 | +| `cycle-source` | `direction` = `next` \| `previous` | 将全局目标切换到下一个/上一个已安装的输入源(循环),并开启锁定。 | +| `switch-source` | `id` \| `name` | 立即将当前输入源切换**一次**,仅此一次——它**不会**开启或修改持续锁定。若此时已有持续锁定在生效,它会胜出,并把输入源切回锁定目标。 | + +`direction` 还接受别名 `prev`、`forward`、`back`、`up`、`down`。 + +### Per-app rules + +| Command | Parameters | Effect | +|---|---|---| +| `set-app-rule` | `bundle` *(req)*, `mode` = `lock` \| `switch` \| `ignore` \| `default` *(default `lock`)*, `source` \| `source-name` *(req for `lock`/`switch`)* | 为某个应用创建或替换规则。`lock` 会持续强制锁定该输入源;`switch` 会在激活时切换一次然后放手;`ignore` 会为该应用禁用锁定;`default` 则回退到全局默认。 | +| `remove-app-rule` | `bundle` *(req)* | 删除 `bundle` 的规则。若不存在则返回 `rule_not_found`。 | +| `cycle-app-source` | `direction` *(req)*, `bundle` *(optional; default = frontmost app)* | 将该应用自己的规则切换到下一个/上一个输入源。若该应用没有规则则为空操作(`rule_not_found`)。 | +| `remove-frontmost-app-rule` | — | 删除当前最前台应用的规则。 | +| `clear-app-rules` | — | 移除**所有**按应用规则。 | + +### General settings + +| Command | Parameters | Effect | +|---|---|---| +| `set-launch-at-login` *(alias `launch-at-login`)* | `enabled` = `true` \| `false` \| `toggle` | 将 LockIME 注册/取消注册为登录项。 | +| `set-language` | `code` = `en` \| `zh-Hans` \| `zh-Hant` \| `ja` \| `fr` \| `de` \| `es` \| `pt` \| `ru` \| `system` | 设置应用内的语言覆盖;`system`(别名 `auto`)会清除它并跟随 macOS 的语言。宽松匹配:`zh-CN`→`zh-Hans`、`zh-TW`→`zh-Hant`、`fr-CA`→`fr`,…… | + +### Enhanced mode & per-URL rules + +按 URL 规则需要可选的、受 Accessibility 把关的**增强模式**。 + +| Command | Parameters | Effect | +|---|---|---| +| `set-enhanced-mode` | `enabled` = `true` \| `false` \| `toggle` | 开启/关闭增强模式(或翻转它)。 | +| `set-url-rule` | `host` *(req)*, `source` \| `source-name` *(req)*, `action` = `lock` \| `switch` *(default `lock`)*, `id` *(optional UUID)* | 创建或替换一条按 URL 规则。`host` 是一个模式,如 `github.com`(匹配子域名)或 `*.example.com`。若不带 `id`,则更新同一 host 的现有规则,而不是新建重复项。 | +| `remove-url-rule` | `id` *(UUID)* \| `host` | 通过 `id`(来自 `list-url-rules`)或 `host` 删除一条 URL 规则。 | +| `clear-url-rules` | — | 移除**所有**按 URL 规则。 | + +### App + +| Command | Parameters | Effect | +|---|---|---| +| `quit` | — | 退出 LockIME。 | + +(另见 [`set-language`](#general-settings) 和 [`set-launch-at-login`](#general-settings)。) + +LockIME 刻意**不提供任何打开其 UI 的命令**(设置、关于、更新窗口):这套 API 是 +为无界面自动化设计的,而不是用来驱动窗口。 + +### Queries + +查询命令通过 `x-success` 回调返回一个 JSON 负载(参见 +[x-callback-url](#x-callback-url))。 + +| Command | Result | +|---|---| +| `status` | 整个状态——参见[下文](#status-payload)。 | +| `current-source` | 实时输入源的 `{ "id": "...", "name": "..." }`。 | +| `list-sources` *(alias `sources`)* | 已安装输入源的数组:`{ "id", "name", "isCJKV", "isEnabled", "isSelectCapable" }`。 | +| `list-app-rules` *(alias `app-rules`)* | `{ "bundleID", "mode", "source"? }` 的数组。 | +| `list-url-rules` *(alias `url-rules`)* | `{ "id", "host", "action", "source" }` 的数组。 | +| `list-log` *(aliases `log`, `recent-activations`)* | 最近 24 小时的强制切换记录,最新的在前:`{ "timestamp", "inputSource", "inputSourceName", "reason", "durationMs", "fromSourceName"?, "app"?, "bundleID"?, "ruleSource"?, "matchedHost"? }`。 | +| `get-config` *(alias `config`)* | 完整的持久化配置对象。 | +| `version` | `{ "version": "x.y.z", "build": "n" }`。 | +| `ping` | `{ "ok": true, "app": "LockIME", "version": "x.y.z", "build": "n" }`——一个廉价的存在性/版本探测。 | + +#### `status` payload + +```json +{ + "locked": true, + "enhancedMode": false, + "launchAtLogin": true, + "accessibilityGranted": true, + "activationCount": 42, + "language": "en", + "version": "1.2.0", + "build": "20260615", + "currentSource": { "id": "com.apple.keylayout.ABC", "name": "ABC" }, + "defaultSource": { "id": "com.apple.keylayout.ABC", "name": "ABC" }, + "frontmostApp": "com.apple.Safari" +} +``` + +`currentSource`、`defaultSource` 和 `frontmostApp` 仅在已知时才会出现。 + +--- + +## Errors + +失败时(且存在 `x-error` 回调时),LockIME 会追加一个稳定的、供机器使用的 +`errorCode` 和一个供人阅读的 `errorMessage`。错误文本在设计上是**英文且稳定** +的——它会跨入你的应用并进入日志,所以从不本地化。 + +| `errorCode` | When | +|---|---| +| `api_disabled` | API 已关闭——请在“设置 ▸ 通用 ▸ 自动化”中启用它。 | +| `malformed_url` | URL 无法被解析。 | +| `no_command` | 未提供命令 token。 | +| `unknown_command` | 命令 token 无法识别。 | +| `missing_parameter` | 缺少某个必需参数。 | +| `invalid_parameter` | 某个参数值超出范围(错误的 `mode`、`action`、`direction`、`code` 或 UUID)。 | +| `unknown_source` | `id`/`name` 没有匹配到任何已安装的可选用输入源。 | +| `no_input_sources` | 没有安装任何可选用的输入源。 | +| `rule_not_found` | 目标的应用/URL 规则不存在。 | +| `not_supported` | 操作无法完成(例如配置序列化)。 | + +--- + +## Examples + +**Shell / `open(1)`** + +```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" +open "lockime://set-url-rule?host=github.com&source=com.apple.keylayout.ABC&action=switch" +open "lockime://set-launch-at-login?enabled=on" +``` + +**AppleScript** + +```applescript +open location "lockime://toggle-lock" +``` + +**Shortcuts (macOS)** + +添加一个 **Open URLs** 动作并填入 `lockime://lock`,或者用 **Get Contents of URL** +加上 x-callback-url 形式来读回状态。 + +**从脚本读取状态**(使用一个回调接收方应用/URL): + +```sh +open "lockime://status?x-success=myreceiver%3A%2F%2Fstatus" +``` + +--- + +## Notes & guarantees + +- **幂等且可逆。** 重新发送一条命令是安全的;除了你主动要求的规则编辑之外, + 不会破坏任何东西。 +- **从不抢占焦点。** 没有任何命令会把 LockIME 带到前台或打开它的任何窗口——这套 + API 在设计上就是无界面的。 +- **锁定保持权威。** `switch-source` 是一次性的礼让式切换;一个持续生效的连续 + 锁定会重新强制其输入源。 +- **输入源的身份是 `id`。** 显示名只是为了方便,且依赖于系统语言环境;为了实现 + 稳定的自动化,请优先使用 `id`(来自 `list-sources`)。 +- **备份不包含 API。** 配置导出/导入(`.lockime` 文件)涵盖的是你的规则,而非 + 任何与 API 相关的内容——没有单独的 API 状态需要携带。 diff --git a/docs/URL-Scheme-API/README.zh-TW.md b/docs/URL-Scheme-API/README.zh-TW.md new file mode 100644 index 0000000..e6b9ce0 --- /dev/null +++ b/docs/URL-Scheme-API/README.zh-TW.md @@ -0,0 +1,244 @@ +# URL Scheme API + +[English](README.md) · [简体中文](README.zh-CN.md) · **繁體中文** · [日本語](README.ja.md) · [Français](README.fr.md) · [Deutsch](README.de.md) · [Español](README.es.md) · [Português](README.pt.md) · [Русский](README.ru.md) + +LockIME 提供一個 `lockime://` URL scheme,讓其他應用程式、指令稿、「捷徑」(Shortcuts)、 +Stream Deck、Alfred/Raycast、AppleScript——任何能開啟 URL 的東西——都能驅動它: +切換鎖定、重新指定輸入法、管理規則,並讀回狀態。 + +每個指令都是一個 URL,預設為發出即不管(fire-and-forget),並可選擇搭配 +[x-callback-url](https://x-callback-url.com) 回呼,用於成功/錯誤,以及從查詢指令回傳資料。 + +> **請先啟用。** URL Scheme API **預設為關閉**。請到 +> **LockIME ▸ 設定 ▸ 一般 ▸ 自動化 ▸ URL Scheme API** 把它開啟。在它關閉期間, +> 每個指令都會回傳 `api_disabled` 錯誤,且不會有任何動作。 + +> **安全提示。** 一旦啟用,指令執行時**不會逐一出現確認提示**——任何 +> 能開啟 `lockime://` URL 的程序(包括一個網頁)都能驅動 LockIME。每個指令都可逆, +> 而且都不會碰你的檔案;惡意呼叫者最多只能切換你的輸入法鎖定或編輯規則。 +> 不使用時,請讓 API 保持關閉。 + +--- + +## URL shape + +接受兩種等效的形式: + +``` +lockime://?=&= +lockime://x-callback-url/?=&… +``` + +- **指令權杖**(``)不分大小寫。 +- **參數名稱**不分大小寫;**參數值**會被原樣採用 + (所以 bundle ID 和 source ID 會保留其大小寫)。 +- 對於含有保留字元(`?`、`&`、`=`、`/`、空格、…)的值, + 務必進行 **percent-encode**。一個像 `ABC – Extended` 這樣的輸入法顯示名稱 + 會變成 `name=ABC%20%E2%80%93%20Extended`。 + +`x-callback-url/` 前綴是給 x-callback-url 工具用的可選糖衣語法; +下方的回呼參數在裸形式上同樣有效。 + +> **Development builds.** LockIME 的 Debug 建置會註冊 `lockime-dev://` +> 而非 `lockime://`,所以本地建置永遠不會劫持已安裝 +> 正式版的 scheme。其餘一切完全相同。 + +--- + +## x-callback-url + +任何指令都可以攜帶這些保留參數: + +| Parameter | Meaning | +|---|---| +| `x-success` | 指令成功後開啟的 URL。對於**查詢**指令,JSON 結果會以 `result=`(經 percent-encode)附加在後面。 | +| `x-error` | 指令失敗時開啟的 URL,並在後面附加 `errorCode=&errorMessage=`。 | +| `x-source` | 呼叫端應用程式的顯示名稱(僅供參考;LockIME 會記錄它)。 | + +動作指令會觸發 `x-success` 但不帶 `result`。查詢指令會透過 +`x-success` 回傳其酬載;若沒有 `x-success` URL,查詢就沒有地方 +送出其結果(它仍會執行,且無害)。 + +往返範例——索取狀態並把它收回你自己的應用程式: + +``` +lockime://status?x-success=myapp%3A%2F%2Fgot-status +``` + +成功時 LockIME 會開啟: + +``` +myapp://got-status?result=%7B%22locked%22%3Atrue%2C…%7D +``` + +--- + +## Command reference + +### Master lock + +| Command | Parameters | Effect | +|---|---|---| +| `lock` | — | 把主鎖切換為**開**。 | +| `unlock` | — | 把主鎖切換為**關**。 | +| `toggle-lock` *(alias `toggle`)* | — | 翻轉主鎖。 | + +### Global input source + +一個**輸入法**由 `id`(標準的 Text Input Source 識別字,例如 +`com.apple.keylayout.ABC`,即 [`list-sources`](#queries) 回傳的值)或由 +`name`(其在地化的顯示名稱,不分大小寫)指定。它必須指向一個目前已 +安裝、可選取的輸入法,否則指令會回傳 `unknown_source`。 + +| Command | Parameters | Effect | +|---|---|---| +| `lock-to-source` | `id` \| `name` | 設定全域預設輸入法**並**開啟鎖定。 | +| `set-default-source` | `id` \| `name` *(omit both to clear)* | 設定(或清除)全域預設輸入法,不改變開/關狀態。 | +| `cycle-source` | `direction` = `next` \| `previous` | 把全域目標推進到下一個/上一個已安裝的輸入法(循環),並開啟鎖定。 | +| `switch-source` | `id` \| `name` | 立刻把目前的輸入法**切換一次**,僅此一次——它**不會**開啟或修改持續鎖定。若此時已有持續鎖定在生效,它會勝出,並把輸入法切回鎖定目標。 | + +`direction` 也接受別名 `prev`、`forward`、`back`、`up`、`down`。 + +### Per-app rules + +| Command | Parameters | Effect | +|---|---|---| +| `set-app-rule` | `bundle` *(req)*, `mode` = `lock` \| `switch` \| `ignore` \| `default` *(default `lock`)*, `source` \| `source-name` *(req for `lock`/`switch`)* | 為一個應用程式建立或取代規則。`lock` 會持續強制使用該輸入法;`switch` 會在啟用時切換一次然後放手;`ignore` 會為該應用程式停用鎖定;`default` 會退回使用全域預設。 | +| `remove-app-rule` | `bundle` *(req)* | 刪除 `bundle` 的規則。若不存在則回傳 `rule_not_found`。 | +| `cycle-app-source` | `direction` *(req)*, `bundle` *(optional; default = frontmost app)* | 把該應用程式自己的規則推進到下一個/上一個輸入法。若該應用程式沒有規則則為無操作(`rule_not_found`)。 | +| `remove-frontmost-app-rule` | — | 刪除目前最前方應用程式的規則。 | +| `clear-app-rules` | — | 移除**所有**依應用程式規則。 | + +### General settings + +| Command | Parameters | Effect | +|---|---|---| +| `set-launch-at-login` *(alias `launch-at-login`)* | `enabled` = `true` \| `false` \| `toggle` | 把 LockIME 註冊/取消註冊為登入項目。 | +| `set-language` | `code` = `en` \| `zh-Hans` \| `zh-Hant` \| `ja` \| `fr` \| `de` \| `es` \| `pt` \| `ru` \| `system` | 設定應用程式內的語言覆寫;`system`(別名 `auto`)會清除它並跟隨 macOS 語言。寬鬆相容:`zh-CN`→`zh-Hans`、`zh-TW`→`zh-Hant`、`fr-CA`→`fr`、…。 | + +### Enhanced mode & per-URL rules + +依 URL 規則需要可選的、由 Accessibility 把關的**增強模式**。 + +| Command | Parameters | Effect | +|---|---|---| +| `set-enhanced-mode` | `enabled` = `true` \| `false` \| `toggle` | 開啟/關閉增強模式(或翻轉它)。 | +| `set-url-rule` | `host` *(req)*, `source` \| `source-name` *(req)*, `action` = `lock` \| `switch` *(default `lock`)*, `id` *(optional UUID)* | 建立或取代一條依 URL 規則。`host` 是像 `github.com`(會比對子網域)或 `*.example.com` 這樣的模式。若不帶 `id`,會更新同一 host 的既有規則,而不是建立重複的。 | +| `remove-url-rule` | `id` *(UUID)* \| `host` | 依其 `id`(來自 `list-url-rules`)或依 `host` 刪除一條 URL 規則。 | +| `clear-url-rules` | — | 移除**所有**依 URL 規則。 | + +### App + +| Command | Parameters | Effect | +|---|---|---| +| `quit` | — | 結束 LockIME。 | + +(另見 [`set-language`](#general-settings) 與 [`set-launch-at-login`](#general-settings)。) + +LockIME 刻意**不提供任何開啟其 UI 的指令**(Settings、About、更新視窗): +這個 API 是給無介面(headless)的自動化用的,而不是用來驅動視窗。 + +### Queries + +查詢指令會透過 `x-success` 回呼回傳一個 JSON 酬載(見 +[x-callback-url](#x-callback-url))。 + +| Command | Result | +|---|---| +| `status` | 整個狀態——見[下方](#status-payload)。 | +| `current-source` | 使用中輸入法的 `{ "id": "...", "name": "..." }`。 | +| `list-sources` *(alias `sources`)* | 已安裝輸入法的陣列:`{ "id", "name", "isCJKV", "isEnabled", "isSelectCapable" }`。 | +| `list-app-rules` *(alias `app-rules`)* | `{ "bundleID", "mode", "source"? }` 的陣列。 | +| `list-url-rules` *(alias `url-rules`)* | `{ "id", "host", "action", "source" }` 的陣列。 | +| `list-log` *(aliases `log`, `recent-activations`)* | 過去 24 小時的強制切換條目,最新的在前:`{ "timestamp", "inputSource", "inputSourceName", "reason", "durationMs", "fromSourceName"?, "app"?, "bundleID"?, "ruleSource"?, "matchedHost"? }`。 | +| `get-config` *(alias `config`)* | 完整的持久化設定物件。 | +| `version` | `{ "version": "x.y.z", "build": "n" }`。 | +| `ping` | `{ "ok": true, "app": "LockIME", "version": "x.y.z", "build": "n" }`——一個低成本的存在/版本探測。 | + +#### `status` payload + +```json +{ + "locked": true, + "enhancedMode": false, + "launchAtLogin": true, + "accessibilityGranted": true, + "activationCount": 42, + "language": "en", + "version": "1.2.0", + "build": "20260615", + "currentSource": { "id": "com.apple.keylayout.ABC", "name": "ABC" }, + "defaultSource": { "id": "com.apple.keylayout.ABC", "name": "ABC" }, + "frontmostApp": "com.apple.Safari" +} +``` + +`currentSource`、`defaultSource` 和 `frontmostApp` 只在已知時才會出現。 + +--- + +## Errors + +失敗時(且存在 `x-error` 回呼時),LockIME 會附加一個穩定的 +機器用 `errorCode` 和一個人類可讀的 `errorMessage`。錯誤文字在設計上是**英文且 +穩定的**——它會跨入你的應用程式並進入記錄,所以永遠不會被在地化。 + +| `errorCode` | When | +|---|---| +| `api_disabled` | API 已關閉——請到「設定 ▸ 一般 ▸ 自動化」啟用它。 | +| `malformed_url` | 無法解析此 URL。 | +| `no_command` | 未提供指令權杖。 | +| `unknown_command` | 無法辨識此指令權杖。 | +| `missing_parameter` | 缺少一個必要參數。 | +| `invalid_parameter` | 一個參數值超出範圍(不正確的 `mode`、`action`、`direction`、`code` 或 UUID)。 | +| `unknown_source` | 此 `id`/`name` 沒有比對到任何已安裝且可選取的輸入法。 | +| `no_input_sources` | 沒有安裝任何可選取的輸入法。 | +| `rule_not_found` | 目標的應用程式/URL 規則不存在。 | +| `not_supported` | 無法完成此操作(例如設定序列化)。 | + +--- + +## Examples + +**Shell / `open(1)`** + +```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" +open "lockime://set-url-rule?host=github.com&source=com.apple.keylayout.ABC&action=switch" +open "lockime://set-launch-at-login?enabled=on" +``` + +**AppleScript** + +```applescript +open location "lockime://toggle-lock" +``` + +**Shortcuts (macOS)** + +新增一個 **Open URLs** 動作搭配 `lockime://lock`,或用 **Get Contents of URL** +加上 x-callback-url 形式來讀回狀態。 + +**Read status from a script**(使用一個回呼接收端應用程式/URL): + +```sh +open "lockime://status?x-success=myreceiver%3A%2F%2Fstatus" +``` + +--- + +## Notes & guarantees + +- **幂等且可逆。** 重送一個指令是安全的;除了你要求的規則編輯之外, + 不會有任何東西被破壞。 +- **永不搶奪焦點。** 沒有任何指令會把 LockIME 帶到前景或開啟它的任何視窗 + ——這個 API 在設計上就是無介面(headless)的。 +- **鎖定始終具有權威性。** `switch-source` 是一次性的禮貌切換;一個 + 常駐的持續鎖定會重新堅持使用它的輸入法。 +- **輸入法的身分是 `id`。** 顯示名稱只是方便起見,而且取決於 + 系統語言;要做穩定的自動化,請優先使用 `id`(來自 `list-sources`)。 +- **備份不包含 API。** 設定的匯出/匯入(`.lockime` 檔案) + 涵蓋的是你的規則,而非任何 API 專屬的東西——沒有獨立的 API 狀態 + 需要攜帶。 diff --git a/project.yml b/project.yml index b0985e2..42a720a 100644 --- a/project.yml +++ b/project.yml @@ -77,6 +77,10 @@ targets: PRODUCT_MODULE_NAME: LockIME PRODUCT_BUNDLE_IDENTIFIER: com.oomol.LockIME LOCKIME_BUNDLE_DISPLAY_NAME: LockIME + # URL-scheme API. Release registers `lockime://`; Debug uses a distinct + # `lockime-dev://` (overridden below) so a local dev build never hijacks + # the installed release's scheme. The handler is scheme-agnostic. + LOCKIME_URL_SCHEME: lockime INFOPLIST_FILE: Sources/LockIME/Info.plist CODE_SIGN_ENTITLEMENTS: Sources/LockIME/LockIME.entitlements ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME: AccentColor @@ -90,6 +94,7 @@ targets: PRODUCT_NAME: LockIME Dev PRODUCT_BUNDLE_IDENTIFIER: com.oomol.LockIME.dev LOCKIME_BUNDLE_DISPLAY_NAME: LockIME Dev + LOCKIME_URL_SCHEME: lockime-dev CODE_SIGN_IDENTITY: "-" DEVELOPMENT_TEAM: "" ENABLE_HARDENED_RUNTIME: NO