641 lines
20 KiB
Swift
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
|
|
}
|
|
}
|
|
}
|