diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index f7963df..0195c37 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -30,7 +30,7 @@ jobs: - name: 校验依赖(仅允许内置 @rpath framework,禁止包外 dylib) run: | - # 放宽后的核心戒律:dufs 死于运行时缺失的包外 dylib(/opt/homebrew)。这里逐条核对—— + # 放宽后的核心戒律:不依赖任何包外 dylib——运行时去包外路径(/opt/homebrew 等)找库会缺库。这里逐条核对—— # 系统库放行;@rpath 引用必须对应包内 Contents/Frameworks 里存在的 framework; # 其余绝对路径包外 dylib 一律失败。build.sh 已做同样检查,这里在 CI 再设一道可见闸门。 BIN=dist/LocalShare.app/Contents/MacOS/LocalShare diff --git a/CLAUDE.md b/CLAUDE.md index 5532eed..adb3c7a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,7 +4,9 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co ## 项目本质 -macOS 原生单窗口 app(Swift / SwiftUI):选一个文件夹 → 窗口出现二维码 → 同 WiFi 下的手机扫码即可在浏览器里只读浏览该文件夹(可按需开启访客上传,默认只读)。核心约束是**不依赖任何包外动态库**——dufs 当年死于运行时缺失的 Homebrew dylib(`/opt/homebrew/...liblzma.5.dylib`,换台机器就没)。因此戒律的**精神**是「换任何机器都不会缺库」:系统框架照常链接;纯 Swift 第三方库(Swifter)以 SPM 源码静态编进二进制;**二进制 framework(Sparkle)以 `@rpath` 内置进 `.app/Contents/Frameworks/`、随包走、永不缺失**——这是 0.3 起放宽后的边界。绝对路径包外 dylib(`/opt/homebrew`、`/usr/local`)一律禁止。由来与全部设计决策见 `PLAN.md`(权威交接文档,跟随 git)。 +macOS 原生单窗口 app(Swift / SwiftUI):选一个文件夹 → 窗口出现二维码 → 同 WiFi 下的手机扫码即可在浏览器里只读浏览该文件夹(可按需开启访客上传,默认只读)。核心约束是**不依赖任何包外动态库**——只要可执行文件运行时还得去包外路径(`/opt/homebrew`、`/usr/local`)找 `.dylib`,换台没装那个库的机器就缺库崩溃。因此戒律的**精神**是「换任何机器都不会缺库」:系统框架照常链接;纯 Swift 第三方库(Swifter)以 SPM 源码静态编进二进制;**二进制 framework(Sparkle)以 `@rpath` 内置进 `.app/Contents/Frameworks/`、随包走、永不缺失**——这是 0.3 起放宽后的边界。绝对路径包外 dylib(`/opt/homebrew`、`/usr/local`)一律禁止。 + +> 配套文档:架构全貌、设计决策与实现要点见 `docs/ARCHITECTURE.md`;视觉设计规范(颜色/字体/组件,源码多处按 §x 引用)见根目录 `DESIGN.md`。 ## 常用命令 @@ -36,23 +38,25 @@ otool -L "dist/LocalShare.app/Contents/MacOS/LocalShare" | grep -v "/usr/lib/\|/ ## 架构 -入口在 `App.swift` 的 `@main enum EntryPoint`,三层分流:`LS_HEADLESS=1` 走 `HeadlessServer`(裸起服务)→ `CLI.parse` 命中 argv(`localshare <路径>…` / `--headless`)走 `CLI.run` → 否则跑 `LocalShareApp`(SwiftUI)。三条路径共用同一个 `FileServer`,这是逻辑不分叉的关键。CLI 默认把路径经 `NSWorkspace.open(urls, withApplicationAt:)` 转发给 GUI(`AppDelegate.application(_:open:)` 接收 → `AppState.setShared` 热切换;open 事件早于 `AppState` 构造时缓冲在 `pendingOpenURLs`);`--headless` 则本进程前台起服务并打印终端二维码。`localshare` 命令本体是设置面板安装的 symlink(`CLIInstaller`),指向包内主二进制——dyld 解析 `@executable_path` 前会 realpath,故包内 Sparkle 照常加载;但 CLI 进程内**不可用 `Bundle.main`** 定位 .app(详见 `PLAN.md`「命令行启动」与 `HeadlessServer.runForeground` 上方注释的 release 编译器坑)。 +> 本节是导航图,完整请求流程 / XSS 硬化 / 生命周期 / CLI / headless 等细节见 `docs/ARCHITECTURE.md`。 + +入口在 `App.swift` 的 `@main enum EntryPoint`,三层分流:`LS_HEADLESS=1` 走 `HeadlessServer`(裸起服务)→ `CLI.parse` 命中 argv(`localshare <路径>…` / `--headless`)走 `CLI.run` → 否则跑 `LocalShareApp`(SwiftUI)。三条路径共用同一个 `FileServer`,这是逻辑不分叉的关键。CLI 默认把路径经 `NSWorkspace.open(urls, withApplicationAt:)` 转发给 GUI(`AppDelegate.application(_:open:)` 接收 → `AppState.setShared` 热切换;open 事件早于 `AppState` 构造时缓冲在 `pendingOpenURLs`);`--headless` 则本进程前台起服务并打印终端二维码。`localshare` 命令本体是设置面板安装的 symlink(`CLIInstaller`),指向包内主二进制——dyld 解析 `@executable_path` 前会 realpath,故包内 Sparkle 照常加载;但 CLI 进程内**不可用 `Bundle.main`** 定位 .app(详见 `docs/ARCHITECTURE.md` 的「App 入口 / Headless / CLI」一节与 `HeadlessServer.runForeground` 上方注释的 release 编译器坑)。 -数据流单向:`AppState`(`@MainActor ObservableObject`,唯一真相源)持有 `FileServer`、网络候选、派生出 `primaryURL` → `qrImage`;`ContentView` 只读渲染。真相源是 `sharedItems: [URL]`(0=空、1=单项、N=多选),派生 `isMultiple`/`isEmpty`/`sharedURL`(首项便利)。`AppState` 负责生命周期——init 时从 `UserDefaults` 恢复上次分享(多选存 `lastSharedPaths` 数组、旧单值键 `lastFolderPath` 作迁移回退,缺失项剔除)并**自动 start**(同事开 app 即见码);选文件/夹用 `NSOpenPanel`(`allowsMultipleSelection = true`),拖拽收齐所有 provider 后一次提交。`RecentShare` 用 `paths: [String]` 记录多选、自定义 `init(from:)` 兼容旧单 `path` 记录。 +数据流单向:`AppState`(`@MainActor ObservableObject`,唯一真相源)持有 `FileServer`、网络候选、派生出 `primaryURL` → `qrImage`;`ContentView` 只读渲染。真相源是 `sharedItems: [URL]`(0=空、1=单项、N=多选),派生 `isMultiple`/`isEmpty`/`sharedURL`(首项便利)。`AppState` 负责生命周期——**冷启动不自动重播**:落主页、不自动起文件服务(开 app 把文件夹悄悄端上 LAN 是隐患,与文本「重启不自动重播」同姿态),上次分享留「最近分享」一键重发;只关窗不退出时进程/服务续活、唤回即回原状。选文件/夹用 `NSOpenPanel`(`allowsMultipleSelection = true`),拖拽收齐所有 provider 后一次提交;`RecentShare` 用 `paths: [String]` 记录多选、自定义 `init(from:)` 兼容旧单 `path` 记录。 -`FileServer` 是核心,全部请求逻辑塞在**单个 Swifter middleware 闭包**里(永远返回 response,绕开 router)。`Share` 三态 `.directory` / `.file` / `.multiple([Item])`。请求先过 ① token 鉴权(`?t=` 或 cookie `ls_token`,靠 query 放行时种会话 cookie,浏览器导航(Accept 含 `text/html`)随即 302 到去掉 `?t=` 的干净 URL(token 不留地址栏/历史;curl 与 `*/*` 子请求不触发,测 `smoke-token-302.sh`);**token 随每次「分享」动作轮换**——`setShared`/`stop` 即作废旧链接与旧 cookie,权限不跨分享延续),鉴权后顺手按客户端 IP 记 lastSeen 并 best-effort 后台反查设备名(`getnameinfo`,缓存 `nameCache`、串行队列、对端自报名清洗去控制/RTL 码点、随 token 轮换清零)(在线感知:45s 窗口内活跃 IP 即「N 人正在浏览」;GUI 由 `AppState` 2s 轮询展示在线情况(摘要行:反查到设备名才领衔具名(单台直呼其名、多台「领衔 + 等 N 人」),查不到统一「N 人正在浏览」、**不在摘要露 IP**;点摘要行弹 `ViewerListPopover` 列出全部在线访客——查到名字显名、查不到显**完整 IP**,并各带本次会话开始时长(`ViewerInfo.since`,源自 `firstSeen`);`activeViewerInfos`→`ViewerInfo`(`fullLabel` 供列表));`/ls/ping` 为保留心跳路径、先于分享内容命中,listing 页 JS 每 15s 打一次、**只回人数不外泄设备名**),再按形态分流:单文件直接发那一个文件、不暴露同目录其它;单文件夹与多选里的每个目录项共用 `serveTree(rootURL:relPath:…)`——② 防目录穿越(每项各自为根)→ ③ 目录(无斜杠先 301、有 `index.html` 发它、否则 `DirectoryListing` 列表页)→ ④ 文件 64KB 分块流式;例外是 `.md(.markdown)`/`.json(.geojson)`/`.csv(.tsv)` 的**浏览器导航**(Accept 含 `text/html` 且无 `?raw=1`)发预览壳页——壳页与文件**同 URL**(正文相对引用 `assets/x.png`、相邻 md 链接靠浏览器解析直接命中常规服务,勿改成 `/ls/preview` 之类保留路径),客户端渲染(md 用内嵌 vendored marked、原始 HTML 转义不执行 + 链接/图片协议白名单挡 `javascript:`(`MarkdownViewer.rendererConfig`,测 `smoke-md-link-sanitize.cjs` 跑真实 marked);json 折叠树、csv 表格为手写零依赖,大数据靠懒构建/分档渲染),新增预览类型在 `FileServer.previewHTML` 登记;curl/壳页取文 fetch(Accept `*/*`)与 `?raw=1` 一律拿原文。**多选**无共同磁盘根,合成**虚拟根**:空路径发 `DirectoryListing.html(items:rootName:)` 列出选中项,其余请求拆**首段 `key`** 映射到真实 URL(`Share.makeItems` 以 lastPathComponent 为 key、跨目录拖拽重名以 `-2` 兜底);未知 key 或文件项带子路径 → 404。**访客上传**(`Permission.add`,0.6):`uploadEnabled` 开关(与 share 同锁,仅单文件夹分享可开、换分享自动回只读),POST multipart 写到当前浏览目录——落点过同一套防穿越校验、文件名只取末段清洗、重名 -2、临时文件原子换名、500MB 上限 413;可执行文档扩展名(html/svg 家族,`executableDocExtensions`)落地追加 `.txt` 去势——堵「上传 index.html 顶替目录页致零点击存储型 XSS」(只作用于上传,磁盘自带静态站点不受影响;另全站响应加 `X-Content-Type-Options: nosniff`,回归测试 `tools/smoke-upload-defang.sh`);落地后打 `com.apple.quarantine`(`markQuarantine`,分享者双击触发 Gatekeeper)(注意 Swifter 进 middleware 前已把 body 整段读进内存,上限只能事后拒绝;分片上传留给 v1.5);`onUpload` 回调在 socket 线程,GUI hop 回 MainActor 出「新收到」卡片。网页措辞(kicker/colophon)经 `permSummary` 派生,绝不硬编码。**仅当前网络可见**:`FileServer.listenAddress` 非 nil 时只绑该块网卡的 IPv4(Swifter 原生 `listenAddressIPv4`,无须 fork),由 `AppState.bindSelectedOnly` 驱动(切网卡/开关不轮换 token 地重绑、IP 消失回退全接口、`inet_pton` 非法即抛错不静默绑全接口);默认 nil=绑 `0.0.0.0`。Headless 测多选用 `LS_FOLDERS`(`:`/换行分隔),开上传加 `LS_UPLOAD=1`,指定绑定网卡加 `LS_BIND=`。 +`FileServer` 是核心,全部请求逻辑塞在**单个 Swifter middleware 闭包**里(永远返回 response,绕开 router)。`Share` 三态 `.directory` / `.file` / `.multiple([Item])`。请求先过 ① **token 鉴权**(`?t=` 或 cookie `ls_token`;浏览器导航靠 query 放行后种会话 cookie 并 302 到去掉 `?t=` 的干净 URL,curl 与 `*/*` 子请求不触发;**token 随每次「分享」动作轮换**,`setShared`/`stop` 即作废旧链接/cookie,权限不跨分享延续),鉴权后按客户端 IP 记 `lastSeen` + 后台 best-effort 反查设备名(在线感知:45s 窗口内活跃 IP 即「N 人正在浏览」,`/ls/ping` 心跳路径只回人数、不外泄设备名;GUI 由 `AppState` 2s 轮询展示,摘要不露 IP、点开弹列表)。再按形态分流:单文件直发那一个、不暴露同目录其它;单文件夹与多选目录项共用 `serveTree`——② **防穿越**(每项各自为根)→ ③ 目录(无斜杠 301 / 有 `index.html` 发它 / 否则 `DirectoryListing` 列表页)→ ④ 文件 64KB 分块流式。例外:`.md`/`.json`/`.csv` 的**浏览器导航**发预览壳页——壳页与文件**同 URL**(正文相对引用靠浏览器解析命中常规服务,勿改成保留路径),客户端渲染(md 用内嵌 vendored marked + 链接/图片协议白名单挡 `javascript:`(`MarkdownViewer.rendererConfig`)、json/csv 手写零依赖);`?raw=1`/curl(Accept `*/*`)一律拿原文。**多选**无共同磁盘根,合成**虚拟根**:空路径列出选中项,其余拆**首段 `key`** 映射真实 URL(`Share.makeItems` 以 lastPathComponent 为 key、重名 `-2` 兜底);未知 key / 文件项带子路径 → 404。**访客上传**(`Permission.add`,0.6):`uploadEnabled` 开关(与 share 同锁,仅单文件夹分享可开、换分享自动回只读),POST multipart 写当前目录——过同一套防穿越 + 文件名只取末段清洗 + 重名 `-2` + 临时文件原子换名 + 500MB 413;可执行文档扩展名(html/svg 家族,`executableDocExtensions`)落地追加 `.txt` 去势(堵「上传 index.html 顶替目录页致零点击存储型 XSS」,只作用于上传、磁盘自带静态站点不受影响)+ 全站 `X-Content-Type-Options: nosniff` + 落地打 `com.apple.quarantine`;注意 Swifter 进 middleware 前已把 body 整段读进内存,上限只能事后拒绝。**仅当前网络可见**:`FileServer.listenAddress` 非 nil 时只绑该网卡 IPv4(Swifter 原生 `listenAddressIPv4`,由 `AppState.bindSelectedOnly` 驱动、IP 消失回退全接口、`inet_pton` 非法即抛错),默认 nil=绑 `0.0.0.0`。Headless 环境变量(`LS_FOLDERS`/`LS_UPLOAD`/`LS_BIND`/`LS_TEXT` 等)与完整 XSS 硬化见 `docs/ARCHITECTURE.md` §3。 -辅助模块各自单一职责:`NetworkInfo`(`getifaddrs` 枚举 → 只留私网 IPv4、过滤 VPN/bridge/回环、en0 优先排序)、`QRCode`(CoreImage `CIQRCodeGenerator`)、`Token`、`Mime`(text 类带 `charset=utf-8`)、`DirectoryListing`(移动端友好 HTML,href 逐段编码、隐藏文件不列)、`PreviewPage`(预览壳页共用骨架:tokens/刊头/取景框/心跳)+ `MarkdownViewer`/`JsonViewer`/`CsvViewer`(三类预览内容卡,面包屑复用 `DirectoryListing.breadcrumb`)、`MarkedJS`(vendored marked,Swift 字符串常量编进二进制而非资源 bundle——三条启动路径都无须定位资源文件,升级整文件替换)、`Lang`/`L`/`LStr`(i18n 中英文案表,同样编进二进制:静态文案走 `L`(枚举键,编译器强制穷尽)、带插值/复数/语序差异走 `LStr`、网页 JS 侧拼接走 `LStr.i18nJSON`;**两个解析域彼此独立**——原生 app 语言跟设置 `AppState.langPref`(跟随系统/中文/English),网页**逐请求**跟浏览器 `Accept-Language`(`q` 值降序、`q=0` 跳过),绝不读 app 设置,详见 `Lang.swift` 与 PLAN.md §3「国际化」)。 +辅助模块各自单一职责:`NetworkInfo`(`getifaddrs` 枚举 → 只留私网 IPv4、过滤 VPN/bridge/回环、en0 优先排序)、`QRCode`(CoreImage `CIQRCodeGenerator`)、`Token`、`Mime`(text 类带 `charset=utf-8`)、`DirectoryListing`(移动端友好 HTML,href 逐段编码、隐藏文件不列)、`PreviewPage`(预览壳页共用骨架)+ `MarkdownViewer`/`JsonViewer`/`CsvViewer`(三类预览内容卡,面包屑复用 `DirectoryListing.breadcrumb`)、`MarkedJS`(vendored marked,Swift 字符串常量编进二进制而非资源 bundle——三条启动路径都无须定位资源文件,升级整文件替换)、`Lang`/`L`/`LStr`(i18n 中英文案表,同样编进二进制:静态文案走 `L`(枚举键,编译器强制穷尽)、带插值/复数/语序差异走 `LStr`、网页 JS 侧拼接走 `LStr.i18nJSON`;**两个解析域彼此独立**——原生 app 语言跟设置 `AppState.langPref`(跟随系统/中文/English),网页**逐请求**跟浏览器 `Accept-Language`(`q` 值降序、`q=0` 跳过),绝不读 app 设置,详见 `Lang.swift` 与 `docs/ARCHITECTURE.md` §3「国际化」)。 -`Updater.swift` 封装 Sparkle 自动更新:`UpdaterController` 持 `SPUStandardUpdaterController`,仅 GUI 路径(`LocalShareApp`)构造,headless 完全不碰。配置全在 `bundle/Info.plist`(`SUFeedURL` / `SUPublicEDKey` / `SUEnableAutomaticChecks`)。信任链走 EdDSA(私钥签更新包、app 内嵌公钥校验),与 ad-hoc 代码签名无关,故未公证也能安全自更新;`Info.plist` 的 `SUPublicEDKey` 还是占位值时 `UpdaterController` 不启动 updater。CI 发布时用 `sign_update` 签 DMG,`appcast.xml` 作为 Release 资产上传(feed 走 `releases/latest/download` 固定地址,发布对仓库零写入)。详见 `PLAN.md` 的「自动更新」一节。 +`Updater.swift` 封装 Sparkle 自动更新:`UpdaterController` 持 `SPUStandardUpdaterController`,仅 GUI 路径(`LocalShareApp`)构造,headless 完全不碰。配置全在 `bundle/Info.plist`(`SUFeedURL` / `SUPublicEDKey` / `SUEnableAutomaticChecks`)。信任链走 EdDSA(私钥签更新包、app 内嵌公钥校验),与 ad-hoc 代码签名无关,故未公证也能安全自更新;`Info.plist` 的 `SUPublicEDKey` 还是占位值时 `UpdaterController` 不启动 updater。CI 发布时用 `sign_update` 签 DMG,`appcast.xml` 作为 Release 资产上传(feed 走 `releases/latest/download` 固定地址,发布对仓库零写入)。详见 `docs/ARCHITECTURE.md` 的「自动更新」一节。 ## 发布与版本 -版本号以 git tag 为准(`vX.Y.Z`,打在 master tip 上):`bundle/Info.plist` 里的 `CFBundleShortVersionString` 只是占位值,CI 构建时用 tag 版本覆写、`CFBundleVersion` 写 run number,这个改写不会提交回 master——所以本地构建显示旧版本号是正常的,**发版不需要改 Info.plist**。发布步骤:变更全部合入 master → changelog 写进 annotated tag 的注释(`git tag -a vX.Y.Z -F notes.md`)→ 推 tag,`.github/workflows/release.yml`(监听 `v*`)接手:编译 universal → 依赖校验 → 打 DMG → 建 GitHub Release → EdDSA 签名 + 生成 `appcast.xml` 作为 Release 资产上传。appcast 不进 git:Sparkle feed 是 `https://github.com/rrbe/LocalShare/releases/latest/download/appcast.xml`(GitHub 保证恒指向最新 release 的同名资产),发布对仓库零写入,与 master 的「必须走 PR」规则互不相干。一次性迁移已随 v0.7.0 完成:仓库根的 `appcast.xml` 最后一次更新到 0.7.0 后冻结,只服务 ≤0.6.0 老客户端(其 feed 指向 raw master),它们升到 0.7.0 即转入新 feed;确认无老客户端存量后此文件可删。workflow 跑的是 tag 指向那个提交里的文件,所以改 release.yml 要先合入 master 再打 tag。changelog 写法:tag 注释里只写 `-` 列表,一个功能一条、用面向用户的说法、重要的放前面;不要写 `#` 标题行(`git tag` 默认会把 `#` 开头的行当注释删掉,「更新内容」这个标题由 workflow 加);范围用 `git log v上一版..origin/master --oneline` 圈定。Release 正文由 workflow 拼成:版本简介 + 更新内容(tag 注释)+ 固定安装说明。 +版本号以 git tag 为准(`vX.Y.Z`,打在 master tip 上):`bundle/Info.plist` 里的 `CFBundleShortVersionString` 只是占位值,CI 构建时用 tag 版本覆写、`CFBundleVersion` 写 run number,这个改写不会提交回 master——所以本地构建显示旧版本号是正常的,**发版不需要改 Info.plist**。发布步骤:变更全部合入 master → changelog 写进 annotated tag 的注释(`git tag -a vX.Y.Z -F notes.md`)→ 推 tag,`.github/workflows/release.yml`(监听 `v*`)接手:编译 universal → 依赖校验 → 打 DMG → 建 GitHub Release → EdDSA 签名 + 生成 `appcast.xml` 作为 Release 资产上传。appcast 不进 git:Sparkle feed 是 `https://github.com/rrbe/LocalShare/releases/latest/download/appcast.xml`(GitHub 保证恒指向最新 release 的同名资产),发布对仓库零写入,与 master 的「必须走 PR」规则互不相干。workflow 跑的是 tag 指向那个提交里的文件,所以改 release.yml 要先合入 master 再打 tag。changelog 写法:tag 注释里只写 `-` 列表,一个功能一条、用面向用户的说法、重要的放前面;不要写 `#` 标题行(`git tag` 默认会把 `#` 开头的行当注释删掉,「更新内容」这个标题由 workflow 加);范围用 `git log v上一版..origin/master --oneline` 圈定。Release 正文由 workflow 拼成:版本简介 + 更新内容(tag 注释)+ 固定安装说明。 ## 跨文件的关键约束(改动前必读) -- **不依赖包外 dylib(戒律的精神,不可破)**:判据是「换任何机器都不会缺库」。纯 Swift 依赖优先以 SPM 源码静态编进二进制;确需二进制 framework(如 Sparkle)时,必须 `@rpath` 引用并由 `build.sh` 内置进 `Contents/Frameworks/`、深度签名、且通过依赖校验(见 build.sh 末尾与 CI)。**绝对路径包外 dylib(`/opt/homebrew`、`/usr/local` 等)一律禁止**——这正是 dufs 当年崩在运行时缺 `liblzma.5.dylib` 的坑。新增/改依赖后务必跑上面的 `otool` 复核 + 确认 framework 已随包。 +- **不依赖包外 dylib(戒律的精神,不可破)**:判据是「换任何机器都不会缺库」。纯 Swift 依赖优先以 SPM 源码静态编进二进制;确需二进制 framework(如 Sparkle)时,必须 `@rpath` 引用并由 `build.sh` 内置进 `Contents/Frameworks/`、深度签名、且通过依赖校验(见 build.sh 末尾与 CI)。**绝对路径包外 dylib(`/opt/homebrew`、`/usr/local` 等)一律禁止**——这类运行时缺库正是「换台机器就崩」的根源。新增/改依赖后务必跑上面的 `otool` 复核 + 确认 framework 已随包。 - **Swifter 1.5.0 的 path 二次编码 bug**:`req.path` 落地文件系统前**仍残留一层百分号编码**,必须 `removingPercentEncoding` 解码(见 `FileServer.handle`)。纯 ASCII 路径无 `%` 故 `a.html` 正常,但 `b%20c.txt`、中文名不解码会 404。防穿越用的也是解码后的路径,所以 `%2e%2e` 同样被挡。 - **防穿越逻辑**(`FileServer.handle` 第 2 步):拼接后 `standardizedFileURL.resolvingSymlinksInPath`,结果必须 `== rootPath` 或 `hasPrefix(rootPath + "/")`。动这段务必重跑穿越用例(`../`、`%2e%2e`、`..%2f`)。 - **线程模型**:Swifter 请求回调跑在后台 socket 线程,`AppState` 在 `@MainActor`。两者共享的可变状态是 `FileServer` 的 `share` / `token` / `uploadEnabled` / `lastSeen` / `nameCache` / `nameLookupInFlight`(运行中「更换分享」会改前两者;设备名反查的缓存与在查 IP 集同锁保护),同一把 `NSLock` 保护——换分享时**不重启 server**(端口不变),但 **token 即刻轮换**:先换钥匙再换内容(杜绝旧 token 瞬间可读新分享),旧链接/cookie 作废、访客需重扫新码。Package 用 Swift 5 语言模式正是为放宽这里的并发检查。 diff --git a/DESIGN.md b/DESIGN.md index 9e2b35e..9b73f5c 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -192,11 +192,43 @@ - **在线访客设备名**:分享屏地址下方的在线行不再裸显人数(见 §5.2)——小绿点 + 「<设备名> 正在浏览」/「<设备名> 等 N 人正在浏览」(11.5 sans inkMute),查不到设备名回退「…IP 尾号」,`lineLimit(1)` 末端截断。 - **明文风险提示**(两处,克制灰字 inkMute + `lock.open` 图标):① 设置页「访问权限」区下一行;② 分享屏底部「连不上?」气泡末尾、隔离线下另起一行。措辞统一「同一网络下传输不加密 · 公共 Wi-Fi(咖啡馆 / 机场等)下同网的人可能看到内容,敏感文件别在这种网络分享」。**不进彩底警告框**——克制告知、不吓人。 +### 6.7 传递文本:发 / 收(0.9)★ + +`Screen.text`,带返回二级页(头部 `← + 「传递文本」+ ⚙`,品牌名只留主页)。两条**独立单向通道**——发出去(Mac→手机)的 `sharedText` 与收回来(手机→Mac)的收件箱互不相通;一页一码、两端双向。 + +屏内区块(从上到下): + +1. **二维码卡 / 空态**:服务运行且有码 → QRCard(172×172,内距 18,圆角 14)+ 说明(sans 13 semibold) + CopyPill(mono 13,§5.6) + 备用地址(mono 10);否则空态提示「发送一段文本,或开启接收,扫码即可」。 +2. **发文本卡**(`surface` 圆角 16、内距 16、`line` 1px 描边):小节眼标「发送文本」(sans 11 bold,字距 0.8,inkMute) → `PlainTextEditor`(`field` 底圆角 12,**mono 13**,最小高 118,自绘 placeholder 兼容中文输入法,光标 accent) → `PrimaryBtn`「分享」(已分享后文案变「更新」,右侧可现「撤回」GhostBtn)。 +3. **允许收文本行**(`surface` 圆角 14、内距 14):标题「允许收文本」+ 说明「对方扫码后可把一段文本发到这台 Mac」(sans 12.5 inkMute) + 右侧 Switch(§5.3)。**opt-in,默认关**。 +4. **收件箱卡**(仅 `textInboxEnabled` 或已有收件时出现,`surface` 圆角 14):头行 = 状态圆点(accent) + 「收到的文本」(sans 11 bold) + 未读角标(Capsule,accent 底白字 sans 10 bold,「N 条新」)+「清空」;列表最多显 12 条、超出显条数。单行 = TextGlyph(26,accentSoft 底) + 来源设备名/IP(sans 11.5 semibold) ·收到时长(mono 10.5 inkFaint) + 文本预览(mono 11.5,≤3 行,可选中) + 复制(成功 ✓ 1.3s)/删除 ✕。 +5. **停止传递**:`DangerBtn`「停止」,仅 `isRunning && (有文本 ‖ 收件开)` 时出现。 + +关键行为(务必守): + +- **token = 会话维度**:更新文本(`setSharedText`)**不轮换 token**(否则把正在看的对端刷掉、还误伤共存的文件分享链接);只在换分享 / 停止 / 清除这些会话边界才轮换。 +- **接收双上限挡内存**:单条 ≤ 64KB(事后 413)+ 收件箱 ≤ 100 条挤旧。**仅应用内提醒**(未读角标 / 「新收到」卡片),不发系统通知。 +- **接收呼吸点**:「传递文本」入口与运行态用 `PulsingDot`(size 6,缓慢淡入淡出,发或收都亮)区别于静态未读。 +- **停止 `stopTextTransfer`**:撤文本 + 清草稿 + 关接收 + 删持久化(不在磁盘留口令)+ 停服务(轮换 token 作废链接)+ 回主页。 +- **持久化两开关各默认关**(「记住分享的文本」/「记住收到的文本」,语义不同:发的是自己粘的、收的是他人投递的)。开「记住分享」只回填草稿、**不自动广播**。 +- 网页端:发文本页纯文本转义显示 + 大「复制」(`execCommand` 回退——纯 http 非安全上下文) + http(s) 自动链接;收文本表单与访客上传表单**同处、同条件**出现。 + +### 6.8 语言切换 / 国际化(i18n,0.9)★ + +设置页「语言」= 三分段控件(与「外观」同款 `…Seg` 结构):`跟随系统 / 中文 / English`。圆角 9、高 34、间距 8、均分宽;选中 = accent 底 + 白字(sans 13 semibold)、无描边,未选 = `surface` 底 + `ink` 字 + `line` 描边(sans 13 medium)。**语言用本族文字呈现**(「中文」「English」不翻译),「跟随系统」随当前语言显示。绑定 `AppState.langPref`(system / zh / en,持久化)。 + +**两个解析域彼此独立**(务必守): + +- 原生 app —— 跟设置 `langPref`(`AppState.lang` / `Lang.current` 快照供菜单、CLI 读取)。 +- 网页 —— **逐请求**按浏览器 `Accept-Language`(`q` 值降序、`q=0` 跳过),**绝不读 app 设置**。同一台 Mac 分享:桌面界面可中文、手机按自己系统语言显示。 + +文案三类:静态走 `L`(枚举键,编译器强制穷尽)、插值 / 复数 / 语序差异走 `LStr`、网页 JS 侧拼接走 `i18nJSON`(`jsEscape` 把 `<` 转义挡 ``)。加语言只需在 `L`/`LStr` 各补一支。 + --- ## 7. 文案规范 Copywriting -- 全中文,简短克制;分隔统一用「 · 」(空格+中点+空格)。 +- 中英双语(简体中文基准 + English,全部经 `Lang.swift` 文案表,见 §6.8);简短克制,分隔统一用「 · 」(空格+中点+空格)。 - 技术值(IP、端口、路径、体积、项数、日期)一律 mono。 - 危险操作动词直白:「停止」。正向:「选择文件或文件夹 / 重新广播 / 重新分享」。 - 权限措辞跟随状态:「只读分享 / 可读写」,不要写「读写模式开启」这类机翻腔。 diff --git a/PLAN.md b/PLAN.md deleted file mode 100644 index d794705..0000000 --- a/PLAN.md +++ /dev/null @@ -1,375 +0,0 @@ -# LocalShare · 设计与实现计划(PLAN) - -> 一个 macOS 原生单窗口 app:不懂技术的同事打开它、选一个文件夹,窗口中央出现二维码, -> 用 iPhone 扫一下就能在 Safari 里浏览/打开那个文件夹里的 html 等文件。 -> 全程不碰终端、不装 Homebrew、不依赖任何动态库。 - -本文件是**可移植的交接文档**——跟随 git 走,任何机器 `git pull` 后都能据此继续。 - ---- - -## 0. 背景与核心戒律 - -- 起因:要让小白同事在手机上看电脑里的 html。iOS Safari 不能直接开本地文件,AirDrop 过去也打不开,必须有一个**本机进程监听端口**对外提供 HTTP。 -- 纯浏览器技术栈做不到(无法 listen 端口 / 接受入站连接),所以必须是原生 app。 -- **核心戒律**:dufs 的崩溃根因是 arm64 二进制**运行时缺失动态库**(动态链接了 Homebrew 的 `liblzma.5.dylib`,指向 `/opt/homebrew/...`,换台机器就没)。因此戒律的精神是**「换任何机器都不会缺库」**:只链接系统框架;纯 Swift 第三方库以 SPM 源码静态编进 app。Swift/SwiftUI + SPM 源码依赖天然满足。 - - **0.3 放宽**:自动更新需要 Sparkle,而 Sparkle 只以二进制 framework 分发(含动态库 + XPC 服务 + helper app),无法源码静态编进。结论:**允许把二进制 framework 以 `@rpath` 内置进 `.app/Contents/Frameworks/`**——它随包走、自包含、运行时永不缺失,完全规避 dufs 那种「包外缺库」的失败模式。判据据此从「零第三方 dylib」收紧为更准确的一条:**禁止任何位于 `.app` 包外的 dylib(绝对路径如 `/opt/homebrew`、`/usr/local` 一律禁止);只接受 `@rpath` 引用、且已验证存在于 `Contents/Frameworks/` 的内置 framework**。`build.sh` 与 CI 均按此逐条校验(见下)。 - ---- - -## 1. 已锁定的设计决策 - -| 维度 | 决策 | -|---|---| -| 平台 | 仅 macOS(Apple Silicon 为主),原生 `.app` | -| 网络 | 仅同一 WiFi(LAN),无隧道/无公网/无账号 | -| 技术栈 | Swift / SwiftUI,只链接系统框架,零 dylib 风险 | -| HTTP 服务 | Swifter(SPM 源码编译进 app),只读静态服务 | -| 服务模型 | 三种分享形态:① 单文件夹 → 移动端友好目录列表(含 `index.html` 则直接显示它);② 单文件 → 扫码直接打开、不暴露同目录其它文件;③ 多文件/目录 → 合成**虚拟根**列出这批选中项,首段路径映射到对应真实 URL、再落到该项内部。三者**均防目录穿越**,每个目录项各自为根锁死路径 | -| 鉴权 | 每次「分享」动作生成随机 token(0.7 起;换分享/停止即轮换,旧链接、旧 cookie、拍走的旧二维码即刻作废,权限不跨分享延续),内嵌进二维码 URL(`?t=…`);首访校验后种会话 cookie,后续资源自动放行;猜 `IP:端口` 的路人被 403 | -| 协议 | 明文 http。威胁模型:防「猜地址的路人」(52 bit token),不防同网嗅探与持链者转发——后者的风险窗口随 token 轮换收敛到单次分享内。自签证书会把扫码进门变成手机上的证书警告页,伤害核心体验,不做(0.6 加入上传后内容不再纯静态,重新评估过,结论不变) | -| 二维码地址 | 裸 LAN IP(智能选接口、多候选给下拉);窗口另显 `.local` 备选链接 + 可复制 URL | -| 二维码生成 | CoreImage `CIQRCodeGenerator`,无第三方库 | -| GUI | 单窗口:**功能主页**(拖拽/选择分享文件 + 传递文本入口 + 最近分享)→ 文件票据 / 传递文本 / 设置 / 历史**均为带返回的二级页**;票据含大二维码 + 可点/复制 URL + 在线人数 + 启停 + 接口下拉 + “打不开?”排错行 | -| 生命周期 | 上次分享记入「最近分享」(冷启动**不**自动重播、开 app 落主页,安全对齐文本「重启不自动重播」)· 关窗不退出(进程/服务续活、菜单栏唤回即回原状)· 端口自动选(占用则换)· 退出才停服务 | -| 容错 | 检测无 WiFi/无 IP 并提示 · 首启引导点防火墙“允许” · 常驻排错提示 · 空文件夹友好态 | -| 分发 | Xcode ad-hoc 签名 · 你首次帮同事过一次 Gatekeeper(放行被持久记住) | -| 自动更新 | Sparkle(二进制 framework,`@rpath` 内置进 `Contents/Frameworks/`)· 自动后台检查、发现新版弹提示由用户确认 · 信任链走 **EdDSA 签名**(与 ad-hoc 代码签名无关,故未公证也安全)· appcast 作为 GitHub Release 资产上传,feed 走 `releases/latest/download/appcast.xml` 固定地址,**发布对仓库零写入**(仓库根 `appcast.xml` 已于 0.7.0 冻结,仅供老客户端迁移) | -| 沙盒 | **不开 App Sandbox**(内部手发、不上 App Store),省掉沙盒对“读任意文件夹”的限制 | -| 国际化 | 简体中文 + English 双语(0.9)。文案编进二进制(`Lang.swift`,不依赖资源 bundle,同 MarkedJS 思路);**两个解析域彼此独立**——原生 app 跟设置(跟随系统 / 中文 / English),网页**逐请求**按浏览器 `Accept-Language` 协商、绝不读 app 设置。加语言只需在 `L`/`LStr` 各补一支 | - ---- - -## 2. 工程结构 - -``` -lan-file-share/ - Package.swift # swift-tools-version:5.9(Swift 5 语言模式,放宽并发检查) - Package.resolved # Swifter pin 在 1.5.0 - PLAN.md # 本文件 - README.md - build.sh # swift build -c release → 组装 .app → ad-hoc 签名 → 输出 dist/ - bundle/Info.plist # .app 的静态 Info.plist 模板(build.sh 拷入) - .gitignore # .build/ dist/ *.app .DS_Store - Sources/LocalShare/ - App.swift # @main enum EntryPoint:分流 GUI/headless;含 LocalShareApp 与 AppDelegate - AppState.swift # ObservableObject:folder/server/urls/status/candidates,启停、选目录、持久化 - ContentView.swift # 单窗口 SwiftUI:二维码 / URL / 文件夹 / 接口下拉 / 排错 - FileServer.swift # Swifter 封装:token 中间件 + 防穿越 + index.html + MIME 流式 - DirectoryListing.swift # 目录列表页 HTML 生成(viewport、简洁 CSS、逐段编码的绝对 href) - NetworkInfo.swift # getifaddrs 枚举接口 → 私网 IPv4 候选;.local 主机名 - QRCode.swift # CoreImage 生成 QR → NSImage - Token.swift # 随机 url-safe token - Mime.swift # 扩展名 → MIME 映射(text 类带 charset=utf-8) - Lang.swift # i18n:编进二进制的中英文案表(L/LStr/i18nJSON);app 跟设置、网页跟 Accept-Language - HeadlessServer.swift # LS_HEADLESS=1 无界面模式(测试/自动化用)+ CLI 前台模式 - CLI.swift # 命令行入口:argv 解析、转发 GUI(NSWorkspace)、--headless 分流 - CLIInstaller.swift # /usr/local/bin/localshare symlink 的检测/安装(含 osascript 提权) - Updater.swift # Sparkle 自动更新封装(仅 GUI 构造,headless 不碰) -``` - ---- - -## 3. 关键实现要点(含已核对的 Swifter 1.5.0 API) - -### Swifter API 事实(已核对源码,避免凭记忆踩坑) -- `HttpServer.middleware: [(HttpRequest) -> HttpResponse?]`——返回非 nil 即短路;我们把**全部逻辑放进一个 middleware 闭包**,永远返回 response,绕开 router。 -- `HttpResponse.raw(Int code, String reason, [String:String]? headers, ((HttpResponseBodyWriter) throws -> Void)? writer)`——用它控制状态码 / 自定义头(Set-Cookie、Content-Type、Content-Length)/ 流式写文件。 -- `HttpResponseBodyWriter.write(_ data: Data)`——按块写文件。 -- ⚠️ `request.path` **不是干净解码的**:Swifter 的 `HttpParser` 先把请求目标用 `.urlQueryAllowed` 再编码一次(把已有的 `%` 变成 `%25`),再取 `URLComponents.path` 只解一层,**净结果是 `request.path` 仍残留一层百分号编码**。所以带空格/中文的路径必须在 FileServer 里再 `removingPercentEncoding` 解码后才能落地文件系统(纯 ASCII 路径无 `%`,故 `a.html` 正常而 `b%20c.txt` 不解码会 404)。 -- `request.queryParams: [(String,String)]` 取 `t`;`request.headers`(key 小写)取 `cookie`。token 限定为 `[a-z0-9]`,不受上面的编码残留影响。 -- `start(_ port: in_port_t, forceIPv4: Bool = false)` throws;端口占用会 throw → 我们循环换端口。绑定全接口(手机可达),用 `forceIPv4: true`。`stop()` 停服务。 -- 注意:`.raw` 的 body length 未知(-1)→ respond() 不会自动加 Content-Length、连接发完即关(无 keep-alive)。故 FileServer 对文件**主动在 headers 写入 `Content-Length`**(已知文件大小,让手机显示进度);LAN 上一把静态文件无 keep-alive 也完全够用。 - -### FileServer 请求处理流程(单 middleware 闭包) -1. **鉴权**:读 `?t=`,或读 cookie `ls_token`。任一等于当前分享的 token 即放行(每请求取一次快照,轮换瞬间不串);都没有 → 返回 403 小页面。若靠 `?t=` 放行,则在响应里加 `Set-Cookie: ls_token=; Path=/; SameSite=Lax; HttpOnly`(会话 cookie,不设 Max-Age——token 轮换后旧值反正立即失效;页面 JS 不读它)。 -2. **路径安全**(先解码,再防穿越): - ``` - decoded = request.path.removingPercentEncoding // 修正上面的 Swifter 编码残留 - rel = decoded 去掉前导 '/' - root = folderURL.resolvingSymlinksInPath().standardizedFileURL.path - target = root.appendingPathComponent(rel).standardizedFileURL.resolvingSymlinksInPath().path - guard target == root || target.hasPrefix(root + "/") else { 403 } - ``` - `standardizedFileURL` 解掉 `..`,杜绝 `GET /../../etc/passwd`;编码点点(`%2e%2e`)也因「先解码后标准化」被一并挡住。 -3. **目录**:① 若请求的目录路径不以 `/` 结尾 → 先 301 加斜杠(让 `index.html` 里的相对资源能正确解析);② 含 `index.html` → 发该文件;③ 否则发 DirectoryListing 列表页。列表 href 为**绝对路径并逐段百分号编码**(`encodePath`,保留 `/` 分隔符);隐藏文件(`.` 开头)不列。非根列表首行固定「返回上一级」(不参与前端筛选/排序,空目录也保留,多选子树的上一级天然指回虚拟根);文件行 `target=_blank` 新标签打开、目录行原地进入。 -4. **文件**:按扩展名查 MIME(text 类加 `; charset=utf-8`,关键——中文 html 才不乱码),`FileHandle` 分块(64KB)`writer.write(Data)` 流式发。例外:`.md(.markdown)`/`.json(.geojson)`/`.csv(.tsv)` 的**浏览器导航**(Accept 含 `text/html` 且无 `?raw=1`)发预览壳页(共用 `PreviewPage` 骨架:`MarkdownViewer` 内嵌 vendored marked、原始 HTML 转义不执行;`JsonViewer` 手写折叠树 + 路径搜索;`CsvViewer` 手写 RFC4180 解析 + 排序/筛选,后两者零 vendored 依赖;大数据靠懒构建/分档渲染兜底);壳页与文件同 URL,正文相对引用(`assets/` 图、相邻 md 链接)由浏览器解析、命中本表的常规服务,多选虚拟根因 key=lastPathComponent 保持原名而同样成立。curl/脚本/壳页取文(Accept `*/*`)与 `?raw=1` 一律拿原始文件(页角「查看原文」)。 - -> 单文件夹的 2~4 步抽成 `serveTree(rootURL:relPath:…)` 复用。**单文件**直接发那一个文件。**多选**(`Share.multiple([Item])`,`Item` 持 `key`/`url`/`isDir`,`makeItems` 由选中 URL 构造、key 取 lastPathComponent 并对极少数重名做 `-2` 后缀兜底):空路径 → 合成虚拟根列表页(`DirectoryListing.html(items:rootName:)`,rootName=「分享内容」);否则拆首段 `key` 查项——文件项仅当无子路径才发、目录项以 `item.url` 为根走 `serveTree`(`relPath`=去掉 key 段后的剩余,面包屑天然渲染成「分享内容 / key / …」)。未知 key / 文件项带子路径 → 404;穿越判据每项独立。Headless 测多选用 `LS_FOLDERS`(`:`/换行分隔)。 - -> **网页侧 XSS 硬化(0.7.x)**:被服务的内容跑在分享源(`http://本机:端口`)下,HTML/SVG 会被当同源页面执行脚本——能在浏览者会话里读写整个分享、把页面伪装成可信的列表页钓鱼、拿浏览者当 LAN 跳板。两道防线: -> 1. **访客上传去势(主修)**:`sanitizeFileName` 末尾对 `executableDocExtensions`(html/htm/xhtml/xht/shtml/svg/svgz/mht/mhtml)追加 `.txt`,落地成 `text/plain`。专堵「传一个 `index.html` 顶替目录列表页 → 别人点进该目录**零点击**执行脚本」这条存储型 XSS(`availableURL` 只在重名时改名,故空目录里的 `index.html` 会成为默认页),也中和点开即跑的上传 HTML/SVG。文件本体保留、不丢。 -> 2. **全站 `X-Content-Type-Options: nosniff`(纵深防御)**:关掉浏览器 MIME 猜测。正确声明类型的文件照常内联显示、未知类型本就 `octet-stream` 下载,**零回归**;它拦的是「octet-stream 被猜成 HTML 执行」,拦不住「类型本就是 text/html 的执行」——所以 defang 才是上传向量的主修,nosniff 是补强。 -> 3. **Markdown 链接/图片协议白名单**:`.md` 走 marked 预览渲染、不经上面的去势,故单独在 `MarkdownViewer.rendererConfig` 覆盖 marked 的 `link`/`image` 渲染器——只**放行**安全协议(链接 `http`/`https`/`mailto`/`tel`,图片再加 `data:`)与相对/锚点,其余一律拦,堵「恶意 `.md` 里一个 `[x](javascript:…)` 链接被点开即在分享页同源跑脚本」;用白名单而非黑名单,因黑名单天然漏(实体编码、未来新协议)。两个易错点:**(a) 先解 HTML 实体再判**——marked 默认渲染器把 href 里的实体(`javascript:`、`javascript:`)原样写进属性、浏览器解析时才解码,不解码就「检查串≠执行串」被旁路;**(b)** 解码后再剥掉码点 ≤32 的字符(挡 `javascript:`),冒号须在任何 `/ ? #` 之前才算协议。安全 URL 返回 `false` 走 marked 默认渲染,不误伤。配置用 `/* MD-RENDERER-CONFIG */` 标记,`tools/smoke-md-link-sanitize.cjs` 连同**真实 vendored marked** 在 node 里跑断言(含实体编码绕过用例,测真配置非复刻)。 -> -> **只作用于上传/不可信内容路径**:分享者自己放进文件夹的静态站点(含磁盘上的 `index.html`)经 `.directory` 直接服务、不过 `sanitizeFileName`,照常渲染——「分享一个站点目录、index.html 当首页」的功能不受影响。回归测试 `tools/smoke-upload-defang.sh`(无头 + curl,复现完整攻击链 + 不误伤正常文件 + 不破坏磁盘静态站点)。 -> -> **配套两项卫生加固(0.7.x)**:① **token-302 清洗**——浏览器经 `?t=` 首访(Accept 含 `text/html`、尚无 cookie)时,种好 cookie 后立刻 302 到去掉 `?t=` 的同一路径,token 不残留地址栏/历史;curl/脚本与壳页 `?raw=1`/子资源(Accept `*/*`)不触发、照旧直接拿内容(测 `tools/smoke-token-302.sh`)。② **上传文件打 `com.apple.quarantine`**——访客上传落地后 `setxattr` 隔离属性,分享者双击时与「浏览器下载的文件」同享 Gatekeeper 待遇(纯 libSystem,best-effort)。 -> -> 未做(接收端是浏览器、自签 TLS 会触发证书警告页伤体验,与「扫码即用」冲突,业界同形态产品如 LocalSend 的 Web Share 同样退回明文 HTTP):传输层加密。明文 HTTP 下「同网嗅探/恶意 AP 读到 `?t=` 或文件流」是该形态的固有上限,靠 token 轮换 + 默认只读 + 二维码带外传 token 收敛,详见威胁模型行。 - -### NetworkInfo -- `getifaddrs` 遍历;取 `IFF_UP && !IFF_LOOPBACK && AF_INET`;`getnameinfo(NI_NUMERICHOST)` 拿 IP。 -- 只留私网段:`192.168.*` / `10.*` / `172.16–31.*`(过滤掉 VPN/utun、bridge)。 -- 排序:`en0`(WiFi) 优先,其次 `en*`,再其次其它;去重。 -- `.local`:`ProcessInfo.processInfo.hostName`,确保以 `.local` 结尾再展示。 - -### QRCode -- `CIFilter.qrCodeGenerator()`,`message = url.data(.utf8)`,`correctionLevel = "M"`;`CGAffineTransform` 放大(避免糊);`CIContext` → `CGImage` → `NSImage`。 - -### AppState / 生命周期 -- 最近分享持久化到 `UserDefaults`(`recentShares`,非沙盒存路径字符串即可,无需 security-scoped bookmark)。 -- **冷启动不恢复分享**:init 不再把上次分享读回 `sharedItems`(删了 `lastSharedPaths` / 旧 `lastFolderPath` 两个仅供恢复的键),落主页、不自动起文件服务——开 app 把某文件夹悄悄端上 LAN 是隐患,与文本「重启不自动重播」同姿态;上次分享留在「最近分享」一键重发。收件箱是显式开关,仍自动起服务(并落地传递文本页)。只**关窗不退出**时进程与服务都续活、`@StateObject` 整进程只建一次,唤回窗口即回离开时那一屏——那条路径不过 init,故 init 只管「真退出后重开」这一冷启动(界面如实反映「服务是否还活着」)。 -- 端口选择:偏好列表 `[8080, 8000, 8888, 9000]` 逐个 try start,全失败再随机 49152–65535。 -- 选目录/文件用 `NSOpenPanel`(`allowsMultipleSelection = true`)。 -- 分享变更:不重启 server(端口不变),加锁更新 FileServer 的 share 与 token——先换钥匙再换内容,杜绝旧 token 瞬间可读新分享。 -- token 每次「分享」动作生成(QR 与校验共用):`setShared` 与 `stop` 均轮换,旧链接/cookie/二维码即刻作废;在线感知记录随轮换清零。窗口里的地址条显示与复制同一字符串(完整 URL 含 `?t=`,超长仅 UI 中段省略),无「隐 token 的展示地址」。 - -### 屏幕路由 / 二级页统一(v0.7+) -- `Screen` 枚举:`.share`(功能主页)/ `.file`(文件二维码票据)/ `.text`(传递文本)/ `.settings` / `.history`。**文件票据与传递文本同为带返回的二级页**——头部统一 `← + 标题 + ⚙`(`ShareScreen` / `TextScreen` / `NoNetworkScreen` 同款),品牌名「LocalShare」只留主页(`HomeScreen`)。 -- 进出口:`setShared`(选/拖/`reshare` 文件、CLI open 事件)→ `enterFile()`(`.file`);二级页 `←` 一律 `goShare()` 回主页;`clearShare` 清空回主页,`stop` 留在票据显「重新广播」。`.file` 恒有非空 `sharedItems`(clearShare 即回主页),故无空票据。 -- 主页「正在分享」横幅(`ActiveShareBanner`):文件在后台续跑、用户退回主页时,顶部出紧凑可点行(qrcode 图标 + `正在分享 / 文件名` + 在线人数 + chevron),点按 `enterFile()` 回票据;运行缀 `PulsingDot`、停止未清除显静默态。与文件并存的文本仍由「传递文本」入口的呼吸点表示(判据收紧为 `isRunning && (hasText || textInboxEnabled)`,免得跑文件时误亮)。 - -### App.swift / 入口 -- `@main enum EntryPoint` 三层分流:`LS_HEADLESS=1` 走 `HeadlessServer.run()`(无界面,测试/自动化)→ `CLI.parse(CommandLine.arguments)` 命中则走 `CLI.run`(命令行调用,见下「命令行启动」)→ 否则 `LocalShareApp.main()` 跑 SwiftUI。 -- `AppDelegate`(`NSApplicationDelegateAdaptor`,`@MainActor`):`applicationDidFinishLaunching` 里 `NSApp.setActivationPolicy(.regular)` + `activate(ignoringOtherApps:)`(裸跑也能前台);`applicationShouldTerminateAfterLastWindowClosed → false`(关窗不退出,菜单栏图标常驻);`application(_:open:)` 接 CLI 转发的文件 → `AppState.setShared` + 唤窗。open 事件可能早于 `AppState` 构造(`@StateObject` 时机由 SwiftUI 决定),先存 `pendingOpenURLs` 缓冲、`AppState.init` 末尾消费。 -- 唤窗链路:主窗已关时 `openWindow` 只能从活着的视图环境拿——`MenuBarExtra` 的 label 视图(`MenuBarIcon`)常驻菜单栏、监听 `.lsShowMainWindow` 通知代为 `openWindow(id: "main")`;`AppState.showMainWindow()` 只发通知。 - -### HeadlessServer(测试/自动化 + CLI 前台) -- `LS_HEADLESS=1` 时仅起 `FileServer` 并 `RunLoop.main.run()`,不拉 GUI。环境变量:`LS_FOLDER` / `LS_FOLDERS`(二选一)、`LS_TOKEN`(默认 `testtoken`)、`LS_PORT`(默认 8080);启动后打印 `LS_URL …` 便于脚本读取。 -- `runForeground(urls:preferredPorts:)` 是 `localshare --headless` 的前台模式:随机 token(`Token.generate()`,GUI 同款安全模型)、URL 用局域网 IP(`NetworkInfo`,手机要扫)、交互终端(isatty)下追加 ANSI 半块字符二维码(`QRCode.ansi`,黑码白底写死、深浅终端均可扫)、管道场景只输出 `LS_URL` 行。 -- ⚠️ **编译器坑**:Swift 6.2.4 的 `-O` 在「枚举载荷里的 `Optional` → 函数内构造 `[in_port_t]` → 传入 `FileServer.start`」这条链上会错编出垃圾数组指针(release 必崩 `Fatal error: failed to allocate …`,debug 正常,加 print 即消失的 heisenbug)。规避:`runForeground` 直接收具体 `preferredPorts: [in_port_t]`,Optional 的展开留在调用方 `CLI.run`。动这段必须用 **release** 构建重跑 `--headless` 带/不带 `--port` 的冒烟。 - -### 命令行启动(CLI,0.4) -- 形态:`localshare a.html b.pdf`(唤起/复用 GUI 分享这些路径);`localshare --headless [--port N] <路径>…`(前台起服务不开窗);`localshare` 不带参数仅唤起窗口;`--help` / `--version`。 -- 安装物是 **symlink** `/usr/local/bin/localshare → LocalShare.app/Contents/MacOS/LocalShare`。可行性关键:dyld 解析 `@executable_path`(Sparkle 的 rpath)前会对主二进制 realpath,故经 symlink 启动包内 framework 照常加载(Sublime `subl` 同款机制,已实测 universal 包不崩)。 -- argv 判定(`CLI.parse`,保证 Finder/LaunchServices 启动永不误入):丢弃 `-psn_*`、`-NSDocumentRevisionsDebugMode` 噪音;识别 flag 与非 `-` 开头的路径参数;有 flag 或路径才算 CLI;未知 `-` 选项在 argv[0] 是 `localshare`(经 symlink)时报错 exit 2,否则视为 AppKit 噪音走 GUI。 -- 转发 GUI:CLI 进程**不可用 `Bundle.main`**(经 symlink 启动时它可能按链接路径解析)——自取 `_NSGetExecutablePath` → 解 symlink → 上溯三级拿 `.app`,裸跑回退按 bundle id 查已安装 app;然后 `NSWorkspace.open(urls, withApplicationAt:)`(显式指定目标 app,无需 `CFBundleDocumentTypes`,运行中实例默认复用、热切换不重启 server、token/cookie 不失效)。CLI 进程只跑 `RunLoop` 等回调,**不碰 NSApplication**(否则 Dock 闪幽灵图标)。 -- 安装器(`CLIInstaller`,设置面板「命令行工具」节):状态三态 notInstalled / installed / stale(两侧 `resolvingSymlinksInPath` 后比对,stale 时面板直接亮出实际指向);安装先直接 `createSymbolicLink`,权限不足转 `osascript … with administrator privileges`(路径双层转义;stderr 含 `-128` = 用户取消,静默返回);卸载只删 symlink、同名真实文件不动。GUI 进程内 `Bundle.main` 可靠(永远由真实 .app 路径拉起);但裸二进制(`swift run`)没有 .app 可指,`binaryPath()` 返回 nil → 面板收起「安装」按钮(状态/卸载照常),避免装出指向 `.build` 构建产物的链接(曾导致 CLI 定位不到 .app、转发落到旧版实例)。 - -### 自动更新(Sparkle) -- **依赖形态**:Sparkle 以二进制 framework 经 SPM `binaryTarget` 引入(`Package.swift` 里 `from: "2.6.0"`,实测解析到 2.9.3)。`swift build` 会把 `Sparkle.framework` 解到 `.build/artifacts/sparkle/Sparkle/Sparkle.xcframework/macos-arm64_x86_64/`(单切片已含 arm64+x86_64),同目录 `bin/` 还带 `sign_update` / `generate_keys` / `generate_appcast`。 -- **内置与 rpath**:`Package.swift` 给 executableTarget 加了 `-rpath @executable_path/../Frameworks`;`build.sh` 用 `ditto` 把 framework 拷进 `Contents/Frameworks/`,于是 `Contents/MacOS/LocalShare` 经该 rpath 即能加载包内 framework(脱离 `.build` 也成立,已实测 headless 启动无 dyld 错误)。 -- **签名**:`build.sh` inside-out 深度 ad-hoc 签名(`--deep` 签 framework 内的 XPCServices/Updater.app/Autoupdate/dylib → 再签整个 app),随后 `codesign --verify --deep --strict` 校验。 -- **依赖校验**(`build.sh` 末尾 + CI):逐条过滤主二进制依赖,系统库放行、`@rpath/X.framework` 必须对应 `Contents/Frameworks/X.framework` 存在、其余包外 dylib 判失败。 -- **代码集成**:`Updater.swift` 的 `UpdaterController`(`@MainActor`)持 `SPUStandardUpdaterController`,在 `LocalShareApp` 以 `@StateObject` 构造(headless 路径不触及);菜单 About 下方加「检查更新…」。配置全在 `Info.plist`:`SUFeedURL`、`SUPublicEDKey`、`SUEnableAutomaticChecks=true`、`SUScheduledCheckInterval=86400`、`SUAutomaticallyUpdate=false`(发现新版只提示、不静默装)。 -- **信任链**:走 EdDSA(Ed25519)——更新包用私钥签名、app 内嵌 `SUPublicEDKey` 校验,**与代码签名/公证无关**,所以 ad-hoc + 未公证也能安全自更新。且 Sparkle 安装的更新不带 `com.apple.quarantine`,首次装好后续升级不再触发 Gatekeeper。 -- **CI 链路**(`.github/workflows/release.yml`):build → 依赖闸门 → 打 DMG → 建 Release → 用 `sign_update` 对 DMG 做 EdDSA 签名 → 生成单条 `` 的 `appcast.xml`(enclosure 指向 Release 里的 DMG URL)→ **作为资产上传到本次 Release(不提交 git,发布对仓库零写入)**。feed 即 `https://github.com/rrbe/LocalShare/releases/latest/download/appcast.xml`(GitHub 恒指向最新 release 的同名资产)。一次性迁移随 v0.7.0 完成:仓库根 `appcast.xml` 已冻结在 0.7.0、仅服务 ≤0.6.0 老客户端(feed 指向旧 raw master),升级后即转入新 feed;详见 CLAUDE.md「发布与版本」。 - -#### 一次性配置(EdDSA 密钥)— ✅ 已完成,下列为留档 -当年生成密钥对的步骤(仅作记录,无需重做): -1. 本地装 Sparkle 工具后生成 EdDSA 密钥对:`./bin/generate_keys`(私钥进登录钥匙串,终端打印 base64 **公钥**)。 -2. 公钥已填入 `bundle/Info.plist` 的 `SUPublicEDKey`(当前值 `yF0fhsSuotJutGHezmoAFb3+M7nA6gcOln5aEWycXp8=`,**非占位**)。公钥非机密,直接进仓库。 -3. 私钥已存为 GitHub 仓库 secret **`SPARKLE_ED_PRIVATE_KEY`**(导出用 `./bin/generate_keys -x private_key.pem`)。**私钥绝不入仓库**。 - - 兜底逻辑仍在:未配 secret 时 CI 跳过 appcast 生成(仅出 DMG,发 `::warning::`);`Info.plist` 仍是占位公钥时 app 不启动 updater。两道保险让「未配置」状态显式可见。 - -### 容错 UI -- 无 WiFi / 无私网 IP → 不画死码,显示“请先连接 WiFi”。 -- 首次 start 触发 macOS 防火墙“是否允许接受传入连接”——UI 文案引导点**允许**(误点拒绝是现实中“扫了码却打不开”头号原因)。 -- 二维码下常驻一行:“打不开?→ 确认两台设备在同一 WiFi,且该 WiFi 未开启‘访客/设备隔离’。” - -### 国际化(i18n,0.9) -- **形态**:简体中文(基准)+ English 双语,全部用户可见文案做成**编进二进制的 Swift 字符串表**(`Lang.swift`),不依赖任何资源 bundle——与 `MarkedJS.source`、`permSummary` 同一思路,三条启动路径(GUI / CLI / headless)都无须定位文件,升级整文件替换。 -- **两个解析域彼此独立**:① 原生 app 语言来自设置(`AppState.langPref`:跟随系统 / 中文 / English,持久化),`Lang.current` 是给拿不到 `AppState` 的菜单/命令构造处(`App.swift` / `Updater.swift`)读的静态快照,由 `AppState.init` / `setLangPref` 同步;② 网页**逐请求**由浏览器 `Accept-Language` 决定(`Lang.fromAcceptLanguage`:按 `q` 值降序、同 q 保留先出现者、`q=0` 即「明确不接受该语言」跳过),**绝不读 app 设置**——同一台 Mac 分享,电脑界面可中文而手机按自己的系统语言显示。 -- **文案分三类**:静态文案走 `L`(`CaseIterable` 枚举键,`switch` 返回 `(zh, en)`,编译器强制穷尽、无 Optional / 强解包,新增即加一个 case);带插值 / 复数 / 中英语序差异的走 `LStr`(按 `lang` 分支拼装);网页里由 JS 在浏览器侧拼接的走 `LStr.i18nJSON`(注入一个带 `{占位符}` 的字典,JS 只做 replace,语序逻辑仍留在 Swift 侧;`DirectoryListing` / `PreviewPage` 两个生成页都 emit ``)。 -- **安全**:`i18nJSON` 的值注入 `` 提前收尾(与全站 XSS 硬化同一精神)。 -- 测试:`Tests/LocalShareTests/LangTests.swift`(`Accept-Language` 协商、`q` 值优先级、`L` 穷尽性)+ `tools/smoke-accept-language.sh`(无头 + curl 验网页逐请求语言切换),均已挂进 CI。 - ---- - -## 4. 构建与运行 - -```bash -cd lan-file-share -swift build -c release # 编译(首次会拉 Swifter) -./build.sh # 组装并 ad-hoc 签名 → dist/LocalShare.app -open dist/LocalShare.app # 本机自测 -``` - -**发给同事**:把 `dist/*.app` 拷过去;**首次由你帮他打开一次**(双击若被 Gatekeeper 拦,去“系统设置 → 隐私与安全性”点“仍要打开”,仅此一次,之后他双击即用)。 - ---- - -## 5. 进度 - -- [x] 项目脚手架 + `Package.swift` + Swifter 1.5.0 解析 -- [x] Swifter API 核对 -- [x] 本计划文档 -- [x] 源码:Token / Mime / NetworkInfo / QRCode / DirectoryListing / FileServer / AppState / ContentView / App / HeadlessServer -- [x] `bundle/Info.plist` + `build.sh` + `.gitignore` + `README` -- [x] `swift build` 编译通过 -- [x] `curl` 冒烟测全通过:token 校验(无/错→403,对→200+Set-Cookie)、目录列表(隐藏文件不列)、 - index.html 自动显示、无斜杠目录 301、文件 MIME(charset=utf-8)、中文/空格文件名、文件流式 -- [x] 安全:目录穿越(字面 `..`、编码 `%2e%2e`、混合 `..%2f`)全部 403,`/etc/passwd` 不可达 -- [x] 组装 `.app`:codesign 有效;`otool -L` 确认**零第三方 dylib**(仅 /usr/lib 与系统 Frameworks) -- [x] GUI 端到端:启动 → 读记住的文件夹 → 自动起服务 → 监听端口生效 -- [x] 自动更新(Sparkle):framework `@rpath` 内置进 `Contents/Frameworks/` + 深度签名;放宽后依赖闸门(build.sh/CI)只许内置 framework;`Updater.swift` + Info.plist 配置;CI 走 EdDSA 签名 + appcast 作为 Release 资产上传(feed `releases/latest/download`,零写仓库)。EdDSA 密钥已一次性配置完成(见 §3)。 -- [x] 命令行启动:`localshare <路径>…` 转发 GUI / `--headless` 前台模式 / 设置面板安装 symlink; - 已验证 symlink 经 dyld realpath 加载包内 Sparkle、冷启动 open 事件缓冲、热切换实例复用、 - 终端二维码 Vision 实扫解码通过;release 编译器坑已规避并注释(见 §3「命令行启动」)。 -- [x] 在线感知「N 人正在浏览」:FileServer 按客户端 IP 记 lastSeen(45s 窗口,复用 share 同一把 - NSLock),listing 页 JS 每 15s 打 `/ls/ping`(保留路径,先于分享内容命中)回 `{"viewers":n}`; - GUI 由 AppState 2s 轮询展示(0 人隐藏),网页端 ≥2 人才显示(自己即 1 人)。 - 已知缺口:用户自带 index.html 无法注入心跳,只能靠请求时间近似。 - 已验证:双 IP 计 2、同 IP 多请求计 1、无 token 403、中文名/防穿越无回归、release 冒烟通过。 -- [x] 访客上传 v1(0.6):`Permission.add` 接真后端 —— 设置页开关(仅单文件夹分享可开,换分享 - 自动回只读);listing 页上传按钮 + 整页拖拽 + XHR 进度条,完成后刷新;POST multipart 到当前 - 浏览目录(落点过同一套防穿越校验),文件名只取末段并清洗(拒空名/点开头,":" 换 "-"), - 重名 -2 兜底,itemReplacementDirectory 临时文件 + 原子 moveItem;500MB 上限(前端先拦、 - 服务端 413 兜底——注意 Swifter 进 middleware 前已把 body 整段读进内存,上限只能事后拒绝, - 流式/分片是 v1.5 的事);GUI「新收到」卡片点击 Reveal in Finder;headless 用 `LS_UPLOAD=1`。 - 网页措辞(kicker/colophon)经 permSummary 派生,开上传自动变「可读写分享」。 - 已验证:根/子目录上传、文件名穿越清洗、目标路径穿越 403、重名 -2、中文/空格名、点开头 400、 - 不存在目录 404、无 token 403、开关关 403、多选/单文件分享 403、501MB 413 且不落盘、 - 上传内容逐字节完整、未开上传时页面无按钮且措辞仍「只读」、release 冒烟全过。 -- [x] 在线访客显示设备名(0.7.x,PR #15):`FileServer` 后台 best-effort 反查访客 IP 的设备名 - (`getnameinfo`+`NI_NAMEREQD`,串行队列、同 `NSLock` 缓存于 `nameCache`、对端自报名清洗去 - 控制/RTL 码点、随 token 轮换清零),GUI 单台直呼其名、多台「… 等 N 人」、查不到回退「…尾号」; - **网页 `/ls/ping` 仍只回人数**。已知现实:iPhone 多经 mDNS 注册、普通 PTR 常查不到。 -- [x] 「仅当前网络可见」开关(0.7.x,PR #15):`AppState.bindSelectedOnly` → `FileServer.listenAddress`, - 开启则只绑选中网卡的私网 IPv4(Swifter 原生 `listenAddressIPv4`,无须 fork),默认仍绑 `0.0.0.0`; - 切网卡/切开关不轮换 token 地重绑、绑定 IP 消失自动回退全接口并提示;非法地址经 `inet_pton` 校验 - 抛错而非静默绑全接口。冒烟 `tools/smoke-bind-interface.sh`。 -- [x] 明文风险提示(0.7.x,PR #15):底部「连不上?」气泡 + 设置·访问权限 区各一行克制灰字,告知公共 - Wi-Fi 下传输不加密、同网可能被嗅探(与 §1 威胁模型一致;自签 TLS 仍不做)。 -- [x] 国际化 i18n(0.9,PR #22):简体中文 + English 双语,文案编进二进制(`Lang.swift`,不依赖资源 - bundle,三条启动路径都无须定位文件)。两个解析域独立——原生 app 跟设置(跟随系统 / 中文 / English, - `AppState.langPref`,设置页加「语言」分段),网页**逐请求**按浏览器 `Accept-Language` 协商(`q` 值 - 降序、`q=0` 跳过),绝不读 app 设置。文案分 `L`(静态,编译器强制穷尽)/ `LStr`(插值 / 复数 / 语序) - / `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` 原文),可独立分享或挂进多选虚拟根的 - 文本行;离散提交快照、点「分享/更新」提交文本(v2.1 起更新文本不再轮换 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「传递文本」。 -- [x] 传递文本 v2 — 手机→Mac 收文本(PR #26):独立收件箱通道,与 share 正交、不落盘、不依赖文件夹分享。 - 闸门 `textInboxEnabled`(设置「允许收文本」,opt-in 默认关,不限分享形态、开了就把服务拉起);保留路径 - `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`(设置页与 - 文本页同一开关)。**token 改回会话维度**:`setSharedText` 不再轮换 token(v1/v2 每次更新都换、会把正在看的 - 对端刷掉,还误伤共存的文件分享链接),只在 `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`。 -- [x] 二级页导航统一(承接 #25/#26 的主页 + 二级页模型):最早的「分享文件」补齐二级页设计——文件票据 `ShareScreen` - 降为 `Screen.file`,头部换成 `← + 「分享文件」+ ⚙`,与 `TextScreen` 同款;品牌名只留主页。`setShared` 落 `.file`、 - `goShare()` 回主页、新增 `enterFile()` 进票据;`NoNetworkScreen` 也补 ←。主页 `EmptyScreen`→`HomeScreen` 顶部新增 - `ActiveShareBanner`(文件后台续跑时一键回二维码,运行缀呼吸点 / 停止显静默)。**冷启动改不自动重播**:init 删除 - 上次分享恢复块(退役 `lastSharedPaths` / `lastFolderPath`),开 app 落主页、上次分享留「最近分享」重发——对齐 - 文本「重启不自动重播」的安全姿态;只关窗不退出仍服务续播、唤回即回原状(界面如实反映「服务是否还活着」)。 - 已验证:`swift build` / `swift test`(45)通过;冷启动截图确认落主页 + 待命 + 最近分享留存。 - -> 已知坑(已规避并注释):Swifter 1.5.0 的 `HttpParser` 会对请求 path 二次编码,导致 `request.path` -> 仍残留一层百分号编码 —— FileServer 落地文件系统前已用 `removingPercentEncoding` 解码,且不影响防穿越。 - -### 测试方法(无头模式) -```bash -swift build -LS_HEADLESS=1 LS_FOLDER=/path/to/dir LS_TOKEN=testtoken LS_PORT=8099 .build/debug/LocalShare & -curl -s "http://127.0.0.1:8099/?t=testtoken" # 应返回目录列表 -``` - ---- - -## 6. 明确不做(v1 范围外,留给 v2) - -- Apple 公证(要 $99/年开发者号);https + 自签证书(仅当 html 用到 secure-context API 才需要);跨网络隧道(cloudflared/ngrok/tailscale);菜单栏常驻形态。 -- (自动更新已在 0.3 落地,见 §3「自动更新(Sparkle)」;手机上传回电脑已在 0.6 落地,见 §5 进度。) - ---- - -## 7. 规划中(v0.7+) - -(访客上传 v1 已在 0.6 落地,见 §5 进度;当年切范围的理由:上传解决手机照片/文档传到 Mac -这 90% 的诉求,在线编辑在手机浏览器体验差、覆盖丢数据风险大,删除误删风险高,均往后放。) - -### 传递文本(v1 / v2 已落地) - -让「选内容→手机扫码」的内容从「磁盘文件」扩到「一段文本」——剪贴板/链接/口令/说明从桌面甩到手机, -以及反向把手机上的文本收回电脑。**两条独立单向通道,不是同步便签**:发出去的 `sharedText` 与收回来的 -收件箱互不喂给对方。分两个版本交付,因为两端在现有架构里的「落点」完全不对称: - -- **发出去(Mac→手机)几乎全复用现有只读管线**——整个 app 本就是「把内容喂给手机看」,文本只是又一种被 - GET 的内容,唯一新东西是「内容源是内存里的 `String` 而非磁盘 `URL`」。低风险、高杠杆,先做。 -- **收回来(手机→Mac)的难点不在传输**(一个表单 POST 比现有 multipart 上传还轻),而在于 **Mac 要长出 - 一个「收件箱」形态**:新原生 UI + 新生命周期(收到的文本往哪放/怎么清)+ push 模型。独立成 v2。 - -#### v1 — Mac → 手机·发文本(已落地,PR #25;落点见 §5 进度) - -| 维度 | 决定 | -|---|---| -| 数据模型 | `AppState` 加 `sharedText: String?`(全局单一文本,一次只一段) | -| 与文件关系 | 混进多选**虚拟根**当一个条目;**也能独立分享**(一个文件都不选——这才是「传文本」的主力场景) | -| 「空」判定 | 从 `sharedItems.isEmpty` 改为 `sharedItems.isEmpty && sharedText == nil`,牵动 `start/stop/恢复/QR` 全链 | -| 交互模型 | **离散提交快照**:输入框打字不广播,点「分享/更新」才把当前文本快照发出去。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 分享 | -| 原生入口 | 空态「文件 / 文本」**双平级入口**:文件那块旁并列「分享文本」,切到 TextEditor + 「分享」;分享进行中可再调出同一文本框追加 | -| 手机页 | 纯文本转义显示 + 大「复制」按钮 + http(s) URL 安全自动链接(沿用 `MarkdownViewer` 协议白名单挡 `javascript:`)。**不当 Markdown 渲染**(任意纯文本里的 `* _ #` 会被吃掉) | -| 取原文 | `?raw=1` / curl(Accept `*/*`)拿 `text/plain; charset=utf-8` 原文,导航(`text/html`)给壳页——同 md/json/csv 预览的同 URL 双形态 | -| 大小 | **不设上限**:文本是 Mac 端自己粘的、非不可信输入,不必像上传那套;空白文本禁用「分享」按钮 | - -#### v2 — 手机 → Mac·收文本(已落地,PR #26;落点见 §5 进度) - -| 维度 | 决定 | -|---|---| -| 本质 | **独立收件箱通道**,不落盘、不依赖文件夹分享,与 v1 对称(都在 app 里以文本形态存在) | -| 收件箱 | 列表,每条带时间 + 来源(复用现成 `nameCache`/`getnameinfo` 反查设备名,查不到显 IP),单条复制/删除/清空 | -| 持久化 | 设置项「持久化收到的文本」**默认关**(对称 v1;收到的常更敏感/更像垃圾,默认易逝更稳) | -| 闸门 | 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 页(及 `/ls/text` 无共享文本时退化出的发送页)**与现有上传表单同处、同样的出现条件**(收件箱关时不出现) | - -#### 贯穿约束(实现时必须守) - -- **clipboard 在纯 http LAN 下不可用**:`navigator.clipboard.writeText` 只在 secure context(https / localhost)可用, - 而本服务是 `http://192.168.x.x`,复制按钮必须回退到 `document.execCommand('copy')` + 选中隐藏 textarea, - 否则「点了没反应」。这与「不做 TLS」的威胁模型直接相关。 -- **Swifter body 预读内存**:上限只能事后拒绝(同上传),故 v2 必须单条+总数双限。 -- **token / 302 去 `?t=` / 网卡绑定 / i18n(`L`/`LStr`,文案过表)/ 转义** 全自动沿用现有机制;收到的文本在 - SwiftUI `Text` 里显示天然不执行,但若回显进任何**服务页**须转义。 -- v1/v2 各自一个持久化开关(**各一个**:v1「记住分享的文本」、v2「记住收到的文本」均已落地,皆默认关; - 两者隐私语义不同——发出去的是自己粘的、收回来的是他人投递的,分开开关更清晰)。 - -### 上传 v1.5:分片上传 - -绕开 Swifter「整段 body 进内存」的根本限制:前端把大文件切片(每片 16–32MB)逐片 POST, -服务端按序 append 到临时文件、末片原子换名。任意大小可传、内存恒定,还顺带断点续传的底子。 -不 fork Swifter。落地后可放开(或大幅提高)500MB 上限。 - -### 写权限后续 - -- `Permission.edit` / `Permission.del`(在线编辑、删除):后端未实现,开关已留好;做之前先想清 - 覆盖丢数据与并发冲突的兜底。 - -### 设备名反查后续 - -- 现为 best-effort `getnameinfo`(已落地,见 §5),iPhone 多查不到、回退 IP 尾号。要更准需走 - `DNSServiceQueryRecord` 的 mDNS PTR 查询,成本高且仍不保证命中,暂不做。 - -> v0.7.x 已落地的小项(设备名反查、「仅当前网络可见」、明文提示)已移入 §5 进度,不再列为规划。 diff --git a/Package.swift b/Package.swift index 14b7c58..86397ca 100644 --- a/Package.swift +++ b/Package.swift @@ -12,7 +12,7 @@ let package = Package( .package(url: "https://github.com/httpswift/swifter.git", from: "1.5.0"), // Sparkle 自动更新。注意:它不是纯源码包,而是二进制 framework(binaryTarget), // 会以 Sparkle.framework 内置进 .app/Contents/Frameworks(随包走、运行时不缺失), - // 不依赖任何包外 dylib——这正是放宽「零 dylib」戒律的边界(见 PLAN.md §0)。 + // 不依赖任何包外 dylib——这正是放宽「零 dylib」戒律的边界(见 docs/ARCHITECTURE.md §0)。 .package(url: "https://github.com/sparkle-project/Sparkle", from: "2.6.0"), ], targets: [ diff --git a/README.md b/README.md index 491634a..575eff8 100644 --- a/README.md +++ b/README.md @@ -1,67 +1,73 @@ -# LocalShare(局域网文件分享) +# LocalShare (LAN file sharing) -一个 macOS 小工具,启动一个静态文件托管服务,分享你电脑上的特定文件/文件夹,在同一个局域网下的其他设备中访问。 +[简体中文](README_CN.md) | English -

