项目文件夹

文件
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

450 行
23 KiB
Swift

此文件含有模棱两可的 Unicode 字符
此文件含有可能会与其他字符混淆的 Unicode 字符。 如果您是想特意这样的,可以安全地忽略该警告。 使用 Escape 按钮显示他们。
import AppKit
import MacToolsPluginKit
import OSLog
import SwiftUI
/// A borderless overlay window cannot become key/main by default, which would stop
/// the search field and keyboard navigation from working. Override both.
final class LaunchpadOverlayWindow: NSWindow {
override var canBecomeKey: Bool { true }
override var canBecomeMain: Bool { true }
}
/// Holds the AppKit monitor/observer tokens. Kept in a separate, non–actor-isolated
/// class so its `deinit` can remove them as a backstop (Codex P2 #5) without tripping
/// Swift 6's "non-Sendable access from nonisolated deinit" rule. `removeMonitor` /
/// `removeObserver` are safe to call from any thread.
private final class DismissHandlerTokens {
var keyMonitor: Any?
var screenObserver: NSObjectProtocol?
var resignObserver: NSObjectProtocol?
var resignKeyObserver: NSObjectProtocol?
func removeAll() {
if let keyMonitor {
NSEvent.removeMonitor(keyMonitor)
self.keyMonitor = nil
}
for observer in [screenObserver, resignObserver, resignKeyObserver].compactMap({ $0 }) {
NotificationCenter.default.removeObserver(observer)
}
screenObserver = nil
resignObserver = nil
resignKeyObserver = nil
}
deinit { removeAll() }
}
/// Owns the fullscreen grid overlay window: creation, multi-display placement,
/// dismissal and clean teardown. Mirrors the repo's `PhysicalCleanModeSession`
/// teardown discipline; window config follows the LaunchNext-proven recipe.
@MainActor
final class LaunchpadOverlayController: NSObject, NSWindowDelegate {
private var window: LaunchpadOverlayWindow?
private let tokens = DismissHandlerTokens()
private var isTearingDown = false
/// App that was frontmost before we activated, so dismissal can hand focus back
/// (Codex P1 #3). Cleared on launch (the launched app should win focus).
private var previousApp: NSRunningApplication?
private let catalog = LaunchpadAppCatalog()
private let preferences: LaunchpadPreferences
/// Custom-order layout shared with the grid as `@ObservedObject` so a reorder /
/// reset re-renders the open overlay (the grid does NOT observe `onStateChange`).
private let layoutStore: LaunchpadLayoutStore
/// Folder-eject handoff (the floating icon rides in its own child NSWindow). Controller-owned
/// so `close()` can abort an in-flight eject deterministically — that floating window is NOT
/// part of the overlay window and would survive it otherwise. Internal (not private) so tests
/// can drive a carry through the controller's PRODUCTION storeApplier/close wiring (§10-④).
let dragCoordinator = LaunchpadDragCoordinator()
private let localization: PluginLocalization
/// Window mode captured at `open()`. The grid content is rendered against this
/// snapshot, so frame recomputation (screen changes) must use it too — not live
/// `preferences`, which could drift mid-session and desync frame vs. content (Codex P2).
private var sessionMode: LaunchpadPreferences.WindowMode = .fullscreen
/// Grid metrics resolved from the appearance preferences at `open()` (design §1.4).
///
/// INVARIANT — metrics are constant for the whole overlay session: every consumer
/// (SwiftUI pager, AppKit page grids, folder panel, carry floating window, compact
/// frame) reads THIS snapshot, so a settings change can only take effect on the next
/// summon. Mid-carry appearance changes are therefore unreachable by construction
/// (the settings window taking key closes the overlay via `resignKeyObserver`);
/// the only mid-session geometry mutations left are window resize / screen swaps,
/// already fail-safed by `cancelCarry(.geometryChanged)` in `syncGeometry`.
private var sessionMetrics = LaunchpadGridMetrics()
/// Compact scale captured at `open()` alongside `sessionMode` — screen-change frame
/// recomputation must not read live preferences either (same snapshot discipline).
private var sessionCompactScale = LaunchpadPreferences.defaultCompactScale
private let logger = Logger(
subsystem: Bundle.main.bundleIdentifier ?? "cc.ggbond.mactools",
category: "LaunchpadOverlay"
)
init(
preferences: LaunchpadPreferences,
layoutStore: LaunchpadLayoutStore,
localization: PluginLocalization = PluginLocalization(bundle: .main)
) {
self.preferences = preferences
self.layoutStore = layoutStore
self.localization = localization
super.init()
// The synchronous mouseUp data path (design §1.3): the controller owns both the store and
// the coordinator, so the commit writes the store directly — never through a SwiftUI
// `@Published` token a torn-down view could fail to consume (the resign-active data-loss
// race). Captures the store/name directly: no reference back to self, no cycle.
let folderName = localization.string("folder.defaultName", defaultValue: "未命名")
dragCoordinator.storeApplier = { [layoutStore] action, frozenOrder in
Self.apply(action, frozenOrder: frozenOrder, to: layoutStore, folderName: folderName)
}
}
/// Maps a resolved carry commit onto the layout store, returning the landing id the visual
/// channel re-selects. Mirrors the per-result handlers that previously lived in the grid view
/// (`handleMoveOutOfFolder` and friends) — the store mutation semantics are unchanged.
/// Internal (not private) so tests drive the production mapping directly (design §10-⑤).
static func apply(
_ action: CarryStoreAction,
frozenOrder: [LaunchpadDisplayCell],
to store: LaunchpadLayoutStore,
folderName: String
) -> String? {
// Materialize/extend the layout from the order frozen at drag start, not a live read a
// mid-carry catalog reload may have changed (constraint 15).
let rootApps = frozenOrder.compactMap { cell -> LaunchpadAppItem? in
if case .app(let item) = cell { return item }
return nil
}
switch action {
case .none:
return nil
case .move(let id, let target):
store.captureVisibleOrder(rootApps)
switch target {
case .before(let targetID):
store.move(id: id, before: targetID)
case .after(let targetID):
store.move(id: id, after: targetID)
case nil:
// Global tail. resolveCarryCommit only emits nil targets once the virtual tail
// page lands (step 6); defensive mapping in the meantime.
if let last = frozenOrder.last?.layoutID, last != id { store.move(id: id, after: last) }
}
return id
case .makeFolder(let targetAppID, let draggedID):
store.captureVisibleOrder(rootApps)
let newID = UUID().uuidString
store.makeFolder(target: targetAppID, dragged: draggedID, name: folderName, id: newID)
return newID
case .addToFolder(let folderID, let appID):
store.captureVisibleOrder(rootApps)
store.addToFolder(folderID, app: appID)
return folderID
case .moveOutOfFolder(let folderID, let appID, let result):
store.captureVisibleOrder(rootApps)
switch result {
case .reorder(let target):
store.moveOutOfFolder(folderID, app: appID, to: target)
return appID
case .makeFolder(let targetAppID):
let newID = UUID().uuidString
store.ejectIntoNewFolder(source: folderID, app: appID, target: targetAppID,
name: folderName, id: newID)
return newID
case .addToFolder(let destFolderID):
store.ejectIntoFolder(source: folderID, app: appID, destination: destFolderID)
return destFolderID
}
}
}
var isShown: Bool { window != nil }
func toggle() {
isShown ? close() : open()
}
/// Warm the app catalog ahead of the first open so the grid shows icons
/// immediately instead of a spinner: the disk scan runs in the background
/// now (on plugin activation) rather than on the critical path when the
/// user summons the launcher. Idempotent — `reload()`'s generation guard
/// drops a stale scan, and a later `open()` reload supersedes this one for
/// freshness — so calling it more than once is safe.
func prewarm() {
catalog.reload()
}
func open() {
guard window == nil else { return }
// Restore the *user's* app on dismiss, not ourselves. When triggered from the
// menu bar, frontmost may already be MacTools; capturing that would make
// dismissal a no-op (Codex P2). The global-hotkey path captures the real app.
let front = NSWorkspace.shared.frontmostApplication
previousApp = (front == .current) ? nil : front
catalog.reload()
sessionMode = preferences.windowMode // snapshot for this session
sessionMetrics = LaunchpadGridMetrics.resolve(preferences.appearance)
sessionCompactScale = preferences.compactScalePercent
let screen = activeScreen()
let isCompact = sessionMode == .compact
let frame = targetFrame(on: screen)
let win = LaunchpadOverlayWindow(
contentRect: frame,
styleMask: .borderless,
backing: .buffered,
defer: false,
screen: screen
)
win.delegate = self
win.isOpaque = false
win.backgroundColor = .clear
win.hasShadow = isCompact // a floating panel reads better with a shadow
win.isMovable = false
win.isReleasedWhenClosed = false // don't dealloc mid-session
// Cover the menu bar + Dock + the current (possibly fullscreen) Space, but stay
// a *launcher*, not a screen-saver shroud. `.popUpMenu` (101) sits above the
// menu bar (24) and Dock (20) yet well below `.screenSaver` (1000), which Codex
// (P1 #1) flagged as too heavy for this semantic. `.canJoinAllApplications +
// .fullScreenAuxiliary` is what lets it join another app's fullscreen Space.
// Verified on Tahoe covering the full active display (menu bar + Dock) without
// shrouding the other screen. Coverage over a *fullscreen-app* Space (Safari /
// video / Metal games) and Stage Manager warrants a wider device sweep; raise the
// level only if a real fullscreen Space is found to peek through.
win.level = .popUpMenu
win.collectionBehavior = [.canJoinAllApplications, .fullScreenAuxiliary, .stationary, .ignoresCycle]
win.setFrame(frame, display: true)
let host = NSHostingView(rootView: LaunchpadGridView(
catalog: catalog,
layoutStore: layoutStore,
dragCoordinator: dragCoordinator,
columns: preferences.columns,
isCompact: isCompact,
// The session metrics snapshot (design §1.4): the SwiftUI pager, the AppKit
// page grids and the folder panel all derive from this one injection.
metrics: sessionMetrics,
hiddenAppIDs: preferences.hiddenAppIDs,
// Snapshot, same discipline as `sessionMode`: settings changes apply on the
// next summon (the settings window taking key closes the overlay anyway).
backgroundRecipe: preferences.backgroundRecipe,
localization: localization,
onActivate: { [weak self] app in self?.launch(app) },
onReveal: { [weak self] app in self?.reveal(app) },
onHide: { [weak self] app in self?.preferences.hide(app.id) },
onDismiss: { [weak self] in self?.close() }
))
win.contentView = host
if isCompact {
// Round the floating panel. This layer mask clips the in-process SwiftUI
// content (icons, labels, dim layer, legacy ultraThinMaterial); the glass
// recipes additionally round the behind-window blur itself via the backdrop's
// maskImage with the SAME shared radius (see LaunchpadGlassBackdrop).
// TODO(G5): on macOS 26+ wrap the compact panel in NSGlassEffectView (with an
// NSGlassEffectContainerView shared with the folder panel when both are visible).
// `#available` gated, separate commit, Tahoe screenshots.
host.wantsLayer = true
host.layer?.cornerRadius = LaunchpadCompactPanelMetrics.cornerRadius
host.layer?.masksToBounds = true
}
win.makeKeyAndOrderFront(nil)
win.orderFrontRegardless()
NSApp.activate(ignoringOtherApps: true)
window = win
installDismissHandlers(for: win)
}
/// - Parameter restoringFocus: hand focus back to the previously frontmost app.
/// `true` for user dismissal (Esc / background / app switch); `false` when an app
/// was just launched (it should come forward instead).
func close(restoringFocus: Bool = true) {
guard !isTearingDown else { return }
// A carry may be mid-flight (mouse still down): drop its floating icon window before the
// overlay goes — it's a separate window and would outlive us. Ahead of the window guard
// (nil-safe and idempotent) so no close request can ever leave a floating icon behind.
// Fully synchronous; if a commit already landed, its data hit the store in mouseUp and
// only visuals are skipped.
dragCoordinator.cancelCarry(.overlayClosed)
guard let win = window else { return }
isTearingDown = true
removeDismissHandlers()
// A folder rename may be mid-edit on ANY whole-window teardown path (Cmd+Tab
// resign-active, the Settings window taking key, launching an app from inside a
// folder). SwiftUI only contracts `dismantleNSView` for diffing removals — an
// NSHostingView deallocated together with its window may never get one — so resign
// first responder HERE: the field editor ends editing and the rename coordinator
// commits synchronously on this event stack (design §2.3 "unmount → commit, no data
// loss"). Idempotent for every other responder (search field has no end-editing
// hooks) and the session latch keeps any later dismantle call inert.
win.makeFirstResponder(nil)
win.delegate = nil
win.orderOut(nil)
win.close()
window = nil
if restoringFocus, let previousApp, previousApp != .current {
previousApp.activate()
}
previousApp = nil
isTearingDown = false
}
private func launch(_ app: LaunchpadAppItem) {
let config = NSWorkspace.OpenConfiguration()
config.activates = true
let logger = logger
// Dismiss immediately for a Launchpad-snappy feel (we only ever list validated
// bundles, so failures are rare). On the rare failure, beep + log so the click
// isn't silent (Codex P1) — the launched app, on success, wins focus itself.
NSWorkspace.shared.openApplication(at: app.url, configuration: config) { _, error in
if let error {
// Privacy: never log the full path (it can contain ~/<user>); the bundle
// name is enough, and the error may itself embed the path so keep it private.
logger.error("launch failed for \(app.url.lastPathComponent, privacy: .public): \(error.localizedDescription, privacy: .private)")
NSSound.beep()
}
}
close(restoringFocus: false)
}
private func reveal(_ app: LaunchpadAppItem) {
// Reveal brings Finder forward, so close without restoring focus (Finder wins).
NSWorkspace.shared.activateFileViewerSelecting([app.url])
close(restoringFocus: false)
}
/// v1 decision: open on the screen under the mouse (NOT NSScreen.main, NOT all
/// screens). Documented single-display behavior.
private func activeScreen() -> NSScreen {
let mouse = NSEvent.mouseLocation
return NSScreen.screens.first { $0.frame.contains(mouse) }
?? NSScreen.main
?? NSScreen.screens[0]
}
/// Window frame for the current mode: the whole screen (fullscreen) or a centered
/// floating panel (compact). Used for both initial placement and screen changes.
private func targetFrame(on screen: NSScreen) -> NSRect {
guard sessionMode == .compact else { return screen.frame }
// P2 (ruling A5): the scaled branch — user-tunable percentage, a 4×3 grid floor
// that rises with the icon size, and NO 960×680 hard cap any more. Deliberate
// behaviour change whose scope is wider than "large screens" (final-review
// correction): the old cap bound on any visibleFrame over ~1333×829pt, which
// includes every modern built-in laptop display — those users get a ~13% larger
// default panel; the new window-size slider is the dial for that.
return LaunchpadLayoutMath.compactFrame(
visible: screen.visibleFrame,
scalePercent: sessionCompactScale,
metrics: sessionMetrics,
legacyCap: false
)
}
// MARK: - Dismissal (Codex P0 #4)
/// Where an Esc keystroke lands — the design §2.4 ladder: cancel the rename (route the
/// event to the field editor) → close the open folder → close the launcher. Static and
/// value-typed so the whole ladder is unit-testable without a window or a key event.
enum EscapeResolution: Equatable {
case routeToFieldEditor, closeFolder, closeOverlay
}
static func resolveEscape(firstResponder: NSResponder?,
isFolderOpen: Bool,
carryLive: Bool) -> EscapeResolution {
// Mid-IME-composition Esc must cancel the candidate window: let the event through
// to the field editor (Codex P1). Its own `cancelOperation` handles the rest.
if let editor = firstResponder as? NSTextView, editor.hasMarkedText() {
return .routeToFieldEditor
}
// A folder rename being edited owns Esc — the rename field editor's `cancelOperation`
// restores the original name. Checked BEFORE the folder rung so cancelling the edit
// never closes the folder underneath it.
if LaunchpadFolderRenameField.shouldRouteEsc(to: firstResponder) {
return .routeToFieldEditor
}
// Middle rung: an open folder closes before the launcher does. Skipped while a carry
// session is live (carrying OR settling) — Esc mid-carry keeps the historical
// abort-everything semantics (`close()` → `cancelCarry`), and during an eject the
// panel is already zoomed shut anyway.
if isFolderOpen, !carryLive {
return .closeFolder
}
return .closeOverlay
}
/// Close on Esc / app deactivation only — NOT on every key/main loss. IME candidate
/// windows, permission dialogs and Space switches steal key status and must not
/// dismiss the launcher. Background clicks are handled inside the SwiftUI content.
///
/// Both observers are bound to `session` (the window live when they were installed):
/// if the user closes and instantly reopens, a queued notification from the old
/// session no longer matches `self.window` and is dropped (Codex P1 #2). The search
/// field's own `cancelOperation` is a second Esc path when the field is first
/// responder, covering activation-failure cases (Codex P2 #4).
private func installDismissHandlers(for session: LaunchpadOverlayWindow) {
tokens.keyMonitor = NSEvent.addLocalMonitorForEvents(matching: [.keyDown]) { [weak self] event in
// Esc — resolved through the §2.4 ladder rule below (unit-tested): cancel an
// IME composition / a live rename first, then close an open folder, and only
// then the launcher itself.
if event.keyCode == 53 {
guard let self else { return event }
switch Self.resolveEscape(firstResponder: self.window?.firstResponder,
isFolderOpen: self.dragCoordinator.isFolderOpen,
carryLive: self.dragCoordinator.carrySession != nil) {
case .routeToFieldEditor:
return event
case .closeFolder:
// The grid view owns the close choreography (commit a live rename, zoom
// out), so publish a request instead of mutating SwiftUI state from here.
self.dragCoordinator.requestFolderClose()
return nil
case .closeOverlay:
self.close()
return nil
}
}
return event
}
tokens.screenObserver = NotificationCenter.default.addObserver(
forName: NSApplication.didChangeScreenParametersNotification, object: nil, queue: .main
) { [weak self] _ in
Task { @MainActor in
guard let self, let win = self.window, win === session else { return }
win.setFrame(self.targetFrame(on: self.activeScreen()), display: true)
}
}
tokens.resignObserver = NotificationCenter.default.addObserver(
forName: NSApplication.didResignActiveNotification, object: nil, queue: .main
) { [weak self] _ in
Task { @MainActor in
guard let self, self.window === session else { return }
// The user switched away (Cmd+Tab / clicked another app): don't yank focus
// back to the previously-frontmost app and fight their intent. Only the
// explicit Esc / background-click paths restore focus.
self.close(restoringFocus: false)
}
}
// Another window of *this* app took key focus (e.g. the Settings window opened, or the
// user clicked it behind a compact panel). The app stays active, so `didResignActive`
// never fires — dismiss here instead, but ONLY when an ordinary titled window takes key.
// Menus, IME candidate windows and alert panels aren't titled, so right-click menus and
// CJK composition won't close the launcher.
tokens.resignKeyObserver = NotificationCenter.default.addObserver(
forName: NSWindow.didResignKeyNotification, object: session, queue: .main
) { [weak self] _ in
Task { @MainActor in
guard let self, self.window === session, !self.isTearingDown else { return }
if let key = NSApp.keyWindow, key !== session,
!(key is NSPanel), key.styleMask.contains(.titled) {
self.close(restoringFocus: false)
}
}
}
}
private func removeDismissHandlers() {
tokens.removeAll()
}
}