Files
wehub-resource-sync 1d1286fadb
Build / Build and test (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:18:38 +08:00

356 lines
12 KiB
Swift

import AppKit
import SwiftUI
import MacToolsPluginKit
// MARK: - App Entry Model
struct QuitAppEntry: Identifiable, Equatable {
let id: String
let app: NSRunningApplication
var isSelected: Bool
static func == (lhs: QuitAppEntry, rhs: QuitAppEntry) -> Bool {
lhs.id == rhs.id && lhs.isSelected == rhs.isSelected
}
}
// MARK: - View Model
@MainActor
final class QuitAppsViewModel: ObservableObject {
@Published var entries: [QuitAppEntry] = []
private let localization: PluginLocalization
var selectedEntries: [QuitAppEntry] { entries.filter(\.isSelected) }
init(localization: PluginLocalization = PluginLocalization(bundle: .main)) {
self.localization = localization
}
var confirmTitle: String {
let count = selectedEntries.count
guard count > 0 else {
return localization.string("selection.confirm.quitAll", defaultValue: "退出全部应用")
}
return localization.format("selection.confirm.quitCountFormat", defaultValue: "退出 %d 个应用", count)
}
func load() {
let currentSelectionIDs = Set(entries.filter(\.isSelected).map(\.id))
let all = NSWorkspace.shared.runningApplications
let fresh: [QuitAppEntry] = all.compactMap { app in
guard
app.activationPolicy == .regular,
let bid = app.bundleIdentifier,
bid != Bundle.main.bundleIdentifier
else { return nil }
let wasSelected = currentSelectionIDs.contains(bid)
return QuitAppEntry(id: bid, app: app, isSelected: wasSelected)
}.sorted { ($0.app.localizedName ?? "") < ($1.app.localizedName ?? "") }
entries = fresh
}
func invertSelection() {
entries = entries.map { var e = $0; e.isSelected = !e.isSelected; return e }
}
func toggleEntry(id: String) {
guard let idx = entries.firstIndex(where: { $0.id == id }) else { return }
entries[idx].isSelected.toggle()
}
func confirmQuit(onDone: () -> Void) {
let targets = selectedEntries.isEmpty ? entries : selectedEntries
for entry in targets {
entry.app.terminate()
}
onDone()
}
}
// MARK: - Selection Window
@MainActor
final class QuitAppsSelectionWindow: NSPanel {
private let viewModel: QuitAppsViewModel
private let localization: PluginLocalization
private var launchObserver: (any NSObjectProtocol)?
private var terminateObserver: (any NSObjectProtocol)?
private var onDismiss: (() -> Void)?
override func cancelOperation(_ sender: Any?) {
orderOut(nil)
onDismiss?()
}
init(
localization: PluginLocalization = PluginLocalization(bundle: .main),
onDismiss: @escaping () -> Void
) {
self.localization = localization
self.viewModel = QuitAppsViewModel(localization: localization)
let size = NSSize(width: 360, height: 460)
super.init(
contentRect: NSRect(origin: .zero, size: size),
styleMask: [.borderless, .nonactivatingPanel],
backing: .buffered,
defer: false
)
self.onDismiss = onDismiss
isFloatingPanel = true
level = .floating
backgroundColor = .clear
isOpaque = false
hasShadow = true
isMovableByWindowBackground = true
collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary]
let effectView = NSVisualEffectView(frame: NSRect(origin: .zero, size: size))
effectView.material = .sidebar
effectView.blendingMode = .behindWindow
effectView.state = .active
effectView.maskImage = Self.roundedMaskImage(size: size, cornerRadius: 16)
let rootView = QuitAppsSelectionView(
viewModel: viewModel,
localization: localization,
onDismiss: { [weak self] in
self?.orderOut(nil)
onDismiss()
}
)
let hostingView = NSHostingView(rootView: rootView)
hostingView.frame = effectView.bounds
hostingView.autoresizingMask = [.width, .height]
effectView.addSubview(hostingView)
contentView = effectView
setContentSize(size)
viewModel.load()
setupAppObservers()
}
override var canBecomeKey: Bool { true }
override var canBecomeMain: Bool { false }
private func setupAppObservers() {
let nc = NSWorkspace.shared.notificationCenter
launchObserver = nc.addObserver(
forName: NSWorkspace.didLaunchApplicationNotification,
object: nil, queue: .main
) { [weak self] _ in
Task { @MainActor [weak self] in self?.viewModel.load() }
}
terminateObserver = nc.addObserver(
forName: NSWorkspace.didTerminateApplicationNotification,
object: nil, queue: .main
) { [weak self] _ in
Task { @MainActor [weak self] in self?.viewModel.load() }
}
}
func cleanup() {
let nc = NSWorkspace.shared.notificationCenter
if let obs = launchObserver { nc.removeObserver(obs); launchObserver = nil }
if let obs = terminateObserver { nc.removeObserver(obs); terminateObserver = nil }
}
private static func roundedMaskImage(size: NSSize, cornerRadius: CGFloat) -> NSImage {
let image = NSImage(size: size, flipped: false) { rect in
NSColor.black.setFill()
NSBezierPath(roundedRect: rect, xRadius: cornerRadius, yRadius: cornerRadius).fill()
return true
}
image.capInsets = NSEdgeInsets(
top: cornerRadius, left: cornerRadius,
bottom: cornerRadius, right: cornerRadius
)
image.resizingMode = .stretch
return image
}
}
// MARK: - SwiftUI View
private struct QuitAppsSelectionView: View {
@ObservedObject var viewModel: QuitAppsViewModel
let localization: PluginLocalization
let onDismiss: () -> Void
private let columns = [GridItem(.adaptive(minimum: 72), spacing: 8)]
var body: some View {
VStack(spacing: 0) {
headerView
Divider().opacity(0.5)
appGridView
Divider().opacity(0.5)
footerView
}
}
// MARK: Header
private var headerView: some View {
HStack(spacing: 8) {
Image(systemName: "power")
.font(.system(size: 16, weight: .medium))
.foregroundStyle(.red)
Text(localization.string("selection.title", defaultValue: "退出应用"))
.font(.system(size: 15, weight: .semibold))
.foregroundStyle(.primary)
Spacer()
Text(localization.format(
"selection.appCountFormat",
defaultValue: "%d 个应用",
viewModel.entries.count
))
.font(.caption)
.foregroundStyle(.secondary)
if !viewModel.selectedEntries.isEmpty {
Button(action: { viewModel.invertSelection() }) {
Text(localization.string("selection.invert", defaultValue: "反选"))
.font(.system(size: 12))
.foregroundStyle(Color.accentColor)
}
.buttonStyle(.plain)
}
}
.padding(.horizontal, 16)
.padding(.vertical, 12)
}
// MARK: App Grid
private var appGridView: some View {
ScrollView {
if viewModel.entries.isEmpty {
VStack(spacing: 8) {
Spacer(minLength: 60)
Image(systemName: "checkmark.circle")
.font(.system(size: 32))
.foregroundStyle(.secondary)
Text(localization.string("selection.empty", defaultValue: "没有正在运行的应用"))
.font(.subheadline)
.foregroundStyle(.secondary)
Spacer(minLength: 60)
}
.frame(maxWidth: .infinity)
} else {
LazyVGrid(columns: columns, spacing: 12) {
ForEach(viewModel.entries) { entry in
AppIconCell(entry: entry) {
viewModel.toggleEntry(id: entry.id)
}
}
}
.padding(.horizontal, 12)
.padding(.vertical, 12)
}
}
.frame(maxHeight: .infinity)
}
// MARK: Footer
private var footerView: some View {
VStack(spacing: 6) {
Button(action: {
viewModel.confirmQuit(onDone: onDismiss)
}) {
Text(viewModel.confirmTitle)
.font(.system(size: 13, weight: .medium))
.foregroundStyle(.white)
.frame(maxWidth: .infinity)
.padding(.vertical, 9)
.background(
RoundedRectangle(cornerRadius: 10)
.fill(
LinearGradient(
colors: [Color(nsColor: .systemRed).opacity(0.85), Color(nsColor: .systemRed)],
startPoint: .top,
endPoint: .bottom
)
)
)
.overlay(
RoundedRectangle(cornerRadius: 10)
.strokeBorder(Color.white.opacity(0.18), lineWidth: 1)
)
}
.buttonStyle(.plain)
.disabled(viewModel.entries.isEmpty)
Button(action: onDismiss) {
Text(localization.string("selection.cancel", defaultValue: "取消"))
.font(.system(size: 11))
.foregroundStyle(.secondary)
}
.buttonStyle(.plain)
}
.padding(.horizontal, 16)
.padding(.top, 10)
.padding(.bottom, 12)
}
}
// MARK: - App Icon Cell
private struct AppIconCell: View {
let entry: QuitAppEntry
let onTap: () -> Void
var body: some View {
Button(action: onTap) {
VStack(spacing: 5) {
ZStack(alignment: .topTrailing) {
Group {
if let icon = entry.app.icon {
Image(nsImage: icon)
.resizable()
.frame(width: 46, height: 46)
} else {
Image(systemName: "app.fill")
.resizable()
.frame(width: 46, height: 46)
.foregroundStyle(.secondary)
}
}
.clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous))
.overlay(
RoundedRectangle(cornerRadius: 10, style: .continuous)
.strokeBorder(
entry.isSelected ? Color.red : Color.clear,
lineWidth: 2
)
)
if entry.isSelected {
Image(systemName: "checkmark.circle.fill")
.font(.system(size: 16, weight: .bold))
.foregroundStyle(.white, .red)
.offset(x: 5, y: -5)
}
}
Text(entry.app.localizedName ?? "App")
.font(.system(size: 11))
.lineLimit(1)
.truncationMode(.tail)
.foregroundStyle(entry.isSelected ? Color.red : Color.primary)
.frame(width: 68)
.help(entry.app.localizedName ?? "App")
}
}
.buttonStyle(.plain)
.contentShape(Rectangle())
.padding(4)
.background(
RoundedRectangle(cornerRadius: 12)
.fill(entry.isSelected ? Color.red.opacity(0.08) : Color.clear)
)
}
}