- LocalShare 界面截图 -

+A small macOS tool that spins up a static file server so you can share specific files/folders from your Mac with other devices on the same local network. -## 功能 + + + + + + +
Home
Home
Share Files
Share Files
Transfer Text
Transfer Text
-- 二维码分享,相机 app 扫一下浏览器中打开 -- 一次分享多个文件 / 文件夹 -- 支持 HTML / PDF / 视频 / 图片,Markdown / JSON / CSV 在浏览器里直接预览 -- 可选开启访客上传(默认只读),手机里的照片、文档能传回电脑 -- 显示当前在线访客(能反查到就显示设备名,否则显示 IP 尾号) -- 可选「仅当前网络可见」:只在当前 WiFi 开放,电脑连着的其它网络访问不到 -- 自动更新(发现新版会提示,确认后再装) -- 命令行 localshare:唤起窗口分享,或 --headless 在终端显示链接和二维码 +## Features -## 为什么有这个 app +- Share via QR code — scan with the Camera app to open in a browser +- Share multiple files / folders at once +- Serves HTML / PDF / video / images; previews Markdown / JSON / CSV right in the browser +- Optional guest upload (read-only by default) — send photos and documents from your phone back to the Mac +- Shows who's currently viewing (device name when it can be resolved, otherwise the IP) +- Optional "visible on current network only" — open on the current Wi-Fi only, unreachable from other networks the Mac is connected to +- Automatic updates (prompts when a new version is found; installs only after you confirm) +- `localshare` command line: bring up the window to share, or `--headless` to print the link and QR code in the terminal -- iPhone 不支持 html 文件直接在手机 Safari 中打开,需要托管到静态文件服务器才能预览 -- 如果你并不想把文件通过 AirDrop/LocalSend 传到手机,只是想在手机预览 -- 想同时浏览多个文件 -- 分享文件给局域网内的其他人使用 +## Why this app exists -## 使用 +- iPhone Safari can't open local HTML files directly — they need to be served from a static file server to preview +- When you don't want to actually move files to your phone via AirDrop/LocalSend, you just want to preview them there +- When you want to browse several files at once +- To share files with other people on the same LAN -1. 打开 app,拖拽文件到 app 窗口,或手动点「选择文件夹/单个文件」。 -2. 手机连上**与电脑相同的 WiFi**,用相机扫描窗口里的二维码。 -3. 首次启动若系统弹出防火墙提示,点「允许」。 +## Usage -二维码地址形如 `http://192.168.x.x:8080/?t=随机令牌`:链接里带一次性令牌,扫码者无感进入,单纯知道 IP:端口 的人无法访问。 +1. Open the app and drag files onto the window, or click "Choose Files or Folders". +2. Connect your phone to **the same Wi-Fi as the Mac**, then scan the QR code in the window with the Camera app. +3. If macOS shows a firewall prompt on first launch, click "Allow". -> ⚠️ 传输是明文 HTTP(没有加密)。在家里 / 公司这种可信网络下没问题;但在咖啡馆、机场等公共 WiFi 下,同一网络的人有可能看到传输内容——别在这种网络分享敏感文件。需要时可在窗口里开「仅当前网络可见」收窄暴露面。 +The QR code points to something like `http://192.168.x.x:8080/?t=`: the link carries a one-time token, so whoever scans it gets in seamlessly, while anyone who only knows the IP:port cannot access it. -## 终端用法 +> ⚠️ Traffic is plain HTTP (unencrypted). That's fine on trusted networks like home or office; but on public Wi-Fi such as cafés or airports, others on the same network may be able to see what's transferred — don't share sensitive files there. When needed, turn on "visible on current network only" in the window to narrow the exposure. -在「设置 → 命令行工具」里点「安装」,之后可以在终端一键分享: +## Terminal usage + +In Settings → Command-Line Tool, click "Install". After that you can share from the terminal in one command: ```bash -localshare a.html b.pdf # 唤起 LocalShare 窗口分享这些文件 -localshare ~/Documents/报告 # 文件夹同理,可混合多选 -localshare --headless ./dist # 不开窗口,直接在终端打印链接和二维码(Ctrl-C 停止) +localshare a.html b.pdf # bring up the LocalShare window to share these files +localshare ~/Documents/report # folders work the same; mix and match multiple items +localshare --headless ./dist # no window — print the link and QR code in the terminal (Ctrl-C to stop) ``` -## 下载 +## Download https://github.com/rrbe/LocalShare/releases -## 注意事项 +## Notes -ad-hoc 签名,**打开**可能被 Gatekeeper 拦截(提示「已损坏」或「无法打开」) +The app is ad-hoc signed, so **opening** it may be blocked by Gatekeeper (warning that it's "damaged" or "can't be opened"). -- 可以在「系统设置 → 隐私与安全性 → 安全性」中找到拦截提示,点「仍要打开」; -- 或在终端执行下面这条去掉隔离属性,之后正常打开 app 即可 +- In System Settings → Privacy & Security → Security, find the prompt and click "Open Anyway"; or +- run the command below in the terminal to strip the quarantine attribute, then open the app normally: ```bash xattr -dr com.apple.quarantine /Applications/LocalShare.app ``` -## 参考项目 +## Credits -本项目受到如下项目的启发 +This project was inspired by: - [localsend](https://github.com/localsend/localsend) - [dufs](https://github.com/sigoden/dufs) diff --git a/README_CN.md b/README_CN.md new file mode 100644 index 0000000..ce5f3de --- /dev/null +++ b/README_CN.md @@ -0,0 +1,73 @@ +# LocalShare(局域网文件分享) + +简体中文 | [English](README.md) + +一个 macOS 小工具,启动一个静态文件托管服务,分享你电脑上的特定文件/文件夹,在同一个局域网下的其他设备中访问。 + + + + + + + +
主界面
主界面
分享文件
分享文件
传递文本
传递文本
+ +## 功能 + +- 二维码分享,相机 app 扫一下浏览器中打开 +- 一次分享多个文件 / 文件夹 +- 支持 HTML / PDF / 视频 / 图片,Markdown / JSON / CSV 在浏览器里直接预览 +- 可选开启访客上传(默认只读),手机里的照片、文档能传回电脑 +- 显示当前在线访客(能反查到就显示设备名,否则显示 IP 尾号) +- 可选「仅当前网络可见」:只在当前 WiFi 开放,电脑连着的其它网络访问不到 +- 自动更新(发现新版会提示,确认后再装) +- 命令行 localshare:唤起窗口分享,或 --headless 在终端显示链接和二维码 + +## 为什么有这个 app + +- iPhone 不支持 html 文件直接在手机 Safari 中打开,需要托管到静态文件服务器才能预览 +- 如果你并不想把文件通过 AirDrop/LocalSend 传到手机,只是想在手机预览 +- 想同时浏览多个文件 +- 分享文件给局域网内的其他人使用 + +## 使用 + +1. 打开 app,拖拽文件到 app 窗口,或手动点「选择文件夹/单个文件」。 +2. 手机连上**与电脑相同的 WiFi**,用相机扫描窗口里的二维码。 +3. 首次启动若系统弹出防火墙提示,点「允许」。 + +二维码地址形如 `http://192.168.x.x:8080/?t=随机令牌`:链接里带一次性令牌,扫码者无感进入,单纯知道 IP:端口 的人无法访问。 + +> ⚠️ 传输是明文 HTTP(没有加密)。在家里 / 公司这种可信网络下没问题;但在咖啡馆、机场等公共 WiFi 下,同一网络的人有可能看到传输内容——别在这种网络分享敏感文件。需要时可在窗口里开「仅当前网络可见」收窄暴露面。 + +## 终端用法 + +在「设置 → 命令行工具」里点「安装」,之后可以在终端一键分享: + +```bash +localshare a.html b.pdf # 唤起 LocalShare 窗口分享这些文件 +localshare ~/Documents/报告 # 文件夹同理,可混合多选 +localshare --headless ./dist # 不开窗口,直接在终端打印链接和二维码(Ctrl-C 停止) +``` + +## 下载 + +https://github.com/rrbe/LocalShare/releases + +## 注意事项 + +ad-hoc 签名,**打开**可能被 Gatekeeper 拦截(提示「已损坏」或「无法打开」) + +- 可以在「系统设置 → 隐私与安全性 → 安全性」中找到拦截提示,点「仍要打开」; +- 或在终端执行下面这条去掉隔离属性,之后正常打开 app 即可 + +```bash +xattr -dr com.apple.quarantine /Applications/LocalShare.app +``` + +## 参考项目 + +本项目受到如下项目的启发 + +- [localsend](https://github.com/localsend/localsend) +- [dufs](https://github.com/sigoden/dufs) diff --git a/Sources/LocalShare/AppState.swift b/Sources/LocalShare/AppState.swift index 6e10f9a..fdd0123 100644 --- a/Sources/LocalShare/AppState.swift +++ b/Sources/LocalShare/AppState.swift @@ -82,7 +82,7 @@ final class AppState: ObservableObject { } persistText = UserDefaults.standard.bool(forKey: persistTextKey) // 未写入默认 false // 记住分享文本时回填草稿(编辑器预填上次内容),但**不**放进 sharedText、不自动广播—— - // 文本常是密码/口令,自动重新广播会在 LAN 上悄悄重现,故重启需用户手动「分享」(见 PLAN.md)。 + // 文本常是密码/口令,自动重新广播会在 LAN 上悄悄重现,故重启需用户手动「分享」(见 docs/ARCHITECTURE.md)。 if persistText, let t = UserDefaults.standard.string(forKey: sharedTextKey), !t.isEmpty { textDraft = t } @@ -673,88 +673,3 @@ final class AppState: ObservableObject { window.setFrame(frame, display: true, animate: true) } } - -// 一条最近分享记录(持久化到 UserDefaults)。paths 支持多选(1=单项、N=多选); -// text 非 nil 即「文本分享」条目(paths 为空,仅在「记住分享的文本」开启时落库)。 -struct RecentShare: Codable, Identifiable, Equatable { - let paths: [String] - let isFile: Bool // 仅单项有意义 - let detail: String - let date: Date - let text: String? // 文本分享条目的原文;文件条目为 nil - - var isText: Bool { text != nil } - var isMultiple: Bool { paths.count > 1 } - // 文本条目以内容作身份(同一段文本去重、重分享移到顶部);文件条目以路径**集合**作身份 - //(排序后拼接,与 isLive / isCurrentShare 的 Set(paths) 口径一致——同一组文件不同选中顺序视作同一条,避免去重失效)。 - var id: String { text.map { "\u{1}text\u{1}\($0)" } ?? Set(paths).sorted().joined(separator: "\n") } - // 文本给首行预览(空白回退「文本」),多选给本地化计数名,单项给文件名。由持有 lang 的 View 传入。 - func displayName(_ lang: Lang) -> String { - if let text { let p = FileServer.textPreview(text); return p.isEmpty ? L.webText(lang) : p } - if isMultiple { return LStr.multiItemName(paths.count, lang) } - return paths.first.map { ($0 as NSString).lastPathComponent } ?? "" - } - // 文本条目恒可重分享;文件条目只要还有一项存在即可(reshare 时再剔除缺失项)。 - var exists: Bool { - if text != nil { return true } - let fm = FileManager.default - return paths.contains { fm.fileExists(atPath: $0) } - } - - init(paths: [String], isFile: Bool, detail: String, date: Date, text: String? = nil) { - self.paths = paths; self.isFile = isFile; self.detail = detail; self.date = date; self.text = text - } - - // 兼容旧记录:旧版用单 `path` 字段,迁移为 `paths = [path]`;旧记录无 text 字段,解码缺省为 nil。 - enum CodingKeys: String, CodingKey { case paths, path, isFile, detail, date, text } - init(from decoder: Decoder) throws { - let c = try decoder.container(keyedBy: CodingKeys.self) - if let arr = try? c.decode([String].self, forKey: .paths) { - paths = arr - } else if let p = try? c.decode(String.self, forKey: .path) { - paths = [p] - } else { - paths = [] - } - isFile = (try? c.decode(Bool.self, forKey: .isFile)) ?? false - detail = (try? c.decode(String.self, forKey: .detail)) ?? "" - date = (try? c.decode(Date.self, forKey: .date)) ?? Date(timeIntervalSince1970: 0) - text = try? c.decodeIfPresent(String.self, forKey: .text) - } - // 显式 encode(CodingKeys 含迁移用的 .path,会阻断合成):只写 paths 等当前字段。 - func encode(to encoder: Encoder) throws { - var c = encoder.container(keyedBy: CodingKeys.self) - try c.encode(paths, forKey: .paths) - try c.encode(isFile, forKey: .isFile) - try c.encode(detail, forKey: .detail) - try c.encode(date, forKey: .date) - try c.encodeIfPresent(text, forKey: .text) - } -} - -// MARK: - 端口实时校验(DESIGN.md §6.3) - -enum PortState { case ok, occupied, invalid } - -struct PortCheck { - let state: PortState - let message: String - let suggest: in_port_t? -} - -// 输入即校验:空 / 越界 → invalid;命中常用占用端口 → occupied + 建议下一个可用;其余 → ok。 -// 占用集合为启发式(与设计稿一致);真正能否绑定以「应用」时实际 start 结果为准。 -func validatePort(_ raw: String, _ lang: Lang) -> PortCheck { - let occupied: Set = [80, 443, 3000, 5000, 5432, 3306, 8000, 7890] - let v = raw.trimmingCharacters(in: .whitespaces) - guard !v.isEmpty else { return PortCheck(state: .invalid, message: L.portEmptyMsg(lang), suggest: nil) } - guard let n = Int(v) else { return PortCheck(state: .invalid, message: L.portNotNumberMsg(lang), suggest: nil) } - if n < 1024 { return PortCheck(state: .invalid, message: L.portTooLowMsg(lang), suggest: nil) } - if n > 65535 { return PortCheck(state: .invalid, message: L.portTooHighMsg(lang), suggest: nil) } - if occupied.contains(n) { - var s = n + 1 - while occupied.contains(s) || s > 65535 { s = s > 65535 ? 1024 : s + 1 } - return PortCheck(state: .occupied, message: LStr.portOccupied(v, lang), suggest: in_port_t(s)) - } - return PortCheck(state: .ok, message: L.portOk(lang), suggest: nil) -} diff --git a/Sources/LocalShare/ContentView.swift b/Sources/LocalShare/ContentView.swift index 8ee4715..2343c9b 100644 --- a/Sources/LocalShare/ContentView.swift +++ b/Sources/LocalShare/ContentView.swift @@ -103,1521 +103,3 @@ struct ContentView: View { return true } } - -// MARK: - 屏幕脚手架(顶留红绿灯 + 内容 + 底部 HelpRow) - -private let hPad: CGFloat = 22 - -private struct ScreenFrame: View { - let t: Theme - @ViewBuilder var header: () -> Header - @ViewBuilder var content: () -> Body - var body: some View { - VStack(spacing: 0) { - header() - .padding(.horizontal, hPad).padding(.top, 40).padding(.bottom, 14) - ScrollView { - content().padding(.horizontal, hPad).padding(.bottom, 6) - .background(OverlayScrollers()) - } - HelpRow(t: t).padding(.horizontal, hPad).padding(.vertical, 12) - } - } -} - -// 把 SwiftUI ScrollView 底层的 NSScrollView 强制为 overlay 滚动条样式。 -// 鼠标用户在系统「始终显示滚动条」下,默认会拿到常驻、挤占右侧宽度的 legacy 滚动条 -//(`.scrollIndicators(.hidden)` 也压不住它);改 overlay 后与触控板一致——空闲隐藏、 -// 滚动/悬停时才细细淡入,且不占布局宽度。挂在内容里靠 enclosingScrollView 反查容器。 -// 关键:必须在首帧绘制前同步改样式。若拖到下一个 runloop(DispatchQueue.async)才改, -// 内容超出视口的页(如设置页)会先按 legacy 滚动条占走右侧 ~15px 布一次,下一拍切 overlay -// 再把这 15px 还回去——肉眼即「内容向右撑开」的闪动。故改用视图入树回调即时应用,不延后。 -private struct OverlayScrollers: NSViewRepresentable { - func makeNSView(context: Context) -> NSView { ScrollerStyler() } - func updateNSView(_ nsView: NSView, context: Context) { - (nsView as? ScrollerStyler)?.applyOverlay() - } -} - -// 入树即应用:viewDidMoveToSuperview / ToWindow 都在首帧绘制前同步触发,反查 enclosingScrollView -// 并切 overlay;任一时机还拿不到容器,后一个时机补上,全程无 async 延迟。 -private final class ScrollerStyler: NSView { - override func viewDidMoveToSuperview() { super.viewDidMoveToSuperview(); applyOverlay() } - override func viewDidMoveToWindow() { super.viewDidMoveToWindow(); applyOverlay() } - func applyOverlay() { - guard let scroll = enclosingScrollView else { return } - scroll.scrollerStyle = .overlay - scroll.autohidesScrollers = true - } -} - -// MARK: - 文本编辑弹层(空状态「分享文本」/ 分享屏「编辑文本」共用) - -// 离散提交:点「分享 / 更新」才把当前编辑器内容作为新分享广播(state.setSharedText)。 -// 初值来自 textDraft(重启可回填上次内容);清空再提交即撤下文本。 -private struct TextEntrySheet: View { - let t: Theme - let isUpdate: Bool - @EnvironmentObject var state: AppState - @Environment(\.dismiss) private var dismiss - @State private var text: String - - init(t: Theme, initial: String, isUpdate: Bool) { - self.t = t; self.isUpdate = isUpdate - _text = State(initialValue: initial) - } - - private var blank: Bool { text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty } - - var body: some View { - let lang = state.lang - VStack(alignment: .leading, spacing: 14) { - HStack { - Text(L.shareTextButton(lang)).font(.display(18, .semibold)).foregroundStyle(t.ink) - Spacer() - IconButton(t: t, systemImage: "xmark", help: L.back(lang)) { dismiss() } - } - // placeholder 由 NSTextView 自绘:能查 hasMarkedText() 在中文输入法拼音组合时即让位, - // 且画在文本容器内、与正文同一坐标系,对齐天然成立(不再用 SwiftUI 叠加层)。 - PlainTextEditor(text: $text, placeholder: L.textEditorPlaceholder(lang), - placeholderColor: NSColor(t.inkFaint), textColor: NSColor(t.ink), - caret: NSColor(t.accent), inset: 8, autoFocus: true) - .frame(minHeight: 210) - .background(RoundedRectangle(cornerRadius: 12, style: .continuous).fill(t.field)) - .overlay(RoundedRectangle(cornerRadius: 12, style: .continuous).strokeBorder(t.line, lineWidth: 1)) - PrimaryButton(t: t, title: isUpdate ? L.textUpdateAction(lang) : L.textShareAction(lang), - systemImage: "paperplane.fill") { - state.setSharedText(text) - dismiss() - } - .disabled(blank) - .opacity(blank ? 0.5 : 1) - } - .padding(20) - .frame(width: 380, height: 380) - .background(t.bg) - } -} - -// 自带 NSTextView 的纯文本编辑器。两个问题一并根治: -// ① 对齐:SwiftUI 的 TextEditor 底层 NSTextView 有一层 .padding() 控不到的内部内边距 -// (textContainerInset + textContainer.lineFragmentPadding 默认 5);这里把 lineFragmentPadding 归零、 -// textContainerInset 显式设成 (inset,inset),文字起点完全由 inset 决定。 -// ② 中文输入法:placeholder 交给 NSTextView 自绘(PlaceholderTextView),它能查 hasMarkedText()—— -// 拼音组合上屏的瞬间就让位,不与未确认的拼音串重叠。 -private struct PlainTextEditor: NSViewRepresentable { - @Binding var text: String - var placeholder: String - var placeholderColor: NSColor - var textColor: NSColor - var caret: NSColor - var inset: CGFloat - var autoFocus: Bool - - func makeNSView(context: Context) -> NSScrollView { - let tv = PlaceholderTextView() - tv.delegate = context.coordinator - tv.font = .monospacedSystemFont(ofSize: 13, weight: .regular) - tv.textColor = textColor - tv.insertionPointColor = caret - tv.placeholder = placeholder - tv.placeholderColor = placeholderColor - tv.drawsBackground = false - tv.isRichText = false - tv.isEditable = true - tv.isSelectable = true - tv.allowsUndo = true - tv.textContainerInset = NSSize(width: inset, height: inset) - tv.textContainer?.lineFragmentPadding = 0 - // 在 NSScrollView 里随宽变行、随内容长高(标准配方)。 - tv.isVerticallyResizable = true - tv.isHorizontallyResizable = false - tv.autoresizingMask = [.width] - tv.maxSize = NSSize(width: CGFloat.greatestFiniteMagnitude, height: CGFloat.greatestFiniteMagnitude) - tv.textContainer?.widthTracksTextView = true - tv.string = text - - let scroll = NSScrollView() - scroll.documentView = tv - scroll.hasVerticalScroller = true - scroll.drawsBackground = false - scroll.borderType = .noBorder - scroll.autohidesScrollers = true - if autoFocus { - DispatchQueue.main.async { [weak tv] in tv?.window?.makeFirstResponder(tv) } - } - return scroll - } - - 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 - tv.placeholderColor = placeholderColor - } - - func makeCoordinator() -> Coordinator { Coordinator(self) } - - final class Coordinator: NSObject, NSTextViewDelegate { - var parent: PlainTextEditor - init(_ parent: PlainTextEditor) { self.parent = parent } - func textDidChange(_ note: Notification) { - guard let tv = note.object as? NSTextView else { return } - parent.text = tv.string // 含组合中的 marked text;committed 后即为最终文本 - } - } -} - -// 自绘 placeholder 的 NSTextView:仅当无内容且不在输入法组合态时才画占位文字—— -// 中文输入法拼音上屏(hasMarkedText() 为真)即让位,不与拼音串重叠;画在文本容器内,与正文同坐标系。 -private final class PlaceholderTextView: NSTextView { - var placeholder = "" - var placeholderColor: NSColor = .placeholderTextColor - - override func draw(_ dirtyRect: NSRect) { - super.draw(dirtyRect) - guard string.isEmpty, !hasMarkedText() else { return } - let attrs: [NSAttributedString.Key: Any] = [ - .foregroundColor: placeholderColor, - .font: font ?? .monospacedSystemFont(ofSize: 13, weight: .regular), - ] - // lineFragmentPadding 已归零,文字起点即 textContainerInset,占位文字画在同一处。 - placeholder.draw(at: NSPoint(x: textContainerInset.width, y: textContainerInset.height), withAttributes: attrs) - } - // 文本变化(含 marked text 的增删)后重绘,让占位文字及时显隐。 - override func didChangeText() { super.didChangeText(); needsDisplay = true } - override func setMarkedText(_ string: Any, selectedRange: NSRange, replacementRange: NSRange) { - super.setMarkedText(string, selectedRange: selectedRange, replacementRange: replacementRange) - needsDisplay = true - } - override func unmarkText() { super.unmarkText(); needsDisplay = true } -} - -// MARK: - 空状态 - -private struct HomeScreen: View { - let t: Theme - var dragging: Bool - @EnvironmentObject var state: AppState - // 横幅标题:单项=文件/夹名(中段截断),多选=「N 项」(与票据 multipleStub 标题一致)。 - private var activeShareName: String { - state.isMultiple ? LStr.itemCount(state.sharedItems.count, state.lang) - : (state.sharedURL?.lastPathComponent ?? "") - } - // 「传递文本」入口的呼吸点只该表「文本在后台续跑」,故精确判文本态——而非笼统的 isRunning - //(现在主页可能正跑着文件分享,那不该让文本入口误亮)。 - private var textActive: Bool { state.isRunning && (state.hasText || state.textInboxEnabled) } - var body: some View { - let ps = permSummary(state.permission, state.lang) - ScreenFrame(t: t) { - HStack(alignment: .top) { - VStack(alignment: .leading, spacing: 2) { - Text(ps.eyebrow).font(.sans(11, .bold)).tracking(1.2).foregroundStyle(t.accent) - Text("LocalShare").font(.display(28, .semibold)).tracking(-0.3).foregroundStyle(t.ink) - } - Spacer() - // 主页通常「待命」;但文件分享 / 传递文本可在后台续跑(退回主页时),此刻如实显运行态 - //(亮点 + 实际 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) { - // 文件分享在后台续跑、用户退回主页时,顶部出可点横幅一键回票据;停止未清除也显(静默态)。 - if !state.sharedItems.isEmpty { - ActiveShareBanner(t: t, lang: state.lang, name: activeShareName, - running: state.isRunning, viewers: state.viewerCount) { state.enterFile() } - .padding(.bottom, 12) - } - dropZone - // 平级第二入口:传递文本(收/发合一)。点进独立二级页,主页只负责选功能、不就地干活。 - // 收件箱有未读时角标提示;文本在后台续跑时缀呼吸点(见 textActive)。 - TransferTextButton(t: t, lang: state.lang, active: textActive, - 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) }, - onDelete: { state.deleteRecent($0) }) - } - } - } - } - - private var dropZone: some View { - VStack(spacing: 0) { - RoundedRectangle(cornerRadius: 16, style: .continuous) - .fill(t.accentSoft) - .frame(width: 56, height: 56) - .overlay(Image(systemName: "arrow.up.to.line").font(.system(size: 24, weight: .medium)).foregroundStyle(t.accent)) - .padding(.bottom, 14) - Text(L.dropZoneTitle(state.lang)).font(.sans(15.5, .semibold)).foregroundStyle(t.ink) - Text(L.dropZoneSub(state.lang)).font(.sans(12.5)).foregroundStyle(t.inkMute).padding(.top, 4) - PrimaryButton(t: t, title: L.pickAnyButton(state.lang), systemImage: "doc.badge.plus") { state.pickAny() } - .padding(.top, 18) - } - .frame(maxWidth: .infinity) - .padding(.horizontal, 20).padding(.top, 34).padding(.bottom, 28) - .background(RoundedRectangle(cornerRadius: 18, style: .continuous).fill(dragging ? t.accentSoft : t.surface)) - .overlay( - RoundedRectangle(cornerRadius: 18, style: .continuous) - .strokeBorder(style: StrokeStyle(lineWidth: 2, dash: [6, 5])) - .foregroundStyle(dragging ? t.accent : t.lineStrong) - ) - } -} - -// MARK: - 分享屏(单文件 / 文件夹票据) - -private struct ShareScreen: View { - let t: Theme - @EnvironmentObject var state: AppState - @State private var showViewers = false // 在线访客明细弹窗(点摘要行展开) - @State private var showText = false // 文本编辑弹层(编辑当前分享的文本) - private func editText() { showText = true } - var body: some View { - let ps = permSummary(state.permission, state.lang) - ScreenFrame(t: t) { - // 二级页头部,与传递文本页同款:← 返回主页 + 标题 +齿轮。运行态/地址在票据正文(QR 说明 + - // CopyPill)已具,头部不再堆 StatusPill,避免与正文重复、并与 TextScreen 一致。 - HStack(spacing: 10) { - IconButton(t: t, systemImage: "chevron.left", help: L.back(state.lang)) { state.goShare() } - Text(L.shareFileTitle(state.lang)).font(.display(21, .semibold)).foregroundStyle(t.ink) - Spacer() - IconButton(t: t, systemImage: "gearshape", help: L.settings(state.lang)) { state.openSettings() } - } - } content: { - VStack(spacing: 16) { - ticket(ps) - // 文本与文件共存:在票据下补一张「附带文本」小卡(预览 + 编辑)。纯文本分享则文本就是票据本身。 - if state.hasText && !state.isTextOnly { attachedTextCard } - if !state.received.isEmpty { receivedCard } - actions - if state.interfaces.count > 1 { interfacePicker } - if state.sharedIsFile && state.showRecents { - RecentSharesView(t: t, lang: state.lang, items: state.recents.filter { $0.exists && Set($0.paths) != state.currentSharePaths }, - onAll: { state.openHistory() }, onReshare: { state.reshare($0) }, - onDelete: { state.deleteRecent($0) }) - } - } - } - .sheet(isPresented: $showText) { TextEntrySheet(t: t, initial: state.textDraft, isUpdate: true) } - } - - private func ticket(_ ps: PermSummary) -> some View { - TicketCard(t: t) { - if state.isTextOnly { AnyView(textStub(ps)) } - else if state.isMultiple { AnyView(multipleStub(ps)) } - else if state.sharedIsFile { AnyView(fileStub(ps)) } - else { AnyView(folderStub(ps)) } - } pass: { - qrPass - } - } - - // 纯文本分享的存根:文本图标 + 「正在分享文本」+ 字数 + 前几行预览。 - private func textStub(_ ps: PermSummary) -> some View { - let lang = state.lang - let text = state.sharedText ?? "" - return VStack(alignment: .leading, spacing: 9) { - HStack(alignment: .top, spacing: 12) { - TextGlyph(t: t, size: 42) - VStack(alignment: .leading, spacing: 2) { - Text("\(L.sharingTextKicker(lang)) · \(ps.tag)").font(.sans(10.5, .bold)).tracking(0.8).foregroundStyle(t.inkMute) - Text(L.webText(lang)).font(.sans(16, .bold)).foregroundStyle(t.ink) - Text(LStr.charCount(text.count, lang)).font(.mono(11.5)).foregroundStyle(t.inkMute) - } - Spacer(minLength: 8) - ClearButton(t: t, lang: lang) { state.clearShare() } - } - Text(text).font(.mono(11.5)).foregroundStyle(t.inkFaint) - .lineLimit(3).truncationMode(.tail) - .frame(maxWidth: .infinity, alignment: .leading) - .contentShape(Rectangle()) - .onTapGesture { editText() } - } - .padding(.horizontal, 18).padding(.vertical, 16) - } - - // 文本+文件时的「附带文本」卡:单行预览 + 编辑入口(清空再提交即撤下文本,文件保留)。 - private var attachedTextCard: some View { - let lang = state.lang - return HStack(spacing: 11) { - TextGlyph(t: t, size: 30) - VStack(alignment: .leading, spacing: 1) { - Text(L.sharingTextKicker(lang)).font(.sans(11, .bold)).tracking(0.5).foregroundStyle(t.inkMute) - Text(state.sharedText ?? "").font(.mono(11.5)).foregroundStyle(t.ink) - .lineLimit(1).truncationMode(.tail) - } - Spacer(minLength: 6) - GhostButton(t: t, title: L.editTextButton(lang), systemImage: "pencil") { editText() } - } - .padding(.horizontal, 14).padding(.vertical, 10) - .background(RoundedRectangle(cornerRadius: 14, style: .continuous).fill(t.surface)) - .overlay(RoundedRectangle(cornerRadius: 14, style: .continuous).strokeBorder(t.line, lineWidth: 1)) - } - - // 多项存根:叠放印章 + 「正在分享 N 项」+ 文件/文件夹分项概要 + 前几项名称预览。 - private func multipleStub(_ ps: PermSummary) -> some View { - let items = state.sharedItems - let lang = state.lang - let dirCount = items.filter { (try? $0.resourceValues(forKeys: [.isDirectoryKey]).isDirectory) == true }.count - let fileCount = items.count - dirCount - var parts: [String] = [] - if fileCount > 0 { parts.append(LStr.fileCount(fileCount, lang)) } - if dirCount > 0 { parts.append(LStr.folderCount(dirCount, lang)) } - let preview = items.prefix(3).map(\.lastPathComponent).joined(separator: lang == .zh ? "、" : ", ") - + (items.count > 3 ? (lang == .zh ? " 等" : " …") : "") - return VStack(alignment: .leading, spacing: 9) { - HStack(alignment: .top, spacing: 12) { - MultiGlyph(t: t, size: 42) - VStack(alignment: .leading, spacing: 2) { - Text("\(L.sharingKicker(lang)) · \(ps.tag)").font(.sans(10.5, .bold)).tracking(0.8).foregroundStyle(t.inkMute) - Text(LStr.itemCount(items.count, lang)).font(.sans(16, .bold)).foregroundStyle(t.ink) - Text(parts.joined(separator: " · ")).font(.mono(11.5)).foregroundStyle(t.inkMute) - } - Spacer(minLength: 8) - ClearButton(t: t, lang: lang) { state.clearShare() } - } - MultiPreviewMenu(t: t, lang: lang, items: items, preview: preview) { state.revealInFinder($0) } - } - .padding(.horizontal, 18).padding(.vertical, 16) - } - - // 单文件存根 - private func fileStub(_ ps: PermSummary) -> some View { - let url = state.sharedURL ?? URL(fileURLWithPath: "/") - let cat = FileType.category(of: url, isDir: false) - let catName = (cat == .other) ? L.fileKind(state.lang) : cat.displayName(state.lang) - return VStack(alignment: .leading, spacing: 9) { - HStack(alignment: .top, spacing: 12) { - TypeGlyph(t: t, category: cat, ext: url.pathExtension.lowercased(), size: 42) - VStack(alignment: .leading, spacing: 2) { - Text(ps.tag).font(.sans(10.5, .bold)).tracking(0.8).foregroundStyle(t.inkMute) - Text(url.lastPathComponent).font(.sans(14, .semibold)).foregroundStyle(t.ink) - .lineLimit(1).truncationMode(.middle) - Text("\(state.sharedDetail ?? "") · \(catName)").font(.mono(11.5)).foregroundStyle(t.inkMute) - } - Spacer(minLength: 8) - ClearButton(t: t, lang: state.lang) { state.clearShare() } - } - PathRow(t: t, lang: state.lang, url: url, isFile: true) - } - .padding(.horizontal, 18).padding(.vertical, 16) - } - - // 文件夹存根(含路径 + 权限 chips + 改权限入口) - private func folderStub(_ ps: PermSummary) -> some View { - let url = state.sharedURL ?? URL(fileURLWithPath: "/") - return VStack(alignment: .leading, spacing: 0) { - HStack(alignment: .top, spacing: 12) { - FolderGlyph(t: t, size: 42) - VStack(alignment: .leading, spacing: 2) { - Text("\(L.sharingFolderKicker(state.lang)) · \(ps.tag)").font(.sans(10.5, .bold)).tracking(0.8).foregroundStyle(t.inkMute) - Text(url.lastPathComponent).font(.sans(16, .bold)).foregroundStyle(t.ink) - .lineLimit(1).truncationMode(.middle) - Text(state.sharedDetail ?? "").font(.mono(11.5)).foregroundStyle(t.inkMute) - } - Spacer(minLength: 8) - ClearButton(t: t, lang: state.lang) { state.clearShare() } - } - .padding(.horizontal, 18).padding(.top, 16) - PathRow(t: t, lang: state.lang, url: url, isFile: false) - .padding(.horizontal, 18).padding(.top, 8).padding(.bottom, 12) - HStack(spacing: 6) { - ForEach(Array(ps.chips.enumerated()), id: \.offset) { i, c in - PermChip(t: t, text: c, hot: ps.writable && i > 0) - } - Spacer() - Button { state.openSettings() } label: { - Text(L.changePerm(state.lang)).font(.sans(11)).foregroundStyle(t.accent) - }.buttonStyle(.plain) - } - .padding(.horizontal, 18).padding(.bottom, 14) - } - } - - // 访客新上传的文件(最多列 3 条,点击在 Finder 中显示)。换分享/清除时由 AppState 清空。 - private var receivedCard: some View { - VStack(alignment: .leading, spacing: 0) { - HStack(spacing: 6) { - Circle().fill(t.accent).frame(width: 6, height: 6) - Text(L.received(state.lang)).font(.sans(11, .bold)).tracking(0.8).foregroundStyle(t.inkMute) - Spacer() - if state.received.count > 3 { - Text(LStr.itemCount(state.received.count, state.lang)).font(.mono(11)).foregroundStyle(t.inkFaint) - } - } - .padding(.horizontal, 16).padding(.top, 12).padding(.bottom, 5) - ForEach(state.received.prefix(3), id: \.self) { url in - ReceivedRow(t: t, lang: state.lang, url: url) { state.revealReceived(url) } - } - } - .padding(.bottom, 8) - .background(RoundedRectangle(cornerRadius: 14, style: .continuous).fill(t.surface)) - .overlay(RoundedRectangle(cornerRadius: 14, style: .continuous).strokeBorder(t.line, lineWidth: 1)) - } - - // 通行区:QR + 说明 + 复制条 - private var qrPass: some View { - let running = state.isRunning - let caption = state.isTextOnly ? L.scanCaptionText(state.lang) - : (state.isMultiple ? L.scanCaptionMultiple(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) - CopyPill(t: t, lang: state.lang, value: state.primaryURL ?? "—", - compact: true, onOpen: openInBrowser).padding(.top, 10) - if let local = state.localURL { - // 备用地址(主机名 / .local)紧贴主地址、归入卡内,保持内聚。左缩进对齐上方地址文字。 - BackupAddressRow(t: t, lang: state.lang, full: local) { - if let url = URL(string: local) { NSWorkspace.shared.open(url) } - } - .padding(.top, 7).padding(.leading, 12) - } - // 在线访客:小绿点 + 摘要文案;点一下展开全部访客明细(设备名 / 完整 IP)。 - // 0 人时整行隐藏(不占位、不留空文案)。 - if running && state.viewerCount > 0 { - Button { showViewers.toggle() } label: { - HStack(spacing: 6) { - Circle().fill(t.ok).frame(width: 6, height: 6) - Text(viewerText).font(.sans(11.5)).foregroundStyle(t.inkMute) - .lineLimit(1).truncationMode(.tail) - Image(systemName: "chevron.down").font(.sans(8, .semibold)).foregroundStyle(t.inkFaint) - } - } - .buttonStyle(.plain) - .padding(.top, 12) - .transition(.opacity) - .popover(isPresented: $showViewers, arrowEdge: .bottom) { - ViewerListPopover(t: t, lang: state.lang, viewers: state.viewers) - } - } - } - .padding(.horizontal, 18).padding(.bottom, 18) - .animation(.easeInOut(duration: 0.2), value: state.viewerCount > 0) - } - - // 在线访客摘要:反查到设备名才领衔具名(单台直呼其名、多台「领衔 + 等 N 人」); - // 查不到则统一「N 人正在浏览」——不在摘要露 IP 尾号,完整 IP 留给展开列表。 - private var viewerText: String { - LStr.viewerSummary(name: state.viewers.first?.name, count: state.viewerCount, state.lang) - } - - @ViewBuilder private var actions: some View { - if state.isRunning { - HStack(spacing: 10) { - // 纯文本分享:主操作是「编辑文本」而非更换文件。 - if state.isTextOnly { - GhostButton(t: t, title: L.editTextButton(state.lang), - systemImage: "pencil", fullWidth: true) { editText() } - } else { - GhostButton(t: t, title: state.sharedIsFile ? L.replaceFile(state.lang) : L.replace(state.lang), - systemImage: "arrow.left.arrow.right", fullWidth: true) { state.pickAny() } - } - DangerButton(t: t, title: L.stop(state.lang)) { state.stop() } - } - } 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() } - } - } - } - - private var interfacePicker: some View { - Menu { - ForEach(state.interfaces) { iface in - Button(iface.displayName(state.lang)) { state.selectInterface(iface) } - } - } label: { - HStack(spacing: 7) { - Image(systemName: "dot.radiowaves.left.and.right").font(.system(size: 11)) - Text(state.selectedInterface?.displayName(state.lang) ?? L.selectSource(state.lang)).font(.mono(11)) - Image(systemName: "chevron.down").font(.system(size: 8, weight: .semibold)) - } - .foregroundStyle(t.ink) - .padding(.horizontal, 13).padding(.vertical, 7) - .background(Capsule().strokeBorder(t.line, lineWidth: 1)) - } - .menuStyle(.borderlessButton).menuIndicator(.hidden).fixedSize() - } - - private func openInBrowser() { - guard let s = state.primaryURL, let url = URL(string: s) else { return } - NSWorkspace.shared.open(url) - } -} - -// 多选项目预览行:标签沿用原淡色名称预览(前 3 项 + 等),点击弹出全部分享项菜单, -// 选中即在 Finder 中显示——与单项分享的 PathRow、收件行同一交互语言(hover 下划线 + 手型)。 -private struct MultiPreviewMenu: View { - let t: Theme - let lang: Lang - let items: [URL] - let preview: String - let reveal: (URL) -> Void - @State private var hover = false - var body: some View { - Menu { - Section(L.revealInFinder(lang)) { - ForEach(items, id: \.self) { url in - Button(url.lastPathComponent) { reveal(url) } - } - } - } label: { - HStack(alignment: .firstTextBaseline, spacing: 5) { - Text(preview) - .font(.sans(11.5)) - .foregroundStyle(hover ? t.inkMute : t.inkFaint) - .underline(hover, color: t.inkFaint) - .lineLimit(2).truncationMode(.tail) - .multilineTextAlignment(.leading) - Image(systemName: "chevron.down") - .font(.system(size: 7.5, weight: .semibold)) - .foregroundStyle(hover ? t.accent : t.inkFaint) - } - .contentShape(Rectangle()) - } - .menuStyle(.borderlessButton).menuIndicator(.hidden) - .fixedSize(horizontal: false, vertical: true) - .onHover { h in hover = h; if h { NSCursor.pointingHand.push() } else { NSCursor.pop() } } - .help(L.revealShareItems(lang)) - } -} - -// 收件单行:类型小图标 + 文件名,悬停亮出跳转箭头(点击在 Finder 中显示)。 -private struct ReceivedRow: View { - let t: Theme - let lang: Lang - let url: URL - let reveal: () -> Void - @State private var hover = false - var body: some View { - Button(action: reveal) { - HStack(spacing: 9) { - TypeGlyph(t: t, category: FileType.category(of: url, isDir: false), - ext: url.pathExtension.lowercased(), size: 26) - Text(url.lastPathComponent).font(.sans(12.5, .medium)).foregroundStyle(t.ink) - .lineLimit(1).truncationMode(.middle) - Spacer(minLength: 8) - Image(systemName: "arrow.up.forward") - .font(.system(size: 10, weight: .semibold)) - .foregroundStyle(hover ? t.accent : t.inkFaint) - } - .padding(.horizontal, 16).padding(.vertical, 6) - .contentShape(Rectangle()) - .background(hover ? t.surfaceAlt : .clear) - } - .buttonStyle(.plain) - .onHover { hover = $0 } - .help(L.revealInFinder(lang)) - } -} - -// 收件箱卡片(收文本 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 } - } -} - -// 缓慢呼吸(淡入淡出)的小圆点:表「实时进行中」的状态,用动效与静态未读角标区分,避免红点冒充未读。 -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 - let active: Bool // 接收正开着 - let unread: Int - let action: () -> Void - @State private var hover = false - var body: some View { - Button(action: action) { - 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 { - PulsingDot(color: t.accent) - } - } - .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 } - } -} - -// 主页「正在分享」横幅:文件分享在后台续跑、用户却退回主页时,用一条紧凑可点的横幅如实呈现—— -// 点按回到文件票据(.file)看二维码。运行中缀呼吸点 + 在线人数;停止未清除时静默(无呼吸点)。 -// 与 TransferTextButton 同手法,但承载更多信息故略高;前导 qrcode 图标暗示「点这里回到码」, -// 配尾部 chevron 即足以表达「可点回去」,不另加文案(强约束:能用设计语言暗示就不堆字)。 -private struct ActiveShareBanner: View { - let t: Theme - let lang: Lang - let name: String // 单项=文件/夹名;多选=「N 项」 - let running: Bool - let viewers: Int - let action: () -> Void - @State private var hover = false - var body: some View { - Button(action: action) { - HStack(spacing: 11) { - Image(systemName: "qrcode").font(.system(size: 16, weight: .medium)).foregroundStyle(t.accent) - VStack(alignment: .leading, spacing: 1) { - HStack(spacing: 6) { - Text(L.sharingKicker(lang)).font(.sans(10.5, .bold)).tracking(0.6).foregroundStyle(t.inkMute) - if running { PulsingDot(color: t.ok) } - if running && viewers > 0 { - Text(LStr.viewerCountLabel(viewers, lang)).font(.sans(10.5)).foregroundStyle(t.inkMute) - } - } - Text(name).font(.sans(13.5, .semibold)).foregroundStyle(t.ink) - .lineLimit(1).truncationMode(.middle) - } - Spacer(minLength: 8) - Image(systemName: "chevron.right").font(.system(size: 12, weight: .semibold)).foregroundStyle(t.inkFaint) - } - .padding(.horizontal, 14).frame(height: 52) - .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 } - } -} - -// 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 - 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() } - } - } content: { - VStack(spacing: 16) { - if state.isRunning, state.qrImage != nil { qrCard } else { idleHint } - 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 } - } - - // 发文本:编辑器 + 发送/更新(与当前广播一致时置灰);已在广播则可「撤回」(撤下文本,文件不受影响)。 - 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.sendTextKicker(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) - } - } - 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) } - } - .padding(.top, 7).padding(.leading, 12) - } - } - .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),最近活跃在前。 -// 仅分享者本机可见——网页端永不外泄身份(见 FileServer.activeViewers)。 -private struct ViewerListPopover: View { - let t: Theme - let lang: Lang - let viewers: [ViewerInfo] - var body: some View { - VStack(alignment: .leading, spacing: 0) { - HStack(spacing: 6) { - Circle().fill(t.ok).frame(width: 6, height: 6) - Text(L.viewing(lang)).font(.sans(11, .bold)).tracking(0.8).foregroundStyle(t.inkMute) - Spacer(minLength: 16) - Text(LStr.viewerCountLabel(viewers.count, lang)).font(.mono(11)).foregroundStyle(t.inkFaint) - } - .padding(.horizontal, 14).padding(.top, 12).padding(.bottom, 8) - ForEach(viewers) { v in - // 左:身份(查到设备名则名字为主、完整 IP 作副行;查不到直接显示完整 IP)。 - // 右:本次浏览开始至今的时长,尾部对齐成一列,便于多人时纵向扫读。 - HStack(alignment: .firstTextBaseline, spacing: 10) { - VStack(alignment: .leading, spacing: 1) { - Text(v.fullLabel).font(.sans(12.5, .medium)).foregroundStyle(t.ink) - .lineLimit(1).truncationMode(.middle) - if !v.name.isEmpty { - Text(v.ip).font(.mono(10.5)).foregroundStyle(t.inkFaint) - } - } - Spacer(minLength: 8) - Text(LStr.elapsed(v.since, lang)).font(.sans(10.5)).foregroundStyle(t.inkFaint) - .fixedSize() - } - .frame(maxWidth: .infinity, alignment: .leading) - .padding(.horizontal, 14).padding(.vertical, 5) - } - } - .padding(.bottom, 8) - .frame(width: 230) - } -} - -// MARK: - 未接入局域网 - -private struct NoNetworkScreen: View { - let t: Theme - @EnvironmentObject var state: AppState - var body: some View { - ScreenFrame(t: t) { - // 现挂在文件票据二级页(.file)下,带 ← 返回主页,免得无网络时卡死在此页。 - HStack(spacing: 10) { - IconButton(t: t, systemImage: "chevron.left", help: L.back(state.lang)) { state.goShare() } - Text(L.shareFileTitle(state.lang)).font(.display(21, .semibold)).foregroundStyle(t.ink) - Spacer() - IconButton(t: t, systemImage: "gearshape", help: L.settings(state.lang)) { state.openSettings() } - } - } content: { - VStack(spacing: 14) { - Spacer(minLength: 60) - Image(systemName: "wifi.slash").font(.system(size: 46)).foregroundStyle(t.inkFaint) - Text(L.noNetwork(state.lang)).font(.display(21)).foregroundStyle(t.ink) - Text(L.noNetworkHint(state.lang)) - .font(.sans(13)).foregroundStyle(t.inkMute) - .multilineTextAlignment(.center).lineSpacing(3) - GhostButton(t: t, title: L.refresh(state.lang), systemImage: "arrow.clockwise") { state.refreshNetwork() } - .padding(.top, 4) - Spacer(minLength: 40) - } - .frame(maxWidth: .infinity) - } - } -} - -// MARK: - 设置(网络 / 访问权限 / 外观 / 主界面 / 命令行工具) - -private struct SettingsScreen: View { - let t: Theme - @EnvironmentObject var state: AppState - @EnvironmentObject var updater: UpdaterController - @State private var portText = "" - var body: some View { - // portText 初始为空、onAppear 才填入当前端口;首帧若按空串校验会闪出「无效 + 放弃/应用」行再弹回。 - // 空串一律视作「当前生效端口」,让首帧与落定后一致,消除进入设置页时的这层闪烁。 - let lang = state.lang - let effectivePort = portText.isEmpty ? String(state.configuredPort) : portText - let pv = validatePort(effectivePort, lang) - let pColor = pv.state == .ok ? t.ok : (pv.state == .occupied ? t.warn : t.danger) - let changed = !portText.isEmpty && (Int(portText) ?? -1) != Int(state.configuredPort) - let ps = permSummary(state.permission, lang) - return ScreenFrame(t: t) { - HStack(spacing: 10) { - IconButton(t: t, systemImage: "chevron.left", help: L.back(lang)) { state.goShare() } - Text(L.shareSettings(lang)).font(.display(21, .semibold)).foregroundStyle(t.ink) - Spacer() - } - } content: { - VStack(alignment: .leading, spacing: 0) { - // MARK: 网络(监听端口 + 可见范围) - SectionLabel(t: t, text: L.sectionNetwork(lang)).padding(.bottom, 8) - - // 监听端口:IP 前缀 + 端口输入框 + 实时可用性校验。 - HStack(spacing: 10) { - Text("\(state.selectedInterface?.ip ?? L.thisMachine(lang)) :").font(.mono(14)).foregroundStyle(t.inkMute) - TextField("", text: $portText) - .textFieldStyle(.plain) - .font(.mono(15, .bold)).foregroundStyle(t.ink) - .frame(width: 72) - .padding(.horizontal, 10).padding(.vertical, 6) - .background(RoundedRectangle(cornerRadius: 8, style: .continuous).fill(t.field)) - .overlay(RoundedRectangle(cornerRadius: 8, style: .continuous) - .strokeBorder(pv.state == .ok ? t.lineStrong : pColor, lineWidth: 1.5)) - .onChange(of: portText) { portText = String($0.filter(\.isNumber).prefix(5)) } - .onSubmit { apply(pv, changed: changed) } - Spacer() - HStack(spacing: 5) { - Image(systemName: pv.state == .ok ? "checkmark" : "questionmark.circle") - .font(.system(size: 13, weight: .bold)) - Text(pv.state == .ok ? L.portOk(lang) : (pv.state == .occupied ? L.portOccupied(lang) : L.portInvalid(lang))) - .font(.sans(11.5, .bold)) - } - .foregroundStyle(pColor) - } - .padding(.leading, 14).padding(.trailing, 10).padding(.vertical, 10) - .background(RoundedRectangle(cornerRadius: 12, style: .continuous).fill(t.surface)) - .overlay(RoundedRectangle(cornerRadius: 12, style: .continuous) - .strokeBorder(pv.state == .ok ? t.line : pColor, lineWidth: 1)) - - HStack(alignment: .top, spacing: 8) { - Text(pv.state == .ok ? L.portOkHint(lang) : pv.message) - .font(.sans(11.5, pv.state == .ok ? .regular : .semibold)) - .foregroundStyle(pv.state == .ok ? t.inkMute : pColor) - .fixedSize(horizontal: false, vertical: true) - Spacer(minLength: 4) - if let s = pv.suggest { - Button { portText = String(s) } label: { - Text(LStr.changeToPort(s, lang)).font(.sans(11.5, .bold)).foregroundStyle(t.accent) - .padding(.horizontal, 10).frame(height: 24) - .background(Capsule().fill(t.accentSoft)) - }.buttonStyle(.plain) - } - } - .padding(.top, 8) - - // 改了才出现这排操作:放弃(还原成当前生效端口,无效输入也可退回)+ 应用。 - // 用纯色文字而非实心全宽块——重启服务不是破坏性动作,不必视觉吓人。 - if changed { - HStack(spacing: 18) { - Spacer() - Button { portText = String(state.configuredPort) } label: { - HStack(spacing: 5) { - Image(systemName: "arrow.uturn.backward").font(.system(size: 12, weight: .semibold)) - Text(L.discardChanges(lang)).font(.sans(13, .semibold)) - } - .foregroundStyle(t.inkMute) - } - .buttonStyle(.plain) - if pv.state != .invalid { - Button { apply(pv, changed: changed) } label: { - Text(L.applyRestart(lang)).font(.sans(13, .semibold)).foregroundStyle(t.accent) - } - .buttonStyle(.plain) - } - } - .padding(.top, 12) - } - - // 仅当前网络可见:同属网络设置,紧随端口、以分隔线归组。只有同时连了多个网络时才有意义, - // 故描述按是否多网卡分两种措辞,避免单网卡时给出空泛的“其它网络”字样。 - settingRow(top: true, title: L.bindOnlyTitle(lang), - desc: state.interfaces.count > 1 - ? L.bindOnlyDescMulti(lang) - : L.bindOnlyDescSingle(lang)) { - ToggleSwitch(t: t, isOn: state.bindSelectedOnly) { state.setBindSelectedOnly(!state.bindSelectedOnly) } - } - .padding(.top, 14) - - // MARK: 访问权限 - HStack { - SectionLabel(t: t, text: L.sectionPermission(lang)) - Spacer() - Text("\(L.currentColon(lang))\(ps.tag)").font(.sans(11, .bold)) - .foregroundStyle(ps.writable ? t.accent : t.inkMute) - .padding(.horizontal, 9).padding(.vertical, 2) - .background(Capsule().fill(ps.writable ? t.accentSoft : .clear)) - .overlay(Capsule().strokeBorder(ps.writable ? .clear : t.line, lineWidth: 1)) - } - .padding(.top, 24).padding(.bottom, 4) - - permRow(name: L.permReadName(lang), desc: L.permReadDesc(lang), locked: true, on: true) - permRow(name: L.permUploadName(lang), - desc: state.canToggleUpload ? L.permUploadDescOn(lang) : L.permUploadDescOff(lang), - locked: !state.canToggleUpload, - 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) - Text(ps.writable ? L.permInfoWritable(lang) : L.permInfoReadonly(lang)) - .font(.sans(11.5)).foregroundStyle(t.dark ? t.ink : Color(hex: 0x8a3a1e)).lineSpacing(2) - Spacer(minLength: 0) - } - .padding(12) - .background(RoundedRectangle(cornerRadius: 10, style: .continuous).fill(t.accentSoft)) - .padding(.top, 12) - - // 明文传输提示:纯 LAN 不加密,公共网络下同网的人能看到内容。用克制的灰字、不进彩底警告框。 - HStack(alignment: .top, spacing: 8) { - Image(systemName: "lock.open").font(.system(size: 13)).foregroundStyle(t.inkMute).padding(.top, 1) - Text(L.plaintextWarning(lang)) - .font(.sans(11.5)).foregroundStyle(t.inkMute).lineSpacing(2) - Spacer(minLength: 0) - } - .padding(.top, 12) - - // MARK: 外观 - SectionLabel(t: t, text: L.sectionAppearance(lang)).padding(.top, 24).padding(.bottom, 8) - HStack(spacing: 8) { - appearanceSeg(L.appearanceFollow(lang), .system) - appearanceSeg(L.appearanceLight(lang), .light) - appearanceSeg(L.appearanceDark(lang), .dark) - } - - // MARK: 语言 - SectionLabel(t: t, text: L.sectionLanguage(lang)).padding(.top, 24).padding(.bottom, 8) - HStack(spacing: 8) { - langSeg(L.langFollow(lang), .system) - langSeg("中文", .zh) // 语言名用本族文字,不翻译 - langSeg("English", .en) - } - - // MARK: 主界面(最近分享展示 + 窗口尺寸) - SectionLabel(t: t, text: L.sectionMain(lang)).padding(.top, 24).padding(.bottom, 4) - settingRow(title: L.showRecentsTitle(lang), desc: L.showRecentsDesc(lang)) { - ToggleSwitch(t: t, isOn: state.showRecents) { state.setShowRecents(!state.showRecents) } - } - 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() - } - } - - // MARK: 更新 - // 始终展示这一组:开关留在设置里,用户才能确认「自动更新」这个功能确实存在。 - // dev / 未签名构建里 updater 未启动(占位 EdDSA 公钥),此时只把开关置灰、并改说明文案 - // 点明原因——是「此构建未启用」而非把整段藏掉。isActive 只决定可用态,不决定是否渲染。 - SectionLabel(t: t, text: L.sectionUpdate(lang)).padding(.top, 24).padding(.bottom, 4) - settingRow(title: L.autoUpdate(lang), - desc: updater.isActive - ? L.autoUpdateDescOn(lang) - : L.autoUpdateDescOff(lang)) { - ToggleSwitch(t: t, isOn: updater.automaticChecks, locked: !updater.isActive) { - updater.setAutomaticChecks(!updater.automaticChecks) - } - } - - // MARK: 命令行工具 - // 裸二进制(swift run)没有 .app 可指:不给安装按钮,状态/卸载照常。 - SectionLabel(t: t, text: L.sectionCLI(lang)).padding(.top, 24).padding(.bottom, 8) - HStack(spacing: 12) { - VStack(alignment: .leading, spacing: 2) { - Text("localshare").font(.mono(13.5, .bold)).foregroundStyle(t.ink) - Text(cliHint).font(.sans(11.5)).foregroundStyle(t.inkMute) - .lineLimit(1).truncationMode(.middle) - } - Spacer(minLength: 8) - if state.cliStatus != .notInstalled { - Button { state.uninstallCLI() } label: { - Text(L.uninstall(lang)).font(.sans(13, .semibold)).foregroundStyle(t.inkMute) - } - .buttonStyle(.plain) - .padding(.trailing, 4) - } - if state.cliStatus == .installed { - HStack(spacing: 5) { - Image(systemName: "checkmark").font(.system(size: 13, weight: .bold)) - Text(L.installed(lang)).font(.sans(11.5, .bold)) - } - .foregroundStyle(t.ok) - } else if CLIInstaller.binaryPath() != nil { - GhostButton(t: t, - title: state.cliStatus == .notInstalled ? L.install(lang) : L.reinstall(lang), - systemImage: "terminal") { - state.installCLI() - } - } - } - .padding(.vertical, 4) - } - } - .onAppear { - portText = String(state.configuredPort) - state.refreshCLIStatus() - } - } - - // 命令行工具状态行:已安装显示链接路径;链接归属不了当前进程(裸跑/指向别处)时 - // 直接亮出实际指向,让人自己判断;未装时一句话点明用途或受限原因。 - private var cliHint: String { - switch state.cliStatus { - case .installed: - return CLIInstaller.linkPath - case .stale(let dest): - return "→ " + (dest as NSString).abbreviatingWithTildeInPath - case .notInstalled: - return CLIInstaller.binaryPath() != nil ? L.cliHintAvailable(state.lang) : L.cliHintUnavailable(state.lang) - } - } - - private func appearanceSeg(_ label: String, _ pref: AppState.AppearancePref) -> some View { - let on = state.appearance == pref - return Button { state.setAppearance(pref) } label: { - Text(label).font(.sans(13, on ? .semibold : .medium)) - .foregroundStyle(on ? .white : t.ink) - .frame(maxWidth: .infinity).frame(height: 34) - .background(RoundedRectangle(cornerRadius: 9, style: .continuous).fill(on ? t.accent : t.surface)) - .overlay(RoundedRectangle(cornerRadius: 9, style: .continuous).strokeBorder(on ? .clear : t.line, lineWidth: 1)) - } - .buttonStyle(.plain) - } - - // 语言分段:结构同 appearanceSeg,绑 langPref / setLangPref。 - private func langSeg(_ label: String, _ pref: LangPref) -> some View { - let on = state.langPref == pref - return Button { state.setLangPref(pref) } label: { - Text(label).font(.sans(13, on ? .semibold : .medium)) - .foregroundStyle(on ? .white : t.ink) - .frame(maxWidth: .infinity).frame(height: 34) - .background(RoundedRectangle(cornerRadius: 9, style: .continuous).fill(on ? t.accent : t.surface)) - .overlay(RoundedRectangle(cornerRadius: 9, style: .continuous).strokeBorder(on ? .clear : t.line, lineWidth: 1)) - } - .buttonStyle(.plain) - } - - // 通用设置行:「标题 +(可选)说明 + 右侧控件」。同一分组内多行靠 top 顶部分隔线对齐, - // 紧贴小节标题的首行不画线(top 默认 false)——分隔线只用来区隔相邻行,不重复标题已有的分隔。 - private func settingRow(top: Bool = false, title: String, desc: String? = nil, - @ViewBuilder trailing: () -> Trailing) -> some View { - HStack(spacing: 12) { - VStack(alignment: .leading, spacing: 2) { - Text(title).font(.sans(13.5, .semibold)).foregroundStyle(t.ink) - if let desc { - Text(desc).font(.sans(11.5)).foregroundStyle(t.inkMute) - .fixedSize(horizontal: false, vertical: true) - } - } - Spacer(minLength: 8) - trailing() - } - .padding(.vertical, 13) - .overlay(alignment: .top) { if top { Rectangle().fill(t.line).frame(height: 1) } } - } - - // 权限专用行(带「始终开启」标记与可锁定开关)。locked 且无 action = 锁定常开(读取); - // locked 且有 action = 当前形态不可用(开关置灰)。top 同 settingRow:仅相邻行间画分隔线。 - private func permRow(name: String, desc: String, locked: Bool, on: Bool, top: Bool = false, - action: (() -> Void)? = nil) -> some View { - HStack(spacing: 12) { - VStack(alignment: .leading, spacing: 2) { - HStack(spacing: 6) { - Text(name).font(.sans(13.5, .semibold)).foregroundStyle(t.ink) - if locked && action == nil { Text(L.alwaysOn(state.lang)).font(.sans(11)).foregroundStyle(t.inkFaint) } - } - Text(desc).font(.sans(11.5)).foregroundStyle(t.inkMute) - } - Spacer() - ToggleSwitch(t: t, isOn: on, locked: locked, action: action ?? {}) - } - .padding(.vertical, 13) - .overlay(alignment: .top) { if top { Rectangle().fill(t.line).frame(height: 1) } } - } - - private func apply(_ pv: PortCheck, changed: Bool) { - guard pv.state != .invalid, changed, let p = Int(portText) else { return } - state.applyPort(in_port_t(p)) - state.goShare() - } -} - -// MARK: - 分享历史 - -private struct HistoryScreen: View { - let t: Theme - @EnvironmentObject var state: AppState - @State private var confirmClear = false // 「清空全部」是批量销毁,需二次确认(单条 ✕ 不需要) - var body: some View { - ScreenFrame(t: t) { - HStack(spacing: 10) { - IconButton(t: t, systemImage: "chevron.left", help: L.back(state.lang)) { state.goShare() } - Text(L.shareHistory(state.lang)).font(.display(21, .semibold)).foregroundStyle(t.ink) - Spacer() - if !state.recents.isEmpty { - Button { confirmClear = true } label: { - Text(L.clearAll(state.lang)).font(.sans(12)).foregroundStyle(t.inkMute) - }.buttonStyle(.plain) - .confirmationDialog(L.clearAllConfirm(state.lang), isPresented: $confirmClear, titleVisibility: .visible) { - Button(L.clearAll(state.lang), role: .destructive) { state.clearRecents() } - Button(L.cancel(state.lang), role: .cancel) {} - } - } - } - } content: { - if state.recents.isEmpty { - VStack(spacing: 8) { - Spacer(minLength: 80) - Image(systemName: "clock.arrow.circlepath").font(.system(size: 38)).foregroundStyle(t.inkFaint) - Text(L.noHistory(state.lang)).font(.sans(14, .semibold)).foregroundStyle(t.inkMute) - }.frame(maxWidth: .infinity) - } else { - VStack(spacing: 0) { - ForEach(Array(state.recents.enumerated()), id: \.element.id) { i, h in - historyRow(h, top: i > 0) - } - } - } - } - } - - private func historyRow(_ h: RecentShare, top: Bool) -> some View { - let live = state.isLive(h) - return HStack(spacing: 12) { - RecentGlyph(t: t, item: h, size: 38) - VStack(alignment: .leading, spacing: 2) { - HStack(spacing: 7) { - Text(h.displayName(state.lang)).font(.sans(13.5, .semibold)).foregroundStyle(t.ink) - .lineLimit(1).truncationMode(.middle) - if live { - HStack(spacing: 4) { - StatusDot(color: t.accent, live: true, size: 5) - Text(L.live(state.lang)).font(.sans(10.5, .bold)).foregroundStyle(t.accent) - } - } - } - Text("\(h.detail) · \(LStr.friendlyDate(h.date, state.lang))").font(.mono(11.5)).foregroundStyle(t.inkMute) - .lineLimit(1) - } - Spacer(minLength: 4) - if live { - DangerButton(t: t, title: L.stop(state.lang)) { state.stop() } - } else { - GhostButton(t: t, title: L.reshare(state.lang), systemImage: "arrow.left.arrow.right") { state.reshare(h) } - } - // 逐条删除(文本/文件一视同仁):同款 ✕,只动历史、不影响正在直播的分享。 - ClearButton(t: t, lang: state.lang, help: L.deleteEntry(state.lang)) { state.deleteRecent(h) } - } - .padding(.vertical, 13) - .overlay(alignment: .top) { if top { Rectangle().fill(t.line).frame(height: 1) } } - } -} - -// MARK: - 最近分享模块(空状态 + 单文件分享复用) - -private struct RecentSharesView: View { - let t: Theme - let lang: Lang - var items: [RecentShare] - var onAll: () -> Void - var onReshare: (RecentShare) -> Void - var onDelete: (RecentShare) -> Void - var body: some View { - if items.isEmpty { - EmptyView() - } else { - VStack(spacing: 0) { - HStack { - SectionLabel(t: t, text: L.sectionRecent(lang)) - Spacer() - Button { onAll() } label: { Text(L.viewAll(lang)).font(.sans(12)).foregroundStyle(t.accent) } - .buttonStyle(.plain) - } - .padding(.bottom, 6) - ForEach(Array(items.prefix(2).enumerated()), id: \.element.id) { i, h in - HStack(spacing: 11) { - RecentGlyph(t: t, item: h, size: 30) - VStack(alignment: .leading, spacing: 1) { - Text(h.displayName(lang)).font(.sans(13, .semibold)).foregroundStyle(t.ink) - .lineLimit(1).truncationMode(.middle) - Text(LStr.friendlyDate(h.date, lang)).font(.mono(11)).foregroundStyle(t.inkMute) - } - Spacer(minLength: 4) - GhostButton(t: t, title: L.reshare(lang), systemImage: "arrow.left.arrow.right") { onReshare(h) } - ClearButton(t: t, lang: lang, help: L.deleteEntry(lang)) { onDelete(h) } - } - .padding(.vertical, 9) - .overlay(alignment: .top) { if i > 0 { Rectangle().fill(t.line).frame(height: 1) } } - } - } - .padding(.top, 22) - } - } -} - -// 历史 / 最近行用的图标:多项→叠放方块,单文件夹→FolderGlyph,单文件→类型方块。 -private struct RecentGlyph: View { - let t: Theme - var item: RecentShare - var size: CGFloat - var body: some View { - if item.isText { - TextGlyph(t: t, size: size) - } else if item.isMultiple { - MultiGlyph(t: t, size: size) - } else if item.isFile, let path = item.paths.first { - let url = URL(fileURLWithPath: path) - TypeGlyph(t: t, category: FileType.category(of: url, isDir: false), - ext: url.pathExtension.lowercased(), size: size) - } else { - FolderGlyph(t: t, size: size) - } - } -} - -// MARK: - 底部帮助行 - -private struct HelpRow: View { - let t: Theme - @EnvironmentObject var state: AppState - @State private var show = false - var body: some View { - let lang = state.lang - return HStack { - Button { show.toggle() } label: { - HStack(spacing: 5) { - Image(systemName: "questionmark.circle").font(.system(size: 12)) - Text(L.cantConnect(lang)).font(.sans(12.5, .medium)) - } - .foregroundStyle(t.inkMute) - } - .buttonStyle(.plain) - .popover(isPresented: $show, arrowEdge: .bottom) { - VStack(alignment: .leading, spacing: 11) { - Text(L.cantConnectTitle(lang)).font(.sans(12, .semibold)).foregroundStyle(t.ink) - row("1", L.help1(lang)) - row("2", L.help2(lang)) - row("3", L.help3(lang)) - Rectangle().fill(t.line).frame(height: 1).padding(.vertical, 1) - HStack(alignment: .top, spacing: 9) { - Image(systemName: "lock.open").font(.system(size: 11)).foregroundStyle(t.inkMute).frame(width: 16) - Text(L.helpPlaintext(lang)) - .font(.sans(11.5)).foregroundStyle(t.inkMute).lineSpacing(2) - Spacer(minLength: 0) - } - } - .padding(16).frame(width: 312) - } - Spacer() - Text(appVersion).font(.mono(10)).foregroundStyle(t.inkFaint).textSelection(.enabled) - } - } - private func row(_ n: String, _ text: String) -> some View { - HStack(alignment: .top, spacing: 9) { - Text(n).font(.mono(10, .semibold)).foregroundStyle(t.accent) - .frame(width: 16, height: 16) - .background(Circle().strokeBorder(t.accent.opacity(0.4), lineWidth: 1)) - Text(text).font(.sans(11.5)).foregroundStyle(t.inkMute).lineSpacing(2) - Spacer(minLength: 0) - } - } -} - -// MARK: - 工具 - -// 版本号取自 bundle;裸二进制无 bundle 回退 dev。 -private var appVersion: String { - (Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String).map { "v\($0)" } ?? "dev" -} diff --git a/Sources/LocalShare/FileServer.swift b/Sources/LocalShare/FileServer.swift index 554c8fb..5efe4a9 100644 --- a/Sources/LocalShare/FileServer.swift +++ b/Sources/LocalShare/FileServer.swift @@ -2,30 +2,6 @@ import Foundation import Darwin // getnameinfo / inet_pton / sockaddr_in(设备名反查) import Swifter -// 一名在线访客的明细(仅在分享者本机窗口展示,绝不外泄给网页端)。 -// name 是反查到的设备名,查不到为空串;ip 始终是完整 IPv4。 -struct ViewerInfo: Identifiable { - let ip: String - let name: String // 反查到的设备名,查不到为空串 - let since: Date // 本次浏览会话首次出现时间(断开超出在线窗口再来即重新计) - var id: String { ip } - // 展开列表:设备名优先,查不到显示完整 IP(不再只剩尾号,便于区分是哪几台)。 - 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);② 防目录穿越(路径解析后必须仍在所选文件夹内)。 diff --git a/Sources/LocalShare/HistoryScreen.swift b/Sources/LocalShare/HistoryScreen.swift new file mode 100644 index 0000000..1cc7f9a --- /dev/null +++ b/Sources/LocalShare/HistoryScreen.swift @@ -0,0 +1,134 @@ +import SwiftUI + +// MARK: - 分享历史 + +struct HistoryScreen: View { + let t: Theme + @EnvironmentObject var state: AppState + @State private var confirmClear = false // 「清空全部」是批量销毁,需二次确认(单条 ✕ 不需要) + var body: some View { + ScreenFrame(t: t) { + HStack(spacing: 10) { + IconButton(t: t, systemImage: "chevron.left", help: L.back(state.lang)) { state.goShare() } + Text(L.shareHistory(state.lang)).font(.display(21, .semibold)).foregroundStyle(t.ink) + Spacer() + if !state.recents.isEmpty { + Button { confirmClear = true } label: { + Text(L.clearAll(state.lang)).font(.sans(12)).foregroundStyle(t.inkMute) + }.buttonStyle(.plain) + .confirmationDialog(L.clearAllConfirm(state.lang), isPresented: $confirmClear, titleVisibility: .visible) { + Button(L.clearAll(state.lang), role: .destructive) { state.clearRecents() } + Button(L.cancel(state.lang), role: .cancel) {} + } + } + } + } content: { + if state.recents.isEmpty { + VStack(spacing: 8) { + Spacer(minLength: 80) + Image(systemName: "clock.arrow.circlepath").font(.system(size: 38)).foregroundStyle(t.inkFaint) + Text(L.noHistory(state.lang)).font(.sans(14, .semibold)).foregroundStyle(t.inkMute) + }.frame(maxWidth: .infinity) + } else { + VStack(spacing: 0) { + ForEach(Array(state.recents.enumerated()), id: \.element.id) { i, h in + historyRow(h, top: i > 0) + } + } + } + } + } + + private func historyRow(_ h: RecentShare, top: Bool) -> some View { + let live = state.isLive(h) + return HStack(spacing: 12) { + RecentGlyph(t: t, item: h, size: 38) + VStack(alignment: .leading, spacing: 2) { + HStack(spacing: 7) { + Text(h.displayName(state.lang)).font(.sans(13.5, .semibold)).foregroundStyle(t.ink) + .lineLimit(1).truncationMode(.middle) + if live { + HStack(spacing: 4) { + StatusDot(color: t.accent, live: true, size: 5) + Text(L.live(state.lang)).font(.sans(10.5, .bold)).foregroundStyle(t.accent) + } + } + } + Text("\(h.detail) · \(LStr.friendlyDate(h.date, state.lang))").font(.mono(11.5)).foregroundStyle(t.inkMute) + .lineLimit(1) + } + Spacer(minLength: 4) + if live { + DangerButton(t: t, title: L.stop(state.lang)) { state.stop() } + } else { + GhostButton(t: t, title: L.reshare(state.lang), systemImage: "arrow.left.arrow.right") { state.reshare(h) } + } + // 逐条删除(文本/文件一视同仁):同款 ✕,只动历史、不影响正在直播的分享。 + ClearButton(t: t, lang: state.lang, help: L.deleteEntry(state.lang)) { state.deleteRecent(h) } + } + .padding(.vertical, 13) + .overlay(alignment: .top) { if top { Rectangle().fill(t.line).frame(height: 1) } } + } +} + +// MARK: - 最近分享模块(空状态 + 单文件分享复用) + +struct RecentSharesView: View { + let t: Theme + let lang: Lang + var items: [RecentShare] + var onAll: () -> Void + var onReshare: (RecentShare) -> Void + var onDelete: (RecentShare) -> Void + var body: some View { + if items.isEmpty { + EmptyView() + } else { + VStack(spacing: 0) { + HStack { + SectionLabel(t: t, text: L.sectionRecent(lang)) + Spacer() + Button { onAll() } label: { Text(L.viewAll(lang)).font(.sans(12)).foregroundStyle(t.accent) } + .buttonStyle(.plain) + } + .padding(.bottom, 6) + ForEach(Array(items.prefix(2).enumerated()), id: \.element.id) { i, h in + HStack(spacing: 11) { + RecentGlyph(t: t, item: h, size: 30) + VStack(alignment: .leading, spacing: 1) { + Text(h.displayName(lang)).font(.sans(13, .semibold)).foregroundStyle(t.ink) + .lineLimit(1).truncationMode(.middle) + Text(LStr.friendlyDate(h.date, lang)).font(.mono(11)).foregroundStyle(t.inkMute) + } + Spacer(minLength: 4) + GhostButton(t: t, title: L.reshare(lang), systemImage: "arrow.left.arrow.right") { onReshare(h) } + ClearButton(t: t, lang: lang, help: L.deleteEntry(lang)) { onDelete(h) } + } + .padding(.vertical, 9) + .overlay(alignment: .top) { if i > 0 { Rectangle().fill(t.line).frame(height: 1) } } + } + } + .padding(.top, 22) + } + } +} + +// 历史 / 最近行用的图标:多项→叠放方块,单文件夹→FolderGlyph,单文件→类型方块。 +struct RecentGlyph: View { + let t: Theme + var item: RecentShare + var size: CGFloat + var body: some View { + if item.isText { + TextGlyph(t: t, size: size) + } else if item.isMultiple { + MultiGlyph(t: t, size: size) + } else if item.isFile, let path = item.paths.first { + let url = URL(fileURLWithPath: path) + TypeGlyph(t: t, category: FileType.category(of: url, isDir: false), + ext: url.pathExtension.lowercased(), size: size) + } else { + FolderGlyph(t: t, size: size) + } + } +} diff --git a/Sources/LocalShare/HomeScreen.swift b/Sources/LocalShare/HomeScreen.swift new file mode 100644 index 0000000..d5bdd4b --- /dev/null +++ b/Sources/LocalShare/HomeScreen.swift @@ -0,0 +1,151 @@ +import SwiftUI + +// MARK: - 空状态 + +struct HomeScreen: View { + let t: Theme + var dragging: Bool + @EnvironmentObject var state: AppState + // 横幅标题:单项=文件/夹名(中段截断),多选=「N 项」(与票据 multipleStub 标题一致)。 + private var activeShareName: String { + state.isMultiple ? LStr.itemCount(state.sharedItems.count, state.lang) + : (state.sharedURL?.lastPathComponent ?? "") + } + // 「传递文本」入口的呼吸点只该表「文本在后台续跑」,故精确判文本态——而非笼统的 isRunning + //(现在主页可能正跑着文件分享,那不该让文本入口误亮)。 + private var textActive: Bool { state.isRunning && (state.hasText || state.textInboxEnabled) } + var body: some View { + let ps = permSummary(state.permission, state.lang) + ScreenFrame(t: t) { + HStack(alignment: .top) { + VStack(alignment: .leading, spacing: 2) { + Text(ps.eyebrow).font(.sans(11, .bold)).tracking(1.2).foregroundStyle(t.accent) + Text("LocalShare").font(.display(28, .semibold)).tracking(-0.3).foregroundStyle(t.ink) + } + Spacer() + // 主页通常「待命」;但文件分享 / 传递文本可在后台续跑(退回主页时),此刻如实显运行态 + //(亮点 + 实际 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) { + // 文件分享在后台续跑、用户退回主页时,顶部出可点横幅一键回票据;停止未清除也显(静默态)。 + if !state.sharedItems.isEmpty { + ActiveShareBanner(t: t, lang: state.lang, name: activeShareName, + running: state.isRunning, viewers: state.viewerCount) { state.enterFile() } + .padding(.bottom, 12) + } + dropZone + // 平级第二入口:传递文本(收/发合一)。点进独立二级页,主页只负责选功能、不就地干活。 + // 收件箱有未读时角标提示;文本在后台续跑时缀呼吸点(见 textActive)。 + TransferTextButton(t: t, lang: state.lang, active: textActive, + 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) }, + onDelete: { state.deleteRecent($0) }) + } + } + } + } + + private var dropZone: some View { + VStack(spacing: 0) { + RoundedRectangle(cornerRadius: 16, style: .continuous) + .fill(t.accentSoft) + .frame(width: 56, height: 56) + .overlay(Image(systemName: "arrow.up.to.line").font(.system(size: 24, weight: .medium)).foregroundStyle(t.accent)) + .padding(.bottom, 14) + Text(L.dropZoneTitle(state.lang)).font(.sans(15.5, .semibold)).foregroundStyle(t.ink) + Text(L.dropZoneSub(state.lang)).font(.sans(12.5)).foregroundStyle(t.inkMute).padding(.top, 4) + PrimaryButton(t: t, title: L.pickAnyButton(state.lang), systemImage: "doc.badge.plus") { state.pickAny() } + .padding(.top, 18) + } + .frame(maxWidth: .infinity) + .padding(.horizontal, 20).padding(.top, 34).padding(.bottom, 28) + .background(RoundedRectangle(cornerRadius: 18, style: .continuous).fill(dragging ? t.accentSoft : t.surface)) + .overlay( + RoundedRectangle(cornerRadius: 18, style: .continuous) + .strokeBorder(style: StrokeStyle(lineWidth: 2, dash: [6, 5])) + .foregroundStyle(dragging ? t.accent : t.lineStrong) + ) + } +} +// 主页「传递文本」入口:平级第二功能,点进 .text 二级页(收/发合一)。收件箱有未读时数字角标提示、 +// 接收开着时缀一个缓慢呼吸的红点——让「正在接收」与「有新文本」在选择页一眼可见,无须把内容堆到主页。 +struct TransferTextButton: View { + let t: Theme + let lang: Lang + let active: Bool // 接收正开着 + let unread: Int + let action: () -> Void + @State private var hover = false + var body: some View { + Button(action: action) { + 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 { + PulsingDot(color: t.accent) + } + } + .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 } + } +} + +// 主页「正在分享」横幅:文件分享在后台续跑、用户却退回主页时,用一条紧凑可点的横幅如实呈现—— +// 点按回到文件票据(.file)看二维码。运行中缀呼吸点 + 在线人数;停止未清除时静默(无呼吸点)。 +// 与 TransferTextButton 同手法,但承载更多信息故略高;前导 qrcode 图标暗示「点这里回到码」, +// 配尾部 chevron 即足以表达「可点回去」,不另加文案(强约束:能用设计语言暗示就不堆字)。 +struct ActiveShareBanner: View { + let t: Theme + let lang: Lang + let name: String // 单项=文件/夹名;多选=「N 项」 + let running: Bool + let viewers: Int + let action: () -> Void + @State private var hover = false + var body: some View { + Button(action: action) { + HStack(spacing: 11) { + Image(systemName: "qrcode").font(.system(size: 16, weight: .medium)).foregroundStyle(t.accent) + VStack(alignment: .leading, spacing: 1) { + HStack(spacing: 6) { + Text(L.sharingKicker(lang)).font(.sans(10.5, .bold)).tracking(0.6).foregroundStyle(t.inkMute) + if running { PulsingDot(color: t.ok) } + if running && viewers > 0 { + Text(LStr.viewerCountLabel(viewers, lang)).font(.sans(10.5)).foregroundStyle(t.inkMute) + } + } + Text(name).font(.sans(13.5, .semibold)).foregroundStyle(t.ink) + .lineLimit(1).truncationMode(.middle) + } + Spacer(minLength: 8) + Image(systemName: "chevron.right").font(.system(size: 12, weight: .semibold)).foregroundStyle(t.inkFaint) + } + .padding(.horizontal, 14).frame(height: 52) + .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 } + } +} diff --git a/Sources/LocalShare/Lang.swift b/Sources/LocalShare/Lang.swift index 91ba484..b34a4c8 100644 --- a/Sources/LocalShare/Lang.swift +++ b/Sources/LocalShare/Lang.swift @@ -1,7 +1,7 @@ import Foundation // i18n 核心:把所有用户可见文案做成「编进二进制的 Swift 字符串表」,不依赖任何资源 bundle -// (戒律的精神见 CLAUDE.md / PLAN.md §0)——与 MarkedJS.source、permSummary 同一思路,三条启动 +// (戒律的精神见 CLAUDE.md / docs/ARCHITECTURE.md §0)——与 MarkedJS.source、permSummary 同一思路,三条启动 // 路径(headless / CLI / GUI)都无须定位文件。当前支持 简体中文(zh) + English(en)。 // // 两个解析域彼此独立: diff --git a/Sources/LocalShare/NoNetworkScreen.swift b/Sources/LocalShare/NoNetworkScreen.swift new file mode 100644 index 0000000..fb54dc8 --- /dev/null +++ b/Sources/LocalShare/NoNetworkScreen.swift @@ -0,0 +1,32 @@ +import SwiftUI + +// MARK: - 未接入局域网 + +struct NoNetworkScreen: View { + let t: Theme + @EnvironmentObject var state: AppState + var body: some View { + ScreenFrame(t: t) { + // 现挂在文件票据二级页(.file)下,带 ← 返回主页,免得无网络时卡死在此页。 + HStack(spacing: 10) { + IconButton(t: t, systemImage: "chevron.left", help: L.back(state.lang)) { state.goShare() } + Text(L.shareFileTitle(state.lang)).font(.display(21, .semibold)).foregroundStyle(t.ink) + Spacer() + IconButton(t: t, systemImage: "gearshape", help: L.settings(state.lang)) { state.openSettings() } + } + } content: { + VStack(spacing: 14) { + Spacer(minLength: 60) + Image(systemName: "wifi.slash").font(.system(size: 46)).foregroundStyle(t.inkFaint) + Text(L.noNetwork(state.lang)).font(.display(21)).foregroundStyle(t.ink) + Text(L.noNetworkHint(state.lang)) + .font(.sans(13)).foregroundStyle(t.inkMute) + .multilineTextAlignment(.center).lineSpacing(3) + GhostButton(t: t, title: L.refresh(state.lang), systemImage: "arrow.clockwise") { state.refreshNetwork() } + .padding(.top, 4) + Spacer(minLength: 40) + } + .frame(maxWidth: .infinity) + } + } +} diff --git a/Sources/LocalShare/Permission.swift b/Sources/LocalShare/Permission.swift index d0d8ddf..c1a7660 100644 --- a/Sources/LocalShare/Permission.swift +++ b/Sources/LocalShare/Permission.swift @@ -1,7 +1,7 @@ import Foundation // 权限模型(DESIGN.md §6.2)。read 锁定常开;add(访客上传)0.6 起接上真后端,仅单文件夹分享可开、 -// 换分享自动回只读;edit/del 后端未实现、暂恒为 false(规划见 PLAN.md §7)。措辞框架统一:所有展示 +// 换分享自动回只读;edit/del 后端未实现且已决定不做、恒为 false(见 docs/ARCHITECTURE.md §5)。措辞框架统一:所有展示 // 分享态的界面(含网页 listing 页)都经 PermSummary 派生「只读 / 可读写」文案,绝不各自硬编码。 struct Permission: Equatable { var read = true // 锁定常开 diff --git a/Sources/LocalShare/PortValidation.swift b/Sources/LocalShare/PortValidation.swift new file mode 100644 index 0000000..82d63ab --- /dev/null +++ b/Sources/LocalShare/PortValidation.swift @@ -0,0 +1,29 @@ +import Foundation +import Darwin + +// MARK: - 端口实时校验(DESIGN.md §6.3) + +enum PortState { case ok, occupied, invalid } + +struct PortCheck { + let state: PortState + let message: String + let suggest: in_port_t? +} + +// 输入即校验:空 / 越界 → invalid;命中常用占用端口 → occupied + 建议下一个可用;其余 → ok。 +// 占用集合为启发式(与设计稿一致);真正能否绑定以「应用」时实际 start 结果为准。 +func validatePort(_ raw: String, _ lang: Lang) -> PortCheck { + let occupied: Set = [80, 443, 3000, 5000, 5432, 3306, 8000, 7890] + let v = raw.trimmingCharacters(in: .whitespaces) + guard !v.isEmpty else { return PortCheck(state: .invalid, message: L.portEmptyMsg(lang), suggest: nil) } + guard let n = Int(v) else { return PortCheck(state: .invalid, message: L.portNotNumberMsg(lang), suggest: nil) } + if n < 1024 { return PortCheck(state: .invalid, message: L.portTooLowMsg(lang), suggest: nil) } + if n > 65535 { return PortCheck(state: .invalid, message: L.portTooHighMsg(lang), suggest: nil) } + if occupied.contains(n) { + var s = n + 1 + while occupied.contains(s) || s > 65535 { s = s > 65535 ? 1024 : s + 1 } + return PortCheck(state: .occupied, message: LStr.portOccupied(v, lang), suggest: in_port_t(s)) + } + return PortCheck(state: .ok, message: L.portOk(lang), suggest: nil) +} diff --git a/Sources/LocalShare/ReceivedText.swift b/Sources/LocalShare/ReceivedText.swift new file mode 100644 index 0000000..e1d3f21 --- /dev/null +++ b/Sources/LocalShare/ReceivedText.swift @@ -0,0 +1,14 @@ +import Foundation + +// 手机投递到 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 } +} diff --git a/Sources/LocalShare/RecentShare.swift b/Sources/LocalShare/RecentShare.swift new file mode 100644 index 0000000..65f855f --- /dev/null +++ b/Sources/LocalShare/RecentShare.swift @@ -0,0 +1,59 @@ +import Foundation + +// 一条最近分享记录(持久化到 UserDefaults)。paths 支持多选(1=单项、N=多选); +// text 非 nil 即「文本分享」条目(paths 为空,仅在「记住分享的文本」开启时落库)。 +struct RecentShare: Codable, Identifiable, Equatable { + let paths: [String] + let isFile: Bool // 仅单项有意义 + let detail: String + let date: Date + let text: String? // 文本分享条目的原文;文件条目为 nil + + var isText: Bool { text != nil } + var isMultiple: Bool { paths.count > 1 } + // 文本条目以内容作身份(同一段文本去重、重分享移到顶部);文件条目以路径**集合**作身份 + //(排序后拼接,与 isLive / isCurrentShare 的 Set(paths) 口径一致——同一组文件不同选中顺序视作同一条,避免去重失效)。 + var id: String { text.map { "\u{1}text\u{1}\($0)" } ?? Set(paths).sorted().joined(separator: "\n") } + // 文本给首行预览(空白回退「文本」),多选给本地化计数名,单项给文件名。由持有 lang 的 View 传入。 + func displayName(_ lang: Lang) -> String { + if let text { let p = FileServer.textPreview(text); return p.isEmpty ? L.webText(lang) : p } + if isMultiple { return LStr.multiItemName(paths.count, lang) } + return paths.first.map { ($0 as NSString).lastPathComponent } ?? "" + } + // 文本条目恒可重分享;文件条目只要还有一项存在即可(reshare 时再剔除缺失项)。 + var exists: Bool { + if text != nil { return true } + let fm = FileManager.default + return paths.contains { fm.fileExists(atPath: $0) } + } + + init(paths: [String], isFile: Bool, detail: String, date: Date, text: String? = nil) { + self.paths = paths; self.isFile = isFile; self.detail = detail; self.date = date; self.text = text + } + + // 兼容旧记录:旧版用单 `path` 字段,迁移为 `paths = [path]`;旧记录无 text 字段,解码缺省为 nil。 + enum CodingKeys: String, CodingKey { case paths, path, isFile, detail, date, text } + init(from decoder: Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + if let arr = try? c.decode([String].self, forKey: .paths) { + paths = arr + } else if let p = try? c.decode(String.self, forKey: .path) { + paths = [p] + } else { + paths = [] + } + isFile = (try? c.decode(Bool.self, forKey: .isFile)) ?? false + detail = (try? c.decode(String.self, forKey: .detail)) ?? "" + date = (try? c.decode(Date.self, forKey: .date)) ?? Date(timeIntervalSince1970: 0) + text = try? c.decodeIfPresent(String.self, forKey: .text) + } + // 显式 encode(CodingKeys 含迁移用的 .path,会阻断合成):只写 paths 等当前字段。 + func encode(to encoder: Encoder) throws { + var c = encoder.container(keyedBy: CodingKeys.self) + try c.encode(paths, forKey: .paths) + try c.encode(isFile, forKey: .isFile) + try c.encode(detail, forKey: .detail) + try c.encode(date, forKey: .date) + try c.encodeIfPresent(text, forKey: .text) + } +} diff --git a/Sources/LocalShare/ScreenSupport.swift b/Sources/LocalShare/ScreenSupport.swift new file mode 100644 index 0000000..9a79788 --- /dev/null +++ b/Sources/LocalShare/ScreenSupport.swift @@ -0,0 +1,263 @@ +import SwiftUI +import AppKit + +// MARK: - 屏幕脚手架(顶留红绿灯 + 内容 + 底部 HelpRow) + +let hPad: CGFloat = 22 + +struct ScreenFrame: View { + let t: Theme + @ViewBuilder var header: () -> Header + @ViewBuilder var content: () -> Body + var body: some View { + VStack(spacing: 0) { + header() + .padding(.horizontal, hPad).padding(.top, 40).padding(.bottom, 14) + ScrollView { + content().padding(.horizontal, hPad).padding(.bottom, 6) + .background(OverlayScrollers()) + } + HelpRow(t: t).padding(.horizontal, hPad).padding(.vertical, 12) + } + } +} + +// 把 SwiftUI ScrollView 底层的 NSScrollView 强制为 overlay 滚动条样式。 +// 鼠标用户在系统「始终显示滚动条」下,默认会拿到常驻、挤占右侧宽度的 legacy 滚动条 +//(`.scrollIndicators(.hidden)` 也压不住它);改 overlay 后与触控板一致——空闲隐藏、 +// 滚动/悬停时才细细淡入,且不占布局宽度。挂在内容里靠 enclosingScrollView 反查容器。 +// 关键:必须在首帧绘制前同步改样式。若拖到下一个 runloop(DispatchQueue.async)才改, +// 内容超出视口的页(如设置页)会先按 legacy 滚动条占走右侧 ~15px 布一次,下一拍切 overlay +// 再把这 15px 还回去——肉眼即「内容向右撑开」的闪动。故改用视图入树回调即时应用,不延后。 +struct OverlayScrollers: NSViewRepresentable { + func makeNSView(context: Context) -> NSView { ScrollerStyler() } + func updateNSView(_ nsView: NSView, context: Context) { + (nsView as? ScrollerStyler)?.applyOverlay() + } +} + +// 入树即应用:viewDidMoveToSuperview / ToWindow 都在首帧绘制前同步触发,反查 enclosingScrollView +// 并切 overlay;任一时机还拿不到容器,后一个时机补上,全程无 async 延迟。 +final class ScrollerStyler: NSView { + override func viewDidMoveToSuperview() { super.viewDidMoveToSuperview(); applyOverlay() } + override func viewDidMoveToWindow() { super.viewDidMoveToWindow(); applyOverlay() } + func applyOverlay() { + guard let scroll = enclosingScrollView else { return } + scroll.scrollerStyle = .overlay + scroll.autohidesScrollers = true + } +} + +// MARK: - 文本编辑弹层(空状态「分享文本」/ 分享屏「编辑文本」共用) + +// 离散提交:点「分享 / 更新」才把当前编辑器内容作为新分享广播(state.setSharedText)。 +// 初值来自 textDraft(重启可回填上次内容);清空再提交即撤下文本。 +struct TextEntrySheet: View { + let t: Theme + let isUpdate: Bool + @EnvironmentObject var state: AppState + @Environment(\.dismiss) private var dismiss + @State private var text: String + + init(t: Theme, initial: String, isUpdate: Bool) { + self.t = t; self.isUpdate = isUpdate + _text = State(initialValue: initial) + } + + private var blank: Bool { text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty } + + var body: some View { + let lang = state.lang + VStack(alignment: .leading, spacing: 14) { + HStack { + Text(L.shareTextButton(lang)).font(.display(18, .semibold)).foregroundStyle(t.ink) + Spacer() + IconButton(t: t, systemImage: "xmark", help: L.back(lang)) { dismiss() } + } + // placeholder 由 NSTextView 自绘:能查 hasMarkedText() 在中文输入法拼音组合时即让位, + // 且画在文本容器内、与正文同一坐标系,对齐天然成立(不再用 SwiftUI 叠加层)。 + PlainTextEditor(text: $text, placeholder: L.textEditorPlaceholder(lang), + placeholderColor: NSColor(t.inkFaint), textColor: NSColor(t.ink), + caret: NSColor(t.accent), inset: 8, autoFocus: true) + .frame(minHeight: 210) + .background(RoundedRectangle(cornerRadius: 12, style: .continuous).fill(t.field)) + .overlay(RoundedRectangle(cornerRadius: 12, style: .continuous).strokeBorder(t.line, lineWidth: 1)) + PrimaryButton(t: t, title: isUpdate ? L.textUpdateAction(lang) : L.textShareAction(lang), + systemImage: "paperplane.fill") { + state.setSharedText(text) + dismiss() + } + .disabled(blank) + .opacity(blank ? 0.5 : 1) + } + .padding(20) + .frame(width: 380, height: 380) + .background(t.bg) + } +} + +// 自带 NSTextView 的纯文本编辑器。两个问题一并根治: +// ① 对齐:SwiftUI 的 TextEditor 底层 NSTextView 有一层 .padding() 控不到的内部内边距 +// (textContainerInset + textContainer.lineFragmentPadding 默认 5);这里把 lineFragmentPadding 归零、 +// textContainerInset 显式设成 (inset,inset),文字起点完全由 inset 决定。 +// ② 中文输入法:placeholder 交给 NSTextView 自绘(PlaceholderTextView),它能查 hasMarkedText()—— +// 拼音组合上屏的瞬间就让位,不与未确认的拼音串重叠。 +struct PlainTextEditor: NSViewRepresentable { + @Binding var text: String + var placeholder: String + var placeholderColor: NSColor + var textColor: NSColor + var caret: NSColor + var inset: CGFloat + var autoFocus: Bool + + func makeNSView(context: Context) -> NSScrollView { + let tv = PlaceholderTextView() + tv.delegate = context.coordinator + tv.font = .monospacedSystemFont(ofSize: 13, weight: .regular) + tv.textColor = textColor + tv.insertionPointColor = caret + tv.placeholder = placeholder + tv.placeholderColor = placeholderColor + tv.drawsBackground = false + tv.isRichText = false + tv.isEditable = true + tv.isSelectable = true + tv.allowsUndo = true + tv.textContainerInset = NSSize(width: inset, height: inset) + tv.textContainer?.lineFragmentPadding = 0 + // 在 NSScrollView 里随宽变行、随内容长高(标准配方)。 + tv.isVerticallyResizable = true + tv.isHorizontallyResizable = false + tv.autoresizingMask = [.width] + tv.maxSize = NSSize(width: CGFloat.greatestFiniteMagnitude, height: CGFloat.greatestFiniteMagnitude) + tv.textContainer?.widthTracksTextView = true + tv.string = text + + let scroll = NSScrollView() + scroll.documentView = tv + scroll.hasVerticalScroller = true + scroll.drawsBackground = false + scroll.borderType = .noBorder + scroll.autohidesScrollers = true + if autoFocus { + DispatchQueue.main.async { [weak tv] in tv?.window?.makeFirstResponder(tv) } + } + return scroll + } + + 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 + tv.placeholderColor = placeholderColor + } + + func makeCoordinator() -> Coordinator { Coordinator(self) } + + final class Coordinator: NSObject, NSTextViewDelegate { + var parent: PlainTextEditor + init(_ parent: PlainTextEditor) { self.parent = parent } + func textDidChange(_ note: Notification) { + guard let tv = note.object as? NSTextView else { return } + parent.text = tv.string // 含组合中的 marked text;committed 后即为最终文本 + } + } +} + +// 自绘 placeholder 的 NSTextView:仅当无内容且不在输入法组合态时才画占位文字—— +// 中文输入法拼音上屏(hasMarkedText() 为真)即让位,不与拼音串重叠;画在文本容器内,与正文同坐标系。 +final class PlaceholderTextView: NSTextView { + var placeholder = "" + var placeholderColor: NSColor = .placeholderTextColor + + override func draw(_ dirtyRect: NSRect) { + super.draw(dirtyRect) + guard string.isEmpty, !hasMarkedText() else { return } + let attrs: [NSAttributedString.Key: Any] = [ + .foregroundColor: placeholderColor, + .font: font ?? .monospacedSystemFont(ofSize: 13, weight: .regular), + ] + // lineFragmentPadding 已归零,文字起点即 textContainerInset,占位文字画在同一处。 + placeholder.draw(at: NSPoint(x: textContainerInset.width, y: textContainerInset.height), withAttributes: attrs) + } + // 文本变化(含 marked text 的增删)后重绘,让占位文字及时显隐。 + override func didChangeText() { super.didChangeText(); needsDisplay = true } + override func setMarkedText(_ string: Any, selectedRange: NSRange, replacementRange: NSRange) { + super.setMarkedText(string, selectedRange: selectedRange, replacementRange: replacementRange) + needsDisplay = true + } + override func unmarkText() { super.unmarkText(); needsDisplay = true } +} +// 缓慢呼吸(淡入淡出)的小圆点:表「实时进行中」的状态,用动效与静态未读角标区分,避免红点冒充未读。 +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 } + } +} +// MARK: - 底部帮助行 + +struct HelpRow: View { + let t: Theme + @EnvironmentObject var state: AppState + @State private var show = false + var body: some View { + let lang = state.lang + return HStack { + Button { show.toggle() } label: { + HStack(spacing: 5) { + Image(systemName: "questionmark.circle").font(.system(size: 12)) + Text(L.cantConnect(lang)).font(.sans(12.5, .medium)) + } + .foregroundStyle(t.inkMute) + } + .buttonStyle(.plain) + .popover(isPresented: $show, arrowEdge: .bottom) { + VStack(alignment: .leading, spacing: 11) { + Text(L.cantConnectTitle(lang)).font(.sans(12, .semibold)).foregroundStyle(t.ink) + row("1", L.help1(lang)) + row("2", L.help2(lang)) + row("3", L.help3(lang)) + Rectangle().fill(t.line).frame(height: 1).padding(.vertical, 1) + HStack(alignment: .top, spacing: 9) { + Image(systemName: "lock.open").font(.system(size: 11)).foregroundStyle(t.inkMute).frame(width: 16) + Text(L.helpPlaintext(lang)) + .font(.sans(11.5)).foregroundStyle(t.inkMute).lineSpacing(2) + Spacer(minLength: 0) + } + } + .padding(16).frame(width: 312) + } + Spacer() + Text(appVersion).font(.mono(10)).foregroundStyle(t.inkFaint).textSelection(.enabled) + } + } + private func row(_ n: String, _ text: String) -> some View { + HStack(alignment: .top, spacing: 9) { + Text(n).font(.mono(10, .semibold)).foregroundStyle(t.accent) + .frame(width: 16, height: 16) + .background(Circle().strokeBorder(t.accent.opacity(0.4), lineWidth: 1)) + Text(text).font(.sans(11.5)).foregroundStyle(t.inkMute).lineSpacing(2) + Spacer(minLength: 0) + } + } +} + +// MARK: - 工具 + +// 版本号取自 bundle;裸二进制无 bundle 回退 dev。 +var appVersion: String { + (Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String).map { "v\($0)" } ?? "dev" +} diff --git a/Sources/LocalShare/SendTextPage.swift b/Sources/LocalShare/SendTextPage.swift index 5177864..f066ca4 100644 --- a/Sources/LocalShare/SendTextPage.swift +++ b/Sources/LocalShare/SendTextPage.swift @@ -3,7 +3,7 @@ import Foundation // 手机→Mac「发文本给电脑」表单(传递文本 v2 的手机端)。两处复用同一份片段: // · 列表页(DirectoryListing):收件箱开启时挂在文件列表之下,与访客上传表单同条件出现; // · 独立发送页(/ls/send):「只收文本、没分享任何内容」时二维码直指此页(FileServer 在 textInboxEnabled 时服务)。 -// 关键约束(见 PLAN.md「传递文本 · v2」与 CLAUDE.md): +// 关键约束(见 docs/ARCHITECTURE.md「传递文本」与 CLAUDE.md): // · 纯 http 局域网是非安全上下文——这里走 fetch POST 原文,无须 clipboard,不受该限制影响; // · 单条上限与服务端 textInboxLimit(64KB)对齐,前端按 UTF-8 字节数先行拦截(Blob().size); // · 投递的文本只在 Mac 端 SwiftUI Text 里显示(天然不执行),故无回显 XSS 之虞。 diff --git a/Sources/LocalShare/SettingsScreen.swift b/Sources/LocalShare/SettingsScreen.swift new file mode 100644 index 0000000..d950971 --- /dev/null +++ b/Sources/LocalShare/SettingsScreen.swift @@ -0,0 +1,319 @@ +import SwiftUI +import AppKit + +// MARK: - 设置(网络 / 访问权限 / 外观 / 主界面 / 命令行工具) + +struct SettingsScreen: View { + let t: Theme + @EnvironmentObject var state: AppState + @EnvironmentObject var updater: UpdaterController + @State private var portText = "" + var body: some View { + // portText 初始为空、onAppear 才填入当前端口;首帧若按空串校验会闪出「无效 + 放弃/应用」行再弹回。 + // 空串一律视作「当前生效端口」,让首帧与落定后一致,消除进入设置页时的这层闪烁。 + let lang = state.lang + let effectivePort = portText.isEmpty ? String(state.configuredPort) : portText + let pv = validatePort(effectivePort, lang) + let pColor = pv.state == .ok ? t.ok : (pv.state == .occupied ? t.warn : t.danger) + let changed = !portText.isEmpty && (Int(portText) ?? -1) != Int(state.configuredPort) + let ps = permSummary(state.permission, lang) + return ScreenFrame(t: t) { + HStack(spacing: 10) { + IconButton(t: t, systemImage: "chevron.left", help: L.back(lang)) { state.goShare() } + Text(L.shareSettings(lang)).font(.display(21, .semibold)).foregroundStyle(t.ink) + Spacer() + } + } content: { + VStack(alignment: .leading, spacing: 0) { + // MARK: 网络(监听端口 + 可见范围) + SectionLabel(t: t, text: L.sectionNetwork(lang)).padding(.bottom, 8) + + // 监听端口:IP 前缀 + 端口输入框 + 实时可用性校验。 + HStack(spacing: 10) { + Text("\(state.selectedInterface?.ip ?? L.thisMachine(lang)) :").font(.mono(14)).foregroundStyle(t.inkMute) + TextField("", text: $portText) + .textFieldStyle(.plain) + .font(.mono(15, .bold)).foregroundStyle(t.ink) + .frame(width: 72) + .padding(.horizontal, 10).padding(.vertical, 6) + .background(RoundedRectangle(cornerRadius: 8, style: .continuous).fill(t.field)) + .overlay(RoundedRectangle(cornerRadius: 8, style: .continuous) + .strokeBorder(pv.state == .ok ? t.lineStrong : pColor, lineWidth: 1.5)) + .onChange(of: portText) { portText = String($0.filter(\.isNumber).prefix(5)) } + .onSubmit { apply(pv, changed: changed) } + Spacer() + HStack(spacing: 5) { + Image(systemName: pv.state == .ok ? "checkmark" : "questionmark.circle") + .font(.system(size: 13, weight: .bold)) + Text(pv.state == .ok ? L.portOk(lang) : (pv.state == .occupied ? L.portOccupied(lang) : L.portInvalid(lang))) + .font(.sans(11.5, .bold)) + } + .foregroundStyle(pColor) + } + .padding(.leading, 14).padding(.trailing, 10).padding(.vertical, 10) + .background(RoundedRectangle(cornerRadius: 12, style: .continuous).fill(t.surface)) + .overlay(RoundedRectangle(cornerRadius: 12, style: .continuous) + .strokeBorder(pv.state == .ok ? t.line : pColor, lineWidth: 1)) + + HStack(alignment: .top, spacing: 8) { + Text(pv.state == .ok ? L.portOkHint(lang) : pv.message) + .font(.sans(11.5, pv.state == .ok ? .regular : .semibold)) + .foregroundStyle(pv.state == .ok ? t.inkMute : pColor) + .fixedSize(horizontal: false, vertical: true) + Spacer(minLength: 4) + if let s = pv.suggest { + Button { portText = String(s) } label: { + Text(LStr.changeToPort(s, lang)).font(.sans(11.5, .bold)).foregroundStyle(t.accent) + .padding(.horizontal, 10).frame(height: 24) + .background(Capsule().fill(t.accentSoft)) + }.buttonStyle(.plain) + } + } + .padding(.top, 8) + + // 改了才出现这排操作:放弃(还原成当前生效端口,无效输入也可退回)+ 应用。 + // 用纯色文字而非实心全宽块——重启服务不是破坏性动作,不必视觉吓人。 + if changed { + HStack(spacing: 18) { + Spacer() + Button { portText = String(state.configuredPort) } label: { + HStack(spacing: 5) { + Image(systemName: "arrow.uturn.backward").font(.system(size: 12, weight: .semibold)) + Text(L.discardChanges(lang)).font(.sans(13, .semibold)) + } + .foregroundStyle(t.inkMute) + } + .buttonStyle(.plain) + if pv.state != .invalid { + Button { apply(pv, changed: changed) } label: { + Text(L.applyRestart(lang)).font(.sans(13, .semibold)).foregroundStyle(t.accent) + } + .buttonStyle(.plain) + } + } + .padding(.top, 12) + } + + // 仅当前网络可见:同属网络设置,紧随端口、以分隔线归组。只有同时连了多个网络时才有意义, + // 故描述按是否多网卡分两种措辞,避免单网卡时给出空泛的“其它网络”字样。 + settingRow(top: true, title: L.bindOnlyTitle(lang), + desc: state.interfaces.count > 1 + ? L.bindOnlyDescMulti(lang) + : L.bindOnlyDescSingle(lang)) { + ToggleSwitch(t: t, isOn: state.bindSelectedOnly) { state.setBindSelectedOnly(!state.bindSelectedOnly) } + } + .padding(.top, 14) + + // MARK: 访问权限 + HStack { + SectionLabel(t: t, text: L.sectionPermission(lang)) + Spacer() + Text("\(L.currentColon(lang))\(ps.tag)").font(.sans(11, .bold)) + .foregroundStyle(ps.writable ? t.accent : t.inkMute) + .padding(.horizontal, 9).padding(.vertical, 2) + .background(Capsule().fill(ps.writable ? t.accentSoft : .clear)) + .overlay(Capsule().strokeBorder(ps.writable ? .clear : t.line, lineWidth: 1)) + } + .padding(.top, 24).padding(.bottom, 4) + + permRow(name: L.permReadName(lang), desc: L.permReadDesc(lang), locked: true, on: true) + permRow(name: L.permUploadName(lang), + desc: state.canToggleUpload ? L.permUploadDescOn(lang) : L.permUploadDescOff(lang), + locked: !state.canToggleUpload, + 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) + Text(ps.writable ? L.permInfoWritable(lang) : L.permInfoReadonly(lang)) + .font(.sans(11.5)).foregroundStyle(t.dark ? t.ink : Color(hex: 0x8a3a1e)).lineSpacing(2) + Spacer(minLength: 0) + } + .padding(12) + .background(RoundedRectangle(cornerRadius: 10, style: .continuous).fill(t.accentSoft)) + .padding(.top, 12) + + // 明文传输提示:纯 LAN 不加密,公共网络下同网的人能看到内容。用克制的灰字、不进彩底警告框。 + HStack(alignment: .top, spacing: 8) { + Image(systemName: "lock.open").font(.system(size: 13)).foregroundStyle(t.inkMute).padding(.top, 1) + Text(L.plaintextWarning(lang)) + .font(.sans(11.5)).foregroundStyle(t.inkMute).lineSpacing(2) + Spacer(minLength: 0) + } + .padding(.top, 12) + + // MARK: 外观 + SectionLabel(t: t, text: L.sectionAppearance(lang)).padding(.top, 24).padding(.bottom, 8) + HStack(spacing: 8) { + appearanceSeg(L.appearanceFollow(lang), .system) + appearanceSeg(L.appearanceLight(lang), .light) + appearanceSeg(L.appearanceDark(lang), .dark) + } + + // MARK: 语言 + SectionLabel(t: t, text: L.sectionLanguage(lang)).padding(.top, 24).padding(.bottom, 8) + HStack(spacing: 8) { + langSeg(L.langFollow(lang), .system) + langSeg("中文", .zh) // 语言名用本族文字,不翻译 + langSeg("English", .en) + } + + // MARK: 主界面(最近分享展示 + 窗口尺寸) + SectionLabel(t: t, text: L.sectionMain(lang)).padding(.top, 24).padding(.bottom, 4) + settingRow(title: L.showRecentsTitle(lang), desc: L.showRecentsDesc(lang)) { + ToggleSwitch(t: t, isOn: state.showRecents) { state.setShowRecents(!state.showRecents) } + } + 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() + } + } + + // MARK: 更新 + // 始终展示这一组:开关留在设置里,用户才能确认「自动更新」这个功能确实存在。 + // dev / 未签名构建里 updater 未启动(占位 EdDSA 公钥),此时只把开关置灰、并改说明文案 + // 点明原因——是「此构建未启用」而非把整段藏掉。isActive 只决定可用态,不决定是否渲染。 + SectionLabel(t: t, text: L.sectionUpdate(lang)).padding(.top, 24).padding(.bottom, 4) + settingRow(title: L.autoUpdate(lang), + desc: updater.isActive + ? L.autoUpdateDescOn(lang) + : L.autoUpdateDescOff(lang)) { + ToggleSwitch(t: t, isOn: updater.automaticChecks, locked: !updater.isActive) { + updater.setAutomaticChecks(!updater.automaticChecks) + } + } + + // MARK: 命令行工具 + // 裸二进制(swift run)没有 .app 可指:不给安装按钮,状态/卸载照常。 + SectionLabel(t: t, text: L.sectionCLI(lang)).padding(.top, 24).padding(.bottom, 8) + HStack(spacing: 12) { + VStack(alignment: .leading, spacing: 2) { + Text("localshare").font(.mono(13.5, .bold)).foregroundStyle(t.ink) + Text(cliHint).font(.sans(11.5)).foregroundStyle(t.inkMute) + .lineLimit(1).truncationMode(.middle) + } + Spacer(minLength: 8) + if state.cliStatus != .notInstalled { + Button { state.uninstallCLI() } label: { + Text(L.uninstall(lang)).font(.sans(13, .semibold)).foregroundStyle(t.inkMute) + } + .buttonStyle(.plain) + .padding(.trailing, 4) + } + if state.cliStatus == .installed { + HStack(spacing: 5) { + Image(systemName: "checkmark").font(.system(size: 13, weight: .bold)) + Text(L.installed(lang)).font(.sans(11.5, .bold)) + } + .foregroundStyle(t.ok) + } else if CLIInstaller.binaryPath() != nil { + GhostButton(t: t, + title: state.cliStatus == .notInstalled ? L.install(lang) : L.reinstall(lang), + systemImage: "terminal") { + state.installCLI() + } + } + } + .padding(.vertical, 4) + } + } + .onAppear { + portText = String(state.configuredPort) + state.refreshCLIStatus() + } + } + + // 命令行工具状态行:已安装显示链接路径;链接归属不了当前进程(裸跑/指向别处)时 + // 直接亮出实际指向,让人自己判断;未装时一句话点明用途或受限原因。 + private var cliHint: String { + switch state.cliStatus { + case .installed: + return CLIInstaller.linkPath + case .stale(let dest): + return "→ " + (dest as NSString).abbreviatingWithTildeInPath + case .notInstalled: + return CLIInstaller.binaryPath() != nil ? L.cliHintAvailable(state.lang) : L.cliHintUnavailable(state.lang) + } + } + + private func appearanceSeg(_ label: String, _ pref: AppState.AppearancePref) -> some View { + let on = state.appearance == pref + return Button { state.setAppearance(pref) } label: { + Text(label).font(.sans(13, on ? .semibold : .medium)) + .foregroundStyle(on ? .white : t.ink) + .frame(maxWidth: .infinity).frame(height: 34) + .background(RoundedRectangle(cornerRadius: 9, style: .continuous).fill(on ? t.accent : t.surface)) + .overlay(RoundedRectangle(cornerRadius: 9, style: .continuous).strokeBorder(on ? .clear : t.line, lineWidth: 1)) + } + .buttonStyle(.plain) + } + + // 语言分段:结构同 appearanceSeg,绑 langPref / setLangPref。 + private func langSeg(_ label: String, _ pref: LangPref) -> some View { + let on = state.langPref == pref + return Button { state.setLangPref(pref) } label: { + Text(label).font(.sans(13, on ? .semibold : .medium)) + .foregroundStyle(on ? .white : t.ink) + .frame(maxWidth: .infinity).frame(height: 34) + .background(RoundedRectangle(cornerRadius: 9, style: .continuous).fill(on ? t.accent : t.surface)) + .overlay(RoundedRectangle(cornerRadius: 9, style: .continuous).strokeBorder(on ? .clear : t.line, lineWidth: 1)) + } + .buttonStyle(.plain) + } + + // 通用设置行:「标题 +(可选)说明 + 右侧控件」。同一分组内多行靠 top 顶部分隔线对齐, + // 紧贴小节标题的首行不画线(top 默认 false)——分隔线只用来区隔相邻行,不重复标题已有的分隔。 + private func settingRow(top: Bool = false, title: String, desc: String? = nil, + @ViewBuilder trailing: () -> Trailing) -> some View { + HStack(spacing: 12) { + VStack(alignment: .leading, spacing: 2) { + Text(title).font(.sans(13.5, .semibold)).foregroundStyle(t.ink) + if let desc { + Text(desc).font(.sans(11.5)).foregroundStyle(t.inkMute) + .fixedSize(horizontal: false, vertical: true) + } + } + Spacer(minLength: 8) + trailing() + } + .padding(.vertical, 13) + .overlay(alignment: .top) { if top { Rectangle().fill(t.line).frame(height: 1) } } + } + + // 权限专用行(带「始终开启」标记与可锁定开关)。locked 且无 action = 锁定常开(读取); + // locked 且有 action = 当前形态不可用(开关置灰)。top 同 settingRow:仅相邻行间画分隔线。 + private func permRow(name: String, desc: String, locked: Bool, on: Bool, top: Bool = false, + action: (() -> Void)? = nil) -> some View { + HStack(spacing: 12) { + VStack(alignment: .leading, spacing: 2) { + HStack(spacing: 6) { + Text(name).font(.sans(13.5, .semibold)).foregroundStyle(t.ink) + if locked && action == nil { Text(L.alwaysOn(state.lang)).font(.sans(11)).foregroundStyle(t.inkFaint) } + } + Text(desc).font(.sans(11.5)).foregroundStyle(t.inkMute) + } + Spacer() + ToggleSwitch(t: t, isOn: on, locked: locked, action: action ?? {}) + } + .padding(.vertical, 13) + .overlay(alignment: .top) { if top { Rectangle().fill(t.line).frame(height: 1) } } + } + + private func apply(_ pv: PortCheck, changed: Bool) { + guard pv.state != .invalid, changed, let p = Int(portText) else { return } + state.applyPort(in_port_t(p)) + state.goShare() + } +} diff --git a/Sources/LocalShare/ShareScreen.swift b/Sources/LocalShare/ShareScreen.swift new file mode 100644 index 0000000..017146d --- /dev/null +++ b/Sources/LocalShare/ShareScreen.swift @@ -0,0 +1,360 @@ +import SwiftUI +import AppKit + +// MARK: - 分享屏(单文件 / 文件夹票据) + +struct ShareScreen: View { + let t: Theme + @EnvironmentObject var state: AppState + @State private var showViewers = false // 在线访客明细弹窗(点摘要行展开) + @State private var showText = false // 文本编辑弹层(编辑当前分享的文本) + private func editText() { showText = true } + var body: some View { + let ps = permSummary(state.permission, state.lang) + ScreenFrame(t: t) { + // 二级页头部,与传递文本页同款:← 返回主页 + 标题 +齿轮。运行态/地址在票据正文(QR 说明 + + // CopyPill)已具,头部不再堆 StatusPill,避免与正文重复、并与 TextScreen 一致。 + HStack(spacing: 10) { + IconButton(t: t, systemImage: "chevron.left", help: L.back(state.lang)) { state.goShare() } + Text(L.shareFileTitle(state.lang)).font(.display(21, .semibold)).foregroundStyle(t.ink) + Spacer() + IconButton(t: t, systemImage: "gearshape", help: L.settings(state.lang)) { state.openSettings() } + } + } content: { + VStack(spacing: 16) { + ticket(ps) + // 文本与文件共存:在票据下补一张「附带文本」小卡(预览 + 编辑)。纯文本分享则文本就是票据本身。 + if state.hasText && !state.isTextOnly { attachedTextCard } + if !state.received.isEmpty { receivedCard } + actions + if state.interfaces.count > 1 { interfacePicker } + if state.sharedIsFile && state.showRecents { + RecentSharesView(t: t, lang: state.lang, items: state.recents.filter { $0.exists && Set($0.paths) != state.currentSharePaths }, + onAll: { state.openHistory() }, onReshare: { state.reshare($0) }, + onDelete: { state.deleteRecent($0) }) + } + } + } + .sheet(isPresented: $showText) { TextEntrySheet(t: t, initial: state.textDraft, isUpdate: true) } + } + + private func ticket(_ ps: PermSummary) -> some View { + TicketCard(t: t) { + if state.isTextOnly { AnyView(textStub(ps)) } + else if state.isMultiple { AnyView(multipleStub(ps)) } + else if state.sharedIsFile { AnyView(fileStub(ps)) } + else { AnyView(folderStub(ps)) } + } pass: { + qrPass + } + } + + // 纯文本分享的存根:文本图标 + 「正在分享文本」+ 字数 + 前几行预览。 + private func textStub(_ ps: PermSummary) -> some View { + let lang = state.lang + let text = state.sharedText ?? "" + return VStack(alignment: .leading, spacing: 9) { + HStack(alignment: .top, spacing: 12) { + TextGlyph(t: t, size: 42) + VStack(alignment: .leading, spacing: 2) { + Text("\(L.sharingTextKicker(lang)) · \(ps.tag)").font(.sans(10.5, .bold)).tracking(0.8).foregroundStyle(t.inkMute) + Text(L.webText(lang)).font(.sans(16, .bold)).foregroundStyle(t.ink) + Text(LStr.charCount(text.count, lang)).font(.mono(11.5)).foregroundStyle(t.inkMute) + } + Spacer(minLength: 8) + ClearButton(t: t, lang: lang) { state.clearShare() } + } + Text(text).font(.mono(11.5)).foregroundStyle(t.inkFaint) + .lineLimit(3).truncationMode(.tail) + .frame(maxWidth: .infinity, alignment: .leading) + .contentShape(Rectangle()) + .onTapGesture { editText() } + } + .padding(.horizontal, 18).padding(.vertical, 16) + } + + // 文本+文件时的「附带文本」卡:单行预览 + 编辑入口(清空再提交即撤下文本,文件保留)。 + private var attachedTextCard: some View { + let lang = state.lang + return HStack(spacing: 11) { + TextGlyph(t: t, size: 30) + VStack(alignment: .leading, spacing: 1) { + Text(L.sharingTextKicker(lang)).font(.sans(11, .bold)).tracking(0.5).foregroundStyle(t.inkMute) + Text(state.sharedText ?? "").font(.mono(11.5)).foregroundStyle(t.ink) + .lineLimit(1).truncationMode(.tail) + } + Spacer(minLength: 6) + GhostButton(t: t, title: L.editTextButton(lang), systemImage: "pencil") { editText() } + } + .padding(.horizontal, 14).padding(.vertical, 10) + .background(RoundedRectangle(cornerRadius: 14, style: .continuous).fill(t.surface)) + .overlay(RoundedRectangle(cornerRadius: 14, style: .continuous).strokeBorder(t.line, lineWidth: 1)) + } + + // 多项存根:叠放印章 + 「正在分享 N 项」+ 文件/文件夹分项概要 + 前几项名称预览。 + private func multipleStub(_ ps: PermSummary) -> some View { + let items = state.sharedItems + let lang = state.lang + let dirCount = items.filter { (try? $0.resourceValues(forKeys: [.isDirectoryKey]).isDirectory) == true }.count + let fileCount = items.count - dirCount + var parts: [String] = [] + if fileCount > 0 { parts.append(LStr.fileCount(fileCount, lang)) } + if dirCount > 0 { parts.append(LStr.folderCount(dirCount, lang)) } + let preview = items.prefix(3).map(\.lastPathComponent).joined(separator: lang == .zh ? "、" : ", ") + + (items.count > 3 ? (lang == .zh ? " 等" : " …") : "") + return VStack(alignment: .leading, spacing: 9) { + HStack(alignment: .top, spacing: 12) { + MultiGlyph(t: t, size: 42) + VStack(alignment: .leading, spacing: 2) { + Text("\(L.sharingKicker(lang)) · \(ps.tag)").font(.sans(10.5, .bold)).tracking(0.8).foregroundStyle(t.inkMute) + Text(LStr.itemCount(items.count, lang)).font(.sans(16, .bold)).foregroundStyle(t.ink) + Text(parts.joined(separator: " · ")).font(.mono(11.5)).foregroundStyle(t.inkMute) + } + Spacer(minLength: 8) + ClearButton(t: t, lang: lang) { state.clearShare() } + } + MultiPreviewMenu(t: t, lang: lang, items: items, preview: preview) { state.revealInFinder($0) } + } + .padding(.horizontal, 18).padding(.vertical, 16) + } + + // 单文件存根 + private func fileStub(_ ps: PermSummary) -> some View { + let url = state.sharedURL ?? URL(fileURLWithPath: "/") + let cat = FileType.category(of: url, isDir: false) + let catName = (cat == .other) ? L.fileKind(state.lang) : cat.displayName(state.lang) + return VStack(alignment: .leading, spacing: 9) { + HStack(alignment: .top, spacing: 12) { + TypeGlyph(t: t, category: cat, ext: url.pathExtension.lowercased(), size: 42) + VStack(alignment: .leading, spacing: 2) { + Text(ps.tag).font(.sans(10.5, .bold)).tracking(0.8).foregroundStyle(t.inkMute) + Text(url.lastPathComponent).font(.sans(14, .semibold)).foregroundStyle(t.ink) + .lineLimit(1).truncationMode(.middle) + Text("\(state.sharedDetail ?? "") · \(catName)").font(.mono(11.5)).foregroundStyle(t.inkMute) + } + Spacer(minLength: 8) + ClearButton(t: t, lang: state.lang) { state.clearShare() } + } + PathRow(t: t, lang: state.lang, url: url, isFile: true) + } + .padding(.horizontal, 18).padding(.vertical, 16) + } + + // 文件夹存根(含路径 + 权限 chips + 改权限入口) + private func folderStub(_ ps: PermSummary) -> some View { + let url = state.sharedURL ?? URL(fileURLWithPath: "/") + return VStack(alignment: .leading, spacing: 0) { + HStack(alignment: .top, spacing: 12) { + FolderGlyph(t: t, size: 42) + VStack(alignment: .leading, spacing: 2) { + Text("\(L.sharingFolderKicker(state.lang)) · \(ps.tag)").font(.sans(10.5, .bold)).tracking(0.8).foregroundStyle(t.inkMute) + Text(url.lastPathComponent).font(.sans(16, .bold)).foregroundStyle(t.ink) + .lineLimit(1).truncationMode(.middle) + Text(state.sharedDetail ?? "").font(.mono(11.5)).foregroundStyle(t.inkMute) + } + Spacer(minLength: 8) + ClearButton(t: t, lang: state.lang) { state.clearShare() } + } + .padding(.horizontal, 18).padding(.top, 16) + PathRow(t: t, lang: state.lang, url: url, isFile: false) + .padding(.horizontal, 18).padding(.top, 8).padding(.bottom, 12) + HStack(spacing: 6) { + ForEach(Array(ps.chips.enumerated()), id: \.offset) { i, c in + PermChip(t: t, text: c, hot: ps.writable && i > 0) + } + Spacer() + Button { state.openSettings() } label: { + Text(L.changePerm(state.lang)).font(.sans(11)).foregroundStyle(t.accent) + }.buttonStyle(.plain) + } + .padding(.horizontal, 18).padding(.bottom, 14) + } + } + + // 访客新上传的文件(最多列 3 条,点击在 Finder 中显示)。换分享/清除时由 AppState 清空。 + private var receivedCard: some View { + VStack(alignment: .leading, spacing: 0) { + HStack(spacing: 6) { + Circle().fill(t.accent).frame(width: 6, height: 6) + Text(L.received(state.lang)).font(.sans(11, .bold)).tracking(0.8).foregroundStyle(t.inkMute) + Spacer() + if state.received.count > 3 { + Text(LStr.itemCount(state.received.count, state.lang)).font(.mono(11)).foregroundStyle(t.inkFaint) + } + } + .padding(.horizontal, 16).padding(.top, 12).padding(.bottom, 5) + ForEach(state.received.prefix(3), id: \.self) { url in + ReceivedRow(t: t, lang: state.lang, url: url) { state.revealReceived(url) } + } + } + .padding(.bottom, 8) + .background(RoundedRectangle(cornerRadius: 14, style: .continuous).fill(t.surface)) + .overlay(RoundedRectangle(cornerRadius: 14, style: .continuous).strokeBorder(t.line, lineWidth: 1)) + } + + // 通行区:QR + 说明 + 复制条 + private var qrPass: some View { + let running = state.isRunning + let caption = state.isTextOnly ? L.scanCaptionText(state.lang) + : (state.isMultiple ? L.scanCaptionMultiple(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) + CopyPill(t: t, lang: state.lang, value: state.primaryURL ?? "—", + compact: true, onOpen: openInBrowser).padding(.top, 10) + if let local = state.localURL { + // 备用地址(主机名 / .local)紧贴主地址、归入卡内,保持内聚。左缩进对齐上方地址文字。 + BackupAddressRow(t: t, lang: state.lang, full: local) { + if let url = URL(string: local) { NSWorkspace.shared.open(url) } + } + .padding(.top, 7).padding(.leading, 12) + } + // 在线访客:小绿点 + 摘要文案;点一下展开全部访客明细(设备名 / 完整 IP)。 + // 0 人时整行隐藏(不占位、不留空文案)。 + if running && state.viewerCount > 0 { + Button { showViewers.toggle() } label: { + HStack(spacing: 6) { + Circle().fill(t.ok).frame(width: 6, height: 6) + Text(viewerText).font(.sans(11.5)).foregroundStyle(t.inkMute) + .lineLimit(1).truncationMode(.tail) + Image(systemName: "chevron.down").font(.sans(8, .semibold)).foregroundStyle(t.inkFaint) + } + } + .buttonStyle(.plain) + .padding(.top, 12) + .transition(.opacity) + .popover(isPresented: $showViewers, arrowEdge: .bottom) { + ViewerListPopover(t: t, lang: state.lang, viewers: state.viewers) + } + } + } + .padding(.horizontal, 18).padding(.bottom, 18) + .animation(.easeInOut(duration: 0.2), value: state.viewerCount > 0) + } + + // 在线访客摘要:反查到设备名才领衔具名(单台直呼其名、多台「领衔 + 等 N 人」); + // 查不到则统一「N 人正在浏览」——不在摘要露 IP 尾号,完整 IP 留给展开列表。 + private var viewerText: String { + LStr.viewerSummary(name: state.viewers.first?.name, count: state.viewerCount, state.lang) + } + + @ViewBuilder private var actions: some View { + if state.isRunning { + HStack(spacing: 10) { + // 纯文本分享:主操作是「编辑文本」而非更换文件。 + if state.isTextOnly { + GhostButton(t: t, title: L.editTextButton(state.lang), + systemImage: "pencil", fullWidth: true) { editText() } + } else { + GhostButton(t: t, title: state.sharedIsFile ? L.replaceFile(state.lang) : L.replace(state.lang), + systemImage: "arrow.left.arrow.right", fullWidth: true) { state.pickAny() } + } + DangerButton(t: t, title: L.stop(state.lang)) { state.stop() } + } + } 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() } + } + } + } + + private var interfacePicker: some View { + Menu { + ForEach(state.interfaces) { iface in + Button(iface.displayName(state.lang)) { state.selectInterface(iface) } + } + } label: { + HStack(spacing: 7) { + Image(systemName: "dot.radiowaves.left.and.right").font(.system(size: 11)) + Text(state.selectedInterface?.displayName(state.lang) ?? L.selectSource(state.lang)).font(.mono(11)) + Image(systemName: "chevron.down").font(.system(size: 8, weight: .semibold)) + } + .foregroundStyle(t.ink) + .padding(.horizontal, 13).padding(.vertical, 7) + .background(Capsule().strokeBorder(t.line, lineWidth: 1)) + } + .menuStyle(.borderlessButton).menuIndicator(.hidden).fixedSize() + } + + private func openInBrowser() { + guard let s = state.primaryURL, let url = URL(string: s) else { return } + NSWorkspace.shared.open(url) + } +} + +// 多选项目预览行:标签沿用原淡色名称预览(前 3 项 + 等),点击弹出全部分享项菜单, +// 选中即在 Finder 中显示——与单项分享的 PathRow、收件行同一交互语言(hover 下划线 + 手型)。 +struct MultiPreviewMenu: View { + let t: Theme + let lang: Lang + let items: [URL] + let preview: String + let reveal: (URL) -> Void + @State private var hover = false + var body: some View { + Menu { + Section(L.revealInFinder(lang)) { + ForEach(items, id: \.self) { url in + Button(url.lastPathComponent) { reveal(url) } + } + } + } label: { + HStack(alignment: .firstTextBaseline, spacing: 5) { + Text(preview) + .font(.sans(11.5)) + .foregroundStyle(hover ? t.inkMute : t.inkFaint) + .underline(hover, color: t.inkFaint) + .lineLimit(2).truncationMode(.tail) + .multilineTextAlignment(.leading) + Image(systemName: "chevron.down") + .font(.system(size: 7.5, weight: .semibold)) + .foregroundStyle(hover ? t.accent : t.inkFaint) + } + .contentShape(Rectangle()) + } + .menuStyle(.borderlessButton).menuIndicator(.hidden) + .fixedSize(horizontal: false, vertical: true) + .onHover { h in hover = h; if h { NSCursor.pointingHand.push() } else { NSCursor.pop() } } + .help(L.revealShareItems(lang)) + } +} +// 在线访客明细弹窗:列出全部活跃访客(设备名优先,查不到显示完整 IP),最近活跃在前。 +// 仅分享者本机可见——网页端永不外泄身份(见 FileServer.activeViewers)。 +struct ViewerListPopover: View { + let t: Theme + let lang: Lang + let viewers: [ViewerInfo] + var body: some View { + VStack(alignment: .leading, spacing: 0) { + HStack(spacing: 6) { + Circle().fill(t.ok).frame(width: 6, height: 6) + Text(L.viewing(lang)).font(.sans(11, .bold)).tracking(0.8).foregroundStyle(t.inkMute) + Spacer(minLength: 16) + Text(LStr.viewerCountLabel(viewers.count, lang)).font(.mono(11)).foregroundStyle(t.inkFaint) + } + .padding(.horizontal, 14).padding(.top, 12).padding(.bottom, 8) + ForEach(viewers) { v in + // 左:身份(查到设备名则名字为主、完整 IP 作副行;查不到直接显示完整 IP)。 + // 右:本次浏览开始至今的时长,尾部对齐成一列,便于多人时纵向扫读。 + HStack(alignment: .firstTextBaseline, spacing: 10) { + VStack(alignment: .leading, spacing: 1) { + Text(v.fullLabel).font(.sans(12.5, .medium)).foregroundStyle(t.ink) + .lineLimit(1).truncationMode(.middle) + if !v.name.isEmpty { + Text(v.ip).font(.mono(10.5)).foregroundStyle(t.inkFaint) + } + } + Spacer(minLength: 8) + Text(LStr.elapsed(v.since, lang)).font(.sans(10.5)).foregroundStyle(t.inkFaint) + .fixedSize() + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 14).padding(.vertical, 5) + } + } + .padding(.bottom, 8) + .frame(width: 230) + } +} diff --git a/Sources/LocalShare/TextScreen.swift b/Sources/LocalShare/TextScreen.swift new file mode 100644 index 0000000..fa9e245 --- /dev/null +++ b/Sources/LocalShare/TextScreen.swift @@ -0,0 +1,265 @@ +import SwiftUI +import AppKit + +// 收件单行:类型小图标 + 文件名,悬停亮出跳转箭头(点击在 Finder 中显示)。 +struct ReceivedRow: View { + let t: Theme + let lang: Lang + let url: URL + let reveal: () -> Void + @State private var hover = false + var body: some View { + Button(action: reveal) { + HStack(spacing: 9) { + TypeGlyph(t: t, category: FileType.category(of: url, isDir: false), + ext: url.pathExtension.lowercased(), size: 26) + Text(url.lastPathComponent).font(.sans(12.5, .medium)).foregroundStyle(t.ink) + .lineLimit(1).truncationMode(.middle) + Spacer(minLength: 8) + Image(systemName: "arrow.up.forward") + .font(.system(size: 10, weight: .semibold)) + .foregroundStyle(hover ? t.accent : t.inkFaint) + } + .padding(.horizontal, 16).padding(.vertical, 6) + .contentShape(Rectangle()) + .background(hover ? t.surfaceAlt : .clear) + } + .buttonStyle(.plain) + .onHover { hover = $0 } + .help(L.revealInFinder(lang)) + } +} + +// 收件箱卡片(收文本 v2):手机投递来的文本,新→旧。每条带来源(设备名 / IP)+ 收到时长 + 正文预览, +// 单条复制 / 删除,整卡清空(二次确认)。复用「新收到」卡片视觉语言。未读角标随到达累加,进入本卡即清。 +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 行,可选中),右侧复制(成功闪 ✓)+ 删除。 +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 } + } +} +// MARK: - 传递文本二级页(收/发合一) + +// 一页一码:上半发文本(编辑器 + 发送/更新/撤回),中间一个二维码恒指 /ls/text,下半是「允许收文本」 +// 开关 + 收件箱。手机扫这一个码即可读取电脑文本并(开关开着时)发回文本——双向都在这页。 +struct TextScreen: View { + let t: Theme + @EnvironmentObject var state: AppState + @State private var draft = "" + var body: some View { + let lang = state.lang + 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() } + } + } content: { + VStack(spacing: 16) { + if state.isRunning, state.qrImage != nil { qrCard } else { idleHint } + 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 } + } + + // 发文本:编辑器 + 发送/更新(与当前广播一致时置灰);已在广播则可「撤回」(撤下文本,文件不受影响)。 + 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.sendTextKicker(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) + } + } + 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) } + } + .padding(.top, 7).padding(.leading, 12) + } + } + .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) + } +} diff --git a/Sources/LocalShare/TextViewer.swift b/Sources/LocalShare/TextViewer.swift index f093857..cec7a0d 100644 --- a/Sources/LocalShare/TextViewer.swift +++ b/Sources/LocalShare/TextViewer.swift @@ -3,7 +3,7 @@ import Foundation // 文本预览页(Mac→手机发文本,PreviewPage 壳 + 客户端渲染)。与 md/json/csv 查看器同套路, // 但内容源不是磁盘文件而是内存里的一段字符串:服务端把文本以安全的 JS 字符串内联进页面 // (共用 LStr.jsEscape:转义 < 挡 、转义换行/行分隔符破坏 JS 串),客户端只读地放进
。
-// 关键约束(见 PLAN.md「传递文本」与 CLAUDE.md):
+// 关键约束(见 docs/ARCHITECTURE.md「传递文本」与 CLAUDE.md):
 //  · 纯文本展示——不当 Markdown 渲染(任意文本里的 * _ # 不该被吃掉),用 textContent 注入天然防 XSS;
 //  · 复制按钮必须走 execCommand 回退——纯 http 局域网是非安全上下文,navigator.clipboard 不可用;
 //  · 自动链接只认 http(s),由正则保证 scheme,不会引入 javascript: 之类。
