From 60cc0db6f0f3128a8383a7b6b2e0eeebf732b244 Mon Sep 17 00:00:00 2001 From: shawn Date: Thu, 30 Jul 2026 00:26:11 +0800 Subject: [PATCH 1/8] docs: add remote sharing implementation plan --- docs/REMOTE_SHARING_PLAN.md | 207 ++++++++++++++++++++++++++++++++++++ 1 file changed, 207 insertions(+) create mode 100644 docs/REMOTE_SHARING_PLAN.md diff --git a/docs/REMOTE_SHARING_PLAN.md b/docs/REMOTE_SHARING_PLAN.md new file mode 100644 index 0000000..054ce94 --- /dev/null +++ b/docs/REMOTE_SHARING_PLAN.md @@ -0,0 +1,207 @@ +# Remote Sharing Plan + +> Status: proposed. This document records the architecture decision and implementation plan; LocalShare remains LAN-only until the plan is implemented. + +## Goal + +Allow a Mac running LocalShare to share files with a remote device that has only a web browser. + +The first version should: + +- use a user-owned public server and domain; +- require no companion app, VPN, or account on the receiving device; +- keep files on the Mac and stream them through the server without storage; +- preserve the existing `FileServer`, URL paths, token authentication, and QR flow; +- expose remote shares only while explicitly enabled; +- add no package-external runtime dylibs. + +## Options Considered + +| Approach | Browser-only receiver | Self-hosted | Fit for LocalShare | +|---|---:|---:|---| +| [frp SSH Tunnel Gateway](https://gofrp.org/en/docs/features/common/ssh/) behind Nginx | Yes | Yes | **Selected.** Uses macOS `/usr/bin/ssh`, reuses `FileServer`, and needs no bundled tunnel client. | +| Plain OpenSSH reverse forwarding behind Nginx | Yes | Yes | Smallest single-Mac setup, but frp adds HTTP host routing and a path to multiple clients without a custom control service. | +| [Cloudflare Tunnel](https://developers.cloudflare.com/tunnel/) / [ngrok](https://ngrok.com/docs/agent/) | Yes | No | Mature UX reference, but requires a vendor account, agent, and hosted edge. | +| [Tailscale Funnel](https://tailscale.com/docs/features/tailscale-funnel) / [NetBird Reverse Proxy](https://docs.netbird.io/manage/reverse-proxy) | Yes | Depends on provider | Adds a VPN identity/control plane and another agent; public proxy features are broader than the first version needs. | +| Tailscale, Headscale, or raw WireGuard mesh | No, unless a public gateway is added | Yes | Good for trusted devices or connected sites, but the receiver otherwise needs a VPN client. | +| [zrok](https://github.com/openziti/zrok) / [Pangolin](https://docs.pangolin.net/) | Yes | Yes | Closest complete products, but their control planes, connectors, and licensing/operations are unnecessary for one server and one active share. | +| WebRTC or relay-assisted transfer, such as [PairDrop](https://github.com/schlagmichdoch/PairDrop) or [Magic Wormhole](https://magic-wormhole.readthedocs.io/en/latest/file-transfer-protocol.html) | Sometimes | Yes | Requires a new transfer protocol and receiver flow instead of reusing LocalShare's HTTP browser. | +| Upload files to temporary cloud storage | Yes | Yes | Lets the Mac go offline, but stores file contents on the server and changes LocalShare's live-sharing model. | + +## Decision + +Use Nginx for the public HTTPS endpoint and frp's SSH Tunnel Gateway for temporary reverse forwarding: + +```text +browser + -> HTTPS :443 +Nginx + -> HTTP 127.0.0.1:18080 +frps HTTP virtual host + -> encrypted SSH reverse tunnel +macOS /usr/bin/ssh + -> LocalShare FileServer +``` + +This is a relayed design, not peer-to-peer NAT traversal. All remote traffic crosses the user's server. + +For the first version: + +- one fixed public hostname, such as `share.example.com`; +- one active remote share per server; +- no database, allocation API, wildcard domain, or bundled tunnel binary; +- Nginx and frps run continuously, while the proxy registration, SSH connection, and share token are temporary. + +## Server Plan + +### Nginx + +Nginx owns ports 80 and 443, redirects to HTTPS, and proxies the fixed share hostname to the private frps HTTP port. + +The server block must: + +- preserve `Host`; +- set `X-Forwarded-Proto` and `X-Forwarded-For`; +- disable response buffering for streamed downloads; +- allow only `GET` and `HEAD` in the first version; +- disable access logs for the share host, or redact the `t` query parameter; +- never cache responses. + +Minimal proxy shape: + +```nginx +location / { + limit_except GET HEAD { deny all; } + + proxy_pass http://127.0.0.1:18080; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Forwarded-Proto https; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_buffering off; + proxy_request_buffering off; + proxy_read_timeout 1h; +} +``` + +### frps + +frps provides the SSH gateway and HTTP host routing. + +Suggested ports: + +| Port | Purpose | Public | +|---|---|---:| +| 443 | Nginx HTTPS | Yes | +| 2200 | frps SSH Tunnel Gateway | Yes | +| 18080 | frps HTTP virtual host | No | +| 7000 | regular frpc transport, unused by v1 | No | + +Production configuration must: + +- authenticate SSH clients with `sshTunnelGateway.authorizedKeysFile`; +- use a persistent SSH host key; +- run as an unprivileged service account; +- block ports 18080 and 7000 at the public firewall; +- avoid logging LocalShare share tokens. + +## LocalShare Plan + +### Configuration + +Store only non-secret connection settings: + +- public HTTPS origin; +- SSH host and port; +- optional SSH identity path; +- server host-key verification state. + +Use the system SSH configuration, agent, `known_hosts`, and Keychain where available. Never disable strict host-key verification. + +### Tunnel Lifecycle + +Add one small `RemoteTunnel` component that launches `/usr/bin/ssh` with `Process`, passes arguments directly without a shell, captures errors, and exposes connection state. + +The command shape is: + +```bash +/usr/bin/ssh -T \ + -o BatchMode=yes \ + -o ClearAllForwardings=yes \ + -o ExitOnForwardFailure=yes \ + -o ServerAliveInterval=15 \ + -o ServerAliveCountMax=3 \ + -p 2200 \ + -R :80:: \ + v0@relay.example.com \ + http --proxy_name localshare --custom_domain share.example.com +``` + +Lifecycle rules: + +- start the tunnel only after `FileServer` has a listening port; +- keep the tunnel when share content changes, but rotate the share token; +- stop the tunnel when remote sharing or the share itself stops; +- keep it alive when the window closes, matching current server behavior; +- stop it when the app exits; +- report disconnection and retry with capped backoff while remote sharing remains enabled. + +When "Current network only" is enabled, forward to the server's selected listen address instead of assuming loopback. + +### URL and UI + +Refactor URL construction to accept either the current LAN origin or the configured public HTTPS origin while preserving existing paths and `?t=` authentication. + +The share screen adds: + +- a Remote Access switch; +- connecting, online, and offline states; +- the public URL and QR code while remote access is active; +- retry and stop actions. + +The LAN URL remains available. + +### Security + +Before enabling public access: + +- raise share-token entropy to at least 128 bits; +- force remote sharing to read-only and disable upload/text receive routes at both the app and Nginx layers; +- mark cookies `Secure` for trusted HTTPS proxy requests; +- give remote shares a default one-hour lifetime; +- invalidate old tokens immediately on content change or stop; +- trust forwarded client IP headers only when remote mode is active and the request arrived through the local tunnel; +- document that Nginx terminates TLS, so the user-owned server is inside the trust boundary. + +End-to-end encryption against an untrusted relay is not part of the first version. + +## Delivery + +1. Deploy Nginx and frps on a test server and repeat the existing local proof of concept over the public internet. +2. Add strong tokens and public-origin URL construction with unit tests. +3. Add `RemoteTunnel`, connection settings, and lifecycle handling. +4. Add the minimal remote-share UI and fixed expiration behavior. +5. Run unit tests, headless smoke tests, release build, and the dependency gate. + +Public-server acceptance checks: + +- no token returns 403; +- browser `?t=` -> cookie -> clean URL flow still works; +- a large file downloaded through Nginx and frp matches its source checksum; +- changing or stopping a share invalidates the old URL; +- stopping SSH makes the public endpoint unavailable; +- network changes reconnect without reviving an expired token; +- ports 18080 and 7000 are unreachable publicly; +- Nginx/frps logs do not contain share tokens. + +## Deferred + +Add these only when the single-server limit becomes real: + +- multiple simultaneous Macs or users; +- wildcard subdomains and dynamic proxy allocation; +- a management API or account system; +- browser authentication beyond the LocalShare capability URL; +- P2P/WebRTC transport; +- resumable downloads; +- end-to-end encryption against an untrusted relay. From e8540c0dd3e3985c9b6b749b988e2960718c3c96 Mon Sep 17 00:00:00 2001 From: shawn Date: Sat, 1 Aug 2026 23:40:16 +0800 Subject: [PATCH 2/8] feat: add browser-only remote sharing --- .github/workflows/ci.yml | 3 + README.md | 7 +- README_CN.md | 7 +- Sources/LocalShare/App.swift | 4 + Sources/LocalShare/AppState.swift | 189 +++++++++++++++-- Sources/LocalShare/FileServer.swift | 67 +++++- Sources/LocalShare/HeadlessServer.swift | 7 +- Sources/LocalShare/Lang.swift | 43 +++- Sources/LocalShare/RemoteTunnel.swift | 198 ++++++++++++++++++ Sources/LocalShare/SettingsScreen.swift | 73 ++++++- Sources/LocalShare/ShareScreen.swift | 69 ++++++ Sources/LocalShare/Token.swift | 6 +- .../LocalShareTests/RemoteSharingTests.swift | 54 +++++ docs/ARCHITECTURE.md | 25 ++- docs/REMOTE_SHARING_PLAN.md | 2 +- tools/smoke-remote-readonly.sh | 44 ++++ 16 files changed, 758 insertions(+), 40 deletions(-) create mode 100644 Sources/LocalShare/RemoteTunnel.swift create mode 100644 Tests/LocalShareTests/RemoteSharingTests.swift create mode 100755 tools/smoke-remote-readonly.sh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index caa82e4..c531072 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -51,6 +51,9 @@ jobs: - name: 网页逐请求语言(Accept-Language) run: ./tools/smoke-accept-language.sh + - name: 远程访问只读策略 + run: ./tools/smoke-remote-readonly.sh + - name: 传递文本(/ls/text + 转义 + 虚拟根文本行) run: ./tools/smoke-text.sh diff --git a/README.md b/README.md index e153bb3..2b987a4 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 remote access through your own HTTPS origin and frp SSH gateway; remote shares are read-only and expire after one hour ## 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 + +Configure your HTTPS origin, relay SSH host, port, and optional identity in Settings, then enable Remote Access on an active share. This requires the user-owned Nginx + frp SSH gateway described in [the remote-sharing plan](docs/REMOTE_SHARING_PLAN.md); the receiving device only needs a browser. The relay is inside the trust boundary, and remote access is read-only with a one-hour lifetime. + ## 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..e943649 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` 在终端显示链接和二维码 +- 可选通过自有 HTTPS 地址和 frp SSH 网关远程访问;远程分享只读,默认一小时后失效 ## 为什么有这个 app @@ -40,6 +41,10 @@ > ⚠️ 传输是明文 HTTP(没有加密)。机场咖啡厅等公共网络下最好不要使用。 +### 远程浏览器分享 + +在「设置」里填写 HTTPS 公共地址、转发服务器 SSH 主机/端口和可选私钥路径,然后在正在分享的票据上打开「远程访问」。这需要按[远程分享计划](docs/REMOTE_SHARING_PLAN.md)部署自有 Nginx + frp SSH 网关,对端设备只需浏览器;转发服务器属于信任边界,远程访问只读,默认一小时后失效。 + ## 终端用法 在「设置 → 命令行工具」里点「安装」,之后可以在终端一键分享: 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 fdd0123..f2cedc3 100644 --- a/Sources/LocalShare/AppState.swift +++ b/Sources/LocalShare/AppState.swift @@ -1,5 +1,6 @@ import AppKit import SwiftUI +import Network // 全局状态:当前分享对象(文件夹或单个文件)、服务运行态、网络接口候选、二维码 URL、 // 监听端口配置、最近分享历史、屏幕路由、权限(读取常开,上传可切换)。 @@ -45,6 +46,11 @@ final class AppState: ObservableObject { @Published var textInboxEnabled = false // 「允许收文本」闸门(默认关,持久化;不限分享形态) @Published var persistReceivedText = false // 「记住收到的文本」(默认关,持久化) @Published var cliStatus: CLIInstaller.Status = .notInstalled // 命令行工具安装状态 + @Published var remoteSettings = RemoteSettings() + @Published private(set) var remoteAccessEnabled = false + @Published private(set) var remoteStatus: RemoteTunnel.Status = .disabled + @Published private(set) var remoteExpiresAt: Date? + @Published private(set) var remoteError: String? // GUI 进程仅构造一次,init 末尾自登记;AppDelegate 的 open 事件回调经它触达状态。 static private(set) var shared: AppState? @@ -54,6 +60,10 @@ final class AppState: ObservableObject { @Published private(set) var token = Token.generate() private var server: FileServer? private var viewerTimer: Timer? + private let remoteTunnel = RemoteTunnel() + private var remoteExpiryTimer: Timer? + private var networkMonitor: NWPathMonitor? + private var networkPathStatus: NWPath.Status? private let portKey = "configuredPort" private let bindSelectedOnlyKey = "bindSelectedOnly" @@ -66,12 +76,28 @@ final class AppState: ObservableObject { private let textInboxKey = "textInboxEnabled" // 收件箱闸门(默认关) private let persistReceivedKey = "persistReceivedText" // 是否记住收到的文本(默认关) private let receivedTextsKey = "receivedTexts" // 收件箱内容(仅 persistReceivedText 开时写入) + private let remoteOriginKey = "remotePublicOrigin" + private let remoteSSHHostKey = "remoteSSHHost" + private let remoteSSHPortKey = "remoteSSHPort" + private let remoteIdentityPathKey = "remoteSSHIdentityPath" + private static let remoteLifetime: TimeInterval = 60 * 60 // 配置端口优先,其余作回退(8080 列入回退以防配置端口占用)。 private let fallbackPorts: [in_port_t] = [8000, 8888, 9000, 8080] init() { let savedPort = UserDefaults.standard.integer(forKey: portKey) if (1024...65535).contains(savedPort) { configuredPort = in_port_t(savedPort) } + let savedRemotePort = UserDefaults.standard.integer(forKey: remoteSSHPortKey) + remoteSettings = RemoteSettings( + publicOrigin: UserDefaults.standard.string(forKey: remoteOriginKey) ?? "", + sshHost: UserDefaults.standard.string(forKey: remoteSSHHostKey) ?? "", + sshPort: (1...65535).contains(savedRemotePort) ? savedRemotePort : 2200, + identityPath: UserDefaults.standard.string(forKey: remoteIdentityPathKey) ?? "" + ) + remoteTunnel.onStateChange = { [weak self] status, error in + self?.remoteStatus = status + self?.remoteError = error + } bindSelectedOnly = UserDefaults.standard.bool(forKey: bindSelectedOnlyKey) // 未写入默认 false if let a = UserDefaults.standard.string(forKey: appearanceKey).flatMap(AppearancePref.init) { appearance = a } if let l = UserDefaults.standard.string(forKey: langPrefKey).flatMap(LangPref.init) { langPref = l } @@ -107,6 +133,7 @@ final class AppState: ObservableObject { AppDelegate.pendingOpenURLs = [] setShared(urls) } + startNetworkMonitoring() } // MARK: - 选择态派生 @@ -126,27 +153,34 @@ final class AppState: ObservableObject { // MARK: - 派生 URL / 二维码 // 文件夹/多选模式 → 根地址;单文件模式 → 直链该文件(路径仅供浏览器显示文件名/扩展名)。 - private func makeURL(host: String) -> String { + private func makeURL(base: String) -> String { let q = "?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 origin = remoteSettings.publicURL else { return nil } + return makeURL(base: origin.absoluteString) } var primaryURL: String? { + if let remoteURL { return remoteURL } guard isRunning, port != 0, let ip = selectedInterface?.ip else { return nil } - return makeURL(host: ip) + return makeURL(base: "http://\(ip):\(port)") } var localURL: String? { guard isRunning, port != 0, let host = localHost else { return nil } - return makeURL(host: host) + return makeURL(base: "http://\(host):\(port)") } var qrImage: NSImage? { @@ -165,6 +199,129 @@ final class AppState: ObservableObject { selectedInterface = interfaces.first } + // Remote access is session-only. Connection details persist, but the public + // switch never does: reopening the app cannot silently publish a share. + var canEnableRemote: Bool { isRunning && !isEmpty && remoteSettings.isValid } + + func saveRemoteSettings(_ settings: RemoteSettings) { + remoteSettings = settings + UserDefaults.standard.set(settings.publicOrigin, forKey: remoteOriginKey) + UserDefaults.standard.set(settings.sshHost, forKey: remoteSSHHostKey) + UserDefaults.standard.set(settings.sshPort, forKey: remoteSSHPortKey) + UserDefaults.standard.set(settings.identityPath, forKey: remoteIdentityPathKey) + guard remoteAccessEnabled else { return } + guard settings.isValid else { + disableRemoteAccess() + remoteError = L.remoteConfigInvalid(lang) + return + } + startRemoteTunnel() + } + + func setRemoteAccessEnabled(_ on: Bool) { + guard on != remoteAccessEnabled else { return } + if on { + guard canEnableRemote else { + remoteError = L.remoteConfigHint(lang) + return + } + permission.add = false + textInboxEnabled = false + UserDefaults.standard.set(false, forKey: textInboxKey) + remoteAccessEnabled = true + remoteError = nil + remoteExpiresAt = Date().addingTimeInterval(Self.remoteLifetime) + scheduleRemoteExpiry() + applyRemotePolicy() + pushToServer() + startRemoteTunnel() + } else { + disableRemoteAccess() + } + } + + func retryRemoteAccess() { + guard remoteAccessEnabled else { return } + remoteError = nil + startRemoteTunnel() + } + + private func disableRemoteAccess(rotateToken: Bool = false) { + remoteTunnel.stop() + remoteExpiryTimer?.invalidate() + remoteExpiryTimer = nil + remoteAccessEnabled = false + remoteExpiresAt = nil + remoteError = nil + if rotateToken { token = Token.generate() } + if isRunning { pushToServer() } + } + + private func expireRemoteAccess() { + guard remoteAccessEnabled, let expires = remoteExpiresAt, expires <= Date() else { return } + disableRemoteAccess(rotateToken: true) + remoteError = L.remoteExpired(lang) + } + + private func scheduleRemoteExpiry() { + remoteExpiryTimer?.invalidate() + remoteExpiryTimer = Timer.scheduledTimer(withTimeInterval: Self.remoteLifetime, repeats: false) { [weak self] _ in + Task { @MainActor [weak self] in self?.expireRemoteAccess() } + } + } + + private var tunnelLocalAddress: String { + bindSelectedOnly ? (selectedInterface?.ip ?? "127.0.0.1") : "127.0.0.1" + } + + private var tunnelProxyAddresses: Set { + var addresses: Set = ["127.0.0.1", "::1"] + if let selected = selectedInterface?.ip { addresses.insert(selected) } + return addresses + } + + private func applyRemotePolicy() { + server?.remoteAccessEnabled = remoteAccessEnabled + server?.trustedProxyAddresses = tunnelProxyAddresses + server?.uploadEnabled = permission.add && canToggleUpload && !remoteAccessEnabled + server?.textInboxEnabled = textInboxEnabled && !remoteAccessEnabled + } + + private func startRemoteTunnel() { + guard remoteAccessEnabled, isRunning else { return } + guard let configuration = remoteSettings.tunnelConfiguration(localAddress: tunnelLocalAddress, localPort: port) else { + remoteStatus = .offline + remoteError = L.remoteConfigInvalid(lang) + return + } + applyRemotePolicy() + remoteTunnel.start(configuration) + } + + 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.bindSelectedOnly && oldInterfaces != self.interfaces { + self.rebindServer() + } else if statusChanged || oldInterfaces != self.interfaces { + self.startRemoteTunnel() + } + } + } + monitor.start(queue: queue) + } + // MARK: - 选择分享对象 func pickFolder() { pick(files: false, directories: true, message: L.pickFolderMsg(lang)) } @@ -203,8 +360,8 @@ final class AppState: ObservableObject { private func pushToServer() { server?.token = token server?.sharedText = hasText ? sharedText : nil - server?.textInboxEnabled = textInboxEnabled server?.share = currentShare + applyRemotePolicy() } // 分享 / 更新一段文本(Mac→手机)。token 的「会话」维度与分享文件一致:只在会话边界轮换 @@ -273,11 +430,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() { @@ -307,11 +465,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() // 从空状态开启 → 起服务(只收模式) @@ -389,8 +548,10 @@ final class AppState: ObservableObject { refreshNetwork() let fs = FileServer(share: currentShare, token: token) fs.sharedText = hasText ? sharedText : nil - fs.uploadEnabled = permission.add && canToggleUpload - fs.textInboxEnabled = textInboxEnabled + fs.remoteAccessEnabled = remoteAccessEnabled + fs.trustedProxyAddresses = tunnelProxyAddresses + fs.uploadEnabled = permission.add && canToggleUpload && !remoteAccessEnabled + fs.textInboxEnabled = textInboxEnabled && !remoteAccessEnabled fs.onUpload = { url in // socket 线程 → 主线程 Task { @MainActor [weak self] in self?.recordReceived(url) } } @@ -407,6 +568,7 @@ final class AppState: ObservableObject { isRunning = true lastError = nil startViewerPolling() + if remoteAccessEnabled { startRemoteTunnel() } } catch { // 绑定指定网卡失败(IP 刚因 DHCP/切网消失):退回全接口重试一次,给提示但不让分享中断。 if bindIP != nil { @@ -417,6 +579,7 @@ final class AppState: ObservableObject { isRunning = true lastError = LStr.ifaceUnavailable(lang) startViewerPolling() + if remoteAccessEnabled { startRemoteTunnel() } return } } @@ -430,6 +593,7 @@ final class AppState: ObservableObject { private func rebindServer() { guard isRunning else { return } viewerTimer?.invalidate(); viewerTimer = nil + if remoteAccessEnabled { remoteTunnel.stop() } server?.stop() server = nil start() // 复用 self.token,仅监听地址/端口随当前选择重算 @@ -450,6 +614,7 @@ final class AppState: ObservableObject { } func stop() { + disableRemoteAccess() viewerTimer?.invalidate() viewerTimer = nil viewerCount = 0 diff --git a/Sources/LocalShare/FileServer.swift b/Sources/LocalShare/FileServer.swift index 5efe4a9..13287ce 100644 --- a/Sources/LocalShare/FileServer.swift +++ b/Sources/LocalShare/FileServer.swift @@ -94,6 +94,21 @@ final class FileServer { // 每收到一段文本回调一次(socket 线程);GUI 用它入收件箱 + 提示未读,设置方自行 hop 主线程。 var onReceiveText: ((ReceivedText) -> Void)? + // Remote mode is deliberately a server-wide read-only gate: the same origin is + // reachable on LAN and through the tunnel, so allowing POST locally would also + // expose it publicly. The proxy address allowlist limits forwarded-header trust + // to the local SSH connection rather than any arbitrary LAN request. + private var _remoteAccessEnabled = false + var remoteAccessEnabled: Bool { + get { lock.lock(); defer { lock.unlock() }; return _remoteAccessEnabled } + set { lock.lock(); _remoteAccessEnabled = newValue; lock.unlock() } + } + private var _trustedProxyAddresses: Set = ["127.0.0.1", "::1"] + var trustedProxyAddresses: Set { + get { lock.lock(); defer { lock.unlock() }; return _trustedProxyAddresses } + set { lock.lock(); _trustedProxyAddresses = newValue; lock.unlock() } + } + // 单条文本上限(远小于上传的 500MB)。Swifter 进 middleware 前已把 body 整段读进内存,故同上传只能 // 事后拒绝;总数上限(收件箱满挤旧)由 AppState 把关——两道一起才挡得住「刷一堆不超单条限的消息撑爆内存」。 static let textInboxLimit = 64 * 1024 @@ -219,6 +234,28 @@ final class FileServer { return String(String(String.UnicodeScalarView(kept)).trimmingCharacters(in: .whitespaces).prefix(40)) } + // Only accept forwarded identity from a request that arrived at a trusted local + // tunnel endpoint and carries the HTTPS marker added by Nginx. The first XFF + // value is the original browser address; malformed values fall back to req.address. + static func forwardedClientAddress(headers: [String: String], sourceAddress: String?, + remoteEnabled: Bool, trustedProxyAddresses: Set) -> String? { + guard remoteEnabled, + let sourceAddress, + trustedProxyAddresses.contains(sourceAddress), + headers["x-forwarded-proto"]?.lowercased() == "https", + let raw = headers["x-forwarded-for"]?.split(separator: ",").first else { return nil } + let address = raw.trimmingCharacters(in: .whitespaces) + guard isIPAddress(address) else { return nil } + return address + } + + private static func isIPAddress(_ value: String) -> Bool { + var v4 = in_addr() + if inet_pton(AF_INET, value, &v4) == 1 { return true } + var v6 = in6_addr() + return inet_pton(AF_INET6, value, &v6) == 1 + } + init(share: Share, token: String) { self._share = share self._token = token @@ -264,24 +301,36 @@ final class FileServer { // 1. 鉴权:query ?t= 或 cookie 任一匹配当前分享的 token(每请求取一次快照, // 保证鉴权判断与下面 Set-Cookie 写的是同一把钥匙,轮换瞬间也不串) let token = self.token + let remoteMode = remoteAccessEnabled + let forwardedAddress = Self.forwardedClientAddress( + headers: req.headers, + sourceAddress: req.address, + remoteEnabled: remoteMode, + trustedProxyAddresses: trustedProxyAddresses + ) + let clientAddress = forwardedAddress ?? req.address // 网页语言逐请求决定:按浏览器 Accept-Language,与原生 app 设置无关。往下穿进每个 HTML 生产者。 let lang = Lang.fromAcceptLanguage(req.headers["accept-language"]) // 收件箱开启:listing 页(同上传表单的出现条件)多挂一张「发文本给电脑」表单。每请求取一次快照。 - let recvOn = textInboxEnabled + let recvOn = textInboxEnabled && !remoteMode let viaQuery = req.queryParams.first { $0.0 == "t" }?.1 == token let viaCookie = cookieValue("ls_token", in: req.headers["cookie"]) == token guard viaQuery || viaCookie else { return htmlResponse(403, "Forbidden", Self.forbiddenPage(lang)) } + if remoteMode && req.method != "GET" && req.method != "HEAD" { + return jsonResponse(405, "Method Not Allowed", errorJSON(L.remoteReadOnly(lang)), extra: [:]) + } // 经 query 放行且尚无(有效)cookie 时,种会话 cookie,后续资源请求免带 token。 // 不设 Max-Age:随浏览器会话走;token 轮换后旧值反正立即失效。页面 JS 不读它,HttpOnly。 var extra: [String: String] = [:] if viaQuery, !viaCookie { - extra["Set-Cookie"] = "ls_token=\(token); Path=/; SameSite=Lax; HttpOnly" + let secure = forwardedAddress != nil ? "; Secure" : "" + extra["Set-Cookie"] = "ls_token=\(token); Path=/; SameSite=Lax; HttpOnly\(secure)" } // 鉴权通过的每个请求都刷新该 IP 的活跃时间(含心跳与下载中的请求)。 - touchPresence(req.address) + touchPresence(clientAddress) // 心跳/在线数端点(保留路径,优先于分享内容命中;分享里恰好有 ls/ping 的概率可忽略)。 // req.path 不含 query(Swifter 用 URLComponents.path),精确比较即可。 @@ -357,7 +406,7 @@ final class FileServer { // 收文本(保留路径,先于上传拦截):POST /ls/text 投递一段文本到收件箱。开关关 → 403; // 超单条上限 → 413;空白 → 400。落库与未读由 onReceiveText 交给 AppState。 if req.method == "POST", req.path == "/ls/text" { - return handleReceiveText(req: req, lang: lang, extra: extra) + return handleReceiveText(req: req, clientAddress: clientAddress, lang: lang, extra: extra) } // 访客上传:POST 到当前浏览的目录。开关关 / 非文件夹分享一律拒绝(先于单文件分支拦截, @@ -409,7 +458,8 @@ final class FileServer { let rel = String(decodedPath.drop { $0 == "/" }) return serveTree(rootURL: rootURL, relPath: rel, encodedPath: req.path, decodedPath: decodedPath, rootName: rootURL.lastPathComponent, - canUpload: uploadEnabled, canReceiveText: recvOn, viewer: wantsViewer, lang: lang, extra: extra) + canUpload: uploadEnabled && !remoteMode, canReceiveText: recvOn, + viewer: wantsViewer, lang: lang, extra: extra) } // 可预览类型(md/json/csv)且浏览器导航 → 预览壳页(与文件同 URL,相对引用天然成立); @@ -496,8 +546,9 @@ final class FileServer { // 安全:闸门 textInboxEnabled 把关;超 textInboxLimit → 413;去首尾空白后为空 → 400。 // 收到的文本经 onReceiveText 交 AppState(socket 线程,设置方 hop 主线程入收件箱);FileServer 不存列表。 // 文本本身不回显进任何服务页(仅在 Mac 端 SwiftUI Text 里显示,天然不执行),故无须额外转义。 - private func handleReceiveText(req: HttpRequest, lang: Lang, extra: [String: String]) -> HttpResponse { - guard textInboxEnabled else { + private func handleReceiveText(req: HttpRequest, clientAddress: String?, lang: Lang, + extra: [String: String]) -> HttpResponse { + guard textInboxEnabled && !remoteAccessEnabled else { return jsonResponse(403, "Forbidden", errorJSON(L.recvDisabled(lang)), extra: extra) } guard req.body.count <= Self.textInboxLimit else { @@ -508,7 +559,7 @@ final class FileServer { guard !trimmed.isEmpty else { return jsonResponse(400, "Bad Request", errorJSON(L.recvEmpty(lang)), extra: extra) } - let ip = req.address ?? "" + let ip = clientAddress ?? "" lock.lock(); let name = nameCache[ip] ?? ""; lock.unlock() // 反查到则带设备名,否则展示层回退 IP onReceiveText?(ReceivedText(text: trimmed, ip: ip, name: name, date: Date())) return jsonResponse(200, "OK", #"{"ok":true}"#, extra: extra) diff --git a/Sources/LocalShare/HeadlessServer.swift b/Sources/LocalShare/HeadlessServer.swift index af03fe2..5f1dd24 100644 --- a/Sources/LocalShare/HeadlessServer.swift +++ b/Sources/LocalShare/HeadlessServer.swift @@ -11,6 +11,7 @@ import Foundation // LS_TEXT 分享一段文本(可单独,也可与 LS_FOLDER(S) 共存);纯文本时 URL 直指 /ls/text // LS_RECV 置 1 开启收文本(收件箱);无任何分享内容时 URL 直指 /ls/text(收发合一,退化成纯发送页) // LS_RECV_LOG 收到文本时把原文追加进该文件(以 0x01 分隔),供冒烟测回读校验 +// LS_REMOTE 置 1 模拟远程只读策略(仅用于本地回归,不启动 SSH 隧道) enum HeadlessServer { static func run() { let env = ProcessInfo.processInfo.environment @@ -18,6 +19,7 @@ enum HeadlessServer { let port = in_port_t(env["LS_PORT"].flatMap { Int($0) } ?? 8080) let text = env["LS_TEXT"].flatMap { $0.isEmpty ? nil : $0 } let recvOn = env["LS_RECV"] == "1" + let remoteOn = env["LS_REMOTE"] == "1" let paths: [String] if let multi = env["LS_FOLDERS"] { @@ -33,10 +35,11 @@ enum HeadlessServer { let urls = paths.map { URL(fileURLWithPath: $0) } let server = FileServer(share: makeShare(urls, hasText: text != nil), token: token) - server.uploadEnabled = env["LS_UPLOAD"] == "1" + server.remoteAccessEnabled = remoteOn + server.uploadEnabled = env["LS_UPLOAD"] == "1" && !remoteOn server.listenAddress = env["LS_BIND"] // nil → 全部接口(默认) server.sharedText = text - server.textInboxEnabled = recvOn + server.textInboxEnabled = recvOn && !remoteOn if let logPath = env["LS_RECV_LOG"] { server.onReceiveText = { rt in // socket 线程:把原文追加进日志文件,供冒烟测回读 let chunk = Data((rt.text + "\u{1}").utf8) diff --git a/Sources/LocalShare/Lang.swift b/Sources/LocalShare/Lang.swift index f1cba3e..734a249 100644 --- a/Sources/LocalShare/Lang.swift +++ b/Sources/LocalShare/Lang.swift @@ -95,7 +95,7 @@ enum L: CaseIterable { case noNetwork, noNetworkHint // 设置 —— 小节标题 - case sectionNetwork, sectionPermission, sectionAppearance, sectionLanguage, sectionMain + case sectionNetwork, sectionRemote, sectionPermission, sectionAppearance, sectionLanguage, sectionMain case sectionUpdate, sectionCLI, sectionRecent case shareSettings, shareHistory, currentColon @@ -103,6 +103,10 @@ enum L: CaseIterable { case thisMachine, portOk, portOccupied, portInvalid, portOkHint case bindOnlyTitle, bindOnlyDescMulti, bindOnlyDescSingle + // 设置 —— 远程访问 + case remotePublicOrigin, remoteSSHHost, remoteSSHPort, remoteIdentityPath, remoteSave + case remoteHTTPSHint, remoteHostKeyHint + // 设置 —— 端口校验(静态部分;被占用一项带数字走 LStr) case portEmptyMsg, portNotNumberMsg, portTooLowMsg, portTooHighMsg @@ -111,6 +115,11 @@ enum L: CaseIterable { case permUploadDescOn, permUploadDescOff case permInfoWritable, permInfoReadonly, plaintextWarning + // 远程访问 + case remoteAccess, remoteAccessDesc, remoteConfigHint, remoteConfigInvalid + case remoteConnecting, remoteOnline, remoteOffline, remoteRetry, remoteStop + case remoteExpired, remoteReadOnly + // 设置 —— 外观 case appearanceFollow, appearanceLight, appearanceDark @@ -237,6 +246,7 @@ enum L: CaseIterable { "Connect this Mac to the same Wi-Fi /\nwired network as the target device, then refresh.") case .sectionNetwork: return ("网络", "Network") + case .sectionRemote: return ("远程访问", "Remote Access") case .sectionPermission: return ("访问权限", "Access") case .sectionAppearance: return ("外观", "Appearance") case .sectionLanguage: return ("语言", "Language") @@ -260,6 +270,16 @@ enum L: CaseIterable { case .bindOnlyDescSingle: return ("只在当前网络开放,日后接入别的网络时也访问不到", "Open only on the current network; future networks won't reach it either") + case .remotePublicOrigin: return ("公共地址", "Public origin") + case .remoteSSHHost: return ("SSH 主机", "SSH host") + case .remoteSSHPort: return ("SSH 端口", "SSH port") + case .remoteIdentityPath: return ("SSH 私钥路径(可选)", "SSH identity path (optional)") + case .remoteSave: return ("保存远程设置", "Save remote settings") + case .remoteHTTPSHint: return ("需要 HTTPS 地址,例如 https://share.example.com", + "Use an HTTPS origin, for example https://share.example.com") + case .remoteHostKeyHint: return ("SSH 使用系统 known_hosts 严格校验;先确认服务器指纹已登记。", + "SSH uses the system known_hosts with strict verification; add the server fingerprint first.") + case .portEmptyMsg: return ("请输入端口号", "Enter a port number") case .portNotNumberMsg: return ("端口需为数字", "Port must be a number") case .portTooLowMsg: return ("需 ≥ 1024 · 1023 以下为系统保留端口", "Must be ≥ 1024 · ports below 1024 are reserved") @@ -277,6 +297,20 @@ enum L: CaseIterable { case .plaintextWarning: return ("同一网络下传输不加密 · 公共 Wi-Fi(咖啡馆 / 机场等)下同网的人可能看到内容,敏感文件别在这种网络分享。", "Traffic isn't encrypted on the LAN · on public Wi-Fi (cafés, airports) others on the network may see the content. Don't share sensitive files there.") + case .remoteAccess: return ("远程访问", "Remote Access") + case .remoteAccessDesc: return ("通过你的 HTTPS 地址,让浏览器跨网络只读访问当前分享。", + "Let a browser read the current share through your HTTPS origin.") + case .remoteConfigHint: return ("先在设置中填写公共地址和 SSH 连接信息。", + "Add the public origin and SSH connection details in Settings first.") + case .remoteConfigInvalid: return ("远程设置不完整或无效。", "Remote settings are incomplete or invalid.") + case .remoteConnecting: return ("连接中", "Connecting") + case .remoteOnline: return ("在线", "Online") + case .remoteOffline: return ("已断开 · 将自动重试", "Offline · retrying") + case .remoteRetry: return ("重试", "Retry") + case .remoteStop: return ("停止远程访问", "Stop remote access") + case .remoteExpired: return ("远程分享已过期,旧链接已失效。", "Remote sharing expired; the old link is no longer valid.") + case .remoteReadOnly: return ("远程访问为只读。", "Remote access is read-only.") + case .appearanceFollow: return ("跟随系统", "System") case .appearanceLight: return ("浅色", "Light") case .appearanceDark: return ("深色", "Dark") @@ -436,6 +470,13 @@ enum L: CaseIterable { // 语序、复数、数字位置中英各异,故不入 key→value 表,由函数按 lang 拼装。 enum LStr { + static func remoteExpiry(_ date: Date, _ lang: Lang) -> String { + let formatter = DateFormatter() + formatter.locale = lang == .zh ? Locale(identifier: "zh_CN") : Locale(identifier: "en_US") + formatter.dateFormat = lang == .zh ? "M月d日 HH:mm 失效" : "expires MMM d, HH:mm" + return formatter.string(from: date) + } + // "3 项" / "3 items"(目录元信息、计数、describeShared 共用) static func itemCount(_ n: Int, _ lang: Lang) -> String { lang == .zh ? "\(n) 项" : "\(n) item\(n == 1 ? "" : "s")" diff --git a/Sources/LocalShare/RemoteTunnel.swift b/Sources/LocalShare/RemoteTunnel.swift new file mode 100644 index 0000000..38872a2 --- /dev/null +++ b/Sources/LocalShare/RemoteTunnel.swift @@ -0,0 +1,198 @@ +import Foundation + +struct RemoteSettings: Equatable { + var publicOrigin = "" + var sshHost = "" + var sshPort = 2200 + var identityPath = "" + + var publicURL: URL? { + let raw = publicOrigin.trimmingCharacters(in: .whitespacesAndNewlines) + guard let url = URL(string: raw), + url.scheme?.lowercased() == "https", + url.host != nil, + url.user == nil, + url.password == nil, + url.query == nil, + url.fragment == nil, + url.path.isEmpty || url.path == "/" else { return nil } + return url + } + + var customDomain: String? { publicURL?.host } + + var isValid: Bool { + guard publicURL != nil, + !sshHost.isEmpty, + !sshHost.contains(where: { $0.isWhitespace || $0 == "/" }), + (1...65535).contains(sshPort) else { return false } + return true + } + + func tunnelConfiguration(localAddress: String, localPort: in_port_t) -> RemoteTunnel.Configuration? { + guard isValid, let customDomain else { return nil } + let identity = identityPath.trimmingCharacters(in: .whitespacesAndNewlines) + return RemoteTunnel.Configuration( + sshHost: sshHost, + sshPort: sshPort, + identityPath: identity.isEmpty ? nil : (identity as NSString).expandingTildeInPath, + customDomain: customDomain, + localAddress: localAddress, + localPort: localPort + ) + } +} + +@MainActor +final class RemoteTunnel { + enum Status: Equatable { + case disabled + case connecting + case online + case offline + } + + struct Configuration: Equatable { + let sshHost: String + let sshPort: Int + let identityPath: String? + let customDomain: String + let localAddress: String + let localPort: in_port_t + } + + private(set) var status: Status = .disabled + private(set) var lastError: String? + var onStateChange: ((Status, String?) -> Void)? + + private var process: Process? + private var stderrPipe: Pipe? + private var retryTask: Task? + private var configuration: Configuration? + private var shouldRun = false + private var retryDelay: TimeInterval = 1 + + nonisolated static func arguments(for config: Configuration) -> [String] { + var args = [ + "-T", + "-o", "BatchMode=yes", + "-o", "ClearAllForwardings=yes", + "-o", "ExitOnForwardFailure=yes", + "-o", "ServerAliveInterval=15", + "-o", "ServerAliveCountMax=3", + "-o", "StrictHostKeyChecking=yes", + "-p", String(config.sshPort), + ] + if let identityPath = config.identityPath { + args += ["-i", identityPath] + } + args += [ + "-R", ":80:\(config.localAddress):\(config.localPort)", + "v0@\(config.sshHost)", + "http", + "--proxy_name", "localshare", + "--custom_domain", config.customDomain, + ] + return args + } + + func start(_ configuration: Configuration) { + shouldRun = true + self.configuration = configuration + retryTask?.cancel() + retryTask = nil + retryDelay = 1 + stopProcess() + launch() + } + + func stop() { + shouldRun = false + configuration = nil + retryTask?.cancel() + retryTask = nil + stopProcess() + setStatus(.disabled, error: nil) + } + + private func launch() { + guard shouldRun, let configuration else { return } + setStatus(.connecting, error: nil) + + let process = Process() + process.executableURL = URL(fileURLWithPath: "/usr/bin/ssh") + process.arguments = Self.arguments(for: configuration) + process.standardOutput = FileHandle(forWritingAtPath: "/dev/null") + + let stderr = Pipe() + process.standardError = stderr + stderr.fileHandleForReading.readabilityHandler = { [weak self] handle in + let data = handle.availableData + guard !data.isEmpty else { return } + let message = String(decoding: data, as: UTF8.self).trimmingCharacters(in: .whitespacesAndNewlines) + guard !message.isEmpty else { return } + Task { @MainActor [weak self] in self?.lastError = message } + } + process.terminationHandler = { [weak self] terminated in + Task { @MainActor [weak self] in self?.handleTermination(terminated) } + } + self.process = process + self.stderrPipe = stderr + + do { + try process.run() + retryDelay = 1 + setStatus(.online, error: nil) + } catch { + self.process = nil + self.stderrPipe = nil + setStatus(.offline, error: error.localizedDescription) + scheduleRetry() + } + } + + private func handleTermination(_ terminated: Process) { + guard process === terminated else { return } + process = nil + stderrPipe?.fileHandleForReading.readabilityHandler = nil + stderrPipe = nil + guard shouldRun else { + setStatus(.disabled, error: nil) + return + } + let error = lastError ?? "ssh exited with status \(terminated.terminationStatus)" + setStatus(.offline, error: error) + scheduleRetry() + } + + private func scheduleRetry() { + guard shouldRun, configuration != nil else { return } + retryTask?.cancel() + 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.shouldRun else { return } + self.launch() + } + } + + private func stopProcess() { + guard let process else { return } + process.terminationHandler = nil + stderrPipe?.fileHandleForReading.readabilityHandler = nil + if process.isRunning { process.terminate() } + self.process = nil + stderrPipe = nil + } + + private func setStatus(_ status: Status, error: String?) { + self.status = status + lastError = error + onStateChange?(status, error) + } +} diff --git a/Sources/LocalShare/SettingsScreen.swift b/Sources/LocalShare/SettingsScreen.swift index fb61b69..ffdc133 100644 --- a/Sources/LocalShare/SettingsScreen.swift +++ b/Sources/LocalShare/SettingsScreen.swift @@ -8,6 +8,10 @@ struct SettingsScreen: View { @EnvironmentObject var state: AppState @EnvironmentObject var updater: UpdaterController @State private var portText = "" + @State private var remoteOriginText = "" + @State private var remoteSSHHostText = "" + @State private var remoteSSHPortText = "" + @State private var remoteIdentityText = "" var body: some View { // portText 初始为空、onAppear 才填入当前端口;首帧若按空串校验会闪出「无效 + 放弃/应用」行再弹回。 // 空串一律视作「当前生效端口」,让首帧与落定后一致,消除进入设置页时的这层闪烁。 @@ -104,6 +108,32 @@ struct SettingsScreen: View { } } + // MARK: 远程访问 + eyebrow(L.sectionRemote(lang)) + groupBox { + remoteField(title: L.remotePublicOrigin(lang), text: $remoteOriginText) + remoteField(title: L.remoteSSHHost(lang), text: $remoteSSHHostText, top: true) + remoteField(title: L.remoteSSHPort(lang), text: $remoteSSHPortText, top: true, width: 72, + numbersOnly: true) + remoteField(title: L.remoteIdentityPath(lang), text: $remoteIdentityText, top: true) + HStack(alignment: .top, spacing: 8) { + Image(systemName: "lock.shield").font(.system(size: 13)).foregroundStyle(t.accent) + VStack(alignment: .leading, spacing: 3) { + Text(L.remoteHTTPSHint(lang)).font(.sans(11.5)).foregroundStyle(t.inkMute) + Text(L.remoteHostKeyHint(lang)).font(.sans(11.5)).foregroundStyle(t.inkMute) + } + Spacer(minLength: 0) + } + .padding(.vertical, 10) + HStack { + Spacer() + GhostButton(t: t, title: L.remoteSave(lang), systemImage: "checkmark") { + saveRemoteSettings() + } + } + .padding(.bottom, 12) + } + // MARK: 访问权限(眼标右侧挂「当前权限」胶囊) HStack(spacing: 8) { SectionLabel(t: t, text: L.sectionPermission(lang)) @@ -119,14 +149,16 @@ struct SettingsScreen: View { groupBox { permRow(name: L.permReadName(lang), desc: L.permReadDesc(lang), locked: true, on: true) permRow(name: L.permUploadName(lang), - desc: state.canToggleUpload ? L.permUploadDescOn(lang) : L.permUploadDescOff(lang), - locked: !state.canToggleUpload, + desc: state.remoteAccessEnabled ? L.remoteReadOnly(lang) + : (state.canToggleUpload ? L.permUploadDescOn(lang) : L.permUploadDescOff(lang)), + locked: !state.canToggleUpload || state.remoteAccessEnabled, on: state.permission.add, top: true) { state.setUploadAllowed(!state.permission.add) } // 收文本:独立闸门,不限分享形态(甚至什么都没分享也能开),故不随 share 置灰。 - permRow(name: L.recvInboxTitle(lang), desc: L.recvInboxDesc(lang), - locked: false, on: state.textInboxEnabled, top: true) { + permRow(name: L.recvInboxTitle(lang), + desc: state.remoteAccessEnabled ? L.remoteReadOnly(lang) : L.recvInboxDesc(lang), + locked: state.remoteAccessEnabled, on: state.textInboxEnabled, top: true) { state.setTextInboxEnabled(!state.textInboxEnabled) } } @@ -241,6 +273,10 @@ struct SettingsScreen: View { } .onAppear { portText = String(state.configuredPort) + remoteOriginText = state.remoteSettings.publicOrigin + remoteSSHHostText = state.remoteSettings.sshHost + remoteSSHPortText = String(state.remoteSettings.sshPort) + remoteIdentityText = state.remoteSettings.identityPath state.refreshCLIStatus() } } @@ -342,4 +378,33 @@ 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) -> some View { + HStack(spacing: 10) { + Text(title).font(.sans(13.5, .semibold)).foregroundStyle(t.ink) + Spacer(minLength: 8) + TextField("", 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)) + .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( + publicOrigin: remoteOriginText, + sshHost: remoteSSHHostText, + sshPort: Int(remoteSSHPortText) ?? 0, + identityPath: remoteIdentityText + )) + } } diff --git a/Sources/LocalShare/ShareScreen.swift b/Sources/LocalShare/ShareScreen.swift index 017146d..35f3b83 100644 --- a/Sources/LocalShare/ShareScreen.swift +++ b/Sources/LocalShare/ShareScreen.swift @@ -23,6 +23,7 @@ struct ShareScreen: View { } content: { VStack(spacing: 16) { ticket(ps) + if state.isRunning { remoteCard } // 文本与文件共存:在票据下补一张「附带文本」小卡(预览 + 编辑)。纯文本分享则文本就是票据本身。 if state.hasText && !state.isTextOnly { attachedTextCard } if !state.received.isEmpty { receivedCard } @@ -49,6 +50,74 @@ struct ShareScreen: View { } } + private var remoteCard: some View { + VStack(alignment: .leading, spacing: 10) { + HStack(spacing: 10) { + Image(systemName: "globe").font(.system(size: 17, weight: .medium)).foregroundStyle(t.accent) + VStack(alignment: .leading, spacing: 2) { + Text(L.remoteAccess(state.lang)).font(.sans(13.5, .semibold)).foregroundStyle(t.ink) + Text(L.remoteAccessDesc(state.lang)).font(.sans(11.5)).foregroundStyle(t.inkMute) + } + Spacer(minLength: 8) + ToggleSwitch(t: t, isOn: state.remoteAccessEnabled, + locked: !state.remoteAccessEnabled && !state.canEnableRemote) { + state.setRemoteAccessEnabled(!state.remoteAccessEnabled) + } + } + if state.remoteAccessEnabled { + if let url = state.remoteURL { + CopyPill(t: t, lang: state.lang, value: url, compact: true) { + if let url = URL(string: url) { NSWorkspace.shared.open(url) } + } + } + HStack(spacing: 7) { + Circle().fill(remoteStatusColor).frame(width: 7, height: 7) + Text(remoteStatusText).font(.sans(11.5, .semibold)).foregroundStyle(t.inkMute) + Spacer() + if state.remoteStatus == .offline { + GhostButton(t: t, title: L.remoteRetry(state.lang), systemImage: "arrow.clockwise") { + state.retryRemoteAccess() + } + } + GhostButton(t: t, title: L.remoteStop(state.lang)) { + state.setRemoteAccessEnabled(false) + } + } + if let expires = state.remoteExpiresAt { + Text(LStr.remoteExpiry(expires, state.lang)).font(.mono(10.5)).foregroundStyle(t.inkFaint) + } + if let error = state.remoteError, !error.isEmpty { + Text(error).font(.sans(11)).foregroundStyle(t.danger).lineLimit(2) + } + } else { + Text(state.remoteError ?? (state.remoteSettings.isValid ? L.remoteAccessDesc(state.lang) : L.remoteConfigHint(state.lang))) + .font(.sans(11.5)).foregroundStyle(state.remoteError == nil ? t.inkMute : t.danger) + .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 .online: return L.remoteOnline(state.lang) + case .offline: return L.remoteOffline(state.lang) + case .disabled: return L.remoteConfigHint(state.lang) + } + } + + private var remoteStatusColor: Color { + switch state.remoteStatus { + case .online: return t.ok + case .offline: return t.danger + case .connecting: return t.warn + case .disabled: return t.inkFaint + } + } + // 纯文本分享的存根:文本图标 + 「正在分享文本」+ 字数 + 前几行预览。 private func textStub(_ ps: PermSummary) -> some View { let lang = state.lang 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 only; no tunnel, public endpoint, or account | +| Network | Same Wi-Fi / LAN by default; optional user-owned Nginx + frp SSH gateway; no receiver app, VPN, or account | | Stack | Swift / SwiftUI, system frameworks only, no external package dylib risk | | HTTP server | Swifter from SPM source, primarily read-only static serving | | 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. Changing or stopping a share rotates the token immediately, invalidating old links, cookies, and QR codes. Anyone guessing `IP:port` receives 403 | -| 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 LAN IP selected from network candidates; generated with CoreImage `CIQRCodeGenerator`; no third-party QR dependency. Window also shows a `.local` fallback and copyable URL | +| Protocol | LAN uses plain HTTP; remote mode uses the configured HTTPS origin terminated by the user's Nginx. A 128-bit token blocks guessed addresses, rotates on share boundaries, and expires remote access after one hour | +| QR code | Uses the public origin while remote mode is active, otherwise the selected LAN IP; generated with CoreImage `CIQRCodeGenerator`; no third-party QR dependency. Window keeps the LAN `.local` fallback | | 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, server, and an enabled tunnel continue running, and menu bar activation restores the previous screen. Ports are selected automatically; service and tunnel stop 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 | @@ -67,6 +67,7 @@ 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 + RemoteTunnel.swift # HTTPS-origin settings and direct /usr/bin/ssh reverse tunnel NetworkInfo.swift # getifaddrs -> private IPv4 candidates and .local hostname QRCode.swift # CoreImage QR -> NSImage, plus terminal ANSI output Token.swift / Mime.swift # Random token and extension -> MIME mapping with charset=utf-8 for text @@ -77,7 +78,7 @@ 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 ``` `@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. @@ -98,6 +99,7 @@ LocalShare/ 2. **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. 3. **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. 4. **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. +5. **Remote policy**: while remote mode is enabled, only `GET` and `HEAD` are accepted, upload and text-receive routes disappear, and `ls_token` receives `Secure` only for a request from the trusted local tunnel with `X-Forwarded-Proto: https`. `X-Forwarded-For` is used for presence only in that same case. Single-folder serving extracts steps 2-4 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. @@ -123,11 +125,18 @@ After auth, requests record `lastSeen` by client IP. A 45-second window counts a When `FileServer.listenAddress` is non-nil, Swifter binds only that IPv4 address through `listenAddressIPv4`. `AppState.bindSelectedOnly` drives it. Changing interface or switch state rebinds without rotating the token. If the selected IP disappears, the app falls back to all interfaces. Invalid `inet_pton` input throws instead of silently binding all interfaces. Default `nil` means `0.0.0.0`. +### Remote Sharing + +Remote settings persist only the HTTPS origin, SSH host/port, and optional identity path. The switch is session-only and starts `/usr/bin/ssh` directly with `BatchMode`, `ExitOnForwardFailure`, keepalives, and `StrictHostKeyChecking=yes`; the system SSH agent, config, and `known_hosts` remain the trust store. The tunnel forwards the actual LocalShare listen address, so current-network-only binding remains valid. + +`RemoteTunnel` reports connecting/online/offline state and retries with a 1–60 second capped backoff. Changing share content only updates the locked `FileServer` state and rotates the token; stopping the share, disabling remote mode, app termination, or the one-hour expiry stops the tunnel. Expiry also rotates the token while leaving the LAN share available. + ### 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 public URL/QR replace the LAN primary URL only while the switch is enabled. A disconnected tunnel retries without changing the expiry deadline. - 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. @@ -189,7 +198,7 @@ LS_HEADLESS=1 LS_FOLDER=/path/to/dir LS_TOKEN=testtoken LS_PORT=8099 .build/debu 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=`, 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=`, 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. @@ -201,7 +210,7 @@ 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. -- Cross-network tunneling such as cloudflared, ngrok, or Tailscale. +- Vendor tunnel clients such as cloudflared, ngrok, or Tailscale; the first remote mode uses the user-owned Nginx/frp gateway and system SSH only. - **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:** diff --git a/docs/REMOTE_SHARING_PLAN.md b/docs/REMOTE_SHARING_PLAN.md index 054ce94..7bc78ff 100644 --- a/docs/REMOTE_SHARING_PLAN.md +++ b/docs/REMOTE_SHARING_PLAN.md @@ -1,6 +1,6 @@ # Remote Sharing Plan -> Status: proposed. This document records the architecture decision and implementation plan; LocalShare remains LAN-only until the plan is implemented. +> Status: app implementation complete. Public-server deployment and end-to-end internet acceptance remain environment-specific. ## Goal diff --git a/tools/smoke-remote-readonly.sh b/tools/smoke-remote-readonly.sh new file mode 100755 index 0000000..dc25611 --- /dev/null +++ b/tools/smoke-remote-readonly.sh @@ -0,0 +1,44 @@ +#!/usr/bin/env bash +# 防回归冒烟测:远程模式只读、拒绝 POST、并只在受信任 HTTPS 转发请求上使用 Secure cookie。 +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_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" +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" + +echo "── 仅受信任 HTTPS 转发请求的 cookie 带 Secure" +H=$(curl -s -D - -o /dev/null -H 'Accept: text/html' \ + -H 'X-Forwarded-Proto: https' -H 'X-Forwarded-For: 203.0.113.4' "$base/?t=$TOK") +echo "$H" | grep -qi '^Set-Cookie:.*Secure' && ok "Secure cookie" || bad "缺 Secure cookie" + +echo +echo "结果:PASS=$PASS FAIL=$FAIL" +[ "$FAIL" -eq 0 ] From c59f07377f247af076bfb48fd21570b88d2829a2 Mon Sep 17 00:00:00 2001 From: shawn Date: Tue, 11 Aug 2026 23:59:12 +0800 Subject: [PATCH 3/8] feat: add self-hosted remote sharing --- .github/workflows/ci.yml | 11 + README.md | 4 +- README_CN.md | 4 +- Sources/LocalShare/AppState.swift | 160 ++-- Sources/LocalShare/FileServer.swift | 116 +-- Sources/LocalShare/HeadlessServer.swift | 2 +- Sources/LocalShare/Lang.swift | 45 +- Sources/LocalShare/RemoteAgent.swift | 511 ++++++++++++ Sources/LocalShare/RemoteTunnel.swift | 198 ----- Sources/LocalShare/SettingsScreen.swift | 39 +- Sources/LocalShare/ShareScreen.swift | 58 +- .../LocalShareTests/RemoteSharingTests.swift | 73 +- docs/ARCHITECTURE.md | 23 +- docs/REMOTE_SHARING_PLAN.md | 284 +++---- server/README.md | 185 +++++ server/go.mod | 5 + server/go.sum | 2 + server/main.go | 784 ++++++++++++++++++ server/main_test.go | 176 ++++ tools/smoke-remote-readonly.sh | 11 +- 20 files changed, 2046 insertions(+), 645 deletions(-) create mode 100644 Sources/LocalShare/RemoteAgent.swift delete mode 100644 Sources/LocalShare/RemoteTunnel.swift create mode 100644 server/README.md create mode 100644 server/go.mod create mode 100644 server/go.sum create mode 100644 server/main.go create mode 100644 server/main_test.go diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c531072..2f81ab6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -15,6 +15,17 @@ on: pull_request: jobs: + server: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version: '1.23' + - name: 测试 LocalShare Server + working-directory: server + run: go test ./... + smoke: runs-on: macos-latest # 与 release 同款 runner,自带 Xcode / Swift 工具链与 Node steps: diff --git a/README.md b/README.md index 2b987a4..0d65a3d 100644 --- a/README.md +++ b/README.md @@ -22,7 +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 remote access through your own HTTPS origin and frp SSH gateway; remote shares are read-only and expire after one hour +- Optional browser-only remote access through a self-hosted LocalShare Server; remote shares are read-only ## Why this app exists @@ -43,7 +43,7 @@ The QR code points to something like `http://192.168.x.x:8080/?t=` ### Remote browser sharing -Configure your HTTPS origin, relay SSH host, port, and optional identity in Settings, then enable Remote Access on an active share. This requires the user-owned Nginx + frp SSH gateway described in [the remote-sharing plan](docs/REMOTE_SHARING_PLAN.md); the receiving device only needs a browser. The relay is inside the trust boundary, and remote access is read-only with a one-hour lifetime. +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 diff --git a/README_CN.md b/README_CN.md index e943649..0345907 100644 --- a/README_CN.md +++ b/README_CN.md @@ -22,7 +22,7 @@ - 支持分享后,由对端反向传输文件、文本 - 显示当前在线访客 - 命令行 `localshare` 命令:唤起窗口分享,或 `--headless` 在终端显示链接和二维码 -- 可选通过自有 HTTPS 地址和 frp SSH 网关远程访问;远程分享只读,默认一小时后失效 +- 可选通过自托管 LocalShare Server 远程访问浏览器;远程分享只读 ## 为什么有这个 app @@ -43,7 +43,7 @@ ### 远程浏览器分享 -在「设置」里填写 HTTPS 公共地址、转发服务器 SSH 主机/端口和可选私钥路径,然后在正在分享的票据上打开「远程访问」。这需要按[远程分享计划](docs/REMOTE_SHARING_PLAN.md)部署自有 Nginx + frp SSH 网关,对端设备只需浏览器;转发服务器属于信任边界,远程访问只读,默认一小时后失效。 +运行自包含的 [`localshare-server`](server/README.md),生成一次性 Enrollment Key,在「设置」里填写 Server 地址和 key。配对后设备凭据会保存到 Keychain;正在分享时只需点击 Connect/Disconnect,对端设备只需浏览器。详见[远程分享计划](docs/REMOTE_SHARING_PLAN.md)。 ## 终端用法 diff --git a/Sources/LocalShare/AppState.swift b/Sources/LocalShare/AppState.swift index f2cedc3..51c2bd0 100644 --- a/Sources/LocalShare/AppState.swift +++ b/Sources/LocalShare/AppState.swift @@ -47,9 +47,11 @@ final class AppState: ObservableObject { @Published var persistReceivedText = false // 「记住收到的文本」(默认关,持久化) @Published var cliStatus: CLIInstaller.Status = .notInstalled // 命令行工具安装状态 @Published var remoteSettings = RemoteSettings() + @Published var remoteEnrollmentKey = "" // 仅用于首次配对;成功后不落盘 @Published private(set) var remoteAccessEnabled = false - @Published private(set) var remoteStatus: RemoteTunnel.Status = .disabled - @Published private(set) var remoteExpiresAt: Date? + @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 事件回调经它触达状态。 @@ -60,8 +62,7 @@ final class AppState: ObservableObject { @Published private(set) var token = Token.generate() private var server: FileServer? private var viewerTimer: Timer? - private let remoteTunnel = RemoteTunnel() - private var remoteExpiryTimer: Timer? + private let remoteAgent = RemoteAgent() private var networkMonitor: NWPathMonitor? private var networkPathStatus: NWPath.Status? @@ -76,27 +77,25 @@ final class AppState: ObservableObject { private let textInboxKey = "textInboxEnabled" // 收件箱闸门(默认关) private let persistReceivedKey = "persistReceivedText" // 是否记住收到的文本(默认关) private let receivedTextsKey = "receivedTexts" // 收件箱内容(仅 persistReceivedText 开时写入) - private let remoteOriginKey = "remotePublicOrigin" - private let remoteSSHHostKey = "remoteSSHHost" - private let remoteSSHPortKey = "remoteSSHPort" - private let remoteIdentityPathKey = "remoteSSHIdentityPath" - private static let remoteLifetime: TimeInterval = 60 * 60 + private let remoteServerKey = "remoteServerAddress" // 配置端口优先,其余作回退(8080 列入回退以防配置端口占用)。 private let fallbackPorts: [in_port_t] = [8000, 8888, 9000, 8080] init() { let savedPort = UserDefaults.standard.integer(forKey: portKey) if (1024...65535).contains(savedPort) { configuredPort = in_port_t(savedPort) } - let savedRemotePort = UserDefaults.standard.integer(forKey: remoteSSHPortKey) - remoteSettings = RemoteSettings( - publicOrigin: UserDefaults.standard.string(forKey: remoteOriginKey) ?? "", - sshHost: UserDefaults.standard.string(forKey: remoteSSHHostKey) ?? "", - sshPort: (1...65535).contains(savedRemotePort) ? savedRemotePort : 2200, - identityPath: UserDefaults.standard.string(forKey: remoteIdentityPathKey) ?? "" - ) - remoteTunnel.onStateChange = { [weak self] status, error in + remoteSettings = RemoteSettings(serverAddress: UserDefaults.standard.string(forKey: remoteServerKey) ?? "") + remotePaired = remoteAgent.isPaired(for: remoteSettings.serverURL) + remoteAgent.onStateChange = { [weak self] status, error in self?.remoteStatus = status self?.remoteError = error + if status == .connected { + self?.remotePaired = self?.remoteAgent.isPaired ?? false + self?.remoteEnrollmentKey = "" + } + } + remoteAgent.onShareURLChange = { [weak self] url in + self?.remoteShareBaseURL = url } bindSelectedOnly = UserDefaults.standard.bool(forKey: bindSelectedOnlyKey) // 未写入默认 false if let a = UserDefaults.standard.string(forKey: appearanceKey).flatMap(AppearancePref.init) { appearance = a } @@ -153,8 +152,8 @@ final class AppState: ObservableObject { // MARK: - 派生 URL / 二维码 // 文件夹/多选模式 → 根地址;单文件模式 → 直链该文件(路径仅供浏览器显示文件名/扩展名)。 - private func makeURL(base: 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——这一页既显示电脑共享的文本(可读可复制), // 又在「允许手机发回来」开着时挂出发送框;只收文本时它退化成纯发送页。扫一次,双向都在这。 @@ -168,8 +167,8 @@ final class AppState: ObservableObject { } var remoteURL: String? { - guard remoteAccessEnabled, isRunning, let origin = remoteSettings.publicURL else { return nil } - return makeURL(base: origin.absoluteString) + guard remoteAccessEnabled, isRunning, let base = remoteShareBaseURL else { return nil } + return makeURL(base: base, authenticated: false) } var primaryURL: String? { @@ -199,103 +198,64 @@ final class AppState: ObservableObject { selectedInterface = interfaces.first } - // Remote access is session-only. Connection details persist, but the public - // switch never does: reopening the app cannot silently publish a share. var canEnableRemote: Bool { isRunning && !isEmpty && remoteSettings.isValid } - func saveRemoteSettings(_ settings: RemoteSettings) { + func saveRemoteSettings(_ settings: RemoteSettings, enrollmentKey: String = "") { + if remoteAccessEnabled { disconnectRemote() } remoteSettings = settings - UserDefaults.standard.set(settings.publicOrigin, forKey: remoteOriginKey) - UserDefaults.standard.set(settings.sshHost, forKey: remoteSSHHostKey) - UserDefaults.standard.set(settings.sshPort, forKey: remoteSSHPortKey) - UserDefaults.standard.set(settings.identityPath, forKey: remoteIdentityPathKey) - guard remoteAccessEnabled else { return } - guard settings.isValid else { - disableRemoteAccess() - remoteError = L.remoteConfigInvalid(lang) - return - } - startRemoteTunnel() + remoteEnrollmentKey = enrollmentKey + UserDefaults.standard.set(settings.serverAddress, forKey: remoteServerKey) + remotePaired = remoteAgent.isPaired(for: remoteSettings.serverURL) } - func setRemoteAccessEnabled(_ on: Bool) { - guard on != remoteAccessEnabled else { return } - if on { - guard canEnableRemote else { - remoteError = L.remoteConfigHint(lang) - return - } - permission.add = false - textInboxEnabled = false - UserDefaults.standard.set(false, forKey: textInboxKey) - remoteAccessEnabled = true - remoteError = nil - remoteExpiresAt = Date().addingTimeInterval(Self.remoteLifetime) - scheduleRemoteExpiry() - applyRemotePolicy() - pushToServer() - startRemoteTunnel() - } else { - disableRemoteAccess() + func connectRemote(enrollmentKey: String) { + guard canEnableRemote, let serverURL = remoteSettings.serverURL else { + remoteError = L.remoteConfigHint(lang) + return } - } - - func retryRemoteAccess() { - guard remoteAccessEnabled else { return } + permission.add = false + textInboxEnabled = false + UserDefaults.standard.set(false, forKey: textInboxKey) + remoteAccessEnabled = true remoteError = nil - startRemoteTunnel() + applyRemotePolicy() + remoteAgent.connect(serverURL: serverURL, enrollmentKey: enrollmentKey, + localBaseURL: localShareBaseURL, localToken: token, + deviceName: Host.current().localizedName ?? "Mac") } - private func disableRemoteAccess(rotateToken: Bool = false) { - remoteTunnel.stop() - remoteExpiryTimer?.invalidate() - remoteExpiryTimer = nil + func disconnectRemote() { + remoteAgent.disconnect() remoteAccessEnabled = false - remoteExpiresAt = nil - remoteError = nil - if rotateToken { token = Token.generate() } - if isRunning { pushToServer() } + remoteShareBaseURL = nil + remotePaired = remoteAgent.isPaired + applyRemotePolicy() } - private func expireRemoteAccess() { - guard remoteAccessEnabled, let expires = remoteExpiresAt, expires <= Date() else { return } - disableRemoteAccess(rotateToken: true) - remoteError = L.remoteExpired(lang) + func forgetRemoteDevice() { + disconnectRemote() + remoteAgent.forgetDevice() + remotePaired = false } - private func scheduleRemoteExpiry() { - remoteExpiryTimer?.invalidate() - remoteExpiryTimer = Timer.scheduledTimer(withTimeInterval: Self.remoteLifetime, repeats: false) { [weak self] _ in - Task { @MainActor [weak self] in self?.expireRemoteAccess() } - } - } - - private var tunnelLocalAddress: String { + private var agentLocalAddress: String { bindSelectedOnly ? (selectedInterface?.ip ?? "127.0.0.1") : "127.0.0.1" } - private var tunnelProxyAddresses: Set { - var addresses: Set = ["127.0.0.1", "::1"] - if let selected = selectedInterface?.ip { addresses.insert(selected) } - return addresses + private var localShareBaseURL: URL { + URL(string: "http://\(agentLocalAddress):\(port)")! } private func applyRemotePolicy() { server?.remoteAccessEnabled = remoteAccessEnabled - server?.trustedProxyAddresses = tunnelProxyAddresses server?.uploadEnabled = permission.add && canToggleUpload && !remoteAccessEnabled server?.textInboxEnabled = textInboxEnabled && !remoteAccessEnabled } - private func startRemoteTunnel() { - guard remoteAccessEnabled, isRunning else { return } - guard let configuration = remoteSettings.tunnelConfiguration(localAddress: tunnelLocalAddress, localPort: port) else { - remoteStatus = .offline - remoteError = L.remoteConfigInvalid(lang) - return - } - applyRemotePolicy() - remoteTunnel.start(configuration) + 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") } private func startNetworkMonitoring() { @@ -315,7 +275,7 @@ final class AppState: ObservableObject { if self.bindSelectedOnly && oldInterfaces != self.interfaces { self.rebindServer() } else if statusChanged || oldInterfaces != self.interfaces { - self.startRemoteTunnel() + self.startRemoteAgent() } } } @@ -362,6 +322,9 @@ final class AppState: ObservableObject { server?.sharedText = hasText ? sharedText : nil server?.share = currentShare applyRemotePolicy() + if remoteAccessEnabled, isRunning { + remoteAgent.updateLocalShare(baseURL: localShareBaseURL, token: token) + } } // 分享 / 更新一段文本(Mac→手机)。token 的「会话」维度与分享文件一致:只在会话边界轮换 @@ -549,7 +512,6 @@ final class AppState: ObservableObject { let fs = FileServer(share: currentShare, token: token) fs.sharedText = hasText ? sharedText : nil fs.remoteAccessEnabled = remoteAccessEnabled - fs.trustedProxyAddresses = tunnelProxyAddresses fs.uploadEnabled = permission.add && canToggleUpload && !remoteAccessEnabled fs.textInboxEnabled = textInboxEnabled && !remoteAccessEnabled fs.onUpload = { url in // socket 线程 → 主线程 @@ -568,7 +530,7 @@ final class AppState: ObservableObject { isRunning = true lastError = nil startViewerPolling() - if remoteAccessEnabled { startRemoteTunnel() } + if remoteAccessEnabled { startRemoteAgent() } } catch { // 绑定指定网卡失败(IP 刚因 DHCP/切网消失):退回全接口重试一次,给提示但不让分享中断。 if bindIP != nil { @@ -579,7 +541,7 @@ final class AppState: ObservableObject { isRunning = true lastError = LStr.ifaceUnavailable(lang) startViewerPolling() - if remoteAccessEnabled { startRemoteTunnel() } + if remoteAccessEnabled { startRemoteAgent() } return } } @@ -593,7 +555,7 @@ final class AppState: ObservableObject { private func rebindServer() { guard isRunning else { return } viewerTimer?.invalidate(); viewerTimer = nil - if remoteAccessEnabled { remoteTunnel.stop() } + if remoteAccessEnabled { remoteAgent.disconnect() } server?.stop() server = nil start() // 复用 self.token,仅监听地址/端口随当前选择重算 @@ -614,7 +576,7 @@ final class AppState: ObservableObject { } func stop() { - disableRemoteAccess() + disconnectRemote() viewerTimer?.invalidate() viewerTimer = nil viewerCount = 0 diff --git a/Sources/LocalShare/FileServer.swift b/Sources/LocalShare/FileServer.swift index 13287ce..687c426 100644 --- a/Sources/LocalShare/FileServer.swift +++ b/Sources/LocalShare/FileServer.swift @@ -95,20 +95,13 @@ final class FileServer { var onReceiveText: ((ReceivedText) -> Void)? // Remote mode is deliberately a server-wide read-only gate: the same origin is - // reachable on LAN and through the tunnel, so allowing POST locally would also - // expose it publicly. The proxy address allowlist limits forwarded-header trust - // to the local SSH connection rather than any arbitrary LAN request. + // reachable on LAN and through the remote agent, so allowing POST locally would + // also expose it remotely. private var _remoteAccessEnabled = false var remoteAccessEnabled: Bool { get { lock.lock(); defer { lock.unlock() }; return _remoteAccessEnabled } set { lock.lock(); _remoteAccessEnabled = newValue; lock.unlock() } } - private var _trustedProxyAddresses: Set = ["127.0.0.1", "::1"] - var trustedProxyAddresses: Set { - get { lock.lock(); defer { lock.unlock() }; return _trustedProxyAddresses } - set { lock.lock(); _trustedProxyAddresses = newValue; lock.unlock() } - } - // 单条文本上限(远小于上传的 500MB)。Swifter 进 middleware 前已把 body 整段读进内存,故同上传只能 // 事后拒绝;总数上限(收件箱满挤旧)由 AppState 把关——两道一起才挡得住「刷一堆不超单条限的消息撑爆内存」。 static let textInboxLimit = 64 * 1024 @@ -234,28 +227,6 @@ final class FileServer { return String(String(String.UnicodeScalarView(kept)).trimmingCharacters(in: .whitespaces).prefix(40)) } - // Only accept forwarded identity from a request that arrived at a trusted local - // tunnel endpoint and carries the HTTPS marker added by Nginx. The first XFF - // value is the original browser address; malformed values fall back to req.address. - static func forwardedClientAddress(headers: [String: String], sourceAddress: String?, - remoteEnabled: Bool, trustedProxyAddresses: Set) -> String? { - guard remoteEnabled, - let sourceAddress, - trustedProxyAddresses.contains(sourceAddress), - headers["x-forwarded-proto"]?.lowercased() == "https", - let raw = headers["x-forwarded-for"]?.split(separator: ",").first else { return nil } - let address = raw.trimmingCharacters(in: .whitespaces) - guard isIPAddress(address) else { return nil } - return address - } - - private static func isIPAddress(_ value: String) -> Bool { - var v4 = in_addr() - if inet_pton(AF_INET, value, &v4) == 1 { return true } - var v6 = in6_addr() - return inet_pton(AF_INET6, value, &v6) == 1 - } - init(share: Share, token: String) { self._share = share self._token = token @@ -302,13 +273,7 @@ final class FileServer { // 保证鉴权判断与下面 Set-Cookie 写的是同一把钥匙,轮换瞬间也不串) let token = self.token let remoteMode = remoteAccessEnabled - let forwardedAddress = Self.forwardedClientAddress( - headers: req.headers, - sourceAddress: req.address, - remoteEnabled: remoteMode, - trustedProxyAddresses: trustedProxyAddresses - ) - let clientAddress = forwardedAddress ?? req.address + let clientAddress = req.address // 网页语言逐请求决定:按浏览器 Accept-Language,与原生 app 设置无关。往下穿进每个 HTML 生产者。 let lang = Lang.fromAcceptLanguage(req.headers["accept-language"]) // 收件箱开启:listing 页(同上传表单的出现条件)多挂一张「发文本给电脑」表单。每请求取一次快照。 @@ -325,8 +290,7 @@ final class FileServer { // 不设 Max-Age:随浏览器会话走;token 轮换后旧值反正立即失效。页面 JS 不读它,HttpOnly。 var extra: [String: String] = [:] if viaQuery, !viaCookie { - let secure = forwardedAddress != nil ? "; Secure" : "" - extra["Set-Cookie"] = "ls_token=\(token); Path=/; SameSite=Lax; HttpOnly\(secure)" + extra["Set-Cookie"] = "ls_token=\(token); Path=/; SameSite=Lax; HttpOnly" } // 鉴权通过的每个请求都刷新该 IP 的活跃时间(含心跳与下载中的请求)。 @@ -364,6 +328,7 @@ final class FileServer { // 壳页自身的取文 fetch(Accept */*)拿原始文件;?raw=1 为显式逃生门(页角「查看原文」)。 let rawRequested = req.queryParams.contains { $0.0 == "raw" && $0.1 == "1" } let wantsViewer = !rawRequested && (req.headers["accept"] ?? "").contains("text/html") + let rangeHeader = req.headers["range"] // 分享文本端点(保留路径,先于分享内容路由;与 /ls/ping 同款):导航发文本预览壳页, // ?raw=1 / curl(Accept */*)发 text/plain 原文。无分享文本时 404。token 清洗的 302 已在上面处理, @@ -421,7 +386,8 @@ final class FileServer { guard FileManager.default.fileExists(atPath: fileURL.path) else { return htmlResponse(404, "Not Found", Self.notFoundPage(lang), extra: extra) } - return contentResponse(fileURL, viewer: wantsViewer, crumbs: nil, canUpload: false, canReceiveText: recvOn, lang: lang, extra: extra) + return contentResponse(fileURL, viewer: wantsViewer, crumbs: nil, canUpload: false, canReceiveText: recvOn, + lang: lang, extra: extra, range: rangeHeader) } // 多选模式:虚拟根列出选中项;首段 key 映射到对应真实项后落地(目录项再走子树服务)。 @@ -447,11 +413,13 @@ final class FileServer { return htmlResponse(404, "Not Found", Self.notFoundPage(lang), extra: extra) } let crumbs = DirectoryListing.breadcrumb(requestPath: decodedPath, rootName: rootName) - return contentResponse(item.url, viewer: wantsViewer, crumbs: crumbs, canUpload: false, canReceiveText: recvOn, lang: lang, extra: extra) + return contentResponse(item.url, viewer: wantsViewer, crumbs: crumbs, canUpload: false, canReceiveText: recvOn, + lang: lang, extra: extra, range: rangeHeader) } return serveTree(rootURL: item.url, relPath: rest, encodedPath: req.path, decodedPath: decodedPath, rootName: rootName, - canUpload: false, canReceiveText: recvOn, viewer: wantsViewer, lang: lang, extra: extra) + canUpload: false, canReceiveText: recvOn, viewer: wantsViewer, lang: lang, extra: extra, + range: rangeHeader) } guard case .directory(let rootURL) = share else { return .internalServerError } @@ -459,17 +427,18 @@ final class FileServer { return serveTree(rootURL: rootURL, relPath: rel, encodedPath: req.path, decodedPath: decodedPath, rootName: rootURL.lastPathComponent, canUpload: uploadEnabled && !remoteMode, canReceiveText: recvOn, - viewer: wantsViewer, lang: lang, extra: extra) + viewer: wantsViewer, lang: lang, extra: extra, range: rangeHeader) } // 可预览类型(md/json/csv)且浏览器导航 → 预览壳页(与文件同 URL,相对引用天然成立); // 其余发文件本体。新增预览类型只需在此登记,壳页骨架见 PreviewPage。 private func contentResponse(_ url: URL, viewer: Bool, crumbs: String?, - canUpload: Bool, canReceiveText: Bool, lang: Lang, extra: [String: String]) -> HttpResponse { + canUpload: Bool, canReceiveText: Bool, lang: Lang, extra: [String: String], + range: String? = nil) -> HttpResponse { if viewer, let html = Self.previewHTML(url, crumbs: crumbs, canUpload: canUpload, canReceiveText: canReceiveText, lang: lang) { return htmlResponse(200, "OK", html, extra: extra) } - return fileResponse(url, lang: lang, extra: extra) + return fileResponse(url, lang: lang, extra: extra, range: range) } private static func previewHTML(_ url: URL, crumbs: String?, canUpload: Bool, canReceiveText: Bool, lang: Lang) -> String? { @@ -505,7 +474,8 @@ final class FileServer { // 单根目录与多选里的每个目录项共用此函数(多选时 rootURL=项目本身、relPath=去掉 key 段后的剩余)。 private func serveTree(rootURL: URL, relPath: String, encodedPath: String, decodedPath: String, rootName: String, canUpload: Bool, - canReceiveText: Bool, viewer: Bool, lang: Lang, extra: [String: String]) -> HttpResponse { + canReceiveText: Bool, viewer: Bool, lang: Lang, extra: [String: String], + range: String? = nil) -> HttpResponse { // 防目录穿越:判据抽进 resolveWithinRoot,与 handleUpload 共用一份,避免两处漂移。 guard let target = Self.resolveWithinRoot(rootURL, relPath: relPath) else { return htmlResponse(403, "Forbidden", Self.forbiddenPage(lang)) @@ -527,7 +497,7 @@ final class FileServer { } let indexURL = target.appendingPathComponent("index.html") if fm.fileExists(atPath: indexURL.path) { - return fileResponse(indexURL, lang: lang, extra: extra) + return fileResponse(indexURL, lang: lang, extra: extra, range: range) } let html = DirectoryListing.html(directory: target, requestPath: decodedPath, rootName: rootName, canUpload: canUpload, @@ -536,7 +506,8 @@ final class FileServer { } let crumbs = DirectoryListing.breadcrumb(requestPath: decodedPath, rootName: rootName) - return contentResponse(target, viewer: viewer, crumbs: crumbs, canUpload: canUpload, canReceiveText: canReceiveText, lang: lang, extra: extra) + return contentResponse(target, viewer: viewer, crumbs: crumbs, canUpload: canUpload, canReceiveText: canReceiveText, + lang: lang, extra: extra, range: range) } // MARK: - 收文本处理 @@ -689,29 +660,64 @@ final class FileServer { return .raw(code, reason, headers) { writer in try? writer.write(body) } } - private func fileResponse(_ url: URL, lang: Lang, extra: [String: String]) -> HttpResponse { + private func fileResponse(_ url: URL, lang: Lang, extra: [String: String], range: String? = nil) -> HttpResponse { var headers = extra headers["Content-Type"] = Mime.contentType(forExtension: url.pathExtension) // 关掉浏览器的 MIME 猜测兜底:一律按上面声明的类型处理。已正确声明类型的文件照常内联显示, // 未知类型本就回退 octet-stream 下载——nosniff 只是确保它不会被某些浏览器猜成 HTML 执行。 headers["X-Content-Type-Options"] = "nosniff" - if let size = try? url.resourceValues(forKeys: [.fileSizeKey]).fileSize { - headers["Content-Length"] = String(size) + let size = Int64((try? url.resourceValues(forKeys: [.fileSizeKey]).fileSize) ?? 0) + headers["Accept-Ranges"] = "bytes" + var status = 200 + var start: Int64 = 0 + var length = size + if let range, !range.isEmpty { + guard let selected = Self.byteRange(range, size: size) else { + headers["Content-Range"] = "bytes */\(size)" + headers["Content-Length"] = "0" + return .raw(416, "Range Not Satisfiable", headers, nil) + } + status = 206 + start = selected.start + length = selected.end - selected.start + 1 + headers["Content-Range"] = "bytes \(selected.start)-\(selected.end)/\(size)" } - return .raw(200, "OK", headers) { writer in + headers["Content-Length"] = String(length) + return .raw(status, status == 206 ? "Partial Content" : "OK", headers) { writer in guard let handle = try? FileHandle(forReadingFrom: url) else { try? writer.write(Data(L.webReadFailed(lang).utf8)) return } defer { try? handle.close() } - while true { - let chunk = handle.readData(ofLength: 64 * 1024) + if start > 0 { try? handle.seek(toOffset: UInt64(start)) } + var remaining = length + while remaining > 0 { + let chunk = handle.readData(ofLength: Int(min(Int64(64 * 1024), remaining))) if chunk.isEmpty { break } try writer.write(chunk) + remaining -= Int64(chunk.count) } } } + static func byteRange(_ header: String, size: Int64) -> (start: Int64, end: Int64)? { + guard size > 0, + header.lowercased().hasPrefix("bytes="), + let value = header.dropFirst(6).split(separator: ",", omittingEmptySubsequences: false).first, + !header.dropFirst(6).contains(","), + let dash = value.firstIndex(of: "-") else { return nil } + let first = value[.. 0 else { return nil } + return (max(0, size - suffix), size - 1) + } + guard let start = Int64(first), start >= 0, start < size else { return nil } + let end = last.isEmpty ? size - 1 : (Int64(last) ?? -1) + guard end >= start else { return nil } + return (start, min(end, size - 1)) + } + // 分享文本的原文响应(?raw=1 / curl):text/plain;带 Content-Length 让客户端显示进度、知道何时收完。 private func plainTextResponse(_ text: String, extra: [String: String]) -> HttpResponse { var headers = extra diff --git a/Sources/LocalShare/HeadlessServer.swift b/Sources/LocalShare/HeadlessServer.swift index 5f1dd24..4db486e 100644 --- a/Sources/LocalShare/HeadlessServer.swift +++ b/Sources/LocalShare/HeadlessServer.swift @@ -11,7 +11,7 @@ import Foundation // LS_TEXT 分享一段文本(可单独,也可与 LS_FOLDER(S) 共存);纯文本时 URL 直指 /ls/text // LS_RECV 置 1 开启收文本(收件箱);无任何分享内容时 URL 直指 /ls/text(收发合一,退化成纯发送页) // LS_RECV_LOG 收到文本时把原文追加进该文件(以 0x01 分隔),供冒烟测回读校验 -// LS_REMOTE 置 1 模拟远程只读策略(仅用于本地回归,不启动 SSH 隧道) +// LS_REMOTE 置 1 模拟远程只读策略(仅用于本地回归,不启动远程 Agent) enum HeadlessServer { static func run() { let env = ProcessInfo.processInfo.environment diff --git a/Sources/LocalShare/Lang.swift b/Sources/LocalShare/Lang.swift index 734a249..4c0cb45 100644 --- a/Sources/LocalShare/Lang.swift +++ b/Sources/LocalShare/Lang.swift @@ -104,8 +104,8 @@ enum L: CaseIterable { case bindOnlyTitle, bindOnlyDescMulti, bindOnlyDescSingle // 设置 —— 远程访问 - case remotePublicOrigin, remoteSSHHost, remoteSSHPort, remoteIdentityPath, remoteSave - case remoteHTTPSHint, remoteHostKeyHint + case remoteServerAddress, remoteEnrollmentKey, remoteSave, remoteConnect, remoteDisconnect, remoteForget + case remotePairHint // 设置 —— 端口校验(静态部分;被占用一项带数字走 LStr) case portEmptyMsg, portNotNumberMsg, portTooLowMsg, portTooHighMsg @@ -116,9 +116,8 @@ enum L: CaseIterable { case permInfoWritable, permInfoReadonly, plaintextWarning // 远程访问 - case remoteAccess, remoteAccessDesc, remoteConfigHint, remoteConfigInvalid - case remoteConnecting, remoteOnline, remoteOffline, remoteRetry, remoteStop - case remoteExpired, remoteReadOnly + case remoteAccess, remoteAccessDesc, remoteConfigHint + case remoteConnecting, remoteOnline, remoteOffline, remoteReadOnly // 设置 —— 外观 case appearanceFollow, appearanceLight, appearanceDark @@ -270,15 +269,14 @@ enum L: CaseIterable { case .bindOnlyDescSingle: return ("只在当前网络开放,日后接入别的网络时也访问不到", "Open only on the current network; future networks won't reach it either") - case .remotePublicOrigin: return ("公共地址", "Public origin") - case .remoteSSHHost: return ("SSH 主机", "SSH host") - case .remoteSSHPort: return ("SSH 端口", "SSH port") - case .remoteIdentityPath: return ("SSH 私钥路径(可选)", "SSH identity path (optional)") - case .remoteSave: return ("保存远程设置", "Save remote settings") - case .remoteHTTPSHint: return ("需要 HTTPS 地址,例如 https://share.example.com", - "Use an HTTPS origin, for example https://share.example.com") - case .remoteHostKeyHint: return ("SSH 使用系统 known_hosts 严格校验;先确认服务器指纹已登记。", - "SSH uses the system known_hosts with strict verification; add the server fingerprint first.") + case .remoteServerAddress: return ("Server 地址", "Server address") + case .remoteEnrollmentKey: return ("配对 Key", "Enrollment key") + case .remoteSave: return ("保存", "Save") + case .remoteConnect: return ("Connect", "Connect") + case .remoteDisconnect: return ("Disconnect", "Disconnect") + case .remoteForget: return ("忘记此 Server", "Forget this server") + case .remotePairHint: return ("首次连接需要管理员生成的一次性 Key;配对后凭证会安全保存。", + "The first connection needs a one-time key from the server administrator; the device credential is saved after pairing.") case .portEmptyMsg: return ("请输入端口号", "Enter a port number") case .portNotNumberMsg: return ("端口需为数字", "Port must be a number") @@ -298,17 +296,13 @@ enum L: CaseIterable { "Traffic isn't encrypted on the LAN · on public Wi-Fi (cafés, airports) others on the network may see the content. Don't share sensitive files there.") case .remoteAccess: return ("远程访问", "Remote Access") - case .remoteAccessDesc: return ("通过你的 HTTPS 地址,让浏览器跨网络只读访问当前分享。", - "Let a browser read the current share through your HTTPS origin.") - case .remoteConfigHint: return ("先在设置中填写公共地址和 SSH 连接信息。", - "Add the public origin and SSH connection details in Settings first.") - case .remoteConfigInvalid: return ("远程设置不完整或无效。", "Remote settings are incomplete or invalid.") + case .remoteAccessDesc: return ("通过 LocalShare Server,让浏览器跨网络只读访问当前分享。", + "Let a browser read the current share through LocalShare Server.") + case .remoteConfigHint: return ("先在设置中填写 Server 地址和配对 Key。", + "Enter the Server address and enrollment key in Settings first.") case .remoteConnecting: return ("连接中", "Connecting") case .remoteOnline: return ("在线", "Online") case .remoteOffline: return ("已断开 · 将自动重试", "Offline · retrying") - case .remoteRetry: return ("重试", "Retry") - case .remoteStop: return ("停止远程访问", "Stop remote access") - case .remoteExpired: return ("远程分享已过期,旧链接已失效。", "Remote sharing expired; the old link is no longer valid.") case .remoteReadOnly: return ("远程访问为只读。", "Remote access is read-only.") case .appearanceFollow: return ("跟随系统", "System") @@ -470,13 +464,6 @@ enum L: CaseIterable { // 语序、复数、数字位置中英各异,故不入 key→value 表,由函数按 lang 拼装。 enum LStr { - static func remoteExpiry(_ date: Date, _ lang: Lang) -> String { - let formatter = DateFormatter() - formatter.locale = lang == .zh ? Locale(identifier: "zh_CN") : Locale(identifier: "en_US") - formatter.dateFormat = lang == .zh ? "M月d日 HH:mm 失效" : "expires MMM d, HH:mm" - return formatter.string(from: date) - } - // "3 项" / "3 items"(目录元信息、计数、describeShared 共用) static func itemCount(_ n: Int, _ lang: Lang) -> String { lang == .zh ? "\(n) 项" : "\(n) item\(n == 1 ? "" : "s")" diff --git a/Sources/LocalShare/RemoteAgent.swift b/Sources/LocalShare/RemoteAgent.swift new file mode 100644 index 0000000..782a17a --- /dev/null +++ b/Sources/LocalShare/RemoteAgent.swift @@ -0,0 +1,511 @@ +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 deviceID = "device-id" + static let serverAddress = "server-address" + + 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) { + 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] + if SecItemUpdate(query as CFDictionary, attributes as CFDictionary) != errSecSuccess { + var item = query + item[kSecValueData as String] = data + _ = SecItemAdd(item as CFDictionary, nil) + } + } + + static func removeAll() { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + ] + _ = SecItemDelete(query as CFDictionary) + } +} + +@MainActor +final class RemoteAgent: NSObject { + private static let pairedServerKey = "remotePairedServerAddress" + + enum Status: Equatable { + case disconnected + case connecting + case connected + case reconnecting + } + + private struct Message: 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 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 deviceName = "device_name" + case enrollmentKey = "enrollment_key" + } + } + + private struct EnrollmentResponse: Decodable { + let deviceID: String + let deviceToken: String + + enum CodingKeys: String, CodingKey { + case deviceID = "device_id" + case deviceToken = "device_token" + } + } + + private enum LocalEvent { + case headers(Int, [String: String]) + case data(Data) + case finished(Error?) + } + + 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: LocalRequest] = [:] + + 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) { + shouldConnect = true + 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 { + let credentials = try await Self.enroll(serverURL: serverURL, + key: enrollmentKey.trimmingCharacters(in: .whitespacesAndNewlines), + deviceName: deviceName) + RemoteKeychain.set(credentials.deviceToken, for: RemoteKeychain.deviceToken) + cachedDeviceToken = credentials.deviceToken + cachedCredentialServer = serverURL.absoluteString + UserDefaults.standard.set(serverURL.absoluteString, forKey: Self.pairedServerKey) + } + guard cachedDeviceToken(for: serverURL) != nil else { + throw NSError(domain: "LocalShare.Remote", code: 1, + userInfo: [NSLocalizedDescriptionKey: "Enter an enrollment key first."]) + } + self.openSocket() + } catch { + self.shouldConnect = false + self.setStatus(.disconnected, error: error.localizedDescription) + } + } + } + + func disconnect() { + shouldConnect = false + retryTask?.cancel() + retryTask = nil + heartbeatTask?.cancel() + heartbeatTask = nil + closeSocket() + shareBaseURL = nil + onShareURLChange?(nil) + setStatus(.disconnected, error: nil) + } + + func forgetDevice() { + disconnect() + 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(Message(type: "share.start", protocolVersion: 1, deviceName: nil)) + } + + private func openSocket() { + guard let serverURL, + let token = cachedDeviceToken(for: serverURL), + let endpoint = agentURL(for: serverURL) else { + setStatus(.disconnected, error: "Invalid server address or device credentials.") + 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(Message.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: Message) { + 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, method: method, path: path, headers: message.headers ?? [:]) + case "request.cancel": + if let id = message.requestID { cancelLocalRequest(id) } + case "error": + lastError = message.error + onStateChange?(status, lastError) + default: + break + } + } + + private func startLocalRequest(id: String, method: String, path: String, headers: [String: String]) { + guard let base = localBaseURL, !localToken.isEmpty else { + send(Message(type: "error", requestID: id, error: "Local share is not running.")) + return + } + let separator = path.contains("?") ? "&" : "?" + guard let url = URL(string: base.absoluteString + path + separator + "t=" + localToken) else { + send(Message(type: "error", requestID: id, error: "Invalid local request path.")) + 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 = LocalRequest(id: id, request: request) { [weak self] event in + Task { @MainActor [weak self] in self?.handleLocalEvent(id: id, event: event) } + } + localRequests[id] = local + local.start() + } + + private func handleLocalEvent(id: String, event: LocalEvent) { + guard localRequests[id] != nil else { return } + switch event { + case .headers(let status, let headers): + send(Message(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 { + send(Message(type: "error", requestID: id, error: error.localizedDescription)) + } else { + send(Message(type: "response.end", requestID: id)) + } + localRequests.removeValue(forKey: id)?.invalidate() + } + } + + private func sendDataResponse(_ requestID: String, data: Data) { + guard let webSocket else { return } + webSocket.send(.data(encodeData(requestID, data))) { [weak self] error in + if let error { + Task { @MainActor [weak self] in self?.connectionEnded(error, from: webSocket) } + } + } + } + + private func send(_ message: Message) { + 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() + } + + private func cancelLocalRequests() { + for request in localRequests.values { request.cancel() } + localRequests.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) async throws -> EnrollmentResponse { + 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(Message(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 NSError(domain: "LocalShare.Remote", code: (response as? HTTPURLResponse)?.statusCode ?? 0, + userInfo: [NSLocalizedDescriptionKey: "Enrollment was rejected by the server."]) + } + return try JSONDecoder().decode(EnrollmentResponse.self, from: data) + } + + static func encodeData(_ 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 + } + + private func encodeData(_ id: String, _ data: Data) -> Data { Self.encodeData(id, data) } + + private final class LocalRequest: NSObject, @unchecked Sendable, URLSessionDataDelegate, URLSessionTaskDelegate { + let id: String + private let request: URLRequest + private let emit: (LocalEvent) -> Void + private var session: URLSession? + private var task: URLSessionDataTask? + + init(id: String, request: URLRequest, emit: @escaping (LocalEvent) -> Void) { + self.id = id + self.request = request + self.emit = emit + } + + func start() { + let session = URLSession(configuration: .ephemeral, delegate: self, delegateQueue: nil) + self.session = session + let task = session.dataTask(with: request) + self.task = task + task.resume() + } + + func cancel() { task?.cancel(); invalidate() } + func invalidate() { session?.invalidateAndCancel(); session = nil; task = 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) { + emit(.data(data)) + } + + func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) { + emit(.finished(error)) + } + } +} + +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(Message(type: "hello", protocolVersion: 1, + deviceName: Host.current().localizedName ?? "Mac")) + if self.localBaseURL != nil { self.send(Message(type: "share.start", protocolVersion: 1)) } + 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/RemoteTunnel.swift b/Sources/LocalShare/RemoteTunnel.swift deleted file mode 100644 index 38872a2..0000000 --- a/Sources/LocalShare/RemoteTunnel.swift +++ /dev/null @@ -1,198 +0,0 @@ -import Foundation - -struct RemoteSettings: Equatable { - var publicOrigin = "" - var sshHost = "" - var sshPort = 2200 - var identityPath = "" - - var publicURL: URL? { - let raw = publicOrigin.trimmingCharacters(in: .whitespacesAndNewlines) - guard let url = URL(string: raw), - url.scheme?.lowercased() == "https", - url.host != nil, - url.user == nil, - url.password == nil, - url.query == nil, - url.fragment == nil, - url.path.isEmpty || url.path == "/" else { return nil } - return url - } - - var customDomain: String? { publicURL?.host } - - var isValid: Bool { - guard publicURL != nil, - !sshHost.isEmpty, - !sshHost.contains(where: { $0.isWhitespace || $0 == "/" }), - (1...65535).contains(sshPort) else { return false } - return true - } - - func tunnelConfiguration(localAddress: String, localPort: in_port_t) -> RemoteTunnel.Configuration? { - guard isValid, let customDomain else { return nil } - let identity = identityPath.trimmingCharacters(in: .whitespacesAndNewlines) - return RemoteTunnel.Configuration( - sshHost: sshHost, - sshPort: sshPort, - identityPath: identity.isEmpty ? nil : (identity as NSString).expandingTildeInPath, - customDomain: customDomain, - localAddress: localAddress, - localPort: localPort - ) - } -} - -@MainActor -final class RemoteTunnel { - enum Status: Equatable { - case disabled - case connecting - case online - case offline - } - - struct Configuration: Equatable { - let sshHost: String - let sshPort: Int - let identityPath: String? - let customDomain: String - let localAddress: String - let localPort: in_port_t - } - - private(set) var status: Status = .disabled - private(set) var lastError: String? - var onStateChange: ((Status, String?) -> Void)? - - private var process: Process? - private var stderrPipe: Pipe? - private var retryTask: Task? - private var configuration: Configuration? - private var shouldRun = false - private var retryDelay: TimeInterval = 1 - - nonisolated static func arguments(for config: Configuration) -> [String] { - var args = [ - "-T", - "-o", "BatchMode=yes", - "-o", "ClearAllForwardings=yes", - "-o", "ExitOnForwardFailure=yes", - "-o", "ServerAliveInterval=15", - "-o", "ServerAliveCountMax=3", - "-o", "StrictHostKeyChecking=yes", - "-p", String(config.sshPort), - ] - if let identityPath = config.identityPath { - args += ["-i", identityPath] - } - args += [ - "-R", ":80:\(config.localAddress):\(config.localPort)", - "v0@\(config.sshHost)", - "http", - "--proxy_name", "localshare", - "--custom_domain", config.customDomain, - ] - return args - } - - func start(_ configuration: Configuration) { - shouldRun = true - self.configuration = configuration - retryTask?.cancel() - retryTask = nil - retryDelay = 1 - stopProcess() - launch() - } - - func stop() { - shouldRun = false - configuration = nil - retryTask?.cancel() - retryTask = nil - stopProcess() - setStatus(.disabled, error: nil) - } - - private func launch() { - guard shouldRun, let configuration else { return } - setStatus(.connecting, error: nil) - - let process = Process() - process.executableURL = URL(fileURLWithPath: "/usr/bin/ssh") - process.arguments = Self.arguments(for: configuration) - process.standardOutput = FileHandle(forWritingAtPath: "/dev/null") - - let stderr = Pipe() - process.standardError = stderr - stderr.fileHandleForReading.readabilityHandler = { [weak self] handle in - let data = handle.availableData - guard !data.isEmpty else { return } - let message = String(decoding: data, as: UTF8.self).trimmingCharacters(in: .whitespacesAndNewlines) - guard !message.isEmpty else { return } - Task { @MainActor [weak self] in self?.lastError = message } - } - process.terminationHandler = { [weak self] terminated in - Task { @MainActor [weak self] in self?.handleTermination(terminated) } - } - self.process = process - self.stderrPipe = stderr - - do { - try process.run() - retryDelay = 1 - setStatus(.online, error: nil) - } catch { - self.process = nil - self.stderrPipe = nil - setStatus(.offline, error: error.localizedDescription) - scheduleRetry() - } - } - - private func handleTermination(_ terminated: Process) { - guard process === terminated else { return } - process = nil - stderrPipe?.fileHandleForReading.readabilityHandler = nil - stderrPipe = nil - guard shouldRun else { - setStatus(.disabled, error: nil) - return - } - let error = lastError ?? "ssh exited with status \(terminated.terminationStatus)" - setStatus(.offline, error: error) - scheduleRetry() - } - - private func scheduleRetry() { - guard shouldRun, configuration != nil else { return } - retryTask?.cancel() - 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.shouldRun else { return } - self.launch() - } - } - - private func stopProcess() { - guard let process else { return } - process.terminationHandler = nil - stderrPipe?.fileHandleForReading.readabilityHandler = nil - if process.isRunning { process.terminate() } - self.process = nil - stderrPipe = nil - } - - private func setStatus(_ status: Status, error: String?) { - self.status = status - lastError = error - onStateChange?(status, error) - } -} diff --git a/Sources/LocalShare/SettingsScreen.swift b/Sources/LocalShare/SettingsScreen.swift index ffdc133..943c8a7 100644 --- a/Sources/LocalShare/SettingsScreen.swift +++ b/Sources/LocalShare/SettingsScreen.swift @@ -8,10 +8,8 @@ struct SettingsScreen: View { @EnvironmentObject var state: AppState @EnvironmentObject var updater: UpdaterController @State private var portText = "" - @State private var remoteOriginText = "" - @State private var remoteSSHHostText = "" - @State private var remoteSSHPortText = "" - @State private var remoteIdentityText = "" + @State private var remoteServerText = "" + @State private var remoteKeyText = "" var body: some View { // portText 初始为空、onAppear 才填入当前端口;首帧若按空串校验会闪出「无效 + 放弃/应用」行再弹回。 // 空串一律视作「当前生效端口」,让首帧与落定后一致,消除进入设置页时的这层闪烁。 @@ -111,16 +109,14 @@ struct SettingsScreen: View { // MARK: 远程访问 eyebrow(L.sectionRemote(lang)) groupBox { - remoteField(title: L.remotePublicOrigin(lang), text: $remoteOriginText) - remoteField(title: L.remoteSSHHost(lang), text: $remoteSSHHostText, top: true) - remoteField(title: L.remoteSSHPort(lang), text: $remoteSSHPortText, top: true, width: 72, - numbersOnly: true) - remoteField(title: L.remoteIdentityPath(lang), text: $remoteIdentityText, top: true) + remoteField(title: L.remoteServerAddress(lang), text: $remoteServerText) + if !state.remotePaired { + remoteField(title: L.remoteEnrollmentKey(lang), text: $remoteKeyText, top: true) + } HStack(alignment: .top, spacing: 8) { - Image(systemName: "lock.shield").font(.system(size: 13)).foregroundStyle(t.accent) + Image(systemName: "key.horizontal").font(.system(size: 13)).foregroundStyle(t.accent) VStack(alignment: .leading, spacing: 3) { - Text(L.remoteHTTPSHint(lang)).font(.sans(11.5)).foregroundStyle(t.inkMute) - Text(L.remoteHostKeyHint(lang)).font(.sans(11.5)).foregroundStyle(t.inkMute) + Text(L.remotePairHint(lang)).font(.sans(11.5)).foregroundStyle(t.inkMute) } Spacer(minLength: 0) } @@ -130,6 +126,11 @@ struct SettingsScreen: View { GhostButton(t: t, title: L.remoteSave(lang), systemImage: "checkmark") { saveRemoteSettings() } + if state.remotePaired { + Button { state.forgetRemoteDevice() } label: { + Text(L.remoteForget(lang)).font(.sans(12.5, .semibold)).foregroundStyle(t.danger) + }.buttonStyle(.plain) + } } .padding(.bottom, 12) } @@ -273,10 +274,8 @@ struct SettingsScreen: View { } .onAppear { portText = String(state.configuredPort) - remoteOriginText = state.remoteSettings.publicOrigin - remoteSSHHostText = state.remoteSettings.sshHost - remoteSSHPortText = String(state.remoteSettings.sshPort) - remoteIdentityText = state.remoteSettings.identityPath + remoteServerText = state.remoteSettings.serverAddress + remoteKeyText = state.remoteEnrollmentKey state.refreshCLIStatus() } } @@ -400,11 +399,7 @@ struct SettingsScreen: View { } private func saveRemoteSettings() { - state.saveRemoteSettings(RemoteSettings( - publicOrigin: remoteOriginText, - sshHost: remoteSSHHostText, - sshPort: Int(remoteSSHPortText) ?? 0, - identityPath: remoteIdentityText - )) + state.saveRemoteSettings(RemoteSettings(serverAddress: remoteServerText), enrollmentKey: remoteKeyText) + remoteKeyText = "" } } diff --git a/Sources/LocalShare/ShareScreen.swift b/Sources/LocalShare/ShareScreen.swift index 35f3b83..fe1ea70 100644 --- a/Sources/LocalShare/ShareScreen.swift +++ b/Sources/LocalShare/ShareScreen.swift @@ -59,39 +59,41 @@ struct ShareScreen: View { Text(L.remoteAccessDesc(state.lang)).font(.sans(11.5)).foregroundStyle(t.inkMute) } Spacer(minLength: 8) - ToggleSwitch(t: t, isOn: state.remoteAccessEnabled, - locked: !state.remoteAccessEnabled && !state.canEnableRemote) { - state.setRemoteAccessEnabled(!state.remoteAccessEnabled) + 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 { - if let url = state.remoteURL { - CopyPill(t: t, lang: state.lang, value: url, compact: true) { - if let url = URL(string: url) { NSWorkspace.shared.open(url) } - } - } 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) - Spacer() - if state.remoteStatus == .offline { - GhostButton(t: t, title: L.remoteRetry(state.lang), systemImage: "arrow.clockwise") { - state.retryRemoteAccess() - } - } - GhostButton(t: t, title: L.remoteStop(state.lang)) { - state.setRemoteAccessEnabled(false) + if state.remoteStatus == .reconnecting { + Text(L.remoteOffline(state.lang)).font(.sans(11)).foregroundStyle(t.inkMute) } } - if let expires = state.remoteExpiresAt { - Text(LStr.remoteExpiry(expires, state.lang)).font(.mono(10.5)).foregroundStyle(t.inkFaint) - } if let error = state.remoteError, !error.isEmpty { Text(error).font(.sans(11)).foregroundStyle(t.danger).lineLimit(2) } - } else { - Text(state.remoteError ?? (state.remoteSettings.isValid ? L.remoteAccessDesc(state.lang) : L.remoteConfigHint(state.lang))) - .font(.sans(11.5)).foregroundStyle(state.remoteError == nil ? t.inkMute : t.danger) + } 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) } } @@ -103,18 +105,18 @@ struct ShareScreen: View { private var remoteStatusText: String { switch state.remoteStatus { case .connecting: return L.remoteConnecting(state.lang) - case .online: return L.remoteOnline(state.lang) - case .offline: return L.remoteOffline(state.lang) - case .disabled: return L.remoteConfigHint(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 .online: return t.ok - case .offline: return t.danger + case .connected: return t.ok + case .reconnecting: return t.danger case .connecting: return t.warn - case .disabled: return t.inkFaint + case .disconnected: return t.inkFaint } } diff --git a/Tests/LocalShareTests/RemoteSharingTests.swift b/Tests/LocalShareTests/RemoteSharingTests.swift index 103afbc..1765c80 100644 --- a/Tests/LocalShareTests/RemoteSharingTests.swift +++ b/Tests/LocalShareTests/RemoteSharingTests.swift @@ -1,54 +1,49 @@ -import Foundation import XCTest @testable import LocalShare final class RemoteSharingTests: XCTestCase { - func testTokensHave128BitsOfHexEntropy() { + func testRemoteSettingsAcceptOnlyBaseServerAddress() { + XCTAssertTrue(RemoteSettings(serverAddress: "https://ls.example.com").isValid) + XCTAssertEqual(RemoteSettings(serverAddress: "https://ls.example.com/").serverURL?.absoluteString, + "https://ls.example.com") + XCTAssertTrue(RemoteSettings(serverAddress: "http://127.0.0.1:8080").isValid) + XCTAssertFalse(RemoteSettings(serverAddress: "").isValid) + XCTAssertFalse(RemoteSettings(serverAddress: "https://ls.example.com/path").isValid) + XCTAssertFalse(RemoteSettings(serverAddress: "https://user:pass@ls.example.com").isValid) + XCTAssertFalse(RemoteSettings(serverAddress: "not a url").isValid) + } + + func testShareTokenStillHas128BitsOfHexEntropy() { let token = Token.generate() XCTAssertEqual(token.count, 32) XCTAssertTrue(token.allSatisfy { "0123456789abcdef".contains($0) }) } - func testRemoteSettingsRequireHttpsAndValidSSHPort() { - let valid = RemoteSettings(publicOrigin: "https://share.example.com", sshHost: "relay.example.com", - sshPort: 2200) - XCTAssertTrue(valid.isValid) - XCTAssertEqual(valid.customDomain, "share.example.com") - - XCTAssertFalse(RemoteSettings(publicOrigin: "http://share.example.com", sshHost: "relay.example.com", - sshPort: 2200).isValid) - XCTAssertFalse(RemoteSettings(publicOrigin: "https://share.example.com/path", sshHost: "relay.example.com", - sshPort: 2200).isValid) - XCTAssertFalse(RemoteSettings(publicOrigin: "https://share.example.com", sshHost: "relay.example.com", - sshPort: 0).isValid) + @MainActor func testRemoteDataFrameHasOneLengthPrefix() { + let frame = RemoteAgent.encodeData("req_1", Data("hello".utf8)) + XCTAssertEqual(Array(frame.prefix(4)), [0, 0, 0, 5]) + XCTAssertEqual(String(data: frame.dropFirst(4).dropFirst(5), encoding: .utf8), "hello") } - func testTunnelArgumentsStayDirectAndVerifyHostKeys() { - let settings = RemoteSettings(publicOrigin: "https://share.example.com", sshHost: "relay.example.com", - sshPort: 2200, identityPath: "~/.ssh/localshare") - let config = settings.tunnelConfiguration(localAddress: "127.0.0.1", localPort: 18081)! - let args = RemoteTunnel.arguments(for: config) - XCTAssertTrue(args.contains("StrictHostKeyChecking=yes")) - XCTAssertTrue(args.contains("-i")) - XCTAssertTrue(args.contains("\(NSHomeDirectory())/.ssh/localshare")) - XCTAssertTrue(args.contains(":80:127.0.0.1:18081")) - XCTAssertEqual(args.suffix(5), ["http", "--proxy_name", "localshare", "--custom_domain", "share.example.com"]) + @MainActor func testRemotePairStatusUsesUserDefaultsMarker() { + let key = "remotePairedServerAddress" + let previous = UserDefaults.standard.object(forKey: key) + defer { + if let previous { UserDefaults.standard.set(previous, forKey: key) } + else { UserDefaults.standard.removeObject(forKey: key) } + } + UserDefaults.standard.set("http://127.0.0.1:18080", forKey: key) + let agent = RemoteAgent() + XCTAssertTrue(agent.isPaired(for: URL(string: "http://127.0.0.1:18080"))) + XCTAssertFalse(agent.isPaired(for: URL(string: "http://127.0.0.1:18081"))) } - func testForwardedAddressNeedsTrustedHTTPSProxy() { - let headers = [ - "x-forwarded-proto": "https", - "x-forwarded-for": "203.0.113.4, 127.0.0.1", - ] - XCTAssertEqual( - FileServer.forwardedClientAddress(headers: headers, sourceAddress: "127.0.0.1", - remoteEnabled: true, trustedProxyAddresses: ["127.0.0.1"]), - "203.0.113.4" - ) - XCTAssertNil(FileServer.forwardedClientAddress(headers: headers, sourceAddress: "192.168.1.2", - remoteEnabled: true, trustedProxyAddresses: ["127.0.0.1"])) - XCTAssertNil(FileServer.forwardedClientAddress(headers: ["x-forwarded-proto": "http"], - sourceAddress: "127.0.0.1", remoteEnabled: true, - trustedProxyAddresses: ["127.0.0.1"])) + func testByteRangesSupportPrefixSuffixAndRejectMultipleRanges() { + XCTAssertEqual(FileServer.byteRange("bytes=2-5", size: 10)?.start, 2) + XCTAssertEqual(FileServer.byteRange("bytes=2-5", size: 10)?.end, 5) + XCTAssertEqual(FileServer.byteRange("bytes=-3", size: 10)?.start, 7) + XCTAssertEqual(FileServer.byteRange("bytes=8-", size: 10)?.end, 9) + XCTAssertNil(FileServer.byteRange("bytes=20-", size: 10)) + XCTAssertNil(FileServer.byteRange("bytes=0-1,4-5", size: 10)) } } diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 32aeef3..c4199f0 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.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-owned Nginx + frp SSH gateway; no receiver app, VPN, or account | +| Network | Same Wi-Fi / LAN by default; optional self-hosted LocalShare Server with one outbound WebSocket; no receiver app or VPN | | Stack | Swift / SwiftUI, system frameworks only, no external package dylib risk | | HTTP server | Swifter from SPM source, primarily read-only static serving | | 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. Changing or stopping a share rotates the token immediately, invalidating old links, cookies, and QR codes. Anyone guessing `IP:port` receives 403 | -| Protocol | LAN uses plain HTTP; remote mode uses the configured HTTPS origin terminated by the user's Nginx. A 128-bit token blocks guessed addresses, rotates on share boundaries, and expires remote access after one hour | +| Protocol | LAN uses the existing HTTP FileServer; remote mode uses the Server's HTTP/WebSocket relay. Device and share tokens are high-entropy and rotate at share boundaries | | QR code | Uses the public origin while remote mode is active, otherwise the selected LAN IP; generated with CoreImage `CIQRCodeGenerator`; no third-party QR dependency. Window keeps the LAN `.local` fallback | | 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 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, server, and an enabled tunnel continue running, and menu bar activation restores the previous screen. Ports are selected automatically; service and tunnel stop 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,7 @@ 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 - RemoteTunnel.swift # HTTPS-origin settings and direct /usr/bin/ssh reverse tunnel + RemoteAgent.swift # Server pairing, Keychain device token, WebSocket relay, reconnect NetworkInfo.swift # getifaddrs -> private IPv4 candidates and .local hostname QRCode.swift # CoreImage QR -> NSImage, plus terminal ANSI output Token.swift / Mime.swift # Random token and extension -> MIME mapping with charset=utf-8 for text @@ -79,6 +80,7 @@ LocalShare/ 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, 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. @@ -99,7 +101,7 @@ LocalShare/ 2. **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. 3. **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. 4. **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. -5. **Remote policy**: while remote mode is enabled, only `GET` and `HEAD` are accepted, upload and text-receive routes disappear, and `ls_token` receives `Secure` only for a request from the trusted local tunnel with `X-Forwarded-Proto: https`. `X-Forwarded-For` is used for presence only in that same case. +5. **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 2-4 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. @@ -127,16 +129,16 @@ When `FileServer.listenAddress` is non-nil, Swifter binds only that IPv4 address ### Remote Sharing -Remote settings persist only the HTTPS origin, SSH host/port, and optional identity path. The switch is session-only and starts `/usr/bin/ssh` directly with `BatchMode`, `ExitOnForwardFailure`, keepalives, and `StrictHostKeyChecking=yes`; the system SSH agent, config, and `known_hosts` remain the trust store. The tunnel forwards the actual LocalShare listen address, so current-network-only binding remains valid. +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. -`RemoteTunnel` reports connecting/online/offline state and retries with a 1–60 second capped backoff. Changing share content only updates the locked `FileServer` state and rotates the token; stopping the share, disabling remote mode, app termination, or the one-hour expiry stops the tunnel. Expiry also rotates the token while leaving the LAN share available. +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. ### 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 public URL/QR replace the LAN primary URL only while the switch is enabled. A disconnected tunnel retries without changing the expiry deadline. +- 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. - 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. @@ -193,6 +195,9 @@ 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 @@ -210,7 +215,7 @@ 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. -- Vendor tunnel clients such as cloudflared, ngrok, or Tailscale; the first remote mode uses the user-owned Nginx/frp gateway and system SSH only. +- SSH/frp/Nginx/Tailscale/Cloudflare tunnel integration; the remote path is the self-contained LocalShare Server. These remain possible transport or deployment references, not Client runtime dependencies. - **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:** diff --git a/docs/REMOTE_SHARING_PLAN.md b/docs/REMOTE_SHARING_PLAN.md index 7bc78ff..76eb689 100644 --- a/docs/REMOTE_SHARING_PLAN.md +++ b/docs/REMOTE_SHARING_PLAN.md @@ -1,207 +1,179 @@ -# Remote Sharing Plan +# LocalShare Remote Sharing Plan -> Status: app implementation complete. Public-server deployment and end-to-end internet acceptance remain environment-specific. +> 目标:让浏览器通过一个自托管的 LocalShare Server 访问 Mac 上正在分享的文件。Server 是单个二进制,不依赖 SSH、frp、Nginx、Tailscale 或其他运行时组件。 -## Goal - -Allow a Mac running LocalShare to share files with a remote device that has only a web browser. - -The first version should: - -- use a user-owned public server and domain; -- require no companion app, VPN, or account on the receiving device; -- keep files on the Mac and stream them through the server without storage; -- preserve the existing `FileServer`, URL paths, token authentication, and QR flow; -- expose remote shares only while explicitly enabled; -- add no package-external runtime dylibs. - -## Options Considered - -| Approach | Browser-only receiver | Self-hosted | Fit for LocalShare | -|---|---:|---:|---| -| [frp SSH Tunnel Gateway](https://gofrp.org/en/docs/features/common/ssh/) behind Nginx | Yes | Yes | **Selected.** Uses macOS `/usr/bin/ssh`, reuses `FileServer`, and needs no bundled tunnel client. | -| Plain OpenSSH reverse forwarding behind Nginx | Yes | Yes | Smallest single-Mac setup, but frp adds HTTP host routing and a path to multiple clients without a custom control service. | -| [Cloudflare Tunnel](https://developers.cloudflare.com/tunnel/) / [ngrok](https://ngrok.com/docs/agent/) | Yes | No | Mature UX reference, but requires a vendor account, agent, and hosted edge. | -| [Tailscale Funnel](https://tailscale.com/docs/features/tailscale-funnel) / [NetBird Reverse Proxy](https://docs.netbird.io/manage/reverse-proxy) | Yes | Depends on provider | Adds a VPN identity/control plane and another agent; public proxy features are broader than the first version needs. | -| Tailscale, Headscale, or raw WireGuard mesh | No, unless a public gateway is added | Yes | Good for trusted devices or connected sites, but the receiver otherwise needs a VPN client. | -| [zrok](https://github.com/openziti/zrok) / [Pangolin](https://docs.pangolin.net/) | Yes | Yes | Closest complete products, but their control planes, connectors, and licensing/operations are unnecessary for one server and one active share. | -| WebRTC or relay-assisted transfer, such as [PairDrop](https://github.com/schlagmichdoch/PairDrop) or [Magic Wormhole](https://magic-wormhole.readthedocs.io/en/latest/file-transfer-protocol.html) | Sometimes | Yes | Requires a new transfer protocol and receiver flow instead of reusing LocalShare's HTTP browser. | -| Upload files to temporary cloud storage | Yes | Yes | Lets the Mac go offline, but stores file contents on the server and changes LocalShare's live-sharing model. | - -## Decision - -Use Nginx for the public HTTPS endpoint and frp's SSH Tunnel Gateway for temporary reverse forwarding: +## 1. 方案 ```text -browser - -> HTTPS :443 -Nginx - -> HTTP 127.0.0.1:18080 -frps HTTP virtual host - -> encrypted SSH reverse tunnel -macOS /usr/bin/ssh - -> LocalShare FileServer +浏览器 + │ GET /share//... + ▼ +LocalShare Server :8080 + ├─ Control Plane:Enrollment Key、Device Token、设备连接、分享注册 + ├─ Data Relay:把浏览器 HTTP 请求转成 WebSocket 消息 + └─ HTTP endpoint:认证分享 token、流式返回文件 + ▲ 一个长连接 WebSocket + │ +LocalShare Client(Mac) + └─ RemoteAgent → 本机 FileServer → 用户选择的文件 ``` -This is a relayed design, not peer-to-peer NAT traversal. All remote traffic crosses the user's server. +Server 不保存文件。文件内容始终由 Client 从本机 `FileServer` 读取,再经过 Server 流式转发给浏览器。 -For the first version: +V1 的边界: -- one fixed public hostname, such as `share.example.com`; -- one active remote share per server; -- no database, allocation API, wildcard domain, or bundled tunnel binary; -- Nginx and frps run continuously, while the proxy registration, SSH connection, and share token are temporary. +- 一个 Server 可管理多个设备,但每台设备最多一个当前分享; +- 一个 Client 只连接一个 Server; +- 只转发 `GET` / `HEAD`,支持 `Range`,支持取消请求; +- 远程模式强制只读,不开放上传和 Text Transfer; +- 不做 Server 端文件缓存、用户账号、浏览器账号登录或多租户计费。 -## Server Plan +## 2. 用户配置体验 -### Nginx +### Server 管理员 -Nginx owns ports 80 and 443, redirects to HTTPS, and proxies the fixed share hostname to the private frps HTTP port. +下载并运行一个二进制: -The server block must: +```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 +``` -- preserve `Host`; -- set `X-Forwarded-Proto` and `X-Forwarded-For`; -- disable response buffering for streamed downloads; -- allow only `GET` and `HEAD` in the first version; -- disable access logs for the share host, or redact the `t` query parameter; -- never cache responses. +首次配对时生成一次性 Enrollment Key: -Minimal proxy shape: +```bash +localshare-server key create --name macbook +# 输出 key 只显示一次 +``` -```nginx -location / { - limit_except GET HEAD { deny all; } +管理命令: - proxy_pass http://127.0.0.1:18080; - proxy_http_version 1.1; - proxy_set_header Host $host; - proxy_set_header X-Forwarded-Proto https; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_buffering off; - proxy_request_buffering off; - proxy_read_timeout 1h; -} +```bash +localshare-server key list +localshare-server key revoke +localshare-server device list +localshare-server device revoke ``` -### frps +`state.json` 只保存 Enrollment Key 和 Device Token 的 SHA-256,不保存可直接使用的明文密钥;目录和文件权限分别为 `0700` / `0600`。 + +### Client 用户 -frps provides the SSH gateway and HTTP host routing. +第一次在 LocalShare 设置中填写: -Suggested ports: +1. Server address,例如 `https://ls.example.com`; +2. Enrollment Key。 -| Port | Purpose | Public | -|---|---|---:| -| 443 | Nginx HTTPS | Yes | -| 2200 | frps SSH Tunnel Gateway | Yes | -| 18080 | frps HTTP virtual host | No | -| 7000 | regular frpc transport, unused by v1 | No | +点击保存并连接后,Client 调用 `/api/v1/enroll` 换取 Device Token,并把 Device Token 保存到 macOS Keychain。Enrollment Key 不落盘。 -Production configuration must: +之后设置页只需要保留 Server address;分享页提供: -- authenticate SSH clients with `sshTunnelGateway.authorizedKeysFile`; -- use a persistent SSH host key; -- run as an unprivileged service account; -- block ports 18080 and 7000 at the public firewall; -- avoid logging LocalShare share tokens. +- `Connect`:建立 WebSocket 长连接并注册当前分享; +- `Disconnect`:断开当前连接,但保留已配对的 Device Token; +- `Forget Device`:删除 Keychain 中的 Device Token,下一次需要新的 Enrollment Key。 -## LocalShare Plan +应用重启后不自动恢复远程分享;用户在当前分享页点击 `Connect` 即可重新连接。连接断开时,Client 自动重试,退避上限 60 秒。 -### Configuration +## 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 原样流式返回浏览器 +``` -Store only non-secret connection settings: +WebSocket 文本消息使用 JSON;文件数据使用带 request ID 的二进制帧: -- public HTTPS origin; -- SSH host and port; -- optional SSH identity path; -- server host-key verification state. +```text +4 bytes big-endian request-id length +request-id bytes +file bytes +``` -Use the system SSH configuration, agent, `known_hosts`, and Keychain where available. Never disable strict host-key verification. +Server 只转发必要的请求头:`Accept`、`Accept-Language`、`If-Modified-Since`、`If-None-Match`、`Range`、`User-Agent`。响应会移除 `Set-Cookie`、`Connection` 和 `Transfer-Encoding`,并重写站内 `Location` 到 `/share/`。 -### Tunnel Lifecycle +## 4. Server 实现 -Add one small `RemoteTunnel` component that launches `/usr/bin/ssh` with `Process`, passes arguments directly without a shell, captures errors, and exposes connection state. +Server 使用 Go 标准库 HTTP;WebSocket 只需要一个小型、纯 Go 的协议依赖,最终编译进单个静态二进制。Control Plane 和 Data Relay 共用同一个 HTTP 端口。 -The command shape is: +HTTP 路由: -```bash -/usr/bin/ssh -T \ - -o BatchMode=yes \ - -o ClearAllForwardings=yes \ - -o ExitOnForwardFailure=yes \ - -o ServerAliveInterval=15 \ - -o ServerAliveCountMax=3 \ - -p 2200 \ - -R :80:: \ - v0@relay.example.com \ - http --proxy_name localshare --custom_domain share.example.com -``` +| 路径 | 作用 | 认证 | +|---|---|---| +| `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 | -Lifecycle rules: +Server 内存中保存当前连接和分享注册;重启后设备凭据仍从 `state.json` 恢复,但 Client 需要重新连接,旧的 Share Token 失效。 -- start the tunnel only after `FileServer` has a listening port; -- keep the tunnel when share content changes, but rotate the share token; -- stop the tunnel when remote sharing or the share itself stops; -- keep it alive when the window closes, matching current server behavior; -- stop it when the app exits; -- report disconnection and retry with capped backoff while remote sharing remains enabled. +请求必须有超时和取消传播。浏览器断开时,Server 发送 `request.cancel`,Client 取消对应的本机 `URLSessionDataTask`,避免大文件继续占用带宽。 -When "Current network only" is enabled, forward to the server's selected listen address instead of assuming loopback. +## 5. Client 实现 -### URL and UI +`RemoteAgent.swift` 只负责远程控制和流转发,不复制 FileServer 的鉴权、路径解析或预览逻辑: -Refactor URL construction to accept either the current LAN origin or the configured public HTTPS origin while preserving existing paths and `?t=` authentication. +- 使用系统 `URLSessionWebSocketTask` 建立出站连接; +- Device Token 仅从 Keychain 读取; +- `share.start` 时把本机 FileServer 地址和当前 token 作为内部转发目标; +- Server 下发请求后,只允许白名单请求头进入本机 FileServer; +- 从本机响应按块发送数据,不把完整文件读入内存; +- 断线清理所有本地请求,重新连接后重新注册当前分享; +- 分享内容变化时更新本机目标并重新注册,Server 生成新的 Share Token。 -The share screen adds: +AppState 继续作为唯一状态源:远程状态、公开 URL 和本地只读策略都由 AppState 管理。RemoteAgent 不直接修改 SwiftUI 状态。 -- a Remote Access switch; -- connecting, online, and offline states; -- the public URL and QR code while remote access is active; -- retry and stop actions. +## 6. 安全边界 -The LAN URL remains available. +- 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 随分享注册变化或停止立即失效; +- WebSocket 是 Client 主动发起的出站长连接,Server 不需要访问 Mac 的监听端口。 -### Security +## 7. 交付阶段 -Before enabling public access: +1. 在 `server/` 新增 Go module、状态存储、CLI、Enrollment、设备认证和健康检查。 +2. 实现同端口 WebSocket Agent 与浏览器 Data Relay,先支持只读 GET/HEAD 和流式数据。 +3. 在 Client 增加 Keychain 配对、Connect/Disconnect、断线重连和远程只读策略。 +4. 增加 Range、取消、旧 token 失效、设备撤销和连接状态测试。 +5. 更新 CI、README、架构文档,并做本地 Server + headless Client 的端到端冒烟测试。 -- raise share-token entropy to at least 128 bits; -- force remote sharing to read-only and disable upload/text receive routes at both the app and Nginx layers; -- mark cookies `Secure` for trusted HTTPS proxy requests; -- give remote shares a default one-hour lifetime; -- invalidate old tokens immediately on content change or stop; -- trust forwarded client IP headers only when remote mode is active and the request arrived through the local tunnel; -- document that Nginx terminates TLS, so the user-owned server is inside the trust boundary. +## 8. 明确放弃的方案 -End-to-end encryption against an untrusted relay is not part of the first version. +### SSH tunnel / frp / Nginx -## Delivery +它们可以转发 TCP,但要求用户额外准备 SSH 信任、端口、反向转发、代理配置和多个服务生命周期。对 LocalShare 来说,这些配置不提供用户价值,且文件请求协议、设备配对和分享注册仍然需要 Client 自己实现,因此改为一个自包含 Server。 -1. Deploy Nginx and frps on a test server and repeat the existing local proof of concept over the public internet. -2. Add strong tokens and public-origin URL construction with unit tests. -3. Add `RemoteTunnel`, connection settings, and lifecycle handling. -4. Add the minimal remote-share UI and fixed expiration behavior. -5. Run unit tests, headless smoke tests, release build, and the dependency gate. +### Tailscale / Cloudflare Tunnel -Public-server acceptance checks: +它们适合作为未来的底层网络或部署参考,但会引入外部控制面、账号/设备身份或特定供应商依赖。当前版本先把控制面和数据中继边界做清楚,后续如需要可替换 Client 的传输层,不改变浏览器 Data Relay 和 FileServer 协议。 -- no token returns 403; -- browser `?t=` -> cookie -> clean URL flow still works; -- a large file downloaded through Nginx and frp matches its source checksum; -- changing or stopping a share invalidates the old URL; -- stopping SSH makes the public endpoint unavailable; -- network changes reconnect without reviving an expired token; -- ports 18080 and 7000 are unreachable publicly; -- Nginx/frps logs do not contain share tokens. +### WebRTC / P2P -## Deferred +能减少中继流量,但需要 NAT 穿透、ICE、浏览器端连接协商和新的数据协议,无法复用当前 HTTP 文件服务。只有在中继成本成为真实问题时再考虑。 -Add these only when the single-server limit becomes real: +## 9. 暂不实现 -- multiple simultaneous Macs or users; -- wildcard subdomains and dynamic proxy allocation; -- a management API or account system; -- browser authentication beyond the LocalShare capability URL; -- P2P/WebRTC transport; -- resumable downloads; -- end-to-end encryption against an untrusted relay. +- 多 Client 同时共享同一 Share Token; +- Server 端文件缓存和断点持久化; +- 上传、删除、编辑和远程 Text Transfer; +- Server Web 管理后台; +- 自动 DNS、证书和云厂商部署; +- 对不可信 Server 的端到端加密。 diff --git a/server/README.md b/server/README.md new file mode 100644 index 0000000..ecab40a --- /dev/null +++ b/server/README.md @@ -0,0 +1,185 @@ +# LocalShare Server + +LocalShare Server 是远程分享的控制面和数据中继。它只转发请求与文件流,不保存 Mac 上的分享内容。Server 使用一个端口,要求 Go 1.23+。 + +## 一、编译 + +在仓库根目录执行: + +```bash +cd server +go test ./... +go test -race ./... +go build -trimpath -ldflags "-s -w" -o localshare-server . +``` + +如果部署到 Linux,可以在 macOS 上交叉编译: + +```bash +CGO_ENABLED=0 GOOS=linux GOARCH=amd64 \ + go build -trimpath -ldflags "-s -w" -o localshare-server . +``` + +把生成的 `localshare-server` 复制到目标服务器即可,不需要安装 SSH/frp/Nginx 或其他 LocalShare 运行时组件。 + +## 二、本机快速测试 + +打开两个终端。 + +终端 A:生成测试目录和一次性配对 key: + +```bash +cd server +state_dir=$(mktemp -d) +./localshare-server key create --name test-mac --state-dir "$state_dir" +``` + +复制命令输出的 `key`。它只允许成功配对一次。 + +终端 A 继续启动 Server: + +```bash +./localshare-server serve \ + --listen 127.0.0.1:18080 \ + --public-url http://127.0.0.1:18080 \ + --state-dir "$state_dir" +``` + +终端 B 检查 Server: + +```bash +curl http://127.0.0.1:18080/healthz +``` + +预期返回包含: + +```json +{"status":"ok"} +``` + +这里的 `127.0.0.1` 只适合 Server 和 LocalShare Client 在同一台 Mac 上测试。若 Client 在另一台 Mac,Server 必须监听可访问地址,例如 `--listen :18080`,并使用服务器局域网地址作为 Client 的 Server address。 + +## 三、配置 LocalShare Client + +1. 编译并启动 macOS Client: + + ```bash + cd .. + swift build + .build/debug/LocalShare + ``` + + 或使用 `./build.sh` 生成,再执行 `open dist/LocalShare.app`。 + +2. 在 Client 中选择一个文件或文件夹,让本地分享先运行。 +3. 打开「设置 → 远程访问」: + + - `Server address`:填写 `http://127.0.0.1:18080`,或远程服务器的可访问地址; + - `Enrollment key`:填写终端 A 生成的 `key`。 + +4. 点击保存,回到分享票据,点击 `Connect`。 +5. 配对成功后,Client 会把 Device Token 保存到 macOS Keychain;Enrollment Key 不会持久化。 +6. 连接成功后,分享票据上会显示 Server 生成的远程访问地址。用浏览器打开这个地址即可测试文件浏览和下载。 + +之后再次使用只需要点击 `Connect`,不需要重新填写 key。`Disconnect` 只断开当前长连接,`Forget Device` 才会删除 Keychain 中的 Device Token。 + +Client 重启后不会自动公开分享;选择文件并点击 `Connect` 即可使用已经配对的设备凭据重新连接。 + +## 四、Linux 服务器部署 + +生产部署只需要持久化二进制、状态目录和一个监听端口: + +```bash +sudo install -m 0755 localshare-server /usr/local/bin/localshare-server +sudo useradd --system --home /var/lib/localshare --shell /usr/sbin/nologin localshare +sudo install -d -o localshare -g localshare -m 0700 /var/lib/localshare +``` + +先生成配对 key: + +```bash +sudo -u localshare /usr/local/bin/localshare-server key create \ + --name macbook \ + --state-dir /var/lib/localshare +``` + +然后启动: + +```bash +sudo -u localshare /usr/local/bin/localshare-server serve \ + --listen :18080 \ + --public-url http://server.example:18080 \ + --state-dir /var/lib/localshare +``` + +`--public-url` 必须是浏览器和 Client 使用的 Server 基地址,不能带额外路径或 query。`state-dir` 必须持久化;删除其中的 `state.json` 会使已配对设备全部失效。 + +如果使用 HTTPS,Server 可以直接终止 TLS: + +```bash +sudo -u localshare /usr/local/bin/localshare-server serve \ + --listen :18443 \ + --public-url https://ls.example.com \ + --tls-cert /etc/localshare/server.crt \ + --tls-key /etc/localshare/server.key \ + --state-dir /var/lib/localshare +``` + +`https` 的 `--public-url` 必须同时提供 `--tls-cert` 和 `--tls-key`;证书的域名必须匹配 Server address。HTTP 配置不应传入 TLS 参数。 + +如使用 systemd,可创建 `/etc/systemd/system/localshare-server.service`: + +```ini +[Unit] +Description=LocalShare Server +After=network-online.target +Wants=network-online.target + +[Service] +User=localshare +Group=localshare +ExecStart=/usr/local/bin/localshare-server serve --listen :18080 --public-url http://server.example:18080 --state-dir /var/lib/localshare +Restart=on-failure +NoNewPrivileges=true +PrivateTmp=true +ProtectHome=true +ProtectSystem=strict +ReadWritePaths=/var/lib/localshare + +[Install] +WantedBy=multi-user.target +``` + +启动并检查: + +```bash +sudo systemctl daemon-reload +sudo systemctl enable --now localshare-server +curl http://server.example:18080/healthz +sudo journalctl -u localshare-server -f +``` + +防火墙只需要允许 Server 的这个端口。Client 不需要把 Mac 的 FileServer 端口暴露给外部;Client 会主动向 Server 建立 WebSocket 长连接。 + +## 五、管理命令 + +```bash +localshare-server key list +localshare-server key revoke +localshare-server device list --state-dir /var/lib/localshare +localshare-server device revoke --state-dir /var/lib/localshare +``` + +Server 只在 `state.json` 中保存 Enrollment Key 和 Device Token 的 SHA-256,不保存明文 token。Enrollment Key、Device Token 和 Share Token 都不应写入公开日志或提交到代码仓库。 + +## 六、排查顺序 + +1. `curl /healthz` 是否返回 200。 +2. Server address 是否为基地址,例如 `http://server.example:18080`,没有额外 path/query。 +3. Enrollment Key 是否已经被使用;已使用时重新运行 `key create`。 +4. `device list` 是否能看到配对设备。 +5. Client 是否已经选择文件并点击 `Connect`;空分享时 Connect 会被禁用。 +6. Client 状态是否为 `Connected`,并且分享票据是否出现远程 URL。 +7. 远程 URL 是否仍然对应当前连接;Disconnect、换分享内容或 Server 重启后旧 Share Token 都会失效。 + +完整架构、消息协议和取舍见 [`docs/REMOTE_SHARING_PLAN.md`](../docs/REMOTE_SHARING_PLAN.md)。 diff --git a/server/go.mod b/server/go.mod new file mode 100644 index 0000000..e66be03 --- /dev/null +++ b/server/go.mod @@ -0,0 +1,5 @@ +module github.com/rrbe/LocalShare/server + +go 1.23 + +require github.com/coder/websocket v1.8.15 diff --git a/server/go.sum b/server/go.sum new file mode 100644 index 0000000..4e0a48d --- /dev/null +++ b/server/go.sum @@ -0,0 +1,2 @@ +github.com/coder/websocket v1.8.15 h1:6B2JPeOGlpff2Uz6vOEH1Vzpi0iUz20A+lPVhPHtNUA= +github.com/coder/websocket v1.8.15/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg= diff --git a/server/main.go b/server/main.go new file mode 100644 index 0000000..9dd6912 --- /dev/null +++ b/server/main.go @@ -0,0 +1,784 @@ +package main + +import ( + "context" + "crypto/rand" + "crypto/sha256" + "crypto/subtle" + "encoding/base64" + "encoding/binary" + "encoding/json" + "errors" + "flag" + "fmt" + "io" + "log" + "net/http" + "net/url" + "os" + "path/filepath" + "strings" + "sync" + "time" + + "github.com/coder/websocket" +) + +const protocolVersion = 1 +const maxWebSocketMessageSize = 8 << 20 + +type credential struct { + ID string `json:"id"` + Name string `json:"name"` + Hash string `json:"hash"` + Used bool `json:"used"` + CreatedAt time.Time `json:"created_at"` +} + +type device struct { + ID string `json:"id"` + Name string `json:"name"` + TokenHash string `json:"token_hash"` + Revoked bool `json:"revoked"` + CreatedAt time.Time `json:"created_at"` +} + +type state struct { + EnrollmentKeys []credential `json:"enrollment_keys"` + Devices []device `json:"devices"` +} + +type stateStore struct { + path string + mu sync.Mutex +} + +func newStateStore(dir string) (*stateStore, error) { + if err := os.MkdirAll(dir, 0o700); err != nil { + return nil, err + } + return &stateStore{path: filepath.Join(dir, "state.json")}, nil +} + +func (s *stateStore) load() (state, error) { + s.mu.Lock() + defer s.mu.Unlock() + return s.loadLocked() +} + +func (s *stateStore) loadLocked() (state, error) { + data, err := os.ReadFile(s.path) + if errors.Is(err, os.ErrNotExist) { + return state{}, nil + } + if err != nil { + return state{}, err + } + var current state + if err := json.Unmarshal(data, ¤t); err != nil { + return state{}, fmt.Errorf("read state: %w", err) + } + return current, nil +} + +func (s *stateStore) save(current state) error { + s.mu.Lock() + defer s.mu.Unlock() + return s.saveLocked(current) +} + +func (s *stateStore) saveLocked(current state) error { + data, err := json.MarshalIndent(current, "", " ") + if err != nil { + return err + } + tmp := s.path + ".tmp" + if err := os.WriteFile(tmp, data, 0o600); err != nil { + return err + } + return os.Rename(tmp, s.path) +} + +func randomToken(prefix string) (string, error) { + data := make([]byte, 32) + if _, err := rand.Read(data); err != nil { + return "", err + } + return prefix + base64.RawURLEncoding.EncodeToString(data), nil +} + +func tokenHash(value string) string { + hash := sha256.Sum256([]byte(value)) + return base64.RawURLEncoding.EncodeToString(hash[:]) +} + +func (s *stateStore) createEnrollmentKey(name string) (string, string, error) { + raw, err := randomToken("ls_enroll_") + if err != nil { + return "", "", err + } + id, err := randomToken("key_") + if err != nil { + return "", "", err + } + current, err := s.load() + if err != nil { + return "", "", err + } + current.EnrollmentKeys = append(current.EnrollmentKeys, credential{ + ID: id, Name: name, Hash: tokenHash(raw), CreatedAt: time.Now().UTC(), + }) + if err := s.save(current); err != nil { + return "", "", err + } + return id, raw, nil +} + +func (s *stateStore) enroll(raw, name string) (string, string, error) { + s.mu.Lock() + defer s.mu.Unlock() + current, err := s.loadLocked() + if err != nil { + return "", "", err + } + hash := tokenHash(raw) + keyIndex := -1 + for i := range current.EnrollmentKeys { + if subtle.ConstantTimeCompare([]byte(current.EnrollmentKeys[i].Hash), []byte(hash)) == 1 && !current.EnrollmentKeys[i].Used { + keyIndex = i + break + } + } + if keyIndex < 0 { + return "", "", errors.New("invalid or already used enrollment key") + } + deviceID, err := randomToken("device_") + if err != nil { + return "", "", err + } + deviceToken, err := randomToken("ls_device_") + if err != nil { + return "", "", err + } + current.EnrollmentKeys[keyIndex].Used = true + current.Devices = append(current.Devices, device{ + ID: deviceID, Name: name, TokenHash: tokenHash(deviceToken), CreatedAt: time.Now().UTC(), + }) + if err := s.saveLocked(current); err != nil { + return "", "", err + } + return deviceID, deviceToken, nil +} + +func (s *stateStore) deviceForToken(raw string) (device, bool, error) { + current, err := s.load() + if err != nil { + return device{}, false, err + } + hash := tokenHash(raw) + for _, item := range current.Devices { + if subtle.ConstantTimeCompare([]byte(item.TokenHash), []byte(hash)) == 1 { + return item, !item.Revoked, nil + } + } + return device{}, false, nil +} + +func (s *stateStore) revokeDevice(id string) error { + s.mu.Lock() + defer s.mu.Unlock() + current, err := s.loadLocked() + if err != nil { + return err + } + found := false + for i := range current.Devices { + if current.Devices[i].ID == id { + current.Devices[i].Revoked = true + found = true + } + } + if !found { + return errors.New("device not found") + } + return s.saveLocked(current) +} + +type wireMessage struct { + Type string `json:"type"` + Protocol int `json:"protocol,omitempty"` + RequestID string `json:"request_id,omitempty"` + Method string `json:"method,omitempty"` + Path string `json:"path,omitempty"` + Headers map[string]string `json:"headers,omitempty"` + Status int `json:"status,omitempty"` + Error string `json:"error,omitempty"` + ShareURL string `json:"share_url,omitempty"` + DeviceName string `json:"device_name,omitempty"` + Enrollment string `json:"enrollment_key,omitempty"` +} + +type relayMessage struct { + kind string + status int + headers map[string]string + data []byte + err error +} + +type pendingRequest struct { + ch chan relayMessage + done chan struct{} +} + +type agent struct { + hub *hub + device device + conn *websocket.Conn + sendMu sync.Mutex + pendingMu sync.Mutex + pending map[string]*pendingRequest +} + +type share struct { + token string + deviceID string + agent *agent +} + +type hub struct { + store *stateStore + publicURL string + mu sync.Mutex + agents map[string]*agent + shares map[string]*share +} + +func newHub(store *stateStore, publicURL string) *hub { + return &hub{store: store, publicURL: strings.TrimRight(publicURL, "/"), agents: map[string]*agent{}, shares: map[string]*share{}} +} + +func normalizePublicURL(value string) (string, error) { + u, err := url.ParseRequestURI(strings.TrimSpace(value)) + if err != nil || u.Host == "" || (u.Scheme != "http" && u.Scheme != "https") || + u.User != nil || (u.Path != "" && u.Path != "/") || u.RawQuery != "" || u.Fragment != "" { + return "", errors.New("--public-url must be an http(s) base URL without path or query") + } + return strings.TrimRight(u.String(), "/"), nil +} + +func (h *hub) enrollHandler(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + writeJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "method not allowed"}) + return + } + var request wireMessage + if err := json.NewDecoder(io.LimitReader(r.Body, 64<<10)).Decode(&request); err != nil || request.Enrollment == "" { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid enrollment request"}) + return + } + deviceID, deviceToken, err := h.store.enroll(request.Enrollment, request.DeviceName) + if err != nil { + writeJSON(w, http.StatusUnauthorized, map[string]string{"error": err.Error()}) + return + } + writeJSON(w, http.StatusOK, map[string]string{"device_id": deviceID, "device_token": deviceToken}) +} + +func (h *hub) agentHandler(w http.ResponseWriter, r *http.Request) { + token := bearerToken(r.Header.Get("Authorization")) + device, valid, err := h.store.deviceForToken(token) + if err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "state unavailable"}) + return + } + if !valid { + writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "invalid device token"}) + return + } + conn, err := websocket.Accept(w, r, nil) + if err != nil { + return + } + conn.SetReadLimit(maxWebSocketMessageSize) + a := &agent{hub: h, device: device, conn: conn, pending: map[string]*pendingRequest{}} + h.mu.Lock() + if old := h.agents[device.ID]; old != nil { + _ = old.close(websocket.StatusGoingAway, "replaced") + } + h.agents[device.ID] = a + h.mu.Unlock() + defer h.removeAgent(a) + _ = a.send(wireMessage{Type: "hello", Protocol: protocolVersion}) + a.readLoop(r.Context()) +} + +func (h *hub) removeAgent(a *agent) { + h.mu.Lock() + if h.agents[a.device.ID] == a { + delete(h.agents, a.device.ID) + } + for token, item := range h.shares { + if item.agent == a { + delete(h.shares, token) + } + } + h.mu.Unlock() + a.failPending() +} + +func (h *hub) startShare(a *agent) (string, error) { + token, err := randomToken("share_") + if err != nil { + return "", err + } + h.mu.Lock() + for oldToken, item := range h.shares { + if item.agent == a { + delete(h.shares, oldToken) + } + } + h.shares[token] = &share{token: token, deviceID: a.device.ID, agent: a} + h.mu.Unlock() + return h.publicURL + "/share/" + token, nil +} + +func (h *hub) stopShare(a *agent) { + h.mu.Lock() + defer h.mu.Unlock() + for token, item := range h.shares { + if item.agent == a { + delete(h.shares, token) + } + } +} + +func (h *hub) shareHandler(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet && r.Method != http.MethodHead { + w.Header().Set("Allow", "GET, HEAD") + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + pathPart := strings.TrimPrefix(r.URL.EscapedPath(), "/share/") + parts := strings.SplitN(pathPart, "/", 2) + if len(parts) == 0 || parts[0] == "" { + http.NotFound(w, r) + return + } + token := parts[0] + localPath := "/" + if len(parts) == 2 { + localPath += parts[1] + if !strings.HasPrefix(localPath, "/") { + localPath = "/" + localPath + } + } + query := r.URL.Query() + query.Del("t") + if encodedQuery := query.Encode(); encodedQuery != "" { + localPath += "?" + encodedQuery + } + h.mu.Lock() + item := h.shares[token] + h.mu.Unlock() + if item == nil { + http.NotFound(w, r) + return + } + requestID, err := randomToken("req_") + if err != nil { + http.Error(w, "request unavailable", http.StatusInternalServerError) + return + } + messages := item.agent.beginRequest(wireMessage{ + Type: "request.begin", RequestID: requestID, Method: r.Method, Path: localPath, + Headers: forwardedRequestHeaders(r), + }) + defer item.agent.finishRequest(requestID) + started := false + for { + select { + case <-r.Context().Done(): + item.agent.cancelRequest(requestID) + return + case message, ok := <-messages.ch: + if !ok { + if !started { + http.Error(w, "agent unavailable", http.StatusBadGateway) + } + return + } + switch message.kind { + case "headers": + if started { + continue + } + started = true + copyResponseHeaders(w, message.headers, token) + if message.status == 0 { + message.status = http.StatusOK + } + w.WriteHeader(message.status) + case "data": + if !started { + http.Error(w, "invalid agent response", http.StatusBadGateway) + return + } + if _, err := w.Write(message.data); err != nil { + item.agent.cancelRequest(requestID) + return + } + if flusher, ok := w.(http.Flusher); ok { + flusher.Flush() + } + case "error": + if !started { + http.Error(w, message.err.Error(), http.StatusBadGateway) + } + return + case "end": + if !started { + http.Error(w, "invalid agent response", http.StatusBadGateway) + } + return + } + case <-messages.done: + if !started { + http.Error(w, "agent unavailable", http.StatusBadGateway) + } + return + } + } +} + +func (a *agent) readLoop(ctx context.Context) { + for { + typ, payload, err := a.conn.Read(ctx) + if err != nil { + return + } + if typ == websocket.MessageBinary { + id, data, ok := decodeData(payload) + if ok { + a.deliver(id, relayMessage{kind: "data", data: data}) + } + continue + } + var message wireMessage + if json.Unmarshal(payload, &message) != nil { + continue + } + switch message.Type { + case "share.start": + shareURL, err := a.hub.startShare(a) + if err != nil { + _ = a.send(wireMessage{Type: "error", Error: err.Error()}) + } else { + _ = a.send(wireMessage{Type: "share.ready", ShareURL: shareURL}) + } + case "share.stop": + a.hub.stopShare(a) + case "response.begin": + a.deliver(message.RequestID, relayMessage{kind: "headers", status: message.Status, headers: message.Headers}) + case "response.end": + a.deliver(message.RequestID, relayMessage{kind: "end"}) + case "error": + a.deliver(message.RequestID, relayMessage{kind: "error", err: errors.New(message.Error)}) + } + } +} + +func (a *agent) send(message wireMessage) error { + data, err := json.Marshal(message) + if err != nil { + return err + } + a.sendMu.Lock() + defer a.sendMu.Unlock() + return a.conn.Write(context.Background(), websocket.MessageText, data) +} + +func (a *agent) close(code websocket.StatusCode, reason string) error { + return a.conn.Close(code, reason) +} + +func (a *agent) beginRequest(request wireMessage) *pendingRequest { + pending := &pendingRequest{ch: make(chan relayMessage, 1), done: make(chan struct{})} + a.pendingMu.Lock() + a.pending[request.RequestID] = pending + a.pendingMu.Unlock() + if err := a.send(request); err != nil { + a.deliver(request.RequestID, relayMessage{kind: "error", err: err}) + } + return pending +} + +func (a *agent) finishRequest(requestID string) { + a.pendingMu.Lock() + if pending := a.pending[requestID]; pending != nil { + delete(a.pending, requestID) + close(pending.done) + } + a.pendingMu.Unlock() +} + +func (a *agent) cancelRequest(requestID string) { + _ = a.send(wireMessage{Type: "request.cancel", RequestID: requestID}) +} + +func (a *agent) deliver(requestID string, message relayMessage) { + a.pendingMu.Lock() + pending := a.pending[requestID] + a.pendingMu.Unlock() + if pending != nil { + select { + case pending.ch <- message: + case <-pending.done: + } + } +} + +func (a *agent) failPending() { + a.pendingMu.Lock() + defer a.pendingMu.Unlock() + for id, pending := range a.pending { + delete(a.pending, id) + close(pending.done) + } +} + +func forwardedRequestHeaders(r *http.Request) map[string]string { + allowed := []string{"Accept", "Accept-Language", "If-Modified-Since", "If-None-Match", "Range", "User-Agent"} + result := make(map[string]string) + for _, name := range allowed { + if value := r.Header.Get(name); value != "" { + result[name] = value + } + } + return result +} + +func copyResponseHeaders(w http.ResponseWriter, headers map[string]string, shareToken string) { + for name, value := range headers { + lower := strings.ToLower(name) + if lower == "set-cookie" || lower == "connection" || lower == "transfer-encoding" { + continue + } + if strings.EqualFold(name, "Location") { + value = rewriteLocation(value, shareToken) + } + w.Header().Set(name, value) + } +} + +func rewriteLocation(value, shareToken string) string { + if strings.HasPrefix(value, "/") { + return "/share/" + shareToken + value + } + return value +} + +func encodeData(requestID string, data []byte) []byte { + id := []byte(requestID) + frame := make([]byte, 4+len(id)+len(data)) + binary.BigEndian.PutUint32(frame[:4], uint32(len(id))) + copy(frame[4:], id) + copy(frame[4+len(id):], data) + return frame +} + +func decodeData(frame []byte) (string, []byte, bool) { + if len(frame) < 4 { + return "", nil, false + } + idLen := int(binary.BigEndian.Uint32(frame[:4])) + if idLen <= 0 || 4+idLen > len(frame) { + return "", nil, false + } + return string(frame[4 : 4+idLen]), frame[4+idLen:], true +} + +func bearerToken(value string) string { + parts := strings.Fields(value) + if len(parts) == 2 && strings.EqualFold(parts[0], "Bearer") { + return parts[1] + } + return "" +} + +func writeJSON(w http.ResponseWriter, status int, value any) { + w.Header().Set("Content-Type", "application/json") + w.Header().Set("Cache-Control", "no-store") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(value) +} + +func runServe(args []string) error { + flags := flag.NewFlagSet("serve", flag.ContinueOnError) + listen := flags.String("listen", ":8080", "HTTP and WebSocket listen address") + publicURL := flags.String("public-url", "", "public base URL used in share links") + stateDir := flags.String("state-dir", "./localshare-data", "state directory") + tlsCert := flags.String("tls-cert", "", "TLS certificate PEM file (required for an https public URL)") + tlsKey := flags.String("tls-key", "", "TLS private key PEM file (required for an https public URL)") + if err := flags.Parse(args); err != nil { + return err + } + if *publicURL == "" { + return errors.New("--public-url is required") + } + baseURL, err := normalizePublicURL(*publicURL) + if err != nil { + return err + } + useTLS := strings.HasPrefix(baseURL, "https://") + if (*tlsCert == "") != (*tlsKey == "") { + return errors.New("--tls-cert and --tls-key must be provided together") + } + if useTLS && (*tlsCert == "" || *tlsKey == "") { + return errors.New("https --public-url requires --tls-cert and --tls-key") + } + if !useTLS && (*tlsCert != "" || *tlsKey != "") { + return errors.New("--tls-cert and --tls-key require an https --public-url") + } + store, err := newStateStore(*stateDir) + if err != nil { + return err + } + h := newHub(store, baseURL) + server := &http.Server{Addr: *listen, Handler: newHTTPHandler(h), ReadHeaderTimeout: 10 * time.Second} + log.Printf("localshare-server listening on %s, public URL %s", *listen, baseURL) + if useTLS { + return server.ListenAndServeTLS(*tlsCert, *tlsKey) + } + return server.ListenAndServe() +} + +func newHTTPHandler(h *hub) http.Handler { + mux := http.NewServeMux() + mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) { + writeJSON(w, http.StatusOK, map[string]string{"status": "ok"}) + }) + mux.HandleFunc("/api/v1/enroll", h.enrollHandler) + mux.HandleFunc("/api/v1/agent", h.agentHandler) + mux.HandleFunc("/share/", h.shareHandler) + return mux +} + +func runKey(args []string) error { + if len(args) == 0 { + return errors.New("key command requires create, list, or revoke") + } + flags := flag.NewFlagSet("key", flag.ContinueOnError) + stateDir := flags.String("state-dir", "./localshare-data", "state directory") + name := flags.String("name", "", "key label") + if err := flags.Parse(args[1:]); err != nil { + return err + } + store, err := newStateStore(*stateDir) + if err != nil { + return err + } + switch args[0] { + case "create": + id, value, err := store.createEnrollmentKey(*name) + if err != nil { + return err + } + fmt.Printf("id: %s\nkey: %s\n", id, value) + return nil + case "list": + current, err := store.load() + if err != nil { + return err + } + for _, item := range current.EnrollmentKeys { + fmt.Printf("%s\t%s\tused=%t\n", item.ID, item.Name, item.Used) + } + return nil + case "revoke": + if flags.NArg() != 1 { + return errors.New("key revoke requires an id") + } + return revokeEnrollmentKey(store, flags.Arg(0)) + default: + return fmt.Errorf("unknown key command %q", args[0]) + } +} + +func revokeEnrollmentKey(store *stateStore, id string) error { + store.mu.Lock() + defer store.mu.Unlock() + current, err := store.loadLocked() + if err != nil { + return err + } + for i := range current.EnrollmentKeys { + if current.EnrollmentKeys[i].ID == id { + current.EnrollmentKeys[i].Used = true + return store.saveLocked(current) + } + } + return errors.New("key not found") +} + +func runDevice(args []string) error { + if len(args) == 0 || (args[0] != "list" && args[0] != "revoke") || (args[0] == "revoke" && len(args) < 2) { + return errors.New("device command requires list or revoke ") + } + flags := flag.NewFlagSet("device", flag.ContinueOnError) + stateDir := flags.String("state-dir", "./localshare-data", "state directory") + if err := flags.Parse(args[1:]); err != nil { + return err + } + positional := flags.Args() + store, err := newStateStore(*stateDir) + if err != nil { + return err + } + if args[0] == "list" { + if len(positional) != 0 { + return errors.New("device list does not accept an id") + } + current, err := store.load() + if err != nil { + return err + } + for _, item := range current.Devices { + fmt.Printf("%s\t%s\trevoked=%t\n", item.ID, item.Name, item.Revoked) + } + return nil + } + if len(positional) != 1 { + return errors.New("device revoke requires an id") + } + return store.revokeDevice(positional[0]) +} + +func usage() { + fmt.Fprintln(os.Stderr, "usage: localshare-server serve --public-url [--listen :8080] [--state-dir dir] [--tls-cert file --tls-key file]") + fmt.Fprintln(os.Stderr, " localshare-server key create|list|revoke [--name name] [--state-dir dir]") + fmt.Fprintln(os.Stderr, " localshare-server device list|revoke [--state-dir dir] []") +} + +func main() { + if len(os.Args) < 2 { + usage() + os.Exit(2) + } + var err error + switch os.Args[1] { + case "serve": + err = runServe(os.Args[2:]) + case "key": + err = runKey(os.Args[2:]) + case "device": + err = runDevice(os.Args[2:]) + default: + usage() + err = fmt.Errorf("unknown command %q", os.Args[1]) + } + if err != nil && !errors.Is(err, http.ErrServerClosed) { + log.Print(err) + os.Exit(1) + } +} diff --git a/server/main_test.go b/server/main_test.go new file mode 100644 index 0000000..3a3b1be --- /dev/null +++ b/server/main_test.go @@ -0,0 +1,176 @@ +package main + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/coder/websocket" +) + +func TestDataFrameRoundTrip(t *testing.T) { + frame := encodeData("req_123", []byte("hello")) + id, data, ok := decodeData(frame) + if !ok || id != "req_123" || !bytes.Equal(data, []byte("hello")) { + t.Fatalf("round trip failed: %q %q %t", id, data, ok) + } + if _, _, ok := decodeData([]byte{0, 0, 0, 9, 'x'}); ok { + t.Fatal("accepted truncated frame") + } +} + +func TestEnrollmentKeyIsOneTime(t *testing.T) { + store, err := newStateStore(t.TempDir()) + if err != nil { + t.Fatal(err) + } + _, key, err := store.createEnrollmentKey("test") + if err != nil { + t.Fatal(err) + } + if _, _, err := store.enroll(key, "Mac"); err != nil { + t.Fatal(err) + } + if _, _, err := store.enroll(key, "Mac"); err == nil { + t.Fatal("enrollment key was reusable") + } +} + +func TestRewriteLocationKeepsSharePrefix(t *testing.T) { + if got := rewriteLocation("/folder/", "share_123"); got != "/share/share_123/folder/" { + t.Fatalf("got %q", got) + } + if got := rewriteLocation("https://example.com/x", "share_123"); got != "https://example.com/x" { + t.Fatalf("rewrote absolute URL: %q", got) + } +} + +func TestNormalizePublicURLRequiresBaseHTTPURL(t *testing.T) { + if got, err := normalizePublicURL("https://ls.example.com/"); err != nil || got != "https://ls.example.com" { + t.Fatalf("normalized URL = %q, err = %v", got, err) + } + for _, value := range []string{"", "ls.example.com", "https://ls.example.com/path", "https://ls.example.com/?x=1"} { + if _, err := normalizePublicURL(value); err == nil { + t.Fatalf("accepted invalid public URL %q", value) + } + } +} + +func TestPublicShareQueryCannotOverrideLocalToken(t *testing.T) { + req := httptest.NewRequest("GET", "http://server/share/share_1/a.txt?t=attacker&raw=1", nil) + query := req.URL.Query() + query.Del("t") + if got := query.Encode(); got != "raw=1" { + t.Fatalf("forwarded query = %q", got) + } +} + +func TestWebSocketRelayStreamsAgentResponse(t *testing.T) { + store, err := newStateStore(t.TempDir()) + if err != nil { + t.Fatal(err) + } + hub := newHub(store, "") + server := httptest.NewServer(newHTTPHandler(hub)) + defer server.Close() + hub.publicURL = server.URL + + _, enrollmentKey, err := store.createEnrollmentKey("test") + if err != nil { + t.Fatal(err) + } + _, deviceToken, err := store.enroll(enrollmentKey, "Mac") + if err != nil { + t.Fatal(err) + } + conn, _, err := websocket.Dial(context.Background(), server.URL+"/api/v1/agent", &websocket.DialOptions{ + HTTPHeader: http.Header{"Authorization": []string{"Bearer " + deviceToken}}, + }) + if err != nil { + t.Fatal(err) + } + defer conn.Close(websocket.StatusNormalClosure, "done") + + ctx := context.Background() + typ, payload, err := conn.Read(ctx) + if err != nil || typ != websocket.MessageText { + t.Fatalf("hello = %v %q", err, payload) + } + var hello wireMessage + if err := json.Unmarshal(payload, &hello); err != nil || hello.Type != "hello" { + t.Fatalf("invalid hello: %q", payload) + } + if err := conn.Write(ctx, websocket.MessageText, []byte(`{"type":"share.start","protocol":1}`)); err != nil { + t.Fatal(err) + } + _, payload, err = conn.Read(ctx) + if err != nil { + t.Fatal(err) + } + var ready wireMessage + if err := json.Unmarshal(payload, &ready); err != nil || ready.Type != "share.ready" { + t.Fatalf("invalid share response: %q", payload) + } + if !strings.HasPrefix(ready.ShareURL, server.URL+"/share/") { + t.Fatalf("unexpected share URL: %q", ready.ShareURL) + } + + result := make(chan []byte, 1) + errors := make(chan error, 1) + go func() { + response, err := http.Get(ready.ShareURL + "/hello.txt") + if err != nil { + errors <- err + return + } + defer response.Body.Close() + if response.StatusCode != http.StatusOK { + errors <- fmt.Errorf("browser status = %d", response.StatusCode) + return + } + body, err := io.ReadAll(response.Body) + if err != nil { + errors <- err + return + } + result <- body + }() + + _, payload, err = conn.Read(ctx) + if err != nil { + t.Fatal(err) + } + var request wireMessage + if err := json.Unmarshal(payload, &request); err != nil || request.Type != "request.begin" { + t.Fatalf("invalid request: %q", payload) + } + body := bytes.Repeat([]byte("x"), 128<<10) + responseBegin, _ := json.Marshal(wireMessage{ + Type: "response.begin", RequestID: request.RequestID, Status: http.StatusOK, + Headers: map[string]string{"Content-Type": "text/plain", "Content-Length": fmt.Sprint(len(body))}, + }) + if err := conn.Write(ctx, websocket.MessageText, responseBegin); err != nil { + t.Fatal(err) + } + if err := conn.Write(ctx, websocket.MessageBinary, encodeData(request.RequestID, body)); err != nil { + t.Fatal(err) + } + responseEnd, _ := json.Marshal(wireMessage{Type: "response.end", RequestID: request.RequestID}) + if err := conn.Write(ctx, websocket.MessageText, responseEnd); err != nil { + t.Fatal(err) + } + select { + case err := <-errors: + t.Fatal(err) + case got := <-result: + if !bytes.Equal(got, body) { + t.Fatalf("browser body = %q", got) + } + } +} diff --git a/tools/smoke-remote-readonly.sh b/tools/smoke-remote-readonly.sh index dc25611..9a5fd5e 100755 --- a/tools/smoke-remote-readonly.sh +++ b/tools/smoke-remote-readonly.sh @@ -1,5 +1,5 @@ #!/usr/bin/env bash -# 防回归冒烟测:远程模式只读、拒绝 POST、并只在受信任 HTTPS 转发请求上使用 Secure cookie。 +# 防回归冒烟测:远程模式只读,并验证文件流支持 Range。 set -u BIN="${BIN:-.build/debug/LocalShare}" @@ -34,10 +34,11 @@ S=$(curl -s -o /dev/null -w '%{http_code}' -X POST -F 'f=@/etc/hosts' "$base/?t= 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" -echo "── 仅受信任 HTTPS 转发请求的 cookie 带 Secure" -H=$(curl -s -D - -o /dev/null -H 'Accept: text/html' \ - -H 'X-Forwarded-Proto: https' -H 'X-Forwarded-For: 203.0.113.4' "$base/?t=$TOK") -echo "$H" | grep -qi '^Set-Cookie:.*Secure' && ok "Secure cookie" || bad "缺 Secure cookie" +echo "── 远程模式仍可读取并支持 Range" +S=$(curl -s -o /dev/null -w '%{http_code}' "$base/a.txt?t=$TOK") +[ "$S" = "200" ] && ok "读取 200" || bad "读取应 200 实为 $S" +S=$(curl -s -o /dev/null -w '%{http_code}' -H 'Range: bytes=0-1' "$base/a.txt?t=$TOK") +[ "$S" = "206" ] && ok "Range 206" || bad "Range 应 206 实为 $S" echo echo "结果:PASS=$PASS FAIL=$FAIL" From e5b476f259ad640bc23fe3bb6a8d5466ef47d358 Mon Sep 17 00:00:00 2001 From: shawn Date: Wed, 12 Aug 2026 00:06:24 +0800 Subject: [PATCH 4/8] chore: ignore server binary --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index fd0c45d..fe7fad5 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ .build/ .swiftpm/ +/server/localshare-server dist/ *.app .DS_Store From c1877c83c263ff77c7ec6d32fcbc6b9c2f6f9609 Mon Sep 17 00:00:00 2001 From: shawn Date: Wed, 12 Aug 2026 01:02:56 +0800 Subject: [PATCH 5/8] fix: harden remote sharing relay --- .github/workflows/ci.yml | 16 +- AGENTS.md | 3 + Sources/LocalShare/AppState.swift | 39 ++- Sources/LocalShare/Components.swift | 15 + Sources/LocalShare/CsvViewer.swift | 5 +- Sources/LocalShare/DirectoryListing.swift | 46 +-- Sources/LocalShare/FileServer.swift | 30 +- Sources/LocalShare/HeadlessServer.swift | 24 +- Sources/LocalShare/JsonViewer.swift | 5 +- Sources/LocalShare/Lang.swift | 19 ++ Sources/LocalShare/MarkdownViewer.swift | 5 +- Sources/LocalShare/PreviewPage.swift | 7 +- Sources/LocalShare/RemoteAgent.swift | 275 +++++------------- Sources/LocalShare/RemoteLocalRequest.swift | 60 ++++ Sources/LocalShare/RemoteProtocol.swift | 73 +++++ Sources/LocalShare/RemoteSettings.swift | 74 +++++ Sources/LocalShare/SendTextPage.swift | 6 +- Sources/LocalShare/ShareScreen.swift | 5 +- Sources/LocalShare/TextViewer.swift | 5 +- .../LocalShareTests/RemoteSharingTests.swift | 39 +++ docs/ARCHITECTURE.md | 10 + docs/REMOTE_SHARING_PLAN.md | 16 +- server/README.md | 1 + server/main.go | 246 ++++++++++------ server/main_test.go | 263 ++++++++++++++++- tools/smoke-remote-e2e.sh | 80 +++++ tools/smoke-remote-readonly.sh | 9 +- 27 files changed, 1029 insertions(+), 347 deletions(-) create mode 100644 Sources/LocalShare/RemoteLocalRequest.swift create mode 100644 Sources/LocalShare/RemoteProtocol.swift create mode 100644 Sources/LocalShare/RemoteSettings.swift create mode 100755 tools/smoke-remote-e2e.sh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2f81ab6..4791038 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -24,12 +24,17 @@ jobs: go-version: '1.23' - name: 测试 LocalShare Server working-directory: server - run: go test ./... + run: | + go test -race ./... + go vet ./... smoke: runs-on: macos-latest # 与 release 同款 runner,自带 Xcode / Swift 工具链与 Node steps: - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version: '1.23' - name: 编译 debug(首次会拉取 Swifter / Sparkle) run: swift build @@ -65,6 +70,15 @@ jobs: - 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 849fb67..8f71ebf 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/Sources/LocalShare/AppState.swift b/Sources/LocalShare/AppState.swift index 8091c2a..32b041e 100644 --- a/Sources/LocalShare/AppState.swift +++ b/Sources/LocalShare/AppState.swift @@ -88,11 +88,16 @@ final class AppState: ObservableObject { remoteSettings = RemoteSettings(serverAddress: UserDefaults.standard.string(forKey: remoteServerKey) ?? "") remotePaired = remoteAgent.isPaired(for: remoteSettings.serverURL) remoteAgent.onStateChange = { [weak self] status, error in - self?.remoteStatus = status - self?.remoteError = error + guard let self else { return } + self.remoteStatus = status + self.remoteError = error if status == .connected { - self?.remotePaired = self?.remoteAgent.isPaired ?? false - self?.remoteEnrollmentKey = "" + 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 @@ -193,7 +198,7 @@ final class AppState: ObservableObject { selectedInterface = interfaces.first } - var canEnableRemote: Bool { isRunning && !isEmpty && remoteSettings.isValid } + var canEnableRemote: Bool { isRunning && !sharedItems.isEmpty && remoteSettings.isValid } func saveRemoteSettings(_ settings: RemoteSettings, enrollmentKey: String = "") { if remoteAccessEnabled { disconnectRemote() } @@ -208,15 +213,12 @@ final class AppState: ObservableObject { remoteError = L.remoteConfigHint(lang) return } - permission.add = false - textInboxEnabled = false - UserDefaults.standard.set(false, forKey: textInboxKey) remoteAccessEnabled = true remoteError = nil applyRemotePolicy() remoteAgent.connect(serverURL: serverURL, enrollmentKey: enrollmentKey, localBaseURL: localShareBaseURL, localToken: token, - deviceName: Host.current().localizedName ?? "Mac") + deviceName: Host.current().localizedName ?? "Mac", lang: lang) } func disconnectRemote() { @@ -228,9 +230,19 @@ final class AppState: ObservableObject { } func forgetRemoteDevice() { - disconnectRemote() - remoteAgent.forgetDevice() - remotePaired = false + 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 { @@ -245,12 +257,13 @@ final class AppState: ObservableObject { 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") + localToken: token, deviceName: Host.current().localizedName ?? "Mac", lang: lang) } private func startNetworkMonitoring() { diff --git a/Sources/LocalShare/Components.swift b/Sources/LocalShare/Components.swift index e498cda..82b55a3 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 760a1f8..bb951a3 100644 --- a/Sources/LocalShare/DirectoryListing.swift +++ b/Sources/LocalShare/DirectoryListing.swift @@ -8,7 +8,8 @@ import UniformTypeIdentifiers // 文件夹始终分组在前;③ 类型 chips 真实过滤;④ 计数随过滤显示 N / total。无 emoji、无彩色填充图标。 // 导航:非根列表首行固定「返回上一级」(.row.back,不参与搜索/排序/过滤,空目录也保留); // 目录行原地进入,文件行新标签打开(target=_blank,与行尾外开箭头图标一致,列表不丢)。 -// 只用系统字体栈 + 内联原生 JS,零外部依赖、局域网离线可渲染。href 用绝对路径并逐段编码。 +// 只用系统字体栈 + 内联原生 JS,零外部依赖、局域网离线可渲染。href 使用相对路径并逐段编码, +// 同时兼容 LAN 根路径与远程 `/share//` 挂载前缀。 enum DirectoryListing { private static let dateFmt: DateFormatter = { let f = DateFormatter() @@ -34,14 +35,13 @@ 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, + return render(entries: entries, requestPath: requestPath, rootName: rootName, canUpload: canUpload, canReceiveText: canReceiveText, 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, @@ -50,14 +50,14 @@ enum DirectoryListing { if a.isDir != b.isDir { return a.isDir } return a.name.localizedStandardCompare(b.name) == .orderedAscending } - return render(entries: sorted, base: "/", requestPath: "/", rootName: rootName, + return render(entries: sorted, requestPath: "/", rootName: rootName, canUpload: false, canReceiveText: canReceiveText, 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, + requestPath: String, rootName: String, canUpload: Bool, canReceiveText: Bool, textPreview: String?, lang: Lang) -> String { let fm = FileManager.default var rows = "" @@ -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 { @@ -88,7 +88,7 @@ enum DirectoryListing { 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) + 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,15 +146,14 @@ 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 @@ -162,7 +161,7 @@ enum DirectoryListing { 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 { + 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)) · \(htmlText(ps.tag))
    \(dropMask) - + + diff --git a/Sources/LocalShare/RemoteAgent.swift b/Sources/LocalShare/RemoteAgent.swift index 782a17a..dbe61ae 100644 --- a/Sources/LocalShare/RemoteAgent.swift +++ b/Sources/LocalShare/RemoteAgent.swift @@ -1,67 +1,4 @@ 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 deviceID = "device-id" - static let serverAddress = "server-address" - - 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) { - 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] - if SecItemUpdate(query as CFDictionary, attributes as CFDictionary) != errSecSuccess { - var item = query - item[kSecValueData as String] = data - _ = SecItemAdd(item as CFDictionary, nil) - } - } - - static func removeAll() { - let query: [String: Any] = [ - kSecClass as String: kSecClassGenericPassword, - kSecAttrService as String: service, - ] - _ = SecItemDelete(query as CFDictionary) - } -} @MainActor final class RemoteAgent: NSObject { @@ -74,46 +11,6 @@ final class RemoteAgent: NSObject { case reconnecting } - private struct Message: 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 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 deviceName = "device_name" - case enrollmentKey = "enrollment_key" - } - } - - private struct EnrollmentResponse: Decodable { - let deviceID: String - let deviceToken: String - - enum CodingKeys: String, CodingKey { - case deviceID = "device_id" - case deviceToken = "device_token" - } - } - - private enum LocalEvent { - case headers(Int, [String: String]) - case data(Data) - case finished(Error?) - } - private var serverURL: URL? private var localBaseURL: URL? private var localToken = "" @@ -125,7 +22,9 @@ final class RemoteAgent: NSObject { private var heartbeatTask: Task? private var webSocket: URLSessionWebSocketTask? private var session: URLSession? - private var localRequests: [String: LocalRequest] = [:] + 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? @@ -142,8 +41,10 @@ final class RemoteAgent: NSObject { return UserDefaults.standard.string(forKey: Self.pairedServerKey) == serverURL.absoluteString } - func connect(serverURL: URL, enrollmentKey: String, localBaseURL: URL, localToken: String, deviceName: String) { + 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 @@ -158,15 +59,22 @@ final class RemoteAgent: NSObject { if !enrollmentKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { let credentials = try await Self.enroll(serverURL: serverURL, key: enrollmentKey.trimmingCharacters(in: .whitespacesAndNewlines), - deviceName: deviceName) - RemoteKeychain.set(credentials.deviceToken, for: RemoteKeychain.deviceToken) + 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 - UserDefaults.standard.set(serverURL.absoluteString, forKey: Self.pairedServerKey) + if persistCredential { + UserDefaults.standard.set(serverURL.absoluteString, forKey: Self.pairedServerKey) + } } guard cachedDeviceToken(for: serverURL) != nil else { - throw NSError(domain: "LocalShare.Remote", code: 1, - userInfo: [NSLocalizedDescriptionKey: "Enter an enrollment key first."]) + throw RemoteDisplayError(message: L.remoteEnrollmentRequired(lang)) } self.openSocket() } catch { @@ -188,9 +96,9 @@ final class RemoteAgent: NSObject { setStatus(.disconnected, error: nil) } - func forgetDevice() { + func forgetDevice() throws { disconnect() - RemoteKeychain.removeAll() + try RemoteKeychain.removeAll() cachedDeviceToken = nil cachedCredentialServer = nil UserDefaults.standard.removeObject(forKey: Self.pairedServerKey) @@ -200,14 +108,15 @@ final class RemoteAgent: NSObject { localBaseURL = baseURL localToken = token guard status == .connected else { return } - send(Message(type: "share.start", protocolVersion: 1, deviceName: nil)) + 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 { - setStatus(.disconnected, error: "Invalid server address or device credentials.") + shouldConnect = false + setStatus(.disconnected, error: L.remoteInvalidCredentials(lang)) return } let configuration = URLSessionConfiguration.ephemeral @@ -262,7 +171,8 @@ final class RemoteAgent: NSObject { private func handle(_ message: URLSessionWebSocketTask.Message) { switch message { case .string(let raw): - guard let data = raw.data(using: .utf8), let message = try? JSONDecoder().decode(Message.self, from: data) else { return } + 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. @@ -272,7 +182,7 @@ final class RemoteAgent: NSObject { } } - private func handle(_ message: Message) { + private func handle(_ message: RemoteWireMessage) { switch message.type { case "hello": if let url = shareBaseURL { onShareURLChange?(url) } @@ -281,25 +191,41 @@ final class RemoteAgent: NSObject { onShareURLChange?(message.shareURL) case "request.begin": guard let id = message.requestID, let method = message.method, let path = message.path else { return } - startLocalRequest(id: id, method: method, path: path, headers: message.headers ?? [:]) + 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 = message.error + lastError = L.remoteServerError(lang) onStateChange?(status, lastError) default: break } } - private func startLocalRequest(id: String, method: String, path: String, headers: [String: String]) { + 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(Message(type: "error", requestID: id, error: "Local share is not running.")) + send(RemoteWireMessage(type: "error", requestID: id, error: L.remoteLocalUnavailable(requestLang))) return } let separator = path.contains("?") ? "&" : "?" - guard let url = URL(string: base.absoluteString + path + separator + "t=" + localToken) else { - send(Message(type: "error", requestID: id, error: "Invalid local request path.")) + 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) @@ -307,40 +233,46 @@ final class RemoteAgent: NSObject { 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 = LocalRequest(id: id, request: request) { [weak self] event in + 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: LocalEvent) { + private func handleLocalEvent(id: String, event: RemoteLocalEvent) { guard localRequests[id] != nil else { return } switch event { case .headers(let status, let headers): - send(Message(type: "response.begin", requestID: id, headers: headers, status: status)) + 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 { - send(Message(type: "error", requestID: id, error: error.localizedDescription)) + let requestLang = localRequestLanguages[id] ?? lang + send(RemoteWireMessage(type: "error", requestID: id, + error: LStr.remoteRequestFailed(error.localizedDescription, requestLang))) } else { - send(Message(type: "response.end", requestID: id)) + 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(encodeData(requestID, data))) { [weak self] error in - if let error { - Task { @MainActor [weak self] in self?.connectionEnded(error, from: webSocket) } + 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: Message) { + 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 { @@ -351,11 +283,13 @@ final class RemoteAgent: NSObject { 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) { @@ -402,78 +336,24 @@ final class RemoteAgent: NSObject { onStateChange?(value, error) } - private static func enroll(serverURL: URL, key: String, deviceName: String) async throws -> EnrollmentResponse { + 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(Message(type: "enroll", deviceName: deviceName, enrollmentKey: key)) + 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 NSError(domain: "LocalShare.Remote", code: (response as? HTTPURLResponse)?.statusCode ?? 0, - userInfo: [NSLocalizedDescriptionKey: "Enrollment was rejected by the server."]) + throw RemoteDisplayError(message: L.remoteEnrollmentRejected(lang)) } - return try JSONDecoder().decode(EnrollmentResponse.self, from: data) + return try JSONDecoder().decode(RemoteEnrollmentResponse.self, from: data) } static func encodeData(_ 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 - } - - private func encodeData(_ id: String, _ data: Data) -> Data { Self.encodeData(id, data) } - - private final class LocalRequest: NSObject, @unchecked Sendable, URLSessionDataDelegate, URLSessionTaskDelegate { - let id: String - private let request: URLRequest - private let emit: (LocalEvent) -> Void - private var session: URLSession? - private var task: URLSessionDataTask? - - init(id: String, request: URLRequest, emit: @escaping (LocalEvent) -> Void) { - self.id = id - self.request = request - self.emit = emit - } - - func start() { - let session = URLSession(configuration: .ephemeral, delegate: self, delegateQueue: nil) - self.session = session - let task = session.dataTask(with: request) - self.task = task - task.resume() - } - - func cancel() { task?.cancel(); invalidate() } - func invalidate() { session?.invalidateAndCancel(); session = nil; task = 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) { - emit(.data(data)) - } - - func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) { - emit(.finished(error)) - } + RemoteFrameCodec.encode(id, data) } } @@ -484,9 +364,12 @@ extension RemoteAgent: URLSessionWebSocketDelegate { guard let self, self.webSocket === webSocketTask else { return } self.retryDelay = 1 self.setStatus(.connected, error: nil) - self.send(Message(type: "hello", protocolVersion: 1, - deviceName: Host.current().localizedName ?? "Mac")) - if self.localBaseURL != nil { self.send(Message(type: "share.start", protocolVersion: 1)) } + 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 { 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 f066ca4..3c47ae9 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); @@ -192,9 +192,9 @@ enum SendText { \(card(lang: lang, withHead: false))
    \(L.webProvidedBy(lang))
    - + diff --git a/Sources/LocalShare/ShareScreen.swift b/Sources/LocalShare/ShareScreen.swift index 6c0ac8e..81f890a 100644 --- a/Sources/LocalShare/ShareScreen.swift +++ b/Sources/LocalShare/ShareScreen.swift @@ -54,7 +54,7 @@ struct ShareScreen: View { private var remoteCard: some View { VStack(alignment: .leading, spacing: 10) { HStack(spacing: 10) { - Image(systemName: "globe").font(.system(size: 17, weight: .medium)).foregroundStyle(t.accent) + GlobeLineIcon(color: t.accent) VStack(alignment: .leading, spacing: 2) { Text(L.remoteAccess(state.lang)).font(.sans(13.5, .semibold)).foregroundStyle(t.ink) Text(L.remoteAccessDesc(state.lang)).font(.sans(11.5)).foregroundStyle(t.inkMute) @@ -81,9 +81,6 @@ struct ShareScreen: View { .frame(maxWidth: .infinity, alignment: .leading) Circle().fill(remoteStatusColor).frame(width: 7, height: 7) Text(remoteStatusText).font(.sans(11.5, .semibold)).foregroundStyle(t.inkMute) - if state.remoteStatus == .reconnecting { - Text(L.remoteOffline(state.lang)).font(.sans(11)).foregroundStyle(t.inkMute) - } } if let error = state.remoteError, !error.isEmpty { Text(error).font(.sans(11)).foregroundStyle(t.danger).lineLimit(2) diff --git a/Sources/LocalShare/TextViewer.swift b/Sources/LocalShare/TextViewer.swift index cec7a0d..1adeafc 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/Tests/LocalShareTests/RemoteSharingTests.swift b/Tests/LocalShareTests/RemoteSharingTests.swift
    index 1765c80..073259e 100644
    --- a/Tests/LocalShareTests/RemoteSharingTests.swift
    +++ b/Tests/LocalShareTests/RemoteSharingTests.swift
    @@ -46,4 +46,43 @@ final class RemoteSharingTests: XCTestCase {
             XCTAssertNil(FileServer.byteRange("bytes=20-", size: 10))
             XCTAssertNil(FileServer.byteRange("bytes=0-1,4-5", size: 10))
         }
    +
    +    func testShareIDGateRejectsRequestsFromPreviousRegistration() {
    +        XCTAssertTrue(RemoteShareGate.accepts("token-new", currentToken: "token-new"))
    +        XCTAssertFalse(RemoteShareGate.accepts("token-old", currentToken: "token-new"))
    +        XCTAssertFalse(RemoteShareGate.accepts(nil, currentToken: "token-new"))
    +    }
    +
    +    func testWireProtocolCarriesShareID() throws {
    +        let raw = try JSONEncoder().encode(RemoteWireMessage(type: "share.start", protocolVersion: 1,
    +                                                              shareID: "token-new"))
    +        let decoded = try JSONDecoder().decode(RemoteWireMessage.self, from: raw)
    +        XCTAssertEqual(decoded.shareID, "token-new")
    +        XCTAssertTrue(String(decoding: raw, as: UTF8.self).contains("\"share_id\":\"token-new\""))
    +    }
    +
    +    func testOnlyAuthenticationCleanupRedirectIsFollowedLocally() {
    +        let auth = HTTPURLResponse(url: URL(string: "http://127.0.0.1/")!, statusCode: 302,
    +                                   httpVersion: nil, headerFields: ["Set-Cookie": "ls_token=x; Path=/"])!
    +        let directory = HTTPURLResponse(url: URL(string: "http://127.0.0.1/folder")!, statusCode: 301,
    +                                        httpVersion: nil, headerFields: ["Location": "/folder/"])!
    +        XCTAssertTrue(RemoteRedirectPolicy.shouldFollow(auth))
    +        XCTAssertFalse(RemoteRedirectPolicy.shouldFollow(directory))
    +    }
    +
    +    func testGeneratedPagesKeepNavigationInsideMountedSharePrefix() {
    +        let file = URL(fileURLWithPath: "/tmp/report.txt")
    +        let root = DirectoryListing.html(items: [(name: "report.txt", url: file, isDir: false)],
    +                                         rootName: "Shared", textPreview: "hello", lang: .en)
    +        XCTAssertTrue(root.contains("href=\"report.txt\""))
    +        XCTAssertTrue(root.contains("href=\"ls/text\""))
    +        XCTAssertTrue(root.contains("var LS_ROOT=\"\""))
    +        XCTAssertFalse(root.contains("href=\"/report.txt\""))
    +        XCTAssertFalse(root.contains("fetch('/ls/ping"))
    +
    +        let preview = MarkdownViewer.html(fileName: "readme.md", requestPath: "/docs/readme.md",
    +                                          crumbs: nil, canUpload: false, lang: .en)
    +        XCTAssertTrue(preview.contains("var LS_ROOT=\"../\""))
    +        XCTAssertTrue(preview.contains("fetch(LS_ROOT+'ls/ping'"))
    +    }
     }
    diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md
    index 38a2d6c..57c3cb9 100644
    --- a/docs/ARCHITECTURE.md
    +++ b/docs/ARCHITECTURE.md
    @@ -133,6 +133,16 @@ Remote settings persist the Server address. The first connection also accepts a
     
     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`.
    diff --git a/docs/REMOTE_SHARING_PLAN.md b/docs/REMOTE_SHARING_PLAN.md
    index 76eb689..949dc83 100644
    --- a/docs/REMOTE_SHARING_PLAN.md
    +++ b/docs/REMOTE_SHARING_PLAN.md
    @@ -103,7 +103,13 @@ 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 实现
     
    @@ -120,7 +126,9 @@ HTTP 路由:
     
     Server 内存中保存当前连接和分享注册;重启后设备凭据仍从 `state.json` 恢复,但 Client 需要重新连接,旧的 Share Token 失效。
     
    -请求必须有超时和取消传播。浏览器断开时,Server 发送 `request.cancel`,Client 取消对应的本机 `URLSessionDataTask`,避免大文件继续占用带宽。
    +请求使用 60 秒无数据空闲超时;每收到响应头或数据块都会续期。浏览器断开或超时时,Server 发送
    +`request.cancel`,Client 取消对应的本机 `URLSessionDataTask`,避免大文件继续占用带宽。
    +控制消息的 WebSocket 写入另有 10 秒超时,避免失活连接卡住 HTTP handler。
     
     ## 5. Client 实现
     
    @@ -130,7 +138,8 @@ Server 内存中保存当前连接和分享注册;重启后设备凭据仍从
     - Device Token 仅从 Keychain 读取;
     - `share.start` 时把本机 FileServer 地址和当前 token 作为内部转发目标;
     - Server 下发请求后,只允许白名单请求头进入本机 FileServer;
    -- 从本机响应按块发送数据,不把完整文件读入内存;
    +- 从本机响应按块发送数据;每个请求只允许一个尚未确认发送的分块,WebSocket 完成该分块后才恢复
    +  `URLSessionDataTask`,形成有界背压,不把完整文件排队进内存;
     - 断线清理所有本地请求,重新连接后重新注册当前分享;
     - 分享内容变化时更新本机目标并重新注册,Server 生成新的 Share Token。
     
    @@ -145,6 +154,7 @@ AppState 继续作为唯一状态源:远程状态、公开 URL 和本地只读
     - Server 不信任浏览器传来的代理身份头,也不把设备 token 转发给浏览器;
     - Server 不记录文件内容;默认日志不打印 Enrollment Key、Device Token、Share Token;
     - Share Token 随分享注册变化或停止立即失效;
    +- 即使旧 Share Token 的删除消息尚在传输,`share_id` 代次校验也会阻止它读取新分享;
     - WebSocket 是 Client 主动发起的出站长连接,Server 不需要访问 Mac 的监听端口。
     
     ## 7. 交付阶段
    @@ -153,7 +163,7 @@ AppState 继续作为唯一状态源:远程状态、公开 URL 和本地只读
     2. 实现同端口 WebSocket Agent 与浏览器 Data Relay,先支持只读 GET/HEAD 和流式数据。
     3. 在 Client 增加 Keychain 配对、Connect/Disconnect、断线重连和远程只读策略。
     4. 增加 Range、取消、旧 token 失效、设备撤销和连接状态测试。
    -5. 更新 CI、README、架构文档,并做本地 Server + headless Client 的端到端冒烟测试。
    +5. 更新 CI、README、架构文档,并在 debug/release 下运行本地 Server + headless Client 的端到端冒烟测试。
     
     ## 8. 明确放弃的方案
     
    diff --git a/server/README.md b/server/README.md
    index ecab40a..c4fb905 100644
    --- a/server/README.md
    +++ b/server/README.md
    @@ -171,6 +171,7 @@ localshare-server device revoke --state-dir /var/lib/localshare 
     ```
     
     Server 只在 `state.json` 中保存 Enrollment Key 和 Device Token 的 SHA-256,不保存明文 token。Enrollment Key、Device Token 和 Share Token 都不应写入公开日志或提交到代码仓库。
    +运行中的 Server 与管理 CLI 通过状态目录里的 `.state.lock` 串行化修改,避免创建 Key、配对和撤销并发时丢更新;目录权限会收敛为 `0700`,`state.json` 与锁文件为 `0600`。
     
     ## 六、排查顺序
     
    diff --git a/server/main.go b/server/main.go
    index 9dd6912..bda75ab 100644
    --- a/server/main.go
    +++ b/server/main.go
    @@ -19,6 +19,7 @@ import (
     	"path/filepath"
     	"strings"
     	"sync"
    +	"syscall"
     	"time"
     
     	"github.com/coder/websocket"
    @@ -26,6 +27,8 @@ import (
     
     const protocolVersion = 1
     const maxWebSocketMessageSize = 8 << 20
    +const defaultRelayIdleTimeout = 60 * time.Second
    +const webSocketWriteTimeout = 10 * time.Second
     
     type credential struct {
     	ID        string    `json:"id"`
    @@ -49,24 +52,35 @@ type state struct {
     }
     
     type stateStore struct {
    -	path string
    -	mu   sync.Mutex
    +	path     string
    +	lockPath string
    +	mu       sync.Mutex
     }
     
     func newStateStore(dir string) (*stateStore, error) {
     	if err := os.MkdirAll(dir, 0o700); err != nil {
     		return nil, err
     	}
    -	return &stateStore{path: filepath.Join(dir, "state.json")}, nil
    +	if err := os.Chmod(dir, 0o700); err != nil {
    +		return nil, err
    +	}
    +	return &stateStore{
    +		path:     filepath.Join(dir, "state.json"),
    +		lockPath: filepath.Join(dir, ".state.lock"),
    +	}, nil
     }
     
     func (s *stateStore) load() (state, error) {
    -	s.mu.Lock()
    -	defer s.mu.Unlock()
    -	return s.loadLocked()
    +	var current state
    +	err := s.withFileLock(false, func() error {
    +		var err error
    +		current, err = s.loadUnlocked()
    +		return err
    +	})
    +	return current, err
     }
     
    -func (s *stateStore) loadLocked() (state, error) {
    +func (s *stateStore) loadUnlocked() (state, error) {
     	data, err := os.ReadFile(s.path)
     	if errors.Is(err, os.ErrNotExist) {
     		return state{}, nil
    @@ -81,24 +95,69 @@ func (s *stateStore) loadLocked() (state, error) {
     	return current, nil
     }
     
    -func (s *stateStore) save(current state) error {
    -	s.mu.Lock()
    -	defer s.mu.Unlock()
    -	return s.saveLocked(current)
    -}
    -
    -func (s *stateStore) saveLocked(current state) error {
    +func (s *stateStore) saveUnlocked(current state) error {
     	data, err := json.MarshalIndent(current, "", "  ")
     	if err != nil {
     		return err
     	}
     	tmp := s.path + ".tmp"
    -	if err := os.WriteFile(tmp, data, 0o600); err != nil {
    +	file, err := os.OpenFile(tmp, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o600)
    +	if err != nil {
    +		return err
    +	}
    +	if err := file.Chmod(0o600); err != nil {
    +		_ = file.Close()
    +		return err
    +	}
    +	if _, err := file.Write(data); err != nil {
    +		_ = file.Close()
    +		return err
    +	}
    +	if err := file.Sync(); err != nil {
    +		_ = file.Close()
    +		return err
    +	}
    +	if err := file.Close(); err != nil {
     		return err
     	}
     	return os.Rename(tmp, s.path)
     }
     
    +func (s *stateStore) withFileLock(exclusive bool, work func() error) error {
    +	s.mu.Lock()
    +	defer s.mu.Unlock()
    +	lockFile, err := os.OpenFile(s.lockPath, os.O_CREATE|os.O_RDWR, 0o600)
    +	if err != nil {
    +		return err
    +	}
    +	defer lockFile.Close()
    +	if err := lockFile.Chmod(0o600); err != nil {
    +		return err
    +	}
    +	mode := syscall.LOCK_SH
    +	if exclusive {
    +		mode = syscall.LOCK_EX
    +	}
    +	if err := syscall.Flock(int(lockFile.Fd()), mode); err != nil {
    +		return err
    +	}
    +	defer syscall.Flock(int(lockFile.Fd()), syscall.LOCK_UN)
    +	return work()
    +}
    +
    +func (s *stateStore) update(work func(*state) error) error {
    +	return s.withFileLock(true, func() error {
    +		current, err := s.loadUnlocked()
    +		if err != nil {
    +			return err
    +		}
    +		if err := work(¤t); err != nil {
    +			return err
    +		}
    +		return s.saveUnlocked(current)
    +	})
    +}
    +
     func randomToken(prefix string) (string, error) {
     	data := make([]byte, 32)
     	if _, err := rand.Read(data); err != nil {
    @@ -121,37 +180,18 @@ func (s *stateStore) createEnrollmentKey(name string) (string, string, error) {
     	if err != nil {
     		return "", "", err
     	}
    -	current, err := s.load()
    -	if err != nil {
    -		return "", "", err
    -	}
    -	current.EnrollmentKeys = append(current.EnrollmentKeys, credential{
    -		ID: id, Name: name, Hash: tokenHash(raw), CreatedAt: time.Now().UTC(),
    -	})
    -	if err := s.save(current); err != nil {
    +	if err := s.update(func(current *state) error {
    +		current.EnrollmentKeys = append(current.EnrollmentKeys, credential{
    +			ID: id, Name: name, Hash: tokenHash(raw), CreatedAt: time.Now().UTC(),
    +		})
    +		return nil
    +	}); err != nil {
     		return "", "", err
     	}
     	return id, raw, nil
     }
     
     func (s *stateStore) enroll(raw, name string) (string, string, error) {
    -	s.mu.Lock()
    -	defer s.mu.Unlock()
    -	current, err := s.loadLocked()
    -	if err != nil {
    -		return "", "", err
    -	}
    -	hash := tokenHash(raw)
    -	keyIndex := -1
    -	for i := range current.EnrollmentKeys {
    -		if subtle.ConstantTimeCompare([]byte(current.EnrollmentKeys[i].Hash), []byte(hash)) == 1 && !current.EnrollmentKeys[i].Used {
    -			keyIndex = i
    -			break
    -		}
    -	}
    -	if keyIndex < 0 {
    -		return "", "", errors.New("invalid or already used enrollment key")
    -	}
     	deviceID, err := randomToken("device_")
     	if err != nil {
     		return "", "", err
    @@ -160,11 +200,24 @@ func (s *stateStore) enroll(raw, name string) (string, string, error) {
     	if err != nil {
     		return "", "", err
     	}
    -	current.EnrollmentKeys[keyIndex].Used = true
    -	current.Devices = append(current.Devices, device{
    -		ID: deviceID, Name: name, TokenHash: tokenHash(deviceToken), CreatedAt: time.Now().UTC(),
    -	})
    -	if err := s.saveLocked(current); err != nil {
    +	hash := tokenHash(raw)
    +	if err := s.update(func(current *state) error {
    +		keyIndex := -1
    +		for i := range current.EnrollmentKeys {
    +			if subtle.ConstantTimeCompare([]byte(current.EnrollmentKeys[i].Hash), []byte(hash)) == 1 && !current.EnrollmentKeys[i].Used {
    +				keyIndex = i
    +				break
    +			}
    +		}
    +		if keyIndex < 0 {
    +			return errors.New("invalid or already used enrollment key")
    +		}
    +		current.EnrollmentKeys[keyIndex].Used = true
    +		current.Devices = append(current.Devices, device{
    +			ID: deviceID, Name: name, TokenHash: tokenHash(deviceToken), CreatedAt: time.Now().UTC(),
    +		})
    +		return nil
    +	}); err != nil {
     		return "", "", err
     	}
     	return deviceID, deviceToken, nil
    @@ -185,23 +238,15 @@ func (s *stateStore) deviceForToken(raw string) (device, bool, error) {
     }
     
     func (s *stateStore) revokeDevice(id string) error {
    -	s.mu.Lock()
    -	defer s.mu.Unlock()
    -	current, err := s.loadLocked()
    -	if err != nil {
    -		return err
    -	}
    -	found := false
    -	for i := range current.Devices {
    -		if current.Devices[i].ID == id {
    -			current.Devices[i].Revoked = true
    -			found = true
    +	return s.update(func(current *state) error {
    +		for i := range current.Devices {
    +			if current.Devices[i].ID == id {
    +				current.Devices[i].Revoked = true
    +				return nil
    +			}
     		}
    -	}
    -	if !found {
     		return errors.New("device not found")
    -	}
    -	return s.saveLocked(current)
    +	})
     }
     
     type wireMessage struct {
    @@ -214,6 +259,7 @@ type wireMessage struct {
     	Status     int               `json:"status,omitempty"`
     	Error      string            `json:"error,omitempty"`
     	ShareURL   string            `json:"share_url,omitempty"`
    +	ShareID    string            `json:"share_id,omitempty"`
     	DeviceName string            `json:"device_name,omitempty"`
     	Enrollment string            `json:"enrollment_key,omitempty"`
     }
    @@ -242,20 +288,25 @@ type agent struct {
     
     type share struct {
     	token    string
    +	shareID  string
     	deviceID string
     	agent    *agent
     }
     
     type hub struct {
    -	store     *stateStore
    -	publicURL string
    -	mu        sync.Mutex
    -	agents    map[string]*agent
    -	shares    map[string]*share
    +	store              *stateStore
    +	publicURL          string
    +	mu                 sync.Mutex
    +	agents             map[string]*agent
    +	shares             map[string]*share
    +	requestIdleTimeout time.Duration
     }
     
     func newHub(store *stateStore, publicURL string) *hub {
    -	return &hub{store: store, publicURL: strings.TrimRight(publicURL, "/"), agents: map[string]*agent{}, shares: map[string]*share{}}
    +	return &hub{
    +		store: store, publicURL: strings.TrimRight(publicURL, "/"), agents: map[string]*agent{},
    +		shares: map[string]*share{}, requestIdleTimeout: defaultRelayIdleTimeout,
    +	}
     }
     
     func normalizePublicURL(value string) (string, error) {
    @@ -327,7 +378,10 @@ func (h *hub) removeAgent(a *agent) {
     	a.failPending()
     }
     
    -func (h *hub) startShare(a *agent) (string, error) {
    +func (h *hub) startShare(a *agent, shareID string) (string, error) {
    +	if shareID == "" {
    +		return "", errors.New("share id is required")
    +	}
     	token, err := randomToken("share_")
     	if err != nil {
     		return "", err
    @@ -338,9 +392,9 @@ func (h *hub) startShare(a *agent) (string, error) {
     			delete(h.shares, oldToken)
     		}
     	}
    -	h.shares[token] = &share{token: token, deviceID: a.device.ID, agent: a}
    +	h.shares[token] = &share{token: token, shareID: shareID, deviceID: a.device.ID, agent: a}
     	h.mu.Unlock()
    -	return h.publicURL + "/share/" + token, nil
    +	return h.publicURL + "/share/" + token + "/", nil
     }
     
     func (h *hub) stopShare(a *agent) {
    @@ -366,6 +420,14 @@ func (h *hub) shareHandler(w http.ResponseWriter, r *http.Request) {
     		return
     	}
     	token := parts[0]
    +	if len(parts) == 1 {
    +		target := r.URL.EscapedPath() + "/"
    +		if r.URL.RawQuery != "" {
    +			target += "?" + r.URL.RawQuery
    +		}
    +		http.Redirect(w, r, target, http.StatusPermanentRedirect)
    +		return
    +	}
     	localPath := "/"
     	if len(parts) == 2 {
     		localPath += parts[1]
    @@ -392,9 +454,20 @@ func (h *hub) shareHandler(w http.ResponseWriter, r *http.Request) {
     	}
     	messages := item.agent.beginRequest(wireMessage{
     		Type: "request.begin", RequestID: requestID, Method: r.Method, Path: localPath,
    -		Headers: forwardedRequestHeaders(r),
    +		Headers: forwardedRequestHeaders(r), ShareID: item.shareID,
     	})
     	defer item.agent.finishRequest(requestID)
    +	idleTimer := time.NewTimer(h.requestIdleTimeout)
    +	defer idleTimer.Stop()
    +	resetIdleTimer := func() {
    +		if !idleTimer.Stop() {
    +			select {
    +			case <-idleTimer.C:
    +			default:
    +			}
    +		}
    +		idleTimer.Reset(h.requestIdleTimeout)
    +	}
     	started := false
     	for {
     		select {
    @@ -408,6 +481,7 @@ func (h *hub) shareHandler(w http.ResponseWriter, r *http.Request) {
     				}
     				return
     			}
    +			resetIdleTimer()
     			switch message.kind {
     			case "headers":
     				if started {
    @@ -447,6 +521,12 @@ func (h *hub) shareHandler(w http.ResponseWriter, r *http.Request) {
     				http.Error(w, "agent unavailable", http.StatusBadGateway)
     			}
     			return
    +		case <-idleTimer.C:
    +			item.agent.cancelRequest(requestID)
    +			if !started {
    +				w.WriteHeader(http.StatusGatewayTimeout)
    +			}
    +			return
     		}
     	}
     }
    @@ -470,7 +550,7 @@ func (a *agent) readLoop(ctx context.Context) {
     		}
     		switch message.Type {
     		case "share.start":
    -			shareURL, err := a.hub.startShare(a)
    +			shareURL, err := a.hub.startShare(a, message.ShareID)
     			if err != nil {
     				_ = a.send(wireMessage{Type: "error", Error: err.Error()})
     			} else {
    @@ -495,7 +575,9 @@ func (a *agent) send(message wireMessage) error {
     	}
     	a.sendMu.Lock()
     	defer a.sendMu.Unlock()
    -	return a.conn.Write(context.Background(), websocket.MessageText, data)
    +	ctx, cancel := context.WithTimeout(context.Background(), webSocketWriteTimeout)
    +	defer cancel()
    +	return a.conn.Write(ctx, websocket.MessageText, data)
     }
     
     func (a *agent) close(code websocket.StatusCode, reason string) error {
    @@ -706,19 +788,15 @@ func runKey(args []string) error {
     }
     
     func revokeEnrollmentKey(store *stateStore, id string) error {
    -	store.mu.Lock()
    -	defer store.mu.Unlock()
    -	current, err := store.loadLocked()
    -	if err != nil {
    -		return err
    -	}
    -	for i := range current.EnrollmentKeys {
    -		if current.EnrollmentKeys[i].ID == id {
    -			current.EnrollmentKeys[i].Used = true
    -			return store.saveLocked(current)
    +	return store.update(func(current *state) error {
    +		for i := range current.EnrollmentKeys {
    +			if current.EnrollmentKeys[i].ID == id {
    +				current.EnrollmentKeys[i].Used = true
    +				return nil
    +			}
     		}
    -	}
    -	return errors.New("key not found")
    +		return errors.New("key not found")
    +	})
     }
     
     func runDevice(args []string) error {
    diff --git a/server/main_test.go b/server/main_test.go
    index 3a3b1be..1375241 100644
    --- a/server/main_test.go
    +++ b/server/main_test.go
    @@ -8,8 +8,11 @@ import (
     	"io"
     	"net/http"
     	"net/http/httptest"
    +	"os"
    +	"path/filepath"
     	"strings"
     	"testing"
    +	"time"
     
     	"github.com/coder/websocket"
     )
    @@ -106,7 +109,7 @@ func TestWebSocketRelayStreamsAgentResponse(t *testing.T) {
     	if err := json.Unmarshal(payload, &hello); err != nil || hello.Type != "hello" {
     		t.Fatalf("invalid hello: %q", payload)
     	}
    -	if err := conn.Write(ctx, websocket.MessageText, []byte(`{"type":"share.start","protocol":1}`)); err != nil {
    +	if err := conn.Write(ctx, websocket.MessageText, []byte(`{"type":"share.start","protocol":1,"share_id":"local_1"}`)); err != nil {
     		t.Fatal(err)
     	}
     	_, payload, err = conn.Read(ctx)
    @@ -150,6 +153,9 @@ func TestWebSocketRelayStreamsAgentResponse(t *testing.T) {
     	if err := json.Unmarshal(payload, &request); err != nil || request.Type != "request.begin" {
     		t.Fatalf("invalid request: %q", payload)
     	}
    +	if request.ShareID != "local_1" {
    +		t.Fatalf("request share id = %q", request.ShareID)
    +	}
     	body := bytes.Repeat([]byte("x"), 128<<10)
     	responseBegin, _ := json.Marshal(wireMessage{
     		Type: "response.begin", RequestID: request.RequestID, Status: http.StatusOK,
    @@ -174,3 +180,258 @@ func TestWebSocketRelayStreamsAgentResponse(t *testing.T) {
     		}
     	}
     }
    +
    +func TestStateStorePermissionsAndCrossInstanceLock(t *testing.T) {
    +	dir := filepath.Join(t.TempDir(), "state")
    +	if err := os.MkdirAll(dir, 0o755); err != nil {
    +		t.Fatal(err)
    +	}
    +	first, err := newStateStore(dir)
    +	if err != nil {
    +		t.Fatal(err)
    +	}
    +	second, err := newStateStore(dir)
    +	if err != nil {
    +		t.Fatal(err)
    +	}
    +	info, err := os.Stat(dir)
    +	if err != nil {
    +		t.Fatal(err)
    +	}
    +	if info.Mode().Perm() != 0o700 {
    +		t.Fatalf("state dir mode = %v", info.Mode().Perm())
    +	}
    +
    +	locked := make(chan struct{})
    +	release := make(chan struct{})
    +	firstDone := make(chan error, 1)
    +	go func() {
    +		firstDone <- first.withFileLock(true, func() error {
    +			close(locked)
    +			<-release
    +			return nil
    +		})
    +	}()
    +	<-locked
    +	secondDone := make(chan error, 1)
    +	go func() {
    +		_, _, err := second.createEnrollmentKey("second")
    +		secondDone <- err
    +	}()
    +	select {
    +	case err := <-secondDone:
    +		t.Fatalf("second store bypassed file lock: %v", err)
    +	case <-time.After(30 * time.Millisecond):
    +	}
    +	close(release)
    +	if err := <-firstDone; err != nil {
    +		t.Fatal(err)
    +	}
    +	if err := <-secondDone; err != nil {
    +		t.Fatal(err)
    +	}
    +	info, err = os.Stat(filepath.Join(dir, "state.json"))
    +	if err != nil {
    +		t.Fatal(err)
    +	}
    +	if info.Mode().Perm() != 0o600 {
    +		t.Fatalf("state file mode = %v", info.Mode().Perm())
    +	}
    +}
    +
    +func TestStartingNewShareInvalidatesOldToken(t *testing.T) {
    +	store, err := newStateStore(t.TempDir())
    +	if err != nil {
    +		t.Fatal(err)
    +	}
    +	h := newHub(store, "http://example.test")
    +	a := &agent{device: device{ID: "device_1"}}
    +	oldURL, err := h.startShare(a, "local_old")
    +	if err != nil {
    +		t.Fatal(err)
    +	}
    +	newURL, err := h.startShare(a, "local_new")
    +	if err != nil {
    +		t.Fatal(err)
    +	}
    +	if oldURL == newURL || !strings.HasSuffix(newURL, "/") {
    +		t.Fatalf("share URLs = %q, %q", oldURL, newURL)
    +	}
    +	oldToken := strings.TrimSuffix(strings.TrimPrefix(oldURL, "http://example.test/share/"), "/")
    +	h.mu.Lock()
    +	old := h.shares[oldToken]
    +	var current *share
    +	for _, item := range h.shares {
    +		current = item
    +	}
    +	h.mu.Unlock()
    +	if old != nil || current == nil || current.shareID != "local_new" {
    +		t.Fatalf("old=%v current=%+v", old, current)
    +	}
    +}
    +
    +func TestSharePathWithoutSlashRedirectsBeforeRelay(t *testing.T) {
    +	store, err := newStateStore(t.TempDir())
    +	if err != nil {
    +		t.Fatal(err)
    +	}
    +	h := newHub(store, "http://example.test")
    +	req := httptest.NewRequest(http.MethodGet, "http://example.test/share/share_1?raw=1", nil)
    +	recorder := httptest.NewRecorder()
    +	h.shareHandler(recorder, req)
    +	if recorder.Code != http.StatusPermanentRedirect || recorder.Header().Get("Location") != "/share/share_1/?raw=1" {
    +		t.Fatalf("redirect = %d %q", recorder.Code, recorder.Header().Get("Location"))
    +	}
    +}
    +
    +func TestRelayTimeoutSendsCancel(t *testing.T) {
    +	h, server, conn, shareURL := openTestAgent(t)
    +	defer server.Close()
    +	defer conn.Close(websocket.StatusNormalClosure, "done")
    +	h.requestIdleTimeout = 30 * time.Millisecond
    +
    +	status := make(chan int, 1)
    +	go func() {
    +		response, err := http.Get(shareURL + "slow.txt")
    +		if err != nil {
    +			status <- 0
    +			return
    +		}
    +		defer response.Body.Close()
    +		status <- response.StatusCode
    +	}()
    +	ctx, cancel := context.WithTimeout(context.Background(), time.Second)
    +	defer cancel()
    +	_, payload, err := conn.Read(ctx)
    +	if err != nil {
    +		t.Fatal(err)
    +	}
    +	var begin wireMessage
    +	if json.Unmarshal(payload, &begin) != nil || begin.Type != "request.begin" {
    +		t.Fatalf("begin = %q", payload)
    +	}
    +	_, payload, err = conn.Read(ctx)
    +	if err != nil {
    +		t.Fatal(err)
    +	}
    +	var cancelMessage wireMessage
    +	if json.Unmarshal(payload, &cancelMessage) != nil || cancelMessage.Type != "request.cancel" || cancelMessage.RequestID != begin.RequestID {
    +		t.Fatalf("cancel = %q", payload)
    +	}
    +	if got := <-status; got != http.StatusGatewayTimeout {
    +		t.Fatalf("status = %d", got)
    +	}
    +}
    +
    +func TestBrowserCancellationPropagatesToAgent(t *testing.T) {
    +	_, server, conn, shareURL := openTestAgent(t)
    +	defer server.Close()
    +	defer conn.Close(websocket.StatusNormalClosure, "done")
    +	ctx, cancelBrowser := context.WithCancel(context.Background())
    +	request, err := http.NewRequestWithContext(ctx, http.MethodGet, shareURL+"cancel.txt", nil)
    +	if err != nil {
    +		t.Fatal(err)
    +	}
    +	done := make(chan error, 1)
    +	go func() {
    +		response, err := http.DefaultClient.Do(request)
    +		if response != nil {
    +			response.Body.Close()
    +		}
    +		done <- err
    +	}()
    +	readCtx, cancelRead := context.WithTimeout(context.Background(), time.Second)
    +	defer cancelRead()
    +	_, payload, err := conn.Read(readCtx)
    +	if err != nil {
    +		t.Fatal(err)
    +	}
    +	var begin wireMessage
    +	if json.Unmarshal(payload, &begin) != nil || begin.Type != "request.begin" {
    +		t.Fatalf("begin = %q", payload)
    +	}
    +	cancelBrowser()
    +	_, payload, err = conn.Read(readCtx)
    +	if err != nil {
    +		t.Fatal(err)
    +	}
    +	var cancelMessage wireMessage
    +	if json.Unmarshal(payload, &cancelMessage) != nil || cancelMessage.Type != "request.cancel" || cancelMessage.RequestID != begin.RequestID {
    +		t.Fatalf("cancel = %q", payload)
    +	}
    +	if err := <-done; err == nil {
    +		t.Fatal("browser request unexpectedly succeeded")
    +	}
    +}
    +
    +func TestRevokedDeviceCannotOpenNewWebSocket(t *testing.T) {
    +	store, err := newStateStore(t.TempDir())
    +	if err != nil {
    +		t.Fatal(err)
    +	}
    +	h := newHub(store, "")
    +	server := httptest.NewServer(newHTTPHandler(h))
    +	defer server.Close()
    +	_, enrollmentKey, err := store.createEnrollmentKey("test")
    +	if err != nil {
    +		t.Fatal(err)
    +	}
    +	deviceID, deviceToken, err := store.enroll(enrollmentKey, "Mac")
    +	if err != nil {
    +		t.Fatal(err)
    +	}
    +	if err := store.revokeDevice(deviceID); err != nil {
    +		t.Fatal(err)
    +	}
    +	conn, response, err := websocket.Dial(context.Background(), server.URL+"/api/v1/agent", &websocket.DialOptions{
    +		HTTPHeader: http.Header{"Authorization": []string{"Bearer " + deviceToken}},
    +	})
    +	if conn != nil {
    +		conn.Close(websocket.StatusNormalClosure, "done")
    +	}
    +	if err == nil || response == nil || response.StatusCode != http.StatusUnauthorized {
    +		t.Fatalf("dial err=%v response=%v", err, response)
    +	}
    +}
    +
    +func openTestAgent(t *testing.T) (*hub, *httptest.Server, *websocket.Conn, string) {
    +	t.Helper()
    +	store, err := newStateStore(t.TempDir())
    +	if err != nil {
    +		t.Fatal(err)
    +	}
    +	h := newHub(store, "")
    +	server := httptest.NewServer(newHTTPHandler(h))
    +	h.publicURL = server.URL
    +	_, enrollmentKey, err := store.createEnrollmentKey("test")
    +	if err != nil {
    +		t.Fatal(err)
    +	}
    +	_, deviceToken, err := store.enroll(enrollmentKey, "Mac")
    +	if err != nil {
    +		t.Fatal(err)
    +	}
    +	conn, _, err := websocket.Dial(context.Background(), server.URL+"/api/v1/agent", &websocket.DialOptions{
    +		HTTPHeader: http.Header{"Authorization": []string{"Bearer " + deviceToken}},
    +	})
    +	if err != nil {
    +		t.Fatal(err)
    +	}
    +	ctx, cancel := context.WithTimeout(context.Background(), time.Second)
    +	defer cancel()
    +	if _, _, err := conn.Read(ctx); err != nil {
    +		t.Fatal(err)
    +	}
    +	if err := conn.Write(ctx, websocket.MessageText, []byte(`{"type":"share.start","protocol":1,"share_id":"local_1"}`)); err != nil {
    +		t.Fatal(err)
    +	}
    +	_, payload, err := conn.Read(ctx)
    +	if err != nil {
    +		t.Fatal(err)
    +	}
    +	var ready wireMessage
    +	if json.Unmarshal(payload, &ready) != nil || ready.Type != "share.ready" {
    +		t.Fatalf("ready = %q", payload)
    +	}
    +	return h, server, conn, ready.ShareURL
    +}
    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
    index 9a5fd5e..1875afd 100755
    --- a/tools/smoke-remote-readonly.sh
    +++ b/tools/smoke-remote-readonly.sh
    @@ -13,12 +13,13 @@ PASS=0; FAIL=0
     ok(){ echo "  ✅ $1"; PASS=$((PASS+1)); }
     bad(){ echo "  ❌ $1"; FAIL=$((FAIL+1)); }
     
    -LS_HEADLESS=1 LS_FOLDER="$ROOT" LS_REMOTE=1 LS_UPLOAD=1 LS_RECV=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
    @@ -33,11 +34,13 @@ S=$(curl -s -o /dev/null -w '%{http_code}' -X POST -F 'f=@/etc/hosts' "$base/?t=
     [ "$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/a.txt?t=$TOK")
    +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/a.txt?t=$TOK")
    +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
    
    From 5382fb4d9ade1554d17e2896a3f13497d72020f7 Mon Sep 17 00:00:00 2001
    From: shawn 
    Date: Sat, 29 Aug 2026 23:32:47 +0800
    Subject: [PATCH 6/8] build: publish remote server binaries
    
    ---
     .github/workflows/ci.yml      |  4 ++--
     .github/workflows/release.yml | 24 +++++++++++++++++++++++-
     server/README.md              | 12 ++++++++++--
     tools/smoke-text.sh           |  2 +-
     4 files changed, 36 insertions(+), 6 deletions(-)
    
    diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
    index b4b754a..ec0538f 100644
    --- a/.github/workflows/ci.yml
    +++ b/.github/workflows/ci.yml
    @@ -19,7 +19,7 @@ jobs:
         runs-on: ubuntu-latest
         steps:
           - uses: actions/checkout@v7
    -      - uses: actions/setup-go@v5
    +      - uses: actions/setup-go@v7
             with:
               go-version: '1.23'
           - name: 测试 LocalShare Server
    @@ -32,7 +32,7 @@ jobs:
         runs-on: macos-latest # 与 release 同款 runner,自带 Xcode / Swift 工具链与 Node
         steps:
           - uses: actions/checkout@v7
    -      - uses: actions/setup-go@v5
    +      - uses: actions/setup-go@v7
             with:
               go-version: '1.23'
     
    diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
    index fc595dd..8060378 100644
    --- a/.github/workflows/release.yml
    +++ b/.github/workflows/release.yml
    @@ -35,6 +35,22 @@ jobs:
               }
               echo "VERSION=${RELEASE_TAG#v}" >> "$GITHUB_ENV"
     
    +      - uses: actions/setup-go@v7
    +        if: ${{ hashFiles('server/go.mod') != '' }}
    +        with:
    +          go-version-file: server/go.mod
    +
    +      - name: 编译 LocalShare Server(Linux amd64 / arm64)
    +        if: ${{ hashFiles('server/go.mod') != '' }}
    +        working-directory: server
    +        run: |
    +          CGO_ENABLED=0 GOOS=linux GOARCH=amd64 \
    +            go build -trimpath -ldflags "-s -w" -o "../LocalShare-Server-$VERSION-linux-amd64" .
    +          CGO_ENABLED=0 GOOS=linux GOARCH=arm64 \
    +            go build -trimpath -ldflags "-s -w" -o "../LocalShare-Server-$VERSION-linux-arm64" .
    +          cd ..
    +          shasum -a 256 LocalShare-Server-* > "LocalShare-Server-$VERSION-checksums.txt"
    +
           - name: 写入版本号到 Info.plist
             run: |
               /usr/libexec/PlistBuddy -c "Set :CFBundleShortVersionString $VERSION" bundle/Info.plist
    @@ -94,6 +110,9 @@ jobs:
                 CHANGELOG="$(git tag -l --format='%(contents)' "$RELEASE_TAG")"
               fi
               printf '**LocalShare %s** · 局域网文件 / 文件夹只读分享,扫码即看。\nUniversal(Apple Silicon + Intel),要求 macOS 13+。\n' "$VERSION" > release_body.md
    +          if [ -f "LocalShare-Server-$VERSION-linux-amd64" ]; then
    +            printf '\n远程分享 Server 同时提供 Linux amd64 / arm64 单文件二进制及 SHA-256 校验文件。\n' >> release_body.md
    +          fi
               if [ -n "$CHANGELOG" ]; then
                 printf '\n## 更新内容\n\n%s\n' "$CHANGELOG" >> release_body.md
               fi
    @@ -115,7 +134,10 @@ jobs:
             with:
               tag_name: ${{ env.RELEASE_TAG }}
               name: LocalShare ${{ env.VERSION }}
    -          files: LocalShare-${{ env.VERSION }}.dmg
    +          files: |
    +            LocalShare-${{ env.VERSION }}.dmg
    +            LocalShare-Server-${{ env.VERSION }}-linux-*
    +            LocalShare-Server-${{ env.VERSION }}-checksums.txt
               body_path: release_body.md
     
           - name: EdDSA 签名 DMG + 生成 appcast.xml 挂到 Release
    diff --git a/server/README.md b/server/README.md
    index c4fb905..65b3d47 100644
    --- a/server/README.md
    +++ b/server/README.md
    @@ -2,9 +2,17 @@
     
     LocalShare Server 是远程分享的控制面和数据中继。它只转发请求与文件流,不保存 Mac 上的分享内容。Server 使用一个端口,要求 Go 1.23+。
     
    -## 一、编译
    +## 一、下载或编译
     
    -在仓库根目录执行:
    +GitHub Release 提供 Linux amd64 / arm64 单文件二进制和 SHA-256 校验文件。下载与服务器架构匹配的
    +`LocalShare-Server--linux-` 后安装:
    +
    +```bash
    +chmod +x LocalShare-Server-*-linux-*
    +sudo install -m 0755 LocalShare-Server-*-linux-* /usr/local/bin/localshare-server
    +```
    +
    +也可以在仓库根目录从源码编译:
     
     ```bash
     cd server
    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"
    
    From 6f405d9fcac70d8d78595e0f6eb84e5246ef9370 Mon Sep 17 00:00:00 2001
    From: shawn 
    Date: Sun, 30 Aug 2026 01:01:27 +0800
    Subject: [PATCH 7/8] docs: add LocalShare documentation site
    
    ---
     .../skills/localshare-server-deploy/SKILL.md  |  107 ++
     .gitignore                                    |    3 +
     docs/.vitepress/config.mts                    |   81 +
     docs/.vitepress/theme/CopyPrompt.vue          |   44 +
     docs/.vitepress/theme/ZoomImage.vue           |   46 +
     docs/.vitepress/theme/custom.css              |  251 +++
     docs/.vitepress/theme/index.ts                |   12 +
     docs/client/file-sharing.md                   |   36 +
     docs/client/getting-started.md                |   52 +
     docs/client/text-transfer.md                  |   31 +
     docs/index.md                                 |   49 +
     docs/public/llms.txt                          |   25 +
     docs/server/codex-deploy.md                   |   56 +
     docs/server/connect-client.md                 |   62 +
     docs/server/deployment.md                     |  130 ++
     docs/server/index.md                          |   57 +
     package.json                                  |   19 +
     pnpm-lock.yaml                                | 1628 +++++++++++++++++
     18 files changed, 2689 insertions(+)
     create mode 100644 .agents/skills/localshare-server-deploy/SKILL.md
     create mode 100644 docs/.vitepress/config.mts
     create mode 100644 docs/.vitepress/theme/CopyPrompt.vue
     create mode 100644 docs/.vitepress/theme/ZoomImage.vue
     create mode 100644 docs/.vitepress/theme/custom.css
     create mode 100644 docs/.vitepress/theme/index.ts
     create mode 100644 docs/client/file-sharing.md
     create mode 100644 docs/client/getting-started.md
     create mode 100644 docs/client/text-transfer.md
     create mode 100644 docs/index.md
     create mode 100644 docs/public/llms.txt
     create mode 100644 docs/server/codex-deploy.md
     create mode 100644 docs/server/connect-client.md
     create mode 100644 docs/server/deployment.md
     create mode 100644 docs/server/index.md
     create mode 100644 package.json
     create mode 100644 pnpm-lock.yaml
    
    diff --git a/.agents/skills/localshare-server-deploy/SKILL.md b/.agents/skills/localshare-server-deploy/SKILL.md
    new file mode 100644
    index 0000000..1761c4a
    --- /dev/null
    +++ b/.agents/skills/localshare-server-deploy/SKILL.md
    @@ -0,0 +1,107 @@
    +---
    +name: localshare-server-deploy
    +description: 在 Linux 主机上安装、配置、升级或排查自托管 LocalShare Server,并验证 systemd、健康检查和一次性配对 key。用于用户要求部署 LocalShare 远程分享 Server;不用于 macOS Client 或纯局域网分享。
    +license: MIT
    +metadata:
    +  author: rrbe
    +  version: "1.0.0"
    +---
    +
    +# LocalShare Server 部署
    +
    +部署一个可持久运行的 `localshare-server`,验证公开地址可用,并为 LocalShare Client 生成一次性 Enrollment Key。Server 只转发请求与文件流,不保存 Mac 上的分享内容。
    +
    +目标环境是使用 systemd 的 Linux,需具备 `curl`、sudo 或 root 权限,以及访问 GitHub Releases 的出站 HTTPS 网络。
    +
    +## 确认部署边界
    +
    +- 只操作用户明确指定的主机。用户说“当前服务器”时操作当前 Linux 主机;未指定远程目标时不要猜测 SSH 主机。
    +- 开始写入前确定公开基地址、监听端口,以及使用 HTTP 还是 Server 直接终止 TLS。公开基地址不能带额外 path 或 query。
    +- HTTPS 需要域名匹配的证书和私钥路径。不要自行申请证书、配置反向代理或修改 DNS,除非用户明确要求。
    +- 修改防火墙、覆盖已有 systemd unit、替换已有二进制或调整已有 TLS 文件前,先展示检测结果并取得确认。
    +- Enrollment Key、Device Token 和 Share Token 不得写入公开日志或代码仓库。
    +
    +## 只读预检
    +
    +1. 检查 `uname -s` 为 Linux,并确认 `uname -m`:`x86_64` 映射为 `amd64`,`aarch64` 或 `arm64` 映射为 `arm64`。其他架构停止并报告不支持。
    +2. 确认 `systemctl`、`curl`、`sha256sum`、`sudo` 或 root 权限可用。
    +3. 检查目标端口是否被占用,并读取现有的 `localshare-server.service`、`/usr/local/bin/localshare-server` 和 `/var/lib/localshare` 状态。
    +4. 如果发现已有部署,保留 `/var/lib/localshare/state.json`;在用户确认升级或修复方案之前不要覆盖文件。
    +
    +## 下载并校验 Release
    +
    +从 `https://github.com/rrbe/LocalShare/releases/latest` 解析最新 `vX.Y.Z` tag,去掉 `v` 后得到版本号。下载:
    +
    +```text
    +LocalShare-Server--linux-
    +LocalShare-Server--checksums.txt
    +```
    +
    +在 `mktemp -d` 创建的临时目录中下载文件,用 checksums 文件和 `sha256sum -c` 校验目标二进制。只有校验通过后,才使用 `sudo install -m 0755` 安装到 `/usr/local/bin/localshare-server`。
    +
    +如果最新 Release 没有对应 Server 资产,停止并说明情况;可以询问用户是否改为从源码构建,但不要静默下载未经发布的构建。
    +
    +## 创建运行账户和状态目录
    +
    +如不存在,创建系统用户:
    +
    +```bash
    +sudo useradd --system --home /var/lib/localshare --shell /usr/sbin/nologin localshare
    +sudo install -d -o localshare -g localshare -m 0700 /var/lib/localshare
    +```
    +
    +状态目录必须持久化。不得删除、清空或重新生成已有 `state.json`。保持状态目录权限为 `0700`;Server 会把状态文件和锁文件权限收敛为 `0600`。
    +
    +## 配置 systemd
    +
    +使用 `/etc/systemd/system/localshare-server.service`。基础 `ExecStart` 为:
    +
    +```text
    +/usr/local/bin/localshare-server serve --listen : --public-url  --state-dir /var/lib/localshare
    +```
    +
    +Server 直接终止 TLS 时追加 `--tls-cert  --tls-key `,并确认 `localshare` 用户可以读取这两个文件。
    +
    +Service 至少使用:
    +
    +```ini
    +[Unit]
    +Description=LocalShare Server
    +After=network-online.target
    +Wants=network-online.target
    +
    +[Service]
    +User=localshare
    +Group=localshare
    +ExecStart=
    +Restart=on-failure
    +NoNewPrivileges=true
    +PrivateTmp=true
    +ProtectHome=true
    +ProtectSystem=strict
    +ReadWritePaths=/var/lib/localshare
    +
    +[Install]
    +WantedBy=multi-user.target
    +```
    +
    +写入后运行 `systemctl daemon-reload` 和 `systemctl enable --now localshare-server`。
    +
    +## 验证并生成配对 key
    +
    +1. 确认 `systemctl is-active localshare-server` 返回 `active`。
    +2. 请求 `/healthz`,必须返回 HTTP 200 和包含 `{"status":"ok"}` 的 JSON。
    +3. 如果失败,查看 `journalctl -u localshare-server --no-pager -n 100`,修复真实原因后重新验证;不要在健康检查失败时生成 key 或宣布完成。
    +4. 健康检查通过后运行:
    +
    +   ```bash
    +   sudo -u localshare /usr/local/bin/localshare-server key create \
    +     --name  \
    +     --state-dir /var/lib/localshare
    +   ```
    +
    +5. 把一次性 Enrollment Key 只返回给用户,并提醒在 macOS Client 的“设置 → 远程访问”中填写公开地址和 key。
    +
    +## 交付结果
    +
    +报告已安装版本和架构、公开地址、监听端口、TLS 模式、systemd 状态、健康检查结果、状态目录,以及 Enrollment Key。明确列出任何没有执行的防火墙、DNS、证书或外部连通性工作。
    diff --git a/.gitignore b/.gitignore
    index 13534f6..6c09826 100644
    --- a/.gitignore
    +++ b/.gitignore
    @@ -13,3 +13,6 @@ private_key.txt
     .claude/
     .codex/
     .worktrees/
    +node_modules/
    +docs/.vitepress/cache/
    +docs/.vitepress/dist/
    diff --git a/docs/.vitepress/config.mts b/docs/.vitepress/config.mts
    new file mode 100644
    index 0000000..148e585
    --- /dev/null
    +++ b/docs/.vitepress/config.mts
    @@ -0,0 +1,81 @@
    +import { defineConfig } from 'vitepress'
    +
    +export default defineConfig({
    +  lang: 'zh-CN',
    +  title: 'LocalShare 文档',
    +  description: 'LocalShare Client 与 Server 的使用和部署文档',
    +  cleanUrls: true,
    +  head: [
    +    ['meta', { name: 'theme-color', content: '#efeae1' }],
    +  ],
    +  themeConfig: {
    +    siteTitle: 'LocalShare 文档',
    +    nav: [
    +      { text: '文档', link: '/' },
    +      { text: '下载', link: 'https://github.com/rrbe/LocalShare/releases' },
    +    ],
    +    sidebar: [
    +      {
    +        text: '开始',
    +        items: [
    +          { text: '概览', link: '/' },
    +          { text: '快速开始', link: '/client/getting-started' },
    +        ],
    +      },
    +      {
    +        text: 'LocalShare Client',
    +        items: [
    +          { text: '传递文件', link: '/client/file-sharing' },
    +          { text: '传递文本', link: '/client/text-transfer' },
    +        ],
    +      },
    +      {
    +        text: 'LocalShare Server',
    +        items: [
    +          { text: 'Server 简介', link: '/server/' },
    +          { text: '安装与部署', link: '/server/deployment' },
    +          { text: '让 Codex 部署', link: '/server/codex-deploy' },
    +          { text: '连接 Client', link: '/server/connect-client' },
    +        ],
    +      },
    +      {
    +        text: '深入了解',
    +        items: [
    +          { text: '应用架构', link: '/ARCHITECTURE' },
    +          { text: '远程分享设计', link: '/REMOTE_SHARING_PLAN' },
    +        ],
    +      },
    +    ],
    +    search: {
    +      provider: 'local',
    +      options: {
    +        translations: {
    +          button: {
    +            buttonText: '搜索文档',
    +            buttonAriaLabel: '搜索文档',
    +          },
    +          modal: {
    +            noResultsText: '没有找到相关内容',
    +            resetButtonTitle: '清除查询条件',
    +            footer: {
    +              selectText: '选择',
    +              navigateText: '切换',
    +              closeText: '关闭',
    +            },
    +          },
    +        },
    +      },
    +    },
    +    outline: false,
    +    socialLinks: [
    +      { icon: 'github', link: 'https://github.com/rrbe/LocalShare' },
    +    ],
    +    docFooter: {
    +      prev: '上一页',
    +      next: '下一页',
    +    },
    +    footer: {
    +      message: 'LocalShare 基于 MIT License 发布',
    +    },
    +  },
    +})
    diff --git a/docs/.vitepress/theme/CopyPrompt.vue b/docs/.vitepress/theme/CopyPrompt.vue
    new file mode 100644
    index 0000000..91eb2b4
    --- /dev/null
    +++ b/docs/.vitepress/theme/CopyPrompt.vue
    @@ -0,0 +1,44 @@
    +
    +
    +
    diff --git a/docs/.vitepress/theme/ZoomImage.vue b/docs/.vitepress/theme/ZoomImage.vue
    new file mode 100644
    index 0000000..ab57e31
    --- /dev/null
    +++ b/docs/.vitepress/theme/ZoomImage.vue
    @@ -0,0 +1,46 @@
    +
    +
    +
    diff --git a/docs/.vitepress/theme/custom.css b/docs/.vitepress/theme/custom.css
    new file mode 100644
    index 0000000..2cc6c01
    --- /dev/null
    +++ b/docs/.vitepress/theme/custom.css
    @@ -0,0 +1,251 @@
    +:root {
    +  --vp-c-brand-1: #c94424;
    +  --vp-c-brand-2: #df4f28;
    +  --vp-c-brand-3: #b83b20;
    +  --vp-c-brand-soft: rgba(223, 79, 40, 0.12);
    +  --vp-c-bg: #f7f3ec;
    +  --vp-c-bg-alt: #efeae1;
    +  --vp-c-bg-elv: #fffdf9;
    +  --vp-c-bg-soft: #eee7dc;
    +  --vp-c-divider: rgba(56, 48, 42, 0.14);
    +  --vp-c-text-1: #2d2824;
    +  --vp-c-text-2: #6f655d;
    +  --vp-font-family-base: Inter, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
    +  --vp-font-family-mono: "SFMono-Regular", Consolas, "Liberation Mono", monospace;
    +  --vp-sidebar-bg-color: #efeae1;
    +  --vp-code-block-bg: #ebe5dc;
    +  --vp-code-line-highlight-color: rgba(255, 255, 255, 0.06);
    +  --vp-nav-bg-color: rgba(247, 243, 236, 0.92);
    +}
    +
    +.dark {
    +  --vp-c-brand-1: #f27856;
    +  --vp-c-brand-2: #df6745;
    +  --vp-c-brand-3: #f58a69;
    +  --vp-c-brand-soft: rgba(242, 120, 86, 0.14);
    +  --vp-c-bg: #201e1c;
    +  --vp-c-bg-alt: #282522;
    +  --vp-c-bg-elv: #302c29;
    +  --vp-c-bg-soft: #302c29;
    +  --vp-c-divider: rgba(255, 247, 238, 0.12);
    +  --vp-c-text-1: #f5efe8;
    +  --vp-c-text-2: #bcb1a7;
    +  --vp-sidebar-bg-color: #282522;
    +  --vp-nav-bg-color: rgba(32, 30, 28, 0.92);
    +  --vp-code-block-bg: #171513;
    +}
    +
    +.VPNavBarTitle .title,
    +.vp-doc h1,
    +.vp-doc h2,
    +.vp-doc h3 {
    +  font-family: Georgia, "Times New Roman", "Songti SC", serif;
    +}
    +
    +.VPNavBarTitle .title {
    +  letter-spacing: -0.02em;
    +}
    +
    +.vp-doc h1 {
    +  max-width: 760px;
    +  font-size: clamp(2.1rem, 4vw, 2.5rem);
    +  line-height: 1.18;
    +  letter-spacing: -0.035em;
    +}
    +
    +.vp-doc h2 {
    +  margin-top: 3rem;
    +  border-top-color: var(--vp-c-divider);
    +  letter-spacing: -0.02em;
    +}
    +
    +.vp-doc .doc-lead {
    +  max-width: 720px;
    +  margin: 1rem 0 2rem;
    +  color: var(--vp-c-text-2);
    +  font-size: 1.15rem;
    +  line-height: 1.8;
    +}
    +
    +.vp-doc .zoom-trigger {
    +  display: block;
    +  width: min(100%, 400px);
    +  margin: 2rem auto;
    +  padding: 0;
    +  border: 0;
    +  background: transparent;
    +  cursor: zoom-in;
    +}
    +
    +.vp-doc .zoom-trigger--compact {
    +  width: min(100%, 340px);
    +}
    +
    +.vp-doc .zoom-trigger .doc-screenshot {
    +  display: block;
    +  width: 100%;
    +  margin: 0;
    +}
    +
    +.vp-doc .zoom-trigger:focus-visible {
    +  outline: 2px solid var(--vp-c-brand-1);
    +  outline-offset: 5px;
    +}
    +
    +.image-zoom {
    +  width: min(900px, calc(100vw - 32px));
    +  max-width: none;
    +  max-height: calc(100vh - 32px);
    +  padding: 0;
    +  border: 0;
    +  background: transparent;
    +  overflow: auto;
    +  scrollbar-width: none;
    +}
    +
    +.image-zoom::-webkit-scrollbar {
    +  display: none;
    +}
    +
    +.image-zoom::backdrop {
    +  background: rgba(24, 21, 19, 0.76);
    +  backdrop-filter: blur(4px);
    +}
    +
    +.image-zoom__panel {
    +  position: relative;
    +}
    +
    +.image-zoom__panel > img {
    +  display: block;
    +  width: 100%;
    +  height: auto;
    +}
    +
    +.image-zoom__close {
    +  position: sticky;
    +  top: 12px;
    +  z-index: 1;
    +  float: right;
    +  width: 38px;
    +  height: 38px;
    +  margin: 12px 12px -50px 0;
    +  border: 1px solid rgba(45, 40, 36, 0.15);
    +  border-radius: 50%;
    +  background: rgba(255, 253, 249, 0.92);
    +  color: #2d2824;
    +  font-size: 24px;
    +  line-height: 1;
    +  cursor: pointer;
    +  backdrop-filter: blur(8px);
    +}
    +
    +.vp-doc .feature-grid {
    +  display: grid;
    +  grid-template-columns: repeat(2, minmax(0, 1fr));
    +  gap: 14px;
    +  margin: 1.5rem 0 2rem;
    +}
    +
    +.vp-doc .feature-card {
    +  min-height: 150px;
    +  padding: 20px;
    +  border: 1px solid var(--vp-c-divider);
    +  border-radius: 16px;
    +  background: var(--vp-c-bg-elv);
    +}
    +
    +.vp-doc .feature-card h3 {
    +  margin: 0 0 8px;
    +  font-size: 1.05rem;
    +}
    +
    +.vp-doc .feature-card p {
    +  margin: 0;
    +  color: var(--vp-c-text-2);
    +  line-height: 1.7;
    +}
    +
    +.vp-doc .flow {
    +  margin: 1.5rem 0;
    +  padding: 18px 20px;
    +  overflow-x: auto;
    +  border: 1px solid var(--vp-c-divider);
    +  border-radius: 14px;
    +  background: var(--vp-c-bg-alt);
    +  color: var(--vp-c-text-1);
    +  font-family: var(--vp-font-family-mono);
    +  font-size: 0.9rem;
    +  line-height: 1.8;
    +  white-space: pre;
    +}
    +
    +.vp-doc .copy-prompt {
    +  margin: 1.5rem 0 2rem;
    +  padding: 20px;
    +  border: 1px solid var(--vp-c-divider);
    +  border-radius: 16px;
    +  background: var(--vp-c-bg-alt);
    +}
    +
    +.vp-doc .copy-prompt__text {
    +  margin: 0;
    +  color: var(--vp-c-text-1);
    +  font-family: var(--vp-font-family-mono);
    +  font-size: 0.88rem;
    +  line-height: 1.75;
    +}
    +
    +.vp-doc .copy-prompt__button {
    +  display: inline-flex;
    +  min-height: 38px;
    +  align-items: center;
    +  justify-content: center;
    +  margin-top: 16px;
    +  padding: 8px 15px;
    +  border: 1px solid var(--vp-c-brand-1);
    +  border-radius: 10px;
    +  background: var(--vp-c-brand-1);
    +  color: #fff;
    +  font-weight: 650;
    +  cursor: pointer;
    +}
    +
    +.vp-doc .copy-prompt__button:hover {
    +  background: var(--vp-c-brand-2);
    +}
    +
    +.vp-doc a:focus-visible,
    +.VPNav a:focus-visible,
    +button:focus-visible {
    +  outline: 2px solid var(--vp-c-brand-1);
    +  outline-offset: 3px;
    +}
    +
    +@media (max-width: 639px) {
    +  .vp-doc h1 {
    +    font-size: 2.2rem;
    +  }
    +
    +  .vp-doc .feature-grid {
    +    grid-template-columns: 1fr;
    +  }
    +
    +  .vp-doc .zoom-trigger,
    +  .vp-doc .zoom-trigger--compact {
    +    width: min(100%, 280px);
    +  }
    +
    +  .image-zoom {
    +    width: calc(100vw - 20px);
    +    max-height: calc(100vh - 20px);
    +  }
    +
    +  .vp-doc .copy-prompt {
    +    padding: 16px;
    +  }
    +
    +  .vp-doc .copy-prompt__button {
    +    width: 100%;
    +  }
    +}
    diff --git a/docs/.vitepress/theme/index.ts b/docs/.vitepress/theme/index.ts
    new file mode 100644
    index 0000000..5d76a49
    --- /dev/null
    +++ b/docs/.vitepress/theme/index.ts
    @@ -0,0 +1,12 @@
    +import DefaultTheme from 'vitepress/theme'
    +import CopyPrompt from './CopyPrompt.vue'
    +import ZoomImage from './ZoomImage.vue'
    +import './custom.css'
    +
    +export default {
    +  extends: DefaultTheme,
    +  enhanceApp({ app }) {
    +    app.component('CopyPrompt', CopyPrompt)
    +    app.component('ZoomImage', ZoomImage)
    +  },
    +}
    diff --git a/docs/client/file-sharing.md b/docs/client/file-sharing.md
    new file mode 100644
    index 0000000..0d59175
    --- /dev/null
    +++ b/docs/client/file-sharing.md
    @@ -0,0 +1,36 @@
    +---
    +title: 传递文件
    +description: 使用 LocalShare 分享文件、文件夹并在浏览器中预览或下载。
    +---
    +
    +# 传递文件
    +
    +LocalShare 会把选中的内容映射到临时浏览地址。浏览器可以逐层浏览目录、预览支持的格式或下载原文件,而 Mac 上的源文件不会被复制到其他位置。
    +
    +
    +
    +## 支持的分享方式
    +
    +| 选择内容 | 浏览器中的表现 |
    +| --- | --- |
    +| 单个文件 | 直接预览或下载该文件,无法访问同目录中的其他内容 |
    +| 单个文件夹 | 浏览文件夹目录,并访问其内部文件 |
    +| 多个项目 | 在虚拟根目录中展示所有选中的文件和文件夹 |
    +
    +常见的 HTML、PDF、视频和图片可以直接由浏览器打开。Markdown、JSON 和 CSV 会显示专用预览页面,也可以使用原始内容地址下载。
    +
    +## 更换分享内容
    +
    +重新选择文件或文件夹时,LocalShare 会立即轮换访问令牌。旧链接、旧二维码和旧浏览器 Cookie 随即失效,不会继续指向新内容。
    +
    +停止分享也会使当前链接失效。关闭窗口不会退出应用;只要分享仍在运行,菜单栏中的 LocalShare 仍可继续提供服务。
    +
    +## 访客上传
    +
    +分享单个文件夹时,可以按需允许局域网访客上传文件。上传内容只会写入当前分享目录,并经过文件名清理、路径检查、大小限制和隔离标记处理。
    +
    +远程 Server 模式始终只读,不支持访客上传。
    +
    +## 访问范围
    +
    +LocalShare 会解析标准化路径和符号链接,浏览器只能访问明确选择的文件或目录。即使请求包含 `..`、百分号编码路径或指向外部的符号链接,也不能越过分享根目录。
    diff --git a/docs/client/getting-started.md b/docs/client/getting-started.md
    new file mode 100644
    index 0000000..cb53975
    --- /dev/null
    +++ b/docs/client/getting-started.md
    @@ -0,0 +1,52 @@
    +---
    +title: 快速开始
    +description: 安装 LocalShare Client,并通过二维码或链接开始第一次局域网分享。
    +---
    +
    +# 快速开始
    +
    +LocalShare Client 运行在 macOS 13 或更高版本。访问者不需要安装应用,只需要与 Mac 位于同一局域网并使用现代浏览器。
    +
    +## 1. 安装 Client
    +
    +从 [GitHub Releases](https://github.com/rrbe/LocalShare/releases) 下载最新版 LocalShare,将应用移动到“应用程序”目录后打开。
    +
    +如果 Gatekeeper 提示应用“已损坏”或“无法打开”,可以在“系统设置 → 隐私与安全性”中选择仍要打开,或者执行:
    +
    +```bash
    +xattr -dr com.apple.quarantine /Applications/LocalShare.app
    +```
    +
    +首次分享时,macOS 可能询问是否允许接收传入网络连接。请选择“允许”。
    +
    +## 2. 选择内容
    +
    +在主窗口中选择文件或文件夹。LocalShare 会启动本地只读服务,并生成访问链接和二维码。
    +
    +你也可以混合选择多个文件和文件夹。浏览器会看到一个虚拟根目录,但不能访问所选项目之外的文件。
    +
    +## 3. 在浏览器中打开
    +
    +让手机扫描二维码,或者把链接发送给同一 Wi-Fi 下的其他设备。链接中的随机令牌负责访问验证;浏览器首次验证后会跳转到不含令牌的干净地址。
    +
    +::: warning 公共网络
    +局域网分享使用 HTTP,不提供传输加密。机场、咖啡厅等不可信网络中不建议分享敏感内容;跨网络访问建议使用配置了 HTTPS 的 LocalShare Server。
    +:::
    +
    +## 命令行使用
    +
    +在“设置 → 命令行工具”中安装 `localshare` 后,可以直接从终端发起分享:
    +
    +```bash
    +localshare a.html b.pdf
    +localshare ~/Documents/报告
    +localshare --headless ./dist
    +```
    +
    +前两条命令会唤起 LocalShare 窗口,`--headless` 会直接在终端输出链接和二维码,按 `Ctrl-C` 停止。
    +
    +## 下一步
    +
    +- 了解[文件分享](./file-sharing)支持的内容和访问规则
    +- 使用[文本传递](./text-transfer)在 Mac 与手机之间复制文字
    +- 需要远程访问时,部署 [LocalShare Server](../server/)
    diff --git a/docs/client/text-transfer.md b/docs/client/text-transfer.md
    new file mode 100644
    index 0000000..b93de72
    --- /dev/null
    +++ b/docs/client/text-transfer.md
    @@ -0,0 +1,31 @@
    +---
    +title: 传递文本
    +description: 在 LocalShare Client 与同一局域网中的浏览器之间双向传递文本。
    +---
    +
    +# 传递文本
    +
    +文本传递适合发送临时命令、网址、地址或其他不便手动输入的内容。它使用与文件分享相同的本地服务和访问令牌。
    +
    +
    +
    +## 从 Mac 发送到浏览器
    +
    +1. 在主窗口中切换到“传递文本”。
    +2. 输入或粘贴文本。
    +3. 使用手机扫描二维码,或者打开显示的访问链接。
    +4. 在浏览器中查看并复制文本。
    +
    +编辑内容后,浏览器重新加载即可看到新文本。文本存放在当前 Client 进程中,不会写入 LocalShare Server。
    +
    +## 从浏览器发送到 Mac
    +
    +局域网访客也可以在文本页面中输入内容并发送回 Mac。收到的新内容会显示在 Client 中,适合把手机上的链接或短文本快速传回电脑。
    +
    +::: info 远程模式的限制
    +远程访问只提供只读文件浏览,不提供 Text Transfer,也不会把浏览器提交的文字转发到 Client。
    +:::
    +
    +## 什么时候使用文件分享
    +
    +文本传递面向短文本,不替代文件上传。需要发送文档、图片或其他二进制内容时,请使用[文件分享](./file-sharing);需要让访客把文件传回 Mac,可以在局域网文件夹分享中启用访客上传。
    diff --git a/docs/index.md b/docs/index.md
    new file mode 100644
    index 0000000..b16e86e
    --- /dev/null
    +++ b/docs/index.md
    @@ -0,0 +1,49 @@
    +---
    +title: LocalShare 文档
    +description: 通过局域网或自托管 Server,把 Mac 上的文件和文本安全地送到浏览器。
    +---
    +
    +# 从 Mac 分享到任何浏览器
    +
    +

    LocalShare 是原生 macOS 文件分享工具。选择文件或文件夹后,手机、平板和电脑只需打开链接或扫描二维码,就能在浏览器中查看内容;需要跨网络访问时,可以接入自托管的 LocalShare Server。

    + + + +## 选择使用方式 + +
    + +## 工作方式 + +局域网模式下,LocalShare Client 本身就是文件服务器,内容从 Mac 直接发送到访问者的浏览器。 + +
    浏览器 ── 同一局域网 ──▶ LocalShare Client ──▶ Mac 上的文件
    + +远程模式下,Client 主动连接 LocalShare Server。Server 只转发浏览器请求与文件流,不保存分享内容,也不需要向公网暴露 Mac 的本地端口。 + +
    远程浏览器 ──▶ LocalShare Server ◀── WebSocket ── LocalShare Client
    + +::: info 远程访问始终只读 +通过 LocalShare Server 访问时,不开放访客上传和浏览器向 Mac 发送文本的能力。本地局域网分享仍可按需启用这些功能。 +::: + +## 主要能力 + +- 分享单个文件、文件夹或多个混合项目 +- 浏览 HTML、PDF、视频与图片,并预览 Markdown、JSON 和 CSV +- 在 Mac 与局域网浏览器之间双向传递文本 +- 可选访客上传、在线访客显示和命令行分享 +- 通过自托管 Server 提供纯浏览器远程只读访问 + +## 从这里开始 + +第一次使用请阅读[快速开始](./client/getting-started)。如果准备跨网络分享,请先了解 [LocalShare Server](./server/)。 diff --git a/docs/public/llms.txt b/docs/public/llms.txt new file mode 100644 index 0000000..29bdec6 --- /dev/null +++ b/docs/public/llms.txt @@ -0,0 +1,25 @@ +# LocalShare + +> LocalShare is a native macOS app for sharing files and text to browsers on the same network, with optional read-only remote access through a self-hosted LocalShare Server. + +The macOS Client serves selected content directly. In remote mode, the Client opens an authenticated WebSocket to LocalShare Server; the Server relays requests and streams without storing shared files. + +## User documentation + +- [Documentation overview](https://raw.githubusercontent.com/rrbe/LocalShare/master/docs/index.md): Product overview and local versus remote sharing model. +- [Client quick start](https://raw.githubusercontent.com/rrbe/LocalShare/master/docs/client/getting-started.md): Install and start a local share. +- [File sharing](https://raw.githubusercontent.com/rrbe/LocalShare/master/docs/client/file-sharing.md): File, folder, preview, upload, and access-boundary behavior. +- [Text transfer](https://raw.githubusercontent.com/rrbe/LocalShare/master/docs/client/text-transfer.md): Bidirectional text transfer on the local network. +- [Server overview](https://raw.githubusercontent.com/rrbe/LocalShare/master/docs/server/index.md): Server responsibilities and remote request path. +- [Server deployment](https://raw.githubusercontent.com/rrbe/LocalShare/master/docs/server/deployment.md): Binary installation, TLS, systemd, and management commands. +- [Connect Client](https://raw.githubusercontent.com/rrbe/LocalShare/master/docs/server/connect-client.md): Pair a Client and publish a remote read-only share. + +## Agent workflows + +- [Deploy with Codex](https://raw.githubusercontent.com/rrbe/LocalShare/master/docs/server/codex-deploy.md): Copyable prompt and Skill installation guidance. +- [LocalShare Server deployment Skill](https://raw.githubusercontent.com/rrbe/LocalShare/master/.agents/skills/localshare-server-deploy/SKILL.md): Reusable instructions for installing and validating LocalShare Server on Linux. + +## Technical references + +- [Application architecture](https://raw.githubusercontent.com/rrbe/LocalShare/master/docs/ARCHITECTURE.md): Client, FileServer, security, CLI, and threading architecture. +- [Remote sharing design](https://raw.githubusercontent.com/rrbe/LocalShare/master/docs/REMOTE_SHARING_PLAN.md): Remote protocol, authentication, relay, and security decisions. diff --git a/docs/server/codex-deploy.md b/docs/server/codex-deploy.md new file mode 100644 index 0000000..f6e6477 --- /dev/null +++ b/docs/server/codex-deploy.md @@ -0,0 +1,56 @@ +--- +title: 让 Codex 部署 +description: 复制一段提示,让 Codex 按 LocalShare 官方 Skill 部署并验证 Server。 +--- + +# 让 Codex 部署 + +如果 Codex 正运行在目标 Linux 服务器上,可以把下面这段话直接发给它。Codex 会读取 LocalShare 官方部署 Skill,检查服务器环境,只在缺少必要信息或遇到已有部署时向你确认。 + + + +这段提示不会预设域名或证书路径。生产部署所需的公开地址、HTTPS 方式等信息无法从服务器可靠推断时,Codex 会继续询问。 + +## 为什么使用 Skill + +Skill 适合描述可重复执行的操作流程。LocalShare 的 Skill 固化了以下边界: + +- 只在明确的 Linux 主机上部署,不自行选择远程服务器 +- 从 GitHub Release 下载与架构匹配的二进制并校验 SHA-256 +- 保留 `/var/lib/localshare/state.json`,不让升级破坏已配对设备 +- 检查已有端口和 systemd 服务,避免直接覆盖未知配置 +- 启动后验证 `/healthz`,最后才生成一次性 Enrollment Key + +完整内容可以在仓库中查看:[`localshare-server-deploy/SKILL.md`](https://github.com/rrbe/LocalShare/blob/master/.agents/skills/localshare-server-deploy/SKILL.md)。 + +## 是否需要先安装 Skill + +不需要。上面的提示会让 Codex 直接读取官方 `SKILL.md`,适合只部署一次的用户。 + +如果经常管理 LocalShare Server,可以把 Skill 安装到 Codex。Codex 官方提供的 `$skill-installer` 可以从其他仓库下载 Skill;社区常用的 [skills CLI](https://skills.sh/) 也支持从 GitHub 路径安装: + +```bash +npx skills add \ + https://github.com/rrbe/LocalShare/tree/master/.agents/skills/localshare-server-deploy \ + --global --agent codex --yes +``` + +安装后可以直接告诉 Codex: + +```text +使用 $localshare-server-deploy 在当前 Linux 服务器上完成部署。 +``` + +## `llms.txt` 的作用 + +[`llms.txt`](/llms.txt) 是文档索引,不是安装脚本。它帮助模型快速找到 Client、Server、部署文档和 Skill,但不会让 Codex 自动获得执行流程或服务器权限。 + +因此 LocalShare 同时提供: + +| 入口 | 用途 | +| --- | --- | +| 复制给 Codex 的提示 | 一次性部署,用户无需预先安装任何内容 | +| `SKILL.md` | 可复用、可安装的部署流程 | +| `llms.txt` | 帮助模型发现相关文档和 Skill | + +相关规范:[Codex Skills](https://developers.openai.com/codex/skills/)、[Agent Skills specification](https://agentskills.io/specification)、[`llms.txt` proposal](https://llmstxt.org/)。 diff --git a/docs/server/connect-client.md b/docs/server/connect-client.md new file mode 100644 index 0000000..76db92b --- /dev/null +++ b/docs/server/connect-client.md @@ -0,0 +1,62 @@ +--- +title: 连接 LocalShare Client +description: 使用 Enrollment Key 配对 Client,并开始远程只读分享。 +--- + +# 连接 LocalShare Client + +Server 部署完成后,只需用 Enrollment Key 完成一次设备配对。之后 Client 会使用保存在 macOS Keychain 中的设备凭据重新连接。 + +## 开始之前 + +确认以下条件: + +- `curl /healthz` 返回 200 +- Server address 是不含额外路径和 query 的基地址 +- 已运行 `localshare-server key create` 并取得尚未使用的 Enrollment Key +- LocalShare Client 已选择要分享的文件或文件夹 + +## 1. 配置远程访问 + +在 LocalShare Client 中打开“设置 → 远程访问”,填写: + +| 字段 | 内容 | +| --- | --- | +| Server address | Server 的公开基地址,例如 `https://share.example.com:18443` | +| Enrollment key | Server 生成的一次性配对 key | + +保存设置。配对成功后,Device Token 会存入 macOS Keychain,Enrollment Key 不会持久化。 + +## 2. 发布当前分享 + +回到文件分享票据并点击 `Connect`。连接成功后,票据会显示 Server 生成的远程访问地址。 + +
    选择分享内容 ──▶ Connect ──▶ 获得远程地址 ──▶ 发给访问者
    + +访问者只需在浏览器中打开该地址。请求会经过 Server 转发到当前 Client,文件本身不会保存在 Server 上。 + +## 3. 后续连接 + +设备配对只需要进行一次。Client 重启后不会自动公开分享;选择文件或文件夹,再点击 `Connect`,即可使用 Keychain 中的凭据重新连接。 + +- `Disconnect`:断开当前长连接,使当前远程地址失效,但保留设备配对 +- `Forget Device`:删除 Keychain 中的 Device Token,下次需要新的 Enrollment Key +- 更换分享内容:立即轮换本地访问令牌,远程分享也会使用新的身份 + +::: warning 远程访问范围 +远程地址只开放文件和目录的只读浏览,不支持访客上传或 Text Transfer。不要把 Enrollment Key、Device Token 或 Share Token 公开发送给无关人员。 +::: + +## 排查连接问题 + +按以下顺序检查: + +1. `curl /healthz` 是否返回 200。 +2. Server address 是否为正确的 HTTP 或 HTTPS 基地址。 +3. Enrollment Key 是否已被使用;如果是,重新创建一个 key。 +4. `device list` 是否能看到已经配对的设备。 +5. Client 是否已选择分享内容;空分享时 `Connect` 不可用。 +6. Client 状态是否为 `Connected`,票据上是否出现远程 URL。 +7. 当前 URL 是否仍对应本次连接;断开连接、切换内容或 Server 重启后,旧 Share Token 会失效。 + +需要了解消息协议和安全边界时,请阅读[远程分享设计](../REMOTE_SHARING_PLAN)。 diff --git a/docs/server/deployment.md b/docs/server/deployment.md new file mode 100644 index 0000000..ed743ff --- /dev/null +++ b/docs/server/deployment.md @@ -0,0 +1,130 @@ +--- +title: 安装与部署 +description: 下载 LocalShare Server,配置状态目录、监听端口、TLS 与 systemd 服务。 +--- + +# 安装与部署 + +LocalShare Server 是 Go 编写的单文件程序。GitHub Release 提供 Linux amd64 和 arm64 二进制,运行时不依赖 SSH、frp、Nginx 或其他 LocalShare 组件。 + +::: tip 交给 Codex 完成 +如果 Codex 正运行在目标 Linux 服务器上,可以直接复制一段部署提示,让它检查环境、安装 Server、配置 systemd 并验证结果。参见[让 Codex 部署](./codex-deploy)。 +::: + +## 下载二进制 + +从 [GitHub Releases](https://github.com/rrbe/LocalShare/releases) 下载与服务器架构匹配的文件,然后安装: + +```bash +chmod +x LocalShare-Server-*-linux-* +sudo install -m 0755 LocalShare-Server-*-linux-* \ + /usr/local/bin/localshare-server +``` + +也可以从源码构建: + +```bash +cd server +go test ./... +go build -trimpath -ldflags "-s -w" -o localshare-server . +``` + +要求 Go 1.23 或更高版本。 + +## 准备运行用户和状态目录 + +```bash +sudo useradd --system \ + --home /var/lib/localshare \ + --shell /usr/sbin/nologin localshare + +sudo install -d -o localshare -g localshare -m 0700 \ + /var/lib/localshare +``` + +状态目录必须持久化。删除其中的 `state.json` 会让全部已配对设备失效。 + +## 创建 Enrollment Key + +```bash +sudo -u localshare /usr/local/bin/localshare-server key create \ + --name macbook \ + --state-dir /var/lib/localshare +``` + +命令会输出一个只允许成功配对一次的 key。复制它,下一步将在 Client 中使用。 + +## 启动 Server + +先用 HTTP 验证部署: + +```bash +sudo -u localshare /usr/local/bin/localshare-server serve \ + --listen :18080 \ + --public-url http://server.example:18080 \ + --state-dir /var/lib/localshare +``` + +`--public-url` 是浏览器和 Client 使用的 Server 基地址,不能包含额外路径或 query。防火墙只需要允许当前监听端口。 + +生产环境建议直接配置 HTTPS: + +```bash +sudo -u localshare /usr/local/bin/localshare-server serve \ + --listen :18443 \ + --public-url https://share.example.com:18443 \ + --tls-cert /etc/localshare/server.crt \ + --tls-key /etc/localshare/server.key \ + --state-dir /var/lib/localshare +``` + +证书必须覆盖 `share.example.com`。使用 `https` 的 `--public-url` 时,必须同时提供 `--tls-cert` 和 `--tls-key`。 + +## 配置 systemd + +创建 `/etc/systemd/system/localshare-server.service`: + +```ini +[Unit] +Description=LocalShare Server +After=network-online.target +Wants=network-online.target + +[Service] +User=localshare +Group=localshare +ExecStart=/usr/local/bin/localshare-server serve --listen :18080 --public-url http://server.example:18080 --state-dir /var/lib/localshare +Restart=on-failure +NoNewPrivileges=true +PrivateTmp=true +ProtectHome=true +ProtectSystem=strict +ReadWritePaths=/var/lib/localshare + +[Install] +WantedBy=multi-user.target +``` + +启动服务并检查状态: + +```bash +sudo systemctl daemon-reload +sudo systemctl enable --now localshare-server +curl http://server.example:18080/healthz +sudo journalctl -u localshare-server -f +``` + +健康检查应返回包含 `{"status":"ok"}` 的 JSON。 + +## 管理设备和 key + +```bash +localshare-server key list --state-dir /var/lib/localshare +localshare-server key revoke --state-dir /var/lib/localshare +localshare-server device list --state-dir /var/lib/localshare +localshare-server device revoke --state-dir /var/lib/localshare +``` + +Enrollment Key、Device Token 和 Share Token 都不应写入公开日志或提交到代码仓库。 + +完成部署后,继续[连接 LocalShare Client](./connect-client)。 diff --git a/docs/server/index.md b/docs/server/index.md new file mode 100644 index 0000000..373527a --- /dev/null +++ b/docs/server/index.md @@ -0,0 +1,57 @@ +--- +title: LocalShare Server +description: LocalShare Server 是用于跨网络只读分享的自托管控制面和数据中继。 +--- + +# LocalShare Server + +LocalShare Server 是远程分享的控制面和数据中继。它让外部浏览器访问当前在线的 LocalShare Client,同时保持 Mac 位于防火墙或 NAT 之后。 + +## 它负责什么 + +
    +
    +

    设备配对

    +

    使用一次性 Enrollment Key 注册 Client,并用保存在 Keychain 中的 Device Token 进行后续认证。

    +
    +
    +

    请求中继

    +

    接收远程浏览器请求,通过 Client 主动建立的 WebSocket 转发,再把响应流返回浏览器。

    +
    +
    +

    临时分享

    +

    每次连接发布新的 Share Token。断开连接、更换分享内容或 Server 重启后,旧地址都会失效。

    +
    +
    +

    最小持久化

    +

    只保存 Enrollment Key 和 Device Token 的 SHA-256,不保存 Mac 上的文件、目录或明文凭据。

    +
    +
    + +## 请求路径 + +
    远程浏览器 + │ HTTPS + ▼ +LocalShare Server + │ 已认证 WebSocket + ▼ +LocalShare Client ──▶ 本地 FileServer ──▶ 分享内容
    + +Client 主动连接 Server,因此不需要端口转发、SSH 隧道或把 Mac 的 FileServer 暴露到公网。Server 只需要一个可供浏览器和 Client 访问的端口。 + +## 适用场景 + +- 手机和 Mac 不在同一个 Wi-Fi +- 希望使用自己的域名与 TLS 证书 +- 需要让外部人员通过浏览器临时只读访问文件 +- 不希望把文件预先上传到云盘或长期存放在中继服务中 + +## 不提供的能力 + +- Server 不保存或同步分享内容 +- 远程访问不支持访客上传 +- 远程访问不支持 Text Transfer +- Server 不替代长期对象存储或用户账号系统 + +准备开始部署时,请继续阅读[安装与部署](./deployment)。 diff --git a/package.json b/package.json new file mode 100644 index 0000000..b3bbd93 --- /dev/null +++ b/package.json @@ -0,0 +1,19 @@ +{ + "name": "localshare-docs", + "private": true, + "type": "module", + "packageManager": "pnpm@10.33.2", + "scripts": { + "docs:dev": "vitepress dev docs", + "docs:build": "vitepress build docs", + "docs:preview": "vitepress preview docs" + }, + "devDependencies": { + "vitepress": "1.6.4" + }, + "pnpm": { + "onlyBuiltDependencies": [ + "esbuild" + ] + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml new file mode 100644 index 0000000..89efe7b --- /dev/null +++ b/pnpm-lock.yaml @@ -0,0 +1,1628 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + devDependencies: + vitepress: + specifier: 1.6.4 + version: 1.6.4(@algolia/client-search@5.57.0)(postcss@8.5.26)(search-insights@2.17.3) + +packages: + + '@algolia/abtesting@1.23.0': + resolution: {integrity: sha512-j45MBISstltys9QyQ4xf6quRiN1g7vMuwQL9VM4dx8YuRZvCQ173b9royZAx6iAbRX3IB1VnG1z//NuwyQ8jpQ==} + engines: {node: '>= 14.0.0'} + + '@algolia/autocomplete-core@1.17.7': + resolution: {integrity: sha512-BjiPOW6ks90UKl7TwMv7oNQMnzU+t/wk9mgIDi6b1tXpUek7MW0lbNOUHpvam9pe3lVCf4xPFT+lK7s+e+fs7Q==} + + '@algolia/autocomplete-plugin-algolia-insights@1.17.7': + resolution: {integrity: sha512-Jca5Ude6yUOuyzjnz57og7Et3aXjbwCSDf/8onLHSQgw1qW3ALl9mrMWaXb5FmPVkV3EtkD2F/+NkT6VHyPu9A==} + peerDependencies: + search-insights: '>= 1 < 3' + + '@algolia/autocomplete-preset-algolia@1.17.7': + resolution: {integrity: sha512-ggOQ950+nwbWROq2MOCIL71RE0DdQZsceqrg32UqnhDz8FlO9rL8ONHNsI2R1MH0tkgVIDKI/D0sMiUchsFdWA==} + peerDependencies: + '@algolia/client-search': '>= 4.9.1 < 6' + algoliasearch: '>= 4.9.1 < 6' + + '@algolia/autocomplete-shared@1.17.7': + resolution: {integrity: sha512-o/1Vurr42U/qskRSuhBH+VKxMvkkUVTLU6WZQr+L5lGZZLYWyhdzWjW0iGXY7EkwRTjBqvN2EsR81yCTGV/kmg==} + peerDependencies: + '@algolia/client-search': '>= 4.9.1 < 6' + algoliasearch: '>= 4.9.1 < 6' + + '@algolia/client-abtesting@5.57.0': + resolution: {integrity: sha512-JVFFujiZUCguk5tz3LZr4fTQxqpIrj4/Jw3SI7kMljSqtfLxYn/s/TWH0J2s4iNfsDpxPhgFGMotCpmDI4kZ8w==} + engines: {node: '>= 14.0.0'} + + '@algolia/client-analytics@5.57.0': + resolution: {integrity: sha512-6KqECK4ED3JJQEoDrQWnGPQzElA828xAD4qK5ceawNNyP/LcSvzAoLHjFkoTPksZ/kxj6VUtCRH+IHZesLltng==} + engines: {node: '>= 14.0.0'} + + '@algolia/client-common@5.57.0': + resolution: {integrity: sha512-uqpGF3oXYsoCbQq5d7BzNrNTfIfuvJyGP1CKvSW27T9boUg7KOwyxsAw1AX0a3jSW2HrYEJ/NN+Z4MiGivbpeQ==} + engines: {node: '>= 14.0.0'} + + '@algolia/client-insights@5.57.0': + resolution: {integrity: sha512-u5NboJVJXDEFplvNnqqX4CxkXPYysjJRj47hOSh9329H8kG5gFLKJBIiS5utMQ+GZm8xQl3Te7NInDk6elEADQ==} + engines: {node: '>= 14.0.0'} + + '@algolia/client-personalization@5.57.0': + resolution: {integrity: sha512-uzc0b2LmHAK9/QID4xeo35OG84AkZl4YewkCqawqAOGLjT2eZpM/OZx45ESygMHG30Ws+ZTSdluPtMJcUnrbWQ==} + engines: {node: '>= 14.0.0'} + + '@algolia/client-query-suggestions@5.57.0': + resolution: {integrity: sha512-dIAhnM6ue/ssa5PjgNfu4g8A4yTojl9ZOUzZU3wIaIKRerL2R/3Emuf9n/D6ICXXP167KC6XCeC7nliSw7cuSw==} + engines: {node: '>= 14.0.0'} + + '@algolia/client-search@5.57.0': + resolution: {integrity: sha512-2TTPTTKSJmCptvhCm4Xf3bBYMqZni+Pgc2hVdqc4l9wsBpSJNVTVIKpnd10OubUgkGcmppVDj1XQqYaf6EnPSQ==} + engines: {node: '>= 14.0.0'} + + '@algolia/ingestion@1.57.0': + resolution: {integrity: sha512-W4JseHKt+pzOxlFV+T3MWEG0h4Z2Se5zjoXUD0ewlw8aOWMG/yjRdopUdLQsXULepB/My2tDuZjkwk2sMfsUrQ==} + engines: {node: '>= 14.0.0'} + + '@algolia/monitoring@1.57.0': + resolution: {integrity: sha512-BrxJVE0/eLinEPICCD7BKN/2xnt0nkjge70u8zzE2ISP3fuB3tjLgcwpanUycvlHBFLI4gK0l5ol54p6IYuR/Q==} + engines: {node: '>= 14.0.0'} + + '@algolia/recommend@5.57.0': + resolution: {integrity: sha512-Gc29jkeiLKlVfHvyrIgyUHHE+aYTdXEeLfK42rjr5/1TTVsYwUJz0XkvoIBIqfMjcDg6gXeHb1jTUZ0H+SYIlQ==} + engines: {node: '>= 14.0.0'} + + '@algolia/requester-browser-xhr@5.57.0': + resolution: {integrity: sha512-PIPnPN7MP3fp2VAi01BVXhCWmD366ZB2Hkq5TlYKtThd4KxUtMmaaNDpFgVCTXtSIqWVZLJntOHRvxg/sIPd8Q==} + engines: {node: '>= 14.0.0'} + + '@algolia/requester-fetch@5.57.0': + resolution: {integrity: sha512-AX3RlOudXMdTwtwUqdAf5hAVLvXfOZZH1FZh6ALDdrhVLT0TtAIe48N6nYcWcTnwoTxK/wDIQqZ19IMjK8zJAA==} + engines: {node: '>= 14.0.0'} + + '@algolia/requester-node-http@5.57.0': + resolution: {integrity: sha512-cWZc1dKb7wy9/wPpwMtL1y89gK2G7y2A47Coa7zwf1ydtIeJm4+S+XxoQ2b/ZRiQnrC1YHavLjYUPDCdnT7Khg==} + engines: {node: '>= 14.0.0'} + + '@babel/helper-string-parser@7.29.7': + resolution: {integrity: sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-identifier@7.29.7': + resolution: {integrity: sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==} + engines: {node: '>=6.9.0'} + + '@babel/parser@7.29.8': + resolution: {integrity: sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==} + engines: {node: '>=6.0.0'} + hasBin: true + + '@babel/types@7.29.8': + resolution: {integrity: sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==} + engines: {node: '>=6.9.0'} + + '@docsearch/css@3.8.2': + resolution: {integrity: sha512-y05ayQFyUmCXze79+56v/4HpycYF3uFqB78pLPrSV5ZKAlDuIAAJNhaRi8tTdRNXh05yxX/TyNnzD6LwSM89vQ==} + + '@docsearch/js@3.8.2': + resolution: {integrity: sha512-Q5wY66qHn0SwA7Taa0aDbHiJvaFJLOJyHmooQ7y8hlwwQLQ/5WwCcoX0g7ii04Qi2DJlHsd0XXzJ8Ypw9+9YmQ==} + + '@docsearch/react@3.8.2': + resolution: {integrity: sha512-xCRrJQlTt8N9GU0DG4ptwHRkfnSnD/YpdeaXe02iKfqs97TkZJv60yE+1eq/tjPcVnTW8dP5qLP7itifFVV5eg==} + peerDependencies: + '@types/react': '>= 16.8.0 < 19.0.0' + react: '>= 16.8.0 < 19.0.0' + react-dom: '>= 16.8.0 < 19.0.0' + search-insights: '>= 1 < 3' + peerDependenciesMeta: + '@types/react': + optional: true + react: + optional: true + react-dom: + optional: true + search-insights: + optional: true + + '@esbuild/aix-ppc64@0.21.5': + resolution: {integrity: sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==} + engines: {node: '>=12'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.21.5': + resolution: {integrity: sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==} + engines: {node: '>=12'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.21.5': + resolution: {integrity: sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==} + engines: {node: '>=12'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.21.5': + resolution: {integrity: sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==} + engines: {node: '>=12'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.21.5': + resolution: {integrity: sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==} + engines: {node: '>=12'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.21.5': + resolution: {integrity: sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==} + engines: {node: '>=12'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.21.5': + resolution: {integrity: sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==} + engines: {node: '>=12'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.21.5': + resolution: {integrity: sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.21.5': + resolution: {integrity: sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==} + engines: {node: '>=12'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.21.5': + resolution: {integrity: sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==} + engines: {node: '>=12'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.21.5': + resolution: {integrity: sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==} + engines: {node: '>=12'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.21.5': + resolution: {integrity: sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==} + engines: {node: '>=12'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.21.5': + resolution: {integrity: sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==} + engines: {node: '>=12'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.21.5': + resolution: {integrity: sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==} + engines: {node: '>=12'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.21.5': + resolution: {integrity: sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==} + engines: {node: '>=12'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.21.5': + resolution: {integrity: sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==} + engines: {node: '>=12'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.21.5': + resolution: {integrity: sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-x64@0.21.5': + resolution: {integrity: sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==} + engines: {node: '>=12'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-x64@0.21.5': + resolution: {integrity: sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==} + engines: {node: '>=12'} + cpu: [x64] + os: [openbsd] + + '@esbuild/sunos-x64@0.21.5': + resolution: {integrity: sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==} + engines: {node: '>=12'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.21.5': + resolution: {integrity: sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==} + engines: {node: '>=12'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.21.5': + resolution: {integrity: sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==} + engines: {node: '>=12'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.21.5': + resolution: {integrity: sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==} + engines: {node: '>=12'} + cpu: [x64] + os: [win32] + + '@iconify-json/simple-icons@1.2.94': + resolution: {integrity: sha512-l8UWzVxKaqZd9ABsE/M/9p6NyGkQnmCnOoZyhQmjlXCtY5PuL2rcWxOFk2l9pk7ux3ERMPkTLE4jl6kQpTkwxA==} + + '@iconify/types@2.0.0': + resolution: {integrity: sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg==} + + '@jridgewell/sourcemap-codec@1.6.0': + resolution: {integrity: sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==} + + '@napi-rs/lzma-linux-x64-gnu@1.5.1': + resolution: {integrity: sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==} + engines: {node: ^22.20 || ^24.12 || >=25} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-android-arm-eabi@4.63.1': + resolution: {integrity: sha512-UZ8sUxPTiHWYX9QNdJedb1kDZSpS1t/VPWBWGSgqHNi9w3Cu6IXvu2mzbhiTiPvtrqgTQJ+zqiAq2iPIPilpaQ==} + cpu: [arm] + os: [android] + + '@rollup/rollup-android-arm64@4.63.1': + resolution: {integrity: sha512-cQ4nFQABN5cDvDpbvJ7bMStCpnaVxynZrRMfUJYgxcIk9Sh54FIO1vtfkg0B69REjER77ioZ/ov+eAApx/KmLQ==} + cpu: [arm64] + os: [android] + + '@rollup/rollup-darwin-arm64@4.63.1': + resolution: {integrity: sha512-FQNqd1lRy/0QhDk3xeRIkSBiCpXCiDnZO3YLVdcDKN1UBiKToNftCzcXYNLshmPDUMlu2TdeS8tGcsU6f3YF1Q==} + cpu: [arm64] + os: [darwin] + + '@rollup/rollup-darwin-x64@4.63.1': + resolution: {integrity: sha512-pvD16V939D3CloK0+qikpGaxiPrDUXTe7Y5cWOMkMSy7m1cawa8EGy/kXYi/G/cKAC4HDAbSnzCIk1WmsoOKXg==} + cpu: [x64] + os: [darwin] + + '@rollup/rollup-freebsd-arm64@4.63.1': + resolution: {integrity: sha512-pcFGeL2345VwdTnJhA6zLbew+YgWB0qBG2+dMtXjCicf6+rm6kO6cOoh5VnTe0ZMrMRgRyuHmCJxZWrIdzYuOw==} + cpu: [arm64] + os: [freebsd] + + '@rollup/rollup-freebsd-x64@4.63.1': + resolution: {integrity: sha512-mRJlqSRulVzcKq/LKA6ICSIc3K/l4fzlVn/gePn2nXIHy8seRi5z/eeRE0d/XMBxcMldiXtQTSpRj0tkkC3g8Q==} + cpu: [x64] + os: [freebsd] + + '@rollup/rollup-linux-arm-gnueabihf@4.63.1': + resolution: {integrity: sha512-YDUNvVM85TI3g/1OpnqKP1h4NeW/j64DfWMf+G3M809xNk1bJSnpFp4sh83NpmVE5DXnkh8ULor4LTVZKoYLHw==} + cpu: [arm] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-arm-musleabihf@4.63.1': + resolution: {integrity: sha512-7Mcn71p9ZuQFAj+h+dhQXy/yeLePRS2yKRnmW1DijA9thKO5qap0GNOIQK4yQ6iP3SU0Mrb/yWo8h8vgRba8lw==} + cpu: [arm] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-arm64-gnu@4.63.1': + resolution: {integrity: sha512-4YiLQTX6U4CSl0L9cluep9A9W6UmTfqBDc2/CH6wlu54pl4E7Jn3cOD8oxzvBDEGk/JMKgJ47C8g+radF7mwvg==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-arm64-musl@4.63.1': + resolution: {integrity: sha512-2ra8F7w8OquwZN9z2/fKFnli69wa8PLwaVzRMIPGb13ByMJwC28Fbp8YcVGoUhlYMTt7j5j9bNgpysrN2UM+vw==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-loong64-gnu@4.63.1': + resolution: {integrity: sha512-Sy20ncyhjmBP0Ml+UvQbimjlk6VFgjW5uNP+qqwHB00mTE8Bl2C1TuHTlRwK2YoXeZbee5lP2XevBWVkAQAtSQ==} + cpu: [loong64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-loong64-musl@4.63.1': + resolution: {integrity: sha512-noITLp8oNjYliPnGWmLyelIHwULGqbHloQHGw1rtxbWhTuWooRpnZarZQJ1y9EUC4szuCusCc+HEpUtxpIwYvA==} + cpu: [loong64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-ppc64-gnu@4.63.1': + resolution: {integrity: sha512-hlxxXd+F1mWiAcaFR7Sv9ZQT6m6UfI8+Vy/kFJzztq2pDMU/0wZ9sish0iszNZvsQDo8Gc0i5yuFEOz5dDf6fA==} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-ppc64-musl@4.63.1': + resolution: {integrity: sha512-EF7OpqQTQ/BvGqLzUi4rEHuagCV9MugAUXSHemwPW5vxZ75RR+jxO/2j95Ph2dalMpFHSVECjRoioHZgA9zOYA==} + cpu: [ppc64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-riscv64-gnu@4.63.1': + resolution: {integrity: sha512-wQO3JesW9PRkwlabQ27y7sPfVOOTLRG73I4F2UYHG5PXun3J9U3y+b7ezVKSYbsvSKGQ1k1cq8Qlun4C9kLt3w==} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-riscv64-musl@4.63.1': + resolution: {integrity: sha512-ouAGwhO6wHRXdnOVCOsB0tRFkA7nhNB2Nwax6oECXN0YiN8EYUTBAOudADOB1PI+yDL61TeNx/u7MVCzksNbkQ==} + cpu: [riscv64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-s390x-gnu@4.63.1': + resolution: {integrity: sha512-q2R38Sn+1J8RxhfJ+T54wSWmyKXWec+9jgDfqO2AtArEqHO5R2aeayp5H5OYLr5UYDVGsVaZPEFUooMhYCdz5A==} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-x64-gnu@4.63.1': + resolution: {integrity: sha512-gfI5T24WLLuFfSKw7Go/zDXjAAV0fny0swTaDv+WjK7vqcw4cRhFfdsyKL1n+ukI+ooBxn3bVQnyrn06WpI50w==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-x64-musl@4.63.1': + resolution: {integrity: sha512-4h6XqthmB4Hspji84wvgk+ElodTsGj+dbZqHJHHtKxj4mYq0ANSEEPX9ys3moJueqsRjwpaJYH7874Itwnj2ow==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@rollup/rollup-openbsd-x64@4.63.1': + resolution: {integrity: sha512-dlfCOa87o1VAYegLQ9EKilx2JCeRofiyPGhTCmqnuXZ6bMPiycO1rq1+sKoulAp7pGLIsTIw+1x5R+zgh5LhhA==} + cpu: [x64] + os: [openbsd] + + '@rollup/rollup-openharmony-arm64@4.63.1': + resolution: {integrity: sha512-cjkLbOlfcm3QGhMM1J5zaZjsw1GggbN6rw9UTSSRrPrR1KkcXnN7Uq9rPw34xImQ9VOY9GN+6u2Zj80B9ptkcw==} + cpu: [arm64] + os: [openharmony] + + '@rollup/rollup-win32-arm64-msvc@4.63.1': + resolution: {integrity: sha512-Li1KdUnWGE4N3e1F/B4RTB1ms+nG4WBgjByO46pkeBVX/2UBsY53xf5vK9WygVmnH3RwncIST7lkSdLSY6P9lg==} + cpu: [arm64] + os: [win32] + + '@rollup/rollup-win32-ia32-msvc@4.63.1': + resolution: {integrity: sha512-t4ZYOSoLTgwhuFMrmTMLx/+i1DQVK7HYqMc6kY46EApwi8X0nIVphzdNoThU3xt6n+N5urG1/gxBdCaKDLavfg==} + cpu: [ia32] + os: [win32] + + '@rollup/rollup-win32-x64-gnu@4.63.1': + resolution: {integrity: sha512-RgroPfMmKlD1RzSDxvwgcPiy2HNQKoYV7OmwIXDsk73uKW5t6B/V8KIy27SMv/FNXFo/oSBtWc9J0X7t91ezZg==} + cpu: [x64] + os: [win32] + + '@rollup/rollup-win32-x64-msvc@4.63.1': + resolution: {integrity: sha512-at8QVep6S3h5Y6gSbdGU06bRY5WJkf6WUduM9YtvYMbYhB1MOFfUgc6kehitQXzOtMSaT70q7f9ydPhpqu821w==} + cpu: [x64] + os: [win32] + + '@shikijs/core@2.5.0': + resolution: {integrity: sha512-uu/8RExTKtavlpH7XqnVYBrfBkUc20ngXiX9NSrBhOVZYv/7XQRKUyhtkeflY5QsxC0GbJThCerruZfsUaSldg==} + + '@shikijs/engine-javascript@2.5.0': + resolution: {integrity: sha512-VjnOpnQf8WuCEZtNUdjjwGUbtAVKuZkVQ/5cHy/tojVVRIRtlWMYVjyWhxOmIq05AlSOv72z7hRNRGVBgQOl0w==} + + '@shikijs/engine-oniguruma@2.5.0': + resolution: {integrity: sha512-pGd1wRATzbo/uatrCIILlAdFVKdxImWJGQ5rFiB5VZi2ve5xj3Ax9jny8QvkaV93btQEwR/rSz5ERFpC5mKNIw==} + + '@shikijs/langs@2.5.0': + resolution: {integrity: sha512-Qfrrt5OsNH5R+5tJ/3uYBBZv3SuGmnRPejV9IlIbFH3HTGLDlkqgHymAlzklVmKBjAaVmkPkyikAV/sQ1wSL+w==} + + '@shikijs/themes@2.5.0': + resolution: {integrity: sha512-wGrk+R8tJnO0VMzmUExHR+QdSaPUl/NKs+a4cQQRWyoc3YFbUzuLEi/KWK1hj+8BfHRKm2jNhhJck1dfstJpiw==} + + '@shikijs/transformers@2.5.0': + resolution: {integrity: sha512-SI494W5X60CaUwgi8u4q4m4s3YAFSxln3tzNjOSYqq54wlVgz0/NbbXEb3mdLbqMBztcmS7bVTaEd2w0qMmfeg==} + + '@shikijs/types@2.5.0': + resolution: {integrity: sha512-ygl5yhxki9ZLNuNpPitBWvcy9fsSKKaRuO4BAlMyagszQidxcpLAr0qiW/q43DtSIDxO6hEbtYLiFZNXO/hdGw==} + + '@shikijs/vscode-textmate@10.0.2': + resolution: {integrity: sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==} + + '@types/estree@1.0.9': + resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} + + '@types/hast@3.0.5': + resolution: {integrity: sha512-rp/ezSWaD1m44dPKICGhiskI13nVr7qTloFwDa/IYkhhf5nzwP+zIQcIJh3WIFSBOy/H1PzB40jPjMDksN4F+g==} + + '@types/linkify-it@5.0.0': + resolution: {integrity: sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q==} + + '@types/markdown-it@14.2.0': + resolution: {integrity: sha512-NoQ2yGlLWj4wpxMs+TYmRKk3thDrQ97agr7sFqfLsAlvoS8SNQuTrlObhFqG9iugdTtgOE9jpJ6FNM4ZGsa5xQ==} + + '@types/mdast@4.0.4': + resolution: {integrity: sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==} + + '@types/mdurl@2.0.0': + resolution: {integrity: sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg==} + + '@types/unist@3.0.3': + resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==} + + '@types/web-bluetooth@0.0.21': + resolution: {integrity: sha512-oIQLCGWtcFZy2JW77j9k8nHzAOpqMHLQejDA48XXMWH6tjCQHz5RCFz1bzsmROyL6PUm+LLnUiI4BCn221inxA==} + + '@ungap/structured-clone@1.4.0': + resolution: {integrity: sha512-1mEZtMKPM09vDmQt5y7YvmN2+DFTP7Tg0EWXdic8/C6VRnpb33e4ghisCIE3WZjsE2N8mf+QV1Zqh7ZFYLWInQ==} + + '@vitejs/plugin-vue@5.2.4': + resolution: {integrity: sha512-7Yx/SXSOcQq5HiiV3orevHUFn+pmMB4cgbEkDYgnkUWb0WfeQ/wa2yFv6D5ICiCQOVpjA7vYDXrC7AGO8yjDHA==} + engines: {node: ^18.0.0 || >=20.0.0} + peerDependencies: + vite: ^5.0.0 || ^6.0.0 + vue: ^3.2.25 + + '@vue/compiler-core@3.5.42': + resolution: {integrity: sha512-2Ye1ilMtKXxl8qZUrQ5j0CdgenFp/HFQmta6rfRyfEsTG69L6Wk+tWuNoHYHMx9E8tF2Slvdg1FuwDvAXdy1LQ==} + + '@vue/compiler-dom@3.5.42': + resolution: {integrity: sha512-qbhQZEFmycr+ni/qyuccS4sucNN7VAbDfbkvNxWOX2VfgFm90MNs3/UhRNKoPMEIVn0F8gdlYjLPvqxHwHeQOA==} + + '@vue/compiler-sfc@3.5.42': + resolution: {integrity: sha512-fkCAFB4okcAANGMThboWnScp/gzWjU0ZSkVnjTIiplmMDq2uq0tIB3j+xVu4rhv5rvOgBySCysudmbMd6xRRqw==} + + '@vue/compiler-ssr@3.5.42': + resolution: {integrity: sha512-xmLk3wLkbizPAiLyomjgFFosf2ys9b5Ghb+oh/k2tnvipNz8OFrQOiTcWCzyK7MpBp9KkyGtfvgfLUivbmuGYA==} + + '@vue/devtools-api@7.7.10': + resolution: {integrity: sha512-KxtEpUOOpFz/qOGRrAwA36QF7DqIA+FXgCYit9mk9wjbaZt0sXOFz81ElOZtKA4HbWHUdwNjZHBFsFFyp5BZiA==} + + '@vue/devtools-kit@7.7.10': + resolution: {integrity: sha512-3WNi2Kq4tbpVbmhml7RiphmAt0279oh3fKNeWMQIrltfX8Q91b4i5PL8DtyNKdwmcsGrV4fg+erwWOmD05CLIw==} + + '@vue/devtools-shared@7.7.10': + resolution: {integrity: sha512-wOPslzB8vTvpxwdaOcR2qAbwmuSP0L+rhpoC6Cf56V3Jip+HWb7PQQXOUPgBNQARpXsbQX/+mvi8kKucmBGRwQ==} + + '@vue/reactivity@3.5.42': + resolution: {integrity: sha512-TzNNfKpb7hDxbQltwAut8VDQA5YP+BuRlxntHUuRjyKwlMvmAPbs3unhCvieijifY6vFfVBwsS7wG/C7uq+bEQ==} + + '@vue/runtime-core@3.5.42': + resolution: {integrity: sha512-9uACtuHs7vJGkm5Bp3xu4xRDLFTIYy5DgxpToVjqGIAhAEKwQfsaLvKINhM6nFVp6bZPRFGdDqd1g52MqKsotA==} + + '@vue/runtime-dom@3.5.42': + resolution: {integrity: sha512-rsCmhiWLaRxGltLwhlCWyYkFn7WAbKRh0q17eZ1A6Dq6eqc2ACQ61IIryxz0LrsvCzHSilLA9JHovVwM8CNE2g==} + + '@vue/server-renderer@3.5.42': + resolution: {integrity: sha512-2++5dUyYS4gvo7xQXSECUDhB7TS0aOl5SeVfC5qSq1Jgfhjvegw1zqhwTIR3imZ+QYPJQw9gfcFvXGAjGZ7ajQ==} + + '@vue/shared@3.5.42': + resolution: {integrity: sha512-2rPxex1jQf4jvl9MOHl6YaXCPcrNqz/FstMOEh3QWY+/OME9nQTvl9WYeCwhW7AFjaR0SnngZGlp/wkR6rkI6g==} + + '@vueuse/core@12.8.2': + resolution: {integrity: sha512-HbvCmZdzAu3VGi/pWYm5Ut+Kd9mn1ZHnn4L5G8kOQTPs/IwIAmJoBrmYk2ckLArgMXZj0AW3n5CAejLUO+PhdQ==} + + '@vueuse/integrations@12.8.2': + resolution: {integrity: sha512-fbGYivgK5uBTRt7p5F3zy6VrETlV9RtZjBqd1/HxGdjdckBgBM4ugP8LHpjolqTj14TXTxSK1ZfgPbHYyGuH7g==} + peerDependencies: + async-validator: ^4 + axios: ^1 + change-case: ^5 + drauu: ^0.4 + focus-trap: ^7 + fuse.js: ^7 + idb-keyval: ^6 + jwt-decode: ^4 + nprogress: ^0.2 + qrcode: ^1.5 + sortablejs: ^1 + universal-cookie: ^7 + peerDependenciesMeta: + async-validator: + optional: true + axios: + optional: true + change-case: + optional: true + drauu: + optional: true + focus-trap: + optional: true + fuse.js: + optional: true + idb-keyval: + optional: true + jwt-decode: + optional: true + nprogress: + optional: true + qrcode: + optional: true + sortablejs: + optional: true + universal-cookie: + optional: true + + '@vueuse/metadata@12.8.2': + resolution: {integrity: sha512-rAyLGEuoBJ/Il5AmFHiziCPdQzRt88VxR+Y/A/QhJ1EWtWqPBBAxTAFaSkviwEuOEZNtW8pvkPgoCZQ+HxqW1A==} + + '@vueuse/shared@12.8.2': + resolution: {integrity: sha512-dznP38YzxZoNloI0qpEfpkms8knDtaoQ6Y/sfS0L7Yki4zh40LFHEhur0odJC6xTHG5dxWVPiUWBXn+wCG2s5w==} + + algoliasearch@5.57.0: + resolution: {integrity: sha512-HpND7MBGctOAkd1GoQoDZCGoCpqNTS5NG1LuhElFet3RdLJkwnyTYZXZhXwtpAQPrI36fqQ3eT6KQrdKDTKu3A==} + engines: {node: '>= 14.0.0'} + + birpc@2.9.0: + resolution: {integrity: sha512-KrayHS5pBi69Xi9JmvoqrIgYGDkD6mcSe/i6YKi3w5kekCLzrX4+nawcXqrj2tIp50Kw/mT/s3p+GVK0A0sKxw==} + + ccount@2.0.1: + resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==} + + character-entities-html4@2.1.0: + resolution: {integrity: sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==} + + character-entities-legacy@3.0.0: + resolution: {integrity: sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==} + + comma-separated-tokens@2.0.3: + resolution: {integrity: sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==} + + copy-anything@4.1.0: + resolution: {integrity: sha512-ufbM3smX/Jbnpk5wcQjzd1MgBpzmqfNETUAyZNrGwU9foRlyHoGzMMBBCRzEhQLBjZfFDE1W2ufPXX2vdWkV8Q==} + engines: {node: '>=18'} + + csstype@3.2.3: + resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + + dequal@2.0.3: + resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} + engines: {node: '>=6'} + + devlop@1.1.0: + resolution: {integrity: sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==} + + emoji-regex-xs@1.0.0: + resolution: {integrity: sha512-LRlerrMYoIDrT6jgpeZ2YYl/L8EulRTt5hQcYjy5AInh7HWXKimpqx68aknBFpGL2+/IcogTcaydJEgaTmOpDg==} + + entities@7.0.1: + resolution: {integrity: sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==} + engines: {node: '>=0.12'} + + esbuild@0.21.5: + resolution: {integrity: sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==} + engines: {node: '>=12'} + hasBin: true + + estree-walker@2.0.2: + resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==} + + focus-trap@7.8.0: + resolution: {integrity: sha512-/yNdlIkpWbM0ptxno3ONTuf+2g318kh2ez3KSeZN5dZ8YC6AAmgeWz+GasYYiBJPFaYcSAPeu4GfhUaChzIJXA==} + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + hast-util-to-html@9.0.5: + resolution: {integrity: sha512-OguPdidb+fbHQSU4Q4ZiLKnzWo8Wwsf5bZfbvu7//a9oTYoqD/fWpe96NuHkoS9h0ccGOTe0C4NGXdtS0iObOw==} + + hast-util-whitespace@3.0.0: + resolution: {integrity: sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==} + + hookable@5.5.3: + resolution: {integrity: sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ==} + + html-void-elements@3.0.0: + resolution: {integrity: sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==} + + magic-string@0.30.21: + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + + mark.js@8.11.1: + resolution: {integrity: sha512-1I+1qpDt4idfgLQG+BNWmrqku+7/2bi5nLf4YwF8y8zXvmfiTBY3PV3ZibfrjBueCByROpuBjLLFCajqkgYoLQ==} + + mdast-util-to-hast@13.2.1: + resolution: {integrity: sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA==} + + micromark-util-character@2.1.1: + resolution: {integrity: sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==} + + micromark-util-encode@2.0.1: + resolution: {integrity: sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==} + + micromark-util-sanitize-uri@2.0.1: + resolution: {integrity: sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ==} + + micromark-util-symbol@2.0.1: + resolution: {integrity: sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==} + + micromark-util-types@2.0.2: + resolution: {integrity: sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA==} + + minisearch@7.2.0: + resolution: {integrity: sha512-dqT2XBYUOZOiC5t2HRnwADjhNS2cecp9u+TJRiJ1Qp/f5qjkeT5APcGPjHw+bz89Ms8Jp+cG4AlE+QZ/QnDglg==} + + mitt@3.0.1: + resolution: {integrity: sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==} + + nanoid@3.3.18: + resolution: {integrity: sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + oniguruma-to-es@3.1.1: + resolution: {integrity: sha512-bUH8SDvPkH3ho3dvwJwfonjlQ4R80vjyvrU8YpxuROddv55vAEJrTuCuCVUhhsHbtlD9tGGbaNApGQckXhS8iQ==} + + perfect-debounce@1.0.0: + resolution: {integrity: sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + postcss@8.5.26: + resolution: {integrity: sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==} + engines: {node: ^10 || ^12 || >=14} + + preact@10.29.8: + resolution: {integrity: sha512-ej2aVZ+vZ8WO7tvlQWRM9N63A0KzF9q4mWJfDUHgYaIofWY9hu74QdnQrjoPMmZi2/nZ5gN0bJCQF49xQqx09Q==} + peerDependencies: + preact-render-to-string: '>=5' + peerDependenciesMeta: + preact-render-to-string: + optional: true + + property-information@7.2.0: + resolution: {integrity: sha512-IAtzIB6sUiWaJYrX9smp3V46pBGbBeLFRGdh25kg1334VcBlD8HzhPeNIWQH9zhGmo2itIe25EHt9dQP7G5hmg==} + + regex-recursion@6.0.2: + resolution: {integrity: sha512-0YCaSCq2VRIebiaUviZNs0cBz1kg5kVS2UKUfNIx8YVs1cN3AV7NTctO5FOKBA+UT2BPJIWZauYHPqJODG50cg==} + + regex-utilities@2.3.0: + resolution: {integrity: sha512-8VhliFJAWRaUiVvREIiW2NXXTmHs4vMNnSzuJVhscgmGav3g9VDxLrQndI3dZZVVdp0ZO/5v0xmX516/7M9cng==} + + regex@6.1.0: + resolution: {integrity: sha512-6VwtthbV4o/7+OaAF9I5L5V3llLEsoPyq9P1JVXkedTP33c7MfCG0/5NOPcSJn0TzXcG9YUrR0gQSWioew3LDg==} + + rfdc@1.4.1: + resolution: {integrity: sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==} + + rollup@4.63.1: + resolution: {integrity: sha512-3Df9jsstwhccuEfmAMi9l8XUh/GOkVObmFTU7CCVBysEbcOZLl84jCtaAZMcPiMz2EGKsATzQcU+Xr3n/wU6cg==} + engines: {node: '>=18.0.0', npm: '>=8.0.0'} + hasBin: true + + search-insights@2.17.3: + resolution: {integrity: sha512-RQPdCYTa8A68uM2jwxoY842xDhvx3E5LFL1LxvxCNMev4o5mLuokczhzjAgGwUZBAmOKZknArSxLKmXtIi2AxQ==} + + shiki@2.5.0: + resolution: {integrity: sha512-mI//trrsaiCIPsja5CNfsyNOqgAZUb6VpJA+340toL42UpzQlXpwRV9nch69X6gaUxrr9kaOOa6e3y3uAkGFxQ==} + + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + + space-separated-tokens@2.0.2: + resolution: {integrity: sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==} + + speakingurl@14.0.1: + resolution: {integrity: sha512-1POYv7uv2gXoyGFpBCmpDVSNV74IfsWlDW216UPjbWufNf+bSU6GdbDsxdcxtfwb4xlI3yxzOTKClUosxARYrQ==} + engines: {node: '>=0.10.0'} + + stringify-entities@4.0.4: + resolution: {integrity: sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==} + + superjson@2.2.6: + resolution: {integrity: sha512-H+ue8Zo4vJmV2nRjpx86P35lzwDT3nItnIsocgumgr0hHMQ+ZGq5vrERg9kJBo5AWGmxZDhzDo+WVIJqkB0cGA==} + engines: {node: '>=16'} + + tabbable@6.5.0: + resolution: {integrity: sha512-wieBHXygIm7OyQOu5hQlkk62/WyCFYGlWg7L6/ZCUZwx0o398Zkn4pVmMyfYhfMG8kGrj/Krt8eIk6UKC6VzwA==} + + trim-lines@3.0.1: + resolution: {integrity: sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==} + + unist-util-is@6.0.1: + resolution: {integrity: sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==} + + unist-util-position@5.0.0: + resolution: {integrity: sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA==} + + unist-util-stringify-position@4.0.0: + resolution: {integrity: sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==} + + unist-util-visit-parents@6.0.2: + resolution: {integrity: sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ==} + + unist-util-visit@5.1.0: + resolution: {integrity: sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg==} + + vfile-message@4.0.3: + resolution: {integrity: sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==} + + vfile@6.0.3: + resolution: {integrity: sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==} + + vite@5.4.21: + resolution: {integrity: sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==} + engines: {node: ^18.0.0 || >=20.0.0} + hasBin: true + peerDependencies: + '@types/node': ^18.0.0 || >=20.0.0 + less: '*' + lightningcss: ^1.21.0 + sass: '*' + sass-embedded: '*' + stylus: '*' + sugarss: '*' + terser: ^5.4.0 + peerDependenciesMeta: + '@types/node': + optional: true + less: + optional: true + lightningcss: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + + vitepress@1.6.4: + resolution: {integrity: sha512-+2ym1/+0VVrbhNyRoFFesVvBvHAVMZMK0rw60E3X/5349M1GuVdKeazuksqopEdvkKwKGs21Q729jX81/bkBJg==} + hasBin: true + peerDependencies: + markdown-it-mathjax3: ^4 + postcss: ^8 + peerDependenciesMeta: + markdown-it-mathjax3: + optional: true + postcss: + optional: true + + vue@3.5.42: + resolution: {integrity: sha512-4RyHQTbQvOPs3MfvUO1Sg0YRrKNnA0mAVtvpd12Tg1fKDN7OHBUl1IqSn8zGJjK9nI3NkNp8cgTpVrSZC5TTcA==} + peerDependencies: + typescript: '*' + peerDependenciesMeta: + typescript: + optional: true + + zwitch@2.0.4: + resolution: {integrity: sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==} + +snapshots: + + '@algolia/abtesting@1.23.0': + dependencies: + '@algolia/client-common': 5.57.0 + '@algolia/requester-browser-xhr': 5.57.0 + '@algolia/requester-fetch': 5.57.0 + '@algolia/requester-node-http': 5.57.0 + + '@algolia/autocomplete-core@1.17.7(@algolia/client-search@5.57.0)(algoliasearch@5.57.0)(search-insights@2.17.3)': + dependencies: + '@algolia/autocomplete-plugin-algolia-insights': 1.17.7(@algolia/client-search@5.57.0)(algoliasearch@5.57.0)(search-insights@2.17.3) + '@algolia/autocomplete-shared': 1.17.7(@algolia/client-search@5.57.0)(algoliasearch@5.57.0) + transitivePeerDependencies: + - '@algolia/client-search' + - algoliasearch + - search-insights + + '@algolia/autocomplete-plugin-algolia-insights@1.17.7(@algolia/client-search@5.57.0)(algoliasearch@5.57.0)(search-insights@2.17.3)': + dependencies: + '@algolia/autocomplete-shared': 1.17.7(@algolia/client-search@5.57.0)(algoliasearch@5.57.0) + search-insights: 2.17.3 + transitivePeerDependencies: + - '@algolia/client-search' + - algoliasearch + + '@algolia/autocomplete-preset-algolia@1.17.7(@algolia/client-search@5.57.0)(algoliasearch@5.57.0)': + dependencies: + '@algolia/autocomplete-shared': 1.17.7(@algolia/client-search@5.57.0)(algoliasearch@5.57.0) + '@algolia/client-search': 5.57.0 + algoliasearch: 5.57.0 + + '@algolia/autocomplete-shared@1.17.7(@algolia/client-search@5.57.0)(algoliasearch@5.57.0)': + dependencies: + '@algolia/client-search': 5.57.0 + algoliasearch: 5.57.0 + + '@algolia/client-abtesting@5.57.0': + dependencies: + '@algolia/client-common': 5.57.0 + '@algolia/requester-browser-xhr': 5.57.0 + '@algolia/requester-fetch': 5.57.0 + '@algolia/requester-node-http': 5.57.0 + + '@algolia/client-analytics@5.57.0': + dependencies: + '@algolia/client-common': 5.57.0 + '@algolia/requester-browser-xhr': 5.57.0 + '@algolia/requester-fetch': 5.57.0 + '@algolia/requester-node-http': 5.57.0 + + '@algolia/client-common@5.57.0': {} + + '@algolia/client-insights@5.57.0': + dependencies: + '@algolia/client-common': 5.57.0 + '@algolia/requester-browser-xhr': 5.57.0 + '@algolia/requester-fetch': 5.57.0 + '@algolia/requester-node-http': 5.57.0 + + '@algolia/client-personalization@5.57.0': + dependencies: + '@algolia/client-common': 5.57.0 + '@algolia/requester-browser-xhr': 5.57.0 + '@algolia/requester-fetch': 5.57.0 + '@algolia/requester-node-http': 5.57.0 + + '@algolia/client-query-suggestions@5.57.0': + dependencies: + '@algolia/client-common': 5.57.0 + '@algolia/requester-browser-xhr': 5.57.0 + '@algolia/requester-fetch': 5.57.0 + '@algolia/requester-node-http': 5.57.0 + + '@algolia/client-search@5.57.0': + dependencies: + '@algolia/client-common': 5.57.0 + '@algolia/requester-browser-xhr': 5.57.0 + '@algolia/requester-fetch': 5.57.0 + '@algolia/requester-node-http': 5.57.0 + + '@algolia/ingestion@1.57.0': + dependencies: + '@algolia/client-common': 5.57.0 + '@algolia/requester-browser-xhr': 5.57.0 + '@algolia/requester-fetch': 5.57.0 + '@algolia/requester-node-http': 5.57.0 + + '@algolia/monitoring@1.57.0': + dependencies: + '@algolia/client-common': 5.57.0 + '@algolia/requester-browser-xhr': 5.57.0 + '@algolia/requester-fetch': 5.57.0 + '@algolia/requester-node-http': 5.57.0 + + '@algolia/recommend@5.57.0': + dependencies: + '@algolia/client-common': 5.57.0 + '@algolia/requester-browser-xhr': 5.57.0 + '@algolia/requester-fetch': 5.57.0 + '@algolia/requester-node-http': 5.57.0 + + '@algolia/requester-browser-xhr@5.57.0': + dependencies: + '@algolia/client-common': 5.57.0 + + '@algolia/requester-fetch@5.57.0': + dependencies: + '@algolia/client-common': 5.57.0 + + '@algolia/requester-node-http@5.57.0': + dependencies: + '@algolia/client-common': 5.57.0 + + '@babel/helper-string-parser@7.29.7': {} + + '@babel/helper-validator-identifier@7.29.7': {} + + '@babel/parser@7.29.8': + dependencies: + '@babel/types': 7.29.8 + + '@babel/types@7.29.8': + dependencies: + '@babel/helper-string-parser': 7.29.7 + '@babel/helper-validator-identifier': 7.29.7 + + '@docsearch/css@3.8.2': {} + + '@docsearch/js@3.8.2(@algolia/client-search@5.57.0)(search-insights@2.17.3)': + dependencies: + '@docsearch/react': 3.8.2(@algolia/client-search@5.57.0)(search-insights@2.17.3) + preact: 10.29.8 + transitivePeerDependencies: + - '@algolia/client-search' + - '@types/react' + - preact-render-to-string + - react + - react-dom + - search-insights + + '@docsearch/react@3.8.2(@algolia/client-search@5.57.0)(search-insights@2.17.3)': + dependencies: + '@algolia/autocomplete-core': 1.17.7(@algolia/client-search@5.57.0)(algoliasearch@5.57.0)(search-insights@2.17.3) + '@algolia/autocomplete-preset-algolia': 1.17.7(@algolia/client-search@5.57.0)(algoliasearch@5.57.0) + '@docsearch/css': 3.8.2 + algoliasearch: 5.57.0 + optionalDependencies: + search-insights: 2.17.3 + transitivePeerDependencies: + - '@algolia/client-search' + + '@esbuild/aix-ppc64@0.21.5': + optional: true + + '@esbuild/android-arm64@0.21.5': + optional: true + + '@esbuild/android-arm@0.21.5': + optional: true + + '@esbuild/android-x64@0.21.5': + optional: true + + '@esbuild/darwin-arm64@0.21.5': + optional: true + + '@esbuild/darwin-x64@0.21.5': + optional: true + + '@esbuild/freebsd-arm64@0.21.5': + optional: true + + '@esbuild/freebsd-x64@0.21.5': + optional: true + + '@esbuild/linux-arm64@0.21.5': + optional: true + + '@esbuild/linux-arm@0.21.5': + optional: true + + '@esbuild/linux-ia32@0.21.5': + optional: true + + '@esbuild/linux-loong64@0.21.5': + optional: true + + '@esbuild/linux-mips64el@0.21.5': + optional: true + + '@esbuild/linux-ppc64@0.21.5': + optional: true + + '@esbuild/linux-riscv64@0.21.5': + optional: true + + '@esbuild/linux-s390x@0.21.5': + optional: true + + '@esbuild/linux-x64@0.21.5': + optional: true + + '@esbuild/netbsd-x64@0.21.5': + optional: true + + '@esbuild/openbsd-x64@0.21.5': + optional: true + + '@esbuild/sunos-x64@0.21.5': + optional: true + + '@esbuild/win32-arm64@0.21.5': + optional: true + + '@esbuild/win32-ia32@0.21.5': + optional: true + + '@esbuild/win32-x64@0.21.5': + optional: true + + '@iconify-json/simple-icons@1.2.94': + dependencies: + '@iconify/types': 2.0.0 + + '@iconify/types@2.0.0': {} + + '@jridgewell/sourcemap-codec@1.6.0': {} + + '@napi-rs/lzma-linux-x64-gnu@1.5.1': + optional: true + + '@rollup/rollup-android-arm-eabi@4.63.1': + optional: true + + '@rollup/rollup-android-arm64@4.63.1': + optional: true + + '@rollup/rollup-darwin-arm64@4.63.1': + optional: true + + '@rollup/rollup-darwin-x64@4.63.1': + optional: true + + '@rollup/rollup-freebsd-arm64@4.63.1': + optional: true + + '@rollup/rollup-freebsd-x64@4.63.1': + optional: true + + '@rollup/rollup-linux-arm-gnueabihf@4.63.1': + optional: true + + '@rollup/rollup-linux-arm-musleabihf@4.63.1': + optional: true + + '@rollup/rollup-linux-arm64-gnu@4.63.1': + optional: true + + '@rollup/rollup-linux-arm64-musl@4.63.1': + optional: true + + '@rollup/rollup-linux-loong64-gnu@4.63.1': + optional: true + + '@rollup/rollup-linux-loong64-musl@4.63.1': + optional: true + + '@rollup/rollup-linux-ppc64-gnu@4.63.1': + optional: true + + '@rollup/rollup-linux-ppc64-musl@4.63.1': + optional: true + + '@rollup/rollup-linux-riscv64-gnu@4.63.1': + optional: true + + '@rollup/rollup-linux-riscv64-musl@4.63.1': + optional: true + + '@rollup/rollup-linux-s390x-gnu@4.63.1': + optional: true + + '@rollup/rollup-linux-x64-gnu@4.63.1': + optional: true + + '@rollup/rollup-linux-x64-musl@4.63.1': + optional: true + + '@rollup/rollup-openbsd-x64@4.63.1': + optional: true + + '@rollup/rollup-openharmony-arm64@4.63.1': + optional: true + + '@rollup/rollup-win32-arm64-msvc@4.63.1': + optional: true + + '@rollup/rollup-win32-ia32-msvc@4.63.1': + optional: true + + '@rollup/rollup-win32-x64-gnu@4.63.1': + optional: true + + '@rollup/rollup-win32-x64-msvc@4.63.1': + optional: true + + '@shikijs/core@2.5.0': + dependencies: + '@shikijs/engine-javascript': 2.5.0 + '@shikijs/engine-oniguruma': 2.5.0 + '@shikijs/types': 2.5.0 + '@shikijs/vscode-textmate': 10.0.2 + '@types/hast': 3.0.5 + hast-util-to-html: 9.0.5 + + '@shikijs/engine-javascript@2.5.0': + dependencies: + '@shikijs/types': 2.5.0 + '@shikijs/vscode-textmate': 10.0.2 + oniguruma-to-es: 3.1.1 + + '@shikijs/engine-oniguruma@2.5.0': + dependencies: + '@shikijs/types': 2.5.0 + '@shikijs/vscode-textmate': 10.0.2 + + '@shikijs/langs@2.5.0': + dependencies: + '@shikijs/types': 2.5.0 + + '@shikijs/themes@2.5.0': + dependencies: + '@shikijs/types': 2.5.0 + + '@shikijs/transformers@2.5.0': + dependencies: + '@shikijs/core': 2.5.0 + '@shikijs/types': 2.5.0 + + '@shikijs/types@2.5.0': + dependencies: + '@shikijs/vscode-textmate': 10.0.2 + '@types/hast': 3.0.5 + + '@shikijs/vscode-textmate@10.0.2': {} + + '@types/estree@1.0.9': {} + + '@types/hast@3.0.5': + dependencies: + '@types/unist': 3.0.3 + + '@types/linkify-it@5.0.0': {} + + '@types/markdown-it@14.2.0': + dependencies: + '@types/linkify-it': 5.0.0 + '@types/mdurl': 2.0.0 + + '@types/mdast@4.0.4': + dependencies: + '@types/unist': 3.0.3 + + '@types/mdurl@2.0.0': {} + + '@types/unist@3.0.3': {} + + '@types/web-bluetooth@0.0.21': {} + + '@ungap/structured-clone@1.4.0': {} + + '@vitejs/plugin-vue@5.2.4(vite@5.4.21)(vue@3.5.42)': + dependencies: + vite: 5.4.21 + vue: 3.5.42 + + '@vue/compiler-core@3.5.42': + dependencies: + '@babel/parser': 7.29.8 + '@vue/shared': 3.5.42 + entities: 7.0.1 + estree-walker: 2.0.2 + source-map-js: 1.2.1 + + '@vue/compiler-dom@3.5.42': + dependencies: + '@vue/compiler-core': 3.5.42 + '@vue/shared': 3.5.42 + + '@vue/compiler-sfc@3.5.42': + dependencies: + '@babel/parser': 7.29.8 + '@vue/compiler-core': 3.5.42 + '@vue/compiler-dom': 3.5.42 + '@vue/compiler-ssr': 3.5.42 + '@vue/shared': 3.5.42 + estree-walker: 2.0.2 + magic-string: 0.30.21 + postcss: 8.5.26 + source-map-js: 1.2.1 + + '@vue/compiler-ssr@3.5.42': + dependencies: + '@vue/compiler-dom': 3.5.42 + '@vue/shared': 3.5.42 + + '@vue/devtools-api@7.7.10': + dependencies: + '@vue/devtools-kit': 7.7.10 + + '@vue/devtools-kit@7.7.10': + dependencies: + '@vue/devtools-shared': 7.7.10 + birpc: 2.9.0 + hookable: 5.5.3 + mitt: 3.0.1 + perfect-debounce: 1.0.0 + speakingurl: 14.0.1 + superjson: 2.2.6 + + '@vue/devtools-shared@7.7.10': + dependencies: + rfdc: 1.4.1 + + '@vue/reactivity@3.5.42': + dependencies: + '@vue/shared': 3.5.42 + + '@vue/runtime-core@3.5.42': + dependencies: + '@vue/reactivity': 3.5.42 + '@vue/shared': 3.5.42 + + '@vue/runtime-dom@3.5.42': + dependencies: + '@vue/reactivity': 3.5.42 + '@vue/runtime-core': 3.5.42 + '@vue/shared': 3.5.42 + csstype: 3.2.3 + + '@vue/server-renderer@3.5.42': + dependencies: + '@vue/compiler-ssr': 3.5.42 + '@vue/runtime-dom': 3.5.42 + '@vue/shared': 3.5.42 + + '@vue/shared@3.5.42': {} + + '@vueuse/core@12.8.2': + dependencies: + '@types/web-bluetooth': 0.0.21 + '@vueuse/metadata': 12.8.2 + '@vueuse/shared': 12.8.2 + vue: 3.5.42 + transitivePeerDependencies: + - typescript + + '@vueuse/integrations@12.8.2(focus-trap@7.8.0)': + dependencies: + '@vueuse/core': 12.8.2 + '@vueuse/shared': 12.8.2 + vue: 3.5.42 + optionalDependencies: + focus-trap: 7.8.0 + transitivePeerDependencies: + - typescript + + '@vueuse/metadata@12.8.2': {} + + '@vueuse/shared@12.8.2': + dependencies: + vue: 3.5.42 + transitivePeerDependencies: + - typescript + + algoliasearch@5.57.0: + dependencies: + '@algolia/abtesting': 1.23.0 + '@algolia/client-abtesting': 5.57.0 + '@algolia/client-analytics': 5.57.0 + '@algolia/client-common': 5.57.0 + '@algolia/client-insights': 5.57.0 + '@algolia/client-personalization': 5.57.0 + '@algolia/client-query-suggestions': 5.57.0 + '@algolia/client-search': 5.57.0 + '@algolia/ingestion': 1.57.0 + '@algolia/monitoring': 1.57.0 + '@algolia/recommend': 5.57.0 + '@algolia/requester-browser-xhr': 5.57.0 + '@algolia/requester-fetch': 5.57.0 + '@algolia/requester-node-http': 5.57.0 + + birpc@2.9.0: {} + + ccount@2.0.1: {} + + character-entities-html4@2.1.0: {} + + character-entities-legacy@3.0.0: {} + + comma-separated-tokens@2.0.3: {} + + copy-anything@4.1.0: {} + + csstype@3.2.3: {} + + dequal@2.0.3: {} + + devlop@1.1.0: + dependencies: + dequal: 2.0.3 + + emoji-regex-xs@1.0.0: {} + + entities@7.0.1: {} + + esbuild@0.21.5: + optionalDependencies: + '@esbuild/aix-ppc64': 0.21.5 + '@esbuild/android-arm': 0.21.5 + '@esbuild/android-arm64': 0.21.5 + '@esbuild/android-x64': 0.21.5 + '@esbuild/darwin-arm64': 0.21.5 + '@esbuild/darwin-x64': 0.21.5 + '@esbuild/freebsd-arm64': 0.21.5 + '@esbuild/freebsd-x64': 0.21.5 + '@esbuild/linux-arm': 0.21.5 + '@esbuild/linux-arm64': 0.21.5 + '@esbuild/linux-ia32': 0.21.5 + '@esbuild/linux-loong64': 0.21.5 + '@esbuild/linux-mips64el': 0.21.5 + '@esbuild/linux-ppc64': 0.21.5 + '@esbuild/linux-riscv64': 0.21.5 + '@esbuild/linux-s390x': 0.21.5 + '@esbuild/linux-x64': 0.21.5 + '@esbuild/netbsd-x64': 0.21.5 + '@esbuild/openbsd-x64': 0.21.5 + '@esbuild/sunos-x64': 0.21.5 + '@esbuild/win32-arm64': 0.21.5 + '@esbuild/win32-ia32': 0.21.5 + '@esbuild/win32-x64': 0.21.5 + + estree-walker@2.0.2: {} + + focus-trap@7.8.0: + dependencies: + tabbable: 6.5.0 + + fsevents@2.3.3: + optional: true + + hast-util-to-html@9.0.5: + dependencies: + '@types/hast': 3.0.5 + '@types/unist': 3.0.3 + ccount: 2.0.1 + comma-separated-tokens: 2.0.3 + hast-util-whitespace: 3.0.0 + html-void-elements: 3.0.0 + mdast-util-to-hast: 13.2.1 + property-information: 7.2.0 + space-separated-tokens: 2.0.2 + stringify-entities: 4.0.4 + zwitch: 2.0.4 + + hast-util-whitespace@3.0.0: + dependencies: + '@types/hast': 3.0.5 + + hookable@5.5.3: {} + + html-void-elements@3.0.0: {} + + magic-string@0.30.21: + dependencies: + '@jridgewell/sourcemap-codec': 1.6.0 + + mark.js@8.11.1: {} + + mdast-util-to-hast@13.2.1: + dependencies: + '@types/hast': 3.0.5 + '@types/mdast': 4.0.4 + '@ungap/structured-clone': 1.4.0 + devlop: 1.1.0 + micromark-util-sanitize-uri: 2.0.1 + trim-lines: 3.0.1 + unist-util-position: 5.0.0 + unist-util-visit: 5.1.0 + vfile: 6.0.3 + + micromark-util-character@2.1.1: + dependencies: + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-util-encode@2.0.1: {} + + micromark-util-sanitize-uri@2.0.1: + dependencies: + micromark-util-character: 2.1.1 + micromark-util-encode: 2.0.1 + micromark-util-symbol: 2.0.1 + + micromark-util-symbol@2.0.1: {} + + micromark-util-types@2.0.2: {} + + minisearch@7.2.0: {} + + mitt@3.0.1: {} + + nanoid@3.3.18: {} + + oniguruma-to-es@3.1.1: + dependencies: + emoji-regex-xs: 1.0.0 + regex: 6.1.0 + regex-recursion: 6.0.2 + + perfect-debounce@1.0.0: {} + + picocolors@1.1.1: {} + + postcss@8.5.26: + dependencies: + nanoid: 3.3.18 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + preact@10.29.8: {} + + property-information@7.2.0: {} + + regex-recursion@6.0.2: + dependencies: + regex-utilities: 2.3.0 + + regex-utilities@2.3.0: {} + + regex@6.1.0: + dependencies: + regex-utilities: 2.3.0 + + rfdc@1.4.1: {} + + rollup@4.63.1: + dependencies: + '@types/estree': 1.0.9 + optionalDependencies: + '@napi-rs/lzma-linux-x64-gnu': 1.5.1 + '@rollup/rollup-android-arm-eabi': 4.63.1 + '@rollup/rollup-android-arm64': 4.63.1 + '@rollup/rollup-darwin-arm64': 4.63.1 + '@rollup/rollup-darwin-x64': 4.63.1 + '@rollup/rollup-freebsd-arm64': 4.63.1 + '@rollup/rollup-freebsd-x64': 4.63.1 + '@rollup/rollup-linux-arm-gnueabihf': 4.63.1 + '@rollup/rollup-linux-arm-musleabihf': 4.63.1 + '@rollup/rollup-linux-arm64-gnu': 4.63.1 + '@rollup/rollup-linux-arm64-musl': 4.63.1 + '@rollup/rollup-linux-loong64-gnu': 4.63.1 + '@rollup/rollup-linux-loong64-musl': 4.63.1 + '@rollup/rollup-linux-ppc64-gnu': 4.63.1 + '@rollup/rollup-linux-ppc64-musl': 4.63.1 + '@rollup/rollup-linux-riscv64-gnu': 4.63.1 + '@rollup/rollup-linux-riscv64-musl': 4.63.1 + '@rollup/rollup-linux-s390x-gnu': 4.63.1 + '@rollup/rollup-linux-x64-gnu': 4.63.1 + '@rollup/rollup-linux-x64-musl': 4.63.1 + '@rollup/rollup-openbsd-x64': 4.63.1 + '@rollup/rollup-openharmony-arm64': 4.63.1 + '@rollup/rollup-win32-arm64-msvc': 4.63.1 + '@rollup/rollup-win32-ia32-msvc': 4.63.1 + '@rollup/rollup-win32-x64-gnu': 4.63.1 + '@rollup/rollup-win32-x64-msvc': 4.63.1 + fsevents: 2.3.3 + + search-insights@2.17.3: {} + + shiki@2.5.0: + dependencies: + '@shikijs/core': 2.5.0 + '@shikijs/engine-javascript': 2.5.0 + '@shikijs/engine-oniguruma': 2.5.0 + '@shikijs/langs': 2.5.0 + '@shikijs/themes': 2.5.0 + '@shikijs/types': 2.5.0 + '@shikijs/vscode-textmate': 10.0.2 + '@types/hast': 3.0.5 + + source-map-js@1.2.1: {} + + space-separated-tokens@2.0.2: {} + + speakingurl@14.0.1: {} + + stringify-entities@4.0.4: + dependencies: + character-entities-html4: 2.1.0 + character-entities-legacy: 3.0.0 + + superjson@2.2.6: + dependencies: + copy-anything: 4.1.0 + + tabbable@6.5.0: {} + + trim-lines@3.0.1: {} + + unist-util-is@6.0.1: + dependencies: + '@types/unist': 3.0.3 + + unist-util-position@5.0.0: + dependencies: + '@types/unist': 3.0.3 + + unist-util-stringify-position@4.0.0: + dependencies: + '@types/unist': 3.0.3 + + unist-util-visit-parents@6.0.2: + dependencies: + '@types/unist': 3.0.3 + unist-util-is: 6.0.1 + + unist-util-visit@5.1.0: + dependencies: + '@types/unist': 3.0.3 + unist-util-is: 6.0.1 + unist-util-visit-parents: 6.0.2 + + vfile-message@4.0.3: + dependencies: + '@types/unist': 3.0.3 + unist-util-stringify-position: 4.0.0 + + vfile@6.0.3: + dependencies: + '@types/unist': 3.0.3 + vfile-message: 4.0.3 + + vite@5.4.21: + dependencies: + esbuild: 0.21.5 + postcss: 8.5.26 + rollup: 4.63.1 + optionalDependencies: + fsevents: 2.3.3 + + vitepress@1.6.4(@algolia/client-search@5.57.0)(postcss@8.5.26)(search-insights@2.17.3): + dependencies: + '@docsearch/css': 3.8.2 + '@docsearch/js': 3.8.2(@algolia/client-search@5.57.0)(search-insights@2.17.3) + '@iconify-json/simple-icons': 1.2.94 + '@shikijs/core': 2.5.0 + '@shikijs/transformers': 2.5.0 + '@shikijs/types': 2.5.0 + '@types/markdown-it': 14.2.0 + '@vitejs/plugin-vue': 5.2.4(vite@5.4.21)(vue@3.5.42) + '@vue/devtools-api': 7.7.10 + '@vue/shared': 3.5.42 + '@vueuse/core': 12.8.2 + '@vueuse/integrations': 12.8.2(focus-trap@7.8.0) + focus-trap: 7.8.0 + mark.js: 8.11.1 + minisearch: 7.2.0 + shiki: 2.5.0 + vite: 5.4.21 + vue: 3.5.42 + optionalDependencies: + postcss: 8.5.26 + transitivePeerDependencies: + - '@algolia/client-search' + - '@types/node' + - '@types/react' + - async-validator + - axios + - change-case + - drauu + - fuse.js + - idb-keyval + - jwt-decode + - less + - lightningcss + - nprogress + - preact-render-to-string + - qrcode + - react + - react-dom + - sass + - sass-embedded + - search-insights + - sortablejs + - stylus + - sugarss + - terser + - typescript + - universal-cookie + + vue@3.5.42: + dependencies: + '@vue/compiler-dom': 3.5.42 + '@vue/compiler-sfc': 3.5.42 + '@vue/runtime-dom': 3.5.42 + '@vue/server-renderer': 3.5.42 + '@vue/shared': 3.5.42 + + zwitch@2.0.4: {} From 773d0f4bca18b728222017157e4eb2b33b3ba0a1 Mon Sep 17 00:00:00 2001 From: shawn Date: Sun, 30 Aug 2026 01:12:34 +0800 Subject: [PATCH 8/8] ci: deploy docs to GitHub Pages --- .github/workflows/docs.yml | 57 ++++++++++++++++++++++++++++++++++++++ docs/.vitepress/config.mts | 3 ++ 2 files changed, 60 insertions(+) create mode 100644 .github/workflows/docs.yml diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml new file mode 100644 index 0000000..4f4090b --- /dev/null +++ b/.github/workflows/docs.yml @@ -0,0 +1,57 @@ +name: Deploy docs + +on: + push: + branches: + - master + workflow_dispatch: + +permissions: + contents: read + pages: write + id-token: write + +concurrency: + group: pages + cancel-in-progress: false + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + + - uses: pnpm/action-setup@v6 + + - uses: actions/setup-node@v7 + with: + node-version: 24 + cache: pnpm + + - name: Configure GitHub Pages + id: pages + uses: actions/configure-pages@v6 + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Build documentation + env: + DOCS_BASE: ${{ steps.pages.outputs.base_path }} + run: pnpm docs:build + + - name: Upload Pages artifact + uses: actions/upload-pages-artifact@v5 + with: + path: docs/.vitepress/dist + + deploy: + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + needs: build + runs-on: ubuntu-latest + steps: + - name: Deploy to GitHub Pages + id: deployment + uses: actions/deploy-pages@v5 diff --git a/docs/.vitepress/config.mts b/docs/.vitepress/config.mts index 148e585..91179d8 100644 --- a/docs/.vitepress/config.mts +++ b/docs/.vitepress/config.mts @@ -1,6 +1,9 @@ import { defineConfig } from 'vitepress' +const pagesBase = process.env.DOCS_BASE + export default defineConfig({ + base: pagesBase ? `${pagesBase}/` : '/', lang: 'zh-CN', title: 'LocalShare 文档', description: 'LocalShare Client 与 Server 的使用和部署文档',