Skip to content

Commit dfaa484

Browse files
GustashCopilot
andauthored
feat: implement game details for launching declared exe and args (#15)
* feat: fetching/storing product info * feat: use game details for executable and args * chore: document SaveGameDetails Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * fix: bad argument Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * fix: address PR issues --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
1 parent ad406a0 commit dfaa484

5 files changed

Lines changed: 272 additions & 37 deletions

File tree

auth/product.go

Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
package auth
2+
3+
import (
4+
"context"
5+
"encoding/json"
6+
"fmt"
7+
"io"
8+
"net/http"
9+
"net/url"
10+
"os"
11+
"path/filepath"
12+
13+
"github.com/gustash/freecarnival/logger"
14+
)
15+
16+
const productInfoUrl = "https://developers.indiegala.com/get_product_info"
17+
18+
type ProductInfo struct {
19+
Status string `json:"status"`
20+
Message string `json:"message"`
21+
22+
GameDetails *GameDetails `json:"product_data,omitempty"`
23+
}
24+
25+
type GameDetails struct {
26+
ExePath string `json:"exe_path,omitempty"`
27+
Args string `json:"args,omitempty"`
28+
Cwd string `json:"cwd,omitempty"`
29+
}
30+
31+
// productInfoDir returns the directory for storing game details
32+
func productInfoDir() (string, error) {
33+
dir, err := configDir()
34+
if err != nil {
35+
return "", err
36+
}
37+
productPath := filepath.Join(dir, "product")
38+
if err := os.MkdirAll(productPath, 0o700); err != nil {
39+
return "", err
40+
}
41+
return productPath, nil
42+
}
43+
44+
// gameDetailsPath returns the file path to the specified game's details
45+
func gameDetailsPath(slug string) (string, error) {
46+
dir, err := productInfoDir()
47+
if err != nil {
48+
return "", err
49+
}
50+
return filepath.Join(dir, fmt.Sprintf("%s.json", slug)), nil
51+
}
52+
53+
// SaveGameDetails saves the provided GameDetails for the specified game slug to a file.
54+
func SaveGameDetails(slug string, gameDetails *GameDetails) error {
55+
path, err := gameDetailsPath(slug)
56+
if err != nil {
57+
return err
58+
}
59+
60+
data, err := json.MarshalIndent(gameDetails, "", " ")
61+
if err != nil {
62+
return err
63+
}
64+
65+
return os.WriteFile(path, data, 0o600)
66+
}
67+
68+
// FetchGameDetails gets game details from the API (e.g. exe_name, args...).
69+
func FetchGameDetails(ctx context.Context, client *http.Client, slug string) (*GameDetails, error) {
70+
return getProductInfoWithUrl(ctx, client, productInfoUrl, slug)
71+
}
72+
73+
// getProductInfoWithUrl is an internal function that calls the server
74+
// to fetch game details
75+
func getProductInfoWithUrl(ctx context.Context, client *http.Client, targetURL, slug string) (*GameDetails, error) {
76+
products, err := LoadLibrary()
77+
if err != nil {
78+
return nil, err
79+
}
80+
product := FindProductBySlug(products, slug)
81+
if product == nil {
82+
return nil, fmt.Errorf("couldn't find %s in library", slug)
83+
}
84+
85+
endpoint, err := url.Parse(targetURL)
86+
if err != nil {
87+
return nil, err
88+
}
89+
90+
queryParams := url.Values{}
91+
queryParams.Add("dev_id", product.Namespace)
92+
queryParams.Add("prod_name", slug)
93+
94+
endpoint.RawQuery = queryParams.Encode()
95+
96+
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint.String(), nil)
97+
if err != nil {
98+
return nil, err
99+
}
100+
101+
req.Header.Set("User-Agent", "galaClient")
102+
req.Header.Set("Content-Type", "application/json")
103+
104+
resp, err := client.Do(req)
105+
if err != nil {
106+
return nil, err
107+
}
108+
defer resp.Body.Close()
109+
110+
bodyBytes, err := io.ReadAll(resp.Body)
111+
if err != nil {
112+
return nil, err
113+
}
114+
bodyStr := string(bodyBytes)
115+
116+
var pi ProductInfo
117+
if err := json.Unmarshal(bodyBytes, &pi); err != nil {
118+
// If JSON decoding fails, treat as hard error
119+
return nil, fmt.Errorf("failed to decode product info response: %w (body: %s)", err, bodyStr)
120+
}
121+
122+
if resp.StatusCode == http.StatusOK && pi.Status == "success" {
123+
if pi.GameDetails == nil {
124+
return nil, fmt.Errorf("server didn't respond with game details: (body: %s)", bodyStr)
125+
}
126+
127+
if err := SaveGameDetails(slug, pi.GameDetails); err != nil {
128+
logger.Warn("Failed to save product info", "slug", slug, "error", err)
129+
}
130+
131+
return pi.GameDetails, nil
132+
}
133+
134+
return nil, fmt.Errorf("fetching product info failed: %s (%s)", pi.Message, pi.Status)
135+
}

