@allansantos-dev/smart-tool 0.9.43 → 0.9.46

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.
package/tray.py CHANGED
@@ -22,6 +22,7 @@ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
22
22
  import autostart
23
23
  import config
24
24
  import daemon_launcher
25
+ import i18n
25
26
  import setup_audit
26
27
  import toast_notice
27
28
  import update_check
@@ -60,14 +61,14 @@ _ICONS = {
60
61
  }
61
62
 
62
63
  _TITLES = {
63
- "ok": "Smart Tool - daemon running",
64
- "starting": "Smart Tool - starting the daemon...",
65
- "needs_key": "Smart Tool - model gateway unavailable",
66
- "needs_models": "Smart Tool - choose the models in settings",
67
- "catalog_unavailable": "Smart Tool - model catalog unavailable",
68
- "config_corrupt": "Smart Tool - settings file has an error",
69
- "status_error": "Smart Tool - failed to read the settings",
70
- "down": "Smart Tool - daemon down",
64
+ "ok": i18n.N_("Smart Tool - running"),
65
+ "starting": i18n.N_("Smart Tool - starting..."),
66
+ "needs_key": i18n.N_("Smart Tool - model gateway unavailable"),
67
+ "needs_models": i18n.N_("Smart Tool - choose the models in settings"),
68
+ "catalog_unavailable": i18n.N_("Smart Tool - model catalog unavailable"),
69
+ "config_corrupt": i18n.N_("Smart Tool - settings file has an error"),
70
+ "status_error": i18n.N_("Smart Tool - failed to read the settings"),
71
+ "down": i18n.N_("Smart Tool - stopped"),
71
72
  }
72
73
 
73
74
 
@@ -78,7 +79,30 @@ def _set_state(health, icon=None):
78
79
  _state["health"] = health
79
80
  if icon is not None:
80
81
  icon.icon = _ICONS[health]
81
- icon.title = _TITLES[health]
82
+ icon.title = i18n.t(_TITLES[health])
83
+
84
+
85
+ def _notify(icon, text, **values):
86
+ """Windows notification, translated (text is an English catalog key). A toast under Smart Tool's own app id,
87
+ since the tray balloon is shown as "Python"; when Windows refuses the toast, the balloon carries the text and the
88
+ refusal."""
89
+ message = i18n.t(text, **values)
90
+ try:
91
+ toast_notice.show("Smart Tool", [message], [])
92
+ except Exception as exc:
93
+ icon.notify(f"{message} ({exc})", "Smart Tool")
94
+
95
+
96
+ def _follow_language(icon):
97
+ """Redraws the menu and the tooltip after the language changed here or in a settings page."""
98
+ try:
99
+ current = i18n.language()
100
+ except Exception:
101
+ return
102
+ if _state.get("language") != current:
103
+ _state["language"] = current
104
+ icon.title = i18n.t(_TITLES[_state["health"]])
105
+ icon.update_menu()
82
106
 
83
107
 
84
108
  def _configuration_state(url):
@@ -111,6 +135,7 @@ def _poll_loop(icon):
111
135
  _set_state("status_error", icon)
112
136
  except Exception:
113
137
  _set_state("down", icon)
138
+ _follow_language(icon)
114
139
  time.sleep(POLL_INTERVAL_S)
115
140
 
116
141
 
@@ -121,19 +146,22 @@ def _show_notices(icon, notices):
121
146
  if offer:
122
147
  lines = [f"{role}: {change['current']} -> {change['new']}" for role, change in offer["changes"].items()]
123
148
  nonce = offer["nonce"]
