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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
17 changes: 13 additions & 4 deletions Sources/LockIME/API/URLCommandHandler.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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<Any?, URLCommandError> {
switch resolve(selector) {
case .failure(let error):
Expand All @@ -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)
}
}
Expand Down Expand Up @@ -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),
]
}
Expand Down
27 changes: 25 additions & 2 deletions Sources/LockIME/AppState.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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()
}

Expand All @@ -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.
Expand Down
Loading
Loading