Skip to content

Commit 1734b54

Browse files
authored
Merge pull request #409 from LNPANDY/fix/shell-settings-off-switches
A game can turn cloud saves off, and the shell folders can be removed
2 parents 06d382c + 46bce14 commit 1734b54

12 files changed

Lines changed: 267 additions & 29 deletions

‎frontend-ios/Sources/ShellDemo/SampleData.swift‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ func demoText(_ key: String.LocalizationValue) -> String {
3131
GameDetail(game: tile, sections: [
3232
SettingSection(id: "game", title: demoText("Game"), rows: [
3333
SettingRow(id: "DM_OPERATION_MODE", label: demoText("Play mode"), note: demoText("The game's display and performance profile."), kind: .choice([SettingOption("handheld", demoText("Handheld")), SettingOption("docked", demoText("Docked"))]), value: "handheld"),
34-
SettingRow(id: "DM_SHOW_FPS", label: demoText("Show frame rate"), kind: .toggle(on: "1", off: "0"), value: "0"),
34+
SettingRow(id: "DM_SHOW_FPS", label: demoText("Show frame rate"), kind: .toggle(on: "1", off: "0"), value: "1", fromShell: tile.id == "orbit"),
3535
SettingRow(id: "DM_LANGUAGE", label: demoText("Language"), kind: .choice([SettingOption("en-US", demoText("American English")), SettingOption("en-GB", demoText("British English")), SettingOption("ja", demoText("Japanese")), SettingOption("fr", demoText("French")), SettingOption("de", demoText("German")), SettingOption("es-419", demoText("Latin American Spanish")), SettingOption("es", demoText("Spanish")), SettingOption("it", demoText("Italian")), SettingOption("nl", demoText("Dutch")), SettingOption("fr-CA", demoText("Canadian French")), SettingOption("ru", demoText("Russian")), SettingOption("ko", demoText("Korean")), SettingOption("zh-Hant", demoText("Traditional Chinese")), SettingOption("zh-Hans", demoText("Simplified Chinese")), SettingOption("pt-BR", demoText("Brazilian Portuguese"))]), value: "en-US")
3636
]),
3737
SettingSection(id: "controls", title: demoText("Controls"), rows: [
@@ -43,7 +43,8 @@ func demoText(_ key: String.LocalizationValue) -> String {
4343
])
4444
], dataSource: tile.state == .needsGameData ? "" : demoText("This app's folder"), dataNote: demoText("Choose a RomFS folder or romfs.img on this device, an external drive, or iCloud Drive."),
4545
cloudSaves: tile.id == "orbit" ? demoText("iCloud Drive / Game Saves") : nil,
46-
dlc: tile.id == "orbit" ? demoText("2 additional content packs") : demoText("No additional content"), hasModifiedSettings: true)
46+
dlc: tile.id == "orbit" ? demoText("2 additional content packs") : demoText("No additional content"), hasModifiedSettings: true,
47+
cloudFromShell: tile.id == "orbit")
4748
}
4849
}
4950
static let controllers = [

‎frontend-ios/Sources/ShellDemo/ShellDemoApp.swift‎

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -139,13 +139,16 @@ struct DemoScreen: View {
139139
for section in games[g].sections.indices {
140140
if let index = games[g].sections[section].rows.firstIndex(where: { $0.id == row }) {
141141
games[g].sections[section].rows[index].value = value
142+
games[g].sections[section].rows[index].fromShell = false
142143
games[g].hasModifiedSettings = true
143144
}
144145
}
145146
}, onPickGameData: { _ in notice = demoText("Choose game data requested. The host supplies the picker.") },
146147
onUseAppData: { _ in notice = demoText("Use app data requested.") },
147148
onPickCloudSaves: { _ in notice = demoText("Choose cloud folder requested. The host supplies the picker.") },
148-
onDisableCloudSaves: { id in if let i = games.firstIndex(where: { $0.id == id }) { games[i].cloudSaves = nil } },
149+
onDisableCloudSaves: { id in
150+
if let i = games.firstIndex(where: { $0.id == id }) { games[i].cloudSaves = nil; games[i].cloudFromShell = false; games[i].cloudCanUseShell = true }
151+
},
149152
onResetDefaults: { id in
150153
if let i = games.firstIndex(where: { $0.id == id }), let original = SampleData.games.first(where: { $0.id == id }) {
151154
games[i].sections = original.sections
@@ -155,7 +158,13 @@ struct DemoScreen: View {
155158
onAppUpdate: { notice = demoText("App update requested. The host supplies update instructions.") },
156159
onBack: { notice = demoText("Back requested.") },
157160
onPickShellData: { notice = demoText("Choose game data requested. The host supplies the picker.") },
158-
onPickShellCloud: { notice = demoText("Choose cloud folder requested. The host supplies the picker.") })
161+
onPickShellCloud: { notice = demoText("Choose cloud folder requested. The host supplies the picker.") },
162+
onUseShellCloud: { id in
163+
if let i = games.firstIndex(where: { $0.id == id }), let original = SampleData.games.first(where: { $0.id == id }) {
164+
games[i].cloudSaves = original.cloudSaves; games[i].cloudFromShell = true; games[i].cloudCanUseShell = false
165+
}
166+
}, onClearShellData: { ShellSettingsStore.current.clearDataFolderName() },
167+
onClearShellCloud: { ShellSettingsStore.current.clearCloudSavesFolderName() })
159168
}
160169
}
161170

