studio: regenerate desktop launcher on unsloth studio update (macOS + Linux + Windows) (#5577)

* studio: regenerate desktop launcher on `unsloth studio update`

Today `unsloth studio update` only mutates the venv. The macOS .app bundle,
the Linux .desktop file, and the shared launch-studio.sh stub bake their
paths and `studio_install_id` at install time and never refresh. Users who
update an existing Studio install report the Dock / Applications icon still
pointing at the old launcher; only a fresh `curl ... install.sh | sh`
fixes it because that path re-enters install.sh's create_studio_shortcuts.

Wire the same logic into the update path:

- install.sh: add --shortcuts-only. Skips the heavy install steps, resolves
  STUDIO_HOME / OS / DATA_DIR through the existing _resolve_studio_destinations
  + platform detection, then calls create_studio_shortcuts and exits.
- unsloth_cli/commands/studio.py: after setup.sh succeeds, call install.sh
  with --shortcuts-only. Prefers a local checkout's install.sh (when
  STUDIO_LOCAL_REPO is set) or one shipped under _PACKAGE_ROOT, and falls
  back to fetching the upstream installer from https://unsloth.ai/install.sh
  for PyPI-installed users (the wheel does not ship install.sh).

Net effect: `unsloth studio update` now refreshes the macOS .app stub,
launcher script, studio.conf, and Linux .desktop entry on every update, so
the desktop icon stays in sync with the venv that setup.sh just updated.
Env-override and Tauri modes keep their existing behavior (no persistent
menu shortcuts, but the launch-studio.sh is still regenerated).

Windows is unchanged here; setup.ps1 already handles its own Start Menu /
Desktop .lnk creation on update.

* studio: also regenerate Windows .lnk shortcuts on update

Mirror the macOS fix: install.ps1 gains --shortcuts-only that short-circuits
to New-StudioShortcuts, and unsloth studio update calls it after setup.ps1
the same way it now does on macOS / Linux.

PyPI installs do not ship install.ps1, so the Python helper fetches the
upstream script from https://unsloth.ai/install.ps1 and pipes it into
powershell.exe -Command - with an explicit Install-UnslothStudio call
appended (irm | iex relies on the trailing @args, which is empty when
launched from stdin).

setup.ps1 alone never recreates the Start Menu / Desktop .lnk targets or
the launch-studio.{ps1,vbs} scripts, so without this update users on
Windows hit the same stale-icon regression that triggered the macOS PR.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* studio: rename unsloth.exe to .deleteme before update on Windows

Pip's editable reinstall calls uninstall first, which deletes every RECORD
entry. unsloth.exe is one of them, and Windows refuses to delete a file
whose image is mapped into the running process tree. The first
unsloth studio update after install therefore fails with:

  OSError: [WinError 32] The process cannot access the file because it
  is being used by another process: ...\Scripts\unsloth.exe

Windows does allow renaming an in-use exe, so move it aside before
_run_setup_script kicks pip. pip then drops a fresh unsloth.exe at the
original path; the *.exe.deleteme left behind is cleaned up at the start
of the next update once the previous shim has exited.

* studio: rename unsloth.exe from setup.ps1 to reliably bypass exe lock

* studio: print python -m workaround when Windows exe lock blocks update

* studio: use python -c hint (unsloth_cli has no __main__)

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* install.sh: reshape --shortcuts-only Tauri guard to pass exit-order test

* shorter comments in update / launcher regen logic

* studio update: env-mode passthrough + non-silent shortcuts-only error

* studio update: address codex/gemini PR review

- Strip install.ps1's `Install-UnslothStudio @args` auto-invoke before
  appending an explicit `--shortcuts-only` call so PyPI Windows installs
  don't re-run the full installer over stdin.
- subprocess.run(input=wrapper, ...) now uses encoding="utf-8" so box
  drawing chars in install.ps1 don't UnicodeEncodeError on CP1252.
- Wrap _run_setup_script in try/except to restore unsloth.exe from
  .deleteme if setup fails, and mirror that rollback inside setup.ps1
  when install_python_stack.py exits non-zero.
- Capture subprocess return codes in _refresh_desktop_shortcuts and
  echo a one-line warning on non-zero so silent stale-shortcut failures
  surface.
- Drop --local from the Windows lock-recovery hint so users on PyPI
  installs don't accidentally switch into editable-checkout mode.
- Quote $VENV_ABS_BIN/unsloth in the install.sh shortcuts-only error
  so paths with spaces print legibly.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* studio update: harden Windows refresh per multi-reviewer pass

