├── images
├── screenshot.png
├── screenshot2.png
└── store-preview.png
├── RClick
├── Resources
│ └── template.xlsx
├── Assets.xcassets
│ ├── template.xlsx
│ ├── Logo.imageset
│ │ ├── AppIcon@1x.png
│ │ └── Contents.json
│ ├── toolbar.imageset
│ │ ├── toolbar.png
│ │ ├── toolbar@2x.png
│ │ ├── toolbar@3x.png
│ │ └── Contents.json
│ ├── icon-file-md.imageset
│ │ ├── md@1x.png
│ │ ├── md@2x.png
│ │ ├── md@3x.png
│ │ └── Contents.json
│ ├── template.dataset
│ │ ├── template.xlsx
│ │ └── Contents.json
│ ├── AppIcon.appiconset
│ │ ├── AppIcon@1x.png
│ │ ├── AppIcon@2x.png
│ │ ├── AppIcon@4x.png
│ │ ├── AppIcon-lite.png
│ │ ├── AppIcon@0.25x.png
│ │ ├── AppIcon@0.5x.png
│ │ ├── AppIcon@1x 1.png
│ │ ├── AppIcon@2x 1.png
│ │ ├── AppIcon-lite@1x.png
│ │ ├── AppIcon-lite@1x 1.png
│ │ └── Contents.json
│ ├── MenuBar.imageset
│ │ ├── menu-bar@1x.png
│ │ ├── menu-bar@2x.png
│ │ ├── menu-bar-dark@1x.png
│ │ ├── menu-bar-dark@2x.png
│ │ └── Contents.json
│ ├── icon-file-txt.imageset
│ │ ├── txt@1x.png
│ │ ├── txt@2x.png
│ │ ├── txt@3x.png
│ │ └── Contents.json
│ ├── icon-file-docx.imageset
│ │ ├── docx@1x.png
│ │ ├── docx@2x.png
│ │ ├── docx@3x.png
│ │ └── Contents.json
│ ├── icon-file-json.imageset
│ │ ├── json@1x.png
│ │ ├── json@2x.png
│ │ ├── json@4x.png
│ │ └── Contents.json
│ ├── icon-file-pptx.imageset
│ │ ├── pptx@1x.png
│ │ ├── pptx@2x.png
│ │ ├── pptx@3x.png
│ │ └── Contents.json
│ ├── icon-file-xlsx.imageset
│ │ ├── xlsx@1x.png
│ │ ├── xlsx@2x.png
│ │ ├── xlsx@3x.png
│ │ └── Contents.json
│ ├── Contents.json
│ ├── AccentColor.colorset
│ │ └── Contents.json
│ └── github.imageset
│ │ ├── Contents.json
│ │ └── github.svg
├── Preview Content
│ └── Preview Assets.xcassets
│ │ └── Contents.json
├── Info.plist
├── Shared
│ ├── AppLogger.swift
│ ├── Constants.swift
│ ├── Utils.swift
│ ├── Extension+.swift
│ ├── Messager.swift
│ ├── UpdaterView.swift
│ ├── LaunchAtLogin.swift
│ ├── StringExtension.swift
│ └── Updater.swift
├── RClick.entitlements
├── Settings
│ ├── SettingsWindow.swift
│ ├── ActionSettingsTabView.swift
│ ├── AboutSettingsTabView.swift
│ ├── CommonDirsSettingTabView.swift
│ ├── SettingsView.swift
│ ├── GeneralSettingsTabView.swift
│ ├── AppsSettingsTabView.swift
│ └── NewFileSettingsTabView.swift
├── MenuBarView.swift
├── Model
│ └── RCBase.swift
├── AppState.swift
├── Localizable.xcstrings
└── RClickApp.swift
├── RClick.xcodeproj
├── project.xcworkspace
│ ├── contents.xcworkspacedata
│ ├── xcshareddata
│ │ ├── WorkspaceSettings.xcsettings
│ │ ├── IDEWorkspaceChecks.plist
│ │ └── swiftpm
│ │ │ └── Package.resolved
│ └── xcuserdata
│ │ └── lixu.xcuserdatad
│ │ └── WorkspaceSettings.xcsettings
├── xcuserdata
│ └── lixu.xcuserdatad
│ │ ├── xcdebugger
│ │ └── Breakpoints_v2.xcbkptlist
│ │ └── xcschemes
│ │ └── xcschememanagement.plist
└── xcshareddata
│ └── xcschemes
│ ├── FinderSyncExt.xcscheme
│ ├── FileProviderExt.xcscheme
│ └── RClick.xcscheme
├── FinderSyncExt
├── FinderSyncExt.entitlements
├── Info.plist
├── MenuItemClickable.swift
└── FinderSyncExt.swift
├── .gitignore
└── README.md
/images/screenshot.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/wflixu/RClick/HEAD/images/screenshot.png
--------------------------------------------------------------------------------
/images/screenshot2.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/wflixu/RClick/HEAD/images/screenshot2.png
--------------------------------------------------------------------------------
/images/store-preview.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/wflixu/RClick/HEAD/images/store-preview.png
--------------------------------------------------------------------------------
/RClick/Resources/template.xlsx:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/wflixu/RClick/HEAD/RClick/Resources/template.xlsx
--------------------------------------------------------------------------------
/RClick/Assets.xcassets/template.xlsx:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/wflixu/RClick/HEAD/RClick/Assets.xcassets/template.xlsx
--------------------------------------------------------------------------------
/RClick/Preview Content/Preview Assets.xcassets/Contents.json:
--------------------------------------------------------------------------------
1 | {
2 | "info" : {
3 | "author" : "xcode",
4 | "version" : 1
5 | }
6 | }
7 |
--------------------------------------------------------------------------------
/RClick/Assets.xcassets/Logo.imageset/AppIcon@1x.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/wflixu/RClick/HEAD/RClick/Assets.xcassets/Logo.imageset/AppIcon@1x.png
--------------------------------------------------------------------------------
/RClick/Assets.xcassets/toolbar.imageset/toolbar.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/wflixu/RClick/HEAD/RClick/Assets.xcassets/toolbar.imageset/toolbar.png
--------------------------------------------------------------------------------
/RClick/Assets.xcassets/icon-file-md.imageset/md@1x.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/wflixu/RClick/HEAD/RClick/Assets.xcassets/icon-file-md.imageset/md@1x.png
--------------------------------------------------------------------------------
/RClick/Assets.xcassets/icon-file-md.imageset/md@2x.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/wflixu/RClick/HEAD/RClick/Assets.xcassets/icon-file-md.imageset/md@2x.png
--------------------------------------------------------------------------------
/RClick/Assets.xcassets/icon-file-md.imageset/md@3x.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/wflixu/RClick/HEAD/RClick/Assets.xcassets/icon-file-md.imageset/md@3x.png
--------------------------------------------------------------------------------
/RClick/Assets.xcassets/template.dataset/template.xlsx:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/wflixu/RClick/HEAD/RClick/Assets.xcassets/template.dataset/template.xlsx
--------------------------------------------------------------------------------
/RClick/Assets.xcassets/toolbar.imageset/toolbar@2x.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/wflixu/RClick/HEAD/RClick/Assets.xcassets/toolbar.imageset/toolbar@2x.png
--------------------------------------------------------------------------------
/RClick/Assets.xcassets/toolbar.imageset/toolbar@3x.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/wflixu/RClick/HEAD/RClick/Assets.xcassets/toolbar.imageset/toolbar@3x.png
--------------------------------------------------------------------------------
/RClick/Assets.xcassets/AppIcon.appiconset/AppIcon@1x.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/wflixu/RClick/HEAD/RClick/Assets.xcassets/AppIcon.appiconset/AppIcon@1x.png
--------------------------------------------------------------------------------
/RClick/Assets.xcassets/AppIcon.appiconset/AppIcon@2x.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/wflixu/RClick/HEAD/RClick/Assets.xcassets/AppIcon.appiconset/AppIcon@2x.png
--------------------------------------------------------------------------------
/RClick/Assets.xcassets/AppIcon.appiconset/AppIcon@4x.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/wflixu/RClick/HEAD/RClick/Assets.xcassets/AppIcon.appiconset/AppIcon@4x.png
--------------------------------------------------------------------------------
/RClick/Assets.xcassets/MenuBar.imageset/menu-bar@1x.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/wflixu/RClick/HEAD/RClick/Assets.xcassets/MenuBar.imageset/menu-bar@1x.png
--------------------------------------------------------------------------------
/RClick/Assets.xcassets/MenuBar.imageset/menu-bar@2x.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/wflixu/RClick/HEAD/RClick/Assets.xcassets/MenuBar.imageset/menu-bar@2x.png
--------------------------------------------------------------------------------
/RClick/Assets.xcassets/icon-file-txt.imageset/txt@1x.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/wflixu/RClick/HEAD/RClick/Assets.xcassets/icon-file-txt.imageset/txt@1x.png
--------------------------------------------------------------------------------
/RClick/Assets.xcassets/icon-file-txt.imageset/txt@2x.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/wflixu/RClick/HEAD/RClick/Assets.xcassets/icon-file-txt.imageset/txt@2x.png
--------------------------------------------------------------------------------
/RClick/Assets.xcassets/icon-file-txt.imageset/txt@3x.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/wflixu/RClick/HEAD/RClick/Assets.xcassets/icon-file-txt.imageset/txt@3x.png
--------------------------------------------------------------------------------
/RClick/Assets.xcassets/AppIcon.appiconset/AppIcon-lite.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/wflixu/RClick/HEAD/RClick/Assets.xcassets/AppIcon.appiconset/AppIcon-lite.png
--------------------------------------------------------------------------------
/RClick/Assets.xcassets/AppIcon.appiconset/AppIcon@0.25x.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/wflixu/RClick/HEAD/RClick/Assets.xcassets/AppIcon.appiconset/AppIcon@0.25x.png
--------------------------------------------------------------------------------
/RClick/Assets.xcassets/AppIcon.appiconset/AppIcon@0.5x.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/wflixu/RClick/HEAD/RClick/Assets.xcassets/AppIcon.appiconset/AppIcon@0.5x.png
--------------------------------------------------------------------------------
/RClick/Assets.xcassets/AppIcon.appiconset/AppIcon@1x 1.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/wflixu/RClick/HEAD/RClick/Assets.xcassets/AppIcon.appiconset/AppIcon@1x 1.png
--------------------------------------------------------------------------------
/RClick/Assets.xcassets/AppIcon.appiconset/AppIcon@2x 1.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/wflixu/RClick/HEAD/RClick/Assets.xcassets/AppIcon.appiconset/AppIcon@2x 1.png
--------------------------------------------------------------------------------
/RClick/Assets.xcassets/icon-file-docx.imageset/docx@1x.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/wflixu/RClick/HEAD/RClick/Assets.xcassets/icon-file-docx.imageset/docx@1x.png
--------------------------------------------------------------------------------
/RClick/Assets.xcassets/icon-file-docx.imageset/docx@2x.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/wflixu/RClick/HEAD/RClick/Assets.xcassets/icon-file-docx.imageset/docx@2x.png
--------------------------------------------------------------------------------
/RClick/Assets.xcassets/icon-file-docx.imageset/docx@3x.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/wflixu/RClick/HEAD/RClick/Assets.xcassets/icon-file-docx.imageset/docx@3x.png
--------------------------------------------------------------------------------
/RClick/Assets.xcassets/icon-file-json.imageset/json@1x.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/wflixu/RClick/HEAD/RClick/Assets.xcassets/icon-file-json.imageset/json@1x.png
--------------------------------------------------------------------------------
/RClick/Assets.xcassets/icon-file-json.imageset/json@2x.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/wflixu/RClick/HEAD/RClick/Assets.xcassets/icon-file-json.imageset/json@2x.png
--------------------------------------------------------------------------------
/RClick/Assets.xcassets/icon-file-json.imageset/json@4x.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/wflixu/RClick/HEAD/RClick/Assets.xcassets/icon-file-json.imageset/json@4x.png
--------------------------------------------------------------------------------
/RClick/Assets.xcassets/icon-file-pptx.imageset/pptx@1x.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/wflixu/RClick/HEAD/RClick/Assets.xcassets/icon-file-pptx.imageset/pptx@1x.png
--------------------------------------------------------------------------------
/RClick/Assets.xcassets/icon-file-pptx.imageset/pptx@2x.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/wflixu/RClick/HEAD/RClick/Assets.xcassets/icon-file-pptx.imageset/pptx@2x.png
--------------------------------------------------------------------------------
/RClick/Assets.xcassets/icon-file-pptx.imageset/pptx@3x.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/wflixu/RClick/HEAD/RClick/Assets.xcassets/icon-file-pptx.imageset/pptx@3x.png
--------------------------------------------------------------------------------
/RClick/Assets.xcassets/icon-file-xlsx.imageset/xlsx@1x.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/wflixu/RClick/HEAD/RClick/Assets.xcassets/icon-file-xlsx.imageset/xlsx@1x.png
--------------------------------------------------------------------------------
/RClick/Assets.xcassets/icon-file-xlsx.imageset/xlsx@2x.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/wflixu/RClick/HEAD/RClick/Assets.xcassets/icon-file-xlsx.imageset/xlsx@2x.png
--------------------------------------------------------------------------------
/RClick/Assets.xcassets/icon-file-xlsx.imageset/xlsx@3x.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/wflixu/RClick/HEAD/RClick/Assets.xcassets/icon-file-xlsx.imageset/xlsx@3x.png
--------------------------------------------------------------------------------
/RClick/Assets.xcassets/AppIcon.appiconset/AppIcon-lite@1x.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/wflixu/RClick/HEAD/RClick/Assets.xcassets/AppIcon.appiconset/AppIcon-lite@1x.png
--------------------------------------------------------------------------------
/RClick/Assets.xcassets/MenuBar.imageset/menu-bar-dark@1x.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/wflixu/RClick/HEAD/RClick/Assets.xcassets/MenuBar.imageset/menu-bar-dark@1x.png
--------------------------------------------------------------------------------
/RClick/Assets.xcassets/MenuBar.imageset/menu-bar-dark@2x.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/wflixu/RClick/HEAD/RClick/Assets.xcassets/MenuBar.imageset/menu-bar-dark@2x.png
--------------------------------------------------------------------------------
/RClick/Assets.xcassets/AppIcon.appiconset/AppIcon-lite@1x 1.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/wflixu/RClick/HEAD/RClick/Assets.xcassets/AppIcon.appiconset/AppIcon-lite@1x 1.png
--------------------------------------------------------------------------------
/RClick/Assets.xcassets/Contents.json:
--------------------------------------------------------------------------------
1 | {
2 | "info" : {
3 | "author" : "xcode",
4 | "version" : 1
5 | },
6 | "properties" : {
7 | "compression-type" : "lossy"
8 | }
9 | }
10 |
--------------------------------------------------------------------------------
/RClick.xcodeproj/project.xcworkspace/contents.xcworkspacedata:
--------------------------------------------------------------------------------
1 |
2 |
4 |
6 |
7 |
8 |
--------------------------------------------------------------------------------
/RClick/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 |
--------------------------------------------------------------------------------
/RClick.xcodeproj/xcuserdata/lixu.xcuserdatad/xcdebugger/Breakpoints_v2.xcbkptlist:
--------------------------------------------------------------------------------
1 |
2 |
6 |
7 |
--------------------------------------------------------------------------------
/RClick.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
--------------------------------------------------------------------------------
/RClick.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | IDEDidComputeMac32BitWarning
6 |
7 |
8 |
9 |
--------------------------------------------------------------------------------
/RClick/Assets.xcassets/template.dataset/Contents.json:
--------------------------------------------------------------------------------
1 | {
2 | "data" : [
3 | {
4 | "filename" : "template.xlsx",
5 | "idiom" : "universal",
6 | "universal-type-identifier" : "org.openxmlformats.spreadsheetml.sheet"
7 | }
8 | ],
9 | "info" : {
10 | "author" : "xcode",
11 | "version" : 1
12 | }
13 | }
14 |
--------------------------------------------------------------------------------
/RClick/Assets.xcassets/github.imageset/Contents.json:
--------------------------------------------------------------------------------
1 | {
2 | "images" : [
3 | {
4 | "filename" : "github.svg",
5 | "idiom" : "universal",
6 | "scale" : "1x"
7 | },
8 | {
9 | "idiom" : "universal",
10 | "scale" : "2x"
11 | },
12 | {
13 | "idiom" : "universal",
14 | "scale" : "3x"
15 | }
16 | ],
17 | "info" : {
18 | "author" : "xcode",
19 | "version" : 1
20 | }
21 | }
22 |
--------------------------------------------------------------------------------
/RClick/Assets.xcassets/Logo.imageset/Contents.json:
--------------------------------------------------------------------------------
1 | {
2 | "images" : [
3 | {
4 | "filename" : "AppIcon@1x.png",
5 | "idiom" : "universal",
6 | "scale" : "1x"
7 | },
8 | {
9 | "idiom" : "universal",
10 | "scale" : "2x"
11 | },
12 | {
13 | "idiom" : "universal",
14 | "scale" : "3x"
15 | }
16 | ],
17 | "info" : {
18 | "author" : "xcode",
19 | "version" : 1
20 | }
21 | }
22 |
--------------------------------------------------------------------------------
/RClick/Info.plist:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | NSAppTransportSecurity
6 |
7 | NSAllowsArbitraryLoads
8 |
9 |
10 | NSApplicationSupportsIndirectInputEvents
11 |
12 | NSServices
13 |
14 |
15 |
16 |
17 |
18 |
--------------------------------------------------------------------------------
/RClick/Shared/AppLogger.swift:
--------------------------------------------------------------------------------
1 | //
2 | // AppLogger.swift
3 | // RClick
4 | //
5 | // Created by 李旭 on 2024/4/25.
6 | //
7 |
8 | import OSLog
9 |
10 |
11 | @propertyWrapper
12 | struct AppLog {
13 |
14 | private let logger: Logger
15 |
16 | init(subsystem: String = Bundle.main.bundleIdentifier ?? "", category: String = "main") {
17 | self.logger = Logger(subsystem: subsystem, category: category)
18 | }
19 |
20 | var wrappedValue: Logger {
21 | return logger
22 | }
23 | }
24 |
25 |
--------------------------------------------------------------------------------
/RClick/Assets.xcassets/icon-file-md.imageset/Contents.json:
--------------------------------------------------------------------------------
1 | {
2 | "images" : [
3 | {
4 | "filename" : "md@1x.png",
5 | "idiom" : "universal",
6 | "scale" : "1x"
7 | },
8 | {
9 | "filename" : "md@2x.png",
10 | "idiom" : "universal",
11 | "scale" : "2x"
12 | },
13 | {
14 | "filename" : "md@3x.png",
15 | "idiom" : "universal",
16 | "scale" : "3x"
17 | }
18 | ],
19 | "info" : {
20 | "author" : "xcode",
21 | "version" : 1
22 | }
23 | }
24 |
--------------------------------------------------------------------------------
/RClick.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved:
--------------------------------------------------------------------------------
1 | {
2 | "originHash" : "b26c070fd1f0b029780331f3cefa3909a6d58ea6b3ae242bffe49d6011fe06fe",
3 | "pins" : [
4 | {
5 | "identity" : "zipfoundation",
6 | "kind" : "remoteSourceControl",
7 | "location" : "https://github.com/weichsel/ZIPFoundation.git",
8 | "state" : {
9 | "revision" : "02b6abe5f6eef7e3cbd5f247c5cc24e246efcfe0",
10 | "version" : "0.9.19"
11 | }
12 | }
13 | ],
14 | "version" : 3
15 | }
16 |
--------------------------------------------------------------------------------
/RClick/Assets.xcassets/icon-file-txt.imageset/Contents.json:
--------------------------------------------------------------------------------
1 | {
2 | "images" : [
3 | {
4 | "filename" : "txt@1x.png",
5 | "idiom" : "universal",
6 | "scale" : "1x"
7 | },
8 | {
9 | "filename" : "txt@2x.png",
10 | "idiom" : "universal",
11 | "scale" : "2x"
12 | },
13 | {
14 | "filename" : "txt@3x.png",
15 | "idiom" : "universal",
16 | "scale" : "3x"
17 | }
18 | ],
19 | "info" : {
20 | "author" : "xcode",
21 | "version" : 1
22 | }
23 | }
24 |
--------------------------------------------------------------------------------
/RClick/Assets.xcassets/toolbar.imageset/Contents.json:
--------------------------------------------------------------------------------
1 | {
2 | "images" : [
3 | {
4 | "filename" : "toolbar.png",
5 | "idiom" : "universal",
6 | "scale" : "1x"
7 | },
8 | {
9 | "filename" : "toolbar@2x.png",
10 | "idiom" : "universal",
11 | "scale" : "2x"
12 | },
13 | {
14 | "filename" : "toolbar@3x.png",
15 | "idiom" : "universal",
16 | "scale" : "3x"
17 | }
18 | ],
19 | "info" : {
20 | "author" : "xcode",
21 | "version" : 1
22 | }
23 | }
24 |
--------------------------------------------------------------------------------
/RClick/Assets.xcassets/icon-file-docx.imageset/Contents.json:
--------------------------------------------------------------------------------
1 | {
2 | "images" : [
3 | {
4 | "filename" : "docx@1x.png",
5 | "idiom" : "universal",
6 | "scale" : "1x"
7 | },
8 | {
9 | "filename" : "docx@2x.png",
10 | "idiom" : "universal",
11 | "scale" : "2x"
12 | },
13 | {
14 | "filename" : "docx@3x.png",
15 | "idiom" : "universal",
16 | "scale" : "3x"
17 | }
18 | ],
19 | "info" : {
20 | "author" : "xcode",
21 | "version" : 1
22 | }
23 | }
24 |
--------------------------------------------------------------------------------
/RClick/Assets.xcassets/icon-file-json.imageset/Contents.json:
--------------------------------------------------------------------------------
1 | {
2 | "images" : [
3 | {
4 | "filename" : "json@1x.png",
5 | "idiom" : "universal",
6 | "scale" : "1x"
7 | },
8 | {
9 | "filename" : "json@2x.png",
10 | "idiom" : "universal",
11 | "scale" : "2x"
12 | },
13 | {
14 | "filename" : "json@4x.png",
15 | "idiom" : "universal",
16 | "scale" : "3x"
17 | }
18 | ],
19 | "info" : {
20 | "author" : "xcode",
21 | "version" : 1
22 | }
23 | }
24 |
--------------------------------------------------------------------------------
/RClick/Assets.xcassets/icon-file-pptx.imageset/Contents.json:
--------------------------------------------------------------------------------
1 | {
2 | "images" : [
3 | {
4 | "filename" : "pptx@1x.png",
5 | "idiom" : "universal",
6 | "scale" : "1x"
7 | },
8 | {
9 | "filename" : "pptx@2x.png",
10 | "idiom" : "universal",
11 | "scale" : "2x"
12 | },
13 | {
14 | "filename" : "pptx@3x.png",
15 | "idiom" : "universal",
16 | "scale" : "3x"
17 | }
18 | ],
19 | "info" : {
20 | "author" : "xcode",
21 | "version" : 1
22 | }
23 | }
24 |
--------------------------------------------------------------------------------
/RClick/Assets.xcassets/icon-file-xlsx.imageset/Contents.json:
--------------------------------------------------------------------------------
1 | {
2 | "images" : [
3 | {
4 | "filename" : "xlsx@1x.png",
5 | "idiom" : "universal",
6 | "scale" : "1x"
7 | },
8 | {
9 | "filename" : "xlsx@2x.png",
10 | "idiom" : "universal",
11 | "scale" : "2x"
12 | },
13 | {
14 | "filename" : "xlsx@3x.png",
15 | "idiom" : "universal",
16 | "scale" : "3x"
17 | }
18 | ],
19 | "info" : {
20 | "author" : "xcode",
21 | "version" : 1
22 | }
23 | }
24 |
--------------------------------------------------------------------------------
/RClick/RClick.entitlements:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | com.apple.security.app-sandbox
6 |
7 | com.apple.security.application-groups
8 |
9 | group.cn.wflixu.RClick
10 |
11 | com.apple.security.files.bookmarks.app-scope
12 |
13 | com.apple.security.files.user-selected.read-write
14 |
15 |
16 |
17 |
--------------------------------------------------------------------------------
/FinderSyncExt/FinderSyncExt.entitlements:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | com.apple.security.app-sandbox
6 |
7 | com.apple.security.application-groups
8 |
9 | group.cn.wflixu.RClick
10 |
11 | com.apple.security.files.bookmarks.app-scope
12 |
13 | com.apple.security.files.user-selected.read-write
14 |
15 |
16 |
17 |
--------------------------------------------------------------------------------
/RClick/Shared/Constants.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Constants.swift
3 | // RClick
4 | //
5 | // Created by 李旭 on 2024/9/25.
6 | //
7 |
8 | import Foundation
9 |
10 |
11 | public enum Constants {
12 | static let HomedirPath = Utils.getRealHomeDir()
13 | /// The identifier for the settings window.
14 | static let settingsWindowID = "rclick-settings"
15 | static let protectedDirs = [
16 | HomedirPath + "/Desktop/",
17 | HomedirPath + "/Desktop/danger/",
18 | HomedirPath + "/Applications/",
19 | "/Applications/",
20 | "/System/",
21 | "/Library/",
22 | "/Users/",
23 | "/usr/",
24 | "/bin/",
25 | "/sbin/",
26 | "/var/"
27 | ]
28 |
29 | }
30 |
--------------------------------------------------------------------------------
/FinderSyncExt/Info.plist:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | NSExtension
6 |
7 | CFBundleShortVersionString
8 | $(MARKETING_VERSION)
9 | CFBundleVersion
10 | $(CURRENT_PROJECT_VERSION)
11 | ITSAppUsesNonExemptEncryption
12 |
13 | NSExtensionAttributes
14 |
15 | NSExtensionPointIdentifier
16 | com.apple.FinderSync
17 | NSExtensionPrincipalClass
18 | $(PRODUCT_MODULE_NAME).FinderSyncExt
19 |
20 |
21 |
22 |
--------------------------------------------------------------------------------
/RClick/Shared/Utils.swift:
--------------------------------------------------------------------------------
1 | import Foundation
2 |
3 | public class Utils {
4 | public static func isProtectedFolder(_ path: String) -> Bool {
5 | print("isProtectedFolder: \(path)")
6 |
7 | return Constants.protectedDirs.contains { protectedPath in
8 | print("Comparing with protected path: \(protectedPath)")
9 | return path == protectedPath
10 | }
11 | }
12 | // MARK:
13 | public static func getRealHomeDir() -> String {
14 | let fullPath = NSHomeDirectory()
15 | let components = fullPath.components(separatedBy: "/")
16 | let limitedComponents = Array(components.prefix(3)) // 取前3个是因为第一个是空字符串(路径以/开头)
17 | return limitedComponents.joined(separator: "/")
18 | }
19 | }
20 |
--------------------------------------------------------------------------------
/RClick/Assets.xcassets/github.imageset/github.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/RClick.xcodeproj/project.xcworkspace/xcuserdata/lixu.xcuserdatad/WorkspaceSettings.xcsettings:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | BuildLocationStyle
6 | CustomLocation
7 | CustomBuildIntermediatesPath
8 | Build/Intermediates.noindex
9 | CustomBuildLocationType
10 | RelativeToDerivedData
11 | CustomBuildProductsPath
12 | Build/Products
13 | DerivedDataCustomLocation
14 | DerivedData
15 | DerivedDataLocationStyle
16 | WorkspaceRelativePath
17 | ShowSharedSchemesAutomaticallyEnabled
18 |
19 |
20 |
21 |
--------------------------------------------------------------------------------
/RClick/Assets.xcassets/MenuBar.imageset/Contents.json:
--------------------------------------------------------------------------------
1 | {
2 | "images" : [
3 | {
4 | "filename" : "menu-bar@1x.png",
5 | "idiom" : "mac",
6 | "scale" : "1x"
7 | },
8 | {
9 | "appearances" : [
10 | {
11 | "appearance" : "luminosity",
12 | "value" : "dark"
13 | }
14 | ],
15 | "filename" : "menu-bar-dark@1x.png",
16 | "idiom" : "mac",
17 | "scale" : "1x"
18 | },
19 | {
20 | "filename" : "menu-bar@2x.png",
21 | "idiom" : "mac",
22 | "scale" : "2x"
23 | },
24 | {
25 | "appearances" : [
26 | {
27 | "appearance" : "luminosity",
28 | "value" : "dark"
29 | }
30 | ],
31 | "filename" : "menu-bar-dark@2x.png",
32 | "idiom" : "mac",
33 | "scale" : "2x"
34 | }
35 | ],
36 | "info" : {
37 | "author" : "xcode",
38 | "version" : 1
39 | }
40 | }
41 |
--------------------------------------------------------------------------------
/RClick/Settings/SettingsWindow.swift:
--------------------------------------------------------------------------------
1 | //
2 | // SettingsWindow.swift
3 | // RClick
4 | //
5 | // Created by 李旭 on 2024/9/25.
6 | //
7 |
8 | import SwiftUI
9 |
10 | struct SettingsWindow: Scene {
11 | @ObservedObject var appState: AppState
12 |
13 | @EnvironmentObject var updateManager: UpdateManager
14 |
15 | let onAppear: () -> Void
16 |
17 | var body: some Scene {
18 | Window("Settings", id: Constants.settingsWindowID) {
19 | SettingsView()
20 | .environmentObject(appState)
21 | .onAppear {
22 | onAppear()
23 | }
24 | .frame(minWidth: 800, minHeight: 500)
25 | .sheet(isPresented: $updateManager.showUpdateSheet) {
26 | UpdateView(updateManager: updateManager)
27 | }
28 | }
29 | .windowResizability(.contentSize)
30 | .windowStyle(.hiddenTitleBar)
31 | .defaultSize(width: 800, height: 500)
32 | }
33 |
34 | }
35 |
--------------------------------------------------------------------------------
/RClick.xcodeproj/xcuserdata/lixu.xcuserdatad/xcschemes/xcschememanagement.plist:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | SchemeUserState
6 |
7 | FileProviderExt.xcscheme_^#shared#^_
8 |
9 | orderHint
10 | 2
11 |
12 | FinderSyncExt.xcscheme_^#shared#^_
13 |
14 | isShown
15 |
16 | orderHint
17 | 1
18 |
19 | RClick.xcscheme_^#shared#^_
20 |
21 | orderHint
22 | 0
23 |
24 |
25 | SuppressBuildableAutocreation
26 |
27 | 5C119D022BBE9B9900B2D7C4
28 |
29 | primary
30 |
31 |
32 | 5C119D2F2BBEA9AC00B2D7C4
33 |
34 | primary
35 |
36 |
37 |
38 |
39 |
40 |
--------------------------------------------------------------------------------
/RClick/Shared/Extension+.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Extension+.swift
3 | // RClick
4 | //
5 | // Created by 李旭 on 2024/8/9.
6 | //
7 |
8 | import Foundation
9 | import SwiftUI
10 |
11 | extension View {
12 | #if os(iOS)
13 | func onBackground(_ f: @escaping () -> Void) -> some View {
14 | self.onReceive(
15 | NotificationCenter.default.publisher(for: UIApplication.willResignActiveNotification),
16 | perform: { _ in f() }
17 | )
18 | }
19 |
20 | func onForeground(_ f: @escaping () -> Void) -> some View {
21 | self.onReceive(
22 | NotificationCenter.default.publisher(for: UIApplication.didBecomeActiveNotification),
23 | perform: { _ in f() }
24 | )
25 | }
26 | #else
27 | func onBackground(_ f: @escaping () -> Void) -> some View {
28 | self.onReceive(
29 | NotificationCenter.default.publisher(for: NSApplication.willResignActiveNotification),
30 | perform: { _ in f() }
31 | )
32 | }
33 |
34 | func onForeground(_ f: @escaping () -> Void) -> some View {
35 | self.onReceive(
36 | NotificationCenter.default.publisher(for: NSApplication.didBecomeActiveNotification),
37 | perform: { _ in f() }
38 | )
39 | }
40 | #endif
41 | }
42 |
--------------------------------------------------------------------------------
/RClick/Assets.xcassets/AppIcon.appiconset/Contents.json:
--------------------------------------------------------------------------------
1 | {
2 | "images" : [
3 | {
4 | "filename" : "AppIcon-lite.png",
5 | "idiom" : "mac",
6 | "scale" : "1x",
7 | "size" : "16x16"
8 | },
9 | {
10 | "filename" : "AppIcon-lite@1x 1.png",
11 | "idiom" : "mac",
12 | "scale" : "2x",
13 | "size" : "16x16"
14 | },
15 | {
16 | "filename" : "AppIcon-lite@1x.png",
17 | "idiom" : "mac",
18 | "scale" : "1x",
19 | "size" : "32x32"
20 | },
21 | {
22 | "filename" : "AppIcon@0.25x.png",
23 | "idiom" : "mac",
24 | "scale" : "2x",
25 | "size" : "32x32"
26 | },
27 | {
28 | "filename" : "AppIcon@0.5x.png",
29 | "idiom" : "mac",
30 | "scale" : "1x",
31 | "size" : "128x128"
32 | },
33 | {
34 | "filename" : "AppIcon@1x 1.png",
35 | "idiom" : "mac",
36 | "scale" : "2x",
37 | "size" : "128x128"
38 | },
39 | {
40 | "filename" : "AppIcon@1x.png",
41 | "idiom" : "mac",
42 | "scale" : "1x",
43 | "size" : "256x256"
44 | },
45 | {
46 | "filename" : "AppIcon@2x 1.png",
47 | "idiom" : "mac",
48 | "scale" : "2x",
49 | "size" : "256x256"
50 | },
51 | {
52 | "filename" : "AppIcon@2x.png",
53 | "idiom" : "mac",
54 | "scale" : "1x",
55 | "size" : "512x512"
56 | },
57 | {
58 | "filename" : "AppIcon@4x.png",
59 | "idiom" : "mac",
60 | "scale" : "2x",
61 | "size" : "512x512"
62 | }
63 | ],
64 | "info" : {
65 | "author" : "xcode",
66 | "version" : 1
67 | }
68 | }
69 |
--------------------------------------------------------------------------------
/RClick/MenuBarView.swift:
--------------------------------------------------------------------------------
1 | //
2 | // MenuBarView.swift
3 | // RClick
4 | //
5 | // Created by 李旭 on 2024/4/4.
6 | //
7 |
8 | import AppKit
9 | import SwiftUI
10 |
11 | struct MenuBarView: View {
12 | @Environment(\.openWindow) var openWindow: OpenWindowAction
13 |
14 | let messager = Messager.shared
15 |
16 | var body: some View {
17 | VStack {
18 | Button(action: actionSettings) {
19 | Image(systemName: "gearshape")
20 | Text("Settings")
21 | }
22 | .keyboardShortcut(",", modifiers: [.command])
23 |
24 | Button(action: actionQuit) {
25 | Image(systemName: "xmark.square")
26 | Text("Quit")
27 | }
28 | .keyboardShortcut("q", modifiers: [.command])
29 | }
30 | }
31 |
32 | private func actionSettings() {
33 | openWindow(id: Constants.settingsWindowID)
34 |
35 | let windows = NSApplication.shared.windows
36 |
37 | // 查找已存在的目标窗口
38 | if let existingWindow = windows.first(where: { $0.identifier?.rawValue == Constants.settingsWindowID }) {
39 | existingWindow.makeKeyAndOrderFront(nil) // 将窗口置于最前
40 | NSApplication.shared.activate(ignoringOtherApps: true) // 激活应用
41 | }
42 | }
43 |
44 | private func actionQuit() {
45 | messager.sendMessage(name: "quit", data: MessagePayload(action: "quit"))
46 |
47 | Task {
48 | try await Task.sleep(nanoseconds: UInt64(1.0 * 1e9))
49 |
50 | NSApplication.shared.terminate(self)
51 | }
52 | }
53 | }
54 |
55 | #Preview {
56 | MenuBarView()
57 | }
58 |
--------------------------------------------------------------------------------
/.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 | ## Obj-C/Swift specific
9 | *.hmap
10 |
11 | ## App packaging
12 | *.ipa
13 | *.dSYM.zip
14 | *.dSYM
15 |
16 | ## Playgrounds
17 | timeline.xctimeline
18 | playground.xcworkspace
19 |
20 | # Swift Package Manager
21 | #
22 | # Add this line if you want to avoid checking in source code from Swift Package Manager dependencies.
23 | # Packages/
24 | # Package.pins
25 | # Package.resolved
26 | # *.xcodeproj
27 | #
28 | # Xcode automatically generates this directory with a .xcworkspacedata file and xcuserdata
29 | # hence it is not needed unless you have added a package configuration file to your project
30 | # .swiftpm
31 |
32 | .build/
33 |
34 | # CocoaPods
35 | #
36 | # We recommend against adding the Pods directory to your .gitignore. However
37 | # you should judge for yourself, the pros and cons are mentioned at:
38 | # https://guides.cocoapods.org/using/using-cocoapods.html#should-i-check-the-pods-directory-into-source-control
39 | #
40 | # Pods/
41 | #
42 | # Add this line if you want to avoid checking in source code from the Xcode workspace
43 | # *.xcworkspace
44 |
45 | # Carthage
46 | #
47 | # Add this line if you want to avoid checking in source code from Carthage dependencies.
48 | # Carthage/Checkouts
49 |
50 |
51 | # fastlane
52 | #
53 | # It is recommended to not store the screenshots in the git repo.
54 | # Instead, use fastlane to re-generate the screenshots whenever they are needed.
55 | # For more information about the recommended setup visit:
56 | # https://docs.fastlane.tools/best-practices/source-control/#source-control
57 |
58 | output/*
59 | Build/
60 | DerivedData/
--------------------------------------------------------------------------------
/RClick/Settings/ActionSettingsTabView.swift:
--------------------------------------------------------------------------------
1 | //
2 | // ActionSettingsView.swift
3 | // RClick
4 | //
5 | // Created by 李旭 on 2024/4/9.
6 | //
7 |
8 | import SwiftUI
9 |
10 | struct ActionSettingsTabView: View {
11 | @EnvironmentObject var appState: AppState
12 |
13 | let messager = Messager.shared
14 |
15 | var body: some View {
16 | VStack {
17 | HStack {
18 |
19 | Spacer()
20 | Button {
21 | appState.resetActionItems()
22 | } label: {
23 | Label("Reset", systemImage: "arrow.triangle.2.circlepath")
24 | .font(.body)
25 | }
26 | }
27 |
28 | List {
29 | ForEach($appState.actions) { $item in
30 | HStack {
31 | Image(systemName: item.icon)
32 | .resizable()
33 | .aspectRatio(contentMode: .fit)
34 | .frame(width: 20, height: 20)
35 | Text(LocalizedStringKey(item.name)).font(.title2)
36 | Spacer()
37 | Toggle("", isOn: $item.enabled)
38 | .onChange(of: item.enabled) {
39 | appState.toggleActionItem()
40 | messager.sendMessage(name: "running", data: MessagePayload(action: "running", target: []))
41 | }
42 | .toggleStyle(.switch)
43 | }
44 | .padding(.top, 12)
45 | .padding(.bottom, 4)
46 | }
47 | }
48 | }
49 | }
50 | }
51 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 |
2 | [](https://github.com/wflixu/RClick/releases)
3 |
4 | # RClick
5 |
6 | [](https://swift.org/) [](https://developer.apple.com/xcode/swiftui/) [](https://www.apple.com/macos/monterey/)
7 |
8 |
9 |
10 |
11 | Config you MacOS ContextMenu items, useing Latest Swift and SwiftUI.
12 |
13 |
14 |
15 | ## 🚀 Features
16 |
17 | - [x] **Open with External App:** Easily open files or directories using your preferred external application (e.g., SomeApp).
18 | - [x] **Copy File/Folder Path:** Quickly copy the full path of the selected file or directory to the clipboard for easy sharing or referencing.
19 | - [x] **Delete Files or Directories:** Seamlessly delete files or directories with a single click, ensuring a smooth user experience.
20 | - [x] **Hide files and dirs:** hide files or directories with a single click, ensuring a smooth user experience.
21 | - [x] **Create New Files:** Generate new files of various formats directly from the context menu, including: .txt (Plain Text).json (JSON).md (Markdown).docx (Microsoft Word).pptx (Microsoft PowerPoint).xlsx (Microsoft Excel)
22 | - [x] **Quick Access Folders:** Add frequently accessed directories like `Downloads`, `Desktop`, and `Documents` to the Finder context menu for instant navigation.
23 |
24 | ## 📸 Screenshots
25 |
26 | 
27 | 
28 |
29 |
30 |
31 | ## 📦 Installation
32 |
33 | The latest distribution file can be downloaded from [release page](https://github.com/wflixu/RClick/releases)
34 |
35 | ## Other similar projects:
36 |
37 | - https://github.com/RoadToDream/SzContext
38 | - https://github.com/Kyle-Ye/MenuHelper
39 | - https://github.com/lexrus/SwiftyMenu
40 | - https://github.com/Ji4n1ng/OpenInTerminal
41 |
42 | ## Support
43 |
44 | If you like this project, you can support me via app store.
45 |
46 | [
](https://apps.apple.com/cn/app/rclick/id6496849273?mt=12)
47 |
48 | 
49 |
50 | ## 🤝 Report Issue
51 |
52 | For developer and user, if you find any bug or have any suggestion, please report issue on [RClick repo](https://github.com/wflixu/RClick/issues)
53 |
54 |
--------------------------------------------------------------------------------
/RClick/Settings/AboutSettingsTabView.swift:
--------------------------------------------------------------------------------
1 | //
2 | // AdvancedSettingsView.swift
3 | // RClick
4 | //
5 | // Created by 李旭 on 2024/4/4.
6 | //
7 |
8 | import AppKit
9 | import ExtensionFoundation
10 | import ExtensionKit
11 | import FinderSync
12 | import SwiftUI
13 |
14 | struct AboutSettingsTabView: View {
15 | let messager = Messager.shared
16 | @EnvironmentObject var updateManager: UpdateManager
17 |
18 | var body: some View {
19 | VStack {
20 | HStack {
21 | Spacer()
22 | Image("Logo")
23 | .resizable()
24 | .frame(maxWidth: 128, maxHeight: 128)
25 | Spacer()
26 | }
27 | HStack {
28 | Spacer()
29 | Text("RClick").font(.title)
30 | Text("\(getAppVersion())(\(getBuildVersion()))")
31 | Spacer()
32 | }
33 | HStack {
34 | Spacer()
35 | Text("RClick is a right-click menu extension that allows you to add applications for opening folders and includes some common actions!").font(.title3)
36 | Spacer()
37 | }
38 | Spacer()
39 | // 添加一个按钮,点击后检查更新
40 | VStack {
41 | // 检查更新按钮
42 | Button(action: {
43 | Task {
44 | await updateManager.checkForUpdates(force: true)
45 | }
46 | }) {
47 | if updateManager.isChecking {
48 | ProgressView()
49 | .scaleEffect(0.8)
50 | } else {
51 | Text("检查更新")
52 | }
53 | }
54 | }
55 | Spacer()
56 | Divider()
57 | HStack(alignment: .center) {
58 | Button {
59 | NSWorkspace.shared.open(URL(string: "https://github.com/wflixu/RClick")!)
60 | } label: {
61 | Image("github")
62 | }
63 |
64 | Text(verbatim: "https://github.com/wflixu/RClick")
65 | Spacer()
66 | }
67 | }
68 | }
69 |
70 | func getAppVersion() -> String {
71 | if let version = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String {
72 | return version
73 | }
74 | return "Unknown"
75 | }
76 |
77 | func getBuildVersion() -> String {
78 | if let buildVersion = Bundle.main.infoDictionary?["CFBundleVersion"] as? String {
79 | return buildVersion
80 | }
81 | return "Unknown"
82 | }
83 | }
84 |
85 | #Preview {
86 | AboutSettingsTabView()
87 | }
88 |
--------------------------------------------------------------------------------
/RClick/Shared/Messager.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Messager.swift
3 | // RClick
4 | //
5 | // Created by 李旭 on 2024/4/9.
6 | //
7 |
8 | import AppKit
9 | import Foundation
10 | import ScriptingBridge
11 |
12 | enum ActionType: String {
13 | case open
14 | case create
15 | case copy
16 | case delete
17 | }
18 |
19 | struct MessagePayload: Codable {
20 | var action: String = ""
21 | var target: [String] = []
22 | var rid: String = ""
23 | // ctx-items ctx-container ctx-sidebar toolbar
24 | var trigger: String = "" // 改为可选类型,避免解码失败
25 |
26 | public var description: String {
27 | return "MessagePayload(action: \(action), target: \(target), rid:\(rid), trigger: \(trigger))"
28 | }
29 | }
30 |
31 | class Messager {
32 | static let shared = Messager()
33 |
34 | @AppLog(category: "messager")
35 | private var logger
36 |
37 | let center: DistributedNotificationCenter = .default()
38 | var bus: [String: (_ payload: MessagePayload) -> Void] = [:]
39 |
40 | func sendMessage(name: String, data: MessagePayload) {
41 | let message: String = createMessageData(messsagePayload: data)
42 | logger.warning("start sendMessage ... to \(name)")
43 | center.postNotificationName(NSNotification.Name(name), object: message, userInfo: nil, deliverImmediately: true)
44 | }
45 |
46 | func createMessageData(messsagePayload: MessagePayload) -> String {
47 | let encoder = JSONEncoder()
48 | let data = try! encoder.encode(messsagePayload)
49 | let messsagePayloadString = String(data: data, encoding: .utf8)!
50 |
51 | return messsagePayloadString
52 | }
53 |
54 | func reconstructEntry(messagePayload: String) -> MessagePayload {
55 | let jsonData = messagePayload.data(using: .utf8)!
56 | do {
57 | let messsagePayloadCacheEntry = try JSONDecoder().decode(MessagePayload.self, from: jsonData)
58 | return messsagePayloadCacheEntry
59 | } catch {
60 | logger.warning("Failed to decode MessagePayload: \(error), jsondata:\(jsonData)")
61 | return MessagePayload() // Return a default instance to handle errors gracefully
62 | }
63 | }
64 |
65 |
66 | func on(name: String, handler: @escaping (MessagePayload) -> Void) {
67 | center.addObserver(self, selector: #selector(recievedMessage(_:)), name: NSNotification.Name(name), object: nil)
68 | bus.updateValue(handler, forKey: name)
69 | }
70 |
71 | @objc func recievedMessage(_ notification: NSNotification) {
72 | let payload = reconstructEntry(messagePayload: notification.object as! String)
73 | if let handler = bus[notification.name.rawValue] {
74 | handler(payload)
75 | } else {
76 | logger.warning("there no handler\(notification.name.rawValue)")
77 | }
78 | }
79 | }
80 |
--------------------------------------------------------------------------------
/RClick/Settings/CommonDirsSettingTabView.swift:
--------------------------------------------------------------------------------
1 | //
2 | // CommonDirsSettingTabView.swift
3 | // RClick
4 | //
5 | // Created by 李旭 on 2024/4/10.
6 | //
7 |
8 | import AppKit
9 | import Cocoa
10 | import FinderSync
11 | import SwiftUI
12 |
13 | struct CommonDirsSettingTabView: View {
14 | @AppLog(category: "settings-general")
15 | private var logger
16 |
17 | @EnvironmentObject var store: AppState
18 |
19 | @State private var showCommonDirImporter = false
20 |
21 | var body: some View {
22 | VStack(alignment: .leading, spacing: 8) {
23 | Section {
24 | List {
25 | ForEach(store.cdirs) { item in
26 | HStack {
27 | Image(systemName: "folder")
28 | Text(verbatim: item.url.path)
29 | Spacer()
30 | Button {
31 | removeCommonDir(item)
32 | } label: {
33 | Image(systemName: "trash")
34 | }
35 | }
36 | }
37 | }
38 | } header: {
39 | HStack {
40 | Text("Common Folders").font(.title3).fontWeight(.semibold)
41 | Spacer()
42 | Button {
43 | showCommonDirImporter = true
44 | } label: { Label("Add", systemImage: "folder.badge.plus") }
45 | }
46 | } footer: {
47 | Text("Quick access to frequently used folders")
48 | .foregroundColor(.secondary)
49 | .font(.caption)
50 | }
51 | .fileImporter(
52 | isPresented: $showCommonDirImporter,
53 | allowedContentTypes: [.directory],
54 | allowsMultipleSelection: false
55 | ) { result in
56 | switch result {
57 | case .success(let urls):
58 | if let url = urls.first {
59 | let commonDir = CommonDir(id: UUID().uuidString, name: url.lastPathComponent, url: url, icon: "folder")
60 | if !store.cdirs.contains(where: { $0.url == commonDir.url }) {
61 | store.cdirs.append(commonDir)
62 | try? store.saveCommonDir()
63 | }
64 | }
65 | case .failure(let error):
66 | logger.error("Failed to select common folder: \(error.localizedDescription)")
67 | }
68 | }
69 | }
70 | }
71 |
72 | @MainActor private func removeCommonDir(_ item: CommonDir) {
73 | if let index = store.cdirs.firstIndex(of: item) {
74 | store.cdirs.remove(at: index)
75 | try? store.saveCommonDir()
76 | }
77 | }
78 | }
79 |
--------------------------------------------------------------------------------
/RClick.xcodeproj/xcshareddata/xcschemes/FinderSyncExt.xcscheme:
--------------------------------------------------------------------------------
1 |
2 |
6 |
10 |
11 |
17 |
23 |
24 |
25 |
31 |
37 |
38 |
39 |
40 |
41 |
47 |
48 |
61 |
63 |
69 |
70 |
71 |
72 |
80 |
82 |
88 |
89 |
90 |
91 |
93 |
94 |
97 |
98 |
99 |
--------------------------------------------------------------------------------
/RClick/Shared/UpdaterView.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Up.swift
3 | // RClick
4 | //
5 | // Created by 李旭 on 2025/9/21.
6 | //
7 | import SwiftUI
8 |
9 | struct UpdateView: View {
10 | @Environment(\.dismiss) private var dismiss
11 | @ObservedObject var updateManager: UpdateManager
12 |
13 | var body: some View {
14 | VStack(spacing: 20) {
15 | if updateManager.isChecking {
16 | checkingView
17 | } else if let release = updateManager.availableUpdate {
18 | updateAvailableView(release)
19 | } else if let error = updateManager.updateError {
20 | errorView(error)
21 | } else {
22 | noUpdateView
23 | }
24 | }
25 | .padding(20)
26 | .frame(width: 400)
27 | }
28 |
29 | private var checkingView: some View {
30 | VStack(spacing: 15) {
31 | ProgressView()
32 | .scaleEffect(1.5)
33 | Text("正在检查更新...")
34 | .font(.headline)
35 | }
36 | }
37 |
38 | private func updateAvailableView(_ release: GitHubRelease) -> some View {
39 | // 更新可用视图实现保持不变...
40 | VStack(spacing: 15) {
41 | Image(systemName: "arrow.down.circle.fill")
42 | .font(.system(size: 50))
43 | .foregroundColor(.blue)
44 |
45 | Text("发现新版本")
46 | .font(.title2)
47 | .bold()
48 |
49 | Text("版本 \(release.version)")
50 | .font(.title3)
51 | .foregroundColor(.secondary)
52 |
53 | ScrollView {
54 | Text(release.body)
55 | .font(.body)
56 | .padding(5)
57 | }
58 | .frame(maxHeight: 150)
59 | .background(Color.secondary.opacity(0.1))
60 | .cornerRadius(8)
61 |
62 | HStack {
63 | Button("忽略此版本") {
64 | updateManager.ignoreCurrentUpdate()
65 | updateManager.dismissUpdateSheet()
66 | }
67 |
68 | Button("手动下载") {
69 | updateManager.openReleasesPage()
70 | updateManager.dismissUpdateSheet()
71 | }
72 |
73 | Button("下载并安装") {
74 | Task {
75 | await updateManager.downloadAndInstallUpdate()
76 | }
77 | }
78 | .buttonStyle(.borderedProminent)
79 | }
80 | }
81 | }
82 |
83 | private var noUpdateView: some View {
84 | VStack(spacing: 15) {
85 | Image(systemName: "checkmark.circle.fill")
86 | .font(.system(size: 50))
87 | .foregroundColor(.green)
88 |
89 | Text("已是最新版本")
90 | .font(.title2)
91 | .bold()
92 |
93 | Text("当前版本已是最新,无需更新。")
94 | .foregroundColor(.secondary)
95 |
96 | Button("确定") {
97 | updateManager.dismissUpdateSheet()
98 | }
99 | .buttonStyle(.borderedProminent)
100 | }
101 | }
102 |
103 | private func errorView(_ error: String) -> some View {
104 | VStack(spacing: 15) {
105 | Image(systemName: "exclamationmark.triangle.fill")
106 | .font(.system(size: 50))
107 | .foregroundColor(.yellow)
108 |
109 | Text("检查更新失败")
110 | .font(.title2)
111 | .bold()
112 |
113 | Text(error)
114 | .font(.body)
115 | .multilineTextAlignment(.center)
116 |
117 | Button("确定") {
118 | updateManager.dismissUpdateSheet()
119 | }
120 | .buttonStyle(.bordered)
121 | }
122 | }
123 | }
124 |
--------------------------------------------------------------------------------
/RClick.xcodeproj/xcshareddata/xcschemes/FileProviderExt.xcscheme:
--------------------------------------------------------------------------------
1 |
2 |
6 |
10 |
11 |
17 |
23 |
24 |
25 |
31 |
37 |
38 |
39 |
40 |
41 |
47 |
48 |
58 |
60 |
66 |
67 |
68 |
69 |
75 |
76 |
77 |
78 |
86 |
88 |
94 |
95 |
96 |
97 |
99 |
100 |
103 |
104 |
105 |
--------------------------------------------------------------------------------
/RClick/Settings/SettingsView.swift:
--------------------------------------------------------------------------------
1 | //
2 | // SettingsView.swift
3 | // RClick
4 | //
5 | // Created by 李旭 on 2024/4/4.
6 | //
7 |
8 | import SwiftUI
9 |
10 | enum Tabs: String, CaseIterable, Identifiable {
11 | case general = "General"
12 | case apps = "Apps"
13 | case actions = "Actions"
14 | case newFile = "New File"
15 | case cdirs = "Common Dir"
16 | case about = "About"
17 |
18 | var id: String { self.rawValue }
19 |
20 | var icon: String {
21 | switch self {
22 | case .general: "slider.horizontal.2.square"
23 | case .apps: "apps.ipad.landscape"
24 | case .actions: "bolt.square"
25 | case .newFile: "doc.badge.plus"
26 | case .cdirs: "folder.badge.gearshape"
27 | case .about: "exclamationmark.circle"
28 | }
29 | }
30 | }
31 |
32 | struct SettingsView: View {
33 | @State private var selectedTab: Tabs = .general
34 | @EnvironmentObject var appState: AppState
35 | @State var showSelectApp = false
36 |
37 | @ViewBuilder
38 | private var sidebar: some View {
39 | Section {
40 | Divider()
41 | List(selection: self.$selectedTab) {
42 | ForEach(Tabs.allCases, id: \.self) { tab in
43 | HStack {
44 | // 使用固定大小的frame来确保图标大小一致
45 | Label {
46 | Text(LocalizedStringKey(tab.rawValue))
47 | .font(.title2)
48 | } icon: {
49 | Image(systemName: tab.icon)
50 | .font(.title2)
51 | .frame(width: 24, height: 24)
52 | }
53 | .padding(.all, 8)
54 | .labelStyle(.titleAndIcon)
55 | Spacer(minLength: 0)
56 | }
57 | .onTapGesture {
58 | self.selectedTab = tab
59 | }
60 | }
61 | }
62 | .listStyle(SidebarListStyle())
63 | .scrollDisabled(true)
64 | .navigationSplitViewColumnWidth(210)
65 | } header: {
66 | // App Icon 部分
67 | VStack {
68 | HStack {
69 | Spacer()
70 | Image("Logo")
71 | .resizable()
72 | .frame(width: 64, height: 64)
73 | Spacer()
74 | }
75 | HStack {
76 | Spacer()
77 | Text("RClick").font(.title)
78 | Text("\(self.getAppVersion())")
79 | Spacer()
80 | }
81 | }
82 | .padding(.horizontal)
83 | .padding(.vertical, 24)
84 | }
85 | .frame(minWidth: 200, idealWidth: 250, maxWidth: 300)
86 | .removeSidebarToggle()
87 | }
88 |
89 | @ViewBuilder var detailView: some View {
90 | // 右侧内容
91 | Group {
92 | switch self.selectedTab {
93 | case .general:
94 | GeneralSettingsTabView()
95 | case .apps:
96 | AppsSettingsTabView()
97 | case .actions:
98 | ActionSettingsTabView()
99 | case .newFile:
100 | NewFileSettingsTabView()
101 | case .cdirs:
102 | CommonDirsSettingTabView()
103 | case .about:
104 | AboutSettingsTabView()
105 | }
106 | }
107 | .frame(maxWidth: .infinity, maxHeight: .infinity)
108 | .frame(minWidth: 450,idealWidth: 600, maxWidth: 800)
109 | .padding()
110 | }
111 |
112 | var body: some View {
113 | NavigationSplitView {
114 | self.sidebar
115 | } detail: {
116 | self.detailView
117 | }
118 | }
119 |
120 | func getAppVersion() -> String {
121 | if let version = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String {
122 | return version
123 | }
124 | return "Unknown"
125 | }
126 | }
127 |
128 | extension View {
129 | /// Removes the sidebar toggle button from the toolbar.
130 | func removeSidebarToggle() -> some View {
131 | toolbar(removing: .sidebarToggle)
132 | .toolbar {
133 | Color.clear
134 | }
135 | }
136 | }
137 |
138 | #Preview {
139 | SettingsView()
140 | }
141 |
--------------------------------------------------------------------------------
/RClick/Shared/LaunchAtLogin.swift:
--------------------------------------------------------------------------------
1 | //
2 | // LaunchAtLogin.swift
3 | // RClick
4 | // from https://github.com/sindresorhus/LaunchAtLogin-Modern
5 | // Created by 李旭 on 2024/12/19.
6 | //
7 |
8 | import Foundation
9 |
10 | import SwiftUI
11 | import ServiceManagement
12 | import os.log
13 |
14 | public enum LaunchAtLogin {
15 | private static let logger = Logger(subsystem: Bundle.main.bundleIdentifier ?? "LaunchAtLogin", category: "main")
16 | fileprivate static let observable = Observable()
17 |
18 | /**
19 | Toggle “launch at login” for your app or check whether it's enabled.
20 | */
21 | public static var isEnabled: Bool {
22 | get { SMAppService.mainApp.status == .enabled }
23 | set {
24 | observable.objectWillChange.send()
25 |
26 | do {
27 | if newValue {
28 | if SMAppService.mainApp.status == .enabled {
29 | try? SMAppService.mainApp.unregister()
30 | }
31 |
32 | try SMAppService.mainApp.register()
33 | } else {
34 | try SMAppService.mainApp.unregister()
35 | }
36 | } catch {
37 | logger.error("Failed to \(newValue ? "enable" : "disable") launch at login: \(error.localizedDescription)")
38 | }
39 | }
40 | }
41 |
42 | /**
43 | Whether the app was launched at login.
44 |
45 | - Important: This property must only be checked in `NSApplicationDelegate#applicationDidFinishLaunching`.
46 | */
47 | public static var wasLaunchedAtLogin: Bool {
48 | let event = NSAppleEventManager.shared().currentAppleEvent
49 | return event?.eventID == kAEOpenApplication
50 | && event?.paramDescriptor(forKeyword: keyAEPropData)?.enumCodeValue == keyAELaunchedAsLogInItem
51 | }
52 | }
53 |
54 | extension LaunchAtLogin {
55 | final class Observable: ObservableObject {
56 | var isEnabled: Bool {
57 | get { LaunchAtLogin.isEnabled }
58 | set {
59 | LaunchAtLogin.isEnabled = newValue
60 | }
61 | }
62 | }
63 | }
64 |
65 | extension LaunchAtLogin {
66 | /**
67 | This package comes with a `LaunchAtLogin.Toggle` view which is like the built-in `Toggle` but with a predefined binding and label. Clicking the view toggles “launch at login” for your app.
68 |
69 | ```
70 | struct ContentView: View {
71 | var body: some View {
72 | LaunchAtLogin.Toggle()
73 | }
74 | }
75 | ```
76 |
77 | The default label is `"Launch at login"`, but it can be overridden for localization and other needs:
78 |
79 | ```
80 | struct ContentView: View {
81 | var body: some View {
82 | LaunchAtLogin.Toggle {
83 | Text("Launch at login")
84 | }
85 | }
86 | }
87 | ```
88 | */
89 | public struct Toggle: View {
90 | @ObservedObject private var launchAtLogin = LaunchAtLogin.observable
91 | private let label: Label
92 |
93 | /**
94 | Creates a toggle that displays a custom label.
95 |
96 | - Parameters:
97 | - label: A view that describes the purpose of the toggle.
98 | */
99 | public init(@ViewBuilder label: () -> Label) {
100 | self.label = label()
101 | }
102 |
103 | public var body: some View {
104 | SwiftUI.Toggle(isOn: $launchAtLogin.isEnabled) { label }
105 | }
106 | }
107 | }
108 |
109 | extension LaunchAtLogin.Toggle {
110 | /**
111 | Creates a toggle that generates its label from a localized string key.
112 |
113 | This initializer creates a ``Text`` view on your behalf with the provided `titleKey`.
114 |
115 | - Parameters:
116 | - titleKey: The key for the toggle's localized title, that describes the purpose of the toggle.
117 | */
118 | public init(_ titleKey: LocalizedStringKey) {
119 | label = Text(titleKey)
120 | }
121 |
122 | /**
123 | Creates a toggle that generates its label from a string.
124 |
125 | This initializer creates a `Text` view on your behalf with the provided `title`.
126 |
127 | - Parameters:
128 | - title: A string that describes the purpose of the toggle.
129 | */
130 | public init(_ title: some StringProtocol) {
131 | label = Text(title)
132 | }
133 |
134 | /**
135 | Creates a toggle with the default title of `Launch at login`.
136 | */
137 | public init() {
138 | self.init("Launch at login")
139 | }
140 | }
141 |
142 |
--------------------------------------------------------------------------------
/RClick.xcodeproj/xcshareddata/xcschemes/RClick.xcscheme:
--------------------------------------------------------------------------------
1 |
2 |
6 |
10 |
11 |
17 |
23 |
24 |
25 |
31 |
37 |
38 |
39 |
40 |
41 |
47 |
48 |
60 |
62 |
68 |
69 |
70 |
71 |
77 |
78 |
79 |
80 |
84 |
85 |
86 |
87 |
93 |
95 |
101 |
102 |
103 |
104 |
106 |
107 |
110 |
111 |
112 |
--------------------------------------------------------------------------------
/RClick/Shared/StringExtension.swift:
--------------------------------------------------------------------------------
1 | //
2 | // StringExtension.swift
3 | // RClick
4 | //
5 | // Created by 李旭 on 2024/4/5.
6 | //
7 |
8 | import Foundation
9 |
10 | import os.log
11 |
12 | let bundleIdentifier = Bundle.main.bundleIdentifier ?? ""
13 | var subsystem: String { bundleIdentifier }
14 |
15 | private let logger = Logger(subsystem: subsystem, category: "user_defaults")
16 |
17 | enum Key {
18 | static let showContextualMenuForItem = "SHOW_CONTEXTUAL_MENU_FOR_ITEM"
19 | static let showContextualMenuForContainer = "SHOW_CONTEXTUAL_MENU_FOR_CONTAINER"
20 | static let showContextualMenuForSidebar = "SHOW_CONTEXTUAL_MENU_FOR_SIDEBAR"
21 | static let showToolbarItemMenu = "SHOW_TOOLBAR_ITEM_MENU"
22 | static let showDockIcon = "SHOW_DOCK_ICON"
23 |
24 | static let globalApplicationArgumentsString = "GLOBAL_APPLICATION_ARGUMENTS_STRING"
25 | static let globalApplicationEnvironmentString = "GLOBAL_APPLICATION_ENVIRONMENT_STRING"
26 |
27 | static let copySeparator = "COPY_SEPARATOR"
28 | static let newFileName = "NEW_FILE_NAME"
29 | static let newFileExtension = "NEW_FILE_EXTENSION"
30 |
31 | static let showSubMenuForApplication = "SHOW_SUB_MENU_FOR_APPLICATION"
32 | static let showSubMenuForAction = "SHOW_SUB_MENU_FOR_ACTION"
33 | static let messageFromFinder = "RCLICK_FINDER_Main"
34 | static let messageFromMain = "RCLICK_MAIN_FINDER"
35 |
36 | static let apps = "RCLICK_APPs"
37 | static let actions = "RCLICK_ACTIONS"
38 | static let fileTypes = "RCLICK_FILE_TYPES"
39 | static let permDirs = "RCLICK_PERMISSIVE_DIRS"
40 | static let commonDirs = "RCLICK_COMMON_DIRS"
41 | static let showMenuBarExtra = "showMenuBarExtra"
42 | static let showInDock = "SHOW_IN_DOCK"
43 |
44 | }
45 |
46 | enum NewFileExtension: String, CaseIterable, Identifiable {
47 | var id: String { rawValue }
48 | case none = "(none)"
49 | case swift
50 | case txt
51 | }
52 |
53 | extension String {
54 | func toDictionary(separator: Character = " ") -> [String: String] {
55 | split(separator: separator)
56 | .map { $0.split(separator: "=") }
57 | .filter { $0.count == 2 }
58 | .reduce(into: [String: String]()) { result, pair in
59 | let key = String(pair[0])
60 | let value = String(pair[1])
61 | result[key] = value
62 | }
63 | }
64 | }
65 |
66 | extension Dictionary {
67 | func toString(separator: String = " ") -> String {
68 | compactMap { "\($0)=\($1)" }.joined(separator: separator)
69 | }
70 | }
71 |
72 | func loadLocalizationKeys(from tableName: String, bundle: Bundle = .main) -> [String: String] {
73 | var keyToLocalizedString = [String: String]()
74 | var localizedStringToKey = [String: String]()
75 |
76 | if let path = bundle.path(forResource: tableName, ofType: "strings"),
77 | let strings = NSDictionary(contentsOfFile: path) as? [String: String]
78 | {
79 | for (key, value) in strings {
80 | keyToLocalizedString[key] = value
81 | localizedStringToKey[value] = key
82 | }
83 | }
84 | return localizedStringToKey
85 | }
86 |
87 | extension String {
88 | static func key(forLocalizedString localizedString: String, in tableName: String, bundle: Bundle = .main) -> String? {
89 | let localizedStringToKey = loadLocalizationKeys(from: tableName, bundle: bundle)
90 | return localizedStringToKey[localizedString]
91 | }
92 | }
93 |
94 | // if let key = String.key(forLocalizedString: "Hello", in: "Localizable") {
95 | // print("The key for 'Hello' is \(key)")
96 | // }
97 |
98 | extension UserDefaults {
99 | static var group: UserDefaults {
100 | UserDefaults(suiteName: "group.cn.wflixu.RClick")!
101 | }
102 |
103 | var showContextualMenuForItem: Bool {
104 | defaults(for: Key.showContextualMenuForItem) ?? true
105 | }
106 |
107 | var showContextualMenuForContainer: Bool {
108 | defaults(for: Key.showContextualMenuForContainer) ?? true
109 | }
110 |
111 | var showContextualMenuForSidebar: Bool {
112 | defaults(for: Key.showContextualMenuForSidebar) ?? true
113 | }
114 |
115 | var showToolbarItemMenu: Bool {
116 | defaults(for: Key.showToolbarItemMenu) ?? true
117 | }
118 |
119 | var copySeparator: String {
120 | let spparator = defaults(for: Key.copySeparator) ?? ""
121 | return spparator.isEmpty ? " " : spparator
122 | }
123 |
124 | var newFileName: String {
125 | defaults(for: Key.newFileName) ?? "Untitled"
126 | }
127 |
128 | var newFileExtension: NewFileExtension {
129 | let fileExtensionRaw = defaults(for: Key.newFileExtension) ?? ""
130 | return NewFileExtension(rawValue: fileExtensionRaw) ?? .none
131 | }
132 |
133 | var showSubMenuForApplication: Bool {
134 | defaults(for: Key.showSubMenuForApplication) ?? false
135 | }
136 |
137 | var showSubMenuForAction: Bool {
138 | defaults(for: Key.showSubMenuForAction) ?? false
139 | }
140 |
141 | private func defaults(for key: String) -> T? {
142 | if let value = object(forKey: key) as? T {
143 | return value
144 | } else {
145 | logger.warning("Missing key for \(key, privacy: .public), using default true value")
146 | return nil
147 | }
148 | }
149 | }
150 |
--------------------------------------------------------------------------------
/RClick/Model/RCBase.swift:
--------------------------------------------------------------------------------
1 | //
2 | // RCBase.swift
3 | // RClick
4 | //
5 | // Created by 李旭 on 2024/9/26.
6 | //
7 | import AppKit
8 | import Foundation
9 | import OSLog
10 |
11 | private let logger = Logger(subsystem: Bundle.main.bundleIdentifier ?? "RClick", category: "folder_item")
12 |
13 | protocol RCBase: Hashable, Identifiable, Codable {
14 | var id: String { get }
15 | }
16 |
17 | struct OpenWithApp: RCBase {
18 | var id: String
19 |
20 | init(id: String = UUID().uuidString, appURL url: URL) {
21 | self.id = id
22 | self.url = url
23 | itemName = url.deletingPathExtension().lastPathComponent
24 | }
25 |
26 | var url: URL
27 | var itemName: String
28 | var inheritFromGlobalArguments = true
29 | var inheritFromGlobalEnvironment = true
30 | var arguments: [String] = []
31 | var environment: [String: String] = [:]
32 |
33 | var appName: String {
34 | FileManager.default.displayName(atPath: url.path)
35 | }
36 |
37 | var name: String {
38 | itemName.isEmpty ? appName : itemName
39 | }
40 | }
41 |
42 | extension OpenWithApp {
43 | init?(bundleIdentifier identifier: String) {
44 | guard let url = NSWorkspace.shared.urlForApplication(withBundleIdentifier: identifier) else {
45 | return nil
46 | }
47 | self.init(appURL: url)
48 | }
49 |
50 | static let vscode = OpenWithApp(bundleIdentifier: "com.microsoft.VSCode")
51 | static let terminal = OpenWithApp(bundleIdentifier: "com.apple.Terminal")
52 | static var defaultApps: [OpenWithApp] {
53 | [
54 | .terminal,
55 | .vscode
56 | ].compactMap { $0 }
57 | }
58 | }
59 |
60 | struct PermissiveDir: RCBase {
61 | var id: String
62 | var url: URL
63 | var bookmark: Data
64 |
65 | init(id: String = UUID().uuidString, permUrl url: URL) {
66 | self.id = id
67 | self.url = url
68 | let result = url.startAccessingSecurityScopedResource()
69 | logger.info("start init PermissiveDir------------------------")
70 | if !result {
71 | logger.error("Fail to start access security scoped resource on \(url.path)")
72 | }
73 | do {
74 | bookmark = try url.bookmarkData(options: .withSecurityScope)
75 | } catch {
76 | logger.warning("\(error.localizedDescription)")
77 | fatalError()
78 | }
79 | }
80 |
81 | // enum CodingKeys: String, CodingKey {
82 | // case url, bookmark
83 | // }
84 | //
85 | // init(from decoder: any Decoder) throws {
86 | // let values = try decoder.container(keyedBy: CodingKeys.self)
87 | // bookmark = try values.decode(Data.self, forKey: .bookmark)
88 | // var isStale = false
89 | // do {
90 | // url = try URL(resolvingBookmarkData: bookmark, options: .withSecurityScope, relativeTo: nil, bookmarkDataIsStale: &isStale)
91 | // let result = url.startAccessingSecurityScopedResource()
92 | //
93 | // if !result {
94 | // logger.error("Fail to start access security scoped resource on \(path)")
95 | // }
96 | // } catch {
97 | // // Show for the main app
98 | // url = try values.decode(URL.self, forKey: .url)
99 | // }
100 | // id = UUID().uuidString
101 | // }
102 | }
103 |
104 | extension PermissiveDir {
105 | static var home: PermissiveDir? {
106 | guard let pw = getpwuid(getuid()),
107 | let home = pw.pointee.pw_dir
108 | else {
109 | return nil
110 | }
111 | let path = FileManager.default.string(withFileSystemRepresentation: home, length: strlen(home))
112 | let url = URL(fileURLWithPath: path)
113 | return PermissiveDir(permUrl: url)
114 | }
115 |
116 | static var application: PermissiveDir? {
117 | PermissiveDir(permUrl: URL(fileURLWithPath: "/Applications"))
118 | }
119 |
120 | static var volumns: [PermissiveDir] {
121 | let volumns = (FileManager.default.mountedVolumeURLs(includingResourceValuesForKeys: [], options: .skipHiddenVolumes) ?? []).dropFirst()
122 | return volumns.compactMap { PermissiveDir(permUrl: $0) }
123 | }
124 |
125 | static var defaultFolders: [PermissiveDir] {
126 | [.home].compactMap { $0 } + volumns
127 | }
128 | }
129 |
130 | // 常用目录
131 | struct CommonDir: RCBase {
132 | var id: String
133 | var name: String
134 | var url: URL
135 | var icon: String
136 | init(id: String, name: String, url: URL, icon: String) {
137 | self.id = id
138 | self.name = name
139 | self.url = url
140 | self.icon = icon
141 | }
142 | }
143 |
144 | struct RCAction: RCBase {
145 | static func == (lhs: RCAction, rhs: RCAction) -> Bool {
146 | lhs.id == rhs.id
147 | }
148 |
149 | var id: String
150 |
151 | var name: String
152 | var enabled = true
153 | var idx: Int
154 | var icon: String
155 |
156 | init(id: String, name: String, enabled: Bool = true, idx: Int, icon: String) {
157 | self.id = id
158 | self.name = name
159 | self.enabled = enabled
160 | self.idx = idx
161 | self.icon = icon
162 | }
163 | }
164 |
165 | extension RCAction {
166 |
167 | static let copyPath = RCAction(id: "copy-path", name: "Copy Path", idx: 0, icon: "doc.on.doc")
168 | static let deleteDirect = RCAction(id: "delete-direct", name: "Delete Direct", idx: 1, icon: "trash")
169 | static let hideFileDir = RCAction(id: "hide", name: "Hide", idx: 2, icon: "eye.slash")
170 | static let unhideFileDir = RCAction(id: "unhide", name: "Unhide", idx: 3, icon: "eye")
171 | static let airdrop = RCAction(id: "airdrop", name: "AirDrop", idx: 4, icon: "paperplane")
172 |
173 | static var all: [RCAction] = [.copyPath, .deleteDirect,.airdrop, .hideFileDir, .unhideFileDir]
174 | }
175 |
176 | // New File Type
177 | struct NewFile: RCBase {
178 | static func == (lhs: NewFile, rhs: NewFile) -> Bool {
179 | lhs.id == rhs.id
180 | }
181 |
182 | var ext: String
183 | var name: String
184 | var enabled = true
185 | var idx: Int
186 | var icon: String
187 | var id: String
188 | var openApp: URL?
189 | var template: URL?
190 |
191 | init(ext: String, name: String, enabled: Bool = true, idx: Int, icon: String = "document", id: String = UUID().uuidString) {
192 | self.ext = ext
193 | self.name = name
194 | self.enabled = enabled
195 | self.idx = idx
196 | self.icon = icon
197 | self.id = id
198 | }
199 | }
200 |
201 | extension NewFile {
202 | static var all: [NewFile] = [.txt, .md, .json, .docx, .pptx, .xlsx]
203 |
204 | static let json = NewFile(ext: ".json", name: "JSON", idx: 0, icon: "icon-file-json")
205 | static let txt = NewFile(ext: ".txt", name: "TXT", idx: 1, icon: "icon-file-txt")
206 | static let md = NewFile(ext: ".md", name: "Markdown", idx: 2, icon: "icon-file-md")
207 | static let docx = NewFile(ext: ".docx", name: "DOCX", idx: 3, icon: "icon-file-docx")
208 | static let pptx = NewFile(ext: ".pptx", name: "PPTX", idx: 4, icon: "icon-file-pptx")
209 | static let xlsx = NewFile(ext: ".xlsx", name: "XLSX", idx: 5, icon: "icon-file-xlsx")
210 | }
211 |
--------------------------------------------------------------------------------
/RClick/Settings/GeneralSettingsTabView.swift:
--------------------------------------------------------------------------------
1 | //
2 | // GeneralSettingsTabView.swift
3 | // RClick
4 | //
5 | // Created by 李旭 on 2024/4/10.
6 | //
7 |
8 | import AppKit
9 | import Cocoa
10 | import FinderSync
11 | import SwiftUI
12 |
13 | struct GeneralSettingsTabView: View {
14 | @AppLog(category: "settings-general")
15 | private var logger
16 |
17 | @AppStorage("extensionEnabled") private var extensionEnabled = false
18 | @AppStorage(Key.showMenuBarExtra) private var showMenuBarExtra = true
19 | @AppStorage(Key.showInDock) private var showInDock = false
20 |
21 | @EnvironmentObject var store: AppState
22 |
23 | @State private var showAlert = false
24 | @State private var wrongFold = false
25 |
26 | @State private var showDirImporter = false
27 |
28 | @Environment(\.scenePhase) private var scenePhase
29 |
30 | let messager = Messager.shared
31 |
32 | var enableIcon: String {
33 | if extensionEnabled {
34 | return "checkmark.circle.fill"
35 | } else {
36 | return "checkmark.circle"
37 | }
38 | }
39 |
40 | var body: some View {
41 | VStack(alignment: .leading, spacing: 8) {
42 | HStack(alignment: .bottom) {
43 | Text("Enable extension").font(.title3).fontWeight(.semibold)
44 | Spacer()
45 | Button(action: openExtensionset) {
46 | Label("Open Settings", systemImage: enableIcon)
47 | }
48 | }
49 |
50 | Text("The RClick extension needs to be enabled for it to work properly")
51 | .font(.headline)
52 | .fontWeight(.thin)
53 | .foregroundColor(Color.gray)
54 | Divider()
55 |
56 | HStack {
57 | LaunchAtLogin.Toggle(
58 | LocalizedStringKey("Launch at login")
59 | )
60 | }
61 | Divider()
62 | Text("App Icon Show").font(.title2)
63 |
64 | HStack {
65 | Toggle("Show in menu bar", isOn: $showMenuBarExtra)
66 | .toggleStyle(.checkbox)
67 | Spacer()
68 | // 设置 showMenuBarExtra 的开关
69 | Toggle("Show in dock", isOn: $showInDock)
70 | .toggleStyle(.checkbox)
71 | .onChange(of: showInDock) { _, newValue in
72 | logger.debug("the hcnage --- a kjd \(newValue)")
73 | // 在这里处理开关状态的变化
74 | if newValue {
75 | // 显示菜单栏图标
76 | NSApp.setActivationPolicy(.regular)
77 | } else {
78 | // 隐藏菜单栏图标
79 | NSApp.setActivationPolicy(.accessory)
80 | }
81 | }
82 | }
83 | // 设置 showMenuBarExtra 的开关
84 |
85 | Divider()
86 | HStack {}.frame(height: 10)
87 |
88 | VStack(alignment: .leading) {
89 | Section {
90 | List {
91 | ForEach(store.dirs) { item in
92 | HStack {
93 | Image(systemName: "folder")
94 | Text(verbatim: item.url.path)
95 | Spacer()
96 | Button {
97 | removeBookmark(item)
98 | } label: {
99 | Image(systemName: "trash")
100 | }
101 | }
102 | }
103 | }
104 | } header: {
105 | HStack {
106 | Text("Authorization folder").font(.title3).fontWeight(.semibold)
107 | Spacer()
108 | Button {
109 | showDirImporter = true
110 | } label: { Label("Add", systemImage: "folder.badge.plus") }
111 | }
112 |
113 | } footer: {
114 | VStack {
115 | HStack {
116 | Text("The operation of the menu can only be executed in authorized folders")
117 | .foregroundColor(.secondary)
118 | .font(.caption)
119 | Spacer()
120 | }
121 | }
122 | }
123 | }
124 | .alert(
125 | Text("Invalid Folder Selection"),
126 | isPresented: $wrongFold
127 | ) {
128 | Button("OK") {
129 | showDirImporter = true
130 | }
131 | } message: {
132 | Text("The selected folder is a subdirectory of the previously chosen folder. Please select a different folder.")
133 | }
134 | }
135 | .alert(
136 | Text("Not Authorized Folder"),
137 | isPresented: $showAlert
138 | ) {
139 | Button("OK") {
140 | showDirImporter = true
141 | }
142 | } message: {
143 | Text("You must grant access to the folder to use this feature.")
144 | }
145 | .fileImporter(
146 | isPresented: $showDirImporter,
147 | allowedContentTypes: [.directory],
148 | allowsMultipleSelection: false
149 | ) { result in
150 | switch result {
151 | case .success(let dirs):
152 | startAddDir(dirs.first!)
153 |
154 | case .failure(let error):
155 | // handle error
156 | print(error)
157 | }
158 | }
159 |
160 | .onAppear {
161 | extensionEnabled = FIFinderSyncController.isExtensionEnabled
162 |
163 | }.onForeground {
164 | updateEnableState()
165 | // Task {
166 | // await checkPermissionFolder()
167 | // }
168 | }
169 | .task {
170 | // await checkPermissionFolder()
171 | }
172 | }
173 |
174 | func updateEnableState() {
175 | extensionEnabled = FIFinderSyncController.isExtensionEnabled
176 | }
177 |
178 | func checkPermissionFolder() async {
179 | let isEmpty = store.dirs.isEmpty
180 | if isEmpty {
181 | showAlert = true
182 | } else {
183 | logger.info("no empty")
184 | }
185 | }
186 |
187 | @MainActor
188 | func startAddDir(_ url: URL) {
189 | let hasParentDir = store.hasParentBookmark(of: url)
190 | if hasParentDir {
191 | wrongFold = true
192 | // showAlert = true
193 | logger.info("hasParentDir\(hasParentDir)")
194 | } else {
195 | store.dirs.append(PermissiveDir(permUrl: url))
196 | try? store.savePermissiveDir()
197 |
198 | let observeDirs = store.dirs.map { $0.url.path }
199 | messager.sendMessage(name: "running", data: MessagePayload(action: "running", target: observeDirs))
200 | }
201 | }
202 |
203 | @MainActor private func removeBookmark(_ item: PermissiveDir) {
204 | // 根据item 查找offsets
205 | if let index = store.dirs.firstIndex(of: item) {
206 | store.deletePermissiveDir(index: index)
207 | }
208 | }
209 |
210 | private func openExtensionset() {
211 | FinderSync.FIFinderSyncController.showExtensionManagementInterface()
212 | }
213 | }
214 |
--------------------------------------------------------------------------------
/RClick/AppState.swift:
--------------------------------------------------------------------------------
1 | //
2 | // File.swift
3 | // RClick
4 | //
5 | // Created by 李旭 on 2024/9/26.
6 | //
7 |
8 | import Combine
9 | import Foundation
10 | import OrderedCollections
11 | import SwiftUI
12 |
13 | @MainActor
14 | class AppState: ObservableObject {
15 | static let shared = AppState()
16 |
17 | @AppLog(category: "AppState")
18 | private var logger
19 |
20 | @Published var apps: [OpenWithApp] = []
21 | @Published var dirs: [PermissiveDir] = []
22 | @Published var actions: [RCAction] = []
23 | @Published var newFiles: [NewFile] = []
24 | @Published var cdirs: [CommonDir] = []
25 | @Published var inExt: Bool
26 |
27 | @Published var showMenuBar: Bool = true;
28 |
29 |
30 | init(inExt: Bool = false) {
31 | self.inExt = inExt
32 | Task {
33 | await MainActor.run {
34 | logger.info("start load")
35 | try? load()
36 | }
37 | }
38 | }
39 |
40 | // Apps
41 | @MainActor func deleteApp(index: Int) {
42 | apps.remove(at: index)
43 | do {
44 | try save()
45 | // 使用 result
46 | } catch {
47 | // 处理错误
48 | logger.info("save error: \(error.localizedDescription)")
49 | }
50 | }
51 |
52 | @MainActor func addApp(item: OpenWithApp) {
53 | logger.info("start add app")
54 | apps.append(item)
55 |
56 | do {
57 | try save()
58 | // 使用 result
59 | } catch {
60 | // 处理错误
61 | logger.info("save error: \(error.localizedDescription)")
62 | }
63 | }
64 |
65 | @MainActor
66 | func updateApp(id: String, itemName: String, arguments: [String], environment: [String: String]) {
67 | if let index = apps.firstIndex(where: { $0.id == id }) {
68 | var updatedApp = apps[index]
69 | updatedApp.itemName = itemName
70 | updatedApp.arguments = arguments
71 | updatedApp.environment = environment
72 | apps[index] = updatedApp
73 | try? save()
74 | }
75 | }
76 |
77 | func getAppItem(rid: String) -> OpenWithApp? {
78 | return apps.first { rid.contains($0.id) }
79 | }
80 |
81 | func getFileType(rid: String) -> NewFile? {
82 | return newFiles.first(where: { nf in
83 | rid == nf.id
84 | })
85 | }
86 |
87 | @MainActor func addNewFile(_ item: NewFile) {
88 | logger.info("start add new file type")
89 | newFiles.append(item)
90 |
91 | do {
92 | try save()
93 | // 使用 result
94 | } catch {
95 | // 处理错误
96 | logger.info("save error: \(error.localizedDescription)")
97 | }
98 | }
99 |
100 | func getActionItem(rid: String) -> RCAction? {
101 | actions.first(where: { rcAtion in
102 | rcAtion.id == rid
103 | })
104 | }
105 |
106 | // Action
107 | @MainActor func toggleActionItem() {
108 | try? save()
109 | }
110 |
111 | @MainActor func resetActionItems() {
112 | actions = RCAction.all
113 | try? save()
114 | }
115 |
116 | @MainActor func resetFiletypeItems() {
117 | newFiles = NewFile.all
118 | try? save()
119 | }
120 |
121 | // Permission
122 | @MainActor func deletePermissiveDir(index: Int) {
123 | dirs.remove(at: index)
124 |
125 | try? save()
126 | }
127 |
128 | @MainActor func hasParentBookmark(of url: URL) -> Bool {
129 | return false
130 | // let storedUrls = dirs.map { $0.url }
131 | // for storedURL in storedUrls {
132 | // // 确保 storedURL 是一个目录,并且传入的 URL 以 storedURL 的路径为前缀
133 | // if url.path.hasPrefix(storedURL.path) {
134 | // return true
135 | // }
136 | // }
137 | // return false
138 | }
139 |
140 | @MainActor
141 | private func save() throws {
142 | let encoder = PropertyListEncoder()
143 | let appItemsData = try encoder.encode(OrderedSet(apps))
144 | let actionItemsData = try encoder.encode(OrderedSet(actions))
145 | let filetypeItemsData = try encoder.encode(OrderedSet(newFiles))
146 | let permDirsData = try encoder.encode(OrderedSet(dirs))
147 | let commonDirsData = try encoder.encode(OrderedSet(cdirs))
148 | UserDefaults.group.set(appItemsData, forKey: Key.apps)
149 | UserDefaults.group.set(actionItemsData, forKey: Key.actions)
150 | UserDefaults.group.set(filetypeItemsData, forKey: Key.fileTypes)
151 | UserDefaults.group.set(permDirsData, forKey: Key.permDirs)
152 | UserDefaults.group.set(commonDirsData, forKey: Key.commonDirs)
153 | }
154 |
155 | @MainActor
156 | func savePermissiveDir() throws {
157 | let encoder = PropertyListEncoder()
158 | let permDirsData = try encoder.encode(OrderedSet(dirs))
159 | UserDefaults.group.set(permDirsData, forKey: Key.permDirs)
160 | }
161 |
162 | // 保存常用文件夹
163 | @MainActor
164 | func saveCommonDir() throws {
165 | let encoder = PropertyListEncoder()
166 | let commonDirsData = try encoder.encode(OrderedSet(cdirs))
167 | UserDefaults.group.set(commonDirsData, forKey: Key.commonDirs)
168 | logger.info("save common dirs success")
169 | }
170 |
171 | @MainActor func refresh() {
172 | _ = try? load()
173 | }
174 |
175 | @MainActor func sync() {
176 | _ = try? save()
177 | }
178 |
179 | @MainActor
180 | private func load() throws {
181 | let decoder = PropertyListDecoder()
182 | if !inExt {
183 | if let permDirsData = UserDefaults.group.data(forKey: Key.permDirs) {
184 | dirs = try decoder.decode([PermissiveDir].self, from: permDirsData)
185 | logger.info("load permDir success")
186 |
187 | for dir in dirs {
188 | var isStale = false
189 | do {
190 | let folderURL = try URL(resolvingBookmarkData: dir.bookmark, options: .withSecurityScope, relativeTo: nil, bookmarkDataIsStale: &isStale)
191 |
192 | if isStale {
193 | // 重新创建 bookmarkData
194 | // createBookmark(for: folderURL) // 这里可以调用之前的函数
195 | }
196 |
197 | // 进入安全范围
198 | let success = folderURL.startAccessingSecurityScopedResource()
199 | if success {
200 | // 完成后释放资源
201 | logger.info("startAccessingSecurityScopedResource success")
202 | // folderURL.stopAccessingSecurityScopedResource()
203 | } else {
204 | logger.warning("fail access scope \(dir.url.path)")
205 | }
206 | } catch {
207 | print("解析 bookmark 失败:\(error)")
208 | }
209 | }
210 |
211 | } else {
212 | logger.warning("load permission dirfailed")
213 |
214 | dirs = []
215 | }
216 | }
217 |
218 | if let commonDirsData = UserDefaults.group.data(forKey: Key.commonDirs) {
219 | cdirs = try decoder.decode([CommonDir].self, from: commonDirsData)
220 |
221 | logger.info("load common dirs success")
222 | } else {
223 | logger.warning("load common dirs failed")
224 | cdirs = []
225 | }
226 |
227 | if let actionData = UserDefaults.group.data(forKey: Key.actions) {
228 | actions = try decoder.decode([RCAction].self, from: actionData)
229 | logger.info("load actions success")
230 | } else {
231 | logger.warning("load actions failed")
232 | actions = RCAction.all
233 | }
234 |
235 | if let filetypeItemData = UserDefaults.group.data(forKey: Key.fileTypes) {
236 | newFiles = try decoder.decode([NewFile].self, from: filetypeItemData)
237 | logger.info("load filetype success")
238 | } else {
239 | logger.warning("load new file type failed")
240 | newFiles = NewFile.all
241 | }
242 |
243 | if let appItemData = UserDefaults.group.data(forKey: Key.apps) {
244 | apps = try decoder.decode([OpenWithApp].self, from: appItemData)
245 | logger.info("load apps success")
246 | } else {
247 | logger.warning("load apps failed")
248 | apps = OpenWithApp.defaultApps
249 | }
250 | }
251 | }
252 |
--------------------------------------------------------------------------------
/FinderSyncExt/MenuItemClickable.swift:
--------------------------------------------------------------------------------
1 | //
2 | // MenuItemClickable.swift
3 | // RClick
4 | //
5 | // Created by 李旭 on 2024/4/7.
6 | //
7 |
8 | import AppKit
9 | import Foundation
10 | import os.log
11 |
12 | private let logger = Logger(subsystem: subsystem, category: "menu_click")
13 |
14 | protocol MenuItemClickable {
15 | func menuClick(with urls: [URL])
16 | }
17 |
18 | extension AppMenuItem: MenuItemClickable {
19 | func menuClick(with urls: [URL]) {
20 |
21 | // Task {
22 | //
23 | // do {
24 | // let config = NSWorkspace.OpenConfiguration()
25 | // config.promptsUserIfNeeded = true
26 | // config.arguments = arguments
27 | // config.environment = environment
28 | //
29 | // logger.warning("app:\(url) is opening \(urls)")
30 | // urls.forEach { url in
31 | // let result = url.startAccessingSecurityScopedResource()
32 | // if !result {
33 | // logger.error("Fail to start access security scoped resource on \(url.path)")
34 | // }
35 | // }
36 | // let application = try await NSWorkspace.shared.open(urls, withApplicationAt: url, configuration: config)
37 | // if let path = application.bundleURL?.path,
38 | // let identifier = application.bundleIdentifier,
39 | // let date = application.launchDate
40 | // {
41 | // logger.notice("Success: open \(identifier, privacy: .public) app at \(path, privacy: .public) in \(date, privacy: .public)")
42 | // }
43 | // } catch {
44 | // guard let error = error as? CocoaError,
45 | // let underlyingError = error.userInfo["NSUnderlyingError"] as? NSError else { return }
46 | // logger.error("Error---: \(error.localizedDescription)")
47 | // Task { @MainActor in
48 | // if underlyingError.code == -10820 {
49 | // let alert = NSAlert(error: error)
50 | // alert.addButton(withTitle: String(localized: "OK", comment: "OK button"))
51 | // alert.addButton(withTitle: String(localized: "Remove", comment: "Remove app button"))
52 | // let response = alert.runModal()
53 | // logger.notice("NSAlert response result \(response.rawValue)")
54 | // switch response {
55 | // case .alertFirstButtonReturn:
56 | // logger.notice("Dismiss error with OK")
57 | // case .alertSecondButtonReturn:
58 | // logger.notice("Dismiss error with Remove app")
59 | // if let index = menuStore.appItems.firstIndex(of: self) {
60 | // menuStore.deleteAppItems(offsets: IndexSet(integer: index))
61 | // }
62 | // default:
63 | // break
64 | // }
65 | // } else {
66 | // let panel = NSOpenPanel()
67 | // panel.allowsMultipleSelection = true
68 | // panel.allowedContentTypes = [.folder]
69 | // panel.canChooseDirectories = true
70 | // panel.directoryURL = URL(fileURLWithPath: urls[0].path)
71 | // let response = await panel.begin()
72 | // logger.notice("NSOpenPanel response result \(response.rawValue)")
73 | // if response == .OK {
74 | // folderStore.appendItems(panel.urls.map { BookmarkFolderItem($0) })
75 | // }
76 | // }
77 | // }
78 | // }
79 | // }
80 | }
81 | }
82 |
83 | extension ActionMenuItem: MenuItemClickable {
84 | static let actions: [([URL]) -> ActionMenuResult] = [
85 | { urls in
86 | let board = NSPasteboard.general
87 | board.clearContents()
88 | let string = urls
89 | .map(\.path)
90 | .map {
91 | let option = UserDefaults.group.copyOption
92 | switch option {
93 | case .origin:
94 | return $0
95 | case .escape:
96 | return $0.replacingOccurrences(of: " ", with: #"\ "#)
97 | case .quoto:
98 | return "\"\($0)\""
99 | }
100 | }
101 | .joined(separator: UserDefaults.group.copySeparator)
102 | let success = board.setString(string, forType: .string)
103 |
104 | return ActionMenuResult(success: success, message: "Pasteboard setString to \(string)")
105 | },
106 | { urls in
107 | let board = NSPasteboard.general
108 | board.clearContents()
109 | let string = urls
110 | .map(\.lastPathComponent)
111 | .map {
112 | let option = UserDefaults.group.copyOption
113 | switch option {
114 | case .origin:
115 | return $0
116 | case .escape:
117 | return $0.replacingOccurrences(of: " ", with: #"\ "#)
118 | case .quoto:
119 | return "\"\($0)\""
120 | }
121 | }
122 | .joined(separator: UserDefaults.group.copySeparator)
123 | let success = board.setString(string, forType: .string)
124 | return ActionMenuResult(success: success, message: "Pasteboard setString to \(string)")
125 | },
126 | { urls in
127 | let subResults = urls.map { url in
128 | let success = NSWorkspace.shared.selectFile(url.deletingLastPathComponent().path, inFileViewerRootedAtPath: "")
129 | return ActionMenuResult(success: success)
130 | }
131 | return ActionMenuResult(success: subResults.allSatisfy(\.success), subResults: subResults)
132 | },
133 | { urls in
134 | let subResults = urls.map { url in
135 | let name = UserDefaults.group.newFileName
136 | let fileExtension = UserDefaults.group.newFileExtension.rawValue
137 | let manager = FileManager.default
138 | let target: URL
139 | if manager.directoryExists(atPath: url.path) {
140 | target = url
141 | .appendingPathComponent(name)
142 | .appendingPathExtension(fileExtension)
143 | } else {
144 | target = url
145 | .deletingLastPathComponent()
146 | .appendingPathComponent(name)
147 | .appendingPathExtension(fileExtension)
148 | }
149 | logger.notice("Trying to create empty file at \(target.path, privacy: .public)")
150 | let success = FileManager.default.createFile(atPath: target.path, contents: Data(), attributes: nil)
151 | return ActionMenuResult(success: success)
152 | }
153 | return ActionMenuResult(success: subResults.allSatisfy(\.success), subResults: subResults)
154 | },
155 | ]
156 |
157 | func menuClick(with urls: [URL]) {
158 | let result = ActionMenuItem.actions[actionIndex](urls)
159 | if result.success {
160 | logger.notice("\(result.description, privacy: .public)")
161 | } else {
162 | logger.error("\(result.description, privacy: .public)")
163 | }
164 | }
165 | }
166 |
167 | struct ActionMenuResult: CustomStringConvertible {
168 | var success = false
169 | var message: String?
170 | var subResults: [ActionMenuResult]?
171 |
172 | var description: String {
173 | var result = "ActionMenuResult:\n"
174 | result.append("success: \(success ? "✅" : "❌") \n")
175 | if let message {
176 | result.append("message: \(message)\n")
177 | }
178 | if let subResults {
179 | result.append("subResults:\n")
180 | subResults.forEach { result.append($0.description) }
181 | result.append("\n")
182 | }
183 | return result
184 | }
185 | }
186 |
187 | extension FileManager {
188 | fileprivate func directoryExists(atPath path: String) -> Bool {
189 | fileExists(atPath: path, isDirectory: true)
190 | }
191 |
192 | private func fileExists(atPath path: String, isDirectory: Bool) -> Bool {
193 | var isDirectoryBool = ObjCBool(isDirectory)
194 | let exists = fileExists(atPath: path, isDirectory: &isDirectoryBool)
195 | return exists && (isDirectoryBool.boolValue == isDirectory)
196 | }
197 | }
198 |
--------------------------------------------------------------------------------
/RClick/Settings/AppsSettingsTabView.swift:
--------------------------------------------------------------------------------
1 | //
2 | // AppsSettingsTabView.swift
3 | // RClick
4 | //
5 | // Created by 李旭 on 2024/11/18.
6 | //
7 |
8 | import AppKit
9 | import SwiftUI
10 |
11 | struct AppsSettingsTabView: View {
12 | @EnvironmentObject var appState: AppState
13 | @State var showSelectApp = false
14 | @State private var expandedAppId: String?
15 | @State private var editingApp: OpenWithApp?
16 | @State private var editingItemName: String = ""
17 | @State private var editingArguments: String = ""
18 | @State private var editingEnvironment: String = ""
19 |
20 | let messager = Messager.shared
21 |
22 | var body: some View {
23 | ZStack {
24 | VStack {
25 | HStack {
26 |
27 | Spacer()
28 | Button {
29 | showSelectApp = true
30 | } label: {
31 | Label("Add", systemImage: "plus.app")
32 | .font(.body)
33 | }
34 | }
35 |
36 | List {
37 | ForEach(appState.apps) { item in
38 | VStack {
39 | // App 基本信息行
40 | HStack {
41 | Image(nsImage: NSWorkspace.shared.icon(forFile: item.url.path))
42 | .resizable()
43 | .aspectRatio(contentMode: .fit)
44 | .frame(width: 32, height: 32)
45 | Text(item.name).font(.title2)
46 | Spacer()
47 |
48 | // 展开/收起按钮
49 | Button {
50 | withAnimation {
51 | if expandedAppId == item.id {
52 | expandedAppId = nil
53 | } else {
54 | expandedAppId = item.id
55 | }
56 | }
57 | } label: {
58 | Image(systemName: expandedAppId == item.id ? "chevron.up" : "chevron.down")
59 | }
60 |
61 | // 编辑按钮
62 | Button {
63 | editingApp = item
64 | editingItemName = item.itemName
65 | editingArguments = item.arguments.joined(separator: "; ")
66 | editingEnvironment = item.environment.map { "\($0.key)=\($0.value)" }.joined(separator: "\n")
67 | } label: {
68 | Image(systemName: "pencil")
69 | }
70 |
71 | // 删除按钮
72 | Button {
73 | deleteApp(item)
74 | } label: {
75 | Image(systemName: "trash")
76 | }
77 | }
78 | .padding(.vertical, 4)
79 |
80 | // 展开的属性信息
81 | if expandedAppId == item.id {
82 | VStack(alignment: .leading, spacing: 12) {
83 | if !item.arguments.isEmpty {
84 | VStack(alignment: .leading, spacing: 4) {
85 | Text("Arguments:").font(.headline)
86 | VStack(alignment: .leading, spacing: 2) {
87 | ForEach(item.arguments, id: \.self) { arg in
88 | HStack(spacing: 4) {
89 | Image(systemName: "arrow.right")
90 | .foregroundColor(.secondary)
91 | .font(.caption)
92 | Text(arg)
93 | .font(.system(.body, design: .monospaced))
94 | }
95 | }
96 | }
97 | .padding(.leading, 8)
98 | }
99 | }
100 |
101 | if !item.environment.isEmpty {
102 | VStack(alignment: .leading, spacing: 4) {
103 | Text("Environment:").font(.headline)
104 | VStack(alignment: .leading, spacing: 2) {
105 | ForEach(Array(item.environment.sorted(by: { $0.key < $1.key })), id: \.key) { key, value in
106 | HStack(spacing: 4) {
107 | Image(systemName: "arrow.right")
108 | .foregroundColor(.secondary)
109 | .font(.caption)
110 | Text("\(key)=\(value)")
111 | .font(.system(.body, design: .monospaced))
112 | }
113 | }
114 | }
115 | .padding(.leading, 8)
116 | }
117 | }
118 | }
119 | .padding(.leading, 40)
120 | .padding(.vertical, 8)
121 |
122 | .transition(.opacity)
123 | .frame(maxWidth: .infinity, alignment: .leading) // 添加这行使宽度填充整个可用空间
124 | .background(Color(NSColor.alternatingContentBackgroundColors[1])) // 使用系统交替背景色
125 | .cornerRadius(6)
126 | }
127 | }
128 | }
129 | }
130 | }
131 | .fileImporter(
132 | isPresented: $showSelectApp,
133 | allowedContentTypes: [.application],
134 | allowsMultipleSelection: false
135 | ) { result in
136 | switch result {
137 | case .success(let files):
138 | if let url = files.first {
139 | appState.addApp(item: OpenWithApp(appURL: url))
140 | }
141 | case .failure(let error):
142 | print(error)
143 | }
144 | }
145 |
146 | // 编辑浮层
147 | if editingApp != nil {
148 | Color.black.opacity(0.3)
149 | .ignoresSafeArea()
150 | .onTapGesture {
151 | editingApp = nil
152 | }
153 |
154 | VStack {
155 | HStack {
156 | Text("Edit App Properties").font(.title2)
157 | }.padding(.top, 16)
158 |
159 |
160 | VStack(alignment: .leading, spacing: 16) {
161 | VStack(alignment: .leading) {
162 | Text("Display Name").font(.headline)
163 | TextField("Display Name", text: $editingItemName)
164 | .textFieldStyle(.roundedBorder)
165 | }
166 |
167 | VStack(alignment: .leading) {
168 | Text("Arguments").font(.headline)
169 | Text("One argument per semicolon (;)").font(.caption)
170 | .foregroundColor(.secondary)
171 | TextField("Arguments", text: $editingArguments)
172 | .textFieldStyle(.roundedBorder)
173 | }
174 |
175 | VStack(alignment: .leading) {
176 | Text("Environment Variables").font(.headline)
177 | Text("Format: KEY=VALUE, one per line").font(.caption)
178 | .foregroundColor(.secondary)
179 | TextEditor(text: $editingEnvironment)
180 | .font(.system(.body, design: .monospaced))
181 | .frame(height: 100)
182 | .border(Color.gray.opacity(0.2))
183 | }
184 | }
185 | .padding()
186 |
187 | HStack {
188 | Button("Cancel") {
189 | editingApp = nil
190 | }
191 | .keyboardShortcut(.escape)
192 |
193 | Button("Save") {
194 | if let app = editingApp {
195 | updateApp(app)
196 | }
197 | editingApp = nil
198 | }
199 | .keyboardShortcut(.return)
200 | }
201 | .padding(.bottom)
202 | }
203 | .frame(width: 400)
204 | .background(Color(NSColor.windowBackgroundColor))
205 | .cornerRadius(12)
206 | .shadow(radius: 10)
207 | }
208 | }
209 | }
210 |
211 | private func parseEnvironmentVariables(_ text: String) -> [String: String] {
212 | var result: [String: String] = [:]
213 | for line in text.split(separator: "\n") {
214 | let parts = line.split(separator: "=", maxSplits: 1)
215 | if parts.count == 2 {
216 | result[String(parts[0]).trimmingCharacters(in: .whitespaces)] = String(parts[1]).trimmingCharacters(in: .whitespaces)
217 | }
218 | }
219 | return result
220 | }
221 |
222 | @MainActor private func updateApp(_ app: OpenWithApp) {
223 | appState.updateApp(
224 | id: app.id,
225 | itemName: editingItemName,
226 | arguments: editingArguments.components(separatedBy: ";").map { $0.trimmingCharacters(in: .whitespaces) }.filter { !$0.isEmpty },
227 | environment: parseEnvironmentVariables(editingEnvironment)
228 | )
229 | messager.sendMessage(name: "running", data: MessagePayload(action: "running", target: []))
230 | }
231 |
232 | @MainActor private func deleteApp(_ appItem: OpenWithApp) {
233 | if let index = appState.apps.firstIndex(where: { $0.id == appItem.id }) {
234 | appState.deleteApp(index: index)
235 | if expandedAppId == appItem.id {
236 | expandedAppId = nil
237 | }
238 | }
239 | messager.sendMessage(name: "running", data: MessagePayload(action: "running", target: []))
240 | }
241 | }
242 |
--------------------------------------------------------------------------------
/FinderSyncExt/FinderSyncExt.swift:
--------------------------------------------------------------------------------
1 | //
2 | // FinderSync.swift
3 | // FinderSyncExt
4 | //
5 | // Created by 李旭 on 2024/4/4.
6 | //
7 |
8 | import AppKit
9 | import Cocoa
10 | import FinderSync
11 |
12 | // MARK: DELETE
13 |
14 | import OSLog
15 |
16 | private let logger = Logger(subsystem: Bundle.main.bundleIdentifier ?? "RClick", category: "FinderOpen")
17 |
18 | @MainActor
19 | class FinderSyncExt: FIFinderSync {
20 | var myFolderURL = URL(fileURLWithPath: "/Users/")
21 | var isHostAppOpen = false
22 | lazy var appState: AppState = .init(inExt: true)
23 |
24 | private var tagRidDict: [Int: String] = [:]
25 |
26 | let messager = Messager.shared
27 |
28 | var triggerManKind = FIMenuKind.contextualMenuForContainer
29 |
30 | override init() {
31 | super.init()
32 |
33 | FIFinderSyncController.default().directoryURLs = [myFolderURL]
34 | logger.info("FinderSync() launched from \(Bundle.main.bundlePath as NSString)")
35 |
36 | messager.on(name: "quit") { _ in
37 |
38 | self.isHostAppOpen = false
39 | }
40 | messager.on(name: "running") { payload in
41 |
42 | self.isHostAppOpen = true
43 |
44 | if payload.target.count > 0 {
45 | FIFinderSyncController.default().directoryURLs = Set(payload.target.map { URL(fileURLWithPath: $0) })
46 | }
47 | Task {
48 | self.appState.refresh()
49 | self.heartBeat()
50 | }
51 | }
52 |
53 | heartBeat()
54 | }
55 |
56 | func heartBeat() {
57 | logger.warning("start send message -- heartbeat")
58 | messager.sendMessage(name: Key.messageFromFinder, data: MessagePayload(action: "heartbeat", target: [], rid: ""))
59 | }
60 |
61 | // MARK: - Primary Finder Sync protocol methods
62 |
63 | override func beginObservingDirectory(at url: URL) {
64 | // The user is now seeing the container's contents.
65 | // If they see it in more than one view at a time, we're only told once.
66 | logger.info("beginObservingDirectoryAtURL: \(url.path as NSString)")
67 | let dirs = FIFinderSyncController.default().directoryURLs!
68 |
69 | for dir in dirs {
70 | logger.notice("Sync directory set to \(dir.path)")
71 | }
72 | }
73 |
74 | override func endObservingDirectory(at url: URL) {
75 | // The user is no longer seeing the container's contents.
76 | logger.info("endObservingDirectoryAtURL: \(url.path as NSString)")
77 | }
78 |
79 | override func requestBadgeIdentifier(for url: URL) {
80 | NSLog("requestBadgeIdentifierForURL: %@", url.path as NSString)
81 | }
82 |
83 | // MARK: - Menu and toolbar item support
84 |
85 | override var toolbarItemName: String {
86 | return "RClick"
87 | }
88 |
89 | override var toolbarItemToolTip: String {
90 | return "RClick: Click the toolbar item for a menu."
91 | }
92 |
93 | override var toolbarItemImage: NSImage {
94 | return NSImage(named: "toolbar")!
95 | }
96 |
97 | @MainActor override func menu(for menuKind: FIMenuKind) -> NSMenu {
98 | // Produce a menu for the extension.
99 | logger.info("mak menddd .....")
100 | triggerManKind = menuKind
101 | logger.info("start build menu ....")
102 | let applicationMenu = NSMenu(title: "RClick")
103 | guard isHostAppOpen else {
104 | return applicationMenu
105 | }
106 |
107 | switch menuKind {
108 | // finder 中没有选中文件或文件夹
109 |
110 | case .toolbarItemMenu, .contextualMenuForItems, .contextualMenuForContainer:
111 | logger.info("mak menddd .....")
112 | createMenuForToolbar(applicationMenu)
113 |
114 | default:
115 | logger.warning("not have menuKind ")
116 | }
117 |
118 | return applicationMenu
119 | }
120 |
121 | @objc func createMenuForToolbar(_ applicationMenu: NSMenu) {
122 | for nsmenu in createAppItems() {
123 | applicationMenu.addItem(nsmenu)
124 | }
125 |
126 | if let fileMenuItem = createFileCreateMenuItem() {
127 | applicationMenu.addItem(fileMenuItem)
128 | }
129 |
130 | if let commonDirMenuItem = createCommonDirMenuItem() {
131 | applicationMenu.addItem(commonDirMenuItem)
132 | }
133 |
134 | for item in createActionMenuItems() {
135 | applicationMenu.addItem(item)
136 | }
137 | }
138 |
139 | @objc func createAppItems() -> [NSMenuItem] {
140 | var appMenuItems: [NSMenuItem] = []
141 | //
142 | for item in appState.apps {
143 | let menuItem = NSMenuItem()
144 | menuItem.target = self
145 | menuItem.title = String(localized: "Open With \(item.name)")
146 | menuItem.action = #selector(appOpen(_:))
147 | menuItem.toolTip = "\(item.name)"
148 | menuItem.tag = getUniqueTag(for: item.id)
149 | menuItem.image = NSWorkspace.shared.icon(forFile: item.url.path)
150 | appMenuItems.append(menuItem)
151 | }
152 | return appMenuItems
153 | }
154 |
155 | private func getUniqueTag(for rid: String) -> Int {
156 | var newTag = Int.random(in: 1...Int.max)
157 |
158 | // 确保生成的 tag 不在已有的 keys 中
159 | while tagRidDict.keys.contains(newTag) {
160 | newTag = Int.random(in: 1...Int.max)
161 | }
162 | tagRidDict[newTag] = rid
163 | return newTag
164 | }
165 |
166 | @objc func createActionMenuItems() -> [NSMenuItem] {
167 | var actionMenuitems: [NSMenuItem] = []
168 |
169 | for item in appState.actions.filter(\.enabled) {
170 | let menuItem = NSMenuItem()
171 | menuItem.target = self
172 | menuItem.title = String(localized: String.LocalizationValue(item.name))
173 | menuItem.action = #selector(actioning(_:))
174 | menuItem.toolTip = "\(item.name)"
175 | menuItem.tag = getUniqueTag(for: item.id)
176 | menuItem.image = NSImage(systemSymbolName: item.icon, accessibilityDescription: item.name)!
177 |
178 | actionMenuitems.append(menuItem)
179 | }
180 | return actionMenuitems
181 | }
182 |
183 | // 创建文件菜单容器
184 | @objc func createCommonDirMenuItem() -> NSMenuItem? {
185 | let commonDirs = appState.cdirs
186 | if commonDirs.isEmpty {
187 | logger.warning("没有启用的常用文件夹")
188 | return nil
189 | }
190 | logger.info("开始创建常用文件夹菜单项")
191 |
192 | let menuItem = NSMenuItem()
193 | menuItem.title = String(localized: "Favorite Folders")
194 | menuItem.image = NSImage(systemSymbolName: "folder.badge.questionmark", accessibilityDescription: "folder.badge.questionmark")!
195 | let submenu = NSMenu(title: "Favorite Folders submenu")
196 |
197 | for dir in commonDirs {
198 | let menuItem = NSMenuItem()
199 | menuItem.target = self
200 | menuItem.title = dir.name
201 | menuItem.action = #selector(openCommonDir(_:))
202 | menuItem.toolTip = dir.url.path
203 | menuItem.tag = getUniqueTag(for: dir.id)
204 | menuItem.image = NSImage(systemSymbolName: "folder", accessibilityDescription: "folder")!
205 |
206 | submenu.addItem(menuItem)
207 | logger.info("添加常用文件夹菜单项: \(dir.name)")
208 | }
209 |
210 | menuItem.submenu = submenu
211 | logger.info("常用文件夹菜单创建完成")
212 | return menuItem
213 | }
214 |
215 | @MainActor @objc func openCommonDir(_ menuItem: NSMenuItem) {
216 | guard let rid = tagRidDict[menuItem.tag] else {
217 | logger.warning("未获取到rid")
218 | return
219 | }
220 | guard let dirItem = appState.cdirs.first(where: { $0.id == rid }) else {
221 | logger.warning("未找到对应的常用文件夹配置,rid: \(rid)")
222 | return
223 | }
224 |
225 | messager.sendMessage(name: Key.messageFromFinder, data: MessagePayload(action: "common-dirs", target: [dirItem.url.path], rid: dirItem.id))
226 | logger.info("已发送打开常用文件夹消息: \(dirItem.name), 路径: \(dirItem.url.path)")
227 | }
228 |
229 | @objc func createFileCreateMenuItem() -> NSMenuItem? {
230 | let enabledFiletypeItems = appState.newFiles.filter(\.enabled)
231 | if enabledFiletypeItems.isEmpty {
232 | return nil
233 | }
234 | let menuItem = NSMenuItem()
235 | menuItem.title = String(localized: "New File")
236 | menuItem.image = NSImage(systemSymbolName: "doc.badge.plus", accessibilityDescription: "doc.badge.plus")!
237 | let submenu = NSMenu(title: "file create menu")
238 | for item in enabledFiletypeItems {
239 | let menuItem = NSMenuItem()
240 | menuItem.target = self
241 | menuItem.title = item.name
242 | menuItem.action = #selector(createFile(_:))
243 | menuItem.toolTip = "\(item.name)"
244 | menuItem.tag = getUniqueTag(for: item.id)
245 |
246 | if let app = item.openApp {
247 | menuItem.image = NSWorkspace.shared.icon(forFile: app.path)
248 | menuItem.image?.isTemplate = true
249 | } else {
250 | if !item.icon.starts(with: "icon-") {
251 | menuItem.image = NSImage(systemSymbolName: item.icon, accessibilityDescription: item.icon)!
252 | } else {
253 | if let img = NSImage(named: item.icon) {
254 | menuItem.image = img
255 | menuItem.image?.isTemplate = true
256 | }
257 | }
258 | }
259 |
260 | submenu.addItem(menuItem)
261 | }
262 | menuItem.submenu = submenu
263 | return menuItem
264 | }
265 |
266 | @MainActor @objc func createFile(_ menuItem: NSMenuItem) {
267 | guard let rid = tagRidDict[menuItem.tag] else {
268 | logger.warning("not get rid for \(menuItem.tag)")
269 | return
270 | }
271 | let url = FIFinderSyncController.default().targetedURL()
272 |
273 | if let target = url?.path() {
274 | messager.sendMessage(name: Key.messageFromFinder, data: MessagePayload(action: "Create File", target: [target], rid: rid))
275 | }
276 | }
277 |
278 | @MainActor @objc func actioning(_ menuItem: NSMenuItem) {
279 | guard let rid = tagRidDict[menuItem.tag] else {
280 | logger.warning("not get rid")
281 | return
282 | }
283 | let target = getTargets(triggerManKind)
284 | let trigger = getTriggerKind(triggerManKind)
285 | if target.isEmpty {
286 | logger.warning("not dir when actioning")
287 | return
288 | }
289 | logger.info("actioning \(rid) , trigger:\(trigger)")
290 | messager.sendMessage(name: Key.messageFromFinder, data: MessagePayload(action: "actioning", target: target, rid: rid, trigger: trigger))
291 | }
292 |
293 | func getTargets(_ kind: FIMenuKind) -> [String] {
294 | var target: [String] = []
295 |
296 | switch triggerManKind {
297 | case FIMenuKind.contextualMenuForItems:
298 | if let urls = FIFinderSyncController.default().selectedItemURLs() {
299 | for url in urls {
300 | target.append(url.path())
301 | }
302 | } else {
303 | logger.warning("not have selected dirs")
304 | }
305 |
306 | case FIMenuKind.toolbarItemMenu:
307 | if let urls = FIFinderSyncController.default().selectedItemURLs() {
308 | for url in urls {
309 | target.append(url.path())
310 | }
311 | }
312 | if target.isEmpty {
313 | if let targetURL = FIFinderSyncController.default().targetedURL() {
314 | target.append(targetURL.path())
315 | }
316 | }
317 |
318 | default:
319 | if let targetURL = FIFinderSyncController.default().targetedURL() {
320 | target.append(targetURL.path())
321 | }
322 | }
323 |
324 | return target
325 | }
326 |
327 | @objc func appOpen(_ menuItem: NSMenuItem) {
328 | guard let rid = tagRidDict[menuItem.tag] else {
329 | logger.warning("not get rid")
330 | return
331 | }
332 |
333 | let target: [String] = getTargets(triggerManKind)
334 | if !target.isEmpty {
335 | messager.sendMessage(name: Key.messageFromFinder, data: MessagePayload(action: "open", target: target, rid: rid))
336 | } else {
337 | logger.warning("not get target")
338 | }
339 | }
340 |
341 | @objc func getTriggerKind(_ kind: FIMenuKind) -> String {
342 | switch kind {
343 | case .contextualMenuForItems:
344 | return "ctx-items"
345 | case .contextualMenuForContainer:
346 | return "ctx-container"
347 | case .contextualMenuForSidebar:
348 | return "ctx-sidebar"
349 | case .toolbarItemMenu:
350 | return "toolbar"
351 | default:
352 | return "unknown"
353 | }
354 | }
355 | }
356 |
--------------------------------------------------------------------------------
/RClick/Localizable.xcstrings:
--------------------------------------------------------------------------------
1 | {
2 | "sourceLanguage" : "en",
3 | "strings" : {
4 | "" : {
5 | "localizations" : {
6 | "zh-Hans" : {
7 | "stringUnit" : {
8 | "state" : "translated",
9 | "value" : ""
10 | }
11 | }
12 | }
13 | },
14 | "%@" : {
15 |
16 | },
17 | "%@(%@)" : {
18 | "localizations" : {
19 | "en" : {
20 | "stringUnit" : {
21 | "state" : "new",
22 | "value" : "%1$@(%2$@)"
23 | }
24 | },
25 | "zh-Hans" : {
26 | "stringUnit" : {
27 | "state" : "translated",
28 | "value" : "%1$@(%2$@)"
29 | }
30 | }
31 | }
32 | },
33 | "%@=%@" : {
34 | "localizations" : {
35 | "en" : {
36 | "stringUnit" : {
37 | "state" : "new",
38 | "value" : "%1$@=%2$@"
39 | }
40 | }
41 | }
42 | },
43 | "About" : {
44 | "extractionState" : "manual",
45 | "localizations" : {
46 | "en" : {
47 | "stringUnit" : {
48 | "state" : "translated",
49 | "value" : "About"
50 | }
51 | },
52 | "zh-Hans" : {
53 | "stringUnit" : {
54 | "state" : "translated",
55 | "value" : "关于"
56 | }
57 | }
58 | }
59 | },
60 | "Actions" : {
61 | "extractionState" : "manual",
62 | "localizations" : {
63 | "zh-Hans" : {
64 | "stringUnit" : {
65 | "state" : "translated",
66 | "value" : "操作"
67 | }
68 | }
69 | }
70 | },
71 | "Add" : {
72 | "localizations" : {
73 | "en" : {
74 | "stringUnit" : {
75 | "state" : "translated",
76 | "value" : "Add"
77 | }
78 | },
79 | "zh-Hans" : {
80 | "stringUnit" : {
81 | "state" : "translated",
82 | "value" : "添加"
83 | }
84 | }
85 | }
86 | },
87 | "Add New File Type" : {
88 | "localizations" : {
89 | "zh-Hans" : {
90 | "stringUnit" : {
91 | "state" : "translated",
92 | "value" : "添加新建文件类型"
93 | }
94 | }
95 | }
96 | },
97 | "App Icon Show" : {
98 | "localizations" : {
99 | "zh-Hans" : {
100 | "stringUnit" : {
101 | "state" : "translated",
102 | "value" : "App 图标展示"
103 | }
104 | }
105 | }
106 | },
107 | "Apps" : {
108 | "extractionState" : "manual",
109 | "localizations" : {
110 | "zh-Hans" : {
111 | "stringUnit" : {
112 | "state" : "translated",
113 | "value" : "菜单App"
114 | }
115 | }
116 | }
117 | },
118 | "Arguments" : {
119 | "localizations" : {
120 | "zh-Hans" : {
121 | "stringUnit" : {
122 | "state" : "translated",
123 | "value" : "参数"
124 | }
125 | }
126 | }
127 | },
128 | "Arguments:" : {
129 |
130 | },
131 | "Authorization folder" : {
132 | "localizations" : {
133 | "zh-Hans" : {
134 | "stringUnit" : {
135 | "state" : "translated",
136 | "value" : "授权的文件夹"
137 | }
138 | }
139 | }
140 | },
141 | "Cancel" : {
142 | "localizations" : {
143 | "zh-Hans" : {
144 | "stringUnit" : {
145 | "state" : "translated",
146 | "value" : "取消"
147 | }
148 | }
149 | }
150 | },
151 | "Change App" : {
152 |
153 | },
154 | "Change Template" : {
155 |
156 | },
157 | "Common Dir" : {
158 | "extractionState" : "manual",
159 | "localizations" : {
160 | "zh-Hans" : {
161 | "stringUnit" : {
162 | "state" : "translated",
163 | "value" : "常用文件夹"
164 | }
165 | }
166 | }
167 | },
168 | "Common Folders" : {
169 | "localizations" : {
170 | "zh-Hans" : {
171 | "stringUnit" : {
172 | "state" : "translated",
173 | "value" : "常用文件夹"
174 | }
175 | }
176 | }
177 | },
178 | "Copy Path" : {
179 | "extractionState" : "manual",
180 | "localizations" : {
181 | "en" : {
182 | "stringUnit" : {
183 | "state" : "translated",
184 | "value" : "Copy Path"
185 | }
186 | },
187 | "zh-Hans" : {
188 | "stringUnit" : {
189 | "state" : "translated",
190 | "value" : "复制路径"
191 | }
192 | }
193 | }
194 | },
195 | "Default Open App" : {
196 | "localizations" : {
197 | "zh-Hans" : {
198 | "stringUnit" : {
199 | "state" : "translated",
200 | "value" : "默认打开 App"
201 | }
202 | }
203 | }
204 | },
205 | "Delete Direct" : {
206 | "extractionState" : "manual",
207 | "localizations" : {
208 | "en" : {
209 | "stringUnit" : {
210 | "state" : "translated",
211 | "value" : "Delete Direct"
212 | }
213 | },
214 | "zh-Hans" : {
215 | "stringUnit" : {
216 | "state" : "translated",
217 | "value" : "直接删除"
218 | }
219 | }
220 | }
221 | },
222 | "Display Name" : {
223 | "localizations" : {
224 | "zh-Hans" : {
225 | "stringUnit" : {
226 | "state" : "translated",
227 | "value" : "显示名称"
228 | }
229 | }
230 | }
231 | },
232 | "Edit App Properties" : {
233 | "localizations" : {
234 | "zh-Hans" : {
235 | "stringUnit" : {
236 | "state" : "translated",
237 | "value" : "编辑App 属性"
238 | }
239 | }
240 | }
241 | },
242 | "Edit File Type" : {
243 |
244 | },
245 | "Enable extension" : {
246 | "localizations" : {
247 | "zh-Hans" : {
248 | "stringUnit" : {
249 | "state" : "translated",
250 | "value" : "启用扩展"
251 | }
252 | }
253 | }
254 | },
255 | "Environment Variables" : {
256 | "localizations" : {
257 | "zh-Hans" : {
258 | "stringUnit" : {
259 | "state" : "translated",
260 | "value" : "环境变量"
261 | }
262 | }
263 | }
264 | },
265 | "Environment:" : {
266 |
267 | },
268 | "Extension" : {
269 |
270 | },
271 | "Favorite Folders" : {
272 | "localizations" : {
273 | "zh-Hans" : {
274 | "stringUnit" : {
275 | "state" : "translated",
276 | "value" : "常用文件夹"
277 | }
278 | }
279 | }
280 | },
281 | "File Extension (e.g., .txt)" : {
282 |
283 | },
284 | "Format: KEY=VALUE, one per line" : {
285 |
286 | },
287 | "General" : {
288 | "extractionState" : "manual",
289 | "localizations" : {
290 | "zh-Hans" : {
291 | "stringUnit" : {
292 | "state" : "translated",
293 | "value" : "通用"
294 | }
295 | }
296 | }
297 | },
298 | "Hide" : {
299 | "extractionState" : "manual",
300 | "localizations" : {
301 | "zh-Hans" : {
302 | "stringUnit" : {
303 | "state" : "translated",
304 | "value" : "隐藏"
305 | }
306 | }
307 | }
308 | },
309 | "Icon" : {
310 |
311 | },
312 | "Invalid Folder Selection" : {
313 | "localizations" : {
314 | "zh-Hans" : {
315 | "stringUnit" : {
316 | "state" : "translated",
317 | "value" : "无效的文件夹选择"
318 | }
319 | }
320 | }
321 | },
322 | "Launch at login" : {
323 | "localizations" : {
324 | "zh-Hans" : {
325 | "stringUnit" : {
326 | "state" : "translated",
327 | "value" : "登陆时启动"
328 | }
329 | }
330 | }
331 | },
332 | "Name" : {
333 |
334 | },
335 | "New File" : {
336 | "localizations" : {
337 | "zh-Hans" : {
338 | "stringUnit" : {
339 | "state" : "translated",
340 | "value" : "新建文件"
341 | }
342 | }
343 | }
344 | },
345 | "Not Authorized Folder" : {
346 | "localizations" : {
347 | "zh-Hans" : {
348 | "stringUnit" : {
349 | "state" : "translated",
350 | "value" : "无授权文件夹"
351 | }
352 | }
353 | }
354 | },
355 | "OK" : {
356 | "localizations" : {
357 | "zh-Hans" : {
358 | "stringUnit" : {
359 | "state" : "translated",
360 | "value" : "确认"
361 | }
362 | }
363 | }
364 | },
365 | "One argument per semicolon (;)" : {
366 |
367 | },
368 | "Open Settings" : {
369 | "localizations" : {
370 | "zh-Hans" : {
371 | "stringUnit" : {
372 | "state" : "translated",
373 | "value" : "打开设置"
374 | }
375 | }
376 | }
377 | },
378 | "Open With %@" : {
379 | "localizations" : {
380 | "zh-Hans" : {
381 | "stringUnit" : {
382 | "state" : "translated",
383 | "value" : "用 %@ 打开"
384 | }
385 | }
386 | }
387 | },
388 | "Preview:" : {
389 |
390 | },
391 | "Quick access to frequently used folders" : {
392 |
393 | },
394 | "Quit" : {
395 | "localizations" : {
396 | "en" : {
397 | "stringUnit" : {
398 | "state" : "translated",
399 | "value" : "Quit"
400 | }
401 | },
402 | "zh-Hans" : {
403 | "stringUnit" : {
404 | "state" : "translated",
405 | "value" : "停止"
406 | }
407 | }
408 | }
409 | },
410 | "RClick" : {
411 | "localizations" : {
412 | "en" : {
413 | "stringUnit" : {
414 | "state" : "translated",
415 | "value" : "RClick"
416 | }
417 | },
418 | "zh-Hans" : {
419 | "stringUnit" : {
420 | "state" : "translated",
421 | "value" : "RClick"
422 | }
423 | }
424 | }
425 | },
426 | "RClick is a right-click menu extension that allows you to add applications for opening folders and includes some common actions!" : {
427 | "localizations" : {
428 | "zh-Hans" : {
429 | "stringUnit" : {
430 | "state" : "translated",
431 | "value" : "RClick 是一个右键菜单拓展,可以添加打开文件夹的应用,可以添加一些常用的操作!"
432 | }
433 | }
434 | }
435 | },
436 | "Reset" : {
437 | "localizations" : {
438 | "zh-Hans" : {
439 | "stringUnit" : {
440 | "state" : "translated",
441 | "value" : "重置"
442 | }
443 | }
444 | }
445 | },
446 | "Save" : {
447 | "localizations" : {
448 | "zh-Hans" : {
449 | "stringUnit" : {
450 | "state" : "translated",
451 | "value" : "保存"
452 | }
453 | }
454 | }
455 | },
456 | "Select App" : {
457 | "localizations" : {
458 | "zh-Hans" : {
459 | "stringUnit" : {
460 | "state" : "translated",
461 | "value" : "选择App"
462 | }
463 | }
464 | }
465 | },
466 | "Select Template" : {
467 |
468 | },
469 | "Settings" : {
470 | "localizations" : {
471 | "en" : {
472 | "stringUnit" : {
473 | "state" : "translated",
474 | "value" : "Settings"
475 | }
476 | },
477 | "zh-Hans" : {
478 | "stringUnit" : {
479 | "state" : "translated",
480 | "value" : "设置"
481 | }
482 | }
483 | }
484 | },
485 | "SF Symbol name or custom icon" : {
486 |
487 | },
488 | "Show in dock" : {
489 | "localizations" : {
490 | "zh-Hans" : {
491 | "stringUnit" : {
492 | "state" : "translated",
493 | "value" : "在 Dock 栏显示"
494 | }
495 | }
496 | }
497 | },
498 | "Show in menu bar" : {
499 | "localizations" : {
500 | "zh-Hans" : {
501 | "stringUnit" : {
502 | "state" : "translated",
503 | "value" : "在菜单栏显示"
504 | }
505 | }
506 | }
507 | },
508 | "Template" : {
509 |
510 | },
511 | "The operation of the menu can only be executed in authorized folders" : {
512 | "localizations" : {
513 | "zh-Hans" : {
514 | "stringUnit" : {
515 | "state" : "translated",
516 | "value" : "菜单中的操作在授权文件中才能执行"
517 | }
518 | }
519 | }
520 | },
521 | "The RClick extension needs to be enabled for it to work properly" : {
522 | "localizations" : {
523 | "zh-Hans" : {
524 | "stringUnit" : {
525 | "state" : "translated",
526 | "value" : "RClick 需要启用 扩展才能正常工作"
527 | }
528 | }
529 | }
530 | },
531 | "The selected folder is a subdirectory of the previously chosen folder. Please select a different folder." : {
532 | "localizations" : {
533 | "zh-Hans" : {
534 | "stringUnit" : {
535 | "state" : "translated",
536 | "value" : "选择的文件夹已经包含在已授权的文件夹中"
537 | }
538 | }
539 | }
540 | },
541 | "Unhide" : {
542 | "extractionState" : "manual",
543 | "localizations" : {
544 | "zh-Hans" : {
545 | "stringUnit" : {
546 | "state" : "translated",
547 | "value" : "显示"
548 | }
549 | }
550 | }
551 | },
552 | "Untitled" : {
553 | "localizations" : {
554 | "zh-Hans" : {
555 | "stringUnit" : {
556 | "state" : "translated",
557 | "value" : "未命名"
558 | }
559 | }
560 | }
561 | },
562 | "Want to add a feature? Give feedback here." : {
563 | "extractionState" : "manual",
564 | "localizations" : {
565 | "zh-Hans" : {
566 | "stringUnit" : {
567 | "state" : "translated",
568 | "value" : "需要添加新功能,在这里反馈"
569 | }
570 | }
571 | }
572 | },
573 | "You must grant access to the folder to use this feature." : {
574 | "localizations" : {
575 | "en" : {
576 | "stringUnit" : {
577 | "state" : "translated",
578 | "value" : "You must grant access to the folder to use this feature."
579 | }
580 | },
581 | "zh-Hans" : {
582 | "stringUnit" : {
583 | "state" : "translated",
584 | "value" : "你必须首选最少一个文件夹才能使用"
585 | }
586 | }
587 | }
588 | },
589 | "下载并安装" : {
590 |
591 | },
592 | "发现新版本" : {
593 |
594 | },
595 | "后缀:" : {
596 |
597 | },
598 | "已是最新版本" : {
599 |
600 | },
601 | "当前版本已是最新,无需更新。" : {
602 |
603 | },
604 | "忽略此版本" : {
605 |
606 | },
607 | "手动下载" : {
608 |
609 | },
610 | "检查更新" : {
611 |
612 | },
613 | "检查更新失败" : {
614 |
615 | },
616 | "模板路径:%@" : {
617 |
618 | },
619 | "模版:" : {
620 |
621 | },
622 | "正在检查更新..." : {
623 |
624 | },
625 | "版本 %@" : {
626 |
627 | },
628 | "确定" : {
629 |
630 | }
631 | },
632 | "version" : "1.0"
633 | }
--------------------------------------------------------------------------------
/RClick/Shared/Updater.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Updater.swift
3 | // RClick
4 | //
5 | // Created by 李旭 on 2025/9/21.
6 | //
7 |
8 | import Foundation
9 | import SwiftUI
10 |
11 | // MARK: - 数据模型
12 |
13 | struct GitHubRelease: Codable, Identifiable {
14 | let id: Int
15 | let tagName: String
16 | let name: String
17 | let body: String
18 | let draft: Bool
19 | let prerelease: Bool
20 | let publishedAt: Date
21 | let assets: [Asset]
22 | let htmlUrl: String
23 |
24 | var version: String {
25 | tagName.replacingOccurrences(of: "v", with: "")
26 | }
27 |
28 | struct Asset: Codable {
29 | let id: Int
30 | let name: String
31 | let browserDownloadUrl: String
32 | let size: Int
33 | let contentType: String?
34 |
35 | enum CodingKeys: String, CodingKey {
36 | case id, name, size
37 | case browserDownloadUrl = "browser_download_url"
38 | case contentType = "content_type"
39 | }
40 | }
41 |
42 | enum CodingKeys: String, CodingKey {
43 | case id
44 | case tagName = "tag_name"
45 | case name, body, draft, prerelease, assets
46 | case publishedAt = "published_at"
47 | case htmlUrl = "html_url"
48 | }
49 | }
50 |
51 | // MARK: - 用户偏好设置
52 |
53 | class UpdatePreferences: ObservableObject {
54 | @AppStorage("ignoredVersion") private var ignoredVersionData: Data = .init()
55 |
56 | // 获取忽略的版本列表
57 | var ignoredVersions: [String] {
58 | get {
59 | do {
60 | return try JSONDecoder().decode([String].self, from: ignoredVersionData)
61 | } catch {
62 | return []
63 | }
64 | }
65 | set {
66 | do {
67 | ignoredVersionData = try JSONEncoder().encode(newValue)
68 | } catch {
69 | print("Failed to save ignored versions: \(error)")
70 | }
71 | }
72 | }
73 |
74 | // 忽略特定版本
75 | func ignoreVersion(_ version: String) {
76 | var ignored = ignoredVersions
77 | if !ignored.contains(version) {
78 | ignored.append(version)
79 | ignoredVersions = ignored
80 | }
81 | }
82 |
83 | // 检查版本是否被忽略
84 | func isVersionIgnored(_ version: String) -> Bool {
85 | ignoredVersions.contains(version)
86 | }
87 | }
88 |
89 | // MARK: - GitHub API 服务
90 |
91 | class GitHubReleaseChecker {
92 | private let owner: String
93 | private let repo: String
94 |
95 | init(owner: String, repo: String) {
96 | self.owner = owner
97 | self.repo = repo
98 | }
99 |
100 | // 获取最新release
101 | func fetchLatestRelease() async throws -> GitHubRelease {
102 | let url = URL(string: "https://api.github.com/repos/\(owner)/\(repo)/releases/latest")!
103 | print(url)
104 | var request = URLRequest(url: url)
105 | request.setValue("application/vnd.github.v3+json", forHTTPHeaderField: "Accept")
106 |
107 | let (data, response) = try await URLSession.shared.data(for: request)
108 |
109 | guard let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode == 200 else {
110 | throw URLError(.badServerResponse)
111 | }
112 |
113 | let decoder = JSONDecoder()
114 | decoder.dateDecodingStrategy = .iso8601
115 | return try decoder.decode(GitHubRelease.self, from: data)
116 | }
117 |
118 | // 检查是否需要更新
119 | func checkForUpdate(currentVersion: String, includePrereleases: Bool = false) async -> GitHubRelease? {
120 | print(currentVersion)
121 | do {
122 | let latestRelease = try await fetchLatestRelease()
123 |
124 | // 跳过草稿版和预发布版(除非明确包含)
125 | if latestRelease.draft || (!includePrereleases && latestRelease.prerelease) {
126 | return nil
127 | }
128 |
129 | // 比较版本
130 | if compareVersions(currentVersion, latestRelease.version) == .orderedAscending {
131 | return latestRelease
132 | } else {
133 | print("the last verison \(latestRelease.version)")
134 | }
135 | } catch {
136 | print("检查更新失败: \(error)")
137 | }
138 |
139 | return nil
140 | }
141 |
142 | // 语义化版本比较
143 | private func compareVersions(_ version1: String, _ version2: String) -> ComparisonResult {
144 | let components1 = version1.components(separatedBy: ".")
145 | let components2 = version2.components(separatedBy: ".")
146 |
147 | for i in 0 ..< max(components1.count, components2.count) {
148 | let part1 = i < components1.count ? components1[i] : "0"
149 | let part2 = i < components2.count ? components2[i] : "0"
150 |
151 | if let num1 = Int(part1), let num2 = Int(part2) {
152 | if num1 < num2 { return .orderedAscending }
153 | if num1 > num2 { return .orderedDescending }
154 | } else {
155 | // 处理非数字部分(如beta、rc等)
156 | let comparison = part1.compare(part2)
157 | if comparison != .orderedSame {
158 | return comparison
159 | }
160 | }
161 | }
162 |
163 | return .orderedSame
164 | }
165 | }
166 |
167 | // MARK: - 更新管理器
168 |
169 | @MainActor
170 | class UpdateManager: ObservableObject {
171 | @Published var availableUpdate: GitHubRelease?
172 | @Published var isChecking = false
173 | @Published var updateError: String?
174 | @Published var isDownloading = false
175 | @Published var downloadProgress: Double = 0
176 | @Published var showUpdateSheet = false
177 |
178 | private let githubChecker: GitHubReleaseChecker
179 | private let preferences: UpdatePreferences
180 | private let currentVersion: String
181 |
182 | init(owner: String, repo: String, currentVersion: String) {
183 | self.githubChecker = GitHubReleaseChecker(owner: owner, repo: repo)
184 | self.preferences = UpdatePreferences()
185 | self.currentVersion = currentVersion
186 | }
187 |
188 | // 关闭更新提示
189 | func dismissUpdateSheet() {
190 | showUpdateSheet = false
191 | availableUpdate = nil
192 | updateError = nil
193 | }
194 |
195 | // 检查更新
196 | func checkForUpdates(force: Bool = false) async {
197 | isChecking = true
198 | updateError = nil
199 | showUpdateSheet = true
200 |
201 | defer { isChecking = false }
202 |
203 | guard let release = await githubChecker.checkForUpdate(currentVersion: currentVersion) else {
204 | print("not release")
205 | updateError = "当前已经是最新版本"
206 | return
207 | }
208 |
209 | // 检查用户是否忽略了此版本
210 | if !force && preferences.isVersionIgnored(release.version) {
211 | print("忽略这个版本")
212 | updateError = "已忽略版本 \(release.version)"
213 | return
214 | }
215 |
216 | availableUpdate = release
217 | }
218 |
219 | // MARK: - 下载和安装方法
220 |
221 | func downloadAndInstallUpdate() async {
222 | print("start downloadAndInstallUpdate")
223 | guard let release = availableUpdate else {
224 | updateError = "没有可用的更新"
225 | print("没有可用的更新")
226 | return
227 | }
228 |
229 | // 查找 .app.zip 附件
230 | guard let appZipAsset = release.assets.first(where: { $0.name.lowercased().hasSuffix(".app.zip") }) else {
231 | updateError = "未找到 .app.zip 格式的应用程序包"
232 | print("没有可用的更新")
233 | return
234 | }
235 |
236 | isDownloading = true
237 | downloadProgress = 0
238 |
239 | do {
240 | // 1. 下载 ZIP 文件
241 | let downloadedURL = try await downloadAsset(asset: appZipAsset)
242 |
243 | // 2. 解压到临时目录
244 | let appURL = try await extractAppZip(zipURL: downloadedURL)
245 |
246 | // 3. 安装应用到应用程序目录
247 | try await installApplication(appURL: appURL)
248 |
249 | // 4. 清理临时文件
250 | try? FileManager.default.removeItem(at: downloadedURL)
251 | try? FileManager.default.removeItem(at: appURL.deletingLastPathComponent())
252 |
253 | // 5. 提示用户安装完成
254 | showInstallationCompleteAlert()
255 |
256 | } catch {
257 | updateError = "安装失败: \(error.localizedDescription)"
258 | }
259 |
260 | isDownloading = false
261 | }
262 |
263 | func downloadAsset(asset: GitHubRelease.Asset) async throws -> URL {
264 | print("start downloadAsset:\(asset.browserDownloadUrl)")
265 | let tempDir = FileManager.default.temporaryDirectory
266 | let downloadURL = tempDir.appendingPathComponent(asset.name)
267 |
268 | var request = URLRequest(url: URL(string: asset.browserDownloadUrl)!)
269 | request.setValue("application/octet-stream", forHTTPHeaderField: "Accept")
270 |
271 | // 使用 AsyncThrowingStream 来包装下载进度和结果
272 | return try await withCheckedThrowingContinuation { continuation in
273 | // Stream bytes and write to destination file
274 | let session = URLSession(configuration: .default, delegate: nil, delegateQueue: nil)
275 | let task = session.downloadTask(with: request) { tempURL, response, error in
276 |
277 | print("start do")
278 | if let error = error {
279 | print("downn error")
280 | continuation.resume(throwing: error)
281 | return
282 | }
283 |
284 | guard let tempURL = tempURL,
285 | let httpResponse = response as? HTTPURLResponse,
286 | httpResponse.statusCode == 200
287 | else {
288 | continuation.resume(throwing: DownloadError.downloadFailed("下载失败"))
289 | print("downn error")
290 | return
291 | }
292 |
293 | do {
294 | // 移动文件到目标位置
295 | try? FileManager.default.removeItem(at: downloadURL)
296 | try FileManager.default.moveItem(at: tempURL, to: downloadURL)
297 | print("download url: \(downloadURL.path)")
298 | continuation.resume(returning: downloadURL)
299 | } catch {
300 | continuation.resume(throwing: error)
301 | }
302 | }
303 |
304 | task.resume()
305 | }
306 | }
307 |
308 | // 关联对象键
309 | private var DownloadDelegateKey: UInt8 = 0
310 |
311 | // MARK: - 解压 APP Zip 文件
312 |
313 | private func extractAppZip(zipURL: URL) async throws -> URL {
314 | let tempDir = FileManager.default.temporaryDirectory
315 | let extractionDir = tempDir.appendingPathComponent("app_extraction")
316 |
317 | // 创建解压目录
318 | try FileManager.default.createDirectory(at: extractionDir, withIntermediateDirectories: true)
319 |
320 | // 使用系统命令解压
321 | let process = Process()
322 | process.executableURL = URL(fileURLWithPath: "/usr/bin/unzip")
323 | process.arguments = ["-o", zipURL.path, "-d", extractionDir.path]
324 |
325 | let outputPipe = Pipe()
326 | let errorPipe = Pipe()
327 | process.standardOutput = outputPipe
328 | process.standardError = errorPipe
329 |
330 | try process.run()
331 | process.waitUntilExit()
332 |
333 | guard process.terminationStatus == 0 else {
334 | let errorData = errorPipe.fileHandleForReading.readDataToEndOfFile()
335 | let errorString = String(data: errorData, encoding: .utf8) ?? "未知错误"
336 | throw InstallationError.zipExtractionFailed("解压失败: \(errorString)")
337 | }
338 |
339 | // 查找解压后的 .app 文件
340 | let fileManager = FileManager.default
341 | let contents = try fileManager.contentsOfDirectory(at: extractionDir, includingPropertiesForKeys: nil)
342 |
343 | guard let appURL = contents.first(where: { $0.pathExtension == "app" }) else {
344 | throw InstallationError.noAppFound("在ZIP文件中未找到.app应用程序")
345 | }
346 |
347 | return appURL
348 | }
349 |
350 | // MARK: - 安装应用到应用程序目录
351 |
352 | private func installApplication(appURL: URL) async throws {
353 | let fileManager = FileManager.default
354 | let applicationsURL = fileManager.urls(for: .applicationDirectory, in: .localDomainMask).first!
355 | let destinationAppURL = applicationsURL.appendingPathComponent(appURL.lastPathComponent)
356 | print("start install \(appURL.path) --- \(destinationAppURL.path)")
357 | do {
358 | // 检查目标位置是否已存在应用
359 | if fileManager.fileExists(atPath: destinationAppURL.path) {
360 | // 先尝试移动到废纸篓而不是直接删除
361 | try fileManager.trashItem(at: destinationAppURL, resultingItemURL: nil)
362 | }
363 |
364 | // 复制应用到应用程序目录
365 | try fileManager.copyItem(at: appURL, to: destinationAppURL)
366 |
367 | // 验证应用程序是否有效
368 | guard Bundle(url: destinationAppURL) != nil else {
369 | // try fileManager.removeItem(at: destinationAppURL)
370 | throw InstallationError.invalidAppBundle("应用程序包无效或损坏")
371 | }
372 | } catch {
373 | print("❌ 安装失败: \(error)")
374 | }
375 |
376 | }
377 |
378 | // MARK: - 显示安装完成提示
379 |
380 | private func showInstallationCompleteAlert() {
381 | let alert = NSAlert()
382 | alert.messageText = "更新安装完成"
383 | alert.informativeText = "应用程序已成功更新。需要重启应用来完成更新过程。"
384 | alert.addButton(withTitle: "立即重启")
385 | alert.addButton(withTitle: "稍后重启")
386 |
387 | let response = alert.runModal()
388 | if response == .alertFirstButtonReturn {
389 | // 启动新应用并退出当前应用
390 | launchNewApplicationAndExit()
391 | }
392 | }
393 |
394 | // MARK: - 启动新应用并退出
395 |
396 | private func launchNewApplicationAndExit() {
397 | let fileManager = FileManager.default
398 | let applicationsURL = fileManager.urls(for: .applicationDirectory, in: .localDomainMask).first!
399 | let currentAppName = Bundle.main.object(forInfoDictionaryKey: "CFBundleName") as? String ?? "RClick"
400 | let newAppURL = applicationsURL.appendingPathComponent("\(currentAppName).app")
401 |
402 | let configuration = NSWorkspace.OpenConfiguration()
403 | NSWorkspace.shared.openApplication(at: newAppURL, configuration: configuration) { _, error in
404 | if error != nil {
405 | print("启动新应用失败,可能需要手动启动")
406 | }
407 | // 无论如何都退出当前应用
408 | NSApp.terminate(nil)
409 | }
410 | }
411 |
412 | // 忽略当前可用更新
413 | func ignoreCurrentUpdate() {
414 | if let version = availableUpdate?.version {
415 | preferences.ignoreVersion(version)
416 | availableUpdate = nil
417 | }
418 | }
419 |
420 | // 打开GitHub发布页面
421 | func openReleasesPage() {
422 | if let url = URL(string: "https://github.com/wflixu/RClick/releases") {
423 | NSWorkspace.shared.open(url)
424 | }
425 | }
426 |
427 | // MARK: - 错误类型
428 |
429 | enum DownloadError: LocalizedError {
430 | case downloadFailed(String)
431 |
432 | var errorDescription: String? {
433 | switch self {
434 | case .downloadFailed(let message):
435 | return message
436 | }
437 | }
438 | }
439 |
440 | enum InstallationError: LocalizedError {
441 | case zipExtractionFailed(String)
442 | case noAppFound(String)
443 | case invalidAppBundle(String)
444 |
445 | var errorDescription: String? {
446 | switch self {
447 | case .zipExtractionFailed(let message):
448 | return message
449 | case .noAppFound(let message):
450 | return message
451 | case .invalidAppBundle(let message):
452 | return message
453 | }
454 | }
455 | }
456 | }
457 |
--------------------------------------------------------------------------------
/RClick/Settings/NewFileSettingsTabView.swift:
--------------------------------------------------------------------------------
1 | //
2 | // NewFileSettingsTabView.swift
3 | // RClick
4 | //
5 | // Created by 李旭 on 2024/11/18.
6 | //
7 |
8 | import AppKit
9 | import SwiftUI
10 | import UniformTypeIdentifiers
11 |
12 | struct NewFileSettingsTabView: View {
13 | @AppLog(category: "NewFileSettingsTabView")
14 | private var logger
15 |
16 | @EnvironmentObject var appState: AppState
17 | @State private var editingFile: NewFile?
18 | @State private var showSelectApp = false
19 |
20 | // 编辑状态
21 | @State private var editingName: String = ""
22 | @State private var editingExt: String = ""
23 | @State private var editingIcon: String = "document"
24 | @State private var editingOpenApp: URL?
25 | // 添加状态变量
26 | @State private var editingTemplate: URL?
27 | @State private var showSelectTemplate = false
28 |
29 | // 新建状态
30 | @State private var isAddingNew = false
31 |
32 | let messager = Messager.shared
33 | // 优化后的存储路径选择
34 | let templatesDir: URL? = // 选项1: 应用程序支持目录(推荐)
35 | FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first?
36 | .appendingPathComponent("RClick/Templates")
37 |
38 | var body: some View {
39 | ZStack {
40 | VStack {
41 | HStack {
42 | Spacer()
43 | Button {
44 | isAddingNew = true
45 | editingFile = NewFile(ext: "", name: "", idx: appState.newFiles.count)
46 | resetEditingFields()
47 | } label: {
48 | Label("Add", systemImage: "plus")
49 | .font(.body)
50 | }
51 | Button {
52 | appState.resetFiletypeItems()
53 | } label: {
54 | Label("Reset", systemImage: "arrow.triangle.2.circlepath")
55 | .font(.body)
56 | }
57 | }
58 | // TODO: 编辑 Button 和 Toggle 放在列表的右边
59 | List {
60 | ForEach($appState.newFiles) { $item in
61 | HStack(spacing: 12) {
62 | // 左侧图标和名称
63 | HStack(spacing: 8) {
64 | // 图标显示逻辑
65 | if let appUrl = item.openApp {
66 | Image(nsImage: NSWorkspace.shared.icon(forFile: appUrl.path()))
67 | .resizable()
68 | .aspectRatio(contentMode: .fit)
69 | .frame(width: 32, height: 32)
70 | } else {
71 | if item.icon.starts(with: "icon-") {
72 | Image(item.icon)
73 | .resizable()
74 | .aspectRatio(contentMode: .fit)
75 | .frame(width: 20, height: 20)
76 | } else {
77 | Image(systemName: item.icon)
78 | .resizable()
79 | .aspectRatio(contentMode: .fit)
80 | .frame(width: 20, height: 20)
81 | }
82 | }
83 |
84 | HStack(alignment: .center) {
85 | Text(item.name).font(.title3)
86 | }
87 | VStack(alignment: .leading) {
88 | HStack {
89 | Text("后缀:")
90 | Text(item.ext)
91 | }
92 |
93 | if let templateUrl = item.template {
94 | HStack(spacing: 4) {
95 | Text("模版:")
96 | Text(templateUrl.lastPathComponent) // 文件名
97 | Image(systemName: "info.circle") // 提示图标
98 | .foregroundColor(.secondary)
99 | .font(.system(size: 12))
100 | .help("模板路径:\(templateUrl.path)") // 图标悬停提示
101 | }
102 | }
103 | }
104 | .font(.system(size: 14))
105 | .foregroundColor(.secondary)
106 | }
107 |
108 | Spacer()
109 |
110 | // 右侧按钮组
111 | HStack(spacing: 16) {
112 | Button {
113 | editingFile = item
114 | editingName = item.name
115 | editingExt = item.ext
116 | editingIcon = item.icon
117 | editingOpenApp = item.openApp
118 | editingTemplate = item.template
119 | } label: {
120 | Image(systemName: "pencil")
121 | .font(.system(size: 14))
122 | .frame(width: 24, height: 24)
123 | }
124 | .buttonStyle(.plain)
125 |
126 | Toggle("", isOn: $item.enabled)
127 | .onChange(of: item.enabled) {
128 | appState.toggleActionItem()
129 | messager.sendMessage(name: "running", data: MessagePayload(action: "running", target: []))
130 | }
131 | .toggleStyle(.switch)
132 | .frame(width: 50)
133 | }
134 | .padding(.trailing, 4)
135 | }
136 | .padding(.vertical, 8)
137 | }
138 | }
139 | }
140 |
141 | // 编辑浮层
142 | if editingFile != nil {
143 | Color.black.opacity(0.3)
144 | .ignoresSafeArea()
145 | .onTapGesture {
146 | cancelEditing()
147 | }
148 |
149 | VStack {
150 | Text(isAddingNew ? "Add New File Type" : "Edit File Type")
151 | .font(.title2)
152 | .padding(.top)
153 |
154 | VStack(alignment: .leading, spacing: 16) {
155 | VStack(alignment: .leading) {
156 | Text("Name").font(.headline)
157 | TextField("Display Name", text: $editingName)
158 | .textFieldStyle(.roundedBorder)
159 | }
160 |
161 | VStack(alignment: .leading) {
162 | Text("Extension").font(.headline)
163 | TextField("File Extension (e.g., .txt)", text: $editingExt)
164 | .textFieldStyle(.roundedBorder)
165 | }
166 |
167 | // 在编辑浮层中添加模板选择部分
168 | VStack(alignment: .leading) {
169 | Text("Template").font(.headline)
170 | HStack {
171 | if let templateUrl = editingTemplate {
172 | Text(templateUrl.lastPathComponent)
173 | Button {
174 | editingTemplate = nil
175 | } label: {
176 | Image(systemName: "xmark.circle.fill")
177 | }
178 | .buttonStyle(.plain)
179 | }
180 | Button {
181 | self.showSelectTemplate = true
182 | self.logger.info("click.. sleect tele")
183 | } label: {
184 | Text(editingTemplate == nil ? "Select Template" : "Change Template")
185 | }
186 | .fileImporter(
187 | isPresented: $showSelectTemplate,
188 | allowedContentTypes: [
189 | .content
190 | ],
191 | allowsMultipleSelection: false
192 | ) { result in
193 | logger.warning("start select template result")
194 | switch result {
195 | case .success(let files):
196 | if let url = files.first {
197 | editingTemplate = url
198 | }
199 | case .failure:
200 | logger.warning("error when import template file")
201 | }
202 | }
203 | .zIndex(1)
204 | }
205 | }
206 |
207 | VStack(alignment: .leading) {
208 | Text("Icon").font(.headline)
209 | TextField("SF Symbol name or custom icon", text: $editingIcon)
210 | .textFieldStyle(.roundedBorder)
211 |
212 | if !editingIcon.isEmpty {
213 | HStack {
214 | Text("Preview:")
215 | if editingIcon.starts(with: "icon-") {
216 | Image(editingIcon)
217 | .resizable()
218 | .frame(width: 20, height: 20)
219 | } else {
220 | Image(systemName: editingIcon)
221 | .resizable()
222 | .frame(width: 20, height: 20)
223 | }
224 | }
225 | }
226 | }
227 |
228 | VStack(alignment: .leading) {
229 | Text("Default Open App").font(.headline)
230 | HStack {
231 | if let appUrl = editingOpenApp {
232 | Image(nsImage: NSWorkspace.shared.icon(forFile: appUrl.path()))
233 | .resizable()
234 | .frame(width: 20, height: 20)
235 | Text(appUrl.lastPathComponent)
236 | }
237 |
238 | Button {
239 | showSelectApp = true
240 | } label: {
241 | Text(editingOpenApp == nil ? "Select App" : "Change App")
242 | }
243 | .fileImporter(
244 | isPresented: $showSelectApp,
245 | allowedContentTypes: [.application],
246 | allowsMultipleSelection: false
247 | ) { result in
248 | switch result {
249 | case .success(let files):
250 | if let url = files.first {
251 | editingOpenApp = url
252 | }
253 | case .failure(let error):
254 | print(error)
255 | }
256 | }
257 | .zIndex(1)
258 |
259 | if editingOpenApp != nil {
260 | Button {
261 | editingOpenApp = nil
262 | } label: {
263 | Image(systemName: "xmark.circle.fill")
264 | }
265 | .buttonStyle(.plain)
266 | }
267 | }
268 | }
269 | }
270 | .padding()
271 |
272 | HStack {
273 | Button("Cancel") {
274 | cancelEditing()
275 | }
276 | .keyboardShortcut(.escape)
277 |
278 | Button(isAddingNew ? "Add" : "Save") {
279 | saveChanges()
280 | }
281 | .keyboardShortcut(.return)
282 | .disabled(editingName.isEmpty || editingExt.isEmpty)
283 | }
284 | .padding(.bottom)
285 | }
286 | .frame(width: 400)
287 | .background(Color(NSColor.windowBackgroundColor))
288 | .cornerRadius(12)
289 | .shadow(radius: 10)
290 | }
291 | }
292 | }
293 |
294 | private func resetEditingFields() {
295 | editingName = ""
296 | editingExt = ""
297 | editingIcon = "document"
298 | editingOpenApp = nil
299 | }
300 |
301 | private func cancelEditing() {
302 | editingFile = nil
303 | isAddingNew = false
304 | resetEditingFields()
305 | }
306 |
307 | private func saveChanges() {
308 | if isAddingNew {
309 | var newFile = NewFile(
310 | ext: editingExt,
311 | name: editingName,
312 | idx: appState.newFiles.count,
313 | icon: editingIcon
314 | )
315 | if let app = editingOpenApp {
316 | newFile.openApp = app
317 | }
318 | appState.addNewFile(newFile)
319 | } else if let file = editingFile,
320 | let index = appState.newFiles.firstIndex(where: { $0.id == file.id })
321 | {
322 | var updatedFile = file
323 | updatedFile.name = editingName
324 | updatedFile.ext = editingExt
325 | updatedFile.icon = editingIcon
326 | updatedFile.openApp = editingOpenApp
327 | if let templateUrl = editingTemplate {
328 | // 创建目录并复制模板
329 | if let templateDir = templatesDir {
330 | try? FileManager.default.createDirectory(at: templateDir, withIntermediateDirectories: true)
331 | let destUrl = templateDir.appendingPathComponent(templateUrl.lastPathComponent)
332 | try? FileManager.default.copyItem(at: templateUrl, to: destUrl)
333 | updatedFile.template = destUrl
334 | }
335 | }
336 | appState.newFiles[index] = updatedFile
337 | }
338 | Task {
339 | appState.sync()
340 | }
341 | messager.sendMessage(name: "running", data: MessagePayload(action: "running", target: []))
342 | cancelEditing()
343 | }
344 | }
345 |
--------------------------------------------------------------------------------
/RClick/RClickApp.swift:
--------------------------------------------------------------------------------
1 | //
2 | // RClickApp.swift
3 | // RClick
4 | //
5 | // Created by 李旭 on 2024/4/4.
6 | //
7 | import AppKit
8 | import Foundation
9 | import SwiftUI
10 |
11 | import FinderSync
12 | import os.log
13 |
14 | @main
15 | struct RClickApp: App {
16 | @NSApplicationDelegateAdaptor private var appDelegate: AppDelegate
17 |
18 | @Environment(\.scenePhase) private var scenePhase
19 |
20 | @AppStorage(Key.showMenuBarExtra, store: .group) private var showMenuBarExtra = true
21 |
22 | @Environment(\.openWindow) var openWindow
23 |
24 | @AppLog(category: "main")
25 | private var logger
26 | let messager = Messager.shared
27 |
28 | @StateObject var appState = AppState.shared
29 |
30 | @StateObject private var updateManager = UpdateManager(
31 | owner: "wflixu",
32 | repo: "RClick",
33 | currentVersion: Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "1.0.0"
34 | )
35 |
36 | var body: some Scene {
37 | SettingsWindow(appState: appState, onAppear: {})
38 | .defaultAppStorage(.group)
39 | .environmentObject(updateManager)
40 |
41 | // showMenuBarExtra 为 true 时显示菜单条
42 | MenuBarExtra(
43 | "RClick", image: "MenuBar", isInserted: $showMenuBarExtra
44 | ) {
45 | MenuBarView()
46 | }.defaultAppStorage(.group)
47 | }
48 | }
49 |
50 | @MainActor
51 | class AppDelegate: NSObject, NSApplicationDelegate {
52 | @AppLog(category: "AppDelegate")
53 | private var logger
54 |
55 | var appState: AppState = .shared
56 | var pluginRunning: Bool = false
57 | var heartBeatCount = 0
58 |
59 | let messager = Messager.shared
60 | var showMenuBarExtra = UserDefaults.group.bool(forKey: Key.showMenuBarExtra)
61 | var showInDock = UserDefaults.group.bool(forKey: Key.showInDock)
62 | var settingsWindow: NSWindow!
63 |
64 | func applicationDidFinishLaunching(_ aNotification: Notification) {
65 | // 在 app 启动后执行的函数
66 |
67 | if showInDock {
68 | NSApp.setActivationPolicy(.regular)
69 | } else {
70 | NSApp.setActivationPolicy(.accessory)
71 | }
72 |
73 | messager.on(name: Key.messageFromFinder) { payload in
74 |
75 | self.logger.info("recive mess from finder by app \(payload.description)")
76 | switch payload.action {
77 | case "open":
78 | self.openApp(rid: payload.rid, target: payload.target)
79 | case "actioning":
80 | self.actionHandler(rid: payload.rid, target: payload.target, trigger: payload.trigger)
81 | case "Create File":
82 | self.createFile(rid: payload.rid, target: payload.target)
83 | case "common-dirs":
84 | self.openCommonDirs(target: payload.target)
85 | case "heartbeat":
86 | self.logger.warning("message from finder plugin heartbeat")
87 | self.pluginRunning = true
88 | default:
89 | self.logger.warning("actioning payload no matched")
90 | }
91 | }
92 |
93 | sendObserveDirMessage()
94 | }
95 |
96 | func openCommonDirs(target: [String]) {
97 | logger.info("开始打开常用目录,目标路径: \(target)")
98 |
99 | for dirPath in target {
100 | let path = dirPath.removingPercentEncoding ?? dirPath
101 | let url = URL(fileURLWithPath: path, isDirectory: true)
102 |
103 | logger.info("正在打开目录: \(path)")
104 | NSWorkspace.shared.open(url)
105 | }
106 |
107 | logger.info("常用目录打开操作完成")
108 | }
109 |
110 | func sendObserveDirMessage() {
111 | let target: [String] = appState.dirs.map { $0.url.path() }
112 |
113 | messager.sendMessage(name: "running", data: MessagePayload(action: "running", target: target))
114 | if !pluginRunning {
115 | DispatchQueue.main.asyncAfter(deadline: .now() + 3.0) {
116 | self.sendObserveDirMessage()
117 | }
118 | }
119 | }
120 |
121 | // 创建一个当前文件夹下的不存在的新建文件名
122 | func getUniqueFilePath(dir: String, ext: String) -> String {
123 | // 创建文件管理器
124 | let fileManager = FileManager.default
125 |
126 | // 基础文件名
127 | let baseFileName = String(localized: "Untitled")
128 |
129 | // 初始文件路径
130 | var filePath = "\(dir)\(baseFileName)\(ext)"
131 |
132 | // 文件计数器
133 | var counter = 1
134 |
135 | // 查询文件是否存在,直到找到一个不存在的路径
136 | while fileManager.fileExists(atPath: filePath) {
137 | // 更新文件名和路径,使用计数器递增
138 | let newFileName = "\(baseFileName)\(counter)"
139 | filePath = "\(dir)\(newFileName)\(ext)"
140 | counter += 1
141 | }
142 |
143 | return filePath
144 | }
145 |
146 | func actionHandler(rid: String, target: [String], trigger: String) {
147 | guard let rcitem = appState.getActionItem(rid: rid) else {
148 | logger.warning("when createFile,but not have fileType ")
149 | return
150 | }
151 |
152 | switch rcitem.id {
153 | case "copy-path":
154 | copyPath(target)
155 | case "delete-direct":
156 | deleteFoldorFile(target, trigger)
157 | case "unhide":
158 | unhideFilesAndDirs(target, trigger)
159 | case "hide":
160 | hideFilesAndDirs(target, trigger)
161 | case "airdrop":
162 | showAirDrop(target, trigger)
163 | default:
164 | logger.warning("no action id matched")
165 | }
166 | }
167 |
168 | func showAirDrop(_ target: [String], _ trigger: String) {
169 | logger.info("---- showAirDrop trigger:\(trigger)")
170 | let fm = FileManager.default
171 | var fileURLs: [URL] = []
172 |
173 | if trigger == "ctx-container" {
174 | // 显示警告对话框
175 | let alert = NSAlert()
176 | alert.messageText = "警告"
177 | alert.informativeText = "无法共享当前文件夹,请选择文件或子文件夹进行共享。"
178 | alert.alertStyle = .warning
179 | alert.addButton(withTitle: "确定")
180 | alert.runModal()
181 | return
182 | }
183 |
184 | for item in target {
185 | let decodedPath = item.removingPercentEncoding ?? item
186 | logger.info("airdrop path \(decodedPath)")
187 |
188 | if Utils.isProtectedFolder(decodedPath) {
189 | // 显示警告对话框
190 | let alert = NSAlert()
191 | alert.messageText = "警告"
192 | alert.informativeText = "无法分享系统保护文件夹:\(decodedPath)"
193 | alert.alertStyle = .warning
194 | alert.addButton(withTitle: "确定")
195 | alert.runModal()
196 |
197 | logger.warning("试图分享受保护的系统文件夹,操作已被阻止: \(decodedPath)")
198 | continue
199 | }
200 |
201 | var isDir: ObjCBool = false
202 | if fm.fileExists(atPath: decodedPath, isDirectory: &isDir) {
203 | if isDir.boolValue {
204 | logger.warning("不能通过 AirDrop 分享文件夹: \(decodedPath)")
205 | let alert = NSAlert()
206 | alert.messageText = "提示"
207 | alert.informativeText = "不能通过 AirDrop 分享文件夹:\(decodedPath)"
208 | alert.alertStyle = .informational
209 | alert.addButton(withTitle: "确定")
210 | alert.runModal()
211 | continue
212 | } else {
213 | fileURLs.append(URL(fileURLWithPath: decodedPath))
214 | }
215 | }
216 | }
217 |
218 | if !fileURLs.isEmpty {
219 | if let airDropService = NSSharingService(named: .sendViaAirDrop) {
220 | airDropService.perform(withItems: fileURLs)
221 | logger.info("已通过 AirDrop 分享文件: \(fileURLs.map { $0.path }.joined(separator: ", "))")
222 | } else {
223 | logger.warning("无法获取 AirDrop 服务")
224 | }
225 | }
226 | }
227 |
228 | // 显示目标文件夹下的隐藏的所有文件和文件夹
229 | func unhideFilesAndDirs(_ target: [String], _ trigger: String) {
230 | logger.info("开始取消隐藏文件和目录,目标路径: \(target)")
231 | if let dirPath = target.first {
232 | let fileManager = FileManager.default
233 | let path = dirPath.removingPercentEncoding ?? dirPath
234 | logger.info("处理主目录: \(path)")
235 | var url = URL(fileURLWithPath: path)
236 |
237 | // 仅处理目录下一级的内容
238 | do {
239 | let contents = try fileManager.contentsOfDirectory(at: url, includingPropertiesForKeys: [.isHiddenKey], options: [.skipsPackageDescendants])
240 | for case var fileURL in contents {
241 | do {
242 | var resourceValues = URLResourceValues()
243 | resourceValues.isHidden = false
244 | try fileURL.setResourceValues(resourceValues)
245 | logger.info("成功取消隐藏: \(fileURL.path)")
246 | } catch {
247 | logger.error("取消隐藏失败: \(fileURL.path): \(error)")
248 | }
249 | }
250 | } catch {
251 | logger.error("获取目录内容失败: \(error)")
252 | }
253 |
254 | // 处理目录本身
255 | do {
256 | var resourceValues = URLResourceValues()
257 | resourceValues.isHidden = false
258 | try url.setResourceValues(resourceValues)
259 | logger.info("成功取消隐藏主目录: \(path)")
260 | } catch {
261 | logger.error("取消隐藏主目录失败: \(path): \(error)")
262 | }
263 | logger.info("取消隐藏操作完成,共处理目录: \(path)")
264 | }
265 | }
266 |
267 | // 隐藏目标文件或文件夹
268 | func hideFilesAndDirs(_ target: [String], _ trigger: String) {
269 | logger.info("开始隐藏文件和目录,目标路径: \(target), 触发器: \(trigger)")
270 | let fileManager = FileManager.default
271 |
272 | if trigger == "ctx-container", let dirPath = target.first {
273 | let path = dirPath.removingPercentEncoding ?? dirPath
274 | logger.info("处理主目录: \(path)")
275 | let url = URL(fileURLWithPath: path)
276 |
277 | // 仅处理目录下一级的内容
278 | do {
279 | let contents = try fileManager.contentsOfDirectory(at: url, includingPropertiesForKeys: [.isDirectoryKey], options: [.skipsPackageDescendants])
280 | for case var fileURL in contents {
281 | // 如果是受保护的文件路径,跳过
282 | if Utils.isProtectedFolder(fileURL.path) {
283 | logger.warning("跳过受保护的文件路径: \(fileURL.path)")
284 | continue
285 | }
286 | do {
287 | var resourceValues = URLResourceValues()
288 | resourceValues.isHidden = true
289 | try fileURL.setResourceValues(resourceValues)
290 | logger.info("成功隐藏: \(fileURL.path)")
291 | } catch {
292 | logger.error("隐藏失败: \(fileURL.path): \(error)")
293 | }
294 | }
295 | } catch {
296 | logger.error("获取目录内容失败: \(error)")
297 | }
298 | } else if trigger == "ctx-items" {
299 | for dirPath in target {
300 | let path = dirPath.removingPercentEncoding ?? dirPath
301 | logger.info("处理路径: \(path)")
302 | var url = URL(fileURLWithPath: path)
303 |
304 | // 处理单个文件或目录
305 | if Utils.isProtectedFolder(path) {
306 | logger.warning("跳过受保护的文件路径: \(path)")
307 | continue
308 | }
309 | do {
310 | var resourceValues = URLResourceValues()
311 | resourceValues.isHidden = true
312 | try url.setResourceValues(resourceValues)
313 | logger.info("成功隐藏: \(path)")
314 | } catch {
315 | logger.error("隐藏失败: \(path): \(error)")
316 | }
317 | }
318 | }
319 | logger.info("隐藏操作完成")
320 | }
321 |
322 | func copyPath(_ target: [String]) {
323 | if let dirPath = target.first {
324 | let pasteboard = NSPasteboard.general
325 | // must do to fix bug
326 | pasteboard.clearContents()
327 |
328 | pasteboard.setString(dirPath.removingPercentEncoding ?? dirPath, forType: .string)
329 | }
330 | }
331 |
332 | func deleteFoldorFile(_ target: [String], _ trigger: String) {
333 | logger.info("---- deleteFoldorFile trigger:\(trigger)")
334 | let fm = FileManager.default
335 | // 如果是容器,无法删除
336 | if trigger == "ctx-container" {
337 | // 显示警告对话框
338 | let alert = NSAlert()
339 | alert.messageText = "警告"
340 | alert.informativeText = "无法删除当前文件夹,请选择文件或子文件夹进行删除。"
341 | alert.alertStyle = .warning
342 | alert.addButton(withTitle: "确定")
343 | alert.runModal()
344 | return
345 | }
346 |
347 | for item in target {
348 | let decodedPath = item.removingPercentEncoding ?? item
349 |
350 | if Utils.isProtectedFolder(decodedPath) {
351 | // 显示警告对话框
352 | let alert = NSAlert()
353 | alert.messageText = "警告"
354 | alert.informativeText = "无法删除系统保护文件夹:\(decodedPath)"
355 | alert.alertStyle = .warning
356 | alert.addButton(withTitle: "确定")
357 | alert.runModal()
358 |
359 | logger.warning("试图删除受保护的系统文件夹,操作已被阻止: \(decodedPath)")
360 | continue
361 | }
362 |
363 | if let permDir = appState.dirs.first(where: { permd in
364 | item.contains(permd.url.path())
365 | }) {
366 | var isStale = false
367 | do {
368 | let folderURL = try URL(resolvingBookmarkData: permDir.bookmark, options: .withSecurityScope, relativeTo: nil, bookmarkDataIsStale: &isStale)
369 |
370 | if isStale {
371 | // 重新创建 bookmarkData
372 | // createBookmark(for: folderURL) // 这里可以调用之前的函数
373 | }
374 |
375 | // 进入安全范围
376 | let success = folderURL.startAccessingSecurityScopedResource()
377 | if success {
378 | try fm.removeItem(atPath: item.removingPercentEncoding ?? item)
379 | // 完成后释放资源
380 | folderURL.stopAccessingSecurityScopedResource()
381 | } else {
382 | logger.warning("fail access scope \(permDir.url.path)")
383 | }
384 | } catch {
385 | logger.error("delete \(target) file run error \(error)")
386 | }
387 | }
388 | }
389 | }
390 |
391 | func createFile(rid: String, target: [String]) {
392 | guard let rcitem = appState.getFileType(rid: rid), let dirPath = target.first else {
393 | logger.warning("when createFile,but not have fileType \(rid) ")
394 | return
395 | }
396 |
397 | let ext = rcitem.ext
398 | logger.info("create file dir:\(dirPath) -- ext \(ext)")
399 | // 完整的文件路径
400 | let filePath = getUniqueFilePath(dir: dirPath.removingPercentEncoding ?? dirPath, ext: ext)
401 |
402 | let fileURL = URL(fileURLWithPath: filePath)
403 |
404 | if let dir = appState.dirs.first(where: {
405 | dirPath.contains($0.url.path)
406 | }) {
407 | var isStale = false
408 | do {
409 | let folderURL = try URL(resolvingBookmarkData: dir.bookmark, options: .withSecurityScope, relativeTo: nil, bookmarkDataIsStale: &isStale)
410 |
411 | // 进入安全范围
412 | let success = folderURL.startAccessingSecurityScopedResource()
413 | if success {
414 | do {
415 | let fileManager = FileManager.default
416 |
417 | // 检查是否有有效的模板URL
418 | if let templateUrl = rcitem.template {
419 | try fileManager.copyItem(at: templateUrl, to: fileURL)
420 | logger.info("已成功复制模板到目标路径: \(fileURL.path)")
421 |
422 | } else {
423 | // 从Bundle中获取模板文件
424 | if let defaultTemplateURL = Bundle.main.url(forResource: "template", withExtension: ext.replacingOccurrences(of: ".", with: "")) {
425 | logger.info("使用模板创建文件,模板路径: \(defaultTemplateURL.path)")
426 | try fileManager.copyItem(at: defaultTemplateURL, to: fileURL)
427 | logger.info("已成功复制模板到目标路径: \(fileURL.path)")
428 | } else {
429 | logger.warning("模板文件不存在: \(ext)")
430 | // 模板不存在时创建空文件
431 | try Data().write(to: fileURL)
432 | }
433 | }
434 | } catch let error as NSError {
435 | switch error.domain {
436 | case NSCocoaErrorDomain:
437 | switch error.code {
438 | case NSFileNoSuchFileError:
439 | logger.error("文件不存在: \(filePath)")
440 | case NSFileWriteOutOfSpaceError:
441 | logger.error("磁盘空间不足")
442 | case NSFileWriteNoPermissionError:
443 | logger.error("没有写入权限: \(filePath)")
444 | default:
445 | logger.error("创建文件错误: \(error.localizedDescription) (错误码: \(error.code))")
446 | }
447 | default:
448 | logger.error("未处理的错误: \(error.localizedDescription) (错误码: \(error.code))")
449 | }
450 | }
451 | // 完成后释放资源
452 | folderURL.stopAccessingSecurityScopedResource()
453 | } else {
454 | logger.warning("fail access scope \(dir.url.path)")
455 | }
456 | } catch {
457 | print("解析 bookmark 失败:\(error)")
458 | }
459 | }
460 | }
461 |
462 | func openApp(rid: String, target: [String]) {
463 | guard let rcitem = appState.getAppItem(rid: rid) else {
464 | logger.warning("when openapp,but not have app \(rid)")
465 | return
466 | }
467 |
468 | let appUrl = rcitem.url
469 | let config = NSWorkspace.OpenConfiguration()
470 | config.promptsUserIfNeeded = false
471 |
472 | for dirPath in target {
473 | let dir = URL(fileURLWithPath: dirPath.removingPercentEncoding ?? dirPath, isDirectory: true)
474 |
475 | config.arguments = rcitem.arguments
476 | config.environment = rcitem.environment
477 |
478 | if appUrl.path.hasSuffix("WezTerm.app") {
479 | // 创建一个 Process 实例
480 | let process = Process()
481 |
482 | // 设置要运行的二进制文件路径
483 | process.executableURL = URL(fileURLWithPath: "/Users/lixu/play/rpm/target/debug/rpm")
484 |
485 | // 设置命令行参数(如果有)
486 | process.arguments = ["--name", "arg2"]
487 |
488 | // 设置标准输出和标准错误
489 | let pipe = Pipe()
490 | process.standardOutput = pipe
491 | process.standardError = pipe
492 |
493 | do {
494 | // 启动进程
495 | try process.run()
496 |
497 | // 等待进程完成
498 | process.waitUntilExit()
499 |
500 | // 读取输出
501 | let data = pipe.fileHandleForReading.readDataToEndOfFile()
502 | if let output = String(data: data, encoding: .utf8) {
503 | print("Output: \(output)")
504 | }
505 | } catch {
506 | print("Error: \(error)")
507 | }
508 | } else {
509 | logger.info("starting open dir .........\(dir.path), app:\(appUrl.path())")
510 | NSWorkspace.shared.open([dir], withApplicationAt: appUrl, configuration: config) { runningApp, error in
511 | if let error = error {
512 | print("Error opening application: \(error.localizedDescription)")
513 | } else if let runningApp = runningApp {
514 | print("Successfully opened application: \(runningApp.localizedName ?? "Unknown")")
515 | }
516 | }
517 | }
518 | }
519 | }
520 |
521 | func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool {
522 | return false
523 | }
524 |
525 | func applicationWillTerminate(_ notification: Notification) {
526 | messager.sendMessage(name: "quit", data: MessagePayload(action: "quit", target: [], trigger: "unknown"))
527 | logger.info("applicationWillTerminate")
528 | }
529 | }
530 |
--------------------------------------------------------------------------------