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
135 changes: 135 additions & 0 deletions auth/product.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
package auth

import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"os"
"path/filepath"

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

const productInfoUrl = "https://developers.indiegala.com/get_product_info"

type ProductInfo struct {
Status string `json:"status"`
Message string `json:"message"`

GameDetails *GameDetails `json:"product_data,omitempty"`
}

type GameDetails struct {
ExePath string `json:"exe_path,omitempty"`
Args string `json:"args,omitempty"`
Cwd string `json:"cwd,omitempty"`
}

// productInfoDir returns the directory for storing game details
func productInfoDir() (string, error) {
dir, err := configDir()
if err != nil {
return "", err
}
productPath := filepath.Join(dir, "product")
if err := os.MkdirAll(productPath, 0o700); err != nil {
return "", err
}
return productPath, nil
}

// gameDetailsPath returns the file path to the specified game's details
func gameDetailsPath(slug string) (string, error) {
dir, err := productInfoDir()
if err != nil {
return "", err
}
return filepath.Join(dir, fmt.Sprintf("%s.json", slug)), nil
}

// SaveGameDetails saves the provided GameDetails for the specified game slug to a file.
func SaveGameDetails(slug string, gameDetails *GameDetails) error {
path, err := gameDetailsPath(slug)
if err != nil {
return err
}

data, err := json.MarshalIndent(gameDetails, "", " ")
if err != nil {
return err
}

return os.WriteFile(path, data, 0o600)
}

// FetchGameDetails gets game details from the API (e.g. exe_name, args...).
func FetchGameDetails(ctx context.Context, client *http.Client, slug string) (*GameDetails, error) {
return getProductInfoWithUrl(ctx, client, productInfoUrl, slug)
}

// getProductInfoWithUrl is an internal function that calls the server
// to fetch game details
func getProductInfoWithUrl(ctx context.Context, client *http.Client, targetURL, slug string) (*GameDetails, error) {
products, err := LoadLibrary()
if err != nil {
return nil, err
}
product := FindProductBySlug(products, slug)
if product == nil {
return nil, fmt.Errorf("couldn't find %s in library", slug)
}

endpoint, err := url.Parse(targetURL)
if err != nil {
return nil, err
}

queryParams := url.Values{}
queryParams.Add("dev_id", product.Namespace)
queryParams.Add("prod_name", slug)

endpoint.RawQuery = queryParams.Encode()

req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint.String(), nil)
if err != nil {
return nil, err
}

req.Header.Set("User-Agent", "galaClient")
req.Header.Set("Content-Type", "application/json")

resp, err := client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()

bodyBytes, err := io.ReadAll(resp.Body)
if err != nil {
return nil, err
}
bodyStr := string(bodyBytes)

var pi ProductInfo
if err := json.Unmarshal(bodyBytes, &pi); err != nil {
// If JSON decoding fails, treat as hard error
return nil, fmt.Errorf("failed to decode product info response: %w (body: %s)", err, bodyStr)
}

if resp.StatusCode == http.StatusOK && pi.Status == "success" {
if pi.GameDetails == nil {
return nil, fmt.Errorf("server didn't respond with game details: (body: %s)", bodyStr)
}

if err := SaveGameDetails(slug, pi.GameDetails); err != nil {
logger.Warn("Failed to save product info", "slug", slug, "error", err)
}

return pi.GameDetails, nil
}

return nil, fmt.Errorf("fetching product info failed: %s (%s)", pi.Message, pi.Status)
}
35 changes: 35 additions & 0 deletions cmd_info.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"fmt"

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

Expand Down Expand Up @@ -42,6 +43,40 @@ Shows the game name, available versions for each platform, and installation stat
fmt.Printf("ID: %d\n", product.ID)
fmt.Printf("Namespace: %s\n", product.Namespace)

client, _, err := auth.LoadSessionClient()

if err == nil {
// Display game details
gameDetails, err := auth.FetchGameDetails(cmd.Context(), client, product.SluggedName)

if err == nil {
fmt.Println()

exePath := "None"
if gameDetails.ExePath != "" {
exePath = gameDetails.ExePath
}

args := "None"
if gameDetails.Args != "" {
args = gameDetails.Args
}

cwd := "None"
if gameDetails.Cwd != "" {
cwd = gameDetails.Cwd
}

fmt.Printf("Exe Path: %s\n", exePath)
fmt.Printf("Args: %s\n", args)
fmt.Printf("Cwd: %s\n", cwd)
} else {
logger.Warn("Failed to fetch game details", "error", err)
}
} else {
logger.Warn("Failed to get session client", "error", err)
}

