Skip to content

Commit 52a7548

Browse files
authored
Merge pull request #198 from teal-bauer/feat/settings-validation
feat: validate settings keys and accept flexible forceUpdate formats
2 parents de916ae + e17ac02 commit 52a7548

File tree

5 files changed

+151
-24
lines changed

5 files changed

+151
-24
lines changed

README.md

Lines changed: 28 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -208,27 +208,33 @@ Windows: `%LOCALAPPDATA%\min-ed-launcher\settings.json`
208208

209209
Linux: `$XDG_CONFIG_HOME/min-ed-launcher/settings.json` (`~/.config` if `$XDG_CONFIG_HOME` isn't set)
210210
211-
| Settings | Effect |
212-
|-----------------------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
213-
| apiUri | FDev API base URI. Should only be changed if you are doing local development |
214-
| watchForCrashes | Determines if the game should be launched by `WatchDog64.exe` or not |
215-
| gameLocation | Path to game's install folder. Specify this if the launcher can't figure it out by itself |
216-
| language | Sets the game's language. Supported values are _en_ and the names of the language folders in Elite's install directory |
217-
| autoUpdate | Automatically update games that are out of date |
218-
| checkForLauncherUpdates | Check if there is a newer version of min-ed-launcher |
219-
| maxConcurrentDownloads | Maximum number of simultaneous downloads when downloading updates |
220-
| forceUpdate | By default, Steam and Epic updates are handled by their respective platform. In cases like the Odyssey alpha, FDev doesn't provide updates through Steam or Epic. This allows the launcher to force updates to be done via FDev servers by providing a comma delimited list of SKUs |
221-
| processes | Additional applications to launch before launching the game |
222-
| processes.arguments | Optional arguments for the process |
223-
| processes.restartOnRelaunch | Will shutdown and restart the application when the `/restart` flag is specified before restarting the game |
224-
| processes.keepOpen | Keep application open after launcher exits |
225-
| shutdownProcesses | Additional applications to launch after game has shutdown |
226-
| shutdownTimeout | Time, in seconds, to wait for additional applications to shutdown before forcefully terminating them |
227-
| filterOverrides | Manually override a product's filter for use with launch options filter flag (e.g. /edo, /edh, etc...) |
228-
| additionalProducts | Provide extra products to the authorized product list. Useful for launching Horizons 4.0 when you own the Odyssey DLC |
229-
| cacheDir | Path to directory used for downloading game updates. See [cache] section for default location |
230-
| gameStartDelay | Time to delay after starting processes but before starting ED. Defaults to zero |
231-
| shutdownDelay | Time to delay before closing processes. Defaults to zero |
211+
| Key | Type | Default | Description |
212+
|---------------------------|--------|------------------------------|---------------------------------------------------------------------------------|
213+
| `apiUri` | string | `"https://api.zaonce.net"` | FDev API base URI |
214+
| `watchForCrashes` | bool | `false` | Launch game via `WatchDog64.exe` |
215+
| `gameLocation` | string | *null* | Path to game install folder. Auto-detected if omitted |
216+
| `language` | string | *null* | Game language (`en`, or a language folder name) |
217+
| `autoUpdate` | bool | `true` | Auto-update out-of-date games |
218+
| `checkForLauncherUpdates` | bool | `true` | Check for new min-ed-launcher versions |
219+
| `maxConcurrentDownloads` | int | `4` | Max simultaneous update downloads |
220+
| `forceUpdate` | array | `[]` | SKUs to force-update via FDev servers |
221+
| `processes` | array | `[]` | Programs to launch before the game. See [process fields](#process-fields) below |
222+
| `shutdownProcesses` | array | `[]` | Programs to launch after game shutdown. Same fields as `processes` |
223+
| `shutdownTimeout` | int | `10` | Seconds to wait before force-killing processes |
224+
| `filterOverrides` | array | `[]` | Override product filters for launch flags (e.g. `/edo`, `/edh`) |
225+
| `additionalProducts` | array | `[]` | Extra products for the authorized list |
226+
| `cacheDir` | string | *(OS default)* | Directory for update downloads. See [cache] section for default location |
227+
| `gameStartDelay` | int | `0` | Seconds to wait after starting processes but before launching the game |
228+
| `shutdownDelay` | int | `0` | Seconds to wait before closing processes |
229+
230+
#### Process fields
231+
232+
| Field | Type | Default | Description |
233+
|---------------------|--------|--------------|----------------------------------------------------------|
234+
| `fileName` | string | *(required)* | Path to executable |
235+
| `arguments` | string | *null* | Command-line arguments |
236+
| `restartOnRelaunch` | bool | `false` | Restart the process when the game restarts via `/restart` |
237+
| `keepOpen` | bool | `false` | Don't stop this process when the launcher exits |
232238

233239
> [!NOTE]
234240
> When specifying a path for `gameLocation`, `cacheDir` or `processes.fileName` on Windows, it's required to escape backslashes. Make sure to use a
@@ -250,7 +256,7 @@ double backslash (`\\`) instead of a single backslash (`\`).
250256
"autoUpdate": true,
251257
"checkForLauncherUpdates": true,
252258
"maxConcurrentDownloads": 4,
253-
"forceUpdate": "PUBLIC_TEST_SERVER_OD",
259+
"forceUpdate": ["PUBLIC_TEST_SERVER_OD"],
254260
"cacheDir": "C:\\path\\to\\dir",
255261
"gameStartDelay": 0,
256262
"shutdownDelay": 0,

src/MinEdLauncher/Settings.fs

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -208,10 +208,63 @@ type Config =
208208
GameStartDelay: int
209209
[<DefaultValue("0")>]
210210
ShutdownDelay: int }
211+
let private levenshteinDistance (a: string) (b: string) =
212+
let a = a.ToLowerInvariant()
213+
let b = b.ToLowerInvariant()
214+
let m, n = a.Length, b.Length
215+
let d = Array2D.zeroCreate (m + 1) (n + 1)
216+
for i in 0..m do d[i, 0] <- i
217+
for j in 0..n do d[0, j] <- j
218+
for i in 1..m do
219+
for j in 1..n do
220+
let cost = if a[i - 1] = b[j - 1] then 0 else 1
221+
d[i, j] <- min (min (d[i - 1, j] + 1) (d[i, j - 1] + 1)) (d[i - 1, j - 1] + cost)
222+
d[m, n]
223+
224+
let private knownConfigKeys =
225+
[ "apiUri"; "watchForCrashes"; "gameLocation"; "language"; "autoUpdate"
226+
"checkForLauncherUpdates"; "maxConcurrentDownloads"; "forceUpdate"
227+
"processes"; "shutdownProcesses"; "filterOverrides"; "additionalProducts"
228+
"shutdownTimeout"; "cacheDir"; "gameStartDelay"; "shutdownDelay" ]
229+
|> OrdinalIgnoreCaseSet.ofSeq
230+
231+
let private warnUnknownKeys (configRoot: IConfigurationRoot) =
232+
configRoot.GetChildren()
233+
|> Seq.iter (fun section ->
234+
let key = section.Key
235+
if not (knownConfigKeys.Contains key) then
236+
let known, dist =
237+
knownConfigKeys
238+
|> Seq.map (fun known -> known, levenshteinDistance key known)
239+
|> Seq.minBy snd
240+
if dist <= 3 then
241+
Log.warn $"Unknown settings key '%s{key}'. Did you mean '%s{known}'?"
242+
else
243+
Log.warn $"Unknown settings key '%s{key}'")
244+
245+
let private parseForceUpdate (configRoot: IConfigurationRoot) (config: Config) =
246+
let section = configRoot["forceUpdate"]
247+
let children = configRoot.GetSection("forceUpdate").GetChildren() |> Seq.toList
248+
if not (isNull section) && children.IsEmpty then
249+
let values =
250+
section.Split(',', StringSplitOptions.RemoveEmptyEntries ||| StringSplitOptions.TrimEntries)
251+
|> Array.toList
252+
{ config with ForceUpdate = values }
253+
elif not children.IsEmpty then
254+
let values =
255+
children
256+
|> List.choose (fun child ->
257+
let v = child.Value
258+
if String.IsNullOrWhiteSpace(v) then None else Some v)
259+
{ config with ForceUpdate = values }
260+
else
261+
config
262+
211263
let parseConfig fileName =
212264
let configRoot = ConfigurationBuilder()
213265
.AddJsonFile(fileName, false)
214266
.Build()
267+
warnUnknownKeys configRoot
215268
let parseKvps section keyName valueName map =
216269
configRoot.GetSection(section).GetChildren()
217270
|> Seq.choose (fun section ->
@@ -240,6 +293,7 @@ let parseConfig fileName =
240293
match AppConfig(configRoot).Get<Config>() with
241294
| Ok config ->
242295
// FsConfig doesn't support list of records so handle it manually
296+
let config = parseForceUpdate configRoot config
243297
let processes = parseProcesses "processes"
244298
let shutdownProcesses = parseProcesses "shutdownProcesses"
245299
let filterOverrides =

src/MinEdLauncher/Types.fs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ type OrdinalIgnoreCaseSet = FSharpx.Collections.Tagged.Set<string, OrdinalIgnore
1515
module OrdinalIgnoreCaseSet =
1616
let intersect set2 set1 = OrdinalIgnoreCaseSet.Intersection(set1, set2)
1717
let any (set: OrdinalIgnoreCaseSet) = not set.IsEmpty
18-
let ofSeq (items: string[]) = OrdinalIgnoreCaseSet.Create(OrdinalIgnoreCaseComparer(), items)
18+
let ofSeq (items: string seq) = OrdinalIgnoreCaseSet.Create(OrdinalIgnoreCaseComparer(), items)
1919
let empty = OrdinalIgnoreCaseSet.Empty(OrdinalIgnoreCaseComparer())
2020

2121
type OrdinalIgnoreCaseMap<'Value> = FSharpx.Collections.Tagged.Map<string, 'Value, OrdinalIgnoreCaseComparer>

src/MinEdLauncher/settings.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
"autoUpdate": true,
66
"checkForLauncherUpdates": true,
77
"maxConcurrentDownloads": 4,
8-
"forceUpdate": "",
8+
"forceUpdate": [],
99
"processes": [],
1010
"shutdownProcesses": [],
1111
"filterOverrides": [

tests/Settings.fs

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,73 @@ open MinEdLauncher.Settings
88
open MinEdLauncher.Types
99
open MinEdLauncher.Tests.Extensions
1010

11+
let private writeJsonToTempFile (json: string) =
12+
let path = Path.Combine(Path.GetTempPath(), $"test-settings-{Guid.NewGuid()}.json")
13+
File.WriteAllText(path, json)
14+
path
15+
16+
let private minimalJson = """{
17+
"apiUri": "https://api.zaonce.net",
18+
"watchForCrashes": false,
19+
"autoUpdate": true,
20+
"checkForLauncherUpdates": true,
21+
"maxConcurrentDownloads": 4,
22+
"forceUpdate": "",
23+
"processes": [],
24+
"shutdownProcesses": [],
25+
"filterOverrides": [],
26+
"additionalProducts": []
27+
}"""
28+
29+
[<Tests>]
30+
let parseConfigTests =
31+
testList "Parsing config file" [
32+
test "Unknown key with close match suggests correction" {
33+
let json = minimalJson.Replace("\"forceUpdate\"", "\"forceUdate\"")
34+
let path = writeJsonToTempFile json
35+
try
36+
let result = parseConfig path
37+
Expect.isOk result "Config should still parse with unknown keys"
38+
finally
39+
File.Delete(path)
40+
}
41+
test "forceUpdate as comma-separated string" {
42+
let json = minimalJson.Replace("\"forceUpdate\": \"\"", "\"forceUpdate\": \"a, b , c\"")
43+
let path = writeJsonToTempFile json
44+
try
45+
let config = Expect.wantOk (parseConfig path) ""
46+
Expect.equal config.ForceUpdate ["a"; "b"; "c"] ""
47+
finally
48+
File.Delete(path)
49+
}
50+
test "forceUpdate as JSON array" {
51+
let json = minimalJson.Replace("\"forceUpdate\": \"\"", "\"forceUpdate\": [\"x\", \"y\"]")
52+
let path = writeJsonToTempFile json
53+
try
54+
let config = Expect.wantOk (parseConfig path) ""
55+
Expect.equal config.ForceUpdate ["x"; "y"] ""
56+
finally
57+
File.Delete(path)
58+
}
59+
test "forceUpdate as empty string yields empty list" {
60+
let path = writeJsonToTempFile minimalJson
61+
try
62+
let config = Expect.wantOk (parseConfig path) ""
63+
Expect.isEmpty config.ForceUpdate ""
64+
finally
65+
File.Delete(path)
66+
}
67+
test "forceUpdate as empty array yields empty list" {
68+
let json = minimalJson.Replace("\"forceUpdate\": \"\"", "\"forceUpdate\": []")
69+
let path = writeJsonToTempFile json
70+
try
71+
let config = Expect.wantOk (parseConfig path) ""
72+
Expect.isEmpty config.ForceUpdate ""
73+
finally
74+
File.Delete(path)
75+
}
76+
]
77+
1178
[<Tests>]
1279
let tests =
1380
let parseWithFallback fallback args =

0 commit comments

Comments
 (0)