From 8792469af4f50a4fa5d62801d117db5037d9e44c Mon Sep 17 00:00:00 2001 From: Kevin Cui Date: Tue, 16 Jun 2026 10:59:37 -0400 Subject: [PATCH 1/2] feat(rules): add URL match types and drag-to-reorder priority MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per-URL rules (enhanced mode) gain a match type — domain suffix, exact domain, domain keyword, or a regular expression over the full URL — and a user-arranged priority order: rules resolve top to bottom, first match wins, and the list is drag-reorderable. The URL-scheme API, .lockime backup/import, and the localized docs carry the new field through. A rule's pattern is its portable identity (backups and import key on it), so the mutators enforce that no two rules ever share one: the upsert/reorder logic is centralized in URLRuleList, and the editor and URL-scheme API reject a colliding pattern. An edit can no longer silently mint a duplicate that would collapse, losing a rule, on the next export/import. URL-regex matching is bounded in UTF-16 code units to cap the regex engine's backtracking on long inputs. Signed-off-by: Kevin Cui --- README.md | 4 + Sources/LockIME/API/URLCommandHandler.swift | 17 +- Sources/LockIME/AppState.swift | 27 +- Sources/LockIME/Localizable.xcstrings | 1040 +++++++++++++++++ Sources/LockIME/UI/AboutView.swift | 4 + .../UI/Settings/AppRulesSettingsPane.swift | 4 + .../UI/Settings/ImportReviewSheet.swift | 61 +- .../UI/Settings/URLRulesSettingsPane.swift | 629 +++++++--- Sources/LockIMEKit/API/URLCommand.swift | 30 +- Sources/LockIMEKit/Backup/ConfigBackup.swift | 31 +- Sources/LockIMEKit/Backup/ImportPlan.swift | 149 ++- Sources/LockIMEKit/Enhanced/URLMatcher.swift | 96 +- .../LockIMEKit/LockEngine/LockEngine.swift | 15 +- .../LockIMEKit/Rules/LockConfiguration.swift | 83 +- Sources/LockIMEKit/Rules/URLRuleList.swift | 82 ++ Tests/LockIMEKitTests/ConfigBackupTests.swift | 44 + Tests/LockIMEKitTests/ImportPlanTests.swift | 216 ++++ .../LocalizationGuardTests.swift | 53 +- .../LockConfigurationTests.swift | 63 + .../URLCommandParserTests.swift | 53 +- Tests/LockIMEKitTests/URLMatcherTests.swift | 182 ++- Tests/LockIMEKitTests/URLRuleListTests.swift | 127 ++ docs/README/README.de.md | 1 + docs/README/README.es.md | 1 + docs/README/README.fr.md | 1 + docs/README/README.ja.md | 1 + docs/README/README.pt.md | 1 + docs/README/README.ru.md | 1 + docs/README/README.zh-CN.md | 1 + docs/README/README.zh-TW.md | 1 + docs/URL-Scheme-API/README.de.md | 27 +- docs/URL-Scheme-API/README.es.md | 27 +- docs/URL-Scheme-API/README.fr.md | 27 +- docs/URL-Scheme-API/README.ja.md | 27 +- docs/URL-Scheme-API/README.md | 26 +- docs/URL-Scheme-API/README.pt.md | 27 +- docs/URL-Scheme-API/README.ru.md | 27 +- docs/URL-Scheme-API/README.zh-CN.md | 25 +- docs/URL-Scheme-API/README.zh-TW.md | 26 +- 39 files changed, 2993 insertions(+), 264 deletions(-) create mode 100644 Sources/LockIMEKit/Rules/URLRuleList.swift create mode 100644 Tests/LockIMEKitTests/URLRuleListTests.swift diff --git a/README.md b/README.md index 221f488..8165268 100644 --- a/README.md +++ b/README.md @@ -61,6 +61,10 @@ Either way, the app keeps itself up to date via Sparkle. - **Lock or switch** — per-app and per-URL rules can *lock* an input source (re-applied whenever it drifts) or just *switch* to it once when you focus the app or page, then step out of the way and let you change it freely. +- **Flexible URL matching** — per-URL rules (enhanced mode) match by a domain and + its subdomains, an exact domain, a domain keyword, or a regular expression over + the full URL, and apply in a priority order you drag to arrange — first match + wins. - **Menu-bar control** — activate/deactivate, switch the locked input source, view the current source, and track the activation count from the menu bar. - **Keyboard shortcuts** — configurable global shortcuts to toggle locking and diff --git a/Sources/LockIME/API/URLCommandHandler.swift b/Sources/LockIME/API/URLCommandHandler.swift index 491a59c..564b555 100644 --- a/Sources/LockIME/API/URLCommandHandler.swift +++ b/Sources/LockIME/API/URLCommandHandler.swift @@ -99,8 +99,8 @@ final class URLCommandHandler { // 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 .setURLRule(let id, let host, let selector, let action, let matchType): + return performSetURLRule(id: id, host: host, selector: selector, action: action, matchType: matchType) case .removeURLRule(let selector): return performRemoveURLRule(selector) case .clearURLRules: @@ -161,7 +161,7 @@ final class URLCommandHandler { } private func performSetURLRule( - id: UUID?, host: String, selector: SourceSelector, action: RuleAction + id: UUID?, host: String, selector: SourceSelector, action: RuleAction, matchType: URLMatchType ) -> Result { switch resolve(selector) { case .failure(let error): @@ -172,7 +172,15 @@ final class URLCommandHandler { 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)) + // Refuse a write that would leave two rules sharing a pattern (the + // portable identity backups/import key on) — that pair collapses, + // losing one, on the next export→import. The no-id path resolves onto + // the same-host rule above (so it's never "different"); this guards an + // explicit id whose host belongs to *another* rule. + if state.config.urlRules.contains(where: { $0.id != ruleID && Self.sameHost($0.hostPattern, host) }) { + return .failure(.invalidParameter(name: "host", value: host)) + } + state.upsertURLRule(URLRule(id: ruleID, hostPattern: host, lockedSourceID: sourceID, action: action, matchType: matchType)) return .success(nil) } } @@ -284,6 +292,7 @@ final class URLCommandHandler { "id": rule.id.uuidString, "host": rule.hostPattern, "action": rule.action.rawValue, + "matchType": rule.matchType.rawValue, "source": sourceDict(rule.lockedSourceID), ] } diff --git a/Sources/LockIME/AppState.swift b/Sources/LockIME/AppState.swift index 9c64a51..4aab974 100644 --- a/Sources/LockIME/AppState.swift +++ b/Sources/LockIME/AppState.swift @@ -371,8 +371,14 @@ final class AppState { } func upsertURLRule(_ rule: URLRule) { - config.urlRules.removeAll { $0.id == rule.id } - config.urlRules.append(rule) + // Insert/update in place so editing a rule's binding (match type / action / + // source) keeps its position — order is priority now, and an edit must not + // silently demote a rule to the bottom — while never minting two rules with + // the same pattern (the portable identity; a duplicate pair collapses on the + // next export→import). The `URLRuleList` helper holds both invariants and is + // unit-tested; the editor and the URL-scheme API reject a pattern collision + // before reaching here so the user/automation gets feedback. + config.urlRules = URLRuleList.upserting(rule, into: config.urlRules) commit() } @@ -381,6 +387,23 @@ final class AppState { commit() } + /// Commit a drag-reordered URL-rule list. Order *is* priority — rules resolve + /// top-to-bottom and the first match wins — so this is a meaningful edit, not + /// cosmetic. The live drag reorders a view-local draft (never `config`), so the + /// engine/disk stay untouched mid-drag — a cancelled drag persists nothing — + /// and this commits once when the drop lands. A no-op (saving + re-applying the + /// engine) when the order is unchanged, and a guard against a non-permutation + /// (mismatched rule set) so a stale draft can never replace the live rules. + func reorderURLRules(_ ordered: [URLRule]) { + // `URLRuleList.reordered` returns nil for a no-op (unchanged order) or a + // non-permutation (a stale drag-start snapshot whose id set no longer + // matches the live rules), and otherwise relinks to the *live* rules by id + // so a content edit that landed mid-drag survives the reorder. + guard let reordered = URLRuleList.reordered(config.urlRules, by: ordered) else { return } + config.urlRules = reordered + commit() + } + // MARK: - URL-scheme API support /// Live current input-source id (the engine's view), for status queries. diff --git a/Sources/LockIME/Localizable.xcstrings b/Sources/LockIME/Localizable.xcstrings index aacde4f..be38117 100644 --- a/Sources/LockIME/Localizable.xcstrings +++ b/Sources/LockIME/Localizable.xcstrings @@ -2,6 +2,58 @@ "sourceLanguage": "en", "version": "1.0", "strings": { + "A rule with this pattern already exists.": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Es existiert bereits eine Regel mit diesem Muster." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Ya existe una regla con este patrón." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Une règle avec ce modèle existe déjà." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "このパターンのルールは既に存在します。" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Já existe uma regra com este padrão." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Правило с таким шаблоном уже существует." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "已存在使用此模式的规则。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "已存在使用此模式的規則。" + } + } + } + }, "API command": { "localizations": { "de": { @@ -9669,6 +9721,994 @@ } } } + }, + "URL rule order": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Reihenfolge der URL-Regeln" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Orden de las reglas de URL" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Ordre des règles d’URL" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "URL ルールの順序" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Ordem das regras de URL" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Порядок URL-правил" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "URL 规则顺序" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "URL 規則順序" + } + } + } + }, + "The backup lists your URL rules in a different priority order. Order is priority — the first matching rule wins.": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Die Sicherung listet deine URL-Regeln in einer anderen Prioritätsreihenfolge. Die Reihenfolge ist die Priorität – die erste passende Regel gewinnt." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "La copia de seguridad ordena tus reglas de URL con una prioridad diferente. El orden es la prioridad: gana la primera regla coincidente." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "La sauvegarde classe vos règles d’URL dans un ordre de priorité différent. L’ordre définit la priorité : la première règle correspondante l’emporte." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "バックアップは URL ルールを異なる優先順位で並べています。順序が優先度です。最初に一致したルールが適用されます。" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "O backup lista suas regras de URL em uma ordem de prioridade diferente. A ordem é a prioridade — a primeira regra correspondente vence." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "В резервной копии URL-правила расположены в другом порядке приоритета. Порядок задаёт приоритет — побеждает первое совпавшее правило." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "备份中 URL 规则的优先级顺序与当前不同。顺序即优先级——第一条匹配的规则生效。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "備份中 URL 規則的優先順序與目前不同。順序即優先順序——第一條符合的規則生效。" + } + } + } + }, + "Drag to reorder": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Zum Neuordnen ziehen" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Arrastrar para reordenar" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Glisser pour réordonner" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ドラッグして並べ替え" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Arraste para reordenar" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Перетащите для изменения порядка" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "拖动以重新排序" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "拖曳以重新排序" + } + } + } + }, + "Domain suffix": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Domain-Suffix" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Sufijo de dominio" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Suffixe de domaine" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ドメインサフィックス" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Sufixo de domínio" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Суффикс домена" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "域名后缀" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "網域後綴" + } + } + } + }, + "Exact domain": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Exakte Domain" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Dominio exacto" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Domaine exact" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "完全一致ドメイン" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Domínio exato" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Точный домен" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "精确域名" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "精確網域" + } + } + } + }, + "Domain keyword": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Domain-Schlüsselwort" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Palabra clave de dominio" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Mot-clé de domaine" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ドメインキーワード" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Palavra-chave de domínio" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Ключевое слово домена" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "域名关键词" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "網域關鍵字" + } + } + } + }, + "URL regex": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "URL-Regex" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Regex de URL" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Regex d’URL" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "URL 正規表現" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Regex de URL" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Regex URL" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "网址正则" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "網址正則" + } + } + } + }, + "Keyword (e.g. google)": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Schlüsselwort (z. B. google)" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Palabra clave (p. ej. google)" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Mot-clé (ex. google)" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "キーワード(例:google)" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Palavra-chave (ex.: google)" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Ключевое слово (например, google)" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "关键词(例如 google)" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "關鍵字(例如 google)" + } + } + } + }, + "Regex (e.g. /pull/)": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Regex (z. B. /pull/)" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Regex (p. ej. /pull/)" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Regex (ex. /pull/)" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "正規表現(例:/pull/)" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Regex (ex.: /pull/)" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Regex (например, /pull/)" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "正则(例如 /pull/)" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "正則(例如 /pull/)" + } + } + } + }, + "Invalid regular expression": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Ungültiger regulärer Ausdruck" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Expresión regular no válida" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Expression régulière non valide" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "無効な正規表現" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Expressão regular inválida" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Недопустимое регулярное выражение" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "无效的正则表达式" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "無效的正規表示式" + } + } + } + }, + "Checked top to bottom — the first match wins. Drag to reorder.": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Von oben nach unten geprüft – die erste Übereinstimmung gewinnt. Zum Umsortieren ziehen." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Se comprueban de arriba abajo: gana la primera coincidencia. Arrastra para reordenar." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Vérifiées de haut en bas — la première correspondance l’emporte. Glissez pour réordonner." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "上から順に評価され、最初に一致したルールが適用されます。ドラッグして並べ替えできます。" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Verificadas de cima para baixo — a primeira correspondência vence. Arraste para reordenar." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Проверяются сверху вниз — побеждает первое совпадение. Перетащите для изменения порядка." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "从上到下依次匹配,首条命中生效。拖动可调整顺序。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "由上而下依序比對,第一條命中者生效。拖曳可調整順序。" + } + } + } + }, + "Matches the domain and all its subdomains.": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Trifft auf die Domain und alle ihre Subdomains zu." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Coincide con el dominio y todos sus subdominios." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Correspond au domaine et à tous ses sous-domaines." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ドメインとそのすべてのサブドメインに一致します。" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Corresponde ao domínio e a todos os seus subdomínios." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Совпадает с доменом и всеми его поддоменами." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "匹配该域名及其所有子域名。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "比對該網域及其所有子網域。" + } + } + } + }, + "Matches only this exact domain, not its subdomains.": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Trifft nur auf diese exakte Domain zu, nicht auf ihre Subdomains." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Coincide solo con este dominio exacto, no con sus subdominios." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Correspond uniquement à ce domaine exact, pas à ses sous-domaines." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "この完全一致のドメインのみに一致し、サブドメインには一致しません。" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Corresponde apenas a este domínio exato, não aos seus subdomínios." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Совпадает только с этим точным доменом, без его поддоменов." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "仅匹配该精确域名,不含其子域名。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "僅比對該精確網域,不含其子網域。" + } + } + } + }, + "Matches any domain that contains this text.": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Trifft auf jede Domain zu, die diesen Text enthält." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Coincide con cualquier dominio que contenga este texto." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Correspond à tout domaine contenant ce texte." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "このテキストを含むすべてのドメインに一致します。" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Corresponde a qualquer domínio que contenha este texto." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Совпадает с любым доменом, содержащим этот текст." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "匹配包含该文本的任意域名。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "比對包含該文字的任意網域。" + } + } + } + }, + "Matches the full URL with a regular expression. Be specific so it doesn't match unrelated pages.": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Trifft mit einem regulären Ausdruck auf die gesamte URL zu. Formulieren Sie ihn präzise, damit er keine fremden Seiten erfasst." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Coincide con la URL completa mediante una expresión regular. Sé específico para no afectar a páginas no relacionadas." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Correspond à l’URL complète via une expression régulière. Soyez précis pour ne pas viser des pages sans rapport." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "正規表現で URL 全体に一致します。無関係なページに一致しないよう、具体的に指定してください。" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Corresponde ao URL completo com uma expressão regular. Seja específico para não atingir páginas não relacionadas." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Совпадает со всем URL по регулярному выражению. Будьте точны, чтобы не затронуть посторонние страницы." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "用正则表达式匹配完整网址。请写得足够具体,以免误匹配无关页面。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "以正規表示式比對完整網址。請寫得夠具體,以免誤比對無關頁面。" + } + } + } + }, + "Add URL Rule": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "URL-Regel hinzufügen" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Añadir regla de URL" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Ajouter une règle d’URL" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "URL ルールを追加" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Adicionar regra de URL" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Добавить правило URL" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "添加网址规则" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "新增網址規則" + } + } + } + }, + "Edit URL Rule": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "URL-Regel bearbeiten" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Editar regla de URL" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Modifier la règle d’URL" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "URL ルールを編集" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Editar regra de URL" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Изменить правило URL" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "编辑网址规则" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "編輯網址規則" + } + } + } + }, + "Match type": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Übereinstimmungstyp" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Tipo de coincidencia" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Type de correspondance" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "マッチタイプ" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Tipo de correspondência" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Тип совпадения" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "匹配类型" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "比對類型" + } + } + } + }, + "Add Rule…": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Regel hinzufügen …" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Añadir regla…" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Ajouter une règle…" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ルールを追加…" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Adicionar regra…" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Добавить правило…" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "添加规则…" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "新增規則…" + } + } + } } } } diff --git a/Sources/LockIME/UI/AboutView.swift b/Sources/LockIME/UI/AboutView.swift index c715a56..e5b2876 100644 --- a/Sources/LockIME/UI/AboutView.swift +++ b/Sources/LockIME/UI/AboutView.swift @@ -61,6 +61,10 @@ struct AboutView: View { .background(.regularMaterial) .sheet(isPresented: $showingAcknowledgements) { AcknowledgementsView() + // A sheet bridges into its own AppKit window that doesn't inherit + // the app's in-app language override — re-inject it. + .environment(\.locale, state.locale) + .id(state.localeIdentifier) } } diff --git a/Sources/LockIME/UI/Settings/AppRulesSettingsPane.swift b/Sources/LockIME/UI/Settings/AppRulesSettingsPane.swift index 682a347..6baddbc 100644 --- a/Sources/LockIME/UI/Settings/AppRulesSettingsPane.swift +++ b/Sources/LockIME/UI/Settings/AppRulesSettingsPane.swift @@ -67,6 +67,10 @@ struct AppRulesSettingsPane: View { ) } } + // A sheet bridges into its own AppKit window that doesn't inherit the + // app's in-app language override — re-inject it (rebuilding on change). + .environment(\.locale, state.locale) + .id(state.localeIdentifier) } } diff --git a/Sources/LockIME/UI/Settings/ImportReviewSheet.swift b/Sources/LockIME/UI/Settings/ImportReviewSheet.swift index 6d606bc..620a8d2 100644 --- a/Sources/LockIME/UI/Settings/ImportReviewSheet.swift +++ b/Sources/LockIME/UI/Settings/ImportReviewSheet.swift @@ -51,6 +51,13 @@ final class ImportReviewModel: Identifiable { func setMode(_ newMode: ImportMode) { plan.mode = newMode } + // MARK: URL-rule order (shown only when the file's order differs) + + var urlOrderDiffers: Bool { plan.urlOrderDiffers } + /// The effective order choice (the override, else the mode default). + var urlOrderUseFile: Bool { plan.effectiveUseFileOrder } + func setURLOrderUseFile(_ useFile: Bool) { plan.urlOrderUseFile = useFile } + // MARK: New-rule inclusion func setInclude(_ itemID: String, _ on: Bool) { mutate(itemID) { $0.include = on } } @@ -121,6 +128,9 @@ struct ImportReviewSheet: View { Divider() Form { modeSection + // Merge-only: Replace adopts the file's order wholesale, so there's + // nothing to choose there. + if model.mode == .merge, model.urlOrderDiffers { orderSection } if !model.newAppItems.isEmpty { newAppSection } if !model.newURLItems.isEmpty { newURLSection } if model.mode == .merge, !model.conflictItems.isEmpty { conflictSection } @@ -178,6 +188,28 @@ struct ImportReviewSheet: View { } } + // MARK: URL-rule order + + /// Shown in Merge when the file orders the shared URL rules differently (Replace + /// always adopts the file's order, so it offers no choice). Order is priority + /// (first match wins), so this is a real, reviewable choice — keep the local + /// arrangement or adopt the file's. Reuses the conflict picker's + /// "Keep Local"/"Use File" labels. + private var orderSection: some View { + Section { + Picker("", selection: Binding(get: { model.urlOrderUseFile }, set: { model.setURLOrderUseFile($0) })) { + Text("Keep Local").tag(false) + Text("Use File").tag(true) + } + .pickerStyle(.segmented) + .labelsHidden() + } header: { + Text("URL rule order") + } footer: { + SectionFooter("The backup lists your URL rules in a different priority order. Order is priority — the first matching rule wins.") + } + } + // MARK: New rules (App and URL split into their own sections) private var newAppSection: some View { @@ -428,18 +460,19 @@ struct ImportReviewSheet: View { /// Returning `Text` keeps it recolorable inside `HStack`s while resolving /// catalog keys against the injected `\.locale`. private func fileBindingText(_ item: ImportItem) -> Text { - bindingText(subject: item.subject, mode: item.fileMode, action: item.fileAction, source: item.fileSource) + bindingText(subject: item.subject, mode: item.fileMode, action: item.fileAction, source: item.fileSource, matchType: item.fileMatchType) } private func localBindingText(_ item: ImportItem) -> Text { - bindingText(subject: item.subject, mode: item.localMode, action: item.localAction, source: item.localSource) + bindingText(subject: item.subject, mode: item.localMode, action: item.localAction, source: item.localSource, matchType: item.localMatchType) } private func bindingText( subject: ImportItem.Subject, mode: AppRuleMode?, action: RuleAction?, - source: InputSourceID? + source: InputSourceID?, + matchType: URLMatchType? ) -> Text { switch subject { case .globalDefault: @@ -451,7 +484,13 @@ struct ImportReviewSheet: View { return pinnedBindingText(isSwitch: mode == .switched, source: source) case .url: guard let source else { return Text("Default") } - return pinnedBindingText(isSwitch: action == .switchOnce, source: source) + let pinned = pinnedBindingText(isSwitch: action == .switchOnce, source: source) + // Append the match type when it isn't the default so two same-source, + // same-action rules that differ only by match type stay distinguishable. + if let matchType, matchType != .domainSuffix { + return pinned + Text(verbatim: " · ") + Text(matchType.importLabel) + } + return pinned } } @@ -485,3 +524,17 @@ private extension ImportReviewModel { missingItems.allSatisfy { $0.missingDisposition == .remove } && !missingItems.isEmpty ? .remove : .keep } } + +private extension URLMatchType { + /// A compact label for the import comparison. Reuses the same catalog keys as + /// the URL Rules editor (the default suffix type is never labelled — it's the + /// unmarked, common case). + var importLabel: LocalizedStringKey { + switch self { + case .domainSuffix: "Domain suffix" + case .domain: "Exact domain" + case .domainKeyword: "Domain keyword" + case .urlRegex: "URL regex" + } + } +} diff --git a/Sources/LockIME/UI/Settings/URLRulesSettingsPane.swift b/Sources/LockIME/UI/Settings/URLRulesSettingsPane.swift index fa79494..170bc68 100644 --- a/Sources/LockIME/UI/Settings/URLRulesSettingsPane.swift +++ b/Sources/LockIME/UI/Settings/URLRulesSettingsPane.swift @@ -1,16 +1,37 @@ import AppKit import LockIMEKit import SwiftUI +import UniformTypeIdentifiers -/// Per-URL rules, gated behind the Accessibility-powered "enhanced mode". Rules -/// can only be edited once enhanced mode is enabled (which itself needs the -/// Accessibility permission). +/// What the editor sheet is editing — a brand-new rule, or an existing one. +/// Drives `.sheet(item:)`. +private enum EditorTarget: Identifiable { + case add + case edit(URLRule) + var id: String { + switch self { + case .add: "add" // i18n-exempt: a sheet-identity token, not a UI string + case .edit(let rule): rule.id.uuidString + } + } +} + +/// Per-URL rules, gated behind the Accessibility-powered "enhanced mode". Each +/// rule is a read-only **summary row** (drag to reorder by priority); adding and +/// editing happen in a dedicated `URLRuleEditor` sheet, so a row never crams a +/// type picker, a text field, and two more pickers onto one line. struct URLRulesSettingsPane: View { @Environment(AppState.self) private var state - @State private var newHost = "" - @State private var newSourceID: InputSourceID? - @State private var newAction: RuleAction = .lock + @State private var sheetTarget: EditorTarget? + /// The UUID string of the rule currently being dragged, shared with each row's + /// drop delegate so the list can reorder live as the row is dragged over others. + @State private var draggingID: String? + /// While a drag is in progress, the reordered list shown to the user. Kept + /// view-local — the live drag never mutates `config`, so a cancelled drag (a + /// release that lands on no drop target) persists and re-applies nothing; only + /// a committed drop calls `state.reorderURLRules`. `nil` when not dragging. + @State private var draftOrder: [URLRule]? var body: some View { let enhancedBinding = Binding( @@ -19,171 +40,519 @@ struct URLRulesSettingsPane: View { ) Form { - Section { - Toggle("Enhanced mode (per-URL rules)", isOn: enhancedBinding) - .disabled(!state.accessibilityGranted) + enhancedSection(enhancedBinding) + rulesSection + } + .formStyle(.grouped) + // Fallback reorder drop for the whole pane: a drag released *not* on a row + // (section chrome, the Add button, empty Form space) still commits, instead + // of leaving the draft order shown-but-unpersisted. Rows handle their own + // drop (deeper target wins); this only catches the off-row release. + .onDrop(of: [.text], delegate: PaneDropDelegate(draggingID: $draggingID, onCommit: { commitReorder() })) + .navigationTitle(state.loc("URL Rules")) + .onAppear { state.refreshAccessibilityStatus() } + .onReceive(NotificationCenter.default.publisher(for: NSApplication.didBecomeActiveNotification)) { _ in + state.refreshAccessibilityStatus() + } + .sheet(item: $sheetTarget) { target in + // A sheet bridges into its own AppKit window, which doesn't reliably + // inherit the app's in-app language override — re-inject it (and + // rebuild on language change) so the editor isn't resolved against the + // system language. Same pattern as the import Review sheet. + sheetEditor(target) + .environment(\.locale, state.locale) + .id(state.localeIdentifier) + } + } - if !state.accessibilityGranted { - AccessibilityRequiredNote("Enhanced mode requires Accessibility") - } + // MARK: Enhanced-mode section - // After an import, URL rules can exist while enhanced mode is - // still off (import never flips per-device runtime state) — a - // light, one-line hint, no prompt or multi-step guidance. - if !state.config.enhancedModeEnabled, !state.config.urlRules.isEmpty { - HStack(spacing: DS.Spacing.md) { - Image(systemName: "info.circle").foregroundStyle(.secondary) - Text("Enhanced mode is off, so your URL rules aren't active yet. Turn it on for them to take effect.") - .font(DS.Font.sectionFooter) - .foregroundStyle(.secondary) - Spacer(minLength: 0) - } - .padding(.vertical, DS.Spacing.xxs) + @ViewBuilder + private func enhancedSection(_ enhancedBinding: Binding) -> some View { + Section { + Toggle("Enhanced mode (per-URL rules)", isOn: enhancedBinding) + .disabled(!state.accessibilityGranted) + + if !state.accessibilityGranted { + AccessibilityRequiredNote("Enhanced mode requires Accessibility") + } + + // After an import, URL rules can exist while enhanced mode is still + // off (import never flips per-device runtime state) — a light, one-line + // hint, no prompt or multi-step guidance. + if !state.config.enhancedModeEnabled, !state.config.urlRules.isEmpty { + HStack(spacing: DS.Spacing.md) { + Image(systemName: "info.circle").foregroundStyle(.secondary) + Text("Enhanced mode is off, so your URL rules aren't active yet. Turn it on for them to take effect.") + .font(DS.Font.sectionFooter) + .foregroundStyle(.secondary) + Spacer(minLength: 0) } - } header: { - Text("Enhanced mode") - } footer: { - SectionFooter("Enhanced mode reads the active browser URL via Accessibility to apply per-URL rules. The core lock needs no permissions.") + .padding(.vertical, DS.Spacing.xxs) } + } header: { + Text("Enhanced mode") + } footer: { + SectionFooter("Enhanced mode reads the active browser URL via Accessibility to apply per-URL rules. The core lock needs no permissions.") + } + } - Section { - // Rules stay visible — and editable/removable — even when - // enhanced mode is off (e.g. right after an import), just shown - // dimmed since they aren't active yet. Only *adding* needs the - // mode (and its Accessibility permission) on. - if !state.config.urlRules.isEmpty { - ForEach(state.config.urlRules) { rule in - URLRuleRow(rule: rule) - .transition(.move(edge: .top).combined(with: .opacity)) - } - } else if state.config.enhancedModeEnabled { - emptyState - } else { - HStack(spacing: DS.Spacing.md) { - Image(systemName: "lock") - .foregroundStyle(.secondary) - Text("Enable enhanced mode to add per-URL rules.") - .foregroundStyle(.secondary) - } - .padding(.vertical, DS.Spacing.xxs) + // MARK: Rules section + + @ViewBuilder + private var rulesSection: some View { + Section { + if !state.config.urlRules.isEmpty { + // Order is priority (first match wins), so surface the reordering + // affordance once there's more than one rule. + if state.config.urlRules.count > 1 { reorderCaption } + ForEach(displayedRules) { rule in + URLRuleSummaryRow( + rule: rule, + active: state.config.enhancedModeEnabled, + draggingID: $draggingID, + onBeginDrag: { beginDrag(rule.id) }, + onEdit: { clearDrag(); sheetTarget = .edit(rule) }, + onReorderOver: { draggedID in reorder(draggedID: draggedID, over: rule.id) }, + onDropCommit: { commitReorder() } + ) } - if state.config.enhancedModeEnabled { - addRow + } else if state.config.enhancedModeEnabled { + emptyState + } else { + HStack(spacing: DS.Spacing.md) { + Image(systemName: "lock").foregroundStyle(.secondary) + Text("Enable enhanced mode to add per-URL rules.").foregroundStyle(.secondary) } - } header: { - Text("URL rules") - } footer: { - SectionFooter("Per-URL rules work in Safari, Firefox, and Chromium-based browsers (Chrome, Edge, Brave, Arc, Vivaldi, Opera).") + .padding(.vertical, DS.Spacing.xxs) + } + + if state.config.enhancedModeEnabled { + Button { clearDrag(); sheetTarget = .add } label: { Label("Add Rule…", systemImage: "plus") } } + } header: { + Text("URL rules") + } footer: { + SectionFooter("Per-URL rules work in Safari, Firefox, and Chromium-based browsers (Chrome, Edge, Brave, Arc, Vivaldi, Opera).") } - .formStyle(.grouped) - .navigationTitle(state.loc("URL Rules")) - // The grant button (and its watcher lifecycle) now lives in General; this - // pane only reflects the shared status, refreshing to catch a revoke. - .onAppear { state.refreshAccessibilityStatus() } - .onReceive(NotificationCenter.default.publisher(for: NSApplication.didBecomeActiveNotification)) { _ in - state.refreshAccessibilityStatus() + } + + private var reorderCaption: some View { + HStack(spacing: DS.Spacing.md) { + Image(systemName: "arrow.up.arrow.down").foregroundStyle(.secondary) + Text("Checked top to bottom — the first match wins. Drag to reorder.") + .font(DS.Font.sectionFooter) + .foregroundStyle(.secondary) + Spacer(minLength: 0) } + .padding(.vertical, DS.Spacing.xxs) } private var emptyState: some View { HStack(spacing: DS.Spacing.md) { - Image(systemName: "globe") - .foregroundStyle(.secondary) - Text("No URL rules yet.") - .foregroundStyle(.secondary) + Image(systemName: "globe").foregroundStyle(.secondary) + Text("No URL rules yet.").foregroundStyle(.secondary) } .frame(maxWidth: .infinity, alignment: .leading) .padding(.vertical, DS.Spacing.xxs) } - private var addRow: some View { - HStack(spacing: DS.Spacing.md) { - TextField("Host (e.g. github.com)", text: $newHost) - .textFieldStyle(.roundedBorder) - Picker("", selection: $newAction) { - Text("Lock to").tag(RuleAction.lock) - Text("Switch to").tag(RuleAction.switchOnce) - } - .labelsHidden() - .fixedSize() - Picker("", selection: $newSourceID) { - Text("Default").tag(InputSourceID?.none) - ForEach(state.availableSources) { source in - Text(source.localizedName).tag(InputSourceID?.some(source.id)) - } + // MARK: Editor sheet + + @ViewBuilder + private func sheetEditor(_ target: EditorTarget) -> some View { + Group { + switch target { + case .add: + URLRuleEditor(add: state.config.defaultSourceID, onCommit: { commit($0) }, onClose: { sheetTarget = nil }) + case .edit(let rule): + URLRuleEditor(edit: rule, onCommit: { commit($0) }, onRemove: { remove(rule) }, onClose: { sheetTarget = nil }) } - .labelsHidden() - .fixedSize() - Button("Add") { - let host = newHost.trimmingCharacters(in: .whitespaces) - guard !host.isEmpty, let sourceID = newSourceID ?? state.config.defaultSourceID else { return } - withAnimation(DS.Motion.list) { - state.upsertURLRule(URLRule(hostPattern: host, lockedSourceID: sourceID, action: newAction)) + } + .padding(DS.Spacing.xl) + .frame(width: 400) + } + + private func commit(_ rule: URLRule) { + withAnimation(DS.Motion.list) { state.upsertURLRule(rule) } + sheetTarget = nil + } + + private func remove(_ rule: URLRule) { + withAnimation(DS.Motion.list) { state.removeURLRule(id: rule.id) } + sheetTarget = nil + } + + /// The rows to show: the in-progress drag's local draft while dragging, else + /// the live (committed) order. The draft never touches `config`, so a cancelled + /// drag persists and re-applies nothing. + /// + /// The draft is shown only while it's a genuine permutation of the live rules + /// (same id set). If `config` changed underneath an in-progress drag (an + /// external `lockime://set-url-rule`/remove) or the draft is otherwise stale, we + /// fall back to the committed order so an external edit is never masked by a + /// stuck draft. A drag released off every drop target leaves the draft set with + /// no commit; it self-heals on the next drag or edit (which re-snapshots or + /// clears it) — `config` is never mutated mid-drag, so nothing is lost. + private var displayedRules: [URLRule] { + guard draggingID != nil, let draft = draftOrder, + Set(draft.map(\.id)) == Set(state.config.urlRules.map(\.id)) + else { return state.config.urlRules } + return draft + } + + /// Start of a drag: snapshot the current order into the view-local draft and + /// mark which rule is moving. (Snapshotting here also discards any stale draft + /// left by a previous drag that was released off-target.) + private func beginDrag(_ id: UUID) { + draftOrder = state.config.urlRules + draggingID = id.uuidString + } + + /// Live reorder: while a rule is dragged over `target`, move it into the + /// target's slot *in the draft* so the rows part to show where it will land + /// (what-you-see-is-what-you-get — no separate insertion line). In-memory and + /// view-local; nothing is persisted or re-applied until the drop commits. + private func reorder(draggedID: String, over target: UUID) { + guard draggedID != target.uuidString else { return } + var draft = draftOrder ?? state.config.urlRules + guard let from = draft.firstIndex(where: { $0.id.uuidString == draggedID }), + let to = draft.firstIndex(where: { $0.id == target }), from != to + else { return } + let moved = draft.remove(at: from) + draft.insert(moved, at: to) + withAnimation(DS.Motion.list) { draftOrder = draft } + } + + /// End of a drag: commit the draft order (one save + engine apply, a no-op if + /// unchanged or not a permutation) and clear the drag state. Reached from a + /// drop on a row or anywhere else in the pane — never left half-applied. + private func commitReorder() { + let draft = draftOrder + clearDrag() + if let draft { state.reorderURLRules(draft) } + } + + /// Drop the in-progress drag's view-local state without committing. + private func clearDrag() { + draftOrder = nil + draggingID = nil + } +} + +// MARK: - Summary row + +/// A read-only summary of one rule — `[grab] [icon] pattern · [type badge] · +/// binding`. Click the content to edit; drag the grab handle to reorder. +/// +/// Reorder uses `.onDrag` (from the handle) + a `DropDelegate` returning +/// `DropProposal(operation: .move)` — which suppresses the copy "+" badge that +/// `.dropDestination` shows — and reorders *live* as the row passes over others +/// (the rows part to show the landing spot; on release it is already in place). +/// `.onMove` isn't usable here because this list lives in a grouped `Form`. +private struct URLRuleSummaryRow: View { + @Environment(AppState.self) private var state + let rule: URLRule + let active: Bool + @Binding var draggingID: String? + let onBeginDrag: () -> Void + let onEdit: () -> Void + let onReorderOver: (String) -> Void + let onDropCommit: () -> Void + + var body: some View { + HStack(spacing: DS.Spacing.md) { + Image(systemName: "line.3.horizontal") + .font(.body) + .foregroundStyle(.secondary) + .frame(width: 20, height: 24) + .contentShape(.rect) + .help("Drag to reorder") + .onDrag { + onBeginDrag() + return NSItemProvider(object: rule.id.uuidString as NSString) + } preview: { + dragPreview } - newHost = "" - newSourceID = nil - newAction = .lock + + HStack(spacing: DS.Spacing.md) { + Image(systemName: "globe").foregroundStyle(.secondary) + + Text(verbatim: rule.hostPattern) + .foregroundStyle(active ? .primary : .secondary) + .lineLimit(1) + .truncationMode(.middle) + + Spacer(minLength: DS.Spacing.md) + + typeBadge + bindingText + .font(DS.Font.rowSubtitle) + .foregroundStyle(.secondary) + Image(systemName: "chevron.right") + .font(.caption2) + .foregroundStyle(.tertiary) } - .disabled( - newHost.trimmingCharacters(in: .whitespaces).isEmpty - || (newSourceID == nil && state.config.defaultSourceID == nil) + .contentShape(.rect) + .onTapGesture { onEdit() } + } + .padding(.vertical, DS.Spacing.xs) + .onDrop( + of: [.text], + delegate: RuleDropDelegate( + targetID: rule.id, + draggingID: $draggingID, + onReorderOver: onReorderOver, + onDropCommit: onDropCommit ) + ) + } + + /// A compact "lifted row" card that floats under the cursor while dragging — + /// a solid card (the system already renders the drag image translucent, so a + /// material here would double up into a muddy blob) sized to its content. + private var dragPreview: some View { + HStack(spacing: DS.Spacing.md) { + Image(systemName: "globe").foregroundStyle(.secondary) + Text(verbatim: rule.hostPattern).foregroundStyle(.primary) + typeBadge } + .fixedSize() + .padding(.horizontal, DS.Spacing.lg) + .padding(.vertical, DS.Spacing.md) + .background(Color(nsColor: .windowBackgroundColor), in: RoundedRectangle(cornerRadius: DS.Radius.row)) + .overlay( + RoundedRectangle(cornerRadius: DS.Radius.row).strokeBorder(.separator, lineWidth: 1) + ) + } + + private var typeBadge: some View { + Text(rule.matchType.pickerLabel) + .font(DS.Font.rowSubtitle) + .padding(.horizontal, DS.Spacing.sm) + .padding(.vertical, DS.Spacing.xxs) + .background(.quaternary, in: Capsule()) + .foregroundStyle(.secondary) + } + + private var bindingText: Text { + let name = state.sourceDisplayName(for: rule.lockedSourceID) ?? rule.lockedSourceID.rawValue + return rule.action == .switchOnce ? Text("Switch to \(name)") : Text("Lock to \(name)") } } -private struct URLRuleRow: View { +/// Drop delegate for a live, "+"-free reorder. `dropEntered` moves the dragged +/// rule into this row's slot (so rows part to reveal the landing spot); +/// `dropUpdated` returns `.move` to suppress the copy badge; `performDrop` +/// persists the final order. +private struct RuleDropDelegate: DropDelegate { + let targetID: UUID + @Binding var draggingID: String? + let onReorderOver: (String) -> Void + let onDropCommit: () -> Void + + func dropUpdated(info: DropInfo) -> DropProposal? { + DropProposal(operation: .move) + } + + func dropEntered(info: DropInfo) { + guard let id = draggingID, id != targetID.uuidString else { return } + onReorderOver(id) + } + + func performDrop(info: DropInfo) -> Bool { + onDropCommit() + return true + } +} + +/// Pane-wide fallback drop target so a reorder drag released *off* any row still +/// commits the draft order (rather than leaving it shown-but-unpersisted). Only +/// participates while a rule drag is in progress, so it never intercepts an +/// unrelated text drop. +private struct PaneDropDelegate: DropDelegate { + @Binding var draggingID: String? + let onCommit: () -> Void + + func validateDrop(info: DropInfo) -> Bool { draggingID != nil } + + func dropUpdated(info: DropInfo) -> DropProposal? { + draggingID != nil ? DropProposal(operation: .move) : nil + } + + func performDrop(info: DropInfo) -> Bool { + guard draggingID != nil else { return false } + onCommit() + return true + } +} + +// MARK: - The shared editor + +/// The rule editor, shown in a sheet for both adding and editing. Edits a local +/// draft and commits on Add/Done — dismissing without committing discards, the +/// standard editor contract. +private struct URLRuleEditor: View { @Environment(AppState.self) private var state - let rule: URLRule + + private let isAdd: Bool + private let ruleID: UUID + @State private var pattern: String + @State private var matchType: URLMatchType + @State private var action: RuleAction + @State private var source: InputSourceID? + private let onCommit: (URLRule) -> Void + private let onRemove: (() -> Void)? + private let onClose: () -> Void + + init(add defaultSource: InputSourceID?, onCommit: @escaping (URLRule) -> Void, onClose: @escaping () -> Void) { + self.isAdd = true + self.ruleID = UUID() + _pattern = State(initialValue: "") + _matchType = State(initialValue: .domainSuffix) + _action = State(initialValue: .lock) + _source = State(initialValue: nil) + self.onCommit = onCommit + self.onRemove = nil + self.onClose = onClose + } + + init(edit rule: URLRule, onCommit: @escaping (URLRule) -> Void, onRemove: @escaping () -> Void, onClose: @escaping () -> Void) { + self.isAdd = false + self.ruleID = rule.id + _pattern = State(initialValue: rule.hostPattern) + _matchType = State(initialValue: rule.matchType) + _action = State(initialValue: rule.action) + _source = State(initialValue: rule.lockedSourceID) + self.onCommit = onCommit + self.onRemove = onRemove + self.onClose = onClose + } + + private var trimmed: String { pattern.trimmingCharacters(in: .whitespaces) } + private var resolvedSource: InputSourceID? { source ?? state.config.defaultSourceID } + private var regexInvalid: Bool { matchType == .urlRegex && !trimmed.isEmpty && !URLMatcher.isValidRegex(trimmed) } + /// Whether the typed pattern already belongs to a *different* rule. The pattern + /// is a rule's portable identity (match-type-independent — backups/import key on + /// `hostPattern` alone), so two rules sharing one would silently collapse on the + /// next export→import. Block it here (with feedback) rather than letting the save + /// quietly overwrite the other rule or mint a duplicate. + private var patternCollides: Bool { + !trimmed.isEmpty && state.config.urlRules.contains { $0.id != ruleID && $0.hasSamePattern(as: trimmed) } + } + private var canCommit: Bool { !trimmed.isEmpty && resolvedSource != nil && !regexInvalid && !patternCollides } var body: some View { - // Dim the row when enhanced mode is off — the rule exists but isn't - // active yet — while keeping its controls usable (modify / remove). - let active = state.config.enhancedModeEnabled - return HStack(spacing: DS.Spacing.lg) { - Image(systemName: "globe") - .foregroundStyle(.secondary) - Text(rule.hostPattern) - .foregroundStyle(active ? .primary : .secondary) - Spacer(minLength: DS.Spacing.md) - Picker("", selection: actionBinding) { + VStack(alignment: .leading, spacing: DS.Spacing.lg) { + Text(isAdd ? "Add URL Rule" : "Edit URL Rule") + .font(.headline) + + // Match type + HStack(spacing: DS.Spacing.md) { + Text("Match type").foregroundStyle(.secondary) + Spacer(minLength: DS.Spacing.md) + Picker("", selection: $matchType) { + ForEach(URLMatchType.allCases) { type in Text(type.pickerLabel).tag(type) } + } + .labelsHidden() + .fixedSize() + } + + // Pattern + per-type hint / regex error (the placeholder names the + // field, so it needs no separate label). + VStack(alignment: .leading, spacing: DS.Spacing.xs) { + TextField("", text: $pattern, prompt: Text(matchType.patternPlaceholder)) + .textFieldStyle(.roundedBorder) + if regexInvalid { + Label("Invalid regular expression", systemImage: "exclamationmark.triangle") + .font(DS.Font.sectionFooter) + .foregroundStyle(DS.Palette.warning) + } else if patternCollides { + Label("A rule with this pattern already exists.", systemImage: "exclamationmark.triangle") + .font(DS.Font.sectionFooter) + .foregroundStyle(DS.Palette.warning) + } else { + Text(matchType.helpText) + .font(DS.Font.sectionFooter) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + } + + // Lock vs switch — the two segments are self-describing, so no label. + Picker("", selection: $action) { Text("Lock to").tag(RuleAction.lock) Text("Switch to").tag(RuleAction.switchOnce) } .labelsHidden() - .fixedSize() - Picker("", selection: sourceBinding) { - ForEach(state.availableSources) { source in - Text(source.localizedName).tag(source.id) + .pickerStyle(.segmented) + + // Target input source + HStack(spacing: DS.Spacing.md) { + Text("Input source").foregroundStyle(.secondary) + Spacer(minLength: DS.Spacing.md) + Picker("", selection: $source) { + Text("Default").tag(InputSourceID?.none) + ForEach(state.availableSources) { src in + Text(src.localizedName).tag(InputSourceID?.some(src.id)) + } } + .labelsHidden() + .fixedSize() } - .labelsHidden() - .fixedSize() - Button(role: .destructive) { - withAnimation(DS.Motion.list) { - state.removeURLRule(id: rule.id) - } - } label: { - Image(systemName: "trash") + + footer + } + } + + private var footer: some View { + HStack(spacing: DS.Spacing.md) { + if let onRemove { + Button(role: .destructive) { onRemove() } label: { Text("Remove") } } - .buttonStyle(.borderless) - .help("Remove rule") + Spacer() + Button("Cancel") { onClose() } + .keyboardShortcut(.cancelAction) + Button(isAdd ? "Add" : "Done") { commit() } + .keyboardShortcut(.defaultAction) + .buttonStyle(.borderedProminent) + .disabled(!canCommit) } - .padding(.vertical, DS.Spacing.xxs) } - private var sourceBinding: Binding { - Binding( - get: { rule.lockedSourceID }, - set: { state.upsertURLRule(URLRule(id: rule.id, hostPattern: rule.hostPattern, lockedSourceID: $0, action: rule.action)) } - ) + private func commit() { + guard let src = resolvedSource, !trimmed.isEmpty, !regexInvalid, !patternCollides else { return } + onCommit(URLRule(id: ruleID, hostPattern: trimmed, lockedSourceID: src, action: action, matchType: matchType)) } +} - private var actionBinding: Binding { - Binding( - get: { rule.action }, - set: { state.upsertURLRule(URLRule(id: rule.id, hostPattern: rule.hostPattern, lockedSourceID: rule.lockedSourceID, action: $0)) } - ) +// MARK: - Match-type display + +private extension URLMatchType { + /// The picker label / row badge. Literal keys so they stay in the catalog. + var pickerLabel: LocalizedStringKey { + switch self { + case .domainSuffix: "Domain suffix" + case .domain: "Exact domain" + case .domainKeyword: "Domain keyword" + case .urlRegex: "URL regex" + } + } + + var patternPlaceholder: LocalizedStringKey { + switch self { + case .domainSuffix, .domain: "Host (e.g. github.com)" + case .domainKeyword: "Keyword (e.g. google)" + case .urlRegex: "Regex (e.g. /pull/)" + } + } + + var helpText: LocalizedStringKey { + switch self { + case .domainSuffix: "Matches the domain and all its subdomains." + case .domain: "Matches only this exact domain, not its subdomains." + case .domainKeyword: "Matches any domain that contains this text." + case .urlRegex: "Matches the full URL with a regular expression. Be specific so it doesn't match unrelated pages." + } } } diff --git a/Sources/LockIMEKit/API/URLCommand.swift b/Sources/LockIMEKit/API/URLCommand.swift index c3a17c3..0071513 100644 --- a/Sources/LockIMEKit/API/URLCommand.swift +++ b/Sources/LockIMEKit/API/URLCommand.swift @@ -55,7 +55,7 @@ public enum URLCommand: Equatable, Sendable { // Enhanced mode + per-URL rules case setEnhancedMode(FlagArg) - case setURLRule(id: UUID?, host: String, source: SourceSelector, action: RuleAction) + case setURLRule(id: UUID?, host: String, source: SourceSelector, action: RuleAction, matchType: URLMatchType) case removeURLRule(URLRuleSelector) case clearURLRules @@ -354,7 +354,18 @@ public enum URLCommandParser { } private static func parseSetURLRule(_ params: [String: String]) -> Result { - guard let host = nonEmpty(params["host"]) else { return .failure(.missingParameter("host")) } + // The pattern rides in `host` for historical reasons; `pattern` is an + // alias that reads better for keyword/regex rules. + guard let rawHost = nonEmpty(params["host"]) ?? nonEmpty(params["pattern"]) else { + return .failure(.missingParameter("host")) + } + // Which param the pattern actually came from, so an error names what the + // caller sent (`host` wins when both are given). + let patternParam = nonEmpty(params["host"]) != nil ? "host" : "pattern" + // Trim (mirroring the editor) so a whitespace-only value can't persist a + // rule that normalizes to empty and silently matches nothing. + let host = rawHost.trimmingCharacters(in: .whitespaces) + guard !host.isEmpty else { return .failure(.missingParameter(patternParam)) } guard let selector = ruleSource(params) else { return .failure(.missingParameter("source")) } let action: RuleAction switch (params["action"] ?? "lock").lowercased() { @@ -362,6 +373,19 @@ public enum URLCommandParser { case "switch", "switchonce", "switch-once": action = .switchOnce case let other: return .failure(.invalidParameter(name: "action", value: other)) } + let matchType: URLMatchType + switch (params["match-type"] ?? params["matchtype"] ?? "domain-suffix").lowercased() { + case "domain-suffix", "domainsuffix", "suffix": matchType = .domainSuffix + case "domain", "domain-exact", "exact": matchType = .domain + case "domain-keyword", "domainkeyword", "keyword": matchType = .domainKeyword + case "url-regex", "urlregex", "regex": matchType = .urlRegex + case let other: return .failure(.invalidParameter(name: "match-type", value: other)) + } + // A regex rule whose pattern doesn't compile would silently match + // nothing — reject it at parse time, naming the param the caller used. + if matchType == .urlRegex, !URLMatcher.isValidRegex(host) { + return .failure(.invalidParameter(name: patternParam, value: host)) + } var ruleID: UUID? if let raw = nonEmpty(params["id"]) { guard let parsed = UUID(uuidString: raw) else { @@ -369,7 +393,7 @@ public enum URLCommandParser { } ruleID = parsed } - return .success(.setURLRule(id: ruleID, host: host, source: selector, action: action)) + return .success(.setURLRule(id: ruleID, host: host, source: selector, action: action, matchType: matchType)) } private static func parseRemoveURLRule(_ params: [String: String]) -> Result { diff --git a/Sources/LockIMEKit/Backup/ConfigBackup.swift b/Sources/LockIMEKit/Backup/ConfigBackup.swift index 27e883a..5275222 100644 --- a/Sources/LockIMEKit/Backup/ConfigBackup.swift +++ b/Sources/LockIMEKit/Backup/ConfigBackup.swift @@ -8,25 +8,42 @@ public struct BackupURLRule: Codable, Equatable, Sendable { public var lockedSourceID: InputSourceID /// Whether a matched URL locks to the source or just switches to it once. public var action: RuleAction + /// How `hostPattern` is matched against the browser's current URL. + public var matchType: URLMatchType - public init(hostPattern: String, lockedSourceID: InputSourceID, action: RuleAction = .lock) { + public init( + hostPattern: String, + lockedSourceID: InputSourceID, + action: RuleAction = .lock, + matchType: URLMatchType = .domainSuffix + ) { self.hostPattern = hostPattern self.lockedSourceID = lockedSourceID self.action = action + self.matchType = matchType } private enum CodingKeys: String, CodingKey { - case hostPattern, lockedSourceID, action + case hostPattern, lockedSourceID, action, matchType } - // Lenient: a backup written before the lock/switch distinction (or a - // hand-authored file) carries no `action` → default `.lock`. Keeps reading - // robust even though the .lockime format itself is pre-release. + // Lenient: a backup written before the lock/switch distinction carries no + // `action` → default `.lock`, and one written before match types carries no + // `matchType` → default `.domainSuffix` (the original host-suffix behavior). + // `action`/`matchType` are decoded as raw *strings* and mapped, so an + // *unrecognized* value (a newer LockIME wrote a match type this build doesn't + // know) also falls back to the default rather than throwing — otherwise that + // throw propagates through `decodeIfPresent([BackupURLRule].self)` and the + // whole backup mis-reports as `.damaged`. Keeps reading robust even though the + // .lockime format itself is pre-release. public init(from decoder: any Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) hostPattern = try container.decode(String.self, forKey: .hostPattern) lockedSourceID = try container.decode(InputSourceID.self, forKey: .lockedSourceID) - action = try container.decodeIfPresent(RuleAction.self, forKey: .action) ?? .lock + let rawAction = try container.decodeIfPresent(String.self, forKey: .action) + action = rawAction.flatMap(RuleAction.init(rawValue:)) ?? .lock + let rawMatchType = try container.decodeIfPresent(String.self, forKey: .matchType) + matchType = rawMatchType.flatMap(URLMatchType.init(rawValue:)) ?? .domainSuffix } } @@ -158,7 +175,7 @@ public extension ConfigBackup { let payload = BackupPayload( defaultSourceID: config.defaultSourceID, appRules: config.appRules, - urlRules: config.urlRules.map { BackupURLRule(hostPattern: $0.hostPattern, lockedSourceID: $0.lockedSourceID, action: $0.action) }, + urlRules: config.urlRules.map { BackupURLRule(hostPattern: $0.hostPattern, lockedSourceID: $0.lockedSourceID, action: $0.action, matchType: $0.matchType) }, sourceNames: catalog ) return ConfigBackup(appVersion: appVersion, payload: payload) diff --git a/Sources/LockIMEKit/Backup/ImportPlan.swift b/Sources/LockIMEKit/Backup/ImportPlan.swift index f785463..501f684 100644 --- a/Sources/LockIMEKit/Backup/ImportPlan.swift +++ b/Sources/LockIMEKit/Backup/ImportPlan.swift @@ -62,11 +62,15 @@ public struct ImportItem: Identifiable, Sendable, Equatable { /// File-side URL-rule action — lock vs one-shot switch (`nil` for the global /// default and app rules, whose lock/switch distinction rides in the mode). public let fileAction: RuleAction? + /// File-side URL-rule match type (`nil` for the global default and app rules). + public let fileMatchType: URLMatchType? /// Local-side mode/source, populated only for `.conflict` items. public let localMode: AppRuleMode? public let localSource: InputSourceID? /// Local-side URL-rule action, populated only for URL `.conflict` items. public let localAction: RuleAction? + /// Local-side URL-rule match type, populated only for URL `.conflict` items. + public let localMatchType: URLMatchType? // MARK: user choices @@ -89,7 +93,9 @@ public struct ImportItem: Identifiable, Sendable, Equatable { resolution: ConflictResolution, missingDisposition: MissingSourceDisposition, fileAction: RuleAction? = nil, - localAction: RuleAction? = nil + localAction: RuleAction? = nil, + fileMatchType: URLMatchType? = nil, + localMatchType: URLMatchType? = nil ) { self.id = id self.subject = subject @@ -97,9 +103,11 @@ public struct ImportItem: Identifiable, Sendable, Equatable { self.fileMode = fileMode self.fileSource = fileSource self.fileAction = fileAction + self.fileMatchType = fileMatchType self.localMode = localMode self.localSource = localSource self.localAction = localAction + self.localMatchType = localMatchType self.include = include self.resolution = resolution self.missingDisposition = missingDisposition @@ -154,6 +162,21 @@ public struct ImportPlan: Sendable, Equatable { /// names take precedence, with the file's catalog filling in those it lacks /// (so a missing source still shows a human label). public let sourceNames: [InputSourceID: String] + /// Whether the file orders the URL rules it shares with the local config in a + /// different priority sequence. Order is priority (first match wins), so when + /// this is true the import surfaces a reviewable order choice. False when the + /// two agree, or share fewer than two rules. + public let urlOrderDiffers: Bool + /// The user's **Merge-only** URL-rule order choice: `true` adopt the file's + /// order, `false`/`nil` keep the local order (the default). Ignored under + /// Replace, which always adopts the file's order. + public var urlOrderUseFile: Bool? + + /// The effective URL-rule order. Replace adopts the file's order + /// unconditionally — it makes the config *match* the file, order included — so + /// the order choice is a Merge-only affordance: Merge keeps the local order + /// unless the user opts into the file's. + public var effectiveUseFileOrder: Bool { mode == .replace ? true : (urlOrderUseFile ?? false) } public init( current: LockConfiguration, @@ -222,29 +245,40 @@ public struct ImportPlan: Sendable, Equatable { } } - // URL rules (keyed by host pattern). + // URL rules (keyed by host pattern). The pattern is a URL rule's portable + // identity — `ImportItem.id`, `localByHost`, and `urlMap` all key on it — + // so two file rules sharing a pattern would mint colliding item ids and + // break the Review list's `ForEach`/`firstIndex`. De-dupe the file's rules + // by pattern up front (keep the first, preserve order), the same collapse + // the resolver applies; the editor enforces one rule per pattern locally, + // so this only guards a hand-authored or legacy file. let localByHost = Dictionary( current.urlRules.map { ($0.hostPattern, $0) }, uniquingKeysWith: { first, _ in first } ) - for rule in backup.payload.urlRules { + var seenFileHosts = Set() + let fileURLRules = backup.payload.urlRules.filter { seenFileHosts.insert($0.hostPattern).inserted } + for rule in fileURLRules { if let local = localByHost[rule.hostPattern] { - // A lock-vs-switch difference on the same source is a conflict. + // A difference in source, lock-vs-switch, or match type on the + // same pattern is a conflict. let status: ImportItem.Status = - (local.lockedSourceID == rule.lockedSourceID && local.action == rule.action) + (local.lockedSourceID == rule.lockedSourceID && local.action == rule.action + && local.matchType == rule.matchType) ? .unchanged : .conflict items.append(ImportItem( id: "url:\(rule.hostPattern)", subject: .url(hostPattern: rule.hostPattern), status: status, fileMode: nil, fileSource: rule.lockedSourceID, localMode: nil, localSource: local.lockedSourceID, include: true, resolution: .keepLocal, missingDisposition: .keep, - fileAction: rule.action, localAction: local.action + fileAction: rule.action, localAction: local.action, + fileMatchType: rule.matchType, localMatchType: local.matchType )) } else { items.append(ImportItem( id: "url:\(rule.hostPattern)", subject: .url(hostPattern: rule.hostPattern), status: .new, fileMode: nil, fileSource: rule.lockedSourceID, localMode: nil, localSource: nil, include: true, resolution: .keepLocal, missingDisposition: .keep, - fileAction: rule.action + fileAction: rule.action, fileMatchType: rule.matchType )) } } @@ -255,6 +289,14 @@ public struct ImportPlan: Sendable, Equatable { let fileHosts = Set(backup.payload.urlRules.map(\.hostPattern)) self.localOnlyAppRuleCount = current.appRules.filter { !fileBundleIDs.contains($0.bundleID) }.count self.localOnlyURLRuleCount = current.urlRules.filter { !fileHosts.contains($0.hostPattern) }.count + + // Order is priority; the order choice only matters (and is only surfaced) + // when file and local share rules in a different relative order. + self.urlOrderUseFile = nil + let fileOrder = fileURLRules.map(\.hostPattern) + let localOrder = current.urlRules.map(\.hostPattern) + let common = Set(fileOrder).intersection(localOrder) + self.urlOrderDiffers = fileOrder.filter(common.contains) != localOrder.filter(common.contains) } // MARK: - Derived sections (pure functions of the current choices) @@ -315,20 +357,26 @@ public struct ImportPlan: Sendable, Equatable { /// base config and never imported. public func resolvedConfiguration() -> LockConfiguration { var appRules: [String: AppRule] - var urlRules: [String: URLRule] var defaultSource: InputSourceID? + // URL rules carry an explicit user-controlled priority (first match wins), + // so unlike app rules they are never alphabetized. `urlMap` holds the + // binding per pattern; the final order is computed afterwards from the + // order choice (`resolvedURLOrder`). (Duplicate host patterns collapse to + // one — the import diff keys URL rules by pattern.) + var urlMap: [String: URLRule] + switch mode { case .merge: appRules = Dictionary(baseConfig.appRules.map { ($0.bundleID, $0) }, uniquingKeysWith: { first, _ in first }) - urlRules = Dictionary(baseConfig.urlRules.map { ($0.hostPattern, $0) }, uniquingKeysWith: { first, _ in first }) + urlMap = Dictionary(baseConfig.urlRules.map { ($0.hostPattern, $0) }, uniquingKeysWith: { first, _ in first }) defaultSource = baseConfig.defaultSourceID case .replace: // Drop local-only rules. The global default is preserved unless the // file specifies one (a default item below) — a file without a // default never silently clears the user's. appRules = [:] - urlRules = [:] + urlMap = [:] defaultSource = baseConfig.defaultSourceID } @@ -354,20 +402,54 @@ public struct ImportPlan: Sendable, Equatable { } case .url(let host): if drop { - urlRules[host] = nil + urlMap[host] = nil } else if let source = item.fileSource { - urlRules[host] = URLRule(hostPattern: host, lockedSourceID: source, action: item.fileAction ?? .lock) + urlMap[host] = URLRule( + hostPattern: host, + lockedSourceID: source, + action: item.fileAction ?? .lock, + matchType: item.fileMatchType ?? .domainSuffix + ) } } } var result = baseConfig result.appRules = appRules.values.sorted { $0.bundleID < $1.bundleID } - result.urlRules = urlRules.values.sorted { $0.hostPattern < $1.hostPattern } + result.urlRules = resolvedURLOrder(present: urlMap) result.defaultSourceID = defaultSource return result } + /// The final URL-rule priority order over the surviving rules in `urlMap`, + /// honoring the order choice. Use-file → the file's order first, then any rule + /// present only locally (a Merge carry-over) appended in local order; keep-local + /// → local order first, then file-only rules appended in file order. First + /// occurrence wins; rules dropped from `urlMap` (missing-source removals) fall + /// out via the `urlMap[$0] != nil` filter. + private func resolvedURLOrder(present urlMap: [String: URLRule]) -> [URLRule] { + let fileOrder = items.compactMap { item -> String? in + if case .url(let host) = item.subject { return host } else { return nil } + } + let localOrder = orderedHostPatterns(baseConfig.urlRules) + let primary = effectiveUseFileOrder ? fileOrder : localOrder + let secondary = effectiveUseFileOrder ? localOrder : fileOrder + var seen = Set() + return (primary + secondary) + .filter { urlMap[$0] != nil && seen.insert($0).inserted } + .compactMap { urlMap[$0] } + } + + /// The host patterns of `rules` in order, first occurrence only. + private func orderedHostPatterns(_ rules: [URLRule]) -> [String] { + var seen = Set() + var order: [String] = [] + for rule in rules where seen.insert(rule.hostPattern).inserted { + order.append(rule.hostPattern) + } + return order + } + // MARK: - Summary /// A live tally of the import's effect under the current choices. @@ -375,24 +457,30 @@ public struct ImportPlan: Sendable, Equatable { let resolved = resolvedConfiguration() let base = baseConfig - let baseKeys = bindingKeys(of: base) - let resolvedKeys = bindingKeys(of: resolved) + let baseKeys = bindingKeys(of: base, includeURLPosition: true) + let resolvedKeys = bindingKeys(of: resolved, includeURLPosition: true) + // Position-blind binding fingerprints — same keys, values without the + // URL-rule index — so `inactive` can ask "did this rule's *binding* change" + // separately from the position-aware "did anything change" that drives + // `updated`/`hasEffect`. + let baseBindings = bindingKeys(of: base, includeURLPosition: false) + let resolvedBindings = bindingKeys(of: resolved, includeURLPosition: false) let resolvedSources = bindingSources(of: resolved) var added = 0, updated = 0, removed = 0, inactive = 0 for (key, binding) in resolvedKeys { - let changed: Bool if let was = baseKeys[key] { - changed = was != binding - if changed { updated += 1 } + if was != binding { updated += 1 } } else { - changed = true added += 1 } - // "Inactive" is scoped to what the import actually added or rebound — - // never a pre-existing local rule merely carried over — so the receipt - // ("其中 M 条…未生效") stays a true subset of the imported count. - if changed, let source = resolvedSources[key], !installedSourceIDs.contains(source) { + // "Inactive" is scoped to what the import actually added or *rebound* — + // never a pre-existing local rule merely carried over or merely + // reordered — so the receipt ("其中 M 条…未生效") stays a true subset of + // the imported count. A pure reorder changes priority, not a source's + // install status, so it tests the position-BLIND binding here. + let rebound = baseBindings[key] == nil || baseBindings[key] != resolvedBindings[key] + if rebound, let source = resolvedSources[key], !installedSourceIDs.contains(source) { inactive += 1 } } @@ -414,7 +502,7 @@ public struct ImportPlan: Sendable, Equatable { /// A comparable per-key binding snapshot of a configuration, so the same key /// in two configs can be diffed for "changed". The global default is keyed /// `"default"`; app rules `"app:"`; URL rules `"url:"`. - private func bindingKeys(of config: LockConfiguration) -> [String: String] { + private func bindingKeys(of config: LockConfiguration, includeURLPosition: Bool) -> [String: String] { var map: [String: String] = [:] if let def = config.defaultSourceID { map["default"] = def.rawValue } for rule in config.appRules { @@ -423,8 +511,17 @@ public struct ImportPlan: Sendable, Equatable { let source = rule.mode.pinsSource ? (rule.lockedSourceID?.rawValue ?? "") : "" map["app:\(rule.bundleID)"] = "\(rule.mode.rawValue)|\(source)" } - for rule in config.urlRules { - map["url:\(rule.hostPattern)"] = "\(rule.action.rawValue)|\(rule.lockedSourceID.rawValue)" + for (index, rule) in config.urlRules.enumerated() { + // Order is priority for URL rules (first match wins), so a rule's + // POSITION is part of its effective behavior: with `includeURLPosition` + // the index is folded in so a backup that only reorders the same rules + // reads as a change — flipping `hasEffect` on and tallying as an update + // (a position-blind key reported "no changes" and left Apply disabled). + // The position-BLIND form is used to decide `inactive`, which tracks a + // rule's source-install status: a reorder doesn't change that, so it + // must not inflate the inactive count. + let position = includeURLPosition ? "\(index)|" : "" + map["url:\(rule.hostPattern)"] = "\(position)\(rule.action.rawValue)|\(rule.matchType.rawValue)|\(rule.lockedSourceID.rawValue)" } return map } diff --git a/Sources/LockIMEKit/Enhanced/URLMatcher.swift b/Sources/LockIMEKit/Enhanced/URLMatcher.swift index 3f88f86..ff5d70f 100644 --- a/Sources/LockIMEKit/Enhanced/URLMatcher.swift +++ b/Sources/LockIMEKit/Enhanced/URLMatcher.swift @@ -69,6 +69,12 @@ public enum BrowserBundleIDs { } /// Pure host extraction and pattern matching for per-URL rules. +/// +/// Matching is **type-directed** (`URLRule.matchType`): the three domain types +/// look only at the URL's host, while `urlRegex` matches the *whole* URL string +/// (scheme/host/path/query/fragment) — the only type that can tell two pages of +/// one site apart. Rules are evaluated top-to-bottom; the first that matches +/// wins, so list order is the rule priority. public enum URLMatcher { /// The host of a URL string, lowercased (`https://Gist.GitHub.com/x` → `gist.github.com`). public static func host(from urlString: String) -> String? { @@ -77,23 +83,93 @@ public enum URLMatcher { return host?.lowercased() } - /// The first rule whose pattern matches `host`, or `nil`. - public static func matchedRule(host: String?, rules: [URLRule]) -> URLRule? { - guard let host = host?.lowercased(), !host.isEmpty else { return nil } - return rules.first { matches(host: host, pattern: $0.hostPattern) } + /// The first rule (in list order) matching `urlString`, or `nil`. + /// + /// The host is derived once and shared by the domain-family types; `urlRegex` + /// ignores it and matches the raw URL. An empty/authority-less URL still lets + /// a `urlRegex` rule run (it may match `about:`-style URLs), but the domain + /// types short-circuit to no-match without a host. + public static func matchedRule(urlString: String, rules: [URLRule]) -> URLRule? { + let host = host(from: urlString) + return rules.first { matches(rule: $0, urlString: urlString, host: host) } + } + + /// The locked source of the first rule matching `urlString`. + public static func match(urlString: String, rules: [URLRule]) -> InputSourceID? { + matchedRule(urlString: urlString, rules: rules)?.lockedSourceID } - /// The locked source of the first rule whose pattern matches `host`. - public static func match(host: String?, rules: [URLRule]) -> InputSourceID? { - matchedRule(host: host, rules: rules)?.lockedSourceID + /// Whether `rule` matches the URL, dispatching on the rule's match type. + /// `host` is the pre-extracted, lowercased host (or `nil` when the URL has no + /// authority) so callers iterating many rules extract it once. + static func matches(rule: URLRule, urlString: String, host: String?) -> Bool { + switch rule.matchType { + case .domainSuffix: + guard let host else { return false } + return matchesSuffix(host: host, pattern: rule.hostPattern) + case .domain: + guard let host else { return false } + let pattern = normalizedHostPattern(rule.hostPattern) + // An empty normalized pattern (a blank or `*.`-only rule) must not match + // an empty-host URL — fail closed, mirroring `matchesSuffix`. + guard !pattern.isEmpty else { return false } + return host == pattern + case .domainKeyword: + guard let host else { return false } + let keyword = rule.hostPattern.lowercased().trimmingCharacters(in: .whitespaces) + return !keyword.isEmpty && host.contains(keyword) + case .urlRegex: + return matchesRegex(urlString, pattern: rule.hostPattern) + } } /// A pattern matches a host if equal, a parent domain, or a `*.` wildcard. /// `github.com` matches `github.com` and `gist.github.com`. - static func matches(host: String, pattern rawPattern: String) -> Bool { - var pattern = rawPattern.lowercased().trimmingCharacters(in: .whitespaces) - if pattern.hasPrefix("*.") { pattern.removeFirst(2) } + static func matchesSuffix(host: String, pattern rawPattern: String) -> Bool { + let pattern = normalizedHostPattern(rawPattern) guard !pattern.isEmpty else { return false } return host == pattern || host.hasSuffix("." + pattern) } + + /// Lowercase + trim a host pattern and drop a leading `*.` so `*.google.com` + /// and `google.com` normalize alike. + private static func normalizedHostPattern(_ rawPattern: String) -> String { + var pattern = rawPattern.lowercased().trimmingCharacters(in: .whitespaces) + if pattern.hasPrefix("*.") { pattern.removeFirst(2) } + return pattern + } + + /// Upper bound on the URL length a `urlRegex` rule will match against. Real + /// URLs are far shorter; this only caps the regex engine's worst-case + /// backtracking against a pathologically long input. + static let maxRegexURLLength = 8192 + + /// Whether `urlString` contains a match for `pattern` (unanchored, + /// case-insensitive). An empty or invalid pattern never matches — a + /// half-typed or broken regex must not silently match every URL. + /// + /// The match is **length-bounded**: a user-authored pattern runs against the + /// live browser URL synchronously on the main actor (the URL poll), so an + /// absurdly long URL is rejected outright (fail-closed — no match) to bound + /// the engine's backtracking cost. The bound counts **UTF-16 code units** — + /// the unit `NSRange`/the ICU engine actually scans — not graphemes, so a + /// short-grapheme/long-code-unit string can't slip past it. This caps the + /// long-input amplification vector; it is *not* full ReDoS protection — a + /// pathological pattern (e.g. `(a+)+$`) can still backtrack for a long time on a + /// *short* URL, blocking the poll's main actor. The pattern is the user's own, + /// so this is an accepted residual; bounding *that* would require evaluating the + /// match off the main actor with a wall-clock deadline. + static func matchesRegex(_ urlString: String, pattern: String) -> Bool { + guard !pattern.isEmpty, !urlString.isEmpty, urlString.utf16.count <= maxRegexURLLength, + let regex = try? NSRegularExpression(pattern: pattern, options: [.caseInsensitive]) + else { return false } + let range = NSRange(urlString.startIndex..., in: urlString) + return regex.firstMatch(in: urlString, range: range) != nil + } + + /// Whether `pattern` is a valid regular expression — for the editor to warn + /// before a `urlRegex` rule is saved (an invalid pattern matches nothing). + public static func isValidRegex(_ pattern: String) -> Bool { + (try? NSRegularExpression(pattern: pattern)) != nil + } } diff --git a/Sources/LockIMEKit/LockEngine/LockEngine.swift b/Sources/LockIMEKit/LockEngine/LockEngine.swift index dc8142c..bab0533 100644 --- a/Sources/LockIMEKit/LockEngine/LockEngine.swift +++ b/Sources/LockIMEKit/LockEngine/LockEngine.swift @@ -200,7 +200,7 @@ public final class LockEngine { reason: effectiveReason(for: reason, ruleSource: ruleSource), bundleID: effectiveBundleID, ruleSource: ruleSource, - matchedHost: ruleSource == .urlRule ? urlMatch?.host : nil + matchedHost: ruleSource == .urlRule ? urlMatch?.pattern : nil ) // Re-arm the one-shot for a genuine frontmost/URL state — but NOT for a // launcher overlay shadowing the app (see the `.switchOnce` arm). @@ -209,7 +209,7 @@ public final class LockEngine { // A one-shot switch never holds the lock: clear any standing target // from a prior lock rule first, unconditionally. controller.setTarget(nil) - let context = ruleSource == .urlRule ? urlMatch?.host : effectiveBundleID + let context = ruleSource == .urlRule ? urlMatch?.pattern : effectiveBundleID let key = SwitchKey(ruleSource: ruleSource, context: context, sourceID: id) // Dedup against the launcher slot during an excursion, the frontmost // slot otherwise — so a launcher's own `.switched` rule firing while it @@ -279,12 +279,15 @@ public final class LockEngine { } } - /// The targeted source, matched host, and action from a URL rule, when - /// enhanced mode is on and the current page matches one. - private func enhancedURLMatch() -> (id: InputSourceID, host: String, action: RuleAction)? { + /// The targeted source, matched rule pattern, and action from a URL rule, + /// when enhanced mode is on and the current page matches one. The whole URL + /// is passed to the matcher so `urlRegex` rules can see path/query/fragment; + /// the returned `pattern` is the matched rule's own pattern (a stable per-rule + /// key for the one-shot dedup and the "why" shown in the activation log). + private func enhancedURLMatch() -> (id: InputSourceID, pattern: String, action: RuleAction)? { guard config.enhancedModeEnabled, let urlProvider, !config.urlRules.isEmpty else { return nil } let urlString = urlProvider.currentURL(forBundleID: effectiveBundleID) ?? "" - guard let rule = URLMatcher.matchedRule(host: URLMatcher.host(from: urlString), rules: config.urlRules) + guard let rule = URLMatcher.matchedRule(urlString: urlString, rules: config.urlRules) else { return nil } return (rule.lockedSourceID, rule.hostPattern, rule.action) } diff --git a/Sources/LockIMEKit/Rules/LockConfiguration.swift b/Sources/LockIMEKit/Rules/LockConfiguration.swift index add2758..c5fc538 100644 --- a/Sources/LockIMEKit/Rules/LockConfiguration.swift +++ b/Sources/LockIMEKit/Rules/LockConfiguration.swift @@ -52,47 +52,110 @@ public struct AppRule: Codable, Sendable, Hashable, Identifiable { self.mode = mode self.lockedSourceID = lockedSourceID } + + private enum CodingKeys: String, CodingKey { + case bundleID, mode, lockedSourceID + } + + // Lenient decoding (same rationale as `URLRule`): `mode` is decoded as a raw + // string and mapped, so a value this build doesn't recognize (a newer build + // added an `AppRuleMode` case, then the file is read after a downgrade) falls + // back to `.locked` instead of throwing — a per-element throw would propagate + // through `decodeIfPresent([AppRule].self)` and silently drop the whole config. + // `encode(to:)` stays synthesized off these keys. + public init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + bundleID = try container.decode(String.self, forKey: .bundleID) + let rawMode = try container.decodeIfPresent(String.self, forKey: .mode) + mode = rawMode.flatMap(AppRuleMode.init(rawValue:)) ?? .locked + lockedSourceID = try container.decodeIfPresent(InputSourceID.self, forKey: .lockedSourceID) + } +} + +/// How a `URLRule`'s pattern string is matched against the browser's current URL. +/// +/// The pattern (`URLRule.hostPattern`) is interpreted differently per case, so a +/// single rule type can be a domain, a domain family, a substring, or an +/// arbitrary regular expression. `domainSuffix` is the original behavior and the +/// lenient-decode default — every rule persisted before this field existed keeps +/// matching exactly as before. Rules are evaluated **top-to-bottom, first match +/// wins**, so the order of `LockConfiguration.urlRules` is the priority. +public enum URLMatchType: String, Codable, Sendable, CaseIterable, Identifiable { + /// Match the host *and all its subdomains* — `github.com` matches + /// `github.com` and `gist.github.com`. The original (and default) behavior; + /// a leading `*.` in the pattern is tolerated and ignored. + case domainSuffix = "domain-suffix" + /// Match *only* the exact host, never a subdomain — `github.com` matches + /// `github.com` but not `gist.github.com`. + case domain = "domain" + /// Match when the host *contains* the pattern as a substring — `google` + /// matches `google.com`, `mail.google.com`, and `googleapis.com`. + case domainKeyword = "domain-keyword" + /// Match the **whole URL** (scheme · host · path · query · fragment) against + /// the pattern as a regular expression. The only type that sees past the + /// host, so it can distinguish pages of one site by path or query. Matching + /// is case-insensitive and unanchored (use `^`/`$` to anchor). + case urlRegex = "url-regex" + + public var id: String { rawValue } } /// A per-URL rule for the optional Accessibility-gated enhanced mode. public struct URLRule: Codable, Sendable, Hashable, Identifiable { public var id: UUID - /// Host pattern, e.g. `github.com` (matches subdomains) or `*.google.com`. + /// The pattern string, interpreted per `matchType`: a host for + /// `domainSuffix`/`domain`, a substring for `domainKeyword`, or a regular + /// expression over the whole URL for `urlRegex`. Named `hostPattern` for + /// backward compatibility — it is the persisted key and the original meaning + /// (a host) is still the default interpretation. public var hostPattern: String public var lockedSourceID: InputSourceID /// Whether a matched URL locks to the source or just switches to it once. public var action: RuleAction + /// How `hostPattern` is matched against the browser's current URL. + public var matchType: URLMatchType public init( id: UUID = UUID(), hostPattern: String, lockedSourceID: InputSourceID, - action: RuleAction = .lock + action: RuleAction = .lock, + matchType: URLMatchType = .domainSuffix ) { self.id = id self.hostPattern = hostPattern self.lockedSourceID = lockedSourceID self.action = action + self.matchType = matchType } // Explicit keys (preserving the v1.x names) so the custom decoder below can - // reference `.action`; `encode(to:)` stays synthesized off these. + // reference `.action`/`.matchType`; `encode(to:)` stays synthesized off these. private enum CodingKeys: String, CodingKey { - case id, hostPattern, lockedSourceID, action + case id, hostPattern, lockedSourceID, action, matchType } // Lenient decoding: rules persisted before the lock/switch distinction carry - // no `action`, so a missing key decodes to `.lock` (the original behavior). - // This matters load-bearingly: `LockConfiguration` decodes `[URLRule]` with - // `decodeIfPresent`, which *propagates* a per-element throw — a non-lenient - // decoder would make one legacy URL rule abort the whole config load and - // silently drop every rule (see `RuleStore.load`'s `try?`). + // no `action`, and rules persisted before match types carry no `matchType`, + // so a missing key decodes to the original behavior (`.lock` / `.domainSuffix`). + // Crucially we decode `action`/`matchType` as raw *strings* and map them + // ourselves rather than as the enums directly: a missing key AND an + // *unrecognized* value (e.g. a newer build wrote a match type this build + // doesn't know, then the file is read after a downgrade) both fall back to the + // default instead of throwing. This matters load-bearingly: `LockConfiguration` + // decodes `[URLRule]` with `decodeIfPresent`, which *propagates* a per-element + // throw — a decoder that threw on an unknown value would make one such URL rule + // abort the whole config load and silently drop every rule (see + // `RuleStore.load`'s `try?`). public init(from decoder: any Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) id = try container.decode(UUID.self, forKey: .id) hostPattern = try container.decode(String.self, forKey: .hostPattern) lockedSourceID = try container.decode(InputSourceID.self, forKey: .lockedSourceID) - action = try container.decodeIfPresent(RuleAction.self, forKey: .action) ?? .lock + let rawAction = try container.decodeIfPresent(String.self, forKey: .action) + action = rawAction.flatMap(RuleAction.init(rawValue:)) ?? .lock + let rawMatchType = try container.decodeIfPresent(String.self, forKey: .matchType) + matchType = rawMatchType.flatMap(URLMatchType.init(rawValue:)) ?? .domainSuffix } } diff --git a/Sources/LockIMEKit/Rules/URLRuleList.swift b/Sources/LockIMEKit/Rules/URLRuleList.swift new file mode 100644 index 0000000..85ca433 --- /dev/null +++ b/Sources/LockIMEKit/Rules/URLRuleList.swift @@ -0,0 +1,82 @@ +import Foundation + +public extension URLRule { + /// Whether this rule's pattern is the same as `pattern`, **ignoring case** — + /// the same notion of sameness the URL-scheme API's host fallback uses + /// (`compare(options: .caseInsensitive)`). `hostPattern` is a rule's portable + /// identity, *match-type-independent* — the import diff keys a URL rule by + /// `"url:"` regardless of type, so two rules with an **identical** + /// pattern collapse (losing one) on the next export→import. Case-*variant* + /// patterns don't collapse on import (it keys on the exact string) but are + /// functionally redundant, since host matching is itself case-insensitive — so + /// the mutation paths fold them too, by comparing case-insensitively here. + func hasSamePattern(as pattern: String) -> Bool { + hostPattern.compare(pattern, options: .caseInsensitive) == .orderedSame + } +} + +/// Pure list operations for URL rules, holding the two load-bearing invariants in +/// one testable place (the `AppState` mutators are thin wrappers over these): +/// +/// 1. **Order is priority** — rules resolve top-to-bottom, first match wins — so an +/// edit must keep a rule's slot, never demote it to the bottom. +/// 2. **Pattern is identity** — these mutation paths never produce two rules sharing +/// a pattern (case-insensitively): backups/import key on `hostPattern`, so an +/// identical pair would collapse (losing one) on the next export→import, and a +/// case-variant pair is functionally redundant (host matching is case-insensitive). +/// The import diff de-dupes by the exact string; these guards are stricter, so a +/// case-variant pair can never be minted here in the first place. +public enum URLRuleList { + /// Insert or update `rule`, preserving both invariants and returning the new + /// list. The slot is resolved so a duplicate pattern can never be minted: + /// + /// - If `rule`'s pattern is unused by any *other* rule and a rule with its id + /// exists, update that rule **in place** (an edit keeps its priority slot). + /// - Else if some rule already owns the pattern (an add of an existing pattern, + /// or an edit that moved a rule's pattern onto another's), update **that** + /// rule in place (the documented upsert-by-host) and drop the rule the edit + /// vacated, so the pattern stays unique. + /// - Else append. + /// + /// Callers that can surface feedback (the editor, the URL-scheme API) reject a + /// collision *before* calling this, so the lossy fold is a last-resort net that + /// keeps the persisted config self-consistent even if a future caller forgets. + public static func upserting(_ rule: URLRule, into rules: [URLRule]) -> [URLRule] { + var rules = rules + let collidesWithOther = rules.contains { $0.id != rule.id && $0.hasSamePattern(as: rule.hostPattern) } + + if let idIndex = rules.firstIndex(where: { $0.id == rule.id }), !collidesWithOther { + rules[idIndex] = rule + } else if let patternIndex = rules.firstIndex(where: { $0.hasSamePattern(as: rule.hostPattern) }) { + let existingID = rules[patternIndex].id + rules[patternIndex] = URLRule( + id: existingID, + hostPattern: rule.hostPattern, + lockedSourceID: rule.lockedSourceID, + action: rule.action, + matchType: rule.matchType + ) + // If the edit moved a *different* rule (`rule.id`) onto this pattern, + // remove its vacated slot so the pattern isn't duplicated. + rules.removeAll { $0.id == rule.id && $0.id != existingID } + } else { + rules.append(rule) + } + return rules + } + + /// The result of committing a drag-reorder: `rules` re-sequenced to match the + /// id order of `ordered`, or `nil` when the reorder is a no-op (unchanged + /// order) or invalid (a non-permutation — a stale drag-start snapshot whose id + /// set no longer matches the live rules). Rules are relinked to the **live** + /// objects by id rather than carrying `ordered`'s snapshotted bindings, so a + /// content edit that landed mid-drag (e.g. a `lockime://set-url-rule`) survives + /// the reorder instead of being clobbered by the drag-start snapshot. + public static func reordered(_ rules: [URLRule], by ordered: [URLRule]) -> [URLRule]? { + let byID = Dictionary(rules.map { ($0.id, $0) }, uniquingKeysWith: { first, _ in first }) + guard Set(ordered.map(\.id)) == Set(byID.keys), + ordered.map(\.id) != rules.map(\.id) + else { return nil } + return ordered.compactMap { byID[$0.id] } + } +} diff --git a/Tests/LockIMEKitTests/ConfigBackupTests.swift b/Tests/LockIMEKitTests/ConfigBackupTests.swift index 5bb586b..0fb3704 100644 --- a/Tests/LockIMEKitTests/ConfigBackupTests.swift +++ b/Tests/LockIMEKitTests/ConfigBackupTests.swift @@ -90,6 +90,50 @@ struct ConfigBackupTests { """ let backup = try ConfigBackup.read(Data(json.utf8)).get() #expect(backup.payload.urlRules.first?.action == .lock) + // A backup that predates match types likewise decodes to the original + // suffix behavior — old .lockime files keep loading unchanged. + #expect(backup.payload.urlRules.first?.matchType == .domainSuffix) + } + + @Test("a backup with an unknown matchType/action degrades it, not the whole read") + func unknownEnumValuesDecodeLeniently() throws { + // A backup from a newer LockIME may carry a matchType/action value this + // build doesn't know. A non-lenient decoder would throw, propagate through + // the urlRules array decode, and mis-report the whole file as `.damaged`. + // Each unknown value must degrade to its default while the rest reads fine. + let json = """ + {"format": "\(ConfigBackup.formatIdentifier)", "minReader": 1, "appVersion": "9", + "payload": {"defaultSourceID": "com.apple.keylayout.US", "urlRules": [ + {"hostPattern": "github.com", "lockedSourceID": "com.apple.keylayout.ABC", "action": "warp", "matchType": "telepathy"}, + {"hostPattern": "example.com", "lockedSourceID": "com.apple.keylayout.US", "action": "switchOnce", "matchType": "domain"} + ]}} + """ + let backup = try ConfigBackup.read(Data(json.utf8)).get() // NOT .damaged + #expect(backup.payload.urlRules.count == 2) + #expect(backup.payload.defaultSourceID == "com.apple.keylayout.US") + let gh = try #require(backup.payload.urlRules.first { $0.hostPattern == "github.com" }) + #expect(gh.action == .lock) + #expect(gh.matchType == .domainSuffix) + let ex = try #require(backup.payload.urlRules.first { $0.hostPattern == "example.com" }) + #expect(ex.action == .switchOnce) + #expect(ex.matchType == .domain) + } + + @Test("URL-rule match types survive make→encode→read") + func matchTypesRoundTrip() throws { + let config = LockConfiguration( + enhancedModeEnabled: true, + urlRules: [ + URLRule(hostPattern: "github.com", lockedSourceID: "com.apple.keylayout.US", matchType: .domain), + URLRule(hostPattern: "google", lockedSourceID: "com.apple.keylayout.ABC", matchType: .domainKeyword), + URLRule(hostPattern: "/pull/", lockedSourceID: "com.apple.inputmethod.SCIM.ITABC", action: .switchOnce, matchType: .urlRegex), + ] + ) + let decoded = try ConfigBackup.read(ConfigBackup.make(from: config, appVersion: "1", sourceNames: names).encoded()).get() + #expect(decoded.payload.urlRules.map(\.matchType) == [.domain, .domainKeyword, .urlRegex]) + // Order is priority and must survive the round-trip (a JSON array preserves it). + #expect(decoded.payload.urlRules.map(\.hostPattern) == ["github.com", "google", "/pull/"]) + #expect(decoded.payload.urlRules.last?.action == .switchOnce) } @Test("encoded() is human-readable pretty JSON with unescaped slashes") diff --git a/Tests/LockIMEKitTests/ImportPlanTests.swift b/Tests/LockIMEKitTests/ImportPlanTests.swift index 8259cfd..99ad3bb 100644 --- a/Tests/LockIMEKitTests/ImportPlanTests.swift +++ b/Tests/LockIMEKitTests/ImportPlanTests.swift @@ -632,4 +632,220 @@ struct ImportPlanTests { #expect(replaced.urlRules.first?.action == .switchOnce) #expect(replaced.urlRules.first?.lockedSourceID == "US") } + + // MARK: - Match type + + @Test("a match-type-only difference on the same pattern is a URL conflict") + func matchTypeConflict() { + let current = LockConfiguration(urlRules: [ + URLRule(hostPattern: "github.com", lockedSourceID: "US", matchType: .domainSuffix), + ]) + var plan = ImportPlan(current: current, backup: backup( + urlRules: [BackupURLRule(hostPattern: "github.com", lockedSourceID: "US", matchType: .domain)] + ), installedSources: installed) + let conflict = item(plan, "url:github.com") + #expect(conflict?.status == .conflict) + #expect(conflict?.localMatchType == .domainSuffix) + #expect(conflict?.fileMatchType == .domain) + // Keep-local (the merge default) keeps the suffix; choosing the file + // applies the exact-domain match type. + #expect(plan.resolvedConfiguration().urlRules.first?.matchType == .domainSuffix) + if let idx = plan.items.firstIndex(where: { $0.id == "url:github.com" }) { + plan.items[idx].resolution = .useFile + } + #expect(plan.resolvedConfiguration().urlRules.first?.matchType == .domain) + } + + @Test("a new URL rule carries its match type into the resolved config") + func newURLRuleCarriesMatchType() { + let plan = ImportPlan(current: .default, backup: backup( + urlRules: [BackupURLRule(hostPattern: "/pull/", lockedSourceID: "US", matchType: .urlRegex)] + ), installedSources: installed) + #expect(item(plan, "url:/pull/")?.fileMatchType == .urlRegex) + #expect(plan.resolvedConfiguration().urlRules.first?.matchType == .urlRegex) + } + + // MARK: - Order preservation (priority survives import) + + @Test("merge preserves local URL-rule order and appends new file rules after it") + func mergePreservesURLOrder() { + // Local priority is a, b. The file re-lists them as c (new), b, a — a merge + // must keep the LOCAL order (a, b) and append only the new rule (c). + let current = LockConfiguration(urlRules: [ + URLRule(hostPattern: "a.com", lockedSourceID: "US"), + URLRule(hostPattern: "b.com", lockedSourceID: "US"), + ]) + let plan = ImportPlan(current: current, backup: backup(urlRules: [ + BackupURLRule(hostPattern: "c.com", lockedSourceID: "US"), + BackupURLRule(hostPattern: "b.com", lockedSourceID: "US"), + BackupURLRule(hostPattern: "a.com", lockedSourceID: "US"), + ]), installedSources: installed) + #expect(plan.resolvedConfiguration().urlRules.map(\.hostPattern) == ["a.com", "b.com", "c.com"]) + } + + @Test("replace uses the file's URL-rule order verbatim — never re-sorted") + func replaceUsesFileOrder() { + let current = LockConfiguration(urlRules: [ + URLRule(hostPattern: "a.com", lockedSourceID: "US"), + URLRule(hostPattern: "b.com", lockedSourceID: "US"), + ]) + var plan = ImportPlan(current: current, backup: backup(urlRules: [ + BackupURLRule(hostPattern: "z.com", lockedSourceID: "US"), + BackupURLRule(hostPattern: "a.com", lockedSourceID: "US"), + BackupURLRule(hostPattern: "m.com", lockedSourceID: "US"), + ]), installedSources: installed) + plan.mode = .replace + // The file's order is the user's chosen priority — NOT alphabetical. The + // old resolver sorted by hostPattern, which would have produced a, m, z. + #expect(plan.resolvedConfiguration().urlRules.map(\.hostPattern) == ["z.com", "a.com", "m.com"]) + } + + // MARK: - Duplicate host patterns (the editor prevents these, but a + // hand-authored/legacy file could carry them) + + @Test("two file rules sharing a host pattern collapse to one item — no id collision") + func duplicateFileHostPatternsCollapseToOneItem() { + // The pattern is a URL rule's portable identity (ImportItem.id, urlMap key), + // so two file rules with the same pattern must NOT produce two items with + // the same id (which would break the Review list's ForEach/firstIndex). The + // builder de-dupes by pattern, keeping the first. + let plan = ImportPlan(current: .default, backup: backup(urlRules: [ + BackupURLRule(hostPattern: "github.com", lockedSourceID: "US", matchType: .domainSuffix), + BackupURLRule(hostPattern: "github.com", lockedSourceID: "ABC", matchType: .domain), + ]), installedSources: installed) + let urlItems = plan.items.filter { $0.id == "url:github.com" } + #expect(urlItems.count == 1) + // First wins, deterministically. + #expect(urlItems.first?.fileSource == "US") + #expect(urlItems.first?.fileMatchType == .domainSuffix) + // The resolved config likewise carries exactly one rule for the pattern. + let resolved = plan.resolvedConfiguration().urlRules.filter { $0.hostPattern == "github.com" } + #expect(resolved.count == 1) + #expect(resolved.first?.lockedSourceID == "US") + } + + // MARK: - Order is a diff dimension (a reorder-only backup must be importable) + + @Test("replace detects and applies a reorder-only backup — order is priority") + func replaceAppliesReorderOnlyBackup() { + // Local order 1,2,3,4. The file lists the SAME rules/bindings in reverse. + // Order is priority (first match wins), so this IS a change: Replace must + // detect it (Apply enabled) and apply the file's order. A position-blind + // diff reported "no changes" and disabled Apply — the bug this guards. + let current = LockConfiguration(urlRules: [ + URLRule(hostPattern: "1.com", lockedSourceID: "US"), + URLRule(hostPattern: "2.com", lockedSourceID: "US"), + URLRule(hostPattern: "3.com", lockedSourceID: "US"), + URLRule(hostPattern: "4.com", lockedSourceID: "US"), + ]) + var plan = ImportPlan(current: current, backup: backup(urlRules: [ + BackupURLRule(hostPattern: "4.com", lockedSourceID: "US"), + BackupURLRule(hostPattern: "3.com", lockedSourceID: "US"), + BackupURLRule(hostPattern: "2.com", lockedSourceID: "US"), + BackupURLRule(hostPattern: "1.com", lockedSourceID: "US"), + ]), installedSources: installed) + plan.mode = .replace + #expect(plan.summary().hasEffect) + #expect(plan.resolvedConfiguration().urlRules.map(\.hostPattern) == ["4.com", "3.com", "2.com", "1.com"]) + } + + @Test("merge keeps local order, so a reorder-only backup is a no-op by design") + func mergeIgnoresReorderOnlyBackup() { + // Merge is non-destructive: it keeps the local arrangement and only adds + // new rules, so a file that merely reorders existing rules changes nothing + // in a merge. (Use Replace to adopt a backup's order.) + let current = LockConfiguration(urlRules: [ + URLRule(hostPattern: "1.com", lockedSourceID: "US"), + URLRule(hostPattern: "2.com", lockedSourceID: "US"), + ]) + let plan = ImportPlan(current: current, backup: backup(urlRules: [ + BackupURLRule(hostPattern: "2.com", lockedSourceID: "US"), + BackupURLRule(hostPattern: "1.com", lockedSourceID: "US"), + ]), installedSources: installed) // default mode = .merge + #expect(!plan.summary().hasEffect) + #expect(plan.resolvedConfiguration().urlRules.map(\.hostPattern) == ["1.com", "2.com"]) + } + + @Test("urlOrderDiffers flags a reorder of the shared rules — and only that") + func urlOrderDiffersDetection() { + let current = LockConfiguration(urlRules: [ + URLRule(hostPattern: "a.com", lockedSourceID: "US"), + URLRule(hostPattern: "b.com", lockedSourceID: "US"), + ]) + // Same rules, reversed → the order choice matters. + let reordered = ImportPlan(current: current, backup: backup(urlRules: [ + BackupURLRule(hostPattern: "b.com", lockedSourceID: "US"), + BackupURLRule(hostPattern: "a.com", lockedSourceID: "US"), + ]), installedSources: installed) + #expect(reordered.urlOrderDiffers) + // Same rules, same order → no choice to make. + let same = ImportPlan(current: current, backup: backup(urlRules: [ + BackupURLRule(hostPattern: "a.com", lockedSourceID: "US"), + BackupURLRule(hostPattern: "b.com", lockedSourceID: "US"), + ]), installedSources: installed) + #expect(!same.urlOrderDiffers) + // A purely-new file rule is not a reorder of the shared set. + let added = ImportPlan(current: current, backup: backup(urlRules: [ + BackupURLRule(hostPattern: "a.com", lockedSourceID: "US"), + BackupURLRule(hostPattern: "b.com", lockedSourceID: "US"), + BackupURLRule(hostPattern: "c.com", lockedSourceID: "US"), + ]), installedSources: installed) + #expect(!added.urlOrderDiffers) + } + + @Test("merge can opt into the file's order (overriding the keep-local default)") + func mergeCanAdoptFileOrder() { + let current = LockConfiguration(urlRules: [ + URLRule(hostPattern: "1.com", lockedSourceID: "US"), + URLRule(hostPattern: "2.com", lockedSourceID: "US"), + ]) + var plan = ImportPlan(current: current, backup: backup(urlRules: [ + BackupURLRule(hostPattern: "2.com", lockedSourceID: "US"), + BackupURLRule(hostPattern: "1.com", lockedSourceID: "US"), + ]), installedSources: installed) // default merge → keep local + #expect(plan.urlOrderDiffers) + #expect(!plan.summary().hasEffect) // default keeps local order + plan.urlOrderUseFile = true // user adopts the file's order + #expect(plan.summary().hasEffect) + #expect(plan.resolvedConfiguration().urlRules.map(\.hostPattern) == ["2.com", "1.com"]) + } + + @Test("replace always adopts the file's order — the order choice is Merge-only") + func replaceAlwaysUsesFileOrder() { + let current = LockConfiguration(urlRules: [ + URLRule(hostPattern: "1.com", lockedSourceID: "US"), + URLRule(hostPattern: "2.com", lockedSourceID: "US"), + ]) + var plan = ImportPlan(current: current, backup: backup(urlRules: [ + BackupURLRule(hostPattern: "2.com", lockedSourceID: "US"), + BackupURLRule(hostPattern: "1.com", lockedSourceID: "US"), + ]), installedSources: installed) + plan.mode = .replace + #expect(plan.resolvedConfiguration().urlRules.map(\.hostPattern) == ["2.com", "1.com"]) + // Replace makes the config match the file — order included — so a leftover + // Merge-side override is ignored: the file's order still wins. + plan.urlOrderUseFile = false + #expect(plan.resolvedConfiguration().urlRules.map(\.hostPattern) == ["2.com", "1.com"]) + } + + @Test("reordering a rule that pins a missing source does not tally as inactive") + func reorderDoesNotInflateInactive() { + // `a` pins a not-installed source; `b` is fine. The file shares both rules, + // reversed. Applying the file's order is a reorder, not a rebind — a + // reorder doesn't change a source's install status, so it must NOT be + // counted as a newly-inactive import (only added/rebound rules are). + let current = LockConfiguration(urlRules: [ + URLRule(hostPattern: "a.com", lockedSourceID: "Missing"), + URLRule(hostPattern: "b.com", lockedSourceID: "US"), + ]) + var plan = ImportPlan(current: current, backup: backup(urlRules: [ + BackupURLRule(hostPattern: "b.com", lockedSourceID: "US"), + BackupURLRule(hostPattern: "a.com", lockedSourceID: "Missing"), + ]), installedSources: installed) // "Missing" is not installed + plan.mode = .replace // replace defaults to file order → the reorder applies + let s = plan.summary() + #expect(s.hasEffect) // the reorder is a real change… + #expect(s.inactive == 0) // …but not a new inactive import + #expect(plan.resolvedConfiguration().urlRules.map(\.hostPattern) == ["b.com", "a.com"]) + } } diff --git a/Tests/LockIMEKitTests/LocalizationGuardTests.swift b/Tests/LockIMEKitTests/LocalizationGuardTests.swift index d366f4a..623c02a 100644 --- a/Tests/LockIMEKitTests/LocalizationGuardTests.swift +++ b/Tests/LockIMEKitTests/LocalizationGuardTests.swift @@ -154,15 +154,26 @@ struct LocalizationGuardTests { @Test("keys resolved outside SwiftUI exist in the catalog") func dynamicKeysExistInCatalog() throws { - // Keys that reach the catalog through `loc(...)`/`AppKitStrings` or a - // computed `messageKey` are invisible to Xcode's string extraction — - // a typo silently falls back to English. Every literal passed to - // those entry points must be a real catalog key. + // Keys that reach the catalog outside SwiftUI's literal extraction are + // invisible to Xcode's string extractor — a typo silently falls back to + // English. Three such entry points are scanned: `loc(...)` / + // `AppKitStrings.string(...)` / `.help(...)` calls; `UpdateFailure`'s bare + // full-line `messageKey` literals; and `LocalizedStringKey`-returning enum + // `switch` arms (e.g. `URLMatchType.pickerLabel`/`helpText`, the import + // sheet's mode/match-type labels). Every literal at those points must be a + // real catalog key. A `case` arm that returns a non-localized identity + // token (not a UI string) opts out with an `i18n-exempt` comment. let keys = Set(try Self.catalogStrings().keys) let callPattern = try Regex<(Substring, Substring)>( - #"(?:\bloc\(|AppKitStrings\.string\()\s*"((?:[^"\\]|\\.)+)""# + #"(?:\bloc\(|AppKitStrings\.string\(|\.help\()\s*"((?:[^"\\]|\\.)+)""# ) let literalLinePattern = try Regex<(Substring, Substring)>(#"(?m)^\s*"((?:[^"\\]|\\.)+)"$"#) + // A `case : "literal"` line — the shape of a computed property + // returning a `LocalizedStringKey` per enum case (the literal is the whole + // arm body, so the line ends in the closing quote). + let caseArmPattern = try Regex<(Substring, Substring)>( + #"^\s*case\s+\.[^:"]*:\s*"((?:[^"\\]|\\.)+)"\s*$"# + ) for (name, text) in try Self.appSwiftFiles() { var referenced = text.matches(of: callPattern).map { String($0.1) } @@ -170,9 +181,41 @@ struct LocalizationGuardTests { // `messageKey` returns bare full-line literals from a switch. referenced += text.matches(of: literalLinePattern).map { String($0.1) } } + for line in text.split(separator: "\n", omittingEmptySubsequences: false) + where !line.contains("i18n-exempt") { + if let match = String(line).firstMatch(of: caseArmPattern) { + referenced.append(String(match.1)) + } + } for key in referenced where !keys.contains(key) { Issue.record("\(name) resolves \"\(key)\" but Localizable.xcstrings has no such key") } } } + + @Test("every .sheet re-injects the in-app locale override") + func sheetsReinjectLocale() throws { + // A `.sheet` (like `.navigationTitle`) bridges into its own AppKit window, + // which resets `\.locale` to the *system* language — so a sheet whose + // content uses string literals renders against the system locale, not the + // app's in-app override, producing a half-translated screen. The fix is to + // re-inject `.environment(\.locale, state.locale)` at the call site (see + // BackupSettingsPane). This guards that every sheet does so. + for (name, text) in try Self.appSwiftFiles() { + let lines = Array(text.split(separator: "\n", omittingEmptySubsequences: false)) + for (index, line) in lines.enumerated() { + // Skip comments (e.g. a doc comment mentioning `.sheet(...)`). + let code = line.prefix(upTo: line.firstRange(of: "//")?.lowerBound ?? line.endIndex) + guard code.contains(".sheet(") else { continue } + // The re-injection lives inside the sheet's content closure, a few + // lines down — scan a generous window. + let window = lines[index.. URLCommand? { command(url) } + // Canonical tokens. + #expect(parsed("lockime://set-url-rule?host=github.com&source=com.apple.keylayout.ABC&match-type=domain") + == .setURLRule(id: nil, host: "github.com", source: .id(abc), action: .lock, matchType: .domain)) + #expect(parsed("lockime://set-url-rule?host=github.com&source=com.apple.keylayout.ABC&match-type=domain-keyword") + == .setURLRule(id: nil, host: "github.com", source: .id(abc), action: .lock, matchType: .domainKeyword)) + // Aliases (keyword / regex) and the `pattern` alias for the pattern param. + #expect(parsed("lockime://set-url-rule?pattern=google&source=com.apple.keylayout.ABC&match-type=keyword") + == .setURLRule(id: nil, host: "google", source: .id(abc), action: .lock, matchType: .domainKeyword)) + #expect(parsed("lockime://set-url-rule?pattern=%2Fpull%2F&source=com.apple.keylayout.ABC&match-type=regex") + == .setURLRule(id: nil, host: "/pull/", source: .id(abc), action: .lock, matchType: .urlRegex)) + } + + @Test("set-url-rule rejects a bad match-type and an uncompilable regex") + func setURLRuleMatchTypeErrors() { + #expect(failure("lockime://set-url-rule?host=x.com&source=com.apple.keylayout.ABC&match-type=bogus") + == .invalidParameter(name: "match-type", value: "bogus")) + // A regex rule whose pattern doesn't compile is rejected at parse time (it + // would otherwise silently match nothing). The pattern rides in `host`. + #expect(failure("lockime://set-url-rule?host=%5Bunclosed&source=com.apple.keylayout.ABC&match-type=regex") + == .invalidParameter(name: "host", value: "[unclosed")) + // A valid regex with the same brackets balanced parses fine. + #expect(command("lockime://set-url-rule?host=%5Ba-z%5D&source=com.apple.keylayout.ABC&match-type=regex") + == .setURLRule(id: nil, host: "[a-z]", source: .id(abc), action: .lock, matchType: .urlRegex)) + } + + @Test("set-url-rule trims the pattern, prefers host over pattern, and names the param it errors on") + func setURLRulePatternParamHandling() { + // A whitespace-only pattern normalizes to empty and would persist a dead + // rule; reject it as missing, naming the param the caller actually sent. + #expect(failure("lockime://set-url-rule?host=%20%20&source=com.apple.keylayout.ABC") + == .missingParameter("host")) + #expect(failure("lockime://set-url-rule?pattern=%20%20&source=com.apple.keylayout.ABC") + == .missingParameter("pattern")) + // When both are given, `host` wins (back-compat); surrounding space is trimmed. + #expect(command("lockime://set-url-rule?host=%20github.com%20&pattern=ignored.com&source=com.apple.keylayout.ABC") + == .setURLRule(id: nil, host: "github.com", source: .id(abc), action: .lock, matchType: .domainSuffix)) + // An uncompilable regex sent via the `pattern` alias is reported against + // `pattern` — the param the caller used — not the historical `host`. + #expect(failure("lockime://set-url-rule?pattern=%5Bunclosed&source=com.apple.keylayout.ABC&match-type=regex") + == .invalidParameter(name: "pattern", value: "[unclosed")) } @Test("set-url-rule reports missing host/source and invalid id/action") diff --git a/Tests/LockIMEKitTests/URLMatcherTests.swift b/Tests/LockIMEKitTests/URLMatcherTests.swift index d590ac5..35bf82b 100644 --- a/Tests/LockIMEKitTests/URLMatcherTests.swift +++ b/Tests/LockIMEKitTests/URLMatcherTests.swift @@ -20,7 +20,7 @@ struct URLMatcherTests { func noHost() { #expect(URLMatcher.host(from: "") == nil) #expect(URLMatcher.host(from: "not a url") == nil) - // Browser placeholder pages carry no authority, so a per-URL rule can + // Browser placeholder pages carry no authority, so a domain rule can // never false-match them — a Firefox new tab surfaces as about:newtab, // not a real host. (We rely on a nil host here rather than normalizing // these scheme-by-scheme.) @@ -28,51 +28,179 @@ struct URLMatcherTests { #expect(URLMatcher.host(from: "about:blank") == nil) } - @Test("exact and subdomain matches") - func exactAndSubdomain() { + // MARK: Domain-suffix (the default, original behavior) + + @Test("domain suffix matches the host and its subdomains (default type)") + func domainSuffix() { let rules = [URLRule(hostPattern: "github.com", lockedSourceID: us)] - #expect(URLMatcher.match(host: "github.com", rules: rules) == us) - #expect(URLMatcher.match(host: "gist.github.com", rules: rules) == us) - #expect(URLMatcher.match(host: "notgithub.com", rules: rules) == nil) - #expect(URLMatcher.match(host: "example.com", rules: rules) == nil) + #expect(URLMatcher.match(urlString: "https://github.com/x", rules: rules) == us) + #expect(URLMatcher.match(urlString: "https://gist.github.com/y", rules: rules) == us) + #expect(URLMatcher.match(urlString: "https://notgithub.com/", rules: rules) == nil) + #expect(URLMatcher.match(urlString: "https://example.com/", rules: rules) == nil) + // The default match type is domainSuffix, so a rule built without one + // behaves exactly as before this feature existed. + #expect(URLRule(hostPattern: "x", lockedSourceID: us).matchType == .domainSuffix) } - @Test("wildcard pattern matches base and subdomains") + @Test("wildcard suffix pattern matches base and subdomains") func wildcard() { let rules = [URLRule(hostPattern: "*.google.com", lockedSourceID: pinyin)] - #expect(URLMatcher.match(host: "google.com", rules: rules) == pinyin) - #expect(URLMatcher.match(host: "mail.google.com", rules: rules) == pinyin) - #expect(URLMatcher.match(host: "evilgoogle.com", rules: rules) == nil) + #expect(URLMatcher.match(urlString: "https://google.com", rules: rules) == pinyin) + #expect(URLMatcher.match(urlString: "https://mail.google.com", rules: rules) == pinyin) + #expect(URLMatcher.match(urlString: "https://evilgoogle.com", rules: rules) == nil) + } + + // MARK: Exact domain + + @Test("exact domain matches only the host, never a subdomain") + func exactDomain() { + let rules = [URLRule(hostPattern: "github.com", lockedSourceID: us, matchType: .domain)] + #expect(URLMatcher.match(urlString: "https://github.com/x", rules: rules) == us) + #expect(URLMatcher.match(urlString: "https://gist.github.com/x", rules: rules) == nil) + // Host comparison is case-insensitive; a leading `*.` normalizes away too. + #expect(URLMatcher.match(urlString: "https://GitHub.com/x", rules: rules) == us) + let wild = [URLRule(hostPattern: "*.github.com", lockedSourceID: us, matchType: .domain)] + #expect(URLMatcher.match(urlString: "https://github.com/x", rules: wild) == us) + } + + @Test("an exact-domain rule with a blank or `*.`-only pattern never matches (fails closed)") + func exactDomainBlankPattern() { + // " " and "*." both normalize to an empty host pattern. A non-empty host can + // never equal "", and crucially an empty host must NOT match either — mirror + // domain-suffix's empty-pattern guard so a blank rule can't silently match. + for pat in [" ", "*.", ""] { + let rules = [URLRule(hostPattern: pat, lockedSourceID: us, matchType: .domain)] + #expect(URLMatcher.match(urlString: "https://github.com/x", rules: rules) == nil) + // Directly exercise the empty-host short-circuit (the regression): + // without the guard, "" == "" would true-match. + #expect(!URLMatcher.matches(rule: rules[0], urlString: "", host: "")) + } + } + + // MARK: Domain keyword + + @Test("domain keyword matches any host containing the keyword") + func domainKeyword() { + let rules = [URLRule(hostPattern: "google", lockedSourceID: pinyin, matchType: .domainKeyword)] + #expect(URLMatcher.match(urlString: "https://google.com", rules: rules) == pinyin) + #expect(URLMatcher.match(urlString: "https://mail.google.com", rules: rules) == pinyin) + #expect(URLMatcher.match(urlString: "https://googleapis.com", rules: rules) == pinyin) + #expect(URLMatcher.match(urlString: "https://example.com", rules: rules) == nil) + // An empty/whitespace keyword never matches (it would otherwise match all). + let blank = [URLRule(hostPattern: " ", lockedSourceID: us, matchType: .domainKeyword)] + #expect(URLMatcher.match(urlString: "https://example.com", rules: blank) == nil) + } + + // MARK: URL regex (the only type that sees past the host) + + @Test("url regex matches the whole URL including path, query, and fragment") + func urlRegex() { + let pull = [URLRule(hostPattern: "github\\.com/[^/]+/[^/]+/pull/", lockedSourceID: us, matchType: .urlRegex)] + #expect(URLMatcher.match(urlString: "https://github.com/owner/repo/pull/42", rules: pull) == us) + #expect(URLMatcher.match(urlString: "https://github.com/owner/repo/issues/42", rules: pull) == nil) + // Query and fragment are part of the matched string. + let query = [URLRule(hostPattern: "tab=settings", lockedSourceID: pinyin, matchType: .urlRegex)] + #expect(URLMatcher.match(urlString: "https://app.example.com/x?tab=settings#a", rules: query) == pinyin) + let fragment = [URLRule(hostPattern: "#section-3$", lockedSourceID: pinyin, matchType: .urlRegex)] + #expect(URLMatcher.match(urlString: "https://app.example.com/x#section-3", rules: fragment) == pinyin) + } + + @Test("url regex is case-insensitive and unanchored") + func urlRegexFlags() { + // Uppercase pattern matches a lowercase path (case-insensitive), anywhere + // in the URL (unanchored). + let rules = [URLRule(hostPattern: "ADMIN", lockedSourceID: us, matchType: .urlRegex)] + #expect(URLMatcher.match(urlString: "https://example.com/admin/panel", rules: rules) == us) + } + + @Test("an invalid or empty regex never matches (and isValidRegex flags it)") + func urlRegexInvalid() { + let broken = [URLRule(hostPattern: "[unclosed", lockedSourceID: us, matchType: .urlRegex)] + #expect(URLMatcher.match(urlString: "https://example.com/", rules: broken) == nil) + let empty = [URLRule(hostPattern: "", lockedSourceID: us, matchType: .urlRegex)] + #expect(URLMatcher.match(urlString: "https://example.com/", rules: empty) == nil) + #expect(!URLMatcher.isValidRegex("[unclosed")) + #expect(!URLMatcher.isValidRegex("(a")) + #expect(URLMatcher.isValidRegex("github\\.com/.*/pull")) + } + + @Test("url regex matching is length-bounded against a pathologically long URL") + func urlRegexLengthBounded() { + let rules = [URLRule(hostPattern: "example", lockedSourceID: us, matchType: .urlRegex)] + // A normal URL containing the pattern still matches. + #expect(URLMatcher.match(urlString: "https://example.com/", rules: rules) == us) + // A URL past the cap is rejected outright (fail-closed) rather than fed to + // the backtracking engine — even though it contains the pattern. + let huge = "https://example.com/" + String(repeating: "a", count: URLMatcher.maxRegexURLLength) + #expect(huge.count > URLMatcher.maxRegexURLLength) + #expect(URLMatcher.match(urlString: huge, rules: rules) == nil) + } + + @Test("the length bound counts UTF-16 code units at the exact boundary, not graphemes") + func urlRegexLengthBoundUTF16() { + let rules = [URLRule(hostPattern: "a", lockedSourceID: us, matchType: .urlRegex)] + // Exactly at the cap (in UTF-16 units) and containing the pattern → matches. + let atCap = String(repeating: "a", count: URLMatcher.maxRegexURLLength) + #expect(atCap.utf16.count == URLMatcher.maxRegexURLLength) + #expect(URLMatcher.match(urlString: atCap, rules: rules) == us) + // One code unit over → rejected. + #expect(URLMatcher.match(urlString: atCap + "a", rules: rules) == nil) + // Few graphemes but many UTF-16 units (each emoji is 2 units): the bound is + // in UTF-16, so a grapheme count at the cap is still rejected when its code- + // unit length exceeds it — a grapheme-based count would have let it through. + let emoji = String(repeating: "😀", count: URLMatcher.maxRegexURLLength) + #expect(emoji.count == URLMatcher.maxRegexURLLength) + #expect(emoji.utf16.count > URLMatcher.maxRegexURLLength) + #expect(URLMatcher.match(urlString: emoji, rules: [URLRule(hostPattern: "😀", lockedSourceID: us, matchType: .urlRegex)]) == nil) + } + + @Test("a regex can match an authority-less URL a domain rule never could") + func urlRegexNoAuthority() { + let suffix = [URLRule(hostPattern: "x.com", lockedSourceID: us)] + #expect(URLMatcher.match(urlString: "about:newtab", rules: suffix) == nil) + let regex = [URLRule(hostPattern: "^about:", lockedSourceID: pinyin, matchType: .urlRegex)] + #expect(URLMatcher.match(urlString: "about:newtab", rules: regex) == pinyin) + // …but an empty URL still matches nothing, regex or not. + #expect(URLMatcher.match(urlString: "", rules: regex) == nil) } - @Test("first matching rule wins") + // MARK: Ordering / precedence + + @Test("first matching rule wins — list order is the priority, across match types") func firstWins() { + // A specific regex above a broad suffix: the /pull page goes to `us`, + // everything else on github.com goes to `pinyin`. let rules = [ - URLRule(hostPattern: "docs.github.com", lockedSourceID: pinyin), - URLRule(hostPattern: "github.com", lockedSourceID: us), + URLRule(hostPattern: "/pull/", lockedSourceID: us, matchType: .urlRegex), + URLRule(hostPattern: "github.com", lockedSourceID: pinyin), ] - #expect(URLMatcher.match(host: "docs.github.com", rules: rules) == pinyin) - #expect(URLMatcher.match(host: "api.github.com", rules: rules) == us) - } - - @Test("nil host or no rules yields no match") - func noMatch() { - #expect(URLMatcher.match(host: nil, rules: [URLRule(hostPattern: "x.com", lockedSourceID: us)]) == nil) - #expect(URLMatcher.match(host: "x.com", rules: []) == nil) + #expect(URLMatcher.match(urlString: "https://github.com/o/r/pull/1", rules: rules) == us) + #expect(URLMatcher.match(urlString: "https://github.com/o/r/issues/1", rules: rules) == pinyin) + // Reversing the priority makes the broad suffix win everywhere — the only + // thing that changed is order, which is exactly what reordering controls. + let reversed = Array(rules.reversed()) + #expect(URLMatcher.match(urlString: "https://github.com/o/r/pull/1", rules: reversed) == pinyin) } - @Test("matchedRule surfaces the winning rule (host pattern + source)") + @Test("matchedRule surfaces the winning rule") func matchedRuleReturnsRule() { let rules = [ URLRule(hostPattern: "docs.github.com", lockedSourceID: pinyin), URLRule(hostPattern: "github.com", lockedSourceID: us), ] - #expect(URLMatcher.matchedRule(host: "docs.github.com", rules: rules)?.hostPattern == "docs.github.com") - #expect(URLMatcher.matchedRule(host: "api.github.com", rules: rules)?.hostPattern == "github.com") - #expect(URLMatcher.matchedRule(host: "example.com", rules: rules) == nil) - #expect(URLMatcher.matchedRule(host: nil, rules: rules) == nil) + #expect(URLMatcher.matchedRule(urlString: "https://docs.github.com/x", rules: rules)?.hostPattern == "docs.github.com") + #expect(URLMatcher.matchedRule(urlString: "https://api.github.com/x", rules: rules)?.hostPattern == "github.com") + #expect(URLMatcher.matchedRule(urlString: "https://example.com/", rules: rules) == nil) + #expect(URLMatcher.matchedRule(urlString: "", rules: rules) == nil) } + @Test("no rules yields no match") + func noRules() { + #expect(URLMatcher.match(urlString: "https://x.com", rules: []) == nil) + } + + // MARK: Browser detection (unchanged) + @Test("browser bundle detection (Safari + Chromium + Gecko)") func browsers() { #expect(BrowserBundleIDs.isBrowser("com.apple.Safari")) diff --git a/Tests/LockIMEKitTests/URLRuleListTests.swift b/Tests/LockIMEKitTests/URLRuleListTests.swift new file mode 100644 index 0000000..a114491 --- /dev/null +++ b/Tests/LockIMEKitTests/URLRuleListTests.swift @@ -0,0 +1,127 @@ +import Foundation +import Testing + +@testable import LockIMEKit + +/// The pure URL-rule list operations behind `AppState.upsertURLRule` / +/// `reorderURLRules`. These hold two load-bearing invariants that were previously +/// only exercised through the (untestable) app-target mutators: +/// 1. order is priority — an edit keeps a rule's slot, never demotes it; +/// 2. pattern is identity — no two rules ever share a pattern (case-insensitively), +/// else the pair collapses, losing one, on the next export→import. +@Suite("URLRuleList") +struct URLRuleListTests { + private let us: InputSourceID = "com.apple.keylayout.US" + private let pinyin: InputSourceID = "com.apple.inputmethod.SCIM.ITABC" + + private func rule( + _ host: String, _ src: InputSourceID, id: UUID = UUID(), + action: RuleAction = .lock, type: URLMatchType = .domainSuffix + ) -> URLRule { + URLRule(id: id, hostPattern: host, lockedSourceID: src, action: action, matchType: type) + } + + // MARK: upserting + + @Test("editing a rule's binding keeps its position (order is priority)") + func editKeepsPosition() { + let a = rule("a.com", us), b = rule("b.com", us), c = rule("c.com", us) + let edited = URLRule(id: b.id, hostPattern: "b.com", lockedSourceID: pinyin, action: .switchOnce, matchType: .domain) + let out = URLRuleList.upserting(edited, into: [a, b, c]) + #expect(out.map(\.id) == [a.id, b.id, c.id]) // slot unchanged + #expect(out[1].lockedSourceID == pinyin) // binding updated in place + #expect(out[1].matchType == .domain) + #expect(out[1].action == .switchOnce) + } + + @Test("adding a unique pattern appends to the end") + func addUniqueAppends() { + let a = rule("a.com", us) + let out = URLRuleList.upserting(rule("b.com", pinyin), into: [a]) + #expect(out.map(\.hostPattern) == ["a.com", "b.com"]) + } + + @Test("adding a rule whose pattern already exists updates it in place, never duplicating") + func addDuplicatePatternUpdatesInPlace() { + let a = rule("a.com", us), b = rule("b.com", us) + // A brand-new id but an existing pattern → update the existing rule, keep slot. + let out = URLRuleList.upserting(rule("a.com", pinyin, action: .switchOnce), into: [a, b]) + #expect(out.count == 2) // no duplicate + #expect(out.map(\.hostPattern) == ["a.com", "b.com"]) // slot preserved + #expect(out[0].id == a.id) // existing id kept + #expect(out[0].lockedSourceID == pinyin) // binding adopted + #expect(out[0].action == .switchOnce) + } + + @Test("a same-pattern collision is case-insensitive (mirrors the URL-scheme host fallback)") + func collisionCaseInsensitive() { + let a = rule("GitHub.com", us) + let out = URLRuleList.upserting(rule("github.com", pinyin), into: [a]) + #expect(out.count == 1) // folded, not duplicated + #expect(out[0].id == a.id) + #expect(out[0].lockedSourceID == pinyin) + } + + @Test("editing a rule's pattern onto ANOTHER rule's collapses them — never two same-pattern rules") + func editOntoAnotherPatternCollapses() { + let a = rule("a.com", us), b = rule("b.com", pinyin) + // Edit a (by id) so its pattern becomes b's pattern. + let edited = URLRule(id: a.id, hostPattern: "b.com", lockedSourceID: us, action: .switchOnce, matchType: .domain) + let out = URLRuleList.upserting(edited, into: [a, b]) + #expect(out.count == 1) // the invariant: exactly one rule per pattern + #expect(out.filter { $0.hasSamePattern(as: "b.com") }.count == 1) + // The surviving rule sits in b's slot/id, carrying the edited binding. + #expect(out[0].id == b.id) + #expect(out[0].lockedSourceID == us) + #expect(out[0].matchType == .domain) + #expect(out[0].action == .switchOnce) + } + + @Test("upserting never produces two rules sharing a pattern, across paths") + func upsertKeepsPatternsUnique() { + var rules = [rule("a.com", us), rule("b.com", us), rule("c.com", us)] + rules = URLRuleList.upserting(rule("a.com", pinyin), into: rules) // re-add existing + rules = URLRuleList.upserting(rule("B.COM", pinyin), into: rules) // case-variant + let editC = URLRule(id: rules[2].id, hostPattern: "a.com", lockedSourceID: us, action: .lock, matchType: .domain) + rules = URLRuleList.upserting(editC, into: rules) // edit onto another + let patterns = rules.map { $0.hostPattern.lowercased() } + #expect(Set(patterns).count == patterns.count) // no duplicate pattern, any casing + } + + // MARK: reordered + + @Test("reordered re-sequences the live rules by id") + func reorderBasic() { + let a = rule("a.com", us), b = rule("b.com", us), c = rule("c.com", us) + let out = URLRuleList.reordered([a, b, c], by: [c, a, b]) + #expect(out?.map(\.id) == [c.id, a.id, b.id]) + } + + @Test("reordered returns nil for an unchanged order (a no-op)") + func reorderUnchangedIsNil() { + let a = rule("a.com", us), b = rule("b.com", us) + #expect(URLRuleList.reordered([a, b], by: [a, b]) == nil) + } + + @Test("reordered returns nil for a non-permutation (a stale drag-start snapshot)") + func reorderNonPermutationIsNil() { + let a = rule("a.com", us), b = rule("b.com", us), c = rule("c.com", us) + #expect(URLRuleList.reordered([a, b, c], by: [a, b]) == nil) // missing an id + #expect(URLRuleList.reordered([a, b], by: [a, b, c]) == nil) // extra/unknown id + } + + @Test("reordered relinks to the LIVE rule, discarding the snapshot's stale binding") + func reorderRelinksToLive() { + let a = rule("a.com", us), b = rule("b.com", us) + // A drag-start snapshot of b carrying a STALE binding; the live b was rebound + // (e.g. a `lockime://set-url-rule` landed mid-drag). The reorder must keep the + // live binding, not the snapshot's. + let staleB = URLRule(id: b.id, hostPattern: "b.com", lockedSourceID: us, action: .lock, matchType: .domainSuffix) + let liveB = URLRule(id: b.id, hostPattern: "b.com", lockedSourceID: pinyin, action: .switchOnce, matchType: .domain) + let out = URLRuleList.reordered([a, liveB], by: [staleB, a]) + #expect(out?.map(\.id) == [b.id, a.id]) + #expect(out?.first?.lockedSourceID == pinyin) // live binding, not the stale snapshot + #expect(out?.first?.action == .switchOnce) + #expect(out?.first?.matchType == .domain) + } +} diff --git a/docs/README/README.de.md b/docs/README/README.de.md index edb18a4..e7f37eb 100644 --- a/docs/README/README.de.md +++ b/docs/README/README.de.md @@ -51,6 +51,7 @@ Oder lade die zu deinem Mac passende `.dmg`-Datei (`-arm64` für Apple silicon, - **Sofortiges Wieder-Sperren** — schaltet die aktive Eingabequelle in dem Moment zurück, in dem du (oder eine andere App) sie wechselst, global oder pro App. - **Sperren oder wechseln** — Regeln pro App und pro URL können eine Eingabequelle *sperren* (bei jeder Abweichung erneut angewendet) oder einmalig dorthin *wechseln*, sobald du die App oder Seite aktivierst, und dich danach frei wählen lassen. +- **Flexibler URL-Abgleich** — Regeln pro URL (erweiterter Modus) passen über eine Domain und ihre Subdomains, eine exakte Domain, ein Domain-Schlüsselwort oder einen regulären Ausdruck über die vollständige URL und greifen in einer Prioritätsreihenfolge, die du per Ziehen anordnest — der erste Treffer gewinnt. - **Steuerung über die Menüleiste** — aktivieren/deaktivieren, die gesperrte Eingabequelle wechseln, die aktuelle Eingabequelle einsehen und die Auslösungen direkt in der Menüleiste verfolgen. - **Tastatur-Kurzbefehle** — konfigurierbare globale Kurzbefehle zum Ein- und Ausschalten der Sperre und zum Durchschalten der gesperrten Eingabequelle sowie App-spezifische Kurzbefehle, um die Regel der vordersten App durchzuschalten oder zu entfernen. - **Start bei Anmeldung** — startet automatisch beim Anmelden (standardmäßig aus). diff --git a/docs/README/README.es.md b/docs/README/README.es.md index 4b7daf1..0a961e3 100644 --- a/docs/README/README.es.md +++ b/docs/README/README.es.md @@ -55,6 +55,7 @@ En cualquier caso, la aplicación se mantiene actualizada mediante Sparkle. - **Rebloqueo instantáneo** — devuelve la fuente de entrada activa a la bloqueada en el momento en que tú (u otra aplicación) la cambias, globalmente o por aplicación. - **Bloquear o cambiar** — las reglas por aplicación y por URL pueden *bloquear* una fuente de entrada (se vuelve a aplicar cada vez que se desvía) o solo *cambiar* a ella una vez cuando activas la aplicación o la página, y luego dejarte cambiarla libremente. +- **Coincidencia de URL flexible** — las reglas por URL (modo mejorado) coinciden por un dominio y sus subdominios, por un dominio exacto, por una palabra clave de dominio o por una expresión regular sobre la URL completa, y se aplican en el orden de prioridad que tú arrastras para organizar — la primera coincidencia gana. - **Control desde la barra de menús** — activa/desactiva, cambia la fuente de entrada bloqueada, consulta la fuente actual y sigue el contador de activaciones desde la barra de menús. - **Atajos de teclado** — atajos globales configurables para activar/desactivar el bloqueo y recorrer la fuente de entrada bloqueada, además de atajos por aplicación para recorrer o eliminar la regla de la aplicación en primer plano. - **Arranque al iniciar sesión** — se inicia automáticamente al iniciar sesión (desactivado por defecto). diff --git a/docs/README/README.fr.md b/docs/README/README.fr.md index 26d0b7f..fcb35ce 100644 --- a/docs/README/README.fr.md +++ b/docs/README/README.fr.md @@ -51,6 +51,7 @@ Ou téléchargez le `.dmg` correspondant à votre Mac (`-arm64` pour Apple silic - **Reverrouillage instantané** — rebascule la source de saisie active dès que vous (ou une autre application) la changez, globalement ou par application. - **Verrouiller ou basculer** — les règles par application et par URL peuvent *verrouiller* une source de saisie (réappliquée dès qu'elle dévie) ou simplement y *basculer* une fois lorsque vous activez l'application ou la page, puis vous laisser libre de la changer. +- **Correspondance d'URL flexible** — les règles par URL (mode renforcé) correspondent par un domaine et ses sous-domaines, un domaine exact, un mot-clé de domaine, ou une expression régulière sur l'URL entière, et s'appliquent dans un ordre de priorité que vous organisez par glisser-déposer — la première correspondance l'emporte. - **Contrôle depuis la barre de menus** — activer/désactiver, changer la source de saisie verrouillée, voir la source actuelle et suivre le nombre d'activations depuis la barre de menus. - **Raccourcis clavier** — des raccourcis globaux configurables pour activer/désactiver le verrouillage et faire défiler la source de saisie verrouillée, ainsi que des raccourcis par application pour faire défiler ou supprimer la règle de l’application au premier plan. - **Lancement à la connexion** — démarre automatiquement à l'ouverture de session (désactivé par défaut). diff --git a/docs/README/README.ja.md b/docs/README/README.ja.md index f445313..1ad623a 100644 --- a/docs/README/README.ja.md +++ b/docs/README/README.ja.md @@ -51,6 +51,7 @@ brew install --cask oomol-lab/tap/lockime - **即時再ロック**——あなた(または他のアプリ)が入力ソースを切り替えた瞬間に、ロック中のものへ切り戻します。グローバルにも、アプリごとにも。 - **ロックまたは切り替え**——アプリごと・URL ごとのルールは、入力ソースを*ロック*(ずれるたびに切り戻す)することも、アプリやページをアクティブにしたときに一度だけ*切り替え*て、その後は自由に変更できるようにすることもできます。 +- **柔軟な URL マッチング**——URL ごとのルール(拡張モード)は、ドメインとそのサブドメイン、完全一致のドメイン、ドメインのキーワード、または URL 全体に対する正規表現でマッチし、ドラッグして並べ替える優先順位順に適用されます——最初にマッチしたものが優先されます。 - **メニューバーからの操作**——メニューバーから有効化/無効化、ロック中の入力ソースの切り替え、現在の入力ソースの確認、作動回数の追跡。 - **キーボードショートカット**——設定可能なグローバルショートカットでロックのオン/オフやロック中の入力ソースの切り替え(前 / 次)ができ、さらに最前面のアプリのルールを切り替えたり解除したりするアプリごとのショートカットも利用できます。 - **ログイン時に起動**——ログイン時に自動的に起動(デフォルトはオフ)。 diff --git a/docs/README/README.pt.md b/docs/README/README.pt.md index b40f1af..4f124db 100644 --- a/docs/README/README.pt.md +++ b/docs/README/README.pt.md @@ -55,6 +55,7 @@ De qualquer forma, o app se mantém atualizado sozinho via Sparkle. - **Rebloqueio instantâneo** — devolve a fonte de entrada ativa para a bloqueada no momento em que você (ou outro app) a troca, globalmente ou por app. - **Bloquear ou alternar** — as regras por app e por URL podem *bloquear* uma fonte de entrada (reaplicada sempre que ela desvia) ou apenas *alternar* para ela uma vez quando você foca o app ou a página, deixando você livre para mudá-la depois. +- **Correspondência flexível de URL** — as regras por URL (modo aprimorado) correspondem por um domínio e seus subdomínios, por um domínio exato, por uma palavra-chave de domínio, ou por uma expressão regular sobre a URL inteira, e se aplicam em uma ordem de prioridade que você arrasta para organizar — a primeira correspondência vence. - **Controle pela barra de menus** — ative/desative, troque a fonte de entrada bloqueada, veja a fonte atual e acompanhe o contador de ativações pela barra de menus. - **Atalhos de teclado** — atalhos globais configuráveis para ativar/desativar o bloqueio e percorrer a fonte de entrada bloqueada, além de atalhos por app para percorrer ou remover a regra do app em primeiro plano. - **Iniciar no login** — inicia automaticamente quando você faz login (desativado por padrão). diff --git a/docs/README/README.ru.md b/docs/README/README.ru.md index b454cc4..b866e9a 100644 --- a/docs/README/README.ru.md +++ b/docs/README/README.ru.md @@ -51,6 +51,7 @@ brew install --cask oomol-lab/tap/lockime - **Мгновенная повторная блокировка** — возвращает активный источник ввода к заблокированному в тот же момент, когда вы (или другое приложение) его меняете, — глобально или для каждого приложения. - **Блокировать или переключать** — правила для приложений и для URL могут *блокировать* источник ввода (повторно применяя его при любом отклонении) или один раз *переключать* на него при открытии приложения или страницы, а затем не мешать вам его менять. +- **Гибкое сопоставление URL** — правила для URL (расширенный режим) сопоставляются по домену и его поддоменам, по точному домену, по ключевому слову домена или по регулярному выражению над всем URL, и применяются в порядке приоритета, который вы задаёте перетаскиванием, — побеждает первое совпадение. - **Управление из строки меню** — включение/выключение, смена заблокированного источника ввода, просмотр текущего источника и счётчик срабатываний прямо в строке меню. - **Сочетания клавиш** — настраиваемые глобальные сочетания для включения/выключения блокировки и перебора заблокированного источника ввода, а также сочетания для отдельных приложений, позволяющие переключать или удалять правило активного приложения. - **Запуск при входе в систему** — стартует автоматически при входе (по умолчанию выключено). diff --git a/docs/README/README.zh-CN.md b/docs/README/README.zh-CN.md index 92ab8b5..7702844 100644 --- a/docs/README/README.zh-CN.md +++ b/docs/README/README.zh-CN.md @@ -54,6 +54,7 @@ Mac 匹配的 `.dmg`(Apple silicon 选 `-arm64`,Intel 选 `-x86_64`)。 - **即时重新锁定**——每当你(或其他应用)切换输入源时,立即切回被锁定的那个,可全局或按应用生效。 - **锁定或切换**——按应用和按 URL 的规则既可以*锁定*某个输入源(一旦偏离就重新切回),也可以在你切到该应用或页面时只*切换*一次,之后任你自由更改。 +- **灵活的 URL 匹配**——按 URL 规则(增强模式)可以按某个域名及其子域名、某个精确域名、某个域名关键词,或针对完整 URL 的正则表达式来匹配,并按你拖动排列出的优先级顺序生效——第一个命中者胜出。 - **菜单栏控制**——在菜单栏激活/停用、切换被锁定的输入源、查看当前输入源、追踪激活次数。 - **键盘快捷键**——可配置的全局快捷键用于开关锁定、切换被锁定的输入源(上一个 / 下一个),以及针对当前最前台应用的快捷键,用于切换或移除该应用的规则。 - **登录时启动**——登录后自动启动(默认关闭)。 diff --git a/docs/README/README.zh-TW.md b/docs/README/README.zh-TW.md index e179149..28a978f 100644 --- a/docs/README/README.zh-TW.md +++ b/docs/README/README.zh-TW.md @@ -51,6 +51,7 @@ brew install --cask oomol-lab/tap/lockime - **即時重新鎖定**——每當你(或其他應用程式)切換輸入法時,立即切回被鎖定的那個,可全域或依應用程式生效。 - **鎖定或切換**——各應用程式與各 URL 的規則既可*鎖定*某個輸入法(一旦偏離就重新切回),也可以在你切到該應用程式或頁面時只*切換*一次,之後任你自由變更。 +- **彈性的 URL 比對**——依 URL 規則(增強模式)可依一個網域及其子網域、一個確切網域、一個網域關鍵字,或一個涵蓋整個 URL 的正規表示式來比對,並依你拖曳排列的優先序套用——第一個比對到的勝出。 - **選單列控制**——在選單列啟用/停用、切換被鎖定的輸入法、檢視目前輸入法、追蹤觸發次數。 - **鍵盤快速鍵**——可自訂的全域快速鍵用於開關鎖定、切換被鎖定的輸入法(上一個 / 下一個),以及針對目前最前台應用程式的快速鍵,用於切換或移除該應用程式的規則。 - **登入時啟動**——登入後自動啟動(預設關閉)。 diff --git a/docs/URL-Scheme-API/README.de.md b/docs/URL-Scheme-API/README.de.md index dc83cdb..bd58100 100644 --- a/docs/URL-Scheme-API/README.de.md +++ b/docs/URL-Scheme-API/README.de.md @@ -134,10 +134,28 @@ Regeln pro URL erfordern den optionalen, über Accessibility freigeschalteten | 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. | +| `set-url-rule` | `host` *(Alias `pattern`, erf.)*, `source` \| `source-name` *(erf.)*, `match-type` = `domain-suffix` \| `domain` \| `domain-keyword` \| `url-regex` *(Standard `domain-suffix`)*, `action` = `lock` \| `switch` *(Standard `lock`)*, `id` *(optionale UUID)* | Eine Regel pro URL erstellen oder ersetzen. Wie das Muster verglichen wird, hängt von `match-type` ab (siehe [unten](#match-types)). Ohne `id` wird eine bestehende Regel für dasselbe Muster 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. | +#### Match types + +`match-type` entscheidet, wie das Muster einer Regel mit der aktuellen URL des +Browsers verglichen wird. Regeln werden **von oben nach unten ausgewertet, und der +erste Treffer gewinnt**, sodass ihre Reihenfolge ihre Priorität ist (zum Umordnen +unter **Einstellungen ▸ URL-Regeln** ziehen). + +| `match-type` | Pattern is… | Matches | +|---|---|---| +| `domain-suffix` *(default)* | ein Host, z. B. `github.com` | den Host **und alle seine Subdomains** (`github.com`, `gist.github.com`). Ein führendes `*.` wird toleriert. | +| `domain` | ein Host, z. B. `github.com` | **nur genau diesen Host**, niemals eine Subdomain. | +| `domain-keyword` | eine Teilzeichenkette, z. B. `google` | jeden Host, der sie **enthält** (`google.com`, `mail.google.com`, `googleapis.com`). | +| `url-regex` | ein regulärer Ausdruck | die **gesamte URL** (Schema · Host · Pfad · Query · Fragment) — ohne Beachtung der Groß-/Kleinschreibung und nicht verankert. Der einzige Typ, der Seiten einer Website nach Pfad oder Query unterscheiden kann. Ein nicht kompilierbares Muster wird mit `invalid_parameter` abgelehnt. | + +`match-type` akzeptiert auch die Aliasse `suffix`, `keyword` und `regex`. Bei einer +`url-regex`-Regel enthält das Muster meist Zeichen (`?`, `&`, `/`, `\`), die in der +URL prozentkodiert werden müssen. + ### App | Command | Parameters | Effect | @@ -161,7 +179,7 @@ Abfragebefehle geben eine JSON-Nutzlast über den `x-success`-Rückruf zurück | `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-url-rules` *(alias `url-rules`)* | Array von `{ "id", "host", "action", "matchType", "source" }`, in Prioritätsreihenfolge (der erste Treffer gewinnt). | | `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" }`. | @@ -204,7 +222,7 @@ in deine App und in Protokolle, daher wird er niemals lokalisiert. | `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). | +| `invalid_parameter` | Ein Parameterwert liegt außerhalb des gültigen Bereichs (ungültiges `mode`, `action`, `match-type`, `direction`, `code`, ein nicht kompilierbares `url-regex`-Muster oder eine fehlerhafte 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. | @@ -221,6 +239,9 @@ 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-url-rule?host=github.com&source=com.apple.keylayout.ABC&match-type=domain" +# url-regex passt auf die gesamte URL — kodiere das Muster prozentual (hier: github\.com/.*/pull) +open "lockime://set-url-rule?pattern=github%5C.com%2F.%2A%2Fpull&source=com.apple.keylayout.ABC&match-type=url-regex" open "lockime://set-launch-at-login?enabled=on" ``` diff --git a/docs/URL-Scheme-API/README.es.md b/docs/URL-Scheme-API/README.es.md index c39afb3..caf49ff 100644 --- a/docs/URL-Scheme-API/README.es.md +++ b/docs/URL-Scheme-API/README.es.md @@ -126,10 +126,28 @@ Las reglas por URL requieren el **modo mejorado** opcional protegido por Accessi | 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. | +| `set-url-rule` | `host` *(alias `pattern`, req)*, `source` \| `source-name` *(req)*, `match-type` = `domain-suffix` \| `domain` \| `domain-keyword` \| `url-regex` *(default `domain-suffix`)*, `action` = `lock` \| `switch` *(default `lock`)*, `id` *(optional UUID)* | Crea o reemplaza una regla por URL. La forma en que se compara el patrón depende de `match-type` (ver [más abajo](#match-types)). Sin `id`, se actualiza una regla existente del mismo patrón 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. | +#### Match types + +`match-type` decide cómo se compara el patrón de una regla con la URL actual +del navegador. Las reglas se evalúan **de arriba abajo y la primera coincidencia +gana**, así que su orden es su prioridad (arrástralas para reordenarlas en +**Ajustes ▸ Reglas por URL**). + +| `match-type` | Pattern is… | Matches | +|---|---|---| +| `domain-suffix` *(default)* | un host, p. ej. `github.com` | el host **y todos sus subdominios** (`github.com`, `gist.github.com`). Se tolera un `*.` inicial. | +| `domain` | un host, p. ej. `github.com` | **solo ese host exacto**, nunca un subdominio. | +| `domain-keyword` | una subcadena, p. ej. `google` | cualquier host que **la contenga** (`google.com`, `mail.google.com`, `googleapis.com`). | +| `url-regex` | una expresión regular | la **URL completa** (esquema · host · ruta · consulta · fragmento) — sin distinguir mayúsculas y minúsculas y sin anclar. El único tipo capaz de distinguir páginas de un mismo sitio por ruta o consulta. Un patrón que no se puede compilar se rechaza con `invalid_parameter`. | + +`match-type` también acepta los alias `suffix`, `keyword` y `regex`. En una regla +`url-regex` el patrón suele contener caracteres (`?`, `&`, `/`, `\`) +que deben codificarse con percent-encode en la URL. + ### App | Command | Parameters | Effect | @@ -152,7 +170,7 @@ Los comandos de consulta devuelven una carga útil JSON a través del callback ` | `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-url-rules` *(alias `url-rules`)* | Array de `{ "id", "host", "action", "matchType", "source" }`, en orden de prioridad (la primera coincidencia gana). | | `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" }`. | @@ -194,7 +212,7 @@ localiza. | `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). | +| `invalid_parameter` | El valor de un parámetro está fuera de rango (`mode`, `action`, `match-type`, `direction` o `code` incorrecto, un patrón `url-regex` que no se puede compilar, o un UUID mal formado). | | `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. | @@ -211,6 +229,9 @@ 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-url-rule?host=github.com&source=com.apple.keylayout.ABC&match-type=domain" +# url-regex coincide con la URL completa — codifica el patrón con percent-encode (aquí: github\.com/.*/pull) +open "lockime://set-url-rule?pattern=github%5C.com%2F.%2A%2Fpull&source=com.apple.keylayout.ABC&match-type=url-regex" open "lockime://set-launch-at-login?enabled=on" ``` diff --git a/docs/URL-Scheme-API/README.fr.md b/docs/URL-Scheme-API/README.fr.md index a952a3a..c9ab4d8 100644 --- a/docs/URL-Scheme-API/README.fr.md +++ b/docs/URL-Scheme-API/README.fr.md @@ -127,10 +127,28 @@ Les règles par URL nécessitent le **mode renforcé** optionnel, soumis à l'au | 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. | +| `set-url-rule` | `host` *(alias `pattern`, req)*, `source` \| `source-name` *(req)*, `match-type` = `domain-suffix` \| `domain` \| `domain-keyword` \| `url-regex` *(default `domain-suffix`)*, `action` = `lock` \| `switch` *(default `lock`)*, `id` *(optional UUID)* | Créer ou remplacer une règle par URL. La manière dont le motif est mis en correspondance dépend de `match-type` (voir [ci-dessous](#match-types)). Sans `id`, une règle existante pour le même motif 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. | +#### Match types + +`match-type` détermine comment le motif d'une règle est comparé à l'URL actuelle +du navigateur. Les règles sont évaluées **de haut en bas et la première +correspondance l'emporte** ; leur ordre est donc leur priorité (réorganisez-les +par glisser-déposer dans **Réglages ▸ Règles par URL**). + +| `match-type` | Pattern is… | Matches | +|---|---|---| +| `domain-suffix` *(default)* | un hôte, p. ex. `github.com` | l'hôte **et tous ses sous-domaines** (`github.com`, `gist.github.com`). Un `*.` en tête est toléré. | +| `domain` | un hôte, p. ex. `github.com` | **uniquement cet hôte exact**, jamais un sous-domaine. | +| `domain-keyword` | une sous-chaîne, p. ex. `google` | tout hôte qui la **contient** (`google.com`, `mail.google.com`, `googleapis.com`). | +| `url-regex` | une expression régulière | l'**URL entière** (schéma · hôte · chemin · requête · fragment) — insensible à la casse et non ancrée. Le seul type capable de distinguer les pages d'un même site par le chemin ou la requête. Un motif non compilable est rejeté avec `invalid_parameter`. | + +`match-type` accepte aussi les alias `suffix`, `keyword` et `regex`. Pour une +règle `url-regex`, le motif contient généralement des caractères (`?`, `&`, `/`, `\`) +qui doivent être encodés en pourcentage dans l'URL. + ### App | Command | Parameters | Effect | @@ -153,7 +171,7 @@ Les commandes de requête retournent une charge utile JSON via le rappel `x-succ | `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-url-rules` *(alias `url-rules`)* | Tableau de `{ "id", "host", "action", "matchType", "source" }`, par ordre de priorité (la première correspondance l'emporte). | | `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" }`. | @@ -195,7 +213,7 @@ vers les journaux, il n'est donc jamais localisé. | `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). | +| `invalid_parameter` | Une valeur de paramètre est hors plage (mauvais `mode`, `action`, `match-type`, `direction`, `code`, un motif `url-regex` non compilable, ou un UUID mal formé). | | `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. | @@ -212,6 +230,9 @@ 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-url-rule?host=github.com&source=com.apple.keylayout.ABC&match-type=domain" +# url-regex correspond à l'URL entière — encodez le motif en pourcentage (ici : github\.com/.*/pull) +open "lockime://set-url-rule?pattern=github%5C.com%2F.%2A%2Fpull&source=com.apple.keylayout.ABC&match-type=url-regex" open "lockime://set-launch-at-login?enabled=on" ``` diff --git a/docs/URL-Scheme-API/README.ja.md b/docs/URL-Scheme-API/README.ja.md index a737db9..a230ea9 100644 --- a/docs/URL-Scheme-API/README.ja.md +++ b/docs/URL-Scheme-API/README.ja.md @@ -126,10 +126,28 @@ 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 の既存ルールが複製されずに更新されます。 | +| `set-url-rule` | `host` *(alias `pattern`, req)*, `source` \| `source-name` *(req)*, `match-type` = `domain-suffix` \| `domain` \| `domain-keyword` \| `url-regex` *(default `domain-suffix`)*, `action` = `lock` \| `switch` *(default `lock`)*, `id` *(optional UUID)* | URL ごとのルールを作成または置き換えます。パターンがどのようにマッチされるかは `match-type` によって決まります([下記](#match-types)を参照)。`id` がなければ、同じパターンの既存ルールが複製されずに更新されます。 | | `remove-url-rule` | `id` *(UUID)* \| `host` | URL ルールを、その `id`(`list-url-rules` から)または `host` で削除します。 | | `clear-url-rules` | — | **すべて**の URL ごとのルールを削除します。 | +#### Match types + +`match-type` は、ルールのパターンをブラウザの現在の URL とどのように比較するかを +決定します。ルールは**上から下へ評価され、最初にマッチしたものが優先**されます。 +そのため並び順がそのまま優先順位になります(**設定 ▸ URL Rules** でドラッグして +並べ替えます)。 + +| `match-type` | Pattern is… | Matches | +|---|---|---| +| `domain-suffix` *(default)* | host。例:`github.com` | その host **およびそのすべてのサブドメイン**(`github.com`、`gist.github.com`)。先頭の `*.` は許容されます。 | +| `domain` | host。例:`github.com` | **その host に完全一致するもののみ**で、サブドメインにはマッチしません。 | +| `domain-keyword` | 部分文字列。例:`google` | それを**含む**任意の host(`google.com`、`mail.google.com`、`googleapis.com`)。 | +| `url-regex` | 正規表現 | **URL 全体**(scheme · host · path · query · fragment)——大文字小文字を区別せず、アンカーなし。パスやクエリによって同一サイトのページを区別できる唯一のタイプです。コンパイルできないパターンは `invalid_parameter` で拒否されます。 | + +`match-type` はエイリアス `suffix`、`keyword`、`regex` も受け付けます。`url-regex` +ルールでは、パターンに通常 URL 内でパーセントエンコードが必要な文字(`?`、`&`、`/`、 +`\`)が含まれます。 + ### App | Command | Parameters | Effect | @@ -153,7 +171,7 @@ LockIME は設計上、**UI を開くコマンドを一切公開していませ | `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-url-rules` *(alias `url-rules`)* | `{ "id", "host", "action", "matchType", "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" }`。 | @@ -194,7 +212,7 @@ LockIME は設計上、**UI を開くコマンドを一切公開していませ | `no_command` | コマンドトークンが指定されませんでした。 | | `unknown_command` | コマンドトークンが認識されませんでした。 | | `missing_parameter` | 必須パラメータが存在しません。 | -| `invalid_parameter` | パラメータ値が範囲外です(不正な `mode`、`action`、`direction`、`code`、または UUID)。 | +| `invalid_parameter` | パラメータ値が範囲外です(不正な `mode`、`action`、`match-type`、`direction`、`code`、コンパイルできない `url-regex` パターン、または不正な形式の UUID)。 | | `unknown_source` | `id`/`name` がインストール済みで選択可能なソースのいずれにもマッチしません。 | | `no_input_sources` | 選択可能な入力ソースが一つもインストールされていません。 | | `rule_not_found` | 対象のアプリ/URL ルールが存在しません。 | @@ -211,6 +229,9 @@ 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-url-rule?host=github.com&source=com.apple.keylayout.ABC&match-type=domain" +# url-regex は URL 全体にマッチします——パターンをパーセントエンコードしてください(ここでは:github\.com/.*/pull) +open "lockime://set-url-rule?pattern=github%5C.com%2F.%2A%2Fpull&source=com.apple.keylayout.ABC&match-type=url-regex" open "lockime://set-launch-at-login?enabled=on" ``` diff --git a/docs/URL-Scheme-API/README.md b/docs/URL-Scheme-API/README.md index 3c68a31..b08482f 100644 --- a/docs/URL-Scheme-API/README.md +++ b/docs/URL-Scheme-API/README.md @@ -126,10 +126,27 @@ 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. | +| `set-url-rule` | `host` *(alias `pattern`, req)*, `source` \| `source-name` *(req)*, `match-type` = `domain-suffix` \| `domain` \| `domain-keyword` \| `url-regex` *(default `domain-suffix`)*, `action` = `lock` \| `switch` *(default `lock`)*, `id` *(optional UUID)* | Create or replace a per-URL rule. How the pattern is matched depends on `match-type` (see [below](#match-types)). Without `id`, an existing rule for the same pattern 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. | +#### Match types + +`match-type` decides how a rule's pattern is compared to the browser's current +URL. Rules are evaluated **top to bottom and the first match wins**, so their +order is their priority (drag to reorder in **Settings ▸ URL Rules**). + +| `match-type` | Pattern is… | Matches | +|---|---|---| +| `domain-suffix` *(default)* | a host, e.g. `github.com` | the host **and all its subdomains** (`github.com`, `gist.github.com`). A leading `*.` is tolerated. | +| `domain` | a host, e.g. `github.com` | **only that exact host**, never a subdomain. | +| `domain-keyword` | a substring, e.g. `google` | any host that **contains** it (`google.com`, `mail.google.com`, `googleapis.com`). | +| `url-regex` | a regular expression | the **whole URL** (scheme · host · path · query · fragment) — case-insensitive and unanchored. The only type that can tell pages of one site apart by path or query. An uncompilable pattern is rejected with `invalid_parameter`. | + +`match-type` also accepts the aliases `suffix`, `keyword`, and `regex`. For a +`url-regex` rule the pattern usually contains characters (`?`, `&`, `/`, `\`) +that must be percent-encoded in the URL. + ### App | Command | Parameters | Effect | @@ -152,7 +169,7 @@ Query commands return a JSON payload through the `x-success` callback (see | `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-url-rules` *(alias `url-rules`)* | Array of `{ "id", "host", "action", "matchType", "source" }`, in priority order (first match wins). | | `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" }`. | @@ -194,7 +211,7 @@ localized. | `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). | +| `invalid_parameter` | A parameter value is out of range (bad `mode`, `action`, `match-type`, `direction`, `code`, an uncompilable `url-regex` pattern, or a malformed 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. | @@ -211,6 +228,9 @@ 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-url-rule?host=github.com&source=com.apple.keylayout.ABC&match-type=domain" +# url-regex matches the whole URL — percent-encode the pattern (here: github\.com/.*/pull) +open "lockime://set-url-rule?pattern=github%5C.com%2F.%2A%2Fpull&source=com.apple.keylayout.ABC&match-type=url-regex" open "lockime://set-launch-at-login?enabled=on" ``` diff --git a/docs/URL-Scheme-API/README.pt.md b/docs/URL-Scheme-API/README.pt.md index aeb5e79..4774df7 100644 --- a/docs/URL-Scheme-API/README.pt.md +++ b/docs/URL-Scheme-API/README.pt.md @@ -132,10 +132,28 @@ 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. | +| `set-url-rule` | `host` *(apelido `pattern`, obrigatório)*, `source` \| `source-name` *(obrigatório)*, `match-type` = `domain-suffix` \| `domain` \| `domain-keyword` \| `url-regex` *(padrão `domain-suffix`)*, `action` = `lock` \| `switch` *(padrão `lock`)*, `id` *(UUID opcional)* | Cria ou substitui uma regra por URL. A forma como o padrão é correspondido depende de `match-type` (veja [abaixo](#match-types)). Sem `id`, uma regra existente para o mesmo padrão é 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. | +#### Match types + +`match-type` decide como o padrão de uma regra é comparado com a URL atual do +navegador. As regras são avaliadas **de cima para baixo e a primeira +correspondência vence**, de modo que sua ordem é sua prioridade (arraste para +reordenar em **Ajustes ▸ Regras por URL**). + +| `match-type` | Pattern is… | Matches | +|---|---|---| +| `domain-suffix` *(default)* | um host, p. ex. `github.com` | o host **e todos os seus subdomínios** (`github.com`, `gist.github.com`). Um `*.` no início é tolerado. | +| `domain` | um host, p. ex. `github.com` | **apenas esse host exato**, nunca um subdomínio. | +| `domain-keyword` | uma substring, p. ex. `google` | qualquer host que a **contenha** (`google.com`, `mail.google.com`, `googleapis.com`). | +| `url-regex` | uma expressão regular | a **URL inteira** (esquema · host · caminho · consulta · fragmento) — sem diferenciar maiúsculas de minúsculas e sem âncoras. O único tipo que consegue distinguir páginas de um mesmo site por caminho ou consulta. Um padrão que não compila é rejeitado com `invalid_parameter`. | + +`match-type` também aceita os apelidos `suffix`, `keyword` e `regex`. Para uma +regra `url-regex`, o padrão geralmente contém caracteres (`?`, `&`, `/`, `\`) +que devem ser codificados em percent-encode na URL. + ### App | Command | Parameters | Effect | @@ -159,7 +177,7 @@ Os comandos de consulta retornam um payload JSON através do callback | `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-url-rules` *(alias `url-rules`)* | Array de `{ "id", "host", "action", "matchType", "source" }`, em ordem de prioridade (a primeira correspondência vence). | | `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" }`. | @@ -201,7 +219,7 @@ para os logs, portanto nunca é localizado. | `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). | +| `invalid_parameter` | O valor de um parâmetro está fora do intervalo (`mode`, `action`, `match-type`, `direction`, `code` inválido, um padrão `url-regex` que não compila, ou um UUID malformado). | | `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. | @@ -218,6 +236,9 @@ 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-url-rule?host=github.com&source=com.apple.keylayout.ABC&match-type=domain" +# url-regex corresponde à URL inteira — codifique o padrão em percent-encode (aqui: github\.com/.*/pull) +open "lockime://set-url-rule?pattern=github%5C.com%2F.%2A%2Fpull&source=com.apple.keylayout.ABC&match-type=url-regex" open "lockime://set-launch-at-login?enabled=on" ``` diff --git a/docs/URL-Scheme-API/README.ru.md b/docs/URL-Scheme-API/README.ru.md index 1f98b60..ca64646 100644 --- a/docs/URL-Scheme-API/README.ru.md +++ b/docs/URL-Scheme-API/README.ru.md @@ -128,10 +128,28 @@ myapp://got-status?result=%7B%22locked%22%3Atrue%2C…%7D | 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` существующее правило для того же хоста обновляется, а не дублируется. | +| `set-url-rule` | `host` *(alias `pattern`, req)*, `source` \| `source-name` *(req)*, `match-type` = `domain-suffix` \| `domain` \| `domain-keyword` \| `url-regex` *(default `domain-suffix`)*, `action` = `lock` \| `switch` *(default `lock`)*, `id` *(optional UUID)* | Создать или заменить правило для URL. Способ сопоставления шаблона зависит от `match-type` (см. [ниже](#match-types)). Без `id` существующее правило для того же шаблона обновляется, а не дублируется. | | `remove-url-rule` | `id` *(UUID)* \| `host` | Удалить правило для URL по его `id` (из `list-url-rules`) или по `host`. | | `clear-url-rules` | — | Удалить **все** правила для URL. | +#### Match types + +`match-type` определяет, как шаблон правила сравнивается с текущим URL в +браузере. Правила обрабатываются **сверху вниз, и побеждает первое совпадение**, +поэтому их порядок задаёт их приоритет (перетаскивайте для изменения порядка в +**Настройки ▸ Правила для URL**). + +| `match-type` | Pattern is… | Matches | +|---|---|---| +| `domain-suffix` *(default)* | хост, например `github.com` | этот хост **и все его поддомены** (`github.com`, `gist.github.com`). Ведущий `*.` допускается. | +| `domain` | хост, например `github.com` | **только этот точный хост**, без поддоменов. | +| `domain-keyword` | подстрока, например `google` | любой хост, который её **содержит** (`google.com`, `mail.google.com`, `googleapis.com`). | +| `url-regex` | регулярное выражение | **весь URL** (схема · хост · путь · запрос · фрагмент) — без учёта регистра и без привязки к началу/концу. Единственный тип, способный различать страницы одного сайта по пути или запросу. Шаблон, который не компилируется, отклоняется с ошибкой `invalid_parameter`. | + +`match-type` также принимает псевдонимы `suffix`, `keyword` и `regex`. В правиле +`url-regex` шаблон обычно содержит символы (`?`, `&`, `/`, `\`), которые нужно +кодировать процентами в URL. + ### App | Command | Parameters | Effect | @@ -155,7 +173,7 @@ LockIME намеренно не предоставляет **никаких ко | `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-url-rules` *(alias `url-rules`)* | Массив `{ "id", "host", "action", "matchType", "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" }`. | @@ -197,7 +215,7 @@ LockIME намеренно не предоставляет **никаких ко | `no_command` | Токен команды не был передан. | | `unknown_command` | Токен команды не распознан. | | `missing_parameter` | Обязательный параметр отсутствует. | -| `invalid_parameter` | Значение параметра вне допустимого диапазона (неверный `mode`, `action`, `direction`, `code` или UUID). | +| `invalid_parameter` | Значение параметра вне допустимого диапазона (неверный `mode`, `action`, `match-type`, `direction`, `code`, не компилирующийся шаблон `url-regex` или некорректный UUID). | | `unknown_source` | `id`/`name` не совпадает ни с одним установленным выбираемым источником. | | `no_input_sources` | Не установлено ни одного выбираемого источника ввода. | | `rule_not_found` | Указанное правило для приложения/URL не существует. | @@ -214,6 +232,9 @@ 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-url-rule?host=github.com&source=com.apple.keylayout.ABC&match-type=domain" +# url-regex сопоставляется со всем URL — закодируйте шаблон процентами (здесь: github\.com/.*/pull) +open "lockime://set-url-rule?pattern=github%5C.com%2F.%2A%2Fpull&source=com.apple.keylayout.ABC&match-type=url-regex" open "lockime://set-launch-at-login?enabled=on" ``` diff --git a/docs/URL-Scheme-API/README.zh-CN.md b/docs/URL-Scheme-API/README.zh-CN.md index f816167..df897de 100644 --- a/docs/URL-Scheme-API/README.zh-CN.md +++ b/docs/URL-Scheme-API/README.zh-CN.md @@ -122,10 +122,26 @@ myapp://got-status?result=%7B%22locked%22%3Atrue%2C…%7D | 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 的现有规则,而不是新建重复项。 | +| `set-url-rule` | `host` *(alias `pattern`, req)*, `source` \| `source-name` *(req)*, `match-type` = `domain-suffix` \| `domain` \| `domain-keyword` \| `url-regex` *(default `domain-suffix`)*, `action` = `lock` \| `switch` *(default `lock`)*, `id` *(optional UUID)* | 创建或替换一条按 URL 规则。模式如何匹配取决于 `match-type`(参见[下文](#match-types))。若不带 `id`,则更新同一模式的现有规则,而不是新建重复项。 | | `remove-url-rule` | `id` *(UUID)* \| `host` | 通过 `id`(来自 `list-url-rules`)或 `host` 删除一条 URL 规则。 | | `clear-url-rules` | — | 移除**所有**按 URL 规则。 | +#### Match types + +`match-type` 决定一条规则的模式如何与浏览器当前的 URL 进行比较。规则**从上到下 +逐条求值,第一个命中者胜出**,所以它们的顺序就是优先级(在 **Settings ▸ URL +Rules** 中拖动以重排)。 + +| `match-type` | Pattern is… | Matches | +|---|---|---| +| `domain-suffix` *(default)* | 一个 host,如 `github.com` | 匹配该 host **及其所有子域名**(`github.com`、`gist.github.com`)。开头的 `*.` 会被容许。 | +| `domain` | 一个 host,如 `github.com` | **仅匹配该精确 host**,绝不匹配子域名。 | +| `domain-keyword` | 一个子串,如 `google` | 匹配任何**包含**它的 host(`google.com`、`mail.google.com`、`googleapis.com`)。 | +| `url-regex` | 一个正则表达式 | 匹配**整个 URL**(scheme · host · path · query · fragment)——不区分大小写且不锚定。这是唯一能按 path 或 query 区分同一站点不同页面的类型。无法编译的模式会以 `invalid_parameter` 被拒绝。 | + +`match-type` 还接受别名 `suffix`、`keyword` 和 `regex`。对于一条 `url-regex` +规则,模式通常会包含必须在 URL 中进行百分号编码的字符(`?`、`&`、`/`、`\`)。 + ### App | Command | Parameters | Effect | @@ -148,7 +164,7 @@ LockIME 刻意**不提供任何打开其 UI 的命令**(设置、关于、更 | `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-url-rules` *(alias `url-rules`)* | `{ "id", "host", "action", "matchType", "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" }`。 | @@ -189,7 +205,7 @@ LockIME 刻意**不提供任何打开其 UI 的命令**(设置、关于、更 | `no_command` | 未提供命令 token。 | | `unknown_command` | 命令 token 无法识别。 | | `missing_parameter` | 缺少某个必需参数。 | -| `invalid_parameter` | 某个参数值超出范围(错误的 `mode`、`action`、`direction`、`code` 或 UUID)。 | +| `invalid_parameter` | 某个参数值超出范围(错误的 `mode`、`action`、`match-type`、`direction`、`code`,一个无法编译的 `url-regex` 模式,或一个格式错误的 UUID)。 | | `unknown_source` | `id`/`name` 没有匹配到任何已安装的可选用输入源。 | | `no_input_sources` | 没有安装任何可选用的输入源。 | | `rule_not_found` | 目标的应用/URL 规则不存在。 | @@ -206,6 +222,9 @@ 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-url-rule?host=github.com&source=com.apple.keylayout.ABC&match-type=domain" +# url-regex 匹配整个 URL——请对模式进行百分号编码(此处为:github\.com/.*/pull) +open "lockime://set-url-rule?pattern=github%5C.com%2F.%2A%2Fpull&source=com.apple.keylayout.ABC&match-type=url-regex" open "lockime://set-launch-at-login?enabled=on" ``` diff --git a/docs/URL-Scheme-API/README.zh-TW.md b/docs/URL-Scheme-API/README.zh-TW.md index e6b9ce0..d7c7fd8 100644 --- a/docs/URL-Scheme-API/README.zh-TW.md +++ b/docs/URL-Scheme-API/README.zh-TW.md @@ -123,10 +123,27 @@ myapp://got-status?result=%7B%22locked%22%3Atrue%2C…%7D | 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 的既有規則,而不是建立重複的。 | +| `set-url-rule` | `host` *(alias `pattern`, req)*, `source` \| `source-name` *(req)*, `match-type` = `domain-suffix` \| `domain` \| `domain-keyword` \| `url-regex` *(default `domain-suffix`)*, `action` = `lock` \| `switch` *(default `lock`)*, `id` *(optional UUID)* | 建立或取代一條依 URL 規則。模式如何比對取決於 `match-type`(見[下方](#match-types))。若不帶 `id`,會更新同一模式的既有規則,而不是建立重複的。 | | `remove-url-rule` | `id` *(UUID)* \| `host` | 依其 `id`(來自 `list-url-rules`)或依 `host` 刪除一條 URL 規則。 | | `clear-url-rules` | — | 移除**所有**依 URL 規則。 | +#### Match types + +`match-type` 決定一條規則的模式如何與瀏覽器目前的 URL 比對。規則會 +**由上而下評估,第一個比對到的勝出**,所以它們的順序就是它們的優先序 +(在 **設定 ▸ 依 URL 規則** 中拖曳即可重新排序)。 + +| `match-type` | Pattern is… | Matches | +|---|---|---| +| `domain-suffix` *(default)* | 一個 host,例如 `github.com` | 該 host **及其所有子網域**(`github.com`、`gist.github.com`)。開頭的 `*.` 可被容忍。 | +| `domain` | 一個 host,例如 `github.com` | **只比對該確切 host**,絕不含子網域。 | +| `domain-keyword` | 一個子字串,例如 `google` | 任何**包含**它的 host(`google.com`、`mail.google.com`、`googleapis.com`)。 | +| `url-regex` | 一個正規表示式 | **整個 URL**(scheme · host · path · query · fragment)——不分大小寫且不錨定。唯一能依 path 或 query 區分同一站台不同頁面的類型。無法編譯的模式會以 `invalid_parameter` 被拒。 | + +`match-type` 也接受別名 `suffix`、`keyword` 和 `regex`。對於一條 +`url-regex` 規則,模式通常含有必須在 URL 中 percent-encode 的字元 +(`?`、`&`、`/`、`\`)。 + ### App | Command | Parameters | Effect | @@ -149,7 +166,7 @@ LockIME 刻意**不提供任何開啟其 UI 的指令**(Settings、About、更 | `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-url-rules` *(alias `url-rules`)* | `{ "id", "host", "action", "matchType", "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" }`。 | @@ -190,7 +207,7 @@ LockIME 刻意**不提供任何開啟其 UI 的指令**(Settings、About、更 | `no_command` | 未提供指令權杖。 | | `unknown_command` | 無法辨識此指令權杖。 | | `missing_parameter` | 缺少一個必要參數。 | -| `invalid_parameter` | 一個參數值超出範圍(不正確的 `mode`、`action`、`direction`、`code` 或 UUID)。 | +| `invalid_parameter` | 一個參數值超出範圍(不正確的 `mode`、`action`、`match-type`、`direction`、`code`,無法編譯的 `url-regex` 模式,或格式錯誤的 UUID)。 | | `unknown_source` | 此 `id`/`name` 沒有比對到任何已安裝且可選取的輸入法。 | | `no_input_sources` | 沒有安裝任何可選取的輸入法。 | | `rule_not_found` | 目標的應用程式/URL 規則不存在。 | @@ -207,6 +224,9 @@ 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-url-rule?host=github.com&source=com.apple.keylayout.ABC&match-type=domain" +# url-regex 會比對整個 URL——請把模式 percent-encode(此處為:github\.com/.*/pull) +open "lockime://set-url-rule?pattern=github%5C.com%2F.%2A%2Fpull&source=com.apple.keylayout.ABC&match-type=url-regex" open "lockime://set-launch-at-login?enabled=on" ``` From 1836caad08eab09a0c3fcf3bf2f7cd34c7b3cc30 Mon Sep 17 00:00:00 2001 From: Kevin Cui Date: Tue, 16 Jun 2026 11:26:50 -0400 Subject: [PATCH 2/2] fix(rules): harden reorder, tighten i18n sheet guard, clarify alias docs Address CodeRabbit review on #26: - URLRuleList.reordered: reject non-permutations carrying duplicate ids (matching id set but wrong count, e.g. [a, a, b] against [a, b]); set equality alone let such input through and returned a list with a duplicated rule, corrupting the persisted priority order. - LocalizationGuardTests: tighten the .sheet locale guard to require the window re-inject the app's own locale (state/appState.locale), not just any \.locale value, and strip per-line comments so a commented-out modifier can't satisfy it. - URL-Scheme-API docs (all languages): mark the match-type alias list as non-exhaustive, since the parser accepts more aliases (domain-exact, exact, domainsuffix, domainkeyword, urlregex, matchtype) than were listed. English authoritative copy and every translation kept in sync. Signed-off-by: Kevin Cui --- Sources/LockIMEKit/Rules/URLRuleList.swift | 13 ++++++++++--- .../LockIMEKitTests/LocalizationGuardTests.swift | 15 +++++++++++---- Tests/LockIMEKitTests/URLRuleListTests.swift | 1 + docs/URL-Scheme-API/README.de.md | 2 +- docs/URL-Scheme-API/README.es.md | 2 +- docs/URL-Scheme-API/README.fr.md | 2 +- docs/URL-Scheme-API/README.ja.md | 2 +- docs/URL-Scheme-API/README.md | 2 +- docs/URL-Scheme-API/README.pt.md | 2 +- docs/URL-Scheme-API/README.ru.md | 2 +- docs/URL-Scheme-API/README.zh-CN.md | 2 +- docs/URL-Scheme-API/README.zh-TW.md | 2 +- 12 files changed, 31 insertions(+), 16 deletions(-) diff --git a/Sources/LockIMEKit/Rules/URLRuleList.swift b/Sources/LockIMEKit/Rules/URLRuleList.swift index 85ca433..764a80f 100644 --- a/Sources/LockIMEKit/Rules/URLRuleList.swift +++ b/Sources/LockIMEKit/Rules/URLRuleList.swift @@ -74,9 +74,16 @@ public enum URLRuleList { /// the reorder instead of being clobbered by the drag-start snapshot. public static func reordered(_ rules: [URLRule], by ordered: [URLRule]) -> [URLRule]? { let byID = Dictionary(rules.map { ($0.id, $0) }, uniquingKeysWith: { first, _ in first }) - guard Set(ordered.map(\.id)) == Set(byID.keys), - ordered.map(\.id) != rules.map(\.id) + let orderedIDs = ordered.map(\.id) + // A valid permutation has the same length, no repeats, and the same id + // set as the live rules. Checking only set-equality would accept a + // non-permutation like `[a, a, b]` (against `[a, b]`) and return a list + // with a duplicated rule, corrupting the persisted priority order. + guard orderedIDs.count == rules.count, + Set(orderedIDs).count == orderedIDs.count, + Set(orderedIDs) == Set(byID.keys), + orderedIDs != rules.map(\.id) else { return nil } - return ordered.compactMap { byID[$0.id] } + return orderedIDs.compactMap { byID[$0] } } } diff --git a/Tests/LockIMEKitTests/LocalizationGuardTests.swift b/Tests/LockIMEKitTests/LocalizationGuardTests.swift index 623c02a..f85deb3 100644 --- a/Tests/LockIMEKitTests/LocalizationGuardTests.swift +++ b/Tests/LockIMEKitTests/LocalizationGuardTests.swift @@ -200,7 +200,11 @@ struct LocalizationGuardTests { // content uses string literals renders against the system locale, not the // app's in-app override, producing a half-translated screen. The fix is to // re-inject `.environment(\.locale, state.locale)` at the call site (see - // BackupSettingsPane). This guards that every sheet does so. + // BackupSettingsPane). This guards that every sheet does so — and that it + // injects the app's *own* locale, not some other value: matching only + // `.environment(\.locale` would pass `.environment(\.locale, .current)`, + // which still bridges in the system language. + let reinjection = try Regex(#"\.environment\(\s*\\\.locale\s*,\s*(?:appState|state)\.locale\s*\)"#) for (name, text) in try Self.appSwiftFiles() { let lines = Array(text.split(separator: "\n", omittingEmptySubsequences: false)) for (index, line) in lines.enumerated() { @@ -208,9 +212,12 @@ struct LocalizationGuardTests { let code = line.prefix(upTo: line.firstRange(of: "//")?.lowerBound ?? line.endIndex) guard code.contains(".sheet(") else { continue } // The re-injection lives inside the sheet's content closure, a few - // lines down — scan a generous window. - let window = lines[index..