diff --git a/studio/setup.ps1 b/studio/setup.ps1 index c6932121c9..8e15976e8c 100644 --- a/studio/setup.ps1 +++ b/studio/setup.ps1 @@ -1129,6 +1129,25 @@ if ($script:StudioVtOk -and -not $env:NO_COLOR) { Write-Host " $Rule" -ForegroundColor DarkGray } +# WebView2 caches keyed by the app bundle id hold copies of the previous +# frontend and can keep serving it after an update (old styles linger). +# Cache-only paths: Local Storage, IndexedDB, cookies, settings, models, +# and the studio database are untouched. +if ($env:LOCALAPPDATA) { + $wvDefault = Join-Path $env:LOCALAPPDATA "ai.unsloth.studio\EBWebView\Default" + $wvCleared = $false + foreach ($wvSub in @("Cache", "Code Cache", "GPUCache", "Service Worker")) { + $wvPath = Join-Path $wvDefault $wvSub + if (Test-Path -LiteralPath $wvPath) { + try { + Remove-Item -LiteralPath $wvPath -Recurse -Force -ErrorAction Stop + $wvCleared = $true + } catch { } + } + } + if ($wvCleared) { substep "cleared stale WebView caches (ai.unsloth.studio); settings and data kept" } +} + # Back up User PATH under HKCU\Software\Unsloth before any modifications. try { $envKey = [Microsoft.Win32.Registry]::CurrentUser.OpenSubKey('Environment', $false) diff --git a/studio/setup.sh b/studio/setup.sh index 3c97f77065..13d654cdd6 100755 --- a/studio/setup.sh +++ b/studio/setup.sh @@ -475,6 +475,50 @@ rm -rf "$REPO_ROOT/unsloth_compiled_cache" rm -rf "$SCRIPT_DIR/backend/unsloth_compiled_cache" rm -rf "$SCRIPT_DIR/tmp/unsloth_compiled_cache" +# WebView caches keyed by the app bundle id hold copies of the previous +# frontend and can keep serving it after an update (old styles linger). +# Cache-only paths: LocalStorage, IndexedDB, cookies, settings, models, +# and the studio database are untouched. +_clear_webview_caches() { + _wvc_bid="ai.unsloth.studio" + _wvc_paths=() + case "$(uname -s 2>/dev/null)" in + Darwin) + _wvc_paths=( + "$HOME/Library/Caches/$_wvc_bid" + "$HOME/Library/WebKit/$_wvc_bid/WebsiteData/CacheStorage" + "$HOME/Library/WebKit/$_wvc_bid/WebsiteData/ServiceWorkers" + "$HOME/Library/WebKit/$_wvc_bid/WebsiteData/DiskCache" + ) + ;; + Linux) + # wry keys the WebKitGTK base-cache dir to the app DATA dir (same + # as base-data), so cache subdirs also live under + # ~/.local/share/. Clear only the cache-typed subdirs there; + # sibling localstorage/, indexeddb/, and cookies stay. + _wvc_data="${XDG_DATA_HOME:-$HOME/.local/share}/$_wvc_bid" + _wvc_paths=( + "${XDG_CACHE_HOME:-$HOME/.cache}/$_wvc_bid" + "$_wvc_data/WebKitCache" + "$_wvc_data/CacheStorage" + "$_wvc_data/serviceworkers" + "$_wvc_data/ServiceWorkers" + ) + ;; + *) return 0 ;; + esac + _wvc_cleared=false + for _wvc_p in "${_wvc_paths[@]}"; do + [ -e "$_wvc_p" ] || continue + rm -rf "$_wvc_p" 2>/dev/null && _wvc_cleared=true || true + done + if [ "$_wvc_cleared" = true ]; then + substep "cleared stale WebView caches ($_wvc_bid); settings and data kept" + fi + return 0 +} +_clear_webview_caches + # ── Detect Colab ── IS_COLAB=false keynames=$'\n'$(printenv | cut -d= -f1) diff --git a/studio/src-tauri/src/main.rs b/studio/src-tauri/src/main.rs index 405b390177..e422124f0c 100644 --- a/studio/src-tauri/src/main.rs +++ b/studio/src-tauri/src/main.rs @@ -161,6 +161,64 @@ fn setup_tray(app: &tauri::App) -> Result<(), Box> { Ok(()) } +// Clear WebView caches before the webview initializes. An in-app update runs +// setup.sh/setup.ps1 while the old WebView still holds these files (its clear +// silently fails), so without this a relaunch can serve the previous frontend +// from cache. Cache-only paths; LocalStorage, IndexedDB, cookies, and app +// data are kept. Mirrors setup.sh _clear_webview_caches / setup.ps1. +fn clear_webview_caches(bundle_id: &str) { + use std::path::PathBuf; + let mut paths: Vec = Vec::new(); + #[cfg(target_os = "windows")] + if let Ok(local) = std::env::var("LOCALAPPDATA") { + if !local.is_empty() { + let profile = PathBuf::from(local) + .join(bundle_id) + .join("EBWebView") + .join("Default"); + for sub in ["Cache", "Code Cache", "GPUCache", "Service Worker"] { + paths.push(profile.join(sub)); + } + } + } + #[cfg(target_os = "macos")] + if let Ok(home) = std::env::var("HOME") { + if !home.is_empty() { + let home = PathBuf::from(home); + paths.push(home.join("Library/Caches").join(bundle_id)); + let data = home.join("Library/WebKit").join(bundle_id).join("WebsiteData"); + for sub in ["CacheStorage", "ServiceWorkers", "DiskCache"] { + paths.push(data.join(sub)); + } + } + } + #[cfg(target_os = "linux")] + if let Ok(home) = std::env::var("HOME") { + if !home.is_empty() { + let home = PathBuf::from(home); + let cache = std::env::var("XDG_CACHE_HOME") + .ok() + .filter(|v| !v.is_empty()) + .map(PathBuf::from) + .unwrap_or_else(|| home.join(".cache")); + paths.push(cache.join(bundle_id)); + // wry keys the WebKitGTK base-cache dir to the app data dir. + let data = std::env::var("XDG_DATA_HOME") + .ok() + .filter(|v| !v.is_empty()) + .map(PathBuf::from) + .unwrap_or_else(|| home.join(".local/share")) + .join(bundle_id); + for sub in ["WebKitCache", "CacheStorage", "serviceworkers", "ServiceWorkers"] { + paths.push(data.join(sub)); + } + } + } + for p in paths { + let _ = fs::remove_dir_all(&p); + } +} + fn main() { // Fix PATH for GUI apps (macOS .app bundles, Linux AppImage, Windows) // GUI apps don't inherit shell dotfile PATH — this spawns the user's @@ -171,6 +229,11 @@ fn main() { info!("Unsloth Studio desktop app starting"); windows_job::initialize(); + // Must run before the Builder: the config-defined window (and its + // WebView, which locks these files) exists by the time setup hooks run. + let context = tauri::generate_context!(); + clear_webview_caches(&context.config().identifier); + tauri::Builder::default() .plugin(tauri_plugin_single_instance::init(|app, _args, _cwd| { if let Some(window) = app.get_webview_window("main") { @@ -247,7 +310,7 @@ fn main() { api.prevent_close(); } }) - .build(tauri::generate_context!()) + .build(context) .expect("error while building tauri application") .run(|app, event| { if let tauri::RunEvent::Exit = event { diff --git a/tests/run_all.sh b/tests/run_all.sh index eaa726f73c..147aaf1645 100755 --- a/tests/run_all.sh +++ b/tests/run_all.sh @@ -14,6 +14,8 @@ 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" +# bash, not sh: the extracted setup.sh function uses bash arrays (dash chokes). +bash "$TESTS_DIR/sh/test_setup_webview_cache_clear.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_setup_webview_cache_clear.sh b/tests/sh/test_setup_webview_cache_clear.sh new file mode 100755 index 0000000000..19a3ee0064 --- /dev/null +++ b/tests/sh/test_setup_webview_cache_clear.sh @@ -0,0 +1,103 @@ +#!/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 +# Unit tests for _clear_webview_caches() from studio/setup.sh. +# +# The WebView caches keyed by the app bundle id (ai.unsloth.studio) hold copies +# of the previous frontend, so an install/update must clear them or the app can +# keep rendering old styles. Clearing must be cache-only: LocalStorage, +# IndexedDB, app data, and unrelated apps' caches stay intact. +# +# Follows the extract-via-sed pattern of test_uninstall_shared_icon.sh; uname +# is overridden per test with a shell function to select the OS branch. +# shellcheck disable=SC2329 # uname stubs are invoked inside the extracted function +set -e + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +SETUP_SH="$SCRIPT_DIR/../../studio/setup.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; } + +# Extract just the function definition (top-level, closes at column 0). +FUNC_FILE=$(mktemp -p "$_TMP_ROOT") +sed -n '/^_clear_webview_caches() {/,/^}/p' "$SETUP_SH" > "$FUNC_FILE" +# shellcheck disable=SC1090 +. "$FUNC_FILE" +substep() { :; } # stub the setup.sh logger + +# ── 1. macOS: cache paths removed, user-facing storage kept ── +H=$(mktemp -d -p "$_TMP_ROOT") +mkdir -p "$H/Library/Caches/$BID/WebKit/NetworkCache" \ + "$H/Library/WebKit/$BID/WebsiteData/CacheStorage" \ + "$H/Library/WebKit/$BID/WebsiteData/ServiceWorkers" \ + "$H/Library/WebKit/$BID/WebsiteData/DiskCache" \ + "$H/Library/WebKit/$BID/WebsiteData/LocalStorage" \ + "$H/Library/WebKit/$BID/WebsiteData/IndexedDB" \ + "$H/Library/Application Support/$BID" \ + "$H/Library/Caches/com.other.app" +uname() { echo Darwin; } +HOME="$H" _clear_webview_caches +assert_gone "macOS: Caches/$BID removed" "$H/Library/Caches/$BID" +assert_gone "macOS: WebsiteData/CacheStorage removed" "$H/Library/WebKit/$BID/WebsiteData/CacheStorage" +assert_gone "macOS: WebsiteData/ServiceWorkers removed" "$H/Library/WebKit/$BID/WebsiteData/ServiceWorkers" +assert_gone "macOS: WebsiteData/DiskCache removed" "$H/Library/WebKit/$BID/WebsiteData/DiskCache" +assert_present "macOS: LocalStorage kept" "$H/Library/WebKit/$BID/WebsiteData/LocalStorage" +assert_present "macOS: IndexedDB kept" "$H/Library/WebKit/$BID/WebsiteData/IndexedDB" +assert_present "macOS: Application Support kept" "$H/Library/Application Support/$BID" +assert_present "macOS: unrelated app cache kept" "$H/Library/Caches/com.other.app" + +# ── 2. Linux: cache paths removed (XDG cache dir AND the cache subdirs wry +# keys to the app data dir), user-facing storage kept ── +H=$(mktemp -d -p "$_TMP_ROOT") +D="$H/.local/share/$BID" +mkdir -p "$H/.cache/$BID" "$D/WebKitCache" "$D/CacheStorage" "$D/serviceworkers" \ + "$D/localstorage" "$D/indexeddb" "$H/.config/$BID" "$H/.cache/other.app" +: > "$D/cookies.sqlite" +uname() { echo Linux; } +HOME="$H" XDG_CACHE_HOME="" XDG_DATA_HOME="" _clear_webview_caches +assert_gone "linux: ~/.cache/$BID removed" "$H/.cache/$BID" +assert_gone "linux: data-dir WebKitCache removed" "$D/WebKitCache" +assert_gone "linux: data-dir CacheStorage removed" "$D/CacheStorage" +assert_gone "linux: data-dir serviceworkers removed" "$D/serviceworkers" +assert_present "linux: localstorage kept" "$D/localstorage" +assert_present "linux: indexeddb kept" "$D/indexeddb" +assert_present "linux: cookies kept" "$D/cookies.sqlite" +assert_present "linux: ~/.config/$BID kept" "$H/.config/$BID" +assert_present "linux: unrelated app cache kept" "$H/.cache/other.app" + +# ── 3. Linux: XDG_CACHE_HOME / XDG_DATA_HOME overrides honored ── +H=$(mktemp -d -p "$_TMP_ROOT") +XDG=$(mktemp -d -p "$_TMP_ROOT") +mkdir -p "$XDG/cache/$BID" "$XDG/data/$BID/WebKitCache" "$XDG/data/$BID/localstorage" "$H/.cache/$BID" +HOME="$H" XDG_CACHE_HOME="$XDG/cache" XDG_DATA_HOME="$XDG/data" _clear_webview_caches +assert_gone "linux: XDG_CACHE_HOME/$BID removed" "$XDG/cache/$BID" +assert_gone "linux: XDG_DATA_HOME WebKitCache removed" "$XDG/data/$BID/WebKitCache" +assert_present "linux: XDG_DATA_HOME localstorage kept" "$XDG/data/$BID/localstorage" +assert_present "linux: ~/.cache/$BID kept under override" "$H/.cache/$BID" + +# ── 4. Nothing to clear is a clean no-op ── +H=$(mktemp -d -p "$_TMP_ROOT") +uname() { echo Darwin; } +if HOME="$H" _clear_webview_caches; then + echo " PASS: empty HOME -> no-op exit 0"; PASS=$((PASS+1)) +else + echo " FAIL: empty HOME -> nonzero exit"; FAIL=$((FAIL+1)) +fi + +# ── 5. Unknown OS is a no-op ── +H=$(mktemp -d -p "$_TMP_ROOT") +mkdir -p "$H/Library/Caches/$BID" +uname() { echo SunOS; } +HOME="$H" _clear_webview_caches +assert_present "unknown OS: nothing removed" "$H/Library/Caches/$BID" + +echo "" +echo "Results: $PASS passed, $FAIL failed" +[ "$FAIL" = 0 ] 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)