@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/CHANGELOG.md +53 -0
- package/README.md +25 -5
- package/autostart.py +5 -3
- package/browser_control.py +71 -15
- package/client_hooks.py +2 -2
- package/config.py +12 -0
- package/document_read.py +272 -0
- package/document_text.py +212 -54
- package/folder_picker.py +7 -5
- package/hook_decision.py +9 -1
- package/i18n.py +213 -0
- package/lazy_mcp_proxy.py +185 -0
- package/locales/pt-BR.json +923 -0
- package/package.json +1 -1
- package/projects_ui.py +7 -2
- package/requester.py +76 -0
- package/research_metrics.py +5 -3
- package/setup_audit.py +5 -3
- package/setup_ui.py +15 -2
- package/smart_tool_daemon.py +69 -10
- package/toast_notice.py +3 -2
- package/tool_redirect.py +2 -1
- package/tray.py +106 -49
- package/update_check.py +28 -10
- package/update_splash.py +197 -0
- package/version.py +1 -1
- package/web_search_health.py +5 -2
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 -
|
|
64
|
-
"starting": "Smart Tool - starting
|
|
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 -
|
|
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
|
-
|
|
132
|
-
[("
|
|
133
|
-
|
|
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
|
|
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.
|
|
188
|
+
updated = update_check.update_for_tray()
|
|
161
189
|
if updated:
|
|
162
|
-
icon
|
|
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
|
|
166
|
-
|
|
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
|
|
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
|
|
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
|
|
222
|
+
_notify(icon, "Automatic update skipped: {error}", error=exc)
|
|
195
223
|
if automatic:
|
|
196
|
-
icon
|
|
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
|
|
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
|
|
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
|
|
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"]
|
|
247
|
+
latest = _state["update"]
|
|
218
248
|
if not latest:
|
|
219
|
-
icon
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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.
|
|
297
|
-
|
|
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
|
|
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,
|
|
346
|
-
|
|
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,
|
|
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
|
|
140
|
-
the
|
|
141
|
-
|
|
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
|
-
|
|
148
|
-
|
|
149
|
-
subprocess
|
|
150
|
-
|
|
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)
|
package/update_splash.py
ADDED
|
@@ -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
package/web_search_health.py
CHANGED
|
@@ -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")
|