From 688a6ec8921315ad15f375bb3081a8b2bc4a9510 Mon Sep 17 00:00:00 2001 From: shawn Date: Sat, 27 Jun 2026 18:15:56 +0800 Subject: [PATCH 1/2] =?UTF-8?q?feat:=20=E5=88=86=E4=BA=AB=E6=96=87?= =?UTF-8?q?=E4=BB=B6=E4=BA=8C=E7=BA=A7=E9=A1=B5=E7=BB=9F=E4=B8=80=20+=20?= =?UTF-8?q?=E5=86=B7=E5=90=AF=E5=8A=A8=E4=B8=8D=E8=87=AA=E5=8A=A8=E9=87=8D?= =?UTF-8?q?=E6=92=AD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 文件票据降为带返回的二级页(Screen.file),头部换成 ← + 「分享文件」+ ⚙,与传递文本同款;品牌名只留主页 - 主页新增「正在分享」横幅:文件后台续跑时一键回二维码(运行缀呼吸点、停止显静默) - 冷启动不再自动重播上次分享,开 app 落主页;上次分享留「最近分享」一键重发(对齐文本「重启不自动重播」的安全姿态) - 只关窗不退出仍服务续播、唤回即回原状——界面如实反映「服务是否还活着」 - 无网络页补返回按钮;传递文本入口呼吸点判据收紧,免得跑文件时误亮 - 退役仅供冷启动恢复的 lastSharedPaths / lastFolderPath 键 - PLAN.md 记入新路由模型与冷启动安全姿态 --- PLAN.md | 22 ++++-- Sources/LocalShare/AppState.swift | 39 ++++------ Sources/LocalShare/ContentView.swift | 103 ++++++++++++++++++++------- Sources/LocalShare/Lang.swift | 2 + 4 files changed, 110 insertions(+), 56 deletions(-) diff --git a/PLAN.md b/PLAN.md index f98ac16..d794705 100644 --- a/PLAN.md +++ b/PLAN.md @@ -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 冻结,仅供老客户端迁移) | @@ -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` 末尾消费。 @@ -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` 解码,且不影响防穿越。 diff --git a/Sources/LocalShare/AppState.swift b/Sources/LocalShare/AppState.swift index 8e98be1..c480e3b 100644 --- a/Sources/LocalShare/AppState.swift +++ b/Sources/LocalShare/AppState.swift @@ -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 与 @@ -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 // 主界面是否展示「最近分享」模块(持久化) @@ -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" @@ -92,20 +92,12 @@ 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 } AppState.shared = self // 消费早到的 open 事件(CLI 冷启动时可能先于本 init 到达),覆盖上面恢复的旧分享。 @@ -198,10 +190,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() } } @@ -338,7 +328,7 @@ final class AppState: ObservableObject { } UserDefaults.standard.removeObject(forKey: sharedTextKey) // 撤下即清,不在磁盘残留口令 stop() // 停服务:端口归零、token 轮换作废所有旧链接/cookie/二维码 - screen = .share // 回功能选择页(此时无任何分享 → EmptyScreen) + screen = .share // 回功能主页(此时无任何分享 → HomeScreen) } // 「记住收到的文本」开关。开:立即把当前收件箱落盘;关:抹掉磁盘留存(内存当次仍在,退出即忘)。 @@ -494,8 +484,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 { @@ -602,7 +590,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) { diff --git a/Sources/LocalShare/ContentView.swift b/Sources/LocalShare/ContentView.swift index 39a297f..8ee4715 100644 --- a/Sources/LocalShare/ContentView.swift +++ b/Sources/LocalShare/ContentView.swift @@ -3,8 +3,9 @@ import AppKit import UniformTypeIdentifiers // 单窗口 UI(票据风)。权威规范见 DESIGN.md。窗口为无边框工具窗(红绿灯浮于内容左上), -// 内容收成约 420 宽的竖列,顶部留 40 给红绿灯。屏幕路由:分享 / 设置 / 历史;分享屏据状态再分 -// 空状态 / 单文件票据 / 文件夹票据 / 未接入网络。主题随系统浅深切换(Theme.make)。 +// 内容收成约 420 宽的竖列,顶部留 40 给红绿灯。屏幕路由:主页 / 文件票据 / 传递文本 / 设置 / 历史—— +// 文件票据与传递文本同为带返回的二级页;主页在文件后台续跑时挂「正在分享」横幅一键回票据。 +// 主题随系统浅深切换(Theme.make)。 struct ContentView: View { @EnvironmentObject var state: AppState @Environment(\.colorScheme) private var systemScheme @@ -51,15 +52,12 @@ struct ContentView: View { case .history: HistoryScreen(t: t) case .share: - // 分享屏只管文件:没有文件时即功能选择页(拖拽分享 / 传递文本),有文件才出票据。 - // 文本收发是独立的二级页(.text),不再挤进这里。 - if state.sharedItems.isEmpty { - EmptyScreen(t: t, dragging: isDropTargeted) - } else if !state.hasNetwork { - NoNetworkScreen(t: t) - } else { - ShareScreen(t: t) - } + // 功能主页(launchpad):拖拽/选择分享文件、传递文本入口、最近分享。文件分享在后台续跑时 + // 顶部出「正在分享」横幅一键回票据(见 HomeScreen)。文件票据本身是独立二级页 .file。 + HomeScreen(t: t, dragging: isDropTargeted) + case .file: + // 文件二维码票据(二级页,带返回)。无网络时换未接入网络页(同样带返回)。 + if state.hasNetwork { ShareScreen(t: t) } else { NoNetworkScreen(t: t) } case .text: TextScreen(t: t) } @@ -303,10 +301,18 @@ private final class PlaceholderTextView: NSTextView { // MARK: - 空状态 -private struct EmptyScreen: View { +private struct HomeScreen: View { let t: Theme var dragging: Bool @EnvironmentObject var state: AppState + // 横幅标题:单项=文件/夹名(中段截断),多选=「N 项」(与票据 multipleStub 标题一致)。 + private var activeShareName: String { + state.isMultiple ? LStr.itemCount(state.sharedItems.count, state.lang) + : (state.sharedURL?.lastPathComponent ?? "") + } + // 「传递文本」入口的呼吸点只该表「文本在后台续跑」,故精确判文本态——而非笼统的 isRunning + //(现在主页可能正跑着文件分享,那不该让文本入口误亮)。 + private var textActive: Bool { state.isRunning && (state.hasText || state.textInboxEnabled) } var body: some View { let ps = permSummary(state.permission, state.lang) ScreenFrame(t: t) { @@ -316,8 +322,8 @@ private struct EmptyScreen: View { Text("LocalShare").font(.display(28, .semibold)).tracking(-0.3).foregroundStyle(t.ink) } Spacer() - // 选择页通常是「待命」;但传递文本可在后台续跑(从文本页 ← 退回来时),此刻如实显运行态 - // (亮点 + 实际 IP:端口),不再骗「待命」。要彻底停在文本页点「停止」。 + // 主页通常「待命」;但文件分享 / 传递文本可在后台续跑(退回主页时),此刻如实显运行态 + //(亮点 + 实际 IP:端口),不再骗「待命」。要彻底停就进对应票据点「停止」。 if state.isRunning { StatusPill(t: t, running: true, host: state.selectedInterface?.ip, port: state.port) } else { @@ -327,10 +333,16 @@ private struct EmptyScreen: View { } } content: { VStack(spacing: 0) { + // 文件分享在后台续跑、用户退回主页时,顶部出可点横幅一键回票据;停止未清除也显(静默态)。 + if !state.sharedItems.isEmpty { + ActiveShareBanner(t: t, lang: state.lang, name: activeShareName, + running: state.isRunning, viewers: state.viewerCount) { state.enterFile() } + .padding(.bottom, 12) + } dropZone // 平级第二入口:传递文本(收/发合一)。点进独立二级页,主页只负责选功能、不就地干活。 - // 收件箱有未读时角标提示;传递在后台续跑时缀呼吸点(active=isRunning,此屏必无文件分享)。 - TransferTextButton(t: t, lang: state.lang, active: state.isRunning, + // 收件箱有未读时角标提示;文本在后台续跑时缀呼吸点(见 textActive)。 + TransferTextButton(t: t, lang: state.lang, active: textActive, unread: state.unreadReceived) { state.openText() } .padding(.top, 12) if state.showRecents { @@ -376,13 +388,12 @@ private struct ShareScreen: View { var body: some View { let ps = permSummary(state.permission, state.lang) ScreenFrame(t: t) { - HStack(spacing: 8) { - Text("LocalShare").font(.display(22, .semibold)).tracking(-0.2).foregroundStyle(t.ink) - .lineLimit(1).minimumScaleFactor(0.7) - Spacer(minLength: 8) - StatusPill(t: t, running: state.isRunning, host: state.selectedInterface?.ip, - port: state.isRunning ? state.port : state.configuredPort) - .layoutPriority(1) // IP:端口是数据,缺宽时让品牌标题先缩(它有 minimumScaleFactor),地址不被截断 + // 二级页头部,与传递文本页同款:← 返回主页 + 标题 +齿轮。运行态/地址在票据正文(QR 说明 + + // CopyPill)已具,头部不再堆 StatusPill,避免与正文重复、并与 TextScreen 一致。 + HStack(spacing: 10) { + IconButton(t: t, systemImage: "chevron.left", help: L.back(state.lang)) { state.goShare() } + Text(L.shareFileTitle(state.lang)).font(.display(21, .semibold)).foregroundStyle(t.ink) + Spacer() IconButton(t: t, systemImage: "gearshape", help: L.settings(state.lang)) { state.openSettings() } } } content: { @@ -865,6 +876,45 @@ private struct TransferTextButton: View { } } +// 主页「正在分享」横幅:文件分享在后台续跑、用户却退回主页时,用一条紧凑可点的横幅如实呈现—— +// 点按回到文件票据(.file)看二维码。运行中缀呼吸点 + 在线人数;停止未清除时静默(无呼吸点)。 +// 与 TransferTextButton 同手法,但承载更多信息故略高;前导 qrcode 图标暗示「点这里回到码」, +// 配尾部 chevron 即足以表达「可点回去」,不另加文案(强约束:能用设计语言暗示就不堆字)。 +private struct ActiveShareBanner: View { + let t: Theme + let lang: Lang + let name: String // 单项=文件/夹名;多选=「N 项」 + let running: Bool + let viewers: Int + let action: () -> Void + @State private var hover = false + var body: some View { + Button(action: action) { + HStack(spacing: 11) { + Image(systemName: "qrcode").font(.system(size: 16, weight: .medium)).foregroundStyle(t.accent) + VStack(alignment: .leading, spacing: 1) { + HStack(spacing: 6) { + Text(L.sharingKicker(lang)).font(.sans(10.5, .bold)).tracking(0.6).foregroundStyle(t.inkMute) + if running { PulsingDot(color: t.ok) } + if running && viewers > 0 { + Text(LStr.viewerCountLabel(viewers, lang)).font(.sans(10.5)).foregroundStyle(t.inkMute) + } + } + Text(name).font(.sans(13.5, .semibold)).foregroundStyle(t.ink) + .lineLimit(1).truncationMode(.middle) + } + Spacer(minLength: 8) + Image(systemName: "chevron.right").font(.system(size: 12, weight: .semibold)).foregroundStyle(t.inkFaint) + } + .padding(.horizontal, 14).frame(height: 52) + .background(RoundedRectangle(cornerRadius: 12, style: .continuous).fill(hover ? t.surfaceAlt : t.surface)) + .overlay(RoundedRectangle(cornerRadius: 12, style: .continuous) + .strokeBorder(hover ? t.lineStrong : t.line, lineWidth: 1)) + } + .buttonStyle(.plain).onHover { hover = $0 } + } +} + // MARK: - 传递文本二级页(收/发合一) // 一页一码:上半发文本(编辑器 + 发送/更新/撤回),中间一个二维码恒指 /ls/text,下半是「允许收文本」 @@ -1043,10 +1093,11 @@ private struct NoNetworkScreen: View { @EnvironmentObject var state: AppState var body: some View { ScreenFrame(t: t) { - HStack { - Text("LocalShare").font(.display(22, .semibold)).tracking(-0.2).foregroundStyle(t.ink) + // 现挂在文件票据二级页(.file)下,带 ← 返回主页,免得无网络时卡死在此页。 + HStack(spacing: 10) { + IconButton(t: t, systemImage: "chevron.left", help: L.back(state.lang)) { state.goShare() } + Text(L.shareFileTitle(state.lang)).font(.display(21, .semibold)).foregroundStyle(t.ink) Spacer() - StatusPill(t: t, running: false, port: state.configuredPort) IconButton(t: t, systemImage: "gearshape", help: L.settings(state.lang)) { state.openSettings() } } } content: { diff --git a/Sources/LocalShare/Lang.swift b/Sources/LocalShare/Lang.swift index 1a7fa6c..91ba484 100644 --- a/Sources/LocalShare/Lang.swift +++ b/Sources/LocalShare/Lang.swift @@ -85,6 +85,7 @@ enum L: CaseIterable { case dropToShare, dropHint, dropZoneTitle, dropZoneSub, pickAnyButton // 分享屏 + case shareFileTitle // 文件票据二级页标题(与「传递文本」并列) case received, changePerm, broadcastStopped, selectSource case sharingKicker, sharingFolderKicker case scanCaptionMultiple, scanCaptionFile, scanCaptionFolder @@ -217,6 +218,7 @@ enum L: CaseIterable { case .dropZoneSub: return ("同一 Wi-Fi 下的设备即可扫码访问", "Devices on the same Wi-Fi can scan to access") case .pickAnyButton: return ("选择文件或文件夹", "Choose Files or Folders") + case .shareFileTitle: return ("分享文件", "Share Files") case .received: return ("新收到", "Received") case .changePerm: return ("改权限 ›", "Permissions ›") case .broadcastStopped: return ("已停止广播", "Broadcast stopped") From 1a5a29c053ef48832505320254ee66c7b566c394 Mon Sep 17 00:00:00 2001 From: shawn Date: Sat, 27 Jun 2026 18:58:34 +0800 Subject: [PATCH 2/2] =?UTF-8?q?refactor:=20=E6=B8=85=E7=90=86=E5=86=B7?= =?UTF-8?q?=E5=90=AF=E5=8A=A8=E9=87=8D=E6=9E=84=E9=81=97=E7=95=99=E7=9A=84?= =?UTF-8?q?=E5=A4=B1=E7=9C=9F=E6=B3=A8=E9=87=8A=E4=B8=8E=E6=AD=BB=E5=88=A4?= =?UTF-8?q?=E6=8D=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - init 落地屏判据去掉恒为真的 sharedItems.isEmpty 子条件(冷启动不恢复分享,此处必空) - 修正 pendingOpenURLs 注释:不再「覆盖恢复的旧分享」(恢复块已删),改为据 open 事件落文件票据 --- Sources/LocalShare/AppState.swift | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/Sources/LocalShare/AppState.swift b/Sources/LocalShare/AppState.swift index c480e3b..6e10f9a 100644 --- a/Sources/LocalShare/AppState.swift +++ b/Sources/LocalShare/AppState.swift @@ -97,10 +97,11 @@ final class AppState: ObservableObject { // 唤回窗口即回到原状——那条路径不经过本 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 = []