Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 17 additions & 5 deletions PLAN.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,8 +30,8 @@
| 协议 | 明文 http。威胁模型:防「猜地址的路人」(52 bit token),不防同网嗅探与持链者转发——后者的风险窗口随 token 轮换收敛到单次分享内。自签证书会把扫码进门变成手机上的证书警告页,伤害核心体验,不做(0.6 加入上传后内容不再纯静态,重新评估过,结论不变) |
| 二维码地址 | 裸 LAN IP(智能选接口、多候选给下拉);窗口另显 `.local` 备选链接 + 可复制 URL |
| 二维码生成 | CoreImage `CIQRCodeGenerator`,无第三方库 |
| GUI | 单窗口:大二维码居中 + 可点/复制 URL + 当前文件夹/更换 + 启停状态 + 接口下拉 + “打不开?”排错行 |
| 生命周期 | 记住上次文件夹 · 开 app 自动起服务 · 端口自动选(占用则换)· 退出停服务 |
| 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 冻结,仅供老客户端迁移) |
Expand Down Expand Up @@ -118,13 +118,18 @@ lan-file-share/
- `CIFilter.qrCodeGenerator()`,`message = url.data(.utf8)`,`correctionLevel = "M"`;`CGAffineTransform` 放大(避免糊);`CIContext` → `CGImage` → `NSImage`。

