├── .gitattributes ├── .gitignore ├── DevDoc.md ├── DocImages ├── AIChat.png ├── Application-Settings.png ├── info-uti.png └── tool.png ├── Fonts.md ├── LICENSE ├── README.md ├── Selected.xcodeproj ├── project.pbxproj ├── project.xcworkspace │ ├── contents.xcworkspacedata │ └── xcshareddata │ │ ├── IDEWorkspaceChecks.plist │ │ ├── WorkspaceSettings.xcsettings │ │ └── swiftpm │ │ └── Package.resolved └── xcshareddata │ └── xcschemes │ └── Selected.xcscheme ├── Selected ├── App.swift ├── Assets.xcassets │ ├── AccentColor.colorset │ │ └── Contents.json │ ├── 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 ├── ClipHistory.xcdatamodeld │ └── ClipHistory.xcdatamodel │ │ └── contents ├── Configuration │ ├── Defaults.swift │ └── UserConfigs.swift ├── Fonts │ └── UbuntuMonoNerdFontMono-Regular.ttf ├── GetText.swift ├── Info.plist ├── Localizable.xcstrings ├── Plugin │ ├── Actions.swift │ ├── GPTAction.swift │ ├── Option.swift │ ├── PluginInfo.swift │ ├── RunCommandAction.swift │ └── SpeakAction.swift ├── Preview Content │ └── Preview Assets.xcassets │ │ └── Contents.json ├── Selected.entitlements ├── Service │ ├── AI.swift │ ├── AIUtils │ │ ├── FunctionDefinition.swift │ │ ├── ImageGeneration.swift │ │ └── TTSManager.swift │ ├── Claude.swift │ ├── Clipboard.swift │ ├── LocationManager.swift │ ├── OCR.swift │ ├── OpenAI.swift │ ├── Persistence.swift │ ├── Spotlight.swift │ ├── StarDict.swift │ ├── Template.swift │ ├── Updater.swift │ └── Utils.swift ├── SyntaxHighlighter │ └── CodeSyntaxHighlighter.swift ├── View │ ├── AudioPlayerView.swift │ ├── Base │ │ ├── BarButton.swift │ │ ├── FloatingPanel.swift │ │ ├── IconImage.swift │ │ ├── ShortcutRecorderView.swift │ │ └── TextView.swift │ ├── ChatView │ │ ├── ChatTextView.swift │ │ ├── MarkdowLateXView.swift │ │ ├── MessageView.swift │ │ ├── MessageViewModel.swift │ │ └── TranslationView.swift │ ├── ClipView │ │ ├── ClipView.swift │ │ ├── PDFView.swift │ │ ├── QuickLookView.swift │ │ ├── RTFView.swift │ │ ├── SearchView.swift │ │ └── WebView.swift │ ├── CommandView.swift │ ├── MenuItemView.swift │ ├── PluginListView.swift │ ├── PluginView │ │ └── ApplicationSettingView.swift │ ├── PopBarView.swift │ ├── PopResultView.swift │ ├── SettingsView.swift │ ├── SharingButton.swift │ └── SpotlightView.swift ├── WindowManager.swift ├── Windows │ └── ChatWindow.swift └── stardict.tar.gz ├── SelectedTests └── SelectedTests.swift ├── SelectedUITests ├── SelectedUITests.swift └── SelectedUITestsLaunchTests.swift ├── extension-package.md └── icon.md /.gitattributes: -------------------------------------------------------------------------------- 1 | *.sqlite3 filter=lfs diff=lfs merge=lfs -text 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Xcode 2 | # 3 | # gitignore contributors: remember to update Global/Xcode.gitignore, Objective-C.gitignore & Swift.gitignore 4 | 5 | ## User settings 6 | xcuserdata/ 7 | 8 | .DS_Store 9 | 10 | ## compatibility with Xcode 8 and earlier (ignoring not required starting Xcode 9) 11 | *.xcscmblueprint 12 | *.xccheckout 13 | 14 | ## compatibility with Xcode 3 and earlier (ignoring not required starting Xcode 4) 15 | build/ 16 | DerivedData/ 17 | *.moved-aside 18 | *.pbxuser 19 | !default.pbxuser 20 | *.mode1v3 21 | !default.mode1v3 22 | *.mode2v3 23 | !default.mode2v3 24 | *.perspectivev3 25 | !default.perspectivev3 26 | 27 | ## Obj-C/Swift specific 28 | *.hmap 29 | 30 | ## App packaging 31 | *.ipa 32 | *.dSYM.zip 33 | *.dSYM 34 | 35 | ## Playgrounds 36 | timeline.xctimeline 37 | playground.xcworkspace 38 | 39 | # Swift Package Manager 40 | # 41 | # Add this line if you want to avoid checking in source code from Swift Package Manager dependencies. 42 | # Packages/ 43 | # Package.pins 44 | # Package.resolved 45 | # *.xcodeproj 46 | # 47 | # Xcode automatically generates this directory with a .xcworkspacedata file and xcuserdata 48 | # hence it is not needed unless you have added a package configuration file to your project 49 | # .swiftpm 50 | 51 | .build/ 52 | 53 | # CocoaPods 54 | # 55 | # We recommend against adding the Pods directory to your .gitignore. However 56 | # you should judge for yourself, the pros and cons are mentioned at: 57 | # https://guides.cocoapods.org/using/using-cocoapods.html#should-i-check-the-pods-directory-into-source-control 58 | # 59 | # Pods/ 60 | # 61 | # Add this line if you want to avoid checking in source code from the Xcode workspace 62 | # *.xcworkspace 63 | 64 | # Carthage 65 | # 66 | # Add this line if you want to avoid checking in source code from Carthage dependencies. 67 | # Carthage/Checkouts 68 | 69 | Carthage/Build/ 70 | 71 | # Accio dependency management 72 | Dependencies/ 73 | .accio/ 74 | 75 | # fastlane 76 | # 77 | # It is recommended to not store the screenshots in the git repo. 78 | # Instead, use fastlane to re-generate the screenshots whenever they are needed. 79 | # For more information about the recommended setup visit: 80 | # https://docs.fastlane.tools/best-practices/source-control/#source-control 81 | 82 | fastlane/report.xml 83 | fastlane/Preview.html 84 | fastlane/screenshots/**/*.png 85 | fastlane/test_output 86 | 87 | # Code Injection 88 | # 89 | # After new code Injection tools there's a generated folder /iOSInjectionProject 90 | # https://github.com/johnno1962/injectionforxcode 91 | 92 | iOSInjectionProject/ 93 | -------------------------------------------------------------------------------- /DevDoc.md: -------------------------------------------------------------------------------- 1 | 面向开发者的文档 2 | 3 | 4 | 5 | ## 自定义操作列表 6 | 7 | 在“设置-应用”中可以配置 8 | 9 | 配置文件在 `Library/Application Support/Selected/UserConfiguration.json`。 10 | 11 | 内容示例: 12 | 13 | ```json 14 | { 15 | "appConditions": [ 16 | { 17 | "bundleID": "com.apple.dt.Xcode", 18 | "actions": ["selected.websearch", "selected.xcode.format"] 19 | } 20 | ] 21 | } 22 | ``` 23 | 24 | `appConditions.bundleID` 为应用的 bundleID。 25 | `actions` 为 `action.identifier` 列表。 26 | 27 | 具体可以用哪些以及如何自定义 action,请看内置操作与自定义插件。 28 | 29 | 没有为应用配置 action 列表或者为应用配置的 action 列表为空时,将会显示所有可用操作。 -------------------------------------------------------------------------------- /DocImages/AIChat.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sakeven/Selected/5535a6cd07ba259f3804220e6e73d1bdba4b64b8/DocImages/AIChat.png -------------------------------------------------------------------------------- /DocImages/Application-Settings.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sakeven/Selected/5535a6cd07ba259f3804220e6e73d1bdba4b64b8/DocImages/Application-Settings.png -------------------------------------------------------------------------------- /DocImages/info-uti.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sakeven/Selected/5535a6cd07ba259f3804220e6e73d1bdba4b64b8/DocImages/info-uti.png -------------------------------------------------------------------------------- /DocImages/tool.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sakeven/Selected/5535a6cd07ba259f3804220e6e73d1bdba4b64b8/DocImages/tool.png -------------------------------------------------------------------------------- /Fonts.md: -------------------------------------------------------------------------------- 1 | macOS App 如何配置自定义字体。具体参考 https://stackoverflow.com/a/57412354 2 | 3 | 1. Create a folder named Fonts. 4 | 5 | ![enter image description here](https://i.stack.imgur.com/vVCFC.png) 6 | 7 | 2. Add fonts to the `Fonts` folder. Uncheck `Add to targets` and check `Copy items if needed`. 8 | 9 | ![enter image description here](https://i.stack.imgur.com/6unon.png) 10 | 11 | 3. Add `Application fonts resource path` to Info.plist and enter `Fonts`. 12 | 13 | ![enter image description here](https://i.stack.imgur.com/Mwji4.png) 14 | 15 | 4. Go to `Build Phases` and create a `New Copy Files Phase`. 16 | 17 | ![enter image description here](https://i.stack.imgur.com/8k0Jl.png) 18 | 19 | 5. Set the `Destinations` to `Resources` and `Subpath` to `Fonts`. Then add your font files to the list. 20 | 21 | ![enter image description here](https://i.stack.imgur.com/ptkFy.png) 22 | 23 | 6. 获取字体名称 24 | 25 | ```swift 26 | for family: String in NSFontManager.shared.availableFontFamilies { 27 | print("\(family)") 28 | for name in NSFontManager.shared.availableMembers(ofFontFamily: family)! { 29 | print("== \(name[0])") 30 | } 31 | } 32 | ``` 33 | 34 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # OS Requirements 2 | macOS Ventura 13.0 or above 3 | 4 | # Installation 5 | 6 | 1. Download Selected.zip from [releases](https://github.com/sakeven/Selected/releases). 7 | 2. Unzip it, and move it to the Applications directory. 8 | 3. For the first installation, you need to set up Accessibility features to allow this app to capture selected text. 9 | 10 | 11 | # Features 12 | A Mac tool that allows various operations on selected text. 13 | 14 | When you select text with the mouse or through the keyboard (cmd+A, cmd+shift+arrow keys), the Selected toolbar will automatically pop up, allowing quick text operations such as copying, translating, searching, querying GPT, reading text aloud, opening links, keyboard operations, executing commands, calculating expression or sharing the text, etc. It also supports custom extensions. 15 | 16 | image-20240421174659528 17 | 18 | 1. Allows for the customization of operation lists for different applications. (This can be configured in "Settings - Applications") 19 | 2. Supports customizing the addresses and keys for OpenAI, Gemini and Claude API. The translation and inquiry GPT functions depend on this. It also supports OpenAI and Claude's function calling. You can have GPT perform searches, fetch web content, write emails, or control your macOS, and virtually anything else.image-20240716203229690 20 | 3. Supports custom extensions. 21 | 22 | # Supported Applications 23 | 24 | Some applications have already been tested, while others that have not been tested may also be supported. 25 | 26 | See https://github.com/sakeven/Selected/wiki/Supported-Applications 27 | 28 | # Custom Action List 29 | 30 | This can be configured in "Settings - Applications". 31 | 32 | image-20240421175133604 33 | 34 | 1. Supports adding currently running applications (does not support deleting an application) 35 | * Add through "Add - Select an Application" 36 | 2. Supports setting a series of actions for an application 37 | - Add through "Add - Select an Action" 38 | - Supports deleting an action 39 | - Supports drag-and-drop to rearrange the order of actions 40 | 41 | ## Built-in Operations 42 | 43 | | Action | action.identifier | function | icon | 44 | | -------------------- | ------------------------ | ------------------------------------------------------------ | ---- | 45 | | Web Search | selected.websearch | Search via https://www.google.com/search. It can be customized in the settings page. | 🔍 | 46 | | OpenLinks | selected.openlinks | Open all URL links in the text at the same time. | 🔗 | 47 | | Copy | selected.copy | Copy the currently selected text. | 📃 | 48 | | Speak | selected.speak | Read the text. If an OpenAI API Key is configured, use OpenAI's TTS (Text-to-Speech) service to generate speech, otherwise use the system's text reading functionality. | ▶️ | 49 | | 翻译到中文 | selected.translation.cn | Translate to Chinese. If the selected text is a word, translate the detailed meaning of the word. An API key must be configured in the settings. | 译中 | 50 | | Translate to English | selected.translation.en | Translate to English. You need to configure the OpenAI or Gemini API key in the settings. | 🌍 | 51 | | Share | (none) | Share the selected text by macOS share extension. | 📤 | 52 | | Calculator | (none) | Auto calculate the expression like 1+2/3*4-5 when you selected it. | (none) | 53 | 54 | ## Custom Extentions 55 | 56 | The extension is placed in the `Library/Application Support/Selected/Extensions` directory, with one directory per extension. 57 | 58 | Inside the extension directory, there must be a `config.yaml` file that describes the relevant information about the extension. 59 | 60 | Example: 61 | 62 | ```yam 63 | info: 64 | icon: file://./go-logo-white.svg 65 | name: Go Search 66 | enabled: true 67 | actions: 68 | - meta: 69 | title: GoSearch 70 | icon: file://./go-logo-white.svg 71 | identifier: selected.gosearch 72 | after: "" 73 | url: 74 | url: https://pkg.go.dev/search?limit=25&m=symbol&q={text} 75 | ``` 76 | 77 | | Fields | Type | Description | 78 | | -------------------------- | ------ | ------------------------------------------------------------ | 79 | | info | object | Base information of the extension. | 80 | | info.icon | string | Icon. The icon size should be 30*30. It supports specifying files with `file://`. `file://./go-logo-white.svg` is an example of loading the icon from the extension directory. It also supports direct configuration of sf symbols, such as `magnifyingglass` (🔍). The icon will be displayed in the configured extension list. | 81 | | info.name | string | Extension name | 82 | | enabled | boolean | Whether activate this extension or not. | 83 | | actions | list | Action List | 84 | | action.meta | object | Meta information of the Action | 85 | | action.meta.title | string | Action title. Used to display the name of the operation when the mouse hovers over the toolbar. | 86 | | action.meta.icon | string | The setup is the same as info.icon. It is used for display on the toolbar. | 87 | | action.meta.identifier | string | action's id, unique identifier. | 88 | | action.meta.after | string | Handling after the action is executed. Required. Supports configuration of empty (`""`), `paste`, `copy`, `show`. | 89 | | action.meta.regex | string | Regular expressions, used to match selected text, only display action when a match occurs. Optional values. | 90 | | action.url | object | Action of URL type. | 91 | | action.url.url | string | A link that, upon clicking (action), will open this link. It supports schemes to open other apps. For example, `https://www.google.com.hk/search?q={selected.text} `for conducting a Google search. Or open `things:///add?title={selected.text}&show-quick-entry=true` to add a task in Things3. `{selected.text}` is used to replace the selected text. | 92 | | action.service | object | Action of service type. | 93 | | action.service.name | string | Service Name。For example, `Make Sticky` creates a new note (note application). | 94 | | action.keycombo | object | Shortcut key type action. | 95 | | action.keycombo.keycombo | string | Shortcut keys, such as "cmd i", etc. Support for function keys like "cmd", "shift", "ctrl", "option", "fn", "caps", as well as lowercase letters, numbers, symbols, and other key positions. Key positioning support is not yet complete, pending further testing and improvement. | 96 | | action.keycombo.keycombos | string list | A list of Shortcut keys. Only one of keycombo or keycombos can be set in action.keycombo.| 97 | | action.gpt | object | To interact with GPT, such as OpenAI (3.5 turbo model) or Gemini, you need to configure the relevant API key in the settings. | 98 | | action.gpt.prompt | string | GPT prompt words, such as `enriching and refining the following content. The content reads: {selected.text}.` Use `{selected.text}` to replace the selected text. | 99 | | action.gpt.tools | tool list | GPT function calling definition. A list of tool. | 100 | | tool.name | string | GPT function name. | 101 | | tool.description| string | GPT function description. | 102 | | tool.parameters| string | JSON schema of GPT function parameters.| 103 | | tool.command| string list | When call the function, run the command. | 104 | | tool.showResult| boolean | Whether show function result in GUI window. | 105 | | action.runCommand | object | Execute a command. | 106 | | action.runCommand.command | string | Command and parameter list. The working directory during command execution is the plugin directory. The environment variables currently provided include: `SELECTED_TEXT` and `SELECTED_BUNDLEID`, which represent the currently selected text and the current application, respectively. | 107 | 108 | Each action can and must be configured with only one of the following: action.url, action.service, action.keycombo, action.gpt, or action.runCommand. 109 | 110 | # Official Extensions 111 | 112 | See https://github.com/sakeven/Selected-Extensions 113 | 114 | # Note 115 | This tool is a hobby project of the author and is still under rapid development and iteration, with incomplete features. Everyone is welcome to submit suggestions for features and implementation code. 116 | 117 | # Contribution 118 | This project welcomes any contributions. 119 | 120 | As the author is a complete beginner in Swift, SwiftUI, and macOS App development, all implementations are acquired through GPT, searching, and reading the code and documentation of related projects (EasyDict, PopClip). Therefore, if you wish to contribute code, please clearly explain how the code is implemented and why it is implemented in this way, to help the author understand your code. 121 | -------------------------------------------------------------------------------- /Selected.xcodeproj/project.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /Selected.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IDEDidComputeMac32BitWarning 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /Selected.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /Selected.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved: -------------------------------------------------------------------------------- 1 | { 2 | "originHash" : "fb972b440911e579493b6180c1bf72bb20838171c88b266242e1bf15995e8598", 3 | "pins" : [ 4 | { 5 | "identity" : "ddmathparser", 6 | "kind" : "remoteSourceControl", 7 | "location" : "https://github.com/davedelong/DDMathParser", 8 | "state" : { 9 | "revision" : "0a159109e2393c3e1db4e2b638009c82e6650b4d", 10 | "version" : "3.1.0" 11 | } 12 | }, 13 | { 14 | "identity" : "defaults", 15 | "kind" : "remoteSourceControl", 16 | "location" : "https://github.com/sindresorhus/Defaults.git", 17 | "state" : { 18 | "revision" : "38925e3cfacf3fb89a81a35b1cd44fd5a5b7e0fa", 19 | "version" : "8.2.0" 20 | } 21 | }, 22 | { 23 | "identity" : "dswaveformimage", 24 | "kind" : "remoteSourceControl", 25 | "location" : "https://github.com/dmrschmidt/DSWaveformImage", 26 | "state" : { 27 | "revision" : "4c56578ee10128ee2b2c04c9c5aa73812de722db", 28 | "version" : "14.2.2" 29 | } 30 | }, 31 | { 32 | "identity" : "generative-ai-swift", 33 | "kind" : "remoteSourceControl", 34 | "location" : "https://github.com/google/generative-ai-swift", 35 | "state" : { 36 | "revision" : "44b8ce120425f9cf53ca756f3434ca2c2696f8bd", 37 | "version" : "0.5.6" 38 | } 39 | }, 40 | { 41 | "identity" : "grdb.swift", 42 | "kind" : "remoteSourceControl", 43 | "location" : "https://github.com/groue/GRDB.swift", 44 | "state" : { 45 | "revision" : "2cf6c756e1e5ef6901ebae16576a7e4e4b834622", 46 | "version" : "6.29.3" 47 | } 48 | }, 49 | { 50 | "identity" : "highlightr", 51 | "kind" : "remoteSourceControl", 52 | "location" : "https://github.com/raspu/Highlightr", 53 | "state" : { 54 | "revision" : "bcf2d0590f32ac2528feb72d7e34f5b463801a47", 55 | "version" : "2.2.1" 56 | } 57 | }, 58 | { 59 | "identity" : "hotkey", 60 | "kind" : "remoteSourceControl", 61 | "location" : "https://github.com/soffes/HotKey", 62 | "state" : { 63 | "branch" : "main", 64 | "revision" : "a3cf605d7a96f6ff50e04fcb6dea6e2613cfcbe4" 65 | } 66 | }, 67 | { 68 | "identity" : "latexswiftui", 69 | "kind" : "remoteSourceControl", 70 | "location" : "https://github.com/colinc86/LaTeXSwiftUI", 71 | "state" : { 72 | "branch" : "main", 73 | "revision" : "c45e0fd45f64923c49c5904a9f9626bc8939f05f" 74 | } 75 | }, 76 | { 77 | "identity" : "mathjaxswift", 78 | "kind" : "remoteSourceControl", 79 | "location" : "https://github.com/colinc86/MathJaxSwift", 80 | "state" : { 81 | "revision" : "e23d6eab941da699ac4a60fb0e60f3ba5c937459", 82 | "version" : "3.4.0" 83 | } 84 | }, 85 | { 86 | "identity" : "networkimage", 87 | "kind" : "remoteSourceControl", 88 | "location" : "https://github.com/gonzalezreal/NetworkImage", 89 | "state" : { 90 | "revision" : "2849f5323265386e200484b0d0f896e73c3411b9", 91 | "version" : "6.0.1" 92 | } 93 | }, 94 | { 95 | "identity" : "openai", 96 | "kind" : "remoteSourceControl", 97 | "location" : "https://github.com/MacPaw/OpenAI", 98 | "state" : { 99 | "revision" : "9261cd39d55a718bcc360fbc29515a331cad5dbb", 100 | "version" : "0.4.3" 101 | } 102 | }, 103 | { 104 | "identity" : "pathkit", 105 | "kind" : "remoteSourceControl", 106 | "location" : "https://github.com/kylef/PathKit.git", 107 | "state" : { 108 | "revision" : "3bfd2737b700b9a36565a8c94f4ad2b050a5e574", 109 | "version" : "1.0.1" 110 | } 111 | }, 112 | { 113 | "identity" : "settingsaccess", 114 | "kind" : "remoteSourceControl", 115 | "location" : "https://github.com/orchetect/SettingsAccess", 116 | "state" : { 117 | "revision" : "08e80c35501f273afa2f5d6f737429bbe395ff81", 118 | "version" : "2.1.0" 119 | } 120 | }, 121 | { 122 | "identity" : "shortcutrecorder", 123 | "kind" : "remoteSourceControl", 124 | "location" : "https://github.com/Kentzo/ShortcutRecorder", 125 | "state" : { 126 | "revision" : "c86ce0f9be5353ba998966121c7631602a9a36f7", 127 | "version" : "3.4.0" 128 | } 129 | }, 130 | { 131 | "identity" : "sparkle", 132 | "kind" : "remoteSourceControl", 133 | "location" : "https://github.com/sparkle-project/Sparkle", 134 | "state" : { 135 | "revision" : "0ca3004e98712ea2b39dd881d28448630cce1c99", 136 | "version" : "2.7.0" 137 | } 138 | }, 139 | { 140 | "identity" : "spectre", 141 | "kind" : "remoteSourceControl", 142 | "location" : "https://github.com/kylef/Spectre.git", 143 | "state" : { 144 | "revision" : "26cc5e9ae0947092c7139ef7ba612e34646086c7", 145 | "version" : "0.10.1" 146 | } 147 | }, 148 | { 149 | "identity" : "stencil", 150 | "kind" : "remoteSourceControl", 151 | "location" : "https://github.com/stencilproject/Stencil.git", 152 | "state" : { 153 | "revision" : "4f222ac85d673f35df29962fc4c36ccfdaf9da5b", 154 | "version" : "0.15.1" 155 | } 156 | }, 157 | { 158 | "identity" : "swift-cmark", 159 | "kind" : "remoteSourceControl", 160 | "location" : "https://github.com/swiftlang/swift-cmark", 161 | "state" : { 162 | "revision" : "b022b08312decdc46585e0b3440d97f6f22ef703", 163 | "version" : "0.6.0" 164 | } 165 | }, 166 | { 167 | "identity" : "swift-html-entities", 168 | "kind" : "remoteSourceControl", 169 | "location" : "https://github.com/Kitura/swift-html-entities", 170 | "state" : { 171 | "revision" : "d8ca73197f59ce260c71bd6d7f6eb8bbdccf508b", 172 | "version" : "4.0.1" 173 | } 174 | }, 175 | { 176 | "identity" : "swift-http-types", 177 | "kind" : "remoteSourceControl", 178 | "location" : "https://github.com/apple/swift-http-types", 179 | "state" : { 180 | "revision" : "a0a57e949a8903563aba4615869310c0ebf14c03", 181 | "version" : "1.4.0" 182 | } 183 | }, 184 | { 185 | "identity" : "swift-markdown-ui", 186 | "kind" : "remoteSourceControl", 187 | "location" : "https://github.com/gonzalezreal/swift-markdown-ui", 188 | "state" : { 189 | "revision" : "5f613358148239d0292c0cef674a3c2314737f9e", 190 | "version" : "2.4.1" 191 | } 192 | }, 193 | { 194 | "identity" : "swift-openapi-runtime", 195 | "kind" : "remoteSourceControl", 196 | "location" : "https://github.com/apple/swift-openapi-runtime", 197 | "state" : { 198 | "revision" : "8f33cc5dfe81169fb167da73584b9c72c3e8bc23", 199 | "version" : "1.8.2" 200 | } 201 | }, 202 | { 203 | "identity" : "swiftanthropic", 204 | "kind" : "remoteSourceControl", 205 | "location" : "https://github.com/jamesrochabrun/SwiftAnthropic", 206 | "state" : { 207 | "revision" : "c069979c681de4434b6611c091c0cab01f141213", 208 | "version" : "2.1.7" 209 | } 210 | }, 211 | { 212 | "identity" : "swiftdraw", 213 | "kind" : "remoteSourceControl", 214 | "location" : "https://github.com/swhitty/SwiftDraw", 215 | "state" : { 216 | "revision" : "2a36e6ce369cbecdd031ee95f5b5b3faa69ef50f", 217 | "version" : "0.21.0" 218 | } 219 | }, 220 | { 221 | "identity" : "yams", 222 | "kind" : "remoteSourceControl", 223 | "location" : "https://github.com/jpsim/Yams.git", 224 | "state" : { 225 | "revision" : "3d6871d5b4a5cd519adf233fbb576e0a2af71c17", 226 | "version" : "5.4.0" 227 | } 228 | } 229 | ], 230 | "version" : 3 231 | } 232 | -------------------------------------------------------------------------------- /Selected.xcodeproj/xcshareddata/xcschemes/Selected.xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 5 | 8 | 9 | 15 | 21 | 22 | 23 | 24 | 25 | 31 | 32 | 42 | 44 | 50 | 51 | 52 | 53 | 59 | 61 | 67 | 68 | 69 | 70 | 72 | 73 | 76 | 77 | 78 | -------------------------------------------------------------------------------- /Selected/App.swift: -------------------------------------------------------------------------------- 1 | // 2 | // App.swift 3 | // Selected 4 | // 5 | // Created by sake on 2024/2/28. 6 | // 7 | 8 | import SwiftUI 9 | import Accessibility 10 | import AppKit 11 | import Foundation 12 | import Defaults 13 | 14 | 15 | class AppDelegate: NSObject, NSApplicationDelegate { 16 | 17 | func applicationDidFinishLaunching(_ notification: Notification) { 18 | setDefaultAppForCustomFileType() 19 | // 不需要主窗口,不需要显示在 dock 上 20 | NSApp.setActivationPolicy(NSApplication.ActivationPolicy.accessory) 21 | requestAccessibilityPermissions() 22 | 23 | DispatchQueue.main.async { 24 | PersistenceController.shared.startDailyTimer() 25 | } 26 | 27 | PluginManager.shared.loadPlugins() 28 | ConfigurationManager.shared.loadConfiguration() 29 | DispatchQueue.main.async { 30 | monitorMouseMove() 31 | } 32 | DispatchQueue.main.async { 33 | ClipService.shared.startMonitoring() 34 | } 35 | 36 | DispatchQueue.main.async { 37 | ClipboardHotKeyManager.shared.registerHotKey() 38 | SpotlightHotKeyManager.shared.registerHotKey() 39 | } 40 | 41 | // 注册空间改变通知 42 | // 这里不能使用 NotificationCenter.default. 43 | NSWorkspace.shared.notificationCenter.addObserver(self, 44 | selector: #selector(spaceDidChange), 45 | name: NSWorkspace.activeSpaceDidChangeNotification, 46 | object: nil) 47 | } 48 | 49 | @objc func spaceDidChange() { 50 | // 当空间改变时触发 51 | ClipWindowManager.shared.forceCloseWindow() 52 | ChatWindowManager.shared.closeAllWindows(.force) 53 | SpotlightWindowManager.shared.forceCloseWindow() 54 | } 55 | 56 | func application(_ application: NSApplication, open urls: [URL]) { 57 | for url in urls { 58 | // 处理打开的文件 59 | print("\(url.path)") 60 | PluginManager.shared.install(url: url) 61 | } 62 | } 63 | 64 | func applicationWillBecomeActive(_ notification: Notification) { 65 | // 当 app 变为活跃时关闭全局热键 66 | ClipboardHotKeyManager.shared.unregisterHotKey() 67 | SpotlightHotKeyManager.shared.unregisterHotKey() 68 | } 69 | 70 | func applicationDidResignActive(_ notification: Notification) { 71 | if Defaults[.enableClipboard] { 72 | // 当 app 退到后台时开启全局热键 73 | ClipboardHotKeyManager.shared.registerHotKey() 74 | } 75 | SpotlightHotKeyManager.shared.registerHotKey() 76 | } 77 | } 78 | 79 | 80 | func setDefaultAppForCustomFileType() { 81 | let customUTI = "io.kitool.selected.ext" 82 | let bundleIdentifier = Bundle.main.bundleIdentifier ?? "io.kitool.Selected" 83 | print("bundleIdentifier \(bundleIdentifier)") 84 | 85 | LSSetDefaultRoleHandlerForContentType(customUTI as CFString, .editor, bundleIdentifier as CFString) 86 | } 87 | 88 | 89 | @main 90 | struct SelectedApp: App { 91 | @NSApplicationDelegateAdaptor(AppDelegate.self) var appDelegate 92 | 93 | var body: some Scene { 94 | MenuBarExtra() { 95 | MenuItemView() 96 | } label: { 97 | Label { 98 | Text("Selected") 99 | } icon: { 100 | Image(systemName: "pencil.and.scribble") 101 | .resizable() 102 | .renderingMode(.template) 103 | .scaledToFit() 104 | } 105 | .help("Selected") 106 | } 107 | .menuBarExtraStyle(.menu) 108 | .commands { 109 | SelectedMainMenu() 110 | }.handlesExternalEvents(matching: []) 111 | Settings { 112 | SettingsView() 113 | } 114 | } 115 | } 116 | 117 | 118 | func requestAccessibilityPermissions() { 119 | // 判断权限 120 | let options: NSDictionary = [kAXTrustedCheckOptionPrompt.takeUnretainedValue() as String: true] 121 | let accessEnabled = AXIsProcessTrustedWithOptions(options) 122 | 123 | print("accessEnabled: \(accessEnabled)") 124 | 125 | if !accessEnabled { 126 | // 请求权限 127 | // 注意不能是 sandbox,否则辅助功能里无法看到这个 app 128 | NSWorkspace.shared.open(URL(string: "x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility")!) 129 | } 130 | } 131 | 132 | let kExpandedLength: CGFloat = 100 133 | 134 | 135 | // 监听鼠标移动 136 | func monitorMouseMove() { 137 | var eventState = EventState() 138 | var hoverWorkItem: DispatchWorkItem? 139 | var lastSelectedText = "" 140 | 141 | NSEvent.addGlobalMonitorForEvents(matching: 142 | [.mouseMoved, .leftMouseUp, .leftMouseDragged, .keyDown, .scrollWheel] 143 | ) { (event) in 144 | if PauseModel.shared.pause { 145 | return 146 | } 147 | if event.type == .mouseMoved { 148 | if WindowManager.shared.closeOnlyPopbarWindows(.expanded) { 149 | lastSelectedText = "" 150 | } 151 | eventState.lastMouseEventType = .mouseMoved 152 | } else if event.type == .scrollWheel { 153 | lastSelectedText = "" 154 | _ = WindowManager.shared.closeAllWindows(.original) 155 | } else { 156 | print("event \(eventTypeMap[event.type]!) \(eventTypeMap[eventState.lastMouseEventType]!)") 157 | var updatedSelectedText = false 158 | if eventState.isSelected(event: event) { 159 | if let ctx = getSelectedText() { 160 | print("SelectedContext %@", ctx) 161 | if !ctx.Text.isEmpty { 162 | updatedSelectedText = true 163 | if lastSelectedText != ctx.Text { 164 | lastSelectedText = ctx.Text 165 | hoverWorkItem?.cancel() 166 | 167 | let workItem = DispatchWorkItem { 168 | WindowManager.shared.createPopBarWindow(ctx) 169 | } 170 | hoverWorkItem = workItem 171 | let delay = 0.2 172 | // 在 0.2 秒后执行 173 | // 解决,3 连击选定整行是从 2 连击加一次连击产生的。所以会在短时间内出现2个2次连续鼠标左键释放。 174 | // 导致获取选定文本两次,绘制、关闭、再绘制窗口,造成窗口闪烁。 175 | // 如果 0.2 秒内再次有点击的话,就取消之前的绘制窗口,这样能避免窗口闪烁。 176 | DispatchQueue.main.asyncAfter(deadline: .now() + delay, execute: workItem) 177 | } 178 | } 179 | } 180 | } 181 | 182 | if !updatedSelectedText && 183 | getBundleID() != SelfBundleID { 184 | lastSelectedText = "" 185 | _ = WindowManager.shared.closeAllWindows(.original) 186 | ChatWindowManager.shared.closeAllWindows(.original) 187 | } 188 | } 189 | } 190 | print("monitorMouseMove") 191 | } 192 | 193 | struct EventState { 194 | // 在 vscode、zed 里,使用在没有任何选择的文本时,cmd+c 可以复制整行。 195 | // 而这两个 app 只能通过 cmd+c 获取选中的文本。 196 | // 导致如果我们只监听 leftMouseUp 的话,会导致无论点击在哪里,都会形式悬浮栏。 197 | // 所以这里,我们改成,如果当前是 leftMouseUp: 198 | // 1. 判断上次 leftMouseUp 的时间是否小于 0.5s,这个是鼠标左键连击的判断方法 199 | // 双击选词,三击选行。 200 | // 2. 判断上次是否是 leftMouseDragged。这表示左键单击+拖拽选择文本。 201 | // 另外我们还监听了:cmd+A(全选),以及 cmd+shift+arrow(部分选择)。 202 | var lastLeftMouseUPTime = 0.0 203 | var lastMouseEventType: NSEvent.EventType = .leftMouseUp 204 | 205 | let keyCodeArrows: [UInt16] = [Keycode.leftArrow, Keycode.rightArrow, Keycode.downArrow, Keycode.upArrow] 206 | 207 | mutating func isSelected(event: NSEvent ) -> Bool { 208 | defer { 209 | if event.type != .keyDown { 210 | lastMouseEventType = event.type 211 | } 212 | } 213 | if event.type == .leftMouseUp { 214 | let selected = lastMouseEventType == .leftMouseDragged || 215 | ((lastMouseEventType == .leftMouseUp) && (event.timestamp - lastLeftMouseUPTime < 0.5)) 216 | lastLeftMouseUPTime = event.timestamp 217 | return selected 218 | } else if event.type == .keyDown { 219 | if event.keyCode == Keycode.a { 220 | return event.modifierFlags.contains(.command) && 221 | !event.modifierFlags.contains(.shift) && !event.modifierFlags.contains(.control) 222 | } else if keyCodeArrows.contains( event.keyCode) { 223 | let keyMask: NSEvent.ModifierFlags = [.command, .shift] 224 | return event.modifierFlags.intersection(keyMask) == keyMask 225 | } 226 | } 227 | return false 228 | } 229 | } 230 | 231 | let eventTypeMap: [ NSEvent.EventType: String] = [ 232 | .mouseMoved: "mouseMoved", 233 | .keyDown: "keydonw", 234 | .keyUp: "keyup", 235 | .leftMouseUp: "leftMouseUp", 236 | .leftMouseDragged: "leftMouseDragged", 237 | .scrollWheel: "scrollWheel" 238 | ] 239 | 240 | -------------------------------------------------------------------------------- /Selected/Assets.xcassets/AccentColor.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "idiom" : "universal" 5 | } 6 | ], 7 | "info" : { 8 | "author" : "xcode", 9 | "version" : 1 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /Selected/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 | -------------------------------------------------------------------------------- /Selected/Assets.xcassets/AppIcon.appiconset/icon_128x128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sakeven/Selected/5535a6cd07ba259f3804220e6e73d1bdba4b64b8/Selected/Assets.xcassets/AppIcon.appiconset/icon_128x128.png -------------------------------------------------------------------------------- /Selected/Assets.xcassets/AppIcon.appiconset/icon_128x128@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sakeven/Selected/5535a6cd07ba259f3804220e6e73d1bdba4b64b8/Selected/Assets.xcassets/AppIcon.appiconset/icon_128x128@2x.png -------------------------------------------------------------------------------- /Selected/Assets.xcassets/AppIcon.appiconset/icon_16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sakeven/Selected/5535a6cd07ba259f3804220e6e73d1bdba4b64b8/Selected/Assets.xcassets/AppIcon.appiconset/icon_16x16.png -------------------------------------------------------------------------------- /Selected/Assets.xcassets/AppIcon.appiconset/icon_16x16@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sakeven/Selected/5535a6cd07ba259f3804220e6e73d1bdba4b64b8/Selected/Assets.xcassets/AppIcon.appiconset/icon_16x16@2x.png -------------------------------------------------------------------------------- /Selected/Assets.xcassets/AppIcon.appiconset/icon_256x256.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sakeven/Selected/5535a6cd07ba259f3804220e6e73d1bdba4b64b8/Selected/Assets.xcassets/AppIcon.appiconset/icon_256x256.png -------------------------------------------------------------------------------- /Selected/Assets.xcassets/AppIcon.appiconset/icon_256x256@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sakeven/Selected/5535a6cd07ba259f3804220e6e73d1bdba4b64b8/Selected/Assets.xcassets/AppIcon.appiconset/icon_256x256@2x.png -------------------------------------------------------------------------------- /Selected/Assets.xcassets/AppIcon.appiconset/icon_32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sakeven/Selected/5535a6cd07ba259f3804220e6e73d1bdba4b64b8/Selected/Assets.xcassets/AppIcon.appiconset/icon_32x32.png -------------------------------------------------------------------------------- /Selected/Assets.xcassets/AppIcon.appiconset/icon_32x32@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sakeven/Selected/5535a6cd07ba259f3804220e6e73d1bdba4b64b8/Selected/Assets.xcassets/AppIcon.appiconset/icon_32x32@2x.png -------------------------------------------------------------------------------- /Selected/Assets.xcassets/AppIcon.appiconset/icon_512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sakeven/Selected/5535a6cd07ba259f3804220e6e73d1bdba4b64b8/Selected/Assets.xcassets/AppIcon.appiconset/icon_512x512.png -------------------------------------------------------------------------------- /Selected/Assets.xcassets/AppIcon.appiconset/icon_512x512@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sakeven/Selected/5535a6cd07ba259f3804220e6e73d1bdba4b64b8/Selected/Assets.xcassets/AppIcon.appiconset/icon_512x512@2x.png -------------------------------------------------------------------------------- /Selected/Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /Selected/ClipHistory.xcdatamodeld/ClipHistory.xcdatamodel/contents: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /Selected/Configuration/Defaults.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Defaults.swift 3 | // Selected 4 | // 5 | // Created by sake on 2024/2/28. 6 | // 7 | 8 | import Defaults 9 | import Foundation 10 | import OpenAI 11 | import ShortcutRecorder 12 | import SwiftUI 13 | 14 | 15 | 16 | // Service Configuration 17 | extension Defaults.Keys { 18 | 19 | static let search = Key("SearchURL", default: "https://www.google.com/search?q={selected.text}") 20 | 21 | static let aiService = Key("AIService", default: "OpenAI") 22 | 23 | // OpenAI 24 | static let openAIAPIKey = Key("OpenAIAPIKey", default: "") 25 | static let openAIAPIHost = Key("OpenAIAPIHost",default: "api.openai.com") 26 | static let openAIModel = Key("OpenAIModel", default: .gpt4_o) 27 | static let openAIModelReasoningEffort = Key("openAIModelReasoningEffort", default: 28 | "medium") 29 | 30 | static let openAITranslationModel = Key("OpenAITranslationModel", default: .gpt4_o_mini) 31 | 32 | static let openAIVoice = Key("OpenAIVoice", default: .shimmer) 33 | static let openAITTSModel = Key("OpenAITTSModel", default: .gpt_4o_mini_tts) 34 | static let openAITTSInstructions = Key("OpenAITTSInstructions", default: "") 35 | 36 | 37 | // Gemini 38 | static let geminiAPIKey = Key("GeminiAPIKey", default: "") 39 | 40 | // Claude 41 | static let claudeAPIKey = Key("ClaudeAPIKey", default: "") 42 | static let claudeAPIHost = Key("ClaudeAPIHost", default: "https://api.anthropic.com") 43 | static let claudeModel = Key("ClaudeModel", default: ClaudeModel.claude35Sonnet.value) 44 | 45 | // clipboard 46 | static let enableClipboard = Key("EnableClipboard", default: false) 47 | static let clipboardShortcut = Key("ClipboardShortcut", default: Shortcut(keyEquivalent: "⌥Space")!) 48 | static let clipboardHistoryTime = Key("ClipboardHistoryTime", default: ClipboardHistoryTime.SevenDays) 49 | 50 | // spotlight 51 | static let spotlightShortcut = Key("SpotlightShortcut", default: Shortcut(keyEquivalent: "⌥X")!) 52 | } 53 | 54 | 55 | enum ClipboardHistoryTime: String, Defaults.Serializable, CaseIterable { 56 | case OneDay = "24 Hours", SevenDays="7 Days", ThirtyDays = "30 Days" 57 | case ThreeMonths = "3 Months", SixMonths="6 Months", OneYear = "1 Year" 58 | 59 | var localizedName: LocalizedStringKey { LocalizedStringKey(rawValue) } 60 | } 61 | 62 | 63 | extension ChatQuery.ReasoningEffort: Defaults.Serializable, @retroactive CaseIterable{ 64 | public static var allCases: [ChatQuery.ReasoningEffort] = [.low, .medium, .high] 65 | } 66 | 67 | 68 | extension AudioSpeechQuery.AudioSpeechVoice: Defaults.Serializable{} 69 | 70 | 71 | extension Shortcut: Defaults.Serializable{ 72 | public static let bridge = ShortcutBridge() 73 | } 74 | 75 | public struct ShortcutBridge: Defaults.Bridge { 76 | public typealias Value = Shortcut 77 | public typealias Serializable = [ShortcutKey: Any] 78 | 79 | public func serialize(_ value: Value?) -> Serializable? { 80 | guard let value else { 81 | return nil 82 | } 83 | return value.dictionaryRepresentation 84 | } 85 | 86 | public func deserialize(_ object: Serializable?) -> Value? { 87 | guard 88 | let val = object 89 | else { 90 | return nil 91 | } 92 | return Shortcut(dictionary: val) 93 | } 94 | } 95 | 96 | 97 | // 应用程序支持目录的URL 98 | let appSupportURL = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first!.appendingPathComponent("Selected/", isDirectory: true) 99 | 100 | -------------------------------------------------------------------------------- /Selected/Configuration/UserConfigs.swift: -------------------------------------------------------------------------------- 1 | // 2 | // UserConfigs.swift 3 | // Selected 4 | // 5 | // Created by sake on 2024/3/17. 6 | // 7 | 8 | import Foundation 9 | 10 | typealias ActionID = String 11 | 12 | // AppCondition 指定某个 app 下的 action 列表。 13 | struct AppCondition: Codable { 14 | let bundleID: String // bundleID of app 15 | var actions: [ActionID] // 在这个 app 下启用的插件列表,以及显示顺序 16 | } 17 | 18 | // URLCondition 指定某个 url 下的 action 列表。 19 | struct URLCondition: Codable { 20 | let url: String // URLCondition 21 | var actions: [ActionID] // 在这个 app 下启用的插件列表,以及显示顺序 22 | } 23 | 24 | struct UserConfiguration: Codable { 25 | var defaultActions: [ActionID] 26 | var appConditions: [AppCondition] // 用户设置的应用列表 27 | var urlConditions: [URLCondition] // 用户设置的 URL 列表 28 | } 29 | 30 | // ConfigurationManager 读取、保存应用的复杂配置,比如什么应用下启用哪些 action 等等。 31 | // 配置保存在 "Library/Application Support/Selected" 下。 32 | class ConfigurationManager { 33 | static let shared = ConfigurationManager() 34 | private let configurationFileName = "UserConfiguration.json" 35 | 36 | var userConfiguration: UserConfiguration 37 | 38 | init() { 39 | userConfiguration = UserConfiguration(defaultActions: [], appConditions: [], urlConditions: []) 40 | loadConfiguration() 41 | } 42 | 43 | func getAppCondition(bundleID: String) -> AppCondition? { 44 | for condition in userConfiguration.appConditions { 45 | if condition.bundleID == bundleID { 46 | return condition 47 | } 48 | } 49 | if userConfiguration.defaultActions.count > 0 { 50 | return AppCondition(bundleID: bundleID, actions: userConfiguration.defaultActions) 51 | } 52 | return nil 53 | } 54 | 55 | func getURLCondition(url: String) -> URLCondition? { 56 | for condition in userConfiguration.urlConditions { 57 | if url.contains(condition.url) { 58 | return condition 59 | } 60 | } 61 | return nil 62 | } 63 | 64 | func loadConfiguration() { 65 | let fileURL = appSupportURL.appendingPathComponent(configurationFileName) 66 | print("UserConfiguration \(fileURL.absoluteString)") 67 | do { 68 | let data = try Data(contentsOf: fileURL) 69 | userConfiguration = try JSONDecoder().decode(UserConfiguration.self, from: data) 70 | } catch { 71 | print("Error loading configuration: \(error)") 72 | } 73 | } 74 | 75 | func saveConfiguration() { 76 | let fileURL = appSupportURL.appendingPathComponent(configurationFileName) 77 | do { 78 | let encoder = JSONEncoder() 79 | encoder.outputFormatting = .prettyPrinted 80 | let data = try encoder.encode(userConfiguration) 81 | try data.write(to: fileURL, options: .atomic) 82 | } catch { 83 | print("Error saving configuration: \(error)") 84 | } 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /Selected/Fonts/UbuntuMonoNerdFontMono-Regular.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sakeven/Selected/5535a6cd07ba259f3804220e6e73d1bdba4b64b8/Selected/Fonts/UbuntuMonoNerdFontMono-Regular.ttf -------------------------------------------------------------------------------- /Selected/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleVersion 6 | 0.2.1 7 | SUFeedURL 8 | https://raw.githubusercontent.com/sakeven/Selected-Release/main/appcast.xml 9 | SUPublicEDKey 10 | gyZTNONhuZUnxRNatLw6Lwj9KTHi8DaZXX+m8s6dZ+Y= 11 | NSLocationAlwaysAndWhenInUseUsageDescription 12 | Based on the address, GPT provides better suggestions and responses. 13 | ATSApplicationFontsPath 14 | Fonts 15 | CFBundleDocumentTypes 16 | 17 | 18 | CFBundleTypeExtensions 19 | 20 | selectedext 21 | 22 | CFBundleTypeIconSystemGenerated 23 | 1 24 | CFBundleTypeName 25 | selected extension 26 | CFBundleTypeRole 27 | Viewer 28 | LSHandlerRank 29 | Owner 30 | LSTypeIsPackage 31 | 32 | 33 | 34 | UTExportedTypeDeclarations 35 | 36 | 37 | UTTypeConformsTo 38 | 39 | public.folder 40 | 41 | UTTypeDescription 42 | selected extension 43 | UTTypeIdentifier 44 | io.kitool.selected.ext 45 | UTTypeTagSpecification 46 | 47 | public.filename-extension 48 | 49 | selectedext 50 | 51 | 52 | 53 | 54 | 55 | 56 | -------------------------------------------------------------------------------- /Selected/Plugin/GPTAction.swift: -------------------------------------------------------------------------------- 1 | // 2 | // GPTAction.swift 3 | // Selected 4 | // 5 | // Created by sake on 2024/6/2. 6 | // 7 | 8 | import Foundation 9 | import Defaults 10 | 11 | class GptAction: Decodable{ 12 | var prompt: String 13 | var tools: [FunctionDefinition]? 14 | 15 | init(prompt: String) { 16 | self.prompt = prompt 17 | } 18 | 19 | func generate(pluginInfo: PluginInfo, generic: GenericAction) -> PerformAction { 20 | if generic.after == kAfterPaste { 21 | return PerformAction( 22 | actionMeta: generic, complete: { ctx in 23 | let chatCtx = ChatContext(text: ctx.Text, webPageURL: ctx.WebPageURL, bundleID: ctx.BundleID) 24 | await ChatService(prompt: self.prompt, options: pluginInfo.getOptionsValue())!.chat(ctx: chatCtx) { _, ret in 25 | if ret.role == .assistant { 26 | DispatchQueue.main.async{ 27 | _ = WindowManager.shared.closeOnlyPopbarWindows(.force) 28 | } 29 | pasteText(ret.message) 30 | } 31 | } 32 | }) 33 | } else { 34 | var chatService: AIChatService = ChatService(prompt: prompt, options: pluginInfo.getOptionsValue())! 35 | if let tools = tools { 36 | switch Defaults[.aiService] { 37 | case "Claude": 38 | chatService = ClaudeService(prompt: prompt, tools: tools, options: pluginInfo.getOptionsValue()) 39 | default: 40 | chatService = OpenAIService(prompt: prompt, tools: tools, options: pluginInfo.getOptionsValue()) 41 | } 42 | } 43 | 44 | return PerformAction( 45 | actionMeta: generic, complete: { ctx in 46 | let chatCtx = ChatContext(text: ctx.Text, webPageURL: ctx.WebPageURL, bundleID: ctx.BundleID) 47 | _ = WindowManager.shared.closeOnlyPopbarWindows(.force) 48 | ChatWindowManager.shared.createChatWindow(chatService: chatService, withContext: chatCtx) 49 | }) 50 | } 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /Selected/Plugin/Option.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Option.swift 3 | // Selected 4 | // 5 | // Created by sake on 2024/3/30. 6 | // 7 | 8 | import Foundation 9 | 10 | // it stores at ~/Library/Preferences/io.kitool.Selected..plist 11 | struct Option: Decodable { 12 | var identifier: String 13 | var type: OptionType 14 | var description: String? 15 | var defaultVal: String? 16 | var values: [String]? 17 | } 18 | 19 | enum OptionType: String, Decodable { 20 | case string, boolean, multiple, secret 21 | } 22 | 23 | func getBoolOption(pluginName: String, identifier: String) -> Bool { 24 | let defaults = UserDefaults(suiteName: defaultsSuiteName(pluginName))! 25 | return defaults.bool(forKey: identifier) 26 | } 27 | 28 | func getStringOption(pluginName: String, identifier: String) -> String? { 29 | let defaults = UserDefaults(suiteName: defaultsSuiteName(pluginName))! 30 | return defaults.string(forKey: identifier) 31 | } 32 | 33 | func setOption(pluginName: String, identifier: String, val: Any) { 34 | let defaults = UserDefaults(suiteName: defaultsSuiteName(pluginName))! 35 | defaults.set(val, forKey: identifier) 36 | PluginManager.shared.optionValueChangeCnt += 1 37 | } 38 | 39 | func removeOptionsOf(pluginName: String) { 40 | UserDefaults.standard.removePersistentDomain(forName: defaultsSuiteName(pluginName)) 41 | } 42 | 43 | func defaultsSuiteName(_ pluginName: String) -> String { 44 | let bundleIdentifier = Bundle.main.bundleIdentifier ?? "io.kitool.Selected" 45 | return bundleIdentifier + "." + pluginName 46 | } 47 | -------------------------------------------------------------------------------- /Selected/Plugin/RunCommandAction.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ScriptAction.swift 3 | // Selected 4 | // 5 | // Created by sake on 2024/3/19. 6 | // 7 | 8 | import Foundation 9 | import AppKit 10 | 11 | 12 | class RunCommandAction: Decodable { 13 | var command: [String] 14 | var pluginPath: String? // we will execute command in pluginPath. 15 | 16 | enum CodingKeys: String, CodingKey { 17 | case command 18 | } 19 | 20 | required init(from decoder: Decoder) throws { 21 | let values = try decoder.container(keyedBy: CodingKeys.self) 22 | command = try values.decode([String].self, forKey: .command) 23 | } 24 | 25 | 26 | init(command: [String], options: [Option]) { 27 | self.command = command 28 | } 29 | 30 | func generate(pluginInfo: PluginInfo, generic: GenericAction) -> PerformAction { 31 | return PerformAction(actionMeta: 32 | generic, complete: { ctx in 33 | guard self.command.count > 0 else { 34 | return 35 | } 36 | 37 | guard let pluginPath = self.pluginPath else { 38 | return 39 | } 40 | 41 | 42 | let joinedURLs = ctx.URLs.joined(separator: "\n") 43 | 44 | var env = ["SELECTED_TEXT": ctx.Text, 45 | "SELECTED_BUNDLEID": ctx.BundleID, 46 | "SELECTED_ACTION": generic.identifier, 47 | "SELECTED_WEBPAGE_URL": ctx.WebPageURL, 48 | "SELECTED_URLS": joinedURLs] 49 | let optionVals = pluginInfo.getOptionsValue() 50 | optionVals.forEach{ (key: String, value: String) in 51 | env["SELECTED_OPTIONS_"+key.uppercased()] = value 52 | } 53 | if let path = ProcessInfo.processInfo.environment["PATH"] { 54 | env["PATH"] = "/opt/homebrew/bin:/opt/homebrew/sbin:" + path 55 | } 56 | 57 | do { 58 | if let output = try executeCommand( 59 | workdir: pluginPath, 60 | command: self.command[0], 61 | arguments: [String](self.command[1...]), 62 | withEnv: env) { 63 | if ctx.Editable && generic.after == kAfterPaste { 64 | pasteText(output) 65 | } else if generic.after == kAfterCopy { 66 | copyText(output) 67 | } else if generic.after == kAfterShow { 68 | WindowManager.shared.createTextWindow(output) 69 | } 70 | } 71 | } catch { 72 | NSLog("executeCommand: \(error)") 73 | } 74 | }) 75 | } 76 | } 77 | 78 | func pasteText(_ text: String) { 79 | let id = UUID().uuidString 80 | ClipService.shared.pauseMonitor(id) 81 | defer { 82 | ClipService.shared.resumeMonitor(id) 83 | } 84 | let pasteboard = NSPasteboard.general 85 | let lastCopyText = pasteboard.string(forType: .string) 86 | 87 | pasteboard.clearContents() 88 | pasteboard.setString(text, forType: .string) 89 | PressPasteKey() 90 | usleep(100000) 91 | pasteboard.setString(lastCopyText ?? "", forType: .string) 92 | } 93 | 94 | func copyText(_ text: String) { 95 | let pasteboard = NSPasteboard.general 96 | pasteboard.clearContents() 97 | pasteboard.setString(text, forType: .string) 98 | } 99 | 100 | public func executeCommand( 101 | workdir: String, command: String, arguments: [String] = [], withEnv env: [String:String]) throws -> String? { 102 | let process = Process() 103 | process.qualityOfService = .default 104 | let stdOutPipe = Pipe() 105 | let stdErrPipe = Pipe() 106 | var path: String? 107 | if let p = ProcessInfo.processInfo.environment["PATH"] { 108 | path = "/opt/homebrew/bin:/opt/homebrew/sbin:" + p 109 | } 110 | 111 | let executableURL = findExecutablePath(commandName: command, 112 | currentDirectoryURL: URL(fileURLWithPath: workdir), 113 | path: path) 114 | 115 | process.executableURL = executableURL 116 | process.arguments = arguments 117 | process.standardOutput = stdOutPipe 118 | process.standardError = stdErrPipe 119 | process.currentDirectoryURL = URL(fileURLWithPath: workdir) 120 | 121 | var copiedEnv = env 122 | copiedEnv["PATH"] = path 123 | process.environment = copiedEnv 124 | 125 | var stdOutData = Data() 126 | var stdErrData = Data() 127 | 128 | // Create a Dispatch group to handle reading from pipes asynchronously 129 | let group = DispatchGroup() 130 | 131 | // Asynchronously read stdout 132 | group.enter() 133 | stdOutPipe.fileHandleForReading.readabilityHandler = { handle in 134 | let data = handle.availableData 135 | if data.isEmpty { 136 | stdOutPipe.fileHandleForReading.readabilityHandler = nil 137 | group.leave() 138 | } else { 139 | stdOutData.append(data) 140 | } 141 | } 142 | 143 | // Asynchronously read stderr 144 | group.enter() 145 | stdErrPipe.fileHandleForReading.readabilityHandler = { handle in 146 | let data = handle.availableData 147 | if data.isEmpty { 148 | stdErrPipe.fileHandleForReading.readabilityHandler = nil 149 | group.leave() 150 | } else { 151 | stdErrData.append(data) 152 | } 153 | } 154 | 155 | 156 | let timeout: TimeInterval = 60 // 1 min 157 | let timer = DispatchSource.makeTimerSource() 158 | timer.schedule(deadline: .now() + timeout) 159 | timer.setEventHandler { 160 | if process.isRunning { 161 | process.terminate() 162 | print("Process terminated due to timeout.") 163 | } 164 | timer.cancel() 165 | } 166 | 167 | var output: String? = nil 168 | 169 | try process.run() 170 | timer.activate() 171 | process.waitUntilExit() 172 | 173 | // Ensure all data has been read 174 | group.wait() 175 | 176 | output = String(data: stdOutData + stdErrData, encoding: .utf8) 177 | return output 178 | } 179 | 180 | 181 | private func findExecutablePath(commandName: String, currentDirectoryURL: URL? = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first, path: String? = ProcessInfo.processInfo.environment["PATH"]) -> URL? { 182 | let fileManager = FileManager.default 183 | // 先检查是否是绝对路径 184 | let executableURL = URL(fileURLWithPath: commandName) 185 | if executableURL.isFileURL, fileManager.isExecutableFile(atPath: executableURL.path) { 186 | return executableURL 187 | } 188 | 189 | // 检查命令是否在当前目录 190 | if let currentDirectoryURL = currentDirectoryURL { 191 | let currentDirectoryExecutable = currentDirectoryURL.appendingPathComponent(commandName) 192 | if FileManager.default.isExecutableFile(atPath: currentDirectoryExecutable.path) { 193 | return currentDirectoryExecutable 194 | } 195 | } 196 | 197 | // 然后检查命令是否在 PATH 环境变量中的某个目录 198 | if let path = path { 199 | let paths = path.split(separator: ":").map { String($0) } 200 | for p in paths { 201 | let potentialURL = URL(fileURLWithPath: p).appendingPathComponent(commandName) 202 | if FileManager.default.isExecutableFile(atPath: potentialURL.path) { 203 | return potentialURL 204 | } 205 | } 206 | } 207 | 208 | // 如果找不到可执行文件返回 nil 209 | return nil 210 | } 211 | -------------------------------------------------------------------------------- /Selected/Plugin/SpeakAction.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SpeakAction.swift 3 | // Selected 4 | // 5 | // Created by sake on 2024/3/28. 6 | // 7 | 8 | import Foundation 9 | import SwiftUI 10 | 11 | class SpeackAction: Decodable { 12 | func generate(generic: GenericAction) -> PerformAction { 13 | return PerformAction( 14 | actionMeta: generic, complete: { ctx in 15 | await TTSManager.speak(ctx.Text) 16 | }) 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /Selected/Preview Content/Preview Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /Selected/Selected.entitlements: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | com.apple.security.automation.apple-events 6 | 7 | com.apple.security.cs.allow-jit 8 | 9 | com.apple.security.personal-information.location 10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /Selected/Service/AI.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AI.swift 3 | // Selected 4 | // 5 | // Created by sake on 2024/3/10. 6 | // 7 | 8 | import Defaults 9 | import SwiftUI 10 | import OpenAI 11 | 12 | public struct ChatContext { 13 | let text: String 14 | let webPageURL: String 15 | let bundleID: String 16 | } 17 | 18 | func isWord(str: String) -> Bool { 19 | for c in str { 20 | if c.isLetter || c == "-" { 21 | continue 22 | } 23 | return false 24 | } 25 | return true 26 | } 27 | 28 | struct Translation { 29 | let toLanguage: String 30 | 31 | func translate(content: String, completion: @escaping (_: String) -> Void) async -> Void{ 32 | if toLanguage == "cn" { 33 | await contentTrans2Chinese(content: content, completion: completion) 34 | } else if toLanguage == "en" { 35 | await contentTrans2English(content: content, completion: completion) 36 | } 37 | } 38 | 39 | private func isWord(str: String) -> Bool { 40 | for c in str { 41 | if c.isLetter || c == "-" { 42 | continue 43 | } 44 | return false 45 | } 46 | return true 47 | } 48 | 49 | private func contentTrans2Chinese(content: String, completion: @escaping (_: String) -> Void) async -> Void{ 50 | switch Defaults[.aiService] { 51 | case "OpenAI": 52 | if isWord(str: content) { 53 | let OpenAIWordTrans = OpenAIService(prompt: "翻译以下单词到中文,详细说明单词的不同意思,并且给出原语言的例句与翻译。使用 markdown 的格式回复,要求第一行标题为单词。单词为:{selected.text}", model: Defaults[.openAITranslationModel]) 54 | await OpenAIWordTrans.chatOne(selectedText: content, completion: completion) 55 | } else { 56 | let OpenAITrans2Chinese = OpenAIService(prompt:"你是一位精通简体中文的专业翻译。翻译指定的内容到中文。规则:请直接回复翻译后的内容。内容为:{selected.text}", model: Defaults[.openAITranslationModel]) 57 | await OpenAITrans2Chinese.chatOne(selectedText: content, completion: completion) 58 | } 59 | case "Claude": 60 | if isWord(str: content) { 61 | await ClaudeWordTrans.chatOne(selectedText: content, completion: completion) 62 | } else { 63 | await ClaudeTrans2Chinese.chatOne(selectedText: content, completion: completion) 64 | } 65 | default: 66 | completion("no model \(Defaults[.aiService])") 67 | } 68 | } 69 | 70 | private func contentTrans2English(content: String, completion: @escaping (_: String) -> Void) async -> Void{ 71 | switch Defaults[.aiService] { 72 | case "OpenAI": 73 | let OpenAITrans2English = OpenAIService(prompt:"You are a professional translator proficient in English. Translate the following content into English. Rule: reply with the translated content directly. The content is:{selected.text}", model: Defaults[.openAITranslationModel]) 74 | await OpenAITrans2English.chatOne(selectedText: content, completion: completion) 75 | case "Claude": 76 | await ClaudeTrans2English.chatOne(selectedText: content, completion: completion) 77 | default: 78 | completion("no model \(Defaults[.aiService])") 79 | } 80 | } 81 | 82 | private func convert(index: Int, message: ResponseMessage)->Void { 83 | 84 | } 85 | } 86 | 87 | struct ChatService: AIChatService{ 88 | var chatService: AIChatService 89 | 90 | init?(prompt: String, options: [String:String]){ 91 | switch Defaults[.aiService] { 92 | case "OpenAI": 93 | chatService = OpenAIService(prompt: prompt, options: options) 94 | case "Claude": 95 | chatService = ClaudeService(prompt: prompt, options: options) 96 | default: 97 | return nil 98 | } 99 | } 100 | 101 | func chat(ctx: ChatContext, completion: @escaping (_: Int, _: ResponseMessage) -> Void) async -> Void{ 102 | await chatService.chat(ctx: ctx, completion: completion) 103 | } 104 | 105 | func chatFollow( 106 | index: Int, 107 | userMessage: String, 108 | completion: @escaping (_: Int, _: ResponseMessage) -> Void) async -> Void { 109 | await chatService.chatFollow(index: index, userMessage: userMessage, completion: completion) 110 | } 111 | } 112 | 113 | 114 | 115 | 116 | 117 | public protocol AIChatService { 118 | func chat(ctx: ChatContext, completion: @escaping (_: Int, _: ResponseMessage) -> Void) async -> Void 119 | func chatFollow( 120 | index: Int, 121 | userMessage: String, 122 | completion: @escaping (_: Int, _: ResponseMessage) -> Void) async -> Void 123 | } 124 | 125 | 126 | public class ResponseMessage: ObservableObject, Identifiable, Equatable{ 127 | public static func == (lhs: ResponseMessage, rhs: ResponseMessage) -> Bool { 128 | lhs.id == rhs.id 129 | } 130 | 131 | public enum Status: String { 132 | case initial, updating, finished, failure 133 | } 134 | 135 | public enum Role: String { 136 | case assistant, tool, user, system 137 | } 138 | 139 | public var id = UUID() 140 | @Published var message: String 141 | @Published var role: Role 142 | @Published var status: Status 143 | var new: Bool = false // new start of message 144 | 145 | init(id: UUID = UUID(), message: String, role: Role, new: Bool = false, status: Status = .initial) { 146 | self.id = id 147 | self.message = message 148 | self.role = role 149 | self.new = new 150 | self.status = status 151 | } 152 | } 153 | 154 | 155 | func systemPrompt() -> String{ 156 | let dateFormatter = DateFormatter() 157 | dateFormatter.locale = Locale.current 158 | dateFormatter.dateFormat = "yyyy-MM-dd HH:mm:ss" 159 | let localDate = dateFormatter.string(from: Date()) 160 | 161 | let language = getCurrentAppLanguage() 162 | var currentLocation = "" 163 | if let location = LocationManager.shared.place { 164 | currentLocation = "I'm at \(location)" 165 | } 166 | return """ 167 | Current time is \(localDate). 168 | \(currentLocation) 169 | You are a tool running on macOS called Selected. You can help user do anything. 170 | The system language is \(language), you should try to reply in \(language) as much as possible, unless the user specifies to use another language, such as specifying to translate into a certain language. 171 | """ 172 | } 173 | 174 | 175 | let svgToolOpenAIDef = ChatQuery.ChatCompletionToolParam.FunctionDefinition( 176 | name: "svg_dispaly", 177 | description: "When user requests you to create an SVG, you can use this tool to display the SVG.", 178 | parameters: .init( 179 | fields: [ 180 | .type( .object), 181 | .properties( 182 | [ 183 | "raw": .init( 184 | fields: [ 185 | .type(.string), .description("SVG content") 186 | ]) 187 | ]) 188 | ]) 189 | ) 190 | 191 | 192 | 193 | struct SVGData: Codable, Equatable { 194 | public let raw: String 195 | } 196 | 197 | // 输入为 svg 的原始数据,要求保存到一个临时文件里,然后通过默认浏览器打开这个文件。 198 | func openSVGInBrowser(svgData: String) -> Bool { 199 | do { 200 | let data = try JSONDecoder().decode(SVGData.self, from: svgData.data(using: .utf8)!) 201 | 202 | // 创建临时文件路径 203 | let tempDir = FileManager.default.temporaryDirectory 204 | let tempFile = tempDir.appendingPathComponent("temp_svg_\(UUID().uuidString).svg") 205 | 206 | // 将 SVG 数据写入临时文件 207 | try data.raw.write(to: tempFile, atomically: true, encoding: .utf8) 208 | 209 | // 使用默认浏览器打开文件 210 | DispatchQueue.global().async { 211 | NSWorkspace.shared.open(tempFile) 212 | } 213 | return true 214 | } catch { 215 | print("打开 SVG 文件时发生错误: \(error.localizedDescription)") 216 | return false 217 | } 218 | } 219 | 220 | let MAX_CHAT_ROUNDS = 20 221 | -------------------------------------------------------------------------------- /Selected/Service/AIUtils/FunctionDefinition.swift: -------------------------------------------------------------------------------- 1 | // 2 | // FunctionDefinition.swift 3 | // Selected 4 | // 5 | // Created by sake on 20/3/25. 6 | // 7 | 8 | 9 | import Foundation 10 | import OpenAI 11 | 12 | public struct FunctionDefinition: Codable, Equatable { 13 | /// 函数名称,必须只包含 a-z, A-Z, 0-9,下划线或连字符,最大长度 64。 14 | public let name: String 15 | /// 函数的描述信息 16 | public let description: String 17 | /// 函数参数的 JSON Schema 描述 18 | public let parameters: String 19 | /// 执行该函数时所需的命令数组 20 | public var command: [String]? 21 | /// 命令执行时的工作目录 22 | public var workdir: String? 23 | /// 是否显示执行结果,默认为 true 24 | public var showResult: Bool? = true 25 | /// 可选的模板字符串 26 | public var template: String? 27 | 28 | /// 运行该函数对应的命令 29 | func Run(arguments: String, options: [String: String] = [:]) throws -> String? { 30 | guard let command = self.command else { 31 | return nil 32 | } 33 | // 获取除第一个元素外的参数 34 | var args = Array(command.dropFirst()) 35 | args.append(arguments) 36 | 37 | // 设置环境变量 38 | var env = [String: String]() 39 | options.forEach { key, value in 40 | env["SELECTED_OPTIONS_\(key.uppercased())"] = value 41 | } 42 | if let path = ProcessInfo.processInfo.environment["PATH"] { 43 | env["PATH"] = "/opt/homebrew/bin:/opt/homebrew/sbin:" + path 44 | } 45 | // 注意:这里假定 executeCommand(workdir:command:arguments:withEnv:) 已经在其他地方实现 46 | return try executeCommand(workdir: workdir!, command: command[0], arguments: args, withEnv: env) 47 | } 48 | 49 | /// 解析 JSON Schema 参数为 FunctionParameters 对象 50 | func getParameters() -> AnyJSONSchema? { 51 | return try? JSONDecoder().decode(AnyJSONSchema.self, from: parameters.data(using: .utf8)!) 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /Selected/Service/AIUtils/ImageGeneration.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ImageGeneration.swift 3 | // Selected 4 | // 5 | // Created by sake on 20/3/25. 6 | // 7 | 8 | 9 | import Foundation 10 | import OpenAI 11 | 12 | public struct ImageGeneration { 13 | /// 根据传入参数调用 Dall-E 3 生成图片,并返回图片 URL 14 | public static func generateDalle3Image(openAI: OpenAI, arguments: String) async throws -> String { 15 | let promptData = try JSONDecoder().decode(Dalle3Prompt.self, from: arguments.data(using: .utf8)!) 16 | let imageQuery = ImagesQuery(prompt: promptData.prompt, model: .dall_e_3) 17 | let res = try await openAI.images(query: imageQuery) 18 | guard let url = res.data.first?.url else { 19 | throw NSError(domain: "ImageGeneration", code: -1, userInfo: [NSLocalizedDescriptionKey: "No image URL returned"]) 20 | } 21 | print("image URL: %@", url) 22 | return url 23 | } 24 | } 25 | 26 | public struct Dalle3Prompt: Codable, Equatable { 27 | /// 用于图片生成的提示语 28 | public let prompt: String 29 | } 30 | -------------------------------------------------------------------------------- /Selected/Service/AIUtils/TTSManager.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TTSManager.swift 3 | // Selected 4 | // 5 | // Created by sake on 20/3/25. 6 | // 7 | 8 | 9 | import Foundation 10 | import AVFoundation 11 | import Defaults 12 | import SwiftUI 13 | import OpenAI 14 | 15 | public class TTSManager { 16 | 17 | // MARK: - 属性 18 | 19 | /// 系统语音合成器,用于当 OpenAI APIKey 为空时调用系统 TTS 20 | private static let speechSynthesizer = AVSpeechSynthesizer() 21 | 22 | /// OpenAI 语音合成播放使用的音频播放器 23 | private static var audioPlayer: AVAudioPlayer? 24 | 25 | /// TTS 缓存数据结构 26 | private struct VoiceData { 27 | var data: Data 28 | var lastAccessTime: Date 29 | } 30 | 31 | /// 缓存字典,key 为文本的 hash 值 32 | private static var voiceDataCache = [Int: VoiceData]() 33 | 34 | // MARK: - 缓存管理 35 | 36 | /// 清理缓存中超过 120 秒未使用的数据 37 | private static func clearExpiredVoiceData() { 38 | let now = Date() 39 | voiceDataCache = voiceDataCache.filter { $0.value.lastAccessTime.addingTimeInterval(120) >= now } 40 | } 41 | 42 | // MARK: - 系统 TTS 43 | 44 | /// 使用系统语音合成(AVSpeechSynthesizer)朗读文本 45 | private static func systemSpeak(_ text: String) { 46 | speechSynthesizer.stopSpeaking(at: .word) 47 | let utterance = AVSpeechUtterance(string: text) 48 | utterance.pitchMultiplier = 0.8 49 | utterance.postUtteranceDelay = 0.2 50 | utterance.volume = 0.8 51 | speechSynthesizer.speak(utterance) 52 | } 53 | 54 | // MARK: - OpenAI TTS 调用 55 | 56 | /// 通过 OpenAI API 调用语音合成,并直接播放生成的语音 57 | private static func play(text: String) async { 58 | clearExpiredVoiceData() 59 | let hashValue = text.hash 60 | if let cached = voiceDataCache[hashValue] { 61 | print("Using cached TTS data") 62 | audioPlayer?.stop() 63 | do { 64 | audioPlayer = try AVAudioPlayer(data: cached.data) 65 | audioPlayer?.play() 66 | } catch { 67 | print("Audio player error: \(error)") 68 | } 69 | return 70 | } 71 | 72 | let configuration = OpenAI.Configuration(token: Defaults[.openAIAPIKey], 73 | host: Defaults[.openAIAPIHost], 74 | timeoutInterval: 60.0) 75 | let openAI = OpenAI(configuration: configuration) 76 | let model = Defaults[.openAITTSModel] 77 | let instructions = model == .gpt_4o_mini_tts ? Defaults[.openAITTSInstructions] : "" 78 | let query = AudioSpeechQuery(model: model, 79 | input: text, 80 | voice: Defaults[.openAIVoice], 81 | instructions: instructions, 82 | responseFormat: .mp3, 83 | speed: 1.0) 84 | 85 | do { 86 | let result = try await openAI.audioCreateSpeech(query: query) 87 | voiceDataCache[hashValue] = VoiceData(data: result.audio, lastAccessTime: Date()) 88 | audioPlayer?.stop() 89 | audioPlayer = try AVAudioPlayer(data: result.audio) 90 | audioPlayer?.play() 91 | } catch { 92 | print("audioCreateSpeech error: \(error)") 93 | } 94 | } 95 | 96 | /// 通过 OpenAI API 获取 TTS 音频数据,适用于需要自定义播放方式(例如在新窗口中播放)的场景 97 | private static func fetchTTSData(text: String) async -> Data? { 98 | clearExpiredVoiceData() 99 | let hashValue = text.hash 100 | if let cached = voiceDataCache[hashValue] { 101 | print("Using cached TTS data") 102 | return cached.data 103 | } 104 | 105 | let configuration = OpenAI.Configuration(token: Defaults[.openAIAPIKey], 106 | host: Defaults[.openAIAPIHost], 107 | timeoutInterval: 60.0) 108 | let openAI = OpenAI(configuration: configuration) 109 | let model = Defaults[.openAITTSModel] 110 | let instructions = model == .gpt_4o_mini_tts ? Defaults[.openAITTSInstructions] : "" 111 | let query = AudioSpeechQuery(model: model, 112 | input: text, 113 | voice: Defaults[.openAIVoice], 114 | instructions: instructions, 115 | responseFormat: .mp3, 116 | speed: 1.0) 117 | do { 118 | let result = try await openAI.audioCreateSpeech(query: query) 119 | voiceDataCache[hashValue] = VoiceData(data: result.audio, lastAccessTime: Date()) 120 | return result.audio 121 | } catch { 122 | print("audioCreateSpeech error: \(error)") 123 | return nil 124 | } 125 | } 126 | 127 | // MARK: - 综合调用入口 128 | 129 | /// 综合 TTS 播放函数,根据 OpenAI APIKey 和文本内容决定调用系统 TTS 还是 OpenAI TTS 130 | /// 131 | /// - Parameters: 132 | /// - text: 待朗读文本 133 | /// - view: 是否以视图窗口方式播放(适用于多句文本);默认为 true 134 | /// 135 | /// 如果 OpenAI APIKey 为空,则调用系统 TTS,否则: 136 | /// - 当文本为单词或 view 为 false 时直接播放语音; 137 | /// - 否则,获取 TTS 数据后在新窗口中播放(需 WindowManager 实现相关方法)。 138 | public static func speak(_ text: String, view: Bool = true) async { 139 | // 如果未配置 OpenAI APIKey,则调用系统语音 140 | if Defaults[.openAIAPIKey].isEmpty { 141 | systemSpeak(text) 142 | } else { 143 | // isWord(str:) 为自定义辅助方法,判断文本是否为单词(需自行实现) 144 | if isWord(str: text) || !view { 145 | await play(text: text) 146 | } else { 147 | if let data = await fetchTTSData(text: text) { 148 | DispatchQueue.main.async { 149 | // WindowManager.shared.createAudioPlayerWindow(_:) 为自定义方法, 150 | // 用于在新窗口中播放音频数据,需自行实现 151 | WindowManager.shared.createAudioPlayerWindow(data) 152 | } 153 | } 154 | } 155 | } 156 | } 157 | 158 | /// 停止所有正在进行的语音合成播放,包括系统 TTS 与 OpenAI TTS 159 | public static func stopSpeak() { 160 | speechSynthesizer.stopSpeaking(at: .word) 161 | audioPlayer?.stop() 162 | } 163 | } 164 | -------------------------------------------------------------------------------- /Selected/Service/LocationManager.swift: -------------------------------------------------------------------------------- 1 | // 2 | // LocationManager.swift 3 | // Selected 4 | // 5 | // Created by sake on 2024/7/3. 6 | // 7 | 8 | import Foundation 9 | import CoreLocation 10 | 11 | 12 | class LocationManager: NSObject, CLLocationManagerDelegate { 13 | static let shared = LocationManager() 14 | 15 | 16 | private let locationManager = CLLocationManager() 17 | private let geocoder = CLGeocoder() 18 | 19 | 20 | var location: CLLocation? 21 | var place: String? 22 | 23 | override init() { 24 | super.init() 25 | locationManager.delegate = self 26 | locationManager.desiredAccuracy = kCLLocationAccuracyBest 27 | locationManager.requestAlwaysAuthorization() 28 | locationManager.startUpdatingLocation() 29 | } 30 | 31 | func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) { 32 | guard let location = locations.last else { return } 33 | 34 | DispatchQueue.main.async { 35 | self.location = location 36 | } 37 | var p : String? = nil 38 | geocoder.reverseGeocodeLocation(location) { 39 | placemarks, error in 40 | if let err = error { 41 | print("reverseGeocodeLocation \(err)") 42 | return 43 | } else if let placemarks = placemarks { 44 | if let placemark = placemarks.first { 45 | p = "\(placemark.name!), \(placemark.locality!), \(placemark.administrativeArea!), \(placemark.country!)" 46 | DispatchQueue.main.async { 47 | self.place = p 48 | } 49 | } 50 | } 51 | } 52 | } 53 | 54 | func locationManager(_ manager: CLLocationManager, didFailWithError error: Error) { 55 | print("Failed to find user's location: \(error.localizedDescription)") 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /Selected/Service/OCR.swift: -------------------------------------------------------------------------------- 1 | // 2 | // OCR.swift 3 | // Selected 4 | // 5 | // Created by sake on 2024/4/10. 6 | // 7 | 8 | import Foundation 9 | import Vision 10 | import AppKit 11 | 12 | func recognizeTextInImage(_ image: NSImage) { 13 | guard let cgImage = image.cgImage(forProposedRect: nil, context: nil, hints: nil) else { 14 | return 15 | } 16 | 17 | print("recognizeTextInImage") 18 | let request = VNRecognizeTextRequest { request, error in 19 | guard let observations = request.results as? [VNRecognizedTextObservation], 20 | error == nil else { 21 | return 22 | } 23 | 24 | for observation in observations { 25 | guard let topCandidate = observation.topCandidates(1).first else { 26 | continue 27 | } 28 | print(topCandidate.string) 29 | } 30 | } 31 | request.recognitionLevel = .accurate 32 | var recognitionLanguages = Set( Locale.preferredLanguages) 33 | print("Preferred languages: \(recognitionLanguages)") 34 | recognitionLanguages.insert("en") 35 | request.recognitionLanguages = [String](recognitionLanguages) 36 | 37 | let handler = VNImageRequestHandler(cgImage: cgImage) 38 | do { 39 | try handler.perform([request]) 40 | } catch { 41 | print("Error recognizing text: \(error)") 42 | } 43 | print("recognizeTextInImage end") 44 | } 45 | -------------------------------------------------------------------------------- /Selected/Service/Persistence.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Persistence.swift 3 | // Selected 4 | // 5 | // Created by sake on 2024/4/8. 6 | // 7 | 8 | import Foundation 9 | import CoreData 10 | import Cocoa 11 | import SwiftUI 12 | import Defaults 13 | 14 | class PersistenceController { 15 | static let shared = PersistenceController() 16 | 17 | let container: NSPersistentContainer 18 | 19 | init() { 20 | container = NSPersistentContainer(name: "ClipHistory") 21 | container.loadPersistentStores { (storeDescription, error) in 22 | if let error = error as NSError? { 23 | fatalError("Unresolved error \(error), \(error.userInfo)") 24 | } 25 | } 26 | } 27 | 28 | func updateClipHistoryData(_ clipData: ClipHistoryData) { 29 | let ctx = container.viewContext 30 | clipData.lastCopiedAt = Date() 31 | clipData.numberOfCopies += 1 32 | ctx.performAndWait { 33 | do { 34 | try ctx.save() 35 | print("saved") 36 | } catch { 37 | fatalError("\(error)") 38 | } 39 | } 40 | } 41 | 42 | func store(_ clipData: ClipData) { 43 | let ctx = PersistenceController.shared.container.viewContext 44 | let clipHistoryData = 45 | NSEntityDescription.insertNewObject( 46 | forEntityName: "ClipHistoryData", into: ctx) 47 | as! ClipHistoryData 48 | 49 | clipHistoryData.application = clipData.appBundleID 50 | clipHistoryData.firstCopiedAt = Date(timeIntervalSince1970: Double(clipData.timeStamp)/1000) 51 | clipHistoryData.lastCopiedAt = clipHistoryData.firstCopiedAt 52 | clipHistoryData.numberOfCopies = 1 53 | clipHistoryData.plainText = clipData.plainText 54 | clipHistoryData.url = clipData.url 55 | for item in clipData.items { 56 | let clipHistoryItem = 57 | NSEntityDescription.insertNewObject( 58 | forEntityName: "ClipHistoryItem", into: ctx) 59 | as! ClipHistoryItem 60 | 61 | clipHistoryItem.data = item.data 62 | clipHistoryItem.type = item.type.rawValue 63 | clipHistoryItem.refer = clipHistoryData 64 | clipHistoryData.addToItems(clipHistoryItem) 65 | } 66 | clipHistoryData.md5 = clipHistoryData.MD5() 67 | 68 | ctx.performAndWait { 69 | if let got = get(byMD5: clipHistoryData.md5!) { 70 | if got != clipHistoryData { 71 | clipHistoryData.firstCopiedAt = got.firstCopiedAt 72 | clipHistoryData.numberOfCopies = got.numberOfCopies + 1 73 | ctx.delete(got) 74 | print("saved \(clipHistoryData.firstCopiedAt!) \(got.firstCopiedAt!)") 75 | } 76 | } 77 | do { 78 | try ctx.save() 79 | print("saved \(clipHistoryData.md5!)") 80 | } catch { 81 | fatalError("\(error)") 82 | } 83 | } 84 | } 85 | 86 | func get(byMD5 md5: String) -> ClipHistoryData? { 87 | let fetchRequest = NSFetchRequest(entityName: "ClipHistoryData") 88 | fetchRequest.predicate = NSPredicate(format: "md5 = %@",md5 ) 89 | fetchRequest.sortDescriptors = [NSSortDescriptor(keyPath: \ClipHistoryData.lastCopiedAt, ascending: true)] 90 | let ctx = PersistenceController.shared.container.viewContext 91 | do{ 92 | let res = try ctx.fetch(fetchRequest) 93 | return res.first 94 | } catch { 95 | fatalError("\(error)") 96 | } 97 | } 98 | 99 | func delete(item: ClipHistoryData) { 100 | let ctx = PersistenceController.shared.container.viewContext 101 | ctx.performAndWait { 102 | do{ 103 | ctx.delete(item) 104 | try ctx.save() 105 | } catch { 106 | fatalError("\(error)") 107 | } 108 | } 109 | } 110 | 111 | func deleteBefore(byDate date: Date){ 112 | let fetchRequest = NSFetchRequest(entityName: "ClipHistoryData") 113 | fetchRequest.predicate = NSPredicate(format: "lastCopiedAt < %@", date as NSDate) 114 | fetchRequest.sortDescriptors = [NSSortDescriptor(keyPath: \ClipHistoryData.lastCopiedAt, ascending: true)] 115 | let ctx = PersistenceController.shared.container.viewContext 116 | 117 | ctx.performAndWait { 118 | do{ 119 | let res = try ctx.fetch(fetchRequest) 120 | for data in res { 121 | ctx.delete(data) 122 | } 123 | try ctx.save() 124 | } catch { 125 | fatalError("\(error)") 126 | } 127 | } 128 | } 129 | 130 | func startDailyTimer() { 131 | cleanTask() 132 | let timer = Timer.scheduledTimer(timeInterval: 86400, // 24 * 60 * 60 seconds 133 | target: self, 134 | selector: #selector(cleanTask), 135 | userInfo: nil, 136 | repeats: true) 137 | RunLoop.main.add(timer, forMode: .common) 138 | } 139 | 140 | @objc func cleanTask() { 141 | var ago: Date 142 | switch Defaults[.clipboardHistoryTime] { 143 | case .OneDay: 144 | ago = Calendar.current.date(byAdding: .hour, value: -24, to: Date())! 145 | case .SevenDays: 146 | ago = Calendar.current.date(byAdding: .day, value: -7, to: Date())! 147 | case .ThirtyDays: 148 | ago = Calendar.current.date(byAdding: .day, value: -30, to: Date())! 149 | case .ThreeMonths: 150 | ago = Calendar.current.date(byAdding: .month, value: -3, to: Date())! 151 | case .SixMonths: 152 | ago = Calendar.current.date(byAdding: .month, value: -6, to: Date())! 153 | case .OneYear: 154 | ago = Calendar.current.date(byAdding: .year, value: -1, to: Date())! 155 | } 156 | deleteBefore(byDate: ago) 157 | } 158 | } 159 | 160 | 161 | import CryptoKit 162 | 163 | 164 | func MD5(string: String) -> String { 165 | var md5 = Insecure.MD5() 166 | md5.update(data: Data(string.utf8)) 167 | let digest = md5.finalize() 168 | return digest.map { 169 | String(format: "%02hhx", $0) 170 | }.joined() 171 | } 172 | 173 | 174 | extension ClipHistoryData { 175 | func getItems() -> [ClipHistoryItem] { 176 | if let items = items { 177 | return items.array as! [ClipHistoryItem] 178 | } 179 | return [] 180 | } 181 | 182 | func MD5() -> String { 183 | var md5 = Insecure.MD5() 184 | for item in getItems(){ 185 | md5.update(data: item.data!) 186 | } 187 | let digest = md5.finalize() 188 | return digest.map { 189 | String(format: "%02hhx", $0) 190 | }.joined() 191 | } 192 | } 193 | -------------------------------------------------------------------------------- /Selected/Service/Spotlight.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Spotlight.swift 3 | // Selected 4 | // 5 | // Created by sake on 2024/8/4. 6 | // 7 | 8 | import Foundation 9 | import Carbon 10 | import ShortcutRecorder 11 | import SwiftUI 12 | import HotKey 13 | import Defaults 14 | 15 | class SpotlightHotKeyManager { 16 | static let shared = SpotlightHotKeyManager() 17 | 18 | private var hotkey: HotKey? 19 | 20 | init(){ 21 | NSEvent.addGlobalMonitorForEvents(matching: 22 | [.leftMouseDown, .rightMouseDown, .otherMouseDown] 23 | ) { (event) in 24 | _ = SpotlightWindowManager.shared.closeWindow() 25 | } 26 | } 27 | 28 | func registerHotKey() { 29 | if hotkey != nil { 30 | return 31 | } 32 | hotkey = HotKey(key: .init(carbonKeyCode: Defaults[.spotlightShortcut].carbonKeyCode)!, modifiers: Defaults[.spotlightShortcut].modifierFlags) 33 | hotkey?.keyDownHandler = { 34 | SpotlightWindowManager.shared.createWindow() 35 | } 36 | } 37 | 38 | func unregisterHotKey() { 39 | hotkey?.keyDownHandler = nil 40 | hotkey = nil 41 | } 42 | } 43 | 44 | 45 | // MARK: - window 46 | 47 | class SpotlightWindowManager { 48 | static let shared = SpotlightWindowManager() 49 | 50 | 51 | private var windowCtr: WindowController? 52 | 53 | fileprivate func createWindow() { 54 | windowCtr?.close() 55 | let view = SpotlightView() 56 | let window = WindowController(rootView: AnyView(view)) 57 | windowCtr = window 58 | window.showWindow(nil) 59 | 60 | NotificationCenter.default.addObserver(forName: NSWindow.willCloseNotification, object: window.window, queue: nil) { _ in 61 | self.windowCtr = nil 62 | } 63 | return 64 | } 65 | 66 | fileprivate func closeWindow() -> Bool { 67 | guard let windowCtr = windowCtr else { 68 | return true 69 | } 70 | var closed = false 71 | let frame = windowCtr.window!.frame 72 | if !frame.contains(NSEvent.mouseLocation){ 73 | windowCtr.close() 74 | closed = true 75 | self.windowCtr = nil 76 | } 77 | return closed 78 | } 79 | 80 | func resignKey(){ 81 | windowCtr?.window?.resignKey() 82 | } 83 | 84 | func forceCloseWindow() { 85 | guard let windowCtr = windowCtr else { 86 | return 87 | } 88 | windowCtr.close() 89 | self.windowCtr = nil 90 | } 91 | } 92 | 93 | 94 | private class WindowController: NSWindowController, NSWindowDelegate { 95 | 96 | init(rootView: AnyView) { 97 | let window = FloatingPanel( 98 | contentRect: .zero, 99 | backing: .buffered, 100 | defer: false, 101 | key: true // 成为 key 和 main window 就可以用一些快捷键,比如方向键,以及可以文本编辑。 102 | ) 103 | 104 | super.init(window: window) 105 | 106 | window.center() 107 | window.level = .screenSaver 108 | window.contentView = NSHostingView(rootView: rootView) 109 | window.delegate = self // 设置代理为自己来监听窗口事件 110 | window.makeKeyAndOrderFront(nil) 111 | window.backgroundColor = .clear 112 | window.isOpaque = false 113 | if WindowPositionManager.shared.restorePosition(for: window) { 114 | return 115 | } 116 | 117 | let windowFrame = window.frame 118 | let screenFrame = NSScreen.main?.visibleFrame ?? .zero // 获取主屏幕的可见区域 119 | 120 | // 确保窗口不会超出屏幕边缘 121 | let x = (screenFrame.maxX - windowFrame.width) / 2 122 | let y = (screenFrame.maxY - windowFrame.height)*3 / 4 123 | window.setFrameOrigin(NSPoint(x: x, y: y)) 124 | } 125 | 126 | func windowDidMove(_ notification: Notification) { 127 | if let window = notification.object as? NSWindow { 128 | WindowPositionManager.shared.storePosition(of: window) 129 | } 130 | } 131 | 132 | func windowDidResize(_ notification: Notification) { 133 | if let window = notification.object as? NSWindow { 134 | WindowPositionManager.shared.storePosition(of: window) 135 | } 136 | } 137 | 138 | required init?(coder: NSCoder) { 139 | fatalError("init(coder:) has not been implemented") 140 | } 141 | 142 | func windowDidResignActive(_ notification: Notification) { 143 | self.close() // 如果需要的话 144 | } 145 | 146 | override func showWindow(_ sender: Any?) { 147 | super.showWindow(sender) 148 | } 149 | 150 | func windowWillClose(_ notification: Notification) { 151 | ClipViewModel.shared.selectedItem = nil 152 | } 153 | } 154 | 155 | 156 | private class WindowPositionManager { 157 | static let shared = WindowPositionManager() 158 | let key = "SpotlightWindowPosition" 159 | 160 | func storePosition(of window: NSWindow) { 161 | let frameString = NSStringFromRect(window.frame) 162 | UserDefaults.standard.set(frameString, forKey: key) 163 | } 164 | 165 | func restorePosition(for window: NSWindow) -> Bool { 166 | if let frameString = UserDefaults.standard.string(forKey: key) { 167 | let frame = NSRectFromString(frameString) 168 | window.setFrame(frame, display: true) 169 | return true 170 | } 171 | return false 172 | } 173 | } 174 | -------------------------------------------------------------------------------- /Selected/Service/StarDict.swift: -------------------------------------------------------------------------------- 1 | // 2 | // StarDict.swift 3 | // Selected 4 | // 5 | // Created by sake on 2024/5/7. 6 | // 7 | 8 | import Foundation 9 | import GRDB 10 | 11 | struct Word: Codable, FetchableRecord, TableRecord { 12 | static let databaseTableName = "stardict" 13 | 14 | var id: Int 15 | var word: String 16 | var phonetic: String 17 | var translation: String 18 | var exchange: String 19 | } 20 | 21 | class StarDict { 22 | static let shared = StarDict() 23 | var databaseFileURL: URL 24 | 25 | init() { 26 | let fileManager = FileManager.default 27 | databaseFileURL = appSupportURL.appendingPathComponent("stardict.sqlite3") 28 | print("databaseFileURL: \(databaseFileURL.path)") 29 | if let bundleDatabasePath = Bundle.main.path(forResource: "stardict", ofType: "tar.gz") { 30 | if !fileManager.fileExists(atPath: databaseFileURL.path) { 31 | extractTarGzFile(tarGzPath: bundleDatabasePath , destination: appSupportURL) 32 | } 33 | } 34 | } 35 | 36 | func query(word: String) throws -> Word?{ 37 | let dbQueue = try DatabaseQueue(path: databaseFileURL.path) 38 | if let ret = try dbQueue.read({ db in 39 | try Word.filter(Column("word") == word).fetchOne(db) 40 | }) { 41 | return ret 42 | } 43 | 44 | return try dbQueue.read { db in 45 | try Word.filter(Column("word") == stripWord(word)).fetchOne(db) 46 | } 47 | } 48 | 49 | private func stripWord(_ word: String) -> String { 50 | return word.filter({ $0.isLetter || $0.isNumber }).lowercased() 51 | } 52 | } 53 | 54 | private func extractTarGzFile(tarGzPath: String, destination: URL) { 55 | // 创建一个 Process 来执行 tar 命令 56 | let process = Process() 57 | process.executableURL = URL(fileURLWithPath: "/usr/bin/tar") 58 | process.arguments = ["-xzf", tarGzPath, "-C", destination.path] 59 | 60 | do { 61 | try process.run() 62 | process.waitUntilExit() // 等待解压完成 63 | if process.terminationStatus == 0 { 64 | print("File successfully extracted.") 65 | } else { 66 | print("Error occurred during extraction. Status code: \(process.terminationStatus)") 67 | } 68 | } catch { 69 | print("Failed to start process: \(error)") 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /Selected/Service/Template.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Template.swift 3 | // Selected 4 | // 5 | // Created by sake on 2024/7/7. 6 | // 7 | 8 | import Foundation 9 | import Stencil 10 | 11 | func parseJSONString(jsonString: String) -> [String: Any]? { 12 | guard let data = jsonString.data(using: .utf8) else { 13 | print("Failed to convert string to data.") 14 | return nil 15 | } 16 | 17 | guard let jsonObject = try? JSONSerialization.jsonObject(with: data, options: []), 18 | let dictionary = jsonObject as? [String: Any] else { 19 | print("Failed to decode JSON.") 20 | return nil 21 | } 22 | 23 | return dictionary 24 | } 25 | 26 | 27 | func renderTemplate(templateString: String, json: String) -> String { 28 | if var dictionary = parseJSONString(jsonString: json) { 29 | dictionary["system"] = ["language": getCurrentAppLanguage()] 30 | print("language \(getCurrentAppLanguage())") 31 | return renderTemplate(templateString: templateString, with: dictionary) 32 | } 33 | return "" 34 | } 35 | 36 | func renderTemplate(templateString: String, with context: [String: Any]) -> String { 37 | let environment = Environment(loader: nil, trimBehaviour: .all) 38 | do { 39 | let rendered = try environment.renderTemplate(string: templateString, context: context) 40 | return rendered 41 | } catch { 42 | print("Failed to render template: \(error)") 43 | return "" 44 | } 45 | } 46 | 47 | func renderChatContent(content: String, chatCtx: ChatContext, options: [String:String]? = [String:String]()) -> String { 48 | var ctx = [String:Any]() 49 | ctx["options"] = options 50 | ctx["selected"] = chatCtx 51 | 52 | return renderTemplate(templateString: content, with: ctx) 53 | } 54 | -------------------------------------------------------------------------------- /Selected/Service/Updater.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Updater.swift 3 | // Selected 4 | // 5 | // Created by sake on 2024/8/6. 6 | // 7 | 8 | import Foundation 9 | import SwiftUI 10 | import Sparkle 11 | 12 | // This view model class publishes when new updates can be checked by the user 13 | final class CheckForUpdatesViewModel: ObservableObject { 14 | @Published var canCheckForUpdates = false 15 | 16 | init(updater: SPUUpdater) { 17 | updater.publisher(for: \.canCheckForUpdates) 18 | .assign(to: &$canCheckForUpdates) 19 | } 20 | } 21 | 22 | // This is the view for the Check for Updates menu item 23 | // Note this intermediate view is necessary for the disabled state on the menu item to work properly before Monterey. 24 | // See https://stackoverflow.com/questions/68553092/menu-not-updating-swiftui-bug for more info 25 | struct CheckForUpdatesView: View { 26 | @ObservedObject private var checkForUpdatesViewModel: CheckForUpdatesViewModel 27 | private let updater: SPUUpdater 28 | 29 | init(updater: SPUUpdater) { 30 | self.updater = updater 31 | 32 | // Create our view model for our CheckForUpdatesView 33 | self.checkForUpdatesViewModel = CheckForUpdatesViewModel(updater: updater) 34 | } 35 | 36 | var body: some View { 37 | Button("Check for Updates", action: updater.checkForUpdates) 38 | .disabled(!checkForUpdatesViewModel.canCheckForUpdates) 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /Selected/SyntaxHighlighter/CodeSyntaxHighlighter.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SplashCodeSyntaxHighlighter.swift 3 | // Selected 4 | // 5 | // Created by sake on 2024/3/8. 6 | // 7 | 8 | import SwiftUI 9 | import Highlightr 10 | 11 | 12 | enum CodeTheme: String { 13 | case dark = "monokai-sublime" 14 | case light = "github" 15 | } 16 | 17 | class CustomCodeSyntaxHighlighter { 18 | private let syntaxHighlighter: Highlightr 19 | 20 | // It's important to cache generated code block view 21 | // when we use it in a streaming Markdown content. 22 | // Markdown will be rendered multiple times in a very short time. 23 | private var cacheCode = [String: Text]() 24 | 25 | init() { 26 | let highlightr = Highlightr()! 27 | highlightr.ignoreIllegals = true 28 | syntaxHighlighter = highlightr 29 | } 30 | 31 | deinit { 32 | cacheCode = [:] 33 | } 34 | 35 | func setTheme(theme: CodeTheme) -> Self { 36 | syntaxHighlighter.setTheme(to: theme.rawValue) 37 | return self 38 | } 39 | 40 | func getlanguage(_ language: String?) -> String { 41 | guard var language = language else { 42 | return "plaintext" 43 | } 44 | 45 | if language == "shell" || language == "sh" { 46 | language = "bash" 47 | } 48 | if !syntaxHighlighter.supportedLanguages().contains(language) { 49 | language = "plaintext" 50 | } 51 | return language 52 | } 53 | 54 | func highlightCode(_ content: String, language: String?) -> Text { 55 | if let v = cacheCode[content] { 56 | return v 57 | } 58 | 59 | let highlightedCode = syntaxHighlighter.highlight(content, as: getlanguage(language))! 60 | let attributedString = NSMutableAttributedString(attributedString: highlightedCode) 61 | let font = NSFont(name: "UbuntuMonoNFM", size: 14)! 62 | attributedString.addAttribute(.font, value: font, range: NSRange(location: 0, length: attributedString.length)) 63 | 64 | let v = Text(AttributedString(attributedString)) 65 | cacheCode[content] = v 66 | return v 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /Selected/View/AudioPlayerView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Audio.swift 3 | // Selected 4 | // 5 | // Created by sake on 2024/6/10. 6 | // 7 | 8 | import Foundation 9 | import SwiftUI 10 | import DSWaveformImageViews 11 | import Defaults 12 | 13 | struct ProgressWaveformView: View { 14 | let audioURL: URL 15 | let progress: Binding 16 | 17 | var body: some View { 18 | GeometryReader { geometry in 19 | WaveformView(audioURL: audioURL) { shape in 20 | shape.fill(.clear) 21 | shape.fill(.blue).mask(alignment: .leading) { 22 | Rectangle().frame(width: geometry.size.width * progress.wrappedValue) 23 | } 24 | } 25 | } 26 | } 27 | } 28 | 29 | 30 | import AVFoundation 31 | 32 | class AudioPlayer: ObservableObject { 33 | private var player: AVAudioPlayer? 34 | @Published var isPlaying: Bool = false 35 | @Published var currentTime: TimeInterval = 0.0 36 | @Published var duration: TimeInterval = 0.0 37 | 38 | private var timer: Timer? 39 | 40 | func loadAudio(url: URL) { 41 | do { 42 | player = try AVAudioPlayer(contentsOf: url) 43 | duration = player?.duration ?? 0.0 44 | } catch { 45 | print("Error loading audio file: \(error)") 46 | } 47 | } 48 | 49 | func play() { 50 | player?.play() 51 | isPlaying = true 52 | startTimer() 53 | } 54 | 55 | func pause() { 56 | player?.pause() 57 | isPlaying = false 58 | stopTimer() 59 | } 60 | 61 | private func startTimer() { 62 | timer = Timer.scheduledTimer(withTimeInterval: 0.01, repeats: true) { [weak self] _ in 63 | guard let self = self else { return } 64 | self.isPlaying = self.player?.isPlaying ?? false 65 | if self.isPlaying { 66 | self.currentTime = self.player?.currentTime ?? 0.0 67 | } else { 68 | stopTimer() 69 | } 70 | } 71 | } 72 | 73 | private func stopTimer() { 74 | timer?.invalidate() 75 | timer = nil 76 | } 77 | 78 | func seek(to time: TimeInterval) { 79 | player?.currentTime = time 80 | currentTime = time 81 | } 82 | 83 | func save(_ audioURL: URL) { 84 | let paths = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask) 85 | guard let documentsDirectory = paths.first else{ 86 | return 87 | } 88 | let unixTime = Int(Date().timeIntervalSince1970) 89 | let tts = documentsDirectory.appending(path: "Selected/tts-\(unixTime).mp3") 90 | do{ 91 | try FileManager.default.createDirectory(at: tts.deletingLastPathComponent(), withIntermediateDirectories: true) 92 | try FileManager.default.moveItem(at: audioURL, to: tts) 93 | NSWorkspace.shared.selectFile(nil, inFileViewerRootedAtPath: tts.deletingLastPathComponent().path) 94 | } catch { 95 | NSLog("move failed \(error)") 96 | } 97 | } 98 | } 99 | 100 | 101 | struct AudioPlayerView: View { 102 | @StateObject private var audioPlayer = AudioPlayer() 103 | @State private var sliderValue: Double = 0.0 104 | 105 | let audioURL: URL 106 | @State var progress: Double = 0 107 | 108 | var body: some View { 109 | // Audio player card 110 | VStack { 111 | ZStack { 112 | RoundedRectangle(cornerRadius: 16) 113 | .fill(Color.white) 114 | .shadow(radius: 8) 115 | .clipShape(RoundedRectangle(cornerRadius: 16)) 116 | 117 | VStack(spacing: 16) { 118 | HStack { 119 | Text(String(format: "%02d:%02d", ((Int)((audioPlayer.currentTime))) / 60, ((Int)((audioPlayer.currentTime))) % 60)) 120 | .foregroundColor(Color.black.opacity(0.6)) 121 | .font(.custom("Quicksand Regular", size: 14)) 122 | .frame(width: 40).padding(.leading, 10) 123 | ZStack{ 124 | ProgressWaveformView(audioURL: audioURL, progress: $progress).frame(width: 450) 125 | Slider(value: $sliderValue, in: 0...audioPlayer.duration) { isEditing in 126 | if !isEditing { 127 | audioPlayer.seek(to: sliderValue) 128 | } 129 | }.foregroundColor(.clear).background(.clear).opacity(0.1) 130 | .controlSize(.mini).frame(width: 450) 131 | .onChange(of: audioPlayer.currentTime) { newValue in 132 | sliderValue = newValue 133 | progress = sliderValue/audioPlayer.duration 134 | } 135 | } 136 | 137 | Text(String(format: "%02d:%02d", ((Int)((audioPlayer.duration-audioPlayer.currentTime))) / 60, ((Int)((audioPlayer.duration-audioPlayer.currentTime))) % 60)) 138 | .foregroundColor(Color.black.opacity(0.6)) 139 | .font(.custom("Quicksand Regular", size: 14)) 140 | .frame(width: 40) 141 | }.padding(.top, 15) 142 | 143 | // Controls 144 | HStack { 145 | // Play/Pause button 146 | Button(action: { 147 | audioPlayer.isPlaying ? audioPlayer.pause() : audioPlayer.play() 148 | }) { 149 | ZStack { 150 | Circle() 151 | .fill(Color(red: 0.2, green: 0.8, blue: 0.8)) 152 | .frame(width: 30, height: 30) 153 | 154 | if audioPlayer.isPlaying { 155 | Image(systemName: "pause.fill") 156 | .foregroundColor(.white) 157 | .font(.system(size: 16, weight: .bold)) 158 | } else { 159 | Image(systemName: "play.fill") 160 | .foregroundColor(.white) 161 | .font(.system(size: 16, weight: .bold)) 162 | } 163 | } 164 | } 165 | .buttonStyle(PlainButtonStyle()) 166 | 167 | // Download button 168 | Button(action: {audioPlayer.save(audioURL)}) { 169 | ZStack { 170 | Circle() 171 | .fill(Color.gray.opacity(0.2)) 172 | .frame(width: 30, height: 30) 173 | 174 | Image(systemName: "arrow.down") 175 | .foregroundColor(.gray) 176 | .font(.system(size: 16, weight: .medium)) 177 | } 178 | } 179 | .buttonStyle(PlainButtonStyle()) 180 | 181 | Spacer() 182 | 183 | // Info text 184 | HStack(spacing: 5) { 185 | Text(Defaults[.openAIVoice].rawValue) 186 | .foregroundColor(.gray) 187 | 188 | Text("·") 189 | .foregroundColor(.gray) 190 | 191 | Text(valueFormatter.string(from: NSNumber(value: audioPlayer.duration))!) 192 | .foregroundColor(.gray) 193 | 194 | Text("·") 195 | .foregroundColor(.gray) 196 | 197 | Text("1x") 198 | .foregroundColor(.gray) 199 | 200 | Text("·") 201 | .foregroundColor(.gray) 202 | 203 | Text("mp3") 204 | .foregroundColor(.gray) 205 | 206 | Text("·") 207 | .foregroundColor(.gray) 208 | 209 | HStack(spacing: 2) { 210 | Text("Instructions") 211 | .foregroundColor(.gray) 212 | 213 | Image(systemName: "chevron.down") 214 | .font(.system(size: 12)) 215 | .foregroundColor(.gray) 216 | } 217 | } 218 | .font(.system(size: 14)) 219 | } 220 | .padding(.horizontal, 20) 221 | .padding(.bottom, 15) 222 | } 223 | } 224 | }.frame(width: 600, height: 150) 225 | .onAppear() { 226 | audioPlayer.loadAudio(url: audioURL) 227 | audioPlayer.play() 228 | } 229 | } 230 | } 231 | -------------------------------------------------------------------------------- /Selected/View/Base/BarButton.swift: -------------------------------------------------------------------------------- 1 | // 2 | // BarButton.swift 3 | // Selected 4 | // 5 | // Created by sake on 2024/3/11. 6 | // 7 | 8 | import SwiftUI 9 | 10 | extension String { 11 | func trimPrefix(_ prefix: String) -> String { 12 | guard self.hasPrefix(prefix) else { return self } 13 | return String(self.dropFirst(prefix.count)) 14 | } 15 | } 16 | 17 | struct BarButton: View { 18 | var icon: String 19 | var title: String 20 | var clicked: ((_: Binding) -> Void) /// use closure for callback 21 | 22 | @State private var shouldPopover: Bool = false 23 | @State private var hoverWorkItem: DispatchWorkItem? 24 | 25 | @State private var isLoading = false 26 | 27 | 28 | var body: some View { 29 | Button { 30 | DispatchQueue.main.async { 31 | clicked($isLoading) 32 | NSLog("isLoading \(isLoading)") 33 | } 34 | } label: { 35 | ZStack { 36 | HStack{ 37 | Icon(icon) 38 | }.frame(width: 40, height: 30).opacity(isLoading ? 0.5 : 1) 39 | if isLoading { 40 | ProgressView() 41 | .progressViewStyle(CircularProgressViewStyle(tint: .gray)) 42 | .scaleEffect(0.5, anchor: .center) // 根据需要调整大小和位置 43 | } 44 | } 45 | }.frame(width: 40, height: 30) 46 | .buttonStyle(BarButtonStyle()).onHover(perform: { hovering in 47 | hoverWorkItem?.cancel() 48 | if title.count == 0 { 49 | shouldPopover = false 50 | return 51 | } 52 | if !hovering{ 53 | shouldPopover = false 54 | return 55 | } 56 | 57 | let workItem = DispatchWorkItem { 58 | shouldPopover = hovering 59 | } 60 | hoverWorkItem = workItem 61 | DispatchQueue.main.asyncAfter(deadline: .now() + 0.6, execute: workItem) 62 | }) 63 | .popover(isPresented: $shouldPopover, content: { 64 | // 增加 interactiveDismissDisabled。 65 | // 否则有 popover 时,需要点击 action 使得 popover 消失然后再次点击才能产生 onclick 事件。 66 | Text(title).font(.headline).padding(5).interactiveDismissDisabled() 67 | }) 68 | } 69 | } 70 | 71 | // BarButtonStyle: click、onHover 显示不同的颜色 72 | struct BarButtonStyle: ButtonStyle { 73 | @State var isHover = false 74 | 75 | func makeBody(configuration: Configuration) -> some View { 76 | configuration.label 77 | .background(getColor(isPressed: configuration.isPressed)) 78 | .foregroundColor(.white) 79 | .onHover(perform: { hovering in 80 | isHover = hovering 81 | }) 82 | } 83 | 84 | func getColor(isPressed: Bool) -> Color{ 85 | if isPressed { 86 | return .blue.opacity(0.4) 87 | } 88 | return isHover ? .blue : .gray 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /Selected/View/Base/FloatingPanel.swift: -------------------------------------------------------------------------------- 1 | // 2 | // FloatingPanel.swift 3 | // Selected 4 | // 5 | // Created by sake on 2024/3/10. 6 | // 7 | // from https://www.markusbodner.com/til/2021/02/08/create-a-spotlight/alfred-like-window-on-macos-with-swiftui/ 8 | 9 | import SwiftUI 10 | 11 | class FloatingPanel: NSPanel { 12 | var key = false 13 | var main = false 14 | init(contentRect: NSRect, backing: NSWindow.BackingStoreType, defer flag: Bool, key: Bool = false) { 15 | self.key = key 16 | self.main = key 17 | // Not sure if .titled does affect anything here. Kept it because I think it might help with accessibility but I did not test that. 18 | super.init(contentRect: contentRect, styleMask: [.nonactivatingPanel, .resizable, .closable, .fullSizeContentView], backing: backing, defer: flag) 19 | 20 | // Set this if you want the panel to remember its size/position 21 | // self.setFrameAutosaveName("a unique name") 22 | 23 | // Allow the pannel to be on top of almost all other windows 24 | self.isFloatingPanel = true 25 | self.level = .floating 26 | 27 | // Allow the pannel to appear in a fullscreen space 28 | self.collectionBehavior.insert(.fullScreenAuxiliary) 29 | 30 | // While we may set a title for the window, don't show it 31 | self.titleVisibility = .hidden 32 | self.titlebarAppearsTransparent = true 33 | 34 | // Since there is no titlebar make the window moveable by click-dragging on the background 35 | self.isMovableByWindowBackground = true 36 | 37 | // Keep the panel around after closing since I expect the user to open/close it often 38 | self.isReleasedWhenClosed = false 39 | 40 | // Activate this if you want the window to hide once it is no longer focused 41 | // self.hidesOnDeactivate = true 42 | 43 | // Hide the traffic icons (standard close, minimize, maximize buttons) 44 | self.standardWindowButton(.closeButton)?.isHidden = true 45 | self.standardWindowButton(.miniaturizeButton)?.isHidden = true 46 | self.standardWindowButton(.zoomButton)?.isHidden = true 47 | } 48 | 49 | // `canBecomeKey` and `canBecomeMain` are required so that text inputs inside the panel can receive focus 50 | override var canBecomeKey: Bool { 51 | return key 52 | } 53 | 54 | override var canBecomeMain: Bool { 55 | return main 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /Selected/View/Base/IconImage.swift: -------------------------------------------------------------------------------- 1 | // 2 | // IconImage.swift 3 | // Selected 4 | // 5 | // Created by sake on 2024/3/18. 6 | // 7 | 8 | import Foundation 9 | import SwiftUI 10 | 11 | struct Icon: View { 12 | let text: String 13 | 14 | init(_ text: String) { 15 | self.text = text 16 | } 17 | 18 | var body : some View { 19 | 20 | if text.starts(with: "file://") { 21 | // load from a file 22 | 23 | let im: NSImage = 24 | { 25 | 26 | $0.size.height = 10 27 | $0.size.width = 10 28 | 29 | return $0 30 | }( 31 | NSImage(contentsOfFile: text.trimPrefix("file://"))! 32 | ) 33 | return 34 | AnyView(Image(nsImage: im).resizable().aspectRatio(contentMode: .fit) 35 | .frame(width: 20, height: 20).frame(width: 30, height: 30)) 36 | } else if text.starts(with: "symbol:") { 37 | return 38 | AnyView(Image(systemName: text.trimPrefix("symbol:")).resizable().aspectRatio(contentMode: .fit) 39 | .frame(width: 20, height: 20).frame(width: 30, height: 30) 40 | ) 41 | } 42 | 43 | let t = text.split(separator: " ") 44 | guard t.count == 2 else { 45 | return AnyView(Image(systemName: "circle")) 46 | } 47 | 48 | let shape = String(t[0]) 49 | let characters = String(t[1]) 50 | guard characters.count <= 3 else { 51 | return AnyView(Image(systemName: shape)) 52 | } 53 | 54 | var size = 14 55 | if characters.count == 2{ 56 | size = 8 57 | } else if characters.count == 3 { 58 | size = 5 59 | } 60 | 61 | return AnyView(Image(systemName: shape).resizable().frame(width: 20, height: 20) 62 | .overlay { 63 | Text(characters).font(.system(size: CGFloat(size))) 64 | }.frame(width: 30, height: 30)) 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /Selected/View/Base/ShortcutRecorderView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ShortcutRecorderView.swift 3 | // Selected 4 | // 5 | // Created by sake on 2024/4/21. 6 | // 7 | 8 | import SwiftUI 9 | import ShortcutRecorder 10 | 11 | struct ShortcutRecorderView: NSViewRepresentable { 12 | @Binding var shortcut: Shortcut 13 | 14 | func makeNSView(context: Context) -> RecorderControl { 15 | let recorder = RecorderControl() 16 | return recorder 17 | } 18 | 19 | func updateNSView(_ nsView: RecorderControl, context: Context) { 20 | nsView.objectValue = shortcut 21 | nsView.delegate = context.coordinator 22 | } 23 | 24 | func makeCoordinator() -> Coordinator { 25 | Coordinator(self) 26 | } 27 | 28 | class Coordinator: NSObject, RecorderControlDelegate { 29 | var parent: ShortcutRecorderView 30 | 31 | init(_ recorderWrapper: ShortcutRecorderView) { 32 | self.parent = recorderWrapper 33 | } 34 | 35 | func shortcutRecorderDidEndRecording(_ recorder: RecorderControl) { 36 | if let objectValue = recorder.objectValue { 37 | parent.shortcut = objectValue 38 | } 39 | } 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /Selected/View/Base/TextView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TextView.swift 3 | // Selected 4 | // 5 | // Created by sake on 2024/4/18. 6 | // 7 | 8 | import SwiftUI 9 | import AppKit 10 | 11 | struct TextView: NSViewRepresentable { 12 | var text: String 13 | var font: NSFont? = NSFont(name: "UbuntuMonoNFM", size: 14) 14 | 15 | func makeNSView(context: Context) -> NSScrollView { 16 | let scrollView = NSScrollView() 17 | let textView = NSTextView() 18 | 19 | // 配置滚动视图 20 | scrollView.hasVerticalScroller = true 21 | scrollView.documentView = textView 22 | scrollView.backgroundColor = .clear 23 | scrollView.drawsBackground = false // 确保不会绘制默认的背景 24 | 25 | // 配置文本视图 26 | textView.isEditable = false 27 | textView.autoresizingMask = [.width] 28 | textView.backgroundColor = .clear 29 | textView.drawsBackground = false 30 | textView.translatesAutoresizingMaskIntoConstraints = true 31 | textView.string = text 32 | textView.font = font 33 | 34 | return scrollView 35 | } 36 | 37 | func updateNSView(_ nsView: NSScrollView, context: Context) { 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /Selected/View/ChatView/ChatTextView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ChatTextView.swift 3 | // Selected 4 | // 5 | // Created by sake on 2024/6/29. 6 | // 7 | 8 | import Foundation 9 | import SwiftUI 10 | import MarkdownUI 11 | import Defaults 12 | 13 | 14 | struct ChatTextView: View { 15 | let ctx: ChatContext 16 | 17 | @ObservedObject var viewModel: MessageViewModel 18 | @EnvironmentObject var pinned: PinnedModel 19 | @State private var task: Task? = nil 20 | 21 | var body: some View { 22 | VStack(alignment: .leading) { 23 | VStack(alignment: .leading){ 24 | if ctx.bundleID != "" { 25 | HStack { 26 | getIcon(ctx.bundleID) 27 | Text(getAppName(ctx.bundleID)) 28 | Spacer() 29 | Button { 30 | pinned.pinned = !pinned.pinned 31 | } label: { 32 | if pinned.pinned { 33 | Text("unpin") 34 | } else { 35 | Text("pin") 36 | } 37 | } 38 | }.padding(.bottom, 10) 39 | } 40 | Text(ctx.text.trimmingCharacters(in: .whitespacesAndNewlines)).font(.custom( "UbuntuMonoNFM", size: 14)).foregroundColor(.gray).lineLimit(1) 41 | .frame(alignment: .leading).padding(.leading, 10) 42 | if ctx.webPageURL != "" { 43 | HStack { 44 | Spacer() 45 | Link(destination: URL(string: ctx.webPageURL)!, label: { 46 | Image(systemName: "globe") 47 | }) 48 | } 49 | } 50 | }.padding() 51 | 52 | ScrollViewReader { scrollViewProxy in 53 | List($viewModel.messages) { $message in 54 | MessageView(message: message).id(message.id) 55 | }.scrollContentBackground(.hidden) 56 | .listStyle(.inset) 57 | .frame(width: 750, height: 400).task { 58 | task = Task{ 59 | await viewModel.fetchMessages(ctx: ctx) 60 | } 61 | }.onChange(of: viewModel.messages) { _ in 62 | if let lastItemIndex = $viewModel.messages.last?.id { 63 | // Scroll to the last item 64 | withAnimation { 65 | scrollViewProxy.scrollTo(lastItemIndex, anchor: .bottom) 66 | } 67 | } 68 | } 69 | } 70 | ChatInputView(viewModel: viewModel) 71 | .frame(minHeight: 50) 72 | .padding(.leading, 20.0) 73 | .padding(.trailing, 20.0) 74 | .padding(.bottom, 10) 75 | }.frame(width: 750).onDisappear(){ 76 | task?.cancel() 77 | } 78 | } 79 | 80 | private func getAppName(_ bundleID: String) -> String { 81 | let bundleURL = NSWorkspace.shared.urlForApplication(withBundleIdentifier: bundleID)! 82 | return FileManager.default.displayName(atPath: bundleURL.path) 83 | } 84 | 85 | private func getIcon(_ bundleID: String) -> some View { 86 | let bundleURL = NSWorkspace.shared.urlForApplication(withBundleIdentifier: bundleID)! 87 | return AnyView( 88 | Image(nsImage: NSWorkspace.shared.icon(forFile: bundleURL.path)).resizable().aspectRatio(contentMode: .fit).frame(width: 30, height: 30) 89 | ) 90 | } 91 | } 92 | 93 | struct ChatInputView: View { 94 | var viewModel: MessageViewModel 95 | @State private var newText: String = "" 96 | @State private var task: Task? = nil 97 | 98 | var body: some View { 99 | if #available(macOS 14.0, *) { 100 | ZStack(alignment: .leading){ 101 | if newText.isEmpty { 102 | Text("Press cmd+enter to send new message") 103 | .disabled(true) 104 | .padding(4) 105 | } 106 | TextEditor(text: $newText).onKeyPress(.return, phases: .down) {keyPress in 107 | if !keyPress.modifiers.contains(.command) { 108 | return .ignored 109 | } 110 | submitMessage() 111 | return .handled 112 | } 113 | .opacity(self.newText.isEmpty ? 0.25 : 1) 114 | .padding(10) 115 | } .scrollContentBackground(.hidden) 116 | .background(Color.gray.opacity(0.1)) 117 | .cornerRadius(8) 118 | .onDisappear(){ 119 | task?.cancel() 120 | } 121 | } else { 122 | // Fallback on earlier versions 123 | TextField("Press enter to send new message", text: $newText, axis: .vertical) 124 | .lineLimit(3...) 125 | .textFieldStyle(.plain) 126 | .padding(10) 127 | .scrollContentBackground(.hidden) 128 | .background(Color.gray.opacity(0.1)) 129 | .cornerRadius(8) 130 | .padding() 131 | .onSubmit { 132 | submitMessage() 133 | }.onDisappear(){ 134 | task?.cancel() 135 | } 136 | } 137 | } 138 | 139 | func submitMessage(){ 140 | let message = newText 141 | newText = "" 142 | task = Task { 143 | await viewModel.submit(message: message) 144 | } 145 | } 146 | } 147 | -------------------------------------------------------------------------------- /Selected/View/ChatView/MarkdowLateXView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ContentView.swift 3 | // Selected 4 | // 5 | // Created by sake on 2025/3/16. 6 | // 7 | 8 | import SwiftUI 9 | import MarkdownUI 10 | import LaTeXSwiftUI 11 | import Highlightr 12 | 13 | struct LaTeXImageProvider: InlineImageProvider, ImageProvider { 14 | let latexFormulas: [String: String] 15 | 16 | func image(with url: URL, label: String) async throws -> Image { 17 | if url.scheme == "latex", let id = url.host, let formula = latexFormulas[id] { 18 | return try await renderLatexImage(formula) 19 | } 20 | return try await DefaultInlineImageProvider.default.image(with: url, label: label) 21 | } 22 | 23 | public func makeImage(url: URL?) -> some View { 24 | if let url = url, url.scheme == "latex", let id = url.host, let formula = latexFormulas[id] { 25 | LaTeX(formula) 26 | .frame(maxWidth: 500) 27 | .padding(.vertical, 0) 28 | } else if let url = url { 29 | DefaultImageProvider.default.makeImage(url: url) 30 | } else { 31 | DefaultImageProvider.default.makeImage(url: nil) 32 | } 33 | } 34 | 35 | @MainActor 36 | private func renderLatexImage(_ formula: String) async throws -> Image { 37 | // 在MainActor上创建视图 38 | let latexView = LaTeX(formula) 39 | .frame(maxWidth: 500) 40 | .padding(.vertical, 0) 41 | 42 | // 设置渲染器 43 | let renderer = ImageRenderer(content: latexView) 44 | renderer.scale = NSScreen.main?.backingScaleFactor ?? 2.0 45 | 46 | // 渲染为图像 47 | if let nsImage = renderer.nsImage { 48 | return Image(nsImage: nsImage) 49 | } 50 | 51 | throw URLError(.cannotDecodeContentData) 52 | } 53 | } 54 | 55 | struct MarkdownWithLateXView: View { 56 | 57 | @Environment(\.colorScheme) private var colorScheme 58 | var highlighter = CustomCodeSyntaxHighlighter() 59 | 60 | @Binding var markdownString: String 61 | 62 | // 用于标识已处理过的标记 63 | private let latexBlockPlaceholder = "LATEX_BLOCK_" 64 | 65 | // 正则表达式匹配 LaTeX 公式 66 | private let inlineLatexPattern = #"\$(.*?)\$"# 67 | private let inlineLatexPattern2 = #"\\\((.*?)\\\)"# 68 | 69 | private let blockLatexPattern = #"\$\$(.*?)\$\$"# 70 | private let blockLatexPattern2 = #"\\\[(.*?)\\\]"# 71 | 72 | var body: some View { 73 | let (processedMarkdown, latexFormulas) = processMarkdownWithLatex() 74 | 75 | return Markdown{processedMarkdown}.markdownBlockStyle(\.codeBlock) { configuration in 76 | // 处理块级公式占位 77 | if let id = extractLatexId(from: configuration.language ?? "", prefix: latexBlockPlaceholder), 78 | let formula = latexFormulas[id] { 79 | LaTeX(formula) 80 | .padding() 81 | .frame(maxWidth: .infinity, alignment: .center) 82 | } else { 83 | codeBlock(configuration) 84 | } 85 | }.markdownInlineImageProvider(LaTeXImageProvider(latexFormulas: latexFormulas)) 86 | .markdownImageProvider(LaTeXImageProvider(latexFormulas: latexFormulas)) 87 | } 88 | 89 | func getLanguage(_ configuration: CodeBlockConfiguration) -> String { 90 | guard let language = configuration.language else { 91 | return "plaintext" 92 | } 93 | return language == "" ? "plaintext": language 94 | } 95 | 96 | @ViewBuilder 97 | private func codeBlock(_ configuration: CodeBlockConfiguration) -> some View { 98 | VStack(alignment: .leading, spacing: 0) { 99 | HStack { 100 | Text(getLanguage(configuration)) 101 | .font(.system(.caption, design: .monospaced)) 102 | .fontWeight(.semibold) 103 | Spacer() 104 | 105 | Image(systemName: "clipboard") 106 | .onTapGesture { 107 | copyToClipboard(configuration.content) 108 | } 109 | } 110 | .padding(.horizontal, 5) 111 | 112 | Divider() 113 | 114 | // wrap long lines 115 | highlighter.setTheme(theme: codeTheme).highlightCode(configuration.content, language: configuration.language) 116 | .relativeLineSpacing(.em(0.5)) 117 | .padding(5) 118 | .markdownMargin(top: .em(1), bottom: .em(1)) 119 | } 120 | .overlay( 121 | RoundedRectangle(cornerRadius: 10) 122 | .stroke(Color.black, lineWidth: 2) 123 | ) 124 | .clipShape(RoundedRectangle(cornerRadius: 8)) 125 | .markdownMargin(top: .zero, bottom: .em(0.8)) 126 | } 127 | 128 | 129 | private func copyToClipboard(_ string: String) { 130 | let pasteboard = NSPasteboard.general 131 | pasteboard.clearContents() 132 | pasteboard.setString(string, forType: .string) 133 | } 134 | 135 | private var codeTheme: CodeTheme { 136 | switch self.colorScheme { 137 | case .dark: 138 | return .dark 139 | default: 140 | return .light 141 | } 142 | } 143 | 144 | 145 | // 提取 LaTeX 公式 ID 146 | private func extractLatexId(from text: String, prefix: String) -> String? { 147 | guard text.hasPrefix(prefix) else { return nil } 148 | return String(text.dropFirst(prefix.count)) 149 | } 150 | 151 | private func blockLatex(markdown: String, latexFormulas: inout [String: String], blockLatexPattern: String) -> (String){ 152 | // 处理块级公式 153 | let result = markdown 154 | var forumlaIDs = [String: String]() 155 | 156 | let blockLatexRegex = try! NSRegularExpression(pattern: blockLatexPattern, options: [.dotMatchesLineSeparators]) 157 | let blockMatches = blockLatexRegex.matches(in: result, range: NSRange(result.startIndex..., in: result)) 158 | let mutableResult = NSMutableString(string: result) 159 | for match in blockMatches.reversed() { 160 | if let range = Range(match.range, in: result) { 161 | let formula = String(result[range]) 162 | let latexContent = formula 163 | var id = "" 164 | if let val = forumlaIDs[latexContent] { 165 | id = val 166 | } else { 167 | id = UUID().uuidString 168 | latexFormulas[id] = latexContent 169 | forumlaIDs[latexContent] = id 170 | } 171 | 172 | // 替换为自定义代码块 173 | let replacement = "\n```\(latexBlockPlaceholder)\(id)\n```\n" 174 | mutableResult.replaceCharacters(in: match.range, with: replacement) 175 | } 176 | } 177 | return String(mutableResult) 178 | } 179 | 180 | private func inlineLatex(markdown: String, latexFormulas: inout [String: String], inlineLatexPattern: String) -> (String) { 181 | // 处理内联公式 182 | let result = markdown 183 | var forumlaIDs = [String: String]() 184 | 185 | let inlineLatexRegex = try! NSRegularExpression(pattern: inlineLatexPattern, options: []) 186 | let inlineMatches = inlineLatexRegex.matches(in: result, range: NSRange(result.startIndex..., in: result)) 187 | let mutableInlineResult = NSMutableString(string: result) 188 | 189 | for match in inlineMatches.reversed() { 190 | if let range = Range(match.range, in: result) { 191 | let formula = String(result[range]) 192 | let latexContent = formula 193 | var id = "" 194 | if let val = forumlaIDs[latexContent] { 195 | id = val 196 | } else { 197 | id = UUID().uuidString 198 | latexFormulas[id] = latexContent 199 | forumlaIDs[latexContent] = id 200 | } 201 | 202 | // 使用自定义 URL schema 的内联图像 203 | let replacement = "![LaTeX formula](latex://\(id))" 204 | mutableInlineResult.replaceCharacters(in: match.range, with: replacement) 205 | } 206 | } 207 | return String(mutableInlineResult) 208 | } 209 | 210 | // 处理 Markdown 中的 LaTeX 公式,返回处理后的文本和公式字典 211 | private func processMarkdownWithLatex() -> (String, [String: String]) { 212 | var result = markdownString 213 | var latexFormulas = [String: String]() 214 | 215 | result = blockLatex(markdown: result, latexFormulas: &latexFormulas, blockLatexPattern: blockLatexPattern) 216 | 217 | result = blockLatex(markdown: result, latexFormulas: &latexFormulas, blockLatexPattern: blockLatexPattern2) 218 | 219 | result = inlineLatex(markdown: result, latexFormulas: &latexFormulas, inlineLatexPattern: inlineLatexPattern) 220 | 221 | result = inlineLatex(markdown: result, latexFormulas: &latexFormulas, inlineLatexPattern: inlineLatexPattern2) 222 | return (result, latexFormulas) 223 | } 224 | } 225 | -------------------------------------------------------------------------------- /Selected/View/ChatView/MessageView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MessageView.swift 3 | // Selected 4 | // 5 | // Created by sake on 2024/6/29. 6 | // 7 | 8 | import Foundation 9 | import SwiftUI 10 | import MarkdownUI 11 | import Highlightr 12 | 13 | struct MessageView: View { 14 | @ObservedObject var message: ResponseMessage 15 | 16 | @Environment(\.colorScheme) private var colorScheme 17 | 18 | 19 | @State private var rotation: Double = 0 20 | @State private var animationTimer: Timer? = nil 21 | 22 | var body: some View { 23 | VStack(alignment: .leading){ 24 | HStack{ 25 | Text(LocalizedStringKey(message.role.rawValue)) 26 | .foregroundStyle(.blue.gradient).font(.headline) 27 | if message.role == .assistant || message.role == .tool { 28 | switch message.status { 29 | case .initial: 30 | Image(systemName: "arrow.clockwise").foregroundStyle(.gray) 31 | case .updating: 32 | Image(systemName: "arrow.2.circlepath") 33 | .foregroundStyle(.orange) 34 | .rotationEffect(.degrees(rotation)) 35 | .animation(.linear(duration: 1).repeatForever(autoreverses: false), value: rotation) 36 | .onAppear(){ 37 | animationTimer?.invalidate() // Invalidate any existing timer 38 | animationTimer = Timer.scheduledTimer(withTimeInterval: 0.02, repeats: true) { _ in 39 | rotation += 5 40 | if rotation >= 360 { 41 | rotation -= 360 42 | } 43 | } 44 | }.onDisappear(){ 45 | animationTimer?.invalidate() 46 | } 47 | case .finished: 48 | Image(systemName: "checkmark.circle").foregroundStyle(.green) 49 | default: 50 | EmptyView() 51 | } 52 | } else if message.role == .system && message.status == .failure { 53 | Image(systemName: "exclamationmark.triangle.fill").foregroundStyle(.red) 54 | } 55 | Spacer() 56 | if message.role == .assistant { 57 | Button(action: { 58 | let pasteboard = NSPasteboard.general 59 | pasteboard.clearContents() 60 | pasteboard.setString(message.message, forType: .string) 61 | }, label: { 62 | Image(systemName: "doc.on.clipboard.fill") 63 | }) 64 | .foregroundColor(Color.white) 65 | .cornerRadius(5) 66 | Button { 67 | Task { 68 | await TTSManager.speak(MarkdownContent(message.message).renderPlainText(), view: false) 69 | } 70 | } label: { 71 | Image(systemName: "play.circle") 72 | }.foregroundColor(Color.white) 73 | .cornerRadius(5) 74 | } 75 | }.frame(height: 20).padding(.trailing, 30.0) 76 | 77 | 78 | if message.role == .system { 79 | if message.status == .failure { 80 | Text(message.message).foregroundStyle(.red) 81 | .padding(.leading, 20.0) 82 | .padding(.trailing, 40.0) 83 | .padding(.top, 5) 84 | .padding(.bottom, 20) 85 | } else { 86 | Text(message.message) 87 | .padding(.leading, 20.0) 88 | .padding(.trailing, 40.0) 89 | .padding(.top, 5) 90 | .padding(.bottom, 20) 91 | } 92 | } else { 93 | MarkdownWithLateXView(markdownString: $message.message) 94 | .padding(.leading, 20.0) 95 | .padding(.trailing, 40.0) 96 | .padding(.top, 5) 97 | .padding(.bottom, 20) 98 | } 99 | }.frame(width: 750) 100 | } 101 | 102 | private var codeTheme: CodeTheme { 103 | switch self.colorScheme { 104 | case .dark: 105 | return .dark 106 | default: 107 | return .light 108 | } 109 | } 110 | } 111 | -------------------------------------------------------------------------------- /Selected/View/ChatView/MessageViewModel.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MessageViewModel.swift 3 | // Selected 4 | // 5 | // Created by sake on 2024/6/29. 6 | // 7 | 8 | import Foundation 9 | 10 | @MainActor 11 | class MessageViewModel: ObservableObject { 12 | @Published var messages: [ResponseMessage] = [] 13 | var chatService: AIChatService 14 | 15 | init(chatService: AIChatService) { 16 | self.chatService = chatService 17 | self.messages.append(ResponseMessage(message: NSLocalizedString("waiting", comment: "system info"), role: .system)) 18 | } 19 | 20 | func submit(message: String) async { 21 | await withTaskGroup(of: Void.self) { group in 22 | group.addTask { 23 | await MainActor.run { 24 | self.messages.append(ResponseMessage(message: message, role: .user, status: .finished)) 25 | } 26 | } 27 | } 28 | await chatService.chatFollow(index: messages.count-1, userMessage: message){ [weak self] index, message in 29 | DispatchQueue.main.async { 30 | [weak self] in 31 | guard let self = self else { return } 32 | if self.messages.count < index+1 { 33 | self.messages.append(ResponseMessage(message: "", role: message.role)) 34 | } 35 | if message.role != self.messages[index].role { 36 | self.messages[index].role = message.role 37 | } 38 | self.messages[index].status = message.status 39 | if message.new { 40 | self.messages[index].message = message.message 41 | } else { 42 | self.messages[index].message += message.message 43 | } 44 | } 45 | } 46 | } 47 | 48 | func fetchMessages(ctx: ChatContext) async -> Void{ 49 | await chatService.chat(ctx: ctx) { [weak self] index, message in 50 | DispatchQueue.main.async { 51 | [weak self] in 52 | guard let self = self else { return } 53 | if self.messages.count < index+1 { 54 | self.messages.append(ResponseMessage(message: "", role: message.role)) 55 | } 56 | 57 | if message.role != self.messages[index].role { 58 | self.messages[index].role = message.role 59 | } 60 | 61 | self.messages[index].status = message.status 62 | if message.new { 63 | self.messages[index].message = message.message 64 | } else { 65 | self.messages[index].message += message.message 66 | } 67 | } 68 | } 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /Selected/View/ChatView/TranslationView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TranslationView.swift 3 | // Selected 4 | // 5 | // Created by sake on 2024/6/29. 6 | // 7 | 8 | import Foundation 9 | import SwiftUI 10 | import MarkdownUI 11 | 12 | struct TranslationView: View { 13 | var text: String 14 | @State var transText: String = "..." 15 | @State private var hasRep = false 16 | var to: String = "cn" 17 | 18 | @Environment(\.colorScheme) private var colorScheme 19 | var highlighter = CustomCodeSyntaxHighlighter() 20 | 21 | @State private var word: Word? 22 | 23 | var body: some View { 24 | VStack(alignment: .leading) { 25 | if let w = word { 26 | Label { 27 | Text("[\(w.phonetic)]") 28 | } icon: { 29 | Text("phonetic") 30 | }.padding(.top, 20).padding(.leading, 20) 31 | if w.exchange != "" { 32 | Label { 33 | Text(w.exchange) 34 | } icon: { 35 | Text("exchange") 36 | }.padding(.leading, 20) 37 | } 38 | Divider() 39 | } 40 | ScrollView(.vertical){ 41 | Markdown(self.transText) 42 | .markdownBlockStyle(\.codeBlock, body: {label in 43 | // wrap long lines 44 | highlighter.setTheme(theme: codeTheme).highlightCode(label.content, language: label.language) 45 | .padding() 46 | .clipShape(RoundedRectangle(cornerRadius: 8)) 47 | .markdownMargin(top: .em(1), bottom: .em(1)) 48 | }) 49 | .padding(.leading, 20.0) 50 | .padding(.trailing, 20.0) 51 | .padding(.top, 20) 52 | .frame(width: 550, alignment: .leading) 53 | .task { 54 | if isPreview { 55 | return 56 | } 57 | if isWord(str: text) { 58 | word = try! StarDict.shared.query(word: text) 59 | } 60 | await Translation(toLanguage: to).translate(content: text) { content in 61 | if !hasRep { 62 | transText = content 63 | hasRep = true 64 | } else { 65 | transText = transText + content 66 | } 67 | } 68 | } 69 | }.frame(width: 550, height: 300) 70 | Divider() 71 | HStack{ 72 | Button(action: { 73 | let pasteboard = NSPasteboard.general 74 | pasteboard.clearContents() 75 | let painText = MarkdownContent(self.transText).renderPlainText() 76 | pasteboard.setString(painText, forType: .string) 77 | }, label: { 78 | Image(systemName: "doc.on.clipboard.fill") 79 | }) 80 | .foregroundColor(Color.white) 81 | .cornerRadius(5) 82 | Button { 83 | Task{ 84 | await TTSManager.speak(MarkdownContent(self.transText).renderPlainText()) 85 | } 86 | } label: { 87 | Image(systemName: "play.circle") 88 | }.foregroundColor(Color.white) 89 | .cornerRadius(5) 90 | }.frame(width: 550, height: 30).padding(.bottom, 10) 91 | } 92 | } 93 | 94 | private var codeTheme: CodeTheme { 95 | switch self.colorScheme { 96 | case .dark: 97 | return .dark 98 | default: 99 | return .light 100 | } 101 | } 102 | } 103 | 104 | 105 | var isPreview: Bool { 106 | return ProcessInfo.processInfo.environment["XCODE_RUNNING_FOR_PREVIEWS"] == "1" 107 | } 108 | 109 | #Preview { 110 | TranslationView(text: "单词;语言的基本单位,用来表达概念、事物或动作。语言的基本单位,用来表达概念、事物或动作。", transText: """ 111 | ### Word 112 | 113 | - **意思1:** 单词;语言的基本单位,用来表达概念、事物或动作。语言的基本单位,用来表达概念、事物或动作。语言的基本单位,用来表达概念、事物或动作。语言的基本单位,用来表达概念、事物或动作。语言的基本单位,用来表达概念、事物或动作。语言的基本单位,用来表达概念、事物或动作。 114 | 115 | **例句:** He asked me to spell the word "responsibility". 116 | 117 | - **意思1:** 单词;语言的基本单位,用来表达概念、事物或动作。 118 | 119 | **例句:** He asked me to spell the word "responsibility". 120 | 121 | - **意思1:** 单词;语言的基本单位,用来表达概念、事物或动作。 122 | 123 | **例句:** He asked me to spell the word "responsibility". 124 | 125 | - **意思1:** 单词;语言的基本单位,用来表达概念、事物或动作。 126 | 127 | **例句:** He asked me to spell the word "responsibility". 128 | 129 | - **意思2:** 单词;语言的基本单位,用来表达概念、事物或动作。 130 | 131 | **例句:** He asked me to spell the word "responsibility". 132 | """ 133 | ) 134 | } 135 | -------------------------------------------------------------------------------- /Selected/View/ClipView/PDFView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PDFView.swift 3 | // Selected 4 | // 5 | // Created by sake on 2024/4/7. 6 | // 7 | 8 | import Foundation 9 | import SwiftUI 10 | import PDFKit 11 | 12 | struct PDFKitRepresentedView: NSViewRepresentable { 13 | let url: URL 14 | 15 | func makeNSView(context: Context) -> PDFView { 16 | let pdfView = PDFView() 17 | pdfView.autoScales = true // Automatically scale the PDF to fit the view 18 | pdfView.autoresizingMask = [.width, .height] 19 | // 加载 PDF 文档 20 | if let document = PDFDocument(url: url) { 21 | pdfView.document = document 22 | } 23 | return pdfView 24 | } 25 | 26 | func updateNSView(_ nsView: PDFView, context: Context) { 27 | // 这个方法里面可以留空,因为 PDFView 的内容不会经常改变 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /Selected/View/ClipView/QuickLookView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // QuickLookView.swift 3 | // Selected 4 | // 5 | // Created by sake on 2024/4/7. 6 | // 7 | 8 | import SwiftUI 9 | import QuickLookUI 10 | 11 | struct QuickLookPreview: NSViewRepresentable { 12 | var url: URL 13 | 14 | func makeNSView(context: Context) -> QLPreviewView { 15 | // 初始化并配置 QLPreviewView 16 | let preview = QLPreviewView() 17 | preview.previewItem = url as NSURL 18 | return preview 19 | } 20 | 21 | func updateNSView(_ nsView: QLPreviewView, context: Context) { 22 | // 更新视图(如果需要) 23 | nsView.previewItem = url as NSURL 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /Selected/View/ClipView/RTFView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // RTFView.swift 3 | // Selected 4 | // 5 | // Created by sake on 2024/4/7. 6 | // 7 | 8 | import Foundation 9 | import SwiftUI 10 | 11 | struct RTFView: NSViewRepresentable { 12 | var rtfData: Data 13 | 14 | func makeNSView(context: Context) -> NSScrollView { 15 | let textView = NSTextView() 16 | textView.isEditable = false // 设为false禁止编辑 17 | textView.autoresizingMask = [.width] 18 | textView.translatesAutoresizingMaskIntoConstraints = true 19 | if let attributedString = 20 | try? NSMutableAttributedString(data: rtfData, 21 | options: [ 22 | .documentType: NSAttributedString.DocumentType.rtf], 23 | documentAttributes: nil) { 24 | let originalRange = NSMakeRange(0, attributedString.length); 25 | attributedString.addAttribute(NSAttributedString.Key.backgroundColor, value: NSColor.clear, range: originalRange) 26 | 27 | textView.textStorage?.setAttributedString(attributedString) 28 | } 29 | textView.drawsBackground = false // 确保不会绘制默认的背景 30 | textView.backgroundColor = .clear 31 | 32 | let scrollView = NSScrollView() 33 | scrollView.hasVerticalScroller = true 34 | scrollView.documentView = textView 35 | scrollView.backgroundColor = .clear 36 | scrollView.drawsBackground = false // 确保不会绘制默认的背景 37 | return scrollView 38 | } 39 | 40 | func updateNSView(_ nsView: NSScrollView, context: Context) { 41 | // 用于更新视图 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /Selected/View/ClipView/SearchView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SearchView.swift 3 | // Selected 4 | // 5 | // Created by sake on 19/3/25. 6 | // 7 | 8 | import SwiftUI 9 | 10 | // MARK: - 自定义搜索框(基于 NSSearchField,可以捕获方向键事件) 11 | struct CustomSearchField: NSViewRepresentable { 12 | @Binding var text: String 13 | var placeholder: String = "Search" 14 | var onArrowKey: (ArrowDirection) -> Void 15 | 16 | enum ArrowDirection { 17 | case up, down 18 | } 19 | 20 | class Coordinator: NSObject, NSSearchFieldDelegate { 21 | var parent: CustomSearchField 22 | 23 | init(parent: CustomSearchField) { 24 | self.parent = parent 25 | } 26 | 27 | func controlTextDidChange(_ notification: Notification) { 28 | if let searchField = notification.object as? NSSearchField { 29 | parent.text = searchField.stringValue 30 | } 31 | } 32 | 33 | func control(_ control: NSControl, textView: NSTextView, doCommandBy commandSelector: Selector) -> Bool { 34 | if commandSelector == #selector(NSResponder.moveUp(_:)) { 35 | parent.onArrowKey(.up) 36 | return true 37 | } else if commandSelector == #selector(NSResponder.moveDown(_:)) { 38 | parent.onArrowKey(.down) 39 | return true 40 | } 41 | return false 42 | } 43 | } 44 | 45 | func makeCoordinator() -> Coordinator { 46 | return Coordinator(parent: self) 47 | } 48 | 49 | func makeNSView(context: Context) -> NSSearchField { 50 | let searchField = NSSearchField() 51 | searchField.delegate = context.coordinator 52 | searchField.placeholderString = placeholder 53 | return searchField 54 | } 55 | 56 | func updateNSView(_ nsView: NSSearchField, context: Context) { 57 | nsView.stringValue = text 58 | } 59 | } 60 | 61 | // MARK: - 搜索框外层样式封装 62 | struct SearchBarView: View { 63 | @Binding var searchText: String 64 | var onArrowKey: (CustomSearchField.ArrowDirection) -> Void 65 | 66 | var body: some View { 67 | HStack(spacing: 8) { 68 | CustomSearchField(text: $searchText, placeholder: "Search", onArrowKey: onArrowKey) 69 | .frame(height: 28) 70 | .padding(.vertical, 4) 71 | } 72 | .padding(.horizontal, 2) 73 | .padding(.vertical, 2) 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /Selected/View/ClipView/WebView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // WebView.swift 3 | // Selected 4 | // 5 | // Created by sake on 2024/4/7. 6 | // 7 | 8 | import Foundation 9 | import SwiftUI 10 | import WebKit 11 | 12 | struct WebView: NSViewRepresentable { 13 | let url: URL 14 | 15 | func makeNSView(context: Context) -> WKWebView { 16 | let webView = WKWebView() 17 | webView.configuration.preferences.isTextInteractionEnabled = false 18 | webView.customUserAgent = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/86.0.4240.193 Safari/537.36" 19 | return webView 20 | } 21 | 22 | func updateNSView(_ nsView: WKWebView, context: Context) { 23 | let request = URLRequest(url: self.url) 24 | nsView.load(request) 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /Selected/View/CommandView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CommandView.swift 3 | // Selected 4 | // 5 | // Created by sake on 2024/2/29. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct SelectedMainMenu: Commands { 11 | @Environment(\.openURL) 12 | private var openURL 13 | 14 | var body: some Commands { 15 | // Help 16 | CommandGroup(replacing: CommandGroupPlacement.help, addition: { 17 | Button("Feedback") { 18 | openURL(URL(string: "https://github.com/sakeven/Selected/issues")!) 19 | } 20 | }) 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /Selected/View/MenuItemView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MenuItemView.swift 3 | // Selected 4 | // 5 | // Created by sake on 2024/2/28. 6 | // 7 | 8 | 9 | import SettingsAccess 10 | import SwiftUI 11 | import Sparkle 12 | 13 | class PauseModel: ObservableObject { 14 | static let shared = PauseModel() 15 | @Published var pause: Bool = false 16 | } 17 | 18 | struct MenuItemView: View { 19 | @Environment(\.openURL) 20 | private var openURL 21 | 22 | @ObservedObject var pause = PauseModel.shared 23 | 24 | 25 | let updaterController = SPUStandardUpdaterController(startingUpdater: true, updaterDelegate: nil, userDriverDelegate: nil) 26 | 27 | var body: some View { 28 | Group { 29 | settingItem 30 | .keyboardShortcut(.init(",")) 31 | pauseItem.keyboardShortcut(.init("p")) 32 | Divider() 33 | feedbackItem 34 | docItem 35 | aboutItem 36 | CheckForUpdatesView(updater: updaterController.updater) 37 | Divider() 38 | quitItem 39 | .keyboardShortcut(.init("q")) 40 | } 41 | } 42 | 43 | @ViewBuilder 44 | private var aboutItem: some View { 45 | Button("About") { 46 | NSApp.activate(ignoringOtherApps: true) 47 | NSApp.orderFrontStandardAboutPanel(nil) 48 | } 49 | } 50 | 51 | @ViewBuilder 52 | private var pauseItem: some View { 53 | if pause.pause { 54 | Button("Resume") { 55 | pause.pause = false 56 | } 57 | } else { 58 | Button("Pause") { 59 | pause.pause = true 60 | } 61 | } 62 | } 63 | 64 | 65 | @ViewBuilder 66 | private var feedbackItem: some View { 67 | Button("Feedback") { 68 | openURL(URL(string: "https://github.com/sakeven/Selected/issues")!) 69 | } 70 | } 71 | 72 | @ViewBuilder 73 | private var docItem: some View { 74 | Button("Document") { 75 | openURL(URL(string: "https://github.com/sakeven/Selected?tab=readme-ov-file#%E5%8A%9F%E8%83%BD")!) 76 | } 77 | } 78 | 79 | @ViewBuilder 80 | private var settingItem: some View { 81 | SettingsLink { 82 | Text("Settings") 83 | } preAction: { 84 | print("打开设置") 85 | NSApp.activate(ignoringOtherApps: true) 86 | } postAction: { 87 | // nothing to do 88 | } 89 | } 90 | 91 | @ViewBuilder 92 | private var quitItem: some View { 93 | Button("Quit") { 94 | print("退出应用") 95 | NSApplication.shared.terminate(nil) 96 | } 97 | } 98 | } 99 | 100 | #Preview { 101 | MenuItemView() 102 | } 103 | -------------------------------------------------------------------------------- /Selected/View/PluginListView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PluginListView.swift 3 | // Selected 4 | // 5 | // Created by sake on 2024/3/18. 6 | // 7 | 8 | import Foundation 9 | import SwiftUI 10 | 11 | struct PluginListView: View { 12 | @ObservedObject var pluginMgr = PluginManager.shared 13 | 14 | var body: some View { 15 | VStack{ 16 | List{ 17 | ForEach($pluginMgr.plugins, id: \.self.info.name) { $plugin in 18 | DisclosureGroup{ 19 | if !$plugin.info.options.isEmpty { 20 | Form{ 21 | Section("Options"){ 22 | ForEach($plugin.info.options, id: \.self.identifier) { 23 | $option in 24 | OptionView(pluginName: plugin.info.name, option: $option) 25 | } 26 | } 27 | }.formStyle(.grouped).scrollContentBackground(.hidden) 28 | } 29 | } label: { 30 | HStack{ 31 | Label( 32 | title: { Text(plugin.info.name).padding(.leading, 10) 33 | if let desc = plugin.info.description { 34 | Text(desc).font(.system(size: 10)) 35 | } 36 | }, 37 | icon: { Icon(plugin.info.icon)} 38 | ).padding(10).contextMenu { 39 | Button(action: { 40 | NSLog("delete \(plugin.info.name)") 41 | pluginMgr.remove(plugin.info.pluginDir, plugin.info.name) 42 | }){ 43 | Text("Delete") 44 | } 45 | } 46 | } 47 | } 48 | } 49 | } 50 | } 51 | } 52 | } 53 | 54 | #Preview { 55 | PluginListView() 56 | } 57 | 58 | struct OptionView: View { 59 | var pluginName: String 60 | @Binding var option: Option 61 | 62 | @State private var toggle: Bool = false 63 | @State private var text: String = "" 64 | 65 | init(pluginName: String, option: Binding