Compare commits
8 commits
main
...
launcher-n
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b869eabe07 | ||
|
|
b9f50ed00c | ||
|
|
86200cb0be | ||
|
|
b03142eac8 | ||
|
|
0d0425c8c4 | ||
|
|
bf3a6f1a51 | ||
|
|
52acbcc250 | ||
|
|
c980658592 |
5 changed files with 521 additions and 6 deletions
3
.github/workflows/studio-backend-ci.yml
vendored
3
.github/workflows/studio-backend-ci.yml
vendored
|
|
@ -232,7 +232,8 @@ jobs:
|
|||
tests/sh/test_torch_constraint.sh \
|
||||
tests/sh/test_torch_flavor.sh \
|
||||
tests/sh/test_with_llama_cpp_dir_flag.sh \
|
||||
tests/sh/test_with_llama_cpp_dir_link_behavior.sh; do
|
||||
tests/sh/test_with_llama_cpp_dir_link_behavior.sh \
|
||||
tests/sh/test_launcher_no_browser.sh; do
|
||||
echo "::group::$s"
|
||||
bash "$s"
|
||||
echo "::endgroup::"
|
||||
|
|
|
|||
|
|
@ -86,6 +86,8 @@ unsloth studio -p 8888
|
|||
```
|
||||
For LAN or cloud access, add `-H 0.0.0.0` (raw port only; add `--cloudflare` for a public URL). 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
|
||||
|
|
|
|||
140
install.ps1
140
install.ps1
|
|
@ -102,6 +102,8 @@ function Install-UnslothStudio {
|
|||
$SkipTorch = $false
|
||||
$SkipAutostart = $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++) {
|
||||
|
|
@ -112,6 +114,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) {
|
||||
|
|
@ -656,6 +660,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
|
||||
|
|
@ -663,6 +681,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)
|
||||
|
|
@ -759,7 +801,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
|
||||
}
|
||||
|
||||
|
|
@ -776,7 +818,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
|
||||
|
|
@ -825,7 +867,7 @@ try {
|
|||
[System.IO.File]::WriteAllText(`$portFile, "`$launchPort`n")
|
||||
} catch {}
|
||||
}
|
||||
Start-Process "http://localhost:`$launchPort"
|
||||
Open-StudioUrl "http://localhost:`$launchPort"
|
||||
`$browserOpened = `$true
|
||||
break
|
||||
}
|
||||
|
|
@ -2586,6 +2628,32 @@ 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.
|
||||
# Enter keeps the choice baked into the existing launcher so a reinstall
|
||||
# that accepts the defaults never flips a saved no-browser preference.
|
||||
$_browserPromptOk = [Environment]::UserInteractive -and (-not [Console]::IsInputRedirected)
|
||||
if (-not $OpenBrowserPref -and $_browserPromptOk) {
|
||||
$_existingPref = ""
|
||||
$_promptLauncher = if ($StudioDataDir) { Join-Path $StudioDataDir "launch-studio.ps1" } else { $null }
|
||||
if ($_promptLauncher -and (Test-Path -LiteralPath $_promptLauncher)) {
|
||||
try {
|
||||
$_prevText = [System.IO.File]::ReadAllText($_promptLauncher)
|
||||
if ($_prevText -match "(?m)^\`$openBrowserDefault = '([01])'") {
|
||||
$_existingPref = $Matches[1]
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
$_browserHint = if ($_existingPref -eq '0') { '[y/N]' } else { '[Y/n]' }
|
||||
Write-Host ""
|
||||
$_browserReply = Read-Host " Open Unsloth Studio in your default browser after launch? $_browserHint"
|
||||
$OpenBrowserPref = if ($_browserReply -match '^[Nn]') { '0' }
|
||||
elseif ($_browserReply -match '^[Yy]') { '1' }
|
||||
elseif ($_existingPref) { $_existingPref }
|
||||
else { '1' }
|
||||
}
|
||||
|
||||
# New-StudioShortcuts gates the .lnk shortcuts on env-mode internally.
|
||||
New-StudioShortcuts -UnslothExePath $UnslothExe
|
||||
|
||||
|
|
@ -2620,11 +2688,75 @@ exit 0
|
|||
# caller explicitly disabled the post-install prompt.
|
||||
# In non-interactive environments (CI, Docker) just print instructions.
|
||||
$IsInteractive = (-not $SkipAutostart) -and [Environment]::UserInteractive -and (-not [Console]::IsInputRedirected)
|
||||
# Select the same bounded free-port range as the generated desktop launcher.
|
||||
# Passing the selected port to both the server and watcher prevents an
|
||||
# existing Studio on 8888 from moving the backend while the watcher stays
|
||||
# behind.
|
||||
function Find-PostInstallStudioPort {
|
||||
param([int]$BasePort = 8888, [int]$MaxPortOffset = 20)
|
||||
$probes = @(
|
||||
@{ Family = [System.Net.Sockets.AddressFamily]::InterNetwork; Host = '127.0.0.1' },
|
||||
@{ Family = [System.Net.Sockets.AddressFamily]::InterNetworkV6; Host = '::1' }
|
||||
)
|
||||
for ($offset = 0; $offset -le $MaxPortOffset; $offset++) {
|
||||
$candidate = $BasePort + $offset
|
||||
$busy = $false
|
||||
foreach ($probe in $probes) {
|
||||
$client = $null
|
||||
try {
|
||||
$client = [System.Net.Sockets.TcpClient]::new($probe.Family)
|
||||
$connect = $client.ConnectAsync($probe.Host, $candidate)
|
||||
if ($connect.Wait(100) -and $client.Connected) {
|
||||
$busy = $true
|
||||
break
|
||||
}
|
||||
} catch {
|
||||
} finally {
|
||||
if ($client) { $client.Dispose() }
|
||||
}
|
||||
}
|
||||
if (-not $busy) { return $candidate }
|
||||
}
|
||||
return $BasePort
|
||||
}
|
||||
|
||||
# Background watcher for the foreground launch below: once the server is
|
||||
# healthy on the selected port, open the browser per the persisted
|
||||
# preference. Guarded by the per-install root id so a different Studio is
|
||||
# never the one opened.
|
||||
$_browserWatch = {
|
||||
param($RootId, $Port)
|
||||
$deadline = (Get-Date).AddSeconds(120)
|
||||
while ((Get-Date) -lt $deadline) {
|
||||
try {
|
||||
$r = Invoke-RestMethod -Uri "http://127.0.0.1:$Port/api/health" -TimeoutSec 1 -Method Get
|
||||
if ($r.service -eq 'Unsloth UI Backend' -and
|
||||
((-not $RootId) -or $r.studio_root_id -eq $RootId)) {
|
||||
Start-Process "http://localhost:$Port"
|
||||
break
|
||||
}
|
||||
} catch {}
|
||||
Start-Sleep -Seconds 1
|
||||
}
|
||||
}
|
||||
if ($IsInteractive) {
|
||||
Write-Host ""
|
||||
$reply = Read-Host " Start Unsloth Studio now? [Y/n]"
|
||||
if ([string]::IsNullOrWhiteSpace($reply) -or $reply -match '^[Yy]') {
|
||||
& $UnslothExe studio -p 8888
|
||||
$_launchPort = Find-PostInstallStudioPort
|
||||
# Open the browser once the server is up, unless opted out. The
|
||||
# server prints its own URL, so no watcher is needed when off.
|
||||
if ($OpenBrowserPref -ne '0') {
|
||||
$_watchRootId = ""
|
||||
$_watchIdFile = Join-Path $StudioHome "share\studio_install_id"
|
||||
if (Test-Path -LiteralPath $_watchIdFile) {
|
||||
try { $_watchRootId = ([System.IO.File]::ReadAllText($_watchIdFile)).Trim() } catch {}
|
||||
}
|
||||
try {
|
||||
$null = Start-Job -ScriptBlock $_browserWatch -ArgumentList @($_watchRootId, $_launchPort)
|
||||
} catch {}
|
||||
}
|
||||
& $UnslothExe studio -p $_launchPort
|
||||
} else {
|
||||
step "launch" "to start later, run:"
|
||||
substep "unsloth studio -p 8888"
|
||||
|
|
|
|||
160
install.sh
160
install.sh
|
|
@ -13,6 +13,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
|
||||
#
|
||||
|
|
@ -53,6 +54,8 @@ _NO_TORCH_FLAG=false
|
|||
_SKIP_AUTOSTART=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
|
||||
|
|
@ -84,6 +87,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
|
||||
|
|
@ -656,6 +661,22 @@ 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-insensitive falsy check, matching the PowerShell launcher's -notin.
|
||||
case "$(printf '%s' "${UNSLOTH_STUDIO_NO_BROWSER:-}" | tr '[:upper:]' '[:lower:]')" in
|
||||
''|0|false|no|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
|
||||
|
|
@ -789,6 +810,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
|
||||
|
|
@ -1020,11 +1045,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).
|
||||
|
|
@ -1407,6 +1442,10 @@ if [ "$_SHORTCUTS_ONLY" = true ]; then
|
|||
fi
|
||||
create_studio_shortcuts "$VENV_ABS_BIN/unsloth" "$OS"
|
||||
fi
|
||||
# Drain piped stdin (curl | sh -s -- --shortcuts-only ...) before this
|
||||
# early exit; otherwise curl dies with EPIPE, prints "curl: (23) Failure
|
||||
# writing output to destination", and fails the whole pipeline.
|
||||
[ ! -t 0 ] && cat > /dev/null 2>&1
|
||||
exit 0
|
||||
fi
|
||||
|
||||
|
|
@ -1640,6 +1679,10 @@ _maybe_reroute_strixhalo_to_2404() {
|
|||
[ -n "$_USER_PYTHON" ] && _rr_args="$_rr_args --python $(_rr_q "$_USER_PYTHON")"
|
||||
[ "$_VERBOSE" = true ] && _rr_args="$_rr_args --verbose"
|
||||
[ "$TAURI_MODE" = true ] && _rr_args="$_rr_args --tauri"
|
||||
# Forward an explicit browser choice; "" (undecided) forwards nothing so
|
||||
# the rerouted install keeps its own default.
|
||||
[ "$_STUDIO_OPEN_BROWSER" = "0" ] && _rr_args="$_rr_args --no-browser"
|
||||
[ "$_STUDIO_OPEN_BROWSER" = "1" ] && _rr_args="$_rr_args --browser"
|
||||
if [ -n "${UNSLOTH_WSL_REROUTE_CMD:-}" ]; then
|
||||
_rr_cmd="$UNSLOTH_WSL_REROUTE_CMD" # user took full control
|
||||
elif [ -n "$_rr_args" ]; then
|
||||
|
|
@ -3170,6 +3213,31 @@ 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.
|
||||
# Enter keeps the choice persisted in studio.conf so a reinstall that
|
||||
# accepts the defaults never flips a saved no-browser preference.
|
||||
if [ -z "$_STUDIO_OPEN_BROWSER" ] && [ -t 1 ] && [ -r /dev/tty ]; then
|
||||
_existing_open_browser=""
|
||||
if [ -f "$DATA_DIR/studio.conf" ]; then
|
||||
_existing_open_browser=$(sed -n "s/^STUDIO_OPEN_BROWSER='\([01]\)'\$/\1/p" \
|
||||
"$DATA_DIR/studio.conf" 2>/dev/null | head -n 1)
|
||||
fi
|
||||
if [ "$_existing_open_browser" = "0" ]; then
|
||||
_browser_hint="[y/N]"
|
||||
else
|
||||
_browser_hint="[Y/n]"
|
||||
fi
|
||||
echo ""
|
||||
printf " Open Unsloth Studio in your default browser after launch? %s " "$_browser_hint"
|
||||
read -r _browser_reply </dev/tty || _browser_reply=""
|
||||
case "$_browser_reply" in
|
||||
[nN]*) _STUDIO_OPEN_BROWSER=0 ;;
|
||||
[yY]*) _STUDIO_OPEN_BROWSER=1 ;;
|
||||
*) _STUDIO_OPEN_BROWSER="${_existing_open_browser:-1}" ;;
|
||||
esac
|
||||
fi
|
||||
create_studio_shortcuts "$VENV_ABS_BIN/unsloth" "$OS"
|
||||
fi
|
||||
|
||||
|
|
@ -3227,6 +3295,87 @@ printf " ${C_TITLE}%s${C_RST}\n" "Unsloth Studio installed!"
|
|||
printf " ${C_DIM}%s${C_RST}\n" "$RULE"
|
||||
echo ""
|
||||
|
||||
# Select the same bounded free-port range as the generated desktop launcher.
|
||||
# Passing the selected port to both the server and watcher prevents an existing
|
||||
# Studio on 8888 from making the backend move while the watcher stays behind.
|
||||
_find_post_install_port() {
|
||||
_pifp_base="${1:-8888}"
|
||||
_pifp_max_offset="${2:-20}"
|
||||
"$VENV_DIR/bin/python" - "$_pifp_base" "$_pifp_max_offset" <<'PY'
|
||||
import socket
|
||||
import sys
|
||||
|
||||
base = int(sys.argv[1])
|
||||
max_offset = int(sys.argv[2])
|
||||
|
||||
def is_free(port):
|
||||
endpoints = (
|
||||
(socket.AF_INET, ("127.0.0.1", port)),
|
||||
(socket.AF_INET6, ("::1", port, 0, 0)),
|
||||
)
|
||||
for family, address in endpoints:
|
||||
try:
|
||||
with socket.socket(family, socket.SOCK_STREAM) as probe:
|
||||
probe.settimeout(0.1)
|
||||
if probe.connect_ex(address) == 0:
|
||||
return False
|
||||
except OSError:
|
||||
# IPv6 can be unavailable; match the backend's loopback probe.
|
||||
continue
|
||||
return True
|
||||
|
||||
for offset in range(max_offset + 1):
|
||||
candidate = base + offset
|
||||
if is_free(candidate):
|
||||
print(candidate)
|
||||
raise SystemExit(0)
|
||||
raise SystemExit(1)
|
||||
PY
|
||||
}
|
||||
|
||||
# Background watcher for the post-install foreground launch below: once the
|
||||
# server is healthy on the selected port, open the browser per the persisted
|
||||
# preference. Guarded by the per-install root id so a different Studio is never
|
||||
# the one opened.
|
||||
_post_install_browser_watch() {
|
||||
_pibw_port="$1"
|
||||
_pibw_url="http://localhost:$_pibw_port"
|
||||
_pibw_id=$(cat "$STUDIO_HOME/share/studio_install_id" 2>/dev/null || true)
|
||||
(
|
||||
_pibw_deadline=$(($(date +%s) + 120))
|
||||
while [ "$(date +%s)" -lt "$_pibw_deadline" ]; do
|
||||
_pibw_resp=$(curl -fsS --max-time 1 "http://127.0.0.1:$_pibw_port/api/health" 2>/dev/null \
|
||||
|| wget -qO- --timeout=1 "http://127.0.0.1:$_pibw_port/api/health" 2>/dev/null \
|
||||
|| true)
|
||||
case "$_pibw_resp" in
|
||||
*'"Unsloth UI Backend"'*)
|
||||
if [ -n "$_pibw_id" ]; then
|
||||
case "$_pibw_resp" in
|
||||
*"\"studio_root_id\":\"$_pibw_id\""*|*"\"studio_root_id\": \"$_pibw_id\""*) ;;
|
||||
*) sleep 1; continue ;;
|
||||
esac
|
||||
fi
|
||||
if [ "$(uname)" = "Darwin" ] && command -v open >/dev/null 2>&1; then
|
||||
open "$_pibw_url" 2>/dev/null
|
||||
elif grep -qi microsoft /proc/version 2>/dev/null; then
|
||||
if command -v powershell.exe >/dev/null 2>&1; then
|
||||
powershell.exe -NoProfile -Command "Start-Process '$_pibw_url'" >/dev/null 2>&1
|
||||
elif command -v cmd.exe >/dev/null 2>&1; then
|
||||
cmd.exe /c start "" "$_pibw_url" >/dev/null 2>&1
|
||||
elif command -v xdg-open >/dev/null 2>&1; then
|
||||
xdg-open "$_pibw_url" >/dev/null 2>&1
|
||||
fi
|
||||
elif command -v xdg-open >/dev/null 2>&1; then
|
||||
xdg-open "$_pibw_url" >/dev/null 2>&1
|
||||
fi
|
||||
exit 0
|
||||
;;
|
||||
esac
|
||||
sleep 1
|
||||
done
|
||||
) &
|
||||
}
|
||||
|
||||
# In interactive terminals, ask the user before starting Studio unless the
|
||||
# caller explicitly disabled the post-install prompt.
|
||||
# In non-interactive environments (Docker, CI, cloud-init) just print instructions.
|
||||
|
|
@ -3242,6 +3391,15 @@ if [ "$_SKIP_AUTOSTART" != true ] && [ -t 1 ]; then
|
|||
case "${_reply:-y}" in
|
||||
[Yy]*|"")
|
||||
step "launch" "starting Unsloth Studio..."
|
||||
_post_install_port=$(_find_post_install_port 8888 20) || _post_install_port=8888
|
||||
case "$_post_install_port" in
|
||||
''|*[!0-9]*) _post_install_port=8888 ;;
|
||||
esac
|
||||
# Open the browser once the server is up, unless opted out. The
|
||||
# server prints its own URL, so no watcher is needed when off.
|
||||
if [ "${_STUDIO_OPEN_BROWSER:-1}" != "0" ]; then
|
||||
_post_install_browser_watch "$_post_install_port"
|
||||
fi
|
||||
# Detach stdin from the `curl | sh` pipe: as a foreground server the
|
||||
# studio would otherwise drain the rest of this piped script, leaving
|
||||
# the shell to die parsing the now-truncated tail (`unexpected fi`).
|
||||
|
|
@ -3250,7 +3408,7 @@ if [ "$_SKIP_AUTOSTART" != true ] && [ -t 1 ]; then
|
|||
trap '' INT
|
||||
# `|| ...`: capture the exit code without set -e aborting first.
|
||||
_LAUNCH_EXIT=0
|
||||
(trap - INT; exec "$VENV_DIR/bin/unsloth" studio -p 8888 </dev/null) || _LAUNCH_EXIT=$?
|
||||
(trap - INT; exec "$VENV_DIR/bin/unsloth" studio -p "$_post_install_port" </dev/null) || _LAUNCH_EXIT=$?
|
||||
if [ "$_LAUNCH_EXIT" -ne 0 ] && [ "$_MIGRATED" = true ]; then
|
||||
echo ""
|
||||
echo "⚠️ Unsloth Studio failed to start after migration."
|
||||
|
|
|
|||
222
tests/sh/test_launcher_no_browser.sh
Executable file
222
tests/sh/test_launcher_no_browser.sh
Executable file
|
|
@ -0,0 +1,222 @@
|
|||
#!/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
|
||||
# The generated desktop launchers must support suppressing the automatic
|
||||
# default-browser open after the server becomes healthy (PWA users run Studio
|
||||
# in a browser-app window, not the OS default browser). Covered surface:
|
||||
# - launch-studio.sh: --no-browser flag, UNSLOTH_STUDIO_NO_BROWSER env var,
|
||||
# persisted STUDIO_OPEN_BROWSER from studio.conf; prints the URL when off.
|
||||
# - install.sh: --no-browser flag, interactive prompt, studio.conf persistence
|
||||
# that survives the --shortcuts-only refresh run by `unsloth studio update`.
|
||||
# - install.ps1: same feature baked into launch-studio.ps1 (Open-StudioUrl).
|
||||
set -e
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
INSTALL_SH="$SCRIPT_DIR/../../install.sh"
|
||||
INSTALL_PS1="$SCRIPT_DIR/../../install.ps1"
|
||||
PASS=0
|
||||
FAIL=0
|
||||
|
||||
# Here-strings, not `echo | grep -q`: grep exiting on first match SIGPIPEs
|
||||
# the echo on large haystacks, spamming "write error: Broken pipe" in CI logs.
|
||||
assert_contains() {
|
||||
_label="$1"; _haystack="$2"; _needle="$3"
|
||||
if grep -qF -- "$_needle" <<< "$_haystack"; then
|
||||
echo " PASS: $_label"
|
||||
PASS=$((PASS + 1))
|
||||
else
|
||||
echo " FAIL: $_label (expected to find '$_needle')"
|
||||
FAIL=$((FAIL + 1))
|
||||
fi
|
||||
}
|
||||
|
||||
assert_not_contains() {
|
||||
_label="$1"; _haystack="$2"; _needle="$3"
|
||||
if grep -qF -- "$_needle" <<< "$_haystack"; then
|
||||
echo " FAIL: $_label (found '$_needle' but should not)"
|
||||
FAIL=$((FAIL + 1))
|
||||
else
|
||||
echo " PASS: $_label"
|
||||
PASS=$((PASS + 1))
|
||||
fi
|
||||
}
|
||||
|
||||
echo ""
|
||||
echo "=== install.sh launcher template ==="
|
||||
|
||||
# Extract the heredoc that generates ~/.local/share/unsloth/launch-studio.sh.
|
||||
_launcher=$(awk '/cat > "\$_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"
|
||||
# Mixed-case falsy values (False, Off) must not disable, matching the
|
||||
# PowerShell launcher's case-insensitive -notin.
|
||||
assert_contains \
|
||||
"launcher template: env var check is case-insensitive" \
|
||||
"$_launcher" "tr '[:upper:]' '[:lower:]'"
|
||||
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?"
|
||||
# A reinstall that accepts the prompt default must keep the saved choice.
|
||||
assert_contains \
|
||||
"install.sh: prompt Enter keeps the persisted preference" \
|
||||
"$_installer" '*) _STUDIO_OPEN_BROWSER="${_existing_open_browser:-1}"'
|
||||
# The WSL Strix Halo reroute must forward an explicit browser choice.
|
||||
assert_contains \
|
||||
"install.sh: reroute forwards --no-browser" \
|
||||
"$_installer" '[ "$_STUDIO_OPEN_BROWSER" = "0" ] && _rr_args="$_rr_args --no-browser"'
|
||||
# The post-install foreground launch honors the preference too.
|
||||
assert_contains \
|
||||
"install.sh: post-install launch opens browser via gated watcher" \
|
||||
"$_installer" '_post_install_browser_watch "$_post_install_port"'
|
||||
assert_contains \
|
||||
"install.sh: post-install launch selects a free port" \
|
||||
"$_installer" "_find_post_install_port()"
|
||||
assert_contains \
|
||||
"install.sh: server uses the watcher-selected port" \
|
||||
"$_installer" 'studio -p "$_post_install_port"'
|
||||
# The --shortcuts-only early exit must not EPIPE a curl | sh pipeline.
|
||||
assert_contains \
|
||||
"install.sh: shortcuts-only exit drains piped stdin" \
|
||||
"$_installer" '[ ! -t 0 ] && cat > /dev/null'
|
||||
|
||||
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?"
|
||||
assert_file_contains \
|
||||
"install.ps1: prompt Enter keeps the baked preference" \
|
||||
"$INSTALL_PS1" 'elseif ($_existingPref) { $_existingPref }'
|
||||
assert_file_contains \
|
||||
"install.ps1: post-install launch opens browser via gated watcher" \
|
||||
"$INSTALL_PS1" 'Start-Job -ScriptBlock $_browserWatch'
|
||||
assert_file_contains \
|
||||
"install.ps1: post-install launch selects a free port" \
|
||||
"$INSTALL_PS1" 'function Find-PostInstallStudioPort {'
|
||||
assert_file_contains \
|
||||
"install.ps1: watcher receives the selected port" \
|
||||
"$INSTALL_PS1" 'ArgumentList @($_watchRootId, $_launchPort)'
|
||||
assert_file_contains \
|
||||
"install.ps1: server uses the watcher-selected port" \
|
||||
"$INSTALL_PS1" '& $UnslothExe studio -p $_launchPort'
|
||||
# All launcher URL opens must route through the gated helper. The one
|
||||
# allowed direct call is inside the post-install $_browserWatch scriptblock,
|
||||
# whose Start-Job call site is itself gated on the preference.
|
||||
_ps1_direct_open=$(grep -cE 'Start-Process "http://localhost:' "$INSTALL_PS1" || true)
|
||||
_ps1_watch_open=$(awk '/\$_browserWatch = \{/{f=1} f && /^ \}$/{exit} f' "$INSTALL_PS1" \
|
||||
| grep -cE 'Start-Process "http://localhost:' || true)
|
||||
if [ "$_ps1_direct_open" -eq 1 ] && [ "$_ps1_watch_open" -eq 1 ]; then
|
||||
echo " PASS: only the gated browser watcher opens a URL directly"
|
||||
PASS=$((PASS + 1))
|
||||
else
|
||||
echo " FAIL: found $_ps1_direct_open direct URL opens ($_ps1_watch_open in the watcher); all others must route through Open-StudioUrl"
|
||||
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"
|
||||
Loading…
Add table
Add a link
Reference in a new issue