// Installation status
fmt.Println()
if installInfo != nil {
Expand Down
17 changes: 11 additions & 6 deletions cmd_install.go
Original file line number Diff line number Diff line change
Expand Up @@ -124,18 +124,23 @@ the latest version for the current OS will be used.`,
}
}

logger.Info("Installing game",
"name", product.Name,
"version", productVersion.Version,
"os", productVersion.OS,
"path", installPath)

// Load session for authenticated downloads
client, _, err := auth.LoadSessionClient()
if err != nil {
return fmt.Errorf("could not load session: %w (try running 'login' first)", err)
}

logger.Info("Fetching game details")
if _, err := auth.FetchGameDetails(cmd.Context(), client, product.SluggedName); err != nil {
logger.Warn("Failed to fetch game details", "error", err)
}

logger.Info("Installing game",
"name", product.Name,
"version", productVersion.Version,
"os", productVersion.OS,
"path", installPath)

// Create download options
opts := download.Options{
MaxDownloadWorkers: maxDownloadWorkers,
Expand Down
72 changes: 41 additions & 31 deletions cmd_launch.go
Original file line number Diff line number Diff line change
Expand Up @@ -38,12 +38,6 @@ Use --wine to specify a custom Wine path, --wrapper to use a custom wrapper
RunE: func(cmd *cobra.Command, args []string) error {
slug := args[0]

// Get game arguments (everything after --)
var gameArgs []string
if cmd.ArgsLenAtDash() > 0 {
gameArgs = args[cmd.ArgsLenAtDash():]
}

// Check if game is installed
installInfo, err := auth.GetInstalled(slug)
if err != nil {
Expand All @@ -53,38 +47,54 @@ Use --wine to specify a custom Wine path, --wrapper to use a custom wrapper
return fmt.Errorf("%s is not installed", slug)
}

// Find executables
executables, err := launch.FindExecutables(installInfo.InstallPath, installInfo.OS)
if err != nil {
return fmt.Errorf("failed to find executables: %w", err)
// Fetch game details
var exe *launch.Executable
var gameArgs []string
if client, _, err := auth.LoadSessionClient(); err == nil {
exe, gameArgs, err = launch.FindDeclaredExecutable(cmd.Context(), client, installInfo, slug)
if err != nil {
logger.Warn("Failed to get executable details from server, falling back to manual search", "error", err)
}
}

if len(executables) == 0 {
return fmt.Errorf("no executables found in %s", installInfo.InstallPath)
// Get game arguments (everything after --)
if cmd.ArgsLenAtDash() > 0 {
gameArgs = append(gameArgs, args[cmd.ArgsLenAtDash():]...)
}

// If --list flag, just show executables and exit
if list {
fmt.Printf("Executables for %s:\n\n", slug)
for i, exe := range executables {
fmt.Printf(" %d. %s\n", i+1, exe.Name)
fmt.Printf(" %s\n", exe.Path)
if exe == nil {
// Find executables
executables, err := launch.FindExecutables(installInfo.InstallPath, installInfo.OS)
if err != nil {
return fmt.Errorf("failed to find executables: %w", err)
}
return nil
}

// Select executable (use first one if multiple found and no --exe specified)
var exe *launch.Executable
if exeName != "" {
exe, err = launch.SelectExecutable(executables, exeName)
if err != nil {
return err
if len(executables) == 0 {
return fmt.Errorf("no executables found in %s", installInfo.InstallPath)
}
} else {
exe = &executables[0]
if len(executables) > 1 {
logger.Info("Multiple executables found, using first", "exe", exe.Name)
logger.Info("Use --list to see all, --exe <name> to specify another")

// If --list flag, just show executables and exit
if list {
fmt.Printf("Executables for %s:\n\n", slug)
for i, exe := range executables {
fmt.Printf(" %d. %s\n", i+1, exe.Name)
fmt.Printf(" %s\n", exe.Path)
}
return nil
}

// Select executable (use first one if multiple found and no --exe specified)
if exeName != "" {
exe, err = launch.SelectExecutable(executables, exeName)
if err != nil {
return err
}
} else {
exe = &executables[0]
if len(executables) > 1 {
logger.Info("Multiple executables found, using first", "exe", exe.Name)
logger.Info("Use --list to see all, --exe <name> to specify another")
}
}
}

Expand Down
50 changes: 50 additions & 0 deletions launch/launch.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,15 +4,18 @@ package launch
import (
"context"
"fmt"
"net/http"
"os"
"os/exec"
"path"
"path/filepath"
"runtime"
"strings"

"github.com/google/shlex"
"github.com/gustash/freecarnival/auth"
"github.com/gustash/freecarnival/logger"
"github.com/gustash/freecarnival/manifest"
)

// Executable represents a launchable executable.
Expand Down Expand Up @@ -341,3 +344,50 @@ func SelectExecutable(executables []Executable, exeName string) (*Executable, er

return nil, fmt.Errorf("multiple executables found, please specify one with --exe")
}

// FindDeclaredExecutable looks for GameDetails for the game to launch and returns the executable and args
// defined there, if any
func FindDeclaredExecutable(ctx context.Context, client *http.Client, installInfo *auth.InstallInfo, slug string) (*Executable, []string, error) {
// The game details exe and args are only valid for Windows builds
// If the installed build is any other OS, let's ignore them
if installInfo.OS != auth.BuildOSWindows {
return nil, nil, nil
}
var exe *Executable
var args []string

logger.Debug("Looking for game details")
gameDetails, err := auth.FetchGameDetails(ctx, client, slug)
if err != nil {
return nil, nil, err
}

if gameDetails.Args != "" {
// Some games use $ to separate the args. Not sure if this logic works for all cases
if strings.Contains(gameDetails.Args, "$") {
args = strings.Split(gameDetails.Args, "$")
} else {
args, err = shlex.Split(gameDetails.Args)
if err != nil {
logger.Warn("Failed to parse args, skipping them", "args", gameDetails.Args, "error", err)
}
}
}

if gameDetails.ExePath != "" {
// Not too sure about this. At least syberia-ii prepends the slugged name to the
// path of the exe. I assume the galaClient always installs in folders with the
// slugged name, but since we don't do that here, we skip it.
// This might break if some games don't do this, and if that happens, we should
// find a better solution for handling this.
exePath := strings.Replace(gameDetails.ExePath, fmt.Sprintf("%s\\", slug), "", 1)
exePath = path.Join(installInfo.InstallPath, exePath)
exePath = manifest.NormalizePath(exePath)
exe = &Executable{
Path: exePath,
Name: filepath.Base(exePath),
}
}

return exe, args, nil
}