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/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0c9553a..ec0538f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -15,10 +15,26 @@ on: pull_request: jobs: + server: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + - uses: actions/setup-go@v7 + with: + go-version: '1.23' + - name: 测试 LocalShare Server + working-directory: server + run: | + go test -race ./... + go vet ./... + smoke: runs-on: macos-latest # 与 release 同款 runner,自带 Xcode / Swift 工具链与 Node steps: - uses: actions/checkout@v7 + - uses: actions/setup-go@v7 + with: + go-version: '1.23' - name: 编译 debug(首次会拉取 Swifter / Sparkle) run: swift build @@ -54,6 +70,18 @@ jobs: - name: 网页逐请求语言(Accept-Language) run: ./tools/smoke-accept-language.sh + - name: 远程访问只读策略 + run: ./tools/smoke-remote-readonly.sh + + - name: 远程访问完整链路 + run: ./tools/smoke-remote-e2e.sh + + - name: Release 模式远程访问完整链路 + run: | + swift build -c release + BIN=.build/release/LocalShare ./tools/smoke-remote-readonly.sh + CLIENT_BIN=.build/release/LocalShare ./tools/smoke-remote-e2e.sh + - name: 传递文本(/ls/text + 转义 + 虚拟根文本行) run: ./tools/smoke-text.sh diff --git a/.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/.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/.gitignore b/.gitignore index 55c1238..6c09826 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ .build/ .swiftpm/ +/server/localshare-server dist/ *.app .DS_Store @@ -12,3 +13,6 @@ private_key.txt .claude/ .codex/ .worktrees/ +node_modules/ +docs/.vitepress/cache/ +docs/.vitepress/dist/ 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/README.md b/README.md index e153bb3..0d65a3d 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ [简体中文](README_CN.md) | English -A small macOS app that moves files and text between your Mac and other devices (phone, tablet…) over WiFi — the other end just uses a browser, with no client to install. +A small macOS app that moves files and text between your Mac and other devices (phone, tablet…) over WiFi, with optional browser-only remote sharing — the other end just uses a browser, with no client to install. - Pick a file or folder, scan the QR code that appears (or just open the link), and browse the files in your phone's browser (HTML, PDF, Markdown, images…) - Two-way text transfer — type some text and share it via a QR code, or send text back from the phone to the Mac @@ -22,6 +22,7 @@ A small macOS app that moves files and text between your Mac and other devices ( - After you share, the other end can send files and text back - Shows who's currently viewing - `localshare` command line: bring up the window to share, or `--headless` to print the link and QR code in the terminal +- Optional browser-only remote access through a self-hosted LocalShare Server; remote shares are read-only ## Why this app exists @@ -40,6 +41,10 @@ The QR code points to something like `http://192.168.x.x:8080/?t=` > ⚠️ Traffic is plain HTTP (unencrypted). Best avoided on public networks like airports and cafés. +### Remote browser sharing + +Run the self-contained [`localshare-server`](server/README.md), create a one-time Enrollment Key, then enter the Server address and key in Settings. After pairing, the app remembers the device credential in Keychain; active shares use Connect/Disconnect and the receiving device only needs a browser. See the [remote-sharing plan](docs/REMOTE_SHARING_PLAN.md). + ## Terminal usage In Settings → Command-Line Tool, click "Install". After that you can share from the terminal in one command: diff --git a/README_CN.md b/README_CN.md index a8f886e..0345907 100644 --- a/README_CN.md +++ b/README_CN.md @@ -2,7 +2,7 @@ 简体中文 | [English](README.md) -一个 macOS 小工具,在本机和手机等设备之间通过 WiFi 互通文件和文本,对端使用浏览器,无须安装客户端。 +一个 macOS 小工具,在本机和手机等设备之间通过 WiFi 互通文件和文本,也支持可选的纯浏览器跨网络分享,对端无须安装客户端。 - 选中文件或文件夹,相机扫描出现的二维码,或直接打开链接,在手机浏览器里浏览这些文件(HTML、PDF、Markdown、图片……) - 支持双向传送文本,编辑文字通过二维码分享后传输,并可从手机端发送文本回 Mac @@ -22,6 +22,7 @@ - 支持分享后,由对端反向传输文件、文本 - 显示当前在线访客 - 命令行 `localshare` 命令:唤起窗口分享,或 `--headless` 在终端显示链接和二维码 +- 可选通过自托管 LocalShare Server 远程访问浏览器;远程分享只读 ## 为什么有这个 app @@ -40,6 +41,10 @@ > ⚠️ 传输是明文 HTTP(没有加密)。机场咖啡厅等公共网络下最好不要使用。 +### 远程浏览器分享 + +运行自包含的 [`localshare-server`](server/README.md),生成一次性 Enrollment Key,在「设置」里填写 Server 地址和 key。配对后设备凭据会保存到 Keychain;正在分享时只需点击 Connect/Disconnect,对端设备只需浏览器。详见[远程分享计划](docs/REMOTE_SHARING_PLAN.md)。 + ## 终端用法 在「设置 → 命令行工具」里点「安装」,之后可以在终端一键分享: diff --git a/Sources/LocalShare/App.swift b/Sources/LocalShare/App.swift index 710e0dc..11e7664 100644 --- a/Sources/LocalShare/App.swift +++ b/Sources/LocalShare/App.swift @@ -115,6 +115,10 @@ final class AppDelegate: NSObject, NSApplicationDelegate { NSApp.activate(ignoringOtherApps: true) } + func applicationWillTerminate(_ notification: Notification) { + AppState.shared?.stop() + } + // 关窗不退出:菜单栏图标常驻,服务继续广播;退出走菜单栏「退出」或 ⌘Q。 func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { false diff --git a/Sources/LocalShare/AppState.swift b/Sources/LocalShare/AppState.swift index 8c04db4..503d585 100644 --- a/Sources/LocalShare/AppState.swift +++ b/Sources/LocalShare/AppState.swift @@ -1,5 +1,6 @@ import AppKit import SwiftUI +import Network // 全局状态:当前分享对象(文件夹或单个文件)、服务运行态、网络接口候选、二维码 URL、 // 监听端口配置、最近分享历史、屏幕路由、权限(读取常开,上传可切换)。 @@ -11,7 +12,7 @@ final class AppState: ObservableObject { enum Screen { case share, file, text, settings, history } enum AppearancePref: String { case system, light, dark } enum SettingsCategory: CaseIterable { - case network, permission, appearanceLanguage, main, update, cli + case network, remote, permission, appearanceLanguage, main, update, cli } // 设计默认窗口尺寸(票据风竖窗,设计稿 400×720)。供 App 的 .defaultSize 与 @@ -53,6 +54,13 @@ final class AppState: ObservableObject { @Published var tailscaleAccessEnabled = false // Tailscale 入站闸门(默认关,持久化) @Published private(set) var tailscaleStatus: TailscaleStatus? @Published var cliStatus: CLIInstaller.Status = .notInstalled // 命令行工具安装状态 + @Published var remoteSettings = RemoteSettings() + @Published var remoteEnrollmentKey = "" // 仅用于首次配对;成功后不落盘 + @Published private(set) var remoteAccessEnabled = false + @Published private(set) var remoteStatus: RemoteAgent.Status = .disconnected + @Published private(set) var remotePaired = false + @Published private(set) var remoteShareBaseURL: String? + @Published private(set) var remoteError: String? // GUI 进程仅构造一次,init 末尾自登记;AppDelegate 的 open 事件回调经它触达状态。 static private(set) var shared: AppState? @@ -63,6 +71,9 @@ final class AppState: ObservableObject { @Published private(set) var accessCode = AccessCode.generate() private var server: FileServer? private var viewerTimer: Timer? + private let remoteAgent = RemoteAgent() + private var networkMonitor: NWPathMonitor? + private var networkPathStatus: NWPath.Status? private var networkRefreshID = UUID() private var wideLayoutBeforeSettings: Bool? // 已移除的“仅当前网络可见”开关不再面向新用户,但升级时必须继续兑现既有的隔离选择, @@ -80,6 +91,7 @@ final class AppState: ObservableObject { private let textInboxKey = "textInboxEnabled" // 收件箱闸门(默认关) private let persistReceivedKey = "persistReceivedText" // 是否记住收到的文本(默认关) private let receivedTextsKey = "receivedTexts" // 收件箱内容(仅 persistReceivedText 开时写入) + private let remoteServerKey = "remoteServerAddress" private let accessCodeEnabledKey = "accessCodeEnabled" private let tailscaleAccessEnabledKey = "tailscaleAccessEnabled" // 配置端口优先,其余作回退(8080 列入回退以防配置端口占用)。 @@ -88,6 +100,24 @@ final class AppState: ObservableObject { init() { let savedPort = UserDefaults.standard.integer(forKey: portKey) if (1024...65535).contains(savedPort) { configuredPort = in_port_t(savedPort) } + remoteSettings = RemoteSettings(serverAddress: UserDefaults.standard.string(forKey: remoteServerKey) ?? "") + remotePaired = remoteAgent.isPaired(for: remoteSettings.serverURL) + remoteAgent.onStateChange = { [weak self] status, error in + guard let self else { return } + self.remoteStatus = status + self.remoteError = error + if status == .connected { + self.remotePaired = self.remoteAgent.isPaired + self.remoteEnrollmentKey = "" + } else if status == .disconnected, error != nil, self.remoteAccessEnabled { + // 首次配对/凭证读取失败是终止态,不应把未连接的 LAN 分享继续锁在远程只读模式。 + self.remoteAccessEnabled = false + self.applyRemotePolicy() + } + } + remoteAgent.onShareURLChange = { [weak self] url in + self?.remoteShareBaseURL = url + } accessCodeEnabled = UserDefaults.standard.bool(forKey: accessCodeEnabledKey) tailscaleAccessEnabled = UserDefaults.standard.bool(forKey: tailscaleAccessEnabledKey) legacyBindSelectedOnly = UserDefaults.standard.bool(forKey: bindSelectedOnlyKey) @@ -130,6 +160,7 @@ final class AppState: ObservableObject { AppDelegate.pendingOpenURLs = [] setShared(urls) } + startNetworkMonitoring() } // MARK: - 选择态派生 @@ -149,17 +180,23 @@ final class AppState: ObservableObject { // MARK: - 派生 URL / 二维码 // 文件夹/多选模式 → 根地址;单文件模式 → 直链该文件(路径仅供浏览器显示文件名/扩展名)。 - private func makeURL(host: String) -> String { - let q = "?t=\(token)" + private func makeURL(base: String, authenticated: Bool = true) -> String { + let q = authenticated ? "?t=\(token)" : "" + let root = base.hasSuffix("/") ? String(base.dropLast()) : base // 传递文本(收/发合一):二维码恒指 /ls/text——这一页既显示电脑共享的文本(可读可复制), // 又在「允许手机发回来」开着时挂出发送框;只收文本时它退化成纯发送页。扫一次,双向都在这。 - if isTextOnly || isReceiveOnly { return "http://\(host):\(port)/ls/text\(q)" } + if isTextOnly || isReceiveOnly { return "\(root)/ls/text\(q)" } // 单文件直链该文件(文本与文件共存时走虚拟根,不直链,故附带 !hasText)。 if sharedIsFile, !hasText, let name = sharedItems.first?.lastPathComponent, let enc = name.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) { - return "http://\(host):\(port)/\(enc)\(q)" + return "\(root)/\(enc)\(q)" } - return "http://\(host):\(port)/\(q)" + return "\(root)/\(q)" + } + + var remoteURL: String? { + guard remoteAccessEnabled, isRunning, let base = remoteShareBaseURL else { return nil } + return makeURL(base: base, authenticated: false) } private var primaryHost: String? { @@ -167,6 +204,7 @@ final class AppState: ObservableObject { } var primaryURL: String? { + if let remoteURL { return remoteURL } guard isRunning, port != 0, let host = primaryHost else { return nil } return makeURL(host: host) } @@ -174,12 +212,13 @@ final class AppState: ObservableObject { // 票据上的手输入口:默认仍显示/复制完整 token URL;访问码模式只给 origin,浏览器再经 /ls/join // 输入短码。二维码始终使用 primaryURL,不因展示模式降低安全性或增加扫码步骤。 var presentedURL: String? { + if let remoteURL { return remoteURL } guard isRunning, port != 0, let host = primaryHost else { return nil } return makePresentedURL(host: host) } var presentedAccessCode: String? { - isRunning && accessCodeEnabled ? accessCode : nil + isRunning && remoteURL == nil && accessCodeEnabled ? accessCode : nil } var qrImage: NSImage? { @@ -190,7 +229,7 @@ final class AppState: ObservableObject { // 主地址之外的入口统一收在折叠区。访问码模式下它们只显示 origin,与主地址共用一枚短码; // 普通模式则保留当前分享路径和 token。Tailscale 两项只在用户显式开放且检测到时出现。 var otherAddresses: [ShareAddress] { - guard isRunning, port != 0 else { return [] } + guard isRunning, port != 0, remoteURL == nil else { return [] } var result: [ShareAddress] = [] if selectedInterface != nil, let hostname = NetworkInfo.localHostName(), hostname != primaryHost { result.append(ShareAddress(kind: .localHostname, scope: .localNetwork, @@ -213,6 +252,10 @@ final class AppState: ObservableObject { accessCodeEnabled ? "http://\(host):\(port)" : makeURL(host: host) } + private func makeURL(host: String) -> String { + makeURL(base: "http://\(host):\(port)") + } + var hasNetwork: Bool { !interfaces.isEmpty || (tailscaleAccessEnabled && tailscaleStatus != nil) } // MARK: - 网络 @@ -237,6 +280,98 @@ final class AppState: ObservableObject { } } + var canEnableRemote: Bool { isRunning && !sharedItems.isEmpty && remoteSettings.isValid } + + func saveRemoteSettings(_ settings: RemoteSettings, enrollmentKey: String = "") { + if remoteAccessEnabled { disconnectRemote() } + remoteSettings = settings + remoteEnrollmentKey = enrollmentKey + UserDefaults.standard.set(settings.serverAddress, forKey: remoteServerKey) + remotePaired = remoteAgent.isPaired(for: remoteSettings.serverURL) + } + + func connectRemote(enrollmentKey: String) { + guard canEnableRemote, let serverURL = remoteSettings.serverURL else { + remoteError = L.remoteConfigHint(lang) + return + } + remoteAccessEnabled = true + remoteError = nil + applyRemotePolicy() + remoteAgent.connect(serverURL: serverURL, enrollmentKey: enrollmentKey, + localBaseURL: localShareBaseURL, localToken: token, + deviceName: Host.current().localizedName ?? "Mac", lang: lang) + } + + func disconnectRemote() { + remoteAgent.disconnect() + remoteAccessEnabled = false + remoteShareBaseURL = nil + remotePaired = remoteAgent.isPaired + applyRemotePolicy() + } + + func forgetRemoteDevice() { + remoteAccessEnabled = false + remoteShareBaseURL = nil + applyRemotePolicy() + do { + try remoteAgent.forgetDevice() + remotePaired = false + } catch let failure as RemoteKeychain.Failure { + remotePaired = remoteAgent.isPaired + remoteError = LStr.remoteKeychainFailed(failure.reason, lang) + } catch { + remotePaired = remoteAgent.isPaired + remoteError = LStr.remoteKeychainFailed(error.localizedDescription, lang) + } + } + + private var agentLocalAddress: String { + legacyBindSelectedOnly ? (selectedInterface?.ip ?? "127.0.0.1") : "127.0.0.1" + } + + private var localShareBaseURL: URL { + URL(string: "http://\(agentLocalAddress):\(port)")! + } + + private func applyRemotePolicy() { + server?.remoteAccessEnabled = remoteAccessEnabled + server?.uploadEnabled = permission.add && canToggleUpload && !remoteAccessEnabled + server?.textInboxEnabled = textInboxEnabled && !remoteAccessEnabled + server?.sharedText = remoteAccessEnabled ? nil : (hasText ? sharedText : nil) + } + + private func startRemoteAgent() { + guard remoteAccessEnabled, isRunning, let serverURL = remoteSettings.serverURL else { return } + remoteAgent.connect(serverURL: serverURL, enrollmentKey: "", localBaseURL: localShareBaseURL, + localToken: token, deviceName: Host.current().localizedName ?? "Mac", lang: lang) + } + + private func startNetworkMonitoring() { + let monitor = NWPathMonitor() + networkMonitor = monitor + let queue = DispatchQueue(label: "localshare.network-monitor") + monitor.pathUpdateHandler = { [weak self] path in + let status = path.status + Task { @MainActor [weak self] in + guard let self else { return } + let statusChanged = self.networkPathStatus != status + self.networkPathStatus = status + guard self.isRunning else { return } + let oldInterfaces = self.interfaces + self.refreshNetwork() + guard self.remoteAccessEnabled else { return } + if self.legacyBindSelectedOnly && oldInterfaces != self.interfaces { + self.rebindServer() + } else if statusChanged || oldInterfaces != self.interfaces { + self.startRemoteAgent() + } + } + } + monitor.start(queue: queue) + } + // MARK: - 选择分享对象 func pickFolder() { pick(files: false, directories: true, message: L.pickFolderMsg(lang)) } @@ -276,8 +411,11 @@ final class AppState: ObservableObject { server?.setCredentials(token: token, accessCode: accessCodeEnabled ? accessCode : nil) server?.tailscaleAccessEnabled = tailscaleAccessEnabled server?.sharedText = hasText ? sharedText : nil - server?.textInboxEnabled = textInboxEnabled server?.share = currentShare + applyRemotePolicy() + if remoteAccessEnabled, isRunning { + remoteAgent.updateLocalShare(baseURL: localShareBaseURL, token: token) + } } // 分享 / 更新一段文本(Mac→手机)。token 的「会话」维度与分享文件一致:只在会话边界轮换 @@ -346,11 +484,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() { @@ -380,11 +519,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() // 从空状态开启 → 起服务(只收模式) @@ -464,8 +604,9 @@ final class AppState: ObservableObject { fs.accessCode = accessCodeEnabled ? accessCode : nil fs.tailscaleAccessEnabled = tailscaleAccessEnabled fs.sharedText = hasText ? sharedText : nil - fs.uploadEnabled = permission.add && canToggleUpload - fs.textInboxEnabled = textInboxEnabled + fs.remoteAccessEnabled = remoteAccessEnabled + fs.uploadEnabled = permission.add && canToggleUpload && !remoteAccessEnabled + fs.textInboxEnabled = textInboxEnabled && !remoteAccessEnabled fs.onUpload = { url in // socket 线程 → 主线程 Task { @MainActor [weak self] in self?.recordReceived(url) } } @@ -487,6 +628,7 @@ final class AppState: ObservableObject { isRunning = true lastError = nil startViewerPolling() + if remoteAccessEnabled { startRemoteAgent() } } catch { lastError = LStr.startFailed(error.localizedDescription, lang) isRunning = false @@ -505,12 +647,14 @@ final class AppState: ObservableObject { guard isRunning else { return } viewerTimer?.invalidate() viewerTimer = nil + if remoteAccessEnabled { remoteAgent.disconnect() } server?.stop() server = nil start() } func stop() { + disconnectRemote() viewerTimer?.invalidate() viewerTimer = nil viewerCount = 0 diff --git a/Sources/LocalShare/Components.swift b/Sources/LocalShare/Components.swift index 25abc75..320fbc9 100644 --- a/Sources/LocalShare/Components.swift +++ b/Sources/LocalShare/Components.swift @@ -1,4 +1,19 @@ import SwiftUI + +struct GlobeLineIcon: View { + let color: Color + + var body: some View { + ZStack { + Circle().stroke(color, lineWidth: 1.7) + Ellipse().stroke(color, lineWidth: 1.6).frame(width: 7, height: 15) + Capsule().fill(color).frame(width: 13, height: 1.6) + } + .frame(width: 15, height: 15) + .frame(width: 16, height: 16) + .accessibilityHidden(true) + } +} import AppKit // 票据风 UI 组件库(DESIGN.md §5)。全部接受 `t: Theme` 显式传入(与设计稿 props 结构一一对应), diff --git a/Sources/LocalShare/CsvViewer.swift b/Sources/LocalShare/CsvViewer.swift index 8438c8c..72ad8e5 100644 --- a/Sources/LocalShare/CsvViewer.swift +++ b/Sources/LocalShare/CsvViewer.swift @@ -5,9 +5,10 @@ import Foundation // 首行恒作表头。点击表头排序(采样判定数值列走数值比较)、输入框按任意单元格筛选行; // 排序/筛选作用于全量数据,DOM 按 1000 行一档「再显示」渐进渲染——几十 MB 不卡死页面。 enum CsvViewer { - static func html(fileName: String, crumbs: String?, canUpload: Bool, canReceiveText: Bool = false, lang: Lang) -> String { + static func html(fileName: String, requestPath: String = "/", crumbs: String?, canUpload: Bool, + canReceiveText: Bool = false, lang: Lang) -> String { PreviewPage.html( - fileName: fileName, crumbs: crumbs, canUpload: canUpload, lang: lang, + fileName: fileName, requestPath: requestPath, crumbs: crumbs, canUpload: canUpload, lang: lang, body: """
diff --git a/Sources/LocalShare/DirectoryListing.swift b/Sources/LocalShare/DirectoryListing.swift index 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 new file mode 100644 index 0000000..dbe61ae --- /dev/null +++ b/Sources/LocalShare/RemoteAgent.swift @@ -0,0 +1,394 @@ +import Foundation + +@MainActor +final class RemoteAgent: NSObject { + private static let pairedServerKey = "remotePairedServerAddress" + + enum Status: Equatable { + case disconnected + case connecting + case connected + case reconnecting + } + + private var serverURL: URL? + private var localBaseURL: URL? + private var localToken = "" + private var cachedDeviceToken: String? + private var cachedCredentialServer: String? + private var shouldConnect = false + private var retryDelay: TimeInterval = 1 + private var retryTask: Task? + private var heartbeatTask: Task? + private var webSocket: URLSessionWebSocketTask? + private var session: URLSession? + private var localRequests: [String: RemoteLocalRequest] = [:] + private var localRequestLanguages: [String: Lang] = [:] + private var lang: Lang = .systemDefault + + private(set) var status: Status = .disconnected + private(set) var lastError: String? + private(set) var shareBaseURL: String? + var onStateChange: ((Status, String?) -> Void)? + var onShareURLChange: ((String?) -> Void)? + + var isPaired: Bool { + isPaired(for: serverURL) + } + + func isPaired(for serverURL: URL?) -> Bool { + guard let serverURL else { return false } + return UserDefaults.standard.string(forKey: Self.pairedServerKey) == serverURL.absoluteString + } + + func connect(serverURL: URL, enrollmentKey: String, localBaseURL: URL, localToken: String, + deviceName: String, lang: Lang, persistCredential: Bool = true) { + shouldConnect = true + self.lang = lang + self.serverURL = serverURL + self.localBaseURL = localBaseURL + self.localToken = localToken + retryTask?.cancel() + retryTask = nil + closeSocket() + setStatus(.connecting, error: nil) + + Task { @MainActor [weak self] in + guard let self else { return } + do { + if !enrollmentKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + let credentials = try await Self.enroll(serverURL: serverURL, + key: enrollmentKey.trimmingCharacters(in: .whitespacesAndNewlines), + deviceName: deviceName, lang: lang) + if persistCredential { + do { + try RemoteKeychain.set(credentials.deviceToken, for: RemoteKeychain.deviceToken) + } catch let failure as RemoteKeychain.Failure { + throw RemoteDisplayError(message: LStr.remoteKeychainFailed(failure.reason, lang)) + } + } + cachedDeviceToken = credentials.deviceToken + cachedCredentialServer = serverURL.absoluteString + if persistCredential { + UserDefaults.standard.set(serverURL.absoluteString, forKey: Self.pairedServerKey) + } + } + guard cachedDeviceToken(for: serverURL) != nil else { + throw RemoteDisplayError(message: L.remoteEnrollmentRequired(lang)) + } + self.openSocket() + } catch { + self.shouldConnect = false + self.setStatus(.disconnected, error: error.localizedDescription) + } + } + } + + func disconnect() { + shouldConnect = false + retryTask?.cancel() + retryTask = nil + heartbeatTask?.cancel() + heartbeatTask = nil + closeSocket() + shareBaseURL = nil + onShareURLChange?(nil) + setStatus(.disconnected, error: nil) + } + + func forgetDevice() throws { + disconnect() + try RemoteKeychain.removeAll() + cachedDeviceToken = nil + cachedCredentialServer = nil + UserDefaults.standard.removeObject(forKey: Self.pairedServerKey) + } + + func updateLocalShare(baseURL: URL, token: String) { + localBaseURL = baseURL + localToken = token + guard status == .connected else { return } + send(RemoteWireMessage(type: "share.start", protocolVersion: 1, shareID: token, deviceName: nil)) + } + + private func openSocket() { + guard let serverURL, + let token = cachedDeviceToken(for: serverURL), + let endpoint = agentURL(for: serverURL) else { + shouldConnect = false + setStatus(.disconnected, error: L.remoteInvalidCredentials(lang)) + return + } + let configuration = URLSessionConfiguration.ephemeral + configuration.waitsForConnectivity = true + let session = URLSession(configuration: configuration, delegate: self, delegateQueue: OperationQueue()) + var request = URLRequest(url: endpoint) + request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") + self.session = session + let task = session.webSocketTask(with: request) + webSocket = task + task.resume() + } + + private func cachedDeviceToken(for serverURL: URL) -> String? { + let address = serverURL.absoluteString + if cachedCredentialServer == address, let cachedDeviceToken { + return cachedDeviceToken + } + if UserDefaults.standard.string(forKey: Self.pairedServerKey) != address { + // Compatibility for credentials created before the paired-server marker existed. + guard RemoteKeychain.value(for: RemoteKeychain.serverAddress) == address else { return nil } + UserDefaults.standard.set(address, forKey: Self.pairedServerKey) + } + guard let token = RemoteKeychain.value(for: RemoteKeychain.deviceToken), !token.isEmpty else { return nil } + cachedDeviceToken = token + cachedCredentialServer = address + return token + } + + private func agentURL(for base: URL) -> URL? { + guard var components = URLComponents(url: base, resolvingAgainstBaseURL: false) else { return nil } + components.scheme = components.scheme?.lowercased() == "https" ? "wss" : "ws" + components.path = "/api/v1/agent" + components.query = nil + components.fragment = nil + return components.url + } + + private func receiveNext(from task: URLSessionWebSocketTask) { + task.receive { [weak self] result in + Task { @MainActor [weak self] in + guard let self, self.webSocket === task else { return } + switch result { + case .success(let message): self.handle(message) + case .failure(let error): self.connectionEnded(error) + } + if self.webSocket === task { self.receiveNext(from: task) } + } + } + } + + private func handle(_ message: URLSessionWebSocketTask.Message) { + switch message { + case .string(let raw): + guard let data = raw.data(using: .utf8), + let message = try? JSONDecoder().decode(RemoteWireMessage.self, from: data) else { return } + handle(message) + case .data: + // V1 only streams file bytes from the Mac to the server. + break + @unknown default: + break + } + } + + private func handle(_ message: RemoteWireMessage) { + switch message.type { + case "hello": + if let url = shareBaseURL { onShareURLChange?(url) } + case "share.ready": + shareBaseURL = message.shareURL + onShareURLChange?(message.shareURL) + case "request.begin": + guard let id = message.requestID, let method = message.method, let path = message.path else { return } + startLocalRequest(id: id, shareID: message.shareID, method: method, + path: path, headers: message.headers ?? [:]) + case "request.cancel": + if let id = message.requestID { cancelLocalRequest(id) } + case "error": + lastError = L.remoteServerError(lang) + onStateChange?(status, lastError) + default: + break + } + } + + private func startLocalRequest(id: String, shareID: String?, method: String, + path: String, headers: [String: String]) { + guard RemoteShareGate.accepts(shareID, currentToken: localToken) else { + send(RemoteWireMessage(type: "response.begin", requestID: id, + headers: ["Content-Length": "0"], status: 410)) + send(RemoteWireMessage(type: "response.end", requestID: id)) + return + } + guard method == "GET" || method == "HEAD" else { + send(RemoteWireMessage(type: "response.begin", requestID: id, + headers: ["Content-Length": "0", "Allow": "GET, HEAD"], status: 405)) + send(RemoteWireMessage(type: "response.end", requestID: id)) + return + } + let requestLang = Lang.fromAcceptLanguage(headers["Accept-Language"]) + guard let base = localBaseURL, !localToken.isEmpty else { + send(RemoteWireMessage(type: "error", requestID: id, error: L.remoteLocalUnavailable(requestLang))) + return + } + let separator = path.contains("?") ? "&" : "?" + guard path.hasPrefix("/"), + let url = URL(string: base.absoluteString + path + separator + "t=" + localToken) else { + send(RemoteWireMessage(type: "error", requestID: id, error: L.remoteInvalidRequest(requestLang))) + return + } + var request = URLRequest(url: url) + request.httpMethod = method + for (key, value) in headers where ["Accept", "Accept-Language", "If-Modified-Since", "If-None-Match", "Range", "User-Agent"].contains(key) { + request.setValue(value, forHTTPHeaderField: key) + } + let local = RemoteLocalRequest(id: id, request: request) { [weak self] event in + Task { @MainActor [weak self] in self?.handleLocalEvent(id: id, event: event) } + } + localRequests[id] = local + localRequestLanguages[id] = requestLang + local.start() + } + + private func handleLocalEvent(id: String, event: RemoteLocalEvent) { + guard localRequests[id] != nil else { return } + switch event { + case .headers(let status, let headers): + send(RemoteWireMessage(type: "response.begin", requestID: id, headers: headers, status: status)) + case .data(let data): + sendDataResponse(id, data: data) + case .finished(let error): + if let error, (error as NSError).code != NSURLErrorCancelled { + let requestLang = localRequestLanguages[id] ?? lang + send(RemoteWireMessage(type: "error", requestID: id, + error: LStr.remoteRequestFailed(error.localizedDescription, requestLang))) + } else { + send(RemoteWireMessage(type: "response.end", requestID: id)) + } + localRequests.removeValue(forKey: id)?.invalidate() + localRequestLanguages.removeValue(forKey: id) + } + } + + private func sendDataResponse(_ requestID: String, data: Data) { + guard let webSocket else { return } + webSocket.send(.data(Self.encodeData(requestID, data))) { [weak self] error in + Task { @MainActor [weak self] in + guard let self else { return } + if let error { self.connectionEnded(error, from: webSocket) } + else { self.localRequests[requestID]?.resumeAfterChunk() } + } + } + } + + private func send(_ message: RemoteWireMessage) { + guard let webSocket, let data = try? JSONEncoder().encode(message), let raw = String(data: data, encoding: .utf8) else { return } + webSocket.send(.string(raw)) { [weak self] error in + if let error { + Task { @MainActor [weak self] in self?.connectionEnded(error, from: webSocket) } + } + } + } + + private func cancelLocalRequest(_ id: String) { + localRequests.removeValue(forKey: id)?.cancel() + localRequestLanguages.removeValue(forKey: id) + } + + private func cancelLocalRequests() { + for request in localRequests.values { request.cancel() } + localRequests.removeAll() + localRequestLanguages.removeAll() + } + + private func connectionEnded(_ error: Error, from task: URLSessionWebSocketTask? = nil) { + if let task, webSocket !== task { return } + guard webSocket != nil else { return } + closeSocket() + shareBaseURL = nil + onShareURLChange?(nil) + let message = error.localizedDescription + lastError = message + guard shouldConnect else { + setStatus(.disconnected, error: message) + return + } + setStatus(.reconnecting, error: message) + scheduleRetry() + } + + private func scheduleRetry() { + guard shouldConnect, retryTask == nil else { return } + let delay = retryDelay + retryDelay = min(retryDelay * 2, 60) + retryTask = Task { @MainActor [weak self] in + do { try await Task.sleep(nanoseconds: UInt64(delay * 1_000_000_000)) } catch { return } + guard let self, self.shouldConnect else { return } + self.retryTask = nil + self.openSocket() + } + } + + private func closeSocket() { + heartbeatTask?.cancel() + heartbeatTask = nil + cancelLocalRequests() + webSocket?.cancel(with: .normalClosure, reason: nil) + webSocket = nil + session?.invalidateAndCancel() + session = nil + } + + private func setStatus(_ value: Status, error: String?) { + status = value + lastError = error + onStateChange?(value, error) + } + + private static func enroll(serverURL: URL, key: String, deviceName: String, + lang: Lang) async throws -> RemoteEnrollmentResponse { + let endpoint = serverURL.appendingPathComponent("api/v1/enroll") + var request = URLRequest(url: endpoint) + request.httpMethod = "POST" + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.httpBody = try JSONEncoder().encode( + RemoteWireMessage(type: "enroll", deviceName: deviceName, enrollmentKey: key) + ) + let (data, response) = try await URLSession.shared.data(for: request) + guard let http = response as? HTTPURLResponse, (200..<300).contains(http.statusCode) else { + throw RemoteDisplayError(message: L.remoteEnrollmentRejected(lang)) + } + return try JSONDecoder().decode(RemoteEnrollmentResponse.self, from: data) + } + + static func encodeData(_ id: String, _ data: Data) -> Data { + RemoteFrameCodec.encode(id, data) + } +} + +extension RemoteAgent: URLSessionWebSocketDelegate { + nonisolated func urlSession(_ session: URLSession, webSocketTask: URLSessionWebSocketTask, + didOpenWithProtocol protocol: String?) { + Task { @MainActor [weak self] in + guard let self, self.webSocket === webSocketTask else { return } + self.retryDelay = 1 + self.setStatus(.connected, error: nil) + self.send(RemoteWireMessage(type: "hello", protocolVersion: 1, + deviceName: Host.current().localizedName ?? "Mac")) + if self.localBaseURL != nil { + self.send(RemoteWireMessage(type: "share.start", protocolVersion: 1, + shareID: self.localToken)) + } + self.heartbeatTask?.cancel() + self.heartbeatTask = Task { @MainActor [weak self] in + while !Task.isCancelled { + do { try await Task.sleep(nanoseconds: 15_000_000_000) } catch { return } + guard let self, self.webSocket === webSocketTask else { return } + webSocketTask.sendPing { error in + if let error { + Task { @MainActor [weak self] in self?.connectionEnded(error, from: webSocketTask) } + } + } + } + } + self.receiveNext(from: webSocketTask) + } + } + + nonisolated func urlSession(_ session: URLSession, task: URLSessionTask, + didCompleteWithError error: Error?) { + guard let error, let webSocketTask = task as? URLSessionWebSocketTask else { return } + Task { @MainActor [weak self] in self?.connectionEnded(error, from: webSocketTask) } + } +} diff --git a/Sources/LocalShare/RemoteLocalRequest.swift b/Sources/LocalShare/RemoteLocalRequest.swift new file mode 100644 index 0000000..05eb368 --- /dev/null +++ b/Sources/LocalShare/RemoteLocalRequest.swift @@ -0,0 +1,60 @@ +import Foundation + +final class RemoteLocalRequest: NSObject, @unchecked Sendable, URLSessionDataDelegate, URLSessionTaskDelegate { + let id: String + private let request: URLRequest + private let emit: (RemoteLocalEvent) -> Void + private var session: URLSession? + private var task: URLSessionDataTask? + + init(id: String, request: URLRequest, emit: @escaping (RemoteLocalEvent) -> Void) { + self.id = id + self.request = request + self.emit = emit + } + + func start() { + let queue = OperationQueue() + queue.maxConcurrentOperationCount = 1 + let session = URLSession(configuration: .ephemeral, delegate: self, delegateQueue: queue) + self.session = session + let task = session.dataTask(with: request) + self.task = task + task.resume() + } + + func resumeAfterChunk() { task?.resume() } + func cancel() { task?.cancel(); invalidate() } + func invalidate() { session?.invalidateAndCancel(); session = nil; task = nil } + + func urlSession(_ session: URLSession, task: URLSessionTask, + willPerformHTTPRedirection response: HTTPURLResponse, + newRequest request: URLRequest, + completionHandler: @escaping (URLRequest?) -> Void) { + completionHandler(RemoteRedirectPolicy.shouldFollow(response) ? request : nil) + } + + func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, + didReceive response: URLResponse, + completionHandler: @escaping (URLSession.ResponseDisposition) -> Void) { + guard let response = response as? HTTPURLResponse else { + completionHandler(.cancel) + return + } + var headers: [String: String] = [:] + for (key, value) in response.allHeaderFields { + if let key = key as? String, let value = value as? String { headers[key] = value } + } + emit(.headers(response.statusCode, headers)) + completionHandler(.allow) + } + + func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, didReceive data: Data) { + dataTask.suspend() + emit(.data(data)) + } + + func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) { + emit(.finished(error)) + } +} diff --git a/Sources/LocalShare/RemoteProtocol.swift b/Sources/LocalShare/RemoteProtocol.swift new file mode 100644 index 0000000..10580aa --- /dev/null +++ b/Sources/LocalShare/RemoteProtocol.swift @@ -0,0 +1,73 @@ +import Foundation + +struct RemoteDisplayError: LocalizedError { + let message: String + var errorDescription: String? { message } +} + +struct RemoteWireMessage: Codable { + var type: String + var protocolVersion: Int? = nil + var requestID: String? = nil + var method: String? = nil + var path: String? = nil + var headers: [String: String]? = nil + var status: Int? = nil + var error: String? = nil + var shareURL: String? = nil + var shareID: String? = nil + var deviceName: String? = nil + var enrollmentKey: String? = nil + + enum CodingKeys: String, CodingKey { + case type + case protocolVersion = "protocol" + case requestID = "request_id" + case method, path, headers, status, error + case shareURL = "share_url" + case shareID = "share_id" + case deviceName = "device_name" + case enrollmentKey = "enrollment_key" + } +} + +struct RemoteEnrollmentResponse: Decodable { + let deviceID: String + let deviceToken: String + + enum CodingKeys: String, CodingKey { + case deviceID = "device_id" + case deviceToken = "device_token" + } +} + +enum RemoteLocalEvent { + case headers(Int, [String: String]) + case data(Data) + case finished(Error?) +} + +enum RemoteFrameCodec { + static func encode(_ id: String, _ data: Data) -> Data { + let idData = Data(id.utf8) + var result = Data() + var length = UInt32(idData.count).bigEndian + withUnsafeBytes(of: &length) { result.append(contentsOf: $0) } + result.append(idData) + result.append(data) + return result + } +} + +enum RemoteRedirectPolicy { + static func shouldFollow(_ response: HTTPURLResponse) -> Bool { + response.statusCode == 302 && + (response.value(forHTTPHeaderField: "Set-Cookie") ?? "").contains("ls_token=") + } +} + +enum RemoteShareGate { + static func accepts(_ shareID: String?, currentToken: String) -> Bool { + !currentToken.isEmpty && shareID == currentToken + } +} diff --git a/Sources/LocalShare/RemoteSettings.swift b/Sources/LocalShare/RemoteSettings.swift new file mode 100644 index 0000000..a564fca --- /dev/null +++ b/Sources/LocalShare/RemoteSettings.swift @@ -0,0 +1,74 @@ +import Foundation +import Security + +struct RemoteSettings: Equatable { + var serverAddress = "" + + var serverURL: URL? { + let raw = serverAddress.trimmingCharacters(in: .whitespacesAndNewlines) + guard let url = URL(string: raw), + let scheme = url.scheme?.lowercased(), scheme == "http" || scheme == "https", + url.host != nil, url.user == nil, url.password == nil, + url.query == nil, url.fragment == nil, + url.path.isEmpty || url.path == "/" else { return nil } + var components = URLComponents(url: url, resolvingAgainstBaseURL: false) + components?.path = "" + return components?.url + } + + var isValid: Bool { serverURL != nil } +} + +enum RemoteKeychain { + private static let service = "live.ezze.localshare.remote" + static let deviceToken = "device-token" + static let serverAddress = "server-address" + + struct Failure: Error { + let status: OSStatus + + var reason: String { + (SecCopyErrorMessageString(status, nil) as String?) ?? "OSStatus (status)" + } + } + + static func value(for account: String) -> String? { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: account, + kSecReturnData as String: true, + kSecMatchLimit as String: kSecMatchLimitOne, + ] + var result: CFTypeRef? + guard SecItemCopyMatching(query as CFDictionary, &result) == errSecSuccess, + let data = result as? Data else { return nil } + return String(data: data, encoding: .utf8) + } + + static func set(_ value: String, for account: String) throws { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: account, + ] + let data = Data(value.utf8) + let attributes: [String: Any] = [kSecValueData as String: data] + let updateStatus = SecItemUpdate(query as CFDictionary, attributes as CFDictionary) + if updateStatus == errSecSuccess { return } + guard updateStatus == errSecItemNotFound else { throw Failure(status: updateStatus) } + var item = query + item[kSecValueData as String] = data + let addStatus = SecItemAdd(item as CFDictionary, nil) + guard addStatus == errSecSuccess else { throw Failure(status: addStatus) } + } + + static func removeAll() throws { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + ] + let status = SecItemDelete(query as CFDictionary) + guard status == errSecSuccess || status == errSecItemNotFound else { throw Failure(status: status) } + } +} diff --git a/Sources/LocalShare/SendTextPage.swift b/Sources/LocalShare/SendTextPage.swift index 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/SettingsScreen.swift b/Sources/LocalShare/SettingsScreen.swift index 2aa9476..9c5aa42 100644 --- a/Sources/LocalShare/SettingsScreen.swift +++ b/Sources/LocalShare/SettingsScreen.swift @@ -8,6 +8,8 @@ struct SettingsScreen: View { @EnvironmentObject var state: AppState @EnvironmentObject var updater: UpdaterController @State private var portText = "" + @State private var remoteServerText = "" + @State private var remoteKeyText = "" var body: some View { // portText 初始为空、onAppear 才填入当前端口;首帧若按空串校验会闪出「无效 + 放弃/应用」行再弹回。 // 空串一律视作「当前生效端口」,让首帧与落定后一致,消除进入设置页时的这层闪烁。 @@ -36,6 +38,8 @@ struct SettingsScreen: View { } .onAppear { portText = String(state.configuredPort) + remoteServerText = state.remoteSettings.serverAddress + remoteKeyText = state.remoteEnrollmentKey state.refreshCLIStatus() } .onDisappear { state.endSettingsLayout() } @@ -78,6 +82,7 @@ struct SettingsScreen: View { private func categoryTitle(_ category: AppState.SettingsCategory, _ lang: Lang) -> String { switch category { case .network: return L.sectionNetwork(lang) + case .remote: return L.sectionRemote(lang) case .permission: return L.sectionPermission(lang) case .appearanceLanguage: return L.sectionAppearanceLanguage(lang) case .main: return L.sectionMain(lang) @@ -89,6 +94,7 @@ struct SettingsScreen: View { private func categoryIcon(_ category: AppState.SettingsCategory) -> String { switch category { case .network: return "network" + case .remote: return "globe" case .permission: return "lock.shield" case .appearanceLanguage: return "paintbrush" case .main: return "macwindow" @@ -104,6 +110,8 @@ struct SettingsScreen: View { case .network: networkSettings(lang: lang, portCheck: portCheck, portColor: portColor, portChanged: portChanged, tailscaleDescription: tailscaleDescription) + case .remote: + remoteAccessSettings(lang) case .permission: permissionSettings(lang: lang, summary: permissionSummary) case .appearanceLanguage: @@ -195,6 +203,36 @@ struct SettingsScreen: View { } } + private func remoteAccessSettings(_ lang: Lang) -> some View { + VStack(alignment: .leading, spacing: 0) { + groupBox { + 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: "key.horizontal").font(.system(size: 13)).foregroundStyle(t.accent) + Text(L.remotePairHint(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() + } + if state.remotePaired { + Button { state.forgetRemoteDevice() } label: { + Text(L.remoteForget(lang)).font(.sans(12.5, .semibold)).foregroundStyle(t.danger) + } + .buttonStyle(.plain) + } + } + .padding(.bottom, 12) + } + } + } + private func permissionSettings(lang: Lang, summary: PermSummary) -> some View { VStack(alignment: .leading, spacing: 0) { groupBox { @@ -204,12 +242,15 @@ struct SettingsScreen: View { state.setAccessCodeEnabled(!state.accessCodeEnabled) } permRow(name: L.permUploadName(lang), - desc: state.canToggleUpload ? L.permUploadDescOn(lang) : L.permUploadDescOff(lang), - locked: !state.canToggleUpload, on: state.permission.add, top: true) { + 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) } - 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) } } @@ -342,7 +383,7 @@ struct SettingsScreen: View { } // 分组卡:surface 底 + line 描边的圆角容器,把当前分类中的相关设置行包成一张卡。 - // 右侧仅在一个分类内仍有多个小节时使用 SectionLabel,左侧导航负责标出六个顶层分类。 + // 右侧仅在一个分类内仍有多个小节时使用 SectionLabel,左侧导航负责标出七个顶层分类。 // 组内多行靠 settingRow / permRow 自带的内缩顶分隔线区隔(行宽 = 卡内宽,分隔线天然内缩,不触卡缘)。 @ViewBuilder private func groupBox(@ViewBuilder content: () -> Content) -> some View { VStack(spacing: 0, content: content) @@ -425,4 +466,29 @@ 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(serverAddress: remoteServerText), enrollmentKey: remoteKeyText) + remoteKeyText = "" + } } diff --git a/Sources/LocalShare/ShareScreen.swift b/Sources/LocalShare/ShareScreen.swift index 0ac4f91..336d5f6 100644 --- a/Sources/LocalShare/ShareScreen.swift +++ b/Sources/LocalShare/ShareScreen.swift @@ -24,6 +24,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 } @@ -50,6 +51,73 @@ struct ShareScreen: View { } } + private var remoteCard: some View { + VStack(alignment: .leading, spacing: 10) { + HStack(spacing: 10) { + GlobeLineIcon(color: t.accent) + VStack(alignment: .leading, spacing: 2) { + Text(L.remoteAccess(state.lang)).font(.sans(13.5, .semibold)).foregroundStyle(t.ink) + Text(L.remoteAccessDesc(state.lang)).font(.sans(11.5)).foregroundStyle(t.inkMute) + } + Spacer(minLength: 8) + if state.remoteAccessEnabled { + GhostButton(t: t, title: L.remoteDisconnect(state.lang), systemImage: "xmark.circle") { + state.disconnectRemote() + } + } else { + GhostButton(t: t, title: L.remoteConnect(state.lang), systemImage: "link") { + state.connectRemote(enrollmentKey: state.remoteEnrollmentKey) + } + .disabled(!state.canEnableRemote) + } + } + if state.remoteAccessEnabled { + HStack(spacing: 7) { + Text(state.remoteSettings.serverAddress) + .font(.mono(11.5)) + .foregroundStyle(t.inkMute) + .lineLimit(1) + .truncationMode(.middle) + .frame(maxWidth: .infinity, alignment: .leading) + Circle().fill(remoteStatusColor).frame(width: 7, height: 7) + Text(remoteStatusText).font(.sans(11.5, .semibold)).foregroundStyle(t.inkMute) + } + if let error = state.remoteError, !error.isEmpty { + Text(error).font(.sans(11)).foregroundStyle(t.danger).lineLimit(2) + } + } else if let error = state.remoteError, !error.isEmpty { + Text(error) + .font(.sans(11.5)).foregroundStyle(t.danger) + .fixedSize(horizontal: false, vertical: true) + } else if !state.remoteSettings.isValid { + Text(L.remoteConfigHint(state.lang)) + .font(.sans(11.5)).foregroundStyle(t.inkMute) + .fixedSize(horizontal: false, vertical: true) + } + } + .padding(14) + .background(RoundedRectangle(cornerRadius: 14, style: .continuous).fill(t.surface)) + .overlay(RoundedRectangle(cornerRadius: 14, style: .continuous).strokeBorder(t.line, lineWidth: 1)) + } + + private var remoteStatusText: String { + switch state.remoteStatus { + case .connecting: return L.remoteConnecting(state.lang) + case .connected: return L.remoteOnline(state.lang) + case .reconnecting: return L.remoteOffline(state.lang) + case .disconnected: return L.remoteConfigHint(state.lang) + } + } + + private var remoteStatusColor: Color { + switch state.remoteStatus { + case .connected: return t.ok + case .reconnecting: return t.danger + case .connecting: return t.warn + case .disconnected: return t.inkFaint + } + } + // 纯文本分享的存根:文本图标 + 「正在分享文本」+ 字数 + 前几行预览。 private func textStub(_ ps: PermSummary) -> some View { let lang = state.lang 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/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..
    +import { onBeforeUnmount, ref } from 'vue'
    +
    +const props = defineProps<{
    +  text: string
    +}>()
    +
    +const state = ref<'idle' | 'copied' | 'failed'>('idle')
    +let resetTimer: ReturnType | undefined
    +
    +async function copy() {
    +  try {
    +    await navigator.clipboard.writeText(props.text)
    +    state.value = 'copied'
    +  } catch {
    +    state.value = 'failed'
    +  }
    +
    +  if (resetTimer) {
    +    clearTimeout(resetTimer)
    +  }
    +  resetTimer = setTimeout(() => {
    +    state.value = 'idle'
    +  }, 2200)
    +}
    +
    +onBeforeUnmount(() => {
    +  if (resetTimer) {
    +    clearTimeout(resetTimer)
    +  }
    +})
    +
    +
    +
    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/ARCHITECTURE.md b/docs/ARCHITECTURE.md
    index 4246da8..8ea7a87 100644
    --- a/docs/ARCHITECTURE.md
    +++ b/docs/ARCHITECTURE.md
    @@ -1,6 +1,6 @@
     # LocalShare Architecture
     
    -> Native single-window macOS app: choose a folder, show a QR code, and let phones on the same Wi-Fi browse it read-only in a browser. Guest upload and Text Transfer can be enabled when needed; the default is read-only.
    +> Native single-window macOS app: choose a folder, show a QR code, and let phones on the same Wi-Fi—or an explicitly enabled browser-only remote endpoint—browse it read-only. Guest upload and Text Transfer can be enabled for LAN shares; the default is read-only.
     
     This is the **current architecture reference**: core constraints, project structure, important design decisions, and implementation notes for anyone continuing work after `git pull`. Related documents: visual design specification in `DESIGN.md`; release/version workflow in `CLAUDE.md`.
     
    @@ -21,15 +21,15 @@ The typical crash this project avoids is an arm64 binary that **misses a dynamic
     | Area | Decision |
     |---|---|
     | Platform | macOS only, native `.app`, Apple Silicon first |
    -| Network | Same Wi-Fi / LAN by default; optional user-enabled Tailscale access; no public endpoint or account |
    -| Stack | Swift / SwiftUI, system frameworks only, no external package dylib risk |
    -| HTTP server | Swifter from SPM source, primarily read-only static serving |
    +| Network | Same Wi-Fi / LAN by default; optional user-enabled Tailscale access or self-hosted LocalShare Server with one outbound WebSocket; no receiver app is required |
    +| Stack | Swift / SwiftUI Client plus a separate Go Server; the app uses system frameworks and has no package-external runtime dylibs |
    +| HTTP server | Swifter from SPM source for local serving; the self-hosted Go Server provides the remote control plane and data relay |
     | Share model | Three shapes: single folder -> mobile-friendly directory listing, serving `index.html` directly when present; single file -> scan opens the file and sibling files are not exposed; multiple files/folders -> synthetic virtual root listing selected items, with the first path segment mapped to a real URL. All shapes block directory traversal, each item using its own root |
    -| Auth | Every share action creates a random token embedded in the QR URL as `?t=...`; first visit validates it and sets a session cookie; later resources are allowed by cookie. An optional short access code can exchange for that cookie at `/ls/join`, with per-client rate limiting. Changing, stopping, or switching access-code mode rotates credentials immediately |
    -| Protocol | Plain HTTP. Threat model: block people who only guess the address; do not protect against same-network sniffing or forwarded links. Token rotation limits forwarded-link lifetime to the current share. Self-signed TLS would turn scan-to-use into certificate warnings, so it is intentionally not used |
    -| QR code | Raw selected LAN IP (or Tailscale IP when it is the only enabled network); generated with CoreImage `CIQRCodeGenerator`; no third-party QR dependency. The ticket can fold out hostname and enabled Tailscale alternatives |
    +| Auth | Every share action creates a random token embedded in the LAN QR URL as `?t=...`; first visit validates it and sets a session cookie. An optional short access code can exchange for that cookie at `/ls/join`, with per-client rate limiting. Remote mode uses separate high-entropy device and share tokens. Changing, stopping, or switching access-code mode rotates the applicable share credentials immediately |
    +| Protocol | LAN and Tailscale use plain HTTP. Remote mode uses the Server's HTTP/WebSocket relay, and the self-hosted Server can terminate TLS when configured with a certificate and key |
    +| QR code | Uses the public origin while remote mode is active; otherwise uses the selected LAN IP or Tailscale IP when it is the only enabled network. It is generated with CoreImage `CIQRCodeGenerator`; the ticket can fold out the Bonjour hostname and enabled Tailscale alternatives |
     | GUI | Single window: functional home screen with drag/drop and picker, Text Transfer entry, and Recent Shares; file ticket, Text Transfer, Settings, and History are secondary pages with back navigation |
    -| Lifecycle | Cold launch does **not** replay the last share. The app starts on the home screen; the last share remains in Recent Shares for one-click restart. Closing the window does not quit; process and server continue running, and menu bar activation restores the previous screen. Ports are selected automatically; service stops on app quit |
    +| Lifecycle | Cold launch does **not** replay the last share or remote switch. The app starts on the home screen; the last share remains in Recent Shares for one-click restart. Closing the window does not quit; process and server continue running, and menu bar activation restores the previous screen. Ports are selected automatically; an enabled remote agent stops on app quit |
     | Distribution | Xcode ad-hoc signing. The first Gatekeeper approval is handled manually and then remembered by macOS |
     | Automatic updates | Sparkle bundled through `@rpath`; background checks, user-confirmed update prompt, EdDSA trust chain independent of ad-hoc code signing and notarization |
     | Sandbox | App Sandbox is off because this is internally distributed and must read arbitrary user-selected folders |
    @@ -52,6 +52,7 @@ LocalShare/
       DESIGN.md                # Visual design spec; source references use section numbers from this file
       docs/
         ARCHITECTURE.md        # This file
    +    REMOTE_SHARING_PLAN.md # LocalShare Server, pairing, relay, and client flow
         images/                # README screenshots
       Sources/LocalShare/
         App.swift              # @main EntryPoint: GUI / headless / CLI dispatch; LocalShareApp and AppDelegate
    @@ -67,7 +68,8 @@ LocalShare/
         MarkdownViewer / JsonViewer / CsvViewer  # Preview cards; Markdown uses vendored marked; JSON/CSV are zero-dependency
         MarkedJS.swift         # Vendored marked compiled as a Swift string constant
         SendTextPage / TextViewer  # Text Transfer: phone -> Mac send page and text preview shell
    -    NetworkInfo.swift      # getifaddrs -> private IPv4 candidates
    +    RemoteAgent.swift      # Server pairing, Keychain device token, WebSocket relay, reconnect
    +    NetworkInfo.swift      # getifaddrs -> private IPv4 candidates, Tailscale IP, and .local hostname
         TailscaleInfo.swift    # optional CLI status probe -> Tailscale IPv4 + MagicDNS name
         ShareAddress.swift     # complete alternate URL + network scope; includes a future opaque public-relay kind
         QRCode.swift           # CoreImage QR -> NSImage, plus terminal ANSI output
    @@ -79,7 +81,8 @@ LocalShare/
       Sources/LocalShareShareExtension/
         main.swift / ShareController.swift  # Finder/System Share menu bridge; forwards file URLs to the host app
       Tests/LocalShareTests/   # XCTest pure-function tests: traversal, filename sanitation, multi-share keys, i18n, text
    -  tools/                   # Headless + curl smoke scripts: traversal, filenames, multiselect, upload defang, token 302, md links, language, text
    +  tools/                   # Headless + curl smoke scripts: traversal, filenames, multiselect, upload defang, token 302, remote read-only, md links, language, text
    +  server/                  # Separate Go module: self-contained Control Plane and Data Relay binary
     ```
     
     `@main enum EntryPoint` has three dispatch layers: `LS_HEADLESS=1` -> `HeadlessServer`; matching `CLI.parse` argv -> `CLI.run`; otherwise `LocalShareApp` (SwiftUI). All three paths share the same `FileServer`, which keeps request logic from forking.
    @@ -102,6 +105,7 @@ LocalShare/
     4. **Traversal guard**: `decoded -> strip leading / -> append to root -> standardizedFileURL.resolvingSymlinksInPath`. The result must equal the root path or have `root + "/"` as prefix; otherwise return 403. `standardizedFileURL` removes `..`; encoded dot-dot is blocked because decoding happens before normalization.
     5. **Directories**: path without trailing slash gets 301 so relative resources resolve; `index.html` is served directly when present; otherwise `DirectoryListing` renders the listing with segment-encoded absolute hrefs, hidden files omitted, and a fixed parent row outside the root.
     6. **Files**: MIME is inferred by extension, text types get `charset=utf-8`, and `FileHandle` streams in 64 KB chunks. Exception: browser navigation to `.md` / `.json` / `.csv` returns a preview shell at the **same URL as the file**, so relative references resolve through normal serving. curl, `?raw=1`, and `*/*` receive raw file content.
    +7. **Remote policy**: while remote mode is enabled, only `GET` and `HEAD` are accepted, and upload and text-receive routes disappear. The Server authenticates its own Share Token and never forwards browser credentials to the Mac.
     
     Single-folder serving extracts steps 4-6 into `serveTree(rootURL:relPath:...)`. **Single-file** shares return only that file. **Multi-share** uses `Share.multiple([Item])`, where `makeItems` uses `lastPathComponent` as key and appends `-2` for collisions. Empty path returns the virtual-root listing; otherwise the first path segment is mapped to a real URL. Unknown keys and subpaths under file items return 404. Each item has an independent traversal root.
     
    @@ -131,13 +135,30 @@ The GUI no longer exposes a current-network-only toggle and new installations li
     
     Tailscale access is persisted but defaults off. `NetworkInfo` detects its IPv4 by the documented `100.64.0.0/10` range rather than an interface-name assumption. When an installed Tailscale CLI is available, `TailscaleInfo` runs `status --json --peers=false` off the main actor with a two-second timeout and extracts `Self.DNSName`; failure degrades to the detected IP only. Enabling Tailscale permits tailnet clients through the request gate. Disabling it keeps the all-interface listener but rejects `100.64/10` clients before auth.
     
    -The ticket keeps one primary address and folds Bonjour hostname, Tailscale MagicDNS, and Tailscale IP into `otherAddresses`. In access-code mode each is an origin paired with the shared short code; otherwise each preserves the current path and token. `ShareAddress` stores a complete URL so a future public relay may supply an opaque server URL instead of being forced into the LAN host/query-token shape. No public relay address is created or shown today.
    +The ticket keeps one primary address and folds Bonjour hostname, Tailscale MagicDNS, and Tailscale IP into `otherAddresses`. In access-code mode each is an origin paired with the shared short code; otherwise each preserves the current path and token. `ShareAddress` stores a complete URL so opaque relay URLs can be represented without being forced into the LAN host/query-token shape. The current remote URL is shown as the primary ticket URL rather than an alternate address.
    +
    +### Remote Sharing
    +
    +Remote settings persist the Server address. The first connection also accepts a one-time Enrollment Key, exchanges it for a Device Token, and stores that token in macOS Keychain. `RemoteAgent` then opens an outbound WebSocket to `/api/v1/agent`, registers the current share, and retries with a 1–60 second capped backoff.
    +
    +The Server combines the Control Plane and Data Relay on one public port. Browser requests use `/share//...`; the Server sends them over the existing WebSocket to the Client, which calls its local `FileServer` and streams the response back. The Client keeps the existing token and traversal logic as the single source of file-serving truth. Remote mode is read-only and does not restore on cold launch; Connect/Disconnect controls the session while Forget Device removes the Keychain credential.
    +
    +Generated browser pages use relative links because the same `FileServer` HTML is mounted both at LAN `/` and remote
    +`/share//`. `share.start` registers a `share_id` generation and every relayed request echoes it; the Client
    +rejects stale generations before touching `FileServer`, closing the old-token/new-content race during share changes.
    +Relay requests have a 60-second idle timeout and cancellation propagation. The Client pauses each local data task while
    +its current WebSocket data frame is in flight, providing bounded backpressure instead of queueing a whole file.
    +
    +`state.json` mutations are protected by both an in-process mutex and a `.state.lock` advisory file lock, because the
    +running `serve` process and administrator CLI commands access the same state directory. Existing directories are
    +normalized to `0700`; state and lock files are `0600`.
     
     ### AppState and Lifecycle
     
     - Single source of truth is `sharedItems: [URL]`: empty, single, or multiple. Derived values include `isMultiple`, `isEmpty`, and convenience `sharedURL`.
     - **Cold launch does not restore sharing**. `init` does not read the last share back into `sharedItems`, and no file service starts automatically. Quietly serving a folder on LAN after opening the app would be unsafe. The last share remains available in Recent Shares (`RecentShare.paths`) for manual restart. Text inbox can auto-start only when explicitly enabled.
     - Closing the window does **not** quit. The process and service continue; `@StateObject` is created once per process, so reactivating the app returns to the previous screen.
    +- Remote access is opt-in per session. It is forced read-only, never restored on cold launch, and the Server URL/QR replace the LAN primary URL only after share registration. A disconnected agent retries and re-registers the current share.
     - 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.
     
    @@ -194,12 +215,15 @@ swift test                      # XCTest pure-function tests
     ./build.sh                      # Assemble and ad-hoc sign -> dist/LocalShare.app
     open dist/LocalShare.app        # Local GUI smoke test
     
    +cd server && go test ./...      # LocalShare Server unit tests
    +go build -o localshare-server ./server
    +
     # Headless end-to-end test for server logic
     LS_HEADLESS=1 LS_FOLDER=/path/to/dir LS_TOKEN=testtoken LS_PORT=8099 .build/debug/LocalShare &
     curl -s "http://127.0.0.1:8099/?t=testtoken"   # Should return a directory listing or index.html
     ```
     
    -Headless multi-share uses `LS_FOLDERS` separated by `:` or newlines. Enable upload with `LS_UPLOAD=1`, bind an interface with `LS_BIND=`, allow Tailscale clients with `LS_TAILSCALE=1`, and test Text Transfer with `LS_TEXT` / `LS_RECV`. The two test layers (`swift test` and `tools/smoke-*.sh`) run in `.github/workflows/ci.yml` for every PR and `master` push.
    +Headless multi-share uses `LS_FOLDERS` separated by `:` or newlines. Enable upload with `LS_UPLOAD=1`, bind an interface with `LS_BIND=`, allow Tailscale clients with `LS_TAILSCALE=1`, simulate remote read-only policy with `LS_REMOTE=1`, and test Text Transfer with `LS_TEXT` / `LS_RECV`. The two test layers (`swift test` and `tools/smoke-*.sh`) run in `.github/workflows/ci.yml` for every PR and `master` push.
     
     **Sharing with a coworker**: copy `dist/*.app`; for the first launch, help them pass Gatekeeper once by opening the app, then System Settings -> Privacy & Security -> Open Anyway. macOS remembers this approval.
     
    @@ -211,11 +235,11 @@ Headless multi-share uses `LS_FOLDERS` separated by `:` or newlines. Enable uplo
     
     - Apple notarization, which requires a paid developer account.
     - HTTPS with a self-signed certificate, which would replace scan-to-use with certificate warnings.
    -- Automatic third-party or public tunneling such as cloudflared and ngrok.
    +- Automatic third-party or public tunneling such as SSH/frp, cloudflared, and ngrok. The remote path uses the self-contained LocalShare Server; optional Tailscale access remains a user-managed network path.
     - **Online edit/delete in the browser**. This is intentionally not supported: mobile browser editing is poor, overwrite/delete risks are high, and guest upload already covers the primary phone -> Mac need. `Permission.edit` and `Permission.del` remain `false`.
     
     **Potential future work:**
     
     - **Chunked upload**: bypass Swifter's whole-body-in-memory limitation by slicing on the frontend, appending chunks server-side, and atomically renaming on the final chunk. This would allow arbitrary size with constant memory and remove the 500 MB cap.
    -- **Public relay**: an app-owned outbound tunnel and forwarding server may later return an opaque public URL. The address model can represent it, but no relay connection, server, setting, or UI placeholder is implemented yet.
    +- **Managed relay hosting**: the relay is currently self-hosted. A future managed service could reuse the existing opaque public URL model without changing LAN or Tailscale address handling.
     - **Better device-name lookup**: current lookup is best-effort `getnameinfo`; iPhones often do not resolve and fall back to IP. More accurate mDNS PTR lookup via `DNSServiceQueryRecord` is possible but more complex and still not guaranteed.
    diff --git a/docs/REMOTE_SHARING_PLAN.md b/docs/REMOTE_SHARING_PLAN.md
    new file mode 100644
    index 0000000..949dc83
    --- /dev/null
    +++ b/docs/REMOTE_SHARING_PLAN.md
    @@ -0,0 +1,189 @@
    +# LocalShare Remote Sharing Plan
    +
    +> 目标:让浏览器通过一个自托管的 LocalShare Server 访问 Mac 上正在分享的文件。Server 是单个二进制,不依赖 SSH、frp、Nginx、Tailscale 或其他运行时组件。
    +
    +## 1. 方案
    +
    +```text
    +浏览器
    +  │ GET /share//...
    +  ▼
    +LocalShare Server :8080
    +  ├─ Control Plane:Enrollment Key、Device Token、设备连接、分享注册
    +  ├─ Data Relay:把浏览器 HTTP 请求转成 WebSocket 消息
    +  └─ HTTP endpoint:认证分享 token、流式返回文件
    +       ▲ 一个长连接 WebSocket
    +       │
    +LocalShare Client(Mac)
    +  └─ RemoteAgent → 本机 FileServer → 用户选择的文件
    +```
    +
    +Server 不保存文件。文件内容始终由 Client 从本机 `FileServer` 读取,再经过 Server 流式转发给浏览器。
    +
    +V1 的边界:
    +
    +- 一个 Server 可管理多个设备,但每台设备最多一个当前分享;
    +- 一个 Client 只连接一个 Server;
    +- 只转发 `GET` / `HEAD`,支持 `Range`,支持取消请求;
    +- 远程模式强制只读,不开放上传和 Text Transfer;
    +- 不做 Server 端文件缓存、用户账号、浏览器账号登录或多租户计费。
    +
    +## 2. 用户配置体验
    +
    +### Server 管理员
    +
    +下载并运行一个二进制:
    +
    +```bash
    +localshare-server serve \
    +  --listen :8080 \
    +  --public-url https://ls.example.com \
    +  --tls-cert /etc/localshare/server.crt \
    +  --tls-key /etc/localshare/server.key \
    +  --state-dir /var/lib/localshare
    +```
    +
    +首次配对时生成一次性 Enrollment Key:
    +
    +```bash
    +localshare-server key create --name macbook
    +# 输出 key 只显示一次
    +```
    +
    +管理命令:
    +
    +```bash
    +localshare-server key list
    +localshare-server key revoke 
    +localshare-server device list
    +localshare-server device revoke 
    +```
    +
    +`state.json` 只保存 Enrollment Key 和 Device Token 的 SHA-256,不保存可直接使用的明文密钥;目录和文件权限分别为 `0700` / `0600`。
    +
    +### Client 用户
    +
    +第一次在 LocalShare 设置中填写:
    +
    +1. Server address,例如 `https://ls.example.com`;
    +2. Enrollment Key。
    +
    +点击保存并连接后,Client 调用 `/api/v1/enroll` 换取 Device Token,并把 Device Token 保存到 macOS Keychain。Enrollment Key 不落盘。
    +
    +之后设置页只需要保留 Server address;分享页提供:
    +
    +- `Connect`:建立 WebSocket 长连接并注册当前分享;
    +- `Disconnect`:断开当前连接,但保留已配对的 Device Token;
    +- `Forget Device`:删除 Keychain 中的 Device Token,下一次需要新的 Enrollment Key。
    +
    +应用重启后不自动恢复远程分享;用户在当前分享页点击 `Connect` 即可重新连接。连接断开时,Client 自动重试,退避上限 60 秒。
    +
    +## 3. 连接与请求流程
    +
    +```text
    +管理员生成 Enrollment Key
    +  → Client 输入 Server address + Enrollment Key
    +  → POST /api/v1/enroll
    +  → Server 一次性消费 Enrollment Key,返回 Device ID + Device Token
    +  → Client 将 Device Token 写入 Keychain
    +  → WebSocket /api/v1/agent(Authorization: Bearer Device Token)
    +  → Client 发送 share.start
    +  → Server 返回 /share/
    +  → 浏览器访问 /share//path
    +  → Server 发送 request.begin
    +  → Client 请求本机 FileServer,并发送 response.begin / 二进制数据 / response.end
    +  → Server 原样流式返回浏览器
    +```
    +
    +WebSocket 文本消息使用 JSON;文件数据使用带 request ID 的二进制帧:
    +
    +```text
    +4 bytes big-endian request-id length
    +request-id bytes
    +file bytes
    +```
    +
    +`share.start` 同时携带当前本机分享代次 `share_id`;Server 将它绑定到公开 Share Token,
    +并在每个 `request.begin` 中原样带回。Client 只接受与当前代次一致的请求,因此换分享期间尚未来得及
    +从 Server 删除的旧公开 token 也只能得到 `410`,不能读取新内容。
    +
    +Server 只转发必要的请求头:`Accept`、`Accept-Language`、`If-Modified-Since`、`If-None-Match`、`Range`、`User-Agent`。响应会移除 `Set-Cookie`、`Connection` 和 `Transfer-Encoding`,并重写站内 `Location` 到 `/share/`。
    +LocalShare 自己生成的页面使用相对链接和相对心跳地址,使同一份 HTML 同时适用于 LAN 根路径和
    +`/share//` 挂载路径;没有尾斜杠的公开分享入口先由 Server 规范化重定向。
    +
    +## 4. Server 实现
    +
    +Server 使用 Go 标准库 HTTP;WebSocket 只需要一个小型、纯 Go 的协议依赖,最终编译进单个静态二进制。Control Plane 和 Data Relay 共用同一个 HTTP 端口。
    +
    +HTTP 路由:
    +
    +| 路径 | 作用 | 认证 |
    +|---|---|---|
    +| `GET /healthz` | 健康检查 | 无 |
    +| `POST /api/v1/enroll` | Enrollment Key 换 Device Token | Enrollment Key |
    +| `GET /api/v1/agent` | Client WebSocket 长连接 | Device Token |
    +| `GET/HEAD /share//...` | 浏览器文件请求 | Share Token |
    +
    +Server 内存中保存当前连接和分享注册;重启后设备凭据仍从 `state.json` 恢复,但 Client 需要重新连接,旧的 Share Token 失效。
    +
    +请求使用 60 秒无数据空闲超时;每收到响应头或数据块都会续期。浏览器断开或超时时,Server 发送
    +`request.cancel`,Client 取消对应的本机 `URLSessionDataTask`,避免大文件继续占用带宽。
    +控制消息的 WebSocket 写入另有 10 秒超时,避免失活连接卡住 HTTP handler。
    +
    +## 5. Client 实现
    +
    +`RemoteAgent.swift` 只负责远程控制和流转发,不复制 FileServer 的鉴权、路径解析或预览逻辑:
    +
    +- 使用系统 `URLSessionWebSocketTask` 建立出站连接;
    +- Device Token 仅从 Keychain 读取;
    +- `share.start` 时把本机 FileServer 地址和当前 token 作为内部转发目标;
    +- Server 下发请求后,只允许白名单请求头进入本机 FileServer;
    +- 从本机响应按块发送数据;每个请求只允许一个尚未确认发送的分块,WebSocket 完成该分块后才恢复
    +  `URLSessionDataTask`,形成有界背压,不把完整文件排队进内存;
    +- 断线清理所有本地请求,重新连接后重新注册当前分享;
    +- 分享内容变化时更新本机目标并重新注册,Server 生成新的 Share Token。
    +
    +AppState 继续作为唯一状态源:远程状态、公开 URL 和本地只读策略都由 AppState 管理。RemoteAgent 不直接修改 SwiftUI 状态。
    +
    +## 6. 安全边界
    +
    +- Enrollment Key 一次性使用;
    +- Device Token 和 Share Token 使用高熵随机值,Server 只保存 Device Token 哈希;
    +- 撤销 Device 后,新建 WebSocket 会被拒绝;
    +- Client 远程连接期间 FileServer 只接受 `GET` / `HEAD`,并禁用上传和接收文本;
    +- Server 不信任浏览器传来的代理身份头,也不把设备 token 转发给浏览器;
    +- Server 不记录文件内容;默认日志不打印 Enrollment Key、Device Token、Share Token;
    +- Share Token 随分享注册变化或停止立即失效;
    +- 即使旧 Share Token 的删除消息尚在传输,`share_id` 代次校验也会阻止它读取新分享;
    +- WebSocket 是 Client 主动发起的出站长连接,Server 不需要访问 Mac 的监听端口。
    +
    +## 7. 交付阶段
    +
    +1. 在 `server/` 新增 Go module、状态存储、CLI、Enrollment、设备认证和健康检查。
    +2. 实现同端口 WebSocket Agent 与浏览器 Data Relay,先支持只读 GET/HEAD 和流式数据。
    +3. 在 Client 增加 Keychain 配对、Connect/Disconnect、断线重连和远程只读策略。
    +4. 增加 Range、取消、旧 token 失效、设备撤销和连接状态测试。
    +5. 更新 CI、README、架构文档,并在 debug/release 下运行本地 Server + headless Client 的端到端冒烟测试。
    +
    +## 8. 明确放弃的方案
    +
    +### SSH tunnel / frp / Nginx
    +
    +它们可以转发 TCP,但要求用户额外准备 SSH 信任、端口、反向转发、代理配置和多个服务生命周期。对 LocalShare 来说,这些配置不提供用户价值,且文件请求协议、设备配对和分享注册仍然需要 Client 自己实现,因此改为一个自包含 Server。
    +
    +### Tailscale / Cloudflare Tunnel
    +
    +它们适合作为未来的底层网络或部署参考,但会引入外部控制面、账号/设备身份或特定供应商依赖。当前版本先把控制面和数据中继边界做清楚,后续如需要可替换 Client 的传输层,不改变浏览器 Data Relay 和 FileServer 协议。
    +
    +### WebRTC / P2P
    +
    +能减少中继流量,但需要 NAT 穿透、ICE、浏览器端连接协商和新的数据协议,无法复用当前 HTTP 文件服务。只有在中继成本成为真实问题时再考虑。
    +
    +## 9. 暂不实现
    +
    +- 多 Client 同时共享同一 Share Token;
    +- Server 端文件缓存和断点持久化;
    +- 上传、删除、编辑和远程 Text Transfer;
    +- Server Web 管理后台;
    +- 自动 DNS、证书和云厂商部署;
    +- 对不可信 Server 的端到端加密。
    diff --git a/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: {} diff --git a/server/README.md b/server/README.md new file mode 100644 index 0000000..65b3d47 --- /dev/null +++ b/server/README.md @@ -0,0 +1,194 @@ +# LocalShare Server + +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 +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 都不应写入公开日志或提交到代码仓库。 +运行中的 Server 与管理 CLI 通过状态目录里的 `.state.lock` 串行化修改,避免创建 Key、配对和撤销并发时丢更新;目录权限会收敛为 `0700`,`state.json` 与锁文件为 `0600`。 + +## 六、排查顺序 + +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..bda75ab --- /dev/null +++ b/server/main.go @@ -0,0 +1,862 @@ +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" + "syscall" + "time" + + "github.com/coder/websocket" +) + +const protocolVersion = 1 +const maxWebSocketMessageSize = 8 << 20 +const defaultRelayIdleTimeout = 60 * time.Second +const webSocketWriteTimeout = 10 * time.Second + +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 + lockPath string + mu sync.Mutex +} + +func newStateStore(dir string) (*stateStore, error) { + if err := os.MkdirAll(dir, 0o700); err != nil { + return nil, err + } + 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) { + var current state + err := s.withFileLock(false, func() error { + var err error + current, err = s.loadUnlocked() + return err + }) + return current, err +} + +func (s *stateStore) loadUnlocked() (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) saveUnlocked(current state) error { + data, err := json.MarshalIndent(current, "", " ") + if err != nil { + return err + } + tmp := s.path + ".tmp" + 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 { + 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 + } + 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) { + deviceID, err := randomToken("device_") + if err != nil { + return "", "", err + } + deviceToken, err := randomToken("ls_device_") + if err != nil { + return "", "", err + } + 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 +} + +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 { + 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 + } + } + return errors.New("device not found") + }) +} + +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"` + ShareID string `json:"share_id,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 + shareID string + deviceID string + agent *agent +} + +type hub struct { + 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{}, requestIdleTimeout: defaultRelayIdleTimeout, + } +} + +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, shareID string) (string, error) { + if shareID == "" { + return "", errors.New("share id is required") + } + 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, shareID: shareID, 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] + 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] + 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), 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 { + 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 + } + resetIdleTimer() + 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 + case <-idleTimer.C: + item.agent.cancelRequest(requestID) + if !started { + w.WriteHeader(http.StatusGatewayTimeout) + } + 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, message.ShareID) + 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() + 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 { + 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 { + 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") + }) +} + +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..1375241 --- /dev/null +++ b/server/main_test.go @@ -0,0 +1,437 @@ +package main + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "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,"share_id":"local_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) + } + 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, + 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) + } + } +} + +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 new file mode 100755 index 0000000..1875afd --- /dev/null +++ b/tools/smoke-remote-readonly.sh @@ -0,0 +1,48 @@ +#!/usr/bin/env bash +# 防回归冒烟测:远程模式只读,并验证文件流支持 Range。 +set -u + +BIN="${BIN:-.build/debug/LocalShare}" +[ -x "$BIN" ] || { echo "找不到 $BIN,先跑 swift build"; exit 2; } + +PORT=$(( (RANDOM % 10000) + 44000 )) +TOK="remote-readonly-test" +ROOT="$(mktemp -d)" +echo hi > "$ROOT/a.txt" +PASS=0; FAIL=0 +ok(){ echo " ✅ $1"; PASS=$((PASS+1)); } +bad(){ echo " ❌ $1"; FAIL=$((FAIL+1)); } + +LS_HEADLESS=1 LS_FOLDER="$ROOT" LS_TEXT=secret LS_REMOTE=1 LS_UPLOAD=1 LS_RECV=1 \ + LS_TOKEN="$TOK" LS_PORT="$PORT" "$BIN" >/dev/null 2>&1 & +SRV=$! +trap 'kill $SRV 2>/dev/null; wait $SRV 2>/dev/null; rm -rf "$ROOT"' EXIT + +base="http://127.0.0.1:$PORT" +share_file_path="/$(basename "$ROOT")/a.txt" +for _ in $(seq 1 50); do + [ "$(curl -s -o /dev/null -w '%{http_code}' "$base/?t=$TOK")" != "000" ] && break + sleep 0.1 +done + +echo "── 无 token 仍返回 403" +S=$(curl -s -o /dev/null -w '%{http_code}' "$base/") +[ "$S" = "403" ] && ok "无 token 403" || bad "应 403 实为 $S" + +echo "── 远程模式拒绝上传与收文本" +S=$(curl -s -o /dev/null -w '%{http_code}' -X POST -F 'f=@/etc/hosts' "$base/?t=$TOK") +[ "$S" = "405" ] && ok "上传 405" || bad "上传应 405 实为 $S" +S=$(curl -s -o /dev/null -w '%{http_code}' -X POST --data-binary 'x' "$base/ls/text?t=$TOK") +[ "$S" = "405" ] && ok "收文本 405" || bad "收文本应 405 实为 $S" +S=$(curl -s -o /dev/null -w '%{http_code}' "$base/ls/text?t=$TOK") +[ "$S" = "404" ] && ok "共享文本不公开" || bad "共享文本应 404 实为 $S" + +echo "── 远程模式仍可读取并支持 Range" +S=$(curl -s -o /dev/null -w '%{http_code}' "$base$share_file_path?t=$TOK") +[ "$S" = "200" ] && ok "读取 200" || bad "读取应 200 实为 $S" +S=$(curl -s -o /dev/null -w '%{http_code}' -H 'Range: bytes=0-1' "$base$share_file_path?t=$TOK") +[ "$S" = "206" ] && ok "Range 206" || bad "Range 应 206 实为 $S" + +echo +echo "结果:PASS=$PASS FAIL=$FAIL" +[ "$FAIL" -eq 0 ] diff --git a/tools/smoke-text.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"