diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..1826fa2 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,32 @@ +name: CI / Tests + +on: + pull_request: + branches: + - main + workflow_dispatch: + +jobs: + test: + name: Run Unit & Integration Tests + runs-on: ubuntu-latest + + steps: + - name: Checkout Repository + uses: actions/checkout@v4 + + - name: Setup Go + uses: actions/setup-go@v5 + with: + go-version: '1.23.5' + cache: true + + - name: Run CLI Tests + run: | + echo "๐Ÿงช Running CLI Unit Tests..." + cd cli && go test -v ./... + + - name: Run Server Tests + run: | + echo "๐Ÿงช Running Server Integration Tests..." + cd server && go test -v ./... diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index e072212..47c8b7a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -19,6 +19,18 @@ jobs: with: fetch-depth: 0 # Required to get all history for correct versioning + - name: Setup Go + uses: actions/setup-go@v5 + with: + go-version: '1.23.5' + + - name: Run Tests + run: | + echo "๐Ÿงช Running CLI Unit Tests..." + cd cli && go test -v ./... + echo "๐Ÿงช Running Server Integration Tests..." + cd ../server && go test -v ./... + - name: Bump version and push tag id: tag_version uses: anothrNick/github-tag-action@v1 @@ -30,11 +42,6 @@ jobs: DEFAULT_BRANCH: main RELEASE_BRANCHES: main - - name: Setup Go - uses: actions/setup-go@v5 - with: - go-version: '1.23.5' - - name: Build and Package Binaries run: | VERSION_TAG="${{ steps.tag_version.outputs.new_tag }}" diff --git a/cli/main.go b/cli/main.go index 13a2c27..41a8194 100644 --- a/cli/main.go +++ b/cli/main.go @@ -19,6 +19,7 @@ import ( "runtime" "strconv" "strings" + "sync" "syscall" "time" @@ -670,6 +671,7 @@ func launchDaemonProcess() { _ = os.WriteFile(uuidPath, []byte(sessionUUID), 0644) + // Build args for the child process: strip -d/--daemon flags args := []string{} for _, arg := range os.Args[1:] { if arg != "-d" && arg != "--daemon" { @@ -678,6 +680,7 @@ func launchDaemonProcess() { } cmd := exec.Command(os.Args[0], args...) + cmd.Env = append(os.Environ(), "MINISHARE_DAEMON=1") logFile, err := os.OpenFile(logPath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644) if err != nil { log.Fatalf("failed to open daemon log file: %v", err) @@ -921,8 +924,11 @@ func printHelp() { // ------------------------------------------------------------------- func runHost() { cfg := LoadConfig() + isDaemon := os.Getenv("MINISHARE_DAEMON") == "1" - fmt.Printf("\033[90m[MiniShare] Connecting to signaling server: %s\033[0m\n", cfg.ServerURL) + if !isDaemon { + fmt.Printf("\033[90m[MiniShare] Connecting to signaling server: %s\033[0m\n", cfg.ServerURL) + } manualFlag := flag.Bool("manual", false, "Run in manual copy-paste mode") flag.CommandLine.Parse(os.Args[1:]) @@ -945,6 +951,13 @@ func runHost() { } defer ptmx.Close() + // Sync initial pty size from the host terminal (non-daemon only) + if !isDaemon { + if cols, rows, err := term.GetSize(int(os.Stdin.Fd())); err == nil { + _ = pty.Setsize(ptmx, &pty.Winsize{Rows: uint16(rows), Cols: uint16(cols)}) + } + } + pc, err := webrtc.NewPeerConnection(webrtc.Configuration{ ICEServers: []webrtc.ICEServer{{URLs: []string{"stun:stun.l.google.com:19302"}}}, }) @@ -954,31 +967,60 @@ func runHost() { defer pc.Close() done := make(chan struct{}) + var doneOnce sync.Once + closeDone := func() { doneOnce.Do(func() { close(done) }) } dc, err := pc.CreateDataChannel("terminal", nil) if err != nil { log.Fatalf("failed to create data channel: %v", err) } + // Put host terminal into raw mode so local I/O works correctly (non-daemon) + if !isDaemon { + if oldState, err := term.MakeRaw(int(os.Stdin.Fd())); err == nil { + defer term.Restore(int(os.Stdin.Fd()), oldState) + } + } + + // Handle SIGWINCH for terminal resize (non-daemon only) + if !isDaemon { + winchChan := make(chan os.Signal, 1) + signal.Notify(winchChan, syscall.SIGWINCH) + go func() { + for { + select { + case <-winchChan: + if cols, rows, err := term.GetSize(int(os.Stdin.Fd())); err == nil { + _ = pty.Setsize(ptmx, &pty.Winsize{Rows: uint16(rows), Cols: uint16(cols)}) + } + case <-done: + return + } + } + }() + } + sigChan := make(chan os.Signal, 1) signal.Notify(sigChan, os.Interrupt, syscall.SIGTERM) go func() { <-sigChan - log.Println("\n[MiniShare] Host shutting down...") + if !isDaemon { + fmt.Print("\r\n[MiniShare] Host shutting down...\r\n") + } else { + log.Println("[MiniShare] Host shutting down...") + } if dc != nil { _ = dc.Close() } _ = pc.Close() - select { - case <-done: - default: - close(done) - } + closeDone() }() dc.OnOpen(func() { - fmt.Println("\n\033[1;32mโœ“ Peer connected successfully (Web Browser or CLI client)\033[0m") - fmt.Println("\033[1;32mSession active โ€” terminal streaming peer-to-peer...\033[0m\n") + if !isDaemon { + fmt.Print("\r\n\033[1;32mโœ“ Peer connected successfully (Web Browser or CLI client)\033[0m\r\n") + fmt.Print("\033[1;32mSession active โ€” terminal streaming peer-to-peer...\033[0m\r\n\r\n") + } log.Println("Data channel open โ€” P2P session live") hostname, _ := os.Hostname() banner := fmt.Sprintf("\r\n\033[1;32mโ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”\033[0m\r\n"+ @@ -989,11 +1031,16 @@ func runHost() { hostname, runtime.GOOS, shell) _ = dc.Send([]byte(banner)) + // Read pty output and send to both data channel and local stdout go func() { buf := make([]byte, 4096) for { n, err := ptmx.Read(buf) if n > 0 { + // Echo to local terminal (host can see what's happening) + if !isDaemon { + _, _ = os.Stdout.Write(buf[:n]) + } if sendErr := dc.Send(buf[:n]); sendErr != nil { break } @@ -1002,16 +1049,47 @@ func runHost() { break } } - select { - case <-done: - default: - close(done) - } + closeDone() }() + + // Pipe host's local stdin to pty (non-daemon only) + if !isDaemon { + go func() { + buf := make([]byte, 1024) + for { + n, err := os.Stdin.Read(buf) + if n > 0 { + _, _ = ptmx.Write(buf[:n]) + } + if err != nil { + break + } + } + }() + } }) var cmdBuffer bytes.Buffer dc.OnMessage(func(msg webrtc.DataChannelMessage) { + // Check for resize control message (prefixed with SOH byte \x01) + if len(msg.Data) > 1 && msg.Data[0] == 0x01 { + var resizeMsg struct { + Type string `json:"type"` + Cols int `json:"cols"` + Rows int `json:"rows"` + } + if err := json.Unmarshal(msg.Data[1:], &resizeMsg); err == nil && resizeMsg.Type == "resize" { + if resizeMsg.Cols > 0 && resizeMsg.Rows > 0 { + _ = pty.Setsize(ptmx, &pty.Winsize{ + Rows: uint16(resizeMsg.Rows), + Cols: uint16(resizeMsg.Cols), + }) + log.Printf("[Resize] Terminal resized to %dx%d", resizeMsg.Cols, resizeMsg.Rows) + } + } + return + } + // Check if we need to enforce security rules hasBlockedCmds := len(cfg.BlockedCommands) > 0 hasBlockedDirs := len(cfg.BlockedFolders) > 0 @@ -1127,20 +1205,12 @@ func runHost() { dc.OnClose(func() { log.Println("Data channel closed") - select { - case <-done: - default: - close(done) - } + closeDone() }) pc.OnICEConnectionStateChange(func(state webrtc.ICEConnectionState) { if state == webrtc.ICEConnectionStateDisconnected || state == webrtc.ICEConnectionStateFailed || state == webrtc.ICEConnectionStateClosed { - select { - case <-done: - default: - close(done) - } + closeDone() } }) @@ -1188,14 +1258,18 @@ func runHost() { } webLink := fmt.Sprintf("%s/app/%s", serverURL, sessResp.UUID) - fmt.Println("\n\033[1;32mโšก MiniShare Host Session Live\033[0m") - fmt.Printf("๐Ÿ”‘ \033[1;37mSession UUID:\033[0m \033[1;36m%s\033[0m\n", sessResp.UUID) - fmt.Printf("๐Ÿ’ป \033[1;37mConnect via CLI:\033[0m \033[1;33mminishare connect %s\033[0m\n", sessResp.UUID) - fmt.Printf("๐ŸŒ \033[1;37mConnect via Web Browser:\033[0m \033[4;36m%s\033[0m\n", webLink) - copyToClipboard(sessResp.UUID) - fmt.Println("\033[1;32m๐Ÿ‘‰ Session UUID copied to clipboard automatically!\033[0m") - - fmt.Println("\n\033[90mWaiting for peer to connect...\033[0m") + if !isDaemon { + fmt.Print("\r\n\033[1;32mโšก MiniShare Host Session Live\033[0m\r\n") + fmt.Printf("๐Ÿ”‘ \033[1;37mSession UUID:\033[0m \033[1;36m%s\033[0m\r\n", sessResp.UUID) + fmt.Printf("๐Ÿ’ป \033[1;37mConnect via CLI:\033[0m \033[1;33mminishare connect %s\033[0m\r\n", sessResp.UUID) + fmt.Printf("๐ŸŒ \033[1;37mConnect via Web Browser:\033[0m \033[4;36m%s\033[0m\r\n", webLink) + copyToClipboard(sessResp.UUID) + fmt.Print("\033[1;32m๐Ÿ‘‰ Session UUID copied to clipboard automatically!\033[0m\r\n") + fmt.Print("\r\n\033[90mWaiting for peer to connect...\033[0m\r\n") + } else { + log.Printf("โšก MiniShare Host Session Live โ€” UUID: %s", sessResp.UUID) + log.Printf("๐ŸŒ Web: %s", webLink) + } go func() { for { @@ -1227,7 +1301,11 @@ func runHost() { }() <-done - log.Println("Session ended") + if !isDaemon { + fmt.Print("\r\n[MiniShare] Session ended\r\n") + } else { + log.Println("Session ended") + } time.Sleep(100 * time.Millisecond) } diff --git a/cli/main_test.go b/cli/main_test.go new file mode 100644 index 0000000..430e089 --- /dev/null +++ b/cli/main_test.go @@ -0,0 +1,396 @@ +package main + +import ( + "encoding/json" + "os" + "path/filepath" + "regexp" + "strings" + "testing" + "time" +) + +// --------------------------------------------------------------------------- +// generateUUID +// --------------------------------------------------------------------------- +func TestGenerateUUID_Format(t *testing.T) { + uuid := generateUUID() + // UUIDs from this generator look like "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx" + matched, _ := regexp.MatchString(`^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$`, uuid) + if !matched { + t.Errorf("generateUUID() = %q, does not match expected UUID v4 format", uuid) + } +} + +func TestGenerateUUID_Unique(t *testing.T) { + seen := make(map[string]bool) + for i := 0; i < 100; i++ { + u := generateUUID() + if seen[u] { + t.Fatalf("generateUUID() produced duplicate: %s", u) + } + seen[u] = true + } +} + +// --------------------------------------------------------------------------- +// parseDurationStr +// --------------------------------------------------------------------------- +func TestParseDurationStr(t *testing.T) { + tests := []struct { + input string + wantNever bool + wantErr bool + minDuration time.Duration // approximate minimum from now + }{ + {"never", true, false, 0}, + {"permanent", true, false, 0}, + {"0", true, false, 0}, + {"infinite", true, false, 0}, + {"1h", false, false, 59 * time.Minute}, + {"30m", false, false, 29 * time.Minute}, + {"2d", false, false, 47 * time.Hour}, + {"1y", false, false, 364 * 24 * time.Hour}, + {"2mo", false, false, 58 * 24 * time.Hour}, + {"garbage", false, true, 0}, + } + + for _, tc := range tests { + t.Run(tc.input, func(t *testing.T) { + now := time.Now() + expiry, neverExpires, err := parseDurationStr(tc.input) + + if tc.wantErr { + if err == nil { + t.Errorf("parseDurationStr(%q) expected error, got nil", tc.input) + } + return + } + + if err != nil { + t.Fatalf("parseDurationStr(%q) unexpected error: %v", tc.input, err) + } + + if neverExpires != tc.wantNever { + t.Errorf("parseDurationStr(%q) neverExpires = %v, want %v", tc.input, neverExpires, tc.wantNever) + } + + if !tc.wantNever { + diff := expiry.Sub(now) + if diff < tc.minDuration { + t.Errorf("parseDurationStr(%q) expiry too soon: %v < %v from now", tc.input, diff, tc.minDuration) + } + } + }) + } +} + +// --------------------------------------------------------------------------- +// cleanInput +// --------------------------------------------------------------------------- +func TestCleanInput(t *testing.T) { + tests := []struct { + input string + want string + }{ + {" hello ", "hello"}, + {"hello\nworld", "helloworld"}, + {"hello\r\nworld", "helloworld"}, + {" hello world ", "helloworld"}, + {`"quoted"`, "quoted"}, + {"'single'", "single"}, + {"no-change", "no-change"}, + {"", ""}, + } + + for _, tc := range tests { + got := cleanInput(tc.input) + if got != tc.want { + t.Errorf("cleanInput(%q) = %q, want %q", tc.input, got, tc.want) + } + } +} + +// --------------------------------------------------------------------------- +// Config round-trip (LoadConfig / SaveConfig) +// --------------------------------------------------------------------------- +func TestConfigRoundTrip(t *testing.T) { + // Setup temp config path + tmpDir := t.TempDir() + configPath := filepath.Join(tmpDir, "config.json") + t.Setenv("MINISHARE_CONFIG", configPath) + + cfg := &Config{ + ServerURL: "http://localhost:9090", + PersistentUUID: "test-uuid-1234", + UUIDExpiresAt: time.Now().Add(1 * time.Hour).Truncate(time.Second), + BlockedCommands: []string{"rm", "sudo"}, + BlockedFolders: []string{"/etc", "/var/log"}, + } + + if err := SaveConfig(cfg); err != nil { + t.Fatalf("SaveConfig() error: %v", err) + } + + loaded := LoadConfig() + + if loaded.ServerURL != cfg.ServerURL { + t.Errorf("ServerURL = %q, want %q", loaded.ServerURL, cfg.ServerURL) + } + if loaded.PersistentUUID != cfg.PersistentUUID { + t.Errorf("PersistentUUID = %q, want %q", loaded.PersistentUUID, cfg.PersistentUUID) + } + if !loaded.UUIDExpiresAt.Equal(cfg.UUIDExpiresAt) { + t.Errorf("UUIDExpiresAt = %v, want %v", loaded.UUIDExpiresAt, cfg.UUIDExpiresAt) + } + if len(loaded.BlockedCommands) != len(cfg.BlockedCommands) { + t.Errorf("BlockedCommands len = %d, want %d", len(loaded.BlockedCommands), len(cfg.BlockedCommands)) + } + if len(loaded.BlockedFolders) != len(cfg.BlockedFolders) { + t.Errorf("BlockedFolders len = %d, want %d", len(loaded.BlockedFolders), len(cfg.BlockedFolders)) + } +} + +func TestLoadConfig_MissingFile(t *testing.T) { + tmpDir := t.TempDir() + configPath := filepath.Join(tmpDir, "nonexistent.json") + t.Setenv("MINISHARE_CONFIG", configPath) + + cfg := LoadConfig() + if cfg.ServerURL != DefaultServerURL { + t.Errorf("LoadConfig() with missing file: ServerURL = %q, want %q", cfg.ServerURL, DefaultServerURL) + } +} + +func TestLoadConfig_CorruptFile(t *testing.T) { + tmpDir := t.TempDir() + configPath := filepath.Join(tmpDir, "corrupt.json") + t.Setenv("MINISHARE_CONFIG", configPath) + + _ = os.WriteFile(configPath, []byte("{invalid json"), 0644) + + cfg := LoadConfig() + if cfg.ServerURL != DefaultServerURL { + t.Errorf("LoadConfig() with corrupt file: ServerURL = %q, want %q", cfg.ServerURL, DefaultServerURL) + } +} + +// --------------------------------------------------------------------------- +// encodePayload / decodePayload round-trip +// --------------------------------------------------------------------------- +func TestEncodeDecodePayload(t *testing.T) { + type TestPayload struct { + Name string `json:"name"` + Value int `json:"value"` + } + + original := TestPayload{Name: "test", Value: 42} + encoded := encodePayload(original) + + if encoded == "" { + t.Fatal("encodePayload() returned empty string") + } + + var decoded TestPayload + decodePayload(encoded, &decoded) + + if decoded.Name != original.Name || decoded.Value != original.Value { + t.Errorf("decodePayload() = %+v, want %+v", decoded, original) + } +} + +// --------------------------------------------------------------------------- +// parseBlockArgs +// --------------------------------------------------------------------------- +func TestParseBlockArgs(t *testing.T) { + tests := []struct { + input []string + want []string + }{ + {[]string{"rm,sudo,shutdown"}, []string{"rm", "sudo", "shutdown"}}, + {[]string{"rm", "sudo"}, []string{"rm", "sudo"}}, + {[]string{"rm, sudo , shutdown"}, []string{"rm", "sudo", "shutdown"}}, + {[]string{}, nil}, + {[]string{","}, nil}, + } + + for _, tc := range tests { + got := parseBlockArgs(tc.input) + if len(got) != len(tc.want) { + t.Errorf("parseBlockArgs(%v) len = %d, want %d", tc.input, len(got), len(tc.want)) + continue + } + for i := range got { + if got[i] != tc.want[i] { + t.Errorf("parseBlockArgs(%v)[%d] = %q, want %q", tc.input, i, got[i], tc.want[i]) + } + } + } +} + +// --------------------------------------------------------------------------- +// HandleBlockCommand / HandleUnblockCommand integration +// --------------------------------------------------------------------------- +func TestBlockUnblockIntegration(t *testing.T) { + tmpDir := t.TempDir() + configPath := filepath.Join(tmpDir, "config.json") + t.Setenv("MINISHARE_CONFIG", configPath) + + // Save initial config + _ = SaveConfig(&Config{ServerURL: DefaultServerURL}) + + // Block some commands + HandleBlockCommand([]string{"cmd", "rm,sudo"}) + + cfg := LoadConfig() + if len(cfg.BlockedCommands) != 2 { + t.Fatalf("After blocking, expected 2 blocked commands, got %d", len(cfg.BlockedCommands)) + } + if cfg.BlockedCommands[0] != "rm" || cfg.BlockedCommands[1] != "sudo" { + t.Errorf("Blocked commands = %v, want [rm sudo]", cfg.BlockedCommands) + } + + // Block duplicates โ€” should not grow + HandleBlockCommand([]string{"cmd", "rm"}) + cfg = LoadConfig() + if len(cfg.BlockedCommands) != 2 { + t.Errorf("After duplicate block, expected 2 blocked commands, got %d", len(cfg.BlockedCommands)) + } + + // Unblock one + HandleUnblockCommand([]string{"cmd", "rm"}) + cfg = LoadConfig() + if len(cfg.BlockedCommands) != 1 { + t.Fatalf("After unblock, expected 1 blocked command, got %d", len(cfg.BlockedCommands)) + } + if cfg.BlockedCommands[0] != "sudo" { + t.Errorf("Remaining blocked command = %q, want 'sudo'", cfg.BlockedCommands[0]) + } +} + +// --------------------------------------------------------------------------- +// HandleResetCommand +// --------------------------------------------------------------------------- +func TestHandleResetCommand_All(t *testing.T) { + tmpDir := t.TempDir() + configPath := filepath.Join(tmpDir, "config.json") + t.Setenv("MINISHARE_CONFIG", configPath) + + // Save a modified config + _ = SaveConfig(&Config{ + ServerURL: "http://custom-server.example.com", + PersistentUUID: "custom-uuid", + BlockedCommands: []string{"rm"}, + BlockedFolders: []string{"/secret"}, + }) + + HandleResetCommand([]string{"all"}) + + cfg := LoadConfig() + if cfg.ServerURL != DefaultServerURL { + t.Errorf("After reset all, ServerURL = %q, want %q", cfg.ServerURL, DefaultServerURL) + } + if cfg.PersistentUUID != "" { + t.Errorf("After reset all, PersistentUUID = %q, want empty", cfg.PersistentUUID) + } + if len(cfg.BlockedCommands) != 0 { + t.Errorf("After reset all, BlockedCommands = %v, want empty", cfg.BlockedCommands) + } + if len(cfg.BlockedFolders) != 0 { + t.Errorf("After reset all, BlockedFolders = %v, want empty", cfg.BlockedFolders) + } +} + +func TestHandleResetCommand_Server(t *testing.T) { + tmpDir := t.TempDir() + configPath := filepath.Join(tmpDir, "config.json") + t.Setenv("MINISHARE_CONFIG", configPath) + + _ = SaveConfig(&Config{ + ServerURL: "http://custom.example.com", + PersistentUUID: "keep-me", + }) + + HandleResetCommand([]string{"server"}) + + cfg := LoadConfig() + if cfg.ServerURL != DefaultServerURL { + t.Errorf("After reset server, ServerURL = %q, want %q", cfg.ServerURL, DefaultServerURL) + } + if cfg.PersistentUUID != "keep-me" { + t.Errorf("After reset server, PersistentUUID should be preserved, got %q", cfg.PersistentUUID) + } +} + +// --------------------------------------------------------------------------- +// GetConfigPath (via MINISHARE_CONFIG env) +// --------------------------------------------------------------------------- +func TestGetConfigPath_FromEnv(t *testing.T) { + expected := "/tmp/test-minishare/config.json" + t.Setenv("MINISHARE_CONFIG", expected) + + got := GetConfigPath() + if got != expected { + t.Errorf("GetConfigPath() = %q, want %q", got, expected) + } +} + +// --------------------------------------------------------------------------- +// postJSON / getJSON with real HTTP (would need a test server) +// These are tested indirectly via the server tests below. +// We test the JSON marshalling here as a sanity check. +// --------------------------------------------------------------------------- +func TestPostJSONMarshal(t *testing.T) { + payload := map[string]string{"key": "value"} + data, err := json.Marshal(payload) + if err != nil { + t.Fatalf("json.Marshal failed: %v", err) + } + if !strings.Contains(string(data), `"key":"value"`) { + t.Errorf("Unexpected JSON: %s", string(data)) + } +} + +// --------------------------------------------------------------------------- +// getDaemonPIDPath / getDaemonUUIDPath / getDaemonLogPath +// --------------------------------------------------------------------------- +func TestDaemonPaths(t *testing.T) { + tmpDir := t.TempDir() + configPath := filepath.Join(tmpDir, "config.json") + t.Setenv("MINISHARE_CONFIG", configPath) + + pidPath := getDaemonPIDPath() + if !strings.HasSuffix(pidPath, "daemon.pid") { + t.Errorf("getDaemonPIDPath() = %q, want suffix daemon.pid", pidPath) + } + + uuidPath := getDaemonUUIDPath() + if !strings.HasSuffix(uuidPath, "daemon.uuid") { + t.Errorf("getDaemonUUIDPath() = %q, want suffix daemon.uuid", uuidPath) + } + + logPath := getDaemonLogPath() + if !strings.HasSuffix(logPath, "daemon.log") { + t.Errorf("getDaemonLogPath() = %q, want suffix daemon.log", logPath) + } + + // They should all be in the same directory + if filepath.Dir(pidPath) != filepath.Dir(uuidPath) || filepath.Dir(uuidPath) != filepath.Dir(logPath) { + t.Error("Daemon paths should all be in the same directory") + } +} + +// --------------------------------------------------------------------------- +// processExists (basic sanity โ€” our own PID should exist) +// --------------------------------------------------------------------------- +func TestProcessExists(t *testing.T) { + pid := os.Getpid() + if !processExists(pid) { + t.Errorf("processExists(%d) = false, want true for own PID", pid) + } + + // Very high PID that almost certainly doesn't exist + if processExists(9999999) { + t.Error("processExists(9999999) = true, expected false") + } +} diff --git a/server/app.html b/server/app.html index acd9e4b..9c39ae7 100644 --- a/server/app.html +++ b/server/app.html @@ -23,6 +23,7 @@ background-color: var(--bg-body); font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; min-height: 100vh; + min-height: 100dvh; display: flex; flex-direction: column; justify-content: center; @@ -34,6 +35,8 @@ width: 92%; max-width: 1100px; height: 75vh; + height: 75dvh; + min-height: 300px; background-color: var(--bg-term); border-radius: 16px; box-shadow: 0 24px 48px rgba(0, 0, 0, 0.28); @@ -113,6 +116,55 @@ .nav-links { display: none; } + body { + padding: 60px 8px 8px 8px; + } + .mac-window { + width: 100%; + height: calc(100vh - 68px); + height: calc(100dvh - 68px); + border-radius: 10px; + } + .window-header { + height: 38px; + padding: 0 10px; + } + .header-center { + font-size: 11px; + gap: 6px; + } + .header-actions { + gap: 8px; + } + .action-link { + font-size: 11px; + } + .setup-overlay { + flex-wrap: wrap; + padding: 10px 12px; + gap: 8px; + } + .setup-overlay input { + min-width: 0; + width: 100%; + font-size: 12px; + padding: 6px 10px; + } + .setup-overlay button { + font-size: 12px; + padding: 6px 12px; + } + #terminal-container { + padding: 8px 10px 10px 10px; + } + .dots { + gap: 6px; + width: 60px; + } + .dot { + width: 10px; + height: 10px; + } } .window-header { height: 46px; @@ -306,6 +358,7 @@ term.open(termContainer); fitAddon.fit(); + // Resize terminal on window resize window.addEventListener('resize', () => fitAddon.fit()); // Focus terminal only when clicking inside mac-window outside input/buttons @@ -562,6 +615,8 @@ document.getElementById('setup-bar').style.display = 'none'; term.clear(); term.focus(); + // Send initial terminal dimensions + sendResize(); dataChannel.send('\r'); }; @@ -635,6 +690,30 @@ showOfflinePrompt(); } + // Send terminal resize control message (SOH + JSON) over data channel + function sendResize() { + if (dataChannel && dataChannel.readyState === 'open') { + const msg = JSON.stringify({ type: 'resize', cols: term.cols, rows: term.rows }); + const soh = new Uint8Array([0x01]); + const payload = new TextEncoder().encode(msg); + const combined = new Uint8Array(soh.length + payload.length); + combined.set(soh); + combined.set(payload, soh.length); + dataChannel.send(combined); + } + } + + // Listen for terminal resize events and send dimensions to host + term.onResize(({ cols, rows }) => { + sendResize(); + }); + + // Re-fit terminal on window resize and notify host + window.removeEventListener('resize', () => fitAddon.fit()); + window.addEventListener('resize', () => { + fitAddon.fit(); + }); + function encodePayload(obj) { return btoa(JSON.stringify(obj)); } diff --git a/server/index.html b/server/index.html index f93899c..9f41a73 100644 --- a/server/index.html +++ b/server/index.html @@ -760,6 +760,110 @@ transform: translateY(0); } + /* Download / Install Section */ + .download { + padding: 96px 0; + background-color: var(--bg-secondary); + border-top: 1px solid var(--border-color); + border-bottom: 1px solid var(--border-color); + } + + .platform-grid { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 20px; + margin-bottom: 32px; + } + + .platform-card { + background: var(--bg-card); + border: 1px solid var(--border-color); + border-radius: 14px; + padding: 28px 20px; + text-align: center; + box-shadow: 0 4px 15px rgba(0, 0, 0, 0.02); + transition: transform 0.2s ease, box-shadow 0.2s ease; + } + + .platform-card:hover { + transform: translateY(-4px); + box-shadow: 0 12px 30px rgba(0, 0, 0, 0.06); + } + + .platform-icon { + font-size: 32px; + margin-bottom: 14px; + } + + .platform-name { + font-family: var(--font-display); + font-size: 17px; + font-weight: 700; + color: var(--color-primary); + margin-bottom: 4px; + } + + .platform-arch { + font-size: 12.5px; + color: var(--color-text-muted); + margin-bottom: 16px; + } + + .platform-btn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 6px; + padding: 8px 20px; + font-size: 13px; + font-weight: 600; + border-radius: 999px; + text-decoration: none; + transition: all 0.2s ease; + background-color: var(--color-primary); + color: #fff; + } + + .platform-btn:hover { + background-color: var(--color-accent-dark); + transform: translateY(-1px); + } + + .platform-btn.secondary { + background: transparent; + color: var(--color-primary); + border: 1px solid var(--border-color); + } + + .platform-btn.secondary:hover { + border-color: var(--color-accent-dark); + color: var(--color-accent-dark); + } + + .platform-note { + font-size: 12px; + color: var(--color-text-muted); + margin-top: 10px; + line-height: 1.4; + } + + .download-actions { + text-align: center; + margin-top: 8px; + } + + @media (max-width: 900px) { + .platform-grid { + grid-template-columns: repeat(2, 1fr); + } + } + + @media (max-width: 500px) { + .platform-grid { + grid-template-columns: 1fr; + } + } + /* Responsive */ @media (max-width: 900px) { .steps-grid { @@ -803,6 +907,7 @@ Get Started @@ -920,6 +1025,69 @@

