From c980658592fd2a9e40f8b2acc75ac289a32d178b Mon Sep 17 00:00:00 2001 From: Unsloth Date: Wed, 8 Jul 2026 22:15:16 -0700 Subject: [PATCH] Add no-browser launch option for the Studio desktop launcher The generated launchers (launch-studio.sh / launch-studio.ps1) always opened the default browser once the server became healthy. Add a --no-browser launcher flag, the UNSLOTH_STUDIO_NO_BROWSER env var, and a persisted installer preference (studio.conf / baked into the ps1 launcher) with an interactive install prompt. When auto-open is off the launcher still starts or attaches to the server and prints the URL, for users who run Studio as a browser PWA or app window. The --shortcuts-only refresh run by studio update preserves the choice. --- README.md | 2 + install.ps1 | 58 ++++++++- install.sh | 46 +++++++ tests/sh/test_launcher_no_browser.sh | 174 +++++++++++++++++++++++++++ 4 files changed, 277 insertions(+), 3 deletions(-) create mode 100755 tests/sh/test_launcher_no_browser.sh diff --git a/README.md b/README.md index e3fd4e6980..3116f608cc 100644 --- a/README.md +++ b/README.md @@ -86,6 +86,8 @@ unsloth studio -p 8888 ``` For cloud or global access, add `-H 0.0.0.0`. By default, Unsloth is accessible only locally. +Launching from the terminal never opens a browser -- open the printed URL yourself. The desktop shortcut opens your default browser once the server is up; to launch the server without that (e.g. when running Studio as a browser PWA / app window), answer "n" at the installer's browser prompt, pass `--no-browser` to the launcher, or set `UNSLOTH_STUDIO_NO_BROWSER=1`. + To reach Studio over HTTPS, use `unsloth studio --secure`. Studio stays bound to localhost and is reached only through a free Cloudflare tunnel, which publishes it at a public `https://*.trycloudflare.com` URL (it fails closed if the tunnel can't start, so the raw port is never exposed). This makes Studio reachable from the internet, so anyone with the link and API key can use it and run code: keep your API key private (see Remote access below). #### Docker diff --git a/install.ps1 b/install.ps1 index 696f4e613a..a8c52a7b4b 100644 --- a/install.ps1 +++ b/install.ps1 @@ -99,6 +99,8 @@ function Install-UnslothStudio { $TauriMode = $false $SkipTorch = $false $ShortcutsOnly = $false + # Launcher browser auto-open: "" = undecided (prompt, else keep existing, else on). + $OpenBrowserPref = "" $WithLlamaCppDir = "" $argList = $args for ($i = 0; $i -lt $argList.Count; $i++) { @@ -109,6 +111,8 @@ function Install-UnslothStudio { "--verbose" { $script:UnslothVerbose = $true } "-v" { $script:UnslothVerbose = $true } "--shortcuts-only" { $ShortcutsOnly = $true } + "--no-browser" { $OpenBrowserPref = '0' } + "--browser" { $OpenBrowserPref = '1' } "--package" { $i++ if ($i -ge $argList.Count) { @@ -640,6 +644,20 @@ function Install-UnslothStudio { "`$portFile = `$null`n`$mutexName = 'Local\UnslothStudioLauncher'`n" } + # Browser auto-open: explicit installer choice wins; else keep the + # value baked into the existing launcher so `studio update` + # (--shortcuts-only) never resets it. + $_openBrowser = $OpenBrowserPref + if (-not $_openBrowser -and (Test-Path -LiteralPath $launcherPs1)) { + try { + $_prevLauncher = [System.IO.File]::ReadAllText($launcherPs1) + if ($_prevLauncher -match "(?m)^\`$openBrowserDefault = '([01])'") { + $_openBrowser = $Matches[1] + } + } catch {} + } + if ($_openBrowser -ne '0') { $_openBrowser = '1' } + $launcherContent = @" $studioHomeExport`$ErrorActionPreference = 'Stop' `$basePort = 8888 @@ -647,6 +665,30 @@ $studioHomeExport`$ErrorActionPreference = 'Stop' `$timeoutSec = 60 `$pollIntervalMs = 1000 `$_ExpectedStudioRootId = '$_studioRootId' +`$openBrowserDefault = '$_openBrowser' + +# Browser auto-open: disabled by -NoBrowser/--no-browser, the +# UNSLOTH_STUDIO_NO_BROWSER env var, or the baked installer preference. +# When off the server still starts; the URL is printed instead (PWA use). +`$openBrowser = (`$openBrowserDefault -ne '0') +if (`$env:UNSLOTH_STUDIO_NO_BROWSER -and + (`$env:UNSLOTH_STUDIO_NO_BROWSER -notin @('0', 'false', 'no', 'off'))) { + `$openBrowser = `$false +} +foreach (`$_launchArg in `$args) { + if (`$_launchArg -in @('-NoBrowser', '--no-browser')) { `$openBrowser = `$false } + elseif (`$_launchArg -in @('-Browser', '--browser')) { `$openBrowser = `$true } +} + +function Open-StudioUrl { + param([Parameter(Mandatory = `$true)][string]`$Url) + if (`$openBrowser) { + Start-Process `$Url + } else { + # Hidden-window launches have no console; never fail on the echo. + try { Write-Host "Unsloth Studio is running at: `$Url" } catch {} + } +} function Test-StudioHealth { param([Parameter(Mandatory = `$true)][int]`$Port) @@ -743,7 +785,7 @@ function Find-FreeLaunchPort { # If Studio is already healthy on any expected port, just open it and exit. `$existingPort = Find-HealthyStudioPort if (`$existingPort) { - Start-Process "http://localhost:`$existingPort" + Open-StudioUrl "http://localhost:`$existingPort" exit 0 } @@ -760,7 +802,7 @@ try { `$deadline = (Get-Date).AddSeconds(`$timeoutSec) while ((Get-Date) -lt `$deadline) { `$port = Find-HealthyStudioPort - if (`$port) { Start-Process "http://localhost:`$port"; exit 0 } + if (`$port) { Open-StudioUrl "http://localhost:`$port"; exit 0 } Start-Sleep -Milliseconds `$pollIntervalMs } exit 0 @@ -809,7 +851,7 @@ try { [System.IO.File]::WriteAllText(`$portFile, "`$launchPort`n") } catch {} } - Start-Process "http://localhost:`$launchPort" + Open-StudioUrl "http://localhost:`$launchPort" `$browserOpened = `$true break } @@ -2570,6 +2612,16 @@ exit 0 return } + # Ask once (interactive installs only) whether the launcher should open + # the browser after the server is up. Skipped when --no-browser/--browser + # was passed or input is redirected; then an existing choice is kept. + $_browserPromptOk = [Environment]::UserInteractive -and (-not [Console]::IsInputRedirected) + if (-not $OpenBrowserPref -and $_browserPromptOk) { + Write-Host "" + $_browserReply = Read-Host " Open Unsloth Studio in your default browser after launch? [Y/n]" + $OpenBrowserPref = if ($_browserReply -match '^[Nn]') { '0' } else { '1' } + } + # New-StudioShortcuts gates the .lnk shortcuts on env-mode internally. New-StudioShortcuts -UnslothExePath $UnslothExe diff --git a/install.sh b/install.sh index 0acc9ec0be..74e003035a 100755 --- a/install.sh +++ b/install.sh @@ -12,6 +12,7 @@ # curl -fsSL https://unsloth.ai/install.sh | UNSLOTH_PYTHON=3.12 sh # pin Python version # curl -fsSL https://unsloth.ai/install.sh | UNSLOTH_STUDIO_HOME=/abs/path sh # Equivalent flags: ./install.sh --no-torch --python 3.12 (or pipe them: sh -s -- --no-torch) +# ./install.sh --no-browser: launcher starts the server without opening the browser. # # Install dir priority: UNSLOTH_STUDIO_HOME > STUDIO_HOME (alias) > $HOME/.unsloth/studio # @@ -51,6 +52,8 @@ _USER_PYTHON="" _NO_TORCH_FLAG=false _VERBOSE=false _SHORTCUTS_ONLY=false +# Launcher browser auto-open: "" = undecided (prompt, else keep existing, else on). +_STUDIO_OPEN_BROWSER="" _next_is_package=false _next_is_python=false _next_is_llama_cpp_dir=false @@ -82,6 +85,8 @@ for arg in "$@"; do --no-torch) _NO_TORCH_FLAG=true ;; --verbose|-v) _VERBOSE=true ;; --shortcuts-only) _SHORTCUTS_ONLY=true ;; + --no-browser) _STUDIO_OPEN_BROWSER=0 ;; + --browser) _STUDIO_OPEN_BROWSER=1 ;; --with-llama-cpp-dir) _next_is_llama_cpp_dir=true ;; esac done @@ -647,6 +652,21 @@ if [ -z "${UNSLOTH_EXE:-}" ] || [ ! -x "${UNSLOTH_EXE:-}" ]; then exit 1 fi +# Browser auto-open. Priority: --no-browser/--browser arg, then +# UNSLOTH_STUDIO_NO_BROWSER env var, then studio.conf, default on. +# When off the server still starts; the URL is printed instead (PWA use). +OPEN_BROWSER="${STUDIO_OPEN_BROWSER:-1}" +case "${UNSLOTH_STUDIO_NO_BROWSER:-}" in + ''|0|false|FALSE|no|NO|off|OFF) ;; + *) OPEN_BROWSER=0 ;; +esac +for _arg in "$@"; do + case "$_arg" in + --no-browser) OPEN_BROWSER=0 ;; + --browser) OPEN_BROWSER=1 ;; + esac +done + BASE_PORT=8888 MAX_PORT_OFFSET=20 TIMEOUT_SEC=60 @@ -780,6 +800,10 @@ _find_launch_port() { # ── Open browser ── _open_browser() { _url="$1" + if [ "$OPEN_BROWSER" = "0" ]; then + echo "Unsloth Studio is running at: $_url" + return 0 + fi if [ "$(uname)" = "Darwin" ] && command -v open >/dev/null 2>&1; then open "$_url" elif grep -qi microsoft /proc/version 2>/dev/null; then @@ -1011,11 +1035,21 @@ LAUNCHER_EOF chmod +x "$_css_launcher" + # Browser auto-open: explicit installer choice wins; else keep the existing + # studio.conf value so `studio update` (--shortcuts-only) never resets it. + _css_open_browser="${_STUDIO_OPEN_BROWSER:-}" + if [ -z "$_css_open_browser" ] && [ -f "$_css_data_dir/studio.conf" ]; then + _css_open_browser=$(sed -n "s/^STUDIO_OPEN_BROWSER='\([01]\)'\$/\1/p" \ + "$_css_data_dir/studio.conf" 2>/dev/null | head -n 1) + fi + [ "$_css_open_browser" = "0" ] || _css_open_browser=1 + # studio.conf: exe path + (env-mode only) persisted env vars so fresh # shells launch the right install without re-exporting. _css_quoted_exe=$(printf '%s' "$_css_exe" | sed "s/'/'\\\\''/g") { printf '%s\n' "UNSLOTH_EXE='$_css_quoted_exe'" + printf '%s\n' "STUDIO_OPEN_BROWSER='$_css_open_browser'" if [ "$_STUDIO_HOME_REDIRECT" = "env" ]; then # When an override resolves to the legacy default, llama.cpp # still lives at ~/.unsloth/llama.cpp (one shared build). @@ -3160,6 +3194,18 @@ esac # create_studio_shortcuts gates persistent menu shortcuts on env-mode; # launcher + studio.conf + icon are always written. if [ "$TAURI_MODE" != true ]; then + # Ask once (interactive installs only) whether the launcher should open + # the browser after the server is up. Skipped when --no-browser/--browser + # was passed or no TTY; then an existing choice is kept, defaulting to on. + if [ -z "$_STUDIO_OPEN_BROWSER" ] && [ -t 1 ] && [ -r /dev/tty ]; then + echo "" + printf " Open Unsloth Studio in your default browser after launch? [Y/n] " + read -r _browser_reply "\$_css_launcher"/{found=1} found{print} /^LAUNCHER_EOF$/{found=0}' "$INSTALL_SH") +assert_contains \ + "launcher template: --no-browser argument handled" \ + "$_launcher" "--no-browser) OPEN_BROWSER=0" +assert_contains \ + "launcher template: UNSLOTH_STUDIO_NO_BROWSER env var handled" \ + "$_launcher" "UNSLOTH_STUDIO_NO_BROWSER" +assert_contains \ + "launcher template: studio.conf preference is the default" \ + "$_launcher" 'OPEN_BROWSER="${STUDIO_OPEN_BROWSER:-1}"' +assert_contains \ + "launcher template: _open_browser is gated on OPEN_BROWSER" \ + "$_launcher" '[ "$OPEN_BROWSER" = "0" ]' +assert_contains \ + "launcher template: URL still printed when auto-open is off" \ + "$_launcher" "Unsloth Studio is running at:" + +echo "" +echo "=== install.sh installer plumbing ===" + +_installer=$(cat "$INSTALL_SH") +assert_contains \ + "install.sh: --no-browser flag parsed" \ + "$_installer" "--no-browser) _STUDIO_OPEN_BROWSER=0" +assert_contains \ + "install.sh: preference persisted into studio.conf" \ + "$_installer" "STUDIO_OPEN_BROWSER='\$_css_open_browser'" +assert_contains \ + "install.sh: existing studio.conf choice preserved on refresh" \ + "$_installer" "s/^STUDIO_OPEN_BROWSER=" +assert_contains \ + "install.sh: interactive prompt asks about browser auto-open" \ + "$_installer" "Open Unsloth Studio in your default browser after launch?" + +echo "" +echo "=== install.sh _open_browser gating (functional) ===" + +# Extract the _open_browser function from the (column-0) launcher heredoc and +# drive it with stubbed browser openers on PATH. +_fn=$(printf '%s\n' "$_launcher" | awk '/^_open_browser\(\) \{/{found=1} found{print} found && /^\}/{exit}') +if [ -z "$_fn" ]; then + echo " FAIL: could not extract _open_browser from launcher template" + FAIL=$((FAIL + 1)) +else + _tmp=$(mktemp -d) + trap 'rm -rf "$_tmp"' EXIT + for _stub in open xdg-open; do + printf '#!/bin/sh\necho "BROWSER_OPENED:$1" >> "$RECORD"\n' > "$_tmp/$_stub" + chmod +x "$_tmp/$_stub" + done + + # Off: no browser process, URL echoed instead. + _out=$(RECORD="$_tmp/record_off" PATH="$_tmp:$PATH" bash -c \ + "OPEN_BROWSER=0; $_fn; _open_browser http://localhost:9999") + assert_contains \ + "OPEN_BROWSER=0 prints the URL" \ + "$_out" "Unsloth Studio is running at: http://localhost:9999" + if [ -f "$_tmp/record_off" ]; then + echo " FAIL: OPEN_BROWSER=0 still invoked a browser opener" + FAIL=$((FAIL + 1)) + else + echo " PASS: OPEN_BROWSER=0 does not invoke a browser opener" + PASS=$((PASS + 1)) + fi + + # On (default): browser opener invoked with the URL. + RECORD="$_tmp/record_on" PATH="$_tmp:$PATH" bash -c \ + "OPEN_BROWSER=1; $_fn; _open_browser http://localhost:9999" > /dev/null + # xdg-open is backgrounded inside _open_browser; give the stub a moment. + _i=0 + while [ ! -s "$_tmp/record_on" ] && [ "$_i" -lt 20 ]; do + sleep 0.1 + _i=$((_i + 1)) + done + assert_contains \ + "OPEN_BROWSER=1 invokes a browser opener with the URL" \ + "$(cat "$_tmp/record_on" 2>/dev/null)" "BROWSER_OPENED:http://localhost:9999" +fi + +echo "" +echo "=== install.ps1 launcher template ===" + +# grep the file directly: piping the whole installer into grep -q trips +# SIGPIPE noise from echo once grep exits on first match. +assert_file_contains() { + _label="$1"; _file="$2"; _needle="$3" + if grep -qF -- "$_needle" "$_file"; then + echo " PASS: $_label" + PASS=$((PASS + 1)) + else + echo " FAIL: $_label (expected to find '$_needle')" + FAIL=$((FAIL + 1)) + fi +} + +assert_file_contains \ + "install.ps1: --no-browser flag parsed" \ + "$INSTALL_PS1" "\"--no-browser\" { \$OpenBrowserPref = '0' }" +assert_file_contains \ + "install.ps1: preference baked into launch-studio.ps1" \ + "$INSTALL_PS1" "openBrowserDefault = '\$_openBrowser'" +assert_file_contains \ + "install.ps1: launcher honors UNSLOTH_STUDIO_NO_BROWSER" \ + "$INSTALL_PS1" "UNSLOTH_STUDIO_NO_BROWSER" +assert_file_contains \ + "install.ps1: gated helper defined" \ + "$INSTALL_PS1" "function Open-StudioUrl {" +assert_file_contains \ + "install.ps1: interactive prompt asks about browser auto-open" \ + "$INSTALL_PS1" "Open Unsloth Studio in your default browser after launch?" +# All launcher URL opens must route through the gated helper. +_ps1_direct_open=$(grep -cF 'Start-Process "http://localhost:' "$INSTALL_PS1" || true) +if [ "$_ps1_direct_open" -eq 0 ]; then + echo " PASS: no ungated Start-Process http://localhost calls remain" + PASS=$((PASS + 1)) +else + echo " FAIL: $_ps1_direct_open ungated Start-Process http://localhost call(s) remain" + FAIL=$((FAIL + 1)) +fi + +echo "" +echo "=== Results ===" +echo " PASS: $PASS" +echo " FAIL: $FAIL" +if [ "$FAIL" -gt 0 ]; then + echo "FAILED" + exit 1 +fi +echo "ALL PASSED"