├── .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 | 
6 |
7 | 2. Add fonts to the `Fonts` folder. Uncheck `Add to targets` and check `Copy items if needed`.
8 |
9 | 
10 |
11 | 3. Add `Application fonts resource path` to Info.plist and enter `Fonts`.
12 |
13 | 
14 |
15 | 4. Go to `Build Phases` and create a `New Copy Files Phase`.
16 |
17 | 
18 |
19 | 5. Set the `Destinations` to `Resources` and `Subpath` to `Fonts`. Then add your font files to the list.
20 |
21 | 
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 |
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.
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 |
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 = ")"
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