- PowerShell stdin path now writes the wrapper to a UTF-8 BOM tempfile
  and runs it via `-File`. `powershell.exe -Command -` decodes stdin
  with the OEM code page, which mangles box-drawing chars in the
  fetched install.ps1; -File reads the BOM and decodes UTF-8 cleanly.
- _restore_self_exe_lock_windows now treats a zero-byte unsloth.exe as
  a partial-write and prefers the .deleteme copy. setup.ps1 mirrors
  the same check.
- _release_self_exe_lock_windows uses os.replace for atomic overwrite
  so a stale .deleteme from an aborted prior update doesn't break the
  rename.
- Lock-recovery hint mentions that --local should be re-added when
  the user installed from a repo checkout.

* studio update: respect Tauri context and tidy Windows .deleteme

Tauri's update.rs spawns `unsloth studio update`; without a signal,
the CLI's _refresh_desktop_shortcuts would call install.{sh,ps1}
--shortcuts-only and create duplicate ~/Applications/Unsloth Studio.app
(or .desktop / .lnk) entries that collide with the Tauri bundle.

- update.rs now sets UNSLOTH_TAURI_UPDATE=1 on the spawned child.
- studio.py's update() skips _refresh_desktop_shortcuts when that env
  var is set; Tauri owns its own bundle entries.
- After a successful Windows update, drop the .deleteme orphan so
  repeated updates don't accumulate stale binaries that could later
  be promoted by _restore_self_exe_lock_windows on a cross-version
  failure.
- Tempfile for the PyPI-fallback PowerShell path now uses an
  unsloth-studio-refresh- prefix so AV/EDR rules and user greps can
  identify it.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* studio update: drop obsolete WinError 32 hint, echo Tauri skip

The rename trick in _release_self_exe_lock_windows + setup.ps1's
restore now handle the .exe-lock case in-flow; the printed hint
suggested re-running update via venv python, but that just re-enters
the same update() and hits the same failure if the rename didn't help.
Removing the misleading hint and its helper.

Also surface a one-line typer.echo when refresh is skipped under
UNSLOTH_TAURI_UPDATE so --verbose logs make the branch visible.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
This commit is contained in:
Daniel Han 2026-05-19 05:49:10 -07:00 committed by GitHub
commit d1681ea158
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 324 additions and 1 deletions

View file

