diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 97212d28..33ed19e6 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -8,7 +8,15 @@ jobs: build: runs-on: ubuntu-latest steps: + - name: 'Setup Go' + uses: actions/setup-go@v4 + with: + go-version: '^1.20' - name: 'Checkout Repository' uses: actions/checkout@v3 + - name: 'Setup Go' + uses: actions/setup-go@v4 + with: + go-version: '^1.21' - name: 'Build Vinegar' run: make VINEGAR_GOFLAGS="--tags nogui" diff --git a/.github/workflows/vendor.yml b/.github/workflows/vendor.yml index 16e0d992..368ab307 100644 --- a/.github/workflows/vendor.yml +++ b/.github/workflows/vendor.yml @@ -13,12 +13,12 @@ jobs: - name: 'Setup Go' uses: actions/setup-go@v4 with: - go-version: '^1.20' + go-version: '^1.21' - name: 'Make the vendor directory' run: go mod vendor - name: 'Package the source directory' run: | - RELEASE="vinegar-${{ github.ref_name }}" + RELEASE="vinegar-${{ github.event.release.tag_name }}" cd .. cp -r vinegar $RELEASE @@ -30,6 +30,6 @@ jobs: uses: svenstaro/upload-release-action@v2 with: repo_token: ${{ secrets.GITHUB_TOKEN }} - file: "../vinegar-${{ github.ref_name }}.tar.xz" + file: "../vinegar-${{ github.event.release.tag_name }}.tar.xz" overwrite: true make_latest: false \ No newline at end of file diff --git a/README.md b/README.md index c45b2cda..1069ec28 100644 --- a/README.md +++ b/README.md @@ -22,6 +22,8 @@ An open-source, minimal, configurable, fast bootstrapper for running Roblox on L + Automatic DXVK Installer and uninstaller + Automatic Wineprefix killer when Roblox has quit + Automatic removal of outdated cached packages and versions of Roblox ++ Discord Rich Presence support ++ Roblox's logs appear within Vinegar + FPS Unlocking for Player by default, without rbxfpsunlocker + Browser launch via MIME + Custom execution of wine program within wineprefix @@ -36,7 +38,7 @@ An open-source, minimal, configurable, fast bootstrapper for running Roblox on L + Faster Multi-threaded installation and extraction of Roblox + Multiple instances of Roblox open simultaneously + Loading window during setup -+ Logging for both Vinegar and Wine ++ Logging for both Vinegar, Wine and Roblox # See Also + [Discord Server](https://discord.gg/dzdzZ6Pps2) diff --git a/bloxstraprpc/activity.go b/bloxstraprpc/activity.go new file mode 100644 index 00000000..187b0f3c --- /dev/null +++ b/bloxstraprpc/activity.go @@ -0,0 +1,164 @@ +package bloxstraprpc + +import ( + "log" + "regexp" + "strings" + "time" + + "github.com/hugolgst/rich-go/client" +) + +const ( + GameJoiningEntry = "[FLog::Output] ! Joining game" + GameJoiningPrivateServerEntry = "[FLog::GameJoinUtil] GameJoinUtil::joinGamePostPrivateServer" + GameJoiningReservedServerEntry = "[FLog::GameJoinUtil] GameJoinUtil::initiateTeleportToReservedServer" + GameJoiningUDMUXEntry = "[FLog::Network] UDMUX Address = " + GameJoinedEntry = "[FLog::Network] serverId:" + GameDisconnectedEntry = "[FLog::Network] Time to disconnect replication data:" + GameTeleportingEntry = "[FLog::SingleSurfaceApp] initiateTeleport" + GameMessageEntry = "[FLog::Output] [BloxstrapRPC]" +) + +var ( + GameJoiningEntryPattern = regexp.MustCompile(`! Joining game '([0-9a-f\-]{36})' place ([0-9]+) at ([0-9\.]+)`) + GameJoiningUDMUXPattern = regexp.MustCompile(`UDMUX Address = ([0-9\.]+), Port = [0-9]+ \| RCC Server Address = ([0-9\.]+), Port = [0-9]+`) + GameJoinedEntryPattern = regexp.MustCompile(`serverId: ([0-9\.]+)\|[0-9]+`) +) + +type ServerType int + +const ( + Public ServerType = iota + Private + Reserved +) + +type Activity struct { + presence client.Activity + timeStartedUniverse time.Time + currentUniverseID string + + ingame bool + teleported bool + server ServerType + placeID string + jobID string + mac string + + teleport bool + reservedteleport bool +} + +func (a *Activity) HandleRobloxLog(line string) error { + if !a.ingame && a.placeID == "" { + if strings.Contains(line, GameJoiningPrivateServerEntry) { + a.server = Private + return nil + } + + if strings.Contains(line, GameJoiningEntry) { + a.handleGameJoining(line) + return nil + } + } + + if !a.ingame && a.placeID != "" { + if strings.Contains(line, GameJoiningUDMUXEntry) { + a.handleUDMUX(line) + return nil + } + + if strings.Contains(line, GameJoinedEntry) { + a.handleGameJoined(line) + return a.SetCurrentGame() + } + } + + if a.ingame && a.placeID != "" { + if strings.Contains(line, GameDisconnectedEntry) { + log.Printf("Disconnected From Game (%s/%s/%s)", a.placeID, a.jobID, a.mac) + a.Clear() + return a.SetCurrentGame() + } + + if strings.Contains(line, GameTeleportingEntry) { + log.Printf("Teleporting to server (%s/%s/%s)", a.placeID, a.jobID, a.mac) + a.teleport = true + return nil + } + + if a.teleport && strings.Contains(line, GameJoiningReservedServerEntry) { + log.Printf("Teleporting to reserved server") + a.reservedteleport = true + return nil + } + + if strings.Contains(line, GameMessageEntry) { + m, err := ParseMessage(line) + if err != nil { + return err + } + + a.ProcessMessage(&m) + return a.UpdatePresence() + } + } + + return nil +} + +func (a *Activity) handleUDMUX(line string) { + m := GameJoiningUDMUXPattern.FindStringSubmatch(line) + if len(m) != 3 || m[2] != a.mac { + return + } + + a.mac = m[1] + log.Printf("Got game join UDMUX: %s", a.mac) +} + +func (a *Activity) handleGameJoining(line string) { + m := GameJoiningEntryPattern.FindStringSubmatch(line) + if len(m) != 4 { + return + } + + a.ingame = false + a.jobID = m[1] + a.placeID = m[2] + a.mac = m[3] + + if a.teleport { + a.teleported = true + a.teleport = false + } + + if a.reservedteleport { + a.server = Reserved + a.reservedteleport = false + } + + log.Printf("Joining Game (%s/%s/%s)", a.jobID, a.placeID, a.mac) +} + +func (a *Activity) handleGameJoined(line string) { + m := GameJoinedEntryPattern.FindStringSubmatch(line) + if len(m) != 2 || m[1] != a.mac { + return + } + + a.ingame = true + log.Printf("Joined Game (%s/%s/%s)", a.placeID, a.jobID, a.mac) + // handle rpc +} + +func (a *Activity) Clear() { + a.teleported = false + a.ingame = false + a.placeID = "" + a.jobID = "" + a.mac = "" + a.server = Public + a.presence = client.Activity{} +} diff --git a/bloxstraprpc/discordrpc.go b/bloxstraprpc/discordrpc.go new file mode 100644 index 00000000..050a653a --- /dev/null +++ b/bloxstraprpc/discordrpc.go @@ -0,0 +1,154 @@ +package bloxstraprpc + +import ( + "log" + "strconv" + "time" + + "github.com/hugolgst/rich-go/client" + "github.com/vinegarhq/vinegar/roblox/api" +) + +// This is Bloxstrap's Discord RPC application ID. +const RPCAppID = "1005469189907173486" + +func Login() error { + log.Println("Authenticating Discord RPC") + return client.Login(RPCAppID) +} + +func Logout() { + log.Println("Deauthenticating Discord RPC") + client.Logout() +} + +func (a *Activity) SetCurrentGame() error { + if !a.ingame { + log.Println("Not in game, clearing presence") + a.presence = client.Activity{} + } else { + if err := a.SetPresence(); err != nil { + return err + } + } + + return a.UpdatePresence() +} + +func (a *Activity) SetPresence() error { + var status string + log.Printf("Setting presence for Place ID %s", a.placeID) + + uid, err := api.GetUniverseID(a.placeID) + if err != nil { + return err + } + log.Printf("Got Universe ID as %s", uid) + + if !a.teleported || uid != a.currentUniverseID { + a.timeStartedUniverse = time.Now() + } + + a.currentUniverseID = uid + + gd, err := api.GetGameDetails(uid) + if err != nil { + return err + } + log.Println("Got Game details") + + tn, err := api.GetGameIcon(uid, "PlaceHolder", "512x512", "Png", false) + if err != nil { + return err + } + log.Printf("Got Universe thumbnail as %s", tn.ImageURL) + + switch a.server { + case Public: + status = "by " + gd.Creator.Name + case Private: + status = "In a private server" + case Reserved: + status = "In a reserved server" + } + + a.presence = client.Activity{ + State: status, + Details: "Playing " + gd.Name, + LargeImage: tn.ImageURL, + LargeText: gd.Name, + SmallImage: "roblox", + SmallText: "Roblox", + Timestamps: &client.Timestamps{ + Start: &a.timeStartedUniverse, + }, + Buttons: []*client.Button{ + { + Label: "See game page", + Url: "https://www.roblox.com/games/" + a.placeID, + }, + }, + } + + return nil +} + +func (a *Activity) ProcessMessage(m *Message) { + if m.Command != "SetRichPresence" { + return + } + + if m.Data.Details != "" { + a.presence.Details = m.Data.Details + } + + if m.Data.State != "" { + a.presence.State = m.Data.State + } + + if a.presence.Timestamps != nil { + if m.TimestampStart == 0 { + a.presence.Timestamps.Start = nil + } else { + ts := time.UnixMilli(m.TimestampStart) + a.presence.Timestamps.Start = &ts + } + } + + if a.presence.Timestamps != nil { + if m.TimestampEnd == 0 { + a.presence.Timestamps.End = nil + } else { + te := time.UnixMilli(m.TimestampEnd) + a.presence.Timestamps.End = &te + } + } + + if m.SmallImage.Clear { + a.presence.SmallImage = "" + } + + if m.SmallImage.AssetID != 0 { + a.presence.SmallImage = "https://assetdelivery.roblox.com/v1/asset/?id" + + strconv.FormatInt(m.SmallImage.AssetID, 10) + } + + if m.LargeImage.Clear { + a.presence.LargeImage = "" + } + + if m.LargeImage.AssetID != 0 { + a.presence.LargeImage = "https://assetdelivery.roblox.com/v1/asset/?id" + + strconv.FormatInt(m.LargeImage.AssetID, 10) + } +} + +func (a *Activity) UpdatePresence() error { + // if a.presence == client.Activity{} { + // log.Println("Presence is empty, clearing") + // return ClearPresence() + // } + + log.Printf("Updating presence: %+v", a.presence) + return client.SetActivity(a.presence) +} diff --git a/bloxstraprpc/message.go b/bloxstraprpc/message.go new file mode 100644 index 00000000..d042bdd5 --- /dev/null +++ b/bloxstraprpc/message.go @@ -0,0 +1,52 @@ +package bloxstraprpc + +import ( + "encoding/json" + "errors" + "log" + "strings" +) + +type RichPresenceImage struct { + AssetID int64 `json:"assetId"` + HoverText int64 `json:"hoverText"` + Clear bool `json:"clear"` + Reset bool `json:"reset"` +} + +type Data struct { + Details string `json:"details"` + State string `json:"state"` + TimestampStart int64 `json:"timeStart"` + TimestampEnd int64 `json:"timeEnd"` + SmallImage RichPresenceImage `json:"smallImage"` + LargeImage RichPresenceImage `json:"largeImage"` +} + +type Message struct { + Command string `json:"command"` + Data `json:"data"` +} + +func ParseMessage(line string) (Message, error) { + var m Message + + msg := line[strings.Index(line, GameMessageEntry)+len(GameMessageEntry)+1:] + + if err := json.Unmarshal([]byte(msg), &m); err != nil { + return m, err + } + + if m.Command == "" { + return Message{}, errors.New("command is empty") + } + + // discord RPC implementation requires a limit of 128 characters + if len(m.Data.Details) > 128 || len(m.Data.State) > 128 { + return Message{}, errors.New("details or state cannot be longer than 128 characters") + } + + log.Printf("Received message: %+v", m) + + return m, nil +} diff --git a/cmd/vinegar/binary.go b/cmd/vinegar/binary.go index 0738df8c..39ff3091 100644 --- a/cmd/vinegar/binary.go +++ b/cmd/vinegar/binary.go @@ -1,24 +1,38 @@ package main import ( + "bufio" "fmt" + "io" "log" + "os" "os/exec" + "os/signal" "path/filepath" + "strconv" "strings" + "syscall" "time" + bsrpc "github.com/vinegarhq/vinegar/bloxstraprpc" "github.com/vinegarhq/vinegar/internal/config" "github.com/vinegarhq/vinegar/internal/config/state" "github.com/vinegarhq/vinegar/internal/dirs" "github.com/vinegarhq/vinegar/internal/splash" "github.com/vinegarhq/vinegar/roblox" "github.com/vinegarhq/vinegar/roblox/bootstrapper" + "github.com/vinegarhq/vinegar/roblox/version" "github.com/vinegarhq/vinegar/util" "github.com/vinegarhq/vinegar/wine" "github.com/vinegarhq/vinegar/wine/dxvk" ) +const ( + DialogInternalBrowserBrokenTitle = "WebView/InternalBrowser is broken" + DialogUseBrowserMsg = "Use the browser for whatever you were doing just now." + DialogQuickLoginMsg = "Use Quick Log In to authenticate ('Log In With Another Device' button)" +) + type Binary struct { Splash *splash.Splash @@ -30,7 +44,11 @@ type Binary struct { Dir string Prefix *wine.Prefix Type roblox.BinaryType - Version roblox.Version + Version version.Version + + // Logging + Auth bool + Activity bsrpc.Activity } func NewBinary(bt roblox.BinaryType, cfg *config.Config, pfx *wine.Prefix) Binary { @@ -57,59 +75,147 @@ func NewBinary(bt roblox.BinaryType, cfg *config.Config, pfx *wine.Prefix) Binar } func (b *Binary) Run(args ...string) error { - exe := b.Type.Executable() + if b.Config.DiscordRPC { + if err := bsrpc.Login(); err != nil { + log.Printf("Failed to authenticate Discord RPC: %s, disabling RPC", err) + b.Config.DiscordRPC = false + } + + // NOTE: This will panic if logout fails + defer bsrpc.Logout() + } + + // REQUIRED for HandleRobloxLog to function. + os.Setenv("WINEDEBUG", os.Getenv("WINEDEBUG")+",warn+debugstr") + cmd, err := b.Command(args...) if err != nil { return err } + o, err := cmd.OutputPipe() + if err != nil { + return err + } + + // Act as the signal holder, as roblox/wine will not do anything with the INT signal. + // Additionally, if Vinegar got TERM, it will also immediately exit, but roblox + // continues running. + c := make(chan os.Signal, 1) + signal.Notify(c, os.Interrupt, syscall.SIGTERM) + go func() { + <-c + + // Only kill the process if it even had a PID + if cmd.Process != nil { + log.Println("Killing Roblox") + // This way, cmd.Run() will return and the wineprefix killer will be ran. + cmd.Process.Kill() + } + + // Don't handle INT after it was recieved, this way if another signal was sent, + // Vinegar will immediately exit. + signal.Stop(c) + }() + + go b.HandleOutput(o) log.Printf("Launching %s", b.Name) b.Splash.Message("Launching " + b.Alias) - kill := true + defer func() { + // Don't do anything if the process even ran correctly. + if cmd.Process == nil { + return + } + + for { + time.Sleep(100 * time.Millisecond) + + // This is because there may be a race condition between the process + // procfs depletion and the proccess getting killed. + // CommFound walks over procfs, so here ensure that the process no longer + // exists in procfs. + _, err := os.Stat(filepath.Join("/proc", strconv.Itoa(cmd.Process.Pid))) + if err != nil { + break + } + } + + if util.CommFound("Roblox") { + log.Println("Another Roblox instance is already running, not killing wineprefix") + return + } + + if b.Config.AutoKillPrefix { + b.Prefix.Kill() + } + }() - // If roblox is already running, don't kill wineprefix, even if - // auto kill prefix is enabled - if util.CommFound("Roblox") { - log.Println("Roblox is already running, not killing wineprefix after exit") - kill = false + if err := cmd.Run(); err != nil { + return fmt.Errorf("roblox process: %w", err) } - // Launches into foreground - if err := cmd.Start(); err != nil { - return err + return nil +} + +func (b *Binary) HandleOutput(wr io.Reader) { + s := bufio.NewScanner(wr) + for s.Scan() { + txt := s.Text() + + // XXXX:channel:class OutputDebugStringA "[FLog::Foo] Message" + if len(txt) >= 39 && txt[19:37] == "OutputDebugStringA" { + // length of roblox Flog message + if len(txt) >= 90 { + b.HandleRobloxLog(txt[39 : len(txt)-1]) + } + continue + } + + fmt.Fprintln(b.Prefix.Output, txt) } +} - time.Sleep(2500 * time.Millisecond) - b.Splash.Close() +func (b *Binary) HandleRobloxLog(line string) { + // As soon as a singular Roblox log has been hit, close the splash window + if !b.Splash.IsClosed() { + b.Splash.Close() + } - if kill && b.Config.AutoKillPrefix { - log.Println("Waiting for Roblox's process to die :)") + fmt.Fprintln(b.Prefix.Output, line) - for { - time.Sleep(1 * time.Second) + if strings.Contains(line, "DID_LOG_IN") { + b.Auth = true + return + } - if !util.CommFound(exe[:15]) { - break - } + if strings.Contains(line, "InternalBrowser") { + msg := DialogUseBrowserMsg + if !b.Auth { + msg = DialogQuickLoginMsg } - b.Prefix.Kill() + b.Splash.Dialog(DialogInternalBrowserBrokenTitle, msg) + return } - return nil + if b.Config.DiscordRPC { + if err := b.Activity.HandleRobloxLog(line); err != nil { + log.Printf("Failed to handle Discord RPC: %s", err) + } + } } -func (b *Binary) FetchVersion() (roblox.Version, error) { +func (b *Binary) FetchVersion() (version.Version, error) { b.Splash.Message("Fetching " + b.Alias) if b.Config.ForcedVersion != "" { log.Printf("WARNING: using forced version: %s", b.Config.ForcedVersion) - return roblox.NewVersion(b.Type, b.Config.Channel, b.Config.ForcedVersion) + return version.New(b.Type, b.Config.Channel, b.Config.ForcedVersion), nil } - return roblox.LatestVersion(b.Type, b.Config.Channel) + return version.Fetch(b.Type, b.Config.Channel) } func (b *Binary) Setup() error { @@ -128,7 +234,7 @@ func (b *Binary) Setup() error { } if stateVer != ver.GUID { - log.Printf("Installing %s (%s -> %s)", b.Name, stateVer, ver) + log.Printf("Installing %s (%s -> %s)", b.Name, stateVer, ver.GUID) if err := b.Install(); err != nil { return err @@ -185,6 +291,15 @@ func (b *Binary) Install() error { return err } + if b.Type == roblox.Studio { + brokenFont := filepath.Join(b.Dir, "StudioFonts", "SourceSansPro-Black.ttf") + + log.Printf("Removing broken font %s", brokenFont) + if err := os.RemoveAll(brokenFont); err != nil { + log.Printf("Failed to remove font: %s", err) + } + } + if err := bootstrapper.WriteAppSettings(b.Dir); err != nil { return err } @@ -297,7 +412,7 @@ func (b *Binary) Command(args ...string) (*wine.Cmd, error) { mutexer := b.Prefix.Command("wine", filepath.Join(BinPrefix, "robloxmutexer.exe")) err := mutexer.Start() if err != nil { - return &wine.Cmd{}, err + return &wine.Cmd{}, fmt.Errorf("robloxmutexer: %w") } } @@ -307,6 +422,7 @@ func (b *Binary) Command(args ...string) (*wine.Cmd, error) { if len(launcher) >= 1 { cmd.Args = append(launcher, cmd.Args...) + // For safety, ensure that the launcher is in PATH launcherPath, err := exec.LookPath(launcher[0]) if err != nil { return &wine.Cmd{}, err diff --git a/cmd/vinegar/vinegar.go b/cmd/vinegar/vinegar.go index f0229b78..d3693c02 100644 --- a/cmd/vinegar/vinegar.go +++ b/cmd/vinegar/vinegar.go @@ -6,9 +6,7 @@ import ( "io" "log" "os" - "os/signal" "path/filepath" - "syscall" "github.com/vinegarhq/vinegar/internal/config" "github.com/vinegarhq/vinegar/internal/config/editor" @@ -16,6 +14,7 @@ import ( "github.com/vinegarhq/vinegar/internal/dirs" "github.com/vinegarhq/vinegar/internal/logs" "github.com/vinegarhq/vinegar/roblox" + "github.com/vinegarhq/vinegar/sysinfo" "github.com/vinegarhq/vinegar/wine" ) @@ -24,7 +23,7 @@ var BinPrefix string func usage() { fmt.Fprintln(os.Stderr, "usage: vinegar [-config filepath] player|studio [args...]") fmt.Fprintln(os.Stderr, "usage: vinegar [-config filepath] exec prog [args...]") - fmt.Fprintln(os.Stderr, " vinegar [-config filepath] edit|kill|uninstall|delete|install-webview2|winetricks") + fmt.Fprintln(os.Stderr, " vinegar [-config filepath] edit|kill|uninstall|delete|install-webview2|winetricks|sysinfo") os.Exit(1) } @@ -49,29 +48,22 @@ func main() { } // These commands (except player & studio) don't require a configuration, // but they require a wineprefix, hence wineroot of configuration is required. - case "player", "studio", "exec", "kill", "install-webview2", "winetricks": - pfxKilled := false + case "sysinfo", "player", "studio", "exec", "kill", "install-webview2", "winetricks": cfg, err := config.Load(*configPath) if err != nil { log.Fatal(err) } - pfx := wine.New(dirs.Prefix) - - c := make(chan os.Signal, 1) - signal.Notify(c, syscall.SIGTERM, syscall.SIGINT) - - go func() { - <-c - pfxKilled = true - pfx.Kill() - - if pfxKilled { - 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 { + log.Fatal(err) + } switch cmd { + case "sysinfo": + Sysinfo(&pfx) case "exec": if len(args) < 2 { usage() @@ -95,7 +87,6 @@ func main() { logFile := logs.File(cmd) logOutput := io.MultiWriter(logFile, os.Stderr) - pfx.Output = logOutput log.SetOutput(logOutput) @@ -109,16 +100,22 @@ func main() { } go func() { + defer func() { + if r := recover(); r != nil { + log.Println("WARNING: Recovered from splash panic", r) + } + }() + err := b.Splash.Run() if err != nil { - log.Fatal(err) + log.Printf("WARNING: Failed to run splash window: %s", err) } }() b.Splash.Desc(b.Config.Channel) errHandler := func(err error) { - if !cfg.Splash.Enabled { + if !cfg.Splash.Enabled || b.Splash.IsClosed() { log.Fatal(err) } @@ -127,8 +124,8 @@ func main() { select {} // wait for window to close } - if _, err := os.Stat(filepath.Join(pfx.Dir, "drive_c", "windows")); err != nil { - log.Printf("Initializing wineprefix at %s", pfx.Dir) + if _, err := os.Stat(filepath.Join(pfx.Dir(), "drive_c", "windows")); err != nil { + log.Printf("Initializing wineprefix at %s", pfx.Dir()) b.Splash.Message("Initializing wineprefix") if err := PrefixInit(&pfx); err != nil { @@ -157,11 +154,7 @@ func PrefixInit(pfx *wine.Prefix) error { return err } - if err := pfx.DisableCrashDialogs(); err != nil { - return err - } - - return pfx.RegistryAdd("HKEY_CURRENT_USER\\Control Panel\\Desktop", "LogPixels", wine.REG_DWORD, "97") + return pfx.SetDPI(97) } func Uninstall() { @@ -191,3 +184,24 @@ func Delete() { log.Fatal(err) } } + +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) + } + + info := `## System information +* Distro: %s +* Processor: %s + * Supports AVX: %t +* Kernel: %s +* Wine: %s` + + fmt.Printf(info, sysinfo.Distro, sysinfo.CPU, sysinfo.HasAVX, sysinfo.Kernel, ver) + if sysinfo.InFlatpak { + fmt.Println("* Flatpak: [x]") + } +} diff --git a/desktop/roblox-app.desktop.in b/desktop/roblox-app.desktop.in index 738c741c..c9e8cd8c 100644 --- a/desktop/roblox-app.desktop.in +++ b/desktop/roblox-app.desktop.in @@ -1,6 +1,6 @@ [Desktop Entry] Type=Application -Name=Roblox App (Vinegar) +Name=Roblox App Icon=$FLATPAK.player Exec=vinegar player -app Terminal=false diff --git a/desktop/roblox-player.desktop.in b/desktop/roblox-player.desktop.in index 567d9db6..4683262c 100644 --- a/desktop/roblox-player.desktop.in +++ b/desktop/roblox-player.desktop.in @@ -1,6 +1,6 @@ [Desktop Entry] Type=Application -Name=Roblox Player (Vinegar) +Name=Roblox Player NoDisplay=true Icon=$FLATPAK.player Exec=vinegar player %u diff --git a/desktop/roblox-studio.desktop.in b/desktop/roblox-studio.desktop.in index 71d421d0..72def383 100644 --- a/desktop/roblox-studio.desktop.in +++ b/desktop/roblox-studio.desktop.in @@ -1,6 +1,6 @@ [Desktop Entry] Type=Application -Name=Roblox Studio (Vinegar) +Name=Roblox Studio Icon=$FLATPAK.studio Exec=vinegar studio %u Terminal=false diff --git a/go.mod b/go.mod index d2b8d78b..e7321bd7 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module github.com/vinegarhq/vinegar -go 1.20 +go 1.21 require ( github.com/BurntSushi/toml v1.3.2 @@ -12,6 +12,7 @@ require ( require ( dario.cat/mergo v1.0.0 gioui.org v0.3.0 + github.com/hugolgst/rich-go v0.0.0-20230917173849-4a4fb1d3c362 golang.org/x/sys v0.11.0 ) @@ -23,6 +24,7 @@ require ( golang.org/x/exp/shiny v0.0.0-20220827204233-334a2380cb91 // indirect golang.org/x/image v0.5.0 // indirect golang.org/x/text v0.7.0 // indirect + gopkg.in/natefinch/npipe.v2 v2.0.0-20160621034901-c1b8fa8bdcce // indirect ) retract ( diff --git a/go.sum b/go.sum index 395b7f31..ad3053a7 100644 --- a/go.sum +++ b/go.sum @@ -1,6 +1,7 @@ dario.cat/mergo v1.0.0 h1:AGCNq9Evsj31mOgNPcLyXc+4PNABt905YmuqPYYpBWk= dario.cat/mergo v1.0.0/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk= eliasnaur.com/font v0.0.0-20230308162249-dd43949cb42d h1:ARo7NCVvN2NdhLlJE9xAbKweuI9L6UgfTbYb0YwPacY= +eliasnaur.com/font v0.0.0-20230308162249-dd43949cb42d/go.mod h1:OYVuxibdk9OSLX8vAqydtRPP87PyTFcT9uH3MlEGBQA= gioui.org v0.3.0 h1:xZty/uLl1+/HNKpumX60JPQd46n8Zy6lc5T3IRMKoR4= gioui.org v0.3.0/go.mod h1:1H72sKEk/fNFV+l0JNeM2Dt3co3Y4uaQcD+I+/GQ0e4= gioui.org/cpu v0.0.0-20210808092351-bfe733dd3334/go.mod h1:A8M0Cn5o+vY5LTMlnRoK3O5kG+rH0kWfJjeKd9QpBmQ= @@ -17,9 +18,13 @@ github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSs github.com/go-text/typesetting v0.0.0-20230803102845-24e03d8b5372 h1:FQivqchis6bE2/9uF70M2gmmLpe82esEm2QadL0TEJo= github.com/go-text/typesetting v0.0.0-20230803102845-24e03d8b5372/go.mod h1:evDBbvNR/KaVFZ2ZlDSOWWXIUKq0wCOEtzLxRM8SG3k= github.com/go-text/typesetting-utils v0.0.0-20230616150549-2a7df14b6a22 h1:LBQTFxP2MfsyEDqSKmUBZaDuDHN1vpqDyOZjcqS7MYI= +github.com/go-text/typesetting-utils v0.0.0-20230616150549-2a7df14b6a22/go.mod h1:DDxDdQEnB70R8owOx3LVpEFvpMK9eeH1o2r0yZhFI9o= +github.com/hugolgst/rich-go v0.0.0-20230917173849-4a4fb1d3c362 h1:Q8D2HP1l2mOoeRVLhHjDhK8MRb7LkjESWRtd2gbauws= +github.com/hugolgst/rich-go v0.0.0-20230917173849-4a4fb1d3c362/go.mod h1:nGaW7CGfNZnhtiFxMpc4OZdqIexGXjUlBnlmpZmjEKA= github.com/otiai10/copy v1.12.0 h1:cLMgSQnXBs1eehF0Wy/FAGsgDTDmAqFR7rQylBb1nDY= github.com/otiai10/copy v1.12.0/go.mod h1:rSaLseMUsZFFbsFGc7wCJnnkTAvdc5L6VWxPE4308Ww= github.com/otiai10/mint v1.5.1 h1:XaPLeE+9vGbuyEHem1JNk3bYc7KKqyI/na0/mLd/Kks= +github.com/otiai10/mint v1.5.1/go.mod h1:MJm72SBthJjz8qhefc4z1PYEieWmy8Bku7CjcAqyUSM= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= @@ -62,6 +67,8 @@ golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtn golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/natefinch/npipe.v2 v2.0.0-20160621034901-c1b8fa8bdcce h1:+JknDZhAj8YMt7GC73Ei8pv4MzjDUNPHgQWJdtMAaDU= +gopkg.in/natefinch/npipe.v2 v2.0.0-20160621034901-c1b8fa8bdcce/go.mod h1:5AcXVHNjg+BDxry382+8OKon8SEWiKktQR07RKPsv1c= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/config/config.go b/internal/config/config.go index d17027f5..d81dbeb6 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -29,6 +29,7 @@ type Binary struct { Channel string `toml:"channel"` Launcher string `toml:"launcher"` Renderer string `toml:"renderer"` + DiscordRPC bool `toml:"discord_rpc"` ForcedVersion string `toml:"forced_version"` AutoKillPrefix bool `toml:"auto_kill_prefix"` Dxvk bool `toml:"dxvk"` @@ -81,7 +82,7 @@ func Default() Config { "WINEARCH": "win64", "WINEDEBUG": "err-kerberos,err-ntlm", "WINEESYNC": "1", - "WINEDLLOVERRIDES": "dxdiagn=d;winemenubuilder.exe=d", + "WINEDLLOVERRIDES": "dxdiagn,winemenubuilder.exe,mscoree,mshtml=", "DXVK_LOG_LEVEL": "warn", "DXVK_LOG_PATH": "none", @@ -94,6 +95,7 @@ func Default() Config { ForcedGpu: "prime-discrete", }, Player: Binary{ + DiscordRPC: true, Dxvk: true, AutoKillPrefix: true, FFlags: roblox.FFlags{ diff --git a/internal/config/state/cleaners.go b/internal/config/state/cleaners.go index 6705ec86..25162e17 100644 --- a/internal/config/state/cleaners.go +++ b/internal/config/state/cleaners.go @@ -3,7 +3,6 @@ package state import ( "log" "os" - "path/filepath" "github.com/vinegarhq/vinegar/internal/dirs" "github.com/vinegarhq/vinegar/util" @@ -19,7 +18,7 @@ func CleanPackages() error { return util.WalkDirExcluded(dirs.Downloads, pkgs, func(path string) error { log.Printf("Removing unused package %s", path) - return os.Remove(filepath.Join(dirs.Downloads, path)) + return os.Remove(path) }) } @@ -33,6 +32,6 @@ func CleanVersions() error { return util.WalkDirExcluded(dirs.Versions, vers, func(path string) error { log.Printf("Removing unused version directory %s", path) - return os.RemoveAll(filepath.Join(dirs.Versions, path)) + return os.RemoveAll(path) }) } diff --git a/internal/logs/logs.go b/internal/logs/logs.go index 2edf0432..c5db1a23 100644 --- a/internal/logs/logs.go +++ b/internal/logs/logs.go @@ -20,8 +20,8 @@ func File(name string) *os.File { file, err := os.Create(path) if err != nil { - log.Printf("Failed to create %s log file: %s", name, err) - return nil + log.Printf("Failed to create %s log file: %s, using Stderr", name, err) + return os.Stderr } log.Printf("Logging to file: %s", path) diff --git a/internal/splash/nosplash.go b/internal/splash/nosplash.go index 2ae30124..622fdb17 100644 --- a/internal/splash/nosplash.go +++ b/internal/splash/nosplash.go @@ -4,6 +4,7 @@ package splash import ( "errors" + "log" "github.com/vinegarhq/vinegar/internal/config" ) @@ -27,6 +28,14 @@ func (ui *Splash) Progress(progress float32) { func (ui *Splash) Close() { } +func (ui *Splash) IsClosed() bool { + return true +} + +func (ui *Splash) Dialog(title, msg string) { + log.Printf("Dialog: %s %s", title, msg) +} + func New(cfg *config.Splash) *Splash { return &Splash{ Config: cfg, diff --git a/internal/splash/splash.go b/internal/splash/splash.go index 5515656e..aa079f2e 100644 --- a/internal/splash/splash.go +++ b/internal/splash/splash.go @@ -68,11 +68,22 @@ func (ui *Splash) Close() { ui.Perform(system.ActionClose) } -func New(cfg *config.Splash) *Splash { - width := unit.Dp(448) - height := unit.Dp(240) +func (ui *Splash) IsClosed() bool { + return ui.closed +} + +func window(width, height unit.Dp) *app.Window { + return app.NewWindow( + app.Decorated(false), + app.Size(width, height), + app.MinSize(width, height), + app.MaxSize(width, height), + app.Title("Vinegar"), + ) +} - th := material.NewTheme() +func theme(cfg *config.Splash) (th *material.Theme) { + th = material.NewTheme() th.Shaper = text.NewShaper(text.WithCollection(gofont.Collection())) th.Palette = material.Palette{ Bg: rgb(cfg.Bg), @@ -81,19 +92,64 @@ func New(cfg *config.Splash) *Splash { ContrastFg: rgb(cfg.Gray2), } + return +} + +func New(cfg *config.Splash) *Splash { + width := unit.Dp(448) + height := unit.Dp(240) + logo, _, _ := image.Decode(bytes.NewReader(vinegarlogo)) return &Splash{ logo: logo, - Theme: th, + Theme: theme(cfg), Config: cfg, - Window: app.NewWindow( - app.Decorated(false), - app.Size(width, height), - app.MinSize(width, height), - app.MaxSize(width, height), - app.Title("Vinegar"), - ), + Window: window(width, height), + } +} + +// Make a new application window using vinegar's existing properties to +// simulate a dialog. +func (ui *Splash) Dialog(title, msg string) { + var ops op.Ops + var okButton widget.Clickable + width := unit.Dp(480) + height := unit.Dp(144) + w := window(width, height) + + if !ui.Config.Enabled || ui.Theme == nil { + return + } + + for e := range w.Events() { + switch e := e.(type) { + case system.DestroyEvent: + // no real care for errors, this is a dialog + return + case system.FrameEvent: + gtx := layout.NewContext(&ops, e) + paint.Fill(gtx.Ops, ui.Theme.Palette.Bg) + + if okButton.Clicked() { + w.Perform(system.ActionClose) + } + + layout.Center.Layout(gtx, func(gtx C) D { + return layout.Flex{ + Axis: layout.Vertical, + Alignment: layout.Middle, + }.Layout(gtx, + layout.Rigid(material.H6(ui.Theme, title).Layout), + layout.Rigid(layout.Spacer{Height: unit.Dp(10)}.Layout), + layout.Rigid(material.Body2(ui.Theme, msg).Layout), + layout.Rigid(layout.Spacer{Height: unit.Dp(16)}.Layout), + layout.Rigid(button(ui.Theme, &okButton, "Ok").Layout), + ) + }) + + e.Frame(gtx.Ops) + } } } @@ -103,6 +159,7 @@ func (ui *Splash) Run() error { var exitButton widget.Clickable if !ui.Config.Enabled { + ui.closed = true return nil } @@ -164,19 +221,12 @@ func (ui *Splash) Run() error { }), layout.Rigid(func(gtx C) D { - inset := layout.Inset{ - Top: unit.Dp(16), - Right: unit.Dp(6), - Left: unit.Dp(6), - } - + inset := buttonInset() return layout.Flex{}.Layout(gtx, layout.Rigid(func(gtx C) D { return inset.Layout(gtx, func(gtx C) D { - btn := material.Button(ui.Theme, &exitButton, "Cancel") + btn := button(ui.Theme, &exitButton, "Cancel") btn.Background = rgb(ui.Config.Red) - btn.Color = ui.Theme.Palette.Fg - btn.CornerRadius = 16 return btn.Layout(gtx) }) }), @@ -186,10 +236,7 @@ func (ui *Splash) Run() error { } return inset.Layout(gtx, func(gtx C) D { - btn := material.Button(ui.Theme, &showLogButton, "Show Log") - btn.Color = ui.Theme.Palette.Fg - btn.CornerRadius = 16 - return btn.Layout(gtx) + return button(ui.Theme, &showLogButton, "Show Log").Layout(gtx) }) }), ) @@ -201,5 +248,21 @@ func (ui *Splash) Run() error { } } + ui.closed = true return nil } + +func buttonInset() layout.Inset { + return layout.Inset{ + Top: unit.Dp(16), + Right: unit.Dp(6), + Left: unit.Dp(6), + } +} + +func button(th *material.Theme, button *widget.Clickable, txt string) (bs material.ButtonStyle) { + bs = material.Button(th, button, txt) + bs.Color = th.Palette.Fg + bs.CornerRadius = 16 + return +} diff --git a/roblox/api/api.go b/roblox/api/api.go new file mode 100644 index 00000000..d487b213 --- /dev/null +++ b/roblox/api/api.go @@ -0,0 +1,54 @@ +package api + +import ( + "encoding/json" + "errors" + "fmt" + "log" + "net/http" +) + +const APIURL = "https://%s.roblox.com/%s" + +var httpClient = &http.Client{} + +var ErrBadStatus = errors.New("bad status") + +func SetClient(client *http.Client) { + httpClient = client +} + +func Request(method, service, endpoint string, v interface{}) error { + log.Printf("Performing Roblox API %s %s request on %s", method, service, endpoint) + + url := fmt.Sprintf(APIURL, service, endpoint) + + req, err := http.NewRequest(method, url, nil) + if err != nil { + return err + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "application/json") + + resp, err := httpClient.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + // Return the given API error only if the decoder succeeded + errsResp := new(errorsResponse) + if err := json.NewDecoder(resp.Body).Decode(errsResp); err == nil { + return errsResp + } + + return fmt.Errorf("%w: %s", ErrBadStatus, resp.Status) + } + + if v != nil { + return json.NewDecoder(resp.Body).Decode(v) + } + + return nil +} diff --git a/roblox/api/clientsettings.go b/roblox/api/clientsettings.go new file mode 100644 index 00000000..e476b63f --- /dev/null +++ b/roblox/api/clientsettings.go @@ -0,0 +1,29 @@ +package api + +import ( + "github.com/vinegarhq/vinegar/roblox" +) + +type ClientVersion struct { + Version string `json:"version"` + ClientVersionUpload string `json:"clientVersionUpload"` + BootstrapperVersion string `json:"bootstrapperVersion"` + NextClientVersionUpload string `json:"nextClientVersionUpload,omitempty"` + NextClientVersion string `json:"nextClientVersion,omitempty"` +} + +func GetClientVersion(bt roblox.BinaryType, channel string) (ClientVersion, error) { + var cv ClientVersion + + ep := "v2/client-version/" + bt.BinaryName() + if channel != "" { + ep += "/channel/" + channel + } + + err := Request("GET", "clientsettings", ep, &cv) + if err != nil { + return ClientVersion{}, err + } + + return cv, nil +} diff --git a/roblox/api/error.go b/roblox/api/error.go new file mode 100644 index 00000000..47e8163e --- /dev/null +++ b/roblox/api/error.go @@ -0,0 +1,35 @@ +package api + +import ( + "fmt" + "strings" +) + +type ErrorResponse struct { + Code int `json:"code"` + Message string `json:"message"` + Field string `json:"field,omitempty"` +} + +type errorsResponse struct { + Errors []ErrorResponse `json:"errors,omitempty"` +} + +func (err ErrorResponse) Error() string { + return fmt.Sprintf("response code %d: %s", err.Code, err.Message) +} + +func (errs errorsResponse) Error() string { + s := make([]string, len(errs.Errors)) + for i, e := range errs.Errors { + s[i] = e.Error() + } + return strings.Join(s, "; ") +} + +func (errs errorsResponse) Unwrap() error { + if len(errs.Errors) == 0 { + return nil + } + return errs.Errors[0] +} diff --git a/roblox/api/games.go b/roblox/api/games.go new file mode 100644 index 00000000..57fcf614 --- /dev/null +++ b/roblox/api/games.go @@ -0,0 +1,52 @@ +package api + +type Creator struct { + ID int64 `json:"id"` + Name string `json:"name"` + Type string `json:"type"` + IsRNVAccount bool `json:"isRNVAccount"` + HasVerifiedBadge bool `json:"hasVerifiedBadge"` +} + +type GameDetail struct { + ID int64 `json:"id"` + RootPlaceID int64 `json:"rootPlaceId"` + Name string `json:"name"` + Description string `json:"description"` + SourceName string `json:"sourceName"` + SourceDescription string `json:"sourceDescription"` + Creator Creator `json:"creator"` + Price int64 `json:"price"` + AllowedGearGenres []string `json:"allowedGearGenres"` + AllowedGearCategories []string `json:"allowedGearCategories"` + IsGenreEnforced bool `json:"isGenreEnforced"` + CopyingAllowed bool `json:"copyingAllowed"` + Playing int64 `json:"playing"` + Visits int64 `json:"visits"` + MaxPlayers int32 `json:"maxPlayers"` + Created string `json:"created"` + Updated string `json:"updated"` + StudioAccessToApisAllowed bool `json:"studioAccessToApisAllowed"` + CreateVipServersAllowed bool `json:"createVipServersAllowed"` + UniverseAvatarType string `json:"universeAvatarType"` + Genre string `json:"genre"` + IsAllGenre bool `json:"isAllGenre"` + IsFavoritedByUser bool `json:"isFavoritedByUser"` + FavoritedCount int64 `json:"favoritedCount"` +} + +type GameDetailsResponse struct { + Data []GameDetail `json:"data"` +} + +func GetGameDetails(universeID string) (GameDetail, error) { + var gdr GameDetailsResponse + + // uids := strings.Join(universeIDs, ",") + err := Request("GET", "games", "v1/games?universeIds="+universeID, &gdr) + if err != nil { + return GameDetail{}, err + } + + return gdr.Data[0], nil +} diff --git a/roblox/api/thumbnails.go b/roblox/api/thumbnails.go new file mode 100644 index 00000000..d84f4213 --- /dev/null +++ b/roblox/api/thumbnails.go @@ -0,0 +1,30 @@ +package api + +import ( + "fmt" +) + +type Thumbnail struct { + TargetID int64 `json:"targetId"` + State string `json:"state"` + ImageURL string `json:"imageUrl"` + Version string `json:"version"` +} + +type thumbnailResponse struct { + Data []Thumbnail `json:"data"` +} + +func GetGameIcon(universeID, returnPolicy, size, format string, isCircular bool) (Thumbnail, error) { + var tnr thumbnailResponse + + err := Request("GET", "thumbnails", + fmt.Sprintf("v1/games/icons?universeIds=%s&returnPolicy=%s&size=%s&format=%s&isCircular=%t", + universeID, returnPolicy, size, format, isCircular), &tnr, + ) + if err != nil { + return Thumbnail{}, err + } + + return tnr.Data[0], nil +} diff --git a/roblox/api/universe.go b/roblox/api/universe.go new file mode 100644 index 00000000..24491c9b --- /dev/null +++ b/roblox/api/universe.go @@ -0,0 +1,21 @@ +package api + +import ( + "strconv" +) + +type UniverseIdResponse struct { + UniverseID int64 `json:"universeId"` +} + +func GetUniverseID(placeID string) (string, error) { + var uidr UniverseIdResponse + + // This API is undocumented. + err := Request("GET", "apis", "universes/v1/places/"+placeID+"/universe", &uidr) + if err != nil { + return "", err + } + + return strconv.FormatInt(uidr.UniverseID, 10), nil +} diff --git a/roblox/bootstrapper/manifest.go b/roblox/bootstrapper/manifest.go index 254f4f14..e9c4e2da 100644 --- a/roblox/bootstrapper/manifest.go +++ b/roblox/bootstrapper/manifest.go @@ -5,29 +5,41 @@ import ( "log" "strings" - "github.com/vinegarhq/vinegar/roblox" + "github.com/vinegarhq/vinegar/roblox/version" "github.com/vinegarhq/vinegar/util" ) type Manifest struct { - *roblox.Version + *version.Version DeployURL string Packages } -func FetchManifest(ver *roblox.Version) (Manifest, error) { +func channelPath(channel string) string { + // Ensure that the channel is lowercased, since internally in + // 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 + if channel == "" || channel == version.DefaultChannel { + return "/" + } + + return "/channel/" + channel + "/" +} + +func FetchManifest(ver *version.Version) (Manifest, error) { cdn, err := CDN() if err != nil { return Manifest{}, err } + durl := cdn + channelPath(ver.Channel) + ver.GUID - deployURL := cdn + roblox.ChannelPath(ver.Channel) + ver.GUID - - log.Printf("Fetching manifest for %s (%s)", ver.GUID, deployURL) + log.Printf("Fetching manifest for %s (%s)", ver.GUID, durl) - manif, err := util.Body(deployURL + "-rbxPkgManifest.txt") + manif, err := util.Body(durl + "-rbxPkgManifest.txt") if err != nil { - return Manifest{}, fmt.Errorf("fetch %s manifest: %w, is your channel valid?", ver.GUID, err) + return Manifest{}, fmt.Errorf("fetch %s manifest: %w", ver.GUID, err) } pkgs, err := ParsePackages(strings.Split(manif, "\r\n")) @@ -37,7 +49,7 @@ func FetchManifest(ver *roblox.Version) (Manifest, error) { return Manifest{ Version: ver, - DeployURL: deployURL, + DeployURL: durl, Packages: pkgs, }, nil } diff --git a/roblox/fflags.go b/roblox/fflags.go index 85673a92..3b8705a8 100644 --- a/roblox/fflags.go +++ b/roblox/fflags.go @@ -9,15 +9,12 @@ import ( "path/filepath" ) -var ( - DefaultRenderer = "D3D11" - Renderers = []string{ - "OpenGL", - "D3D11FL10", - DefaultRenderer, - "Vulkan", - } -) +var renderers = []string{ + "OpenGL", + "D3D11FL10", + "D3D11", + "Vulkan", +} type FFlags map[string]interface{} @@ -48,8 +45,6 @@ func (f *FFlags) Apply(versionDir string) error { return err } - log.Printf("FFlags used: %s", string(fflags)) - _, err = file.Write(fflags) if err != nil { return err @@ -59,11 +54,12 @@ func (f *FFlags) Apply(versionDir string) error { } func ValidRenderer(renderer string) bool { + // Assume Roblox's internal default renderer if renderer == "" { - renderer = DefaultRenderer + return true } - for _, r := range Renderers { + for _, r := range renderers { if renderer == r { return true } @@ -73,8 +69,9 @@ func ValidRenderer(renderer string) bool { } func (f *FFlags) SetRenderer(renderer string) error { + // Assume Roblox's internal default renderer if renderer == "" { - renderer = DefaultRenderer + return nil } if !ValidRenderer(renderer) { @@ -88,7 +85,7 @@ func (f *FFlags) SetRenderer(renderer string) error { log.Printf("Using renderer: %s", renderer) // Disable all other renderers except the given one. - for _, r := range Renderers { + for _, r := range renderers { isRenderer := r == renderer (*f)["FFlagDebugGraphicsPrefer"+r] = isRenderer diff --git a/roblox/version.go b/roblox/version.go deleted file mode 100644 index 7c66d9a1..00000000 --- a/roblox/version.go +++ /dev/null @@ -1,97 +0,0 @@ -package roblox - -import ( - "encoding/json" - "errors" - "fmt" - "log" - "strings" - - "github.com/vinegarhq/vinegar/util" -) - -const ( - DefaultChannel = "live" - ClientSettingsURL = "https://clientsettingscdn.roblox.com/v2/client-version" -) - -var ErrNoVersion = errors.New("no version found") - -type ClientVersion struct { - Version string `json:"version"` - ClientVersionUpload string `json:"clientVersionUpload"` - BootstrapperVersion string `json:"bootstrapperVersion"` - NextClientVersionUpload string `json:"nextClientVersionUpload"` - NextClientVersion string `json:"nextClientVersion"` -} - -type Version struct { - Type BinaryType - Channel string - GUID string -} - -func ChannelPath(channel string) string { - // Ensure that the channel is lowercased, since internally in - // ClientSettings it will be lowercased, but not on the deploy mirror. - channel = strings.ToLower(channel) - - if channel == DefaultChannel { - return "/" - } - - return "/channel/" + channel + "/" -} - -func NewVersion(bt BinaryType, channel string, GUID string) (Version, error) { - if channel == "" { - channel = DefaultChannel - } - - if GUID == "" { - return Version{}, ErrNoVersion - } - - log.Printf("Found %s version %s", bt.BinaryName(), GUID) - - return Version{ - Type: bt, - Channel: channel, - GUID: GUID, - }, nil -} - -func LatestVersion(bt BinaryType, channel string) (Version, error) { - name := bt.BinaryName() - var cv ClientVersion - - if channel == "" { - channel = DefaultChannel - } - - url := ClientSettingsURL + "/" + name + ChannelPath(channel) - - log.Printf("Fetching latest version of %s for channel %s (%s)", name, channel, url) - - resp, err := util.Body(url) - if err != nil { - if errors.Is(err, util.ErrBadStatus) { - return Version{}, fmt.Errorf("invalid channel given for %s", name) - } else { - return Version{}, fmt.Errorf("fetch version for %s: %w", name, err) - } - } - - err = json.Unmarshal([]byte(resp), &cv) - if err != nil { - return Version{}, fmt.Errorf("version clientsettings unmarshal: %w", err) - } - - if cv.ClientVersionUpload == "" { - return Version{}, ErrNoVersion - } - - log.Printf("Fetched %s canonical version %s", bt.BinaryName(), cv.Version) - - return NewVersion(bt, channel, cv.ClientVersionUpload) -} diff --git a/roblox/version/version.go b/roblox/version/version.go new file mode 100644 index 00000000..88b7bd25 --- /dev/null +++ b/roblox/version/version.go @@ -0,0 +1,55 @@ +package version + +import ( + "log" + + "github.com/vinegarhq/vinegar/roblox" + "github.com/vinegarhq/vinegar/roblox/api" +) + +const DefaultChannel = "live" + +type ClientVersion struct { + Version string `json:"version"` + ClientVersionUpload string `json:"clientVersionUpload"` + BootstrapperVersion string `json:"bootstrapperVersion"` + NextClientVersionUpload string `json:"nextClientVersionUpload"` + NextClientVersion string `json:"nextClientVersion"` +} + +type Version struct { + Type roblox.BinaryType + Channel string + GUID string +} + +func New(bt roblox.BinaryType, channel string, GUID string) Version { + if channel == "" { + channel = DefaultChannel + } + + log.Printf("Got %s version %s with channel %s", bt.BinaryName(), GUID, channel) + + return Version{ + Type: bt, + Channel: channel, + GUID: GUID, + } +} + +func Fetch(bt roblox.BinaryType, channel string) (Version, error) { + if channel == "" { + channel = DefaultChannel + } + + log.Printf("Fetching latest version of %s for channel %s", bt.BinaryName(), channel) + + cv, err := api.GetClientVersion(bt, channel) + if err != nil { + return Version{}, err + } + + log.Printf("Fetched %s canonical version %s", bt.BinaryName(), cv.Version) + + return New(bt, channel, cv.ClientVersionUpload), nil +} diff --git a/sysinfo/card.go b/sysinfo/card.go new file mode 100644 index 00000000..92615686 --- /dev/null +++ b/sysinfo/card.go @@ -0,0 +1,57 @@ +//go:build linux && amd64 + +package sysinfo + +import ( + "os" + "path" + "path/filepath" + "strings" +) + +type card struct { + Path string + Driver string + Index int + Embedded bool +} + +const drmPath = "/sys/class/drm" + +var embeddedDisplays = []string{"eDP", "LVDS", "DP-2"} + +func getCards() (cs []card) { + drmCards, _ := filepath.Glob(path.Join(drmPath, "card[0-9]")) + + for i, c := range drmCards { + d, _ := filepath.EvalSymlinks(path.Join(c, "device/driver")) + d = path.Base(d) + + cs = append(cs, card{ + Path: c, + Driver: d, + Index: i, + Embedded: embedded(c), + }) + } + + return +} + +func embedded(cardPath string) (embed bool) { + filepath.Walk(drmPath, func(p string, f os.FileInfo, err error) error { + if !strings.HasPrefix(p, cardPath) { + return nil + } + + for _, hwd := range embeddedDisplays { + if strings.Contains(p, hwd) { + embed = true + } + } + + return nil + }) + + return +} diff --git a/sysinfo/cpu.go b/sysinfo/cpu.go new file mode 100644 index 00000000..f8c5fc20 --- /dev/null +++ b/sysinfo/cpu.go @@ -0,0 +1,32 @@ +//go:build linux + +package sysinfo + +import ( + "bufio" + "os" + "regexp" +) + +func cpuModel() string { + column := regexp.MustCompile("\t+: ") + + f, _ := os.Open("/proc/cpuinfo") + defer f.Close() + + s := bufio.NewScanner(f) + + for s.Scan() { + sl := column.Split(s.Text(), 2) + if sl == nil { + continue + } + + // pfft, who needs multiple cpus? just return if we got all we need + if sl[0] == "model name" { + return sl[1] + } + } + + return "unknown cpu" +} diff --git a/sysinfo/distro.go b/sysinfo/distro.go new file mode 100644 index 00000000..46e036c5 --- /dev/null +++ b/sysinfo/distro.go @@ -0,0 +1,59 @@ +//go:build linux + +package sysinfo + +import ( + "bufio" + "os" + "strings" +) + +type distro struct { + Name string + Version string +} + +func getDistro() (d distro) { + d = distro{ + Name: "unknown distro name", + Version: "unknown distro version", + } + + f, err := os.Open("/etc/os-release") + if err != nil { + return + } + defer f.Close() + + s := bufio.NewScanner(f) + + for s.Scan() { + m := strings.SplitN(s.Text(), "=", 2) + if len(m) != 2 { + continue + } + + val := strings.Trim(m[1], "\"") + + switch m[0] { + case "PRETTY_NAME": + d.Name = val + case "VERSION_ID": + d.Version = val + } + } + + return +} + +func (d distro) String() string { + if d.Name == "" { + d.Name = "Linux" + } + + if d.Version == "" { + d.Version = "Linux" + } + + return d.Name + " " + d.Version +} diff --git a/sysinfo/kernel.go b/sysinfo/kernel.go new file mode 100644 index 00000000..ec56596e --- /dev/null +++ b/sysinfo/kernel.go @@ -0,0 +1,36 @@ +package sysinfo + +import ( + "strings" + "syscall" +) + +type kernel struct { + Release string + Version string +} + +func getKernel() kernel { + var un syscall.Utsname + _ = syscall.Uname(&un) + + return kernel{ + Release: unameString(un.Release), + Version: unameString(un.Version), + } +} + +func (k kernel) String() string { + return k.Release + " " + k.Version +} + +func unameString(unarr [65]int8) string { + var sb strings.Builder + for _, b := range unarr[:] { + if b == 0 { + break + } + sb.WriteByte(byte(b)) + } + return sb.String() +} diff --git a/sysinfo/sysinfo.go b/sysinfo/sysinfo.go new file mode 100644 index 00000000..7934cedd --- /dev/null +++ b/sysinfo/sysinfo.go @@ -0,0 +1,27 @@ +//go:build amd64 +package sysinfo + +import ( + "os" + + "golang.org/x/sys/cpu" +) + +var ( + Kernel kernel + CPU string + Cards []card + Distro distro + HasAVX = cpu.X86.HasAVX + InFlatpak bool +) + +func init() { + Kernel = getKernel() + CPU = cpuModel() + Cards = getCards() + Distro = getDistro() + + _, err := os.Stat("/.flatpak-info") + InFlatpak = err == nil +} diff --git a/util/paths.go b/util/paths.go index c79094e6..5d0acf0a 100644 --- a/util/paths.go +++ b/util/paths.go @@ -2,23 +2,24 @@ package util import ( "os" + "path/filepath" + "slices" ) +// WalkDirExcluded will walk the file tree located at dir, calling +// onExcluded for every file or directory that does not have a name in included. func WalkDirExcluded(dir string, included []string, onExcluded func(string) error) error { files, err := os.ReadDir(dir) if err != nil { return err } -find: for _, file := range files { - for _, inc := range included { - if file.Name() == inc { - continue find - } + if slices.Contains(included, file.Name()) { + continue } - if err := onExcluded(file.Name()); err != nil { + if err := onExcluded(filepath.Join(dir, file.Name())); err != nil { return err } } diff --git a/wine/cmd.go b/wine/cmd.go new file mode 100644 index 00000000..ec77cc9a --- /dev/null +++ b/wine/cmd.go @@ -0,0 +1,84 @@ +package wine + +import ( + "errors" + "io" + "log" + "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, + } +} + +// SetOutput set's the command's standard output and error to +// the given io.Writer. +func (c *Cmd) SetOutput(o io.Writer) { + c.Stdout = o + c.Stderr = o +} + +// 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.SetOutput(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 +} + +func (c *Cmd) Start() error { + c.Env = append(c.Environ(), + "WINEPREFIX="+c.prefixDir, + ) + + log.Printf("Starting command: %s", c.String()) + + return c.Cmd.Start() +} + +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 38f8cc3a..cc6b2b06 100644 --- a/wine/dxvk/dxvk.go +++ b/wine/dxvk/dxvk.go @@ -39,7 +39,7 @@ func Remove(pfx *wine.Prefix) error { log.Println("Removing DLL:", dllPath) - if err := os.Remove(filepath.Join(pfx.Dir, dllPath)); err != nil { + if err := os.Remove(filepath.Join(pfx.Dir(), dllPath)); err != nil { return err } } @@ -83,8 +83,8 @@ func Extract(name string, pfx *wine.Prefix) error { } destDir, ok := map[string]string{ - "x64": filepath.Join(pfx.Dir, "drive_c", "windows", "system32"), - "x32": filepath.Join(pfx.Dir, "drive_c", "windows", "syswow64"), + "x64": filepath.Join(pfx.Dir(), "drive_c", "windows", "system32"), + "x32": filepath.Join(pfx.Dir(), "drive_c", "windows", "syswow64"), }[filepath.Base(filepath.Dir(header.Name))] if !ok { diff --git a/wine/exec.go b/wine/exec.go deleted file mode 100644 index 459e7446..00000000 --- a/wine/exec.go +++ /dev/null @@ -1,33 +0,0 @@ -package wine - -import ( - "log" - "os/exec" -) - -type Cmd struct { - *exec.Cmd -} - -func (p *Prefix) Command(name string, arg ...string) *Cmd { - cmd := exec.Command(name, arg...) - cmd.Stderr = p.Output - cmd.Stdout = p.Output - cmd.Env = append(cmd.Environ(), - "WINEPREFIX="+p.Dir, - ) - - return &Cmd{cmd} -} - -func (c *Cmd) Start() error { - log.Printf("Starting command: %s", c.String()) - - return c.Cmd.Start() -} - -func (c *Cmd) Run() error { - log.Printf("Running command: %s", c.String()) - - return c.Cmd.Run() -} diff --git a/wine/prefix.go b/wine/prefix.go index c4d4e2dc..1e9ad534 100644 --- a/wine/prefix.go +++ b/wine/prefix.go @@ -3,21 +3,27 @@ package wine import ( "io" "log" - "os" ) type Prefix struct { - Dir string + // Output specifies the descendant prefix commmand's + // Stderr and Stdout together. Output io.Writer + + dir string } -func New(dir string) Prefix { +func New(dir string, out io.Writer) Prefix { return Prefix{ - Dir: dir, - Output: os.Stderr, + Output: out, + dir: dir, } } +func (p *Prefix) Dir() string { + return p.dir +} + func (p *Prefix) Wine(exe string, arg ...string) *Cmd { arg = append([]string{exe}, arg...) diff --git a/wine/tricks.go b/wine/tricks.go index 98e45403..7679b7f3 100644 --- a/wine/tricks.go +++ b/wine/tricks.go @@ -2,6 +2,7 @@ package wine import ( "log" + "strconv" ) func (p *Prefix) DisableCrashDialogs() error { @@ -15,3 +16,7 @@ func (p *Prefix) Winetricks() error { return p.Command("winetricks").Run() } + +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 19b0f916..26e09527 100644 --- a/wine/user.go +++ b/wine/user.go @@ -11,5 +11,5 @@ func (p *Prefix) AppDataDir() (string, error) { return "", err } - return filepath.Join(p.Dir, "drive_c", "users", user.Username, "AppData"), nil + return filepath.Join(p.dir, "drive_c", "users", user.Username, "AppData"), nil }