diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 381d9c1..b2994e1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -32,6 +32,9 @@ jobs: runs-on: macos-latest # 与 release 同款 runner,自带 Xcode / Swift 工具链与 Node steps: - uses: actions/checkout@v7 + - uses: actions/setup-go@v7 + with: + go-version: '1.23' - name: 编译 debug(首次会拉取 Swifter / Sparkle) run: swift build @@ -70,6 +73,18 @@ jobs: - name: 网页逐请求语言(Accept-Language) run: ./tools/smoke-accept-language.sh + - name: 远程访问只读策略 + run: ./tools/smoke-remote-readonly.sh + + - name: 远程访问完整链路 + run: ./tools/smoke-remote-e2e.sh + + - name: Release 模式远程访问完整链路 + run: | + swift build -c release + BIN=.build/release/LocalShare ./tools/smoke-remote-readonly.sh + CLIENT_BIN=.build/release/LocalShare ./tools/smoke-remote-e2e.sh + - name: 传递文本(/ls/text + 转义 + 虚拟根文本行) run: ./tools/smoke-text.sh diff --git a/AGENTS.md b/AGENTS.md index c886a4f..ec8abfc 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -109,6 +109,9 @@ Release flow: - **Threading model**: Swifter callbacks run on socket threads, while `AppState` is `@MainActor`. Shared mutable server state is protected by one `NSLock`: `share`, `token`, `uploadEnabled`, `lastSeen`, `nameCache`, and `nameLookupInFlight`. - **Share changes do not restart the server** when the port is unchanged, but they rotate tokens immediately. Change the key before changing content. - **`.raw` has no keep-alive and unknown body length**, so file responses must write `Content-Length`. +- **Remote mount prefix**: generated HTML must use relative navigation/heartbeat endpoints so it works under both `/` + and `/share//`; remote requests must carry the registered `share_id`, and the Client must reject stale IDs + before forwarding to `FileServer`. ## Design Constraint diff --git a/README.md b/README.md index e153bb3..0d65a3d 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ [简体中文](README_CN.md) | English -A small macOS app that moves files and text between your Mac and other devices (phone, tablet…) over WiFi — the other end just uses a browser, with no client to install. +A small macOS app that moves files and text between your Mac and other devices (phone, tablet…) over WiFi, with optional browser-only remote sharing — the other end just uses a browser, with no client to install. - Pick a file or folder, scan the QR code that appears (or just open the link), and browse the files in your phone's browser (HTML, PDF, Markdown, images…) - Two-way text transfer — type some text and share it via a QR code, or send text back from the phone to the Mac @@ -22,6 +22,7 @@ A small macOS app that moves files and text between your Mac and other devices ( - After you share, the other end can send files and text back - Shows who's currently viewing - `localshare` command line: bring up the window to share, or `--headless` to print the link and QR code in the terminal +- Optional browser-only remote access through a self-hosted LocalShare Server; remote shares are read-only ## Why this app exists @@ -40,6 +41,10 @@ The QR code points to something like `http://192.168.x.x:8080/?t=` > ⚠️ Traffic is plain HTTP (unencrypted). Best avoided on public networks like airports and cafés. +### Remote browser sharing + +Run the self-contained [`localshare-server`](server/README.md), create a one-time Enrollment Key, then enter the Server address and key in Settings. After pairing, the app remembers the device credential in Keychain; active shares use Connect/Disconnect and the receiving device only needs a browser. See the [remote-sharing plan](docs/REMOTE_SHARING_PLAN.md). + ## Terminal usage In Settings → Command-Line Tool, click "Install". After that you can share from the terminal in one command: diff --git a/README_CN.md b/README_CN.md index a8f886e..0345907 100644 --- a/README_CN.md +++ b/README_CN.md @@ -2,7 +2,7 @@ 简体中文 | [English](README.md) -一个 macOS 小工具,在本机和手机等设备之间通过 WiFi 互通文件和文本,对端使用浏览器,无须安装客户端。 +一个 macOS 小工具,在本机和手机等设备之间通过 WiFi 互通文件和文本,也支持可选的纯浏览器跨网络分享,对端无须安装客户端。 - 选中文件或文件夹,相机扫描出现的二维码,或直接打开链接,在手机浏览器里浏览这些文件(HTML、PDF、Markdown、图片……) - 支持双向传送文本,编辑文字通过二维码分享后传输,并可从手机端发送文本回 Mac @@ -22,6 +22,7 @@ - 支持分享后,由对端反向传输文件、文本 - 显示当前在线访客 - 命令行 `localshare` 命令:唤起窗口分享,或 `--headless` 在终端显示链接和二维码 +- 可选通过自托管 LocalShare Server 远程访问浏览器;远程分享只读 ## 为什么有这个 app @@ -40,6 +41,10 @@ > ⚠️ 传输是明文 HTTP(没有加密)。机场咖啡厅等公共网络下最好不要使用。 +### 远程浏览器分享 + +运行自包含的 [`localshare-server`](server/README.md),生成一次性 Enrollment Key,在「设置」里填写 Server 地址和 key。配对后设备凭据会保存到 Keychain;正在分享时只需点击 Connect/Disconnect,对端设备只需浏览器。详见[远程分享计划](docs/REMOTE_SHARING_PLAN.md)。 + ## 终端用法 在「设置 → 命令行工具」里点「安装」,之后可以在终端一键分享: diff --git a/Sources/LocalShare/App.swift b/Sources/LocalShare/App.swift index 710e0dc..11e7664 100644 --- a/Sources/LocalShare/App.swift +++ b/Sources/LocalShare/App.swift @@ -115,6 +115,10 @@ final class AppDelegate: NSObject, NSApplicationDelegate { NSApp.activate(ignoringOtherApps: true) } + func applicationWillTerminate(_ notification: Notification) { + AppState.shared?.stop() + } + // 关窗不退出:菜单栏图标常驻,服务继续广播;退出走菜单栏「退出」或 ⌘Q。 func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { false diff --git a/Sources/LocalShare/AppState.swift b/Sources/LocalShare/AppState.swift index 313f65f..069f27a 100644 --- a/Sources/LocalShare/AppState.swift +++ b/Sources/LocalShare/AppState.swift @@ -1,5 +1,6 @@ import AppKit import SwiftUI +import Network // 全局状态:当前分享对象(文件夹或单个文件)、服务运行态、网络接口候选、二维码 URL、 // 监听端口配置、最近分享历史、屏幕路由、权限(读取常开,上传可切换)。 @@ -56,6 +57,13 @@ final class AppState: ObservableObject { @Published var tailscaleAccessEnabled = false // Tailscale 入站闸门(默认关,持久化) @Published private(set) var tailscaleStatus: TailscaleStatus? @Published var cliStatus: CLIInstaller.Status = .notInstalled // 命令行工具安装状态 + @Published var remoteSettings = RemoteSettings() + @Published var remoteEnrollmentKey = "" // 仅用于首次配对;成功后不落盘 + @Published private(set) var remoteAccessEnabled = false + @Published private(set) var remoteStatus: RemoteAgent.Status = .disconnected + @Published private(set) var remotePaired = false + @Published private(set) var remoteShareBaseURL: String? + @Published private(set) var remoteError: String? // GUI 进程仅构造一次,init 末尾自登记;AppDelegate 的 open 事件回调经它触达状态。 static private(set) var shared: AppState? @@ -66,6 +74,9 @@ final class AppState: ObservableObject { @Published private(set) var accessCode = AccessCode.generate() private var server: FileServer? private var viewerTimer: Timer? + private let remoteAgent = RemoteAgent() + private var networkMonitor: NWPathMonitor? + private var networkPathStatus: NWPath.Status? private let shareAutoStop = ShareAutoStop() private var networkRefreshID = UUID() private var wideLayoutBeforeSettings: Bool? @@ -84,6 +95,7 @@ final class AppState: ObservableObject { private let textInboxKey = "textInboxEnabled" // 收件箱闸门(默认关) private let persistReceivedKey = "persistReceivedText" // 是否记住收到的文本(默认关) private let receivedTextsKey = "receivedTexts" // 收件箱内容(仅 persistReceivedText 开时写入) + private let remoteServerKey = "remoteServerAddress" private let accessCodeEnabledKey = "accessCodeEnabled" private let tailscaleAccessEnabledKey = "tailscaleAccessEnabled" // 配置端口优先,其余作回退(8080 列入回退以防配置端口占用)。 @@ -97,6 +109,24 @@ final class AppState: ObservableObject { shareAutoStop.onExpire = { [weak self] in self?.stop() } let savedPort = UserDefaults.standard.integer(forKey: portKey) if (1024...65535).contains(savedPort) { configuredPort = in_port_t(savedPort) } + remoteSettings = RemoteSettings(serverAddress: UserDefaults.standard.string(forKey: remoteServerKey) ?? "") + remotePaired = remoteAgent.isPaired(for: remoteSettings.serverURL) + remoteAgent.onStateChange = { [weak self] status, error in + guard let self else { return } + self.remoteStatus = status + self.remoteError = error + if status == .connected { + self.remotePaired = self.remoteAgent.isPaired + self.remoteEnrollmentKey = "" + } else if status == .disconnected, error != nil, self.remoteAccessEnabled { + // 首次配对/凭证读取失败是终止态,不应把未连接的 LAN 分享继续锁在远程只读模式。 + self.remoteAccessEnabled = false + self.applyRemotePolicy() + } + } + remoteAgent.onShareURLChange = { [weak self] url in + self?.remoteShareBaseURL = url + } accessCodeEnabled = UserDefaults.standard.bool(forKey: accessCodeEnabledKey) tailscaleAccessEnabled = UserDefaults.standard.bool(forKey: tailscaleAccessEnabledKey) legacyBindSelectedOnly = UserDefaults.standard.bool(forKey: bindSelectedOnlyKey) @@ -139,6 +169,7 @@ final class AppState: ObservableObject { AppDelegate.pendingOpenURLs = [] setShared(urls) } + startNetworkMonitoring() } // MARK: - 选择态派生 @@ -158,17 +189,23 @@ final class AppState: ObservableObject { // MARK: - 派生 URL / 二维码 // 文件夹/多选模式 → 根地址;单文件模式 → 直链该文件(路径仅供浏览器显示文件名/扩展名)。 - private func makeURL(host: String) -> String { - let q = "?t=\(token)" + private func makeURL(base: String, authenticated: Bool = true) -> String { + let q = authenticated ? "?t=\(token)" : "" + let root = base.hasSuffix("/") ? String(base.dropLast()) : base // 传递文本(收/发合一):二维码恒指 /ls/text——这一页既显示电脑共享的文本(可读可复制), // 又在「允许手机发回来」开着时挂出发送框;只收文本时它退化成纯发送页。扫一次,双向都在这。 - if isTextOnly || isReceiveOnly { return "http://\(host):\(port)/ls/text\(q)" } + if isTextOnly || isReceiveOnly { return "\(root)/ls/text\(q)" } // 单文件直链该文件(文本与文件共存时走虚拟根,不直链,故附带 !hasText)。 if sharedIsFile, !hasText, let name = sharedItems.first?.lastPathComponent, let enc = name.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) { - return "http://\(host):\(port)/\(enc)\(q)" + return "\(root)/\(enc)\(q)" } - return "http://\(host):\(port)/\(q)" + return "\(root)/\(q)" + } + + var remoteURL: String? { + guard remoteAccessEnabled, isRunning, let base = remoteShareBaseURL else { return nil } + return makeURL(base: base, authenticated: false) } private var primaryHost: String? { @@ -176,6 +213,7 @@ final class AppState: ObservableObject { } var primaryURL: String? { + if let remoteURL { return remoteURL } guard isRunning, port != 0, let host = primaryHost else { return nil } return makeURL(host: host) } @@ -183,12 +221,13 @@ final class AppState: ObservableObject { // 票据上的手输入口:默认仍显示/复制完整 token URL;访问码模式只给 origin,浏览器再经 /ls/join // 输入短码。二维码始终使用 primaryURL,不因展示模式降低安全性或增加扫码步骤。 var presentedURL: String? { + if let remoteURL { return remoteURL } guard isRunning, port != 0, let host = primaryHost else { return nil } return makePresentedURL(host: host) } var presentedAccessCode: String? { - isRunning && accessCodeEnabled ? accessCode : nil + isRunning && remoteURL == nil && accessCodeEnabled ? accessCode : nil } var qrImage: NSImage? { @@ -199,7 +238,7 @@ final class AppState: ObservableObject { // 主地址之外的入口统一收在折叠区。访问码模式下它们只显示 origin,与主地址共用一枚短码; // 普通模式则保留当前分享路径和 token。Tailscale 两项只在用户显式开放且检测到时出现。 var otherAddresses: [ShareAddress] { - guard isRunning, port != 0 else { return [] } + guard isRunning, port != 0, remoteURL == nil else { return [] } var result: [ShareAddress] = [] if selectedInterface != nil, let hostname = NetworkInfo.localHostName(), hostname != primaryHost { result.append(ShareAddress(kind: .localHostname, scope: .localNetwork, @@ -222,6 +261,10 @@ final class AppState: ObservableObject { accessCodeEnabled ? "http://\(host):\(port)" : makeURL(host: host) } + private func makeURL(host: String) -> String { + makeURL(base: "http://\(host):\(port)") + } + var hasNetwork: Bool { !interfaces.isEmpty || (tailscaleAccessEnabled && tailscaleStatus != nil) } // MARK: - 网络 @@ -246,6 +289,115 @@ final class AppState: ObservableObject { } } + var effectivePermission: Permission { remoteAccessEnabled ? Permission() : permission } + + var canEnableRemote: Bool { isRunning && !sharedItems.isEmpty && remoteSettings.isValid } + + func saveRemoteSettings(_ settings: RemoteSettings, enrollmentKey: String = "") { + if remoteAccessEnabled { disconnectRemote() } + remoteSettings = settings + remoteEnrollmentKey = enrollmentKey + UserDefaults.standard.set(settings.serverAddress, forKey: remoteServerKey) + remotePaired = remoteAgent.isPaired(for: remoteSettings.serverURL) + } + + func connectRemote(enrollmentKey: String) { + guard canEnableRemote, let serverURL = remoteSettings.serverURL else { + remoteError = L.remoteConfigHint(lang) + return + } + remoteAccessEnabled = true + remoteError = nil + applyRemotePolicy() + remoteAgent.connect(serverURL: serverURL, enrollmentKey: enrollmentKey, + localBaseURL: localShareBaseURL, localToken: token, + deviceName: Host.current().localizedName ?? "Mac", lang: lang) + } + + func pairRemote(enrollmentKey: String) async -> String? { + guard let serverURL = remoteSettings.serverURL else { return L.remoteConfigHint(lang) } + do { + try await remoteAgent.pair(serverURL: serverURL, enrollmentKey: enrollmentKey, + deviceName: Host.current().localizedName ?? "Mac", lang: lang) + remotePaired = true + remoteEnrollmentKey = "" + remoteError = nil + return nil + } catch { + remotePaired = remoteAgent.isPaired(for: serverURL) + return error.localizedDescription + } + } + + func disconnectRemote() { + remoteAgent.disconnect() + remoteAccessEnabled = false + remoteShareBaseURL = nil + remotePaired = remoteAgent.isPaired + applyRemotePolicy() + } + + func forgetRemoteDevice() { + remoteAccessEnabled = false + remoteShareBaseURL = nil + applyRemotePolicy() + do { + try remoteAgent.forgetDevice() + remotePaired = false + } catch let failure as RemoteKeychain.Failure { + remotePaired = remoteAgent.isPaired + remoteError = LStr.remoteKeychainFailed(failure.reason, lang) + } catch { + remotePaired = remoteAgent.isPaired + remoteError = LStr.remoteKeychainFailed(error.localizedDescription, lang) + } + } + + private var agentLocalAddress: String { + legacyBindSelectedOnly ? (selectedInterface?.ip ?? "127.0.0.1") : "127.0.0.1" + } + + private var localShareBaseURL: URL { + URL(string: "http://\(agentLocalAddress):\(port)")! + } + + private func applyRemotePolicy() { + server?.remoteAccessEnabled = remoteAccessEnabled + server?.uploadEnabled = permission.add && canToggleUpload && !remoteAccessEnabled + server?.textInboxEnabled = textInboxEnabled && !remoteAccessEnabled + server?.sharedText = remoteAccessEnabled ? nil : (hasText ? sharedText : nil) + } + + private func startRemoteAgent() { + guard remoteAccessEnabled, isRunning, let serverURL = remoteSettings.serverURL else { return } + remoteAgent.connect(serverURL: serverURL, enrollmentKey: "", localBaseURL: localShareBaseURL, + localToken: token, deviceName: Host.current().localizedName ?? "Mac", lang: lang) + } + + private func startNetworkMonitoring() { + let monitor = NWPathMonitor() + networkMonitor = monitor + let queue = DispatchQueue(label: "localshare.network-monitor") + monitor.pathUpdateHandler = { [weak self] path in + let status = path.status + Task { @MainActor [weak self] in + guard let self else { return } + let statusChanged = self.networkPathStatus != status + self.networkPathStatus = status + guard self.isRunning else { return } + let oldInterfaces = self.interfaces + self.refreshNetwork() + guard self.remoteAccessEnabled else { return } + if self.legacyBindSelectedOnly && oldInterfaces != self.interfaces { + self.rebindServer() + } else if statusChanged || oldInterfaces != self.interfaces { + self.startRemoteAgent() + } + } + } + monitor.start(queue: queue) + } + // MARK: - 选择分享对象 func pickFolder() { pick(files: false, directories: true, message: L.pickFolderMsg(lang)) } @@ -288,8 +440,11 @@ final class AppState: ObservableObject { server?.setCredentials(token: token, accessCode: accessCodeEnabled ? accessCode : nil) server?.tailscaleAccessEnabled = tailscaleAccessEnabled server?.sharedText = hasText ? sharedText : nil - server?.textInboxEnabled = textInboxEnabled server?.share = currentShare + applyRemotePolicy() + if remoteAccessEnabled, isRunning { + remoteAgent.updateLocalShare(baseURL: localShareBaseURL, token: token) + } } // 分享 / 更新一段文本(Mac→手机)。token 的「会话」维度与分享文件一致:只在会话边界轮换 @@ -358,11 +513,12 @@ final class AppState: ObservableObject { // 仅「单个文件夹」分享有上传落点;单文件/多选时设置页开关置灰。 // 附带文本会把分享转成虚拟根(无单一落点),故此时上传也不可用。 - var canToggleUpload: Bool { sharedItems.count == 1 && !sharedIsFile && !hasText } + var canToggleUpload: Bool { !remoteAccessEnabled && sharedItems.count == 1 && !sharedIsFile && !hasText } func setUploadAllowed(_ on: Bool) { + guard !remoteAccessEnabled else { return } permission.add = on && canToggleUpload - server?.uploadEnabled = permission.add + applyRemotePolicy() } private func resetUpload() { @@ -392,11 +548,12 @@ final class AppState: ObservableObject { // 「允许收文本」闸门。开:不限分享形态都能开,开了就把服务拉起(无分享时即「只收模式」),不轮换 token、 // 不重启(运行中只切 server 标志,发送表单随之显隐、已发链接继续有效)。关:若再无其它分享则停服务。 func setTextInboxEnabled(_ on: Bool) { + guard !(remoteAccessEnabled && on) else { return } guard on != textInboxEnabled else { return } textInboxEnabled = on UserDefaults.standard.set(on, forKey: textInboxKey) if isRunning { - if isServing { server?.textInboxEnabled = on } // 仍有理由服务:原地切标志 + if isServing { applyRemotePolicy() } // 仍有理由服务:原地切标志 else { stop() } // 关掉且无其它分享 → 拆服务,回到空状态 } else if isServing { start() // 从空状态开启 → 起服务(只收模式) @@ -477,8 +634,9 @@ final class AppState: ObservableObject { fs.accessCode = accessCodeEnabled ? accessCode : nil fs.tailscaleAccessEnabled = tailscaleAccessEnabled fs.sharedText = hasText ? sharedText : nil - fs.uploadEnabled = permission.add && canToggleUpload - fs.textInboxEnabled = textInboxEnabled + fs.remoteAccessEnabled = remoteAccessEnabled + fs.uploadEnabled = permission.add && canToggleUpload && !remoteAccessEnabled + fs.textInboxEnabled = textInboxEnabled && !remoteAccessEnabled fs.onUpload = { url in // socket 线程 → 主线程 Task { @MainActor [weak self] in self?.recordReceived(url) } } @@ -501,6 +659,7 @@ final class AppState: ObservableObject { isRunning = true lastError = nil startViewerPolling() + if remoteAccessEnabled { startRemoteAgent() } if !wasRunning { resetAutoStop() } } catch { lastError = LStr.startFailed(error.localizedDescription, lang) @@ -521,12 +680,14 @@ final class AppState: ObservableObject { guard isRunning else { return } viewerTimer?.invalidate() viewerTimer = nil + if remoteAccessEnabled { remoteAgent.disconnect() } server?.stop() server = nil start() } func stop() { + disconnectRemote() shareAutoStop.cancel() autoStopAt = nil viewerTimer?.invalidate() diff --git a/Sources/LocalShare/Components.swift b/Sources/LocalShare/Components.swift index 25abc75..320fbc9 100644 --- a/Sources/LocalShare/Components.swift +++ b/Sources/LocalShare/Components.swift @@ -1,4 +1,19 @@ import SwiftUI + +struct GlobeLineIcon: View { + let color: Color + + var body: some View { + ZStack { + Circle().stroke(color, lineWidth: 1.7) + Ellipse().stroke(color, lineWidth: 1.6).frame(width: 7, height: 15) + Capsule().fill(color).frame(width: 13, height: 1.6) + } + .frame(width: 15, height: 15) + .frame(width: 16, height: 16) + .accessibilityHidden(true) + } +} import AppKit // 票据风 UI 组件库(DESIGN.md §5)。全部接受 `t: Theme` 显式传入(与设计稿 props 结构一一对应), diff --git a/Sources/LocalShare/CsvViewer.swift b/Sources/LocalShare/CsvViewer.swift index 8438c8c..72ad8e5 100644 --- a/Sources/LocalShare/CsvViewer.swift +++ b/Sources/LocalShare/CsvViewer.swift @@ -5,9 +5,10 @@ import Foundation // 首行恒作表头。点击表头排序(采样判定数值列走数值比较)、输入框按任意单元格筛选行; // 排序/筛选作用于全量数据,DOM 按 1000 行一档「再显示」渐进渲染——几十 MB 不卡死页面。 enum CsvViewer { - static func html(fileName: String, crumbs: String?, canUpload: Bool, canReceiveText: Bool = false, lang: Lang) -> String { + static func html(fileName: String, requestPath: String = "/", crumbs: String?, canUpload: Bool, + canReceiveText: Bool = false, lang: Lang) -> String { PreviewPage.html( - fileName: fileName, crumbs: crumbs, canUpload: canUpload, lang: lang, + fileName: fileName, requestPath: requestPath, crumbs: crumbs, canUpload: canUpload, lang: lang, body: """
diff --git a/Sources/LocalShare/DirectoryListing.swift b/Sources/LocalShare/DirectoryListing.swift index 05a668b..02bbe57 100644 --- a/Sources/LocalShare/DirectoryListing.swift +++ b/Sources/LocalShare/DirectoryListing.swift @@ -8,7 +8,8 @@ import UniformTypeIdentifiers // 文件夹始终分组在前;③ 类型 chips 真实过滤;④ 计数随过滤显示 N / total。无 emoji、无彩色填充图标。 // 导航:非根列表首行固定「返回上一级」(.row.back,不参与搜索/排序/过滤,空目录也保留); // 目录行原地进入;列表模式文件行新标签打开,方格模式图片/视频由 MediaGallery 在页内预览。 -// 只用系统字体栈 + 内联原生 JS,零外部依赖、局域网离线可渲染。href 用绝对路径并逐段编码。 +// 只用系统字体栈 + 内联原生 JS,零外部依赖、局域网离线可渲染。href 使用相对路径并逐段编码, +// 同时兼容 LAN 根路径与远程 `/share//` 挂载前缀。 enum DirectoryListing { private static let dateFmt: DateFormatter = { let f = DateFormatter() @@ -20,7 +21,7 @@ enum DirectoryListing { // 真实目录页:枚举目录 → 目录在前/名称序 → 交给渲染核心(href 基路径 = 请求路径)。 // canUpload:单文件夹分享且开了访客上传时为 true,页面出上传按钮 + 整页拖拽,措辞联动「可读写」。 static func html(directory: URL, requestPath: String, rootName: String, canUpload: Bool = false, - canReceiveText: Bool = false, lang: Lang) -> String { + canReceiveText: Bool = false, canBatchDownload: Bool = true, lang: Lang) -> String { let fm = FileManager.default let urls = (try? fm.contentsOfDirectory( at: directory, @@ -34,31 +35,30 @@ enum DirectoryListing { return a.lastPathComponent.localizedStandardCompare(b.lastPathComponent) == .orderedAscending } - let base = requestPath.hasSuffix("/") ? requestPath : requestPath + "/" let entries = sorted.map { (name: $0.lastPathComponent, url: $0, isDir: isDirectory($0)) } - return render(entries: entries, base: base, requestPath: requestPath, rootName: rootName, - canUpload: canUpload, canReceiveText: canReceiveText, textPreview: nil, lang: lang) + return render(entries: entries, requestPath: requestPath, rootName: rootName, + canUpload: canUpload, canReceiveText: canReceiveText, canBatchDownload: canBatchDownload, textPreview: nil, lang: lang) } // 多选虚拟根页:选中项无共同磁盘根,直接给定 (显示名=key, 真实 url, 是否目录) 列表渲染。 - // href 基路径为根 `/`,请求路径为 `/`(面包屑只显根名);同样目录在前/名称序。 + // 请求路径为 `/`(面包屑只显根名);同样目录在前/名称序。 // textPreview 非 nil:在文件项之上钉一个指向 /ls/text 的「文本」行(首行预览)——文本与文件共存、 // 或纯文本分享(items 为空)时由 FileServer 传入;它不参与搜索/排序/筛选(同「返回上一级」行)。 static func html(items: [(name: String, url: URL, isDir: Bool)], rootName: String, - textPreview: String? = nil, canReceiveText: Bool = false, lang: Lang) -> String { + textPreview: String? = nil, canReceiveText: Bool = false, canBatchDownload: Bool = true, lang: Lang) -> String { let sorted = items.sorted { a, b in if a.isDir != b.isDir { return a.isDir } return a.name.localizedStandardCompare(b.name) == .orderedAscending } - return render(entries: sorted, base: "/", requestPath: "/", rootName: rootName, - canUpload: false, canReceiveText: canReceiveText, textPreview: textPreview, lang: lang) + return render(entries: sorted, requestPath: "/", rootName: rootName, + canUpload: false, canReceiveText: canReceiveText, canBatchDownload: canBatchDownload, textPreview: textPreview, lang: lang) } // 渲染核心:给定条目(显示名 + 真实 url + 是否目录) + href 基路径 + 请求路径 + 根名,产出整页。 // 类型/扩展名按「真实文件名」判定(url.lastPathComponent),与显示名 key 解耦。 private static func render(entries: [(name: String, url: URL, isDir: Bool)], - base: String, requestPath: String, rootName: String, canUpload: Bool, - canReceiveText: Bool, textPreview: String?, lang: Lang) -> String { + requestPath: String, rootName: String, canUpload: Bool, + canReceiveText: Bool, canBatchDownload: Bool, textPreview: String?, lang: Lang) -> String { let fm = FileManager.default var rows = "" var folderCount = 0 @@ -68,7 +68,7 @@ enum DirectoryListing { let vals = try? e.url.resourceValues(forKeys: [.contentTypeKey, .fileSizeKey, .creationDateKey, .contentModificationDateKey]) let cat = FileType.category(isDir: dir, contentType: vals?.contentType, name: e.url.lastPathComponent) if dir { folderCount += 1 } else { counts[cat, default: 0] += 1 } - let href = encodePath(base + e.name + (dir ? "/" : "")) + let href = encodePath(e.name + (dir ? "/" : "")) let date = vals?.creationDate ?? vals?.contentModificationDate ?? Date(timeIntervalSince1970: 0) let meta: String if dir { @@ -87,8 +87,8 @@ enum DirectoryListing { let chips = filterChips(folderCount: folderCount, counts: counts, total: total, lang: lang) return page(title: title, crumbs: crumbs, chips: chips, rows: rows, isEmpty: entries.isEmpty, total: total, canUpload: canUpload, - canReceiveText: canReceiveText, backHref: parentHref(of: requestPath), - textPreview: textPreview, lang: lang) + canReceiveText: canReceiveText, canBatchDownload: canBatchDownload, backHref: parentHref(of: requestPath), + textPreview: textPreview, rootPrefix: rootRelativePrefix(requestPath: requestPath), lang: lang) } // MARK: - 片段 @@ -138,7 +138,7 @@ enum DirectoryListing { private static func parentHref(of requestPath: String) -> String? { let segs = requestPath.split(separator: "/").map(String.init) guard !segs.isEmpty else { return nil } - return encodePath("/" + segs.dropLast().map { $0 + "/" }.joined()) + return "../" } // 面包屑:根(站名) / seg / …,末段当前不可点。Markdown 预览页(FileServer.contentResponse)复用。 @@ -146,23 +146,22 @@ enum DirectoryListing { let segs = requestPath.split(separator: "/").map(String.init) var html = segs.isEmpty ? "\(htmlText(rootName))" - : "\(htmlText(rootName))" - var cum = "/" + : "\(htmlText(rootName))" for (i, seg) in segs.enumerated() { - cum += seg + "/" html += "/" if i == segs.count - 1 { html += "\(htmlText(seg))" } else { - html += "\(htmlText(seg))" + let href = relativeDirectoryHref(from: requestPath, targetDepth: i + 1) + html += "\(htmlText(seg))" } } return html } private static func page(title: String, crumbs: String, chips: String, rows: String, - isEmpty: Bool, total: Int, canUpload: Bool, canReceiveText: Bool, - backHref: String?, textPreview: String?, lang: Lang) -> String { + isEmpty: Bool, total: Int, canUpload: Bool, canReceiveText: Bool, canBatchDownload: Bool, + backHref: String?, textPreview: String?, rootPrefix: String, lang: Lang) -> String { // 措辞统一经 PermSummary 派生(同 GUI):网页端可能出现「上传」与「可收文本」两类写能力 let ps = permSummary(Permission(add: canUpload, recvText: canReceiveText), lang) let uploadButton = canUpload ? """ @@ -187,7 +186,7 @@ enum DirectoryListing { let display = textPreview.isEmpty ? L.webText(lang) : textPreview let firstCls = backRow.isEmpty ? " first" : "" textRow = """ -
  • \ +
  • \ \ \(htmlText(display))\ \(L.webText(lang))\ @@ -416,7 +415,7 @@ enum DirectoryListing {
    \(L.webProvidedBy(lang))
    \(dropMask) - + + diff --git a/Sources/LocalShare/RemoteAgent.swift b/Sources/LocalShare/RemoteAgent.swift new file mode 100644 index 0000000..3bd58fb --- /dev/null +++ b/Sources/LocalShare/RemoteAgent.swift @@ -0,0 +1,404 @@ +import Foundation + +@MainActor +final class RemoteAgent: NSObject { + private static let pairedServerKey = "remotePairedServerAddress" + + enum Status: Equatable { + case disconnected + case connecting + case connected + case reconnecting + } + + private var serverURL: URL? + private var localBaseURL: URL? + private var localToken = "" + private var cachedDeviceToken: String? + private var cachedCredentialServer: String? + private var shouldConnect = false + private var retryDelay: TimeInterval = 1 + private var retryTask: Task? + private var heartbeatTask: Task? + private var webSocket: URLSessionWebSocketTask? + private var session: URLSession? + private var localRequests: [String: RemoteLocalRequest] = [:] + private var localRequestLanguages: [String: Lang] = [:] + private var lang: Lang = .systemDefault + + private(set) var status: Status = .disconnected + private(set) var lastError: String? + private(set) var shareBaseURL: String? + var onStateChange: ((Status, String?) -> Void)? + var onShareURLChange: ((String?) -> Void)? + + var isPaired: Bool { + isPaired(for: serverURL) + } + + func isPaired(for serverURL: URL?) -> Bool { + guard let serverURL else { return false } + return UserDefaults.standard.string(forKey: Self.pairedServerKey) == serverURL.absoluteString + } + + func connect(serverURL: URL, enrollmentKey: String, localBaseURL: URL, localToken: String, + deviceName: String, lang: Lang, persistCredential: Bool = true) { + shouldConnect = true + self.lang = lang + self.serverURL = serverURL + self.localBaseURL = localBaseURL + self.localToken = localToken + retryTask?.cancel() + retryTask = nil + closeSocket() + setStatus(.connecting, error: nil) + + Task { @MainActor [weak self] in + guard let self else { return } + do { + if !enrollmentKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + try await self.pair(serverURL: serverURL, enrollmentKey: enrollmentKey, + deviceName: deviceName, lang: lang, + persistCredential: persistCredential) + } + guard cachedDeviceToken(for: serverURL) != nil else { + throw RemoteDisplayError(message: L.remoteEnrollmentRequired(lang)) + } + self.openSocket() + } catch { + self.shouldConnect = false + self.setStatus(.disconnected, error: error.localizedDescription) + } + } + } + + func pair(serverURL: URL, enrollmentKey: String, deviceName: String, + lang: Lang, persistCredential: Bool = true) async throws { + let key = enrollmentKey.trimmingCharacters(in: .whitespacesAndNewlines) + guard !key.isEmpty else { + throw RemoteDisplayError(message: L.remoteEnrollmentRequired(lang)) + } + let credentials = try await Self.enroll(serverURL: serverURL, key: key, + deviceName: deviceName, lang: lang) + if persistCredential { + do { + try RemoteKeychain.set(credentials.deviceToken, for: RemoteKeychain.deviceToken) + } catch let failure as RemoteKeychain.Failure { + throw RemoteDisplayError(message: LStr.remoteKeychainFailed(failure.reason, lang)) + } + } + cachedDeviceToken = credentials.deviceToken + cachedCredentialServer = serverURL.absoluteString + if persistCredential { + UserDefaults.standard.set(serverURL.absoluteString, forKey: Self.pairedServerKey) + } + } + + func disconnect() { + shouldConnect = false + retryTask?.cancel() + retryTask = nil + heartbeatTask?.cancel() + heartbeatTask = nil + closeSocket() + shareBaseURL = nil + onShareURLChange?(nil) + setStatus(.disconnected, error: nil) + } + + func forgetDevice() throws { + disconnect() + try RemoteKeychain.removeAll() + cachedDeviceToken = nil + cachedCredentialServer = nil + UserDefaults.standard.removeObject(forKey: Self.pairedServerKey) + } + + func updateLocalShare(baseURL: URL, token: String) { + localBaseURL = baseURL + localToken = token + guard status == .connected else { return } + send(RemoteWireMessage(type: "share.start", protocolVersion: 1, shareID: token, deviceName: nil)) + } + + private func openSocket() { + guard let serverURL, + let token = cachedDeviceToken(for: serverURL), + let endpoint = agentURL(for: serverURL) else { + shouldConnect = false + setStatus(.disconnected, error: L.remoteInvalidCredentials(lang)) + return + } + let configuration = URLSessionConfiguration.ephemeral + configuration.waitsForConnectivity = true + let session = URLSession(configuration: configuration, delegate: self, delegateQueue: OperationQueue()) + var request = URLRequest(url: endpoint) + request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") + self.session = session + let task = session.webSocketTask(with: request) + webSocket = task + task.resume() + } + + private func cachedDeviceToken(for serverURL: URL) -> String? { + let address = serverURL.absoluteString + if cachedCredentialServer == address, let cachedDeviceToken { + return cachedDeviceToken + } + if UserDefaults.standard.string(forKey: Self.pairedServerKey) != address { + // Compatibility for credentials created before the paired-server marker existed. + guard RemoteKeychain.value(for: RemoteKeychain.serverAddress) == address else { return nil } + UserDefaults.standard.set(address, forKey: Self.pairedServerKey) + } + guard let token = RemoteKeychain.value(for: RemoteKeychain.deviceToken), !token.isEmpty else { return nil } + cachedDeviceToken = token + cachedCredentialServer = address + return token + } + + private func agentURL(for base: URL) -> URL? { + guard var components = URLComponents(url: base, resolvingAgainstBaseURL: false) else { return nil } + components.scheme = components.scheme?.lowercased() == "https" ? "wss" : "ws" + components.path = "/api/v1/agent" + components.query = nil + components.fragment = nil + return components.url + } + + private func receiveNext(from task: URLSessionWebSocketTask) { + task.receive { [weak self] result in + Task { @MainActor [weak self] in + guard let self, self.webSocket === task else { return } + switch result { + case .success(let message): self.handle(message) + case .failure(let error): self.connectionEnded(error) + } + if self.webSocket === task { self.receiveNext(from: task) } + } + } + } + + private func handle(_ message: URLSessionWebSocketTask.Message) { + switch message { + case .string(let raw): + guard let data = raw.data(using: .utf8), + let message = try? JSONDecoder().decode(RemoteWireMessage.self, from: data) else { return } + handle(message) + case .data: + // V1 only streams file bytes from the Mac to the server. + break + @unknown default: + break + } + } + + private func handle(_ message: RemoteWireMessage) { + switch message.type { + case "hello": + if let url = shareBaseURL { onShareURLChange?(url) } + case "share.ready": + shareBaseURL = message.shareURL + onShareURLChange?(message.shareURL) + case "request.begin": + guard let id = message.requestID, let method = message.method, let path = message.path else { return } + startLocalRequest(id: id, shareID: message.shareID, method: method, + path: path, headers: message.headers ?? [:]) + case "request.cancel": + if let id = message.requestID { cancelLocalRequest(id) } + case "error": + lastError = L.remoteServerError(lang) + onStateChange?(status, lastError) + default: + break + } + } + + private func startLocalRequest(id: String, shareID: String?, method: String, + path: String, headers: [String: String]) { + guard RemoteShareGate.accepts(shareID, currentToken: localToken) else { + send(RemoteWireMessage(type: "response.begin", requestID: id, + headers: ["Content-Length": "0"], status: 410)) + send(RemoteWireMessage(type: "response.end", requestID: id)) + return + } + guard method == "GET" || method == "HEAD" else { + send(RemoteWireMessage(type: "response.begin", requestID: id, + headers: ["Content-Length": "0", "Allow": "GET, HEAD"], status: 405)) + send(RemoteWireMessage(type: "response.end", requestID: id)) + return + } + let requestLang = Lang.fromAcceptLanguage(headers["Accept-Language"]) + guard let base = localBaseURL, !localToken.isEmpty else { + send(RemoteWireMessage(type: "error", requestID: id, error: L.remoteLocalUnavailable(requestLang))) + return + } + let separator = path.contains("?") ? "&" : "?" + guard path.hasPrefix("/"), + let url = URL(string: base.absoluteString + path + separator + "t=" + localToken) else { + send(RemoteWireMessage(type: "error", requestID: id, error: L.remoteInvalidRequest(requestLang))) + return + } + var request = URLRequest(url: url) + request.httpMethod = method + for (key, value) in headers where ["Accept", "Accept-Language", "If-Modified-Since", "If-None-Match", "Range", "User-Agent"].contains(key) { + request.setValue(value, forHTTPHeaderField: key) + } + let local = RemoteLocalRequest(id: id, request: request) { [weak self] event in + Task { @MainActor [weak self] in self?.handleLocalEvent(id: id, event: event) } + } + localRequests[id] = local + localRequestLanguages[id] = requestLang + local.start() + } + + private func handleLocalEvent(id: String, event: RemoteLocalEvent) { + guard localRequests[id] != nil else { return } + switch event { + case .headers(let status, let headers): + send(RemoteWireMessage(type: "response.begin", requestID: id, headers: headers, status: status)) + case .data(let data): + sendDataResponse(id, data: data) + case .finished(let error): + if let error, (error as NSError).code != NSURLErrorCancelled { + let requestLang = localRequestLanguages[id] ?? lang + send(RemoteWireMessage(type: "error", requestID: id, + error: LStr.remoteRequestFailed(error.localizedDescription, requestLang))) + } else { + send(RemoteWireMessage(type: "response.end", requestID: id)) + } + localRequests.removeValue(forKey: id)?.invalidate() + localRequestLanguages.removeValue(forKey: id) + } + } + + private func sendDataResponse(_ requestID: String, data: Data) { + guard let webSocket else { return } + webSocket.send(.data(Self.encodeData(requestID, data))) { [weak self] error in + Task { @MainActor [weak self] in + guard let self else { return } + if let error { self.connectionEnded(error, from: webSocket) } + else { self.localRequests[requestID]?.resumeAfterChunk() } + } + } + } + + private func send(_ message: RemoteWireMessage) { + guard let webSocket, let data = try? JSONEncoder().encode(message), let raw = String(data: data, encoding: .utf8) else { return } + webSocket.send(.string(raw)) { [weak self] error in + if let error { + Task { @MainActor [weak self] in self?.connectionEnded(error, from: webSocket) } + } + } + } + + private func cancelLocalRequest(_ id: String) { + localRequests.removeValue(forKey: id)?.cancel() + localRequestLanguages.removeValue(forKey: id) + } + + private func cancelLocalRequests() { + for request in localRequests.values { request.cancel() } + localRequests.removeAll() + localRequestLanguages.removeAll() + } + + private func connectionEnded(_ error: Error, from task: URLSessionWebSocketTask? = nil) { + if let task, webSocket !== task { return } + guard webSocket != nil else { return } + closeSocket() + shareBaseURL = nil + onShareURLChange?(nil) + let message = error.localizedDescription + lastError = message + guard shouldConnect else { + setStatus(.disconnected, error: message) + return + } + setStatus(.reconnecting, error: message) + scheduleRetry() + } + + private func scheduleRetry() { + guard shouldConnect, retryTask == nil else { return } + let delay = retryDelay + retryDelay = min(retryDelay * 2, 60) + retryTask = Task { @MainActor [weak self] in + do { try await Task.sleep(nanoseconds: UInt64(delay * 1_000_000_000)) } catch { return } + guard let self, self.shouldConnect else { return } + self.retryTask = nil + self.openSocket() + } + } + + private func closeSocket() { + heartbeatTask?.cancel() + heartbeatTask = nil + cancelLocalRequests() + webSocket?.cancel(with: .normalClosure, reason: nil) + webSocket = nil + session?.invalidateAndCancel() + session = nil + } + + private func setStatus(_ value: Status, error: String?) { + status = value + lastError = error + onStateChange?(value, error) + } + + private static func enroll(serverURL: URL, key: String, deviceName: String, + lang: Lang) async throws -> RemoteEnrollmentResponse { + let endpoint = serverURL.appendingPathComponent("api/v1/enroll") + var request = URLRequest(url: endpoint) + request.httpMethod = "POST" + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.httpBody = try JSONEncoder().encode( + RemoteWireMessage(type: "enroll", deviceName: deviceName, enrollmentKey: key) + ) + let (data, response) = try await URLSession.shared.data(for: request) + guard let http = response as? HTTPURLResponse, (200..<300).contains(http.statusCode) else { + throw RemoteDisplayError(message: L.remoteEnrollmentRejected(lang)) + } + return try JSONDecoder().decode(RemoteEnrollmentResponse.self, from: data) + } + + static func encodeData(_ id: String, _ data: Data) -> Data { + RemoteFrameCodec.encode(id, data) + } +} + +extension RemoteAgent: URLSessionWebSocketDelegate { + nonisolated func urlSession(_ session: URLSession, webSocketTask: URLSessionWebSocketTask, + didOpenWithProtocol protocol: String?) { + Task { @MainActor [weak self] in + guard let self, self.webSocket === webSocketTask else { return } + self.retryDelay = 1 + self.setStatus(.connected, error: nil) + self.send(RemoteWireMessage(type: "hello", protocolVersion: 1, + deviceName: Host.current().localizedName ?? "Mac")) + if self.localBaseURL != nil { + self.send(RemoteWireMessage(type: "share.start", protocolVersion: 1, + shareID: self.localToken)) + } + self.heartbeatTask?.cancel() + self.heartbeatTask = Task { @MainActor [weak self] in + while !Task.isCancelled { + do { try await Task.sleep(nanoseconds: 15_000_000_000) } catch { return } + guard let self, self.webSocket === webSocketTask else { return } + webSocketTask.sendPing { error in + if let error { + Task { @MainActor [weak self] in self?.connectionEnded(error, from: webSocketTask) } + } + } + } + } + self.receiveNext(from: webSocketTask) + } + } + + nonisolated func urlSession(_ session: URLSession, task: URLSessionTask, + didCompleteWithError error: Error?) { + guard let error, let webSocketTask = task as? URLSessionWebSocketTask else { return } + Task { @MainActor [weak self] in self?.connectionEnded(error, from: webSocketTask) } + } +} diff --git a/Sources/LocalShare/RemoteLocalRequest.swift b/Sources/LocalShare/RemoteLocalRequest.swift new file mode 100644 index 0000000..05eb368 --- /dev/null +++ b/Sources/LocalShare/RemoteLocalRequest.swift @@ -0,0 +1,60 @@ +import Foundation + +final class RemoteLocalRequest: NSObject, @unchecked Sendable, URLSessionDataDelegate, URLSessionTaskDelegate { + let id: String + private let request: URLRequest + private let emit: (RemoteLocalEvent) -> Void + private var session: URLSession? + private var task: URLSessionDataTask? + + init(id: String, request: URLRequest, emit: @escaping (RemoteLocalEvent) -> Void) { + self.id = id + self.request = request + self.emit = emit + } + + func start() { + let queue = OperationQueue() + queue.maxConcurrentOperationCount = 1 + let session = URLSession(configuration: .ephemeral, delegate: self, delegateQueue: queue) + self.session = session + let task = session.dataTask(with: request) + self.task = task + task.resume() + } + + func resumeAfterChunk() { task?.resume() } + func cancel() { task?.cancel(); invalidate() } + func invalidate() { session?.invalidateAndCancel(); session = nil; task = nil } + + func urlSession(_ session: URLSession, task: URLSessionTask, + willPerformHTTPRedirection response: HTTPURLResponse, + newRequest request: URLRequest, + completionHandler: @escaping (URLRequest?) -> Void) { + completionHandler(RemoteRedirectPolicy.shouldFollow(response) ? request : nil) + } + + func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, + didReceive response: URLResponse, + completionHandler: @escaping (URLSession.ResponseDisposition) -> Void) { + guard let response = response as? HTTPURLResponse else { + completionHandler(.cancel) + return + } + var headers: [String: String] = [:] + for (key, value) in response.allHeaderFields { + if let key = key as? String, let value = value as? String { headers[key] = value } + } + emit(.headers(response.statusCode, headers)) + completionHandler(.allow) + } + + func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, didReceive data: Data) { + dataTask.suspend() + emit(.data(data)) + } + + func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) { + emit(.finished(error)) + } +} diff --git a/Sources/LocalShare/RemoteProtocol.swift b/Sources/LocalShare/RemoteProtocol.swift new file mode 100644 index 0000000..10580aa --- /dev/null +++ b/Sources/LocalShare/RemoteProtocol.swift @@ -0,0 +1,73 @@ +import Foundation + +struct RemoteDisplayError: LocalizedError { + let message: String + var errorDescription: String? { message } +} + +struct RemoteWireMessage: Codable { + var type: String + var protocolVersion: Int? = nil + var requestID: String? = nil + var method: String? = nil + var path: String? = nil + var headers: [String: String]? = nil + var status: Int? = nil + var error: String? = nil + var shareURL: String? = nil + var shareID: String? = nil + var deviceName: String? = nil + var enrollmentKey: String? = nil + + enum CodingKeys: String, CodingKey { + case type + case protocolVersion = "protocol" + case requestID = "request_id" + case method, path, headers, status, error + case shareURL = "share_url" + case shareID = "share_id" + case deviceName = "device_name" + case enrollmentKey = "enrollment_key" + } +} + +struct RemoteEnrollmentResponse: Decodable { + let deviceID: String + let deviceToken: String + + enum CodingKeys: String, CodingKey { + case deviceID = "device_id" + case deviceToken = "device_token" + } +} + +enum RemoteLocalEvent { + case headers(Int, [String: String]) + case data(Data) + case finished(Error?) +} + +enum RemoteFrameCodec { + static func encode(_ id: String, _ data: Data) -> Data { + let idData = Data(id.utf8) + var result = Data() + var length = UInt32(idData.count).bigEndian + withUnsafeBytes(of: &length) { result.append(contentsOf: $0) } + result.append(idData) + result.append(data) + return result + } +} + +enum RemoteRedirectPolicy { + static func shouldFollow(_ response: HTTPURLResponse) -> Bool { + response.statusCode == 302 && + (response.value(forHTTPHeaderField: "Set-Cookie") ?? "").contains("ls_token=") + } +} + +enum RemoteShareGate { + static func accepts(_ shareID: String?, currentToken: String) -> Bool { + !currentToken.isEmpty && shareID == currentToken + } +} diff --git a/Sources/LocalShare/RemoteSettings.swift b/Sources/LocalShare/RemoteSettings.swift new file mode 100644 index 0000000..a564fca --- /dev/null +++ b/Sources/LocalShare/RemoteSettings.swift @@ -0,0 +1,74 @@ +import Foundation +import Security + +struct RemoteSettings: Equatable { + var serverAddress = "" + + var serverURL: URL? { + let raw = serverAddress.trimmingCharacters(in: .whitespacesAndNewlines) + guard let url = URL(string: raw), + let scheme = url.scheme?.lowercased(), scheme == "http" || scheme == "https", + url.host != nil, url.user == nil, url.password == nil, + url.query == nil, url.fragment == nil, + url.path.isEmpty || url.path == "/" else { return nil } + var components = URLComponents(url: url, resolvingAgainstBaseURL: false) + components?.path = "" + return components?.url + } + + var isValid: Bool { serverURL != nil } +} + +enum RemoteKeychain { + private static let service = "live.ezze.localshare.remote" + static let deviceToken = "device-token" + static let serverAddress = "server-address" + + struct Failure: Error { + let status: OSStatus + + var reason: String { + (SecCopyErrorMessageString(status, nil) as String?) ?? "OSStatus (status)" + } + } + + static func value(for account: String) -> String? { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: account, + kSecReturnData as String: true, + kSecMatchLimit as String: kSecMatchLimitOne, + ] + var result: CFTypeRef? + guard SecItemCopyMatching(query as CFDictionary, &result) == errSecSuccess, + let data = result as? Data else { return nil } + return String(data: data, encoding: .utf8) + } + + static func set(_ value: String, for account: String) throws { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: account, + ] + let data = Data(value.utf8) + let attributes: [String: Any] = [kSecValueData as String: data] + let updateStatus = SecItemUpdate(query as CFDictionary, attributes as CFDictionary) + if updateStatus == errSecSuccess { return } + guard updateStatus == errSecItemNotFound else { throw Failure(status: updateStatus) } + var item = query + item[kSecValueData as String] = data + let addStatus = SecItemAdd(item as CFDictionary, nil) + guard addStatus == errSecSuccess else { throw Failure(status: addStatus) } + } + + static func removeAll() throws { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + ] + let status = SecItemDelete(query as CFDictionary) + guard status == errSecSuccess || status == errSecItemNotFound else { throw Failure(status: status) } + } +} diff --git a/Sources/LocalShare/SendTextPage.swift b/Sources/LocalShare/SendTextPage.swift index cf51509..0aa2c06 100644 --- a/Sources/LocalShare/SendTextPage.swift +++ b/Sources/LocalShare/SendTextPage.swift @@ -129,7 +129,7 @@ enum SendText { if(!v.trim())return; if(new Blob([v]).size>MAX){flash(LS_I18N.sendOverLimit,false);return;} btn.disabled=true; - fetch('/ls/text',{method:'POST',headers:{'Content-Type':'text/plain;charset=utf-8'},body:v}) + fetch(LS_ROOT+'ls/text',{method:'POST',headers:{'Content-Type':'text/plain;charset=utf-8'},body:v}) .then(function(r){ if(r.ok){btn.disabled=false;ta.value='';remember(v);flash(LS_I18N.sent,true);ta.focus();} else fail(r); @@ -187,9 +187,9 @@ enum SendText {

    \(esc(L.webSendTitle(lang)))

    \(card(lang: lang, withHead: false)) - + diff --git a/Sources/LocalShare/SettingsScreen.swift b/Sources/LocalShare/SettingsScreen.swift index 77c39a4..1b3e088 100644 --- a/Sources/LocalShare/SettingsScreen.swift +++ b/Sources/LocalShare/SettingsScreen.swift @@ -8,6 +8,15 @@ struct SettingsScreen: View { @EnvironmentObject var state: AppState @EnvironmentObject var updater: UpdaterController @State private var portText = "" + @State private var remoteServerText = "" + @State private var remoteKeyText = "" + @State private var remoteFeedback: RemoteFeedback? + @State private var remotePairTask: Task? + + private enum RemoteFeedback: Equatable { + case saved, pairing, paired, failed(String) + } + var body: some View { // portText 初始为空、onAppear 才填入当前端口;首帧若按空串校验会闪出「无效 + 放弃/应用」行再弹回。 // 空串一律视作「当前生效端口」,让首帧与落定后一致,消除进入设置页时的这层闪烁。 @@ -16,7 +25,7 @@ struct SettingsScreen: View { let pv = validatePort(effectivePort, lang) let pColor = pv.state == .ok ? t.ok : (pv.state == .occupied ? t.warn : t.danger) let changed = !portText.isEmpty && (Int(portText) ?? -1) != Int(state.configuredPort) - let ps = permSummary(state.permission, lang) + let ps = permSummary(state.effectivePermission, lang) let tailscaleDesc = state.tailscaleAccessEnabled && state.tailscaleStatus == nil ? L.tailscaleDescMissing(lang) : nil return ScreenFrame(t: t) { @@ -36,9 +45,16 @@ struct SettingsScreen: View { } .onAppear { portText = String(state.configuredPort) + remoteServerText = state.remoteSettings.serverAddress + remoteKeyText = state.remoteEnrollmentKey state.refreshCLIStatus() } - .onDisappear { state.endSettingsLayout() } + .onChange(of: remoteServerText) { _ in remoteFeedback = nil } + .onChange(of: remoteKeyText) { _ in remoteFeedback = nil } + .onDisappear { + remotePairTask?.cancel() + state.endSettingsLayout() + } } // MARK: - 经典设置侧栏 @@ -194,6 +210,57 @@ struct SettingsScreen: View { } } } + + eyebrow(L.sectionRemote(lang)) + remoteAccessSettings(lang) + } + } + + private func remoteAccessSettings(_ lang: Lang) -> some View { + VStack(alignment: .leading, spacing: 0) { + groupBox { + remoteField(title: L.remoteServerAddress(lang), text: $remoteServerText) + remoteField(title: L.remoteEnrollmentKey(lang), text: $remoteKeyText, top: true, secure: true, + placeholder: state.remotePaired ? L.remotePairedPlaceholder(lang) : "", + disabled: state.remotePaired) + if !state.remotePaired { + Text(L.remotePairHint(lang)).font(.sans(11.5)).foregroundStyle(t.inkMute) + .padding(.vertical, 10) + } + HStack { + if let remoteFeedback, remoteFeedback != .saved { + Image(systemName: remoteFeedbackIcon(remoteFeedback)) + .foregroundStyle(remoteFeedbackColor(remoteFeedback)) + Text(remoteFeedbackText(remoteFeedback, lang)) + .font(.sans(11.5, .semibold)) + .foregroundStyle(remoteFeedbackColor(remoteFeedback)) + } + Spacer(minLength: 0) + if !state.remotePaired { + GhostButton(t: t, title: L.remotePair(lang), systemImage: "key.horizontal") { + testRemotePairing() + } + .disabled(remoteFeedback == .pairing) + .opacity(remoteFeedback == .pairing ? 0.55 : 1) + } + GhostButton(t: t, + title: remoteFeedback == .saved ? L.remoteSaved(lang) : L.remoteSave(lang), + systemImage: remoteFeedback == .saved ? "checkmark.circle.fill" : "checkmark") { + saveRemoteSettings() + } + if state.remotePaired { + Button { + state.forgetRemoteDevice() + remoteKeyText = "" + remoteFeedback = nil + } label: { + Text(L.remoteForget(lang)).font(.sans(12.5, .semibold)).foregroundStyle(t.danger) + } + .buttonStyle(.plain) + } + } + .padding(.bottom, 12) + } } } @@ -207,11 +274,15 @@ struct SettingsScreen: View { state.setAccessCodeEnabled(!state.accessCodeEnabled) } permRow(name: L.permUploadName(lang), - desc: state.canToggleUpload ? nil : L.permUploadDescOff(lang), - locked: !state.canToggleUpload, on: state.permission.add, top: true) { + desc: state.remoteAccessEnabled ? L.remoteReadOnly(lang) + : (state.canToggleUpload ? nil : L.permUploadDescOff(lang)), + locked: !state.canToggleUpload || state.remoteAccessEnabled, + on: state.effectivePermission.add, top: true) { state.setUploadAllowed(!state.permission.add) } - permRow(name: L.recvInboxTitle(lang), locked: false, on: state.textInboxEnabled, top: true) { + permRow(name: L.recvInboxTitle(lang), + desc: state.remoteAccessEnabled ? L.remoteReadOnly(lang) : nil, + locked: state.remoteAccessEnabled, on: state.textInboxEnabled && !state.remoteAccessEnabled, top: true) { state.setTextInboxEnabled(!state.textInboxEnabled) } } @@ -433,4 +504,78 @@ struct SettingsScreen: View { state.applyPort(in_port_t(p)) state.goShare() } + + private func remoteField(title: String, text: Binding, top: Bool = false, + width: CGFloat = 190, numbersOnly: Bool = false, secure: Bool = false, + placeholder: String = "", disabled: Bool = false) -> some View { + HStack(spacing: 10) { + Text(title).font(.sans(13.5, .semibold)).foregroundStyle(t.ink) + Spacer(minLength: 8) + Group { + if secure { + SecureField(placeholder, text: text) + } else { + TextField(placeholder, text: text) + } + } + .textFieldStyle(.plain) + .font(.mono(11.5)) + .frame(width: width) + .padding(.horizontal, 9).padding(.vertical, 6) + .background(RoundedRectangle(cornerRadius: 8, style: .continuous).fill(t.field)) + .overlay(RoundedRectangle(cornerRadius: 8, style: .continuous).strokeBorder(t.line, lineWidth: 1)) + .disabled(disabled) + .onChange(of: text.wrappedValue) { value in + if numbersOnly { text.wrappedValue = String(value.filter(\.isNumber).prefix(5)) } + } + } + .padding(.vertical, 9) + .overlay(alignment: .top) { if top { Rectangle().fill(t.line).frame(height: 1) } } + } + + private func saveRemoteSettings() { + state.saveRemoteSettings(RemoteSettings(serverAddress: remoteServerText), enrollmentKey: remoteKeyText) + remoteFeedback = .saved + } + + private func testRemotePairing() { + remotePairTask?.cancel() + state.saveRemoteSettings(RemoteSettings(serverAddress: remoteServerText), enrollmentKey: remoteKeyText) + remoteFeedback = .pairing + remotePairTask = Task { @MainActor in + let error = await state.pairRemote(enrollmentKey: remoteKeyText) + guard !Task.isCancelled else { return } + if let error { + remoteFeedback = .failed(error) + } else { + remoteKeyText = "" + remoteFeedback = .paired + } + } + } + + private func remoteFeedbackText(_ feedback: RemoteFeedback, _ lang: Lang) -> String { + switch feedback { + case .saved: return L.remoteSaved(lang) + case .pairing: return L.remotePairing(lang) + case .paired: return L.remotePairSucceeded(lang) + case .failed(let message): return message + } + } + + private func remoteFeedbackIcon(_ feedback: RemoteFeedback) -> String { + switch feedback { + case .saved, .paired: return "checkmark.circle.fill" + case .pairing: return "ellipsis.circle" + case .failed: return "exclamationmark.triangle.fill" + } + } + + private func remoteFeedbackColor(_ feedback: RemoteFeedback) -> Color { + switch feedback { + case .saved, .paired: return t.ok + case .pairing: return t.inkMute + case .failed: return t.danger + } + } } diff --git a/Sources/LocalShare/ShareScreen.swift b/Sources/LocalShare/ShareScreen.swift index ada8a60..8db62d1 100644 --- a/Sources/LocalShare/ShareScreen.swift +++ b/Sources/LocalShare/ShareScreen.swift @@ -10,7 +10,7 @@ struct ShareScreen: View { @State private var showText = false // 文本编辑弹层(编辑当前分享的文本) private func editText() { showText = true } var body: some View { - let ps = permSummary(state.permission, state.lang) + let ps = permSummary(state.effectivePermission, state.lang) ScreenFrame(t: t) { // 二级页头部,与传递文本页同款:← 返回主页 + 标题 +齿轮。运行态/地址在票据正文(QR 说明 + // CopyPill)已具,头部不再堆 StatusPill,避免与正文重复、并与 TextScreen 一致。 @@ -24,8 +24,9 @@ struct ShareScreen: View { } content: { VStack(spacing: 16) { ticket(ps) + if state.isRunning && !state.sharedItems.isEmpty { remoteCard } // 文本与文件共存:在票据下补一张「附带文本」小卡(预览 + 编辑)。纯文本分享则文本就是票据本身。 - if state.hasText && !state.isTextOnly { attachedTextCard } + if state.hasText && !state.isTextOnly && !state.remoteAccessEnabled { attachedTextCard } if !state.received.isEmpty { receivedCard } actions if state.interfaces.count > 1 { interfacePicker } @@ -50,6 +51,75 @@ struct ShareScreen: View { } } + private var remoteCard: some View { + VStack(alignment: .leading, spacing: 10) { + HStack(spacing: 10) { + GlobeLineIcon(color: t.accent) + VStack(alignment: .leading, spacing: 2) { + Text(L.remoteAccess(state.lang)).font(.sans(13.5, .semibold)).foregroundStyle(t.ink) + if !state.remoteAccessEnabled { + Text(L.remoteAccessDesc(state.lang)).font(.sans(11.5)).foregroundStyle(t.inkMute) + } + } + Spacer(minLength: 8) + if state.remoteAccessEnabled { + GhostButton(t: t, title: L.remoteDisconnect(state.lang), systemImage: "xmark.circle") { + state.disconnectRemote() + } + } else { + GhostButton(t: t, title: L.remoteConnect(state.lang), systemImage: "link") { + state.connectRemote(enrollmentKey: state.remoteEnrollmentKey) + } + .disabled(!state.canEnableRemote) + } + } + if state.remoteAccessEnabled { + HStack(spacing: 7) { + Text(state.remoteSettings.serverAddress) + .font(.mono(11.5)) + .foregroundStyle(t.inkMute) + .lineLimit(1) + .truncationMode(.middle) + .frame(maxWidth: .infinity, alignment: .leading) + Circle().fill(remoteStatusColor).frame(width: 7, height: 7) + Text(remoteStatusText).font(.sans(11.5, .semibold)).foregroundStyle(t.inkMute) + } + if let error = state.remoteError, !error.isEmpty { + Text(error).font(.sans(11)).foregroundStyle(t.danger).lineLimit(2) + } + } else if let error = state.remoteError, !error.isEmpty { + Text(error) + .font(.sans(11.5)).foregroundStyle(t.danger) + .fixedSize(horizontal: false, vertical: true) + } else if !state.remoteSettings.isValid { + Text(L.remoteConfigHint(state.lang)) + .font(.sans(11.5)).foregroundStyle(t.inkMute) + .fixedSize(horizontal: false, vertical: true) + } + } + .padding(14) + .background(RoundedRectangle(cornerRadius: 14, style: .continuous).fill(t.surface)) + .overlay(RoundedRectangle(cornerRadius: 14, style: .continuous).strokeBorder(t.line, lineWidth: 1)) + } + + private var remoteStatusText: String { + switch state.remoteStatus { + case .connecting: return L.remoteConnecting(state.lang) + case .connected: return L.remoteOnline(state.lang) + case .reconnecting: return L.remoteOffline(state.lang) + case .disconnected: return L.remoteConfigHint(state.lang) + } + } + + private var remoteStatusColor: Color { + switch state.remoteStatus { + case .connected: return t.ok + case .reconnecting: return t.danger + case .connecting: return t.warn + case .disconnected: return t.inkFaint + } + } + // 纯文本分享的存根:文本图标 + 「正在分享文本」+ 字数 + 前几行预览。 private func textStub(_ ps: PermSummary) -> some View { let lang = state.lang @@ -200,7 +270,8 @@ struct ShareScreen: View { // 通行区:QR + 说明 + 复制条 private var qrPass: some View { let running = state.isRunning - let caption = state.isTextOnly ? L.scanCaptionText(state.lang) : L.scanCaptionAccess(state.lang) + let caption = state.remoteURL != nil ? L.scanCaptionRemote(state.lang) + : (state.isTextOnly ? L.scanCaptionText(state.lang) : L.scanCaptionAccess(state.lang)) return VStack(spacing: 0) { QRCard(image: state.qrImage, size: 172, dimmed: !running).padding(.top, 22) Text(running ? caption : L.broadcastStopped(state.lang)) @@ -214,10 +285,12 @@ struct ShareScreen: View { OtherAddressesDisclosure(t: t, lang: state.lang, addresses: state.otherAddresses) .padding(.top, 7) } - AccessModeButton(t: t, lang: state.lang, usingAccessCode: state.accessCodeEnabled) { - state.setAccessCodeEnabled(!state.accessCodeEnabled) + if state.remoteURL == nil { + AccessModeButton(t: t, lang: state.lang, usingAccessCode: state.accessCodeEnabled) { + state.setAccessCodeEnabled(!state.accessCodeEnabled) + } + .padding(.top, 6) } - .padding(.top, 6) AutoStopDeadline(t: t).padding(.top, 8) // 在线访客:小绿点 + 摘要文案;点一下展开全部访客明细(设备名 / 完整 IP)。 // 0 人时整行隐藏(不占位、不留空文案)。 diff --git a/Sources/LocalShare/TextScreen.swift b/Sources/LocalShare/TextScreen.swift index a456c2c..1a5e88e 100644 --- a/Sources/LocalShare/TextScreen.swift +++ b/Sources/LocalShare/TextScreen.swift @@ -152,7 +152,10 @@ struct TextScreen: View { } } content: { VStack(spacing: 16) { - if state.isRunning, state.hasText || state.textInboxEnabled, state.qrImage != nil { + if state.remoteAccessEnabled { + Text(L.remoteTextUnavailable(lang)).font(.sans(12.5)).foregroundStyle(t.inkMute) + .multilineTextAlignment(.center) + } else if state.isRunning, state.hasText || state.textInboxEnabled, state.qrImage != nil { qrCard } else if !state.hasNetwork { noNetworkHint @@ -165,7 +168,7 @@ struct TextScreen: View { if state.textInboxEnabled || !state.receivedTexts.isEmpty { ReceivedTextsCard(t: t) } // 正在传递(发文本或收文本)才出「停止」:一步撤文本+关接收+停服务+回选择页, // 对齐文件票据的「停止」。只是编辑没发、也没开接收时无可停,靠 ← 返回即可。 - if state.isRunning && (state.hasText || state.textInboxEnabled) { + if state.isRunning && !state.remoteAccessEnabled && (state.hasText || state.textInboxEnabled) { HStack { Spacer() DangerButton(t: t, title: L.stop(state.lang)) { state.stopTextTransfer() } @@ -203,8 +206,8 @@ struct TextScreen: View { systemImage: "paperplane.fill", fullWidth: true) { state.setSharedText(draft) } - .disabled(blank || unchanged) - .opacity(blank || unchanged ? 0.5 : 1) + .disabled(blank || unchanged || state.remoteAccessEnabled) + .opacity(blank || unchanged || state.remoteAccessEnabled ? 0.5 : 1) } .padding(16) .background(RoundedRectangle(cornerRadius: 16, style: .continuous).fill(t.surface)) @@ -260,7 +263,7 @@ struct TextScreen: View { return HStack(spacing: 12) { Text(L.recvInboxTitle(lang)).font(.sans(13, .semibold)).foregroundStyle(t.ink) Spacer(minLength: 8) - ToggleSwitch(t: t, isOn: state.textInboxEnabled) { state.setTextInboxEnabled(!state.textInboxEnabled) } + ToggleSwitch(t: t, isOn: state.textInboxEnabled && !state.remoteAccessEnabled, locked: state.remoteAccessEnabled) { state.setTextInboxEnabled(!state.textInboxEnabled) } } .padding(14) .background(RoundedRectangle(cornerRadius: 14, style: .continuous).fill(t.surface)) diff --git a/Sources/LocalShare/TextViewer.swift b/Sources/LocalShare/TextViewer.swift index b13559e..d444c56 100644 --- a/Sources/LocalShare/TextViewer.swift +++ b/Sources/LocalShare/TextViewer.swift @@ -9,9 +9,10 @@ import Foundation // · 自动链接只认 http(s),由正则保证 scheme,不会引入 javascript: 之类。 enum TextViewer { // text:要展示/复制的原文;crumbs:与文件共存于虚拟根时显示「分享内容 / 文本」,纯文本分享传 nil。 - static func html(text: String, crumbs: String?, canUpload: Bool, canReceiveText: Bool = false, lang: Lang) -> String { + static func html(text: String, requestPath: String = "/ls/text", crumbs: String?, canUpload: Bool, + canReceiveText: Bool = false, lang: Lang) -> String { PreviewPage.html( - fileName: L.webText(lang), crumbs: crumbs, canUpload: canUpload, lang: lang, + fileName: L.webText(lang), requestPath: requestPath, crumbs: crumbs, canUpload: canUpload, lang: lang, body: """
    
    diff --git a/Sources/LocalShare/Token.swift b/Sources/LocalShare/Token.swift
    index 0101465..f39c624 100644
    --- a/Sources/LocalShare/Token.swift
    +++ b/Sources/LocalShare/Token.swift
    @@ -3,8 +3,10 @@ import Foundation
     // 分享访问令牌:每次「分享」动作生成一把(换分享/停止即轮换,旧链接与 cookie 随之作废),
     // 内嵌进二维码 URL(?t=…)。扫码者首访校验通过后种 cookie;手动猜 IP:端口 的人因缺 token 被 403。
     enum Token {
    -    static func generate(length: Int = 10) -> String {
    -        let alphabet = Array("abcdefghijklmnopqrstuvwxyz0123456789")
    +    // 32 hex characters = 128 bits. The old 10-character base36 token was suitable
    +    // for LAN address hiding, but too small once a link can cross the public internet.
    +    static func generate(length: Int = 32) -> String {
    +        let alphabet = Array("0123456789abcdef")
             var rng = SystemRandomNumberGenerator()
             return String((0.. Native single-window macOS app: choose a folder, show a QR code, and let phones on the same Wi-Fi browse it read-only in a browser. Guest upload and Text Transfer can be enabled when needed; the default is read-only.
    +> Native single-window macOS app: choose a folder, show a QR code, and let phones on the same Wi-Fi—or an explicitly enabled browser-only remote endpoint—browse it read-only. Guest upload and Text Transfer can be enabled for LAN shares; the default is read-only.
     
     This is the **current architecture reference**: core constraints, project structure, important design decisions, and implementation notes for anyone continuing work after `git pull`. Related documents: visual design specification in `DESIGN.md`; release/version workflow in `CLAUDE.md`.
     
    @@ -21,15 +21,15 @@ The typical crash this project avoids is an arm64 binary that **misses a dynamic
     | Area | Decision |
     |---|---|
     | Platform | macOS only, native `.app`, Apple Silicon first |
    -| Network | Same Wi-Fi / LAN by default; optional user-enabled Tailscale access; no public endpoint or account |
    -| Stack | Swift / SwiftUI, system frameworks only, no external package dylib risk |
    -| HTTP server | Swifter from SPM source, primarily read-only static serving |
    +| Network | Same Wi-Fi / LAN by default; optional user-enabled Tailscale access or self-hosted LocalShare Server with one outbound WebSocket; no receiver app is required |
    +| Stack | Swift / SwiftUI Client plus a separate Go Server; the app uses system frameworks and has no package-external runtime dylibs |
    +| HTTP server | Swifter from SPM source for local serving; the self-hosted Go Server provides the remote control plane and data relay |
     | Share model | Three shapes: single folder -> mobile-friendly directory listing, serving `index.html` directly when present; single file -> scan opens the file and sibling files are not exposed; multiple files/folders -> synthetic virtual root listing selected items, with the first path segment mapped to a real URL. All shapes block directory traversal, each item using its own root |
    -| Auth | Every share action creates a random token embedded in the QR URL as `?t=...`; first visit validates it and sets a session cookie; later resources are allowed by cookie. An optional short access code can exchange for that cookie at `/ls/join`, with per-client rate limiting. Changing, stopping, or switching access-code mode rotates credentials immediately |
    -| Protocol | Plain HTTP. Threat model: block people who only guess the address; do not protect against same-network sniffing or forwarded links. Token rotation limits forwarded-link lifetime to the current share. Self-signed TLS would turn scan-to-use into certificate warnings, so it is intentionally not used |
    -| QR code | Raw selected LAN IP (or Tailscale IP when it is the only enabled network); generated with CoreImage `CIQRCodeGenerator`; no third-party QR dependency. The ticket can fold out hostname and enabled Tailscale alternatives |
    +| Auth | Every share action creates a random token embedded in the LAN QR URL as `?t=...`; first visit validates it and sets a session cookie. An optional short access code can exchange for that cookie at `/ls/join`, with per-client rate limiting. Remote mode uses separate high-entropy device and share tokens. Changing, stopping, or switching access-code mode rotates the applicable share credentials immediately |
    +| Protocol | LAN and Tailscale use plain HTTP. Remote mode uses the Server's HTTP/WebSocket relay, and the self-hosted Server can terminate TLS when configured with a certificate and key |
    +| QR code | Uses the public origin while remote mode is active; otherwise uses the selected LAN IP or Tailscale IP when it is the only enabled network. It is generated with CoreImage `CIQRCodeGenerator`; the ticket can fold out the Bonjour hostname and enabled Tailscale alternatives |
     | GUI | Single window: functional home screen with drag/drop and picker, Text Transfer entry, and Recent Shares; file ticket, Text Transfer, Settings, and History are secondary pages with back navigation |
    -| Lifecycle | Cold launch does **not** replay the last share. The app starts on the home screen; the last share remains in Recent Shares for one-click restart. Closing the window does not quit; process and server continue running, and menu bar activation restores the previous screen. Ports are selected automatically; service stops on app quit |
    +| Lifecycle | Cold launch does **not** replay the last share or remote switch. The app starts on the home screen; the last share remains in Recent Shares for one-click restart. Closing the window does not quit; process and server continue running, and menu bar activation restores the previous screen. Ports are selected automatically; an enabled remote agent stops on app quit |
     | Distribution | Xcode ad-hoc signing. The first Gatekeeper approval is handled manually and then remembered by macOS |
     | Automatic updates | Sparkle bundled through `@rpath`; background checks, user-confirmed update prompt, EdDSA trust chain independent of ad-hoc code signing and notarization |
     | Sandbox | App Sandbox is off because this is internally distributed and must read arbitrary user-selected folders |
    @@ -52,6 +52,7 @@ LocalShare/
       DESIGN.md                # Visual design spec; source references use section numbers from this file
       docs/
         ARCHITECTURE.md        # This file
    +    REMOTE_SHARING_PLAN.md # LocalShare Server, pairing, relay, and client flow
         images/                # README screenshots
       Sources/LocalShare/
         App.swift              # @main EntryPoint: GUI / headless / CLI dispatch; LocalShareApp and AppDelegate
    @@ -67,7 +68,8 @@ LocalShare/
         MarkdownViewer / JsonViewer / CsvViewer  # Preview cards; Markdown uses vendored marked; JSON/CSV are zero-dependency
         MarkedJS.swift         # Vendored marked compiled as a Swift string constant
         SendTextPage / TextViewer  # Text Transfer: phone -> Mac send page and text preview shell
    -    NetworkInfo.swift      # getifaddrs -> private IPv4 candidates
    +    RemoteAgent.swift      # Server pairing, Keychain device token, WebSocket relay, reconnect
    +    NetworkInfo.swift      # getifaddrs -> private IPv4 candidates, Tailscale IP, and .local hostname
         TailscaleInfo.swift    # optional CLI status probe -> Tailscale IPv4 + MagicDNS name
         ShareAddress.swift     # complete alternate URL + network scope; includes a future opaque public-relay kind
         QRCode.swift           # CoreImage QR -> NSImage, plus terminal ANSI output
    @@ -79,7 +81,8 @@ LocalShare/
       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
    +  tools/                   # Headless + curl smoke scripts: traversal, filenames, multiselect, upload defang, token 302, remote read-only, md links, language, text
    +  server/                  # Separate Go module: self-contained Control Plane and Data Relay binary
     ```
     
     `@main enum EntryPoint` has three dispatch layers: `LS_HEADLESS=1` -> `HeadlessServer`; matching `CLI.parse` argv -> `CLI.run`; otherwise `LocalShareApp` (SwiftUI). All three paths share the same `FileServer`, which keeps request logic from forking.
    @@ -102,6 +105,7 @@ LocalShare/
     4. **Traversal guard**: `decoded -> strip leading / -> append to root -> standardizedFileURL.resolvingSymlinksInPath`. The result must equal the root path or have `root + "/"` as prefix; otherwise return 403. `standardizedFileURL` removes `..`; encoded dot-dot is blocked because decoding happens before normalization.
     5. **Directories**: path without trailing slash gets 301 so relative resources resolve; `index.html` is served directly when present; otherwise `DirectoryListing` renders the listing with segment-encoded absolute hrefs, hidden files omitted, and a fixed parent row outside the root.
     6. **Files**: MIME is inferred by extension, text types get `charset=utf-8`, and `FileHandle` streams in 64 KB chunks. Exception: browser navigation to `.md` / `.json` / `.csv` returns a preview shell at the **same URL as the file**, so relative references resolve through normal serving. curl, `?raw=1`, and `*/*` receive raw file content.
    +7. **Remote policy**: while remote mode is enabled, only `GET` and `HEAD` are accepted, and upload and text-receive routes disappear. The Server authenticates its own Share Token and never forwards browser credentials to the Mac.
     
     Single-folder serving extracts steps 4-6 into `serveTree(rootURL:relPath:...)`. **Single-file** shares return only that file. **Multi-share** uses `Share.multiple([Item])`, where `makeItems` uses `lastPathComponent` as key and appends `-2` for collisions. Empty path returns the virtual-root listing; otherwise the first path segment is mapped to a real URL. Unknown keys and subpaths under file items return 404. Each item has an independent traversal root.
     
    @@ -131,13 +135,30 @@ The GUI no longer exposes a current-network-only toggle and new installations li
     
     Tailscale access is persisted but defaults off. `NetworkInfo` detects its IPv4 by the documented `100.64.0.0/10` range rather than an interface-name assumption. When an installed Tailscale CLI is available, `TailscaleInfo` runs `status --json --peers=false` off the main actor with a two-second timeout and extracts `Self.DNSName`; failure degrades to the detected IP only. Enabling Tailscale permits tailnet clients through the request gate. Disabling it keeps the all-interface listener but rejects `100.64/10` clients before auth.
     
    -The ticket keeps one primary address and folds Bonjour hostname, Tailscale MagicDNS, and Tailscale IP into `otherAddresses`. In access-code mode each is an origin paired with the shared short code; otherwise each preserves the current path and token. `ShareAddress` stores a complete URL so a future public relay may supply an opaque server URL instead of being forced into the LAN host/query-token shape. No public relay address is created or shown today.
    +The ticket keeps one primary address and folds Bonjour hostname, Tailscale MagicDNS, and Tailscale IP into `otherAddresses`. In access-code mode each is an origin paired with the shared short code; otherwise each preserves the current path and token. `ShareAddress` stores a complete URL so opaque relay URLs can be represented without being forced into the LAN host/query-token shape. The current remote URL is shown as the primary ticket URL rather than an alternate address.
    +
    +### Remote Sharing
    +
    +Remote settings persist the Server address. The first connection also accepts a one-time Enrollment Key, exchanges it for a Device Token, and stores that token in macOS Keychain. `RemoteAgent` then opens an outbound WebSocket to `/api/v1/agent`, registers the current share, and retries with a 1–60 second capped backoff.
    +
    +The Server combines the Control Plane and Data Relay on one public port. Browser requests use `/share//...`; the Server sends them over the existing WebSocket to the Client, which calls its local `FileServer` and streams the response back. The Client keeps the existing token and traversal logic as the single source of file-serving truth. Remote mode is read-only and does not restore on cold launch; Connect/Disconnect controls the session while Forget Device removes the Keychain credential.
    +
    +Generated browser pages use relative links because the same `FileServer` HTML is mounted both at LAN `/` and remote
    +`/share//`. `share.start` registers a `share_id` generation and every relayed request echoes it; the Client
    +rejects stale generations before touching `FileServer`, closing the old-token/new-content race during share changes.
    +Relay requests have a 60-second idle timeout and cancellation propagation. The Client pauses each local data task while
    +its current WebSocket data frame is in flight, providing bounded backpressure instead of queueing a whole file.
    +
    +`state.json` mutations are protected by both an in-process mutex and a `.state.lock` advisory file lock, because the
    +running `serve` process and administrator CLI commands access the same state directory. Existing directories are
    +normalized to `0700`; state and lock files are `0600`.
     
     ### AppState and Lifecycle
     
     - Single source of truth is `sharedItems: [URL]`: empty, single, or multiple. Derived values include `isMultiple`, `isEmpty`, and convenience `sharedURL`.
     - **Cold launch does not restore sharing**. `init` does not read the last share back into `sharedItems`, and no file service starts automatically. Quietly serving a folder on LAN after opening the app would be unsafe. The last share remains available in Recent Shares (`RecentShare.paths`) for manual restart. Text inbox can auto-start only when explicitly enabled.
     - Closing the window does **not** quit. The process and service continue; `@StateObject` is created once per process, so reactivating the app returns to the previous screen.
    +- Remote access is opt-in per session. It is forced read-only, never restored on cold launch, and the Server URL/QR replace the LAN primary URL only after share registration. A disconnected agent retries and re-registers the current share.
     - **Timed stop (GUI)**: Settings → Access defaults to stopping after 1 hour, with Never, 10 minutes, 1/6/24 hours, and a custom hours/minutes duration (1 minute–99 hours 59 minutes). The preference persists; each successful start, file replacement, or timer-setting change starts a fresh countdown. Text edits and network/port rebinding retain the deadline. The timer uses wall time, runs with the window closed, and checks overdue deadlines on wake. Expiry calls `stop()`, stopping file/text/receive service and rotating credentials while retaining the selected content. Headless mode is unchanged.
     - Port preference order is `[8080, 8000, 8888, 9000]`; if all fail, choose a random high port.
     - **Changing a share does not restart the server** when the port is unchanged. The lock updates `share` and `token`, with the key changed before the content to prevent an old token from briefly reading the new share.
    @@ -195,12 +216,15 @@ swift test                      # XCTest pure-function tests
     ./build.sh                      # Assemble and ad-hoc sign -> dist/LocalShare.app
     open dist/LocalShare.app        # Local GUI smoke test
     
    +cd server && go test ./...      # LocalShare Server unit tests
    +go build -o localshare-server ./server
    +
     # Headless end-to-end test for server logic
     LS_HEADLESS=1 LS_FOLDER=/path/to/dir LS_TOKEN=testtoken LS_PORT=8099 .build/debug/LocalShare &
     curl -s "http://127.0.0.1:8099/?t=testtoken"   # Should return a directory listing or index.html
     ```
     
    -Headless multi-share uses `LS_FOLDERS` separated by `:` or newlines. Enable upload with `LS_UPLOAD=1`, bind an interface with `LS_BIND=`, allow Tailscale clients with `LS_TAILSCALE=1`, and test Text Transfer with `LS_TEXT` / `LS_RECV`. The two test layers (`swift test` and `tools/smoke-*.sh`) run in `.github/workflows/ci.yml` for every PR and `master` push.
    +Headless multi-share uses `LS_FOLDERS` separated by `:` or newlines. Enable upload with `LS_UPLOAD=1`, bind an interface with `LS_BIND=`, allow Tailscale clients with `LS_TAILSCALE=1`, simulate remote read-only policy with `LS_REMOTE=1`, and test Text Transfer with `LS_TEXT` / `LS_RECV`. The two test layers (`swift test` and `tools/smoke-*.sh`) run in `.github/workflows/ci.yml` for every PR and `master` push.
     
     **Sharing with a coworker**: copy `dist/*.app`; for the first launch, help them pass Gatekeeper once by opening the app, then System Settings -> Privacy & Security -> Open Anyway. macOS remembers this approval.
     
    @@ -212,11 +236,11 @@ Headless multi-share uses `LS_FOLDERS` separated by `:` or newlines. Enable uplo
     
     - Apple notarization, which requires a paid developer account.
     - HTTPS with a self-signed certificate, which would replace scan-to-use with certificate warnings.
    -- Automatic third-party or public tunneling such as cloudflared and ngrok.
    +- Automatic third-party or public tunneling such as SSH/frp, cloudflared, and ngrok. The remote path uses the self-contained LocalShare Server; optional Tailscale access remains a user-managed network path.
     - **Online edit/delete in the browser**. This is intentionally not supported: mobile browser editing is poor, overwrite/delete risks are high, and guest upload already covers the primary phone -> Mac need. `Permission.edit` and `Permission.del` remain `false`.
     
     **Potential future work:**
     
     - **Chunked upload**: bypass Swifter's whole-body-in-memory limitation by slicing on the frontend, appending chunks server-side, and atomically renaming on the final chunk. This would allow arbitrary size with constant memory and remove the 500 MB cap.
    -- **Public relay**: an app-owned outbound tunnel and forwarding server may later return an opaque public URL. The address model can represent it, but no relay connection, server, setting, or UI placeholder is implemented yet.
    +- **Managed relay hosting**: the relay is currently self-hosted. A future managed service could reuse the existing opaque public URL model without changing LAN or Tailscale address handling.
     - **Better device-name lookup**: current lookup is best-effort `getnameinfo`; iPhones often do not resolve and fall back to IP. More accurate mDNS PTR lookup via `DNSServiceQueryRecord` is possible but more complex and still not guaranteed.
    diff --git a/docs/REMOTE_SHARING_PLAN.md b/docs/REMOTE_SHARING_PLAN.md
    new file mode 100644
    index 0000000..949dc83
    --- /dev/null
    +++ b/docs/REMOTE_SHARING_PLAN.md
    @@ -0,0 +1,189 @@
    +# LocalShare Remote Sharing Plan
    +
    +> 目标:让浏览器通过一个自托管的 LocalShare Server 访问 Mac 上正在分享的文件。Server 是单个二进制,不依赖 SSH、frp、Nginx、Tailscale 或其他运行时组件。
    +
    +## 1. 方案
    +
    +```text
    +浏览器
    +  │ GET /share//...
    +  ▼
    +LocalShare Server :8080
    +  ├─ Control Plane:Enrollment Key、Device Token、设备连接、分享注册
    +  ├─ Data Relay:把浏览器 HTTP 请求转成 WebSocket 消息
    +  └─ HTTP endpoint:认证分享 token、流式返回文件
    +       ▲ 一个长连接 WebSocket
    +       │
    +LocalShare Client(Mac)
    +  └─ RemoteAgent → 本机 FileServer → 用户选择的文件
    +```
    +
    +Server 不保存文件。文件内容始终由 Client 从本机 `FileServer` 读取,再经过 Server 流式转发给浏览器。
    +
    +V1 的边界:
    +
    +- 一个 Server 可管理多个设备,但每台设备最多一个当前分享;
    +- 一个 Client 只连接一个 Server;
    +- 只转发 `GET` / `HEAD`,支持 `Range`,支持取消请求;
    +- 远程模式强制只读,不开放上传和 Text Transfer;
    +- 不做 Server 端文件缓存、用户账号、浏览器账号登录或多租户计费。
    +
    +## 2. 用户配置体验
    +
    +### Server 管理员
    +
    +下载并运行一个二进制:
    +
    +```bash
    +localshare-server serve \
    +  --listen :8080 \
    +  --public-url https://ls.example.com \
    +  --tls-cert /etc/localshare/server.crt \
    +  --tls-key /etc/localshare/server.key \
    +  --state-dir /var/lib/localshare
    +```
    +
    +首次配对时生成一次性 Enrollment Key:
    +
    +```bash
    +localshare-server key create --name macbook
    +# 输出 key 只显示一次
    +```
    +
    +管理命令:
    +
    +```bash
    +localshare-server key list
    +localshare-server key revoke 
    +localshare-server device list
    +localshare-server device revoke 
    +```
    +
    +`state.json` 只保存 Enrollment Key 和 Device Token 的 SHA-256,不保存可直接使用的明文密钥;目录和文件权限分别为 `0700` / `0600`。
    +
    +### Client 用户
    +
    +第一次在 LocalShare 设置中填写:
    +
    +1. Server address,例如 `https://ls.example.com`;
    +2. Enrollment Key。
    +
    +点击保存并连接后,Client 调用 `/api/v1/enroll` 换取 Device Token,并把 Device Token 保存到 macOS Keychain。Enrollment Key 不落盘。
    +
    +之后设置页只需要保留 Server address;分享页提供:
    +
    +- `Connect`:建立 WebSocket 长连接并注册当前分享;
    +- `Disconnect`:断开当前连接,但保留已配对的 Device Token;
    +- `Forget Device`:删除 Keychain 中的 Device Token,下一次需要新的 Enrollment Key。
    +
    +应用重启后不自动恢复远程分享;用户在当前分享页点击 `Connect` 即可重新连接。连接断开时,Client 自动重试,退避上限 60 秒。
    +
    +## 3. 连接与请求流程
    +
    +```text
    +管理员生成 Enrollment Key
    +  → Client 输入 Server address + Enrollment Key
    +  → POST /api/v1/enroll
    +  → Server 一次性消费 Enrollment Key,返回 Device ID + Device Token
    +  → Client 将 Device Token 写入 Keychain
    +  → WebSocket /api/v1/agent(Authorization: Bearer Device Token)
    +  → Client 发送 share.start
    +  → Server 返回 /share/
    +  → 浏览器访问 /share//path
    +  → Server 发送 request.begin
    +  → Client 请求本机 FileServer,并发送 response.begin / 二进制数据 / response.end
    +  → Server 原样流式返回浏览器
    +```
    +
    +WebSocket 文本消息使用 JSON;文件数据使用带 request ID 的二进制帧:
    +
    +```text
    +4 bytes big-endian request-id length
    +request-id bytes
    +file bytes
    +```
    +
    +`share.start` 同时携带当前本机分享代次 `share_id`;Server 将它绑定到公开 Share Token,
    +并在每个 `request.begin` 中原样带回。Client 只接受与当前代次一致的请求,因此换分享期间尚未来得及
    +从 Server 删除的旧公开 token 也只能得到 `410`,不能读取新内容。
    +
    +Server 只转发必要的请求头:`Accept`、`Accept-Language`、`If-Modified-Since`、`If-None-Match`、`Range`、`User-Agent`。响应会移除 `Set-Cookie`、`Connection` 和 `Transfer-Encoding`,并重写站内 `Location` 到 `/share/`。
    +LocalShare 自己生成的页面使用相对链接和相对心跳地址,使同一份 HTML 同时适用于 LAN 根路径和
    +`/share//` 挂载路径;没有尾斜杠的公开分享入口先由 Server 规范化重定向。
    +
    +## 4. Server 实现
    +
    +Server 使用 Go 标准库 HTTP;WebSocket 只需要一个小型、纯 Go 的协议依赖,最终编译进单个静态二进制。Control Plane 和 Data Relay 共用同一个 HTTP 端口。
    +
    +HTTP 路由:
    +
    +| 路径 | 作用 | 认证 |
    +|---|---|---|
    +| `GET /healthz` | 健康检查 | 无 |
    +| `POST /api/v1/enroll` | Enrollment Key 换 Device Token | Enrollment Key |
    +| `GET /api/v1/agent` | Client WebSocket 长连接 | Device Token |
    +| `GET/HEAD /share//...` | 浏览器文件请求 | Share Token |
    +
    +Server 内存中保存当前连接和分享注册;重启后设备凭据仍从 `state.json` 恢复,但 Client 需要重新连接,旧的 Share Token 失效。
    +
    +请求使用 60 秒无数据空闲超时;每收到响应头或数据块都会续期。浏览器断开或超时时,Server 发送
    +`request.cancel`,Client 取消对应的本机 `URLSessionDataTask`,避免大文件继续占用带宽。
    +控制消息的 WebSocket 写入另有 10 秒超时,避免失活连接卡住 HTTP handler。
    +
    +## 5. Client 实现
    +
    +`RemoteAgent.swift` 只负责远程控制和流转发,不复制 FileServer 的鉴权、路径解析或预览逻辑:
    +
    +- 使用系统 `URLSessionWebSocketTask` 建立出站连接;
    +- Device Token 仅从 Keychain 读取;
    +- `share.start` 时把本机 FileServer 地址和当前 token 作为内部转发目标;
    +- Server 下发请求后,只允许白名单请求头进入本机 FileServer;
    +- 从本机响应按块发送数据;每个请求只允许一个尚未确认发送的分块,WebSocket 完成该分块后才恢复
    +  `URLSessionDataTask`,形成有界背压,不把完整文件排队进内存;
    +- 断线清理所有本地请求,重新连接后重新注册当前分享;
    +- 分享内容变化时更新本机目标并重新注册,Server 生成新的 Share Token。
    +
    +AppState 继续作为唯一状态源:远程状态、公开 URL 和本地只读策略都由 AppState 管理。RemoteAgent 不直接修改 SwiftUI 状态。
    +
    +## 6. 安全边界
    +
    +- Enrollment Key 一次性使用;
    +- Device Token 和 Share Token 使用高熵随机值,Server 只保存 Device Token 哈希;
    +- 撤销 Device 后,新建 WebSocket 会被拒绝;
    +- Client 远程连接期间 FileServer 只接受 `GET` / `HEAD`,并禁用上传和接收文本;
    +- Server 不信任浏览器传来的代理身份头,也不把设备 token 转发给浏览器;
    +- Server 不记录文件内容;默认日志不打印 Enrollment Key、Device Token、Share Token;
    +- Share Token 随分享注册变化或停止立即失效;
    +- 即使旧 Share Token 的删除消息尚在传输,`share_id` 代次校验也会阻止它读取新分享;
    +- WebSocket 是 Client 主动发起的出站长连接,Server 不需要访问 Mac 的监听端口。
    +
    +## 7. 交付阶段
    +
    +1. 在 `server/` 新增 Go module、状态存储、CLI、Enrollment、设备认证和健康检查。
    +2. 实现同端口 WebSocket Agent 与浏览器 Data Relay,先支持只读 GET/HEAD 和流式数据。
    +3. 在 Client 增加 Keychain 配对、Connect/Disconnect、断线重连和远程只读策略。
    +4. 增加 Range、取消、旧 token 失效、设备撤销和连接状态测试。
    +5. 更新 CI、README、架构文档,并在 debug/release 下运行本地 Server + headless Client 的端到端冒烟测试。
    +
    +## 8. 明确放弃的方案
    +
    +### SSH tunnel / frp / Nginx
    +
    +它们可以转发 TCP,但要求用户额外准备 SSH 信任、端口、反向转发、代理配置和多个服务生命周期。对 LocalShare 来说,这些配置不提供用户价值,且文件请求协议、设备配对和分享注册仍然需要 Client 自己实现,因此改为一个自包含 Server。
    +
    +### Tailscale / Cloudflare Tunnel
    +
    +它们适合作为未来的底层网络或部署参考,但会引入外部控制面、账号/设备身份或特定供应商依赖。当前版本先把控制面和数据中继边界做清楚,后续如需要可替换 Client 的传输层,不改变浏览器 Data Relay 和 FileServer 协议。
    +
    +### WebRTC / P2P
    +
    +能减少中继流量,但需要 NAT 穿透、ICE、浏览器端连接协商和新的数据协议,无法复用当前 HTTP 文件服务。只有在中继成本成为真实问题时再考虑。
    +
    +## 9. 暂不实现
    +
    +- 多 Client 同时共享同一 Share Token;
    +- Server 端文件缓存和断点持久化;
    +- 上传、删除、编辑和远程 Text Transfer;
    +- Server Web 管理后台;
    +- 自动 DNS、证书和云厂商部署;
    +- 对不可信 Server 的端到端加密。
    diff --git a/tools/smoke-remote-e2e.sh b/tools/smoke-remote-e2e.sh
    new file mode 100755
    index 0000000..82f05ae
    --- /dev/null
    +++ b/tools/smoke-remote-e2e.sh
    @@ -0,0 +1,80 @@
    +#!/usr/bin/env bash
    +# 真正贯通 Browser -> Go Server -> WebSocket -> Swift RemoteAgent -> FileServer。
    +set -u
    +
    +CLIENT_BIN="${CLIENT_BIN:-.build/debug/LocalShare}"
    +[ -x "$CLIENT_BIN" ] || { echo "找不到 $CLIENT_BIN,先跑 swift build"; exit 2; }
    +
    +TMP="$(mktemp -d)"
    +STATE="$TMP/state"
    +SHARE="$TMP/share"
    +SERVER_BIN="$TMP/localshare-server"
    +SERVER_PORT=$(( (RANDOM % 8000) + 36000 ))
    +LOCAL_PORT=$(( SERVER_PORT + 9000 ))
    +mkdir -p "$SHARE/sub folder"
    +printf 'hello remote\n' > "$SHARE/a.txt"
    +printf 'nested\n' > "$SHARE/sub folder/b.txt"
    +
    +SERVER_PID=""
    +CLIENT_PID=""
    +cleanup(){
    +  [ -n "$CLIENT_PID" ] && kill "$CLIENT_PID" 2>/dev/null || true
    +  [ -n "$SERVER_PID" ] && kill "$SERVER_PID" 2>/dev/null || true
    +  [ -n "$CLIENT_PID" ] && wait "$CLIENT_PID" 2>/dev/null || true
    +  [ -n "$SERVER_PID" ] && wait "$SERVER_PID" 2>/dev/null || true
    +  rm -rf "$TMP"
    +}
    +trap cleanup EXIT
    +
    +(cd server && go build -o "$SERVER_BIN" .)
    +ENROLLMENT_KEY=$("$SERVER_BIN" key create --name smoke --state-dir "$STATE" | awk '/^key: /{print $2}')
    +[ -n "$ENROLLMENT_KEY" ] || { echo "未生成 Enrollment Key"; exit 1; }
    +
    +SERVER_URL="http://127.0.0.1:$SERVER_PORT"
    +"$SERVER_BIN" serve --listen "127.0.0.1:$SERVER_PORT" --public-url "$SERVER_URL" \
    +  --state-dir "$STATE" >"$TMP/server.log" 2>&1 &
    +SERVER_PID=$!
    +
    +LS_HEADLESS=1 LS_FOLDER="$SHARE" LS_TOKEN=e2e-local-token LS_PORT="$LOCAL_PORT" LS_REMOTE=1 \
    +  LS_REMOTE_SERVER="$SERVER_URL" LS_REMOTE_KEY="$ENROLLMENT_KEY" \
    +  "$CLIENT_BIN" >"$TMP/client.log" 2>&1 &
    +CLIENT_PID=$!
    +
    +REMOTE_URL=""
    +for _ in $(seq 1 100); do
    +  REMOTE_URL=$(awk '/^LS_REMOTE_URL /{print $2; exit}' "$TMP/client.log")
    +  [ -n "$REMOTE_URL" ] && break
    +  if ! kill -0 "$CLIENT_PID" 2>/dev/null; then
    +    cat "$TMP/client.log"
    +    exit 1
    +  fi
    +  sleep 0.1
    +done
    +[ -n "$REMOTE_URL" ] || { cat "$TMP/server.log"; cat "$TMP/client.log"; exit 1; }
    +
    +PASS=0; FAIL=0
    +ok(){ echo "  ✅ $1"; PASS=$((PASS+1)); }
    +bad(){ echo "  ❌ $1"; FAIL=$((FAIL+1)); }
    +
    +echo "── 公开 URL 规范化并保留 share 前缀"
    +NO_SLASH=${REMOTE_URL%/}
    +LOCATION=$(curl -s -o /dev/null -w '%{redirect_url}' "$NO_SLASH")
    +[ "$LOCATION" = "$REMOTE_URL" ] && ok "无斜杠入口重定向" || bad "重定向为 $LOCATION"
    +
    +echo "── 目录 HTML 使用相对导航"
    +HTML=$(curl -s "$REMOTE_URL")
    +printf '%s' "$HTML" | grep -q 'href="a.txt"' && ok "文件链接相对" || bad "缺少相对文件链接"
    +printf '%s' "$HTML" | grep -q 'var LS_ROOT=""' && ok "页面根相对" || bad "页面根仍为绝对路径"
    +NESTED_HTML=$(curl -s "${REMOTE_URL}sub%20folder/")
    +printf '%s' "$NESTED_HTML" | grep -q 'href="b.txt"' && ok "嵌套目录链接相对" || bad "嵌套目录链接错误"
    +printf '%s' "$NESTED_HTML" | grep -q 'href="../"' && ok "面包屑保留挂载前缀" || bad "面包屑根链接错误"
    +
    +echo "── 远程读取、嵌套目录与 Range"
    +[ "$(curl -s "${REMOTE_URL}a.txt")" = "hello remote" ] && ok "读取文件" || bad "读取文件失败"
    +[ "$(curl -s "${REMOTE_URL}sub%20folder/b.txt")" = "nested" ] && ok "读取嵌套文件" || bad "读取嵌套文件失败"
    +RANGE=$(curl -s -H 'Range: bytes=0-4' "${REMOTE_URL}a.txt")
    +[ "$RANGE" = "hello" ] && ok "Range" || bad "Range 返回 $RANGE"
    +
    +echo
    +echo "结果:PASS=$PASS  FAIL=$FAIL"
    +[ "$FAIL" -eq 0 ]
    diff --git a/tools/smoke-remote-readonly.sh b/tools/smoke-remote-readonly.sh
    new file mode 100755
    index 0000000..1875afd
    --- /dev/null
    +++ b/tools/smoke-remote-readonly.sh
    @@ -0,0 +1,48 @@
    +#!/usr/bin/env bash
    +# 防回归冒烟测:远程模式只读,并验证文件流支持 Range。
    +set -u
    +
    +BIN="${BIN:-.build/debug/LocalShare}"
    +[ -x "$BIN" ] || { echo "找不到 $BIN,先跑 swift build"; exit 2; }
    +
    +PORT=$(( (RANDOM % 10000) + 44000 ))
    +TOK="remote-readonly-test"
    +ROOT="$(mktemp -d)"
    +echo hi > "$ROOT/a.txt"
    +PASS=0; FAIL=0
    +ok(){ echo "  ✅ $1"; PASS=$((PASS+1)); }
    +bad(){ echo "  ❌ $1"; FAIL=$((FAIL+1)); }
    +
    +LS_HEADLESS=1 LS_FOLDER="$ROOT" LS_TEXT=secret LS_REMOTE=1 LS_UPLOAD=1 LS_RECV=1 \
    +  LS_TOKEN="$TOK" LS_PORT="$PORT" "$BIN" >/dev/null 2>&1 &
    +SRV=$!
    +trap 'kill $SRV 2>/dev/null; wait $SRV 2>/dev/null; rm -rf "$ROOT"' EXIT
    +
    +base="http://127.0.0.1:$PORT"
    +share_file_path="/$(basename "$ROOT")/a.txt"
    +for _ in $(seq 1 50); do
    +  [ "$(curl -s -o /dev/null -w '%{http_code}' "$base/?t=$TOK")" != "000" ] && break
    +  sleep 0.1
    +done
    +
    +echo "── 无 token 仍返回 403"
    +S=$(curl -s -o /dev/null -w '%{http_code}' "$base/")
    +[ "$S" = "403" ] && ok "无 token 403" || bad "应 403 实为 $S"
    +
    +echo "── 远程模式拒绝上传与收文本"
    +S=$(curl -s -o /dev/null -w '%{http_code}' -X POST -F 'f=@/etc/hosts' "$base/?t=$TOK")
    +[ "$S" = "405" ] && ok "上传 405" || bad "上传应 405 实为 $S"
    +S=$(curl -s -o /dev/null -w '%{http_code}' -X POST --data-binary 'x' "$base/ls/text?t=$TOK")
    +[ "$S" = "405" ] && ok "收文本 405" || bad "收文本应 405 实为 $S"
    +S=$(curl -s -o /dev/null -w '%{http_code}' "$base/ls/text?t=$TOK")
    +[ "$S" = "404" ] && ok "共享文本不公开" || bad "共享文本应 404 实为 $S"
    +
    +echo "── 远程模式仍可读取并支持 Range"
    +S=$(curl -s -o /dev/null -w '%{http_code}' "$base$share_file_path?t=$TOK")
    +[ "$S" = "200" ] && ok "读取 200" || bad "读取应 200 实为 $S"
    +S=$(curl -s -o /dev/null -w '%{http_code}' -H 'Range: bytes=0-1' "$base$share_file_path?t=$TOK")
    +[ "$S" = "206" ] && ok "Range 206" || bad "Range 应 206 实为 $S"
    +
    +echo
    +echo "结果:PASS=$PASS  FAIL=$FAIL"
    +[ "$FAIL" -eq 0 ]
    diff --git a/tools/smoke-text-receive.sh b/tools/smoke-text-receive.sh
    index 8d7b3c9..11990dd 100755
    --- a/tools/smoke-text-receive.sh
    +++ b/tools/smoke-text-receive.sh
    @@ -44,7 +44,7 @@ echo "── GET /ls/text(cookie,无共享文本)退化成发送表单"
     P=$(curl -s -H 'Accept: text/html' -b "ls_token=$TOK" "$base/ls/text")
     echo "$P" | grep -q 'id="sendta"' && ok "含输入框" || bad "缺输入框 textarea"
     echo "$P" | grep -q 'id="sendbtn"' && ok "含发送按钮" || bad "缺发送按钮"
    -echo "$P" | grep -q "/ls/text" && ok "脚本投递到 /ls/text" || bad "未见 /ls/text 投递"
    +echo "$P" | grep -Fq "fetch(LS_ROOT+'ls/text'" && ok "脚本投递到挂载前缀下的 ls/text" || bad "未见挂载前缀下的 ls/text 投递"
     
     echo "── 旧 /ls/send 302 兼容跳 /ls/text"
     H=$(curl -s -D - -o /dev/null -b "ls_token=$TOK" "$base/ls/send")
    diff --git a/tools/smoke-text.sh b/tools/smoke-text.sh
    index 83ad6af..a283973 100755
    --- a/tools/smoke-text.sh
    +++ b/tools/smoke-text.sh
    @@ -69,7 +69,7 @@ base="http://127.0.0.1:$PORT"
     echo "── 文本+文件:虚拟根列出文本行(指向 /ls/text)"
     L=$(curl -s -H 'Accept: text/html' -b "ls_token=$TOK" "$base/")
     echo "$L" | grep -q 'class="row txtentry' && ok "含文本行" || bad "缺文本行"
    -echo "$L" | grep -q 'href="/ls/text"' && ok "文本行链到 /ls/text" || bad "文本行未链到 /ls/text"
    +echo "$L" | grep -q 'href="ls/text"' && ok "文本行相对链接解析到 /ls/text" || bad "文本行未使用相对 ls/text 链接"
     echo "$L" | grep -q 'snippet preview line' && ok "显示首行预览" || bad "缺预览"
     S=$(curl -s -o /dev/null -w '%{http_code}' "$base/ls/text?t=$TOK&raw=1")
     [ "$S" = "200" ] && ok "/ls/text 仍可取文本" || bad "/ls/text 应 200 实为 $S"