356 lines
12 KiB
Swift
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)
|
|
)
|
|
}
|
|
}
|