Skip to content

Commit fadf6fb

Browse files
Install updates in place instead of opening a download page
Check for Updates now downloads the build for the running platform, swaps it in and relaunches, rather than sending you to a browser to do it by hand. A running application cannot reliably delete itself, so the swap is done by a detached helper that waits for the process to exit first. The old copy is moved aside rather than deleted and is restored if the move fails, so an interrupted update cannot leave the user with no application - verified both paths: a clean swap leaves no leftovers, and a missing replacement rolls back with the original intact. Downloads are verified as plausible before installing, extracted with ditto on macOS so bundle symlinks survive, and the asset is chosen by platform. Two cases refuse rather than fail badly: running from source, where the app cannot replace itself, and an install directory that is not writable, which is what macOS App Management protection looks like from the inside - that one names the setting to change. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent 96d0b36 commit fadf6fb

5 files changed

Lines changed: 271 additions & 9 deletions

File tree

README.md

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -132,8 +132,21 @@ explains the analysis loop. It appears once; after that Sortero opens straight
132132
into your library. Reopen it any time from **Help → Setup Wizard…**.
133133

134134
**Help → Check for Updates…** compares your version against the latest GitHub
135-
release and offers to open the download page. It can also check automatically
136-
on launch (at most once a day) — toggle that in the same menu.
135+
release. If there's a newer one it offers to **download, install and relaunch**
136+
in one step: Sortero fetches the build for your platform, hands the swap to a
137+
small helper, quits, and reopens on the new version.
138+
139+
The helper waits for Sortero to exit before touching anything, keeps the old
140+
copy aside until the new one is in place, and puts it back if the move fails —
141+
a failed update never leaves you without an application.
142+
143+
Two caveats. Running from source it won't self-update, and says so. And macOS
144+
asks permission before one app modifies another in `/Applications`; if it's
145+
refused, allow Sortero under **System Settings → Privacy & Security → App
146+
Management**, or keep Sortero somewhere in your home folder.
147+
148+
It can also check automatically on launch (at most once a day) — toggle that in
149+
the same menu.
137150

138151
> While the repository is private, the update check can't read the release list
139152
> anonymously and will say so. Either make the repo public, or add a GitHub

sortero/app.py

Lines changed: 40 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
from tkinter import ttk, filedialog, messagebox
55

66
from . import (library, organize, dupes, fixtags, importer, journal, playlists,
7-
auth, paths, settings, updates, wizard, session)
7+
auth, paths, settings, updates, wizard, session, updater)
88
from .common import human_size
99

1010
APP = "Sortero"
@@ -358,6 +358,44 @@ def _maybe_auto_update(self):
358358
if settings.get("check_updates_on_launch") and updates.due():
359359
self.after(2500, lambda: self.check_updates(quiet=True))
360360

361+
def _offer_install(self, res):
362+
"""Found a newer release - download, swap it in, and relaunch."""
363+
import webbrowser
364+
if not updater.running_frozen():
365+
if messagebox.askyesno(APP, res["message"] + "\n\nThis copy is running "
366+
"from source, so it can't replace itself. "
367+
"Open the download page?"):
368+
webbrowser.open(res["url"])
369+
return
370+
if not messagebox.askyesno(
371+
APP, res["message"] + "\n\nDownload it, install it and restart "
372+
"Sortero now?\n\nAnything unsaved is finished first — this "
373+
"only quits once the new version is ready."):
374+
return
375+
try:
376+
asset = updater.pick_asset(res.get("assets") or [])
377+
except updater.UpdateError as e:
378+
messagebox.showerror(APP, str(e))
379+
return
380+
381+
def work(progress, log):
382+
log(f"downloading {asset['name']}…")
383+
new = updater.prepare(asset, progress=progress)
384+
log(f"unpacked to {new}")
385+
return new
386+
387+
def done(new_path):
388+
try:
389+
updater.install(new_path)
390+
except updater.UpdateError as e:
391+
messagebox.showerror(APP, str(e))
392+
return
393+
messagebox.showinfo(APP, "Update ready. Sortero will close and reopen "
394+
"on the new version in a moment.")
395+
self.after(300, self.destroy)
396+
397+
self.task.run(work, done, "Downloading update")
398+
361399
def check_updates(self, quiet=True):
362400
"""quiet=True only speaks up when there is actually an update."""
363401
def work(progress, log):
@@ -366,9 +404,7 @@ def work(progress, log):
366404
def done(res):
367405
state = res["state"]
368406
if state == "update":
369-
if messagebox.askyesno(APP, res["message"] + "\n\nOpen the download page?"):
370-
import webbrowser
371-
webbrowser.open(res["url"])
407+
self._offer_install(res)
372408
elif not quiet:
373409
if state == "private":
374410
if messagebox.askyesno(APP, res["message"] + "\n\nOpen Releases now?"):

sortero/updater.py

