Files
ggbond268--mactools/Plugins/WindowSwitcher/Sources/WindowSwitcherModels.swift
T
wehub-resource-sync 1d1286fadb
Build / Build and test (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:18:38 +08:00

641 lines
20 KiB
Swift

import AppKit
import ApplicationServices
import Carbon.HIToolbox
import Foundation
import MacToolsPluginKit
enum WindowSwitcherConstants {
static let pluginID = "window-switcher"
static let shortcutDefinitionID = "switcher"
static let shortcutActionID = "switch"
static let accessibilityPermissionID = "accessibility"
}
enum WindowSwitcherMode: String, Codable, CaseIterable, Identifiable {
case keyWindow
case directCycle
var id: String { rawValue }
}
enum WindowSwitcherSortMode: String, Codable, CaseIterable, Identifiable {
case recentUse
case fixed
var id: String { rawValue }
}
struct WindowSwitcherConfiguration: Codable, Equatable {
var isEnabled: Bool
var mode: WindowSwitcherMode
var sortMode: WindowSwitcherSortMode
init(
isEnabled: Bool,
mode: WindowSwitcherMode,
sortMode: WindowSwitcherSortMode
) {
self.isEnabled = isEnabled
self.mode = mode
self.sortMode = sortMode
}
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
self.isEnabled = try container.decodeIfPresent(Bool.self, forKey: .isEnabled) ?? true
self.mode = try container.decodeIfPresent(WindowSwitcherMode.self, forKey: .mode) ?? .keyWindow
self.sortMode = try container.decodeIfPresent(WindowSwitcherSortMode.self, forKey: .sortMode) ?? .recentUse
}
static let `default` = WindowSwitcherConfiguration(
isEnabled: true,
mode: .keyWindow,
sortMode: .recentUse
)
private enum CodingKeys: String, CodingKey {
case isEnabled
case mode
case sortMode
}
}
struct WindowSwitcherShortcutBindingState: Codable, Equatable {
static let currentVersion = 1
var version: Int
var manual: [String: String]
var automatic: [String: String]
init(
version: Int = currentVersion,
manual: [String: String] = [:],
automatic: [String: String] = [:]
) {
self.version = version
self.manual = manual
self.automatic = automatic
}
}
enum WindowSwitcherShortcutCustomizationResult {
case updated([WindowSwitcherAppEntry])
case conflict
case unavailable
}
@MainActor
final class WindowSwitcherStore: ObservableObject {
private enum Keys {
static let configuration = "configuration"
static let shortcutBindings = "shortcut-bindings"
static let obsoleteShortcutAssignments = "shortcut-assignments"
}
@Published private(set) var configuration: WindowSwitcherConfiguration
@Published private(set) var shortcutBindings: WindowSwitcherShortcutBindingState
private let storage: PluginStorage
private let encoder = JSONEncoder()
private let decoder = JSONDecoder()
init(storage: PluginStorage) {
self.storage = storage
if let data = storage.data(forKey: Keys.configuration),
let loaded = try? decoder.decode(WindowSwitcherConfiguration.self, from: data) {
self.configuration = loaded
} else {
self.configuration = .default
}
if let data = storage.data(forKey: Keys.shortcutBindings),
let loaded = try? decoder.decode(WindowSwitcherShortcutBindingState.self, from: data) {
self.shortcutBindings = loaded
} else {
self.shortcutBindings = WindowSwitcherShortcutBindingState()
}
storage.removeObject(forKey: Keys.obsoleteShortcutAssignments)
}
func setMode(_ mode: WindowSwitcherMode) {
guard configuration.mode != mode else {
return
}
configuration.mode = mode
persist()
}
func setSortMode(_ sortMode: WindowSwitcherSortMode) {
guard configuration.sortMode != sortMode else {
return
}
configuration.sortMode = sortMode
persist()
}
func setEnabled(_ isEnabled: Bool) {
guard configuration.isEnabled != isEnabled else {
return
}
configuration.isEnabled = isEnabled
persist()
}
private func persist() {
guard let data = try? encoder.encode(configuration) else {
return
}
storage.set(data, forKey: Keys.configuration)
}
func assignShortcuts(to entries: [WindowSwitcherAppEntry]) -> [WindowSwitcherAppEntry] {
let result = WindowSwitcherShortcutAssignment.assignShortcuts(
to: entries,
bindingState: shortcutBindings
)
if result.bindingState != shortcutBindings {
shortcutBindings = result.bindingState
persistShortcutBindings()
}
return result.entries
}
func setManualShortcut(
_ rawToken: String?,
for entryID: String,
in entries: [WindowSwitcherAppEntry]
) -> WindowSwitcherShortcutCustomizationResult {
let identities = WindowSwitcherShortcutAssignment.identities(for: entries)
guard let targetIndex = entries.firstIndex(where: { $0.id == entryID }),
identities.indices.contains(targetIndex)
else {
return .unavailable
}
let targetIdentity = identities[targetIndex]
let current = WindowSwitcherShortcutAssignment.assignShortcuts(
to: entries,
bindingState: shortcutBindings
)
var updatedState = current.bindingState
if let rawToken {
guard let token = WindowSwitcherShortcutAssignment.normalizedManualToken(rawToken) else {
return .unavailable
}
guard !hasShortcutConflict(
token,
targetIdentity: targetIdentity,
identities: identities,
current: current
) else {
return .conflict
}
updatedState.manual[targetIdentity] = token
} else {
updatedState.manual.removeValue(forKey: targetIdentity)
}
let updated = WindowSwitcherShortcutAssignment.assignShortcuts(
to: entries,
bindingState: updatedState
)
if updated.bindingState != shortcutBindings {
shortcutBindings = updated.bindingState
persistShortcutBindings()
}
return .updated(updated.entries)
}
func hasShortcutConflict(
_ rawToken: String,
for entryID: String,
in entries: [WindowSwitcherAppEntry]
) -> Bool {
guard let token = WindowSwitcherShortcutAssignment.normalizedManualToken(rawToken) else {
return true
}
let identities = WindowSwitcherShortcutAssignment.identities(for: entries)
guard let targetIndex = entries.firstIndex(where: { $0.id == entryID }),
identities.indices.contains(targetIndex)
else {
return true
}
let current = WindowSwitcherShortcutAssignment.assignShortcuts(
to: entries,
bindingState: shortcutBindings
)
return hasShortcutConflict(
token,
targetIdentity: identities[targetIndex],
identities: identities,
current: current
)
}
private func hasShortcutConflict(
_ token: String,
targetIdentity: String,
identities: [String],
current: WindowSwitcherShortcutAssignment.Result
) -> Bool {
let conflictsWithSavedManualBinding = current.bindingState.manual.contains {
identity, assignedToken in
identity != targetIdentity
&& WindowSwitcherShortcutAssignment.normalizedManualToken(assignedToken) == token
}
let conflictsWithRunningEntry = current.entries.enumerated().contains { index, entry in
identities[index] != targetIdentity && entry.shortcutToken == token
}
return conflictsWithSavedManualBinding || conflictsWithRunningEntry
}
private func persistShortcutBindings() {
guard let data = try? encoder.encode(shortcutBindings) else {
return
}
storage.set(data, forKey: Keys.shortcutBindings)
}
}
struct WindowSwitcherAppEntry: Identifiable {
let id: String
let processIdentifier: pid_t
let bundleIdentifier: String?
let appName: String
let windowTitle: String?
let icon: NSImage?
let windowElement: AXUIElement?
let isMinimized: Bool
var shortcutToken: String?
var displayName: String {
guard let title = cleanWindowTitle else {
return appName
}
return title
}
var displaySubtitle: String? {
guard let title = cleanWindowTitle,
title.caseInsensitiveCompare(appName) != .orderedSame
else {
return nil
}
return appName
}
var shortcutDisplay: String? {
shortcutToken.flatMap(WindowSwitcherSelectionShortcut.init(storageValue:))?.displayValue
}
var isWindowEntry: Bool {
windowElement != nil
}
var appIdentifier: String {
if let bundleIdentifier, !bundleIdentifier.isEmpty {
return "bundle:\(bundleIdentifier)"
}
return "pid:\(processIdentifier)"
}
private var cleanWindowTitle: String? {
guard let title = windowTitle?.trimmingCharacters(in: .whitespacesAndNewlines),
!title.isEmpty
else {
return nil
}
return title
}
}
struct WindowSwitcherSelectionShortcut: Equatable {
private static let commandPrefix = "cmd+"
let key: String
let usesCommand: Bool
init?(key: String, usesCommand: Bool) {
let normalizedKey = key.lowercased()
guard Self.isAllowedKey(normalizedKey) else {
return nil
}
self.key = normalizedKey
self.usesCommand = usesCommand
}
init?(storageValue: String) {
let normalized = storageValue.lowercased()
let usesCommand = normalized.hasPrefix(Self.commandPrefix)
let key = usesCommand
? String(normalized.dropFirst(Self.commandPrefix.count))
: normalized
self.init(key: key, usesCommand: usesCommand)
}
var storageValue: String {
usesCommand ? Self.commandPrefix + key : key
}
var displayValue: String {
(usesCommand ? "⌘" : "") + key.uppercased()
}
private static func isAllowedKey(_ key: String) -> Bool {
guard key.unicodeScalars.count == 1,
let scalar = key.unicodeScalars.first
else {
return false
}
return (scalar.value >= 97 && scalar.value <= 122)
|| (scalar.value >= 48 && scalar.value <= 57)
}
}
extension WindowSwitcherAppEntry: Equatable {
static func == (lhs: WindowSwitcherAppEntry, rhs: WindowSwitcherAppEntry) -> Bool {
lhs.id == rhs.id
&& lhs.displayName == rhs.displayName
&& lhs.displaySubtitle == rhs.displaySubtitle
&& lhs.shortcutToken == rhs.shortcutToken
}
}
enum WindowSwitcherShortcutAssignment {
struct Result {
let entries: [WindowSwitcherAppEntry]
let bindingState: WindowSwitcherShortcutBindingState
}
private struct Target {
let index: Int
let identity: String
let preferredToken: String?
}
static let letterKeyOrder: [String] = [
"f", "j", "d", "k", "s", "l", "a", "g", "h",
"e", "i", "r", "u", "w", "o", "q", "p",
"c", "m", "v", "n", "x", "b", "z", "t", "y",
]
static let digitKeyOrder: [String] = ["1", "2", "3", "4", "5", "6", "7", "8", "9", "0"]
static let maximumShortcutCount = (letterKeyOrder.count + digitKeyOrder.count) * 2
static func assignShortcuts(to entries: [WindowSwitcherAppEntry]) -> [WindowSwitcherAppEntry] {
assignShortcuts(to: entries, bindingState: WindowSwitcherShortcutBindingState()).entries
}
static func assignShortcuts(
to entries: [WindowSwitcherAppEntry],
bindingState: WindowSwitcherShortcutBindingState
) -> Result {
guard !entries.isEmpty else {
return Result(entries: [], bindingState: bindingState)
}
let targets = assignmentTargets(for: entries)
let reservedManualTokens = Set(bindingState.manual.values.compactMap(normalizedManualToken))
let availableTokens = shortcutTokens(count: maximumShortcutCount)
var assignedTokens = Array<String?>(repeating: nil, count: entries.count)
var activeManualTokens = Set<String>()
var usedTokens = reservedManualTokens
for target in targets {
guard let token = bindingState.manual[target.identity].flatMap(normalizedManualToken),
activeManualTokens.insert(token).inserted
else {
continue
}
assignedTokens[target.index] = token
}
for target in targets where assignedTokens[target.index] == nil {
guard let token = bindingState.automatic[target.identity].flatMap(normalizedShortcutToken),
!usedTokens.contains(token)
else {
continue
}
assignedTokens[target.index] = token
usedTokens.insert(token)
}
for target in targets where assignedTokens[target.index] == nil {
guard let token = target.preferredToken,
!usedTokens.contains(token)
else {
continue
}
assignedTokens[target.index] = token
usedTokens.insert(token)
}
for target in targets where assignedTokens[target.index] == nil {
guard let token = availableTokens.first(where: { !usedTokens.contains($0) }) else {
continue
}
assignedTokens[target.index] = token
usedTokens.insert(token)
}
let assignedEntries = entries.enumerated().map { index, entry in
var copy = entry
copy.shortcutToken = assignedTokens[index]
return copy
}
let updatedState = updatedBindingState(
from: bindingState,
targets: targets,
assignedTokens: assignedTokens
)
return Result(entries: assignedEntries, bindingState: updatedState)
}
static func shortcutTokens(count: Int) -> [String] {
guard count > 0 else {
return []
}
let plainTokens = letterKeyOrder + digitKeyOrder
let commandTokens = plainTokens.map { "cmd+\($0)" }
return Array((plainTokens + commandTokens).prefix(count))
}
static func identities(for entries: [WindowSwitcherAppEntry]) -> [String] {
assignmentTargets(for: entries).map(\.identity)
}
static func normalizedManualToken(_ token: String) -> String? {
WindowSwitcherSelectionShortcut(storageValue: token)?.storageValue
}
private static func assignmentTargets(for entries: [WindowSwitcherAppEntry]) -> [Target] {
var appOccurrences: [String: Int] = [:]
return entries.enumerated().map { index, entry in
let appIdentifier = entry.appIdentifier
let occurrence = appOccurrences[appIdentifier, default: 0]
appOccurrences[appIdentifier] = occurrence + 1
return Target(
index: index,
identity: occurrence == 0
? appIdentifier
: "\(appIdentifier)#window:\(occurrence + 1)",
preferredToken: occurrence == 0 ? preferredSingleKeyToken(for: entry) : nil
)
}
}
private static func updatedBindingState(
from bindingState: WindowSwitcherShortcutBindingState,
targets: [Target],
assignedTokens: [String?]
) -> WindowSwitcherShortcutBindingState {
var state = bindingState
state.version = WindowSwitcherShortcutBindingState.currentVersion
let validManualBindings = state.manual.compactMapValues(normalizedManualToken)
state.manual = validManualBindings
let manualIdentities = Set(validManualBindings.keys)
let manualTokens = Set(validManualBindings.values)
var activeTokens = Set<String>()
var activeIdentities = Set<String>()
for target in targets {
guard let token = assignedTokens[target.index]
else {
continue
}
activeTokens.insert(token)
guard !manualIdentities.contains(target.identity) else {
continue
}
state.automatic[target.identity] = token
activeIdentities.insert(target.identity)
}
for (identity, rawToken) in state.automatic {
guard let token = normalizedShortcutToken(rawToken) else {
state.automatic.removeValue(forKey: identity)
continue
}
let conflictsWithManual = !manualIdentities.contains(identity) && manualTokens.contains(token)
let conflictsWithActive = !activeIdentities.contains(identity)
&& !manualIdentities.contains(identity)
&& activeTokens.contains(token)
if conflictsWithManual || conflictsWithActive {
state.automatic.removeValue(forKey: identity)
}
}
guard state.automatic.count > 256 else {
return state
}
let retainedIdentities = activeIdentities.union(manualIdentities)
let retained = state.automatic.filter { retainedIdentities.contains($0.key) }
let inactive = state.automatic
.filter { !retainedIdentities.contains($0.key) }
.sorted { $0.key < $1.key }
.prefix(max(0, 256 - retained.count))
state.automatic = inactive.reduce(into: retained) { result, item in
result[item.key] = item.value
}
return state
}
private static func preferredSingleKeyToken(for entry: WindowSwitcherAppEntry) -> String? {
let candidates = [
entry.appName,
entry.bundleIdentifier?.split(separator: ".").last.map(String.init),
entry.bundleIdentifier,
].compactMap { $0 }
for candidate in candidates {
if let token = firstASCIIKey(in: candidate) {
return token
}
}
return nil
}
private static func firstASCIIKey(in text: String) -> String? {
for scalar in text.lowercased().unicodeScalars {
guard scalar.value >= 97, scalar.value <= 122 else {
continue
}
return String(Character(scalar))
}
return nil
}
private static func normalizedShortcutToken(_ token: String) -> String? {
WindowSwitcherSelectionShortcut(storageValue: token)?.storageValue
}
}
enum WindowSwitcherShortcutBindingStore {
static let defaultBinding = ShortcutBinding(
keyCode: UInt16(kVK_Tab),
modifiers: .command
)
private static let defaultsKey = "shortcut.customization.\(itemID)"
static var itemID: String {
"\(WindowSwitcherConstants.pluginID).shortcut.\(WindowSwitcherConstants.shortcutDefinitionID)"
}
static func resolvedBinding(userDefaults: UserDefaults = .standard) -> ShortcutBinding? {
guard let data = userDefaults.data(forKey: defaultsKey) else {
return defaultBinding
}
do {
let customization = try JSONDecoder().decode(ShortcutCustomization.self, from: data)
return ShortcutStoreResolve.resolve(customization: customization, defaultBinding: defaultBinding)
} catch {
return defaultBinding
}
}
}
private enum ShortcutStoreResolve {
static func resolve(customization: ShortcutCustomization, defaultBinding: ShortcutBinding?) -> ShortcutBinding? {
switch customization {
case .inheritDefault:
return defaultBinding
case let .custom(binding):
return binding
case .cleared:
return nil
}
}
}