239 lines
11 KiB
Swift
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))
|
|
}
|
|
}
|