@allansantos-dev/smart-tool 0.9.45 → 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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@allansantos-dev/smart-tool",
3
- "version": "0.9.45",
3
+ "version": "0.9.46",
4
4
  "description": "Local MCP server that gives coding agents (Claude Code, Codex) cheaper, sharper tools than their built-in search. Windows.",
5
5
  "license": "Apache-2.0",
6
6
  "author": "Allan Santos",
package/projects_ui.py CHANGED
@@ -1,13 +1,16 @@
1
1
  """Tela local de projetos; todas as operações passam pelos jobs do daemon."""
2
2
  import html
3
3
  import re
4
+ import i18n
4
5
  import setup_ui
5
6
  import project_maps_ui
6
7
  import project_insights_ui
7
8
 
8
9
 
9
10
  def render_projects_page():
11
+ """The projects page with its token, map and insight components and the interface language script."""
10
12
  return (_PAGE.replace("__TOKEN__", html.escape(setup_ui.SETUP_TOKEN, quote=True))
13
+ .replace("__I18N__", i18n.page_script(setup_ui.SETUP_TOKEN))
11
14
  .replace("__MAP_STYLE__", project_maps_ui.STYLE)
12
15
  .replace("__MAP_HTML__", project_maps_ui.HTML)
13
16
  .replace("__MAP_SCRIPT__", project_maps_ui.SCRIPT)
@@ -24,13 +27,14 @@ def render_map_window():
24
27
  '__STYLE__': style.replace('__MAP_STYLE__', project_maps_ui.STYLE).replace('__INSIGHTS_STYLE__', project_insights_ui.STYLE),
25
28
  '__MAP_HTML__': project_maps_ui.HTML,
26
29
  '__MAP_SCRIPT__': project_maps_ui.SCRIPT,
30
+ '__I18N__': i18n.page_script(setup_ui.SETUP_TOKEN),
27
31
  }
28
32
  return re.sub('|'.join(re.escape(key) for key in values), lambda m:values[m[0]], _MAP_WINDOW)
29
33
 
30
34
 
31
35
  _MAP_WINDOW = r'''<!doctype html>
32
36
  <html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
33
- <meta name="setup-token" content="__TOKEN__"><title>Project maps · Smart Tool</title><style>
37
+ <meta name="setup-token" content="__TOKEN__"><title>Project maps · Smart Tool</title>__I18N__<style>
34
38
  __STYLE__
35
39
  body.map-window{padding:0}.map-window header,.map-window main{max-width:none}.map-window header{padding:22px 28px 14px}.map-window main{padding:0 20px 30px}.map-window h1{font-size:22px}.map-window #window-root{margin:6px 0 0;font-size:12px;overflow-wrap:anywhere}.map-window .maps{margin-top:0}.map-window .maps>summary{display:none}.map-window .maps-workspace{grid-template-columns:minmax(0,1fr) 285px}.map-window #maps-svg{height:calc(100vh - 360px);min-height:430px}.map-window .maps-tools #maps-new-window{display:none}.map-window #maps-color-legend{max-height:90px}.map-window #window-message:empty{display:none}.map-window #window-message{margin-bottom:14px;overflow-wrap:anywhere}
36
40
  @media(max-width:850px){.map-window .maps-workspace{grid-template-columns:1fr}.map-window header{padding-inline:18px}.map-window main{padding-inline:12px}.map-window #maps-svg{height:450px;min-height:0}}
@@ -51,6 +55,7 @@ _PAGE = r'''<!doctype html>
51
55
  <html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
52
56
  <meta name="setup-token" content="__TOKEN__">
53
57
  <title>Projects and indexing · Smart Tool</title>
58
+ __I18N__
54
59
  <style>
