From 127070e226dfbc5582d379d16573994b40436b0e Mon Sep 17 00:00:00 2001 From: commilitia Date: Sat, 27 Jun 2026 03:50:04 +0800 Subject: [PATCH] =?UTF-8?q?=E8=AE=BE=E5=A4=87=E5=90=8D=E4=B8=8E=E4=BB=A4?= =?UTF-8?q?=E7=89=8C=E8=A7=A3=E8=80=A6=20+=20iOS=20Broker=20=E5=BA=94?= =?UTF-8?q?=E7=94=A8=E5=86=85=E7=99=BB=E5=BD=95=20/=20=E6=B6=88=E6=81=AF?= =?UTF-8?q?=20/=20=E5=9B=BE=E5=BA=93=20/=20=E8=AE=B0=E5=BD=95=E6=8C=81?= =?UTF-8?q?=E4=B9=85=E5=8C=96=20+=20=E4=B8=89=E7=AB=AF=20bug=20=E4=BF=AE?= =?UTF-8?q?=E5=A4=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 设备名与令牌解耦(presence 按 device_id 键控): - hub 主键改稳定 device_id(presence / online / kick 用之),peer 路由仍按设备名经 hub 内 clientFor 解析——不动传输核心;Client 加 Name + 连接键,PresenceDevice 加 device_id,sse 传 claims.DeviceID,publishPresence 按 device_id 判在线、name 取自 devices 表 - 改名走已有 PATCH /api/devices/{device_id}(不重铸会话、不换 token、不中断 SSE)+ hub.Rename 更新活跃连接名 + 重广播 presence,全端即时无重复项;sessions 列表 name 用 devices 表覆盖 broker Label;web 改名从重铸迁到 PATCH,iOS 设置页即时改名;DeviceItem / DeviceInfo 按 device_id 键控判本机 - 移除「需移除两次」:publishPresence 不再把「有 device_id 但无 devices 行」的 live 连接当在线(吊销后用未过期 token 重连的僵尸),只显示 code-less 连接;合法重登新建行故正常 - revoke 对无 broker 会话的本地行也清(清掉合成测试 / 幽灵设备);跨端吊销 / 断连一律按 device_id Kick iOS Broker 应用内登录(免扫码): - BrokerLogin.swift:ASWebAuthenticationSession 跑 broker /device/authorize + /device/token 的 PKCE 设备授权流(对齐桌面 loopback,自定义 scheme cdrop://auth-callback 回调)→ /api/auth/device-session 代铸出带真名 + 稳定 device_id 的设备会话;DeviceIDStore 跨登录复用 device_id 杜绝重登重复 - LoginView 主登录改 broker 账号登录、扫码降级为可展开备选 iOS 功能补全: - 设备间消息(引擎 main.ts 单条增量过桥 + onNativeEvent sendMessage;EngineController 消息状态 + 新 MessagesView 会话 UI;后端 /api/message 已就绪) - 图库发送(PhotosPicker 文件 / 图库菜单 → stagePhotoData → 复用 cdrop-file 发送链路;NSPhotoLibraryUsageDescription) - 传输历史 / 消息记录持久化(RecordsStore 落 Application Support JSON,跨重启存活)+ 长按删除 + 清空(二次确认) - 进行中传输主动取消 / 立即切中继(详情页两按钮,复用引擎桥命令) - 身份愈合(引擎 /api/me 取真实显示名经 identityUpdated 回报 Keychain,修扫码登录「用户显示 UUID」) - 触发本地网络权限(NWBrowser 浏览 _cdrop._tcp)使 WKWebView WebRTC 取 host 候选 iOS 发送 / 启动修复: - 发送在读文件即失败:引擎在 https 源下 fetch("cdrop-file://") 跨源 + Range 头触发 CORS 预检被拦 → 改走原生桥 bridgeFileSource / readFileSlice(512 KiB 分块 base64),绕开 CORS、整文件仍不整体进内存 - 冷启重开「missing injected session」:EngineWebView.makeUIView 在 boot 注入前确定性接好 auth,消除注入竞态 桌面 bug 修复: - 登出静默失败:window.confirm 在 Wails WebView 失效(返回 falsy 致动作被静默跳过)→ 跨平台 DOM 确认弹窗(utils/confirm 的 Promise 式 confirmDialog + ui/ConfirmHost 的 Mantine Modal)取代之;登出改 await 先吊销 broker 会话再清本地(桌面 clearDesktopSession 会丢 Go 侧 refresh 的竞态) - 扫 iOS 登录二维码被拒「不是本站的码」:wails:// 源永不等 https 站点源 → 桌面放宽 parseLinkApprovalUrl 的 origin 等值校验(仍留 /link 路径 + r/c;批准 r/c 一律发往本端后端、外站码只 404,无开放重定向) - macOS 本地网络权限触发(localnetwork_darwin NWBrowser + Info.plist 键);相机修复(NSCameraUsageDescription) web / 跨端: - presence 透传 device_id + 离线检测按 device_id;会话列表随 presence 防抖重拉(已登出设备自动从列表消失,不再需手动刷新);移除设备 / 清剪贴板 / 撤销令牌 / 登出均改 DOM 确认弹窗 分发 / 签名: - 真机签名从手动 scaffold 改自动 provisioning(删 Signing.xcconfig,project.yml 去 profile specifier,just ios-device 用 -allowProvisioningUpdates + ASC 团队 key);App Group 改名 group.net.commilitia.Commilitia-Drop(含 entitlements) - hub 测试加 device_id 键控 + 改名解耦回归(-race) --- Justfile | 12 +- desktop/app.go | 3 + desktop/build/darwin/Info.dev.plist | 8 + desktop/build/darwin/Info.plist | 8 + desktop/platform/localnetwork_darwin.go | 21 ++ desktop/platform/localnetwork_darwin.m | 33 +++ desktop/platform/localnetwork_other.go | 6 + internal/httpapi/devices.go | 8 +- internal/httpapi/server.go | 2 +- internal/httpapi/sessions.go | 41 +++- internal/httpapi/sse.go | 5 +- internal/hub/hub.go | 136 +++++++++---- internal/hub/hub_test.go | 126 +++++++++--- ios/CDrop/.gitignore | 5 +- ios/CDrop/CDrop.entitlements | 2 +- ios/CDrop/REALDEVICE.md | 73 +++---- ios/CDrop/Share/CDropShare.entitlements | 2 +- ios/CDrop/Shared/AppGroup.swift | 2 +- ios/CDrop/Signing.xcconfig | 11 - ios/CDrop/Sources/Auth/AuthManager.swift | 176 +++++++++++++++- ios/CDrop/Sources/Auth/BrokerLogin.swift | 111 ++++++++++ ios/CDrop/Sources/Auth/LoginView.swift | 62 ++++-- ios/CDrop/Sources/DeviceNameStore.swift | 7 +- .../Sources/Engine/EngineController.swift | 190 +++++++++++++++++- ios/CDrop/Sources/Engine/EngineWebView.swift | 5 + ios/CDrop/Sources/Info.plist | 2 + .../Sources/LocalNetworkPermission.swift | 26 +++ ios/CDrop/Sources/MessagesView.swift | 164 +++++++++++++++ ios/CDrop/Sources/RecordsStore.swift | 33 +++ ios/CDrop/Sources/Resources/i18n/en-US.json | 16 +- ios/CDrop/Sources/Resources/i18n/zh-CN.json | 16 +- ios/CDrop/Sources/Resources/i18n/zh-TW.json | 16 +- ios/CDrop/Sources/RootView.swift | 121 ++++++++++- ios/CDrop/Widgets/CDropWidgets.entitlements | 2 +- ios/CDrop/project.yml | 16 +- ios/PLAN.md | 2 +- web/src/engine/main.ts | 114 +++++++++-- web/src/features/auth/auth.ts | 57 +++++- web/src/features/clipboard/ClipboardPanel.tsx | 3 +- web/src/features/shortcut/ShortcutTokens.tsx | 3 +- web/src/features/transfer/hub.ts | 8 +- web/src/i18n/locales/en-US.ts | 16 +- web/src/i18n/locales/zh-CN.ts | 16 +- web/src/i18n/locales/zh-TW.ts | 16 +- web/src/main.tsx | 2 + web/src/net/ios.ts | 56 ++++++ web/src/net/qr.ts | 11 +- web/src/routes/settings.tsx | 35 ++-- web/src/store/types.ts | 3 + web/src/ui/ConfirmHost.tsx | 31 +++ web/src/utils/confirm.ts | 42 ++++ 51 files changed, 1655 insertions(+), 227 deletions(-) create mode 100644 desktop/platform/localnetwork_darwin.go create mode 100644 desktop/platform/localnetwork_darwin.m create mode 100644 desktop/platform/localnetwork_other.go delete mode 100644 ios/CDrop/Signing.xcconfig create mode 100644 ios/CDrop/Sources/Auth/BrokerLogin.swift create mode 100644 ios/CDrop/Sources/LocalNetworkPermission.swift create mode 100644 ios/CDrop/Sources/MessagesView.swift create mode 100644 ios/CDrop/Sources/RecordsStore.swift create mode 100644 web/src/ui/ConfirmHost.tsx create mode 100644 web/src/utils/confirm.ts diff --git a/Justfile b/Justfile index bb81582..e62d26b 100644 --- a/Justfile +++ b/Justfile @@ -77,13 +77,15 @@ desktop-doctor: ios-devices: xcrun devicectl list devices -# 真机构建 + 装机(手动签名)。前置:付费 ADP;门户建好 App ID/App Group/Push/设备/profile; -# 按 ios/CDrop/REALDEVICE.md 建好 gitignore 的 ios/CDrop/Local.xcconfig(Team ID + 3 个 profile 名)。 -# 模拟器构建不需这些(base ad-hoc)。用法:just ios-device <设备UDID>(UDID 见 just ios-devices)。 +# 真机构建 + 装机(自动 provisioning,经 ASC API Key)。前置:付费 ADP;App ID 的 App Groups / +# Push 能力(门户建好或自动建);根目录 gitignore 的 .env 配齐:CDROP_TEAM_ID / CDROP_ASC_KEY_PATH +# (.p8 路径)/ CDROP_ASC_KEY_ID / CDROP_ASC_ISSUER_ID。-allowProvisioningUpdates 自动登记连接的 +# 设备 + 建 profile(绕过手动 profile 的设备选择坑)。模拟器构建不需这些(base ad-hoc)。 +# 用法:just ios-device <设备UDID>(UDID 见 just ios-devices)。 ios-device udid: - test -f ios/CDrop/Local.xcconfig || { echo "缺 ios/CDrop/Local.xcconfig——见 ios/CDrop/REALDEVICE.md"; exit 1; } + [ -n "$CDROP_TEAM_ID" ] && [ -n "$CDROP_ASC_KEY_PATH" ] && [ -n "$CDROP_ASC_KEY_ID" ] && [ -n "$CDROP_ASC_ISSUER_ID" ] || { echo "缺 .env(CDROP_TEAM_ID / CDROP_ASC_KEY_PATH / CDROP_ASC_KEY_ID / CDROP_ASC_ISSUER_ID)——见 ios/CDrop/REALDEVICE.md"; exit 1; } cd ios/CDrop && xcodegen generate - cd ios/CDrop && xcodebuild -project CDrop.xcodeproj -scheme CDrop -configuration Debug -destination 'generic/platform=iOS' -derivedDataPath build/DD-device build + cd ios/CDrop && xcodebuild -project CDrop.xcodeproj -scheme CDrop -configuration Debug -destination "platform=iOS,id={{udid}}" -derivedDataPath build/DD-device -allowProvisioningUpdates -authenticationKeyPath "$CDROP_ASC_KEY_PATH" -authenticationKeyID "$CDROP_ASC_KEY_ID" -authenticationKeyIssuerID "$CDROP_ASC_ISSUER_ID" DEVELOPMENT_TEAM="$CDROP_TEAM_ID" CODE_SIGN_STYLE=Automatic CODE_SIGN_IDENTITY="Apple Development" build xcrun devicectl device install app --device {{udid}} ios/CDrop/build/DD-device/Build/Products/Debug-iphoneos/CDrop.app # ---- deploy plumbing ---- diff --git a/desktop/app.go b/desktop/app.go index faa20fc..3ef4c3a 100644 --- a/desktop/app.go +++ b/desktop/app.go @@ -54,6 +54,9 @@ func (a *App) startup(ctx context.Context) { _ = platform.SetLaunchAtLogin(true) } a.startClipboardSync(ctx) + // 触发 macOS 本地网络权限(macOS 15+ 隐私门):使本进程 WKWebView 的 WebRTC 能收集 host / + // mDNS 候选、同内网走直连而非 prflx↔prflx 慢路径(见 platform.TriggerLocalNetwork)。其他平台空实现。 + platform.TriggerLocalNetwork() platform.InstallStatusBar( platform.StatusBarMenu{ Title: "cdrop", diff --git a/desktop/build/darwin/Info.dev.plist b/desktop/build/darwin/Info.dev.plist index dd09a71..b8cfbec 100644 --- a/desktop/build/darwin/Info.dev.plist +++ b/desktop/build/darwin/Info.dev.plist @@ -23,6 +23,14 @@ true NSHumanReadableCopyright {{.Info.Copyright}} + NSCameraUsageDescription + Commilitia Drop 使用相机扫描二维码以登录 / 批准设备。 + NSLocalNetworkUsageDescription + Commilitia Drop 使用本地网络发现同内网设备并建立点对点直连传输。 + NSBonjourServices + + _cdrop._tcp + {{if .Info.FileAssociations}} CFBundleDocumentTypes diff --git a/desktop/build/darwin/Info.plist b/desktop/build/darwin/Info.plist index a7409f7..bd6c26c 100644 --- a/desktop/build/darwin/Info.plist +++ b/desktop/build/darwin/Info.plist @@ -23,6 +23,14 @@ true NSHumanReadableCopyright {{.Info.Copyright}} + NSCameraUsageDescription + Commilitia Drop 使用相机扫描二维码以登录 / 批准设备。 + NSLocalNetworkUsageDescription + Commilitia Drop 使用本地网络发现同内网设备并建立点对点直连传输。 + NSBonjourServices + + _cdrop._tcp + {{if .Info.FileAssociations}} CFBundleDocumentTypes diff --git a/desktop/platform/localnetwork_darwin.go b/desktop/platform/localnetwork_darwin.go new file mode 100644 index 0000000..d7d9d08 --- /dev/null +++ b/desktop/platform/localnetwork_darwin.go @@ -0,0 +1,21 @@ +//go:build darwin + +package platform + +/* +#cgo darwin CFLAGS: -x objective-c -fobjc-arc +#cgo darwin LDFLAGS: -framework Network +#include + +void cdropTriggerLocalNetwork(void); +*/ +import "C" + +// TriggerLocalNetwork 在 macOS 上发起一次 Bonjour 浏览,触发「本地网络」权限请求(macOS 15+ / +// Sequoia 起也有本地网络隐私门)。未授权时本进程内的 WKWebView WebRTC 收集不到 host / mDNS 候选 +// → 同内网传输退到 prflx↔prflx 慢路径(实测桌面→iOS 仅几百 KB/s,而 Chrome 走 host 可达近 +// 10 MB/s)。授权后 WKWebView 才能拿到本地候选实现直连。仅 macOS 需要;Windows 无此门(见 +// localnetwork_other.go 的空实现)。幂等:原生侧保活单个 browser。 +func TriggerLocalNetwork() { + C.cdropTriggerLocalNetwork() +} diff --git a/desktop/platform/localnetwork_darwin.m b/desktop/platform/localnetwork_darwin.m new file mode 100644 index 0000000..3a07159 --- /dev/null +++ b/desktop/platform/localnetwork_darwin.m @@ -0,0 +1,33 @@ +#import + +// cdropTriggerLocalNetwork:起一个对 _cdrop._tcp 的 Bonjour 浏览,触发 macOS 本地网络权限弹窗。 +// WKWebView 自身不会触发该权限请求,须由宿主 App 主动发起一次本地网络访问;授权后本进程内的 +// WebRTC 才能收集 host / mDNS 候选实现同内网直连(见 localnetwork_darwin.go 注释)。浏览结果本身 +// 不关心——「发起访问」这一动作即触发授权。保活单个 browser(静态全局,ARC 下持有),幂等。 +static nw_browser_t gCdropBrowser = nil; + +void cdropTriggerLocalNetwork(void) { + if (gCdropBrowser != nil) { + return; + } + nw_browse_descriptor_t descriptor = + nw_browse_descriptor_create_bonjour_service("_cdrop._tcp", NULL); + nw_parameters_t parameters = nw_parameters_create(); + nw_parameters_set_include_peer_to_peer(parameters, true); + + nw_browser_t browser = nw_browser_create(descriptor, parameters); + nw_browser_set_queue(browser, dispatch_get_main_queue()); + nw_browser_set_browse_results_changed_handler( + browser, ^(nw_browse_result_t old_result, nw_browse_result_t new_result, bool batch_complete) { + // 不关心结果;浏览动作本身即触发权限请求。 + }); + nw_browser_set_state_changed_handler( + browser, ^(nw_browser_state_t state, nw_error_t error) { + // 进入 failed/cancelled 时释放,允许下次重试。 + if (state == nw_browser_state_failed || state == nw_browser_state_cancelled) { + gCdropBrowser = nil; + } + }); + gCdropBrowser = browser; + nw_browser_start(browser); +} diff --git a/desktop/platform/localnetwork_other.go b/desktop/platform/localnetwork_other.go new file mode 100644 index 0000000..6edd9b8 --- /dev/null +++ b/desktop/platform/localnetwork_other.go @@ -0,0 +1,6 @@ +//go:build !darwin + +package platform + +// TriggerLocalNetwork 仅 macOS 需要(本地网络隐私门)。Windows / Linux 无此门,空实现。 +func TriggerLocalNetwork() {} diff --git a/internal/httpapi/devices.go b/internal/httpapi/devices.go index 33587b5..2385fbe 100644 --- a/internal/httpapi/devices.go +++ b/internal/httpapi/devices.go @@ -39,7 +39,7 @@ func (s *Server) handleDevices(w http.ResponseWriter, r *http.Request) { Name: d.Name, Type: d.Type, Tier: d.Tier, - Online: s.hub.Online(claims.UserID, d.Name), + Online: s.hub.Online(claims.UserID, d.DeviceID), LastSeen: d.LastSeen, }) } @@ -105,5 +105,11 @@ func (s *Server) handleRenameDevice(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusNotFound, map[string]string{"error": "device not found"}) return } + // Decouple: the broker session (token) is untouched — only the device's display name + // changes. Update the live hub entry's name (kept keyed by the stable device_id) and + // re-broadcast presence so every peer sees the new name at once, with no re-login, no + // session re-mint, and no duplicate device row. + s.hub.Rename(claims.UserID, deviceID, name) + s.hub.PublishPresence(r.Context(), claims.UserID) w.WriteHeader(http.StatusNoContent) } diff --git a/internal/httpapi/server.go b/internal/httpapi/server.go index ad4d15b..b13c5d9 100644 --- a/internal/httpapi/server.go +++ b/internal/httpapi/server.go @@ -258,7 +258,7 @@ func (s *Server) handleDisconnect(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusBadRequest, map[string]string{"error": "missing device"}) return } - s.hub.Kick(claims.UserID, deviceName) + s.hub.Kick(claims.UserID, claims.DeviceID, deviceName) s.hub.PublishPresence(r.Context(), claims.UserID) w.WriteHeader(http.StatusNoContent) } diff --git a/internal/httpapi/sessions.go b/internal/httpapi/sessions.go index 3389db0..8432836 100644 --- a/internal/httpapi/sessions.go +++ b/internal/httpapi/sessions.go @@ -69,10 +69,16 @@ func (s *Server) handleSessionsList(w http.ResponseWriter, r *http.Request) { // logged-out device's row is reaped there, not on this read path — this GET stays // side-effect-free, and the session list itself is always R1-authoritative regardless of // any stale cache row (the row only ever supplies a type for a device that is in R1). + // device_id -> cached type + live name. The name overlay is what keeps the session list + // consistent after a decoupled rename: the broker Label is only set at mint time and does + // not follow a PATCH /api/devices rename, so we prefer the devices-table name (the same + // source presence uses) and fall back to the broker Label only for a row-less session. typeByID := map[string]string{} + nameByID := map[string]string{} if devs, err := s.queries.ListDevicesByUser(r.Context(), claims.UserID); err == nil { for _, d := range devs { typeByID[d.DeviceID] = d.Type + nameByID[d.DeviceID] = d.Name } } @@ -88,6 +94,10 @@ func (s *Server) handleSessionsList(w http.ResponseWriter, r *http.Request) { if typ == "" { typ = "browser" } + name := nameByID[sess.Meta] + if name == "" { + name = sess.Label + } scope := "full" if jwtauth.ScopeTier(sess.Scope) == "guest" { scope = "guest" @@ -95,11 +105,11 @@ func (s *Server) handleSessionsList(w http.ResponseWriter, r *http.Request) { out = append(out, sessionView{ ID: sess.Meta, DeviceID: sess.Meta, - DeviceName: sess.Label, + DeviceName: name, Kind: typ, Scope: scope, Current: sess.Meta == claims.DeviceID, - Online: s.hub.Online(claims.UserID, sess.Label), + Online: s.hub.Online(claims.UserID, sess.Meta), CreatedAt: sess.CreatedAt, LastUsedAt: sess.LastUsedAt, }) @@ -133,9 +143,11 @@ func (s *Server) handleSessionRevoke(w http.ResponseWriter, r *http.Request) { // pruned cache row still revokes correctly and stays authorized to this user. func (s *Server) revokeDevice(r *http.Request, userID, deviceID string) (int, bool) { sid, name := "", "" + localRowOwned := false if dev, err := s.queries.GetDevice(r.Context(), deviceID); err == nil && dev.UserID == userID { sid = dev.BrokerSid name = dev.Name + localRowOwned = true } if sid == "" { sessions, err := s.broker.ListSessions(r.Context(), userID) @@ -152,8 +164,22 @@ func (s *Server) revokeDevice(r *http.Request, userID, deviceID string) (int, bo } } if sid == "" { - // Not this user's device, or already gone from both the cache and the broker. - return http.StatusNotFound, false + // No broker session for this device_id. If we still own a local cache row, it is a stale / + // phantom entry — a synthetic test device (the Diag residue) or a row whose broker session + // is long gone. Drop the local row + kick + republish so the user can always clear such a + // device from their list; only a device_id we own nothing for is a genuine 404. + if !localRowOwned { + return http.StatusNotFound, false + } + if _, err := s.queries.DeleteDevice(r.Context(), db.DeleteDeviceParams{ + DeviceID: deviceID, + UserID: userID, + }); err != nil { + slog.Warn("delete phantom device cache failed", "err", err, "user", userID, "device", deviceID) + } + s.hub.Kick(userID, deviceID, name) + s.hub.PublishPresence(r.Context(), userID) + return http.StatusNoContent, true } // Revoke the broker session first so the device can't refresh; a 404 (already gone) is // idempotent success inside RevokeSession. @@ -169,9 +195,10 @@ func (s *Server) revokeDevice(r *http.Request, userID, deviceID string) (int, bo // and the next list-prune clean it). Report success so the client sees the logout. slog.Warn("delete device cache failed", "err", err, "user", userID, "device", deviceID) } - if name != "" { - s.hub.Kick(userID, name) - } + // Kick by the stable device_id (the hub key); name rides along for the code-less fallback + // path inside Kick. This makes cross-device revoke land reliably (the prior name-keyed Kick + // could miss a renamed device, leaving it able to keep refreshing — the "移除失败" symptom). + s.hub.Kick(userID, deviceID, name) s.hub.PublishPresence(r.Context(), userID) return http.StatusNoContent, true } diff --git a/internal/httpapi/sse.go b/internal/httpapi/sse.go index b1e2dcf..dbd9683 100644 --- a/internal/httpapi/sse.go +++ b/internal/httpapi/sse.go @@ -51,7 +51,10 @@ func (s *Server) handleEvents(w http.ResponseWriter, r *http.Request) { } flusher.Flush() - client := s.hub.Connect(r.Context(), claims.UserID, deviceName, deviceType) + // Key the hub entry by the stable device_id (X-Auth-Meta) when present, so a rename never + // orphans this connection; deviceName rides along for presence fallback + name addressing. + // A code-less browser (no device_id) falls back to name keying inside Connect. + client := s.hub.Connect(r.Context(), claims.UserID, claims.DeviceID, deviceName, deviceType) defer s.hub.Disconnect(client) slog.Debug("sse connected", "user", claims.UserID, "device", deviceName) diff --git a/internal/hub/hub.go b/internal/hub/hub.go index af7fd96..76bd7f6 100644 --- a/internal/hub/hub.go +++ b/internal/hub/hub.go @@ -23,8 +23,10 @@ type Event struct { // PresenceDevice is the per-device entry in a `presence` event payload. // Field names align with brief §5: { name, type, online }; last_seen 是 -// 设置页展示「上次活跃」需要的扩展字段。 +// 设置页展示「上次活跃」需要的扩展字段。device_id 是稳定标识:name 与令牌解耦后, +// 前端按它去重 / 判本机 / 寻址,改名只换 name 字段而 device_id 不变(避免改名生重复项)。 type PresenceDevice struct { + DeviceID string `json:"device_id"` Name string `json:"name"` Type string `json:"type"` Online bool `json:"online"` @@ -37,11 +39,28 @@ const clientBuffer = 32 type Client struct { UserID string DeviceID string + // Name 是连接声明的设备名(X-Device-Name)。presence 的权威 name 来自 devices 表(按 + // device_id 查,改名即时反映);本字段仅用于 (1) 无 devices 行的 live 连接(代铸前的 + // 全局 SSO 浏览器)的 presence 兜底标签,(2) 按 name 寻址时的解析(见 clientFor)。 + Name string // Type is the client-declared device type (browser / macos / windows / linux / ios). // It lets presence label a live device that has no devices-table row (a global-SSO // browser or a broker-authenticated desktop). Type string - ch chan Event + // key 是 users[userID] 这一级 map 的键:有 device_id 用 device_id(稳定,改名不变), + // 无(代铸前浏览器)回落 "name:"+name。Disconnect 据此精确摘除自身。 + key string + ch chan Event +} + +// connKey 计算一个 live 连接在 hub map 里的键:device_id 优先(稳定标识,改名不变,故改名 +// 不再产生孤儿键 / 重复项 —— 这是「设备名与令牌解耦」的服务端命门);无 device_id(代铸前 +// 的全局 SSO 浏览器)回落 "name:"+name,与 device_id 命名空间不冲突。 +func connKey(deviceID, name string) string { + if deviceID != "" { + return deviceID + } + return "name:" + name } func (c *Client) Events() <-chan Event { return c.ch } @@ -70,14 +89,18 @@ func New(devices DeviceLister) *Hub { } // Connect registers a new SSE client and announces presence to the user's other devices. -// If a client for (userID, deviceID) already exists (e.g., a tab refresh), its channel is closed. -// deviceType is the caller's declared device type, surfaced in presence for a live device -// that has no devices-table row. -func (h *Hub) Connect(ctx context.Context, userID, deviceID, deviceType string) *Client { +// Keyed by the connection key (device_id when present): a reconnect of the SAME device — even +// after a rename, since device_id is stable — replaces its own entry (its channel is closed) +// rather than orphaning the old one. deviceName is the connection-declared name (presence +// fallback + name addressing); deviceType is surfaced for a live device with no devices-table row. +func (h *Hub) Connect(ctx context.Context, userID, deviceID, deviceName, deviceType string) *Client { + key := connKey(deviceID, deviceName) c := &Client{ UserID: userID, DeviceID: deviceID, + Name: deviceName, Type: deviceType, + key: key, ch: make(chan Event, clientBuffer), } @@ -85,10 +108,10 @@ func (h *Hub) Connect(ctx context.Context, userID, deviceID, deviceType string) if h.users[userID] == nil { h.users[userID] = map[string]*Client{} } - if old, ok := h.users[userID][deviceID]; ok { + if old, ok := h.users[userID][key]; ok { close(old.ch) } - h.users[userID][deviceID] = c + h.users[userID][key] = c h.mu.Unlock() go h.publishPresence(ctx, userID) @@ -99,8 +122,8 @@ func (h *Hub) Connect(ctx context.Context, userID, deviceID, deviceType string) // to peers if the client has not reconnected. func (h *Hub) Disconnect(c *Client) { h.mu.Lock() - if active, ok := h.users[c.UserID][c.DeviceID]; ok && active == c { - delete(h.users[c.UserID], c.DeviceID) + if active, ok := h.users[c.UserID][c.key]; ok && active == c { + delete(h.users[c.UserID], c.key) if len(h.users[c.UserID]) == 0 { delete(h.users, c.UserID) } @@ -109,7 +132,7 @@ func (h *Hub) Disconnect(c *Client) { time.AfterFunc(h.grace, func() { h.mu.RLock() - _, stillOnline := h.users[c.UserID][c.DeviceID] + _, stillOnline := h.users[c.UserID][c.key] h.mu.RUnlock() if stillOnline { return @@ -120,18 +143,21 @@ func (h *Hub) Disconnect(c *Client) { }) } -// SendTo routes an event to a specific (userID, deviceID). Reports whether -// the target was online and the event was queued. +// SendTo routes an event to one of the user's live clients addressed by `target`. The +// target may be a stable device_id (direct map hit) OR a device name — peer addressing +// across cdrop (signaling / messages / transfers) is by name, so this resolves a name to +// its live client via clientFor while presence/online/kick key by the stable device_id. +// Reports whether the target was online and the event was queued. // // The send happens while still holding the read lock so it can never race a Kick / Connect- // replace / Close that closes the channel (those hold the write lock): a send on a closed // channel panics even inside a select, so close-vs-send must be mutually exclusive. The send // is non-blocking (select default), so holding the read lock across it is brief. -func (h *Hub) SendTo(userID, deviceID string, ev Event) bool { +func (h *Hub) SendTo(userID, target string, ev Event) bool { h.mu.RLock() defer h.mu.RUnlock() - c, ok := h.users[userID][deviceID] - if !ok { + c := h.clientFor(userID, target) + if c == nil { return false } select { @@ -139,11 +165,27 @@ func (h *Hub) SendTo(userID, deviceID string, ev Event) bool { return true default: slog.Warn("client buffer full; dropping event", - "user", userID, "device", deviceID, "type", ev.Type) + "user", userID, "device", target, "type", ev.Type) return false } } +// clientFor resolves `target` (device_id or device name) to a live client. Caller holds at +// least the read lock. A direct map hit handles device_id (and the "name:"+name fallback key +// of a code-less browser is never addressed directly); otherwise a scan matches the current +// display name — so a renamed device is reachable by its NEW name as soon as Rename lands. +func (h *Hub) clientFor(userID, target string) *Client { + if c, ok := h.users[userID][target]; ok { + return c + } + for _, c := range h.users[userID] { + if c.Name == target { + return c + } + } + return nil +} + // Broadcast fans an event out to every live client of a user. The non-blocking sends run // under the read lock so they can't race a concurrent channel close (see SendTo). func (h *Hub) Broadcast(userID string, ev Event) { @@ -159,7 +201,7 @@ func (h *Hub) Broadcast(userID string, ev Event) { } } -// Online reports whether a (userID, deviceID) currently has a live SSE. +// Online reports whether a device (by stable device_id) currently has a live SSE. func (h *Hub) Online(userID, deviceID string) bool { h.mu.RLock() defer h.mu.RUnlock() @@ -167,17 +209,34 @@ func (h *Hub) Online(userID, deviceID string) bool { return ok } -// Kick force-removes a (userID, deviceID) entry from the hub and closes its event channel; -// the SSE handler exits on the next iteration. The close happens UNDER the write lock — the -// same discipline as Connect-replace and Close — so it can never race a send from -// publishPresence / Broadcast / SendTo (those hold the read lock), which would otherwise -// panic on a send to a closed channel. revokeDevice now Kicks on every logout / device -// delete / session revoke, so this path is hot, not rare. -func (h *Hub) Kick(userID, deviceID string) { +// Rename updates a live client's display name in place (keyed by stable device_id), so a +// device renamed via PATCH /api/devices/{device_id} stays the SAME hub entry — no reconnect, +// no token re-mint, no duplicate — and becomes reachable by its new name (clientFor) at once. +// No-op if the device has no live connection (its next connect carries the new name anyway). +func (h *Hub) Rename(userID, deviceID, name string) { + if deviceID == "" { + return + } h.mu.Lock() defer h.mu.Unlock() if c, ok := h.users[userID][deviceID]; ok { - delete(h.users[userID], deviceID) + c.Name = name + } +} + +// Kick force-removes a device's live entry from the hub and closes its event channel; the SSE +// handler exits on the next iteration. Keyed by the connection key (device_id when present, +// else "name:"+name) so it摘除 the exact entry Connect inserted. The close happens UNDER the +// write lock — the same discipline as Connect-replace and Close — so it can never race a send +// from publishPresence / Broadcast / SendTo (those hold the read lock), which would otherwise +// panic on a send to a closed channel. revokeDevice now Kicks on every logout / device delete / +// session revoke, so this path is hot, not rare. +func (h *Hub) Kick(userID, deviceID, name string) { + key := connKey(deviceID, name) + h.mu.Lock() + defer h.mu.Unlock() + if c, ok := h.users[userID][key]; ok { + delete(h.users[userID], key) if len(h.users[userID]) == 0 { delete(h.users, userID) } @@ -226,21 +285,28 @@ func (h *Hub) publishPresence(ctx context.Context, userID string) { seen := make(map[string]bool, len(devs)) items := make([]PresenceDevice, 0, len(devs)+len(live)) for _, d := range devs { - _, online := live[d.Name] + // Online keyed by the stable device_id (not the name) — so a renamed device stays + // online through the rename, and the authoritative name is the (just-updated) DB row. + _, online := live[d.DeviceID] items = append(items, PresenceDevice{ - Name: d.Name, Type: d.Type, Online: online, LastSeen: d.LastSeen, + DeviceID: d.DeviceID, Name: d.Name, Type: d.Type, Online: online, LastSeen: d.LastSeen, }) - seen[d.Name] = true + seen[d.DeviceID] = true } - // Live connections without a devices-table row — a global-SSO browser before 代铸, or a - // device whose cache row hasn't landed yet. They are reachable on the hub (clipboard / - // signaling already route to them), so they must appear as online peers too. - for name, c := range live { - if seen[name] { + // Live connections without a devices-table row. Only a code-less connection (DeviceID=="" — + // a global-SSO browser before 代铸, keyed by name) is surfaced here, by its declared name. + // + // A connection WITH a device_id but no row is deliberately NOT surfaced: its row is either + // deleted (a device that was just revoked but reconnected on a still-valid access token — + // the "需要移除两次" zombie) or a 代铸 row that hasn't landed yet. Hiding it stops the revoked + // device from reappearing as a phantom online peer; a legitimate re-login mints a fresh + // devices row and shows normally via the loop above. + for _, c := range live { + if c.DeviceID != "" { continue } items = append(items, PresenceDevice{ - Name: name, Type: c.Type, Online: true, LastSeen: now, + Name: c.Name, Type: c.Type, Online: true, LastSeen: now, }) } diff --git a/internal/hub/hub_test.go b/internal/hub/hub_test.go index 6d0d2ba..c74d5d0 100644 --- a/internal/hub/hub_test.go +++ b/internal/hub/hub_test.go @@ -41,18 +41,18 @@ func waitForEvent(t *testing.T, c *Client, want string, timeout time.Duration) E func TestConnectFiresPresenceEvent(t *testing.T) { lister := &fakeLister{ - devices: []db.Device{{UserID: "alice", Name: "tab-1", Type: "browser", LastSeen: 1}}, + devices: []db.Device{{UserID: "alice", DeviceID: "dev_1", Name: "tab-1", Type: "browser", LastSeen: 1}}, } h := New(lister) defer h.Close() - c := h.Connect(context.Background(), "alice", "tab-1", "browser") + c := h.Connect(context.Background(), "alice", "dev_1", "tab-1", "browser") defer h.Disconnect(c) ev := waitForEvent(t, c, "presence", time.Second) data, _ := ev.Data.(map[string]any) devs, _ := data["devices"].([]PresenceDevice) - if len(devs) != 1 || devs[0].Name != "tab-1" || !devs[0].Online { + if len(devs) != 1 || devs[0].Name != "tab-1" || devs[0].DeviceID != "dev_1" || !devs[0].Online { t.Errorf("presence devices: %+v", devs) } } @@ -60,18 +60,18 @@ func TestConnectFiresPresenceEvent(t *testing.T) { func TestSecondClientSeesFirstAsOnline(t *testing.T) { lister := &fakeLister{ devices: []db.Device{ - {UserID: "alice", Name: "tab-1", Type: "browser", LastSeen: 1}, - {UserID: "alice", Name: "tab-2", Type: "browser", LastSeen: 2}, + {UserID: "alice", DeviceID: "dev_1", Name: "tab-1", Type: "browser", LastSeen: 1}, + {UserID: "alice", DeviceID: "dev_2", Name: "tab-2", Type: "browser", LastSeen: 2}, }, } h := New(lister) defer h.Close() - c1 := h.Connect(context.Background(), "alice", "tab-1", "browser") + c1 := h.Connect(context.Background(), "alice", "dev_1", "tab-1", "browser") defer h.Disconnect(c1) _ = waitForEvent(t, c1, "presence", time.Second) - c2 := h.Connect(context.Background(), "alice", "tab-2", "browser") + c2 := h.Connect(context.Background(), "alice", "dev_2", "tab-2", "browser") defer h.Disconnect(c2) // c2 receives its own presence (announced on Connect). @@ -96,13 +96,13 @@ func TestSecondClientSeesFirstAsOnline(t *testing.T) { } } -// A live device with no devices-table row (global-SSO browser / broker-auth desktop) -// must still appear in presence as online, labelled by its declared type. +// A live device with no devices-table row (global-SSO browser / broker-auth desktop) and no +// device_id must still appear in presence as online, labelled by its declared name + type. func TestLiveOnlyDeviceAppearsInPresence(t *testing.T) { h := New(&fakeLister{}) // empty managed-device set defer h.Close() - c := h.Connect(context.Background(), "alice", "macbook", "macos") + c := h.Connect(context.Background(), "alice", "", "macbook", "macos") defer h.Disconnect(c) ev := waitForEvent(t, c, "presence", time.Second) @@ -112,22 +112,31 @@ func TestLiveOnlyDeviceAppearsInPresence(t *testing.T) { } } -func TestSendToRoutesEvent(t *testing.T) { - lister := &fakeLister{} +func TestSendToRoutesEventByName(t *testing.T) { + lister := &fakeLister{ + devices: []db.Device{{UserID: "alice", DeviceID: "dev_1", Name: "tab-1", Type: "browser"}}, + } h := New(lister) defer h.Close() - c := h.Connect(context.Background(), "alice", "tab-1", "browser") + c := h.Connect(context.Background(), "alice", "dev_1", "tab-1", "browser") defer h.Disconnect(c) _ = waitForEvent(t, c, "presence", time.Second) + // Peer addressing is by name; the hub resolves it to the device_id-keyed client. if !h.SendTo("alice", "tab-1", Event{Type: "signal", Data: map[string]any{"x": 1}}) { - t.Fatal("SendTo to live client should report true") + t.Fatal("SendTo by name to a live client should report true") } ev := waitForEvent(t, c, "signal", time.Second) if ev.Data.(map[string]any)["x"] != 1 { t.Errorf("payload: %+v", ev.Data) } + + // Addressing by the stable device_id also works (direct map hit). + if !h.SendTo("alice", "dev_1", Event{Type: "signal"}) { + t.Fatal("SendTo by device_id should report true") + } + _ = waitForEvent(t, c, "signal", time.Second) } func TestSendToOfflineDeviceReportsFalse(t *testing.T) { @@ -138,20 +147,79 @@ func TestSendToOfflineDeviceReportsFalse(t *testing.T) { } } +// TestRenameKeepsEntryAndUpdatesRouting is the decoupling guarantee: a renamed device keeps +// its single hub entry (keyed by the stable device_id), so presence shows the new name with no +// duplicate and the device stays online and reachable by its NEW name — no reconnect, no token. +func TestRenameKeepsEntryAndUpdatesRouting(t *testing.T) { + lister := &fakeLister{ + devices: []db.Device{{UserID: "alice", DeviceID: "dev_1", Name: "iPhone", Type: "ios"}}, + } + h := New(lister) + defer h.Close() + + c := h.Connect(context.Background(), "alice", "dev_1", "iPhone", "ios") + defer h.Disconnect(c) + _ = waitForEvent(t, c, "presence", time.Second) + + // Simulate the rename: the DB row's name changes, then the hub is told. + lister.devices[0].Name = "My iPhone" + h.Rename("alice", "dev_1", "My iPhone") + h.PublishPresence(context.Background(), "alice") + + ev := waitForEvent(t, c, "presence", time.Second) + devs := ev.Data.(map[string]any)["devices"].([]PresenceDevice) + if len(devs) != 1 { + t.Fatalf("rename must not duplicate the device: got %+v", devs) + } + if devs[0].Name != "My iPhone" || devs[0].DeviceID != "dev_1" || !devs[0].Online { + t.Errorf("presence after rename: %+v", devs[0]) + } + + // Reachable by the new name; the old name no longer resolves. + if !h.SendTo("alice", "My iPhone", Event{Type: "signal"}) { + t.Error("renamed device should be reachable by its new name") + } + _ = waitForEvent(t, c, "signal", time.Second) + if h.SendTo("alice", "iPhone", Event{Type: "signal"}) { + t.Error("old name should no longer resolve after rename") + } +} + func TestReconnectClosesOldChannel(t *testing.T) { h := New(&fakeLister{}) defer h.Close() - old := h.Connect(context.Background(), "alice", "tab-1", "browser") + old := h.Connect(context.Background(), "alice", "dev_1", "tab-1", "browser") // drain the initial presence so we can detect close <-old.Events() - _ = h.Connect(context.Background(), "alice", "tab-1", "browser") + _ = h.Connect(context.Background(), "alice", "dev_1", "tab-1", "browser") select { case _, ok := <-old.Events(): if ok { - t.Error("old channel should be closed on reconnect from same (user, device)") + t.Error("old channel should be closed on reconnect from same device_id") + } + case <-time.After(time.Second): + t.Fatal("timeout waiting for old channel close") + } +} + +// A reconnect of the SAME device under a DIFFERENT name (e.g. mid-rename) still replaces its +// own entry rather than orphaning the old one — because the key is the stable device_id. +func TestReconnectAfterRenameReplacesSameEntry(t *testing.T) { + h := New(&fakeLister{}) + defer h.Close() + + old := h.Connect(context.Background(), "alice", "dev_1", "iPhone", "ios") + <-old.Events() + + _ = h.Connect(context.Background(), "alice", "dev_1", "My iPhone", "ios") + + select { + case _, ok := <-old.Events(): + if ok { + t.Error("old channel should be closed even when the name changed (same device_id)") } case <-time.After(time.Second): t.Fatal("timeout waiting for old channel close") @@ -160,16 +228,16 @@ func TestReconnectClosesOldChannel(t *testing.T) { func TestDisconnectRemovesFromOnlineSet(t *testing.T) { h := New(&fakeLister{ - devices: []db.Device{{UserID: "alice", Name: "tab-1", Type: "browser"}}, + devices: []db.Device{{UserID: "alice", DeviceID: "dev_1", Name: "tab-1", Type: "browser"}}, }) defer h.Close() - c := h.Connect(context.Background(), "alice", "tab-1", "browser") - if !h.Online("alice", "tab-1") { + c := h.Connect(context.Background(), "alice", "dev_1", "tab-1", "browser") + if !h.Online("alice", "dev_1") { t.Fatal("client should be online after Connect") } h.Disconnect(c) - if h.Online("alice", "tab-1") { + if h.Online("alice", "dev_1") { t.Error("client should not be online after Disconnect") } } @@ -180,7 +248,7 @@ func TestDisconnectRemovesFromOnlineSet(t *testing.T) { // the whole process; now close and send are mutually exclusive. Run with -race. func TestConcurrentKickAndPresenceNoPanic(t *testing.T) { h := New(&fakeLister{ - devices: []db.Device{{UserID: "u", Name: "d", Type: "browser"}}, + devices: []db.Device{{UserID: "u", DeviceID: "dev_0", Name: "d", Type: "browser"}}, }) defer h.Close() @@ -191,13 +259,15 @@ func TestConcurrentKickAndPresenceNoPanic(t *testing.T) { wg.Add(1) go func(n int) { defer wg.Done() + id := fmt.Sprintf("dev_%d", n) name := fmt.Sprintf("d%d", n) for j := 0; j < iters; j += 1 { - h.Connect(context.Background(), "u", name, "browser") + h.Connect(context.Background(), "u", id, name, "browser") h.PublishPresence(context.Background(), "u") h.Broadcast("u", Event{Type: "x"}) h.SendTo("u", name, Event{Type: "y"}) - h.Kick("u", name) + h.Rename("u", id, name+"-r") + h.Kick("u", id, name) } }(i) } @@ -208,16 +278,16 @@ func TestConcurrentKickAndPresenceNoPanic(t *testing.T) { func TestGracePeriodDelaysOfflinePresence(t *testing.T) { h := New(&fakeLister{ devices: []db.Device{ - {UserID: "alice", Name: "tab-1", Type: "browser"}, - {UserID: "alice", Name: "tab-2", Type: "browser"}, + {UserID: "alice", DeviceID: "dev_1", Name: "tab-1", Type: "browser"}, + {UserID: "alice", DeviceID: "dev_2", Name: "tab-2", Type: "browser"}, }, }) h.grace = 100 * time.Millisecond defer h.Close() - c1 := h.Connect(context.Background(), "alice", "tab-1", "browser") + c1 := h.Connect(context.Background(), "alice", "dev_1", "tab-1", "browser") defer h.Disconnect(c1) - c2 := h.Connect(context.Background(), "alice", "tab-2", "browser") + c2 := h.Connect(context.Background(), "alice", "dev_2", "tab-2", "browser") // Drain initial presence frames _ = waitForEvent(t, c1, "presence", time.Second) _ = waitForEvent(t, c1, "presence", time.Second) diff --git a/ios/CDrop/.gitignore b/ios/CDrop/.gitignore index 6f96c85..13effee 100644 --- a/ios/CDrop/.gitignore +++ b/ios/CDrop/.gitignore @@ -7,5 +7,6 @@ DerivedData/ *.xcuserstate .DS_Store -# 真机签名的账号特定值(Team ID + profile 名)——公开仓库不留账号标识,见 REALDEVICE.md。 -Local.xcconfig +# 真机签名的账号凭据——公开仓库不留账号标识。ASC API Key 的 .p8 + Team/Key/Issuer ID +# 走根目录 gitignore 的 .env(见 REALDEVICE.md);.p8 若落本目录一并忽略。 +*.p8 diff --git a/ios/CDrop/CDrop.entitlements b/ios/CDrop/CDrop.entitlements index fe08abd..218f050 100644 --- a/ios/CDrop/CDrop.entitlements +++ b/ios/CDrop/CDrop.entitlements @@ -10,7 +10,7 @@ 模拟器无需签名即生效;真机须付费 ADP 注册该 group(账号门控)。 --> com.apple.security.application-groups - group.net.commilitia.cdrop + group.net.commilitia.Commilitia-Drop diff --git a/ios/CDrop/REALDEVICE.md b/ios/CDrop/REALDEVICE.md index 86f823a..90d9bca 100644 --- a/ios/CDrop/REALDEVICE.md +++ b/ios/CDrop/REALDEVICE.md @@ -1,15 +1,15 @@ # cdrop iOS 真机分发手册 -把 cdrop 装到你自己的 iPhone(开发签名,1 年 profile)。代码侧全部就绪并经模拟器验证;真机只差**账号 + 门户 + 签名**这一层。本手册走**手动签名 + 全 CLI**路线——门户你手动建(一次),构建装机一条命令,**全程不碰 Xcode GUI**。 +把 cdrop 装到你自己的 iPhone(开发签名)。代码侧全部就绪并经模拟器验证;真机只差**账号 + 签名**这一层。本手册走 **ASC API Key 自动 provisioning + 全 CLI** 路线——一把 App Store Connect API Key,`-allowProvisioningUpdates` 自动登记连接的设备 + 创建/更新 App ID / App Group / Push / profile / 开发证书,构建装机一条命令,**全程不碰 Xcode GUI、不必手动建 profile**(绕过手动 profile「设备列表只剩 Mac」的坑)。 -## 包名与标识(门户里要用的) +## 包名与标识 | 项 | 值 | |---|---| | 主 app Bundle ID | `net.commilitia.Commilitia-Drop` | | Share Extension | `net.commilitia.Commilitia-Drop.share` | | 控件扩展 | `net.commilitia.Commilitia-Drop.widgets` | -| App Group | `group.net.commilitia.cdrop`(与包名相互独立,刻意不同名) | +| App Group | `group.net.commilitia.Commilitia-Drop` | | APNs topic(服务端 `CDROP_APNS_TOPIC`) | `net.commilitia.Commilitia-Drop` | ## 前置 @@ -19,61 +19,54 @@ --- -## A. 门户操作(developer.apple.com/account,一次性) +## A. ASC API Key + .env(核心,一次性) -> 入口:登录后左侧 **Certificates, Identifiers & Profiles**(证书 / 标识符 / 描述文件总枢纽)——A1–A5 都在这里面;A6 在 **Keys**;Team ID 在 **Membership details**(10 位,记下)。 +`-allowProvisioningUpdates` 凭一把 App Store Connect API Key 替你在门户自动登记设备、建/改 App ID(含 App Groups + Push 能力)、建 App Group、建 profile、建/下开发证书——**不必手动建 profile**。 -1. **证书**(Apple Development):本机「钥匙串访问」可能已有(「我的证书」分类里看)。没有就:钥匙串访问菜单 → 证书助理 → 从证书颁发机构请求证书 → 存到磁盘 → 门户 **Certificates** → + → Apple Development → 传 CSR → 下载 `.cer` 双击安装。 -2. **App IDs ×3**(**Identifiers** → + → App IDs → App,Bundle ID 选 **Explicit**): - - `net.commilitia.Commilitia-Drop` — 勾 **App Groups** + **Push Notifications** - - `net.commilitia.Commilitia-Drop.share` — 勾 **App Groups** - - `net.commilitia.Commilitia-Drop.widgets` — 勾 **App Groups** -3. **App Group**:**Identifiers** 页类型筛选切到 **App Groups** → + → `group.net.commilitia.cdrop`。再回上面 3 个 App ID 各自的 App Groups 能力里 **Edit/Configure** 关联它(三个都要)。 -4. **设备**:**Devices** → + → 填 UDID(插上手机后 `just ios-devices` 读)。 -5. **描述文件 ×3**(**Profiles** → + → **iOS App Development**,非 Distribution):每个选对应 App ID + 证书 + 设备,命名清楚(建议 `CDrop Dev` / `CDrop Share Dev` / `CDrop Widgets Dev`)→ 下载 → 双击安装。 -6. **APNs Auth Key**(**Keys** → + → 勾 Apple Push Notifications service):下载 `AuthKey_XXXXX.p8`(仅一次),记 **Key ID** + **Issuer ID**(服务端发推送用,见 D)。这把 `.p8` 与 A1 的证书是两码事。 +**生成 API Key**:appstoreconnect.apple.com → **用户和访问** → **集成** → **App Store Connect API** → **团队密钥** → 「+」→ 角色 **Admin**(要能管设备 / profile / 标识符)→ 命名 → 生成 → 下载 `AuthKey_XXXXX.p8`(**仅一次**)。记下三样: -> 常见坑:Bundle ID 选 **Explicit** 不是 Wildcard;App Group 务必 3 个 App ID 都关联;Profile 选 **Development** 不要 Distribution。 +- **Key ID**(密钥行里那串,10 位) +- **Issuer ID**(密钥页顶部,UUID 形) +- **Team ID**(账号 **Membership** 里,10 位) + +**配 `.env`**(仓库根目录,已 gitignore,公开仓库不留账号凭据): + +``` +CDROP_TEAM_ID=ABCDE12345 +CDROP_ASC_KEY_PATH=/绝对路径/AuthKey_XXXXX.p8 +CDROP_ASC_KEY_ID=XXXXXXXXXX +CDROP_ASC_ISSUER_ID=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx +``` + +`.p8` 放仓库外即可,`CDROP_ASC_KEY_PATH` 指它绝对路径(落本目录则被 `*.p8` 忽略)。 + +> 你若已在门户手动建过 App ID / App Group / 证书,自动 provisioning 会复用、不冲突。**「手动建 profile 时设备列表只剩 Mac」**=那台 iPhone 在 **Devices** 里被登记成了 macOS 平台(登记表单 Platform 默认 macOS);自动 provisioning 直接按连接的真机重登记,绕过此坑。 + +**顺手:APNs Auth Key**(发推送用,与上面 ASC Key 是两码事):门户 **Keys** → + → 勾 **Apple Push Notifications service** → 下载 `AuthKey_YYYYY.p8`(仅一次),记 Key ID + Issuer ID(服务端配,见 C)。 --- -## B. 本地签名配置(gitignore,填一次) - -在 `ios/CDrop/Local.xcconfig` 写入(此文件已 gitignore,公开仓库不留你的账号标识): - -``` -CDROP_TEAM_ID = ABCDE12345 -CDROP_PROFILE_APP = CDrop Dev -CDROP_PROFILE_SHARE = CDrop Share Dev -CDROP_PROFILE_WIDGETS = CDrop Widgets Dev -``` - -- `CDROP_TEAM_ID`=A0 的 Team ID;三个 `CDROP_PROFILE_*`=A5 你起的 profile 名(**名字**,不是文件路径)。 -- 机制:`Signing.xcconfig`(已提交)仅对 **device SDK(iphoneos)** 套用手动签名 + 这些值;**模拟器**仍走 `project.yml` 的 ad-hoc(`CODE_SIGN_IDENTITY = "-"`),故缺 `Local.xcconfig` 也不影响模拟器构建。 - ---- - -## C. 构建装机(全 CLI) +## B. 构建装机(全 CLI) ```sh -just ios-devices # 手机插 USB,读 UDID(填进门户 A4 + 下行) -just ios-device <你的设备UDID> # 真机构建(手动签名)+ devicectl 装机 +just ios-devices # 手机插 USB,读 UDID +just ios-device <你的设备UDID> # 自动 provisioning 构建 + devicectl 装机 ``` -- `just ios-device` = `xcodegen generate` → `xcodebuild`(device,手动签名走 `Local.xcconfig`)→ `xcrun devicectl device install app`。无 Xcode GUI。 +- `just ios-device` = `xcodegen generate` → `xcodebuild -allowProvisioningUpdates`(凭 .env 的 ASC Key 自动签名 + 登记设备 + 建 profile)→ `xcrun devicectl device install app`。无 Xcode GUI。 - 首次装机后,iPhone 上首启该开发者 app 即可直接跑(付费 ADP 开发证书,无需手动「信任开发者」)。 --- -## D. 服务端前置 +## C. 服务端前置 -1. **引擎可达**:真机上引擎 WebView 加载 `https://drop.commilitia.net/engine.html`——**该文件随 cdrop 二进制部署到 prod**(`//go:embed` 进 binary,`just docker-image` 含最新 `dist`)。手机要用,prod 须是含本轮改动的最新部署。 -2. **APNs 真发**:把 A6 的 `.p8` 放到服务器,给后端容器配 `CDROP_APNS_KEY_PATH` / `CDROP_APNS_KEY_ID` / `CDROP_APNS_TEAM_ID` / `CDROP_APNS_TOPIC=net.commilitia.Commilitia-Drop` / `CDROP_APNS_ENV`(Xcode 开发构建的 device token 属 **sandbox**,故联调填 `sandbox`)。缺配置则推送惰性关闭,其余功能照常。 +1. **引擎可达**:真机上引擎 WebView 加载 `https://drop.commilitia.net/engine.html`——该文件随 cdrop 二进制部署到 prod(`//go:embed` 进 binary,`just docker-image` 含最新 `dist`)。手机要用,prod 须是含本轮改动的最新部署。 +2. **APNs 真发**:把 A 里那把 **APNs Auth Key** `.p8` 放到服务器,给后端容器配 `CDROP_APNS_KEY_PATH` / `CDROP_APNS_KEY_ID` / `CDROP_APNS_TEAM_ID` / `CDROP_APNS_TOPIC=net.commilitia.Commilitia-Drop` / `CDROP_APNS_ENV`(Xcode 开发构建的 device token 属 **sandbox**,故联调填 `sandbox`)。缺配置则推送惰性关闭,其余功能照常。 3. 本机联调可选:环境变量 `CDROP_ENGINE_URL` 指向可达引擎。 --- -## E. 只能真机验的清单 +## D. 只能真机验的清单 代码已实现,下列是真机才能验的点: @@ -83,6 +76,6 @@ just ios-device <你的设备UDID> # 真机构建(手动签名)+ devicectl - [ ] 发送 / 接收:选文件 → 选设备 → 对端收到;对端发来落 Files(Documents)。 - [ ] **大文件流式发送**(R-iOS-4 已实现):发大文件,WebView 内存应有界(按 Range 块拉取,不整文件入内存)。 - [ ] **后台续传**(R-iOS-1 / R-iOS-3):传输中切后台 → `BGContinuedProcessingTask` 系统进度 UI;验 WKWebView JS 是否随之保活(不保活则退化为回前台续传,可接受)。 -- [ ] **APNs 推送**:离线设备收「收到文件」通知(需 D2 的 `.p8`)。 +- [ ] **APNs 推送**:离线设备收「收到文件」通知(需 C2 的 `.p8`)。 - [ ] **Share Extension**:别的 app 分享 → 选 Commilitia Drop → 唤起主 app 选设备发送。 - [ ] **控制中心剪贴板两控件**:控制中心加「上传 / 拉取剪贴板」控件 → 锁屏 / 解锁态点按 → 云剪贴板读写。 diff --git a/ios/CDrop/Share/CDropShare.entitlements b/ios/CDrop/Share/CDropShare.entitlements index ae503b8..74739f5 100644 --- a/ios/CDrop/Share/CDropShare.entitlements +++ b/ios/CDrop/Share/CDropShare.entitlements @@ -5,7 +5,7 @@ com.apple.security.application-groups - group.net.commilitia.cdrop + group.net.commilitia.Commilitia-Drop diff --git a/ios/CDrop/Shared/AppGroup.swift b/ios/CDrop/Shared/AppGroup.swift index daba1c0..a34d82a 100644 --- a/ios/CDrop/Shared/AppGroup.swift +++ b/ios/CDrop/Shared/AppGroup.swift @@ -8,7 +8,7 @@ import Foundation // 文件名);发送 / 取消后删掉该子目录即清理。 enum AppGroup { - static let identifier = "group.net.commilitia.cdrop" + static let identifier = "group.net.commilitia.Commilitia-Drop" // 共享收件箱根目录。容器不存在(App Group 未生效)时返回 nil,调用方降级为静默不交接。 static func inboxURL() -> URL? diff --git a/ios/CDrop/Signing.xcconfig b/ios/CDrop/Signing.xcconfig deleted file mode 100644 index f55150a..0000000 --- a/ios/CDrop/Signing.xcconfig +++ /dev/null @@ -1,11 +0,0 @@ -// 真机签名配置。仅对 device SDK(iphoneos)生效——模拟器仍走 project.yml base 的 ad-hoc -// 签名(CODE_SIGN_IDENTITY = "-"),无需 Apple 账号即可构建 / 跑模拟器。 -// -// 账号特定值(Team ID、各 target 的 profile 名)放在同目录 **gitignore** 的 Local.xcconfig -// 里(公开仓库不留账号标识)。Local.xcconfig 不存在时下面的可选 include 跳过,device 构建会 -// 因缺 Team/profile 失败——这正是预期(真机构建须先按 REALDEVICE.md 建好 Local.xcconfig)。 -#include? "Local.xcconfig" - -CODE_SIGN_STYLE[sdk=iphoneos*] = Manual -CODE_SIGN_IDENTITY[sdk=iphoneos*] = Apple Development -DEVELOPMENT_TEAM[sdk=iphoneos*] = $(CDROP_TEAM_ID) diff --git a/ios/CDrop/Sources/Auth/AuthManager.swift b/ios/CDrop/Sources/Auth/AuthManager.swift index 2277d89..d098352 100644 --- a/ios/CDrop/Sources/Auth/AuthManager.swift +++ b/ios/CDrop/Sources/Auth/AuthManager.swift @@ -1,3 +1,4 @@ +import AuthenticationServices import Foundation import Observation import UIKit @@ -35,14 +36,19 @@ final class AuthManager var session: Session? var qrPayload: String? - var statusText: String = t("ios.login.generating") + // 空初值:登录页以 broker 账号登录为主,无操作时不显状态文案;各登录流程开始时自行设置。 + var statusText: String = "" // 二维码已失效(denied/expired/失败)→ 登录页据此提示并高亮「刷新二维码」。 var qrExpired = false - // 登录代次:每次 startQRLogin 自增,旧轮询据此识别自己已被刷新取代、丢弃结果,避免 - // 「刷新时仍在轮询」两条循环并发改 statusText / qrPayload 打架。 + // 登录代次:每次 startQRLogin / startBrokerLogin 自增,旧流程据此识别自己已被取代、丢弃 + // 结果,避免两条登录流程并发改 statusText / session 打架。 private var loginGeneration = 0 + // Broker 登录期间强持 ASWebAuthenticationSession 封装(其 presentationContextProvider 为 weak、 + // session 自身亦须保活至回调),完成 / 失败后置空。 + private var brokerFlow: BrokerAuthFlow? + // Keychain 持久化坐标(单 app 私有,见 Keychain.swift)。 private static let keychainService = "net.commilitia.cdrop.session" private static let keychainAccount = "session" @@ -110,14 +116,152 @@ final class AuthManager } } + // 应用内 Broker 登录(device-authorization + PKCE,见 BrokerLogin.swift):手机直接输账号登录, + // 无需另一台设备扫码。流程=拉 broker 配置 → ASWebAuthenticationSession 授权拿 code → 换 bootstrap + // 令牌 → 代铸成 cdrop 设备会话(带真名 / 稳定 device_id)→ 落 full 会话 + Keychain。 + func startBrokerLogin() async + { + loginGeneration += 1 + let gen = loginGeneration + qrExpired = false + statusText = t("ios.login.brokerStarting") + do + { + let cfg = try await fetchAuthConfig() + guard !cfg.brokerURL.isEmpty, !cfg.brokerApp.isEmpty else { throw BrokerLoginError.incompleteConfig } + + let verifier = PKCE.randomToken(64) + let challenge = PKCE.challenge(for: verifier) + let state = PKCE.randomToken(24) + let redirectURI = "cdrop://auth-callback" + + var comp = URLComponents(string: cfg.brokerURL.trimmingTrailingSlash() + "/device/authorize")! + comp.queryItems = [ + URLQueryItem(name: "app", value: cfg.brokerApp), + URLQueryItem(name: "redirect_uri", value: redirectURI), + URLQueryItem(name: "state", value: state), + URLQueryItem(name: "code_challenge", value: challenge), + URLQueryItem(name: "code_challenge_method", value: "S256"), + URLQueryItem(name: "description", value: "Commilitia Drop iOS"), + ] + guard let authURL = comp.url else { throw BrokerLoginError.incompleteConfig } + + let flow = BrokerAuthFlow() + brokerFlow = flow + let callback = try await flow.run(url: authURL, callbackScheme: "cdrop") + brokerFlow = nil + if gen != loginGeneration { return } + + let items = URLComponents(url: callback, resolvingAgainstBaseURL: false)?.queryItems ?? [] + guard items.first(where: { $0.name == "state" })?.value == state + else { throw BrokerLoginError.stateMismatch } + guard let code = items.first(where: { $0.name == "code" })?.value, !code.isEmpty + else { throw BrokerLoginError.missingCode } + + let bootstrap = try await exchangeDeviceToken( + brokerURL: cfg.brokerURL, code: code, verifier: verifier, redirectURI: redirectURI) + let ds = try await mintDeviceSession(bootstrap: bootstrap) + if gen != loginGeneration { return } + + DeviceIDStore.value = ds.device_id + let realName = (ds.name?.isEmpty == false) ? ds.name! : ds.user_id + session = Session(accessToken: ds.access_token, + refreshToken: ds.refresh_token, + user: User(id: ds.user_id, name: realName, avatar: ds.avatar), + deviceName: ds.device_name ?? DeviceNameStore.value, + deviceId: ds.device_id, + scope: "full") + persist() + } + catch + { + brokerFlow = nil + if gen != loginGeneration { return } + // 用户主动取消(关闭授权页)不算失败:静默回到登录页等待再次操作。 + if let asErr = error as? ASWebAuthenticationSessionError, asErr.code == .canceledLogin + { + statusText = t("ios.login.waiting") + return + } + statusText = t("ios.login.failed") + } + } + func logout() { session = nil qrPayload = nil - statusText = t("ios.login.generating") + statusText = "" Keychain.delete(service: Self.keychainService, account: Self.keychainAccount) } + // ---- Broker 登录辅助:配置拉取 / 令牌交换 / 代铸 ---- + + private struct AuthConfig { let brokerURL: String; let brokerApp: String } + + private struct DeviceTokenResp: Decodable + { + let access: String + let refresh: String + let access_expires: Int64 + } + + // 代铸响应:access/refresh + 稳定 device_id + 经边缘核验的真实身份(user_id/name/avatar)。 + private struct DeviceSessionResp: Decodable + { + let access_token: String + let refresh_token: String + let device_id: String + let device_name: String? + let user_id: String + let name: String? + let avatar: String? + } + + private func fetchAuthConfig() async throws -> AuthConfig + { + let (data, _) = try await URLSession.shared.data(from: URL(string: "\(apiBase)/api/auth/config")!) + let obj = (try? JSONSerialization.jsonObject(with: data)) as? [String: Any] ?? [:] + return AuthConfig(brokerURL: obj["broker_url"] as? String ?? "", + brokerApp: obj["broker_app"] as? String ?? "") + } + + private func exchangeDeviceToken( + brokerURL: String, code: String, verifier: String, redirectURI: String) async throws -> String + { + var req = URLRequest(url: URL(string: brokerURL.trimmingTrailingSlash() + "/device/token")!) + req.httpMethod = "POST" + req.setValue("application/json", forHTTPHeaderField: "Content-Type") + req.setValue("application/json", forHTTPHeaderField: "Accept") + req.httpBody = try JSONSerialization.data(withJSONObject: [ + "code": code, "code_verifier": verifier, "redirect_uri": redirectURI, + ]) + let (data, resp) = try await URLSession.shared.data(for: req) + let status = (resp as? HTTPURLResponse)?.statusCode ?? 0 + guard status == 200 else { throw BrokerLoginError.tokenFailed(status) } + let tr = try JSONDecoder().decode(DeviceTokenResp.self, from: data) + guard !tr.access.isEmpty else { throw BrokerLoginError.badResponse } + return tr.access + } + + private func mintDeviceSession(bootstrap: String) async throws -> DeviceSessionResp + { + var req = URLRequest(url: URL(string: "\(apiBase)/api/auth/device-session")!) + req.httpMethod = "POST" + req.setValue("Bearer \(bootstrap)", forHTTPHeaderField: "Authorization") + req.setValue("application/json", forHTTPHeaderField: "Content-Type") + req.setValue("ios", forHTTPHeaderField: "X-Device-Type") + req.httpBody = try JSONSerialization.data(withJSONObject: [ + "device_id": DeviceIDStore.value, + "device_name": DeviceNameStore.value, + "device_type": "ios", + ]) + let (data, resp) = try await URLSession.shared.data(for: req) + let status = (resp as? HTTPURLResponse)?.statusCode ?? 0 + guard status == 200 else { throw BrokerLoginError.deviceSessionFailed(status) } + return try JSONDecoder().decode(DeviceSessionResp.self, from: data) + } + // 持久化当前会话到 Keychain(扫码批准后调用)。 private func persist() { @@ -136,6 +280,30 @@ final class AuthManager persist() } + // updateIdentity:引擎据 /api/me 取到真实显示名(QR 登录注入的是 subject UUID 名)→ 经桥 + // identityUpdated 回报,更新会话身份 + Keychain,使设置页显示真名而非 UUID(#12)。仅换 + // 显示身份,令牌 / 设备名不变。 + func updateIdentity(name: String, avatar: String?) + { + guard let cur = session else { return } + let av = (avatar?.isEmpty == false) ? avatar : cur.user.avatar + session = Session(accessToken: cur.accessToken, refreshToken: cur.refreshToken, + user: User(id: cur.user.id, name: name, avatar: av), + deviceName: cur.deviceName, deviceId: cur.deviceId, scope: cur.scope) + persist() + } + + // updateDeviceName:本机即时改名成功(PATCH /api/devices/{device_id})后经桥 renamed 回报, + // 更新会话里的设备名 + Keychain,使重启后 boot 注入新名。令牌 / 身份不变——设备名与令牌解耦。 + func updateDeviceName(_ name: String) + { + guard let cur = session else { return } + session = Session(accessToken: cur.accessToken, refreshToken: cur.refreshToken, + user: cur.user, deviceName: name, + deviceId: cur.deviceId, scope: cur.scope) + persist() + } + // 仅供本机模拟器联调(CDROP_DEBUG_SESSION=1):注入占位会话跳过扫码,用于验证引擎从 // prod 加载 + 桥往返。令牌无效、鉴权会 401,但能证 engine.html 在 WKWebView 里加载运行。 func debugSession() diff --git a/ios/CDrop/Sources/Auth/BrokerLogin.swift b/ios/CDrop/Sources/Auth/BrokerLogin.swift new file mode 100644 index 0000000..e6d0dab --- /dev/null +++ b/ios/CDrop/Sources/Auth/BrokerLogin.swift @@ -0,0 +1,111 @@ +import AuthenticationServices +import CryptoKit +import Foundation +import UIKit + +// 应用内 Broker 登录(device-authorization + PKCE,对齐桌面 desktop/platform/oauth.go)。iOS 无法 +// 跑 loopback 监听,故用 ASWebAuthenticationSession + 自定义 scheme 回调(cdrop://auth-callback): +// 1. GET {broker}/device/authorize?app=cdrop&redirect_uri=cdrop://auth-callback&state&code_challenge&S256 +// 2. 用户在 broker SSO 授权 → 回调 cdrop://auth-callback?code=..&state=.. +// 3. POST {broker}/device/token {code, code_verifier, redirect_uri} → bootstrap 机器令牌 +// 4. POST {api}/api/auth/device-session(Bearer bootstrap)→ cdrop 设备会话(带真名 / device_id) +// 这样手机上直接输账号登录,无需另一台设备扫码批准。 +// 前置:broker 须为 cdrop 应用注册 redirect_uri cdrop://auth-callback(同桌面 loopback 须注册)。 + +enum BrokerLoginError: Error +{ + case incompleteConfig + case cannotStart + case noCallback + case stateMismatch + case missingCode + case tokenFailed(Int) + case deviceSessionFailed(Int) + case badResponse +} + +// ASWebAuthenticationSession 的 async 封装 + 展示锚点提供者。须被持有至完成(session 的 +// presentationContextProvider 是 weak,且 session 自身也须保活),故由 AuthManager 在登录期间强持。 +@MainActor +final class BrokerAuthFlow: NSObject, ASWebAuthenticationPresentationContextProviding +{ + private var session: ASWebAuthenticationSession? + + // 打开授权页,等回调 URL(cdrop://auth-callback?code=..&state=..)。用户取消 / 失败抛错。 + func run(url: URL, callbackScheme: String) async throws -> URL + { + try await withCheckedThrowingContinuation + { cont in + let s = ASWebAuthenticationSession(url: url, callbackURLScheme: callbackScheme) + { [weak self] callback, error in + self?.session = nil + if let error { cont.resume(throwing: error) } + else if let callback { cont.resume(returning: callback) } + else { cont.resume(throwing: BrokerLoginError.noCallback) } + } + s.presentationContextProvider = self + // 共享浏览会话:若用户已在 Safari 登录 broker SSO,则免再输密码(无缝 SSO)。 + s.prefersEphemeralWebBrowserSession = false + self.session = s + if !s.start() { cont.resume(throwing: BrokerLoginError.cannotStart) } + } + } + + func presentationAnchor(for session: ASWebAuthenticationSession) -> ASPresentationAnchor + { + let scenes = UIApplication.shared.connectedScenes.compactMap { $0 as? UIWindowScene } + return scenes.flatMap { $0.windows }.first { $0.isKeyWindow } + ?? scenes.first?.windows.first + ?? ASPresentationAnchor() + } +} + +// 跨登录持久的稳定 device_id(broker meta / 会话↔设备连接键)。存 UserDefaults,登出不清—— +// 故重登复用同一 device_id,broker R2 幂等轮换同一会话、设备表 UPSERT 同一行,杜绝重复设备项 +// (对齐 web 的 localStorage cdrop.device_id)。首次为空,由后端铸发后回存。 +enum DeviceIDStore +{ + private static let key = "cdrop.device_id" + static var value: String + { + get { UserDefaults.standard.string(forKey: key) ?? "" } + set { UserDefaults.standard.set(newValue, forKey: key) } + } +} + +// PKCE / 随机串(对齐桌面 randString:n 字节 base64url 取前 n 字符,落在 PKCE unreserved 集内)。 +enum PKCE +{ + static func randomToken(_ n: Int) -> String + { + var bytes = [UInt8](repeating: 0, count: n) + _ = SecRandomCopyBytes(kSecRandomDefault, n, &bytes) + return String(Data(bytes).base64URLEncoded().prefix(n)) + } + + static func challenge(for verifier: String) -> String + { + let digest = SHA256.hash(data: Data(verifier.utf8)) + return Data(digest).base64URLEncoded() + } +} + +extension Data +{ + // base64url(无填充,- / _ 替 + /),用于 PKCE challenge / 随机串。 + func base64URLEncoded() -> String + { + base64EncodedString() + .replacingOccurrences(of: "+", with: "-") + .replacingOccurrences(of: "/", with: "_") + .replacingOccurrences(of: "=", with: "") + } +} + +extension String +{ + func trimmingTrailingSlash() -> String + { + hasSuffix("/") ? String(dropLast()) : self + } +} diff --git a/ios/CDrop/Sources/Auth/LoginView.swift b/ios/CDrop/Sources/Auth/LoginView.swift index f6b15b6..cd5aeeb 100644 --- a/ios/CDrop/Sources/Auth/LoginView.swift +++ b/ios/CDrop/Sources/Auth/LoginView.swift @@ -2,34 +2,70 @@ import CoreImage.CIFilterBuiltins import SwiftUI import UIKit -// 扫码登录界面:显示二维码 + 引导用已登录的 cdrop 扫码批准。轮询在 AuthManager 里。 +// 登录界面:主登录=应用内 Broker 账号登录(ASWebAuthenticationSession,直接输账号、无需另一台 +// 设备);扫码登录降级为可展开的备选(需另一台已登录设备扫码批准)。两条流程经 AuthManager。 struct LoginView: View { @Environment(AuthManager.self) private var auth + @State private var showQR = false var body: some View { - VStack(spacing: 24) + VStack(spacing: 20) { Spacer() Text(t("app.brand")) .font(.largeTitle) .bold() - qrArea - Text(auth.statusText) - .font(.callout) - .foregroundStyle(auth.qrExpired ? Color.red : Color.secondary) - refreshButton - Text(t("ios.login.guide")) - .font(.footnote) - .foregroundStyle(.secondary) - .multilineTextAlignment(.center) - .padding(.horizontal, 40) + + // 主登录:应用内 broker 账号登录。 + Button { Task { await auth.startBrokerLogin() } } + label: + { + Label(t("ios.login.broker"), systemImage: "person.crop.circle") + .frame(maxWidth: .infinity) + } + .buttonStyle(.glassProminent) + .padding(.horizontal, 40) + + if !auth.statusText.isEmpty + { + Text(auth.statusText) + .font(.callout) + .foregroundStyle(auth.qrExpired ? Color.red : Color.secondary) + .multilineTextAlignment(.center) + .padding(.horizontal, 40) + } + + // 备选:扫码登录(需另一台已登录设备)。展开时才发起二维码,避免无谓请求。 + DisclosureGroup(isExpanded: $showQR) + { + VStack(spacing: 16) + { + qrArea + refreshButton + Text(t("ios.login.guide")) + .font(.footnote) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + } + .padding(.top, 12) + } + label: + { + Text(t("ios.login.scanAlt")) + .font(.subheadline) + } + .padding(.horizontal, 40) + .onChange(of: showQR) + { + if showQR, auth.qrPayload == nil { Task { await auth.startQRLogin() } } + } + Spacer() } .padding() .tint(.cdropAccent) - .task { await auth.startQRLogin() } } // 刷新二维码:失效后高亮(glassProminent),未失效时也常驻可手动刷新。两种玻璃样式类型 diff --git a/ios/CDrop/Sources/DeviceNameStore.swift b/ios/CDrop/Sources/DeviceNameStore.swift index 91b269d..3e8f5ad 100644 --- a/ios/CDrop/Sources/DeviceNameStore.swift +++ b/ios/CDrop/Sources/DeviceNameStore.swift @@ -1,9 +1,10 @@ import Foundation import UIKit -// 用户可编辑的设备名,持久在 UserDefaults。用于扫码登录 qr/start 的 device_name——设备名 -// 在登录时即烤进自签会话令牌,后端按 (userID, deviceName) 记 presence,无在线改名接口 -// (见 internal/httpapi/qr.go),故改名在「下次登录」生效。默认取系统设备名。 +// 用户可编辑的设备名,持久在 UserDefaults。用于扫码登录 qr/start 的初始 device_name。设备名 +// 已与会话令牌解耦:登录后在设置页改名经 PATCH /api/devices/{device_id} 即时生效(后端按稳定 +// device_id 改名 + 重广播 presence,不换 token),成功后由引擎 renamed 事件回写本存储 + Keychain +// 会话名(见 EngineController / AuthManager)。默认取系统设备名。 enum DeviceNameStore { private static let key = "cdrop.deviceName" diff --git a/ios/CDrop/Sources/Engine/EngineController.swift b/ios/CDrop/Sources/Engine/EngineController.swift index 23ea916..37496aa 100644 --- a/ios/CDrop/Sources/Engine/EngineController.swift +++ b/ios/CDrop/Sources/Engine/EngineController.swift @@ -9,16 +9,30 @@ import WebKit // (对齐 web hub.ts handlePresence 的防御式取值)。 struct DeviceItem: Identifiable, Equatable { + // deviceID 是稳定标识(broker meta):设备名与令牌解耦后,去重 / 判本机 / 跨端吊销都按它, + // 改名只换 name 而 deviceID 不变。后端 presence 总会带;偶发缺失(代铸前的连接)回落 name。 + let deviceID: String let name: String let type: String let online: Bool let lastSeen: Double - var id: String { name } + var id: String { deviceID.isEmpty ? name : deviceID } +} + +// 引擎过桥推来的设备间文本消息(对应 web store 的 MessageRecord)。原生侧持久化(见 RecordsStore), +// 故重启存活、可手动删除——不再「关 app 即清」。 +struct MessageItem: Identifiable, Equatable, Codable +{ + let id: String + let direction: String // incoming | outgoing + let peerName: String + let text: String + let sentAt: Double } // 引擎过桥推来的传输记录(对应 web engine/main.ts 的 toWire 精简视图)。ice* 为选中候选 // 对诊断(揭示 P2P 是否实为 TURN 中继,解释慢速)。同样手动容忍解析。 -struct TransferItem: Identifiable, Equatable +struct TransferItem: Identifiable, Equatable, Codable { let sessionId: String let direction: String @@ -52,6 +66,7 @@ final class EngineController: NSObject var devices: [DeviceItem] = [] // 在线 / 离线设备 presence(每次 presence 事件整组替换) var transfers: [TransferItem] = [] // 活跃传输(每次 transfers 事件整组替换) var history: [TransferItem] = [] // 已完成传输(transferDone 逐条前插,限长 30) + var messages: [MessageItem] = [] // 设备间文本消息(引擎每次 message 事件整组替换;最新在前) // 诊断信号(设置页展示):SSE 是否连上 / 是否在重连 + 累计收到的 presence 事件数 + 引擎 // 最近一条 warn/error 日志。用来定位「设备空」卡在哪一环(未连接?401?静默挂起?)。 @@ -80,9 +95,13 @@ final class EngineController: NSObject return URL(string: "https://drop.commilitia.net/engine.html")! } - // 会话来源(登录后注入引擎 boot)。由 app 在启动时接好(AppRoot.onAppear)。 + // 会话来源(登录后注入引擎 boot)。在 EngineWebView.makeUIView 里 boot 注入前同步接好(#11)。 var auth: AuthManager? + // 本机稳定设备 id(broker meta):判本机(presence 里哪台是自己)与即时改名(PATCH + // /api/devices/{device_id})都用它,与设备名解耦——改名不变。 + var selfDeviceID: String { auth?.session?.deviceId ?? "" } + private var webView: WKWebView? private let downloads = DownloadManager() @@ -90,10 +109,17 @@ final class EngineController: NSObject // 回取字节(下方 WKURLSchemeHandler 供给)。 private var outgoing: [String: URL] = [:] + // 历史 / 消息限长(原生持久化,跨重启存活)。 + private static let historyCap = 50 + private static let messagesCap = 200 + override init() { super.init() PushRegistry.shared.engine = self + // 加载上次持久化的传输历史 / 消息记录,修「重启清空」。新事件经引擎桥增量追加。 + history = RecordsStore.load([TransferItem].self, "history") ?? [] + messages = RecordsStore.load([MessageItem].self, "messages") ?? [] } // makeWebView:构建离屏 WebView——注入 __CDROP_BOOT__(device_type:"ios")、注册消息 @@ -133,6 +159,8 @@ final class EngineController: NSObject deviceName = currentDeviceName() // 已登录、引擎启动 → 请求通知授权并注册远程通知(幂等)。令牌回来后经引擎桥上报。 PushRegistry.shared.requestAuthorizationAndRegister() + // 触发本地网络权限弹窗(#5):使 WKWebView 内的 WebRTC 能收集 host 候选、同内网直连。 + LocalNetworkPermission.trigger() return wv } @@ -146,6 +174,10 @@ final class EngineController: NSObject devices = [] transfers = [] history = [] + messages = [] + // 登出清空持久记录,不跨账号残留(重启保活仅针对同一登录态)。 + RecordsStore.clear("history") + RecordsStore.clear("messages") status = t("ios.engine.disconnected") deviceName = "" PushRegistry.shared.reset() @@ -157,6 +189,32 @@ final class EngineController: NSObject CDropAPI.clearWidgetDeviceID() } + // 删除一条传输历史记录(D)/ 清空全部(持久化同步)。仅删本地展示记录,不影响已落盘文件。 + func deleteTransferRecord(_ sessionId: String) + { + history.removeAll { $0.sessionId == sessionId } + RecordsStore.save(history, "history") + } + + func clearHistory() + { + history.removeAll() + RecordsStore.clear("history") + } + + // 删除一条消息(H)/ 清空全部(持久化同步)。 + func deleteMessage(_ id: String) + { + messages.removeAll { $0.id == id } + RecordsStore.save(messages, "messages") + } + + func clearMessages() + { + messages.removeAll() + RecordsStore.clear("messages") + } + // 收到的文件(Documents 沙盒,与 Files app 同一批)。供「收到的文件」视图浏览 / 转发。 func receivedFiles() -> [URL] { downloads.receivedFiles() } func deleteReceivedFile(_ url: URL) { downloads.deleteReceivedFile(url) } @@ -193,11 +251,44 @@ final class EngineController: NSObject sendCommand("clipboardPull", payload: [:]) } - // 设备管理:移除 / 吊销一台设备。 - func revokeDevice(_ name: String) + // 设备管理:移除 / 吊销一台设备。按稳定 device_id(DELETE /api/devices/{device_id})—— + // 之前传设备名致后端找不到而「移除失败」(#2)。 + func revokeDevice(_ deviceID: String) { deviceActionStatus = "" - sendCommand("revokeDevice", payload: [ "name": name ]) + sendCommand("revokeDevice", payload: [ "device_id": deviceID ]) + } + + // 主动取消一个进行中的传输(O)。引擎置该会话为 CANCELLED,随后 transfers / transferDone 回推。 + func cancelTransfer(_ sessionId: String) + { + sendCommand("cancelTransfer", payload: [ "sessionId": sessionId ]) + } + + // 立即切到中继(O):跳过等待 ICE 直连的窗口,直接走 relay(同内网直连建不起来 / 不想等时用)。 + func switchToRelay(_ sessionId: String) + { + sendCommand("switchToRelay", payload: [ "sessionId": sessionId ]) + } + + // 设备间文本消息(发送):把 { to=对端设备名, text } 交引擎经 POST /api/message 转发。乐观回显 + // 由引擎侧 store.addMessage 触发的 message 事件整组回推(含本条 outgoing),故这里不本地插。 + func sendMessage(to peerName: String, text: String) + { + let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty, !peerName.isEmpty else { return } + sendCommand("sendMessage", payload: [ "to": peerName, "text": trimmed ]) + } + + // 本机即时改名(设备名与令牌解耦):把 { device_id=本机, name } 交引擎经 PATCH /api/devices/ + // {device_id} 只改名字——不换 token、不重连、不产生重复项;成功后引擎回报 "renamed",本端据此 + // 落本地名 + 更新 Keychain 会话名(见 handleNotify)。无 device_id(异常)则不发。 + func renameSelf(to name: String) + { + let id = selfDeviceID + let trimmed = name.trimmingCharacters(in: .whitespacesAndNewlines) + guard !id.isEmpty, !trimmed.isEmpty else { return } + sendCommand("renameSelf", payload: [ "device_id": id, "name": trimmed ]) } // APNs 令牌登记:原生拿到令牌(PushRegistry.didRegister)或引擎刚就绪(ready)任一时机调用。 @@ -282,6 +373,20 @@ final class EngineController: NSObject pendingShareFiles = [] } + // 图库选择(#14):PhotosPicker 给的是 PhotosPickerItem,须先 loadTransferable 取字节,再落 + // 一个临时文件,才能复用既有 cdrop-file scheme 发送链路(按 Range 读盘、整文件不入内存)。 + // 返回临时文件 URL(在 temporaryDirectory,系统回收);写盘失败回 nil。 + func stagePhotoData(_ data: Data, suggestedName: String) -> URL? + { + let dir = FileManager.default.temporaryDirectory + .appendingPathComponent("PhotoOutbox/\(UUID().uuidString)", isDirectory: true) + try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + let name = suggestedName.isEmpty ? "photo.jpg" : suggestedName + let dest = dir.appendingPathComponent(name) + do { try data.write(to: dest); return dest } + catch { return nil } + } + // 暂存待发文件,返回引擎用的 cdrop-file:// 引用。文件选择器给的 URL 是安全作用域 // 资源,需 start...Access 才能读。 private func stageOutgoingFile(_ url: URL) -> String @@ -291,6 +396,24 @@ final class EngineController: NSObject outgoing[id] = url return "cdrop-file://\(id)" } + + // 发送侧按 [start,end) seek 读暂存的待发文件、回 base64(供 bridgeFileSource 经桥取片)。 + // url 形如 cdrop-file://,id 即 stageOutgoingFile 的暂存键。无该文件 / 读失败抛错。 + fileprivate func readOutgoingSlice(url: String, start: Int, end: Int) throws -> String + { + guard let id = URL(string: url)?.host, let fileURL = outgoing[id] + else + { + throw NSError(domain: "cdrop.engine", code: 404, + userInfo: [NSLocalizedDescriptionKey: "no staged file: \(url)"]) + } + let handle = try FileHandle(forReadingFrom: fileURL) + defer { try? handle.close() } + try handle.seek(toOffset: UInt64(max(0, start))) + let count = max(0, end - start) + let data = (try handle.read(upToCount: count)) ?? Data() + return data.base64EncodedString() + } } // MARK: - JS → 原生(RPC + 通知) @@ -337,6 +460,12 @@ extension EngineController: WKScriptMessageHandler case "abortDownload": downloads.abort(sessionId: payload["sessionId"] as? String ?? "") resolve(id: id, ok: true, value: nil) + case "readFileSlice": + // 发送侧取片:原生 seek 暂存的待发文件读 [start,end)、回 base64(见 bridgeFileSource)。 + let b64 = try readOutgoingSlice(url: payload["url"] as? String ?? "", + start: Self.intOf(payload["start"]), + end: Self.intOf(payload["end"])) + resolve(id: id, ok: true, value: b64) default: resolve(id: id, ok: false, value: "unknown method: \(method)") } @@ -380,6 +509,21 @@ extension EngineController: WKScriptMessageHandler transfers = raw.compactMap { ($0 as? [String: Any]).flatMap(Self.parseTransfer) } syncBackgroundTask() } + case "message": + // 引擎推来的单条新消息(增量):前插 + 去重 + 限长 + 落盘。原生为持久真源,故不被 + // 引擎重启后的空列表覆盖(重启清空问题的根因)。 + if let p = payload as? [String: Any], + let m = p["message"] as? [String: Any], + let item = Self.parseMessage(m) + { + messages.removeAll { $0.id == item.id } + messages.insert(item, at: 0) + if messages.count > Self.messagesCap + { + messages.removeLast(messages.count - Self.messagesCap) + } + RecordsStore.save(messages, "messages") + } case "transferDone": // 完成项移出活跃、前插历史;按 sessionId 去重避免重复事件叠加。 if let p = payload as? [String: Any], let item = Self.parseTransfer(p) @@ -387,8 +531,9 @@ extension EngineController: WKScriptMessageHandler transfers.removeAll { $0.sessionId == item.sessionId } history.removeAll { $0.sessionId == item.sessionId } history.insert(item, at: 0) - if history.count > 30 { history.removeLast(history.count - 30) } + if history.count > Self.historyCap { history.removeLast(history.count - Self.historyCap) } syncBackgroundTask() + RecordsStore.save(history, "history") // 持久化,跨重启存活 } case "sendStarted": // 随后的 transfers 事件会带出该活跃项,这里无需额外处理。 @@ -420,6 +565,23 @@ extension EngineController: WKScriptMessageHandler clipboardStatus = t("ios.clipboard.uploaded") case "deviceRevoked": deviceActionStatus = t("ios.devices.revoked") + case "identityUpdated": + // 引擎据 /api/me 取到真实显示名(QR 登录注入的是 UUID 名)→ 更新会话身份 + Keychain, + // 设置页据此显示真名而非 UUID(#12)。 + if let p = payload as? [String: Any], let name = p["name"] as? String, !name.isEmpty + { + auth?.updateIdentity(name: name, avatar: p["avatar"] as? String) + } + case "renamed": + // 本机即时改名成功(PATCH /api/devices/{device_id} 已落库 + 重广播 presence)→ 落本地名 + // 并更新 Keychain 会话名,使重启后 boot 注入新名。 + if let p = payload as? [String: Any], let name = p["name"] as? String, !name.isEmpty + { + DeviceNameStore.value = name + auth?.updateDeviceName(name) + deviceName = name + deviceActionStatus = t("ios.settings.deviceName.success") + } case "error": // 复用现有 error 通知:剪贴板 / 吊销失败也走这里,分流到对应状态行。 if let p = payload as? [String: Any] @@ -580,12 +742,24 @@ extension EngineController static func parseDevice(_ d: [String: Any]) -> DeviceItem? { guard let name = d["name"] as? String, let type = d["type"] as? String else { return nil } - return DeviceItem(name: name, + // 引擎推来的是 web store 的 DeviceInfo(camelCase:deviceId / lastSeen)。 + return DeviceItem(deviceID: d["deviceId"] as? String ?? "", + name: name, type: type, online: boolOf(d["online"]), lastSeen: doubleOf(d["lastSeen"])) } + static func parseMessage(_ m: [String: Any]) -> MessageItem? + { + guard let id = m["id"] as? String, let text = m["text"] as? String else { return nil } + return MessageItem(id: id, + direction: m["direction"] as? String ?? "incoming", + peerName: m["peerName"] as? String ?? "", + text: text, + sentAt: doubleOf(m["sentAt"])) + } + static func parseTransfer(_ t: [String: Any]) -> TransferItem? { guard let sessionId = t["sessionId"] as? String else { return nil } diff --git a/ios/CDrop/Sources/Engine/EngineWebView.swift b/ios/CDrop/Sources/Engine/EngineWebView.swift index 0b0bc16..d073e9e 100644 --- a/ios/CDrop/Sources/Engine/EngineWebView.swift +++ b/ios/CDrop/Sources/Engine/EngineWebView.swift @@ -6,9 +6,14 @@ import WebKit struct EngineWebView: UIViewRepresentable { let controller: EngineController + // 会话源:在构建 WebView(注入 __CDROP_BOOT__)之前确定性接好。冷启动重开时,本视图的 + // makeUIView 可能先于 AppRoot.onAppear 跑——若那时 controller.auth 仍为 nil,boot 注入 + // session:null → 引擎报「missing injected session」整个 boot 死(#11)。故在此同步接好。 + let auth: AuthManager func makeUIView(context: Context) -> WKWebView { + controller.auth = auth return controller.makeWebView() } diff --git a/ios/CDrop/Sources/Info.plist b/ios/CDrop/Sources/Info.plist index d10f3d5..97c4327 100644 --- a/ios/CDrop/Sources/Info.plist +++ b/ios/CDrop/Sources/Info.plist @@ -56,6 +56,8 @@ Commilitia Drop 使用相机扫描二维码登录新设备。 NSLocalNetworkUsageDescription Commilitia Drop 需要访问本地网络以发现同内网设备并建立直连传输。 + NSPhotoLibraryUsageDescription + Commilitia Drop 需要访问照片库,以便从相册选取图片或视频发送给其他设备。 UIBackgroundModes remote-notification diff --git a/ios/CDrop/Sources/LocalNetworkPermission.swift b/ios/CDrop/Sources/LocalNetworkPermission.swift new file mode 100644 index 0000000..a73a43b --- /dev/null +++ b/ios/CDrop/Sources/LocalNetworkPermission.swift @@ -0,0 +1,26 @@ +import Foundation +import Network + +// 触发本地网络权限弹窗(#5)。iOS 14+ 默认拒绝 App 访问本地网络;未授权时 WKWebView 内的 +// WebRTC 收集不到 host 候选 → 同内网也退回 srflx / relay(慢、非直连)。WKWebView 自身不会 +// 触发该权限请求,须由宿主 App 主动发起一次本地网络访问。这里启动一个对 _cdrop._tcp 的 +// NWBrowser 浏览即触发系统弹窗;用户允许后,后续 RTCPeerConnection 才能收集本地 host / mDNS +// 候选实现直连。仅需触发一次;保持 browser 存活以维持授权态。project.yml 已声明 +// NSLocalNetworkUsageDescription + NSBonjourServices(_cdrop._tcp)。 +enum LocalNetworkPermission +{ + private static var browser: NWBrowser? + + // 启动浏览以触发权限请求。幂等:已在浏览则不重复启。浏览结果本身不关心——「发起访问」这一 + // 动作即触发系统授权弹窗。 + static func trigger() + { + guard browser == nil else { return } + let params = NWParameters() + params.includePeerToPeer = true + let b = NWBrowser(for: .bonjour(type: "_cdrop._tcp", domain: nil), using: params) + b.browseResultsChangedHandler = { _, _ in } + browser = b + b.start(queue: .main) + } +} diff --git a/ios/CDrop/Sources/MessagesView.swift b/ios/CDrop/Sources/MessagesView.swift new file mode 100644 index 0000000..6db42e6 --- /dev/null +++ b/ios/CDrop/Sources/MessagesView.swift @@ -0,0 +1,164 @@ +import SwiftUI + +// 设备间文本消息(#13):聊天式列表 + 底部撰写栏。消息由引擎经桥推来(in-memory,关 app 即清, +// 对齐 web 语义),收发都经 POST /api/message(hub SendTo 实时转发;对端离线则后端发推送)。 +// 可发给任意非本机设备(在线即时达、离线走推送),故目标取全部非本机设备而非仅在线。 +struct MessagesView: View +{ + @Environment(EngineController.self) private var engine + @State private var draft = "" + @State private var target = "" + @State private var showClearConfirm = false + + // 可发消息的目标:全部非本机设备(按 device_id 判本机,与设备名解耦)。 + private var targets: [DeviceItem] + { + let selfID = engine.selfDeviceID + return engine.devices.filter + { d in + if !selfID.isEmpty, !d.deviceID.isEmpty { return d.deviceID != selfID } + return d.name != engine.deviceName + } + } + + // store 是最新在前;聊天式从上到下需旧→新。 + private var ordered: [MessageItem] { engine.messages.reversed() } + + var body: some View + { + VStack(spacing: 0) + { + if engine.messages.isEmpty + { + ContentUnavailableView(t("ios.messages.empty"), systemImage: "message") + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + else + { + ScrollViewReader + { proxy in + ScrollView + { + LazyVStack(alignment: .leading, spacing: 10) + { + ForEach(ordered) + { msg in + messageRow(msg) + .id(msg.id) + .contextMenu + { + Button(role: .destructive) { engine.deleteMessage(msg.id) } + label: { Label(t("transfer.action.delete"), systemImage: "trash") } + } + } + } + .padding() + } + .onChange(of: engine.messages.count) { scrollToLast(proxy) } + .onAppear { scrollToLast(proxy) } + } + } + composeBar + } + .navigationTitle(t("ios.tab.messages")) + .toolbar + { + if !engine.messages.isEmpty + { + ToolbarItem(placement: .topBarTrailing) + { + Button(role: .destructive) { showClearConfirm = true } + label: { Label(t("ios.records.clear"), systemImage: "trash") } + } + } + } + .confirmationDialog(t("ios.records.clearConfirm"), isPresented: $showClearConfirm, titleVisibility: .visible) + { + Button(t("ios.records.clear"), role: .destructive) { engine.clearMessages() } + Button(t("common.cancel"), role: .cancel) { } + } + .onAppear { if target.isEmpty { target = targets.first?.name ?? "" } } + } + + private func scrollToLast(_ proxy: ScrollViewProxy) + { + guard let last = ordered.last else { return } + withAnimation { proxy.scrollTo(last.id, anchor: .bottom) } + } + + private func messageRow(_ msg: MessageItem) -> some View + { + let outgoing = msg.direction == "outgoing" + return HStack + { + if outgoing { Spacer(minLength: 48) } + VStack(alignment: outgoing ? .trailing : .leading, spacing: 2) + { + Text(msg.peerName) + .font(.caption2) + .foregroundStyle(.secondary) + Text(msg.text) + .padding(.horizontal, 12) + .padding(.vertical, 8) + .background(outgoing ? Color.cdropAccent.opacity(0.18) : Color(.secondarySystemBackground), + in: RoundedRectangle(cornerRadius: 14)) + .textSelection(.enabled) + } + if !outgoing { Spacer(minLength: 48) } + } + .frame(maxWidth: .infinity, alignment: outgoing ? .trailing : .leading) + } + + @ViewBuilder + private var composeBar: some View + { + VStack(spacing: 6) + { + if targets.isEmpty + { + Text(t("ios.messages.noPeers")) + .font(.caption) + .foregroundStyle(.secondary) + .frame(maxWidth: .infinity, alignment: .leading) + } + else + { + HStack(spacing: 10) + { + Menu + { + ForEach(targets) + { dev in + Button(dev.name) { target = dev.name } + } + } + label: + { + Label(target.isEmpty ? t("ios.send.pickDevice") : target, + systemImage: "chevron.up.chevron.down") + .font(.caption) + .lineLimit(1) + } + TextField(t("ios.messages.placeholder"), text: $draft, axis: .vertical) + .textFieldStyle(.roundedBorder) + .lineLimit(1...4) + .submitLabel(.send) + .onSubmit { send() } + Button { send() } + label: { Image(systemName: "paperplane.fill").font(.title3) } + .disabled(draft.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || target.isEmpty) + } + } + } + .padding() + .background(.bar) + } + + private func send() + { + let text = draft.trimmingCharacters(in: .whitespacesAndNewlines) + guard !text.isEmpty, !target.isEmpty else { return } + engine.sendMessage(to: target, text: text) + draft = "" + } +} diff --git a/ios/CDrop/Sources/RecordsStore.swift b/ios/CDrop/Sources/RecordsStore.swift new file mode 100644 index 0000000..107b151 --- /dev/null +++ b/ios/CDrop/Sources/RecordsStore.swift @@ -0,0 +1,33 @@ +import Foundation + +// 传输历史 / 消息记录的本地持久化(修「重启清空」)。落 Application Support 下的 JSON 文件—— +// 不进 Documents(那是「收到的文件」对用户可见的目录),也不进 UserDefaults(记录可能较多)。 +// 原生侧是这两类记录的真源:引擎只过桥推增量,原生加载 / 追加 / 删除 / 落盘(见 EngineController)。 +enum RecordsStore +{ + private static func fileURL(_ name: String) -> URL? + { + guard let dir = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first + else { return nil } + try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + return dir.appendingPathComponent("cdrop-\(name).json") + } + + static func save(_ value: T, _ name: String) + { + guard let url = fileURL(name), let data = try? JSONEncoder().encode(value) else { return } + try? data.write(to: url, options: .atomic) + } + + static func load(_ type: T.Type, _ name: String) -> T? + { + guard let url = fileURL(name), let data = try? Data(contentsOf: url) else { return nil } + return try? JSONDecoder().decode(type, from: data) + } + + static func clear(_ name: String) + { + guard let url = fileURL(name) else { return } + try? FileManager.default.removeItem(at: url) + } +} diff --git a/ios/CDrop/Sources/Resources/i18n/en-US.json b/ios/CDrop/Sources/Resources/i18n/en-US.json index ce5d319..1a48f91 100644 --- a/ios/CDrop/Sources/Resources/i18n/en-US.json +++ b/ios/CDrop/Sources/Resources/i18n/en-US.json @@ -301,6 +301,9 @@ "ios.login.guide": "Scan to approve from another signed-in device", "ios.login.expired": "Code expired — please try again", "ios.login.failed": "Sign-in failed — please try again", + "ios.login.broker": "Sign In", + "ios.login.brokerStarting": "Opening sign-in…", + "ios.login.scanAlt": "Sign in by scanning from another device", "ios.login.refresh": "Refresh QR Code", "ios.login.needFull": "This device needs full access — choose \"Trust this device\" when approving.", "ios.send.pickDevice": "Choose a device", @@ -343,7 +346,18 @@ "ios.settings.user": "User", "ios.settings.logout": "Sign Out", "ios.settings.deviceName": "Device Name", - "ios.settings.deviceNameNote": "Renaming takes effect on next sign-in", + "ios.settings.deviceNameNote": "Renaming takes effect at once and syncs to all devices, without affecting sign-in.", + "ios.settings.deviceName.success": "Device name updated", + "ios.tab.messages": "Messages", + "ios.send.fromFiles": "From Files", + "ios.send.fromPhotos": "From Photos", + "ios.messages.empty": "No messages yet", + "ios.messages.noPeers": "No device to message", + "ios.messages.placeholder": "Type a message…", + "ios.records.clear": "Clear", + "ios.records.clearConfirm": "Clear all records? This cannot be undone.", + "ios.transfer.cancel": "Cancel transfer", + "ios.transfer.forceRelay": "Relay now", "ios.settings.deviceCount": "Known Devices", "ios.settings.signaling": "Signaling", "ios.settings.presenceEvents": "Presence Events", diff --git a/ios/CDrop/Sources/Resources/i18n/zh-CN.json b/ios/CDrop/Sources/Resources/i18n/zh-CN.json index 3af806c..80ab776 100644 --- a/ios/CDrop/Sources/Resources/i18n/zh-CN.json +++ b/ios/CDrop/Sources/Resources/i18n/zh-CN.json @@ -301,6 +301,9 @@ "ios.login.guide": "用另一台已登录的设备扫码批准", "ios.login.expired": "二维码已失效,请重试", "ios.login.failed": "登录失败,请重试", + "ios.login.broker": "登录", + "ios.login.brokerStarting": "正在打开登录…", + "ios.login.scanAlt": "用其他设备扫码登录", "ios.login.refresh": "刷新二维码", "ios.login.needFull": "此设备需要完整权限,批准时请选择“信任此设备”", "ios.send.pickDevice": "选择接收设备", @@ -343,7 +346,18 @@ "ios.settings.user": "用户", "ios.settings.logout": "退出登录", "ios.settings.deviceName": "设备名称", - "ios.settings.deviceNameNote": "改名将在下次登录后生效", + "ios.settings.deviceNameNote": "改名即时生效并同步到所有设备,不影响登录。", + "ios.settings.deviceName.success": "设备名已更新", + "ios.tab.messages": "消息", + "ios.send.fromFiles": "从文件", + "ios.send.fromPhotos": "从图库", + "ios.messages.empty": "暂无消息", + "ios.messages.noPeers": "没有可发送消息的设备", + "ios.messages.placeholder": "输入消息…", + "ios.records.clear": "清空", + "ios.records.clearConfirm": "确定清空全部记录?此操作不可撤销。", + "ios.transfer.cancel": "取消传输", + "ios.transfer.forceRelay": "立即切到中继", "ios.settings.deviceCount": "已知设备", "ios.settings.signaling": "信令连接", "ios.settings.presenceEvents": "在线事件", diff --git a/ios/CDrop/Sources/Resources/i18n/zh-TW.json b/ios/CDrop/Sources/Resources/i18n/zh-TW.json index 6448052..c2f08be 100644 --- a/ios/CDrop/Sources/Resources/i18n/zh-TW.json +++ b/ios/CDrop/Sources/Resources/i18n/zh-TW.json @@ -301,6 +301,9 @@ "ios.login.guide": "用另一台已登入的裝置掃碼核准", "ios.login.expired": "QR 碼已失效,請重試", "ios.login.failed": "登入失敗,請重試", + "ios.login.broker": "登入", + "ios.login.brokerStarting": "正在開啟登入…", + "ios.login.scanAlt": "用其他裝置掃碼登入", "ios.login.refresh": "重新整理 QR 碼", "ios.login.needFull": "此裝置需要完整權限,批准時請選擇「信任此裝置」", "ios.send.pickDevice": "選擇接收裝置", @@ -343,7 +346,18 @@ "ios.settings.user": "使用者", "ios.settings.logout": "登出", "ios.settings.deviceName": "裝置名稱", - "ios.settings.deviceNameNote": "改名將在下次登入後生效", + "ios.settings.deviceNameNote": "改名即時生效並同步到所有裝置,不影響登入。", + "ios.settings.deviceName.success": "裝置名稱已更新", + "ios.tab.messages": "訊息", + "ios.send.fromFiles": "從檔案", + "ios.send.fromPhotos": "從相簿", + "ios.messages.empty": "尚無訊息", + "ios.messages.noPeers": "沒有可傳送訊息的裝置", + "ios.messages.placeholder": "輸入訊息…", + "ios.records.clear": "清空", + "ios.records.clearConfirm": "確定清空全部記錄?此操作無法復原。", + "ios.transfer.cancel": "取消傳輸", + "ios.transfer.forceRelay": "立即切到中繼", "ios.settings.deviceCount": "已知裝置", "ios.settings.signaling": "信令連線", "ios.settings.presenceEvents": "上線事件", diff --git a/ios/CDrop/Sources/RootView.swift b/ios/CDrop/Sources/RootView.swift index 14d9851..da7f028 100644 --- a/ios/CDrop/Sources/RootView.swift +++ b/ios/CDrop/Sources/RootView.swift @@ -1,3 +1,4 @@ +import PhotosUI import SwiftUI import UIKit import UniformTypeIdentifiers @@ -9,6 +10,9 @@ import UniformTypeIdentifiers struct RootView: View { @Environment(EngineController.self) private var engine + // auth 经环境注入(AppRoot 已 .environment(auth)),用于把会话源在 EngineWebView.makeUIView + // 里同步接给引擎——早于 boot 注入,消除冷启重开的「missing injected session」竞态(#11)。 + @Environment(AuthManager.self) private var auth // 初始标签可经 CDROP_TAB 环境变量指定(本机截图测试用:直接进设置看引擎状态)。 @State private var selection = ProcessInfo.processInfo.environment["CDROP_TAB"] ?? "transfer" @@ -30,6 +34,13 @@ struct RootView: View DeviceListView() } } + Tab(t("ios.tab.messages"), systemImage: "message", value: "messages") + { + NavigationStack + { + MessagesView() + } + } Tab(t("ios.tab.files"), systemImage: "folder", value: "files") { NavigationStack @@ -49,7 +60,7 @@ struct RootView: View .tint(.cdropAccent) .background { - EngineWebView(controller: engine) + EngineWebView(controller: engine, auth: auth) .frame(width: 0, height: 0) .opacity(0) .allowsHitTesting(false) @@ -78,9 +89,12 @@ struct TransferListView: View { @Environment(EngineController.self) private var engine @State private var showImporter = false + @State private var showPhotoPicker = false + @State private var photoItem: PhotosPickerItem? @State private var pickedURL: URL? @State private var showDevicePicker = false @State private var showNoDevices = false + @State private var showClearConfirm = false // 可发送目标:在线、且非本机(共用 EngineController.sendableDevices)。 private var sendableDevices: [DeviceItem] @@ -115,6 +129,12 @@ struct TransferListView: View ForEach(engine.history) { item in transferLink(item) + .contextMenu + { + Button(role: .destructive) + { engine.deleteTransferRecord(item.sessionId) } + label: { Label(t("transfer.action.delete"), systemImage: "trash") } + } } } } @@ -122,9 +142,33 @@ struct TransferListView: View } } .navigationTitle(t("ios.tab.transfer")) + .toolbar + { + if !engine.history.isEmpty + { + ToolbarItem(placement: .topBarTrailing) + { + Button(role: .destructive) { showClearConfirm = true } + label: { Label(t("ios.records.clear"), systemImage: "trash") } + } + } + } + .confirmationDialog(t("ios.records.clearConfirm"), isPresented: $showClearConfirm, titleVisibility: .visible) + { + Button(t("ios.records.clear"), role: .destructive) { engine.clearHistory() } + Button(t("common.cancel"), role: .cancel) { } + } .overlay(alignment: .bottomTrailing) { - Button { showImporter = true } + // 发送入口:文件(Files / 文档)或图库(Photos)。图库走 PhotosPicker,选中后取字节落 + // 临时文件再复用既有发送链路(#14)。 + Menu + { + Button { showImporter = true } + label: { Label(t("ios.send.fromFiles"), systemImage: "folder") } + Button { showPhotoPicker = true } + label: { Label(t("ios.send.fromPhotos"), systemImage: "photo.on.rectangle") } + } label: { Image(systemName: "paperplane.fill") @@ -143,6 +187,9 @@ struct TransferListView: View else { showDevicePicker = true } } } + .photosPicker(isPresented: $showPhotoPicker, selection: $photoItem, + matching: .any(of: [ .images, .videos ])) + .onChange(of: photoItem) { loadPhoto() } .confirmationDialog(t("ios.send.pickDevice"), isPresented: $showDevicePicker, titleVisibility: .visible) { ForEach(sendableDevices) @@ -184,6 +231,32 @@ struct TransferListView: View engine.sendFile(to: device, fileURL: url) pickedURL = nil } + + // 图库选中后:异步取字节 → 落临时文件 → 走与文件相同的「选设备发送」流程(#14)。 + private func loadPhoto() + { + guard let item = photoItem else { return } + Task + { + guard let data = try? await item.loadTransferable(type: Data.self), + let url = engine.stagePhotoData(data, suggestedName: suggestedPhotoName(item)) + else { return } + await MainActor.run + { + pickedURL = url + photoItem = nil + if sendableDevices.isEmpty { showNoDevices = true } + else { showDevicePicker = true } + } + } + } + + // 据所选项的内容类型推一个带正确扩展名的文件名(图片默认 jpg,视频默认 mov)。 + private func suggestedPhotoName(_ item: PhotosPickerItem) -> String + { + let ext = item.supportedContentTypes.first?.preferredFilenameExtension ?? "jpg" + return "photo-\(UUID().uuidString.prefix(8)).\(ext)" + } } // 传输卡片:内容层,用语义材质背景而非玻璃(玻璃专属功能 / 导航层)。状态 / 阶段 / 速率 @@ -262,6 +335,21 @@ struct TransferDetailView: View { List { + // 进行中的传输:主动取消 / 立即切中继(O)。已是中继则不再显示「立即中继」。 + if isActive(item) + { + Section + { + Button(role: .destructive) { engine.cancelTransfer(item.sessionId) } + label: { Label(t("ios.transfer.cancel"), systemImage: "xmark.circle") } + if item.mode != "relay" + { + Button { engine.switchToRelay(item.sessionId) } + label: { Label(t("ios.transfer.forceRelay"), + systemImage: "antenna.radiowaves.left.and.right") } + } + } + } Section { LabeledContent(t("ios.detail.direction"), @@ -343,7 +431,13 @@ struct DeviceListView: View engine.devices.sorted { a, b in isSelf(a) && !isSelf(b) } } - private func isSelf(_ dev: DeviceItem) -> Bool { dev.name == engine.deviceName } + // 判本机按稳定 device_id(与设备名解耦——改名后仍判得准);selfDeviceID 缺失时回落按名。 + private func isSelf(_ dev: DeviceItem) -> Bool + { + let selfID = engine.selfDeviceID + if !selfID.isEmpty, !dev.deviceID.isEmpty { return dev.deviceID == selfID } + return dev.name == engine.deviceName + } var body: some View { @@ -385,7 +479,7 @@ struct DeviceListView: View { Button(t("ios.devices.remove"), role: .destructive) { - if let target = revokeTarget { engine.revokeDevice(target.name) } + if let target = revokeTarget { engine.revokeDevice(target.deviceID) } } Button(t("common.cancel"), role: .cancel) { } } @@ -438,9 +532,17 @@ struct SettingsView: View Section { LabeledContent(t("ios.settings.user"), value: user.name) + // 即时改名(设备名与令牌解耦):提交即经引擎 PATCH /api/devices/{device_id} 改名, + // 不换 token、不重连、不产生重复项,全端即时看到新名(见 EngineController.renameSelf)。 TextField(t("ios.settings.deviceName"), text: $deviceNameDraft) .submitLabel(.done) - .onChange(of: deviceNameDraft) { DeviceNameStore.value = deviceNameDraft } + .onSubmit { commitRename() } + if !engine.deviceActionStatus.isEmpty + { + Text(engine.deviceActionStatus) + .font(.caption) + .foregroundStyle(.secondary) + } Button(role: .destructive) { logout() } label: { @@ -511,6 +613,15 @@ struct SettingsView: View return t("ios.engine.disconnected") } + // 即时改名:提交时把草稿交引擎改名(PATCH /api/devices/{device_id})。空 / 未变则忽略。 + // 成功后引擎经 renamed 事件落本地名 + Keychain 会话名 + 刷新 engine.deviceName。 + private func commitRename() + { + let trimmed = deviceNameDraft.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty, trimmed != (auth.session?.deviceName ?? "") else { return } + engine.renameSelf(to: trimmed) + } + // 登出:先复位引擎(断 SSE + 丢 WebView),再清会话回登录页(清 Keychain 由 auth 负责)。 private func logout() { diff --git a/ios/CDrop/Widgets/CDropWidgets.entitlements b/ios/CDrop/Widgets/CDropWidgets.entitlements index ece16d8..5b86a45 100644 --- a/ios/CDrop/Widgets/CDropWidgets.entitlements +++ b/ios/CDrop/Widgets/CDropWidgets.entitlements @@ -5,7 +5,7 @@ com.apple.security.application-groups - group.net.commilitia.cdrop + group.net.commilitia.Commilitia-Drop diff --git a/ios/CDrop/project.yml b/ios/CDrop/project.yml index e347cca..d9475e6 100644 --- a/ios/CDrop/project.yml +++ b/ios/CDrop/project.yml @@ -18,9 +18,6 @@ targets: type: application platform: iOS deploymentTarget: "26.0" - configFiles: - Debug: Signing.xcconfig - Release: Signing.xcconfig sources: - path: Sources - path: Shared @@ -63,6 +60,8 @@ targets: NSBonjourServices: - "_cdrop._tcp" NSCameraUsageDescription: "Commilitia Drop 使用相机扫描二维码登录新设备。" + # 图库选择:从相册选取图片 / 视频发送(PhotosPicker,见 RootView #14)。 + NSPhotoLibraryUsageDescription: "Commilitia Drop 需要访问照片库,以便从相册选取图片或视频发送给其他设备。" settings: base: PRODUCT_BUNDLE_IDENTIFIER: net.commilitia.Commilitia-Drop @@ -71,18 +70,12 @@ targets: # APNs 推送的 aps-environment entitlement(见 CDrop.entitlements)。模拟器取令牌可用; # 真机签名须付费 ADP 开 Push 能力(账号门控)。 CODE_SIGN_ENTITLEMENTS: CDrop.entitlements - # 真机:用门户 profile 手动签名(仅 device SDK;模拟器走 base ad-hoc)。profile 名取自 - # gitignore 的 Local.xcconfig(见 Signing.xcconfig / REALDEVICE.md)。 - "PROVISIONING_PROFILE_SPECIFIER[sdk=iphoneos*]": "$(CDROP_PROFILE_APP)" # Share Extension:抓分享文件 → App Group 收件箱 → 深链主 app 选设备发送(只交接、不跑引擎, # 见 PLAN §I5)。只编 ShareViewController + 共享的 AppGroup,绝不拉主 app Sources(避超 120MB)。 CDropShare: type: app-extension platform: iOS deploymentTarget: "26.0" - configFiles: - Debug: Signing.xcconfig - Release: Signing.xcconfig sources: - path: Share - path: Shared @@ -103,16 +96,12 @@ targets: PRODUCT_BUNDLE_IDENTIFIER: net.commilitia.Commilitia-Drop.share TARGETED_DEVICE_FAMILY: "1,2" CODE_SIGN_ENTITLEMENTS: Share/CDropShare.entitlements - "PROVISIONING_PROFILE_SPECIFIER[sdk=iphoneos*]": "$(CDROP_PROFILE_SHARE)" # 控制中心剪贴板两控件(WidgetKit ControlWidget,iOS 18+,PLAN §I4)。纯原生 REST + 控件专用 # 设备会话(不跑引擎)。i18n JSON 一并打包使控件标签随设备语言(t() 经 no-subdir 回退读 bundle)。 CDropWidgets: type: app-extension platform: iOS deploymentTarget: "26.0" - configFiles: - Debug: Signing.xcconfig - Release: Signing.xcconfig sources: - path: Widgets - path: Shared @@ -128,7 +117,6 @@ targets: PRODUCT_BUNDLE_IDENTIFIER: net.commilitia.Commilitia-Drop.widgets TARGETED_DEVICE_FAMILY: "1,2" CODE_SIGN_ENTITLEMENTS: Widgets/CDropWidgets.entitlements - "PROVISIONING_PROFILE_SPECIFIER[sdk=iphoneos*]": "$(CDROP_PROFILE_WIDGETS)" schemes: CDrop: build: diff --git a/ios/PLAN.md b/ios/PLAN.md index 3d1a7c1..79d4d94 100644 --- a/ios/PLAN.md +++ b/ios/PLAN.md @@ -111,7 +111,7 @@ Share Extension 落法(据上):**抓文件 → 写 App Group 容器 → | **I6** APNs | 服务端通道(§3)+ 原生注册;不含剪贴板 | `.p8` + 真机硬等账号 | | **I7** 打磨与分发 | 后台窗口;图标 / 启动屏(复用品牌资产);旁加载分发 | 硬等账号 | -> **实现状态(2026-06-27)**:I1/I2/I3/I4/I5/I6 的**代码**全部落地——发送端流式(R-iOS-4,FileSource + 原生 Range,整文件不进 WebView 内存);后台续传(BGContinuedProcessingTask);APNs(后端 `internal/apns` ES256 + 原生注册经引擎桥);Share Extension(App Group 收件箱 + `cdrop://share` 深链);控制中心两控件(**专用 broker 设备会话**,纯原生 REST,不与引擎抢 refresh 轮换)。模拟器验 + ultracode 多 agent 审查修讫(0 HIGH,修 2 MED + 7 LOW)。**I0 账号 / 证书 + 真机签名 / 真发推送 / 旁加载分发**仍账号门控——手册 `ios/CDrop/REALDEVICE.md`(手动签名 + `just ios-device` 全 CLI 装机,门户手动建)。包名 `net.commilitia.Commilitia-Drop`。 +> **实现状态(2026-06-27)**:I1/I2/I3/I4/I5/I6 的**代码**全部落地——发送端流式(R-iOS-4,FileSource + 原生 Range,整文件不进 WebView 内存);后台续传(BGContinuedProcessingTask);APNs(后端 `internal/apns` ES256 + 原生注册经引擎桥);Share Extension(App Group 收件箱 + `cdrop://share` 深链);控制中心两控件(**专用 broker 设备会话**,纯原生 REST,不与引擎抢 refresh 轮换)。模拟器验 + ultracode 多 agent 审查修讫(0 HIGH,修 2 MED + 7 LOW)。**I0 账号 / 证书 + 真机签名 / 真发推送 / 旁加载分发**仍账号门控——手册 `ios/CDrop/REALDEVICE.md`(ASC API Key 自动 provisioning + `just ios-device` 全 CLI 装机,`-allowProvisioningUpdates` 自动登记设备 / 建 profile)。包名 `net.commilitia.Commilitia-Drop`,App Group `group.net.commilitia.Commilitia-Drop`。 --- diff --git a/web/src/engine/main.ts b/web/src/engine/main.ts index 2b83c5b..dd45fc2 100644 --- a/web/src/engine/main.ts +++ b/web/src/engine/main.ts @@ -21,12 +21,12 @@ import { refreshSessionScope } from "../features/auth/auth"; import { fetchClipboard, uploadClipboard } from "../features/clipboard/clipboard"; +import { sendMessage } from "../features/messaging/messaging"; import { apiFetch } from "../net/api"; import { startHub } from "../features/transfer/hub"; import { refreshICEServers, stopICEServerRefresh } from "../features/transfer/iceServers"; import { cancelTransfer, skipWaitRelay, startOutgoingTransfer } from "../features/transfer/transfer"; -import { rangeSource } from "../features/transfer/source"; -import { isIOSShell, notifyNative, onNativeEvent } from "../net/ios"; +import { bridgeFileSource, isIOSShell, notifyNative, onNativeEvent } from "../net/ios"; import { useAppStore } from "../store"; import type { TransferRecord } from "../store/types"; @@ -101,6 +101,7 @@ function subscribeStore(): void let prevActive = st0.activeTransfers; let prevDoneId = st0.history[0]?.sessionId; let prevDevices = st0.devices; + let prevMsgId = st0.messages[0]?.id; let prevSse = st0.sseConnected; let prevAuthed = st0.accessToken !== null; let prevRefresh = st0.refreshToken; @@ -139,6 +140,16 @@ function subscribeStore(): void notifyNative("presence", { devices: s.devices }); pushHubState(s); } + // 设备间文本消息:messages 最新在前(addMessage 前插),每次新增就把新的头条作为「增量」 + // 推原生(而非整列)。原生持久化记录、按 id 去重前插——单条增量避免引擎重启后空列表整组 + // 覆盖掉原生已持久化的历史消息(重启清空问题的根因)。messages 由 hub.ts handleIncomingMessage + // (入)与 sendMessage(出)维护。 + const headMsg = s.messages[0]; + if (headMsg && headMsg.id !== prevMsgId) + { + prevMsgId = headMsg.id; + notifyNative("message", { message: headMsg }); + } if (s.sseConnected !== prevSse) { prevSse = s.sseConnected; @@ -167,14 +178,15 @@ function subscribeStore(): void }); } -// handleSendFile:原生把待发文件经 WKURLSchemeHandler 以 payload.url 暴露。R-iOS-4:用 -// rangeSource 惰性按块拉取(原生据 Range 头 seek 文件、回 206),整文件永不进 WebView -// 内存——大文件不再有 jetsam 风险,传输层 p2p / relay 透明复用。 +// handleSendFile:原生把待发文件经 payload.url(cdrop-file://)暴露。R-iOS-4:用 +// bridgeFileSource 经桥按块取片(原生据 [start,end) seek 文件、回 base64),整文件永不整体进 +// WebView 内存——大文件不再有 jetsam 风险,传输层 p2p / relay 透明复用。改走桥而非跨 origin +// fetch,绕开自定义 scheme 的 CORS 拦截(否则发送在读文件即失败)。 async function handleSendFile(p: SendFilePayload): Promise { try { - const src = rangeSource(p.url, p.name, p.size, p.type ?? ""); + const src = bridgeFileSource(p.url, p.name, p.size, p.type ?? ""); const sessionId = await startOutgoingTransfer(p.target, src); notifyNative("sendStarted", { sessionId, name: p.name }); } @@ -237,13 +249,33 @@ function bindCommands(): void }) .catch((e) => notifyNative("error", { stage: "clipboard", message: String(e) })); }); - // 设备管理:移除 / 吊销一台设备(DELETE /api/devices/{name})。需完整会话;若服务端要求 - // step-up(403)这里拿不到浏览器再认证流程,回错给原生提示「请在网页端完成」。 + // 设备管理:移除 / 吊销一台设备(DELETE /api/devices/{device_id})。按稳定 device_id(路由 + // 参数即 device_id),原生从 presence 的 device_id 传来——之前误传设备名致后端按 device_id + // 找不到而「移除失败」。需完整会话;403(step-up)回错给原生提示「请在网页端完成」。 onNativeEvent("revokeDevice", (payload) => { - const name = (payload as { name?: string }).name ?? ""; - if (!name) { return; } - void revokeDevice(name); + const p = payload as { device_id?: string; name?: string }; + const id = p.device_id ?? ""; + if (!id) { return; } + void revokeDevice(id); + }); + // 设备间文本消息(发送):原生把 { to=对端设备名, text } 送来,经 POST /api/message 转发(hub + // SendTo 按名解析到对端 live 连接)。发送成功后 store.addMessage 会触发上面的 message 订阅回推。 + onNativeEvent("sendMessage", (payload) => + { + const p = payload as { to?: string; text?: string }; + if (!p.to || !p.text) { return; } + void sendMessage(p.to, p.text) + .catch((e) => notifyNative("error", { stage: "message", message: e instanceof Error ? e.message : String(e) })); + }); + // 本机即时改名(设备名与令牌解耦):原生送来 { device_id=本机, name },经 PATCH /api/devices/ + // {device_id} 只改名字、不换 token、不重连——后端按 device_id 改名 + 重广播 presence。成功后 + // 更新引擎 selfDeviceName(后续 X-Device-Name 用新名),并回报原生更新本地名 / Keychain。 + onNativeEvent("renameSelf", (payload) => + { + const p = payload as { device_id?: string; name?: string }; + if (!p.device_id || !p.name) { return; } + void renameSelf(p.device_id, p.name); }); // APNs 设备令牌登记:原生(AppDelegate)拿到令牌经桥送来,引擎用新鲜会话 token 上报后端。 onNativeEvent("registerPush", (payload) => @@ -311,11 +343,11 @@ async function provisionWidgetSession(deviceId: string, deviceName: string): Pro } } -async function revokeDevice(name: string): Promise +async function revokeDevice(deviceID: string): Promise { try { - const r = await apiFetch(`/api/devices/${encodeURIComponent(name)}`, { method: "DELETE" }); + const r = await apiFetch(`/api/devices/${encodeURIComponent(deviceID)}`, { method: "DELETE" }); if (r.status === 403) { notifyNative("error", { stage: "revoke", message: "step_up_required" }); @@ -326,7 +358,7 @@ async function revokeDevice(name: string): Promise notifyNative("error", { stage: "revoke", message: `HTTP ${r.status}` }); return; } - notifyNative("deviceRevoked", { name }); + notifyNative("deviceRevoked", { device_id: deviceID }); } catch (e) { @@ -334,6 +366,59 @@ async function revokeDevice(name: string): Promise } } +// renameSelf renames THIS device by its stable device_id, decoupled from the session token: +// PATCH /api/devices/{device_id} only changes the name (no re-mint), then the engine adopts the +// new name as X-Device-Name and reports it back so the native shell persists it. +async function renameSelf(deviceID: string, name: string): Promise +{ + try + { + const r = await apiFetch(`/api/devices/${encodeURIComponent(deviceID)}`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ name }), + }); + if (!r.ok) + { + notifyNative("error", { stage: "rename", message: `HTTP ${r.status}` }); + return; + } + useAppStore.getState().setSelfDeviceName(name); + notifyNative("renamed", { name }); + } + catch (e) + { + notifyNative("error", { stage: "rename", message: e instanceof Error ? e.message : String(e) }); + } +} + +// healIOSIdentity corrects the display identity after a QR login. collectQRSession returns the +// subject UUID as the name (cdrop holds no accounts table; qr.go), so the injected session shows +// "你好, ". Once the token is live at the edge, /api/me carries the real X-Auth-Name: read +// it, update the store, and push it to the native shell (which persists it to the Keychain so the +// real name survives a relaunch). Best-effort; a failure just leaves the prior name. +async function healIOSIdentity(): Promise +{ + try + { + const r = await apiFetch("/api/me"); + if (!r.ok) { return; } + const me = (await r.json()) as { user_id?: string; name?: string; avatar?: string }; + if (!me?.user_id) { return; } + const realName = me.name && me.name !== me.user_id ? me.name : ""; + if (!realName) { return; } + const cur = useAppStore.getState(); + if (!cur.user) { return; } + if (cur.user.name === realName && cur.user.avatar === (me.avatar || cur.user.avatar)) { return; } + useAppStore.getState().setAuth({ + accessToken: cur.accessToken, + user: { id: cur.user.id, name: realName, avatar: me.avatar || cur.user.avatar }, + }); + notifyNative("identityUpdated", { user_id: me.user_id, name: realName, avatar: me.avatar ?? "" }); + } + catch { /* best-effort identity heal */ } +} + // installLogBridge:把 console.warn / error 过桥给原生(设置页显示「最近日志」)。SSE 失败 // 等都走 console.warn,这样不接 Web Inspector 也能在真机看到失败原因(401 / 网络 / 静默挂起)。 function installLogBridge(): void @@ -384,6 +469,7 @@ function boot(): void void startHub(hubCtrl.signal); // SSE 信令循环:presence / 传入 offer / 状态 / 信令 void refreshICEServers(); // 预取 TURN / STUN,首次 WebRTC 即可用 void refreshSessionScope(); // 校正 /api/me 的权限级别 + void healIOSIdentity(); // 用 /api/me 的真名替换 QR 登录注入的 UUID 名(#12) // 启动即推一次当前快照:subscribe 只在「之后」的变更触发,初始态(多为空,但桌面壳 // 复用同入口时可能已有)须主动补发,免原生 UI 等到下一次变更才填。 diff --git a/web/src/features/auth/auth.ts b/web/src/features/auth/auth.ts index 0171d1c..626a276 100644 --- a/web/src/features/auth/auth.ts +++ b/web/src/features/auth/auth.ts @@ -49,18 +49,22 @@ export function loginRedirect(): void // ---- logout --------------------------------------------------------------- -export function logout() +export async function logout(): Promise { // Fire-and-forget: tell the backend to kick this device from the Hub immediately // (so peers don't wait out the 30s grace). apiFetch reads the access token before // yielding, so the subsequent clearAuth can't strip this request's Authorization. void apiFetch("/api/me/disconnect", { method: "POST" }).catch(() => { /* ignore */ }); - // Revoke this device's own broker session server-side (self-service logout), so it - // can't be refreshed back. For a global-SSO browser this is a no-op (nothing to - // revoke). Best-effort. + // Revoke this device's own broker session server-side BEFORE clearing local credentials, + // so it can't be refreshed back. This must be AWAITED, not fire-and-forget: on desktop the + // refresh token lives in the Go process and clearDesktopSession() drops it — if we cleared + // first, an expired-access 401 here could no longer refresh-and-retry and the broker session + // would leak (the "桌面登出静默失败" half that isn't the missing confirm dialog). apiFetch + // transparently refreshes the access token for this call. Global-SSO browser → server no-op. if (useAppStore.getState().authMode === "prod") { - void apiFetch("/api/auth/logout", { method: "POST" }).catch(() => { /* ignore */ }); + try { await apiFetch("/api/auth/logout", { method: "POST" }); } + catch { /* best-effort; clear local state regardless */ } } useAppStore.getState().clearAuth(); // Desktop: drop the Go-side persisted session, else the next launch re-injects it. @@ -277,6 +281,49 @@ export async function ensureDeviceSession(): Promise } } +// renameDevice changes this device's display name WITHOUT touching its session token — the +// decoupled rename. It PATCHes /api/devices/{device_id}; the server updates the name keyed by +// the stable device_id and re-broadcasts presence, so every peer sees the new name at once with +// no token rotation, no SSE interruption, and no duplicate device row. Falls back to a 代铸 +// re-mint only when this browser has no managed device session yet (cookie-only, pre-代铸) or the +// row is unexpectedly gone (404). Caller has already updated selfDeviceName (drives X-Device-Name). +export async function renameDevice(): Promise +{ + const st = useAppStore.getState(); + if (st.authMode !== "prod") { return; } + const name = st.selfDeviceName; + if (!name) { return; } + + const storedId = (typeof window !== "undefined") + ? (window.localStorage.getItem(DEVICE_ID_KEY) ?? "") + : ""; + // No managed device session yet (no token / no device_id): adopt the name at 代铸 instead. + if (!storedId || (!st.accessToken && !isDesktop())) + { + await renameDeviceSession(); + return; + } + + let r: Response; + try + { + r = await apiFetch(`/api/devices/${encodeURIComponent(storedId)}`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ name }), + }); + } + catch + { + // Transient network error: the local name is already set and rides X-Device-Name; a + // later presence refresh / reconnect reconciles. Don't fall back to a token-rotating + // re-mint on a transient error. + return; + } + // 404 = the device row is gone (e.g. revoked elsewhere): re-mint to re-register under the name. + if (r.status === 404) { await renameDeviceSession(); } +} + // renameDeviceSession re-代铸s with the (already-updated) selfDeviceName and the same stable // device_id, so the broker label — hence the unified device list and presence — reflects the // new name. R2 rotates the one session in place (no duplicate device row); the rotated tokens diff --git a/web/src/features/clipboard/ClipboardPanel.tsx b/web/src/features/clipboard/ClipboardPanel.tsx index 9720085..1c92b35 100644 --- a/web/src/features/clipboard/ClipboardPanel.tsx +++ b/web/src/features/clipboard/ClipboardPanel.tsx @@ -8,6 +8,7 @@ import { Trash2, } from "lucide-react"; import { Button, DynText, IconButton, Panel, Tooltip } from "../../ui/primitives"; +import { confirmDialog } from "../../utils/confirm"; import { toast } from "../../ui/feedback"; import type { ClipboardState } from "../../store"; import { t } from "../../i18n"; @@ -110,7 +111,7 @@ export function ClipboardPanel(props: ClipboardPanelProps) const handleClear = async () => { if (!hasContent) { return; } - if (!window.confirm(t("home.clipboard.clearConfirm"))) { return; } + if (!await confirmDialog(t("home.clipboard.clearConfirm"))) { return; } setBusy(true); try { diff --git a/web/src/features/shortcut/ShortcutTokens.tsx b/web/src/features/shortcut/ShortcutTokens.tsx index 81f80be..89351b3 100644 --- a/web/src/features/shortcut/ShortcutTokens.tsx +++ b/web/src/features/shortcut/ShortcutTokens.tsx @@ -3,6 +3,7 @@ import { Group, Stack, Text } from "@mantine/core"; import { Copy, KeyRound, Plus } from "lucide-react"; import { apiJSON } from "../../net/api"; import { t } from "../../i18n"; +import { confirmDialog } from "../../utils/confirm"; import { formatRelative } from "../../utils/format"; import { Badge, Button, Callout, DynText, Panel, TextField } from "../../ui/primitives"; import { toast } from "../../ui/feedback"; @@ -113,7 +114,7 @@ export function ShortcutTokens() const handleRevoke = async (token: ShortcutTokenView) => { - if (!window.confirm(t("settings.shortcut.revokeConfirm"))) { return; } + if (!await confirmDialog(t("settings.shortcut.revokeConfirm"))) { return; } setRevoking((prev) => new Set(prev).add(token.jti)); try diff --git a/web/src/features/transfer/hub.ts b/web/src/features/transfer/hub.ts index 10009c0..3d79d73 100644 --- a/web/src/features/transfer/hub.ts +++ b/web/src/features/transfer/hub.ts @@ -194,6 +194,7 @@ function handlePresence(data: unknown): void const r = raw as Record; if (typeof r.name !== "string" || typeof r.type !== "string") { continue; } devices.push({ + deviceId: typeof r.device_id === "string" && r.device_id ? r.device_id : undefined, name: r.name, type: r.type, online: Boolean(r.online), @@ -202,7 +203,10 @@ function handlePresence(data: unknown): void } const store = useAppStore.getState(); - const prevOnline = new Map(store.devices.map((d) => [d.name, d.online] as const)); + // Track online state by the stable device_id (fall back to name for a code-less device), + // so a rename — which changes only the name — is never misread as offline-then-online. + const idOf = (d: DeviceInfo): string => d.deviceId ?? d.name; + const prevOnline = new Map(store.devices.map((d) => [idOf(d), d.online] as const)); store.setDevices(devices); // Detect peers that just transitioned to offline; if any active transfer @@ -210,7 +214,7 @@ function handlePresence(data: unknown): void // by transfer:state FAILED) will remove the entry, so we don't double-fire. for (const d of devices) { - if (!d.online && prevOnline.get(d.name)) + if (!d.online && prevOnline.get(idOf(d))) { for (const t of Object.values(store.activeTransfers)) { diff --git a/web/src/i18n/locales/en-US.ts b/web/src/i18n/locales/en-US.ts index c3e4781..f72a20b 100644 --- a/web/src/i18n/locales/en-US.ts +++ b/web/src/i18n/locales/en-US.ts @@ -355,6 +355,9 @@ export const enUS: Partial = { "ios.login.guide": "Scan to approve from another signed-in device", "ios.login.expired": "Code expired — please try again", "ios.login.failed": "Sign-in failed — please try again", + "ios.login.broker": "Sign In", + "ios.login.brokerStarting": "Opening sign-in…", + "ios.login.scanAlt": "Sign in by scanning from another device", "ios.login.refresh": "Refresh QR Code", "ios.login.needFull": "This device needs full access — choose \"Trust this device\" when approving.", "ios.send.pickDevice": "Choose a device", @@ -397,7 +400,18 @@ export const enUS: Partial = { "ios.settings.user": "User", "ios.settings.logout": "Sign Out", "ios.settings.deviceName": "Device Name", - "ios.settings.deviceNameNote": "Renaming takes effect on next sign-in", + "ios.settings.deviceNameNote": "Renaming takes effect at once and syncs to all devices, without affecting sign-in.", + "ios.settings.deviceName.success": "Device name updated", + "ios.tab.messages": "Messages", + "ios.send.fromFiles": "From Files", + "ios.send.fromPhotos": "From Photos", + "ios.messages.empty": "No messages yet", + "ios.messages.noPeers": "No device to message", + "ios.messages.placeholder": "Type a message…", + "ios.records.clear": "Clear", + "ios.records.clearConfirm": "Clear all records? This cannot be undone.", + "ios.transfer.cancel": "Cancel transfer", + "ios.transfer.forceRelay": "Relay now", "ios.settings.deviceCount": "Known Devices", "ios.settings.signaling": "Signaling", "ios.settings.presenceEvents": "Presence Events", diff --git a/web/src/i18n/locales/zh-CN.ts b/web/src/i18n/locales/zh-CN.ts index 7c51482..ee4b625 100644 --- a/web/src/i18n/locales/zh-CN.ts +++ b/web/src/i18n/locales/zh-CN.ts @@ -351,6 +351,9 @@ export const zhCN = { "ios.login.guide": "用另一台已登录的设备扫码批准", "ios.login.expired": "二维码已失效,请重试", "ios.login.failed": "登录失败,请重试", + "ios.login.broker": "登录", + "ios.login.brokerStarting": "正在打开登录…", + "ios.login.scanAlt": "用其他设备扫码登录", "ios.login.refresh": "刷新二维码", "ios.login.needFull": "此设备需要完整权限,批准时请选择“信任此设备”", "ios.send.pickDevice": "选择接收设备", @@ -393,7 +396,18 @@ export const zhCN = { "ios.settings.user": "用户", "ios.settings.logout": "退出登录", "ios.settings.deviceName": "设备名称", - "ios.settings.deviceNameNote": "改名将在下次登录后生效", + "ios.settings.deviceNameNote": "改名即时生效并同步到所有设备,不影响登录。", + "ios.settings.deviceName.success": "设备名已更新", + "ios.tab.messages": "消息", + "ios.send.fromFiles": "从文件", + "ios.send.fromPhotos": "从图库", + "ios.messages.empty": "暂无消息", + "ios.messages.noPeers": "没有可发送消息的设备", + "ios.messages.placeholder": "输入消息…", + "ios.records.clear": "清空", + "ios.records.clearConfirm": "确定清空全部记录?此操作不可撤销。", + "ios.transfer.cancel": "取消传输", + "ios.transfer.forceRelay": "立即切到中继", "ios.settings.deviceCount": "已知设备", "ios.settings.signaling": "信令连接", "ios.settings.presenceEvents": "在线事件", diff --git a/web/src/i18n/locales/zh-TW.ts b/web/src/i18n/locales/zh-TW.ts index b3ad692..1fa4a99 100644 --- a/web/src/i18n/locales/zh-TW.ts +++ b/web/src/i18n/locales/zh-TW.ts @@ -355,6 +355,9 @@ export const zhTW: Partial = { "ios.login.guide": "用另一台已登入的裝置掃碼核准", "ios.login.expired": "QR 碼已失效,請重試", "ios.login.failed": "登入失敗,請重試", + "ios.login.broker": "登入", + "ios.login.brokerStarting": "正在開啟登入…", + "ios.login.scanAlt": "用其他裝置掃碼登入", "ios.login.refresh": "重新整理 QR 碼", "ios.login.needFull": "此裝置需要完整權限,批准時請選擇「信任此裝置」", "ios.send.pickDevice": "選擇接收裝置", @@ -397,7 +400,18 @@ export const zhTW: Partial = { "ios.settings.user": "使用者", "ios.settings.logout": "登出", "ios.settings.deviceName": "裝置名稱", - "ios.settings.deviceNameNote": "改名將在下次登入後生效", + "ios.settings.deviceNameNote": "改名即時生效並同步到所有裝置,不影響登入。", + "ios.settings.deviceName.success": "裝置名稱已更新", + "ios.tab.messages": "訊息", + "ios.send.fromFiles": "從檔案", + "ios.send.fromPhotos": "從相簿", + "ios.messages.empty": "尚無訊息", + "ios.messages.noPeers": "沒有可傳送訊息的裝置", + "ios.messages.placeholder": "輸入訊息…", + "ios.records.clear": "清空", + "ios.records.clearConfirm": "確定清空全部記錄?此操作無法復原。", + "ios.transfer.cancel": "取消傳輸", + "ios.transfer.forceRelay": "立即切到中繼", "ios.settings.deviceCount": "已知裝置", "ios.settings.signaling": "信令連線", "ios.settings.presenceEvents": "上線事件", diff --git a/web/src/main.tsx b/web/src/main.tsx index 5299a17..079a8b9 100644 --- a/web/src/main.tsx +++ b/web/src/main.tsx @@ -10,6 +10,7 @@ import { type MantineColorsTuple, } from "@mantine/core"; import { RouterProvider, createRouter } from "@tanstack/react-router"; +import { ConfirmHost } from "./ui/ConfirmHost"; import { ToastViewport } from "./ui/feedback"; import { routeTree } from "./routeTree.gen"; import { useAppStore, type ThemeMode } from "./store"; @@ -107,6 +108,7 @@ function App() forceColorScheme={force} > + ); diff --git a/web/src/net/ios.ts b/web/src/net/ios.ts index d41747c..1c5167f 100644 --- a/web/src/net/ios.ts +++ b/web/src/net/ios.ts @@ -19,6 +19,8 @@ // - 接收落盘四方法 beginDownload / appendDownload / finalizeDownload / // abortDownload 与桌面 desktop.ts 同形(data 为 base64),落 iOS 沙盒文件。 +import type { FileSource } from "../features/transfer/source"; + // WKScriptMessageHandler 的 JS 侧投递接口(仅 postMessage)。 interface WebKitMessageHandler { @@ -146,6 +148,15 @@ function bytesToBase64(bytes: Uint8Array): string return btoa(binary); } +// base64ToBytes 把原生回传的 base64(发送侧按 Range 读出的文件片)解回字节。 +function base64ToBytes(b64: string): Uint8Array +{ + const binary = atob(b64); + const out = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i += 1) { out[i] = binary.charCodeAt(i); } + return out; +} + // saveIncomingFileIOS:整文件(接收端小文件路径)经 base64 过桥交原生写入沙盒下载 // 目录,返回最终绝对路径。桥缺失或写盘失败抛错,由调用方回退。 export async function saveIncomingFileIOS(name: string, blob: Blob): Promise @@ -177,3 +188,48 @@ export async function abortIncomingDownloadIOS(sessionId: string): Promise try { await callNative("abortDownload", { sessionId }); } catch { /* 取消 / 失败路径,吞掉 */ } } + +// ── 发送侧文件源(经桥按 Range 取片) ──────────────────────────────────────── + +// 每次桥取片的上限:把单次 evaluateJavaScript 回传的 base64 串控制在 ~683 KiB(512 KiB×4/3), +// 远小于会卡顿的体量。p2p 一次读 4 MiB、relay 一次 1 MiB,bridgeFileSource 内部按此循环拼齐。 +const BRIDGE_SLICE_MAX = 512 * 1024; + +// readFileSliceIOS:经桥让原生按 [start, end) seek 读暂存的待发文件、回 base64,再解回字节。 +// 取代原先「fetch(cdrop-file://) + Range」——引擎在 https origin 下跨 origin fetch 自定义 scheme +// 被 CORS 拦死(Range 头触发预检、scheme handler 无 CORS 头)→ 发送在读文件即失败。改走桥与 +// 接收落盘同形,绕开 CORS;整文件仍永不整体进 WebView 内存(按需取片)。 +async function readFileSliceIOS(url: string, start: number, end: number): Promise> +{ + const b64 = await callNative("readFileSlice", { url, start, end }); + return base64ToBytes(b64); +} + +// bridgeFileSource:iOS 无头引擎的发送字节源(取代 rangeSource 的跨 origin fetch)。slice 把请求 +// 区间切成 ≤BRIDGE_SLICE_MAX 的子片逐次过桥取回再拼齐,返回严格等于 [start, end) 的副本,故 +// p2p / relay 的块读语义完全不变。 +export function bridgeFileSource(url: string, name: string, size: number, type: string): FileSource +{ + return { + name, + size, + type: type || "application/octet-stream", + async slice(start, end) + { + const total = end - start; + if (total <= 0) { return new Uint8Array(0); } + const out = new Uint8Array(total); + let pos = 0; + while (pos < total) + { + const s = start + pos; + const e = Math.min(s + BRIDGE_SLICE_MAX, end); + const bytes = await readFileSliceIOS(url, s, e); + if (bytes.byteLength === 0) { break; } // 防御:原生异常返回空,避免死循环 + out.set(bytes.subarray(0, Math.min(bytes.byteLength, total - pos)), pos); + pos += bytes.byteLength; + } + return out; + }, + }; +} diff --git a/web/src/net/qr.ts b/web/src/net/qr.ts index 08e5c0d..6af5149 100644 --- a/web/src/net/qr.ts +++ b/web/src/net/qr.ts @@ -1,4 +1,5 @@ import { apiFetch, apiJSON } from "./api"; +import { isDesktop } from "./desktop"; // 扫码登录的网络封装。两条信任边界: // - 显码页(新设备)在 start 时还未登录,故 start / status 是公开端点, @@ -264,19 +265,23 @@ export interface LinkParams } // parseLinkApprovalUrl 把扫到的二维码内容解析成批准页参数,严格校验后才放行—— -// 这是应用内扫码器的安全闸门,决定扫到的码能否在已登录会话内被导航。 -// 只接受「本站 origin + /link 路径 + 同时带 r 与 c」的 URL: +// 这是应用内扫码器的安全闸门,决定扫到的码能否在已登录会话内被导航。校验: // - 同源(origin 严格等于当前页 origin)——挡开放重定向 / 钓鱼站二维码; // - 路径恰为 /link——挡指向本站其他页的码; // - r/c 皆非空——挡残缺码。 // 任一不满足返回 null(调用方据此提示「继续对准」,不导航)。 +// +// 桌面例外:Wails 壳跑在 wails:// 自定义 scheme origin,而二维码恒带 https 站点 origin,故同源 +// 校验在桌面永不可能命中 → 桌面扫任何码都被误判「不是本站的码」。桌面放宽 origin 等值校验(仍留 +// /link 路径 + r/c 校验)是安全的:批准用的 r/c 一律发往本端自己的后端(与码的 origin 无关,外站 +// 码只会 404),且导航目标是 SPA 自己的 /link 路由而非码里的外部 URL,故无开放重定向风险。 export function parseLinkApprovalUrl(raw: string): LinkParams | null { let url: URL; try { url = new URL(raw); } catch { return null; } - if (url.origin !== window.location.origin) { return null; } + if (!isDesktop() && url.origin !== window.location.origin) { return null; } if (url.pathname !== "/link") { return null; } const requestId = url.searchParams.get("r"); diff --git a/web/src/routes/settings.tsx b/web/src/routes/settings.tsx index ee3bce4..96f51a8 100644 --- a/web/src/routes/settings.tsx +++ b/web/src/routes/settings.tsx @@ -9,7 +9,8 @@ import { } from "@mantine/core"; import { createFileRoute, Link, redirect, useNavigate } from "@tanstack/react-router"; import { Bell, LogOut, ShieldAlert, Trash2 } from "lucide-react"; -import { logout, renameDeviceSession, syncWebDeviceName } from "../features/auth/auth"; +import { logout, renameDevice, syncWebDeviceName } from "../features/auth/auth"; +import { confirmDialog } from "../utils/confirm"; import { DesktopSettings } from "../features/desktop/DesktopSettings"; import { isDesktop, persistDesktopDeviceName } from "../net/desktop"; import { @@ -73,14 +74,13 @@ function SettingsPage() setRenaming(true); try { - // 改名只换 label,不换身份(device_id 稳定):先落本地名(root effect 会以新名重连 - // SSE),再以同一 device_id 重新代铸,把新 label 推给 broker、原地轮换那条会话, - // 故统一设备列表与 presence 都显示新名、不产生重复设备行。桌面在此只更新本地名, - // broker label 于下次登录代铸时同步。 + // 改名与会话令牌解耦:先落本地名(root effect 会以新名作 X-Device-Name),再 PATCH + // /api/devices/{device_id} 只改名字——服务端按稳定 device_id 改名 + 重广播 presence, + // 不换 token、不中断 SSE、不产生重复设备行;全端即时看到新名。桌面同步持久化本地名。 setSelfDeviceName(trimmedName); persistDesktopDeviceName(trimmedName); // 桌面:持久化到 Go 配置,跨重启存活 syncWebDeviceName(trimmedName); // 浏览器:持久化到服务端 session,抗 PWA 存储清除 - await renameDeviceSession(); + await renameDevice(); toast.ok(t("settings.deviceName.success")); } catch (e) @@ -95,20 +95,21 @@ function SettingsPage() } }; - const handleUnregisterSelf = () => + const handleUnregisterSelf = async () => { if (!selfDeviceName) { return; } - if (!window.confirm(t("settings.unregister.currentConfirm"))) { return; } + if (!await confirmDialog(t("settings.unregister.currentConfirm"))) { return; } // logout() 已自吊销本设备的 broker 会话(/api/auth/logout,按 device_id),故无需再按 // 设备名 DELETE(迁移后设备路由按 device_id,旧的按名删除一律 404、且属冗余)。 + // await:先完成 broker 吊销再清本地(桌面 clearDesktopSession 会丢 Go 侧 refresh)。 setSelfDeviceName(null); - logout(); + await logout(); navigate({ to: "/login", search: { dev_user: undefined } }); }; - const handleSignOut = () => + const handleSignOut = async () => { - logout(); + await logout(); navigate({ to: "/login", search: { dev_user: undefined } }); }; @@ -357,6 +358,16 @@ function SessionsPanel(props: { isGuest: boolean; onSignedOutSelf: () => void }) void reload(); }, [ isGuest, reload ]); + // 会话列表随 presence 自动刷新(N):设备上线 / 下线 / 被吊销(含别处登出)都改 store.devices, + // 这里据此防抖重拉 /api/auth/sessions,使已登出设备及时从列表消失,不再需要手动刷新页面。 + const devices = useAppStore((s) => s.devices); + useEffect(() => + { + if (isGuest) { return; } + const timer = window.setTimeout(() => { void reload(); }, 800); + return () => window.clearTimeout(timer); + }, [ devices, isGuest, reload ]); + // handleRevoke 登出一条会话(= 一台设备)。后端连带 broker 吊销 + 删设备行。 // step-up 二次认证已移除(full 档即可信)。 const handleRevoke = async (sess: AuthSession) => @@ -364,7 +375,7 @@ function SessionsPanel(props: { isGuest: boolean; onSignedOutSelf: () => void }) const confirmMsg = sess.current ? t("settings.sessions.confirmCurrent") : t("settings.sessions.confirmOther", { name: sess.deviceName }); - if (!window.confirm(confirmMsg)) { return; } + if (!await confirmDialog(confirmMsg)) { return; } setBusyId(sess.id); try diff --git a/web/src/store/types.ts b/web/src/store/types.ts index 1fa2f0a..e138b73 100644 --- a/web/src/store/types.ts +++ b/web/src/store/types.ts @@ -16,6 +16,9 @@ export interface User export interface DeviceInfo { + /** 稳定不透明设备 id(broker meta)。设备名与令牌解耦后,它是去重 / 判本机 / 跨端 + 吊销的真正标识——改名只换 name,deviceId 不变。后端 presence 总会带;旧缓存可能缺。 */ + deviceId?: string; name: string; type: string; online: boolean; diff --git a/web/src/ui/ConfirmHost.tsx b/web/src/ui/ConfirmHost.tsx new file mode 100644 index 0000000..33c52b1 --- /dev/null +++ b/web/src/ui/ConfirmHost.tsx @@ -0,0 +1,31 @@ +import { Button, Group, Modal, Text } from "@mantine/core"; +import { t } from "../i18n"; +import { useConfirmStore } from "../utils/confirm"; + +// 全局确认框宿主:挂一次于 React 树(main.tsx,MantineProvider 内)。confirmDialog() 触发时弹出, +// 用户点确认 / 取消结清对应 Promise。DOM 渲染,浏览器 + Wails 桌面均可用(取代失效的 window.confirm)。 +export function ConfirmHost() +{ + const pending = useConfirmStore((s) => s.pending); + const settle = useConfirmStore((s) => s.settle); + + return ( + settle(false)} + title={t("common.confirm")} + centered + withCloseButton={false} + > + {pending?.message} + + + + + + ); +} diff --git a/web/src/utils/confirm.ts b/web/src/utils/confirm.ts new file mode 100644 index 0000000..df0a516 --- /dev/null +++ b/web/src/utils/confirm.ts @@ -0,0 +1,42 @@ +import { create } from "zustand"; + +// 应用内确认框(取代在 Wails 桌面 WebView 失效的 window.confirm)。confirmDialog 弹一个 DOM +// 渲染的 Mantine Modal(浏览器 + 桌面都可用),返回用户是否确认。须在 React 树里挂一次 +// (见 main.tsx)。破坏性操作(移除设备 / 全部清除 / 撤销令牌等)用它做二次确认。 + +interface PendingConfirm +{ + message: string; + resolve: (ok: boolean) => void; +} + +interface ConfirmStore +{ + pending: PendingConfirm | null; + open: (message: string) => Promise; + settle: (ok: boolean) => void; +} + +export const useConfirmStore = create((set, get) => +({ + pending: null, + open: (message) => new Promise((resolve) => + { + // 同一时刻只挂一个确认框;若已有未决的,先把它按「取消」结清,避免 resolve 泄漏。 + const prev = get().pending; + if (prev) { prev.resolve(false); } + set({ pending: { message, resolve } }); + }), + settle: (ok) => + { + const p = get().pending; + if (!p) { return; } + set({ pending: null }); + p.resolve(ok); + }, +})); + +export function confirmDialog(message: string): Promise +{ + return useConfirmStore.getState().open(message); +}