设备名与令牌解耦 + 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:
@@ -1,3 +1,4 @@
|
||||
import PhotosUI
|
||||
import SwiftUI
|
||||
import UIKit
|
||||
import UniformTypeIdentifiers
|
||||
@@ -9,6 +10,9 @@ import UniformTypeIdentifiers
|
||||
struct RootView: View
|
||||
{
|
||||
@Environment(EngineController.self) private var engine
|
||||
// auth 经环境注入(AppRoot 已 .environment(auth)),用于把会话源在 EngineWebView.makeUIView
|
||||
// 里同步接给引擎——早于 boot 注入,消除冷启重开的「missing injected session」竞态(#11)。
|
||||
@Environment(AuthManager.self) private var auth
|
||||
// 初始标签可经 CDROP_TAB 环境变量指定(本机截图测试用:直接进设置看引擎状态)。
|
||||
@State private var selection = ProcessInfo.processInfo.environment["CDROP_TAB"] ?? "transfer"
|
||||
|
||||
@@ -30,6 +34,13 @@ struct RootView: View
|
||||
DeviceListView()
|
||||
}
|
||||
}
|
||||
Tab(t("ios.tab.messages"), systemImage: "message", value: "messages")
|
||||
{
|
||||
NavigationStack
|
||||
{
|
||||
MessagesView()
|
||||
}
|
||||
}
|
||||
Tab(t("ios.tab.files"), systemImage: "folder", value: "files")
|
||||
{
|
||||
NavigationStack
|
||||
@@ -49,7 +60,7 @@ struct RootView: View
|
||||
.tint(.cdropAccent)
|
||||
.background
|
||||
{
|
||||
EngineWebView(controller: engine)
|
||||
EngineWebView(controller: engine, auth: auth)
|
||||
.frame(width: 0, height: 0)
|
||||
.opacity(0)
|
||||
.allowsHitTesting(false)
|
||||
@@ -78,9 +89,12 @@ struct TransferListView: View
|
||||
{
|
||||
@Environment(EngineController.self) private var engine
|
||||
@State private var showImporter = false
|
||||
@State private var showPhotoPicker = false
|
||||
@State private var photoItem: PhotosPickerItem?
|
||||
@State private var pickedURL: URL?
|
||||
@State private var showDevicePicker = false
|
||||
@State private var showNoDevices = false
|
||||
@State private var showClearConfirm = false
|
||||
|
||||
// 可发送目标:在线、且非本机(共用 EngineController.sendableDevices)。
|
||||
private var sendableDevices: [DeviceItem]
|
||||
@@ -115,6 +129,12 @@ struct TransferListView: View
|
||||
ForEach(engine.history)
|
||||
{ item in
|
||||
transferLink(item)
|
||||
.contextMenu
|
||||
{
|
||||
Button(role: .destructive)
|
||||
{ engine.deleteTransferRecord(item.sessionId) }
|
||||
label: { Label(t("transfer.action.delete"), systemImage: "trash") }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -122,9 +142,33 @@ struct TransferListView: View
|
||||
}
|
||||
}
|
||||
.navigationTitle(t("ios.tab.transfer"))
|
||||
.toolbar
|
||||
{
|
||||
if !engine.history.isEmpty
|
||||
{
|
||||
ToolbarItem(placement: .topBarTrailing)
|
||||
{
|
||||
Button(role: .destructive) { showClearConfirm = true }
|
||||
label: { Label(t("ios.records.clear"), systemImage: "trash") }
|
||||
}
|
||||
}
|
||||
}
|
||||
.confirmationDialog(t("ios.records.clearConfirm"), isPresented: $showClearConfirm, titleVisibility: .visible)
|
||||
{
|
||||
Button(t("ios.records.clear"), role: .destructive) { engine.clearHistory() }
|
||||
Button(t("common.cancel"), role: .cancel) { }
|
||||
}
|
||||
.overlay(alignment: .bottomTrailing)
|
||||
{
|
||||
Button { showImporter = true }
|
||||
// 发送入口:文件(Files / 文档)或图库(Photos)。图库走 PhotosPicker,选中后取字节落
|
||||
// 临时文件再复用既有发送链路(#14)。
|
||||
Menu
|
||||
{
|
||||
Button { showImporter = true }
|
||||
label: { Label(t("ios.send.fromFiles"), systemImage: "folder") }
|
||||
Button { showPhotoPicker = true }
|
||||
label: { Label(t("ios.send.fromPhotos"), systemImage: "photo.on.rectangle") }
|
||||
}
|
||||
label:
|
||||
{
|
||||
Image(systemName: "paperplane.fill")
|
||||
@@ -143,6 +187,9 @@ struct TransferListView: View
|
||||
else { showDevicePicker = true }
|
||||
}
|
||||
}
|
||||
.photosPicker(isPresented: $showPhotoPicker, selection: $photoItem,
|
||||
matching: .any(of: [ .images, .videos ]))
|
||||
.onChange(of: photoItem) { loadPhoto() }
|
||||
.confirmationDialog(t("ios.send.pickDevice"), isPresented: $showDevicePicker, titleVisibility: .visible)
|
||||
{
|
||||
ForEach(sendableDevices)
|
||||
@@ -184,6 +231,32 @@ struct TransferListView: View
|
||||
engine.sendFile(to: device, fileURL: url)
|
||||
pickedURL = nil
|
||||
}
|
||||
|
||||
// 图库选中后:异步取字节 → 落临时文件 → 走与文件相同的「选设备发送」流程(#14)。
|
||||
private func loadPhoto()
|
||||
{
|
||||
guard let item = photoItem else { return }
|
||||
Task
|
||||
{
|
||||
guard let data = try? await item.loadTransferable(type: Data.self),
|
||||
let url = engine.stagePhotoData(data, suggestedName: suggestedPhotoName(item))
|
||||
else { return }
|
||||
await MainActor.run
|
||||
{
|
||||
pickedURL = url
|
||||
photoItem = nil
|
||||
if sendableDevices.isEmpty { showNoDevices = true }
|
||||
else { showDevicePicker = true }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 据所选项的内容类型推一个带正确扩展名的文件名(图片默认 jpg,视频默认 mov)。
|
||||
private func suggestedPhotoName(_ item: PhotosPickerItem) -> String
|
||||
{
|
||||
let ext = item.supportedContentTypes.first?.preferredFilenameExtension ?? "jpg"
|
||||
return "photo-\(UUID().uuidString.prefix(8)).\(ext)"
|
||||
}
|
||||
}
|
||||
|
||||
// 传输卡片:内容层,用语义材质背景而非玻璃(玻璃专属功能 / 导航层)。状态 / 阶段 / 速率
|
||||
@@ -262,6 +335,21 @@ struct TransferDetailView: View
|
||||
{
|
||||
List
|
||||
{
|
||||
// 进行中的传输:主动取消 / 立即切中继(O)。已是中继则不再显示「立即中继」。
|
||||
if isActive(item)
|
||||
{
|
||||
Section
|
||||
{
|
||||
Button(role: .destructive) { engine.cancelTransfer(item.sessionId) }
|
||||
label: { Label(t("ios.transfer.cancel"), systemImage: "xmark.circle") }
|
||||
if item.mode != "relay"
|
||||
{
|
||||
Button { engine.switchToRelay(item.sessionId) }
|
||||
label: { Label(t("ios.transfer.forceRelay"),
|
||||
systemImage: "antenna.radiowaves.left.and.right") }
|
||||
}
|
||||
}
|
||||
}
|
||||
Section
|
||||
{
|
||||
LabeledContent(t("ios.detail.direction"),
|
||||
@@ -343,7 +431,13 @@ struct DeviceListView: View
|
||||
engine.devices.sorted { a, b in isSelf(a) && !isSelf(b) }
|
||||
}
|
||||
|
||||
private func isSelf(_ dev: DeviceItem) -> Bool { dev.name == engine.deviceName }
|
||||
// 判本机按稳定 device_id(与设备名解耦——改名后仍判得准);selfDeviceID 缺失时回落按名。
|
||||
private func isSelf(_ dev: DeviceItem) -> Bool
|
||||
{
|
||||
let selfID = engine.selfDeviceID
|
||||
if !selfID.isEmpty, !dev.deviceID.isEmpty { return dev.deviceID == selfID }
|
||||
return dev.name == engine.deviceName
|
||||
}
|
||||
|
||||
var body: some View
|
||||
{
|
||||
@@ -385,7 +479,7 @@ struct DeviceListView: View
|
||||
{
|
||||
Button(t("ios.devices.remove"), role: .destructive)
|
||||
{
|
||||
if let target = revokeTarget { engine.revokeDevice(target.name) }
|
||||
if let target = revokeTarget { engine.revokeDevice(target.deviceID) }
|
||||
}
|
||||
Button(t("common.cancel"), role: .cancel) { }
|
||||
}
|
||||
@@ -438,9 +532,17 @@ struct SettingsView: View
|
||||
Section
|
||||
{
|
||||
LabeledContent(t("ios.settings.user"), value: user.name)
|
||||
// 即时改名(设备名与令牌解耦):提交即经引擎 PATCH /api/devices/{device_id} 改名,
|
||||
// 不换 token、不重连、不产生重复项,全端即时看到新名(见 EngineController.renameSelf)。
|
||||
TextField(t("ios.settings.deviceName"), text: $deviceNameDraft)
|
||||
.submitLabel(.done)
|
||||
.onChange(of: deviceNameDraft) { DeviceNameStore.value = deviceNameDraft }
|
||||
.onSubmit { commitRename() }
|
||||
if !engine.deviceActionStatus.isEmpty
|
||||
{
|
||||
Text(engine.deviceActionStatus)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
Button(role: .destructive) { logout() }
|
||||
label:
|
||||
{
|
||||
@@ -511,6 +613,15 @@ struct SettingsView: View
|
||||
return t("ios.engine.disconnected")
|
||||
}
|
||||
|
||||
// 即时改名:提交时把草稿交引擎改名(PATCH /api/devices/{device_id})。空 / 未变则忽略。
|
||||
// 成功后引擎经 renamed 事件落本地名 + Keychain 会话名 + 刷新 engine.deviceName。
|
||||
private func commitRename()
|
||||
{
|
||||
let trimmed = deviceNameDraft.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty, trimmed != (auth.session?.deviceName ?? "") else { return }
|
||||
engine.renameSelf(to: trimmed)
|
||||
}
|
||||
|
||||
// 登出:先复位引擎(断 SSE + 丢 WebView),再清会话回登录页(清 Keychain 由 auth 负责)。
|
||||
private func logout()
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user