forked from J-M-PUNK/tideway
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathdesktop.py
More file actions
1804 lines (1648 loc) · 75.8 KB
/
Copy pathdesktop.py
File metadata and controls
1804 lines (1648 loc) · 75.8 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
"""Packaged desktop entry point.
Starts the FastAPI server on a fixed loopback port, opens a pywebview
window pointed at it, and tears everything down cleanly when the window
closes. Single-instance: a second launch detects the running copy via
/api/health, asks it to focus its window, and exits.
Import chain note: importing `server` eagerly instantiates TidalClient,
Downloader, and everything else. That's intentional — we want to fail
fast (missing data dir, broken session file, etc.) before showing the
window, so the user sees a real error in the console instead of a
hung blank webview.
"""
from __future__ import annotations
import argparse
import io
import logging
import os
import sys
import threading
import time
import urllib.error
import urllib.request
from pathlib import Path
from typing import Optional
# PyInstaller's windowed mode on Windows (`console=False` in the spec)
# starts the process with no console attached, so `sys.stdout` and
# `sys.stderr` come up as `None`. Anything that calls `.write()` or
# `.isatty()` on them then crashes — uvicorn's DefaultFormatter is the
# canonical victim, but every `print(..., file=sys.stderr, ...)` we have
# would trip the same wire. Wire both streams to os.devnull so logging
# is silently dropped instead of bringing the app down at startup.
#
# encoding="utf-8" + errors="replace" matters: without them the wrapper
# inherits the locale code page (cp1250 on Polish Windows, cp1252 on
# Western European, etc.) with strict error handling. A `print()` of a
# string containing characters outside that code page raises
# UnicodeEncodeError, which bricks any worker thread that prints a
# non-ASCII track title. Issues #7, #36, #70 all trace back to this.
if sys.stdout is None:
sys.stdout = io.TextIOWrapper(
open(os.devnull, "wb"),
encoding="utf-8",
errors="replace",
write_through=True,
)
if sys.stderr is None:
sys.stderr = io.TextIOWrapper(
open(os.devnull, "wb"),
encoding="utf-8",
errors="replace",
write_through=True,
)
# Dev-mode + console-mode frozen builds also have real stderr/stdout,
# which on Windows still inherit the locale code page with strict
# errors. Reconfigure to errors="replace" so a print of a non-encodable
# character degrades to a `?` instead of raising. No behavior change on
# Linux/macOS where the default encoding is already UTF-8.
for _stream in (sys.stdout, sys.stderr):
reconfigure = getattr(_stream, "reconfigure", None)
if reconfigure is not None:
try:
reconfigure(errors="replace")
except (ValueError, OSError):
# Some wrappers (e.g. pytest captures, the io.TextIOWrapper
# we just created above with errors already set) refuse a
# second reconfigure. Not load-bearing — skip.
pass
def _configure_webview2_autoplay() -> None:
"""Let WebView2 autoplay audible media without a user gesture.
Windows-only. Music-video playback in the app relies on hls.js
(Chromium doesn't decode HLS natively), and Chromium's default
autoplay policy blocks audible playback until the Media
Engagement Index for the origin is high enough. That makes
videos start muted with a one-click-to-unmute.
For the packaged app, the "origin" is a fresh loopback with no
engagement history every launch — the policy never relaxes
naturally. Overriding via the Chromium flag matches what every
Electron-based music app (Spotify desktop, Tidal desktop,
Apple Music Web) does.
WebView2 reads WEBVIEW2_ADDITIONAL_BROWSER_ARGUMENTS at startup,
so setting it before `webview.start()` is sufficient. No-op on
macOS (WKWebView has its own autoplay handling) and on Linux
(WebKitGTK uses a different mechanism that users can configure
through GNOME / KDE policy).
"""
if not sys.platform.startswith("win"):
return
# Merge with any pre-existing value — dev builds may already
# set other flags we shouldn't stomp on.
existing = os.environ.get("WEBVIEW2_ADDITIONAL_BROWSER_ARGUMENTS", "")
flag = "--autoplay-policy=no-user-gesture-required"
if flag not in existing:
os.environ["WEBVIEW2_ADDITIONAL_BROWSER_ARGUMENTS"] = (
f"{existing} {flag}".strip()
)
# Binding 127.0.0.1 (not 0.0.0.0) keeps the server invisible to the LAN —
# the desktop app is a single-user tool and nothing on it should be
# reachable from another device.
HOST = "127.0.0.1"
# Port is deterministic so the single-instance probe always knows where
# to look. Picked from the IANA user/ephemeral range, far from common
# dev-server ports to minimize conflicts. If you hit a clash, change
# here AND in the launcher probe below.
PORT = 47823
HEALTH_URL = f"http://{HOST}:{PORT}/api/health"
FOCUS_URL = f"http://{HOST}:{PORT}/api/_internal/focus"
def _probe_existing_instance(timeout: float = 0.5) -> bool:
"""Return True if a sibling app is already serving on PORT.
False covers both "port is free" and "port is held by something
else." In the latter case we let uvicorn's bind error surface —
squatting on our own port without the health marker is something
the user will want to see, not silently suppress.
"""
try:
with urllib.request.urlopen(HEALTH_URL, timeout=timeout) as resp:
if resp.status != 200:
return False
import json
body = json.loads(resp.read().decode("utf-8"))
return body.get("app") == "tidal-downloader"
except (urllib.error.URLError, OSError, ValueError):
return False
def _ask_existing_to_focus() -> None:
"""Best-effort: poke the running instance to raise its window."""
try:
req = urllib.request.Request(FOCUS_URL, method="POST")
with urllib.request.urlopen(req, timeout=1.0):
pass
except Exception:
# The running instance may not have a window (headless dev run)
# or may be shutting down — nothing useful we can do either way.
pass
def _child_env() -> dict:
"""Environment for spawning system binaries from a frozen build.
AppRun (AppImage) and PyInstaller prepend our bundled library dir
to LD_LIBRARY_PATH so the frozen app finds its own libraries. A
child process inherits that, so a system binary — the browser
fallback's /bin/sh, xdg-open — loads our bundled libreadline /
libtinfo instead of the host's and dies with
"undefined symbol: rl_trim_arg_from_keyseq". AppRun stashes the
pristine host value in TIDEWAY_HOST_LD_LIBRARY_PATH; PyInstaller
stashes its own pre-launch value in LD_LIBRARY_PATH_ORIG. Restore
whichever we have, else drop the var, so children link against the
host's libraries. No-op when not frozen — nothing to leak.
"""
env = dict(os.environ)
if not getattr(sys, "frozen", False):
return env
host = env.get("TIDEWAY_HOST_LD_LIBRARY_PATH")
if host is None:
host = env.get("LD_LIBRARY_PATH_ORIG")
if host:
env["LD_LIBRARY_PATH"] = host
else:
env.pop("LD_LIBRARY_PATH", None)
return env
def _open_in_browser(url: str) -> None:
"""Open `url` in the user's browser without leaking the bundle's
library path into the opener.
macOS (`open`) and Windows (`os.startfile`) openers don't exec a
shell that would load our libs, so webbrowser is used directly.
A frozen Linux build must spawn the opener with a sanitized
environment (see _child_env); webbrowser itself goes through
/bin/sh, which is exactly what crashes, so call the opener
directly. If no opener is found we print the URL rather than
re-trip the crash.
"""
if sys.platform != "linux" or not getattr(sys, "frozen", False):
import webbrowser
webbrowser.open(url)
return
import shutil
import subprocess
env = _child_env()
path_dirs = env.get("PATH")
for opener in ("xdg-open", "gio"):
exe = shutil.which(opener, path=path_dirs)
if not exe:
continue
argv = [exe, "open", url] if opener == "gio" else [exe, url]
try:
subprocess.Popen(
argv,
env=env,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
start_new_session=True,
)
return
except OSError:
continue
print(
f"[desktop] Couldn't find xdg-open. Open this in your browser: {url}",
file=sys.stderr,
flush=True,
)
def _run_uvicorn_in_thread() -> "uvicorn.Server": # type: ignore[name-defined]
"""Start uvicorn on a daemon thread and return the Server handle so
the main thread can stop it on window close."""
import uvicorn
# Import server here, not at module top, so a caller that only wants
# the probe helpers (e.g. tests) doesn't pay the full import cost.
import server as _server # noqa: F401 — side-effectful init
config = uvicorn.Config(
"server:app",
host=HOST,
port=PORT,
log_level="info",
# Disable reload / workers — single process is what pywebview
# expects, and reload would spawn a second server that doesn't
# share the download broker state.
reload=False,
workers=1,
# Keep the access log quiet in packaged builds; uvicorn's default
# formatter is noisy for a GUI app.
access_log=False,
# Stay on the stock asyncio loop rather than letting uvicorn pick
# uvloop, which `uvicorn[standard]` pulls in and selects by
# default. python-zeroconf's QueryScheduler keeps uvloop's idle
# handle armed, so the loop busy-spins instead of sleeping: one
# service browser costs ~9% of a core, and Cast and Tidal Connect
# each start one, which is the ~20% idle burn reported in #308.
# Measured on the same machine, idle with no client attached:
# uvloop 24.8%, asyncio 0.1%.
#
# uvloop earns its keep on servers juggling thousands of
# connections. This one serves a single local WebView plus the
# occasional audio stream to a speaker on the LAN, so there is no
# throughput here for it to win back.
loop="asyncio",
)
server = uvicorn.Server(config)
def _run() -> None:
try:
server.run()
except Exception as exc:
print(f"[desktop] uvicorn crashed: {exc!r}", file=sys.stderr, flush=True)
threading.Thread(target=_run, daemon=True, name="uvicorn").start()
# Wait for the server to accept connections before we open the
# window — opening too early gives the user a white flash while
# pywebview retries the initial load.
deadline = time.monotonic() + 10.0
while time.monotonic() < deadline:
if server.started:
return server
time.sleep(0.05)
# Started flag never flipped — likely a bind failure or import
# error. Let pywebview open anyway so the user sees *something*;
# the console will show the real error.
print("[desktop] uvicorn did not report started within 10s", file=sys.stderr, flush=True)
return server
# Flipped by the first _graceful_shutdown call. Makes it idempotent —
# the post-start() path, the Linux closed-event path, and the exit
# watchdog can all call it without double-flushing state — and lets
# the watchdog's diagnostics say whether the clean path ever ran.
_graceful_ran = threading.Event()
def _graceful_shutdown(server: "uvicorn.Server") -> None: # type: ignore[name-defined]
"""Signal uvicorn to stop and flush state to disk. Runs on the main
thread after the pywebview window closes (or from the Linux closed
handler / exit watchdog).
Every step prints a trace line. Quit-hang reports arrive as
"terminal prints nothing", which is only diagnosable if the
shutdown path narrates how far it got."""
if _graceful_ran.is_set():
return
_graceful_ran.set()
print("[desktop] shutdown: signaling uvicorn", file=sys.stderr, flush=True)
try:
server.should_exit = True
except Exception as exc:
print(f"[desktop] shutdown: uvicorn signal failed: {exc!r}", file=sys.stderr, flush=True)
# Flush the downloader's pending-queue snapshot so a reopen picks up
# where we left off. The worker threads are daemons and will be
# killed on process exit; the state on disk is what matters.
print("[desktop] shutdown: persisting download queue", file=sys.stderr, flush=True)
try:
import server as _server
_server.downloader._persist_pending() # type: ignore[attr-defined]
except Exception as exc:
print(f"[desktop] shutdown: download-queue persist failed: {exc!r}", file=sys.stderr, flush=True)
# Tear down the macOS Now Playing integration before the
# interpreter finalizes. MediaRemote retains our nowPlayingInfo
# (Python-backed NSNumbers) and MPRemoteCommandCenter blocks; a
# callback fired on its serial queue during finalization re-enters
# the dying interpreter and trips libpthread's pthread_exit abort
# ("Tideway quit unexpectedly", crash thread on
# com.apple.MediaRemote...serialQueue). stop() removes every Python
# object from Apple's side so a late callback has nothing to call.
print("[desktop] shutdown: stopping Now Playing bridge", file=sys.stderr, flush=True)
try:
import server as _server
bridge = getattr(_server, "macos_now_playing_bridge", None)
if bridge is not None:
bridge.stop()
except Exception as exc:
print(f"[desktop] shutdown: Now Playing stop failed: {exc!r}", file=sys.stderr, flush=True)
# Same for the Linux MPRIS service: disconnect from the session
# bus so desktop widgets drop the player entry immediately instead
# of showing a dead "Tideway" until the bus notices the connection
# is gone. No-op off Linux or when the service never started.
try:
import server as _server
mpris = getattr(_server, "mpris_bridge", None)
if mpris is not None:
mpris.stop()
except Exception as exc:
print(f"[desktop] shutdown: MPRIS stop failed: {exc!r}", file=sys.stderr, flush=True)
# Close the audio OutputStream before the interpreter exits.
# sounddevice registers an atexit Pa_Terminate(); if a stream is
# still open when Python finalizes, PortAudio tears it down from
# that atexit hook against a live CoreAudio callback and aborts
# (malloc heap corruption -> "Tideway quit unexpectedly"). This
# only started biting once the macOS quit paths actually
# terminated the process. stop() does the ordered teardown
# (abort + close the stream, stop the decoder thread) so
# Pa_Terminate has nothing left to free.
print("[desktop] shutdown: stopping audio engine", file=sys.stderr, flush=True)
try:
import server as _server
player = getattr(_server, "_pcm_player_singleton", None)
if player is not None:
player.stop()
except Exception as exc:
print(f"[desktop] shutdown: audio stop failed: {exc!r}", file=sys.stderr, flush=True)
print("[desktop] shutdown: state flushed", file=sys.stderr, flush=True)
# Ensures only one watchdog thread starts no matter how many close
# paths try to arm it.
_watchdog_armed = threading.Event()
def _arm_exit_watchdog(
server: "uvicorn.Server", # type: ignore[name-defined]
deadline_s: float = 10.0,
) -> None:
"""Guarantee the process dies once a window close has begun.
The geometry-cache fix removes the known close-path deadlock, but
pywebview's GTK backend has a wider history of the loop wedging
during teardown (r0x0r/pywebview #95, #690, #793) — and any wedge
between the `closing` event and the `closed` event leaves the
Linux closed-handler's os._exit unreachable. Armed from `closing`
(the first thing pywebview fires when a close starts). If the
process is still alive `deadline_s` later, log a diagnostic naming
which threads are alive and whether the clean shutdown ever ran,
flush state, and force-exit. State is safe: `_graceful_shutdown`
is idempotent and every worker thread is a daemon designed to die
with the process.
On the normal path the process exits within moments of the close
and the (daemon) watchdog dies with it, having done nothing.
"""
if _watchdog_armed.is_set():
return
_watchdog_armed.set()
def _watch() -> None:
time.sleep(deadline_s)
clean = _graceful_ran.is_set()
print(
f"[desktop] exit watchdog: still alive {deadline_s:.0f}s after the "
f"window close began (clean shutdown ran: {clean}) — forcing exit. "
"If you are reporting a quit hang, include the lines above.",
file=sys.stderr,
flush=True,
)
alive = ", ".join(
f"{t.name}{'' if t.daemon else ' (non-daemon)'}"
for t in threading.enumerate()
)
print(f"[desktop] exit watchdog: threads alive: {alive}", file=sys.stderr, flush=True)
_graceful_shutdown(server)
os._exit(0)
threading.Thread(target=_watch, daemon=True, name="exit-watchdog").start()
def _enable_webview_media_prefs() -> None:
"""Patch the Cocoa backend with the WKWebView config pywebview 6.2
ships without: private `fullScreenEnabled` pref so HTML
Fullscreen works on macOS; inline-media playback; PiP media
playback; and — critically — an autoresizing mask on the WKWebView
so zooming the window actually grows the content instead of
leaving the web view stranded at its initial size while the OS
window background (white) fills the gap.
Best-effort: any import / attribute failure falls through silently
— we'd rather ship with one tweak disabled than crash at startup
if Apple renames a key.
"""
if sys.platform != "darwin":
return
try:
from webview.platforms.cocoa import BrowserView
import AppKit
except Exception:
return
try:
from app import window_chrome as _window_chrome
except Exception:
_window_chrome = None # type: ignore[assignment]
original_init = BrowserView.__init__
def patched_init(self, window): # type: ignore[no-untyped-def]
original_init(self, window)
# Register the new BrowserView's NSWindow with the chrome
# tinter so the titlebar inherits the app's theme color.
# `self.window` is the NSWindow on the cocoa BrowserView;
# falling through silently if pywebview ever renames it
# leaves the original system gradient titlebar — visible
# but not broken.
if _window_chrome is not None:
try:
ns = getattr(self, "window", None)
if ns is not None:
_window_chrome.register_macos_nswindow(ns)
except Exception:
pass
try:
config = self.webview.configuration()
prefs = config.preferences()
# Private WKPreferences SPI — all addressed via KVC using
# the underscore-stripped key (KVC strips the leading `_`
# when mapping to the `_setXxx:` selector). Names verified
# against WebKit source in WKPreferencesPrivate.h.
for key in (
"fullScreenEnabled",
"allowsPictureInPictureMediaPlayback",
"allowsInlineMediaPlayback",
"inlineMediaPlaybackRequiresPlaysInlineAttribute",
"mediaSourceEnabled",
):
try:
prefs.setValue_forKey_(
False if key == "inlineMediaPlaybackRequiresPlaysInlineAttribute" else True,
key,
)
except Exception:
# New SDKs have started throwing on certain
# private keys — keep going so one-off rejections
# don't stop the others from applying.
pass
# Public API — explicit belt-and-suspenders even though
# it's also handled via the pref above on macOS.
try:
config.setAllowsInlineMediaPlayback_(True)
except Exception:
pass
except Exception:
pass
# Window resize fix — without this mask, the WKWebView keeps
# its initial 1280x800 frame and the OS window chrome (white)
# shows around it whenever the user zooms or drags the edge.
try:
self.webview.setAutoresizingMask_(
AppKit.NSViewWidthSizable | AppKit.NSViewHeightSizable
)
except Exception:
pass
BrowserView.__init__ = patched_init
# X11/GDK button numbers for the mouse's dedicated side buttons, and the
# history move each one means. GDK numbers buttons from 1, so these are
# the 8th and 9th physical buttons — the same pair X11 reports for the
# thumb rest on essentially every mouse that has them.
_GDK_BUTTON_BACK = 8
_GDK_BUTTON_FORWARD = 9
_GDK_HISTORY_BUTTONS = {
_GDK_BUTTON_BACK: "back",
_GDK_BUTTON_FORWARD: "forward",
}
def gdk_history_move(button: int) -> Optional[str]:
"""Which history move a GDK button number means, if any.
Split out from the signal handler so the mapping is testable
without a GTK main loop.
"""
return _GDK_HISTORY_BUTTONS.get(button)
def _wire_gtk_mouse_nav() -> None:
"""Route the mouse's Back/Forward side buttons to history on the GTK
backend (#316).
These buttons cannot be handled in the web layer on Linux. WebKitGTK
does not deliver the 4th and 5th buttons to the DOM at all, which
MDN states plainly: "On Linux (GTK), the 4th button and the 5th
button are not supported." So the app's `mouseup` listener — which
does work on WebView2 and WKWebView — never fires there, and the
buttons instead did whatever GTK did with them, which is what the
reporter saw as unpredictable behaviour over the sidebar.
GDK does deliver them, as buttons 8 and 9 on `button-press-event`,
so the handler belongs at that layer. Returning True marks the event
handled so WebKitGTK does not also act on it.
pywebview only connects that signal itself for frameless windows
with easy_drag, and Tideway sets easy_drag=False, so the signal is
free. We hook the BrowserView constructor because the widget does
not exist before then — the same approach _enable_webview_media_prefs
already uses for Cocoa.
"""
if not sys.platform.startswith("linux"):
return
try:
from webview.platforms.gtk import BrowserView
except Exception:
# No GTK backend in this environment (Qt, or the AppImage's
# no-backend fallback). Nothing to wire up.
return
original_init = BrowserView.__init__
def patched_init(self, window): # type: ignore[no-untyped-def]
original_init(self, window)
def on_button_press(_widget, event): # type: ignore[no-untyped-def]
button = getattr(event, "button", 0)
move = gdk_history_move(button)
if move is None:
# 8 and 9 are what X11 reports for the thumb buttons, but
# that is convention rather than something GDK's docs
# promise, and this could not be tried on a real GTK
# widget before shipping. Naming any other extra button
# we see means a report says which number to map instead
# of costing another round trip, the way #309's silent
# paths did.
if button > 3:
logging.getLogger("tideway.audio").info(
f"[desktop] unmapped mouse button {button} "
"(expected 8=back, 9=forward)"
)
return False
# WebKit's own back/forward list, which is what the router's
# pushState entries land in, so moving through it delivers
# the popstate React Router navigates on. Preferred over
# evaluating `history.back()`: pywebview's evaluate_js blocks
# on a semaphore that only the GTK main loop can release, and
# this handler runs on that loop, so calling it here would
# deadlock the way the geometry read used to.
if move == "back":
if self.webview.can_go_back():
self.webview.go_back()
elif self.webview.can_go_forward():
self.webview.go_forward()
# Consume it either way: at the ends of the history there is
# nothing to do, but letting it through would hand the button
# back to whatever GTK did with it before.
return True
self.webview.connect("button-press-event", on_button_press)
BrowserView.__init__ = patched_init
def _guard_cocoa_window_move() -> None:
"""Stop a macOS 26 (Tahoe) startup crash in pywebview's Cocoa
backend (issue #215).
pywebview sets the window frame inside BrowserView.__init__ —
move() when there's a restored position, else center() — and that
fires the `windowDidMove_` window-delegate callback. On macOS 26 the
NSWindow has not been ordered onto a display yet at that point, so
`NSWindow.screen()` returns None and pywebview's handler crashes
dereferencing `screen().frame()` (older macOS returned a screen
here). The dock icon flashes and the process dies before any window
appears, with no recovery.
Swap pywebview's WindowDelegate for a subclass whose windowDidMove_
skips the event while the window has no screen — a "moved" event for
a window that isn't on a display yet carries no usable geometry
anyway. Subclassing (rather than reassigning the method on the
existing class) registers the override cleanly through PyObjC so
AppKit actually dispatches to it, and pywebview instantiates the
delegate by reference — `BrowserView.WindowDelegate.alloc().init()`
— so swapping the class attribute before any window is created is
all it takes.
Best-effort and darwin-only: if a future pywebview restructures its
delegate the guard quietly no-ops, leaving the original behaviour
rather than introducing a new failure.
"""
if sys.platform != "darwin":
return
try:
import objc
from webview.platforms.cocoa import BrowserView
except Exception:
return
base_delegate = getattr(BrowserView, "WindowDelegate", None)
if base_delegate is None or not hasattr(base_delegate, "windowDidMove_"):
return
class _GuardedWindowDelegate(base_delegate): # type: ignore[misc,valid-type]
def windowDidMove_(self, notification): # type: ignore[no-untyped-def]
i = BrowserView.get_instance("window", notification.object())
if i is not None:
win = getattr(i, "window", None)
# screen() is None until the window is placed on a
# display — which is exactly the __init__-time move that
# crashes on macOS 26. Skip it; there's nothing to emit.
if win is None or win.screen() is None:
return
objc.super(_GuardedWindowDelegate, self).windowDidMove_(notification)
BrowserView.WindowDelegate = _GuardedWindowDelegate
def main(argv: Optional[list[str]] = None) -> int:
# Applied before the webview backend loads — WebView2 reads the
# env var at init time.
_configure_webview2_autoplay()
parser = argparse.ArgumentParser(description="Tideway desktop app")
parser.add_argument(
"--browser",
action="store_true",
help="Open in the default browser instead of a pywebview window "
"(useful on systems without WebView2).",
)
args = parser.parse_args(argv)
# The desktop shell serves the built frontend from web/dist. In a
# source checkout that folder only exists after a Vite build, and
# without it the window opens on a JSON 404 — which renders as a
# blank white page with no clue what went wrong (#262). Refuse to
# start with instructions instead. Frozen builds and the Flatpak
# bundle the folder, so this only ever trips a source run.
from app.paths import bundled_resource_dir
if not (bundled_resource_dir() / "web" / "dist" / "index.html").is_file():
print(
"[desktop] web/dist is missing — the frontend hasn't been "
"built, so the window would come up blank. Build it first:\n"
" cd web && npm install && npm run build\n"
"then re-run desktop.py. (For day-to-day frontend work use "
"./run.sh, which serves the UI from Vite instead.)",
file=sys.stderr,
flush=True,
)
return 1
# Single-instance guard: if /api/health responds, a sibling is
# already up. Ask it to focus and exit quietly.
if _probe_existing_instance():
_ask_existing_to_focus()
return 0
server = _run_uvicorn_in_thread()
if args.browser:
_open_in_browser(f"http://{HOST}:{PORT}/")
try:
# Block main thread until Ctrl-C or uvicorn exits on its own.
while not server.should_exit:
time.sleep(0.5)
except KeyboardInterrupt:
pass
_graceful_shutdown(server)
return 0
try:
import webview # pywebview
_enable_webview_media_prefs()
_guard_cocoa_window_move()
_wire_gtk_mouse_nav()
except ImportError:
print(
"[desktop] pywebview not installed; falling back to default browser. "
"Install with: pip install pywebview",
file=sys.stderr,
flush=True,
)
_open_in_browser(f"http://{HOST}:{PORT}/")
try:
while not server.should_exit:
time.sleep(0.5)
except KeyboardInterrupt:
pass
_graceful_shutdown(server)
return 0
# On Windows we suppress the OS-drawn caption (min/max/close) and
# let the React shell paint its own integrated titlebar — same
# pattern as VS Code, Discord, Spotify. macOS keeps the native
# traffic lights (re-implementing them faithfully is a tar pit and
# Mac users have strong muscle memory for their position and
# behavior); the existing transparent-titlebar tinting in
# window_chrome.py already blends them into the app body. Linux
# stays untouched — GTK CSD theming varies too much across distros
# to do reliably.
use_frameless = sys.platform == "win32"
# Restore the window's last size + position. settings.window_* is
# -1 until the first close, so a fresh install still gets the
# 1280x800 default and pywebview's centred placement (x/y=None).
# Negative x/y are legitimate on multi-monitor setups, so only
# the -1 sentinel is treated as "unset".
import server as _server
_win_w, _win_h, _win_x, _win_y = 1280, 800, None, None
try:
_sw = int(getattr(_server.settings, "window_width", -1))
_sh = int(getattr(_server.settings, "window_height", -1))
_sx = int(getattr(_server.settings, "window_x", -1))
_sy = int(getattr(_server.settings, "window_y", -1))
if _sw >= 800 and _sh >= 600:
_win_w, _win_h = _sw, _sh
if _sx != -1 or _sy != -1:
_win_x, _win_y = _sx, _sy
except Exception:
pass
# pywebview's Cocoa backend ignores create_window's x / y and the
# create-time size, so on macOS the window always comes up centred
# at the default size. _restore_macos_geometry() re-applies the
# persisted geometry after the window is shown; this flag gates it
# so a fresh install keeps pywebview's centred default.
_have_saved_geom = _win_x is not None and _win_y is not None
window = webview.create_window(
"Tideway",
f"http://{HOST}:{PORT}/",
width=_win_w,
height=_win_h,
x=_win_x,
y=_win_y,
min_size=(800, 600),
frameless=use_frameless,
# easy_drag would make every mousedown try to drag the window,
# which breaks button clicks and feels laggy. We declare drag
# regions explicitly via CSS `-webkit-app-region: drag` on the
# React titlebar instead.
easy_drag=False,
)
# Live geometry cache, fed by pywebview's resized / moved events.
# _save_window_geometry used to read window.width/.height/.x/.y at
# close time, but on the GTK backend those getters marshal to the
# main loop via glib.idle_add and then BLOCK on a semaphore until
# the idle callback runs. The `closing` event's handlers execute
# synchronously ON that same main loop (it's a should_lock Event,
# fired from close_window), so the geometry read deadlocked the
# loop against itself: it sat in semaphore.acquire() waiting for
# an idle callback that only the blocked loop could run. Nothing
# after the deadlock ever happened — no destroy, no `closed`
# event, no shutdown logs, webview.start() never returned — which
# is exactly the Linux "window closes but the process survives
# until killed" report. Caching the values as the events deliver
# them costs an int store per resize/move and makes the close
# path entirely backend-free.
_geom = {"w": _win_w, "h": _win_h, "x": _win_x, "y": _win_y}
def _on_window_resized(width: int, height: int) -> None:
_geom["w"], _geom["h"] = int(width), int(height)
def _on_window_moved(x: int, y: int) -> None:
_geom["x"], _geom["y"] = int(x), int(y)
try:
window.events.resized += _on_window_resized
window.events.moved += _on_window_moved
except Exception as exc:
# Geometry restore degrades to the last persisted values —
# visible but not load-bearing. Log it: a backend that stops
# delivering these events is worth knowing about.
print(
f"[desktop] geometry event hooks failed: {exc!r}",
file=sys.stderr,
flush=True,
)
def _save_window_geometry() -> None:
"""Persist the cached size + position so the next launch
restores it. Reads ONLY the event-fed cache — never the
window object — so it is safe to call from any thread,
including inside the `closing` event on the GUI loop (see the
deadlock note above the cache)."""
try:
from app.settings import save_settings as _save_settings
w, h = int(_geom["w"]), int(_geom["h"])
# A minimized/zero-size read is junk; keep the last good
# geometry rather than persisting a collapsed window.
if w < 800 or h < 600:
return
_server.settings.window_width = w
_server.settings.window_height = h
# x/y stay None until the first moved event (fresh install
# that was never dragged) — keep the persisted values
# untouched rather than writing a fake origin.
if _geom["x"] is not None and _geom["y"] is not None:
_server.settings.window_x = int(_geom["x"])
_server.settings.window_y = int(_geom["y"])
_save_settings(_server.settings)
except Exception as exc:
print(
f"[desktop] window geometry save failed: {exc!r}",
file=sys.stderr,
flush=True,
)
# Close behavior. On Windows / Linux the X destroys the window and
# the process exits. On macOS we follow the platform convention:
# the X hides the window without quitting, and the dock icon
# brings it back. Cmd+Q still quits because that goes through
# NSApplication.terminate, a separate code path that doesn't
# fire pywebview's `closing` event. Same for the in-app Quit
# menu's `_quit_app` below, which calls window.destroy() directly.
#
# We removed the Windows tray icon in v1.5.2 because hide-to-tray
# was unexpected and the tray's only purpose was to bring the
# window back. macOS has the dock for that, so it's not unexpected.
def _show_window() -> None:
# Used by the focus callback for second-instance launches and
# by the dock-icon reopen handler on macOS: bring the existing
# window to front instead of spawning a second one.
try:
window.show()
except Exception:
pass
try:
window.restore()
except Exception:
pass
def _quit_app() -> None:
# Used by the in-app Quit menu's /api/_internal/quit
# endpoint. Bypasses the closing-to-hide path on macOS by
# calling destroy directly, so capture geometry first.
print("[desktop] quit requested from app menu", file=sys.stderr, flush=True)
_save_window_geometry()
try:
window.destroy()
except Exception as exc:
# A destroy that fails means the Quit click "did nothing"
# from the user's perspective — log the actual error so a
# report contains it instead of silence.
print(
f"[desktop] quit: window destroy failed: {exc!r}",
file=sys.stderr,
flush=True,
)
if sys.platform == "darwin":
def _on_closing_macos() -> bool:
"""Cancel the X-button close path and hide instead.
Returning False from `closing` tells pywebview to keep the
window alive. Cmd+Q and the in-app Quit menu both bypass
this handler (they go through NSApp.terminate / window
.destroy respectively), so the user can still quit; only
the X click is intercepted."""
_save_window_geometry()
try:
window.hide()
except Exception:
pass
return False
try:
window.events.closing += _on_closing_macos
except Exception:
pass
def _macos_quit() -> None:
# A real quit (Dock right-click → Quit, Apple-menu Quit,
# Cmd+Q) must tear down every window, not just the main
# one — pywebview only stops the run loop once the last
# window closes, so leaving a mini-player open would
# otherwise keep the process alive. Mirrors the in-app
# Quit's destroy() path, which the existing graceful
# shutdown already hangs off of.
_save_window_geometry()
for w in list(webview.windows):
try:
w.destroy()
except Exception:
pass
# Re-show the hidden window when the user clicks the dock icon,
# and take over applicationShouldTerminate: so OS-level quit
# paths actually quit. Wired AFTER webview.start() (via the
# `shown` event) so NSApp and pywebview's app delegate exist;
# both helpers are idempotent.
def _install_macos_app_hooks() -> None:
try:
from app import window_chrome as _window_chrome
_window_chrome.install_macos_dock_reopen(_show_window)
_window_chrome.install_macos_quit_handler(_macos_quit)
except Exception:
pass
try:
window.events.shown += _install_macos_app_hooks
except Exception:
pass
_geom_restored: list[bool] = []
def _restore_macos_geometry() -> None:
"""Re-apply the persisted size + position once the window
exists. Needed because pywebview's Cocoa backend ignores
the geometry passed to create_window — the window always
comes up centred at the default size otherwise. One-shot:
a later dock-reopen must not stomp a position the user
moved the window to during the session."""
if not _have_saved_geom or _geom_restored:
return
_geom_restored.append(True)
try:
window.resize(_win_w, _win_h)
except Exception:
pass
try:
window.move(_win_x, _win_y)
except Exception:
pass
try:
window.events.shown += _restore_macos_geometry
except Exception:
pass
else:
# Windows / Linux: the X destroys the window and the process
# exits, so geometry has to be captured here, in the closing
# event, while the window is still alive. The save reads only
# the event-fed cache — it must NOT touch the window object,
# because this handler runs synchronously on the GUI loop (see
# the deadlock note above the cache). Returning True lets the
# close proceed unchanged.
def _on_closing_win_linux() -> bool:
print(
"[desktop] window close requested",
file=sys.stderr,
flush=True,
)
_save_window_geometry()
# Arm the watchdog at the earliest moment of the close —
# any wedge between here and the `closed` event would
# otherwise leave the process alive with no recourse.
_arm_exit_watchdog(server)
return True
try:
window.events.closing += _on_closing_win_linux
except Exception:
pass
# Linux-only force-exit. pywebview's WebKitGTK backend has a
# long-standing issue where `webview.start()` doesn't always
# return after the window is destroyed; the Flatpak sandbox
# makes this more reliable to hit, leaving the process alive