├── .cursorrules ├── .github └── workflows │ └── release.yml ├── .gitignore ├── .mise.toml ├── .package.resolved ├── ExportOptions.plist ├── LICENSE ├── Project.swift ├── README.md ├── README.zh-CN.md ├── Screenshots └── main-20250913-220809.png ├── Tuist.swift ├── Tuist └── Signing │ └── TypeSwitch.entitlements └── TypeSwitch ├── Resources ├── Assets.xcassets │ ├── AppIcon.appiconset │ │ ├── Contents.json │ │ ├── icon_128x128.png │ │ ├── icon_128x128@2x.png │ │ ├── icon_16x16.png │ │ ├── icon_16x16@2x.png │ │ ├── icon_256x256.png │ │ ├── icon_256x256@2x.png │ │ ├── icon_32x32.png │ │ ├── icon_32x32@2x.png │ │ ├── icon_512x512.png │ │ └── icon_512x512@2x.png │ └── Contents.json ├── Base.lproj │ └── Localizable.strings ├── Preview Content │ └── Preview Assets.xcassets │ │ └── Contents.json ├── en.lproj │ └── Localizable.strings ├── zh-Hans.lproj │ └── Localizable.strings └── zh-Hant.lproj │ └── Localizable.strings └── Sources ├── App └── TypeSwitchApp.swift ├── Core ├── Extensions │ └── Defaults+Extensions.swift └── Models │ ├── AppInfo.swift │ ├── InputMethod.swift │ └── InputSourceProperties.swift ├── Services ├── AppManagement │ ├── AppInfoService.swift │ └── AppListService.swift ├── InputMethod │ ├── InputMethodManager.swift │ └── InputMethodService.swift └── System │ └── LaunchAtLoginService.swift └── UI └── Views └── MenuBar ├── AppInfoView.swift ├── AppRowView.swift ├── ConfiguredAppsView.swift ├── MenuBarView.swift ├── RunningAppsView.swift └── SettingsView.swift /.cursorrules: -------------------------------------------------------------------------------- 1 | 您是一位专注于 SwiftUI macOS 开发的输入法自动切换工具。请遵循以下准则提供协助: 2 | 3 | 项目依赖管理: 4 | - 使用 Tuist 管理项目结构和依赖 5 | - 支持动态配置和环境变量读取 6 | - 遵循 Tuist 的项目组织方式 7 | - 使用 Environment 类型处理环境变量 8 | 9 | 系统兼容性要求: 10 | - 最低支持 macOS 13 Ventura 11 | - SwiftUI 要求 macOS 13 或更高版本 12 | - 优先使用 macOS 13+ 新特性优化性能 13 | - 确保项目的 Deployment Target 设置正确 14 | 15 | 设计规范: 16 | - 遵循 Apple Human Interface Guidelines 17 | - 保持界面整洁和简约 18 | - 确保视觉层次分明 19 | - 重视细节完整性 20 | - 优先使用新的设计规范和组件 21 | 22 | UI 设计准则: 23 | - 布局规范: 24 | * 合理使用留白 25 | * 保持内容和控件并排放置 26 | * 确保各元素对齐 27 | * 适配不同屏幕尺寸 28 | * 适当使用 Emoji 进行界面修饰和强调 29 | - 视觉设计: 30 | * 使用系统标准颜色 31 | * 支持浅色/深色模式 32 | * 确保文字和背景对比度 33 | * 使用系统字体 34 | - 交互设计: 35 | * 实现标准的交互方式 36 | * 提供清晰的反馈 37 | * 保持导航的一致性 38 | * 遵循系统常见的交互模式 39 | 40 | 第三方库使用规范: 41 | - Defaults 使用规范: 42 | * 使用 Defaults 管理所有 UserDefaults 存储 43 | * 统一在扩展中声明所有 Keys 44 | * 确保 Key 名称符合 ASCII 且不以 @ 开头 45 | * 为每个 Key 提供默认值 46 | - SwiftUIX 优先级: 47 | * 优先使用 SwiftUIX 提供的组件和扩展 48 | * 避免重复实现已有功能 49 | * 使用其提供的性能优化特性 50 | * 遵循其推荐的最佳实践 51 | - SwifterSwift 使用: 52 | * 优先使用其提供的扩展方法 53 | * 利用其内置的语法糖优化代码 54 | * 使用其性能优化的实现方案 55 | 56 | 必要的第三方依赖: 57 | - Defaults: https://github.com/sindresorhus/Defaults 58 | - SwiftUIX: https://github.com/SwiftUIX/SwiftUIX 59 | - SwifterSwift: https://github.com/SwifterSwift/SwifterSwift 60 | 61 | SwiftLint 代码规范: 62 | - 代码行长度限制:单行不超过 110 字符 63 | - 函数参数数量:不超过 5 个参数 64 | - 类型命名规范: 65 | * 最小长度 4 个字符 66 | * 最大长度 40 字符 67 | - 变量命名规范: 68 | * 使用驼峰命名法 69 | * 保持命名的语义化 70 | * 避免无意义的缩写 71 | - 代码格式规范: 72 | * 冒号规则:变量定义时紧跟变量名,后面加一个空格 73 | * 逗号规则:前不离身,后加空格 74 | * 控制语句:if、for、while 等条件不使用括号包裹 75 | * 条件返回:条件语句返回值需要换行 76 | * 空格使用:运算符前后必须有空格 77 | * 括号使用:左括号后和右括号前不能有空格 78 | 79 | 文档要求: 80 | - 为公开 API 添加文档注释 81 | - 标注第三方库使用说明 82 | - 记录兼容性考虑 83 | - 说明性能优化点 84 | - 使用 /// 格式的文档注释 85 | - 注释需要使用完整的句子 86 | - 中文注释需要使用空格分隔 87 | 88 | 错误处理: 89 | - 使用 Result 类型进行错误处理 90 | - 提供清晰的错误提示信息 91 | - 避免强制解包 92 | - 合理使用 try? 和 try! 93 | 94 | 性能优化: 95 | - 优先使用系统原生组件 96 | - 避免重复造轮子 97 | - 利用 SwiftUIX 的性能优化特性 98 | - 合理使用 SwifterSwift 的扩展功能 99 | 100 | 测试规范: 101 | - 单元测试覆盖核心功能 102 | - 使用 XCTest 框架 103 | - 测试方法名称清晰表达测试目的 104 | - 每个测试用例只测试一个功能点 105 | 106 | 软件执行刘 107 | 1. 选择 Xcode 工程文件 108 | 2. 点击从 Xcode 导出 Xcloc 文件临时目录 109 | 3. 读取 Xcloc 文件并展示在侧边栏 110 | 4. 侧边选择后显示全部的国际化字符串在图标 -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Build and Release 2 | 3 | on: 4 | push: 5 | tags: 6 | - 'v*' 7 | 8 | permissions: 9 | contents: write 10 | discussions: write 11 | 12 | jobs: 13 | build: 14 | name: Build and Release 15 | runs-on: macos-14 16 | 17 | steps: 18 | - name: Checkout 19 | uses: actions/checkout@v4 20 | with: 21 | fetch-depth: 0 22 | 23 | - name: Setup Xcode 24 | uses: maxim-lobanov/setup-xcode@v1 25 | with: 26 | xcode-version: '16.1' 27 | 28 | - name: Install Tuist 29 | run: | 30 | brew install tuist 31 | 32 | - name: Generate Xcode Project 33 | run: | 34 | # 生成基于时间戳的构建号(格式:YYYYMMDDHHmm) 35 | BUILD_NUMBER=$(date "+%Y%m%d%H%M") 36 | # 替换 Project.swift 中的占位符 37 | sed -i '' "s/@BUILD_NUMBER@/$BUILD_NUMBER/g" Project.swift 38 | echo "Build number set to: $BUILD_NUMBER" 39 | tuist generate --no-open 40 | 41 | - name: Build App 42 | run: | 43 | xcodebuild \ 44 | -workspace TypeSwitch.xcworkspace \ 45 | -scheme TypeSwitch \ 46 | -configuration Release \ 47 | -derivedDataPath ./DerivedData \ 48 | -arch arm64 -arch x86_64 \ 49 | clean build CODE_SIGN_IDENTITY="" CODE_SIGNING_REQUIRED=NO 50 | 51 | - name: Create DMG 52 | run: | 53 | brew install create-dmg 54 | create-dmg \ 55 | --volname "TypeSwitch" \ 56 | --window-size 500 300 \ 57 | --icon-size 100 \ 58 | --icon "TypeSwitch.app" 150 150 \ 59 | --app-drop-link 350 150 \ 60 | --no-internet-enable \ 61 | "TypeSwitch.dmg" \ 62 | "DerivedData/Build/Products/Release/TypeSwitch.app" 63 | 64 | - name: Generate Checksums 65 | run: | 66 | echo "### TypeSwitch ${{ github.ref_name }}" > checksums.txt 67 | echo "" >> checksums.txt 68 | echo "- Universal Binary (Apple Silicon + Intel)" >> checksums.txt 69 | echo "- macOS 13.0+" >> checksums.txt 70 | echo "" >> checksums.txt 71 | echo "### SHA-256 Checksums" >> checksums.txt 72 | echo "\`\`\`" >> checksums.txt 73 | shasum -a 256 TypeSwitch.dmg >> checksums.txt 74 | echo "\`\`\`" >> checksums.txt 75 | 76 | - name: Release 77 | uses: softprops/action-gh-release@v1 78 | if: startsWith(github.ref, 'refs/tags/v') 79 | with: 80 | files: | 81 | TypeSwitch.dmg 82 | checksums.txt 83 | body_path: checksums.txt 84 | draft: false 85 | prerelease: ${{ contains(github.ref, '-beta') || contains(github.ref, '-alpha') }} 86 | generate_release_notes: true 87 | env: 88 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | ### macOS ### 2 | # General 3 | .DS_Store 4 | .AppleDouble 5 | .LSOverride 6 | 7 | # Icon must end with two 8 | Icon 9 | 10 | # Thumbnails 11 | ._* 12 | 13 | # Files that might appear in the root of a volume 14 | .DocumentRevisions-V100 15 | .fseventsd 16 | .Spotlight-V100 17 | .TemporaryItems 18 | .Trashes 19 | .VolumeIcon.icns 20 | .com.apple.timemachine.donotpresent 21 | 22 | # Directories potentially created on remote AFP share 23 | .AppleDB 24 | .AppleDesktop 25 | Network Trash Folder 26 | Temporary Items 27 | .apdisk 28 | 29 | ### Xcode ### 30 | # Xcode 31 | # 32 | # gitignore contributors: remember to update Global/Xcode.gitignore, Objective-C.gitignore & Swift.gitignore 33 | 34 | ## User settings 35 | xcuserdata/ 36 | 37 | ## compatibility with Xcode 8 and earlier (ignoring not required starting Xcode 9) 38 | *.xcscmblueprint 39 | *.xccheckout 40 | 41 | ## compatibility with Xcode 3 and earlier (ignoring not required starting Xcode 4) 42 | build/ 43 | DerivedData/ 44 | *.moved-aside 45 | *.pbxuser 46 | !default.pbxuser 47 | *.mode1v3 48 | !default.mode1v3 49 | *.mode2v3 50 | !default.mode2v3 51 | *.perspectivev3 52 | !default.perspectivev3 53 | 54 | ### Xcode Patch ### 55 | *.xcodeproj/* 56 | !*.xcodeproj/project.pbxproj 57 | !*.xcodeproj/xcshareddata/ 58 | !*.xcworkspace/contents.xcworkspacedata 59 | /*.gcno 60 | 61 | ### Projects ### 62 | *.xcodeproj 63 | *.xcworkspace 64 | 65 | ### Tuist derived files ### 66 | graph.dot 67 | Derived/ 68 | 69 | ### Tuist managed dependencies ### 70 | Tuist/.build -------------------------------------------------------------------------------- /.mise.toml: -------------------------------------------------------------------------------- 1 | [tools] 2 | tuist = "4.36.0" 3 | -------------------------------------------------------------------------------- /.package.resolved: -------------------------------------------------------------------------------- 1 | { 2 | "originHash" : "a67c807fa33f88d371e742a7da5a6b449ad93bf80f40f66525488d8a70210b33", 3 | "pins" : [ 4 | { 5 | "identity" : "defaults", 6 | "kind" : "remoteSourceControl", 7 | "location" : "https://github.com/sindresorhus/Defaults", 8 | "state" : { 9 | "revision" : "3efef5a28ebdbbe922d4a2049493733ed14475a6", 10 | "version" : "7.3.1" 11 | } 12 | }, 13 | { 14 | "identity" : "swifterswift", 15 | "kind" : "remoteSourceControl", 16 | "location" : "https://github.com/SwifterSwift/SwifterSwift", 17 | "state" : { 18 | "revision" : "5d948a15446bddb6c367bc7e7100cfcbaa0e9b57", 19 | "version" : "8.0.0" 20 | } 21 | }, 22 | { 23 | "identity" : "swiftuix", 24 | "kind" : "remoteSourceControl", 25 | "location" : "https://github.com/SwiftUIX/SwiftUIX", 26 | "state" : { 27 | "revision" : "e984fd2e08140ad5a95d084be38fe02b774bc15d", 28 | "version" : "0.2.3" 29 | } 30 | } 31 | ], 32 | "version" : 3 33 | } 34 | -------------------------------------------------------------------------------- /ExportOptions.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | method 6 | developer-id 7 | signingStyle 8 | automatic 9 | compileBitcode 10 | 11 | 12 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 TypeSwitch Contributors 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Project.swift: -------------------------------------------------------------------------------- 1 | import ProjectDescription 2 | 3 | // MARK: - Version 4 | let appVersion = "0.4" // 应用版本号 5 | let buildVersion = "@BUILD_NUMBER@" // 构建版本号占位符,会被 GitHub Actions 替换 6 | 7 | let project = Project( 8 | name: "TypeSwitch", 9 | options: .options( 10 | defaultKnownRegions: ["zh-Hans", "zh-Hant", "en"], 11 | developmentRegion: "zh-Hans" 12 | ), 13 | packages: [ 14 | .remote(url: "https://github.com/SwiftUIX/SwiftUIX", requirement: .upToNextMajor(from: "0.2.3")), 15 | .remote(url: "https://github.com/SwifterSwift/SwifterSwift", requirement: .upToNextMajor(from: "8.0.0")), 16 | .remote(url: "https://github.com/sindresorhus/Defaults", requirement: .upToNextMajor(from: "7.3.1")) 17 | ], 18 | settings: .settings( 19 | base: [ 20 | "SWIFT_VERSION": SettingValue(stringLiteral: "5.9"), 21 | "DEVELOPMENT_LANGUAGE": SettingValue(stringLiteral: "zh-Hans"), 22 | "SWIFT_EMIT_LOC_STRINGS": SettingValue(stringLiteral: "YES"), 23 | "MARKETING_VERSION": SettingValue(stringLiteral: appVersion), 24 | "CURRENT_PROJECT_VERSION": SettingValue(stringLiteral: buildVersion), 25 | // 宏定义支持 26 | "SWIFT_STRICT_CONCURRENCY": SettingValue(stringLiteral: "complete"), 27 | "ENABLE_MACROS": SettingValue(stringLiteral: "YES"), 28 | "SWIFT_MACRO_DEBUGGING": SettingValue(stringLiteral: "YES") 29 | ], 30 | configurations: [ 31 | .debug(name: "Debug"), 32 | .release(name: "Release") 33 | ] 34 | ), 35 | targets: [ 36 | .target( 37 | name: "TypeSwitch", 38 | destinations: .macOS, 39 | product: .app, 40 | bundleId: "top.ygsgdbd.TypeSwitch", 41 | deploymentTargets: .macOS("13.0"), 42 | infoPlist: .extendingDefault(with: [ 43 | "LSUIElement": true, // 设置为纯菜单栏应用 44 | "CFBundleDevelopmentRegion": "zh-Hans", // 设置默认开发区域为简体中文 45 | "CFBundleLocalizations": ["zh-Hans", "zh-Hant", "en"], // 支持的语言列表 46 | "AppleLanguages": ["zh-Hans"], // 设置默认语言为简体中文 47 | "NSHumanReadableCopyright": "Copyright © 2024 ygsgdbd. All rights reserved.", 48 | "LSApplicationCategoryType": "public.app-category.utilities", 49 | "LSMinimumSystemVersion": "13.0", 50 | "CFBundleShortVersionString": .string(appVersion), // 市场版本号 51 | "CFBundleVersion": .string(buildVersion) // 构建版本号 52 | ]), 53 | sources: ["TypeSwitch/Sources/**"], 54 | resources: ["TypeSwitch/Resources/**"], 55 | entitlements: .file(path: "Tuist/Signing/TypeSwitch.entitlements"), 56 | dependencies: [ 57 | .package(product: "SwiftUIX"), 58 | .package(product: "SwifterSwift"), 59 | .package(product: "Defaults") 60 | ], 61 | settings: .settings( 62 | base: [ 63 | // 宏定义支持 64 | "SWIFT_STRICT_CONCURRENCY": "complete", 65 | "ENABLE_MACROS": "YES", 66 | "SWIFT_MACRO_DEBUGGING": "YES", 67 | "SWIFT_MACRO_EXPANSION": "YES" 68 | ], 69 | configurations: [ 70 | .debug(name: "Debug"), 71 | .release(name: "Release") 72 | ] 73 | ) 74 | ) 75 | ] 76 | ) -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # TypeSwitch 🔄 2 | 3 |
4 | 5 | [![Swift](https://img.shields.io/badge/Swift-5.9-orange.svg)](https://swift.org) 6 | [![Platform](https://img.shields.io/badge/Platform-macOS%2013.0+-blue.svg)](https://www.apple.com/macos/) 7 | [![License](https://img.shields.io/badge/License-MIT-green.svg)](LICENSE) 8 | [![Homebrew](https://img.shields.io/badge/homebrew-available-brightgreen.svg)](https://github.com/ygsgdbd/homebrew-tap) 9 | [![Release](https://img.shields.io/github/v/release/ygsgdbd/TypeSwitch?include_prereleases)](https://github.com/ygsgdbd/TypeSwitch/releases) 10 | [![PRs Welcome](https://img.shields.io/badge/PRs-welcome-brightgreen.svg)](https://github.com/ygsgdbd/TypeSwitch/pulls) 11 | 12 | [🇨🇳 中文文档](README.zh-CN.md) | [📦 Installation](#-installation) | [📖 Usage](#-usage) 13 | 14 |
15 | 16 | TypeSwitch is a modern macOS menu bar application built with SwiftUI for automatically switching input methods across different applications. It runs quietly in the background and provides an elegant menu bar interface for managing input method preferences per application. 17 | 18 | ✨ **Featuring macOS 26 Liquid Glass Design** - Experience the beautiful translucent interface with cutting-edge macOS 26 liquid glass effects, creating an elegant and modern user experience that seamlessly integrates with your system. 19 | 20 | ## ✨ Screenshots 21 | 22 |
23 | Main Interface 24 |

Menu Bar Interface - Set default input method for different applications

25 |
26 | 27 | 28 | ## 🎯 Features 29 | 30 | - 🔄 **Auto Switch**: Automatically switch to preset input methods when changing applications 31 | - 📱 **Menu Bar Interface**: Clean and intuitive menu bar interface for easy access 32 | - 🎯 **Per-App Settings**: Set independent input method preferences for each application 33 | - 🚀 **Auto Start**: Support automatic startup at login 34 | - 📋 **Running Apps**: View and configure currently running applications 35 | - ⚙️ **Installed Apps**: Manage input method settings for all installed applications 36 | - ⌨️ **Keyboard Shortcuts**: 37 | - `⌘ + Q` - Quit application 38 | - 🔗 **Quick Links**: Direct access to GitHub repository and latest releases 39 | 40 | ## 🔧 System Requirements 41 | 42 | - 🖥 macOS 13.0 or later (compatible up to macOS 26) 43 | - 🔐 Accessibility permission for monitoring application switches 44 | - ⌨️ Input method switching permission 45 | 46 | ## 📦 Installation 47 | 48 | ### 🍺 Option 1: Homebrew 49 | 50 | ```bash 51 | brew install ygsgdbd/tap/typeswitch --cask 52 | ``` 53 | 54 | ### 💾 Option 2: Manual Installation 55 | 56 | 1. Download the latest version from [Releases](https://github.com/ygsgdbd/TypeSwitch/releases) 57 | 2. Drag the application to Applications folder 58 | 3. Grant necessary system permissions on first launch 59 | 60 | ## 📖 Usage 61 | 62 | 1. After launching, the app icon (⌨️) appears in the menu bar 63 | 2. Click the menu bar icon to open the dropdown menu 64 | 3. The menu shows two sections: 65 | - **Running Apps**: Currently running applications 66 | - **Configured Apps**: Applications with input method settings 67 | 4. Click on any application to set its input method: 68 | - Select "Default" to use system default input method 69 | - Select any installed input method to set as default for that app 70 | 5. The input method will automatically switch when you switch to that application 71 | 6. Use the settings section to enable auto-launch at login 72 | 73 | ## 🔒 Security 74 | 75 | TypeSwitch takes user privacy and security seriously: 76 | 77 | - 🏠 All data is stored locally, nothing is uploaded to the network 78 | - 🚫 No user information or usage data is collected 79 | - 📖 Source code is fully open source and welcome for review 80 | - 🛡️ Uses Swift's built-in security features 81 | - 🔐 Permission usage: 82 | - Accessibility: Only used for detecting application switches 83 | - Input method switching: Only used for switching input methods 84 | - Auto-start: Only used for launching at startup 85 | 86 | ## Dependencies 87 | 88 | This project uses the following open source libraries: 89 | 90 | - [Defaults](https://github.com/sindresorhus/Defaults) (7.3.1) - For persistent settings storage 91 | - [SwiftUIX](https://github.com/SwiftUIX/SwiftUIX) (0.2.3) - Provides additional SwiftUI components 92 | - [SwifterSwift](https://github.com/SwifterSwift/SwifterSwift) (8.0.0) - Swift native extensions 93 | 94 | Build tools: 95 | - [Tuist](https://github.com/tuist/tuist) - For project generation and management 96 | 97 | ## Development 98 | 99 | ### Requirements 100 | 101 | - Xcode 15.0+ 102 | - Swift 5.9+ 103 | - macOS 13.0+ (compatible up to macOS 26) 104 | - [Tuist](https://github.com/tuist/tuist) 105 | 106 | ### Build Steps 107 | 108 | 1. Install [Tuist](https://github.com/tuist/tuist#install-▶️) 109 | 110 | 2. Clone repository 111 | ```bash 112 | git clone https://github.com/ygsgdbd/TypeSwitch.git 113 | cd TypeSwitch 114 | ``` 115 | 116 | 3. Generate Xcode project 117 | ```bash 118 | tuist generate 119 | ``` 120 | 121 | 4. Open and build 122 | ```bash 123 | open TypeSwitch.xcworkspace 124 | ``` 125 | 126 | ### Automated Build and Release 127 | 128 | This project uses GitHub Actions for automated building and releasing: 129 | 130 | 1. Push a new version tag to trigger automatic build: 131 | ```bash 132 | git tag v1.0.0 133 | git push origin v1.0.0 134 | ``` 135 | 136 | 2. GitHub Actions will automatically: 137 | - Build the application 138 | - Create DMG package 139 | - Release new version 140 | - Generate changelog 141 | 142 | 3. Build artifacts can be downloaded from [Releases](https://github.com/ygsgdbd/TypeSwitch/releases) 143 | 144 | ### Project Structure 145 | 146 | ``` 147 | TypeSwitch/ 148 | ├── Project.swift # Tuist project configuration 149 | ├── Tuist/ # Tuist configuration files 150 | │ └── Signing/ 151 | │ └── TypeSwitch.entitlements 152 | ├── TypeSwitch/ # Main source code 153 | │ ├── Sources/ 154 | │ │ ├── App/ # App entry point 155 | │ │ │ └── TypeSwitchApp.swift 156 | │ │ ├── Core/ # Core models and extensions 157 | │ │ │ ├── Models/ 158 | │ │ │ │ ├── AppInfo.swift 159 | │ │ │ │ ├── InputMethod.swift 160 | │ │ │ │ └── InputSourceProperties.swift 161 | │ │ │ └── Extensions/ 162 | │ │ │ └── Defaults+Extensions.swift 163 | │ │ ├── Services/ # Business logic services 164 | │ │ │ ├── AppManagement/ 165 | │ │ │ │ ├── AppInfoService.swift 166 | │ │ │ │ └── AppListService.swift 167 | │ │ │ ├── InputMethod/ 168 | │ │ │ │ ├── InputMethodManager.swift 169 | │ │ │ │ └── InputMethodService.swift 170 | │ │ │ └── System/ 171 | │ │ │ └── LaunchAtLoginService.swift 172 | │ │ └── UI/ # User interface 173 | │ │ └── Views/ 174 | │ │ └── MenuBar/ # Menu bar interface 175 | │ │ ├── MenuBarView.swift 176 | │ │ ├── RunningAppsView.swift 177 | │ │ ├── ConfiguredAppsView.swift 178 | │ │ ├── AppRowView.swift 179 | │ │ ├── SettingsView.swift 180 | │ │ └── AppInfoView.swift 181 | │ └── Resources/ # App resources 182 | │ ├── Assets.xcassets/ # App icons and images 183 | │ └── *.lproj/ # Localization files 184 | └── Screenshots/ # App screenshots 185 | ``` 186 | 187 | ## Contributing 188 | 189 | Pull requests and issues are welcome. Before submitting a PR, please ensure: 190 | 191 | 1. Code follows project style 192 | 2. Necessary tests are added 193 | 3. Documentation is updated 194 | 195 | ## License 196 | 197 | This project is licensed under the MIT License. See [LICENSE](LICENSE) file for details. 198 | 199 | ## Acknowledgments 🙏 200 | 201 | This project was inspired by and received help from: 202 | - [SwitchKey](https://github.com/itsuhane/SwitchKey) - An excellent input method switcher that provided valuable reference 203 | - Swift and SwiftUI community 204 | - All contributors and users who provided feedback 205 | -------------------------------------------------------------------------------- /README.zh-CN.md: -------------------------------------------------------------------------------- 1 | # TypeSwitch 🔄 2 | 3 |
4 | 5 | [![Swift](https://img.shields.io/badge/Swift-5.9-orange.svg)](https://swift.org) 6 | [![Platform](https://img.shields.io/badge/Platform-macOS%2013.0+-blue.svg)](https://www.apple.com/macos/) 7 | [![License](https://img.shields.io/badge/License-MIT-green.svg)](LICENSE) 8 | [![Homebrew](https://img.shields.io/badge/homebrew-available-brightgreen.svg)](https://github.com/ygsgdbd/homebrew-tap) 9 | [![Release](https://img.shields.io/github/v/release/ygsgdbd/TypeSwitch?include_prereleases)](https://github.com/ygsgdbd/TypeSwitch/releases) 10 | [![PRs Welcome](https://img.shields.io/badge/PRs-welcome-brightgreen.svg)](https://github.com/ygsgdbd/TypeSwitch/pulls) 11 | 12 | [🇺🇸 English](README.md) | [📦 安装方法](#安装方法) | [📖 使用说明](#使用说明) 13 | 14 |
15 | 16 | TypeSwitch 是一个基于 SwiftUI 开发的现代 macOS 菜单栏应用,用于自动切换不同应用的输入法。应用在后台静默运行,通过优雅的菜单栏界面为每个应用管理输入法偏好设置。 17 | 18 | ✨ **支持 macOS 26 液态玻璃设计** - 体验美观的半透明界面,采用先进的 macOS 26 液态玻璃效果,创造优雅现代的用户体验,与系统完美融合。 19 | 20 | ## 截图预览 21 | 22 |
23 | 主界面 24 |

菜单栏界面 - 为不同应用设置默认输入法

25 |
26 | 27 | 28 | ## 功能特点 29 | 30 | - 🔄 自动切换:在切换应用时自动切换到预设的输入法 31 | - 📱 菜单栏界面:简洁直观的菜单栏界面,方便快速访问 32 | - 🎯 按应用设置:为每个应用设置独立的输入法偏好 33 | - 🚀 开机启动:支持开机自动启动 34 | - 📋 运行中应用:查看和配置当前运行的应用 35 | - ⚙️ 已安装应用:管理所有已安装应用的输入法设置 36 | - ⌨️ 快捷键支持: 37 | - `⌘ + Q` - 退出应用 38 | - 🔗 快速链接:直接访问 GitHub 仓库和最新发布版本 39 | 40 | ## 系统要求 41 | 42 | - 🖥 macOS 13.0 或更高版本(兼容至 macOS 26) 43 | - 🔐 需要辅助功能权限用于监控应用切换 44 | - ⌨️ 需要输入法切换权限 45 | 46 | ## 安装方法 47 | 48 | ### 🍺 方式一:Homebrew 49 | 50 | ```bash 51 | # 添加 tap 52 | brew tap ygsgdbd/tap 53 | 54 | # 安装应用 55 | brew install --cask typeswitch 56 | ``` 57 | 58 | ### 💾 方式二:手动安装 59 | 60 | 1. 从 [Releases](https://github.com/ygsgdbd/TypeSwitch/releases) 下载最新版本 61 | 2. 将应用拖入应用程序文件夹 62 | 3. 首次启动时授予必要系统权限 63 | 64 | ## 使用���明 65 | 66 | 1. 启动后,应用图标(⌨️)会出现在菜单栏中 67 | 2. 点击菜单栏图标打开下拉菜单 68 | 3. 菜单显示两个部分: 69 | - **运行中应用**:当前正在运行的应用 70 | - **已配置应用**:已设置输入法的应用 71 | 4. 点击任意应用来设置其输入法: 72 | - 选择"默认"使用系统默认输入法 73 | - 选择任意已安装的输入法作为该应用的默认输入法 74 | 5. 当切换到该应用时,输入法会自动切换 75 | 6. 使用设置部分可以启用开机自动启动 76 | 77 | ## 🔒 安全 78 | 79 | TypeSwitch 非常重视用户隐私和安全: 80 | 81 | - 🏠 所有数据本地存储,不会上传网络 82 | - 🚫 不收集任何用户信息或使用数据 83 | - 📖 源代码完全开放,欢迎审查 84 | - 🛡️ 使用 Swift 内置的安全特性 85 | - 🔐 权限使用说明: 86 | - 辅助功能:仅用于检测应用切换 87 | - 输入法切换:仅用于切换输入法 88 | - 自动启动:仅用于开机启动 89 | 90 | ## 依赖说明 91 | 92 | 本项目使用以下开源库: 93 | 94 | - [Defaults](https://github.com/sindresorhus/Defaults) (7.3.1) - 用于持久化存储设置 95 | - [SwiftUIX](https://github.com/SwiftUIX/SwiftUIX) (0.2.3) - 提供额外的 SwiftUI 组件 96 | - [SwifterSwift](https://github.com/SwifterSwift/SwifterSwift) (8.0.0) - Swift 原生扩展 97 | 98 | 构建工具: 99 | - [Tuist](https://github.com/tuist/tuist) - 用于项目生成和管理 100 | 101 | ## 开发相关 102 | 103 | ### 环境要求 104 | 105 | - Xcode 15.0+ 106 | - Swift 5.9+ 107 | - macOS 13.0+(兼容至 macOS 26) 108 | - [Tuist](https://github.com/tuist/tuist) 109 | 110 | ### 构建步骤 111 | 112 | 1. 安装 [Tuist](https://github.com/tuist/tuist#install-▶️) 113 | 114 | 2. 克隆仓库 115 | ```bash 116 | git clone https://github.com/ygsgdbd/TypeSwitch.git 117 | cd TypeSwitch 118 | ``` 119 | 120 | 3. 生成 Xcode 项目 121 | ```bash 122 | tuist generate 123 | ``` 124 | 125 | 4. 打开项目并构建 126 | ```bash 127 | open TypeSwitch.xcworkspace 128 | ``` 129 | 130 | ### 自动构建和发布 131 | 132 | 本项目使用 GitHub Actions 进行自动构建和发布: 133 | 134 | 1. 推送新的版本标签会触发自动构建: 135 | ```bash 136 | git tag v1.0.0 137 | git push origin v1.0.0 138 | ``` 139 | 140 | 2. GitHub Actions 会自动: 141 | - 构建应用 142 | - 创建 DMG 安装包 143 | - 发布新版本 144 | - 生成更新日志 145 | 146 | 3. 构建产物可在 [Releases](https://github.com/ygsgdbd/TypeSwitch/releases) 页面下载 147 | 148 | ### 项目结构 149 | 150 | ``` 151 | TypeSwitch/ 152 | ├── Project.swift # Tuist 项目配置 153 | ├── Tuist/ # Tuist 配置文件 154 | │ └── Signing/ 155 | │ └── TypeSwitch.entitlements 156 | ├── TypeSwitch/ # 主要源代码 157 | │ ├── Sources/ 158 | │ │ ├── App/ # 应用入口 159 | │ │ │ └── TypeSwitchApp.swift 160 | │ │ ├── Core/ # 核心模型和扩展 161 | │ │ │ ├── Models/ 162 | │ │ │ │ ├── AppInfo.swift 163 | │ │ │ │ ├── InputMethod.swift 164 | │ │ │ │ └── InputSourceProperties.swift 165 | │ │ │ └── Extensions/ 166 | │ │ │ └── Defaults+Extensions.swift 167 | │ │ ├── Services/ # 业务逻辑服务 168 | │ │ │ ├── AppManagement/ 169 | │ │ │ │ ├── AppInfoService.swift 170 | │ │ │ │ └── AppListService.swift 171 | │ │ │ ├── InputMethod/ 172 | │ │ │ │ ├── InputMethodManager.swift 173 | │ │ │ │ └── InputMethodService.swift 174 | │ │ │ └── System/ 175 | │ │ │ └── LaunchAtLoginService.swift 176 | │ │ └── UI/ # 用户界面 177 | │ │ └── Views/ 178 | │ │ └── MenuBar/ # 菜单栏界面 179 | │ │ ├── MenuBarView.swift 180 | │ │ ├── RunningAppsView.swift 181 | │ │ ├── ConfiguredAppsView.swift 182 | │ │ ├── AppRowView.swift 183 | │ │ ├── SettingsView.swift 184 | │ │ └── AppInfoView.swift 185 | │ └── Resources/ # 应用资源 186 | │ ├── Assets.xcassets/ # 应用图标和图片 187 | │ └── *.lproj/ # 本地化文件 188 | └── Screenshots/ # 应用截图 189 | ``` 190 | 191 | ## 贡献指南 192 | 193 | 欢迎提交 Pull Request 和创建 Issue,在提交 PR 之前,请确保: 194 | 195 | 1. 代码符合项目的代码风格 196 | 2. 添加了必要的测试 197 | 3. 更新了相关文档 198 | 199 | ## 许可证 200 | 201 | 本项目基于 MIT 许可证开源。详见 [LICENSE](LICENSE) 文件。 202 | 203 | ## 致谢 🙏 204 | 205 | 本项目受到以下项目和社区的启发和帮助: 206 | - [SwitchKey](https://github.com/itsuhane/SwitchKey) - 一个优秀的输入法切换工具,为本项目提供了宝贵的参考 207 | - Swift 和 SwiftUI 社区 208 | - 所有提供反馈和贡献者和用户 209 | -------------------------------------------------------------------------------- /Screenshots/main-20250913-220809.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ygsgdbd/TypeSwitch/c0a2ee903867a36acde57177df756aa672265715/Screenshots/main-20250913-220809.png -------------------------------------------------------------------------------- /Tuist.swift: -------------------------------------------------------------------------------- 1 | import ProjectDescription 2 | 3 | let tuist = Tuist( 4 | // Create an account with "tuist auth" and a project with "tuist project create" 5 | // then uncomment the section below and set the project full-handle. 6 | // * Read more: https://docs.tuist.io/guides/quick-start/gather-insights 7 | // 8 | // fullHandle: "{account_handle}/{project_handle}", 9 | ) 10 | -------------------------------------------------------------------------------- /Tuist/Signing/TypeSwitch.entitlements: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | com.apple.security.automation.apple-events 6 | 7 | com.apple.security.temporary-exception.apple-events 8 | 9 | com.apple.systemevents 10 | 11 | 12 | -------------------------------------------------------------------------------- /TypeSwitch/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "icon_16x16.png", 5 | "idiom" : "mac", 6 | "scale" : "1x", 7 | "size" : "16x16" 8 | }, 9 | { 10 | "filename" : "icon_16x16@2x.png", 11 | "idiom" : "mac", 12 | "scale" : "2x", 13 | "size" : "16x16" 14 | }, 15 | { 16 | "filename" : "icon_32x32.png", 17 | "idiom" : "mac", 18 | "scale" : "1x", 19 | "size" : "32x32" 20 | }, 21 | { 22 | "filename" : "icon_32x32@2x.png", 23 | "idiom" : "mac", 24 | "scale" : "2x", 25 | "size" : "32x32" 26 | }, 27 | { 28 | "filename" : "icon_128x128.png", 29 | "idiom" : "mac", 30 | "scale" : "1x", 31 | "size" : "128x128" 32 | }, 33 | { 34 | "filename" : "icon_128x128@2x.png", 35 | "idiom" : "mac", 36 | "scale" : "2x", 37 | "size" : "128x128" 38 | }, 39 | { 40 | "filename" : "icon_256x256.png", 41 | "idiom" : "mac", 42 | "scale" : "1x", 43 | "size" : "256x256" 44 | }, 45 | { 46 | "filename" : "icon_256x256@2x.png", 47 | "idiom" : "mac", 48 | "scale" : "2x", 49 | "size" : "256x256" 50 | }, 51 | { 52 | "filename" : "icon_512x512.png", 53 | "idiom" : "mac", 54 | "scale" : "1x", 55 | "size" : "512x512" 56 | }, 57 | { 58 | "filename" : "icon_512x512@2x.png", 59 | "idiom" : "mac", 60 | "scale" : "2x", 61 | "size" : "512x512" 62 | } 63 | ], 64 | "info" : { 65 | "author" : "xcode", 66 | "version" : 1 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /TypeSwitch/Resources/Assets.xcassets/AppIcon.appiconset/icon_128x128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ygsgdbd/TypeSwitch/c0a2ee903867a36acde57177df756aa672265715/TypeSwitch/Resources/Assets.xcassets/AppIcon.appiconset/icon_128x128.png -------------------------------------------------------------------------------- /TypeSwitch/Resources/Assets.xcassets/AppIcon.appiconset/icon_128x128@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ygsgdbd/TypeSwitch/c0a2ee903867a36acde57177df756aa672265715/TypeSwitch/Resources/Assets.xcassets/AppIcon.appiconset/icon_128x128@2x.png -------------------------------------------------------------------------------- /TypeSwitch/Resources/Assets.xcassets/AppIcon.appiconset/icon_16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ygsgdbd/TypeSwitch/c0a2ee903867a36acde57177df756aa672265715/TypeSwitch/Resources/Assets.xcassets/AppIcon.appiconset/icon_16x16.png -------------------------------------------------------------------------------- /TypeSwitch/Resources/Assets.xcassets/AppIcon.appiconset/icon_16x16@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ygsgdbd/TypeSwitch/c0a2ee903867a36acde57177df756aa672265715/TypeSwitch/Resources/Assets.xcassets/AppIcon.appiconset/icon_16x16@2x.png -------------------------------------------------------------------------------- /TypeSwitch/Resources/Assets.xcassets/AppIcon.appiconset/icon_256x256.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ygsgdbd/TypeSwitch/c0a2ee903867a36acde57177df756aa672265715/TypeSwitch/Resources/Assets.xcassets/AppIcon.appiconset/icon_256x256.png -------------------------------------------------------------------------------- /TypeSwitch/Resources/Assets.xcassets/AppIcon.appiconset/icon_256x256@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ygsgdbd/TypeSwitch/c0a2ee903867a36acde57177df756aa672265715/TypeSwitch/Resources/Assets.xcassets/AppIcon.appiconset/icon_256x256@2x.png -------------------------------------------------------------------------------- /TypeSwitch/Resources/Assets.xcassets/AppIcon.appiconset/icon_32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ygsgdbd/TypeSwitch/c0a2ee903867a36acde57177df756aa672265715/TypeSwitch/Resources/Assets.xcassets/AppIcon.appiconset/icon_32x32.png -------------------------------------------------------------------------------- /TypeSwitch/Resources/Assets.xcassets/AppIcon.appiconset/icon_32x32@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ygsgdbd/TypeSwitch/c0a2ee903867a36acde57177df756aa672265715/TypeSwitch/Resources/Assets.xcassets/AppIcon.appiconset/icon_32x32@2x.png -------------------------------------------------------------------------------- /TypeSwitch/Resources/Assets.xcassets/AppIcon.appiconset/icon_512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ygsgdbd/TypeSwitch/c0a2ee903867a36acde57177df756aa672265715/TypeSwitch/Resources/Assets.xcassets/AppIcon.appiconset/icon_512x512.png -------------------------------------------------------------------------------- /TypeSwitch/Resources/Assets.xcassets/AppIcon.appiconset/icon_512x512@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ygsgdbd/TypeSwitch/c0a2ee903867a36acde57177df756aa672265715/TypeSwitch/Resources/Assets.xcassets/AppIcon.appiconset/icon_512x512@2x.png -------------------------------------------------------------------------------- /TypeSwitch/Resources/Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /TypeSwitch/Resources/Base.lproj/Localizable.strings: -------------------------------------------------------------------------------- 1 | // 应用信息 2 | "app.about" = "关于 APP %@"; 3 | "app.github_repository" = "打开 GitHub 仓库"; 4 | "app.latest_release" = "最新 Release"; 5 | 6 | // 菜单 7 | "menu.quit" = "退出"; 8 | 9 | // 设置 10 | "settings.general.auto_launch" = "开机启动"; 11 | 12 | // 应用列表 13 | "apps.section.running_count" = "正在运行 (%d)"; 14 | "apps.section.configured_count" = "已配置 (%d)"; 15 | 16 | // 输入法相关 17 | "input_method.default_option" = "--"; 18 | 19 | // 错误信息 20 | "error.get_input_methods_failed" = "获取输入法列表失败"; 21 | "error.input_method_not_found" = "找不到指定的输入法: %@"; 22 | "error.input_method_not_enabled" = "输入法未启用: %@"; 23 | "error.switch_input_method_failed" = "切换输入法失败: %@"; 24 | "error.get_current_input_method_failed" = "获取当前输入法失败"; -------------------------------------------------------------------------------- /TypeSwitch/Resources/Preview Content/Preview Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /TypeSwitch/Resources/en.lproj/Localizable.strings: -------------------------------------------------------------------------------- 1 | // App Information 2 | "app.about" = "About APP %@"; 3 | "app.github_repository" = "Open GitHub Repository"; 4 | "app.latest_release" = "Latest Release"; 5 | 6 | // Menu 7 | "menu.quit" = "Quit"; 8 | 9 | // Settings 10 | "settings.general.auto_launch" = "Launch at Login"; 11 | 12 | // App List 13 | "apps.section.running_count" = "Running (%d)"; 14 | "apps.section.configured_count" = "Configured (%d)"; 15 | 16 | // Input Method Related 17 | "input_method.default_option" = "--"; 18 | 19 | // Error Messages 20 | "error.get_input_methods_failed" = "Failed to get input methods list"; 21 | "error.input_method_not_found" = "Input method not found: %@"; 22 | "error.input_method_not_enabled" = "Input method not enabled: %@"; 23 | "error.switch_input_method_failed" = "Failed to switch input method: %@"; 24 | "error.get_current_input_method_failed" = "Failed to get current input method"; -------------------------------------------------------------------------------- /TypeSwitch/Resources/zh-Hans.lproj/Localizable.strings: -------------------------------------------------------------------------------- 1 | // 应用信息 2 | "app.about" = "关于 APP %@"; 3 | "app.github_repository" = "打开 GitHub 仓库"; 4 | "app.latest_release" = "最新 Release"; 5 | 6 | // 菜单 7 | "menu.quit" = "退出"; 8 | 9 | // 设置 10 | "settings.general.auto_launch" = "开机启动"; 11 | 12 | // 应用列表 13 | "apps.section.running_count" = "运行中 (%d)"; 14 | "apps.section.configured_count" = "已配置 (%d)"; 15 | 16 | // 输入法相关 17 | "input_method.default_option" = "--"; 18 | 19 | // 错误信息 20 | "error.get_input_methods_failed" = "获取输入法列表失败"; 21 | "error.input_method_not_found" = "找不到指定的输入法: %@"; 22 | "error.input_method_not_enabled" = "输入法未启用: %@"; 23 | "error.switch_input_method_failed" = "切换输入法失败: %@"; 24 | "error.get_current_input_method_failed" = "获取当前输入法失败"; -------------------------------------------------------------------------------- /TypeSwitch/Resources/zh-Hant.lproj/Localizable.strings: -------------------------------------------------------------------------------- 1 | // 應用程式資訊 2 | "app.about" = "關於 APP %@"; 3 | "app.github_repository" = "開啟 GitHub 倉庫"; 4 | "app.latest_release" = "最新 Release"; 5 | 6 | // 選單 7 | "menu.quit" = "結束"; 8 | 9 | // 設定 10 | "settings.general.auto_launch" = "開機啟動"; 11 | 12 | // 應用程式列表 13 | "apps.section.running_count" = "正在執行 (%d)"; 14 | "apps.section.configured_count" = "已設定 (%d)"; 15 | 16 | // 輸入法相關 17 | "input_method.default_option" = "--"; 18 | 19 | // 錯誤訊息 20 | "error.get_input_methods_failed" = "取得輸入法列表失敗"; 21 | "error.input_method_not_found" = "找不到指定的輸入法: %@"; 22 | "error.input_method_not_enabled" = "輸入法未啟用: %@"; 23 | "error.switch_input_method_failed" = "切換輸入法失敗: %@"; 24 | "error.get_current_input_method_failed" = "取得目前輸入法失敗"; -------------------------------------------------------------------------------- /TypeSwitch/Sources/App/TypeSwitchApp.swift: -------------------------------------------------------------------------------- 1 | import SwiftUIX 2 | import AppKit 3 | 4 | @main 5 | struct TypeSwitchApp: App { 6 | @StateObject private var inputMethodManager = InputMethodManager.shared 7 | 8 | var body: some Scene { 9 | MenuBarExtra { 10 | MenuBarView() 11 | .environmentObject(inputMethodManager) 12 | .task { 13 | await inputMethodManager.refreshAllData() 14 | } 15 | } label: { 16 | Image(systemName: .keyboard) 17 | } 18 | .menuBarExtraStyle(.menu) 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /TypeSwitch/Sources/Core/Extensions/Defaults+Extensions.swift: -------------------------------------------------------------------------------- 1 | @preconcurrency import Defaults 2 | import Foundation 3 | 4 | /// Defaults 扩展,统一管理所有应用设置 Keys 5 | extension Defaults.Keys { 6 | /// 应用输入法设置存储 Key 7 | /// 存储格式:`[String: String?]`,其中 String 是应用的 bundleId,String? 是输入法 ID(nil 表示不配置) 8 | nonisolated static let appInputMethodSettings = Key<[String: String?]>("appInputMethodSettings", default: [:], suite: .init(suiteName: "group.top.ygsgdbd.TypeSwitch")!) 9 | 10 | } 11 | -------------------------------------------------------------------------------- /TypeSwitch/Sources/Core/Models/AppInfo.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import SwiftUI 3 | 4 | /// 应用信息数据模型 5 | struct AppInfo: Identifiable, Sendable, Hashable { 6 | let bundleId: String 7 | let name: String 8 | private let iconPath: String 9 | 10 | var id: String { bundleId } 11 | 12 | @MainActor 13 | var icon: Image { 14 | Image(nsImage: NSWorkspace.shared.icon(forFile: iconPath)) 15 | } 16 | 17 | init(bundleId: String, name: String, iconPath: String) { 18 | self.bundleId = bundleId 19 | self.name = name 20 | self.iconPath = iconPath 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /TypeSwitch/Sources/Core/Models/InputMethod.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | /// 输入法数据模型 4 | struct InputMethod: Identifiable, Hashable, Codable { 5 | let id: String 6 | let name: String 7 | } 8 | -------------------------------------------------------------------------------- /TypeSwitch/Sources/Core/Models/InputSourceProperties.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | /// 输入源属性数据模型 4 | struct InputSourceProperties { 5 | let sourceID: String 6 | let sourceType: String 7 | let localizedName: String 8 | let isSelectable: Bool 9 | let isEnabled: Bool 10 | } 11 | -------------------------------------------------------------------------------- /TypeSwitch/Sources/Services/AppManagement/AppInfoService.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import AppKit 3 | import SwifterSwift 4 | 5 | /// 应用信息服务类,负责管理应用版本信息和相关链接 6 | enum AppInfoService { 7 | /// GitHub 仓库信息 8 | private static let githubRepository = "ygsgdbd/TypeSwitch" 9 | private static let githubBaseURL = "https://github.com" 10 | 11 | /// 获取应用版本信息 12 | static var appVersion: String { 13 | Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "Unknown" 14 | } 15 | 16 | /// 获取构建版本信息 17 | static var buildVersion: String { 18 | Bundle.main.infoDictionary?["CFBundleVersion"] as? String ?? "Unknown" 19 | } 20 | 21 | /// 获取完整版本信息 22 | static var fullVersionInfo: String { 23 | "v\(appVersion) (\(buildVersion))" 24 | } 25 | 26 | /// 获取 GitHub 仓库 URL 27 | static var githubRepositoryURL: URL? { 28 | URL(string: "\(githubBaseURL)/\(githubRepository)") 29 | } 30 | 31 | /// 获取 GitHub Releases 页面 URL 32 | static var githubReleasesURL: URL? { 33 | URL(string: "\(githubBaseURL)/\(githubRepository)/releases") 34 | } 35 | 36 | /// 获取最新 Release 页面 URL 37 | static var latestReleaseURL: URL? { 38 | URL(string: "\(githubBaseURL)/\(githubRepository)/releases/latest") 39 | } 40 | 41 | /// 打开 GitHub 仓库页面 42 | static func openGitHubRepository() { 43 | guard let url = githubRepositoryURL else { return } 44 | NSWorkspace.shared.open(url) 45 | } 46 | 47 | /// 打开 GitHub Releases 页面 48 | static func openGitHubReleases() { 49 | guard let url = githubReleasesURL else { return } 50 | NSWorkspace.shared.open(url) 51 | } 52 | 53 | /// 打开最新 Release 页面 54 | static func openLatestRelease() { 55 | guard let url = latestReleaseURL else { return } 56 | NSWorkspace.shared.open(url) 57 | } 58 | 59 | /// 复制版本信息到剪贴板 60 | static func copyVersionInfo() { 61 | let pasteboard = NSPasteboard.general 62 | pasteboard.clearContents() 63 | pasteboard.setString(fullVersionInfo, forType: .string) 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /TypeSwitch/Sources/Services/AppManagement/AppListService.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import SwiftUI 3 | import SwifterSwift 4 | 5 | /// 应用列表服务类 6 | /// 负责获取系统中已安装和正在运行的应用信息 7 | enum AppListService { 8 | /// 应用搜索目录列表 9 | /// 按优先级排序:用户应用目录 > 系统应用目录 10 | static let applicationDirs = [ 11 | "/Applications", // 用户安装的应用 12 | "~/Applications", // 用户主目录下的应用 13 | "/System/Applications" // 系统应用 14 | ].map { NSString(string: $0).expandingTildeInPath } 15 | 16 | /// 获取当前运行中的应用(仅返回在已安装应用列表中的应用) 17 | static func fetchRunningApps() async -> [AppInfo] { 18 | // 先获取已安装应用列表(已经过滤掉了自己) 19 | let installedApps = await fetchInstalledApps() 20 | let installedBundleIds = Set(installedApps.map { $0.bundleId }) 21 | 22 | // 获取运行中的应用 23 | let runningApps = NSWorkspace.shared.runningApplications 24 | 25 | return runningApps.compactMap { runningApp in 26 | guard let bundleURL = runningApp.bundleURL else { return nil } 27 | 28 | // 只保留在已安装应用列表中的应用(已安装应用列表已经过滤掉了自己) 29 | guard let bundleId = runningApp.bundleIdentifier, 30 | installedBundleIds.contains(bundleId) else { 31 | return nil 32 | } 33 | 34 | return createAppInfo(from: bundleURL) 35 | } 36 | .sorted { $0.name.localizedStandardCompare($1.name) == .orderedAscending } 37 | } 38 | 39 | /// 获取系统中已安装的应用列表 40 | /// - Returns: 已安装的应用信息数组,按名称排序,已过滤掉TypeSwitch应用本身 41 | static func fetchInstalledApps() async -> [AppInfo] { 42 | await withTaskGroup(of: [AppInfo].self) { group in 43 | for dir in applicationDirs { 44 | group.addTask { 45 | await fetchAppsInDirectory(dir) 46 | } 47 | } 48 | 49 | var apps: [AppInfo] = [] 50 | for await dirApps in group { 51 | apps.append(contentsOf: dirApps) 52 | } 53 | 54 | var uniqueApps: Set = [] 55 | return apps.filter { uniqueApps.insert($0).inserted } 56 | .filter { $0.bundleId != Bundle.main.bundleIdentifier } // 过滤掉自己(TypeSwitch应用) 57 | .sorted { $0.name.localizedStandardCompare($1.name) == .orderedAscending } 58 | } 59 | } 60 | 61 | /// 从指定目录获取应用信息(使用流式处理优化内存使用) 62 | private static func fetchAppsInDirectory(_ dir: String) async -> [AppInfo] { 63 | let fileManager = FileManager.default 64 | 65 | guard fileManager.fileExists(atPath: dir), 66 | fileManager.isReadableFile(atPath: dir) else { 67 | print("⚠️ 无法访问目录: \(dir)") 68 | return [] 69 | } 70 | 71 | let dirURL = URL(fileURLWithPath: dir) 72 | guard let enumerator = fileManager.enumerator( 73 | at: dirURL, 74 | includingPropertiesForKeys: [.isApplicationKey], 75 | options: [.skipsHiddenFiles, .skipsPackageDescendants] 76 | ) else { 77 | print("⚠️ 无法创建目录枚举器: \(dir)") 78 | return [] 79 | } 80 | 81 | var dirApps: [AppInfo] = [] 82 | 83 | // 流式处理,避免一次性加载所有URL到内存 84 | while let fileURL = enumerator.nextObject() as? URL { 85 | guard fileManager.isReadableFile(atPath: fileURL.path) else { continue } 86 | 87 | do { 88 | let resourceValues = try fileURL.resourceValues(forKeys: [.isApplicationKey]) 89 | guard resourceValues.isApplication == true else { continue } 90 | 91 | if let app = createAppInfo(from: fileURL) { 92 | dirApps.append(app) 93 | } 94 | } catch { 95 | print("⚠️ 处理应用时出错: \(fileURL.path) - \(error.localizedDescription)") 96 | continue 97 | } 98 | } 99 | 100 | return dirApps 101 | } 102 | 103 | /// 从Bundle URL创建AppInfo对象 104 | /// - Parameter fileURL: 应用的Bundle URL 105 | /// - Returns: 创建成功的AppInfo对象,失败时返回nil 106 | private static func createAppInfo(from fileURL: URL) -> AppInfo? { 107 | guard let bundle = Bundle(url: fileURL), 108 | let bundleId = bundle.bundleIdentifier, 109 | let name = bundle.infoDictionary?["CFBundleDisplayName"] as? String ?? 110 | bundle.infoDictionary?["CFBundleName"] as? String else { 111 | return nil 112 | } 113 | 114 | return AppInfo(bundleId: bundleId, name: name, iconPath: fileURL.path) 115 | } 116 | } 117 | -------------------------------------------------------------------------------- /TypeSwitch/Sources/Services/InputMethod/InputMethodManager.swift: -------------------------------------------------------------------------------- 1 | import AppKit 2 | import Carbon 3 | import Combine 4 | import Foundation 5 | import SwiftUI 6 | import Defaults 7 | 8 | 9 | 10 | @MainActor 11 | final class InputMethodManager: ObservableObject { 12 | static let shared = InputMethodManager() 13 | 14 | @Published var inputMethods: [InputMethod] = [] 15 | @Published var installedApps: [AppInfo] = [] 16 | @Published var runningApps: [AppInfo] = [] 17 | 18 | // UI 状态 19 | @Published private(set) var settingsVersion: UUID = UUID() // 跟踪设置变化以触发 UI 更新 20 | 21 | 22 | // 存储订阅 23 | private var cancellables: Set = [] 24 | 25 | private init() { 26 | Task { await refreshAllData() } 27 | setupSubscriptions() 28 | } 29 | 30 | deinit { 31 | // cancellables 会在对象销毁时自动清理 32 | } 33 | 34 | // MARK: - Setup 35 | 36 | private func setupSubscriptions() { 37 | // 监听输入法变化 38 | DistributedNotificationCenter.default() 39 | .publisher(for: NSNotification.Name(kTISNotifyEnabledKeyboardInputSourcesChanged as String)) 40 | .receive(on: DispatchQueue.main) 41 | .debounce(for: .milliseconds(300), scheduler: DispatchQueue.main) 42 | .sink { [weak self] _ in 43 | Task { @MainActor in 44 | await self?.refreshInputMethods() 45 | } 46 | } 47 | .store(in: &cancellables) 48 | 49 | // 监听应用启动通知 50 | NSWorkspace.shared.notificationCenter 51 | .publisher(for: NSWorkspace.didLaunchApplicationNotification) 52 | .receive(on: DispatchQueue.main) 53 | .debounce(for: .milliseconds(500), scheduler: DispatchQueue.main) 54 | .sink { [weak self] _ in 55 | Task { @MainActor in 56 | await self?.refreshRunningApps() 57 | } 58 | } 59 | .store(in: &cancellables) 60 | 61 | // 监听应用退出通知 62 | NSWorkspace.shared.notificationCenter 63 | .publisher(for: NSWorkspace.didTerminateApplicationNotification) 64 | .receive(on: DispatchQueue.main) 65 | .debounce(for: .milliseconds(500), scheduler: DispatchQueue.main) 66 | .sink { [weak self] _ in 67 | Task { @MainActor in 68 | await self?.refreshRunningApps() 69 | } 70 | } 71 | .store(in: &cancellables) 72 | 73 | // 监听应用激活通知 74 | NSWorkspace.shared.notificationCenter 75 | .publisher(for: NSWorkspace.didActivateApplicationNotification) 76 | .receive(on: DispatchQueue.main) 77 | .debounce(for: .milliseconds(300), scheduler: DispatchQueue.main) 78 | .sink { [weak self] notification in 79 | Task { @MainActor in 80 | await self?.handleAppActivation(notification) 81 | } 82 | } 83 | .store(in: &cancellables) 84 | } 85 | 86 | // MARK: - Public Methods 87 | 88 | func refreshAllData() async { 89 | await withTaskGroup(of: Void.self) { group in 90 | group.addTask { await self.refreshInputMethods() } 91 | group.addTask { await self.refreshInstalledApps() } 92 | group.addTask { await self.refreshRunningApps() } 93 | } 94 | } 95 | 96 | func refreshInputMethods() async { 97 | do { 98 | let methods = try InputMethodService.fetchInputMethods() 99 | self.inputMethods = methods 100 | } catch { 101 | print("Failed to fetch input methods: \(error)") 102 | } 103 | } 104 | 105 | /// 刷新已安装的应用 106 | func refreshInstalledApps() async { 107 | installedApps = await AppListService.fetchInstalledApps() 108 | } 109 | 110 | /// 刷新运行中的应用 111 | func refreshRunningApps() async { 112 | runningApps = await AppListService.fetchRunningApps() 113 | } 114 | 115 | // MARK: - Private Methods 116 | 117 | 118 | /// 处理应用激活事件 119 | private func handleAppActivation(_ notification: Notification) async { 120 | guard let userInfo = notification.userInfo, 121 | let app = userInfo[NSWorkspace.applicationUserInfoKey] as? NSRunningApplication, 122 | let bundleId = app.bundleIdentifier else { 123 | return 124 | } 125 | 126 | // 查找对应的应用信息 127 | guard let appInfo = installedApps.first(where: { $0.bundleId == bundleId }) else { 128 | return 129 | } 130 | 131 | // 检查是否有为该应用设置的输入法 132 | guard let targetInputMethodId = getInputMethod(for: appInfo) else { 133 | return 134 | } 135 | 136 | // 检查当前输入法是否已经是目标输入法 137 | do { 138 | let currentInputMethodId = try InputMethodService.getCurrentInputMethodId() 139 | if currentInputMethodId == targetInputMethodId { 140 | return // 已经是目标输入法,无需切换 141 | } 142 | } catch { 143 | print("⚠️ 获取当前输入法失败: \(error.localizedDescription)") 144 | return 145 | } 146 | 147 | // 执行输入法切换 148 | do { 149 | try InputMethodService.switchToInputMethod(targetInputMethodId) 150 | print("✅ 为应用 \(appInfo.name) 切换到输入法: \(targetInputMethodId)") 151 | } catch { 152 | print("❌ 切换到输入法失败: \(error.localizedDescription)") 153 | } 154 | } 155 | 156 | /// 设置应用的输入法 157 | func setInputMethod(for app: AppInfo, to inputMethodId: String?) { 158 | var settings = Defaults[.appInputMethodSettings] 159 | 160 | if let inputMethodId = inputMethodId { 161 | // 设置输入法 162 | settings[app.bundleId] = inputMethodId 163 | } else { 164 | // 移除输入法设置 165 | settings.removeValue(forKey: app.bundleId) 166 | } 167 | 168 | Defaults[.appInputMethodSettings] = settings 169 | settingsVersion = UUID() 170 | } 171 | 172 | /// 获取应用的输入法ID 173 | func getInputMethod(for app: AppInfo) -> String? { 174 | return Defaults[.appInputMethodSettings][app.bundleId] ?? nil 175 | } 176 | 177 | // MARK: - UI Helper Methods 178 | 179 | /// 获取已配置输入法的应用列表 180 | var configuredApps: [AppInfo] { 181 | let settings = Defaults[.appInputMethodSettings] 182 | return installedApps.filter { app in 183 | settings[app.bundleId] != nil 184 | } 185 | } 186 | 187 | /// 获取应用选中的输入法名称 188 | func getSelectedInputMethodName(for app: AppInfo) -> String? { 189 | // 依赖于 settingsVersion 以确保设置变化时 UI 更新 190 | _ = settingsVersion 191 | 192 | guard let inputMethodId = getInputMethod(for: app), !inputMethodId.isEmpty else { 193 | return nil 194 | } 195 | 196 | return inputMethods.first(where: { $0.id == inputMethodId })?.name 197 | } 198 | } 199 | -------------------------------------------------------------------------------- /TypeSwitch/Sources/Services/InputMethod/InputMethodService.swift: -------------------------------------------------------------------------------- 1 | import Carbon 2 | import Foundation 3 | import OSLog 4 | 5 | /// 输入法服务类 6 | /// 负责获取、切换和管理系统输入法 7 | @MainActor 8 | enum InputMethodService { 9 | 10 | /// 输入法相关错误 11 | enum InputMethodError: Error, LocalizedError { 12 | case failedToFetchInputMethods 13 | case inputMethodNotFound(String) 14 | case inputMethodNotEnabled(String) 15 | case failedToSwitchInputMethod(String) 16 | case failedToGetCurrentInputMethod 17 | 18 | var errorDescription: String? { 19 | switch self { 20 | case .failedToFetchInputMethods: 21 | return TypeSwitchStrings.Error.getInputMethodsFailed 22 | case .inputMethodNotFound(let id): 23 | return TypeSwitchStrings.Error.inputMethodNotFound(id) 24 | case .inputMethodNotEnabled(let id): 25 | return TypeSwitchStrings.Error.inputMethodNotEnabled(id) 26 | case .failedToSwitchInputMethod(let id): 27 | return TypeSwitchStrings.Error.switchInputMethodFailed(id) 28 | case .failedToGetCurrentInputMethod: 29 | return TypeSwitchStrings.Error.getCurrentInputMethodFailed 30 | } 31 | } 32 | } 33 | 34 | 35 | // MARK: - 公共方法 36 | 37 | /// 获取所有可用的输入法 38 | /// - Returns: 输入法数组,按名称排序 39 | /// - Throws: Error 当获取失败时 40 | static func fetchInputMethods() throws -> [InputMethod] { 41 | let inputSources = try getInputSourceList() 42 | 43 | // 过滤和转换输入源 44 | let methods = inputSources.compactMap { source -> InputMethod? in 45 | guard let properties = getInputSourceProperties(source), 46 | isValidInputSourceType(properties.sourceType), 47 | properties.isSelectable, properties.isEnabled 48 | else { 49 | return nil 50 | } 51 | 52 | return InputMethod(id: properties.sourceID, name: properties.localizedName) 53 | } 54 | 55 | // 按本地化名称排序 56 | return methods.sorted { $0.name.localizedStandardCompare($1.name) == .orderedAscending } 57 | } 58 | 59 | /// 切换到指定的输入法 60 | /// - Parameter inputMethodID: 输入法ID 61 | /// - Throws: Error 当切换失败时 62 | static func switchToInputMethod(_ inputMethodID: String) throws { 63 | let inputSources = try getInputSourceList() 64 | 65 | // 查找目标输入源 66 | guard let targetSource = inputSources.first(where: { source in 67 | guard let properties = getInputSourceProperties(source) else { 68 | return false 69 | } 70 | return properties.sourceID == inputMethodID 71 | }) else { 72 | throw InputMethodError.inputMethodNotFound(inputMethodID) 73 | } 74 | 75 | // 切换输入法前确保输入法是启用的 76 | guard let enabledPtr = TISGetInputSourceProperty(targetSource, kTISPropertyInputSourceIsEnabled), 77 | let enabled = Unmanaged.fromOpaque(enabledPtr).takeUnretainedValue() as? Bool, 78 | enabled 79 | else { 80 | throw InputMethodError.inputMethodNotEnabled(inputMethodID) 81 | } 82 | 83 | let status = TISSelectInputSource(targetSource) 84 | if status != noErr { 85 | throw InputMethodError.failedToSwitchInputMethod(inputMethodID) 86 | } 87 | } 88 | 89 | /// 获取当前激活的输入法ID 90 | /// - Returns: 当前输入法ID 91 | /// - Throws: Error 当获取失败时 92 | static func getCurrentInputMethodId() throws -> String { 93 | // 获取当前输入源 94 | guard let currentSource = TISCopyCurrentKeyboardInputSource()?.takeRetainedValue() else { 95 | throw InputMethodError.failedToGetCurrentInputMethod 96 | } 97 | 98 | // 获取输入源属性 99 | guard let properties = getInputSourceProperties(currentSource) else { 100 | throw InputMethodError.failedToGetCurrentInputMethod 101 | } 102 | 103 | return properties.sourceID 104 | } 105 | 106 | // MARK: - Private Helpers 107 | 108 | /// 获取输入源列表 109 | /// - Returns: 输入源数组 110 | /// - Throws: Error 当获取失败时 111 | private static func getInputSourceList() throws -> [TISInputSource] { 112 | guard let inputSourceList = TISCreateInputSourceList(nil, false)?.takeRetainedValue(), 113 | let inputSources = (inputSourceList as NSArray) as? [TISInputSource] 114 | else { 115 | throw InputMethodError.failedToFetchInputMethods 116 | } 117 | 118 | return inputSources 119 | } 120 | 121 | 122 | /// 获取输入源属性 123 | /// - Parameter source: 输入源 124 | /// - Returns: 输入源属性,失败时返回nil 125 | private static func getInputSourceProperties(_ source: TISInputSource) -> InputSourceProperties? { 126 | // 获取输入源ID 127 | guard let sourceIDPtr = TISGetInputSourceProperty(source, kTISPropertyInputSourceID), 128 | let sourceID = Unmanaged.fromOpaque(sourceIDPtr).takeUnretainedValue() as String?, 129 | // 获取输入源类型 130 | let sourceTypePtr = TISGetInputSourceProperty(source, kTISPropertyInputSourceType), 131 | let sourceType = Unmanaged.fromOpaque(sourceTypePtr).takeUnretainedValue() as String?, 132 | // 获取本地化名称 133 | let localizedNamePtr = TISGetInputSourceProperty(source, kTISPropertyLocalizedName), 134 | let localizedName = Unmanaged.fromOpaque(localizedNamePtr).takeUnretainedValue() as String?, 135 | // 获取可选择状态 136 | let selectablePtr = TISGetInputSourceProperty(source, kTISPropertyInputSourceIsSelectCapable), 137 | // 获取启用状态 138 | let enabledPtr = TISGetInputSourceProperty(source, kTISPropertyInputSourceIsEnabled) 139 | else { 140 | return nil 141 | } 142 | 143 | let isSelectable = CFBooleanGetValue(Unmanaged.fromOpaque(selectablePtr).takeUnretainedValue()) 144 | let isEnabled = CFBooleanGetValue(Unmanaged.fromOpaque(enabledPtr).takeUnretainedValue()) 145 | 146 | return InputSourceProperties( 147 | sourceID: sourceID, 148 | sourceType: sourceType, 149 | localizedName: localizedName, 150 | isSelectable: isSelectable, 151 | isEnabled: isEnabled 152 | ) 153 | } 154 | 155 | /// 检查输入源类型是否有效 156 | /// - Parameter sourceType: 输入源类型 157 | /// - Returns: 是否为有效的输入源类型 158 | private static func isValidInputSourceType(_ sourceType: String) -> Bool { 159 | sourceType == (kTISTypeKeyboardLayout as String) || sourceType == (kTISTypeKeyboardInputMode as String) 160 | } 161 | } 162 | -------------------------------------------------------------------------------- /TypeSwitch/Sources/Services/System/LaunchAtLoginService.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import ServiceManagement 3 | 4 | /// 开机启动服务类 5 | enum LaunchAtLoginService { 6 | /// 获取当前开机启动状态 7 | static var isEnabled: Bool { 8 | SMAppService.mainApp.status == .enabled 9 | } 10 | 11 | /// 设置开机启动状态 12 | /// - Parameter enabled: 是否启用开机启动 13 | /// - Returns: 设置是否成功 14 | @discardableResult 15 | static func setLaunchAtLogin(_ enabled: Bool) -> Bool { 16 | do { 17 | if enabled { 18 | try SMAppService.mainApp.register() 19 | } else { 20 | try SMAppService.mainApp.unregister() 21 | } 22 | return true 23 | } catch { 24 | print("❌ 设置自动启动失败: \(error.localizedDescription)") 25 | return false 26 | } 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /TypeSwitch/Sources/UI/Views/MenuBar/AppInfoView.swift: -------------------------------------------------------------------------------- 1 | import AppKit 2 | import SwiftUI 3 | 4 | /// 应用信息视图,显示版本信息和相关链接 5 | struct AppInfoView: View { 6 | var body: some View { 7 | Section { 8 | // 版本信息 9 | Text(TypeSwitchStrings.App.about(AppInfoService.fullVersionInfo)) 10 | .foregroundColor(.secondary) 11 | 12 | Divider() 13 | 14 | // 相关链接 15 | Button(TypeSwitchStrings.App.githubRepository) { 16 | AppInfoService.openGitHubRepository() 17 | } 18 | 19 | Button(TypeSwitchStrings.App.latestRelease) { 20 | AppInfoService.openLatestRelease() 21 | } 22 | 23 | Divider() 24 | 25 | // 退出应用 26 | Button(TypeSwitchStrings.Menu.quit, role: .destructive) { 27 | NSApplication.shared.terminate(nil) 28 | } 29 | .keyboardShortcut("q", modifiers: .command) 30 | } 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /TypeSwitch/Sources/UI/Views/MenuBar/AppRowView.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | import SwiftUIX 3 | 4 | /// 应用行视图,处理单个应用的显示和输入法选择 5 | struct AppRowView: View { 6 | let app: AppInfo 7 | @EnvironmentObject private var viewModel: InputMethodManager 8 | 9 | var body: some View { 10 | Menu { 11 | // 默认输入法选项 12 | Button(action: { 13 | viewModel.setInputMethod(for: app, to: nil) 14 | }) { 15 | if viewModel.getInputMethod(for: app) == nil { 16 | Image(systemName: .checkmark) 17 | } 18 | Text(TypeSwitchStrings.InputMethod.defaultOption) 19 | } 20 | 21 | Divider() 22 | 23 | // 已安装的输入法选项 24 | ForEach(viewModel.inputMethods, id: \.id) { inputMethod in 25 | Button(action: { 26 | viewModel.setInputMethod(for: app, to: inputMethod.id) 27 | }) { 28 | if viewModel.getInputMethod(for: app) == inputMethod.id { 29 | Image(systemName: .checkmark) 30 | } 31 | Text(inputMethod.name) 32 | } 33 | } 34 | } label: { 35 | // 应用行标签内容 36 | app.icon 37 | Text(app.name) 38 | viewModel.getSelectedInputMethodName(for: app).ifSome { Text($0) } 39 | } 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /TypeSwitch/Sources/UI/Views/MenuBar/ConfiguredAppsView.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | /// 已配置的应用列表视图 4 | struct ConfiguredAppsView: View { 5 | @EnvironmentObject private var viewModel: InputMethodManager 6 | 7 | var body: some View { 8 | Section { 9 | Menu(TypeSwitchStrings.Apps.Section.configuredCount(viewModel.configuredApps.count)) { 10 | ForEach(viewModel.configuredApps) { app in 11 | AppRowView(app: app) 12 | } 13 | } 14 | } 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /TypeSwitch/Sources/UI/Views/MenuBar/MenuBarView.swift: -------------------------------------------------------------------------------- 1 | import AppKit 2 | import SwiftUI 3 | import SwiftUIX 4 | 5 | /// 菜单栏主视图 6 | struct MenuBarView: View { 7 | @EnvironmentObject private var viewModel: InputMethodManager 8 | 9 | var body: some View { 10 | Group { 11 | RunningAppsView() 12 | ConfiguredAppsView() 13 | 14 | Divider() 15 | 16 | SettingsView() 17 | 18 | Divider() 19 | 20 | AppInfoView() 21 | } 22 | } 23 | } 24 | 25 | #Preview { 26 | MenuBarView() 27 | .environmentObject(InputMethodManager.shared) 28 | } 29 | -------------------------------------------------------------------------------- /TypeSwitch/Sources/UI/Views/MenuBar/RunningAppsView.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | /// 运行中的应用列表视图 4 | struct RunningAppsView: View { 5 | @EnvironmentObject private var viewModel: InputMethodManager 6 | 7 | var body: some View { 8 | Section(TypeSwitchStrings.Apps.Section.runningCount(viewModel.runningApps.count)) { 9 | ForEach(viewModel.runningApps) { app in 10 | AppRowView(app: app) 11 | } 12 | } 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /TypeSwitch/Sources/UI/Views/MenuBar/SettingsView.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | /// 设置视图,包含各种应用设置选项 4 | struct SettingsView: View { 5 | @State private var isLaunchAtLoginEnabled = LaunchAtLoginService.isEnabled 6 | 7 | var body: some View { 8 | Section { 9 | Toggle(TypeSwitchStrings.Settings.General.autoLaunch, isOn: $isLaunchAtLoginEnabled) 10 | .toggleStyle(.checkbox) 11 | .onChange(of: isLaunchAtLoginEnabled) { newValue in 12 | _ = LaunchAtLoginService.setLaunchAtLogin(newValue) 13 | } 14 | } 15 | } 16 | } 17 | --------------------------------------------------------------------------------