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.
@@ -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 _normalize_local_url(url: str, host: Optional[str], port: int) -> str:
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
- module_path = Path(__file__).resolve()
85
- package_root = module_path.parent
86
- project_root = package_root.parent
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 _load_icon() -> Image.Image:
149
- """Load the tray icon image, falling back to the embedded pen artwork."""
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), (17, 24, 39, 255))
125
+ image.putpixel((63, 63), (59, 130, 246, 255))
169
126
  return image
170
127
 
171
128
 
172
- _EMBEDDED_ICON_BASE64 = (
173
- "iVBORw0KGgoAAAANSUhEUgAAAEAAAABACAYAAACqaXHeAAAAAXNSR0IArs4c6QAAAARnQU1BAACx"
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
- def _load_embedded_icon() -> Image.Image:
241
- """Decode and return the embedded Talks Reducer tray icon."""
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
- data = base64.b64decode(_EMBEDDED_ICON_BASE64)
244
- with Image.open(BytesIO(data)) as image:
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
- LOGGER.debug("Attempting to load tray icon image.")
152
+ class _HeadlessTrayBackend:
153
+ """Placeholder backend used when the tray icon is disabled."""
252
154
 
253
- for candidate in _iter_icon_candidates():
254
- LOGGER.debug("Checking icon candidate at %s", candidate)
255
- if candidate.exists():
256
- try:
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[pystray.Icon] = None
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 = build_interface()
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._ready_event.set()
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[pystray.Icon] = None,
360
- _item: Optional[pystray.MenuItem] = None,
243
+ _icon: Optional[Any] = None,
244
+ _item: Optional[Any] = None,
361
245
  ) -> None:
362
246
  url = self._resolve_url()
363
247
  if url:
364
- webbrowser.open(url)
365
- LOGGER.debug("Opened browser to %s", url)
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.debug("GUI process monitor exited with %s", exc)
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[pystray.Icon] = None,
396
- _item: Optional[pystray.MenuItem] = None,
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[pystray.Icon] = None,
430
- _item: Optional[pystray.MenuItem] = None,
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.stop()
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 run(self) -> None:
439
- """Start the server and block until the tray icon exits."""
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
- server_thread = threading.Thread(
442
- target=self._launch_server, name="talks-reducer-server", daemon=True
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
- server_thread.start()
340
+ self._startup_error = error
341
+ LOGGER.error("%s", error)
445
342
 
446
- if not self._ready_event.wait(timeout=30):
447
- raise RuntimeError(
448
- "Timed out while waiting for the Talks Reducer server to start."
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
- if self._open_browser_on_start:
452
- self._handle_open_webui()
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 = pystray.Menu(
471
- pystray.MenuItem(version_label, None, enabled=False),
472
- pystray.MenuItem(
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
- pystray.MenuItem("Open WebUI", self._handle_open_webui),
478
- pystray.MenuItem("Quit", self._handle_quit),
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 = pystray.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.visible = False
508
- with suppress(Exception):
509
- self._icon.stop()
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.debug("Error while terminating GUI process: %s", exc)
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
- if args.tray_mode != "headless" and PYSTRAY_IMPORT_ERROR is not None:
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