webatem 0.2.2__tar.gz → 0.2.3__tar.gz
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.
- {webatem-0.2.2 → webatem-0.2.3}/PKG-INFO +14 -10
- {webatem-0.2.2 → webatem-0.2.3}/README.md +13 -9
- {webatem-0.2.2 → webatem-0.2.3}/pyproject.toml +1 -1
- {webatem-0.2.2 → webatem-0.2.3}/webatem/launcher.py +198 -160
- {webatem-0.2.2 → webatem-0.2.3}/webatem/server.py +13 -2
- {webatem-0.2.2 → webatem-0.2.3}/webatem/templates/launcher.html +27 -4
- {webatem-0.2.2 → webatem-0.2.3}/webatem/urls.py +1 -0
- {webatem-0.2.2 → webatem-0.2.3}/webatem/views.py +11 -1
- {webatem-0.2.2 → webatem-0.2.3}/webatem.egg-info/PKG-INFO +14 -10
- {webatem-0.2.2 → webatem-0.2.3}/LICENSE +0 -0
- {webatem-0.2.2 → webatem-0.2.3}/atem_control/__init__.py +0 -0
- {webatem-0.2.2 → webatem-0.2.3}/atem_control/activity.py +0 -0
- {webatem-0.2.2 → webatem-0.2.3}/atem_control/apps.py +0 -0
- {webatem-0.2.2 → webatem-0.2.3}/atem_control/control/__init__.py +0 -0
- {webatem-0.2.2 → webatem-0.2.3}/atem_control/control/activity.py +0 -0
- {webatem-0.2.2 → webatem-0.2.3}/atem_control/control/commands.py +0 -0
- {webatem-0.2.2 → webatem-0.2.3}/atem_control/control/consumer.py +0 -0
- {webatem-0.2.2 → webatem-0.2.3}/atem_control/control/device_api.py +0 -0
- {webatem-0.2.2 → webatem-0.2.3}/atem_control/control/logging.py +0 -0
- {webatem-0.2.2 → webatem-0.2.3}/atem_control/control/views.py +0 -0
- {webatem-0.2.2 → webatem-0.2.3}/atem_control/discovery.py +0 -0
- {webatem-0.2.2 → webatem-0.2.3}/atem_control/hyperdeck/__init__.py +0 -0
- {webatem-0.2.2 → webatem-0.2.3}/atem_control/hyperdeck/connection.py +0 -0
- {webatem-0.2.2 → webatem-0.2.3}/atem_control/hyperdeck/views.py +0 -0
- {webatem-0.2.2 → webatem-0.2.3}/atem_control/ip_upload_lock.py +0 -0
- {webatem-0.2.2 → webatem-0.2.3}/atem_control/media_pool/__init__.py +0 -0
- {webatem-0.2.2 → webatem-0.2.3}/atem_control/media_pool/broadcast.py +0 -0
- {webatem-0.2.2 → webatem-0.2.3}/atem_control/media_pool/views.py +0 -0
- {webatem-0.2.2 → webatem-0.2.3}/atem_control/media_pool/watcher.py +0 -0
- {webatem-0.2.2 → webatem-0.2.3}/atem_control/migrations/0001_initial.py +0 -0
- {webatem-0.2.2 → webatem-0.2.3}/atem_control/migrations/0002_switcher.py +0 -0
- {webatem-0.2.2 → webatem-0.2.3}/atem_control/migrations/0003_remove_atemcontrollog_atem_contro_user_id_3d6bb9_idx_and_more.py +0 -0
- {webatem-0.2.2 → webatem-0.2.3}/atem_control/migrations/0004_delete_switcher.py +0 -0
- {webatem-0.2.2 → webatem-0.2.3}/atem_control/migrations/__init__.py +0 -0
- {webatem-0.2.2 → webatem-0.2.3}/atem_control/models.py +0 -0
- {webatem-0.2.2 → webatem-0.2.3}/atem_control/netutil.py +0 -0
- {webatem-0.2.2 → webatem-0.2.3}/atem_control/profile/__init__.py +0 -0
- {webatem-0.2.2 → webatem-0.2.3}/atem_control/profile/dialog.py +0 -0
- {webatem-0.2.2 → webatem-0.2.3}/atem_control/profile/export.py +0 -0
- {webatem-0.2.2 → webatem-0.2.3}/atem_control/profile/views.py +0 -0
- {webatem-0.2.2 → webatem-0.2.3}/atem_control/routing.py +0 -0
- {webatem-0.2.2 → webatem-0.2.3}/atem_control/sightings.py +0 -0
- {webatem-0.2.2 → webatem-0.2.3}/atem_control/static/css/atem_connect.css +0 -0
- {webatem-0.2.2 → webatem-0.2.3}/atem_control/static/css/atem_control.css +0 -0
- {webatem-0.2.2 → webatem-0.2.3}/atem_control/static/css/theme.css +0 -0
- {webatem-0.2.2 → webatem-0.2.3}/atem_control/static/fonts/poppins/OFL.txt +0 -0
- {webatem-0.2.2 → webatem-0.2.3}/atem_control/static/fonts/poppins/Poppins-Bold.ttf +0 -0
- {webatem-0.2.2 → webatem-0.2.3}/atem_control/static/fonts/poppins/Poppins-Italic.ttf +0 -0
- {webatem-0.2.2 → webatem-0.2.3}/atem_control/static/fonts/poppins/Poppins-Medium.ttf +0 -0
- {webatem-0.2.2 → webatem-0.2.3}/atem_control/static/fonts/poppins/Poppins-Regular.ttf +0 -0
- {webatem-0.2.2 → webatem-0.2.3}/atem_control/static/fonts/poppins/Poppins-SemiBold.ttf +0 -0
- {webatem-0.2.2 → webatem-0.2.3}/atem_control/static/icon.png +0 -0
- {webatem-0.2.2 → webatem-0.2.3}/atem_control/static/js/atem_connect.js +0 -0
- {webatem-0.2.2 → webatem-0.2.3}/atem_control/static/js/atem_control.js +0 -0
- {webatem-0.2.2 → webatem-0.2.3}/atem_control/static/js/atem_control_page.js +0 -0
- {webatem-0.2.2 → webatem-0.2.3}/atem_control/static/js/atem_discovery.js +0 -0
- {webatem-0.2.2 → webatem-0.2.3}/atem_control/static/js/atem_hyperdeck.js +0 -0
- {webatem-0.2.2 → webatem-0.2.3}/atem_control/static/js/atem_profile.js +0 -0
- {webatem-0.2.2 → webatem-0.2.3}/atem_control/static/js/atem_realtime_slider.js +0 -0
- {webatem-0.2.2 → webatem-0.2.3}/atem_control/static/js/atem_server_settings.js +0 -0
- {webatem-0.2.2 → webatem-0.2.3}/atem_control/static/js/custom-scrollbar.js +0 -0
- {webatem-0.2.2 → webatem-0.2.3}/atem_control/static/js/pp-select.js +0 -0
- {webatem-0.2.2 → webatem-0.2.3}/atem_control/static/vendor/README.md +0 -0
- {webatem-0.2.2 → webatem-0.2.3}/atem_control/static/vendor/alpine.min.js +0 -0
- {webatem-0.2.2 → webatem-0.2.3}/atem_control/static/vendor/alpinejs-collapse-3.14.3.min.js +0 -0
- {webatem-0.2.2 → webatem-0.2.3}/atem_control/static/vendor/bootstrap-icons/bootstrap-icons.min.css +0 -0
- {webatem-0.2.2 → webatem-0.2.3}/atem_control/static/vendor/bootstrap-icons/fonts/bootstrap-icons.woff +0 -0
- {webatem-0.2.2 → webatem-0.2.3}/atem_control/static/vendor/bootstrap-icons/fonts/bootstrap-icons.woff2 +0 -0
- {webatem-0.2.2 → webatem-0.2.3}/atem_control/static/vendor/webatem.css +0 -0
- {webatem-0.2.2 → webatem-0.2.3}/atem_control/storage.py +0 -0
- {webatem-0.2.2 → webatem-0.2.3}/atem_control/tally.py +0 -0
- {webatem-0.2.2 → webatem-0.2.3}/atem_control/templates/connect.html +0 -0
- {webatem-0.2.2 → webatem-0.2.3}/atem_control/templates/control/_atem_info.html +0 -0
- {webatem-0.2.2 → webatem-0.2.3}/atem_control/templates/control/_fade_to_black.html +0 -0
- {webatem-0.2.2 → webatem-0.2.3}/atem_control/templates/control/_fairlight.html +0 -0
- {webatem-0.2.2 → webatem-0.2.3}/atem_control/templates/control/_header.html +0 -0
- {webatem-0.2.2 → webatem-0.2.3}/atem_control/templates/control/_hyperdeck_panel.html +0 -0
- {webatem-0.2.2 → webatem-0.2.3}/atem_control/templates/control/_macros.html +0 -0
- {webatem-0.2.2 → webatem-0.2.3}/atem_control/templates/control/_media_pool.html +0 -0
- {webatem-0.2.2 → webatem-0.2.3}/atem_control/templates/control/_profile_modal.html +0 -0
- {webatem-0.2.2 → webatem-0.2.3}/atem_control/templates/control/_settings_dsk.html +0 -0
- {webatem-0.2.2 → webatem-0.2.3}/atem_control/templates/control/_settings_transition.html +0 -0
- {webatem-0.2.2 → webatem-0.2.3}/atem_control/templates/control/_settings_usk.html +0 -0
- {webatem-0.2.2 → webatem-0.2.3}/atem_control/templates/control/_slider.html +0 -0
- {webatem-0.2.2 → webatem-0.2.3}/atem_control/templates/control/_transition_controls.html +0 -0
- {webatem-0.2.2 → webatem-0.2.3}/atem_control/templates/control/_transition_style.html +0 -0
- {webatem-0.2.2 → webatem-0.2.3}/atem_control/templates/control/_usk_fly.html +0 -0
- {webatem-0.2.2 → webatem-0.2.3}/atem_control/templates/control/_usk_keyframe.html +0 -0
- {webatem-0.2.2 → webatem-0.2.3}/atem_control/templates/control/_usk_mask.html +0 -0
- {webatem-0.2.2 → webatem-0.2.3}/atem_control/templates/control/_usk_slider.html +0 -0
- {webatem-0.2.2 → webatem-0.2.3}/atem_control/templates/control.html +0 -0
- {webatem-0.2.2 → webatem-0.2.3}/atem_control/templatetags/__init__.py +0 -0
- {webatem-0.2.2 → webatem-0.2.3}/atem_control/templatetags/static_v.py +0 -0
- {webatem-0.2.2 → webatem-0.2.3}/atem_control/uploader.py +0 -0
- {webatem-0.2.2 → webatem-0.2.3}/atem_control/urls.py +0 -0
- {webatem-0.2.2 → webatem-0.2.3}/setup.cfg +0 -0
- {webatem-0.2.2 → webatem-0.2.3}/webatem/__init__.py +0 -0
- {webatem-0.2.2 → webatem-0.2.3}/webatem/__main__.py +0 -0
- {webatem-0.2.2 → webatem-0.2.3}/webatem/asgi.py +0 -0
- {webatem-0.2.2 → webatem-0.2.3}/webatem/context.py +0 -0
- {webatem-0.2.2 → webatem-0.2.3}/webatem/settings.py +0 -0
- {webatem-0.2.2 → webatem-0.2.3}/webatem/templates/base.html +0 -0
- {webatem-0.2.2 → webatem-0.2.3}/webatem/websocket.py +0 -0
- {webatem-0.2.2 → webatem-0.2.3}/webatem.egg-info/SOURCES.txt +0 -0
- {webatem-0.2.2 → webatem-0.2.3}/webatem.egg-info/dependency_links.txt +0 -0
- {webatem-0.2.2 → webatem-0.2.3}/webatem.egg-info/entry_points.txt +0 -0
- {webatem-0.2.2 → webatem-0.2.3}/webatem.egg-info/requires.txt +0 -0
- {webatem-0.2.2 → webatem-0.2.3}/webatem.egg-info/top_level.txt +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: webatem
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.3
|
|
4
4
|
Summary: Browser-based control for Blackmagic ATEM switchers: run it, open the browser, your switchers are there
|
|
5
5
|
Author: Lucas Romanenko
|
|
6
6
|
License: MIT
|
|
@@ -76,8 +76,10 @@ Every way of running it **auto-discovers the ATEMs on your network**
|
|
|
76
76
|
|
|
77
77
|
1. Click your platform's **Download** button at the top (or the
|
|
78
78
|
[Releases](https://github.com/lucas-romanenko/webatem/releases) page).
|
|
79
|
-
2. Open it.
|
|
80
|
-
|
|
79
|
+
2. Open it. The icon appears in the menu bar / tray and the launcher window
|
|
80
|
+
comes up: pick the interface and port if the defaults are not right, then
|
|
81
|
+
**Launch GUI** opens the control page in your browser with the ATEMs on
|
|
82
|
+
your network listed. Pick one and you're controlling it.
|
|
81
83
|
|
|
82
84
|
That's the whole setup — nothing to install alongside it, no Python, no Docker.
|
|
83
85
|
|
|
@@ -90,7 +92,9 @@ page in your browser), **Hide** and **Quit**. Closing the window just hides
|
|
|
90
92
|
it; the tray menu is three items — **Show/Hide window**, **Launch GUI**,
|
|
91
93
|
**Quit**. Changing the interface or port restarts the server on the new
|
|
92
94
|
address right there; handy on a laptop with Wi-Fi, Ethernet and a VPN at
|
|
93
|
-
once. The same settings are behind the gear on the connect page.
|
|
95
|
+
once. The same settings are behind the gear on the connect page. The default
|
|
96
|
+
port is **8880** (Bitfocus Companion has 8000); if the port is taken anyway,
|
|
97
|
+
the next free one is used and the window says so.
|
|
94
98
|
|
|
95
99
|
<details>
|
|
96
100
|
<summary><b>First-launch security prompt</b> (the app isn't code-signed yet)</summary>
|
|
@@ -117,7 +121,7 @@ docker run -d --name webatem --network host --restart unless-stopped \
|
|
|
117
121
|
-v webatem-data:/app/data ghcr.io/lucas-romanenko/webatem:latest
|
|
118
122
|
```
|
|
119
123
|
|
|
120
|
-
Open `http://<that-box>:
|
|
124
|
+
Open `http://<that-box>:8880` from any device on the network. It comes back
|
|
121
125
|
automatically on reboot. No `.env` file needed — every setting has a working
|
|
122
126
|
default (SQLite DB + a generated secret key live in the `webatem-data`
|
|
123
127
|
volume); see [.env.example](.env.example) for knobs like `PORT`, `TIME_ZONE`,
|
|
@@ -131,8 +135,8 @@ prints the address to open from another device:
|
|
|
131
135
|
|
|
132
136
|
```
|
|
133
137
|
WebATEM is running.
|
|
134
|
-
On this machine: http://127.0.0.1:
|
|
135
|
-
From another device: http://192.168.1.50:
|
|
138
|
+
On this machine: http://127.0.0.1:8880/atem/
|
|
139
|
+
From another device: http://192.168.1.50:8880/atem/
|
|
136
140
|
```
|
|
137
141
|
|
|
138
142
|
Keep it up with a systemd unit, `tmux` or `nohup`.
|
|
@@ -151,7 +155,7 @@ webatem
|
|
|
151
155
|
```
|
|
152
156
|
|
|
153
157
|
Same behaviour as the download: a native process on your real network, the
|
|
154
|
-
|
|
158
|
+
tray icon and the launcher window appear where there is a desktop. Prebuilt wheels
|
|
155
159
|
for every platform mean no compiler is needed. Upgrade with
|
|
156
160
|
`pipx upgrade webatem`.
|
|
157
161
|
|
|
@@ -291,7 +295,7 @@ ATEM switchers (UDP 9910) · HyperDecks (TCP 9993 / FTP)
|
|
|
291
295
|
command dispatch table, the media-pool watcher, LAN discovery, and the UI.
|
|
292
296
|
- **`webatem/`** — the project package: settings, the ASGI entry, the
|
|
293
297
|
WebSocket origin guard, and `launcher.py` — the `webatem` command that runs
|
|
294
|
-
the same web app locally
|
|
298
|
+
the same web app locally and lives in the tray with its launcher window. The
|
|
295
299
|
downloads are that launcher frozen per OS by PyInstaller
|
|
296
300
|
(`build/desktop/`); the PyPI package is the same code installed by pip.
|
|
297
301
|
- **One tag, three deliverables.** A `v*` tag publishes the Mac / Windows /
|
|
@@ -306,7 +310,7 @@ git clone https://github.com/lucas-romanenko/webatem.git
|
|
|
306
310
|
cd webatem
|
|
307
311
|
npm install && npm run build:css # the stylesheet — without it the app renders unstyled
|
|
308
312
|
pip install -e ".[test]" # Python 3.10+; the device libraries install as wheels
|
|
309
|
-
python -m webatem # the launcher: server +
|
|
313
|
+
python -m webatem # the launcher: server + tray + window
|
|
310
314
|
python -m pytest tests/ -q # the suite (no hardware needed)
|
|
311
315
|
```
|
|
312
316
|
|
|
@@ -38,8 +38,10 @@ Every way of running it **auto-discovers the ATEMs on your network**
|
|
|
38
38
|
|
|
39
39
|
1. Click your platform's **Download** button at the top (or the
|
|
40
40
|
[Releases](https://github.com/lucas-romanenko/webatem/releases) page).
|
|
41
|
-
2. Open it.
|
|
42
|
-
|
|
41
|
+
2. Open it. The icon appears in the menu bar / tray and the launcher window
|
|
42
|
+
comes up: pick the interface and port if the defaults are not right, then
|
|
43
|
+
**Launch GUI** opens the control page in your browser with the ATEMs on
|
|
44
|
+
your network listed. Pick one and you're controlling it.
|
|
43
45
|
|
|
44
46
|
That's the whole setup — nothing to install alongside it, no Python, no Docker.
|
|
45
47
|
|
|
@@ -52,7 +54,9 @@ page in your browser), **Hide** and **Quit**. Closing the window just hides
|
|
|
52
54
|
it; the tray menu is three items — **Show/Hide window**, **Launch GUI**,
|
|
53
55
|
**Quit**. Changing the interface or port restarts the server on the new
|
|
54
56
|
address right there; handy on a laptop with Wi-Fi, Ethernet and a VPN at
|
|
55
|
-
once. The same settings are behind the gear on the connect page.
|
|
57
|
+
once. The same settings are behind the gear on the connect page. The default
|
|
58
|
+
port is **8880** (Bitfocus Companion has 8000); if the port is taken anyway,
|
|
59
|
+
the next free one is used and the window says so.
|
|
56
60
|
|
|
57
61
|
<details>
|
|
58
62
|
<summary><b>First-launch security prompt</b> (the app isn't code-signed yet)</summary>
|
|
@@ -79,7 +83,7 @@ docker run -d --name webatem --network host --restart unless-stopped \
|
|
|
79
83
|
-v webatem-data:/app/data ghcr.io/lucas-romanenko/webatem:latest
|
|
80
84
|
```
|
|
81
85
|
|
|
82
|
-
Open `http://<that-box>:
|
|
86
|
+
Open `http://<that-box>:8880` from any device on the network. It comes back
|
|
83
87
|
automatically on reboot. No `.env` file needed — every setting has a working
|
|
84
88
|
default (SQLite DB + a generated secret key live in the `webatem-data`
|
|
85
89
|
volume); see [.env.example](.env.example) for knobs like `PORT`, `TIME_ZONE`,
|
|
@@ -93,8 +97,8 @@ prints the address to open from another device:
|
|
|
93
97
|
|
|
94
98
|
```
|
|
95
99
|
WebATEM is running.
|
|
96
|
-
On this machine: http://127.0.0.1:
|
|
97
|
-
From another device: http://192.168.1.50:
|
|
100
|
+
On this machine: http://127.0.0.1:8880/atem/
|
|
101
|
+
From another device: http://192.168.1.50:8880/atem/
|
|
98
102
|
```
|
|
99
103
|
|
|
100
104
|
Keep it up with a systemd unit, `tmux` or `nohup`.
|
|
@@ -113,7 +117,7 @@ webatem
|
|
|
113
117
|
```
|
|
114
118
|
|
|
115
119
|
Same behaviour as the download: a native process on your real network, the
|
|
116
|
-
|
|
120
|
+
tray icon and the launcher window appear where there is a desktop. Prebuilt wheels
|
|
117
121
|
for every platform mean no compiler is needed. Upgrade with
|
|
118
122
|
`pipx upgrade webatem`.
|
|
119
123
|
|
|
@@ -253,7 +257,7 @@ ATEM switchers (UDP 9910) · HyperDecks (TCP 9993 / FTP)
|
|
|
253
257
|
command dispatch table, the media-pool watcher, LAN discovery, and the UI.
|
|
254
258
|
- **`webatem/`** — the project package: settings, the ASGI entry, the
|
|
255
259
|
WebSocket origin guard, and `launcher.py` — the `webatem` command that runs
|
|
256
|
-
the same web app locally
|
|
260
|
+
the same web app locally and lives in the tray with its launcher window. The
|
|
257
261
|
downloads are that launcher frozen per OS by PyInstaller
|
|
258
262
|
(`build/desktop/`); the PyPI package is the same code installed by pip.
|
|
259
263
|
- **One tag, three deliverables.** A `v*` tag publishes the Mac / Windows /
|
|
@@ -268,7 +272,7 @@ git clone https://github.com/lucas-romanenko/webatem.git
|
|
|
268
272
|
cd webatem
|
|
269
273
|
npm install && npm run build:css # the stylesheet — without it the app renders unstyled
|
|
270
274
|
pip install -e ".[test]" # Python 3.10+; the device libraries install as wheels
|
|
271
|
-
python -m webatem # the launcher: server +
|
|
275
|
+
python -m webatem # the launcher: server + tray + window
|
|
272
276
|
python -m pytest tests/ -q # the suite (no hardware needed)
|
|
273
277
|
```
|
|
274
278
|
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "webatem"
|
|
7
|
-
version = "0.2.
|
|
7
|
+
version = "0.2.3"
|
|
8
8
|
description = "Browser-based control for Blackmagic ATEM switchers: run it, open the browser, your switchers are there"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
license = { text = "MIT" }
|
|
@@ -9,20 +9,25 @@ can, exactly like ATEM Software Control.) Packaged into one self-contained
|
|
|
9
9
|
executable per OS via PyInstaller — no Python, no Docker required by the
|
|
10
10
|
user: download, double-click, browser opens, switchers appear.
|
|
11
11
|
|
|
12
|
-
Like Bitfocus Companion: a
|
|
13
|
-
|
|
14
|
-
Start minimized, Run at login, Launch GUI
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
12
|
+
Like Bitfocus Companion: a menu bar / system tray icon with three items
|
|
13
|
+
(Show/Hide window, Launch GUI, Quit) and a small window — Running, the
|
|
14
|
+
address, the interface and port, Start minimized, Run at login, Launch GUI
|
|
15
|
+
/ Hide / Quit. The window is the app's own /launcher/ page in a native
|
|
16
|
+
webview, run as a SEPARATE PROCESS: a native window and a tray icon each
|
|
17
|
+
want the main thread and the event loop, and sharing one loop between them
|
|
18
|
+
lost the tray on macOS (0.2.2). The tray process is the server; Show spawns
|
|
19
|
+
the window process, Hide ends it. Where the window library is missing it
|
|
20
|
+
is the tray alone (its menu then opens the launcher page in the browser);
|
|
21
|
+
where no tray is possible either — a headless Linux server — it runs in the
|
|
22
|
+
foreground as a plain process (Ctrl+C to quit).
|
|
23
|
+
|
|
24
|
+
If the port is taken at startup (Companion lives on 8000, hence 8880 here)
|
|
25
|
+
the next free port is used and the window says so.
|
|
21
26
|
|
|
22
27
|
The Docker image stays the path for a shared studio server (a Linux box
|
|
23
28
|
with a dedicated IP), where mDNS works natively too.
|
|
24
29
|
|
|
25
|
-
Env knobs (all optional): PORT (default
|
|
30
|
+
Env knobs (all optional): PORT (default 8880), HOST (default 0.0.0.0 so
|
|
26
31
|
other devices on the LAN can reach it), WEBATEM_DATA_DIR (override the
|
|
27
32
|
per-user data location), WEBATEM_NO_BROWSER=1 (don't auto-open a browser),
|
|
28
33
|
WEBATEM_NO_TRAY=1 (foreground mode even on a desktop), WEBATEM_NO_WINDOW=1
|
|
@@ -195,17 +200,20 @@ def set_autostart(enabled: bool) -> None:
|
|
|
195
200
|
|
|
196
201
|
# ---------------------------------------------------------------------------
|
|
197
202
|
# The server, supervised: uvicorn in a worker thread, restarted on a new
|
|
198
|
-
# address when the
|
|
199
|
-
#
|
|
203
|
+
# address when the launcher window asks (Companion's "run on interface X,
|
|
204
|
+
# port Y"), the process — and the tray — staying up throughout.
|
|
200
205
|
# ---------------------------------------------------------------------------
|
|
201
206
|
|
|
202
207
|
class _Supervisor:
|
|
208
|
+
PORT_TRIES = 20 # when the configured port is taken, walk up this far
|
|
209
|
+
|
|
203
210
|
def __init__(self, application, host: str, port: int):
|
|
204
211
|
self.application = application
|
|
205
212
|
self.host, self.port = host, port
|
|
206
213
|
self.last_error = None
|
|
207
|
-
self.
|
|
208
|
-
self.
|
|
214
|
+
self.startup_note = None # "port X was in use; using Y"
|
|
215
|
+
self.ready = threading.Event() # set once a server answers
|
|
216
|
+
self.on_change = None # hook after a restart
|
|
209
217
|
self._wake = threading.Event()
|
|
210
218
|
self._pending = None
|
|
211
219
|
self._stopping = False
|
|
@@ -265,8 +273,22 @@ class _Supervisor:
|
|
|
265
273
|
worker.join(15)
|
|
266
274
|
|
|
267
275
|
def _loop(self) -> None:
|
|
276
|
+
wanted = self.port
|
|
268
277
|
server, worker = self._serve(self.host, self.port)
|
|
269
|
-
|
|
278
|
+
up = self._came_up(server, worker)
|
|
279
|
+
tries = 0
|
|
280
|
+
while not up and tries < self.PORT_TRIES:
|
|
281
|
+
# The port is taken (Companion on 8000, an earlier WebATEM, …):
|
|
282
|
+
# take the next one rather than failing.
|
|
283
|
+
self._shutdown(server, worker)
|
|
284
|
+
tries += 1
|
|
285
|
+
self.port = wanted + tries
|
|
286
|
+
server, worker = self._serve(self.host, self.port)
|
|
287
|
+
up = self._came_up(server, worker)
|
|
288
|
+
if up:
|
|
289
|
+
if self.port != wanted:
|
|
290
|
+
self.startup_note = f'Port {wanted} was in use, so WebATEM is on port {self.port} this time.'
|
|
291
|
+
print(self.startup_note, flush=True)
|
|
270
292
|
self.ready.set()
|
|
271
293
|
while True:
|
|
272
294
|
self._wake.wait()
|
|
@@ -280,6 +302,7 @@ class _Supervisor:
|
|
|
280
302
|
server, worker = self._serve(host, port)
|
|
281
303
|
if self._came_up(server, worker):
|
|
282
304
|
self.host, self.port, self.last_error = host, port, None
|
|
305
|
+
self.startup_note = None
|
|
283
306
|
print(f'{APP_NAME} now listening on {host}:{port}', flush=True)
|
|
284
307
|
else:
|
|
285
308
|
self.last_error = f'could not listen on {host}:{port} (is the port in use?)'
|
|
@@ -297,10 +320,11 @@ class _Supervisor:
|
|
|
297
320
|
|
|
298
321
|
class _Controller:
|
|
299
322
|
"""What the web app sees (webatem.server.runtime): the live address, a
|
|
300
|
-
restart,
|
|
323
|
+
restart, the start-at-login switch, and Quit."""
|
|
301
324
|
|
|
302
325
|
def __init__(self, supervisor):
|
|
303
326
|
self._s = supervisor
|
|
327
|
+
self.on_quit = None
|
|
304
328
|
|
|
305
329
|
@property
|
|
306
330
|
def host(self):
|
|
@@ -314,16 +338,116 @@ class _Controller:
|
|
|
314
338
|
def last_error(self):
|
|
315
339
|
return self._s.last_error
|
|
316
340
|
|
|
341
|
+
@property
|
|
342
|
+
def startup_note(self):
|
|
343
|
+
return self._s.startup_note
|
|
344
|
+
|
|
317
345
|
def restart(self, host, port):
|
|
318
346
|
self._s.restart(host, port)
|
|
319
347
|
|
|
348
|
+
def quit(self):
|
|
349
|
+
# From a request handler: let the response out, then stop the tray.
|
|
350
|
+
if self.on_quit:
|
|
351
|
+
threading.Timer(0.3, self.on_quit).start()
|
|
352
|
+
|
|
320
353
|
autostart_enabled = staticmethod(autostart_enabled)
|
|
321
354
|
set_autostart = staticmethod(set_autostart)
|
|
322
355
|
|
|
323
356
|
|
|
324
357
|
# ---------------------------------------------------------------------------
|
|
325
|
-
# The
|
|
326
|
-
#
|
|
358
|
+
# The launcher window: this same program run again with --window <url>,
|
|
359
|
+
# showing the page in a native webview (pywebview). Its own process, so its
|
|
360
|
+
# event loop never competes with the tray's.
|
|
361
|
+
# ---------------------------------------------------------------------------
|
|
362
|
+
|
|
363
|
+
def _window_support() -> bool:
|
|
364
|
+
"""Is pywebview there? Checked WITHOUT importing it: importing its macOS
|
|
365
|
+
backend switches this process to a regular (Dock) application at import
|
|
366
|
+
time, which is exactly what took the menu-bar icon away in 0.2.2. The
|
|
367
|
+
window process imports it; this one never does."""
|
|
368
|
+
if os.environ.get('WEBATEM_NO_WINDOW') == '1':
|
|
369
|
+
return False
|
|
370
|
+
try:
|
|
371
|
+
import importlib.util
|
|
372
|
+
return importlib.util.find_spec('webview') is not None
|
|
373
|
+
except Exception: # noqa: BLE001
|
|
374
|
+
return False
|
|
375
|
+
|
|
376
|
+
|
|
377
|
+
def _on_ui_thread(fn) -> None:
|
|
378
|
+
"""Run fn on the tray's event loop thread where that matters (macOS: the
|
|
379
|
+
NSApplication is stopped from the main thread only)."""
|
|
380
|
+
if sys.platform == 'darwin':
|
|
381
|
+
try:
|
|
382
|
+
from PyObjCTools import AppHelper
|
|
383
|
+
AppHelper.callAfter(fn)
|
|
384
|
+
return
|
|
385
|
+
except Exception: # noqa: BLE001
|
|
386
|
+
pass
|
|
387
|
+
fn()
|
|
388
|
+
|
|
389
|
+
|
|
390
|
+
def _window_process(url: str) -> None:
|
|
391
|
+
import webview
|
|
392
|
+
|
|
393
|
+
class Api:
|
|
394
|
+
def launch(self, target):
|
|
395
|
+
_open_browser(target)
|
|
396
|
+
|
|
397
|
+
def hide(self):
|
|
398
|
+
window.destroy()
|
|
399
|
+
|
|
400
|
+
window = webview.create_window(APP_NAME, url, js_api=Api(), width=520, height=700, resizable=False)
|
|
401
|
+
webview.start()
|
|
402
|
+
|
|
403
|
+
|
|
404
|
+
class _WindowChild:
|
|
405
|
+
"""Show spawns the window process, Hide ends it; the close button ends
|
|
406
|
+
it too (the page's Hide and Quit go through the bridge / the server)."""
|
|
407
|
+
|
|
408
|
+
def __init__(self, supervisor):
|
|
409
|
+
self._s = supervisor
|
|
410
|
+
self._proc = None
|
|
411
|
+
|
|
412
|
+
def _command(self):
|
|
413
|
+
url = f'http://127.0.0.1:{self._s.port}/launcher/'
|
|
414
|
+
if getattr(sys, 'frozen', False):
|
|
415
|
+
return [sys.executable, '--window', url]
|
|
416
|
+
return [sys.executable, '-m', 'webatem', '--window', url]
|
|
417
|
+
|
|
418
|
+
def visible(self) -> bool:
|
|
419
|
+
return self._proc is not None and self._proc.poll() is None
|
|
420
|
+
|
|
421
|
+
def show(self) -> None:
|
|
422
|
+
if self.visible():
|
|
423
|
+
return
|
|
424
|
+
import subprocess
|
|
425
|
+
try:
|
|
426
|
+
self._proc = subprocess.Popen(self._command(), stdin=subprocess.DEVNULL,
|
|
427
|
+
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
|
428
|
+
except Exception as e: # noqa: BLE001
|
|
429
|
+
print(f'The launcher window could not open: {e}', flush=True)
|
|
430
|
+
self._proc = None
|
|
431
|
+
|
|
432
|
+
def hide(self) -> None:
|
|
433
|
+
proc, self._proc = self._proc, None
|
|
434
|
+
if proc is not None and proc.poll() is None:
|
|
435
|
+
proc.terminate()
|
|
436
|
+
|
|
437
|
+
def reap():
|
|
438
|
+
try:
|
|
439
|
+
proc.wait(3)
|
|
440
|
+
except Exception: # noqa: BLE001
|
|
441
|
+
proc.kill()
|
|
442
|
+
threading.Thread(target=reap, daemon=True).start()
|
|
443
|
+
|
|
444
|
+
def toggle(self) -> None:
|
|
445
|
+
self.hide() if self.visible() else self.show()
|
|
446
|
+
|
|
447
|
+
|
|
448
|
+
# ---------------------------------------------------------------------------
|
|
449
|
+
# The tray (pystray) — on the main thread, as macOS insists (0.2.1's proven
|
|
450
|
+
# shape). The server runs supervised in threads; Quit stops everything.
|
|
327
451
|
# ---------------------------------------------------------------------------
|
|
328
452
|
|
|
329
453
|
def _tray_image():
|
|
@@ -338,7 +462,7 @@ def _tray_image():
|
|
|
338
462
|
return Image.new('RGBA', (64, 64), (246, 139, 42, 255))
|
|
339
463
|
|
|
340
464
|
|
|
341
|
-
def _run_tray(supervisor) -> None:
|
|
465
|
+
def _run_tray(supervisor, controller, window, show_window: bool) -> None:
|
|
342
466
|
"""Menu bar / system tray icon; returns when the user quits."""
|
|
343
467
|
import pystray
|
|
344
468
|
from pystray import Menu, MenuItem
|
|
@@ -356,136 +480,56 @@ def _run_tray(supervisor) -> None:
|
|
|
356
480
|
except Exception as e: # noqa: BLE001 — a failed toggle must not kill the tray
|
|
357
481
|
print(f'Start at login could not be changed: {e}', flush=True)
|
|
358
482
|
|
|
359
|
-
def quit_app(icon, item):
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
483
|
+
def quit_app(icon, item=None):
|
|
484
|
+
if window is not None:
|
|
485
|
+
window.hide()
|
|
486
|
+
_on_ui_thread(icon.stop)
|
|
487
|
+
|
|
488
|
+
if window is not None:
|
|
489
|
+
# Companion's three: the window carries the settings.
|
|
490
|
+
menu = Menu(
|
|
491
|
+
MenuItem('Show/Hide window', lambda icon, item: window.toggle(), default=True),
|
|
492
|
+
MenuItem('Launch GUI', lambda icon, item: _open_browser(local_url())),
|
|
493
|
+
Menu.SEPARATOR,
|
|
494
|
+
MenuItem('Quit', quit_app),
|
|
495
|
+
)
|
|
496
|
+
else:
|
|
497
|
+
menu = Menu(
|
|
498
|
+
MenuItem(shown_address, None, enabled=False),
|
|
499
|
+
MenuItem('Open in browser', lambda icon, item: _open_browser(local_url()), default=True),
|
|
500
|
+
MenuItem('Server settings…', lambda icon, item: _open_browser(local_url().replace('/atem/', '/launcher/'))),
|
|
501
|
+
MenuItem('Start at login', toggle_autostart, checked=lambda item: autostart_enabled()),
|
|
502
|
+
Menu.SEPARATOR,
|
|
503
|
+
MenuItem('Quit', quit_app),
|
|
504
|
+
)
|
|
370
505
|
icon = pystray.Icon(APP_NAME, _tray_image(), APP_NAME, menu)
|
|
371
506
|
supervisor.on_change = icon.update_menu
|
|
507
|
+
controller.on_quit = lambda: quit_app(icon)
|
|
372
508
|
|
|
373
|
-
def
|
|
374
|
-
|
|
375
|
-
# tray pretending: tell the user and leave.
|
|
376
|
-
if not supervisor.ready.wait(30):
|
|
509
|
+
def after_start():
|
|
510
|
+
if not supervisor.ready.wait(60):
|
|
377
511
|
try:
|
|
378
512
|
if getattr(icon, 'HAS_NOTIFICATION', False):
|
|
379
|
-
icon.notify(f'{APP_NAME} could not
|
|
513
|
+
icon.notify(f'{APP_NAME} could not find a free port to start on.', APP_NAME)
|
|
380
514
|
except Exception:
|
|
381
515
|
pass
|
|
382
516
|
time.sleep(3)
|
|
383
517
|
icon.stop()
|
|
518
|
+
return
|
|
519
|
+
if window is not None and show_window:
|
|
520
|
+
window.show()
|
|
384
521
|
|
|
385
|
-
threading.Thread(target=
|
|
522
|
+
threading.Thread(target=after_start, daemon=True).start()
|
|
386
523
|
icon.run()
|
|
387
524
|
|
|
388
525
|
|
|
389
|
-
class _WindowApi:
|
|
390
|
-
"""What the launcher page can call through window.pywebview.api."""
|
|
391
|
-
|
|
392
|
-
def __init__(self, supervisor):
|
|
393
|
-
self._s = supervisor
|
|
394
|
-
self.window = None
|
|
395
|
-
self.visible = True
|
|
396
|
-
|
|
397
|
-
def launch(self):
|
|
398
|
-
_open_browser(_urls(self._s.host, self._s.port)[0])
|
|
399
|
-
|
|
400
|
-
def show(self):
|
|
401
|
-
if self.window is not None:
|
|
402
|
-
self.window.show()
|
|
403
|
-
self.visible = True
|
|
404
|
-
|
|
405
|
-
def hide(self):
|
|
406
|
-
if self.window is not None:
|
|
407
|
-
self.window.hide()
|
|
408
|
-
self.visible = False
|
|
409
|
-
|
|
410
|
-
def toggle(self):
|
|
411
|
-
self.hide() if self.visible else self.show()
|
|
412
|
-
|
|
413
|
-
def quit(self):
|
|
414
|
-
if self.window is not None:
|
|
415
|
-
self.window.destroy() # webview.start() returns; main() winds down
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
_LOADING_HTML = ('<!doctype html><html style="background:#121315;color:#8f9399;font:15px system-ui">'
|
|
419
|
-
'<body style="display:flex;align-items:center;justify-content:center;height:100vh;margin:0">'
|
|
420
|
-
'Starting WebATEM…</body></html>')
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
def _run_window(supervisor, start_hidden: bool) -> None:
|
|
424
|
-
"""The Companion-style launcher window plus a detached tray icon sharing
|
|
425
|
-
its event loop. Returns when the user quits."""
|
|
426
|
-
import webview
|
|
427
|
-
api = _WindowApi(supervisor)
|
|
428
|
-
|
|
429
|
-
def page_url():
|
|
430
|
-
return f'http://127.0.0.1:{supervisor.port}/launcher/'
|
|
431
|
-
|
|
432
|
-
window = webview.create_window(APP_NAME, html=_LOADING_HTML, js_api=api, width=520, height=680,
|
|
433
|
-
resizable=False, hidden=start_hidden)
|
|
434
|
-
api.window = window
|
|
435
|
-
api.visible = not start_hidden
|
|
436
|
-
|
|
437
|
-
def on_closing():
|
|
438
|
-
api.hide() # the close button hides; Quit quits
|
|
439
|
-
return False
|
|
440
|
-
window.events.closing += on_closing
|
|
441
|
-
window.events.shown += lambda: setattr(api, 'visible', True)
|
|
442
|
-
|
|
443
|
-
icon = None
|
|
444
|
-
try:
|
|
445
|
-
import pystray
|
|
446
|
-
from pystray import Menu, MenuItem
|
|
447
|
-
menu = Menu(
|
|
448
|
-
MenuItem('Show/Hide window', lambda i, it: api.toggle(), default=True),
|
|
449
|
-
MenuItem('Launch GUI', lambda i, it: api.launch()),
|
|
450
|
-
Menu.SEPARATOR,
|
|
451
|
-
MenuItem('Quit', lambda i, it: api.quit()),
|
|
452
|
-
)
|
|
453
|
-
icon = pystray.Icon(APP_NAME, _tray_image(), APP_NAME, menu)
|
|
454
|
-
if sys.platform == 'darwin':
|
|
455
|
-
from AppKit import NSApplication
|
|
456
|
-
icon.run_detached(darwin_nsapplication=NSApplication.sharedApplication())
|
|
457
|
-
else:
|
|
458
|
-
icon.run_detached()
|
|
459
|
-
except Exception as e: # noqa: BLE001
|
|
460
|
-
print(f'No tray ({e}); the window alone.', flush=True)
|
|
461
|
-
icon = None
|
|
462
|
-
|
|
463
|
-
def after_change():
|
|
464
|
-
# The server moved: the page it came from is gone; load it from the new address.
|
|
465
|
-
try:
|
|
466
|
-
window.load_url(page_url())
|
|
467
|
-
except Exception:
|
|
468
|
-
pass
|
|
469
|
-
supervisor.on_change = after_change
|
|
470
|
-
|
|
471
|
-
def setup():
|
|
472
|
-
if supervisor.ready.wait(30):
|
|
473
|
-
window.load_url(page_url())
|
|
474
|
-
else:
|
|
475
|
-
window.load_html('<!doctype html><html style="background:#121315;color:#ff5c6e;font:15px system-ui">'
|
|
476
|
-
'<body style="padding:2rem">WebATEM could not start on port '
|
|
477
|
-
f'{supervisor.port}. Is it already running?</body></html>')
|
|
478
|
-
|
|
479
|
-
webview.start(func=setup)
|
|
480
|
-
if icon is not None:
|
|
481
|
-
try:
|
|
482
|
-
icon.stop()
|
|
483
|
-
except Exception:
|
|
484
|
-
pass
|
|
485
|
-
|
|
486
|
-
|
|
487
526
|
def main() -> None:
|
|
488
|
-
|
|
527
|
+
argv = sys.argv[1:]
|
|
528
|
+
if '--window' in argv:
|
|
529
|
+
i = argv.index('--window')
|
|
530
|
+
_window_process(argv[i + 1] if i + 1 < len(argv) else 'http://127.0.0.1:8880/launcher/')
|
|
531
|
+
return
|
|
532
|
+
autostarted = '--autostart' in argv
|
|
489
533
|
data_dir = _data_dir()
|
|
490
534
|
# A windowed build has no console: send output to a log in the data dir
|
|
491
535
|
# so a failure is diagnosable instead of silent.
|
|
@@ -512,12 +556,6 @@ def main() -> None:
|
|
|
512
556
|
from webatem import server as srv
|
|
513
557
|
cfg = srv.load() # HOST/PORT env > server.json > defaults
|
|
514
558
|
host, port = cfg['host'], cfg['port']
|
|
515
|
-
local_url, lan_url = _urls(host, port)
|
|
516
|
-
|
|
517
|
-
# Auto-open a browser only where there's a display, the user didn't opt
|
|
518
|
-
# out, and this is not the login-time start (a browser popping up at
|
|
519
|
-
# boot is not what anyone ticked the box for).
|
|
520
|
-
want_browser = _has_display() and os.environ.get('WEBATEM_NO_BROWSER') != '1' and not autostarted
|
|
521
559
|
|
|
522
560
|
from webatem.asgi import application
|
|
523
561
|
|
|
@@ -528,42 +566,42 @@ def main() -> None:
|
|
|
528
566
|
except Exception as e: # noqa: BLE001
|
|
529
567
|
print(f'No tray ({e}); running in the foreground.', flush=True)
|
|
530
568
|
want_tray = False
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
print(f'No launcher window ({e}); the tray alone.', flush=True)
|
|
537
|
-
want_window = False
|
|
569
|
+
has_window = want_tray and _window_support()
|
|
570
|
+
# With the window there is no browser at start: the window is the first
|
|
571
|
+
# thing (Launch GUI opens the browser). Without it, the browser opens as
|
|
572
|
+
# before — unless this is the login-time start.
|
|
573
|
+
want_browser = _has_display() and not has_window and os.environ.get('WEBATEM_NO_BROWSER') != '1' and not autostarted
|
|
538
574
|
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
575
|
+
supervisor = _Supervisor(application, host, port)
|
|
576
|
+
controller = _Controller(supervisor)
|
|
577
|
+
srv.runtime.register(controller)
|
|
578
|
+
supervisor.start()
|
|
579
|
+
|
|
580
|
+
local_url, lan_url = _urls(host, port)
|
|
581
|
+
lines = ['', f' {APP_NAME} is starting on {host}:{port}.']
|
|
542
582
|
lines.append(f' On this machine: {local_url}')
|
|
543
583
|
if lan_url and lan_url != local_url:
|
|
544
584
|
lines.append(f' From another device: {lan_url}')
|
|
545
|
-
lines.append(' Address and port: the launcher window, or
|
|
585
|
+
lines.append(' Address and port: the launcher window, or /launcher/ in a browser.')
|
|
546
586
|
lines.append(' (Quit from the tray icon.)' if want_tray else ' (Press Ctrl+C to quit.)')
|
|
547
587
|
lines.append('')
|
|
548
588
|
print('\n'.join(lines), flush=True)
|
|
549
589
|
|
|
550
|
-
supervisor = _Supervisor(application, host, port)
|
|
551
|
-
srv.runtime.register(_Controller(supervisor))
|
|
552
|
-
supervisor.start()
|
|
553
590
|
if want_browser:
|
|
554
|
-
threading.Thread(target=lambda: supervisor.ready.wait(
|
|
591
|
+
threading.Thread(target=lambda: supervisor.ready.wait(60) and _open_browser(_urls(supervisor.host, supervisor.port)[0]), daemon=True).start()
|
|
555
592
|
|
|
593
|
+
window = _WindowChild(supervisor) if has_window else None
|
|
556
594
|
try:
|
|
557
|
-
if
|
|
558
|
-
|
|
559
|
-
elif want_tray:
|
|
560
|
-
_run_tray(supervisor)
|
|
595
|
+
if want_tray:
|
|
596
|
+
_run_tray(supervisor, controller, window, show_window=not (bool(cfg.get('start_minimized')) or autostarted))
|
|
561
597
|
else:
|
|
562
598
|
while supervisor._thread.is_alive():
|
|
563
599
|
supervisor._thread.join(1)
|
|
564
600
|
except KeyboardInterrupt:
|
|
565
601
|
pass
|
|
566
602
|
finally:
|
|
603
|
+
if window is not None:
|
|
604
|
+
window.hide()
|
|
567
605
|
supervisor.stop()
|
|
568
606
|
supervisor.join(20)
|
|
569
607
|
|