‎frontend-ios/Sources/ShellHost/GameSettingsStore.swift‎

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,9 @@ import ShellUI
66
/// `cloudSavesBookmark`, so the engine can read the same names later.
77
public enum GameDefaults {
88
public static let suitePrefix = "dev.nx2apple.shell.game."
9+
/// True when the player turned cloud saves off for this game. It wins over the shell's cloud-saves folder, and
10+
/// choosing a folder for the game clears it.
11+
public static let cloudSavesOffKey = "cloudSavesOff"
912
public static func suite(for titleID: String) -> UserDefaults {
1013
UserDefaults(suiteName: suitePrefix + titleID) ?? .standard
1114
}
@@ -38,6 +41,11 @@ public struct GameSettingsStore {
3841

3942
public func value(of setting: LauncherSetting) -> String { saved(setting) ?? fallback(setting) }
4043

44+
/// True when the row shows the shell's choice: the game has none saved and the shell asks for a value the game offers.
45+
public func isFromShell(_ setting: LauncherSetting) -> Bool {
46+
saved(setting) == nil && shell[setting.env].map(setting.accepts) == true
47+
}
48+
4149
public func values() -> [String: String] {
4250
Dictionary(uniqueKeysWithValues: profile.settings.map { ($0.env, value(of: $0)) })
4351
}
@@ -57,16 +65,17 @@ public struct GameSettingsStore {
5765
for setting in profile.settings { defaults.removeObject(forKey: setting.defaultsKey) }
5866
}
5967

68+
/// Any saved choice can be forgotten by the reset, also one that keeps the game's default against the shell's.
6069
public var hasModifiedSettings: Bool {
61-
profile.settings.contains { setting in saved(setting).map { !setting.isDefault($0) } ?? false }
70+
profile.settings.contains { saved($0) != nil }
6271
}
6372
}
6473

6574
/// The profile and its values as the views' sections.
6675
public enum SettingsPresenter {
6776
/// A row with `needs` (`<VAR>=<value>`) is drawn only while the row that sets that variable has that value. A row
6877
/// naming a variable the list does not have stays drawn. Sections come in the list's order.
69-
public static func sections(profile: LauncherProfile, values: [String: String]) -> [SettingSection] {
78+
public static func sections(profile: LauncherProfile, values: [String: String], fromShell: Set<String> = []) -> [SettingSection] {
7079
func visible(_ s: LauncherSetting) -> Bool {
7180
guard let needs = s.needs, let eq = needs.firstIndex(of: "=") else { return true }
7281
guard let v = values[String(needs[..<eq])] else { return true }
@@ -75,18 +84,18 @@ public enum SettingsPresenter {
7584
var titles: [String] = []
7685
for s in profile.settings where !titles.contains(s.sectionTitle) { titles.append(s.sectionTitle) }
7786
return titles.compactMap { title in
78-
let rows = profile.settings.filter { $0.sectionTitle == title && visible($0) }.map { row($0, value: values[$0.env] ?? $0.def) }
87+
let rows = profile.settings.filter { $0.sectionTitle == title && visible($0) }.map { row($0, value: values[$0.env] ?? $0.def, fromShell: fromShell.contains($0.env)) }
7988
return rows.isEmpty ? nil : SettingSection(id: title, title: title, rows: rows)
8089
}
8190
}
8291

83-
static func row(_ s: LauncherSetting, value: String) -> SettingRow {
92+
static func row(_ s: LauncherSetting, value: String, fromShell: Bool = false) -> SettingRow {
8493
let kind: SettingKind
8594
switch s.kind {
8695
case "toggle": kind = .toggle(on: s.onValue, off: s.offValue)
8796
case "range": kind = .range(min: s.min ?? 0, max: s.max ?? 1, step: s.step ?? 0.1, unit: s.env.hasSuffix("SCALE") ? "×" : "")
8897
default: kind = .choice((s.options ?? []).map { SettingOption($0[0], $0[1]) })
8998
}
90-
return SettingRow(id: s.env, label: s.label, note: s.note, kind: kind, value: value)
99+
return SettingRow(id: s.env, label: s.label, note: s.note, kind: kind, value: value, fromShell: fromShell)
91100
}
92101
}

‎frontend-ios/Sources/ShellHost/ShellHostModel.swift‎

Lines changed: 54 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -144,6 +144,21 @@ public struct ShellHostConfig: Sendable {
144144
return s
145145
}
146146

147+
private enum CloudSaves { case off, own(URL), stale, shell(URL), none }
148+
149+
/// Where the game's cloud saves go: off when the player turned them off for the game, then the game's own folder,
150+
/// then the shell settings' folder.
151+
private func cloudSaves(_ id: String) -> CloudSaves {
152+
if config.defaults(id).bool(forKey: GameDefaults.cloudSavesOffKey) { return .off }
153+
switch cloudStore(id).resolve() {
154+
case .url(let url): return .own(url)
155+
case .stale: return .stale
156+
case .none:
157+
if case .url(let url) = shellCloud.resolve() { return .shell(url) }
158+
return .none
159+
}
160+
}
161+
147162
private func data(for entry: CatalogueEntry) -> DataResolution {
148163
guard let manifest = entry.manifest, entry.problem == nil else { return .missing }
149164
return DataLocator.locate(manifest: manifest, picked: dataStore(entry.id).resolve(), appFolder: appFolder(for: entry.id),
@@ -172,17 +187,21 @@ public struct ShellHostConfig: Sendable {
172187
var cloud: String?
173188
var cloudNote = ""
174189
var cloudFromShell = false
175-
switch cloudStore(entry.id).resolve() {
176-
case .url(let url): cloud = url.lastPathComponent
190+
switch cloudSaves(entry.id) {
191+
case .own(let url): cloud = url.lastPathComponent
177192
case .stale: cloudNote = hostText("The folder you chose before is no longer readable. Please choose it again.")
178-
case .none:
179-
if case .url(let url) = shellCloud.resolve() { cloud = url.lastPathComponent; cloudFromShell = true }
193+
case .shell(let url): cloud = hostText("\(url.lastPathComponent) (from Settings)"); cloudFromShell = true
194+
case .off, .none: break
180195
}
196+
var shellFolder = false
197+
if case .url = shellCloud.resolve() { shellFolder = true }
198+
let fromShell = Set(profile.settings.filter(store.isFromShell).map(\.env))
181199
return GameDetail(
182-
game: tile, sections: SettingsPresenter.sections(profile: profile, values: store.values()),
200+
game: tile, sections: SettingsPresenter.sections(profile: profile, values: store.values(), fromShell: fromShell),
183201
dataSource: dataSource(data), dataNote: dataNote(data, found: data.found),
184202
cloudSaves: cloud, cloudNote: cloudNote, dlc: dlc(data), hasModifiedSettings: store.hasModifiedSettings,
185-
dataFromShell: data.source == .shell, cloudFromShell: cloudFromShell)
203+
dataFromShell: data.source == .shell, cloudFromShell: cloudFromShell,
204+
cloudCanUseShell: shellFolder && !cloudFromShell)
186205
}
187206

188207
private func dataSource(_ data: DataResolution) -> String {
@@ -224,7 +243,10 @@ public struct ShellHostConfig: Sendable {
224243
onShowLog: { [weak self] id in self?.showLog(id) },
225244
onAppUpdate: { [weak self] in self?.appUpdate() },
226245
onPickShellData: { [weak self] in self.map { model in Task { await model.pickShellDataNow() } } },
227-
onPickShellCloud: { [weak self] in self.map { model in Task { await model.pickShellCloudNow() } } })
246+
onPickShellCloud: { [weak self] in self.map { model in Task { await model.pickShellCloudNow() } } },
247+
onUseShellCloud: { [weak self] id in self?.useShellCloudSaves(id) },
248+
onClearShellData: { [weak self] in self?.clearShellData() },
249+
onClearShellCloud: { [weak self] in self?.clearShellCloud() })
228250
}
229251

230252
public func change(_ id: String, setting: String, value: String) {
@@ -269,7 +291,8 @@ public struct ShellHostConfig: Sendable {
269291

270292
func pickCloudSavesNow(_ id: String) async {
271293
guard let url = await picker.pickFolder() else { return }
272-
if !cloudStore(id).store(url) { notice = hostText("That folder could not be remembered. Try a different one.") }
294+
if cloudStore(id).store(url) { config.defaults(id).removeObject(forKey: GameDefaults.cloudSavesOffKey) }
295+
else { notice = hostText("That folder could not be remembered. Try a different one.") }
273296
refresh(id)
274297
}
275298

@@ -289,9 +312,29 @@ public struct ShellHostConfig: Sendable {
289312

290313
public func disableCloudSaves(_ id: String) {
291314
cloudStore(id).clear()
315+
config.defaults(id).set(true, forKey: GameDefaults.cloudSavesOffKey)
316+
refresh(id)
317+
}
318+
319+
/// The game follows the shell settings' cloud-saves folder again.
320+
public func useShellCloudSaves(_ id: String) {
321+
cloudStore(id).clear()
322+
config.defaults(id).removeObject(forKey: GameDefaults.cloudSavesOffKey)
292323
refresh(id)
293324
}
294325

326+
func clearShellData() {
327+
shellData.clear()
328+
shellSettings.clearDataFolderName()
329+
refreshAll()
330+
}
331+
332+
func clearShellCloud() {
333+
shellCloud.clear()
334+
shellSettings.clearCloudSavesFolderName()
335+
refreshAll()
336+
}
337+
295338
public func showLog(_ id: String) {
296339
guard let entry = entry(id) else { return }
297340
let app = appFolder(for: id)
@@ -316,10 +359,9 @@ public struct ShellHostConfig: Sendable {
316359
guard let entry = entry(id), let manifest = entry.manifest, let profile = entry.profile, let framework = entry.frameworkURL,
317360
details.first(where: { $0.id == id })?.game.state == .playable, let dataURL = data(for: entry).url else { return }
318361
var cloud: URL?
319-
switch cloudStore(id).resolve() {
320-
case .url(let url): cloud = url
321-
case .stale: break
322-
case .none: if case .url(let url) = shellCloud.resolve() { cloud = url }
362+
switch cloudSaves(id) {
363+
case .own(let url), .shell(let url): cloud = url
364+
case .off, .stale, .none: break
323365
}
324366
// Remembered when Play is triggered, even if the launcher then fails (the stub). The order changes at the next load.
325367
LastPlayedStore(defaults: config.defaults(id)).record(config.now())

‎frontend-ios/Sources/ShellUI/ChoicePicker.swift‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -169,6 +169,7 @@ struct ChoiceValueRow: View {
169169
if !row.note.isEmpty {
170170
Text(row.note).shellType(ShellType.note).foregroundStyle(.white.opacity(0.5)).fixedSize(horizontal: false, vertical: true)
171171
}
172+
if row.fromShell { FromSettingsMark() }
172173
}
173174
Spacer(minLength: 8)
174175
Text(valueLabel).shellText(14.5, .semibold).foregroundStyle(ShellPalette.cream)

‎frontend-ios/Sources/ShellUI/GameDetailView.swift‎

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,11 @@ public struct GameDetailView: View {
4949
note: detail.cloudNote.isEmpty ? ShellStrings.text("Choose the same iCloud Drive folder you use on your Mac.") : detail.cloudNote) {
5050
Button(ShellStrings.text("Choose folder…")) { actions.onPickCloudSaves(detail.id) }
5151
.buttonStyle(ShellButtonStyle(fills: true)).shellTarget("set.cloud") { actions.onPickCloudSaves(detail.id) }
52-
if detail.cloudSaves != nil && !detail.cloudFromShell {
52+
if detail.cloudCanUseShell {
53+
Button(ShellStrings.text("Use Settings folder")) { actions.onUseShellCloud(detail.id) }
54+
.buttonStyle(ShellButtonStyle(fills: true)).shellTarget("set.cloud.shell") { actions.onUseShellCloud(detail.id) }
55+
}
56+
if detail.cloudSaves != nil {
5357
Button(ShellStrings.text("Turn off")) { actions.onDisableCloudSaves(detail.id) }
5458
.buttonStyle(ShellButtonStyle(fills: true)).accessibilityLabel(ShellStrings.text("Turn off cloud saves"))
5559
.shellTarget("set.cloud.off") { actions.onDisableCloudSaves(detail.id) }
@@ -208,6 +212,7 @@ struct SettingControl: View, Equatable {
208212
VStack(alignment: .leading, spacing: 4) {
209213
Text(row.label).shellType(ShellType.settingLabel).fixedSize(horizontal: false, vertical: true)
210214
if !row.note.isEmpty { Text(row.note).shellType(ShellType.note).foregroundStyle(.white.opacity(0.5)).fixedSize(horizontal: false, vertical: true) }
215+
if row.fromShell { FromSettingsMark() }
211216
}
212217
}
213218
/// The Mac's segmented choice, across the whole card: a dim track, equal segments, the chosen value in cream.
@@ -255,6 +260,14 @@ struct SettingControl: View, Equatable {
255260
}
256261
}
257262

263+
/// Says that a row shows the shell settings' choice, because the game has none of its own.
264+
struct FromSettingsMark: View {
265+
var body: some View {
266+
Text(ShellStrings.text("From Settings")).shellType(ShellType.note).foregroundStyle(ShellPalette.cream.opacity(0.75))
267+
.fixedSize(horizontal: false, vertical: true)
268+
}
269+
}
270+
258271
/// Children side by side in equal widths that fill the row. Its ideal width is the widest child times the count, so
259272
/// a `ViewThatFits` can fall back when the labels would not fit at equal widths.
260273
struct EqualRow: Layout {

‎frontend-ios/Sources/ShellUI/Resources/Localizable.xcstrings‎

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -385,6 +385,22 @@
385385
}
386386
}
387387
},
388+
"From Settings": {
389+
"localizations": {
390+
"en": {
391+
"stringUnit": {
392+
"state": "translated",
393+
"value": "From Settings"
394+
}
395+
},
396+
"zh-Hans": {
397+
"stringUnit": {
398+
"state": "translated",
399+
"value": "来自设置"
400+
}
401+
}
402+
}
403+
},
388404
"Game data": {
389405
"localizations": {
390406
"en": {
@@ -833,6 +849,22 @@
833849
}
834850
}
835851
},
852+
"Remove": {
853+
"localizations": {
854+
"en": {
855+
"stringUnit": {
856+
"state": "translated",
857+
"value": "Remove"
858+
}
859+
},
860+
"zh-Hans": {
861+
"stringUnit": {
862+
"state": "translated",
863+
"value": "移除"
864+
}
865+
}
866+
}
867+
},
836868
"Reset to tested defaults": {
837869
"localizations": {
838870
"en": {
@@ -993,6 +1025,22 @@
9931025
}
9941026
}
9951027
},
1028+
"Use Settings folder": {
1029+
"localizations": {
1030+
"en": {
1031+
"stringUnit": {
1032+
"state": "translated",
1033+
"value": "Use Settings folder"
1034+
}
1035+
},
1036+
"zh-Hans": {
1037+
"stringUnit": {
1038+
"state": "translated",
1039+
"value": "使用设置中的文件夹"
1040+
}
1041+
}
1042+
}
1043+
},
9961044
"Use app data": {
9971045
"localizations": {
9981046
"en": {

0 commit comments

Comments
 (0)