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.
- station_appkit/__init__.py +65 -0
- station_appkit/macwindow.py +559 -0
- station_appkit/selfupdate.py +296 -0
- station_appkit/testing.py +48 -0
- station_appkit/updates.py +97 -0
- station_appkit/window.py +231 -0
- station_appkit-1.0.0.dist-info/METADATA +55 -0
- station_appkit-1.0.0.dist-info/RECORD +10 -0
- station_appkit-1.0.0.dist-info/WHEEL +4 -0
- station_appkit-1.0.0.dist-info/licenses/LICENSE +21 -0
|
@@ -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
|