cmd_info.go

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import (
44
"fmt"
55

66
"github.com/gustash/freecarnival/auth"
7+
"github.com/gustash/freecarnival/logger"
78
"github.com/spf13/cobra"
89
)
910

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

46+
client, _, err := auth.LoadSessionClient()
47+
48+
if err == nil {
49+
// Display game details
50+
gameDetails, err := auth.FetchGameDetails(cmd.Context(), client, product.SluggedName)
51+
52+
if err == nil {
53+
fmt.Println()
54+
55+
exePath := "None"
56+
if gameDetails.ExePath != "" {
57+
exePath = gameDetails.ExePath
58+
}
59+
60+
args := "None"
61+
if gameDetails.Args != "" {
62+
args = gameDetails.Args
63+
}
64+
65+
cwd := "None"
66+
if gameDetails.Cwd != "" {
67+
cwd = gameDetails.Cwd
68+
}
69+
70+
fmt.Printf("Exe Path: %s\n", exePath)
71+
fmt.Printf("Args: %s\n", args)
72+
fmt.Printf("Cwd: %s\n", cwd)
73+
} else {
74+
logger.Warn("Failed to fetch game details", "error", err)
75+
}
76+
} else {
77+
logger.Warn("Failed to get session client", "error", err)
78+
}
79+
4580
// Installation status
4681
fmt.Println()
4782
if installInfo != nil {

cmd_install.go

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -124,18 +124,23 @@ the latest version for the current OS will be used.`,
124124
}
125125
}
126126

127-
logger.Info("Installing game",
128-
"name", product.Name,
129-
"version", productVersion.Version,
130-
"os", productVersion.OS,
131-
"path", installPath)
132-
133127
// Load session for authenticated downloads
134128
client, _, err := auth.LoadSessionClient()
135129
if err != nil {
136130
return fmt.Errorf("could not load session: %w (try running 'login' first)", err)
137131
}
138132

133+
logger.Info("Fetching game details")
134+
if _, err := auth.FetchGameDetails(cmd.Context(), client, product.SluggedName); err != nil {
135+
logger.Warn("Failed to fetch game details", "error", err)
136+
}
137+
138+
logger.Info("Installing game",
139+
"name", product.Name,
140+
"version", productVersion.Version,
141+
"os", productVersion.OS,
142+
"path", installPath)
143+
139144
// Create download options
140145
opts := download.Options{
141146
MaxDownloadWorkers: maxDownloadWorkers,

cmd_launch.go

Lines changed: 41 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -38,12 +38,6 @@ Use --wine to specify a custom Wine path, --wrapper to use a custom wrapper
3838
RunE: func(cmd *cobra.Command, args []string) error {
3939
slug := args[0]
4040

41-
// Get game arguments (everything after --)
42-
var gameArgs []string
43-
if cmd.ArgsLenAtDash() > 0 {
44-
gameArgs = args[cmd.ArgsLenAtDash():]
45-
}
46-
4741
// Check if game is installed
4842
installInfo, err := auth.GetInstalled(slug)
4943
if err != nil {
@@ -53,38 +47,54 @@ Use --wine to specify a custom Wine path, --wrapper to use a custom wrapper
5347
return fmt.Errorf("%s is not installed", slug)
5448
}
5549

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

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

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

76-
// Select executable (use first one if multiple found and no --exe specified)
77-
var exe *launch.Executable
78-
if exeName != "" {
79-
exe, err = launch.SelectExecutable(executables, exeName)
80-
if err != nil {
81-
return err
72+
if len(executables) == 0 {
73+
return fmt.Errorf("no executables found in %s", installInfo.InstallPath)
8274
}
83-
} else {
84-
exe = &executables[0]
85-
if len(executables) > 1 {
86-
logger.Info("Multiple executables found, using first", "exe", exe.Name)
87-
logger.Info("Use --list to see all, --exe <name> to specify another")
75+
76+
// If --list flag, just show executables and exit
77+
if list {
78+
fmt.Printf("Executables for %s:\n\n", slug)
79+
for i, exe := range executables {
80+
fmt.Printf(" %d. %s\n", i+1, exe.Name)
81+
fmt.Printf(" %s\n", exe.Path)
82+
}
83+
return nil
84+
}
85+
86+
// Select executable (use first one if multiple found and no --exe specified)
87+
if exeName != "" {
88+
exe, err = launch.SelectExecutable(executables, exeName)
89+
if err != nil {
90+
return err
91+
}
92+
} else {
93+
exe = &executables[0]
94+
if len(executables) > 1 {
95+
logger.Info("Multiple executables found, using first", "exe", exe.Name)
96+
logger.Info("Use --list to see all, --exe <name> to specify another")
97+
}
8898
}
8999
}
90100

launch/launch.go

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,15 +4,18 @@ package launch
44
import (
55
"context"
66
"fmt"
7+
"net/http"
78
"os"
89
"os/exec"
10+
"path"
911
"path/filepath"
1012
"runtime"
1113
"strings"
1214

1315
"github.com/google/shlex"
1416
"github.com/gustash/freecarnival/auth"
1517
"github.com/gustash/freecarnival/logger"
18+
"github.com/gustash/freecarnival/manifest"
1619
)
1720

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

342345
return nil, fmt.Errorf("multiple executables found, please specify one with --exe")
343346
}
347+
348+
// FindDeclaredExecutable looks for GameDetails for the game to launch and returns the executable and args
349+
// defined there, if any
350+
func FindDeclaredExecutable(ctx context.Context, client *http.Client, installInfo *auth.InstallInfo, slug string) (*Executable, []string, error) {
351+
// The game details exe and args are only valid for Windows builds
352+
// If the installed build is any other OS, let's ignore them
353+
if installInfo.OS != auth.BuildOSWindows {
354+
return nil, nil, nil
355+
}
356+
var exe *Executable
357+
var args []string
358+
359+
logger.Debug("Looking for game details")
360+
gameDetails, err := auth.FetchGameDetails(ctx, client, slug)
361+
if err != nil {
362+
return nil, nil, err
363+
}
364+
365+
if gameDetails.Args != "" {
366+
// Some games use $ to separate the args. Not sure if this logic works for all cases
367+
if strings.Contains(gameDetails.Args, "$") {
368+
args = strings.Split(gameDetails.Args, "$")
369+
} else {
370+
args, err = shlex.Split(gameDetails.Args)
371+
if err != nil {
372+
logger.Warn("Failed to parse args, skipping them", "args", gameDetails.Args, "error", err)
373+
}
374+
}
375+
}
376+
377+
if gameDetails.ExePath != "" {
378+
// Not too sure about this. At least syberia-ii prepends the slugged name to the
379+
// path of the exe. I assume the galaClient always installs in folders with the
380+
// slugged name, but since we don't do that here, we skip it.
381+
// This might break if some games don't do this, and if that happens, we should
382+
// find a better solution for handling this.
383+
exePath := strings.Replace(gameDetails.ExePath, fmt.Sprintf("%s\\", slug), "", 1)
384+
exePath = path.Join(installInfo.InstallPath, exePath)
385+
exePath = manifest.NormalizePath(exePath)
386+
exe = &Executable{
387+
Path: exePath,
388+
Name: filepath.Base(exePath),
389+
}
390+
}
391+
392+
return exe, args, nil
393+
}

0 commit comments

Comments
 (0)