From 5e669f9faebea6375671b069eaa46b43b464037e Mon Sep 17 00:00:00 2001 From: shawn Date: Mon, 6 Jul 2026 10:29:07 +0800 Subject: [PATCH 1/7] feat: add macos share extension --- Package.swift | 6 ++ .../ShareController.swift | 89 +++++++++++++++++++ Sources/LocalShareShareExtension/main.swift | 10 +++ build.sh | 60 +++++++++---- bundle/ShareExtension.entitlements | 11 +++ bundle/ShareExtensionInfo.plist | 52 +++++++++++ docs/ARCHITECTURE.md | 11 +++ 7 files changed, 220 insertions(+), 19 deletions(-) create mode 100644 Sources/LocalShareShareExtension/ShareController.swift create mode 100644 Sources/LocalShareShareExtension/main.swift create mode 100644 bundle/ShareExtension.entitlements create mode 100644 bundle/ShareExtensionInfo.plist diff --git a/Package.swift b/Package.swift index 86397ca..892ec75 100644 --- a/Package.swift +++ b/Package.swift @@ -30,6 +30,12 @@ let package = Package( .unsafeFlags(["-Xlinker", "-rpath", "-Xlinker", "@executable_path/../Frameworks"]), ] ), + // macOS 分享菜单扩展:只接收 Finder/系统分享面板传来的文件 URL,再唤起主 app。 + // 不依赖 Swifter/Sparkle,也不引入任何包外 dylib。 + .executableTarget( + name: "LocalShareShareExtension", + dependencies: [] + ), // 单元测试:@testable import 可执行 target,无须把源码拆成单独 library、也无须把 // internal 符号提成 public——直接测纯函数(防穿越判据、文件名清洗、key 去重等)。 .testTarget( diff --git a/Sources/LocalShareShareExtension/ShareController.swift b/Sources/LocalShareShareExtension/ShareController.swift new file mode 100644 index 0000000..16c967f --- /dev/null +++ b/Sources/LocalShareShareExtension/ShareController.swift @@ -0,0 +1,89 @@ +import AppKit +import Foundation +import UniformTypeIdentifiers + +final class ShareController: NSObject, NSExtensionRequestHandling { + func beginRequest(with context: NSExtensionContext) { + let providers = context.inputItems + .compactMap { $0 as? NSExtensionItem } + .flatMap { $0.attachments ?? [] } + + guard !providers.isEmpty else { + context.completeRequest(returningItems: nil) + return + } + + loadFileURLs(from: providers) { urls in + let existing = urls.filter { FileManager.default.fileExists(atPath: $0.path) } + guard !existing.isEmpty, let appURL = Self.hostAppURL() else { + context.completeRequest(returningItems: nil) + return + } + + let config = NSWorkspace.OpenConfiguration() + config.activates = true + NSWorkspace.shared.open(existing, withApplicationAt: appURL, configuration: config) { _, error in + if let error { + NSLog("LocalShare share extension launch failed: \(error.localizedDescription)") + } + context.completeRequest(returningItems: nil) + } + } + } + + private func loadFileURLs(from providers: [NSItemProvider], completion: @escaping ([URL]) -> Void) { + let group = DispatchGroup() + let lock = NSLock() + var urls: [URL] = [] + + for provider in providers { + guard let typeIdentifier = Self.supportedTypeIdentifier(for: provider) else { continue } + group.enter() + provider.loadItem(forTypeIdentifier: typeIdentifier, options: nil) { item, error in + defer { group.leave() } + if let error { + NSLog("LocalShare share extension item load failed: \(error.localizedDescription)") + return + } + guard let url = Self.fileURL(from: item) else { return } + lock.lock() + urls.append(url.standardizedFileURL) + lock.unlock() + } + } + + group.notify(queue: .main) { + completion(urls) + } + } + + private static func supportedTypeIdentifier(for provider: NSItemProvider) -> String? { + let identifiers = [ + UTType.fileURL.identifier, + UTType.url.identifier + ] + return identifiers.first { provider.hasItemConformingToTypeIdentifier($0) } + } + + private static func fileURL(from item: NSSecureCoding?) -> URL? { + if let url = item as? URL, url.isFileURL { return url } + if let url = item as? NSURL, url.isFileURL { return url as URL } + if let data = item as? Data { + let url = URL(dataRepresentation: data, relativeTo: nil) + if url?.isFileURL == true { return url } + } + if let string = item as? String, let url = URL(string: string), url.isFileURL { + return url + } + return nil + } + + private static func hostAppURL() -> URL? { + var url = Bundle.main.bundleURL + while url.pathExtension != "app" && url.path != "/" { + url.deleteLastPathComponent() + } + if url.pathExtension == "app" { return url } + return NSWorkspace.shared.urlForApplication(withBundleIdentifier: "live.ezze.localshare") + } +} diff --git a/Sources/LocalShareShareExtension/main.swift b/Sources/LocalShareShareExtension/main.swift new file mode 100644 index 0000000..2c614cf --- /dev/null +++ b/Sources/LocalShareShareExtension/main.swift @@ -0,0 +1,10 @@ +import Darwin +import Foundation + +@_silgen_name("NSExtensionMain") +private func foundationNSExtensionMain( + _ argc: Int32, + _ argv: UnsafeMutablePointer?> +) -> Int32 + +exit(foundationNSExtensionMain(CommandLine.argc, CommandLine.unsafeArgv)) diff --git a/build.sh b/build.sh index 31aefaf..71c969c 100755 --- a/build.sh +++ b/build.sh @@ -5,8 +5,10 @@ set -euo pipefail ROOT="$(cd "$(dirname "$0")" && pwd)" BINARY="LocalShare" +EXT_BINARY="LocalShareShareExtension" APP_DISPLAY="LocalShare" APP="$ROOT/dist/$APP_DISPLAY.app" +EXT="$APP/Contents/PlugIns/ShareExtension.appex" # 两个架构都编,cp 时合成 fat binary。纯 Swift(含 Swifter 源码)可在任一机型交叉编译。 ARCHS=(--arch arm64 --arch x86_64) @@ -14,17 +16,29 @@ echo "==> swift build -c release (arm64 + x86_64)" swift build -c release --package-path "$ROOT" "${ARCHS[@]}" BIN_PATH="$(swift build -c release --package-path "$ROOT" "${ARCHS[@]}" --show-bin-path)/$BINARY" +EXT_BIN_PATH="$(swift build -c release --package-path "$ROOT" "${ARCHS[@]}" --show-bin-path)/$EXT_BINARY" [ -f "$BIN_PATH" ] || { echo "构建产物未找到: $BIN_PATH"; exit 1; } +[ -f "$EXT_BIN_PATH" ] || { echo "构建产物未找到: $EXT_BIN_PATH"; exit 1; } echo "==> 校验 universal 切片" lipo -info "$BIN_PATH" +lipo -info "$EXT_BIN_PATH" echo "==> 组装 $APP" rm -rf "$APP" -mkdir -p "$APP/Contents/MacOS" "$APP/Contents/Resources" +mkdir -p "$APP/Contents/MacOS" "$APP/Contents/Resources" "$EXT/Contents/MacOS" "$EXT/Contents/Resources" cp "$BIN_PATH" "$APP/Contents/MacOS/$BINARY" cp "$ROOT/bundle/Info.plist" "$APP/Contents/Info.plist" cp "$ROOT/bundle/AppIcon.icns" "$APP/Contents/Resources/AppIcon.icns" +cp "$EXT_BIN_PATH" "$EXT/Contents/MacOS/$EXT_BINARY" +cp "$ROOT/bundle/ShareExtensionInfo.plist" "$EXT/Contents/Info.plist" +cp "$ROOT/bundle/AppIcon.icns" "$EXT/Contents/Resources/AppIcon.icns" + +# 扩展版本号与宿主 app 保持一致;release workflow 会在 build.sh 前改写 bundle/Info.plist。 +APP_SHORT_VERSION="$(/usr/libexec/PlistBuddy -c 'Print :CFBundleShortVersionString' "$APP/Contents/Info.plist")" +APP_BUILD_VERSION="$(/usr/libexec/PlistBuddy -c 'Print :CFBundleVersion' "$APP/Contents/Info.plist")" +/usr/libexec/PlistBuddy -c "Set :CFBundleShortVersionString $APP_SHORT_VERSION" "$EXT/Contents/Info.plist" +/usr/libexec/PlistBuddy -c "Set :CFBundleVersion $APP_BUILD_VERSION" "$EXT/Contents/Info.plist" echo "==> 内置 Sparkle.framework(随包走,不依赖任何包外 dylib)" # Sparkle 是二进制 framework(非源码),SPM 把它解到 .build/artifacts 下的 xcframework; @@ -36,10 +50,11 @@ mkdir -p "$APP/Contents/Frameworks" # ditto 完整保留 framework 的 Versions 符号链接与权限(codesign 对结构敏感,勿用 cp)。 ditto "$FRAMEWORK_SRC" "$APP/Contents/Frameworks/Sparkle.framework" -echo "==> ad-hoc 签名(inside-out:先内置 framework,再整个 app)" +echo "==> ad-hoc 签名(inside-out:先内置 framework/extension,再整个 app)" # 必须先签嵌套代码再签外层。--deep 递归签 framework 内的 XPCServices / Updater.app / Autoupdate / # Sparkle dylib;ad-hoc 无特殊 requirement,--deep 在此可靠。随后签 app 主体并封包。 codesign --force --deep --sign - "$APP/Contents/Frameworks/Sparkle.framework" +codesign --force --sign - --entitlements "$ROOT/bundle/ShareExtension.entitlements" "$EXT" codesign --force --sign - "$APP" echo "==> 校验依赖:禁止包外 dylib,仅允许已内置的 @rpath framework(放宽后的核心戒律)" @@ -47,23 +62,30 @@ echo "==> 校验依赖:禁止包外 dylib,仅允许已内置的 @rpath frame # 系统库放行;@rpath 引用必须对应 Contents/Frameworks 里确实存在的 framework;其余(绝对路径 # 包外 dylib)一律判失败。 FAIL=0 -while IFS= read -r dep; do - [ -z "$dep" ] && continue - case "$dep" in - /usr/lib/*|/System/Library/*) ;; # 系统库,放行 - @rpath/*) - fw="${dep#@rpath/}"; fw="${fw%%/*}" # 取 framework 名,如 Sparkle.framework - if [ -e "$APP/Contents/Frameworks/$fw" ]; then - echo " ok 内置依赖: $dep" - else - echo " ✗ @rpath 依赖未随包: $dep(缺 Contents/Frameworks/$fw)"; FAIL=1 - fi - ;; - *) echo " ✗ 包外 dylib 依赖: $dep"; FAIL=1 ;; - esac -# otool -L 对 universal 二进制会按架构各打一行头(顶格),依赖行则以制表符缩进—— -# 只取缩进行即可跳过所有头行,sort -u 合并 arm64/x86_64 的重复项。 -done < <(otool -L "$APP/Contents/MacOS/$BINARY" | grep '^[[:space:]]' | awk '{print $1}' | sort -u) +check_deps() { + local executable="$1" + local label="$2" + echo " $label" + while IFS= read -r dep; do + [ -z "$dep" ] && continue + case "$dep" in + /usr/lib/*|/System/Library/*) ;; # 系统库,放行 + @rpath/*) + fw="${dep#@rpath/}"; fw="${fw%%/*}" # 取 framework 名,如 Sparkle.framework + if [ -e "$APP/Contents/Frameworks/$fw" ]; then + echo " ok 内置依赖: $dep" + else + echo " ✗ @rpath 依赖未随包: $dep(缺 Contents/Frameworks/$fw)"; FAIL=1 + fi + ;; + *) echo " ✗ 包外 dylib 依赖: $dep"; FAIL=1 ;; + esac + # otool -L 对 universal 二进制会按架构各打一行头(顶格),依赖行则以制表符缩进—— + # 只取缩进行即可跳过所有头行,sort -u 合并 arm64/x86_64 的重复项。 + done < <(otool -L "$executable" | grep '^[[:space:]]' | awk '{print $1}' | sort -u) +} +check_deps "$APP/Contents/MacOS/$BINARY" "$BINARY" +check_deps "$EXT/Contents/MacOS/$EXT_BINARY" "$EXT_BINARY" [ "$FAIL" -eq 0 ] || { echo "依赖校验失败:检测到包外 dylib,违反核心戒律(见 docs/ARCHITECTURE.md §0)"; exit 1; } echo "==> 验证签名有效性" diff --git a/bundle/ShareExtension.entitlements b/bundle/ShareExtension.entitlements new file mode 100644 index 0000000..cab6c83 --- /dev/null +++ b/bundle/ShareExtension.entitlements @@ -0,0 +1,11 @@ + + + + + com.apple.security.app-sandbox + + com.apple.security.files.user-selected.read-only + + + diff --git a/bundle/ShareExtensionInfo.plist b/bundle/ShareExtensionInfo.plist new file mode 100644 index 0000000..7dc06a1 --- /dev/null +++ b/bundle/ShareExtensionInfo.plist @@ -0,0 +1,52 @@ + + + + + CFBundleDevelopmentRegion + zh_CN + CFBundleDisplayName + LocalShare + CFBundleExecutable + LocalShareShareExtension + CFBundleIconFile + AppIcon + CFBundleIdentifier + live.ezze.localshare.ShareExtension + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + LocalShareShareExtension + CFBundlePackageType + XPC! + CFBundleShortVersionString + 0.2.0 + CFBundleSupportedPlatforms + + MacOSX + + CFBundleVersion + 1 + LSMinimumSystemVersion + 13.0 + NSExtension + + NSExtensionAttributes + + NSExtensionActivationRule + + NSExtensionActivationDictionaryVersion + 2 + NSExtensionActivationSupportsAttachmentsWithMaxCount + 999 + NSExtensionActivationSupportsFileWithMaxCount + 999 + + + NSExtensionPointIdentifier + com.apple.share-services + NSExtensionPrincipalClass + LocalShareShareExtension.ShareController + + + diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 177e5de..0aa73a2 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -45,6 +45,8 @@ LocalShare/ Package.resolved # Swifter pinned at 1.5.0 build.sh # swift build -c release -> assemble .app -> ad-hoc sign -> dist/ bundle/Info.plist # Static .app Info.plist template: Sparkle feed/key and version placeholders + bundle/ShareExtensionInfo.plist / ShareExtension.entitlements + # macOS Share menu extension metadata and sandbox permission README.md / README_CN.md # English default + Chinese CLAUDE.md # Claude Code working guide; loaded from repo root DESIGN.md # Visual design spec; source references use section numbers from this file @@ -72,6 +74,8 @@ LocalShare/ HeadlessServer.swift # LS_HEADLESS=1 mode and CLI foreground mode CLI.swift / CLIInstaller.swift # argv parsing, GUI forwarding, symlink installer Updater.swift # Sparkle automatic update wrapper, constructed only in GUI path + Sources/LocalShareShareExtension/ + main.swift / ShareController.swift # Finder/System Share menu bridge; forwards file URLs to the host app Tests/LocalShareTests/ # XCTest pure-function tests: traversal, filename sanitation, multi-share keys, i18n, text tools/ # Headless + curl smoke scripts: traversal, filenames, multiselect, upload defang, token 302, md links, language, text ``` @@ -137,6 +141,13 @@ When `FileServer.listenAddress` is non-nil, Swifter binds only that IPv4 address - The installed `localshare` command is a **symlink** to the main binary inside the app bundle. dyld resolves `@executable_path` after realpath, so bundled Sparkle still loads. But CLI code **must not use `Bundle.main`** to locate the `.app`; it resolves `_NSGetExecutablePath`, follows symlinks, then walks up three levels. - **Release compiler trap**: with `-O`, the chain "optional `in_port_t` payload inside enum -> construct `[in_port_t]` inside function -> pass to `FileServer.start`" can miscompile into a bad array pointer. Debug works; release crashes. Workaround: `runForeground` accepts concrete `preferredPorts: [in_port_t]`, and callers unwrap the optional. Any CLI or `HeadlessServer` change must be smoke-tested in release with `--headless`. +### macOS Share Menu Extension + +- `LocalShareShareExtension` is a separate SwiftPM executable target packaged as `Contents/PlugIns/ShareExtension.appex` with `NSExtensionPointIdentifier = com.apple.share-services`. It is intentionally thin: load file URLs from `NSItemProvider`, verify they still exist, then call `NSWorkspace.open(urls, withApplicationAt:)` so the existing `AppDelegate.application(_:open:)` path hot-swaps the share. +- The extension has no Swifter or Sparkle dependency. It links only system frameworks and Swift system libraries, so it stays inside the no package-external dylib rule. +- PluginKit will not list the extension unless the `.appex` is sandbox-signed. `build.sh` signs only the extension with `bundle/ShareExtension.entitlements` (`app-sandbox` + user-selected read-only files); the host app remains unsandboxed because server sharing still needs normal access to user-selected folders. +- Local testing must use an installed app path such as `/Applications/LocalShare.app`; `dist/LocalShare.app` alone may not appear in `pluginkit` or System Settings. After replacing an installed app, open it once, then enable LocalShare under System Settings -> General -> Login Items & Extensions -> Extensions -> Sharing if macOS leaves it disabled. + ### Automatic Updates with Sparkle - Sparkle is imported as an SPM `binaryTarget`. `build.sh` uses `ditto` to copy `Sparkle.framework` into `Contents/Frameworks/`, and `executableTarget` adds `-rpath @executable_path/../Frameworks`. After inside-out ad-hoc signing, `codesign --verify --deep --strict` verifies the bundle. From a5395e739a27d045e21ebb767a644cefab090396 Mon Sep 17 00:00:00 2001 From: shawn Date: Mon, 6 Jul 2026 11:08:32 +0800 Subject: [PATCH 2/7] fix: declare shareable document types --- bundle/Info.plist | 21 +++++++++++++++++++++ docs/ARCHITECTURE.md | 1 + 2 files changed, 22 insertions(+) diff --git a/bundle/Info.plist b/bundle/Info.plist index 8173ebc..3c4a5c9 100644 --- a/bundle/Info.plist +++ b/bundle/Info.plist @@ -12,6 +12,27 @@ AppIcon CFBundleIdentifier live.ezze.localshare + CFBundleDocumentTypes + + + CFBundleTypeExtensions + + * + + CFBundleTypeName + LocalShare Item + CFBundleTypeRole + Viewer + LSHandlerRank + Alternate + LSItemContentTypes + + public.data + public.content + public.folder + + + CFBundlePackageType APPL CFBundleInfoDictionaryVersion diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 0aa73a2..c1be307 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -144,6 +144,7 @@ When `FileServer.listenAddress` is non-nil, Swifter binds only that IPv4 address ### macOS Share Menu Extension - `LocalShareShareExtension` is a separate SwiftPM executable target packaged as `Contents/PlugIns/ShareExtension.appex` with `NSExtensionPointIdentifier = com.apple.share-services`. It is intentionally thin: load file URLs from `NSItemProvider`, verify they still exist, then call `NSWorkspace.open(urls, withApplicationAt:)` so the existing `AppDelegate.application(_:open:)` path hot-swaps the share. +- The host app declares `CFBundleDocumentTypes` for file/folder content. Without this LaunchServices can show "cannot open the specified document or URL" before the app delegate receives the shared URLs. - The extension has no Swifter or Sparkle dependency. It links only system frameworks and Swift system libraries, so it stays inside the no package-external dylib rule. - PluginKit will not list the extension unless the `.appex` is sandbox-signed. `build.sh` signs only the extension with `bundle/ShareExtension.entitlements` (`app-sandbox` + user-selected read-only files); the host app remains unsandboxed because server sharing still needs normal access to user-selected folders. - Local testing must use an installed app path such as `/Applications/LocalShare.app`; `dist/LocalShare.app` alone may not appear in `pluginkit` or System Settings. After replacing an installed app, open it once, then enable LocalShare under System Settings -> General -> Login Items & Extensions -> Extensions -> Sharing if macOS leaves it disabled. From 7d5dc9068ea2d93d6e1b37e506ee639beb3c032f Mon Sep 17 00:00:00 2001 From: shawn Date: Mon, 6 Jul 2026 11:12:56 +0800 Subject: [PATCH 3/7] fix: handle document open callbacks --- Sources/LocalShare/App.swift | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/Sources/LocalShare/App.swift b/Sources/LocalShare/App.swift index 97b23a6..4edc0eb 100644 --- a/Sources/LocalShare/App.swift +++ b/Sources/LocalShare/App.swift @@ -112,8 +112,23 @@ final class AppDelegate: NSObject, NSApplicationDelegate { false } - // CLI 转发(NSWorkspace open)落地处:换分享项并唤回窗口。 + // CLI / Share Extension 转发(NSWorkspace open)落地处:换分享项并唤回窗口。 func application(_ application: NSApplication, open urls: [URL]) { + openSharedURLs(urls) + } + + // 声明 CFBundleDocumentTypes 后,LaunchServices 可能走传统文稿打开回调。 + func application(_ sender: NSApplication, openFiles filenames: [String]) { + openSharedURLs(filenames.map { URL(fileURLWithPath: $0) }) + sender.reply(toOpenOrPrint: .success) + } + + func application(_ sender: NSApplication, openFile filename: String) -> Bool { + openSharedURLs([URL(fileURLWithPath: filename)]) + return true + } + + private func openSharedURLs(_ urls: [URL]) { let existing = urls.filter { FileManager.default.fileExists(atPath: $0.path) } guard !existing.isEmpty else { return } if let state = AppState.shared { From b37f75e0228ba29f3b91ad9a88c3c016132ba467 Mon Sep 17 00:00:00 2001 From: shawn Date: Mon, 6 Jul 2026 11:19:09 +0800 Subject: [PATCH 4/7] fix: forward shared urls without sandbox filtering --- .../ShareController.swift | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/Sources/LocalShareShareExtension/ShareController.swift b/Sources/LocalShareShareExtension/ShareController.swift index 16c967f..c679fd4 100644 --- a/Sources/LocalShareShareExtension/ShareController.swift +++ b/Sources/LocalShareShareExtension/ShareController.swift @@ -9,20 +9,26 @@ final class ShareController: NSObject, NSExtensionRequestHandling { .flatMap { $0.attachments ?? [] } guard !providers.isEmpty else { + NSLog("LocalShare share extension received no item providers") context.completeRequest(returningItems: nil) return } loadFileURLs(from: providers) { urls in - let existing = urls.filter { FileManager.default.fileExists(atPath: $0.path) } - guard !existing.isEmpty, let appURL = Self.hostAppURL() else { + guard !urls.isEmpty else { + NSLog("LocalShare share extension received no file URLs") + context.completeRequest(returningItems: nil) + return + } + guard let appURL = Self.hostAppURL() else { + NSLog("LocalShare share extension could not locate host app") context.completeRequest(returningItems: nil) return } let config = NSWorkspace.OpenConfiguration() config.activates = true - NSWorkspace.shared.open(existing, withApplicationAt: appURL, configuration: config) { _, error in + NSWorkspace.shared.open(urls, withApplicationAt: appURL, configuration: config) { _, error in if let error { NSLog("LocalShare share extension launch failed: \(error.localizedDescription)") } @@ -37,7 +43,11 @@ final class ShareController: NSObject, NSExtensionRequestHandling { var urls: [URL] = [] for provider in providers { - guard let typeIdentifier = Self.supportedTypeIdentifier(for: provider) else { continue } + guard let typeIdentifier = Self.supportedTypeIdentifier(for: provider) else { + let types = provider.registeredTypeIdentifiers.joined(separator: ", ") + NSLog("LocalShare share extension unsupported provider types: \(types)") + continue + } group.enter() provider.loadItem(forTypeIdentifier: typeIdentifier, options: nil) { item, error in defer { group.leave() } From 0f1fa9adf8bb0044dc3ea6acffef46269152c484 Mon Sep 17 00:00:00 2001 From: shawn Date: Mon, 6 Jul 2026 11:23:22 +0800 Subject: [PATCH 5/7] fix: route share extension through url scheme --- Sources/LocalShare/App.swift | 16 +++++++++++++++- .../ShareController.swift | 15 ++++++++++++++- bundle/Info.plist | 11 +++++++++++ docs/ARCHITECTURE.md | 2 +- 4 files changed, 41 insertions(+), 3 deletions(-) diff --git a/Sources/LocalShare/App.swift b/Sources/LocalShare/App.swift index 4edc0eb..fa32e1b 100644 --- a/Sources/LocalShare/App.swift +++ b/Sources/LocalShare/App.swift @@ -114,7 +114,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate { // CLI / Share Extension 转发(NSWorkspace open)落地处:换分享项并唤回窗口。 func application(_ application: NSApplication, open urls: [URL]) { - openSharedURLs(urls) + openSharedURLs(Self.sharedFileURLs(from: urls)) } // 声明 CFBundleDocumentTypes 后,LaunchServices 可能走传统文稿打开回调。 @@ -138,4 +138,18 @@ final class AppDelegate: NSObject, NSApplicationDelegate { Self.pendingOpenURLs += existing } } + + private static func sharedFileURLs(from urls: [URL]) -> [URL] { + urls.flatMap { url -> [URL] in + guard url.scheme == "localshare" else { return [url] } + guard url.host == "share", + let components = URLComponents(url: url, resolvingAgainstBaseURL: false) else { + return [] + } + return (components.queryItems ?? []) + .filter { $0.name == "path" } + .compactMap(\.value) + .map { URL(fileURLWithPath: $0).standardizedFileURL } + } + } } diff --git a/Sources/LocalShareShareExtension/ShareController.swift b/Sources/LocalShareShareExtension/ShareController.swift index c679fd4..518c646 100644 --- a/Sources/LocalShareShareExtension/ShareController.swift +++ b/Sources/LocalShareShareExtension/ShareController.swift @@ -25,10 +25,15 @@ final class ShareController: NSObject, NSExtensionRequestHandling { context.completeRequest(returningItems: nil) return } + guard let shareURL = Self.shareURL(for: urls) else { + NSLog("LocalShare share extension could not build callback URL") + context.completeRequest(returningItems: nil) + return + } let config = NSWorkspace.OpenConfiguration() config.activates = true - NSWorkspace.shared.open(urls, withApplicationAt: appURL, configuration: config) { _, error in + NSWorkspace.shared.open([shareURL], withApplicationAt: appURL, configuration: config) { _, error in if let error { NSLog("LocalShare share extension launch failed: \(error.localizedDescription)") } @@ -96,4 +101,12 @@ final class ShareController: NSObject, NSExtensionRequestHandling { if url.pathExtension == "app" { return url } return NSWorkspace.shared.urlForApplication(withBundleIdentifier: "live.ezze.localshare") } + + private static func shareURL(for urls: [URL]) -> URL? { + var components = URLComponents() + components.scheme = "localshare" + components.host = "share" + components.queryItems = urls.map { URLQueryItem(name: "path", value: $0.path) } + return components.url + } } diff --git a/bundle/Info.plist b/bundle/Info.plist index 3c4a5c9..327c970 100644 --- a/bundle/Info.plist +++ b/bundle/Info.plist @@ -12,6 +12,17 @@ AppIcon CFBundleIdentifier live.ezze.localshare + CFBundleURLTypes + + + CFBundleURLName + live.ezze.localshare.share + CFBundleURLSchemes + + localshare + + + CFBundleDocumentTypes diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index c1be307..387b2d5 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -143,7 +143,7 @@ When `FileServer.listenAddress` is non-nil, Swifter binds only that IPv4 address ### macOS Share Menu Extension -- `LocalShareShareExtension` is a separate SwiftPM executable target packaged as `Contents/PlugIns/ShareExtension.appex` with `NSExtensionPointIdentifier = com.apple.share-services`. It is intentionally thin: load file URLs from `NSItemProvider`, verify they still exist, then call `NSWorkspace.open(urls, withApplicationAt:)` so the existing `AppDelegate.application(_:open:)` path hot-swaps the share. +- `LocalShareShareExtension` is a separate SwiftPM executable target packaged as `Contents/PlugIns/ShareExtension.appex` with `NSExtensionPointIdentifier = com.apple.share-services`. It is intentionally thin: load file URLs from `NSItemProvider`, encode their paths into `localshare://share?path=...`, then open that URL with the host app so `AppDelegate.application(_:open:)` hot-swaps the share. - The host app declares `CFBundleDocumentTypes` for file/folder content. Without this LaunchServices can show "cannot open the specified document or URL" before the app delegate receives the shared URLs. - The extension has no Swifter or Sparkle dependency. It links only system frameworks and Swift system libraries, so it stays inside the no package-external dylib rule. - PluginKit will not list the extension unless the `.appex` is sandbox-signed. `build.sh` signs only the extension with `bundle/ShareExtension.entitlements` (`app-sandbox` + user-selected read-only files); the host app remains unsandboxed because server sharing still needs normal access to user-selected folders. From 647489fe338b08f799a27fa16216a81f0e0e74e9 Mon Sep 17 00:00:00 2001 From: shawn Date: Mon, 6 Jul 2026 11:53:22 +0800 Subject: [PATCH 6/7] fix: receive share callback urls in swiftui --- Sources/LocalShare/App.swift | 12 ++++++++++-- docs/ARCHITECTURE.md | 2 +- 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/Sources/LocalShare/App.swift b/Sources/LocalShare/App.swift index fa32e1b..18b4474 100644 --- a/Sources/LocalShare/App.swift +++ b/Sources/LocalShare/App.swift @@ -29,7 +29,15 @@ struct LocalShareApp: App { var body: some Scene { Window("LocalShare", id: "main") { - ContentView().environmentObject(state).environmentObject(updater) + ContentView() + .environmentObject(state) + .environmentObject(updater) + .onOpenURL { url in + let urls = AppDelegate.sharedFileURLs(from: [url]) + guard !urls.isEmpty else { return } + state.setShared(urls) + state.showMainWindow() + } } .windowStyle(.hiddenTitleBar) // 全幅出血:暖底铺到顶,红绿灯浮于内容之上 // 票据风竖窗(设计稿 400×720)。数字与「恢复默认尺寸」共用 AppState 的常量。 @@ -139,7 +147,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate { } } - private static func sharedFileURLs(from urls: [URL]) -> [URL] { + static func sharedFileURLs(from urls: [URL]) -> [URL] { urls.flatMap { url -> [URL] in guard url.scheme == "localshare" else { return [url] } guard url.host == "share", diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 387b2d5..6679efc 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -143,7 +143,7 @@ When `FileServer.listenAddress` is non-nil, Swifter binds only that IPv4 address ### macOS Share Menu Extension -- `LocalShareShareExtension` is a separate SwiftPM executable target packaged as `Contents/PlugIns/ShareExtension.appex` with `NSExtensionPointIdentifier = com.apple.share-services`. It is intentionally thin: load file URLs from `NSItemProvider`, encode their paths into `localshare://share?path=...`, then open that URL with the host app so `AppDelegate.application(_:open:)` hot-swaps the share. +- `LocalShareShareExtension` is a separate SwiftPM executable target packaged as `Contents/PlugIns/ShareExtension.appex` with `NSExtensionPointIdentifier = com.apple.share-services`. It is intentionally thin: load file URLs from `NSItemProvider`, encode their paths into `localshare://share?path=...`, then open that URL with the host app. SwiftUI `.onOpenURL` is the primary receiver, with AppDelegate open callbacks kept as fallback paths. - The host app declares `CFBundleDocumentTypes` for file/folder content. Without this LaunchServices can show "cannot open the specified document or URL" before the app delegate receives the shared URLs. - The extension has no Swifter or Sparkle dependency. It links only system frameworks and Swift system libraries, so it stays inside the no package-external dylib rule. - PluginKit will not list the extension unless the `.appex` is sandbox-signed. `build.sh` signs only the extension with `bundle/ShareExtension.entitlements` (`app-sandbox` + user-selected read-only files); the host app remains unsandboxed because server sharing still needs normal access to user-selected folders. From 86b5a97ae6c4c8d869fcadf59b44484b0ae9ee83 Mon Sep 17 00:00:00 2001 From: shawn Date: Mon, 6 Jul 2026 12:15:52 +0800 Subject: [PATCH 7/7] fix: reject invalid share callback paths --- Sources/LocalShare/App.swift | 1 + Tests/LocalShareTests/CallbackURLTests.swift | 18 ++++++++++++++++++ 2 files changed, 19 insertions(+) create mode 100644 Tests/LocalShareTests/CallbackURLTests.swift diff --git a/Sources/LocalShare/App.swift b/Sources/LocalShare/App.swift index 18b4474..710e0dc 100644 --- a/Sources/LocalShare/App.swift +++ b/Sources/LocalShare/App.swift @@ -157,6 +157,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate { return (components.queryItems ?? []) .filter { $0.name == "path" } .compactMap(\.value) + .filter { !$0.isEmpty && ($0 as NSString).isAbsolutePath } .map { URL(fileURLWithPath: $0).standardizedFileURL } } } diff --git a/Tests/LocalShareTests/CallbackURLTests.swift b/Tests/LocalShareTests/CallbackURLTests.swift new file mode 100644 index 0000000..527a446 --- /dev/null +++ b/Tests/LocalShareTests/CallbackURLTests.swift @@ -0,0 +1,18 @@ +import XCTest +@testable import LocalShare + +final class CallbackURLTests: XCTestCase { + @MainActor + func testShareCallbackAcceptsAbsolutePaths() { + let url = URL(string: "localshare://share?path=/tmp/a.txt&path=/Users/me/docs")! + let paths = AppDelegate.sharedFileURLs(from: [url]).map(\.path) + XCTAssertEqual(paths, ["/tmp/a.txt", "/Users/me/docs"]) + } + + @MainActor + func testShareCallbackRejectsEmptyAndRelativePaths() { + let url = URL(string: "localshare://share?path=&path=foo&path=../bar&path=/tmp/ok.txt")! + let paths = AppDelegate.sharedFileURLs(from: [url]).map(\.path) + XCTAssertEqual(paths, ["/tmp/ok.txt"]) + } +}