55
60
  :root{color-scheme:light dark;--bg:#f6f7f9;--surface:#fff;--border:#dde1e6;--ink:#1b2430;--muted:#5b6472;--accent:#2f6fed;--good:#18723c;--bad:#b52c2c;--warn:#805300;--selected:#eaf1ff}
56
61
  @media(prefers-color-scheme:dark){:root{--bg:#14171c;--surface:#1c2027;--border:#39414e;--ink:#e6e9ee;--muted:#a6afbb;--accent:#91baff;--good:#68dc93;--bad:#ff9696;--warn:#e0b565;--selected:#263751}}
@@ -96,7 +101,7 @@ function setAdd(open){const restore=$('add-form').contains(document.activeElemen
96
101
  function select(id){selected=id;scopeKey='';scopeDirty=false;window.SmartMaps?.reset();window.SmartInsights?.reset();history.replaceState(null,'','#'+id);render()}
97
102
  function jobsHtml(rows){return rows.map(j=>`<div class="job"><div class="job-line"><strong>${esc(kindNames[j.kind]||j.kind)}</strong><span class="state ${j.status==='error'?'bad':''}">${esc(states[j.status]||j.status)}</span></div>${j.message?`<p>${esc(j.cancel_requested?'Cancellation requested; waiting for the current call to finish.':j.message)}</p>`:''}${j.status==='pending'?`<progress aria-label="Indexing progress" ${j.total_files>0&&j.processed_files!=null?`max="${j.total_files}" value="${j.processed_files}"`:''}></progress>`:''}${j.error?`<p class="state bad">${esc(j.error)}</p>`:''}${j.warning?`<p>${esc(j.warning)}</p>`:''}<small>${esc(j.job_id)}${j.elapsed_s!=null?' · '+j.elapsed_s+' s':''}</small></div>`).join('')}
98
103
  function render(){
99
- $('count').textContent=projects.length+' folder'+(projects.length===1?' registered':'s registered');
104
+ $('count').textContent=projects.length===1?'1 folder registered':projects.length+' folders registered';
100
105
  const list=$('project-list');
101
106
  for(const node of [...list.children])if(!projects.some(p=>p.id===node.dataset.project))node.remove();
102
107
  for(const p of projects){let button=list.querySelector(`[data-project="${p.id}"]`);if(!button){button=document.createElement('button');button.className='project-item';button.dataset.project=p.id;button.append(document.createElement('strong'),document.createElement('small'));list.append(button)}button.setAttribute('aria-current',String(p.id===selected));button.firstChild.textContent=p.name;button.lastChild.textContent=(states[p.status]||p.status)+(p.watch&&!p.paused?' · Watching':'')}
package/setup_audit.py CHANGED
@@ -31,6 +31,7 @@ import yaml
31
31
 
32
32
  import atomic_io
33
33
  import endpoint_sync
34
+ import i18n
34
35
  import paths
35
36
 
36
37
  REPORT_DIR = os.path.join(paths.DATA_DIR, "setup-audit")
@@ -904,13 +905,14 @@ def notices_due():
904
905
 
905
906
 
906
907
  def toast_text(row):
907
- """(title, lines) of the toast for one `notices_due` row."""
908
+ """(title, lines) of the toast for one `notices_due` row, in the interface language."""
908
909
  total = row["errors"] + row["warnings"]
909
- title = f"Setup audit: {total} new problem{'s' if total != 1 else ''} in the agent configuration"
910
+ title = (i18n.t("Setup audit: 1 new problem in the agent configuration") if total == 1
911
+ else i18n.t("Setup audit: {count} new problems in the agent configuration", count=total))
910
912
  lines = [os.path.basename(row["project"].rstrip("\\/")) or row["project"]]
911
913
  lines += [f"{item['severity']}: {item['check']} - {item['subject']}"[:120] for item in row["items"][:3]]
912
914
  if len(row["items"]) > 3:
913
- lines.append(f"and {len(row['items']) - 3} more")
915
+ lines.append(i18n.t("and {count} more", count=len(row["items"]) - 3))
914
916
  return title, lines
915
917
 
916
918
 
package/setup_ui.py CHANGED
@@ -28,6 +28,7 @@ import config
28
28
  import daemon_launcher
29
29
  import endpoint_sync
30
30
  import gateway
31
+ import i18n
31
32
  import model_catalog
32
33
  import model_defaults
33
34
  import model_health
@@ -101,6 +102,7 @@ def render_setup_page():
101
102
  # Único valor que entra no corpo do HTML, e não no <script>: escapado porque vem
102
103
  # de variável de ambiente, ainda que só quem já executa código possa definí-la.
103
104
  "__SETUP_ORIGIN__": html.escape(setup_origin(), quote=True),
105
+ "__I18N__": i18n.page_script(SETUP_TOKEN),
104
106
  }
105
107
  # Um único passe sobre o template original: .replace() encadeado re-escanearia o
106
108
  # próprio resultado e corromperia o JSON se um valor coincidisse com o outro placeholder.
@@ -419,6 +421,16 @@ def handle_providers_save(body):
419
421
  "validated_results": validated}
420
422
 
421
423
 
424
+ def handle_language_save(body):
425
+ """Saves the interface language the language button picked; returns {"language": <saved>}."""
426
+ if not isinstance(body, dict):
427
+ raise SetupError("Request body must be a JSON object with 'language'.")
428
+ try:
429
+ return {"language": i18n.set_language(body.get("language"))}
430
+ except ValueError as exc:
431
+ raise SetupError(str(exc)) from exc
432
+
433
+
422
434
  def handle_autostart_get():
423
435
  return autostart.status()
424
436
 
@@ -494,6 +506,7 @@ _PAGE_TEMPLATE = """<!doctype html>
494
506
  <meta charset="utf-8">
495
507
  <meta name="viewport" content="width=device-width, initial-scale=1">
496
508
  <title>Settings · Smart Tool</title>
509
+ __I18N__
497
510
  <style>
498
511
  :root { color-scheme: light dark;
499
512
  --bg: #f6f7f9; --surface: #ffffff; --line: #dde1e6; --ink: #1b2430; --muted: #5b6472; --soft: #eef1f5;
@@ -665,7 +678,7 @@ _PAGE_TEMPLATE = """<!doctype html>
665
678
  </head>
666
679
  <body>
667
680
  <header class="top">
668
- <div><h1>Smart Tool settings</h1><span class="where">__SETUP_ORIGIN__ · version <span id="app-version"></span></span></div>
681
+ <div><h1>Smart Tool settings</h1><span class="where">__SETUP_ORIGIN__ · <span>Version</span> <span id="app-version"></span></span></div>
669
682
  <nav><a href="/projects">Projects and indexing →</a></nav>
670
683
  </header>
671
684
  <div class="shell">
@@ -1010,7 +1023,7 @@ function renderOverview() {
1010
1023
  badge.hidden = !entry;
1011
1024
  badge.textContent = entry ? entry.count : "";
1012
1025
  badge.dataset.tone = entry && entry.bad ? "bad" : "warn";
1013
- badge.title = entry ? plural(entry.count, "item") + (entry.count === 1 ? " needs" : " need") + " attention" : "";
1026
+ badge.title = !entry ? "" : entry.count === 1 ? "1 item needs attention" : entry.count + " items need attention";
1014
1027
  }
1015
1028
  }
1016
1029
 
@@ -4163,6 +4163,7 @@ _SETUP_SAVES = {
4163
4163
  "/setup/gateway": setup_ui.handle_gateway_save,
4164
4164
  "/setup/autostart": setup_ui.handle_autostart_action,
4165
4165
  "/setup/contact": setup_ui.handle_contact_save,
4166
+ "/setup/language": setup_ui.handle_language_save,
4166
4167
  "/setup/audit": setup_ui.handle_audit,
4167
4168
  }
4168
4169
 
package/toast_notice.py CHANGED
@@ -74,13 +74,14 @@ def action_uri(action, **params):
74
74
 
75
75
 
76
76
  def toast_xml(title, lines, buttons):
77
- """`buttons` is a list of (label, uri); each one launches its protocol link and closes the toast."""
77
+ """`buttons` is a list of (label, uri); each one launches its protocol link and closes the toast. An empty list
78
+ gives a toast without an actions element."""
78
79
  text = "".join(f"<text>{escape(line)}</text>" for line in [title, *lines])
79
80
  actions = "".join(
80
81
  f'<action content={quoteattr(label)} arguments={quoteattr(uri)} activationType="protocol"/>'
81
82
  for label, uri in buttons)
82
83
  return (f'<toast><visual><binding template="ToastGeneric">{text}</binding></visual>'
83
- f'<actions>{actions}</actions></toast>')
84
+ + (f'<actions>{actions}</actions>' if actions else '') + '</toast>')
84
85
 
85
86
 
86
87
  def show(title, lines, buttons, tag=None):
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)