鉴权并入 Auth Broker:委派设备会话统一模型 + 四端迁移

后端(委托 Auth Broker,路径 A):
- 删自建鉴权(OIDC exchange / 自签会话 / step-up / shortcut / web_sessions / accounts),cdrop 不再存任何凭证;鉴权中间件改读边缘注入的 X-Auth-Subject/Scope/Meta/Name/Roles 头(dev 旁路保留);Claims 加 Tier() / Guest()
- internal/brokerclient:mint / revoke(带 X-Broker-App)/ refresh / ListSessions(R1 列举),直连内网、吊销幂等

统一会话模型“委派设备会话”(Delegated Device Sessions):
- 每个客户端(浏览器 / 桌面 / 扫码设备)=一条带 meta(device_id) + label 的 broker 机器会话;Broker 作设备会话唯一注册表(R1 按用户+app 列举 + R2 按 (user,app,meta) 幂等铸造),cdrop 退化为薄覆盖层、不再自存权威会话表
- 新增代铸端点 POST /api/auth/device-session:凭边缘已验明的 X-Auth-Subject 委托 broker 铸 / 轮换设备会话(meta=device_id、按调用方 tier 防越权、sameOrigin CSRF、per-IP 限流);R2 幂等保证同一 device_id 重登原地轮换、不堆重复设备
- 会话列表=R1 权威 + 叠加 type(本地缓存)/ online(Hub presence,按设备名)/ current(meta 匹配本请求 X-Auth-Meta)+ 过滤 meta=""(device-authorize 引导会话残留);devices 表降级为 type/presence 薄缓存(非会话权威),device_id 主键、upsert 按 user 限定
- 吊销按 device_id → 缓存优先 / R1 兜底解析 sid → broker 吊销 + X-Broker-App;扫码登录保留三密钥编排,collect 改委托 broker 铸 + 落缓存行

Web 前端:
- 登录走 broker 全局 SSO 代跳(/api/auth/login 302);bootstrap 经 /api/me 注入身份后代铸设备会话(稳定 device_id 存 localStorage、Web Locks 跨 tab 串行防重复铸造);refresh 走 /api/auth/refresh
- 设备管理按 device_id;改名=同 device_id 重代铸(R2 原地轮换换 label、不产生重复行);登录页反应式守卫修登录回环
- 去 OIDC PKCE / step-up(删 oauth.callback / stepUp)

桌面客户端(Wails):
- loopback PKCE(RFC 8252)改指 broker 设备授权流(/device/authorize + /device/token)拿引导令牌,再代铸出带 meta 的托管设备会话——与浏览器同模型、同管理、同吊销;身份取自代铸响应(修“显示名显示为 UUID”);refresh 保留显示名;稳定 device_id 入桌面配置

iOS 客户端(arch A,原生 SwiftUI + 离屏无头 WebView 引擎 + 原生↔JS 桥):
- 引擎 / 文件管理 / 设备管理 / 应用图标 / 本地化(此前实现,随本次落入版本库)
- 鉴权=引擎自刷(boot 注入 refresh_token)+ broker 轮换经 sessionRotated 回报原生更新 Keychain;去 cookie 同步;Session 加 refreshToken / deviceId

实时 / 健壮性:
- presence 走 Hub union(设备表行 ∪ 表外实时连接,按名去重、live-only 标在线)
- Hub 通道 close 一律在写锁内、非阻塞 send 一律在读锁内,消除 close-vs-send 闭通道 send panic(revoke 每次 Kick 后该路径变热)

