From 21972c5c522e4630c80925d18a3325a3a73fef77 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=88=B6=E6=9D=96?= Date: Fri, 6 Dec 2024 00:25:35 +0800 Subject: [PATCH] Initial commit with project setup including .gitignore, configuration files, and source code for TypeSwitch application. Added support for managing input methods and installed applications, along with UI components for user interaction. --- .gitignore | 70 ++++++ .mise.toml | 2 + .package.resolved | 33 +++ Project.swift | 50 ++++ Tuist.swift | 9 + Tuist/Package.swift | 22 ++ .../AccentColor.colorset/Contents.json | 11 + .../AppIcon.appiconset/Contents.json | 58 +++++ .../Resources/Assets.xcassets/Contents.json | 6 + .../Preview Assets.xcassets/Contents.json | 6 + TypeSwitch/Sources/AppListUtils.swift | 65 ++++++ TypeSwitch/Sources/ContentView.swift | 217 ++++++++++++++++++ TypeSwitch/Sources/DefaultsKeys.swift | 11 + TypeSwitch/Sources/InputMethodManager.swift | 143 ++++++++++++ TypeSwitch/Sources/InputMethodUtils.swift | 102 ++++++++ TypeSwitch/Sources/Models.swift | 26 +++ TypeSwitch/Sources/TypeSwitchApp.swift | 19 ++ .../Tests/InputMethodSwitcherTests.swift | 8 + 18 files changed, 858 insertions(+) create mode 100644 .gitignore create mode 100644 .mise.toml create mode 100644 .package.resolved create mode 100644 Project.swift create mode 100644 Tuist.swift create mode 100644 Tuist/Package.swift create mode 100644 TypeSwitch/Resources/Assets.xcassets/AccentColor.colorset/Contents.json create mode 100644 TypeSwitch/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json create mode 100644 TypeSwitch/Resources/Assets.xcassets/Contents.json create mode 100644 TypeSwitch/Resources/Preview Content/Preview Assets.xcassets/Contents.json create mode 100644 TypeSwitch/Sources/AppListUtils.swift create mode 100644 TypeSwitch/Sources/ContentView.swift create mode 100644 TypeSwitch/Sources/DefaultsKeys.swift create mode 100644 TypeSwitch/Sources/InputMethodManager.swift create mode 100644 TypeSwitch/Sources/InputMethodUtils.swift create mode 100644 TypeSwitch/Sources/Models.swift create mode 100644 TypeSwitch/Sources/TypeSwitchApp.swift create mode 100644 TypeSwitch/Tests/InputMethodSwitcherTests.swift diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..24b244f --- /dev/null +++ b/.gitignore @@ -0,0 +1,70 @@ +### macOS ### +# General +.DS_Store +.AppleDouble +.LSOverride + +# Icon must end with two +Icon + +# Thumbnails +._* + +# Files that might appear in the root of a volume +.DocumentRevisions-V100 +.fseventsd +.Spotlight-V100 +.TemporaryItems +.Trashes +.VolumeIcon.icns +.com.apple.timemachine.donotpresent + +# Directories potentially created on remote AFP share +.AppleDB +.AppleDesktop +Network Trash Folder +Temporary Items +.apdisk + +### Xcode ### +# Xcode +# +# gitignore contributors: remember to update Global/Xcode.gitignore, Objective-C.gitignore & Swift.gitignore + +## User settings +xcuserdata/ + +## compatibility with Xcode 8 and earlier (ignoring not required starting Xcode 9) +*.xcscmblueprint +*.xccheckout + +## compatibility with Xcode 3 and earlier (ignoring not required starting Xcode 4) +build/ +DerivedData/ +*.moved-aside +*.pbxuser +!default.pbxuser +*.mode1v3 +!default.mode1v3 +*.mode2v3 +!default.mode2v3 +*.perspectivev3 +!default.perspectivev3 + +### Xcode Patch ### +*.xcodeproj/* +!*.xcodeproj/project.pbxproj +!*.xcodeproj/xcshareddata/ +!*.xcworkspace/contents.xcworkspacedata +/*.gcno + +### Projects ### +*.xcodeproj +*.xcworkspace + +### Tuist derived files ### +graph.dot +Derived/ + +### Tuist managed dependencies ### +Tuist/.build \ No newline at end of file diff --git a/.mise.toml b/.mise.toml new file mode 100644 index 0000000..18725da --- /dev/null +++ b/.mise.toml @@ -0,0 +1,2 @@ +[tools] +tuist = "4.36.0" diff --git a/.package.resolved b/.package.resolved new file mode 100644 index 0000000..571f2b9 --- /dev/null +++ b/.package.resolved @@ -0,0 +1,33 @@ +{ + "originHash" : "34bd913d9089fe41f02bf038193989039a85b3413c3faeddca7573d68c9a1f1d", + "pins" : [ + { + "identity" : "defaults", + "kind" : "remoteSourceControl", + "location" : "https://github.com/sindresorhus/Defaults", + "state" : { + "revision" : "ef1b2318fb549002bb533bec3a8ad98ae09f2cb6", + "version" : "9.0.0" + } + }, + { + "identity" : "swift-syntax", + "kind" : "remoteSourceControl", + "location" : "https://github.com/swiftlang/swift-syntax", + "state" : { + "revision" : "0687f71944021d616d34d922343dcef086855920", + "version" : "600.0.1" + } + }, + { + "identity" : "swiftuix", + "kind" : "remoteSourceControl", + "location" : "https://github.com/SwiftUIX/SwiftUIX", + "state" : { + "revision" : "e984fd2e08140ad5a95d084be38fe02b774bc15d", + "version" : "0.2.3" + } + } + ], + "version" : 3 +} diff --git a/Project.swift b/Project.swift new file mode 100644 index 0000000..fa3df1a --- /dev/null +++ b/Project.swift @@ -0,0 +1,50 @@ +import ProjectDescription + +let project = Project( + name: "TypeSwitch", + packages: [ + .remote(url: "https://github.com/sindresorhus/Defaults", requirement: .upToNextMajor(from: "9.0.0")), + .remote(url: "https://github.com/SwiftUIX/SwiftUIX", requirement: .upToNextMajor(from: "0.1.9")), + ], + targets: [ + .target( + name: "TypeSwitch", + destinations: .macOS, + product: .app, + bundleId: "top.ygsgdbd.TypeSwitch", + deploymentTargets: .macOS("13.0"), + infoPlist: .extendingDefault(with: [ + "LSUIElement": true, // 设置为纯菜单栏应用 + "com.apple.security.app-sandbox": true, + "com.apple.security.network.client": true, + "com.apple.security.files.user-selected.read-write": true, + "com.apple.developer.icloud-container-identifiers": ["iCloud.top.ygsgdbd.TypeSwitch"], + "com.apple.developer.icloud-services": ["CloudKit"], + "com.apple.developer.ubiquity-kvstore-identifier": "top.ygsgdbd.TypeSwitch", + "com.apple.security.application-groups": ["group.top.ygsgdbd.TypeSwitch"], + ]), + sources: ["TypeSwitch/Sources/**"], + resources: ["TypeSwitch/Resources/**"], + dependencies: [ + .package(product: "Defaults"), + .package(product: "SwiftUIX"), + ], + settings: .settings(base: [ + "ENABLE_APP_SANDBOX": "YES", + "ENABLE_ICLOUD_SERVICES": "YES", + "ENABLE_ICLOUD_KEYVALUE_STORAGE": "YES", + ]) + ), + .target( + name: "TypeSwitchTests", + destinations: .macOS, + product: .unitTests, + bundleId: "top.ygsgdbd.TypeSwitchTests", + deploymentTargets: .macOS("13.0"), + infoPlist: .default, + sources: ["TypeSwitch/Tests/**"], + resources: [], + dependencies: [.target(name: "TypeSwitch")] + ), + ] +) diff --git a/Tuist.swift b/Tuist.swift new file mode 100644 index 0000000..4733e16 --- /dev/null +++ b/Tuist.swift @@ -0,0 +1,9 @@ +import ProjectDescription + +let tuist = Tuist( +// Create an account with "tuist auth" and a project with "tuist project create" +// then uncomment the section below and set the project full-handle. +// * Read more: https://docs.tuist.io/guides/quick-start/gather-insights +// +// fullHandle: "{account_handle}/{project_handle}", +) diff --git a/Tuist/Package.swift b/Tuist/Package.swift new file mode 100644 index 0000000..1e32751 --- /dev/null +++ b/Tuist/Package.swift @@ -0,0 +1,22 @@ +// swift-tools-version: 6.0 +import PackageDescription + +#if TUIST + import struct ProjectDescription.PackageSettings + + let packageSettings = PackageSettings( + // Customize the product types for specific package product + // Default is .staticFramework + // productTypes: ["Alamofire": .framework,] + productTypes: [:] + ) +#endif + +let package = Package( + name: "InputMethodSwitcher", + dependencies: [ + // Add your own dependencies here: + // .package(url: "https://github.com/Alamofire/Alamofire", from: "5.0.0"), + // You can read more about dependencies here: https://docs.tuist.io/documentation/tuist/dependencies + ] +) diff --git a/TypeSwitch/Resources/Assets.xcassets/AccentColor.colorset/Contents.json b/TypeSwitch/Resources/Assets.xcassets/AccentColor.colorset/Contents.json new file mode 100644 index 0000000..eb87897 --- /dev/null +++ b/TypeSwitch/Resources/Assets.xcassets/AccentColor.colorset/Contents.json @@ -0,0 +1,11 @@ +{ + "colors" : [ + { + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/TypeSwitch/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json b/TypeSwitch/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..3f00db4 --- /dev/null +++ b/TypeSwitch/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,58 @@ +{ + "images" : [ + { + "idiom" : "mac", + "scale" : "1x", + "size" : "16x16" + }, + { + "idiom" : "mac", + "scale" : "2x", + "size" : "16x16" + }, + { + "idiom" : "mac", + "scale" : "1x", + "size" : "32x32" + }, + { + "idiom" : "mac", + "scale" : "2x", + "size" : "32x32" + }, + { + "idiom" : "mac", + "scale" : "1x", + "size" : "128x128" + }, + { + "idiom" : "mac", + "scale" : "2x", + "size" : "128x128" + }, + { + "idiom" : "mac", + "scale" : "1x", + "size" : "256x256" + }, + { + "idiom" : "mac", + "scale" : "2x", + "size" : "256x256" + }, + { + "idiom" : "mac", + "scale" : "1x", + "size" : "512x512" + }, + { + "idiom" : "mac", + "scale" : "2x", + "size" : "512x512" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/TypeSwitch/Resources/Assets.xcassets/Contents.json b/TypeSwitch/Resources/Assets.xcassets/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/TypeSwitch/Resources/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/TypeSwitch/Resources/Preview Content/Preview Assets.xcassets/Contents.json b/TypeSwitch/Resources/Preview Content/Preview Assets.xcassets/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/TypeSwitch/Resources/Preview Content/Preview Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/TypeSwitch/Sources/AppListUtils.swift b/TypeSwitch/Sources/AppListUtils.swift new file mode 100644 index 0000000..6c2f5b8 --- /dev/null +++ b/TypeSwitch/Sources/AppListUtils.swift @@ -0,0 +1,65 @@ +import Foundation +import SwiftUI + +enum AppListUtils { + static let applicationDirs = [ + "/Applications", + "~/Applications", + "/System/Applications" + ].map { NSString(string: $0).expandingTildeInPath } + + static func fetchInstalledApps() async -> [AppInfo] { + await withTaskGroup(of: [AppInfo].self) { group in + for dir in applicationDirs { + group.addTask { + await fetchAppsInDirectory(dir) + } + } + + var apps: [AppInfo] = [] + for await dirApps in group { + apps.append(contentsOf: dirApps) + } + + let uniqueApps = Dictionary(grouping: apps, by: \.bundleId) + .values + .compactMap { $0.first } + + return uniqueApps.sorted { $0.name.localizedStandardCompare($1.name) == .orderedAscending } + } + } + + private static func fetchAppsInDirectory(_ dir: String) async -> [AppInfo] { + let fileManager = FileManager.default + + guard fileManager.fileExists(atPath: dir), + fileManager.isReadableFile(atPath: dir) else { + return [] + } + + guard let enumerator = fileManager.enumerator( + at: URL(fileURLWithPath: dir), + includingPropertiesForKeys: [.isApplicationKey], + options: [.skipsHiddenFiles, .skipsPackageDescendants] + ) else { return [] } + + var dirApps: [AppInfo] = [] + for case let fileURL as URL in enumerator { + guard fileManager.isReadableFile(atPath: fileURL.path) else { continue } + + do { + let resourceValues = try fileURL.resourceValues(forKeys: [.isApplicationKey]) + guard resourceValues.isApplication == true, + let bundle = Bundle(url: fileURL), + let bundleId = bundle.bundleIdentifier, + let name = bundle.infoDictionary?["CFBundleName"] as? String + else { continue } + + dirApps.append(AppInfo(bundleId: bundleId, name: name, iconPath: fileURL.path)) + } catch { + continue + } + } + return dirApps + } +} \ No newline at end of file diff --git a/TypeSwitch/Sources/ContentView.swift b/TypeSwitch/Sources/ContentView.swift new file mode 100644 index 0000000..69ac902 --- /dev/null +++ b/TypeSwitch/Sources/ContentView.swift @@ -0,0 +1,217 @@ +import SwiftUI +import ServiceManagement +import AppKit + +struct ContentView: View { + @EnvironmentObject private var viewModel: InputMethodManager + @FocusState private var isSearchFocused: Bool + @Environment(\.dismiss) private var dismiss + @State private var showError = false + @State private var errorMessage = "" + + var body: some View { + VStack(spacing: 0) { + // 搜索框 + TextField("搜索应用... (⌘F)", text: $viewModel.searchText) + .textFieldStyle(.roundedBorder) + .padding(.horizontal) + .padding(.top, 8) + .focused($isSearchFocused) + .onSubmit { + if viewModel.searchText.isEmpty { + dismiss() + } + } + + List(viewModel.filteredApps, id: \.bundleId) { app in + AppRow(app: app) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(8) + } + .listStyle(.plain) + + Divider() + + // 底部控制区域 + HStack { + Toggle("开机自动启动", isOn: $viewModel.isAutoLaunchEnabled) + .toggleStyle(.switch) + .onChange(of: viewModel.isAutoLaunchEnabled) { newValue in + Task { + do { + if newValue { + try SMAppService.mainApp.register() + } else { + try await SMAppService.mainApp.unregister() + } + } catch { + // 还原开关状态 + await MainActor.run { + viewModel.isAutoLaunchEnabled = !newValue + errorMessage = "设置开机自启动失败:\(error.localizedDescription)" + showError = true + } + } + } + } + + Spacer() + + HStack(spacing: 16) { + RefreshButton() + QuitButton() + } + } + .padding() + } + .frame(minWidth: 600, idealWidth: 800, maxWidth: .infinity, minHeight: 400) + .task { + await viewModel.refreshAllData() + } + .onExitCommand { + if viewModel.searchText.isEmpty { + dismiss() + } else { + viewModel.searchText = "" + } + } + .background { + Button("") { + isSearchFocused = true + } + .keyboardShortcut("f", modifiers: .command) + .opacity(0) + } + .alert("错误", isPresented: $showError) { + Button("确定") { + showError = false + } + } message: { + Text(errorMessage) + } + } +} + +// MARK: - Subviews + +private struct AppRow: View { + let app: AppInfo + + var body: some View { + Grid(horizontalSpacing: 16) { + GridRow { + AppIcon(app: app) + .equatable() + AppName(app: app) + .equatable() + InputMethodPicker(app: app) + } + } + } +} + +private struct AppIcon: View, Equatable { + let app: AppInfo + + static func == (lhs: AppIcon, rhs: AppIcon) -> Bool { + lhs.app.bundleId == rhs.app.bundleId + } + + var body: some View { + app.icon + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: 24, height: 24) + .gridCellColumns(1) + .gridCellAnchor(.center) + } +} + +private struct AppName: View, Equatable { + let app: AppInfo + + static func == (lhs: AppName, rhs: AppName) -> Bool { + lhs.app.bundleId == rhs.app.bundleId + } + + var body: some View { + Text(app.name) + .font(.system(size: 12)) + .lineLimit(1) + .truncationMode(.middle) + .frame(width: 160, alignment: .leading) + .gridCellColumns(1) + .gridCellAnchor(.leading) + } +} + +private struct InputMethodPicker: View { + let app: AppInfo + @EnvironmentObject private var viewModel: InputMethodManager + + var body: some View { + Picker("", selection: makeBinding()) { + Text("默认").tag("") + ForEach(viewModel.inputMethods) { inputMethod in + Text(inputMethod.name) + .font(.system(size: 11)) + .tag(inputMethod.id) + } + } + .pickerStyle(.segmented) + .controlSize(.small) + .gridCellColumns(1) + .gridCellAnchor(.trailing) + } + + private func makeBinding() -> Binding { + Binding( + get: { [app, viewModel] in + viewModel.appSettings[app.bundleId] ?? "" + }, + set: { [app, viewModel] newValue in + viewModel.appSettings[app.bundleId] = newValue + } + ) + } +} + +private struct RefreshButton: View { + @EnvironmentObject private var viewModel: InputMethodManager + + var body: some View { + Button { + Task { + await viewModel.refreshAllData() + } + } label: { + if viewModel.isRefreshing { + ProgressView() + .controlSize(.small) + .scaleEffect(0.7) + } else { + Text("刷新 (⌘R)") + } + } + .help("刷新应用列表") + .keyboardShortcut("r", modifiers: .command) + .disabled(viewModel.isRefreshing) + } +} + +private struct QuitButton: View { + var body: some View { + Button("退出 (⌘Q)") { + NSApplication.shared.terminate(nil) + } + .help("退出应用") + .keyboardShortcut("q", modifiers: .command) + } +} + +struct ContentView_Previews: PreviewProvider { + static var previews: some View { + ContentView() + } +} + diff --git a/TypeSwitch/Sources/DefaultsKeys.swift b/TypeSwitch/Sources/DefaultsKeys.swift new file mode 100644 index 0000000..46cd8c1 --- /dev/null +++ b/TypeSwitch/Sources/DefaultsKeys.swift @@ -0,0 +1,11 @@ +import Foundation +import Defaults + +extension Defaults.Keys { + static let appInputMethodSettings = Key<[String: String]>( + "appInputMethodSettings", + default: [:], + suite: .init(suiteName: "group.top.ygsgdbd.TypeSwitch")!, + iCloud: true + ) +} diff --git a/TypeSwitch/Sources/InputMethodManager.swift b/TypeSwitch/Sources/InputMethodManager.swift new file mode 100644 index 0000000..ef7ca75 --- /dev/null +++ b/TypeSwitch/Sources/InputMethodManager.swift @@ -0,0 +1,143 @@ +import Foundation +import ServiceManagement +import AppKit +import Combine +import Defaults +import Carbon + +@MainActor +class InputMethodManager: ObservableObject { + static let shared = InputMethodManager() + + @Published var inputMethods: [InputMethod] = [] + @Published var installedApps: [AppInfo] = [] + @Published var appSettings: [String: String] = [:] { + didSet { + // 当设置更新时,同步到 Defaults + Defaults[.appInputMethodSettings] = appSettings + } + } + + // UI 状态 + @Published var isAutoLaunchEnabled = false + @Published var isRefreshing = false + @Published var searchText = "" + + var filteredApps: [AppInfo] { + if searchText.isEmpty { + return installedApps + } + return installedApps.filter { app in + app.name.localizedCaseInsensitiveContains(searchText) + } + } + + private var inputSourceObserver: NSObjectProtocol? + private var workspaceObserver: NSObjectProtocol? + + private init() { + // 从 Defaults 加载设置 + appSettings = Defaults[.appInputMethodSettings] + + // 监听输入法变化 + inputSourceObserver = DistributedNotificationCenter.default().addObserver( + forName: NSNotification.Name(kTISNotifyEnabledKeyboardInputSourcesChanged as String), + object: nil, + queue: .main + ) { [weak self] _ in + guard let self = self else { return } + Task { @MainActor in + await self.refreshInputMethods() + } + } + + // 监听应用切换 + workspaceObserver = NSWorkspace.shared.notificationCenter.addObserver( + forName: NSWorkspace.didActivateApplicationNotification, + object: nil, + queue: .main + ) { [weak self] notification in + guard let self = self, + let app = notification.userInfo?[NSWorkspace.applicationUserInfoKey] as? NSRunningApplication, + let bundleId = app.bundleIdentifier + else { return } + + // 检查是否有为该应用设置输入法 + if let inputMethodId = self.appSettings[bundleId] { + Task { + do { + try await self.switchToInputMethod(inputMethodId) + } catch { + print("Failed to switch input method: \(error)") + } + } + } + } + } + + deinit { + if let observer = inputSourceObserver { + DistributedNotificationCenter.default().removeObserver(observer) + } + if let observer = workspaceObserver { + NSWorkspace.shared.notificationCenter.removeObserver(observer) + } + } + + func refreshInputMethods() async { + do { + let newInputMethods = try InputMethodUtils.fetchInputMethods() + + // 检查输入法列表是否发生变化 + let oldInputMethodIds = Set(inputMethods.map { $0.id }) + let newInputMethodIds = Set(newInputMethods.map { $0.id }) + + // 如果输入法列表发生变化,清理无效的设置 + if oldInputMethodIds != newInputMethodIds { + cleanInvalidSettings(validInputMethodIds: newInputMethodIds) + } + + inputMethods = newInputMethods + } catch { + print("Failed to refresh input methods: \(error)") + } + } + + private func cleanInvalidSettings(validInputMethodIds: Set) { + var updatedSettings = appSettings + var hasChanges = false + + // 移除不存在的输入法设置 + for (bundleId, inputMethodId) in appSettings { + if !validInputMethodIds.contains(inputMethodId) { + updatedSettings[bundleId] = nil + hasChanges = true + } + } + + if hasChanges { + appSettings = updatedSettings + } + } + + func switchToInputMethod(_ inputMethodID: String) async throws { + try await Task.detached { + try InputMethodUtils.switchToInputMethod(inputMethodID) + }.value + } + + func refreshInstalledApps() async { + installedApps = await AppListUtils.fetchInstalledApps() + } + + // 刷新所有数据 + func refreshAllData() async { + guard !isRefreshing else { return } + isRefreshing = true + defer { isRefreshing = false } + + await refreshInputMethods() + await refreshInstalledApps() + isAutoLaunchEnabled = SMAppService.mainApp.status == .enabled + } +} diff --git a/TypeSwitch/Sources/InputMethodUtils.swift b/TypeSwitch/Sources/InputMethodUtils.swift new file mode 100644 index 0000000..9075ff8 --- /dev/null +++ b/TypeSwitch/Sources/InputMethodUtils.swift @@ -0,0 +1,102 @@ +import Foundation +import Carbon + +enum InputMethodError: Error { + case failedToGetInputSources + case inputMethodNotFound + case switchFailed +} + +enum InputMethodUtils { + static func fetchInputMethods() throws -> [InputMethod] { + // 创建输入源列表 + guard let inputSourceList = TISCreateInputSourceList(nil, false)?.takeRetainedValue(), + let inputSources = (inputSourceList as NSArray) as? [TISInputSource] else { + throw InputMethodError.failedToGetInputSources + } + + // 过滤和转换输入源 + let methods = inputSources.compactMap { source -> InputMethod? in + // 获取所有需要的属性 + guard let properties = getInputSourceProperties(source), + // 检查输入源类型是否符合要求 + isValidInputSourceType(properties.sourceType), + // 检查输入源是否可用 + properties.isSelectable && properties.isEnabled else { + return nil + } + + return InputMethod(id: properties.sourceID, name: properties.localizedName) + } + + // 按本地化名称排序 + return methods.sorted { $0.name.localizedStandardCompare($1.name) == .orderedAscending } + } + + static func switchToInputMethod(_ inputMethodID: String) throws { + // 创建输入源列表 + guard let inputSourceList = TISCreateInputSourceList(nil, false)?.takeRetainedValue(), + let inputSources = (inputSourceList as NSArray) as? [TISInputSource] else { + throw InputMethodError.failedToGetInputSources + } + + // 查找目标输入源 + guard let targetSource = inputSources.first(where: { source in + guard let properties = getInputSourceProperties(source) else { + return false + } + return properties.sourceID == inputMethodID + }) else { + throw InputMethodError.inputMethodNotFound + } + + // 切换输入法 + let status = TISSelectInputSource(targetSource) + if status != noErr { + throw InputMethodError.switchFailed + } + } + + // MARK: - Private Helpers + + private struct InputSourceProperties { + let sourceID: String + let sourceType: String + let localizedName: String + let isSelectable: Bool + let isEnabled: Bool + } + + private static func getInputSourceProperties(_ source: TISInputSource) -> InputSourceProperties? { + // 获取输入源ID + guard let sourceIDPtr = TISGetInputSourceProperty(source, kTISPropertyInputSourceID), + let sourceID = Unmanaged.fromOpaque(sourceIDPtr).takeUnretainedValue() as String?, + // 获取输入源类型 + let sourceTypePtr = TISGetInputSourceProperty(source, kTISPropertyInputSourceType), + let sourceType = Unmanaged.fromOpaque(sourceTypePtr).takeUnretainedValue() as String?, + // 获取本地化名称 + let localizedNamePtr = TISGetInputSourceProperty(source, kTISPropertyLocalizedName), + let localizedName = Unmanaged.fromOpaque(localizedNamePtr).takeUnretainedValue() as String?, + // 获取可选择状态 + let selectablePtr = TISGetInputSourceProperty(source, kTISPropertyInputSourceIsSelectCapable), + // 获取启用状态 + let enabledPtr = TISGetInputSourceProperty(source, kTISPropertyInputSourceIsEnabled) else { + return nil + } + + let isSelectable = CFBooleanGetValue(Unmanaged.fromOpaque(selectablePtr).takeUnretainedValue()) + let isEnabled = CFBooleanGetValue(Unmanaged.fromOpaque(enabledPtr).takeUnretainedValue()) + + return InputSourceProperties( + sourceID: sourceID, + sourceType: sourceType, + localizedName: localizedName, + isSelectable: isSelectable, + isEnabled: isEnabled + ) + } + + private static func isValidInputSourceType(_ sourceType: String) -> Bool { + sourceType == (kTISTypeKeyboardLayout as String) || sourceType == (kTISTypeKeyboardInputMode as String) + } +} \ No newline at end of file diff --git a/TypeSwitch/Sources/Models.swift b/TypeSwitch/Sources/Models.swift new file mode 100644 index 0000000..b0b0cc2 --- /dev/null +++ b/TypeSwitch/Sources/Models.swift @@ -0,0 +1,26 @@ +import Foundation +import SwiftUI + +struct InputMethod: Identifiable { + let id: String + let name: String +} + +struct AppInfo: Identifiable, Sendable { + let bundleId: String + let name: String + private let iconPath: String + + var id: String { bundleId } + + @MainActor + var icon: Image { + Image(nsImage: NSWorkspace.shared.icon(forFile: iconPath)) + } + + init(bundleId: String, name: String, iconPath: String) { + self.bundleId = bundleId + self.name = name + self.iconPath = iconPath + } +} \ No newline at end of file diff --git a/TypeSwitch/Sources/TypeSwitchApp.swift b/TypeSwitch/Sources/TypeSwitchApp.swift new file mode 100644 index 0000000..a7f202c --- /dev/null +++ b/TypeSwitch/Sources/TypeSwitchApp.swift @@ -0,0 +1,19 @@ +import SwiftUI + +@main +struct TypeSwitchApp: App { + @StateObject private var inputMethodManager = InputMethodManager.shared + + var body: some Scene { + MenuBarExtra("TypeSwitch", systemImage: "keyboard") { + ContentView() + .environmentObject(inputMethodManager) + } + .menuBarExtraStyle(.window) + + Settings { + ContentView() + .environmentObject(inputMethodManager) + } + } +} diff --git a/TypeSwitch/Tests/InputMethodSwitcherTests.swift b/TypeSwitch/Tests/InputMethodSwitcherTests.swift new file mode 100644 index 0000000..24c157d --- /dev/null +++ b/TypeSwitch/Tests/InputMethodSwitcherTests.swift @@ -0,0 +1,8 @@ +import Foundation +import XCTest + +final class InputMethodSwitcherTests: XCTestCase { + func test_twoPlusTwo_isFour() { + XCTAssertEqual(2+2, 4) + } +} \ No newline at end of file