From 611896446b47bffec741ddfc68cb98f008a465b7 Mon Sep 17 00:00:00 2001 From: shawn Date: Fri, 26 Jun 2026 00:09:19 +0800 Subject: [PATCH 01/17] =?UTF-8?q?docs:=20PLAN=20=E6=A0=87=E8=AE=B0?= =?UTF-8?q?=E4=BC=A0=E9=80=92=E6=96=87=E6=9C=AC=20v1=20=E5=B7=B2=E8=90=BD?= =?UTF-8?q?=E5=9C=B0=E3=80=81v2=20=E5=AE=9E=E7=8E=B0=E4=B8=AD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- PLAN.md | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/PLAN.md b/PLAN.md index 631a3f1..f4a6fb1 100644 --- a/PLAN.md +++ b/PLAN.md @@ -234,6 +234,15 @@ open dist/LocalShare.app # 本机自测 / `LStr.i18nJSON`(JS 侧拼接,`jsEscape` 防 ``)。详见 §3「国际化(i18n)」。 已验证:`LangTests` 单测(协商 / q 值 / 穷尽)+ `tools/smoke-accept-language.sh` 冒烟,CI 全过; release 编译通过。 +- [x] 传递文本 v1 — Mac→手机发文本(PR #25):`AppState.sharedText` 与 share 正交,保留路径 `/ls/text` + 提供(导航发 `TextViewer` 壳页、`?raw=1`/curl 发 `text/plain` 原文),可独立分享或挂进多选虚拟根的 + 文本行;离散提交快照、点「分享/更新」即轮换 token;空态加「分享文本」入口(自带 `NSTextView`、 + placeholder 由其自绘以兼容中文输入法组合态);手机页纯文本 + 大「复制」按钮(`execCommand` 回退—— + 纯 http LAN 是非安全上下文、`navigator.clipboard` 不可用)+ http(s) 安全自动链接;设置「记住分享的 + 文本」默认关(开则重启回填草稿、**不自动广播**);历史复用 `RecentShare`(扩 `text:`)+ 逐条 ✕ 删除 + + 「清空」二次确认。`textContent` 注入 + `<`→`<`(共享 `LStr.jsEscape`)双重防注入;`LS_TEXT` + headless 钩子;`tools/smoke-text.sh`(15 项)+ `TextShareTests` 入 CI。设计见 §7「传递文本」。 +- [ ] 传递文本 v2 — 手机→Mac 收文本(实现中):见 §7「传递文本 · v2」。 > 已知坑(已规避并注释):Swifter 1.5.0 的 `HttpParser` 会对请求 path 二次编码,导致 `request.path` > 仍残留一层百分号编码 —— FileServer 落地文件系统前已用 `removingPercentEncoding` 解码,且不影响防穿越。 @@ -259,7 +268,7 @@ curl -s "http://127.0.0.1:8099/?t=testtoken" # 应返回目录列表 (访客上传 v1 已在 0.6 落地,见 §5 进度;当年切范围的理由:上传解决手机照片/文档传到 Mac 这 90% 的诉求,在线编辑在手机浏览器体验差、覆盖丢数据风险大,删除误删风险高,均往后放。) -### 传递文本(设计已定,待实现) +### 传递文本(v1 已落地,v2 实现中) 让「选内容→手机扫码」的内容从「磁盘文件」扩到「一段文本」——剪贴板/链接/口令/说明从桌面甩到手机, 以及反向把手机上的文本收回电脑。**两条独立单向通道,不是同步便签**:发出去的 `sharedText` 与收回来的 @@ -270,7 +279,7 @@ curl -s "http://127.0.0.1:8099/?t=testtoken" # 应返回目录列表 - **收回来(手机→Mac)的难点不在传输**(一个表单 POST 比现有 multipart 上传还轻),而在于 **Mac 要长出 一个「收件箱」形态**:新原生 UI + 新生命周期(收到的文本往哪放/怎么清)+ push 模型。独立成 v2。 -#### v1 — Mac → 手机·发文本 +#### v1 — Mac → 手机·发文本(已落地,PR #25;落点见 §5 进度) | 维度 | 决定 | |---|---| @@ -286,7 +295,7 @@ curl -s "http://127.0.0.1:8099/?t=testtoken" # 应返回目录列表 | 取原文 | `?raw=1` / curl(Accept `*/*`)拿 `text/plain; charset=utf-8` 原文,导航(`text/html`)给壳页——同 md/json/csv 预览的同 URL 双形态 | | 大小 | **不设上限**:文本是 Mac 端自己粘的、非不可信输入,不必像上传那套;空白文本禁用「分享」按钮 | -#### v2 — 手机 → Mac·收文本 +#### v2 — 手机 → Mac·收文本(实现中) | 维度 | 决定 | |---|---| @@ -306,7 +315,8 @@ curl -s "http://127.0.0.1:8099/?t=testtoken" # 应返回目录列表 - **Swifter body 预读内存**:上限只能事后拒绝(同上传),故 v2 必须单条+总数双限。 - **token / 302 去 `?t=` / 网卡绑定 / i18n(`L`/`LStr`,文案过表)/ 转义** 全自动沿用现有机制;收到的文本在 SwiftUI `Text` 里显示天然不执行,但若回显进任何**服务页**须转义。 -- v1/v2 各自一个持久化开关(可考虑合并成一项,留实现时定)。 +- v1/v2 各自一个持久化开关(实现时定为**各一个**:v1「记住分享的文本」已落地、v2「持久化收到的文本」 + 待加;两者隐私语义不同——发出去的是自己粘的、收回来的是他人投递的,分开开关更清晰)。 ### 上传 v1.5:分片上传 From 4cba49361d97b955983e8afa7e349d1041273f09 Mon Sep 17 00:00:00 2001 From: shawn Date: Fri, 26 Jun 2026 11:15:42 +0800 Subject: [PATCH 02/17] =?UTF-8?q?feat:=20=E4=BC=A0=E9=80=92=E6=96=87?= =?UTF-8?q?=E6=9C=AC=20v2=20=E2=80=94=20=E6=94=B6=E6=96=87=E6=9C=AC?= =?UTF-8?q?=E6=9C=8D=E5=8A=A1=E7=AB=AF=E4=B8=8E=E6=89=8B=E6=9C=BA=E7=AB=AF?= =?UTF-8?q?=E7=BD=91=E9=A1=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 手机→Mac 的反向文本通道(与 v1 对称),独立于 share、不落盘。 - FileServer:POST /ls/text 收一段纯文本(请求体即原文),textInboxEnabled 闸门 + 单条 64KB 上限(事后 413)+ onReceiveText 回调;GET /ls/send 出独立 发送页;listing 页按 canReceiveText 内嵌发送表单。 - SendText:可复用的「发文本给电脑」表单片段 + 独立发送页骨架。 - 预览/文本壳页(PreviewPage 及 md/json/csv/文本查看器)开收件箱时也内嵌发送 表单,使纯文本分享 /ls/text 成「读 + 回填」双向页。 - 权限提示:Permission.recvText 经 permSummary 在网页眉头追加「· 可收文本」+ 一枚 chip(不计入 writable,只读/可读写仍指内容本身)。 - 手机端体验:壳页加 touch-action:manipulation 禁双击放大;发送框手机 16px 防 聚焦缩放;文本页复制按钮挪到正文下方、复制走 readonly+屏外 textarea 防页面跳动。 - Lang:新增中英文案(设置/收件箱/发送页/错误/JS 提示)。 - HeadlessServer:LS_RECV / LS_RECV_LOG 钩子,供端到端冒烟。 --- Sources/LocalShare/CsvViewer.swift | 4 +- Sources/LocalShare/DirectoryListing.swift | 30 +++-- Sources/LocalShare/FileServer.swift | 102 ++++++++++++--- Sources/LocalShare/HeadlessServer.swift | 26 +++- Sources/LocalShare/JsonViewer.swift | 4 +- Sources/LocalShare/Lang.swift | 47 ++++++- Sources/LocalShare/MarkdownViewer.swift | 4 +- Sources/LocalShare/Permission.swift | 13 +- Sources/LocalShare/PreviewPage.swift | 14 +- Sources/LocalShare/SendTextPage.swift | 150 ++++++++++++++++++++++ Sources/LocalShare/TextViewer.swift | 16 ++- 11 files changed, 360 insertions(+), 50 deletions(-) create mode 100644 Sources/LocalShare/SendTextPage.swift diff --git a/Sources/LocalShare/CsvViewer.swift b/Sources/LocalShare/CsvViewer.swift index 87f5796..8438c8c 100644 --- a/Sources/LocalShare/CsvViewer.swift +++ b/Sources/LocalShare/CsvViewer.swift @@ -5,7 +5,7 @@ import Foundation // 首行恒作表头。点击表头排序(采样判定数值列走数值比较)、输入框按任意单元格筛选行; // 排序/筛选作用于全量数据,DOM 按 1000 行一档「再显示」渐进渲染——几十 MB 不卡死页面。 enum CsvViewer { - static func html(fileName: String, crumbs: String?, canUpload: Bool, lang: Lang) -> String { + static func html(fileName: String, crumbs: String?, canUpload: Bool, canReceiveText: Bool = false, lang: Lang) -> String { PreviewPage.html( fileName: fileName, crumbs: crumbs, canUpload: canUpload, lang: lang, body: """ @@ -18,7 +18,7 @@ enum CsvViewer {

\(L.webLoading(lang))