配置 / 删旧栈:
- config 改 broker 接入(CDROP_BROKER_* / CDROP_PUBLIC_URL / 按档 TTL),prod 强校验 broker 配置 + PUBLIC_URL(CSRF Origin 守卫不失效)
- 删 auth.go / selftoken.go / shortcut.go / jwks.go + 三表(web_sessions / accounts / shortcut_tokens)及验证链;.env.example / compose.snippet.yaml / Caddyfile.snippet 更新为 broker 模型(人机分流 + 公开端点放行 + X-Auth-Meta 透传)
- 测试全重写:QR / 会话含 mock broker(R1 列举 + R2 幂等);hub 加 close-vs-send 并发回归;config 加 prod 必填校验
This commit is contained in:
2026-06-26 02:07:11 +08:00
parent c79b176b87
commit 10cf36ecee
104 changed files with 7533 additions and 5318 deletions
+8
View File
@@ -0,0 +1,8 @@
# CDrop.xcodeproj 由 xcodegen 从 project.yml 生成——不提交,用 `xcodegen generate` 重建。
CDrop.xcodeproj/
# Xcode 构建产物
build/
DerivedData/
*.xcuserstate
.DS_Store
+40
View File
@@ -0,0 +1,40 @@
# cdrop iOS 真机自测手册
账号到位即可照做。模拟器构建不需签名,已全程 compile + 跑通;**真机**要付费 ADP + 签名 + 实体 iPhone,下面是 turnkey 步骤 + 只能真机验的清单。
## 前置
- 付费 Apple Developer Program($99/年)。
- 一台 iPhone(iOS 26),Mac 装 Xcode 26。
- 已登录的 cdrop(网页 / 桌面)一个——用来扫码批准本机登录。
## 出工程 + 签名
1. `cd ios/CDrop && xcodegen generate`(从 `project.yml` 生成 `CDrop.xcodeproj`)。
2. Xcode 打开 `CDrop.xcodeproj` → 选 `CDrop` target → Signing & Capabilities:
- **Team** 选你的 ADP 团队;**Bundle Identifier** 用你账号下可注册的(默认 `net.commilitia.cdrop`,前缀按你的改)。
- 让 Xcode 自动管理签名(Automatically manage signing)。
3. 连 iPhone、信任、选作运行目标 → Run。
- `project.yml` 里 `CODE_SIGNING_ALLOWED: NO` 是给模拟器的;Xcode 真机自动签名会覆盖它,无需手改。
## 引擎可达性(让传输真能连)
- 真机上引擎 WebView 默认加载 `https://drop.commilitia.net/engine.html`——**该文件要先随 web 部署到 prod**(`vite build` 已产出 `dist/engine.html`,随正常 web 发布上线)。
- 或本机联调:设环境变量 `CDROP_ENGINE_URL` 指向可达引擎、`CDROP_API_BASE` 指向后端。
## 后续功能要补的 capability(按需,各自要在 Developer Portal 配 App ID)
- **App Groups**(`group.net.commilitia.cdrop`):Share Extension / 控制中心控件与主 app 共享数据。
- **Push Notifications** + APNs `.p8`:传输事件推送。
- **Keychain Sharing**:跨 app / 扩展共享登录态。
- **Associated Domains**:若改用 OIDC Universal Link 回调(扫码登录则不需要)。
## 只能真机验的清单
- [ ] 液态玻璃在真机渲染(含陀螺仪高光 / 动效)。
- [ ] 扫码登录:本机显码 → 用已登录 cdrop 扫码批准 → app 进入主界面。
- [ ] **本地网络权限**:首次同内网传输弹 `NSLocalNetworkUsageDescription` 提示;授予后 ICE 收到 host 候选(R-iOS-6);拒绝则回退中继仍可传。
- [ ] 发送:选文件 → 选设备 → 对端收到。
- [ ] 接收:对端发来 → 落到 Files(沙盒 Documents)。
- [ ] 后台:传输中切后台 → `BGContinuedProcessingTask` 续传行为(R-iOS-1 / R-iOS-3)。
- [ ] 大文件:发送侧内存(当前整文件入内存,大文件需转分块流式 = R-iOS-4)。
## 已知 TODO(真机阶段一并收)
- 会话 Keychain 持久化 + 自动续期(当前会话在内存、重启需重扫;续期端点 `/api/auth/refresh` 已可用,靠 cookie)。
- 发送侧大文件分块流式(R-iOS-4)。
- 剪贴板 App Intent + 控制中心控件(决策 C)、Share Extension——需上面的 capability + 账号才能跑。
@@ -0,0 +1,14 @@
{
"images" : [
{
"filename" : "icon-1024.png",
"idiom" : "universal",
"platform" : "ios",
"size" : "1024x1024"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

@@ -0,0 +1,6 @@
{
"info" : {
"author" : "xcode",
"version" : 1
}
}
+223
View File
@@ -0,0 +1,223 @@
import Foundation
import Observation
import UIKit
// 扫码登录(新设备侧)+ 会话持有。iOS 显示二维码,已登录的 web / 桌面设备用“扫码登录”
// 扫码批准,本端轮询 /api/auth/qr/status 拿到自签会话。字段 / 流程见后端 internal/httpapi/
// qr.go。登录是纯 REST(不经引擎 WebView),故 engine.html 未部署也能登录。
//
// 续期(Auth Broker 路径 A):access_token 默认 900s;引擎(WebView)持注入的 refresh_token,
// 到期经 POST /api/auth/refresh {refresh_token} 自刷(cdrop 代理 broker,见 refresh.go),broker
// 轮换 refresh 后经桥 sessionRotated 回报本端更新 Keychain;refresh 过期 / 吊销则经 authExpired
// 回登录页重扫。本端不再依赖 cdrop_session cookie。
@MainActor
@Observable
final class AuthManager
{
struct User: Codable
{
let id: String
let name: String
let avatar: String?
}
struct Session: Codable
{
let accessToken: String
// refreshToken:broker 委托会话的续期凭证,随 access 一并注入引擎自刷用;轮换后更新。
let refreshToken: String
let user: User
let deviceName: String
// deviceId:cdrop 生成的稳定不透明设备 id(= broker meta,回显 X-Auth-Meta)。
let deviceId: String
let scope: String
}
var session: Session?
var qrPayload: String?
var statusText: String = t("ios.login.generating")
// 二维码已失效(denied/expired/失败)→ 登录页据此提示并高亮「刷新二维码」。
var qrExpired = false
// 登录代次:每次 startQRLogin 自增,旧轮询据此识别自己已被刷新取代、丢弃结果,避免
// 「刷新时仍在轮询」两条循环并发改 statusText / qrPayload 打架。
private var loginGeneration = 0
// Keychain 持久化坐标(单 app 私有,见 Keychain.swift)。
private static let keychainService = "net.commilitia.cdrop.session"
private static let keychainAccount = "session"
// 启动即尝试恢复持久会话:命中则 app 直接进主界面,免反复扫码(迭代 / 重装友好)。
// 恢复的令牌可能已过期(guest 1h 上限)——此时引擎鉴权 401,用户经设置页登出重扫即可。
init()
{
if let data = Keychain.load(service: Self.keychainService, account: Self.keychainAccount),
let restored = try? JSONDecoder().decode(Session.self, from: data)
{
// 仅恢复完整权限会话;陈旧 guest(旧版本落地的)丢弃、强制重登为 full。
if restored.scope == "full" { session = restored }
else { Keychain.delete(service: Self.keychainService, account: Self.keychainAccount) }
}
}
// 后端基址:默认线上,可经 CDROP_API_BASE 覆盖(本机测试)。
private var apiBase: String
{
ProcessInfo.processInfo.environment["CDROP_API_BASE"] ?? "https://drop.commilitia.net"
}
private struct QRStart: Decodable
{
let request_id: String
let poll_secret: String
let qr_payload: String
let expires_at: Int64
}
private struct QRStatus: Decodable
{
let status: String
let access_token: String?
let refresh_token: String?
let device_id: String?
let expires_in: Int?
let user: User?
let device_name: String?
}
// startQRLogin:发起二维码 + 轮询直到批准 / 失效。可重复调用(刷新二维码):自增代次,
// 旧轮询识别到代次变更即弃。
func startQRLogin() async
{
loginGeneration += 1
let gen = loginGeneration
qrExpired = false
qrPayload = nil
statusText = t("ios.login.generating")
do
{
let start = try await qrStart()
if gen != loginGeneration { return }
qrPayload = start.qr_payload
statusText = t("ios.login.waiting")
try await poll(requestID: start.request_id, pollSecret: start.poll_secret, gen: gen)
}
catch
{
if gen != loginGeneration { return }
statusText = t("ios.login.failed")
qrExpired = true
}
}
func logout()
{
session = nil
qrPayload = nil
statusText = t("ios.login.generating")
Keychain.delete(service: Self.keychainService, account: Self.keychainAccount)
}
// 持久化当前会话到 Keychain(扫码批准后调用)。
private func persist()
{
guard let session, let data = try? JSONEncoder().encode(session) else { return }
Keychain.save(data, service: Self.keychainService, account: Self.keychainAccount)
}
// updateSession:引擎自刷致 broker 轮换后,经桥 sessionRotated 回报的新令牌对 → 更新
// 内存会话 + Keychain,使重启后不再用已失效的旧 refresh。仅换令牌,身份 / 设备名不变。
func updateSession(accessToken: String, refreshToken: String)
{
guard let cur = session else { return }
session = Session(accessToken: accessToken, refreshToken: refreshToken,
user: cur.user, deviceName: cur.deviceName,
deviceId: cur.deviceId, scope: cur.scope)
persist()
}
// 仅供本机模拟器联调(CDROP_DEBUG_SESSION=1):注入占位会话跳过扫码,用于验证引擎从
// prod 加载 + 桥往返。令牌无效、鉴权会 401,但能证 engine.html 在 WKWebView 里加载运行。
func debugSession()
{
session = Session(accessToken: "debug", refreshToken: "",
user: User(id: "debug", name: "Simulator", avatar: nil),
deviceName: "Simulator", deviceId: "", scope: "guest")
}
private func qrStart() async throws -> QRStart
{
var req = URLRequest(url: URL(string: "\(apiBase)/api/auth/qr/start")!)
req.httpMethod = "POST"
req.setValue("application/json", forHTTPHeaderField: "Content-Type")
req.setValue("ios", forHTTPHeaderField: "X-Device-Type")
req.httpBody = try JSONSerialization.data(
withJSONObject: [ "device_name": deviceName(), "device_type": "ios" ])
let (data, _) = try await URLSession.shared.data(for: req)
return try JSONDecoder().decode(QRStart.self, from: data)
}
private func poll(requestID: String, pollSecret: String, gen: Int) async throws
{
while session == nil && gen == loginGeneration
{
var comp = URLComponents(string: "\(apiBase)/api/auth/qr/status")!
comp.queryItems = [ URLQueryItem(name: "request_id", value: requestID) ]
var req = URLRequest(url: comp.url!)
req.setValue(pollSecret, forHTTPHeaderField: "X-Poll-Secret")
let (data, _) = try await URLSession.shared.data(for: req)
if gen != loginGeneration { return } // 已被刷新取代,丢弃本轮结果
let st = try JSONDecoder().decode(QRStatus.self, from: data)
switch st.status
{
case "approved":
if let token = st.access_token, let user = st.user
{
// 原生 App 仅允许完整权限(用户原则:iOS/桌面只接受 full,受限访客是 Web/PWA
// 的权宜)。批准方若按「仅此次」授予 guest,这里据 /api/me 的真实 scope 拒绝、
// 提示改选「信任此设备」,并让二维码可刷新重扫——不落地 guest 会话。
let scope = await fetchScope(token: token)
if gen != loginGeneration { return }
if scope != "full"
{
statusText = t("ios.login.needFull")
qrExpired = true
return
}
session = Session(accessToken: token,
refreshToken: st.refresh_token ?? "",
user: user,
deviceName: st.device_name ?? deviceName(),
deviceId: st.device_id ?? "",
scope: "full")
persist()
}
return
case "denied", "expired":
statusText = t("ios.login.expired")
qrExpired = true
return
default:
continue // pending → 立即下一轮(服务端长轮询最多挂 25s)
}
}
}
// 据刚发的 access token 拉 /api/me 取真实会话级别(full / guest)。失败回空串(按非 full
// 处理,保守拒绝)。
private func fetchScope(token: String) async -> String
{
var req = URLRequest(url: URL(string: "\(apiBase)/api/me")!)
req.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
guard let (data, _) = try? await URLSession.shared.data(for: req),
let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
let scope = obj["scope"] as? String
else { return "" }
return scope
}
private func deviceName() -> String
{
return DeviceNameStore.value
}
}
+53
View File
@@ -0,0 +1,53 @@
import Foundation
import Security
// 极简 Keychain 封装:单 app 私有 generic-password 条目,无 access group。Keychain *共享*
// (跨 app / extension)才需付费 ADP 的 entitlement;本封装不跨 app,故零 entitlement,
// 模拟器与真机均可用(见 ios/PLAN.md §9 禁区分级——共享才是账号项,私有不是)。
//
// 用途:持久化扫码登录拿到的会话(access_token + user),使 app 重装 / 重建后免反复扫码。
// kSecAttrAccessibleAfterFirstUnlock:设备首次解锁后即可读(含后台),符合“常驻登录”诉求,
// 又不在锁屏前暴露。
enum Keychain
{
static func save(_ data: Data, service: String, account: String)
{
let base: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: account,
]
SecItemDelete(base as CFDictionary) // 覆盖写:先删旧条目再加,避免 duplicate item
var attrs = base
attrs[kSecValueData as String] = data
attrs[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlock
SecItemAdd(attrs as CFDictionary, nil)
}
static func load(service: String, account: String) -> Data?
{
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: account,
kSecReturnData as String: true,
kSecMatchLimit as String: kSecMatchLimitOne,
]
var out: AnyObject?
guard SecItemCopyMatching(query as CFDictionary, &out) == errSecSuccess,
let data = out as? Data
else { return nil }
return data
}
static func delete(service: String, account: String)
{
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: account,
]
SecItemDelete(query as CFDictionary)
}
}
+86
View File
@@ -0,0 +1,86 @@
import CoreImage.CIFilterBuiltins
import SwiftUI
import UIKit
// 扫码登录界面:显示二维码 + 引导用已登录的 cdrop 扫码批准。轮询在 AuthManager 里。
struct LoginView: View
{
@Environment(AuthManager.self) private var auth
var body: some View
{
VStack(spacing: 24)
{
Spacer()
Text(t("app.brand"))
.font(.largeTitle)
.bold()
qrArea
Text(auth.statusText)
.font(.callout)
.foregroundStyle(auth.qrExpired ? Color.red : Color.secondary)
refreshButton
Text(t("ios.login.guide"))
.font(.footnote)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
.padding(.horizontal, 40)
Spacer()
}
.padding()
.tint(.cdropAccent)
.task { await auth.startQRLogin() }
}
// 刷新二维码:失效后高亮(glassProminent),未失效时也常驻可手动刷新。两种玻璃样式类型
// 不同,故经 @ViewBuilder 条件分支。
@ViewBuilder
private var refreshButton: some View
{
let button = Button { Task { await auth.startQRLogin() } }
label: { Label(t("ios.login.refresh"), systemImage: "arrow.clockwise") }
if auth.qrExpired { button.buttonStyle(.glassProminent) }
else { button.buttonStyle(.glass) }
}
@ViewBuilder
private var qrArea: some View
{
if let payload = auth.qrPayload, let img = qrImage(payload)
{
Image(uiImage: img)
.interpolation(.none)
.resizable()
.frame(width: 220, height: 220)
.padding(16)
.background(.white, in: RoundedRectangle(cornerRadius: 16))
.opacity(auth.qrExpired ? 0.25 : 1) // 失效后置灰,视觉提示需刷新
.overlay
{
if auth.qrExpired
{
Image(systemName: "arrow.clockwise.circle.fill")
.font(.system(size: 56))
.foregroundStyle(.secondary)
}
}
}
else
{
ProgressView()
.frame(width: 220, height: 220)
}
}
// CoreImage 内建二维码生成(无第三方依赖)。payload 是后端给的完整 /link?r=..&c=.. URL。
private func qrImage(_ string: String) -> UIImage?
{
let filter = CIFilter.qrCodeGenerator()
filter.message = Data(string.utf8)
guard let output = filter.outputImage else { return nil }
let scaled = output.transformed(by: CGAffineTransform(scaleX: 10, y: 10))
let ctx = CIContext()
guard let cg = ctx.createCGImage(scaled, from: scaled.extent) else { return nil }
return UIImage(cgImage: cg)
}
}
+51
View File
@@ -0,0 +1,51 @@
import SwiftUI
// cdrop iOS 客户端入口(arch A,见 ios/PLAN.md)。原生 SwiftUI 界面(液态玻璃)+ 离屏
// 无头 WKWebView 跑复用的传输引擎,二者经 EngineController 桥接;登录走扫码(AuthManager)。
@main
struct CDropApp: App
{
@State private var auth = AuthManager()
@State private var engine = EngineController()
var body: some Scene
{
WindowGroup
{
AppRoot(auth: auth, engine: engine)
}
}
}
// 登录门:有会话进主界面(引擎以该会话启动),无会话进扫码登录。
struct AppRoot: View
{
let auth: AuthManager
let engine: EngineController
var body: some View
{
Group
{
if auth.session != nil
{
RootView()
.environment(engine)
.environment(auth)
}
else
{
LoginView()
.environment(auth)
}
}
.onAppear
{
engine.auth = auth
if ProcessInfo.processInfo.environment["CDROP_DEBUG_SESSION"] == "1"
{
auth.debugSession()
}
}
}
}
+25
View File
@@ -0,0 +1,25 @@
import Foundation
import UIKit
// 用户可编辑的设备名,持久在 UserDefaults。用于扫码登录 qr/start 的 device_name——设备名
// 在登录时即烤进自签会话令牌,后端按 (userID, deviceName) 记 presence,无在线改名接口
// (见 internal/httpapi/qr.go),故改名在「下次登录」生效。默认取系统设备名。
enum DeviceNameStore
{
private static let key = "cdrop.deviceName"
static var value: String
{
get
{
if let v = UserDefaults.standard.string(forKey: key), !v.isEmpty { return v }
let sys = UIDevice.current.name
return sys.isEmpty ? "iPhone" : sys
}
set
{
let trimmed = newValue.trimmingCharacters(in: .whitespacesAndNewlines)
UserDefaults.standard.set(trimmed.isEmpty ? nil : trimmed, forKey: key)
}
}
}
@@ -0,0 +1,121 @@
import Foundation
// 接收落盘管理器(对应桌面 desktop/platform/download.go):把接收到的字节写入 app 沙盒
// Documents。小文件整文件写(saveWhole);大文件流式 begin / append / finalize;abort
// 丢弃临时文件。线程:由 EngineController 在主线程调用,文件 IO 同步执行(首版求简单可
// 验,后续可挪后台队列)。
final class DownloadManager
{
private struct Active
{
let handle: FileHandle
let tmpURL: URL
}
private var active: [String: Active] = [:]
private func downloadDir() -> URL
{
return FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0]
}
// 列出已落盘的接收文件(Documents 顶层,排除流式临时 .part / 隐藏文件),最近的在前。
// 供应用内「收到的文件」视图浏览 / 打开 / 转发,也是 Files app 可见的同一批文件。
func receivedFiles() -> [URL]
{
let urls = (try? FileManager.default.contentsOfDirectory(
at: downloadDir(),
includingPropertiesForKeys: [ .contentModificationDateKey ],
options: [ .skipsHiddenFiles ])) ?? []
return urls
.filter { $0.pathExtension != "part" && !$0.lastPathComponent.hasPrefix(".cdrop-") }
.sorted { a, b in modDate(a) > modDate(b) }
}
func deleteReceivedFile(_ url: URL)
{
try? FileManager.default.removeItem(at: url)
}
private func modDate(_ url: URL) -> Date
{
return (try? url.resourceValues(forKeys: [ .contentModificationDateKey ]))?
.contentModificationDate ?? .distantPast
}
// 整文件写入(接收端小文件路径,对应桥 saveDownload)。返回最终绝对路径。
func saveWhole(name: String, data: Data) throws -> String
{
let url = uniqueURL(for: sanitize(name))
try data.write(to: url)
return url.path
}
func begin(sessionId: String) throws
{
cleanup(sessionId) // re-begin 安全:先清旧条目再开新文件
let tmp = downloadDir().appendingPathComponent(".cdrop-\(sanitize(sessionId)).part")
FileManager.default.createFile(atPath: tmp.path, contents: nil)
let handle = try FileHandle(forWritingTo: tmp)
active[sessionId] = Active(handle: handle, tmpURL: tmp)
}
func append(sessionId: String, data: Data) throws
{
guard let a = active[sessionId] else { throw Err.unknownSession }
try a.handle.write(contentsOf: data)
}
func finalize(sessionId: String, name: String) throws -> String
{
guard let a = active[sessionId] else { throw Err.unknownSession }
try a.handle.close()
active[sessionId] = nil
let dest = uniqueURL(for: sanitize(name))
try FileManager.default.moveItem(at: a.tmpURL, to: dest)
return dest.path
}
func abort(sessionId: String)
{
cleanup(sessionId)
}
private func cleanup(_ sessionId: String)
{
guard let a = active[sessionId] else { return }
try? a.handle.close()
try? FileManager.default.removeItem(at: a.tmpURL)
active[sessionId] = nil
}
// 唯一化:若已存在则插入 (1) (2)…(对应桌面 uniquePath)。
private func uniqueURL(for name: String) -> URL
{
let dir = downloadDir()
let base = (name as NSString).deletingPathExtension
let ext = (name as NSString).pathExtension
var candidate = dir.appendingPathComponent(name)
var i = 1
while FileManager.default.fileExists(atPath: candidate.path)
{
let next = ext.isEmpty ? "\(base) (\(i))" : "\(base) (\(i)).\(ext)"
candidate = dir.appendingPathComponent(next)
i += 1
}
return candidate
}
// 文件名 sanitize:只取末段、剥路径分隔符,防目录穿越(对应桌面 sanitizeFileName)。
private func sanitize(_ name: String) -> String
{
let last = (name as NSString).lastPathComponent
let cleaned = last.replacingOccurrences(of: "/", with: "_")
return cleaned.isEmpty ? "download" : cleaned
}
enum Err: Error
{
case unknownSession
}
}
@@ -0,0 +1,537 @@
import Foundation
import Observation
import UIKit
import WebKit
// 引擎过桥推来的设备 presence(对应 web store 的 DeviceInfo)。lastSeen 是 ms epoch。
// 不用 Decodable:过桥的 JS 值经 WKWebView 桥后类型不稳(bool 可能成 NSNumber、
// 整数 / 浮点混用),严格 JSONDecoder 一处不匹配就整组失败、列表全空。改手动容忍解析
// (对齐 web hub.ts handlePresence 的防御式取值)。
struct DeviceItem: Identifiable, Equatable
{
let name: String
let type: String
let online: Bool
let lastSeen: Double
var id: String { name }
}
// 引擎过桥推来的传输记录(对应 web engine/main.ts 的 toWire 精简视图)。ice* 为选中候选
// 对诊断(揭示 P2P 是否实为 TURN 中继,解释慢速)。同样手动容忍解析。
struct TransferItem: Identifiable, Equatable
{
let sessionId: String
let direction: String
let fileName: String
let fileSize: Int
let state: String
let mode: String?
let peerName: String
let phase: String?
let bytesTransferred: Int?
let bytesPerSec: Double?
let iceConn: String?
let iceLocal: String?
let iceRemote: String?
var id: String { sessionId }
}
// 原生 ↔ 无头 JS 引擎的桥(对侧契约见 web/src/net/ios.ts、ios/PLAN.md §2)。
// 注册名为 "cdropEngine" 的 WKScriptMessageHandler 收 { id, method, payload }(RPC)或
// { notify, payload }(单向通知);RPC 处理完经 evaluateJavaScript 调
// window.__cdropEngineResolve(id, ok, value) 回交;推命令经 window.__cdropEngineEvent。
@MainActor
@Observable
final class EngineController: NSObject
{
// SwiftUI 观察的引擎态。
var status: String = t("ios.engine.disconnected")
var deviceName: String = ""
// 引擎经桥推来的真实态,替换原生 UI 原先的 demo 数据。
var devices: [DeviceItem] = [] // 在线 / 离线设备 presence(每次 presence 事件整组替换)
var transfers: [TransferItem] = [] // 活跃传输(每次 transfers 事件整组替换)
var history: [TransferItem] = [] // 已完成传输(transferDone 逐条前插,限长 30)
// 诊断信号(设置页展示):SSE 是否连上 / 是否在重连 + 累计收到的 presence 事件数 + 引擎
// 最近一条 warn/error 日志。用来定位「设备空」卡在哪一环(未连接?401?静默挂起?)。
var hubConnected = false
var hubReconnecting = false
var presenceCount = 0
var lastEngineLog = ""
// 剪贴板 / 设备管理操作的瞬时反馈(设置页 / 设备页展示)。
var clipboardStatus = ""
var deviceActionStatus = ""
// 引擎页地址:须安全源(WebRTC 在非安全 origin 可能被禁,见 PLAN R-iOS-3)。默认线上
// https;可经环境变量 CDROP_ENGINE_URL 覆盖(本机测试指向 http://localhost——localhost
// 是潜在可信源、算安全上下文,故 WebRTC 仍可用,同 Windows 桌面 loopback 思路)。
private var engineURL: URL
{
if let s = ProcessInfo.processInfo.environment["CDROP_ENGINE_URL"], let u = URL(string: s)
{
return u
}
return URL(string: "https://drop.commilitia.net/engine.html")!
}
// 会话来源(登录后注入引擎 boot)。由 app 在启动时接好(AppRoot.onAppear)。
var auth: AuthManager?
private var webView: WKWebView?
private let downloads = DownloadManager()
// 待发文件暂存:id → 安全作用域 URL(文件选择器选中的文件)。引擎经 cdrop-file://<id>
// 回取字节(下方 WKURLSchemeHandler 供给)。
private var outgoing: [String: URL] = [:]
override init()
{
super.init()
}
// makeWebView:构建离屏 WebView——注入 __CDROP_BOOT__(device_type:"ios")、注册消息
// 处理器、注册 cdrop-file 自定义 scheme(发送侧给 JS 喂文件字节)。幂等。
func makeWebView() -> WKWebView
{
if let existing = webView { return existing }
let ucc = WKUserContentController()
ucc.add(self, name: "cdropEngine")
// boot 注入:把登录后的真实会话注入引擎(无会话则 null)。device_type 标 ios 供后端
// 登记;api_base 默认空(引擎与 /api 同源,从 prod 加载时相对 URL 即可)。
let debug = ProcessInfo.processInfo.environment["CDROP_DEBUG_SESSION"] == "1"
let boot = "window.__CDROP_BOOT__ = { session: \(sessionJSON()), "
+ "device_name: \(jsString(currentDeviceName())), "
+ "api_base: \(jsString(apiBaseForBoot())), device_type: \"ios\", debug: \(debug) };"
ucc.addUserScript(WKUserScript(source: boot,
injectionTime: .atDocumentStart,
forMainFrameOnly: true))
let cfg = WKWebViewConfiguration()
cfg.userContentController = ucc
cfg.setURLSchemeHandler(self, forURLScheme: "cdrop-file")
// 迁移到 Auth Broker 后引擎自刷走注入的 refresh_token(POST /api/auth/refresh 代理
// broker),不再依赖 cdrop_session cookie,故无需把 URLSession 的 cookie 同步进 WebView。
let wv = WKWebView(frame: .zero, configuration: cfg)
// 始终重新拉取 engine.html(忽略本地缓存),否则引擎更新后 WebView 可能仍跑旧包,
// 新命令(剪贴板 / 设备管理等)在旧引擎里没有对应 handler → 静默失效。engine.html 很
// 小、且它引用的哈希化 bundle 仍按内容寻址正常缓存,开销可忽略。
var request = URLRequest(url: engineURL)
request.cachePolicy = .reloadIgnoringLocalCacheData
wv.load(request)
webView = wv
deviceName = currentDeviceName()
return wv
}
// 登出复位:通知引擎断开 SSE、丢弃 WebView 并清空 UI 态。下次登录时 makeWebView 以新
// 会话重建(boot 注入读 auth.session 的最新值)。
func reset()
{
sendCommand("shutdown", payload: [:])
webView = nil
devices = []
transfers = []
history = []
status = t("ios.engine.disconnected")
deviceName = ""
}
// 收到的文件(Documents 沙盒,与 Files app 同一批)。供「收到的文件」视图浏览 / 转发。
func receivedFiles() -> [URL] { downloads.receivedFiles() }
func deleteReceivedFile(_ url: URL) { downloads.deleteReceivedFile(url) }
// 按文件名在已落盘文件里找对应项(传输详情「打开文件」直达用)。落盘时同名会被
// 唯一化为「name (1)」,故先精确匹配、再退回基名前缀匹配。
func receivedFile(matching name: String) -> URL?
{
let files = downloads.receivedFiles()
if let exact = files.first(where: { $0.lastPathComponent == name }) { return exact }
let base = (name as NSString).deletingPathExtension
return files.first { $0.lastPathComponent.hasPrefix(base) }
}
// 可发送目标:在线、且非本机(不发给自己)。发送 / 转发两处入口共用此过滤。
func sendableDevices() -> [DeviceItem]
{
return devices.filter { $0.online && $0.name != deviceName }
}
// 剪贴板上行:读本机 UIPasteboard 文本交引擎上传云剪贴板(用户手势触发,前台读取合规)。
func uploadClipboard()
{
let content = UIPasteboard.general.string ?? ""
guard !content.isEmpty else { clipboardStatus = t("ios.clipboard.empty"); return }
clipboardStatus = t("ios.clipboard.uploading")
sendCommand("clipboardUpload", payload: [ "content": content ])
}
// 剪贴板下行:让引擎拉一次云剪贴板,回来写入 UIPasteboard(见 handleNotify "clipboard")。
func pullClipboard()
{
clipboardStatus = t("ios.clipboard.pulling")
sendCommand("clipboardPull", payload: [:])
}
// 设备管理:移除 / 吊销一台设备。
func revokeDevice(_ name: String)
{
deviceActionStatus = ""
sendCommand("revokeDevice", payload: [ "name": name ])
}
// 推命令给引擎(原生 → JS,window.__cdropEngineEvent)。
func sendCommand(_ name: String, payload: [String: Any])
{
guard let json = jsonString(payload) else { return }
webView?.evaluateJavaScript("window.__cdropEngineEvent(\(jsString(name)), \(json));")
}
// sendFile:暂存选中文件、把 cdrop-file://<id> 引用 + 目标设备交给引擎发起传输。
func sendFile(to target: String, fileURL: URL)
{
let ref = stageOutgoingFile(fileURL)
var size = 0
if let attrs = try? FileManager.default.attributesOfItem(atPath: fileURL.path),
let n = attrs[.size] as? Int { size = n }
sendCommand("sendFile", payload: [
"target": target,
"url": ref,
"name": fileURL.lastPathComponent,
"size": size,
])
}
// 暂存待发文件,返回引擎用的 cdrop-file://<id> 引用。文件选择器给的 URL 是安全作用域
// 资源,需 start...Access 才能读。
private func stageOutgoingFile(_ url: URL) -> String
{
let id = UUID().uuidString
_ = url.startAccessingSecurityScopedResource()
outgoing[id] = url
return "cdrop-file://\(id)"
}
}
// MARK: - JS → 原生(RPC + 通知)
extension EngineController: WKScriptMessageHandler
{
func userContentController(_ uc: WKUserContentController, didReceive message: WKScriptMessage)
{
guard let body = message.body as? [String: Any] else { return }
if let id = body["id"] as? Int
{
handleRPC(id: id,
method: body["method"] as? String ?? "",
payload: body["payload"] as? [String: Any] ?? [:])
return
}
if let notify = body["notify"] as? String
{
handleNotify(notify, payload: body["payload"])
}
}
private func handleRPC(id: Int, method: String, payload: [String: Any])
{
do
{
switch method
{
case "saveDownload":
let name = payload["name"] as? String ?? "download"
let path = try downloads.saveWhole(name: name, data: decodeBase64(payload["data"]))
resolve(id: id, ok: true, value: path)
case "beginDownload":
try downloads.begin(sessionId: payload["sessionId"] as? String ?? "")
resolve(id: id, ok: true, value: nil)
case "appendDownload":
try downloads.append(sessionId: payload["sessionId"] as? String ?? "",
data: decodeBase64(payload["data"]))
resolve(id: id, ok: true, value: nil)
case "finalizeDownload":
let path = try downloads.finalize(sessionId: payload["sessionId"] as? String ?? "",
name: payload["name"] as? String ?? "download")
resolve(id: id, ok: true, value: path)
case "abortDownload":
downloads.abort(sessionId: payload["sessionId"] as? String ?? "")
resolve(id: id, ok: true, value: nil)
default:
resolve(id: id, ok: false, value: "unknown method: \(method)")
}
}
catch
{
resolve(id: id, ok: false, value: "\(error)")
}
}
private func handleNotify(_ name: String, payload: Any?)
{
switch name
{
case "ready":
status = t("ios.engine.ready")
case "probe":
// 本机 WebRTC-in-WKWebView 探针结果(验 arch A 命门 R-iOS-3)。
if let p = payload as? [String: Any]
{
let secure = (p["secure"] as? Bool) ?? false
let rtc = (p["rtc"] as? String) ?? "?"
let host = (p["host"] as? Int) ?? 0
let srflx = (p["srflx"] as? Int) ?? 0
status = "安全:\(secure) RTC:\(rtc) host:\(host) srflx:\(srflx)"
}
case "presence":
// 注意:桥来的 JS 数组是 NSArray,`as? [[String: Any]]` 这种嵌套泛型条件转换
// 在 Foundation 桥接下常整体失败(即便数据合法)→ 列表全空。故先转 [Any] 再逐
// 元素 as? [String: Any],这是先前「健壮解析」仍漏掉的真正失败点。
if let p = payload as? [String: Any], let raw = p["devices"] as? [Any]
{
devices = raw.compactMap { ($0 as? [String: Any]).flatMap(Self.parseDevice) }
}
case "transfers":
if let p = payload as? [String: Any], let raw = p["active"] as? [Any]
{
transfers = raw.compactMap { ($0 as? [String: Any]).flatMap(Self.parseTransfer) }
}
case "transferDone":
// 完成项移出活跃、前插历史;按 sessionId 去重避免重复事件叠加。
if let p = payload as? [String: Any], let item = Self.parseTransfer(p)
{
transfers.removeAll { $0.sessionId == item.sessionId }
history.removeAll { $0.sessionId == item.sessionId }
history.insert(item, at: 0)
if history.count > 30 { history.removeLast(history.count - 30) }
}
case "sendStarted":
// 随后的 transfers 事件会带出该活跃项,这里无需额外处理。
break
case "hubState":
if let p = payload as? [String: Any]
{
hubConnected = Self.boolOf(p["connected"])
hubReconnecting = Self.boolOf(p["reconnecting"])
presenceCount = Self.intOf(p["presenceCount"])
}
case "log":
if let p = payload as? [String: Any], let msg = p["msg"] as? String
{
lastEngineLog = msg
}
case "clipboard":
// 引擎拉回的云剪贴板内容 → 写入本机 UIPasteboard。
if let p = payload as? [String: Any], let content = p["content"] as? String, !content.isEmpty
{
UIPasteboard.general.string = content
clipboardStatus = t("ios.clipboard.pulled")
}
else
{
clipboardStatus = t("ios.clipboard.empty")
}
case "clipboardUploaded":
clipboardStatus = t("ios.clipboard.uploaded")
case "deviceRevoked":
deviceActionStatus = t("ios.devices.revoked")
case "error":
// 复用现有 error 通知:剪贴板 / 吊销失败也走这里,分流到对应状态行。
if let p = payload as? [String: Any]
{
let stage = p["stage"] as? String ?? ""
let msg = p["message"] as? String ?? ""
if stage == "clipboard" { clipboardStatus = t("ios.clipboard.failed") }
else if stage == "revoke"
{
deviceActionStatus = msg == "step_up_required"
? t("ios.devices.revokeStepUp") : t("ios.devices.revokeFailed")
}
else { status = "错误:\(msg)" }
}
case "sessionRotated":
// 引擎自刷致 broker 轮换 refresh → 更新 Keychain,重启免用失效旧 refresh。
if let p = payload as? [String: Any],
let access = p["access_token"] as? String,
let refresh = p["refresh_token"] as? String
{
auth?.updateSession(accessToken: access, refreshToken: refresh)
}
case "authExpired":
// 引擎确证会话已失效(refresh 过期 / 吊销)→ 清 Keychain 回登录页。
auth?.logout()
reset()
default:
break
}
}
private func resolve(id: Int, ok: Bool, value: Any?)
{
webView?.evaluateJavaScript("window.__cdropEngineResolve(\(id), \(ok), \(jsonValue(value)));")
}
}
// MARK: - 发送侧自定义 scheme(cdrop-file)
extension EngineController: WKURLSchemeHandler
{
// 发送侧:原生把暂存的待发文件经 cdrop-file://<id> 喂给引擎(JS fetch 它得到字节,
// 见 PLAN §2 / R-iOS-4)。v1 整文件读入内存返回(小 / 中文件可行);大文件改分块
// didReceive 流式以避 jetsam = R-iOS-4 优化点。
func webView(_ webView: WKWebView, start urlSchemeTask: any WKURLSchemeTask)
{
guard let url = urlSchemeTask.request.url, let id = url.host, let fileURL = outgoing[id]
else
{
respond(urlSchemeTask, status: 404)
return
}
guard let data = try? Data(contentsOf: fileURL)
else
{
respond(urlSchemeTask, status: 404)
return
}
let resp = HTTPURLResponse(url: url, statusCode: 200, httpVersion: "HTTP/1.1",
headerFields: [
"Content-Type": "application/octet-stream",
"Content-Length": "\(data.count)",
])!
urlSchemeTask.didReceive(resp)
urlSchemeTask.didReceive(data)
urlSchemeTask.didFinish()
}
func webView(_ webView: WKWebView, stop urlSchemeTask: any WKURLSchemeTask)
{
// 无长任务(v1 同步返回),无需处理。
}
private func respond(_ task: any WKURLSchemeTask, status: Int)
{
guard let url = task.request.url,
let resp = HTTPURLResponse(url: url, statusCode: status, httpVersion: nil, headerFields: nil)
else { return }
task.didReceive(resp)
task.didFinish()
}
}
// MARK: - 通知解析(手动容忍,见 DeviceItem 注释)
extension EngineController
{
// 桥来的数值经 WKWebView 后可能是 NSNumber / Int / Double / Bool,任一字段类型不符都
// 不应让整组失败。下列取值器逐字段宽松转换,缺失给默认。
static func parseDevice(_ d: [String: Any]) -> DeviceItem?
{
guard let name = d["name"] as? String, let type = d["type"] as? String else { return nil }
return DeviceItem(name: name,
type: type,
online: boolOf(d["online"]),
lastSeen: doubleOf(d["lastSeen"]))
}
static func parseTransfer(_ t: [String: Any]) -> TransferItem?
{
guard let sessionId = t["sessionId"] as? String else { return nil }
let ice = t["ice"] as? [String: Any]
return TransferItem(sessionId: sessionId,
direction: t["direction"] as? String ?? "outgoing",
fileName: t["fileName"] as? String ?? "",
fileSize: intOf(t["fileSize"]),
state: t["state"] as? String ?? "PENDING",
mode: t["mode"] as? String,
peerName: t["peerName"] as? String ?? "",
phase: t["phase"] as? String,
bytesTransferred: t["bytesTransferred"].map { intOf($0) },
bytesPerSec: t["bytesPerSec"].map { doubleOf($0) },
iceConn: ice?["conn"] as? String,
iceLocal: ice?["local"] as? String,
iceRemote: ice?["remote"] as? String)
}
static func boolOf(_ v: Any?) -> Bool
{
if let b = v as? Bool { return b }
if let n = v as? NSNumber { return n.boolValue }
return false
}
static func intOf(_ v: Any?) -> Int
{
if let i = v as? Int { return i }
if let n = v as? NSNumber { return n.intValue }
if let d = v as? Double { return Int(d) }
return 0
}
static func doubleOf(_ v: Any?) -> Double
{
if let d = v as? Double { return d }
if let n = v as? NSNumber { return n.doubleValue }
if let i = v as? Int { return Double(i) }
return 0
}
}
// MARK: - 编码助手
private extension EngineController
{
func decodeBase64(_ v: Any?) -> Data
{
guard let s = v as? String, let d = Data(base64Encoded: s) else { return Data() }
return d
}
func jsonString(_ obj: [String: Any]) -> String?
{
guard JSONSerialization.isValidJSONObject(obj),
let d = try? JSONSerialization.data(withJSONObject: obj),
let s = String(data: d, encoding: .utf8)
else { return nil }
return s
}
// 把任意值编码成可嵌入 JS 的字面量(字符串走 JSON 转义;nil → null)。借数组包裹再
// 剥括号,绕过 JSONSerialization 不接受顶层标量的限制。
func jsonValue(_ v: Any?) -> String
{
guard let v = v else { return "null" }
guard let d = try? JSONSerialization.data(withJSONObject: [v]),
let s = String(data: d, encoding: .utf8)
else { return "null" }
return String(s.dropFirst().dropLast())
}
func jsString(_ s: String) -> String
{
return jsonValue(s)
}
// 引擎 boot 注入用:会话 JSON(无会话则 null)、设备名、api_base。
func sessionJSON() -> String
{
guard let s = auth?.session else { return "null" }
return "{ access_token: \(jsString(s.accessToken)), "
+ "refresh_token: \(jsString(s.refreshToken)), "
+ "user: { id: \(jsString(s.user.id)), name: \(jsString(s.user.name)) } }"
}
func currentDeviceName() -> String
{
return auth?.session?.deviceName ?? "iPhone"
}
func apiBaseForBoot() -> String
{
return ProcessInfo.processInfo.environment["CDROP_API_BASE"] ?? ""
}
}
@@ -0,0 +1,19 @@
import SwiftUI
import WebKit
// 把 EngineController 持有的离屏 WKWebView 桥进 SwiftUI 视图树(保持存活)。无可见 UI
// 用途——引擎由 controller 经 JS 桥驱动(见 ios/PLAN.md arch A)。
struct EngineWebView: UIViewRepresentable
{
let controller: EngineController
func makeUIView(context: Context) -> WKWebView
{
return controller.makeWebView()
}
func updateUIView(_ uiView: WKWebView, context: Context)
{
// 无需更新。
}
}
+66
View File
@@ -0,0 +1,66 @@
import Foundation
// i18n:读 app bundle 内的 <locale>.json(由 web 单一真源经 web/scripts/emit-i18n.mjs 产出,
// 强约束②,见 ios/PLAN.md §5)。跟随系统语言、回退 zh-CN。各 JSON 已含完整回退(缺失键
// 在产出时已并入 zh-CN 值),故 loader 只需选对 locale。
enum I18n
{
private static let dict: [String: String] = load()
static func t(_ key: String) -> String
{
return dict[key] ?? key
}
// 插值变体,与 web t(key, vars) 同形:占位符 {{name}}。catalog 里带占位的键(如
// settings.lastSeen 的 {{time}})须经此填充,否则字面量 {{...}} 会漏到 UI。
static func t(_ key: String, _ args: [String: String]) -> String
{
var s = dict[key] ?? key
for (name, value) in args
{
s = s.replacingOccurrences(of: "{{\(name)}}", with: value)
}
return s
}
private static func load() -> [String: String]
{
guard let url = resourceURL(preferredLocale()) ?? resourceURL("zh-CN"),
let data = try? Data(contentsOf: url),
let obj = try? JSONSerialization.jsonObject(with: data) as? [String: String]
else { return [:] }
return obj
}
private static func resourceURL(_ loc: String) -> URL?
{
return Bundle.main.url(forResource: loc, withExtension: "json", subdirectory: "i18n")
?? Bundle.main.url(forResource: loc, withExtension: "json")
}
private static func preferredLocale() -> String
{
for lang in Locale.preferredLanguages
{
if lang.hasPrefix("zh-Hant") || lang.hasPrefix("zh-TW") || lang.hasPrefix("zh-HK")
{
return "zh-TW"
}
if lang.hasPrefix("zh") { return "zh-CN" }
if lang.hasPrefix("en") { return "en-US" }
}
return "zh-CN"
}
}
// 全局简写,与 web 的 t() 对齐。
func t(_ key: String) -> String
{
return I18n.t(key)
}
func t(_ key: String, _ args: [String: String]) -> String
{
return I18n.t(key, args)
}
+49
View File
@@ -0,0 +1,49 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>zh-Hans</string>
<key>CFBundleDisplayName</key>
<string>Commilitia Drop</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleLocalizations</key>
<array>
<string>zh-Hans</string>
<string>zh-Hant</string>
<string>en</string>
</array>
<key>CFBundleName</key>
<string>$(PRODUCT_NAME)</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>1.0</string>
<key>CFBundleVersion</key>
<string>1</string>
<key>LSSupportsOpeningDocumentsInPlace</key>
<true/>
<key>NSAppTransportSecurity</key>
<dict>
<key>NSAllowsLocalNetworking</key>
<true/>
</dict>
<key>NSBonjourServices</key>
<array>
<string>_cdrop._tcp</string>
</array>
<key>NSCameraUsageDescription</key>
<string>Commilitia Drop 使用相机扫描二维码登录新设备。</string>
<key>NSLocalNetworkUsageDescription</key>
<string>Commilitia Drop 需要访问本地网络以发现同内网设备并建立直连传输。</string>
<key>UIFileSharingEnabled</key>
<true/>
<key>UILaunchScreen</key>
<dict/>
</dict>
</plist>
+72
View File
@@ -0,0 +1,72 @@
import QuickLook
import SwiftUI
// 预览项包装:URL 本身不是 Identifiable,sheet(item:) 需要它。
struct PreviewItem: Identifiable
{
let id = UUID()
let url: URL
}
// 文件预览 sheet:QuickLookView 外套 NavigationStack + toolbar「完成」按钮,供「收到的
// 文件」与传输详情两处共用。@Environment(\.dismiss) 关闭 sheet 即清空外层 item 绑定。
struct FilePreviewSheet: View
{
@Environment(\.dismiss) private var dismiss
let item: PreviewItem
var body: some View
{
NavigationStack
{
QuickLookView(url: item.url)
.ignoresSafeArea()
.navigationTitle(item.url.lastPathComponent)
.navigationBarTitleDisplayMode(.inline)
.toolbar
{
ToolbarItem(placement: .topBarTrailing)
{
Button(t("ios.files.done")) { dismiss() }
}
}
}
}
}
// QLPreviewController 的纯 SwiftUI 包装(无内建关闭入口)。关闭交给外层 SwiftUI
// NavigationStack 的 toolbar「完成」按钮——QL 自身的轻点切换 chrome 只影响它自己的工具
// 栏,不动外层导航栏,故 Done 始终可达(上轮用 UINavigationController 内建 Done 不可靠:
// QL 全屏时把它一起藏了)。
struct QuickLookView: UIViewControllerRepresentable
{
let url: URL
func makeUIViewController(context: Context) -> QLPreviewController
{
let controller = QLPreviewController()
controller.dataSource = context.coordinator
return controller
}
func updateUIViewController(_ controller: QLPreviewController, context: Context)
{
controller.reloadData()
}
func makeCoordinator() -> Coordinator { Coordinator(url: url) }
final class Coordinator: NSObject, QLPreviewControllerDataSource
{
let url: URL
init(url: URL) { self.url = url }
func numberOfPreviewItems(in controller: QLPreviewController) -> Int { 1 }
func previewController(_ controller: QLPreviewController,
previewItemAt index: Int) -> QLPreviewItem
{
return url as NSURL
}
}
}
+358
View File
@@ -0,0 +1,358 @@
{
"app.brand": "Commilitia Drop",
"deviceType.browser": "Browser",
"deviceType.macos": "macOS client",
"deviceType.windows": "Windows client",
"deviceType.linux": "Linux client",
"deviceType.ios": "iOS client",
"deviceType.shortcut": "Shortcut",
"app.connected": "Online",
"app.connecting": "Connecting…",
"app.disconnectedTitle": "Reconnecting",
"app.disconnectedHint": "Lost connection to the server — retrying automatically. No need to refresh.",
"auth.sessionLost.title": "Signed out",
"auth.sessionLost.body": "This device's login was revoked or expired. Please sign in again.",
"nav.accountMenu": "Account menu",
"nav.thisDeviceLabel": "This device: ",
"nav.settings": "Settings",
"nav.signOut": "Sign out",
"nav.backToHome": "← Home",
"nav.language": "语言 / Language",
"nav.theme": "主题 / Theme",
"nav.theme.light": "Light",
"nav.theme.dark": "Dark",
"nav.theme.system": "Follow system",
"nav.themeToggle": "Toggle theme",
"home.greeting": "Hello, {{name}}",
"home.deviceList.title": "Devices",
"home.deviceList.onlineSuffix": "({{count}} online)",
"home.deviceList.showOffline": "Show offline ({{count}})",
"home.deviceList.hideOffline": "Hide offline",
"home.deviceList.empty": "No peer devices yet. Open the app in another tab with a different device name (Settings → Device name) to see it here.",
"home.tabs.file": "File",
"home.tabs.message": "Message",
"home.tabs.clipboard": "Clipboard",
"home.file.dropzone": "Drop a file here or click to browse",
"home.file.selectedSize": "{{name}} ({{size}})",
"home.file.rejected": "File rejected",
"home.cta.selectDeviceAndFile": "Select a device and a file",
"home.cta.selectFile": "Select a file",
"home.cta.selectDevice": "Select a device",
"home.cta.sendTo": "Send to {{name}}",
"home.message.placeholder": "Type a message to {{name}}…",
"home.message.placeholderEmpty": "Select a device first",
"home.message.hint": "Enter to send, Shift+Enter for newline. Max 4 KB.",
"home.message.title": "Messages",
"home.message.countSuffix": "({{count}})",
"home.message.clearAll": "Clear all",
"home.transfer.active": "Active",
"home.transfer.history": "Recent transfers",
"home.clipboard.cloudTitle": "Latest from cloud",
"home.clipboard.cloudFrom": "From {{name}} · {{time}}",
"home.clipboard.cloudFromSelf": "From this device · {{time}}",
"home.clipboard.cloudEmpty": "Nothing synced yet.",
"home.clipboard.copyToLocal": "Copy to local clipboard",
"home.clipboard.copyToLocalSuccess": "Copied to local clipboard",
"home.clipboard.uploadTitle": "Sync local clipboard",
"home.clipboard.uploadHint": "Plain text only, max 64 KB. Styles (bold / color) are stripped.",
"home.clipboard.uploadButton": "Upload local clipboard",
"home.clipboard.uploading": "Uploading…",
"home.clipboard.uploadEmpty": "Local clipboard is empty",
"home.clipboard.refresh": "Refresh",
"home.clipboard.clear": "Clear cloud",
"home.clipboard.clearConfirm": "Clear the cloud clipboard? All devices will see empty content.",
"home.clipboard.clearSuccess": "Cloud clipboard cleared",
"home.clipboard.hidden": "Content hidden — click Reveal to show",
"home.clipboard.reveal": "Reveal",
"home.clipboard.hide": "Hide",
"setup.title": "Name this device",
"setup.help": "The name is shown to your other devices. Avoid spaces, ≤32 chars.",
"setup.field": "Device name",
"setup.save": "Save and continue",
"login.title": "Sign in",
"login.dev.help": "Dev mode: pick any user identifier. Two tabs with the same identifier simulate two devices of one user.",
"login.dev.field": "Dev user id",
"login.dev.continue": "Continue",
"login.prod.help": "Sign in via Casdoor.",
"login.prod.button": "Sign in with Casdoor",
"oauth.failed": "Sign-in failed",
"oauth.signingIn": "Signing in…",
"oauth.missingParams": "Missing 'code' or 'state' query parameter",
"qr.entry.fromLogin": "Sign in with a QR code",
"qr.entry.scanNewDevice": "Scan to link a device",
"qr.scan.title": "Scan to link a device",
"qr.scan.subtitle": "Point the code shown on the other device at the frame. Approval stays on this signed-in device.",
"qr.scan.starting": "Starting the camera…",
"qr.scan.aiming": "Line the code up inside the frame",
"qr.scan.foreign": "That isn't a sign-in code for this site. Aim at the code from “Sign in with a QR code” on the new device.",
"qr.scan.noCamera": "This device has no camera available. Use a phone or tablet with a camera to scan instead.",
"qr.scan.denied": "Camera access was blocked. Allow the camera in your browser's site settings, then reopen this page.",
"qr.scan.error": "Couldn't open the camera. Try again, or scan from a device with a camera.",
"qr.scan.howto1": "On the new device, open “Sign in with a QR code” to show a code.",
"qr.scan.howto2": "Line that code up in the frame above — approval starts automatically.",
"qr.scan.back": "Back",
"qr.show.title": "Sign in with a QR code",
"qr.show.subtitle": "Name this device, then scan the code from a signed-in phone to let it into your account.",
"qr.show.nameHint": "Shown to your other devices. ASCII characters only.",
"qr.show.generate": "Generate code",
"qr.show.scanTitle": "Scan to approve",
"qr.show.howto1": "Open the camera or cdrop on another signed-in device.",
"qr.show.howto2": "Scan the code above and approve this device.",
"qr.show.waiting": "Waiting for approval…",
"qr.show.approved": "Approved — signing you in…",
"qr.show.expired": "This code has expired. Generate a new one and scan again.",
"qr.show.denied": "This request was declined. If that was you, generate a new code and try again.",
"qr.show.regenerate": "Generate a new code",
"qr.show.back": "Back to home",
"qr.show.backToName": "Back",
"qr.approve.title": "Approve a new device",
"qr.approve.subtitle": "Confirm this device may sign in to your account. Check the device and origin first.",
"qr.approve.loading": "Reading device details…",
"qr.approve.badLink": "This approval link is incomplete. Generate a new code on the device and scan it again.",
"qr.approve.signInFirst": "Sign in to your account first, then approve this device.",
"qr.approve.trustLabel": "Trust duration",
"qr.approve.trust.title": "Trust this device (7 days)",
"qr.approve.trust.hint": "Skip approval for 7 days, renewed on each use.",
"qr.approve.once.title": "Just this once (1 hour)",
"qr.approve.once.hint": "Signed in for 1 hour, then scan again.",
"qr.approve.scopeFull": "This device signs fully into your account for 7 days: it can send and receive files and messages, and manage your account and devices.",
"qr.approve.scopeGuest": "Signs in as a limited guest — it can send and receive files and messages, but can't change your account or approve other devices; expires after 1 hour.",
"qr.approve.nativeFull": "This is a native app device — it signs in with full access (trusted, valid for 7 days). Native apps don't support limited guest sessions.",
"qr.approve.trustContinue": "Trust & Continue",
"qr.approve.approve": "Approve sign-in",
"qr.approve.stepUp.notice": "For security, verify your identity before approving — the button below takes you through a fresh sign-in.",
"qr.approve.stepUp.button": "Verify and approve",
"qr.approve.stepUp.rejected": "Identity verification didn't pass or has expired. Verify again to confirm it's you, then approve.",
"qr.approve.deny": "Decline",
"qr.approve.doneApprovedTitle": "Approved",
"qr.approve.doneApproved": "This device is now linked to your account. You can send and receive files on it.",
"qr.approve.doneDeniedTitle": "Declined",
"qr.approve.doneDenied": "The request was declined. That device won't sign in to your account.",
"qr.approve.expiredTitle": "Code expired",
"qr.approve.expired": "This code is no longer valid. Generate a new one on the device and scan again.",
"qr.approve.errorTitle": "Something went wrong",
"qr.approve.error": "That didn't go through. Try again, or generate a new code on the device.",
"qr.approve.backHome": "Back to home",
"settings.title": "Settings",
"settings.currentDevice.title": "This device",
"settings.deviceName.field": "Device name",
"settings.deviceName.save": "Save",
"settings.deviceName.unchanged": "Name is unchanged",
"settings.deviceName.empty": "Name cannot be empty",
"settings.deviceName.asciiOnly": "Device name must use ASCII characters only (letters, numbers, symbols).",
"settings.deviceName.success": "Device name updated",
"settings.unregister.current": "Unregister this device",
"settings.unregister.currentHint": "Removes this device's registration and signs you out.",
"settings.unregister.currentConfirm": "Unregistering this device will remove its registration and sign you out. Continue?",
"settings.error.delete": "Action failed: {{message}}",
"settings.guest.badge": "Limited guest",
"settings.guest.title": "This device is a limited guest",
"settings.guest.body": "This device is signed in as a limited guest: it can send and receive files, messages, and clipboard, but cannot manage the account, remove devices, or authorize new ones. To get full access, sign in again on your main device.",
"settings.sessions.title": "Login sessions",
"settings.sessions.hint": "Every device currently signed in to this account — browser, desktop, and mobile clients; online ones are listed first. Signing a device out ends its session immediately; it must sign in again on that device to continue.",
"settings.sessions.guestNote": "Limited guests can't manage login sessions. To manage them, sign in fully on your main device.",
"settings.sessions.empty": "No other login sessions.",
"settings.sessions.loadError": "Failed to load login sessions.",
"settings.sessions.retry": "Retry",
"settings.sessions.current": "This device",
"settings.sessions.scopeFull": "Full",
"settings.sessions.scopeGuest": "Limited guest",
"settings.sessions.kind.oidc": "Account login",
"settings.sessions.kind.self": "Local account",
"settings.sessions.kind.guest": "Scanned guest",
"settings.sessions.lastUsed": "Last active {{time}}",
"settings.sessions.neverUsed": "Not active yet",
"settings.sessions.signOut": "Sign out",
"settings.sessions.signingOut": "Signing out…",
"settings.sessions.signOutCurrent": "Sign out this device",
"settings.sessions.confirmOther": "Sign out device \"{{name}}\"? Its session will be invalidated immediately and it must sign in again.",
"settings.sessions.confirmCurrent": "Signing out this device is the same as logging out. Continue?",
"settings.sessions.revoked": "Signed that device out",
"settings.sessions.stepUpRejected": "Identity verification failed or expired. Please try again.",
"settings.online": "Online",
"settings.offline": "Offline",
"settings.lastSeen": "Last seen {{time}}",
"settings.push.title": "Push notifications",
"settings.push.hint": "When this page is closed, new files, messages, and transfer results arrive as system notifications; while it's open, you'll see in-app alerts instead.",
"settings.push.enable": "Enable push",
"settings.push.enabled": "Enabled",
"settings.push.disable": "Turn off",
"settings.push.deniedHint": "Notifications are blocked by the browser. Allow them in this site's settings, then try again.",
"settings.push.denied": "Notification permission denied",
"settings.push.enableOk": "Push notifications enabled",
"settings.push.disableOk": "Push notifications turned off",
"settings.push.failed": "Couldn't enable push. Please try again.",
"notify.incoming.title": "Incoming file",
"notify.incoming.body": "{{sender}} is sending {{file}}",
"notify.transfer.doneTitle": "Transfer complete",
"notify.transfer.failedTitle": "Transfer failed",
"settings.account.title": "Account",
"settings.account.signOut": "Sign out",
"settings.desktop.title": "Desktop",
"settings.desktop.clipboardSync": "Auto-sync clipboard",
"settings.desktop.clipboardSyncHint": "Copies are uploaded to the cloud, and clipboard updates from your other devices are applied here.",
"settings.desktop.launchAtLogin": "Launch at login",
"settings.desktop.launchAtLoginHint": "Start cdrop in the background after you sign in (lives in the menu bar).",
"settings.desktop.downloadDir": "Download folder",
"settings.desktop.downloadDirHint": "Received files are saved here. Leave unset to use the system Downloads folder.",
"settings.desktop.downloadDirChoose": "Change…",
"settings.desktop.downloadDirReset": "Reset to default",
"settings.desktop.downloadDirPicker": "Choose download folder",
"settings.desktop.ttlNote": "For security, cloud clipboard content is cleared automatically after 5 minutes.",
"settings.shortcut.title": "iOS Shortcut",
"settings.shortcut.intro": "Issue a long-lived, clipboard-only, revocable token for the iOS Shortcuts app. Even if leaked it can only read/write the clipboard — nothing else.",
"settings.shortcut.labelField": "Label",
"settings.shortcut.labelPlaceholder": "e.g. My iPhone",
"settings.shortcut.issue": "Issue",
"settings.shortcut.empty": "No tokens issued yet.",
"settings.shortcut.loadError": "Failed to load tokens.",
"settings.shortcut.retry": "Retry",
"settings.shortcut.revoke": "Revoke",
"settings.shortcut.revoking": "Revoking…",
"settings.shortcut.revokeConfirm": "Revoking immediately disables any Shortcut using this token. Continue?",
"settings.shortcut.revoked": "Revoked",
"settings.shortcut.revokedToast": "Token revoked",
"settings.shortcut.expired": "Expired",
"settings.shortcut.expiresAt": "Expires {{date}}",
"settings.shortcut.lastUsed": "Last used {{time}}",
"settings.shortcut.neverUsed": "Never used",
"settings.shortcut.unavailable": "Shortcut tokens are not enabled on the server.",
"settings.shortcut.tooMany": "You have reached the active-token limit. Revoke an old one first.",
"settings.shortcut.issueError": "Failed: {{message}}",
"settings.shortcut.issuedTitle": "Token issued",
"settings.shortcut.onceWarning": "This token is shown only once. Copy it now — you won't be able to see it again after closing.",
"settings.shortcut.tokenLabel": "Token (Bearer)",
"settings.shortcut.copy": "Copy",
"settings.shortcut.copyFailed": "Copy failed — select and copy manually.",
"settings.shortcut.copied": "Copied",
"settings.shortcut.baseLabel": "Server address",
"settings.shortcut.guideTitle": "Set up on iOS (manual sync)",
"settings.shortcut.guideIntro": "Build two shortcuts in the iPhone Shortcuts app, filling in the server address and token above on first run:",
"settings.shortcut.guideDeviceName": "Add header X-Device-Name: your-device-name (ASCII only) to both shortcuts so it shows as one device in the list.",
"settings.shortcut.guidePullTitle": "Pull (cloud → local clipboard)",
"settings.shortcut.guidePull1": "'Get Contents of URL': set the URL to your-server-address + /api/clipboard, method GET, add header Authorization: Bearer your-token.",
"settings.shortcut.guidePull2": "'Get Dictionary Value': key content.",
"settings.shortcut.guidePull3": "If non-empty, 'Copy to Clipboard'; optionally add 'Show Notification'.",
"settings.shortcut.guidePushTitle": "Push (local clipboard → cloud)",
"settings.shortcut.guidePush1": "'Get Clipboard'; stop if empty.",
"settings.shortcut.guidePush2": "'Get Contents of URL': same URL, method PUT, headers Authorization: Bearer your-token and Content-Type: application/json, JSON body: {\"content\": clipboard, \"content_type\": \"text/plain\"}.",
"settings.shortcut.guidePush3": "Optionally add 'Show Notification'.",
"settings.shortcut.guideToleranceNote": "A single run may fail on a flaky network — just retry. The automatic polling version (next phase) retries silently in the background.",
"settings.shortcut.done": "Done",
"transfer.savedTo": "Saved to {{path}}",
"transfer.saveFailed": "Failed to save file: {{error}}",
"transfer.action.cancel": "Cancel",
"transfer.action.delete": "Delete",
"transfer.action.relayNow": "Relay now",
"transfer.action.details": "Details",
"transfer.state.PENDING": "Pending",
"transfer.state.ACCEPTED": "Accepted",
"transfer.state.P2P_ACTIVE": "Transferring",
"transfer.state.RELAY_ACTIVE": "Relaying",
"transfer.state.DONE": "Done",
"transfer.state.FAILED": "Failed",
"transfer.state.CANCELLED": "Cancelled",
"transfer.mode.p2p": "P2P",
"transfer.mode.relay": "Relay",
"transfer.phase.initializing": "Initializing…",
"transfer.phase.waiting_accept": "Waiting for the other side to accept…",
"transfer.phase.ice_gathering": "Gathering network candidates…",
"transfer.phase.ice_checking": "Establishing direct tunnel…",
"transfer.phase.ice_connected": "Tunnel established",
"transfer.phase.dc_open": "Channel open",
"transfer.phase.transferring": "Transferring",
"transfer.phase.completing": "Finalizing…",
"transfer.phase.fallback_pending": "Falling back to relay…",
"transfer.phase.relay_uploading": "Uploading via relay",
"transfer.phase.relay_downloading": "Downloading via relay",
"transfer.stalled": "Looks stalled ({{seconds}}s without progress)",
"transfer.bytesProgress": "{{sent}} / {{total}}",
"transfer.bytesRate": "{{rate}}/s",
"transfer.debug.toggle": "Debug",
"transfer.debug.iceGathering": "Gathering",
"transfer.debug.iceConnection": "Connection",
"transfer.debug.candidatesLocal": "Local candidates",
"transfer.debug.candidatesRemote": "Remote candidates",
"transfer.debug.selectedPair": "Selected pair",
"transfer.debug.noPair": "No nominated pair yet",
"transfer.debug.candidatesLine": "host {{host}} · mDNS {{mdns}} · srflx {{srflx}} · prflx {{prflx}} · relay {{relay}}",
"transfer.debug.dataChannel": "Data channel",
"transfer.debug.dcLine": "{{state}} · buffered {{buffered}}",
"time.justNow": "just now",
"time.secondsAgo": "{{n}}s ago",
"time.minutesAgo": "{{n}}m ago",
"time.hoursAgo": "{{n}}h ago",
"common.dismiss": "Dismiss",
"common.cancel": "Cancel",
"common.confirm": "Confirm",
"errors.messageEmpty": "Message is empty",
"errors.messageOverflow": "Message exceeds 4 KB; send a file instead",
"errors.noReceiver": "No receiver selected",
"errors.devTokenMissing": "Dev mode: VITE_CDROP_DEV_TOKEN is not set in .env.local",
"errors.noAccessToken": "No access token; user must log in",
"errors.clipboardUnavailable": "Browser clipboard API unavailable (requires HTTPS + permission)",
"errors.clipboardOverflow": "Content too large; exceeds {{max}} byte limit",
"errors.clipboardWriteFailed": "Failed to write to local clipboard: {{message}}",
"ios.tab.transfer": "Transfers",
"ios.tab.devices": "Devices",
"ios.tab.settings": "Settings",
"ios.login.generating": "Generating code…",
"ios.login.waiting": "Waiting for approval",
"ios.login.guide": "Scan to approve from another signed-in device",
"ios.login.expired": "Code expired — please try again",
"ios.login.failed": "Sign-in failed — please try again",
"ios.login.refresh": "Refresh QR Code",
"ios.login.needFull": "This device needs full access — choose \"Trust this device\" when approving.",
"ios.send.pickDevice": "Choose a device",
"ios.transfer.sending": "Sending",
"ios.settings.engine": "Engine",
"ios.settings.status": "Status",
"ios.settings.device": "Device",
"ios.settings.clipboard": "Clipboard",
"ios.settings.clipboardNote": "Upload pushes this device's clipboard to your others; Pull writes the latest cloud clipboard.",
"ios.clipboard.upload": "Upload Clipboard",
"ios.clipboard.pull": "Pull Clipboard",
"ios.clipboard.uploading": "Uploading…",
"ios.clipboard.pulling": "Pulling…",
"ios.clipboard.uploaded": "Uploaded to cloud clipboard",
"ios.clipboard.pulled": "Written to clipboard",
"ios.clipboard.empty": "Clipboard is empty",
"ios.clipboard.failed": "Clipboard sync failed",
"ios.devices.remove": "Remove Device",
"ios.devices.thisDevice": "This device",
"ios.devices.revoked": "Device removed",
"ios.devices.revokeFailed": "Remove failed",
"ios.devices.revokeStepUp": "Re-verify on the web to remove this device",
"ios.engine.disconnected": "Disconnected",
"ios.engine.ready": "Ready",
"ios.transfer.empty": "No transfers yet",
"ios.transfer.incoming": "Receive",
"ios.transfer.outgoing": "Send",
"ios.send.noDevices": "No online devices to send to",
"ios.devices.empty": "No devices yet",
"ios.detail.title": "Transfer Details",
"ios.detail.direction": "Direction",
"ios.detail.peer": "Peer",
"ios.detail.state": "State",
"ios.detail.phase": "Phase",
"ios.detail.channel": "Channel",
"ios.detail.size": "Size",
"ios.detail.progress": "Progress",
"ios.detail.speed": "Speed",
"ios.settings.account": "Account",
"ios.settings.user": "User",
"ios.settings.logout": "Sign Out",
"ios.settings.deviceName": "Device Name",
"ios.settings.deviceNameNote": "Renaming takes effect on next sign-in",
"ios.settings.deviceCount": "Known Devices",
"ios.settings.signaling": "Signaling",
"ios.settings.presenceEvents": "Presence Events",
"ios.settings.reconnecting": "Reconnecting",
"ios.settings.lastLog": "Last Log",
"ios.tab.files": "Files",
"ios.files.title": "Received Files",
"ios.files.empty": "No files received yet",
"ios.files.share": "Share",
"ios.files.done": "Done",
"ios.detail.openFile": "Open File"
}
+358
View File
@@ -0,0 +1,358 @@
{
"app.brand": "Commilitia Drop",
"deviceType.browser": "浏览器",
"deviceType.macos": "macOS 客户端",
"deviceType.windows": "Windows 客户端",
"deviceType.linux": "Linux 客户端",
"deviceType.ios": "iOS 客户端",
"deviceType.shortcut": "快捷指令",
"app.connected": "已连接",
"app.connecting": "正在连接…",
"app.disconnectedTitle": "正在重连",
"app.disconnectedHint": "与服务器的连接已断开,正在自动重连,无需刷新页面。",
"auth.sessionLost.title": "登录已失效",
"auth.sessionLost.body": "此设备的登录凭据已被注销或过期,请重新登录。",
"nav.accountMenu": "账号菜单",
"nav.thisDeviceLabel": "本机:",
"nav.settings": "设置",
"nav.signOut": "退出登录",
"nav.backToHome": "← 主页",
"nav.language": "语言 / Language",
"nav.theme": "主题 / Theme",
"nav.theme.light": "浅色",
"nav.theme.dark": "深色",
"nav.theme.system": "跟随系统",
"nav.themeToggle": "切换主题",
"home.greeting": "你好,{{name}}",
"home.deviceList.title": "设备",
"home.deviceList.onlineSuffix": "({{count}} 台在线)",
"home.deviceList.showOffline": "显示离线设备({{count}})",
"home.deviceList.hideOffline": "隐藏离线设备",
"home.deviceList.empty": "暂无其他设备。在另一标签页或设备上以相同账户登录,并设置一个不同的设备名(设置 → 设备名称),即可在此处看到它。",
"home.tabs.file": "文件",
"home.tabs.message": "消息",
"home.tabs.clipboard": "剪贴板",
"home.file.dropzone": "拖放文件到此处或点击选择",
"home.file.selectedSize": "{{name}}({{size}})",
"home.file.rejected": "文件已拒绝",
"home.cta.selectDeviceAndFile": "请选择设备与文件",
"home.cta.selectFile": "请选择文件",
"home.cta.selectDevice": "请选择设备",
"home.cta.sendTo": "发送到 {{name}}",
"home.message.placeholder": "发送消息到 {{name}}…",
"home.message.placeholderEmpty": "请先选择设备",
"home.message.hint": "回车发送,Shift + 回车换行,最长4 KB。",
"home.message.title": "消息",
"home.message.countSuffix": "({{count}})",
"home.message.clearAll": "全部清除",
"home.transfer.active": "进行中",
"home.transfer.history": "最近的传输",
"home.clipboard.cloudTitle": "云端最新",
"home.clipboard.cloudFrom": "来自 {{name}} · {{time}}",
"home.clipboard.cloudFromSelf": "来自本机 · {{time}}",
"home.clipboard.cloudEmpty": "尚未同步任何内容。",
"home.clipboard.copyToLocal": "复制到本机剪贴板",
"home.clipboard.copyToLocalSuccess": "已复制到本机剪贴板",
"home.clipboard.uploadTitle": "同步本机剪贴板",
"home.clipboard.uploadHint": "仅纯文本,最大64 KB;样式(粗体/颜色等)会被剥除。",
"home.clipboard.uploadButton": "上传本机剪贴板",
"home.clipboard.uploading": "上传中…",
"home.clipboard.uploadEmpty": "本机剪贴板为空",
"home.clipboard.refresh": "刷新",
"home.clipboard.clear": "清空云端",
"home.clipboard.clearConfirm": "确认清空云端剪贴板?所有设备将看到空内容。",
"home.clipboard.clearSuccess": "已清空云端剪贴板",
"home.clipboard.hidden": "内容已隐藏 · 点击“显示”查看",
"home.clipboard.reveal": "显示",
"home.clipboard.hide": "隐藏",
"setup.title": "为本机命名",
"setup.help": "此名称会展示给你的其他设备。建议避免空格,长度不超过 32 字符。",
"setup.field": "设备名称",
"setup.save": "保存并继续",
"login.title": "登录",
"login.dev.help": "Dev模式:随意填写一个用户标识。同标识的两个标签页将模拟同一用户的两台设备。",
"login.dev.field": "Dev用户标识",
"login.dev.continue": "继续",
"login.prod.help": "通过Casdoor登录。",
"login.prod.button": "使用Casdoor登录",
"oauth.failed": "登录失败",
"oauth.signingIn": "正在登录…",
"oauth.missingParams": "缺少code或state查询参数",
"qr.entry.fromLogin": "扫码登录此设备",
"qr.entry.scanNewDevice": "扫码登录新设备",
"qr.scan.title": "扫码登录新设备",
"qr.scan.subtitle": "把另一台设备显示的二维码对准取景框。批准全程留在这台已登录设备上。",
"qr.scan.starting": "正在启动相机…",
"qr.scan.aiming": "把二维码对准取景框",
"qr.scan.foreign": "这不是本站的登录二维码。请对准新设备上“扫码登录此设备”生成的码。",
"qr.scan.noCamera": "这台设备没有可用的摄像头。请改用带相机的手机或平板扫码。",
"qr.scan.denied": "相机权限被拒绝。请在浏览器站点设置里允许使用相机,再重新打开此页。",
"qr.scan.error": "无法打开相机。请重试,或改用带相机的设备扫码。",
"qr.scan.howto1": "在新设备上打开“扫码登录此设备”,生成一张二维码。",
"qr.scan.howto2": "把那张二维码对准上方取景框,识别后会自动进入批准。",
"qr.scan.back": "返回",
"qr.show.title": "扫码登录此设备",
"qr.show.subtitle": "先给这台设备起个名字,再用已登录的手机扫码批准它接入你的账号。",
"qr.show.nameHint": "此名称会展示给你的其他设备,仅可使用 ASCII 字符。",
"qr.show.generate": "生成二维码",
"qr.show.scanTitle": "用手机扫码批准",
"qr.show.howto1": "在另一台已登录的设备上打开相机或 cdrop。",
"qr.show.howto2": "扫描上方二维码,按提示批准此设备。",
"qr.show.waiting": "等待批准…",
"qr.show.approved": "已批准,正在进入…",
"qr.show.expired": "二维码已过期。重新生成一张再扫。",
"qr.show.denied": "这次接入被拒绝。如确为你本人操作,重新生成二维码再试。",
"qr.show.regenerate": "重新生成二维码",
"qr.show.back": "返回主页",
"qr.show.backToName": "返回上一步",
"qr.approve.title": "批准新设备",
"qr.approve.subtitle": "确认让这台设备登录你的账号。请核对设备与来源无误。",
"qr.approve.loading": "正在读取设备信息…",
"qr.approve.badLink": "这个批准链接不完整。请在新设备上重新生成二维码再扫。",
"qr.approve.signInFirst": "先登录你的账号,再批准这台设备接入。",
"qr.approve.trustLabel": "信任时长",
"qr.approve.trust.title": "信任此设备(7 天)",
"qr.approve.trust.hint": "7 天内免再次批准,每次使用自动续期。",
"qr.approve.once.title": "仅此一次(1 小时)",
"qr.approve.once.hint": "登录有效 1 小时,到期需重新扫码。",
"qr.approve.scopeFull": "这台设备将完整登录你的账号、7 天内有效:可正常收发文件与消息,并能管理账号与设备。",
"qr.approve.scopeGuest": "以受限访客身份登录——可收发文件与消息,但不能更改账号、不能批准其他设备;1 小时后失效。",
"qr.approve.nativeFull": "这是一台原生应用设备,将以完整权限登录(信任此设备、7 天内有效)。原生应用不支持受限访客。",
"qr.approve.trustContinue": "信任并继续",
"qr.approve.approve": "批准登录",
"qr.approve.stepUp.notice": "为安全起见,批准前需先验证你的身份——点下方按钮会引导你重新登录一次。",
"qr.approve.stepUp.button": "验证并批准",
"qr.approve.stepUp.rejected": "身份验证未通过或已过期。请再验证一次,确认是你本人后即可批准。",
"qr.approve.deny": "拒绝",
"qr.approve.doneApprovedTitle": "已批准",
"qr.approve.doneApproved": "这台设备已接入你的账号,现在即可在它上面收发文件。",
"qr.approve.doneDeniedTitle": "已拒绝",
"qr.approve.doneDenied": "这次接入已被拒绝,那台设备不会登录你的账号。",
"qr.approve.expiredTitle": "二维码已过期",
"qr.approve.expired": "这张二维码已失效。请在新设备上重新生成再扫。",
"qr.approve.errorTitle": "出错了",
"qr.approve.error": "操作没能完成。请重试,或在新设备上重新生成二维码。",
"qr.approve.backHome": "返回主页",
"settings.title": "设置",
"settings.currentDevice.title": "当前设备",
"settings.deviceName.field": "设备名称",
"settings.deviceName.save": "保存",
"settings.deviceName.unchanged": "新名称与当前一致",
"settings.deviceName.empty": "名称不能为空",
"settings.deviceName.asciiOnly": "设备名称只能使用 ASCII 字符(英文字母、数字、符号)。",
"settings.deviceName.success": "设备名称已更新",
"settings.unregister.current": "注销当前设备",
"settings.unregister.currentHint": "将退出登录并移除此设备的注册。",
"settings.unregister.currentConfirm": "注销当前设备会移除此设备的注册并退出登录,确认继续?",
"settings.error.delete": "操作失败:{{message}}",
"settings.guest.badge": "受限访客",
"settings.guest.title": "这台设备是受限访客",
"settings.guest.body": "本设备以受限访客身份登录:可正常收发文件、消息与剪贴板,但不能管理账号、移除设备或授权新设备。需要完整权限,请在你的主设备上重新登录。",
"settings.sessions.title": "登录会话",
"settings.sessions.hint": "这里列出当前登录此账号的所有设备,含浏览器、桌面与移动客户端;在线的排在上方。登出某台设备会立即结束它的登录,需在该设备上重新登录才能继续使用。",
"settings.sessions.guestNote": "受限访客无法管理登录会话;如需管理,请在你的主设备上完整登录。",
"settings.sessions.empty": "暂无其他登录会话。",
"settings.sessions.loadError": "登录会话加载失败。",
"settings.sessions.retry": "重试",
"settings.sessions.current": "本机",
"settings.sessions.scopeFull": "完整",
"settings.sessions.scopeGuest": "受限访客",
"settings.sessions.kind.oidc": "账号登录",
"settings.sessions.kind.self": "本地账号",
"settings.sessions.kind.guest": "扫码访客",
"settings.sessions.lastUsed": "最近活跃 {{time}}",
"settings.sessions.neverUsed": "尚未活跃",
"settings.sessions.signOut": "登出",
"settings.sessions.signingOut": "正在登出…",
"settings.sessions.signOutCurrent": "登出本机",
"settings.sessions.confirmOther": "确认登出设备“{{name}}”?该会话将立即失效,需重新登录。",
"settings.sessions.confirmCurrent": "登出本机会等同于退出登录,确认继续?",
"settings.sessions.revoked": "已登出该设备",
"settings.sessions.stepUpRejected": "身份验证未通过或已过期,请再试一次。",
"settings.online": "在线",
"settings.offline": "离线",
"settings.lastSeen": "上次活跃 {{time}}",
"settings.push.title": "推送通知",
"settings.push.hint": "页面关闭时,新文件、消息和传输结果会以系统通知提醒;页面打开时仍走应用内提示。",
"settings.push.enable": "启用推送",
"settings.push.enabled": "已启用",
"settings.push.disable": "关闭推送",
"settings.push.deniedHint": "通知权限已被浏览器拒绝,请在浏览器的站点设置中允许后重试。",
"settings.push.denied": "通知权限被拒绝",
"settings.push.enableOk": "已启用推送通知",
"settings.push.disableOk": "已关闭推送通知",
"settings.push.failed": "启用推送失败,请重试",
"notify.incoming.title": "收到文件",
"notify.incoming.body": "{{sender}} 发来 {{file}}",
"notify.transfer.doneTitle": "传输完成",
"notify.transfer.failedTitle": "传输失败",
"settings.account.title": "账号",
"settings.account.signOut": "退出登录",
"settings.desktop.title": "桌面",
"settings.desktop.clipboardSync": "剪贴板自动同步",
"settings.desktop.clipboardSyncHint": "复制即上传到云端,并接收其他设备的剪贴板更新。",
"settings.desktop.launchAtLogin": "开机自启",
"settings.desktop.launchAtLoginHint": "登录系统后自动在后台启动 cdrop(菜单栏常驻)。",
"settings.desktop.downloadDir": "下载目录",
"settings.desktop.downloadDirHint": "接收到的文件保存到此目录;留空则使用系统下载目录。",
"settings.desktop.downloadDirChoose": "更改…",
"settings.desktop.downloadDirReset": "恢复默认",
"settings.desktop.downloadDirPicker": "选择下载目录",
"settings.desktop.ttlNote": "出于安全考虑,云端剪贴板内容会在 5 分钟后自动清除。",
"settings.shortcut.title": "iOS 快捷指令",
"settings.shortcut.intro": "为 iOS“快捷指令”签发长效、仅限剪贴板、可随时吊销的专用令牌。即便令牌泄漏也只能读写剪贴板、无法触及其他数据。",
"settings.shortcut.labelField": "备注名称",
"settings.shortcut.labelPlaceholder": "例如:我的 iPhone",
"settings.shortcut.issue": "签发",
"settings.shortcut.empty": "尚未签发任何令牌。",
"settings.shortcut.loadError": "令牌列表加载失败。",
"settings.shortcut.retry": "重试",
"settings.shortcut.revoke": "吊销",
"settings.shortcut.revoking": "正在吊销…",
"settings.shortcut.revokeConfirm": "吊销后使用此令牌的快捷指令将立即失效,确认继续?",
"settings.shortcut.revoked": "已吊销",
"settings.shortcut.revokedToast": "令牌已吊销",
"settings.shortcut.expired": "已过期",
"settings.shortcut.expiresAt": "到期 {{date}}",
"settings.shortcut.lastUsed": "上次使用 {{time}}",
"settings.shortcut.neverUsed": "尚未使用",
"settings.shortcut.unavailable": "服务器未启用快捷指令令牌。",
"settings.shortcut.tooMany": "活跃令牌已达上限,请先吊销旧令牌。",
"settings.shortcut.issueError": "操作失败:{{message}}",
"settings.shortcut.issuedTitle": "令牌已签发",
"settings.shortcut.onceWarning": "令牌仅显示这一次,请立即复制保存;关闭后无法再次查看。",
"settings.shortcut.tokenLabel": "令牌(Bearer)",
"settings.shortcut.copy": "复制",
"settings.shortcut.copyFailed": "复制失败,请手动选择复制",
"settings.shortcut.copied": "已复制",
"settings.shortcut.baseLabel": "服务器地址",
"settings.shortcut.guideTitle": "在 iOS 上手动搭建(手动同步)",
"settings.shortcut.guideIntro": "在 iPhone 的“快捷指令”App 中新建两个指令,首次运行时填入上面的服务器地址与令牌:",
"settings.shortcut.guideDeviceName": "两个指令都加请求头 X-Device-Name: 你的设备名(仅 ASCII),让它在设备列表里统一显示。",
"settings.shortcut.guidePullTitle": "拉取(云端 → 本机剪贴板)",
"settings.shortcut.guidePull1": "“获取 URL 内容”:地址设为 你的服务器地址 加 /api/clipboard,方法 GET,请求头加 Authorization: Bearer 你的令牌。",
"settings.shortcut.guidePull2": "“获取词典值”:取键 content。",
"settings.shortcut.guidePull3": "内容非空时用“拷贝到剪贴板”写入,可再加“显示通知”提示已拉取。",
"settings.shortcut.guidePushTitle": "上传(本机剪贴板 → 云端)",
"settings.shortcut.guidePush1": "“获取剪贴板”;若为空则停止。",
"settings.shortcut.guidePush2": "“获取 URL 内容”:地址同上,方法 PUT,请求头加 Authorization: Bearer 你的令牌 与 Content-Type: application/json,请求体选 JSON:{\"content\": 剪贴板, \"content_type\": \"text/plain\"}。",
"settings.shortcut.guidePush3": "可加“显示通知”提示已上传。",
"settings.shortcut.guideToleranceNote": "网络不稳定时单次运行可能失败,重试即可;自动轮询版会在后台静默重试(下一阶段提供)。",
"settings.shortcut.done": "完成",
"transfer.savedTo": "已保存到 {{path}}",
"transfer.saveFailed": "保存文件失败:{{error}}",
"transfer.action.cancel": "取消",
"transfer.action.delete": "删除",
"transfer.action.relayNow": "立即中继",
"transfer.action.details": "详情",
"transfer.state.PENDING": "等待中",
"transfer.state.ACCEPTED": "已接受",
"transfer.state.P2P_ACTIVE": "传输中",
"transfer.state.RELAY_ACTIVE": "中继中",
"transfer.state.DONE": "已完成",
"transfer.state.FAILED": "失败",
"transfer.state.CANCELLED": "已取消",
"transfer.mode.p2p": "P2P",
"transfer.mode.relay": "中继",
"transfer.phase.initializing": "正在初始化…",
"transfer.phase.waiting_accept": "等待对端接受…",
"transfer.phase.ice_gathering": "收集网络候选…",
"transfer.phase.ice_checking": "建立直连通道…",
"transfer.phase.ice_connected": "通道已连接",
"transfer.phase.dc_open": "信道已打开",
"transfer.phase.transferring": "传输中",
"transfer.phase.completing": "收尾中…",
"transfer.phase.fallback_pending": "切换到中继路径…",
"transfer.phase.relay_uploading": "中继上传中",
"transfer.phase.relay_downloading": "中继下载中",
"transfer.stalled": "似乎卡住了({{seconds}} 秒无进度)",
"transfer.bytesProgress": "{{sent}} / {{total}}",
"transfer.bytesRate": "{{rate}}/秒",
"transfer.debug.toggle": "调试信息",
"transfer.debug.iceGathering": "候选收集",
"transfer.debug.iceConnection": "连接状态",
"transfer.debug.candidatesLocal": "本端候选",
"transfer.debug.candidatesRemote": "对端候选",
"transfer.debug.selectedPair": "选中候选对",
"transfer.debug.noPair": "尚未选定候选对",
"transfer.debug.candidatesLine": "host {{host}} · mDNS {{mdns}} · srflx {{srflx}} · prflx {{prflx}} · relay {{relay}}",
"transfer.debug.dataChannel": "数据信道",
"transfer.debug.dcLine": "{{state}} · 待发送 {{buffered}}",
"time.justNow": "刚刚",
"time.secondsAgo": "{{n}} 秒前",
"time.minutesAgo": "{{n}} 分钟前",
"time.hoursAgo": "{{n}} 小时前",
"common.dismiss": "移除",
"common.cancel": "取消",
"common.confirm": "确认",
"errors.messageEmpty": "消息为空",
"errors.messageOverflow": "消息超过4 KB;请改用文件传输",
"errors.noReceiver": "未选择接收方",
"errors.devTokenMissing": "Dev模式:未在.env.local设置VITE_CDROP_DEV_TOKEN",
"errors.noAccessToken": "无访问令牌:用户必须先登录",
"errors.clipboardUnavailable": "浏览器不支持剪贴板API(需HTTPS与权限)",
"errors.clipboardOverflow": "内容过大,超过 {{max}} 字节上限",
"errors.clipboardWriteFailed": "写入本机剪贴板失败:{{message}}",
"ios.tab.transfer": "传输",
"ios.tab.devices": "设备",
"ios.tab.settings": "设置",
"ios.login.generating": "正在生成二维码…",
"ios.login.waiting": "等待扫码批准",
"ios.login.guide": "用另一台已登录的设备扫码批准",
"ios.login.expired": "二维码已失效,请重试",
"ios.login.failed": "登录失败,请重试",
"ios.login.refresh": "刷新二维码",
"ios.login.needFull": "此设备需要完整权限,批准时请选择“信任此设备”",
"ios.send.pickDevice": "选择接收设备",
"ios.transfer.sending": "发送中",
"ios.settings.engine": "引擎",
"ios.settings.status": "状态",
"ios.settings.device": "设备",
"ios.settings.clipboard": "剪贴板",
"ios.settings.clipboardNote": "上传把本机剪贴板推到其他设备;拉取写入最新云剪贴板。",
"ios.clipboard.upload": "上传剪贴板",
"ios.clipboard.pull": "拉取剪贴板",
"ios.clipboard.uploading": "正在上传…",
"ios.clipboard.pulling": "正在拉取…",
"ios.clipboard.uploaded": "已上传到云剪贴板",
"ios.clipboard.pulled": "已写入本机剪贴板",
"ios.clipboard.empty": "剪贴板为空",
"ios.clipboard.failed": "剪贴板同步失败",
"ios.devices.remove": "移除设备",
"ios.devices.thisDevice": "本机",
"ios.devices.revoked": "已移除设备",
"ios.devices.revokeFailed": "移除失败",
"ios.devices.revokeStepUp": "需在网页端重新验证后才能移除",
"ios.engine.disconnected": "未连接",
"ios.engine.ready": "已就绪",
"ios.transfer.empty": "暂无传输",
"ios.transfer.incoming": "接收",
"ios.transfer.outgoing": "发送",
"ios.send.noDevices": "没有在线设备,无法发送",
"ios.devices.empty": "暂无设备",
"ios.detail.title": "传输详情",
"ios.detail.direction": "方向",
"ios.detail.peer": "对端",
"ios.detail.state": "状态",
"ios.detail.phase": "阶段",
"ios.detail.channel": "通道",
"ios.detail.size": "大小",
"ios.detail.progress": "进度",
"ios.detail.speed": "速度",
"ios.settings.account": "账户",
"ios.settings.user": "用户",
"ios.settings.logout": "退出登录",
"ios.settings.deviceName": "设备名称",
"ios.settings.deviceNameNote": "改名将在下次登录后生效",
"ios.settings.deviceCount": "已知设备",
"ios.settings.signaling": "信令连接",
"ios.settings.presenceEvents": "在线事件",
"ios.settings.reconnecting": "重连中",
"ios.settings.lastLog": "最近日志",
"ios.tab.files": "文件",
"ios.files.title": "收到的文件",
"ios.files.empty": "还没有收到文件",
"ios.files.share": "分享",
"ios.files.done": "完成",
"ios.detail.openFile": "打开文件"
}
+358
View File
@@ -0,0 +1,358 @@
{
"app.brand": "Commilitia Drop",
"deviceType.browser": "瀏覽器",
"deviceType.macos": "macOS 用戶端",
"deviceType.windows": "Windows 用戶端",
"deviceType.linux": "Linux 用戶端",
"deviceType.ios": "iOS 用戶端",
"deviceType.shortcut": "捷徑",
"app.connected": "已連線",
"app.connecting": "正在連線…",
"app.disconnectedTitle": "正在重新連線",
"app.disconnectedHint": "與伺服器的連線已中斷,正在自動重新連線,無需重新整理頁面。",
"auth.sessionLost.title": "登入已失效",
"auth.sessionLost.body": "此裝置的登入憑證已被撤銷或逾期,請重新登入。",
"nav.accountMenu": "帳號選單",
"nav.thisDeviceLabel": "本機:",
"nav.settings": "設定",
"nav.signOut": "登出",
"nav.backToHome": "← 首頁",
"nav.language": "語言 / Language",
"nav.theme": "主題 / Theme",
"nav.theme.light": "淺色",
"nav.theme.dark": "深色",
"nav.theme.system": "跟隨系統",
"nav.themeToggle": "切換主題",
"home.greeting": "你好,{{name}}",
"home.deviceList.title": "裝置",
"home.deviceList.onlineSuffix": "({{count}} 部線上)",
"home.deviceList.showOffline": "顯示離線裝置({{count}})",
"home.deviceList.hideOffline": "隱藏離線裝置",
"home.deviceList.empty": "尚無其他裝置。在另一個分頁或裝置上以同一帳號登入,並設定一個不同的裝置名稱(設定 → 裝置名稱),即可在此處看到它。",
"home.tabs.file": "檔案",
"home.tabs.message": "訊息",
"home.tabs.clipboard": "剪貼簿",
"home.file.dropzone": "拖曳檔案到此處或點選選擇",
"home.file.selectedSize": "{{name}}({{size}})",
"home.file.rejected": "檔案已拒絕",
"home.cta.selectDeviceAndFile": "請選擇裝置與檔案",
"home.cta.selectFile": "請選擇檔案",
"home.cta.selectDevice": "請選擇裝置",
"home.cta.sendTo": "傳送到 {{name}}",
"home.message.placeholder": "傳送訊息給 {{name}}…",
"home.message.placeholderEmpty": "請先選擇裝置",
"home.message.hint": "Enter傳送,Shift + Enter換行,最長4 KB。",
"home.message.title": "訊息",
"home.message.countSuffix": "({{count}})",
"home.message.clearAll": "全部清除",
"home.transfer.active": "進行中",
"home.transfer.history": "最近的傳輸",
"home.clipboard.cloudTitle": "雲端最新",
"home.clipboard.cloudFrom": "來自 {{name}} · {{time}}",
"home.clipboard.cloudFromSelf": "來自本機 · {{time}}",
"home.clipboard.cloudEmpty": "尚未同步任何內容。",
"home.clipboard.copyToLocal": "複製到本機剪貼簿",
"home.clipboard.copyToLocalSuccess": "已複製到本機剪貼簿",
"home.clipboard.uploadTitle": "同步本機剪貼簿",
"home.clipboard.uploadHint": "僅純文字,最大64 KB;樣式(粗體 / 顏色等)會被剝除。",
"home.clipboard.uploadButton": "上傳本機剪貼簿",
"home.clipboard.uploading": "上傳中…",
"home.clipboard.uploadEmpty": "本機剪貼簿為空",
"home.clipboard.refresh": "重新整理",
"home.clipboard.clear": "清空雲端",
"home.clipboard.clearConfirm": "確認清空雲端剪貼簿?所有裝置將看到空內容。",
"home.clipboard.clearSuccess": "已清空雲端剪貼簿",
"home.clipboard.hidden": "內容已隱藏 · 點擊「顯示」查看",
"home.clipboard.reveal": "顯示",
"home.clipboard.hide": "隱藏",
"setup.title": "為本機命名",
"setup.help": "此名稱會顯示給你的其他裝置。建議避免空格,長度不超過 32 個字元。",
"setup.field": "裝置名稱",
"setup.save": "儲存並繼續",
"login.title": "登入",
"login.dev.help": "Dev模式:隨意填寫一個使用者識別碼。同識別碼的兩個分頁將模擬同一使用者的兩部裝置。",
"login.dev.field": "Dev使用者識別碼",
"login.dev.continue": "繼續",
"login.prod.help": "透過Casdoor登入。",
"login.prod.button": "透過Casdoor登入",
"oauth.failed": "登入失敗",
"oauth.signingIn": "正在登入…",
"oauth.missingParams": "缺少code或state查詢參數",
"qr.entry.fromLogin": "掃碼登入此裝置",
"qr.entry.scanNewDevice": "掃碼登入新裝置",
"qr.scan.title": "掃碼登入新裝置",
"qr.scan.subtitle": "把另一部裝置顯示的 QR 碼對準取景框。批准全程留在這部已登入裝置上。",
"qr.scan.starting": "正在啟動相機…",
"qr.scan.aiming": "把 QR 碼對準取景框",
"qr.scan.foreign": "這不是本站的登入 QR 碼。請對準新裝置上「掃碼登入此裝置」產生的碼。",
"qr.scan.noCamera": "這部裝置沒有可用的相機。請改用有相機的手機或平板掃碼。",
"qr.scan.denied": "相機權限被拒絕。請在瀏覽器網站設定裡允許使用相機,再重新開啟此頁。",
"qr.scan.error": "無法開啟相機。請重試,或改用有相機的裝置掃碼。",
"qr.scan.howto1": "在新裝置上開啟「掃碼登入此裝置」,產生一張 QR 碼。",
"qr.scan.howto2": "把那張 QR 碼對準上方取景框,辨識後會自動進入批准。",
"qr.scan.back": "返回",
"qr.show.title": "掃碼登入此裝置",
"qr.show.subtitle": "先為這部裝置命名,再用已登入的手機掃碼批准它接入你的帳號。",
"qr.show.nameHint": "此名稱會顯示給你的其他裝置,僅可使用 ASCII 字元。",
"qr.show.generate": "產生 QR 碼",
"qr.show.scanTitle": "用手機掃碼批准",
"qr.show.howto1": "在另一部已登入的裝置上開啟相機或 cdrop。",
"qr.show.howto2": "掃描上方 QR 碼,依提示批准此裝置。",
"qr.show.waiting": "等待批准…",
"qr.show.approved": "已批准,正在進入…",
"qr.show.expired": "QR 碼已逾時。重新產生一張再掃。",
"qr.show.denied": "這次接入被拒絕。如確為你本人操作,重新產生 QR 碼再試。",
"qr.show.regenerate": "重新產生 QR 碼",
"qr.show.back": "返回首頁",
"qr.show.backToName": "返回上一步",
"qr.approve.title": "批准新裝置",
"qr.approve.subtitle": "確認讓這部裝置登入你的帳號。請核對裝置與來源無誤。",
"qr.approve.loading": "正在讀取裝置資訊…",
"qr.approve.badLink": "這個批准連結不完整。請在新裝置上重新產生 QR 碼再掃。",
"qr.approve.signInFirst": "先登入你的帳號,再批准這部裝置接入。",
"qr.approve.trustLabel": "信任時長",
"qr.approve.trust.title": "信任此裝置(7 天)",
"qr.approve.trust.hint": "7 天內免再次批准,每次使用自動續期。",
"qr.approve.once.title": "僅此一次(1 小時)",
"qr.approve.once.hint": "登入有效 1 小時,逾時需重新掃碼。",
"qr.approve.scopeFull": "這部裝置將完整登入你的帳號、7 天內有效:可正常收發檔案與訊息,並能管理帳號與裝置。",
"qr.approve.scopeGuest": "以受限訪客身分登入——可收發檔案與訊息,但不能變更帳號、不能批准其他裝置;1 小時後失效。",
"qr.approve.nativeFull": "這是一台原生應用裝置,將以完整權限登入(信任此裝置、7 天內有效)。原生應用不支援受限訪客。",
"qr.approve.trustContinue": "信任並繼續",
"qr.approve.approve": "批准登入",
"qr.approve.stepUp.notice": "為安全起見,批准前需先驗證你的身分——點下方按鈕會引導你重新登入一次。",
"qr.approve.stepUp.button": "驗證並批准",
"qr.approve.stepUp.rejected": "身分驗證未通過或已逾時。請再驗證一次,確認是你本人後即可批准。",
"qr.approve.deny": "拒絕",
"qr.approve.doneApprovedTitle": "已批准",
"qr.approve.doneApproved": "這部裝置已接入你的帳號,現在即可在它上面收發檔案。",
"qr.approve.doneDeniedTitle": "已拒絕",
"qr.approve.doneDenied": "這次接入已被拒絕,那部裝置不會登入你的帳號。",
"qr.approve.expiredTitle": "QR 碼已逾時",
"qr.approve.expired": "這張 QR 碼已失效。請在新裝置上重新產生再掃。",
"qr.approve.errorTitle": "出錯了",
"qr.approve.error": "操作沒能完成。請重試,或在新裝置上重新產生 QR 碼。",
"qr.approve.backHome": "返回首頁",
"settings.title": "設定",
"settings.currentDevice.title": "目前裝置",
"settings.deviceName.field": "裝置名稱",
"settings.deviceName.save": "儲存",
"settings.deviceName.unchanged": "新名稱與目前一致",
"settings.deviceName.empty": "名稱不能為空",
"settings.deviceName.asciiOnly": "裝置名稱只能使用 ASCII 字元(英文字母、數字、符號)。",
"settings.deviceName.success": "裝置名稱已更新",
"settings.unregister.current": "解除註冊本裝置",
"settings.unregister.currentHint": "將登出並移除本裝置的註冊。",
"settings.unregister.currentConfirm": "解除註冊本裝置會移除其註冊並登出,確認繼續?",
"settings.error.delete": "操作失敗:{{message}}",
"settings.guest.badge": "受限訪客",
"settings.guest.title": "這部裝置是受限訪客",
"settings.guest.body": "本裝置以受限訪客身分登入:可正常收發檔案、訊息與剪貼簿,但不能管理帳號、移除裝置或授權新裝置。需要完整權限,請在你的主裝置上重新登入。",
"settings.sessions.title": "登入工作階段",
"settings.sessions.hint": "這裡列出目前登入此帳號的所有裝置,含瀏覽器、桌面與行動用戶端;線上的排在上方。登出某台裝置會立即結束它的登入,需在該裝置上重新登入才能繼續使用。",
"settings.sessions.guestNote": "受限訪客無法管理登入工作階段;如需管理,請在你的主裝置上完整登入。",
"settings.sessions.empty": "尚無其他登入工作階段。",
"settings.sessions.loadError": "登入工作階段載入失敗。",
"settings.sessions.retry": "重試",
"settings.sessions.current": "本機",
"settings.sessions.scopeFull": "完整",
"settings.sessions.scopeGuest": "受限訪客",
"settings.sessions.kind.oidc": "帳號登入",
"settings.sessions.kind.self": "本機帳號",
"settings.sessions.kind.guest": "掃碼訪客",
"settings.sessions.lastUsed": "最近活躍 {{time}}",
"settings.sessions.neverUsed": "尚未活躍",
"settings.sessions.signOut": "登出",
"settings.sessions.signingOut": "正在登出…",
"settings.sessions.signOutCurrent": "登出本機",
"settings.sessions.confirmOther": "確認登出裝置「{{name}}」?該工作階段將立即失效,需重新登入。",
"settings.sessions.confirmCurrent": "登出本機等同於登出帳號,確認繼續?",
"settings.sessions.revoked": "已登出該裝置",
"settings.sessions.stepUpRejected": "身分驗證未通過或已過期,請再試一次。",
"settings.online": "線上",
"settings.offline": "離線",
"settings.lastSeen": "上次活躍 {{time}}",
"settings.push.title": "推播通知",
"settings.push.hint": "頁面關閉時,新檔案、訊息與傳輸結果會以系統通知提醒;頁面開啟時仍走應用內提示。",
"settings.push.enable": "啟用推播",
"settings.push.enabled": "已啟用",
"settings.push.disable": "關閉推播",
"settings.push.deniedHint": "通知權限已被瀏覽器拒絕,請在瀏覽器的網站設定中允許後重試。",
"settings.push.denied": "通知權限被拒絕",
"settings.push.enableOk": "已啟用推播通知",
"settings.push.disableOk": "已關閉推播通知",
"settings.push.failed": "啟用推播失敗,請重試",
"notify.incoming.title": "收到檔案",
"notify.incoming.body": "{{sender}} 傳來 {{file}}",
"notify.transfer.doneTitle": "傳輸完成",
"notify.transfer.failedTitle": "傳輸失敗",
"settings.account.title": "帳號",
"settings.account.signOut": "登出",
"settings.desktop.title": "桌面",
"settings.desktop.clipboardSync": "剪貼簿自動同步",
"settings.desktop.clipboardSyncHint": "複製後即上傳雲端,並接收其他裝置的剪貼簿更新。",
"settings.desktop.launchAtLogin": "開機自動啟動",
"settings.desktop.launchAtLoginHint": "登入系統後自動在背景啟動 cdrop(常駐選單列)。",
"settings.desktop.downloadDir": "下載資料夾",
"settings.desktop.downloadDirHint": "接收到的檔案會儲存至此資料夾;留空則使用系統下載資料夾。",
"settings.desktop.downloadDirChoose": "變更…",
"settings.desktop.downloadDirReset": "還原預設",
"settings.desktop.downloadDirPicker": "選擇下載資料夾",
"settings.desktop.ttlNote": "基於安全考量,雲端剪貼簿內容會在 5 分鐘後自動清除。",
"settings.shortcut.title": "iOS 捷徑",
"settings.shortcut.intro": "為 iOS「捷徑」簽發長效、僅限剪貼簿、可隨時撤銷的專用權杖。即使權杖外洩也只能讀寫剪貼簿、無法觸及其他資料。",
"settings.shortcut.labelField": "備註名稱",
"settings.shortcut.labelPlaceholder": "例如:我的 iPhone",
"settings.shortcut.issue": "簽發",
"settings.shortcut.empty": "尚未簽發任何權杖。",
"settings.shortcut.loadError": "權杖清單載入失敗。",
"settings.shortcut.retry": "重試",
"settings.shortcut.revoke": "撤銷",
"settings.shortcut.revoking": "正在撤銷…",
"settings.shortcut.revokeConfirm": "撤銷後使用此權杖的捷徑將立即失效,確認繼續?",
"settings.shortcut.revoked": "已撤銷",
"settings.shortcut.revokedToast": "權杖已撤銷",
"settings.shortcut.expired": "已過期",
"settings.shortcut.expiresAt": "到期 {{date}}",
"settings.shortcut.lastUsed": "上次使用 {{time}}",
"settings.shortcut.neverUsed": "尚未使用",
"settings.shortcut.unavailable": "伺服器未啟用捷徑權杖。",
"settings.shortcut.tooMany": "使用中的權杖已達上限,請先撤銷舊權杖。",
"settings.shortcut.issueError": "操作失敗:{{message}}",
"settings.shortcut.issuedTitle": "權杖已簽發",
"settings.shortcut.onceWarning": "權杖僅顯示這一次,請立即複製保存;關閉後無法再次檢視。",
"settings.shortcut.tokenLabel": "權杖(Bearer)",
"settings.shortcut.copy": "複製",
"settings.shortcut.copyFailed": "複製失敗,請手動選取複製",
"settings.shortcut.copied": "已複製",
"settings.shortcut.baseLabel": "伺服器位址",
"settings.shortcut.guideTitle": "在 iOS 上手動建立(手動同步)",
"settings.shortcut.guideIntro": "在 iPhone 的「捷徑」App 中新增兩個捷徑,首次執行時填入上方的伺服器位址與權杖:",
"settings.shortcut.guideDeviceName": "兩個捷徑都加標頭 X-Device-Name: 你的裝置名稱(僅 ASCII),讓它在裝置清單中統一顯示。",
"settings.shortcut.guidePullTitle": "拉取(雲端 → 本機剪貼簿)",
"settings.shortcut.guidePull1": "「取得 URL 內容」:網址設為 你的伺服器位址 加 /api/clipboard,方法 GET,標頭加 Authorization: Bearer 你的權杖。",
"settings.shortcut.guidePull2": "「取得字典值」:取鍵 content。",
"settings.shortcut.guidePull3": "內容非空時用「複製到剪貼簿」寫入,可再加「顯示通知」提示已拉取。",
"settings.shortcut.guidePushTitle": "上傳(本機剪貼簿 → 雲端)",
"settings.shortcut.guidePush1": "「取得剪貼簿」;若為空則停止。",
"settings.shortcut.guidePush2": "「取得 URL 內容」:網址同上,方法 PUT,標頭加 Authorization: Bearer 你的權杖 與 Content-Type: application/json,請求內容選 JSON:{\"content\": 剪貼簿, \"content_type\": \"text/plain\"}。",
"settings.shortcut.guidePush3": "可加「顯示通知」提示已上傳。",
"settings.shortcut.guideToleranceNote": "網路不穩時單次執行可能失敗,重試即可;自動輪詢版會在背景靜默重試(下一階段提供)。",
"settings.shortcut.done": "完成",
"transfer.savedTo": "已儲存至 {{path}}",
"transfer.saveFailed": "儲存檔案失敗:{{error}}",
"transfer.action.cancel": "取消",
"transfer.action.delete": "刪除",
"transfer.action.relayNow": "立即中繼",
"transfer.action.details": "詳情",
"transfer.state.PENDING": "等待中",
"transfer.state.ACCEPTED": "已接受",
"transfer.state.P2P_ACTIVE": "傳輸中",
"transfer.state.RELAY_ACTIVE": "中繼中",
"transfer.state.DONE": "已完成",
"transfer.state.FAILED": "失敗",
"transfer.state.CANCELLED": "已取消",
"transfer.mode.p2p": "P2P",
"transfer.mode.relay": "中繼",
"transfer.phase.initializing": "正在初始化…",
"transfer.phase.waiting_accept": "等待對端接受…",
"transfer.phase.ice_gathering": "收集網路候選…",
"transfer.phase.ice_checking": "建立直連通道…",
"transfer.phase.ice_connected": "通道已連線",
"transfer.phase.dc_open": "通道已開啟",
"transfer.phase.transferring": "傳輸中",
"transfer.phase.completing": "收尾中…",
"transfer.phase.fallback_pending": "切換到中繼路徑…",
"transfer.phase.relay_uploading": "中繼上傳中",
"transfer.phase.relay_downloading": "中繼下載中",
"transfer.stalled": "似乎卡住了({{seconds}} 秒無進度)",
"transfer.bytesProgress": "{{sent}} / {{total}}",
"transfer.bytesRate": "{{rate}}/秒",
"transfer.debug.toggle": "偵錯資訊",
"transfer.debug.iceGathering": "候選收集",
"transfer.debug.iceConnection": "連線狀態",
"transfer.debug.candidatesLocal": "本端候選",
"transfer.debug.candidatesRemote": "對端候選",
"transfer.debug.selectedPair": "選中候選對",
"transfer.debug.noPair": "尚未選定候選對",
"transfer.debug.candidatesLine": "host {{host}} · mDNS {{mdns}} · srflx {{srflx}} · prflx {{prflx}} · relay {{relay}}",
"transfer.debug.dataChannel": "資料通道",
"transfer.debug.dcLine": "{{state}} · 待傳送 {{buffered}}",
"time.justNow": "剛剛",
"time.secondsAgo": "{{n}} 秒前",
"time.minutesAgo": "{{n}} 分鐘前",
"time.hoursAgo": "{{n}} 小時前",
"common.dismiss": "移除",
"common.cancel": "取消",
"common.confirm": "確認",
"errors.messageEmpty": "訊息為空",
"errors.messageOverflow": "訊息超過4 KB;請改用檔案傳輸",
"errors.noReceiver": "未選擇接收方",
"errors.devTokenMissing": "Dev模式:未在.env.local設定VITE_CDROP_DEV_TOKEN",
"errors.noAccessToken": "無存取權杖:使用者必須先登入",
"errors.clipboardUnavailable": "瀏覽器不支援剪貼簿API(需HTTPS與權限)",
"errors.clipboardOverflow": "內容過大,超過 {{max}} 位元組上限",
"errors.clipboardWriteFailed": "寫入本機剪貼簿失敗:{{message}}",
"ios.tab.transfer": "傳輸",
"ios.tab.devices": "裝置",
"ios.tab.settings": "設定",
"ios.login.generating": "正在產生 QR 碼…",
"ios.login.waiting": "等待掃碼核准",
"ios.login.guide": "用另一台已登入的裝置掃碼核准",
"ios.login.expired": "QR 碼已失效,請重試",
"ios.login.failed": "登入失敗,請重試",
"ios.login.refresh": "重新整理 QR 碼",
"ios.login.needFull": "此裝置需要完整權限,批准時請選擇「信任此裝置」",
"ios.send.pickDevice": "選擇接收裝置",
"ios.transfer.sending": "傳送中",
"ios.settings.engine": "引擎",
"ios.settings.status": "狀態",
"ios.settings.device": "裝置",
"ios.settings.clipboard": "剪貼簿",
"ios.settings.clipboardNote": "上傳把本機剪貼簿推到其他裝置;拉取寫入最新雲端剪貼簿。",
"ios.clipboard.upload": "上傳剪貼簿",
"ios.clipboard.pull": "拉取剪貼簿",
"ios.clipboard.uploading": "正在上傳…",
"ios.clipboard.pulling": "正在拉取…",
"ios.clipboard.uploaded": "已上傳到雲端剪貼簿",
"ios.clipboard.pulled": "已寫入本機剪貼簿",
"ios.clipboard.empty": "剪貼簿是空的",
"ios.clipboard.failed": "剪貼簿同步失敗",
"ios.devices.remove": "移除裝置",
"ios.devices.thisDevice": "本機",
"ios.devices.revoked": "已移除裝置",
"ios.devices.revokeFailed": "移除失敗",
"ios.devices.revokeStepUp": "需在網頁端重新驗證後才能移除",
"ios.engine.disconnected": "未連線",
"ios.engine.ready": "已就緒",
"ios.transfer.empty": "尚無傳輸",
"ios.transfer.incoming": "接收",
"ios.transfer.outgoing": "傳送",
"ios.send.noDevices": "沒有上線裝置,無法傳送",
"ios.devices.empty": "尚無裝置",
"ios.detail.title": "傳輸詳情",
"ios.detail.direction": "方向",
"ios.detail.peer": "對端",
"ios.detail.state": "狀態",
"ios.detail.phase": "階段",
"ios.detail.channel": "通道",
"ios.detail.size": "大小",
"ios.detail.progress": "進度",
"ios.detail.speed": "速度",
"ios.settings.account": "帳戶",
"ios.settings.user": "使用者",
"ios.settings.logout": "登出",
"ios.settings.deviceName": "裝置名稱",
"ios.settings.deviceNameNote": "改名將在下次登入後生效",
"ios.settings.deviceCount": "已知裝置",
"ios.settings.signaling": "信令連線",
"ios.settings.presenceEvents": "上線事件",
"ios.settings.reconnecting": "重新連線中",
"ios.settings.lastLog": "最近日誌",
"ios.tab.files": "檔案",
"ios.files.title": "收到的檔案",
"ios.files.empty": "尚未收到檔案",
"ios.files.share": "分享",
"ios.files.done": "完成",
"ios.detail.openFile": "開啟檔案"
}
+685
View File
@@ -0,0 +1,685 @@
import SwiftUI
import UIKit
import UniformTypeIdentifiers
// 液态玻璃原生壳:底部标签栏 + 导航栈(iOS 26 SDK 自动玻璃化)。离屏无头引擎 WebView 挂在
// 背景里保持存活、零尺寸不可见。内容卡片属内容层不加玻璃;悬浮主操作用玻璃强调按钮(功能
// 层)。见 ios/PLAN.md §8.1。用户可见文案全走 i18n(t(),源=web catalog)。设备 / 传输
// 列表由引擎经桥推来的真实 presence / 传输态驱动(EngineController,arch A)。
struct RootView: View
{
@Environment(EngineController.self) private var engine
// 初始标签可经 CDROP_TAB 环境变量指定(本机截图测试用:直接进设置看引擎状态)。
@State private var selection = ProcessInfo.processInfo.environment["CDROP_TAB"] ?? "transfer"
var body: some View
{
TabView(selection: $selection)
{
Tab(t("ios.tab.transfer"), systemImage: "arrow.up.arrow.down", value: "transfer")
{
NavigationStack
{
TransferListView()
}
}
Tab(t("ios.tab.devices"), systemImage: "laptopcomputer.and.iphone", value: "devices")
{
NavigationStack
{
DeviceListView()
}
}
Tab(t("ios.tab.files"), systemImage: "folder", value: "files")
{
NavigationStack
{
ReceivedFilesView()
}
}
Tab(t("ios.tab.settings"), systemImage: "gearshape", value: "settings")
{
NavigationStack
{
SettingsView()
}
}
}
.tabBarMinimizeBehavior(.onScrollDown)
.tint(.cdropAccent)
.background
{
EngineWebView(controller: engine)
.frame(width: 0, height: 0)
.opacity(0)
.allowsHitTesting(false)
}
}
}
struct TransferListView: View
{
@Environment(EngineController.self) private var engine
@State private var showImporter = false
@State private var pickedURL: URL?
@State private var showDevicePicker = false
@State private var showNoDevices = false
// 可发送目标:在线、且非本机(共用 EngineController.sendableDevices)。
private var sendableDevices: [DeviceItem]
{
engine.sendableDevices()
}
var body: some View
{
ScrollView
{
if engine.transfers.isEmpty && engine.history.isEmpty
{
ContentUnavailableView(t("ios.transfer.empty"), systemImage: "tray")
.padding(.top, 80)
}
else
{
VStack(spacing: 16)
{
if !engine.transfers.isEmpty
{
sectionHeader(t("home.transfer.active"))
ForEach(engine.transfers)
{ item in
transferLink(item)
}
}
if !engine.history.isEmpty
{
sectionHeader(t("home.transfer.history"))
ForEach(engine.history)
{ item in
transferLink(item)
}
}
}
.padding()
}
}
.navigationTitle(t("ios.tab.transfer"))
.overlay(alignment: .bottomTrailing)
{
Button { showImporter = true }
label:
{
Image(systemName: "paperplane.fill")
.font(.title2)
.padding(18)
}
.buttonStyle(.glassProminent)
.padding(24)
}
.fileImporter(isPresented: $showImporter, allowedContentTypes: [ .item ])
{ result in
if case .success(let url) = result
{
pickedURL = url
if sendableDevices.isEmpty { showNoDevices = true }
else { showDevicePicker = true }
}
}
.confirmationDialog(t("ios.send.pickDevice"), isPresented: $showDevicePicker, titleVisibility: .visible)
{
ForEach(sendableDevices)
{ dev in
Button(dev.name) { startSend(to: dev.name) }
}
Button(t("common.cancel"), role: .cancel) { }
}
.alert(t("ios.send.noDevices"), isPresented: $showNoDevices) { }
}
private func sectionHeader(_ title: String) -> some View
{
HStack
{
Text(title)
.font(.subheadline)
.foregroundStyle(.secondary)
Spacer()
}
}
private func transferLink(_ item: TransferItem) -> some View
{
NavigationLink
{
TransferDetailView(sessionId: item.sessionId)
}
label:
{
TransferCardView(item: item)
}
.buttonStyle(.plain)
}
private func startSend(to device: String)
{
guard let url = pickedURL else { return }
engine.sendFile(to: device, fileURL: url)
pickedURL = nil
}
}
// 传输卡片:内容层,用语义材质背景而非玻璃(玻璃专属功能 / 导航层)。状态 / 阶段 / 速率
// 文案全部复用共享 transfer.* catalog(强约束②)。
struct TransferCardView: View
{
let item: TransferItem
var body: some View
{
VStack(alignment: .leading, spacing: 8)
{
HStack(spacing: 6)
{
Image(systemName: item.direction == "incoming"
? "arrow.down.circle" : "arrow.up.circle")
.foregroundStyle(.secondary)
Text(item.fileName)
.font(.headline)
.lineLimit(1)
Spacer()
}
if isActive(item)
{
ProgressView(value: progressOf(item))
}
Text(detailLine)
.font(.caption)
.foregroundStyle(.secondary)
}
.padding()
.frame(maxWidth: .infinity, alignment: .leading)
.background(.regularMaterial, in: RoundedRectangle(cornerRadius: 16))
}
// 卡片副行:活跃时显示阶段,否则显示终态;附文件大小,活跃且有速率时附速度。
private var detailLine: String
{
var parts: [String] = []
if isActive(item), let phase = item.phase
{
parts.append(t("transfer.phase.\(phase)"))
}
else
{
parts.append(t("transfer.state.\(item.state)"))
}
parts.append(formatBytes(item.fileSize))
if isActive(item), let rate = item.bytesPerSec, rate > 0
{
parts.append(t("transfer.bytesRate", [ "rate": formatBytes(Int(rate)) ]))
}
return parts.joined(separator: " · ")
}
}
// 传输详情(TODO #5):按 sessionId 从引擎实时态查当前记录,故进度 / 速率随传输刷新;
// 传输完成后从 transfers 移入 history,仍按 sessionId 命中。
struct TransferDetailView: View
{
@Environment(EngineController.self) private var engine
let sessionId: String
@State private var previewItem: PreviewItem?
private var item: TransferItem?
{
engine.transfers.first { $0.sessionId == sessionId }
?? engine.history.first { $0.sessionId == sessionId }
}
var body: some View
{
Group
{
if let item
{
List
{
Section
{
LabeledContent(t("ios.detail.direction"),
value: item.direction == "incoming"
? t("ios.transfer.incoming") : t("ios.transfer.outgoing"))
LabeledContent(t("ios.detail.peer"), value: item.peerName)
LabeledContent(t("ios.detail.state"), value: t("transfer.state.\(item.state)"))
if let phase = item.phase
{
LabeledContent(t("ios.detail.phase"), value: t("transfer.phase.\(phase)"))
}
if let mode = item.mode, !mode.isEmpty
{
LabeledContent(t("ios.detail.channel"), value: t("transfer.mode.\(mode)"))
}
}
Section
{
LabeledContent(t("ios.detail.size"), value: formatBytes(item.fileSize))
if let sent = item.bytesTransferred
{
LabeledContent(t("ios.detail.progress"),
value: "\(formatBytes(sent)) / \(formatBytes(item.fileSize))")
}
if let rate = item.bytesPerSec, rate > 0
{
LabeledContent(t("ios.detail.speed"),
value: t("transfer.bytesRate", [ "rate": formatBytes(Int(rate)) ]))
}
ProgressView(value: progressOf(item))
}
// 收到的文件「直达」:完成的接收传输可直接打开对应文件。
if item.direction == "incoming", item.state == "DONE",
let url = engine.receivedFile(matching: item.fileName)
{
Section
{
Button { previewItem = PreviewItem(url: url) }
label: { Label(t("ios.detail.openFile"), systemImage: "doc") }
}
}
// ICE 连接诊断:选中候选对含 "relay" 即 P2P 实走 TURN 中继(解释慢速)。
if item.iceConn != nil || item.iceLocal != nil
{
Section
{
if let conn = item.iceConn
{
LabeledContent(t("transfer.debug.iceConnection"), value: conn)
}
if let local = item.iceLocal, let remote = item.iceRemote
{
LabeledContent(t("transfer.debug.selectedPair"), value: "\(local) ⇄ \(remote)")
}
}
}
}
}
else
{
ContentUnavailableView(t("ios.transfer.empty"), systemImage: "tray")
}
}
.navigationTitle(item?.fileName ?? t("ios.detail.title"))
.navigationBarTitleDisplayMode(.inline)
.sheet(item: $previewItem) { item in FilePreviewSheet(item: item) }
}
}
struct DeviceListView: View
{
@Environment(EngineController.self) private var engine
@State private var revokeTarget: DeviceItem?
@State private var showRevoke = false
// 全部设备:含本机(本机标「本机」、不可移除自己——登出在设置页)。本机排最前。
private var allDevices: [DeviceItem]
{
engine.devices.sorted { a, b in isSelf(a) && !isSelf(b) }
}
private func isSelf(_ dev: DeviceItem) -> Bool { dev.name == engine.deviceName }
var body: some View
{
Group
{
if engine.devices.isEmpty
{
ContentUnavailableView(t("ios.devices.empty"), systemImage: "laptopcomputer.slash")
}
else
{
List
{
if !engine.deviceActionStatus.isEmpty
{
Text(engine.deviceActionStatus)
.font(.caption)
.foregroundStyle(.secondary)
}
ForEach(allDevices)
{ dev in
deviceRow(dev)
.contentShape(Rectangle())
.onTapGesture { if !isSelf(dev) { revokeTarget = dev; showRevoke = true } }
.swipeActions(edge: .trailing)
{
if !isSelf(dev)
{
Button(role: .destructive) { revokeTarget = dev; showRevoke = true }
label: { Label(t("ios.devices.remove"), systemImage: "trash") }
}
}
}
}
}
}
.navigationTitle(t("ios.tab.devices"))
.confirmationDialog(revokeTarget?.name ?? "", isPresented: $showRevoke, titleVisibility: .visible)
{
Button(t("ios.devices.remove"), role: .destructive)
{
if let target = revokeTarget { engine.revokeDevice(target.name) }
}
Button(t("common.cancel"), role: .cancel) { }
}
}
private func deviceRow(_ dev: DeviceItem) -> some View
{
Label
{
VStack(alignment: .leading, spacing: 2)
{
HStack(spacing: 6)
{
Text(dev.name)
if isSelf(dev)
{
Text(t("ios.devices.thisDevice"))
.font(.caption2)
.foregroundStyle(Color.cdropAccent)
}
}
Text(dev.online
? t("settings.online")
: t("settings.lastSeen", [ "time": relativeTime(dev.lastSeen) ]))
.font(.caption)
.foregroundStyle(.secondary)
}
}
icon:
{
Image(systemName: deviceSymbol(dev.type))
.foregroundStyle(dev.online ? Color.green : Color.secondary)
}
}
}
struct SettingsView: View
{
@Environment(EngineController.self) private var engine
@Environment(AuthManager.self) private var auth
// 设备名编辑态:持久在 UserDefaults,提交时落库。当前会话名烤在令牌里,改名下次登录生效。
@State private var deviceNameDraft = DeviceNameStore.value
var body: some View
{
List
{
if let user = auth.session?.user
{
Section
{
LabeledContent(t("ios.settings.user"), value: user.name)
TextField(t("ios.settings.deviceName"), text: $deviceNameDraft)
.submitLabel(.done)
.onChange(of: deviceNameDraft) { DeviceNameStore.value = deviceNameDraft }
Button(role: .destructive) { logout() }
label:
{
Text(t("ios.settings.logout"))
}
}
header:
{
Text(t("ios.settings.account"))
}
footer:
{
Text(t("ios.settings.deviceNameNote"))
}
}
Section(t("ios.settings.engine"))
{
LabeledContent(t("ios.settings.status"), value: engine.status)
LabeledContent(t("ios.settings.device"),
value: engine.deviceName.isEmpty ? "—" : engine.deviceName)
LabeledContent(t("ios.settings.signaling"), value: signalingState)
LabeledContent(t("ios.settings.presenceEvents"), value: "\(engine.presenceCount)")
LabeledContent(t("ios.settings.deviceCount"), value: "\(engine.devices.count)")
if !engine.lastEngineLog.isEmpty
{
VStack(alignment: .leading, spacing: 2)
{
Text(t("ios.settings.lastLog"))
.font(.caption)
.foregroundStyle(.secondary)
Text(engine.lastEngineLog)
.font(.caption2)
.foregroundStyle(.secondary)
.textSelection(.enabled)
}
}
}
Section
{
Button { engine.uploadClipboard() }
label: { Label(t("ios.clipboard.upload"), systemImage: "arrow.up.doc.on.clipboard") }
Button { engine.pullClipboard() }
label: { Label(t("ios.clipboard.pull"), systemImage: "arrow.down.doc") }
if !engine.clipboardStatus.isEmpty
{
Text(engine.clipboardStatus)
.font(.caption)
.foregroundStyle(.secondary)
}
}
header:
{
Text(t("ios.settings.clipboard"))
}
footer:
{
Text(t("ios.settings.clipboardNote"))
}
}
.navigationTitle(t("ios.tab.settings"))
}
// 信令连接态:已连接 / 重连中 / 未连接,三态便于区分「在努力连但连不上」与「根本没连」。
private var signalingState: String
{
if engine.hubConnected { return t("app.connected") }
if engine.hubReconnecting { return t("ios.settings.reconnecting") }
return t("ios.engine.disconnected")
}
// 登出:先复位引擎(断 SSE + 丢 WebView),再清会话回登录页(清 Keychain 由 auth 负责)。
private func logout()
{
engine.reset()
auth.logout()
}
}
// 收到的文件(TODO #2 数据侧延伸 + 解 send 测试无源文件):列 Documents 沙盒里已落盘的
// 接收文件,可 QuickLook 预览 / 系统分享 / 转发给在线设备 / 删除。这批文件也经 Info.plist
// 的 UIFileSharingEnabled + LSSupportsOpeningDocumentsInPlace 在 Files app「我的 iPhone」
// 下可见。转发用已有发送链路(engine.sendFile),故收到的文件可直接再发。
struct ReceivedFilesView: View
{
@Environment(EngineController.self) private var engine
@State private var files: [URL] = []
@State private var previewItem: PreviewItem?
@State private var pickedURL: URL?
@State private var showDevicePicker = false
@State private var showNoDevices = false
var body: some View
{
Group
{
if files.isEmpty
{
ContentUnavailableView(t("ios.files.empty"), systemImage: "tray")
}
else
{
List
{
ForEach(files, id: \.self)
{ url in
Button { previewItem = PreviewItem(url: url) }
label: { fileRow(url) }
.buttonStyle(.plain)
.swipeActions(edge: .leading)
{
Button { startSend(url) }
label: { Label(t("ios.transfer.outgoing"), systemImage: "paperplane") }
.tint(.cdropAccent)
}
.contextMenu
{
ShareLink(item: url)
{ Label(t("ios.files.share"), systemImage: "square.and.arrow.up") }
Button { startSend(url) }
label: { Label(t("ios.transfer.outgoing"), systemImage: "paperplane") }
Button(role: .destructive) { delete(url) }
label: { Label(t("transfer.action.delete"), systemImage: "trash") }
}
}
.onDelete { offsets in deleteAt(offsets) } // 尾滑删除 + EditButton 批量删
}
}
}
.navigationTitle(t("ios.files.title"))
.toolbar
{
if !files.isEmpty
{
ToolbarItem(placement: .topBarTrailing) { EditButton() }
}
}
.sheet(item: $previewItem) { item in FilePreviewSheet(item: item) }
.onAppear { reload() }
.onChange(of: engine.history.count) { reload() }
.confirmationDialog(t("ios.send.pickDevice"), isPresented: $showDevicePicker, titleVisibility: .visible)
{
ForEach(engine.sendableDevices())
{ dev in
Button(dev.name) { send(to: dev.name) }
}
Button(t("common.cancel"), role: .cancel) { }
}
.alert(t("ios.send.noDevices"), isPresented: $showNoDevices) { }
}
private func fileRow(_ url: URL) -> some View
{
HStack(spacing: 10)
{
Image(systemName: "doc")
.foregroundStyle(.secondary)
VStack(alignment: .leading, spacing: 2)
{
Text(url.lastPathComponent)
.lineLimit(1)
Text(fileSizeString(url))
.font(.caption)
.foregroundStyle(.secondary)
}
}
}
private func reload() { files = engine.receivedFiles() }
private func delete(_ url: URL)
{
engine.deleteReceivedFile(url)
reload()
}
private func deleteAt(_ offsets: IndexSet)
{
for i in offsets { engine.deleteReceivedFile(files[i]) }
reload()
}
private func startSend(_ url: URL)
{
pickedURL = url
if engine.sendableDevices().isEmpty { showNoDevices = true }
else { showDevicePicker = true }
}
private func send(to device: String)
{
guard let url = pickedURL else { return }
engine.sendFile(to: device, fileURL: url)
pickedURL = nil
}
}
// MARK: - 展示助手
// 是否仍在进行中(非终态):决定显示进度条 / 阶段还是终态标签。
private func isActive(_ item: TransferItem) -> Bool
{
return item.state != "DONE" && item.state != "FAILED" && item.state != "CANCELLED"
}
private func progressOf(_ item: TransferItem) -> Double
{
guard item.fileSize > 0, let sent = item.bytesTransferred else { return 0 }
return min(1.0, Double(sent) / Double(item.fileSize))
}
private func formatBytes(_ bytes: Int) -> String
{
let f = ByteCountFormatter()
f.countStyle = .file
return f.string(fromByteCount: Int64(bytes))
}
private func fileSizeString(_ url: URL) -> String
{
let size = (try? url.resourceValues(forKeys: [ .fileSizeKey ]))?.fileSize ?? 0
return formatBytes(size)
}
// lastSeen 是 epoch 秒(后端 devices.last_seen=now.Unix(),web formatRelative 也按秒);
// 转相对时间填进共享 settings.lastSeen 文案的 {{time}}。
private func relativeTime(_ epochSec: Double) -> String
{
let date = Date(timeIntervalSince1970: epochSec)
let f = RelativeDateTimeFormatter()
f.unitsStyle = .short
return f.localizedString(for: date, relativeTo: Date())
}
// 设备类型 → SF Symbol(对齐 web mapKind 的语义)。
private func deviceSymbol(_ type: String) -> String
{
let s = type.lowercased()
if s.contains("mac") || s == "darwin" { return "laptopcomputer" }
if s.contains("win") { return "pc" }
if s.contains("linux") { return "terminal" }
if s.contains("ipad") { return "ipad" }
if s.contains("ios") || s.contains("iphone") { return "iphone" }
if s.contains("android") { return "candybarphone" }
if s == "shortcut" { return "bolt" }
if s == "browser" { return "globe" }
return "desktopcomputer"
}
// 品牌主色(强约束③,与 web 主 accent 对齐):浅色 #644AC9 / 深色 #9580FF。后续接 Asset
// Catalog Color Set 作单一真源;此处先以动态 UIColor 落地。
extension Color
{
static let cdropAccent = Color(uiColor: UIColor
{ trait in
trait.userInterfaceStyle == .dark
? UIColor(red: 0.584, green: 0.502, blue: 1.0, alpha: 1)
: UIColor(red: 0.392, green: 0.290, blue: 0.788, alpha: 1)
})
}
+11
View File
@@ -0,0 +1,11 @@
# cdrop iOS 待办(择期处理)
2026-06-24 记录:扫码登录端到端验通后用户列出,留待后续处理(“A”任务覆盖的数据侧除外)。
1. **登录会话显示修复**(未做):iOS 客户端在(web)“登录会话”列表中误显示为“本地账号”,应按 device_type 显示“iOS 客户端”(`deviceType.ios`)。疑似列表按会话 kind(self / 扫码自签)而非设备类型贴标签——需让扫码自签会话也带 device_type 标签呈现。
2. **设备 / session 管理非空壳**(数据侧已做,管理操作未做):列表数据已由“A”接真实 presence(`DeviceListView` 读 `engine.devices`)。剩余:会话管理操作(查看 / 吊销其他会话等)。
3. **禁止临时登录**(未做):iOS app 不应允许 guest / 临时(once)登录;扫码登录应强制 full / persist(“信任此设备”)scope。当前后端 qr 默认 guest、`AuthManager` 也写死 `scope: "guest"`——需 iOS 侧走 full。
4. **退出登录**(已做):设置页“账户”区已加登出(destructive),清 Keychain 会话 + 复位引擎 + 回登录页(`SettingsView.logout` → `auth.logout()` + `engine.reset()`)。
5. **传输详情**(已做):`TransferDetailView` 按 sessionId 实时查引擎态,展示方向 / 对端 / 状态 / 阶段 / 通道 / 大小 / 进度 / 速度;卡片可点入。剩余可选:ICE 候选明细(`transfer.debug.*` catalog 已有,未接)。
> **A 已完成**:引擎 presence / 传输 → 原生 UI 数据绑定(三屏全替 demo)+ `TransferDetailView` + Keychain 会话持久化 + 登出 + i18n 插值。闭合 #4 / #5 与 #2 数据侧;prod 已部署 `engine.html`(presence 生效)。**剩余择期**:#1(device_type 标签)、#3(强制 full 登录)、#2 会话管理操作、#5 ICE 明细(可选)。全部仍未提交(按用户序:真机测完再 commit)。
+52
View File
@@ -0,0 +1,52 @@
name: CDrop
options:
bundleIdPrefix: net.commilitia
deploymentTarget:
iOS: "26.0"
createIntermediateGroups: true
settings:
base:
SWIFT_VERSION: "5.0"
CODE_SIGNING_ALLOWED: "NO"
CODE_SIGNING_REQUIRED: "NO"
targets:
CDrop:
type: application
platform: iOS
deploymentTarget: "26.0"
sources:
- path: Sources
info:
path: Sources/Info.plist
properties:
CFBundleDisplayName: Commilitia Drop
# 声明支持的语言,使系统控件(EditButton 的「编辑」、滑动删除的「删除」等系统字符
# 串)跟随设备语言本地化,而非永远英文。开发区域设简体。
CFBundleDevelopmentRegion: zh-Hans
CFBundleLocalizations:
- zh-Hans
- zh-Hant
- en
UILaunchScreen: {}
# 让接收文件落地的 Documents 目录在 Files app「我的 iPhone / Commilitia Drop」下
# 可见、可就地打开(收到的文件有处可开、可被其他 app 取用)。
UIFileSharingEnabled: true
LSSupportsOpeningDocumentsInPlace: true
NSAppTransportSecurity:
NSAllowsLocalNetworking: true
NSLocalNetworkUsageDescription: "Commilitia Drop 需要访问本地网络以发现同内网设备并建立直连传输。"
NSBonjourServices:
- "_cdrop._tcp"
NSCameraUsageDescription: "Commilitia Drop 使用相机扫描二维码登录新设备。"
settings:
base:
PRODUCT_BUNDLE_IDENTIFIER: net.commilitia.cdrop
TARGETED_DEVICE_FAMILY: "1,2"
ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon
schemes:
CDrop:
build:
targets:
CDrop: all
run:
config: Debug