鉴权并入 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:
@@ -0,0 +1,8 @@
|
||||
# CDrop.xcodeproj 由 xcodegen 从 project.yml 生成——不提交,用 `xcodegen generate` 重建。
|
||||
CDrop.xcodeproj/
|
||||
|
||||
# Xcode 构建产物
|
||||
build/
|
||||
DerivedData/
|
||||
*.xcuserstate
|
||||
.DS_Store
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
{
|
||||
// 无需更新。
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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": "打开文件"
|
||||
}
|
||||
@@ -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": "開啟檔案"
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
@@ -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)。
|
||||
@@ -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
|
||||
@@ -0,0 +1,60 @@
|
||||
# cdrop web ↔ iOS 同步检查单
|
||||
|
||||
> 机制,不是清单快照。web(React)与 iOS(SwiftUI)视图层**有意分叉**(液态玻璃决定不可能像素一致)。一致性**优先靠强约束同步**(单一真源、机械消费、漂移不可能,见 `ios/PLAN.md` §5);本文件**只装强约束管不住、需人判定的残余**。
|
||||
> 关联:`ios/PLAN.md` 决策 D / §5。
|
||||
|
||||
---
|
||||
|
||||
## 何时跑(触发)
|
||||
|
||||
- 任一 PR 改了用户可见 UI / 流程 / 元素 → 过下方“残余项”。
|
||||
- 改了后端 API 字段 → 过残余项 3。
|
||||
- 发布任一端前 → 全表过一遍 + 更新“漂移日志”。
|
||||
|
||||
---
|
||||
|
||||
## 残余项(强约束之外,逐项人工核对)
|
||||
|
||||
### 1. 元素 / 屏 / 功能存在性对等
|
||||
|
||||
任一端新增用户可见的元素 / 屏 / 功能 → 确认各端都有对应(或登记为残余项 4 的“允许分叉”)。机制无法逼一个 SwiftUI View 自动长出,只能此处提醒。
|
||||
|
||||
### 2. 非引擎驱动的新流程 / 状态
|
||||
|
||||
不在共享传输引擎里的纯 UI 流程 / 状态(设置项、登录分支、空 / 错 / 加载态)→ 两端手对。**引擎驱动的传输行为不在此列**(强约束已保,无需手对)。
|
||||
|
||||
### 3. 后端 API 字段变更
|
||||
|
||||
后端改了请求 / 响应字段 → 提醒**两端瘦客户端都跟改**(后端是契约源,但客户端类型手维护)。
|
||||
|
||||
### 4. 允许分叉登记(明确 OK 不一致)
|
||||
|
||||
- **视觉材质**:液态玻璃 vs Theme B。
|
||||
- **导航外壳**:原生 `NavigationStack` / `TabView` vs web 布局。
|
||||
- **字体 / 排版**:系统字体 + Dynamic Type vs Orbit Gothic / Noto;间距 / 圆角 / 字阶各用平台母语。
|
||||
- **平台专属**:控制中心控件、Share Extension(仅 iOS);Web Push 订阅 UI、桌面设置区块(非 iOS)。
|
||||
|
||||
---
|
||||
|
||||
## PR 自检片段(复制进 PR 描述)
|
||||
|
||||
- [ ] 新增 / 改动的用户可见元素 / 屏 / 功能,各端都有或已登记允许分叉?
|
||||
- [ ] 新增非引擎流程 / 状态,两端已对齐?
|
||||
- [ ] 改了后端字段?两端客户端都跟改?
|
||||
- [ ] 新增文案走了 i18n 单一源(非硬编码)?
|
||||
|
||||
---
|
||||
|
||||
## 漂移日志
|
||||
|
||||
记录每次“已同步”的两端基准,使漂移可见:
|
||||
|
||||
| 日期 | web commit | iOS commit | 同步内容 |
|
||||
|---|---|---|---|
|
||||
| 2026-06-24 | 建档(iOS 未开工) | — | 初始:架构 A 定、引擎 / i18n / 品牌 / API 四强约束确立,检查单建立 |
|
||||
| 2026-06-24 | 引擎入口 net/ios.ts + engine/main.ts(未提交) | — | web 侧无头引擎入口 + iOS 桥 + 接收 sink 接入 + Vite 多入口;tsc / build 双过 |
|
||||
| 2026-06-24 | — | ios/CDrop/ scaffold(未提交) | iOS 原生壳:液态玻璃 TabView + 离屏引擎 WebView 宿主 + 桥 + 落盘管理器;编过 iphonesimulator26.5 |
|
||||
| 2026-06-24 | — | ios/CDrop/ 登录+发送(未提交) | 扫码登录新设备侧(AuthManager + LoginView,对 prod qr/start 验通显码)+ 发送路径(cdrop-file scheme 供文件 + 文件选择器)+ 会话注入引擎 boot |
|
||||
| 2026-06-24 | i18n locales + scripts/emit-i18n.mjs(未提交) | ios/CDrop i18n(未提交) | **强约束②落地**:web locales 加 `ios.*` 键 → `emit-i18n.mjs` 产出 JSON → iOS `t()` 读(loader I18n.swift)。用户可见**去代号 cdrop**(品牌用 `app.brand`=Commilitia Drop),显示名改 Commilitia Drop。**改 locale 后须重跑 `node web/scripts/emit-i18n.mjs`**(已记入脚本头) |
|
||||
| 2026-06-24 | engine/main.ts 加 presence 推送 + 初始快照(未提交) | RootView / EngineController / AuthManager 真数据绑定(未提交) | **A 任务**:引擎订阅 `store.devices` 推 `presence` 事件 + boot 后补发 presence/transfers 初始快照;原生 `EngineController` 解码进 `@Observable` devices/transfers/history;`RootView` 三屏全替 demo→真数据 + 新增 `TransferDetailView`(TODO #5)。i18n 加 `ios.detail.*` / `ios.settings.account|user|logout` / `ios.transfer.empty|incoming|outgoing` / `ios.send.noDevices` / `ios.devices.empty`,传输态文案复用共享 `transfer.state|phase|mode|bytesRate`。iOS `t()` 加 `{{name}}` 插值变体对齐 web。Keychain 会话持久化 + 设置页登出(闭合 TODO #4)。web tsc/build + xcodebuild iphonesimulator26.5 双过,模拟器三屏空态截图验。**已部署 prod**:`engine.html` → `engine-BB8iav7K.js`(含 presence 推送),真会话下即生效 |
|
||||
| 2026-06-24 | engine/main.ts:transfers 推送节流 ~3Hz + toWire 加 ice 摘要(未提交,**待部署**) | EngineController 手动容忍解析 + ICE 字段;RootView 详情页 ICE 行 + 收到文件视图 + 设备名编辑 + 设备计数;DownloadManager listFiles/delete;新 DeviceNameStore / QuickLookPreview;project.yml 加文件共享键 + 品牌化权限串(未提交) | **首轮真机测试反馈修复**:① 速度数字 ~10Hz 闪 → 节流活跃传输推送到 3Hz(iOS-only,亦降 P2P 接收主线程抖动);② 设备列表空 → 原生改手动容忍解析(严格 JSONDecoder 遇 bool/int 失配整组失败的隐患),加「已知设备」计数诊断;③ 收到文件无处开 / Files 不可见 / 阻塞发送 → `UIFileSharingEnabled`+`LSSupportsOpeningDocumentsInPlace`(Documents 已是落地目录)+ 应用内「收到的文件」视图(QuickLook 预览 / 系统分享 / 转发在线设备 / 删除);④ 设备名不可改 → `DeviceNameStore`(UserDefaults)+ 设置页编辑框(后端令牌烤名、改名下次登录生效);⑤ P2P <100KB/s(relay 1MB/s)→ 详情页暴露选中 ICE 候选对诊断是否 TURN 中继(不盲改共享 p2p.ts 水位)。i18n 加 `ios.files.*`/`ios.settings.deviceName|deviceNameNote|deviceCount`,ICE 行复用 `transfer.debug.*`。web 双过 + xcodebuild 编过 + 模拟器设置 / 传输屏截图验。**已部署 prod**:`engine.html` → `engine-BC6mZ9zP.js`(含节流 + ice 摘要) |
|
||||
+198
-69
@@ -1,119 +1,248 @@
|
||||
# cdrop iOS 客户端实施计划
|
||||
|
||||
> WKWebView 原生壳 · 复用 `web/` bundle · APNs 推送 + 原生剪贴板 / 分享
|
||||
> 状态:**规划中,待 Apple Developer 账号**。本文是“账号到位即可开工”的准备文档——把架构、里程碑、以及哪些活现在就能干 / 哪些硬等签名讲清楚。
|
||||
> 关联:根 `README.md`、`desktop/PLAN.md`(同“原生壳复用同一份 UI”思路的先例)、`internal/push`(Web Push 服务端,APNs 通道与之并列)。记忆 `cdrop-state` / `cdrop-deploy-pointers`。
|
||||
> 原生 SwiftUI 界面(液态玻璃)· 离屏无头 WebView 复用传输引擎 · 控制中心剪贴板 + Share Extension + APNs
|
||||
> 状态:**架构已定(2026-06-24 修订)**,账号无关部分即刻可开工;真机 / 推送 / 扩展实测硬等 Apple Developer 账号。
|
||||
> 关联:根 `README.md`、`desktop/PLAN.md`、`internal/push`、`ios/PARITY.md`(web↔iOS 同步检查单)。记忆 `cdrop-state` / `cdrop-deploy-pointers`。
|
||||
|
||||
---
|
||||
|
||||
## 0. 范围、定位与关键决策
|
||||
## 0. 定位与关键决策(2026-06-24 修订)
|
||||
|
||||
**定位**:与桌面端同理——iOS 客户端不是“另写一套 UI”,而是“带原生外壳的同一份 UI”。UI 复用 `web/src` 的 React 应用(WKWebView 加载),价值全在原生层补浏览器在 iOS 上做不到的事。
|
||||
定位变更:iOS 客户端**不再**是“web UI 装进 WebView”,而是“**原生 SwiftUI 界面 + 复用的无头传输引擎**”。起因——新增“服从 iOS 设计语言(液态玻璃)”硬要求,与“WebView 里跑 React UI”直接冲突:液态玻璃是 iOS 26 系统级材质,只有原生 SwiftUI 的 `.glassEffect` 拿得到,网页只能 CSS 仿。故 UI 转原生,但**保留引擎复用**。
|
||||
|
||||
**为什么要原生壳(而非仅靠 PWA)**:任务 2 的 Web Push 已让 iOS 16.4+ 已安装的 PWA 能收推送,所以“通知”本身不必等原生。原生壳的真正价值在三处 iOS 网页端做不到的能力:
|
||||
### 决策 A(修订):架构 = 原生 SwiftUI 壳 + 离屏无头 WebView 引擎
|
||||
|
||||
1. **原生剪贴板**:iOS 对网页 `navigator.clipboard` 限制极严(仅用户手势触发、无法后台监听),而 cdrop 的核心是剪贴板同步。原生 `UIPasteboard` 才能做到顺畅读写。
|
||||
2. **Share Extension**:从任意 app 的分享菜单直接“发送到 cdrop”,网页无法注册。
|
||||
3. **后台与通知**:原生 APNs + 后台执行窗口,配合通知点击深链回 app。
|
||||
取代 2026-06-18 的“WebView 复用 React UI”决策。
|
||||
|
||||
**范围**:常驻 iOS 客户端,复用 web UI + 原生层(剪贴板 / 分享 / APNs / 登录态 Keychain)。
|
||||
- 所有可见 UI 用**原生 SwiftUI**(iOS 26 液态玻璃;旧版优雅回落,见 §7)。
|
||||
- 文件传输的 **P2P + relay + 会话 + 信令逻辑 = 现有 `web/src` 的 JS 引擎本体**,跑在**离屏(无 UI)WKWebView** 里,原生经桥驱动。零重写、保住同内网 P2P、行为与 web 天然同构(上轮刚修的“按已交付字节算进度”“抽干再标完成”等自动一致)。
|
||||
- **兜底方案 B**:若真机上无头 WKWebView 跑 WebRTC DataChannel 不稳,退“全原生 + relay-only 重写 + 放弃 P2P”。该验证留到真机可测时优先做(R-iOS-3)。
|
||||
|
||||
**非范围(推后)**:iPad 专属布局、Apple Watch、App Clip、原生 SwiftUI 重写 UI(明确不做——UI 复用 web)、Android。
|
||||
**桥方向与桌面相反(关键,易踩坑)**:
|
||||
|
||||
### 决策 A:架构 = WKWebView 原生壳 + APNs(已定)
|
||||
- 桌面(Wails):React UI 在 WebView 顶层,调 DOWN 到 Go 拿原生能力。
|
||||
- iOS(arch A):SwiftUI 原生在顶层,调 DOWN 到离屏 WebView 的 JS 引擎跑传输。
|
||||
- 即 iOS 的 WebView 是“**无头 worker**”,不是“UI 容器”。需要一个**独立的无头引擎入口**(不加载 React、只装传输模块 + RPC 桥),而非复用 React app 再隐藏 UI。
|
||||
|
||||
用户在 2026-06-18 的选型中选定**WKWebView 原生壳 + APNs**(而非 PWA-only,也非全原生 SwiftUI)。理由:复用现有 web UI、与桌面瘦客户端架构一致、工作量中等,同时拿到原生剪贴板 / 分享 / 后台。
|
||||
### 决策 B(修订):分发 = 非 App Store,但仍守苹果开发标准
|
||||
|
||||
### 决策 B:硬等 Apple Developer 账号
|
||||
- **必须付费 Apple Developer Program(ADP,$99/年)**:cdrop 四项能力——APNs、App Groups、Keychain 共享、Associated Domains——**免费 Personal Team 一律不支持**(账号策略,非 OS 封锁),且免费证书 7 天过期。故“等账号”=等**付费 ADP**。
|
||||
- **分发走 Ad Hoc**(最贴近桌面 Dropbox 旁加载的等价物):100 台 / 年上限、设备 UDID 须预先登记、profile 年度更新。自用(自己几台设备)足够;比桌面“下载即用”多一道 UDID 登记摩擦——iOS 无更省的合规途径。
|
||||
- 备选 **TestFlight**:体验最好(无需 UDID、可达万级),但**需经 Apple Beta 审核**、build 90 天过期——与“不上架”初衷部分相悖,留作扩面再议。
|
||||
- **Enterprise(ADEP $299/年)**:无设备上限,但仅限“内部员工”,对外分发违反协议、证书可被吊销——不取。
|
||||
- **不过审 ≠ 不守标准**:仍遵守 HIG、液态玻璃规范、App Intents / WidgetKit 约定、隐私(权限说明 / just-in-time 请求)、entitlements 最小化。原 R-iOS-5(Guideline 4.2 过审风险)在 Ad Hoc 下作废;若走 TestFlight 则 Beta 审核仍在。
|
||||
|
||||
iOS 的真机调试、TestFlight / App Store 分发、APNs 全部要求 Apple Developer Program 成员资格 + 签名证书 + Provisioning Profile。**账号到位前**只能:写服务端 APNs 通道(账号无关的代码结构)、scaffold Xcode 工程、在模拟器跑 UI(模拟器不收 APNs)。**账号到位后**才能:真机推送、剪贴板 / 分享扩展真机验证、分发。
|
||||
### 决策 C(新):剪贴板 = 控制中心两控件,零推送
|
||||
|
||||
- 通道用**持久的 `/api/clipboard`**(REST、LWW、版本探针、作用域令牌可访问),不用 ephemeral 的 `/api/message`。
|
||||
- iOS 18 控制中心控件(`ControlWidget` + App Intent)提供“上 / 下”两件:**上** = 读 `UIPasteboard` → `PUT /api/clipboard`;**下** = `GET` → 写 `UIPasteboard`。用户主动点、**零推送**(频繁剪贴板变动推送干扰极大)。
|
||||
- 平台天花板:程序化**读**剪贴板弹一次系统横幅(写不弹);后台无法监听复制。故“后台自动同步”降级为“一键发送 / 一键拉取”。
|
||||
- 这条链**纯原生 URLSession,不依赖无头 WebView**——与文件传输引擎彻底解耦,互不拖累。
|
||||
|
||||
### 决策 D(新):视图层分叉,先吃强约束、残余进检查单
|
||||
|
||||
接受 web(React)与 iOS(SwiftUI)视图层是两套实现(液态玻璃决定不可能像素一致)。一致性按两层管,详见 §5:
|
||||
|
||||
- **强约束同步**(机制保证、漂移不可能):① 传输行为靠 arch A 共享引擎;② i18n 文案单一源(iOS 读 web 那份 JSON);③ 品牌资产 + 主色单一源;④ 后端 API 即共享契约。
|
||||
- **检查单**(机制管不住、人判定的残余):元素 / 屏 / 功能存在性对等、非引擎驱动的新流程、后端字段变更提醒、允许分叉登记。
|
||||
- **明确不做**:设计令牌 codegen 管线(过度工程,且与液态玻璃系统材质 / 语义色 / SF Symbol 母语相冲)——间距 / 圆角 / 字阶归“允许分叉”,仅品牌主色 + logo 作共享源。
|
||||
|
||||
### 决策 E(新):权限策略 = 持久权限首启显式询问 + 缺失则降级或阻塞提示
|
||||
|
||||
- **持久型权限(本地网络、相机)在首次启动的引导中显式询问**:先用一屏说明用途、再触发系统弹窗——既满足“首启显式获取”,又不踩 HIG 的“无上下文裸弹窗”反模式。这类权限**一次授予长期持久**(iOS 18 有重装后状态不重置的已知 bug,需 UI 兜底)。
|
||||
- **缺失 / 拒绝时按特性二选一**:① **有兜底则降级**——本地网络→回退中继并提示“仅中继、无法同内网直连”;通知→无后台推送但前台仍可用。② **无兜底则提示“无权限”并阻塞该特性**——相机→扫码不可用,提示 + 跳系统设置;阻塞的是**该特性**、非整个 app。
|
||||
- 不拿任一权限当**整个 app** 的门槛(HIG 5.1.2)。详见 §9.2 逐权限映射。
|
||||
|
||||
---
|
||||
|
||||
## 1. 与任务 2(Web Push)服务端模型的关系
|
||||
## 1. 后端现状(决定 iOS 能做什么,2026-06-24 扫描)
|
||||
|
||||
任务 2 已落地服务端推送底座,iOS 的 APNs 通道**复用同一套设计**,只换发送后端:
|
||||
|
||||
| 维度 | Web Push(已建) | iOS APNs(待建) |
|
||||
| 能力 | 结论 | 对 iOS 的含义 |
|
||||
|---|---|---|
|
||||
| 凭据 | 自签 VAPID 公私钥 | Apple APNs Auth Key(`.p8` + Key ID + Team ID) |
|
||||
| 订阅令牌 | endpoint + p256dh/auth | APNs device token(每安装一枚) |
|
||||
| 存储 | `push_subscriptions` 表 | 同表加 `platform` 列区分,或新 `apns_tokens` 表 |
|
||||
| 收件定位 | `(user_id, device_name)` | 同上——iOS 设备登记 `device_type=ios` |
|
||||
| 触发门控 | `Hub.SendTo` 未投递才推 | **完全相同**——前台 WebView 在线即走 SSE,挂起 / 关闭才 APNs |
|
||||
| 文案渲染 | 服务端按订阅 `locale` 渲染 | 同上,复用 `internal/push` 的 `localize` |
|
||||
| 文件传输 | 纯实时(P2P / 实时 relay,relay 是内存环 64MiB、2 分钟空闲释放),**零落盘暂存** | Share Extension 不靠服务端暂存;接收方须同时在线 |
|
||||
| 剪贴板 `/api/clipboard` | 持久 LWW + REST,作用域令牌可访问 | 决策 C 的纯原生链路 |
|
||||
| 消息 `/api/message` | 临时、不持久、不可轮询 | 文本同步走剪贴板通道,消息仅尽力而为实时 |
|
||||
| 作用域令牌 | 仅 `clipboard` 一个 scope,`rejectScoped` 挡死 `/transfer` | 发送须完整会话;Share Extension 用交接而非自带令牌 |
|
||||
| 推送 | 仅 Web Push(VAPID),无 APNs | APNs 须新建(§3) |
|
||||
| 原生鉴权 | IdP bearer(RS256)/ 扫码自签会话;设备隐式登记(`X-Device-Name`) | 原生 app 可正常登录持会话 |
|
||||
|
||||
**关键**:“页面打开用应用内提示,否则系统通知”的判定在 iOS 上=**app 是否前台**。前台时 WebView 的 SSE 在线、`Hub.SendTo` 投递成功 → 应用内呈现;app 挂起 / 退后台 → SSE 断 → `SendTo` 失败 → 服务端发 APNs。与 Web 端逻辑同构,只是“在线”的物理含义不同。
|
||||
|
||||
> ⚠️ iOS WebView 退后台后会被系统挂起,SSE 必断。所以 iOS 的后台通知**只能**靠服务端 APNs,**不能**像桌面那样靠常驻进程里的 JS(桌面 Go 进程常驻、iOS 没有等价物)。这正是必须建 APNs 通道、而非复用桌面 `notifyNativeIfBackground` 思路的原因。
|
||||
Share Extension 落法(据上):**抓文件 → 写 App Group 容器 → 深链唤起主程序**选设备发送(接收方须在线、大文件主程序稳跑、扩展不持完整令牌更安全)。
|
||||
|
||||
---
|
||||
|
||||
## 2. 里程碑
|
||||
## 2. 桥协议(arch A 的接口契约,账号无关、可先冻结)
|
||||
|
||||
原生 ↔ 无头 JS 引擎的 RPC:`WKScriptMessageHandler`(JS→Swift)+ `evaluateJavaScript`(Swift→JS),类比 Wails 的 `EventsEmit` / bound methods,但**方向相反**(原生在顶层)。
|
||||
|
||||
- **平台判定**:`isIOSShell()`(`web/src/net/ios.ts`)查 `window.webkit.messageHandlers.cdropEngine` 在场(类比 `isDesktop()` 查 `window.go.main.App`)。无头入口(`web/src/engine/main.ts`)据此装配桥。
|
||||
- **注入**:原生在加载 bundle 前经 WKUserScript 注入 `window.__CDROP_BOOT__ = { session, device_name, api_base, device_type:"ios" }`,store 在 import 时即水合(沿桌面同一注入范式);JS 永不持有长寿命密钥(refresh_token 只在原生 Keychain)。
|
||||
- **原生 → 引擎命令**(原生 `evaluateJavaScript` 调 `window.__cdropEngineEvent(name, payload)`):`sendFile { target, url, name, size, type? }`、`cancelTransfer { sessionId }`、`switchToRelay { sessionId }`、`session { access_token, user }`(续期令牌)、`shutdown`。
|
||||
- **引擎 → 原生**:① 单向通知(`postMessage({ notify, payload })`):`ready` / `transfers` / `transferDone` / `sendStarted` / `error`;② 请求 / 响应 RPC(`postMessage({ id, method, payload })`,原生回调 `window.__cdropEngineResolve(id, ok, value)`):接收落盘 `saveDownload` / `beginDownload` / `appendDownload` / `finalizeDownload` / `abortDownload`(data 为 base64,复用桌面同形)。剪贴板不走此桥(决策 C 纯原生)。
|
||||
- **复用边界**:接收侧字节桥沿用桌面已验证的 base64 分批、有界内存(iOS 上限更小,64MB/16MB 避 jetsam);**发送侧经 `url` 取回文件为唯一新实现点**——当前整文件 fetch 进内存(小 / 中文件可行),大文件按 slice 向原生拉取是真机优化点(R-iOS-4)。
|
||||
- **已落地(web 侧,账号无关、`tsc`/`vite build` 双过、对现网零影响)**:`net/ios.ts`(桥 + `isIOSShell` + 接收封装 + `notifyNative`)、`engine/main.ts`(无头入口)、`incomingSink.ts`(通用混合槽路由 iOS)、`engine.html` + `vite.config.ts` 多入口。
|
||||
- **加载源(关键)**:无头引擎**必须从安全上下文加载**——`RTCPeerConnection` 在非安全 origin(如自定义 `cdrop://` scheme,WebKit 视作 non-secure context)下可能被禁。故引擎页走 `https://`:线上 `drop.commilitia.net` 或应用内本地 https 服务的静态 bundle;**自定义 scheme 仅用于 `WKURLSchemeHandler` 给 JS 流式喂文件字节**,不可用它直载引擎页。见 R-iOS-3。
|
||||
|
||||
---
|
||||
|
||||
## 3. 服务端 APNs 通道(账号无关的结构,`.p8` 到位再落代码)
|
||||
|
||||
新增 `internal/apns` 与 `internal/push` 并列,只换发送后端:
|
||||
|
||||
- **库**:APNs 走 HTTP/2 + JWT(`.p8` 的 ES256 签名),可用 `github.com/sideshow/apns2`,或自手写(与现有 go-jose 一致)。
|
||||
- **配置**(koanf,`CDROP_APNS_*`,全可选,缺失即 iOS 推送惰性关闭):`APNS_KEY_PATH`、`APNS_KEY_ID`、`APNS_TEAM_ID`、`APNS_TOPIC`(bundle id)、`APNS_ENV`。
|
||||
- **存储**:`push_subscriptions` 加 `platform` 列,iOS 行存 APNs token。`SubscriptionID` 仍用 token 哈希保幂等 upsert。
|
||||
- **端点**:`POST /api/push/apns/register {device_token, locale}`(鉴权 + `X-Device-Name`,与 `/api/push/subscribe` 同组)。
|
||||
- **触发**:在 `transfer:incoming` / `transfer:done|failed` / `message` 的“未投递”分支按 `platform` 分流。文案复用 `internal/push` 的 `localize`。**注意——剪贴板不触发推送**(决策 C 走控制中心拉取)。
|
||||
|
||||
代码结构账号无关,但真发 / 真测要 `.p8`,故与 iOS 客户端同期落(账号到位后),避免悬空实现。
|
||||
|
||||
---
|
||||
|
||||
## 4. 里程碑(修订)
|
||||
|
||||
| 里程碑 | 内容 | 账号依赖 |
|
||||
|---|---|---|
|
||||
| **I0** 账号与证书前置 | Apple Developer Program 加入;App ID(`net.commilitia.cdrop`,与桌面 bundle id 一致或加 `.ios` 后缀);APNs Auth Key `.p8`;Provisioning Profile | **硬等账号** |
|
||||
| **I1** WKWebView 壳骨架 | Xcode 工程;WKWebView 加载 `https://drop.commilitia.net`(或内嵌 `web/dist`);注入 `X-Device-Type: ios`;基础生命周期 | 模拟器可跑;真机等账号 |
|
||||
| **I2** 登录态 | `ASWebAuthenticationSession` 跑 OIDC PKCE(系统级安全浏览器,复用 web client);refresh_token 存 **iOS Keychain**,JS 永不持有(与桌面 Go keyring、web HttpOnly cookie 同策略) | 模拟器可跑 |
|
||||
| **I3** APNs 推送(核心) | 原生注册 APNs device token → 上报 `POST /api/push/apns/register`;**服务端 APNs 发送通道**(见 §3);通知点击深链回 app | 真机硬等账号 |
|
||||
| **I4** 原生剪贴板 | `UIPasteboard` 读(用户手势 / 前台)+ 写;与云剪贴板 LWW 模型对接(复用 `/api/clipboard`);说明 iOS 后台无法监听的限制 | 模拟器部分可跑 |
|
||||
| **I5** Share Extension | 独立 extension target:从任意 app 分享文件 → 唤起 cdrop 发送流程 | 真机硬等账号 |
|
||||
| **I6** 打磨与分发 | 后台执行窗口;图标 / 启动屏(复用品牌资产);TestFlight → App Store | 硬等账号 |
|
||||
| **I0** 账号与证书前置 | **付费 ADP($99/年,4 项能力全要)**;主 app + 各扩展 App ID(扩展 id 须主 app 前缀);开启 Push / App Groups / Keychain / Associated Domains;注册 App Group ID;APNs Auth Key `.p8`(**仅一次下载**);Distribution 证书 + Ad Hoc Profile(登记设备 UDID) | 硬等付费账号 |
|
||||
| **I1** 原生骨架 + 无头引擎装配 | Xcode 工程(SwiftUI);离屏 WKWebView 载无头引擎入口;`isIOSShell` 注入桥 | 模拟器可跑 UI;引擎入口 web 侧账号无关 |
|
||||
| **I2** 登录态 | `ASWebAuthenticationSession` PKCE / 扫码;refresh_token 存 Keychain;session 注入 JS | 模拟器可跑 |
|
||||
| **I3** 传输(核心) | 无头引擎桥(send 自定义 scheme 流式 / receive 复用 `Begin`-`Append`-`Finalize`)+ 原生传输 UI(液态玻璃、含进度 / 速度 / 状态) | 真机硬等账号 |
|
||||
| **I4** 剪贴板 | 控制中心两控件 + App Intents;纯原生 `/api/clipboard` 读写 + 版本探针 | 控件真机硬等账号;REST 链路可先写 |
|
||||
| **I5** Share Extension | 抓文件 → App Group → 深链主程序选设备发送 | 真机硬等账号 |
|
||||
| **I6** APNs | 服务端通道(§3)+ 原生注册;不含剪贴板 | `.p8` + 真机硬等账号 |
|
||||
| **I7** 打磨与分发 | 后台窗口;图标 / 启动屏(复用品牌资产);旁加载分发 | 硬等账号 |
|
||||
|
||||
---
|
||||
|
||||
## 3. 服务端 APNs 通道(账号无关的结构,可先设计)
|
||||
## 5. 一致性:强约束同步 vs 检查单(决策 D 展开)
|
||||
|
||||
新增 `internal/push` 的兄弟实现 `internal/apns`(或 `push.Sender` 旁加一个 APNs 后端),与 Web Push 并列:
|
||||
**检查单是兜底,不是默认。** 先尽量用强约束(单一真源、两端机械消费、漂移不可能),管不住的残余才进检查单(见 `ios/PARITY.md`)。
|
||||
|
||||
- **库**:APNs 走 HTTP/2 + JWT(`.p8` 的 ES256 签名),可用 `github.com/sideshow/apns2` 之类,或自手写(与现有 go-jose 一致)。
|
||||
- **配置**(koanf,`CDROP_APNS_*`,全可选——缺失即 iOS 推送惰性关闭,类同 VAPID):`APNS_KEY_PATH`(`.p8`)、`APNS_KEY_ID`、`APNS_TEAM_ID`、`APNS_TOPIC`(bundle id)、`APNS_ENV`(sandbox / production)。
|
||||
- **存储**:`push_subscriptions` 加 `platform TEXT NOT NULL DEFAULT 'web'` 列 + iOS 行存 APNs token(复用 `endpoint` 字段存 token,或加专列)。`SubscriptionID` 仍用 token 的哈希保持幂等 upsert。
|
||||
- **端点**:`POST /api/push/apns/register {device_token, locale}`(鉴权 + `X-Device-Name`,与 `/api/push/subscribe` 同组)。
|
||||
- **发送**:在 `transfer:incoming` / `message` / `transfer:state(DONE|FAILED)` 的“未投递”分支里,按 `platform` 分流到 Web Push 或 APNs。文案复用 `internal/push` 的 `localize`(已支持 zh-CN / zh-TW / en-US)。
|
||||
### 强约束(机制保证)
|
||||
|
||||
> 这部分**代码结构账号无关**,但 APNs 发送要 `.p8` 才能真发 / 真测,故**与 iOS 客户端同期做**(账号到位后),避免落一坨无法验证的代码。设计先定在此。
|
||||
1. **传输行为 → arch A 共享 JS 引擎。** 整套状态机 / 进度 / 完成 / 错误 / 协议无法漂移,零额外成本。
|
||||
2. **i18n 文案 → 单一源。** iOS 原生侧薄 loader 读 web 那份 i18n JSON(随 bundle 或从服务端取),不写转换器、不做 `.xcstrings` 镜像。缺键即露。
|
||||
3. **品牌资产 + 主色 → 单一源。** 资产沿用 Dropbox 那套(web+desktop 已接);品牌主色 + logo 作小共享常量。
|
||||
4. **后端 API → 后端即共享契约。** 两端瘦客户端对齐同一 JSON 形状,不加 codegen(过度工程);字段变更的人工残余进检查单。
|
||||
|
||||
### 检查单(人判定的残余)
|
||||
|
||||
见 `ios/PARITY.md`:① 元素 / 屏 / 功能存在性对等;② 非引擎驱动的新流程 / 状态;③ 后端 API 字段变更提醒;④ 允许分叉登记。
|
||||
|
||||
### 明确不做
|
||||
|
||||
设计令牌 codegen 管线——过度工程,且与液态玻璃系统材质 / 语义色 / SF Symbol 母语相冲。间距 / 圆角 / 字阶归“允许分叉”。
|
||||
|
||||
---
|
||||
|
||||
## 4. 复用边界与平台注入
|
||||
## 6. 现在能做 vs 等账号(修订)
|
||||
|
||||
- **UI 全复用** `web/src`:iOS 壳不写业务 UI。
|
||||
- **平台判定**:web 现有 `isDesktop()` 靠 `window.runtime` + `window.go`(Wails 注入)。iOS 壳需类似注入一个标识(如 `window.__CDROP_IOS__` 或 `WKScriptMessageHandler` 桥),前端加 `isIOSShell()`,让原生剪贴板 / 分享走桥、Web Push 入口隐藏(iOS 用 APNs 不用 VAPID)。
|
||||
- **JS↔原生桥**:`WKScriptMessageHandler`(JS→Swift)+ `evaluateJavaScript`(Swift→JS),类比 Wails 的 `EventsEmit` / bound methods。需要的桥方法初稿:`requestApnsToken`、`readClipboard` / `writeClipboard`、`saveFile`、`showNotification`(前台)。
|
||||
- **登录回跳**:`ASWebAuthenticationSession` 的 callback URL scheme 注册(如 `cdrop://oauth/callback`),与 web 的 `redirect_uri` 并存(后端 `/api/auth/config` 已可按 client 下发;或复用 web client 的 loopback 思路)。
|
||||
**账号无关、即刻可做**:
|
||||
|
||||
- 本计划 + `ios/PARITY.md` 同步检查单(✔ 本轮)。
|
||||
- web 侧**无头引擎入口**(✔ 已实现,账号无关):`net/ios.ts`(桥 + `isIOSShell()` + 接收封装 + `notifyNative`)、`engine/main.ts`(不加载 React 的独立入口:水合 store → `startHub` → 暴露 RPC + 订阅推事件)、`incomingSink.ts`(通用混合槽路由 iOS,内存上限 64MB/16MB 避 jetsam)、`engine.html` + `vite.config.ts` 多入口。`tsc` / `vite build` 双过、对现网 web / 桌面零影响。
|
||||
- 桥协议契约冻结(§2,✔ 已与实现对齐)。
|
||||
- i18n 单一源:现有 catalog 已是扁平 `{ key: string }` 结构(`src/i18n/locales/*.ts`),数据形态即可导出 JSON;原生侧薄 loader + 构建期 emit 待原生工程就位时同期落(避免向无消费方空导出)。
|
||||
- 剪贴板 REST 链路(`/api/clipboard` 读写 + 版本探针)的数据流设计(控件实现等账号,协议可先定)。
|
||||
- **iOS 原生 scaffold**(✔ 已实现,账号无关):`ios/CDrop/`(xcodegen `project.yml`)——液态玻璃壳(TabView + `.tabBarMinimizeBehavior` + `.buttonStyle(.glassProminent)`)+ 离屏引擎 `WKWebView` 宿主 + 桥 `EngineController`(对侧契约同 `net/ios.ts`)+ `DownloadManager`(落沙盒)。**已编过 `iphonesimulator26.5`(iOS 26 SDK,含液态玻璃)**——证 arch A Swift 侧成立。**启动 / 截图待 iOS 26 模拟器运行时(约 7GB,账号无关)下载**(`xcodebuild -downloadPlatform iOS`)。
|
||||
- **明确不做**(避免悬空 / 过度工程):设计令牌 codegen;APNs 发送代码(`.p8` 到位再落)。(注:原“Xcode 工程等账号”已作废——**模拟器构建不需签名 / Provisioning**,工程已建且 compile-verified;只有真机签名 / APNs 真发 / 分发才等付费 ADP。)
|
||||
|
||||
**硬等付费 ADP 账号**:真机签名 / 跑、APNs 真发、Share Extension、控制中心控件真机、旁加载分发。
|
||||
|
||||
---
|
||||
|
||||
## 5. 风险与未决
|
||||
## 7. 风险与未决(修订)
|
||||
|
||||
- **R-iOS-1 WebView 后台挂起**:app 退后台 SSE 必断 → 后台通知只能靠 APNs(已纳入 I3)。前台恢复时 WebView 需重连 SSE(web `startHub` 的重连循环已具备)。
|
||||
- **R-iOS-2 剪贴板后台限制**:iOS 不允许后台读剪贴板(隐私);只能前台 / 用户手势。同步语义要据此降级(前台进入时同步一次,而非桌面式实时监听)。iOS 14+ 还有“粘贴需用户确认”横幅。
|
||||
- **R-iOS-3 远端 vs 内嵌 bundle**:WKWebView 加载线上 `drop.commilitia.net`(部署即更新、但离线不可用)vs 内嵌 `web/dist`(随 app 版本、可离线壳)。倾向**内嵌 bundle + API 指向线上**(与桌面一致:`go:embed` 的对应物是 app bundle 里的静态文件),首屏快且可控。
|
||||
- **R-iOS-4 App ID 与 audience**:iOS client 若用独立 OAuth client_id,后端 `CDROP_OIDC_AUDIENCE` 需纳入(已支持逗号分隔多值);若复用 web client(如桌面),则无需改。倾向复用 web client(最省)。
|
||||
- **R-iOS-5 审核**:App Store 审核对“仅是网页壳”的 app 有顾虑(Guideline 4.2)。原生剪贴板 + 分享扩展 + APNs 提供了足够的原生价值,过审风险可控,但需在审核说明里强调。
|
||||
- **R-iOS-1 后台挂起 / JS 全停**:app 退后台即挂起、WKWebView JS(含 WebRTC 事件循环)停摆,DataChannel 无媒体轨后台不保活。文件传输本需前台(双方在线、用户看进度),可接受;用户主动触发的传输用 iOS 26 `BGContinuedProcessingTask` 续跑(带系统进度 UI)。剪贴板走控制中心 App Intent,不依赖常驻 WebView。
|
||||
- **R-iOS-2 剪贴板后台限制**:读弹横幅、后台不可监听 → 控制中心手动两控件已据此设计;app 内读用 `UIPasteControl`、探测用 `hasStrings`(均不弹横幅)。
|
||||
- **R-iOS-3 无头 WebRTC 真机稳定性 + 安全上下文**:arch A 最大未验证点——① `RTCPeerConnection` 须在安全 origin;② `BGContinuedProcessingTask` 能否让 WebView JS 不被挂起待验。**2026-06-24 模拟器已部分证实**:iOS 26 WKWebView 从 `http://127.0.0.1`(`isSecureContext=true`,loopback = 潜在可信源)跑 `RTCPeerConnection` + DataChannel + `createOffer` = **RTC:ok**、STUN srflx 候选正常、原生↔JS 桥往返正常 → **arch A 核心成立、不转 B**。注:安全源不止 https,loopback 亦可(可选应用内本地 https/loopback 服务内嵌 bundle,离线可用)。**余真机验**:host / 同内网直连候选 + 本地网络权限(R-iOS-6)、后台挂起续传(BGContinuedProcessingTask)。
|
||||
- **R-iOS-4 字节桥**:接收侧复用桌面 `Begin`/`Append`/`Finalize`(base64 分批、有界)已验证;发送侧自定义 scheme(`WKURLSchemeHandler`)流式喂 JS 为新实现点。
|
||||
- **R-iOS-5**(原 App Store 过审)在 Ad Hoc 旁加载下作废;若改走 TestFlight 则 Beta 审核仍在。
|
||||
- **R-iOS-6 本地网络权限(同内网 P2P 硬墙)**:WebRTC host 候选 / mDNS 触发 `NSLocalNetworkUsageDescription`(须配 `NSBonjourServices`),拒绝即只剩中继候选 → 回退 relay(cdrop 已有兜底);mDNS 失败不可区分“无服务”,UI 须降级。**不可拿它当核心功能门槛**。
|
||||
- **R-iOS-7 付费账号 + 分发摩擦**:4 项能力 + 分发全需付费 ADP;Ad Hoc 100 台 + UDID 预登记是旁加载硬约束——比桌面 Dropbox“下载即用”多一道摩擦。
|
||||
|
||||
---
|
||||
|
||||
## 6. 现在能做 vs 等账号
|
||||
## 8. iOS 设计规范要点(HIG · 液态玻璃)
|
||||
|
||||
**账号到位前可做(账号无关)**:
|
||||
- 本计划(✔ 本文)。
|
||||
- 服务端 APNs 通道的**设计冻结**(§3)——代码待 `.p8` 同期落,避免无法验证的悬空实现。
|
||||
- 前端 `isIOSShell()` 平台注入点的预留(可与 iOS 壳同期,改动小)。
|
||||
抓取自 Apple HIG / WWDC25(2026-06-24),只取对 cdrop 界面有约束力的重点。
|
||||
|
||||
**硬等 Apple Developer 账号**:Xcode 工程真机签名、APNs 真发真测、Share Extension、TestFlight / App Store 分发。
|
||||
### 8.1 液态玻璃(材质用法)
|
||||
|
||||
- **两层模型**:玻璃只用于**功能 / 导航层**(Tab bar / Nav bar / Toolbar / 浮动控件 / sheet 边框);**内容层禁用**——列表、设备卡、**传输卡属内容层,不加玻璃**。
|
||||
- 用 **iOS 26 SDK 编译,Tab bar / Nav bar / Toolbar / sheet 自动玻璃化**;同时**移除所有自定义 bar 背景 / 装饰色**(否则破坏系统玻璃)。
|
||||
- 自定义悬浮控件(如传输卡上的取消 / 暂停 pill)才手动 `.glassEffect(.regular.interactive())`,多块玻璃须放进 `GlassEffectContainer` 共享采样;主操作用 `.buttonStyle(.glassProminent)`。
|
||||
- **禁忌**:玻璃叠玻璃、滚动内容区铺玻璃、大面积内容玻璃背景、非主操作滥用 tint。
|
||||
- Tab bar 滚动收起 `.tabBarMinimizeBehavior(.onScrollDown)`;全局传输状态可置 `.tabViewBottomAccessory`(类 Apple Music 迷你条)。圆角走同心 `.rect(cornerRadius: .containerConcentric)`,sheet / popover 系统自动同心。
|
||||
|
||||
### 8.2 基础(色彩 / 字体 / 图标 / 布局)
|
||||
|
||||
- **色彩**:用**语义色**(`label` / `secondaryLabel`、`systemBackground` 层级、`systemFill`、`separator`),**不硬编码 hex**——暗色 / 对比度 / vibrancy 免费。**品牌主色以 Asset Catalog 的 Color Set(含 Dark 变体)接入**(`Color("BrandPrimary")`)——这正是强约束③(§5)在 iOS 侧的落地形态。
|
||||
- **字体 / Dynamic Type**:系统 text styles(Body 17 / Headline / Title…)+ **必须支持 Dynamic Type**(正文 / 标题 / 主按钮可缩放)。与“字体归允许分叉”不矛盾——分叉的是**字体选择**,Dynamic Type 支持是**硬要求**。
|
||||
- **SF Symbols**:优先系统符号(自动与文字对齐 / 字重匹配),四种渲染模式(mono / hierarchical / palette / multicolor);品牌专属才自定义 symbol(须注解四模式以获 Dynamic Type / 无障碍)。
|
||||
- **布局**:内容 / 控件留安全区,**最小命中区 44×44pt**,对比度 **4.5:1**(正文)/ **3:1**(大字 / 非文字),不硬编码宽度。
|
||||
|
||||
### 8.3 控件 / App Intents(剪贴板两控件落地)
|
||||
|
||||
- 剪贴板“上 / 下”用 **`ControlWidgetButton`**(一次性动作);**SF Symbol 必备**(锁屏 / 操作按钮只显示图标,须独立达意)。跨设备状态靠**推送 reload**(`ControlCenter.reloadControls`)、**不轮询**;控件内**不发网络请求**,经 App Group 容器读主 app 写入。
|
||||
- **App Intent**:动词+宾语命名、`title` 编译期常量本地化串、**默认后台执行**(返回 dialog / snippet、不开 app);iOS 26 **Interactive Snippets** 可在不开 app 下做“选设备”二级操作。
|
||||
|
||||
### 8.4 分享(Share Extension)
|
||||
|
||||
- 扩展只做轻:内容预览 + 选设备 + 验证 → 写 **App Group 容器** + 交接主 app;**不在扩展内跑传输引擎**(内存 ~120MB,见 §9.1)。宽度系统固定不可改;完成即“已提交”退出,不等传输完成。
|
||||
|
||||
### 8.5 通知(APNs)
|
||||
|
||||
- 文案具体(“文件 X 已接收(3.2MB)”);中断级别用 **`.active`**(传输完成 / 失败),**不滥用 `.timeSensitive`**;`threadIdentifier` 按“完成 / 失败 / 消息”分组;**首个有价值操作后**再 just-in-time 请求权限,按类别细分开关。**剪贴板不推送**(决策 C)。
|
||||
|
||||
### 8.6 无障碍(玻璃语境)
|
||||
|
||||
- Reduce Transparency / Increase Contrast / Reduce Motion:系统对 Tab / Nav 自动回落;**自定义玻璃控件须自行提供不透明回落 + 关动效**。传输进度动画检测 `accessibilityReduceMotion` 降级为渐隐 / 变色。VoiceOver:图标按钮 `accessibilityLabel` 用动词(“发送文件”而非“箭头向上”)。
|
||||
|
||||
### 8.7 App 图标(iOS 26)
|
||||
|
||||
- 用 **Icon Composer** 做分层图标(logo 前景 + 品牌色渐变背景),系统材质自动出高光 / 折射 / 阴影——源稿**不要**烘焙圆角 / 阴影 / 斜面。须验明 / 暗 / clear / tinted 各变体(暗背景下 logo 填充色可辨)。接品牌资产源(强约束③)。
|
||||
|
||||
---
|
||||
|
||||
## 附:与桌面端的策略对照(一以贯之)
|
||||
## 9. 开发禁区 / 平台约束(硬性 vs 审核 / 账号)
|
||||
|
||||
| 维度 | 桌面(Wails) | iOS(WKWebView 壳) |
|
||||
分级:**【OS 硬】**=系统强制、绕不过;**【账号】**=付费 / 账号策略;**【审核】**=仅 App Store / TestFlight 审核约束(Ad Hoc 旁加载不触发)。
|
||||
|
||||
### 9.1 运行 / 生命周期
|
||||
|
||||
- **【OS 硬】后台一律挂起、JS 全停**:app 退后台即被挂起,WKWebView 的 JS(含 WebRTC 事件循环)停摆,DataChannel(无媒体轨)后台不保活 → 传输**必须前台发起**;用户主动触发的传输用 iOS 26 **`BGContinuedProcessingTask`** 续跑(带系统进度 UI,能否让 WebView JS 不被挂起须真机验,R-iOS-3)。
|
||||
- **【OS 硬】无后台常驻监听**:剪贴板 / 消息**不能**后台轮询监听 → 决策 C 的控制中心手动触发正据此。
|
||||
- **【OS 硬】自定义 scheme = 非安全上下文 + OPFS 单文件 ~10MB**:故引擎从 https 安全源加载(§2)、大文件**不走 OPFS**、经原生桥 `Begin`/`Append`/`Finalize` 落沙盒(接收侧已是此设计)。
|
||||
- **【OS 硬】Share Extension ~120MB jetsam**:扩展内**装不下** WKWebView + WebRTC 引擎 → 只做交接(§8.4)。
|
||||
|
||||
### 9.2 权限 / 隐私 / 网络(落实决策 E 的逐权限映射)
|
||||
|
||||
- **【OS 硬】本地网络权限(持久)**:同内网 P2P 直连(WebRTC host 候选 / mDNS)触发 `NSLocalNetworkUsageDescription`,**须配 `NSBonjourServices`**;一次授予长期持久 → **首启引导显式询问**(决策 E)。缺失静默失败、拒绝则只剩中继候选 → **降级回退 relay 并提示“仅中继”**(cdrop 已有兜底;mDNS 失败不可区分“无服务”,UI 须降级)。
|
||||
- **【OS 硬】相机权限(持久,扫码登录)**:`NSCameraUsageDescription` 缺失即崩;首启 / 首次扫码时显式询问。**无兜底 → 拒绝则提示“无权限”并阻塞“扫码”特性**(跳系统设置);登录可走 OIDC 等其他途径,阻塞的是扫码、非整个 app。
|
||||
- **【OS 硬】Info.plist 用途说明缺失即崩**:相机、相册键(若选图)必声明;剪贴板**无** iOS plist key(横幅系统自动、关不掉)。
|
||||
- **【OS 硬】ATS 强制 HTTPS / TLS1.2+**:cdrop 已全 https,无需例外。
|
||||
- **【OS 硬】剪贴板**:后台不可读、读内容弹横幅(关不掉)、写不弹、`hasStrings` 探测不弹、`UIPasteControl` 读不弹 → “上”控件接受一次横幅、app 内读用 `UIPasteControl`、“下”控件写无感。
|
||||
- **【审核】隐私清单 `PrivacyInfo.xcprivacy` + Required Reason API**(UserDefaults `CA92.1` 等):上架 / TestFlight 才强制;Ad Hoc 非硬性,但第三方 WebRTC SDK 自带清单仍建议补。
|
||||
|
||||
### 9.3 能力 / 扩展 / 签名 / 分发
|
||||
|
||||
- **【账号】4 项能力全需付费 ADP**:APNs / App Groups / Keychain 共享 / Associated Domains——免费 Personal Team 一律不支持(决策 B / I0)。
|
||||
- **【OS 硬】扩展归属**:Share Extension / ControlWidget 的 bundle id 须**主 app 前缀**、同 Team 签名;与主 app 共享数据**唯一合法途径 = App Group 容器**。
|
||||
- **【OS 硬 + 账号】Ad Hoc 100 台 / 年 + UDID 预登记**:旁加载的硬上限与摩擦(决策 B)。
|
||||
- **【OS 硬】APNs 环境隔离**:dev profile=sandbox、ad-hoc / 分发=production,token 不互通。
|
||||
|
||||
---
|
||||
|
||||
## 附:与桌面端策略对照(修订)
|
||||
|
||||
| 维度 | 桌面(Wails) | iOS(arch A) |
|
||||
|---|---|---|
|
||||
| UI | 复用 `web/src` | 复用 `web/src` |
|
||||
| refresh_token | Go keyring(AES-GCM) | iOS Keychain |
|
||||
| 登录 | loopback PKCE(Go 内) | `ASWebAuthenticationSession`(PKCE) |
|
||||
| 后台通知 | Go 常驻进程 + 原生 API(窗口非前台才弹) | **服务端 APNs**(app 挂起无常驻进程) |
|
||||
| 剪贴板 | Go 原生 Monitor 双向实时 | `UIPasteboard`,前台 / 手势(后台受限) |
|
||||
| 平台判定 | `isDesktop()`(`window.runtime`) | `isIOSShell()`(注入标识) |
|
||||
| UI | 复用 `web/src` | 原生 SwiftUI(分叉,PARITY 管) |
|
||||
| 引擎 | Go 原生 | 复用 `web/src` JS(无头) |
|
||||
| 桥方向 | JS-UI 顶层 → Go | SwiftUI 顶层 → 无头 JS |
|
||||
| refresh_token | Go keyring | iOS Keychain |
|
||||
| 登录 | loopback PKCE | `ASWebAuthenticationSession` / 扫码 |
|
||||
| 后台通知 | Go 常驻进程 + 原生 API | 服务端 APNs |
|
||||
| 剪贴板 | Go Monitor 实时双向 | 控制中心手动两控件(纯原生 REST) |
|
||||
| 平台判定 | `isDesktop()`(`window.runtime`) | `isIOSShell()`(`window.webkit.messageHandlers`) |
|
||||
| 分发 | Dropbox 旁加载 | 旁加载(非 App Store) |
|
||||
|
||||
Reference in New Issue
Block a user