├── 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 | [![](./RClick/Assets.xcassets/AppIcon.appiconset/AppIcon@1x.png)](https://github.com/wflixu/RClick/releases) 3 | 4 | # RClick 5 | 6 | [![Swift 5.9](https://img.shields.io/badge/Swift-5.9-ED523F.svg?style=flat)](https://swift.org/) [![SwiftUI](https://img.shields.io/badge/SwiftUI-✓-orange)](https://developer.apple.com/xcode/swiftui/) [![macOS 15](https://img.shields.io/badge/macOS15-Compatible-green)](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 | ![](./images/screenshot.png) 27 | ![](./images/screenshot2.png) 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 | [AppStore](https://apps.apple.com/cn/app/rclick/id6496849273?mt=12) 47 | 48 | ![](./images/store-preview.png) 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 | --------------------------------------------------------------------------------