@ -92,6 +92,7 @@ function Install-UnslothStudio {
$RepoRoot = ""
$TauriMode = $false
$SkipTorch = $false
$ShortcutsOnly = $false
$argList = $args
for ($i = 0; $i -lt $argList.Count; $i++) {
switch ($argList[$i]) {
@ -100,6 +101,7 @@ function Install-UnslothStudio {
"--no-torch" { $SkipTorch = $true }
"--verbose" { $script:UnslothVerbose = $true }
"-v" { $script:UnslothVerbose = $true }
"--shortcuts-only" { $ShortcutsOnly = $true }
"--package" {
$i++
if ($i -ge $argList.Count) {
@ -871,6 +873,19 @@ shell.Run cmd, 0, False
}
}
# Regen .lnk + launcher only; used by `unsloth studio update`.
if ($ShortcutsOnly) {
if ($TauriMode) { return }
$UnslothExe = Join-Path $VenvDir "Scripts\unsloth.exe"
if (-not (Test-Path -LiteralPath $UnslothExe)) {
Write-Host "[ERROR] unsloth.exe missing at $UnslothExe; run install.ps1 first." -ForegroundColor Red
# throw (not Exit-InstallFailure) so non-Tauri callers see rc != 0.
throw "unsloth.exe missing"
}
New-StudioShortcuts -UnslothExePath $UnslothExe
return
}
# ── Check winget ──
Write-TauriLog "STEP" "Checking system dependencies"
if (-not (Get-Command winget -ErrorAction SilentlyContinue)) {

View file

@ -45,6 +45,7 @@ TAURI_MODE=false
_USER_PYTHON=""
_NO_TORCH_FLAG=false
_VERBOSE=false
_SHORTCUTS_ONLY=false
_next_is_package=false
_next_is_python=false
for arg in "$@"; do
@ -65,6 +66,7 @@ for arg in "$@"; do
--python) _next_is_python=true ;;
--no-torch) _NO_TORCH_FLAG=true ;;
--verbose|-v) _VERBOSE=true ;;
--shortcuts-only) _SHORTCUTS_ONLY=true ;;
esac
done
@ -1233,6 +1235,20 @@ elif grep -qi microsoft /proc/version 2>/dev/null; then
fi
step "platform" "$OS"
# Regen launcher/shortcuts only; used by `unsloth studio update`.
if [ "$_SHORTCUTS_ONLY" = true ]; then
# Tauri owns its own shortcuts.
if [ "$TAURI_MODE" != true ]; then
VENV_ABS_BIN="$VENV_DIR/bin"
if [ ! -x "$VENV_ABS_BIN/unsloth" ]; then
echo "ERROR: unsloth binary missing at '$VENV_ABS_BIN/unsloth'; run install.sh first." >&2
exit 1
fi
create_studio_shortcuts "$VENV_ABS_BIN/unsloth" "$OS"
fi
exit 0
fi
# ── Architecture detection & Python version ──
_ARCH=$(uname -m)
MAC_INTEL=false

View file

@ -1842,6 +1842,21 @@ if ($CuTag -eq "cpu") {
}
}
# Rename running unsloth.exe so pip can replace it (Windows refuses to delete a mapped .exe).
$VenvScriptsDir = Join-Path $VenvDir "Scripts"
$RunningUnslothExe = Join-Path $VenvScriptsDir "unsloth.exe"
if (Test-Path -LiteralPath $RunningUnslothExe -PathType Leaf) {
$StaleUnslothExe = "$RunningUnslothExe.deleteme"
if (Test-Path -LiteralPath $StaleUnslothExe) {
Remove-Item -LiteralPath $StaleUnslothExe -Force -ErrorAction SilentlyContinue
}
try {
Rename-Item -LiteralPath $RunningUnslothExe -NewName "unsloth.exe.deleteme" -Force -ErrorAction Stop
} catch {
substep "could not rename unsloth.exe ($($_.Exception.Message)); pip may fail with WinError 32" "Yellow"
}
}
# Ordered heavy dependency installation -- shared cross-platform script
substep "running ordered dependency installation..."
python "$PSScriptRoot\install_python_stack.py"
@ -1851,6 +1866,28 @@ $ErrorActionPreference = $prevEAP
if ($stackExit -ne 0) {
Write-Host "[FAILED] Python dependency installation failed (exit code $stackExit)" -ForegroundColor Red
Write-Host " Re-run the installer or check the error above for details." -ForegroundColor Red
# Restore the pre-rename unsloth.exe so the user keeps a working CLI.
# Treat a zero-byte exe as "pip half-wrote a broken binary" -- prefer the
# stale-but-working copy in .deleteme.
if (Test-Path -LiteralPath "$RunningUnslothExe.deleteme") {
$needRestore = -not (Test-Path -LiteralPath $RunningUnslothExe)
if (-not $needRestore) {
try {
$needRestore = (Get-Item -LiteralPath $RunningUnslothExe -ErrorAction Stop).Length -eq 0
} catch { $needRestore = $true }
}
if ($needRestore) {
try {
if (Test-Path -LiteralPath $RunningUnslothExe) {
Remove-Item -LiteralPath $RunningUnslothExe -Force -ErrorAction SilentlyContinue
}
Rename-Item -LiteralPath "$RunningUnslothExe.deleteme" -NewName "unsloth.exe" -Force -ErrorAction Stop
substep "restored unsloth.exe after failed install"
} catch {
substep "could not restore unsloth.exe ($($_.Exception.Message))" "Yellow"
}
}
}
exit 1
}

View file

