diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index caa82e4..c531072 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -51,6 +51,9 @@ jobs: - name: 网页逐请求语言(Accept-Language) run: ./tools/smoke-accept-language.sh + - name: 远程访问只读策略 + run: ./tools/smoke-remote-readonly.sh + - name: 传递文本(/ls/text + 转义 + 虚拟根文本行) run: ./tools/smoke-text.sh diff --git a/README.md b/README.md index e153bb3..2b987a4 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ [简体中文](README_CN.md) | English -A small macOS app that moves files and text between your Mac and other devices (phone, tablet…) over WiFi — the other end just uses a browser, with no client to install. +A small macOS app that moves files and text between your Mac and other devices (phone, tablet…) over WiFi, with optional browser-only remote sharing — the other end just uses a browser, with no client to install. - Pick a file or folder, scan the QR code that appears (or just open the link), and browse the files in your phone's browser (HTML, PDF, Markdown, images…) - Two-way text transfer — type some text and share it via a QR code, or send text back from the phone to the Mac @@ -22,6 +22,7 @@ A small macOS app that moves files and text between your Mac and other devices ( - After you share, the other end can send files and text back - Shows who's currently viewing - `localshare` command line: bring up the window to share, or `--headless` to print the link and QR code in the terminal +- Optional remote access through your own HTTPS origin and frp SSH gateway; remote shares are read-only and expire after one hour ## Why this app exists @@ -40,6 +41,10 @@ The QR code points to something like `http://192.168.x.x:8080/?t=` > ⚠️ Traffic is plain HTTP (unencrypted). Best avoided on public networks like airports and cafés. +### Remote browser sharing + +Configure your HTTPS origin, relay SSH host, port, and optional identity in Settings, then enable Remote Access on an active share. This requires the user-owned Nginx + frp SSH gateway described in [the remote-sharing plan](docs/REMOTE_SHARING_PLAN.md); the receiving device only needs a browser. The relay is inside the trust boundary, and remote access is read-only with a one-hour lifetime. + ## Terminal usage In Settings → Command-Line Tool, click "Install". After that you can share from the terminal in one command: diff --git a/README_CN.md b/README_CN.md index a8f886e..e943649 100644 --- a/README_CN.md +++ b/README_CN.md @@ -2,7 +2,7 @@ 简体中文 | [English](README.md) -一个 macOS 小工具,在本机和手机等设备之间通过 WiFi 互通文件和文本,对端使用浏览器,无须安装客户端。 +一个 macOS 小工具,在本机和手机等设备之间通过 WiFi 互通文件和文本,也支持可选的纯浏览器跨网络分享,对端无须安装客户端。 - 选中文件或文件夹,相机扫描出现的二维码,或直接打开链接,在手机浏览器里浏览这些文件(HTML、PDF、Markdown、图片……) - 支持双向传送文本,编辑文字通过二维码分享后传输,并可从手机端发送文本回 Mac @@ -22,6 +22,7 @@ - 支持分享后,由对端反向传输文件、文本 - 显示当前在线访客 - 命令行 `localshare` 命令:唤起窗口分享,或 `--headless` 在终端显示链接和二维码 +- 可选通过自有 HTTPS 地址和 frp SSH 网关远程访问;远程分享只读,默认一小时后失效 ## 为什么有这个 app @@ -40,6 +41,10 @@ > ⚠️ 传输是明文 HTTP(没有加密)。机场咖啡厅等公共网络下最好不要使用。 +### 远程浏览器分享 + +在「设置」里填写 HTTPS 公共地址、转发服务器 SSH 主机/端口和可选私钥路径,然后在正在分享的票据上打开「远程访问」。这需要按[远程分享计划](docs/REMOTE_SHARING_PLAN.md)部署自有 Nginx + frp SSH 网关,对端设备只需浏览器;转发服务器属于信任边界,远程访问只读,默认一小时后失效。 + ## 终端用法 在「设置 → 命令行工具」里点「安装」,之后可以在终端一键分享: diff --git a/Sources/LocalShare/App.swift b/Sources/LocalShare/App.swift index 710e0dc..11e7664 100644 --- a/Sources/LocalShare/App.swift +++ b/Sources/LocalShare/App.swift @@ -115,6 +115,10 @@ final class AppDelegate: NSObject, NSApplicationDelegate { NSApp.activate(ignoringOtherApps: true) } + func applicationWillTerminate(_ notification: Notification) { + AppState.shared?.stop() + } + // 关窗不退出:菜单栏图标常驻,服务继续广播;退出走菜单栏「退出」或 ⌘Q。 func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { false diff --git a/Sources/LocalShare/AppState.swift b/Sources/LocalShare/AppState.swift index fdd0123..f2cedc3 100644 --- a/Sources/LocalShare/AppState.swift +++ b/Sources/LocalShare/AppState.swift @@ -1,5 +1,6 @@ import AppKit import SwiftUI +import Network // 全局状态:当前分享对象(文件夹或单个文件)、服务运行态、网络接口候选、二维码 URL、 // 监听端口配置、最近分享历史、屏幕路由、权限(读取常开,上传可切换)。 @@ -45,6 +46,11 @@ final class AppState: ObservableObject { @Published var textInboxEnabled = false // 「允许收文本」闸门(默认关,持久化;不限分享形态) @Published var persistReceivedText = false // 「记住收到的文本」(默认关,持久化) @Published var cliStatus: CLIInstaller.Status = .notInstalled // 命令行工具安装状态 + @Published var remoteSettings = RemoteSettings() + @Published private(set) var remoteAccessEnabled = false + @Published private(set) var remoteStatus: RemoteTunnel.Status = .disabled + @Published private(set) var remoteExpiresAt: Date? + @Published private(set) var remoteError: String? // GUI 进程仅构造一次,init 末尾自登记;AppDelegate 的 open 事件回调经它触达状态。 static private(set) var shared: AppState? @@ -54,6 +60,10 @@ final class AppState: ObservableObject { @Published private(set) var token = Token.generate() private var server: FileServer? private var viewerTimer: Timer? + private let remoteTunnel = RemoteTunnel() + private var remoteExpiryTimer: Timer? + private var networkMonitor: NWPathMonitor? + private var networkPathStatus: NWPath.Status? private let portKey = "configuredPort" private let bindSelectedOnlyKey = "bindSelectedOnly" @@ -66,12 +76,28 @@ final class AppState: ObservableObject { private let textInboxKey = "textInboxEnabled" // 收件箱闸门(默认关) private let persistReceivedKey = "persistReceivedText" // 是否记住收到的文本(默认关) private let receivedTextsKey = "receivedTexts" // 收件箱内容(仅 persistReceivedText 开时写入) + private let remoteOriginKey = "remotePublicOrigin" + private let remoteSSHHostKey = "remoteSSHHost" + private let remoteSSHPortKey = "remoteSSHPort" + private let remoteIdentityPathKey = "remoteSSHIdentityPath" + private static let remoteLifetime: TimeInterval = 60 * 60 // 配置端口优先,其余作回退(8080 列入回退以防配置端口占用)。 private let fallbackPorts: [in_port_t] = [8000, 8888, 9000, 8080] init() { let savedPort = UserDefaults.standard.integer(forKey: portKey) if (1024...65535).contains(savedPort) { configuredPort = in_port_t(savedPort) } + let savedRemotePort = UserDefaults.standard.integer(forKey: remoteSSHPortKey) + remoteSettings = RemoteSettings( + publicOrigin: UserDefaults.standard.string(forKey: remoteOriginKey) ?? "", + sshHost: UserDefaults.standard.string(forKey: remoteSSHHostKey) ?? "", + sshPort: (1...65535).contains(savedRemotePort) ? savedRemotePort : 2200, + identityPath: UserDefaults.standard.string(forKey: remoteIdentityPathKey) ?? "" + ) + remoteTunnel.onStateChange = { [weak self] status, error in + self?.remoteStatus = status + self?.remoteError = error + } bindSelectedOnly = UserDefaults.standard.bool(forKey: bindSelectedOnlyKey) // 未写入默认 false if let a = UserDefaults.standard.string(forKey: appearanceKey).flatMap(AppearancePref.init) { appearance = a } if let l = UserDefaults.standard.string(forKey: langPrefKey).flatMap(LangPref.init) { langPref = l } @@ -107,6 +133,7 @@ final class AppState: ObservableObject { AppDelegate.pendingOpenURLs = [] setShared(urls) } + startNetworkMonitoring() } // MARK: - 选择态派生 @@ -126,27 +153,34 @@ final class AppState: ObservableObject { // MARK: - 派生 URL / 二维码 // 文件夹/多选模式 → 根地址;单文件模式 → 直链该文件(路径仅供浏览器显示文件名/扩展名)。 - private func makeURL(host: String) -> String { + private func makeURL(base: String) -> String { let q = "?t=\(token)" + let root = base.hasSuffix("/") ? String(base.dropLast()) : base // 传递文本(收/发合一):二维码恒指 /ls/text——这一页既显示电脑共享的文本(可读可复制), // 又在「允许手机发回来」开着时挂出发送框;只收文本时它退化成纯发送页。扫一次,双向都在这。 - if isTextOnly || isReceiveOnly { return "http://\(host):\(port)/ls/text\(q)" } + if isTextOnly || isReceiveOnly { return "\(root)/ls/text\(q)" } // 单文件直链该文件(文本与文件共存时走虚拟根,不直链,故附带 !hasText)。 if sharedIsFile, !hasText, let name = sharedItems.first?.lastPathComponent, let enc = name.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) { - return "http://\(host):\(port)/\(enc)\(q)" + return "\(root)/\(enc)\(q)" } - return "http://\(host):\(port)/\(q)" + return "\(root)/\(q)" + } + + var remoteURL: String? { + guard remoteAccessEnabled, isRunning, let origin = remoteSettings.publicURL else { return nil } + return makeURL(base: origin.absoluteString) } var primaryURL: String? { + if let remoteURL { return remoteURL } guard isRunning, port != 0, let ip = selectedInterface?.ip else { return nil } - return makeURL(host: ip) + return makeURL(base: "http://\(ip):\(port)") } var localURL: String? { guard isRunning, port != 0, let host = localHost else { return nil } - return makeURL(host: host) + return makeURL(base: "http://\(host):\(port)") } var qrImage: NSImage? { @@ -165,6 +199,129 @@ final class AppState: ObservableObject { selectedInterface = interfaces.first } + // Remote access is session-only. Connection details persist, but the public + // switch never does: reopening the app cannot silently publish a share. + var canEnableRemote: Bool { isRunning && !isEmpty && remoteSettings.isValid } + + func saveRemoteSettings(_ settings: RemoteSettings) { + remoteSettings = settings + UserDefaults.standard.set(settings.publicOrigin, forKey: remoteOriginKey) + UserDefaults.standard.set(settings.sshHost, forKey: remoteSSHHostKey) + UserDefaults.standard.set(settings.sshPort, forKey: remoteSSHPortKey) + UserDefaults.standard.set(settings.identityPath, forKey: remoteIdentityPathKey) + guard remoteAccessEnabled else { return } + guard settings.isValid else { + disableRemoteAccess() + remoteError = L.remoteConfigInvalid(lang) + return + } + startRemoteTunnel() + } + + func setRemoteAccessEnabled(_ on: Bool) { + guard on != remoteAccessEnabled else { return } + if on { + guard canEnableRemote else { + remoteError = L.remoteConfigHint(lang) + return + } + permission.add = false + textInboxEnabled = false + UserDefaults.standard.set(false, forKey: textInboxKey) + remoteAccessEnabled = true + remoteError = nil + remoteExpiresAt = Date().addingTimeInterval(Self.remoteLifetime) + scheduleRemoteExpiry() + applyRemotePolicy() + pushToServer() + startRemoteTunnel() + } else { + disableRemoteAccess() + } + } + + func retryRemoteAccess() { + guard remoteAccessEnabled else { return } + remoteError = nil + startRemoteTunnel() + } + + private func disableRemoteAccess(rotateToken: Bool = false) { + remoteTunnel.stop() + remoteExpiryTimer?.invalidate() + remoteExpiryTimer = nil + remoteAccessEnabled = false + remoteExpiresAt = nil + remoteError = nil + if rotateToken { token = Token.generate() } + if isRunning { pushToServer() } + } + + private func expireRemoteAccess() { + guard remoteAccessEnabled, let expires = remoteExpiresAt, expires <= Date() else { return } + disableRemoteAccess(rotateToken: true) + remoteError = L.remoteExpired(lang) + } + + private func scheduleRemoteExpiry() { + remoteExpiryTimer?.invalidate() + remoteExpiryTimer = Timer.scheduledTimer(withTimeInterval: Self.remoteLifetime, repeats: false) { [weak self] _ in + Task { @MainActor [weak self] in self?.expireRemoteAccess() } + } + } + + private var tunnelLocalAddress: String { + bindSelectedOnly ? (selectedInterface?.ip ?? "127.0.0.1") : "127.0.0.1" + } + + private var tunnelProxyAddresses: Set { + var addresses: Set = ["127.0.0.1", "::1"] + if let selected = selectedInterface?.ip { addresses.insert(selected) } + return addresses + } + + private func applyRemotePolicy() { + server?.remoteAccessEnabled = remoteAccessEnabled + server?.trustedProxyAddresses = tunnelProxyAddresses + server?.uploadEnabled = permission.add && canToggleUpload && !remoteAccessEnabled + server?.textInboxEnabled = textInboxEnabled && !remoteAccessEnabled + } + + private func startRemoteTunnel() { + guard remoteAccessEnabled, isRunning else { return } + guard let configuration = remoteSettings.tunnelConfiguration(localAddress: tunnelLocalAddress, localPort: port) else { + remoteStatus = .offline + remoteError = L.remoteConfigInvalid(lang) + return + } + applyRemotePolicy() + remoteTunnel.start(configuration) + } + + private func startNetworkMonitoring() { + let monitor = NWPathMonitor() + networkMonitor = monitor + let queue = DispatchQueue(label: "localshare.network-monitor") + monitor.pathUpdateHandler = { [weak self] path in + let status = path.status + Task { @MainActor [weak self] in + guard let self else { return } + let statusChanged = self.networkPathStatus != status + self.networkPathStatus = status + guard self.isRunning else { return } + let oldInterfaces = self.interfaces + self.refreshNetwork() + guard self.remoteAccessEnabled else { return } + if self.bindSelectedOnly && oldInterfaces != self.interfaces { + self.rebindServer() + } else if statusChanged || oldInterfaces != self.interfaces { + self.startRemoteTunnel() + } + } + } + monitor.start(queue: queue) + } + // MARK: - 选择分享对象 func pickFolder() { pick(files: false, directories: true, message: L.pickFolderMsg(lang)) } @@ -203,8 +360,8 @@ final class AppState: ObservableObject { private func pushToServer() { server?.token = token server?.sharedText = hasText ? sharedText : nil - server?.textInboxEnabled = textInboxEnabled server?.share = currentShare + applyRemotePolicy() } // 分享 / 更新一段文本(Mac→手机)。token 的「会话」维度与分享文件一致:只在会话边界轮换 @@ -273,11 +430,12 @@ final class AppState: ObservableObject { // 仅「单个文件夹」分享有上传落点;单文件/多选时设置页开关置灰。 // 附带文本会把分享转成虚拟根(无单一落点),故此时上传也不可用。 - var canToggleUpload: Bool { sharedItems.count == 1 && !sharedIsFile && !hasText } + var canToggleUpload: Bool { !remoteAccessEnabled && sharedItems.count == 1 && !sharedIsFile && !hasText } func setUploadAllowed(_ on: Bool) { + guard !remoteAccessEnabled else { return } permission.add = on && canToggleUpload - server?.uploadEnabled = permission.add + applyRemotePolicy() } private func resetUpload() { @@ -307,11 +465,12 @@ final class AppState: ObservableObject { // 「允许收文本」闸门。开:不限分享形态都能开,开了就把服务拉起(无分享时即「只收模式」),不轮换 token、 // 不重启(运行中只切 server 标志,发送表单随之显隐、已发链接继续有效)。关:若再无其它分享则停服务。 func setTextInboxEnabled(_ on: Bool) { + guard !(remoteAccessEnabled && on) else { return } guard on != textInboxEnabled else { return } textInboxEnabled = on UserDefaults.standard.set(on, forKey: textInboxKey) if isRunning { - if isServing { server?.textInboxEnabled = on } // 仍有理由服务:原地切标志 + if isServing { applyRemotePolicy() } // 仍有理由服务:原地切标志 else { stop() } // 关掉且无其它分享 → 拆服务,回到空状态 } else if isServing { start() // 从空状态开启 → 起服务(只收模式) @@ -389,8 +548,10 @@ final class AppState: ObservableObject { refreshNetwork() let fs = FileServer(share: currentShare, token: token) fs.sharedText = hasText ? sharedText : nil - fs.uploadEnabled = permission.add && canToggleUpload - fs.textInboxEnabled = textInboxEnabled + fs.remoteAccessEnabled = remoteAccessEnabled + fs.trustedProxyAddresses = tunnelProxyAddresses + fs.uploadEnabled = permission.add && canToggleUpload && !remoteAccessEnabled + fs.textInboxEnabled = textInboxEnabled && !remoteAccessEnabled fs.onUpload = { url in // socket 线程 → 主线程 Task { @MainActor [weak self] in self?.recordReceived(url) } } @@ -407,6 +568,7 @@ final class AppState: ObservableObject { isRunning = true lastError = nil startViewerPolling() + if remoteAccessEnabled { startRemoteTunnel() } } catch { // 绑定指定网卡失败(IP 刚因 DHCP/切网消失):退回全接口重试一次,给提示但不让分享中断。 if bindIP != nil { @@ -417,6 +579,7 @@ final class AppState: ObservableObject { isRunning = true lastError = LStr.ifaceUnavailable(lang) startViewerPolling() + if remoteAccessEnabled { startRemoteTunnel() } return } } @@ -430,6 +593,7 @@ final class AppState: ObservableObject { private func rebindServer() { guard isRunning else { return } viewerTimer?.invalidate(); viewerTimer = nil + if remoteAccessEnabled { remoteTunnel.stop() } server?.stop() server = nil start() // 复用 self.token,仅监听地址/端口随当前选择重算 @@ -450,6 +614,7 @@ final class AppState: ObservableObject { } func stop() { + disableRemoteAccess() viewerTimer?.invalidate() viewerTimer = nil viewerCount = 0 diff --git a/Sources/LocalShare/FileServer.swift b/Sources/LocalShare/FileServer.swift index 5efe4a9..13287ce 100644 --- a/Sources/LocalShare/FileServer.swift +++ b/Sources/LocalShare/FileServer.swift @@ -94,6 +94,21 @@ final class FileServer { // 每收到一段文本回调一次(socket 线程);GUI 用它入收件箱 + 提示未读,设置方自行 hop 主线程。 var onReceiveText: ((ReceivedText) -> Void)? + // Remote mode is deliberately a server-wide read-only gate: the same origin is + // reachable on LAN and through the tunnel, so allowing POST locally would also + // expose it publicly. The proxy address allowlist limits forwarded-header trust + // to the local SSH connection rather than any arbitrary LAN request. + private var _remoteAccessEnabled = false + var remoteAccessEnabled: Bool { + get { lock.lock(); defer { lock.unlock() }; return _remoteAccessEnabled } + set { lock.lock(); _remoteAccessEnabled = newValue; lock.unlock() } + } + private var _trustedProxyAddresses: Set = ["127.0.0.1", "::1"] + var trustedProxyAddresses: Set { + get { lock.lock(); defer { lock.unlock() }; return _trustedProxyAddresses } + set { lock.lock(); _trustedProxyAddresses = newValue; lock.unlock() } + } + // 单条文本上限(远小于上传的 500MB)。Swifter 进 middleware 前已把 body 整段读进内存,故同上传只能 // 事后拒绝;总数上限(收件箱满挤旧)由 AppState 把关——两道一起才挡得住「刷一堆不超单条限的消息撑爆内存」。 static let textInboxLimit = 64 * 1024 @@ -219,6 +234,28 @@ final class FileServer { return String(String(String.UnicodeScalarView(kept)).trimmingCharacters(in: .whitespaces).prefix(40)) } + // Only accept forwarded identity from a request that arrived at a trusted local + // tunnel endpoint and carries the HTTPS marker added by Nginx. The first XFF + // value is the original browser address; malformed values fall back to req.address. + static func forwardedClientAddress(headers: [String: String], sourceAddress: String?, + remoteEnabled: Bool, trustedProxyAddresses: Set) -> String? { + guard remoteEnabled, + let sourceAddress, + trustedProxyAddresses.contains(sourceAddress), + headers["x-forwarded-proto"]?.lowercased() == "https", + let raw = headers["x-forwarded-for"]?.split(separator: ",").first else { return nil } + let address = raw.trimmingCharacters(in: .whitespaces) + guard isIPAddress(address) else { return nil } + return address + } + + private static func isIPAddress(_ value: String) -> Bool { + var v4 = in_addr() + if inet_pton(AF_INET, value, &v4) == 1 { return true } + var v6 = in6_addr() + return inet_pton(AF_INET6, value, &v6) == 1 + } + init(share: Share, token: String) { self._share = share self._token = token @@ -264,24 +301,36 @@ final class FileServer { // 1. 鉴权:query ?t= 或 cookie 任一匹配当前分享的 token(每请求取一次快照, // 保证鉴权判断与下面 Set-Cookie 写的是同一把钥匙,轮换瞬间也不串) let token = self.token + let remoteMode = remoteAccessEnabled + let forwardedAddress = Self.forwardedClientAddress( + headers: req.headers, + sourceAddress: req.address, + remoteEnabled: remoteMode, + trustedProxyAddresses: trustedProxyAddresses + ) + let clientAddress = forwardedAddress ?? req.address // 网页语言逐请求决定:按浏览器 Accept-Language,与原生 app 设置无关。往下穿进每个 HTML 生产者。 let lang = Lang.fromAcceptLanguage(req.headers["accept-language"]) // 收件箱开启:listing 页(同上传表单的出现条件)多挂一张「发文本给电脑」表单。每请求取一次快照。 - let recvOn = textInboxEnabled + let recvOn = textInboxEnabled && !remoteMode let viaQuery = req.queryParams.first { $0.0 == "t" }?.1 == token let viaCookie = cookieValue("ls_token", in: req.headers["cookie"]) == token guard viaQuery || viaCookie else { return htmlResponse(403, "Forbidden", Self.forbiddenPage(lang)) } + if remoteMode && req.method != "GET" && req.method != "HEAD" { + return jsonResponse(405, "Method Not Allowed", errorJSON(L.remoteReadOnly(lang)), extra: [:]) + } // 经 query 放行且尚无(有效)cookie 时,种会话 cookie,后续资源请求免带 token。 // 不设 Max-Age:随浏览器会话走;token 轮换后旧值反正立即失效。页面 JS 不读它,HttpOnly。 var extra: [String: String] = [:] if viaQuery, !viaCookie { - extra["Set-Cookie"] = "ls_token=\(token); Path=/; SameSite=Lax; HttpOnly" + let secure = forwardedAddress != nil ? "; Secure" : "" + extra["Set-Cookie"] = "ls_token=\(token); Path=/; SameSite=Lax; HttpOnly\(secure)" } // 鉴权通过的每个请求都刷新该 IP 的活跃时间(含心跳与下载中的请求)。 - touchPresence(req.address) + touchPresence(clientAddress) // 心跳/在线数端点(保留路径,优先于分享内容命中;分享里恰好有 ls/ping 的概率可忽略)。 // req.path 不含 query(Swifter 用 URLComponents.path),精确比较即可。 @@ -357,7 +406,7 @@ final class FileServer { // 收文本(保留路径,先于上传拦截):POST /ls/text 投递一段文本到收件箱。开关关 → 403; // 超单条上限 → 413;空白 → 400。落库与未读由 onReceiveText 交给 AppState。 if req.method == "POST", req.path == "/ls/text" { - return handleReceiveText(req: req, lang: lang, extra: extra) + return handleReceiveText(req: req, clientAddress: clientAddress, lang: lang, extra: extra) } // 访客上传:POST 到当前浏览的目录。开关关 / 非文件夹分享一律拒绝(先于单文件分支拦截, @@ -409,7 +458,8 @@ final class FileServer { let rel = String(decodedPath.drop { $0 == "/" }) return serveTree(rootURL: rootURL, relPath: rel, encodedPath: req.path, decodedPath: decodedPath, rootName: rootURL.lastPathComponent, - canUpload: uploadEnabled, canReceiveText: recvOn, viewer: wantsViewer, lang: lang, extra: extra) + canUpload: uploadEnabled && !remoteMode, canReceiveText: recvOn, + viewer: wantsViewer, lang: lang, extra: extra) } // 可预览类型(md/json/csv)且浏览器导航 → 预览壳页(与文件同 URL,相对引用天然成立); @@ -496,8 +546,9 @@ final class FileServer { // 安全:闸门 textInboxEnabled 把关;超 textInboxLimit → 413;去首尾空白后为空 → 400。 // 收到的文本经 onReceiveText 交 AppState(socket 线程,设置方 hop 主线程入收件箱);FileServer 不存列表。 // 文本本身不回显进任何服务页(仅在 Mac 端 SwiftUI Text 里显示,天然不执行),故无须额外转义。 - private func handleReceiveText(req: HttpRequest, lang: Lang, extra: [String: String]) -> HttpResponse { - guard textInboxEnabled else { + private func handleReceiveText(req: HttpRequest, clientAddress: String?, lang: Lang, + extra: [String: String]) -> HttpResponse { + guard textInboxEnabled && !remoteAccessEnabled else { return jsonResponse(403, "Forbidden", errorJSON(L.recvDisabled(lang)), extra: extra) } guard req.body.count <= Self.textInboxLimit else { @@ -508,7 +559,7 @@ final class FileServer { guard !trimmed.isEmpty else { return jsonResponse(400, "Bad Request", errorJSON(L.recvEmpty(lang)), extra: extra) } - let ip = req.address ?? "" + let ip = clientAddress ?? "" lock.lock(); let name = nameCache[ip] ?? ""; lock.unlock() // 反查到则带设备名,否则展示层回退 IP onReceiveText?(ReceivedText(text: trimmed, ip: ip, name: name, date: Date())) return jsonResponse(200, "OK", #"{"ok":true}"#, extra: extra) diff --git a/Sources/LocalShare/HeadlessServer.swift b/Sources/LocalShare/HeadlessServer.swift index af03fe2..5f1dd24 100644 --- a/Sources/LocalShare/HeadlessServer.swift +++ b/Sources/LocalShare/HeadlessServer.swift @@ -11,6 +11,7 @@ import Foundation // LS_TEXT 分享一段文本(可单独,也可与 LS_FOLDER(S) 共存);纯文本时 URL 直指 /ls/text // LS_RECV 置 1 开启收文本(收件箱);无任何分享内容时 URL 直指 /ls/text(收发合一,退化成纯发送页) // LS_RECV_LOG 收到文本时把原文追加进该文件(以 0x01 分隔),供冒烟测回读校验 +// LS_REMOTE 置 1 模拟远程只读策略(仅用于本地回归,不启动 SSH 隧道) enum HeadlessServer { static func run() { let env = ProcessInfo.processInfo.environment @@ -18,6 +19,7 @@ enum HeadlessServer { let port = in_port_t(env["LS_PORT"].flatMap { Int($0) } ?? 8080) let text = env["LS_TEXT"].flatMap { $0.isEmpty ? nil : $0 } let recvOn = env["LS_RECV"] == "1" + let remoteOn = env["LS_REMOTE"] == "1" let paths: [String] if let multi = env["LS_FOLDERS"] { @@ -33,10 +35,11 @@ enum HeadlessServer { let urls = paths.map { URL(fileURLWithPath: $0) } let server = FileServer(share: makeShare(urls, hasText: text != nil), token: token) - server.uploadEnabled = env["LS_UPLOAD"] == "1" + server.remoteAccessEnabled = remoteOn + server.uploadEnabled = env["LS_UPLOAD"] == "1" && !remoteOn server.listenAddress = env["LS_BIND"] // nil → 全部接口(默认) server.sharedText = text - server.textInboxEnabled = recvOn + server.textInboxEnabled = recvOn && !remoteOn if let logPath = env["LS_RECV_LOG"] { server.onReceiveText = { rt in // socket 线程:把原文追加进日志文件,供冒烟测回读 let chunk = Data((rt.text + "\u{1}").utf8) diff --git a/Sources/LocalShare/Lang.swift b/Sources/LocalShare/Lang.swift index f1cba3e..734a249 100644 --- a/Sources/LocalShare/Lang.swift +++ b/Sources/LocalShare/Lang.swift @@ -95,7 +95,7 @@ enum L: CaseIterable { case noNetwork, noNetworkHint // 设置 —— 小节标题 - case sectionNetwork, sectionPermission, sectionAppearance, sectionLanguage, sectionMain + case sectionNetwork, sectionRemote, sectionPermission, sectionAppearance, sectionLanguage, sectionMain case sectionUpdate, sectionCLI, sectionRecent case shareSettings, shareHistory, currentColon @@ -103,6 +103,10 @@ enum L: CaseIterable { case thisMachine, portOk, portOccupied, portInvalid, portOkHint case bindOnlyTitle, bindOnlyDescMulti, bindOnlyDescSingle + // 设置 —— 远程访问 + case remotePublicOrigin, remoteSSHHost, remoteSSHPort, remoteIdentityPath, remoteSave + case remoteHTTPSHint, remoteHostKeyHint + // 设置 —— 端口校验(静态部分;被占用一项带数字走 LStr) case portEmptyMsg, portNotNumberMsg, portTooLowMsg, portTooHighMsg @@ -111,6 +115,11 @@ enum L: CaseIterable { case permUploadDescOn, permUploadDescOff case permInfoWritable, permInfoReadonly, plaintextWarning + // 远程访问 + case remoteAccess, remoteAccessDesc, remoteConfigHint, remoteConfigInvalid + case remoteConnecting, remoteOnline, remoteOffline, remoteRetry, remoteStop + case remoteExpired, remoteReadOnly + // 设置 —— 外观 case appearanceFollow, appearanceLight, appearanceDark @@ -237,6 +246,7 @@ enum L: CaseIterable { "Connect this Mac to the same Wi-Fi /\nwired network as the target device, then refresh.") case .sectionNetwork: return ("网络", "Network") + case .sectionRemote: return ("远程访问", "Remote Access") case .sectionPermission: return ("访问权限", "Access") case .sectionAppearance: return ("外观", "Appearance") case .sectionLanguage: return ("语言", "Language") @@ -260,6 +270,16 @@ enum L: CaseIterable { case .bindOnlyDescSingle: return ("只在当前网络开放,日后接入别的网络时也访问不到", "Open only on the current network; future networks won't reach it either") + case .remotePublicOrigin: return ("公共地址", "Public origin") + case .remoteSSHHost: return ("SSH 主机", "SSH host") + case .remoteSSHPort: return ("SSH 端口", "SSH port") + case .remoteIdentityPath: return ("SSH 私钥路径(可选)", "SSH identity path (optional)") + case .remoteSave: return ("保存远程设置", "Save remote settings") + case .remoteHTTPSHint: return ("需要 HTTPS 地址,例如 https://share.example.com", + "Use an HTTPS origin, for example https://share.example.com") + case .remoteHostKeyHint: return ("SSH 使用系统 known_hosts 严格校验;先确认服务器指纹已登记。", + "SSH uses the system known_hosts with strict verification; add the server fingerprint first.") + case .portEmptyMsg: return ("请输入端口号", "Enter a port number") case .portNotNumberMsg: return ("端口需为数字", "Port must be a number") case .portTooLowMsg: return ("需 ≥ 1024 · 1023 以下为系统保留端口", "Must be ≥ 1024 · ports below 1024 are reserved") @@ -277,6 +297,20 @@ enum L: CaseIterable { case .plaintextWarning: return ("同一网络下传输不加密 · 公共 Wi-Fi(咖啡馆 / 机场等)下同网的人可能看到内容,敏感文件别在这种网络分享。", "Traffic isn't encrypted on the LAN · on public Wi-Fi (cafés, airports) others on the network may see the content. Don't share sensitive files there.") + case .remoteAccess: return ("远程访问", "Remote Access") + case .remoteAccessDesc: return ("通过你的 HTTPS 地址,让浏览器跨网络只读访问当前分享。", + "Let a browser read the current share through your HTTPS origin.") + case .remoteConfigHint: return ("先在设置中填写公共地址和 SSH 连接信息。", + "Add the public origin and SSH connection details in Settings first.") + case .remoteConfigInvalid: return ("远程设置不完整或无效。", "Remote settings are incomplete or invalid.") + case .remoteConnecting: return ("连接中", "Connecting") + case .remoteOnline: return ("在线", "Online") + case .remoteOffline: return ("已断开 · 将自动重试", "Offline · retrying") + case .remoteRetry: return ("重试", "Retry") + case .remoteStop: return ("停止远程访问", "Stop remote access") + case .remoteExpired: return ("远程分享已过期,旧链接已失效。", "Remote sharing expired; the old link is no longer valid.") + case .remoteReadOnly: return ("远程访问为只读。", "Remote access is read-only.") + case .appearanceFollow: return ("跟随系统", "System") case .appearanceLight: return ("浅色", "Light") case .appearanceDark: return ("深色", "Dark") @@ -436,6 +470,13 @@ enum L: CaseIterable { // 语序、复数、数字位置中英各异,故不入 key→value 表,由函数按 lang 拼装。 enum LStr { + static func remoteExpiry(_ date: Date, _ lang: Lang) -> String { + let formatter = DateFormatter() + formatter.locale = lang == .zh ? Locale(identifier: "zh_CN") : Locale(identifier: "en_US") + formatter.dateFormat = lang == .zh ? "M月d日 HH:mm 失效" : "expires MMM d, HH:mm" + return formatter.string(from: date) + } + // "3 项" / "3 items"(目录元信息、计数、describeShared 共用) static func itemCount(_ n: Int, _ lang: Lang) -> String { lang == .zh ? "\(n) 项" : "\(n) item\(n == 1 ? "" : "s")" diff --git a/Sources/LocalShare/RemoteTunnel.swift b/Sources/LocalShare/RemoteTunnel.swift new file mode 100644 index 0000000..38872a2 --- /dev/null +++ b/Sources/LocalShare/RemoteTunnel.swift @@ -0,0 +1,198 @@ +import Foundation + +struct RemoteSettings: Equatable { + var publicOrigin = "" + var sshHost = "" + var sshPort = 2200 + var identityPath = "" + + var publicURL: URL? { + let raw = publicOrigin.trimmingCharacters(in: .whitespacesAndNewlines) + guard let url = URL(string: raw), + url.scheme?.lowercased() == "https", + url.host != nil, + url.user == nil, + url.password == nil, + url.query == nil, + url.fragment == nil, + url.path.isEmpty || url.path == "/" else { return nil } + return url + } + + var customDomain: String? { publicURL?.host } + + var isValid: Bool { + guard publicURL != nil, + !sshHost.isEmpty, + !sshHost.contains(where: { $0.isWhitespace || $0 == "/" }), + (1...65535).contains(sshPort) else { return false } + return true + } + + func tunnelConfiguration(localAddress: String, localPort: in_port_t) -> RemoteTunnel.Configuration? { + guard isValid, let customDomain else { return nil } + let identity = identityPath.trimmingCharacters(in: .whitespacesAndNewlines) + return RemoteTunnel.Configuration( + sshHost: sshHost, + sshPort: sshPort, + identityPath: identity.isEmpty ? nil : (identity as NSString).expandingTildeInPath, + customDomain: customDomain, + localAddress: localAddress, + localPort: localPort + ) + } +} + +@MainActor +final class RemoteTunnel { + enum Status: Equatable { + case disabled + case connecting + case online + case offline + } + + struct Configuration: Equatable { + let sshHost: String + let sshPort: Int + let identityPath: String? + let customDomain: String + let localAddress: String + let localPort: in_port_t + } + + private(set) var status: Status = .disabled + private(set) var lastError: String? + var onStateChange: ((Status, String?) -> Void)? + + private var process: Process? + private var stderrPipe: Pipe? + private var retryTask: Task? + private var configuration: Configuration? + private var shouldRun = false + private var retryDelay: TimeInterval = 1 + + nonisolated static func arguments(for config: Configuration) -> [String] { + var args = [ + "-T", + "-o", "BatchMode=yes", + "-o", "ClearAllForwardings=yes", + "-o", "ExitOnForwardFailure=yes", + "-o", "ServerAliveInterval=15", + "-o", "ServerAliveCountMax=3", + "-o", "StrictHostKeyChecking=yes", + "-p", String(config.sshPort), + ] + if let identityPath = config.identityPath { + args += ["-i", identityPath] + } + args += [ + "-R", ":80:\(config.localAddress):\(config.localPort)", + "v0@\(config.sshHost)", + "http", + "--proxy_name", "localshare", + "--custom_domain", config.customDomain, + ] + return args + } + + func start(_ configuration: Configuration) { + shouldRun = true + self.configuration = configuration + retryTask?.cancel() + retryTask = nil + retryDelay = 1 + stopProcess() + launch() + } + + func stop() { + shouldRun = false + configuration = nil + retryTask?.cancel() + retryTask = nil + stopProcess() + setStatus(.disabled, error: nil) + } + + private func launch() { + guard shouldRun, let configuration else { return } + setStatus(.connecting, error: nil) + + let process = Process() + process.executableURL = URL(fileURLWithPath: "/usr/bin/ssh") + process.arguments = Self.arguments(for: configuration) + process.standardOutput = FileHandle(forWritingAtPath: "/dev/null") + + let stderr = Pipe() + process.standardError = stderr + stderr.fileHandleForReading.readabilityHandler = { [weak self] handle in + let data = handle.availableData + guard !data.isEmpty else { return } + let message = String(decoding: data, as: UTF8.self).trimmingCharacters(in: .whitespacesAndNewlines) + guard !message.isEmpty else { return } + Task { @MainActor [weak self] in self?.lastError = message } + } + process.terminationHandler = { [weak self] terminated in + Task { @MainActor [weak self] in self?.handleTermination(terminated) } + } + self.process = process + self.stderrPipe = stderr + + do { + try process.run() + retryDelay = 1 + setStatus(.online, error: nil) + } catch { + self.process = nil + self.stderrPipe = nil + setStatus(.offline, error: error.localizedDescription) + scheduleRetry() + } + } + + private func handleTermination(_ terminated: Process) { + guard process === terminated else { return } + process = nil + stderrPipe?.fileHandleForReading.readabilityHandler = nil + stderrPipe = nil + guard shouldRun else { + setStatus(.disabled, error: nil) + return + } + let error = lastError ?? "ssh exited with status \(terminated.terminationStatus)" + setStatus(.offline, error: error) + scheduleRetry() + } + + private func scheduleRetry() { + guard shouldRun, configuration != nil else { return } + retryTask?.cancel() + let delay = retryDelay + retryDelay = min(retryDelay * 2, 60) + retryTask = Task { @MainActor [weak self] in + do { + try await Task.sleep(nanoseconds: UInt64(delay * 1_000_000_000)) + } catch { + return + } + guard let self, self.shouldRun else { return } + self.launch() + } + } + + private func stopProcess() { + guard let process else { return } + process.terminationHandler = nil + stderrPipe?.fileHandleForReading.readabilityHandler = nil + if process.isRunning { process.terminate() } + self.process = nil + stderrPipe = nil + } + + private func setStatus(_ status: Status, error: String?) { + self.status = status + lastError = error + onStateChange?(status, error) + } +} diff --git a/Sources/LocalShare/SettingsScreen.swift b/Sources/LocalShare/SettingsScreen.swift index fb61b69..ffdc133 100644 --- a/Sources/LocalShare/SettingsScreen.swift +++ b/Sources/LocalShare/SettingsScreen.swift @@ -8,6 +8,10 @@ struct SettingsScreen: View { @EnvironmentObject var state: AppState @EnvironmentObject var updater: UpdaterController @State private var portText = "" + @State private var remoteOriginText = "" + @State private var remoteSSHHostText = "" + @State private var remoteSSHPortText = "" + @State private var remoteIdentityText = "" var body: some View { // portText 初始为空、onAppear 才填入当前端口;首帧若按空串校验会闪出「无效 + 放弃/应用」行再弹回。 // 空串一律视作「当前生效端口」,让首帧与落定后一致,消除进入设置页时的这层闪烁。 @@ -104,6 +108,32 @@ struct SettingsScreen: View { } } + // MARK: 远程访问 + eyebrow(L.sectionRemote(lang)) + groupBox { + remoteField(title: L.remotePublicOrigin(lang), text: $remoteOriginText) + remoteField(title: L.remoteSSHHost(lang), text: $remoteSSHHostText, top: true) + remoteField(title: L.remoteSSHPort(lang), text: $remoteSSHPortText, top: true, width: 72, + numbersOnly: true) + remoteField(title: L.remoteIdentityPath(lang), text: $remoteIdentityText, top: true) + HStack(alignment: .top, spacing: 8) { + Image(systemName: "lock.shield").font(.system(size: 13)).foregroundStyle(t.accent) + VStack(alignment: .leading, spacing: 3) { + Text(L.remoteHTTPSHint(lang)).font(.sans(11.5)).foregroundStyle(t.inkMute) + Text(L.remoteHostKeyHint(lang)).font(.sans(11.5)).foregroundStyle(t.inkMute) + } + Spacer(minLength: 0) + } + .padding(.vertical, 10) + HStack { + Spacer() + GhostButton(t: t, title: L.remoteSave(lang), systemImage: "checkmark") { + saveRemoteSettings() + } + } + .padding(.bottom, 12) + } + // MARK: 访问权限(眼标右侧挂「当前权限」胶囊) HStack(spacing: 8) { SectionLabel(t: t, text: L.sectionPermission(lang)) @@ -119,14 +149,16 @@ struct SettingsScreen: View { groupBox { permRow(name: L.permReadName(lang), desc: L.permReadDesc(lang), locked: true, on: true) permRow(name: L.permUploadName(lang), - desc: state.canToggleUpload ? L.permUploadDescOn(lang) : L.permUploadDescOff(lang), - locked: !state.canToggleUpload, + desc: state.remoteAccessEnabled ? L.remoteReadOnly(lang) + : (state.canToggleUpload ? L.permUploadDescOn(lang) : L.permUploadDescOff(lang)), + locked: !state.canToggleUpload || state.remoteAccessEnabled, on: state.permission.add, top: true) { state.setUploadAllowed(!state.permission.add) } // 收文本:独立闸门,不限分享形态(甚至什么都没分享也能开),故不随 share 置灰。 - permRow(name: L.recvInboxTitle(lang), desc: L.recvInboxDesc(lang), - locked: false, on: state.textInboxEnabled, top: true) { + permRow(name: L.recvInboxTitle(lang), + desc: state.remoteAccessEnabled ? L.remoteReadOnly(lang) : L.recvInboxDesc(lang), + locked: state.remoteAccessEnabled, on: state.textInboxEnabled, top: true) { state.setTextInboxEnabled(!state.textInboxEnabled) } } @@ -241,6 +273,10 @@ struct SettingsScreen: View { } .onAppear { portText = String(state.configuredPort) + remoteOriginText = state.remoteSettings.publicOrigin + remoteSSHHostText = state.remoteSettings.sshHost + remoteSSHPortText = String(state.remoteSettings.sshPort) + remoteIdentityText = state.remoteSettings.identityPath state.refreshCLIStatus() } } @@ -342,4 +378,33 @@ struct SettingsScreen: View { state.applyPort(in_port_t(p)) state.goShare() } + + private func remoteField(title: String, text: Binding, top: Bool = false, + width: CGFloat = 190, numbersOnly: Bool = false) -> some View { + HStack(spacing: 10) { + Text(title).font(.sans(13.5, .semibold)).foregroundStyle(t.ink) + Spacer(minLength: 8) + TextField("", text: text) + .textFieldStyle(.plain) + .font(.mono(11.5)) + .frame(width: width) + .padding(.horizontal, 9).padding(.vertical, 6) + .background(RoundedRectangle(cornerRadius: 8, style: .continuous).fill(t.field)) + .overlay(RoundedRectangle(cornerRadius: 8, style: .continuous).strokeBorder(t.line, lineWidth: 1)) + .onChange(of: text.wrappedValue) { value in + if numbersOnly { text.wrappedValue = String(value.filter(\.isNumber).prefix(5)) } + } + } + .padding(.vertical, 9) + .overlay(alignment: .top) { if top { Rectangle().fill(t.line).frame(height: 1) } } + } + + private func saveRemoteSettings() { + state.saveRemoteSettings(RemoteSettings( + publicOrigin: remoteOriginText, + sshHost: remoteSSHHostText, + sshPort: Int(remoteSSHPortText) ?? 0, + identityPath: remoteIdentityText + )) + } } diff --git a/Sources/LocalShare/ShareScreen.swift b/Sources/LocalShare/ShareScreen.swift index 017146d..35f3b83 100644 --- a/Sources/LocalShare/ShareScreen.swift +++ b/Sources/LocalShare/ShareScreen.swift @@ -23,6 +23,7 @@ struct ShareScreen: View { } content: { VStack(spacing: 16) { ticket(ps) + if state.isRunning { remoteCard } // 文本与文件共存:在票据下补一张「附带文本」小卡(预览 + 编辑)。纯文本分享则文本就是票据本身。 if state.hasText && !state.isTextOnly { attachedTextCard } if !state.received.isEmpty { receivedCard } @@ -49,6 +50,74 @@ struct ShareScreen: View { } } + private var remoteCard: some View { + VStack(alignment: .leading, spacing: 10) { + HStack(spacing: 10) { + Image(systemName: "globe").font(.system(size: 17, weight: .medium)).foregroundStyle(t.accent) + VStack(alignment: .leading, spacing: 2) { + Text(L.remoteAccess(state.lang)).font(.sans(13.5, .semibold)).foregroundStyle(t.ink) + Text(L.remoteAccessDesc(state.lang)).font(.sans(11.5)).foregroundStyle(t.inkMute) + } + Spacer(minLength: 8) + ToggleSwitch(t: t, isOn: state.remoteAccessEnabled, + locked: !state.remoteAccessEnabled && !state.canEnableRemote) { + state.setRemoteAccessEnabled(!state.remoteAccessEnabled) + } + } + if state.remoteAccessEnabled { + if let url = state.remoteURL { + CopyPill(t: t, lang: state.lang, value: url, compact: true) { + if let url = URL(string: url) { NSWorkspace.shared.open(url) } + } + } + HStack(spacing: 7) { + Circle().fill(remoteStatusColor).frame(width: 7, height: 7) + Text(remoteStatusText).font(.sans(11.5, .semibold)).foregroundStyle(t.inkMute) + Spacer() + if state.remoteStatus == .offline { + GhostButton(t: t, title: L.remoteRetry(state.lang), systemImage: "arrow.clockwise") { + state.retryRemoteAccess() + } + } + GhostButton(t: t, title: L.remoteStop(state.lang)) { + state.setRemoteAccessEnabled(false) + } + } + if let expires = state.remoteExpiresAt { + Text(LStr.remoteExpiry(expires, state.lang)).font(.mono(10.5)).foregroundStyle(t.inkFaint) + } + if let error = state.remoteError, !error.isEmpty { + Text(error).font(.sans(11)).foregroundStyle(t.danger).lineLimit(2) + } + } else { + Text(state.remoteError ?? (state.remoteSettings.isValid ? L.remoteAccessDesc(state.lang) : L.remoteConfigHint(state.lang))) + .font(.sans(11.5)).foregroundStyle(state.remoteError == nil ? t.inkMute : t.danger) + .fixedSize(horizontal: false, vertical: true) + } + } + .padding(14) + .background(RoundedRectangle(cornerRadius: 14, style: .continuous).fill(t.surface)) + .overlay(RoundedRectangle(cornerRadius: 14, style: .continuous).strokeBorder(t.line, lineWidth: 1)) + } + + private var remoteStatusText: String { + switch state.remoteStatus { + case .connecting: return L.remoteConnecting(state.lang) + case .online: return L.remoteOnline(state.lang) + case .offline: return L.remoteOffline(state.lang) + case .disabled: return L.remoteConfigHint(state.lang) + } + } + + private var remoteStatusColor: Color { + switch state.remoteStatus { + case .online: return t.ok + case .offline: return t.danger + case .connecting: return t.warn + case .disabled: return t.inkFaint + } + } + // 纯文本分享的存根:文本图标 + 「正在分享文本」+ 字数 + 前几行预览。 private func textStub(_ ps: PermSummary) -> some View { let lang = state.lang diff --git a/Sources/LocalShare/Token.swift b/Sources/LocalShare/Token.swift index 0101465..f39c624 100644 --- a/Sources/LocalShare/Token.swift +++ b/Sources/LocalShare/Token.swift @@ -3,8 +3,10 @@ import Foundation // 分享访问令牌:每次「分享」动作生成一把(换分享/停止即轮换,旧链接与 cookie 随之作废), // 内嵌进二维码 URL(?t=…)。扫码者首访校验通过后种 cookie;手动猜 IP:端口 的人因缺 token 被 403。 enum Token { - static func generate(length: Int = 10) -> String { - let alphabet = Array("abcdefghijklmnopqrstuvwxyz0123456789") + // 32 hex characters = 128 bits. The old 10-character base36 token was suitable + // for LAN address hiding, but too small once a link can cross the public internet. + static func generate(length: Int = 32) -> String { + let alphabet = Array("0123456789abcdef") var rng = SystemRandomNumberGenerator() return String((0.. Native single-window macOS app: choose a folder, show a QR code, and let phones on the same Wi-Fi browse it read-only in a browser. Guest upload and Text Transfer can be enabled when needed; the default is read-only. +> Native single-window macOS app: choose a folder, show a QR code, and let phones on the same Wi-Fi—or an explicitly enabled browser-only remote endpoint—browse it read-only. Guest upload and Text Transfer can be enabled for LAN shares; the default is read-only. This is the **current architecture reference**: core constraints, project structure, important design decisions, and implementation notes for anyone continuing work after `git pull`. Related documents: visual design specification in `DESIGN.md`; release/version workflow in `CLAUDE.md`. @@ -21,15 +21,15 @@ The typical crash this project avoids is an arm64 binary that **misses a dynamic | Area | Decision | |---|---| | Platform | macOS only, native `.app`, Apple Silicon first | -| Network | Same Wi-Fi / LAN only; no tunnel, public endpoint, or account | +| Network | Same Wi-Fi / LAN by default; optional user-owned Nginx + frp SSH gateway; no receiver app, VPN, or account | | Stack | Swift / SwiftUI, system frameworks only, no external package dylib risk | | HTTP server | Swifter from SPM source, primarily read-only static serving | | Share model | Three shapes: single folder -> mobile-friendly directory listing, serving `index.html` directly when present; single file -> scan opens the file and sibling files are not exposed; multiple files/folders -> synthetic virtual root listing selected items, with the first path segment mapped to a real URL. All shapes block directory traversal, each item using its own root | | Auth | Every share action creates a random token embedded in the QR URL as `?t=...`; first visit validates it and sets a session cookie; later resources are allowed by cookie. Changing or stopping a share rotates the token immediately, invalidating old links, cookies, and QR codes. Anyone guessing `IP:port` receives 403 | -| Protocol | Plain HTTP. Threat model: block people who only guess the address; do not protect against same-network sniffing or forwarded links. Token rotation limits forwarded-link lifetime to the current share. Self-signed TLS would turn scan-to-use into certificate warnings, so it is intentionally not used | -| QR code | Raw LAN IP selected from network candidates; generated with CoreImage `CIQRCodeGenerator`; no third-party QR dependency. Window also shows a `.local` fallback and copyable URL | +| Protocol | LAN uses plain HTTP; remote mode uses the configured HTTPS origin terminated by the user's Nginx. A 128-bit token blocks guessed addresses, rotates on share boundaries, and expires remote access after one hour | +| QR code | Uses the public origin while remote mode is active, otherwise the selected LAN IP; generated with CoreImage `CIQRCodeGenerator`; no third-party QR dependency. Window keeps the LAN `.local` fallback | | GUI | Single window: functional home screen with drag/drop and picker, Text Transfer entry, and Recent Shares; file ticket, Text Transfer, Settings, and History are secondary pages with back navigation | -| Lifecycle | Cold launch does **not** replay the last share. The app starts on the home screen; the last share remains in Recent Shares for one-click restart. Closing the window does not quit; process and server continue running, and menu bar activation restores the previous screen. Ports are selected automatically; service stops on app quit | +| Lifecycle | Cold launch does **not** replay the last share or remote switch. The app starts on the home screen; the last share remains in Recent Shares for one-click restart. Closing the window does not quit; process, server, and an enabled tunnel continue running, and menu bar activation restores the previous screen. Ports are selected automatically; service and tunnel stop on app quit | | Distribution | Xcode ad-hoc signing. The first Gatekeeper approval is handled manually and then remembered by macOS | | Automatic updates | Sparkle bundled through `@rpath`; background checks, user-confirmed update prompt, EdDSA trust chain independent of ad-hoc code signing and notarization | | Sandbox | App Sandbox is off because this is internally distributed and must read arbitrary user-selected folders | @@ -67,6 +67,7 @@ LocalShare/ MarkdownViewer / JsonViewer / CsvViewer # Preview cards; Markdown uses vendored marked; JSON/CSV are zero-dependency MarkedJS.swift # Vendored marked compiled as a Swift string constant SendTextPage / TextViewer # Text Transfer: phone -> Mac send page and text preview shell + RemoteTunnel.swift # HTTPS-origin settings and direct /usr/bin/ssh reverse tunnel NetworkInfo.swift # getifaddrs -> private IPv4 candidates and .local hostname QRCode.swift # CoreImage QR -> NSImage, plus terminal ANSI output Token.swift / Mime.swift # Random token and extension -> MIME mapping with charset=utf-8 for text @@ -77,7 +78,7 @@ LocalShare/ Sources/LocalShareShareExtension/ main.swift / ShareController.swift # Finder/System Share menu bridge; forwards file URLs to the host app Tests/LocalShareTests/ # XCTest pure-function tests: traversal, filename sanitation, multi-share keys, i18n, text - tools/ # Headless + curl smoke scripts: traversal, filenames, multiselect, upload defang, token 302, md links, language, text + tools/ # Headless + curl smoke scripts: traversal, filenames, multiselect, upload defang, token 302, remote read-only, md links, language, text ``` `@main enum EntryPoint` has three dispatch layers: `LS_HEADLESS=1` -> `HeadlessServer`; matching `CLI.parse` argv -> `CLI.run`; otherwise `LocalShareApp` (SwiftUI). All three paths share the same `FileServer`, which keeps request logic from forking. @@ -98,6 +99,7 @@ LocalShare/ 2. **Traversal guard**: `decoded -> strip leading / -> append to root -> standardizedFileURL.resolvingSymlinksInPath`. The result must equal the root path or have `root + "/"` as prefix; otherwise return 403. `standardizedFileURL` removes `..`; encoded dot-dot is blocked because decoding happens before normalization. 3. **Directories**: path without trailing slash gets 301 so relative resources resolve; `index.html` is served directly when present; otherwise `DirectoryListing` renders the listing with segment-encoded absolute hrefs, hidden files omitted, and a fixed parent row outside the root. 4. **Files**: MIME is inferred by extension, text types get `charset=utf-8`, and `FileHandle` streams in 64 KB chunks. Exception: browser navigation to `.md` / `.json` / `.csv` returns a preview shell at the **same URL as the file**, so relative references resolve through normal serving. curl, `?raw=1`, and `*/*` receive raw file content. +5. **Remote policy**: while remote mode is enabled, only `GET` and `HEAD` are accepted, upload and text-receive routes disappear, and `ls_token` receives `Secure` only for a request from the trusted local tunnel with `X-Forwarded-Proto: https`. `X-Forwarded-For` is used for presence only in that same case. Single-folder serving extracts steps 2-4 into `serveTree(rootURL:relPath:...)`. **Single-file** shares return only that file. **Multi-share** uses `Share.multiple([Item])`, where `makeItems` uses `lastPathComponent` as key and appends `-2` for collisions. Empty path returns the virtual-root listing; otherwise the first path segment is mapped to a real URL. Unknown keys and subpaths under file items return 404. Each item has an independent traversal root. @@ -123,11 +125,18 @@ After auth, requests record `lastSeen` by client IP. A 45-second window counts a When `FileServer.listenAddress` is non-nil, Swifter binds only that IPv4 address through `listenAddressIPv4`. `AppState.bindSelectedOnly` drives it. Changing interface or switch state rebinds without rotating the token. If the selected IP disappears, the app falls back to all interfaces. Invalid `inet_pton` input throws instead of silently binding all interfaces. Default `nil` means `0.0.0.0`. +### Remote Sharing + +Remote settings persist only the HTTPS origin, SSH host/port, and optional identity path. The switch is session-only and starts `/usr/bin/ssh` directly with `BatchMode`, `ExitOnForwardFailure`, keepalives, and `StrictHostKeyChecking=yes`; the system SSH agent, config, and `known_hosts` remain the trust store. The tunnel forwards the actual LocalShare listen address, so current-network-only binding remains valid. + +`RemoteTunnel` reports connecting/online/offline state and retries with a 1–60 second capped backoff. Changing share content only updates the locked `FileServer` state and rotates the token; stopping the share, disabling remote mode, app termination, or the one-hour expiry stops the tunnel. Expiry also rotates the token while leaving the LAN share available. + ### AppState and Lifecycle - Single source of truth is `sharedItems: [URL]`: empty, single, or multiple. Derived values include `isMultiple`, `isEmpty`, and convenience `sharedURL`. - **Cold launch does not restore sharing**. `init` does not read the last share back into `sharedItems`, and no file service starts automatically. Quietly serving a folder on LAN after opening the app would be unsafe. The last share remains available in Recent Shares (`RecentShare.paths`) for manual restart. Text inbox can auto-start only when explicitly enabled. - Closing the window does **not** quit. The process and service continue; `@StateObject` is created once per process, so reactivating the app returns to the previous screen. +- Remote access is opt-in per session. It is forced read-only, never restored on cold launch, and the public URL/QR replace the LAN primary URL only while the switch is enabled. A disconnected tunnel retries without changing the expiry deadline. - Port preference order is `[8080, 8000, 8888, 9000]`; if all fail, choose a random high port. - **Changing a share does not restart the server** when the port is unchanged. The lock updates `share` and `token`, with the key changed before the content to prevent an old token from briefly reading the new share. @@ -189,7 +198,7 @@ LS_HEADLESS=1 LS_FOLDER=/path/to/dir LS_TOKEN=testtoken LS_PORT=8099 .build/debu curl -s "http://127.0.0.1:8099/?t=testtoken" # Should return a directory listing or index.html ``` -Headless multi-share uses `LS_FOLDERS` separated by `:` or newlines. Enable upload with `LS_UPLOAD=1`, bind an interface with `LS_BIND=`, and test Text Transfer with `LS_TEXT` / `LS_RECV`. The two test layers (`swift test` and `tools/smoke-*.sh`) run in `.github/workflows/ci.yml` for every PR and `master` push. +Headless multi-share uses `LS_FOLDERS` separated by `:` or newlines. Enable upload with `LS_UPLOAD=1`, bind an interface with `LS_BIND=`, simulate remote read-only policy with `LS_REMOTE=1`, and test Text Transfer with `LS_TEXT` / `LS_RECV`. The two test layers (`swift test` and `tools/smoke-*.sh`) run in `.github/workflows/ci.yml` for every PR and `master` push. **Sharing with a coworker**: copy `dist/*.app`; for the first launch, help them pass Gatekeeper once by opening the app, then System Settings -> Privacy & Security -> Open Anyway. macOS remembers this approval. @@ -201,7 +210,7 @@ Headless multi-share uses `LS_FOLDERS` separated by `:` or newlines. Enable uplo - Apple notarization, which requires a paid developer account. - HTTPS with a self-signed certificate, which would replace scan-to-use with certificate warnings. -- Cross-network tunneling such as cloudflared, ngrok, or Tailscale. +- Vendor tunnel clients such as cloudflared, ngrok, or Tailscale; the first remote mode uses the user-owned Nginx/frp gateway and system SSH only. - **Online edit/delete in the browser**. This is intentionally not supported: mobile browser editing is poor, overwrite/delete risks are high, and guest upload already covers the primary phone -> Mac need. `Permission.edit` and `Permission.del` remain `false`. **Potential future work:** diff --git a/docs/REMOTE_SHARING_PLAN.md b/docs/REMOTE_SHARING_PLAN.md new file mode 100644 index 0000000..7bc78ff --- /dev/null +++ b/docs/REMOTE_SHARING_PLAN.md @@ -0,0 +1,207 @@ +# Remote Sharing Plan + +> Status: app implementation complete. Public-server deployment and end-to-end internet acceptance remain environment-specific. + +## Goal + +Allow a Mac running LocalShare to share files with a remote device that has only a web browser. + +The first version should: + +- use a user-owned public server and domain; +- require no companion app, VPN, or account on the receiving device; +- keep files on the Mac and stream them through the server without storage; +- preserve the existing `FileServer`, URL paths, token authentication, and QR flow; +- expose remote shares only while explicitly enabled; +- add no package-external runtime dylibs. + +## Options Considered + +| Approach | Browser-only receiver | Self-hosted | Fit for LocalShare | +|---|---:|---:|---| +| [frp SSH Tunnel Gateway](https://gofrp.org/en/docs/features/common/ssh/) behind Nginx | Yes | Yes | **Selected.** Uses macOS `/usr/bin/ssh`, reuses `FileServer`, and needs no bundled tunnel client. | +| Plain OpenSSH reverse forwarding behind Nginx | Yes | Yes | Smallest single-Mac setup, but frp adds HTTP host routing and a path to multiple clients without a custom control service. | +| [Cloudflare Tunnel](https://developers.cloudflare.com/tunnel/) / [ngrok](https://ngrok.com/docs/agent/) | Yes | No | Mature UX reference, but requires a vendor account, agent, and hosted edge. | +| [Tailscale Funnel](https://tailscale.com/docs/features/tailscale-funnel) / [NetBird Reverse Proxy](https://docs.netbird.io/manage/reverse-proxy) | Yes | Depends on provider | Adds a VPN identity/control plane and another agent; public proxy features are broader than the first version needs. | +| Tailscale, Headscale, or raw WireGuard mesh | No, unless a public gateway is added | Yes | Good for trusted devices or connected sites, but the receiver otherwise needs a VPN client. | +| [zrok](https://github.com/openziti/zrok) / [Pangolin](https://docs.pangolin.net/) | Yes | Yes | Closest complete products, but their control planes, connectors, and licensing/operations are unnecessary for one server and one active share. | +| WebRTC or relay-assisted transfer, such as [PairDrop](https://github.com/schlagmichdoch/PairDrop) or [Magic Wormhole](https://magic-wormhole.readthedocs.io/en/latest/file-transfer-protocol.html) | Sometimes | Yes | Requires a new transfer protocol and receiver flow instead of reusing LocalShare's HTTP browser. | +| Upload files to temporary cloud storage | Yes | Yes | Lets the Mac go offline, but stores file contents on the server and changes LocalShare's live-sharing model. | + +## Decision + +Use Nginx for the public HTTPS endpoint and frp's SSH Tunnel Gateway for temporary reverse forwarding: + +```text +browser + -> HTTPS :443 +Nginx + -> HTTP 127.0.0.1:18080 +frps HTTP virtual host + -> encrypted SSH reverse tunnel +macOS /usr/bin/ssh + -> LocalShare FileServer +``` + +This is a relayed design, not peer-to-peer NAT traversal. All remote traffic crosses the user's server. + +For the first version: + +- one fixed public hostname, such as `share.example.com`; +- one active remote share per server; +- no database, allocation API, wildcard domain, or bundled tunnel binary; +- Nginx and frps run continuously, while the proxy registration, SSH connection, and share token are temporary. + +## Server Plan + +### Nginx + +Nginx owns ports 80 and 443, redirects to HTTPS, and proxies the fixed share hostname to the private frps HTTP port. + +The server block must: + +- preserve `Host`; +- set `X-Forwarded-Proto` and `X-Forwarded-For`; +- disable response buffering for streamed downloads; +- allow only `GET` and `HEAD` in the first version; +- disable access logs for the share host, or redact the `t` query parameter; +- never cache responses. + +Minimal proxy shape: + +```nginx +location / { + limit_except GET HEAD { deny all; } + + proxy_pass http://127.0.0.1:18080; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Forwarded-Proto https; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_buffering off; + proxy_request_buffering off; + proxy_read_timeout 1h; +} +``` + +### frps + +frps provides the SSH gateway and HTTP host routing. + +Suggested ports: + +| Port | Purpose | Public | +|---|---|---:| +| 443 | Nginx HTTPS | Yes | +| 2200 | frps SSH Tunnel Gateway | Yes | +| 18080 | frps HTTP virtual host | No | +| 7000 | regular frpc transport, unused by v1 | No | + +Production configuration must: + +- authenticate SSH clients with `sshTunnelGateway.authorizedKeysFile`; +- use a persistent SSH host key; +- run as an unprivileged service account; +- block ports 18080 and 7000 at the public firewall; +- avoid logging LocalShare share tokens. + +## LocalShare Plan + +### Configuration + +Store only non-secret connection settings: + +- public HTTPS origin; +- SSH host and port; +- optional SSH identity path; +- server host-key verification state. + +Use the system SSH configuration, agent, `known_hosts`, and Keychain where available. Never disable strict host-key verification. + +### Tunnel Lifecycle + +Add one small `RemoteTunnel` component that launches `/usr/bin/ssh` with `Process`, passes arguments directly without a shell, captures errors, and exposes connection state. + +The command shape is: + +```bash +/usr/bin/ssh -T \ + -o BatchMode=yes \ + -o ClearAllForwardings=yes \ + -o ExitOnForwardFailure=yes \ + -o ServerAliveInterval=15 \ + -o ServerAliveCountMax=3 \ + -p 2200 \ + -R :80:: \ + v0@relay.example.com \ + http --proxy_name localshare --custom_domain share.example.com +``` + +Lifecycle rules: + +- start the tunnel only after `FileServer` has a listening port; +- keep the tunnel when share content changes, but rotate the share token; +- stop the tunnel when remote sharing or the share itself stops; +- keep it alive when the window closes, matching current server behavior; +- stop it when the app exits; +- report disconnection and retry with capped backoff while remote sharing remains enabled. + +When "Current network only" is enabled, forward to the server's selected listen address instead of assuming loopback. + +### URL and UI + +Refactor URL construction to accept either the current LAN origin or the configured public HTTPS origin while preserving existing paths and `?t=` authentication. + +The share screen adds: + +- a Remote Access switch; +- connecting, online, and offline states; +- the public URL and QR code while remote access is active; +- retry and stop actions. + +The LAN URL remains available. + +### Security + +Before enabling public access: + +- raise share-token entropy to at least 128 bits; +- force remote sharing to read-only and disable upload/text receive routes at both the app and Nginx layers; +- mark cookies `Secure` for trusted HTTPS proxy requests; +- give remote shares a default one-hour lifetime; +- invalidate old tokens immediately on content change or stop; +- trust forwarded client IP headers only when remote mode is active and the request arrived through the local tunnel; +- document that Nginx terminates TLS, so the user-owned server is inside the trust boundary. + +End-to-end encryption against an untrusted relay is not part of the first version. + +## Delivery + +1. Deploy Nginx and frps on a test server and repeat the existing local proof of concept over the public internet. +2. Add strong tokens and public-origin URL construction with unit tests. +3. Add `RemoteTunnel`, connection settings, and lifecycle handling. +4. Add the minimal remote-share UI and fixed expiration behavior. +5. Run unit tests, headless smoke tests, release build, and the dependency gate. + +Public-server acceptance checks: + +- no token returns 403; +- browser `?t=` -> cookie -> clean URL flow still works; +- a large file downloaded through Nginx and frp matches its source checksum; +- changing or stopping a share invalidates the old URL; +- stopping SSH makes the public endpoint unavailable; +- network changes reconnect without reviving an expired token; +- ports 18080 and 7000 are unreachable publicly; +- Nginx/frps logs do not contain share tokens. + +## Deferred + +Add these only when the single-server limit becomes real: + +- multiple simultaneous Macs or users; +- wildcard subdomains and dynamic proxy allocation; +- a management API or account system; +- browser authentication beyond the LocalShare capability URL; +- P2P/WebRTC transport; +- resumable downloads; +- end-to-end encryption against an untrusted relay. diff --git a/tools/smoke-remote-readonly.sh b/tools/smoke-remote-readonly.sh new file mode 100755 index 0000000..dc25611 --- /dev/null +++ b/tools/smoke-remote-readonly.sh @@ -0,0 +1,44 @@ +#!/usr/bin/env bash +# 防回归冒烟测:远程模式只读、拒绝 POST、并只在受信任 HTTPS 转发请求上使用 Secure cookie。 +set -u + +BIN="${BIN:-.build/debug/LocalShare}" +[ -x "$BIN" ] || { echo "找不到 $BIN,先跑 swift build"; exit 2; } + +PORT=$(( (RANDOM % 10000) + 44000 )) +TOK="remote-readonly-test" +ROOT="$(mktemp -d)" +echo hi > "$ROOT/a.txt" +PASS=0; FAIL=0 +ok(){ echo " ✅ $1"; PASS=$((PASS+1)); } +bad(){ echo " ❌ $1"; FAIL=$((FAIL+1)); } + +LS_HEADLESS=1 LS_FOLDER="$ROOT" LS_REMOTE=1 LS_UPLOAD=1 LS_RECV=1 \ + LS_TOKEN="$TOK" LS_PORT="$PORT" "$BIN" >/dev/null 2>&1 & +SRV=$! +trap 'kill $SRV 2>/dev/null; wait $SRV 2>/dev/null; rm -rf "$ROOT"' EXIT + +base="http://127.0.0.1:$PORT" +for _ in $(seq 1 50); do + [ "$(curl -s -o /dev/null -w '%{http_code}' "$base/?t=$TOK")" != "000" ] && break + sleep 0.1 +done + +echo "── 无 token 仍返回 403" +S=$(curl -s -o /dev/null -w '%{http_code}' "$base/") +[ "$S" = "403" ] && ok "无 token 403" || bad "应 403 实为 $S" + +echo "── 远程模式拒绝上传与收文本" +S=$(curl -s -o /dev/null -w '%{http_code}' -X POST -F 'f=@/etc/hosts' "$base/?t=$TOK") +[ "$S" = "405" ] && ok "上传 405" || bad "上传应 405 实为 $S" +S=$(curl -s -o /dev/null -w '%{http_code}' -X POST --data-binary 'x' "$base/ls/text?t=$TOK") +[ "$S" = "405" ] && ok "收文本 405" || bad "收文本应 405 实为 $S" + +echo "── 仅受信任 HTTPS 转发请求的 cookie 带 Secure" +H=$(curl -s -D - -o /dev/null -H 'Accept: text/html' \ + -H 'X-Forwarded-Proto: https' -H 'X-Forwarded-For: 203.0.113.4' "$base/?t=$TOK") +echo "$H" | grep -qi '^Set-Cookie:.*Secure' && ok "Secure cookie" || bad "缺 Secure cookie" + +echo +echo "结果:PASS=$PASS FAIL=$FAIL" +[ "$FAIL" -eq 0 ]