Connect Instantly

+ +
+
+ +

Available for all major platforms.

+

Download precompiled binaries or install via our script. Choose your platform below.

+ +
+ +
+
๐ŸŽ
+

macOS

+

Apple Silicon (M1/M2/M3/M4)

+ + + Download + +

ARM64 โ€ข .zip

+
+ + +
+
๐Ÿ’ป
+

macOS

+

Intel (x86_64)

+ + + Download + +

AMD64 โ€ข .zip

+
+ + +
+
๐Ÿง
+

Linux

+

x86_64 / AMD64

+ + + Download + +

AMD64 โ€ข .zip

+
+ + +
+
๐ŸชŸ
+

Windows

+

x86_64 (Viewer Only)

+ + + Download .zip + +

Download, extract, add to PATH.
Host mode unavailable on Windows.

+
+
+ + +
+
+
@@ -999,6 +1167,7 @@

Ready to share your terminal?

Instantly start a session and invite your team. Build from source or run via Go.

diff --git a/server/main_test.go b/server/main_test.go new file mode 100644 index 0000000..32d8bc5 --- /dev/null +++ b/server/main_test.go @@ -0,0 +1,455 @@ +package main + +import ( + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +// --------------------------------------------------------------------------- +// Test helpers +// --------------------------------------------------------------------------- + +// setupTestHandler creates a fresh Store and returns an http.Handler with all +// routes registered (matching main.go's setup). Uses httptest.ResponseRecorder +// to avoid network port binding. +func setupTestHandler() http.Handler { + store := NewStore() + mux := http.NewServeMux() + + mux.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte("OK")) + }) + + mux.HandleFunc("/api/session", func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + var req struct { + UUID string `json:"uuid"` + Offer string `json:"offer"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.Offer == "" { + http.Error(w, "Invalid offer payload", http.StatusBadRequest) + return + } + + uuid := strings.TrimSpace(req.UUID) + if uuid == "" { + uuid = generateUUID() + } + + sess := &Session{UUID: uuid, Offer: req.Offer} + store.mu.Lock() + store.sessions[uuid] = sess + store.mu.Unlock() + + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]string{"uuid": uuid}) + }) + + mux.HandleFunc("/api/session/", func(w http.ResponseWriter, r *http.Request) { + parts := strings.Split(strings.Trim(r.URL.Path, "/"), "/") + if len(parts) < 4 { + http.Error(w, "Invalid route", http.StatusNotFound) + return + } + uuid := parts[2] + action := parts[3] + + store.mu.RLock() + sess, exists := store.sessions[uuid] + store.mu.RUnlock() + + if !exists { + http.Error(w, "Session not found or expired", http.StatusNotFound) + return + } + + switch action { + case "offer": + if r.Method != http.MethodGet { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]string{"offer": sess.Offer}) + + case "answer": + if r.Method == http.MethodPost { + var req struct { + Answer string `json:"answer"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.Answer == "" { + http.Error(w, "Invalid answer payload", http.StatusBadRequest) + return + } + store.mu.Lock() + sess.Answer = req.Answer + store.mu.Unlock() + w.WriteHeader(http.StatusOK) + } else if r.Method == http.MethodGet { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]string{"answer": sess.Answer}) + } else { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + } + default: + http.Error(w, "Invalid action", http.StatusNotFound) + } + }) + + mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/" { + w.Header().Set("Content-Type", "text/html; charset=utf-8") + _, _ = w.Write(indexHTML) + return + } + if r.URL.Path == "/app" || strings.HasPrefix(r.URL.Path, "/app/") { + w.Header().Set("Content-Type", "text/html; charset=utf-8") + _, _ = w.Write(appHTML) + return + } + http.NotFound(w, r) + }) + + return mux +} + +// doRequest is a helper that makes an HTTP request via httptest.ResponseRecorder +func doRequest(handler http.Handler, method, path string, body string) *httptest.ResponseRecorder { + var bodyReader io.Reader + if body != "" { + bodyReader = strings.NewReader(body) + } + req := httptest.NewRequest(method, path, bodyReader) + if body != "" { + req.Header.Set("Content-Type", "application/json") + } + rr := httptest.NewRecorder() + handler.ServeHTTP(rr, req) + return rr +} + +// --------------------------------------------------------------------------- +// Health Endpoint +// --------------------------------------------------------------------------- +func TestHealthEndpoint(t *testing.T) { + handler := setupTestHandler() + rr := doRequest(handler, http.MethodGet, "/health", "") + + if rr.Code != http.StatusOK { + t.Errorf("GET /health status = %d, want %d", rr.Code, http.StatusOK) + } + if rr.Body.String() != "OK" { + t.Errorf("GET /health body = %q, want 'OK'", rr.Body.String()) + } +} + +// --------------------------------------------------------------------------- +// Session Creation (POST /api/session) +// --------------------------------------------------------------------------- +func TestCreateSession(t *testing.T) { + handler := setupTestHandler() + rr := doRequest(handler, http.MethodPost, "/api/session", `{"offer":"test-offer-data","uuid":"test-session-123"}`) + + if rr.Code != http.StatusOK { + t.Fatalf("POST /api/session status = %d, want %d", rr.Code, http.StatusOK) + } + + var result map[string]string + if err := json.NewDecoder(rr.Body).Decode(&result); err != nil { + t.Fatalf("Failed to decode response: %v", err) + } + if result["uuid"] != "test-session-123" { + t.Errorf("uuid = %q, want 'test-session-123'", result["uuid"]) + } +} + +func TestCreateSession_GeneratesUUID(t *testing.T) { + handler := setupTestHandler() + rr := doRequest(handler, http.MethodPost, "/api/session", `{"offer":"test-offer-data"}`) + + if rr.Code != http.StatusOK { + t.Fatalf("POST /api/session status = %d, want %d", rr.Code, http.StatusOK) + } + + var result map[string]string + _ = json.NewDecoder(rr.Body).Decode(&result) + if result["uuid"] == "" { + t.Error("Expected auto-generated UUID, got empty string") + } +} + +func TestCreateSession_MissingOffer(t *testing.T) { + handler := setupTestHandler() + rr := doRequest(handler, http.MethodPost, "/api/session", `{"uuid":"test-123"}`) + + if rr.Code != http.StatusBadRequest { + t.Errorf("POST /api/session without offer: status = %d, want %d", rr.Code, http.StatusBadRequest) + } +} + +func TestCreateSession_InvalidJSON(t *testing.T) { + handler := setupTestHandler() + rr := doRequest(handler, http.MethodPost, "/api/session", "{invalid json") + + if rr.Code != http.StatusBadRequest { + t.Errorf("POST /api/session with invalid JSON: status = %d, want %d", rr.Code, http.StatusBadRequest) + } +} + +func TestCreateSession_MethodNotAllowed(t *testing.T) { + handler := setupTestHandler() + rr := doRequest(handler, http.MethodGet, "/api/session", "") + + if rr.Code != http.StatusMethodNotAllowed { + t.Errorf("GET /api/session status = %d, want %d", rr.Code, http.StatusMethodNotAllowed) + } +} + +// --------------------------------------------------------------------------- +// Get Offer (GET /api/session/{uuid}/offer) +// --------------------------------------------------------------------------- +func TestGetOffer(t *testing.T) { + handler := setupTestHandler() + + // Create session first + doRequest(handler, http.MethodPost, "/api/session", `{"offer":"my-offer-sdp","uuid":"offer-test-1"}`) + + // Get offer + rr := doRequest(handler, http.MethodGet, "/api/session/offer-test-1/offer", "") + + if rr.Code != http.StatusOK { + t.Fatalf("GET offer status = %d, want %d", rr.Code, http.StatusOK) + } + + var result map[string]string + _ = json.NewDecoder(rr.Body).Decode(&result) + if result["offer"] != "my-offer-sdp" { + t.Errorf("offer = %q, want 'my-offer-sdp'", result["offer"]) + } +} + +func TestGetOffer_NotFound(t *testing.T) { + handler := setupTestHandler() + rr := doRequest(handler, http.MethodGet, "/api/session/nonexistent/offer", "") + + if rr.Code != http.StatusNotFound { + t.Errorf("GET offer for missing session: status = %d, want %d", rr.Code, http.StatusNotFound) + } +} + +// --------------------------------------------------------------------------- +// Answer (POST + GET /api/session/{uuid}/answer) +// --------------------------------------------------------------------------- +func TestPostAndGetAnswer(t *testing.T) { + handler := setupTestHandler() + + // Create session + doRequest(handler, http.MethodPost, "/api/session", `{"offer":"offer-data","uuid":"answer-test-1"}`) + + // Post answer + rr := doRequest(handler, http.MethodPost, "/api/session/answer-test-1/answer", `{"answer":"my-answer-sdp"}`) + if rr.Code != http.StatusOK { + t.Errorf("POST answer status = %d, want %d", rr.Code, http.StatusOK) + } + + // Get answer + rr2 := doRequest(handler, http.MethodGet, "/api/session/answer-test-1/answer", "") + if rr2.Code != http.StatusOK { + t.Fatalf("GET answer status = %d, want %d", rr2.Code, http.StatusOK) + } + + var result map[string]string + _ = json.NewDecoder(rr2.Body).Decode(&result) + if result["answer"] != "my-answer-sdp" { + t.Errorf("answer = %q, want 'my-answer-sdp'", result["answer"]) + } +} + +func TestPostAnswer_MissingAnswer(t *testing.T) { + handler := setupTestHandler() + + // Create session + doRequest(handler, http.MethodPost, "/api/session", `{"offer":"offer-data","uuid":"missing-answer-test"}`) + + // Post empty answer + rr := doRequest(handler, http.MethodPost, "/api/session/missing-answer-test/answer", `{}`) + if rr.Code != http.StatusBadRequest { + t.Errorf("POST answer without answer field: status = %d, want %d", rr.Code, http.StatusBadRequest) + } +} + +func TestGetAnswer_EmptyBeforeSet(t *testing.T) { + handler := setupTestHandler() + + // Create session + doRequest(handler, http.MethodPost, "/api/session", `{"offer":"offer-data","uuid":"empty-answer-test"}`) + + // Get answer before it's set + rr := doRequest(handler, http.MethodGet, "/api/session/empty-answer-test/answer", "") + + var result map[string]string + _ = json.NewDecoder(rr.Body).Decode(&result) + if result["answer"] != "" { + t.Errorf("answer before set = %q, want empty", result["answer"]) + } +} + +// --------------------------------------------------------------------------- +// Static Pages (GET / and GET /app) +// --------------------------------------------------------------------------- +func TestRootPage(t *testing.T) { + handler := setupTestHandler() + rr := doRequest(handler, http.MethodGet, "/", "") + + if rr.Code != http.StatusOK { + t.Errorf("GET / status = %d, want %d", rr.Code, http.StatusOK) + } + + ct := rr.Header().Get("Content-Type") + if !strings.Contains(ct, "text/html") { + t.Errorf("GET / Content-Type = %q, want text/html", ct) + } + + body := rr.Body.String() + if !strings.Contains(body, "MiniShare") { + t.Error("GET / body doesn't contain 'MiniShare'") + } +} + +func TestAppPage(t *testing.T) { + handler := setupTestHandler() + rr := doRequest(handler, http.MethodGet, "/app", "") + + if rr.Code != http.StatusOK { + t.Errorf("GET /app status = %d, want %d", rr.Code, http.StatusOK) + } + + ct := rr.Header().Get("Content-Type") + if !strings.Contains(ct, "text/html") { + t.Errorf("GET /app Content-Type = %q, want text/html", ct) + } +} + +func TestAppPage_WithUUID(t *testing.T) { + handler := setupTestHandler() + rr := doRequest(handler, http.MethodGet, "/app/some-uuid-here", "") + + if rr.Code != http.StatusOK { + t.Errorf("GET /app/uuid status = %d, want %d", rr.Code, http.StatusOK) + } +} + +func TestNotFoundPage(t *testing.T) { + handler := setupTestHandler() + rr := doRequest(handler, http.MethodGet, "/nonexistent", "") + + if rr.Code != http.StatusNotFound { + t.Errorf("GET /nonexistent status = %d, want %d", rr.Code, http.StatusNotFound) + } +} + +// --------------------------------------------------------------------------- +// Invalid Session Action +// --------------------------------------------------------------------------- +func TestInvalidAction(t *testing.T) { + handler := setupTestHandler() + + // Create session + doRequest(handler, http.MethodPost, "/api/session", `{"offer":"offer-data","uuid":"action-test"}`) + + rr := doRequest(handler, http.MethodGet, "/api/session/action-test/invalid", "") + if rr.Code != http.StatusNotFound { + t.Errorf("GET invalid action: status = %d, want %d", rr.Code, http.StatusNotFound) + } +} + +// --------------------------------------------------------------------------- +// Store and generateUUID +// --------------------------------------------------------------------------- +func TestNewStore(t *testing.T) { + store := NewStore() + if store == nil { + t.Fatal("NewStore() returned nil") + } + if store.sessions == nil { + t.Fatal("NewStore().sessions is nil") + } +} + +func TestGenerateUUID_Server(t *testing.T) { + uuid := generateUUID() + if uuid == "" { + t.Fatal("generateUUID() returned empty string") + } + if strings.Count(uuid, "-") != 4 { + t.Errorf("generateUUID() = %q, expected 4 dashes", uuid) + } +} + +// --------------------------------------------------------------------------- +// Full Flow: Create โ†’ Get Offer โ†’ Post Answer โ†’ Get Answer +// --------------------------------------------------------------------------- +func TestFullSignalingFlow(t *testing.T) { + handler := setupTestHandler() + + // 1. Host creates session + createRR := doRequest(handler, http.MethodPost, "/api/session", `{"offer":"host-offer-sdp","uuid":"flow-test-123"}`) + if createRR.Code != http.StatusOK { + t.Fatalf("Create session status = %d", createRR.Code) + } + + // 2. Viewer fetches offer + offerRR := doRequest(handler, http.MethodGet, "/api/session/flow-test-123/offer", "") + var offerData map[string]string + _ = json.NewDecoder(offerRR.Body).Decode(&offerData) + if offerData["offer"] != "host-offer-sdp" { + t.Errorf("Offer = %q, want 'host-offer-sdp'", offerData["offer"]) + } + + // 3. Viewer posts answer + answerRR := doRequest(handler, http.MethodPost, "/api/session/flow-test-123/answer", `{"answer":"viewer-answer-sdp"}`) + if answerRR.Code != http.StatusOK { + t.Fatalf("Post answer status = %d", answerRR.Code) + } + + // 4. Host fetches answer + getAnswerRR := doRequest(handler, http.MethodGet, "/api/session/flow-test-123/answer", "") + var answerData map[string]string + _ = json.NewDecoder(getAnswerRR.Body).Decode(&answerData) + if answerData["answer"] != "viewer-answer-sdp" { + t.Errorf("Answer = %q, want 'viewer-answer-sdp'", answerData["answer"]) + } +} + +// --------------------------------------------------------------------------- +// Landing page includes platform download section (Bug 4 & 5 verification) +// --------------------------------------------------------------------------- +func TestLandingPage_ContainsPlatformDownloads(t *testing.T) { + handler := setupTestHandler() + rr := doRequest(handler, http.MethodGet, "/", "") + + body := rr.Body.String() + checks := []string{ + "Download & Install", + "Apple Silicon", + "Intel (x86_64)", + "Linux", + "Windows", + "releases/latest", + "View All Releases", + } + + for _, check := range checks { + if !strings.Contains(body, check) { + t.Errorf("Landing page missing expected content: %q", check) + } + } +}