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

450 lines
23 KiB
Swift
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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, nonactor-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()
}
}