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.
Files changed (108) hide show
  1. {webatem-0.2.2 → webatem-0.2.3}/PKG-INFO +14 -10
  2. {webatem-0.2.2 → webatem-0.2.3}/README.md +13 -9
  3. {webatem-0.2.2 → webatem-0.2.3}/pyproject.toml +1 -1
  4. {webatem-0.2.2 → webatem-0.2.3}/webatem/launcher.py +198 -160
  5. {webatem-0.2.2 → webatem-0.2.3}/webatem/server.py +13 -2
  6. {webatem-0.2.2 → webatem-0.2.3}/webatem/templates/launcher.html +27 -4
  7. {webatem-0.2.2 → webatem-0.2.3}/webatem/urls.py +1 -0
  8. {webatem-0.2.2 → webatem-0.2.3}/webatem/views.py +11 -1
  9. {webatem-0.2.2 → webatem-0.2.3}/webatem.egg-info/PKG-INFO +14 -10
  10. {webatem-0.2.2 → webatem-0.2.3}/LICENSE +0 -0
  11. {webatem-0.2.2 → webatem-0.2.3}/atem_control/__init__.py +0 -0
  12. {webatem-0.2.2 → webatem-0.2.3}/atem_control/activity.py +0 -0
  13. {webatem-0.2.2 → webatem-0.2.3}/atem_control/apps.py +0 -0
  14. {webatem-0.2.2 → webatem-0.2.3}/atem_control/control/__init__.py +0 -0
  15. {webatem-0.2.2 → webatem-0.2.3}/atem_control/control/activity.py +0 -0
  16. {webatem-0.2.2 → webatem-0.2.3}/atem_control/control/commands.py +0 -0
  17. {webatem-0.2.2 → webatem-0.2.3}/atem_control/control/consumer.py +0 -0
  18. {webatem-0.2.2 → webatem-0.2.3}/atem_control/control/device_api.py +0 -0
  19. {webatem-0.2.2 → webatem-0.2.3}/atem_control/control/logging.py +0 -0
  20. {webatem-0.2.2 → webatem-0.2.3}/atem_control/control/views.py +0 -0
  21. {webatem-0.2.2 → webatem-0.2.3}/atem_control/discovery.py +0 -0
  22. {webatem-0.2.2 → webatem-0.2.3}/atem_control/hyperdeck/__init__.py +0 -0
  23. {webatem-0.2.2 → webatem-0.2.3}/atem_control/hyperdeck/connection.py +0 -0
  24. {webatem-0.2.2 → webatem-0.2.3}/atem_control/hyperdeck/views.py +0 -0
  25. {webatem-0.2.2 → webatem-0.2.3}/atem_control/ip_upload_lock.py +0 -0
  26. {webatem-0.2.2 → webatem-0.2.3}/atem_control/media_pool/__init__.py +0 -0
  27. {webatem-0.2.2 → webatem-0.2.3}/atem_control/media_pool/broadcast.py +0 -0
  28. {webatem-0.2.2 → webatem-0.2.3}/atem_control/media_pool/views.py +0 -0
  29. {webatem-0.2.2 → webatem-0.2.3}/atem_control/media_pool/watcher.py +0 -0
  30. {webatem-0.2.2 → webatem-0.2.3}/atem_control/migrations/0001_initial.py +0 -0
  31. {webatem-0.2.2 → webatem-0.2.3}/atem_control/migrations/0002_switcher.py +0 -0
  32. {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
  33. {webatem-0.2.2 → webatem-0.2.3}/atem_control/migrations/0004_delete_switcher.py +0 -0
  34. {webatem-0.2.2 → webatem-0.2.3}/atem_control/migrations/__init__.py +0 -0
  35. {webatem-0.2.2 → webatem-0.2.3}/atem_control/models.py +0 -0
  36. {webatem-0.2.2 → webatem-0.2.3}/atem_control/netutil.py +0 -0
  37. {webatem-0.2.2 → webatem-0.2.3}/atem_control/profile/__init__.py +0 -0
  38. {webatem-0.2.2 → webatem-0.2.3}/atem_control/profile/dialog.py +0 -0
  39. {webatem-0.2.2 → webatem-0.2.3}/atem_control/profile/export.py +0 -0
  40. {webatem-0.2.2 → webatem-0.2.3}/atem_control/profile/views.py +0 -0
  41. {webatem-0.2.2 → webatem-0.2.3}/atem_control/routing.py +0 -0
  42. {webatem-0.2.2 → webatem-0.2.3}/atem_control/sightings.py +0 -0
  43. {webatem-0.2.2 → webatem-0.2.3}/atem_control/static/css/atem_connect.css +0 -0
  44. {webatem-0.2.2 → webatem-0.2.3}/atem_control/static/css/atem_control.css +0 -0
  45. {webatem-0.2.2 → webatem-0.2.3}/atem_control/static/css/theme.css +0 -0
  46. {webatem-0.2.2 → webatem-0.2.3}/atem_control/static/fonts/poppins/OFL.txt +0 -0
  47. {webatem-0.2.2 → webatem-0.2.3}/atem_control/static/fonts/poppins/Poppins-Bold.ttf +0 -0
  48. {webatem-0.2.2 → webatem-0.2.3}/atem_control/static/fonts/poppins/Poppins-Italic.ttf +0 -0
  49. {webatem-0.2.2 → webatem-0.2.3}/atem_control/static/fonts/poppins/Poppins-Medium.ttf +0 -0
  50. {webatem-0.2.2 → webatem-0.2.3}/atem_control/static/fonts/poppins/Poppins-Regular.ttf +0 -0
  51. {webatem-0.2.2 → webatem-0.2.3}/atem_control/static/fonts/poppins/Poppins-SemiBold.ttf +0 -0
  52. {webatem-0.2.2 → webatem-0.2.3}/atem_control/static/icon.png +0 -0
  53. {webatem-0.2.2 → webatem-0.2.3}/atem_control/static/js/atem_connect.js +0 -0
  54. {webatem-0.2.2 → webatem-0.2.3}/atem_control/static/js/atem_control.js +0 -0
  55. {webatem-0.2.2 → webatem-0.2.3}/atem_control/static/js/atem_control_page.js +0 -0
  56. {webatem-0.2.2 → webatem-0.2.3}/atem_control/static/js/atem_discovery.js +0 -0
  57. {webatem-0.2.2 → webatem-0.2.3}/atem_control/static/js/atem_hyperdeck.js +0 -0
  58. {webatem-0.2.2 → webatem-0.2.3}/atem_control/static/js/atem_profile.js +0 -0
  59. {webatem-0.2.2 → webatem-0.2.3}/atem_control/static/js/atem_realtime_slider.js +0 -0
  60. {webatem-0.2.2 → webatem-0.2.3}/atem_control/static/js/atem_server_settings.js +0 -0
  61. {webatem-0.2.2 → webatem-0.2.3}/atem_control/static/js/custom-scrollbar.js +0 -0
  62. {webatem-0.2.2 → webatem-0.2.3}/atem_control/static/js/pp-select.js +0 -0
  63. {webatem-0.2.2 → webatem-0.2.3}/atem_control/static/vendor/README.md +0 -0
  64. {webatem-0.2.2 → webatem-0.2.3}/atem_control/static/vendor/alpine.min.js +0 -0
  65. {webatem-0.2.2 → webatem-0.2.3}/atem_control/static/vendor/alpinejs-collapse-3.14.3.min.js +0 -0
  66. {webatem-0.2.2 → webatem-0.2.3}/atem_control/static/vendor/bootstrap-icons/bootstrap-icons.min.css +0 -0
  67. {webatem-0.2.2 → webatem-0.2.3}/atem_control/static/vendor/bootstrap-icons/fonts/bootstrap-icons.woff +0 -0
  68. {webatem-0.2.2 → webatem-0.2.3}/atem_control/static/vendor/bootstrap-icons/fonts/bootstrap-icons.woff2 +0 -0
  69. {webatem-0.2.2 → webatem-0.2.3}/atem_control/static/vendor/webatem.css +0 -0
  70. {webatem-0.2.2 → webatem-0.2.3}/atem_control/storage.py +0 -0
  71. {webatem-0.2.2 → webatem-0.2.3}/atem_control/tally.py +0 -0
  72. {webatem-0.2.2 → webatem-0.2.3}/atem_control/templates/connect.html +0 -0
  73. {webatem-0.2.2 → webatem-0.2.3}/atem_control/templates/control/_atem_info.html +0 -0
  74. {webatem-0.2.2 → webatem-0.2.3}/atem_control/templates/control/_fade_to_black.html +0 -0
  75. {webatem-0.2.2 → webatem-0.2.3}/atem_control/templates/control/_fairlight.html +0 -0
  76. {webatem-0.2.2 → webatem-0.2.3}/atem_control/templates/control/_header.html +0 -0
  77. {webatem-0.2.2 → webatem-0.2.3}/atem_control/templates/control/_hyperdeck_panel.html +0 -0
  78. {webatem-0.2.2 → webatem-0.2.3}/atem_control/templates/control/_macros.html +0 -0
  79. {webatem-0.2.2 → webatem-0.2.3}/atem_control/templates/control/_media_pool.html +0 -0
  80. {webatem-0.2.2 → webatem-0.2.3}/atem_control/templates/control/_profile_modal.html +0 -0
  81. {webatem-0.2.2 → webatem-0.2.3}/atem_control/templates/control/_settings_dsk.html +0 -0
  82. {webatem-0.2.2 → webatem-0.2.3}/atem_control/templates/control/_settings_transition.html +0 -0
  83. {webatem-0.2.2 → webatem-0.2.3}/atem_control/templates/control/_settings_usk.html +0 -0
  84. {webatem-0.2.2 → webatem-0.2.3}/atem_control/templates/control/_slider.html +0 -0
  85. {webatem-0.2.2 → webatem-0.2.3}/atem_control/templates/control/_transition_controls.html +0 -0
  86. {webatem-0.2.2 → webatem-0.2.3}/atem_control/templates/control/_transition_style.html +0 -0
  87. {webatem-0.2.2 → webatem-0.2.3}/atem_control/templates/control/_usk_fly.html +0 -0
  88. {webatem-0.2.2 → webatem-0.2.3}/atem_control/templates/control/_usk_keyframe.html +0 -0
  89. {webatem-0.2.2 → webatem-0.2.3}/atem_control/templates/control/_usk_mask.html +0 -0
  90. {webatem-0.2.2 → webatem-0.2.3}/atem_control/templates/control/_usk_slider.html +0 -0
  91. {webatem-0.2.2 → webatem-0.2.3}/atem_control/templates/control.html +0 -0
  92. {webatem-0.2.2 → webatem-0.2.3}/atem_control/templatetags/__init__.py +0 -0
  93. {webatem-0.2.2 → webatem-0.2.3}/atem_control/templatetags/static_v.py +0 -0
  94. {webatem-0.2.2 → webatem-0.2.3}/atem_control/uploader.py +0 -0
  95. {webatem-0.2.2 → webatem-0.2.3}/atem_control/urls.py +0 -0
  96. {webatem-0.2.2 → webatem-0.2.3}/setup.cfg +0 -0
  97. {webatem-0.2.2 → webatem-0.2.3}/webatem/__init__.py +0 -0
  98. {webatem-0.2.2 → webatem-0.2.3}/webatem/__main__.py +0 -0
  99. {webatem-0.2.2 → webatem-0.2.3}/webatem/asgi.py +0 -0
  100. {webatem-0.2.2 → webatem-0.2.3}/webatem/context.py +0 -0
  101. {webatem-0.2.2 → webatem-0.2.3}/webatem/settings.py +0 -0
  102. {webatem-0.2.2 → webatem-0.2.3}/webatem/templates/base.html +0 -0
  103. {webatem-0.2.2 → webatem-0.2.3}/webatem/websocket.py +0 -0
  104. {webatem-0.2.2 → webatem-0.2.3}/webatem.egg-info/SOURCES.txt +0 -0
  105. {webatem-0.2.2 → webatem-0.2.3}/webatem.egg-info/dependency_links.txt +0 -0
  106. {webatem-0.2.2 → webatem-0.2.3}/webatem.egg-info/entry_points.txt +0 -0
  107. {webatem-0.2.2 → webatem-0.2.3}/webatem.egg-info/requires.txt +0 -0
  108. {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.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. It starts a local server, opens your browser, and lists the ATEMs
80
- on your network. Pick one and you're controlling it.
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>:8000` from any device on the network. It comes back
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:8000/atem/
135
- From another device: http://192.168.1.50:8000/atem/
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
- browser opens, the tray icon appears where there is a desktop. Prebuilt wheels
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, opens a browser and lives in the tray. The
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 + browser + tray
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. It starts a local server, opens your browser, and lists the ATEMs
42
- on your network. Pick one and you're controlling it.
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>:8000` from any device on the network. It comes back
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:8000/atem/
97
- From another device: http://192.168.1.50:8000/atem/
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
- browser opens, the tray icon appears where there is a desktop. Prebuilt wheels
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, opens a browser and lives in the tray. The
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 + browser + tray
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.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 small native window (pywebview, showing the
13
- app's own /launcher/ page — Running, the address, the interface and port,
14
- Start minimized, Run at login, Launch GUI / Hide / Quit) and a three-item
15
- menu bar / system tray icon (Show/Hide window, Launch GUI, Quit). Closing
16
- the window hides it; Quit is the way out. Where the window library is
17
- missing it is the tray alone (its menu then carries the address, Open in
18
- browser, Server settings… and Start at login); where no tray is possible
19
- either — a headless Linux server — it runs in the foreground as a plain
20
- process (Ctrl+C to quit).
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 8000), HOST (default 0.0.0.0 so
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 Server settings page asks (Companion's "run on interface
199
- # X, port Y"), the process — and the tray — staying up throughout.
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.ready = threading.Event() # set once the first server answers
208
- self.on_change = None # tray hook: refresh the menu text
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
- if self._came_up(server, worker):
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, and the start-at-login switch."""
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 tray (pystray). Runs on the main thread — macOS insists — with the
326
- # server supervised in threads; Quit stops the supervisor.
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
- icon.stop()
361
-
362
- menu = Menu(
363
- MenuItem(shown_address, None, enabled=False),
364
- MenuItem('Open in browser', lambda icon, item: _open_browser(local_url()), default=True),
365
- MenuItem('Server settings…', lambda icon, item: _open_browser(local_url().replace('/atem/', '/launcher/'))),
366
- MenuItem('Start at login', toggle_autostart, checked=lambda item: autostart_enabled()),
367
- Menu.SEPARATOR,
368
- MenuItem('Quit', quit_app),
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 watch_server():
374
- # If the server never comes up (port taken, say), do not sit in the
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 start on port {supervisor.port} (is it already running?)', APP_NAME)
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=watch_server, daemon=True).start()
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
- autostarted = '--autostart' in sys.argv[1:]
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
- want_window = want_tray and os.environ.get('WEBATEM_NO_WINDOW') != '1'
532
- if want_window:
533
- try:
534
- import webview # noqa: F401 — probe only
535
- except Exception as e: # noqa: BLE001
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
- lines = ['', f' {APP_NAME} is running.']
540
- if want_browser:
541
- lines.append(f' Opening {local_url} …')
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 the gear on the connect page.')
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(30) and _open_browser(local_url), daemon=True).start()
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 want_window:
558
- _run_window(supervisor, start_hidden=bool(cfg.get('start_minimized')) or autostarted)
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