Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
42 changes: 40 additions & 2 deletions Sources/LocalShare/App.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 的常量。
Expand Down Expand Up @@ -112,8 +120,23 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
false
}

// CLI 转发(NSWorkspace open)落地处:换分享项并唤回窗口。
// CLI / Share Extension 转发(NSWorkspace open)落地处:换分享项并唤回窗口。
func application(_ application: NSApplication, open urls: [URL]) {
openSharedURLs(Self.sharedFileURLs(from: 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 {
Expand All @@ -123,4 +146,19 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
Self.pendingOpenURLs += existing
}
}

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)
.filter { !$0.isEmpty && ($0 as NSString).isAbsolutePath }
.map { URL(fileURLWithPath: $0).standardizedFileURL }
}
}
}
112 changes: 112 additions & 0 deletions Sources/LocalShareShareExtension/ShareController.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
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 {
NSLog("LocalShare share extension received no item providers")
context.completeRequest(returningItems: nil)
return
}

loadFileURLs(from: providers) { urls in
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
}
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([shareURL], 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 {
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() }
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")
}

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
}
}
10 changes: 10 additions & 0 deletions Sources/LocalShareShareExtension/main.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import Darwin
import Foundation

@_silgen_name("NSExtensionMain")
private func foundationNSExtensionMain(
_ argc: Int32,
_ argv: UnsafeMutablePointer<UnsafeMutablePointer<CChar>?>
) -> Int32

exit(foundationNSExtensionMain(CommandLine.argc, CommandLine.unsafeArgv))
18 changes: 18 additions & 0 deletions Tests/LocalShareTests/CallbackURLTests.swift
Original file line number Diff line number Diff line change
@@ -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"])
}
}
60 changes: 41 additions & 19 deletions build.sh
Original file line number Diff line number Diff line change
Expand Up @@ -5,26 +5,40 @@ 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)

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;
Expand All @@ -36,34 +50,42 @@ 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(放宽后的核心戒律)"
# 核心戒律:不依赖任何包外 dylib——运行时若去包外路径(/opt/homebrew 等)找库,换台没装的机器就缺库崩溃。这里逐条检查主二进制依赖:
# 系统库放行;@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 "==> 验证签名有效性"
Expand Down
32 changes: 32 additions & 0 deletions bundle/Info.plist
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,38 @@
<string>AppIcon</string>
<key>CFBundleIdentifier</key>
<string>live.ezze.localshare</string>
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleURLName</key>
<string>live.ezze.localshare.share</string>
<key>CFBundleURLSchemes</key>
<array>
<string>localshare</string>
</array>
</dict>
</array>
<key>CFBundleDocumentTypes</key>
<array>
<dict>
<key>CFBundleTypeExtensions</key>
<array>
<string>*</string>
</array>
<key>CFBundleTypeName</key>
<string>LocalShare Item</string>
<key>CFBundleTypeRole</key>
<string>Viewer</string>
<key>LSHandlerRank</key>
<string>Alternate</string>
<key>LSItemContentTypes</key>
<array>
<string>public.data</string>
<string>public.content</string>
<string>public.folder</string>
</array>
</dict>
</array>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleInfoDictionaryVersion</key>
Expand Down
11 changes: 11 additions & 0 deletions bundle/ShareExtension.entitlements
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
"http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.app-sandbox</key>
<true/>
<key>com.apple.security.files.user-selected.read-only</key>
<true/>
</dict>
</plist>
Loading
Loading