@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.
@@ -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.45"
4
+ VERSION = "0.9.46"
5
5
  _CONTACT_RE = re.compile(r"^(?:[^\s@()<>;]+@[^\s@()<>;]+\.[^\s@()<>;]+|https?://[^\s()<>;]+)$")
6
6
 
7
7