diff --git a/Sources/LocalShare/Updater.swift b/Sources/LocalShare/Updater.swift
index 07c7bfa..c0f3352 100644
--- a/Sources/LocalShare/Updater.swift
+++ b/Sources/LocalShare/Updater.swift
@@ -11,7 +11,7 @@ import SwiftUI
 //   SUScheduledCheckInterval 自动后台检查;发现新版会弹原生提示让用户确认安装
 //   (SUAutomaticallyUpdate=false,不静默安装)。
 // - 信任链走 EdDSA:更新包由私钥签名、app 内嵌 SUPublicEDKey 校验,与 ad-hoc 代码签名无关,
-//   因此未公证也能安全自更新(见 PLAN.md「自动更新」一节)。
+//   因此未公证也能安全自更新(见 docs/ARCHITECTURE.md「自动更新」一节)。
 //
 // 配置(含 SUFeedURL / SUPublicEDKey)全部放在 bundle/Info.plist,这里不硬编码。
 @MainActor
diff --git a/Sources/LocalShare/ViewerInfo.swift b/Sources/LocalShare/ViewerInfo.swift
new file mode 100644
index 0000000..d70760a
--- /dev/null
+++ b/Sources/LocalShare/ViewerInfo.swift
@@ -0,0 +1,12 @@
+import Foundation
+
+// 一名在线访客的明细(仅在分享者本机窗口展示,绝不外泄给网页端)。
+// name 是反查到的设备名,查不到为空串;ip 始终是完整 IPv4。
+struct ViewerInfo: Identifiable {
+    let ip: String
+    let name: String        // 反查到的设备名,查不到为空串
+    let since: Date         // 本次浏览会话首次出现时间(断开超出在线窗口再来即重新计)
+    var id: String { ip }
+    // 展开列表:设备名优先,查不到显示完整 IP(不再只剩尾号,便于区分是哪几台)。
+    var fullLabel: String { name.isEmpty ? ip : name }
+}
diff --git a/TODO.md b/TODO.md
deleted file mode 100644
index 4f67ac3..0000000
--- a/TODO.md
+++ /dev/null
@@ -1,2 +0,0 @@
-- [x] i18N(简体中文 + English;原生 app 设置里选语言、网页随浏览器 Accept-Language;见 Lang.swift)
-- [ ] 在线编辑 / 删除权限(访客上传已在 0.6 落地;编辑 / 删除后端未实现,规划见 PLAN.md §7)
diff --git a/appcast.xml b/appcast.xml
deleted file mode 100644
index 35197ca..0000000
--- a/appcast.xml
+++ /dev/null
@@ -1,14 +0,0 @@
-
-
-  
-    LocalShare
-    
-      0.7.0
-      9
-      0.7.0
-      13.0
-      Fri, 12 Jun 2026 03:39:47 +0000
-      
-    
-  
-
diff --git a/build.sh b/build.sh
index 28cdc54..31aefaf 100755
--- a/build.sh
+++ b/build.sh
@@ -43,7 +43,7 @@ codesign --force --deep --sign - "$APP/Contents/Frameworks/Sparkle.framework"
 codesign --force --sign - "$APP"
 
 echo "==> 校验依赖:禁止包外 dylib,仅允许已内置的 @rpath framework(放宽后的核心戒律)"
