diff --git a/bloxstraprpc/bloxstraprpc.go b/bloxstraprpc/bloxstraprpc.go index e30a2b42..82e46a9b 100644 --- a/bloxstraprpc/bloxstraprpc.go +++ b/bloxstraprpc/bloxstraprpc.go @@ -1,12 +1,13 @@ // Package bloxstraprpc implements the BloxstrapRPC protocol. // -// This package remains undocumented as it is modeled after Bloxstrap's -// implementation protocol. +// For more information regarding the protocol, view [Bloxstrap's BloxstrapRPC wiki page] +// +// [Bloxstrap's BloxstrapRPC wiki page]: https://github.com/pizzaboxer/bloxstrap/wiki/Integrating-Bloxstrap-functionality-into-your-game package bloxstraprpc import ( "fmt" - "log" + "log/slog" "regexp" "strings" "time" @@ -59,6 +60,8 @@ func New() Activity { } } +// HandleRobloxLog handles the given Roblox log entry, to set data +// and call functions based on the log entry, declared as *Entry(Pattern) constants. func (a *Activity) HandleRobloxLog(line string) error { entries := map[string]func(string) error{ // In order of which it should appear in log file @@ -66,7 +69,7 @@ func (a *Activity) HandleRobloxLog(line string) error { GameJoiningEntry: a.handleGameJoining, // For JobID (server ID, to join from Discord) GameJoinReportEntry: a.handleGameJoinReport, // For PlaceID and UniverseID GameJoinedEntry: func(_ string) error { return a.handleGameJoined() }, // Sets presence and time - BloxstrapRPCEntry: a.handleGameMessage, // BloxstrapRPC + BloxstrapRPCEntry: a.handleBloxstrapRPC, // BloxstrapRPC GameLeaveEntry: func(_ string) error { return a.handleGameLeave() }, // Clears presence and time } @@ -83,7 +86,7 @@ func (a *Activity) handleGameJoinRequest(line string) error { m := GameJoinRequestEntryPattern.FindStringSubmatch(line) // There are multiple outputs for makePlaceLauncherRequest if len(m) != 3 { - return nil + return fmt.Errorf("log game join request entry is invalid") } if m[1] == "ForTeleport" { @@ -99,60 +102,63 @@ func (a *Activity) handleGameJoinRequest(line string) error { "join-play-together-game": Public, }[m[2]] - log.Printf("Got Game type %d teleporting %t!", a.server, a.teleporting) + slog.Info("Handled GameJoinRequest", "server_type", a.server, "teleporting", a.teleporting) + return nil } func (a *Activity) handleGameJoining(line string) error { m := GameJoiningEntryPattern.FindStringSubmatch(line) if len(m) != 2 { - return fmt.Errorf("log game joining entry is invalid!") + return fmt.Errorf("log game joining entry is invalid") } a.jobID = m[1] - log.Printf("Got Job %s!", a.jobID) + slog.Info("Handled GameJoining", "jobid", a.jobID) + return nil } func (a *Activity) handleGameJoinReport(line string) error { m := GameJoinReportEntryPattern.FindStringSubmatch(line) if len(m) != 3 { - return fmt.Errorf("log game join report entry is invalid!") + return fmt.Errorf("log game join report entry is invalid") } a.placeID = m[1] a.universeID = m[2] - log.Printf("Got Universe %s Place %s!", a.universeID, a.placeID) + slog.Info("Handled GameJoinReport", "universeid", a.universeID, "placeid", a.placeID) + return nil } func (a *Activity) handleGameJoined() error { if !a.teleporting { - log.Println("Updating time!") a.gameTime = time.Now() } a.teleporting = false - log.Println("Game Joined!") + slog.Info("Handled GameJoined", "time", a.gameTime) + return a.UpdateGamePresence(true) } -func (a *Activity) handleGameMessage(line string) error { +func (a *Activity) handleBloxstrapRPC(line string) error { m, err := NewMessage(line) if err != nil { return fmt.Errorf("parse bloxstraprpc message: %w", err) } m.ApplyRichPresence(&a.presence) + slog.Info("Handled BloxstrapRPC", "message", m) + return a.UpdateGamePresence(false) } func (a *Activity) handleGameLeave() error { - log.Println("Left game, clearing presence!") - a.presence = drpc.Activity{} a.gameTime = time.Time{} a.teleporting = false @@ -161,5 +167,7 @@ func (a *Activity) handleGameLeave() error { a.placeID = "" a.jobID = "" + slog.Info("Handled GameLeave") + return a.client.SetActivity(a.presence) } diff --git a/bloxstraprpc/discordrpc.go b/bloxstraprpc/discordrpc.go index 1c347987..c209f19b 100644 --- a/bloxstraprpc/discordrpc.go +++ b/bloxstraprpc/discordrpc.go @@ -1,31 +1,28 @@ package bloxstraprpc import ( - "log" + "log/slog" "github.com/altfoxie/drpc" "github.com/vinegarhq/vinegar/roblox/api" ) func (a *Activity) Connect() error { - log.Println("Connecting to Discord RPC") + slog.Info("Connecting to Discord RPC") return a.client.Connect() } func (a *Activity) Close() error { - log.Println("Closing Discord RPC") + slog.Info("Closing Discord RPC") return a.client.Close() } +// UpdateGamePresence sets the activity based on the current +// game information present in Activity. 'initial' is used +// to fetch game information required for rich presence. func (a *Activity) UpdateGamePresence(initial bool) error { - if a.universeID == "" { - log.Println("Not in game, clearing presence!") - - return a.client.SetActivity(a.presence) - } - a.presence.Buttons = []drpc.Button{{ Label: "See game page", URL: "https://www.roblox.com/games/" + a.placeID, @@ -94,6 +91,7 @@ func (a *Activity) UpdateGamePresence(initial bool) error { } } - log.Printf("Updating Discord presence: %#v", a.presence) + slog.Info("Updating Discord Rich Presence", "presence", a.presence) + return a.client.SetActivity(a.presence) } diff --git a/bloxstraprpc/message.go b/bloxstraprpc/message.go index 0bdeecc6..440a74c9 100644 --- a/bloxstraprpc/message.go +++ b/bloxstraprpc/message.go @@ -3,7 +3,7 @@ package bloxstraprpc import ( "encoding/json" "errors" - "log" + "log/slog" "strconv" "strings" "time" @@ -11,8 +11,6 @@ import ( "github.com/altfoxie/drpc" ) -// RichPresenceImage holds game image information sent -// from a BloxstrapRPC message type RichPresenceImage struct { AssetID *int64 `json:"assetId"` HoverText *string `json:"hoverText"` @@ -20,7 +18,6 @@ type RichPresenceImage struct { Reset bool `json:"reset"` } -// Data holds game information sent from a BloxstrapRPC message type Data struct { Details *string `json:"details"` State *string `json:"state"` @@ -30,8 +27,6 @@ type Data struct { LargeImage *RichPresenceImage `json:"largeImage"` } -// Message is a representation of a BloxstrapRPC message sent -// from a Roblox game using the BloxstrapRPC SDK. type Message struct { Command string `json:"command"` Data `json:"data"` @@ -66,9 +61,12 @@ func NewMessage(line string) (Message, error) { // ApplyRichPresence applies/appends Message's properties to the given // [drpc.Activity] for use in Discord's Rich Presence. +// +// UpdateGamePresence should be called as some of the properties are specific +// to BloxstrapRPC. func (m Message) ApplyRichPresence(p *drpc.Activity) { if m.Command != "SetRichPresence" { - log.Printf("WARNING: Game sent invalid BloxstrapRPC command: %s", m.Command) + slog.Warn("Game sent invalid BloxstrapRPC command", "command", m.Command) return } diff --git a/cmd/robloxmutexer/robloxmutexer.go b/cmd/robloxmutexer/robloxmutexer.go index c8d1818e..64639fc7 100644 --- a/cmd/robloxmutexer/robloxmutexer.go +++ b/cmd/robloxmutexer/robloxmutexer.go @@ -4,36 +4,40 @@ package main import ( - "errors" - "log" + "os" + "log/slog" "golang.org/x/sys/windows" ) +const mutex = "ROBLOX_singletonMutex" + func main() { - log.SetPrefix("robloxmutexer: ") - log.SetFlags(log.Lmsgprefix | log.LstdFlags) + if err := lock(); err != nil { + slog.Error(err.Error()) + os.Exit(1) + } + + slog.Info("Locked", "mutex", mutex) + + _, _ = windows.WaitForSingleObject(windows.CurrentProcess(), windows.INFINITE) +} - name, err := windows.UTF16PtrFromString("ROBLOX_singletonMutex") +func lock() error { + name, err := windows.UTF16PtrFromString(mutex) if err != nil { - log.Fatal(err) + return err } handle, err := windows.CreateMutex(nil, false, name) if err != nil { - if errors.Is(err, windows.ERROR_ALREADY_EXISTS) { - log.Fatal("Roblox's Mutex is already locked!") - } else { - log.Fatal(err) - } + return err } _, err = windows.WaitForSingleObject(handle, 0) if err != nil { - log.Fatal(err) + return err } - log.Println("Locked Roblox singleton Mutex") - - _, _ = windows.WaitForSingleObject(windows.CurrentProcess(), windows.INFINITE) + return nil } diff --git a/cmd/vinegar/binary.go b/cmd/vinegar/binary.go index 38bf8738..f886b10e 100644 --- a/cmd/vinegar/binary.go +++ b/cmd/vinegar/binary.go @@ -5,7 +5,9 @@ import ( "fmt" "io" "log" + "log/slog" "os" + "os/exec" "os/signal" "path/filepath" "regexp" @@ -18,6 +20,7 @@ import ( bsrpc "github.com/vinegarhq/vinegar/bloxstraprpc" "github.com/vinegarhq/vinegar/config" "github.com/vinegarhq/vinegar/internal/bus" + "github.com/vinegarhq/vinegar/internal/dirs" "github.com/vinegarhq/vinegar/internal/state" "github.com/vinegarhq/vinegar/roblox" boot "github.com/vinegarhq/vinegar/roblox/bootstrapper" @@ -33,13 +36,15 @@ const ( DialogQuickLogin = "WebView/InternalBrowser is broken, use Quick Log In to authenticate ('Log In With Another Device' button)" DialogFailure = "Vinegar experienced an error:\n%s" DialogReqChannel = "Roblox is attempting to set your channel to %[1]s, however the current preferred channel is %s.\n\nWould you like to set the channel to %[1]s temporarily?" - DialogNoWine = "Wine is required to run Roblox on Linux, please install it appropiate to your distribution." DialogNoAVX = "Warning: Your CPU does not support AVX. While some people may be able to run without it, most are not able to. VinegarHQ cannot provide support for your installation. Continue?" ) type Binary struct { + // Only initialized in Main Splash *splash.Splash - State *state.State + + GlobalState *state.State + State *state.Binary GlobalConfig *config.Config Config *config.Binary @@ -59,22 +64,41 @@ type Binary struct { BusSession *bus.SessionBus } -func NewBinary(bt roblox.BinaryType, cfg *config.Config, pfx *wine.Prefix) *Binary { - var bcfg config.Binary +func BinaryPrefixDir(bt roblox.BinaryType) string { + return filepath.Join(dirs.Prefixes, strings.ToLower(bt.String())) +} + +func NewBinary(bt roblox.BinaryType, cfg *config.Config) (*Binary, error) { + var bcfg *config.Binary + var bstate *state.Binary + + s, err := state.Load() + if err != nil { + return nil, err + } switch bt { case roblox.Player: - bcfg = cfg.Player + bcfg = &cfg.Player + bstate = &s.Player case roblox.Studio: - bcfg = cfg.Studio + bcfg = &cfg.Studio + bstate = &s.Studio + } + + pfx, err := wine.New(BinaryPrefixDir(bt), bcfg.WineRoot) + if err != nil { + return nil, fmt.Errorf("new prefix %s: %w", bt, err) } return &Binary{ Activity: bsrpc.New(), - Splash: splash.New(&cfg.Splash), + + GlobalState: &s, + State: bstate, GlobalConfig: cfg, - Config: &bcfg, + Config: bcfg, Alias: bt.String(), Name: bt.BinaryName(), @@ -82,21 +106,26 @@ func NewBinary(bt roblox.BinaryType, cfg *config.Config, pfx *wine.Prefix) *Bina Prefix: pfx, BusSession: bus.New(), - } + }, nil } -func (b *Binary) Main(args ...string) { +func (b *Binary) Main(args ...string) error { + b.Splash = splash.New(&b.GlobalConfig.Splash) b.Config.Env.Setenv() logFile, err := LogFile(b.Type.String()) if err != nil { - log.Fatal(err) + return fmt.Errorf("failed to init log file: %w", err) } defer logFile.Close() - logOutput := io.MultiWriter(logFile, os.Stderr) - b.Prefix.Output = logOutput - log.SetOutput(logOutput) + out := io.MultiWriter(os.Stderr, logFile) + b.Prefix.Stderr = out + b.Prefix.Stdout = out + log.SetOutput(out) + defer func() { + b.Splash.LogPath = logFile.Name() + }() firstRun := false if _, err := os.Stat(filepath.Join(b.Prefix.Dir(), "drive_c", "windows")); err != nil { @@ -104,22 +133,14 @@ func (b *Binary) Main(args ...string) { } if firstRun && !sysinfo.CPU.AVX { - c := b.Splash.Dialog(DialogNoAVX, true) - if !c { - log.Fatal("avx is (may be) required to run roblox") - } - log.Println("WARNING: Running roblox without AVX!") - } - - if !wine.WineLook() { - b.Splash.Dialog(DialogNoWine, false) - log.Fatalf("%s is required to run roblox", wine.Wine) + b.Splash.Dialog(DialogNoAVX, false) + slog.Warn("Running roblox without AVX, Roblox will most likely fail to run!") } go func() { err := b.Splash.Run() if errors.Is(splash.ErrClosed, err) { - log.Printf("Splash window closed!") + slog.Warn("Splash window closed!") // Will tell Run() to immediately kill Roblox, as it handles INT/TERM. // Otherwise, it will just with the same appropiate signal. @@ -134,27 +155,23 @@ func (b *Binary) Main(args ...string) { } }() - errHandler := func(err error) { - if !b.GlobalConfig.Splash.Enabled || b.Splash.IsClosed() { - log.Fatal(err) - } - - log.Println(err) - b.Splash.LogPath = logFile.Name() - b.Splash.Invalidate() - b.Splash.Dialog(fmt.Sprintf(DialogFailure, err), false) - os.Exit(1) - } - - // Technically this is 'initializing wineprefix', as SetDPI calls Wine which - // automatically create the Wineprefix. if firstRun { - log.Printf("Initializing wineprefix at %s", b.Prefix.Dir()) + slog.Info("Initializing wineprefix", "dir", b.Prefix.Dir()) b.Splash.SetMessage("Initializing wineprefix") - if err := b.Prefix.SetDPI(97); err != nil { - b.Splash.SetMessage(err.Error()) - errHandler(err) + var err error + switch b.Type { + case roblox.Player: + err = b.Prefix.Init() + case roblox.Studio: + // Studio accepts all DPIs except the default, which is 96. + // Technically this is 'initializing wineprefix', as SetDPI calls Wine which + // automatically create the Wineprefix. + err = b.Prefix.SetDPI(97) + } + + if err != nil { + return fmt.Errorf("failed to init %s prefix: %w", b.Type, err) } } @@ -176,7 +193,7 @@ func (b *Binary) Main(args ...string) { true, ) if r { - log.Println("Switching user channel temporarily to", c[1]) + slog.Warn("Switching user channel temporarily", "channel", c[1]) b.Config.Channel = c[1] } } @@ -185,29 +202,40 @@ func (b *Binary) Main(args ...string) { b.Splash.SetDesc(b.Config.Channel) if err := b.Setup(); err != nil { - b.Splash.SetMessage("Failed to setup Roblox") - errHandler(err) + return fmt.Errorf("failed to setup roblox: %w", err) } if err := b.Run(args...); err != nil { - b.Splash.SetMessage("Failed to run Roblox") - errHandler(err) + return fmt.Errorf("failed to run roblox: %w", err) } + + return nil } func (b *Binary) Run(args ...string) error { if b.Config.DiscordRPC { if err := b.Activity.Connect(); err != nil { - log.Printf("WARNING: Could not initialize Discord RPC: %s, disabling...", err) + slog.Error("Could not connect to Discord RPC", "error", err) b.Config.DiscordRPC = false } else { defer b.Activity.Close() } } + if b.GlobalConfig.MultipleInstances { + slog.Info("Running robloxmutexer") + + mutexer := b.Prefix.Wine(filepath.Join(BinPrefix, "robloxmutexer.exe")) + if err := mutexer.Start(); err != nil { + return fmt.Errorf("start robloxmutexer: %w", err) + } + + defer mutexer.Process.Kill() + } + cmd, err := b.Command(args...) if err != nil { - return err + return fmt.Errorf("%s command: %w", b.Type, err) } // Act as the signal holder, as roblox/wine will not do anything with the INT signal. @@ -216,10 +244,13 @@ func (b *Binary) Run(args ...string) error { c := make(chan os.Signal, 1) signal.Notify(c, os.Interrupt, syscall.SIGTERM) go func() { - <-c + s := <-c + + slog.Warn("Recieved signal", "signal", s) // Only kill Roblox if it hasn't exited if cmd.ProcessState == nil { + slog.Warn("Killing Roblox", "pid", cmd.Process.Pid) // This way, cmd.Run() will return and the wineprefix killer will be ran. cmd.Process.Kill() } @@ -229,7 +260,7 @@ func (b *Binary) Run(args ...string) error { signal.Stop(c) }() - log.Printf("Launching %s (%s)", b.Name, cmd) + slog.Info("Running Binary", "name", b.Name, "cmd", cmd) b.Splash.SetMessage("Launching " + b.Alias) go func() { @@ -244,7 +275,7 @@ func (b *Binary) Run(args ...string) error { // and don't perform post-launch roblox functions. lf, err := RobloxLogFile(b.Prefix) if err != nil { - log.Println(err) + slog.Error("Failed to find Roblox log file", "error", err.Error()) return } @@ -252,35 +283,19 @@ func (b *Binary) Run(args ...string) error { if b.Config.GameMode { if err := b.BusSession.GamemodeRegister(int32(cmd.Process.Pid)); err != nil { - log.Println("Attempted to register to Gamemode daemon") + slog.Error("Attempted to register to Gamemode daemon") } } - // Blocks and tails file forever until roblox is dead - if err := b.Tail(lf); err != nil { - log.Println(err) - } + // Blocks and tails file forever until roblox is dead, unless + // if finding the log file had failed. + b.Tail(lf) }() if err := cmd.Run(); err != nil { - if strings.Contains(err.Error(), "signal:") { - log.Println("WARNING: Roblox exited with", err) - return nil - } - return fmt.Errorf("roblox process: %w", err) } - // may or may not prevent a race condition in procfs - syscall.Sync() - - if CommFound("Roblox") { - log.Println("Another Roblox instance is already running, not killing wineprefix") - return nil - } - - b.Prefix.Kill() - return nil } @@ -313,45 +328,34 @@ func RobloxLogFile(pfx *wine.Prefix) (string, error) { return e.Name, nil } case err := <-w.Errors: - log.Println("fsnotify watcher:", err) + slog.Error("Recieved fsnotify watcher error", "error", err) } } } -func (b *Binary) Tail(name string) error { +func (b *Binary) Tail(name string) { t, err := tail.TailFile(name, tail.Config{Follow: true}) if err != nil { - return err + slog.Error("Could not tail Roblox log file", "error", err) + return } for line := range t.Lines { - fmt.Fprintln(b.Prefix.Output, line.Text) + // fmt.Fprintln(os.Stderr, line.Text) if b.Config.DiscordRPC { if err := b.Activity.HandleRobloxLog(line.Text); err != nil { - log.Printf("Failed to handle Discord RPC: %s", err) + slog.Error("Activity Roblox log handle failed", "error", err) } } } - - return nil } -func (b *Binary) Command(args ...string) (*wine.Cmd, error) { +func (b *Binary) Command(args ...string) (*exec.Cmd, error) { if strings.HasPrefix(strings.Join(args, " "), "roblox-studio:1") { args = []string{"-protocolString", args[0]} } - if b.GlobalConfig.MultipleInstances { - log.Println("Launching robloxmutexer in background") - - mutexer := b.Prefix.Command("wine", filepath.Join(BinPrefix, "robloxmutexer.exe")) - err := mutexer.Start() - if err != nil { - return &wine.Cmd{}, fmt.Errorf("robloxmutexer: %w", err) - } - } - cmd := b.Prefix.Wine(filepath.Join(b.Dir, b.Type.Executable()), args...) launcher := strings.Fields(b.Config.Launcher) @@ -359,7 +363,7 @@ func (b *Binary) Command(args ...string) (*wine.Cmd, error) { cmd.Args = append(launcher, cmd.Args...) p, err := b.Config.LauncherPath() if err != nil { - return &wine.Cmd{}, err + return nil, err } cmd.Path = p } diff --git a/cmd/vinegar/binary_setup.go b/cmd/vinegar/binary_setup.go index d68e7c32..25b2398e 100644 --- a/cmd/vinegar/binary_setup.go +++ b/cmd/vinegar/binary_setup.go @@ -2,13 +2,12 @@ package main import ( "fmt" - "log" + "log/slog" "os" "path/filepath" "sort" "github.com/vinegarhq/vinegar/internal/dirs" - "github.com/vinegarhq/vinegar/internal/state" "github.com/vinegarhq/vinegar/roblox" boot "github.com/vinegarhq/vinegar/roblox/bootstrapper" "github.com/vinegarhq/vinegar/wine/dxvk" @@ -19,7 +18,7 @@ func (b *Binary) FetchDeployment() error { b.Splash.SetMessage("Fetching " + b.Alias) if b.Config.ForcedVersion != "" { - log.Printf("WARNING: using forced version: %s", b.Config.ForcedVersion) + slog.Warn("Using forced deployment!", "guid", b.Config.ForcedVersion) d := boot.NewDeployment(b.Type, b.Config.Channel, b.Config.ForcedVersion) b.Deploy = &d @@ -28,7 +27,7 @@ func (b *Binary) FetchDeployment() error { d, err := boot.FetchDeployment(b.Type, b.Config.Channel) if err != nil { - return err + return fmt.Errorf("fetch %s %s deployment: %w", b.Type, b.Config.Channel, err) } b.Deploy = &d @@ -36,12 +35,6 @@ func (b *Binary) FetchDeployment() error { } func (b *Binary) Setup() error { - s, err := state.Load() - if err != nil { - return err - } - b.State = &s - if err := b.FetchDeployment(); err != nil { return err } @@ -49,34 +42,37 @@ func (b *Binary) Setup() error { b.Dir = filepath.Join(dirs.Versions, b.Deploy.GUID) b.Splash.SetDesc(fmt.Sprintf("%s %s", b.Deploy.GUID, b.Deploy.Channel)) - stateVer := b.State.Version(b.Type) - if stateVer != b.Deploy.GUID { - log.Printf("Installing %s (%s -> %s)", b.Name, stateVer, b.Deploy.GUID) + if b.State.Version != b.Deploy.GUID { + slog.Info("Installing Binary", "name", b.Name, + "old_guid", b.State.Version, "new_guid", b.Deploy.GUID) if err := b.Install(); err != nil { - return err + return fmt.Errorf("install %s: %w", b.Deploy.GUID, err) } } else { - log.Printf("%s is up to date (%s)", b.Name, b.Deploy.GUID) + slog.Info("Binary is up to date!", "name", b.Name, "guid", b.Deploy.GUID) } b.Config.Env.Setenv() - log.Println("Using Renderer:", b.Config.Renderer) if err := b.Config.FFlags.Apply(b.Dir); err != nil { - return err + return fmt.Errorf("apply fflags: %w", err) } if err := dirs.OverlayDir(b.Dir); err != nil { - return err + return fmt.Errorf("overlay dir: %w", err) } if err := b.SetupDxvk(); err != nil { - return err + return fmt.Errorf("setup dxvk: %w", err) } b.Splash.SetProgress(1.0) - return b.State.Save() + if err := b.GlobalState.Save(); err != nil { + return fmt.Errorf("save state: %w", err) + } + + return nil } func (b *Binary) Install() error { @@ -88,11 +84,7 @@ func (b *Binary) Install() error { pm, err := boot.FetchPackageManifest(b.Deploy) if err != nil { - return err - } - - if err := dirs.Mkdirs(dirs.Downloads); err != nil { - return err + return fmt.Errorf("fetch %s package manifest: %w", b.Deploy.GUID, err) } // Prioritize smaller files first, to have less pressure @@ -105,34 +97,38 @@ func (b *Binary) Install() error { b.Splash.SetMessage("Downloading " + b.Alias) if err := b.DownloadPackages(&pm); err != nil { - return err + return fmt.Errorf("download %s packages: %w", b.Deploy.GUID, err) } b.Splash.SetMessage("Extracting " + b.Alias) if err := b.ExtractPackages(&pm); err != nil { - return err + return fmt.Errorf("extract %s packages: %w", b.Deploy.GUID, err) } if b.Type == roblox.Studio { brokenFont := filepath.Join(b.Dir, "StudioFonts", "SourceSansPro-Black.ttf") - log.Printf("Removing broken font %s", brokenFont) + slog.Info("Removing broken font", "path", brokenFont) if err := os.RemoveAll(brokenFont); err != nil { - log.Printf("Failed to remove font: %s", err) + return err } } if err := boot.WriteAppSettings(b.Dir); err != nil { - return err + return fmt.Errorf("appsettings: %w", err) } - b.State.AddBinary(&pm) + b.State.Add(&pm) - if err := b.State.CleanPackages(); err != nil { - return err + if err := b.GlobalState.CleanPackages(); err != nil { + return fmt.Errorf("clean packages: %w", err) + } + + if err := b.GlobalState.CleanVersions(); err != nil { + return fmt.Errorf("clean versions: %w", err) } - return b.State.CleanVersions() + return nil } func (b *Binary) PerformPackages(pm *boot.PackageManifest, fn func(boot.Package) error) error { @@ -159,7 +155,7 @@ func (b *Binary) PerformPackages(pm *boot.PackageManifest, fn func(boot.Package) } func (b *Binary) DownloadPackages(pm *boot.PackageManifest) error { - log.Printf("Downloading %d Packages for %s", len(pm.Packages), pm.Deployment.GUID) + slog.Info("Downloading Packages", "guid", pm.Deployment.GUID, "count", len(pm.Packages)) return b.PerformPackages(pm, func(pkg boot.Package) error { return pkg.Download(filepath.Join(dirs.Downloads, pkg.Checksum), pm.DeployURL) @@ -167,7 +163,7 @@ func (b *Binary) DownloadPackages(pm *boot.PackageManifest) error { } func (b *Binary) ExtractPackages(pm *boot.PackageManifest) error { - log.Printf("Extracting %d Packages for %s", len(pm.Packages), pm.Deployment.GUID) + slog.Info("Extracting Packages", "guid", pm.Deployment.GUID, "count", len(pm.Packages)) pkgDirs := boot.BinaryDirectories(b.Type) @@ -183,10 +179,11 @@ func (b *Binary) ExtractPackages(pm *boot.PackageManifest) error { } func (b *Binary) SetupDxvk() error { - if b.State.DxvkVersion != "" && !b.GlobalConfig.Player.Dxvk && !b.GlobalConfig.Studio.Dxvk { + if b.State.DxvkVersion != "" && + (!b.GlobalConfig.Player.Dxvk && !b.GlobalConfig.Studio.Dxvk) { b.Splash.SetMessage("Uninstalling DXVK") if err := dxvk.Remove(b.Prefix); err != nil { - return err + return fmt.Errorf("remove dxvk: %w", err) } b.State.DxvkVersion = "" @@ -200,13 +197,13 @@ func (b *Binary) SetupDxvk() error { b.Splash.SetProgress(0.0) dxvk.Setenv() - if b.GlobalConfig.DxvkVersion == b.State.DxvkVersion { + if b.Config.DxvkVersion == b.State.DxvkVersion { return nil } // This would only get saved if Install succeeded - b.State.DxvkVersion = b.GlobalConfig.DxvkVersion + b.State.DxvkVersion = b.Config.DxvkVersion b.Splash.SetMessage("Installing DXVK") - return dxvk.Install(b.GlobalConfig.DxvkVersion, b.Prefix) + return dxvk.Install(b.Config.DxvkVersion, b.Prefix) } diff --git a/cmd/vinegar/main.go b/cmd/vinegar/main.go index fd926cd1..a3f168db 100644 --- a/cmd/vinegar/main.go +++ b/cmd/vinegar/main.go @@ -3,19 +3,17 @@ package main import ( "flag" "fmt" + "golang.org/x/term" "log" + "log/slog" "os" - "path" "path/filepath" - "runtime/debug" "time" "github.com/vinegarhq/vinegar/config" "github.com/vinegarhq/vinegar/config/editor" "github.com/vinegarhq/vinegar/internal/dirs" "github.com/vinegarhq/vinegar/roblox" - "github.com/vinegarhq/vinegar/sysinfo" - "github.com/vinegarhq/vinegar/wine" ) var ( @@ -24,10 +22,10 @@ var ( ) func usage() { - fmt.Fprintln(os.Stderr, "usage: vinegar [-config filepath] player|studio [args...]") - fmt.Fprintln(os.Stderr, " vinegar [-config filepath] exec prog args...") - fmt.Fprintln(os.Stderr, " vinegar [-config filepath] kill|winetricks|sysinfo") - fmt.Fprintln(os.Stderr, " vinegar delete|edit|submit|version") + fmt.Fprintln(os.Stderr, "usage: vinegar [-config filepath] player|studio exec|run [args...]") + fmt.Fprintln(os.Stderr, "usage: vinegar [-config filepath] player|studio kill|winetricks") + fmt.Fprintln(os.Stderr, " vinegar [-config filepath] sysinfo") + fmt.Fprintln(os.Stderr, " vinegar delete|edit|version") os.Exit(1) } @@ -38,103 +36,94 @@ func main() { cmd := flag.Arg(0) args := flag.Args() - wine.Wine = "wine64" - switch cmd { - // These commands don't require a configuration case "delete", "edit", "version": switch cmd { case "delete": - Delete() + slog.Info("Deleting Wineprefixes and Roblox Binary deployments!") + + if err := os.RemoveAll(dirs.Prefixes); err != nil { + log.Fatalf("remove %s: %s", dirs.Prefixes, err) + } case "edit": if err := editor.Edit(*configPath); err != nil { - log.Fatal(err) + log.Fatalf("edit %s: %s", *configPath, err) } case "version": fmt.Println("Vinegar", Version) } - // These commands (except player & studio) don't require a configuration, - // but they require a wineprefix, hence wineroot of configuration is required. - case "sysinfo", "player", "studio", "exec", "kill", "winetricks": + case "player", "studio", "sysinfo": + // Remove after a few releases + if _, err := os.Stat(dirs.Prefix); err == nil { + slog.Info("Deleting deprecated old Wineprefix!") + if err := os.RemoveAll(dirs.Prefix); err != nil { + log.Fatalf("delete old prefix %s: %s", dirs.Prefix, err) + } + } + cfg, err := config.Load(*configPath) if err != nil { - log.Fatal(err) + log.Fatalf("load config %s: %s", *configPath, err) + } + + var bt roblox.BinaryType + switch cmd { + case "player": + bt = roblox.Player + case "studio": + bt = roblox.Studio + case "sysinfo": + PrintSysinfo(&cfg) + os.Exit(0) } - pfx := wine.New(dirs.Prefix, os.Stderr) - // Always ensure its created, wine will complain if the root - // directory doesnt exist - if err := os.MkdirAll(dirs.Prefix, 0o755); err != nil { + b, err := NewBinary(bt, &cfg) + if err != nil { log.Fatal(err) } - switch cmd { - case "sysinfo": - Sysinfo(&pfx) + switch flag.Arg(1) { case "exec": if len(args) < 2 { usage() } - if err := pfx.Wine(args[1], args[2:]...).Run(); err != nil { - log.Fatal(err) + if err := b.Prefix.Wine(args[2], args[3:]...).Run(); err != nil { + log.Fatalf("exec prefix %s: %s", bt, err) } case "kill": - pfx.Kill() + b.Prefix.Kill() case "winetricks": - if err := pfx.Winetricks(); err != nil { + if err := b.Prefix.Winetricks(); err != nil { + log.Fatalf("exec winetricks %s: %s", bt, err) + } + case "run": + err = b.Main(args[2:]...) + if err == nil { + slog.Info("Goodbye") + os.Exit(0) + } + + // Only fatal print the error if we are in a terminal, otherwise + // display a dialog message. + if !cfg.Splash.Enabled || term.IsTerminal(int(os.Stderr.Fd())) { log.Fatal(err) } - case "player": - NewBinary(roblox.Player, &cfg, &pfx).Main(args[1:]...) - case "studio": - NewBinary(roblox.Studio, &cfg, &pfx).Main(args[1:]...) + + slog.Error(err.Error()) + b.Splash.SetMessage("Oops!") + b.Splash.Dialog(fmt.Sprintf(DialogFailure, err), false) + os.Exit(1) + default: + usage() } default: usage() } } -func Delete() { - log.Println("Deleting Wineprefix") - if err := os.RemoveAll(dirs.Prefix); err != nil { - log.Fatal(err) - } -} +func DeleteOldPrefix() { -func Sysinfo(pfx *wine.Prefix) { - cmd := pfx.Wine("--version") - cmd.Stdout = nil // required for Output() - ver, err := cmd.Output() - if err != nil { - log.Fatal(err) - } - - var revision string - bi, _ := debug.ReadBuildInfo() - for _, bs := range bi.Settings { - if bs.Key == "vcs.revision" { - revision = fmt.Sprintf("(%s)", bs.Value) - } - } - - info := `* Vinegar: %s %s -* Distro: %s -* Processor: %s - * Supports AVX: %t - * Supports split lock detection: %t -* Kernel: %s -* Wine: %s` - - fmt.Printf(info, Version, revision, sysinfo.Distro, sysinfo.CPU.Name, sysinfo.CPU.AVX, sysinfo.CPU.SplitLockDetect, sysinfo.Kernel, ver) - if sysinfo.InFlatpak { - fmt.Println("* Flatpak: [x]") - } - - fmt.Println("* Cards:") - for i, c := range sysinfo.Cards { - fmt.Printf(" * Card %d: %s %s %s\n", i, c.Driver, path.Base(c.Device), c.Path) - } } func LogFile(name string) (*os.File, error) { @@ -150,7 +139,7 @@ func LogFile(name string) (*os.File, error) { return nil, fmt.Errorf("failed to create %s log file: %w", name, err) } - log.Printf("Logging to file: %s", path) + slog.Info("Logging to file", "path", path) return file, nil } diff --git a/cmd/vinegar/sysinfo.go b/cmd/vinegar/sysinfo.go new file mode 100644 index 00000000..964b8a4c --- /dev/null +++ b/cmd/vinegar/sysinfo.go @@ -0,0 +1,62 @@ +package main + +import ( + "fmt" + "log" + "path" + "runtime/debug" + + "github.com/vinegarhq/vinegar/config" + "github.com/vinegarhq/vinegar/roblox" + "github.com/vinegarhq/vinegar/sysinfo" + "github.com/vinegarhq/vinegar/wine" +) + +func PrintSysinfo(cfg *config.Config) { + playerPfx, err := wine.New(BinaryPrefixDir(roblox.Player), cfg.Player.WineRoot) + if err != nil { + log.Fatalf("player prefix: %s", err) + } + + studioPfx, err := wine.New(BinaryPrefixDir(roblox.Studio), cfg.Studio.WineRoot) + if err != nil { + log.Fatalf("studio prefix: %s", err) + } + + var revision string + bi, _ := debug.ReadBuildInfo() + for _, bs := range bi.Settings { + if bs.Key == "vcs.revision" { + revision = fmt.Sprintf("(%s)", bs.Value) + } + } + + info := `* Vinegar: %s %s +* Distro: %s +* Processor: %s + * Supports AVX: %t + * Supports split lock detection: %t +* Kernel: %s +* Wine (Player): %s +* Wine (Studio): %s +` + + fmt.Printf(info, + Version, revision, + sysinfo.Distro, + sysinfo.CPU.Name, + sysinfo.CPU.AVX, sysinfo.CPU.SplitLockDetect, + sysinfo.Kernel, + playerPfx.Version(), + studioPfx.Version(), + ) + + if sysinfo.InFlatpak { + fmt.Println("* Flatpak: [x]") + } + + fmt.Println("* Cards:") + for i, c := range sysinfo.Cards { + fmt.Printf(" * Card %d: %s %s %s\n", i, c.Driver, path.Base(c.Device), c.Path) + } +} diff --git a/config/config.go b/config/config.go index 62c8aa60..773bc4a0 100644 --- a/config/config.go +++ b/config/config.go @@ -6,7 +6,6 @@ import ( "fmt" "os" "os/exec" - "path/filepath" "strings" "github.com/BurntSushi/toml" @@ -25,9 +24,11 @@ type Binary struct { Channel string `toml:"channel"` Launcher string `toml:"launcher"` Renderer string `toml:"renderer"` + WineRoot string `toml:"wineroot"` DiscordRPC bool `toml:"discord_rpc"` ForcedVersion string `toml:"forced_version"` Dxvk bool `toml:"dxvk"` + DxvkVersion string `toml:"dxvk_version"` FFlags roblox.FFlags `toml:"fflags"` Env Environment `toml:"env"` ForcedGpu string `toml:"gpu"` @@ -36,8 +37,6 @@ type Binary struct { // Config is a representation of the Vinegar configuration. type Config struct { - WineRoot string `toml:"wineroot"` - DxvkVersion string `toml:"dxvk_version"` MultipleInstances bool `toml:"multiple_instances"` SanitizeEnv bool `toml:"sanitize_env"` Player Binary `toml:"player"` @@ -78,8 +77,6 @@ func Load(name string) (Config, error) { // Default returns a sane default configuration for Vinegar. func Default() Config { return Config{ - DxvkVersion: "2.3", - Env: Environment{ "WINEARCH": "win64", "WINEDEBUG": "err-kerberos,err-ntlm", @@ -92,12 +89,13 @@ func Default() Config { }, Player: Binary{ - Dxvk: true, - GameMode: true, - ForcedGpu: "prime-discrete", - Renderer: "D3D11", - Channel: bootstrapper.DefaultChannel, - DiscordRPC: true, + Dxvk: true, + DxvkVersion: "2.3", + GameMode: true, + ForcedGpu: "prime-discrete", + Renderer: "D3D11", + Channel: bootstrapper.DefaultChannel, + DiscordRPC: true, FFlags: roblox.FFlags{ "DFIntTaskSchedulerTargetFps": 640, }, @@ -106,11 +104,12 @@ func Default() Config { }, }, Studio: Binary{ - Dxvk: true, - GameMode: true, - Channel: bootstrapper.DefaultChannel, - ForcedGpu: "prime-discrete", - Renderer: "D3D11", + Dxvk: true, + DxvkVersion: "2.3", + GameMode: true, + Channel: bootstrapper.DefaultChannel, + ForcedGpu: "prime-discrete", + Renderer: "D3D11", // TODO: fill with studio fflag/env goodies FFlags: make(roblox.FFlags), Env: make(Environment), @@ -148,6 +147,12 @@ func (b *Binary) setup() error { } } + if b.WineRoot != "" { + if _, err := wine.Wine64(b.WineRoot); err != nil { + return err + } + } + return b.pickCard() } @@ -156,24 +161,8 @@ func (c *Config) setup() error { SanitizeEnv() } - if c.WineRoot != "" { - bin := filepath.Join(c.WineRoot, "bin") - - if !filepath.IsAbs(c.WineRoot) { - return ErrWineRootAbs - } - - os.Setenv("PATH", bin+":"+os.Getenv("PATH")) - os.Unsetenv("WINEDLLPATH") - } - c.Env.Setenv() - // system wine handled by vinegar - if c.WineRoot != "" && !wine.WineLook() { - return ErrWineRootInvalid - } - if err := c.Player.setup(); err != nil { return fmt.Errorf("player: %w", err) } diff --git a/config/config_test.go b/config/config_test.go index d65226c8..20215b5a 100644 --- a/config/config_test.go +++ b/config/config_test.go @@ -4,11 +4,9 @@ import ( "errors" "os" "os/exec" - "path/filepath" "testing" "github.com/vinegarhq/vinegar/roblox" - "github.com/vinegarhq/vinegar/wine" ) func TestBinarySetup(t *testing.T) { @@ -48,34 +46,3 @@ func TestBinarySetup(t *testing.T) { t.Error("expected exec not found") } } - -func TestSetup(t *testing.T) { - wr := t.TempDir() - c := Default() - c.WineRoot = wr - - // Required to not conflict with system environment - os.Setenv("PATH", "") - - if err := c.setup(); !errors.Is(err, ErrWineRootInvalid) { - t.Error("expected wine root wine check") - } - - if err := os.Mkdir(filepath.Join(wr, "bin"), 0o755); err != nil { - t.Fatal(err) - } - - _, err := os.OpenFile(filepath.Join(wr, "bin", wine.Wine), os.O_CREATE, 0o755) - if err != nil { - t.Fatal(err) - } - - if err := c.setup(); err != nil { - t.Error("valid wine root is invalid") - } - - c.WineRoot = filepath.Join(".", wr) - if err := c.setup(); !errors.Is(err, ErrWineRootAbs) { - t.Error("expected wine root absolute path") - } -} diff --git a/config/editor/editor.go b/config/editor/editor.go index f2e0d10b..56e5d9d4 100644 --- a/config/editor/editor.go +++ b/config/editor/editor.go @@ -2,7 +2,7 @@ package editor import ( "fmt" - "log" + "log/slog" "os" "os/exec" "path/filepath" @@ -18,7 +18,7 @@ func Editor() (string, error) { return editor, nil } - log.Println("no EDITOR set, falling back to nano") + slog.Warn("no $EDITOR set, falling back to nano") return exec.LookPath("nano") } @@ -36,7 +36,7 @@ func Edit(name string) error { } if err := fillTemplate(name); err != nil { - return err + return fmt.Errorf("fill template %s: %w", name, err) } for { @@ -50,8 +50,8 @@ func Edit(name string) error { } if _, err := config.Load(name); err != nil { - log.Println(err) - log.Println("Press enter to re-edit configuration file") + slog.Error(err.Error()) + slog.Info("Press enter to re-edit configuration file") fmt.Scanln() continue @@ -82,7 +82,7 @@ func fillTemplate(name string) error { template := "# See how to configure Vinegar on the documentation website:\n" + "# https://vinegarhq.org/Configuration\n\n" - log.Println("Writing Configuration template") + slog.Info("Writing Configuration template", "path", name) if _, err := f.WriteString(template); err != nil { return err diff --git a/go.mod b/go.mod index 2da7ec85..c39a209a 100644 --- a/go.mod +++ b/go.mod @@ -15,7 +15,8 @@ require ( github.com/fsnotify/fsnotify v1.7.0 github.com/godbus/dbus/v5 v5.1.0 github.com/nxadm/tail v1.4.11 - golang.org/x/sys v0.15.0 + golang.org/x/sys v0.16.0 + golang.org/x/term v0.16.0 ) require ( diff --git a/go.sum b/go.sum index dbfdfae1..481755b7 100644 --- a/go.sum +++ b/go.sum @@ -47,8 +47,10 @@ golang.org/x/sync v0.5.0 h1:60k92dhOjHxJkrqnwsfl8KuaHbn/5dl0lUPUklKo3qE= golang.org/x/sync v0.5.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sys v0.0.0-20211025201205-69cdffdb9359/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220908164124-27713097b956/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.15.0 h1:h48lPFYpsTvQJZF4EKyI4aLHaev3CxivZmv7yZig9pc= -golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.16.0 h1:xWw16ngr6ZMtmxDyKyIgsE93KNKz5HKmMa3b8ALHidU= +golang.org/x/sys v0.16.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/term v0.16.0 h1:m+B6fahuftsE9qjo0VWp2FW0mB3MTJvR0BaMQrq0pmE= +golang.org/x/term v0.16.0/go.mod h1:yn7UURbUtPyrVJPGPq404EukNFxcm/foM+bV/bfcDsY= golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/internal/dirs/copy.go b/internal/dirs/copy.go index 68f8ebda..ef31b64f 100644 --- a/internal/dirs/copy.go +++ b/internal/dirs/copy.go @@ -2,7 +2,7 @@ package dirs import ( "errors" - "log" + "log/slog" "os" cp "github.com/otiai10/copy" @@ -16,7 +16,7 @@ func OverlayDir(dir string) error { return err } - log.Println("Copying Overlay directory's files") + slog.Info("Copying Overlay directory's files", "dir", dir) return cp.Copy(Overlay, dir) } diff --git a/internal/dirs/dirs.go b/internal/dirs/dirs.go index 46002a18..c5dbfec9 100644 --- a/internal/dirs/dirs.go +++ b/internal/dirs/dirs.go @@ -8,28 +8,18 @@ import ( ) var ( - Cache = filepath.Join(xdg.CacheHome, "vinegar") - Config = filepath.Join(xdg.ConfigHome, "vinegar") - Data = filepath.Join(xdg.DataHome, "vinegar") - Overlay = filepath.Join(Config, "overlay") - Downloads = filepath.Join(Cache, "downloads") - Logs = filepath.Join(Cache, "logs") - Prefix string - PrefixData string - Versions string -) - -func init() { + Cache = filepath.Join(xdg.CacheHome, "vinegar") + Config = filepath.Join(xdg.ConfigHome, "vinegar") + Data = filepath.Join(xdg.DataHome, "vinegar") + Overlay = filepath.Join(Config, "overlay") + Downloads = filepath.Join(Cache, "downloads") + Logs = filepath.Join(Cache, "logs") + Prefixes = filepath.Join(Data, "prefixes") + Versions = filepath.Join(Data, "versions") + + // Deprecated: Vinegar supports multiple wine prefixes Prefix = filepath.Join(Data, "prefix") - envPrefix := os.Getenv("WINEPREFIX") - - if filepath.IsAbs(envPrefix) { - Prefix = envPrefix - } - - PrefixData = filepath.Join(Prefix, "vinegar") - Versions = filepath.Join(PrefixData, "versions") -} +) func Mkdirs(dirs ...string) error { for _, dir := range dirs { diff --git a/internal/netutil/netutil.go b/internal/netutil/netutil.go index 570c65a4..93d5638b 100644 --- a/internal/netutil/netutil.go +++ b/internal/netutil/netutil.go @@ -2,10 +2,10 @@ package netutil import ( - "log" "errors" "fmt" "io" + "log" "net/http" "os" ) @@ -54,7 +54,7 @@ func download(url, file string) error { if resp.StatusCode != http.StatusOK { return fmt.Errorf("%w: %s", ErrBadStatus, resp.Status) } - + _, err = io.Copy(out, resp.Body) if err != nil { return err diff --git a/internal/state/cleaners.go b/internal/state/cleaners.go index fe157eae..a6fe2353 100644 --- a/internal/state/cleaners.go +++ b/internal/state/cleaners.go @@ -1,7 +1,7 @@ package state import ( - "log" + "log/slog" "os" "path/filepath" "slices" @@ -12,10 +12,8 @@ import ( // CleanPackages removes all cached package downloads in dirs.Downloads // that aren't held in the state's Binary packages. func (s *State) CleanPackages() error { - log.Println("Checking for unused cached package files") - return walkDirExcluded(dirs.Downloads, s.Packages(), func(path string) error { - log.Printf("Removing unused package %s", path) + slog.Info("Cleaning up unused cached package", "path", path) return os.Remove(path) }) } @@ -23,10 +21,8 @@ func (s *State) CleanPackages() error { // CleanPackages removes all Binary versions that aren't // held in the state's Binary packages. func (s *State) CleanVersions() error { - log.Println("Checking for unused version directories") - return walkDirExcluded(dirs.Versions, s.Versions(), func(path string) error { - log.Printf("Removing unused version directory %s", path) + slog.Info("Cleaning up unused version directory", "path", path) return os.RemoveAll(path) }) } diff --git a/internal/state/state.go b/internal/state/state.go index 45bb8208..45fbe469 100644 --- a/internal/state/state.go +++ b/internal/state/state.go @@ -1,105 +1,98 @@ package state import ( + "encoding/json" "errors" "os" "path/filepath" - "github.com/BurntSushi/toml" "github.com/vinegarhq/vinegar/internal/dirs" - "github.com/vinegarhq/vinegar/roblox" "github.com/vinegarhq/vinegar/roblox/bootstrapper" ) -var path = filepath.Join(dirs.PrefixData, "state.toml") +var path = filepath.Join(dirs.Data, "state.json") -// ApplicationState is used track a Binary's version and it's packages -type BinaryState struct { - Version string - Packages []string +// BinaryState is used track a Binary's deployment and wineprefix. +type Binary struct { + DxvkVersion string + Version string + Packages []string } -// ApplicationStates is a map representation with the string -// type being the binary name in string form. -type BinaryStates map[string]BinaryState - -// State holds various details about Vinegar's configuration +// State holds various details about Vinegar's current state. type State struct { - DxvkVersion string - Applications BinaryStates // called Applications to retain compatibility + Player Binary + Studio Binary } -// Load will load the state file in dirs.PrefixData and return it's -// contents. If the state file does not exist, it will return an -// empty state. +// Load returns the state file's contents in State form. +// +// If the state file does not exist or is empty, an +// empty state is returned. func Load() (State, error) { var state State - _, err := toml.DecodeFile(path, &state) - if err != nil && !errors.Is(err, os.ErrNotExist) { + f, err := os.ReadFile(path) + if (err != nil && errors.Is(err, os.ErrNotExist)) || len(f) == 0 { + return State{}, nil + } + if err != nil { return State{}, err } - if state.Applications == nil { - state.Applications = make(BinaryStates, 0) + if err := json.Unmarshal(f, &state); err != nil { + return State{}, err } return state, nil } -// Save will save the state to a toml-encoded file in dirs.PrefixData +// Save saves the current state to the state file. func (s *State) Save() error { if err := dirs.Mkdirs(filepath.Dir(path)); err != nil { return err } - file, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o644) + f, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o644) if err != nil { return err } - defer file.Close() + defer f.Close() - _, err = file.WriteString("# State saved by vinegar. DO NOT EDIT DIRECTLY.\n\n") + state, err := json.MarshalIndent(s, "", " ") if err != nil { return err } - return toml.NewEncoder(file).Encode(s) + if _, err := f.Write(state); err != nil { + return err + } + + return nil } -// AddBinary adds a given package manifest's packages and it's checksums -// to the state's Applications, with the identifier as the package -// manifest's binary name. -func (s *State) AddBinary(pm *bootstrapper.PackageManifest) { - b := BinaryState{ - Version: pm.Deployment.GUID, - } +// Add formats the given package manifest into a Binary form. +func (bs *Binary) Add(pm *bootstrapper.PackageManifest) { + bs.Version = pm.Deployment.GUID for _, pkg := range pm.Packages { - b.Packages = append(b.Packages, pkg.Checksum) + bs.Packages = append(bs.Packages, pkg.Checksum) } - - s.Applications[pm.Deployment.Type.BinaryName()] = b } -// Packages retrieves all the available Binary packages from the state +// Packages returns all the available Binary packages from the state. func (s *State) Packages() (pkgs []string) { - for _, info := range s.Applications { - pkgs = append(pkgs, info.Packages...) + for _, bs := range []Binary{s.Player, s.Studio} { + pkgs = append(pkgs, bs.Packages...) } return } -// Packages retrieves all the available Binary versions from the state +// Packages returns all the available Binary versions from the state. func (s *State) Versions() (vers []string) { - for _, ver := range s.Applications { - vers = append(vers, ver.Version) + for _, bs := range []Binary{s.Player, s.Studio} { + vers = append(vers, bs.Version) } return } - -// Version is retrieves the version of a Binary from the state -func (s *State) Version(bt roblox.BinaryType) string { - return s.Applications[bt.BinaryName()].Version -} diff --git a/internal/state/state_test.go b/internal/state/state_test.go index 301912c1..3c92c993 100644 --- a/internal/state/state_test.go +++ b/internal/state/state_test.go @@ -1,7 +1,6 @@ package state import ( - "log" "os" "reflect" "testing" @@ -29,8 +28,7 @@ func TestState(t *testing.T) { } v := bootstrapper.NewDeployment(roblox.Player, "", "version-meowmeowmrrp") - s.DxvkVersion = "6.9" - s.AddBinary(&bootstrapper.PackageManifest{ + s.Player.Add(&bootstrapper.PackageManifest{ Deployment: &v, Packages: bootstrapper.Packages{{ Checksum: "meow", @@ -46,15 +44,10 @@ func TestState(t *testing.T) { t.Fatal(err) } - log.Println(sExp) - if sExp.Version(roblox.Player) != v.GUID { + if sExp.Player.Version != v.GUID { t.Fatal("want version stored state") } - if sExp.DxvkVersion != "6.9" { - t.Fatal("want dxvk version stored state") - } - if !reflect.DeepEqual(sExp.Packages(), []string{"meow"}) { t.Fatal("want meow packages") } diff --git a/roblox/api/api.go b/roblox/api/api.go index df03ecd8..2449f13f 100644 --- a/roblox/api/api.go +++ b/roblox/api/api.go @@ -12,7 +12,10 @@ const APIURL = "https://%s.roblox.com/%s" var httpClient = &http.Client{} -var ErrBadStatus = errors.New("bad status") +var ( + ErrBadStatus = errors.New("bad status") + ErrNoData = errors.New("no data") +) // SetClient sets the http.Client used to make API requests. func SetClient(client *http.Client) { diff --git a/roblox/api/games.go b/roblox/api/games.go index baafd155..89c1bfd2 100644 --- a/roblox/api/games.go +++ b/roblox/api/games.go @@ -1,5 +1,7 @@ package api +import "fmt" + // Creator is a representation of the Roblox GameCreator model. type Creator struct { ID int64 `json:"id"` @@ -52,5 +54,9 @@ func GetGameDetails(universeID string) (GameDetail, error) { return GameDetail{}, err } + if len(gdr.Data) == 0 { + return GameDetail{}, fmt.Errorf("game details: %w", ErrNoData) + } + return gdr.Data[0], nil } diff --git a/roblox/api/thumbnails.go b/roblox/api/thumbnails.go index 7787fcf4..cf22d0ef 100644 --- a/roblox/api/thumbnails.go +++ b/roblox/api/thumbnails.go @@ -32,5 +32,9 @@ func GetGameIcon(universeID, returnPolicy, size, format string, isCircular bool) return Thumbnail{}, err } + if len(tnr.Data) == 0 { + return Thumbnail{}, fmt.Errorf("thumbnails: %w", ErrNoData) + } + return tnr.Data[0], nil } diff --git a/roblox/bootstrapper/appsettings.go b/roblox/bootstrapper/appsettings.go index dbd74719..e6c69509 100644 --- a/roblox/bootstrapper/appsettings.go +++ b/roblox/bootstrapper/appsettings.go @@ -1,7 +1,7 @@ package bootstrapper import ( - "log" + "log/slog" "os" "path/filepath" ) @@ -9,9 +9,11 @@ import ( // WriteAppSettings writes the AppSettings.xml file - required // to run Roblox - to a binary's deployment directory. func WriteAppSettings(dir string) error { - log.Println("Writing AppSettings:", dir) + p := filepath.Join(dir, "AppSettings.xml") - f, err := os.Create(filepath.Join(dir, "AppSettings.xml")) + slog.Info("Writing AppSettings.xml", "path", p) + + f, err := os.Create(p) if err != nil { return err } diff --git a/roblox/bootstrapper/cdn.go b/roblox/bootstrapper/cdn.go deleted file mode 100644 index 5471ad52..00000000 --- a/roblox/bootstrapper/cdn.go +++ /dev/null @@ -1,42 +0,0 @@ -package bootstrapper - -import ( - "errors" - "log" - "net/http" -) - -var ( - ErrNoCDNFound = errors.New("no accessible Roblox deploy mirror or cdn found") - CDNs = []string{ - "https://setup.rbxcdn.com", - "https://s3.amazonaws.com/setup.roblox.com", - "https://setup-ak.rbxcdn.com", - "https://setup-hw.rbxcdn.com", - "https://setup-cfly.rbxcdn.com", // Fastest - "https://roblox-setup.cachefly.net", - } -) - -// CDN returns a CDN (from CDNs) that is available. -func CDN() (string, error) { - log.Println("Finding an accessible Roblox deploy mirror") - - for _, cdn := range CDNs { - resp, err := http.Head(cdn + "/" + "version") - if err != nil { - log.Printf("deploy mirror %s: %s", cdn, errors.Unwrap(err)) - - continue - } - resp.Body.Close() - - if resp.StatusCode == 200 { - log.Printf("Found deploy mirror: %s", cdn) - - return cdn, nil - } - } - - return "", ErrNoCDNFound -} diff --git a/roblox/bootstrapper/deployment.go b/roblox/bootstrapper/deployment.go index 9ec118df..2a4db579 100644 --- a/roblox/bootstrapper/deployment.go +++ b/roblox/bootstrapper/deployment.go @@ -1,7 +1,7 @@ package bootstrapper import ( - "log" + "log/slog" "github.com/vinegarhq/vinegar/roblox" "github.com/vinegarhq/vinegar/roblox/api" @@ -38,14 +38,12 @@ func FetchDeployment(bt roblox.BinaryType, channel string) (Deployment, error) { channel = DefaultChannel } - log.Printf("Fetching latest version of %s for channel %s", bt.BinaryName(), channel) + slog.Info("Fetching Binary Deployment", "name", bt.BinaryName(), "channel", channel) cv, err := api.GetClientVersion(bt.BinaryName(), channel) if err != nil { return Deployment{}, err } - log.Printf("Fetched %s canonical version %s", bt.BinaryName(), cv.Version) - return NewDeployment(bt, channel, cv.ClientVersionUpload), nil } diff --git a/roblox/bootstrapper/mirror.go b/roblox/bootstrapper/mirror.go new file mode 100644 index 00000000..c06babac --- /dev/null +++ b/roblox/bootstrapper/mirror.go @@ -0,0 +1,45 @@ +package bootstrapper + +import ( + "errors" + "log/slog" + "net/http" +) + +var ( + ErrNoMirrorFound = errors.New("no accessible deploy mirror found") + + // As of 2024-02-03: + // setup-cfly.rbxcdn.com = roblox-setup.cachefly.net + // setup.rbxcdn.com = setup-ns1.rbxcdn.com = setup-ak.rbxcdn.com + // setup-hw.rbxcdn.com = setup-ll.rbxcdn.com = does not exist + Mirrors = []string{ + // Sorted by speed + "https://setup.rbxcdn.com", + "https://setup-cfly.rbxcdn.com", + "https://s3.amazonaws.com/setup.roblox.com", + } +) + +// Mirror returns an available mirror URL from [Mirrors]. +func Mirror() (string, error) { + slog.Info("Finding an accessible deploy mirror") + + for _, m := range Mirrors { + resp, err := http.Head(m + "/" + "version") + if err != nil { + slog.Error("Bad deploy mirror", "mirror", m, "error", err) + + continue + } + resp.Body.Close() + + if resp.StatusCode == 200 { + slog.Info("Found deploy mirror", "mirror", m) + + return m, nil + } + } + + return "", ErrNoMirrorFound +} diff --git a/roblox/bootstrapper/package.go b/roblox/bootstrapper/package.go index 59652888..3f8422ab 100644 --- a/roblox/bootstrapper/package.go +++ b/roblox/bootstrapper/package.go @@ -5,7 +5,7 @@ import ( "encoding/hex" "fmt" "io" - "log" + "log/slog" "os" "github.com/vinegarhq/vinegar/internal/netutil" @@ -23,7 +23,7 @@ type Packages []Package // Verify checks the named package source file against it's checksum func (p *Package) Verify(src string) error { - log.Printf("Verifying Package %s (%s)", p.Name, p.Checksum) + slog.Info("Verifying Package", "name", p.Name, "path", src) f, err := os.Open(src) if err != nil { @@ -38,7 +38,7 @@ func (p *Package) Verify(src string) error { fsum := hex.EncodeToString(h.Sum(nil)) if p.Checksum != fsum { - return fmt.Errorf("package %s (%s) is corrupted, please re-download or delete package", p.Name, src) + return fmt.Errorf("package %s is corrupted, please re-download or delete package", p.Name) } return nil @@ -49,13 +49,14 @@ func (p *Package) Verify(src string) error { // exists and has the correct checksum, it will return immediately. func (p *Package) Download(dest, deployURL string) error { if err := p.Verify(dest); err == nil { - log.Printf("Package %s is already downloaded", p.Name) + slog.Info("Package is already downloaded", "name", p.Name, "file", dest) return nil } - log.Printf("Downloading Package %s (%s)", p.Name, dest) + url := deployURL + "-" + p.Name + slog.Info("Downloading package", "url", url, "path", dest) - if err := netutil.Download(deployURL+"-"+p.Name, dest); err != nil { + if err := netutil.Download(url, dest); err != nil { return fmt.Errorf("download package %s: %w", p.Name, err) } @@ -68,6 +69,6 @@ func (p *Package) Extract(src, dest string) error { return fmt.Errorf("extract package %s (%s): %w", p.Name, src, err) } - log.Printf("Extracted Package %s (%s): %s", p.Name, p.Checksum, dest) + slog.Info("Extracted package", "name", p.Name, "path", src, "dest", dest) return nil } diff --git a/roblox/bootstrapper/pkg_manifest.go b/roblox/bootstrapper/pkg_manifest.go index c52fed73..84cfc309 100644 --- a/roblox/bootstrapper/pkg_manifest.go +++ b/roblox/bootstrapper/pkg_manifest.go @@ -3,7 +3,7 @@ package bootstrapper import ( "errors" "fmt" - "log" + "log/slog" "strconv" "strings" @@ -28,7 +28,7 @@ func channelPath(channel string) string { // ClientSettings it will be lowercased, but not on the deploy mirror. channel = strings.ToLower(channel) - // Roblox CDN only accepts no channel if its the default channel. + // Roblox deployment mirrors only accepts no channel if its the default channel. // DefaultChannel is in all caps, and since channel is lowercased above, // make it lowercased too in this check. if channel == strings.ToLower(DefaultChannel) { @@ -40,14 +40,15 @@ func channelPath(channel string) string { // FetchPackageManifest retrieves a package manifest for the given binary deployment. func FetchPackageManifest(d *Deployment) (PackageManifest, error) { - cdn, err := CDN() + m, err := Mirror() if err != nil { return PackageManifest{}, err } - durl := cdn + channelPath(d.Channel) + d.GUID + + durl := m + channelPath(d.Channel) + d.GUID url := durl + "-rbxPkgManifest.txt" - log.Printf("Fetching manifest for %s (%s)", d.GUID, url) + slog.Info("Fetching Package Manifest", "url", url) smanif, err := netutil.Body(url) if err != nil { diff --git a/roblox/fflags.go b/roblox/fflags.go index 3cdcb910..0dc84373 100644 --- a/roblox/fflags.go +++ b/roblox/fflags.go @@ -4,7 +4,6 @@ import ( "encoding/json" "errors" "fmt" - "log" "os" "path/filepath" ) @@ -31,8 +30,6 @@ func (f FFlags) Apply(versionDir string) error { dir := filepath.Join(versionDir, "ClientSettings") path := filepath.Join(dir, "ClientAppSettings.json") - log.Println("Applying custom FFlags:", path) - err := os.Mkdir(dir, 0o755) if err != nil && !errors.Is(err, os.ErrExist) { return err diff --git a/splash/dialog.go b/splash/dialog.go index 21bd958a..86a01b1e 100644 --- a/splash/dialog.go +++ b/splash/dialog.go @@ -25,6 +25,11 @@ import ( func (ui *Splash) Dialog(txt string, user bool) (r bool) { var ops op.Ops + if !ui.Config.Enabled { + log.Printf("Dialog: %s", txt) + return + } + // This is required for time when Dialog is called before the main // window is ready for retrieving events. th := material.NewTheme() @@ -53,11 +58,6 @@ func (ui *Splash) Dialog(txt string, user bool) (r bool) { w := window(unit.Dp(width), unit.Dp(height)) - if !ui.Config.Enabled { - log.Printf("Dialog: %s", txt) - return - } - msgState := new(widget.Selectable) var yesButton widget.Clickable // Okay if !user diff --git a/splash/splash.go b/splash/splash.go index f25d2b0a..3bd42cb8 100644 --- a/splash/splash.go +++ b/splash/splash.go @@ -60,21 +60,37 @@ type Splash struct { } func (ui *Splash) SetMessage(msg string) { + if ui.Window == nil { + return + } + ui.message = msg ui.Invalidate() } func (ui *Splash) SetDesc(desc string) { + if ui.Window == nil { + return + } + ui.desc = desc ui.Invalidate() } func (ui *Splash) SetProgress(progress float32) { + if ui.Window == nil { + return + } + ui.progress = progress ui.Invalidate() } func (ui *Splash) Close() { + if ui.Window == nil { + return + } + ui.closed = true ui.Perform(system.ActionClose) } @@ -94,6 +110,13 @@ func window(width, height unit.Dp) *app.Window { } func New(cfg *Config) *Splash { + if !cfg.Enabled { + return &Splash{ + closed: true, + Config: cfg, + } + } + s := Compact if cfg.Style == "familiar" { @@ -150,6 +173,10 @@ func (ui *Splash) loadLogo() error { } func (ui *Splash) Run() error { + if ui.closed { + return nil + } + drawfn := ui.drawCompact if err := ui.loadLogo(); err != nil { @@ -160,10 +187,6 @@ func (ui *Splash) Run() error { ui.closed = true }() - if !ui.Config.Enabled { - return nil - } - if ui.Style == Familiar { drawfn = ui.drawFamiliar } diff --git a/wine/cmd.go b/wine/cmd.go deleted file mode 100644 index fbcc6175..00000000 --- a/wine/cmd.go +++ /dev/null @@ -1,77 +0,0 @@ -package wine - -import ( - "errors" - "io" - "os" - "os/exec" -) - -type Cmd struct { - *exec.Cmd - - // in-order to ensure that the WINEPREFIX environment - // variable cannot be tampered with. - prefixDir string -} - -// Command returns a passthrough Cmd struct to execute the named -// program with the given arguments. -// -// The command's Stderr and Stdout will be set to their os counterparts -// if the prefix's Output is nil. -func (p *Prefix) Command(name string, arg ...string) *Cmd { - cmd := exec.Command(name, arg...) - cmd.Stdout = os.Stdout - cmd.Stderr = os.Stderr - if p.Output != nil { - cmd.Stdout = p.Output - cmd.Stderr = p.Output - } - - return &Cmd{ - Cmd: cmd, - prefixDir: p.dir, - } -} - -// OutputPipe erturns a pipe that will be a MultiReader -// of StderrPipe and StdoutPipe, it will set both Stdout -// and Stderr to nil once ran. -func (c *Cmd) OutputPipe() (io.Reader, error) { - if c.Process != nil { - return nil, errors.New("OutputPipe after process started") - } - - c.Stdout = nil - c.Stderr = nil - - e, err := c.StderrPipe() - if err != nil { - return nil, err - } - - o, err := c.StdoutPipe() - if err != nil { - return nil, err - } - - return io.MultiReader(e, o), nil -} - -// Refer to [exec.Cmd.Start] -func (c *Cmd) Start() error { - c.Env = append(c.Environ(), - "WINEPREFIX="+c.prefixDir, - ) - - return c.Cmd.Start() -} - -// Refer to [exec.Cmd.Run] -func (c *Cmd) Run() error { - if err := c.Start(); err != nil { - return err - } - return c.Wait() -} diff --git a/wine/dxvk/dxvk.go b/wine/dxvk/dxvk.go index 0e49e2dd..1e30218a 100644 --- a/wine/dxvk/dxvk.go +++ b/wine/dxvk/dxvk.go @@ -6,7 +6,7 @@ import ( "compress/gzip" "fmt" "io" - "log" + "log/slog" "os" "path" "path/filepath" @@ -21,19 +21,19 @@ const Repo = "https://github.com/doitsujin/dxvk" // // This is required to call inorder to tell Wine to use DXVK. func Setenv() { - log.Printf("Enabling WINE DXVK DLL overrides") + slog.Info("Enabling WINE DXVK DLL overrides") os.Setenv("WINEDLLOVERRIDES", os.Getenv("WINEDLLOVERRIDES")+";d3d10core=n;d3d11=n;d3d9=n;dxgi=n") } func Remove(pfx *wine.Prefix) error { - log.Println("Deleting DXVK DLLs") + slog.Info("Deleting DXVK DLLs", "pfx", pfx) for _, dir := range []string{"syswow64", "system32"} { for _, dll := range []string{"d3d9", "d3d10core", "d3d11", "dxgi"} { p := filepath.Join(pfx.Dir(), "drive_c", "windows", dir, dll+".dll") - log.Println("Removing DXVK overriden Wine DLL:", p) + slog.Info("Removing DXVK overriden Wine DLL", "path", p) if err := os.Remove(p); err != nil { return err @@ -41,7 +41,7 @@ func Remove(pfx *wine.Prefix) error { } } - log.Println("Restoring Wineprefix DLLs") + slog.Info("Restoring Wineprefix DLLs", "pfx", pfx) return pfx.Wine("wineboot", "-u").Run() } @@ -57,7 +57,7 @@ func Install(ver string, pfx *wine.Prefix) error { } defer os.Remove(f.Name()) - log.Printf("Downloading DXVK %s (%s)", ver, url) + slog.Info("Downloading DXVK tarball", "url", url, "path", f.Name()) if err := netutil.Download(url, f.Name()); err != nil { return fmt.Errorf("download dxvk %s: %w", ver, err) @@ -71,7 +71,7 @@ func Install(ver string, pfx *wine.Prefix) error { } func Extract(name string, pfx *wine.Prefix) error { - log.Printf("Extracting DXVK (%s)", name) + slog.Info("Extracting DXVK", "file", name, "pfx", pfx) tf, err := os.Open(name) if err != nil { @@ -108,7 +108,7 @@ func Extract(name string, pfx *wine.Prefix) error { }[filepath.Base(filepath.Dir(hdr.Name))] if !ok { - log.Printf("Skipping DXVK unhandled file: %s", hdr.Name) + slog.Warn("Skipping DXVK unhandled file", "file", hdr.Name) continue } @@ -123,7 +123,7 @@ func Extract(name string, pfx *wine.Prefix) error { return err } - log.Printf("Extracting DLL %s", p) + slog.Info("Extracting DXVK DLL", "path", p) if _, err = io.Copy(f, tr); err != nil { f.Close() @@ -133,6 +133,6 @@ func Extract(name string, pfx *wine.Prefix) error { f.Close() } - log.Printf("Deleting DXVK tarball (%s)", name) + slog.Info("Deleting DXVK tarball", "path", name) return os.RemoveAll(name) } diff --git a/wine/registry.go b/wine/registry.go index 9948f4fe..6d34df1a 100644 --- a/wine/registry.go +++ b/wine/registry.go @@ -4,8 +4,8 @@ import ( "errors" ) -// RegistryType is the type of registry that the wine reg program -// can accept +// RegistryType is the type of registry that the wine 'reg' program +// can accept. type RegistryType string const ( @@ -18,8 +18,7 @@ const ( REG_NONE RegistryType = "REG_NONE" ) -// RegistryAdd uses a Wine command with 'reg' as the named program, to add a new -// registry key with the named key, value, type, and data +// RegistryAdd adds a new registry key to the Prefix with the named key, value, type, and data. func (p *Prefix) RegistryAdd(key, value string, rtype RegistryType, data string) error { if key == "" { return errors.New("no registry key given") diff --git a/wine/tricks.go b/wine/tricks.go index cd8e2c05..9c4466a7 100644 --- a/wine/tricks.go +++ b/wine/tricks.go @@ -1,18 +1,15 @@ package wine import ( - "log" "strconv" ) -// Winetricks runs Command with winetricks as the named program +// Winetricks runs winetricks within the Prefix. func (p *Prefix) Winetricks() error { - log.Println("Launching winetricks") - - return p.Command("winetricks").Run() + return p.command("winetricks").Run() } -// SetDPI calls RegistryAdd with the intent to set the DPI to the named dpi +// SetDPI sets the Prefix's DPI to the named DPI. func (p *Prefix) SetDPI(dpi int) error { return p.RegistryAdd("HKEY_CURRENT_USER\\Control Panel\\Desktop", "LogPixels", REG_DWORD, strconv.Itoa(dpi)) } diff --git a/wine/user.go b/wine/user.go index 309af4e1..eb30e0ec 100644 --- a/wine/user.go +++ b/wine/user.go @@ -5,8 +5,7 @@ import ( "path/filepath" ) -// AppDataDir gets the current user and retrieves the user's AppData -// in the Prefix's users directory +// AppDataDir returns the current user's AppData within the Prefix. func (p *Prefix) AppDataDir() (string, error) { user, err := user.Current() if err != nil { diff --git a/wine/wine.go b/wine/wine.go index 527a2139..5a81d7a5 100644 --- a/wine/wine.go +++ b/wine/wine.go @@ -3,60 +3,137 @@ package wine import ( + "errors" "io" - "log" + "os" "os/exec" + "path/filepath" ) -// The program used for Wine. -var Wine = "wine" +var ( + ErrWineRootAbs = errors.New("wineroot is not absolute") + ErrWineNotFound = errors.New("wine64 not found in system or wineroot") +) // Prefix is a representation of a wineprefix, which is where -// WINE stores its data, which is equivalent to WINE's C:\ drive. +// WINE stores its data and is equivalent to a C:\ drive. type Prefix struct { - // Output specifies the descendant prefix commmand's - // Stderr and Stdout together. - // - // Wine will always write to stderr instead of stdout, - // Stdout is combined just to make that certain. - Output io.Writer + // Path to a wine installation. + Root string + + // Stdout and Stderr specify the descendant Prefix wine call's + // standard output and error. This is mostly reserved for logging purposes. + // By default, they will be set to their os counterparts. + Stderr io.Writer + Stdout io.Writer + + wine string + dir string +} + +func (p Prefix) String() string { + return p.dir +} + +// Wine64 returns a path to the system or wineroot's 'wine64'. +func Wine64(root string) (string, error) { + var bin string - dir string + if root != "" && !filepath.IsAbs(root) { + bin = filepath.Join(root, "bin") + return "", ErrWineRootAbs + } + + wine, err := exec.LookPath(filepath.Join(bin, "wine64")) + if err != nil && errors.Is(err, os.ErrNotExist) { + return "", ErrWineNotFound + } else if err != nil { + return "", errors.Unwrap(err) + } + + return wine, nil } // New returns a new Prefix. // +// [Wine64] will be used to verify the named root or if +// wine is installed. +// // dir must be an absolute path and has correct permissions // to modify. -func New(dir string, out io.Writer) Prefix { - return Prefix{ - Output: out, - dir: dir, +func New(dir string, root string) (*Prefix, error) { + w, err := Wine64(root) + if err != nil { + return nil, err } -} -// WineLook checks for [Wine] with exec.LookPath, and returns -// true if [Wine] is present and has no problems. -func WineLook() bool { - _, err := exec.LookPath(Wine) - return err == nil + // Always ensure its created, wine will complain if the root + // directory doesnt exist + if err := os.MkdirAll(dir, 0o755); err != nil { + return nil, err + } + + return &Prefix{ + Root: root, + Stderr: os.Stderr, + Stdout: os.Stdout, + wine: w, + dir: dir, + }, nil } -// Dir retrieves the [Prefix]'s directory on the filesystem. +// Dir returns the directory of the Prefix. func (p *Prefix) Dir() string { return p.dir } -// Wine makes a new Cmd with [Wine] as the named program. -func (p *Prefix) Wine(exe string, arg ...string) *Cmd { +// Wine returns a new [exec.Cmd] with wine64 as the named program. +// +// The path of wine64 will either be from $PATH or from Prefix's Root. +func (p *Prefix) Wine(exe string, arg ...string) *exec.Cmd { arg = append([]string{exe}, arg...) - return p.Command(Wine, arg...) + return p.command(p.wine, arg...) +} + +// Kill kills the Prefix's processes. +func (p *Prefix) Kill() error { + return p.Wine("wineboot", "-k").Run() +} + +// Init preforms initialization for first Wine instance. +func (p *Prefix) Init() error { + return p.Wine("wineboot", "-i").Run() } -// Kill runs Command with 'wineserver -k' as the named program. -func (p *Prefix) Kill() { - log.Println("Killing wineprefix") +// Update updates the wineprefix directory. +func (p *Prefix) Update() error { + return p.Wine("wineboot", "-u").Run() +} + +func (p *Prefix) command(name string, arg ...string) *exec.Cmd { + cmd := exec.Command(name, arg...) + cmd.Env = append(cmd.Environ(), + "WINEPREFIX="+p.dir, + ) + + cmd.Stderr = p.Stderr + cmd.Stdout = p.Stdout + + return cmd +} + +// Version returns the wineprefix's Wine version. +func (p *Prefix) Version() string { + cmd := p.Wine("--version") + cmd.Stdout = nil // required for Output() + cmd.Stderr = nil + + ver, _ := cmd.Output() + if len(ver) < 0 { + return "unknown" + } - _ = p.Command("wineserver", "-k").Run() + // remove newline + return string(ver[:len(ver)-1]) }