设备名与令牌解耦 + iOS Broker 应用内登录 / 消息 / 图库 / 记录持久化 + 三端 bug 修复

设备名与令牌解耦(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)
This commit is contained in:
2026-06-27 03:50:04 +08:00
parent 44096069a7
commit 127070e226
51 changed files with 1655 additions and 227 deletions
+7 -5
View File
@@ -77,13 +77,15 @@ desktop-doctor:
ios-devices: ios-devices:
xcrun devicectl list devices xcrun devicectl list devices
# 真机构建 + 装机(手动签名)。前置:付费 ADP门户建好 App ID/App Group/Push/设备/profile # 真机构建 + 装机(自动 provisioning,经 ASC API Key)。前置:付费 ADPApp IDApp Groups /
# 按 ios/CDrop/REALDEVICE.md 建好 gitignore 的 ios/CDrop/Local.xcconfigTeam ID + 3 个 profile 名)。 # Push 能力(门户建好或自动建);根目录 gitignore 的 .env 配齐:CDROP_TEAM_ID / CDROP_ASC_KEY_PATH
# 模拟器构建不需这些(base ad-hoc)。用法:just ios-device <设备UDID>UDID 见 just ios-devices)。 # .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: 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 "缺 .envCDROP_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 && 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 xcrun devicectl device install app --device {{udid}} ios/CDrop/build/DD-device/Build/Products/Debug-iphoneos/CDrop.app
# ---- deploy plumbing ---- # ---- deploy plumbing ----
+3
View File
@@ -54,6 +54,9 @@ func (a *App) startup(ctx context.Context) {
_ = platform.SetLaunchAtLogin(true) _ = platform.SetLaunchAtLogin(true)
} }
a.startClipboardSync(ctx) a.startClipboardSync(ctx)
// 触发 macOS 本地网络权限(macOS 15+ 隐私门):使本进程 WKWebView 的 WebRTC 能收集 host /
// mDNS 候选、同内网走直连而非 prflx↔prflx 慢路径(见 platform.TriggerLocalNetwork)。其他平台空实现。
platform.TriggerLocalNetwork()
platform.InstallStatusBar( platform.InstallStatusBar(
platform.StatusBarMenu{ platform.StatusBarMenu{
Title: "cdrop", Title: "cdrop",
+8
View File
@@ -23,6 +23,14 @@
<string>true</string> <string>true</string>
<key>NSHumanReadableCopyright</key> <key>NSHumanReadableCopyright</key>
<string>{{.Info.Copyright}}</string> <string>{{.Info.Copyright}}</string>
<key>NSCameraUsageDescription</key>
<string>Commilitia Drop 使用相机扫描二维码以登录 / 批准设备。</string>
<key>NSLocalNetworkUsageDescription</key>
<string>Commilitia Drop 使用本地网络发现同内网设备并建立点对点直连传输。</string>
<key>NSBonjourServices</key>
<array>
<string>_cdrop._tcp</string>
</array>
{{if .Info.FileAssociations}} {{if .Info.FileAssociations}}
<key>CFBundleDocumentTypes</key> <key>CFBundleDocumentTypes</key>
<array> <array>
+8
View File
@@ -23,6 +23,14 @@
<string>true</string> <string>true</string>
<key>NSHumanReadableCopyright</key> <key>NSHumanReadableCopyright</key>
<string>{{.Info.Copyright}}</string> <string>{{.Info.Copyright}}</string>
<key>NSCameraUsageDescription</key>
<string>Commilitia Drop 使用相机扫描二维码以登录 / 批准设备。</string>
<key>NSLocalNetworkUsageDescription</key>
<string>Commilitia Drop 使用本地网络发现同内网设备并建立点对点直连传输。</string>
<key>NSBonjourServices</key>
<array>
<string>_cdrop._tcp</string>
</array>
{{if .Info.FileAssociations}} {{if .Info.FileAssociations}}
<key>CFBundleDocumentTypes</key> <key>CFBundleDocumentTypes</key>
<array> <array>
+21
View File
@@ -0,0 +1,21 @@
//go:build darwin
package platform
/*
#cgo darwin CFLAGS: -x objective-c -fobjc-arc
#cgo darwin LDFLAGS: -framework Network
#include <stdlib.h>
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()
}
+33
View File
@@ -0,0 +1,33 @@
#import <Network/Network.h>
// 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);
}
+6
View File
@@ -0,0 +1,6 @@
//go:build !darwin
package platform
// TriggerLocalNetwork 仅 macOS 需要(本地网络隐私门)。Windows / Linux 无此门,空实现。
func TriggerLocalNetwork() {}
+7 -1
View File
@@ -39,7 +39,7 @@ func (s *Server) handleDevices(w http.ResponseWriter, r *http.Request) {
Name: d.Name, Name: d.Name,
Type: d.Type, Type: d.Type,
Tier: d.Tier, Tier: d.Tier,
Online: s.hub.Online(claims.UserID, d.Name), Online: s.hub.Online(claims.UserID, d.DeviceID),
LastSeen: d.LastSeen, 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"}) writeJSON(w, http.StatusNotFound, map[string]string{"error": "device not found"})
return 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) w.WriteHeader(http.StatusNoContent)
} }
+1 -1
View File
@@ -258,7 +258,7 @@ func (s *Server) handleDisconnect(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "missing device"}) writeJSON(w, http.StatusBadRequest, map[string]string{"error": "missing device"})
return return
} }
s.hub.Kick(claims.UserID, deviceName) s.hub.Kick(claims.UserID, claims.DeviceID, deviceName)
s.hub.PublishPresence(r.Context(), claims.UserID) s.hub.PublishPresence(r.Context(), claims.UserID)
w.WriteHeader(http.StatusNoContent) w.WriteHeader(http.StatusNoContent)
} }
+33 -6
View File
@@ -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 // 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 // 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). // 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{} typeByID := map[string]string{}
nameByID := map[string]string{}
if devs, err := s.queries.ListDevicesByUser(r.Context(), claims.UserID); err == nil { if devs, err := s.queries.ListDevicesByUser(r.Context(), claims.UserID); err == nil {
for _, d := range devs { for _, d := range devs {
typeByID[d.DeviceID] = d.Type 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 == "" { if typ == "" {
typ = "browser" typ = "browser"
} }
name := nameByID[sess.Meta]
if name == "" {
name = sess.Label
}
scope := "full" scope := "full"
if jwtauth.ScopeTier(sess.Scope) == "guest" { if jwtauth.ScopeTier(sess.Scope) == "guest" {
scope = "guest" scope = "guest"
@@ -95,11 +105,11 @@ func (s *Server) handleSessionsList(w http.ResponseWriter, r *http.Request) {
out = append(out, sessionView{ out = append(out, sessionView{
ID: sess.Meta, ID: sess.Meta,
DeviceID: sess.Meta, DeviceID: sess.Meta,
DeviceName: sess.Label, DeviceName: name,
Kind: typ, Kind: typ,
Scope: scope, Scope: scope,
Current: sess.Meta == claims.DeviceID, Current: sess.Meta == claims.DeviceID,
Online: s.hub.Online(claims.UserID, sess.Label), Online: s.hub.Online(claims.UserID, sess.Meta),
CreatedAt: sess.CreatedAt, CreatedAt: sess.CreatedAt,
LastUsedAt: sess.LastUsedAt, 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. // pruned cache row still revokes correctly and stays authorized to this user.
func (s *Server) revokeDevice(r *http.Request, userID, deviceID string) (int, bool) { func (s *Server) revokeDevice(r *http.Request, userID, deviceID string) (int, bool) {
sid, name := "", "" sid, name := "", ""
localRowOwned := false
if dev, err := s.queries.GetDevice(r.Context(), deviceID); err == nil && dev.UserID == userID { if dev, err := s.queries.GetDevice(r.Context(), deviceID); err == nil && dev.UserID == userID {
sid = dev.BrokerSid sid = dev.BrokerSid
name = dev.Name name = dev.Name
localRowOwned = true
} }
if sid == "" { if sid == "" {
sessions, err := s.broker.ListSessions(r.Context(), userID) sessions, err := s.broker.ListSessions(r.Context(), userID)
@@ -152,9 +164,23 @@ func (s *Server) revokeDevice(r *http.Request, userID, deviceID string) (int, bo
} }
} }
if sid == "" { if sid == "" {
// Not this user's device, or already gone from both the cache and the broker. // 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 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 // Revoke the broker session first so the device can't refresh; a 404 (already gone) is
// idempotent success inside RevokeSession. // idempotent success inside RevokeSession.
if err := s.broker.RevokeSession(r.Context(), sid); err != nil { if err := s.broker.RevokeSession(r.Context(), sid); err != nil {
@@ -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. // 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) slog.Warn("delete device cache failed", "err", err, "user", userID, "device", deviceID)
} }
if name != "" { // Kick by the stable device_id (the hub key); name rides along for the code-less fallback
s.hub.Kick(userID, name) // 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) s.hub.PublishPresence(r.Context(), userID)
return http.StatusNoContent, true return http.StatusNoContent, true
} }
+4 -1
View File
@@ -51,7 +51,10 @@ func (s *Server) handleEvents(w http.ResponseWriter, r *http.Request) {
} }
flusher.Flush() 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) defer s.hub.Disconnect(client)
slog.Debug("sse connected", "user", claims.UserID, "device", deviceName) slog.Debug("sse connected", "user", claims.UserID, "device", deviceName)
+100 -34
View File
@@ -23,8 +23,10 @@ type Event struct {
// PresenceDevice is the per-device entry in a `presence` event payload. // PresenceDevice is the per-device entry in a `presence` event payload.
// Field names align with brief §5: { name, type, online }; last_seen 是 // Field names align with brief §5: { name, type, online }; last_seen 是
// 设置页展示「上次活跃」需要的扩展字段。 // 设置页展示「上次活跃」需要的扩展字段。device_id 是稳定标识:name 与令牌解耦后,
// 前端按它去重 / 判本机 / 寻址,改名只换 name 字段而 device_id 不变(避免改名生重复项)。
type PresenceDevice struct { type PresenceDevice struct {
DeviceID string `json:"device_id"`
Name string `json:"name"` Name string `json:"name"`
Type string `json:"type"` Type string `json:"type"`
Online bool `json:"online"` Online bool `json:"online"`
@@ -37,13 +39,30 @@ const clientBuffer = 32
type Client struct { type Client struct {
UserID string UserID string
DeviceID 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). // 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 // It lets presence label a live device that has no devices-table row (a global-SSO
// browser or a broker-authenticated desktop). // browser or a broker-authenticated desktop).
Type string Type string
// key 是 users[userID] 这一级 map 的键:有 device_id 用 device_id(稳定,改名不变),
// 无(代铸前浏览器)回落 "name:"+name。Disconnect 据此精确摘除自身。
key string
ch chan Event 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 } func (c *Client) Events() <-chan Event { return c.ch }
// Hub is the in-memory presence + signaling fan-out. // Hub is the in-memory presence + signaling fan-out.
@@ -70,14 +89,18 @@ func New(devices DeviceLister) *Hub {
} }
// Connect registers a new SSE client and announces presence to the user's other devices. // 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. // Keyed by the connection key (device_id when present): a reconnect of the SAME device — even
// deviceType is the caller's declared device type, surfaced in presence for a live device // after a rename, since device_id is stable — replaces its own entry (its channel is closed)
// that has no devices-table row. // rather than orphaning the old one. deviceName is the connection-declared name (presence
func (h *Hub) Connect(ctx context.Context, userID, deviceID, deviceType string) *Client { // 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{ c := &Client{
UserID: userID, UserID: userID,
DeviceID: deviceID, DeviceID: deviceID,
Name: deviceName,
Type: deviceType, Type: deviceType,
key: key,
ch: make(chan Event, clientBuffer), 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 { if h.users[userID] == nil {
h.users[userID] = map[string]*Client{} 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) close(old.ch)
} }
h.users[userID][deviceID] = c h.users[userID][key] = c
h.mu.Unlock() h.mu.Unlock()
go h.publishPresence(ctx, userID) 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. // to peers if the client has not reconnected.
func (h *Hub) Disconnect(c *Client) { func (h *Hub) Disconnect(c *Client) {
h.mu.Lock() h.mu.Lock()
if active, ok := h.users[c.UserID][c.DeviceID]; ok && active == c { if active, ok := h.users[c.UserID][c.key]; ok && active == c {
delete(h.users[c.UserID], c.DeviceID) delete(h.users[c.UserID], c.key)
if len(h.users[c.UserID]) == 0 { if len(h.users[c.UserID]) == 0 {
delete(h.users, c.UserID) delete(h.users, c.UserID)
} }
@@ -109,7 +132,7 @@ func (h *Hub) Disconnect(c *Client) {
time.AfterFunc(h.grace, func() { time.AfterFunc(h.grace, func() {
h.mu.RLock() h.mu.RLock()
_, stillOnline := h.users[c.UserID][c.DeviceID] _, stillOnline := h.users[c.UserID][c.key]
h.mu.RUnlock() h.mu.RUnlock()
if stillOnline { if stillOnline {
return return
@@ -120,18 +143,21 @@ func (h *Hub) Disconnect(c *Client) {
}) })
} }
// SendTo routes an event to a specific (userID, deviceID). Reports whether // SendTo routes an event to one of the user's live clients addressed by `target`. The
// the target was online and the event was queued. // 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- // 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 // 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 // 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. // 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() h.mu.RLock()
defer h.mu.RUnlock() defer h.mu.RUnlock()
c, ok := h.users[userID][deviceID] c := h.clientFor(userID, target)
if !ok { if c == nil {
return false return false
} }
select { select {
@@ -139,11 +165,27 @@ func (h *Hub) SendTo(userID, deviceID string, ev Event) bool {
return true return true
default: default:
slog.Warn("client buffer full; dropping event", slog.Warn("client buffer full; dropping event",
"user", userID, "device", deviceID, "type", ev.Type) "user", userID, "device", target, "type", ev.Type)
return false 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 // 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). // under the read lock so they can't race a concurrent channel close (see SendTo).
func (h *Hub) Broadcast(userID string, ev Event) { 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 { func (h *Hub) Online(userID, deviceID string) bool {
h.mu.RLock() h.mu.RLock()
defer h.mu.RUnlock() defer h.mu.RUnlock()
@@ -167,17 +209,34 @@ func (h *Hub) Online(userID, deviceID string) bool {
return ok return ok
} }
// Kick force-removes a (userID, deviceID) entry from the hub and closes its event channel; // Rename updates a live client's display name in place (keyed by stable device_id), so a
// the SSE handler exits on the next iteration. The close happens UNDER the write lock — the // device renamed via PATCH /api/devices/{device_id} stays the SAME hub entry — no reconnect,
// same discipline as Connect-replace and Close — so it can never race a send from // no token re-mint, no duplicate — and becomes reachable by its new name (clientFor) at once.
// publishPresence / Broadcast / SendTo (those hold the read lock), which would otherwise // No-op if the device has no live connection (its next connect carries the new name anyway).
// panic on a send to a closed channel. revokeDevice now Kicks on every logout / device func (h *Hub) Rename(userID, deviceID, name string) {
// delete / session revoke, so this path is hot, not rare. if deviceID == "" {
func (h *Hub) Kick(userID, deviceID string) { return
}
h.mu.Lock() h.mu.Lock()
defer h.mu.Unlock() defer h.mu.Unlock()
if c, ok := h.users[userID][deviceID]; ok { 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 { if len(h.users[userID]) == 0 {
delete(h.users, userID) delete(h.users, userID)
} }
@@ -226,21 +285,28 @@ func (h *Hub) publishPresence(ctx context.Context, userID string) {
seen := make(map[string]bool, len(devs)) seen := make(map[string]bool, len(devs))
items := make([]PresenceDevice, 0, len(devs)+len(live)) items := make([]PresenceDevice, 0, len(devs)+len(live))
for _, d := range devs { 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{ 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 // Live connections without a devices-table row. Only a code-less connection (DeviceID=="" —
// device whose cache row hasn't landed yet. They are reachable on the hub (clipboard / // a global-SSO browser before 代铸, keyed by name) is surfaced here, by its declared name.
// signaling already route to them), so they must appear as online peers too. //
for name, c := range live { // A connection WITH a device_id but no row is deliberately NOT surfaced: its row is either
if seen[name] { // 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 continue
} }
items = append(items, PresenceDevice{ items = append(items, PresenceDevice{
Name: name, Type: c.Type, Online: true, LastSeen: now, Name: c.Name, Type: c.Type, Online: true, LastSeen: now,
}) })
} }
+98 -28
View File
@@ -41,18 +41,18 @@ func waitForEvent(t *testing.T, c *Client, want string, timeout time.Duration) E
func TestConnectFiresPresenceEvent(t *testing.T) { func TestConnectFiresPresenceEvent(t *testing.T) {
lister := &fakeLister{ 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) h := New(lister)
defer h.Close() 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) defer h.Disconnect(c)
ev := waitForEvent(t, c, "presence", time.Second) ev := waitForEvent(t, c, "presence", time.Second)
data, _ := ev.Data.(map[string]any) data, _ := ev.Data.(map[string]any)
devs, _ := data["devices"].([]PresenceDevice) 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) t.Errorf("presence devices: %+v", devs)
} }
} }
@@ -60,18 +60,18 @@ func TestConnectFiresPresenceEvent(t *testing.T) {
func TestSecondClientSeesFirstAsOnline(t *testing.T) { func TestSecondClientSeesFirstAsOnline(t *testing.T) {
lister := &fakeLister{ lister := &fakeLister{
devices: []db.Device{ devices: []db.Device{
{UserID: "alice", Name: "tab-1", Type: "browser", LastSeen: 1}, {UserID: "alice", DeviceID: "dev_1", Name: "tab-1", Type: "browser", LastSeen: 1},
{UserID: "alice", Name: "tab-2", Type: "browser", LastSeen: 2}, {UserID: "alice", DeviceID: "dev_2", Name: "tab-2", Type: "browser", LastSeen: 2},
}, },
} }
h := New(lister) h := New(lister)
defer h.Close() 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) defer h.Disconnect(c1)
_ = waitForEvent(t, c1, "presence", time.Second) _ = 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) defer h.Disconnect(c2)
// c2 receives its own presence (announced on Connect). // 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) // A live device with no devices-table row (global-SSO browser / broker-auth desktop) and no
// must still appear in presence as online, labelled by its declared type. // device_id must still appear in presence as online, labelled by its declared name + type.
func TestLiveOnlyDeviceAppearsInPresence(t *testing.T) { func TestLiveOnlyDeviceAppearsInPresence(t *testing.T) {
h := New(&fakeLister{}) // empty managed-device set h := New(&fakeLister{}) // empty managed-device set
defer h.Close() defer h.Close()
c := h.Connect(context.Background(), "alice", "macbook", "macos") c := h.Connect(context.Background(), "alice", "", "macbook", "macos")
defer h.Disconnect(c) defer h.Disconnect(c)
ev := waitForEvent(t, c, "presence", time.Second) ev := waitForEvent(t, c, "presence", time.Second)
@@ -112,22 +112,31 @@ func TestLiveOnlyDeviceAppearsInPresence(t *testing.T) {
} }
} }
func TestSendToRoutesEvent(t *testing.T) { func TestSendToRoutesEventByName(t *testing.T) {
lister := &fakeLister{} lister := &fakeLister{
devices: []db.Device{{UserID: "alice", DeviceID: "dev_1", Name: "tab-1", Type: "browser"}},
}
h := New(lister) h := New(lister)
defer h.Close() 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) defer h.Disconnect(c)
_ = waitForEvent(t, c, "presence", time.Second) _ = 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}}) { 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) ev := waitForEvent(t, c, "signal", time.Second)
if ev.Data.(map[string]any)["x"] != 1 { if ev.Data.(map[string]any)["x"] != 1 {
t.Errorf("payload: %+v", ev.Data) 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) { 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) { func TestReconnectClosesOldChannel(t *testing.T) {
h := New(&fakeLister{}) h := New(&fakeLister{})
defer h.Close() 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 // drain the initial presence so we can detect close
<-old.Events() <-old.Events()
_ = h.Connect(context.Background(), "alice", "tab-1", "browser") _ = h.Connect(context.Background(), "alice", "dev_1", "tab-1", "browser")
select { select {
case _, ok := <-old.Events(): case _, ok := <-old.Events():
if ok { 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): case <-time.After(time.Second):
t.Fatal("timeout waiting for old channel close") t.Fatal("timeout waiting for old channel close")
@@ -160,16 +228,16 @@ func TestReconnectClosesOldChannel(t *testing.T) {
func TestDisconnectRemovesFromOnlineSet(t *testing.T) { func TestDisconnectRemovesFromOnlineSet(t *testing.T) {
h := New(&fakeLister{ 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() defer h.Close()
c := h.Connect(context.Background(), "alice", "tab-1", "browser") c := h.Connect(context.Background(), "alice", "dev_1", "tab-1", "browser")
if !h.Online("alice", "tab-1") { if !h.Online("alice", "dev_1") {
t.Fatal("client should be online after Connect") t.Fatal("client should be online after Connect")
} }
h.Disconnect(c) h.Disconnect(c)
if h.Online("alice", "tab-1") { if h.Online("alice", "dev_1") {
t.Error("client should not be online after Disconnect") 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. // the whole process; now close and send are mutually exclusive. Run with -race.
func TestConcurrentKickAndPresenceNoPanic(t *testing.T) { func TestConcurrentKickAndPresenceNoPanic(t *testing.T) {
h := New(&fakeLister{ 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() defer h.Close()
@@ -191,13 +259,15 @@ func TestConcurrentKickAndPresenceNoPanic(t *testing.T) {
wg.Add(1) wg.Add(1)
go func(n int) { go func(n int) {
defer wg.Done() defer wg.Done()
id := fmt.Sprintf("dev_%d", n)
name := fmt.Sprintf("d%d", n) name := fmt.Sprintf("d%d", n)
for j := 0; j < iters; j += 1 { 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.PublishPresence(context.Background(), "u")
h.Broadcast("u", Event{Type: "x"}) h.Broadcast("u", Event{Type: "x"})
h.SendTo("u", name, Event{Type: "y"}) h.SendTo("u", name, Event{Type: "y"})
h.Kick("u", name) h.Rename("u", id, name+"-r")
h.Kick("u", id, name)
} }
}(i) }(i)
} }
@@ -208,16 +278,16 @@ func TestConcurrentKickAndPresenceNoPanic(t *testing.T) {
func TestGracePeriodDelaysOfflinePresence(t *testing.T) { func TestGracePeriodDelaysOfflinePresence(t *testing.T) {
h := New(&fakeLister{ h := New(&fakeLister{
devices: []db.Device{ devices: []db.Device{
{UserID: "alice", Name: "tab-1", Type: "browser"}, {UserID: "alice", DeviceID: "dev_1", Name: "tab-1", Type: "browser"},
{UserID: "alice", Name: "tab-2", Type: "browser"}, {UserID: "alice", DeviceID: "dev_2", Name: "tab-2", Type: "browser"},
}, },
}) })
h.grace = 100 * time.Millisecond h.grace = 100 * time.Millisecond
defer h.Close() 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) 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 // Drain initial presence frames
_ = waitForEvent(t, c1, "presence", time.Second) _ = waitForEvent(t, c1, "presence", time.Second)
_ = waitForEvent(t, c1, "presence", time.Second) _ = waitForEvent(t, c1, "presence", time.Second)
+3 -2
View File
@@ -7,5 +7,6 @@ DerivedData/
*.xcuserstate *.xcuserstate
.DS_Store .DS_Store
# 真机签名的账号特定值(Team ID + profile 名)——公开仓库不留账号标识,见 REALDEVICE.md。 # 真机签名的账号凭据——公开仓库不留账号标识。ASC API Key 的 .p8 + Team/Key/Issuer ID
Local.xcconfig # 走根目录 gitignore 的 .env(见 REALDEVICE.md);.p8 若落本目录一并忽略。
*.p8
+1 -1
View File
@@ -10,7 +10,7 @@
模拟器无需签名即生效;真机须付费 ADP 注册该 group(账号门控)。 --> 模拟器无需签名即生效;真机须付费 ADP 注册该 group(账号门控)。 -->
<key>com.apple.security.application-groups</key> <key>com.apple.security.application-groups</key>
<array> <array>
<string>group.net.commilitia.cdrop</string> <string>group.net.commilitia.Commilitia-Drop</string>
</array> </array>
</dict> </dict>
</plist> </plist>
+27 -34
View File
@@ -1,15 +1,15 @@
# cdrop iOS 真机分发手册 # 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` | | 主 app Bundle ID | `net.commilitia.Commilitia-Drop` |
| Share Extension | `net.commilitia.Commilitia-Drop.share` | | Share Extension | `net.commilitia.Commilitia-Drop.share` |
| 控件扩展 | `net.commilitia.Commilitia-Drop.widgets` | | 控件扩展 | `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` | | 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` 双击安装。 **生成 API Key**appstoreconnect.apple.com → **用户和访问****集成****App Store Connect API****团队密钥** → 「+」→ 角色 **Admin**(要能管设备 / profile / 标识符)→ 命名 → 生成 → 下载 `AuthKey_XXXXX.p8`**仅一次**)。记下三样:
2. **App IDs ×3****Identifiers** → → App IDs → AppBundle 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 的证书是两码事。
> 常见坑:Bundle ID 选 **Explicit** 不是 WildcardApp Group 务必 3 个 App ID 都关联;Profile 选 **Development** 不要 Distribution。 - **Key ID**(密钥行里那串,10 位)
- **Issuer ID**(密钥页顶部,UUID 形)
- **Team ID**(账号 **Membership** 里,10 位)
--- **配 `.env`**(仓库根目录,已 gitignore,公开仓库不留账号凭据):
## B. 本地签名配置(gitignore,填一次)
`ios/CDrop/Local.xcconfig` 写入(此文件已 gitignore,公开仓库不留你的账号标识):
``` ```
CDROP_TEAM_ID=ABCDE12345 CDROP_TEAM_ID=ABCDE12345
CDROP_PROFILE_APP = CDrop Dev CDROP_ASC_KEY_PATH=/绝对路径/AuthKey_XXXXX.p8
CDROP_PROFILE_SHARE = CDrop Share Dev CDROP_ASC_KEY_ID=XXXXXXXXXX
CDROP_PROFILE_WIDGETS = CDrop Widgets Dev CDROP_ASC_ISSUER_ID=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx
``` ```
- `CDROP_TEAM_ID`A0 的 Team ID;三个 `CDROP_PROFILE_*`A5 你起的 profile 名(**名字**,不是文件路径)。 `.p8` 放仓库外即可,`CDROP_ASC_KEY_PATH` 指它绝对路径(落本目录则被 `*.p8` 忽略)。
- 机制:`Signing.xcconfig`(已提交)仅对 **device SDKiphoneos** 套用手动签名 + 这些值;**模拟器**仍走 `project.yml` 的 ad-hoc`CODE_SIGN_IDENTITY = "-"`),故缺 `Local.xcconfig` 也不影响模拟器构建。
> 你若已在门户手动建过 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)。
--- ---
## C. 构建装机(全 CLI ## B. 构建装机(全 CLI
```sh ```sh
just ios-devices # 手机插 USB,读 UDID(填进门户 A4 + 下行) just ios-devices # 手机插 USB,读 UDID
just ios-device <你的设备UDID> # 真机构建(手动签名)+ devicectl 装机 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 开发证书,无需手动「信任开发者」)。 - 首次装机后,iPhone 上首启该开发者 app 即可直接跑(付费 ADP 开发证书,无需手动「信任开发者」)。
--- ---
## D. 服务端前置 ## C. 服务端前置
1. **引擎可达**:真机上引擎 WebView 加载 `https://drop.commilitia.net/engine.html`——**该文件随 cdrop 二进制部署到 prod**`//go:embed` 进 binary`just docker-image` 含最新 `dist`)。手机要用,prod 须是含本轮改动的最新部署。 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`)。缺配置则推送惰性关闭,其余功能照常。 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` 指向可达引擎。 3. 本机联调可选:环境变量 `CDROP_ENGINE_URL` 指向可达引擎。
--- ---
## E. 只能真机验的清单 ## D. 只能真机验的清单
代码已实现,下列是真机才能验的点: 代码已实现,下列是真机才能验的点:
@@ -83,6 +76,6 @@ just ios-device <你的设备UDID> # 真机构建(手动签名)+ devicectl
- [ ] 发送 / 接收:选文件 → 选设备 → 对端收到;对端发来落 Files(Documents)。 - [ ] 发送 / 接收:选文件 → 选设备 → 对端收到;对端发来落 Files(Documents)。
- [ ] **大文件流式发送**(R-iOS-4 已实现):发大文件,WebView 内存应有界(按 Range 块拉取,不整文件入内存)。 - [ ] **大文件流式发送**(R-iOS-4 已实现):发大文件,WebView 内存应有界(按 Range 块拉取,不整文件入内存)。
- [ ] **后台续传**R-iOS-1 / R-iOS-3):传输中切后台 → `BGContinuedProcessingTask` 系统进度 UI;验 WKWebView JS 是否随之保活(不保活则退化为回前台续传,可接受)。 - [ ] **后台续传**R-iOS-1 / R-iOS-3):传输中切后台 → `BGContinuedProcessingTask` 系统进度 UI;验 WKWebView JS 是否随之保活(不保活则退化为回前台续传,可接受)。
- [ ] **APNs 推送**:离线设备收「收到文件」通知(需 D2 的 `.p8`)。 - [ ] **APNs 推送**:离线设备收「收到文件」通知(需 C2 的 `.p8`)。
- [ ] **Share Extension**:别的 app 分享 → 选 Commilitia Drop → 唤起主 app 选设备发送。 - [ ] **Share Extension**:别的 app 分享 → 选 Commilitia Drop → 唤起主 app 选设备发送。
- [ ] **控制中心剪贴板两控件**:控制中心加「上传 / 拉取剪贴板」控件 → 锁屏 / 解锁态点按 → 云剪贴板读写。 - [ ] **控制中心剪贴板两控件**:控制中心加「上传 / 拉取剪贴板」控件 → 锁屏 / 解锁态点按 → 云剪贴板读写。
+1 -1
View File
@@ -5,7 +5,7 @@
<!-- 与主 app 共享的 App GroupShare Extension 把待发文件搬进收件箱(见 AppGroup.swift)。 --> <!-- 与主 app 共享的 App GroupShare Extension 把待发文件搬进收件箱(见 AppGroup.swift)。 -->
<key>com.apple.security.application-groups</key> <key>com.apple.security.application-groups</key>
<array> <array>
<string>group.net.commilitia.cdrop</string> <string>group.net.commilitia.Commilitia-Drop</string>
</array> </array>
</dict> </dict>
</plist> </plist>
+1 -1
View File
@@ -8,7 +8,7 @@ import Foundation
// / // /
enum AppGroup enum AppGroup
{ {
static let identifier = "group.net.commilitia.cdrop" static let identifier = "group.net.commilitia.Commilitia-Drop"
// App Group nil // App Group nil
static func inboxURL() -> URL? static func inboxURL() -> URL?
-11
View File
@@ -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)
+172 -4
View File
@@ -1,3 +1,4 @@
import AuthenticationServices
import Foundation import Foundation
import Observation import Observation
import UIKit import UIKit
@@ -35,14 +36,19 @@ final class AuthManager
var session: Session? var session: Session?
var qrPayload: String? var qrPayload: String?
var statusText: String = t("ios.login.generating") // broker
var statusText: String = ""
// denied/expired/ // denied/expired/
var qrExpired = false var qrExpired = false
// startQRLogin // startQRLogin / startBrokerLogin
// statusText / qrPayload // statusText / session
private var loginGeneration = 0 private var loginGeneration = 0
// Broker ASWebAuthenticationSession presentationContextProvider weak
// session /
private var brokerFlow: BrokerAuthFlow?
// Keychain app Keychain.swift // Keychain app Keychain.swift
private static let keychainService = "net.commilitia.cdrop.session" private static let keychainService = "net.commilitia.cdrop.session"
private static let keychainAccount = "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() func logout()
{ {
session = nil session = nil
qrPayload = nil qrPayload = nil
statusText = t("ios.login.generating") statusText = ""
Keychain.delete(service: Self.keychainService, account: Self.keychainAccount) 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 // Keychain
private func persist() private func persist()
{ {
@@ -136,6 +280,30 @@ final class AuthManager
persist() 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()
}
// updateDeviceNamePATCH /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 // CDROP_DEBUG_SESSION=1
// prod + 401 engine.html WKWebView // prod + 401 engine.html WKWebView
func debugSession() func debugSession()
+111
View File
@@ -0,0 +1,111 @@
import AuthenticationServices
import CryptoKit
import Foundation
import UIKit
// Broker device-authorization + PKCE desktop/platform/oauth.goiOS
// 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-sessionBearer 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?
// URLcdrop://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_idbroker meta / UserDefaults
// device_idbroker 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 / randStringn 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
}
}
+40 -4
View File
@@ -2,34 +2,70 @@ import CoreImage.CIFilterBuiltins
import SwiftUI import SwiftUI
import UIKit import UIKit
// + cdrop AuthManager // Broker ASWebAuthenticationSession
// AuthManager
struct LoginView: View struct LoginView: View
{ {
@Environment(AuthManager.self) private var auth @Environment(AuthManager.self) private var auth
@State private var showQR = false
var body: some View var body: some View
{ {
VStack(spacing: 24) VStack(spacing: 20)
{ {
Spacer() Spacer()
Text(t("app.brand")) Text(t("app.brand"))
.font(.largeTitle) .font(.largeTitle)
.bold() .bold()
qrArea
// 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) Text(auth.statusText)
.font(.callout) .font(.callout)
.foregroundStyle(auth.qrExpired ? Color.red : Color.secondary) .foregroundStyle(auth.qrExpired ? Color.red : Color.secondary)
.multilineTextAlignment(.center)
.padding(.horizontal, 40)
}
//
DisclosureGroup(isExpanded: $showQR)
{
VStack(spacing: 16)
{
qrArea
refreshButton refreshButton
Text(t("ios.login.guide")) Text(t("ios.login.guide"))
.font(.footnote) .font(.footnote)
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
.multilineTextAlignment(.center) .multilineTextAlignment(.center)
}
.padding(.top, 12)
}
label:
{
Text(t("ios.login.scanAlt"))
.font(.subheadline)
}
.padding(.horizontal, 40) .padding(.horizontal, 40)
.onChange(of: showQR)
{
if showQR, auth.qrPayload == nil { Task { await auth.startQRLogin() } }
}
Spacer() Spacer()
} }
.padding() .padding()
.tint(.cdropAccent) .tint(.cdropAccent)
.task { await auth.startQRLogin() }
} }
// glassProminent // glassProminent
+4 -3
View File
@@ -1,9 +1,10 @@
import Foundation import Foundation
import UIKit import UIKit
// UserDefaults qr/start device_name // UserDefaults qr/start device_name
// (userID, deviceName) presence线 // PATCH /api/devices/{device_id}
// internal/httpapi/qr.go // device_id + 广 presence token renamed + Keychain
// EngineController / AuthManager
enum DeviceNameStore enum DeviceNameStore
{ {
private static let key = "cdrop.deviceName" private static let key = "cdrop.deviceName"
+182 -8
View File
@@ -9,16 +9,30 @@ import WebKit
// web hub.ts handlePresence // web hub.ts handlePresence
struct DeviceItem: Identifiable, Equatable struct DeviceItem: Identifiable, Equatable
{ {
// deviceID broker meta / /
// name deviceID presence name
let deviceID: String
let name: String let name: String
let type: String let type: String
let online: Bool let online: Bool
let lastSeen: Double 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* // web engine/main.ts toWire ice*
// P2P TURN // P2P TURN
struct TransferItem: Identifiable, Equatable struct TransferItem: Identifiable, Equatable, Codable
{ {
let sessionId: String let sessionId: String
let direction: String let direction: String
@@ -52,6 +66,7 @@ final class EngineController: NSObject
var devices: [DeviceItem] = [] // 线 / 线 presence presence var devices: [DeviceItem] = [] // 线 / 线 presence presence
var transfers: [TransferItem] = [] // transfers var transfers: [TransferItem] = [] // transfers
var history: [TransferItem] = [] // transferDone 30 var history: [TransferItem] = [] // transferDone 30
var messages: [MessageItem] = [] // message
// SSE / + presence + // SSE / + presence +
// warn/error 401 // warn/error 401
@@ -80,9 +95,13 @@ final class EngineController: NSObject
return URL(string: "https://drop.commilitia.net/engine.html")! return URL(string: "https://drop.commilitia.net/engine.html")!
} }
// boot app AppRoot.onAppear // boot EngineWebView.makeUIView boot #11
var auth: AuthManager? var auth: AuthManager?
// idbroker metapresence PATCH
// /api/devices/{device_id}
var selfDeviceID: String { auth?.session?.deviceId ?? "" }
private var webView: WKWebView? private var webView: WKWebView?
private let downloads = DownloadManager() private let downloads = DownloadManager()
@@ -90,10 +109,17 @@ final class EngineController: NSObject
// WKURLSchemeHandler // WKURLSchemeHandler
private var outgoing: [String: URL] = [:] private var outgoing: [String: URL] = [:]
// /
private static let historyCap = 50
private static let messagesCap = 200
override init() override init()
{ {
super.init() super.init()
PushRegistry.shared.engine = self PushRegistry.shared.engine = self
// /
history = RecordsStore.load([TransferItem].self, "history") ?? []
messages = RecordsStore.load([MessageItem].self, "messages") ?? []
} }
// makeWebView WebView __CDROP_BOOT__device_type:"ios" // makeWebView WebView __CDROP_BOOT__device_type:"ios"
@@ -133,6 +159,8 @@ final class EngineController: NSObject
deviceName = currentDeviceName() deviceName = currentDeviceName()
// //
PushRegistry.shared.requestAuthorizationAndRegister() PushRegistry.shared.requestAuthorizationAndRegister()
// #5使 WKWebView WebRTC host
LocalNetworkPermission.trigger()
return wv return wv
} }
@@ -146,6 +174,10 @@ final class EngineController: NSObject
devices = [] devices = []
transfers = [] transfers = []
history = [] history = []
messages = []
//
RecordsStore.clear("history")
RecordsStore.clear("messages")
status = t("ios.engine.disconnected") status = t("ios.engine.disconnected")
deviceName = "" deviceName = ""
PushRegistry.shared.reset() PushRegistry.shared.reset()
@@ -157,6 +189,32 @@ final class EngineController: NSObject
CDropAPI.clearWidgetDeviceID() 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 / // Documents Files app /
func receivedFiles() -> [URL] { downloads.receivedFiles() } func receivedFiles() -> [URL] { downloads.receivedFiles() }
func deleteReceivedFile(_ url: URL) { downloads.deleteReceivedFile(url) } func deleteReceivedFile(_ url: URL) { downloads.deleteReceivedFile(url) }
@@ -193,11 +251,44 @@ final class EngineController: NSObject
sendCommand("clipboardPull", payload: [:]) sendCommand("clipboardPull", payload: [:])
} }
// / // / device_idDELETE /api/devices/{device_id}
func revokeDevice(_ name: String) // #2
func revokeDevice(_ deviceID: String)
{ {
deviceActionStatus = "" 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.didRegisterready // APNs PushRegistry.didRegisterready
@@ -282,6 +373,20 @@ final class EngineController: NSObject
pendingShareFiles = [] pendingShareFiles = []
} }
// #14PhotosPicker 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://<id> URL // cdrop-file://<id> URL
// start...Access // start...Access
private func stageOutgoingFile(_ url: URL) -> String private func stageOutgoingFile(_ url: URL) -> String
@@ -291,6 +396,24 @@ final class EngineController: NSObject
outgoing[id] = url outgoing[id] = url
return "cdrop-file://\(id)" return "cdrop-file://\(id)"
} }
// [start,end) seek base64 bridgeFileSource
// url cdrop-file://<id>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 + // MARK: - JS RPC +
@@ -337,6 +460,12 @@ extension EngineController: WKScriptMessageHandler
case "abortDownload": case "abortDownload":
downloads.abort(sessionId: payload["sessionId"] as? String ?? "") downloads.abort(sessionId: payload["sessionId"] as? String ?? "")
resolve(id: id, ok: true, value: nil) 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: default:
resolve(id: id, ok: false, value: "unknown method: \(method)") 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) } transfers = raw.compactMap { ($0 as? [String: Any]).flatMap(Self.parseTransfer) }
syncBackgroundTask() 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": case "transferDone":
// sessionId // sessionId
if let p = payload as? [String: Any], let item = Self.parseTransfer(p) 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 } transfers.removeAll { $0.sessionId == item.sessionId }
history.removeAll { $0.sessionId == item.sessionId } history.removeAll { $0.sessionId == item.sessionId }
history.insert(item, at: 0) 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() syncBackgroundTask()
RecordsStore.save(history, "history") //
} }
case "sendStarted": case "sendStarted":
// transfers // transfers
@@ -420,6 +565,23 @@ extension EngineController: WKScriptMessageHandler
clipboardStatus = t("ios.clipboard.uploaded") clipboardStatus = t("ios.clipboard.uploaded")
case "deviceRevoked": case "deviceRevoked":
deviceActionStatus = t("ios.devices.revoked") 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": case "error":
// error / // error /
if let p = payload as? [String: Any] if let p = payload as? [String: Any]
@@ -580,12 +742,24 @@ extension EngineController
static func parseDevice(_ d: [String: Any]) -> DeviceItem? static func parseDevice(_ d: [String: Any]) -> DeviceItem?
{ {
guard let name = d["name"] as? String, let type = d["type"] as? String else { return nil } guard let name = d["name"] as? String, let type = d["type"] as? String else { return nil }
return DeviceItem(name: name, // web store DeviceInfocamelCasedeviceId / lastSeen
return DeviceItem(deviceID: d["deviceId"] as? String ?? "",
name: name,
type: type, type: type,
online: boolOf(d["online"]), online: boolOf(d["online"]),
lastSeen: doubleOf(d["lastSeen"])) 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? static func parseTransfer(_ t: [String: Any]) -> TransferItem?
{ {
guard let sessionId = t["sessionId"] as? String else { return nil } guard let sessionId = t["sessionId"] as? String else { return nil }
@@ -6,9 +6,14 @@ import WebKit
struct EngineWebView: UIViewRepresentable struct EngineWebView: UIViewRepresentable
{ {
let controller: EngineController let controller: EngineController
// WebView __CDROP_BOOT__
// makeUIView AppRoot.onAppear controller.auth nilboot
// session:null missing injected session boot #11
let auth: AuthManager
func makeUIView(context: Context) -> WKWebView func makeUIView(context: Context) -> WKWebView
{ {
controller.auth = auth
return controller.makeWebView() return controller.makeWebView()
} }
+2
View File
@@ -56,6 +56,8 @@
<string>Commilitia Drop 使用相机扫描二维码登录新设备。</string> <string>Commilitia Drop 使用相机扫描二维码登录新设备。</string>
<key>NSLocalNetworkUsageDescription</key> <key>NSLocalNetworkUsageDescription</key>
<string>Commilitia Drop 需要访问本地网络以发现同内网设备并建立直连传输。</string> <string>Commilitia Drop 需要访问本地网络以发现同内网设备并建立直连传输。</string>
<key>NSPhotoLibraryUsageDescription</key>
<string>Commilitia Drop 需要访问照片库,以便从相册选取图片或视频发送给其他设备。</string>
<key>UIBackgroundModes</key> <key>UIBackgroundModes</key>
<array> <array>
<string>remote-notification</string> <string>remote-notification</string>
@@ -0,0 +1,26 @@
import Foundation
import Network
// #5iOS 14+ App 访 WKWebView
// WebRTC host 退 srflx / relayWKWebView
// 宿 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)
}
}
+164
View File
@@ -0,0 +1,164 @@
import SwiftUI
// #13 + in-memory app
// web POST /api/messagehub 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 = ""
}
}
+33
View File
@@ -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<T: Encodable>(_ 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<T: Decodable>(_ 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)
}
}
+15 -1
View File
@@ -301,6 +301,9 @@
"ios.login.guide": "Scan to approve from another signed-in device", "ios.login.guide": "Scan to approve from another signed-in device",
"ios.login.expired": "Code expired — please try again", "ios.login.expired": "Code expired — please try again",
"ios.login.failed": "Sign-in failed — 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.refresh": "Refresh QR Code",
"ios.login.needFull": "This device needs full access — choose \"Trust this device\" when approving.", "ios.login.needFull": "This device needs full access — choose \"Trust this device\" when approving.",
"ios.send.pickDevice": "Choose a device", "ios.send.pickDevice": "Choose a device",
@@ -343,7 +346,18 @@
"ios.settings.user": "User", "ios.settings.user": "User",
"ios.settings.logout": "Sign Out", "ios.settings.logout": "Sign Out",
"ios.settings.deviceName": "Device Name", "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.deviceCount": "Known Devices",
"ios.settings.signaling": "Signaling", "ios.settings.signaling": "Signaling",
"ios.settings.presenceEvents": "Presence Events", "ios.settings.presenceEvents": "Presence Events",
+15 -1
View File
@@ -301,6 +301,9 @@
"ios.login.guide": "用另一台已登录的设备扫码批准", "ios.login.guide": "用另一台已登录的设备扫码批准",
"ios.login.expired": "二维码已失效,请重试", "ios.login.expired": "二维码已失效,请重试",
"ios.login.failed": "登录失败,请重试", "ios.login.failed": "登录失败,请重试",
"ios.login.broker": "登录",
"ios.login.brokerStarting": "正在打开登录…",
"ios.login.scanAlt": "用其他设备扫码登录",
"ios.login.refresh": "刷新二维码", "ios.login.refresh": "刷新二维码",
"ios.login.needFull": "此设备需要完整权限,批准时请选择“信任此设备”", "ios.login.needFull": "此设备需要完整权限,批准时请选择“信任此设备”",
"ios.send.pickDevice": "选择接收设备", "ios.send.pickDevice": "选择接收设备",
@@ -343,7 +346,18 @@
"ios.settings.user": "用户", "ios.settings.user": "用户",
"ios.settings.logout": "退出登录", "ios.settings.logout": "退出登录",
"ios.settings.deviceName": "设备名称", "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.deviceCount": "已知设备",
"ios.settings.signaling": "信令连接", "ios.settings.signaling": "信令连接",
"ios.settings.presenceEvents": "在线事件", "ios.settings.presenceEvents": "在线事件",
+15 -1
View File
@@ -301,6 +301,9 @@
"ios.login.guide": "用另一台已登入的裝置掃碼核准", "ios.login.guide": "用另一台已登入的裝置掃碼核准",
"ios.login.expired": "QR 碼已失效,請重試", "ios.login.expired": "QR 碼已失效,請重試",
"ios.login.failed": "登入失敗,請重試", "ios.login.failed": "登入失敗,請重試",
"ios.login.broker": "登入",
"ios.login.brokerStarting": "正在開啟登入…",
"ios.login.scanAlt": "用其他裝置掃碼登入",
"ios.login.refresh": "重新整理 QR 碼", "ios.login.refresh": "重新整理 QR 碼",
"ios.login.needFull": "此裝置需要完整權限,批准時請選擇「信任此裝置」", "ios.login.needFull": "此裝置需要完整權限,批准時請選擇「信任此裝置」",
"ios.send.pickDevice": "選擇接收裝置", "ios.send.pickDevice": "選擇接收裝置",
@@ -343,7 +346,18 @@
"ios.settings.user": "使用者", "ios.settings.user": "使用者",
"ios.settings.logout": "登出", "ios.settings.logout": "登出",
"ios.settings.deviceName": "裝置名稱", "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.deviceCount": "已知裝置",
"ios.settings.signaling": "信令連線", "ios.settings.signaling": "信令連線",
"ios.settings.presenceEvents": "上線事件", "ios.settings.presenceEvents": "上線事件",
+115 -4
View File
@@ -1,3 +1,4 @@
import PhotosUI
import SwiftUI import SwiftUI
import UIKit import UIKit
import UniformTypeIdentifiers import UniformTypeIdentifiers
@@ -9,6 +10,9 @@ import UniformTypeIdentifiers
struct RootView: View struct RootView: View
{ {
@Environment(EngineController.self) private var engine @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 // CDROP_TAB
@State private var selection = ProcessInfo.processInfo.environment["CDROP_TAB"] ?? "transfer" @State private var selection = ProcessInfo.processInfo.environment["CDROP_TAB"] ?? "transfer"
@@ -30,6 +34,13 @@ struct RootView: View
DeviceListView() DeviceListView()
} }
} }
Tab(t("ios.tab.messages"), systemImage: "message", value: "messages")
{
NavigationStack
{
MessagesView()
}
}
Tab(t("ios.tab.files"), systemImage: "folder", value: "files") Tab(t("ios.tab.files"), systemImage: "folder", value: "files")
{ {
NavigationStack NavigationStack
@@ -49,7 +60,7 @@ struct RootView: View
.tint(.cdropAccent) .tint(.cdropAccent)
.background .background
{ {
EngineWebView(controller: engine) EngineWebView(controller: engine, auth: auth)
.frame(width: 0, height: 0) .frame(width: 0, height: 0)
.opacity(0) .opacity(0)
.allowsHitTesting(false) .allowsHitTesting(false)
@@ -78,9 +89,12 @@ struct TransferListView: View
{ {
@Environment(EngineController.self) private var engine @Environment(EngineController.self) private var engine
@State private var showImporter = false @State private var showImporter = false
@State private var showPhotoPicker = false
@State private var photoItem: PhotosPickerItem?
@State private var pickedURL: URL? @State private var pickedURL: URL?
@State private var showDevicePicker = false @State private var showDevicePicker = false
@State private var showNoDevices = false @State private var showNoDevices = false
@State private var showClearConfirm = false
// 线 EngineController.sendableDevices // 线 EngineController.sendableDevices
private var sendableDevices: [DeviceItem] private var sendableDevices: [DeviceItem]
@@ -115,6 +129,12 @@ struct TransferListView: View
ForEach(engine.history) ForEach(engine.history)
{ item in { item in
transferLink(item) 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")) .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) .overlay(alignment: .bottomTrailing)
{
// Files / Photos PhotosPicker
// #14
Menu
{ {
Button { showImporter = true } 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: label:
{ {
Image(systemName: "paperplane.fill") Image(systemName: "paperplane.fill")
@@ -143,6 +187,9 @@ struct TransferListView: View
else { showDevicePicker = true } 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) .confirmationDialog(t("ios.send.pickDevice"), isPresented: $showDevicePicker, titleVisibility: .visible)
{ {
ForEach(sendableDevices) ForEach(sendableDevices)
@@ -184,6 +231,32 @@ struct TransferListView: View
engine.sendFile(to: device, fileURL: url) engine.sendFile(to: device, fileURL: url)
pickedURL = nil 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 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 Section
{ {
LabeledContent(t("ios.detail.direction"), LabeledContent(t("ios.detail.direction"),
@@ -343,7 +431,13 @@ struct DeviceListView: View
engine.devices.sorted { a, b in isSelf(a) && !isSelf(b) } engine.devices.sorted { a, b in isSelf(a) && !isSelf(b) }
} }
private func isSelf(_ dev: DeviceItem) -> Bool { dev.name == engine.deviceName } // device_idselfDeviceID
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 var body: some View
{ {
@@ -385,7 +479,7 @@ struct DeviceListView: View
{ {
Button(t("ios.devices.remove"), role: .destructive) 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) { } Button(t("common.cancel"), role: .cancel) { }
} }
@@ -438,9 +532,17 @@ struct SettingsView: View
Section Section
{ {
LabeledContent(t("ios.settings.user"), value: user.name) LabeledContent(t("ios.settings.user"), value: user.name)
// PATCH /api/devices/{device_id}
// token EngineController.renameSelf
TextField(t("ios.settings.deviceName"), text: $deviceNameDraft) TextField(t("ios.settings.deviceName"), text: $deviceNameDraft)
.submitLabel(.done) .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() } Button(role: .destructive) { logout() }
label: label:
{ {
@@ -511,6 +613,15 @@ struct SettingsView: View
return t("ios.engine.disconnected") 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 // SSE + WebView Keychain auth
private func logout() private func logout()
{ {
+1 -1
View File
@@ -5,7 +5,7 @@
<!-- 与主 app 共享的 App Group:控件读取 widget_session.json(主 app 代铸的控件会话)。 --> <!-- 与主 app 共享的 App Group:控件读取 widget_session.json(主 app 代铸的控件会话)。 -->
<key>com.apple.security.application-groups</key> <key>com.apple.security.application-groups</key>
<array> <array>
<string>group.net.commilitia.cdrop</string> <string>group.net.commilitia.Commilitia-Drop</string>
</array> </array>
</dict> </dict>
</plist> </plist>
+2 -14
View File
@@ -18,9 +18,6 @@ targets:
type: application type: application
platform: iOS platform: iOS
deploymentTarget: "26.0" deploymentTarget: "26.0"
configFiles:
Debug: Signing.xcconfig
Release: Signing.xcconfig
sources: sources:
- path: Sources - path: Sources
- path: Shared - path: Shared
@@ -63,6 +60,8 @@ targets:
NSBonjourServices: NSBonjourServices:
- "_cdrop._tcp" - "_cdrop._tcp"
NSCameraUsageDescription: "Commilitia Drop 使用相机扫描二维码登录新设备。" NSCameraUsageDescription: "Commilitia Drop 使用相机扫描二维码登录新设备。"
# 图库选择:从相册选取图片 / 视频发送(PhotosPicker,见 RootView #14)。
NSPhotoLibraryUsageDescription: "Commilitia Drop 需要访问照片库,以便从相册选取图片或视频发送给其他设备。"
settings: settings:
base: base:
PRODUCT_BUNDLE_IDENTIFIER: net.commilitia.Commilitia-Drop PRODUCT_BUNDLE_IDENTIFIER: net.commilitia.Commilitia-Drop
@@ -71,18 +70,12 @@ targets:
# APNs 推送的 aps-environment entitlement(见 CDrop.entitlements)。模拟器取令牌可用; # APNs 推送的 aps-environment entitlement(见 CDrop.entitlements)。模拟器取令牌可用;
# 真机签名须付费 ADP 开 Push 能力(账号门控)。 # 真机签名须付费 ADP 开 Push 能力(账号门控)。
CODE_SIGN_ENTITLEMENTS: CDrop.entitlements 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 选设备发送(只交接、不跑引擎, # Share Extension:抓分享文件 → App Group 收件箱 → 深链主 app 选设备发送(只交接、不跑引擎,
# 见 PLAN §I5)。只编 ShareViewController + 共享的 AppGroup,绝不拉主 app Sources(避超 120MB)。 # 见 PLAN §I5)。只编 ShareViewController + 共享的 AppGroup,绝不拉主 app Sources(避超 120MB)。
CDropShare: CDropShare:
type: app-extension type: app-extension
platform: iOS platform: iOS
deploymentTarget: "26.0" deploymentTarget: "26.0"
configFiles:
Debug: Signing.xcconfig
Release: Signing.xcconfig
sources: sources:
- path: Share - path: Share
- path: Shared - path: Shared
@@ -103,16 +96,12 @@ targets:
PRODUCT_BUNDLE_IDENTIFIER: net.commilitia.Commilitia-Drop.share PRODUCT_BUNDLE_IDENTIFIER: net.commilitia.Commilitia-Drop.share
TARGETED_DEVICE_FAMILY: "1,2" TARGETED_DEVICE_FAMILY: "1,2"
CODE_SIGN_ENTITLEMENTS: Share/CDropShare.entitlements CODE_SIGN_ENTITLEMENTS: Share/CDropShare.entitlements
"PROVISIONING_PROFILE_SPECIFIER[sdk=iphoneos*]": "$(CDROP_PROFILE_SHARE)"
# 控制中心剪贴板两控件(WidgetKit ControlWidgetiOS 18+PLAN §I4)。纯原生 REST + 控件专用 # 控制中心剪贴板两控件(WidgetKit ControlWidgetiOS 18+PLAN §I4)。纯原生 REST + 控件专用
# 设备会话(不跑引擎)。i18n JSON 一并打包使控件标签随设备语言(t() 经 no-subdir 回退读 bundle)。 # 设备会话(不跑引擎)。i18n JSON 一并打包使控件标签随设备语言(t() 经 no-subdir 回退读 bundle)。
CDropWidgets: CDropWidgets:
type: app-extension type: app-extension
platform: iOS platform: iOS
deploymentTarget: "26.0" deploymentTarget: "26.0"
configFiles:
Debug: Signing.xcconfig
Release: Signing.xcconfig
sources: sources:
- path: Widgets - path: Widgets
- path: Shared - path: Shared
@@ -128,7 +117,6 @@ targets:
PRODUCT_BUNDLE_IDENTIFIER: net.commilitia.Commilitia-Drop.widgets PRODUCT_BUNDLE_IDENTIFIER: net.commilitia.Commilitia-Drop.widgets
TARGETED_DEVICE_FAMILY: "1,2" TARGETED_DEVICE_FAMILY: "1,2"
CODE_SIGN_ENTITLEMENTS: Widgets/CDropWidgets.entitlements CODE_SIGN_ENTITLEMENTS: Widgets/CDropWidgets.entitlements
"PROVISIONING_PROFILE_SPECIFIER[sdk=iphoneos*]": "$(CDROP_PROFILE_WIDGETS)"
schemes: schemes:
CDrop: CDrop:
build: build:
+1 -1
View File
@@ -111,7 +111,7 @@ Share Extension 落法(据上):**抓文件 → 写 App Group 容器 →
| **I6** APNs | 服务端通道(§3)+ 原生注册;不含剪贴板 | `.p8` + 真机硬等账号 | | **I6** APNs | 服务端通道(§3)+ 原生注册;不含剪贴板 | `.p8` + 真机硬等账号 |
| **I7** 打磨与分发 | 后台窗口;图标 / 启动屏(复用品牌资产);旁加载分发 | 硬等账号 | | **I7** 打磨与分发 | 后台窗口;图标 / 启动屏(复用品牌资产);旁加载分发 | 硬等账号 |
> **实现状态(2026-06-27**I1/I2/I3/I4/I5/I6 的**代码**全部落地——发送端流式(R-iOS-4FileSource + 原生 Range,整文件不进 WebView 内存);后台续传(BGContinuedProcessingTask);APNs(后端 `internal/apns` ES256 + 原生注册经引擎桥);Share ExtensionApp 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-4FileSource + 原生 Range,整文件不进 WebView 内存);后台续传(BGContinuedProcessingTask);APNs(后端 `internal/apns` ES256 + 原生注册经引擎桥);Share ExtensionApp 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`
--- ---
+100 -14
View File
@@ -21,12 +21,12 @@
import { refreshSessionScope } from "../features/auth/auth"; import { refreshSessionScope } from "../features/auth/auth";
import { fetchClipboard, uploadClipboard } from "../features/clipboard/clipboard"; import { fetchClipboard, uploadClipboard } from "../features/clipboard/clipboard";
import { sendMessage } from "../features/messaging/messaging";
import { apiFetch } from "../net/api"; import { apiFetch } from "../net/api";
import { startHub } from "../features/transfer/hub"; import { startHub } from "../features/transfer/hub";
import { refreshICEServers, stopICEServerRefresh } from "../features/transfer/iceServers"; import { refreshICEServers, stopICEServerRefresh } from "../features/transfer/iceServers";
import { cancelTransfer, skipWaitRelay, startOutgoingTransfer } from "../features/transfer/transfer"; import { cancelTransfer, skipWaitRelay, startOutgoingTransfer } from "../features/transfer/transfer";
import { rangeSource } from "../features/transfer/source"; import { bridgeFileSource, isIOSShell, notifyNative, onNativeEvent } from "../net/ios";
import { isIOSShell, notifyNative, onNativeEvent } from "../net/ios";
import { useAppStore } from "../store"; import { useAppStore } from "../store";
import type { TransferRecord } from "../store/types"; import type { TransferRecord } from "../store/types";
@@ -101,6 +101,7 @@ function subscribeStore(): void
let prevActive = st0.activeTransfers; let prevActive = st0.activeTransfers;
let prevDoneId = st0.history[0]?.sessionId; let prevDoneId = st0.history[0]?.sessionId;
let prevDevices = st0.devices; let prevDevices = st0.devices;
let prevMsgId = st0.messages[0]?.id;
let prevSse = st0.sseConnected; let prevSse = st0.sseConnected;
let prevAuthed = st0.accessToken !== null; let prevAuthed = st0.accessToken !== null;
let prevRefresh = st0.refreshToken; let prevRefresh = st0.refreshToken;
@@ -139,6 +140,16 @@ function subscribeStore(): void
notifyNative("presence", { devices: s.devices }); notifyNative("presence", { devices: s.devices });
pushHubState(s); 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) if (s.sseConnected !== prevSse)
{ {
prevSse = s.sseConnected; prevSse = s.sseConnected;
@@ -167,14 +178,15 @@ function subscribeStore(): void
}); });
} }
// handleSendFile:原生把待发文件经 WKURLSchemeHandler 以 payload.url 暴露。R-iOS-4:用 // handleSendFile:原生把待发文件经 payload.urlcdrop-file://<id>暴露。R-iOS-4:用
// rangeSource 惰性按块拉取(原生据 Range 头 seek 文件、回 206),整文件永不进 WebView // bridgeFileSource 经桥按块取片(原生据 [start,end) seek 文件、回 base64),整文件永不整体
// 内存——大文件不再有 jetsam 风险,传输层 p2p / relay 透明复用。 // WebView 内存——大文件不再有 jetsam 风险,传输层 p2p / relay 透明复用。改走桥而非跨 origin
// fetch,绕开自定义 scheme 的 CORS 拦截(否则发送在读文件即失败)。
async function handleSendFile(p: SendFilePayload): Promise<void> async function handleSendFile(p: SendFilePayload): Promise<void>
{ {
try 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); const sessionId = await startOutgoingTransfer(p.target, src);
notifyNative("sendStarted", { sessionId, name: p.name }); notifyNative("sendStarted", { sessionId, name: p.name });
} }
@@ -237,13 +249,33 @@ function bindCommands(): void
}) })
.catch((e) => notifyNative("error", { stage: "clipboard", message: String(e) })); .catch((e) => notifyNative("error", { stage: "clipboard", message: String(e) }));
}); });
// 设备管理:移除 / 吊销一台设备(DELETE /api/devices/{name})。需完整会话;若服务端要求 // 设备管理:移除 / 吊销一台设备(DELETE /api/devices/{device_id})。按稳定 device_id(路由
// step-up(403)这里拿不到浏览器再认证流程,回错给原生提示「请在网页端完成」。 // 参数即 device_id),原生从 presence 的 device_id 传来——之前误传设备名致后端按 device_id
// 找不到而「移除失败」。需完整会话;403(step-up)回错给原生提示「请在网页端完成」。
onNativeEvent("revokeDevice", (payload) => onNativeEvent("revokeDevice", (payload) =>
{ {
const name = (payload as { name?: string }).name ?? ""; const p = payload as { device_id?: string; name?: string };
if (!name) { return; } const id = p.device_id ?? "";
void revokeDevice(name); 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 上报后端。 // APNs 设备令牌登记:原生(AppDelegate)拿到令牌经桥送来,引擎用新鲜会话 token 上报后端。
onNativeEvent("registerPush", (payload) => onNativeEvent("registerPush", (payload) =>
@@ -311,11 +343,11 @@ async function provisionWidgetSession(deviceId: string, deviceName: string): Pro
} }
} }
async function revokeDevice(name: string): Promise<void> async function revokeDevice(deviceID: string): Promise<void>
{ {
try 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) if (r.status === 403)
{ {
notifyNative("error", { stage: "revoke", message: "step_up_required" }); notifyNative("error", { stage: "revoke", message: "step_up_required" });
@@ -326,7 +358,7 @@ async function revokeDevice(name: string): Promise<void>
notifyNative("error", { stage: "revoke", message: `HTTP ${r.status}` }); notifyNative("error", { stage: "revoke", message: `HTTP ${r.status}` });
return; return;
} }
notifyNative("deviceRevoked", { name }); notifyNative("deviceRevoked", { device_id: deviceID });
} }
catch (e) catch (e)
{ {
@@ -334,6 +366,59 @@ async function revokeDevice(name: string): Promise<void>
} }
} }
// 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<void>
{
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
// "你好, <uuid>". 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<void>
{
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 失败 // installLogBridge:把 console.warn / error 过桥给原生(设置页显示「最近日志」)。SSE 失败
// 等都走 console.warn,这样不接 Web Inspector 也能在真机看到失败原因(401 / 网络 / 静默挂起)。 // 等都走 console.warn,这样不接 Web Inspector 也能在真机看到失败原因(401 / 网络 / 静默挂起)。
function installLogBridge(): void function installLogBridge(): void
@@ -384,6 +469,7 @@ function boot(): void
void startHub(hubCtrl.signal); // SSE 信令循环:presence / 传入 offer / 状态 / 信令 void startHub(hubCtrl.signal); // SSE 信令循环:presence / 传入 offer / 状态 / 信令
void refreshICEServers(); // 预取 TURN / STUN,首次 WebRTC 即可用 void refreshICEServers(); // 预取 TURN / STUN,首次 WebRTC 即可用
void refreshSessionScope(); // 校正 /api/me 的权限级别 void refreshSessionScope(); // 校正 /api/me 的权限级别
void healIOSIdentity(); // 用 /api/me 的真名替换 QR 登录注入的 UUID 名(#12
// 启动即推一次当前快照:subscribe 只在「之后」的变更触发,初始态(多为空,但桌面壳 // 启动即推一次当前快照:subscribe 只在「之后」的变更触发,初始态(多为空,但桌面壳
// 复用同入口时可能已有)须主动补发,免原生 UI 等到下一次变更才填。 // 复用同入口时可能已有)须主动补发,免原生 UI 等到下一次变更才填。
+52 -5
View File
@@ -49,18 +49,22 @@ export function loginRedirect(): void
// ---- logout --------------------------------------------------------------- // ---- logout ---------------------------------------------------------------
export function logout() export async function logout(): Promise<void>
{ {
// Fire-and-forget: tell the backend to kick this device from the Hub immediately // 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 // (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. // yielding, so the subsequent clearAuth can't strip this request's Authorization.
void apiFetch("/api/me/disconnect", { method: "POST" }).catch(() => { /* ignore */ }); void apiFetch("/api/me/disconnect", { method: "POST" }).catch(() => { /* ignore */ });
// Revoke this device's own broker session server-side (self-service logout), so it // Revoke this device's own broker session server-side BEFORE clearing local credentials,
// can't be refreshed back. For a global-SSO browser this is a no-op (nothing to // so it can't be refreshed back. This must be AWAITED, not fire-and-forget: on desktop the
// revoke). Best-effort. // 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") 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(); useAppStore.getState().clearAuth();
// Desktop: drop the Go-side persisted session, else the next launch re-injects it. // Desktop: drop the Go-side persisted session, else the next launch re-injects it.
@@ -277,6 +281,49 @@ export async function ensureDeviceSession(): Promise<void>
} }
} }
// 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<void>
{
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 // 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 // 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 // new name. R2 rotates the one session in place (no duplicate device row); the rotated tokens
@@ -8,6 +8,7 @@ import {
Trash2, Trash2,
} from "lucide-react"; } from "lucide-react";
import { Button, DynText, IconButton, Panel, Tooltip } from "../../ui/primitives"; import { Button, DynText, IconButton, Panel, Tooltip } from "../../ui/primitives";
import { confirmDialog } from "../../utils/confirm";
import { toast } from "../../ui/feedback"; import { toast } from "../../ui/feedback";
import type { ClipboardState } from "../../store"; import type { ClipboardState } from "../../store";
import { t } from "../../i18n"; import { t } from "../../i18n";
@@ -110,7 +111,7 @@ export function ClipboardPanel(props: ClipboardPanelProps)
const handleClear = async () => const handleClear = async () =>
{ {
if (!hasContent) { return; } if (!hasContent) { return; }
if (!window.confirm(t("home.clipboard.clearConfirm"))) { return; } if (!await confirmDialog(t("home.clipboard.clearConfirm"))) { return; }
setBusy(true); setBusy(true);
try try
{ {
+2 -1
View File
@@ -3,6 +3,7 @@ import { Group, Stack, Text } from "@mantine/core";
import { Copy, KeyRound, Plus } from "lucide-react"; import { Copy, KeyRound, Plus } from "lucide-react";
import { apiJSON } from "../../net/api"; import { apiJSON } from "../../net/api";
import { t } from "../../i18n"; import { t } from "../../i18n";
import { confirmDialog } from "../../utils/confirm";
import { formatRelative } from "../../utils/format"; import { formatRelative } from "../../utils/format";
import { Badge, Button, Callout, DynText, Panel, TextField } from "../../ui/primitives"; import { Badge, Button, Callout, DynText, Panel, TextField } from "../../ui/primitives";
import { toast } from "../../ui/feedback"; import { toast } from "../../ui/feedback";
@@ -113,7 +114,7 @@ export function ShortcutTokens()
const handleRevoke = async (token: ShortcutTokenView) => 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)); setRevoking((prev) => new Set(prev).add(token.jti));
try try
+6 -2
View File
@@ -194,6 +194,7 @@ function handlePresence(data: unknown): void
const r = raw as Record<string, unknown>; const r = raw as Record<string, unknown>;
if (typeof r.name !== "string" || typeof r.type !== "string") { continue; } if (typeof r.name !== "string" || typeof r.type !== "string") { continue; }
devices.push({ devices.push({
deviceId: typeof r.device_id === "string" && r.device_id ? r.device_id : undefined,
name: r.name, name: r.name,
type: r.type, type: r.type,
online: Boolean(r.online), online: Boolean(r.online),
@@ -202,7 +203,10 @@ function handlePresence(data: unknown): void
} }
const store = useAppStore.getState(); 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); store.setDevices(devices);
// Detect peers that just transitioned to offline; if any active transfer // 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. // by transfer:state FAILED) will remove the entry, so we don't double-fire.
for (const d of devices) 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)) for (const t of Object.values(store.activeTransfers))
{ {
+15 -1
View File
@@ -355,6 +355,9 @@ export const enUS: Partial<TranslationDict> = {
"ios.login.guide": "Scan to approve from another signed-in device", "ios.login.guide": "Scan to approve from another signed-in device",
"ios.login.expired": "Code expired — please try again", "ios.login.expired": "Code expired — please try again",
"ios.login.failed": "Sign-in failed — 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.refresh": "Refresh QR Code",
"ios.login.needFull": "This device needs full access — choose \"Trust this device\" when approving.", "ios.login.needFull": "This device needs full access — choose \"Trust this device\" when approving.",
"ios.send.pickDevice": "Choose a device", "ios.send.pickDevice": "Choose a device",
@@ -397,7 +400,18 @@ export const enUS: Partial<TranslationDict> = {
"ios.settings.user": "User", "ios.settings.user": "User",
"ios.settings.logout": "Sign Out", "ios.settings.logout": "Sign Out",
"ios.settings.deviceName": "Device Name", "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.deviceCount": "Known Devices",
"ios.settings.signaling": "Signaling", "ios.settings.signaling": "Signaling",
"ios.settings.presenceEvents": "Presence Events", "ios.settings.presenceEvents": "Presence Events",
+15 -1
View File
@@ -351,6 +351,9 @@ export const zhCN = {
"ios.login.guide": "用另一台已登录的设备扫码批准", "ios.login.guide": "用另一台已登录的设备扫码批准",
"ios.login.expired": "二维码已失效,请重试", "ios.login.expired": "二维码已失效,请重试",
"ios.login.failed": "登录失败,请重试", "ios.login.failed": "登录失败,请重试",
"ios.login.broker": "登录",
"ios.login.brokerStarting": "正在打开登录…",
"ios.login.scanAlt": "用其他设备扫码登录",
"ios.login.refresh": "刷新二维码", "ios.login.refresh": "刷新二维码",
"ios.login.needFull": "此设备需要完整权限,批准时请选择“信任此设备”", "ios.login.needFull": "此设备需要完整权限,批准时请选择“信任此设备”",
"ios.send.pickDevice": "选择接收设备", "ios.send.pickDevice": "选择接收设备",
@@ -393,7 +396,18 @@ export const zhCN = {
"ios.settings.user": "用户", "ios.settings.user": "用户",
"ios.settings.logout": "退出登录", "ios.settings.logout": "退出登录",
"ios.settings.deviceName": "设备名称", "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.deviceCount": "已知设备",
"ios.settings.signaling": "信令连接", "ios.settings.signaling": "信令连接",
"ios.settings.presenceEvents": "在线事件", "ios.settings.presenceEvents": "在线事件",
+15 -1
View File
@@ -355,6 +355,9 @@ export const zhTW: Partial<TranslationDict> = {
"ios.login.guide": "用另一台已登入的裝置掃碼核准", "ios.login.guide": "用另一台已登入的裝置掃碼核准",
"ios.login.expired": "QR 碼已失效,請重試", "ios.login.expired": "QR 碼已失效,請重試",
"ios.login.failed": "登入失敗,請重試", "ios.login.failed": "登入失敗,請重試",
"ios.login.broker": "登入",
"ios.login.brokerStarting": "正在開啟登入…",
"ios.login.scanAlt": "用其他裝置掃碼登入",
"ios.login.refresh": "重新整理 QR 碼", "ios.login.refresh": "重新整理 QR 碼",
"ios.login.needFull": "此裝置需要完整權限,批准時請選擇「信任此裝置」", "ios.login.needFull": "此裝置需要完整權限,批准時請選擇「信任此裝置」",
"ios.send.pickDevice": "選擇接收裝置", "ios.send.pickDevice": "選擇接收裝置",
@@ -397,7 +400,18 @@ export const zhTW: Partial<TranslationDict> = {
"ios.settings.user": "使用者", "ios.settings.user": "使用者",
"ios.settings.logout": "登出", "ios.settings.logout": "登出",
"ios.settings.deviceName": "裝置名稱", "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.deviceCount": "已知裝置",
"ios.settings.signaling": "信令連線", "ios.settings.signaling": "信令連線",
"ios.settings.presenceEvents": "上線事件", "ios.settings.presenceEvents": "上線事件",
+2
View File
@@ -10,6 +10,7 @@ import {
type MantineColorsTuple, type MantineColorsTuple,
} from "@mantine/core"; } from "@mantine/core";
import { RouterProvider, createRouter } from "@tanstack/react-router"; import { RouterProvider, createRouter } from "@tanstack/react-router";
import { ConfirmHost } from "./ui/ConfirmHost";
import { ToastViewport } from "./ui/feedback"; import { ToastViewport } from "./ui/feedback";
import { routeTree } from "./routeTree.gen"; import { routeTree } from "./routeTree.gen";
import { useAppStore, type ThemeMode } from "./store"; import { useAppStore, type ThemeMode } from "./store";
@@ -107,6 +108,7 @@ function App()
forceColorScheme={force} forceColorScheme={force}
> >
<ToastViewport /> <ToastViewport />
<ConfirmHost />
<RouterProvider key={locale} router={router} /> <RouterProvider key={locale} router={router} />
</MantineProvider> </MantineProvider>
); );
+56
View File
@@ -19,6 +19,8 @@
// - 接收落盘四方法 beginDownload / appendDownload / finalizeDownload / // - 接收落盘四方法 beginDownload / appendDownload / finalizeDownload /
// abortDownload 与桌面 desktop.ts 同形(data 为 base64),落 iOS 沙盒文件。 // abortDownload 与桌面 desktop.ts 同形(data 为 base64),落 iOS 沙盒文件。
import type { FileSource } from "../features/transfer/source";
// WKScriptMessageHandler 的 JS 侧投递接口(仅 postMessage)。 // WKScriptMessageHandler 的 JS 侧投递接口(仅 postMessage)。
interface WebKitMessageHandler interface WebKitMessageHandler
{ {
@@ -146,6 +148,15 @@ function bytesToBase64(bytes: Uint8Array): string
return btoa(binary); return btoa(binary);
} }
// base64ToBytes 把原生回传的 base64(发送侧按 Range 读出的文件片)解回字节。
function base64ToBytes(b64: string): Uint8Array<ArrayBuffer>
{
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 过桥交原生写入沙盒下载 // saveIncomingFileIOS:整文件(接收端小文件路径)经 base64 过桥交原生写入沙盒下载
// 目录,返回最终绝对路径。桥缺失或写盘失败抛错,由调用方回退。 // 目录,返回最终绝对路径。桥缺失或写盘失败抛错,由调用方回退。
export async function saveIncomingFileIOS(name: string, blob: Blob): Promise<string> export async function saveIncomingFileIOS(name: string, blob: Blob): Promise<string>
@@ -177,3 +188,48 @@ export async function abortIncomingDownloadIOS(sessionId: string): Promise<void>
try { await callNative<void>("abortDownload", { sessionId }); } try { await callNative<void>("abortDownload", { sessionId }); }
catch { /* 取消 / 失败路径,吞掉 */ } catch { /* 取消 / 失败路径,吞掉 */ }
} }
// ── 发送侧文件源(经桥按 Range 取片) ────────────────────────────────────────
// 每次桥取片的上限:把单次 evaluateJavaScript 回传的 base64 串控制在 ~683 KiB512 KiB×4/3),
// 远小于会卡顿的体量。p2p 一次读 4 MiB、relay 一次 1 MiBbridgeFileSource 内部按此循环拼齐。
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<Uint8Array<ArrayBuffer>>
{
const b64 = await callNative<string>("readFileSlice", { url, start, end });
return base64ToBytes(b64);
}
// bridgeFileSourceiOS 无头引擎的发送字节源(取代 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;
},
};
}
+8 -3
View File
@@ -1,4 +1,5 @@
import { apiFetch, apiJSON } from "./api"; import { apiFetch, apiJSON } from "./api";
import { isDesktop } from "./desktop";
// 扫码登录的网络封装。两条信任边界: // 扫码登录的网络封装。两条信任边界:
// - 显码页(新设备)在 start 时还未登录,故 start / status 是公开端点, // - 显码页(新设备)在 start 时还未登录,故 start / status 是公开端点,
@@ -264,19 +265,23 @@ export interface LinkParams
} }
// parseLinkApprovalUrl 把扫到的二维码内容解析成批准页参数,严格校验后才放行—— // parseLinkApprovalUrl 把扫到的二维码内容解析成批准页参数,严格校验后才放行——
// 这是应用内扫码器的安全闸门,决定扫到的码能否在已登录会话内被导航。 // 这是应用内扫码器的安全闸门,决定扫到的码能否在已登录会话内被导航。校验:
// 只接受「本站 origin + /link 路径 + 同时带 r 与 c」的 URL:
// - 同源(origin 严格等于当前页 origin)——挡开放重定向 / 钓鱼站二维码; // - 同源(origin 严格等于当前页 origin)——挡开放重定向 / 钓鱼站二维码;
// - 路径恰为 /link——挡指向本站其他页的码; // - 路径恰为 /link——挡指向本站其他页的码;
// - r/c 皆非空——挡残缺码。 // - r/c 皆非空——挡残缺码。
// 任一不满足返回 null(调用方据此提示「继续对准」,不导航)。 // 任一不满足返回 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 export function parseLinkApprovalUrl(raw: string): LinkParams | null
{ {
let url: URL; let url: URL;
try { url = new URL(raw); } try { url = new URL(raw); }
catch { return null; } 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; } if (url.pathname !== "/link") { return null; }
const requestId = url.searchParams.get("r"); const requestId = url.searchParams.get("r");
+23 -12
View File
@@ -9,7 +9,8 @@ import {
} from "@mantine/core"; } from "@mantine/core";
import { createFileRoute, Link, redirect, useNavigate } from "@tanstack/react-router"; import { createFileRoute, Link, redirect, useNavigate } from "@tanstack/react-router";
import { Bell, LogOut, ShieldAlert, Trash2 } from "lucide-react"; 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 { DesktopSettings } from "../features/desktop/DesktopSettings";
import { isDesktop, persistDesktopDeviceName } from "../net/desktop"; import { isDesktop, persistDesktopDeviceName } from "../net/desktop";
import { import {
@@ -73,14 +74,13 @@ function SettingsPage()
setRenaming(true); setRenaming(true);
try try
{ {
// 改名只换 label,不换身份(device_id 稳定):先落本地名(root effect 会以新名重连 // 改名与会话令牌解耦:先落本地名(root effect 会以新名作 X-Device-Name),再 PATCH
// SSE),再以同一 device_id 重新代铸,把新 label 推给 broker、原地轮换那条会话 // /api/devices/{device_id} 只改名字——服务端按稳定 device_id 改名 + 重广播 presence
// 故统一设备列表与 presence 都显示新名、不产生重复设备行。桌面在此只更新本地名 // 不换 token、不中断 SSE、不产生重复设备行;全端即时看到新名。桌面同步持久化本地名
// broker label 于下次登录代铸时同步。
setSelfDeviceName(trimmedName); setSelfDeviceName(trimmedName);
persistDesktopDeviceName(trimmedName); // 桌面:持久化到 Go 配置,跨重启存活 persistDesktopDeviceName(trimmedName); // 桌面:持久化到 Go 配置,跨重启存活
syncWebDeviceName(trimmedName); // 浏览器:持久化到服务端 session,抗 PWA 存储清除 syncWebDeviceName(trimmedName); // 浏览器:持久化到服务端 session,抗 PWA 存储清除
await renameDeviceSession(); await renameDevice();
toast.ok(t("settings.deviceName.success")); toast.ok(t("settings.deviceName.success"));
} }
catch (e) catch (e)
@@ -95,20 +95,21 @@ function SettingsPage()
} }
}; };
const handleUnregisterSelf = () => const handleUnregisterSelf = async () =>
{ {
if (!selfDeviceName) { return; } 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),故无需再按 // logout() 已自吊销本设备的 broker 会话(/api/auth/logout,按 device_id),故无需再按
// 设备名 DELETE(迁移后设备路由按 device_id,旧的按名删除一律 404、且属冗余)。 // 设备名 DELETE(迁移后设备路由按 device_id,旧的按名删除一律 404、且属冗余)。
// await:先完成 broker 吊销再清本地(桌面 clearDesktopSession 会丢 Go 侧 refresh)。
setSelfDeviceName(null); setSelfDeviceName(null);
logout(); await logout();
navigate({ to: "/login", search: { dev_user: undefined } }); navigate({ to: "/login", search: { dev_user: undefined } });
}; };
const handleSignOut = () => const handleSignOut = async () =>
{ {
logout(); await logout();
navigate({ to: "/login", search: { dev_user: undefined } }); navigate({ to: "/login", search: { dev_user: undefined } });
}; };
@@ -357,6 +358,16 @@ function SessionsPanel(props: { isGuest: boolean; onSignedOutSelf: () => void })
void reload(); void reload();
}, [ isGuest, 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 吊销 + 删设备行。 // handleRevoke 登出一条会话(= 一台设备)。后端连带 broker 吊销 + 删设备行。
// step-up 二次认证已移除(full 档即可信)。 // step-up 二次认证已移除(full 档即可信)。
const handleRevoke = async (sess: AuthSession) => const handleRevoke = async (sess: AuthSession) =>
@@ -364,7 +375,7 @@ function SessionsPanel(props: { isGuest: boolean; onSignedOutSelf: () => void })
const confirmMsg = sess.current const confirmMsg = sess.current
? t("settings.sessions.confirmCurrent") ? t("settings.sessions.confirmCurrent")
: t("settings.sessions.confirmOther", { name: sess.deviceName }); : t("settings.sessions.confirmOther", { name: sess.deviceName });
if (!window.confirm(confirmMsg)) { return; } if (!await confirmDialog(confirmMsg)) { return; }
setBusyId(sess.id); setBusyId(sess.id);
try try
+3
View File
@@ -16,6 +16,9 @@ export interface User
export interface DeviceInfo export interface DeviceInfo
{ {
/** 稳定不透明设备 id(broker meta)。设备名与令牌解耦后,它是去重 / /
namedeviceId presence */
deviceId?: string;
name: string; name: string;
type: string; type: string;
online: boolean; online: boolean;
+31
View File
@@ -0,0 +1,31 @@
import { Button, Group, Modal, Text } from "@mantine/core";
import { t } from "../i18n";
import { useConfirmStore } from "../utils/confirm";
// 全局确认框宿主:挂一次于 React 树(main.tsxMantineProvider 内)。confirmDialog() 触发时弹出,
// 用户点确认 / 取消结清对应 Promise。DOM 渲染,浏览器 + Wails 桌面均可用(取代失效的 window.confirm)。
export function ConfirmHost()
{
const pending = useConfirmStore((s) => s.pending);
const settle = useConfirmStore((s) => s.settle);
return (
<Modal
opened={pending !== null}
onClose={() => settle(false)}
title={t("common.confirm")}
centered
withCloseButton={false}
>
<Text size="sm">{pending?.message}</Text>
<Group justify="flex-end" mt="lg">
<Button variant="default" onClick={() => settle(false)}>
{t("common.cancel")}
</Button>
<Button color="red" onClick={() => settle(true)}>
{t("common.confirm")}
</Button>
</Group>
</Modal>
);
}
+42
View File
@@ -0,0 +1,42 @@
import { create } from "zustand";
// 应用内确认框(取代在 Wails 桌面 WebView 失效的 window.confirm)。confirmDialog 弹一个 DOM
// 渲染的 Mantine Modal(浏览器 + 桌面都可用),返回用户是否确认。须在 React 树里挂一次
// <ConfirmHost/>(见 main.tsx)。破坏性操作(移除设备 / 全部清除 / 撤销令牌等)用它做二次确认。
interface PendingConfirm
{
message: string;
resolve: (ok: boolean) => void;
}
interface ConfirmStore
{
pending: PendingConfirm | null;
open: (message: string) => Promise<boolean>;
settle: (ok: boolean) => void;
}
export const useConfirmStore = create<ConfirmStore>((set, get) =>
({
pending: null,
open: (message) => new Promise<boolean>((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<boolean>
{
return useConfirmStore.getState().open(message);
}