From 2e3d0c151c6921a1686b778a76d5cc8d02565935 Mon Sep 17 00:00:00 2001 From: shimmyshimmer <107991372+shimmyshimmer@users.noreply.github.com> Date: Thu, 23 Jul 2026 01:45:20 -0700 Subject: [PATCH 1/3] 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. --- scripts/uninstall.ps1 | 32 ++++++- scripts/uninstall.sh | 39 ++++++++- tests/run_all.sh | 1 + tests/sh/test_uninstall_webview_data.sh | 106 ++++++++++++++++++++++++ 4 files changed, 175 insertions(+), 3 deletions(-) create mode 100755 tests/sh/test_uninstall_webview_data.sh diff --git a/scripts/uninstall.ps1 b/scripts/uninstall.ps1 index 9b6e6ebb86..7fc5efab4c 100644 --- a/scripts/uninstall.ps1 +++ b/scripts/uninstall.ps1 @@ -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 { diff --git a/scripts/uninstall.sh b/scripts/uninstall.sh index 957d2b7af2..303b5225dc 100755 --- a/scripts/uninstall.sh +++ b/scripts/uninstall.sh @@ -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 diff --git a/tests/run_all.sh b/tests/run_all.sh index eaa726f73c..bb62582c61 100755 --- a/tests/run_all.sh +++ b/tests/run_all.sh @@ -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" diff --git a/tests/sh/test_uninstall_webview_data.sh b/tests/sh/test_uninstall_webview_data.sh new file mode 100755 index 0000000000..14898c05c2 --- /dev/null +++ b/tests/sh/test_uninstall_webview_data.sh @@ -0,0 +1,106 @@ +#!/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) 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 + +# run_uninstall : 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 ] From f6b341bc70ea29ead343142a05fb227d140b8245 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Thu, 23 Jul 2026 08:46:44 +0000 Subject: [PATCH 2/3] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- tests/studio/playwright_ui_font_scale.py | 18 ++++++++---------- tests/studio/test_ui_font_scale_contract.py | 12 +++++------- 2 files changed, 13 insertions(+), 17 deletions(-) diff --git a/tests/studio/playwright_ui_font_scale.py b/tests/studio/playwright_ui_font_scale.py index 7dbe36127c..89c7894929 100644 --- a/tests/studio/playwright_ui_font_scale.py +++ b/tests/studio/playwright_ui_font_scale.py @@ -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) diff --git a/tests/studio/test_ui_font_scale_contract.py b/tests/studio/test_ui_font_scale_contract.py index 62bf80e82b..393c60dd6b 100644 --- a/tests/studio/test_ui_font_scale_contract.py +++ b/tests/studio/test_ui_font_scale_contract.py @@ -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("")[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) From 908235741423ea8190f13cab8fc1e6cda0054499 Mon Sep 17 00:00:00 2001 From: shimmyshimmer <107991372+shimmyshimmer@users.noreply.github.com> Date: Thu, 23 Jul 2026 02:28:22 -0700 Subject: [PATCH 3/3] 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. --- tests/sh/test_uninstall_webview_data.sh | 27 +++++++++++++++++++++++-- 1 file changed, 25 insertions(+), 2 deletions(-) diff --git a/tests/sh/test_uninstall_webview_data.sh b/tests/sh/test_uninstall_webview_data.sh index 14898c05c2..099e1037cd 100755 --- a/tests/sh/test_uninstall_webview_data.sh +++ b/tests/sh/test_uninstall_webview_data.sh @@ -7,8 +7,8 @@ # 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) and asserts bundle-id paths are removed -# while unrelated app data survives. +# 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)" @@ -31,6 +31,29 @@ 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" < "$STUB_BIN/$_tool" + chmod +x "$STUB_BIN/$_tool" +done # run_uninstall : run the full script with a stubbed OS. run_uninstall() {