Skip to content

Commit

Permalink
partially enable restart feature for epic users
Browse files Browse the repository at this point in the history
requires legendary/heroic
  • Loading branch information
rfvgyhn committed Jun 6, 2024
1 parent 282c79b commit e08b3bb
Show file tree
Hide file tree
Showing 11 changed files with 260 additions and 31 deletions.
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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
20 changes: 15 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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**

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down
30 changes: 20 additions & 10 deletions src/MinEdLauncher/App.fs
Original file line number Diff line number Diff line change
Expand Up @@ -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 }

Expand Down Expand Up @@ -242,6 +243,7 @@ type AppError =
| Login of LoginError
| NoSelectedProduct
| InvalidProductState of string
| InvalidSession of string

[<RequireQualifiedAccess>]
module AppError =
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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
}
Expand Down Expand Up @@ -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"
Expand Down
40 changes: 33 additions & 7 deletions src/MinEdLauncher/Epic.fs
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
module MinEdLauncher.Epic

open Types
open FsToolkit.ErrorHandling
open Rop
open MinEdLauncher.Token
open System
Expand Down Expand Up @@ -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<Result<RefreshableToken, string>> =
let private requestToken launcherVersion (formValues: string list) : Task<Result<RefreshableToken, string>> =
match epicValues.Force() with
| Ok (clientId, clientSecret, dId) -> task {
let formValues =
Expand Down Expand Up @@ -131,11 +131,37 @@ let private request launcherVersion (formValues: string list) : Task<Result<Refr
Log.debug $"Requesting epic token failed: %s{content}"
return $"%i{int response.StatusCode}: %s{response.ReasonPhrase}" |> 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)
13 changes: 10 additions & 3 deletions src/MinEdLauncher/Extensions.fs
Original file line number Diff line number Diff line change
Expand Up @@ -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) =
Expand All @@ -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
Expand Down Expand Up @@ -454,15 +459,17 @@ 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
else
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
Expand Down
51 changes: 51 additions & 0 deletions src/MinEdLauncher/Legendary.fs
Original file line number Diff line number Diff line change
@@ -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)
1 change: 1 addition & 0 deletions src/MinEdLauncher/MinEdLauncher.fsproj
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@
<Compile Include="Cobra.fs" />
<Compile Include="Api.fs" />
<Compile Include="MachineId.fs" />
<Compile Include="Legendary.fs" />
<Compile Include="Epic.fs" />
<Compile Include="Settings.fs" />
<Compile Include="Console.fs" />
Expand Down
15 changes: 11 additions & 4 deletions src/MinEdLauncher/Token.fs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ module MinEdLauncher.Token
open System
open System.Threading.Tasks
open System.Timers
open FsToolkit.ErrorHandling

type RefreshableToken =
{ Token: string
Expand All @@ -14,7 +15,7 @@ type private RefreshableTokenMessage =
| Get of replyChannel: AsyncReplyChannel<RefreshableToken>
| Refresh of RefreshableToken

type RefreshableTokenManager(initialToken, refresh: (RefreshableToken -> Task<Result<RefreshableToken, string>>)) =
type RefreshableTokenManager(initialToken, refresh: RefreshableToken -> Task<Result<RefreshableToken, string>>, renew: unit -> Task<Result<RefreshableToken, string>>) =
let agent = MailboxProcessor.Start(fun inbox ->
let rec loop token = async {
match! inbox.Receive() with
Expand All @@ -38,22 +39,28 @@ type RefreshableTokenManager(initialToken, refresh: (RefreshableToken -> Task<Re
timer.Start()

member this.Get() = agent.PostAndReply Get
member this.Renew() = task {
return!
renew()
|> 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<Result<unit, string>> |}
| 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
Loading

0 comments on commit e08b3bb

Please sign in to comment.