diff --git a/CHANGELOG.md b/CHANGELOG.md index 5762aa1..ccf85c8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,11 @@ "restartOnRelaunch": true }] ``` + +### Enhancements +- Enable restart feature for Epic users. It's still not as seamless as non-Epic accounts. Requires the usage of [Legendary] + or [Heroic]. Once you've logged in with either, you can go back to using the normal Epic launcher if you wish. It will + require re-logging in every few days though, so it may be preferable to just stick with the alternate launchers. ## [0.10.1] - 2024-05-03 @@ -339,4 +344,5 @@ Initial release [CVE-2023-36796]: https://github.com/dotnet/announcements/issues/274 [CVE-2023-36799]: https://github.com/dotnet/announcements/issues/275 [legendary]: https://github.com/derrod/legendary +[heroic]: https://github.com/Heroic-Games-Launcher/HeroicGamesLauncher [settings file]: README.md#settings \ No newline at end of file diff --git a/README.md b/README.md index c09ce99..86d0b54 100644 --- a/README.md +++ b/README.md @@ -55,8 +55,8 @@ accounts on both Windows and Linux. * **Auto-restart** Automatically restart your game. This can be useful if you're [grinding] for manufactured - engineering materials. Off by default. This feature isn't supported on the Epic platform. - See the [Min-Launcher-Specific] section for more details. + engineering materials. Off by default. For the Epic platform, this feature requires either + [Legendary] or [Heroic]. See the [Epic accounts and the /restart feature] section for more details. * **Multi-Account** @@ -170,9 +170,17 @@ The following arguments are in addition to the above: | /restart delay | Restart the game after it has closed with _delay_ being the number of seconds given to cancel the restart (i.e `/restart 3`) | | /dryrun | Prints output without launching any processes | -Note that the restart feature doesn't work with Epic accounts. After Elite launches, it invalidates -the launcher's auth token and doesn't communicate the new token which then prevents the ability to -login with FDev servers a second time. +##### Epic accounts and the /restart feature +The restart feature requires either [Legendary] or [Heroic] to work with Epic accounts. + +After Elite launches, it invalidates the launcher's initial Epic auth token and doesn't communicate +the new token which then prevents the ability to login with FDev servers a second time. To work around +this, min-ed-launcher can use a more privileged Epic token (created by Legendary/Heroic) to generate +the needed auth tokens. Simply logging in with Legendary or Heroic will allow min-ed-launcher to restart +without re-launching. + +Once you've logged in with either, you can go back to using the normal Epic launcher if you wish. It +will require re-logging in every few days though, so it may be preferable to just stick with the alternate launchers. ### Settings The settings file controls additional settings for the launcher that go beyond what the default @@ -369,6 +377,7 @@ Note that the bootstrap project specifically targets Windows and won't publish o [Arguments]: #arguments [Shared]: #shared [Min-Launcher-Specific]: #min-launcher-specific +[Epic accounts and the /restart feature]: #epic-accounts-and-the-restart-feature [Multi-Account]: #multi-account [Frontier account via Steam or Epic]: #frontier-account-via-steam-or-epic [Epic account via Steam]: #epic-account-via-steam @@ -377,6 +386,7 @@ Note that the bootstrap project specifically targets Windows and won't publish o [Build]: #build [Release Artifacts]: #release-artifacts [legendary]: https://github.com/derrod/legendary +[heroic]: https://github.com/Heroic-Games-Launcher/HeroicGamesLauncher [quirks]: https://github.com/rfvgyhn/min-ed-launcher/issues/45#issuecomment-1030312606 [publish.sh]: publish.sh [publish.ps1]: publish.ps1 diff --git a/src/MinEdLauncher/App.fs b/src/MinEdLauncher/App.fs index de10a47..ac634a7 100644 --- a/src/MinEdLauncher/App.fs +++ b/src/MinEdLauncher/App.fs @@ -62,10 +62,11 @@ let login launcherVersion runningTime httpClient machineId (platform: Platform) let! result = steam.Login() |> Result.map (fun steamUser -> Permanent steamUser.SessionToken) |> (authenticate None) return result } | Epic details -> task { - match! Epic.login launcherVersion details with + match! Epic.loginWithCode launcherVersion details.ExchangeCode with | Ok t -> - let tokenManager = new RefreshableTokenManager(t, Epic.refreshToken launcherVersion) - let! result = tokenManager.Get |> Expires |> Ok |> (authenticate (Some tokenManager)) + let loginViaLegendary() = Legendary.getAccessToken() |> Result.bindTask (Epic.loginWithExistingToken launcherVersion) + let tokenManager = new RefreshableTokenManager(t, Epic.refreshToken launcherVersion, loginViaLegendary) + let! result = {| Get = tokenManager.Get; Renew = tokenManager.Renew |} |> Expires |> Ok |> (authenticate (Some tokenManager)) return result | Error msg -> return Failure msg |> Error } @@ -242,6 +243,7 @@ type AppError = | Login of LoginError | NoSelectedProduct | InvalidProductState of string + | InvalidSession of string [] module AppError = @@ -269,6 +271,7 @@ module AppError = $"Frontier was unable to verify that you own the game. This happens intermittently. Possible fixes include:{Environment.NewLine}{possibleFixes}" | NoSelectedProduct -> "No selected project" | InvalidProductState m -> $"Couldn't start selected product: %s{m}" + | InvalidSession m -> $"Invalid session: %s{m}" let private createGetRunningTime httpClient = task { let localTime = DateTime.UtcNow @@ -285,7 +288,13 @@ let private createGetRunningTime httpClient = task { (double remoteTime + runningTime.TotalSeconds) } -let rec private launchLoop initialLaunch settings playableProducts persistentRunning relaunchRunning cancellationToken processArgs = taskResult { +let private renewEpicTokenIfNeeded platform token = + match platform, token with + | Epic _, Expires t -> t.Renew() + | _ -> Ok () |> Task.fromResult + |> TaskResult.mapError InvalidSession + +let rec private launchLoop initialLaunch settings playableProducts (session: EdSession) persistentRunning relaunchRunning cancellationToken processArgs = taskResult { let! selectedProduct = if settings.AutoRun && initialLaunch then playableProducts @@ -318,6 +327,8 @@ let rec private launchLoop initialLaunch settings playableProducts persistentRun else settings.Processes |> List.filter (fun p -> not p.RestartOnRelaunch) |> List.map _.Info, relaunchProcesses + if not initialLaunch then + do! renewEpicTokenIfNeeded settings.Platform session.PlatformToken let persistentProcesses = persistentRunning |> Option.defaultWith (fun () -> Process.launchProcesses persistentStartInfos) let mutable relaunchProcesses = Process.launchProcesses relaunchStartInfos @@ -333,14 +344,13 @@ let rec private launchLoop initialLaunch settings playableProducts persistentRun Process.stopProcesses settings.ShutdownTimeout relaunchProcesses logStart relaunchStartInfos relaunchProcesses <- Process.launchProcesses relaunchStartInfos + + do! renewEpicTokenIfNeeded settings.Platform session.PlatformToken + launchProduct settings.DryRun settings.CompatTool pArgs selectedProduct.Name p if not settings.AutoQuit then - match settings.Platform with - | Epic _ -> - Log.warn "Unable to re-launch game without fully restarting the launcher when using an Epic account" - return persistentProcesses @ relaunchProcesses, didLoop - | _ -> return! launchLoop false settings playableProducts (Some persistentProcesses) (Some relaunchProcesses) cancellationToken processArgs + return! launchLoop false settings playableProducts session (Some persistentProcesses) (Some relaunchProcesses) cancellationToken processArgs else return persistentProcesses @ relaunchProcesses, didLoop } @@ -510,7 +520,7 @@ let run settings launcherVersion cancellationToken = taskResult { let gameLanguage = Cobra.getGameLang settings.CbLauncherDir settings.PreferredLanguage let processArgs = Product.createArgString settings.DisplayMode gameLanguage connection.Session machineId (getRunningTime()) settings.WatchForCrashes settings.Platform SHA1.hashFile - let! runningProcesses, didLoop = launchLoop true settings playableProducts None None cancellationToken processArgs + let! runningProcesses, didLoop = launchLoop true settings playableProducts connection.Session None None cancellationToken processArgs if settings.ShutdownDelay > TimeSpan.Zero then Log.info $"Delaying shutdown for %.2f{settings.ShutdownDelay.TotalSeconds} seconds" diff --git a/src/MinEdLauncher/Epic.fs b/src/MinEdLauncher/Epic.fs index 215fb02..f1efc72 100644 --- a/src/MinEdLauncher/Epic.fs +++ b/src/MinEdLauncher/Epic.fs @@ -1,6 +1,6 @@ module MinEdLauncher.Epic -open Types +open FsToolkit.ErrorHandling open Rop open MinEdLauncher.Token open System @@ -102,7 +102,7 @@ let private requestToStr formValues contentHeaders (request: HttpRequestMessage) |> Http.dumpHeaders [ request.Headers; contentHeaders ] 2 sb.ToString() -let private request launcherVersion (formValues: string list) : Task> = +let private requestToken launcherVersion (formValues: string list) : Task> = match epicValues.Force() with | Ok (clientId, clientSecret, dId) -> task { let formValues = @@ -131,11 +131,37 @@ let private request launcherVersion (formValues: string list) : Task Error } | Error msg -> Error msg |> Task.fromResult + +let private requestAsEpic path applyOptions = task { + use httpClient = new HttpClient() + use request = new HttpRequestMessage() + + applyOptions request -let login launcherVersion epicDetails = - request launcherVersion [ "grant_type=exchange_code" - $"exchange_code=%s{epicDetails.ExchangeCode}" ] + request.RequestUri <- Uri($"https://account-public-service-prod03.ol.epicgames.com%s{path}") + request.Headers.TryAddWithoutValidation("User-Agent", "UELauncher/11.0.1-14907503+++Portal+Release-Live Windows/10.0.19041.1.256.64bit") |> ignore + + return! httpClient.SendAsync(request) +} + +let private generateExchangeCode token = task { + let! response = requestAsEpic "/account/api/oauth/exchange" (fun r -> r.Headers.Authorization <- AuthenticationHeaderValue("bearer", token)) + + if response.IsSuccessStatusCode then + Log.debug "Requesting epic exchange code success" + use! content = response.Content.ReadAsStreamAsync() + return content |> Json.parseStream >>= Json.rootElement >>= Json.parseProp "code" >>= Json.toString + else + let! content = response.Content.ReadAsStringAsync() + Log.debug $"Requesting epic exchange code failed: %s{content}" + return $"%i{int response.StatusCode}: %s{response.ReasonPhrase}" |> Error } + +let loginWithCode launcherVersion exchangeCode = + requestToken launcherVersion [ "grant_type=exchange_code"; $"exchange_code=%s{exchangeCode}" ] let refreshToken launcherVersion (token: RefreshableToken) = - request launcherVersion [ "grant_type=refresh_token" - $"refresh_token=%s{token.RefreshToken}" ] + requestToken launcherVersion [ "grant_type=refresh_token"; $"refresh_token=%s{token.RefreshToken}" ] + +let loginWithExistingToken launcherVersion token = + generateExchangeCode token + |> TaskResult.bind(loginWithCode launcherVersion) diff --git a/src/MinEdLauncher/Extensions.fs b/src/MinEdLauncher/Extensions.fs index 69cf26b..da71bc2 100644 --- a/src/MinEdLauncher/Extensions.fs +++ b/src/MinEdLauncher/Extensions.fs @@ -157,7 +157,7 @@ module Json = | false, _ -> Error $"Unable to convert string to long '%s{str}'" let asDateTime (prop:JsonElement) = let str = prop.ToString() - match DateTime.TryParse(str) with + match DateTimeOffset.TryParse(str) with | true, value -> Ok value | false, _ -> Error $"Unable to parse string as DateTime '%s{str}'" let asUri (prop:JsonElement) = @@ -166,6 +166,11 @@ module Json = | false, _ -> Error $"Unable to parse '%s{prop.ToString()}' as Uri" let toString (prop:JsonElement) = Ok <| prop.ToString() + let asString (prop:JsonElement) = + try + prop.GetString() |> Ok + with + | :? InvalidOperationException -> Error $"Unable to parse '%s{prop.ToString()}' as string" let asVersion (prop:JsonElement) = match Version.TryParse(prop.ToString()) with | true, value -> Ok value @@ -454,7 +459,7 @@ module Environment = let appData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData) Path.Combine(appData, AppFolderName, subDir) - let configDir = + let configDirFor name = let specialFolder = if RuntimeInformation.IsOSPlatform(OSPlatform.Windows) then Environment.SpecialFolder.LocalApplicationData @@ -462,7 +467,9 @@ module Environment = Environment.SpecialFolder.ApplicationData let path = Environment.GetFolderPath(specialFolder) - Path.Combine(path, AppFolderName) + Path.Combine(path, name) + + let configDir = configDirFor AppFolderName let cacheDir = if RuntimeInformation.IsOSPlatform(OSPlatform.Windows) then diff --git a/src/MinEdLauncher/Legendary.fs b/src/MinEdLauncher/Legendary.fs new file mode 100644 index 0000000..d6e5954 --- /dev/null +++ b/src/MinEdLauncher/Legendary.fs @@ -0,0 +1,51 @@ +module Legendary + +open System +open System.IO +open System.Runtime.InteropServices +open MinEdLauncher +open FsToolkit.ErrorHandling +open Rop + +let parseAccessToken (timeProvider: TimeProvider) json = result { + let! root = json |> Json.rootElement + do! root + |> Json.parseProp "expires_at" + >>= Json.asDateTime + >>= (fun expires -> expires > timeProvider.GetUtcNow() + |> Result.requireTrue "Epic access token is expired. Re-authenticate with Legendary/Heroic") + + return! root |> Json.parseProp "access_token" >>= Json.asString +} + +let getAccessToken() = + let potentialPaths = + if RuntimeInformation.IsOSPlatform(OSPlatform.Windows) then + [ + Environment.GetEnvironmentVariable("LEGENDARY_CONFIG_PATH") + Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".config", "legendary") + Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "heroic", "legendaryConfig", "legendary") + ] + else if RuntimeInformation.IsOSPlatform(OSPlatform.Linux) then + [ + Environment.GetEnvironmentVariable("LEGENDARY_CONFIG_PATH") + Environment.configDirFor "legendary" + Path.Combine(Environment.configDirFor "heroic", "legendaryConfig", "legendary") + ] + else + [] + |> List.filter (fun p -> not (String.IsNullOrWhiteSpace(p))) + + potentialPaths + |> List.map (fun p -> FileInfo(Path.Combine(p, "user.json"))) + |> List.filter _.Exists + |> List.sortByDescending _.LastWriteTime // Assume newest file has latest access token + |> List.tryHead + |> Option.map _.FullName + |> Result.requireSome "Couldn't find Legendary auth file" + |> Result.teeError (fun _ -> + let paths = if potentialPaths.Length = 0 then "None" else String.Join($"{Environment.NewLine} ", potentialPaths) + Log.debug $"Legendary locations checked:{Environment.NewLine}{paths}" + ) + >>= Json.parseFile + >>= (parseAccessToken TimeProvider.System) diff --git a/src/MinEdLauncher/MinEdLauncher.fsproj b/src/MinEdLauncher/MinEdLauncher.fsproj index c980c78..0e4524b 100644 --- a/src/MinEdLauncher/MinEdLauncher.fsproj +++ b/src/MinEdLauncher/MinEdLauncher.fsproj @@ -55,6 +55,7 @@ + diff --git a/src/MinEdLauncher/Token.fs b/src/MinEdLauncher/Token.fs index 68d023c..373461d 100644 --- a/src/MinEdLauncher/Token.fs +++ b/src/MinEdLauncher/Token.fs @@ -3,6 +3,7 @@ module MinEdLauncher.Token open System open System.Threading.Tasks open System.Timers +open FsToolkit.ErrorHandling type RefreshableToken = { Token: string @@ -14,7 +15,7 @@ type private RefreshableTokenMessage = | Get of replyChannel: AsyncReplyChannel | Refresh of RefreshableToken -type RefreshableTokenManager(initialToken, refresh: (RefreshableToken -> Task>)) = +type RefreshableTokenManager(initialToken, refresh: RefreshableToken -> Task>, renew: unit -> Task>) = let agent = MailboxProcessor.Start(fun inbox -> let rec loop token = async { match! inbox.Receive() with @@ -38,22 +39,28 @@ type RefreshableTokenManager(initialToken, refresh: (RefreshableToken -> Task TaskResult.tee(fun t -> Refresh t |> agent.Post) + |> TaskResult.map(fun _ -> ()) + } interface IDisposable with member _.Dispose() = timer.Dispose() type PasswordToken = { Username: string; Password: string; Token: string } type AuthToken = - | Expires of (unit -> RefreshableToken) + | Expires of {| Get: unit -> RefreshableToken; Renew: unit -> Task> |} | Permanent of string | PasswordBased of PasswordToken member this.GetAccessToken() = match this with - | Expires t -> t().Token + | Expires t -> t.Get().Token | Permanent t -> t | PasswordBased t -> t.Token member this.GetRefreshToken() = match this with - | Expires t -> Some (t().RefreshToken) + | Expires t -> Some (t.Get().RefreshToken) | Permanent _ -> None | PasswordBased _ -> None diff --git a/tests/Legendary.fs b/tests/Legendary.fs new file mode 100644 index 0000000..66dc621 --- /dev/null +++ b/tests/Legendary.fs @@ -0,0 +1,107 @@ +module EdLauncher.Tests.Legendary + +open System +open System.Globalization +open System.Text.Json +open System.Text.Json.Nodes +open Expecto +open Microsoft.Extensions.Time.Testing + +[] +let tests = + testList "Legendary" [ + testList "getExistingCredentials" [ + let defaultJson = JsonDocument.Parse(""" + { + "access_token": "eg1~abc...def", + "account_id": "89270d0d394841eda5d57cf3388f9241", + "acr": "urn:epic:loa:aal2", + "app": "launcher", + "auth_time": "2024-06-03T20:23:50.919Z", + "client_id": "9a64592735eb430287fe03e23d2cb3e7", + "client_service": "launcher", + "device_id": "33c751da562c41608c63b4727fc106c1", + "displayName": "Dwight Schrute", + "expires_at": "2024-01-01T06:34:52.193Z", + "expires_in": 28800, + "in_app_id": "6476a6acab374e169472067a6e48c0d9", + "internal_client": true, + "refresh_expires": 1987200, + "refresh_expires_at": "2024-01-23T19:51:43.163Z", + "refresh_token": "eg1~123...456", + "scope": [], + "token_type": "bearer" + }""") + + test "Fails if missing access_token" { + let time = FakeTimeProvider() + let json = defaultJson.Deserialize() + json.Remove("access_token") |> ignore + let json = json.Deserialize() + + let token = Legendary.parseAccessToken time json + + Expect.isError token "" + } + + testTheory "Fails if access_token is not a string" + [ JsonValue.Create(1) :> JsonNode; JsonValue.Create(true) + JsonObject(); JsonArray() ] <| fun value -> + let time = FakeTimeProvider() + let json = defaultJson.Deserialize() + json["access_token"] <- value + let json = json.Deserialize() + + let token = Legendary.parseAccessToken time json + + Expect.isError token "" + + test "Fails if missing expires_at" { + let time = FakeTimeProvider() + let json = defaultJson.Deserialize() + json.Remove("expires_at") |> ignore + let json = json.Deserialize() + + let token = Legendary.parseAccessToken time json + + Expect.isError token "" + } + + testTheory "Fails if expires_at is not a date time" + [ JsonValue.Create(1) :> JsonNode; JsonValue.Create(true) + JsonValue.Create("abc"); JsonObject(); JsonArray(); ] <| fun value -> + let time = FakeTimeProvider() + let json = defaultJson.Deserialize() + json["expires_at"] <- value + let json = json.Deserialize() + + let token = Legendary.parseAccessToken time json + + Expect.isError token "" + + test "Fails if access token is expired" { + let now = DateTimeOffset(DateTime(2000, 1, 1)) + let time = FakeTimeProvider() + time.SetUtcNow(now) + let json = defaultJson.Deserialize() + json["expires_at"] <- now.Subtract(TimeSpan.FromSeconds(1)).ToString("o", CultureInfo.InvariantCulture) + let json = json.Deserialize() + + let token = Legendary.parseAccessToken time json + + Expect.isError token "" + } + + test "Can parse valid file" { + let time = FakeTimeProvider() + let accessToken = "test" + let json = defaultJson.Deserialize() + json["access_token"] <- accessToken + let json = json.Deserialize() + + let token = Legendary.parseAccessToken time json + + Expect.equal token (Ok(accessToken)) "" + } + ] + ] \ No newline at end of file diff --git a/tests/MinEdLauncher.Tests.fsproj b/tests/MinEdLauncher.Tests.fsproj index a4fec19..5aa5e45 100644 --- a/tests/MinEdLauncher.Tests.fsproj +++ b/tests/MinEdLauncher.Tests.fsproj @@ -26,12 +26,14 @@ + + diff --git a/tests/Product.fs b/tests/Product.fs index 756216b..c7a84e4 100644 --- a/tests/Product.fs +++ b/tests/Product.fs @@ -64,13 +64,15 @@ open MinEdLauncher.Tests.Extensions Expect.notStringContains actual "/steam" "" } test "Epic platform contains refresh token" { - let token = { EdSession.Empty with PlatformToken = Expires (fun () -> { RefreshableToken.Empty with RefreshToken = "asdf" }) } + let get = fun () -> { RefreshableToken.Empty with RefreshToken = "asdf" } + let token = { EdSession.Empty with PlatformToken = Expires {| Get = get; Renew = fun () -> Task.fromResult (Result.Ok()) |} } let actual = createArgString Vr None token "" getTimestamp false (Epic EpicDetails.Empty) hashFile product Expect.stringContains actual "\"EpicToken asdf\"" "" } test "Non epic platform doesn't contain refresh token" { - let token = { EdSession.Empty with PlatformToken = Expires (fun () -> { RefreshableToken.Empty with RefreshToken = "asdf" }) } + let get = fun () -> { RefreshableToken.Empty with RefreshToken = "asdf" } + let token = { EdSession.Empty with PlatformToken = Expires {| Get = get; Renew = fun _ -> Task.fromResult (Result.Ok()) |} } let actual = createArgString Vr None token "" getTimestamp false Dev hashFile product Expect.notStringContains actual "\"EpicToken" ""