diff --git a/install.sh b/install.sh index 3d8e08612c..0893955939 100755 --- a/install.sh +++ b/install.sh @@ -89,6 +89,441 @@ _smart_apt_install() { fi } +# ── Helper: create desktop shortcuts and launcher script ── +# Usage: create_studio_shortcuts +# Creates ~/.local/share/unsloth/launch-studio.sh (shared launcher), +# plus platform-specific shortcuts (Linux .desktop / macOS .app bundle). +# Skipped on WSL (no native desktop). +create_studio_shortcuts() { + _css_exe="$1" + _css_os="$2" + + # Skip on WSL -- no native desktop environment + if [ "$_css_os" = "wsl" ]; then + return 0 + fi + + # Validate exe + if [ ! -x "$_css_exe" ]; then + echo "[WARN] Cannot create shortcuts: unsloth not found at $_css_exe" + return 0 + fi + + # Resolve absolute path + _css_exe_dir=$(cd "$(dirname "$_css_exe")" && pwd) + _css_exe="$_css_exe_dir/$(basename "$_css_exe")" + + _css_data_dir="$HOME/.local/share/unsloth" + _css_launcher="$_css_data_dir/launch-studio.sh" + _css_icon_png="$_css_data_dir/unsloth-studio.png" + _css_gem_png="$_css_data_dir/unsloth-gem.png" + + mkdir -p "$_css_data_dir" + + # ── Write launcher script ── + # The launcher is Bash (not POSIX sh). + # We write it with a placeholder and substitute the exe path via sed. + cat > "$_css_launcher" << 'LAUNCHER_EOF' +#!/usr/bin/env bash +# Unsloth Studio Launcher +# Auto-generated by install.sh -- do not edit manually. +set -euo pipefail + +DATA_DIR="$HOME/.local/share/unsloth" + +# Read exe path from config written at install time. +# Sourcing is safe: the config file is written by install.sh, not user input. +if [ -f "$DATA_DIR/studio.conf" ]; then + . "$DATA_DIR/studio.conf" +fi +if [ -z "${UNSLOTH_EXE:-}" ] || [ ! -x "${UNSLOTH_EXE:-}" ]; then + echo "Error: UNSLOTH_EXE not set or not executable. Re-run the installer." >&2 + exit 1 +fi + +BASE_PORT=8888 +MAX_PORT_OFFSET=20 +TIMEOUT_SEC=60 +POLL_INTERVAL_SEC=1 +LOG_FILE="$DATA_DIR/studio.log" +LOCK_DIR="${XDG_RUNTIME_DIR:-/tmp}/unsloth-studio-launcher-$(id -u).lock" + +# ── HTTP GET helper (supports curl and wget) ── +_http_get() { + _url="$1" + if command -v curl >/dev/null 2>&1; then + curl -fsS --max-time 1 "$_url" 2>/dev/null + elif command -v wget >/dev/null 2>&1; then + wget -qO- --timeout=1 "$_url" 2>/dev/null + else + return 1 + fi +} + +# ── Health check ── +_check_health() { + _port=$1 + _resp=$(_http_get "http://127.0.0.1:$_port/api/health") || return 1 + case "$_resp" in + *'"status"'*'"healthy"'*'"service"'*'"Unsloth UI Backend"'*) return 0 ;; + *'"service"'*'"Unsloth UI Backend"'*'"status"'*'"healthy"'*) return 0 ;; + esac + return 1 +} + +# ── Port scanning ── +_candidate_ports() { + echo "$BASE_PORT" + _max_port=$((BASE_PORT + MAX_PORT_OFFSET)) + if command -v ss >/dev/null 2>&1; then + ss -tlnH 2>/dev/null | awk '{print $4}' | grep -oE '[0-9]+$' | \ + awk -v lo="$BASE_PORT" -v hi="$_max_port" '$1 >= lo && $1 <= hi && $1 != lo {print}' || true + elif command -v lsof >/dev/null 2>&1; then + lsof -iTCP -sTCP:LISTEN -nP 2>/dev/null | awk '{print $9}' | grep -oE '[0-9]+$' | \ + awk -v lo="$BASE_PORT" -v hi="$_max_port" '$1 >= lo && $1 <= hi && $1 != lo {print}' || true + else + _offset=1 + while [ "$_offset" -le "$MAX_PORT_OFFSET" ]; do + echo $((BASE_PORT + _offset)) + _offset=$((_offset + 1)) + done + fi +} + +_find_healthy_port() { + for _p in $(_candidate_ports | sort -un); do + if _check_health "$_p"; then + echo "$_p" + return 0 + fi + done + return 1 +} + +# ── Check if a port is busy ── +_is_port_busy() { + _port=$1 + if command -v ss >/dev/null 2>&1; then + ss -tlnH 2>/dev/null | awk '{print $4}' | grep -qE "[.:]$_port$" + elif command -v lsof >/dev/null 2>&1; then + lsof -iTCP:"$_port" -sTCP:LISTEN -nP >/dev/null 2>&1 + else + return 1 + fi +} + +# ── Find a free port in range ── +_find_launch_port() { + _offset=0 + while [ "$_offset" -le "$MAX_PORT_OFFSET" ]; do + _candidate=$((BASE_PORT + _offset)) + if ! _is_port_busy "$_candidate"; then + echo "$_candidate" + return 0 + fi + _offset=$((_offset + 1)) + done + return 1 +} + +# ── Open browser ── +_open_browser() { + _url="$1" + if [ "$(uname)" = "Darwin" ] && command -v open >/dev/null 2>&1; then + open "$_url" + elif command -v xdg-open >/dev/null 2>&1; then + xdg-open "$_url" >/dev/null 2>&1 & + else + echo "Open in your browser: $_url" >&2 + fi +} + +# ── Spawn terminal with studio command ── +_spawn_terminal() { + _cmd="$1" + _os=$(uname) + if [ "$_os" = "Darwin" ]; then + # Escape backslashes and double-quotes for AppleScript string + _cmd_escaped=$(printf '%s' "$_cmd" | sed 's/\\/\\\\/g; s/"/\\"/g') + osascript -e "tell application \"Terminal\" to do script \"$_cmd_escaped\"" >/dev/null 2>&1 && return 0 + else + for _term in gnome-terminal konsole xfce4-terminal mate-terminal lxterminal xterm; do + if command -v "$_term" >/dev/null 2>&1; then + case "$_term" in + gnome-terminal) "$_term" -- sh -c "$_cmd" & return 0 ;; + konsole) "$_term" -e sh -c "$_cmd" & return 0 ;; + xterm) "$_term" -e sh -c "$_cmd" & return 0 ;; + *) "$_term" -e sh -c "$_cmd" & return 0 ;; + esac + fi + done + fi + # Fallback: background with log + echo "No terminal emulator found; running in background. Logs: $LOG_FILE" >&2 + nohup sh -c "$_cmd" >> "$LOG_FILE" 2>&1 & + return 0 +} + +# ── Atomic directory-based single-instance guard ── +_acquire_lock() { + if mkdir "$LOCK_DIR" 2>/dev/null; then + echo "$$" > "$LOCK_DIR/pid" + return 0 + fi + + # Lock dir exists -- check if owner is still alive + _old_pid=$(cat "$LOCK_DIR/pid" 2>/dev/null || true) + if [ -n "$_old_pid" ] && kill -0 "$_old_pid" 2>/dev/null; then + # Another launcher is running; wait for it to bring Studio up + _deadline=$(($(date +%s) + TIMEOUT_SEC)) + while [ "$(date +%s)" -lt "$_deadline" ]; do + _port=$(_find_healthy_port) && { + _open_browser "http://localhost:$_port" + exit 0 + } + sleep "$POLL_INTERVAL_SEC" + done + echo "Timed out waiting for other launcher (PID $_old_pid)" >&2 + exit 0 + fi + + # Stale lock -- reclaim + rm -rf "$LOCK_DIR" + mkdir "$LOCK_DIR" 2>/dev/null || return 1 + echo "$$" > "$LOCK_DIR/pid" +} + +_release_lock() { + rm -rf "$LOCK_DIR" +} + +# ── Main ── +# Fast path: already healthy +_port=$(_find_healthy_port) && { + _open_browser "http://localhost:$_port" + exit 0 +} + +_acquire_lock +trap '_release_lock' EXIT INT TERM + +# Post-lock re-check (handles race with another launcher) +_port=$(_find_healthy_port) && { + _open_browser "http://localhost:$_port" + exit 0 +} + +# Find a free port in range +_launch_port=$(_find_launch_port) || { + echo "No free port found in range ${BASE_PORT}-$((BASE_PORT + MAX_PORT_OFFSET))" >&2 + exit 1 +} + +# Launch studio in a terminal +_launch_cmd=$(printf '%q ' "$UNSLOTH_EXE" studio -H 0.0.0.0 -p "$_launch_port") +_launch_cmd=${_launch_cmd% } +_spawn_terminal "$_launch_cmd" + +# Poll for health +_deadline=$(($(date +%s) + TIMEOUT_SEC)) +while [ "$(date +%s)" -lt "$_deadline" ]; do + _port=$(_find_healthy_port) && { + _open_browser "http://localhost:$_port" + exit 0 + } + sleep "$POLL_INTERVAL_SEC" +done + +echo "Unsloth Studio did not become healthy within ${TIMEOUT_SEC}s." >&2 +echo "Check logs at: $LOG_FILE" >&2 +exit 1 +LAUNCHER_EOF + + chmod +x "$_css_launcher" + + # Write the exe path to a separate conf file sourced by the launcher. + # Using single-quote wrapping with the standard '\'' escape for any + # embedded apostrophes. This avoids all sed metacharacter issues. + _css_quoted_exe=$(printf '%s' "$_css_exe" | sed "s/'/'\\\\''/g") + printf '%s\n' "UNSLOTH_EXE='$_css_quoted_exe'" > "$_css_data_dir/studio.conf" + + # ── Icon: try bundled, then download ── + # favicon.png (small, for Linux) and unsloth-gem.png (large, for macOS icns) + _css_script_dir="" + if [ -n "${0:-}" ] && [ -f "$0" ]; then + _css_script_dir=$(cd "$(dirname "$0")" 2>/dev/null && pwd) || true + fi + + # Try to find favicon.png from installed package (site-packages) or local repo + _css_found_favicon="" + _css_found_gem="" + _css_venv_dir=$(dirname "$(dirname "$_css_exe")") + # Check site-packages + for _sp in "$_css_venv_dir"/lib/python*/site-packages/unsloth/studio/frontend/public; do + if [ -f "$_sp/favicon.png" ]; then + _css_found_favicon="$_sp/favicon.png" + fi + if [ -f "$_sp/unsloth-gem.png" ]; then + _css_found_gem="$_sp/unsloth-gem.png" + fi + done + # Check local repo (when running from clone) + if [ -z "$_css_found_favicon" ] && [ -n "$_css_script_dir" ] && [ -f "$_css_script_dir/studio/frontend/public/favicon.png" ]; then + _css_found_favicon="$_css_script_dir/studio/frontend/public/favicon.png" + fi + if [ -z "$_css_found_gem" ] && [ -n "$_css_script_dir" ] && [ -f "$_css_script_dir/studio/frontend/public/unsloth-gem.png" ]; then + _css_found_gem="$_css_script_dir/studio/frontend/public/unsloth-gem.png" + fi + + # Copy or download favicon.png + if [ -n "$_css_found_favicon" ]; then + cp "$_css_found_favicon" "$_css_icon_png" 2>/dev/null || true + elif [ ! -f "$_css_icon_png" ]; then + download "https://raw.githubusercontent.com/unslothai/unsloth/main/studio/frontend/public/favicon.png" "$_css_icon_png" 2>/dev/null || true + fi + # Copy or download unsloth-gem.png (for macOS icns) + if [ -n "$_css_found_gem" ]; then + cp "$_css_found_gem" "$_css_gem_png" 2>/dev/null || true + elif [ ! -f "$_css_gem_png" ]; then + download "https://raw.githubusercontent.com/unslothai/unsloth/main/studio/frontend/public/unsloth-gem.png" "$_css_gem_png" 2>/dev/null || true + fi + + # Validate PNG header (first 4 bytes: \x89PNG) + _css_validate_png() { + [ -f "$1" ] || return 1 + _hdr=$(od -An -tx1 -N4 "$1" 2>/dev/null | tr -d ' ') + [ "$_hdr" = "89504e47" ] + } + if [ -f "$_css_icon_png" ] && ! _css_validate_png "$_css_icon_png"; then + rm -f "$_css_icon_png" + fi + if [ -f "$_css_gem_png" ] && ! _css_validate_png "$_css_gem_png"; then + rm -f "$_css_gem_png" + fi + + # ── Platform-specific shortcuts ── + _css_created=0 + + if [ "$_css_os" = "linux" ]; then + # ── Linux: .desktop file ── + _css_app_dir="$HOME/.local/share/applications" + mkdir -p "$_css_app_dir" + + _css_desktop="$_css_app_dir/unsloth-studio.desktop" + # Escape backslashes and double-quotes for .desktop Exec= field + _css_exec_escaped=$(printf '%s' "$_css_launcher" | sed 's/\\/\\\\/g; s/"/\\"/g') + _css_icon_escaped=$(printf '%s' "$_css_icon_png" | sed 's/\\/\\\\/g; s/"/\\"/g') + cat > "$_css_desktop" << DESKTOP_EOF +[Desktop Entry] +Version=1.0 +Type=Application +Name=Unsloth Studio +Comment=Launch Unsloth Studio +Exec="$_css_exec_escaped" +Icon=$_css_icon_escaped +Terminal=false +StartupNotify=true +Categories=Development;Science; +DESKTOP_EOF + chmod +x "$_css_desktop" + + # Copy to ~/Desktop if it exists + if [ -d "$HOME/Desktop" ]; then + cp "$_css_desktop" "$HOME/Desktop/unsloth-studio.desktop" 2>/dev/null || true + chmod +x "$HOME/Desktop/unsloth-studio.desktop" 2>/dev/null || true + # Mark as trusted so GNOME/Nautilus allows launching via double-click + if command -v gio >/dev/null 2>&1; then + gio set "$HOME/Desktop/unsloth-studio.desktop" metadata::trusted true 2>/dev/null || true + fi + fi + + # Best-effort update database + update-desktop-database "$_css_app_dir" 2>/dev/null || true + _css_created=1 + + elif [ "$_css_os" = "macos" ]; then + # ── macOS: .app bundle ── + _css_app="$HOME/Applications/Unsloth Studio.app" + _css_contents="$_css_app/Contents" + _css_macos_dir="$_css_contents/MacOS" + _css_res_dir="$_css_contents/Resources" + mkdir -p "$_css_macos_dir" "$_css_res_dir" + + # Info.plist + cat > "$_css_contents/Info.plist" << 'PLIST_EOF' + + + + + CFBundleIdentifier + ai.unsloth.studio + CFBundleName + Unsloth Studio + CFBundleDisplayName + Unsloth Studio + CFBundleExecutable + launch-studio + CFBundleIconFile + AppIcon + CFBundlePackageType + APPL + CFBundleVersion + 1.0 + CFBundleShortVersionString + 1.0 + LSMinimumSystemVersion + 10.15 + NSHighResolutionCapable + + + +PLIST_EOF + + # Executable stub + cat > "$_css_macos_dir/launch-studio" << STUB_EOF +#!/bin/sh +exec "$HOME/.local/share/unsloth/launch-studio.sh" "\$@" +STUB_EOF + chmod +x "$_css_macos_dir/launch-studio" + + # Build AppIcon.icns from unsloth-gem.png (2240x2240) + if [ -f "$_css_gem_png" ] && command -v sips >/dev/null 2>&1 && command -v iconutil >/dev/null 2>&1; then + _css_tmpdir=$(mktemp -d 2>/dev/null) + if [ -d "$_css_tmpdir" ]; then + _css_iconset="$_css_tmpdir/AppIcon.iconset" + mkdir -p "$_css_iconset" + _css_icon_ok=true + for _sz in 16 32 128 256 512; do + _sz2=$((_sz * 2)) + sips -z "$_sz" "$_sz" "$_css_gem_png" --out "$_css_iconset/icon_${_sz}x${_sz}.png" >/dev/null 2>&1 || _css_icon_ok=false + sips -z "$_sz2" "$_sz2" "$_css_gem_png" --out "$_css_iconset/icon_${_sz}x${_sz}@2x.png" >/dev/null 2>&1 || _css_icon_ok=false + done + if [ "$_css_icon_ok" = "true" ]; then + iconutil -c icns "$_css_iconset" -o "$_css_res_dir/AppIcon.icns" 2>/dev/null || true + fi + rm -rf "$_css_tmpdir" + fi + fi + # Fallback: copy PNG as icon + if [ ! -f "$_css_res_dir/AppIcon.icns" ] && [ -f "$_css_icon_png" ]; then + cp "$_css_icon_png" "$_css_res_dir/AppIcon.icns" 2>/dev/null || true + fi + + # Touch so Finder indexes it + touch "$_css_app" + + # Symlink on Desktop + if [ -d "$HOME/Desktop" ]; then + ln -sf "$_css_app" "$HOME/Desktop/Unsloth Studio" 2>/dev/null || true + fi + _css_created=1 + fi + + if [ "$_css_created" -eq 1 ]; then + echo "[OK] Created Unsloth Studio shortcut(s)" + fi +} + echo "" echo "=========================================" echo " Unsloth Studio Installer" @@ -251,6 +686,8 @@ echo "==> Running unsloth studio setup..." REQUESTED_PYTHON_VERSION="$(cd "$VENV_NAME/bin" && pwd)/python" \ "$VENV_NAME/bin/unsloth" studio setup