Lines changed: 211 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,211 @@
1+
"""Download a new release and swap it in, then relaunch.
2+
3+
A running application cannot reliably delete itself, so the actual swap is done
4+
by a small helper script: Sortero launches it detached, quits, and the helper
5+
waits for the process to disappear before moving anything.
6+
7+
The old copy is kept aside until the new one is in place, and restored if the
8+
move fails - a failed update must never leave the user with no application.
9+
"""
10+
import os, re, shutil, subprocess, sys, tempfile, time, urllib.request, zipfile
11+
12+
from . import net, paths
13+
from .version import __version__
14+
15+
ASSET_PATTERNS = {
16+
"darwin": re.compile(r"(?i)macos"),
17+
"win32": re.compile(r"(?i)windows"),
18+
"linux": re.compile(r"(?i)linux"),
19+
}
20+
21+
22+
class UpdateError(Exception):
23+
pass
24+
25+
26+
# ---------------------------------------------------------------- discovery
27+
def running_frozen():
28+
return bool(getattr(sys, "frozen", False))
29+
30+
31+
def installed_path():
32+
"""The thing that would be replaced: the .app bundle, or the program dir."""
33+
if not running_frozen():
34+
return None
35+
exe = os.path.realpath(sys.executable)
36+
if paths.IS_MAC:
37+
# .../Sortero.app/Contents/MacOS/Sortero
38+
parts = exe.split(os.sep)
39+
for i in range(len(parts) - 1, -1, -1):
40+
if parts[i].endswith(".app"):
41+
return os.sep.join(parts[:i + 1])
42+
return None
43+
return os.path.dirname(exe)
44+
45+
46+
def pick_asset(assets):
47+
pat = ASSET_PATTERNS.get(sys.platform, ASSET_PATTERNS["linux"])
48+
for a in assets:
49+
name = a.get("name", "")
50+
if name.endswith(".zip") and pat.search(name):
51+
return a
52+
raise UpdateError("That release has no build for this platform.")
53+
54+
55+
def writable(target):
56+
"""Can we actually replace it? /Applications may be protected by macOS."""
57+
parent = os.path.dirname(target)
58+
try:
59+
probe = os.path.join(parent, f".sortero-write-test-{os.getpid()}")
60+
with open(probe, "w") as fh:
61+
fh.write("x")
62+
os.remove(probe)
63+
return True
64+
except OSError:
65+
return False
66+
67+
68+
# ---------------------------------------------------------------- download
69+
def download(url, dest_dir, progress=None, timeout=120):
70+
os.makedirs(dest_dir, exist_ok=True)
71+
dest = os.path.join(dest_dir, "update.zip")
72+
req = urllib.request.Request(url, headers={
73+
"User-Agent": f"Sortero/{__version__}",
74+
"Accept": "application/octet-stream"})
75+
with net.urlopen(req, timeout=timeout) as r, open(dest, "wb") as fh:
76+
total = int(r.headers.get("Content-Length") or 0)
77+
got = 0
78+
while True:
79+
chunk = r.read(65536)
80+
if not chunk:
81+
break
82+
fh.write(chunk)
83+
got += len(chunk)
84+
if progress and total:
85+
progress(got, total)
86+
if os.path.getsize(dest) < 1_000_000:
87+
raise UpdateError("The download looks truncated; not installing it.")
88+
return dest
89+
90+
91+
def extract(zip_path, into):
92+
os.makedirs(into, exist_ok=True)
93+
if paths.IS_MAC:
94+
# ditto preserves bundle structure and symlinks that zipfile mangles
95+
subprocess.run(["ditto", "-x", "-k", zip_path, into], check=True,
96+
capture_output=True)
97+
else:
98+
with zipfile.ZipFile(zip_path) as z:
99+
z.extractall(into)
100+
101+
for dirpath, dirnames, filenames in os.walk(into):
102+
if paths.IS_MAC:
103+
for d in dirnames:
104+
if d == "Sortero.app":
105+
return os.path.join(dirpath, d)
106+
else:
107+
for f in filenames:
108+
if f in ("Sortero", "Sortero.exe"):
109+
return dirpath
110+
raise UpdateError("Couldn't find Sortero inside the downloaded archive.")
111+
112+
113+
# ------------------------------------------------------------------- swap
114+
MAC_SCRIPT = r"""#!/bin/bash
115+
# Sortero self-update helper. Waits for the app to quit, swaps it, relaunches.
116+
set -u
117+
APP="$1"; NEW="$2"; PID="$3"; LOG="$4"
118+
exec >>"$LOG" 2>&1
119+
echo "waiting for pid $PID"
120+
for _ in $(seq 1 200); do kill -0 "$PID" 2>/dev/null || break; sleep 0.3; done
121+
sleep 0.5
122+
OLD="${APP}.old-$$"
123+
if ! mv "$APP" "$OLD"; then echo "could not move old app"; open "$APP"; exit 1; fi
124+
if ! mv "$NEW" "$APP"; then
125+
echo "install failed - restoring previous version"
126+
mv "$OLD" "$APP"; open "$APP"; exit 1
127+
fi
128+
xattr -dr com.apple.quarantine "$APP" 2>/dev/null
129+
codesign --force --sign - "$APP" 2>/dev/null
130+
rm -rf "$OLD"
131+
echo "installed; relaunching"
132+
open "$APP"
133+
"""
134+
135+
WIN_SCRIPT = r"""@echo off
136+
rem Sortero self-update helper.
137+
setlocal
138+
set APP=%~1
139+
set NEW=%~2
140+
set PID=%~3
141+
:wait
142+
tasklist /FI "PID eq %PID%" | find "%PID%" >nul
143+
if not errorlevel 1 (
144+
timeout /t 1 /nobreak >nul
145+
goto wait
146+
)
147+
timeout /t 1 /nobreak >nul
148+
move "%APP%" "%APP%.old" >nul 2>&1
149+
move "%NEW%" "%APP%" >nul 2>&1
150+
if errorlevel 1 (
151+
move "%APP%.old" "%APP%" >nul 2>&1
152+
) else (
153+
rmdir /s /q "%APP%.old" >nul 2>&1
154+
)
155+
start "" "%APP%\Sortero.exe"
156+
"""
157+
158+
LINUX_SCRIPT = r"""#!/bin/bash
159+
set -u
160+
APP="$1"; NEW="$2"; PID="$3"; LOG="$4"
161+
exec >>"$LOG" 2>&1
162+
for _ in $(seq 1 200); do kill -0 "$PID" 2>/dev/null || break; sleep 0.3; done
163+
sleep 0.5
164+
OLD="${APP}.old-$$"
165+
if ! mv "$APP" "$OLD"; then exit 1; fi
166+
if ! mv "$NEW" "$APP"; then mv "$OLD" "$APP"; "$APP/Sortero" & exit 1; fi
167+
rm -rf "$OLD"
168+
"$APP/Sortero" &
169+
"""
170+
171+
172+
def log_path():
173+
return os.path.join(paths.data_dir(), "update.log")
174+
175+
176+
def install(new_path, target=None):
177+
"""Hand off to the helper and return. The caller must quit immediately."""
178+
target = target or installed_path()
179+
if not target:
180+
raise UpdateError("Sortero is running from source, so it can't replace "
181+
"itself. Pull the latest code instead.")
182+
if not writable(target):
183+
raise UpdateError(
184+
f"Can't write to {os.path.dirname(target)}.\n\n"
185+
"macOS asks permission before one app may modify another in "
186+
"/Applications. Grant Sortero access under System Settings → "
187+
"Privacy & Security → App Management, or move Sortero somewhere "
188+
"like your home folder and try again.")
189+
190+
tmp = tempfile.mkdtemp(prefix="sortero-update-")
191+
if paths.IS_WIN:
192+
script = os.path.join(tmp, "swap.bat")
193+
body, args = WIN_SCRIPT, ["cmd", "/c", script, target, new_path, str(os.getpid())]
194+
else:
195+
script = os.path.join(tmp, "swap.sh")
196+
body = MAC_SCRIPT if paths.IS_MAC else LINUX_SCRIPT
197+
args = ["/bin/bash", script, target, new_path, str(os.getpid()), log_path()]
198+
with open(script, "w") as fh:
199+
fh.write(body)
200+
os.chmod(script, 0o755)
201+
202+
subprocess.Popen(args, start_new_session=True,
203+
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
204+
return script
205+
206+
207+
def prepare(asset, progress=None):
208+
"""Download and unpack; returns the path to the new application."""
209+
tmp = tempfile.mkdtemp(prefix="sortero-dl-")
210+
zip_path = download(asset["browser_download_url"], tmp, progress=progress)
211+
return extract(zip_path, os.path.join(tmp, "unpacked"))

sortero/updates.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -59,10 +59,12 @@ def check(token=None, timeout=15):
5959
settings.set("last_update_check", time.time())
6060
latest = data.get("tag_name") or data.get("name") or ""
6161
url = data.get("html_url") or RELEASES_URL
62+
assets = data.get("assets") or []
6263
if is_newer(latest):
63-
return {"state": "update", "latest": latest, "url": url,
64+
return {"state": "update", "latest": latest, "url": url, "assets": assets,
65+
"notes": (data.get("body") or "").strip(),
6466
"message": f"Sortero {latest} is available. You have {__version__}."}
65-
return {"state": "up-to-date", "latest": latest, "url": url,
67+
return {"state": "up-to-date", "latest": latest, "url": url, "assets": assets,
6668
"message": f"Sortero {__version__} is the latest version."}
6769

6870

sortero/version.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
__version__ = "0.5.0"
1+
__version__ = "0.6.0"

0 commit comments

Comments
 (0)