station-appkit 1.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,65 @@
1
+ """What Station Studio, Station Player and Station Server share as desktop
2
+ apps: the new-version check (updates), the self-updater (selfupdate) and
3
+ the app window (window, and on the Mac macwindow).
4
+
5
+ Each app describes itself once, as an `App`, and hands that in.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ from dataclasses import dataclass, field
10
+
11
+ __version__ = "1.0.0"
12
+
13
+ REPO = "raby/station-studio"
14
+
15
+
16
+ @dataclass(frozen=True)
17
+ class App:
18
+ """One of the three apps, as the shared code needs to know it."""
19
+
20
+ name: str
21
+ """In words: "Station Studio"."""
22
+ slug: str
23
+ """Without the space: "StationStudio". Names the downloads
24
+ (StationStudio-1.2.0-windows-setup.exe), the user agent and the
25
+ Mac window's user agent (StationStudioApp/1.2.0)."""
26
+ tag_prefix: str
27
+ """Its releases' tags: "studio-v"."""
28
+ version: str
29
+ """The version running."""
30
+ port: int
31
+ """The port it serves on unless told otherwise."""
32
+ mac_helper: str = ""
33
+ """The Mac app's helper (Contents/MacOS/<this>), which runs the server;
34
+ the app the Finder opens is its window."""
35
+ env_prefix: str = ""
36
+ """"STATION_STUDIO": STATION_STUDIO_INSPECT, STATION_STUDIO_SELFTEST."""
37
+ window_size: tuple[int, int] = (1280, 860)
38
+ """The app window's first size."""
39
+ browser_args: tuple[str, ...] = field(default=())
40
+ """More flags for the Chrome or Edge app window."""
41
+ repo: str = REPO
42
+
43
+ @property
44
+ def default_url(self) -> str:
45
+ return f"http://127.0.0.1:{self.port}/"
46
+
47
+ @property
48
+ def releases_api(self) -> str:
49
+ return f"https://api.github.com/repos/{self.repo}/releases?per_page=100"
50
+
51
+ def release_api(self, version: str) -> str:
52
+ return f"https://api.github.com/repos/{self.repo}/releases/tags/{self.tag_prefix}{version}"
53
+
54
+ def release_page(self, version: str) -> str:
55
+ return f"https://github.com/{self.repo}/releases/tag/{self.tag_prefix}{version}"
56
+
57
+ @property
58
+ def download_prefix(self) -> str:
59
+ """Only files from the project's own releases are used."""
60
+ return f"https://github.com/{self.repo}/releases/download/"
61
+
62
+ @property
63
+ def temp_prefix(self) -> str:
64
+ """"station-studio": for temporary folders."""
65
+ return self.name.lower().replace(" ", "-")
@@ -0,0 +1,559 @@
1
+ """An app's own window on the Mac (the downloaded apps).
2
+
3
+ The app shows its page in a window of its own, drawn by WebKit (the engine
4
+ Safari uses), with its own Dock icon and menu bar, so no Chrome is needed.
5
+ Closing the window leaves the app running, and the Dock icon brings it
6
+ back; Quit (⌘Q) stops it.
7
+
8
+ The app itself still runs in the .app's helper process (see each app's
9
+ PyInstaller spec), which is what starts at login; this window only shows
10
+ it. Each app subclasses `StationAppWindow` for its own menus and what Quit asks,
11
+ then calls `run`. Only the Mac apps import this module, since it needs
12
+ PyObjC.
13
+ """
14
+ from __future__ import annotations
15
+
16
+ import json
17
+ import logging
18
+ import os
19
+ import subprocess
20
+ import threading
21
+ import time
22
+ import urllib.request
23
+ from pathlib import Path
24
+ from urllib.parse import urlparse
25
+
26
+ import AppKit
27
+ import Foundation
28
+ import objc
29
+ import WebKit
30
+ from PyObjCTools import AppHelper
31
+
32
+ from station_appkit import App
33
+ from station_appkit.window import download_name, free_path
34
+
35
+ BRIDGE = "stationApp" # the page's window.webkit.messageHandlers.stationApp
36
+ # How long the app may be gone before the window gives up and quits (an
37
+ # update restarts it, which takes a while on a slow Mac).
38
+ GONE_FOR = 120
39
+
40
+ logger = logging.getLogger(__name__)
41
+ _local = urllib.request.build_opener(urllib.request.ProxyHandler({}))
42
+
43
+ # WebKit's enum values (named in PyObjC only on newer versions).
44
+ _ACTION_CANCEL, _ACTION_ALLOW = 0, 1
45
+ _RESPONSE_CANCEL, _RESPONSE_ALLOW = 0, 1
46
+ _REPLY_SUCCESS = 0 # NSApplicationDelegateReplySuccess
47
+ _TERMINATE_CANCEL, _TERMINATE_NOW, _TERMINATE_LATER = 0, 1, 2
48
+
49
+ # What the build's own check (each app's *-installers.yml) reads of the page.
50
+ SELF_TEST_RESULT = """JSON.stringify({
51
+ native: Boolean(window.webkit && window.webkit.messageHandlers && window.webkit.messageHandlers.stationApp),
52
+ bridge: typeof window.stationApp === "object", title: document.title,
53
+ sidebar: Boolean(document.querySelector("nav, aside, .sidebar, #sidebar")), agent: navigator.userAgent,
54
+ serviceWorker: Boolean(navigator.serviceWorker && navigator.serviceWorker.controller),
55
+ autoplay: window.__selfTest,
56
+ })"""
57
+
58
+
59
+ def get_json(url: str):
60
+ """The JSON at `url` (the app's own server), or None."""
61
+ try:
62
+ with _local.open(url, timeout=3) as resp:
63
+ return json.load(resp)
64
+ except Exception:
65
+ return None
66
+
67
+
68
+ def app():
69
+ return AppKit.NSApplication.sharedApplication()
70
+
71
+
72
+ def item(title: str, action: str | None, key: str = "", mods: int | None = None, target=None):
73
+ """A menu item."""
74
+ entry = AppKit.NSMenuItem.alloc().initWithTitle_action_keyEquivalent_(title, action, key)
75
+ if mods is not None:
76
+ entry.setKeyEquivalentModifierMask_(mods)
77
+ if target is not None:
78
+ entry.setTarget_(target)
79
+ return entry
80
+
81
+
82
+ def submenu(bar, title: str, items) -> AppKit.NSMenu:
83
+ """A menu in the menu bar; None in `items` is a separator."""
84
+ menu = AppKit.NSMenu.alloc().initWithTitle_(title)
85
+ for entry in items:
86
+ menu.addItem_(entry if entry is not None else AppKit.NSMenuItem.separatorItem())
87
+ holder = AppKit.NSMenuItem.alloc().initWithTitle_action_keyEquivalent_(title, None, "")
88
+ holder.setSubmenu_(menu)
89
+ bar.addItem_(holder)
90
+ return menu
91
+
92
+
93
+ CMD = AppKit.NSEventModifierFlagCommand
94
+ OPT = AppKit.NSEventModifierFlagOption
95
+ SHIFT = AppKit.NSEventModifierFlagShift
96
+ CTRL = AppKit.NSEventModifierFlagControl
97
+
98
+
99
+ class StationAppWindow(AppKit.NSObject):
100
+ """The app, its window and the page in it, all in one.
101
+
102
+ A subclass sets `APP` and may override the @objc.python_method hooks
103
+ (marked "for a subclass") and add its own menu actions."""
104
+
105
+ APP: App = None # for a subclass
106
+ MIN_SIZE = (420, 480)
107
+ STATUS = "api/app" # what answers {"version": …} while the app runs
108
+ AUTOPLAY = False # sound may start without a click first (a music player)
109
+
110
+ def initWithURL_options_(self, url, options):
111
+ self = objc.super(StationAppWindow, self).init()
112
+ if self is None:
113
+ return None
114
+ self.url = url
115
+ self.opts = options # quit_server, bundle, and whatever a subclass wants
116
+ self.window = None
117
+ self.webview = None
118
+ self.leaving = False # quitting without stopping the app (a restart)
119
+ self.stopped = False # the app has been stopped already
120
+ self.version = None
121
+ self.gone_since = None
122
+ self.tested = False
123
+ return self
124
+
125
+ @objc.python_method
126
+ def env(self, what: str) -> str | None:
127
+ """STATION_STUDIO_<what>, say."""
128
+ return os.environ.get(f"{self.APP.env_prefix}_{what}") if self.APP.env_prefix else None
129
+
130
+ # ---- starting up ----------------------------------------------------------
131
+
132
+ def applicationDidFinishLaunching_(self, note):
133
+ self.buildMenus()
134
+ self.buildWindow()
135
+ self.show()
136
+ threading.Thread(target=self.watch, name="watch-app", daemon=True).start()
137
+ self.launched()
138
+
139
+ @objc.python_method
140
+ def launched(self):
141
+ """For a subclass: once the window is up."""
142
+
143
+ @objc.python_method
144
+ def buildWindow(self):
145
+ mask = (AppKit.NSWindowStyleMaskTitled | AppKit.NSWindowStyleMaskClosable
146
+ | AppKit.NSWindowStyleMaskMiniaturizable | AppKit.NSWindowStyleMaskResizable)
147
+ frame = Foundation.NSMakeRect(0, 0, *self.APP.window_size)
148
+ win = AppKit.NSWindow.alloc().initWithContentRect_styleMask_backing_defer_(
149
+ frame, mask, AppKit.NSBackingStoreBuffered, False)
150
+ win.setTitle_(self.APP.name)
151
+ win.setMinSize_(Foundation.NSMakeSize(*self.MIN_SIZE))
152
+ win.setReleasedWhenClosed_(False) # closing only hides it (see windowShouldClose_)
153
+ win.setCollectionBehavior_(AppKit.NSWindowCollectionBehaviorFullScreenPrimary)
154
+ win.center()
155
+ # Where it was left last time, and how big.
156
+ win.setFrameAutosaveName_(self.autosaveName())
157
+ win.setDelegate_(self)
158
+
159
+ config = WebKit.WKWebViewConfiguration.alloc().init()
160
+ if self.AUTOPLAY:
161
+ config.setMediaTypesRequiringUserActionForPlayback_(0)
162
+ # Lets the page tell it's in the app (an install button, say, has no place here).
163
+ config.setApplicationNameForUserAgent_(f"{self.APP.slug}App/{self.APP.version}")
164
+ config.userContentController().addScriptMessageHandler_name_(self, BRIDGE)
165
+ view = WebKit.WKWebView.alloc().initWithFrame_configuration_(win.contentView().bounds(), config)
166
+ view.setAutoresizingMask_(AppKit.NSViewWidthSizable | AppKit.NSViewHeightSizable)
167
+ view.setNavigationDelegate_(self)
168
+ view.setUIDelegate_(self)
169
+ if self.env("INSPECT") and view.respondsToSelector_("setInspectable:"):
170
+ view.setInspectable_(True) # Safari › Develop menu
171
+ view.addObserver_forKeyPath_options_context_(self, "title", 0, None)
172
+ win.setContentView_(view)
173
+ self.window, self.webview = win, view
174
+ view.loadRequest_(Foundation.NSURLRequest.requestWithURL_(Foundation.NSURL.URLWithString_(self.url)))
175
+
176
+ @objc.python_method
177
+ def autosaveName(self) -> str:
178
+ return f"{self.APP.name} window"
179
+
180
+ @objc.python_method
181
+ def buildMenus(self):
182
+ name = self.APP.name
183
+ bar = AppKit.NSMenu.alloc().init()
184
+ submenu(bar, name, [
185
+ item(f"About {name}", "orderFrontStandardAboutPanel:"),
186
+ None,
187
+ item("Settings…", "openSettings:", ",", CMD, self),
188
+ item("Check for Updates…", "openUpdates:", "", None, self),
189
+ None,
190
+ item(f"Hide {name}", "hide:", "h", CMD),
191
+ item("Hide Others", "hideOtherApplications:", "h", CMD | OPT),
192
+ item("Show All", "unhideAllApplications:"),
193
+ None,
194
+ item(f"Quit {name}", "terminate:", "q", CMD),
195
+ ])
196
+ submenu(bar, "File", [
197
+ item("Close Window", "performClose:", "w", CMD),
198
+ ])
199
+ # Without these, ⌘C and ⌘V do nothing in the page's text boxes.
200
+ submenu(bar, "Edit", [
201
+ item("Undo", "undo:", "z", CMD),
202
+ item("Redo", "redo:", "z", CMD | SHIFT),
203
+ None,
204
+ item("Cut", "cut:", "x", CMD),
205
+ item("Copy", "copy:", "c", CMD),
206
+ item("Paste", "paste:", "v", CMD),
207
+ item("Select All", "selectAll:", "a", CMD),
208
+ ])
209
+ for title, items in self.appMenus():
210
+ submenu(bar, title, items)
211
+ submenu(bar, "View", [
212
+ *self.viewItems(),
213
+ item("Enter Full Screen", "toggleFullScreen:", "f", CMD | CTRL),
214
+ ])
215
+ windows = submenu(bar, "Window", [
216
+ item("Minimize", "performMiniaturize:", "m", CMD),
217
+ item("Zoom", "performZoom:", ""),
218
+ None,
219
+ item(name, "showWindow:", "0", CMD, self),
220
+ None,
221
+ item("Bring All to Front", "arrangeInFront:"),
222
+ ])
223
+ helps = submenu(bar, "Help", [
224
+ item(f"{name} Help", "openHelp:", "?", CMD, self),
225
+ ])
226
+ app().setMainMenu_(bar)
227
+ app().setWindowsMenu_(windows)
228
+ app().setHelpMenu_(helps)
229
+
230
+ @objc.python_method
231
+ def appMenus(self) -> list:
232
+ """For a subclass: [(title, items)], between Edit and View."""
233
+ return []
234
+
235
+ @objc.python_method
236
+ def viewItems(self) -> list:
237
+ """For a subclass: items at the top of View."""
238
+ return []
239
+
240
+ @objc.python_method
241
+ def dockItems(self) -> list:
242
+ """For a subclass: the Dock icon's menu."""
243
+ return []
244
+
245
+ def applicationDockMenu_(self, application):
246
+ menu = AppKit.NSMenu.alloc().init()
247
+ for entry in self.dockItems():
248
+ menu.addItem_(entry)
249
+ return menu
250
+
251
+ # ---- the window --------------------------------------------------------------
252
+
253
+ @objc.python_method
254
+ def show(self):
255
+ if self.window is None:
256
+ return
257
+ if self.window.isMiniaturized():
258
+ self.window.deminiaturize_(None)
259
+ self.window.makeKeyAndOrderFront_(None)
260
+ app().activateIgnoringOtherApps_(True)
261
+
262
+ def showWindow_(self, sender):
263
+ self.show()
264
+
265
+ def windowShouldClose_(self, window):
266
+ # Closing hides the window, and the Dock icon (or Window › <the
267
+ # app>) brings it back.
268
+ window.orderOut_(None)
269
+ return False
270
+
271
+ def applicationShouldHandleReopen_hasVisibleWindows_(self, application, visible):
272
+ self.show()
273
+ return True
274
+
275
+ def observeValueForKeyPath_ofObject_change_context_(self, path, obj, change, context):
276
+ if path == "title" and self.window is not None:
277
+ self.window.setTitle_(str(self.webview.title() or "") or self.APP.name)
278
+
279
+ # ---- the page -------------------------------------------------------------------
280
+
281
+ @objc.python_method
282
+ def js(self, code: str, done=None):
283
+ """Run some of the page's script; `done` gets its result (None if
284
+ the page couldn't)."""
285
+ if self.webview is None:
286
+ if done:
287
+ done(None)
288
+ return
289
+
290
+ def handler(result, error):
291
+ if done:
292
+ done(None if error is not None else result)
293
+
294
+ self.webview.evaluateJavaScript_completionHandler_(code, handler)
295
+
296
+ @objc.python_method
297
+ def tell(self, call: str):
298
+ """Call window.stationApp.<call> in the page."""
299
+ self.js(f"window.stationApp && window.stationApp.{call}")
300
+
301
+ @objc.python_method
302
+ def goTo(self, place: str, section: str | None = None):
303
+ """For a subclass: show one of the page's views ("settings",
304
+ "help"), and a section of it ("updates")."""
305
+ self.show()
306
+ self.tell(f"go({json.dumps(place)}{', ' + json.dumps(section) if section else ''})")
307
+
308
+ def openSettings_(self, sender):
309
+ self.goTo("settings")
310
+
311
+ def openUpdates_(self, sender):
312
+ self.goTo("settings", "updates")
313
+
314
+ def openHelp_(self, sender):
315
+ self.goTo("help")
316
+
317
+ def userContentController_didReceiveScriptMessage_(self, controller, message):
318
+ body = message.body()
319
+ if body == "show":
320
+ self.show()
321
+ elif body == "quit": # Quit on the page, which has stopped the app
322
+ self.stopped = True
323
+ app().terminate_(None)
324
+ else:
325
+ self.message(body)
326
+
327
+ @objc.python_method
328
+ def message(self, body):
329
+ """For a subclass: anything else the page tells the app."""
330
+
331
+ # ---- links and files from elsewhere ------------------------------------------------
332
+
333
+ def application_openURLs_(self, application, urls):
334
+ self.openURLs(urls)
335
+ self.show() # a station-studio: link, say
336
+
337
+ @objc.python_method
338
+ def openURLs(self, urls):
339
+ """For a subclass: files (Open With) or links handed to the app."""
340
+
341
+ # ---- what the page asks of the app ---------------------------------------------
342
+
343
+ def webView_runJavaScriptAlertPanelWithMessage_initiatedByFrame_completionHandler_(
344
+ self, view, message, frame, handler):
345
+ alert = AppKit.NSAlert.alloc().init()
346
+ alert.setMessageText_(str(message))
347
+ alert.beginSheetModalForWindow_completionHandler_(self.window, lambda response: handler())
348
+
349
+ def webView_runJavaScriptConfirmPanelWithMessage_initiatedByFrame_completionHandler_(
350
+ self, view, message, frame, handler):
351
+ alert = AppKit.NSAlert.alloc().init()
352
+ alert.setMessageText_(str(message))
353
+ alert.addButtonWithTitle_("OK")
354
+ alert.addButtonWithTitle_("Cancel")
355
+ alert.beginSheetModalForWindow_completionHandler_(
356
+ self.window, lambda response: handler(response == AppKit.NSAlertFirstButtonReturn))
357
+
358
+ def webView_runJavaScriptTextInputPanelWithPrompt_defaultText_initiatedByFrame_completionHandler_(
359
+ self, view, prompt, default, frame, handler):
360
+ alert = AppKit.NSAlert.alloc().init()
361
+ alert.setMessageText_(str(prompt))
362
+ alert.addButtonWithTitle_("OK")
363
+ alert.addButtonWithTitle_("Cancel")
364
+ field = AppKit.NSTextField.alloc().initWithFrame_(Foundation.NSMakeRect(0, 0, 300, 24))
365
+ field.setStringValue_(str(default or ""))
366
+ alert.setAccessoryView_(field)
367
+ alert.window().setInitialFirstResponder_(field)
368
+ alert.beginSheetModalForWindow_completionHandler_(
369
+ self.window, lambda response: handler(
370
+ str(field.stringValue()) if response == AppKit.NSAlertFirstButtonReturn else None))
371
+
372
+ def webView_runOpenPanelWithParameters_initiatedByFrame_completionHandler_(
373
+ self, view, params, frame, handler):
374
+ panel = AppKit.NSOpenPanel.openPanel()
375
+ panel.setCanChooseFiles_(True)
376
+ panel.setCanChooseDirectories_(False)
377
+ panel.setAllowsMultipleSelection_(bool(params.allowsMultipleSelection()))
378
+ panel.beginSheetModalForWindow_completionHandler_(
379
+ self.window, lambda response: handler(
380
+ panel.URLs() if response == AppKit.NSModalResponseOK else None))
381
+
382
+ def webView_createWebViewWithConfiguration_forNavigationAction_windowFeatures_(
383
+ self, view, config, action, features):
384
+ # Links that open a new window (the release page, say) open in the browser.
385
+ url = action.request().URL()
386
+ if url is not None:
387
+ AppKit.NSWorkspace.sharedWorkspace().openURL_(url)
388
+ return None
389
+
390
+ @objc.python_method
391
+ def ours(self, url) -> bool:
392
+ if url is None:
393
+ return True
394
+ scheme = str(url.scheme() or "")
395
+ if scheme in ("about", "data", "blob"):
396
+ return True
397
+ here = urlparse(self.url)
398
+ return scheme == here.scheme and str(url.host() or "") == here.hostname and (url.port() or 80) == (here.port or 80)
399
+
400
+ def webView_decidePolicyForNavigationAction_decisionHandler_(self, view, action, handler):
401
+ url = action.request().URL()
402
+ frame = action.targetFrame()
403
+ main = frame is None or frame.isMainFrame()
404
+ wants_download = action.respondsToSelector_("shouldPerformDownload") and action.shouldPerformDownload()
405
+ if wants_download and url is not None and self.ours(url):
406
+ # An exported playlist or the play log, say: saved to Downloads.
407
+ handler(_ACTION_CANCEL)
408
+ self.download(str(url.absoluteString()))
409
+ elif main and not self.ours(url):
410
+ # A link to a website opens in the browser, not in place of the app.
411
+ handler(_ACTION_CANCEL)
412
+ AppKit.NSWorkspace.sharedWorkspace().openURL_(url)
413
+ else:
414
+ handler(_ACTION_ALLOW)
415
+
416
+ def webView_decidePolicyForNavigationResponse_decisionHandler_(self, view, response, handler):
417
+ if response.canShowMIMEType() or not response.isForMainFrame():
418
+ handler(_RESPONSE_ALLOW)
419
+ else:
420
+ handler(_RESPONSE_CANCEL)
421
+ self.download(str(response.response().URL().absoluteString()))
422
+
423
+ def webView_didFinishNavigation_(self, view, navigation):
424
+ self.pageLoaded()
425
+ report = self.env("SELFTEST")
426
+ if report and not self.tested:
427
+ self.tested = True
428
+ AppHelper.callLater(6, self.selfTest, report)
429
+
430
+ @objc.python_method
431
+ def pageLoaded(self):
432
+ """For a subclass: each time the page has loaded."""
433
+
434
+ @objc.python_method
435
+ def selfTest(self, report: str):
436
+ """For the build's own check: what the page sees of the app."""
437
+ self.js(SELF_TEST_RESULT, lambda result: Path(report).write_text(str(result), encoding="utf-8"))
438
+
439
+ def webViewWebContentProcessDidTerminate_(self, view):
440
+ view.reload()
441
+
442
+ @objc.python_method
443
+ def download(self, url: str):
444
+ def work():
445
+ folder = Path.home() / "Downloads"
446
+ try:
447
+ with _local.open(url, timeout=120) as resp:
448
+ target = free_path(folder, download_name(resp.headers.get("Content-Disposition"), url))
449
+ folder.mkdir(parents=True, exist_ok=True)
450
+ with open(target, "wb") as out:
451
+ while chunk := resp.read(65536):
452
+ out.write(chunk)
453
+ except Exception as exc:
454
+ logger.warning("Could not download %s: %s", url, exc)
455
+ return
456
+ AppHelper.callAfter(self.reveal, target)
457
+
458
+ threading.Thread(target=work, name="download", daemon=True).start()
459
+
460
+ @objc.python_method
461
+ def reveal(self, path: Path):
462
+ AppKit.NSWorkspace.sharedWorkspace().activateFileViewerSelectingURLs_(
463
+ [Foundation.NSURL.fileURLWithPath_(str(path))])
464
+
465
+ # ---- the app behind the window -------------------------------------------
466
+
467
+ @objc.python_method
468
+ def serverVersion(self) -> str | None:
469
+ """The version of the app answering at the window's address, or None."""
470
+ state = get_json(self.url + self.STATUS)
471
+ return str(state["version"]) if isinstance(state, dict) and "version" in state else None
472
+
473
+ @objc.python_method
474
+ def watch(self):
475
+ """Quit along with the app; after an update, reopen as the new
476
+ version."""
477
+ while True:
478
+ time.sleep(3)
479
+ found = self.serverVersion()
480
+ if found is None:
481
+ self.gone_since = self.gone_since or time.monotonic()
482
+ if time.monotonic() - self.gone_since > GONE_FOR:
483
+ logger.info("%s has stopped, so its window closes", self.APP.name)
484
+ self.stopped = True
485
+ AppHelper.callAfter(app().terminate_, None)
486
+ return
487
+ continue
488
+ self.gone_since = None
489
+ if self.version and found != self.version and found != self.APP.version:
490
+ logger.info("%s %s is running now: reopening its window", self.APP.name, found)
491
+ AppHelper.callAfter(self.restart)
492
+ return
493
+ self.version = self.version or found
494
+
495
+ @objc.python_method
496
+ def restart(self):
497
+ bundle = self.opts.get("bundle")
498
+ if bundle:
499
+ # Once this one has gone, as a new app (-n), so macOS opens the
500
+ # new version rather than bringing back this one.
501
+ subprocess.Popen(["/bin/sh", "-c", 'sleep 1; /usr/bin/open -n "$0"', str(bundle)],
502
+ stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL,
503
+ stderr=subprocess.DEVNULL, start_new_session=True)
504
+ self.leaving = True
505
+ app().terminate_(None)
506
+
507
+ # ---- quitting ----------------------------------------------------------------------
508
+
509
+ def applicationShouldTerminate_(self, application):
510
+ if self.leaving or self.stopped:
511
+ return _TERMINATE_NOW
512
+
513
+ def proceed(go: bool = True):
514
+ if go:
515
+ threading.Thread(target=self.stopServer, daemon=True).start()
516
+ else:
517
+ application.replyToApplicationShouldTerminate_(False)
518
+
519
+ # After this returns, so the answer always comes later, as promised.
520
+ AppHelper.callAfter(self.beforeQuit, proceed)
521
+ return _TERMINATE_LATER
522
+
523
+ @objc.python_method
524
+ def beforeQuit(self, proceed):
525
+ """For a subclass: ask first (stations on air, say), then
526
+ `proceed()`, or `proceed(False)` to stay open. Runs on the main
527
+ thread."""
528
+ proceed()
529
+
530
+ @objc.python_method
531
+ def confirm(self, message: str, detail: str) -> bool:
532
+ """Quit or Cancel, asked in a dialog."""
533
+ alert = AppKit.NSAlert.alloc().init()
534
+ alert.setMessageText_(message)
535
+ alert.setInformativeText_(detail)
536
+ alert.addButtonWithTitle_("Quit")
537
+ alert.addButtonWithTitle_("Cancel")
538
+ return alert.runModal() == AppKit.NSAlertFirstButtonReturn
539
+
540
+ @objc.python_method
541
+ def stopServer(self):
542
+ try:
543
+ self.opts["quit_server"]()
544
+ except Exception as exc:
545
+ logger.warning("Could not stop %s: %s", self.APP.name, exc)
546
+ self.stopped = True
547
+ AppHelper.callAfter(app().replyToApplicationShouldTerminate_, True)
548
+
549
+
550
+ def run(window_class, url: str, quit_server, bundle: Path | None = None, **options) -> int:
551
+ """Show the app at `url` in its own window until it quits.
552
+ `quit_server()` stops the app; `options` are for the subclass."""
553
+ application = AppKit.NSApplication.sharedApplication()
554
+ application.setActivationPolicy_(AppKit.NSApplicationActivationPolicyRegular)
555
+ delegate = window_class.alloc().initWithURL_options_(
556
+ url, {"quit_server": quit_server, "bundle": bundle, **options})
557
+ application.setDelegate_(delegate)
558
+ AppHelper.runEventLoop()
559
+ return 0