From 536ba9d10284baf7b49070716c0c77f859964d95 Mon Sep 17 00:00:00 2001 From: shawn Date: Tue, 30 Jun 2026 20:19:13 +0800 Subject: [PATCH 1/6] docs: publish english changelog --- CHANGELOG.md | 76 ++++++++++++++++++++++++------------------------- CHANGELOG_CN.md | 74 +++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 112 insertions(+), 38 deletions(-) create mode 100644 CHANGELOG_CN.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 7574bda..591ccdc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,74 +1,74 @@ -# 更新日志 +# Changelog ## [0.9.1] - 2026-06-28 -- 设置页改用分组卡片布局,分组层级更清晰、信息更好找 -- 「恢复默认窗口尺寸」补充了说明文本 +- Reworked Settings into grouped cards so sections are clearer and easier to scan +- Added explanatory copy for restoring the default window size ## [0.9.0] - 2026-06-27 -- 新增「传递文本」:Mac 与手机之间双向互传文字,扫码即收发,长文本和链接不必再靠聊天工具中转 -- 界面支持中英双语:原生窗口可跟随系统或手动切换,手机网页自动匹配浏览器语言 -- 分享文件改为带返回的二级页,文件在后台续传时主页一键回到二维码 -- 冷启动不再自动重播上次的分享,改由「最近分享」手动重发,更稳妥 +- Added Text Transfer for sending text both ways between Mac and phone; scan once to send or receive long text and links without using a chat app as a relay +- Added Chinese and English UI support: the native app can follow the system language or be switched manually, while the browser UI follows the visitor's browser language +- Moved shared files to a secondary page with back navigation, so the home screen can return to the QR code while transfers continue in the background +- Stopped replaying the last share on cold launch; recent shares can now be restarted manually for a safer default ## [0.8.2] - 2026-06-18 -- 新增「自动更新」开关:可关闭后台自动检查,菜单「检查更新…」仍能手动查 -- 各主界面标题栏新增设置入口(齿轮按钮),不用先分享文件夹也能打开设置 -- 设置项重新分组(网络 / 访问权限 / 外观 / 主界面 / 命令行),更易查找 -- 加宽默认窗口,避免地址栏端口号被截断 -- 修复进入设置页时的几处界面闪烁 +- Added an automatic update setting: background checks can be disabled while the Check for Updates menu item remains available +- Added a Settings entry point to each main title bar, so Settings can be opened without first sharing a folder +- Regrouped Settings into Network, Access, Appearance, Home, and Command Line sections +- Increased the default window width to avoid truncating port numbers in the address field +- Fixed several visual flickers when entering Settings ## [0.8.1] - 2026-06-17 -- 修复卡片边缘豁口处在投影下显出的一块亮斑 -- 统一卡片、描边按钮与设置分段控件的边框粗细,静止态更协调 +- Fixed a bright artifact that appeared around the notched card edge under shadow +- Unified border weights across cards, outline buttons, and Settings segmented controls for a calmer resting state ## [0.8.0] - 2026-06-17 -- 分享链接默认只在当前 WiFi 可见,换网或断网后自动失效,别的网络的人摸不到 -- 能看到谁正在浏览:显示在线人数和设备名,点开还能看每个人的地址和已经看了多久 -- 网页上新增提示,说明局域网传输走的是明文 -- 一批网页安全加固:堵住 Markdown 预览里的 XSS、清洗访问令牌、访客上传的网页/图片文件自动去掉可执行性并加隔离标记 +- Share links are now visible only on the current Wi-Fi by default; they expire automatically after switching networks or disconnecting +- Added live visitor visibility with online counts and device names; details show each visitor's address and session duration +- Added a browser notice that LAN transfers are sent over plain HTTP +- Hardened browser security: blocked XSS vectors in Markdown preview, cleaned access tokens from browser history, defanged executable uploaded documents and images, and added quarantine metadata ## [0.7.0] - 2026-06-12 -- 自动更新换了新源:升级公告(appcast)改为随 GitHub Release 发布,app 从固定地址获取,从本版起生效 -- 发布说明随版本标签维护,Release 页自动展示更新内容 +- Moved automatic update feeds to GitHub Releases; appcast files are now published with each release and fetched from a stable latest-release URL +- Release notes are now maintained through version tag annotations and displayed automatically on the Release page ## [0.6.0] - 2026-06-12 -- 访客上传:单文件夹分享可开启「允许上传」,访客在浏览页直接把文件传到当前目录;默认仍只读、更换分享自动回到只读,单文件上限 500MB -- 浏览器预览 Markdown / JSON / CSV:点开即渲染(Markdown 排版、JSON 折叠树、CSV 表格),文内相对引用照常可用;加 `?raw=1` 或用 curl 仍取原文 -- 每次分享都生成新链接:停止或更换分享后旧链接立即失效,窗口里显示的地址与复制到的链接一致 -- 分享窗口显示「N 人正在浏览」 -- 列表页支持返回上一级,文件改为在新标签打开 -- 多选分享时,卡片中的每一项都可在 Finder 中定位 +- Added guest uploads for single-folder shares: visitors can upload files into the current folder from the browser; shares remain read-only by default, switch back to read-only after changing shares, and enforce a 500 MB per-file limit +- Added browser previews for Markdown, JSON, and CSV: files render in place, relative references keep working, and `?raw=1` or curl still returns the original text +- Generated a fresh link for every share: old links stop working immediately after stopping or changing a share, and the window URL matches copied links +- Added the "N people browsing" indicator to the share window +- Added parent-directory navigation to listing pages and opened files in new tabs +- Made every selected item in a multi-share card revealable in Finder ## [0.5.0] - 2026-06-10 -- 新增 `localshare` 命令行:终端里一条命令即可分享路径,默认转发给 GUI,`--headless` 则前台起服务并打印终端二维码 -- 设置面板可一键安装 / 卸载命令行工具 -- 菜单栏常驻图标(显示 LocalShare / 退出) -- 设置支持隐藏「最近分享」模块 +- Added the `localshare` command: share paths from Terminal, forward to the GUI by default, or run a foreground server with `--headless` +- Added one-click install and uninstall controls for the command-line tool in Settings +- Added a persistent menu bar icon with LocalShare and Quit actions +- Added a setting to hide the Recent Shares section ## [0.4.0] - 2026-06-10 -- 支持一次分享多个文件 / 目录 +- Added support for sharing multiple files and folders at once ## [0.3.0] - 2026-06-09 -- 接入 Sparkle 自动更新,这是首个支持自动更新的版本 +- Added Sparkle automatic updates; this is the first version that can update itself ## [0.2.0] - 2026-06-08 -- 支持拖拽文件 / 文件夹分享 -- 分享卡按类型细分并标注格式,路径可在 Finder 定位或拷贝 +- Added drag-and-drop sharing for files and folders +- Split share cards by file type and added format labels, Finder reveal, and path copy actions ## [0.1.2] - 2026-06-05 -- 局域网文件分享:选一个文件夹就生成二维码,同 WiFi 的手机扫码即可在浏览器里浏览 -- 支持单文件分享,网页端按类型过滤 -- 「暖纸 × 广播」编辑风界面,配套网页目录页与深色模式 -- 修复干净启动时不出窗口的问题 +- Added LAN file sharing: choose a folder, get a QR code, and let phones on the same Wi-Fi browse it in a browser +- Added single-file sharing with browser-side type filtering +- Added the warm-paper broadcast-style interface, matching browser listing pages, and dark mode +- Fixed the window not appearing after a clean launch diff --git a/CHANGELOG_CN.md b/CHANGELOG_CN.md new file mode 100644 index 0000000..7574bda --- /dev/null +++ b/CHANGELOG_CN.md @@ -0,0 +1,74 @@ +# 更新日志 + +## [0.9.1] - 2026-06-28 + +- 设置页改用分组卡片布局,分组层级更清晰、信息更好找 +- 「恢复默认窗口尺寸」补充了说明文本 + +## [0.9.0] - 2026-06-27 + +- 新增「传递文本」:Mac 与手机之间双向互传文字,扫码即收发,长文本和链接不必再靠聊天工具中转 +- 界面支持中英双语:原生窗口可跟随系统或手动切换,手机网页自动匹配浏览器语言 +- 分享文件改为带返回的二级页,文件在后台续传时主页一键回到二维码 +- 冷启动不再自动重播上次的分享,改由「最近分享」手动重发,更稳妥 + +## [0.8.2] - 2026-06-18 + +- 新增「自动更新」开关:可关闭后台自动检查,菜单「检查更新…」仍能手动查 +- 各主界面标题栏新增设置入口(齿轮按钮),不用先分享文件夹也能打开设置 +- 设置项重新分组(网络 / 访问权限 / 外观 / 主界面 / 命令行),更易查找 +- 加宽默认窗口,避免地址栏端口号被截断 +- 修复进入设置页时的几处界面闪烁 + +## [0.8.1] - 2026-06-17 + +- 修复卡片边缘豁口处在投影下显出的一块亮斑 +- 统一卡片、描边按钮与设置分段控件的边框粗细,静止态更协调 + +## [0.8.0] - 2026-06-17 + +- 分享链接默认只在当前 WiFi 可见,换网或断网后自动失效,别的网络的人摸不到 +- 能看到谁正在浏览:显示在线人数和设备名,点开还能看每个人的地址和已经看了多久 +- 网页上新增提示,说明局域网传输走的是明文 +- 一批网页安全加固:堵住 Markdown 预览里的 XSS、清洗访问令牌、访客上传的网页/图片文件自动去掉可执行性并加隔离标记 + +## [0.7.0] - 2026-06-12 + +- 自动更新换了新源:升级公告(appcast)改为随 GitHub Release 发布,app 从固定地址获取,从本版起生效 +- 发布说明随版本标签维护,Release 页自动展示更新内容 + +## [0.6.0] - 2026-06-12 + +- 访客上传:单文件夹分享可开启「允许上传」,访客在浏览页直接把文件传到当前目录;默认仍只读、更换分享自动回到只读,单文件上限 500MB +- 浏览器预览 Markdown / JSON / CSV:点开即渲染(Markdown 排版、JSON 折叠树、CSV 表格),文内相对引用照常可用;加 `?raw=1` 或用 curl 仍取原文 +- 每次分享都生成新链接:停止或更换分享后旧链接立即失效,窗口里显示的地址与复制到的链接一致 +- 分享窗口显示「N 人正在浏览」 +- 列表页支持返回上一级,文件改为在新标签打开 +- 多选分享时,卡片中的每一项都可在 Finder 中定位 + +## [0.5.0] - 2026-06-10 + +- 新增 `localshare` 命令行:终端里一条命令即可分享路径,默认转发给 GUI,`--headless` 则前台起服务并打印终端二维码 +- 设置面板可一键安装 / 卸载命令行工具 +- 菜单栏常驻图标(显示 LocalShare / 退出) +- 设置支持隐藏「最近分享」模块 + +## [0.4.0] - 2026-06-10 + +- 支持一次分享多个文件 / 目录 + +## [0.3.0] - 2026-06-09 + +- 接入 Sparkle 自动更新,这是首个支持自动更新的版本 + +## [0.2.0] - 2026-06-08 + +- 支持拖拽文件 / 文件夹分享 +- 分享卡按类型细分并标注格式,路径可在 Finder 定位或拷贝 + +## [0.1.2] - 2026-06-05 + +- 局域网文件分享:选一个文件夹就生成二维码,同 WiFi 的手机扫码即可在浏览器里浏览 +- 支持单文件分享,网页端按类型过滤 +- 「暖纸 × 广播」编辑风界面,配套网页目录页与深色模式 +- 修复干净启动时不出窗口的问题 From 523b4013c4ade17cf68d728cec16c444a8efe1e8 Mon Sep 17 00:00:00 2001 From: shawn Date: Tue, 30 Jun 2026 20:19:17 +0800 Subject: [PATCH 2/6] docs: translate design guide --- DESIGN.md | 324 +++++++++++++++++++++++++++--------------------------- 1 file changed, 163 insertions(+), 161 deletions(-) diff --git a/DESIGN.md b/DESIGN.md index 9b73f5c..99ac725 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -1,254 +1,256 @@ -# LocalShare 设计规范(DESIGN.md) +# LocalShare Design Specification -> 局域网文件 / 文件夹分享工具。桌面 App 负责「发起分享」,访客通过浏览器(电脑 / 手机)扫码或输入地址访问。 -> 本文件用于实现交接:所有数值、配色、组件行为均来自现有设计稿,请勿凭感觉改动。 +> LAN file and folder sharing tool. The desktop app starts a share; guests scan a QR code or enter an address in a desktop or mobile browser. +> This file is an implementation handoff document. All values, colors, and component behavior come from the current design. Do not change them by feel. --- -## 0. 设计 DNA(一句话) +## 0. Design DNA -暖奶油色(warm cream)底 + 砖红强调色,衬线标题 + 等宽体承载技术信息(IP / 端口 / 路径 / 体积 / 日期)。 -克制、安静、像一张实体票据;圆角中等、阴影轻、留白足。**不要**渐变背景、不要 emoji、不要彩色光晕。 +Warm cream background + brick-red accent, serif titles, and monospace technical values such as IP addresses, ports, paths, sizes, and dates. +Restrained, quiet, and ticket-like. Use medium radii, light shadows, and generous spacing. **Do not** use gradient backgrounds, emoji, or colorful glow effects. --- -## 1. 颜色 Tokens +## 1. Color Tokens -所有颜色集中由 `getTokens(t)` 生成,**实现时必须做成主题变量**(支持浅 / 深色)。强调色可被用户切换。 +All colors are generated by `getTokens(t)` and **must be implemented as theme variables** with light and dark mode support. The accent color can be user-selected. -### 1.1 强调色(accent,可切换) +### 1.1 Accent -默认 `#df4f28`(砖红橙)。可选项:`#df4f28` / `#2a6fdb` / `#1f8a5b` / `#7a5ae0`。 +Default: `#df4f28` (brick red-orange). Available options: `#df4f28` / `#2a6fdb` / `#1f8a5b` / `#7a5ae0`. -- `accentSoft`:浅色 `rgba(223,79,40,0.12)`,深色 `rgba(223,79,40,0.18)`(注意:soft 始终基于默认砖红,切换强调色时 soft 也应跟随同一 hue 的 12% / 18%)。 +- `accentSoft`: light mode `rgba(223,79,40,0.12)`, dark mode `rgba(223,79,40,0.18)`. Note: `accentSoft` should follow the selected accent hue at 12% / 18% opacity. -### 1.2 中性与语义色 +### 1.2 Neutral and Semantic Colors -| Token | 浅色 | 深色 | 用途 | -| ------------ | --------- | --------- | ------------------------------------ | -| `bg` | `#efeae1` | `#1b1814` | 页面 / 窗口底色 | -| `surface` | `#fdfbf7` | `#262219` | 卡片 / 输入框 / 票据正面 | -| `surfaceAlt` | `#f5f0e7` | `#211e16` | hover 态、次级填充 | -| `field` | `#efe9de` | `#1d1a14` | 地址栏 / 端口输入框内底 | -| `ink` | `#2a261d` | `#f2eee5` | 主文字 | -| `inkMute` | `#8c8475` | `#a59d8c` | 次级文字 / 说明 | -| `inkFaint` | `#b4ab99` | `#6d675a` | 最弱文字 / 占位 / 日期 | -| `line` | `#e7dfd1` | `#37322a` | 默认描边 / 分隔线 | -| `lineStrong` | `#dacfbd` | `#494238` | 强描边 / 虚线 / focus | -| `ok` | `#2f9e57` | `#2f9e57` | 成功(端口可用、HTTPS 锁、复制成功) | -| `danger` | `#c43c1c` | `#ef8a6e` | 危险 / 停止 / 端口无效 | -| `warn` | `#b67708` | `#e0a83a` | 警告 / 端口被占用 | +| Token | Light | Dark | Purpose | +| ------------ | --------- | --------- | ---------------------------------------- | +| `bg` | `#efeae1` | `#1b1814` | Page / window background | +| `surface` | `#fdfbf7` | `#262219` | Cards / inputs / ticket front | +| `surfaceAlt` | `#f5f0e7` | `#211e16` | Hover state and secondary fills | +| `field` | `#efe9de` | `#1d1a14` | Address bar / port input background | +| `ink` | `#2a261d` | `#f2eee5` | Primary text | +| `inkMute` | `#8c8475` | `#a59d8c` | Secondary text and helper copy | +| `inkFaint` | `#b4ab99` | `#6d675a` | Weakest text / placeholders / dates | +| `line` | `#e7dfd1` | `#37322a` | Default borders and dividers | +| `lineStrong` | `#dacfbd` | `#494238` | Strong borders / dashed lines / focus | +| `ok` | `#2f9e57` | `#2f9e57` | Success: port available, copy succeeded | +| `danger` | `#c43c1c` | `#ef8a6e` | Danger / stop / invalid port | +| `warn` | `#b67708` | `#e0a83a` | Warning / occupied port | -> 画布(design canvas)的中性底用 `#f0eee9`(与 `bg` 极近,可视为同一暖白)。 +The design canvas neutral background is `#f0eee9`, very close to `bg`. --- -## 2. 字体 Typography +## 2. Typography -三套字族,**用途严格区分**: +Use three font families with strict roles: -- `display`(标题):`"Source Serif 4", Georgia, serif`,weight 600。用户可切到 sans(`titleSerif=false` 时 display 回退为 sans)。 -- `sans`(正文 / 控件):`-apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, "PingFang SC", sans-serif`。 -- `mono`(技术信息):`"JetBrains Mono", ui-monospace, SFMono-Regular, Menlo, monospace`。**凡是 IP、端口、URL、路径、文件体积、项数、日期一律用 mono。** +- `display` for titles: `"Source Serif 4", Georgia, serif`, weight 600. Users can switch titles to sans; when `titleSerif=false`, `display` falls back to sans. +- `sans` for body and controls: `-apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, "PingFang SC", sans-serif`. +- `mono` for technical values: `"JetBrains Mono", ui-monospace, SFMono-Regular, Menlo, monospace`. **All IP addresses, ports, URLs, paths, file sizes, item counts, and dates use mono.** -### 字号 / 字重参考(px) +### Size / Weight Reference -| 角色 | 字号 | 字重 | 字族 | 备注 | -| ------------------------------- | ------------- | ------- | ------------ | ------------------------------------------------- | -| 品牌字标 LocalShare | 22–28 | 600 | display | 空状态 28,紧凑头部 22,letterSpacing ≈ -0.2~-0.3 | -| 客户端页面大标题(Downloads) | 44 / 30(移动) | 600 | display | letterSpacing -0.6 | -| 小节眼标(局域网·只读分享 等) | 11–12 | 700 | sans | letterSpacing 0.6–1.2,accent 色 | -| 卡片标题 / 设置标题 | 16–21 | 600–700 | display/sans | | -| 正文 / 列表项名 | 13–16 | 500–600 | sans | | -| 说明 / 次级 | 11.5–12.5 | 400–600 | sans | inkMute | -| 技术信息(地址/端口/体积/日期) | 11–14 | 500–700 | mono | | +| Role | Size | Weight | Family | Notes | +| --------------------------------- | ------------- | ------- | ------------ | -------------------------------------------------- | +| LocalShare wordmark | 22-28 | 600 | display | Empty state 28, compact header 22 | +| Client page title, e.g. Downloads | 44 / 30 mobile | 600 | display | Slightly tight display letter spacing | +| Section eyebrow | 11-12 | 700 | sans | Accent color, modest letter spacing | +| Card / Settings title | 16-21 | 600-700 | display/sans | | +| Body / list item name | 13-16 | 500-600 | sans | | +| Helper / secondary text | 11.5-12.5 | 400-600 | sans | `inkMute` | +| Technical values | 11-14 | 500-700 | mono | Address, port, size, date | --- -## 3. 间距 / 圆角 / 阴影 / 描边 +## 3. Spacing, Radius, Shadow, Border -- **圆角**:按钮 10–12;卡片 / 票据 14–18;输入 8–12;胶囊 / 状态点 `999`;文件夹·文件图标方块 = `size × 0.26~0.28`。 -- **描边**:默认 `1px solid line`;强调 / 虚线用 `lineStrong`;票据撕裂线 `1.5px dashed lineStrong`。 -- **窗口内边距**:桌面 App 卡片 `LSWin` 默认 20–24。 -- **阴影**(直接照抄): - - 桌面卡片: `0 1px 2px rgba(0,0,0,.10), 0 18px 50px rgba(40,30,15,.16)` - - 浏览器窗口: `0 1px 2px rgba(0,0,0,.10), 0 20px 60px rgba(40,30,15,.18)` - - 票据(boarding pass): `0 10px 30px rgba(40,30,15,0.10)` - - QR 卡: `0 8px 24px rgba(40,30,15,0.08)` - - 下拉 / 弹层: `0 12px 34px rgba(40,30,15,.20)` - - 主按钮: `0 1px 2px rgba(0,0,0,.12)`;停止按钮静态 `0 1px 2px rgba(0,0,0,.18)`,hover `0 4px 14px 59` +- **Radius**: buttons 10-12; cards / tickets 14-18; inputs 8-12; pills / status dots `999`; file and folder glyph squares use `size * 0.26~0.28`. +- **Border**: default `1px solid line`; emphasis and dashed rules use `lineStrong`; ticket tear line uses `1.5px dashed lineStrong`. +- **Window padding**: desktop app card `LSWin` defaults to 20-24. +- **Shadows**: + - Desktop card: `0 1px 2px rgba(0,0,0,.10), 0 18px 50px rgba(40,30,15,.16)` + - Browser window: `0 1px 2px rgba(0,0,0,.10), 0 20px 60px rgba(40,30,15,.18)` + - Ticket / boarding pass: `0 10px 30px rgba(40,30,15,0.10)` + - QR card: `0 8px 24px rgba(40,30,15,0.08)` + - Dropdown / popover: `0 12px 34px rgba(40,30,15,.20)` + - Primary button: `0 1px 2px rgba(0,0,0,.12)`; stop button resting shadow `0 1px 2px rgba(0,0,0,.18)`, hover `0 4px 14px 59` --- -## 4. 图标 Iconography +## 4. Iconography -自绘 16×16 线性图标,`stroke=currentColor`,`strokeWidth 1.6–1.8`,圆角线帽。**禁止用 emoji、禁止彩色填充图标。** -已有图标名:`copy / open / globe / wifi / file / swap / stop / play / chevron / qr / help / check / phone / search / sort / x`。 -新增图标请保持同一视觉重量与 16 网格。 +Use custom 16x16 line icons with `stroke=currentColor`, `strokeWidth` 1.6-1.8, and rounded caps. **No emoji and no colorful filled icons.** +Existing icon names: `copy / open / globe / wifi / file / swap / stop / play / chevron / qr / help / check / phone / search / sort / x`. +New icons must keep the same visual weight and 16px grid. --- -## 5. 组件库 Components +## 5. Components -### 5.1 按钮(三种,语义不可混用) +### 5.1 Buttons -| 组件 | 形态 | 高 | 用途 | -| -------------- | -------------------------------------------------------- | -------------- | ---------------------------------------------- | -| **PrimaryBtn** | 实心 accent,白字,weight 650 | 44,常 100% 宽 | 正向主操作:选择文件或文件夹、重新广播 | -| **GhostBtn** | 透明底 + `line` 描边,hover 变 `surfaceAlt`/`lineStrong` | 34 | 次级:更换、重新分享、查看全部 | -| **DangerBtn** | 实心 `danger` 红,白字,weight 700 | 34 | 破坏性:**停止分享**(必须醒目,不可用 ghost) | +| Component | Form | Height | Purpose | +| -------------- | -------------------------------------------------------- | -------------- | -------------------------------------------- | +| **PrimaryBtn** | Solid accent, white text, weight 650 | 44, usually full width | Primary positive action: choose files/folders, broadcast again | +| **GhostBtn** | Transparent + `line` border; hover uses `surfaceAlt` / `lineStrong` | 34 | Secondary actions: change, reshare, view all | +| **DangerBtn** | Solid `danger`, white text, weight 700 | 34 | Destructive action: **stop sharing**. Must be visually prominent; do not render as ghost | -- IconBtn:34×34 方形,圆角 10,承载齿轮 / 帮助等单图标入口。 -- 按钮内图标尺寸 14–15,与文字 `gap 6–7`。 +- IconBtn: 34x34 square, radius 10, for gear/help icon-only entries. +- Button icons: 14-15px with `gap 6-7` from text. -### 5.2 状态胶囊 +### 5.2 Status Pills -- **StatusPill**:圆角 999,`surface` 底 + `line` 描边,含状态点(Dot)+ 文案 + 竖分隔 + `:端口`(mono)。 - - 状态:`broadcasting`→「广播中」accent + 脉冲点;`connected`→在线访客行显示设备名(单台「<名> 正在浏览」、多台「<名> 等 N 人正在浏览」、查不到回退「…IP 尾号」、超长末端截断);`stopped`→「已停止」inkMute、无脉冲。 -- **Dot**:状态圆点,`live` 时叠加 `lsPulse` 扩散动画(1.8s ease-out 无限)。 -- **Chip**:圆角 999 标签,accent 态用 `accentSoft` 底 + accent 字,普通态透明底 + `line` 描边。 +- **StatusPill**: radius 999, `surface` background + `line` border, containing a status dot, label, vertical divider, and `:port` in mono. + - States: `broadcasting` uses accent + pulsing dot; `connected` shows the visitor line with device name; `stopped` uses `inkMute` without pulse. + - Visitor summaries: one visitor ` is browsing`; multiple visitors ` and N others are browsing`; unknown names fall back to the IP suffix. Long text truncates at the end. +- **Dot**: status dot. `live` adds `lsPulse` expansion animation, `1.8s ease-out infinite`. +- **Chip**: radius 999 label. Accent state uses `accentSoft` background + accent text; normal state uses transparent background + `line` border. -### 5.3 输入 / 开关 +### 5.3 Inputs and Switches -- **端口输入**:`field` 底,mono,宽约 72;左侧 mono 前缀 `192.168.31.223 :`。 -- **搜索输入**:`surface` 底,左 `search` 图标(inkMute),右侧有内容时显示圆形 `x` 清除键。 -- **Switch**:40×24,圆角 999,开 = accent 底、把手靠右;`locked` 时降透明度 0.55 且不可点(用于「读取与下载·始终开启」)。 +- **Port input**: `field` background, mono, about 72px wide; mono prefix at left, e.g. `192.168.31.223 :`. +- **Search input**: `surface` background, search icon at left in `inkMute`, circular `x` clear button when non-empty. +- **Switch**: 40x24, radius 999. On = accent background with handle on the right. `locked` lowers opacity to 0.55 and disables interaction, used for "Read and download, always on". -### 5.4 卡片 / 容器 +### 5.4 Cards and Containers -- **LSWin**:无边框 mac 工具窗,顶部 40px 高、左上三个交通灯点(`#ff5f57/#febc2e/#28c840`),下方为内容区(flex 列)。桌面 App 各屏都包在 LSWin 内。 -- **票据卡(VariantB)**:`surface` 圆角 18 卡片,上半「存根」(文件信息),中部撕裂线 = 两侧半圆缺口(notch,用 `bg` 填充制造镂空) + 虚线,下半 QR + 地址。 -- **Bracket**:列表四角的 L 形角标(`lineStrong`,2px),用于客户端列表的「取景框」装饰。 +- **LSWin**: borderless Mac utility window, 40px title bar with three traffic-light dots (`#ff5f57/#febc2e/#28c840`) and a flex-column content area. Every desktop app screen is wrapped in `LSWin`. +- **Ticket card / VariantB**: `surface` card with radius 18. Top stub contains file metadata, middle tear rule uses side notches filled with `bg` plus a dashed line, bottom contains QR and address. +- **Bracket**: L-shaped corner marks for client lists (`lineStrong`, 2px), used as a viewfinder decoration. -### 5.5 文件 / 文件夹标识 +### 5.5 File and Folder Glyphs -- **FolderGlyph**:圆角方块,`accentSoft` 底 + accent 文件夹图形。 -- **FileGlyph / RowIcon**:圆角方块,按类型着色(见 TYPE_TINT),中间显示扩展名(mono) 或 globe 图标 + 扩展名。 +- **FolderGlyph**: rounded square, `accentSoft` background + accent folder shape. +- **FileGlyph / RowIcon**: rounded square, color-coded by file type. Center shows extension in mono or a globe icon plus extension. -`TYPE_TINT`(类型底色 / 字色):网页 `#c4451f`、表格 `#1f8a5b`、图片 `#2a6fdb`、PDF `#cf4444`、Markdown `#7a5ae0`、文档 `#a9772a`(底为对应色 12–13% 透明)。深色模式下底统一用 `rgba(255,255,255,0.06)`。 +`TYPE_TINT` values: web `#c4451f`, spreadsheet `#1f8a5b`, image `#2a6fdb`, PDF `#cf4444`, Markdown `#7a5ae0`, document `#a9772a`. Background is the same color at 12-13% opacity. In dark mode, all type backgrounds use `rgba(255,255,255,0.06)`. -### 5.6 QR 与地址 +### 5.6 QR and Address -- **QR**:装饰性占位(确定性伪二维码),白底圆角,前景浅色 `#1a1813`(深色模式 `#0e0c09`)。**实现时替换为真实二维码**,但保留尺寸 / 圆角 / 白底 padding。 -- **CopyPill**:`field` 底圆角条,左 mono 地址(超长省略),右复制键(成功显示绿色 check 1.3s) + 在浏览器打开键。 +- **QR**: decorative placeholder in mocks, deterministic pseudo QR, white background, rounded corners, foreground `#1a1813` (dark mode `#0e0c09`). **Replace with a real QR code in implementation**, but keep size, radius, white background, and padding. +- **CopyPill**: `field` rounded bar. Left side is mono address with ellipsis; right side has copy button with green check for 1.3s after success and an open-in-browser button. --- -## 6. 关键业务行为(务必实现,不可简化) +## 6. Required Product Behavior -### 6.1 演示状态 demoState +### 6.1 Demo State -`broadcasting` / `connected` / `stopped` 三态,影响 StatusPill、QR 是否灰显、底部操作按钮(停止 ↔ 重新广播)。 +`demoState` has `broadcasting` / `connected` / `stopped`. It controls StatusPill, whether QR appears dimmed, and the bottom action button state: stop versus broadcast again. -### 6.2 权限模型 + 「只读 / 可读写」联动 ★ +### 6.2 Permission Model and Read-Only / Writable Linkage -权限对象 `{ read(锁定常开), add, edit, del }`。由 `permSummary(perm)` 统一派生,**所有展示分享态的界面都读它,不允许各自硬编码**: +Permission object: `{ read(always locked on), add, edit, del }`. Use `permSummary(perm)` as the single derived source. **Every sharing UI reads from it; do not hard-code labels in individual screens.** -- 任一写权限(add/edit/del)开启 → `writable=true`。 -- `tag`:只读 → `只读`;可写 → `可读写`。 -- `eyebrow`:`局域网 · 只读分享` ↔ `局域网 · 可读写分享`。 -- `chips`:只读 = `[只读, 可下载]`;可写 = `[可下载, 可上传?, 可编辑?, 可删除?]`(按开启项)。 +- If any write permission is enabled (`add/edit/del`), `writable=true`. +- `tag`: read-only -> `Read-only`; writable -> `Read/write`. +- `eyebrow`: `LAN · Read-only share` versus `LAN · Read/write share`. +- `chips`: read-only = `[Read-only, Downloadable]`; writable = `[Downloadable, Uploadable?, Editable?, Deletable?]` depending on enabled permissions. -联动点(同一 perm 状态驱动):空状态眼标、文件夹分享卡眼标 + 权限 chips、单文件票据「正在分享·只读/可读写」、设置页「当前:只读/可读写」标签 + 底部警告文案、备选 VariantA/C 的眼标。**在设置页开关写权限,应实时改变其它界面顶部措辞。** +Linked surfaces driven by the same permission state: empty-state eyebrow, folder-share card eyebrow and permission chips, single-file ticket "Sharing · read-only/read-write", Settings "Current: read-only/read-write" tag, bottom warning copy, and alternate VariantA/C eyebrows. Changing write permissions in Settings must update the wording at the top of other screens immediately. -### 6.3 端口实时校验 ★ +### 6.3 Live Port Validation -输入即校验(`validatePort`): +Validate as the user types via `validatePort`: -- 空 → `请输入端口号`(error)。 -- `<1024` → `需 ≥ 1024 · 1023 以下为系统保留端口`(error)。 -- `>65535` → `超出范围 · 端口最大为 65535`(error)。 -- 命中被占用集合(示例:`80,443,3000,5000,5432,3306,8000,7890`)→ `端口 X 已被占用`(warn)并给出**「改用 :下一个可用端口」**按钮,点击自动填入。 -- 其余 → `端口可用`(ok)。 +- Empty -> `Enter a port number` (error). +- `<1024` -> `Must be >= 1024 · lower ports are reserved by the system` (error). +- `>65535` -> `Out of range · maximum port is 65535` (error). +- Occupied sample set (`80,443,3000,5000,5432,3306,8000,7890`) -> `Port X is already in use` (warn) and show a **Use :nextAvailablePort** button that fills the next available port. +- Otherwise -> `Port available` (ok). -视觉:输入框 / 容器描边随状态变色(ok 用默认 line、其余用语义色);右侧徽标 `可用/被占用/无效` + 图标(check/help);下方一行说明随状态变色,ok 时显示「端口可用 · 修改后会重启服务,已分发的链接需更新。」 +Visuals: input/container border follows status color (ok keeps default line; warn/error use semantic colors). Right-side badge shows `Available / In use / Invalid` with check/help icon. Helper line follows status color; ok copy says changing the port restarts the service and shared links need updating. -### 6.4 客户端浏览页:筛选 / 搜索 / 排序 ★ +### 6.4 Client Folder Page: Filter / Search / Sort -工具栏位于标题下、筛选 chips 上:左「搜索此文件夹…」(按文件名实时过滤),右「排序」下拉。 +Toolbar sits below the title and above filter chips: search input on the left, sort dropdown on the right. -- **类型筛选**(chips):`全部 / 目录 / 网页 / PDF / 图片 / Markdown / 表格 / 文档`,**真实过滤**(目录=文件夹;其余按 `item.type`)。 -- **排序**下拉 5 项:`默认顺序 / 名称 A→Z / 名称 Z→A / 时间 新→旧 / 时间 旧→新`,选中项打勾。**文件夹始终分组排在文件之前**,组内再按所选键排序。 -- 计数:过滤 / 搜索时标题下显示 `N / 304 项`,否则 `304 项`。 -- 每行右侧显示创建日期(mono),按时间排序时该列加重。 -- 无结果显示空态:`未找到匹配的文件` + 提示。 -- 下拉打开时用一层透明 fixed 遮罩点击关闭。 -- **返回上一级**:非根目录列表首行固定一条「返回上一级」行(虚线描边图标 + 弱化文字),不参与筛选 / 搜索 / 排序,空目录也保留;面包屑各级(含根名)可点,下点线提示可点性。 -- **打开方式**:目录行原地进入;文件行 `target=_blank` 新标签打开(与行尾外开箭头图标语义一致,列表页不丢)。 +- **Type filters**: `All / Folders / Web / PDF / Images / Markdown / Tables / Documents`. They must actually filter items; Folders means directories, the rest use `item.type`. +- **Sort dropdown**: `Default order / Name A-Z / Name Z-A / Newest first / Oldest first`; selected item gets a check. **Folders always group before files**, then sort within each group by the selected key. +- Count: while filtering/searching show `N / 304 items`; otherwise `304 items`. +- Right side of each row shows creation date in mono; when sorting by time, emphasize this column. +- Empty result state: `No matching files` with helper copy. +- When dropdown is open, add a transparent fixed overlay that closes it on click. +- **Parent directory row**: non-root listings always have a fixed first row for "Back to parent", with a dashed icon border and muted text. It is excluded from filtering/search/sort and remains visible in empty directories. Breadcrumb segments, including root, are clickable with dotted underline affordance. +- **Open behavior**: directories navigate in place; files open in a new tab via `target=_blank`, matching the external-open arrow at the row end. -### 6.5 客户端预览页:Markdown / JSON / CSV ★ +### 6.5 Client Preview Pages: Markdown / JSON / CSV -浏览器导航到 `.md(.markdown)` / `.json(.geojson)` / `.csv(.tsv)` 时不再裸吐纯文本,而是渲染预览页(共用 `PreviewPage` 壳,与文件**同 URL**,正文相对引用因此天然解析): +Browser navigation to `.md(.markdown)` / `.json(.geojson)` / `.csv(.tsv)` renders a preview page instead of raw text. The preview shares the `PreviewPage` shell and uses the **same URL as the file**, so relative references resolve naturally. -- 壳骨架同列表页:kicker(权限措辞) → 衬线大标题(文件名) → 面包屑(可点,右侧「查看原文」mono 链接) → 取景框内容卡 → 署名;加载/失败态 mono 弱化文案:`正在加载…` / `解析失败 · 查看原文`。 -- `?raw=1` 永远返回原文(页角「查看原文」即它);curl / 脚本(Accept 非 html)自动拿原文,无须参数。 -- **Markdown**:标题衬线、代码 / 表格 mono、引用块 accent 左边线、图片圆角描边 `max-width:100%`;渲染用内嵌 vendored marked;**原始 HTML 一律转义为可见文本**,不执行。 -- **JSON**:折叠树(原生 details,前两层默认展开,▸ 旋转),类型着色(字符串 ok 绿 / 数字 accent / 布尔 warn / null 斜体弱化);子节点懒构建、每 500 项一档「再显示」;长字符串截断 200 字符、点击展开;搜索走数据全量遍历,命中以「完整路径 + 值」平铺(上限 500),工具栏右侧 meta 显示 `对象 · N 键 · 体积`。 -- **CSV**:首行恒为表头;RFC4180 解析(引号字段 / `""` 转义 / 字段内换行),分隔符在 `, ; \t` 间自动判定;表头点击排序(数值列采样判定走数值比较,▲▼ 指示)、输入框按任意单元格筛选(meta 显示 `N / total 行 × M 列`);DOM 每 1000 行一档「再显示」,大文件不卡页面。 +- Shell: kicker based on permission wording -> serif title from filename -> clickable breadcrumb with "View raw" mono link on the right -> viewfinder content card -> footer credit. Loading/failure states use muted mono copy: `Loading...` / `Failed to parse · View raw`. +- `?raw=1` always returns original text; curl/scripts with non-HTML Accept headers automatically receive raw text. +- **Markdown**: serif headings, mono code/tables, accent left border on blockquotes, rounded bordered images with `max-width:100%`. Render with vendored marked. **Raw HTML is always escaped and shown as visible text**, never executed. +- **JSON**: collapsible tree using native `details`, first two levels expanded by default; type coloring for strings/number/boolean/null; lazy child construction; "show more" batches every 500 items; strings truncate at 200 characters and expand on click. Search traverses the full data tree and displays flat matches as `full path + value` up to 500 results. Toolbar meta shows `Object · N keys · size`. +- **CSV**: first row is always header. RFC4180 parsing with quoted fields, `""` escaping, and newlines inside fields. Auto-detect delimiter among `, ; \t`. Header click sorts columns with numeric comparison when sample values look numeric and ▲▼ indicators. Search filters by any cell. Meta shows `N / total rows x M columns`. DOM renders in batches of 1000 rows with "show more" for large files. -### 6.6 网络可见范围 + 明文提示(v0.7.x)★ +### 6.6 Network Visibility and Plain-HTTP Notice -- **「仅当前网络可见」开关**:设置页「监听端口」区下方一行——标题(13.5 semibold sans) + 说明(11.5 sans inkMute,按网卡数量分措辞:多卡「只在选中的信号源上开放,电脑连着的其它网络访问不到」、单卡「只在当前网络开放,日后接入别的网络时也访问不到」) + 右侧 Switch(§5.3)。默认关(对所有网络开放);开启后分享只在选中信号源所在网络可见。 -- **在线访客设备名**:分享屏地址下方的在线行不再裸显人数(见 §5.2)——小绿点 + 「<设备名> 正在浏览」/「<设备名> 等 N 人正在浏览」(11.5 sans inkMute),查不到设备名回退「…IP 尾号」,`lineLimit(1)` 末端截断。 -- **明文风险提示**(两处,克制灰字 inkMute + `lock.open` 图标):① 设置页「访问权限」区下一行;② 分享屏底部「连不上?」气泡末尾、隔离线下另起一行。措辞统一「同一网络下传输不加密 · 公共 Wi-Fi(咖啡馆 / 机场等)下同网的人可能看到内容,敏感文件别在这种网络分享」。**不进彩底警告框**——克制告知、不吓人。 +- **Visible only on current network** switch: in Settings under Listening Port. Row contains a title, helper copy, and Switch. Multi-interface copy: only selected network source can access it; other connected networks cannot. Single-interface copy: only current network can access it, and future networks will not. Default is off, meaning open on all local interfaces; when enabled, the share binds only to the selected source network. +- **Online visitor device names**: the line below the share address no longer shows only a count. It uses a small green dot and visitor summary copy. Unknown names fall back to IP suffixes; line is single-line with end truncation. +- **Plain HTTP notice**: two restrained gray `inkMute` placements with `lock.open` icon: under Access in Settings and after a separator at the end of the "Can't connect?" bubble. Copy: same-network transfers are not encrypted; on public Wi-Fi, others on the same network may see content; avoid sensitive files there. **Do not use a colored warning box.** -### 6.7 传递文本:发 / 收(0.9)★ +### 6.7 Text Transfer: Send / Receive -`Screen.text`,带返回二级页(头部 `← + 「传递文本」+ ⚙`,品牌名只留主页)。两条**独立单向通道**——发出去(Mac→手机)的 `sharedText` 与收回来(手机→Mac)的收件箱互不相通;一页一码、两端双向。 +`Screen.text`, a secondary page with back navigation: header is back arrow + "Text Transfer" + settings gear. Brand name appears only on the home screen. There are two independent one-way channels: `sharedText` for Mac -> phone and inbox for phone -> Mac. -屏内区块(从上到下): +Screen sections, top to bottom: -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 && (有文本 ‖ 收件开)` 时出现。 +1. **QR card / empty state**: service running with URL -> QRCard (172x172, padding 18, radius 14) + explanatory text + CopyPill + fallback mono address. Otherwise show empty copy: send text or enable receiving, then scan. +2. **Send text card**: `surface` radius 16, padding 16, `line` border. Section eyebrow -> `PlainTextEditor` (`field`, radius 12, **mono 13**, min-height 118, custom placeholder compatible with Chinese IME, accent caret) -> PrimaryBtn "Share" or "Update"; show "Withdraw" GhostBtn when already shared. +3. **Allow receiving row**: `surface` radius 14, padding 14. Title + helper copy + Switch. Opt-in and off by default. +4. **Inbox card**: visible only when receiving is enabled or messages exist. Header = accent status dot + title + unread badge + Clear action. List shows up to 12 items, then a count. Each row has TextGlyph, source device/IP and relative time, text preview up to 3 selectable lines, copy success check, and delete button. +5. **Stop transfer**: DangerBtn "Stop", visible only while service is running and there is shared text or receiving is enabled. -关键行为(务必守): +Required behavior: -- **token = 会话维度**:更新文本(`setSharedText`)**不轮换 token**(否则把正在看的对端刷掉、还误伤共存的文件分享链接);只在换分享 / 停止 / 清除这些会话边界才轮换。 -- **接收双上限挡内存**:单条 ≤ 64KB(事后 413)+ 收件箱 ≤ 100 条挤旧。**仅应用内提醒**(未读角标 / 「新收到」卡片),不发系统通知。 -- **接收呼吸点**:「传递文本」入口与运行态用 `PulsingDot`(size 6,缓慢淡入淡出,发或收都亮)区别于静态未读。 -- **停止 `stopTextTransfer`**:撤文本 + 清草稿 + 关接收 + 删持久化(不在磁盘留口令)+ 停服务(轮换 token 作废链接)+ 回主页。 -- **持久化两开关各默认关**(「记住分享的文本」/「记住收到的文本」,语义不同:发的是自己粘的、收的是他人投递的)。开「记住分享」只回填草稿、**不自动广播**。 -- 网页端:发文本页纯文本转义显示 + 大「复制」(`execCommand` 回退——纯 http 非安全上下文) + http(s) 自动链接;收文本表单与访客上传表单**同处、同条件**出现。 +- **Token is session-scoped**: updating text via `setSharedText` **does not rotate the token**, otherwise active visitors would be kicked and coexisting file-share links would break. Rotate only at session boundaries: changing share, stopping, clearing, or stopping text transfer. +- **Receive limits**: each message <= 64 KB with 413 after read; inbox <= 100 items, dropping oldest. Only in-app indication, no system notifications. +- **Receive pulse dot**: Text Transfer entry and running state use `PulsingDot` size 6 when either send or receive is active. +- **`stopTextTransfer`**: withdraw text, clear draft, disable receiving, delete persistence, stop service, rotate token, and return home. +- **Persistence toggles** default off and are separate: remember shared text versus remember received text. Remembering shared text restores the draft only and **does not auto-broadcast**. +- Web side: sent text page escapes plain text, has a large Copy button with `execCommand` fallback because plain HTTP is not a secure context, and auto-links http(s). Receive form appears with the same conditions and placement as guest upload. -### 6.8 语言切换 / 国际化(i18n,0.9)★ +### 6.8 Language Switching / i18n -设置页「语言」= 三分段控件(与「外观」同款 `…Seg` 结构):`跟随系统 / 中文 / English`。圆角 9、高 34、间距 8、均分宽;选中 = accent 底 + 白字(sans 13 semibold)、无描边,未选 = `surface` 底 + `ink` 字 + `line` 描边(sans 13 medium)。**语言用本族文字呈现**(「中文」「English」不翻译),「跟随系统」随当前语言显示。绑定 `AppState.langPref`(system / zh / en,持久化)。 +Settings Language control is a three-segment control matching Appearance segmented controls: `Follow System / 中文 / English`. Radius 9, height 34, gap 8, equal widths. Selected = accent background + white sans 13 semibold, no border. Unselected = `surface` + `ink` + `line` border, sans 13 medium. **Language names are written in their own language**; `Follow System` follows the current app language. Bind to persisted `AppState.langPref` values: system / zh / en. -**两个解析域彼此独立**(务必守): +**Two language domains are independent:** -- 原生 app —— 跟设置 `langPref`(`AppState.lang` / `Lang.current` 快照供菜单、CLI 读取)。 -- 网页 —— **逐请求**按浏览器 `Accept-Language`(`q` 值降序、`q=0` 跳过),**绝不读 app 设置**。同一台 Mac 分享:桌面界面可中文、手机按自己系统语言显示。 +- Native app uses Settings (`AppState.lang` / `Lang.current` snapshot for menus and CLI). +- Web UI is decided **per request** from browser `Accept-Language` with descending `q` values and `q=0` skipped. It must **never** read the app setting. The desktop app can be Chinese while the visitor's phone UI is English, or the reverse. -文案三类:静态走 `L`(枚举键,编译器强制穷尽)、插值 / 复数 / 语序差异走 `LStr`、网页 JS 侧拼接走 `i18nJSON`(`jsEscape` 把 `<` 转义挡 ``)。加语言只需在 `L`/`LStr` 各补一支。 +Copy categories: static strings use `L` enum keys with compiler-enforced exhaustiveness; interpolated/plural/order-sensitive strings use `LStr`; JavaScript-facing strings use `i18nJSON`, where `jsEscape` escapes `<` to block ``. Adding a language requires adding branches to both `L` and `LStr`. --- -## 7. 文案规范 Copywriting +## 7. Copywriting -- 中英双语(简体中文基准 + English,全部经 `Lang.swift` 文案表,见 §6.8);简短克制,分隔统一用「 · 」(空格+中点+空格)。 -- 技术值(IP、端口、路径、体积、项数、日期)一律 mono。 -- 危险操作动词直白:「停止」。正向:「选择文件或文件夹 / 重新广播 / 重新分享」。 -- 权限措辞跟随状态:「只读分享 / 可读写」,不要写「读写模式开启」这类机翻腔。 -- 底部统一署名:`由 LocalShare 提供 · 只读`(可写时相应调整)。 +- Bilingual product UI: Simplified Chinese and English, routed through `Lang.swift` string tables. Keep copy short and restrained. Use ` · ` as the separator. +- Technical values such as IP, port, path, size, item count, and date always use mono. +- Destructive action verb: "Stop". Positive actions: choose files/folders, broadcast again, reshare. +- Permission wording follows state: "read-only share" / "read/write", not translated mechanically. +- Footer credit: `Powered by LocalShare · Read-only`, adjusted when writable. --- -## 8. 布局 / 画板尺寸(交接参考) +## 8. Layout / Canvas Reference -设计稿用 design canvas 并排呈现,实现时按真实响应式即可,比例参考: +Mocks use a design canvas with side-by-side surfaces. Implementation should be responsive; these are proportional references: -- 桌面 App 各屏:400 × 720(无边框工具窗)。 -- 桌面浏览器:约 940 宽,含 48px 高浏览器 chrome(交通灯 + 前进后退 + 带 HTTPS 锁的地址栏,地址 mono)。 -- 手机客户端:iOS 外壳约 390 × 780,顶部精简地址条。 +- Desktop app screens: 400 x 720 borderless utility window. +- Desktop browser: about 940px wide, including 48px browser chrome with traffic lights, navigation buttons, and a mono HTTPS/address bar. +- Mobile client: iOS shell around 390 x 780 with compact address bar. -可调项(设计稿通过 tweaks 暴露,建议产品化保留):强调色、深色模式、标题是否用衬线。 +Configurable product settings should keep the exposed design tweaks: accent color, dark mode, and whether titles use serif. --- -## 9. 实现红线(Do / Don't) +## 9. Implementation Red Lines -**Do**:用 tokens 做主题变量;技术信息上 mono;停止按钮用实心红;权限措辞 / 端口校验 / 搜索排序按上文行为实现;阴影 / 圆角照抄。 -**Don't**:渐变背景、emoji、彩色图标填充、用 ghost 弱化「停止」、各界面各自硬编码「只读」、把端口校验做成提交后才报错、把 chips 做成只高亮不过滤。 +**Do**: build themes from tokens; use mono for technical values; make Stop a solid red button; implement permission wording, port validation, search, and sort exactly; preserve shadows and radii. + +**Don't**: gradient backgrounds, emoji, colorful filled icons, ghost-style Stop buttons, screen-local hard-coded "read-only" labels, submit-only port validation, or chips that highlight without filtering. From 389b87c7ec75abb14e8b4f2749c95161e730b2cc Mon Sep 17 00:00:00 2001 From: shawn Date: Tue, 30 Jun 2026 20:19:21 +0800 Subject: [PATCH 3/6] docs: translate architecture guide --- docs/ARCHITECTURE.md | 252 ++++++++++++++++++++++--------------------- 1 file changed, 127 insertions(+), 125 deletions(-) diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 424b893..177e5de 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -1,196 +1,198 @@ -# LocalShare · 架构(ARCHITECTURE) +# LocalShare Architecture -> macOS 原生单窗口 app:选一个文件夹 → 窗口出现二维码 → 同 WiFi 下的手机扫码即可在浏览器里只读浏览该文件夹(可按需开启访客上传与「传递文本」,默认只读)。 +> Native single-window macOS app: choose a folder, show a QR code, and let phones on the same Wi-Fi browse it read-only in a browser. Guest upload and Text Transfer can be enabled when needed; the default is read-only. -本文是**现状架构参考**——核心约束、工程结构、关键设计决策与实现要点,供任何机器 `git pull` 后据此继续。配套文档:视觉设计规范见根目录 `DESIGN.md`;发布与版本流程见 `CLAUDE.md`。 +This is the **current architecture reference**: core constraints, project structure, important design decisions, and implementation notes for anyone continuing work after `git pull`. Related documents: visual design specification in `DESIGN.md`; release/version workflow in `CLAUDE.md`. --- -## 0. 核心戒律:不依赖包外 dylib +## 0. Core Rule: No External Package Dylibs -崩溃的典型根因是 arm64 二进制**运行时缺失动态库**——动态链接到某个包外 `.dylib`(指向 `/opt/homebrew/…`,换台没装的机器就没)。因此戒律的精神是**「换任何机器都不会缺库」**: +The typical crash this project avoids is an arm64 binary that **misses a dynamic library at runtime** because it links to a package-external `.dylib`, for example under `/opt/homebrew/...`, which is absent on another Mac. The rule means: **the app must not depend on libraries that are missing on a clean machine**. -- 只链接系统框架;纯 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 确在包内)。 +- System frameworks are allowed. Pure Swift third-party dependencies, such as Swifter, are built from SPM source and statically included in the executable. +- **Relaxed since 0.3**: Sparkle, required for automatic updates, is distributed only as a binary framework containing a dynamic library, XPC service, and helper app. It is allowed only when referenced through `@rpath` and bundled inside `.app/Contents/Frameworks/`. It travels with the app and is self-contained, so it avoids the external-library failure mode. +- **Acceptance rule**: any dylib outside the `.app` bundle is forbidden. Absolute paths such as `/opt/homebrew` and `/usr/local` are never allowed. Only `@rpath` references are accepted, and the corresponding framework must exist in `Contents/Frameworks/`. `build.sh` and CI both enforce this by filtering system libraries and checking that all remaining dependencies are bundled frameworks. --- -## 1. 锁定的设计决策 +## 1. Locked Design Decisions -| 维度 | 决策 | +| Area | Decision | |---|---| -| 平台 | 仅 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` | +| Platform | macOS only, native `.app`, Apple Silicon first | +| Network | Same Wi-Fi / LAN only; no tunnel, public endpoint, or account | +| Stack | Swift / SwiftUI, system frameworks only, no external package dylib risk | +| HTTP server | Swifter from SPM source, primarily read-only static serving | +| Share model | Three shapes: single folder -> mobile-friendly directory listing, serving `index.html` directly when present; single file -> scan opens the file and sibling files are not exposed; multiple files/folders -> synthetic virtual root listing selected items, with the first path segment mapped to a real URL. All shapes block directory traversal, each item using its own root | +| Auth | Every share action creates a random token embedded in the QR URL as `?t=...`; first visit validates it and sets a session cookie; later resources are allowed by cookie. Changing or stopping a share rotates the token immediately, invalidating old links, cookies, and QR codes. Anyone guessing `IP:port` receives 403 | +| Protocol | Plain HTTP. Threat model: block people who only guess the address; do not protect against same-network sniffing or forwarded links. Token rotation limits forwarded-link lifetime to the current share. Self-signed TLS would turn scan-to-use into certificate warnings, so it is intentionally not used | +| QR code | Raw LAN IP selected from network candidates; generated with CoreImage `CIQRCodeGenerator`; no third-party QR dependency. Window also shows a `.local` fallback and copyable URL | +| GUI | Single window: functional home screen with drag/drop and picker, Text Transfer entry, and Recent Shares; file ticket, Text Transfer, Settings, and History are secondary pages with back navigation | +| Lifecycle | Cold launch does **not** replay the last share. The app starts on the home screen; the last share remains in Recent Shares for one-click restart. Closing the window does not quit; process and server continue running, and menu bar activation restores the previous screen. Ports are selected automatically; service stops on app quit | +| Distribution | Xcode ad-hoc signing. The first Gatekeeper approval is handled manually and then remembered by macOS | +| Automatic updates | Sparkle bundled through `@rpath`; background checks, user-confirmed update prompt, EdDSA trust chain independent of ad-hoc code signing and notarization | +| Sandbox | App Sandbox is off because this is internally distributed and must read arbitrary user-selected folders | +| Internationalization | Simplified Chinese + English strings compiled into the binary, not resource bundles. Two language domains are independent: native app follows Settings; web UI is selected per request from browser `Accept-Language` | --- -## 2. 工程结构 +## 2. Project Structure -``` +```text 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 引用) + Package.swift # swift-tools-version:5.9; Swift 5 language mode for relaxed concurrency checks + Package.resolved # Swifter pinned at 1.5.0 + build.sh # swift build -c release -> assemble .app -> ad-hoc sign -> dist/ + bundle/Info.plist # Static .app Info.plist template: Sparkle feed/key and version placeholders + README.md / README_CN.md # English default + Chinese + CLAUDE.md # Claude Code working guide; loaded from repo root + DESIGN.md # Visual design spec; source references use section numbers from this file docs/ - ARCHITECTURE.md # 本文件 - images/ # README 截图 + ARCHITECTURE.md # This file + images/ # README screenshots 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…) + App.swift # @main EntryPoint: GUI / headless / CLI dispatch; LocalShareApp and AppDelegate + AppState.swift # @MainActor ObservableObject; single source of truth for shares, text, server, network, presence, persistence + ContentView.swift # Single-window SwiftUI composition: home + secondary pages + Components.swift # Ticket-style UI component library; receives Theme explicitly + Theme.swift # Color/theme generation for light/dark mode and selectable accent + FileServer.swift # Swifter wrapper: token middleware, traversal guard, directory/file/multi-share, upload, presence + Permission.swift # Permission model and PermSummary copy derivation + FileType.swift # Extension classification, preview types, executable-document defang list + DirectoryListing.swift # Mobile-friendly directory listing HTML; segment-encoded hrefs, hidden files omitted + PreviewPage.swift # Shared preview shell using same URL as the file + MarkdownViewer / JsonViewer / CsvViewer # Preview cards; Markdown uses vendored marked; JSON/CSV are zero-dependency + MarkedJS.swift # Vendored marked compiled as a Swift string constant + SendTextPage / TextViewer # Text Transfer: phone -> Mac send page and text preview shell + NetworkInfo.swift # getifaddrs -> private IPv4 candidates and .local hostname + QRCode.swift # CoreImage QR -> NSImage, plus terminal ANSI output + Token.swift / Mime.swift # Random token and extension -> MIME mapping with charset=utf-8 for text + Lang.swift # i18n string tables compiled into the binary: L / LStr / i18nJSON + HeadlessServer.swift # LS_HEADLESS=1 mode and CLI foreground mode + CLI.swift / CLIInstaller.swift # argv parsing, GUI forwarding, symlink installer + Updater.swift # Sparkle automatic update wrapper, constructed only in GUI path + Tests/LocalShareTests/ # XCTest pure-function tests: traversal, filename sanitation, multi-share keys, i18n, text + tools/ # Headless + curl smoke scripts: traversal, filenames, multiselect, upload defang, token 302, md links, language, text ``` -入口 `@main enum EntryPoint` 三层分流:`LS_HEADLESS=1` → `HeadlessServer`(裸起服务)→ `CLI.parse` 命中 argv → `CLI.run` → 否则 `LocalShareApp`(SwiftUI)。三条路径**共用同一个 `FileServer`**,逻辑不分叉。 +`@main enum EntryPoint` has three dispatch layers: `LS_HEADLESS=1` -> `HeadlessServer`; matching `CLI.parse` argv -> `CLI.run`; otherwise `LocalShareApp` (SwiftUI). All three paths share the same `FileServer`, which keeps request logic from forking. --- -## 3. 关键实现要点 +## 3. Key Implementation Notes -### Swifter 1.5.0 API 与编码坑 +### Swifter 1.5.0 API and Encoding Trap -- 全部请求逻辑塞进**单个 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 静态分发足够。 +- All request handling lives in **one middleware closure** that always returns a response and bypasses the router. `HttpResponse.raw(code, reason, headers, writer)` controls status, custom headers, and streamed file writes. +- **Double-encoded path bug**: Swifter's `HttpParser` leaves one layer of percent encoding in `request.path` before filesystem resolution. Always call `removingPercentEncoding`. Plain ASCII paths like `a.html` work either way, but `b%20c.txt` and Chinese filenames 404 without decoding. Traversal checks also use the decoded path, so `%2e%2e` is blocked. +- `.raw` has unknown body length and closes after send with no keep-alive. File responses therefore set **`Content-Length` explicitly**, which also gives phones progress information. This is sufficient for LAN static serving. -### FileServer 请求处理(单 middleware) +### FileServer Request Flow -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` / `*/*` 一律拿原文。 +1. **Token auth**: allow requests when either `?t=` or cookie `ls_token` equals the current token. Each request snapshots the token once so rotation cannot mix states. When `?t=` grants access, send `Set-Cookie: ls_token=...; HttpOnly`. Browser navigations with `Accept: text/html` then **302** to the clean URL without `?t=` so tokens do not stay in address bars or history. curl and `*/*` subresource requests do not trigger the redirect. +2. **Traversal guard**: `decoded -> strip leading / -> append to root -> standardizedFileURL.resolvingSymlinksInPath`. The result must equal the root path or have `root + "/"` as prefix; otherwise return 403. `standardizedFileURL` removes `..`; encoded dot-dot is blocked because decoding happens before normalization. +3. **Directories**: path without trailing slash gets 301 so relative resources resolve; `index.html` is served directly when present; otherwise `DirectoryListing` renders the listing with segment-encoded absolute hrefs, hidden files omitted, and a fixed parent row outside the root. +4. **Files**: MIME is inferred by extension, text types get `charset=utf-8`, and `FileHandle` streams in 64 KB chunks. Exception: browser navigation to `.md` / `.json` / `.csv` returns a preview shell at the **same URL as the file**, so relative references resolve through normal serving. curl, `?raw=1`, and `*/*` receive raw file content. -单文件夹的 2~4 步抽成 `serveTree(rootURL:relPath:…)` 复用。**单文件**直接发那一个。**多选**(`Share.multiple([Item])`,`makeItems` 以 lastPathComponent 为 key、重名 `-2` 兜底):空路径发虚拟根列表页,其余拆首段 `key` 映射真实 URL;未知 key / 文件项带子路径 → 404,穿越判据每项独立。 +Single-folder serving extracts steps 2-4 into `serveTree(rootURL:relPath:...)`. **Single-file** shares return only that file. **Multi-share** uses `Share.multiple([Item])`, where `makeItems` uses `lastPathComponent` as key and appends `-2` for collisions. Empty path returns the virtual-root listing; otherwise the first path segment is mapped to a real URL. Unknown keys and subpaths under file items return 404. Each item has an independent traversal root. -### 网页侧 XSS 硬化与卫生加固 +### Web XSS Hardening -被服务的内容跑在分享源(`http://本机:端口`)下,HTML/SVG 会被当同源页面执行脚本。防线: +Served files run under the share origin (`http://host:port`), so HTML/SVG can execute same-origin scripts. Defenses: -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 跑断言。 +1. **Guest-upload defang**: `sanitizeFileName` appends `.txt` to executable document extensions such as html, htm, xhtml, svg, svgz, and mht. This blocks the stored-XSS path where a guest uploads `index.html` into a folder and future visitors execute it by entering the directory. It applies **only to uploads**; a sharer-owned static site already on disk, including `index.html`, is still served normally for directory shares. +2. **Global `X-Content-Type-Options: nosniff`**: defense in depth; correctly declared files keep working. +3. **Markdown link/image protocol allowlist** in `MarkdownViewer.rendererConfig`: `.md` previews are not upload-defanged, so marked's `link` and `image` renderers are overridden. Allowed protocols are safe web/mail/phone schemes, `data:` for images, and relative/hash URLs. Two details matter: decode HTML entities before checking to block `javascript:`, and remove code points <= 32 after decoding to block `javascript:`. A colon counts as a protocol only before any `/ ? #`. `tools/smoke-md-link-sanitize.cjs` tests this with the real vendored marked build. -配套:**token-302 清洗**(上面第 1 步);**上传文件打 `com.apple.quarantine`**(分享者双击触发 Gatekeeper)。回归测试 `tools/smoke-upload-defang.sh`。 +Related hardening: token 302 cleanup; uploaded files receive `com.apple.quarantine` so Gatekeeper runs if the sharer later opens them. Regression script: `tools/smoke-upload-defang.sh`. -### 访客上传 +### Guest Upload -`Permission.add` 开关(与 share 同锁,仅单文件夹分享可开、换分享自动回只读)。POST multipart 写到当前浏览目录——落点过同一套防穿越、文件名只取末段清洗、重名 `-2`、临时文件原子换名、500MB 上限 413。注意 **Swifter 进 middleware 前已把 body 整段读进内存**,上限只能事后拒绝(分片上传留给将来,见 §5)。`onUpload` 回调在 socket 线程,GUI hop 回 MainActor 出「新收到」卡片。 +`Permission.add` controls upload. It shares the same lock as `share`, can be enabled only for single-folder shares, and resets to read-only when the share changes. Multipart POST writes into the current browser directory after the same traversal check. Filenames are reduced to the last path segment, sanitized, collision-suffixed with `-2`, written through a temp file, then atomically renamed. Per-file limit is 500 MB with 413. **Swifter reads the whole body before middleware runs**, so the limit can only reject after the read; chunked upload is left for future work. `onUpload` runs on the socket thread and hops to `MainActor` for the in-app "new received" card. -### 在线感知 +### Online Presence -鉴权后按客户端 IP 记 `lastSeen`(45s 窗口即「N 人正在浏览」),best-effort 后台反查设备名(`getnameinfo`,缓存 `nameCache`、串行队列、随 token 轮换清零)。`/ls/ping` 为保留心跳路径(先于分享内容命中),listing 页 JS 每 15s 打一次、**只回人数不外泄设备名**;GUI 由 `AppState` 2s 轮询展示(具名领衔、查不到统一「N 人正在浏览」、不在摘要露 IP,点摘要弹列表)。 +After auth, requests record `lastSeen` by client IP. A 45-second window counts as "N people browsing". Device-name reverse lookup runs best-effort in the background through `getnameinfo`, with `nameCache` and `nameLookupInFlight` protected by the server lock and cleared on token rotation. `/ls/ping` is a reserved heartbeat path handled before share content; listing pages call it every 15 seconds and it returns only counts, not device names. The GUI polls every 2 seconds through `AppState`, uses a named lead visitor when known, falls back to "N people browsing", hides IPs from the summary, and shows details in a popover. -### 仅当前网络可见 +### Current-Network-Only Binding -`FileServer.listenAddress` 非 nil 时只绑该网卡 IPv4(Swifter 原生 `listenAddressIPv4`,无须 fork),由 `AppState.bindSelectedOnly` 驱动(切网卡/开关不轮换 token 地重绑、IP 消失回退全接口、`inet_pton` 非法即抛错不静默绑全接口);默认 nil = 绑 `0.0.0.0`。 +When `FileServer.listenAddress` is non-nil, Swifter binds only that IPv4 address through `listenAddressIPv4`. `AppState.bindSelectedOnly` drives it. Changing interface or switch state rebinds without rotating the token. If the selected IP disappears, the app falls back to all interfaces. Invalid `inet_pton` input throws instead of silently binding all interfaces. Default `nil` means `0.0.0.0`. -### AppState 与生命周期 +### AppState and Lifecycle -- 唯一真相源是 `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 瞬间可读新分享。 +- Single source of truth is `sharedItems: [URL]`: empty, single, or multiple. Derived values include `isMultiple`, `isEmpty`, and convenience `sharedURL`. +- **Cold launch does not restore sharing**. `init` does not read the last share back into `sharedItems`, and no file service starts automatically. Quietly serving a folder on LAN after opening the app would be unsafe. The last share remains available in Recent Shares (`RecentShare.paths`) for manual restart. Text inbox can auto-start only when explicitly enabled. +- Closing the window does **not** quit. The process and service continue; `@StateObject` is created once per process, so reactivating the app returns to the previous screen. +- Port preference order is `[8080, 8000, 8888, 9000]`; if all fail, choose a random high port. +- **Changing a share does not restart the server** when the port is unchanged. The lock updates `share` and `token`, with the key changed before the content to prevent an old token from briefly reading the new share. -### 屏幕路由 / 二级页 +### Screen Routing -`Screen` 枚举:`.share`(主页)/ `.file`(文件票据)/ `.text`(传递文本)/ `.settings` / `.history`。文件票据与传递文本同为带返回二级页,头部统一 `← + 标题 + ⚙`,品牌名只留主页。主页「正在分享」横幅(`ActiveShareBanner`):文件后台续跑时顶部出可点行,一键回票据。 +`Screen` enum: `.share` home, `.file` ticket, `.text` Text Transfer, `.settings`, and `.history`. File ticket and Text Transfer are secondary pages with shared header shape: back button, title, settings gear. The LocalShare brand appears only on the home screen. `ActiveShareBanner` appears on the home screen while file sharing continues in the background and lets users return to the ticket in one action. -### App 入口 / Headless / CLI +### App Entry, Headless Mode, and 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` 冒烟。** +- `localshare a.html b.pdf` forwards paths to the GUI with `NSWorkspace.open(urls, withApplicationAt:)`; a running instance is reused and hot-swaps the share without restarting the server. `--headless` runs a foreground server in the current process and prints terminal QR output with `QRCode.ansi`. +- The installed `localshare` command is a **symlink** to the main binary inside the app bundle. dyld resolves `@executable_path` after realpath, so bundled Sparkle still loads. But CLI code **must not use `Bundle.main`** to locate the `.app`; it resolves `_NSGetExecutablePath`, follows symlinks, then walks up three levels. +- **Release compiler trap**: with `-O`, the chain "optional `in_port_t` payload inside enum -> construct `[in_port_t]` inside function -> pass to `FileServer.start`" can miscompile into a bad array pointer. Debug works; release crashes. Workaround: `runForeground` accepts concrete `preferredPorts: [in_port_t]`, and callers unwrap the optional. Any CLI or `HeadlessServer` change must be smoke-tested in release with `--headless`. -### 自动更新(Sparkle) +### Automatic Updates with 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 的同名资产)。 +- Sparkle is imported as an SPM `binaryTarget`. `build.sh` uses `ditto` to copy `Sparkle.framework` into `Contents/Frameworks/`, and `executableTarget` adds `-rpath @executable_path/../Frameworks`. After inside-out ad-hoc signing, `codesign --verify --deep --strict` verifies the bundle. +- `Updater.swift` owns an `@MainActor UpdaterController` holding `SPUStandardUpdaterController`. It is constructed only by `LocalShareApp`; headless paths never touch it. Configuration lives in `Info.plist` (`SUFeedURL`, `SUPublicEDKey`, `SUEnableAutomaticChecks`). When `SUPublicEDKey` is still the placeholder, the updater does not start. +- Trust is EdDSA: the private key signs update packages and the app embeds the public key. This is independent of code signing and notarization, so ad-hoc + unnotarized builds can still self-update safely. Updates installed by Sparkle are not quarantined, so the first manual approval does not repeat on later updates. +- CI `release.yml`: build -> dependency gate -> DMG -> GitHub Release -> `sign_update` EdDSA signature -> generate `appcast.xml` as a **Release asset**. It is not committed to git. Feed URL is `https://github.com/rrbe/LocalShare/releases/latest/download/appcast.xml`, which GitHub keeps pointing at the latest release asset. -### 传递文本 +### Text Transfer -让「选内容→手机扫码」从磁盘文件扩到一段文本,**两条独立单向通道**(发出去的 `sharedText` 与收回来的收件箱互不相通): +Text Transfer expands "choose content -> scan on phone" from disk files to a text snippet. It has **two independent one-way channels**: `sharedText` for Mac -> phone and inbox for phone -> Mac. -- **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` 这些会话边界轮换。两个持久化开关各默认关(发的是自己粘的、收的是他人投递的,隐私语义不同)。 +- **v1 Mac -> phone**: `AppState.sharedText: String?` reuses almost all read-only serving. Text is another GET-served content type under reserved namespace `/ls/text`, matched before virtual-root keys. It can be shared alone or included in a multi-share virtual root. Browser navigation returns `TextViewer`; `?raw=1` and curl return `text/plain`. Phone page escapes text, has a large Copy button with `execCommand` fallback because plain HTTP LAN is not a secure context, and auto-links safe http(s) URLs. +- **v2 phone -> Mac**: independent inbox, no disk storage and no folder-share dependency. Gate is `textInboxEnabled`, opt-in and off by default. Enabling it starts the service if needed. `POST /ls/text` receives plain text; listing pages embed the send form. Limits: each message 64 KB, inbox 100 items with oldest eviction. `onReceiveText` hops from socket thread to `MainActor`; feedback stays in-app with no system notification. +- **Token is session-scoped**: `setSharedText` does **not** rotate the token, otherwise every text update would kick active viewers and break coexisting file-share links. Rotate only on session boundaries: `setShared`, `stop`, `clearShare`, and `stopTextTransfer`. Persistence toggles default off and are separate for shared text versus received text. -### 国际化(i18n) +### Internationalization -- 全部用户可见文案做成**编进二进制的 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` 各补一支。 +- All user-visible copy is compiled into Swift string tables in `Lang.swift`. The app does not depend on resource bundles, so GUI, headless, and CLI paths do not need to locate localized files. +- **Two language domains are independent**: native app language comes from Settings (`AppState.langPref`: follow system / Chinese / English, persisted); web UI is decided **per request** from browser `Accept-Language` (`Lang.fromAcceptLanguage`, descending `q`, skip `q=0`). Web serving never reads the app setting. +- Copy has three categories: static strings use `L` with exhaustive enum cases returning `(zh, en)`; interpolation/plural/order-sensitive strings use `LStr`; JavaScript strings use `LStr.i18nJSON`, where `jsEscape` escapes `<` as `\u003c` to prevent premature ``. Adding a language means adding branches to both `L` and `LStr`. -### 容错 UI +### Resilient UI -无 WiFi / 无私网 IP → 不画死码,提示「请先连接 WiFi」。首次 start 触发 macOS 防火墙提示 → 文案引导点**允许**(误点拒绝是「扫了码却打不开」头号原因)。二维码下常驻一行排错提示(同一 WiFi、未开访客/设备隔离)。空文件夹友好态。 +No Wi-Fi or private IP means no dead QR code; show guidance to connect to Wi-Fi. First server start may trigger the macOS firewall prompt; copy should tell users to click **Allow** because denying it is the most common reason scans fail. QR area keeps a concise troubleshooting line: same Wi-Fi and no guest/device isolation. Empty folders have a friendly state. --- -## 4. 构建与运行 +## 4. Build and Run ```bash -swift build -c release # 编译(首次会拉 Swifter) -swift test # XCTest 纯函数单测 -./build.sh # 组装并 ad-hoc 签名 → dist/LocalShare.app -open dist/LocalShare.app # 本机自测 +swift build -c release # Build; first run fetches Swifter +swift test # XCTest pure-function tests +./build.sh # Assemble and ad-hoc sign -> dist/LocalShare.app +open dist/LocalShare.app # Local GUI smoke test -# 无头端到端(无 GUI,供脚本/curl 验服务端逻辑) +# Headless end-to-end test for server logic LS_HEADLESS=1 LS_FOLDER=/path/to/dir LS_TOKEN=testtoken LS_PORT=8099 .build/debug/LocalShare & -curl -s "http://127.0.0.1:8099/?t=testtoken" # 应返回目录列表或 index.html +curl -s "http://127.0.0.1:8099/?t=testtoken" # Should return a directory listing or 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 自动跑。 +Headless multi-share uses `LS_FOLDERS` separated by `:` or newlines. Enable upload with `LS_UPLOAD=1`, bind an interface with `LS_BIND=`, and test Text Transfer with `LS_TEXT` / `LS_RECV`. The two test layers (`swift test` and `tools/smoke-*.sh`) run in `.github/workflows/ci.yml` for every PR and `master` push. -**发给同事**:把 `dist/*.app` 拷过去;**首次由你帮他打开一次**(双击被 Gatekeeper 拦 → 系统设置 → 隐私与安全性 → 点「仍要打开」,仅此一次)。 +**Sharing with a coworker**: copy `dist/*.app`; for the first launch, help them pass Gatekeeper once by opening the app, then System Settings -> Privacy & Security -> Open Anyway. macOS remembers this approval. --- -## 5. 非目标与未做 +## 5. Non-Goals and Future Work -**明确不做:** +**Explicit non-goals:** -- Apple 公证(要 $99/年开发者号);https + 自签证书(会把扫码即用变成证书警告页,与核心体验冲突);跨网络隧道(cloudflared/ngrok/tailscale)。 -- **在线编辑 / 删除**(访客在浏览器里改/删文件):决定不做——手机浏览器编辑体验差、覆盖丢数据与误删风险高,价值不抵风险。访客上传已覆盖「手机→Mac」的主诉求。`Permission.edit` / `Permission.del` 恒 `false`。 +- Apple notarization, which requires a paid developer account. +- HTTPS with a self-signed certificate, which would replace scan-to-use with certificate warnings. +- Cross-network tunneling such as cloudflared, ngrok, or Tailscale. +- **Online edit/delete in the browser**. This is intentionally not supported: mobile browser editing is poor, overwrite/delete risks are high, and guest upload already covers the primary phone -> Mac need. `Permission.edit` and `Permission.del` remain `false`. -**未做(可能将来):** +**Potential future work:** -- **上传分片**:绕开 Swifter「整段 body 进内存」的限制,前端切片逐片 POST、服务端按序 append + 末片原子换名,任意大小、内存恒定。落地后可放开 500MB 上限。 -- **更准的设备名反查**:现为 best-effort `getnameinfo`,iPhone 多查不到、回退 IP;要更准需走 `DNSServiceQueryRecord` 的 mDNS PTR 查询,成本高且仍不保证命中。 +- **Chunked upload**: bypass Swifter's whole-body-in-memory limitation by slicing on the frontend, appending chunks server-side, and atomically renaming on the final chunk. This would allow arbitrary size with constant memory and remove the 500 MB cap. +- **Better device-name lookup**: current lookup is best-effort `getnameinfo`; iPhones often do not resolve and fall back to IP. More accurate mDNS PTR lookup via `DNSServiceQueryRecord` is possible but more complex and still not guaranteed. From f2c2a4797121bba0005cf0f495254f93044cb4ac Mon Sep 17 00:00:00 2001 From: shawn Date: Tue, 30 Jun 2026 20:19:25 +0800 Subject: [PATCH 4/6] docs: internationalize agent guide --- AGENTS.md | 113 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ CLAUDE.md | 68 +------------------------------- 2 files changed, 114 insertions(+), 67 deletions(-) create mode 100644 AGENTS.md diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..a407cb8 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,113 @@ +## Project + +LocalShare is a native single-window macOS app written in Swift / SwiftUI. A user chooses files or folders, the window shows a QR code, and phones on the same Wi-Fi can browse the share read-only in a browser. Guest upload and Text Transfer are optional; the default is read-only. + +The central engineering rule is **no package-external runtime dylibs**. System frameworks are fine. Pure Swift dependencies such as Swifter are built from SPM source into the binary. Binary frameworks, currently Sparkle, are allowed only when referenced via `@rpath`, bundled inside `.app/Contents/Frameworks/`, signed with the app, and verified by the dependency gate. Absolute package-external dylib paths such as `/opt/homebrew` and `/usr/local` are forbidden. + +Related documents: + +- Architecture: `docs/ARCHITECTURE.md` +- Visual design system: `DESIGN.md` + +## Common Commands + +```bash +swift build # Debug build; first run fetches Swifter +swift build -c release # Release build +swift test # XCTest unit tests for pure functions +./build.sh # Release build -> assemble .app -> ad-hoc sign -> dist/LocalShare.app +open "dist/LocalShare.app" # Local GUI smoke test + +# Headless end-to-end test for server behavior +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" + +# CLI paths; after CLI/HeadlessServer changes, also retest release because of the documented -O compiler trap +.build/debug/LocalShare --headless /path/a.html /path/dir +.build/debug/LocalShare a.html b.pdf +ln -s "$PWD/dist/LocalShare.app/Contents/MacOS/LocalShare" /tmp/localshare +/tmp/localshare --version + +# Dependency rule check: after filtering system libraries, all remaining deps must be bundled @rpath frameworks +otool -L "dist/LocalShare.app/Contents/MacOS/LocalShare" | grep -v "/usr/lib/\|/System/Library/" +# Expected: only @rpath/Sparkle.framework/...; Sparkle.framework must exist in dist/LocalShare.app/Contents/Frameworks +``` + +Tests have two layers: + +- `swift test`: XCTest coverage for pure functions such as `resolveWithinRoot`, `sanitizeFileName`, `Share.makeItems`, `availableURL`, i18n, and text handling. +- `tools/smoke-*.sh` / `smoke-*.cjs`: headless curl smoke tests for token auth and 302 cleanup, traversal blocking, single-file isolation, Chinese/space/percent filename decoding, multi-share virtual roots, upload defanging, interface binding, Markdown link sanitation, Accept-Language routing, and Text Transfer. + +CI runs both layers on every PR and `master` push. `release.yml` only handles releases. Requirements: macOS 13+ and a Swift toolchain. + +## Commit Messages + +Use Conventional Commits and preferably write messages in English: + +```text +: +``` + +Allowed types: `feat` / `fix` / `refactor` / `chore` / `docs` / `test` / `perf` / `style`. + +Use an imperative, lowercase summary under 72 characters, for example: + +```text +fix: prevent stale token from reading new share +``` + +## Architecture Notes + +This section is only a navigation map. Full request flow, XSS hardening, lifecycle, CLI, and headless details live in `docs/ARCHITECTURE.md`. + +`App.swift` uses `@main enum EntryPoint` with three dispatch layers: + +1. `LS_HEADLESS=1` runs `HeadlessServer`. +2. `CLI.parse` handles argv for `localshare ...` and `--headless`. +3. Everything else starts `LocalShareApp`. + +All three paths share one `FileServer`, which keeps request logic unified. GUI CLI forwarding uses `NSWorkspace.open(urls, withApplicationAt:)`; `AppDelegate.application(_:open:)` receives paths and hot-swaps `AppState.setShared`. If open events arrive before `AppState` exists, they are buffered in `pendingOpenURLs`. + +`AppState` is the `@MainActor ObservableObject` and single source of truth. It owns `FileServer`, network candidates, and derived state such as `primaryURL` and `qrImage`. File shares are represented by `sharedItems: [URL]`: empty, single, or multiple. Cold launch does not automatically replay the previous share; Recent Shares offers manual restart. Closing the window does not quit, so the process and server keep running. + +`FileServer` handles all requests through one Swifter middleware closure. It supports: + +- token auth through `?t=` or cookie `ls_token`; browser navigations authenticated by query receive a cookie and 302 to a clean URL +- immediate token rotation on `setShared` / `stop`, invalidating old links and cookies +- online presence tracking by client IP with best-effort device-name lookup +- single-file, single-folder, and multi-share virtual-root routing +- traversal protection using decoded paths, standardized file URLs, and resolved symlinks +- directory listing, `index.html` serving, and 64 KB chunked file streaming +- Markdown / JSON / CSV preview shells at the same URL as the original file, with `?raw=1` and curl returning raw content +- guest upload for single-folder shares, guarded by traversal checks, filename sanitation, collision suffixing, atomic rename, 500 MB limit, executable-document defanging, `nosniff`, and quarantine metadata +- optional binding to the selected IPv4 interface when "current network only" is enabled + +Utility modules have narrow responsibilities: `NetworkInfo`, `QRCode`, `Token`, `Mime`, `DirectoryListing`, `PreviewPage`, `MarkdownViewer`, `JsonViewer`, `CsvViewer`, `MarkedJS`, `Lang`, `L`, and `LStr`. i18n strings are compiled into the binary; the native app follows `AppState.langPref`, while the web UI is selected per request from `Accept-Language`. + +`Updater.swift` wraps Sparkle. `UpdaterController` is constructed only by the GUI path; headless paths never touch Sparkle. Configuration lives in `bundle/Info.plist`. If `SUPublicEDKey` is still the placeholder, the updater does not start. + +## Release and Versioning + +The version is defined by git tags: `vX.Y.Z` on `master` tip. `bundle/Info.plist` contains placeholder values; CI overwrites `CFBundleShortVersionString` from the tag and `CFBundleVersion` from the run number. Do not edit `Info.plist` just to release a version. + +Release flow: + +1. Merge all changes to `master`. +2. Write changelog bullets into an annotated tag note: `git tag -a vX.Y.Z -F notes.md`. +3. Push the tag. +4. `.github/workflows/release.yml` builds universal binaries, runs the dependency gate, creates the DMG, creates the GitHub Release, signs the DMG for Sparkle, and uploads `appcast.xml` as a Release asset. + +`appcast.xml` is not committed. Sparkle feed URL is `https://github.com/rrbe/LocalShare/releases/latest/download/appcast.xml`, which always points to the latest release asset. Release notes should be user-facing `-` bullets in English, with no `#` heading because `git tag` treats those as comments. + +## Cross-File Constraints + +- **No package-external dylibs**: after adding or changing dependencies, run the `otool` check and confirm bundled frameworks exist in `Contents/Frameworks`. +- **Swifter 1.5.0 path double-encoding bug**: `req.path` still contains one percent-encoded layer before filesystem resolution. Decode with `removingPercentEncoding`; this is required for spaces, Chinese filenames, and encoded traversal attempts. +- **Traversal guard**: after joining paths, use `standardizedFileURL.resolvingSymlinksInPath`; the result must be equal to `rootPath` or have `rootPath + "/"` as prefix. Retest `../`, `%2e%2e`, and `..%2f` after changing this area. +- **Threading model**: Swifter callbacks run on socket threads, while `AppState` is `@MainActor`. Shared mutable server state is protected by one `NSLock`: `share`, `token`, `uploadEnabled`, `lastSeen`, `nameCache`, and `nameLookupInFlight`. +- **Share changes do not restart the server** when the port is unchanged, but they rotate tokens immediately. Change the key before changing content. +- **`.raw` has no keep-alive and unknown body length**, so file responses must write `Content-Length`. + +## Design Constraint + +Prefer communicating through the design language over adding explanatory text. When text is necessary, keep it short, common, and natural. diff --git a/CLAUDE.md b/CLAUDE.md index adb3c7a..ad122aa 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,67 +1 @@ -# CLAUDE.md - -This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. - -## 项目本质 - -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`。 - -## 常用命令 - -```bash -swift build # debug 编译(首次拉 Swifter) -swift build -c release # release 编译 -swift test # 单元测试(XCTest,纯函数:防穿越判据 / 文件名清洗 / key 去重) -./build.sh # release 编译 → 组装 .app → ad-hoc 签名 → dist/LocalShare.app -open "dist/LocalShare.app" # 本机自测 GUI - -# 无头端到端测试(无 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 - -# 命令行调用(argv 路径;改 CLI/HeadlessServer 后务必用 release 复测,有已注释的 -O 编译器坑) -.build/debug/LocalShare --headless /path/a.html /path/dir # 前台起服务,打印 LS_URL + 终端二维码 -.build/debug/LocalShare a.html b.pdf # 转发给 GUI app(拉起或复用实例) -ln -s "$PWD/dist/LocalShare.app/Contents/MacOS/LocalShare" /tmp/localshare # symlink 冒烟 -/tmp/localshare --version # 不崩 = dyld 经 realpath 找到包内 Sparkle - -# 验证核心戒律:过滤系统库后,剩余依赖只能是 @rpath 引用、且对应 framework 确在包内 -# Contents/Frameworks(如 Sparkle.framework);出现任何绝对路径包外 dylib 即违规。 -# build.sh / CI 已内置同款逐条校验,这里是手动复核: -otool -L "dist/LocalShare.app/Contents/MacOS/LocalShare" | grep -v "/usr/lib/\|/System/Library/" -# 期望:仅 @rpath/Sparkle.framework/... ;ls dist/LocalShare.app/Contents/Frameworks 应见 Sparkle.framework -``` - -测试分两层:`swift test`(XCTest,`Tests/LocalShareTests/`,测纯函数——防穿越判据 `resolveWithinRoot`、上传文件名清洗 `sanitizeFileName`、多选 key 去重 `Share.makeItems`、重名兜底 `availableURL` 等;用 `@testable import LocalShare` 直接碰可执行 target 的 internal 符号,无须拆 library)+ 无头 `curl` 冒烟测(`tools/smoke-*.sh` 与 `smoke-*.cjs`,端到端验 token 校验与 302 清洗、防穿越与单文件隔离、中文/空格/百分号文件名解码、多选虚拟根路由、上传去势、网卡绑定、Markdown 链接卫生)。两层都由 `.github/workflows/ci.yml` 在每个 PR / master push 上自动跑(`release.yml` 只管发版,互不干扰)。要求 macOS 13+ 与 Swift 工具链。 - -## 架构 - -> 本节是导航图,完整请求流程 / 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` 负责生命周期——**冷启动不自动重播**:落主页、不自动起文件服务(开 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 并 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`(预览壳页共用骨架)+ `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` 固定地址,发布对仓库零写入)。详见 `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」规则互不相干。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` 等)一律禁止**——这类运行时缺库正是「换台机器就崩」的根源。新增/改依赖后务必跑上面的 `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 语言模式正是为放宽这里的并发检查。 -- **`.raw` 响应无 keep-alive**(body length 未知,发完即关),所以文件响应主动写 `Content-Length`(让手机显示进度)。LAN 静态分发无 keep-alive 足够。 - -## 强约束 - -- 设计功能模块时,如果能通过设计语言暗示用户的,就不要加过多文案。如果非要加文案,注意检查不要使用非常见词组和造句方式。 +See [AGENTS.md](./AGENTS.md) From f72286a29c3fcc4ee023f3795b2a1cd881a6fce2 Mon Sep 17 00:00:00 2001 From: shawn Date: Tue, 30 Jun 2026 20:20:25 +0800 Subject: [PATCH 5/6] chore: ignore local agent directories --- .gitignore | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.gitignore b/.gitignore index 73d60bf..fd0c45d 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,6 @@ AppIcon.iconset/ private_key.txt # 编辑器临时文件 *.swp + +.claude/ +.codex/ From 69528b9d5910c5cd4f877072bf9c15ebff21f628 Mon Sep 17 00:00:00 2001 From: shawn Date: Tue, 30 Jun 2026 20:42:46 +0800 Subject: [PATCH 6/6] docs: document bilingual changelog updates --- AGENTS.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/AGENTS.md b/AGENTS.md index a407cb8..849fb67 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -90,6 +90,8 @@ Utility modules have narrow responsibilities: `NetworkInfo`, `QRCode`, `Token`, The version is defined by git tags: `vX.Y.Z` on `master` tip. `bundle/Info.plist` contains placeholder values; CI overwrites `CFBundleShortVersionString` from the tag and `CFBundleVersion` from the run number. Do not edit `Info.plist` just to release a version. +When updating changelogs, update both `CHANGELOG.md` and `CHANGELOG_CN.md` in the same change so the English and Chinese histories stay aligned. + Release flow: 1. Merge all changes to `master`.