From b75e2ec2e00db6e24985fc606eb84e5ed074b0d9 Mon Sep 17 00:00:00 2001 From: Brunelli Cupello Date: Fri, 21 Aug 2026 15:36:55 -0300 Subject: [PATCH 1/2] fix: one unknown storefront tag no longer empties the whole library `AnyGame.init(from:)` decoded the storefront tag with `try`, so a tag the running build does not recognise threw. `GameDataStore` decodes the whole `[AnyGame]` array in one call and `UserDefaults.decodeAndGet` swallows any error into `nil`, which the observer then reads as an empty library -- so a single unreadable row discards every game from every other storefront too. Measured on 0.6.0 by seeding `games` with one unknown storefront tag plus one ordinary local game: 0.6.0 0 of 2 survive -- library wiped, local game included this change 2 of 2 survive -- unknown tag degraded, local untouched Worth shipping ahead of any new storefront case, because the build that needs the tolerance is the older one: by the time a new tag exists on disk, the release that cannot read it is already out. `Game.storefront` on the base class logs instead of asserting, since a bare `Game` is now a legitimate decode outcome rather than a programmer error. Co-Authored-By: Claude Opus 5 --- Mythic/Utilities/Game/Game.swift | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/Mythic/Utilities/Game/Game.swift b/Mythic/Utilities/Game/Game.swift index 64fd306f..3b6d7add 100644 --- a/Mythic/Utilities/Game/Game.swift +++ b/Mythic/Utilities/Game/Game.swift @@ -19,7 +19,10 @@ import AppKit var installationState: InstallationState var storefront: Storefront? { - assertionFailure("Storefront must always be populated by subclasses, when accessed.") + // Not an assertion: `AnyGame` deliberately degrades an unrecognised storefront tag to a bare + // `Game` rather than failing the whole library decode, so instances of this class do + // legitimately reach here. + Logger.app.warning("Storefront read on a base Game (\(self.id, privacy: .public)); it has no storefront.") return nil } @@ -268,7 +271,11 @@ struct AnyGame: Codable, Equatable { init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: Game.CodingKeys.self) - let storefront = try container.decodeIfPresent(Game.Storefront.self, forKey: .storefront) + // `try?`, not `try`: a storefront tag this build does not recognise must degrade to a plain + // `Game`, never fail. `decodeAndGet` decodes the whole `[AnyGame]` array in one call and + // swallows any error into `nil`, so one unreadable row empties the entire library -- every + // game from every other storefront with it. + let storefront = try? container.decodeIfPresent(Game.Storefront.self, forKey: .storefront) self.base = try { switch storefront { From 081ca7e8e4ec2dce199b6a15ffb7f3044480bb08 Mon Sep 17 00:00:00 2001 From: Brunelli Cupello Date: Fri, 21 Aug 2026 15:39:47 -0300 Subject: [PATCH 2/2] feat(`Steam`): add the SteamCMD acquisition path Implements most of the Steam Development Roadmap on the current engine. SteamCMD is a native macOS binary and never involves Wine, so installing, updating, verifying and enumerating all work today; Wine is only involved in launching a downloaded title, through the path that already exists. Covers the board's "SteamCMD installation", "Functional signin + steam guard support", "Fetch game list", "Game installing + progress handling", "Game moving", "Game update checker", "Custom install directory" and "SteamCMD autoupdate parser". **Layering.** `ValveDataFormat` and `SteamCMDOutput` are dependency-free readers for Valve's KeyValues format and for SteamCMD's own output. `SteamCMD` drives the binary; `Steam` is the storefront service, the peer of `Legendary`; `SteamGameManager` conforms to `StorefrontGameManager`. **Things that had to be measured rather than assumed**, recorded at the call sites because each one produced wrong behaviour first: - `licenses_print` caps the app IDs it prints per package and says so (`(N in total)`). On a real account 171 of 455 sat behind one elided line, recovered by re-reading that package with `package_info_print`. - `@ShutdownOnFailedCommand` defaults to 1, which abandons every remaining command once one fails. An empty `app_info_print` counts, so a 25-app metadata batch returned exactly one result. - `common/type` casing is inconsistent -- `game` for Terraria and Dota 2, `Game` for Palworld, in one batch -- so it is normalised before the filter that keeps DLC and soundtracks out of a game library. - SteamCMD's bootstrapper output is localised mid-stream, so only its numeric `[ n%]` / `[----]` prefixes are matched, never English words. - `force_install_dir` must precede `login`, or SteamCMD refuses outright. - Exit code 42 *is* the self-update mechanism, handled by `steamcmd.sh`, which is why the script is invoked rather than the binary. - The mobile-authenticator flow reports its outcome on its own line (`Waiting for confirmation...OK`); the `to Steam Public...` line never gains a verdict in that case. - SteamCMD writes its session relative to `HOME`, which by default is the user's real Steam installation. `HOME` is scoped: measured at 15 files written under Mythic's own directory and zero touched in the user's. - Bulk output is read whole rather than streamed. `Process.runStreamed` splits whatever a single `read()` returned, so a line spanning two reads arrives as two fragments; an enumeration that received 188 licence lines parsed 7 of them. Streaming stays for downloads and for signing in. - Concurrent SteamCMD runs share one session directory and corrupt each other rather than failing: two overlapping enumerations returned 4 and 18 app IDs where one returns 455. Runs are serialised, and enumeration collapses concurrent callers onto one task. **Deliberately not attempted.** Titles that authenticate through a live Steam client cannot launch: the client's Chromium 126 helper does not survive the engine's `wine-7.7`. They download correctly and then fail to start, and that failure now explains itself rather than silently doing nothing. `Fetch game list` covers owned titles; see the issue for why a complete list needs one of three separate problems solved first. Localisation: the pt-BR values are only for strings introduced here, added while testing in that locale -- happy to strip them if Crowdin should own them. No existing catalogue entry is modified. Co-Authored-By: Claude Opus 5 --- Mythic.xcodeproj/project.pbxproj | 52 ++ Mythic/Localizable.xcstrings | 770 ++++++++++++++++++ Mythic/Utilities/Game/Game+Extensions.swift | 2 + Mythic/Utilities/Game/Game.swift | 12 +- Mythic/Utilities/Game/SteamGame.swift | 129 ++- Mythic/Utilities/GameDataStore.swift | 80 +- .../GameManager/SteamGameManager.swift | 436 ++++++++++ .../GameOperation/GameOperationManager.swift | 13 +- Mythic/Utilities/Steam/SteamCMD.swift | 509 ++++++++++++ Mythic/Utilities/Steam/SteamCMDOutput.swift | 216 +++++ Mythic/Utilities/Steam/SteamInterface.swift | 537 ++++++++++++ Mythic/Utilities/Steam/ValveDataFormat.swift | 488 +++++++++++ Mythic/Views/Navigation/AccountsView.swift | 70 +- Mythic/Views/Navigation/LibraryView.swift | 24 +- .../GameCard/GameCard+Extensions.swift | 22 +- .../Unified/Components/GameListView.swift | 106 ++- .../Unified/Models/GameListViewModel.swift | 34 + .../GameImportView/GameImportView.swift | 14 +- .../GameImportView/SteamGameImportView.swift | 227 ++++++ .../Steam/SteamGameInstallationView.swift | 107 +++ .../Steam/SteamGameUninstallationView.swift | 55 ++ .../Sheets/GameOperationStatusView.swift | 12 +- .../Unified/Sheets/SteamSignInView.swift | 289 +++++++ .../Views/Unified/Windows/SettingsView.swift | 102 ++- 24 files changed, 4230 insertions(+), 76 deletions(-) create mode 100644 Mythic/Utilities/GameManager/SteamGameManager.swift create mode 100644 Mythic/Utilities/Steam/SteamCMD.swift create mode 100644 Mythic/Utilities/Steam/SteamCMDOutput.swift create mode 100644 Mythic/Utilities/Steam/SteamInterface.swift create mode 100644 Mythic/Utilities/Steam/ValveDataFormat.swift create mode 100644 Mythic/Views/Unified/Sheets/GameImportView/SteamGameImportView.swift create mode 100644 Mythic/Views/Unified/Sheets/GameInstallationView/Steam/SteamGameInstallationView.swift create mode 100644 Mythic/Views/Unified/Sheets/GameInstallationView/Steam/SteamGameUninstallationView.swift create mode 100644 Mythic/Views/Unified/Sheets/SteamSignInView.swift diff --git a/Mythic.xcodeproj/project.pbxproj b/Mythic.xcodeproj/project.pbxproj index 0f4d8ad0..7bec74a4 100644 --- a/Mythic.xcodeproj/project.pbxproj +++ b/Mythic.xcodeproj/project.pbxproj @@ -7,6 +7,15 @@ objects = { /* Begin PBXBuildFile section */ + A123C929896D524B94F0CE43 /* SteamGameInstallationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A144B5DBF0CCE02FAACE78D6 /* SteamGameInstallationView.swift */; }; + E7F7826EA69425F5C3D35913 /* SteamGameUninstallationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9CFBF789442D61D2328DE354 /* SteamGameUninstallationView.swift */; }; + 3940F8733BCE8D51A09E1804 /* SteamSignInView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BA1A69227E4F2F399744DBD8 /* SteamSignInView.swift */; }; + B755E70D8ACCAF1BF10E1CDC /* SteamGameImportView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 848E3BD28D865EB6BF10CBDC /* SteamGameImportView.swift */; }; + 0450BE541B5821448371E083 /* SteamGameManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8DC19D5B88CA257AEEC7156F /* SteamGameManager.swift */; }; + 37334310F75E7C8572B6D9BB /* SteamInterface.swift in Sources */ = {isa = PBXBuildFile; fileRef = B57D1FD68F9A2231B667BFFA /* SteamInterface.swift */; }; + 5594CC6BF517506DE5DE91F2 /* SteamCMD.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8745DA6B67A53FA13A15A228 /* SteamCMD.swift */; }; + 1FC34523F08207A21A47B3C4 /* ValveDataFormat.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1CA8D98C5272EC81E7383FAF /* ValveDataFormat.swift */; }; + ED47048247179B6971A267A3 /* SteamCMDOutput.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C767C51CB9A3D208CA068D8 /* SteamCMDOutput.swift */; }; 1217C8A92EADC525003EC8C9 /* SparkleUpdaterDownloadingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1217C8A22EADC525003EC8C9 /* SparkleUpdaterDownloadingView.swift */; }; 1217C8AA2EADC525003EC8C9 /* SparkleUpdaterSheetViewModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1217C8A72EADC525003EC8C9 /* SparkleUpdaterSheetViewModifier.swift */; }; 1217C8AB2EADC525003EC8C9 /* SparkleUpdaterFinishView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1217C8A42EADC525003EC8C9 /* SparkleUpdaterFinishView.swift */; }; @@ -149,6 +158,15 @@ /* End PBXCopyFilesBuildPhase section */ /* Begin PBXFileReference section */ + A144B5DBF0CCE02FAACE78D6 /* SteamGameInstallationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SteamGameInstallationView.swift; sourceTree = ""; }; + 9CFBF789442D61D2328DE354 /* SteamGameUninstallationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SteamGameUninstallationView.swift; sourceTree = ""; }; + BA1A69227E4F2F399744DBD8 /* SteamSignInView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SteamSignInView.swift; sourceTree = ""; }; + 848E3BD28D865EB6BF10CBDC /* SteamGameImportView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SteamGameImportView.swift; sourceTree = ""; }; + 8DC19D5B88CA257AEEC7156F /* SteamGameManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SteamGameManager.swift; sourceTree = ""; }; + B57D1FD68F9A2231B667BFFA /* SteamInterface.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SteamInterface.swift; sourceTree = ""; }; + 8745DA6B67A53FA13A15A228 /* SteamCMD.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SteamCMD.swift; sourceTree = ""; }; + 1CA8D98C5272EC81E7383FAF /* ValveDataFormat.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ValveDataFormat.swift; sourceTree = ""; }; + 0C767C51CB9A3D208CA068D8 /* SteamCMDOutput.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SteamCMDOutput.swift; sourceTree = ""; }; 1217C8A12EADC525003EC8C9 /* SparkleUpdaterCheckingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SparkleUpdaterCheckingView.swift; sourceTree = ""; }; 1217C8A22EADC525003EC8C9 /* SparkleUpdaterDownloadingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SparkleUpdaterDownloadingView.swift; sourceTree = ""; }; 1217C8A32EADC525003EC8C9 /* SparkleUpdaterExtractingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SparkleUpdaterExtractingView.swift; sourceTree = ""; }; @@ -291,6 +309,26 @@ /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ + A95EF4CD8B3327D907F7F8F0 /* Steam */ = { + isa = PBXGroup; + children = ( + A144B5DBF0CCE02FAACE78D6 /* SteamGameInstallationView.swift */, + 9CFBF789442D61D2328DE354 /* SteamGameUninstallationView.swift */, + ); + path = Steam; + sourceTree = ""; + }; + 4E6DA85E70DDD24FBF095431 /* Steam */ = { + isa = PBXGroup; + children = ( + B57D1FD68F9A2231B667BFFA /* SteamInterface.swift */, + 8745DA6B67A53FA13A15A228 /* SteamCMD.swift */, + 1CA8D98C5272EC81E7383FAF /* ValveDataFormat.swift */, + 0C767C51CB9A3D208CA068D8 /* SteamCMDOutput.swift */, + ); + path = Steam; + sourceTree = ""; + }; 1217C8A82EADC525003EC8C9 /* SparkleUpdater */ = { isa = PBXGroup; children = ( @@ -337,6 +375,7 @@ 6A1192832EC8D4F5005A3E05 /* GameManager */ = { isa = PBXGroup; children = ( + 8DC19D5B88CA257AEEC7156F /* SteamGameManager.swift */, 6A1192842EC8D4FB005A3E05 /* GameManager.swift */, 6A11928C2ECA0114005A3E05 /* LocalGameManager.swift */, 6A11928E2ECA15AD005A3E05 /* EpicGamesGameManager.swift */, @@ -348,6 +387,7 @@ 6A18E6722EE6748400AA3B15 /* GameImportView */ = { isa = PBXGroup; children = ( + 848E3BD28D865EB6BF10CBDC /* SteamGameImportView.swift */, 6A18E66F2EE6748400AA3B15 /* EpicGamesGameImportView.swift */, 6A18E6702EE6748400AA3B15 /* GameImportView.swift */, 6A18E6712EE6748400AA3B15 /* LocalGameImportView.swift */, @@ -410,6 +450,7 @@ 6A2934CA2BFCFAFD0035CE4B /* Utilities */ = { isa = PBXGroup; children = ( + 4E6DA85E70DDD24FBF095431 /* Steam */, 6A1192782EC8C1D3005A3E05 /* Game */, 6AF7B5DC2ECDF30C00906632 /* GameOperation */, 6A1192832EC8D4F5005A3E05 /* GameManager */, @@ -447,6 +488,7 @@ 6A2934E72BFCFAFD0035CE4B /* Sheets */ = { isa = PBXGroup; children = ( + BA1A69227E4F2F399744DBD8 /* SteamSignInView.swift */, 6A762F762ED8899300D6F1A8 /* GameInstallationView */, 6A18E6722EE6748400AA3B15 /* GameImportView */, 6A2934E42BFCFAFD0035CE4B /* GameOperationStatusView.swift */, @@ -518,6 +560,7 @@ 6A762F762ED8899300D6F1A8 /* GameInstallationView */ = { isa = PBXGroup; children = ( + A95EF4CD8B3327D907F7F8F0 /* Steam */, 6A762F7C2ED93DE400D6F1A8 /* BaseGameInstallationView.swift */, 6ADD21EE2EE33C4C00544E86 /* Local */, 6A762F7B2ED93CEC00D6F1A8 /* Epic Games */, @@ -790,6 +833,15 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + A123C929896D524B94F0CE43 /* SteamGameInstallationView.swift in Sources */, + E7F7826EA69425F5C3D35913 /* SteamGameUninstallationView.swift in Sources */, + 3940F8733BCE8D51A09E1804 /* SteamSignInView.swift in Sources */, + B755E70D8ACCAF1BF10E1CDC /* SteamGameImportView.swift in Sources */, + 0450BE541B5821448371E083 /* SteamGameManager.swift in Sources */, + 37334310F75E7C8572B6D9BB /* SteamInterface.swift in Sources */, + 5594CC6BF517506DE5DE91F2 /* SteamCMD.swift in Sources */, + 1FC34523F08207A21A47B3C4 /* ValveDataFormat.swift in Sources */, + ED47048247179B6971A267A3 /* SteamCMDOutput.swift in Sources */, 6A2935592BFCFAFD0035CE4B /* ContainerCreationView.swift in Sources */, 6A1192852EC8D4FD005A3E05 /* GameManager.swift in Sources */, 6A2960FE2CE1017900917E90 /* NSApplication+Extensions.swift in Sources */, diff --git a/Mythic/Localizable.xcstrings b/Mythic/Localizable.xcstrings index 36436354..2c1491d7 100644 --- a/Mythic/Localizable.xcstrings +++ b/Mythic/Localizable.xcstrings @@ -1,6 +1,776 @@ { "sourceLanguage" : "en", "strings" : { + "%@ closed immediately after starting." : { + "localizations" : { + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ fechou imediatamente depois de abrir." + } + } + } + }, + "%@ closed immediately.\nTitles that use Steamworks need the Steam client installed in their container, and this one hasn't got it." : { + "localizations" : { + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ fechou imediatamente.\nTítulos que usam Steamworks precisam do client da Steam instalado no contêiner, e este não tem." + } + } + } + }, + "Account name" : { + "localizations" : { + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nome da conta" + } + } + } + }, + "Add to Library" : { + "localizations" : { + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Adicionar à biblioteca" + } + } + } + }, + "App ID" : { + "localizations" : { + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "ID do app" + } + } + } + }, + "Approve this sign-in on your phone" : { + "localizations" : { + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Aprove este acesso no seu celular" + } + } + } + }, + "Are you sure you want to remove SteamCMD?" : { + "localizations" : { + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tem certeza de que quer remover o SteamCMD?" + } + } + } + }, + "Are you sure you want to sign out of Steam?" : { + "localizations" : { + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tem certeza de que quer encerrar a sessão da Steam?" + } + } + } + }, + "Artwork comes from Steam's CDN, keyed on the app ID." : { + "localizations" : { + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "As imagens vêm da CDN da Steam, identificadas pelo ID do app." + } + } + } + }, + "Asking Steam about %@…" : { + "localizations" : { + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Consultando a Steam sobre %@…" + } + } + } + }, + "Available for" : { + "localizations" : { + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Disponível para" + } + } + } + }, + "Checking download size…" : { + "localizations" : { + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Verificando o tamanho do download…" + } + } + } + }, + "Choose…" : { + "localizations" : { + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Escolher…" + } + } + } + }, + "Connect Steam" : { + "localizations" : { + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Conectar Steam" + } + } + } + }, + "Couldn't install SteamCMD: %@" : { + "localizations" : { + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Não foi possível instalar o SteamCMD: %@" + } + } + } + }, + "Download" : { + "localizations" : { + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Download" + } + } + } + }, + "Download games built for" : { + "localizations" : { + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Baixar jogos feitos para" + } + } + } + }, + "Each title gets its own folder here, named by its Steam app ID." : { + "localizations" : { + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Cada título ganha sua própria pasta aqui, nomeada pelo ID do app na Steam." + } + } + } + }, + "Enter an app ID to look a title up." : { + "localizations" : { + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Digite um ID de app para buscar um título." + } + } + } + }, + "Find it in the store page URL: store.steampowered.com/app/**1623730**/" : { + "localizations" : { + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Está na URL da página da loja: store.steampowered.com/app/**1623730**/" + } + } + } + }, + "Found %lld Steam games." : { + "localizations" : { + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Encontrados %lld jogos da Steam." + } + } + } + }, + "From your authenticator app, or the email Steam just sent you." : { + "localizations" : { + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Do seu app autenticador, ou do e-mail que a Steam acabou de enviar." + } + } + } + }, + "Games you've downloaded stay on disk." : { + "localizations" : { + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Os jogos que você baixou continuam no disco." + } + } + } + }, + "If your account uses the Steam Mobile Authenticator, keep your phone handy — Steam will ask you to approve this there." : { + "localizations" : { + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Se sua conta usa o Steam Mobile Authenticator, deixe o celular à mão — a Steam vai pedir sua aprovação por lá." + } + } + } + }, + "Install SteamCMD" : { + "localizations" : { + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Instalar o SteamCMD" + } + } + } + }, + "Install games in" : { + "localizations" : { + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Instalar jogos em" + } + } + } + }, + "It's a small download (about 3 MB) from Valve. Mythic needs it to sign in and to download your games." : { + "localizations" : { + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "É um download pequeno (cerca de 3 MB) da Valve. O Mythic precisa dele para entrar na conta e baixar seus jogos." + } + } + } + }, + "Loading your library…" : { + "localizations" : { + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Carregando sua biblioteca…" + } + } + } + }, + "Look Up" : { + "localizations" : { + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Buscar" + } + } + } + }, + "Looking up %lld Steam titles…" : { + "localizations" : { + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Consultando %lld títulos da Steam…" + } + } + } + }, + "Mythic signs in through SteamCMD, Valve's own command-line client." : { + "localizations" : { + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "O Mythic entra na conta pelo SteamCMD, o cliente de linha de comando da própria Valve." + } + } + } + }, + "One quick setup step first." : { + "localizations" : { + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Primeiro, um passo rápido de configuração." + } + } + } + }, + "Password" : { + "localizations" : { + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Senha" + } + } + } + }, + "Reading your Steam licences…" : { + "localizations" : { + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Lendo suas licenças da Steam…" + } + } + } + }, + "Reclaims" : { + "localizations" : { + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Libera" + } + } + } + }, + "Remove SteamCMD" : { + "localizations" : { + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Remover o SteamCMD" + } + } + } + }, + "Set Up" : { + "localizations" : { + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Configurar" + } + } + } + }, + "Setup required" : { + "localizations" : { + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Configuração necessária" + } + } + } + }, + "Sign in to Steam" : { + "localizations" : { + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Entrar na Steam" + } + } + } + }, + "Signing in comes next." : { + "localizations" : { + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "O login vem em seguida." + } + } + } + }, + "Signing in…" : { + "localizations" : { + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Entrando…" + } + } + } + }, + "Steam Guard code" : { + "localizations" : { + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Código do Steam Guard" + } + } + } + }, + "Steam couldn't complete the operation for app %1$@: %2$@" : { + "localizations" : { + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "A Steam não conseguiu concluir a operação do app %1$@: %2$@" + } + } + } + }, + "Steam didn't recognise app %@." : { + "localizations" : { + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "A Steam não reconheceu o app %@." + } + } + } + }, + "Steam doesn't list a way to launch %@ on this platform." : { + "localizations" : { + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "A Steam não indica nenhuma forma de iniciar %@ nesta plataforma." + } + } + } + }, + "Steam doesn't offer a %@ build of this title. Change the platform in Settings ▸ Services ▸ Steam." : { + "localizations" : { + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "A Steam não oferece uma versão %@ deste título. Troque a plataforma em Ajustes ▸ Serviços ▸ Steam." + } + } + } + }, + "Steam ended the sign-in without accepting or rejecting it. Try again." : { + "localizations" : { + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "A Steam encerrou o login sem aceitar nem recusar. Tente de novo." + } + } + } + }, + "Steam is rate-limiting sign-in attempts. Wait a few minutes and try again." : { + "localizations" : { + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "A Steam está limitando as tentativas de login. Espere alguns minutos e tente de novo." + } + } + } + }, + "Steam needs a Steam Guard code to continue." : { + "localizations" : { + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "A Steam precisa de um código do Steam Guard para continuar." + } + } + } + }, + "Steam sent a confirmation to the Steam Mobile App. Open it and tap Approve." : { + "localizations" : { + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "A Steam enviou uma confirmação para o app Steam Mobile. Abra o app e toque em Aprovar." + } + } + } + }, + "Steam sign-in failed." : { + "localizations" : { + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Falha ao entrar na Steam." + } + } + } + }, + "Steam sign-in failed: %@" : { + "localizations" : { + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Falha ao entrar na Steam: %@" + } + } + } + }, + "Steam stops waiting after about a minute." : { + "localizations" : { + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "A Steam desiste de esperar em cerca de um minuto." + } + } + } + }, + "Steam user" : { + "localizations" : { + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Usuário da Steam" + } + } + } + }, + "Steam was waiting for you to approve the sign-in in the Steam Mobile App, and gave up.\nTry again, and confirm the request on your phone when it appears." : { + "localizations" : { + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "A Steam estava esperando você aprovar o acesso no app Steam Mobile e desistiu.\nTente de novo e confirme no celular quando o pedido aparecer." + } + } + } + }, + "SteamCMD is an Intel binary and needs Rosetta 2 to run." : { + "localizations" : { + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "O SteamCMD é um binário Intel e precisa do Rosetta 2 para rodar." + } + } + } + }, + "SteamCMD is an Intel binary and needs Rosetta 2, which isn't installed." : { + "localizations" : { + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "O SteamCMD é um binário Intel e precisa do Rosetta 2, que não está instalado." + } + } + } + }, + "SteamCMD isn't installed yet" : { + "localizations" : { + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "O SteamCMD ainda não está instalado" + } + } + } + }, + "SteamCMD isn't installed yet — set it up in Accounts." : { + "localizations" : { + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "O SteamCMD ainda não está instalado — configure em Contas." + } + } + } + }, + "SteamCMD isn't installed yet." : { + "localizations" : { + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "O SteamCMD ainda não está instalado." + } + } + } + }, + "Step 1 of 2" : { + "localizations" : { + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Passo 1 de 2" + } + } + } + }, + "That Steam username or password wasn't accepted." : { + "localizations" : { + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Esse nome de conta ou senha da Steam não foi aceito." + } + } + } + }, + "That game isn't installed." : { + "localizations" : { + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Esse jogo não está instalado." + } + } + } + }, + "Titles that require the Steam client running won't launch — Mythic Engine can't run it." : { + "localizations" : { + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Títulos que exigem o cliente da Steam em execução não vão abrir — o Mythic Engine não consegue rodá-lo." + } + } + } + }, + "Unable to install SteamCMD." : { + "localizations" : { + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Não foi possível instalar o SteamCMD." + } + } + } + }, + "Unable to look that app ID up." : { + "localizations" : { + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Não foi possível consultar esse ID de app." + } + } + } + }, + "Unable to sign in to Steam." : { + "localizations" : { + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Não foi possível entrar na Steam." + } + } + } + }, + "Waiting for you to approve this in the Steam Mobile App." : { + "localizations" : { + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Aguardando sua aprovação no app Steam Mobile." + } + } + } + }, + "Windows®" : { + "localizations" : { + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Windows®" + } + } + } + }, + "Windows® titles run through Mythic Engine; macOS titles run natively, but far fewer exist." : { + "localizations" : { + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Títulos Windows® rodam pelo Mythic Engine; títulos macOS rodam nativamente, mas existem muito menos." + } + } + } + }, + "You aren't signed in to Steam." : { + "localizations" : { + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Você não está conectado à Steam." + } + } + } + }, + "You aren't signed in to Steam. You can still add titles, but downloading needs an account." : { + "localizations" : { + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Você não está conectado à Steam. Ainda é possível adicionar títulos, mas baixar exige uma conta." + } + } + } + }, + "Your Steam sign-in and any games you downloaded through it will be removed too." : { + "localizations" : { + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sua sessão da Steam e os jogos baixados por ela também serão removidos." + } + } + } + }, + "Your library loads by itself once you're signed in. Use this for anything it missed." : { + "localizations" : { + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sua biblioteca carrega sozinha depois que você entra na conta. Use isto para o que ela não pegou." + } + } + } + }, + "Your password goes straight to SteamCMD and is never written to disk by Mythic." : { + "localizations" : { + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sua senha vai direto para o SteamCMD e nunca é gravada em disco pelo Mythic." + } + } + } + }, + "e.g. 1623730" : { + "localizations" : { + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "ex.: 1623730" + } + } + } + }, + "macOS" : { + "localizations" : { + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "macOS" + } + } + } + }, "" : { "comment" : "An empty string entry.", "isCommentAutoGenerated" : true, diff --git a/Mythic/Utilities/Game/Game+Extensions.swift b/Mythic/Utilities/Game/Game+Extensions.swift index a7264455..90124fa9 100644 --- a/Mythic/Utilities/Game/Game+Extensions.swift +++ b/Mythic/Utilities/Game/Game+Extensions.swift @@ -41,11 +41,13 @@ extension Game { enum Storefront: CustomStringConvertible, CaseIterable, Codable, Hashable { case epicGames case local + case steam var description: String { switch self { case .epicGames: String(localized: "Epic Games") case .local: String(localized: "Local") + case .steam: String(localized: "Steam") } } } diff --git a/Mythic/Utilities/Game/Game.swift b/Mythic/Utilities/Game/Game.swift index 3b6d7add..d8e15c39 100644 --- a/Mythic/Utilities/Game/Game.swift +++ b/Mythic/Utilities/Game/Game.swift @@ -19,8 +19,8 @@ import AppKit var installationState: InstallationState var storefront: Storefront? { - // Not an assertion: `AnyGame` deliberately degrades an unrecognised storefront tag to a bare - // `Game` rather than failing the whole library decode, so instances of this class do + // Not an assertion any more: `AnyGame` deliberately degrades an unrecognised storefront tag to + // a bare `Game` rather than failing the whole library decode, so instances of this class do // legitimately reach here. Logger.app.warning("Storefront read on a base Game (\(self.id, privacy: .public)); it has no storefront.") return nil @@ -271,16 +271,20 @@ struct AnyGame: Codable, Equatable { init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: Game.CodingKeys.self) + // `try?`, not `try`: a storefront tag this build does not recognise must degrade to a plain // `Game`, never fail. `decodeAndGet` decodes the whole `[AnyGame]` array in one call and - // swallows any error into `nil`, so one unreadable row empties the entire library -- every - // game from every other storefront with it. + // swallows any error into `nil` (UserDefaults+Extensions), so one unreadable row would empty + // the entire library -- every Epic and Local game with it. This cannot retroactively protect + // already-shipped builds from `.steam`, but it caps the blast radius at this one release + // boundary and stops the next storefront from repeating it. let storefront = try? container.decodeIfPresent(Game.Storefront.self, forKey: .storefront) self.base = try { switch storefront { case .epicGames: try EpicGamesGame(from: decoder) case .local: try LocalGame(from: decoder) + case .steam: try SteamGame(from: decoder) case nil: try Game(from: decoder) } }() diff --git a/Mythic/Utilities/Game/SteamGame.swift b/Mythic/Utilities/Game/SteamGame.swift index 68b1c6ef..6e39ca89 100644 --- a/Mythic/Utilities/Game/SteamGame.swift +++ b/Mythic/Utilities/Game/SteamGame.swift @@ -5,14 +5,107 @@ // Created by vapidinfinity (esi) on 15/11/2025. // -// Copyright © 2023-2025 vapidinfinity - -// haha you thought +// Copyright © 2023-2026 vapidinfinity import Foundation +import OSLog + +/** + A game owned through Steam and acquired via SteamCMD. -@available(*, deprecated, message: "Soon...") + **Scope.** Mythic acquires Steam titles with SteamCMD, which is a native macOS binary and never touches + Wine, so download/update/verify work on the current engine. Launching a downloaded Windows title goes + through Mythic's existing Wine path. What is *not* supported is any title that authenticates through the + live Steam client at runtime (Steamworks tickets, EOS-over-Steam): the client's Chromium 126 CEF helper + does not survive on the engine's `wine-7.7`, so such titles download correctly and then fail to launch. + Raising that ceiling is an `Engine/` concern, not a `Steam/` one. + */ class SteamGame: Game { + override var storefront: Storefront? { .steam } + + /** + Steam's application ID. + + Stored *as* ``Game/id`` rather than as a field of its own. It is the key `app_update`, `app_status`, + `appmanifest_.acf` and every CDN artwork URL are all written in terms of, and `id` is the only + key `GameDataStore`'s merge actually discriminates on. A separate persisted field would also force + `Game.encode(to:)` out of its extension and into the class body to become overridable, which is + base-class surgery affecting Epic and Local for no gain here. + + This mirrors `EpicGamesGame`, whose `id` is the legendary app name. + */ + var appID: String { id } + + // MARK: - Artwork + // + // `computedVerticalImageURL`/`computedHorizontalImageURL` are synchronous and non-throwing, and the + // render site is a bare `AsyncImage` with no cache and no header plumbing. That rules out SteamGridDB + // here (it needs an API key and an async search); it belongs in a background resolver writing into the + // persisted `_verticalImageURL`/`_horizontalImageURL` instead. The Steam CDN needs no key. + + private static let contentDeliveryNetwork = "https://cdn.cloudflare.steamstatic.com/steam/apps" + + /// 3:4 grid card. + override var computedVerticalImageURL: URL? { + URL(string: "\(Self.contentDeliveryNetwork)/\(appID)/library_600x900_2x.jpg") + } + + /// 16:9 hero/list card. + override var computedHorizontalImageURL: URL? { + URL(string: "\(Self.contentDeliveryNetwork)/\(appID)/header.jpg") + } + + // MARK: - On-disk state + + /** + Location of this title's Steam app manifest, if the game is installed. + + SteamCMD invoked with `force_install_dir ` writes game files directly into `` and the + manifest to `/steamapps/appmanifest_.acf` — verified against a real `app_update` run. + */ + var appManifestURL: URL? { + guard case .installed(let location, _) = installationState else { return nil } + return location + .appending(path: "steamapps") + .appending(path: "appmanifest_\(appID).acf") + } + + /// The parsed manifest, or `nil` if the game is not installed or the manifest is unreadable. + var appManifest: ValveDataFormat.AppManifest? { + guard let appManifestURL, + FileManager.default.fileExists(atPath: appManifestURL.path(percentEncoded: false)) + else { return nil } + + do { + return try ValveDataFormat.parseAppManifest(contentsOf: appManifestURL) + } catch { + Logger.app.warning("Unreadable Steam app manifest for \(self.appID, privacy: .public): \(error.localizedDescription)") + return nil + } + } + + override var isUpdateAvailable: Bool? { + appManifest?.isUpdateAvailable + } + + /// Whether Steam has flagged this installation's files as missing or corrupt. + var requiresVerification: Bool { + appManifest?.requiresVerification ?? false + } + + // MARK: - Initialisers + + /// - Parameter appID: Steam's application ID; becomes ``Game/id``. + init(appID: String, + title: String, + installationState: InstallationState, + containerURL: URL? = nil) { + super.init(id: appID, + title: title, + installationState: installationState, + containerURL: containerURL) + } + override init(id: String = UUID().uuidString, title: String, installationState: InstallationState, @@ -28,4 +121,32 @@ class SteamGame: Game { // say 'thank you, super.init❤️' try super.init(from: decoder) } + + // MARK: - Operations + // + // These forward to SteamGameManager. They are not optional: the base implementations are + // `fatalError`, and GameCard's Play button calls `game.launch()`, not the manager -- so omitting + // `_launch()` did not degrade anything, it killed the app the first time anyone pressed Play on an + // installed Steam title. + + override func _launch() async throws { + try await SteamGameManager.launch(steamGame: self) + } + + override func _update() async throws { + try await SteamGameManager.update(steamGame: self, atQualityOfService: .default) + } + + override func _move(from currentLocation: URL, to newLocation: URL) async throws { + try await SteamGameManager.move(steamGame: self, toLocation: newLocation) + } + + /// Steam has no separate repair verb; `app_update … validate` re-hashes and refetches what differs. + override func _verifyInstallation() async throws { + try await SteamGameManager.repair(steamGame: self, atQualityOfService: .default) + } + + override func getSupportedPlatforms() -> Set? { + Steam.cachedAppInfo(appID: appID).map(Steam.supportedPlatforms(for:)) + } } diff --git a/Mythic/Utilities/GameDataStore.swift b/Mythic/Utilities/GameDataStore.swift index f35731de..b138c2c8 100644 --- a/Mythic/Utilities/GameDataStore.swift +++ b/Mythic/Utilities/GameDataStore.swift @@ -65,38 +65,72 @@ import OSLog GameListViewModel.shared.isUpdatingLibrary = true defer { GameListViewModel.shared.isUpdatingLibrary = false + GameListViewModel.shared.libraryUpdateProgress = nil } - + // if variadics are empty, default to all cases let storefronts = storefronts.isEmpty ? Game.Storefront.allCases : storefronts as [Game.Storefront] - - // legendary (epic games) + + // One storefront failing must not stop the others. Legendary throws NotSignedInError whenever + // there is no Epic account, and it used to abort the whole refresh -- so a user signed in to + // Steam but not Epic would never see their Steam library at all. Failures are collected and the + // first is rethrown once every requested storefront has had its turn. + var failures: [Error] = .init() + if storefronts.contains(.epicGames) { do { - let installables = try Legendary.getInstallableGames() - let installed = try Legendary.getInstalledGames() - - // add installables that aren't installed - for game in installables where !installed.contains(where: { $0 == game }) { - library.update(with: game) - } - - // installed: merge instead of overwrite - for fetchedGame in installed { - if let existing = library.first(where: { $0 == fetchedGame }) { - try existing.merge(with: fetchedGame, requiring: .identicalIgnoredKeys) - library.update(with: existing) - } else { - library.update(with: fetchedGame) + try mergeIntoLibrary(installables: try Legendary.getInstallableGames(), + installed: try Legendary.getInstalledGames()) + } catch { + log.error("Unable to refresh game data from Epic Games: \(error.localizedDescription)") + failures.append(error) + } + } + + if storefronts.contains(.steam) { + do { + // The owned-library enumeration is what makes getInstallableGames() non-empty. It is + // rate-limited internally by a cache lifetime, so calling it on every refresh is cheap + // after the first, and a failure here must not cost us the installed titles below. + if Steam.isSignedIn { + do { + try await Steam.refreshOwnedGames { stage in + Task { @MainActor in + GameListViewModel.shared.libraryUpdateProgress = .init(for: stage) + } + } + } catch { + log.error("Unable to enumerate the Steam library: \(error.localizedDescription)") } } + + // Installed titles come from their on-disk manifests, so they surface even when the + // SteamCMD session has lapsed. + try mergeIntoLibrary(installables: Steam.isSignedIn ? try Steam.getInstallableGames() : [], + installed: try Steam.getInstalledGames()) } catch { - log.error("Unable to refresh game data from Epic Games: \(error.localizedDescription)") - throw error + log.error("Unable to refresh game data from Steam: \(error.localizedDescription)") + failures.append(error) + } + } + + if let firstFailure = failures.first { throw firstFailure } + } + + /// Shared merge step: installables that aren't installed are added outright, installed titles are + /// merged into any existing instance rather than overwriting it. + private func mergeIntoLibrary(installables: [some Game], installed: [some Game]) throws { + for game in installables where !installed.contains(where: { $0 == game }) { + library.update(with: game) + } + + for fetchedGame in installed { + if let existing = library.first(where: { $0 == fetchedGame }) { + try existing.merge(with: fetchedGame, requiring: .identicalIgnoredKeys) + library.update(with: existing) + } else { + library.update(with: fetchedGame) } } - - // TODO: others - // if storefronts.contains(...) { ... } } } diff --git a/Mythic/Utilities/GameManager/SteamGameManager.swift b/Mythic/Utilities/GameManager/SteamGameManager.swift new file mode 100644 index 00000000..9a271c4b --- /dev/null +++ b/Mythic/Utilities/GameManager/SteamGameManager.swift @@ -0,0 +1,436 @@ +// +// SteamGameManager.swift +// Mythic +// +// Created by Brunelli Cupello on 21/8/2026. +// + +// Copyright © 2023-2026 vapidinfinity + +import Foundation +import AppKit +import OSLog +import os + +extension SteamGameManager: StorefrontGameManager { + // Each witness forwards to a typed overload whose argument labels *differ* from its own. That is + // deliberate: `EpicGamesGameManager.install(game:qualityOfService:)` called a typed overload sharing + // its labels, re-selected itself, and recursed until the stack died. + + @MainActor static func launch(game: Game) async throws -> GameOperation { + try await launch(steamGame: cast(game)) + } + + @MainActor static func move(game: Game, to newLocation: URL) async throws -> GameOperation { + try await move(steamGame: cast(game), toLocation: newLocation) + } + + @MainActor static func uninstall(game: Game, persistFiles: Bool) async throws -> GameOperation { + try await uninstall(steamGame: cast(game), persistingFiles: persistFiles) + } + + static func install(game: Game, qualityOfService: QualityOfService) async throws -> GameOperation { + try await install(steamGame: cast(game), atQualityOfService: qualityOfService) + } + + static func update(game: Game, qualityOfService: QualityOfService) async throws -> GameOperation { + try await update(steamGame: cast(game), atQualityOfService: qualityOfService) + } + + static func repair(game: Game, qualityOfService: QualityOfService) async throws -> GameOperation { + try await repair(steamGame: cast(game), atQualityOfService: qualityOfService) + } + + static func fetchUpdateAvailability(for game: Game) throws -> Bool { + try fetchUpdateAvailability(forSteamGame: cast(game)) + } + + static func isFileVerificationRequired(for game: Game) throws -> Bool { + try isFileVerificationRequired(forSteamGame: cast(game)) + } + + @MainActor static func importGame(_ game: Game, platform: Game.Platform, at location: URL) async throws { + try await importSteamGame(cast(game), platform: platform, at: location) + } + + private static func cast(_ game: Game) throws -> SteamGame { + guard case .steam = game.storefront, let steamGame = game as? SteamGame else { + throw CocoaError(.coderInvalidValue) + } + return steamGame + } +} + +final class SteamGameManager { + static var log: Logger { .custom(category: "SteamGameManager") } + + struct NoLaunchOptionError: LocalizedError { + let title: String + var errorDescription: String? { + String(localized: "Steam doesn't list a way to launch \(title) on this platform.") + } + } + + struct NotInstalledError: LocalizedError { + var errorDescription: String? = String(localized: "That game isn't installed.") + } + + /** + A game that started and then exited straight away. + + Reported because the alternative is worse: the launch operation completed without error, so the UI + showed a brief spinner and then nothing at all, with no way for anyone to find out why. The most + common cause for a Steam title is Steamworks failing to initialise, which needs the Steam client -- + so whether the container even has one is checked and said out loud. + */ + struct ImmediateExitError: LocalizedError { + let title: String + let isSteamClientMissing: Bool + /// Tail of the process's own output, for the log rather than the alert. + let output: String + + var errorDescription: String? { + isSteamClientMissing + ? String(localized: """ + \(title) closed immediately. + Titles that use Steamworks need the Steam client installed in their container, and this one hasn't got it. + """) + : String(localized: "\(title) closed immediately after starting.") + } + + var failureReason: String? { output.isEmpty ? nil : output } + } + + /// A launch that ends sooner than this is treated as a failure to start rather than a short session. + private static let immediateExitThreshold: TimeInterval = 15 + + // MARK: - Acquisition + // + // Board items: "Game installing + progress handling", "Game update checker". + + @discardableResult + static func install(steamGame game: SteamGame, + atQualityOfService qualityOfService: QualityOfService) async throws -> GameOperation { + try await runContentOperation(on: game, + type: .install, + validate: false, + qualityOfService: qualityOfService) + } + + @discardableResult + static func update(steamGame game: SteamGame, + atQualityOfService qualityOfService: QualityOfService) async throws -> GameOperation { + try await runContentOperation(on: game, + type: .update, + validate: false, + qualityOfService: qualityOfService) + } + + /// Steam has no distinct repair verb; `app_update ... validate` re-hashes and refetches what differs. + @discardableResult + static func repair(steamGame game: SteamGame, + atQualityOfService qualityOfService: QualityOfService) async throws -> GameOperation { + try await runContentOperation(on: game, + type: .repair, + validate: true, + qualityOfService: qualityOfService) + } + + private static func runContentOperation(on game: SteamGame, + type: GameOperation.ActiveOperationType, + validate: Bool, + qualityOfService: QualityOfService) async throws -> GameOperation { + guard SteamCMD.isInstalled else { throw SteamCMD.Failure.notInstalled } + guard Steam.isSignedIn else { throw Steam.NotSignedInError() } + + let appID = game.appID + let installURL = Steam.installURL(forAppID: appID) + let platform: Game.Platform = Steam.forcedPlatform == .macos ? .macOS : .windows + + let operation: GameOperation = .init(game: game, type: type) { progress in + progress.totalUnitCount = 100 + progress.fileOperationKind = .downloading + + let rate: DownloadRateEstimator = .init() + + try await SteamCMD.run( + commands: [.appUpdate(appID: appID, validate: validate)], + installDirectory: installURL, + // cachedAccountName, not retrieveUser(): the latter can return a display-only + // placeholder, and `+login ` fails. + credentials: .init(username: Steam.cachedAccountName ?? "anonymous"), + forcedPlatform: Steam.forcedPlatform + ) { line in + apply(line, to: progress, rate: rate) + } + + // Only trust the manifest Steam actually wrote, not the fact that the process exited 0. + guard FileManager.default.fileExists( + atPath: installURL.appending(path: "steamapps/appmanifest_\(appID).acf").path(percentEncoded: false) + ) else { + throw SteamCMD.Failure.appOperationFailed(appID: appID, reason: "no app manifest was written") + } + + game.installationState = .installed(location: installURL, platform: platform) + + // Cache the launch metadata now, while we are already online, so launching is not the first + // thing that ever needs it. + try? await Steam.refreshAppInfo(appID: appID) + } + + operation.qualityOfService = qualityOfService + await Game.operationManager.queueOperation(operation) + return operation + } + + /** + Maps SteamCMD's progress line onto `Progress`. + + Byte counts deliberately do *not* go into `fileTotalCount`/`fileCompletedCount`. Those are item + counts -- the status sheet renders them under a "Files" label as `(x/y)` -- so a 41 GB download + reported as `Files (81280707/41306371057)` is not information, it is noise. Epic has real object + counts there; Steam has none, and the sheet now hides the row rather than showing `(0/0)`. + + SteamCMD reports neither throughput nor an ETA, but both follow from consecutive readings of the + cumulative byte count, so they are derived rather than left blank. The rate is smoothed, because a + raw two-second delta jitters enough to be unreadable. + */ + private static func apply(_ line: SteamCMDOutput.Line, + to progress: Progress, + rate: DownloadRateEstimator) { + guard case .progress(let update) = line else { return } + + progress.completedUnitCount = Int64(update.percentage.rounded()) + progress.fileOperationKind = update.phase.isDownloading ? .downloading : .copying + + guard update.totalBytes > 0, + let estimate = rate.sample(currentBytes: update.currentBytes, + totalBytes: update.totalBytes) + else { return } + + progress.throughput = estimate.bytesPerSecond + progress.estimatedTimeRemaining = estimate.timeRemaining + } + + /// Derives download rate and time remaining from SteamCMD's cumulative byte counter. + fileprivate final class DownloadRateEstimator: Sendable { + /// Weight of each new reading. Low enough that one slow tick does not swing the displayed rate. + private static let smoothingFactor: Double = 0.3 + + private let state: OSAllocatedUnfairLock = .init(initialState: .init()) + + func sample(currentBytes: Int64, totalBytes: Int64) -> (bytesPerSecond: Int, timeRemaining: TimeInterval)? { + state.withLock { state in + let now: Date = .now + defer { state.bytes = currentBytes; state.takenAt = now } + + guard let previousBytes = state.bytes, let takenAt = state.takenAt else { return nil } + + let elapsed = now.timeIntervalSince(takenAt) + // SteamCMD reprints progress often; ignore samples too close together to measure a rate + // from, and ignore the counter resetting between phases. + guard elapsed >= 0.5, currentBytes >= previousBytes else { return nil } + + let instantaneous = Double(currentBytes - previousBytes) / elapsed + let rate = state.smoothedRate + .map { $0 + Self.smoothingFactor * (instantaneous - $0) } ?? instantaneous + state.smoothedRate = rate + + guard rate > 0 else { return nil } + + return (bytesPerSecond: Int(rate), + timeRemaining: Double(max(0, totalBytes - currentBytes)) / rate) + } + } + } + + /// Whether the container has a Steam client executable for Steamworks titles to talk to. + private static func containerHasSteamClient(at containerURL: URL) -> Bool { + let steamDirectory = containerURL.appending(path: "drive_c/Program Files (x86)/Steam") + + return ["steam.exe", "Steam.exe"].contains { + FileManager.default.fileExists( + atPath: steamDirectory.appending(path: $0).path(percentEncoded: false) + ) + } + } + + /// Keeps the tail of a launched process's output, so a failure to start can explain itself. + private final class LaunchDiagnostics: Sendable { + private static let limit: Int = 40 + + private let lines: OSAllocatedUnfairLock<[String]> = .init(initialState: .init()) + + func record(_ line: String) { + lines.withLock { lines in + lines.append(line) + if lines.count > Self.limit { lines.removeFirst(lines.count - Self.limit) } + } + } + + var tail: String { + lines.withLock { $0.joined(separator: "\n") } + } + } + + // MARK: - State + // + // Both of these are synchronous by protocol, so they read the on-disk manifest rather than asking + // SteamCMD -- `app_status` would work but is async and cannot be reached from here. + + static func fetchUpdateAvailability(forSteamGame game: SteamGame) throws -> Bool { + guard let manifest = game.appManifest else { throw NotInstalledError() } + return manifest.isUpdateAvailable + } + + static func isFileVerificationRequired(forSteamGame game: SteamGame) throws -> Bool { + guard let manifest = game.appManifest else { throw NotInstalledError() } + return manifest.requiresVerification + } + + // MARK: - Moving + // + // Board item: "Game moving". + + @discardableResult + @MainActor static func move(steamGame game: SteamGame, toLocation newLocation: URL) async throws -> GameOperation { + guard case .installed(let currentLocation, let platform) = game.installationState else { + throw NotInstalledError() + } + + let operation: GameOperation = .init(game: game, type: .move) { _ in + try FileManager.default.moveItem(at: currentLocation, to: newLocation) + game.installationState = .installed(location: newLocation, platform: platform) + } + + await Game.operationManager.queueOperation(operation) + return operation + } + + // MARK: - Uninstalling + + @discardableResult + @MainActor static func uninstall(steamGame game: SteamGame, persistingFiles: Bool) async throws -> GameOperation { + guard case .installed(let location, _) = game.installationState else { throw NotInstalledError() } + + let operation: GameOperation = .init(game: game, type: .uninstall) { _ in + if !persistingFiles { + // Deleting the directory is the uninstall: `app_uninstall` only works against a library + // Steam itself manages, and Mythic gives every title its own force_install_dir. + try FileManager.default.removeItemIfExists(at: location) + } + + game.installationState = .uninstalled + } + + await Game.operationManager.queueOperation(operation) + return operation + } + + // MARK: - Importing + + @MainActor static func importSteamGame(_ game: SteamGame, + platform: Game.Platform, + at location: URL) async throws { + guard (try? ValveDataFormat.parseAppManifest( + contentsOf: location.appending(path: "steamapps/appmanifest_\(game.appID).acf") + )) != nil else { + throw SteamCMD.Failure.appOperationFailed( + appID: game.appID, + reason: "no Steam app manifest for this appid at that location" + ) + } + + game.installationState = .installed(location: location, platform: platform) + GameDataStore.shared.library.update(with: game) + } + + // MARK: - Launching + + @discardableResult + @MainActor static func launch(steamGame game: SteamGame) async throws -> GameOperation { + guard case .installed(let location, let platform) = game.installationState else { + throw NotInstalledError() + } + + let appID = game.appID + let title = game.title + + let operation: GameOperation = .init(game: game, type: .launch) { _ in + let info = try await Steam.appInfo(appID: appID) + let operatingSystem = platform == .macOS ? "macos" : "windows" + + guard let option = info.preferredLaunchOption(forOperatingSystem: operatingSystem) else { + throw NoLaunchOptionError(title: title) + } + + // Steam's `executable` is relative to the install directory. + let executableURL = location.appending(path: option.executable) + guard FileManager.default.fileExists(atPath: executableURL.path(percentEncoded: false)) else { + throw CocoaError(.fileNoSuchFile) + } + + // Steam's own option arguments come first, then anything the user added in Mythic. + let arguments = (option.arguments? + .split(separator: " ") + .map(String.init) ?? .init()) + game.launchArguments + + switch platform { + case .macOS: + let configuration: NSWorkspace.OpenConfiguration = .init() + configuration.arguments = arguments + _ = try await NSWorkspace.shared.openApplication(at: executableURL, configuration: configuration) + + case .windows: + guard let containerURL = game.containerURL else { throw Wine.Container.DoesNotExistError() } + let container = try Wine.getContainerObject(at: containerURL) + + if UserDefaults.standard.bool(forKey: "minimiseOnGameLaunch") { + await MainActor.run { NSApp.windows.first?.miniaturize(nil) } + } + + let process: Process = .init() + process.arguments = [executableURL.path] + arguments + // Games resolve their own data relative to the working directory; Steam sets it to the + // install directory and so must we. + process.currentDirectoryURL = location + // Environment first: `transformProcess` replaces `process.environment` wholesale. + process.environment = try Wine.assembleEnvironmentVariables(forContainerAtURL: container.url) + Wine.transformProcess(process, containerURL: containerURL) + + let diagnostics: LaunchDiagnostics = .init() + let startedAt: Date = .now + + try await withTaskCancellationHandler { + // Streamed rather than fire-and-forget: when a game fails to start it explains itself + // on stderr, and that explanation was previously discarded. + try await process.runStreamed(throwsOnChunkError: false) { chunk in + diagnostics.record(chunk.output) + return nil + } + } onCancel: { + process.interrupt() + } + + guard Date.now.timeIntervalSince(startedAt) >= immediateExitThreshold else { + throw ImmediateExitError( + title: title, + isSteamClientMissing: !containerHasSteamClient(at: containerURL), + output: diagnostics.tail + ) + } + } + } + + await Game.operationManager.queueOperation(operation) + return operation + } +} + +extension SteamGameManager.DownloadRateEstimator { + struct Sample: Sendable { + var bytes: Int64? + var takenAt: Date? + var smoothedRate: Double? + } +} diff --git a/Mythic/Utilities/GameOperation/GameOperationManager.swift b/Mythic/Utilities/GameOperation/GameOperationManager.swift index ecd80724..7b3ed465 100644 --- a/Mythic/Utilities/GameOperation/GameOperationManager.swift +++ b/Mythic/Utilities/GameOperation/GameOperationManager.swift @@ -54,9 +54,12 @@ import DockProgress operation.addDependency(existingOperation) } - // FIXME: Legendary has a self-managed datalock, so we must queue those operations serially - if case .epicGames = operation.game.storefront, operation.type.modifiesFiles { - for existingOperation in queue where existingOperation.game.storefront == .epicGames && existingOperation.type.modifiesFiles { + // FIXME: Legendary has a self-managed datalock, so we must queue those operations serially. + // SteamCMD needs the same treatment for a different reason: every invocation shares one session + // directory, so two concurrent runs fight over it. Local games have no such backend. + if let storefront = operation.game.storefront, storefront != .local, operation.type.modifiesFiles { + for existingOperation in queue + where existingOperation.game.storefront == storefront && existingOperation.type.modifiesFiles { operation.addDependency(existingOperation) } } @@ -114,8 +117,8 @@ import DockProgress // FIXME: since GameDataStore.refreshFromStorefronts is needed to re-sync file status // to fix this, legendary's JSONs must be monitored using an API like FSEvents. // but this is way simpler rofl - if case .epicGames = operation.game.storefront, operation.type.modifiesFiles { - Task(priority: .utility, operation: { try? await GameDataStore.shared.refreshFromStorefronts(.epicGames) }) + if let storefront = operation.game.storefront, storefront != .local, operation.type.modifiesFiles { + Task(priority: .utility, operation: { try? await GameDataStore.shared.refreshFromStorefronts(storefront) }) } log.debug("Operation \(operation.debugDescription) complete.") diff --git a/Mythic/Utilities/Steam/SteamCMD.swift b/Mythic/Utilities/Steam/SteamCMD.swift new file mode 100644 index 00000000..4a912585 --- /dev/null +++ b/Mythic/Utilities/Steam/SteamCMD.swift @@ -0,0 +1,509 @@ +// +// SteamCMD.swift +// Mythic +// +// Created by Brunelli Cupello on 21/8/2026. +// + +// Copyright © 2023-2026 vapidinfinity + +// Reference: https://developer.valvesoftware.com/wiki/SteamCMD (403s to non-browser clients) + +import Foundation +import OSLog +import os + +/** + Mythic's SteamCMD backend. + + SteamCMD is Valve's headless content client. It is a **native macOS binary** and never involves Wine, + which is what makes the Steam acquisition path viable on the current engine even though the Steam GUI + client is not — see ``SteamGame`` for that ceiling. + */ +final class SteamCMD { + static let log: Logger = .custom(category: "steamcmd") + + // MARK: - Locations + + /// `~/Library/Application Support/Mythic/Steam` + static var directory: URL { + Bundle.appHome!.appending(path: "Steam") + } + + /** + `HOME` handed to every SteamCMD invocation. + + SteamCMD writes its session, caches and logs relative to `HOME` — by default straight into + `~/Library/Application Support/Steam`, i.e. into a native Steam client's installation, where it can + disturb an existing login. Pointing `HOME` here keeps Mythic's copy entirely to itself. + + Verified: with `HOME` scoped, a full `login anonymous` run wrote 15 files under this directory and + touched **nothing** in the user's real Steam install. + */ + static var homeDirectory: URL { + directory.appending(path: "root") + } + + /** + The wrapper script, never the raw `steamcmd` binary. + + `steamcmd.sh` exports `DYLD_LIBRARY_PATH`/`DYLD_FRAMEWORK_PATH` pointing at its own directory, and + re-execs itself on exit code 42 — which *is* the self-update mechanism. Invoking the binary directly + loses both. + */ + static var executableURL: URL { + directory.appending(path: "steamcmd.sh") + } + + static var isInstalled: Bool { + FileManager.default.fileExists(atPath: executableURL.path(percentEncoded: false)) + } + + // MARK: - Failures + + enum Failure: LocalizedError, Equatable { + case rosettaMissing + case notInstalled + case bootstrapFailed(underlying: String) + case loginFailed(detail: String?) + case invalidCredentials + case twoFactorRequired + case rateLimited + case mobileConfirmationTimedOut + case loginDidNotComplete + case appOperationFailed(appID: String, reason: String) + + var errorDescription: String? { + switch self { + case .rosettaMissing: + String(localized: "SteamCMD is an Intel binary and needs Rosetta 2 to run.") + case .notInstalled: + String(localized: "SteamCMD isn't installed yet.") + case .bootstrapFailed(let underlying): + String(localized: "Couldn't install SteamCMD: \(underlying)") + case .loginFailed(let detail): + detail.map { String(localized: "Steam sign-in failed: \($0)") } + ?? String(localized: "Steam sign-in failed.") + case .invalidCredentials: + String(localized: "That Steam username or password wasn't accepted.") + case .twoFactorRequired: + String(localized: "Steam needs a Steam Guard code to continue.") + case .rateLimited: + String(localized: "Steam is rate-limiting sign-in attempts. Wait a few minutes and try again.") + case .mobileConfirmationTimedOut: + String(localized: """ + Steam was waiting for you to approve the sign-in in the Steam Mobile App, and gave up. + Try again, and confirm the request on your phone when it appears. + """) + case .loginDidNotComplete: + String(localized: "Steam ended the sign-in without accepting or rejecting it. Try again.") + case .appOperationFailed(let appID, let reason): + String(localized: "Steam couldn't complete the operation for app \(appID): \(reason)") + } + } + } + + // MARK: - Bootstrap + + /// Downloads and unpacks SteamCMD into ``directory``. + static func install() async throws { + guard Rosetta.exists else { throw Failure.rosettaMissing } + + let fileManager: FileManager = .default + try fileManager.createDirectory(at: directory, withIntermediateDirectories: true) + try fileManager.createDirectory(at: homeDirectory, withIntermediateDirectories: true) + + let archiveURL = URL(string: "https://steamcdn-a.akamaihd.net/client/installer/steamcmd_osx.tar.gz")! + let (temporaryURL, response) = try await URLSession.shared.download(from: archiveURL) + defer { try? fileManager.removeItem(at: temporaryURL) } + + if let response = response as? HTTPURLResponse, !(200..<300).contains(response.statusCode) { + throw Failure.bootstrapFailed(underlying: "HTTP \(response.statusCode)") + } + + let unarchive: Process = .init() + unarchive.executableURL = .init(filePath: "/usr/bin/tar") + unarchive.arguments = ["zxf", temporaryURL.path(percentEncoded: false), + "-C", directory.path(percentEncoded: false)] + + // `runWrapped` is overloaded sync/async and the compiler picks the async one here; name it + // explicitly so the choice is visible rather than contextual. + let result = try await unarchive.runWrappedAsync() + try unarchive.checkTerminationStatus() + + guard isInstalled else { + throw Failure.bootstrapFailed(underlying: result.standardError ?? "steamcmd.sh missing after unpack") + } + + log.notice("SteamCMD installed at \(directory.path(percentEncoded: false), privacy: .public)") + } + + static func uninstall() throws { + try FileManager.default.removeItemIfExists(at: directory) + } + + // MARK: - Invocation + + struct Credentials: Sendable { + var username: String + var password: String? + /// Steam Guard email code or mobile authenticator code, if one is already known. + var steamGuardCode: String? + + /// Anonymous access. Only content Valve publishes anonymously is reachable this way. + static let anonymous = Credentials(username: "anonymous") + } + + /// `@sSteamCmdForcePlatformType`. Windows is the default: it is what makes the catalogue large enough + /// to be worth having, and Mythic runs those titles through Wine. + enum ForcedPlatform: String, Sendable { + case windows, macos, linux + } + + enum Command: Sendable { + case appUpdate(appID: String, validate: Bool) + case appInfoPrint(appID: String) + case appInfoUpdate + case appStatus(appID: String) + case licensesPrint + case packageInfoPrint(packageID: String) + case raw(String) + + var arguments: [String] { + switch self { + case .appUpdate(let appID, let validate): + validate ? ["+app_update", appID, "validate"] : ["+app_update", appID] + case .appInfoPrint(let appID): + ["+app_info_print", appID] + case .appInfoUpdate: + ["+app_info_update", "1"] + case .appStatus(let appID): + ["+app_status", appID] + case .licensesPrint: + ["+licenses_print"] + case .packageInfoPrint(let packageID): + ["+package_info_print", packageID] + case .raw(let command): + ["+\(command)"] + } + } + } + + /// Answers SteamCMD's stdin prompts, and refuses to answer the same one forever. + private final class PromptResponder: Sendable { + private let credentials: Credentials? + private let answered: OSAllocatedUnfairLock<[SteamCMDOutput.Prompt: Int]> = .init(initialState: .init()) + + init(credentials: Credentials?) { + self.credentials = credentials + } + + /// - Returns: the reply to write to stdin, or `nil` to stay silent. + func reply(to prompt: SteamCMDOutput.Prompt) -> String? { + answered.withLock { answered in + // A wrong password makes SteamCMD re-prompt; answering identically forever would hang the + // operation and burn through Steam's sign-in rate limit. + let attempts = answered[prompt, default: 0] + guard attempts < 1 else { return nil } + answered[prompt] = attempts + 1 + + return switch prompt { + case .password: credentials?.password.map { $0 + "\n" } + case .steamGuardCode, + .twoFactorCode: credentials?.steamGuardCode.map { $0 + "\n" } + } + } + } + + func wasPrompted(for prompt: SteamCMDOutput.Prompt) -> Bool { + answered.withLock { $0[prompt, default: 0] > 0 } + } + } + + /** + Serialises SteamCMD invocations. + + Every run shares one session directory, and concurrent runs corrupt each other's output rather than + failing cleanly. Measured: two overlapping enumerations of the same account returned 4 and 18 app IDs + where one run returns 455 — each process saw a fragment of the other's `licenses_print`, and the + second wrote its truncated result over the first's. + + Browsing during a long download stays responsive because metadata is read from the on-disk cache, + not by starting a run. + */ + private actor SessionGate { + static let shared = SessionGate() + + private var isBusy = false + private var waiters: [CheckedContinuation] = .init() + + func acquire() async { + guard isBusy else { isBusy = true; return } + await withCheckedContinuation { waiters.append($0) } + } + + func release() { + if waiters.isEmpty { + isBusy = false + } else { + waiters.removeFirst().resume() + } + } + } + + /** + Runs SteamCMD and returns its complete output. + + Use this, not ``run(commands:...)``, whenever the output *is* the result — `licenses_print`, + `package_info_print`, `app_info_print`. `Process.runStreamed` splits whatever a single `read()` + returned and discards whether a newline terminated it, so a line spanning two reads arrives as two + fragments that no line-oriented pattern can match. Measured: an enumeration that received 188 licence + lines parsed 7 of them, because most arrived cut mid-line: + + License packageID 7: + - State : + + Streaming remains right for downloads, where progress matters more than any individual line, and for + signing in, where prompts must be answered as they appear. + */ + static func capture( + commands: [Command], + credentials: Credentials? = nil, + forcedPlatform: ForcedPlatform? = nil, + stopOnFailedCommand: Bool = false + ) async throws -> String { + guard isInstalled else { throw Failure.notInstalled } + guard Rosetta.exists else { throw Failure.rosettaMissing } + + await SessionGate.shared.acquire() + defer { Task { await SessionGate.shared.release() } } + + let process = try prepareProcess(commands: commands, + installDirectory: nil, + credentials: credentials, + forcedPlatform: forcedPlatform, + stopOnFailedCommand: stopOnFailedCommand) + + let result = try await process.runWrappedAsync() + + // Some of SteamCMD's own chatter goes to stderr; the caller wants the transcript, not the split. + return [result.standardOutput, result.standardError].compactMap { $0 }.joined(separator: "\n") + } + + /** + Runs SteamCMD and reports every classified line. + + Argument order is assembled here rather than by callers because it is load-bearing: + **`force_install_dir` must precede `login`**, otherwise SteamCMD refuses with + `Please use force_install_dir before logon!`. + + - Note: `Process.runStreamed` splits whatever a single `read()` returned, so a line delivered across + two reads arrives as two chunks. Prompts survive this because they are matched with `contains`; + a progress tick split this way is simply missed, which is harmless. + */ + @discardableResult + static func run( + commands: [Command], + installDirectory: URL? = nil, + credentials: Credentials? = nil, + forcedPlatform: ForcedPlatform? = .windows, + requiresSuccessfulLogin: Bool = false, + stopOnFailedCommand: Bool = true, + onLine: (@Sendable (SteamCMDOutput.Line) -> Void)? = nil + ) async throws -> Process { + guard isInstalled else { throw Failure.notInstalled } + guard Rosetta.exists else { throw Failure.rosettaMissing } + + await SessionGate.shared.acquire() + defer { Task { await SessionGate.shared.release() } } + + let process = try prepareProcess(commands: commands, + installDirectory: installDirectory, + credentials: credentials, + forcedPlatform: forcedPlatform, + stopOnFailedCommand: stopOnFailedCommand) + + let responder: PromptResponder = .init(credentials: credentials) + let outcome: OperationOutcome = .init() + + // Cancellation is handled here rather than by callers: only this scope has the child process + // while it is running. The previous arrangement handed the process back to the caller *after* + // the await returned, so its cancellation handler had nothing to interrupt. + try await withTaskCancellationHandler { + try await process.runStreamed(throwsOnChunkError: false) { chunk in + let line = SteamCMDOutput.classify(chunk.output) + onLine?(line) + + switch line { + case .prompt(let prompt): + return responder.reply(to: prompt) + case .failure(let failure): + outcome.record(translate(failure)) + case .loginInProgress, .awaitingMobileConfirmation: + // SteamCMD interleaves diagnostics mid-line, so a verdict can arrive as its own chunk -- + // observed with `Loading Steam API...` and a bare `OK` two lines apart. + outcome.beginAwaitingLoginVerdict() + case .other(let raw) where outcome.isAwaitingLoginVerdict: + if raw.trimmingCharacters(in: .whitespaces) == "OK" { outcome.recordLoginSucceeded() } + case .loginResult(let succeeded, let detail): + guard !succeeded else { outcome.recordLoginSucceeded(); break } + // Having been asked for a code we had no answer for is the usual shape of "needs 2FA", + // and is a materially different thing to tell the user than "sign-in failed". + let awaitingCode = responder.wasPrompted(for: .twoFactorCode) + || responder.wasPrompted(for: .steamGuardCode) + outcome.record(awaitingCode ? .twoFactorRequired : .loginFailed(detail: detail)) + case .success(let appID, _): + outcome.recordSuccess(appID: appID) + default: + break + } + + return nil + } + } onCancel: { + process.interrupt() + } + + if let failure = outcome.failure { throw failure } + + // Absence of an error is not proof of a sign-in. A login awaiting Steam Mobile App confirmation + // that then times out produces neither an ERROR line nor a verdict on the `to Steam Public...` + // line, so without this a timeout would be reported to the user as success. + if requiresSuccessfulLogin, !outcome.loginSucceeded { + throw diagnoseIncompleteLogin() + } + + return process + } + + /** + Works out *why* a sign-in ended without a verdict. + + SteamCMD says nothing useful on stdout in this case, but it is explicit in its own connection log, + which lives inside the scoped home Mythic owns. + */ + private static func diagnoseIncompleteLogin() -> Failure { + let connectionLogURL = homeDirectory + .appending(path: "Library/Application Support/Steam/logs/connection_log.txt") + + guard let contents = try? String(contentsOf: connectionLogURL, encoding: .utf8) else { + return .loginDidNotComplete + } + + // Only the tail matters -- the file accumulates across runs. + let tail = contents.split(whereSeparator: \.isNewline).suffix(40).joined(separator: "\n") + + // Only the explicit timeout counts. "Waiting for confirmation" on its own appears in *successful* + // sign-ins too, and matching it reported a completed login as a timeout. + if tail.contains("Timed out waiting for confirmation") { + return .mobileConfirmationTimedOut + } + if tail.contains("Rate Limit Exceeded") { + return .rateLimited + } + + return .loginDidNotComplete + } + + /// Collects the first terminal outcome seen on the stream. + fileprivate final class OperationOutcome: Sendable { + private let state: OSAllocatedUnfairLock = .init(initialState: .init()) + + var failure: Failure? { state.withLock(\.failure) } + var succeededAppIDs: Set { state.withLock(\.succeededAppIDs) } + var loginSucceeded: Bool { state.withLock(\.loginSucceeded) } + var isAwaitingLoginVerdict: Bool { state.withLock(\.isAwaitingLoginVerdict) } + + func record(_ failure: Failure) { + state.withLock { if $0.failure == nil { $0.failure = failure } } + } + + func recordSuccess(appID: String) { + state.withLock { $0.succeededAppIDs.insert(appID) } + } + + func recordLoginSucceeded() { + state.withLock { + $0.loginSucceeded = true + $0.isAwaitingLoginVerdict = false + } + } + + func beginAwaitingLoginVerdict() { + state.withLock { $0.isAwaitingLoginVerdict = true } + } + } + + private static func translate(_ failure: SteamCMDOutput.Failure) -> Failure { + switch failure { + case .invalidPassword: + .invalidCredentials + case .rateLimited: + .rateLimited + case .twoFactorMismatch: + .twoFactorRequired + case .installFailed(let appID, let reason): + .appOperationFailed(appID: appID, reason: reason) + case .appStateAfterUpdate(let appID, let stateFlags): + .appOperationFailed(appID: appID, reason: "state 0x\(String(stateFlags, radix: 16))") + case .timedOutWaitingForUpdate: + .appOperationFailed(appID: "", reason: "timed out waiting for the update to start") + } + } + + /// Shared argument assembly. Order is load-bearing: ConVars, then `force_install_dir`, then `login`. + private static func prepareProcess( + commands: [Command], + installDirectory: URL?, + credentials: Credentials?, + forcedPlatform: ForcedPlatform?, + stopOnFailedCommand: Bool + ) throws -> Process { + var arguments: [String] = .init() + + // ConVars first; they change how later commands behave. + // Defaults to 1 in SteamCMD itself, which is right for a single operation and wrong for a batch: + // one unknown app ID makes `app_info_print` produce an empty block, and SteamCMD then abandons + // every remaining command in the run. Measured: a 25-app metadata batch returned exactly one. + arguments += ["+@ShutdownOnFailedCommand", stopOnFailedCommand ? "1" : "0"] + arguments += ["+@NoPromptForPassword", credentials?.password == nil ? "1" : "0"] + if let forcedPlatform { + arguments += ["+@sSteamCmdForcePlatformType", forcedPlatform.rawValue] + } + + // Then the install directory -- before login, never after. + if let installDirectory { + arguments += ["+force_install_dir", installDirectory.path(percentEncoded: false)] + } + + if let credentials { + arguments += ["+login", credentials.username] + } + + arguments += commands.flatMap(\.arguments) + arguments += ["+quit"] + + let process: Process = .init() + process.executableURL = executableURL + process.currentDirectoryURL = directory + process.arguments = arguments + + var environment = ProcessInfo.processInfo.environment + environment["HOME"] = homeDirectory.path(percentEncoded: false) + process.environment = environment + + try FileManager.default.createDirectory(at: homeDirectory, withIntermediateDirectories: true) + + return process + } + +} + +extension SteamCMD.OperationOutcome { + struct State: Sendable { + var failure: SteamCMD.Failure? + var succeededAppIDs: Set = .init() + var loginSucceeded: Bool = false + var isAwaitingLoginVerdict: Bool = false + } +} diff --git a/Mythic/Utilities/Steam/SteamCMDOutput.swift b/Mythic/Utilities/Steam/SteamCMDOutput.swift new file mode 100644 index 00000000..533d7f72 --- /dev/null +++ b/Mythic/Utilities/Steam/SteamCMDOutput.swift @@ -0,0 +1,216 @@ +// +// SteamCMDOutput.swift +// Mythic +// +// Created by Brunelli Cupello on 21/8/2026. +// + +// Copyright © 2023-2026 vapidinfinity + +import Foundation + +/** + Classification of a single line of SteamCMD output. + + Two properties of SteamCMD's output drive the design here, both verified against SteamCMD `1785799152`: + + 1. **The bootstrapper is localized.** It starts in English, loads `steambootstrapper_.txt`, then + switches mid-stream (observed switching to pt-BR: `Verificando se há atualizações...`). Only the + numeric `[ 0%]` / `[----]` prefixes are stable, so those are all we match. Everything emitted after + `Loading Steam API...` comes from `steamconsole.dylib` and is **not** localized, so string matching is + safe from that point on. + + 2. **Prompts arrive without a trailing newline** and diagnostics interleave mid-line, so prompt detection + uses `contains` rather than equality, and the caller must feed us whatever it has rather than waiting + for a line terminator. + */ +enum SteamCMDOutput { + enum Line: Equatable, Sendable { + /// A depot download/verify progress tick. + case progress(Progress) + /// `Success! App '' fully installed.` / `already up to date.` + case success(appID: String, wasAlreadyUpToDate: Bool) + /// A recognised failure. + case failure(Failure) + /// SteamCMD is waiting on stdin. + case prompt(Prompt) + /// The result of a `login` attempt. + case loginResult(succeeded: Bool, detail: String?) + /// Steam is waiting for the user to approve the sign-in in the Steam Mobile App. + case awaitingMobileConfirmation + /// A `login` line that carries no verdict yet. Steam prints `...to Steam Public...` and appends + /// `OK` or `ERROR (...)` to that same physical line later, so an unresolved line means the + /// attempt is in flight -- most often waiting on a Steam Mobile App confirmation. + case loginInProgress + /// Bootstrapper/self-update chatter. `percentage` is `nil` for the `[----]` indeterminate form. + case bootstrap(percentage: Int?) + /// Nothing we act on for control flow. Carries the raw line, because bulk output — + /// `app_info_print`'s VDF blob, for one — arrives this way and callers need it. + case other(String) + } + + struct Progress: Equatable, Sendable { + /// Raw `Update state (0x??)` value. + var stateFlags: Int + var phase: Phase + /// 0.0 ... 100.0, as printed. + var percentage: Double + var currentBytes: Int64 + var totalBytes: Int64 + } + + /// Phase names as they appear in `steamconsole.dylib`, contiguous in the binary. + enum Phase: Equatable, Sendable { + case stopping, reconfiguring, preallocating, downloading, staging + case committing, verifyingInstall, verifyingUpdate, finalizing, running, unknown + case unrecognised(String) + + init(rawPhase: String) { + switch rawPhase.trimmingCharacters(in: .whitespaces).lowercased() { + case "stopping": self = .stopping + case "reconfiguring": self = .reconfiguring + case "preallocating": self = .preallocating + case "downloading": self = .downloading + case "staging": self = .staging + case "committing": self = .committing + case "verifying install": self = .verifyingInstall + case "verifying update": self = .verifyingUpdate + case "finalizing": self = .finalizing + case "running": self = .running + case "unknown": self = .unknown + case let other: self = .unrecognised(other) + } + } + + /// Whether bytes are moving over the network, for `Progress.fileOperationKind`. + var isDownloading: Bool { + self == .downloading || self == .preallocating + } + } + + enum Prompt: Equatable, Hashable, Sendable { + case password + /// Emailed Steam Guard code. + case steamGuardCode + /// Mobile authenticator code. + case twoFactorCode + } + + enum Failure: Equatable, Sendable { + case appStateAfterUpdate(appID: String, stateFlags: Int) + case installFailed(appID: String, reason: String) + case timedOutWaitingForUpdate(stateFlags: Int) + case invalidPassword + case twoFactorMismatch + case rateLimited + } + + // MARK: - Classification + + /// Classify one line. The line may retain ANSI escapes and a trailing `\r`; both are handled. + static func classify(_ rawLine: String) -> Line { + let line = stripANSIEscapes(rawLine) + .replacingOccurrences(of: "\r", with: "") + + // Prompts are matched first: they arrive without a newline and can share a physical line with + // preceding diagnostics, so `contains` is required, not equality. + if line.contains("Two-factor code:") { return .prompt(.twoFactorCode) } + if line.contains("Steam Guard code:") { return .prompt(.steamGuardCode) } + if line.contains("password:") { return .prompt(.password) } + + if let match = line.firstMatch(of: #/^\s*Update state \(0x([0-9A-Fa-f]+)\) ([^,]+), progress: (\d+\.\d+) \((\d+) / (\d+)\)/#) { + return .progress( + Progress( + stateFlags: Int(match.output.1, radix: 16) ?? 0, + phase: Phase(rawPhase: String(match.output.2)), + percentage: Double(match.output.3) ?? 0, + currentBytes: Int64(match.output.4) ?? 0, + totalBytes: Int64(match.output.5) ?? 0 + ) + ) + } + + if let match = line.firstMatch(of: #/^Success! App '(\d+)' (fully installed|already up to date)\./#) { + return .success(appID: String(match.output.1), + wasAlreadyUpToDate: match.output.2 == "already up to date") + } + + if let match = line.firstMatch(of: #/^\s*Update state \(0x([0-9A-Fa-f]+)\) : Timed out waiting for update to start/#) { + return .failure(.timedOutWaitingForUpdate(stateFlags: Int(match.output.1, radix: 16) ?? 0)) + } + + if let match = line.firstMatch(of: #/^Error! App '(\d+)' state is 0x([0-9A-Fa-f]+) after update job\./#) { + return .failure(.appStateAfterUpdate(appID: String(match.output.1), + stateFlags: Int(match.output.2, radix: 16) ?? 0)) + } + + if let match = line.firstMatch(of: #/^ERROR! Failed to install app '(\d+)' \(([^)]+)\)/#) { + return .failure(.installFailed(appID: String(match.output.1), + reason: String(match.output.2))) + } + + if line.contains("Two-factor code mismatch") { return .failure(.twoFactorMismatch) } + if line.contains("Rate Limit Exceeded") { return .failure(.rateLimited) } + + // Login outcome. The modern shape is `...to Steam Public...ERROR (Invalid Password)`, *not* the + // legacy `FAILED login with result code` that most third-party parsers still match for. + // The mobile-authenticator flow reports its own outcome, on its own line -- `to Steam Public...` + // never gains a verdict in that case. Verbatim from a real successful sign-in: + // Please confirm the login in the Steam Mobile app on your phone. + // Waiting for confirmation... + // Waiting for confirmation...OK + if line.contains("Waiting for confirmation") { + return line.hasSuffix("OK") + ? .loginResult(succeeded: true, detail: nil) + : .awaitingMobileConfirmation + } + + if line.contains("Please confirm the login in the Steam Mobile app") { + return .awaitingMobileConfirmation + } + + if line.contains("to Steam Public...") { + if line.hasSuffix("OK") { return .loginResult(succeeded: true, detail: nil) } + if let match = line.firstMatch(of: #/(?:ERROR|FAILED)\s*\(([^)]+)\)/#) { + let detail = String(match.output.1) + if detail == "Invalid Password" { return .failure(.invalidPassword) } + return .loginResult(succeeded: false, detail: detail) + } + // No verdict on this line yet. Reading that as a failure was wrong: Steam appends the result + // to this same physical line once it has one, and while it waits for a Steam Mobile App + // confirmation there is nothing appended at all. + return .loginInProgress + } + + // Bootstrapper. Numeric prefixes only — the words after them are localized. + if let match = line.firstMatch(of: #/^\[\s*(\d+)%\]/#) { + return .bootstrap(percentage: Int(match.output.1)) + } + if line.hasPrefix("[----]") { + return .bootstrap(percentage: nil) + } + + return .other(line) + } + + /// SteamCMD writes its interactive prompt as `Steam>\u{1B}[0m` — strip escapes before matching. + static func stripANSIEscapes(_ text: String) -> String { + text.replacing(#/\x1B\[[0-9;]*[A-Za-z]/#, with: "") + } + + // MARK: - Expression reference + // + // The literals live inline at their use sites above: `Regex` is not `Sendable`, so holding them as + // `static let` is a hard error under Swift 6 strict concurrency. They are reproduced here for review: + // + // progress ^\s*Update state \(0x([0-9A-Fa-f]+)\) ([^,]+), progress: (\d+\.\d+) \((\d+) / (\d+)\) + // success ^Success! App '(\d+)' (fully installed|already up to date)\. + // appState ^Error! App '(\d+)' state is 0x([0-9A-Fa-f]+) after update job\. + // install ^ERROR! Failed to install app '(\d+)' \(([^)]+)\) + // timeout ^\s*Update state \(0x([0-9A-Fa-f]+)\) : Timed out waiting for update to start + // login (?:ERROR|FAILED)\s*\(([^)]+)\) + // bootstrap ^\[\s*(\d+)%\] + // + // Note the leading whitespace on every progress line, and that the timeout variant is structurally + // different from the progress line (colon-space, no byte pair) rather than a special case of it. +} diff --git a/Mythic/Utilities/Steam/SteamInterface.swift b/Mythic/Utilities/Steam/SteamInterface.swift new file mode 100644 index 00000000..efa93e35 --- /dev/null +++ b/Mythic/Utilities/Steam/SteamInterface.swift @@ -0,0 +1,537 @@ +// +// SteamInterface.swift +// Mythic +// +// Created by Brunelli Cupello on 21/8/2026. +// + +// Copyright © 2023-2026 vapidinfinity + +import Foundation +import OSLog + +/** + The storefront-service surface for Steam — the peer of `Legendary` for Epic. + + Everything here runs through ``SteamCMD``, which is a native macOS binary, so none of it depends on + Mythic Engine. What it cannot do is anything requiring the Steam GUI client; see ``SteamGame``. + */ +final class Steam { + static let log: Logger = .custom(category: "steam") + + // MARK: - Errors + + struct NotSignedInError: LocalizedError { + var errorDescription: String? = String(localized: "You aren't signed in to Steam.") + } + + struct UnknownAppError: LocalizedError { + let appID: String + var errorDescription: String? { + String(localized: "Steam didn't recognise app \(appID).") + } + } + + // MARK: - Install location + // + // Board item: "Custom install directory". + + /// Where Steam titles are installed. Falls back to Mythic's shared install base, then to Documents. + static var installBaseURL: URL { + if let custom = UserDefaults.standard.url(forKey: "steamInstallBaseURL") { + return custom + } + if let shared = UserDefaults.standard.url(forKey: "installBaseURL") { + return shared.appending(path: "Steam") + } + return FileLocations.globalGames ?? .documentsDirectory.appending(path: "Mythic/Steam") + } + + /** + Install location for a single title. + + One `force_install_dir` per game, named by appid. SteamCMD writes the app's manifest to + `/steamapps/appmanifest_.acf` — which is exactly where ``SteamGame/appManifestURL`` + looks — so a per-game directory keeps discovery and state reading symmetrical. The appid rather + than the title also means the path never has to be renamed or escaped. + */ + static func installURL(forAppID appID: String) -> URL { + installBaseURL.appending(path: appID) + } + + /// Forced platform for downloads, as chosen in Settings. + static var forcedPlatform: SteamCMD.ForcedPlatform { + UserDefaults.standard.string(forKey: "steamForcedPlatform") + .flatMap(SteamCMD.ForcedPlatform.init(rawValue:)) ?? .windows + } + + // MARK: - Account + // + // Board item: "Functional signin + steam guard support". + + private static var steamRootURL: URL { + SteamCMD.homeDirectory.appending(path: "Library/Application Support/Steam") + } + + private static var loginUsersURL: URL { + steamRootURL.appending(path: "config/loginusers.vdf") + } + + /** + Whether SteamCMD holds a cached session for a real (non-anonymous) account. + + Verified against an actual sign-in, which corrected an earlier assumption: SteamCMD does **not** + write `config/loginusers.vdf` — that is the desktop client's file, and it stays absent even after a + fully successful login. What SteamCMD does write is `userdata//`, using + `userdata/anonymous/` for anonymous sessions. A numerically-named directory there is therefore the + signal, and a stored username alone is not: that would keep claiming a session long after one + lapsed. + */ + static var hasCachedSession: Bool { + guard let entries = try? FileManager.default.contentsOfDirectory( + atPath: steamRootURL.appending(path: "userdata").path(percentEncoded: false) + ) else { return false } + + return entries.contains { UInt64($0) != nil } + } + + /** + The Steam account SteamCMD currently has a cached session for. + + Primary source is `config/loginusers.vdf`, which survives Mythic's own preferences being reset. + The username Mythic signed in with is kept as a fallback, because a fresh SteamCMD that has only + ever logged in anonymously writes no `loginusers.vdf` at all (verified — an anonymous run produces + `config.vdf`, `libraryfolders.vdf` and `userdata/anonymous/`, and nothing else). + + - Note: the `loginusers.vdf` shape below follows Valve's documented layout. It could not be + exercised here, since doing so needs real Steam credentials. + */ + /** + The account name to hand `+login`, or `nil` if it is not known. + + Deliberately separate from ``retrieveUser()``: that one may return a placeholder so the Accounts card + has something to show, and feeding a placeholder to `+login` would fail every SteamCMD run. + */ + static var cachedAccountName: String? { + guard hasCachedSession else { return nil } + + if let parsed = try? ValveDataFormat.parse(contentsOf: loginUsersURL), + let users = parsed[caseInsensitive: "users"]?.objectValue { + let accounts: [(name: String, isMostRecent: Bool)] = users.values.compactMap { user in + guard let name = user["AccountName"]?.stringValue else { return nil } + return (name: name, isMostRecent: user["MostRecent"]?.stringValue == "1") + } + + if let mostRecent = accounts.first(where: \.isMostRecent) { return mostRecent.name } + if let first = accounts.first { return first.name } + } + + return UserDefaults.standard.string(forKey: "steamUsername") + } + + static func retrieveUser() -> String? { + guard hasCachedSession else { return nil } + + if let parsed = try? ValveDataFormat.parse(contentsOf: loginUsersURL), + let users = parsed[caseInsensitive: "users"]?.objectValue { + // Prefer the account flagged MostRecent; otherwise any account present. + let accounts: [(name: String, isMostRecent: Bool)] = users.values.compactMap { user in + guard let name = user["AccountName"]?.stringValue else { return nil } + return (name: name, isMostRecent: user["MostRecent"]?.stringValue == "1") + } + + if let mostRecent = accounts.first(where: \.isMostRecent) { return mostRecent.name } + if let first = accounts.first { return first.name } + } + + return UserDefaults.standard.string(forKey: "steamUsername") + ?? String(localized: "Steam user") + } + + /** + Whether Mythic can actually act as the signed-in user. + + Requires a usable account name, not merely a cached session on disk: every SteamCMD run needs one for + `+login`, and a session we cannot name is a session we cannot use. Reporting it as signed in would + silently downgrade every subsequent run to anonymous, where `licenses_print` returns nothing at all — + so a library that looked empty would really be a login Mythic could not address. + */ + static var isSignedIn: Bool { cachedAccountName != nil } + + /// What the sign-in flow is currently waiting on, for the UI to relay. + enum SignInStatus: Sendable { + case connecting + /// Steam has pushed a confirmation to the Steam Mobile App and is waiting for approval. + case awaitingMobileConfirmation + } + + /** + Signs in to Steam through SteamCMD. + + - Parameter steamGuardCode: an emailed Steam Guard code or a mobile authenticator code. Pass `nil` + on the first attempt; if the account needs one, this throws ``SteamCMD/Failure/twoFactorRequired`` + and the caller should ask for a code and retry. + - Returns: the signed-in account name. + */ + @discardableResult + static func signIn(username: String, + password: String, + steamGuardCode: String? = nil, + onStatus: (@Sendable (SignInStatus) -> Void)? = nil) async throws -> String { + let credentials: SteamCMD.Credentials = .init(username: username, + password: password, + steamGuardCode: steamGuardCode) + + // No commands: `+login` alone is enough to establish and cache the session. + // requiresSuccessfulLogin makes a timed-out mobile confirmation an error rather than a silent + // "success" that writes a username Mythic has no session for. + try await SteamCMD.run(commands: [], + credentials: credentials, + forcedPlatform: nil, + requiresSuccessfulLogin: true) { line in + switch line { + case .awaitingMobileConfirmation: onStatus?(.awaitingMobileConfirmation) + case .loginInProgress: onStatus?(.connecting) + default: break + } + } + + UserDefaults.standard.set(username, forKey: "steamUsername") + + // A refresh failure must not invalidate an otherwise successful sign-in. + try? await GameDataStore.shared.refreshFromStorefronts(.steam) + + return username + } + + static func signOut() async throws { + if SteamCMD.isInstalled { + // Best-effort: the local session files are removed regardless of whether SteamCMD cooperates. + try? await SteamCMD.run(commands: [.raw("logout")], forcedPlatform: nil) + } + + try? FileManager.default.removeItemIfExists(at: loginUsersURL) + UserDefaults.standard.removeObject(forKey: "steamUsername") + } + + // MARK: - Library + // + // Board item: "Fetch game list". + + /** + Every Steam title installed under ``installBaseURL``. + + Discovery is by on-disk manifest rather than by asking SteamCMD, because it must be synchronous: + `StorefrontGameManager.fetchUpdateAvailability(for:)` and `isFileVerificationRequired(for:)` are + sync and throwing and cannot shell out. + */ + static func getInstalledGames() throws -> [SteamGame] { + let fileManager: FileManager = .default + guard fileManager.fileExists(atPath: installBaseURL.path(percentEncoded: false)) else { return [] } + + let entries = try fileManager.contentsOfDirectory( + at: installBaseURL, + includingPropertiesForKeys: [.isDirectoryKey], + options: [.skipsHiddenFiles] + ) + + return entries.compactMap { entry -> SteamGame? in + guard (try? entry.resourceValues(forKeys: [.isDirectoryKey]).isDirectory) == true else { return nil } + + let steamAppsURL = entry.appending(path: "steamapps") + guard let manifests = try? fileManager.contentsOfDirectory(atPath: steamAppsURL.path(percentEncoded: false)) else { + return nil + } + + guard let manifestName = manifests.first(where: { + $0.hasPrefix("appmanifest_") && $0.hasSuffix(".acf") + }) else { return nil } + + // `try?` already flattens the double optional here. + guard let manifest = try? ValveDataFormat.parseAppManifest( + contentsOf: steamAppsURL.appending(path: manifestName) + ) else { return nil } + + // Steam records the platform nowhere in the manifest, so it is inferred from what the depot + // actually produced: a .app bundle means the macOS build, anything else the Windows one. + let platform: Game.Platform = fileManager.fileExists( + atPath: entry.appending(path: "\(manifest.installDirectoryName ?? "").app").path(percentEncoded: false) + ) ? .macOS : .windows + + return SteamGame( + appID: manifest.appID, + title: manifest.name ?? manifest.appID, + installationState: .installed(location: entry, platform: platform) + ) + } + } + + /** + Titles the account owns but has not installed. + + Read from a cache Mythic maintains itself, because this is synchronous by protocol. That mirrors + Epic, where the equivalent list comes from JSON that legendary has already written; here Mythic is + the thing that writes it, in ``refreshOwnedGames(force:)``. + */ + static func getInstallableGames() throws -> [SteamGame] { + guard isSignedIn else { throw NotSignedInError() } + + return (try? loadOwnedGames())?.games.map { + SteamGame(appID: $0.appID, title: $0.name, installationState: .uninstalled) + } ?? [] + } + + // MARK: - Owned library + // + // Board item: "Fetch game list". + + struct OwnedGamesCache: Codable { + var refreshedAt: Date + var games: [OwnedGame] + } + + private static var ownedGamesCacheURL: URL { + SteamCMD.directory.appending(path: "library.json") + } + + private static func loadOwnedGames() throws -> OwnedGamesCache { + try JSONDecoder().decode(OwnedGamesCache.self, + from: try Data(contentsOf: ownedGamesCacheURL)) + } + + /// How long an enumeration is trusted before it is worth redoing. Licences change rarely, and the + /// refresh costs a SteamCMD session plus a metadata pass over the whole library. + private static let ownedGamesCacheLifetime: TimeInterval = 6 * 60 * 60 + + /** + Enumerates the account's library and caches it. + + There is no single command for this, so it is assembled: + + 1. `licenses_print` lists every package the account holds, with the app IDs in each. Steam caps how + many it prints per package and says so (`(N in total)`), so any package that printed fewer than it + claims is read again through `package_info_print`, which lists them all. Measured on a real + account: 113 packages, 284 app IDs printed, 455 claimed — 171 of them behind a single elided line. + 2. `app_info_print` resolves each ID to a name and a type. The type matters: without it a library + fills up with DLC, soundtracks, demos and dedicated servers. Batched, because one SteamCMD session + costs seconds of bootstrap and login regardless of how many commands it carries. + + Package 0 is skipped. It is Steam's default package — 196 entries of Valve internals and + free-for-everyone apps on the account measured — and anything genuinely owned also appears under a + real licence. + */ + /// Coarse stages of an enumeration, for a caller that wants to show progress. + enum LibraryRefreshStage: Sendable, Equatable { + case readingLicences + /// `completed` of `total` metadata batches done. + case resolvingTitles(completed: Int, total: Int, titleCount: Int) + case finished(gameCount: Int) + } + + /** + Collapses concurrent enumerations into one. + + `refreshFromStorefronts` legitimately gets called more than once at launch — the empty library view + asks for one, and so does the app delegate. Serialising the underlying SteamCMD runs stops them + corrupting each other, but without this the second caller would still redo the whole minute-long pass + for nothing. + */ + private actor RefreshCoordinator { + static let shared = RefreshCoordinator() + + private var inFlight: Task? + + func run(_ operation: @Sendable @escaping () async throws -> Void) async throws { + if let inFlight { return try await inFlight.value } + + let task = Task { try await operation() } + inFlight = task + + defer { inFlight = nil } + try await task.value + } + } + + static func refreshOwnedGames( + force: Bool = false, + onStage: (@Sendable (LibraryRefreshStage) -> Void)? = nil + ) async throws { + try await RefreshCoordinator.shared.run { + try await performOwnedGamesRefresh(force: force, onStage: onStage) + } + } + + private static func performOwnedGamesRefresh( + force: Bool, + onStage: (@Sendable (LibraryRefreshStage) -> Void)? + ) async throws { + guard isSignedIn else { throw NotSignedInError() } + + if !force, + let cache = try? loadOwnedGames(), + Date.now.timeIntervalSince(cache.refreshedAt) < ownedGamesCacheLifetime { + return + } + + onStage?(.readingLicences) + let appIDs = try await fetchOwnedAppIDs() + log.notice("Steam library: \(appIDs.count, privacy: .public) owned app IDs to resolve") + + var games: [OwnedGamesCache.OwnedGame] = .init() + + // 50 keeps a single batch's output to a few thousand lines while still amortising bootstrap. + let batches = appIDs.chunked(into: 50) + onStage?(.resolvingTitles(completed: 0, total: batches.count, titleCount: appIDs.count)) + + for (index, batch) in batches.enumerated() { + // capture, not run: this output *is* the result, and streamed chunks arrive split mid-line. + let output = try await SteamCMD.capture( + commands: batch.map { SteamCMD.Command.appInfoPrint(appID: $0) }, + credentials: metadataCredentials, + // One unrecognised app ID would otherwise abandon the rest of the batch. + stopOnFailedCommand: false + ) + for appID in batch { + guard let info = ValveDataFormat.parseAppInfo(appID: appID, from: output) else { continue } + guard info.isGame else { continue } + + games.append(.init(appID: info.appID, name: info.name)) + cacheAppInfoBlock(appID: appID, from: output) + } + + onStage?(.resolvingTitles(completed: index + 1, + total: batches.count, + titleCount: appIDs.count)) + } + + let cache = OwnedGamesCache(refreshedAt: .now, games: games.sorted { $0.name < $1.name }) + try JSONEncoder().encode(cache).write(to: ownedGamesCacheURL, options: .atomic) + + log.notice("Steam library: \(games.count, privacy: .public) games cached") + onStage?(.finished(gameCount: games.count)) + } + + /// Every app ID the account holds a licence for, package 0 excluded. + private static func fetchOwnedAppIDs() async throws -> [String] { + let licences = try await SteamCMD.capture(commands: [.licensesPrint], + credentials: metadataCredentials) + + let packages = ValveDataFormat.parseLicenses(from: licences) + .filter { $0.packageID != "0" } + + var appIDs: [String] = packages.flatMap(\.listedAppIDs) + + let truncated = packages.filter(\.isTruncated) + if !truncated.isEmpty { + let packageOutput = try await SteamCMD.capture( + commands: truncated.map { SteamCMD.Command.packageInfoPrint(packageID: $0.packageID) }, + credentials: metadataCredentials + ) + + for package in truncated { + appIDs += ValveDataFormat.parsePackageAppIDs(packageID: package.packageID, + from: packageOutput) + } + } + + // Order-preserving deduplication. + var seen: Set = .init() + return appIDs.filter { seen.insert($0).inserted } + } + + /// Saves one app's block out of a batch transcript, so launching does not have to re-fetch it. + private static func cacheAppInfoBlock(appID: String, from output: String) { + guard let block = ValveDataFormat.isolateBlock(id: appID, from: output) else { return } + + do { + try FileManager.default.createDirectory(at: appInfoCacheDirectory, withIntermediateDirectories: true) + try block.write(to: appInfoCacheURL(appID: appID), atomically: true, encoding: .utf8) + } catch { + log.warning("Couldn't cache app info for \(appID, privacy: .public): \(error.localizedDescription)") + } + } + + /// `+login` for a SteamCMD run that only needs metadata: reuse the cached session if there is one, + /// otherwise go anonymous. Passing no credentials at all would skip `+login` entirely and leave the + /// run unauthenticated. + private static var metadataCredentials: SteamCMD.Credentials { + if let accountName = cachedAccountName { return .init(username: accountName) } + return .anonymous + } + + // MARK: - App info, with an on-disk cache + // + // The cache exists because launching needs the executable name, which lives in `config/launch` and + // nowhere on disk after install. A SteamCMD round trip costs seconds (it re-checks for its own + // update every run), so the raw blob is kept beside SteamCMD and re-read synchronously. Caching a + // sidecar file rather than adding a persisted field to `SteamGame` keeps `Game.encode(to:)` in its + // extension -- see the plan's design note on why that matters. + + private static var appInfoCacheDirectory: URL { + SteamCMD.directory.appending(path: "appinfo") + } + + private static func appInfoCacheURL(appID: String) -> URL { + appInfoCacheDirectory.appending(path: "\(appID).vdf") + } + + /// Parsed `app_info_print` blob from the cache, if one has been fetched before. + static func cachedAppInfo(appID: String) -> ValveDataFormat.AppInfo? { + guard let raw = try? String(contentsOf: appInfoCacheURL(appID: appID), encoding: .utf8) else { + return nil + } + return ValveDataFormat.parseAppInfo(appID: appID, from: raw) + } + + /// Cached info if available, otherwise fetched and cached. + static func appInfo(appID: String) async throws -> ValveDataFormat.AppInfo { + if let cached = cachedAppInfo(appID: appID) { return cached } + return try await refreshAppInfo(appID: appID) + } + + /// Fetches `app_info_print` output, caches the raw blob, and returns the parsed result. + @discardableResult + static func refreshAppInfo(appID: String) async throws -> ValveDataFormat.AppInfo { + let raw = try await SteamCMD.capture(commands: [.appInfoPrint(appID: appID)], + credentials: metadataCredentials) + guard let info = ValveDataFormat.parseAppInfo(appID: appID, from: raw) else { + throw UnknownAppError(appID: appID) + } + + do { + try FileManager.default.createDirectory(at: appInfoCacheDirectory, withIntermediateDirectories: true) + try raw.write(to: appInfoCacheURL(appID: appID), atomically: true, encoding: .utf8) + } catch { + // A cache write failure costs a slower launch, not correctness. + log.warning("Couldn't cache app info for \(appID, privacy: .public): \(error.localizedDescription)") + } + + return info + } + + /// Maps `common/oslist` onto Mythic's platform type. + static func supportedPlatforms(for info: ValveDataFormat.AppInfo) -> Set { + var platforms: Set = .init() + if info.operatingSystems.contains("windows") { platforms.insert(.windows) } + if info.operatingSystems.contains("macos") { platforms.insert(.macOS) } + return platforms + } +} + +private extension Array { + /// Splits into batches of at most `size`. Used to keep a single SteamCMD metadata run's output + /// bounded while still amortising its bootstrap and login cost over many commands. + func chunked(into size: Int) -> [[Element]] { + guard size > 0 else { return [self] } + return stride(from: 0, to: count, by: size).map { + Array(self[$0...acf`, + `libraryfolders.vdf` and `config.vdf`. + + This exists because `StorefrontGameManager.fetchUpdateAvailability(for:)` and + `isFileVerificationRequired(for:)` are **synchronous and throwing** — they cannot shell out to + `steamcmd +app_status`. The only synchronously readable source of truth for a Steam title's state is + the on-disk `.acf` manifest, so a parser is required rather than merely convenient. + + Scope is deliberately narrow: read-only, no writing, no binary VDF. Duplicate keys resolve last-wins, + which matches how Steam's own reader behaves for the files we consume. + */ +enum ValveDataFormat { + indirect enum Value: Equatable, Sendable { + case string(String) + case object([String: Value]) + } + + enum ParseError: LocalizedError, Equatable { + case unterminatedString(atOffset: Int) + case expectedKey(butFound: String, atOffset: Int) + case expectedValue(forKey: String, atOffset: Int) + case unbalancedBrace(atOffset: Int) + + var errorDescription: String? { + switch self { + case .unterminatedString(let offset): + "Unterminated quoted string at offset \(offset)." + case .expectedKey(let found, let offset): + "Expected a key at offset \(offset), found \"\(found)\"." + case .expectedValue(let key, let offset): + "Key \"\(key)\" at offset \(offset) has no value." + case .unbalancedBrace(let offset): + "Unbalanced brace at offset \(offset)." + } + } + } + + static func parse(_ text: String) throws -> [String: Value] { + var scanner = Scanner(scalars: Array(text.unicodeScalars)) + let root = try scanner.parseObjectBody(isTopLevel: true) + return root + } + + static func parse(contentsOf url: URL) throws -> [String: Value] { + try parse(String(contentsOf: url, encoding: .utf8)) + } +} + +// MARK: - Accessors + +extension ValveDataFormat.Value { + var stringValue: String? { + if case .string(let value) = self { return value } + return nil + } + + var objectValue: [String: ValveDataFormat.Value]? { + if case .object(let value) = self { return value } + return nil + } + + /// Steam is not internally consistent about casing — a single `.acf` carries both `appid` and + /// `StateFlags` — so lookups are case-insensitive by design. + subscript(key: String) -> ValveDataFormat.Value? { + guard case .object(let dictionary) = self else { return nil } + if let exact = dictionary[key] { return exact } + return dictionary.first { $0.key.caseInsensitiveCompare(key) == .orderedSame }?.value + } + + /// Valve stores every scalar as a quoted string, integers included. + var integerValue: Int? { + guard let stringValue else { return nil } + return Int(stringValue.trimmingCharacters(in: .whitespaces)) + } +} + +extension Dictionary where Key == String, Value == ValveDataFormat.Value { + subscript(caseInsensitive key: String) -> ValveDataFormat.Value? { + if let exact = self[key] { return exact } + return first { $0.key.caseInsensitiveCompare(key) == .orderedSame }?.value + } +} + +// MARK: - Scanner + +private extension ValveDataFormat { + struct Scanner { + let scalars: [Unicode.Scalar] + var index: Int = 0 + + init(scalars: [Unicode.Scalar]) { + self.scalars = scalars + } + + var isAtEnd: Bool { index >= scalars.count } + + mutating func skipIgnored() { + while index < scalars.count { + let scalar = scalars[index] + + if scalar == " " || scalar == "\t" || scalar == "\n" || scalar == "\r" { + index += 1 + } else if scalar == "/", index + 1 < scalars.count, scalars[index + 1] == "/" { + while index < scalars.count, scalars[index] != "\n" { index += 1 } + } else if scalar == "[" { + // Platform conditional, e.g. `"key" "value" [$WIN32]`. Not meaningful on macOS. + while index < scalars.count, scalars[index] != "]" { index += 1 } + if index < scalars.count { index += 1 } + } else { + return + } + } + } + + mutating func readToken() throws -> String? { + skipIgnored() + guard index < scalars.count else { return nil } + + let scalar = scalars[index] + if scalar == "{" || scalar == "}" { + index += 1 + return String(scalar) + } + + if scalar == "\"" { + let openedAt = index + index += 1 + var result = "" + while index < scalars.count { + let current = scalars[index] + if current == "\\", index + 1 < scalars.count { + index += 1 + switch scalars[index] { + case "n": result.unicodeScalars.append("\n") + case "t": result.unicodeScalars.append("\t") + case "\\": result.unicodeScalars.append("\\") + case "\"": result.unicodeScalars.append("\"") + case let other: result.unicodeScalars.append(other) + } + index += 1 + } else if current == "\"" { + index += 1 + return result + } else { + result.unicodeScalars.append(current) + index += 1 + } + } + throw ParseError.unterminatedString(atOffset: openedAt) + } + + // Unquoted token — legal in hand-written VDF, and Steam emits it for some config keys. + var result = "" + while index < scalars.count { + let current = scalars[index] + if current == " " || current == "\t" || current == "\n" || current == "\r" + || current == "\"" || current == "{" || current == "}" { + break + } + result.unicodeScalars.append(current) + index += 1 + } + return result.isEmpty ? nil : result + } + + mutating func parseObjectBody(isTopLevel: Bool) throws -> [String: Value] { + var result: [String: Value] = .init() + + while true { + let offsetBeforeKey = index + guard let key = try readToken() else { + if isTopLevel { return result } + throw ParseError.unbalancedBrace(atOffset: offsetBeforeKey) + } + + if key == "}" { + if isTopLevel { throw ParseError.unbalancedBrace(atOffset: offsetBeforeKey) } + return result + } + + if key == "{" { + throw ParseError.expectedKey(butFound: key, atOffset: offsetBeforeKey) + } + + let offsetBeforeValue = index + guard let token = try readToken() else { + throw ParseError.expectedValue(forKey: key, atOffset: offsetBeforeValue) + } + + if token == "{" { + result[key] = .object(try parseObjectBody(isTopLevel: false)) + } else if token == "}" { + throw ParseError.expectedValue(forKey: key, atOffset: offsetBeforeValue) + } else { + result[key] = .string(token) + } + } + } + } +} + +// MARK: - App manifest + +extension ValveDataFormat { + /// The subset of `appmanifest_.acf` Mythic acts on. + struct AppManifest: Equatable, Sendable { + var appID: String + var name: String? + var installDirectoryName: String? + var stateFlags: StateFlags + var buildID: Int? + var targetBuildID: Int? + var sizeOnDisk: Int64? + + /// `buildid` lagging `TargetBuildID` is the signal Steam itself uses; `updateRequired` alone + /// misses a queued-but-not-yet-flagged update. + var isUpdateAvailable: Bool { + if stateFlags.contains(.updateRequired) { return true } + guard let buildID, let targetBuildID, targetBuildID != 0 else { return false } + return buildID != targetBuildID + } + + var requiresVerification: Bool { + !stateFlags.isDisjoint(with: [.filesMissing, .filesCorrupt]) + } + + var isFullyInstalled: Bool { + stateFlags.contains(.fullyInstalled) + } + } + + static func parseAppManifest(_ text: String) throws -> AppManifest? { + let root = try parse(text) + guard let state = root[caseInsensitive: "AppState"], + let appID = state["appid"]?.stringValue else { return nil } + + return AppManifest( + appID: appID, + name: state["name"]?.stringValue, + installDirectoryName: state["installdir"]?.stringValue, + stateFlags: .init(rawValue: state["StateFlags"]?.integerValue ?? 0), + buildID: state["buildid"]?.integerValue, + targetBuildID: state["TargetBuildID"]?.integerValue, + sizeOnDisk: state["SizeOnDisk"]?.integerValue.map(Int64.init) + ) + } + + static func parseAppManifest(contentsOf url: URL) throws -> AppManifest? { + try parseAppManifest(String(contentsOf: url, encoding: .utf8)) + } +} + +extension ValveDataFormat.AppManifest { + /// Bit flags Steam writes to `AppState/StateFlags`. + struct StateFlags: OptionSet, Equatable, Sendable { + let rawValue: Int + + static let uninstalled = StateFlags(rawValue: 1 << 0) // 1 + static let updateRequired = StateFlags(rawValue: 1 << 1) // 2 + static let fullyInstalled = StateFlags(rawValue: 1 << 2) // 4 + static let updateQueued = StateFlags(rawValue: 1 << 3) // 8 + static let updateOptional = StateFlags(rawValue: 1 << 4) // 16 + static let filesMissing = StateFlags(rawValue: 1 << 5) // 32 + static let sharedOnly = StateFlags(rawValue: 1 << 6) // 64 + static let filesCorrupt = StateFlags(rawValue: 1 << 7) // 128 + static let updateRunning = StateFlags(rawValue: 1 << 8) // 256 + static let updatePaused = StateFlags(rawValue: 1 << 9) // 512 + static let updateStarted = StateFlags(rawValue: 1 << 10) // 1024 + static let uninstalling = StateFlags(rawValue: 1 << 11) // 2048 + static let reconfiguring = StateFlags(rawValue: 1 << 16) // 65536 + static let validating = StateFlags(rawValue: 1 << 17) // 131072 + } +} + +// MARK: - App info + +extension ValveDataFormat { + /// The subset of `app_info_print` output Mythic acts on. + /// + /// Deliberately free of `Game.Platform`: this layer stays dependency-free so it can be checked + /// standalone. Callers map ``operatingSystems`` onto Mythic's platform type. + struct AppInfo: Equatable, Sendable { + var appID: String + var name: String + /// Public-branch download size in bytes, summed across numeric depots, if Steam reports any. + var downloadSize: Int64? + /// Raw `common/oslist` tokens, e.g. `["windows", "macos"]`. + var operatingSystems: Set + /// `config/installdir` — the folder name Steam installs the title into. + var installDirectoryName: String? + /// `config/launch` entries, in the order Steam lists them. + var launchOptions: [LaunchOption] + /// `common/type`, lowercased. Steam is inconsistent about casing here — observed as both `game` + /// (Terraria, Dota 2) and `Game` (Palworld) in the same batch — so it is normalised on the way in. + var type: String? + + var isGame: Bool { type == "game" } + + /// A launch option runnable on `operatingSystem`, preferring the one Steam marks `default`. + func preferredLaunchOption(forOperatingSystem operatingSystem: String) -> LaunchOption? { + let candidates = launchOptions.filter { + // An empty oslist means "any platform", which is how single-platform titles are listed. + $0.operatingSystems.isEmpty || $0.operatingSystems.contains(operatingSystem) + } + + return candidates.first { $0.type == "default" } ?? candidates.first + } + } + + struct LaunchOption: Equatable, Sendable { + var executable: String + var arguments: String? + /// `default`, `option1`, `none`, ... + var type: String? + /// `config/oslist` for this option; empty means unrestricted. + var operatingSystems: Set + var description: String? + } + + /** + Extracts an ``AppInfo`` from raw `app_info_print` output. + + The VDF blob is wrapped in SteamCMD's own startup and shutdown chatter, so the balanced + `"" { ... }` block is isolated by brace depth before parsing. + + Feeding the trailing chatter to the parser is **not** safe, which is why it is cut here rather than + tolerated: `Unloading Steam API...OK` tokenises to three tokens, leaving `API...OK` as a key with no + value, and the parser rightly throws. Whether it throws depends on whether an odd or even number of + trailing tokens happens to follow — so a version that parsed to the end of the input would work or + fail based on how much chatter Steam emitted that run. + */ + /** + Isolates the balanced `"" { ... }` block for `id` out of a larger SteamCMD transcript. + + Necessary because the blob is wrapped in SteamCMD's own chatter, and feeding that chatter to the + parser is not safe: `Unloading Steam API...OK` tokenises to three tokens, leaving `API...OK` as a + key with no value, and the parser rightly throws. Whether it throws at all depends on whether an odd + or even number of trailing tokens happens to follow, so a version that parsed to end-of-input would + work or fail based on how much Steam printed that run. + */ + static func isolateBlock(id: String, from output: String) -> String? { + let lines = output.split(whereSeparator: \.isNewline).map(String.init) + guard let start = lines.firstIndex(where: { + $0.trimmingCharacters(in: .whitespaces) == "\"\(id)\"" + }) else { return nil } + + var depth = 0 + var hasOpened = false + for index in start.. 0 { hasOpened = true } + depth -= lines[index].filter { $0 == "}" }.count + + if hasOpened, depth <= 0 { + return lines[start...index].joined(separator: "\n") + } + } + + return nil + } + + static func parseAppInfo(appID: String, from output: String) -> AppInfo? { + guard let block = isolateBlock(id: appID, from: output), + let root = try? parse(block), + let app = root[caseInsensitive: appID], + let name = app["common"]?["name"]?.stringValue + else { return nil } + + let operatingSystems = Set( + (app["common"]?["oslist"]?.stringValue ?? .init()) + .split(separator: ",") + .map { $0.trimmingCharacters(in: .whitespaces) } + .filter { !$0.isEmpty } + ) + + // Depot keys are numeric; `branches`, `baselanguages` and `workshopdepot` sit alongside them. + var downloadSize: Int64 = 0 + for (key, depot) in app["depots"]?.objectValue ?? .init() where Int(key) != nil { + if let download = depot["manifests"]?["public"]?["download"]?.integerValue { + downloadSize += Int64(download) + } + } + + let configuration = app["config"] + + // Steam keys launch entries by stringified index; sort numerically so option order is Steam's. + let launchOptions: [LaunchOption] = (configuration?["launch"]?.objectValue ?? .init()) + .sorted { Int($0.key) ?? .max < Int($1.key) ?? .max } + .compactMap { _, entry in + guard let executable = entry["executable"]?.stringValue, !executable.isEmpty else { return nil } + + return LaunchOption( + executable: executable, + arguments: entry["arguments"]?.stringValue, + type: entry["type"]?.stringValue, + operatingSystems: Set( + (entry["config"]?["oslist"]?.stringValue ?? .init()) + .split(separator: ",") + .map { $0.trimmingCharacters(in: .whitespaces) } + .filter { !$0.isEmpty } + ), + description: entry["description"]?.stringValue + ) + } + + return .init(appID: appID, + name: name, + downloadSize: downloadSize > 0 ? downloadSize : nil, + operatingSystems: operatingSystems, + installDirectoryName: configuration?["installdir"]?.stringValue, + launchOptions: launchOptions, + type: app["common"]?["type"]?.stringValue?.lowercased()) + } +} + +// MARK: - Licenses + +extension ValveDataFormat { + /** + One entry of `licenses_print` output. + + `licenses_print` is not VDF — it is a human-readable listing — so it gets its own reader rather than + going through the KeyValues parser. + */ + struct LicensePackage: Equatable, Sendable { + var packageID: String + /// App IDs actually printed on the line. + var listedAppIDs: [String] + /// The count Steam claims the package holds. + var totalAppCount: Int + + /// Whether Steam elided part of the list. It caps the printed set, so a package holding more apps + /// than it printed has to be read again through `package_info_print`. + var isTruncated: Bool { listedAppIDs.count < totalAppCount } + } + + /// Parses the `License packageID N:` / ` - Apps : …` pairs out of `licenses_print` output. + static func parseLicenses(from output: String) -> [LicensePackage] { + var packages: [LicensePackage] = .init() + var currentPackageID: String? + + for line in output.split(whereSeparator: \.isNewline) { + if let match = line.firstMatch(of: #/^License packageID (\d+):/#) { + currentPackageID = String(match.output.1) + continue + } + + // Note the tab between "Apps" and the colon in real output. + guard let match = line.firstMatch(of: #/^\s*-\s*Apps\s*:\s*(.*?)\s*\((\d+) in total\)\s*$/#), + let packageID = currentPackageID + else { continue } + + packages.append( + .init(packageID: packageID, + listedAppIDs: String(match.output.1).matches(of: #/\d+/#).map { String($0.output) }, + totalAppCount: Int(match.output.2) ?? 0) + ) + currentPackageID = nil + } + + return packages + } + + /// Pulls the complete app ID list out of a `package_info_print` blob. + static func parsePackageAppIDs(packageID: String, from output: String) -> [String] { + guard let block = isolateBlock(id: packageID, from: output), + let root = try? parse(block), + let appIDs = root[caseInsensitive: packageID]?["appids"]?.objectValue + else { return [] } + + // Keyed by stringified index; sort numerically so the order is Steam's. + return appIDs + .sorted { Int($0.key) ?? .max < Int($1.key) ?? .max } + .compactMap { $0.value.stringValue } + } +} diff --git a/Mythic/Views/Navigation/AccountsView.swift b/Mythic/Views/Navigation/AccountsView.swift index 17ac5a63..2a41d4b2 100644 --- a/Mythic/Views/Navigation/AccountsView.swift +++ b/Mythic/Views/Navigation/AccountsView.swift @@ -18,6 +18,11 @@ struct AccountsView: View { @State private var isEpicSignOutErrorAlertPresented: Bool = false @State private var isEpicAccountCardRefreshed = false + @State private var isSteamSignInSheetPresented: Bool = false + @State private var isSteamCMDInstalled: Bool = true + @State private var isSteamSignOutConfirmationAlertPresented: Bool = false + @State private var isSteamAccountCardRefreshed = false + var body: some View { ScrollView { HStack { @@ -75,11 +80,62 @@ struct AccountsView: View { } } .id(isEpicAccountCardRefreshed) + + AccountCard( + signedInUser: .constant(Steam.retrieveUser()), + image: Image("Steam"), + storefront: .steam, + signInAction: { + isSteamSignInSheetPresented = true + }, + signOutAction: { + isSteamSignOutConfirmationAlertPresented = true + }, + requiresSetup: !isSteamCMDInstalled + ) + .padding() + .background( + RoundedRectangle(cornerRadius: 10) + .fill(.quinary) + ) + .sheet( + isPresented: $isSteamSignInSheetPresented, + onDismiss: { + // The sheet can install SteamCMD, so re-read that too, not just the account. + isSteamCMDInstalled = SteamCMD.isInstalled + isSteamAccountCardRefreshed.toggle() + }, + content: { + SteamSignInView(isPresented: $isSteamSignInSheetPresented) + .frame(width: 460) + } + ) + .alert( + "Are you sure you want to sign out of Steam?", + isPresented: $isSteamSignOutConfirmationAlertPresented + ) { + Button(role: .destructive) { + Task { + // Steam.signOut() is best-effort by design: it clears the local session + // whether or not SteamCMD cooperates, so there is nothing to surface. + try? await Steam.signOut() + isSteamAccountCardRefreshed.toggle() + } + } label: { + Text("Sign Out") + } + } message: { + Text("Games you've downloaded stay on disk.") + } + .id(isSteamAccountCardRefreshed) } } .frame(maxWidth: .infinity, alignment: .leading) .padding() .navigationTitle("Accounts") + .task { + isSteamCMDInstalled = SteamCMD.isInstalled + } .task(priority: .background) { discordRPC.setPresence({ var presence: RichPresence = .init() @@ -103,6 +159,11 @@ extension AccountsView { var signInAction: () -> Void var signOutAction: () -> Void + /// Set when the storefront's backend isn't installed yet, so the card leads with setting that up + /// instead of asking for credentials. Signing in to Steam *is* running SteamCMD, so there is no + /// useful account-without-backend state to offer. Defaults off, leaving Epic unchanged. + var requiresSetup: Bool = false + @State private var isHoveringOverSignOutButton: Bool = false // @State private var isSignOutConfirmationAlertPresented: Bool = false @@ -117,7 +178,12 @@ extension AccountsView { Text(storefront.description) .font(.title.bold()) - Text(signedInUser != nil ? "Signed in as \"\(signedInUser ?? "Unknown")\"" : "Not signed in") + if requiresSetup, signedInUser == nil { + Text("Setup required") + .foregroundStyle(.secondary) + } else { + Text(signedInUser != nil ? "Signed in as \"\(signedInUser ?? "Unknown")\"" : "Not signed in") + } } Group { @@ -145,6 +211,8 @@ extension AccountsView { } } */ + } else if requiresSetup { + Button("Set Up", systemImage: "arrow.down.circle", action: signInAction) } else { Button("Sign In", systemImage: "person", action: signInAction) } diff --git a/Mythic/Views/Navigation/LibraryView.swift b/Mythic/Views/Navigation/LibraryView.swift index fc7a0d7a..b13be1fa 100644 --- a/Mythic/Views/Navigation/LibraryView.swift +++ b/Mythic/Views/Navigation/LibraryView.swift @@ -27,10 +27,26 @@ struct LibraryView: View { .toolbar { ToolbarItem(placement: .status) { if gameListViewModel.isUpdatingLibrary { - ProgressView() - .controlSize(.small) - .help("Mythic is updating your library.") - .padding(10) + HStack(spacing: 6) { + if let fraction = gameListViewModel.libraryUpdateProgress?.fractionCompleted { + ProgressView(value: fraction) + .controlSize(.small) + .frame(width: 90) + } else { + ProgressView() + .controlSize(.small) + } + + // Named, because a bare spinner during a minute-long Steam enumeration reads + // as the app having hung. + if let label = gameListViewModel.libraryUpdateProgress?.label { + Text(label) + .foregroundStyle(.secondary) + .font(.callout) + } + } + .help("Mythic is updating your library.") + .padding(10) } } diff --git a/Mythic/Views/Unified/Components/GameCard/GameCard+Extensions.swift b/Mythic/Views/Unified/Components/GameCard/GameCard+Extensions.swift index 36a30c3a..a98188e8 100644 --- a/Mythic/Views/Unified/Components/GameCard/GameCard+Extensions.swift +++ b/Mythic/Views/Unified/Components/GameCard/GameCard+Extensions.swift @@ -114,7 +114,8 @@ extension GameCard { .padding(2) } } - .disabled(networkMonitor.epicAccessibilityState != .accessible) + // Epic-specific reachability must not disable buttons for other storefronts. + .disabled(game.storefront == .epicGames && networkMonitor.epicAccessibilityState != .accessible) .disabled(game.storefront == .local) .disabled(operationManager.queue.contains(where: { $0.game == game && $0.type == .install })) .help("Install \(game.description)") @@ -129,6 +130,14 @@ extension GameCard { ) .padding() .frame(width: 700, height: 380) + case let steamGame as SteamGame: + SteamGameInstallationView( + game: .init(get: { steamGame }, + set: { game = $0 }), + isPresented: $isInstallSheetPresented + ) + .padding() + .frame(width: 700, height: 380) default: EmptyView() } } @@ -164,7 +173,8 @@ extension GameCard { .padding(2) } } - .disabled(networkMonitor.epicAccessibilityState != .accessible) + // Epic-specific reachability must not disable buttons for other storefronts. + .disabled(game.storefront == .epicGames && networkMonitor.epicAccessibilityState != .accessible) .disabled(game.storefront == .local) .disabled(operationManager.queue.contains(where: { $0.game == game && $0.type == .repair })) .alert("Unable to verify installation.", @@ -207,7 +217,8 @@ extension GameCard { .padding(2) } } - .disabled(networkMonitor.epicAccessibilityState != .accessible) + // Epic-specific reachability must not disable buttons for other storefronts. + .disabled(game.storefront == .epicGames && networkMonitor.epicAccessibilityState != .accessible) // FIXME: .disabled(game.checkIfGameIsRunning()) .disabled(game.isUpdateAvailable != true) .disabled(operationManager.queue.contains(where: { $0.game == game && $0.type == .update })) @@ -357,6 +368,11 @@ extension GameCard { isPresented: $isUninstallSheetPresented) .padding() .frame(width: 700, height: 380) + case let steamGame as SteamGame: + SteamGameUninstallationView(game: .init(get: { steamGame }, set: { game = $0 }), + isPresented: $isUninstallSheetPresented) + .padding() + .frame(width: 700, height: 380) default: EmptyView() } } diff --git a/Mythic/Views/Unified/Components/GameListView.swift b/Mythic/Views/Unified/Components/GameListView.swift index 33b8813c..220bc7b0 100644 --- a/Mythic/Views/Unified/Components/GameListView.swift +++ b/Mythic/Views/Unified/Components/GameListView.swift @@ -18,31 +18,95 @@ struct GameListView: View { @AppStorage("gameCardSize") private var gameCardSize: Double = 200.0 @State private var isGameImportViewPresented: Bool = false + @State private var isSteamSignInViewPresented: Bool = false + private var importGameButton: some View { + Button { + isGameImportViewPresented = true + } label: { + Label("Import Game", systemImage: "plus.app") + .padding(5) + } + } + var body: some View { VStack { if gameDataStore.library.isEmpty { - ContentUnavailableView( - "No games found. 😢", - systemImage: "folder.badge.questionmark", - description: Text(""" - Games in your library will appear here. - If there are games in your library and they're not appearing, try restarting Mythic. - """) - ) - .task { - try? await gameDataStore.refreshFromStorefronts() - } - - Button { - isGameImportViewPresented = true - } label: { - Label("Import Game", systemImage: "plus.app") - .padding(5) - } - .buttonStyle(.borderedProminent) - .sheet(isPresented: $isGameImportViewPresented) { - GameImportView(isPresented: $isGameImportViewPresented) + if let progress = viewModel.libraryUpdateProgress { + // An empty library during a first Steam enumeration is expected and takes a while, so + // it gets a real progress report rather than the same "no games found" as a genuinely + // empty one. + VStack(spacing: 14) { + Image(systemName: "arrow.trianglehead.2.clockwise.rotate.90") + .font(.system(size: 34)) + .foregroundStyle(.tertiary) + + Text("Loading your library…") + .font(.title3.bold()) + + if let fraction = progress.fractionCompleted { + ProgressView(value: fraction) + .frame(maxWidth: 320) + } else { + ProgressView() + .controlSize(.small) + } + + Text(progress.label) + .foregroundStyle(.secondary) + .font(.callout) + } + .padding() + } else { + ContentUnavailableView( + "No games found. 😢", + systemImage: "folder.badge.questionmark", + description: Text(""" + Games in your library will appear here. + If there are games in your library and they're not appearing, try restarting Mythic. + """) + ) + .task { + try? await gameDataStore.refreshFromStorefronts() + } + + // The empty state used to offer only "Import Game", which is the fallback rather than + // the answer: for a storefront, the library arrives by signing in. Whichever + // storefronts are not connected are offered here, so the next step is on screen + // instead of somewhere in Accounts. + HStack { + if !Steam.isSignedIn { + Button { + isSteamSignInViewPresented = true + } label: { + Label { + Text("Connect Steam") + } icon: { + Image("Steam") + .resizable() + .scaledToFit() + .frame(width: 16, height: 16) + } + .padding(5) + } + .buttonStyle(.borderedProminent) + } + + // Importing is the fallback when a storefront is connected, and the primary + // action when none is. + if Steam.isSignedIn { + importGameButton.buttonStyle(.borderedProminent) + } else { + importGameButton.buttonStyle(.bordered) + } + } + .sheet(isPresented: $isGameImportViewPresented) { + GameImportView(isPresented: $isGameImportViewPresented) + } + .sheet(isPresented: $isSteamSignInViewPresented) { + SteamSignInView(isPresented: $isSteamSignInViewPresented) + .frame(width: 460) + } } } else { ScrollView(.vertical) { diff --git a/Mythic/Views/Unified/Models/GameListViewModel.swift b/Mythic/Views/Unified/Models/GameListViewModel.swift index 21c6d921..4a789adc 100644 --- a/Mythic/Views/Unified/Models/GameListViewModel.swift +++ b/Mythic/Views/Unified/Models/GameListViewModel.swift @@ -81,6 +81,21 @@ import OSLog private let logger: Logger = .custom(category: "GameListViewModel") var isUpdatingLibrary: Bool = false + + /** + What a library update is currently doing, for the UI to show. + + `isUpdatingLibrary` alone only ever produced a small toolbar spinner, which is indistinguishable + from nothing happening -- and enumerating a Steam library takes a minute the first time, during + which the library is legitimately empty. + */ + var libraryUpdateProgress: LibraryUpdateProgress? + + struct LibraryUpdateProgress: Equatable, Sendable { + var label: String + /// `nil` when there is no meaningful total to report against. + var fractionCompleted: Double? + } } extension GameListViewModel { @@ -127,3 +142,22 @@ extension GameListViewModel { case title } } + +extension GameListViewModel.LibraryUpdateProgress { + /// Renders a Steam enumeration stage as something worth showing a user. + init(for stage: Steam.LibraryRefreshStage) { + switch stage { + case .readingLicences: + self.init(label: String(localized: "Reading your Steam licences…"), + fractionCompleted: nil) + + case .resolvingTitles(let completed, let total, let titleCount): + self.init(label: String(localized: "Looking up \(titleCount) Steam titles…"), + fractionCompleted: total > 0 ? Double(completed) / Double(total) : nil) + + case .finished(let gameCount): + self.init(label: String(localized: "Found \(gameCount) Steam games."), + fractionCompleted: 1) + } + } +} diff --git a/Mythic/Views/Unified/Sheets/GameImportView/GameImportView.swift b/Mythic/Views/Unified/Sheets/GameImportView/GameImportView.swift index 3298c211..9f2e0f79 100644 --- a/Mythic/Views/Unified/Sheets/GameImportView/GameImportView.swift +++ b/Mythic/Views/Unified/Sheets/GameImportView/GameImportView.swift @@ -17,14 +17,13 @@ struct GameImportView: View { VStack { if #available(macOS 15.0, *) { TabView { - Tab("Epic", systemImage: "storefront") { + Tab("Epic", image: "EGFaceless") { EpicGamesGameImportView(isPresented: $isPresented) } - Tab("Steam", systemImage: "storefront") { - + Tab("Steam", image: "Steam") { + SteamGameImportView(isPresented: $isPresented) } - .hidden() Tab("Local", systemImage: "storefront") { LocalGameImportView(isPresented: $isPresented) @@ -36,9 +35,14 @@ struct GameImportView: View { TabView { EpicGamesGameImportView(isPresented: $isPresented) .tabItem { - Label("Epic", systemImage: "storefront") + Label { Text("Epic") } icon: { Image("EGFaceless") } } + SteamGameImportView(isPresented: $isPresented) + .tabItem { + Label { Text("Steam") } icon: { Image("Steam") } + } + LocalGameImportView(isPresented: $isPresented) .tabItem { Label("Local", systemImage: "storefront") diff --git a/Mythic/Views/Unified/Sheets/GameImportView/SteamGameImportView.swift b/Mythic/Views/Unified/Sheets/GameImportView/SteamGameImportView.swift new file mode 100644 index 00000000..975f1b26 --- /dev/null +++ b/Mythic/Views/Unified/Sheets/GameImportView/SteamGameImportView.swift @@ -0,0 +1,227 @@ +// +// SteamGameImportView.swift +// Mythic +// +// Created by Brunelli Cupello on 21/8/2026. +// + +// Copyright © 2023-2026 vapidinfinity + +import SwiftUI +import OSLog + +/** + Adds a Steam title to the library by app ID. + + Unlike Epic, Steam cannot be asked what an account owns — `licenses_print` truncates its app list at 32 + entries per package and returns nothing at all anonymously, `appcache/appinfo.vdf` is binary, and the Web + API needs a key plus a public profile. So titles are named explicitly here, and Mythic looks the app ID + up to fill in everything else. + + Titles already downloaded through Mythic need no importing — the library picks them up from their app + manifests on refresh. + */ +struct SteamGameImportView: View { + @Bindable var gameDataStore: GameDataStore = .shared + + @Binding var isPresented: Bool + + @State private var appID: String = .init() + @State private var resolvedInfo: ValveDataFormat.AppInfo? + @State private var resolvedPlatforms: Set = .init() + + @State private var isSignInSheetPresented: Bool = false + @State private var isResolving: Bool = false + @State private var resolutionError: Error? + + private var trimmedAppID: String { + appID.trimmingCharacters(in: .whitespacesAndNewlines) + } + + private var isAppIDWellFormed: Bool { + !trimmedAppID.isEmpty && trimmedAppID.allSatisfy(\.isNumber) + } + + var body: some View { + VStack { + HStack { + VStack { + if let resolvedInfo { + GameImageCard(game: SteamGame(appID: resolvedInfo.appID, + title: resolvedInfo.name, + installationState: .uninstalled), + url: URL(string: "https://cdn.cloudflare.steamstatic.com/steam/apps/\(resolvedInfo.appID)/library_600x900_2x.jpg"), + isImageEmpty: .constant(false)) + .aspectRatio(3/4, contentMode: .fit) + } else { + // Before an app ID resolves there is nothing to fetch, and asking the CDN for + // artwork anyway produced a "couldn't load the image" panel in the empty state. + RoundedRectangle(cornerRadius: 10) + .fill(.quinary) + .aspectRatio(3/4, contentMode: .fit) + .overlay { + VStack(spacing: 8) { + Image(systemName: "questionmark.square.dashed") + .font(.system(size: 34)) + .foregroundStyle(.tertiary) + Text("Enter an app ID to look a title up.") + .font(.footnote) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + } + .padding() + } + } + + Label("Artwork comes from Steam's CDN, keyed on the app ID.", systemImage: "info") + .symbolVariant(.circle) + .foregroundStyle(.secondary) + .font(.footnote) + } + .padding([.leading, .top]) + + VStack { + Form { + Section { + HStack { + TextField("App ID", text: $appID, prompt: Text("e.g. 1623730")) + .onSubmit(resolve) + + Button("Look Up", action: resolve) + .disabled(!isAppIDWellFormed) + .disabled(isResolving) + } + + if isResolving { + HStack { + ProgressView().controlSize(.small) + Text("Asking Steam about \(trimmedAppID)…") + .foregroundStyle(.secondary) + } + } + } footer: { + VStack(alignment: .leading, spacing: 4) { + Text("Your library loads by itself once you're signed in. Use this for anything it missed.") + Text("Find it in the store page URL: store.steampowered.com/app/**1623730**/") + } + .font(.footnote) + .foregroundStyle(.secondary) + } + + if let resolvedInfo { + Section { + LabeledContent("Title", value: resolvedInfo.name) + + if let downloadSize = resolvedInfo.downloadSize { + LabeledContent("Download", + value: downloadSize.formatted(.byteCount(style: .file))) + } + + LabeledContent("Available for", + value: resolvedPlatforms.isEmpty + ? String(localized: "Unknown") + : resolvedPlatforms + .map(\.description) + .sorted() + .joined(separator: ", ")) + } + } + + if !SteamCMD.isInstalled { + // Points at Accounts, not Settings: that is where SteamCMD is now set up, and + // it is the same screen where signing in happens. + Label("SteamCMD isn't installed yet — set it up in Accounts.", + systemImage: "exclamationmark.triangle") + .symbolVariant(.fill) + } else if !Steam.isSignedIn { + // Actionable rather than informational: this is the step that makes the whole + // library appear, so the button belongs here rather than a pointer to Accounts. + Section { + Label("You aren't signed in to Steam. You can still add titles, but downloading needs an account.", + systemImage: "person.crop.circle.badge.exclamationmark") + + Button { + isSignInSheetPresented = true + } label: { + Label { + Text("Connect Steam") + } icon: { + Image("Steam") + .resizable() + .scaledToFit() + .frame(width: 16, height: 16) + } + } + } + } + } + .formStyle(.grouped) + } + } + + HStack { + Button("Cancel", role: .cancel) { + isPresented = false + } + + Spacer() + + Button("Add to Library", action: addToLibrary) + .disabled(resolvedInfo == nil) + .buttonStyle(.borderedProminent) + } + .padding([.horizontal, .bottom]) + } + .sheet(isPresented: $isSignInSheetPresented) { + SteamSignInView(isPresented: $isSignInSheetPresented) + .frame(width: 460) + } + .alert("Unable to look that app ID up.", + isPresented: .init(get: { resolutionError != nil }, + set: { if !$0 { resolutionError = nil } }), + presenting: resolutionError) { _ in + Button("OK") { resolutionError = nil } + } message: { error in + Text(error.localizedDescription) + } + } + + private func resolve() { + guard isAppIDWellFormed else { return } + let appID = trimmedAppID + + Task { + isResolving = true + defer { isResolving = false } + + do { + let info = try await Steam.refreshAppInfo(appID: appID) + resolvedInfo = info + resolvedPlatforms = Steam.supportedPlatforms(for: info) + } catch { + Logger.app.error("Steam app lookup failed for \(appID, privacy: .public): \(error.localizedDescription)") + resolvedInfo = nil + resolvedPlatforms = .init() + resolutionError = error + } + } + } + + private func addToLibrary() { + guard let resolvedInfo else { return } + + // Inserted uninstalled, which is the state Epic titles arrive in from getInstallableGames(); + // the card's Install button takes it from here. + gameDataStore.library.update( + with: SteamGame(appID: resolvedInfo.appID, + title: resolvedInfo.name, + installationState: .uninstalled) + ) + + isPresented = false + } +} + +#Preview { + SteamGameImportView(isPresented: .constant(true)) +} diff --git a/Mythic/Views/Unified/Sheets/GameInstallationView/Steam/SteamGameInstallationView.swift b/Mythic/Views/Unified/Sheets/GameInstallationView/Steam/SteamGameInstallationView.swift new file mode 100644 index 00000000..cb0bfa8a --- /dev/null +++ b/Mythic/Views/Unified/Sheets/GameInstallationView/Steam/SteamGameInstallationView.swift @@ -0,0 +1,107 @@ +// +// SteamGameInstallationView.swift +// Mythic +// +// Created by Brunelli Cupello on 21/8/2026. +// + +// Copyright © 2023-2026 vapidinfinity + +import SwiftUI +import OSLog + +struct SteamGameInstallationView: View { + @Binding var game: SteamGame + @Binding var isPresented: Bool + + @State private var isImageEmpty: Bool = true + @State var isOperating: Bool = false + + @State private var appInfo: ValveDataFormat.AppInfo? + @State private var isFetchingAppInfo: Bool = false + + private var installURL: URL { Steam.installURL(forAppID: game.appID) } + + /// The platform SteamCMD will actually be told to fetch, from Settings ▸ Services ▸ Steam. + private var forcedPlatform: Game.Platform { + Steam.forcedPlatform == .macos ? .macOS : .windows + } + + private var isPlatformAvailable: Bool { + guard let appInfo else { return true } // unknown until looked up; do not block on it + return Steam.supportedPlatforms(for: appInfo).contains(forcedPlatform) + } + + var body: some View { + BaseGameInstallationView( + game: .init(get: { game as Game }, + set: { if let castGame = $0 as? SteamGame { game = castGame } }), + isPresented: $isPresented, + isImageEmpty: $isImageEmpty, + type: "Install", + operating: $isOperating, + action: { + _ = try await SteamGameManager.install(steamGame: game, atQualityOfService: .default) + }, + content: { + Form { + LabeledContent("App ID", value: game.appID) + + if isFetchingAppInfo { + HStack { + ProgressView().controlSize(.small) + Text("Checking download size…").foregroundStyle(.secondary) + } + } else if let downloadSize = appInfo?.downloadSize { + LabeledContent("Download", + value: downloadSize.formatted(.byteCount(style: .file))) + } + + LabeledContent("Platform", value: forcedPlatform.description) + + LabeledContent("Location") { + Text(installURL.prettyPath) + .foregroundStyle(.secondary) + .lineLimit(2) + .truncationMode(.middle) + } + + if !isPlatformAvailable { + Label("Steam doesn't offer a \(forcedPlatform.description) build of this title. Change the platform in Settings ▸ Services ▸ Steam.", + systemImage: "exclamationmark.triangle") + .symbolVariant(.fill) + } + + if forcedPlatform == .windows { + // Stated plainly rather than detected: guessing wrong is worse than saying + // nothing, and there is no reliable signal for it in the depot metadata. + Label("Titles that require the Steam client running won't launch — Mythic Engine can't run it.", + systemImage: "info") + .symbolVariant(.circle) + .foregroundStyle(.secondary) + } + } + .formStyle(.grouped) + } + ) + .navigationTitle("Install \(game.description)") + .task { + guard appInfo == nil else { return } + isFetchingAppInfo = true + defer { isFetchingAppInfo = false } + + appInfo = Steam.cachedAppInfo(appID: game.appID) + if appInfo == nil { + appInfo = try? await Steam.refreshAppInfo(appID: game.appID) + } + } + } +} + +#Preview { + SteamGameInstallationView( + game: .constant(.init(appID: "1623730", title: "Palworld", installationState: .uninstalled)), + isPresented: .constant(true) + ) + .padding() +} diff --git a/Mythic/Views/Unified/Sheets/GameInstallationView/Steam/SteamGameUninstallationView.swift b/Mythic/Views/Unified/Sheets/GameInstallationView/Steam/SteamGameUninstallationView.swift new file mode 100644 index 00000000..0c85daa4 --- /dev/null +++ b/Mythic/Views/Unified/Sheets/GameInstallationView/Steam/SteamGameUninstallationView.swift @@ -0,0 +1,55 @@ +// +// SteamGameUninstallationView.swift +// Mythic +// +// Created by Brunelli Cupello on 21/8/2026. +// + +// Copyright © 2023-2026 vapidinfinity + +import SwiftUI +import OSLog + +struct SteamGameUninstallationView: View { + @Binding var game: SteamGame + @Binding var isPresented: Bool + + @State private var isImageEmpty: Bool = true + @State var isOperating: Bool = false + + @State private var removeFromDisk: Bool = true + + var body: some View { + BaseGameInstallationView( + game: .init(get: { game as Game }, + set: { if let castGame = $0 as? SteamGame { game = castGame } }), + isPresented: $isPresented, + isImageEmpty: $isImageEmpty, + type: "Uninstall", + operating: $isOperating, + action: { + _ = try await SteamGameManager.uninstall(steamGame: game, persistingFiles: !removeFromDisk) + }, + content: { + Form { + Toggle("Remove files from disk", systemImage: "trash", isOn: $removeFromDisk) + + if let sizeOnDisk = game.appManifest?.sizeOnDisk { + LabeledContent("Reclaims", + value: sizeOnDisk.formatted(.byteCount(style: .file))) + } + } + .formStyle(.grouped) + } + ) + .navigationTitle("Uninstall \(game.description)") + } +} + +#Preview { + SteamGameUninstallationView( + game: .constant(.init(appID: "1623730", title: "Palworld", installationState: .uninstalled)), + isPresented: .constant(true) + ) + .padding() +} diff --git a/Mythic/Views/Unified/Sheets/GameOperationStatusView.swift b/Mythic/Views/Unified/Sheets/GameOperationStatusView.swift index 7e535e44..2885f9cf 100644 --- a/Mythic/Views/Unified/Sheets/GameOperationStatusView.swift +++ b/Mythic/Views/Unified/Sheets/GameOperationStatusView.swift @@ -41,13 +41,17 @@ struct GameOperationStatusView: View { Text(operation.progressKVOBridge.fractionCompleted.formatted(.percent)) } - if operation.type.modifiesFiles { + // Only shown when the backend actually counts items. Legendary reports objects here; + // SteamCMD reports nothing of the kind, and the row read "(0/0)" for its downloads. + if operation.type.modifiesFiles, + let fileTotalCount = operation.progressKVOBridge.fileTotalCount, + fileTotalCount > 0 { HStack { Label("Files", systemImage: "folder") - + Spacer() - - Text("(\(operation.progressKVOBridge.fileCompletedCount ?? 0)/\(operation.progressKVOBridge.fileTotalCount ?? 0))") + + Text("(\(operation.progressKVOBridge.fileCompletedCount ?? 0)/\(fileTotalCount))") } } diff --git a/Mythic/Views/Unified/Sheets/SteamSignInView.swift b/Mythic/Views/Unified/Sheets/SteamSignInView.swift new file mode 100644 index 00000000..8e45ea60 --- /dev/null +++ b/Mythic/Views/Unified/Sheets/SteamSignInView.swift @@ -0,0 +1,289 @@ +// +// SteamSignInView.swift +// Mythic +// +// Created by Brunelli Cupello on 21/8/2026. +// + +// Copyright © 2023-2026 vapidinfinity + +import SwiftUI +import OSLog + +/** + Signs in to Steam through SteamCMD. + + There is no web flow here on purpose. Steam's OpenID login yields a SteamID and *no* SteamCMD + credential, so it cannot download anything — the credential SteamCMD caches itself is the thing that + makes the rest of the storefront work. + */ +struct SteamSignInView: View { + @Binding var isPresented: Bool + + @State private var username: String = .init() + @State private var password: String = .init() + @State private var steamGuardCode: String = .init() + + /// Revealed only once Steam has actually asked for a code, so the form starts minimal. + @State private var isSteamGuardCodeRequired: Bool = false + + // Signing in *is* running SteamCMD, so there is no meaningful "account but no backend" state. When + // the backend is missing this sheet installs it first rather than sending the user to Settings to + // find it themselves. + @State private var isSteamCMDInstalled: Bool = true + @State private var isRosettaInstalled: Bool = true + @State private var isInstallingSteamCMD: Bool = false + @State private var isSteamCMDInstallSuccessful: Bool? + @State private var steamCMDInstallError: Error? + + @State private var isSigningIn: Bool = false + @State private var isAwaitingMobileConfirmation: Bool = false + @State private var signInError: Error? + + private var canSubmit: Bool { + !username.trimmingCharacters(in: .whitespaces).isEmpty + && !password.isEmpty + && (!isSteamGuardCodeRequired || !steamGuardCode.trimmingCharacters(in: .whitespaces).isEmpty) + } + + var body: some View { + VStack { + HStack { + Image("Steam") + .resizable() + .scaledToFit() + .frame(width: 48, height: 48) + + VStack(alignment: .leading) { + Text("Sign in to Steam") + .font(.title2) + .bold() + Text(!isSteamCMDInstalled + ? String(localized: "One quick setup step first.") + : isAwaitingMobileConfirmation + ? String(localized: "Waiting for you to approve this in the Steam Mobile App.") + : String(localized: "Mythic signs in through SteamCMD, Valve's own command-line client.")) + .foregroundStyle(.secondary) + .font(.footnote) + } + + Spacer() + } + .padding([.horizontal, .top]) + + Form { + if isSteamCMDInstalled { + Section { + TextField("Account name", text: $username) + .textContentType(.username) + .disabled(isSigningIn) + + SecureField("Password", text: $password) + .textContentType(.password) + .disabled(isSigningIn) + .onSubmit(signIn) + } footer: { + Text("Your password goes straight to SteamCMD and is never written to disk by Mythic.") + .font(.footnote) + .foregroundStyle(.secondary) + } + + // Said up front, not just once it goes wrong: accounts on the mobile authenticator + // get a push rather than a typed code, and Steam gives up waiting fairly quickly. + // A user staring at a spinner has no way to guess their phone is the missing step. + if isAwaitingMobileConfirmation { + // Steam really is waiting on the phone right now, and it gives up after about a + // minute. This is the one moment the user has to act, so it gets the loudest + // treatment in the sheet rather than a footnote. + Section { + HStack(alignment: .top) { + Image(systemName: "iphone.gen3.radiowaves.left.and.right") + .font(.system(size: 28)) + .foregroundStyle(.tint) + .padding(.trailing, 4) + + VStack(alignment: .leading, spacing: 4) { + Text("Approve this sign-in on your phone") + .font(.headline) + + Text("Steam sent a confirmation to the Steam Mobile App. Open it and tap Approve.") + .fixedSize(horizontal: false, vertical: true) + + HStack(spacing: 6) { + ProgressView().controlSize(.small) + Text("Steam stops waiting after about a minute.") + .foregroundStyle(.secondary) + .font(.footnote) + } + .padding(.top, 2) + } + } + .padding(.vertical, 4) + } + } else if isSigningIn { + Section { + HStack { + ProgressView().controlSize(.small) + Text("Signing in…") + } + } + } else { + Section { + Label("If your account uses the Steam Mobile Authenticator, keep your phone handy — Steam will ask you to approve this there.", + systemImage: "iphone.and.arrow.right.inward") + .font(.footnote) + .foregroundStyle(.secondary) + } + } + + if isSteamGuardCodeRequired { + Section { + TextField("Steam Guard code", text: $steamGuardCode) + .disabled(isSigningIn) + .onSubmit(signIn) + } footer: { + Text("From your authenticator app, or the email Steam just sent you.") + .font(.footnote) + .foregroundStyle(.secondary) + } + } + } else { + Section { + Label { + VStack(alignment: .leading, spacing: 2) { + Text("SteamCMD isn't installed yet") + .bold() + Text("It's a small download (about 3 MB) from Valve. Mythic needs it to sign in and to download your games.") + .foregroundStyle(.secondary) + .font(.footnote) + .fixedSize(horizontal: false, vertical: true) + } + } icon: { + Image(systemName: "arrow.down.circle") + } + + OperationButton( + "Install SteamCMD", + systemImage: "arrow.down.circle", + operating: $isInstallingSteamCMD, + successful: $isSteamCMDInstallSuccessful + ) { + await installSteamCMD() + } + // SteamCMD ships as an x86_64 Mach-O binary only. + .disabled(!isRosettaInstalled) + } header: { + Text("Step 1 of 2") + } footer: { + if !isRosettaInstalled { + Label("SteamCMD is an Intel binary and needs Rosetta 2, which isn't installed.", + systemImage: "exclamationmark.triangle") + .symbolVariant(.fill) + .font(.footnote) + } else { + Text("Signing in comes next.") + .font(.footnote) + .foregroundStyle(.secondary) + } + } + } + } + .formStyle(.grouped) + + HStack { + Button("Cancel", role: .cancel) { isPresented = false } + .disabled(isSigningIn) + .disabled(isInstallingSteamCMD) + + Spacer() + + if isSteamCMDInstalled { + OperationButton("Sign In", + operating: $isSigningIn, + successful: .constant(nil)) { + await performSignIn() + } + .disabled(!canSubmit) + .buttonStyle(.borderedProminent) + } + } + .padding([.horizontal, .bottom]) + } + .task { + // Both touch the filesystem (Rosetta.exists spawns pgrep), so they are resolved once here + // rather than re-evaluated on every render of `body`. + isSteamCMDInstalled = SteamCMD.isInstalled + isRosettaInstalled = Rosetta.exists + } + .alert("Unable to install SteamCMD.", + isPresented: .init(get: { steamCMDInstallError != nil }, + set: { if !$0 { steamCMDInstallError = nil } }), + presenting: steamCMDInstallError) { _ in + Button("OK") { steamCMDInstallError = nil } + } message: { error in + Text(error.localizedDescription) + } + .alert("Unable to sign in to Steam.", + isPresented: .init(get: { signInError != nil }, + set: { if !$0 { signInError = nil } }), + presenting: signInError) { _ in + Button("OK") { signInError = nil } + } message: { error in + Text(error.localizedDescription) + } + } + + private func installSteamCMD() async { + do { + try await SteamCMD.install() + isSteamCMDInstallSuccessful = true + } catch { + Logger.app.error("SteamCMD installation failed: \(error.localizedDescription)") + isSteamCMDInstallSuccessful = false + steamCMDInstallError = error + } + + // Re-read rather than trusting the outcome flag: `isInstalled` is the filesystem's opinion. + isSteamCMDInstalled = SteamCMD.isInstalled + } + + private func signIn() { + guard canSubmit, !isSigningIn else { return } + Task { await performSignIn() } + } + + private func performSignIn() async { + isAwaitingMobileConfirmation = false + defer { isAwaitingMobileConfirmation = false } + + do { + try await Steam.signIn( + username: username.trimmingCharacters(in: .whitespaces), + password: password, + steamGuardCode: isSteamGuardCodeRequired + ? steamGuardCode.trimmingCharacters(in: .whitespaces) + : nil, + onStatus: { status in + // Reported from SteamCMD's output stream, off the main actor. + Task { @MainActor in + isAwaitingMobileConfirmation = (status == .awaitingMobileConfirmation) + } + } + ) + + password = .init() + isPresented = false + } catch SteamCMD.Failure.twoFactorRequired { + // Not surfaced as an error: it is a step, not a failure. Reveal the field and let them retry. + isSteamGuardCodeRequired = true + } catch { + Logger.app.error("Steam sign-in failed: \(error.localizedDescription)") + signInError = error + } + } +} + +#Preview { + SteamSignInView(isPresented: .constant(true)) + .frame(width: 420) +} diff --git a/Mythic/Views/Unified/Windows/SettingsView.swift b/Mythic/Views/Unified/Windows/SettingsView.swift index 64700128..8c7bd8b1 100644 --- a/Mythic/Views/Unified/Windows/SettingsView.swift +++ b/Mythic/Views/Unified/Windows/SettingsView.swift @@ -9,6 +9,7 @@ import Foundation import SwiftUI +import OSLog import SwordRPC import SemanticVersion @@ -330,6 +331,22 @@ extension SettingsView { @State private var isEpicCloudSynchronising: Bool = false @State private var isEpicCloudSyncSuccessful: Bool? + @State private var isServicesSteamSectionExpanded: Bool = true + @AppStorage("steamForcedPlatform") private var steamForcedPlatform: String = SteamCMD.ForcedPlatform.windows.rawValue + + @State private var isSteamCMDInstalled: Bool = false + @State private var isRosettaInstalled: Bool = true + + @State private var isSteamCMDInstalling: Bool = false + @State private var isSteamCMDInstallSuccessful: Bool? + + @State private var isSteamCMDRemoving: Bool = false + @State private var isSteamCMDRemovalSuccessful: Bool? + @State private var isSteamCMDRemovalAlertPresented: Bool = false + + @State private var steamInstallBaseURL: URL = Steam.installBaseURL + @State private var isSteamInstallDirectoryImporterPresented: Bool = false + var body: some View { Section("Discord", isExpanded: $isServicesDiscordSectionExpanded) { Toggle("Display Mythic activity status on Discord", isOn: $discordRPCEnabled) @@ -380,8 +397,89 @@ extension SettingsView { // TODO: potenially add manual cloud save deletion } - Section("Steam", isExpanded: .constant(false)) { } - .help("Coming Soon") + Section("Steam", isExpanded: $isServicesSteamSectionExpanded) { + if isSteamCMDInstalled { + LabeledContent("Install games in") { + HStack { + Text(steamInstallBaseURL.prettyPath) + .foregroundStyle(.secondary) + .lineLimit(1) + .truncationMode(.middle) + + Button("Choose…") { isSteamInstallDirectoryImporterPresented = true } + } + } + .fileImporter( + isPresented: $isSteamInstallDirectoryImporterPresented, + allowedContentTypes: [.folder] + ) { result in + guard case .success(let directory) = result else { return } + UserDefaults.standard.set(directory, forKey: "steamInstallBaseURL") + steamInstallBaseURL = directory + } + .help("Each title gets its own folder here, named by its Steam app ID.") + + Picker("Download games built for", selection: $steamForcedPlatform) { + Text("Windows®").tag(SteamCMD.ForcedPlatform.windows.rawValue) + Text("macOS").tag(SteamCMD.ForcedPlatform.macos.rawValue) + } + .help("Windows® titles run through Mythic Engine; macOS titles run natively, but far fewer exist.") + + OperationButton( + "Remove SteamCMD", + systemImage: "trash", + operating: $isSteamCMDRemoving, + successful: $isSteamCMDRemovalSuccessful + ) { + isSteamCMDRemovalAlertPresented = true + } + .alert( + "Are you sure you want to remove SteamCMD?", + isPresented: $isSteamCMDRemovalAlertPresented, + actions: { + Button("Remove", role: .destructive) { + do { + try SteamCMD.uninstall() + isSteamCMDRemovalSuccessful = true + } catch { + isSteamCMDRemovalSuccessful = false + } + isSteamCMDInstalled = SteamCMD.isInstalled + } + + Button("Cancel", role: .cancel) { } + }, + message: { + Text("Your Steam sign-in and any games you downloaded through it will be removed too.") + } + ) + } else { + OperationButton( + "Install SteamCMD", + systemImage: "arrow.down.circle", + operating: $isSteamCMDInstalling, + successful: $isSteamCMDInstallSuccessful + ) { + do { + try await SteamCMD.install() + isSteamCMDInstallSuccessful = true + } catch { + Logger.app.error("SteamCMD installation failed: \(error.localizedDescription)") + isSteamCMDInstallSuccessful = false + } + isSteamCMDInstalled = SteamCMD.isInstalled + } + // SteamCMD ships as an x86_64 Mach-O binary only. + .disabled(!isRosettaInstalled) + .help(isRosettaInstalled ? .init() : "SteamCMD is an Intel binary and needs Rosetta 2.") + } + } + .task { + // Both of these touch the filesystem (and Rosetta spawns `pgrep`), so they are resolved + // once here rather than re-evaluated on every render of `body`. + isSteamCMDInstalled = SteamCMD.isInstalled + isRosettaInstalled = Rosetta.exists + } } }