333 lines
12 KiB
Swift
333 lines
12 KiB
Swift
import AppKit
|
|
import Foundation
|
|
|
|
enum RightClickLocalization {
|
|
private static let appleLanguagesKey = "AppleLanguages"
|
|
private static let rightClickTable = "RightClick"
|
|
private static let finderSyncBundleSuffix = ".right-click.finder-sync"
|
|
|
|
static func string(
|
|
_ key: String,
|
|
defaultValue: String,
|
|
bundle: Bundle = .main,
|
|
preferredLanguages: [String]? = nil
|
|
) -> String {
|
|
for localizedBundle in LocalizedBundleResolver.localizedBundles(
|
|
in: bundle,
|
|
preferredLanguages: preferredLanguages ?? effectivePreferredLanguages(for: bundle),
|
|
baseLanguage: "en"
|
|
) {
|
|
let localizedValue = localizedBundle.localizedString(forKey: key, value: nil, table: rightClickTable)
|
|
if localizedValue != key {
|
|
return localizedValue
|
|
}
|
|
}
|
|
|
|
return defaultValue
|
|
}
|
|
|
|
static func format(
|
|
_ key: String,
|
|
defaultValue: String,
|
|
bundle: Bundle = .main,
|
|
preferredLanguages: [String]? = nil,
|
|
_ arguments: CVarArg...
|
|
) -> String {
|
|
let languages = preferredLanguages ?? effectivePreferredLanguages(for: bundle)
|
|
return String(
|
|
format: string(
|
|
key,
|
|
defaultValue: defaultValue,
|
|
bundle: bundle,
|
|
preferredLanguages: preferredLanguages
|
|
),
|
|
locale: LocalizedBundleResolver.locale(for: languages.first),
|
|
arguments: arguments
|
|
)
|
|
}
|
|
|
|
private static func effectivePreferredLanguages(for bundle: Bundle) -> [String] {
|
|
// Finder Sync is a separate process. It must read the host app's
|
|
// explicit AppleLanguages override rather than this extension's list.
|
|
for bundleIdentifier in preferenceBundleIdentifiers(for: bundle) {
|
|
if let appleLanguages = explicitAppleLanguages(forBundleIdentifier: bundleIdentifier) {
|
|
return appleLanguages
|
|
}
|
|
}
|
|
|
|
return Locale.preferredLanguages
|
|
}
|
|
|
|
private static func preferenceBundleIdentifiers(for bundle: Bundle) -> [String] {
|
|
guard let bundleIdentifier = bundle.bundleIdentifier else {
|
|
return []
|
|
}
|
|
|
|
return preferenceBundleIdentifiers(forBundleIdentifier: bundleIdentifier)
|
|
}
|
|
|
|
private static func preferenceBundleIdentifiers(forBundleIdentifier bundleIdentifier: String) -> [String] {
|
|
if bundleIdentifier.hasSuffix(finderSyncBundleSuffix) {
|
|
let hostBundleIdentifier = String(bundleIdentifier.dropLast(finderSyncBundleSuffix.count))
|
|
return [hostBundleIdentifier, bundleIdentifier]
|
|
}
|
|
|
|
return [bundleIdentifier]
|
|
}
|
|
|
|
private static func explicitAppleLanguages(forBundleIdentifier bundleIdentifier: String) -> [String]? {
|
|
let applicationID = bundleIdentifier as CFString
|
|
CFPreferencesAppSynchronize(applicationID)
|
|
guard
|
|
let value = CFPreferencesCopyValue(
|
|
appleLanguagesKey as CFString,
|
|
applicationID,
|
|
kCFPreferencesCurrentUser,
|
|
kCFPreferencesAnyHost
|
|
) as? [String],
|
|
!value.isEmpty
|
|
else {
|
|
return nil
|
|
}
|
|
|
|
return value
|
|
}
|
|
|
|
#if DEBUG
|
|
static func preferenceBundleIdentifiersForTesting(bundleIdentifier: String?) -> [String] {
|
|
guard let bundleIdentifier else {
|
|
return []
|
|
}
|
|
|
|
return preferenceBundleIdentifiers(forBundleIdentifier: bundleIdentifier)
|
|
}
|
|
#endif
|
|
}
|
|
|
|
enum RightClickActionError: LocalizedError, Equatable {
|
|
case directoryUnavailable(String)
|
|
case cannotCreateDirectory(String)
|
|
case cannotCreateFile(String)
|
|
case unsupportedFileExtension(String)
|
|
case applicationUnavailable(String)
|
|
case noValidFiles
|
|
|
|
var errorDescription: String? {
|
|
switch self {
|
|
case let .directoryUnavailable(path):
|
|
RightClickLocalization.format("error.directoryUnavailable", defaultValue: "文件夹不可用:%@", path)
|
|
case let .cannotCreateDirectory(path):
|
|
RightClickLocalization.format("error.cannotCreateDirectory", defaultValue: "无法新建文件夹:%@", path)
|
|
case let .cannotCreateFile(path):
|
|
RightClickLocalization.format("error.cannotCreateFile", defaultValue: "无法新建文件:%@", path)
|
|
case let .unsupportedFileExtension(ext):
|
|
RightClickLocalization.format("error.unsupportedFileExtension", defaultValue: "不支持的文件类型:%@", ext)
|
|
case let .applicationUnavailable(path):
|
|
RightClickLocalization.format("error.applicationUnavailable", defaultValue: "应用不可用:%@", path)
|
|
case .noValidFiles:
|
|
RightClickLocalization.string("error.noValidFiles", defaultValue: "没有可用的文件")
|
|
}
|
|
}
|
|
}
|
|
|
|
struct RightClickPathFormatter {
|
|
static func relativePath(of target: URL, to base: URL) -> String {
|
|
let baseComponents = base.standardizedFileURL.resolvingSymlinksInPath().pathComponents
|
|
let targetComponents = target.standardizedFileURL.resolvingSymlinksInPath().pathComponents
|
|
var sharedCount = 0
|
|
|
|
while sharedCount < baseComponents.count,
|
|
sharedCount < targetComponents.count,
|
|
baseComponents[sharedCount] == targetComponents[sharedCount] {
|
|
sharedCount += 1
|
|
}
|
|
|
|
let upCount = max(0, baseComponents.count - sharedCount)
|
|
let downComponents = targetComponents.dropFirst(sharedCount)
|
|
let components = Array(repeating: "..", count: upCount) + Array(downComponents)
|
|
return components.isEmpty ? "." : components.joined(separator: "/")
|
|
}
|
|
|
|
static func joinedPaths(_ urls: [URL]) -> String {
|
|
urls.map(\.path).joined(separator: "\n")
|
|
}
|
|
|
|
static func joinedFileNames(_ urls: [URL]) -> String {
|
|
urls.map(\.lastPathComponent).joined(separator: "\n")
|
|
}
|
|
|
|
static func joinedRelativePaths(_ urls: [URL], base: URL?) -> String {
|
|
urls
|
|
.map { url in
|
|
guard let base else {
|
|
return url.path
|
|
}
|
|
|
|
return relativePath(of: url, to: base)
|
|
}
|
|
.joined(separator: "\n")
|
|
}
|
|
|
|
/// Shell-escaped paths, space-separated so the whole line can be pasted as
|
|
/// terminal arguments.
|
|
static func joinedShellEscapedPaths(_ urls: [URL]) -> String {
|
|
urls.map { shellEscaped($0.path) }.joined(separator: " ")
|
|
}
|
|
|
|
static func joinedFileURLs(_ urls: [URL]) -> String {
|
|
urls.map(\.absoluteString).joined(separator: "\n")
|
|
}
|
|
|
|
/// Single-quote wrap, with embedded single quotes escaped as `'\''`.
|
|
static func shellEscaped(_ path: String) -> String {
|
|
"'" + path.replacingOccurrences(of: "'", with: "'\\''") + "'"
|
|
}
|
|
}
|
|
|
|
struct RightClickTargetResolver {
|
|
static func targetDirectory(selectedURLs: [URL], targetedURL: URL?) -> URL? {
|
|
if let first = selectedURLs.first {
|
|
return directory(for: first)
|
|
}
|
|
|
|
if let targetedURL {
|
|
return directory(for: targetedURL)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
static func directory(for url: URL) -> URL {
|
|
if isDirectory(url) {
|
|
return url
|
|
}
|
|
|
|
return url.deletingLastPathComponent()
|
|
}
|
|
|
|
static func isDirectory(_ url: URL) -> Bool {
|
|
var isDirectory = ObjCBool(false)
|
|
return FileManager.default.fileExists(atPath: url.path, isDirectory: &isDirectory)
|
|
&& isDirectory.boolValue
|
|
}
|
|
}
|
|
|
|
struct RightClickFileNamePlanner {
|
|
static func nextAvailableFolderURL(
|
|
in directory: URL,
|
|
baseName: String = RightClickLocalization.string("file.defaultFolderName", defaultValue: "新建文件夹")
|
|
) -> URL {
|
|
nextAvailableURL(in: directory, baseName: baseName, pathExtension: nil)
|
|
}
|
|
|
|
static func nextAvailableURL(in directory: URL, baseName: String, pathExtension: String?) -> URL {
|
|
let sanitizedBaseName = baseName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
|
? RightClickLocalization.string("file.untitledName", defaultValue: "未命名")
|
|
: baseName.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
let normalizedExtension = pathExtension?.trimmingCharacters(in: CharacterSet(charactersIn: "."))
|
|
|
|
func candidate(_ index: Int?) -> URL {
|
|
let name: String
|
|
if let index {
|
|
name = "\(sanitizedBaseName) \(index)"
|
|
} else {
|
|
name = sanitizedBaseName
|
|
}
|
|
|
|
let url = directory.appendingPathComponent(name, isDirectory: normalizedExtension == nil)
|
|
guard let normalizedExtension, !normalizedExtension.isEmpty else {
|
|
return url
|
|
}
|
|
|
|
return url.appendingPathExtension(normalizedExtension)
|
|
}
|
|
|
|
let manager = FileManager.default
|
|
var url = candidate(nil)
|
|
guard manager.fileExists(atPath: url.path) else {
|
|
return url
|
|
}
|
|
|
|
var index = 2
|
|
repeat {
|
|
url = candidate(index)
|
|
index += 1
|
|
} while manager.fileExists(atPath: url.path)
|
|
|
|
return url
|
|
}
|
|
}
|
|
|
|
protocol RightClickWorkspaceOpening {
|
|
func activateFileViewerSelecting(_ urls: [URL])
|
|
}
|
|
|
|
struct RightClickWorkspace: RightClickWorkspaceOpening {
|
|
func activateFileViewerSelecting(_ urls: [URL]) {
|
|
NSWorkspace.shared.activateFileViewerSelecting(urls)
|
|
}
|
|
}
|
|
|
|
struct RightClickFileActionService {
|
|
var fileManager: FileManager = .default
|
|
var workspace: RightClickWorkspaceOpening = RightClickWorkspace()
|
|
|
|
func createFolder(in directory: URL) throws -> URL {
|
|
guard RightClickTargetResolver.isDirectory(directory) else {
|
|
throw RightClickActionError.directoryUnavailable(directory.path)
|
|
}
|
|
|
|
let folderURL = RightClickFileNamePlanner.nextAvailableFolderURL(in: directory)
|
|
do {
|
|
try fileManager.createDirectory(at: folderURL, withIntermediateDirectories: false)
|
|
workspace.activateFileViewerSelecting([folderURL])
|
|
return folderURL
|
|
} catch {
|
|
throw RightClickActionError.cannotCreateDirectory(folderURL.path)
|
|
}
|
|
}
|
|
|
|
/// Create an empty file of an allow-listed type inside the target directory,
|
|
/// then reveal it in Finder. The extension allow list also blocks path
|
|
/// traversal through the forwarded `ext` parameter.
|
|
func createFile(in directory: URL, extension fileExtension: String) throws -> URL {
|
|
guard RightClickNewFile.isSupportedExtension(fileExtension) else {
|
|
throw RightClickActionError.unsupportedFileExtension(fileExtension)
|
|
}
|
|
guard RightClickTargetResolver.isDirectory(directory) else {
|
|
throw RightClickActionError.directoryUnavailable(directory.path)
|
|
}
|
|
let fileURL = RightClickFileNamePlanner.nextAvailableURL(
|
|
in: directory,
|
|
baseName: RightClickLocalization.string("file.untitledName", defaultValue: "未命名"),
|
|
pathExtension: fileExtension
|
|
)
|
|
// Defense-in-depth: the resolved file must sit directly inside the target.
|
|
guard fileURL.deletingLastPathComponent().standardizedFileURL.path
|
|
== directory.standardizedFileURL.path else {
|
|
throw RightClickActionError.cannotCreateFile(fileURL.path)
|
|
}
|
|
// Exclusive create: fail (rather than clobber) if the path appeared
|
|
// between nextAvailableURL and now (TOCTOU). createFile(atPath:) would
|
|
// silently truncate an existing file.
|
|
do {
|
|
try Data().write(to: fileURL, options: .withoutOverwriting)
|
|
} catch {
|
|
throw RightClickActionError.cannotCreateFile(fileURL.path)
|
|
}
|
|
workspace.activateFileViewerSelecting([fileURL])
|
|
return fileURL
|
|
}
|
|
}
|
|
|
|
/// New-file types the right-click menu may create. A strict allow list keeps the
|
|
/// feature to its intended types and — because the host's URL scheme can be
|
|
/// invoked by any process — prevents path traversal through the `ext` parameter.
|
|
enum RightClickNewFile {
|
|
static let supportedExtensions: [String] = ["txt", "md", "json"]
|
|
|
|
static func isSupportedExtension(_ ext: String) -> Bool {
|
|
supportedExtensions.contains(ext.lowercased())
|
|
}
|
|
}
|