talks-reducer 0.7.1__py3-none-any.whl → 0.8.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.
- talks_reducer/__about__.py +1 -1
- talks_reducer/cli.py +225 -181
- talks_reducer/discovery.py +78 -22
- talks_reducer/gui/__init__.py +17 -1546
- talks_reducer/gui/__main__.py +1 -1
- talks_reducer/gui/app.py +1385 -0
- talks_reducer/gui/discovery.py +1 -1
- talks_reducer/gui/layout.py +18 -31
- talks_reducer/gui/progress.py +80 -0
- talks_reducer/gui/remote.py +11 -3
- talks_reducer/gui/startup.py +202 -0
- talks_reducer/icons.py +123 -0
- talks_reducer/pipeline.py +65 -31
- talks_reducer/server.py +111 -47
- talks_reducer/server_tray.py +192 -236
- talks_reducer/service_client.py +77 -14
- {talks_reducer-0.7.1.dist-info → talks_reducer-0.8.0.dist-info}/METADATA +24 -2
- talks_reducer-0.8.0.dist-info/RECORD +33 -0
- talks_reducer-0.7.1.dist-info/RECORD +0 -29
- {talks_reducer-0.7.1.dist-info → talks_reducer-0.8.0.dist-info}/WHEEL +0 -0
- {talks_reducer-0.7.1.dist-info → talks_reducer-0.8.0.dist-info}/entry_points.txt +0 -0
- {talks_reducer-0.7.1.dist-info → talks_reducer-0.8.0.dist-info}/licenses/LICENSE +0 -0
- {talks_reducer-0.7.1.dist-info → talks_reducer-0.8.0.dist-info}/top_level.txt +0 -0
talks_reducer/server_tray.py
CHANGED
@@ -4,7 +4,6 @@ from __future__ import annotations
|
|
4
4
|
|
5
5
|
import argparse
|
6
6
|
import atexit
|
7
|
-
import base64
|
8
7
|
import logging
|
9
8
|
import subprocess
|
10
9
|
import sys
|
@@ -12,14 +11,13 @@ import threading
|
|
12
11
|
import time
|
13
12
|
import webbrowser
|
14
13
|
from contextlib import suppress
|
15
|
-
from importlib import resources
|
16
|
-
from io import BytesIO
|
17
14
|
from pathlib import Path
|
18
|
-
from typing import Any, Iterator, Optional, Sequence
|
15
|
+
from typing import Any, Callable, Iterator, Optional, Sequence
|
19
16
|
from urllib.parse import urlsplit, urlunsplit
|
20
17
|
|
21
18
|
from PIL import Image
|
22
19
|
|
20
|
+
from .icons import iter_icon_candidates
|
23
21
|
from .server import build_interface
|
24
22
|
from .version_utils import resolve_version
|
25
23
|
|
@@ -51,9 +49,27 @@ def _guess_local_url(host: Optional[str], port: int) -> str:
|
|
51
49
|
return f"http://{hostname}:{port}/"
|
52
50
|
|
53
51
|
|
54
|
-
def
|
52
|
+
def _coerce_url(value: Optional[Any]) -> Optional[str]:
|
53
|
+
"""Convert an arbitrary URL-like object to a trimmed string if possible."""
|
54
|
+
|
55
|
+
if not value:
|
56
|
+
return None
|
57
|
+
|
58
|
+
try:
|
59
|
+
text = str(value)
|
60
|
+
except Exception: # pragma: no cover - defensive fallback
|
61
|
+
return None
|
62
|
+
|
63
|
+
stripped = text.strip()
|
64
|
+
return stripped or None
|
65
|
+
|
66
|
+
|
67
|
+
def _normalize_local_url(url: Optional[str], host: Optional[str], port: int) -> str:
|
55
68
|
"""Rewrite *url* when a wildcard host should map to the loopback address."""
|
56
69
|
|
70
|
+
if not url:
|
71
|
+
return _guess_local_url(host, port)
|
72
|
+
|
57
73
|
if host not in (None, "", "0.0.0.0"):
|
58
74
|
return url
|
59
75
|
|
@@ -78,208 +94,68 @@ def _normalize_local_url(url: str, host: Optional[str], port: int) -> str:
|
|
78
94
|
return url
|
79
95
|
|
80
96
|
|
97
|
+
if sys.platform.startswith("win"):
|
98
|
+
_TRAY_ICON_FILENAMES = ("icon.ico", "icon.png", "app.ico", "app.png", "app-256.png")
|
99
|
+
else:
|
100
|
+
_TRAY_ICON_FILENAMES = ("icon.png", "icon.ico", "app.png", "app.ico", "app-256.png")
|
101
|
+
_ICON_RELATIVE_PATHS = (
|
102
|
+
Path("talks_reducer") / "resources" / "icons",
|
103
|
+
Path("docs") / "assets",
|
104
|
+
)
|
105
|
+
|
106
|
+
|
81
107
|
def _iter_icon_candidates() -> Iterator[Path]:
|
82
108
|
"""Yield possible tray icon paths ordered from most to least specific."""
|
83
109
|
|
84
|
-
|
85
|
-
|
86
|
-
|
87
|
-
|
88
|
-
frozen_root: Optional[Path] = None
|
89
|
-
frozen_value = getattr(sys, "_MEIPASS", None)
|
90
|
-
if frozen_value:
|
91
|
-
with suppress(Exception):
|
92
|
-
frozen_root = Path(str(frozen_value)).resolve()
|
93
|
-
|
94
|
-
executable_root: Optional[Path] = None
|
95
|
-
with suppress(Exception):
|
96
|
-
executable_root = Path(sys.executable).resolve().parent
|
97
|
-
|
98
|
-
launcher_root: Optional[Path] = None
|
99
|
-
with suppress(Exception):
|
100
|
-
launcher_root = Path(sys.argv[0]).resolve().parent
|
101
|
-
|
102
|
-
base_roots: list[Path] = []
|
103
|
-
for candidate in (
|
104
|
-
package_root,
|
105
|
-
project_root,
|
106
|
-
frozen_root,
|
107
|
-
executable_root,
|
108
|
-
launcher_root,
|
109
|
-
):
|
110
|
-
if candidate and candidate not in base_roots:
|
111
|
-
base_roots.append(candidate)
|
112
|
-
|
113
|
-
expanded_roots: list[Path] = []
|
114
|
-
suffixes = (
|
115
|
-
Path(""),
|
116
|
-
Path("_internal"),
|
117
|
-
Path("Contents") / "Resources",
|
118
|
-
Path("Resources"),
|
119
|
-
)
|
120
|
-
for root in base_roots:
|
121
|
-
for suffix in suffixes:
|
122
|
-
candidate_root = (root / suffix).resolve()
|
123
|
-
if candidate_root not in expanded_roots:
|
124
|
-
expanded_roots.append(candidate_root)
|
125
|
-
|
126
|
-
icon_names = ("icon.ico", "icon.png") if sys.platform == "win32" else ("icon.png", "icon.ico")
|
127
|
-
relative_paths = (
|
128
|
-
Path("talks_reducer") / "resources" / "icons",
|
129
|
-
Path("talks_reducer") / "assets",
|
130
|
-
Path("docs") / "assets",
|
131
|
-
Path("assets"),
|
132
|
-
Path(""),
|
110
|
+
yield from iter_icon_candidates(
|
111
|
+
filenames=_TRAY_ICON_FILENAMES,
|
112
|
+
relative_paths=_ICON_RELATIVE_PATHS,
|
113
|
+
module_file=Path(__file__),
|
133
114
|
)
|
134
115
|
|
135
|
-
seen: set[Path] = set()
|
136
|
-
for root in expanded_roots:
|
137
|
-
if not root.exists():
|
138
|
-
continue
|
139
|
-
for relative in relative_paths:
|
140
|
-
for icon_name in icon_names:
|
141
|
-
candidate = (root / relative / icon_name).resolve()
|
142
|
-
if candidate in seen:
|
143
|
-
continue
|
144
|
-
seen.add(candidate)
|
145
|
-
yield candidate
|
146
|
-
|
147
116
|
|
148
|
-
def
|
149
|
-
"""
|
117
|
+
def _generate_fallback_icon() -> Image.Image:
|
118
|
+
"""Return a simple multi-color square used when packaged icons are missing."""
|
150
119
|
|
151
|
-
LOGGER.debug("Attempting to load tray icon image.")
|
152
|
-
|
153
|
-
for candidate in _iter_icon_candidates():
|
154
|
-
LOGGER.debug("Checking icon candidate at %s", candidate)
|
155
|
-
if candidate.exists():
|
156
|
-
try:
|
157
|
-
with Image.open(candidate) as image:
|
158
|
-
loaded = image.copy()
|
159
|
-
except Exception as exc: # pragma: no cover - diagnostic log
|
160
|
-
LOGGER.warning("Failed to load tray icon from %s: %s", candidate, exc)
|
161
|
-
else:
|
162
|
-
LOGGER.debug("Loaded tray icon from %s", candidate)
|
163
|
-
return loaded
|
164
|
-
|
165
|
-
LOGGER.warning("Falling back to generated tray icon; packaged image not found")
|
166
120
|
image = Image.new("RGBA", (64, 64), color=(37, 99, 235, 255))
|
121
|
+
for index in range(64):
|
122
|
+
image.putpixel((index, index), (17, 24, 39, 255))
|
123
|
+
image.putpixel((63 - index, index), (59, 130, 246, 255))
|
167
124
|
image.putpixel((0, 0), (255, 255, 255, 255))
|
168
|
-
image.putpixel((63, 63), (
|
125
|
+
image.putpixel((63, 63), (59, 130, 246, 255))
|
169
126
|
return image
|
170
127
|
|
171
128
|
|
172
|
-
|
173
|
-
"
|
174
|
-
"jwv8YQUAAAAJcEhZcwAADsIAAA7CARUoSoAAAA3MSURBVHhe5Zt7cB31dcc/Z/deyVfPK11JDsLW"
|
175
|
-
"xAEmMJDGk+mEFGza8oiZ8l+GSWiwMTBJQzDpA2xT4kcKpsWxoSl0SiCTEAPFDYWkEJtCeYSnG0oh"
|
176
|
-
"sQHbacCWbcmyZOlKV5L1vLt7+sdvf3d3rx6hnfwT6Tuzurvnd36Pc37nd37nd3YlzID65o+1OOn0"
|
177
|
-
"FSCXIXKeQJuq1pTz/RYggJYTZ8Fs/AIgIqOgHaq8B/p8UCzuHuzt7ilnxlaIo64+W+VU1a4Tx7lR"
|
178
|
-
"RFoiFgUx9xL2r/Hq4ZAibsMfqw2qhiMhgiJhu4qG3YQ9qGGSkF9j/YdFEcIyiOrFnntVg+/6Y6e2"
|
179
|
-
"DQ0MjMSLEgrILmz9pKRSO0Wcz5jCSNQ5AQ32Br6/stB9fL8llRRQv7D1HCeVfl7EWWQFNgqYI8Jb"
|
180
|
-
"qPYGXnFFoafrl1gF1DY21aUz1W8g8qnZFticgeoH/uT4Hwz29uQdgNSCzCZEPlXON2chcpaTrrwD"
|
181
|
-
"QLItpy1x0hX7xHFqAbTcgcwxxJz0WFAsLnXETX1BRGoteb5ARDLipq5yELkkuVfNbUQiCiJymSMi"
|
182
|
-
"54KAzjl//1Hwe46ii6AskJgnUKhzUJzygrkPG1GCMz9WfjlK8a3Ow9lPIlLAPLQDQCIFzD8fCAkL"
|
183
|
-
"mFcozXbSByjQ32mugYm5vDOW1rs4Vh0aKANdynVXCbetcThrkZLvUIK57RtUGlrbAnFd6e8IeOD2"
|
184
|
-
"FKsvqqTSFY4MeDz0UpE77/PBgVyrSY4oIPrRfEagSuH40RglRdPixQT/hwOXAPlhYLC85P+BZshV"
|
185
|
-
"hjKIEAQB0tDaFkyqKyNVAe3fyfDxOgd8wAVP4M3DRbb9uMiu3YrbDNlKCFSSy2MahfiBMth1lNu3"
|
186
|
-
"bOFz55/PyMgIj//rEzz+LzvJLV7ykU6drkBvN3zpIuGK3xcy6WBKtisOEUEVRMKUmU3JCRRGhEde"
|
187
|
-
"DXj9V9DUCEqkAB3zXSqaA97fmmFxjYv6CmHqjjQUxpWn3y5y7Q+K8CFkW8FxZjeB/s4j7Hj4YVat"
|
188
|
-
"WoUTaqswOMg99/w9d2654zcqwRHoy8NNK4QtqzNks6lo6QrJvJ/YSSgbU2liDEPHiUn+8sFxfrIP"
|
189
|
-
"cjVGAaETVPzA+AHbiW1TJyGbElYvq+TDuzN88xsOhS6l/4SajKFdFzEURsc545zzuHzFipLwANn6"
|
190
|
-
"ev761vVs3LSZfEd7oqwcgQKjyp/+cZpsQxr1FQ3HGN2Hl29+sWW+hrTk8+LWCq6/LAUDkf4cAClN"
|
191
|
-
"t4FVcImuQBHOyDr8zZUZXv1BJVd8Xsh3wtCETlF8UCzS1JiloqIiWQBUV1dz6/p13HzLWvo62n+D"
|
192
|
-
"JQmZSjuAj4AEW7KO7cW0FxFKZwHDbqi2qk1Tq4b3gZD2hIvOTPPoX2XYcVcKLwv5TjMjFvW11fzX"
|
193
|
-
"njc4fLg9IsZQU1PDxg3fZNU1q+k7Nr0SLOX1d4toMUBcQVwQR2KX2cfshVtGiz2TgsnxgBf3Gqdu"
|
194
|
-
"23cztfXf8lREquCmS9PUVzpGAyLGNsRah0SqCiDjCkuXpLh6mUtVLuDFnyljo1BVY+qMDRUYHhll"
|
195
|
-
"+bJlVFdXlwSzyGQynH/+Z/ng0GH2vvUm1dmGKQ6ushaeelNpqfapSgUMDHnkC2XXgE9+0Cdf8MkP"
|
196
|
-
"euQLPv3hc1/BPnt0nvR45MVJbn8kINdq5FNVJNvaFoz7jrg55cDWDG21rvEFYpIk0RqIz1LMXlJQ"
|
197
|
-
"BPZ8WGT7k0X+/Vkl1QwNGYfeY+2svGY1d2/fxsKWllj9CMeOHeOGG9fw7DO7aVq8ZMoWGSgUjjPF"
|
198
|
-
"pGdFfNUkVpCQM9kPsNvgjAqYUttsKeYpPhhBRCEF/ePKLrtbHILcIod8ZzurVl/L9m3fnlEJ7e3t"
|
199
|
-
"rLr2eva89kpJCdavWH14Cv4MOnDEKCo+TWp28lI7TshnYeOAcAk44lYpa2JLoOQAw0pJR2nM3FyG"
|
200
|
-
"oj5UucLSj7usXJ6iuinghZd8qGvk3bdeoftkL8suvGDa5dDQ0MCyCy/gtT0/p/1/DlBd32DaDIUa"
|
201
|
-
"6ISJYaU4zLTX5DQ0S5+YEGrDlG8cJmZQlWzr4mDcdyXdpLx/l7EAmx+0b4aMjFbS6W5DZ2mfXSgK"
|
202
|
-
"7PnAY9OOSd54T2DQWMLd27fR0txsx5HAgYMH+dKXV/L+3n00LW7DV2WgD+5a6XDZZ1wq09YijCkI"
|
203
|
-
"gtqNSs07RvuLwNCY8MRrHv/wZEDTonBrDSEIgfo2EHJIN4cKqDEKMN1Ys7d34fYYM6U4bGAj9k+F"
|
204
|
-
"sK/TY+nN49TiMNzVzvVf+Qrbtm4ll8uVVwdg//79fOGqq2k/0U0xn+HbX3O55coMbrr84BqXJu6v"
|
205
|
-
"YnDg1LDPhodGuW+30hjTu10CpVYdjU+pifvjwmu4HRIzzSSmUphUzlnocs2FDsMnlKa2T/DQ97/P"
|
206
|
-
"bRs2UChMH9yfe+65/Nl1qynmewDl4k+7uGkHDZ2AerFAxwuDIC8wv6Wgx1wUlZoalys+68LENOOz"
|
207
|
-
"gZACKRVzyFE7k/bNsFFCuXKtyVv+uPO2dIAJT+kfVqgKNQdkFmSmthdDKp0q3Re9qOHEBhENLjnz"
|
208
|
-
"oQzm1tDHi2U8MTix5qcecEqXjQHKoDrz22PHnCOee89j93NKrkHo62jn5rVr2WLH7WTr68trAHC4"
|
209
|
-
"vZ0dj+6E+maoEJ76T49Tw54Jglwb3IQBjpsMhMSRMGAKeVzoOenx2M98yJb3ZCDZ0AfUNSm/uCvD"
|
210
|
-
"ongcENOb2iURyitmfcRKwnsxscGJ4YB/frXI+ns86hc6DHa1c8vadWzatJH6urqQP4kjR4/y9TU3"
|
211
|
-
"8dwzu2luM4elvm746ueFS5c6VKQil5wwM2KOJ7ReERiZhCf3+Dz139DUFDlBK1mgJg7QMd+hvkl5"
|
212
|
-
"J6GAqP34FphQQJyA8f5jvvLCAY9NjxZ59y2l4XSHgePt3LJuHZs3bqRuBuGPHj3GV792Ay/8x7M0"
|
213
|
-
"tS0hCEcrQL4AnCIpcKk0jtI0ReUN0FST3AEgcoKSbW3TUd8h26S8szXDohq37Jha3okR2n7WUpp1"
|
214
|
-
"F97v9vmnXZM88GgAdUJzvdDb0c7adevZtHHDjMIf6+jghq+v4dlndpUCIRvcYKPByWnkp0zeEAvS"
|
215
|
-
"UOUmaeWIKyAY8x2ZsgRKnOGvxgQO6YIx997RgCd+XmTN/R70Co2LwBGz5tetv5VNGzdQO100AnR3"
|
216
|
-
"d3PjTd/g33785JRQ2BHomwR64IKlSk2FEtgNOWaKcatU4KVjgg5Ac8vM0aOJA2IKqG1SfmktILCC"
|
217
|
-
"x2rHFCCYGZ8EXv/A486dE7zyMmQWCpm04ervPMJNf/4X/O2WO2ac+ZMnT3Lz2nU89ugjCbO3GPTg"
|
218
|
-
"EzXw4A1plp6ZxgkFjH+4k7BPMX/6BpV7nxrnH3crueYpBgJJC1isY75LTU7ZuzVuAWaKBbPNRekm"
|
219
|
-
"M+sf5n2+99wk2x/0YQHkmqJtc6LoMdLTycGDv+Lssz9Z1rVBPp9n7br17PjhQ9MKT5ihfnxzii9e"
|
220
|
-
"XAWx4/a0a93+qDkWd5wosmLzKAcHoTGclDisBTgmxSmJg0KpQZsHCK1B0krBC3h4zwRnrR1j+4M+"
|
221
|
-
"DadDYy4yQ4CRoRGW/9HFnHbaxyJiDP39/WzYtHlW4Q1JOXuxOZtoEMsI2Webq1A1fstmtXxlYYPL"
|
222
|
-
"5860znMahCKWIsGE/Gh0qdljPQfeOORxzb1jXHtbEXcIcotKAXKiAbeqko6uE4yOjUXEEEb4TTz4"
|
223
|
-
"3ftnFJ5wPYPQ1RfmJ0uXIPYwFvqD0l1YjgPDoz4f9ACZ8paTcAQlUKXSSR4XCQchFXB0KGDLT8ZZ"
|
224
|
-
"ft0Eu55VcouE+srQ5EWSH0wC9ZkFHPn1QZ5++mk8zyvRe3t72bj5Wzxw//3G4c0gPHZCcvCdp4oc"
|
225
|
-
"PDTBZFHxvGCaK0YvKr6v9Pf77Hh+nDfegcYF5S0nIdnWNi2qw0ha+fC+DGdkXWN/Lpzy4Ll3i6zd"
|
226
|
-
"McnRfbFssF2CFmVLkdBvDBw/ym0bNvCHy5fTPzDAYzt/xDO7np515uNwBPpOQbYavrhUqF1gT6kR"
|
227
|
-
"yleuIvyiHV5+W8mVvnicikQc4Lou+Y6Av7vF5YbLF5BJCQe6PO796SSPPB5AA+Sqky9GSsdQylZA"
|
228
|
-
"QgkwcPxIRHCqaTq9ZUrWZzYIcMqHiUGTmJ0VttnsR5j5eBzguq4ISl+HcsmlQnOD8KOXFfqYVYu/"
|
229
|
-
"yzAK8CMFEJpt/xAwArUtUOHOTeGJWUBpF7Dhb2MdNJ4G6bksfOw+2gbLdoC5jGhi5+UXYnFIbAmU"
|
230
|
-
"7eXzBeWxz7xD6QuR+aSIhBMUkcnY8zyD4iAcM1vgXN30ZsWgg2r4D0TzaBFECd/3HFV9IaLPIyWY"
|
231
|
-
"4O9lRz3vp6hO/5pmDsJOsqoW1fefcAonT3SoBg9ZHzCnraAkmqJB8Hihp+s9B8AfH9uigf46wTwn"
|
232
|
-
"Ec5+oN3qFTdgzwJD/X0Dge9frar9EV/4eczvOkrfMYRvulXH1PdXFk6eOEb8MFTo7nxbPe9yDYJD"
|
233
|
-
"iS9BbTLOPMxymfIoUxc+xzqfymOo09RKtBmrHD7GCYYo4Thtf8lxm11eg6Az8Ip/MtDd+ZIlJ96f"
|
234
|
-
"jJ8a6qqoXLBTHLca4TxESgll03Akb7leTKchzSYvEwMPRUzwmMpRu3bwlseUSVxAq5C4kJYU664E"
|
235
|
-
"c8yf0CB4WIuTXy6cPPF+vHjaOgD1Laed6abSVyJyCfDpQLW57B2xTB89WXI4qhJCmrUutbRpEG/Z"
|
236
|
-
"tjEDa4ktORoRkR5F96P6ivr+k4WeroOl0hj+F2nUsotZ+OvIAAAAAElFTkSuQmCC"
|
237
|
-
)
|
129
|
+
def _load_icon() -> Image.Image:
|
130
|
+
"""Load the tray icon image, falling back to a generated placeholder."""
|
238
131
|
|
132
|
+
LOGGER.info("Attempting to load tray icon image.")
|
239
133
|
|
240
|
-
|
241
|
-
|
134
|
+
for candidate in _iter_icon_candidates():
|
135
|
+
LOGGER.info("Checking icon candidate at %s", candidate)
|
136
|
+
if not candidate.exists():
|
137
|
+
continue
|
138
|
+
try:
|
139
|
+
with Image.open(candidate) as image:
|
140
|
+
loaded = image.copy()
|
141
|
+
except Exception as exc: # pragma: no cover - diagnostic log
|
142
|
+
LOGGER.warning("Failed to load tray icon from %s: %s", candidate, exc)
|
143
|
+
continue
|
242
144
|
|
243
|
-
|
244
|
-
|
245
|
-
return image.copy()
|
145
|
+
LOGGER.info("Loaded tray icon from %s", candidate)
|
146
|
+
return loaded
|
246
147
|
|
148
|
+
LOGGER.warning("Falling back to generated tray icon; packaged image not found")
|
149
|
+
return _generate_fallback_icon()
|
247
150
|
|
248
|
-
def _load_icon() -> Image.Image:
|
249
|
-
"""Load the tray icon image, falling back to the embedded pen artwork."""
|
250
151
|
|
251
|
-
|
152
|
+
class _HeadlessTrayBackend:
|
153
|
+
"""Placeholder backend used when the tray icon is disabled."""
|
252
154
|
|
253
|
-
|
254
|
-
|
255
|
-
|
256
|
-
|
257
|
-
with Image.open(candidate) as image:
|
258
|
-
loaded = image.copy()
|
259
|
-
except Exception as exc: # pragma: no cover - diagnostic log
|
260
|
-
LOGGER.warning("Failed to load tray icon from %s: %s", candidate, exc)
|
261
|
-
else:
|
262
|
-
LOGGER.debug("Loaded tray icon from %s", candidate)
|
263
|
-
return loaded
|
264
|
-
|
265
|
-
with suppress(FileNotFoundError):
|
266
|
-
resource_icon = resources.files("talks_reducer") / "assets" / "icon.png"
|
267
|
-
if resource_icon.is_file():
|
268
|
-
LOGGER.debug("Loading tray icon from package resources")
|
269
|
-
with resource_icon.open("rb") as handle:
|
270
|
-
try:
|
271
|
-
with Image.open(handle) as image:
|
272
|
-
return image.copy()
|
273
|
-
except Exception as exc: # pragma: no cover - diagnostic log
|
274
|
-
LOGGER.warning(
|
275
|
-
"Failed to load tray icon from package resources: %s", exc
|
276
|
-
)
|
277
|
-
|
278
|
-
LOGGER.warning("Falling back to generated tray icon; packaged image not found")
|
279
|
-
image = Image.new("RGBA", (64, 64), color=(37, 99, 235, 255))
|
280
|
-
image.putpixel((0, 0), (255, 255, 255, 255))
|
281
|
-
image.putpixel((63, 63), (17, 24, 39, 255))
|
282
|
-
return image
|
155
|
+
def __getattr__(self, name: str) -> Any:
|
156
|
+
raise RuntimeError(
|
157
|
+
"System tray backend is unavailable when running in headless mode."
|
158
|
+
)
|
283
159
|
|
284
160
|
|
285
161
|
class _ServerTrayApplication:
|
@@ -293,22 +169,30 @@ class _ServerTrayApplication:
|
|
293
169
|
share: bool,
|
294
170
|
open_browser: bool,
|
295
171
|
tray_mode: str,
|
172
|
+
tray_backend: Any,
|
173
|
+
build_interface: Callable[[], Any],
|
174
|
+
open_browser_callback: Callable[[str], Any],
|
296
175
|
) -> None:
|
297
176
|
self._host = host
|
298
177
|
self._port = port
|
299
178
|
self._share = share
|
300
179
|
self._open_browser_on_start = open_browser
|
301
180
|
self._tray_mode = tray_mode
|
181
|
+
self._tray_backend = tray_backend
|
182
|
+
self._build_interface = build_interface
|
183
|
+
self._open_browser = open_browser_callback
|
302
184
|
|
303
185
|
self._stop_event = threading.Event()
|
186
|
+
self._server_ready_event = threading.Event()
|
304
187
|
self._ready_event = threading.Event()
|
305
188
|
self._gui_lock = threading.Lock()
|
306
189
|
|
307
190
|
self._server_handle: Optional[Any] = None
|
308
191
|
self._local_url: Optional[str] = None
|
309
192
|
self._share_url: Optional[str] = None
|
310
|
-
self._icon: Optional[
|
193
|
+
self._icon: Optional[Any] = None
|
311
194
|
self._gui_process: Optional[subprocess.Popen[Any]] = None
|
195
|
+
self._startup_error: Optional[BaseException] = None
|
312
196
|
|
313
197
|
# Server lifecycle -------------------------------------------------
|
314
198
|
|
@@ -321,7 +205,7 @@ class _ServerTrayApplication:
|
|
321
205
|
self._port,
|
322
206
|
self._share,
|
323
207
|
)
|
324
|
-
demo =
|
208
|
+
demo = self._build_interface()
|
325
209
|
server = demo.launch(
|
326
210
|
server_name=self._host,
|
327
211
|
server_port=self._port,
|
@@ -333,15 +217,15 @@ class _ServerTrayApplication:
|
|
333
217
|
|
334
218
|
self._server_handle = server
|
335
219
|
fallback_url = _guess_local_url(self._host, self._port)
|
336
|
-
local_url = getattr(server, "local_url", fallback_url)
|
220
|
+
local_url = _coerce_url(getattr(server, "local_url", fallback_url))
|
337
221
|
self._local_url = _normalize_local_url(local_url, self._host, self._port)
|
338
|
-
self._share_url = getattr(server, "share_url", None)
|
339
|
-
self.
|
222
|
+
self._share_url = _coerce_url(getattr(server, "share_url", None))
|
223
|
+
self._server_ready_event.set()
|
340
224
|
LOGGER.info("Server ready at %s", self._local_url)
|
341
225
|
|
342
226
|
# Keep checking for a share URL while the server is running.
|
343
227
|
while not self._stop_event.is_set():
|
344
|
-
share_url = getattr(server, "share_url", None)
|
228
|
+
share_url = _coerce_url(getattr(server, "share_url", None))
|
345
229
|
if share_url:
|
346
230
|
self._share_url = share_url
|
347
231
|
LOGGER.info("Share URL available: %s", share_url)
|
@@ -356,13 +240,13 @@ class _ServerTrayApplication:
|
|
356
240
|
|
357
241
|
def _handle_open_webui(
|
358
242
|
self,
|
359
|
-
_icon: Optional[
|
360
|
-
_item: Optional[
|
243
|
+
_icon: Optional[Any] = None,
|
244
|
+
_item: Optional[Any] = None,
|
361
245
|
) -> None:
|
362
246
|
url = self._resolve_url()
|
363
247
|
if url:
|
364
|
-
|
365
|
-
LOGGER.
|
248
|
+
self._open_browser(url)
|
249
|
+
LOGGER.info("Opened browser to %s", url)
|
366
250
|
else:
|
367
251
|
LOGGER.warning("Server URL not yet available; please try again.")
|
368
252
|
|
@@ -383,7 +267,7 @@ class _ServerTrayApplication:
|
|
383
267
|
try:
|
384
268
|
process.wait()
|
385
269
|
except Exception as exc: # pragma: no cover - best-effort cleanup
|
386
|
-
LOGGER.
|
270
|
+
LOGGER.info("GUI process monitor exited with %s", exc)
|
387
271
|
finally:
|
388
272
|
with self._gui_lock:
|
389
273
|
if self._gui_process is process:
|
@@ -392,8 +276,8 @@ class _ServerTrayApplication:
|
|
392
276
|
|
393
277
|
def _launch_gui(
|
394
278
|
self,
|
395
|
-
_icon: Optional[
|
396
|
-
_item: Optional[
|
279
|
+
_icon: Optional[Any] = None,
|
280
|
+
_item: Optional[Any] = None,
|
397
281
|
) -> None:
|
398
282
|
"""Launch the Talks Reducer GUI in a background subprocess."""
|
399
283
|
|
@@ -406,9 +290,7 @@ class _ServerTrayApplication:
|
|
406
290
|
|
407
291
|
try:
|
408
292
|
LOGGER.info("Launching Talks Reducer GUI via %s", sys.executable)
|
409
|
-
process = subprocess.Popen(
|
410
|
-
[sys.executable, "-m", "talks_reducer.gui"]
|
411
|
-
)
|
293
|
+
process = subprocess.Popen([sys.executable, "-m", "talks_reducer.gui"])
|
412
294
|
except Exception as exc: # pragma: no cover - platform specific
|
413
295
|
LOGGER.error("Failed to launch Talks Reducer GUI: %s", exc)
|
414
296
|
self._gui_process = None
|
@@ -426,35 +308,62 @@ class _ServerTrayApplication:
|
|
426
308
|
|
427
309
|
def _handle_quit(
|
428
310
|
self,
|
429
|
-
icon: Optional[
|
430
|
-
_item: Optional[
|
311
|
+
icon: Optional[Any] = None,
|
312
|
+
_item: Optional[Any] = None,
|
431
313
|
) -> None:
|
432
314
|
self.stop()
|
433
315
|
if icon is not None:
|
434
|
-
icon
|
316
|
+
stop_method = getattr(icon, "stop", None)
|
317
|
+
if callable(stop_method):
|
318
|
+
with suppress(Exception):
|
319
|
+
stop_method()
|
435
320
|
|
436
321
|
# Public API -------------------------------------------------------
|
437
322
|
|
438
|
-
def
|
439
|
-
"""
|
323
|
+
def _await_server_start(self, icon: Optional[Any]) -> None:
|
324
|
+
"""Wait for the server to signal readiness or trigger shutdown on failure."""
|
440
325
|
|
441
|
-
|
442
|
-
|
326
|
+
if self._server_ready_event.wait(timeout=30):
|
327
|
+
try:
|
328
|
+
if self._open_browser_on_start and not self._stop_event.is_set():
|
329
|
+
self._handle_open_webui()
|
330
|
+
finally:
|
331
|
+
self._ready_event.set()
|
332
|
+
return
|
333
|
+
|
334
|
+
if self._stop_event.is_set():
|
335
|
+
return
|
336
|
+
|
337
|
+
error = RuntimeError(
|
338
|
+
"Timed out while waiting for the Talks Reducer server to start."
|
443
339
|
)
|
444
|
-
|
340
|
+
self._startup_error = error
|
341
|
+
LOGGER.error("%s", error)
|
445
342
|
|
446
|
-
if not
|
447
|
-
|
448
|
-
|
449
|
-
|
343
|
+
if icon is not None:
|
344
|
+
notify = getattr(icon, "notify", None)
|
345
|
+
if callable(notify):
|
346
|
+
with suppress(Exception):
|
347
|
+
notify("Talks Reducer server failed to start.")
|
348
|
+
|
349
|
+
self.stop()
|
350
|
+
|
351
|
+
def run(self) -> None:
|
352
|
+
"""Start the server and block until the tray icon exits."""
|
353
|
+
|
354
|
+
self._startup_error = None
|
450
355
|
|
451
|
-
|
452
|
-
self.
|
356
|
+
threading.Thread(
|
357
|
+
target=self._launch_server, name="talks-reducer-server", daemon=True
|
358
|
+
).start()
|
453
359
|
|
454
360
|
if self._tray_mode == "headless":
|
455
361
|
LOGGER.warning(
|
456
362
|
"Tray icon disabled (tray_mode=headless); press Ctrl+C to stop the server."
|
457
363
|
)
|
364
|
+
self._await_server_start(None)
|
365
|
+
if self._startup_error is not None:
|
366
|
+
raise self._startup_error
|
458
367
|
try:
|
459
368
|
while not self._stop_event.wait(0.5):
|
460
369
|
pass
|
@@ -467,23 +376,31 @@ class _ServerTrayApplication:
|
|
467
376
|
f" v{APP_VERSION}" if APP_VERSION and APP_VERSION != "unknown" else ""
|
468
377
|
)
|
469
378
|
version_label = f"Talks Reducer{version_suffix}"
|
470
|
-
menu =
|
471
|
-
|
472
|
-
|
379
|
+
menu = self._tray_backend.Menu(
|
380
|
+
self._tray_backend.MenuItem(version_label, None, enabled=False),
|
381
|
+
self._tray_backend.MenuItem(
|
473
382
|
"Open GUI",
|
474
383
|
self._launch_gui,
|
475
384
|
default=True,
|
476
385
|
),
|
477
|
-
|
478
|
-
|
386
|
+
self._tray_backend.MenuItem("Open WebUI", self._handle_open_webui),
|
387
|
+
self._tray_backend.MenuItem("Quit", self._handle_quit),
|
479
388
|
)
|
480
|
-
self._icon =
|
389
|
+
self._icon = self._tray_backend.Icon(
|
481
390
|
"talks-reducer",
|
482
391
|
icon_image,
|
483
392
|
f"{version_label} Server",
|
484
393
|
menu=menu,
|
485
394
|
)
|
486
395
|
|
396
|
+
watcher = threading.Thread(
|
397
|
+
target=self._await_server_start,
|
398
|
+
args=(self._icon,),
|
399
|
+
name="talks-reducer-server-watcher",
|
400
|
+
daemon=True,
|
401
|
+
)
|
402
|
+
watcher.start()
|
403
|
+
|
487
404
|
if self._tray_mode == "pystray-detached":
|
488
405
|
LOGGER.info("Running tray icon in detached mode")
|
489
406
|
self._icon.run_detached()
|
@@ -492,21 +409,30 @@ class _ServerTrayApplication:
|
|
492
409
|
pass
|
493
410
|
finally:
|
494
411
|
self.stop()
|
412
|
+
if self._startup_error is not None:
|
413
|
+
raise self._startup_error
|
495
414
|
return
|
496
415
|
|
497
416
|
LOGGER.info("Running tray icon in blocking mode")
|
498
417
|
self._icon.run()
|
418
|
+
if self._startup_error is not None:
|
419
|
+
raise self._startup_error
|
499
420
|
|
500
421
|
def stop(self) -> None:
|
501
422
|
"""Stop the tray icon and shut down the Gradio server."""
|
502
423
|
|
503
424
|
self._stop_event.set()
|
425
|
+
self._server_ready_event.set()
|
426
|
+
self._ready_event.set()
|
504
427
|
|
505
428
|
if self._icon is not None:
|
506
429
|
with suppress(Exception):
|
507
|
-
self._icon
|
508
|
-
|
509
|
-
|
430
|
+
if hasattr(self._icon, "visible"):
|
431
|
+
self._icon.visible = False
|
432
|
+
stop_method = getattr(self._icon, "stop", None)
|
433
|
+
if callable(stop_method):
|
434
|
+
with suppress(Exception):
|
435
|
+
stop_method()
|
510
436
|
|
511
437
|
self._stop_gui()
|
512
438
|
|
@@ -535,11 +461,47 @@ class _ServerTrayApplication:
|
|
535
461
|
process.kill()
|
536
462
|
process.wait(timeout=5)
|
537
463
|
except Exception as exc: # pragma: no cover - defensive cleanup
|
538
|
-
LOGGER.
|
464
|
+
LOGGER.info("Error while terminating GUI process: %s", exc)
|
539
465
|
|
540
466
|
self._gui_process = None
|
541
467
|
|
542
468
|
|
469
|
+
def create_tray_app(
|
470
|
+
*,
|
471
|
+
host: Optional[str],
|
472
|
+
port: int,
|
473
|
+
share: bool,
|
474
|
+
open_browser: bool,
|
475
|
+
tray_mode: str,
|
476
|
+
) -> _ServerTrayApplication:
|
477
|
+
"""Build a :class:`_ServerTrayApplication` wired to production dependencies."""
|
478
|
+
|
479
|
+
if tray_mode != "headless" and (
|
480
|
+
pystray is None or PYSTRAY_IMPORT_ERROR is not None
|
481
|
+
):
|
482
|
+
raise RuntimeError(
|
483
|
+
"System tray mode requires the 'pystray' dependency. Install it with "
|
484
|
+
"`pip install pystray` or `pip install talks-reducer[dev]` and try again."
|
485
|
+
) from PYSTRAY_IMPORT_ERROR
|
486
|
+
|
487
|
+
tray_backend: Any
|
488
|
+
if pystray is None:
|
489
|
+
tray_backend = _HeadlessTrayBackend()
|
490
|
+
else:
|
491
|
+
tray_backend = pystray
|
492
|
+
|
493
|
+
return _ServerTrayApplication(
|
494
|
+
host=host,
|
495
|
+
port=port,
|
496
|
+
share=share,
|
497
|
+
open_browser=open_browser,
|
498
|
+
tray_mode=tray_mode,
|
499
|
+
tray_backend=tray_backend,
|
500
|
+
build_interface=build_interface,
|
501
|
+
open_browser_callback=webbrowser.open,
|
502
|
+
)
|
503
|
+
|
504
|
+
|
543
505
|
def main(argv: Optional[Sequence[str]] = None) -> None:
|
544
506
|
"""Launch the Gradio server with a companion system tray icon."""
|
545
507
|
|
@@ -598,13 +560,7 @@ def main(argv: Optional[Sequence[str]] = None) -> None:
|
|
598
560
|
datefmt="%H:%M:%S",
|
599
561
|
)
|
600
562
|
|
601
|
-
|
602
|
-
raise RuntimeError(
|
603
|
-
"System tray mode requires the 'pystray' dependency. Install it with "
|
604
|
-
"`pip install pystray` or `pip install talks-reducer[dev]` and try again."
|
605
|
-
) from PYSTRAY_IMPORT_ERROR
|
606
|
-
|
607
|
-
app = _ServerTrayApplication(
|
563
|
+
app = create_tray_app(
|
608
564
|
host=args.host,
|
609
565
|
port=args.port,
|
610
566
|
share=args.share,
|
@@ -620,7 +576,7 @@ def main(argv: Optional[Sequence[str]] = None) -> None:
|
|
620
576
|
app.stop()
|
621
577
|
|
622
578
|
|
623
|
-
__all__ = ["main"]
|
579
|
+
__all__ = ["create_tray_app", "main"]
|
624
580
|
|
625
581
|
|
626
582
|
if __name__ == "__main__": # pragma: no cover - convenience entry point
|