mirror of
https://github.com/browseros-ai/BrowserOS.git
synced 2026-05-13 15:46:22 +00:00
* feat(cli): production-ready CLI with auto-launch, install, and cross-platform builds - init: accept URL argument and --auto flag for non-interactive setup - install: new command to download BrowserOS app for current platform - launch: auto-detect and launch BrowserOS when server is not running - discovery: prefer server.json (live) over config.yaml (may be stale) - errors: actionable messages guiding users to init/install - goreleaser: cross-platform builds for 6 targets (darwin/linux/windows × amd64/arm64) - ci: GitHub Actions workflow to release CLI binaries on cli/v* tag push * fix(cli): check health status code and add progress dots during launch - Health check in newClient() now verifies HTTP 200, not just no error - waitForServer prints dots during the 30s poll so users know it's working * refactor(cli): make launch an explicit command, remove auto-launch from newClient - launch: new explicit command to find and open BrowserOS app - launch: probes server.json, config, and common ports before launching - launch: if already running, reports URL instead of launching again - init --auto: uses port probing to find running servers - install --deb: errors on non-Linux instead of silently downloading DMG - error messages: guide users to launch/install/init explicitly - removed: auto-launch from newClient() — CLI never does something surprising * fix(cli): platform-native detection, launch, and install for all OSes Detection (isBrowserOSInstalled): - macOS: uses `open -Ra` to query Launch Services (no hardcoded paths) - Linux: checks /usr/bin/browseros (.deb), browseros.desktop, AppImage search - Windows: checks %LOCALAPPDATA%\BrowserOS\Application\BrowserOS.exe and HKCU/HKLM uninstall registry keys Launch (startBrowserOS): - macOS: `open -b com.browseros.BrowserOS` (bundle ID, not path) - Linux: `browseros` binary, AppImage, or `gtk-launch browseros` (fixed: was using xdg-open which opens by MIME type, not desktop files) - Windows: runs BrowserOS.exe from known Chromium per-user install path (fixed: was using `cmd /c start BrowserOS` which doesn't resolve) Install (runPostInstall): - macOS: hdiutil attach → cp -R to /Applications → hdiutil detach - Linux: chmod +x for AppImage, dpkg -i instruction for .deb - Windows: launches installer exe - --deb flag now errors on non-Linux platforms Removed auto-launch from newClient() — CLI never does surprising things. Sources verified from: - packages/browseros/build/common/context.py (binary names per platform) - packages/browseros/build/modules/package/linux.py (.deb structure, .desktop file) - packages/browseros/chromium_patches/chrome/install_static/chromium_install_modes.h (Windows base_app_name="BrowserOS", registry GUID, install paths) - /Applications/BrowserOS.app/Contents/Info.plist (bundle ID)
288 lines
8.3 KiB
Go
288 lines
8.3 KiB
Go
package cmd
|
|
|
|
import (
|
|
"fmt"
|
|
"net/http"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"runtime"
|
|
"strings"
|
|
"time"
|
|
|
|
"browseros-cli/output"
|
|
|
|
"github.com/fatih/color"
|
|
"github.com/spf13/cobra"
|
|
)
|
|
|
|
// macOS bundle identifier — verified from BrowserOS.app/Contents/Info.plist
|
|
const browserOSBundleID = "com.browseros.BrowserOS"
|
|
|
|
func init() {
|
|
cmd := &cobra.Command{
|
|
Use: "launch",
|
|
Short: "Launch the BrowserOS application",
|
|
Long: `Find and launch the BrowserOS application.
|
|
|
|
Uses platform-native detection to find BrowserOS, launches it,
|
|
and waits for the server to become ready.
|
|
|
|
If BrowserOS is already running, reports the server URL.`,
|
|
Annotations: map[string]string{"group": "Setup:"},
|
|
Args: cobra.NoArgs,
|
|
Run: func(cmd *cobra.Command, args []string) {
|
|
green := color.New(color.FgGreen)
|
|
dim := color.New(color.Faint)
|
|
waitSecs, _ := cmd.Flags().GetInt("wait")
|
|
|
|
if url := probeRunningServer(); url != "" {
|
|
green.Printf("BrowserOS is already running at %s\n", url)
|
|
return
|
|
}
|
|
|
|
if !isBrowserOSInstalled() {
|
|
output.Error("BrowserOS is not installed.\n\n"+
|
|
" To install: browseros-cli install", 1)
|
|
}
|
|
|
|
fmt.Println("Launching BrowserOS...")
|
|
if err := startBrowserOS(); err != nil {
|
|
output.Errorf(1, "failed to launch: %v", err)
|
|
}
|
|
|
|
fmt.Print("Waiting for server")
|
|
url, ok := waitForServer(time.Duration(waitSecs) * time.Second)
|
|
fmt.Println()
|
|
|
|
if !ok {
|
|
output.Error("BrowserOS launched but server didn't respond within "+
|
|
fmt.Sprintf("%d seconds.\n", waitSecs)+
|
|
" Check if BrowserOS is fully loaded, then retry.", 1)
|
|
}
|
|
|
|
green.Printf("BrowserOS is ready at %s\n", url)
|
|
fmt.Println()
|
|
dim.Println("Next: browseros-cli init --auto")
|
|
},
|
|
}
|
|
|
|
cmd.Flags().Int("wait", 30, "Seconds to wait for server to start")
|
|
rootCmd.AddCommand(cmd)
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Server probing
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// probeRunningServer checks server.json, config, and common ports for a running server.
|
|
func probeRunningServer() string {
|
|
check := func(baseURL string) bool {
|
|
client := &http.Client{Timeout: 2 * time.Second}
|
|
resp, err := client.Get(baseURL + "/health")
|
|
if err != nil {
|
|
return false
|
|
}
|
|
resp.Body.Close()
|
|
return resp.StatusCode == 200
|
|
}
|
|
|
|
// 1. server.json — written by BrowserOS on startup with the actual port
|
|
if url := loadBrowserosServerURL(); url != "" && check(url) {
|
|
return url
|
|
}
|
|
|
|
// 2. Saved config / env var
|
|
if url := defaultServerURL(); url != "" && check(url) {
|
|
return url
|
|
}
|
|
|
|
// 3. Probe common BrowserOS ports as last resort
|
|
for _, port := range []int{9100, 9200, 9300} {
|
|
url := fmt.Sprintf("http://127.0.0.1:%d", port)
|
|
if check(url) {
|
|
return url
|
|
}
|
|
}
|
|
|
|
return ""
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Platform-native installation detection
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// isBrowserOSInstalled checks if BrowserOS is installed using platform-native methods.
|
|
//
|
|
// macOS: `open -Ra "BrowserOS"` — queries Launch Services (finds apps anywhere)
|
|
// Linux: checks /usr/bin/browseros (.deb), browseros.desktop, or AppImage files
|
|
// Windows: checks executable at %LOCALAPPDATA%\BrowserOS\Application\BrowserOS.exe
|
|
// and registry uninstall key (per-user Chromium install pattern)
|
|
func isBrowserOSInstalled() bool {
|
|
switch runtime.GOOS {
|
|
case "darwin":
|
|
// open -Ra checks if Launch Services knows about the app without launching it.
|
|
// Works regardless of where the app is installed.
|
|
return exec.Command("open", "-Ra", "BrowserOS").Run() == nil
|
|
|
|
case "linux":
|
|
// .deb install puts `browseros` in /usr/bin/
|
|
if _, err := exec.LookPath("browseros"); err == nil {
|
|
return true
|
|
}
|
|
// .deb also creates browseros.desktop
|
|
for _, dir := range []string{
|
|
"/usr/share/applications",
|
|
filepath.Join(userHomeDir(), ".local/share/applications"),
|
|
} {
|
|
if _, err := os.Stat(filepath.Join(dir, "browseros.desktop")); err == nil {
|
|
return true
|
|
}
|
|
}
|
|
// AppImage — user may have it in ~/Downloads, ~/Applications, etc.
|
|
return findLinuxAppImage() != ""
|
|
|
|
case "windows":
|
|
// Chromium per-user install: %LOCALAPPDATA%\BrowserOS\Application\BrowserOS.exe
|
|
if exePath := windowsBrowserOSExe(); exePath != "" {
|
|
if _, err := os.Stat(exePath); err == nil {
|
|
return true
|
|
}
|
|
}
|
|
// Fallback: check uninstall registry (per-user install uses HKCU)
|
|
for _, root := range []string{"HKCU", "HKLM"} {
|
|
key := root + `\Software\Microsoft\Windows\CurrentVersion\Uninstall\BrowserOS`
|
|
if exec.Command("reg", "query", key, "/v", "DisplayName").Run() == nil {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
return false
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Platform-native launch
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// startBrowserOS launches BrowserOS using platform-native methods.
|
|
//
|
|
// macOS: `open -b com.browseros.BrowserOS` — launches by bundle ID
|
|
// Linux: runs `browseros` binary or AppImage directly
|
|
// Windows: runs BrowserOS.exe from the known install path
|
|
func startBrowserOS() error {
|
|
switch runtime.GOOS {
|
|
case "darwin":
|
|
// Launch by bundle ID via Launch Services — no hardcoded paths needed.
|
|
return exec.Command("open", "-b", browserOSBundleID).Run()
|
|
|
|
case "linux":
|
|
// .deb install: browseros is in PATH
|
|
if p, err := exec.LookPath("browseros"); err == nil {
|
|
return startDetached(p)
|
|
}
|
|
// AppImage: run it directly
|
|
if appImage := findLinuxAppImage(); appImage != "" {
|
|
return startDetached(appImage)
|
|
}
|
|
// .desktop file: use gtk-launch (not xdg-open, which opens by MIME type)
|
|
if _, err := exec.LookPath("gtk-launch"); err == nil {
|
|
return exec.Command("gtk-launch", "browseros").Run()
|
|
}
|
|
return fmt.Errorf("BrowserOS found but could not determine how to launch it")
|
|
|
|
case "windows":
|
|
if exePath := windowsBrowserOSExe(); exePath != "" {
|
|
if _, err := os.Stat(exePath); err == nil {
|
|
return startDetached(exePath)
|
|
}
|
|
}
|
|
return fmt.Errorf("BrowserOS.exe not found at expected location")
|
|
|
|
default:
|
|
return fmt.Errorf("unsupported platform: %s", runtime.GOOS)
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Helpers
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// startDetached starts a process in the background without inheriting stdio.
|
|
func startDetached(path string, args ...string) error {
|
|
cmd := exec.Command(path, args...)
|
|
cmd.Stdout = nil
|
|
cmd.Stderr = nil
|
|
cmd.Stdin = nil
|
|
return cmd.Start()
|
|
}
|
|
|
|
// windowsBrowserOSExe returns the expected BrowserOS.exe path on Windows.
|
|
// Chromium per-user installs go to %LOCALAPPDATA%\<base_app_name>\Application\<binary>.
|
|
// base_app_name = "BrowserOS" (from chromium_install_modes.h)
|
|
func windowsBrowserOSExe() string {
|
|
localAppData := os.Getenv("LOCALAPPDATA")
|
|
if localAppData == "" {
|
|
return ""
|
|
}
|
|
return filepath.Join(localAppData, "BrowserOS", "Application", "BrowserOS.exe")
|
|
}
|
|
|
|
// findLinuxAppImage searches common locations for a BrowserOS AppImage.
|
|
func findLinuxAppImage() string {
|
|
home := userHomeDir()
|
|
if home == "" {
|
|
return ""
|
|
}
|
|
for _, dir := range []string{
|
|
home,
|
|
filepath.Join(home, "Applications"),
|
|
filepath.Join(home, "Downloads"),
|
|
"/opt",
|
|
} {
|
|
entries, err := os.ReadDir(dir)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
for _, e := range entries {
|
|
name := e.Name()
|
|
if strings.HasPrefix(name, "BrowserOS") && strings.HasSuffix(name, ".AppImage") {
|
|
return filepath.Join(dir, name)
|
|
}
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// userHomeDir returns the home directory or empty string.
|
|
func userHomeDir() string {
|
|
home, err := os.UserHomeDir()
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
return home
|
|
}
|
|
|
|
// waitForServer polls until a BrowserOS server responds or timeout.
|
|
func waitForServer(maxWait time.Duration) (string, bool) {
|
|
client := &http.Client{Timeout: 2 * time.Second}
|
|
deadline := time.Now().Add(maxWait)
|
|
|
|
for time.Now().Before(deadline) {
|
|
// server.json is written by BrowserOS on startup with the actual port
|
|
if url := loadBrowserosServerURL(); url != "" {
|
|
resp, err := client.Get(url + "/health")
|
|
if err == nil {
|
|
resp.Body.Close()
|
|
if resp.StatusCode == 200 {
|
|
return url, true
|
|
}
|
|
}
|
|
}
|
|
fmt.Print(".")
|
|
time.Sleep(1 * time.Second)
|
|
}
|
|
return "", false
|
|
}
|