Files
ggbond268--mactools/Sources/Core/RightClick/RightClickFileActions.swift
T
wehub-resource-sync 1d1286fadb
Build / Build and test (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:18:38 +08:00

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())
}
}