Files
dartamp/macos/Runner/AppDelegate.swift
T
2026-08-12 17:57:20 +02:00

279 lines
9.7 KiB
Swift

import Cocoa
import FlutterMacOS
@main
class AppDelegate: FlutterAppDelegate {
private let winampChannelName = "winamp"
override func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool {
return true
}
override func applicationSupportsSecureRestorableState(_ app: NSApplication) -> Bool {
return false
}
override func applicationWillFinishLaunching(_ notification: Notification) {
super.applicationWillFinishLaunching(notification)
guard !activateExistingInstanceOrTerminate() else { return }
// Register the channel handler eagerly so Dart's startup menu-state sync
// is not dropped. `applicationDidFinishLaunching` is never delivered in
// this Flutter version, so this is the earliest reliable point. The
// FlutterViewController is already attached here, but poll briefly as a
// fallback for other Flutter versions where it may not be yet.
registerWinampChannelWhenReady()
installMouseDiagnostics()
}
/// Temporary diagnostics: logs every mouse event macOS delivers to one of
/// our Flutter windows (frame, location, and whether the window moved the
/// mouse over its content). Used to trace why the drag loop gets no events
/// at launch while it works after the first resize.
private var mouseDiag: Any?
private func installMouseDiagnostics() {
guard mouseDiag == nil else { return }
mouseDiag = NSEvent.addLocalMonitorForEvents(
matching: [.leftMouseDown, .leftMouseDragged, .leftMouseUp, .mouseMoved]
) { event in
guard let window = event.window,
let vc = window.contentViewController as? FlutterViewController
else { return event }
let view = vc.view
let local = view.convert(event.locationInWindow, from: nil)
let inView = view.bounds.contains(local)
NSLog(
"mdiag %@ local=(%.1f,%.1f) inView=%@ frame=%@ wf=%@",
Self.name(for: event.type),
local.x, local.y,
inView ? "y" : "n",
NSStringFromRect(view.bounds),
NSStringFromRect(window.frame)
)
return event
}
}
private static func name(for type: NSEvent.EventType) -> String {
switch type {
case .leftMouseDown: return "down"
case .leftMouseDragged: return "drag"
case .leftMouseUp: return "up"
case .mouseMoved: return "move"
default: return "other"
}
}
/// Quits this instance if another copy of the app is already running, and
/// activates the existing one. Without this, a second launch stacks a second
/// set of windows (the windows are borderless with custom drag, so a stray
/// main window cannot even be closed). Returns `true` when this instance
/// terminated itself (i.e. the launch must stop), `false` to continue.
private func activateExistingInstanceOrTerminate() -> Bool {
let bundleId = Bundle.main.bundleIdentifier ?? ""
if bundleId.isEmpty { return false }
let selfPid = ProcessInfo.processInfo.processIdentifier
let others = NSRunningApplication.runningApplications(
withBundleIdentifier: bundleId
).filter { $0.processIdentifier != selfPid }
guard let other = others.first else { return false }
other.activate(options: [.activateIgnoringOtherApps])
NSApp.terminate(nil)
return true
}
private func registerWinampChannelWhenReady() {
guard winampChannel != nil else {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { [weak self] in
self?.registerWinampChannelWhenReady()
}
return
}
}
private var flutterController: FlutterViewController? {
return mainFlutterWindow?.contentViewController as? FlutterViewController
}
/// Lazily-created channel back to Dart. The handler is registered once so
/// Dart can report state (always-on-top, EQ/PL open, UI scale) to update
/// the matching menu checkmarks.
private var winampChannel: FlutterMethodChannel? {
if _winampChannel == nil, let controller = flutterController {
let channel = FlutterMethodChannel(
name: winampChannelName,
binaryMessenger: controller.engine.binaryMessenger
)
channel.setMethodCallHandler(handleMethodCall)
_winampChannel = channel
}
return _winampChannel
}
private var _winampChannel: FlutterMethodChannel?
/// Custom cursors registered by the Dart side, keyed by skin cursor name.
private var cursors: [String: NSCursor] = [:]
private func handleMethodCall(_ call: FlutterMethodCall, result: @escaping FlutterResult) {
switch call.method {
case "setAlwaysOnTop":
if let args = call.arguments as? [String: Any],
let on = args["isAlwaysOnTop"] as? Bool {
setMenuItemState(id: "win-always-on-top", title: "Always on Top", on: on)
}
result(nil)
case "setWindowShade":
if let args = call.arguments as? [String: Any], let on = args["on"] as? Bool {
setMenuItemState(id: "win-window-shade", title: "Window Shade", on: on)
}
result(nil)
case "setEqOpen":
if let args = call.arguments as? [String: Any], let open = args["open"] as? Bool {
setMenuItemState(id: "win-equalizer", title: "Equalizer", on: open)
}
result(nil)
case "setPlOpen":
if let args = call.arguments as? [String: Any], let open = args["open"] as? Bool {
setMenuItemState(id: "win-playlist", title: "Playlist", on: open)
}
result(nil)
case "setUiScale":
if let args = call.arguments as? [String: Any], let scale = args["scale"] as? Double {
setUiScaleMenuState(scale)
}
result(nil)
case "getScreenBounds":
// The union of every screen's visible frame (menu bar and dock
// excluded), converted to window_manager's top-left origin coordinates.
// The primary screen's full frame is the origin reference, so displays
// arranged above it map to negative Y and displays below it to Y beyond
// the primary's height — the drag clamp/snap then span the whole desktop.
let screens = NSScreen.screens
if !screens.isEmpty {
let f = screens[0].frame
var union = screens[0].visibleFrame
for screen in screens.dropFirst() {
union = union.union(screen.visibleFrame)
}
result([
"x": union.origin.x,
"y": f.height - union.origin.y - union.height,
"width": union.width,
"height": union.height,
])
} else {
result(nil)
}
case "getMouseLocation":
// The global cursor position, flipped into window_manager's top-left
// origin space (same convention as getScreenBounds and setPosition), so
// the custom drag loop can position windows from absolute screen points
// instead of window-local pointer coordinates.
let mouse = NSEvent.mouseLocation
let screenHeight = NSScreen.screens.first?.frame.height ?? mouse.y
result(["x": mouse.x, "y": screenHeight - mouse.y])
case "registerCursor":
if let args = call.arguments as? [String: Any],
let name = args["name"] as? String,
let pngData = (args["png"] as? FlutterStandardTypedData)?.data,
let image = NSImage(data: pngData) {
let hotX = args["hotspotX"] as? Int ?? 0
let hotY = args["hotspotY"] as? Int ?? 0
cursors[name] = NSCursor(image: image, hotSpot: NSPoint(x: hotX, y: hotY))
}
result(nil)
case "setCursor":
if let args = call.arguments as? [String: Any], let name = args["name"] as? String {
if let cursor = cursors[name] {
cursor.set()
} else {
NSCursor.arrow.set()
}
}
result(nil)
default:
result(FlutterMethodNotImplemented)
}
}
private func setMenuItemState(id: String, title: String, on: Bool) {
menuItem(id: id, title: title)?.state = on ? .on : .off
}
private func setUiScaleMenuState(_ scale: Double) {
let targetId: String
let targetTitle: String
if scale == 1.0 {
targetId = "win-ui-scale-1x"
targetTitle = "1.0x"
} else if scale == 2.0 {
targetId = "win-ui-scale-2x"
targetTitle = "2.0x"
} else {
targetId = "win-ui-scale-3x"
targetTitle = "3.0x"
}
for (id, title) in [
("win-ui-scale-1x", "1.0x"),
("win-ui-scale-2x", "2.0x"),
("win-ui-scale-3x", "3.0x"),
] {
setMenuItemState(id: id, title: title, on: id == targetId && title == targetTitle)
}
}
private func menuItem(id: String, title: String) -> NSMenuItem? {
guard let menu = NSApplication.shared.mainMenu else { return nil }
return findMenuItem(id: id, title: title, in: menu)
}
private func findMenuItem(id: String, title: String, in menu: NSMenu) -> NSMenuItem? {
for item in menu.items {
if item.identifier?.rawValue == id || item.title == title {
return item
}
if let submenu = item.submenu,
let found = findMenuItem(id: id, title: title, in: submenu) {
return found
}
}
return nil
}
private func send(_ method: String, _ arguments: Any? = nil) {
winampChannel?.invokeMethod(method, arguments: arguments)
}
@IBAction func toggleAlwaysOnTop(_ sender: NSMenuItem) {
send("toggleAlwaysOnTop")
}
@IBAction func toggleWindowShade(_ sender: NSMenuItem) {
send("toggleWindowShade")
}
@IBAction func toggleEqualizer(_ sender: NSMenuItem) {
send("toggleEq")
}
@IBAction func togglePlaylist(_ sender: NSMenuItem) {
send("togglePlaylist")
}
@IBAction func setUIScale1x(_ sender: NSMenuItem) {
send("setUIScale", ["scale": 1.0])
}
@IBAction func setUIScale2x(_ sender: NSMenuItem) {
send("setUIScale", ["scale": 2.0])
}
@IBAction func setUIScale3x(_ sender: NSMenuItem) {
send("setUIScale", ["scale": 3.0])
}
@IBAction func importSkin(_ sender: NSMenuItem) {
send("importSkin")
}
}