@allansantos-dev/smart-tool 0.9.31 → 0.9.33

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.
@@ -0,0 +1,99 @@
1
+ """Windows toast with buttons, no dependency: PowerShell 5.1 (always present) shows it through the WinRT toast API and
2
+ each button is a protocol link that Windows hands to `notice_action.py`.
3
+
4
+ An unpackaged app has no COM activator, so a button cannot call back into a running process. The protocol route needs
5
+ only HKCU keys (no admin): one AppUserModelId key (name and icon of the toast) and one URL protocol whose command runs
6
+ `pythonw notice_action.py "<uri>"` without a window. Actions are therefore small independent commands that read and
7
+ write the config file, never state held by the tray.
8
+ """
9
+ import base64
10
+ import os
11
+ import subprocess
12
+ import sys
13
+ import urllib.parse
14
+ from xml.sax.saxutils import quoteattr, escape
15
+
16
+ APP_ID = "SmartTool.Notifications"
17
+ PROTOCOL = "smart-tool-action"
18
+ ACTION_SCRIPT = os.path.join(os.path.dirname(os.path.abspath(__file__)), "notice_action.py")
19
+
20
+ _SHOW_SCRIPT = r"""
21
+ $ErrorActionPreference = 'Stop'
22
+ [Windows.UI.Notifications.ToastNotificationManager, Windows.UI.Notifications, ContentType = WindowsRuntime] | Out-Null
23
+ [Windows.Data.Xml.Dom.XmlDocument, Windows.Data.Xml.Dom.XmlDocument, ContentType = WindowsRuntime] | Out-Null
24
+ $xml = New-Object Windows.Data.Xml.Dom.XmlDocument
25
+ $xml.LoadXml($env:SMART_TOOL_TOAST_XML)
26
+ $toast = New-Object Windows.UI.Notifications.ToastNotification $xml
27
+ if ($env:SMART_TOOL_TOAST_TAG) { $toast.Tag = $env:SMART_TOOL_TOAST_TAG; $toast.Group = 'smart-tool' }
28
+ [Windows.UI.Notifications.ToastNotificationManager]::CreateToastNotifier($env:SMART_TOOL_TOAST_APPID).Show($toast)
29
+ """
30
+
31
+
32
+ def _pythonw():
33
+ candidate = os.path.join(os.path.dirname(sys.executable), "pythonw.exe")
34
+ return candidate if os.path.isfile(candidate) else sys.executable
35
+
36
+
37
+ def action_command():
38
+ """The command the protocol runs; `%1` is the clicked link."""
39
+ return f'"{_pythonw()}" "{ACTION_SCRIPT}" "%1"'
40
+
41
+
42
+ def register():
43
+ """Writes the HKCU keys (idempotent). Raises on any failure: a toast whose buttons do nothing is worse than none."""
44
+ import winreg
45
+ with winreg.CreateKey(winreg.HKEY_CURRENT_USER, rf"Software\Classes\AppUserModelId\{APP_ID}") as key:
46
+ winreg.SetValueEx(key, "DisplayName", 0, winreg.REG_SZ, "Smart Tool")
47
+ root = rf"Software\Classes\{PROTOCOL}"
48
+ with winreg.CreateKey(winreg.HKEY_CURRENT_USER, root) as key:
49
+ winreg.SetValueEx(key, None, 0, winreg.REG_SZ, "URL:Smart Tool action")
50
+ winreg.SetValueEx(key, "URL Protocol", 0, winreg.REG_SZ, "")
51
+ with winreg.CreateKey(winreg.HKEY_CURRENT_USER, root + r"\shell\open\command") as key:
52
+ winreg.SetValueEx(key, None, 0, winreg.REG_SZ, action_command())
53
+
54
+
55
+ def unregister():
56
+ """Deletes the HKCU keys `register` wrote, deepest first; returns how many existed (missing keys are not an error)."""
57
+ import winreg
58
+ count = 0
59
+ for path in (rf"Software\Classes\{PROTOCOL}\shell\open\command", rf"Software\Classes\{PROTOCOL}\shell\open",
60
+ rf"Software\Classes\{PROTOCOL}\shell", rf"Software\Classes\{PROTOCOL}",
61
+ rf"Software\Classes\AppUserModelId\{APP_ID}"):
62
+ try:
63
+ winreg.DeleteKey(winreg.HKEY_CURRENT_USER, path)
64
+ count += 1
65
+ except FileNotFoundError:
66
+ pass
67
+ return count
68
+
69
+
70
+ def action_uri(action, **params):
71
+ """The protocol link a button carries: `smart-tool-action:<action>?<params>`; returns the link as text."""
72
+ query = urllib.parse.urlencode(params)
73
+ return f"{PROTOCOL}:{action}" + (f"?{query}" if query else "")
74
+
75
+
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."""
78
+ text = "".join(f"<text>{escape(line)}</text>" for line in [title, *lines])
79
+ actions = "".join(
80
+ f'<action content={quoteattr(label)} arguments={quoteattr(uri)} activationType="protocol"/>'
81
+ for label, uri in buttons)
82
+ return (f'<toast><visual><binding template="ToastGeneric">{text}</binding></visual>'
83
+ f'<actions>{actions}</actions></toast>')
84
+
85
+
86
+ def show(title, lines, buttons, tag=None):
87
+ """Shows the toast; raises RuntimeError with PowerShell's message when Windows refuses it."""
88
+ if sys.platform != "win32":
89
+ raise RuntimeError("Toast notifications are only available on Windows.")
90
+ register()
91
+ script = base64.b64encode(_SHOW_SCRIPT.encode("utf-16-le")).decode("ascii")
92
+ env = {**os.environ, "SMART_TOOL_TOAST_XML": toast_xml(title, lines, buttons),
93
+ "SMART_TOOL_TOAST_APPID": APP_ID, "SMART_TOOL_TOAST_TAG": tag or ""}
94
+ done = subprocess.run(
95
+ ["powershell.exe", "-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-EncodedCommand", script],
96
+ env=env, capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=30,
97
+ creationflags=subprocess.CREATE_NO_WINDOW)
98
+ if done.returncode:
99
+ raise RuntimeError(f"Windows refused the notification: {(done.stderr or done.stdout).strip()[:500]}")
package/tray.py CHANGED
@@ -22,11 +22,13 @@ 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 toast_notice
25
26
  import update_check