### AppState / 生命周期
- `folderURL` 持久化到 `UserDefaults`(非沙盒,存路径字符串即可,无需 security-scoped bookmark)。
- 启动时若有记住的文件夹 → 自动 start
- 最近分享持久化到 `UserDefaults`(`recentShares`,非沙盒存路径字符串即可,无需 security-scoped bookmark)。
- **冷启动不恢复分享**:init 不再把上次分享读回 `sharedItems`(删了 `lastSharedPaths` / 旧 `lastFolderPath` 两个仅供恢复的键),落主页、不自动起文件服务——开 app 把某文件夹悄悄端上 LAN 是隐患,与文本「重启不自动重播」同姿态;上次分享留在「最近分享」一键重发。收件箱是显式开关,仍自动起服务(并落地传递文本页)。只**关窗不退出**时进程与服务都续活、`@StateObject` 整进程只建一次,唤回窗口即回离开时那一屏——那条路径不过 init,故 init 只管「真退出后重开」这一冷启动(界面如实反映「服务是否还活着」)
- 端口选择:偏好列表 `[8080, 8000, 8888, 9000]` 逐个 try start,全失败再随机 49152–65535。
- 选目录用 `NSOpenPanel`(`canChooseDirectories = true`)。
- 选目录/文件用 `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` 末尾消费。
Expand Down Expand Up @@ -269,6 +274,13 @@ open dist/LocalShare.app # 本机自测
· **手机端**:发送页「已发送」历史每条缀紧凑相对时间(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` 解码,且不影响防穿越。
Expand Down
44 changes: 17 additions & 27 deletions Sources/LocalShare/AppState.swift
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,12 @@ import SwiftUI

// 全局状态:当前分享对象(文件夹或单个文件)、服务运行态、网络接口候选、二维码 URL、
// 监听端口配置、最近分享历史、屏幕路由、权限(读取常开,上传可切换)。
// 负责:选目录/选文件、启停服务、端口配置 + 应用(重启服务)、记住上次分享并自动启动
// 负责:选目录/选文件、启停服务、端口配置 + 应用(重启服务)、把分享记入最近分享(冷启动不自动重播)
@MainActor
final class AppState: ObservableObject {
enum Screen { case share, text, settings, history }
// 屏幕路由。.share = 功能主页(launchpad:拖拽分享入口 + 传递文本入口 + 最近分享);
// .file = 文件二维码票据(二级页,带返回)。文本/设置/历史各为带返回的二级页。
enum Screen { case share, file, text, settings, history }
enum AppearancePref: String { case system, light, dark }

// 设计默认窗口尺寸(票据风竖窗,设计稿 400×720)。供 App 的 .defaultSize 与
Expand Down Expand Up @@ -35,7 +37,7 @@ final class AppState: ObservableObject {
@Published var configuredPort: in_port_t = 8080 // 用户期望端口(设置页可改,持久化)
@Published var bindSelectedOnly = false // 仅绑选中网卡(默认关=绑全部接口,持久化)
@Published var recents: [RecentShare] = [] // 最近分享(持久化)
@Published var screen: Screen = .share // 屏幕路由(分享 / 设置 / 历史
@Published var screen: Screen = .share // 屏幕路由(默认落功能主页
@Published var appearance: AppearancePref = .system // 外观:跟随系统 / 浅色 / 深色(持久化)
@Published var langPref: LangPref = .system // 语言:跟随系统 / 中文 / English(持久化)
@Published var showRecents = true // 主界面是否展示「最近分享」模块(持久化)
Expand All @@ -53,8 +55,6 @@ final class AppState: ObservableObject {
private var server: FileServer?
private var viewerTimer: Timer?

private let sharedDefaultsKey = "lastFolderPath" // 旧版单值键(迁移回退用,新写入走 sharedPathsKey)
private let sharedPathsKey = "lastSharedPaths" // 当前分享的项路径数组(支持多选)
private let portKey = "configuredPort"
private let bindSelectedOnlyKey = "bindSelectedOnly"
private let recentsKey = "recentShares"
Expand Down Expand Up @@ -92,23 +92,16 @@ final class AppState: ObservableObject {
if persistReceivedText { loadReceivedTexts() }
loadRecents()
refreshNetwork()
// 恢复上次分享对象,让同事开 app 就能看到二维码。
// 多选存为路径数组(新键);读不到再回退旧单值键(迁移)。缺失的项自动剔除,剩 ≥1 即恢复。
var restorePaths = UserDefaults.standard.stringArray(forKey: sharedPathsKey)
?? UserDefaults.standard.string(forKey: sharedDefaultsKey).map { [$0] }
?? []
restorePaths = restorePaths.filter { FileManager.default.fileExists(atPath: $0) }
if !restorePaths.isEmpty {
sharedItems = restorePaths.map { URL(fileURLWithPath: $0) }
updateSharedIsFile()
describeShared()
}
// 有任何理由起服务(分享内容或收件箱开着)即自动启动。
// 冷启动**不**自动重播上次文件分享:开 app 就把某文件夹悄悄端上 LAN 是隐患(同文本「重启不自动
// 重播」的安全姿态,见上)。上次分享留在「最近分享」一键重发。只关窗口不退出时进程与服务都续活,
// 唤回窗口即回到原状——那条路径不经过本 init,故此处只管「真退出后重开」这一冷启动。
// 收件箱是用户显式开的闸门,仍自动起服务。
if isServing { start() }
// 启动落地屏:有文件→分享页(默认);无文件但收件箱开着→传递文本页(接收已就绪一眼可见)。
if sharedItems.isEmpty && textInboxEnabled { screen = .text }
// 启动落地屏:默认功能主页;收件箱开着则落传递文本页(接收已就绪一眼可见)。
// 冷启动不恢复分享,故此处 sharedItems 必空、无须再判(CLI open 的 setShared 在其后才跑、会改落 .file)。
if textInboxEnabled { screen = .text }
AppState.shared = self
// 消费早到的 open 事件(CLI 冷启动时可能先于本 init 到达),覆盖上面恢复的旧分享
// 消费早到的 open 事件(CLI 冷启动时可能先于本 init 到达):有则据此分享、落文件票据
if !AppDelegate.pendingOpenURLs.isEmpty {
let urls = AppDelegate.pendingOpenURLs
AppDelegate.pendingOpenURLs = []
Expand Down Expand Up @@ -198,10 +191,8 @@ final class AppState: ObservableObject {
updateSharedIsFile()
describeShared()
resetUpload() // 换分享内容即回到只读(安全默认),收件提示一并清空
UserDefaults.standard.set(urls.map(\.path), forKey: sharedPathsKey)
UserDefaults.standard.removeObject(forKey: sharedDefaultsKey) // 清理旧单值键
recordRecent()
screen = .share
screen = .file // 进文件票据二级页(带返回);冷启动不恢复,故只需记入最近分享
if isRunning { pushToServer() } // 运行中不重启(端口不变)
else { start() }
}
Expand Down Expand Up @@ -338,7 +329,7 @@ final class AppState: ObservableObject {
}
UserDefaults.standard.removeObject(forKey: sharedTextKey) // 撤下即清,不在磁盘残留口令
stop() // 停服务:端口归零、token 轮换作废所有旧链接/cookie/二维码
screen = .share // 回功能选择页(此时无任何分享 → EmptyScreen
screen = .share // 回功能主页(此时无任何分享 → HomeScreen
}

// 「记住收到的文本」开关。开:立即把当前收件箱落盘;关:抹掉磁盘留存(内存当次仍在,退出即忘)。
Expand Down Expand Up @@ -494,8 +485,6 @@ final class AppState: ObservableObject {
sharedText = nil // 撤下当前广播的文本
textDraft = "" // 「清除」是彻底复位:连草稿一起清,不在内存里留着上次文本
resetUpload()
UserDefaults.standard.removeObject(forKey: sharedPathsKey)
UserDefaults.standard.removeObject(forKey: sharedDefaultsKey)
UserDefaults.standard.removeObject(forKey: sharedTextKey) // 不在磁盘上残留文本(同「撤下即清」)
screen = .share
if isServing {
Expand Down Expand Up @@ -602,7 +591,8 @@ final class AppState: ObservableObject {

func openSettings() { screen = .settings }
func openHistory() { screen = .history }
func goShare() { screen = .share }
func goShare() { screen = .share } // 回功能主页(launchpad)
func enterFile() { screen = .file } // 进文件票据二级页(二维码 + 操作)
func openText() { screen = .text } // 进传递文本二级页(收/发合一)

func setAppearance(_ a: AppearancePref) {
Expand Down
Loading
Loading