studio: windows desktop shortcut launcher (#4558)

* feat(windows): add Studio desktop/Start shortcuts with health-check launcher

* chore(windows): bundle sloth.ico and set shortcut icons when valid

* chore(windows):add images/sloth.ico

* fix(windows): guard PSScriptRoot for Studio shortcut icon in iex installs

* fix(install): high-DPI sloth.ico and relocate to studio/frontend/publi

* chore(studio): update sloth.ico for clearer desktop and shell icons

* chore(studio): use unsloth.ico for Studio shortcut icon

* feat(windows): improve Studio shortcut launcher (fast health + browser UX)

* fix(windows): stable unsloth.ico URL and Unicode-safe Studio launcher scripts

* fix(windows): escape $ in exe path and write launcher UTF-8 with BOM

* fix(windows): skip shortcuts when Desktop or APPDATA paths are missing

* fix(install): log shortcut/icon/port failures and warn early on missing paths

* fix(install): guard missing LOCALAPPDATA before shortcut paths

* fix(install): harden New-StudioShortcuts and improve success messaging

* fix(install): include port 8908 in studio health check

* fix(install): fix launch-studio.ps1  quoting

* Fix launcher edge cases and normalize indentation in install.ps1

- Handle silent timeout: show a message when Studio is still starting
  but did not become healthy within the timeout, instead of exiting
  with no feedback
- Add -NoProfile to the visible PowerShell terminal launch so the
  user profile cannot hang or error before Studio runs
- Add a named mutex (Local\UnslothStudioLauncher) to prevent
  double-click from spawning duplicate terminals; second instance
  polls for health and opens the browser when ready
- Normalize indentation inside New-StudioShortcuts outer try block
  from mixed 8/12-space to consistent 12-space

* Simplify Get-CandidatePorts port dedup with Sort-Object -Unique

Replace the foreach/-notcontains loop with a single pipeline:
  $ports = (@($basePort) + $listening) | Sort-Object -Unique

* Harden health probe and handle abandoned mutex in launcher

- Test-StudioHealth now checks resp.service == 'Unsloth UI Backend' to
  avoid fingerprinting collisions with other local services on the same
  port range.
- Wrap the mutex WaitOne(0) call in a try/catch for
  AbandonedMutexException so the launcher recovers gracefully when a
  previous instance was killed while holding the mutex.

---------

Co-authored-by: Daniel Han <danielhanchen@gmail.com>
This commit is contained in:
Lee Jackson 2026-03-25 06:41:02 +00:00 committed by GitHub
parent 9b989ee898
commit 557743f027
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 271 additions and 0 deletions

View file

@ -31,6 +31,275 @@ function Install-UnslothStudio {
$env:Path = $unique -join ";"
}
function New-StudioShortcuts {
param(
[Parameter(Mandatory = $true)][string]$UnslothExePath
)
if (-not (Test-Path $UnslothExePath)) {
Write-Host "[WARN] Cannot create shortcuts: unsloth.exe not found at $UnslothExePath" -ForegroundColor Yellow
return
}
try {
# Persist an absolute path in launcher scripts so shortcut working
# directory changes do not break process startup.
$UnslothExePath = (Resolve-Path $UnslothExePath).Path
# Escape for single-quoted embedding in generated launcher script.
# This prevents runtime variable expansion for paths containing '$'.
$SingleQuotedExePath = $UnslothExePath -replace "'", "''"
$localAppDataDir = $env:LOCALAPPDATA
if (-not $localAppDataDir -or [string]::IsNullOrWhiteSpace($localAppDataDir)) {
Write-Host "[WARN] LOCALAPPDATA path unavailable; skipped shortcut creation" -ForegroundColor Yellow
return
}
$appDir = Join-Path $localAppDataDir "Unsloth Studio"
$launcherPs1 = Join-Path $appDir "launch-studio.ps1"
$launcherVbs = Join-Path $appDir "launch-studio.vbs"
$desktopDir = [Environment]::GetFolderPath("Desktop")
$desktopLink = if ($desktopDir -and $desktopDir.Trim()) {
Join-Path $desktopDir "Unsloth Studio.lnk"
} else {
$null
}
$startMenuDir = if ($env:APPDATA -and $env:APPDATA.Trim()) {
Join-Path $env:APPDATA "Microsoft\Windows\Start Menu\Programs"
} else {
$null
}
$startMenuLink = if ($startMenuDir -and $startMenuDir.Trim()) {
Join-Path $startMenuDir "Unsloth Studio.lnk"
} else {
$null
}
if (-not $desktopLink) {
Write-Host "[WARN] Desktop path unavailable; skipped desktop shortcut creation" -ForegroundColor Yellow
}
if (-not $startMenuLink) {
Write-Host "[WARN] APPDATA/Start Menu path unavailable; skipped Start menu shortcut creation" -ForegroundColor Yellow
}
$iconPath = Join-Path $appDir "unsloth.ico"
$bundledIcon = $null
if ($PSScriptRoot -and $PSScriptRoot.Trim()) {
$bundledIcon = Join-Path $PSScriptRoot "studio\frontend\public\unsloth.ico"
}
$iconUrl = "https://raw.githubusercontent.com/unslothai/unsloth/main/studio/frontend/public/unsloth.ico"
if (-not (Test-Path $appDir)) {
New-Item -ItemType Directory -Path $appDir -Force | Out-Null
}
$launcherContent = @"
`$ErrorActionPreference = 'Stop'
`$basePort = 8888
`$maxPortOffset = 20
`$timeoutSec = 60
`$pollIntervalMs = 1000
function Test-StudioHealth {
param([Parameter(Mandatory = `$true)][int]`$Port)
try {
`$url = "http://127.0.0.1:`$Port/api/health"
`$resp = Invoke-RestMethod -Uri `$url -TimeoutSec 1 -Method Get
return (`$resp -and `$resp.status -eq 'healthy' -and `$resp.service -eq 'Unsloth UI Backend')
} catch {
return `$false
}
}
function Get-CandidatePorts {
# Fast path: only probe base port + currently listening ports in range.
`$ports = @(`$basePort)
try {
`$maxPort = `$basePort + `$maxPortOffset
`$listening = Get-NetTCPConnection -State Listen -ErrorAction Stop |
Where-Object { `$_.LocalPort -ge `$basePort -and `$_.LocalPort -le `$maxPort } |
Select-Object -ExpandProperty LocalPort
`$ports = (@(`$basePort) + `$listening) | Sort-Object -Unique
} catch {
Write-Host "[DEBUG] Get-NetTCPConnection failed: `$(`$_.Exception.Message). Falling back to full port scan." -ForegroundColor DarkGray
# Fallback when Get-NetTCPConnection is unavailable/restricted.
for (`$offset = 1; `$offset -le `$maxPortOffset; `$offset++) {
`$ports += (`$basePort + `$offset)
}
}
return `$ports
}
function Find-HealthyStudioPort {
foreach (`$candidate in (Get-CandidatePorts)) {
if (Test-StudioHealth -Port `$candidate) {
return `$candidate
}
}
return `$null
}
# If Studio is already healthy on any expected port, just open it and exit.
`$existingPort = Find-HealthyStudioPort
if (`$existingPort) {
Start-Process "http://localhost:`$existingPort"
exit 0
}
`$launchMutex = [System.Threading.Mutex]::new(`$false, 'Local\UnslothStudioLauncher')
`$haveMutex = `$false
try {
try {
`$haveMutex = `$launchMutex.WaitOne(0)
} catch [System.Threading.AbandonedMutexException] {
`$haveMutex = `$true
}
if (-not `$haveMutex) {
# Another launcher is already running; wait for it to bring Studio up
`$deadline = (Get-Date).AddSeconds(`$timeoutSec)
while ((Get-Date) -lt `$deadline) {
`$port = Find-HealthyStudioPort
if (`$port) { Start-Process "http://localhost:`$port"; exit 0 }
Start-Sleep -Milliseconds `$pollIntervalMs
}
exit 0
}
`$powershellExe = Join-Path `$env:SystemRoot 'System32\WindowsPowerShell\v1.0\powershell.exe'
`$studioExe = '$SingleQuotedExePath'
`$studioCommand = '& "' + `$studioExe + '" studio -H 0.0.0.0 -p ' + `$basePort
`$launchArgs = @(
'-NoExit',
'-NoProfile',
'-ExecutionPolicy',
'Bypass',
'-Command',
`$studioCommand
)
try {
`$proc = Start-Process -FilePath `$powershellExe -ArgumentList `$launchArgs -WorkingDirectory `$env:USERPROFILE -PassThru
} catch {
`$msg = "Could not launch Unsloth Studio terminal.`n`nError: `$(`$_.Exception.Message)"
try {
Add-Type -AssemblyName System.Windows.Forms -ErrorAction Stop
[System.Windows.Forms.MessageBox]::Show(`$msg, 'Unsloth Studio') | Out-Null
} catch {}
exit 1
}
`$browserOpened = `$false
`$deadline = (Get-Date).AddSeconds(`$timeoutSec)
while ((Get-Date) -lt `$deadline) {
`$healthyPort = Find-HealthyStudioPort
if (`$healthyPort) {
Start-Process "http://localhost:`$healthyPort"
`$browserOpened = `$true
break
}
if (`$proc.HasExited) { break }
Start-Sleep -Milliseconds `$pollIntervalMs
}
if (-not `$browserOpened) {
if (`$proc.HasExited) {
`$msg = "Unsloth Studio exited before becoming healthy. Check terminal output for errors."
} else {
`$msg = "Unsloth Studio is still starting but did not become healthy within `$timeoutSec seconds. Check the terminal window for the selected port and open it manually."
}
try {
Add-Type -AssemblyName System.Windows.Forms -ErrorAction Stop
[System.Windows.Forms.MessageBox]::Show(`$msg, 'Unsloth Studio') | Out-Null
} catch {}
}
} finally {
if (`$haveMutex) { `$launchMutex.ReleaseMutex() | Out-Null }
`$launchMutex.Dispose()
}
exit 0
"@
# Write UTF-8 with BOM for reliable decoding by Windows PowerShell 5.1,
# even when install.ps1 is executed from PowerShell 7.
$utf8Bom = New-Object System.Text.UTF8Encoding($true)
[System.IO.File]::WriteAllText($launcherPs1, $launcherContent, $utf8Bom)
$vbsContent = @"
Set shell = CreateObject("WScript.Shell")
cmd = "powershell -NoProfile -ExecutionPolicy Bypass -WindowStyle Hidden -File ""$launcherPs1"""
shell.Run cmd, 0, False
"@
# WSH handles UTF-16LE reliably for .vbs files with non-ASCII paths.
Set-Content -Path $launcherVbs -Value $vbsContent -Encoding Unicode -Force
# Prefer bundled icon from local clone/dev installs.
# If not available, best-effort download from raw GitHub.
# We only attach the icon if the resulting file has a valid ICO header.
$hasValidIcon = $false
if ($bundledIcon -and (Test-Path $bundledIcon)) {
try {
Copy-Item -Path $bundledIcon -Destination $iconPath -Force
} catch {
Write-Host "[DEBUG] Error copying bundled icon: $($_.Exception.Message)" -ForegroundColor DarkGray
}
} elseif (-not (Test-Path $iconPath)) {
try {
Invoke-WebRequest -Uri $iconUrl -OutFile $iconPath -UseBasicParsing
} catch {
Write-Host "[DEBUG] Error downloading icon: $($_.Exception.Message)" -ForegroundColor DarkGray
}
}
if (Test-Path $iconPath) {
try {
$bytes = [System.IO.File]::ReadAllBytes($iconPath)
if (
$bytes.Length -ge 4 -and
$bytes[0] -eq 0 -and
$bytes[1] -eq 0 -and
$bytes[2] -eq 1 -and
$bytes[3] -eq 0
) {
$hasValidIcon = $true
} else {
Remove-Item $iconPath -Force -ErrorAction SilentlyContinue
}
} catch {
Write-Host "[DEBUG] Error validating or removing icon: $($_.Exception.Message)" -ForegroundColor DarkGray
Remove-Item $iconPath -Force -ErrorAction SilentlyContinue
}
}
$wscriptExe = Join-Path $env:SystemRoot "System32\wscript.exe"
$shortcutArgs = "//B //Nologo `"$launcherVbs`""
try {
$wshell = New-Object -ComObject WScript.Shell
$createdShortcutCount = 0
foreach ($linkPath in @($desktopLink, $startMenuLink)) {
if (-not $linkPath -or [string]::IsNullOrWhiteSpace($linkPath)) { continue }
try {
$shortcut = $wshell.CreateShortcut($linkPath)
$shortcut.TargetPath = $wscriptExe
$shortcut.Arguments = $shortcutArgs
$shortcut.WorkingDirectory = $appDir
$shortcut.Description = "Launch Unsloth Studio"
if ($hasValidIcon) {
$shortcut.IconLocation = "$iconPath,0"
}
$shortcut.Save()
$createdShortcutCount++
} catch {
Write-Host "[WARN] Could not create shortcut at ${linkPath}: $($_.Exception.Message)" -ForegroundColor Yellow
}
}
if ($createdShortcutCount -gt 0) {
Write-Host "[OK] Created Unsloth Studio shortcut(s): $createdShortcutCount" -ForegroundColor Green
} else {
Write-Host "[WARN] No Unsloth Studio shortcuts were created" -ForegroundColor Yellow
}
} catch {
Write-Host "[WARN] Shortcut creation unavailable: $($_.Exception.Message)" -ForegroundColor Yellow
}
} catch {
Write-Host "[WARN] Shortcut setup failed; skipping shortcuts: $($_.Exception.Message)" -ForegroundColor Yellow
}
}
# ── Check winget ──
if (-not (Get-Command winget -ErrorAction SilentlyContinue)) {
Write-Host "Error: winget is not available." -ForegroundColor Red
@ -299,6 +568,8 @@ function Install-UnslothStudio {
return
}
New-StudioShortcuts -UnslothExePath $UnslothExe
Write-Host ""
Write-Host "========================================="
Write-Host " Unsloth Studio installed!"

Binary file not shown.

After

Width:  |  Height:  |  Size: 157 KiB