26
27
  import version
27
28
 
28
29
  POLL_INTERVAL_S = 45
29
30
  UPDATE_CHECK_INTERVAL_S = 24 * 3600
31
+ NOTICE_INTERVAL_S = 600
30
32
  SETUP_URL = "http://127.0.0.1:8765/setup"
31
33
 
32
34
  _STATE_LOCK = threading.Lock()
@@ -111,6 +113,42 @@ def _poll_loop(icon):
111
113
  time.sleep(POLL_INTERVAL_S)
112
114
 
113
115
 
116
+ def _show_notices(icon, notices):
117
+ """Shows a toast for each pending notice: the new-default-models offer and each model over the failure limit."""
118
+ offer = notices.get("upgrade")
119
+ if offer:
120
+ lines = [f"{role}: {change['current']} -> {change['new']}" for role, change in offer["changes"].items()]
121
+ nonce = offer["nonce"]
122
+ toast_notice.show("Smart Tool has new recommended models", lines, [
123
+ ("Apply now", toast_notice.action_uri("models-apply", n=nonce)),
124
+ ("Don't show again", toast_notice.action_uri("models-decline", n=nonce)),
125
+ ], tag="models-upgrade")
126
+ for row in notices.get("failing") or []:
127
+ last = row.get("last_error") or "see the failure log in the settings"
128
+ toast_notice.show(
129
+ f"{row['model']} is failing", [f"{row['errors']} of {row['calls']} calls failed in 24 h as {row['role']}.", last[:160]],
130
+ [("Switch model", toast_notice.action_uri("open-setup", section="models")),
131
+ ("Mute 7 days", toast_notice.action_uri("mute-model", model=row["model"]))], tag="model-failing:" + row["model"])
132
+
133
+
134
+ def _notice_loop(icon):
135
+ """Asks the daemon every 10 minutes whether a toast is due. The daemon decides (once a day per notice) and marks it
136
+ announced, so a restart of the tray never repeats one."""
137
+ while True:
138
+ try:
139
+ daemon_url = (daemon_launcher.read_registry() or {}).get("url")
140
+ if daemon_url:
141
+ request = urllib.request.Request(daemon_url.rstrip("/") + "/setup/notices", data=b"{}", method="POST",
142
+ headers={"Content-Type": "application/json"})
143
+ with urllib.request.urlopen(request, timeout=60) as response:
144
+ _show_notices(icon, json.load(response))
145
+ except Exception as exc:
146
+ if str(exc) != _state.get("notice_error"):
147
+ _state["notice_error"] = str(exc)
148
+ icon.notify(f"Could not show a Smart Tool notice: {exc}", "Smart Tool")
149
+ time.sleep(NOTICE_INTERVAL_S)
150
+
151
+
114
152
  def _announce_start(icon):
115
153
  """Tells the user, once per start, that Smart Tool was just updated or that the last automatic update failed."""
116
154
  updated = update_check.note_running_version()
@@ -304,6 +342,7 @@ def main():
304
342
  )
305
343
  threading.Thread(target=_poll_loop, args=(icon,), daemon=True).start()
306
344
  threading.Thread(target=_update_loop, args=(icon,), daemon=True).start()
345
+ threading.Thread(target=_notice_loop, args=(icon,), daemon=True).start()
307
346
  try:
308
347
  icon.run()
309
348
  finally:
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.31"
4
+ VERSION = "0.9.33"
5
5
  _CONTACT_RE = re.compile(r"^(?:[^\s@()<>;]+@[^\s@()<>;]+\.[^\s@()<>;]+|https?://[^\s()<>;]+)$")
6
6
 
7
7