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:
+22
-5
@@ -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(
|
||||
|
||||
@@ -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?
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user