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) ) } }