- 删掉 MaxMind .mmdb 读取器、本地数据库控制器与「IP 归属地数据库」设置区块,去掉「归属地数据源」选择及 ipapi.is / DB-IP / ipinfo.io 解析;PublicAddressLookup 只向 cleanip.io 查询,结果按 IP 记一小时,收到 429 当天不再请求 - 设置同步文档不再带 geoSource / geoIncludeCity / geoAutoUpdate;公网 IP 缓存键升到第 4 版,旧数据源的缓存不再读取 - 网络详情:IP 地址区块标题不再放 cleanip.io 字标,只留在纯净度区块;纯净度色带改为直角 - 删除 server/geoip 的 GeoLite2 同步脚本;README、架构说明、第三方声明、官网与隐私页、译文同步,清掉 53 条不再使用的翻译
237 lines
10 KiB
Swift
237 lines
10 KiB
Swift
import AccountSync
|
||
import AppKit
|
||
import Localization
|
||
import SwiftUI
|
||
|
||
/// 设置 · 账号与同步:登录、同步状态、首次登录的二选一、退出与删除
|
||
struct AccountSettings: View {
|
||
@Environment(AppModel.self) private var model
|
||
@State private var confirmingDelete = false
|
||
|
||
var body: some View {
|
||
let sync = model.sync
|
||
Group {
|
||
if let user = sync.user {
|
||
signedIn(user: user, sync: sync)
|
||
} else {
|
||
signedOut(sync: sync)
|
||
}
|
||
}
|
||
.onAppear { sync.pageOpened() }
|
||
}
|
||
|
||
// MARK: 未登录
|
||
|
||
@ViewBuilder
|
||
private func signedOut(sync: SyncController) -> some View {
|
||
SettingsGroup(caption: tr("账号")) {
|
||
GroupRow(showsDivider: false) {
|
||
VStack(alignment: .leading, spacing: DS.Space.s3) {
|
||
SettingRow(title: tr("登录以同步设置"),
|
||
subtitle: tr("换一台 Mac 登录后,菜单栏、外观、通知与快捷键等偏好会自动恢复。不登录也能使用全部功能。")) {
|
||
EmptyView()
|
||
}
|
||
HStack(spacing: DS.Space.s2) {
|
||
ForEach(sync.providers) { provider in
|
||
Button {
|
||
sync.signIn(with: provider)
|
||
} label: {
|
||
HStack(spacing: DS.Space.s2) {
|
||
ProviderLogo(provider: provider)
|
||
Text(tr("使用 \(provider.title) 登录"))
|
||
}
|
||
}
|
||
.buttonStyle(DSButtonStyle(kind: .secondary))
|
||
.disabled(sync.phase == .signingIn)
|
||
}
|
||
if sync.phase == .signingIn {
|
||
ProgressView().controlSize(.small)
|
||
Button(tr("取消")) { sync.cancelSignIn() }.buttonStyle(DSButtonStyle(kind: .ghost))
|
||
}
|
||
}
|
||
if case .failed(let message) = sync.phase {
|
||
InfoBanner(icon: "exclamationmark.triangle", text: message, tone: .error)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
privacyGroup
|
||
}
|
||
|
||
// MARK: 已登录
|
||
|
||
@ViewBuilder
|
||
private func signedIn(user: SyncUser, sync: SyncController) -> some View {
|
||
SettingsGroup(caption: tr("账号")) {
|
||
GroupRow(showsDivider: false) {
|
||
HStack(spacing: DS.Space.s3) {
|
||
Avatar(url: user.avatar)
|
||
VStack(alignment: .leading, spacing: DS.Space.s1 / 2) {
|
||
Text(user.displayName).dsFont(.sm, weight: .medium).foregroundStyle(DS.Palette.textPrimary)
|
||
Text(accountSubtitle(user)).dsFont(.xs).foregroundStyle(DS.Palette.textSecondary)
|
||
}
|
||
Spacer(minLength: DS.Space.s4)
|
||
Button(tr("退出登录")) { sync.signOut() }
|
||
.buttonStyle(DSButtonStyle(kind: .secondary))
|
||
.disabled(sync.phase == .syncing)
|
||
}
|
||
}
|
||
}
|
||
|
||
SettingsGroup(caption: tr("同步")) {
|
||
GroupRow(showsDivider: false) {
|
||
SettingRow(title: statusTitle(sync), subtitle: statusSubtitle(sync)) {
|
||
Button(sync.phase == .syncing ? tr("正在同步…") : tr("立即同步")) { sync.syncNow() }
|
||
.buttonStyle(DSButtonStyle(kind: .secondary))
|
||
.disabled(sync.phase == .syncing || sync.conflict != nil)
|
||
}
|
||
}
|
||
if let conflict = sync.conflict {
|
||
GroupRow {
|
||
VStack(alignment: .leading, spacing: DS.Space.s3) {
|
||
InfoBanner(icon: "arrow.triangle.2.circlepath",
|
||
text: tr("云端已有 \(conflict.remoteDevice ?? tr("另一台 Mac")) 在 \(Self.relative(conflict.remoteUpdatedAt)) 保存的设置,和这台 Mac 上的不一样。要用哪一份?"),
|
||
tone: .warning)
|
||
HStack(spacing: DS.Space.s2) {
|
||
Spacer()
|
||
Button(tr("上传本机设置")) { sync.resolveConflict(useRemote: false) }
|
||
.buttonStyle(DSButtonStyle(kind: .secondary))
|
||
Button(tr("使用云端设置")) { sync.resolveConflict(useRemote: true) }
|
||
.buttonStyle(DSButtonStyle(kind: .primary))
|
||
}
|
||
}
|
||
}
|
||
}
|
||
if case .failed(let message) = sync.phase {
|
||
GroupRow { InfoBanner(icon: "exclamationmark.triangle", text: message, tone: .error) }
|
||
}
|
||
GroupRow {
|
||
Text(tr("同步的内容:菜单栏项目与风格、刷新频率、外观与语言、详情弹窗的区块、连接探测、通知、快捷键、风扇安全温度、合盖电量下限。当前页面、辅助工具与历史记录留在本机。"))
|
||
.dsFont(.xs)
|
||
.foregroundStyle(DS.Palette.textSecondary)
|
||
.fixedSize(horizontal: false, vertical: true)
|
||
}
|
||
}
|
||
|
||
SettingsGroup(caption: tr("数据")) {
|
||
GroupRow(showsDivider: false) {
|
||
SettingRow(title: tr("删除云端数据"), subtitle: tr("删除账号、所有登录方式与云端保存的设置;这台 Mac 上的设置保留")) {
|
||
Button(tr("删除…")) { confirmingDelete = true }
|
||
.buttonStyle(DSButtonStyle(kind: .secondary))
|
||
.disabled(sync.phase == .syncing)
|
||
.confirmationDialog(tr("删除云端数据?"), isPresented: $confirmingDelete, titleVisibility: .visible) {
|
||
Button(tr("删除"), role: .destructive) { sync.deleteAccount() }
|
||
Button(tr("取消"), role: .cancel) {}
|
||
} message: {
|
||
Text(tr("账号与云端设置会立即删除,无法恢复。"))
|
||
}
|
||
}
|
||
}
|
||
}
|
||
privacyGroup
|
||
}
|
||
|
||
private var privacyGroup: some View {
|
||
SettingsGroup(caption: tr("隐私")) {
|
||
GroupRow(showsDivider: false) {
|
||
SettingRow(title: tr("云端只保存邮箱、姓名与设置文档"),
|
||
subtitle: tr("服务器在 getopenstats.com,不上传任何监控数据;随时可以删除。")) {
|
||
Button(tr("隐私政策")) {
|
||
if let url = URL(string: "https://getopenstats.com/privacy.html") { NSWorkspace.shared.open(url) }
|
||
}
|
||
.buttonStyle(DSButtonStyle(kind: .ghost))
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// MARK: 文案
|
||
|
||
private func accountSubtitle(_ user: SyncUser) -> String {
|
||
let providers = user.providers.compactMap(SyncProvider.init(rawValue:)).map(\.title).joined(separator: " / ")
|
||
let via = providers.isEmpty ? "" : tr("通过 \(providers) 登录")
|
||
guard let email = user.email, !email.isEmpty, email != user.displayName else { return via }
|
||
return via.isEmpty ? email : "\(email) · \(via)"
|
||
}
|
||
|
||
private func statusTitle(_ sync: SyncController) -> String {
|
||
switch sync.phase {
|
||
case .syncing: tr("正在同步")
|
||
case .failed: tr("同步失败")
|
||
default: sync.conflict != nil ? tr("等待选择") : tr("已开启同步")
|
||
}
|
||
}
|
||
|
||
private func statusSubtitle(_ sync: SyncController) -> String {
|
||
guard let date = sync.lastSyncedAt else { return tr("设置改动后 2 秒内上传;启动、唤醒与每 15 分钟检查一次云端") }
|
||
return tr("上次同步:\(Self.relative(date))")
|
||
}
|
||
|
||
private static func relative(_ date: Date) -> String {
|
||
date.formatted(.relative(presentation: .named).locale(L10n.locale))
|
||
}
|
||
}
|
||
|
||
/// 头像:圆形,取不到时用系统人像图标
|
||
private struct Avatar: View {
|
||
let url: URL?
|
||
|
||
var body: some View {
|
||
AsyncImage(url: url) { phase in
|
||
if let image = phase.image {
|
||
image.resizable().scaledToFill()
|
||
} else {
|
||
Image(systemName: "person.crop.circle")
|
||
.font(.system(size: DS.Space.s6))
|
||
.foregroundStyle(DS.Palette.textTertiary)
|
||
}
|
||
}
|
||
.frame(width: DS.Space.s8, height: DS.Space.s8)
|
||
.clipShape(Circle())
|
||
}
|
||
}
|
||
|
||
/// 登录按钮上的品牌标志,16 pt 行内尺寸。Google 用四色 G;Apple 与 GitHub 的标志本身只有单色版本,
|
||
/// 按品牌规范用文字色绘制,浅色模式下为黑、深色模式下为白
|
||
struct ProviderLogo: View {
|
||
let provider: SyncProvider
|
||
var size: CGFloat = DS.Size.iconInline
|
||
|
||
var body: some View {
|
||
Group {
|
||
switch provider {
|
||
case .apple:
|
||
Image(systemName: "apple.logo")
|
||
.font(.system(size: size - DS.Space.s1 / 2, weight: .medium))
|
||
.foregroundStyle(DS.Palette.textPrimary)
|
||
case .github:
|
||
if let image = LogoCache.shared.image(named: "github", template: true) {
|
||
Image(nsImage: image).resizable().scaledToFit().foregroundStyle(DS.Palette.textPrimary)
|
||
}
|
||
case .google:
|
||
if let image = LogoCache.shared.image(named: "google", template: false) {
|
||
Image(nsImage: image).resizable().scaledToFit()
|
||
}
|
||
}
|
||
}
|
||
.frame(width: size, height: size)
|
||
.accessibilityHidden(true)
|
||
}
|
||
}
|
||
|
||
/// 包内 Resources/Logos 下的 SVG:GitHub 标志来自 Octicons(MIT),Google 标志为其品牌资源
|
||
@MainActor
|
||
final class LogoCache {
|
||
static let shared = LogoCache()
|
||
private var images: [String: NSImage] = [:]
|
||
|
||
func image(named name: String, template: Bool) -> NSImage? {
|
||
if let image = images[name] { return image }
|
||
guard let url = Bundle.module.url(forResource: name, withExtension: "svg", subdirectory: "Logos"),
|
||
let image = NSImage(contentsOf: url) else { return nil }
|
||
image.isTemplate = template
|
||
images[name] = image
|
||
return image
|
||
}
|
||
}
|