Compare commits

...
Sign in to create a new pull request.

3 commits

Author SHA1 Message Date
shimmyshimmer
9082357414 Test: never enter the real WSL cleanup from the webview-data test
Stubbing uname alone is not enough on a WSL host: the script's
'grep -qi microsoft /proc/version' probe still fires and the Linux test
cases would run the real WSL cleanup against the host's /mnt/* shortcuts
and /etc profile. Add a PATH-stubbed grep that fails only the
/proc/version probe (delegating everything else to the real grep via an
absolute path so the stub can never self-exec), plus no-op
powershell.exe and sudo stubs as defense in depth.
2026-07-23 02:28:38 -07:00
pre-commit-ci[bot]
f6b341bc70 [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
2026-07-23 08:46:48 +00:00
shimmyshimmer
2e3d0c151c Uninstall: remove WebView runtime data (stale frontend after reinstall)
The desktop app's WebView creates runtime data keyed by the Tauri bundle
id (ai.unsloth.studio) at first launch, not at install time, so the
uninstallers never removed it. A leftover WebKit/WebView2 cache then
serves a stale frontend bundle to the next install, showing old styles
after a supposedly clean reinstall.

uninstall.sh: remove ~/Library Caches/WebKit/Application Support/
HTTPStorages/cookies/saved state/prefs on macOS and the XDG cache/data/
config/state dirs on Linux; stop the desktop app binary first.

uninstall.ps1: remove LOCALAPPDATA (EBWebView profile) and APPDATA dirs
for the bundle id; stop the desktop app and any msedgewebview2.exe
helper holding handles on them first.

Adds tests/sh/test_uninstall_webview_data.sh running the full script
against a fixture HOME for both OS branches.
2026-07-23 01:45:20 -07:00
6 changed files with 211 additions and 20 deletions

View file

@ -3,8 +3,10 @@
#
# Unsloth Studio uninstaller for Windows PowerShell.
# Stops running servers and removes install dir, launcher data, CLI shim,
# desktop and Start Menu shortcuts, the user PATH entry, and the PathBackup
# registry key. Honors custom roots set via UNSLOTH_STUDIO_HOME / STUDIO_HOME
# desktop and Start Menu shortcuts, the user PATH entry, the PathBackup
# registry key, and WebView2 runtime data keyed by the app bundle id
# (EBWebView caches created at first desktop-app launch, not by install.ps1).
# Honors custom roots set via UNSLOTH_STUDIO_HOME / STUDIO_HOME
# at install time (read back from share\studio.conf).
#
# Usage: irm https://raw.githubusercontent.com/unslothai/unsloth/main/scripts/uninstall.ps1 | iex
@ -398,6 +400,32 @@ function Uninstall-UnslothStudio {
_RemovePath $defaultUnslothHome
}
# WebView2/app runtime data keyed by the Tauri bundle id. Created at first
# desktop-app launch, not by install.ps1. LOCALAPPDATA holds the EBWebView
# profile (a leftover copy serves a stale frontend to the next install);
# APPDATA holds the app config dir.
_Step "Removing WebView caches and app data (ai.unsloth.studio)..."
$bundleId = "ai.unsloth.studio"
$webviewDataDirs = @()
if ($env:LOCALAPPDATA) { $webviewDataDirs += Join-Path $env:LOCALAPPDATA $bundleId }
if ($env:APPDATA) { $webviewDataDirs += Join-Path $env:APPDATA $bundleId }
# Stop the desktop app and any msedgewebview2.exe helper using these dirs
# first; WebView2 keeps open handles that make the delete fail.
try { Stop-Process -Name "unsloth-studio" -Force -ErrorAction SilentlyContinue } catch { }
try {
foreach ($proc in (Get-CimInstance Win32_Process -Filter "Name = 'msedgewebview2.exe'" -ErrorAction SilentlyContinue)) {
$cl = $proc.CommandLine
if (-not $cl) { continue }
foreach ($d in $webviewDataDirs) {
if ($cl -ilike "*$d*") {
try { Stop-Process -Id $proc.ProcessId -Force -ErrorAction SilentlyContinue } catch { }
break
}
}
}
} catch { }
foreach ($d in $webviewDataDirs) { _RemovePath $d }
# ── Remove desktop and Start Menu shortcuts ──
_Step "Removing desktop and Start Menu shortcuts..."
try {

View file

@ -4,7 +4,9 @@
#
# Unsloth Studio uninstaller (macOS / Linux / WSL).
# Stops running servers and removes install dir, launcher data,
# CLI shim, desktop shortcut, .app bundle, and Launch Services entry.
# CLI shim, desktop shortcut, .app bundle, Launch Services entry, and
# WebView runtime data keyed by the app bundle id (WebKit/webkit2gtk
# caches created at first app launch, not by install.sh).
# Honors custom roots set via UNSLOTH_STUDIO_HOME / STUDIO_HOME at
# install time (read back from studio.conf).
#
@ -80,6 +82,13 @@ $_roots_from_conf"
pkill -KILL -f "$_pat" 2>/dev/null || true
done
done
# Tauri desktop app (binary "unsloth-studio"): stop it so its WebView
# helpers can't hold or re-create the runtime caches removed below.
# -x is an exact name match, so the "unsloth" CLI shim is never touched.
pkill -TERM -x unsloth-studio 2>/dev/null || true
sleep 0.5
pkill -KILL -x unsloth-studio 2>/dev/null || true
}
_remove_path() {
@ -261,6 +270,25 @@ case "$_os" in
if [ -x "$_lsr" ]; then
"$_lsr" -u "$HOME/Applications/Unsloth Studio.app" 2>/dev/null || true
fi
# WebView/app runtime data keyed by the bundle id. Created by macOS +
# WKWebView at first app launch, not by install.sh, so it survives an
# uninstall and a leftover WebKit cache then serves a stale frontend
# to the next install.
_bid="ai.unsloth.studio"
echo "Removing WebView caches and app data ($_bid)..."
_remove_path "$HOME/Library/Caches/$_bid"
_remove_path "$HOME/Library/WebKit/$_bid"
_remove_path "$HOME/Library/Application Support/$_bid"
_remove_path "$HOME/Library/HTTPStorages/$_bid"
_remove_path "$HOME/Library/HTTPStorages/$_bid.binarycookies"
_remove_path "$HOME/Library/Cookies/$_bid.binarycookies"
_remove_path "$HOME/Library/Saved Application State/$_bid.savedState"
# Delete via defaults first: cfprefsd caches plists in memory and can
# re-write the file after a bare rm.
if command -v defaults >/dev/null 2>&1; then
defaults delete "$_bid" >/dev/null 2>&1 || true
fi
_remove_path "$HOME/Library/Preferences/$_bid.plist"
;;
Linux)
if [ "$_is_wsl" = "1" ]; then
@ -403,6 +431,15 @@ case "$_os" in
echo " sudo rm -rf /opt/rocm /opt/rocm-* && sudo ldconfig"
fi
fi
# WebView/app runtime data keyed by the Tauri bundle id. Created by
# webkit2gtk at first desktop-app launch, not by install.sh; a
# leftover cache serves a stale frontend to the next install.
_bid="ai.unsloth.studio"
echo "Removing WebView caches and app data ($_bid)..."
_remove_path "${XDG_CACHE_HOME:-$HOME/.cache}/$_bid"
_remove_path "${XDG_DATA_HOME:-$HOME/.local/share}/$_bid"
_remove_path "${XDG_CONFIG_HOME:-$HOME/.config}/$_bid"
_remove_path "${XDG_STATE_HOME:-$HOME/.local/state}/$_bid"
echo "Removing Linux .desktop entry..."
_remove_path "$HOME/.local/share/applications/unsloth-studio.desktop"
if command -v update-desktop-database >/dev/null 2>&1; then

View file

@ -14,6 +14,7 @@ sh "$TESTS_DIR/sh/test_nvcc_meets_llama_minimum.sh"
sh "$TESTS_DIR/sh/test_resolve_cuda_archs.sh"
sh "$TESTS_DIR/sh/test_strixhalo_wsl_reroute.sh"
sh "$TESTS_DIR/sh/test_uninstall_shared_icon.sh"
sh "$TESTS_DIR/sh/test_uninstall_webview_data.sh"
sh "$TESTS_DIR/sh/test_torch_flavor.sh"
sh "$TESTS_DIR/sh/test_redact_install_output.sh"
sh "$TESTS_DIR/sh/test_install_uv_override_space.sh"

View file

@ -0,0 +1,129 @@
#!/bin/bash
# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
# Regression tests for WebView runtime-data cleanup in scripts/uninstall.sh.
#
# WKWebView (macOS) and webkit2gtk (Linux) create data keyed by the bundle id
# at first app launch, not at install time, so the uninstaller used to miss it
# and a leftover cache served a stale frontend to the next install. Runs the
# full script against a fixture HOME (pkill/defaults stubbed via PATH, OS
# branch picked by a stubbed uname, /proc/version WSL probe force-failed) and
# asserts bundle-id paths are removed while unrelated app data survives.
set -e
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
UNINSTALL_SH="$SCRIPT_DIR/../../scripts/uninstall.sh"
BID="ai.unsloth.studio"
PASS=0
FAIL=0
_TMP_ROOT=$(mktemp -d)
trap 'rm -rf "$_TMP_ROOT"' EXIT
assert_gone() { _l="$1"; if [ -e "$2" ]; then echo " FAIL: $_l (still present: $2)"; FAIL=$((FAIL+1)); else echo " PASS: $_l"; PASS=$((PASS+1)); fi; }
assert_present() { _l="$1"; if [ -e "$2" ]; then echo " PASS: $_l"; PASS=$((PASS+1)); else echo " FAIL: $_l (missing: $2)"; FAIL=$((FAIL+1)); fi; }
# Stub out process kills and macOS pref/LaunchServices tools so the script can
# run against a fixture HOME without touching the real system.
STUB_BIN="$_TMP_ROOT/stubbin"
mkdir -p "$STUB_BIN"
for _tool in pkill defaults; do
printf '#!/bin/sh\nexit 0\n' > "$STUB_BIN/$_tool"
chmod +x "$STUB_BIN/$_tool"
done
# Force the non-WSL path: with uname stubbed to Linux on a WSL host, the
# script's `grep -qi microsoft /proc/version` probe would still fire and the
# real WSL cleanup would touch the host's /mnt/* shortcuts and /etc profile.
# Fail that one probe; delegate every other grep call to the real grep.
# REAL_GREP must be an absolute path: a bare "grep" (e.g. from an alias-shaped
# `command -v` result) would resolve back to this stub and self-exec forever.
REAL_GREP=$(command -v grep)
case "$REAL_GREP" in /*) ;; *) REAL_GREP=/usr/bin/grep ;; esac
cat > "$STUB_BIN/grep" <<EOF
#!/bin/sh
for _a in "\$@"; do
[ "\$_a" = "/proc/version" ] && exit 1
done
exec "$REAL_GREP" "\$@"
EOF
chmod +x "$STUB_BIN/grep"
# Belt and braces should the WSL branch ever be entered anyway: powershell.exe
# exiting 0 makes its no-op path taken (and skips the /mnt/* drvfs fallback);
# sudo exiting 0 without running its argv keeps /etc untouched.
for _tool in powershell.exe sudo; do
printf '#!/bin/sh\nexit 0\n' > "$STUB_BIN/$_tool"
chmod +x "$STUB_BIN/$_tool"
done
# run_uninstall <home> <uname_output> : run the full script with a stubbed OS.
run_uninstall() {
printf '#!/bin/sh\necho %s\n' "$2" > "$STUB_BIN/uname"
chmod +x "$STUB_BIN/uname"
env -u UNSLOTH_STUDIO_HOME -u STUDIO_HOME \
-u XDG_CACHE_HOME -u XDG_DATA_HOME -u XDG_CONFIG_HOME -u XDG_STATE_HOME \
HOME="$1" PATH="$STUB_BIN:$PATH" sh "$UNINSTALL_SH" >/dev/null 2>&1
}
# ── 1. macOS: every bundle-id-keyed ~/Library path is removed ──
H=$(mktemp -d -p "$_TMP_ROOT")
mkdir -p "$H/Library/Caches/$BID/WebKit/NetworkCache" \
"$H/Library/WebKit/$BID/WebsiteData/CacheStorage" \
"$H/Library/Application Support/$BID" \
"$H/Library/HTTPStorages/$BID" \
"$H/Library/Saved Application State/$BID.savedState" \
"$H/Library/Preferences" \
"$H/Library/Cookies" \
"$H/Library/Caches/com.other.app"
: > "$H/Library/HTTPStorages/$BID.binarycookies"
: > "$H/Library/Cookies/$BID.binarycookies"
: > "$H/Library/Preferences/$BID.plist"
: > "$H/Library/Caches/$BID/stale-frontend.js"
: > "$H/Library/Caches/com.other.app/keepme"
run_uninstall "$H" Darwin
assert_gone "macOS: Caches/$BID removed" "$H/Library/Caches/$BID"
assert_gone "macOS: WebKit/$BID removed" "$H/Library/WebKit/$BID"
assert_gone "macOS: Application Support/$BID removed" "$H/Library/Application Support/$BID"
assert_gone "macOS: HTTPStorages/$BID removed" "$H/Library/HTTPStorages/$BID"
assert_gone "macOS: HTTPStorages/$BID.binarycookies removed" "$H/Library/HTTPStorages/$BID.binarycookies"
assert_gone "macOS: Cookies/$BID.binarycookies removed" "$H/Library/Cookies/$BID.binarycookies"
assert_gone "macOS: Saved Application State removed" "$H/Library/Saved Application State/$BID.savedState"
assert_gone "macOS: Preferences/$BID.plist removed" "$H/Library/Preferences/$BID.plist"
assert_present "macOS: unrelated app cache kept" "$H/Library/Caches/com.other.app/keepme"
# ── 2. Linux: bundle-id-keyed XDG default paths are removed ──
H=$(mktemp -d -p "$_TMP_ROOT")
mkdir -p "$H/.cache/$BID" "$H/.local/share/$BID" "$H/.config/$BID" \
"$H/.local/state/$BID" "$H/.cache/other.app"
run_uninstall "$H" Linux
assert_gone "linux: ~/.cache/$BID removed" "$H/.cache/$BID"
assert_gone "linux: ~/.local/share/$BID removed" "$H/.local/share/$BID"
assert_gone "linux: ~/.config/$BID removed" "$H/.config/$BID"
assert_gone "linux: ~/.local/state/$BID removed" "$H/.local/state/$BID"
assert_present "linux: unrelated app cache kept" "$H/.cache/other.app"
# ── 3. Linux: XDG_*_HOME overrides are honored ──
H=$(mktemp -d -p "$_TMP_ROOT")
XDG=$(mktemp -d -p "$_TMP_ROOT")
mkdir -p "$XDG/cache/$BID" "$XDG/data/$BID" "$XDG/config/$BID" "$XDG/state/$BID"
printf '#!/bin/sh\necho Linux\n' > "$STUB_BIN/uname"
chmod +x "$STUB_BIN/uname"
env -u UNSLOTH_STUDIO_HOME -u STUDIO_HOME \
XDG_CACHE_HOME="$XDG/cache" XDG_DATA_HOME="$XDG/data" \
XDG_CONFIG_HOME="$XDG/config" XDG_STATE_HOME="$XDG/state" \
HOME="$H" PATH="$STUB_BIN:$PATH" sh "$UNINSTALL_SH" >/dev/null 2>&1
assert_gone "linux: XDG_CACHE_HOME override honored" "$XDG/cache/$BID"
assert_gone "linux: XDG_DATA_HOME override honored" "$XDG/data/$BID"
assert_gone "linux: XDG_CONFIG_HOME override honored" "$XDG/config/$BID"
assert_gone "linux: XDG_STATE_HOME override honored" "$XDG/state/$BID"
# ── 4. Nothing to remove is a clean no-op (fresh HOME, exit 0) ──
H=$(mktemp -d -p "$_TMP_ROOT")
if run_uninstall "$H" Darwin; then
echo " PASS: empty HOME -> no-op exit 0"; PASS=$((PASS+1))
else
echo " FAIL: empty HOME -> nonzero exit"; FAIL=$((FAIL+1))
fi
echo ""
echo "Results: $PASS passed, $FAIL failed"
[ "$FAIL" = 0 ]

View file

@ -38,7 +38,11 @@ def fail(m):
raise AssertionError(f"[font-scale] FAIL: {m}")
def near(a, b, tol = 0.35):
def near(
a,
b,
tol = 0.35,
):
return a is not None and b is not None and abs(a - b) <= tol
@ -86,9 +90,7 @@ def open_appearance(page):
page.wait_for_timeout(700)
if page.get_by_role("dialog").count() == 0:
fail("settings dialog did not open")
page.get_by_role("dialog").get_by_role("button").filter(
has_text = "Appearance"
).first.click()
page.get_by_role("dialog").get_by_role("button").filter(has_text = "Appearance").first.click()
page.wait_for_timeout(600)
@ -153,9 +155,7 @@ def main():
page.wait_for_timeout(400)
step("overflowing select scrolls its Radix viewport")
page.get_by_role("dialog").get_by_role("button").filter(
has_text = "Voice"
).first.click()
page.get_by_role("dialog").get_by_role("button").filter(has_text = "Voice").first.click()
page.wait_for_timeout(600)
page.set_viewport_size({"width": 1440, "height": 480})
page.locator("[aria-label='Dictation language']").click()
@ -194,9 +194,7 @@ def main():
page.wait_for_timeout(400)
step("default restores exactly")
page.get_by_role("dialog").get_by_role("button").filter(
has_text = "Appearance"
).first.click()
page.get_by_role("dialog").get_by_role("button").filter(has_text = "Appearance").first.click()
page.wait_for_timeout(500)
set_input(page, "UI font size", DEFAULT)
final = measure(page)

View file

@ -15,9 +15,7 @@ from pathlib import Path
REPO = Path(__file__).resolve().parents[2]
SRC = REPO / "studio/frontend/src"
INDEX_CSS = (SRC / "index.css").read_text(encoding = "utf-8")
STORE = (SRC / "features/settings/stores/appearance-custom-store.ts").read_text(
encoding = "utf-8"
)
STORE = (SRC / "features/settings/stores/appearance-custom-store.ts").read_text(encoding = "utf-8")
SELECT = (SRC / "components/ui/select.tsx").read_text(encoding = "utf-8")
# Raw numeric fontSize props are only allowed where a scaled stylesheet rule
@ -70,9 +68,7 @@ def test_ui_token_families_exist():
def test_explicit_code_font_size_is_never_multiplied():
match = re.search(
r"html\[data-code-font-size\][^{]*\{([^}]*)\}", INDEX_CSS
)
match = re.search(r"html\[data-code-font-size\][^{]*\{([^}]*)\}", INDEX_CSS)
assert match is not None
body = match.group(1)
assert "var(--custom-code-font-size)" in body
@ -83,7 +79,9 @@ def test_radix_select_viewport_owns_the_scroll_state():
viewport = SELECT[SELECT.index("SelectPrimitive.Viewport") :]
assert "overflow-y-auto" in viewport.split("</SelectPrimitive.Viewport>")[0]
# The rounded surface itself must not scroll (WebKit squares its corners).
content_cls = re.search(r"SelectPrimitive\.Content[\s\S]*?className=\{cn\(\s*\"([^\"]+)\"", SELECT)
content_cls = re.search(
r"SelectPrimitive\.Content[\s\S]*?className=\{cn\(\s*\"([^\"]+)\"", SELECT
)
assert content_cls is not None
assert "overflow-hidden" in content_cls.group(1)
assert "overflow-y-auto" not in content_cls.group(1)