124
- toast_notice.show("Smart Tool has new recommended models", lines, [
125
- ("Apply now", toast_notice.action_uri("models-apply", n=nonce)),
126
- ("Don't show again", toast_notice.action_uri("models-decline", n=nonce)),
149
+ toast_notice.show(i18n.t("Smart Tool has new recommended models"), lines, [
150
+ (i18n.t("Apply now"), toast_notice.action_uri("models-apply", n=nonce)),
151
+ (i18n.t("Don't show again"), toast_notice.action_uri("models-decline", n=nonce)),
127
152
  ], tag="models-upgrade")
128
153
  for row in notices.get("failing") or []:
129
- last = row.get("last_error") or "see the failure log in the settings"
154
+ last = row.get("last_error") or i18n.t("see the failure log in the settings")
130
155
  toast_notice.show(
131
- f"{row['model']} is failing", [f"{row['errors']} of {row['calls']} calls failed in 24 h as {row['role']}.", last[:160]],
132
- [("Switch model", toast_notice.action_uri("open-setup", section="models")),
133
- ("Mute 7 days", toast_notice.action_uri("mute-model", model=row["model"]))], tag="model-failing:" + row["model"])
156
+ i18n.t("{model} is failing", model=row["model"]),
157
+ [i18n.t("{errors} of {calls} calls failed in 24 h as {role}.", errors=row["errors"], calls=row["calls"],
158
+ role=row["role"]), last[:160]],
159
+ [(i18n.t("Switch model"), toast_notice.action_uri("open-setup", section="models")),
160
+ (i18n.t("Mute 7 days"), toast_notice.action_uri("mute-model", model=row["model"]))],
161
+ tag="model-failing:" + row["model"])
134
162
  for row in notices.get("audit") or []:
135
163
  title, lines = setup_audit.toast_text(row)
136
- toast_notice.show(title, lines, [("Open the audit", toast_notice.action_uri("open-setup", section="audit"))],
164
+ toast_notice.show(title, lines, [(i18n.t("Open the audit"), toast_notice.action_uri("open-setup", section="audit"))],
137
165
  tag="setup-audit:" + row["project"])
138
166
 
139
167
 
@@ -151,19 +179,19 @@ def _notice_loop(icon):
151
179
  except Exception as exc:
152
180
  if str(exc) != _state.get("notice_error"):
153
181
  _state["notice_error"] = str(exc)
154
- icon.notify(f"Could not show a Smart Tool notice: {exc}", "Smart Tool")
182
+ _notify(icon, "Could not show a Smart Tool notice: {error}", error=exc)
155
183
  time.sleep(NOTICE_INTERVAL_S)
156
184
 
157
185
 
158
186
  def _announce_start(icon):
159
187
  """Tells the user, once per start, that Smart Tool was just updated or that the last automatic update failed."""
160
- updated = update_check.note_running_version()
188
+ updated = update_check.update_for_tray()
161
189
  if updated:
162
- icon.notify(f"Smart Tool updated from {updated['from']} to {updated['to']}.", "Smart Tool")
190
+ _notify(icon, "Smart Tool updated from {old} to {new}.", old=updated["from"], new=updated["to"])
163
191
  failed = update_check.failed_update()
164
192
  if failed:
165
- icon.notify(f"The update to {failed} failed; Smart Tool {version.VERSION} is still installed. "
166
- "It is tried again in 24 h, or now from this menu.", "Smart Tool")
193
+ _notify(icon, "The update to {target} failed; Smart Tool {version} is still installed. It is tried again in "
194
+ "24 h, or now from this menu.", target=failed, version=version.VERSION)
167
195
 
168
196
 
169
197
  def _update_loop(icon):
@@ -172,7 +200,7 @@ def _update_loop(icon):
172
200
  try:
173
201
  _announce_start(icon)
174
202
  except Exception as exc:
175
- icon.notify(f"Could not read the update state: {exc}", "Smart Tool")
203
+ _notify(icon, "Could not read the update state: {error}", error=exc)
176
204
  while True:
177
205
  try:
178
206
  latest = update_check.newer_version()
@@ -182,7 +210,7 @@ def _update_loop(icon):
182
210
  try:
183
211
  update_check.record_check(latest)
184
212
  except Exception as exc:
185
- icon.notify(f"Could not save the update check: {exc}", "Smart Tool")
213
+ _notify(icon, "Could not save the update check: {error}", error=exc)
186
214
  if latest:
187
215
  known = latest == _state["update"]
188
216
  _state["update"] = latest
@@ -191,38 +219,44 @@ def _update_loop(icon):
191
219
  automatic = update_check.automatic_enabled() and update_check.should_auto_update(latest)
192
220
  except Exception as exc:
193
221
  automatic = False
194
- icon.notify(f"Automatic update skipped: {exc}", "Smart Tool")
222
+ _notify(icon, "Automatic update skipped: {error}", error=exc)
195
223
  if automatic:
196
- icon.notify(f"Updating Smart Tool from {version.VERSION} to {latest}...", "Smart Tool")
224
+ _notify(icon, "Updating Smart Tool from {old} to {new}...", old=version.VERSION, new=latest)
197
225
  update_check.run_update(latest, automatic=True)
198
226
  elif not known:
199
- icon.notify(f"Smart Tool {latest} is available: use \"Update to {latest}\" in this menu.", "Smart Tool")
227
+ _notify(icon, "Smart Tool {version} is available: use \"Update to {version}\" in this menu.",
228
+ version=latest)
200
229
  time.sleep(UPDATE_CHECK_INTERVAL_S)
201
230
 
202
231
 
203
232
  def _update_label(item):
204
- return f"Update to {_state['update']}" if _state["update"] else "Check for updates"
233
+ return i18n.t("Update to {version}", version=_state["update"]) if _state["update"] else i18n.t("Check for updates")
205
234
 
206
235
 
207
236
  _update_busy = threading.Lock()
208
237
 
209
238
 
210
239
  def _update_now(icon, item):
211
- """Menu action: installs the known newer version, or checks npm first and says when there is none."""
240
+ """Menu action: installs the known newer version, or checks npm first (telling the user it is checking) and says
241
+ when there is none."""
212
242
  if not _update_busy.acquire(blocking=False):
213
243
  return
214
244
 
215
245
  def run():
216
246
  try:
217
- latest = _state["update"] or update_check.newer_version()
247
+ latest = _state["update"]
218
248
  if not latest:
219
- icon.notify(f"Smart Tool {version.VERSION} is the latest version.", "Smart Tool")
249
+ _notify(icon, "Checking for updates…")
250
+ latest = update_check.newer_version()
251
+ if not latest:
252
+ _notify(icon, "Smart Tool {version} is the latest version.", version=version.VERSION)
220
253
  return
221
254
  _state["update"] = latest
222
255
  icon.update_menu()
256
+ _notify(icon, "Updating Smart Tool from {old} to {new}...", old=version.VERSION, new=latest)
223
257
  update_check.run_update(latest, automatic=False)
224
258
  except Exception as exc:
225
- icon.notify(f"Could not update: {exc}", "Smart Tool")
259
+ _notify(icon, "Could not update: {error}", error=exc)
226
260
  finally:
227
261
  _update_busy.release()
228
262
  threading.Thread(target=run, daemon=True).start()
@@ -237,16 +271,34 @@ def _auto_update_checked(item):
237
271
 
238
272
  def _toggle_auto_update(icon, item):
239
273
  if update_check.env_opted_out():
240
- icon.notify(f"{update_check.ENV_OPT_OUT}=off is set: automatic updates stay off until it is removed.",
241
- "Smart Tool")
274
+ _notify(icon, "{name}=off is set: automatic updates stay off until it is removed.", name=update_check.ENV_OPT_OUT)
242
275
  return
243
276
  try:
244
277
  config.update_config(lambda cfg: cfg.__setitem__("auto_update", not config.auto_update(cfg)))
245
278
  except Exception as exc:
246
- icon.notify(f"Could not change automatic updates: {exc}", "Smart Tool")
279
+ _notify(icon, "Could not change automatic updates: {error}", error=exc)
247
280
  icon.update_menu()
248
281
 
249
282
 
283
+ def _language_checked(lang):
284
+ def checked(item):
285
+ try:
286
+ return i18n.language() == lang
287
+ except Exception:
288
+ return False
289
+ return checked
290
+
291
+
292
+ def _choose_language(lang):
293
+ def choose(icon, item):
294
+ try:
295
+ i18n.set_language(lang)
296
+ except Exception as exc:
297
+ _notify(icon, "Could not change the language: {error}", error=exc)
298
+ _follow_language(icon)
299
+ return choose
300
+
301
+
250
302
  def _open_setup(icon, item):
251
303
  registry = daemon_launcher.read_registry()
252
304
  webbrowser.open((registry["url"].rstrip("/") + "/setup") if registry else SETUP_URL)
@@ -258,7 +310,7 @@ def _open_projects(icon, item):
258
310
  url = daemon_launcher.ensure_daemon_running()
259
311
  webbrowser.open(url.rstrip("/") + "/projects")
260
312
  except Exception as exc:
261
- icon.notify(f"Could not open the projects: {exc}", "Smart Tool")
313
+ _notify(icon, "Could not open the projects: {error}", error=i18n.t(str(exc)))
262
314
  threading.Thread(target=run, daemon=True).start()
263
315
 
264
316
 
@@ -268,7 +320,7 @@ def _add_project(icon, item):
268
320
  import folder_picker
269
321
  folder_picker.register_from_tray()
270
322
  except Exception as exc:
271
- icon.notify(f"Could not select the folder: {exc}", "Smart Tool")
323
+ _notify(icon, "Could not select the folder: {error}", error=exc)
272
324
  threading.Thread(target=run, daemon=True).start()
273
325
 
274
326
 
@@ -293,12 +345,10 @@ def _toggle_autostart(icon, item):
293
345
  else:
294
346
  result = autostart.install()
295
347
  if result.get("method") == "startup_folder" and result.get("fallback_reason"):
296
- icon.notify(
297
- f"Scheduled Task unavailable ({result['fallback_reason']}); using the Startup folder.",
298
- "Smart Tool",
299
- )
348
+ _notify(icon, "Scheduled Task unavailable ({reason}); using the Startup folder.",
349
+ reason=result["fallback_reason"])
300
350
  except Exception as exc:
301
- icon.notify(f"Failed to change automatic start: {exc}", "Smart Tool")
351
+ _notify(icon, "Failed to change automatic start: {error}", error=exc)
302
352
  finally:
303
353
  _autostart_busy.release()
304
354
  icon.update_menu()
@@ -332,18 +382,25 @@ def main():
332
382
  handle = _single_instance_handle()
333
383
  if handle is False:
334
384
  return
385
+ _state["language"] = i18n.language()
335
386
  icon = pystray.Icon(
336
387
  "smart-tool",
337
388
  icon=_ICONS["starting"],
338
- title=_TITLES["starting"],
389
+ title=i18n.t(_TITLES["starting"]),
339
390
  menu=pystray.Menu(
340
- pystray.MenuItem("Projects and indexing…", _open_projects, default=True),
341
- pystray.MenuItem("Index a folder…", _add_project),
342
- pystray.MenuItem("Open settings", _open_setup),
343
- pystray.MenuItem("Start with Windows", _toggle_autostart, checked=_autostart_checked),
391
+ pystray.MenuItem(lambda item: i18n.t("Projects and indexing…"), _open_projects, default=True),
392
+ pystray.MenuItem(lambda item: i18n.t("Index a folder…"), _add_project),
393
+ pystray.MenuItem(lambda item: i18n.t("Open settings"), _open_setup),
394
+ pystray.MenuItem(lambda item: i18n.t("Start with Windows"), _toggle_autostart, checked=_autostart_checked),
344
395
  pystray.MenuItem(_update_label, _update_now),
345
- pystray.MenuItem("Update automatically", _toggle_auto_update, checked=_auto_update_checked),
346
- pystray.MenuItem("Quit", _quit),
396
+ pystray.MenuItem(lambda item: i18n.t("Update automatically"), _toggle_auto_update,
397
+ checked=_auto_update_checked),
398
+ pystray.MenuItem("Idioma / Language", pystray.Menu(
399
+ pystray.MenuItem("Português (Brasil)", _choose_language("pt-BR"), checked=_language_checked("pt-BR"),
400
+ radio=True),
401
+ pystray.MenuItem("English", _choose_language("en"), checked=_language_checked("en"), radio=True),
402
+ )),
403
+ pystray.MenuItem(lambda item: i18n.t("Quit"), _quit),
347
404
  ),
348
405
  )
349
406
  threading.Thread(target=_poll_loop, args=(icon,), daemon=True).start()
package/update_check.py CHANGED
@@ -1,5 +1,6 @@
1
1
  """Newer Smart Tool release on npm and the update itself: the tray checks npm at start and every 24 h and, when
2
- automatic updates are on (the default), runs the same install command a user would, in a visible console. A failed
2
+ automatic updates are on (the default), runs the same install command a user would, behind the update screen
3
+ (update_splash.py) and with no console window. A failed
3
4
  version is tried again after 24 h. What happened (newer version available, updated, update failed) is told to the
4
5
  user by the tray and to agent sessions by the daemon, from the state file, without contacting npm again.
5
6
 
@@ -8,12 +9,12 @@ newer-version notice still shows then, with the command to update by hand."""
8
9
  import json
9
10
  import os
10
11
  import subprocess
12
+ import sys
11
13
  import time
12
14
  import urllib.request
13
15
 
14
16
  import atomic_io
15
17
  import config
16
- import node_locator
17
18
  import paths
18
19
  import version
19
20
 
@@ -102,6 +103,22 @@ def note_running_version(now=None):
102
103
  return found
103
104
 
104
105
 
106
+ def update_for_tray(now=None):
107
+ """The update record (from, to, at) of the running version the first time the tray asks, whether the tray or the
108
+ daemon started first and recorded it; None when there is none, it is older than the updated notice, or the tray
109
+ already told it."""
110
+ note_running_version(now)
111
+ now = now or time.time()
112
+ state = _state()
113
+ updated = state.get("updated") or {}
114
+ if (updated.get("to") != version.VERSION or state.get("tray_told") == version.VERSION
115
+ or now - updated.get("at", 0) >= UPDATED_NOTICE_S):
116
+ return None
117
+ state["tray_told"] = version.VERSION
118
+ _save(state)
119
+ return updated
120
+
121
+
105
122
  def failed_update():
106
123
  """The version an automatic update went for and did not install (the previous version is still running), else
107
124
  None."""
@@ -136,15 +153,16 @@ def notices(now=None):
136
153
 
137
154
 
138
155
  def run_update(latest, automatic):
139
- """Starts the install command in its own visible console, detached from the tray (the installer stops and restarts
140
- the tray). The console closes on success and stays open with the error on failure. npx is looked up now, not in the
141
- PATH the tray was born with: a Node installed or moved since the last logon still runs the update, and the folder
142
- found is put first in the PATH the installer (and the tray it restarts) inherits."""
156
+ """Starts the update screen (update_splash.py) detached from the tray, which the installer stops and restarts:
157
+ it runs the install command with no console window and shows each step and the result. npx is looked up by the
158
+ screen when it starts, not in the PATH the tray was born with."""
143
159
  if automatic:
144
160
  state = _state()
145
161
  state.update(attempted=latest, at=time.time())
146
162
  _save(state)
147
- flags = getattr(subprocess, "CREATE_NEW_CONSOLE", 0) | getattr(subprocess, "CREATE_NEW_PROCESS_GROUP", 0)
148
- command = f'title Smart Tool update to {latest} && {UPDATE_COMMAND} || pause'
149
- subprocess.Popen(["cmd.exe", "/c", command], creationflags=flags, close_fds=True,
150
- cwd=os.path.expanduser("~"), env=node_locator.environment(prefer=node_locator.which("npx.cmd")))
163
+ folder = os.path.dirname(os.path.abspath(__file__))
164
+ pythonw = os.path.join(os.path.dirname(sys.executable), "pythonw.exe")
165
+ flags = getattr(subprocess, "DETACHED_PROCESS", 0) | getattr(subprocess, "CREATE_NEW_PROCESS_GROUP", 0)
166
+ subprocess.Popen([pythonw if os.path.isfile(pythonw) else sys.executable, os.path.join(folder, "update_splash.py"),
167
+ latest], creationflags=flags, close_fds=True, cwd=folder, stdin=subprocess.DEVNULL,
168
+ stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
@@ -0,0 +1,197 @@
1
+ """Update screen: a small window with no buttons that runs the update with no console window and shows each step in
2
+ the interface language, then the result. Started by the tray (update_check.run_update) as `pythonw update_splash.py
3
+ <version>`; it outlives the tray, which the installer stops and starts again.
4
+
5
+ The install command runs with CREATE_NO_WINDOW, so it and every console program it starts (node, the installer,
6
+ pip, PowerShell, schtasks) share one hidden console. Its output goes to UPDATE_LOG; a failure shows the last line,
7
+ the log path and a Windows notification, and the installer has already restored the previous version."""
8
+ import os
9
+ import queue
10
+ import subprocess
11
+ import sys
12
+ import threading
13
+ import time
14
+
15
+ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
16
+ import i18n
17
+ import node_locator
18
+ import paths
19
+ import toast_notice
20
+ import update_check
21
+ import version
22
+
23
+ UPDATE_LOG = os.path.join(paths.DATA_DIR, "update.log")
24
+ TIMEOUT_S = 20 * 60
25
+ SUCCESS_CLOSE_MS = 4000
26
+ FAILURE_CLOSE_MS = 15000
27
+ MUTEX_NAME = "Local\\SmartToolUpdate"
28
+ NO_WINDOW = getattr(subprocess, "CREATE_NO_WINDOW", 0)
29
+ STEPS = (
30
+ ("Downloading uv", i18n.N_("Downloading components…")),
31
+ ("Preparing Python", i18n.N_("Preparing Python…")),
32
+ ("TLS inspection", i18n.N_("Preparing Python…")),
33
+ ("Older Smart Tool installation found", i18n.N_("Removing an older installation…")),
34
+ ("Updating Smart Tool at", i18n.N_("Copying the new files…")),
35
+ ("Installing Smart Tool at", i18n.N_("Copying the new files…")),
36
+ ("Files changed:", i18n.N_("Installing dependencies…")),
37
+ ("Browser control", i18n.N_("Checking browser control…")),
38
+ ("Failed:", i18n.N_("Restoring the previous version…")),
39
+ )
40
+
41
+
42
+ def step_for(line):
43
+ """English catalog key of the step an installer output line starts, or None."""
44
+ for marker, step in STEPS:
45
+ if marker in line:
46
+ return step
47
+ return None
48
+
49
+
50
+ def install_command():
51
+ """argv of the update: the same npx command a user types, through cmd.exe because npx is a .cmd script."""
52
+ npx = node_locator.which("npx.cmd")
53
+ if not npx:
54
+ raise RuntimeError(i18n.t("Node.js (npx) was not found; install Node.js and try again."))
55
+ return ["cmd.exe", "/d", "/c", npx, "--yes", f"{update_check.PACKAGE}@latest", "install"], npx
56
+
57
+
58
+ def run_install(lines):
59
+ """Runs the install command hidden, puts each output line on `lines`, writes them to UPDATE_LOG and returns
60
+ (exit code, last non-empty line). Kills the whole process tree after TIMEOUT_S."""
61
+ command, npx = install_command()
62
+ os.makedirs(os.path.dirname(UPDATE_LOG), exist_ok=True)
63
+ last = ""
64
+ with open(UPDATE_LOG, "w", encoding="utf-8") as log:
65
+ log.write(f"{time.strftime('%Y-%m-%d %H:%M:%S')} {' '.join(command)}\n")
66
+ with subprocess.Popen(command, stdin=subprocess.DEVNULL, stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
67
+ text=True, encoding="utf-8", errors="replace", creationflags=NO_WINDOW,
68
+ cwd=os.path.expanduser("~"), env=node_locator.environment(prefer=npx)) as proc:
69
+ timer = threading.Timer(TIMEOUT_S, lambda: subprocess.run(
70
+ ["taskkill", "/PID", str(proc.pid), "/T", "/F"], capture_output=True, creationflags=NO_WINDOW))
71
+ timer.start()
72
+ try:
73
+ for line in proc.stdout:
74
+ log.write(line)
75
+ log.flush()
76
+ if line.strip():
77
+ last = line.strip()
78
+ lines.put(last)
79
+ code = proc.wait()
80
+ finally:
81
+ timer.cancel()
82
+ log.write(f"{time.strftime('%Y-%m-%d %H:%M:%S')} exit {code}\n")
83
+ return code, last
84
+
85
+
86
+ def _single_instance():
87
+ """True when no other update screen is running (a named mutex held for the life of this process)."""
88
+ if sys.platform != "win32":
89
+ return True
90
+ import ctypes
91
+ kernel32 = ctypes.WinDLL("kernel32", use_last_error=True)
92
+ kernel32.CreateMutexW.restype = ctypes.c_void_p
93
+ handle = kernel32.CreateMutexW(None, False, MUTEX_NAME)
94
+ return bool(handle) and ctypes.get_last_error() != 183
95
+
96
+
97
+ class Splash:
98
+ """The window: product name, the version change, the current step, a moving bar; no buttons and no close box."""
99
+
100
+ def __init__(self, target):
101
+ import tkinter as tk
102
+ from tkinter import ttk
103
+ self.tk = tk
104
+ self.root = root = tk.Tk()
105
+ root.withdraw()
106
+ root.title("Smart Tool")
107
+ root.overrideredirect(True)
108
+ root.attributes("-topmost", True)
109
+ root.protocol("WM_DELETE_WINDOW", lambda: None)
110
+ width, height = 500, 236
111
+ root.geometry(f"{width}x{height}+{(root.winfo_screenwidth() - width) // 2}+{(root.winfo_screenheight() - height) // 3}")
112
+ frame = tk.Frame(root, bg="#ffffff", highlightthickness=1, highlightbackground="#c9d2df")
113
+ frame.pack(fill="both", expand=True)
114
+ tk.Frame(frame, bg="#2f6fed", height=6).pack(fill="x")
115
+ body = tk.Frame(frame, bg="#ffffff", padx=28, pady=18)
116
+ body.pack(fill="both", expand=True)
117
+ tk.Label(body, text="Smart Tool", bg="#ffffff", fg="#2f6fed", font=("Segoe UI Semibold", 11)).pack(anchor="w")
118
+ self.title = tk.Label(body, text=i18n.t("Updating Smart Tool"), bg="#ffffff", fg="#1b2430",
119
+ font=("Segoe UI Semibold", 17))
120
+ self.title.pack(anchor="w", pady=(2, 0))
121
+ tk.Label(body, text=i18n.t("Version {old} → {new}", old=version.VERSION, new=target), bg="#ffffff",
122
+ fg="#5b6472", font=("Segoe UI", 10)).pack(anchor="w")
123
+ style = ttk.Style(root)
124
+ style.theme_use("clam")
125
+ style.configure("Smart.Horizontal.TProgressbar", troughcolor="#e8edf5", background="#2f6fed",
126
+ bordercolor="#e8edf5", lightcolor="#2f6fed", darkcolor="#2f6fed")
127
+ self.bar = ttk.Progressbar(body, mode="indeterminate", style="Smart.Horizontal.TProgressbar", length=444)
128
+ self.bar.pack(anchor="w", pady=(16, 8))
129
+ self.bar.start(12)
130
+ self.step = tk.Label(body, text=i18n.t("Downloading version {version}…", version=target), bg="#ffffff",
131
+ fg="#1b2430", font=("Segoe UI", 10), anchor="w", justify="left", wraplength=444)
132
+ self.step.pack(anchor="w", fill="x")
133
+ self.note = tk.Label(body, text=i18n.t("This can take a few minutes. You do not need to do anything."),
134
+ bg="#ffffff", fg="#5b6472", font=("Segoe UI", 9), anchor="w", justify="left",
135
+ wraplength=444)
136
+ self.note.pack(anchor="w", fill="x", pady=(6, 0))
137
+ root.deiconify()
138
+
139
+ def show_step(self, key):
140
+ self.step.config(text=i18n.t(key))
141
+
142
+ def finish(self, ok, title, detail, note):
143
+ self.bar.stop()
144
+ self.bar.config(mode="determinate", maximum=100, value=100 if ok else 0)
145
+ self.title.config(text=title, fg="#18723c" if ok else "#b52c2c")
146
+ self.step.config(text=detail)
147
+ self.note.config(text=note)
148
+ self.root.after(SUCCESS_CLOSE_MS if ok else FAILURE_CLOSE_MS, self.root.destroy)
149
+
150
+
151
+ def main(argv):
152
+ """Shows the screen, runs the update and reports the outcome; returns the installer's exit code."""
153
+ target = argv[1] if len(argv) > 1 else "latest"
154
+ if not _single_instance():
155
+ return 0
156
+ splash = Splash(target)
157
+ lines, outcome = queue.Queue(), {}
158
+
159
+ def work():
160
+ try:
161
+ outcome["code"], outcome["last"] = run_install(lines)
162
+ except Exception as exc:
163
+ outcome["code"], outcome["last"] = 1, f"{type(exc).__name__}: {exc}"
164
+
165
+ worker = threading.Thread(target=work, daemon=True)
166
+ worker.start()
167
+
168
+ def pump():
169
+ while not lines.empty():
170
+ step = step_for(lines.get_nowait())
171
+ if step:
172
+ splash.show_step(step)
173
+ if worker.is_alive():
174
+ splash.root.after(200, pump)
175
+ return
176
+ if outcome["code"] == 0:
177
+ splash.finish(True, i18n.t("Smart Tool updated"),
178
+ i18n.t("Version {version} is installed and running.", version=target),
179
+ i18n.t("This window closes by itself."))
180
+ return
181
+ detail = i18n.t("The update did not finish; version {version} is still installed.", version=version.VERSION)
182
+ splash.finish(False, i18n.t("Update not finished"), f"{detail}\n{outcome['last'][:180]}",
183
+ i18n.t("Details in {path}", path=UPDATE_LOG))
184
+ try:
185
+ toast_notice.show(i18n.t("Smart Tool update not finished"), [detail, i18n.t("Details in {path}", path=UPDATE_LOG)],
186
+ [], tag="update-failed")
187
+ except Exception as exc:
188
+ splash.note.config(text=f"{splash.note.cget('text')}\n{exc}")
189
+
190
+ splash.root.after(200, pump)
191
+ splash.root.mainloop()
192
+ worker.join(timeout=1)
193
+ return outcome.get("code", 1)
194
+
195
+
196
+ if __name__ == "__main__":
197
+ sys.exit(main(sys.argv))
package/version.py CHANGED
@@ -1,7 +1,7 @@
1
1
  """Single source of the Smart Tool version and of the User-Agent sent to public APIs."""
2
2
  import re
3
3
 
4
- VERSION = "0.9.43"
4
+ VERSION = "0.9.46"
5
5
  _CONTACT_RE = re.compile(r"^(?:[^\s@()<>;]+@[^\s@()<>;]+\.[^\s@()<>;]+|https?://[^\s()<>;]+)$")
6
6
 
7
7
 
@@ -24,14 +24,17 @@ _LOCK = threading.Lock()
24
24
  OUTCOMES = ("ok", "failed", "rate_limited", "captcha", "paused")
25
25
 
26
26
 
27
- def record(tier_name, success, outcome=None):
27
+ def record(tier_name, success, outcome=None, requester=None):
28
28
  """One tier call: outcome tells a provider 429 (rate_limited), a captcha and a self-imposed quota pause (paused,
29
- left out of success rates) apart from an ordinary failure, so quota pressure can be read from the log."""
29
+ left out of success rates) apart from an ordinary failure, so quota pressure can be read from the log. requester
30
+ (requester.claim) says which session asked, never what."""
30
31
  outcome = outcome or ("ok" if success else "failed")
31
32
  if outcome not in OUTCOMES:
32
33
  raise ValueError(f"Unknown web search outcome: {outcome}")
33
34
  os.makedirs(os.path.dirname(HEALTH_PATH), exist_ok=True)
34
35
  entry = {"ts": time.time(), "tier": tier_name, "success": bool(success), "outcome": outcome}
36
+ if requester:
37
+ entry["requester"] = requester
35
38
  with _LOCK:
36
39
  with open(HEALTH_PATH, "a", encoding="utf-8") as f:
37
40
  f.write(json.dumps(entry, ensure_ascii=False) + "\n")