Files
OpenStats/Packages/OpenStatsKit/Sources/OpenStatsUI/Panel/UninstallerPage.swift
T

239 lines
11 KiB
Swift

import Cleaner
import Localization
import Metrics
import SwiftUI
import UniformTypeIdentifiers
/// 卸载应用:左侧应用列表,右侧残留文件与卸载按钮;也可以把应用拖到页面上
struct UninstallerPage: View {
@Environment(AppModel.self) private var model
@State private var search = ""
@State private var confirming = false
@State private var isDropTargeted = false
var body: some View {
let uninstaller = model.uninstaller
HStack(alignment: .top, spacing: DS.Space.s3) {
AppListColumn(search: $search)
.frame(width: DS.Size.sidebarWidth + DS.Space.s16)
PageScroll {
if let app = uninstaller.selected {
AppDetailCard(app: app, confirm: { confirming = true })
} else {
DropHint(isTargeted: isDropTargeted)
}
if let outcome = uninstaller.outcome {
InfoBanner(icon: outcome.isError ? "exclamationmark.triangle.fill" : "checkmark.circle.fill",
text: outcome.text, tone: outcome.isError ? .error : .success)
}
}
}
.padding(.leading, DS.Space.s3)
.onAppear { if uninstaller.apps.isEmpty { uninstaller.loadApps() } }
.onDrop(of: [.fileURL], isTargeted: $isDropTargeted) { providers in
guard let provider = providers.first else { return false }
_ = provider.loadObject(ofClass: URL.self) { url, _ in
guard let url else { return }
Task { @MainActor in uninstaller.select(url: url) }
}
return true
}
.confirmationDialog(uninstaller.selected.map { tr("卸载“\($0.name)”?") } ?? "", isPresented: $confirming, titleVisibility: .visible) {
Button(tr("移到废纸篓"), role: .destructive) { uninstaller.uninstall() }
Button(tr("取消"), role: .cancel) {}
} message: {
Text(tr("应用与勾选的 \(max(0, uninstaller.chosen.count - 1)) 项残留会移到废纸篓,约 \(Format.bytes(uninstaller.chosenSize, base: .decimal))。清空废纸篓前都可以放回。"))
}
}
}
private struct AppListColumn: View {
@Environment(AppModel.self) private var model
@Binding var search: String
var body: some View {
let uninstaller = model.uninstaller
let query = search.trimmingCharacters(in: .whitespaces)
let apps = query.isEmpty ? uninstaller.apps
: uninstaller.apps.filter { $0.name.localizedCaseInsensitiveContains(query) || $0.bundleIdentifier.localizedCaseInsensitiveContains(query) }
VStack(alignment: .leading, spacing: DS.Space.s2) {
HStack(spacing: DS.Space.s2) {
Image(systemName: "magnifyingglass")
.font(.system(size: DS.TextSize.xs.rawValue, weight: .semibold))
.foregroundStyle(DS.Palette.textTertiary)
TextField(tr("搜索应用"), text: $search)
.textFieldStyle(.plain)
.dsFont(.sm)
IconButton(systemName: "arrow.clockwise", help: tr("重新扫描")) { uninstaller.loadApps() }
}
.padding(.leading, DS.Space.s2)
.frame(height: DS.Size.controlHeight)
.background(DS.Palette.elevated, in: RoundedRectangle(cornerRadius: DS.Radius.md))
.overlay(RoundedRectangle(cornerRadius: DS.Radius.md).strokeBorder(DS.Palette.neutral300, lineWidth: DS.Size.stroke))
Text(uninstaller.isLoading ? tr("正在扫描应用…") : tr("\(apps.count) 个应用,系统自带的不列出"))
.dsFont(.xs)
.foregroundStyle(DS.Palette.textTertiary)
ScrollView {
LazyVStack(spacing: DS.Space.s1 / 2) {
ForEach(apps) { app in
AppListRow(app: app, size: uninstaller.sizes[app.id], isSelected: uninstaller.selected == app) {
uninstaller.select(app)
}
}
}
}
.overlayScrollers()
}
.padding(.vertical, DS.Space.s3)
.frame(maxHeight: .infinity, alignment: .top)
}
}
private struct AppListRow: View {
let app: InstalledApp
let size: UInt64?
let isSelected: Bool
let action: () -> Void
@State private var hovering = false
var body: some View {
Button(action: action) {
HStack(spacing: DS.Space.s2) {
AppIconCache.shared.image(bundlePath: app.url.path)
.resizable()
.frame(width: DS.Size.iconStandalone + DS.Space.s1, height: DS.Size.iconStandalone + DS.Space.s1)
VStack(alignment: .leading, spacing: 0) {
Text(verbatim: app.name)
.dsFont(.sm, weight: isSelected ? .semibold : .regular)
.foregroundStyle(isSelected ? DS.Palette.primary : DS.Palette.textPrimary)
.lineLimit(1)
Text(verbatim: size.map { Format.bytes($0, base: .decimal) } ?? tr("计算中"))
.dsFont(.xs)
.foregroundStyle(DS.Palette.textTertiary)
}
Spacer(minLength: 0)
}
.padding(.horizontal, DS.Space.s2)
.padding(.vertical, DS.Space.s1)
.background(isSelected ? DS.Palette.primarySoft : hovering ? DS.Palette.surface : .clear,
in: RoundedRectangle(cornerRadius: DS.Radius.md))
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.onHover { hovering = $0 }
}
}
private struct AppDetailCard: View {
@Environment(AppModel.self) private var model
let app: InstalledApp
let confirm: () -> Void
var body: some View {
@Bindable var uninstaller = model.uninstaller
let running = uninstaller.isRunning(app)
Card {
HStack(spacing: DS.Space.s3) {
AppIconCache.shared.image(bundlePath: app.url.path)
.resizable()
.frame(width: DS.Space.s12, height: DS.Space.s12)
VStack(alignment: .leading, spacing: DS.Space.s1 / 2) {
Text(verbatim: app.name).dsFont(.lg, weight: .semibold).foregroundStyle(DS.Palette.textPrimary)
Text(verbatim: [app.version.map { tr("版本 \($0)") }, app.bundleIdentifier].compactMap { $0 }.joined(separator: " · "))
.dsFont(.xs)
.foregroundStyle(DS.Palette.textSecondary)
.lineLimit(1)
}
Spacer(minLength: 0)
}
if running {
InfoBanner(icon: "exclamationmark.triangle", text: tr("\(app.name) 正在运行,卸载前需要先退出。"), tone: .warning) {
Button(tr("退出应用")) { uninstaller.quit(app) }
.buttonStyle(DSButtonStyle(kind: .secondary))
}
}
HairlineDivider()
HStack {
Text(tr("将移到废纸篓")).dsFont(.sm, weight: .semibold).foregroundStyle(DS.Palette.textPrimary)
Spacer()
Text(verbatim: uninstaller.isScanning ? tr("正在查找残留…") : tr("已选 \(uninstaller.chosen.count) 项 · \(Format.bytes(uninstaller.chosenSize, base: .decimal))"))
.dsFont(.xs)
.foregroundStyle(DS.Palette.textTertiary)
}
ForEach(AppLeftover.Kind.allCases, id: \.self) { kind in
let items = uninstaller.leftovers.filter { $0.kind == kind }
if !items.isEmpty {
VStack(alignment: .leading, spacing: DS.Space.s1) {
Text(kind.title).dsFont(.xs, weight: .medium).foregroundStyle(DS.Palette.textSecondary)
ForEach(items) { item in
HStack(spacing: DS.Space.s2) {
DSCheckbox(isOn: uninstaller.chosen.contains(item.id)) { uninstaller.toggle(item) }
.disabled(item.kind == .application)
Text(verbatim: item.url.path.replacingOccurrences(of: NSHomeDirectory(), with: "~"))
.dsFont(.xs)
.foregroundStyle(DS.Palette.textPrimary)
.lineLimit(1)
.truncationMode(.middle)
.help(item.url.path)
Spacer(minLength: DS.Space.s2)
Text(verbatim: Format.bytes(item.size, base: .decimal))
.dsFont(.xs)
.foregroundStyle(DS.Palette.textTertiary)
.monospacedDigit()
MiniIconButton(systemName: "magnifyingglass", help: tr("在访达中显示")) {
NSWorkspace.shared.activateFileViewerSelecting([item.url])
}
}
}
}
}
}
HairlineDivider()
HStack(spacing: DS.Space.s3) {
DSToggle(isOn: $uninstaller.removeFromDock, label: tr("从程序坞移除图标"))
Text(tr("同时从程序坞移除图标")).dsFont(.sm).foregroundStyle(DS.Palette.textPrimary)
Spacer()
Button(uninstaller.isRemoving ? tr("正在移除…") : tr("卸载")) { confirm() }
.buttonStyle(DSButtonStyle(kind: .primary))
.disabled(running || uninstaller.isScanning || uninstaller.isRemoving)
}
Text(tr("只查找以该应用包名命名的文件,以及 Application Support、Logs 下与应用同名的目录;钥匙串与其他应用共享的数据不会动"))
.dsFont(.xs)
.foregroundStyle(DS.Palette.textTertiary)
.fixedSize(horizontal: false, vertical: true)
}
}
}
private struct DropHint: View {
let isTargeted: Bool
var body: some View {
VStack(spacing: DS.Space.s3) {
Image(systemName: "arrow.down.app")
.font(.system(size: DS.TextSize.xxl.rawValue))
.foregroundStyle(isTargeted ? DS.Palette.primary : DS.Palette.textTertiary)
Text(tr("从左侧选择应用,或把应用拖到这里"))
.dsFont(.sm, weight: .medium)
.foregroundStyle(DS.Palette.textPrimary)
Text(tr("会一并找出它留在资源库里的缓存、偏好设置、容器与登录启动项,全部移到废纸篓,可以放回"))
.dsFont(.xs)
.foregroundStyle(DS.Palette.textSecondary)
.multilineTextAlignment(.center)
.fixedSize(horizontal: false, vertical: true)
}
.frame(maxWidth: .infinity)
.padding(DS.Space.s12)
.background(isTargeted ? DS.Palette.primarySoft : DS.Palette.surface, in: RoundedRectangle(cornerRadius: DS.Radius.lg))
}
}