@ -65,6 +65,10 @@ fn spawn_update(
// the same install the desktop app uses, not an inherited custom root.
cmd.env_remove("UNSLOTH_STUDIO_HOME");
cmd.env_remove("STUDIO_HOME");
// Signal to unsloth_cli that this update was initiated by the Tauri
// desktop bundle so it skips re-creating CLI launchers/.app/.desktop
// shortcuts (Tauri owns its own bundle entries).
cmd.env("UNSLOTH_TAURI_UPDATE", "1");
#[cfg(windows)]
let mut child: Box<dyn ChildWrapper + Send> = {

View file

@ -6,6 +6,7 @@ import hashlib
import json
import os
import platform
import re
import secrets
import sqlite3
import subprocess
@ -13,6 +14,8 @@ import sys
import tempfile
import time
import types
import urllib.error
import urllib.request
from datetime import datetime, timezone
from pathlib import Path
from typing import List, Optional
@ -1064,6 +1067,170 @@ def _run_setup_script(*, verbose: bool = False) -> None:
raise typer.Exit(result.returncode)
_INSTALLER_URL_BASH = "https://unsloth.ai/install.sh"
_INSTALLER_URL_PWSH = "https://unsloth.ai/install.ps1"
def _refresh_desktop_shortcuts(*, verbose: bool = False) -> None:
"""Re-run installer with --shortcuts-only to refresh launchers post-update."""
env = {**os.environ}
if verbose:
env["UNSLOTH_VERBOSE"] = "1"
is_windows = platform.system() == "Windows"
installer_name = "install.ps1" if is_windows else "install.sh"
installer_url = _INSTALLER_URL_PWSH if is_windows else _INSTALLER_URL_BASH
# Prefer local checkout, fall back to package dir, then network fetch.
local_repo = (os.environ.get("STUDIO_LOCAL_REPO") or "").strip()
candidates: list[Path] = []
if local_repo:
candidates.append(Path(local_repo) / installer_name)
candidates.append(_PACKAGE_ROOT / installer_name)
args = ["--shortcuts-only"]
if verbose:
args.append("--verbose")
if is_windows:
ps_argv: list[str] = ["powershell.exe"]
if _should_hide_windows_subprocesses():
ps_argv.extend(
["-NoLogo", "-NoProfile", "-NonInteractive", "-WindowStyle", "Hidden"]
)
for script in candidates:
try:
if script.is_file():
quoted = str(script).replace("'", "''")
argv = list(ps_argv)
argv.extend(
[
"-ExecutionPolicy",
"Bypass",
"-Command",
f"& '{quoted}' {' '.join(args)} *>&1",
]
)
result = subprocess.run(
argv,
env = env,
check = False,
**_windows_hidden_subprocess_kwargs(),
)
if result.returncode != 0:
typer.echo(
f" refresh-launcher install.ps1 exited {result.returncode}"
)
return
except OSError:
continue
# PyPI installs lack install.ps1: fetch + pipe to powershell stdin.
try:
request = urllib.request.Request(
installer_url, headers = {"User-Agent": "unsloth-studio-update"}
)
with urllib.request.urlopen(request, timeout = 30) as response:
installer = response.read().decode("utf-8", errors = "replace")
except (urllib.error.URLError, TimeoutError, OSError) as exc:
typer.echo(
f" refresh-launcher skipped: could not fetch {installer_url} ({exc})"
)
return
# install.ps1 auto-invokes `Install-UnslothStudio @args` at EOF; over
# stdin `$args` is empty so that triggers the full installer flow
# (deps, venv, prompts) before our shortcuts-only call. Strip it.
installer = re.sub(
r"(?m)^[ \t]*Install-UnslothStudio[ \t]+@args[ \t]*\r?\n?",
"",
installer,
)
# stdin-piped scripts have empty $args, so call Install-UnslothStudio explicitly.
marker_args = " ".join(args)
wrapper = installer + f"\nInstall-UnslothStudio {marker_args}\n"
# Write to a UTF-8 BOM tempfile and use -File rather than -Command -.
# `powershell.exe -Command -` reads stdin via [Console]::InputEncoding
# (CP1252/OEM on most Windows boxes), which mangles box-drawing chars
# in install.ps1. -File reads the BOM and decodes correctly. The
# prefix gives AV/EDR engines (and grep'ing users) a clear identity.
ps1_fd, ps1_path = tempfile.mkstemp(
prefix = "unsloth-studio-refresh-",
suffix = ".ps1",
)
try:
with os.fdopen(ps1_fd, "wb") as fh:
fh.write(b"\xef\xbb\xbf" + wrapper.encode("utf-8"))
argv = list(ps_argv)
argv.extend(["-ExecutionPolicy", "Bypass", "-File", ps1_path])
try:
result = subprocess.run(
argv,
env = env,
check = False,
**_windows_hidden_subprocess_kwargs(),
)
if result.returncode != 0:
typer.echo(
f" refresh-launcher fetched install.ps1 exited {result.returncode}"
)
except OSError as exc:
typer.echo(
f" refresh-launcher skipped: powershell exec failed ({exc})"
)
finally:
try:
os.unlink(ps1_path)
except OSError:
pass
return
for script in candidates:
try:
if script.is_file():
result = subprocess.run(
["bash", str(script), *args],
env = env,
check = False,
)
if result.returncode != 0:
typer.echo(
f" refresh-launcher install.sh exited {result.returncode}"
)
return
except OSError:
continue
# PyPI installs lack install.sh: fetch upstream.
try:
request = urllib.request.Request(
installer_url, headers = {"User-Agent": "unsloth-studio-update"}
)
with urllib.request.urlopen(request, timeout = 30) as response:
installer = response.read()
except (urllib.error.URLError, TimeoutError, OSError) as exc:
typer.echo(
f" refresh-launcher skipped: could not fetch {installer_url} ({exc})"
)
return
try:
result = subprocess.run(
["bash", "-s", "--", *args],
input = installer,
env = env,
check = False,
)
if result.returncode != 0:
typer.echo(
f" refresh-launcher fetched install.sh exited {result.returncode}"
)
except OSError as exc:
typer.echo(f" refresh-launcher skipped: bash exec failed ({exc})")
@studio_app.command(hidden = True)
def setup(
verbose: bool = typer.Option(
@ -1093,6 +1260,9 @@ def update(
),
):
"""Update Unsloth Studio dependencies and rebuild."""
# Re-export UNSLOTH_STUDIO_HOME for env-mode installs so the refresh
# subprocess resolves the same install root the user originally chose.
_ensure_studio_env_exported()
# Ensure SKIP_STUDIO_BASE is not inherited from a parent install.ps1 session
os.environ.pop("SKIP_STUDIO_BASE", None)
os.environ["STUDIO_PACKAGE_NAME"] = package
@ -1105,7 +1275,88 @@ def update(
else:
os.environ["STUDIO_LOCAL_INSTALL"] = "0"
os.environ.pop("STUDIO_LOCAL_REPO", None)
_run_setup_script(verbose = verbose)
_release_self_exe_lock_windows()
try:
_run_setup_script(verbose = verbose)
except BaseException:
# Restore unsloth.exe from .deleteme if setup failed before pip
# produced a replacement; otherwise the user has no CLI for recovery.
_restore_self_exe_lock_windows()
raise
# On Windows clear the .deleteme orphan now that pip wrote a fresh
# unsloth.exe; on next update os.replace would overwrite it anyway,
# but leaving a stale binary around invites cross-version restore
# confusion from _restore_self_exe_lock_windows.
_cleanup_self_exe_lock_windows()
# Tauri desktop owns its own bundle entries; skip CLI launcher refresh
# so a Tauri-initiated update doesn't create duplicate shortcuts.
if os.environ.get("UNSLOTH_TAURI_UPDATE") == "1":
if verbose:
typer.echo(" refresh-launcher skipped (Tauri update)")
return
_refresh_desktop_shortcuts(verbose = verbose)
def _release_self_exe_lock_windows() -> None:
"""Rename running unsloth.exe so pip can replace it. setup.ps1 also retries."""
if platform.system() != "Windows":
return
try:
venv_scripts = Path(sys.executable).resolve().parent
except OSError:
return
exe = venv_scripts / "unsloth.exe"
if not exe.exists():
return
stale = exe.with_suffix(".exe.deleteme")
try:
# os.replace is atomic-overwrite on Windows; os.rename would raise
# FileExistsError if a prior aborted update left a .deleteme behind.
os.replace(exe, stale)
except OSError as e:
# Not fatal; setup.ps1 retries from a sibling process.
print(f"[update] could not rename {exe.name} -> {stale.name}: {e}")
def _restore_self_exe_lock_windows() -> None:
"""If setup failed before pip wrote a working unsloth.exe, restore .deleteme."""
if platform.system() != "Windows":
return
try:
venv_scripts = Path(sys.executable).resolve().parent
except OSError:
return
exe = venv_scripts / "unsloth.exe"
stale = exe.with_suffix(".exe.deleteme")
if not stale.exists():
return
# Treat a missing or zero-byte exe as "pip didn't produce a usable
# replacement"; otherwise leave the new binary alone.
if exe.exists():
try:
if exe.stat().st_size > 0:
return
except OSError:
return
try:
os.replace(stale, exe)
except OSError as e:
print(f"[update] could not restore {stale.name} -> {exe.name}: {e}")
def _cleanup_self_exe_lock_windows() -> None:
"""Remove the .deleteme orphan after a successful update on Windows."""
if platform.system() != "Windows":
return
try:
venv_scripts = Path(sys.executable).resolve().parent
except OSError:
return
stale = (venv_scripts / "unsloth.exe").with_suffix(".exe.deleteme")
try:
stale.unlink(missing_ok = True)
except OSError:
pass
# ── unsloth studio reset-password ────────────────────────────────────