""", - css: css, scripts: [boot]) + css: css, scripts: [boot], canReceiveText: canReceiveText) } private static let css = """ diff --git a/Sources/LocalShare/DirectoryListing.swift b/Sources/LocalShare/DirectoryListing.swift index 9d116c4..667ff00 100644 --- a/Sources/LocalShare/DirectoryListing.swift +++ b/Sources/LocalShare/DirectoryListing.swift @@ -19,7 +19,8 @@ enum DirectoryListing { // 真实目录页:枚举目录 → 目录在前/名称序 → 交给渲染核心(href 基路径 = 请求路径)。 // canUpload:单文件夹分享且开了访客上传时为 true,页面出上传按钮 + 整页拖拽,措辞联动「可读写」。 - static func html(directory: URL, requestPath: String, rootName: String, canUpload: Bool = false, lang: Lang) -> String { + static func html(directory: URL, requestPath: String, rootName: String, canUpload: Bool = false, + canReceiveText: Bool = false, lang: Lang) -> String { let fm = FileManager.default let urls = (try? fm.contentsOfDirectory( at: directory, @@ -35,26 +36,29 @@ enum DirectoryListing { let base = requestPath.hasSuffix("/") ? requestPath : requestPath + "/" let entries = sorted.map { (name: $0.lastPathComponent, url: $0, isDir: isDirectory($0)) } - return render(entries: entries, base: base, requestPath: requestPath, rootName: rootName, canUpload: canUpload, textPreview: nil, lang: lang) + return render(entries: entries, base: base, 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, textPreview: String? = nil, lang: Lang) -> String { + static func html(items: [(name: String, url: URL, isDir: Bool)], rootName: String, + textPreview: String? = nil, canReceiveText: Bool = false, lang: Lang) -> String { let sorted = items.sorted { a, b in if a.isDir != b.isDir { return a.isDir } return a.name.localizedStandardCompare(b.name) == .orderedAscending } - return render(entries: sorted, base: "/", requestPath: "/", rootName: rootName, canUpload: false, textPreview: textPreview, lang: lang) + return render(entries: sorted, base: "/", 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, - textPreview: String?, lang: Lang) -> String { + canReceiveText: Bool, textPreview: String?, lang: Lang) -> String { let fm = FileManager.default var rows = "" var folderCount = 0 @@ -83,7 +87,8 @@ enum DirectoryListing { let chips = filterChips(folderCount: folderCount, counts: counts, total: total, lang: lang) return page(title: title, crumbs: crumbs, chips: chips, rows: rows, isEmpty: entries.isEmpty, total: total, canUpload: canUpload, - backHref: parentHref(of: requestPath), textPreview: textPreview, lang: lang) + canReceiveText: canReceiveText, backHref: parentHref(of: requestPath), + textPreview: textPreview, lang: lang) } // MARK: - 片段 @@ -156,10 +161,10 @@ enum DirectoryListing { } private static func page(title: String, crumbs: String, chips: String, rows: String, - isEmpty: Bool, total: Int, canUpload: Bool, backHref: String?, - textPreview: String?, lang: Lang) -> String { - // 措辞统一经 PermSummary 派生(同 GUI),网页端只有「上传」一个写权限会出现 - let ps = permSummary(Permission(add: canUpload), lang) + isEmpty: Bool, total: Int, canUpload: Bool, canReceiveText: Bool, + backHref: String?, textPreview: String?, lang: Lang) -> String { + // 措辞统一经 PermSummary 派生(同 GUI):网页端可能出现「上传」与「可收文本」两类写能力 + let ps = permSummary(Permission(add: canUpload, recvText: canReceiveText), lang) let uploadButton = canUpload ? """ """ : "" @@ -231,7 +236,7 @@ enum DirectoryListing { html,body{margin:0} body{font:15px/1.5 var(--sans);color:var(--ink);background:var(--bg);min-height:100vh; padding:env(safe-area-inset-top) env(safe-area-inset-right) env(safe-area-inset-bottom) env(safe-area-inset-left); - -webkit-text-size-adjust:100%} + -webkit-text-size-adjust:100%;touch-action:manipulation} main{max-width:740px;margin:0 auto;padding:36px 40px 40px} .kicker{display:inline-flex;align-items:center;gap:7px;margin-bottom:12px} .kicker .dot{width:8px;height:8px;border-radius:50%;background:var(--accent)} @@ -369,6 +374,7 @@ enum DirectoryListing { .d-col{display:none} .d-in{display:inline} } + \(canReceiveText ? SendText.css : "")
\(htmlText(ps.eyebrow))
@@ -404,6 +410,7 @@ enum DirectoryListing { \(listInner) + \(canReceiveText ? SendText.card(lang: lang) : "")
\(L.webProvidedBy(lang)) · \(htmlText(ps.tag))
\(dropMask) @@ -555,6 +562,7 @@ enum DirectoryListing { if(e.dataTransfer&&e.dataTransfer.files.length)enqueue(e.dataTransfer.files); }); })(); + \(canReceiveText ? SendText.boot : "") """ diff --git a/Sources/LocalShare/FileServer.swift b/Sources/LocalShare/FileServer.swift index 27f5edc..c3e105b 100644 --- a/Sources/LocalShare/FileServer.swift +++ b/Sources/LocalShare/FileServer.swift @@ -13,6 +13,19 @@ struct ViewerInfo: Identifiable { var fullLabel: String { name.isEmpty ? ip : name } } +// 手机投递到 Mac 的一段文本(传递文本 v2·收件箱条目)。由 FileServer 在 POST /ls/text 命中时构造, +// 经 onReceiveText 回调交给 AppState 持有(FileServer 不存收件箱列表)。Codable 供「持久化收到的文本」 +// 落盘;Identifiable 供 SwiftUI 列表稳定标识。来源用反查到的设备名,查不到回退完整 IP(同 ViewerInfo)。 +struct ReceivedText: Identifiable, Codable, Equatable { + var id = UUID() + let text: String + let ip: String // 来源 IPv4 + let name: String // 反查到的设备名,查不到为空串 + let date: Date // 收到时间 + // 收件箱里的来源标签:有设备名显名,否则显完整 IP。 + var source: String { name.isEmpty ? ip : name } +} + // 基于 Swifter 的只读静态文件服务。 // 全部逻辑放进单个 middleware 闭包(永远返回 response),绕开 router。 // 安全:① token 鉴权(query 或 cookie);② 防目录穿越(路径解析后必须仍在所选文件夹内)。 @@ -94,6 +107,21 @@ final class FileServer { // 每存好一个文件回调一次(socket 线程);GUI 用它提示「新收到」,设置方自行 hop 主线程。 var onUpload: ((URL) -> Void)? + // MARK: - 收文本(手机→Mac,v2) + // 与 share 正交的独立收件箱通道:开关开启时 POST /ls/text 收一段文本、GET /ls/send 出发送页。 + // 不落盘、不依赖文件夹分享(任意分享形态甚至「什么都没分享」都能开)。同 uploadEnabled 加锁、运行中可切。 + private var _textInboxEnabled = false + var textInboxEnabled: Bool { + get { lock.lock(); defer { lock.unlock() }; return _textInboxEnabled } + set { lock.lock(); _textInboxEnabled = newValue; lock.unlock() } + } + // 每收到一段文本回调一次(socket 线程);GUI 用它入收件箱 + 提示未读,设置方自行 hop 主线程。 + var onReceiveText: ((ReceivedText) -> Void)? + + // 单条文本上限(远小于上传的 500MB)。Swifter 进 middleware 前已把 body 整段读进内存,故同上传只能 + // 事后拒绝;总数上限(收件箱满挤旧)由 AppState 把关——两道一起才挡得住「刷一堆不超单条限的消息撑爆内存」。 + static let textInboxLimit = 64 * 1024 + // MARK: - 分享文本(Mac→手机,v1) // 与 share 正交的一段文本:非 nil 即在保留路径 /ls/text 提供(导航发预览壳页、?raw=1/curl 发原文)。 // 纯文本分享时 share=.multiple([])(虚拟根无文件项),二维码直指 /ls/text;与文件共存时挂进虚拟根 @@ -262,6 +290,8 @@ final class FileServer { let token = self.token // 网页语言逐请求决定:按浏览器 Accept-Language,与原生 app 设置无关。往下穿进每个 HTML 生产者。 let lang = Lang.fromAcceptLanguage(req.headers["accept-language"]) + // 收件箱开启:listing 页(同上传表单的出现条件)多挂一张「发文本给电脑」表单。每请求取一次快照。 + let recvOn = textInboxEnabled let viaQuery = req.queryParams.first { $0.0 == "t" }?.1 == token let viaCookie = cookieValue("ls_token", in: req.headers["cookie"]) == token guard viaQuery || viaCookie else { @@ -326,11 +356,27 @@ final class FileServer { crumbs = DirectoryListing.breadcrumb(requestPath: "/" + L.webText(lang), rootName: Self.multipleRootName(lang)) } - return htmlResponse(200, "OK", TextViewer.html(text: text, crumbs: crumbs, canUpload: false, lang: lang), extra: extra) + return htmlResponse(200, "OK", TextViewer.html(text: text, crumbs: crumbs, canUpload: false, canReceiveText: recvOn, lang: lang), extra: extra) } return plainTextResponse(text, extra: extra) } + // 独立发送页(收文本 v2 保留路径,先于分享内容路由):收件箱开启时出「发文本给电脑」页面, + // 供「只收文本、没分享任何内容」时二维码直指(GUI 的 AppState.makeURL)。关闭时 404。 + // token 清洗的 302 已在上面处理。 + if req.method == "GET", req.path == "/ls/send" { + guard textInboxEnabled else { + return htmlResponse(404, "Not Found", Self.notFoundPage(lang), extra: extra) + } + return htmlResponse(200, "OK", SendText.html(lang: lang), extra: extra) + } + + // 收文本(保留路径,先于上传拦截):POST /ls/text 投递一段文本到收件箱。开关关 → 403; + // 超单条上限 → 413;空白 → 400。落库与未读由 onReceiveText 交给 AppState。 + if req.method == "POST", req.path == "/ls/text" { + return handleReceiveText(req: req, lang: lang, extra: extra) + } + // 访客上传:POST 到当前浏览的目录。开关关 / 非文件夹分享一律拒绝(先于单文件分支拦截, // 否则单文件模式下 POST 会拿到文件本体)。 if req.method == "POST" { @@ -343,7 +389,7 @@ final class FileServer { guard FileManager.default.fileExists(atPath: fileURL.path) else { return htmlResponse(404, "Not Found", Self.notFoundPage(lang), extra: extra) } - return contentResponse(fileURL, viewer: wantsViewer, crumbs: nil, canUpload: false, lang: lang, extra: extra) + return contentResponse(fileURL, viewer: wantsViewer, crumbs: nil, canUpload: false, canReceiveText: recvOn, lang: lang, extra: extra) } // 多选模式:虚拟根列出选中项;首段 key 映射到对应真实项后落地(目录项再走子树服务)。 @@ -354,7 +400,7 @@ final class FileServer { let entries = items.map { (name: $0.key, url: $0.url, isDir: $0.isDir) } // 文本与文件共存(或纯文本分享 items 为空)时,虚拟根列表多挂一个指向 /ls/text 的文本行。 let textRow = sharedText.flatMap { $0.isEmpty ? nil : Self.textPreview($0) } - return htmlResponse(200, "OK", DirectoryListing.html(items: entries, rootName: rootName, textPreview: textRow, lang: lang), extra: extra) + return htmlResponse(200, "OK", DirectoryListing.html(items: entries, rootName: rootName, textPreview: textRow, canReceiveText: recvOn, lang: lang), extra: extra) } var segs = decodedPath.split(separator: "/").map(String.init) let key = segs.removeFirst() @@ -369,39 +415,39 @@ final class FileServer { return htmlResponse(404, "Not Found", Self.notFoundPage(lang), extra: extra) } let crumbs = DirectoryListing.breadcrumb(requestPath: decodedPath, rootName: rootName) - return contentResponse(item.url, viewer: wantsViewer, crumbs: crumbs, canUpload: false, lang: lang, extra: extra) + return contentResponse(item.url, viewer: wantsViewer, crumbs: crumbs, canUpload: false, canReceiveText: recvOn, lang: lang, extra: extra) } return serveTree(rootURL: item.url, relPath: rest, encodedPath: req.path, decodedPath: decodedPath, rootName: rootName, - canUpload: false, viewer: wantsViewer, lang: lang, extra: extra) + canUpload: false, canReceiveText: recvOn, viewer: wantsViewer, lang: lang, extra: extra) } guard case .directory(let rootURL) = share else { return .internalServerError } let rel = String(decodedPath.drop { $0 == "/" }) return serveTree(rootURL: rootURL, relPath: rel, encodedPath: req.path, decodedPath: decodedPath, rootName: rootURL.lastPathComponent, - canUpload: uploadEnabled, viewer: wantsViewer, lang: lang, extra: extra) + canUpload: uploadEnabled, canReceiveText: recvOn, viewer: wantsViewer, lang: lang, extra: extra) } // 可预览类型(md/json/csv)且浏览器导航 → 预览壳页(与文件同 URL,相对引用天然成立); // 其余发文件本体。新增预览类型只需在此登记,壳页骨架见 PreviewPage。 private func contentResponse(_ url: URL, viewer: Bool, crumbs: String?, - canUpload: Bool, lang: Lang, extra: [String: String]) -> HttpResponse { - if viewer, let html = Self.previewHTML(url, crumbs: crumbs, canUpload: canUpload, lang: lang) { + canUpload: Bool, canReceiveText: Bool, lang: Lang, extra: [String: String]) -> HttpResponse { + if viewer, let html = Self.previewHTML(url, crumbs: crumbs, canUpload: canUpload, canReceiveText: canReceiveText, lang: lang) { return htmlResponse(200, "OK", html, extra: extra) } return fileResponse(url, lang: lang, extra: extra) } - private static func previewHTML(_ url: URL, crumbs: String?, canUpload: Bool, lang: Lang) -> String? { + private static func previewHTML(_ url: URL, crumbs: String?, canUpload: Bool, canReceiveText: Bool, lang: Lang) -> String? { let name = url.lastPathComponent switch url.pathExtension.lowercased() { case "md", "markdown": - return MarkdownViewer.html(fileName: name, crumbs: crumbs, canUpload: canUpload, lang: lang) + return MarkdownViewer.html(fileName: name, crumbs: crumbs, canUpload: canUpload, canReceiveText: canReceiveText, lang: lang) case "json", "geojson": - return JsonViewer.html(fileName: name, crumbs: crumbs, canUpload: canUpload, lang: lang) + return JsonViewer.html(fileName: name, crumbs: crumbs, canUpload: canUpload, canReceiveText: canReceiveText, lang: lang) case "csv", "tsv": - return CsvViewer.html(fileName: name, crumbs: crumbs, canUpload: canUpload, lang: lang) + return CsvViewer.html(fileName: name, crumbs: crumbs, canUpload: canUpload, canReceiveText: canReceiveText, lang: lang) default: return nil } @@ -426,7 +472,7 @@ final class FileServer { // 单根目录与多选里的每个目录项共用此函数(多选时 rootURL=项目本身、relPath=去掉 key 段后的剩余)。 private func serveTree(rootURL: URL, relPath: String, encodedPath: String, decodedPath: String, rootName: String, canUpload: Bool, - viewer: Bool, lang: Lang, extra: [String: String]) -> HttpResponse { + canReceiveText: Bool, viewer: Bool, lang: Lang, extra: [String: String]) -> HttpResponse { // 防目录穿越:判据抽进 resolveWithinRoot,与 handleUpload 共用一份,避免两处漂移。 guard let target = Self.resolveWithinRoot(rootURL, relPath: relPath) else { return htmlResponse(403, "Forbidden", Self.forbiddenPage(lang)) @@ -451,12 +497,38 @@ final class FileServer { return fileResponse(indexURL, lang: lang, extra: extra) } let html = DirectoryListing.html(directory: target, requestPath: decodedPath, - rootName: rootName, canUpload: canUpload, lang: lang) + rootName: rootName, canUpload: canUpload, + canReceiveText: canReceiveText, lang: lang) return htmlResponse(200, "OK", html, extra: extra) } let crumbs = DirectoryListing.breadcrumb(requestPath: decodedPath, rootName: rootName) - return contentResponse(target, viewer: viewer, crumbs: crumbs, canUpload: canUpload, lang: lang, extra: extra) + return contentResponse(target, viewer: viewer, crumbs: crumbs, canUpload: canUpload, canReceiveText: canReceiveText, lang: lang, extra: extra) + } + + // MARK: - 收文本处理 + + // POST /ls/text(手机→Mac):收一段纯文本投递进收件箱。请求体即原文(text/plain,前端 fetch 直接发 + // textarea 内容,不走表单编码——免去 + / % 的歧义,body.count 即字节数,单条上限判得干净)。 + // 安全:闸门 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 { + return jsonResponse(403, "Forbidden", errorJSON(L.recvDisabled(lang)), extra: extra) + } + guard req.body.count <= Self.textInboxLimit else { + return jsonResponse(413, "Payload Too Large", errorJSON(L.recvOverLimit(lang)), extra: extra) + } + let raw = String(decoding: req.body, as: UTF8.self) + let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { + return jsonResponse(400, "Bad Request", errorJSON(L.recvEmpty(lang)), extra: extra) + } + let ip = req.address ?? "" + 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) } // MARK: - 上传处理 diff --git a/Sources/LocalShare/HeadlessServer.swift b/Sources/LocalShare/HeadlessServer.swift index 28b249b..15b7ea0 100644 --- a/Sources/LocalShare/HeadlessServer.swift +++ b/Sources/LocalShare/HeadlessServer.swift @@ -9,20 +9,23 @@ import Foundation // LS_UPLOAD 置 1 开启访客上传(仅单文件夹分享生效) // LS_BIND 仅绑该 IPv4 地址(选填;默认绑全部接口)——对应 GUI「仅当前网络可见」,供冒烟验证 // LS_TEXT 分享一段文本(可单独,也可与 LS_FOLDER(S) 共存);纯文本时 URL 直指 /ls/text +// LS_RECV 置 1 开启收文本(收件箱);无任何分享内容时 URL 直指 /ls/send +// LS_RECV_LOG 收到文本时把原文追加进该文件(以 0x01 分隔),供冒烟测回读校验 enum HeadlessServer { static func run() { let env = ProcessInfo.processInfo.environment let token = env["LS_TOKEN"] ?? "testtoken" 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 paths: [String] if let multi = env["LS_FOLDERS"] { paths = multi.split(whereSeparator: { $0 == ":" || $0 == "\n" }).map(String.init) } else if let single = env["LS_FOLDER"] { paths = [single] - } else if text != nil { - paths = [] // 纯文本分享:无文件项 + } else if text != nil || recvOn { + paths = [] // 纯文本分享 / 只收文本:无文件项 } else { FileHandle.standardError.write(Data((L.hsEnvMissing(Lang.systemDefault) + "\n").utf8)) exit(2) @@ -33,10 +36,24 @@ enum HeadlessServer { server.uploadEnabled = env["LS_UPLOAD"] == "1" server.listenAddress = env["LS_BIND"] // nil → 全部接口(默认) server.sharedText = text + server.textInboxEnabled = recvOn + if let logPath = env["LS_RECV_LOG"] { + server.onReceiveText = { rt in // socket 线程:把原文追加进日志文件,供冒烟测回读 + let chunk = Data((rt.text + "\u{1}").utf8) + if let h = FileHandle(forWritingAtPath: logPath) { + h.seekToEndOfFile(); h.write(chunk); try? h.close() + } else { + try? chunk.write(to: URL(fileURLWithPath: logPath)) + } + } + } do { let bound = try server.start(preferredPorts: [port]) - // 纯文本分享直指 /ls/text(口径同 GUI 的 AppState.makeURL)。 - let path = (text != nil && urls.isEmpty) ? "/ls/text" : "/" + // 纯文本分享直指 /ls/text、只收文本直指 /ls/send(口径同 GUI 的 AppState.makeURL)。 + let path: String + if text != nil, urls.isEmpty { path = "/ls/text" } + else if recvOn, urls.isEmpty, text == nil { path = "/ls/send" } + else { path = "/" } print("LS_URL http://127.0.0.1:\(bound)\(path)?t=\(token)") fflush(stdout) } catch { @@ -90,6 +107,7 @@ enum HeadlessServer { // 有文本时一律走虚拟根(口径同 GUI 的 AppState.currentShare):纯文本 → 空虚拟根 .multiple([]); // 文本+文件 → 文件项的虚拟根(文本经 server.sharedText 单独挂上,不进 items)。 private static func makeShare(_ urls: [URL], hasText: Bool) -> FileServer.Share { + if urls.isEmpty { return .multiple([]) } // 纯文本 / 只收文本:空虚拟根 if hasText { return .multiple(FileServer.Share.makeItems(urls)) } if urls.count > 1 { return .multiple(FileServer.Share.makeItems(urls)) } var isDir: ObjCBool = false diff --git a/Sources/LocalShare/JsonViewer.swift b/Sources/LocalShare/JsonViewer.swift index 1ba8ac1..2269f6f 100644 --- a/Sources/LocalShare/JsonViewer.swift +++ b/Sources/LocalShare/JsonViewer.swift @@ -6,7 +6,7 @@ import Foundation // 全量遍历键与原始值,命中以「完整路径 + 值」平铺列出(上限 500),清空即回树。 // 长字符串截断 200 字符、点击展开全文。解析失败给「查看原文」出口。 enum JsonViewer { - static func html(fileName: String, crumbs: String?, canUpload: Bool, lang: Lang) -> String { + static func html(fileName: String, crumbs: String?, canUpload: Bool, canReceiveText: Bool = false, lang: Lang) -> String { PreviewPage.html( fileName: fileName, crumbs: crumbs, canUpload: canUpload, lang: lang, body: """ @@ -19,7 +19,7 @@ enum JsonViewer {

\(L.webLoading(lang))

""", - css: css, scripts: [boot]) + css: css, scripts: [boot], canReceiveText: canReceiveText) } private static let css = """ diff --git a/Sources/LocalShare/Lang.swift b/Sources/LocalShare/Lang.swift index 7077b21..e08b306 100644 --- a/Sources/LocalShare/Lang.swift +++ b/Sources/LocalShare/Lang.swift @@ -149,6 +149,11 @@ enum L: CaseIterable { case sharingTextKicker, scanCaptionText, editTextButton case rememberTextTitle, rememberTextDesc, deleteEntry + // 传递文本(v2·手机→Mac 收文本) + case recvInboxTitle, recvInboxDesc, persistRecvTitle, persistRecvDesc + case receivedTextsTitle, receivingTextKicker, inboxName, inboxWaiting + case scanCaptionSend, clearReceivedConfirm, copyTextAction, stopReceivingHelp + // —— 网页(由 Swift 直接拼进 HTML 的静态文案)—— case webUpload, webDropHere, webBackToParent, webEmptyFolder case webNoMatch, webNoMatchSub, webSearchFolder, webClear @@ -157,10 +162,12 @@ enum L: CaseIterable { case webFilterAll, webFilterDir, webProvidedBy case webViewRaw, webLoading, webSearchJSON, webFilterRows case webText, webTextHint, webCopy, webViewRawText + case webSendTitle, webSendEyebrow, webSendSub, webSendHead, webSendPlaceholder, webSendButton // —— 网页错误页 / 上传 JSON —— case webForbiddenTitle, webForbiddenBody, webFileNotFound, webReadFailed case upDisabled, upOverLimit, upPathDenied, upDirMissing, upWriteFailed, upNoFiles + case recvDisabled, recvOverLimit, recvEmpty // —— CLI / headless 终端诊断(按系统语言,见 Lang.systemDefault)—— case cliPortRange, cliHeadlessNeedsPath, cliPortHeadlessOnly, cliAppNotFound @@ -169,7 +176,7 @@ enum L: CaseIterable { // —— 权限派生(permSummary,原生 + 网页共用)—— case permWritable, permReadonly case eyebrowWritable, eyebrowReadonly - case chipDownloadable, chipReadonly, chipCanUpload, chipCanEdit, chipCanDelete + case chipDownloadable, chipReadonly, chipCanUpload, chipCanEdit, chipCanDelete, chipCanReceiveText case permWriteUpload, permWriteEdit, permWriteDelete func callAsFunction(_ lang: Lang) -> String { @@ -329,6 +336,20 @@ enum L: CaseIterable { "Refills the last text after restart for reuse; off forgets it on quit") case .deleteEntry: return ("删除", "Delete") + case .recvInboxTitle: return ("允许收文本", "Allow Receiving Text") + case .recvInboxDesc: return ("手机扫码后可把一段文本发到这台 Mac", "Phones can send text to this Mac after scanning") + case .persistRecvTitle: return ("记住收到的文本", "Remember Received Text") + case .persistRecvDesc: return ("重启后保留收件箱内容;关闭则退出即忘", + "Keeps the inbox after restart; off forgets it on quit") + case .receivedTextsTitle: return ("收到的文本", "Received Text") + case .receivingTextKicker: return ("正在接收文本", "Receiving text") + case .inboxName: return ("收件箱", "Inbox") + case .inboxWaiting: return ("等待手机发来文本…", "Waiting for text from a phone…") + case .scanCaptionSend: return ("扫码把文本发到这台 Mac · 同一 Wi-Fi", "Scan to send text to this Mac · same Wi-Fi") + case .clearReceivedConfirm: return ("清空收到的全部文本?", "Clear all received text?") + case .copyTextAction: return ("复制", "Copy") + case .stopReceivingHelp: return ("停止接收文本", "Stop receiving text") + case .webUpload: return ("上传", "Upload") case .webDropHere: return ("松手上传到这里", "Drop here to upload") case .webBackToParent: return ("返回上一级", "Up one level") @@ -354,6 +375,12 @@ enum L: CaseIterable { case .webTextHint: return ("分享者发来的一段文本", "A snippet shared from the host") case .webCopy: return ("复制", "Copy") case .webViewRawText: return ("查看原始文本", "View raw text") + case .webSendTitle: return ("发送文本到电脑", "Send Text to Computer") + case .webSendEyebrow: return ("局域网 · 发送到电脑", "LAN · send to computer") + case .webSendSub: return ("输入文本,点发送即可投递到这台 Mac。", "Type some text and send it to this Mac.") + case .webSendHead: return ("发文本给电脑", "Send text to the computer") + case .webSendPlaceholder: return ("在此输入要发送到电脑的文本…", "Type text to send to the computer…") + case .webSendButton: return ("发送", "Send") case .webForbiddenTitle: return ("无法访问", "No access") case .webForbiddenBody: return ("请通过电脑上显示的二维码扫码进入。", "Scan the QR code shown on the computer to enter.") @@ -365,6 +392,9 @@ enum L: CaseIterable { case .upDirMissing: return ("目录不存在", "Directory not found") case .upWriteFailed: return ("写入失败", "Write failed") case .upNoFiles: return ("没有可保存的文件", "No files to save") + case .recvDisabled: return ("收文本未开启", "Receiving text not enabled") + case .recvOverLimit: return ("超过 64KB 上限", "Exceeds 64 KB limit") + case .recvEmpty: return ("文本为空", "Text is empty") case .cliPortRange: return ("--port 需要 1–65535 的端口号", "--port requires a port number 1–65535") case .cliHeadlessNeedsPath: return ("--headless 需要至少一个文件或文件夹路径", "--headless requires at least one file or folder path") @@ -386,6 +416,7 @@ enum L: CaseIterable { case .chipCanUpload: return ("可上传", "Can upload") case .chipCanEdit: return ("可编辑", "Can edit") case .chipCanDelete: return ("可删除", "Can delete") + case .chipCanReceiveText: return ("可收文本", "Can receive text") case .permWriteUpload: return ("上传", "Upload") case .permWriteEdit: return ("编辑", "Edit") case .permWriteDelete: return ("删除", "Delete") @@ -422,6 +453,16 @@ enum LStr { lang == .zh ? "\(n) 字" : "\(n) char\(n == 1 ? "" : "s")" } + // 收件箱已收条数(收文本 ticket 副标识 / 卡片计数):"已收到 3 条" / "3 received" + static func receivedCount(_ n: Int, _ lang: Lang) -> String { + lang == .zh ? "已收到 \(n) 条" : "\(n) received" + } + + // 收件箱未读角标:"2 条新" / "2 new" + static func unreadCount(_ n: Int, _ lang: Lang) -> String { + lang == .zh ? "\(n) 条新" : "\(n) new" + } + // 在线访客明细右栏的人数:"3 人" / "3 people" static func viewerCountLabel(_ n: Int, _ lang: Lang) -> String { lang == .zh ? "\(n) 人" : "\(n) \(n == 1 ? "person" : "people")" @@ -583,6 +624,10 @@ enum LStr { ("loadFailed", "加载失败", "Load failed"), // 文本页复制按钮(按钮「复制」初始文案由服务端 L.webCopy 渲染、JS 捕获后复原,故此处只需「已复制」) ("copied", "已复制", "Copied"), + // 发文本给电脑(v2 手机端表单) + ("sent", "已发送", "Sent"), + ("sendFailed", "发送失败", "Send failed"), + ("sendOverLimit","超过 64KB 上限", "over 64 KB limit"), ("parseFailed", "解析失败", "Parse failed"), ("parsing", "正在解析…", "Parsing…"), // JSON viewer diff --git a/Sources/LocalShare/MarkdownViewer.swift b/Sources/LocalShare/MarkdownViewer.swift index ed702b9..debf10c 100644 --- a/Sources/LocalShare/MarkdownViewer.swift +++ b/Sources/LocalShare/MarkdownViewer.swift @@ -6,11 +6,11 @@ import Foundation // (MarkedJS.source),页面加载后 fetch 同 URL 的 ?raw=1 取原文解析; // 原始 HTML 块/行内标签一律转义展示(own-files 威胁面小,仍默认不执行)。 enum MarkdownViewer { - static func html(fileName: String, crumbs: String?, canUpload: Bool, lang: Lang) -> String { + static func html(fileName: String, crumbs: String?, canUpload: Bool, canReceiveText: Bool = false, lang: Lang) -> String { PreviewPage.html( fileName: fileName, crumbs: crumbs, canUpload: canUpload, lang: lang, body: #"

\#(L.webLoading(lang))

"#, - css: css, scripts: [MarkedJS.source, rendererConfig, boot]) + css: css, scripts: [MarkedJS.source, rendererConfig, boot], canReceiveText: canReceiveText) } private static let css = """ diff --git a/Sources/LocalShare/Permission.swift b/Sources/LocalShare/Permission.swift index c2bc638..d0d8ddf 100644 --- a/Sources/LocalShare/Permission.swift +++ b/Sources/LocalShare/Permission.swift @@ -8,6 +8,7 @@ struct Permission: Equatable { var add = false // 访客上传(0.6 起可用,FileServer.uploadEnabled 联动) var edit = false // 访客在线编辑(未开放) var del = false // 访客删除(未开放) + var recvText = false // 访客可发文本给电脑(收文本 v2)。独立于「内容是否可写」,只追加一枚说明,不改 只读/可读写 判定 } // 由单个 perm 对象派生每屏的只读/可读写文案,统一真相源。 @@ -25,12 +26,20 @@ func permSummary(_ p: Permission, _ lang: Lang) -> PermSummary { if p.add { writes.append(L.permWriteUpload(lang)); writeChips.append(L.chipCanUpload(lang)) } if p.edit { writes.append(L.permWriteEdit(lang)); writeChips.append(L.chipCanEdit(lang)) } if p.del { writes.append(L.permWriteDelete(lang)); writeChips.append(L.chipCanDelete(lang)) } + // 收文本不计入 writable(它不让访客改动分享内容,故「只读/可读写」仍指内容本身、上传提示语也不被带偏), + // 只在眉头与权限标签后追加一枚「可收文本」说明——和「可上传」chip 同一视觉语言,轻量。 let writable = !writes.isEmpty + var eyebrow = writable ? L.eyebrowWritable(lang) : L.eyebrowReadonly(lang) + var chips = writable ? [L.chipDownloadable(lang)] + writeChips : [L.chipReadonly(lang), L.chipDownloadable(lang)] + if p.recvText { + eyebrow += " · " + L.chipCanReceiveText(lang) + chips.append(L.chipCanReceiveText(lang)) + } return PermSummary( writable: writable, writes: writes, tag: writable ? L.permWritable(lang) : L.permReadonly(lang), - eyebrow: writable ? L.eyebrowWritable(lang) : L.eyebrowReadonly(lang), - chips: writable ? [L.chipDownloadable(lang)] + writeChips : [L.chipReadonly(lang), L.chipDownloadable(lang)] + eyebrow: eyebrow, + chips: chips ) } diff --git a/Sources/LocalShare/PreviewPage.swift b/Sources/LocalShare/PreviewPage.swift index 7d57a06..32f5194 100644 --- a/Sources/LocalShare/PreviewPage.swift +++ b/Sources/LocalShare/PreviewPage.swift @@ -10,9 +10,13 @@ enum PreviewPage { // " }.joined(separator: "\n") + body: String, css: String, scripts: [String], rawLabel: String? = nil, + canReceiveText: Bool = false) -> String { + let ps = permSummary(Permission(add: canUpload, recvText: canReceiveText), lang) + // 收件箱开启时,预览/文本壳页底部嵌同一份「发文本给电脑」表单(与列表页一致)——让纯文本分享、 + // 单文件预览等没有列表页的形态也能就地回填文本给 Mac。 + let allScripts = canReceiveText ? scripts + [SendText.boot] : scripts + let scriptTags = allScripts.map { "" }.joined(separator: "\n") return """ @@ -41,7 +45,7 @@ enum PreviewPage { html,body{margin:0} body{font:15px/1.5 var(--sans);color:var(--ink);background:var(--bg);min-height:100vh; padding:env(safe-area-inset-top) env(safe-area-inset-right) env(safe-area-inset-bottom) env(safe-area-inset-left); - -webkit-text-size-adjust:100%} + -webkit-text-size-adjust:100%;touch-action:manipulation} main{max-width:740px;margin:0 auto;padding:36px 40px 40px} .kicker{display:inline-flex;align-items:center;gap:7px;margin-bottom:12px} .kicker .dot{width:8px;height:8px;border-radius:50%;background:var(--accent)} @@ -75,6 +79,7 @@ enum PreviewPage { h1.t{font-size:25px} } \(css) + \(canReceiveText ? SendText.css : "")
\(esc(ps.eyebrow))
@@ -87,6 +92,7 @@ enum PreviewPage { \(body) + \(canReceiveText ? SendText.card(lang: lang) : "")
\(L.webProvidedBy(lang)) · \(esc(ps.tag))
diff --git a/Sources/LocalShare/SendTextPage.swift b/Sources/LocalShare/SendTextPage.swift new file mode 100644 index 0000000..e408f29 --- /dev/null +++ b/Sources/LocalShare/SendTextPage.swift @@ -0,0 +1,150 @@ +import Foundation + +// 手机→Mac「发文本给电脑」表单(传递文本 v2 的手机端)。两处复用同一份片段: +// · 列表页(DirectoryListing):收件箱开启时挂在文件列表之下,与访客上传表单同条件出现; +// · 独立发送页(/ls/send):「只收文本、没分享任何内容」时二维码直指此页(FileServer 在 textInboxEnabled 时服务)。 +// 关键约束(见 PLAN.md「传递文本 · v2」与 CLAUDE.md): +// · 纯 http 局域网是非安全上下文——这里走 fetch POST 原文,无须 clipboard,不受该限制影响; +// · 单条上限与服务端 textInboxLimit(64KB)对齐,前端按 UTF-8 字节数先行拦截(Blob().size); +// · 投递的文本只在 Mac 端 SwiftUI Text 里显示(天然不执行),故无回显 XSS 之虞。 +enum SendText { + // 表单卡片片段(不含页面外壳)。withHead:列表页里需要标题区分于文件列表,独立发送页靠页面 H1 故省略。 + static func card(lang: Lang, withHead: Bool = true) -> String { + let head = withHead ? """ +
\ + \ + \(esc(L.webSendHead(lang)))
+ """ : "" + return """ +
+ \(head) + +
+ + +
+
+ """ + } + + static let css = """ + .sendbox{margin-top:22px;padding:0;overflow:hidden} + .sendhead{display:flex;align-items:center;gap:8px;padding:13px 16px;border-bottom:1px solid var(--line); + background:var(--surfaceAlt);font:600 13px var(--sans);color:var(--ink)} + .sendhead svg{color:var(--accent)} + .sendta{display:block;width:100%;min-height:120px;resize:vertical;border:none;outline:none; + background:transparent;color:var(--ink);font:13.5px/1.6 var(--mono);padding:16px 18px; + -webkit-text-size-adjust:100%} + .sendta::placeholder{color:var(--inkFaint)} + .sendbar{display:flex;align-items:center;gap:12px;padding:10px 14px;border-top:1px solid var(--line); + background:var(--surfaceAlt)} + .sendstatus{flex:1;min-width:0;font:12px var(--mono);color:var(--inkMute); + overflow:hidden;text-overflow:ellipsis;white-space:nowrap} + .sendstatus.ok{color:var(--ok)} + .sendstatus.err{color:var(--danger)} + .sendbtn{flex:none;display:inline-flex;align-items:center;gap:7px;height:38px;padding:0 18px;border-radius:10px; + cursor:pointer;font:600 14px var(--sans);white-space:nowrap;border:1px solid var(--accent); + background:var(--accent);color:#fff;transition:filter .15s} + .sendbtn:hover{filter:brightness(1.07)} + .sendbtn:disabled{opacity:.5;cursor:default} + /* 手机上输入框字号 <16px 时 iOS 聚焦会自动放大页面,置 16px 杜绝。 */ + @media(max-width:560px){.sendta{font-size:16px}} + """ + + // 启动脚本:发送 textarea 内容到 POST /ls/text。鉴权走已种下的 cookie(同源)。超 64KB 前端先拦。 + // 成功清空输入并闪「已发送」;413/失败给对应提示。Cmd/Ctrl+Enter 快捷发送。依赖页面已注入的 LS_I18N。 + static let boot = """ + (function(){ + var ta=document.getElementById('sendta'),btn=document.getElementById('sendbtn'),st=document.getElementById('sendstatus'); + if(!btn)return; + var MAX=65536,timer; + function flash(msg,ok){ + st.textContent=msg;st.className='sendstatus '+(ok?'ok':'err'); + clearTimeout(timer);timer=setTimeout(function(){st.textContent='';st.className='sendstatus';},2400); + } + function send(){ + var v=ta.value; + 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}) + .then(function(r){ + btn.disabled=false; + if(r.ok){ta.value='';flash(LS_I18N.sent,true);ta.focus();} + else if(r.status===413){flash(LS_I18N.sendOverLimit,false);} + else{flash(LS_I18N.sendFailed,false);} + }).catch(function(){btn.disabled=false;flash(LS_I18N.sendFailed,false);}); + } + btn.addEventListener('click',send); + ta.addEventListener('keydown',function(e){if((e.metaKey||e.ctrlKey)&&e.key==='Enter'){e.preventDefault();send();}}); + })(); + """ + + // 独立发送页:自带极简外壳(票据风 tokens + 15s 心跳保活),正文是无标题区的发送卡片 + //(页面 H1 已承担标题)。与列表页同源同 tokens;不引外部依赖,局域网离线可渲染。 + static func html(lang: Lang) -> String { + """ + + + + \(esc(L.webSendTitle(lang))) + +
+
\(esc(L.webSendEyebrow(lang)))
+

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

+

\(esc(L.webSendSub(lang)))

+ \(card(lang: lang, withHead: false)) +
\(L.webProvidedBy(lang))
+
+ + + + """ + } + + static func esc(_ s: String) -> String { + s.replacingOccurrences(of: "&", with: "&") + .replacingOccurrences(of: "<", with: "<") + .replacingOccurrences(of: ">", with: ">") + } +} diff --git a/Sources/LocalShare/TextViewer.swift b/Sources/LocalShare/TextViewer.swift index c129c26..f093857 100644 --- a/Sources/LocalShare/TextViewer.swift +++ b/Sources/LocalShare/TextViewer.swift @@ -9,27 +9,27 @@ import Foundation // · 自动链接只认 http(s),由正则保证 scheme,不会引入 javascript: 之类。 enum TextViewer { // text:要展示/复制的原文;crumbs:与文件共存于虚拟根时显示「分享内容 / 文本」,纯文本分享传 nil。 - static func html(text: String, crumbs: String?, canUpload: Bool, lang: Lang) -> String { + static func html(text: String, crumbs: String?, canUpload: Bool, canReceiveText: Bool = false, lang: Lang) -> String { PreviewPage.html( fileName: L.webText(lang), crumbs: crumbs, canUpload: canUpload, lang: lang, body: """
+

               
\(PreviewPage.esc(L.webTextHint(lang)))
-

             
""", css: css, scripts: ["var LS_TEXT=\"\(LStr.jsEscape(text))\";", boot], - rawLabel: L.webViewRawText(lang)) + rawLabel: L.webViewRawText(lang), canReceiveText: canReceiveText) } private static let css = """ .txt{padding:0;overflow:hidden} - .txtbar{display:flex;align-items:center;gap:12px;padding:12px 16px;border-bottom:1px solid var(--line); + .txtbar{display:flex;align-items:center;gap:12px;padding:12px 16px;border-top:1px solid var(--line); background:var(--surfaceAlt)} .txtbar .hint{flex:1;min-width:0;font:12px var(--mono);color:var(--inkMute); overflow:hidden;text-overflow:ellipsis;white-space:nowrap} @@ -71,9 +71,11 @@ enum TextViewer { } function legacy(){ // 纯 http 局域网(非安全上下文)下 navigator.clipboard 不可用,回退选中 + execCommand。 - var ta=document.createElement('textarea');ta.value=LS_TEXT; - ta.style.position='fixed';ta.style.top='0';ta.style.left='0';ta.style.opacity='0'; - document.body.appendChild(ta);ta.focus();ta.select(); + // readonly + 移到屏外:iOS 不弹软键盘、不缩放、不顶起视口(修复点复制时页面跳动)。 + var ta=document.createElement('textarea');ta.value=LS_TEXT;ta.readOnly=true; + ta.style.cssText='position:fixed;top:0;left:-9999px;font-size:16px'; + document.body.appendChild(ta); + ta.focus();ta.setSelectionRange(0,ta.value.length); try{document.execCommand('copy');flash();}catch(e){} document.body.removeChild(ta); } From 114219120ba44edabb41e6bbb808092a8ce56405 Mon Sep 17 00:00:00 2001 From: shawn Date: Fri, 26 Jun 2026 11:16:07 +0800 Subject: [PATCH 03/17] =?UTF-8?q?feat:=20=E4=BC=A0=E9=80=92=E6=96=87?= =?UTF-8?q?=E6=9C=AC=20v2=20=E2=80=94=20Mac=20=E7=AB=AF=E6=94=B6=E4=BB=B6?= =?UTF-8?q?=E7=AE=B1=E3=80=81=E8=AE=BE=E7=BD=AE=E5=BC=80=E5=85=B3=E4=B8=8E?= =?UTF-8?q?=E5=8F=AA=E6=94=B6=E6=A8=A1=E5=BC=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - AppState:receivedTexts(上限 100 挤旧)、textInboxEnabled、persistReceivedText、 未读计数;isServing / isReceiveOnly 把闸门纳入 start/stop/clearShare/QR; onReceiveText hop 回主线程;复制/删/清空/持久化。 - ContentView:收件箱卡片(复用「新收到」样式 + 来源/时长 + 复制/删/清空 + 未读角标); 只收模式 ticket(含 ✕ 一键退出 → 回初始);设置页「允许收文本」+「记住收到的文本」 两开关;qrPass 指向 /ls/send;router 改按 isServing 分流。 - Components:InboxGlyph 收件箱图标。 --- Sources/LocalShare/AppState.swift | 106 +++++++++++++++++- Sources/LocalShare/Components.swift | 12 ++ Sources/LocalShare/ContentView.swift | 158 +++++++++++++++++++++++++-- 3 files changed, 263 insertions(+), 13 deletions(-) diff --git a/Sources/LocalShare/AppState.swift b/Sources/LocalShare/AppState.swift index 2c2ae98..3137eea 100644 --- a/Sources/LocalShare/AppState.swift +++ b/Sources/LocalShare/AppState.swift @@ -28,6 +28,8 @@ final class AppState: ObservableObject { @Published var viewerCount = 0 // 最近 45s 内活跃的访客设备数(FileServer 在线感知) @Published var viewers: [ViewerInfo] = [] // 在线访客明细(设备名 / 完整 IP),最近活跃在前 @Published var received: [URL] = [] // 本次分享期间访客上传的文件(新→旧,最多留 5 条) + @Published var receivedTexts: [ReceivedText] = [] // 手机投递来的文本(收件箱,新→旧,最多 100 条挤旧) + @Published var unreadReceived = 0 // 收件箱未读条数(角标);用户查看/复制即清零 @Published var permission = Permission() // read 常开;add 可切(仅单文件夹分享);edit/del 未开放 @Published var configuredPort: in_port_t = 8080 // 用户期望端口(设置页可改,持久化) @@ -38,6 +40,8 @@ final class AppState: ObservableObject { @Published var langPref: LangPref = .system // 语言:跟随系统 / 中文 / English(持久化) @Published var showRecents = true // 主界面是否展示「最近分享」模块(持久化) @Published var persistText = false // 「记住分享的文本」开关(默认关,持久化) + @Published var textInboxEnabled = false // 「允许收文本」闸门(默认关,持久化;不限分享形态) + @Published var persistReceivedText = false // 「记住收到的文本」(默认关,持久化) @Published var cliStatus: CLIInstaller.Status = .notInstalled // 命令行工具安装状态 // GUI 进程仅构造一次,init 末尾自登记;AppDelegate 的 open 事件回调经它触达状态。 @@ -59,6 +63,9 @@ final class AppState: ObservableObject { private let showRecentsKey = "showRecentShares" private let persistTextKey = "persistSharedText" // 是否记住分享的文本(默认关) private let sharedTextKey = "lastSharedText" // 上次分享的文本(仅 persistText 开时写入) + private let textInboxKey = "textInboxEnabled" // 收件箱闸门(默认关) + private let persistReceivedKey = "persistReceivedText" // 是否记住收到的文本(默认关) + private let receivedTextsKey = "receivedTexts" // 收件箱内容(仅 persistReceivedText 开时写入) // 配置端口优先,其余作回退(8080 列入回退以防配置端口占用)。 private let fallbackPorts: [in_port_t] = [8000, 8888, 9000, 8080] @@ -79,9 +86,13 @@ final class AppState: ObservableObject { if persistText, let t = UserDefaults.standard.string(forKey: sharedTextKey), !t.isEmpty { textDraft = t } + // 收件箱闸门与(可选)持久化的收件箱内容。收件箱开着即便没分享任何内容也要自动起服务(见下)。 + textInboxEnabled = UserDefaults.standard.bool(forKey: textInboxKey) // 未写入默认 false + persistReceivedText = UserDefaults.standard.bool(forKey: persistReceivedKey) + if persistReceivedText { loadReceivedTexts() } loadRecents() refreshNetwork() - // 恢复上次分享对象并自动启动,让同事开 app 就能看到二维码。 + // 恢复上次分享对象,让同事开 app 就能看到二维码。 // 多选存为路径数组(新键);读不到再回退旧单值键(迁移)。缺失的项自动剔除,剩 ≥1 即恢复。 var restorePaths = UserDefaults.standard.stringArray(forKey: sharedPathsKey) ?? UserDefaults.standard.string(forKey: sharedDefaultsKey).map { [$0] } @@ -91,8 +102,9 @@ final class AppState: ObservableObject { sharedItems = restorePaths.map { URL(fileURLWithPath: $0) } updateSharedIsFile() describeShared() - start() } + // 有任何理由起服务(分享内容或收件箱开着)即自动启动。 + if isServing { start() } AppState.shared = self // 消费早到的 open 事件(CLI 冷启动时可能先于本 init 到达),覆盖上面恢复的旧分享。 if !AppDelegate.pendingOpenURLs.isEmpty { @@ -109,6 +121,10 @@ final class AppState: ObservableObject { var isMultiple: Bool { sharedItems.count > 1 } var hasText: Bool { !(sharedText?.isEmpty ?? true) } var isTextOnly: Bool { sharedItems.isEmpty && hasText } // 只分享文本、无文件 + // 有任何理由起服务:有分享内容(文件/文本),或收件箱开着(即便什么都没分享也要服务 /ls/send)。 + var isServing: Bool { !isEmpty || textInboxEnabled } + // 只收文本、没分享任何内容:二维码直指发送页 /ls/send,主界面出收件模式票据。 + var isReceiveOnly: Bool { isEmpty && textInboxEnabled } var sharedURL: URL? { sharedItems.first } // 单项便利访问;UI 仅在单项时使用 var currentSharePaths: Set { Set(sharedItems.map(\.path)) } @@ -119,6 +135,8 @@ final class AppState: ObservableObject { let q = "?t=\(token)" // 纯文本分享:二维码直指 /ls/text(扫码即落文本页,等同单文件直链)。 if isTextOnly { return "http://\(host):\(port)/ls/text\(q)" } + // 只收文本:二维码直指发送页 /ls/send(扫码即落「发文本给电脑」表单)。 + if isReceiveOnly { return "http://\(host):\(port)/ls/send\(q)" } // 单文件直链该文件(文本与文件共存时走虚拟根,不直链,故附带 !hasText)。 if sharedIsFile, !hasText, let name = sharedItems.first?.lastPathComponent, let enc = name.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) { @@ -192,6 +210,7 @@ final class AppState: ObservableObject { private func pushToServer() { server?.token = token server?.sharedText = hasText ? sharedText : nil + server?.textInboxEnabled = textInboxEnabled server?.share = currentShare } @@ -249,7 +268,7 @@ final class AppState: ObservableObject { private var currentShare: FileServer.Share { if hasText { return .multiple(FileServer.Share.makeItems(sharedItems)) } switch sharedItems.count { - case 0: return .directory(URL(fileURLWithPath: NSTemporaryDirectory())) + case 0: return .multiple([]) // 只收文本(无任何分享内容):空虚拟根,服务靠 /ls/send + /ls/text case 1: return sharedIsFile ? .file(sharedItems[0]) : .directory(sharedItems[0]) default: return .multiple(FileServer.Share.makeItems(sharedItems)) } @@ -288,17 +307,86 @@ final class AppState: ObservableObject { NSWorkspace.shared.activateFileViewerSelecting([url]) } + // MARK: - 收件箱(收文本 v2) + + // 「允许收文本」闸门。开:不限分享形态都能开,开了就把服务拉起(无分享时即「只收模式」),不轮换 token、 + // 不重启(运行中只切 server 标志,发送表单随之显隐、已发链接继续有效)。关:若再无其它分享则停服务。 + func setTextInboxEnabled(_ on: Bool) { + guard on != textInboxEnabled else { return } + textInboxEnabled = on + UserDefaults.standard.set(on, forKey: textInboxKey) + if isRunning { + if isServing { server?.textInboxEnabled = on } // 仍有理由服务:原地切标志 + else { stop() } // 关掉且无其它分享 → 拆服务,回到空状态 + } else if isServing { + start() // 从空状态开启 → 起服务(只收模式) + } + } + + // 「记住收到的文本」开关。开:立即把当前收件箱落盘;关:抹掉磁盘留存(内存当次仍在,退出即忘)。 + func setPersistReceivedText(_ on: Bool) { + guard on != persistReceivedText else { return } + persistReceivedText = on + UserDefaults.standard.set(on, forKey: persistReceivedKey) + if on { saveReceivedTexts() } + else { UserDefaults.standard.removeObject(forKey: receivedTextsKey) } + } + + // 收到手机投递的文本(FileServer 回调已 hop 回主线程)。新→旧插入,满 100 条挤掉最旧;未读 +1。 + private func recordReceivedText(_ rt: ReceivedText) { + receivedTexts.insert(rt, at: 0) + if receivedTexts.count > 100 { receivedTexts = Array(receivedTexts.prefix(100)) } + unreadReceived += 1 + saveReceivedTextsIfNeeded() + } + + // 复制一条收到的文本到剪贴板,并清未读(视作已读)。 + func copyReceivedText(_ rt: ReceivedText) { + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(rt.text, forType: .string) + markReceivedRead() + } + + func deleteReceivedText(_ rt: ReceivedText) { + receivedTexts.removeAll { $0.id == rt.id } + saveReceivedTextsIfNeeded() + } + + func clearReceivedTexts() { + receivedTexts.removeAll() + unreadReceived = 0 + saveReceivedTextsIfNeeded() + } + + func markReceivedRead() { unreadReceived = 0 } + + private func saveReceivedTextsIfNeeded() { if persistReceivedText { saveReceivedTexts() } } + private func saveReceivedTexts() { + if let data = try? JSONEncoder().encode(receivedTexts) { + UserDefaults.standard.set(data, forKey: receivedTextsKey) + } + } + private func loadReceivedTexts() { + guard let data = UserDefaults.standard.data(forKey: receivedTextsKey), + let list = try? JSONDecoder().decode([ReceivedText].self, from: data) else { return } + receivedTexts = list + } + // MARK: - 启停 / 端口 func start() { - guard !isEmpty else { return } + guard isServing else { return } refreshNetwork() let fs = FileServer(share: currentShare, token: token) fs.sharedText = hasText ? sharedText : nil fs.uploadEnabled = permission.add && canToggleUpload + fs.textInboxEnabled = textInboxEnabled fs.onUpload = { url in // socket 线程 → 主线程 Task { @MainActor [weak self] in self?.recordReceived(url) } } + fs.onReceiveText = { rt in // socket 线程 → 主线程 + Task { @MainActor [weak self] in self?.recordReceivedText(rt) } + } let prefer = [configuredPort] + fallbackPorts.filter { $0 != configuredPort } // 「仅当前网络可见」开 → 只绑选中网卡;无选中网卡(无网)时退回全接口,避免直接挂掉。 let bindIP = bindSelectedOnly ? selectedInterface?.ip : nil @@ -378,9 +466,9 @@ final class AppState: ObservableObject { func toggle() { isRunning ? stop() : start() } - // 清除当前分享:停服务 + 清空选择,回到空状态(初始拖拽屏)。历史里仍保留该条,可一键重新分享。 + // 清除当前分享:清空选择。收件箱关 → 停服务回到空状态(初始拖拽屏);收件箱开 → 不停服务、转入 + // 「只收文本」模式(换钥匙作废旧分享链接、QR 改指 /ls/send)。历史里仍保留该条,可一键重新分享。 func clearShare() { - stop() sharedItems = [] sharedIsFile = false sharedDetail = nil @@ -391,6 +479,12 @@ final class AppState: ObservableObject { UserDefaults.standard.removeObject(forKey: sharedDefaultsKey) UserDefaults.standard.removeObject(forKey: sharedTextKey) // 不在磁盘上残留文本(同「撤下即清」) screen = .share + if isServing { + token = Token.generate() // 旧分享链接/cookie/二维码作废 + if isRunning { pushToServer() } else { start() } + } else { + stop() + } } // 应用新监听端口:持久化配置;运行中则重启服务(已分发链接需更新)。 diff --git a/Sources/LocalShare/Components.swift b/Sources/LocalShare/Components.swift index 11f6fd9..e64d41b 100644 --- a/Sources/LocalShare/Components.swift +++ b/Sources/LocalShare/Components.swift @@ -248,6 +248,18 @@ struct MultiGlyph: View { } } +// 收件箱图标:圆角方块 + accentSoft 底 + accent 收件托盘形,表示「正在接收手机发来的文本」。 +struct InboxGlyph: View { + let t: Theme + var size: CGFloat = 40 + var body: some View { + RoundedRectangle(cornerRadius: size * 0.28, style: .continuous) + .fill(t.accentSoft) + .frame(width: size, height: size) + .overlay(Image(systemName: "tray.and.arrow.down.fill").font(.system(size: size * 0.4)).foregroundStyle(t.accent)) + } +} + // 文件类型图标:圆角方块 + 类型底色 + 类型 SF Symbol + 小写扩展名(mono)。 struct TypeGlyph: View { let t: Theme diff --git a/Sources/LocalShare/ContentView.swift b/Sources/LocalShare/ContentView.swift index 53e287f..d2e4b95 100644 --- a/Sources/LocalShare/ContentView.swift +++ b/Sources/LocalShare/ContentView.swift @@ -51,7 +51,7 @@ struct ContentView: View { case .history: HistoryScreen(t: t) case .share: - if state.isEmpty { + if !state.isServing { EmptyScreen(t: t, dragging: isDropTargeted) } else if !state.hasNetwork { NoNetworkScreen(t: t) @@ -320,6 +320,10 @@ private struct EmptyScreen: View { showText = true } .padding(.top, 12) + // 收件箱关时仍可能有上次留存的收到文本(记住收到的文本开过):在空态也露出,免得「消失」。 + if !state.receivedTexts.isEmpty { + ReceivedTextsCard(t: t).padding(.top, 12) + } if state.showRecents { RecentSharesView(t: t, lang: state.lang, items: state.recents.filter { $0.exists }, onAll: { state.openHistory() }, onReshare: { state.reshare($0) }, @@ -379,6 +383,8 @@ private struct ShareScreen: View { // 文本与文件共存:在票据下补一张「附带文本」小卡(预览 + 编辑)。纯文本分享则文本就是票据本身。 if state.hasText && !state.isTextOnly { attachedTextCard } if !state.received.isEmpty { receivedCard } + // 收件箱:收到任何文本即展示;只收模式下即便为空也展示(等待提示)。 + if !state.receivedTexts.isEmpty || state.isReceiveOnly { ReceivedTextsCard(t: t) } actions if state.interfaces.count > 1 { interfacePicker } if state.sharedIsFile && state.showRecents { @@ -393,7 +399,8 @@ private struct ShareScreen: View { private func ticket(_ ps: PermSummary) -> some View { TicketCard(t: t) { - if state.isTextOnly { AnyView(textStub(ps)) } + if state.isReceiveOnly { AnyView(inboxStub()) } + else if state.isTextOnly { AnyView(textStub(ps)) } else if state.isMultiple { AnyView(multipleStub(ps)) } else if state.sharedIsFile { AnyView(fileStub(ps)) } else { AnyView(folderStub(ps)) } @@ -402,6 +409,25 @@ private struct ShareScreen: View { } } + // 只收文本(无任何分享内容)的存根:收件箱图标 + 「正在接收文本」+ 已收条数 / 等待提示。 + private func inboxStub() -> some View { + let lang = state.lang + let n = state.receivedTexts.count + return HStack(alignment: .top, spacing: 12) { + InboxGlyph(t: t, size: 42) + VStack(alignment: .leading, spacing: 2) { + Text(L.receivingTextKicker(lang)).font(.sans(10.5, .bold)).tracking(0.8).foregroundStyle(t.inkMute) + Text(L.inboxName(lang)).font(.sans(16, .bold)).foregroundStyle(t.ink) + Text(n == 0 ? L.inboxWaiting(lang) : LStr.receivedCount(n, lang)) + .font(.mono(11.5)).foregroundStyle(t.inkMute) + } + Spacer(minLength: 8) + // 与其它票据一致的 ✕:只收模式没有可「清除」的分享,这里用作退出接收(关收件箱 → 回初始)。 + ClearButton(t: t, lang: lang, help: L.stopReceivingHelp(lang)) { state.setTextInboxEnabled(false) } + } + .padding(.horizontal, 18).padding(.vertical, 16) + } + // 纯文本分享的存根:文本图标 + 「正在分享文本」+ 字数 + 前几行预览。 private func textStub(_ ps: PermSummary) -> some View { let lang = state.lang @@ -548,9 +574,10 @@ private struct ShareScreen: View { // 通行区:QR + 说明 + 复制条 private var qrPass: some View { let running = state.isRunning - let caption = state.isTextOnly ? L.scanCaptionText(state.lang) + let caption = state.isReceiveOnly ? L.scanCaptionSend(state.lang) + : (state.isTextOnly ? L.scanCaptionText(state.lang) : (state.isMultiple ? L.scanCaptionMultiple(state.lang) - : (state.sharedIsFile ? L.scanCaptionFile(state.lang) : L.scanCaptionFolder(state.lang))) + : (state.sharedIsFile ? L.scanCaptionFile(state.lang) : L.scanCaptionFolder(state.lang)))) return VStack(spacing: 0) { QRCard(image: state.qrImage, size: 172, dimmed: !running).padding(.top, 22) Text(running ? caption : L.broadcastStopped(state.lang)).font(.sans(13, .semibold)).foregroundStyle(t.ink).padding(.top, 14) @@ -595,8 +622,11 @@ private struct ShareScreen: View { @ViewBuilder private var actions: some View { if state.isRunning { HStack(spacing: 10) { - // 纯文本分享:主操作是「编辑文本」而非更换文件。 - if state.isTextOnly { + // 纯文本分享:主操作是「编辑文本」而非更换文件。只收模式:可顺手挑些文件/文件夹来分享。 + if state.isReceiveOnly { + GhostButton(t: t, title: L.pickAnyButton(state.lang), + systemImage: "doc.badge.plus", fullWidth: true) { state.pickAny() } + } else if state.isTextOnly { GhostButton(t: t, title: L.editTextButton(state.lang), systemImage: "pencil", fullWidth: true) { editText() } } else { @@ -608,7 +638,10 @@ private struct ShareScreen: View { } else { HStack(spacing: 10) { PrimaryButton(t: t, title: L.rebroadcast(state.lang), systemImage: "play.fill", fullWidth: true) { state.start() } - GhostButton(t: t, title: L.clear(state.lang)) { state.clearShare() } + // 只收模式没有可「清除」的分享内容(关收件箱去设置页),故不出清除钮。 + if !state.isReceiveOnly { + GhostButton(t: t, title: L.clear(state.lang)) { state.clearShare() } + } } } } @@ -703,6 +736,109 @@ private struct ReceivedRow: View { } } +// 收件箱卡片(收文本 v2):手机投递来的文本,新→旧。每条带来源(设备名 / IP)+ 收到时长 + 正文预览, +// 单条复制 / 删除,整卡清空(二次确认)。复用「新收到」卡片视觉语言。未读角标随到达累加,进入本卡即清。 +private struct ReceivedTextsCard: View { + let t: Theme + @EnvironmentObject var state: AppState + @State private var confirmClear = false + var body: some View { + let lang = state.lang + let items = state.receivedTexts + return VStack(alignment: .leading, spacing: 0) { + HStack(spacing: 6) { + Circle().fill(t.accent).frame(width: 6, height: 6) + Text(L.receivedTextsTitle(lang)).font(.sans(11, .bold)).tracking(0.8).foregroundStyle(t.inkMute) + if state.unreadReceived > 0 { + Text(LStr.unreadCount(state.unreadReceived, lang)).font(.sans(10, .bold)).foregroundStyle(.white) + .padding(.horizontal, 6).padding(.vertical, 1) + .background(Capsule().fill(t.accent)) + } + Spacer() + if !items.isEmpty { + Button { confirmClear = true } label: { + Text(L.clearAll(lang)).font(.sans(11)).foregroundStyle(t.inkMute) + } + .buttonStyle(.plain) + .confirmationDialog(L.clearReceivedConfirm(lang), isPresented: $confirmClear, titleVisibility: .visible) { + Button(L.clearAll(lang), role: .destructive) { state.clearReceivedTexts() } + Button(L.cancel(lang), role: .cancel) {} + } + } + } + .padding(.horizontal, 16).padding(.top, 12).padding(.bottom, 5) + + if items.isEmpty { + // 只收模式空收件箱:一句等待提示,避免空卡突兀。 + Text(L.inboxWaiting(lang)).font(.sans(12)).foregroundStyle(t.inkFaint) + .padding(.horizontal, 16).padding(.bottom, 12) + } else { + ForEach(Array(items.prefix(12))) { rt in + ReceivedTextRow(t: t, lang: lang, item: rt, + onCopy: { state.copyReceivedText(rt) }, + onDelete: { state.deleteReceivedText(rt) }) + } + if items.count > 12 { + Text(LStr.receivedCount(items.count, lang)).font(.mono(11)).foregroundStyle(t.inkFaint) + .frame(maxWidth: .infinity, alignment: .trailing) + .padding(.horizontal, 16).padding(.top, 4) + } + } + } + .padding(.bottom, 8) + .background(RoundedRectangle(cornerRadius: 14, style: .continuous).fill(t.surface)) + .overlay(RoundedRectangle(cornerRadius: 14, style: .continuous).strokeBorder(t.line, lineWidth: 1)) + .onAppear { state.markReceivedRead() } // 看到收件箱即视作已读 + } +} + +// 收件箱单行:左小图标 + 来源/时长 + 正文预览(最多 3 行,可选中),右侧复制(成功闪 ✓)+ 删除。 +private struct ReceivedTextRow: View { + let t: Theme + let lang: Lang + let item: ReceivedText + let onCopy: () -> Void + let onDelete: () -> Void + @State private var hover = false + @State private var copied = false + var body: some View { + HStack(alignment: .top, spacing: 9) { + TextGlyph(t: t, size: 26) + VStack(alignment: .leading, spacing: 2) { + HStack(spacing: 6) { + Text(item.source).font(.sans(11.5, .semibold)).foregroundStyle(t.inkMute) + .lineLimit(1).truncationMode(.middle) + Text("·").font(.sans(10)).foregroundStyle(t.inkFaint) + Text(LStr.elapsed(item.date, lang)).font(.mono(10.5)).foregroundStyle(t.inkFaint).fixedSize() + Spacer(minLength: 0) + } + Text(item.text).font(.mono(11.5)).foregroundStyle(t.ink) + .lineLimit(3).truncationMode(.tail) + .frame(maxWidth: .infinity, alignment: .leading) + .textSelection(.enabled) + } + VStack(spacing: 2) { + Button { + onCopy() + copied = true + DispatchQueue.main.asyncAfter(deadline: .now() + 1.3) { copied = false } + } label: { + Image(systemName: copied ? "checkmark" : "doc.on.doc") + .font(.system(size: 11, weight: .medium)) + .foregroundStyle(copied ? t.ok : (hover ? t.ink : t.inkFaint)) + .frame(width: 26, height: 26) + .background(Circle().fill(hover && !copied ? t.surfaceAlt : .clear)) + .contentShape(Circle()) + } + .buttonStyle(.plain).help(L.copyTextAction(lang)) + ClearButton(t: t, lang: lang, help: L.deleteEntry(lang)) { onDelete() } + } + } + .padding(.horizontal, 16).padding(.vertical, 7) + .onHover { hover = $0 } + } +} + // 在线访客明细弹窗:列出全部活跃访客(设备名优先,查不到显示完整 IP),最近活跃在前。 // 仅分享者本机可见——网页端永不外泄身份(见 FileServer.activeViewers)。 private struct ViewerListPopover: View { @@ -894,6 +1030,11 @@ private struct SettingsScreen: View { 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) { + state.setTextInboxEnabled(!state.textInboxEnabled) + } HStack(alignment: .top, spacing: 8) { Image(systemName: "info.circle").font(.system(size: 14)).foregroundStyle(t.accent).padding(.top, 1) @@ -938,6 +1079,9 @@ private struct SettingsScreen: View { settingRow(top: true, title: L.rememberTextTitle(lang), desc: L.rememberTextDesc(lang)) { ToggleSwitch(t: t, isOn: state.persistText) { state.setPersistText(!state.persistText) } } + settingRow(top: true, title: L.persistRecvTitle(lang), desc: L.persistRecvDesc(lang)) { + ToggleSwitch(t: t, isOn: state.persistReceivedText) { state.setPersistReceivedText(!state.persistReceivedText) } + } settingRow(top: true, title: L.resetWindowTitle(lang)) { GhostButton(t: t, title: L.resetDefault(lang), systemImage: "arrow.counterclockwise") { state.resetWindowSize() From 51068993c9956027765b62f096311ac9bad6981e Mon Sep 17 00:00:00 2001 From: shawn Date: Fri, 26 Jun 2026 11:16:24 +0800 Subject: [PATCH 04/17] =?UTF-8?q?test:=20=E6=94=B6=E6=96=87=E6=9C=AC?= =?UTF-8?q?=E7=AB=AF=E5=88=B0=E7=AB=AF=E5=86=92=E7=83=9F=20+=20=E5=8D=95?= =?UTF-8?q?=E6=B5=8B=20+=20CI?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - tools/smoke-text-receive.sh(16 项):只收模式 URL 直指 /ls/send、302 清洗、 原文逐字回读(UTF-8 + < 不被破坏)、无 token 403 / 空白 400 / 超 64KB 413、 闸门关时 404/403 且列表无表单、文件夹+收件箱列表内嵌表单。 - TextShareTests:ReceivedText 来源回退、Codable 往返、id 唯一。 - ci.yml:接入收文本冒烟。 --- .github/workflows/ci.yml | 3 + Tests/LocalShareTests/TextShareTests.swift | 31 ++++++ tools/smoke-text-receive.sh | 111 +++++++++++++++++++++ 3 files changed, 145 insertions(+) create mode 100755 tools/smoke-text-receive.sh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 055978a..caa82e4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -53,3 +53,6 @@ jobs: - name: 传递文本(/ls/text + 转义 + 虚拟根文本行) run: ./tools/smoke-text.sh + + - name: 收文本(/ls/send + POST /ls/text 双限 + 闸门 + 列表内嵌表单) + run: ./tools/smoke-text-receive.sh diff --git a/Tests/LocalShareTests/TextShareTests.swift b/Tests/LocalShareTests/TextShareTests.swift index 8676ace..e9bbd80 100644 --- a/Tests/LocalShareTests/TextShareTests.swift +++ b/Tests/LocalShareTests/TextShareTests.swift @@ -83,4 +83,35 @@ final class TextShareTests: XCTestCase { XCTAssertFalse(back.isText) XCTAssertEqual(back.paths, ["/a/b.txt"]) } + + // MARK: ReceivedText —— 收件箱条目(收文本 v2) + + // 来源标签:反查到设备名显名,查不到回退完整 IP(同 ViewerInfo.fullLabel 口径)。 + func testReceivedTextSourcePrefersName() { + let named = ReceivedText(text: "hi", ip: "192.168.1.9", name: "Shawn-iPhone", date: Date()) + XCTAssertEqual(named.source, "Shawn-iPhone") + let anon = ReceivedText(text: "hi", ip: "192.168.1.9", name: "", date: Date()) + XCTAssertEqual(anon.source, "192.168.1.9") + } + + // 持久化「记住收到的文本」的 Codable 往返:正文/来源/时间/id 全保真(含 UTF-8 与 < 不被破坏)。 + func testReceivedTextCodableRoundTrip() throws { + let rt = ReceivedText(text: "café 你好 ", ip: "10.0.0.2", name: "phone", + date: Date(timeIntervalSince1970: 1700)) + let data = try JSONEncoder().encode([rt]) + let back = try JSONDecoder().decode([ReceivedText].self, from: data) + XCTAssertEqual(back.count, 1) + XCTAssertEqual(back[0].text, "café 你好 ") + XCTAssertEqual(back[0].ip, "10.0.0.2") + XCTAssertEqual(back[0].name, "phone") + XCTAssertEqual(back[0].id, rt.id) // id 随条目稳定保存 + XCTAssertEqual(back[0], rt) // Equatable 整体一致 + } + + // 不同条目身份各异(id 唯一),删除按 id 移除不会误伤同文本的另一条。 + func testReceivedTextDistinctIdentity() { + let a = ReceivedText(text: "same", ip: "1.1.1.1", name: "", date: Date()) + let b = ReceivedText(text: "same", ip: "1.1.1.1", name: "", date: Date()) + XCTAssertNotEqual(a.id, b.id) + } } diff --git a/tools/smoke-text-receive.sh b/tools/smoke-text-receive.sh new file mode 100755 index 0000000..7cd2da8 --- /dev/null +++ b/tools/smoke-text-receive.sh @@ -0,0 +1,111 @@ +#!/usr/bin/env bash +# 防回归冒烟测:收文本(手机→Mac,v2)。覆盖只收模式、闸门关、以及与文件夹分享共存三形态: +# · 只收模式 URL 直指 /ls/send;GET /ls/send 出发送表单(textarea + 发送钮),首访 302 清洗 token; +# · POST /ls/text 收原文(text/plain),LOG 回读逐字一致(含 UTF-8 与 < 不被破坏); +# · 无 token → 403;空白 → 400;超 64KB → 413; +# · 闸门关(仅文件夹)时 /ls/send 404、POST /ls/text 403、列表页不出发送表单; +# · 文件夹 + 开收件箱:列表页内嵌发送表单(与上传表单同条件)。 +# 用法: ./tools/smoke-text-receive.sh 退出码 0=全过。 +set -u +BIN="${BIN:-.build/debug/LocalShare}" +[ -x "$BIN" ] || { echo "找不到 $BIN,先跑 swift build"; exit 2; } + +PASS=0; FAIL=0 +ok(){ echo " ✅ $1"; PASS=$((PASS+1)); } +bad(){ echo " ❌ $1"; FAIL=$((FAIL+1)); } + +wait_up(){ # $1=port $2=path + for _ in $(seq 1 50); do + [ "$(curl -s -o /dev/null -w '%{http_code}' "http://127.0.0.1:$1$2?t=$TOK")" != "000" ] && return 0 + sleep 0.1 + done +} + +TOK="tokrecv" + +# ── 1. 只收文本模式(无任何分享内容)────────────────────────── +PORT=$(( (RANDOM % 10000) + 41000 )) +LOG="$(mktemp)" +LS_HEADLESS=1 LS_RECV=1 LS_RECV_LOG="$LOG" LS_TOKEN="$TOK" LS_PORT="$PORT" "$BIN" >/tmp/ls_recv_url.$$ 2>&1 & +SRV=$! +trap 'kill $SRV 2>/dev/null; wait $SRV 2>/dev/null; rm -f /tmp/ls_recv_url.$$ "$LOG"' EXIT +wait_up "$PORT" "/ls/send" +base="http://127.0.0.1:$PORT" + +echo "── 只收模式:headless URL 直指 /ls/send" +grep -q "LS_URL .*/ls/send?t=$TOK" /tmp/ls_recv_url.$$ && ok "URL 指向 /ls/send" || bad "URL 未指向 /ls/send:$(cat /tmp/ls_recv_url.$$)" + +echo "── 导航(Accept text/html)首访 302 清洗 token" +S=$(curl -s -o /dev/null -w '%{http_code}' -H 'Accept: text/html' "$base/ls/send?t=$TOK") +[ "$S" = "302" ] && ok "302 清洗" || bad "应 302 实为 $S" + +echo "── GET /ls/send(cookie)出发送表单" +P=$(curl -s -H 'Accept: text/html' -b "ls_token=$TOK" "$base/ls/send") +echo "$P" | grep -q 'id="sendta"' && ok "含输入框" || bad "缺输入框 textarea" +echo "$P" | grep -q 'id="sendbtn"' && ok "含发送按钮" || bad "缺发送按钮" +echo "$P" | grep -q "/ls/text" && ok "脚本投递到 /ls/text" || bad "未见 /ls/text 投递" + +echo "── POST /ls/text 收原文 + LOG 逐字回读(含 UTF-8 与 <)" +MSG=$'Hello 你好 x\n第二行 https://example.com' +S=$(curl -s -o /dev/null -w '%{http_code}' -X POST --data-binary "$MSG" -H 'Content-Type: text/plain' -b "ls_token=$TOK" "$base/ls/text") +[ "$S" = "200" ] && ok "投递 200" || bad "应 200 实为 $S" +# LOG 以 0x01 分隔;取首条比对 +GOT=$(printf '%s' "$(cat "$LOG")" | tr -d '\1') +[ "$GOT" = "$MSG" ] && ok "原文逐字一致" || bad "原文不一致:[$GOT]" + +echo "── 无 token → 403" +S=$(curl -s -o /dev/null -w '%{http_code}' -X POST --data-binary 'nope' "$base/ls/text") +[ "$S" = "403" ] && ok "无 token 403" || bad "应 403 实为 $S" + +echo "── 空白文本 → 400" +S=$(curl -s -o /dev/null -w '%{http_code}' -X POST --data-binary ' ' -H 'Content-Type: text/plain' -b "ls_token=$TOK" "$base/ls/text") +[ "$S" = "400" ] && ok "空白 400" || bad "应 400 实为 $S" + +echo "── 超 64KB → 413" +head -c 70000 /dev/zero | tr '\0' 'x' > /tmp/ls_big.$$ +S=$(curl -s -o /dev/null -w '%{http_code}' -X POST --data-binary @/tmp/ls_big.$$ -H 'Content-Type: text/plain' -b "ls_token=$TOK" "$base/ls/text") +[ "$S" = "413" ] && ok "超限 413" || bad "应 413 实为 $S" +rm -f /tmp/ls_big.$$ + +kill $SRV 2>/dev/null; wait $SRV 2>/dev/null + +# ── 2. 闸门关(仅文件夹分享):/ls/send 404、POST 403、列表无表单 ── +PORT=$(( (RANDOM % 10000) + 42000 )) +ROOT="$(mktemp -d)"; echo hi > "$ROOT/a.txt" +LS_HEADLESS=1 LS_FOLDER="$ROOT" LS_TOKEN="$TOK" LS_PORT="$PORT" "$BIN" >/dev/null 2>&1 & +SRV=$! +wait_up "$PORT" "/" +base="http://127.0.0.1:$PORT" + +echo "── 闸门关:/ls/send 404" +S=$(curl -s -o /dev/null -w '%{http_code}' -b "ls_token=$TOK" "$base/ls/send") +[ "$S" = "404" ] && ok "/ls/send 404" || bad "应 404 实为 $S" +echo "── 闸门关:POST /ls/text 403" +S=$(curl -s -o /dev/null -w '%{http_code}' -X POST --data-binary 'x' -b "ls_token=$TOK" "$base/ls/text") +[ "$S" = "403" ] && ok "POST 403" || bad "应 403 实为 $S" +echo "── 闸门关:列表页不出发送表单" +curl -s -b "ls_token=$TOK" "$base/" | grep -q 'id="sendta"' && bad "列表页出现发送表单(不应)" || ok "列表页无发送表单" + +kill $SRV 2>/dev/null; wait $SRV 2>/dev/null; rm -rf "$ROOT" + +# ── 3. 文件夹 + 开收件箱:列表页内嵌发送表单 ────────────────── +PORT=$(( (RANDOM % 10000) + 43000 )) +ROOT="$(mktemp -d)"; echo hi > "$ROOT/a.txt" +LS_HEADLESS=1 LS_FOLDER="$ROOT" LS_RECV=1 LS_TOKEN="$TOK" LS_PORT="$PORT" "$BIN" >/dev/null 2>&1 & +SRV=$! +wait_up "$PORT" "/" +base="http://127.0.0.1:$PORT" + +echo "── 文件夹+收件箱:列表页内嵌发送表单" +L=$(curl -s -b "ls_token=$TOK" "$base/") +echo "$L" | grep -q 'id="sendta"' && ok "列表页含发送表单" || bad "列表页缺发送表单" +echo "$L" | grep -q 'id="sendbtn"' && ok "列表页含发送按钮" || bad "列表页缺发送按钮" +echo "── 文件夹+收件箱:POST /ls/text 仍可投递" +S=$(curl -s -o /dev/null -w '%{http_code}' -X POST --data-binary 'from listing' -H 'Content-Type: text/plain' -b "ls_token=$TOK" "$base/ls/text") +[ "$S" = "200" ] && ok "投递 200" || bad "应 200 实为 $S" + +kill $SRV 2>/dev/null; wait $SRV 2>/dev/null; rm -rf "$ROOT" + +echo +echo "结果:PASS=$PASS FAIL=$FAIL" +[ "$FAIL" -eq 0 ] From 6afb329225346efa298532078fbc12251158963d Mon Sep 17 00:00:00 2001 From: shawn Date: Fri, 26 Jun 2026 11:16:24 +0800 Subject: [PATCH 05/17] =?UTF-8?q?docs:=20PLAN=20=E6=A0=87=E8=AE=B0?= =?UTF-8?q?=E4=BC=A0=E9=80=92=E6=96=87=E6=9C=AC=20v2=20=E8=90=BD=E5=9C=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit §5 进度补 v2 落点说明;§7 标题与 v2 小节改「已落地」;持久化双开关均已落地。 --- PLAN.md | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/PLAN.md b/PLAN.md index f4a6fb1..6907592 100644 --- a/PLAN.md +++ b/PLAN.md @@ -242,7 +242,14 @@ open dist/LocalShare.app # 本机自测 文本」默认关(开则重启回填草稿、**不自动广播**);历史复用 `RecentShare`(扩 `text:`)+ 逐条 ✕ 删除 + 「清空」二次确认。`textContent` 注入 + `<`→`<`(共享 `LStr.jsEscape`)双重防注入;`LS_TEXT` headless 钩子;`tools/smoke-text.sh`(15 项)+ `TextShareTests` 入 CI。设计见 §7「传递文本」。 -- [ ] 传递文本 v2 — 手机→Mac 收文本(实现中):见 §7「传递文本 · v2」。 +- [x] 传递文本 v2 — 手机→Mac 收文本(PR #26):独立收件箱通道,与 share 正交、不落盘、不依赖文件夹分享。 + 闸门 `textInboxEnabled`(设置「允许收文本」,opt-in 默认关,不限分享形态、开了就把服务拉起);保留路径 + `POST /ls/text` 收一段纯文本(请求体即原文)、`GET /ls/send` 出「发文本给电脑」独立页;列表页(同上传 + 表单条件)内嵌同一份发送表单(`SendText` 片段)。**双上限**挡内存:单条 64KB(`textInboxLimit`,事后 + 413)+ 收件箱 100 条挤旧(`AppState.receivedTexts`)。`onReceiveText` socket 线程 hop 回 MainActor 入 + 收件箱卡片(复用「新收到」样式:来源设备名/IP + 时长 + 单条复制/删/清空 + 未读角标);只收模式下二维码 + 直指 `/ls/send`、主界面出收件票据。仅应用内提醒、不发系统通知。设置「记住收到的文本」默认关(对称 v1)。 + `LS_RECV`/`LS_RECV_LOG` headless 钩子;`tools/smoke-text-receive.sh`(16 项)+ `ReceivedText` 单测入 CI。 > 已知坑(已规避并注释):Swifter 1.5.0 的 `HttpParser` 会对请求 path 二次编码,导致 `request.path` > 仍残留一层百分号编码 —— FileServer 落地文件系统前已用 `removingPercentEncoding` 解码,且不影响防穿越。 @@ -268,7 +275,7 @@ curl -s "http://127.0.0.1:8099/?t=testtoken" # 应返回目录列表 (访客上传 v1 已在 0.6 落地,见 §5 进度;当年切范围的理由:上传解决手机照片/文档传到 Mac 这 90% 的诉求,在线编辑在手机浏览器体验差、覆盖丢数据风险大,删除误删风险高,均往后放。) -### 传递文本(v1 已落地,v2 实现中) +### 传递文本(v1 / v2 已落地) 让「选内容→手机扫码」的内容从「磁盘文件」扩到「一段文本」——剪贴板/链接/口令/说明从桌面甩到手机, 以及反向把手机上的文本收回电脑。**两条独立单向通道,不是同步便签**:发出去的 `sharedText` 与收回来的 @@ -295,7 +302,7 @@ curl -s "http://127.0.0.1:8099/?t=testtoken" # 应返回目录列表 | 取原文 | `?raw=1` / curl(Accept `*/*`)拿 `text/plain; charset=utf-8` 原文,导航(`text/html`)给壳页——同 md/json/csv 预览的同 URL 双形态 | | 大小 | **不设上限**:文本是 Mac 端自己粘的、非不可信输入,不必像上传那套;空白文本禁用「分享」按钮 | -#### v2 — 手机 → Mac·收文本(实现中) +#### v2 — 手机 → Mac·收文本(已落地,PR #26;落点见 §5 进度) | 维度 | 决定 | |---|---| @@ -315,8 +322,8 @@ curl -s "http://127.0.0.1:8099/?t=testtoken" # 应返回目录列表 - **Swifter body 预读内存**:上限只能事后拒绝(同上传),故 v2 必须单条+总数双限。 - **token / 302 去 `?t=` / 网卡绑定 / i18n(`L`/`LStr`,文案过表)/ 转义** 全自动沿用现有机制;收到的文本在 SwiftUI `Text` 里显示天然不执行,但若回显进任何**服务页**须转义。 -- v1/v2 各自一个持久化开关(实现时定为**各一个**:v1「记住分享的文本」已落地、v2「持久化收到的文本」 - 待加;两者隐私语义不同——发出去的是自己粘的、收回来的是他人投递的,分开开关更清晰)。 +- v1/v2 各自一个持久化开关(**各一个**:v1「记住分享的文本」、v2「记住收到的文本」均已落地,皆默认关; + 两者隐私语义不同——发出去的是自己粘的、收回来的是他人投递的,分开开关更清晰)。 ### 上传 v1.5:分片上传 From 6e083c21a83e1cc7633f5e24531785d48d9aabc4 Mon Sep 17 00:00:00 2001 From: shawn Date: Fri, 26 Jun 2026 15:15:55 +0800 Subject: [PATCH 06/17] =?UTF-8?q?feat:=20=E4=BC=A0=E9=80=92=E6=96=87?= =?UTF-8?q?=E6=9C=AC=20v2=20=E2=80=94=20=E4=B8=BB=E9=A1=B5=E5=86=85?= =?UTF-8?q?=E8=81=94=E6=8E=A5=E6=94=B6=E5=BC=80=E5=85=B3=E3=80=81=E6=89=8B?= =?UTF-8?q?=E6=9C=BA=E5=8F=91=E9=80=81=E9=A1=B5=E5=B7=B2=E5=8F=91=E9=80=81?= =?UTF-8?q?=E5=8E=86=E5=8F=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 主页「分享文本」旁并排「接收文本」开关,开启即就地长出收件二维码与收件箱,不跳独立页 - 屏幕路由按「是否有分享内容」(isEmpty) 判定,只收模式留在功能选择页、可随时关闭 - 手机发送页记录本地「已发送」历史(localStorage,点一条回填),失败按 403/413/网络细分提示 --- Sources/LocalShare/ContentView.swift | 132 ++++++++++++++++++-------- Sources/LocalShare/Lang.swift | 9 +- Sources/LocalShare/SendTextPage.swift | 50 ++++++++-- 3 files changed, 138 insertions(+), 53 deletions(-) diff --git a/Sources/LocalShare/ContentView.swift b/Sources/LocalShare/ContentView.swift index d2e4b95..62a8797 100644 --- a/Sources/LocalShare/ContentView.swift +++ b/Sources/LocalShare/ContentView.swift @@ -51,7 +51,10 @@ struct ContentView: View { case .history: HistoryScreen(t: t) case .share: - if !state.isServing { + // 路由按「是否有分享内容」而非「是否在服务」:收件箱开着但没分享任何东西时仍是空态—— + // 留在功能选择页(拖拽分享 / 分享文本 / 接收文本 三选一),收件 QR 与收件箱就地长在页内 + //(EmptyScreen 内联),既能随时关掉接收回到初始,也不会被推去 ShareScreen。 + if state.isEmpty { EmptyScreen(t: t, dragging: isDropTargeted) } else if !state.hasNetwork { NoNetworkScreen(t: t) @@ -315,13 +318,23 @@ private struct EmptyScreen: View { } content: { VStack(spacing: 0) { dropZone - // 文本入口:与文件并列的平级第二入口(设计语言上「或,分享一段文本」)。 - GhostButton(t: t, title: L.shareTextButton(state.lang), systemImage: "text.alignleft", fullWidth: true) { - showText = true + // 两个并列的文本动作:分享文本(发给手机)/ 接收文本(手机发来)。后者是随时可切的开关, + // 开了就地长出接收卡,不跳独立页(见下)。 + HStack(spacing: 10) { + GhostButton(t: t, title: L.shareTextButton(state.lang), systemImage: "text.alignleft", fullWidth: true) { + showText = true + } + ReceiveToggleButton(t: t, lang: state.lang, on: state.textInboxEnabled) { + state.setTextInboxEnabled(!state.textInboxEnabled) + } } .padding(.top, 12) - // 收件箱关时仍可能有上次留存的收到文本(记住收到的文本开过):在空态也露出,免得「消失」。 - if !state.receivedTexts.isEmpty { + // 接收开着:就地显示二维码(指向发送页)+ 收件箱,不跳独立页。 + // 关着但有上次留存的收到文本也露出,免得「消失」。 + if state.textInboxEnabled { + ReceiveHomeCard(t: t).padding(.top, 12) + ReceivedTextsCard(t: t).padding(.top, 12) + } else if !state.receivedTexts.isEmpty { ReceivedTextsCard(t: t).padding(.top, 12) } if state.showRecents { @@ -383,8 +396,8 @@ private struct ShareScreen: View { // 文本与文件共存:在票据下补一张「附带文本」小卡(预览 + 编辑)。纯文本分享则文本就是票据本身。 if state.hasText && !state.isTextOnly { attachedTextCard } if !state.received.isEmpty { receivedCard } - // 收件箱:收到任何文本即展示;只收模式下即便为空也展示(等待提示)。 - if !state.receivedTexts.isEmpty || state.isReceiveOnly { ReceivedTextsCard(t: t) } + // 收件箱:开着收文本即展示(空时给等待提示),或留有收到的文本时展示。 + if state.textInboxEnabled || !state.receivedTexts.isEmpty { ReceivedTextsCard(t: t) } actions if state.interfaces.count > 1 { interfacePicker } if state.sharedIsFile && state.showRecents { @@ -399,8 +412,7 @@ private struct ShareScreen: View { private func ticket(_ ps: PermSummary) -> some View { TicketCard(t: t) { - if state.isReceiveOnly { AnyView(inboxStub()) } - else if state.isTextOnly { AnyView(textStub(ps)) } + if state.isTextOnly { AnyView(textStub(ps)) } else if state.isMultiple { AnyView(multipleStub(ps)) } else if state.sharedIsFile { AnyView(fileStub(ps)) } else { AnyView(folderStub(ps)) } @@ -409,25 +421,6 @@ private struct ShareScreen: View { } } - // 只收文本(无任何分享内容)的存根:收件箱图标 + 「正在接收文本」+ 已收条数 / 等待提示。 - private func inboxStub() -> some View { - let lang = state.lang - let n = state.receivedTexts.count - return HStack(alignment: .top, spacing: 12) { - InboxGlyph(t: t, size: 42) - VStack(alignment: .leading, spacing: 2) { - Text(L.receivingTextKicker(lang)).font(.sans(10.5, .bold)).tracking(0.8).foregroundStyle(t.inkMute) - Text(L.inboxName(lang)).font(.sans(16, .bold)).foregroundStyle(t.ink) - Text(n == 0 ? L.inboxWaiting(lang) : LStr.receivedCount(n, lang)) - .font(.mono(11.5)).foregroundStyle(t.inkMute) - } - Spacer(minLength: 8) - // 与其它票据一致的 ✕:只收模式没有可「清除」的分享,这里用作退出接收(关收件箱 → 回初始)。 - ClearButton(t: t, lang: lang, help: L.stopReceivingHelp(lang)) { state.setTextInboxEnabled(false) } - } - .padding(.horizontal, 18).padding(.vertical, 16) - } - // 纯文本分享的存根:文本图标 + 「正在分享文本」+ 字数 + 前几行预览。 private func textStub(_ ps: PermSummary) -> some View { let lang = state.lang @@ -574,10 +567,9 @@ private struct ShareScreen: View { // 通行区:QR + 说明 + 复制条 private var qrPass: some View { let running = state.isRunning - let caption = state.isReceiveOnly ? L.scanCaptionSend(state.lang) - : (state.isTextOnly ? L.scanCaptionText(state.lang) + let caption = state.isTextOnly ? L.scanCaptionText(state.lang) : (state.isMultiple ? L.scanCaptionMultiple(state.lang) - : (state.sharedIsFile ? L.scanCaptionFile(state.lang) : L.scanCaptionFolder(state.lang)))) + : (state.sharedIsFile ? L.scanCaptionFile(state.lang) : L.scanCaptionFolder(state.lang))) return VStack(spacing: 0) { QRCard(image: state.qrImage, size: 172, dimmed: !running).padding(.top, 22) Text(running ? caption : L.broadcastStopped(state.lang)).font(.sans(13, .semibold)).foregroundStyle(t.ink).padding(.top, 14) @@ -622,11 +614,8 @@ private struct ShareScreen: View { @ViewBuilder private var actions: some View { if state.isRunning { HStack(spacing: 10) { - // 纯文本分享:主操作是「编辑文本」而非更换文件。只收模式:可顺手挑些文件/文件夹来分享。 - if state.isReceiveOnly { - GhostButton(t: t, title: L.pickAnyButton(state.lang), - systemImage: "doc.badge.plus", fullWidth: true) { state.pickAny() } - } else if state.isTextOnly { + // 纯文本分享:主操作是「编辑文本」而非更换文件。 + if state.isTextOnly { GhostButton(t: t, title: L.editTextButton(state.lang), systemImage: "pencil", fullWidth: true) { editText() } } else { @@ -638,10 +627,7 @@ private struct ShareScreen: View { } else { HStack(spacing: 10) { PrimaryButton(t: t, title: L.rebroadcast(state.lang), systemImage: "play.fill", fullWidth: true) { state.start() } - // 只收模式没有可「清除」的分享内容(关收件箱去设置页),故不出清除钮。 - if !state.isReceiveOnly { - GhostButton(t: t, title: L.clear(state.lang)) { state.clearShare() } - } + GhostButton(t: t, title: L.clear(state.lang)) { state.clearShare() } } } } @@ -839,6 +825,70 @@ private struct ReceivedTextRow: View { } } +// 「接收文本」开关按钮(空态与文本入口并列):随时可切。开=accent 实底,关=ghost 描边, +// 与并排的「分享文本」幽灵钮等高同语言。点击即 setTextInboxEnabled,不跳页(接收卡就地出现)。 +private struct ReceiveToggleButton: View { + let t: Theme + let lang: Lang + let on: Bool + let action: () -> Void + @State private var hover = false + var body: some View { + Button(action: action) { + HStack(spacing: 6) { + Image(systemName: on ? "tray.and.arrow.down.fill" : "tray.and.arrow.down") + .font(.system(size: 14, weight: .medium)) + Text(L.receiveTextButton(lang)).font(.sans(13, .semibold)) + } + .foregroundStyle(on ? .white : t.ink) + .frame(maxWidth: .infinity).frame(height: 34).padding(.horizontal, 13) + .background(RoundedRectangle(cornerRadius: 10, style: .continuous) + .fill(on ? t.accent : (hover ? t.surfaceAlt : t.surface))) + .overlay(RoundedRectangle(cornerRadius: 10, style: .continuous) + .strokeBorder(on ? .clear : (hover ? t.lineStrong : t.line), lineWidth: 1)) + } + .buttonStyle(.plain).onHover { hover = $0 } + .help(on ? L.stopReceivingHelp(lang) : L.recvInboxDesc(lang)) + } +} + +// 空态的就地接收卡:开了「接收文本」、又没分享别的内容时显示——指向发送页 /ls/send 的二维码 + 地址, +// 让手机扫码就能发文本过来(收件箱另由 ReceivedTextsCard 紧随其下)。无网络时给提示而非空白。 +private struct ReceiveHomeCard: View { + let t: Theme + @EnvironmentObject var state: AppState + var body: some View { + let lang = state.lang + return VStack(spacing: 0) { + HStack(spacing: 6) { + Circle().fill(t.accent).frame(width: 6, height: 6) + Text(L.receivingTextKicker(lang)).font(.sans(11, .bold)).tracking(0.8).foregroundStyle(t.inkMute) + Spacer() + } + .padding(.horizontal, 16).padding(.top, 13) + if state.isRunning, let qr = state.qrImage { + QRCard(image: qr, size: 150).padding(.top, 14) + Text(L.scanCaptionSend(lang)).font(.sans(12.5, .semibold)).foregroundStyle(t.ink).padding(.top, 12) + if let url = state.primaryURL { + CopyPill(t: t, lang: lang, value: url, compact: true, onOpen: { + if let u = URL(string: url) { NSWorkspace.shared.open(u) } + }) + .padding(.top, 10).padding(.horizontal, 16) + } + } else { + VStack(spacing: 8) { + Image(systemName: "wifi.slash").font(.system(size: 26)).foregroundStyle(t.inkFaint) + Text(L.noNetwork(lang)).font(.sans(12.5, .semibold)).foregroundStyle(t.inkMute) + } + .frame(maxWidth: .infinity).padding(.vertical, 26) + } + } + .padding(.bottom, 16) + .background(RoundedRectangle(cornerRadius: 16, style: .continuous).fill(t.surface)) + .overlay(RoundedRectangle(cornerRadius: 16, style: .continuous).strokeBorder(t.line, lineWidth: 1)) + } +} + // 在线访客明细弹窗:列出全部活跃访客(设备名优先,查不到显示完整 IP),最近活跃在前。 // 仅分享者本机可见——网页端永不外泄身份(见 FileServer.activeViewers)。 private struct ViewerListPopover: View { diff --git a/Sources/LocalShare/Lang.swift b/Sources/LocalShare/Lang.swift index e08b306..fb4a587 100644 --- a/Sources/LocalShare/Lang.swift +++ b/Sources/LocalShare/Lang.swift @@ -151,8 +151,8 @@ enum L: CaseIterable { // 传递文本(v2·手机→Mac 收文本) case recvInboxTitle, recvInboxDesc, persistRecvTitle, persistRecvDesc - case receivedTextsTitle, receivingTextKicker, inboxName, inboxWaiting - case scanCaptionSend, clearReceivedConfirm, copyTextAction, stopReceivingHelp + case receivedTextsTitle, receivingTextKicker, inboxWaiting + case scanCaptionSend, clearReceivedConfirm, copyTextAction, stopReceivingHelp, receiveTextButton // —— 网页(由 Swift 直接拼进 HTML 的静态文案)—— case webUpload, webDropHere, webBackToParent, webEmptyFolder @@ -343,12 +343,12 @@ enum L: CaseIterable { "Keeps the inbox after restart; off forgets it on quit") case .receivedTextsTitle: return ("收到的文本", "Received Text") case .receivingTextKicker: return ("正在接收文本", "Receiving text") - case .inboxName: return ("收件箱", "Inbox") case .inboxWaiting: return ("等待手机发来文本…", "Waiting for text from a phone…") case .scanCaptionSend: return ("扫码把文本发到这台 Mac · 同一 Wi-Fi", "Scan to send text to this Mac · same Wi-Fi") case .clearReceivedConfirm: return ("清空收到的全部文本?", "Clear all received text?") case .copyTextAction: return ("复制", "Copy") case .stopReceivingHelp: return ("停止接收文本", "Stop receiving text") + case .receiveTextButton: return ("接收文本", "Receive Text") case .webUpload: return ("上传", "Upload") case .webDropHere: return ("松手上传到这里", "Drop here to upload") @@ -628,6 +628,9 @@ enum LStr { ("sent", "已发送", "Sent"), ("sendFailed", "发送失败", "Send failed"), ("sendOverLimit","超过 64KB 上限", "over 64 KB limit"), + ("sendStale", "链接已失效,请重新扫码", "Link expired — rescan the QR code"), + ("sendNetwork", "网络错误,请重试", "Network error — try again"), + ("sentHistory", "已发送", "Sent"), ("parseFailed", "解析失败", "Parse failed"), ("parsing", "正在解析…", "Parsing…"), // JSON viewer diff --git a/Sources/LocalShare/SendTextPage.swift b/Sources/LocalShare/SendTextPage.swift index e408f29..086a252 100644 --- a/Sources/LocalShare/SendTextPage.swift +++ b/Sources/LocalShare/SendTextPage.swift @@ -25,6 +25,7 @@ enum SendText { \ \(esc(L.webSendButton(lang))) +
""" } @@ -49,20 +50,52 @@ enum SendText { background:var(--accent);color:#fff;transition:filter .15s} .sendbtn:hover{filter:brightness(1.07)} .sendbtn:disabled{opacity:.5;cursor:default} + /* 已发送历史:浏览器本地留存(localStorage),点一条回填到输入框便于改发/重发。 */ + .senthist:empty{display:none} + .senthist{border-top:1px solid var(--line)} + .sh-title{padding:10px 16px 4px;font:600 11px var(--sans);letter-spacing:.04em;color:var(--inkMute)} + .sh-item{padding:8px 16px;font:12px/1.5 var(--mono);color:var(--ink);cursor:pointer; + border-top:1px solid var(--line);white-space:pre-wrap;word-break:break-word; + display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden} + .sh-item:first-of-type{border-top:none} + .sh-item:active{background:var(--surfaceAlt)} /* 手机上输入框字号 <16px 时 iOS 聚焦会自动放大页面,置 16px 杜绝。 */ @media(max-width:560px){.sendta{font-size:16px}} """ // 启动脚本:发送 textarea 内容到 POST /ls/text。鉴权走已种下的 cookie(同源)。超 64KB 前端先拦。 - // 成功清空输入并闪「已发送」;413/失败给对应提示。Cmd/Ctrl+Enter 快捷发送。依赖页面已注入的 LS_I18N。 + // 成功清空输入并闪「已发送」+ 记进本地「已发送」历史(localStorage,点一条回填)。失败按服务端 JSON + // {"error":…} 或状态码给出具体原因(403=链接已失效需重扫、413=超限、网络错误)。Cmd/Ctrl+Enter 快捷发送。 static let boot = """ (function(){ - var ta=document.getElementById('sendta'),btn=document.getElementById('sendbtn'),st=document.getElementById('sendstatus'); + var ta=document.getElementById('sendta'),btn=document.getElementById('sendbtn'), + st=document.getElementById('sendstatus'),hist=document.getElementById('senthist'); if(!btn)return; - var MAX=65536,timer; + var MAX=65536,timer,KEY='ls_sent'; function flash(msg,ok){ st.textContent=msg;st.className='sendstatus '+(ok?'ok':'err'); - clearTimeout(timer);timer=setTimeout(function(){st.textContent='';st.className='sendstatus';},2400); + clearTimeout(timer);timer=setTimeout(function(){st.textContent='';st.className='sendstatus';},3000); + } + function load(){try{return JSON.parse(localStorage.getItem(KEY))||[]}catch(e){return[]}} + function save(a){try{localStorage.setItem(KEY,JSON.stringify(a))}catch(e){}} + function render(){ + if(!hist)return; hist.innerHTML=''; + var a=load(); if(!a.length)return; + var h=document.createElement('div');h.className='sh-title';h.textContent=LS_I18N.sentHistory;hist.appendChild(h); + a.slice(0,20).forEach(function(item){ + var d=document.createElement('div');d.className='sh-item';d.textContent=item; // textContent 注入,安全 + d.addEventListener('click',function(){ta.value=item;ta.focus();}); + hist.appendChild(d); + }); + } + function remember(v){var a=load();a.unshift(v);if(a.length>20)a=a.slice(0,20);save(a);render();} + function fail(r){ + btn.disabled=false; + r.text().then(function(b){ + var msg=''; try{msg=(JSON.parse(b)||{}).error||''}catch(e){} + if(!msg)msg=(r.status===403?LS_I18N.sendStale:(r.status===413?LS_I18N.sendOverLimit:LS_I18N.sendFailed)); + flash(msg,false); + },function(){flash(r.status===403?LS_I18N.sendStale:LS_I18N.sendFailed,false);}); } function send(){ var v=ta.value; @@ -71,14 +104,13 @@ enum SendText { btn.disabled=true; fetch('/ls/text',{method:'POST',headers:{'Content-Type':'text/plain;charset=utf-8'},body:v}) .then(function(r){ - btn.disabled=false; - if(r.ok){ta.value='';flash(LS_I18N.sent,true);ta.focus();} - else if(r.status===413){flash(LS_I18N.sendOverLimit,false);} - else{flash(LS_I18N.sendFailed,false);} - }).catch(function(){btn.disabled=false;flash(LS_I18N.sendFailed,false);}); + if(r.ok){btn.disabled=false;ta.value='';remember(v);flash(LS_I18N.sent,true);ta.focus();} + else fail(r); + },function(){btn.disabled=false;flash(LS_I18N.sendNetwork,false);}); } btn.addEventListener('click',send); ta.addEventListener('keydown',function(e){if((e.metaKey||e.ctrlKey)&&e.key==='Enter'){e.preventDefault();send();}}); + render(); })(); """ From 5118c5a7f8bb5914b21a0907d2fb8428fa01fdbc Mon Sep 17 00:00:00 2001 From: shawn Date: Fri, 26 Jun 2026 16:20:17 +0800 Subject: [PATCH 07/17] =?UTF-8?q?feat:=20=E4=BC=A0=E9=80=92=E6=96=87?= =?UTF-8?q?=E6=9C=AC=E6=94=B6=E5=8F=91=E5=B9=B6=E5=85=A5=E7=8B=AC=E7=AB=8B?= =?UTF-8?q?=E4=BA=8C=E7=BA=A7=E9=A1=B5=EF=BC=8C=E4=B8=BB=E9=A1=B5=E5=9B=9E?= =?UTF-8?q?=E5=BD=92=E5=8A=9F=E8=83=BD=E9=80=89=E6=8B=A9=E9=A1=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 把「分享文本 / 接收文本」并进一个二级页 TextScreen(Screen.text):上半编辑器发文本、 中间一个二维码、下半「允许收文本」开关 + 收件箱。主页 EmptyScreen 收敛回纯功能选择(拖拽 分享 / 一个「传递文本」入口,带未读角标与在线指示),不再就地长接收卡。 网页侧 GET /ls/text 改为恒可渲染:有共享文本发预览壳页(开着接收即自带发送框, PreviewPage.canReceiveText),无文本但开着接收则退化成纯发送页——一页一码、两端双向。 二维码(makeURL)与 headless URL 一律指 /ls/text;旧 /ls/send 保留为 302 跳 /ls/text 兼容。 删去 ReceiveHomeCard / ReceiveToggleButton / InboxGlyph 与随之失效的文案键。 smoke-text-receive.sh 改测 /ls/text 退化页 + /ls/send 302。 --- PLAN.md | 22 ++- Sources/LocalShare/AppState.swift | 19 ++- Sources/LocalShare/Components.swift | 12 -- Sources/LocalShare/ContentView.swift | 201 ++++++++++++++++-------- Sources/LocalShare/FileServer.swift | 47 +++--- Sources/LocalShare/HeadlessServer.swift | 7 +- Sources/LocalShare/Lang.swift | 15 +- tools/smoke-text-receive.sh | 31 ++-- 8 files changed, 220 insertions(+), 134 deletions(-) diff --git a/PLAN.md b/PLAN.md index 6907592..c1d6ae5 100644 --- a/PLAN.md +++ b/PLAN.md @@ -244,12 +244,18 @@ open dist/LocalShare.app # 本机自测 headless 钩子;`tools/smoke-text.sh`(15 项)+ `TextShareTests` 入 CI。设计见 §7「传递文本」。 - [x] 传递文本 v2 — 手机→Mac 收文本(PR #26):独立收件箱通道,与 share 正交、不落盘、不依赖文件夹分享。 闸门 `textInboxEnabled`(设置「允许收文本」,opt-in 默认关,不限分享形态、开了就把服务拉起);保留路径 - `POST /ls/text` 收一段纯文本(请求体即原文)、`GET /ls/send` 出「发文本给电脑」独立页;列表页(同上传 - 表单条件)内嵌同一份发送表单(`SendText` 片段)。**双上限**挡内存:单条 64KB(`textInboxLimit`,事后 - 413)+ 收件箱 100 条挤旧(`AppState.receivedTexts`)。`onReceiveText` socket 线程 hop 回 MainActor 入 - 收件箱卡片(复用「新收到」样式:来源设备名/IP + 时长 + 单条复制/删/清空 + 未读角标);只收模式下二维码 - 直指 `/ls/send`、主界面出收件票据。仅应用内提醒、不发系统通知。设置「记住收到的文本」默认关(对称 v1)。 - `LS_RECV`/`LS_RECV_LOG` headless 钩子;`tools/smoke-text-receive.sh`(16 项)+ `ReceivedText` 单测入 CI。 + `POST /ls/text` 收一段纯文本(请求体即原文);列表页(同上传表单条件)内嵌发送表单(`SendText` 片段)。 + **双上限**挡内存:单条 64KB(`textInboxLimit`,事后 413)+ 收件箱 100 条挤旧(`AppState.receivedTexts`)。 + `onReceiveText` socket 线程 hop 回 MainActor 入收件箱卡片(复用「新收到」样式:来源设备名/IP + 时长 + + 单条复制/删/清空 + 未读角标)。仅应用内提醒、不发系统通知。设置「记住收到的文本」默认关(对称 v1)。 + `LS_RECV`/`LS_RECV_LOG` headless 钩子;`tools/smoke-text-receive.sh` + `ReceivedText` 单测入 CI。 +- [x] 传递文本 v2.1 — 收发合一、主页回归选择页(PR #26):把「分享文本 / 接收文本」并进**一个二级页** + `TextScreen`(`Screen.text`),上半编辑器发文本、中间一个二维码、下半「允许收文本」开关 + 收件箱; + 主页 `EmptyScreen` 收敛回纯功能选择(拖拽分享 / 传递文本),不再就地长接收卡。网页侧 `GET /ls/text` + **恒可渲染**:有共享文本发预览壳页(开着接收即自带发送框,`PreviewPage.canReceiveText`),无文本但开着 + 接收则退化成纯发送页——**一页一码、两端双向**。二维码(`makeURL`)与 headless URL 一律指 `/ls/text`; + 旧 `/ls/send` 保留为 302 跳 `/ls/text` 兼容。「允许收文本」默认关,闸门仍是 `textInboxEnabled`(设置页与 + 文本页同一开关)。`smoke-text-receive.sh` 改测 `/ls/text` 退化页 + `/ls/send` 302。 > 已知坑(已规避并注释):Swifter 1.5.0 的 `HttpParser` 会对请求 path 二次编码,导致 `request.path` > 仍残留一层百分号编码 —— FileServer 落地文件系统前已用 `removingPercentEncoding` 解码,且不影响防穿越。 @@ -309,10 +315,10 @@ curl -s "http://127.0.0.1:8099/?t=testtoken" # 应返回目录列表 | 本质 | **独立收件箱通道**,不落盘、不依赖文件夹分享,与 v1 对称(都在 app 里以文本形态存在) | | 收件箱 | 列表,每条带时间 + 来源(复用现成 `nameCache`/`getnameinfo` 反查设备名,查不到显 IP),单条复制/删除/清空 | | 持久化 | 设置项「持久化收到的文本」**默认关**(对称 v1;收到的常更敏感/更像垃圾,默认易逝更稳) | -| 闸门 | opt-in 默认关(参照 `uploadEnabled`),但**不限分享形态**——收文本不依赖落点,任意模式甚至「什么都没分享」都能开,开了就把服务拉起并出一张指向发送页的 QR | +| 闸门 | opt-in 默认关(参照 `uploadEnabled`),但**不限分享形态**——收文本不依赖落点,任意模式甚至「什么都没分享」都能开,开了就把服务拉起并出一张指向发送页的 QR(v2.1 起这张 QR 与发文本合并为一页一码、统一指 `/ls/text`,见 §5) | | 提醒 | **仅应用内**:复用 `onUpload` 的「新收到」卡片机制(socket 线程 hop 回 MainActor)+ 收件箱未读角标。不发系统通知(macOS 通知要授权,且未公证 ad-hoc 签名下可靠性存疑,往后放) | | 防滥用 | **双上限**:单条文本 ~64KB(远小于上传 500MB)+ 收件箱 ~100 条满了挤掉最旧。不做速率限制(Swifter 不便做、opt-in+LAN 信任足够)。**必须双限**:Swifter 进 middleware 前已把整段 body 读进内存,光限单条挡不住「刷一堆刚好不超限的消息撑爆内存」 | -| 手机端 | 「发文本给电脑」textarea + 发送,放在 listing 页(及独立收文本页)**与现有上传表单同处、同样的出现条件**(收件箱关时不出现) | +| 手机端 | 「发文本给电脑」textarea + 发送,放在 listing 页(及 `/ls/text` 无共享文本时退化出的发送页)**与现有上传表单同处、同样的出现条件**(收件箱关时不出现) | #### 贯穿约束(实现时必须守) diff --git a/Sources/LocalShare/AppState.swift b/Sources/LocalShare/AppState.swift index 3137eea..a1ba7c3 100644 --- a/Sources/LocalShare/AppState.swift +++ b/Sources/LocalShare/AppState.swift @@ -6,7 +6,7 @@ import SwiftUI // 负责:选目录/选文件、启停服务、端口配置 + 应用(重启服务)、记住上次分享并自动启动。 @MainActor final class AppState: ObservableObject { - enum Screen { case share, settings, history } + enum Screen { case share, text, settings, history } enum AppearancePref: String { case system, light, dark } // 设计默认窗口尺寸(票据风竖窗,设计稿 400×720)。供 App 的 .defaultSize 与 @@ -105,6 +105,8 @@ final class AppState: ObservableObject { } // 有任何理由起服务(分享内容或收件箱开着)即自动启动。 if isServing { start() } + // 启动落地屏:有文件→分享页(默认);无文件但收件箱开着→传递文本页(接收已就绪一眼可见)。 + if sharedItems.isEmpty && textInboxEnabled { screen = .text } AppState.shared = self // 消费早到的 open 事件(CLI 冷启动时可能先于本 init 到达),覆盖上面恢复的旧分享。 if !AppDelegate.pendingOpenURLs.isEmpty { @@ -133,10 +135,9 @@ final class AppState: ObservableObject { // 文件夹/多选模式 → 根地址;单文件模式 → 直链该文件(路径仅供浏览器显示文件名/扩展名)。 private func makeURL(host: String) -> String { let q = "?t=\(token)" - // 纯文本分享:二维码直指 /ls/text(扫码即落文本页,等同单文件直链)。 - if isTextOnly { return "http://\(host):\(port)/ls/text\(q)" } - // 只收文本:二维码直指发送页 /ls/send(扫码即落「发文本给电脑」表单)。 - if isReceiveOnly { return "http://\(host):\(port)/ls/send\(q)" } + // 传递文本(收/发合一):二维码恒指 /ls/text——这一页既显示电脑共享的文本(可读可复制), + // 又在「允许手机发回来」开着时挂出发送框;只收文本时它退化成纯发送页。扫一次,双向都在这。 + if isTextOnly || isReceiveOnly { return "http://\(host):\(port)/ls/text\(q)" } // 单文件直链该文件(文本与文件共存时走虚拟根,不直链,故附带 !hasText)。 if sharedIsFile, !hasText, let name = sharedItems.first?.lastPathComponent, let enc = name.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) { @@ -231,11 +232,12 @@ final class AppState: ObservableObject { // 仅纯文本分享记历史(文本条目,且仅 persistText 开时真正落库,见 recordRecent); // 文本+文件时不调——否则「只改了文本」会把已有的文件条目刷到历史顶部、刷新时间戳。 if isTextOnly { recordRecent() } - screen = .share + // 纯文本场景(无文件)的文本动作进/留传递文本页;与文件共存时不抢走文件票据(仍在分享页就地编辑)。 + if sharedItems.isEmpty && (hasText || textInboxEnabled) { screen = .text } if isRunning { - if isEmpty { stop() } // 撤下文本且无文件 → 拆掉服务,回到初始 + if isEmpty && !textInboxEnabled { stop() } // 撤下文本、无文件、也没开接收 → 拆服务回初始 else { pushToServer() } - } else if !isEmpty { + } else if isServing { start() } } @@ -584,6 +586,7 @@ final class AppState: ObservableObject { func openSettings() { screen = .settings } func openHistory() { screen = .history } func goShare() { screen = .share } + func openText() { screen = .text } // 进传递文本二级页(收/发合一) func setAppearance(_ a: AppearancePref) { appearance = a diff --git a/Sources/LocalShare/Components.swift b/Sources/LocalShare/Components.swift index e64d41b..11f6fd9 100644 --- a/Sources/LocalShare/Components.swift +++ b/Sources/LocalShare/Components.swift @@ -248,18 +248,6 @@ struct MultiGlyph: View { } } -// 收件箱图标:圆角方块 + accentSoft 底 + accent 收件托盘形,表示「正在接收手机发来的文本」。 -struct InboxGlyph: View { - let t: Theme - var size: CGFloat = 40 - var body: some View { - RoundedRectangle(cornerRadius: size * 0.28, style: .continuous) - .fill(t.accentSoft) - .frame(width: size, height: size) - .overlay(Image(systemName: "tray.and.arrow.down.fill").font(.system(size: size * 0.4)).foregroundStyle(t.accent)) - } -} - // 文件类型图标:圆角方块 + 类型底色 + 类型 SF Symbol + 小写扩展名(mono)。 struct TypeGlyph: View { let t: Theme diff --git a/Sources/LocalShare/ContentView.swift b/Sources/LocalShare/ContentView.swift index 62a8797..af00c57 100644 --- a/Sources/LocalShare/ContentView.swift +++ b/Sources/LocalShare/ContentView.swift @@ -51,16 +51,17 @@ struct ContentView: View { case .history: HistoryScreen(t: t) case .share: - // 路由按「是否有分享内容」而非「是否在服务」:收件箱开着但没分享任何东西时仍是空态—— - // 留在功能选择页(拖拽分享 / 分享文本 / 接收文本 三选一),收件 QR 与收件箱就地长在页内 - //(EmptyScreen 内联),既能随时关掉接收回到初始,也不会被推去 ShareScreen。 - if state.isEmpty { + // 分享屏只管文件:没有文件时即功能选择页(拖拽分享 / 传递文本),有文件才出票据。 + // 文本收发是独立的二级页(.text),不再挤进这里。 + if state.sharedItems.isEmpty { EmptyScreen(t: t, dragging: isDropTargeted) } else if !state.hasNetwork { NoNetworkScreen(t: t) } else { ShareScreen(t: t) } + case .text: + TextScreen(t: t) } } @@ -302,7 +303,6 @@ private struct EmptyScreen: View { let t: Theme var dragging: Bool @EnvironmentObject var state: AppState - @State private var showText = false var body: some View { let ps = permSummary(state.permission, state.lang) ScreenFrame(t: t) { @@ -318,25 +318,11 @@ private struct EmptyScreen: View { } content: { VStack(spacing: 0) { dropZone - // 两个并列的文本动作:分享文本(发给手机)/ 接收文本(手机发来)。后者是随时可切的开关, - // 开了就地长出接收卡,不跳独立页(见下)。 - HStack(spacing: 10) { - GhostButton(t: t, title: L.shareTextButton(state.lang), systemImage: "text.alignleft", fullWidth: true) { - showText = true - } - ReceiveToggleButton(t: t, lang: state.lang, on: state.textInboxEnabled) { - state.setTextInboxEnabled(!state.textInboxEnabled) - } - } - .padding(.top, 12) - // 接收开着:就地显示二维码(指向发送页)+ 收件箱,不跳独立页。 - // 关着但有上次留存的收到文本也露出,免得「消失」。 - if state.textInboxEnabled { - ReceiveHomeCard(t: t).padding(.top, 12) - ReceivedTextsCard(t: t).padding(.top, 12) - } else if !state.receivedTexts.isEmpty { - ReceivedTextsCard(t: t).padding(.top, 12) - } + // 平级第二入口:传递文本(收/发合一)。点进独立二级页,主页只负责选功能、不就地干活。 + // 收件箱里有未读时角标提示,免得收到的文本在主页「消失」。 + TransferTextButton(t: t, lang: state.lang, active: state.textInboxEnabled, + unread: state.unreadReceived) { state.openText() } + .padding(.top, 12) if state.showRecents { RecentSharesView(t: t, lang: state.lang, items: state.recents.filter { $0.exists }, onAll: { state.openHistory() }, onReshare: { state.reshare($0) }, @@ -344,7 +330,6 @@ private struct EmptyScreen: View { } } } - .sheet(isPresented: $showText) { TextEntrySheet(t: t, initial: state.textDraft, isUpdate: false) } } private var dropZone: some View { @@ -396,8 +381,6 @@ private struct ShareScreen: View { // 文本与文件共存:在票据下补一张「附带文本」小卡(预览 + 编辑)。纯文本分享则文本就是票据本身。 if state.hasText && !state.isTextOnly { attachedTextCard } if !state.received.isEmpty { receivedCard } - // 收件箱:开着收文本即展示(空时给等待提示),或留有收到的文本时展示。 - if state.textInboxEnabled || !state.receivedTexts.isEmpty { ReceivedTextsCard(t: t) } actions if state.interfaces.count > 1 { interfacePicker } if state.sharedIsFile && state.showRecents { @@ -825,68 +808,158 @@ private struct ReceivedTextRow: View { } } -// 「接收文本」开关按钮(空态与文本入口并列):随时可切。开=accent 实底,关=ghost 描边, -// 与并排的「分享文本」幽灵钮等高同语言。点击即 setTextInboxEnabled,不跳页(接收卡就地出现)。 -private struct ReceiveToggleButton: View { +// 主页「传递文本」入口:平级第二功能,点进 .text 二级页(收/发合一)。收件箱有未读时角标提示、 +// 接收开着时缀一个绿点——让「正在传递」与「有新文本」在选择页一眼可见,无须把内容堆到主页。 +private struct TransferTextButton: View { let t: Theme let lang: Lang - let on: Bool + let active: Bool // 接收正开着 + let unread: Int let action: () -> Void @State private var hover = false var body: some View { Button(action: action) { - HStack(spacing: 6) { - Image(systemName: on ? "tray.and.arrow.down.fill" : "tray.and.arrow.down") - .font(.system(size: 14, weight: .medium)) - Text(L.receiveTextButton(lang)).font(.sans(13, .semibold)) + HStack(spacing: 8) { + Image(systemName: "text.bubble").font(.system(size: 14, weight: .medium)) + Text(L.transferText(lang)).font(.sans(13.5, .semibold)) + if unread > 0 { + Text(LStr.unreadCount(unread, lang)).font(.sans(10, .bold)).foregroundStyle(.white) + .padding(.horizontal, 6).padding(.vertical, 1) + .background(Capsule().fill(t.accent)) + } else if active { + Circle().fill(t.accent).frame(width: 6, height: 6) + } } - .foregroundStyle(on ? .white : t.ink) - .frame(maxWidth: .infinity).frame(height: 34).padding(.horizontal, 13) - .background(RoundedRectangle(cornerRadius: 10, style: .continuous) - .fill(on ? t.accent : (hover ? t.surfaceAlt : t.surface))) - .overlay(RoundedRectangle(cornerRadius: 10, style: .continuous) - .strokeBorder(on ? .clear : (hover ? t.lineStrong : t.line), lineWidth: 1)) + .foregroundStyle(t.ink) + .frame(maxWidth: .infinity).frame(height: 44) + .background(RoundedRectangle(cornerRadius: 12, style: .continuous).fill(hover ? t.surfaceAlt : t.surface)) + .overlay(RoundedRectangle(cornerRadius: 12, style: .continuous) + .strokeBorder(hover ? t.lineStrong : t.line, lineWidth: 1)) } .buttonStyle(.plain).onHover { hover = $0 } - .help(on ? L.stopReceivingHelp(lang) : L.recvInboxDesc(lang)) } } -// 空态的就地接收卡:开了「接收文本」、又没分享别的内容时显示——指向发送页 /ls/send 的二维码 + 地址, -// 让手机扫码就能发文本过来(收件箱另由 ReceivedTextsCard 紧随其下)。无网络时给提示而非空白。 -private struct ReceiveHomeCard: View { +// MARK: - 传递文本二级页(收/发合一) + +// 一页一码:上半发文本(编辑器 + 发送/更新/撤回),中间一个二维码恒指 /ls/text,下半是「允许收文本」 +// 开关 + 收件箱。手机扫这一个码即可读取电脑文本并(开关开着时)发回文本——双向都在这页。 +private struct TextScreen: View { let t: Theme @EnvironmentObject var state: AppState + @State private var draft = "" var body: some View { let lang = state.lang - return VStack(spacing: 0) { - HStack(spacing: 6) { - Circle().fill(t.accent).frame(width: 6, height: 6) - Text(L.receivingTextKicker(lang)).font(.sans(11, .bold)).tracking(0.8).foregroundStyle(t.inkMute) + ScreenFrame(t: t) { + HStack(spacing: 10) { + IconButton(t: t, systemImage: "chevron.left", help: L.back(lang)) { state.goShare() } + Text(L.transferText(lang)).font(.display(21, .semibold)).foregroundStyle(t.ink) Spacer() + IconButton(t: t, systemImage: "gearshape", help: L.settings(lang)) { state.openSettings() } } - .padding(.horizontal, 16).padding(.top, 13) - if state.isRunning, let qr = state.qrImage { - QRCard(image: qr, size: 150).padding(.top, 14) - Text(L.scanCaptionSend(lang)).font(.sans(12.5, .semibold)).foregroundStyle(t.ink).padding(.top, 12) - if let url = state.primaryURL { - CopyPill(t: t, lang: lang, value: url, compact: true, onOpen: { - if let u = URL(string: url) { NSWorkspace.shared.open(u) } - }) - .padding(.top, 10).padding(.horizontal, 16) + } content: { + VStack(spacing: 16) { + composeCard + if state.isRunning, state.qrImage != nil { qrCard } else { idleHint } + receiveRow + if state.textInboxEnabled || !state.receivedTexts.isEmpty { ReceivedTextsCard(t: t) } + } + } + .onAppear { draft = state.sharedText ?? state.textDraft } + } + + // 发文本:编辑器 + 发送/更新(与当前广播一致时置灰);已在广播则可「撤回」(撤下文本,文件不受影响)。 + private var composeCard: some View { + let lang = state.lang + let blank = draft.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + let unchanged = draft == (state.sharedText ?? "") + return VStack(alignment: .leading, spacing: 11) { + HStack(spacing: 6) { + Text(L.sendToPhoneKicker(lang)).font(.sans(11, .bold)).tracking(0.8).foregroundStyle(t.inkMute) + Spacer() + if state.hasText { + Button { state.setSharedText(nil); draft = "" } label: { + Text(L.retract(lang)).font(.sans(11)).foregroundStyle(t.inkMute) + }.buttonStyle(.plain) } - } else { - VStack(spacing: 8) { - Image(systemName: "wifi.slash").font(.system(size: 26)).foregroundStyle(t.inkFaint) - Text(L.noNetwork(lang)).font(.sans(12.5, .semibold)).foregroundStyle(t.inkMute) + } + PlainTextEditor(text: $draft, placeholder: L.textEditorPlaceholder(lang), + placeholderColor: NSColor(t.inkFaint), textColor: NSColor(t.ink), + caret: NSColor(t.accent), inset: 10, autoFocus: false) + .frame(minHeight: 118) + .background(RoundedRectangle(cornerRadius: 12, style: .continuous).fill(t.field)) + .overlay(RoundedRectangle(cornerRadius: 12, style: .continuous).strokeBorder(t.line, lineWidth: 1)) + PrimaryButton(t: t, title: state.hasText ? L.textUpdateAction(lang) : L.textShareAction(lang), + systemImage: "paperplane.fill", fullWidth: true) { + state.setSharedText(draft) + } + .disabled(blank || unchanged) + .opacity(blank || unchanged ? 0.5 : 1) + } + .padding(16) + .background(RoundedRectangle(cornerRadius: 16, style: .continuous).fill(t.surface)) + .overlay(RoundedRectangle(cornerRadius: 16, style: .continuous).strokeBorder(t.line, lineWidth: 1)) + } + + // 二维码:恒指 /ls/text。说明文案随状态切换——发+收为「读取或发送」、仅发为「查看」、仅收为「发到本机」。 + private var qrCard: some View { + let lang = state.lang + let caption = state.hasText + ? (state.textInboxEnabled ? L.scanCaptionTransfer(lang) : L.scanCaptionText(lang)) + : L.scanCaptionSend(lang) + return VStack(spacing: 0) { + QRCard(image: state.qrImage, size: 172, dimmed: !state.isRunning).padding(.top, 4) + Text(caption).font(.sans(13, .semibold)).foregroundStyle(t.ink).padding(.top, 14) + CopyPill(t: t, lang: lang, value: state.primaryURL ?? "—", compact: true, onOpen: openInBrowser).padding(.top, 10) + if let local = state.localURL { + BackupAddressRow(t: t, lang: lang, full: local) { + if let u = URL(string: local) { NSWorkspace.shared.open(u) } } - .frame(maxWidth: .infinity).padding(.vertical, 26) + .padding(.top, 7).padding(.leading, 12) } } - .padding(.bottom, 16) + .frame(maxWidth: .infinity) + .padding(.horizontal, 18).padding(.vertical, 18) + .background(RoundedRectangle(cornerRadius: 16, style: .continuous).fill(t.surface)) + .overlay(RoundedRectangle(cornerRadius: 16, style: .continuous).strokeBorder(t.line, lineWidth: 1)) + } + + // 还没东西可服务(没发文本、也没开接收):给一句提示而非空白二维码;无网络时换网提示。 + private var idleHint: some View { + let lang = state.lang + let net = state.hasNetwork + return VStack(spacing: 10) { + Image(systemName: net ? "qrcode" : "wifi.slash").font(.system(size: 28)).foregroundStyle(t.inkFaint) + Text(net ? L.textIdleHint(lang) : L.noNetwork(lang)) + .font(.sans(12.5)).foregroundStyle(t.inkMute) + .multilineTextAlignment(.center).lineSpacing(2) + } + .frame(maxWidth: .infinity).padding(.horizontal, 20).padding(.vertical, 34) .background(RoundedRectangle(cornerRadius: 16, style: .continuous).fill(t.surface)) .overlay(RoundedRectangle(cornerRadius: 16, style: .continuous).strokeBorder(t.line, lineWidth: 1)) } + + // 允许收文本:默认关的小开关(与设置页同一闸门 textInboxEnabled)。开了二维码页就挂出发送框、下面长出收件箱。 + private var receiveRow: some View { + let lang = state.lang + return HStack(alignment: .top, spacing: 12) { + VStack(alignment: .leading, spacing: 2) { + Text(L.recvInboxTitle(lang)).font(.sans(13, .semibold)).foregroundStyle(t.ink) + Text(L.recvInboxDesc(lang)).font(.sans(11.5)).foregroundStyle(t.inkMute) + .fixedSize(horizontal: false, vertical: true) + } + Spacer(minLength: 8) + ToggleSwitch(t: t, isOn: state.textInboxEnabled) { state.setTextInboxEnabled(!state.textInboxEnabled) } + } + .padding(14) + .background(RoundedRectangle(cornerRadius: 14, style: .continuous).fill(t.surface)) + .overlay(RoundedRectangle(cornerRadius: 14, style: .continuous).strokeBorder(t.line, lineWidth: 1)) + } + + private func openInBrowser() { + guard let s = state.primaryURL, let url = URL(string: s) else { return } + NSWorkspace.shared.open(url) + } } // 在线访客明细弹窗:列出全部活跃访客(设备名优先,查不到显示完整 IP),最近活跃在前。 diff --git a/Sources/LocalShare/FileServer.swift b/Sources/LocalShare/FileServer.swift index c3e105b..554c8fb 100644 --- a/Sources/LocalShare/FileServer.swift +++ b/Sources/LocalShare/FileServer.swift @@ -343,32 +343,39 @@ final class FileServer { // 分享文本端点(保留路径,先于分享内容路由;与 /ls/ping 同款):导航发文本预览壳页, // ?raw=1 / curl(Accept */*)发 text/plain 原文。无分享文本时 404。token 清洗的 302 已在上面处理, // 故到这里要么带 cookie 要么是非导航请求,照常服务。 + // 传递文本(收/发合一,保留路径,先于分享内容路由):二维码恒指此页。 + // · 有共享文本:导航发文本预览壳页(开着接收时壳页自带发送框,见 PreviewPage canReceiveText), + // ?raw=1/curl(Accept */*)发 text/plain 原文; + // · 无共享文本但开着接收:退化成纯「发文本给电脑」页(手机→Mac); + // · 两者皆无:没东西可展示,404。token 清洗的 302 已在上面处理。 if req.method == "GET", req.path == "/ls/text" { - guard let text = sharedText, !text.isEmpty else { - return htmlResponse(404, "Not Found", Self.notFoundPage(lang), extra: extra) - } - if wantsViewer { - // 与文件共存(虚拟根有文件项)时显示「分享内容 / 文本」面包屑;纯文本分享不显示。 - // 复用 DirectoryListing.breadcrumb(同 md/json/csv 预览):把「文本」当末段路径传入即得 - // 「根(链) / 文本(当前)」,样式将来变动这里一并跟随。 - var crumbs: String? = nil - if case .multiple(let items) = share, !items.isEmpty { - crumbs = DirectoryListing.breadcrumb(requestPath: "/" + L.webText(lang), - rootName: Self.multipleRootName(lang)) + let text = sharedText ?? "" + if !text.isEmpty { + if wantsViewer { + // 与文件共存(虚拟根有文件项)时显示「分享内容 / 文本」面包屑;纯文本分享不显示。 + // 复用 DirectoryListing.breadcrumb(同 md/json/csv 预览):把「文本」当末段路径传入即得 + // 「根(链) / 文本(当前)」,样式将来变动这里一并跟随。 + var crumbs: String? = nil + if case .multiple(let items) = share, !items.isEmpty { + crumbs = DirectoryListing.breadcrumb(requestPath: "/" + L.webText(lang), + rootName: Self.multipleRootName(lang)) + } + return htmlResponse(200, "OK", TextViewer.html(text: text, crumbs: crumbs, canUpload: false, canReceiveText: recvOn, lang: lang), extra: extra) } - return htmlResponse(200, "OK", TextViewer.html(text: text, crumbs: crumbs, canUpload: false, canReceiveText: recvOn, lang: lang), extra: extra) + return plainTextResponse(text, extra: extra) } - return plainTextResponse(text, extra: extra) + if recvOn { + return htmlResponse(200, "OK", SendText.html(lang: lang), extra: extra) + } + return htmlResponse(404, "Not Found", Self.notFoundPage(lang), extra: extra) } - // 独立发送页(收文本 v2 保留路径,先于分享内容路由):收件箱开启时出「发文本给电脑」页面, - // 供「只收文本、没分享任何内容」时二维码直指(GUI 的 AppState.makeURL)。关闭时 404。 - // token 清洗的 302 已在上面处理。 + // 旧版「只收文本」二维码曾直指 /ls/send;现已并入 /ls/text(收发合一)。保留此路径做 302 兼容, + // 让老二维码/书签仍能落地(cookie 已在 token 清洗步种好,跟随重定向即可鉴权)。 if req.method == "GET", req.path == "/ls/send" { - guard textInboxEnabled else { - return htmlResponse(404, "Not Found", Self.notFoundPage(lang), extra: extra) - } - return htmlResponse(200, "OK", SendText.html(lang: lang), extra: extra) + var h = extra + h["Location"] = "/ls/text" + return .raw(302, "Found", h, nil) } // 收文本(保留路径,先于上传拦截):POST /ls/text 投递一段文本到收件箱。开关关 → 403; diff --git a/Sources/LocalShare/HeadlessServer.swift b/Sources/LocalShare/HeadlessServer.swift index 15b7ea0..af03fe2 100644 --- a/Sources/LocalShare/HeadlessServer.swift +++ b/Sources/LocalShare/HeadlessServer.swift @@ -9,7 +9,7 @@ import Foundation // LS_UPLOAD 置 1 开启访客上传(仅单文件夹分享生效) // LS_BIND 仅绑该 IPv4 地址(选填;默认绑全部接口)——对应 GUI「仅当前网络可见」,供冒烟验证 // LS_TEXT 分享一段文本(可单独,也可与 LS_FOLDER(S) 共存);纯文本时 URL 直指 /ls/text -// LS_RECV 置 1 开启收文本(收件箱);无任何分享内容时 URL 直指 /ls/send +// LS_RECV 置 1 开启收文本(收件箱);无任何分享内容时 URL 直指 /ls/text(收发合一,退化成纯发送页) // LS_RECV_LOG 收到文本时把原文追加进该文件(以 0x01 分隔),供冒烟测回读校验 enum HeadlessServer { static func run() { @@ -49,10 +49,9 @@ enum HeadlessServer { } do { let bound = try server.start(preferredPorts: [port]) - // 纯文本分享直指 /ls/text、只收文本直指 /ls/send(口径同 GUI 的 AppState.makeURL)。 + // 传递文本(发文本 / 只收文本)一律直指 /ls/text(收发合一,口径同 GUI 的 AppState.makeURL)。 let path: String - if text != nil, urls.isEmpty { path = "/ls/text" } - else if recvOn, urls.isEmpty, text == nil { path = "/ls/send" } + if urls.isEmpty, text != nil || recvOn { path = "/ls/text" } else { path = "/" } print("LS_URL http://127.0.0.1:\(bound)\(path)?t=\(token)") fflush(stdout) diff --git a/Sources/LocalShare/Lang.swift b/Sources/LocalShare/Lang.swift index fb4a587..a54c46c 100644 --- a/Sources/LocalShare/Lang.swift +++ b/Sources/LocalShare/Lang.swift @@ -147,12 +147,14 @@ enum L: CaseIterable { // 传递文本(v1) case shareTextButton, textEditorPlaceholder, textShareAction, textUpdateAction case sharingTextKicker, scanCaptionText, editTextButton + // 传递文本二级页(收/发合一) + case transferText, sendToPhoneKicker, scanCaptionTransfer, textIdleHint, retract case rememberTextTitle, rememberTextDesc, deleteEntry // 传递文本(v2·手机→Mac 收文本) case recvInboxTitle, recvInboxDesc, persistRecvTitle, persistRecvDesc - case receivedTextsTitle, receivingTextKicker, inboxWaiting - case scanCaptionSend, clearReceivedConfirm, copyTextAction, stopReceivingHelp, receiveTextButton + case receivedTextsTitle, inboxWaiting + case scanCaptionSend, clearReceivedConfirm, copyTextAction // —— 网页(由 Swift 直接拼进 HTML 的静态文案)—— case webUpload, webDropHere, webBackToParent, webEmptyFolder @@ -331,6 +333,12 @@ enum L: CaseIterable { case .sharingTextKicker: return ("正在分享文本", "Sharing text") case .scanCaptionText: return ("扫码查看文本 · 同一 Wi-Fi", "Scan to view text · same Wi-Fi") case .editTextButton: return ("编辑文本", "Edit Text") + case .transferText: return ("传递文本", "Transfer Text") + case .sendToPhoneKicker: return ("发送给手机", "Send to phone") + case .scanCaptionTransfer: return ("扫码读取或发送文本 · 同一 Wi-Fi", "Scan to read or send text · same Wi-Fi") + case .textIdleHint: return ("发送一段文本,或开启接收,手机扫码即可", + "Send some text or turn on receiving, then scan") + case .retract: return ("撤回", "Retract") case .rememberTextTitle: return ("记住分享的文本", "Remember Shared Text") case .rememberTextDesc: return ("重启后回填上次内容供再次分享;关闭则退出即忘", "Refills the last text after restart for reuse; off forgets it on quit") @@ -342,13 +350,10 @@ enum L: CaseIterable { case .persistRecvDesc: return ("重启后保留收件箱内容;关闭则退出即忘", "Keeps the inbox after restart; off forgets it on quit") case .receivedTextsTitle: return ("收到的文本", "Received Text") - case .receivingTextKicker: return ("正在接收文本", "Receiving text") case .inboxWaiting: return ("等待手机发来文本…", "Waiting for text from a phone…") case .scanCaptionSend: return ("扫码把文本发到这台 Mac · 同一 Wi-Fi", "Scan to send text to this Mac · same Wi-Fi") case .clearReceivedConfirm: return ("清空收到的全部文本?", "Clear all received text?") case .copyTextAction: return ("复制", "Copy") - case .stopReceivingHelp: return ("停止接收文本", "Stop receiving text") - case .receiveTextButton: return ("接收文本", "Receive Text") case .webUpload: return ("上传", "Upload") case .webDropHere: return ("松手上传到这里", "Drop here to upload") diff --git a/tools/smoke-text-receive.sh b/tools/smoke-text-receive.sh index 7cd2da8..8d7b3c9 100755 --- a/tools/smoke-text-receive.sh +++ b/tools/smoke-text-receive.sh @@ -1,9 +1,10 @@ #!/usr/bin/env bash -# 防回归冒烟测:收文本(手机→Mac,v2)。覆盖只收模式、闸门关、以及与文件夹分享共存三形态: -# · 只收模式 URL 直指 /ls/send;GET /ls/send 出发送表单(textarea + 发送钮),首访 302 清洗 token; +# 防回归冒烟测:收文本(手机→Mac,v2)。收发已并入 /ls/text 一页一码。覆盖只收模式、闸门关、共存: +# · 只收模式 URL 直指 /ls/text;无共享文本时该页退化成发送表单(textarea + 发送钮),首访 302 清洗 token; # · POST /ls/text 收原文(text/plain),LOG 回读逐字一致(含 UTF-8 与 < 不被破坏); # · 无 token → 403;空白 → 400;超 64KB → 413; -# · 闸门关(仅文件夹)时 /ls/send 404、POST /ls/text 403、列表页不出发送表单; +# · 旧 /ls/send 仍 302 兼容跳 /ls/text; +# · 闸门关、无文本(仅文件夹)时 /ls/text 404、POST /ls/text 403、列表页不出发送表单; # · 文件夹 + 开收件箱:列表页内嵌发送表单(与上传表单同条件)。 # 用法: ./tools/smoke-text-receive.sh 退出码 0=全过。 set -u @@ -29,22 +30,26 @@ LOG="$(mktemp)" LS_HEADLESS=1 LS_RECV=1 LS_RECV_LOG="$LOG" LS_TOKEN="$TOK" LS_PORT="$PORT" "$BIN" >/tmp/ls_recv_url.$$ 2>&1 & SRV=$! trap 'kill $SRV 2>/dev/null; wait $SRV 2>/dev/null; rm -f /tmp/ls_recv_url.$$ "$LOG"' EXIT -wait_up "$PORT" "/ls/send" +wait_up "$PORT" "/ls/text" base="http://127.0.0.1:$PORT" -echo "── 只收模式:headless URL 直指 /ls/send" -grep -q "LS_URL .*/ls/send?t=$TOK" /tmp/ls_recv_url.$$ && ok "URL 指向 /ls/send" || bad "URL 未指向 /ls/send:$(cat /tmp/ls_recv_url.$$)" +echo "── 只收模式:headless URL 直指 /ls/text" +grep -q "LS_URL .*/ls/text?t=$TOK" /tmp/ls_recv_url.$$ && ok "URL 指向 /ls/text" || bad "URL 未指向 /ls/text:$(cat /tmp/ls_recv_url.$$)" echo "── 导航(Accept text/html)首访 302 清洗 token" -S=$(curl -s -o /dev/null -w '%{http_code}' -H 'Accept: text/html' "$base/ls/send?t=$TOK") +S=$(curl -s -o /dev/null -w '%{http_code}' -H 'Accept: text/html' "$base/ls/text?t=$TOK") [ "$S" = "302" ] && ok "302 清洗" || bad "应 302 实为 $S" -echo "── GET /ls/send(cookie)出发送表单" -P=$(curl -s -H 'Accept: text/html' -b "ls_token=$TOK" "$base/ls/send") +echo "── GET /ls/text(cookie,无共享文本)退化成发送表单" +P=$(curl -s -H 'Accept: text/html' -b "ls_token=$TOK" "$base/ls/text") echo "$P" | grep -q 'id="sendta"' && ok "含输入框" || bad "缺输入框 textarea" echo "$P" | grep -q 'id="sendbtn"' && ok "含发送按钮" || bad "缺发送按钮" echo "$P" | grep -q "/ls/text" && ok "脚本投递到 /ls/text" || bad "未见 /ls/text 投递" +echo "── 旧 /ls/send 302 兼容跳 /ls/text" +H=$(curl -s -D - -o /dev/null -b "ls_token=$TOK" "$base/ls/send") +echo "$H" | grep -qi '^HTTP/.* 302' && echo "$H" | grep -qi '^Location: */ls/text' && ok "/ls/send → 302 /ls/text" || bad "/ls/send 未 302 跳 /ls/text:$(echo "$H" | tr -d '\r' | head -3 | tr '\n' '|')" + echo "── POST /ls/text 收原文 + LOG 逐字回读(含 UTF-8 与 <)" MSG=$'Hello 你好 x\n第二行 https://example.com' S=$(curl -s -o /dev/null -w '%{http_code}' -X POST --data-binary "$MSG" -H 'Content-Type: text/plain' -b "ls_token=$TOK" "$base/ls/text") @@ -69,7 +74,7 @@ rm -f /tmp/ls_big.$$ kill $SRV 2>/dev/null; wait $SRV 2>/dev/null -# ── 2. 闸门关(仅文件夹分享):/ls/send 404、POST 403、列表无表单 ── +# ── 2. 闸门关(仅文件夹分享,无共享文本):/ls/text 404、POST 403、列表无表单 ── PORT=$(( (RANDOM % 10000) + 42000 )) ROOT="$(mktemp -d)"; echo hi > "$ROOT/a.txt" LS_HEADLESS=1 LS_FOLDER="$ROOT" LS_TOKEN="$TOK" LS_PORT="$PORT" "$BIN" >/dev/null 2>&1 & @@ -77,9 +82,9 @@ SRV=$! wait_up "$PORT" "/" base="http://127.0.0.1:$PORT" -echo "── 闸门关:/ls/send 404" -S=$(curl -s -o /dev/null -w '%{http_code}' -b "ls_token=$TOK" "$base/ls/send") -[ "$S" = "404" ] && ok "/ls/send 404" || bad "应 404 实为 $S" +echo "── 闸门关、无文本:/ls/text 404" +S=$(curl -s -o /dev/null -w '%{http_code}' -b "ls_token=$TOK" "$base/ls/text") +[ "$S" = "404" ] && ok "/ls/text 404" || bad "应 404 实为 $S" echo "── 闸门关:POST /ls/text 403" S=$(curl -s -o /dev/null -w '%{http_code}' -X POST --data-binary 'x' -b "ls_token=$TOK" "$base/ls/text") [ "$S" = "403" ] && ok "POST 403" || bad "应 403 实为 $S" From 53d4cb7c3ab277ecdf3fd207f05f94c3ee4549d4 Mon Sep 17 00:00:00 2001 From: shawn Date: Fri, 26 Jun 2026 16:21:12 +0800 Subject: [PATCH 08/17] =?UTF-8?q?fix:=20=E6=9B=B4=E6=96=B0=E6=96=87?= =?UTF-8?q?=E6=9C=AC=E4=B8=8D=E5=86=8D=E8=BD=AE=E6=8D=A2=20token=EF=BC=8Ct?= =?UTF-8?q?oken=20=E6=94=B9=E5=9B=9E=E4=BC=9A=E8=AF=9D=E7=BB=B4=E5=BA=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit setSharedText 不再每次生成新 token。原先每次发送/更新文本都轮换 token,会作废旧链接—— 手机浏览器一刷新就 403、得重扫码;文件+文本共存时还会连累文件分享的访客一起掉线。 改为与分享文件一致的「会话」维度:token 只在会话边界轮换(setShared 换分享、stop/clearShare 结束)。会话内编辑/更新文本不换钥匙,正在看的手机刷新仍有效。要作废旧链接就停止/清除。 --- PLAN.md | 8 +++++--- Sources/LocalShare/AppState.swift | 12 +++++++----- 2 files changed, 12 insertions(+), 8 deletions(-) diff --git a/PLAN.md b/PLAN.md index c1d6ae5..3642f87 100644 --- a/PLAN.md +++ b/PLAN.md @@ -236,7 +236,7 @@ open dist/LocalShare.app # 本机自测 release 编译通过。 - [x] 传递文本 v1 — Mac→手机发文本(PR #25):`AppState.sharedText` 与 share 正交,保留路径 `/ls/text` 提供(导航发 `TextViewer` 壳页、`?raw=1`/curl 发 `text/plain` 原文),可独立分享或挂进多选虚拟根的 - 文本行;离散提交快照、点「分享/更新」即轮换 token;空态加「分享文本」入口(自带 `NSTextView`、 + 文本行;离散提交快照、点「分享/更新」提交文本(v2.1 起更新文本不再轮换 token,见下);空态加「分享文本」入口(自带 `NSTextView`、 placeholder 由其自绘以兼容中文输入法组合态);手机页纯文本 + 大「复制」按钮(`execCommand` 回退—— 纯 http LAN 是非安全上下文、`navigator.clipboard` 不可用)+ http(s) 安全自动链接;设置「记住分享的 文本」默认关(开则重启回填草稿、**不自动广播**);历史复用 `RecentShare`(扩 `text:`)+ 逐条 ✕ 删除 + @@ -255,7 +255,9 @@ open dist/LocalShare.app # 本机自测 **恒可渲染**:有共享文本发预览壳页(开着接收即自带发送框,`PreviewPage.canReceiveText`),无文本但开着 接收则退化成纯发送页——**一页一码、两端双向**。二维码(`makeURL`)与 headless URL 一律指 `/ls/text`; 旧 `/ls/send` 保留为 302 跳 `/ls/text` 兼容。「允许收文本」默认关,闸门仍是 `textInboxEnabled`(设置页与 - 文本页同一开关)。`smoke-text-receive.sh` 改测 `/ls/text` 退化页 + `/ls/send` 302。 + 文本页同一开关)。**token 改回会话维度**:`setSharedText` 不再轮换 token(v1/v2 每次更新都换、会把正在看的 + 手机刷掉,还误伤共存的文件分享链接),只在 `setShared`/`stop`/`clearShare` 这些会话边界轮换。 + `smoke-text-receive.sh` 改测 `/ls/text` 退化页 + `/ls/send` 302。 > 已知坑(已规避并注释):Swifter 1.5.0 的 `HttpParser` 会对请求 path 二次编码,导致 `request.path` > 仍残留一层百分号编码 —— FileServer 落地文件系统前已用 `removingPercentEncoding` 解码,且不影响防穿越。 @@ -299,7 +301,7 @@ curl -s "http://127.0.0.1:8099/?t=testtoken" # 应返回目录列表 | 数据模型 | `AppState` 加 `sharedText: String?`(全局单一文本,一次只一段) | | 与文件关系 | 混进多选**虚拟根**当一个条目;**也能独立分享**(一个文件都不选——这才是「传文本」的主力场景) | | 「空」判定 | 从 `sharedItems.isEmpty` 改为 `sharedItems.isEmpty && sharedText == nil`,牵动 `start/stop/恢复/QR` 全链 | -| 交互模型 | **离散提交快照**:输入框打字不广播,点「分享」才把当前文本快照发出去 = 一次 `setShared` → **轮换 token**;要改就改完再点「更新分享」(再轮换)。与「一次分享=一次 setShared=换钥匙」完全一致,不存在边打字边失效 | +| 交互模型 | **离散提交快照**:输入框打字不广播,点「分享/更新」才把当前文本快照发出去。token 按**会话**维度(同分享文件)——`setSharedText` **不换 token**,只有换分享(`setShared`)/停止/清除这些会话边界才轮换作废旧链接;故**编辑/更新文本时正在看的手机刷新仍有效、无须重扫**(v2.1 修正;v1 曾每次更新都轮换,会把看的人刷掉,也会误伤共存的文件分享链接) | | 路由 | 仅文本无文件 → 扫码直达文本页(同「单文件直接发那一个」);文本+任意文件 → 落虚拟根列表,文本是其中一条。文本服务在保留命名空间 `/ls/text`(像 `/ls/ping` 一样**先于**虚拟根 key 匹配),躲开「有文件正好叫 text」的 key 撞车 | | 持久化 | 设置项「持久化分享文本」**默认关**。关 → 文本压根不写进 recents/UserDefaults;开 → 记一条可恢复(但**不自动 start**)的 history。理由:文本常是密码/验证码/一次性口令,自动恢复+自动起服务会在 LAN 上悄悄重新暴露上次那段 | | 历史 | 复用现成 `.history` 屏 + `RecentShare`(扩 `text: String?`,无 path 的文本条目)。顺手把**「逐条删除」做成 history 通用能力**(文件条目也能删——路径同样泄信息)。删 history ≠ 停掉当前直播的 live 分享 | diff --git a/Sources/LocalShare/AppState.swift b/Sources/LocalShare/AppState.swift index a1ba7c3..2be1e7e 100644 --- a/Sources/LocalShare/AppState.swift +++ b/Sources/LocalShare/AppState.swift @@ -206,8 +206,9 @@ final class AppState: ObservableObject { else { start() } } - // 运行中把当前分享态推给 server(不重启、端口不变)。顺序不变式:先换钥匙(token) 再推内容 - // (sharedText / share)——杜绝旧 token 读到新分享的瞬间窗口(见 CLAUDE.md 线程模型)。setShared / setSharedText 共用。 + // 运行中把当前分享态推给 server(不重启、端口不变)。顺序不变式:先写 token 再推内容(sharedText / share) + // ——换分享(setShared)时新钥匙必须先于新内容落地,杜绝旧 token 读到新分享的瞬间窗口(见 CLAUDE.md 线程模型)。 + // setShared(换钥匙)与 setSharedText(不换钥匙,仅会话内更新文本)共用此推送,故恒按此序写。 private func pushToServer() { server?.token = token server?.sharedText = hasText ? sharedText : nil @@ -215,13 +216,14 @@ final class AppState: ObservableObject { server?.share = currentShare } - // 分享 / 更新一段文本(Mac→手机)。离散提交快照:调用即把当前文本作为新分享广播,轮换 token - // (旧链接/cookie/二维码作废)。传 nil/空白即撤下文本——若同时也无文件则停服务、回到空状态。 + // 分享 / 更新一段文本(Mac→手机)。token 的「会话」维度与分享文件一致:只在会话边界轮换 + //(setShared 换分享、stop/clearShare 结束),**编辑/更新文本本身不换 token**——正在看的手机刷新仍是 + // 同一把钥匙、无须重扫(会话内内容可随手迭代);与文件共存时改文本更不会误伤文件分享的链接。 + // 传 nil/空白即撤下文本;若同时无文件、也没开接收则停服务(由 stop 轮换 token、那才真正作废链接)。 // 文本与已分享的文件正交:设了文本不动文件、撤了文本也不动文件。 func setSharedText(_ raw: String?) { let trimmed = raw?.trimmingCharacters(in: .whitespacesAndNewlines) let newText: String? = (trimmed?.isEmpty ?? true) ? nil : trimmed - token = Token.generate() // 内容变更=新分享:换钥匙 sharedText = newText textDraft = newText ?? "" describeShared() From 20c345ffcb66e99118d9a97b6a9a2f04b8c1d80e Mon Sep 17 00:00:00 2001 From: shawn Date: Fri, 26 Jun 2026 16:21:33 +0800 Subject: [PATCH 09/17] =?UTF-8?q?fix:=20=E8=BE=93=E5=85=A5=E6=B3=95?= =?UTF-8?q?=E7=BB=84=E5=90=88=E6=9C=9F=E9=97=B4=E4=B8=8D=E9=87=8D=E5=86=99?= =?UTF-8?q?=E6=96=87=E6=9C=AC=E8=A7=86=E5=9B=BE=EF=BC=8C=E4=BF=AE=E4=B8=AD?= =?UTF-8?q?=E6=96=87=E5=90=9E=E5=AD=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PlainTextEditor.updateNSView 在 tv.hasMarkedText()(拼音组合中)时直接返回。文本页内联编辑器在 服务运行时会被 2s 在线人数轮询触发的周期性重渲染反复调用 updateNSView,若组合期间回写 string / 重设 typing 属性会打断 marked text、吞字。组合结束后下一拍自然补同步。 --- Sources/LocalShare/ContentView.swift | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/Sources/LocalShare/ContentView.swift b/Sources/LocalShare/ContentView.swift index af00c57..9f00910 100644 --- a/Sources/LocalShare/ContentView.swift +++ b/Sources/LocalShare/ContentView.swift @@ -254,6 +254,10 @@ private struct PlainTextEditor: NSViewRepresentable { func updateNSView(_ scroll: NSScrollView, context: Context) { context.coordinator.parent = self // 让回调里的 binding 始终指向最新的 guard let tv = scroll.documentView as? PlaceholderTextView else { return } + // 输入法组合态(中文拼音未上屏)期间一律不碰文本视图直接返回——SwiftUI 因别处 @Published 变更 + // (如服务运行时每 2s 的在线人数轮询)触发的周期性 updateNSView,若在此刻回写 string 或重设 typing + // 属性,会打断 marked text 造成吞字。组合结束(unmarkText)后的下一拍再补同步颜色等即可。 + if tv.hasMarkedText() { return } if tv.string != text { tv.string = text } // 仅在外部值变化时回写,避免打断输入光标 tv.textColor = textColor tv.insertionPointColor = caret From 0c61451b218089b30ddf8deb9dde2f203d68d733 Mon Sep 17 00:00:00 2001 From: shawn Date: Fri, 26 Jun 2026 16:21:47 +0800 Subject: [PATCH 10/17] =?UTF-8?q?style:=20=E4=BC=A0=E9=80=92=E6=96=87?= =?UTF-8?q?=E6=9C=AC=E9=A1=B5=E4=BA=8C=E7=BB=B4=E7=A0=81=E7=A7=BB=E5=88=B0?= =?UTF-8?q?=E8=BE=93=E5=85=A5=E6=A1=86=E4=B8=8A=E6=96=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Sources/LocalShare/ContentView.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/LocalShare/ContentView.swift b/Sources/LocalShare/ContentView.swift index 9f00910..fa80f6b 100644 --- a/Sources/LocalShare/ContentView.swift +++ b/Sources/LocalShare/ContentView.swift @@ -863,8 +863,8 @@ private struct TextScreen: View { } } content: { VStack(spacing: 16) { - composeCard if state.isRunning, state.qrImage != nil { qrCard } else { idleHint } + composeCard receiveRow if state.textInboxEnabled || !state.receivedTexts.isEmpty { ReceivedTextsCard(t: t) } } From e7da77a82096aeb672bc92b6720925e5f27101a9 Mon Sep 17 00:00:00 2001 From: shawn Date: Fri, 26 Jun 2026 17:23:05 +0800 Subject: [PATCH 11/17] =?UTF-8?q?feat:=20=E6=89=8B=E6=9C=BA=E5=B7=B2?= =?UTF-8?q?=E5=8F=91=E9=80=81=E5=8E=86=E5=8F=B2=E6=AF=8F=E6=9D=A1=E5=8A=A0?= =?UTF-8?q?=E7=B4=A7=E5=87=91=E7=9B=B8=E5=AF=B9=E6=97=B6=E9=97=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 「已发送」历史项右侧缀一个克制的相对时间,规则控长度:24h 内显示 HH:MM、 一年内显示 MM/DD、更久显示 YYYY。历史项存储从纯字符串升级为 {t:文本, d:发送时刻ms}, 带迁移(旧的纯字符串记录归一为无时间戳、时间留空,重发即带上)。.sh-item 改 flex 行: 左正文两行截断、右时间 nowrap 不挤正文。时间纯客户端按渲染时刻相对计算。 --- Sources/LocalShare/SendTextPage.swift | 33 ++++++++++++++++++++------- 1 file changed, 25 insertions(+), 8 deletions(-) diff --git a/Sources/LocalShare/SendTextPage.swift b/Sources/LocalShare/SendTextPage.swift index 086a252..eabaa8f 100644 --- a/Sources/LocalShare/SendTextPage.swift +++ b/Sources/LocalShare/SendTextPage.swift @@ -50,15 +50,17 @@ enum SendText { background:var(--accent);color:#fff;transition:filter .15s} .sendbtn:hover{filter:brightness(1.07)} .sendbtn:disabled{opacity:.5;cursor:default} - /* 已发送历史:浏览器本地留存(localStorage),点一条回填到输入框便于改发/重发。 */ + /* 已发送历史:浏览器本地留存(localStorage),点一条回填到输入框便于改发/重发。右侧缀紧凑相对时间。 */ .senthist:empty{display:none} .senthist{border-top:1px solid var(--line)} .sh-title{padding:10px 16px 4px;font:600 11px var(--sans);letter-spacing:.04em;color:var(--inkMute)} - .sh-item{padding:8px 16px;font:12px/1.5 var(--mono);color:var(--ink);cursor:pointer; - border-top:1px solid var(--line);white-space:pre-wrap;word-break:break-word; - display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden} + .sh-item{display:flex;align-items:flex-start;gap:10px;padding:8px 16px;cursor:pointer; + border-top:1px solid var(--line);font:12px/1.5 var(--mono);color:var(--ink)} .sh-item:first-of-type{border-top:none} .sh-item:active{background:var(--surfaceAlt)} + .sh-text{flex:1;min-width:0;white-space:pre-wrap;word-break:break-word; + display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden} + .sh-time{flex:none;color:var(--inkFaint);font-size:11px;white-space:nowrap;padding-top:1px} /* 手机上输入框字号 <16px 时 iOS 聚焦会自动放大页面,置 16px 杜绝。 */ @media(max-width:560px){.sendta{font-size:16px}} """ @@ -78,17 +80,32 @@ enum SendText { } function load(){try{return JSON.parse(localStorage.getItem(KEY))||[]}catch(e){return[]}} function save(a){try{localStorage.setItem(KEY,JSON.stringify(a))}catch(e){}} + // 历史项新版存 {t:文本, d:发送时刻ms};旧版只存字符串,读取时归一(无时间戳 d=0、时间留空)。 + function norm(it){return (typeof it==='string')?{t:it,d:0}:it;} + function pad(n){return (n<10?'0':'')+n;} + // 紧凑相对时间(右侧角标,控长度):24h 内 HH:MM;一年内 MM/DD;更久 YYYY;无时间戳留空。 + function fmtTime(d){ + if(!d)return ''; + var then=new Date(d),diff=Date.now()-d; + if(diff<864e5)return pad(then.getHours())+':'+pad(then.getMinutes()); + if(diff<31536e6)return pad(then.getMonth()+1)+'/'+pad(then.getDate()); + return ''+then.getFullYear(); + } function render(){ if(!hist)return; hist.innerHTML=''; var a=load(); if(!a.length)return; var h=document.createElement('div');h.className='sh-title';h.textContent=LS_I18N.sentHistory;hist.appendChild(h); a.slice(0,20).forEach(function(item){ - var d=document.createElement('div');d.className='sh-item';d.textContent=item; // textContent 注入,安全 - d.addEventListener('click',function(){ta.value=item;ta.focus();}); - hist.appendChild(d); + var it=norm(item); + var row=document.createElement('div');row.className='sh-item'; + var tx=document.createElement('span');tx.className='sh-text';tx.textContent=it.t; // textContent 注入,安全 + var tm=document.createElement('span');tm.className='sh-time';tm.textContent=fmtTime(it.d); + row.appendChild(tx);row.appendChild(tm); + row.addEventListener('click',function(){ta.value=it.t;ta.focus();}); + hist.appendChild(row); }); } - function remember(v){var a=load();a.unshift(v);if(a.length>20)a=a.slice(0,20);save(a);render();} + function remember(v){var a=load();a.unshift({t:v,d:Date.now()});if(a.length>20)a=a.slice(0,20);save(a);render();} function fail(r){ btn.disabled=false; r.text().then(function(b){ From e9fd2c38a0edf4c5542113678ecf0a8be7bee3c1 Mon Sep 17 00:00:00 2001 From: shawn Date: Fri, 26 Jun 2026 17:28:41 +0800 Subject: [PATCH 12/17] =?UTF-8?q?fix:=20=E6=94=B6=E6=96=87=E6=9C=AC?= =?UTF-8?q?=E6=9C=AA=E8=AF=BB=E5=8F=AA=E7=94=A8=E6=95=B0=E5=AD=97=E8=A7=92?= =?UTF-8?q?=E6=A0=87=EF=BC=8C=E6=8E=A5=E6=94=B6=E5=BC=80=E7=9D=80=E7=94=A8?= =?UTF-8?q?=E5=91=BC=E5=90=B8=E7=BA=A2=E7=82=B9=E4=B8=8D=E5=86=8D=E5=86=92?= =?UTF-8?q?=E5=85=85=E6=9C=AA=E8=AF=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 两处: - recordReceivedText 仅在 screen != .text 时累加未读——人已在传递文本页(收件箱在眼前) 收到的直接算已读,修掉「正在页面上却清不掉红点」的 bug;配合 onAppear 清零,逻辑闭合。 - TransferTextButton 不再用常驻红点表示「接收开着」:未读只走右上角红色数字角标;接收开着、 无未读时缀一颗缓慢呼吸(淡入淡出)的红点,用动效表实时接收中,与静态未读角标天然区分。 --- Sources/LocalShare/AppState.swift | 5 +++-- Sources/LocalShare/ContentView.swift | 21 ++++++++++++++++++--- 2 files changed, 21 insertions(+), 5 deletions(-) diff --git a/Sources/LocalShare/AppState.swift b/Sources/LocalShare/AppState.swift index 2be1e7e..6d95b26 100644 --- a/Sources/LocalShare/AppState.swift +++ b/Sources/LocalShare/AppState.swift @@ -336,11 +336,12 @@ final class AppState: ObservableObject { else { UserDefaults.standard.removeObject(forKey: receivedTextsKey) } } - // 收到手机投递的文本(FileServer 回调已 hop 回主线程)。新→旧插入,满 100 条挤掉最旧;未读 +1。 + // 收到手机投递的文本(FileServer 回调已 hop 回主线程)。新→旧插入,满 100 条挤掉最旧。 + // 未读口径:人已在传递文本页(收件箱就在眼前)收到的直接算已读、不堆红点;在别处收到才计未读。 private func recordReceivedText(_ rt: ReceivedText) { receivedTexts.insert(rt, at: 0) if receivedTexts.count > 100 { receivedTexts = Array(receivedTexts.prefix(100)) } - unreadReceived += 1 + if screen != .text { unreadReceived += 1 } saveReceivedTextsIfNeeded() } diff --git a/Sources/LocalShare/ContentView.swift b/Sources/LocalShare/ContentView.swift index fa80f6b..3141bd0 100644 --- a/Sources/LocalShare/ContentView.swift +++ b/Sources/LocalShare/ContentView.swift @@ -812,8 +812,21 @@ private struct ReceivedTextRow: View { } } -// 主页「传递文本」入口:平级第二功能,点进 .text 二级页(收/发合一)。收件箱有未读时角标提示、 -// 接收开着时缀一个绿点——让「正在传递」与「有新文本」在选择页一眼可见,无须把内容堆到主页。 +// 缓慢呼吸(淡入淡出)的小圆点:表「实时进行中」的状态,用动效与静态未读角标区分,避免红点冒充未读。 +private struct PulsingDot: View { + let color: Color + var size: CGFloat = 6 + @State private var lit = false + var body: some View { + Circle().fill(color).frame(width: size, height: size) + .opacity(lit ? 1 : 0.25) + .animation(.easeInOut(duration: 1.0).repeatForever(autoreverses: true), value: lit) + .onAppear { lit = true } + } +} + +// 主页「传递文本」入口:平级第二功能,点进 .text 二级页(收/发合一)。收件箱有未读时数字角标提示、 +// 接收开着时缀一个缓慢呼吸的红点——让「正在接收」与「有新文本」在选择页一眼可见,无须把内容堆到主页。 private struct TransferTextButton: View { let t: Theme let lang: Lang @@ -826,12 +839,14 @@ private struct TransferTextButton: View { HStack(spacing: 8) { Image(systemName: "text.bubble").font(.system(size: 14, weight: .medium)) Text(L.transferText(lang)).font(.sans(13.5, .semibold)) + // 未读 → 静态红色数字角标(看一眼收件箱即清);接收开着但无未读 → 缓慢呼吸的红点, + // 用淡入淡出表明是"实时接收中"而非静态未读警报,与数字角标天然区分。 if unread > 0 { Text(LStr.unreadCount(unread, lang)).font(.sans(10, .bold)).foregroundStyle(.white) .padding(.horizontal, 6).padding(.vertical, 1) .background(Capsule().fill(t.accent)) } else if active { - Circle().fill(t.accent).frame(width: 6, height: 6) + PulsingDot(color: t.accent) } } .foregroundStyle(t.ink) From 3aa543527b432f34a241c2188131ba32e0f72664 Mon Sep 17 00:00:00 2001 From: shawn Date: Fri, 26 Jun 2026 17:55:20 +0800 Subject: [PATCH 13/17] =?UTF-8?q?refactor:=20=E4=BC=A0=E9=80=92=E6=96=87?= =?UTF-8?q?=E6=9C=AC=E6=96=87=E6=A1=88=E5=8E=BB=E6=89=8B=E6=9C=BA=E5=8C=96?= =?UTF-8?q?=EF=BC=8C=E5=AF=B9=E7=AB=AF=E4=B8=8D=E9=99=90=E6=89=8B=E6=9C=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 扫码的对端可能是平板/另一台电脑,不一定是手机。把 Mac 端文案里的「手机」改成中性说法 (沿用既有的「设备」语气):发送给手机→发送文本、手机扫码后…→对方扫码后…、等待手机发来→ 等待对方发来、…手机扫码即可→…扫码即可、CLI「手机可能无法访问」→「其它设备可能无法访问」。 英文侧同步。顺手把误名的枚举 sendToPhoneKicker 改名 sendTextKicker。手机端「发送文本到电脑」 本就中性,未动。 --- Sources/LocalShare/ContentView.swift | 2 +- Sources/LocalShare/Lang.swift | 14 +++++++------- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/Sources/LocalShare/ContentView.swift b/Sources/LocalShare/ContentView.swift index 3141bd0..a6ffd3a 100644 --- a/Sources/LocalShare/ContentView.swift +++ b/Sources/LocalShare/ContentView.swift @@ -894,7 +894,7 @@ private struct TextScreen: View { let unchanged = draft == (state.sharedText ?? "") return VStack(alignment: .leading, spacing: 11) { HStack(spacing: 6) { - Text(L.sendToPhoneKicker(lang)).font(.sans(11, .bold)).tracking(0.8).foregroundStyle(t.inkMute) + Text(L.sendTextKicker(lang)).font(.sans(11, .bold)).tracking(0.8).foregroundStyle(t.inkMute) Spacer() if state.hasText { Button { state.setSharedText(nil); draft = "" } label: { diff --git a/Sources/LocalShare/Lang.swift b/Sources/LocalShare/Lang.swift index a54c46c..abb7bb2 100644 --- a/Sources/LocalShare/Lang.swift +++ b/Sources/LocalShare/Lang.swift @@ -148,7 +148,7 @@ enum L: CaseIterable { case shareTextButton, textEditorPlaceholder, textShareAction, textUpdateAction case sharingTextKicker, scanCaptionText, editTextButton // 传递文本二级页(收/发合一) - case transferText, sendToPhoneKicker, scanCaptionTransfer, textIdleHint, retract + case transferText, sendTextKicker, scanCaptionTransfer, textIdleHint, retract case rememberTextTitle, rememberTextDesc, deleteEntry // 传递文本(v2·手机→Mac 收文本) @@ -334,9 +334,9 @@ enum L: CaseIterable { case .scanCaptionText: return ("扫码查看文本 · 同一 Wi-Fi", "Scan to view text · same Wi-Fi") case .editTextButton: return ("编辑文本", "Edit Text") case .transferText: return ("传递文本", "Transfer Text") - case .sendToPhoneKicker: return ("发送给手机", "Send to phone") + case .sendTextKicker: return ("发送文本", "Send text") case .scanCaptionTransfer: return ("扫码读取或发送文本 · 同一 Wi-Fi", "Scan to read or send text · same Wi-Fi") - case .textIdleHint: return ("发送一段文本,或开启接收,手机扫码即可", + case .textIdleHint: return ("发送一段文本,或开启接收,扫码即可", "Send some text or turn on receiving, then scan") case .retract: return ("撤回", "Retract") case .rememberTextTitle: return ("记住分享的文本", "Remember Shared Text") @@ -345,12 +345,12 @@ enum L: CaseIterable { case .deleteEntry: return ("删除", "Delete") case .recvInboxTitle: return ("允许收文本", "Allow Receiving Text") - case .recvInboxDesc: return ("手机扫码后可把一段文本发到这台 Mac", "Phones can send text to this Mac after scanning") + case .recvInboxDesc: return ("对方扫码后可把一段文本发到这台 Mac", "After scanning, the other device can send text to this Mac") case .persistRecvTitle: return ("记住收到的文本", "Remember Received Text") case .persistRecvDesc: return ("重启后保留收件箱内容;关闭则退出即忘", "Keeps the inbox after restart; off forgets it on quit") case .receivedTextsTitle: return ("收到的文本", "Received Text") - case .inboxWaiting: return ("等待手机发来文本…", "Waiting for text from a phone…") + case .inboxWaiting: return ("等待对方发来文本…", "Waiting for text from the other device…") case .scanCaptionSend: return ("扫码把文本发到这台 Mac · 同一 Wi-Fi", "Scan to send text to this Mac · same Wi-Fi") case .clearReceivedConfirm: return ("清空收到的全部文本?", "Clear all received text?") case .copyTextAction: return ("复制", "Copy") @@ -407,8 +407,8 @@ enum L: CaseIterable { case .cliAppNotFound: return ("未找到 LocalShare.app,请先把它放进「应用程序」文件夹。", "LocalShare.app not found. Put it in the Applications folder first.") case .hsEnvMissing: return ("LS_FOLDER / LS_FOLDERS 未设置", "LS_FOLDER / LS_FOLDERS not set") - case .hsNoLan: return ("未发现局域网地址,手机可能无法访问,请确认已连接 WiFi。", - "No LAN address found; phones may be unable to connect. Make sure WiFi is connected.") + case .hsNoLan: return ("未发现局域网地址,其它设备可能无法访问,请确认已连接 WiFi。", + "No LAN address found; other devices may be unable to connect. Make sure WiFi is connected.") case .hsScanHint: return ("同一 WiFi 下扫码访问 · 按 Ctrl-C 停止分享", "Scan to access on the same WiFi · press Ctrl-C to stop") From aba1e885e0102f185839915225aa2b6eb0f594c5 Mon Sep 17 00:00:00 2001 From: shawn Date: Fri, 26 Jun 2026 17:55:37 +0800 Subject: [PATCH 14/17] =?UTF-8?q?feat:=20=E6=89=8B=E6=9C=BA=E5=8F=91?= =?UTF-8?q?=E9=80=81=E9=A1=B5=E5=8A=A0=E3=80=8C=E6=B8=85=E7=A9=BA=E5=B7=B2?= =?UTF-8?q?=E5=8F=91=E9=80=81=E5=8E=86=E5=8F=B2=E3=80=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 「已发送」历史标题行右侧加一个克制的「清空」按钮:点一下清掉本机这份 localStorage 发送记录 (手机自用、从不回传 Mac),整段随即隐藏。主要给「一台设备被多人先后扫码」时主动抹掉自己的 发送痕迹。单击即清、不弹二次确认(本地便利数据、低风险,主场景是递给别人前快速清空)。i18n 加 clearHistory(清空 / Clear)。 --- Sources/LocalShare/Lang.swift | 1 + Sources/LocalShare/SendTextPage.swift | 14 ++++++++++++-- 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/Sources/LocalShare/Lang.swift b/Sources/LocalShare/Lang.swift index abb7bb2..1a7fa6c 100644 --- a/Sources/LocalShare/Lang.swift +++ b/Sources/LocalShare/Lang.swift @@ -636,6 +636,7 @@ enum LStr { ("sendStale", "链接已失效,请重新扫码", "Link expired — rescan the QR code"), ("sendNetwork", "网络错误,请重试", "Network error — try again"), ("sentHistory", "已发送", "Sent"), + ("clearHistory", "清空", "Clear"), ("parseFailed", "解析失败", "Parse failed"), ("parsing", "正在解析…", "Parsing…"), // JSON viewer diff --git a/Sources/LocalShare/SendTextPage.swift b/Sources/LocalShare/SendTextPage.swift index eabaa8f..5177864 100644 --- a/Sources/LocalShare/SendTextPage.swift +++ b/Sources/LocalShare/SendTextPage.swift @@ -53,7 +53,11 @@ enum SendText { /* 已发送历史:浏览器本地留存(localStorage),点一条回填到输入框便于改发/重发。右侧缀紧凑相对时间。 */ .senthist:empty{display:none} .senthist{border-top:1px solid var(--line)} - .sh-title{padding:10px 16px 4px;font:600 11px var(--sans);letter-spacing:.04em;color:var(--inkMute)} + .sh-title{display:flex;align-items:baseline;justify-content:space-between;gap:8px; + padding:10px 16px 4px;font:600 11px var(--sans);letter-spacing:.04em;color:var(--inkMute)} + .sh-clear{flex:none;border:none;background:none;cursor:pointer;letter-spacing:0; + font:600 11px var(--sans);color:var(--inkFaint);padding:2px 2px;-webkit-tap-highlight-color:transparent} + .sh-clear:active{color:var(--danger)} .sh-item{display:flex;align-items:flex-start;gap:10px;padding:8px 16px;cursor:pointer; border-top:1px solid var(--line);font:12px/1.5 var(--mono);color:var(--ink)} .sh-item:first-of-type{border-top:none} @@ -94,7 +98,13 @@ enum SendText { function render(){ if(!hist)return; hist.innerHTML=''; var a=load(); if(!a.length)return; - var h=document.createElement('div');h.className='sh-title';h.textContent=LS_I18N.sentHistory;hist.appendChild(h); + var h=document.createElement('div');h.className='sh-title'; + var lbl=document.createElement('span');lbl.textContent=LS_I18N.sentHistory; + var clr=document.createElement('button');clr.className='sh-clear';clr.type='button';clr.textContent=LS_I18N.clearHistory; + // 清空本机这份「已发送」localStorage(手机自用记录,从不回传 Mac)——清完 render 即空、整段隐藏。 + // 主要给「一台设备被多人先后扫码」时主动抹掉自己的发送痕迹。 + clr.addEventListener('click',function(){try{localStorage.removeItem(KEY)}catch(e){}render();}); + h.appendChild(lbl);h.appendChild(clr);hist.appendChild(h); a.slice(0,20).forEach(function(item){ var it=norm(item); var row=document.createElement('div');row.className='sh-item'; From 8fa33c92fb18376ae6ab1ba38813e3430e8f34a4 Mon Sep 17 00:00:00 2001 From: shawn Date: Fri, 26 Jun 2026 18:05:39 +0800 Subject: [PATCH 15/17] =?UTF-8?q?feat:=20=E4=BC=A0=E9=80=92=E6=96=87?= =?UTF-8?q?=E6=9C=AC=E5=8A=A0=E3=80=8C=E5=81=9C=E6=AD=A2=E3=80=8D=EF=BC=8C?= =?UTF-8?q?=E4=B8=BB=E9=A1=B5=E7=8A=B6=E6=80=81=E8=83=B6=E5=9B=8A=E5=A6=82?= =?UTF-8?q?=E5=AE=9E=E6=98=BE=E8=BF=90=E8=A1=8C=E6=80=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 传递文本此前没有单一的「停止」——要彻底停得手动撤文本 + 关接收,且文本页 ← 是纯导航 (回选择页但服务照跑),于是会出现「服务在跑、主页却显待命」。 - AppState.stopTextTransfer():一步撤文本 + 关接收 + 停服务(轮换 token 作废旧链接)+ 回选择页。 - TextScreen:正在传递(发或收)时出红色「停止」按钮,对齐文件票据的停止动作。 - EmptyScreen:服务在后台续跑时表头改用 StatusPill 如实显运行态(亮点 + 实际 IP:端口), 不再骗「待命」;「传递文本」入口的呼吸点改由 isRunning 驱动(此屏必无文件分享), 分享文本时也亮,不只接收时。 --- Sources/LocalShare/AppState.swift | 14 ++++++++++++++ Sources/LocalShare/ContentView.swift | 22 +++++++++++++++++++--- 2 files changed, 33 insertions(+), 3 deletions(-) diff --git a/Sources/LocalShare/AppState.swift b/Sources/LocalShare/AppState.swift index 6d95b26..7b4f943 100644 --- a/Sources/LocalShare/AppState.swift +++ b/Sources/LocalShare/AppState.swift @@ -327,6 +327,20 @@ final class AppState: ObservableObject { } } + // 停止传递文本:一步彻底结束——撤下文本、关收件箱、停服务、回功能选择页。 + // 区别于编辑器里的「撤回」(只撤文本、保留接收);这是文本场景对齐文件票据「停止」的总开关。 + func stopTextTransfer() { + sharedText = nil + textDraft = "" + if textInboxEnabled { + textInboxEnabled = false + UserDefaults.standard.set(false, forKey: textInboxKey) + } + UserDefaults.standard.removeObject(forKey: sharedTextKey) // 撤下即清,不在磁盘残留口令 + stop() // 停服务:端口归零、token 轮换作废所有旧链接/cookie/二维码 + screen = .share // 回功能选择页(此时无任何分享 → EmptyScreen) + } + // 「记住收到的文本」开关。开:立即把当前收件箱落盘;关:抹掉磁盘留存(内存当次仍在,退出即忘)。 func setPersistReceivedText(_ on: Bool) { guard on != persistReceivedText else { return } diff --git a/Sources/LocalShare/ContentView.swift b/Sources/LocalShare/ContentView.swift index a6ffd3a..39a297f 100644 --- a/Sources/LocalShare/ContentView.swift +++ b/Sources/LocalShare/ContentView.swift @@ -316,15 +316,21 @@ private struct EmptyScreen: View { Text("LocalShare").font(.display(28, .semibold)).tracking(-0.3).foregroundStyle(t.ink) } Spacer() - IdlePill(t: t, label: L.idle(state.lang), port: state.configuredPort) + // 选择页通常是「待命」;但传递文本可在后台续跑(从文本页 ← 退回来时),此刻如实显运行态 + // (亮点 + 实际 IP:端口),不再骗「待命」。要彻底停在文本页点「停止」。 + if state.isRunning { + StatusPill(t: t, running: true, host: state.selectedInterface?.ip, port: state.port) + } else { + IdlePill(t: t, label: L.idle(state.lang), port: state.configuredPort) + } IconButton(t: t, systemImage: "gearshape", help: L.settings(state.lang)) { state.openSettings() } } } content: { VStack(spacing: 0) { dropZone // 平级第二入口:传递文本(收/发合一)。点进独立二级页,主页只负责选功能、不就地干活。 - // 收件箱里有未读时角标提示,免得收到的文本在主页「消失」。 - TransferTextButton(t: t, lang: state.lang, active: state.textInboxEnabled, + // 收件箱有未读时角标提示;传递在后台续跑时缀呼吸点(active=isRunning,此屏必无文件分享)。 + TransferTextButton(t: t, lang: state.lang, active: state.isRunning, unread: state.unreadReceived) { state.openText() } .padding(.top, 12) if state.showRecents { @@ -882,6 +888,16 @@ private struct TextScreen: View { composeCard receiveRow if state.textInboxEnabled || !state.receivedTexts.isEmpty { ReceivedTextsCard(t: t) } + // 正在传递(发文本或收文本)才出「停止」:一步撤文本+关接收+停服务+回选择页, + // 对齐文件票据的「停止」。只是编辑没发、也没开接收时无可停,靠 ← 返回即可。 + if state.isRunning && (state.hasText || state.textInboxEnabled) { + HStack { + Spacer() + DangerButton(t: t, title: L.stop(state.lang)) { state.stopTextTransfer() } + Spacer() + } + .padding(.top, 2) + } } } .onAppear { draft = state.sharedText ?? state.textDraft } From d82d443e88d0947665748d8a3e14b2f81a9b9c54 Mon Sep 17 00:00:00 2001 From: shawn Date: Fri, 26 Jun 2026 18:06:18 +0800 Subject: [PATCH 16/17] =?UTF-8?q?docs:=20PLAN=20=E8=A1=A5=E8=AE=B0?= =?UTF-8?q?=E4=BC=A0=E9=80=92=E6=96=87=E6=9C=AC=20v2.1=20=E6=89=93?= =?UTF-8?q?=E7=A3=A8=E4=B8=B2=EF=BC=88=E5=81=9C=E6=AD=A2/=E6=9C=AA?= =?UTF-8?q?=E8=AF=BB/=E5=90=9E=E5=AD=97/=E6=89=8B=E6=9C=BA=E7=AB=AF/?= =?UTF-8?q?=E5=8E=BB=E6=89=8B=E6=9C=BA=E5=8C=96=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- PLAN.md | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/PLAN.md b/PLAN.md index 3642f87..f98ac16 100644 --- a/PLAN.md +++ b/PLAN.md @@ -256,8 +256,19 @@ open dist/LocalShare.app # 本机自测 接收则退化成纯发送页——**一页一码、两端双向**。二维码(`makeURL`)与 headless URL 一律指 `/ls/text`; 旧 `/ls/send` 保留为 302 跳 `/ls/text` 兼容。「允许收文本」默认关,闸门仍是 `textInboxEnabled`(设置页与 文本页同一开关)。**token 改回会话维度**:`setSharedText` 不再轮换 token(v1/v2 每次更新都换、会把正在看的 - 手机刷掉,还误伤共存的文件分享链接),只在 `setShared`/`stop`/`clearShare` 这些会话边界轮换。 + 对端刷掉,还误伤共存的文件分享链接),只在 `setShared`/`stop`/`clearShare`/`stopTextTransfer` 这些会话边界轮换。 `smoke-text-receive.sh` 改测 `/ls/text` 退化页 + `/ls/send` 302。 +- [x] 传递文本 v2.1 打磨串(PR #26,承接上条): + · **停止机制**:文本页加 `stopTextTransfer()`(撤文本+关接收+停服务+回选择页),对齐文件票据「停止」; + 修掉「服务在后台续跑、主页却显待命」——`EmptyScreen` 表头在 `isRunning` 时改用 `StatusPill` 如实显 + 运行态,「传递文本」入口呼吸点亦由 `isRunning` 驱动(发或收都亮)。文本页 ← 仍为非破坏式返回(可续跑/恢复)。 + · **未读口径**:`recordReceivedText` 仅在 `screen != .text` 时累加未读;未读只走数字角标,「接收开着」改 + 缓慢呼吸红点(`PulsingDot`,动效区分于静态未读),不再用常驻红点冒充未读。 + · **输入法吞字**:`PlainTextEditor.updateNSView` 在 `hasMarkedText()` 时直接返回——服务运行时每 2s 在线人数 + 轮询触发的周期性重渲染会在拼音组合中回写 string 致吞字。 + · **手机端**:发送页「已发送」历史每条缀紧凑相对时间(24h 内 HH:MM / 一年内 MM/DD / 更久 YYYY;存储升级为 + `{t,d}` 带迁移)+ 加「清空」按钮(清本机 localStorage,给共用设备主动抹痕)。 + · **文案去手机化**:对端不限手机(可能是平板/电脑),Mac 端文案「手机」→「对方/设备」,枚举 `sendTextKicker`。 > 已知坑(已规避并注释):Swifter 1.5.0 的 `HttpParser` 会对请求 path 二次编码,导致 `request.path` > 仍残留一层百分号编码 —— FileServer 落地文件系统前已用 `removingPercentEncoding` 解码,且不影响防穿越。 From 9b90b95e59fde308d15511e02a91e737ad734577 Mon Sep 17 00:00:00 2001 From: shawn Date: Fri, 26 Jun 2026 18:39:43 +0800 Subject: [PATCH 17/17] =?UTF-8?q?docs:=20=E4=BF=AE=E6=AD=A3=20clearShare?= =?UTF-8?q?=20=E6=B3=A8=E9=87=8A=E4=B8=AD=E8=BF=87=E6=97=B6=E7=9A=84=20/ls?= =?UTF-8?q?/send=20=E4=B8=BA=20/ls/text?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Sources/LocalShare/AppState.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/LocalShare/AppState.swift b/Sources/LocalShare/AppState.swift index 7b4f943..8e98be1 100644 --- a/Sources/LocalShare/AppState.swift +++ b/Sources/LocalShare/AppState.swift @@ -486,7 +486,7 @@ final class AppState: ObservableObject { func toggle() { isRunning ? stop() : start() } // 清除当前分享:清空选择。收件箱关 → 停服务回到空状态(初始拖拽屏);收件箱开 → 不停服务、转入 - // 「只收文本」模式(换钥匙作废旧分享链接、QR 改指 /ls/send)。历史里仍保留该条,可一键重新分享。 + // 「只收文本」模式(换钥匙作废旧分享链接、QR 改指 /ls/text)。历史里仍保留该条,可一键重新分享。 func clearShare() { sharedItems = [] sharedIsFile = false