Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion cmd_install.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"fmt"
"os"
"path/filepath"
"runtime"

"github.com/gustash/freecarnival/auth"
"github.com/gustash/freecarnival/download"
Expand Down Expand Up @@ -65,7 +66,13 @@ the latest version for the current OS will be used.`,
buildOS = auth.BuildOSMac
case "":
// Default based on current OS
buildOS = auth.BuildOSWindows // Default to Windows if not specified
// macOS: prefer native builds
// Linux: prefer Windows builds (for Wine compatibility)
if runtime.GOOS == "darwin" {
buildOS = auth.BuildOSMac
} else {
buildOS = auth.BuildOSWindows
}
default:
return fmt.Errorf("invalid OS '%s': must be windows, linux, or mac", targetOS)
}
Expand Down
8 changes: 7 additions & 1 deletion cmd_launch.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
package main

import (
"context"
"errors"
"fmt"

"github.com/gustash/freecarnival/auth"
Expand Down Expand Up @@ -101,7 +103,11 @@ Use --wine to specify a custom Wine path, or --no-wine to disable Wine.`,
WinePrefix: winePrefix,
NoWine: noWine,
}
if err := launch.Game(exe.Path, installInfo.OS, gameArgs, launchOpts); err != nil {
if err := launch.Game(cmd.Context(), exe.Path, installInfo.OS, gameArgs, launchOpts); err != nil {
// Context cancellation (Ctrl+C) is not an error - user intentionally killed the game
if errors.Is(err, context.Canceled) {
return nil
}
return fmt.Errorf("failed to launch game: %w", err)
}

Expand Down
90 changes: 66 additions & 24 deletions launch/launch.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,17 @@
package launch

import (
"context"
"fmt"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"syscall"

"github.com/gustash/freecarnival/auth"
"github.com/gustash/freecarnival/logger"
)

// Executable represents a launchable executable.
Expand Down Expand Up @@ -157,7 +160,8 @@ func isIgnoredExecutable(name string) bool {
}

// Game launches the specified executable with optional arguments.
func Game(executablePath string, buildOS auth.BuildOS, args []string, opts *Options) error {
// It waits for the process to complete and kills it if the context is cancelled.
func Game(ctx context.Context, executablePath string, buildOS auth.BuildOS, args []string, opts *Options) error {
if _, err := os.Stat(executablePath); os.IsNotExist(err) {
return fmt.Errorf("executable not found: %s", executablePath)
}
Expand All @@ -166,41 +170,26 @@ func Game(executablePath string, buildOS auth.BuildOS, args []string, opts *Opti
opts = &Options{}
}

// On macOS, use 'open' command for .app bundles
if runtime.GOOS == "darwin" && strings.Contains(executablePath, ".app/Contents/MacOS/") {
appPath := executablePath
if idx := strings.Index(executablePath, ".app/"); idx != -1 {
appPath = executablePath[:idx+4]
}

cmdArgs := []string{"-a", appPath}
if len(args) > 0 {
cmdArgs = append(cmdArgs, "--args")
cmdArgs = append(cmdArgs, args...)
}

cmd := exec.Command("open", cmdArgs...)
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
return cmd.Run()
}

needsWine := buildOS == auth.BuildOSWindows && runtime.GOOS != "windows" && !opts.NoWine

if needsWine {
return launchWithWine(executablePath, args, opts)
return launchWithWine(ctx, executablePath, args, opts)
}

return launchNative(ctx, executablePath, args)
}

func launchNative(ctx context.Context, executablePath string, args []string) error {
cmd := exec.Command(executablePath, args...)
cmd.Dir = filepath.Dir(executablePath)
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
cmd.Stdin = os.Stdin

return cmd.Start()
return launchProcess(ctx, cmd)
}

func launchWithWine(executablePath string, args []string, opts *Options) error {
func launchWithWine(ctx context.Context, executablePath string, args []string, opts *Options) error {
winePath := opts.WinePath
if winePath == "" {
winePath = findWine()
Expand All @@ -227,7 +216,60 @@ func launchWithWine(executablePath string, args []string, opts *Options) error {
cmd.Env = append(os.Environ(), "WINEPREFIX="+opts.WinePrefix)
}

return cmd.Start()
return launchProcess(ctx, cmd)
}

func launchProcess(ctx context.Context, cmd *exec.Cmd) error {
// Set up process group on Unix systems
if runtime.GOOS != "windows" {
cmd.SysProcAttr = &syscall.SysProcAttr{
Setpgid: true,
}
}

if err := cmd.Start(); err != nil {
return err
}

// Wait for process or context cancellation
done := make(chan error, 1)
go func() {
done <- cmd.Wait()
}()

select {
case <-ctx.Done():
logger.Info("Terminating game process...")
if err := killProcessGroup(cmd); err != nil {
logger.Warn("Failed to kill process group", "error", err)
}
<-done
return ctx.Err()
case err := <-done:
return err
}
}

// killProcessGroup kills the process and its entire process group
func killProcessGroup(cmd *exec.Cmd) error {
if cmd.Process == nil {
return nil
}

if runtime.GOOS == "windows" {
// Use taskkill to kill the process tree on Windows
taskkill := exec.Command("taskkill", "/PID", fmt.Sprintf("%d", cmd.Process.Pid), "/T", "/F")
return taskkill.Run()
}

// On Unix, kill the entire process group
pgid, err := syscall.Getpgid(cmd.Process.Pid)
if err != nil {
return cmd.Process.Kill()
}

// Kill process group (negative PID kills the group)
return syscall.Kill(-pgid, syscall.SIGTERM)
}

var defaultWineCandidates = []string{
Expand Down
Loading