├── cli-help.txt ├── .gitignore ├── cli ├── registry │ ├── retina_off.reg │ └── retina_on.reg ├── exec_task.go ├── gog_games_paths.go ├── prefix_mods.go ├── version.go ├── is_dir_empty.go ├── backup_metadata.go ├── windows_support.go ├── products_details.go ├── known_postinstall.txt ├── postinstall_script.go ├── inventory.go ├── has_free_space.go ├── linux_proton.go ├── uninstall.go ├── remove_downloads.go ├── reveal.go ├── download.go ├── get_product_details.go ├── umu_configs.go ├── connect.go ├── update.go ├── macos_wine.go ├── validate.go ├── prefix_support.go ├── install.go ├── install_info.go ├── linux_support.go ├── list.go ├── setup_wine.go ├── run.go ├── macos_support.go └── prefix.go ├── data ├── server_paths.go ├── current_os.go ├── user_dirs.go ├── theo_path.go ├── properties.go ├── server_url.go ├── proton_paths.go └── paths.go ├── go.mod ├── clo_delegates └── func_map.go ├── main.go ├── cli-commands.txt ├── go.sum └── README.md /cli-help.txt: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 2 | 3 | # User-specific stuff 4 | .idea/**/*.xml 5 | .idea/**/*.iml 6 | 7 | # Binary build 8 | theo -------------------------------------------------------------------------------- /cli/registry/retina_off.reg: -------------------------------------------------------------------------------- 1 | REGEDIT4 2 | 3 | [HKEY_CURRENT_USER\Software\Wine\Mac Driver] 4 | "RetinaMode"=N 5 | 6 | [HKEY_LOCAL_MACHINE\System\CurrentControlSet\Hardware Profiles\Current\Software\Fonts] 7 | "LogPixels"=dword:00000060 -------------------------------------------------------------------------------- /cli/registry/retina_on.reg: -------------------------------------------------------------------------------- 1 | REGEDIT4 2 | 3 | [HKEY_CURRENT_USER\Software\Wine\Mac Driver] 4 | "RetinaMode"=Y 5 | 6 | [HKEY_LOCAL_MACHINE\System\CurrentControlSet\Hardware Profiles\Current\Software\Fonts] 7 | "LogPixels"=dword:000000C0 -------------------------------------------------------------------------------- /cli/exec_task.go: -------------------------------------------------------------------------------- 1 | package cli 2 | 3 | type execTask struct { 4 | name string 5 | exe string 6 | workDir string 7 | args []string 8 | env []string 9 | prefix string 10 | playTask string 11 | defaultLauncher bool 12 | verbose bool 13 | } 14 | -------------------------------------------------------------------------------- /cli/gog_games_paths.go: -------------------------------------------------------------------------------- 1 | package cli 2 | 3 | import "github.com/arelate/southern_light/gog_integration" 4 | 5 | const ( 6 | gogGamesDir = "GOG Games" 7 | gogGameInstallDir = gogGamesDir + "/*" 8 | gogGameLnkGlob = gogGamesDir + "/*/*.lnk" 9 | gogGameInfoGlobTemplate = gogGamesDir + "/*/" + gog_integration.GogGameInfoFilenameTemplate 10 | ) 11 | -------------------------------------------------------------------------------- /data/server_paths.go: -------------------------------------------------------------------------------- 1 | package data 2 | 3 | const ( 4 | ApiAuthUserPath = "/api/auth-user" 5 | ApiAuthSessionPath = "/api/auth-session" 6 | ApiHealthPath = "/api/health" 7 | ApiProductDetailsPath = "/api/product-details" 8 | ApiWineBinariesVersions = "/api/wine-binaries-versions" 9 | HttpFilesPath = "/files" 10 | HttpImagePath = "/image" 11 | HttpWineBinaryFilePath = "/wine-binary-file" 12 | ) 13 | -------------------------------------------------------------------------------- /data/current_os.go: -------------------------------------------------------------------------------- 1 | package data 2 | 3 | import ( 4 | "github.com/arelate/southern_light/vangogh_integration" 5 | "runtime" 6 | ) 7 | 8 | var goOperatingSystems = map[string]vangogh_integration.OperatingSystem{ 9 | "windows": vangogh_integration.Windows, 10 | "darwin": vangogh_integration.MacOS, 11 | "linux": vangogh_integration.Linux, 12 | } 13 | 14 | func CurrentOs() vangogh_integration.OperatingSystem { 15 | if os, ok := goOperatingSystems[runtime.GOOS]; ok { 16 | return os 17 | } else { 18 | panic(os.ErrUnsupported()) 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /cli/prefix_mods.go: -------------------------------------------------------------------------------- 1 | package cli 2 | 3 | import _ "embed" 4 | 5 | const ( 6 | retinaOnFilename = "retina_on.reg" 7 | retinaOffFilename = "retina_off.reg" 8 | ) 9 | 10 | const regeditBin = "regedit" 11 | 12 | const ( 13 | prefixModEnableRetina = "enable-retina" 14 | prefixModDisableRetina = "disable-retina" 15 | ) 16 | 17 | var ( 18 | //go:embed "registry/retina_on.reg" 19 | retinaOnReg []byte 20 | //go:embed "registry/retina_off.reg" 21 | retinaOffReg []byte 22 | ) 23 | 24 | func PrefixMods() []string { 25 | return []string{ 26 | prefixModEnableRetina, 27 | prefixModDisableRetina, 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/arelate/theo 2 | 3 | go 1.25.4 4 | 5 | require ( 6 | github.com/arelate/southern_light v0.3.66 7 | github.com/boggydigital/author v0.1.23 8 | github.com/boggydigital/backups v0.1.6 9 | github.com/boggydigital/busan v0.1.1 10 | github.com/boggydigital/clo v1.0.8 11 | github.com/boggydigital/dolo v0.2.24 12 | github.com/boggydigital/kevlar v0.6.9 13 | github.com/boggydigital/nod v0.1.30 14 | github.com/boggydigital/pathways v0.1.15 15 | github.com/boggydigital/redux v0.1.9 16 | ) 17 | 18 | require ( 19 | github.com/boggydigital/wits v0.2.3 // indirect 20 | golang.org/x/crypto v0.44.0 // indirect 21 | ) 22 | -------------------------------------------------------------------------------- /clo_delegates/func_map.go: -------------------------------------------------------------------------------- 1 | package clo_delegates 2 | 3 | import ( 4 | "github.com/arelate/southern_light/gog_integration" 5 | "github.com/arelate/southern_light/vangogh_integration" 6 | "github.com/arelate/southern_light/wine_integration" 7 | "github.com/arelate/theo/cli" 8 | ) 9 | 10 | var FuncMap = map[string]func() []string{ 11 | "prefix-mods": cli.PrefixMods, 12 | "wine-programs": wine_integration.WinePrograms, 13 | "wine-binaries-codes": wine_integration.WineBinariesCodes, 14 | "operating-systems": vangogh_integration.OperatingSystemsCloValues, 15 | "language-codes": gog_integration.LanguageCodesCloValues, 16 | "download-types": vangogh_integration.DownloadTypesCloValues, 17 | } 18 | -------------------------------------------------------------------------------- /data/user_dirs.go: -------------------------------------------------------------------------------- 1 | package data 2 | 3 | import ( 4 | "github.com/arelate/southern_light/vangogh_integration" 5 | "os" 6 | "path/filepath" 7 | ) 8 | 9 | func UserDataHomeDir() (string, error) { 10 | 11 | currentOs := CurrentOs() 12 | 13 | switch currentOs { 14 | case vangogh_integration.Linux: 15 | uhd, err := os.UserHomeDir() 16 | if err != nil { 17 | return "", err 18 | } 19 | return filepath.Join(uhd, ".local", "share"), nil 20 | case vangogh_integration.Windows: 21 | // TODO: verify that Windows user data home is also os.UserConfigDir 22 | fallthrough 23 | case vangogh_integration.MacOS: 24 | return os.UserConfigDir() 25 | default: 26 | panic(currentOs.ErrUnsupported()) 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /cli/version.go: -------------------------------------------------------------------------------- 1 | package cli 2 | 3 | import ( 4 | "github.com/boggydigital/nod" 5 | "net/url" 6 | "runtime/debug" 7 | ) 8 | 9 | var ( 10 | GitTag string 11 | ) 12 | 13 | func VersionHandler(_ *url.URL) error { 14 | va := nod.Begin("checking theo version...") 15 | defer va.Done() 16 | 17 | if GitTag == "" { 18 | summary := make(map[string][]string) 19 | if bi, ok := debug.ReadBuildInfo(); ok { 20 | values := []string{bi.Main.Version, bi.Main.Path, bi.GoVersion} 21 | for _, value := range values { 22 | if value != "" { 23 | summary["version info:"] = append(summary["version info:"], value) 24 | } 25 | } 26 | va.EndWithSummary("", summary) 27 | } else { 28 | va.EndWithResult("unknown version") 29 | } 30 | } else { 31 | va.EndWithResult(GitTag) 32 | } 33 | return nil 34 | } 35 | -------------------------------------------------------------------------------- /cli/is_dir_empty.go: -------------------------------------------------------------------------------- 1 | package cli 2 | 3 | import ( 4 | "github.com/arelate/southern_light/vangogh_integration" 5 | "github.com/arelate/theo/data" 6 | "os" 7 | ) 8 | 9 | func osIsDirEmpty(path string) (bool, error) { 10 | if entries, err := os.ReadDir(path); err == nil && len(entries) == 0 { 11 | return true, nil 12 | } else if err == nil { 13 | currentOs := data.CurrentOs() 14 | switch currentOs { 15 | case vangogh_integration.MacOS: 16 | return macOsIsDirEmptyOrDsStoreOnly(entries), nil 17 | case vangogh_integration.Linux: 18 | // currently not tracking any special cases for Linux 19 | return false, nil 20 | case vangogh_integration.Windows: 21 | // currently not tracking any special cases for Windows 22 | return false, nil 23 | default: 24 | return false, currentOs.ErrUnsupported() 25 | } 26 | } else { 27 | return false, err 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /cli/backup_metadata.go: -------------------------------------------------------------------------------- 1 | package cli 2 | 3 | import ( 4 | "github.com/arelate/theo/data" 5 | "github.com/boggydigital/backups" 6 | "github.com/boggydigital/nod" 7 | "github.com/boggydigital/pathways" 8 | "net/url" 9 | ) 10 | 11 | func BackupMetadataHandler(_ *url.URL) error { 12 | return BackupMetadata() 13 | } 14 | 15 | func BackupMetadata() error { 16 | 17 | ba := nod.NewProgress("backing up local metadata...") 18 | defer ba.Done() 19 | 20 | backupsDir, err := pathways.GetAbsDir(data.Backups) 21 | if err != nil { 22 | return err 23 | } 24 | 25 | metadataDir, err := pathways.GetAbsDir(data.Metadata) 26 | if err != nil { 27 | return err 28 | } 29 | 30 | if err = backups.Compress(metadataDir, backupsDir); err != nil { 31 | return err 32 | } 33 | 34 | ca := nod.NewProgress("cleaning up old backups...") 35 | defer ca.Done() 36 | 37 | if err = backups.Cleanup(backupsDir, true, ca); err != nil { 38 | return err 39 | } 40 | 41 | return nil 42 | } 43 | -------------------------------------------------------------------------------- /data/theo_path.go: -------------------------------------------------------------------------------- 1 | package data 2 | 3 | import ( 4 | "errors" 5 | "github.com/arelate/southern_light/vangogh_integration" 6 | "os" 7 | "os/exec" 8 | ) 9 | 10 | func TheoExecutable() (string, error) { 11 | 12 | binFilename := "theo" 13 | 14 | currentOs := CurrentOs() 15 | 16 | switch currentOs { 17 | case vangogh_integration.Windows: 18 | binFilename += ".exe" 19 | case vangogh_integration.Linux: 20 | fallthrough 21 | case vangogh_integration.MacOS: 22 | // do nothing 23 | default: 24 | return "", currentOs.ErrUnsupported() 25 | } 26 | 27 | // check PATH first and make sure the location specified there exists 28 | if binPath, err := exec.LookPath(binFilename); err == nil && binPath != "" { 29 | if _, err = os.Stat(binPath); err == nil { 30 | return binPath, nil 31 | } 32 | } 33 | 34 | // get the current process path 35 | if binPath, err := os.Executable(); err == nil { 36 | return binPath, nil 37 | } 38 | 39 | return "", errors.New("theo binary not found, please add it to a PATH location") 40 | } 41 | -------------------------------------------------------------------------------- /cli/windows_support.go: -------------------------------------------------------------------------------- 1 | package cli 2 | 3 | import ( 4 | "errors" 5 | "github.com/arelate/southern_light/gog_integration" 6 | "github.com/arelate/southern_light/vangogh_integration" 7 | "github.com/boggydigital/redux" 8 | ) 9 | 10 | const exeExt = ".exe" 11 | 12 | func windowsInstallProduct(id string, 13 | dls vangogh_integration.ProductDownloadLinks, 14 | rdx redux.Writeable, 15 | force bool) error { 16 | return errors.New("support for Windows installation is not implemented") 17 | } 18 | 19 | func windowsReveal(path string) error { 20 | return errors.New("support for Windows reveal is not implemented") 21 | } 22 | 23 | func windowsExecute(path string, et *execTask) error { 24 | return errors.New("support for Windows execution is not implemented") 25 | } 26 | 27 | func windowsUninstallProduct(id, langCode string, rdx redux.Readable) error { 28 | return errors.New("support for Windows uninstallation is not implemented") 29 | } 30 | 31 | func windowsFreeSpace(path string) (int64, error) { 32 | return -1, errors.New("support for Windows free space determination is not implemented") 33 | } 34 | 35 | func windowsFindGogGameInfo(id, langCode string, rdx redux.Readable) (string, error) { 36 | return "", errors.New("support for Windows goggame-{id}.info is not implemented") 37 | } 38 | 39 | func windowsFindGogGamesLnk(id, langCode string, rdx redux.Readable) (string, error) { 40 | return "", errors.New("support for Windows .lnk is not implemented") 41 | } 42 | 43 | func windowsExecTaskGogGameInfo(absGogGameInfoPath string, gogGameInfo *gog_integration.GogGameInfo, et *execTask) (*execTask, error) { 44 | return et, nil 45 | } 46 | 47 | func windowsExecTaskLnk(absLnkPath string, et *execTask) (*execTask, error) { 48 | return et, nil 49 | } 50 | -------------------------------------------------------------------------------- /data/properties.go: -------------------------------------------------------------------------------- 1 | package data 2 | 3 | import "github.com/arelate/southern_light/vangogh_integration" 4 | 5 | const ( 6 | ServerConnectionProperties = "server-connection" 7 | 8 | InstallInfoProperty = "install-info" 9 | InstallDateProperty = "install-date" 10 | LastRunDateProperty = "last-run-date" 11 | PlaytimeMinutesProperty = "playtime-minutes" 12 | TotalPlaytimeMinutesProperty = "total-playtime-minutes" 13 | 14 | PrefixEnvProperty = "prefix-env" 15 | PrefixExeProperty = "prefix-exe" 16 | PrefixArgProperty = "prefix-arg" 17 | 18 | WineBinariesVersionsProperty = "wine-binaries-versions" 19 | ) 20 | 21 | const ( 22 | ServerProtocolProperty = "server-protocol" 23 | ServerAddressProperty = "server-address" 24 | ServerPortProperty = "server-port" 25 | ServerUsernameProperty = "server-username" 26 | ServerSessionToken = "server-session-token" 27 | ServerSessionExpires = "server-session-expires" 28 | ) 29 | 30 | func AllProperties() []string { 31 | return []string{ 32 | vangogh_integration.TitleProperty, 33 | vangogh_integration.SlugProperty, 34 | vangogh_integration.SteamAppIdProperty, 35 | vangogh_integration.OperatingSystemsProperty, 36 | vangogh_integration.DevelopersProperty, 37 | vangogh_integration.PublishersProperty, 38 | vangogh_integration.VerticalImageProperty, 39 | vangogh_integration.ImageProperty, 40 | vangogh_integration.HeroProperty, 41 | vangogh_integration.LogoProperty, 42 | vangogh_integration.IconProperty, 43 | vangogh_integration.IconSquareProperty, 44 | vangogh_integration.BackgroundProperty, 45 | ServerConnectionProperties, 46 | InstallInfoProperty, 47 | InstallDateProperty, 48 | LastRunDateProperty, 49 | PlaytimeMinutesProperty, 50 | TotalPlaytimeMinutesProperty, 51 | PrefixEnvProperty, 52 | PrefixExeProperty, 53 | PrefixArgProperty, 54 | WineBinariesVersionsProperty, 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /cli/products_details.go: -------------------------------------------------------------------------------- 1 | package cli 2 | 3 | //type ProductsDetails []*vangogh_integration.ProductDetails 4 | // 5 | //func (psd ProductsDetails) Filter(productTypes ...string) ProductsDetails { 6 | // filtered := make(ProductsDetails, 0, len(psd)) 7 | // 8 | // for _, pd := range psd { 9 | // if !slices.Contains(productTypes, pd.ProductType) { 10 | // continue 11 | // } 12 | // filtered = append(filtered, pd) 13 | // } 14 | // 15 | // return filtered 16 | //} 17 | // 18 | //func (psd ProductsDetails) Ids() []string { 19 | // ids := make([]string, 0, len(psd)) 20 | // 21 | // for _, pd := range psd { 22 | // ids = append(ids, pd.Id) 23 | // } 24 | // 25 | // return ids 26 | //} 27 | // 28 | //func (psd ProductsDetails) GameAndIncludedGamesIds() []string { 29 | // ids := make(map[string]any) 30 | // 31 | // for _, pd := range psd { 32 | // switch pd.ProductType { 33 | // case vangogh_integration.PackProductType: 34 | // for _, includedId := range pd.IncludesGames { 35 | // ids[includedId] = nil 36 | // } 37 | // case vangogh_integration.GameProductType: 38 | // ids[pd.Id] = nil 39 | // case vangogh_integration.DlcProductType: 40 | // // do nothing 41 | // } 42 | // } 43 | // 44 | // return slices.Collect(maps.Keys(ids)) 45 | //} 46 | 47 | //func GetProductsDetails(rdx redux.Writeable, force bool, ids ...string) (ProductsDetails, error) { 48 | // 49 | // gpda := nod.NewProgress("getting multiple products details...") 50 | // gpda.Done() 51 | // 52 | // gpda.TotalInt(len(ids)) 53 | // 54 | // psd := make(ProductsDetails, 0, len(ids)) 55 | // 56 | // for _, id := range ids { 57 | // 58 | // pd, err := getProductDetails(id, rdx, force) 59 | // if err != nil { 60 | // return nil, err 61 | // } 62 | // 63 | // if pd.ProductType == "" { 64 | // return nil, errors.New("product details are missing product type") 65 | // } 66 | // 67 | // psd = append(psd, pd) 68 | // gpda.Increment() 69 | // } 70 | // 71 | // return psd, nil 72 | //} 73 | -------------------------------------------------------------------------------- /data/server_url.go: -------------------------------------------------------------------------------- 1 | package data 2 | 3 | import ( 4 | "errors" 5 | "io" 6 | "net/http" 7 | "net/url" 8 | "strings" 9 | 10 | "github.com/boggydigital/redux" 11 | ) 12 | 13 | func ServerUrl(path string, data url.Values, rdx redux.Readable) (*url.URL, error) { 14 | protocol := "https" 15 | address := "" 16 | 17 | if err := rdx.MustHave(ServerConnectionProperties); err != nil { 18 | return nil, err 19 | } 20 | 21 | if protoVal, ok := rdx.GetLastVal(ServerConnectionProperties, ServerProtocolProperty); ok && protoVal != "" { 22 | protocol = protoVal 23 | } 24 | 25 | if addrVal, ok := rdx.GetLastVal(ServerConnectionProperties, ServerAddressProperty); ok && addrVal != "" { 26 | address = addrVal 27 | } else { 28 | return nil, errors.New("address is empty, check server connection setup") 29 | } 30 | 31 | if portVal, ok := rdx.GetLastVal(ServerConnectionProperties, ServerPortProperty); ok && portVal != "" { 32 | address += ":" + portVal 33 | } 34 | 35 | u := &url.URL{ 36 | Scheme: protocol, 37 | Host: address, 38 | Path: path, 39 | } 40 | 41 | if len(data) > 0 { 42 | u.RawQuery = data.Encode() 43 | } 44 | 45 | return u, nil 46 | } 47 | 48 | func ServerRequest(method, path string, data url.Values, rdx redux.Readable) (*http.Request, error) { 49 | 50 | u, err := ServerUrl(path, data, rdx) 51 | if err != nil { 52 | return nil, err 53 | } 54 | 55 | var sessionToken string 56 | if st, ok := rdx.GetLastVal(ServerConnectionProperties, ServerSessionToken); ok && st != "" { 57 | sessionToken = st 58 | } 59 | 60 | var bodyReader io.Reader 61 | 62 | if method == http.MethodPost && len(data) > 0 { 63 | bodyReader = strings.NewReader(data.Encode()) 64 | } 65 | 66 | req, err := http.NewRequest(method, u.String(), bodyReader) 67 | if err != nil { 68 | return nil, err 69 | } 70 | 71 | if sessionToken != "" { 72 | req.Header.Set("Authorization", "Bearer "+sessionToken) 73 | } 74 | 75 | req.Header.Set("Content-Type", "application/x-www-form-urlencoded") 76 | 77 | return req, nil 78 | } 79 | -------------------------------------------------------------------------------- /cli/known_postinstall.txt: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # www.gog.com 3 | ExecuteSupportScripts() { 4 | ls -ctr support/*.sh | while read script 5 | do 6 | bash $script --install-path="${1}" 7 | rm $script 8 | done 9 | } 10 | # Current version of location selection script 11 | gog_bundle_location=`osascript -e $'tell application "SystemUIServer" to try \n return POSIX path of (choose folder) \n on error errorMsg \n return errorMsg \n end try'` 12 | if [[ $gog_bundle_location == *"User canceled."* ]] 13 | then 14 | echo "Aborted" 15 | exit 1 16 | fi 17 | if [[ $gog_bundle_location == *"SystemUIServer"* ]] 18 | then 19 | gog_bundle_location="/Applications/" 20 | fi 21 | # Older version of location selection script 22 | gog_bundle_location=`osascript -e 'tell application "SystemUIServer" to return POSIX path of (choose folder)' 2>&1` 23 | if [[ $gog_bundle_location == *"User canceled."* ]] 24 | then 25 | echo "Aborted" 26 | exit 1 27 | fi 28 | if [[ $gog_bundle_location == *"AppleEvent timed out"* ]] 29 | then 30 | gog_bundle_location="/Applications/" 31 | fi 32 | # End Current/Older version of location selection script 33 | gog_full_path="${gog_bundle_location}${gog_bundle_name}" 34 | if [ "$gog_installer_type" != "dlc" ]; then 35 | if [ -d "${gog_full_path}" ]; then 36 | ret=`osascript -e "set question to display dialog \"${gog_full_path} already exists and will be removed!\" buttons {\"Yes\", \"No\"} default button 2"` 37 | if [[ $ret == *"Yes"* ]]; then 38 | rm -rf "${gog_full_path}" 39 | else 40 | echo "Aborted." 41 | exit 1 42 | fi 43 | fi 44 | fi 45 | if [ "$gog_installer_type" == "dlc" ]; then 46 | mkdir -p "${gog_full_path}" 47 | cp -rf payload/* "${gog_full_path}" 48 | else 49 | mkdir -p "${gog_full_path}" 50 | mv payload/* "${gog_full_path}" 51 | fi 52 | pkgpath=$(dirname "$PACKAGE_PATH") 53 | #GOG_Large_File_Location 54 | #GAME_SPECIFIC_CODE 55 | ExecuteSupportScripts "${gog_full_path}" 56 | xattr -d com.apple.quarantine "${gog_full_path}" 57 | xattr -r -d com.apple.quarantine "${gog_full_path}" 58 | chown "$USER":staff "${gog_full_path}" 59 | chown -R "$USER":staff "${gog_full_path}" 60 | chown -R :staff "${gog_full_path}" 61 | exit 0 -------------------------------------------------------------------------------- /data/proton_paths.go: -------------------------------------------------------------------------------- 1 | package data 2 | 3 | import ( 4 | _ "embed" 5 | "errors" 6 | "github.com/arelate/southern_light/wine_integration" 7 | "github.com/boggydigital/busan" 8 | "github.com/boggydigital/pathways" 9 | "github.com/boggydigital/redux" 10 | "os" 11 | "path/filepath" 12 | ) 13 | 14 | const relUmuRunPath = "umu/umu-run" 15 | 16 | func UmuRunLatestReleasePath(rdx redux.Readable) (string, error) { 17 | 18 | runtime := wine_integration.UmuLauncher 19 | 20 | if err := rdx.MustHave(WineBinariesVersionsProperty); err != nil { 21 | return "", err 22 | } 23 | 24 | var latestUmuLauncherVersion string 25 | if lulv, ok := rdx.GetLastVal(WineBinariesVersionsProperty, runtime); ok { 26 | latestUmuLauncherVersion = lulv 27 | } 28 | 29 | if latestUmuLauncherVersion == "" { 30 | return "", errors.New("umu-launcher version not found, please run setup-wine") 31 | } 32 | 33 | wineBinaries, err := pathways.GetAbsRelDir(WineBinaries) 34 | if err != nil { 35 | return "", err 36 | } 37 | 38 | absUmuRunBinPath := filepath.Join(wineBinaries, busan.Sanitize(runtime), latestUmuLauncherVersion, relUmuRunPath) 39 | if _, err = os.Stat(absUmuRunBinPath); err == nil { 40 | return absUmuRunBinPath, nil 41 | } 42 | 43 | return "", os.ErrNotExist 44 | } 45 | 46 | func ProtonGeLatestReleasePath(rdx redux.Readable) (string, error) { 47 | 48 | runtime := wine_integration.ProtonGe 49 | 50 | if err := rdx.MustHave(WineBinariesVersionsProperty); err != nil { 51 | return "", err 52 | } 53 | 54 | var latestProtonGeVersion string 55 | if lpgv, ok := rdx.GetLastVal(WineBinariesVersionsProperty, runtime); ok { 56 | latestProtonGeVersion = lpgv 57 | } 58 | 59 | if latestProtonGeVersion == "" { 60 | return "", errors.New("proton-ge version not found, please run setup-wine") 61 | } 62 | 63 | wineBinaries, err := pathways.GetAbsRelDir(WineBinaries) 64 | if err != nil { 65 | return "", err 66 | } 67 | 68 | absProtonGePath := filepath.Join(wineBinaries, busan.Sanitize(runtime), latestProtonGeVersion, latestProtonGeVersion) 69 | if _, err = os.Stat(absProtonGePath); err == nil { 70 | return absProtonGePath, nil 71 | } 72 | 73 | return "", os.ErrNotExist 74 | } 75 | -------------------------------------------------------------------------------- /cli/postinstall_script.go: -------------------------------------------------------------------------------- 1 | package cli 2 | 3 | import ( 4 | "bufio" 5 | _ "embed" 6 | "github.com/arelate/southern_light/vangogh_integration" 7 | "os" 8 | "path/filepath" 9 | "slices" 10 | "strings" 11 | ) 12 | 13 | const ( 14 | bundleNamePfx = "gog_bundle_name=" 15 | installerTypePfx = "gog_installer_type=" 16 | 17 | relPostInstallScriptPath = "package.pkg/Scripts/postinstall" 18 | ) 19 | 20 | //go:embed "known_postinstall.txt" 21 | var knownPostInstall string 22 | 23 | type PostInstallScript struct { 24 | path string 25 | bundleName string 26 | installerType string 27 | customCommands []string 28 | } 29 | 30 | func (pis *PostInstallScript) BundleName() string { 31 | return pis.bundleName 32 | } 33 | 34 | func (pis *PostInstallScript) InstallerType() string { 35 | return pis.installerType 36 | } 37 | 38 | func (pis *PostInstallScript) CustomCommands() []string { 39 | return pis.customCommands 40 | } 41 | 42 | func ParsePostInstallScript(path string) (*PostInstallScript, error) { 43 | 44 | if _, err := os.Stat(path); err != nil { 45 | return nil, err 46 | } 47 | 48 | scriptFile, err := os.Open(path) 49 | if err != nil { 50 | return nil, err 51 | } 52 | defer scriptFile.Close() 53 | 54 | pis := &PostInstallScript{} 55 | knownPostInstallLines := strings.Split(knownPostInstall, "\n") 56 | 57 | scriptScanner := bufio.NewScanner(scriptFile) 58 | for scriptScanner.Scan() { 59 | 60 | if err := scriptScanner.Err(); err != nil { 61 | return nil, err 62 | } 63 | 64 | line := scriptScanner.Text() 65 | if line == "" { 66 | continue 67 | } 68 | 69 | if strings.HasPrefix(line, bundleNamePfx) { 70 | pis.bundleName = strings.Trim(strings.TrimPrefix(line, bundleNamePfx), "\"") 71 | continue 72 | } 73 | if strings.HasPrefix(line, installerTypePfx) { 74 | pis.installerType = strings.Trim(strings.TrimPrefix(line, installerTypePfx), "\"") 75 | continue 76 | } 77 | if !slices.Contains(knownPostInstallLines, line) { 78 | pis.customCommands = append(pis.customCommands, line) 79 | } 80 | } 81 | 82 | return pis, nil 83 | } 84 | 85 | func PostInstallScriptPath(productExtractsDir string, link *vangogh_integration.ProductDownloadLink) string { 86 | localFilenameExtractsDir := filepath.Join(productExtractsDir, link.LocalFilename) 87 | return filepath.Join(localFilenameExtractsDir, relPostInstallScriptPath) 88 | } 89 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bytes" 5 | _ "embed" 6 | "log" 7 | "net/url" 8 | "os" 9 | 10 | "github.com/arelate/theo/cli" 11 | "github.com/arelate/theo/clo_delegates" 12 | "github.com/arelate/theo/data" 13 | "github.com/boggydigital/clo" 14 | "github.com/boggydigital/nod" 15 | "github.com/boggydigital/pathways" 16 | ) 17 | 18 | var ( 19 | //go:embed "cli-commands.txt" 20 | cliCommands []byte 21 | //go:embed "cli-help.txt" 22 | cliHelp []byte 23 | ) 24 | 25 | const debugParam = "debug" 26 | 27 | func main() { 28 | 29 | nod.EnableStdOutPresenter() 30 | 31 | tsa := nod.Begin("theo is complementing vangogh experience") 32 | defer tsa.Done() 33 | 34 | theoRootDir, err := data.InitRootDir() 35 | if err != nil { 36 | log.Fatalln(err) 37 | } 38 | 39 | if err = pathways.Setup("", 40 | theoRootDir, 41 | data.RelToAbsDirs, 42 | data.AllAbsDirs...); err != nil { 43 | log.Fatalln(err) 44 | } 45 | 46 | defs, err := clo.Load( 47 | bytes.NewBuffer(cliCommands), 48 | bytes.NewBuffer(cliHelp), 49 | clo_delegates.FuncMap) 50 | if err != nil { 51 | log.Fatalln(err) 52 | } 53 | 54 | clo.HandleFuncs(map[string]clo.Handler{ 55 | "backup-metadata": cli.BackupMetadataHandler, 56 | "connect": cli.ConnectHandler, 57 | "download": cli.DownloadHandler, 58 | "install": cli.InstallHandler, 59 | "list": cli.ListHandler, 60 | "prefix": cli.PrefixHandler, 61 | "remove-downloads": cli.RemoveDownloadsHandler, 62 | "reveal": cli.RevealHandler, 63 | "run": cli.RunHandler, 64 | "setup-wine": cli.SetupWineHandler, 65 | "steam-shortcut": cli.SteamShortcutHandler, 66 | "uninstall": cli.UninstallHandler, 67 | "update": cli.UpdateHandler, 68 | "validate": cli.ValidateHandler, 69 | "version": cli.VersionHandler, 70 | }) 71 | 72 | if err = defs.AssertCommandsHaveHandlers(); err != nil { 73 | log.Fatalln(err) 74 | } 75 | 76 | var u *url.URL 77 | if u, err = defs.Parse(os.Args[1:]); err != nil { 78 | log.Fatalln(err) 79 | } 80 | 81 | if q := u.Query(); q.Has(debugParam) { 82 | absLogsDir, err := pathways.GetAbsDir(data.Logs) 83 | if err != nil { 84 | log.Fatalln(err) 85 | } 86 | logger, err := nod.EnableFileLogger(u.Path, absLogsDir) 87 | if err != nil { 88 | log.Fatalln(err) 89 | } 90 | defer logger.Close() 91 | } 92 | 93 | if err = defs.Serve(u); err != nil { 94 | tsa.Error(err) 95 | log.Fatalln(err) 96 | } 97 | } 98 | -------------------------------------------------------------------------------- /cli/inventory.go: -------------------------------------------------------------------------------- 1 | package cli 2 | 3 | import ( 4 | "bufio" 5 | "github.com/arelate/southern_light/vangogh_integration" 6 | "github.com/arelate/theo/data" 7 | "github.com/boggydigital/redux" 8 | "io" 9 | "os" 10 | "path/filepath" 11 | "slices" 12 | ) 13 | 14 | func createInventory(absRootDir string, id, langCode string, operatingSystem vangogh_integration.OperatingSystem, rdx redux.Readable, utcTime int64) error { 15 | relFiles, err := data.GetRelFilesModifiedAfter(absRootDir, utcTime) 16 | if err != nil { 17 | return err 18 | } 19 | 20 | return appendInventory(id, langCode, operatingSystem, rdx, relFiles...) 21 | } 22 | 23 | func readInventory(id, langCode string, operatingSystem vangogh_integration.OperatingSystem, rdx redux.Readable) ([]string, error) { 24 | absInventoryFilename, err := data.GetAbsInventoryFilename(id, langCode, operatingSystem, rdx) 25 | if err != nil { 26 | return nil, err 27 | } 28 | 29 | if _, err = os.Stat(absInventoryFilename); os.IsNotExist(err) { 30 | return nil, nil 31 | } 32 | 33 | inventoryFile, err := os.Open(absInventoryFilename) 34 | if err != nil { 35 | return nil, err 36 | } 37 | 38 | relFiles := make([]string, 0) 39 | inventoryScanner := bufio.NewScanner(inventoryFile) 40 | for inventoryScanner.Scan() { 41 | relFiles = append(relFiles, inventoryScanner.Text()) 42 | } 43 | 44 | if err = inventoryScanner.Err(); err != nil { 45 | return nil, err 46 | } 47 | 48 | return relFiles, nil 49 | } 50 | 51 | func appendInventory(id, langCode string, operatingSystem vangogh_integration.OperatingSystem, rdx redux.Readable, newRelFiles ...string) error { 52 | 53 | absInventoryFilename, err := data.GetAbsInventoryFilename(id, langCode, operatingSystem, rdx) 54 | if err != nil { 55 | return err 56 | } 57 | 58 | relFiles, err := readInventory(id, langCode, operatingSystem, rdx) 59 | if err != nil { 60 | return err 61 | } 62 | 63 | for _, nrf := range newRelFiles { 64 | if slices.Contains(relFiles, nrf) { 65 | continue 66 | } 67 | relFiles = append(relFiles, nrf) 68 | } 69 | 70 | absInventoryDir, _ := filepath.Split(absInventoryFilename) 71 | if _, err = os.Stat(absInventoryDir); os.IsNotExist(err) { 72 | if err = os.MkdirAll(absInventoryDir, 0755); err != nil { 73 | return err 74 | } 75 | } else if err != nil { 76 | return err 77 | } 78 | 79 | inventoryFile, err := os.Create(absInventoryFilename) 80 | if err != nil { 81 | return err 82 | } 83 | defer inventoryFile.Close() 84 | 85 | for _, relFile := range relFiles { 86 | if _, err = io.WriteString(inventoryFile, relFile+"\n"); err != nil { 87 | return err 88 | } 89 | } 90 | 91 | return nil 92 | } 93 | -------------------------------------------------------------------------------- /cli/has_free_space.go: -------------------------------------------------------------------------------- 1 | package cli 2 | 3 | import ( 4 | "fmt" 5 | "slices" 6 | 7 | "github.com/arelate/southern_light/vangogh_integration" 8 | "github.com/arelate/theo/data" 9 | "github.com/boggydigital/nod" 10 | ) 11 | 12 | const preserveFreeSpacePercent = 1 13 | 14 | func hasFreeSpaceForProduct( 15 | productDetails *vangogh_integration.ProductDetails, 16 | targetPath string, 17 | ii *InstallInfo, 18 | manualUrlFilter []string) error { 19 | 20 | var totalEstimatedBytes int64 21 | 22 | dls := productDetails.DownloadLinks. 23 | FilterOperatingSystems(ii.OperatingSystem). 24 | FilterLanguageCodes(ii.LangCode). 25 | FilterDownloadTypes(ii.DownloadTypes...) 26 | 27 | for _, dl := range dls { 28 | if len(manualUrlFilter) > 0 && !slices.Contains(manualUrlFilter, dl.ManualUrl) { 29 | continue 30 | } 31 | totalEstimatedBytes += dl.EstimatedBytes 32 | } 33 | 34 | if ok, err := hasFreeSpaceForBytes(targetPath, totalEstimatedBytes); err != nil { 35 | return err 36 | } else if !ok && !ii.force { 37 | return fmt.Errorf("not enough space for %s at %s", productDetails.Id, targetPath) 38 | } else { 39 | return nil 40 | } 41 | } 42 | 43 | func hasFreeSpaceForBytes(path string, bytes int64) (bool, error) { 44 | 45 | var relPath string 46 | if userHomeDataRel, err := data.RelToUserDataHome(path); err == nil { 47 | relPath = userHomeDataRel 48 | } else { 49 | return false, err 50 | } 51 | 52 | hfsa := nod.Begin("checking free space at %s...", relPath) 53 | defer hfsa.Done() 54 | 55 | currentOs := data.CurrentOs() 56 | 57 | var availableBytes int64 58 | var err error 59 | 60 | switch currentOs { 61 | case vangogh_integration.Windows: 62 | availableBytes, err = windowsFreeSpace(path) 63 | case vangogh_integration.MacOS: 64 | fallthrough 65 | case vangogh_integration.Linux: 66 | availableBytes, err = nixFreeSpace(path) 67 | default: 68 | return false, currentOs.ErrUnsupported() 69 | } 70 | 71 | if err != nil { 72 | return false, err 73 | } 74 | 75 | // we don't want to consume all available space, so reserving 76 | // specified percentage of available capacity before the checks 77 | availableBytes = (100 - preserveFreeSpacePercent) * availableBytes / 100 78 | 79 | switch availableBytes > bytes { 80 | case true: 81 | hfsa.EndWithResult("enough for %s (%s free)", 82 | vangogh_integration.FormatBytes(bytes), 83 | vangogh_integration.FormatBytes(availableBytes)) 84 | return true, nil 85 | case false: 86 | hfsa.EndWithResult("not enough for %s (%s free)", 87 | vangogh_integration.FormatBytes(bytes), 88 | vangogh_integration.FormatBytes(availableBytes)) 89 | return false, nil 90 | } 91 | 92 | return availableBytes > bytes, nil 93 | } 94 | -------------------------------------------------------------------------------- /cli-commands.txt: -------------------------------------------------------------------------------- 1 | # Decorators legend: 2 | # $ - supports environmental variable value 3 | # ^ - default property, value 4 | # & - supports multiple values 5 | # * - required value 6 | # {} - placeholder values 7 | # {^} - placeholder values, first value is default 8 | 9 | backup-metadata 10 | 11 | connect 12 | protocol 13 | address 14 | port 15 | username 16 | password 17 | reset 18 | 19 | download 20 | id^* 21 | os&={operating-systems^} 22 | lang-code&={language-codes^} 23 | download-type&={download-types^} 24 | manual-url-filter& 25 | force 26 | 27 | install 28 | id^* 29 | os={operating-systems^} 30 | lang-code={language-codes^} 31 | download-type&={download-types^} 32 | keep-downloads 33 | no-steam-shortcut 34 | steam-assets 35 | env& 36 | reveal 37 | verbose 38 | debug 39 | force 40 | 41 | list 42 | installed 43 | playtasks 44 | steam-shortcuts 45 | os={operating-systems^} 46 | lang-code={language-codes^} 47 | id 48 | all-key-values 49 | 50 | prefix 51 | id^* 52 | lang-code={language-codes^} 53 | env& 54 | arg& 55 | exe 56 | program={wine-programs} 57 | mod={prefix-mods} 58 | install-wine-binary={wine-binaries-codes} 59 | default-env 60 | delete-env 61 | delete-exe 62 | delete-arg 63 | info 64 | archive 65 | remove 66 | verbose 67 | debug 68 | force 69 | 70 | remove-downloads 71 | id^* 72 | os&={operating-systems^} 73 | lang-code&={language-codes^} 74 | download-type&={download-types^} 75 | 76 | reveal 77 | id^ 78 | os={operating-systems^} 79 | lang-code={language-codes^} 80 | installed 81 | downloads 82 | backups 83 | 84 | run 85 | id^ 86 | os={operating-systems^} 87 | lang-code={language-codes^} 88 | env& 89 | arg& 90 | playtask 91 | default-launcher 92 | work-dir 93 | verbose 94 | debug 95 | force 96 | 97 | setup-wine 98 | force 99 | 100 | steam-shortcut 101 | add& 102 | remove& 103 | update-all-installed 104 | os={operating-systems^} 105 | lang-code={language-codes^} 106 | steam-assets 107 | pinned-position=BottomLeft,BottomCenter,CenterCenter,UpperCenter 108 | width-pct 109 | height-pct 110 | force 111 | 112 | uninstall 113 | id^* 114 | os={operating-systems^} 115 | lang-code={language-codes^} 116 | verbose 117 | debug 118 | force 119 | 120 | update 121 | id^ 122 | all 123 | verbose 124 | debug 125 | force 126 | 127 | validate 128 | id^* 129 | os&={operating-systems^} 130 | lang-code&={language-codes^} 131 | manual-url-filter& 132 | 133 | version -------------------------------------------------------------------------------- /cli/linux_proton.go: -------------------------------------------------------------------------------- 1 | package cli 2 | 3 | import ( 4 | "github.com/arelate/southern_light/vangogh_integration" 5 | "github.com/arelate/theo/data" 6 | "github.com/boggydigital/nod" 7 | "github.com/boggydigital/redux" 8 | "os" 9 | "os/exec" 10 | "path/filepath" 11 | "strings" 12 | ) 13 | 14 | func linuxProtonRun(id, langCode string, rdx redux.Readable, et *execTask, force bool) error { 15 | 16 | _, exeFilename := filepath.Split(et.exe) 17 | 18 | lwra := nod.Begin(" running %s with WINE, please wait...", exeFilename) 19 | defer lwra.Done() 20 | 21 | if err := rdx.MustHave( 22 | vangogh_integration.SlugProperty, 23 | vangogh_integration.SteamAppIdProperty); err != nil { 24 | return err 25 | } 26 | 27 | if et.verbose && len(et.env) > 0 { 28 | pea := nod.Begin(" env:") 29 | pea.EndWithResult(strings.Join(et.env, " ")) 30 | } 31 | 32 | absUmuRunPath, err := data.UmuRunLatestReleasePath(rdx) 33 | if err != nil { 34 | return err 35 | } 36 | 37 | absPrefixDir, err := data.GetAbsPrefixDir(id, langCode, rdx) 38 | if err != nil { 39 | return err 40 | } 41 | 42 | absProtonPath, err := data.ProtonGeLatestReleasePath(rdx) 43 | if err != nil { 44 | return err 45 | } 46 | 47 | umuCfg := &UmuConfig{ 48 | GogId: id, 49 | Prefix: absPrefixDir, 50 | Proton: absProtonPath, 51 | ExePath: et.exe, 52 | Args: et.args, 53 | } 54 | 55 | absUmuConfigPath, err := createUmuConfig(umuCfg, rdx, force) 56 | if err != nil { 57 | return err 58 | } 59 | 60 | cmd := exec.Command(absUmuRunPath, "--config", absUmuConfigPath) 61 | 62 | if et.workDir != "" { 63 | cmd.Dir = et.workDir 64 | } 65 | 66 | cmd.Env = append(os.Environ(), et.env...) 67 | 68 | if et.verbose { 69 | cmd.Stdout = os.Stdout 70 | cmd.Stderr = os.Stderr 71 | } 72 | 73 | return cmd.Run() 74 | } 75 | 76 | func linuxProtonRunExecTask(id string, et *execTask, rdx redux.Readable, force bool) error { 77 | 78 | lwra := nod.Begin(" running %s with Proton, please wait...", et.name) 79 | defer lwra.Done() 80 | 81 | if et.verbose && len(et.env) > 0 { 82 | pea := nod.Begin(" env:") 83 | pea.EndWithResult(strings.Join(et.env, " ")) 84 | } 85 | 86 | absUmuRunPath, err := data.UmuRunLatestReleasePath(rdx) 87 | if err != nil { 88 | return err 89 | } 90 | 91 | absProtonPath, err := data.ProtonGeLatestReleasePath(rdx) 92 | if err != nil { 93 | return err 94 | } 95 | 96 | umuCfg := &UmuConfig{ 97 | GogId: id, 98 | Prefix: et.prefix, 99 | Proton: absProtonPath, 100 | ExePath: et.exe, 101 | Args: et.args, 102 | } 103 | 104 | absUmuConfigPath, err := createUmuConfig(umuCfg, rdx, force) 105 | if err != nil { 106 | return err 107 | } 108 | 109 | cmd := exec.Command(absUmuRunPath, "--config", absUmuConfigPath) 110 | 111 | if et.workDir != "" { 112 | cmd.Dir = et.workDir 113 | } 114 | 115 | cmd.Env = append(os.Environ(), et.env...) 116 | 117 | if et.verbose { 118 | cmd.Stdout = os.Stdout 119 | cmd.Stderr = os.Stderr 120 | } 121 | 122 | return cmd.Run() 123 | } 124 | 125 | func linuxInitPrefix(id, langCode string, rdx redux.Readable, _ bool) error { 126 | lipa := nod.Begin(" initializing prefix...") 127 | defer lipa.Done() 128 | 129 | if err := rdx.MustHave(vangogh_integration.SlugProperty); err != nil { 130 | return err 131 | } 132 | 133 | absPrefixDir, err := data.GetAbsPrefixDir(id, langCode, rdx) 134 | if err != nil { 135 | return err 136 | } 137 | 138 | return os.MkdirAll(absPrefixDir, 0755) 139 | } 140 | -------------------------------------------------------------------------------- /cli/uninstall.go: -------------------------------------------------------------------------------- 1 | package cli 2 | 3 | import ( 4 | "net/url" 5 | 6 | "github.com/arelate/southern_light/vangogh_integration" 7 | "github.com/arelate/theo/data" 8 | "github.com/boggydigital/nod" 9 | "github.com/boggydigital/pathways" 10 | "github.com/boggydigital/redux" 11 | ) 12 | 13 | func UninstallHandler(u *url.URL) error { 14 | 15 | q := u.Query() 16 | 17 | id := q.Get(vangogh_integration.IdProperty) 18 | 19 | operatingSystem := vangogh_integration.AnyOperatingSystem 20 | if q.Has(vangogh_integration.OperatingSystemsProperty) { 21 | operatingSystem = vangogh_integration.ParseOperatingSystem(q.Get(vangogh_integration.OperatingSystemsProperty)) 22 | } 23 | 24 | var langCode string 25 | if q.Has(vangogh_integration.LanguageCodeProperty) { 26 | langCode = q.Get(vangogh_integration.LanguageCodeProperty) 27 | } 28 | 29 | ii := &InstallInfo{ 30 | OperatingSystem: operatingSystem, 31 | LangCode: langCode, 32 | verbose: q.Has("verbose"), 33 | force: q.Has("force"), 34 | } 35 | 36 | return Uninstall(id, ii) 37 | } 38 | 39 | func Uninstall(id string, ii *InstallInfo) error { 40 | 41 | ua := nod.Begin("uninstalling %s...", id) 42 | defer ua.Done() 43 | 44 | reduxDir, err := pathways.GetAbsRelDir(data.Redux) 45 | if err != nil { 46 | return err 47 | } 48 | 49 | rdx, err := redux.NewWriter(reduxDir, data.AllProperties()...) 50 | if err != nil { 51 | return err 52 | } 53 | 54 | if !ii.force { 55 | ua.EndWithResult("uninstall requires -force parameter") 56 | return nil 57 | } 58 | 59 | if err = resolveInstallInfo(id, ii, nil, rdx, installedOperatingSystem, installedLangCode); err != nil { 60 | return err 61 | } 62 | 63 | if installedInfoLines, ok := rdx.GetAllValues(data.InstallInfoProperty, id); ok { 64 | 65 | installInfo, _, err := matchInstallInfo(ii, installedInfoLines...) 66 | if err != nil { 67 | return err 68 | } 69 | 70 | if installInfo == nil { 71 | ua.EndWithResult("no install info found for %s %s-%s", id, ii.OperatingSystem, ii.LangCode) 72 | return nil 73 | } 74 | 75 | } 76 | 77 | if err = osUninstallProduct(id, ii, rdx); err != nil { 78 | return err 79 | } 80 | 81 | if err = unpinInstallInfo(id, ii, rdx); err != nil { 82 | return err 83 | } 84 | 85 | if err = removeSteamShortcut(rdx, id); err != nil { 86 | return err 87 | } 88 | 89 | return nil 90 | 91 | } 92 | 93 | func osUninstallProduct(id string, ii *InstallInfo, rdx redux.Writeable) error { 94 | 95 | oupa := nod.Begin(" uninstalling %s %s-%s...", id, ii.OperatingSystem, ii.LangCode) 96 | defer oupa.Done() 97 | 98 | switch ii.OperatingSystem { 99 | case vangogh_integration.MacOS: 100 | fallthrough 101 | case vangogh_integration.Linux: 102 | if err := nixUninstallProduct(id, ii.LangCode, ii.OperatingSystem, rdx); err != nil { 103 | return err 104 | } 105 | case vangogh_integration.Windows: 106 | currentOs := data.CurrentOs() 107 | switch currentOs { 108 | case vangogh_integration.MacOS: 109 | fallthrough 110 | case vangogh_integration.Linux: 111 | 112 | if err := removeProductPrefix(id, ii.LangCode, rdx, ii.force); err != nil { 113 | return err 114 | } 115 | 116 | if err := prefixDeleteProperty(id, ii.LangCode, data.PrefixEnvProperty, rdx, ii.force); err != nil { 117 | return err 118 | } 119 | 120 | case vangogh_integration.Windows: 121 | if err := windowsUninstallProduct(id, ii.LangCode, rdx); err != nil { 122 | return err 123 | } 124 | default: 125 | return currentOs.ErrUnsupported() 126 | } 127 | default: 128 | return ii.OperatingSystem.ErrUnsupported() 129 | } 130 | 131 | return nil 132 | } 133 | -------------------------------------------------------------------------------- /cli/remove_downloads.go: -------------------------------------------------------------------------------- 1 | package cli 2 | 3 | import ( 4 | "net/url" 5 | "os" 6 | "path/filepath" 7 | "strings" 8 | 9 | "github.com/arelate/southern_light/vangogh_integration" 10 | "github.com/arelate/theo/data" 11 | "github.com/boggydigital/nod" 12 | "github.com/boggydigital/pathways" 13 | "github.com/boggydigital/redux" 14 | ) 15 | 16 | func RemoveDownloadsHandler(u *url.URL) error { 17 | 18 | q := u.Query() 19 | 20 | id := q.Get(vangogh_integration.IdProperty) 21 | 22 | operatingSystem := vangogh_integration.AnyOperatingSystem 23 | if q.Has(vangogh_integration.OperatingSystemsProperty) { 24 | operatingSystem = vangogh_integration.ParseOperatingSystem(q.Get(vangogh_integration.OperatingSystemsProperty)) 25 | } 26 | 27 | var langCode string 28 | if q.Has(vangogh_integration.LanguageCodeProperty) { 29 | langCode = q.Get(vangogh_integration.LanguageCodeProperty) 30 | } 31 | 32 | var downloadTypes []vangogh_integration.DownloadType 33 | if q.Has(vangogh_integration.DownloadTypeProperty) { 34 | dts := strings.Split(q.Get(vangogh_integration.DownloadTypeProperty), ",") 35 | downloadTypes = vangogh_integration.ParseManyDownloadTypes(dts) 36 | } 37 | 38 | ii := &InstallInfo{ 39 | OperatingSystem: operatingSystem, 40 | LangCode: langCode, 41 | DownloadTypes: downloadTypes, 42 | force: q.Has("force"), 43 | } 44 | 45 | reduxDir, err := pathways.GetAbsRelDir(data.Redux) 46 | if err != nil { 47 | return err 48 | } 49 | 50 | rdx, err := redux.NewWriter(reduxDir, data.AllProperties()...) 51 | if err != nil { 52 | return err 53 | } 54 | 55 | return RemoveDownloads(id, ii, rdx) 56 | } 57 | 58 | func RemoveDownloads(id string, ii *InstallInfo, rdx redux.Writeable) error { 59 | 60 | rda := nod.NewProgress("removing downloads...") 61 | defer rda.Done() 62 | 63 | printInstallInfoParams(ii, true, id) 64 | 65 | downloadsDir, err := pathways.GetAbsDir(data.Downloads) 66 | if err != nil { 67 | return err 68 | } 69 | 70 | productDetails, err := getProductDetails(id, rdx, ii.force) 71 | if err != nil { 72 | return err 73 | } 74 | 75 | if err = removeProductDownloadLinks(id, productDetails, ii, downloadsDir); err != nil { 76 | return err 77 | } 78 | 79 | rda.Increment() 80 | 81 | return nil 82 | } 83 | 84 | func removeProductDownloadLinks(id string, 85 | productDetails *vangogh_integration.ProductDetails, 86 | ii *InstallInfo, 87 | downloadsDir string) error { 88 | 89 | rdla := nod.Begin(" removing downloads for %s...", productDetails.Title) 90 | defer rdla.Done() 91 | 92 | idPath := filepath.Join(downloadsDir, id) 93 | if _, err := os.Stat(idPath); os.IsNotExist(err) { 94 | rdla.EndWithResult("product downloads dir not present") 95 | return nil 96 | } 97 | 98 | dls := productDetails.DownloadLinks. 99 | FilterOperatingSystems(ii.OperatingSystem). 100 | FilterLanguageCodes(ii.LangCode). 101 | FilterDownloadTypes(ii.DownloadTypes...) 102 | 103 | if len(dls) == 0 { 104 | rdla.EndWithResult("no links are matching operating params") 105 | return nil 106 | } 107 | 108 | for _, dl := range dls { 109 | 110 | // if we don't do this - product downloads dir itself will be removed 111 | if dl.LocalFilename == "" { 112 | continue 113 | } 114 | 115 | path := filepath.Join(downloadsDir, id, dl.LocalFilename) 116 | 117 | fa := nod.NewProgress(" - %s...", dl.LocalFilename) 118 | 119 | if _, err := os.Stat(path); os.IsNotExist(err) { 120 | fa.EndWithResult("not present") 121 | continue 122 | } 123 | 124 | if err := os.Remove(path); err != nil { 125 | return err 126 | } 127 | 128 | fa.Done() 129 | } 130 | 131 | productDownloadsDir := filepath.Join(downloadsDir, id) 132 | if entries, err := os.ReadDir(productDownloadsDir); err == nil && len(entries) == 0 { 133 | rdda := nod.Begin(" removing empty product downloads directory...") 134 | if err = os.Remove(productDownloadsDir); err != nil { 135 | return err 136 | } 137 | rdda.Done() 138 | } else { 139 | return err 140 | } 141 | 142 | return nil 143 | } 144 | -------------------------------------------------------------------------------- /cli/reveal.go: -------------------------------------------------------------------------------- 1 | package cli 2 | 3 | import ( 4 | "errors" 5 | "net/url" 6 | "os" 7 | "path/filepath" 8 | 9 | "github.com/arelate/southern_light/vangogh_integration" 10 | "github.com/arelate/theo/data" 11 | "github.com/boggydigital/nod" 12 | "github.com/boggydigital/pathways" 13 | "github.com/boggydigital/redux" 14 | ) 15 | 16 | func RevealHandler(u *url.URL) error { 17 | 18 | q := u.Query() 19 | 20 | id := q.Get(vangogh_integration.IdProperty) 21 | 22 | operatingSystem := vangogh_integration.AnyOperatingSystem 23 | if q.Has(vangogh_integration.OperatingSystemsProperty) { 24 | operatingSystem = vangogh_integration.ParseOperatingSystem(q.Get(vangogh_integration.OperatingSystemsProperty)) 25 | } 26 | 27 | langCode := "" // installed info language will be used instead of default 28 | if q.Has(vangogh_integration.LanguageCodeProperty) { 29 | langCode = q.Get(vangogh_integration.LanguageCodeProperty) 30 | } 31 | 32 | ii := &InstallInfo{ 33 | OperatingSystem: operatingSystem, 34 | LangCode: langCode, 35 | } 36 | 37 | installed := q.Has("installed") 38 | downloads := q.Has("downloads") 39 | backups := q.Has("backups") 40 | 41 | return Reveal(id, ii, installed, downloads, backups) 42 | } 43 | 44 | func Reveal(id string, ii *InstallInfo, installed, downloads, backups bool) error { 45 | 46 | if installed { 47 | if err := revealInstalled(id, ii); err != nil { 48 | return err 49 | } 50 | } 51 | 52 | if downloads { 53 | if err := revealDownloads(id); err != nil { 54 | return err 55 | } 56 | } 57 | 58 | if backups { 59 | if err := revealBackups(); err != nil { 60 | return err 61 | } 62 | } 63 | 64 | return nil 65 | } 66 | 67 | func revealInstalled(id string, ii *InstallInfo) error { 68 | 69 | ria := nod.Begin("revealing installation for %s...", id) 70 | defer ria.Done() 71 | 72 | reduxDir, err := pathways.GetAbsRelDir(data.Redux) 73 | if err != nil { 74 | return err 75 | } 76 | 77 | rdx, err := redux.NewReader(reduxDir, 78 | data.InstallInfoProperty, 79 | vangogh_integration.SlugProperty) 80 | if err != nil { 81 | return err 82 | } 83 | 84 | if ii.OperatingSystem == vangogh_integration.AnyOperatingSystem { 85 | iios, err := installedInfoOperatingSystem(id, rdx) 86 | if err != nil { 87 | return err 88 | } 89 | 90 | ii.OperatingSystem = iios 91 | } 92 | 93 | if ii.LangCode == "" { 94 | lc, err := installedInfoLangCode(id, ii.OperatingSystem, rdx) 95 | if err != nil { 96 | return err 97 | } 98 | 99 | ii.LangCode = lc 100 | } 101 | 102 | if installedInfoLines, ok := rdx.GetAllValues(data.InstallInfoProperty, id); ok { 103 | 104 | installedInfo, _, err := matchInstallInfo(ii, installedInfoLines...) 105 | if err != nil { 106 | return err 107 | } 108 | 109 | if installedInfo != nil { 110 | return currentOsRevealInstalled(id, ii, rdx) 111 | } else { 112 | ria.EndWithResult("no install found for %s %s-%s", id, ii.OperatingSystem, ii.LangCode) 113 | } 114 | 115 | } 116 | 117 | return nil 118 | } 119 | 120 | func currentOsRevealInstalled(id string, ii *InstallInfo, rdx redux.Readable) error { 121 | 122 | revealPath, err := osInstalledPath(id, ii.OperatingSystem, ii.LangCode, rdx) 123 | if err != nil { 124 | return err 125 | } 126 | 127 | return currentOsReveal(revealPath) 128 | } 129 | 130 | func revealBackups() error { 131 | 132 | rda := nod.Begin("revealing backups...") 133 | defer rda.Done() 134 | 135 | backupsDir, err := pathways.GetAbsDir(data.Backups) 136 | if err != nil { 137 | return err 138 | } 139 | 140 | return currentOsReveal(backupsDir) 141 | } 142 | 143 | func revealDownloads(id string) error { 144 | 145 | rda := nod.Begin("revealing downloads...") 146 | defer rda.Done() 147 | 148 | downloadsDir, err := pathways.GetAbsDir(data.Downloads) 149 | if err != nil { 150 | return err 151 | } 152 | 153 | productDownloadsDir := filepath.Join(downloadsDir, id) 154 | 155 | if _, err = os.Stat(productDownloadsDir); err == nil { 156 | return currentOsReveal(productDownloadsDir) 157 | } else { 158 | return currentOsReveal(downloadsDir) 159 | } 160 | } 161 | 162 | func currentOsReveal(path string) error { 163 | switch data.CurrentOs() { 164 | case vangogh_integration.MacOS: 165 | return macOsReveal(path) 166 | case vangogh_integration.Windows: 167 | return windowsReveal(path) 168 | case vangogh_integration.Linux: 169 | return linuxReveal(path) 170 | default: 171 | return errors.New("cannot reveal on unknown operating system") 172 | } 173 | } 174 | -------------------------------------------------------------------------------- /cli/download.go: -------------------------------------------------------------------------------- 1 | package cli 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "net/url" 7 | "slices" 8 | "strings" 9 | 10 | "github.com/arelate/southern_light/vangogh_integration" 11 | "github.com/arelate/theo/data" 12 | "github.com/boggydigital/dolo" 13 | "github.com/boggydigital/nod" 14 | "github.com/boggydigital/pathways" 15 | "github.com/boggydigital/redux" 16 | ) 17 | 18 | func DownloadHandler(u *url.URL) error { 19 | 20 | q := u.Query() 21 | 22 | id := q.Get(vangogh_integration.IdProperty) 23 | 24 | operatingSystem := vangogh_integration.AnyOperatingSystem 25 | if q.Has(vangogh_integration.OperatingSystemsProperty) { 26 | operatingSystem = vangogh_integration.ParseOperatingSystem(q.Get(vangogh_integration.OperatingSystemsProperty)) 27 | } 28 | 29 | var langCode string 30 | if q.Has(vangogh_integration.LanguageCodeProperty) { 31 | langCode = q.Get(vangogh_integration.LanguageCodeProperty) 32 | } 33 | 34 | var downloadTypes []vangogh_integration.DownloadType 35 | if q.Has(vangogh_integration.DownloadTypeProperty) { 36 | dts := strings.Split(q.Get(vangogh_integration.DownloadTypeProperty), ",") 37 | downloadTypes = vangogh_integration.ParseManyDownloadTypes(dts) 38 | } 39 | 40 | ii := &InstallInfo{ 41 | OperatingSystem: operatingSystem, 42 | LangCode: langCode, 43 | DownloadTypes: downloadTypes, 44 | force: q.Has("force"), 45 | } 46 | 47 | var manualUrlFilter []string 48 | if q.Has("manual-url-filter") { 49 | manualUrlFilter = strings.Split(q.Get("manual-url-filter"), ",") 50 | } 51 | 52 | reduxDir, err := pathways.GetAbsRelDir(data.Redux) 53 | if err != nil { 54 | return err 55 | } 56 | 57 | rdx, err := redux.NewWriter(reduxDir, data.AllProperties()...) 58 | if err != nil { 59 | return err 60 | } 61 | 62 | return Download(id, ii, manualUrlFilter, rdx) 63 | } 64 | 65 | func Download(id string, 66 | ii *InstallInfo, 67 | manualUrlFilter []string, 68 | rdx redux.Writeable) error { 69 | 70 | da := nod.NewProgress("downloading from the server...") 71 | defer da.Done() 72 | 73 | printInstallInfoParams(ii, true, id) 74 | 75 | // always get the latest product details for download purposes 76 | productDetails, err := getProductDetails(id, rdx, true) 77 | if err != nil { 78 | return err 79 | } 80 | 81 | if err = resolveInstallInfo(id, ii, productDetails, rdx, currentOsThenWindows); err != nil { 82 | return err 83 | } 84 | 85 | if err = downloadProductFiles(id, productDetails, ii, manualUrlFilter, rdx); err != nil { 86 | return err 87 | } 88 | 89 | da.Increment() 90 | 91 | return nil 92 | } 93 | 94 | func downloadProductFiles(id string, 95 | productDetails *vangogh_integration.ProductDetails, 96 | ii *InstallInfo, 97 | manualUrlFilter []string, 98 | rdx redux.Readable) error { 99 | 100 | gpdla := nod.Begin(" downloading %s...", productDetails.Title) 101 | defer gpdla.Done() 102 | 103 | if err := rdx.MustHave(data.ServerConnectionProperties); err != nil { 104 | return err 105 | } 106 | 107 | downloadsDir, err := pathways.GetAbsDir(data.Downloads) 108 | if err != nil { 109 | return err 110 | } 111 | 112 | if err = hasFreeSpaceForProduct(productDetails, downloadsDir, ii, manualUrlFilter); err != nil { 113 | return err 114 | } 115 | 116 | dc := dolo.DefaultClient 117 | 118 | if token, ok := rdx.GetLastVal(data.ServerConnectionProperties, data.ServerSessionToken); ok && token != "" { 119 | dc.SetAuthorizationBearer(token) 120 | } 121 | 122 | dls := productDetails.DownloadLinks. 123 | FilterOperatingSystems(ii.OperatingSystem). 124 | FilterLanguageCodes(ii.LangCode). 125 | FilterDownloadTypes(ii.DownloadTypes...) 126 | 127 | if len(dls) == 0 { 128 | return errors.New("no links are matching operating params") 129 | } 130 | 131 | for _, dl := range dls { 132 | 133 | if len(manualUrlFilter) > 0 && !slices.Contains(manualUrlFilter, dl.ManualUrl) { 134 | continue 135 | } 136 | 137 | if dl.ValidationStatus != vangogh_integration.ValidationStatusSuccess && 138 | dl.ValidationStatus != vangogh_integration.ValidationStatusMissingChecksum { 139 | errMsg := fmt.Sprintf("%s validation status %s prevented download", dl.Name, dl.ValidationStatus) 140 | return errors.New(errMsg) 141 | } 142 | 143 | fa := nod.NewProgress(" - %s...", dl.LocalFilename) 144 | 145 | query := url.Values{ 146 | "manual-url": {dl.ManualUrl}, 147 | "id": {id}, 148 | "download-type": {dl.DownloadType.String()}, 149 | } 150 | 151 | fileUrl, err := data.ServerUrl(data.HttpFilesPath, query, rdx) 152 | if err != nil { 153 | fa.EndWithResult(err.Error()) 154 | continue 155 | } 156 | 157 | if err = dc.Download(fileUrl, ii.force, fa, downloadsDir, id, dl.LocalFilename); err != nil { 158 | fa.EndWithResult(err.Error()) 159 | continue 160 | } 161 | 162 | fa.Done() 163 | } 164 | 165 | return nil 166 | } 167 | -------------------------------------------------------------------------------- /cli/get_product_details.go: -------------------------------------------------------------------------------- 1 | package cli 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "errors" 7 | "io" 8 | "net/http" 9 | "net/url" 10 | 11 | "github.com/arelate/southern_light/vangogh_integration" 12 | "github.com/arelate/theo/data" 13 | "github.com/boggydigital/kevlar" 14 | "github.com/boggydigital/nod" 15 | "github.com/boggydigital/pathways" 16 | "github.com/boggydigital/redux" 17 | ) 18 | 19 | func getProductDetails(id string, rdx redux.Writeable, force bool) (*vangogh_integration.ProductDetails, error) { 20 | 21 | gpda := nod.NewProgress(" getting product details for %s...", id) 22 | defer gpda.Done() 23 | 24 | productDetailsDir, err := pathways.GetAbsRelDir(data.ProductDetails) 25 | if err != nil { 26 | return nil, err 27 | } 28 | 29 | kvProductDetails, err := kevlar.New(productDetailsDir, kevlar.JsonExt) 30 | if err != nil { 31 | return nil, err 32 | } 33 | 34 | if dm, err := readLocalProductDetails(id, kvProductDetails); err != nil { 35 | return nil, err 36 | } else if dm != nil && !force { 37 | gpda.EndWithResult("read local") 38 | return dm, nil 39 | } 40 | 41 | if err = validateSessionToken(rdx); err != nil { 42 | return nil, err 43 | } 44 | 45 | productDetails, err := fetchRemoteProductDetails(id, rdx, kvProductDetails) 46 | if err != nil { 47 | return nil, err 48 | } 49 | 50 | gpda.EndWithResult("fetched remote") 51 | 52 | if err = reduceProductDetails(id, productDetails, rdx); err != nil { 53 | return nil, err 54 | } 55 | 56 | return productDetails, nil 57 | } 58 | 59 | func readLocalProductDetails(id string, kvProductDetails kevlar.KeyValues) (*vangogh_integration.ProductDetails, error) { 60 | 61 | if has := kvProductDetails.Has(id); !has { 62 | return nil, nil 63 | } 64 | 65 | tmReadCloser, err := kvProductDetails.Get(id) 66 | if err != nil { 67 | return nil, err 68 | } 69 | defer tmReadCloser.Close() 70 | 71 | var productDetails vangogh_integration.ProductDetails 72 | if err = json.NewDecoder(tmReadCloser).Decode(&productDetails); err != nil { 73 | return nil, err 74 | } 75 | 76 | return &productDetails, nil 77 | } 78 | 79 | func fetchRemoteProductDetails(id string, rdx redux.Readable, kvProductDetails kevlar.KeyValues) (*vangogh_integration.ProductDetails, error) { 80 | 81 | query := url.Values{ 82 | vangogh_integration.IdProperty: {id}, 83 | } 84 | 85 | req, err := data.ServerRequest(http.MethodGet, data.ApiProductDetailsPath, query, rdx) 86 | if err != nil { 87 | return nil, err 88 | } 89 | 90 | resp, err := http.DefaultClient.Do(req) 91 | if err != nil { 92 | return nil, err 93 | } 94 | defer resp.Body.Close() 95 | 96 | if resp.StatusCode < 200 || resp.StatusCode > 299 { 97 | return nil, errors.New("error fetching product details: " + resp.Status) 98 | } 99 | 100 | var bts []byte 101 | buf := bytes.NewBuffer(bts) 102 | tr := io.TeeReader(resp.Body, buf) 103 | 104 | if err := kvProductDetails.Set(id, tr); err != nil { 105 | return nil, err 106 | } 107 | 108 | var productDetails vangogh_integration.ProductDetails 109 | if err = json.NewDecoder(buf).Decode(&productDetails); err != nil { 110 | return nil, err 111 | } 112 | 113 | return &productDetails, nil 114 | } 115 | 116 | func reduceProductDetails(id string, productDetails *vangogh_integration.ProductDetails, rdx redux.Writeable) error { 117 | 118 | rpda := nod.Begin(" reducing product details...") 119 | defer rpda.Done() 120 | 121 | propertyValues := make(map[string][]string) 122 | 123 | oss := make([]string, 0, len(productDetails.OperatingSystems)) 124 | for _, os := range productDetails.OperatingSystems { 125 | oss = append(oss, os.String()) 126 | } 127 | 128 | propertyValues[vangogh_integration.SlugProperty] = []string{productDetails.Slug} 129 | propertyValues[vangogh_integration.SteamAppIdProperty] = []string{productDetails.SteamAppId} 130 | propertyValues[vangogh_integration.TitleProperty] = []string{productDetails.Title} 131 | propertyValues[vangogh_integration.OperatingSystemsProperty] = oss 132 | propertyValues[vangogh_integration.DevelopersProperty] = productDetails.Developers 133 | propertyValues[vangogh_integration.PublishersProperty] = productDetails.Publishers 134 | propertyValues[vangogh_integration.VerticalImageProperty] = []string{productDetails.Images.VerticalImage} 135 | propertyValues[vangogh_integration.ImageProperty] = []string{productDetails.Images.Image} 136 | propertyValues[vangogh_integration.HeroProperty] = []string{productDetails.Images.Hero} 137 | propertyValues[vangogh_integration.LogoProperty] = []string{productDetails.Images.Logo} 138 | propertyValues[vangogh_integration.IconProperty] = []string{productDetails.Images.Icon} 139 | propertyValues[vangogh_integration.IconSquareProperty] = []string{productDetails.Images.IconSquare} 140 | propertyValues[vangogh_integration.BackgroundProperty] = []string{productDetails.Images.Background} 141 | 142 | for property, values := range propertyValues { 143 | if err := rdx.ReplaceValues(property, id, values...); err != nil { 144 | return err 145 | } 146 | } 147 | 148 | return nil 149 | } 150 | -------------------------------------------------------------------------------- /data/paths.go: -------------------------------------------------------------------------------- 1 | package data 2 | 3 | import ( 4 | "errors" 5 | "io/fs" 6 | "os" 7 | "path/filepath" 8 | "strings" 9 | 10 | "github.com/arelate/southern_light/vangogh_integration" 11 | "github.com/boggydigital/pathways" 12 | "github.com/boggydigital/redux" 13 | ) 14 | 15 | const theoDirname = "theo" 16 | 17 | const ( 18 | inventoryExt = ".txt" 19 | ) 20 | 21 | func InitRootDir() (string, error) { 22 | udhd, err := UserDataHomeDir() 23 | if err != nil { 24 | return "", err 25 | } 26 | 27 | rootDir := filepath.Join(udhd, theoDirname) 28 | if _, err := os.Stat(rootDir); os.IsNotExist(err) { 29 | if err := os.MkdirAll(rootDir, 0755); err != nil { 30 | return "", err 31 | } 32 | } 33 | 34 | for _, ad := range AllAbsDirs { 35 | absDir := filepath.Join(rootDir, string(ad)) 36 | if _, err := os.Stat(absDir); os.IsNotExist(err) { 37 | if err := os.MkdirAll(absDir, 0755); err != nil { 38 | return "", err 39 | } 40 | } 41 | } 42 | 43 | for rd, ad := range RelToAbsDirs { 44 | absRelDir := filepath.Join(rootDir, string(ad), string(rd)) 45 | if _, err := os.Stat(absRelDir); os.IsNotExist(err) { 46 | if err := os.MkdirAll(absRelDir, 0755); err != nil { 47 | return "", err 48 | } 49 | } 50 | } 51 | 52 | return filepath.Join(udhd, theoDirname), nil 53 | } 54 | 55 | const ( 56 | Backups pathways.AbsDir = "backups" 57 | Metadata pathways.AbsDir = "metadata" 58 | Downloads pathways.AbsDir = "downloads" 59 | Wine pathways.AbsDir = "wine" 60 | InstalledApps pathways.AbsDir = "installed-apps" 61 | Logs pathways.AbsDir = "logs" 62 | ) 63 | 64 | const ( 65 | Redux pathways.RelDir = "_redux" 66 | ProductDetails pathways.RelDir = "_product-details" 67 | WineDownloads pathways.RelDir = "_downloads" 68 | WineBinaries pathways.RelDir = "_binaries" 69 | PrefixArchive pathways.RelDir = "_prefix-archive" 70 | UmuConfigs pathways.RelDir = "_umu-configs" 71 | Inventory pathways.RelDir = "_inventory" 72 | ) 73 | 74 | var RelToAbsDirs = map[pathways.RelDir]pathways.AbsDir{ 75 | Redux: Metadata, 76 | ProductDetails: Metadata, 77 | Inventory: InstalledApps, 78 | PrefixArchive: Backups, 79 | WineDownloads: Wine, 80 | WineBinaries: Wine, 81 | UmuConfigs: Wine, 82 | } 83 | 84 | var AllAbsDirs = []pathways.AbsDir{ 85 | Backups, 86 | Metadata, 87 | Downloads, 88 | Wine, 89 | InstalledApps, 90 | Logs, 91 | } 92 | 93 | func GetPrefixName(id string, rdx redux.Readable) (string, error) { 94 | if slug, ok := rdx.GetLastVal(vangogh_integration.SlugProperty, id); ok && slug != "" { 95 | return slug, nil 96 | } else { 97 | return "", errors.New("product slug is undefined: " + id) 98 | } 99 | } 100 | 101 | func OsLangCode(operatingSystem vangogh_integration.OperatingSystem, langCode string) string { 102 | return strings.Join([]string{operatingSystem.String(), langCode}, "-") 103 | } 104 | 105 | func GetAbsPrefixDir(id, langCode string, rdx redux.Readable) (string, error) { 106 | if err := rdx.MustHave(vangogh_integration.SlugProperty); err != nil { 107 | return "", err 108 | } 109 | 110 | installedAppsDir, err := pathways.GetAbsDir(InstalledApps) 111 | if err != nil { 112 | return "", err 113 | } 114 | 115 | osLangInstalledAppsDir := filepath.Join(installedAppsDir, OsLangCode(vangogh_integration.Windows, langCode)) 116 | 117 | prefixName, err := GetPrefixName(id, rdx) 118 | if err != nil { 119 | return "", err 120 | } 121 | 122 | return filepath.Join(osLangInstalledAppsDir, prefixName), nil 123 | } 124 | 125 | func GetAbsInventoryFilename(id, langCode string, operatingSystem vangogh_integration.OperatingSystem, rdx redux.Readable) (string, error) { 126 | if err := rdx.MustHave(vangogh_integration.SlugProperty); err != nil { 127 | return "", err 128 | } 129 | 130 | inventoryDir, err := pathways.GetAbsRelDir(Inventory) 131 | if err != nil { 132 | return "", err 133 | } 134 | 135 | osLangInventoryDir := filepath.Join(inventoryDir, OsLangCode(operatingSystem, langCode)) 136 | 137 | if slug, ok := rdx.GetLastVal(vangogh_integration.SlugProperty, id); ok && slug != "" { 138 | return filepath.Join(osLangInventoryDir, slug+inventoryExt), nil 139 | } else { 140 | return "", errors.New("product slug is undefined: " + id) 141 | } 142 | } 143 | 144 | func GetRelFilesModifiedAfter(absDir string, utcTime int64) ([]string, error) { 145 | files := make([]string, 0) 146 | 147 | if err := filepath.Walk(absDir, func(path string, info fs.FileInfo, err error) error { 148 | 149 | if err != nil { 150 | return err 151 | } 152 | 153 | if info.ModTime().UTC().Unix() >= utcTime { 154 | relPath, err := filepath.Rel(absDir, path) 155 | if err != nil { 156 | return err 157 | } 158 | 159 | files = append(files, relPath) 160 | } 161 | 162 | return nil 163 | }); err != nil { 164 | return nil, err 165 | } 166 | 167 | return files, nil 168 | } 169 | 170 | func RelToUserDataHome(path string) (string, error) { 171 | udhd, err := UserDataHomeDir() 172 | if err != nil { 173 | return "", err 174 | } 175 | 176 | if strings.HasPrefix(path, udhd) { 177 | return strings.Replace(path, udhd, "~Data", 1), nil 178 | } else { 179 | return path, nil 180 | } 181 | } 182 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/arelate/southern_light v0.3.34 h1:/YuP1MrujAxZfuRUcnFpDk/EFDDfhQ1+Rkvac+tOXYQ= 2 | github.com/arelate/southern_light v0.3.34/go.mod h1:F69W9BwWJOuw3a/gnN1gR+oEMLAclfbtdHmZcQUb9uU= 3 | github.com/arelate/southern_light v0.3.47 h1:JEaFVnh/amjJS5xd58Mdm8xrGpP+xe/jv0vsnbTb5NI= 4 | github.com/arelate/southern_light v0.3.47/go.mod h1:F69W9BwWJOuw3a/gnN1gR+oEMLAclfbtdHmZcQUb9uU= 5 | github.com/arelate/southern_light v0.3.49 h1:N9nbSxv8Pw6u9D+Q4NWRoFt8PbfkDdhnKvTg6FFVlfs= 6 | github.com/arelate/southern_light v0.3.49/go.mod h1:F69W9BwWJOuw3a/gnN1gR+oEMLAclfbtdHmZcQUb9uU= 7 | github.com/arelate/southern_light v0.3.50 h1:eogRsFm8JboT7hpTJDQ3hXeRNp8FIsIiMojTmFpzCRA= 8 | github.com/arelate/southern_light v0.3.50/go.mod h1:F69W9BwWJOuw3a/gnN1gR+oEMLAclfbtdHmZcQUb9uU= 9 | github.com/arelate/southern_light v0.3.52 h1:5r6n0cxjd0ZjDf55+Zr7QImwSfqh6+UdeomDS3TriUc= 10 | github.com/arelate/southern_light v0.3.52/go.mod h1:hs+emkSayVAhRWsnSkLWSsDqkAM8LE+wxOl1qxq+8OM= 11 | github.com/arelate/southern_light v0.3.53 h1:YIw0sdrJX8dnxNNVkzOuWzQig3E8vGsfulcBmH9/y1g= 12 | github.com/arelate/southern_light v0.3.53/go.mod h1:hs+emkSayVAhRWsnSkLWSsDqkAM8LE+wxOl1qxq+8OM= 13 | github.com/arelate/southern_light v0.3.54 h1:u3B7uO2VAZC2yZwVKQ0Pzt1+JJrf0wPGfBJmJyqUC7M= 14 | github.com/arelate/southern_light v0.3.54/go.mod h1:hs+emkSayVAhRWsnSkLWSsDqkAM8LE+wxOl1qxq+8OM= 15 | github.com/arelate/southern_light v0.3.62 h1:qTNZKuFcbzK0huLH9AE+jXvNeFW8naIlaew4AqrN4y0= 16 | github.com/arelate/southern_light v0.3.62/go.mod h1:hs+emkSayVAhRWsnSkLWSsDqkAM8LE+wxOl1qxq+8OM= 17 | github.com/arelate/southern_light v0.3.66 h1:YVEA4HcpGDafAV9XPv+IdBOzpeQT4i7/YyJ+jzT2ZDE= 18 | github.com/arelate/southern_light v0.3.66/go.mod h1:hs+emkSayVAhRWsnSkLWSsDqkAM8LE+wxOl1qxq+8OM= 19 | github.com/boggydigital/author v0.1.11 h1:mU+i9ZHkFoi20xpuqSU20nJ7Mty7NjTJo3u2Uspx8+U= 20 | github.com/boggydigital/author v0.1.11/go.mod h1:TByLkZmjPXYcK+l3920TEybnXYK7+qjc6oGLKnnEtVI= 21 | github.com/boggydigital/author v0.1.12 h1:wDOWzwAK3xRKv6ESyy9b16farna2NPNWKhkDb4jb/6w= 22 | github.com/boggydigital/author v0.1.12/go.mod h1:TByLkZmjPXYcK+l3920TEybnXYK7+qjc6oGLKnnEtVI= 23 | github.com/boggydigital/author v0.1.13 h1:i2/h+9ss1NUvEGxWpSKrmYzSALE1mrDYiDEePllJnt8= 24 | github.com/boggydigital/author v0.1.13/go.mod h1:TByLkZmjPXYcK+l3920TEybnXYK7+qjc6oGLKnnEtVI= 25 | github.com/boggydigital/author v0.1.14 h1:BO4u4i6ouH3K8UjYURxuSGr5OpOTx0GEfdk3IHVC21c= 26 | github.com/boggydigital/author v0.1.14/go.mod h1:TByLkZmjPXYcK+l3920TEybnXYK7+qjc6oGLKnnEtVI= 27 | github.com/boggydigital/author v0.1.15 h1:G+tKrROLPo4a/DIWF1myOrJbqEHP1+sxPnYun4njUvc= 28 | github.com/boggydigital/author v0.1.15/go.mod h1:TByLkZmjPXYcK+l3920TEybnXYK7+qjc6oGLKnnEtVI= 29 | github.com/boggydigital/author v0.1.18 h1:KEGqFr56dMjOYVQSFb43U05W0wHNnYb14V+HHr3jt8Y= 30 | github.com/boggydigital/author v0.1.18/go.mod h1:11oYKJzlZAGHPZrfZncJrYOvFpc6ctKNuPcWj4lq8ik= 31 | github.com/boggydigital/author v0.1.23 h1:zCCAc6tSdSWyAL42OUakTrqFOahMrcvX8jg1MMaxdv4= 32 | github.com/boggydigital/author v0.1.23/go.mod h1:11oYKJzlZAGHPZrfZncJrYOvFpc6ctKNuPcWj4lq8ik= 33 | github.com/boggydigital/backups v0.1.6 h1:E0YU7K/muSjmJcDtMBUP3mQN46/9kkq+HczuKxRWx14= 34 | github.com/boggydigital/backups v0.1.6/go.mod h1:k2oRf30c7FL5ZCUCVBEzDB2Cem1LevVTkVHR/WqaU7I= 35 | github.com/boggydigital/busan v0.1.1 h1:sP0vbrgazePvThZZh0pQdn9BfWkvdQGTaQLF2YQliAo= 36 | github.com/boggydigital/busan v0.1.1/go.mod h1:aqtJoRSrX5bpQrA0nS7t7Btfl0qkolJ4pd+TNslKQmU= 37 | github.com/boggydigital/clo v1.0.8 h1:/E+xA4e+tvQrtSmMHN/DeKaVEhiSI2aHDx63dJ98lCU= 38 | github.com/boggydigital/clo v1.0.8/go.mod h1:InpSSK4uBJOtOdv32DM9BYCA28MuJGb7iRFow24ubUI= 39 | github.com/boggydigital/dolo v0.2.23 h1:PD6j7/tKfjw31GUxVEN2MgK9IKOqDjSL542r75eoGZc= 40 | github.com/boggydigital/dolo v0.2.23/go.mod h1:/Z2gQ4V/IIc/9WEL2/HIUag4LnCyZZPOx1Kl9KxI7uI= 41 | github.com/boggydigital/dolo v0.2.24 h1:S7SNuCMcVdQo19TYcfBK7hbUxQbcP5GYmKXqy76F+wI= 42 | github.com/boggydigital/dolo v0.2.24/go.mod h1:/Z2gQ4V/IIc/9WEL2/HIUag4LnCyZZPOx1Kl9KxI7uI= 43 | github.com/boggydigital/kevlar v0.6.9 h1:VX1M/F6ZrLxAslADkixcsYQ6HAnqRhwMuT12cikQDZM= 44 | github.com/boggydigital/kevlar v0.6.9/go.mod h1:wlHW4oa6bLm/xVlUoVYinsJKiitkaibBW9Diht46QPo= 45 | github.com/boggydigital/nod v0.1.30 h1:/u3FE04mfH5ka1VqoS6uutTmbYbvUaEHyDDJk96uB+k= 46 | github.com/boggydigital/nod v0.1.30/go.mod h1:bzsGsnXacX7YmctBeqpY0oEQeT1KHqcSNpBhLNbKhfA= 47 | github.com/boggydigital/pathways v0.1.15 h1:oQwqikyCKBIvoQwUthivHyT2oseQu9zzuO4A2FnwHFo= 48 | github.com/boggydigital/pathways v0.1.15/go.mod h1:z7Ip2VdIJg+xhEUsfOhc9oDy31YVDAUZKlipP1AWhE0= 49 | github.com/boggydigital/redux v0.1.9 h1:SSzyraDVaxNYSeWusNiOKx3e6wUGyO3WKvc07lupQvE= 50 | github.com/boggydigital/redux v0.1.9/go.mod h1:mI9586prkmJ00FPJn2fpqBVB3ViATnmgP6yfHq95C2I= 51 | github.com/boggydigital/wits v0.2.3 h1:Z0eB+QlIA18fJmblyV6ZJQ/swPYSFhOxfgMXOQz4/c8= 52 | github.com/boggydigital/wits v0.2.3/go.mod h1:aR/z0vfMLtg0b4hcts0qiSTZcA51O8A2N3U9laqd2Lc= 53 | golang.org/x/crypto v0.42.0 h1:chiH31gIWm57EkTXpwnqf8qeuMUi0yekh6mT2AvFlqI= 54 | golang.org/x/crypto v0.42.0/go.mod h1:4+rDnOTJhQCx2q7/j6rAN5XDw8kPjeaXEUR2eL94ix8= 55 | golang.org/x/crypto v0.43.0 h1:dduJYIi3A3KOfdGOHX8AVZ/jGiyPa3IbBozJ5kNuE04= 56 | golang.org/x/crypto v0.43.0/go.mod h1:BFbav4mRNlXJL4wNeejLpWxB7wMbc79PdRGhWKncxR0= 57 | golang.org/x/crypto v0.44.0 h1:A97SsFvM3AIwEEmTBiaxPPTYpDC47w720rdiiUvgoAU= 58 | golang.org/x/crypto v0.44.0/go.mod h1:013i+Nw79BMiQiMsOPcVCB5ZIJbYkerPrGnOa00tvmc= 59 | -------------------------------------------------------------------------------- /cli/umu_configs.go: -------------------------------------------------------------------------------- 1 | package cli 2 | 3 | import ( 4 | "errors" 5 | "github.com/arelate/southern_light/wine_integration" 6 | "github.com/arelate/theo/data" 7 | "github.com/boggydigital/busan" 8 | "github.com/boggydigital/nod" 9 | "github.com/boggydigital/pathways" 10 | "github.com/boggydigital/redux" 11 | "io" 12 | "os" 13 | "path/filepath" 14 | "strings" 15 | ) 16 | 17 | const ( 18 | umuGogStore = "gog" 19 | ) 20 | 21 | type UmuConfig struct { 22 | GogId string 23 | Prefix string 24 | Proton string 25 | ExePath string 26 | Args []string 27 | } 28 | 29 | func getLatestUmuConfigsDir(rdx redux.Readable) (string, error) { 30 | 31 | runtime := wine_integration.UmuLauncher 32 | 33 | if err := rdx.MustHave(data.WineBinariesVersionsProperty); err != nil { 34 | return "", err 35 | } 36 | 37 | var latestUmuLauncherVersion string 38 | if lulv, ok := rdx.GetLastVal(data.WineBinariesVersionsProperty, runtime); ok { 39 | latestUmuLauncherVersion = lulv 40 | } 41 | 42 | if latestUmuLauncherVersion == "" { 43 | return "", errors.New("umu-launcher version not found, please run setup-wine") 44 | } 45 | 46 | umuConfigsDir, err := pathways.GetAbsRelDir(data.UmuConfigs) 47 | if err != nil { 48 | return "", err 49 | } 50 | 51 | latestUmuConfigsDir := filepath.Join(umuConfigsDir, latestUmuLauncherVersion) 52 | 53 | return latestUmuConfigsDir, nil 54 | } 55 | 56 | func getAbsUmuConfigFilename(id, exePath string, rdx redux.Readable) (string, error) { 57 | 58 | latestUmuConfigsDir, err := getLatestUmuConfigsDir(rdx) 59 | if err != nil { 60 | return "", err 61 | } 62 | 63 | _, exeFilename := filepath.Split(exePath) 64 | 65 | umuConfigPath := filepath.Join(latestUmuConfigsDir, id+"-"+busan.Sanitize(exeFilename)+".toml") 66 | 67 | return umuConfigPath, nil 68 | } 69 | 70 | func createUmuConfig(cfg *UmuConfig, rdx redux.Readable, force bool) (string, error) { 71 | 72 | umuConfigPath, err := getAbsUmuConfigFilename(cfg.GogId, cfg.ExePath, rdx) 73 | if err != nil { 74 | return "", err 75 | } 76 | 77 | if _, err = os.Stat(umuConfigPath); err == nil && !force { 78 | return umuConfigPath, nil 79 | } 80 | 81 | umuConfigDir, _ := filepath.Split(umuConfigPath) 82 | if _, err = os.Stat(umuConfigDir); os.IsNotExist(err) { 83 | if err = os.MkdirAll(umuConfigDir, 0755); err != nil { 84 | return "", err 85 | } 86 | } 87 | 88 | umuConfigFile, err := os.Create(umuConfigPath) 89 | if err != nil { 90 | return "", err 91 | } 92 | 93 | escapedArgs := make([]string, 0, len(cfg.Args)) 94 | for _, arg := range cfg.Args { 95 | //ea := strings.Replace(a, "\"", "\\\"", -1) 96 | ea := strings.Replace(arg, "\\", "\\\\", -1) 97 | ea = strings.Replace(ea, "\"", "\\\"", -1) 98 | escapedArgs = append(escapedArgs, ea) 99 | } 100 | 101 | if _, err = io.WriteString(umuConfigFile, "[umu]\n"); err != nil { 102 | return "", err 103 | } 104 | if _, err = io.WriteString(umuConfigFile, "prefix = \""+cfg.Prefix+"\"\n"); err != nil { 105 | return "", err 106 | } 107 | if _, err = io.WriteString(umuConfigFile, "proton = \""+cfg.Proton+"\"\n"); err != nil { 108 | return "", err 109 | } 110 | if _, err = io.WriteString(umuConfigFile, "exe = \""+cfg.ExePath+"\"\n"); err != nil { 111 | return "", err 112 | } 113 | if len(cfg.Args) > 0 { 114 | if _, err = io.WriteString(umuConfigFile, "launch_args = ["); err != nil { 115 | return "", err 116 | } 117 | quotedArgs := make([]string, 0, len(cfg.Args)) 118 | for _, ea := range escapedArgs { 119 | quotedArgs = append(quotedArgs, "\""+ea+"\"") 120 | } 121 | if _, err = io.WriteString(umuConfigFile, strings.Join(quotedArgs, ", ")); err != nil { 122 | return "", err 123 | } 124 | if _, err = io.WriteString(umuConfigFile, "]\n"); err != nil { 125 | return "", err 126 | } 127 | } 128 | 129 | if _, err = io.WriteString(umuConfigFile, "game_id = \""+cfg.GogId+"\"\n"); err != nil { 130 | return "", err 131 | } 132 | if _, err = io.WriteString(umuConfigFile, "store = \""+umuGogStore+"\"\n"); err != nil { 133 | return "", err 134 | } 135 | 136 | return umuConfigPath, nil 137 | } 138 | 139 | func resetUmuConfigs(rdx redux.Readable) error { 140 | 141 | rauca := nod.NewProgress("resetting umu-configs...") 142 | defer rauca.Done() 143 | 144 | latestUmuConfigsDir, err := getLatestUmuConfigsDir(rdx) 145 | if err != nil { 146 | return err 147 | } 148 | 149 | if _, err = os.Stat(latestUmuConfigsDir); os.IsNotExist(err) { 150 | return nil 151 | } 152 | 153 | lucd, err := os.Open(latestUmuConfigsDir) 154 | if err != nil { 155 | return err 156 | } 157 | 158 | relFilenames, err := lucd.Readdirnames(-1) 159 | if err != nil { 160 | return err 161 | } 162 | 163 | rauca.TotalInt(len(relFilenames)) 164 | 165 | for _, rfn := range relFilenames { 166 | if strings.HasPrefix(rfn, ".") { 167 | rauca.Increment() 168 | continue 169 | } 170 | 171 | afn := filepath.Join(latestUmuConfigsDir, rfn) 172 | if err = os.Remove(afn); err != nil { 173 | return err 174 | } 175 | 176 | rauca.Increment() 177 | } 178 | 179 | var empty bool 180 | if empty, err = osIsDirEmpty(latestUmuConfigsDir); empty && err == nil { 181 | if err = os.RemoveAll(latestUmuConfigsDir); err != nil { 182 | return err 183 | } 184 | } else if err != nil { 185 | return err 186 | } 187 | 188 | return nil 189 | } 190 | -------------------------------------------------------------------------------- /cli/connect.go: -------------------------------------------------------------------------------- 1 | package cli 2 | 3 | import ( 4 | "encoding/json" 5 | "errors" 6 | "net/http" 7 | "net/url" 8 | "time" 9 | 10 | "github.com/arelate/theo/data" 11 | "github.com/boggydigital/author" 12 | "github.com/boggydigital/nod" 13 | "github.com/boggydigital/pathways" 14 | "github.com/boggydigital/redux" 15 | ) 16 | 17 | func ConnectHandler(u *url.URL) error { 18 | 19 | q := u.Query() 20 | 21 | protocol := q.Get("protocol") 22 | address := q.Get("address") 23 | port := q.Get("port") 24 | 25 | username := q.Get("username") 26 | password := q.Get("password") 27 | 28 | reset := q.Has("reset") 29 | 30 | return Connect(protocol, address, port, username, password, reset) 31 | } 32 | 33 | func Connect( 34 | protocol, address, port string, 35 | username, password string, 36 | reset bool) error { 37 | 38 | sa := nod.Begin("connecting to the server...") 39 | defer sa.Done() 40 | 41 | reduxDir, err := pathways.GetAbsRelDir(data.Redux) 42 | if err != nil { 43 | return err 44 | } 45 | 46 | rdx, err := redux.NewWriter(reduxDir, data.ServerConnectionProperties) 47 | if err != nil { 48 | return err 49 | } 50 | 51 | if reset { 52 | if err = resetServerConnection(rdx); err != nil { 53 | return err 54 | } 55 | } 56 | 57 | connectionProperties := make(map[string][]string) 58 | 59 | if protocol != "" { 60 | connectionProperties[data.ServerProtocolProperty] = []string{protocol} 61 | } 62 | 63 | if address != "" { 64 | connectionProperties[data.ServerAddressProperty] = []string{address} 65 | } 66 | 67 | if port != "" { 68 | connectionProperties[data.ServerPortProperty] = []string{port} 69 | } 70 | 71 | if username != "" { 72 | connectionProperties[data.ServerUsernameProperty] = []string{username} 73 | } 74 | 75 | if len(connectionProperties) > 0 { 76 | if err = rdx.BatchReplaceValues(data.ServerConnectionProperties, connectionProperties); err != nil { 77 | return err 78 | } 79 | } 80 | 81 | if err = updateSessionToken(password, rdx); err != nil { 82 | return err 83 | } 84 | 85 | if err = validateSessionToken(rdx); err != nil { 86 | return err 87 | } 88 | 89 | return nil 90 | } 91 | 92 | func resetServerConnection(rdx redux.Writeable) error { 93 | rsa := nod.Begin("resetting server connection...") 94 | defer rsa.Done() 95 | 96 | if err := rdx.MustHave(data.ServerConnectionProperties); err != nil { 97 | return err 98 | } 99 | 100 | setupProperties := []string{ 101 | data.ServerProtocolProperty, 102 | data.ServerAddressProperty, 103 | data.ServerPortProperty, 104 | data.ServerUsernameProperty, 105 | data.ServerSessionToken, 106 | } 107 | 108 | if err := rdx.CutKeys(data.ServerConnectionProperties, setupProperties...); err != nil { 109 | return err 110 | } 111 | 112 | return nil 113 | } 114 | 115 | func validateSessionToken(rdx redux.Readable) error { 116 | 117 | tsa := nod.Begin("validating session token...") 118 | defer tsa.Done() 119 | 120 | if err := rdx.MustHave(data.ServerConnectionProperties); err != nil { 121 | return err 122 | } 123 | 124 | req, err := data.ServerRequest(http.MethodPost, data.ApiAuthSessionPath, nil, rdx) 125 | if err != nil { 126 | return err 127 | } 128 | 129 | resp, err := http.DefaultClient.Do(req) 130 | if err != nil { 131 | return err 132 | } 133 | defer resp.Body.Close() 134 | 135 | if resp.StatusCode == 401 { 136 | msg := "session is not valid, please connect again" 137 | tsa.EndWithResult(msg) 138 | return errors.New(msg) 139 | } 140 | 141 | if resp.StatusCode < 200 || resp.StatusCode > 299 { 142 | return errors.New(resp.Status) 143 | } 144 | 145 | var ste author.SessionTokenExpires 146 | 147 | if err = json.NewDecoder(resp.Body).Decode(&ste); err != nil { 148 | return err 149 | } 150 | 151 | utcNow := time.Now().UTC() 152 | 153 | if utcNow.Before(ste.Expires.Add(-1 * time.Hour * 24)) { 154 | tsa.EndWithResult("session is valid") 155 | return nil 156 | } else { 157 | msg := "session expires soon, run connect to update" 158 | tsa.EndWithResult(msg) 159 | return errors.New(msg) 160 | } 161 | 162 | } 163 | 164 | func updateSessionToken(password string, rdx redux.Writeable) error { 165 | rsa := nod.Begin("updating session token...") 166 | defer rsa.Done() 167 | 168 | if err := rdx.MustHave(data.ServerConnectionProperties); err != nil { 169 | return err 170 | } 171 | 172 | var username string 173 | if up, ok := rdx.GetLastVal(data.ServerConnectionProperties, data.ServerUsernameProperty); ok && up != "" { 174 | username = up 175 | } else { 176 | return errors.New("username not found") 177 | } 178 | 179 | usernamePassword := url.Values{} 180 | usernamePassword.Set(author.UsernameParam, username) 181 | usernamePassword.Set(author.PasswordParam, password) 182 | 183 | req, err := data.ServerRequest(http.MethodPost, data.ApiAuthUserPath, usernamePassword, rdx) 184 | if err != nil { 185 | return err 186 | } 187 | 188 | resp, err := http.DefaultClient.Do(req) 189 | if err != nil { 190 | return err 191 | } 192 | defer resp.Body.Close() 193 | 194 | if resp.StatusCode < 200 || resp.StatusCode > 299 { 195 | return errors.New(resp.Status) 196 | } 197 | 198 | var ste author.SessionTokenExpires 199 | 200 | if err = json.NewDecoder(resp.Body).Decode(&ste); err != nil { 201 | return err 202 | } 203 | 204 | if err = rdx.ReplaceValues(data.ServerConnectionProperties, data.ServerSessionToken, ste.Token); err != nil { 205 | return err 206 | } 207 | 208 | if err = rdx.ReplaceValues(data.ServerConnectionProperties, data.ServerSessionExpires, ste.Expires.Format(http.TimeFormat)); err != nil { 209 | return err 210 | } 211 | 212 | return nil 213 | } 214 | -------------------------------------------------------------------------------- /cli/update.go: -------------------------------------------------------------------------------- 1 | package cli 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "net/url" 7 | "strings" 8 | 9 | "github.com/arelate/southern_light/vangogh_integration" 10 | "github.com/arelate/theo/data" 11 | "github.com/boggydigital/nod" 12 | "github.com/boggydigital/pathways" 13 | "github.com/boggydigital/redux" 14 | ) 15 | 16 | func UpdateHandler(u *url.URL) error { 17 | 18 | q := u.Query() 19 | 20 | id := q.Get(vangogh_integration.IdProperty) 21 | 22 | all := q.Has("all") 23 | verbose := q.Has("verbose") 24 | force := q.Has("force") 25 | 26 | return Update(id, all, verbose, force) 27 | } 28 | 29 | func Update(id string, all, verbose, force bool) error { 30 | 31 | var updateMsg string 32 | switch all { 33 | case false: 34 | updateMsg = fmt.Sprintf("updating %s...", id) 35 | case true: 36 | updateMsg = fmt.Sprintf("updating all products...") 37 | } 38 | 39 | ua := nod.NewProgress(updateMsg) 40 | defer ua.Done() 41 | 42 | reduxDir, err := pathways.GetAbsRelDir(data.Redux) 43 | if err != nil { 44 | return err 45 | } 46 | 47 | rdx, err := redux.NewWriter(reduxDir, data.AllProperties()...) 48 | if err != nil { 49 | return err 50 | } 51 | 52 | updatedIdsInstallInfo, err := checkProductsUpdates(id, rdx, all, force) 53 | if err != nil { 54 | return err 55 | } 56 | 57 | for updatedId, installedInfoSlice := range updatedIdsInstallInfo { 58 | for _, installedInfo := range installedInfoSlice { 59 | 60 | installedInfo.verbose = verbose 61 | installedInfo.force = true // forcing installation to overwrite existing installation 62 | installedInfo.Version = "" // reset Version, so that new one could be set during installation 63 | 64 | if err = Install(updatedId, installedInfo); err != nil { 65 | return err 66 | } 67 | } 68 | } 69 | 70 | return nil 71 | } 72 | 73 | func checkProductsUpdates(id string, rdx redux.Writeable, all, force bool) (map[string][]*InstallInfo, error) { 74 | 75 | cpua := nod.NewProgress("checking for products updates...") 76 | defer cpua.Done() 77 | 78 | if err := rdx.MustHave(data.InstallInfoProperty); err != nil { 79 | return nil, err 80 | } 81 | 82 | checkIds := make([]string, 0) 83 | if id != "" { 84 | checkIds = append(checkIds, id) 85 | } 86 | 87 | if all { 88 | for installedId := range rdx.Keys(data.InstallInfoProperty) { 89 | checkIds = append(checkIds, installedId) 90 | } 91 | } 92 | 93 | cpua.TotalInt(len(checkIds)) 94 | 95 | updatedIdInstalledInfo := make(map[string][]*InstallInfo) 96 | 97 | for _, checkId := range checkIds { 98 | if uii, err := checkProductUpdates(checkId, rdx, force); err == nil && len(uii) > 0 { 99 | updatedIdInstalledInfo[checkId] = uii 100 | } else if err != nil { 101 | return nil, err 102 | } 103 | 104 | cpua.Increment() 105 | } 106 | 107 | updatedIds := make([]string, 0, len(updatedIdInstalledInfo)) 108 | for uid := range updatedIdInstalledInfo { 109 | updatedIds = append(updatedIds, uid) 110 | } 111 | 112 | if len(updatedIdInstalledInfo) > 0 { 113 | cpua.EndWithResult("found updates for: %s", strings.Join(updatedIds, ",")) 114 | } else { 115 | cpua.EndWithResult("all products are up to date") 116 | } 117 | 118 | return updatedIdInstalledInfo, nil 119 | 120 | } 121 | 122 | func checkProductUpdates(id string, rdx redux.Writeable, force bool) ([]*InstallInfo, error) { 123 | 124 | cpua := nod.Begin(" checking product updates for %s...", id) 125 | defer cpua.Done() 126 | 127 | updatedInstalledInfo := make([]*InstallInfo, 0) 128 | 129 | if installedInfoLines, ok := rdx.GetAllValues(data.InstallInfoProperty, id); ok { 130 | 131 | for _, line := range installedInfoLines { 132 | 133 | var installedInfo InstallInfo 134 | if err := json.NewDecoder(strings.NewReader(line)).Decode(&installedInfo); err != nil { 135 | return nil, err 136 | } 137 | 138 | if updated, err := isInstalledInfoUpdated(id, &installedInfo, rdx, force); updated && err == nil { 139 | updatedInstalledInfo = append(updatedInstalledInfo, &installedInfo) 140 | } else if err != nil { 141 | return nil, err 142 | } 143 | 144 | } 145 | 146 | } 147 | 148 | return updatedInstalledInfo, nil 149 | 150 | } 151 | 152 | func isInstalledInfoUpdated(id string, installedInfo *InstallInfo, rdx redux.Writeable, force bool) (bool, error) { 153 | 154 | iiiua := nod.Begin(" checking %s %s-%s version...", id, installedInfo.OperatingSystem, installedInfo.LangCode) 155 | defer iiiua.Done() 156 | 157 | latestProductDetails, err := getProductDetails(id, rdx, true) 158 | if err != nil { 159 | return false, err 160 | } 161 | 162 | installedVersion := installedInfo.Version 163 | latestVersion := productDetailsVersion(latestProductDetails, installedInfo) 164 | 165 | if installedVersion == "" && !force { 166 | iiiua.EndWithResult("cannot determine installed version") 167 | return false, nil 168 | } 169 | 170 | if latestVersion == "" && !force { 171 | iiiua.EndWithResult("cannot determine latest version") 172 | return false, nil 173 | } 174 | 175 | if installedVersion == latestVersion { 176 | iiiua.EndWithResult("already at the latest version: %s", installedVersion) 177 | return false, nil 178 | } else { 179 | iiiua.EndWithResult("found update to install: %s -> %s", installedVersion, latestVersion) 180 | return true, nil 181 | } 182 | } 183 | 184 | func productDetailsVersion(productDetails *vangogh_integration.ProductDetails, ii *InstallInfo) string { 185 | dls := productDetails.DownloadLinks. 186 | FilterOperatingSystems(ii.OperatingSystem). 187 | FilterDownloadTypes(vangogh_integration.Installer). 188 | FilterLanguageCodes(ii.LangCode) 189 | 190 | var version string 191 | for ii, dl := range dls { 192 | if ii == 0 { 193 | version = dl.Version 194 | } 195 | } 196 | 197 | return version 198 | } 199 | -------------------------------------------------------------------------------- /cli/macos_wine.go: -------------------------------------------------------------------------------- 1 | package cli 2 | 3 | import ( 4 | "errors" 5 | "github.com/arelate/southern_light/vangogh_integration" 6 | "github.com/arelate/southern_light/wine_integration" 7 | "github.com/arelate/theo/data" 8 | "github.com/boggydigital/busan" 9 | "github.com/boggydigital/nod" 10 | "github.com/boggydigital/pathways" 11 | "github.com/boggydigital/redux" 12 | "os" 13 | "os/exec" 14 | "path/filepath" 15 | "strings" 16 | ) 17 | 18 | const ( 19 | relCxAppDir = "CrossOver.app" 20 | relCxBinDir = "Contents/SharedSupport/CrossOver/bin" 21 | relCxBottleFilename = "cxbottle" 22 | relCxBottleConfFilename = "cxbottle.conf" 23 | relWineFilename = "wine" 24 | ) 25 | 26 | const defaultCxBottleTemplate = "win10_64" // CrossOver.app/Contents/SharedSupport/CrossOver/share/crossover/bottle_templates 27 | 28 | type ( 29 | wineRunFunc func(id, langCode string, rdx redux.Readable, et *execTask, force bool) error 30 | ) 31 | 32 | func macOsInitPrefix(id, langCode string, rdx redux.Readable, verbose bool) error { 33 | mipa := nod.Begin(" initializing %s prefix...", vangogh_integration.MacOS) 34 | defer mipa.Done() 35 | 36 | if err := rdx.MustHave(vangogh_integration.SlugProperty); err != nil { 37 | return err 38 | } 39 | 40 | return macOsCreateCxBottle(id, langCode, rdx, defaultCxBottleTemplate, verbose) 41 | } 42 | 43 | func macOsWineRun(id, langCode string, rdx redux.Readable, et *execTask, force bool) error { 44 | 45 | _, exeFilename := filepath.Split(et.exe) 46 | 47 | mwra := nod.Begin(" running %s with WINE, please wait...", exeFilename) 48 | defer mwra.Done() 49 | 50 | if err := rdx.MustHave(vangogh_integration.SlugProperty); err != nil { 51 | return err 52 | } 53 | 54 | if et.verbose && len(et.env) > 0 { 55 | pea := nod.Begin(" env:") 56 | pea.EndWithResult(strings.Join(et.env, " ")) 57 | } 58 | 59 | absCxBinDir, err := macOsGetAbsCxBinDir(rdx) 60 | if err != nil { 61 | return err 62 | } 63 | 64 | absWineBinPath := filepath.Join(absCxBinDir, relWineFilename) 65 | 66 | absPrefixDir, err := data.GetAbsPrefixDir(id, langCode, rdx) 67 | if err != nil { 68 | return err 69 | } 70 | 71 | if strings.HasSuffix(et.exe, ".lnk") { 72 | et.args = append([]string{"--start", et.exe}, et.args...) 73 | } else { 74 | et.args = append([]string{et.exe}, et.args...) 75 | } 76 | 77 | et.args = append([]string{"--bottle", absPrefixDir}, et.args...) 78 | 79 | cmd := exec.Command(absWineBinPath, et.args...) 80 | 81 | if et.workDir != "" { 82 | cmd.Dir = et.workDir 83 | } 84 | 85 | cmd.Env = append(os.Environ(), et.env...) 86 | 87 | if et.verbose { 88 | cmd.Stdout = os.Stdout 89 | cmd.Stderr = os.Stderr 90 | } 91 | 92 | return cmd.Run() 93 | } 94 | 95 | func macOsWineRunExecTask(et *execTask, rdx redux.Readable) error { 96 | 97 | mwra := nod.Begin(" running %s with WINE, please wait...", et.name) 98 | defer mwra.Done() 99 | 100 | if et.verbose && len(et.env) > 0 { 101 | pea := nod.Begin(" env:") 102 | pea.EndWithResult(strings.Join(et.env, " ")) 103 | } 104 | 105 | absCxBinDir, err := macOsGetAbsCxBinDir(rdx) 106 | if err != nil { 107 | return err 108 | } 109 | 110 | absWineBinPath := filepath.Join(absCxBinDir, relWineFilename) 111 | 112 | if strings.HasSuffix(et.exe, ".lnk") { 113 | et.args = append([]string{"--start", et.exe}, et.args...) 114 | } else { 115 | et.args = append([]string{et.exe}, et.args...) 116 | } 117 | 118 | et.args = append([]string{"--bottle", et.prefix}, et.args...) 119 | 120 | if et.workDir != "" { 121 | et.args = append([]string{"--workdir", et.workDir}, et.args...) 122 | } 123 | 124 | cmd := exec.Command(absWineBinPath, et.args...) 125 | 126 | if et.workDir != "" { 127 | cmd.Dir = et.workDir 128 | } 129 | 130 | cmd.Env = et.env 131 | 132 | if et.verbose { 133 | cmd.Stdout = os.Stdout 134 | cmd.Stderr = os.Stderr 135 | } 136 | 137 | return cmd.Run() 138 | } 139 | 140 | func macOsGetAbsCxBinDir(rdx redux.Readable) (string, error) { 141 | 142 | if err := rdx.MustHave(data.WineBinariesVersionsProperty); err != nil { 143 | return "", err 144 | } 145 | 146 | var latestCxVersion string 147 | if lcxv, ok := rdx.GetLastVal(data.WineBinariesVersionsProperty, wine_integration.CrossOver); ok { 148 | latestCxVersion = lcxv 149 | } 150 | 151 | if latestCxVersion == "" { 152 | return "", errors.New("CrossOver version not found, please run setup-wine") 153 | } 154 | 155 | wineBinaries, err := pathways.GetAbsRelDir(data.WineBinaries) 156 | if err != nil { 157 | return "", err 158 | } 159 | 160 | absCrossOverBinDir := filepath.Join(wineBinaries, busan.Sanitize(wine_integration.CrossOver), latestCxVersion, relCxAppDir, relCxBinDir) 161 | if _, err = os.Stat(absCrossOverBinDir); err == nil { 162 | return absCrossOverBinDir, nil 163 | } 164 | 165 | return "", os.ErrNotExist 166 | } 167 | 168 | func macOsCreateCxBottle(id, langCode string, rdx redux.Readable, template string, verbose bool) error { 169 | 170 | if err := rdx.MustHave(vangogh_integration.SlugProperty); err != nil { 171 | return err 172 | } 173 | 174 | if template == "" { 175 | template = defaultCxBottleTemplate 176 | } 177 | 178 | absCxBinDir, err := macOsGetAbsCxBinDir(rdx) 179 | if err != nil { 180 | return err 181 | } 182 | 183 | absCxBottlePath := filepath.Join(absCxBinDir, relCxBottleFilename) 184 | 185 | absPrefixDir, err := data.GetAbsPrefixDir(id, langCode, rdx) 186 | if err != nil { 187 | return err 188 | } 189 | 190 | // cxbottle --create returns error when bottle already exists 191 | if _, err = os.Stat(absPrefixDir); err == nil { 192 | 193 | // if a prefix exists, but is missing cxbottle.conf - there will be an error 194 | absCxBottleConfPath := filepath.Join(absPrefixDir, relCxBottleConfFilename) 195 | if _, err = os.Stat(absCxBottleConfPath); os.IsNotExist(err) { 196 | if _, err = os.Create(absCxBottleConfPath); err != nil { 197 | return err 198 | } 199 | } 200 | 201 | return nil 202 | } 203 | 204 | cmd := exec.Command(absCxBottlePath, "--bottle", absPrefixDir, "--create", "--template", template) 205 | 206 | if verbose { 207 | cmd.Stdout = os.Stdout 208 | cmd.Stderr = os.Stderr 209 | } 210 | 211 | return cmd.Run() 212 | } 213 | -------------------------------------------------------------------------------- /cli/validate.go: -------------------------------------------------------------------------------- 1 | package cli 2 | 3 | import ( 4 | "crypto/md5" 5 | "errors" 6 | "fmt" 7 | "net/url" 8 | "os" 9 | "path/filepath" 10 | "slices" 11 | "strings" 12 | 13 | "github.com/arelate/southern_light/vangogh_integration" 14 | "github.com/arelate/theo/data" 15 | "github.com/boggydigital/dolo" 16 | "github.com/boggydigital/nod" 17 | "github.com/boggydigital/pathways" 18 | "github.com/boggydigital/redux" 19 | ) 20 | 21 | type ValidationResult string 22 | 23 | const ( 24 | ValResMismatch = "mismatch" 25 | ValResError = "error" 26 | ValResMissingChecksum = "missing checksum" 27 | ValResFileNotFound = "file not found" 28 | ValResValid = "valid" 29 | ) 30 | 31 | var allValidationResults = []ValidationResult{ 32 | ValResMismatch, 33 | ValResError, 34 | ValResMissingChecksum, 35 | ValResFileNotFound, 36 | ValResValid, 37 | } 38 | 39 | var valResMessageTemplates = map[ValidationResult]string{ 40 | ValResMismatch: "%s files did not match expected checksum", 41 | ValResError: "%s files encountered errors during validation", 42 | ValResMissingChecksum: "%s files are missing checksums", 43 | ValResFileNotFound: "%s files were not found", 44 | ValResValid: "%s files are matching checksums", 45 | } 46 | 47 | func ValidateHandler(u *url.URL) error { 48 | 49 | q := u.Query() 50 | 51 | id := q.Get(vangogh_integration.IdProperty) 52 | 53 | os := vangogh_integration.AnyOperatingSystem 54 | if q.Has(vangogh_integration.OperatingSystemsProperty) { 55 | os = vangogh_integration.ParseOperatingSystem(q.Get(vangogh_integration.OperatingSystemsProperty)) 56 | } 57 | 58 | var langCode string 59 | if q.Has(vangogh_integration.LanguageCodeProperty) { 60 | langCode = q.Get(vangogh_integration.LanguageCodeProperty) 61 | } 62 | 63 | var downloadTypes []vangogh_integration.DownloadType 64 | if q.Has(vangogh_integration.DownloadTypeProperty) { 65 | dts := strings.Split(q.Get(vangogh_integration.DownloadTypeProperty), ",") 66 | downloadTypes = vangogh_integration.ParseManyDownloadTypes(dts) 67 | } 68 | 69 | ii := &InstallInfo{ 70 | OperatingSystem: os, 71 | LangCode: langCode, 72 | DownloadTypes: downloadTypes, 73 | } 74 | 75 | var manualUrlFilter []string 76 | if q.Has("manual-url-filter") { 77 | manualUrlFilter = strings.Split(q.Get("manual-url-filter"), ",") 78 | } 79 | 80 | reduxDir, err := pathways.GetAbsRelDir(data.Redux) 81 | if err != nil { 82 | return err 83 | } 84 | 85 | rdx, err := redux.NewWriter(reduxDir, data.AllProperties()...) 86 | if err != nil { 87 | return err 88 | } 89 | 90 | return Validate(id, ii, manualUrlFilter, rdx) 91 | } 92 | 93 | func Validate(id string, 94 | ii *InstallInfo, 95 | manualUrlFilter []string, 96 | rdx redux.Writeable) error { 97 | 98 | va := nod.NewProgress("validating downloads...") 99 | defer va.Done() 100 | 101 | productDetails, err := getProductDetails(id, rdx, false) 102 | if err != nil { 103 | return err 104 | } 105 | 106 | if mismatchedManualUrls, err := validateLinks(id, ii, manualUrlFilter, productDetails); err != nil { 107 | return err 108 | } else if len(mismatchedManualUrls) > 0 { 109 | 110 | // redownload and revalidate any manual-urls that resulted in mismatched checksums 111 | 112 | ii.force = true 113 | 114 | if err = Download(id, ii, mismatchedManualUrls, rdx); err != nil { 115 | return err 116 | } 117 | 118 | if _, err = validateLinks(id, ii, manualUrlFilter, productDetails); err != nil { 119 | return err 120 | } 121 | } 122 | 123 | return nil 124 | } 125 | 126 | func validateLinks(id string, 127 | ii *InstallInfo, 128 | manualUrlFilter []string, 129 | productDetails *vangogh_integration.ProductDetails) ([]string, error) { 130 | 131 | vla := nod.NewProgress("validating %s...", productDetails.Title) 132 | defer vla.Done() 133 | 134 | downloadsDir, err := pathways.GetAbsDir(data.Downloads) 135 | if err != nil { 136 | return nil, err 137 | } 138 | 139 | dls := productDetails.DownloadLinks. 140 | FilterOperatingSystems(ii.OperatingSystem). 141 | FilterLanguageCodes(ii.LangCode). 142 | FilterDownloadTypes(ii.DownloadTypes...) 143 | 144 | if len(dls) == 0 { 145 | return nil, errors.New("no links are matching operating params") 146 | } 147 | 148 | vla.TotalInt(len(dls)) 149 | 150 | results := make([]ValidationResult, 0, len(dls)) 151 | 152 | var mismatchedManualUrls []string 153 | 154 | for _, dl := range dls { 155 | if len(manualUrlFilter) > 0 && !slices.Contains(manualUrlFilter, dl.ManualUrl) { 156 | continue 157 | } 158 | 159 | vr, err := validateLink(id, &dl, downloadsDir) 160 | if err != nil { 161 | vla.Error(err) 162 | } 163 | 164 | if vr == ValResMismatch { 165 | mismatchedManualUrls = append(mismatchedManualUrls, dl.ManualUrl) 166 | } 167 | 168 | results = append(results, vr) 169 | } 170 | 171 | vla.EndWithResult(summarizeValidationResults(results)) 172 | 173 | return mismatchedManualUrls, nil 174 | } 175 | 176 | func validateLink(id string, link *vangogh_integration.ProductDownloadLink, downloadsDir string) (ValidationResult, error) { 177 | 178 | dla := nod.NewProgress(" - %s...", link.LocalFilename) 179 | defer dla.Done() 180 | 181 | absDownloadPath := filepath.Join(downloadsDir, id, link.LocalFilename) 182 | 183 | var stat os.FileInfo 184 | var err error 185 | 186 | if stat, err = os.Stat(absDownloadPath); os.IsNotExist(err) { 187 | dla.EndWithResult(ValResFileNotFound) 188 | return ValResFileNotFound, nil 189 | } 190 | 191 | if link.Md5 == "" { 192 | dla.EndWithResult(ValResMissingChecksum) 193 | return ValResMissingChecksum, nil 194 | } 195 | 196 | dla.Total(uint64(stat.Size())) 197 | 198 | localFile, err := os.Open(absDownloadPath) 199 | if err != nil { 200 | return ValResError, err 201 | } 202 | 203 | h := md5.New() 204 | if err = dolo.CopyWithProgress(h, localFile, dla); err != nil { 205 | return ValResError, err 206 | } 207 | 208 | computedMd5 := fmt.Sprintf("%x", h.Sum(nil)) 209 | if link.Md5 == computedMd5 { 210 | dla.EndWithResult(ValResValid) 211 | return ValResValid, nil 212 | } else { 213 | dla.EndWithResult(ValResMismatch) 214 | return ValResMismatch, nil 215 | } 216 | } 217 | 218 | func summarizeValidationResults(results []ValidationResult) string { 219 | 220 | desc := make([]string, 0) 221 | 222 | for _, vr := range allValidationResults { 223 | if slices.Contains(results, vr) { 224 | someAll := "some" 225 | if isSameResult(vr, results) { 226 | someAll = "all" 227 | } 228 | desc = append(desc, fmt.Sprintf(valResMessageTemplates[vr], someAll)) 229 | } 230 | } 231 | 232 | return strings.Join(desc, "; ") 233 | } 234 | 235 | func isSameResult(exp ValidationResult, results []ValidationResult) bool { 236 | for _, vr := range results { 237 | if vr != exp { 238 | return false 239 | } 240 | } 241 | return true 242 | } 243 | -------------------------------------------------------------------------------- /cli/prefix_support.go: -------------------------------------------------------------------------------- 1 | package cli 2 | 3 | import ( 4 | "errors" 5 | "os" 6 | "path" 7 | "path/filepath" 8 | "strings" 9 | 10 | "github.com/arelate/southern_light/gog_integration" 11 | "github.com/arelate/southern_light/vangogh_integration" 12 | "github.com/arelate/theo/data" 13 | "github.com/boggydigital/nod" 14 | "github.com/boggydigital/pathways" 15 | "github.com/boggydigital/redux" 16 | ) 17 | 18 | const ( 19 | innoSetupVerySilentArg = "/VERYSILENT" 20 | innoSetupNoRestartArg = "/NORESTART" 21 | innoSetupCloseApplicationsArg = "/CLOSEAPPLICATIONS" 22 | ) 23 | 24 | const relPrefixDriveCDir = "drive_c" 25 | 26 | func prefixGetExePath(id, langCode string, rdx redux.Readable) (string, error) { 27 | 28 | prefixName, err := data.GetPrefixName(id, rdx) 29 | if err != nil { 30 | return "", err 31 | } 32 | 33 | if ep, ok := rdx.GetLastVal(data.PrefixExeProperty, path.Join(prefixName, langCode)); ok && ep != "" { 34 | absPrefixDir, err := data.GetAbsPrefixDir(id, langCode, rdx) 35 | if err != nil { 36 | return "", err 37 | } 38 | 39 | return filepath.Join(absPrefixDir, relPrefixDriveCDir, ep), nil 40 | } 41 | 42 | exePath, err := prefixFindGogGameInfoPrimaryPlayTaskExe(id, langCode, rdx) 43 | if err != nil { 44 | return "", err 45 | } 46 | 47 | if exePath == "" { 48 | exePath, err = prefixFindGogGamesLnk(id, langCode, rdx) 49 | if err != nil { 50 | return "", err 51 | } 52 | } 53 | 54 | return exePath, nil 55 | } 56 | 57 | func prefixFindGlobFile(id, langCode string, rdx redux.Readable, globPattern string) (string, error) { 58 | 59 | if err := rdx.MustHave(vangogh_integration.SlugProperty); err != nil { 60 | return "", nil 61 | } 62 | 63 | absPrefixDir, err := data.GetAbsPrefixDir(id, langCode, rdx) 64 | if err != nil { 65 | return "", err 66 | } 67 | 68 | absPrefixDriveCDir := filepath.Join(absPrefixDir, relPrefixDriveCDir) 69 | 70 | matches, err := filepath.Glob(filepath.Join(absPrefixDriveCDir, globPattern)) 71 | if err != nil { 72 | return "", err 73 | } 74 | 75 | filteredMatches := make([]string, 0, len(matches)) 76 | for _, match := range matches { 77 | if _, filename := filepath.Split(match); strings.HasPrefix(filename, ".") { 78 | continue 79 | } 80 | filteredMatches = append(filteredMatches, match) 81 | } 82 | 83 | if len(filteredMatches) == 1 { 84 | 85 | if _, err = os.Stat(filteredMatches[0]); err == nil { 86 | return filteredMatches[0], nil 87 | } else if os.IsNotExist(err) { 88 | return "", nil 89 | } else { 90 | return "", err 91 | } 92 | 93 | } 94 | 95 | return "", nil 96 | } 97 | 98 | func prefixFindGogGameInstallPath(id, langCode string, rdx redux.Readable) (string, error) { 99 | fi := nod.Begin(" finding install path...") 100 | defer fi.Done() 101 | 102 | return prefixFindGlobFile(id, langCode, rdx, gogGameInstallDir) 103 | } 104 | 105 | func prefixFindGogGameInfo(id, langCode string, rdx redux.Readable) (string, error) { 106 | fpggi := nod.Begin(" finding goggame-%s.info...", id) 107 | defer fpggi.Done() 108 | 109 | return prefixFindGlobFile(id, langCode, rdx, strings.Replace(gogGameInfoGlobTemplate, "{id}", id, -1)) 110 | } 111 | 112 | func prefixFindGogGamesLnk(id, langCode string, rdx redux.Readable) (string, error) { 113 | fpl := nod.Begin(" finding .lnk...") 114 | defer fpl.Done() 115 | 116 | return prefixFindGlobFile(id, langCode, rdx, gogGameLnkGlob) 117 | } 118 | 119 | func prefixFindGogGameInfoPrimaryPlayTaskExe(id, langCode string, rdx redux.Readable) (string, error) { 120 | 121 | absGogGameInfoPath, err := prefixFindGogGameInfo(id, langCode, rdx) 122 | if err != nil { 123 | return "", err 124 | } 125 | 126 | gogGameInfo, err := gog_integration.GetGogGameInfo(absGogGameInfoPath) 127 | if err != nil { 128 | return "", err 129 | } 130 | 131 | var relExePath string 132 | ppt, err := gogGameInfo.GetPlayTask("") 133 | if err != nil { 134 | return "", err 135 | } 136 | 137 | relExePath = ppt.Path 138 | 139 | if relExePath == "" { 140 | return "", errors.New("cannot determine primary or first playTask for " + id) 141 | } 142 | 143 | absExeDir, _ := filepath.Split(absGogGameInfoPath) 144 | 145 | return filepath.Join(absExeDir, relExePath), nil 146 | } 147 | 148 | func prefixInit(id string, langCode string, rdx redux.Readable, verbose bool) error { 149 | 150 | cpa := nod.Begin("initializing prefix for %s...", id) 151 | defer cpa.Done() 152 | 153 | switch data.CurrentOs() { 154 | case vangogh_integration.MacOS: 155 | return macOsInitPrefix(id, langCode, rdx, verbose) 156 | case vangogh_integration.Linux: 157 | return linuxInitPrefix(id, langCode, rdx, verbose) 158 | default: 159 | return data.CurrentOs().ErrUnsupported() 160 | } 161 | } 162 | 163 | func prefixInstallProduct(id string, dls vangogh_integration.ProductDownloadLinks, ii *InstallInfo, rdx redux.Writeable) error { 164 | 165 | currentOs := data.CurrentOs() 166 | 167 | wipa := nod.Begin("installing %s for %s...", id, vangogh_integration.Windows) 168 | defer wipa.Done() 169 | 170 | downloadsDir, err := pathways.GetAbsDir(data.Downloads) 171 | if err != nil { 172 | return err 173 | } 174 | 175 | productDetails, err := getProductDetails(id, rdx, ii.force) 176 | if err != nil { 177 | return err 178 | } 179 | 180 | installedAppsDir, err := pathways.GetAbsDir(data.InstalledApps) 181 | if err != nil { 182 | return err 183 | } 184 | 185 | if err = hasFreeSpaceForProduct(productDetails, installedAppsDir, ii, nil); err != nil { 186 | return err 187 | } 188 | 189 | var currentOsWineRun wineRunFunc 190 | switch currentOs { 191 | case vangogh_integration.MacOS: 192 | currentOsWineRun = macOsWineRun 193 | case vangogh_integration.Linux: 194 | currentOsWineRun = linuxProtonRun 195 | default: 196 | return currentOs.ErrUnsupported() 197 | } 198 | 199 | for _, link := range dls { 200 | 201 | if linkExt := filepath.Ext(link.LocalFilename); linkExt != exeExt { 202 | continue 203 | } 204 | 205 | absInstallerPath := filepath.Join(downloadsDir, id, link.LocalFilename) 206 | 207 | et := &execTask{ 208 | exe: absInstallerPath, 209 | workDir: downloadsDir, 210 | args: []string{innoSetupVerySilentArg, innoSetupNoRestartArg, innoSetupCloseApplicationsArg}, 211 | env: ii.Env, 212 | verbose: ii.verbose, 213 | } 214 | 215 | if err = currentOsWineRun(id, ii.LangCode, rdx, et, ii.force); err != nil { 216 | return err 217 | } 218 | } 219 | 220 | return nil 221 | } 222 | 223 | func prefixCreateInventory(id, langCode string, rdx redux.Readable, utcTime int64) error { 224 | 225 | cpifma := nod.Begin(" creating installed files inventory...") 226 | defer cpifma.Done() 227 | 228 | absPrefixDir, err := data.GetAbsPrefixDir(id, langCode, rdx) 229 | if err != nil { 230 | return err 231 | } 232 | 233 | return createInventory(absPrefixDir, id, langCode, vangogh_integration.Windows, rdx, utcTime) 234 | } 235 | 236 | func prefixReveal(id string, langCode string) error { 237 | 238 | rpa := nod.Begin("revealing prefix for %s...", id) 239 | defer rpa.Done() 240 | 241 | reduxDir, err := pathways.GetAbsRelDir(data.Redux) 242 | if err != nil { 243 | return err 244 | } 245 | 246 | rdx, err := redux.NewReader(reduxDir, vangogh_integration.SlugProperty) 247 | if err != nil { 248 | return err 249 | } 250 | 251 | absPrefixDir, err := data.GetAbsPrefixDir(id, langCode, rdx) 252 | if err != nil { 253 | return err 254 | } 255 | 256 | if _, err = os.Stat(absPrefixDir); os.IsNotExist(err) { 257 | rpa.EndWithResult("not found") 258 | return nil 259 | } 260 | 261 | absPrefixDriveCPath := filepath.Join(absPrefixDir, relPrefixDriveCDir, gogGamesDir) 262 | 263 | return currentOsReveal(absPrefixDriveCPath) 264 | } 265 | -------------------------------------------------------------------------------- /cli/install.go: -------------------------------------------------------------------------------- 1 | package cli 2 | 3 | import ( 4 | "errors" 5 | "maps" 6 | "net/url" 7 | "slices" 8 | "strings" 9 | "time" 10 | 11 | "github.com/arelate/southern_light/vangogh_integration" 12 | "github.com/arelate/theo/data" 13 | "github.com/boggydigital/nod" 14 | "github.com/boggydigital/pathways" 15 | "github.com/boggydigital/redux" 16 | ) 17 | 18 | func InstallHandler(u *url.URL) error { 19 | 20 | q := u.Query() 21 | 22 | id := q.Get(vangogh_integration.IdProperty) 23 | 24 | os := vangogh_integration.AnyOperatingSystem 25 | if q.Has(vangogh_integration.OperatingSystemsProperty) { 26 | os = vangogh_integration.ParseOperatingSystem(q.Get(vangogh_integration.OperatingSystemsProperty)) 27 | } 28 | 29 | var langCode string 30 | if q.Has(vangogh_integration.LanguageCodeProperty) { 31 | langCode = q.Get(vangogh_integration.LanguageCodeProperty) 32 | } 33 | 34 | var downloadTypes []vangogh_integration.DownloadType 35 | if q.Has(vangogh_integration.DownloadTypeProperty) { 36 | dts := strings.Split(q.Get(vangogh_integration.DownloadTypeProperty), ",") 37 | downloadTypes = vangogh_integration.ParseManyDownloadTypes(dts) 38 | } 39 | 40 | ii := &InstallInfo{ 41 | OperatingSystem: os, 42 | LangCode: langCode, 43 | DownloadTypes: downloadTypes, 44 | KeepDownloads: q.Has("keep-downloads"), 45 | NoSteamShortcut: q.Has("no-steam-shortcut"), 46 | UseSteamAssets: q.Has("steam-assets"), 47 | reveal: q.Has("reveal"), 48 | verbose: q.Has("verbose"), 49 | force: q.Has("force"), 50 | } 51 | 52 | if q.Has("env") { 53 | ii.Env = strings.Split(q.Get("env"), ",") 54 | } 55 | 56 | return Install(id, ii) 57 | } 58 | 59 | func Install(id string, ii *InstallInfo) error { 60 | 61 | ia := nod.Begin("installing %s...", id) 62 | defer ia.Done() 63 | 64 | if len(ii.DownloadTypes) == 1 && ii.DownloadTypes[0] == vangogh_integration.AnyDownloadType { 65 | ii.DownloadTypes = []vangogh_integration.DownloadType{vangogh_integration.Installer, vangogh_integration.DLC} 66 | } 67 | 68 | reduxDir, err := pathways.GetAbsRelDir(data.Redux) 69 | if err != nil { 70 | return err 71 | } 72 | 73 | rdx, err := redux.NewWriter(reduxDir, data.AllProperties()...) 74 | if err != nil { 75 | return err 76 | } 77 | 78 | printInstallInfoParams(ii, true, id) 79 | 80 | // always getting the latest product details for install purposes 81 | productDetails, err := getProductDetails(id, rdx, true) 82 | if err != nil { 83 | return err 84 | } 85 | 86 | switch productDetails.ProductType { 87 | case vangogh_integration.DlcProductType: 88 | ia.EndWithResult("install %s required product(s) to get this downloadable content", strings.Join(productDetails.RequiresGames, ",")) 89 | return nil 90 | case vangogh_integration.PackProductType: 91 | ia.EndWithResult("installing product(s) included in this pack: %s", strings.Join(productDetails.IncludesGames, ",")) 92 | for _, includedId := range productDetails.IncludesGames { 93 | if err = Install(includedId, ii); err != nil { 94 | return err 95 | } 96 | } 97 | return nil 98 | case vangogh_integration.GameProductType: 99 | // do nothing 100 | default: 101 | return errors.New("unknown product type " + productDetails.ProductType) 102 | } 103 | 104 | if err = resolveInstallInfo(id, ii, productDetails, rdx, currentOsThenWindows); err != nil { 105 | return err 106 | } 107 | 108 | ii.AddProductDetails(productDetails) 109 | 110 | // don't check existing installations for DLCs, Extras 111 | if slices.Contains(ii.DownloadTypes, vangogh_integration.Installer) && !ii.force { 112 | 113 | if installedInfoLines, ok := rdx.GetAllValues(data.InstallInfoProperty, id); ok { 114 | 115 | installInfo, _, err := matchInstallInfo(ii, installedInfoLines...) 116 | if err != nil { 117 | return err 118 | } 119 | 120 | if installInfo != nil { 121 | ia.EndWithResult("product %s is already installed", id) 122 | return nil 123 | } else { 124 | return err 125 | } 126 | 127 | } 128 | 129 | } 130 | 131 | if err = BackupMetadata(); err != nil { 132 | return err 133 | } 134 | 135 | if err = Download(id, ii, nil, rdx); err != nil { 136 | return err 137 | } 138 | 139 | if err = Validate(id, ii, nil, rdx); err != nil { 140 | return err 141 | } 142 | 143 | if err = osInstallProduct(id, ii, productDetails, rdx); err != nil { 144 | return err 145 | } 146 | 147 | if !ii.NoSteamShortcut { 148 | 149 | sgo := &steamGridOptions{ 150 | useSteamAssets: ii.UseSteamAssets, 151 | logoPosition: defaultLogoPosition(), 152 | } 153 | 154 | if err = addSteamShortcut(id, ii.OperatingSystem, ii.LangCode, sgo, rdx, ii.force); err != nil { 155 | return err 156 | } 157 | } 158 | 159 | if !ii.KeepDownloads { 160 | if err = RemoveDownloads(id, ii, rdx); err != nil { 161 | return err 162 | } 163 | } 164 | 165 | if err = pinInstallInfo(id, ii, rdx); err != nil { 166 | return err 167 | } 168 | 169 | idInstalledDate := map[string][]string{id: {time.Now().UTC().Format(time.RFC3339)}} 170 | if err = rdx.BatchReplaceValues(data.InstallDateProperty, idInstalledDate); err != nil { 171 | return err 172 | } 173 | 174 | if ii.reveal { 175 | if err = revealInstalled(id, ii); err != nil { 176 | return err 177 | } 178 | } 179 | 180 | return nil 181 | } 182 | 183 | func osInstallProduct(id string, ii *InstallInfo, productDetails *vangogh_integration.ProductDetails, rdx redux.Writeable) error { 184 | 185 | start := time.Now().UTC().Unix() 186 | 187 | coipa := nod.Begin("installing %s %s-%s...", id, ii.OperatingSystem, ii.LangCode) 188 | defer coipa.Done() 189 | 190 | if err := rdx.MustHave(vangogh_integration.SlugProperty); err != nil { 191 | return err 192 | } 193 | 194 | dls := productDetails.DownloadLinks. 195 | FilterOperatingSystems(ii.OperatingSystem). 196 | FilterLanguageCodes(ii.LangCode). 197 | FilterDownloadTypes(ii.DownloadTypes...) 198 | 199 | if len(dls) == 0 { 200 | coipa.EndWithResult("no links are matching install params") 201 | return nil 202 | } 203 | 204 | dlcNames := make(map[string]any) 205 | 206 | for _, dl := range dls { 207 | if ii.OperatingSystem != dl.OperatingSystem || 208 | ii.LangCode != dl.LanguageCode { 209 | continue 210 | } 211 | if dl.DownloadType == vangogh_integration.DLC { 212 | dlcNames[dl.Name] = nil 213 | } 214 | } 215 | 216 | if len(dlcNames) > 0 { 217 | ii.DownloadableContent = slices.Collect(maps.Keys(dlcNames)) 218 | } 219 | 220 | installedAppsDir, err := pathways.GetAbsDir(data.InstalledApps) 221 | if err != nil { 222 | return err 223 | } 224 | 225 | if err = hasFreeSpaceForProduct(productDetails, installedAppsDir, ii, nil); err != nil { 226 | return err 227 | } 228 | 229 | switch ii.OperatingSystem { 230 | case vangogh_integration.MacOS: 231 | 232 | if err = macOsInstallProduct(id, dls, rdx, ii.force); err != nil { 233 | return err 234 | } 235 | 236 | case vangogh_integration.Linux: 237 | 238 | if err = linuxInstallProduct(id, dls, rdx); err != nil { 239 | return err 240 | } 241 | 242 | case vangogh_integration.Windows: 243 | 244 | switch data.CurrentOs() { 245 | case vangogh_integration.Windows: 246 | 247 | if err = windowsInstallProduct(id, dls, rdx, ii.force); err != nil { 248 | return err 249 | } 250 | 251 | default: 252 | 253 | if err = prefixInit(id, ii.LangCode, rdx, ii.verbose); err != nil { 254 | return err 255 | } 256 | 257 | if err = prefixInstallProduct(id, dls, ii, rdx); err != nil { 258 | return err 259 | } 260 | 261 | if err = prefixCreateInventory(id, ii.LangCode, rdx, start); err != nil { 262 | return err 263 | } 264 | 265 | if err = prefixDefaultEnv(id, ii.LangCode, rdx); err != nil { 266 | return err 267 | } 268 | 269 | } 270 | default: 271 | return ii.OperatingSystem.ErrUnsupported() 272 | } 273 | 274 | return nil 275 | } 276 | -------------------------------------------------------------------------------- /cli/install_info.go: -------------------------------------------------------------------------------- 1 | package cli 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "errors" 7 | "fmt" 8 | "slices" 9 | "strings" 10 | 11 | "github.com/arelate/southern_light/vangogh_integration" 12 | "github.com/arelate/theo/data" 13 | "github.com/boggydigital/nod" 14 | "github.com/boggydigital/redux" 15 | ) 16 | 17 | const defaultLangCode = "en" 18 | 19 | type resolutionPolicy int 20 | 21 | const ( 22 | currentOsThenWindows resolutionPolicy = iota 23 | installedOperatingSystem 24 | installedLangCode 25 | ) 26 | 27 | type InstallInfo struct { 28 | OperatingSystem vangogh_integration.OperatingSystem `json:"os"` 29 | LangCode string `json:"lang-code"` 30 | DownloadTypes []vangogh_integration.DownloadType `json:"download-types"` 31 | DownloadableContent []string `json:"dlc"` 32 | Version string `json:"version"` 33 | EstimatedBytes int64 `json:"estimated-bytes"` 34 | KeepDownloads bool `json:"keep-downloads"` 35 | NoSteamShortcut bool `json:"no-steam-shortcut"` 36 | UseSteamAssets bool `json:"use-steam-assets"` 37 | Env []string `json:"env"` 38 | reveal bool // won't be serialized 39 | verbose bool // won't be serialized 40 | force bool // won't be serialized 41 | } 42 | 43 | func (ii *InstallInfo) AddProductDetails(pd *vangogh_integration.ProductDetails) { 44 | 45 | dls := pd.DownloadLinks. 46 | FilterOperatingSystems(ii.OperatingSystem). 47 | FilterLanguageCodes(ii.LangCode). 48 | FilterDownloadTypes(ii.DownloadTypes...) 49 | 50 | ii.EstimatedBytes = 0 51 | for _, dl := range dls { 52 | if ii.Version == "" && dl.DownloadType == vangogh_integration.Installer { 53 | ii.Version = dl.Version 54 | } 55 | ii.EstimatedBytes += dl.EstimatedBytes 56 | } 57 | } 58 | 59 | func matchInstallInfo(ii *InstallInfo, lines ...string) (*InstallInfo, string, error) { 60 | for _, line := range lines { 61 | var installedInfo InstallInfo 62 | if err := json.NewDecoder(strings.NewReader(line)).Decode(&installedInfo); err != nil { 63 | return nil, "", err 64 | } 65 | 66 | if installedInfo.OperatingSystem == ii.OperatingSystem && installedInfo.LangCode == ii.LangCode { 67 | return &installedInfo, line, nil 68 | } 69 | } 70 | return nil, "", nil 71 | } 72 | 73 | func pinInstallInfo(id string, ii *InstallInfo, rdx redux.Writeable) error { 74 | 75 | piia := nod.Begin("pinning install info for %s...", id) 76 | defer piia.Done() 77 | 78 | if err := rdx.MustHave(data.InstallInfoProperty); err != nil { 79 | return err 80 | } 81 | 82 | if err := unpinInstallInfo(id, ii, rdx); err != nil { 83 | return err 84 | } 85 | 86 | buf := bytes.NewBuffer(nil) 87 | if err := json.NewEncoder(buf).Encode(ii); err != nil { 88 | return err 89 | } 90 | 91 | return rdx.BatchAddValues(data.InstallInfoProperty, map[string][]string{id: {buf.String()}}) 92 | } 93 | 94 | func unpinInstallInfo(id string, ii *InstallInfo, rdx redux.Writeable) error { 95 | 96 | uiia := nod.Begin(" unpinning install info...") 97 | defer uiia.Done() 98 | 99 | if err := rdx.MustHave(data.InstallInfoProperty); err != nil { 100 | return err 101 | } 102 | 103 | if installedInfoLines, ok := rdx.GetAllValues(data.InstallInfoProperty, id); ok { 104 | 105 | _, installedInfoLine, err := matchInstallInfo(ii, installedInfoLines...) 106 | if err != nil { 107 | return err 108 | } 109 | 110 | if err = rdx.CutValues(data.InstallInfoProperty, id, installedInfoLine); err != nil { 111 | return err 112 | } 113 | 114 | } else { 115 | uiia.EndWithResult("install info not found for %s %s-%s", id, ii.OperatingSystem, ii.LangCode) 116 | } 117 | 118 | return nil 119 | } 120 | 121 | func installedInfoOperatingSystem(id string, rdx redux.Readable) (vangogh_integration.OperatingSystem, error) { 122 | 123 | iiosa := nod.Begin(" checking installed operating system for %s...", id) 124 | defer iiosa.Done() 125 | 126 | if err := rdx.MustHave(data.InstallInfoProperty); err != nil { 127 | return vangogh_integration.AnyOperatingSystem, err 128 | } 129 | 130 | if installedInfoLines, ok := rdx.GetAllValues(data.InstallInfoProperty, id); ok { 131 | 132 | switch len(installedInfoLines) { 133 | case 0: 134 | return vangogh_integration.AnyOperatingSystem, errors.New("zero length installed info for " + id) 135 | default: 136 | 137 | distinctOs := make([]vangogh_integration.OperatingSystem, 0) 138 | 139 | for _, line := range installedInfoLines { 140 | 141 | var ii InstallInfo 142 | if err := json.NewDecoder(strings.NewReader(line)).Decode(&ii); err != nil { 143 | return vangogh_integration.AnyOperatingSystem, err 144 | } 145 | 146 | if !slices.Contains(distinctOs, ii.OperatingSystem) { 147 | distinctOs = append(distinctOs, ii.OperatingSystem) 148 | } 149 | 150 | } 151 | 152 | switch len(distinctOs) { 153 | case 0: 154 | return vangogh_integration.AnyOperatingSystem, errors.New("no supported operating system for " + id) 155 | case 1: 156 | return distinctOs[0], nil 157 | default: 158 | return vangogh_integration.AnyOperatingSystem, errors.New("please specify operating system for " + id) 159 | } 160 | 161 | } 162 | } else { 163 | return vangogh_integration.AnyOperatingSystem, errors.New("no installation found for " + id) 164 | } 165 | } 166 | 167 | func installedInfoLangCode(id string, operatingSystem vangogh_integration.OperatingSystem, rdx redux.Readable) (string, error) { 168 | iilca := nod.Begin(" checking installed language code for %s...", id) 169 | defer iilca.Done() 170 | 171 | if installedInfoLines, ok := rdx.GetAllValues(data.InstallInfoProperty, id); ok { 172 | 173 | switch len(installedInfoLines) { 174 | case 0: 175 | return "", errors.New("zero length installed info for " + id) 176 | default: 177 | 178 | distinctLangCodes := make([]string, 0) 179 | 180 | for _, line := range installedInfoLines { 181 | 182 | var ii InstallInfo 183 | if err := json.NewDecoder(strings.NewReader(line)).Decode(&ii); err != nil { 184 | return "", err 185 | } 186 | 187 | if ii.OperatingSystem != operatingSystem { 188 | continue 189 | } 190 | 191 | if !slices.Contains(distinctLangCodes, ii.LangCode) { 192 | distinctLangCodes = append(distinctLangCodes, ii.LangCode) 193 | } 194 | 195 | } 196 | 197 | switch len(distinctLangCodes) { 198 | case 0: 199 | return "", errors.New("no supported language code system for " + id) 200 | case 1: 201 | return distinctLangCodes[0], nil 202 | default: 203 | return "", errors.New("please specify language code for " + id) 204 | } 205 | 206 | } 207 | } else { 208 | return "", errors.New("no installation found for " + id) 209 | } 210 | } 211 | 212 | func resolveInstallInfo(id string, installInfo *InstallInfo, productDetails *vangogh_integration.ProductDetails, rdx redux.Writeable, policies ...resolutionPolicy) error { 213 | 214 | nod.Log("resolveInstallInfo: policies %v", policies) 215 | 216 | if installInfo.OperatingSystem == vangogh_integration.AnyOperatingSystem { 217 | 218 | nod.Log("resolveInstallInfo: resolving %s=%s...", 219 | vangogh_integration.OperatingSystemsProperty, 220 | installInfo.OperatingSystem) 221 | 222 | if slices.Contains(policies, currentOsThenWindows) { 223 | 224 | if productDetails == nil { 225 | return errors.New("product details are required to resolve install info") 226 | } 227 | 228 | if slices.Contains(productDetails.OperatingSystems, data.CurrentOs()) { 229 | installInfo.OperatingSystem = data.CurrentOs() 230 | } else if slices.Contains(productDetails.OperatingSystems, vangogh_integration.Windows) { 231 | installInfo.OperatingSystem = vangogh_integration.Windows 232 | } else { 233 | unsupportedOsMsg := fmt.Sprintf("product doesn't support %s or %s, only %v", 234 | data.CurrentOs(), vangogh_integration.Windows, productDetails.OperatingSystems) 235 | return errors.New(unsupportedOsMsg) 236 | } 237 | 238 | } else if slices.Contains(policies, installedOperatingSystem) { 239 | 240 | installedOs, err := installedInfoOperatingSystem(id, rdx) 241 | if err != nil { 242 | return err 243 | } 244 | 245 | installInfo.OperatingSystem = installedOs 246 | } 247 | 248 | nod.Log("resolveInstallInfo: resolved %s=%s", 249 | vangogh_integration.OperatingSystemsProperty, 250 | installInfo.OperatingSystem) 251 | } 252 | 253 | if len(installInfo.DownloadTypes) == 0 { 254 | 255 | defaultDownloadTypes := []vangogh_integration.DownloadType{ 256 | vangogh_integration.Installer, 257 | vangogh_integration.DLC, 258 | } 259 | 260 | nod.Log("resolveInstallInfo: resolved %s=%v", 261 | vangogh_integration.DownloadTypeProperty, 262 | defaultDownloadTypes) 263 | 264 | installInfo.DownloadTypes = defaultDownloadTypes 265 | } 266 | 267 | if installInfo.LangCode == "" { 268 | 269 | nod.Log("resolveInstallInfo: resolving %s...", 270 | vangogh_integration.LanguageCodeProperty) 271 | 272 | if slices.Contains(policies, installedLangCode) { 273 | 274 | if lc, err := installedInfoLangCode(id, installInfo.OperatingSystem, rdx); err == nil { 275 | installInfo.LangCode = lc 276 | } else { 277 | return err 278 | } 279 | 280 | } else { 281 | installInfo.LangCode = defaultLangCode 282 | } 283 | 284 | nod.Log("resolveInstallInfo: resolved %s=%s", 285 | vangogh_integration.LanguageCodeProperty, 286 | installInfo.LangCode) 287 | } 288 | 289 | return nil 290 | } 291 | 292 | func printInstallInfoParams(ii *InstallInfo, noPatches bool, ids ...string) { 293 | vangogh_integration.PrintParams(ids, 294 | []vangogh_integration.OperatingSystem{ii.OperatingSystem}, 295 | []string{ii.LangCode}, 296 | ii.DownloadTypes, 297 | noPatches) 298 | } 299 | -------------------------------------------------------------------------------- /cli/linux_support.go: -------------------------------------------------------------------------------- 1 | package cli 2 | 3 | import ( 4 | "bytes" 5 | "errors" 6 | "os" 7 | "os/exec" 8 | "path/filepath" 9 | "slices" 10 | "strconv" 11 | "strings" 12 | 13 | "github.com/arelate/southern_light/gog_integration" 14 | "github.com/arelate/southern_light/vangogh_integration" 15 | "github.com/arelate/theo/data" 16 | "github.com/boggydigital/nod" 17 | "github.com/boggydigital/pathways" 18 | "github.com/boggydigital/redux" 19 | ) 20 | 21 | const ( 22 | desktopGlob = "*.desktop" 23 | mojosetupDir = ".mojosetup" 24 | ) 25 | const relLinuxGogGameInfoDir = "game" 26 | 27 | const linuxStartShFilename = "start.sh" 28 | 29 | const shExt = ".sh" 30 | 31 | func linuxInstallProduct(id string, 32 | dls vangogh_integration.ProductDownloadLinks, 33 | rdx redux.Writeable) error { 34 | 35 | lia := nod.Begin("installing %s for %s...", id, vangogh_integration.Linux) 36 | defer lia.Done() 37 | 38 | if err := rdx.MustHave(vangogh_integration.SlugProperty); err != nil { 39 | return err 40 | } 41 | 42 | downloadsDir, err := pathways.GetAbsDir(data.Downloads) 43 | if err != nil { 44 | return err 45 | } 46 | 47 | for _, link := range dls { 48 | 49 | if filepath.Ext(link.LocalFilename) != shExt { 50 | continue 51 | } 52 | 53 | absInstallerPath := filepath.Join(downloadsDir, id, link.LocalFilename) 54 | 55 | if _, err = os.Stat(absInstallerPath); err != nil { 56 | return err 57 | } 58 | 59 | absInstalledPath, err := osInstalledPath(id, vangogh_integration.Linux, link.LanguageCode, rdx) 60 | if err != nil { 61 | return err 62 | } 63 | 64 | if err = linuxPostDownloadActions(id, &link); err != nil { 65 | return err 66 | } 67 | 68 | preInstallDesktopFiles, err := linuxSnapshotDesktopFiles() 69 | if err != nil { 70 | return err 71 | } 72 | 73 | if err = linuxExecuteInstaller(absInstallerPath, absInstalledPath); err != nil { 74 | return err 75 | } 76 | 77 | postInstallDesktopFiles, err := linuxSnapshotDesktopFiles() 78 | if err != nil { 79 | return err 80 | } 81 | 82 | for _, pidf := range postInstallDesktopFiles { 83 | if slices.Contains(preInstallDesktopFiles, pidf) { 84 | continue 85 | } 86 | 87 | if err = os.Remove(pidf); err != nil { 88 | return err 89 | } 90 | } 91 | 92 | mojosetupProductDir := filepath.Join(absInstalledPath, mojosetupDir) 93 | if _, err = os.Stat(mojosetupProductDir); err == nil { 94 | if err := os.RemoveAll(mojosetupProductDir); err != nil { 95 | return err 96 | } 97 | } 98 | } 99 | 100 | return nil 101 | } 102 | 103 | func linuxExecuteInstaller(absInstallerPath, productInstalledAppDir string) error { 104 | 105 | _, fp := filepath.Split(absInstallerPath) 106 | 107 | leia := nod.Begin(" executing %s, please wait...", fp) 108 | defer leia.Done() 109 | 110 | // https://www.reddit.com/r/linux_gaming/comments/42l258/fully_automated_gog_games_install_howto/ 111 | // tl;dr; those flags are required, but not sufficient. Installing installer and then DLC will 112 | // normally trigger additional prompts. Details: 113 | // Note how linuxSnapshotDesktopFiles is used pre- and post- install to remove 114 | // .desktop files created by the installer. This is notable because if those files are not 115 | // removed and DLCs are installed they will attempt to create the same files and will ask 116 | // to confirm to overwrite, interrupting automated installation. 117 | cmd := exec.Command(absInstallerPath, "--", "--i-agree-to-all-licenses", "--noreadme", "--nooptions", "--noprompt", "--destination", productInstalledAppDir) 118 | cmd.Stdout = os.Stdout 119 | cmd.Stderr = os.Stderr 120 | 121 | return cmd.Run() 122 | } 123 | 124 | func linuxPostDownloadActions(id string, link *vangogh_integration.ProductDownloadLink) error { 125 | 126 | lpda := nod.Begin(" performing %s post-download actions for %s...", vangogh_integration.Linux, id) 127 | defer lpda.Done() 128 | 129 | if data.CurrentOs() != vangogh_integration.Linux { 130 | return errors.New("post-download Linux actions are only supported on Linux") 131 | } 132 | 133 | downloadsDir, err := pathways.GetAbsDir(data.Downloads) 134 | if err != nil { 135 | return err 136 | } 137 | 138 | productInstallerPath := filepath.Join(downloadsDir, id, link.LocalFilename) 139 | 140 | return chmodExecutable(productInstallerPath) 141 | } 142 | 143 | func chmodExecutable(path string) error { 144 | 145 | cea := nod.Begin(" setting executable attribute...") 146 | defer cea.Done() 147 | 148 | // chmod +x path/to/file 149 | cmd := exec.Command("chmod", "+x", path) 150 | cmd.Stdout = os.Stdout 151 | cmd.Stderr = os.Stderr 152 | 153 | return cmd.Run() 154 | } 155 | 156 | func linuxSnapshotDesktopFiles() ([]string, error) { 157 | 158 | desktopFiles := make([]string, 0) 159 | 160 | uhd, err := os.UserHomeDir() 161 | if err != nil { 162 | return nil, err 163 | } 164 | 165 | desktopDir := filepath.Join(uhd, "Desktop") 166 | 167 | udhd, err := data.UserDataHomeDir() 168 | if err != nil { 169 | return nil, err 170 | } 171 | 172 | applicationsDir := filepath.Join(udhd, "applications") 173 | 174 | for _, dir := range []string{desktopDir, applicationsDir} { 175 | 176 | globPath := filepath.Join(dir, desktopGlob) 177 | matches, err := filepath.Glob(globPath) 178 | if err != nil { 179 | return nil, err 180 | } 181 | 182 | desktopFiles = append(desktopFiles, matches...) 183 | } 184 | 185 | return desktopFiles, nil 186 | } 187 | 188 | func linuxReveal(path string) error { 189 | cmd := exec.Command("xdg-open", path) 190 | return cmd.Run() 191 | } 192 | 193 | func nixRunExecTask(et *execTask) error { 194 | 195 | nreta := nod.Begin(" running %s...", et.name) 196 | defer nreta.Done() 197 | 198 | cmd := exec.Command(et.exe, et.args...) 199 | cmd.Dir = et.workDir 200 | 201 | if et.verbose { 202 | cmd.Stdout = os.Stdout 203 | cmd.Stderr = os.Stderr 204 | } 205 | 206 | for _, e := range et.env { 207 | cmd.Env = append(cmd.Env, e) 208 | } 209 | 210 | return cmd.Run() 211 | } 212 | 213 | func linuxFindStartSh(id, langCode string, rdx redux.Readable) (string, error) { 214 | 215 | absInstalledPath, err := osInstalledPath(id, vangogh_integration.Linux, langCode, rdx) 216 | if err != nil { 217 | return "", err 218 | } 219 | 220 | absStartShPath := filepath.Join(absInstalledPath, linuxStartShFilename) 221 | if _, err = os.Stat(absStartShPath); err == nil { 222 | return absStartShPath, nil 223 | } else if os.IsNotExist(err) { 224 | var matches []string 225 | if matches, err = filepath.Glob(filepath.Join(absInstalledPath, "*", linuxStartShFilename)); err == nil && len(matches) > 0 { 226 | return matches[0], nil 227 | } 228 | } 229 | 230 | return "", errors.New("cannot locate start.sh for " + id) 231 | } 232 | 233 | func nixUninstallProduct(id, langCode string, operatingSystem vangogh_integration.OperatingSystem, rdx redux.Readable) error { 234 | 235 | umpa := nod.Begin(" uninstalling %s version of %s...", operatingSystem, id) 236 | defer umpa.Done() 237 | 238 | absBundlePath, err := osInstalledPath(id, operatingSystem, langCode, rdx) 239 | if err != nil { 240 | return err 241 | } 242 | 243 | if _, err := os.Stat(absBundlePath); os.IsNotExist(err) { 244 | umpa.EndWithResult("not present") 245 | return nil 246 | } 247 | 248 | if err = os.RemoveAll(absBundlePath); err != nil { 249 | return err 250 | } 251 | 252 | return nil 253 | } 254 | 255 | func nixFreeSpace(path string) (int64, error) { 256 | 257 | dfPath, err := exec.LookPath("df") 258 | if err != nil { 259 | return -1, err 260 | } 261 | 262 | buf := bytes.NewBuffer(nil) 263 | 264 | dfCmd := exec.Command(dfPath, "-k", path) 265 | dfCmd.Stdout = buf 266 | 267 | if err = dfCmd.Run(); err != nil { 268 | return -1, err 269 | } 270 | 271 | var lines []string 272 | if lines = strings.Split(buf.String(), "\n"); len(lines) < 2 { 273 | return -1, errors.New("unsupported df output lines format") 274 | } 275 | 276 | var ai int 277 | if ai = strings.Index(lines[0], "Available"); ai == 0 || ai >= len(lines[0])-1 { 278 | return -1, errors.New("df output is missing Available") 279 | } 280 | 281 | var sub string 282 | if sub = lines[1][ai:]; len(sub) == 0 { 283 | return -1, errors.New("df values format is too short") 284 | } 285 | 286 | var abs string 287 | var ok bool 288 | if abs, _, ok = strings.Cut(sub, " "); !ok { 289 | abs = sub 290 | } 291 | 292 | if abi, err := strconv.ParseInt(abs, 10, 32); err == nil { 293 | return abi * 1024, nil 294 | } else { 295 | return -1, err 296 | } 297 | } 298 | 299 | func linuxFindGogGameInfo(id, langCode string, rdx redux.Readable) (string, error) { 300 | 301 | absInstalledPath, err := osInstalledPath(id, vangogh_integration.Linux, langCode, rdx) 302 | if err != nil { 303 | return "", err 304 | } 305 | 306 | gogGameInfoFilename := strings.Replace(gog_integration.GogGameInfoFilenameTemplate, "{id}", id, 1) 307 | 308 | absGogGameInfoPath := filepath.Join(absInstalledPath, relLinuxGogGameInfoDir, gogGameInfoFilename) 309 | 310 | if _, err = os.Stat(absGogGameInfoPath); err == nil { 311 | return absGogGameInfoPath, nil 312 | } else if os.IsNotExist(err) { 313 | return "", nil 314 | } else { 315 | return "", err 316 | } 317 | } 318 | 319 | func linuxExecTaskGogGameInfo(absGogGameInfoPath string, gogGameInfo *gog_integration.GogGameInfo, et *execTask) (*execTask, error) { 320 | 321 | pt, err := gogGameInfo.GetPlayTask(et.playTask) 322 | if err != nil { 323 | return nil, err 324 | } 325 | 326 | absGogGameInfoDir, _ := filepath.Split(absGogGameInfoPath) 327 | 328 | exePath := pt.Path 329 | // account for Windows-style relative paths, e.g. DOSBOX\DOSBOX.exe 330 | if parts := strings.Split(exePath, "\\"); len(parts) > 1 { 331 | exePath = filepath.Join(parts...) 332 | } 333 | 334 | absExePath := filepath.Join(absGogGameInfoDir, exePath) 335 | 336 | et.name = pt.Name 337 | et.exe = absExePath 338 | et.workDir = filepath.Join(absGogGameInfoDir, pt.WorkingDir) 339 | 340 | if pt.Arguments != "" { 341 | et.args = append(et.args, pt.Arguments) 342 | } 343 | 344 | return et, nil 345 | } 346 | 347 | func linuxExecTaskStartSh(absStartShPath string, et *execTask) (*execTask, error) { 348 | 349 | et.exe = absStartShPath 350 | 351 | return et, nil 352 | } 353 | -------------------------------------------------------------------------------- /cli/list.go: -------------------------------------------------------------------------------- 1 | package cli 2 | 3 | import ( 4 | "encoding/json" 5 | "errors" 6 | "fmt" 7 | "net/url" 8 | "slices" 9 | "strconv" 10 | "strings" 11 | "time" 12 | 13 | "github.com/arelate/southern_light/gog_integration" 14 | "github.com/arelate/southern_light/steam_vdf" 15 | "github.com/arelate/southern_light/vangogh_integration" 16 | "github.com/arelate/theo/data" 17 | "github.com/boggydigital/nod" 18 | "github.com/boggydigital/pathways" 19 | "github.com/boggydigital/redux" 20 | ) 21 | 22 | var steamShortcutPrintedKeys = []string{ 23 | "appid", 24 | "appname", 25 | "icon", 26 | "Exe", 27 | "StartDir", 28 | "LaunchOptions", 29 | } 30 | 31 | func ListHandler(u *url.URL) error { 32 | 33 | q := u.Query() 34 | 35 | installed := q.Has("installed") 36 | playTasks := q.Has("playtasks") 37 | steamShorts := q.Has("steam-shortcuts") 38 | 39 | operatingSystem := vangogh_integration.AnyOperatingSystem 40 | if q.Has(vangogh_integration.OperatingSystemsProperty) { 41 | operatingSystem = vangogh_integration.ParseOperatingSystem(q.Get(vangogh_integration.OperatingSystemsProperty)) 42 | } 43 | 44 | var langCode string 45 | if q.Has(vangogh_integration.LanguageCodeProperty) { 46 | langCode = q.Get(vangogh_integration.LanguageCodeProperty) 47 | } 48 | 49 | ii := &InstallInfo{ 50 | OperatingSystem: operatingSystem, 51 | LangCode: langCode, 52 | } 53 | 54 | id := q.Get(vangogh_integration.IdProperty) 55 | allKeyValues := q.Has("all-key-values") 56 | 57 | return List(installed, playTasks, steamShorts, ii, id, allKeyValues) 58 | } 59 | 60 | func List(installed, playTasks, steamShortcuts bool, 61 | installInfo *InstallInfo, 62 | id string, allKeyValues bool) error { 63 | 64 | if installed || playTasks || steamShortcuts { 65 | // do nothing 66 | } else { 67 | return errors.New("you need to specify at least one category to list") 68 | } 69 | 70 | if installed { 71 | if err := listInstalled(installInfo); err != nil { 72 | return err 73 | } 74 | } 75 | 76 | if playTasks { 77 | if id == "" { 78 | return errors.New("listing playTasks requires product id") 79 | } 80 | if err := listPlayTasks(id, installInfo.LangCode); err != nil { 81 | return err 82 | } 83 | } 84 | 85 | if steamShortcuts { 86 | if err := listSteamShortcuts(allKeyValues); err != nil { 87 | return err 88 | } 89 | } 90 | 91 | return nil 92 | } 93 | 94 | func listInstalled(ii *InstallInfo) error { 95 | 96 | lia := nod.Begin("listing installed products for %s, %s...", ii.OperatingSystem, ii.LangCode) 97 | defer lia.Done() 98 | 99 | reduxDir, err := pathways.GetAbsRelDir(vangogh_integration.Redux) 100 | if err != nil { 101 | return err 102 | } 103 | 104 | rdx, err := redux.NewReader(reduxDir, 105 | vangogh_integration.TitleProperty, 106 | data.InstallInfoProperty, 107 | data.InstallDateProperty, 108 | data.LastRunDateProperty, 109 | data.TotalPlaytimeMinutesProperty) 110 | if err != nil { 111 | return err 112 | } 113 | 114 | summary := make(map[string][]string) 115 | 116 | installedIds := slices.Collect(rdx.Keys(data.InstallInfoProperty)) 117 | installedIds, err = rdx.Sort(installedIds, false, vangogh_integration.TitleProperty) 118 | if err != nil { 119 | return err 120 | } 121 | 122 | for _, id := range installedIds { 123 | 124 | title := id 125 | if tp, ok := rdx.GetLastVal(vangogh_integration.TitleProperty, id); ok { 126 | title = fmt.Sprintf("%s (%s)", tp, id) 127 | } 128 | 129 | var installedDate string 130 | if ids, ok := rdx.GetLastVal(data.InstallDateProperty, id); ok && ids != "" { 131 | if installDate, err := time.Parse(time.RFC3339, ids); err == nil { 132 | installedDate = installDate.Local().Format(time.DateTime) 133 | } else { 134 | return err 135 | } 136 | } 137 | 138 | installedInfoLines, ok := rdx.GetAllValues(data.InstallInfoProperty, id) 139 | if !ok { 140 | return errors.New("install info not found for " + id) 141 | } 142 | 143 | for _, line := range installedInfoLines { 144 | 145 | var installedInfo InstallInfo 146 | if err = json.NewDecoder(strings.NewReader(line)).Decode(&installedInfo); err != nil { 147 | return err 148 | } 149 | 150 | infoLines := make([]string, 0) 151 | 152 | if (ii.OperatingSystem == vangogh_integration.AnyOperatingSystem || 153 | installedInfo.OperatingSystem == ii.OperatingSystem) && 154 | installedInfo.LangCode == ii.LangCode { 155 | 156 | infoLines = append(infoLines, "os: "+installedInfo.OperatingSystem.String()) 157 | infoLines = append(infoLines, "lang: "+gog_integration.LanguageNativeName(installedInfo.LangCode)) 158 | 159 | pfxDt := "type: " 160 | if len(installedInfo.DownloadTypes) > 1 { 161 | pfxDt = "types: " 162 | } 163 | dts := make([]string, 0, len(installedInfo.DownloadTypes)) 164 | for _, dt := range installedInfo.DownloadTypes { 165 | dts = append(dts, dt.HumanReadableString()) 166 | } 167 | infoLines = append(infoLines, pfxDt+strings.Join(dts, ", ")) 168 | 169 | infoLines = append(infoLines, "version: "+installedInfo.Version) 170 | if installedInfo.EstimatedBytes > 0 { 171 | infoLines = append(infoLines, "size: "+vangogh_integration.FormatBytes(installedInfo.EstimatedBytes)) 172 | } 173 | 174 | summary[title] = append(summary[title], strings.Join(infoLines, "; ")) 175 | 176 | if len(installedInfo.DownloadableContent) > 0 { 177 | summary[title] = append(summary[title], "- dlc: "+strings.Join(installedInfo.DownloadableContent, ", ")) 178 | } 179 | 180 | if installedDate != "" { 181 | summary[title] = append(summary[title], "- installed: "+installedDate) 182 | } 183 | } 184 | } 185 | 186 | // playtimes 187 | 188 | if tpms, sure := rdx.GetLastVal(data.TotalPlaytimeMinutesProperty, id); sure && tpms != "" { 189 | if tpmi, err := strconv.ParseInt(tpms, 10, 64); err == nil { 190 | if tpmi > 0 { 191 | summary[title] = append(summary[title], "- total playtime: "+fmtHoursMinutes(tpmi)) 192 | } 193 | } else { 194 | return err 195 | } 196 | } 197 | 198 | if lrds, sure := rdx.GetLastVal(data.LastRunDateProperty, id); sure && lrds != "" { 199 | if lrdt, err := time.Parse(time.RFC3339, lrds); err == nil { 200 | summary[title] = append(summary[title], "- last run date: "+lrdt.Format(time.DateTime)) 201 | } else { 202 | return err 203 | } 204 | } 205 | 206 | } 207 | 208 | if len(summary) == 0 { 209 | lia.EndWithResult("found nothing") 210 | } else { 211 | lia.EndWithSummary("found the following products:", summary) 212 | } 213 | 214 | return nil 215 | } 216 | 217 | func listPlayTasks(id string, langCode string) error { 218 | 219 | lpta := nod.Begin("listing playTasks for %s...", id) 220 | defer lpta.Done() 221 | 222 | reduxDir, err := pathways.GetAbsRelDir(data.Redux) 223 | if err != nil { 224 | return err 225 | } 226 | 227 | rdx, err := redux.NewWriter(reduxDir, data.AllProperties()...) 228 | if err != nil { 229 | return err 230 | } 231 | 232 | absGogGameInfoPath, err := prefixFindGogGameInfo(id, langCode, rdx) 233 | if err != nil { 234 | return err 235 | } 236 | 237 | gogGameInfo, err := gog_integration.GetGogGameInfo(absGogGameInfoPath) 238 | if err != nil { 239 | return err 240 | } 241 | 242 | playTasksSummary := make(map[string][]string) 243 | 244 | for _, pt := range gogGameInfo.PlayTasks { 245 | list := make([]string, 0) 246 | if pt.Arguments != "" { 247 | list = append(list, "arguments:"+pt.Arguments) 248 | } 249 | list = append(list, "category:"+pt.Category) 250 | if pt.IsPrimary { 251 | list = append(list, "isPrimary:true") 252 | } 253 | if pt.IsHidden { 254 | list = append(list, "isHidden:true") 255 | } 256 | if len(pt.Languages) > 0 { 257 | list = append(list, "languages:"+strings.Join(pt.Languages, ",")) 258 | } 259 | if pt.Link != "" { 260 | list = append(list, "link:"+pt.Link) 261 | } 262 | if len(pt.OsBitness) > 0 { 263 | list = append(list, "osBitness:"+strings.Join(pt.OsBitness, ",")) 264 | } 265 | if pt.Path != "" { 266 | list = append(list, "path:"+pt.Path) 267 | } 268 | list = append(list, "type:"+pt.Type) 269 | if pt.WorkingDir != "" { 270 | list = append(list, "workingDir:"+pt.WorkingDir) 271 | } 272 | 273 | playTasksSummary["name:"+pt.Name] = list 274 | } 275 | 276 | lpta.EndWithSummary("found the following playTasks:", playTasksSummary) 277 | 278 | return nil 279 | } 280 | 281 | func listSteamShortcuts(allKeyValues bool) error { 282 | lssa := nod.Begin("listing Steam shortcuts for all users...") 283 | defer lssa.Done() 284 | 285 | ok, err := steamStateDirExist() 286 | if err != nil { 287 | return err 288 | } 289 | 290 | if !ok { 291 | lssa.EndWithResult("Steam state dir not found") 292 | return nil 293 | } 294 | 295 | loginUsers, err := getSteamLoginUsers() 296 | if err != nil { 297 | return err 298 | } 299 | 300 | for _, loginUser := range loginUsers { 301 | if err := listUserShortcuts(loginUser, allKeyValues); err != nil { 302 | return err 303 | } 304 | } 305 | 306 | return nil 307 | } 308 | 309 | func listUserShortcuts(loginUser string, allKeyValues bool) error { 310 | 311 | lusa := nod.Begin("listing shortcuts for %s...", loginUser) 312 | defer lusa.Done() 313 | 314 | kvUserShortcuts, err := readUserShortcuts(loginUser) 315 | if err != nil { 316 | return err 317 | } 318 | 319 | if kvUserShortcuts == nil { 320 | lusa.EndWithResult("user %s is missing shortcuts file", loginUser) 321 | return nil 322 | } 323 | 324 | if kvShortcuts := steam_vdf.GetKevValuesByKey(kvUserShortcuts, "shortcuts"); kvShortcuts != nil { 325 | 326 | shortcutValues := make(map[string][]string) 327 | 328 | for _, shortcut := range kvShortcuts.Values { 329 | shortcutKey := fmt.Sprintf("shortcut: %s", shortcut.Key) 330 | 331 | for _, kv := range shortcut.Values { 332 | 333 | var addKeyValue bool 334 | switch allKeyValues { 335 | case true: 336 | addKeyValue = true 337 | case false: 338 | addKeyValue = slices.Contains(steamShortcutPrintedKeys, kv.Key) && kv.TypedValue != nil 339 | } 340 | 341 | if addKeyValue { 342 | keyValue := fmt.Sprintf("%s: %v", kv.Key, kv.TypedValue) 343 | shortcutValues[shortcutKey] = append(shortcutValues[shortcutKey], keyValue) 344 | } 345 | } 346 | } 347 | 348 | heading := fmt.Sprintf("Steam user %s shortcuts", loginUser) 349 | lusa.EndWithSummary(heading, shortcutValues) 350 | 351 | } else { 352 | lusa.EndWithResult("no shortcuts found") 353 | } 354 | 355 | return nil 356 | } 357 | 358 | func fmtHoursMinutes(minutes int64) string { 359 | hours := minutes / 60 360 | remainingMinutes := minutes - 60*hours 361 | 362 | var fmtHoursMinutes string 363 | if remainingMinutes > 0 { 364 | fmtHoursMinutes = strconv.FormatInt(remainingMinutes, 10) + " min(s)" 365 | } 366 | if hours > 0 { 367 | fmtHoursMinutes = strconv.FormatInt(hours, 10) + "hr(s) " + fmtHoursMinutes 368 | } 369 | 370 | return fmtHoursMinutes 371 | } 372 | -------------------------------------------------------------------------------- /cli/setup_wine.go: -------------------------------------------------------------------------------- 1 | package cli 2 | 3 | import ( 4 | "encoding/json" 5 | "errors" 6 | "net/http" 7 | "net/url" 8 | "os" 9 | "os/exec" 10 | "path/filepath" 11 | "slices" 12 | "strings" 13 | "time" 14 | 15 | "github.com/arelate/southern_light/vangogh_integration" 16 | "github.com/arelate/southern_light/wine_integration" 17 | "github.com/arelate/theo/data" 18 | "github.com/boggydigital/busan" 19 | "github.com/boggydigital/dolo" 20 | "github.com/boggydigital/nod" 21 | "github.com/boggydigital/pathways" 22 | "github.com/boggydigital/redux" 23 | ) 24 | 25 | func SetupWineHandler(u *url.URL) error { 26 | 27 | q := u.Query() 28 | 29 | force := q.Has("force") 30 | 31 | return SetupWine(force) 32 | } 33 | 34 | func SetupWine(force bool) error { 35 | 36 | start := time.Now() 37 | 38 | currentOs := data.CurrentOs() 39 | 40 | if currentOs == vangogh_integration.Windows { 41 | err := errors.New("WINE is not required on Windows") 42 | return err 43 | } 44 | 45 | uwa := nod.Begin("setting up WINE for %s...", currentOs) 46 | defer uwa.Done() 47 | 48 | reduxDir, err := pathways.GetAbsRelDir(data.Redux) 49 | if err != nil { 50 | return err 51 | } 52 | 53 | rdx, err := redux.NewWriter(reduxDir, 54 | data.ServerConnectionProperties, 55 | data.WineBinariesVersionsProperty) 56 | if err != nil { 57 | return err 58 | } 59 | 60 | wbd, err := getWineBinariesVersions(rdx) 61 | if err != nil { 62 | return err 63 | } 64 | 65 | if err = downloadWineBinaries(wbd, currentOs, rdx, force); err != nil { 66 | return err 67 | } 68 | 69 | if err = validateWineBinaries(wbd, currentOs, start, force); err != nil { 70 | return err 71 | } 72 | 73 | if err = pinWineBinariesVersions(wbd, rdx); err != nil { 74 | return err 75 | } 76 | 77 | if err = cleanupDownloadedWineBinaries(wbd, currentOs); err != nil { 78 | return err 79 | } 80 | 81 | if err = unpackWineBinaries(wbd, currentOs, force); err != nil { 82 | return err 83 | } 84 | 85 | if err = cleanupUnpackedWineBinaries(wbd, currentOs); err != nil { 86 | return err 87 | } 88 | 89 | if err = resetUmuConfigs(rdx); err != nil { 90 | return err 91 | } 92 | 93 | return nil 94 | } 95 | 96 | func getWineBinariesVersions(rdx redux.Readable) ([]vangogh_integration.WineBinaryDetails, error) { 97 | 98 | gwbva := nod.Begin("getting WINE binaries versions...") 99 | defer gwbva.Done() 100 | 101 | if err := rdx.MustHave(data.ServerConnectionProperties); err != nil { 102 | return nil, err 103 | } 104 | 105 | req, err := data.ServerRequest(http.MethodGet, data.ApiWineBinariesVersions, nil, rdx) 106 | if err != nil { 107 | return nil, err 108 | } 109 | 110 | resp, err := http.DefaultClient.Do(req) 111 | if err != nil { 112 | return nil, err 113 | } 114 | 115 | defer resp.Body.Close() 116 | 117 | if resp.StatusCode < 200 || resp.StatusCode > 299 { 118 | return nil, errors.New(resp.Status) 119 | } 120 | 121 | var wbd []vangogh_integration.WineBinaryDetails 122 | 123 | if err = json.NewDecoder(resp.Body).Decode(&wbd); err != nil { 124 | return nil, err 125 | } 126 | 127 | return wbd, nil 128 | } 129 | 130 | func downloadWineBinaries(wbd []vangogh_integration.WineBinaryDetails, 131 | operatingSystem vangogh_integration.OperatingSystem, 132 | rdx redux.Readable, 133 | force bool) error { 134 | 135 | dwba := nod.Begin("downloading WINE binaries...") 136 | defer dwba.Done() 137 | 138 | for _, wineBinary := range wbd { 139 | if wineBinary.OS != operatingSystem && wineBinary.OS != vangogh_integration.Windows { 140 | continue 141 | } 142 | 143 | if err := downloadWineBinary(&wineBinary, rdx, force); err != nil { 144 | return err 145 | } 146 | } 147 | 148 | return nil 149 | } 150 | 151 | func downloadWineBinary(binary *vangogh_integration.WineBinaryDetails, rdx redux.Readable, force bool) error { 152 | 153 | dwba := nod.NewProgress(" - %s %s...", binary.Title, binary.Version) 154 | defer dwba.Done() 155 | 156 | if err := rdx.MustHave(data.WineBinariesVersionsProperty, data.ServerConnectionProperties); err != nil { 157 | return err 158 | } 159 | 160 | wineDownloads, err := pathways.GetAbsRelDir(data.WineDownloads) 161 | if err != nil { 162 | return err 163 | } 164 | 165 | if currentVersion, ok := rdx.GetLastVal(data.WineBinariesVersionsProperty, binary.Title); ok && binary.Version == currentVersion && !force { 166 | dwba.EndWithResult("latest version already available") 167 | return nil 168 | } 169 | 170 | var wineBinaryUrl *url.URL 171 | query := url.Values{ 172 | "title": {binary.Title}, 173 | "os": {binary.OS.String()}, 174 | } 175 | 176 | wineBinaryUrl, err = data.ServerUrl(data.HttpWineBinaryFilePath, query, rdx) 177 | if err != nil { 178 | return err 179 | } 180 | 181 | dc := dolo.DefaultClient 182 | 183 | if token, ok := rdx.GetLastVal(data.ServerConnectionProperties, data.ServerSessionToken); ok && token != "" { 184 | dc.SetAuthorizationBearer(token) 185 | } 186 | 187 | return dc.Download(wineBinaryUrl, force, dwba, wineDownloads, binary.Filename) 188 | } 189 | 190 | func validateWineBinaries(wbd []vangogh_integration.WineBinaryDetails, operatingSystem vangogh_integration.OperatingSystem, since time.Time, force bool) error { 191 | 192 | vwba := nod.NewProgress("validating WINE binaries...") 193 | defer vwba.Done() 194 | 195 | wineDownloads, err := pathways.GetAbsRelDir(data.WineDownloads) 196 | if err != nil { 197 | return err 198 | } 199 | 200 | for _, wineBinary := range wbd { 201 | if wineBinary.OS != operatingSystem && wineBinary.OS != vangogh_integration.Windows { 202 | continue 203 | } 204 | 205 | if err = wine_integration.ValidateWineBinary(&wineBinary, wineDownloads, since, force); err != nil { 206 | return err 207 | } 208 | } 209 | 210 | return nil 211 | } 212 | 213 | func pinWineBinariesVersions(wbd []vangogh_integration.WineBinaryDetails, rdx redux.Writeable) error { 214 | 215 | pwbva := nod.Begin("pinning WINE binaries versions...") 216 | defer pwbva.Done() 217 | 218 | if err := rdx.MustHave(data.WineBinariesVersionsProperty); err != nil { 219 | return err 220 | } 221 | 222 | wineBinariesVersions := make(map[string][]string) 223 | 224 | for _, wineBinary := range wbd { 225 | wineBinariesVersions[wineBinary.Title] = []string{wineBinary.Version} 226 | } 227 | 228 | return rdx.BatchReplaceValues(data.WineBinariesVersionsProperty, wineBinariesVersions) 229 | } 230 | 231 | func cleanupDownloadedWineBinaries(wbd []vangogh_integration.WineBinaryDetails, operatingSystem vangogh_integration.OperatingSystem) error { 232 | 233 | cdwba := nod.NewProgress("cleaning up downloaded WINE binaries...") 234 | defer cdwba.Done() 235 | 236 | expectedFiles := make([]string, 0, len(wbd)) 237 | for _, wineBinary := range wbd { 238 | if wineBinary.OS != operatingSystem && wineBinary.OS != vangogh_integration.Windows { 239 | continue 240 | } 241 | expectedFiles = append(expectedFiles, wineBinary.Filename) 242 | } 243 | 244 | wineDownloads, err := pathways.GetAbsRelDir(data.WineDownloads) 245 | if err != nil { 246 | return err 247 | } 248 | 249 | wineDownloadsDir, err := os.Open(wineDownloads) 250 | if err != nil { 251 | return err 252 | } 253 | 254 | defer wineDownloadsDir.Close() 255 | 256 | actualFiles, err := wineDownloadsDir.Readdirnames(-1) 257 | if err != nil { 258 | return err 259 | } 260 | 261 | unexpectedFiles := make([]string, 0) 262 | 263 | for _, af := range actualFiles { 264 | if strings.HasPrefix(af, ".") { 265 | continue 266 | } 267 | if !slices.Contains(expectedFiles, af) { 268 | unexpectedFiles = append(unexpectedFiles, af) 269 | } 270 | } 271 | 272 | if len(unexpectedFiles) == 0 { 273 | cdwba.EndWithResult("already clean") 274 | return nil 275 | } 276 | 277 | cdwba.TotalInt(len(unexpectedFiles)) 278 | 279 | for _, uf := range unexpectedFiles { 280 | absUnexpectedFile := filepath.Join(wineDownloads, uf) 281 | if err = os.Remove(absUnexpectedFile); err != nil { 282 | return err 283 | } 284 | cdwba.Increment() 285 | } 286 | 287 | return nil 288 | } 289 | 290 | func unpackWineBinaries(wbd []vangogh_integration.WineBinaryDetails, 291 | operatingSystem vangogh_integration.OperatingSystem, 292 | force bool) error { 293 | 294 | uwba := nod.Begin("unpacking WINE binaries...") 295 | defer uwba.Done() 296 | 297 | wineDownloads, err := pathways.GetAbsRelDir(data.WineDownloads) 298 | if err != nil { 299 | return err 300 | } 301 | 302 | wineBinaries, err := pathways.GetAbsRelDir(data.WineBinaries) 303 | if err != nil { 304 | return err 305 | } 306 | 307 | for _, wineBinary := range wbd { 308 | if wineBinary.OS != operatingSystem { 309 | continue 310 | } 311 | 312 | srcPath := filepath.Join(wineDownloads, wineBinary.Filename) 313 | dstPath := filepath.Join(wineBinaries, busan.Sanitize(wineBinary.Title), wineBinary.Version) 314 | 315 | if _, err = os.Stat(dstPath); err == nil && !force { 316 | continue 317 | } 318 | 319 | wba := nod.Begin(" - %s...", wineBinary.Title) 320 | 321 | if err = untar(srcPath, dstPath); err != nil { 322 | return err 323 | } 324 | 325 | wba.Done() 326 | } 327 | 328 | return nil 329 | } 330 | 331 | func cleanupUnpackedWineBinaries(wbd []vangogh_integration.WineBinaryDetails, 332 | operatingSystem vangogh_integration.OperatingSystem) error { 333 | 334 | cuwba := nod.NewProgress("cleaning up unpacked WINE binaries...") 335 | defer cuwba.Done() 336 | 337 | wineBinaries, err := pathways.GetAbsRelDir(data.WineBinaries) 338 | if err != nil { 339 | return err 340 | } 341 | 342 | absExpectedDirs := make([]string, 0) 343 | absActualDirs := make([]string, 0) 344 | 345 | for _, wineBinary := range wbd { 346 | if wineBinary.OS != operatingSystem { 347 | continue 348 | } 349 | 350 | absTitleDir := filepath.Join(wineBinaries, busan.Sanitize(wineBinary.Title)) 351 | 352 | absLatestVersionDir := filepath.Join(absTitleDir, wineBinary.Version) 353 | absExpectedDirs = append(absExpectedDirs, absLatestVersionDir) 354 | 355 | var titleDir *os.File 356 | titleDir, err = os.Open(absTitleDir) 357 | if err != nil { 358 | return err 359 | } 360 | 361 | var filenames []string 362 | filenames, err = titleDir.Readdirnames(-1) 363 | if err != nil { 364 | if err = titleDir.Close(); err != nil { 365 | return err 366 | } 367 | return err 368 | } 369 | 370 | for _, fn := range filenames { 371 | absActualDirs = append(absActualDirs, filepath.Join(absTitleDir, fn)) 372 | } 373 | 374 | if err = titleDir.Close(); err != nil { 375 | return err 376 | } 377 | } 378 | 379 | absUnexpectedDirs := make([]string, 0) 380 | 381 | for _, aad := range absActualDirs { 382 | if !slices.Contains(absExpectedDirs, aad) { 383 | absUnexpectedDirs = append(absUnexpectedDirs, aad) 384 | } 385 | } 386 | 387 | if len(absUnexpectedDirs) == 0 { 388 | cuwba.EndWithResult("already clean") 389 | return nil 390 | } 391 | 392 | cuwba.TotalInt(len(absUnexpectedDirs)) 393 | 394 | for _, aud := range absUnexpectedDirs { 395 | if err = os.RemoveAll(aud); err != nil { 396 | return err 397 | } 398 | cuwba.Increment() 399 | } 400 | 401 | return nil 402 | } 403 | 404 | func untar(srcPath, dstPath string) error { 405 | 406 | if _, err := os.Stat(dstPath); err != nil { 407 | if err = os.MkdirAll(dstPath, 0755); err != nil { 408 | return err 409 | } 410 | } 411 | 412 | cmd := exec.Command("tar", "-xf", srcPath, "-C", dstPath) 413 | return cmd.Run() 414 | } 415 | -------------------------------------------------------------------------------- /cli/run.go: -------------------------------------------------------------------------------- 1 | package cli 2 | 3 | import ( 4 | "errors" 5 | "net/url" 6 | "os" 7 | "path" 8 | "path/filepath" 9 | "strconv" 10 | "strings" 11 | "time" 12 | 13 | "github.com/arelate/southern_light/gog_integration" 14 | "github.com/arelate/southern_light/vangogh_integration" 15 | "github.com/arelate/theo/data" 16 | "github.com/boggydigital/nod" 17 | "github.com/boggydigital/pathways" 18 | "github.com/boggydigital/redux" 19 | ) 20 | 21 | func RunHandler(u *url.URL) error { 22 | 23 | q := u.Query() 24 | 25 | id := q.Get(vangogh_integration.IdProperty) 26 | 27 | operatingSystem := vangogh_integration.AnyOperatingSystem 28 | if q.Has(vangogh_integration.OperatingSystemsProperty) { 29 | operatingSystem = vangogh_integration.ParseOperatingSystem(q.Get(vangogh_integration.OperatingSystemsProperty)) 30 | } 31 | 32 | var langCode string 33 | if q.Has(vangogh_integration.LanguageCodeProperty) { 34 | langCode = q.Get(vangogh_integration.LanguageCodeProperty) 35 | } 36 | 37 | ii := &InstallInfo{ 38 | OperatingSystem: operatingSystem, 39 | LangCode: langCode, 40 | force: q.Has("force"), 41 | } 42 | 43 | et := &execTask{ 44 | workDir: q.Get("work-dir"), 45 | verbose: q.Has("verbose"), 46 | playTask: q.Get("playtask"), 47 | defaultLauncher: q.Has("default-launcher"), 48 | } 49 | 50 | if q.Has("env") { 51 | et.env = strings.Split(q.Get("env"), ",") 52 | } 53 | 54 | if q.Has("arg") { 55 | et.args = strings.Split(q.Get("arg"), ",") 56 | } 57 | 58 | return Run(id, ii, et) 59 | } 60 | 61 | func Run(id string, ii *InstallInfo, et *execTask) error { 62 | 63 | playSessionStart := time.Now() 64 | 65 | ra := nod.NewProgress("running product %s...", id) 66 | defer ra.Done() 67 | 68 | reduxDir, err := pathways.GetAbsRelDir(data.Redux) 69 | if err != nil { 70 | return err 71 | } 72 | 73 | rdx, err := redux.NewWriter(reduxDir, data.AllProperties()...) 74 | 75 | if err != nil { 76 | return err 77 | } 78 | 79 | if err = resolveInstallInfo(id, ii, nil, rdx, installedOperatingSystem, installedLangCode); err != nil { 80 | return err 81 | } 82 | 83 | printInstallInfoParams(ii, true, id) 84 | 85 | if err = checkProductType(id, rdx, ii.force); err != nil { 86 | return err 87 | } 88 | 89 | if err = setLastRunDate(rdx, id); err != nil { 90 | return err 91 | } 92 | 93 | if err = osRun(id, ii, rdx, et); err != nil { 94 | return err 95 | } 96 | 97 | playSessionDuration := time.Since(playSessionStart) 98 | 99 | if err = recordPlaytime(rdx, id, playSessionDuration); err != nil { 100 | return err 101 | } 102 | 103 | return updateTotalPlaytime(rdx, id) 104 | } 105 | 106 | func checkProductType(id string, rdx redux.Writeable, force bool) error { 107 | 108 | productDetails, err := getProductDetails(id, rdx, force) 109 | if err != nil { 110 | return err 111 | } 112 | 113 | switch productDetails.ProductType { 114 | case vangogh_integration.GameProductType: 115 | // do nothing, proceed normally 116 | return nil 117 | case vangogh_integration.PackProductType: 118 | return errors.New("cannot run a PACK product, please run included game(s): " + 119 | strings.Join(productDetails.IncludesGames, ",")) 120 | case vangogh_integration.DlcProductType: 121 | return errors.New("cannot run a DLC product, please run required game(s): " + 122 | strings.Join(productDetails.RequiresGames, ",")) 123 | default: 124 | return errors.New("unsupported product type: " + productDetails.ProductType) 125 | } 126 | } 127 | 128 | func setLastRunDate(rdx redux.Writeable, id string) error { 129 | 130 | if err := rdx.MustHave(data.LastRunDateProperty); err != nil { 131 | return err 132 | } 133 | 134 | fmtUtcNow := time.Now().UTC().Format(time.RFC3339) 135 | return rdx.ReplaceValues(data.LastRunDateProperty, id, fmtUtcNow) 136 | } 137 | 138 | func recordPlaytime(rdx redux.Writeable, id string, dur time.Duration) error { 139 | 140 | if err := rdx.MustHave(data.PlaytimeMinutesProperty); err != nil { 141 | return err 142 | } 143 | 144 | // this will lose some seconds precision 145 | fmtDur := strconv.FormatInt(int64(dur.Minutes()), 10) 146 | 147 | return rdx.AddValues(data.PlaytimeMinutesProperty, id, fmtDur) 148 | } 149 | 150 | func updateTotalPlaytime(rdx redux.Writeable, id string) error { 151 | if err := rdx.MustHave(data.PlaytimeMinutesProperty, data.TotalPlaytimeMinutesProperty); err != nil { 152 | return err 153 | } 154 | 155 | var totalPlaytimeMinutes int64 156 | if tpms, ok := rdx.GetAllValues(data.PlaytimeMinutesProperty, id); ok && len(tpms) > 0 { 157 | for _, mins := range tpms { 158 | if mini, err := strconv.ParseInt(mins, 10, 64); err == nil { 159 | totalPlaytimeMinutes += mini 160 | } else { 161 | return err 162 | } 163 | } 164 | } 165 | 166 | if totalPlaytimeMinutes > 0 { 167 | return rdx.ReplaceValues(data.TotalPlaytimeMinutesProperty, id, strconv.FormatInt(totalPlaytimeMinutes, 10)) 168 | } else { 169 | return nil 170 | } 171 | } 172 | 173 | func osConfirmRunnability(operatingSystem vangogh_integration.OperatingSystem) error { 174 | if operatingSystem == vangogh_integration.MacOS && data.CurrentOs() != vangogh_integration.MacOS { 175 | return errors.New("running macOS versions is only supported on macOS") 176 | } 177 | if operatingSystem == vangogh_integration.Linux && data.CurrentOs() != vangogh_integration.Linux { 178 | return errors.New("running Linux versions is only supported on Linux") 179 | } 180 | return nil 181 | } 182 | 183 | func osRun(id string, ii *InstallInfo, rdx redux.Readable, et *execTask) error { 184 | 185 | var err error 186 | if err = osConfirmRunnability(ii.OperatingSystem); err != nil { 187 | return err 188 | } 189 | 190 | if ii.OperatingSystem == vangogh_integration.Windows && data.CurrentOs() != vangogh_integration.Windows { 191 | 192 | var absPrefixDir string 193 | if absPrefixDir, err = data.GetAbsPrefixDir(id, ii.LangCode, rdx); err == nil { 194 | et.prefix = absPrefixDir 195 | } else { 196 | return err 197 | } 198 | 199 | prefixName, err := data.GetPrefixName(id, rdx) 200 | if err != nil { 201 | return err 202 | } 203 | 204 | langPrefixName := path.Join(prefixName, ii.LangCode) 205 | 206 | if env, ok := rdx.GetAllValues(data.PrefixEnvProperty, langPrefixName); ok { 207 | et.env = mergeEnv(et.env, env) 208 | } 209 | 210 | if exe, ok := rdx.GetLastVal(data.PrefixExeProperty, langPrefixName); ok { 211 | 212 | absExePath := filepath.Join(absPrefixDir, exe) 213 | if _, err = os.Stat(absExePath); err == nil { 214 | et.name = exe 215 | et.exe = absExePath 216 | } 217 | 218 | } 219 | 220 | if arg, ok := rdx.GetAllValues(data.PrefixArgProperty, langPrefixName); ok { 221 | et.args = append(et.args, arg...) 222 | } 223 | 224 | if et.exe != "" { 225 | return osExec(id, ii.OperatingSystem, et, rdx, ii.force) 226 | } 227 | } 228 | 229 | var absGogGameInfoPath string 230 | switch et.defaultLauncher { 231 | case false: 232 | absGogGameInfoPath, err = osFindGogGameInfo(id, ii.OperatingSystem, ii.LangCode, rdx) 233 | if err != nil { 234 | return err 235 | } 236 | case true: 237 | // do nothing 238 | } 239 | 240 | switch absGogGameInfoPath { 241 | case "": 242 | var absDefaultLauncherPath string 243 | if absDefaultLauncherPath, err = osFindDefaultLauncher(id, ii.OperatingSystem, ii.LangCode, rdx); err != nil { 244 | return err 245 | } 246 | if et, err = osExecTaskDefaultLauncher(absDefaultLauncherPath, ii.OperatingSystem, et); err != nil { 247 | return err 248 | } 249 | default: 250 | if et, err = osExecTaskGogGameInfo(absGogGameInfoPath, ii.OperatingSystem, et); err != nil { 251 | return err 252 | } 253 | } 254 | 255 | return osExec(id, ii.OperatingSystem, et, rdx, ii.force) 256 | } 257 | 258 | func osFindGogGameInfo(id string, operatingSystem vangogh_integration.OperatingSystem, langCode string, rdx redux.Readable) (string, error) { 259 | 260 | var gogGameInfoPath string 261 | var err error 262 | 263 | switch operatingSystem { 264 | case vangogh_integration.MacOS: 265 | gogGameInfoPath, err = macOsFindGogGameInfo(id, langCode, rdx) 266 | case vangogh_integration.Linux: 267 | gogGameInfoPath, err = linuxFindGogGameInfo(id, langCode, rdx) 268 | case vangogh_integration.Windows: 269 | currentOs := data.CurrentOs() 270 | switch currentOs { 271 | case vangogh_integration.MacOS: 272 | fallthrough 273 | case vangogh_integration.Linux: 274 | gogGameInfoPath, err = prefixFindGogGameInfo(id, langCode, rdx) 275 | case vangogh_integration.Windows: 276 | gogGameInfoPath, err = windowsFindGogGameInfo(id, langCode, rdx) 277 | default: 278 | return "", currentOs.ErrUnsupported() 279 | } 280 | default: 281 | return "", operatingSystem.ErrUnsupported() 282 | } 283 | 284 | if err != nil { 285 | return "", err 286 | } 287 | 288 | return gogGameInfoPath, nil 289 | } 290 | 291 | func osExecTaskGogGameInfo(absGogGameInfoPath string, operatingSystem vangogh_integration.OperatingSystem, et *execTask) (*execTask, error) { 292 | 293 | _, gogGameInfoFilename := filepath.Split(absGogGameInfoPath) 294 | 295 | eggia := nod.Begin(" running %s...", gogGameInfoFilename) 296 | defer eggia.Done() 297 | 298 | gogGameInfo, err := gog_integration.GetGogGameInfo(absGogGameInfoPath) 299 | if err != nil { 300 | return nil, err 301 | } 302 | 303 | switch operatingSystem { 304 | case vangogh_integration.MacOS: 305 | return macOsExecTaskGogGameInfo(absGogGameInfoPath, gogGameInfo, et) 306 | case vangogh_integration.Linux: 307 | return linuxExecTaskGogGameInfo(absGogGameInfoPath, gogGameInfo, et) 308 | case vangogh_integration.Windows: 309 | currentOs := data.CurrentOs() 310 | switch currentOs { 311 | case vangogh_integration.MacOS: 312 | return macOsExecTaskGogGameInfo(absGogGameInfoPath, gogGameInfo, et) 313 | case vangogh_integration.Linux: 314 | return linuxExecTaskGogGameInfo(absGogGameInfoPath, gogGameInfo, et) 315 | case vangogh_integration.Windows: 316 | return windowsExecTaskGogGameInfo(absGogGameInfoPath, gogGameInfo, et) 317 | default: 318 | return nil, currentOs.ErrUnsupported() 319 | } 320 | default: 321 | return nil, operatingSystem.ErrUnsupported() 322 | } 323 | } 324 | 325 | func osFindDefaultLauncher(id string, operatingSystem vangogh_integration.OperatingSystem, langCode string, rdx redux.Readable) (string, error) { 326 | 327 | var defaultLauncherPath string 328 | var err error 329 | 330 | switch operatingSystem { 331 | case vangogh_integration.MacOS: 332 | defaultLauncherPath, err = macOsFindBundleApp(id, langCode, rdx) 333 | case vangogh_integration.Linux: 334 | defaultLauncherPath, err = linuxFindStartSh(id, langCode, rdx) 335 | case vangogh_integration.Windows: 336 | currentOs := data.CurrentOs() 337 | switch currentOs { 338 | case vangogh_integration.MacOS: 339 | fallthrough 340 | case vangogh_integration.Linux: 341 | defaultLauncherPath, err = prefixFindGogGamesLnk(id, langCode, rdx) 342 | case vangogh_integration.Windows: 343 | defaultLauncherPath, err = windowsFindGogGamesLnk(id, langCode, rdx) 344 | default: 345 | return "", currentOs.ErrUnsupported() 346 | } 347 | default: 348 | return "", operatingSystem.ErrUnsupported() 349 | } 350 | 351 | if err != nil { 352 | return "", err 353 | } 354 | 355 | return defaultLauncherPath, nil 356 | } 357 | 358 | func osExecTaskDefaultLauncher(absDefaultLauncherPath string, operatingSystem vangogh_integration.OperatingSystem, et *execTask) (*execTask, error) { 359 | 360 | _, defaultLauncherFilename := filepath.Split(absDefaultLauncherPath) 361 | 362 | et.name = defaultLauncherFilename 363 | 364 | eggia := nod.Begin(" running %s...", defaultLauncherFilename) 365 | defer eggia.Done() 366 | 367 | switch operatingSystem { 368 | case vangogh_integration.MacOS: 369 | return macOsExecTaskBundleApp(absDefaultLauncherPath, et) 370 | case vangogh_integration.Linux: 371 | return linuxExecTaskStartSh(absDefaultLauncherPath, et) 372 | case vangogh_integration.Windows: 373 | currentOs := data.CurrentOs() 374 | switch currentOs { 375 | case vangogh_integration.MacOS: 376 | fallthrough 377 | case vangogh_integration.Linux: 378 | et.exe = absDefaultLauncherPath 379 | case vangogh_integration.Windows: 380 | return windowsExecTaskLnk(absDefaultLauncherPath, et) 381 | default: 382 | return nil, currentOs.ErrUnsupported() 383 | } 384 | default: 385 | return nil, operatingSystem.ErrUnsupported() 386 | } 387 | 388 | return et, nil 389 | } 390 | 391 | func osExec(id string, operatingSystem vangogh_integration.OperatingSystem, et *execTask, rdx redux.Readable, force bool) error { 392 | 393 | switch operatingSystem { 394 | case vangogh_integration.MacOS: 395 | fallthrough 396 | case vangogh_integration.Linux: 397 | return nixRunExecTask(et) 398 | case vangogh_integration.Windows: 399 | currentOs := data.CurrentOs() 400 | switch currentOs { 401 | case vangogh_integration.MacOS: 402 | return macOsWineRunExecTask(et, rdx) 403 | case vangogh_integration.Linux: 404 | return linuxProtonRunExecTask(id, et, rdx, force) 405 | default: 406 | return currentOs.ErrUnsupported() 407 | } 408 | default: 409 | return operatingSystem.ErrUnsupported() 410 | } 411 | } 412 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # theo 2 | 3 | `theo` is a command-line (CLI) client for [vangogh](https://github.com/arelate/vangogh) that allows downloading, installing and running games from your local DRM-free collection. At the moment `theo` implements support for macOS and Linux, tested on Steam Deck. Eventually `theo` hopes to provide a web GUI to manage your local games (see [longer term vision](#Longer_term_theo_vision)) 4 | 5 | ## Installing theo 6 | 7 | To install `theo` you'll need Go installed on your machine. Please follow [official instructions](https://go.dev/doc/install) to do that. You might want to verify that Go binaries folder is part of your PATH or you'll need to navigate to `~/usr/go/bin` to run it. 8 | 9 | When Go is properly installed, you can then run `go install github.com/arelate/theo@latest` to install the latest available version of `theo`. 10 | 11 | ### Installing on Steam Deck 12 | 13 | At the moment `theo` doesn't provide binary distribution or other convenient ways to be installed on a Steam Deck. It can be used on a Steam Deck with the following steps: 14 | 15 | - Use any computer with Go installed - doesn't matter what operating system, all you need is a recent Go version 16 | - Clone the project and navigate to the source code folder 17 | - Compile for Steam Deck: `GOOS=linux GOARCH=amd64 go build -o theo` 18 | - Copy `theo` to a Steam Deck. Doesn't really matter where as `theo` will detect it's own location and will function correctly. For convenience you might consider putting the binary in the `theo` state folder (~/.local/share/theo) 19 | - Navigate to that location with a terminal application of your choice and make `theo` executable `chmod +x theo` 20 | - (You are ready to use `theo`!) 21 | - Run `theo` commands with `./theo` or add that location to your PATH 22 | 23 | ### Longer term theo vision 24 | 25 | In the future `theo` hopes to provide a web GUI to perform all supported operations. Current idea is that this will be based (and share code with) on `vangogh` with additions related to `theo` operations. 26 | 27 | At the moment `theo` development efforts are focused on achieving reliability, functional correctness and robustness of all operations. 28 | 29 | ## Configure theo to connect to vangogh 30 | 31 | Before using any of the commands below, `theo` needs to be set up to connect to `vangogh`: 32 | 33 | `theo connect -protocol -address
-port -username -password ` - you need to provide address (e.g. vangogh.example.com), username and password for users authorized to access API and download files from that `vangogh` instance (this is set in `vangogh` configuration). Other parameters are optional. 34 | 35 | This is only needed to be done once and `theo` will authorize, store session token and validate it as needed. This should work until `vangogh` session is valid. If you ever need to reset this configuration, `connect -reset` can help with that. 36 | 37 | ## Installing games with theo (native versions and Windows versions on macOS, Linux) 38 | 39 | Basic usage of `theo` comes down to the following commands: 40 | 41 | `theo install ` - will install current OS version of a game 42 | 43 | `theo run ` - will run installed version of a game 44 | 45 | `theo uninstall -force` - will uninstall a game 46 | 47 | More helpful commands: 48 | 49 | `theo list -installed` - will print all currently installed games 50 | 51 | `theo reveal -installed ` - will open the directory containing game installation 52 | 53 | ## macOS requirements for Windows games 54 | 55 | On macOS this functionality **requires** a version of [CrossOver](https://www.codeweavers.com/crossover) purchased and licensed for the current user. `theo` assumes CrossOver bundle is located in `/Applications`. Please note that `theo` uses CrossOver 25 environment variables that won't work in version 24 or earlier. At the moment I'm planning to maintain support for the latest version of CrossOver only. In the future alternatives will be considered (e.g. Kegworks, binary WINE distributions, etc). 56 | 57 | ## Linux requirements for Windows games 58 | 59 | On Linux `theo` will use [umu-launcher](https://github.com/Open-Wine-Components/umu-launcher) and [umu-proton](https://github.com/Open-Wine-Components/umu-proton). You don't need to do anything, theo will download the latest versions for you automatically from `vangogh`, as needed. Those will be installed under `theo` state folder, not globally and won't interfere with other apps. 60 | 61 | ## Advanced usage scenarios 62 | 63 | ### Using specific language version 64 | 65 | theo allows you to specify [language](https://en.wikipedia.org/wiki/List_of_ISO_639_language_codes) version to use, when available on vangogh: 66 | 67 | `theo install -lang-code ja` - will install Japanese version of a game (if it's available) 68 | 69 | `theo run -lang-code ja` - will run Japanese version of a game (if it's installed) 70 | 71 | You need to specify language code for every command (e.g. `run`, `uninstall`, `reveal-installed`). With this functionality you can have multiple version of the same game in different languages installed at the same time (applies to native and Windows versions). 72 | 73 | ### Environment variables for Windows versions on another operating systems 74 | 75 | theo helps you manage Windows versions installations and [WINE](http://winehq.org) options with environmental variables: 76 | 77 | `theo set-prefix-env -env VAR1=VAL1 VAR2=VAL2` - will set VAR1 and VAR2 environmental variables for this game installation and those values (VAL1 and VAL2) will be used for any commands under those installations (e.g. `wine-run`) 78 | 79 | More helpful commands: 80 | 81 | `theo default-prefix-env ` - will reset all environment variables to default values for the current operating system 82 | 83 | `theo delete-prefix-env ` - will completely removed all environment variables (even default ones) for this game installation 84 | 85 | `theo list-prefix-env` - will print all environment variables for every game installed with theo 86 | 87 | NOTE: Default environment variables set for each operating systems are listed here: [cli/default_prefix_env.go](https://github.com/arelate/theo/blob/main/cli/default_prefix_env.go#L12). 88 | 89 | ### Steam shortcuts 90 | 91 | theo will automatically add Steam shortcuts (when Steam installation is detected) and will include artwork provided by vangogh. Steam shortcuts are automatically added during `install` and removed during `uninstall` (use `-no-steam-shortcut` if that's not desired). 92 | 93 | While this functionality has been designed around Steam Deck, it works equally well for Steam Big Picture mode on a desktop operating system. 94 | 95 | NOTE: Steam shortcuts are added for all users currently logged into Steam under current operating system user account. 96 | 97 | Additionally you can use the following commands to manage Steam shortcuts: 98 | 99 | `theo add-steam-shortcut ` - will add Steam shortcut for a game 100 | 101 | `theo remove-steam-shortcut ` - will remove Steam shortcut for a game 102 | 103 | `theo list-steam-shortcuts` - will list all existing Steam shortcuts 104 | 105 | ### Retina mode for Windows versions on macOS 106 | 107 | theo provides `mod-prefix-retina` command to set Retina mode. See more information [here](https://gitlab.winehq.org/wine/wine/-/wikis/Commands/winecfg#screen-resolution-dpi-setting). To revert this change use the command with a `revert` flag, e.g.: 108 | 109 | `theo mod-prefix-retina -revert` 110 | 111 | ### Where is theo data stored? 112 | 113 | theo stores all state under the current user data directory: 114 | - on Linux that is ~/.local/share/theo 115 | - on macOS that is ~/Library/Application Support/theo 116 | 117 | ## vangogh technical decisions and resulting theo behaviors 118 | 119 | `vangogh` is build around storing installers (programs that contain all game data and perform copying of game data and related dependencies on a local machine) from GOG. This is a deliberate decision as by itself `vangogh` can operate completely independently without any client. You can download an installer from `vangogh`, run it and have the game installed. In some cases this becomes quite unpractical - consider Cyberpunk 2077 that is 28 installer files and 11 more installer files for the Phantom Liberty DLC. 120 | 121 | As a result - `theo` was built around this to provide convenience. Installing Cyberpunk with DLC will take a single command (or eventually a single click/tap). The fact that `vangogh` uses installers however implies certain behaviours that would be worth calling out: 122 | 123 | - `theo` doesn't implement support for every compression or packaging format and relies on installers doing their jobs. As a result there's no good way to report progress of the installation. `theo` can report download progress, validation progress, etc - but running an installer is an atomic operation. 124 | - installers need to be downloaded and present to work, which means that disk space requirements are doubled. You need space for the installer and installation. Installers are removed upon successful installation, but must be present while installation is in progress. This applies to both native and WINE installations. Using Cyberpunk 2077 as an example you'll need >300Gb for the installation (= 110Gb for the game itself, 40Gb for the DLC and at least the same amount for the resulting installation) 125 | - updates are achieved by installing a new version on top of the old one (not as a partial updated data), which makes the disk space problem even worse (though keep in mind that the installer will overwrite existing files, so it'll still be double requirements, not much worse) 126 | 127 | ### Can that be solved better? 128 | 129 | There are few alternatives to consider to solve this better, that might be implemented later: 130 | 131 | 1. use application depots that GOG Galaxy uses and transition `vangogh` to use those. Considerations: 132 | - Pros: this would solve both progress and double disk space requirements. This would also be aligned with Steam depots and solving this would allow vangogh to support DRM-free Steam games (and potentially more stores, e.g. Epic) 133 | - Cons: this would defeat independent nature of installers. Potentially this con can be solved by providing server-side ability to package depots into installers on demand. This needs to be investigated further: how to package files into installers, how to handle dependencies, how to handle installer scripts, etc - potentially the way GOG Galaxy handles depots likely provides an answer to all those questions. 134 | 2. unpack installers on the `vangogh` server on demand, adding a step in the installation process. Considerations: 135 | - Pros: this would allow to continue relying on installers as the primary stored artifacts 136 | - Cons: this adds additional storage requirements on `vangogh` server, especially if unpacked installers are not discarded right away and are cached. This doesn't solve patching unless the previous version is also available (which by default is actually true today). In that case both versions would need to be unpacked and diffed to create a patch. In general - this doesn't solve the underlying issue of unpacking installers and just pushes it to the server. While certain things might be less of a problem, e.g. disk space - others will continue to be problematic, e.g. very long time to unpack large games with no good way to report progress. 137 | 3. specifically for the patches - GOG provides them in certain cases, however the way they're implemented is not great and unless something changes radically that won't be a great solution: 138 | - Pros: updates might be easier in terms of disk space requirements 139 | - Cons: patches are not used in 100% of situations on GOG. Sometimes there's a patch. Sometimes there's an installer update. GOG also sometimes provides several patches from different versions to another version. Patches metadata is not very formal either - it's all part of the name, which would require heuristics to find the right patch for a given version to another version as well as determining which version is the latest. And of course that would only solve updates problem, but not the others. 140 | 141 | At the moment the first option seems the most beneficial and might get investigated in the future. One of the two must happen to allow more confidence in that path: 142 | - depots are implemented and validated as a viable option solving known limitations and preserving `vangogh` independence from `theo` (e.g. installers or archives are still available as an option somehow - either on demand or as optionally stored) 143 | - `vangogh` independence becomes lower priority than resolving those limitations and newer features (e.g. Steam DRM-free games) combined. In this case `theo` will be required to download, install games from `vangogh`. 144 | 145 | ## Why is it called theo? 146 | 147 | Theodorus van Gogh (1 May 1857 – 25 January 1891) was a Dutch art dealer and the younger brother of Vincent van Gogh. Known as Theo, his support of his older brother's artistic ambitions and well-being allowed Vincent to devote himself entirely to painting. As an art dealer, Theo van Gogh played a crucial role in introducing contemporary French art to the public. 148 | 149 | ## When will theo support native Windows versions on Windows? 150 | 151 | At the moment `theo` is focused on macOS and Linux support. Implementing (native installations) Windows support in the future should be relatively straightforward - a good starting point would be adding actual implementations for stub functions in [cli/windows_support.go](https://github.com/arelate/theo/blob/main/cli/windows_support.go) and then testing assumptions - e.g. [data/user_dirs.go](https://github.com/arelate/theo/blob/main/data/user_dirs.go). 152 | 153 | At the moment this is not a priority, but might happen in the future. 154 | -------------------------------------------------------------------------------- /cli/macos_support.go: -------------------------------------------------------------------------------- 1 | package cli 2 | 3 | import ( 4 | "errors" 5 | "io" 6 | "io/fs" 7 | "os" 8 | "os/exec" 9 | "path/filepath" 10 | "slices" 11 | "strings" 12 | 13 | "github.com/arelate/southern_light/gog_integration" 14 | "github.com/arelate/southern_light/vangogh_integration" 15 | "github.com/arelate/theo/data" 16 | "github.com/boggydigital/nod" 17 | "github.com/boggydigital/pathways" 18 | "github.com/boggydigital/redux" 19 | ) 20 | 21 | const ( 22 | catCmdPfx = "cat " 23 | appBundleExt = ".app" 24 | ) 25 | 26 | const ( 27 | installerTypeGame = "game" 28 | installerTypeDlc = "dlc" 29 | ) 30 | 31 | const relMacOsGogGameInfoDir = "Contents/Resources" 32 | 33 | const pkgExt = ".pkg" 34 | 35 | const ( 36 | relPayloadPath = "package.pkg/Scripts/payload" 37 | ) 38 | 39 | func macOsInstallProduct(id string, 40 | dls vangogh_integration.ProductDownloadLinks, 41 | rdx redux.Writeable, 42 | force bool) error { 43 | 44 | mia := nod.Begin("installing %s for %s...", id, vangogh_integration.MacOS) 45 | defer mia.Done() 46 | 47 | for _, link := range dls { 48 | 49 | if filepath.Ext(link.LocalFilename) != pkgExt { 50 | continue 51 | } 52 | 53 | if err := macOsExtractInstaller(id, &link, force); err != nil { 54 | return err 55 | } 56 | 57 | if err := macOsPlaceExtracts(id, &link, rdx, force); err != nil { 58 | return err 59 | } 60 | 61 | if err := macOsPostInstallActions(id, &link, rdx); err != nil { 62 | return err 63 | } 64 | 65 | } 66 | 67 | if err := macOsRemoveProductExtracts(id, dls); err != nil { 68 | return err 69 | } 70 | 71 | return nil 72 | } 73 | 74 | func macOsExtractInstaller(id string, link *vangogh_integration.ProductDownloadLink, force bool) error { 75 | 76 | meia := nod.Begin(" extracting installer with pkgutil, please wait...") 77 | defer meia.Done() 78 | 79 | if data.CurrentOs() != vangogh_integration.MacOS { 80 | return errors.New("extracting .pkg installers is only supported on " + vangogh_integration.MacOS.String()) 81 | } 82 | 83 | downloadsDir, err := pathways.GetAbsDir(data.Downloads) 84 | if err != nil { 85 | return err 86 | } 87 | 88 | tempDir := os.TempDir() 89 | 90 | productDownloadsDir := filepath.Join(downloadsDir, id) 91 | productExtractsDir := filepath.Join(tempDir, id) 92 | 93 | localFilenameExtractsDir := filepath.Join(productExtractsDir, link.LocalFilename) 94 | // if the product extracts dir already exists - that would imply that the product 95 | // has been extracted already. Remove the directory with contents if forced 96 | // Return early otherwise (if not forced). 97 | if _, err = os.Stat(localFilenameExtractsDir); err == nil { 98 | if force { 99 | if err = os.RemoveAll(localFilenameExtractsDir); err != nil { 100 | return err 101 | } 102 | } else { 103 | return nil 104 | } 105 | } 106 | 107 | productExtractDir, _ := filepath.Split(localFilenameExtractsDir) 108 | if _, err = os.Stat(productExtractDir); os.IsNotExist(err) { 109 | if err = os.MkdirAll(productExtractDir, 0755); err != nil { 110 | return err 111 | } 112 | } 113 | 114 | localDownload := filepath.Join(productDownloadsDir, link.LocalFilename) 115 | 116 | cmd := exec.Command("pkgutil", "--expand-full", localDownload, localFilenameExtractsDir) 117 | cmd.Stdout = os.Stdout 118 | cmd.Stderr = os.Stderr 119 | 120 | return cmd.Run() 121 | } 122 | 123 | func macOsPlaceExtracts(id string, link *vangogh_integration.ProductDownloadLink, rdx redux.Writeable, force bool) error { 124 | 125 | mpea := nod.Begin(" placing product installation files...") 126 | defer mpea.Done() 127 | 128 | if data.CurrentOs() != vangogh_integration.MacOS { 129 | return errors.New("placing .pkg extracts is only supported on " + vangogh_integration.MacOS.String()) 130 | } 131 | 132 | if err := rdx.MustHave(vangogh_integration.SlugProperty); err != nil { 133 | return err 134 | } 135 | 136 | tempDir := os.TempDir() 137 | 138 | productExtractsDir := filepath.Join(tempDir, id) 139 | 140 | absPostInstallScriptPath := PostInstallScriptPath(productExtractsDir, link) 141 | postInstallScript, err := ParsePostInstallScript(absPostInstallScriptPath) 142 | if err != nil { 143 | return err 144 | } 145 | 146 | absExtractPayloadPath := filepath.Join(productExtractsDir, link.LocalFilename, relPayloadPath) 147 | 148 | if _, err = os.Stat(absExtractPayloadPath); os.IsNotExist(err) { 149 | return errors.New("cannot locate extracts payload") 150 | } 151 | 152 | installerType := postInstallScript.InstallerType() 153 | 154 | absBundlePath, err := osInstalledPath(id, vangogh_integration.MacOS, link.LanguageCode, rdx) 155 | 156 | if strings.HasSuffix(postInstallScript.bundleName, appBundleExt) { 157 | absBundlePath = filepath.Join(absBundlePath, postInstallScript.bundleName) 158 | } 159 | 160 | switch installerType { 161 | case installerTypeGame: 162 | return macOsPlaceGame(absExtractPayloadPath, absBundlePath, force) 163 | case installerTypeDlc: 164 | return macOsPlaceDlc(absExtractPayloadPath, absBundlePath, force) 165 | default: 166 | return errors.New("unknown postinstall script installer type: " + installerType) 167 | } 168 | } 169 | 170 | func macOsPlaceGame(absExtractsPayloadPath, absInstallationPath string, force bool) error { 171 | 172 | mpga := nod.Begin(" placing game installation files...") 173 | defer mpga.Done() 174 | 175 | // when installing a game 176 | if _, err := os.Stat(absInstallationPath); err == nil { 177 | if force { 178 | if err = os.RemoveAll(absInstallationPath); err != nil { 179 | return err 180 | } 181 | } else { 182 | // already installed, overwrite won't be forced 183 | return nil 184 | } 185 | } 186 | 187 | installationDir, _ := filepath.Split(absInstallationPath) 188 | if _, err := os.Stat(installationDir); os.IsNotExist(err) { 189 | if err = os.MkdirAll(installationDir, 0755); err != nil { 190 | return err 191 | } 192 | } 193 | 194 | return os.Rename(absExtractsPayloadPath, absInstallationPath) 195 | } 196 | 197 | func macOsPlaceDlc(absExtractsPayloadPath, absInstallationPath string, force bool) error { 198 | 199 | mpda := nod.Begin(" placing downloadable content files...") 200 | defer mpda.Done() 201 | 202 | if _, err := os.Stat(absInstallationPath); os.IsNotExist(err) { 203 | if err := os.MkdirAll(absInstallationPath, 0755); err != nil { 204 | return err 205 | } 206 | } 207 | 208 | // enumerate all DLC files in the payload directory 209 | dlcFiles := make([]string, 0) 210 | 211 | if err := filepath.Walk(absExtractsPayloadPath, func(path string, info fs.FileInfo, err error) error { 212 | if err == nil && !info.IsDir() { 213 | if relPath, err := filepath.Rel(absExtractsPayloadPath, path); err == nil { 214 | dlcFiles = append(dlcFiles, relPath) 215 | } else { 216 | return err 217 | } 218 | } 219 | return nil 220 | }); err != nil { 221 | return err 222 | } 223 | 224 | for _, dlcFile := range dlcFiles { 225 | 226 | absDstPath := filepath.Join(absInstallationPath, dlcFile) 227 | absDstDir, _ := filepath.Split(absDstPath) 228 | 229 | if _, err := os.Stat(absDstDir); os.IsNotExist(err) { 230 | if err := os.MkdirAll(absDstDir, 0755); err != nil { 231 | return err 232 | } 233 | } 234 | 235 | absSrcPath := filepath.Join(absExtractsPayloadPath, dlcFile) 236 | 237 | if err := os.Rename(absSrcPath, absDstPath); err != nil { 238 | return err 239 | } 240 | } 241 | 242 | return nil 243 | } 244 | 245 | func macOsPostInstallActions(id string, 246 | link *vangogh_integration.ProductDownloadLink, 247 | rdx redux.Readable) error { 248 | 249 | mpia := nod.Begin(" performing post-install %s actions for %s...", vangogh_integration.MacOS, id) 250 | defer mpia.Done() 251 | 252 | if filepath.Ext(link.LocalFilename) != pkgExt { 253 | // for macOS - there's nothing to be done for additional files (that are not .pkg installers) 254 | return nil 255 | } 256 | 257 | downloadsDir, err := pathways.GetAbsDir(data.Downloads) 258 | if err != nil { 259 | return err 260 | } 261 | 262 | productDownloadsDir := filepath.Join(downloadsDir, id) 263 | 264 | tempDir := os.TempDir() 265 | productExtractsDir := filepath.Join(tempDir, id) 266 | 267 | absPostInstallScriptPath := PostInstallScriptPath(productExtractsDir, link) 268 | 269 | pis, err := ParsePostInstallScript(absPostInstallScriptPath) 270 | if err != nil { 271 | return err 272 | } 273 | 274 | absBundlePath, err := osInstalledPath(id, vangogh_integration.MacOS, link.LanguageCode, rdx) 275 | if err != nil { 276 | return err 277 | } 278 | 279 | if customCommands := pis.CustomCommands(); len(customCommands) > 0 { 280 | if err = macOsProcessPostInstallScript(customCommands, productDownloadsDir, absBundlePath); err != nil { 281 | return err 282 | } 283 | } 284 | 285 | if err = macOsRemoveXattrs(absBundlePath); err != nil { 286 | return err 287 | } 288 | 289 | return nil 290 | } 291 | 292 | func macOsRemoveXattrs(path string) error { 293 | 294 | mrxa := nod.Begin(" removing xattrs...") 295 | defer mrxa.Done() 296 | 297 | // xattr -cr /Applications/Bundle Name.app 298 | cmd := exec.Command("xattr", "-cr", path) 299 | cmd.Stdout = os.Stdout 300 | cmd.Stderr = os.Stderr 301 | 302 | return cmd.Run() 303 | } 304 | 305 | func macOsProcessPostInstallScript(commands []string, productDownloadsDir, bundleAppPath string) error { 306 | 307 | pcca := nod.NewProgress(" processing post-install commands...") 308 | defer pcca.Done() 309 | 310 | pcca.TotalInt(len(commands)) 311 | 312 | for _, cmd := range commands { 313 | if strings.HasPrefix(cmd, catCmdPfx) { 314 | if catCmdParts := strings.Split(strings.TrimPrefix(cmd, catCmdPfx), " "); len(catCmdParts) == 3 { 315 | srcGlob := strings.Trim(strings.Replace(catCmdParts[0], "\"${pkgpath}\"", productDownloadsDir, 1), "\"") 316 | dstPath := strings.Trim(strings.Replace(catCmdParts[2], "${gog_full_path}", bundleAppPath, 1), "\"") 317 | if err := macOsCatFiles(srcGlob, dstPath); err != nil { 318 | return err 319 | } 320 | } 321 | pcca.Increment() 322 | continue 323 | } 324 | // at this point we've handled all known commands, so anything here would be unknown 325 | return errors.New("cannot process unknown custom command: " + cmd) 326 | } 327 | return nil 328 | } 329 | 330 | func macOsCatFiles(srcGlob string, dstPath string) error { 331 | 332 | if srcGlob == "" { 333 | return errors.New("cat command source glob cannot be empty") 334 | } 335 | if dstPath == "" { 336 | return errors.New("cat command destination path cannot be empty") 337 | } 338 | 339 | if matches, err := filepath.Glob(srcGlob); err == nil && len(matches) == 0 { 340 | return errors.New("no files match pattern: " + srcGlob) 341 | } 342 | 343 | _, srcFileGlob := filepath.Split(srcGlob) 344 | _, dstFilename := filepath.Split(dstPath) 345 | 346 | ecfa := nod.NewProgress(" cat downloads...%s into installed-apps...%s", srcFileGlob, dstFilename) 347 | defer ecfa.Done() 348 | 349 | dstDir, _ := filepath.Split(dstPath) 350 | if _, err := os.Stat(dstDir); os.IsNotExist(err) { 351 | if err := os.MkdirAll(dstDir, 0755); err != nil { 352 | return err 353 | } 354 | } 355 | 356 | dstFile, err := os.Create(dstPath) 357 | if err != nil { 358 | return err 359 | } 360 | defer dstFile.Close() 361 | 362 | matches, err := filepath.Glob(srcGlob) 363 | if err != nil { 364 | return err 365 | } 366 | 367 | slices.Sort(matches) 368 | 369 | ecfa.TotalInt(len(matches)) 370 | 371 | for _, match := range matches { 372 | 373 | srcFile, err := os.Open(match) 374 | if err != nil { 375 | return err 376 | } 377 | 378 | if _, err := io.Copy(dstFile, srcFile); err != nil { 379 | _ = srcFile.Close() 380 | return err 381 | } 382 | 383 | ecfa.Increment() 384 | _ = srcFile.Close() 385 | 386 | if err := os.Remove(match); err != nil { 387 | return err 388 | } 389 | } 390 | 391 | return nil 392 | } 393 | 394 | func macOsRemoveProductExtracts(id string, dls vangogh_integration.ProductDownloadLinks) error { 395 | 396 | rela := nod.Begin(" removing extracts for %s...", id) 397 | defer rela.Done() 398 | 399 | tempDir := os.TempDir() 400 | 401 | idPath := filepath.Join(tempDir, id) 402 | if _, err := os.Stat(idPath); os.IsNotExist(err) { 403 | rela.EndWithResult("product extracts dir not present") 404 | return nil 405 | } 406 | 407 | for _, dl := range dls { 408 | 409 | path := filepath.Join(tempDir, id, dl.LocalFilename) 410 | 411 | fa := nod.NewProgress(" - %s...", dl.LocalFilename) 412 | 413 | if _, err := os.Stat(path); os.IsNotExist(err) { 414 | fa.EndWithResult("not present") 415 | continue 416 | } 417 | 418 | if err := os.RemoveAll(path); err != nil { 419 | return err 420 | } 421 | 422 | fa.Done() 423 | } 424 | 425 | rdda := nod.Begin(" removing empty product extracts directory...") 426 | if empty, err := osIsDirEmpty(idPath); empty && err == nil { 427 | if err = os.RemoveAll(idPath); err != nil { 428 | return err 429 | } 430 | } else if err != nil { 431 | return err 432 | } 433 | rdda.Done() 434 | 435 | return nil 436 | } 437 | 438 | func macOsIsDirEmptyOrDsStoreOnly(entries []fs.DirEntry) bool { 439 | if len(entries) == 0 { 440 | return true 441 | } 442 | if len(entries) == 1 { 443 | return entries[0].Name() == ".DS_Store" 444 | } 445 | return false 446 | } 447 | 448 | func macOsReveal(path string) error { 449 | cmd := exec.Command("open", "-R", path) 450 | return cmd.Run() 451 | } 452 | 453 | func macOsFindGogGameInfo(id, langCode string, rdx redux.Readable) (string, error) { 454 | 455 | absBundleAppPath, err := macOsFindBundleApp(id, langCode, rdx) 456 | if err != nil { 457 | return "", err 458 | } 459 | 460 | gogGameInfoFilename := strings.Replace(gog_integration.GogGameInfoFilenameTemplate, "{id}", id, 1) 461 | 462 | absGogGameInfoPath := filepath.Join(absBundleAppPath, relMacOsGogGameInfoDir, gogGameInfoFilename) 463 | 464 | if _, err = os.Stat(absGogGameInfoPath); err == nil { 465 | return absGogGameInfoPath, nil 466 | } else if os.IsNotExist(err) { 467 | // some GOG games put Contents/Resources in the top install location, not app bundle 468 | 469 | var absInstalledPath string 470 | absInstalledPath, err = osInstalledPath(id, vangogh_integration.MacOS, langCode, rdx) 471 | if err != nil { 472 | return "", err 473 | } 474 | 475 | absGogGameInfoPath = filepath.Join(absInstalledPath, relMacOsGogGameInfoDir, gogGameInfoFilename) 476 | if _, err = os.Stat(absGogGameInfoPath); err == nil { 477 | return absGogGameInfoPath, nil 478 | } 479 | } else { 480 | return "", err 481 | } 482 | 483 | return "", nil 484 | } 485 | 486 | func macOsFindBundleApp(id, langCode string, rdx redux.Readable) (string, error) { 487 | 488 | absInstalledPath, err := osInstalledPath(id, vangogh_integration.MacOS, langCode, rdx) 489 | if err != nil { 490 | return "", err 491 | } 492 | 493 | if strings.HasSuffix(absInstalledPath, appBundleExt) { 494 | return absInstalledPath, nil 495 | } 496 | 497 | var matches []string 498 | if matches, err = filepath.Glob(filepath.Join(absInstalledPath, "*"+appBundleExt)); err == nil { 499 | if len(matches) == 1 { 500 | return matches[0], nil 501 | } 502 | } 503 | 504 | return "", errors.New("cannot locate macOS bundle.app for " + id) 505 | } 506 | 507 | func macOsExecTaskGogGameInfo(absGogGameInfoPath string, gogGameInfo *gog_integration.GogGameInfo, et *execTask) (*execTask, error) { 508 | 509 | pt, err := gogGameInfo.GetPlayTask(et.playTask) 510 | if err != nil { 511 | return nil, err 512 | } 513 | 514 | absGogGameInfoDir, _ := filepath.Split(absGogGameInfoPath) 515 | absExeRootDir := strings.TrimSuffix(absGogGameInfoDir, relMacOsGogGameInfoDir+"/") 516 | 517 | exePath := pt.Path 518 | // account for Windows-style relative paths, e.g. DOSBOX\DOSBOX.exe 519 | if parts := strings.Split(exePath, "\\"); len(parts) > 1 { 520 | exePath = filepath.Join(parts...) 521 | } 522 | 523 | absExePath := filepath.Join(absExeRootDir, exePath) 524 | 525 | et.name = pt.Name 526 | et.exe = absExePath 527 | et.workDir = filepath.Join(absExeRootDir, pt.WorkingDir) 528 | 529 | if pt.Arguments != "" { 530 | et.args = append(et.args, pt.Arguments) 531 | } 532 | 533 | return et, nil 534 | } 535 | 536 | func macOsExecTaskBundleApp(absBundleAppPath string, et *execTask) (*execTask, error) { 537 | 538 | et.exe = "open" 539 | et.args = append([]string{absBundleAppPath}, et.args...) 540 | 541 | return et, nil 542 | } 543 | 544 | func osInstalledPath(id string, operatingSystem vangogh_integration.OperatingSystem, langCode string, rdx redux.Readable) (string, error) { 545 | 546 | installedAppsDir, err := pathways.GetAbsDir(data.InstalledApps) 547 | if err != nil { 548 | return "", err 549 | } 550 | 551 | osLangInstalledAppsDir := filepath.Join(installedAppsDir, data.OsLangCode(operatingSystem, langCode)) 552 | 553 | if err = rdx.MustHave(vangogh_integration.SlugProperty); err != nil { 554 | return "", err 555 | } 556 | 557 | var appBundle string 558 | if slug, ok := rdx.GetLastVal(vangogh_integration.SlugProperty, id); ok && slug != "" { 559 | appBundle = slug 560 | } else { 561 | return "", errors.New("slug is not defined for product " + id) 562 | } 563 | 564 | return filepath.Join(osLangInstalledAppsDir, appBundle), nil 565 | } 566 | -------------------------------------------------------------------------------- /cli/prefix.go: -------------------------------------------------------------------------------- 1 | package cli 2 | 3 | import ( 4 | "bytes" 5 | "errors" 6 | "fmt" 7 | "io" 8 | "net/url" 9 | "os" 10 | "path" 11 | "path/filepath" 12 | "slices" 13 | "strings" 14 | 15 | "github.com/arelate/southern_light/vangogh_integration" 16 | "github.com/arelate/southern_light/wine_integration" 17 | "github.com/arelate/theo/data" 18 | "github.com/boggydigital/backups" 19 | "github.com/boggydigital/nod" 20 | "github.com/boggydigital/pathways" 21 | "github.com/boggydigital/redux" 22 | ) 23 | 24 | var osEnvDefaults = map[vangogh_integration.OperatingSystem][]string{ 25 | vangogh_integration.MacOS: { 26 | "CX_GRAPHICS_BACKEND=d3dmetal", // other values: dxmt, dxvk, wined3d 27 | "WINEMSYNC=1", 28 | "WINEESYNC=0", 29 | "ROSETTA_ADVERTISE_AVX=1", 30 | // "MTL_HUD_ENABLED=1", // not a candidate for default value, adding for reference 31 | }, 32 | } 33 | 34 | func PrefixHandler(u *url.URL) error { 35 | 36 | q := u.Query() 37 | 38 | id := q.Get(vangogh_integration.IdProperty) 39 | 40 | var langCode string 41 | if q.Has(vangogh_integration.LanguageCodeProperty) { 42 | langCode = q.Get(vangogh_integration.LanguageCodeProperty) 43 | } 44 | 45 | ii := &InstallInfo{ 46 | OperatingSystem: vangogh_integration.Windows, 47 | LangCode: langCode, 48 | force: q.Has("force"), 49 | } 50 | 51 | et := &execTask{ 52 | exe: q.Get("exe"), 53 | verbose: q.Has("verbose"), 54 | } 55 | 56 | if q.Has("env") { 57 | et.env = strings.Split(q.Get("env"), ",") 58 | } 59 | 60 | if q.Has("arg") { 61 | et.args = strings.Split(q.Get("arg"), ",") 62 | } 63 | 64 | mod := q.Get("mod") 65 | program := q.Get("program") 66 | installWineBinary := q.Get("install-wine-binary") 67 | 68 | defaultEnv := q.Has("default-env") 69 | deleteEnv := q.Has("delete-env") 70 | 71 | deleteExe := q.Has("delete-exe") 72 | 73 | deleteArg := q.Has("delete-arg") 74 | 75 | info := q.Has("info") 76 | archive := q.Has("archive") 77 | remove := q.Has("remove") 78 | 79 | return Prefix(id, ii, 80 | mod, program, installWineBinary, 81 | defaultEnv, deleteEnv, deleteExe, deleteArg, 82 | info, archive, remove, 83 | et) 84 | } 85 | 86 | func Prefix(id string, ii *InstallInfo, 87 | mod, program, installWineBinary string, 88 | defaultEnv, deleteEnv, deleteExe, deleteArg bool, 89 | info, archive, remove bool, 90 | et *execTask) error { 91 | 92 | reduxDir, err := pathways.GetAbsRelDir(data.Redux) 93 | if err != nil { 94 | return err 95 | } 96 | 97 | rdx, err := redux.NewWriter(reduxDir, data.AllProperties()...) 98 | if err != nil { 99 | return err 100 | } 101 | 102 | if err = resolveInstallInfo(id, ii, nil, rdx, installedLangCode); err != nil { 103 | return err 104 | } 105 | 106 | absPrefixDir, err := data.GetAbsPrefixDir(id, ii.LangCode, rdx) 107 | if err != nil { 108 | return err 109 | } 110 | 111 | et.prefix = absPrefixDir 112 | 113 | if deleteEnv { 114 | if err = prefixDeleteProperty(id, ii.LangCode, data.PrefixEnvProperty, rdx, ii.force); err != nil { 115 | return err 116 | } 117 | } 118 | 119 | if defaultEnv { 120 | if err = prefixDefaultEnv(id, ii.LangCode, rdx); err != nil { 121 | return err 122 | } 123 | } 124 | 125 | if deleteExe { 126 | if err = prefixDeleteProperty(id, ii.LangCode, data.PrefixExeProperty, rdx, ii.force); err != nil { 127 | return err 128 | } 129 | } 130 | 131 | if deleteArg { 132 | if err = prefixDeleteProperty(id, ii.LangCode, data.PrefixArgProperty, rdx, ii.force); err != nil { 133 | return err 134 | } 135 | } 136 | 137 | if len(et.env) > 0 { 138 | if err = prefixSetEnv(id, ii.LangCode, et.env, rdx); err != nil { 139 | return err 140 | } 141 | } 142 | 143 | if et.exe != "" { 144 | if err = prefixSetExe(id, ii.LangCode, et.exe, rdx); err != nil { 145 | return err 146 | } 147 | } 148 | 149 | if len(et.args) > 0 { 150 | if err = prefixSetArgs(id, ii.LangCode, et.args, rdx); err != nil { 151 | return err 152 | } 153 | } 154 | 155 | if info { 156 | if err = prefixInfo(id, ii.LangCode, rdx); err != nil { 157 | return err 158 | } 159 | } 160 | 161 | if mod != "" { 162 | 163 | switch mod { 164 | case prefixModEnableRetina: 165 | if err = prefixModRetina(id, ii.LangCode, false, rdx, et.verbose, ii.force); err != nil { 166 | return err 167 | } 168 | case prefixModDisableRetina: 169 | if err = prefixModRetina(id, ii.LangCode, true, rdx, et.verbose, ii.force); err != nil { 170 | return err 171 | } 172 | } 173 | 174 | } 175 | 176 | if program != "" { 177 | 178 | if !slices.Contains(wine_integration.WinePrograms(), program) { 179 | return errors.New("unknown prefix WINE program " + program) 180 | } 181 | 182 | et.name = program 183 | et.exe = program 184 | 185 | if err = osExec(id, vangogh_integration.Windows, et, rdx, false); err != nil { 186 | return err 187 | } 188 | 189 | } 190 | 191 | if installWineBinary != "" { 192 | 193 | if !slices.Contains(wine_integration.WineBinariesCodes(), installWineBinary) { 194 | return errors.New("unknown WINE binary " + installWineBinary) 195 | } 196 | 197 | var requestedWineBinary *wine_integration.Binary 198 | for _, binary := range wine_integration.OsWineBinaries { 199 | if binary.OS == vangogh_integration.Windows && binary.Code == installWineBinary { 200 | requestedWineBinary = &binary 201 | } 202 | } 203 | 204 | if requestedWineBinary == nil { 205 | return errors.New("no match for WINE binary code " + installWineBinary) 206 | } 207 | 208 | // TODO: this would only support direct download sources. 209 | // Currently all coded WINE binaries are direct download sources, so this if fine for now. 210 | wbFilename := path.Base(requestedWineBinary.DownloadUrl) 211 | 212 | var wineDownloadsDir string 213 | wineDownloadsDir, err = pathways.GetAbsRelDir(data.WineDownloads) 214 | if err != nil { 215 | return err 216 | } 217 | 218 | et.name = requestedWineBinary.String() 219 | et.exe = filepath.Join(wineDownloadsDir, wbFilename) 220 | 221 | if args, ok := wine_integration.WineBinariesCodesArgs[installWineBinary]; ok { 222 | et.args = args 223 | } 224 | 225 | if _, err = os.Stat(et.exe); os.IsNotExist(err) { 226 | return errors.New("matched WINE binary not found, use setup-wine to download") 227 | } 228 | 229 | if err = osExec(id, vangogh_integration.Windows, et, rdx, false); err != nil { 230 | return err 231 | } 232 | 233 | } 234 | 235 | if archive { 236 | if err = archiveProductPrefix(id, ii.LangCode); err != nil { 237 | return err 238 | } 239 | } 240 | 241 | if remove { 242 | if err = removeProductPrefix(id, ii.LangCode, rdx, ii.force); err != nil { 243 | return err 244 | } 245 | } 246 | 247 | return nil 248 | } 249 | 250 | func archiveProductPrefix(id, langCode string) error { 251 | 252 | appa := nod.Begin("archiving prefix for %s...", id) 253 | defer appa.Done() 254 | 255 | reduxDir, err := pathways.GetAbsRelDir(data.Redux) 256 | if err != nil { 257 | return err 258 | } 259 | 260 | rdx, err := redux.NewReader(reduxDir, vangogh_integration.SlugProperty) 261 | if err != nil { 262 | return err 263 | } 264 | 265 | prefixArchiveDir, err := pathways.GetAbsRelDir(data.PrefixArchive) 266 | if err != nil { 267 | return err 268 | } 269 | 270 | prefixName, err := data.GetPrefixName(id, rdx) 271 | if err != nil { 272 | return err 273 | } 274 | 275 | absPrefixNameArchiveDir := filepath.Join(prefixArchiveDir, prefixName) 276 | 277 | absPrefixDir, err := data.GetAbsPrefixDir(id, langCode, rdx) 278 | if err != nil { 279 | return err 280 | } 281 | 282 | if _, err = os.Stat(absPrefixNameArchiveDir); os.IsNotExist(err) { 283 | if err = os.MkdirAll(absPrefixNameArchiveDir, 0755); err != nil { 284 | return err 285 | } 286 | } 287 | 288 | if err = backups.Compress(absPrefixDir, absPrefixNameArchiveDir); err != nil { 289 | return err 290 | } 291 | 292 | return cleanupProductPrefixArchive(absPrefixNameArchiveDir) 293 | } 294 | 295 | func cleanupProductPrefixArchive(absPrefixNameArchiveDir string) error { 296 | cppa := nod.NewProgress(" cleaning up old prefix archives...") 297 | defer cppa.Done() 298 | 299 | return backups.Cleanup(absPrefixNameArchiveDir, true, cppa) 300 | } 301 | 302 | func prefixModRetina(id, langCode string, revert bool, rdx redux.Writeable, verbose, force bool) error { 303 | 304 | mpa := nod.Begin("modding retina in prefix for %s...", id) 305 | defer mpa.Done() 306 | 307 | if data.CurrentOs() != vangogh_integration.MacOS { 308 | mpa.EndWithResult("retina prefix mod is only applicable to %s", vangogh_integration.MacOS) 309 | return nil 310 | } 311 | 312 | if err := rdx.MustHave(vangogh_integration.SlugProperty, data.PrefixEnvProperty, data.PrefixExeProperty); err != nil { 313 | return err 314 | } 315 | 316 | absPrefixDir, err := data.GetAbsPrefixDir(id, langCode, rdx) 317 | if err != nil { 318 | return err 319 | } 320 | 321 | absDriveCroot := filepath.Join(absPrefixDir, relPrefixDriveCDir) 322 | 323 | regFilename := retinaOnFilename 324 | regContent := retinaOnReg 325 | if revert { 326 | regFilename = retinaOffFilename 327 | regContent = retinaOffReg 328 | } 329 | 330 | absRegPath := filepath.Join(absDriveCroot, regFilename) 331 | if _, err = os.Stat(absRegPath); os.IsNotExist(err) || (err == nil && force) { 332 | if err = createRegFile(absRegPath, regContent); err != nil { 333 | return err 334 | } 335 | } else if err != nil { 336 | return err 337 | } 338 | 339 | et := &execTask{ 340 | exe: regeditBin, 341 | workDir: absDriveCroot, 342 | args: []string{absRegPath}, 343 | verbose: verbose, 344 | } 345 | 346 | switch data.CurrentOs() { 347 | case vangogh_integration.MacOS: 348 | if err := macOsWineRun(id, langCode, rdx, et, force); err != nil { 349 | return err 350 | } 351 | default: 352 | // do nothing 353 | return nil 354 | } 355 | return nil 356 | } 357 | 358 | func createRegFile(absPath string, content []byte) error { 359 | 360 | regFile, err := os.Create(absPath) 361 | if err != nil { 362 | return err 363 | } 364 | defer regFile.Close() 365 | 366 | if _, err := io.Copy(regFile, bytes.NewReader(content)); err != nil { 367 | return err 368 | } 369 | 370 | return nil 371 | } 372 | 373 | func removeProductPrefix(id, langCode string, rdx redux.Readable, force bool) error { 374 | rppa := nod.Begin(" removing installed files from prefix for %s...", id) 375 | defer rppa.Done() 376 | 377 | if err := rdx.MustHave(vangogh_integration.SlugProperty); err != nil { 378 | return err 379 | } 380 | 381 | absPrefixDir, err := data.GetAbsPrefixDir(id, langCode, rdx) 382 | if err != nil { 383 | return err 384 | } 385 | 386 | if _, err = os.Stat(absPrefixDir); os.IsNotExist(err) { 387 | rppa.EndWithResult("not present") 388 | return nil 389 | } 390 | 391 | if !force { 392 | rppa.EndWithResult("found prefix, use -force to remove") 393 | return nil 394 | } 395 | 396 | relInventoryFiles, err := readInventory(id, langCode, vangogh_integration.Windows, rdx) 397 | if os.IsNotExist(err) { 398 | rppa.EndWithResult("installed files inventory not found") 399 | return nil 400 | } else if err != nil { 401 | return err 402 | } 403 | 404 | if err = removePrefixInstalledFiles(absPrefixDir, relInventoryFiles...); err != nil { 405 | return err 406 | } 407 | 408 | if err = removePrefixDirs(absPrefixDir, relInventoryFiles...); err != nil { 409 | return err 410 | } 411 | 412 | return nil 413 | } 414 | 415 | func removePrefixInstalledFiles(absPrefixDir string, relFiles ...string) error { 416 | rpifa := nod.NewProgress(" removing inventoried files in prefix...") 417 | defer rpifa.Done() 418 | 419 | rpifa.TotalInt(len(relFiles)) 420 | 421 | for _, relFile := range relFiles { 422 | 423 | absInventoryFile := filepath.Join(absPrefixDir, relFile) 424 | if stat, err := os.Stat(absInventoryFile); err == nil && !stat.IsDir() { 425 | if err = os.Remove(absInventoryFile); err != nil { 426 | return err 427 | } 428 | } 429 | 430 | rpifa.Increment() 431 | } 432 | 433 | return nil 434 | } 435 | 436 | func removePrefixDirs(absPrefixDir string, relFiles ...string) error { 437 | rpda := nod.NewProgress(" removing prefix empty directories...") 438 | defer rpda.Done() 439 | 440 | rpda.TotalInt(len(relFiles)) 441 | 442 | // filepath.Walk adds files in lexical order and for removal we want to reverse that to attempt to remove 443 | // leafs first, roots last 444 | slices.Reverse(relFiles) 445 | 446 | for _, relFile := range relFiles { 447 | 448 | absDir := filepath.Join(absPrefixDir, relFile) 449 | if stat, err := os.Stat(absDir); err == nil && stat.IsDir() { 450 | var empty bool 451 | if empty, err = osIsDirEmpty(absDir); empty && err == nil { 452 | if err = os.RemoveAll(absDir); err != nil { 453 | return err 454 | } 455 | } else if err != nil { 456 | return err 457 | } 458 | } 459 | 460 | rpda.Increment() 461 | } 462 | 463 | return nil 464 | } 465 | 466 | func prefixSetEnv(id, langCode string, env []string, rdx redux.Writeable) error { 467 | 468 | spea := nod.Begin("setting %s...", data.PrefixEnvProperty) 469 | defer spea.Done() 470 | 471 | if err := rdx.MustHave(vangogh_integration.SlugProperty, data.PrefixEnvProperty); err != nil { 472 | return err 473 | } 474 | 475 | newEnvs := make(map[string][]string) 476 | 477 | prefixName, err := data.GetPrefixName(id, rdx) 478 | if err != nil { 479 | return err 480 | } 481 | 482 | curEnv, _ := rdx.GetAllValues(data.PrefixEnvProperty, path.Join(prefixName, langCode)) 483 | newEnvs[path.Join(prefixName, langCode)] = mergeEnv(curEnv, env) 484 | 485 | if err = rdx.BatchReplaceValues(data.PrefixEnvProperty, newEnvs); err != nil { 486 | return err 487 | } 488 | 489 | return nil 490 | } 491 | 492 | func mergeEnv(env1 []string, env2 []string) []string { 493 | de1, de2 := decodeEnv(env1), decodeEnv(env2) 494 | for k, v := range de2 { 495 | de1[k] = v 496 | } 497 | return encodeEnv(de1) 498 | } 499 | 500 | func decodeEnv(env []string) map[string]string { 501 | de := make(map[string]string, len(env)) 502 | for _, e := range env { 503 | if k, v, ok := strings.Cut(e, "="); ok { 504 | de[k] = v 505 | } 506 | } 507 | return de 508 | } 509 | 510 | func encodeEnv(de map[string]string) []string { 511 | ee := make([]string, 0, len(de)) 512 | for k, v := range de { 513 | ee = append(ee, k+"="+v) 514 | } 515 | return ee 516 | } 517 | 518 | func prefixSetExe(id, langCode string, exe string, rdx redux.Writeable) error { 519 | 520 | spepa := nod.Begin("setting %s...", data.PrefixExeProperty) 521 | defer spepa.Done() 522 | 523 | if strings.HasPrefix(exe, ".") || 524 | strings.HasPrefix(exe, "/") { 525 | spepa.EndWithResult("exe path must be relative and cannot start with . or /") 526 | return nil 527 | } 528 | 529 | if err := rdx.MustHave(vangogh_integration.SlugProperty, data.PrefixExeProperty); err != nil { 530 | return err 531 | } 532 | 533 | absPrefixDir, err := data.GetAbsPrefixDir(id, langCode, rdx) 534 | if err != nil { 535 | return err 536 | } 537 | 538 | absExePath := filepath.Join(absPrefixDir, relPrefixDriveCDir, exe) 539 | if _, err = os.Stat(absExePath); err != nil { 540 | return err 541 | } 542 | 543 | prefixName, err := data.GetPrefixName(id, rdx) 544 | if err != nil { 545 | return err 546 | } 547 | 548 | langPrefixName := path.Join(prefixName, langCode) 549 | 550 | return rdx.ReplaceValues(data.PrefixExeProperty, langPrefixName, exe) 551 | } 552 | 553 | func prefixSetArgs(id, langCode string, args []string, rdx redux.Writeable) error { 554 | 555 | spepa := nod.Begin("setting %s...", data.PrefixArgProperty) 556 | defer spepa.Done() 557 | 558 | if err := rdx.MustHave(vangogh_integration.SlugProperty, data.PrefixArgProperty); err != nil { 559 | return err 560 | } 561 | 562 | prefixName, err := data.GetPrefixName(id, rdx) 563 | if err != nil { 564 | return err 565 | } 566 | 567 | langPrefixName := path.Join(prefixName, langCode) 568 | 569 | return rdx.ReplaceValues(data.PrefixArgProperty, langPrefixName, args...) 570 | } 571 | 572 | func prefixInfo(id, langCode string, rdx redux.Readable) error { 573 | 574 | pia := nod.Begin("looking up prefix details...") 575 | defer pia.Done() 576 | 577 | if err := rdx.MustHave(vangogh_integration.TitleProperty, 578 | data.PrefixEnvProperty, 579 | data.PrefixExeProperty); err != nil { 580 | return err 581 | } 582 | 583 | prefixName, err := data.GetPrefixName(id, rdx) 584 | if err != nil { 585 | return err 586 | } 587 | langPrefixName := path.Join(prefixName, langCode) 588 | 589 | summary := make(map[string][]string) 590 | 591 | properties := []string{data.PrefixEnvProperty, data.PrefixExeProperty, data.PrefixArgProperty} 592 | 593 | for _, p := range properties { 594 | if values, ok := rdx.GetAllValues(p, langPrefixName); ok { 595 | for _, value := range values { 596 | summary[langPrefixName] = append(summary[langPrefixName], fmt.Sprintf("%s:%s", p, value)) 597 | } 598 | } 599 | } 600 | 601 | if len(summary) == 0 { 602 | pia.EndWithResult("found nothing") 603 | } else { 604 | pia.EndWithSummary("results:", summary) 605 | } 606 | 607 | return nil 608 | } 609 | 610 | func prefixDefaultEnv(id, langCode string, rdx redux.Writeable) error { 611 | 612 | pdea := nod.Begin("defaulting prefix environment variables...") 613 | defer pdea.Done() 614 | 615 | if err := rdx.MustHave(vangogh_integration.SlugProperty, data.PrefixEnvProperty); err != nil { 616 | return err 617 | } 618 | 619 | prefixName, err := data.GetPrefixName(id, rdx) 620 | if err != nil { 621 | return err 622 | } 623 | 624 | langPrefixName := path.Join(prefixName, langCode) 625 | 626 | return rdx.ReplaceValues(data.PrefixEnvProperty, langPrefixName, osEnvDefaults[data.CurrentOs()]...) 627 | } 628 | 629 | func prefixDeleteProperty(id, langCode, property string, rdx redux.Writeable, force bool) error { 630 | pdea := nod.Begin("deleting %s...", property) 631 | defer pdea.Done() 632 | 633 | if !force { 634 | pdea.EndWithResult("this operation requires -force flag") 635 | return nil 636 | } 637 | 638 | if err := rdx.MustHave(vangogh_integration.SlugProperty, property); err != nil { 639 | return err 640 | } 641 | 642 | prefixName, err := data.GetPrefixName(id, rdx) 643 | if err != nil { 644 | return err 645 | } 646 | 647 | langPrefixName := path.Join(prefixName, langCode) 648 | 649 | return rdx.CutKeys(property, langPrefixName) 650 | } 651 | --------------------------------------------------------------------------------