fix: enable window dragging outside interactive controls

Use background-only native drag gestures so scroll content and overlays remain draggable without stealing control, text, or queue-row input. Compact mode uses the same explicit drag surfaces rather than broad native background movement.
This commit is contained in:
2026-10-19 17:41:55 +02:00
parent 997eb144d2
commit 6d653e8875
8 changed files with 249 additions and 13 deletions
+22 -5
View File
@@ -36,6 +36,21 @@ class StartupStreamingServer(StreamingServer):
super()._get(request)
def check_window_drag(root, app, fixtures):
result_path = root / "window-drag.json"
diagnostics = root / "window-drag.log"
result = subprocess.run(
["open", "-n", "-W", "--stderr", str(diagnostics), "-a", str(app),
str(fixtures / "cache.mp4"), "--args", "--no-config", "--check-window-drag", str(result_path), str(fixtures)],
capture_output=True, text=True, timeout=30,
)
report = json.loads(result_path.read_text()) if result_path.exists() else {}
if result.returncode != 0 or not report.get("passed"):
log = diagnostics.read_text(errors="replace") if diagnostics.exists() else ""
raise RuntimeError(f"native window drag failed: {report}; {result.stdout}{result.stderr}{log}")
print("PASS native window dragging: " + str(report))
def check_queue_drag(root, app, fixtures):
result_path = root / "queue-drag.json"
diagnostics = root / "queue-drag.log"
@@ -671,11 +686,13 @@ with tempfile.TemporaryDirectory(prefix="terebi-startup-") as directory:
if launch.returncode != 0 or not result["passed"] or result["bundleIdentifier"] != identifier:
raise RuntimeError(f"packaged startup failed: {result}; {stdout}{stderr}")
print("PASS packaged bootstrap: registered app PID/identity, in-process player load, actual playback and Now Playing")
check_queue_drag(root, app, fixtures)
if os.environ.get("TEREBI_CHECK_QUEUE_ONLY") != "1":
check_window_layout(root, app, fixtures)
if os.environ.get("TEREBI_CHECK_LAYOUT_ONLY") != "1":
check_cli_instances(root, app, fixtures)
check_window_drag(root, app, fixtures)
if os.environ.get("TEREBI_CHECK_DRAG_ONLY") != "1":
check_queue_drag(root, app, fixtures)
if os.environ.get("TEREBI_CHECK_QUEUE_ONLY") != "1":
check_window_layout(root, app, fixtures)
if os.environ.get("TEREBI_CHECK_LAYOUT_ONLY") != "1":
check_cli_instances(root, app, fixtures)
finally:
os.close(descriptor)
subprocess.run(
+194
View File
@@ -1,10 +1,15 @@
import AppKit
import Darwin
import MediaPlayer
import ObjectiveC
@_cdecl("terebi_app_main")
@MainActor
public func checkAppStartup() {
if let index = CommandLine.arguments.firstIndex(of: "--check-window-drag") {
WindowDragCheck.run(result: URL(fileURLWithPath: CommandLine.arguments[index + 1]))
return
}
if let index = CommandLine.arguments.firstIndex(of: "--check-queue-drag") {
QueueDragCheck.run(result: URL(fileURLWithPath: CommandLine.arguments[index + 1]))
return
@@ -20,6 +25,195 @@ public func checkAppStartup() {
AppStartupCheck.run()
}
@MainActor
private enum WindowDragCheck {
static var timer: Timer?
static var resultURL: URL!
static var phase = 0
static var probe = 0
static var drags = 0
static var previousDrags = 0
static var start = NSPoint.zero
static var implementations: [(Method, IMP)] = []
static var target: NSWindow?
static var dragDepth = 0
static let names = ["video", "title", "playback padding", "play button", "volume slider",
"start whitespace", "start button", "start artwork", "URL text field",
"queue whitespace", "queue footer", "queue row", "queue button",
"error backdrop", "error button", "audio artwork",
"compact video", "compact padding", "compact button", "hidden controls"]
static let controls: Set<Int> = [3, 4, 6, 8, 11, 12, 14, 18]
// Infrastructure timeout only; verdicts depend on input routing, not elapsed time.
static let deadline = ContinuousClock.now.advanced(by: .seconds(25))
static func run(result: URL) {
resultURL = result
_ = NSApplication.shared
timer = Timer.scheduledTimer(withTimeInterval: 0.05, repeats: true) { _ in
MainActor.assumeIsolated { advance() }
}
RunLoop.main.add(timer!, forMode: .common)
terebiAppMain()
}
static func finish(_ error: String? = nil) {
timer?.invalidate()
for (method, original) in implementations {
imp_removeBlock(method_setImplementation(method, original))
}
try! JSONSerialization.data(withJSONObject: [
"passed": error == nil, "error": error ?? "", "probes": probe, "drags": drags,
]).write(to: resultURL, options: .atomic)
PlayerModel.shared.shutdown()
NSApp.terminate(nil)
}
static func nativeView(in view: NSView, matching predicate: (NSView) -> Bool) -> NSView? {
if predicate(view) { return view }
for child in view.subviews {
if let found = nativeView(in: child, matching: predicate) { return found }
}
return nil
}
static func point(in window: NSWindow) -> NSPoint? {
let content = window.contentView!
let w = content.bounds.width
let h = content.bounds.height
let view: NSView?
switch probe {
case 0, 7: return NSPoint(x: 100, y: 160)
case 1: return NSPoint(x: 150, y: h - 16)
case 2: return NSPoint(x: w / 2 - 210, y: 48)
case 3: return NSPoint(x: w / 2, y: 64)
case 4: view = nativeView(in: content) { ($0 as? NSSlider)?.accessibilityLabel() == "Volume" }
case 5: return NSPoint(x: 600, y: 160)
case 6: return NSPoint(x: 400, y: 272)
case 8: view = nativeView(in: content) { ($0 as? NSTextField)?.isEditable == true }
case 9: return NSPoint(x: w - 130, y: h / 2)
case 10: return NSPoint(x: w - 160, y: 18)
case 11: return NSPoint(x: w - 130, y: h - 80)
case 12: return NSPoint(x: w - 183, y: 18)
case 13: return NSPoint(x: 100, y: h / 2)
case 14: return NSPoint(x: w / 2, y: h / 2 - 51.5)
case 15, 19: return NSPoint(x: w / 2, y: h / 2)
case 16: return NSPoint(x: 100, y: 100)
case 17: return NSPoint(x: w / 2 - 77, y: 28)
case 18: return NSPoint(x: w / 2 - 60, y: 28)
default: return nil
}
guard let view, !view.bounds.isEmpty else { return nil }
return view.convert(NSPoint(x: view.bounds.midX, y: view.bounds.midY), to: nil)
}
static func interceptDrags(in window: NSWindow) {
target = window
let method = class_getInstanceMethod(type(of: window), #selector(NSWindow.performDrag(with:)))!
let original = unsafeBitCast(method_getImplementation(method),
to: (@convention(c) (NSWindow, Selector, NSEvent) -> Void).self)
let handler: @convention(block) (NSWindow, NSEvent) -> Void = { window, event in
MainActor.assumeIsolated {
if window === target && dragDepth == 0 { drags += 1 }
dragDepth += 1
original(window, #selector(NSWindow.performDrag(with:)), event)
dragDepth -= 1
}
}
implementations.append((method, method_setImplementation(method, imp_implementationWithBlock(handler))))
// SwiftUI uses AppKit's completion-taking entry point on newer macOS releases.
let selector = NSSelectorFromString("performWindowDragWithEvent:completionHandler:")
if let method = class_getInstanceMethod(type(of: window), selector) {
let original = unsafeBitCast(method_getImplementation(method),
to: (@convention(c) (NSWindow, Selector, NSEvent, AnyObject?) -> Void).self)
let handler: @convention(block) (NSWindow, NSEvent, AnyObject?) -> Void = { window, event, done in
MainActor.assumeIsolated {
if window === target && dragDepth == 0 { drags += 1 }
dragDepth += 1
original(window, selector, event, done)
dragDepth -= 1
}
}
implementations.append((method, method_setImplementation(method, imp_implementationWithBlock(handler))))
}
}
static func advance() {
guard ContinuousClock.now < deadline else {
finish("Window drag timed out at \(names[probe]), phase \(phase)"); return
}
guard let window = NSApp.windows.first(where: { $0.canBecomeMain && !($0 is NSPanel) }) else { return }
let model = PlayerModel.shared
let fixtures = URL(fileURLWithPath: CommandLine.arguments.last!, isDirectory: true)
if target == nil {
guard model.canTogglePlayback else { return }
interceptDrags(in: window)
model.setVolume(0)
model.setPlayback(playing: false)
window.makeKeyAndOrderFront(nil)
NSApp.activate(ignoringOtherApps: true)
}
if phase == 0 {
guard NSApp.isActive, window.isKeyWindow else {
window.makeKeyAndOrderFront(nil)
NSApp.activate(ignoringOtherApps: true)
return
}
model.setVolume(0)
switch probe {
case 5: model.closeFile()
case 8:
for type in [NSEvent.EventType.leftMouseDown, .leftMouseUp] {
let event = NSEvent.mouseEvent(with: type, location: NSPoint(x: 400, y: 272),
modifierFlags: [], timestamp: ProcessInfo.processInfo.systemUptime,
windowNumber: window.windowNumber, context: nil, eventNumber: type == .leftMouseDown ? 1 : 2,
clickCount: 1, pressure: type == .leftMouseDown ? 1 : 0)!
NSApp.postEvent(NSEvent(cgEvent: event.cgEvent!)!, atStart: false)
}
case 9:
model.open(fixtures.appendingPathComponent("cache.mp4"))
model.showsQueue = true
case 13:
model.showsQueue = false
model.openYouTube("not a video URL")
case 15: model.open(fixtures.appendingPathComponent("audio.wav"))
case 16: model.open(fixtures.appendingPathComponent("cache.mp4"))
case 19: model.hideControls(hideCursor: false)
default: break
}
previousDrags = drags
} else if phase == 1 {
guard !model.isQueueTransitioning, !model.compactWindow.isTransitioning, !model.isBusy else { return }
if probe == 16 && !model.isCompact { model.toggleCompact(); return }
// Posted events do not move the physical pointer into the compact window.
if probe != 19 { model.setPointerInsideWindow(true) }
guard let point = point(in: window) else { return }
start = point
} else if (2...4).contains(phase) {
let type: NSEvent.EventType = phase == 2 ? .leftMouseDown : (phase == 3 ? .leftMouseDragged : .leftMouseUp)
let point = phase == 2 ? start : NSPoint(x: start.x + 20, y: start.y + 48)
let event = NSEvent.mouseEvent(with: type, location: point, modifierFlags: [],
timestamp: ProcessInfo.processInfo.systemUptime, windowNumber: window.windowNumber,
context: nil, eventNumber: probe * 8 + phase, clickCount: 1, pressure: phase == 4 ? 0 : 1)!
let native = event.cgEvent!
if type == .leftMouseDragged {
native.setDoubleValueField(.mouseEventDeltaX, value: 20)
native.setDoubleValueField(.mouseEventDeltaY, value: -48)
}
NSApp.postEvent(NSEvent(cgEvent: native)!, atStart: false)
} else if phase == 6 {
let expected = controls.contains(probe) ? 0 : 1
guard drags - previousDrags == expected else {
finish("\(names[probe]): expected \(expected) native drag requests, got \(drags - previousDrags)"); return
}
probe += 1
if probe == names.count { finish(); return }
phase = 0
return
}
phase += 1
}
}
@MainActor
private enum QueueDragCheck {
static var timer: Timer?
-2
View File
@@ -211,8 +211,6 @@ struct CompactWindowChecks {
"compact removes incompatible primary and auxiliary policies")
try CompactWindowChecks.require(window.contentMinSize == NSSize(width: 160, height: 90),
"compact uses 160x90 content minimum")
try CompactWindowChecks.require(window.isMovableByWindowBackground,
"compact allows background dragging")
try CompactWindowChecks.require(window.canBecomeKey, "compact accepts keyboard playback controls")
try CompactWindowChecks.require(window.titleVisibility == .hidden
&& window.titlebarAppearsTransparent,
+1 -1
View File
@@ -47,9 +47,9 @@ struct SystemPlaybackChecks {
window.isReleasedWhenClosed = false
defer { window.close() }
let surface = PlaybackSurface.DragView(model: model)
surface.frame = window.contentView!.bounds
window.contentView!.addSubview(surface)
model.attachWindow(window)
surface.frame = window.contentView!.bounds
@MainActor func event(_ type: NSEvent.EventType, clicks: Int = 1) -> NSEvent {
NSEvent.mouseEvent(with: type, location: NSPoint(x: 100, y: 100), modifierFlags: [],
timestamp: 0, windowNumber: window.windowNumber, context: nil,