-# dufs 的崩溃根因是运行时缺失的包外 dylib(指向 /opt/homebrew)。这里逐条检查主二进制依赖:
+# 核心戒律:不依赖任何包外 dylib——运行时若去包外路径(/opt/homebrew 等)找库,换台没装的机器就缺库崩溃。这里逐条检查主二进制依赖:
 # 系统库放行;@rpath 引用必须对应 Contents/Frameworks 里确实存在的 framework;其余(绝对路径
 # 包外 dylib)一律判失败。
 FAIL=0
@@ -64,7 +64,7 @@ while IFS= read -r dep; do
 # otool -L 对 universal 二进制会按架构各打一行头(顶格),依赖行则以制表符缩进——
 # 只取缩进行即可跳过所有头行,sort -u 合并 arm64/x86_64 的重复项。
 done < <(otool -L "$APP/Contents/MacOS/$BINARY" | grep '^[[:space:]]' | awk '{print $1}' | sort -u)
-[ "$FAIL" -eq 0 ] || { echo "依赖校验失败:检测到包外 dylib,违反核心戒律(见 PLAN.md §0)"; exit 1; }
+[ "$FAIL" -eq 0 ] || { echo "依赖校验失败:检测到包外 dylib,违反核心戒律(见 docs/ARCHITECTURE.md §0)"; exit 1; }
 
 echo "==> 验证签名有效性"
 codesign --verify --deep --strict "$APP"
diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md
new file mode 100644
index 0000000..424b893
--- /dev/null
+++ b/docs/ARCHITECTURE.md
@@ -0,0 +1,196 @@
+# LocalShare · 架构(ARCHITECTURE)
+
+> macOS 原生单窗口 app:选一个文件夹 → 窗口出现二维码 → 同 WiFi 下的手机扫码即可在浏览器里只读浏览该文件夹(可按需开启访客上传与「传递文本」,默认只读)。
+
+本文是**现状架构参考**——核心约束、工程结构、关键设计决策与实现要点,供任何机器 `git pull` 后据此继续。配套文档:视觉设计规范见根目录 `DESIGN.md`;发布与版本流程见 `CLAUDE.md`。
+
+---
+
+## 0. 核心戒律:不依赖包外 dylib
+
+崩溃的典型根因是 arm64 二进制**运行时缺失动态库**——动态链接到某个包外 `.dylib`(指向 `/opt/homebrew/…`,换台没装的机器就没)。因此戒律的精神是**「换任何机器都不会缺库」**:
+
+- 只链接系统框架;纯 Swift 第三方库(Swifter)以 SPM 源码**静态编进二进制**。
+- **0.3 放宽**:自动更新需要的 Sparkle 只以二进制 framework 分发(含动态库 + XPC 服务 + helper app),无法源码静态编进。结论:允许把二进制 framework 以 `@rpath` **内置进 `.app/Contents/Frameworks/`**——它随包走、自包含、运行时永不缺失,完全规避「包外缺库」的失败模式。
+- **判据**:禁止任何位于 `.app` 包外的 dylib(绝对路径如 `/opt/homebrew`、`/usr/local` 一律禁止);只接受 `@rpath` 引用、且已验证存在于 `Contents/Frameworks/` 的内置 framework。`build.sh` 末尾与 CI 均逐条校验(过滤系统库后,剩余依赖只能是 `@rpath/X.framework` 且对应 framework 确在包内)。
+
+---
+
+## 1. 锁定的设计决策
+
+| 维度 | 决策 |
+|---|---|
+| 平台 | 仅 macOS(Apple Silicon 为主),原生 `.app` |
+| 网络 | 仅同一 WiFi(LAN),无隧道 / 无公网 / 无账号 |
+| 技术栈 | Swift / SwiftUI,只链接系统框架,零包外 dylib 风险 |
+| HTTP 服务 | Swifter(SPM 源码编进 app),只读静态服务为主 |
+| 服务模型 | 三种分享形态:① 单文件夹 → 移动端友好目录列表(含 `index.html` 则直接显示它);② 单文件 → 扫码直接打开、不暴露同目录其它文件;③ 多文件/目录 → 合成**虚拟根**列出选中项,首段路径映射到真实 URL。三者均防目录穿越,每项各自为根 |
+| 鉴权 | 每次「分享」动作生成随机 token,内嵌进二维码 URL(`?t=…`);首访校验后种会话 cookie,后续资源自动放行;换分享 / 停止即轮换,旧链接 / cookie / 二维码即刻作废,权限不跨分享延续;猜 `IP:端口` 的路人被 403 |
+| 协议 | 明文 http。威胁模型:防「猜地址的路人」(token),不防同网嗅探与持链者转发——后者风险窗口随 token 轮换收敛到单次分享内。自签证书会把「扫码即用」变成证书警告页、伤核心体验,**不做** |
+| 二维码 | 裸 LAN IP(智能选接口、多候选给下拉),CoreImage `CIQRCodeGenerator` 生成,无第三方库;窗口另显 `.local` 备选链接 + 可复制 URL |
+| GUI | 单窗口:**功能主页**(拖拽/选择分享 + 传递文本入口 + 最近分享)→ 文件票据 / 传递文本 / 设置 / 历史**均为带返回的二级页** |
+| 生命周期 | 冷启动**不**自动重播上次分享(开 app 落主页,上次分享留「最近分享」一键重发)· 关窗不退出(进程/服务续活、菜单栏唤回即回原状)· 端口自动选 · 退出才停服务 |
+| 分发 | Xcode ad-hoc 签名;首次帮同事过一次 Gatekeeper(放行被持久记住) |
+| 自动更新 | Sparkle(`@rpath` 内置 framework)· 后台检查、发现新版弹提示由用户确认 · 信任链走 **EdDSA 签名**(与 ad-hoc 代码签名无关,未公证也安全) |
+| 沙盒 | **不开 App Sandbox**(内部手发、不上架),省掉沙盒对「读任意文件夹」的限制 |
+| 国际化 | 简体中文 + English 双语,文案编进二进制(不依赖资源 bundle);**两个解析域彼此独立**——原生 app 跟设置,网页**逐请求**按浏览器 `Accept-Language` |
+
+---
+
+## 2. 工程结构
+
+```
+LocalShare/
+  Package.swift            # swift-tools-version:5.9(Swift 5 语言模式,放宽并发检查)
+  Package.resolved         # Swifter pin 在 1.5.0
+  build.sh                 # swift build -c release → 组装 .app → ad-hoc 签名 → dist/
+  bundle/Info.plist        # .app 的静态 Info.plist 模板(Sparkle feed / 公钥 / 版本占位)
+  README.md / README_CN.md # 英文默认 + 中文
+  CLAUDE.md                # 给 Claude Code 的工作指引(根目录,必加载)
+  DESIGN.md                # 视觉设计规范(颜色/字体/组件,被多处源码按 §x 引用)
+  docs/
+    ARCHITECTURE.md        # 本文件
+    images/                # README 截图
+  Sources/LocalShare/
+    App.swift              # @main enum EntryPoint:分流 GUI / headless / CLI;含 LocalShareApp 与 AppDelegate
+    AppState.swift         # @MainActor ObservableObject:唯一真相源(分享项/文本/服务/网络/在线感知/持久化),含 Share / RecentShare / ReceivedText
+    ContentView.swift      # 单窗口 SwiftUI:主页 + 各二级页装配
+    Components.swift       # 票据风 UI 组件库(接受 Theme 显式传入)
+    Theme.swift            # 颜色/主题统一生成(浅/深色,强调色可切)
+    FileServer.swift       # Swifter 封装:token 中间件 + 防穿越 + 目录/文件/多选 + 上传 + 在线感知
+    Permission.swift       # 权限模型(read 常开 / add 访客上传)+ PermSummary 文案派生
+    FileType.swift         # 扩展名分类(预览类型登记、可执行文档去势名单)
+    DirectoryListing.swift # 目录列表页 HTML(移动端友好,href 逐段编码,隐藏文件不列)
+    PreviewPage.swift      # 预览壳页骨架(与文件同 URL,心跳/取景框)
+    MarkdownViewer / JsonViewer / CsvViewer  # 三类预览内容卡(md 用 vendored marked,json/csv 手写零依赖)
+    MarkedJS.swift         # vendored marked,作为 Swift 字符串常量编进二进制
+    SendTextPage / TextViewer  # 传递文本:发送页(手机→Mac)与文本预览壳页
+    NetworkInfo.swift      # getifaddrs 枚举 → 私网 IPv4 候选 + .local 主机名
+    QRCode.swift           # CoreImage 生成 QR → NSImage(+ 终端 ANSI 码)
+    Token.swift / Mime.swift   # 随机 token / 扩展名→MIME(text 带 charset=utf-8)
+    Lang.swift             # i18n:编进二进制的中英文案表(L / LStr / i18nJSON)
+    HeadlessServer.swift   # LS_HEADLESS=1 无界面模式 + CLI 前台模式
+    CLI.swift / CLIInstaller.swift  # 命令行入口(argv 解析、转发 GUI)+ symlink 安装
+    Updater.swift          # Sparkle 自动更新封装(仅 GUI 构造,headless 不碰)
+  Tests/LocalShareTests/   # XCTest 纯函数:防穿越 / 文件名清洗 / 多选 key / i18n / 文本
+  tools/                   # 无头 + curl 冒烟脚本(traversal / filenames / multiselect / upload-defang / token-302 / md-link / accept-language / text…)
+```
+
+入口 `@main enum EntryPoint` 三层分流:`LS_HEADLESS=1` → `HeadlessServer`(裸起服务)→ `CLI.parse` 命中 argv → `CLI.run` → 否则 `LocalShareApp`(SwiftUI)。三条路径**共用同一个 `FileServer`**,逻辑不分叉。
+
+---
+
+## 3. 关键实现要点
+
+### Swifter 1.5.0 API 与编码坑
+
+- 全部请求逻辑塞进**单个 middleware 闭包**(永远返回 response,绕开 router)。`HttpResponse.raw(code, reason, headers, writer)` 控状态码 / 自定义头 / 流式写文件。
+- ⚠️ **path 二次编码 bug**:Swifter 的 `HttpParser` 让 `request.path` 落地文件系统前**仍残留一层百分号编码**,必须 `removingPercentEncoding` 解码。纯 ASCII 路径无 `%` 故 `a.html` 正常,但 `b%20c.txt`、中文名不解码会 404。防穿越用的也是解码后的路径,所以 `%2e%2e` 同样被挡。
+- `.raw` 的 body length 未知(发完即关、无 keep-alive),所以文件响应**主动写 `Content-Length`**(让手机显示进度)。LAN 静态分发足够。
+
+### FileServer 请求处理(单 middleware)
+
+1. **token 鉴权**:`?t=` 或 cookie `ls_token` 任一等于当前 token 即放行(每请求取一次快照,轮换瞬间不串);靠 `?t=` 放行时种 `Set-Cookie: ls_token=…; HttpOnly`。浏览器导航(Accept 含 `text/html`)随即 **302** 到去掉 `?t=` 的干净 URL(token 不留地址栏/历史;curl 与 `*/*` 子请求不触发)。
+2. **防目录穿越**:`decoded → 去前导 / → 拼到 root → standardizedFileURL.resolvingSymlinksInPath`,结果必须 `== root` 或 `hasPrefix(root + "/")`,否则 403。`standardizedFileURL` 解掉 `..`,编码点点(`%2e%2e`)因「先解码后标准化」一并挡住。
+3. **目录**:无斜杠先 301(让相对资源解析)→ 含 `index.html` 发它 → 否则 `DirectoryListing` 列表页(绝对 href 逐段编码、隐藏文件不列、非根列表首行「返回上一级」)。
+4. **文件**:按扩展名查 MIME(text 加 `charset=utf-8`),`FileHandle` 分块(64KB)流式发。例外:`.md`/`.json`/`.csv` 的**浏览器导航**发预览壳页(与文件**同 URL**,正文相对引用靠浏览器解析命中常规服务);curl / `?raw=1` / `*/*` 一律拿原文。
+
+单文件夹的 2~4 步抽成 `serveTree(rootURL:relPath:…)` 复用。**单文件**直接发那一个。**多选**(`Share.multiple([Item])`,`makeItems` 以 lastPathComponent 为 key、重名 `-2` 兜底):空路径发虚拟根列表页,其余拆首段 `key` 映射真实 URL;未知 key / 文件项带子路径 → 404,穿越判据每项独立。
+
+### 网页侧 XSS 硬化与卫生加固
+
+被服务的内容跑在分享源(`http://本机:端口`)下,HTML/SVG 会被当同源页面执行脚本。防线:
+
+1. **访客上传去势(主修)**:`sanitizeFileName` 对可执行文档扩展名(html/htm/xhtml/svg/svgz/mht… 见 `FileType`)追加 `.txt`,落地成 `text/plain`。专堵「上传 `index.html` 顶替目录页 → 别人点进该目录零点击执行脚本」这条存储型 XSS。**只作用于上传**——分享者磁盘上自带的静态站点(含 `index.html`)经 `.directory` 直接服务、不过 `sanitizeFileName`,照常渲染。
+2. **全站 `X-Content-Type-Options: nosniff`**(纵深防御):关掉 MIME 猜测,正确声明类型的文件零回归。
+3. **Markdown 链接/图片协议白名单**(`MarkdownViewer.rendererConfig`):`.md` 走 marked 预览、不经去势,故覆盖 marked 的 `link`/`image` 渲染器,只放行安全协议(`http`/`https`/`mailto`/`tel`,图片加 `data:`)与相对/锚点。两个易错点:**先解 HTML 实体再判**(挡 `javascript:`);解码后剥掉码点 ≤32 的字符(挡 `javascript:`),冒号须在任何 `/ ? #` 之前才算协议。`tools/smoke-md-link-sanitize.cjs` 连同真实 vendored marked 跑断言。
+
+配套:**token-302 清洗**(上面第 1 步);**上传文件打 `com.apple.quarantine`**(分享者双击触发 Gatekeeper)。回归测试 `tools/smoke-upload-defang.sh`。
+
+### 访客上传
+
+`Permission.add` 开关(与 share 同锁,仅单文件夹分享可开、换分享自动回只读)。POST multipart 写到当前浏览目录——落点过同一套防穿越、文件名只取末段清洗、重名 `-2`、临时文件原子换名、500MB 上限 413。注意 **Swifter 进 middleware 前已把 body 整段读进内存**,上限只能事后拒绝(分片上传留给将来,见 §5)。`onUpload` 回调在 socket 线程,GUI hop 回 MainActor 出「新收到」卡片。
+
+### 在线感知
+
+鉴权后按客户端 IP 记 `lastSeen`(45s 窗口即「N 人正在浏览」),best-effort 后台反查设备名(`getnameinfo`,缓存 `nameCache`、串行队列、随 token 轮换清零)。`/ls/ping` 为保留心跳路径(先于分享内容命中),listing 页 JS 每 15s 打一次、**只回人数不外泄设备名**;GUI 由 `AppState` 2s 轮询展示(具名领衔、查不到统一「N 人正在浏览」、不在摘要露 IP,点摘要弹列表)。
+
+### 仅当前网络可见
+
+`FileServer.listenAddress` 非 nil 时只绑该网卡 IPv4(Swifter 原生 `listenAddressIPv4`,无须 fork),由 `AppState.bindSelectedOnly` 驱动(切网卡/开关不轮换 token 地重绑、IP 消失回退全接口、`inet_pton` 非法即抛错不静默绑全接口);默认 nil = 绑 `0.0.0.0`。
+
+### AppState 与生命周期
+
+- 唯一真相源是 `sharedItems: [URL]`(0=空、1=单项、N=多选),派生 `isMultiple`/`isEmpty`/`sharedURL`。
+- **冷启动不恢复分享**:init 不把上次分享读回 `sharedItems`,落主页、不自动起文件服务——开 app 把某文件夹悄悄端上 LAN 是隐患,与文本「重启不自动重播」同姿态。上次分享留「最近分享」(`RecentShare`,`paths: [String]` 记多选)。收件箱是显式开关,仍自动起服务。
+- 只**关窗不退出**时进程与服务续活、`@StateObject` 整进程只建一次,唤回即回离开那屏(不过 init)。
+- 端口偏好 `[8080, 8000, 8888, 9000]` 逐个 try,全失败再随机高位口。
+- **分享变更不重启 server**(端口不变),加锁更新 `share` 与 `token`——**先换钥匙再换内容**,杜绝旧 token 瞬间可读新分享。
+
+### 屏幕路由 / 二级页
+
+`Screen` 枚举:`.share`(主页)/ `.file`(文件票据)/ `.text`(传递文本)/ `.settings` / `.history`。文件票据与传递文本同为带返回二级页,头部统一 `← + 标题 + ⚙`,品牌名只留主页。主页「正在分享」横幅(`ActiveShareBanner`):文件后台续跑时顶部出可点行,一键回票据。
+
+### App 入口 / Headless / CLI
+
+- `localshare a.html b.pdf` 默认把路径经 `NSWorkspace.open(urls, withApplicationAt:)` 转发给 GUI(运行中实例复用、热切换不重启 server);`--headless` 本进程前台起服务并打印终端二维码(`QRCode.ansi`)。
+- `localshare` 命令本体是设置面板安装的 **symlink**,指向包内主二进制——dyld 解析 `@executable_path` 前会 realpath,故包内 Sparkle 照常加载;但 CLI 进程内**不可用 `Bundle.main`** 定位 .app(自取 `_NSGetExecutablePath` → 解 symlink → 上溯三级拿 `.app`)。
+- ⚠️ **release 编译器坑**:`-O` 在「枚举载荷里的 `Optional` → 函数内构造 `[in_port_t]` → 传入 `FileServer.start`」这条链上会错编出垃圾数组指针(release 必崩、debug 正常的 heisenbug)。规避:`runForeground` 直接收具体 `preferredPorts: [in_port_t]`,Optional 展开留在调用方。**动 CLI/HeadlessServer 必须用 release 重跑 `--headless` 冒烟。**
+
+### 自动更新(Sparkle)
+
+- 经 SPM `binaryTarget` 引入;`build.sh` 用 `ditto` 把 `Sparkle.framework` 拷进 `Contents/Frameworks/`,executableTarget 加 `-rpath @executable_path/../Frameworks`。inside-out 深度 ad-hoc 签名后 `codesign --verify --deep --strict` 校验。
+- `Updater.swift` 的 `UpdaterController`(`@MainActor`)持 `SPUStandardUpdaterController`,仅 `LocalShareApp` 构造(headless 不碰)。配置全在 `Info.plist`(`SUFeedURL` / `SUPublicEDKey` / `SUEnableAutomaticChecks`);`SUPublicEDKey` 仍是占位值时不启动 updater。
+- **信任链走 EdDSA**(私钥签更新包、app 内嵌公钥校验),与代码签名/公证无关,故 ad-hoc + 未公证也能安全自更新;Sparkle 装的更新不带 quarantine,首装后续升级不再触发 Gatekeeper。
+- CI(`release.yml`):build → 依赖闸门 → 打 DMG → 建 Release → `sign_update` EdDSA 签 DMG → 生成 `appcast.xml` **作为 Release 资产上传**(不提交 git,发布对仓库零写入)。feed 即 `https://github.com/rrbe/LocalShare/releases/latest/download/appcast.xml`(GitHub 恒指向最新 release 的同名资产)。
+
+### 传递文本
+
+让「选内容→手机扫码」从磁盘文件扩到一段文本,**两条独立单向通道**(发出去的 `sharedText` 与收回来的收件箱互不相通):
+
+- **v1 Mac→手机(发文本)**:`AppState.sharedText: String?`,几乎全复用只读管线——文本只是又一种被 GET 的内容(保留命名空间 `/ls/text`,先于虚拟根 key 匹配)。可独立分享或挂进多选虚拟根;导航发 `TextViewer` 壳页、`?raw=1`/curl 发 `text/plain` 原文。手机页纯文本转义显示 + 大「复制」按钮(`execCommand` 回退——纯 http LAN 是非安全上下文、`navigator.clipboard` 不可用)+ http(s) 安全自动链接。
+- **v2 手机→Mac(收文本)**:独立收件箱通道,不落盘、不依赖文件夹分享。闸门 `textInboxEnabled`(opt-in 默认关,不限分享形态,开了就把服务拉起);`POST /ls/text` 收一段纯文本;列表页内嵌发送表单。**双上限挡内存**:单条 64KB + 收件箱 100 条挤旧(Swifter body 预读内存,必须双限)。`onReceiveText` socket 线程 hop 回 MainActor 入收件箱卡片,仅应用内提醒、不发系统通知。
+- **token 改回会话维度**:`setSharedText` **不轮换 token**(否则每次更新文本会把正在看的对端刷掉、还误伤共存的文件分享链接),只在 `setShared`/`stop`/`clearShare`/`stopTextTransfer` 这些会话边界轮换。两个持久化开关各默认关(发的是自己粘的、收的是他人投递的,隐私语义不同)。
+
+### 国际化(i18n)
+
+- 全部用户可见文案做成**编进二进制的 Swift 字符串表**(`Lang.swift`),不依赖资源 bundle,三条启动路径都无须定位文件,升级整文件替换。
+- **两个解析域彼此独立**:① 原生 app 语言来自设置(`AppState.langPref`:跟随系统 / 中文 / English,持久化);② 网页**逐请求**由浏览器 `Accept-Language` 决定(`Lang.fromAcceptLanguage`:按 `q` 值降序、`q=0` 跳过),**绝不读 app 设置**。
+- 文案分三类:静态走 `L`(`CaseIterable` 枚举键,`switch` 返回 `(zh, en)`,编译器强制穷尽);插值/复数/语序差异走 `LStr`;网页 JS 侧拼接走 `LStr.i18nJSON`(`jsEscape` 把 `<` 转 `<`,挡 `` 提前收尾)。加语言只需在 `L`/`LStr` 各补一支。
+
+### 容错 UI
+
+无 WiFi / 无私网 IP → 不画死码,提示「请先连接 WiFi」。首次 start 触发 macOS 防火墙提示 → 文案引导点**允许**(误点拒绝是「扫了码却打不开」头号原因)。二维码下常驻一行排错提示(同一 WiFi、未开访客/设备隔离)。空文件夹友好态。
+
+---
+
+## 4. 构建与运行
+
+```bash
+swift build -c release          # 编译(首次会拉 Swifter)
+swift test                      # XCTest 纯函数单测
+./build.sh                      # 组装并 ad-hoc 签名 → dist/LocalShare.app
+open dist/LocalShare.app        # 本机自测
+
+# 无头端到端(无 GUI,供脚本/curl 验服务端逻辑)
+LS_HEADLESS=1 LS_FOLDER=/path/to/dir LS_TOKEN=testtoken LS_PORT=8099 .build/debug/LocalShare &
+curl -s "http://127.0.0.1:8099/?t=testtoken"   # 应返回目录列表或 index.html
+```
+
+无头测多选用 `LS_FOLDERS`(`:`/换行分隔),开上传加 `LS_UPLOAD=1`,指定绑定网卡加 `LS_BIND=`,传递文本用 `LS_TEXT` / `LS_RECV`。两层测试(`swift test` + `tools/smoke-*.sh`)都由 `.github/workflows/ci.yml` 在每个 PR / master push 自动跑。
+
+**发给同事**:把 `dist/*.app` 拷过去;**首次由你帮他打开一次**(双击被 Gatekeeper 拦 → 系统设置 → 隐私与安全性 → 点「仍要打开」,仅此一次)。
+
+---
+
+## 5. 非目标与未做
+
+**明确不做:**
+
+- Apple 公证(要 $99/年开发者号);https + 自签证书(会把扫码即用变成证书警告页,与核心体验冲突);跨网络隧道(cloudflared/ngrok/tailscale)。
+- **在线编辑 / 删除**(访客在浏览器里改/删文件):决定不做——手机浏览器编辑体验差、覆盖丢数据与误删风险高,价值不抵风险。访客上传已覆盖「手机→Mac」的主诉求。`Permission.edit` / `Permission.del` 恒 `false`。
+
+**未做(可能将来):**
+
+- **上传分片**:绕开 Swifter「整段 body 进内存」的限制,前端切片逐片 POST、服务端按序 append + 末片原子换名,任意大小、内存恒定。落地后可放开 500MB 上限。
+- **更准的设备名反查**:现为 best-effort `getnameinfo`,iPhone 多查不到、回退 IP;要更准需走 `DNSServiceQueryRecord` 的 mDNS PTR 查询,成本高且仍不保证命中。
diff --git a/docs/images/screenshot-main-page.png b/docs/images/screenshot-main-page.png
new file mode 100644
index 0000000..7d7f391
Binary files /dev/null and b/docs/images/screenshot-main-page.png differ
diff --git a/docs/images/screenshot-share-file.png b/docs/images/screenshot-share-file.png
new file mode 100644
index 0000000..0d7350f
Binary files /dev/null and b/docs/images/screenshot-share-file.png differ
diff --git a/docs/images/screenshot-share-text.png b/docs/images/screenshot-share-text.png
new file mode 100644
index 0000000..ae588e9
Binary files /dev/null and b/docs/images/screenshot-share-text.png differ
diff --git a/screenshot.png b/screenshot.png
deleted file mode 100644
index 88410dc..0000000
Binary files a/screenshot.png and /dev/null differ