Skip to content

Commit cc516c0

Browse files
feat(SaveSystem): third-party emulator detection & migration guide
- Add KnownEmulator enum to PVPrimitives with detection via canOpenURL - Add ExternalEmulatorMigrationView with per-emulator step-by-step guides for Delta, RetroArch, Manic Emu, PPSSPP, and Gamma - Wire view into Settings → Library section - Add delta/retroarch/ppsspp to LSApplicationQueriesSchemes in all iOS and tvOS Info.plist variants - Add whats-new.json entry for 3.9.2 - Add .changelog/3556.md fragment Part of #3551 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 27e70d4 commit cc516c0

File tree

11 files changed

+788
-0
lines changed

11 files changed

+788
-0
lines changed

.changelog/3556.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
### Added
2+
- **Third-Party Emulator Migration Guide** — New "Import from Another Emulator" screen in Settings → Library detects installed emulators (Delta, RetroArch, Manic Emu, PPSSPP, Gamma) and provides step-by-step export/import instructions for each.
3+
- **`KnownEmulator` enum**`PVPrimitives` now exposes a `KnownEmulator` enum with metadata (bundle ID, URL scheme, save extensions, system summary) for each supported third-party emulator and a `@MainActor` detection helper using `canOpenURL`.
4+
- **`LSApplicationQueriesSchemes` additions** — All iOS and tvOS Info.plist variants now declare the `delta`, `retroarch`, and `ppsspp` URL schemes so presence detection works at runtime.
5+
- **Manual Import Guide** — Covers `.sav`/`.srm`/`.state` imports via Files.app and Provenance's built-in web server for users not migrating from a specific third-party app.
Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
//
2+
// KnownEmulator.swift
3+
// PVPrimitives
4+
//
5+
// Created by Joseph Mattiello on 3/28/26.
6+
// Copyright © 2026 Provenance Emu. All rights reserved.
7+
//
8+
9+
import Foundation
10+
11+
/// A known third-party emulator that may be installed on the user's device.
12+
///
13+
/// iOS sandboxing prevents direct file access to other apps' containers, but
14+
/// we can probe for their presence via URL schemes and guide users through
15+
/// manual export/import workflows.
16+
public enum KnownEmulator: String, CaseIterable, Sendable {
17+
/// Delta — Nintendo multi-system emulator by Riley Testut.
18+
/// Supports NES, SNES, N64, GBA, GBC, DS.
19+
case delta
20+
21+
/// Manic Emu — GBA, NES, SNES, and Sega Genesis emulator.
22+
case manic
23+
24+
/// RetroArch — multi-system emulator frontend with a large core library.
25+
case retroArch
26+
27+
/// PPSSPP — PSP emulator.
28+
case ppsspp
29+
30+
/// Gamma — Game Boy / Game Boy Color emulator.
31+
case gamma
32+
}
33+
34+
// MARK: - Display properties
35+
36+
public extension KnownEmulator {
37+
/// Human-readable name of the emulator.
38+
var displayName: String {
39+
switch self {
40+
case .delta: return "Delta"
41+
case .manic: return "Manic Emu"
42+
case .retroArch: return "RetroArch"
43+
case .ppsspp: return "PPSSPP"
44+
case .gamma: return "Gamma"
45+
}
46+
}
47+
48+
/// Bundle identifier of the emulator app.
49+
var bundleIdentifier: String {
50+
switch self {
51+
case .delta: return "com.rileytestut.Delta"
52+
case .manic: return "com.manticstudios.ManticEmu"
53+
case .retroArch: return "com.libretro.RetroArch"
54+
case .ppsspp: return "org.ppsspp.ppsspp"
55+
case .gamma: return "com.littleredgames.GambatteGB"
56+
}
57+
}
58+
59+
/// URL scheme used to probe whether the app is installed.
60+
/// `nil` if the emulator has no registered URL scheme.
61+
var urlScheme: String? {
62+
switch self {
63+
case .delta: return "delta"
64+
case .manic: return nil
65+
case .retroArch: return "retroarch"
66+
case .ppsspp: return "ppsspp"
67+
case .gamma: return nil
68+
}
69+
}
70+
71+
/// SF Symbol name that best represents this emulator's primary platform(s).
72+
var symbolName: String {
73+
switch self {
74+
case .delta: return "gamecontroller.fill"
75+
case .manic: return "bolt.fill"
76+
case .retroArch: return "cpu.fill"
77+
case .ppsspp: return "memorychip"
78+
case .gamma: return "squareshape.dotted.squareshape"
79+
}
80+
}
81+
82+
/// Short description of which systems/games this emulator handles.
83+
var systemSummary: String {
84+
switch self {
85+
case .delta: return "NES, SNES, N64, GBA, GBC, DS"
86+
case .manic: return "GBA, NES, SNES, Genesis"
87+
case .retroArch: return "60+ systems"
88+
case .ppsspp: return "PlayStation Portable (PSP)"
89+
case .gamma: return "Game Boy, Game Boy Color"
90+
}
91+
}
92+
93+
/// The save-file extension(s) this emulator primarily uses.
94+
var saveExtensions: [String] {
95+
switch self {
96+
case .delta: return ["sav", "ssv"]
97+
case .manic: return ["sav", "srm"]
98+
case .retroArch: return ["sav", "srm", "state"]
99+
case .ppsspp: return ["ppst"]
100+
case .gamma: return ["sav"]
101+
}
102+
}
103+
}
104+
105+
// MARK: - Detection
106+
107+
public extension KnownEmulator {
108+
/// Returns `true` when the emulator appears to be installed.
109+
///
110+
/// Detection is performed via `UIApplication.canOpenURL(_:)`, which requires
111+
/// the scheme to be listed in `LSApplicationQueriesSchemes` inside Info.plist.
112+
/// On tvOS and simulator builds this always returns `false`.
113+
@MainActor
114+
var isInstalled: Bool {
115+
#if os(iOS) && !targetEnvironment(simulator)
116+
guard let scheme = urlScheme,
117+
let url = URL(string: "\(scheme)://") else {
118+
return false
119+
}
120+
// UIApplication is only available when imported via UIKit
121+
// We use dynamic lookup to avoid a hard UIKit import in PVPrimitives.
122+
guard let application = NSClassFromString("UIApplication"),
123+
let shared = application.value(forKeyPath: "sharedApplication") as? NSObject else {
124+
return false
125+
}
126+
return (shared.perform(NSSelectorFromString("canOpenURL:"), with: url)?.takeUnretainedValue() as? Bool) ?? false
127+
#else
128+
return false
129+
#endif
130+
}
131+
132+
/// Returns all emulators that are detected as installed on the current device.
133+
@MainActor
134+
static var installedEmulators: [KnownEmulator] {
135+
KnownEmulator.allCases.filter { $0.isInstalled }
136+
}
137+
}

PVUI/Sources/PVSwiftUI/Resources/whats-new.json

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -688,5 +688,29 @@
688688
"subtitle": "The ra.me relay is pre-filled out of the box so online play works without any manual setup on a fresh install."
689689
}
690690
]
691+
},
692+
{
693+
"version": "3.9.2",
694+
"title": "Save Migration Guide",
695+
"features": [
696+
{
697+
"symbolName": "arrow.triangle.2.circlepath",
698+
"symbolColor": "blue",
699+
"title": "Import from Other Emulators",
700+
"subtitle": "New step-by-step guide to migrate your saves from Delta, RetroArch, Manic Emu, PPSSPP, and Gamma. Find it in Settings → Library."
701+
},
702+
{
703+
"symbolName": "checkmark.circle.fill",
704+
"symbolColor": "green",
705+
"title": "Automatic Detection",
706+
"subtitle": "Provenance now detects which emulators are installed and surfaces tailored export instructions for each one."
707+
},
708+
{
709+
"symbolName": "folder.badge.plus",
710+
"symbolColor": "purple",
711+
"title": "Manual Import Guide",
712+
"subtitle": "Not migrating from a specific app? A general guide covers .sav/.srm/.state imports via Files.app and the built-in web server."
713+
}
714+
]
691715
}
692716
]

PVUI/Sources/PVSwiftUI/Settings/SettingsSwiftUI.swift

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2451,6 +2451,15 @@ private struct LibrarySection: View {
24512451
#if os(tvOS)
24522452
.retroFocusButtonStyle(showBorder: false)
24532453
#endif
2454+
2455+
NavigationLink(destination: ExternalEmulatorMigrationView()) {
2456+
SettingsRow(title: "Import from Another Emulator",
2457+
subtitle: "Step-by-step guide to migrate saves from Delta, RetroArch, Manic, PPSSPP, or Gamma.",
2458+
icon: .sfSymbol("arrow.triangle.2.circlepath"))
2459+
}
2460+
#if os(tvOS)
2461+
.retroFocusButtonStyle(showBorder: false)
2462+
#endif
24542463
}
24552464
}
24562465
}

0 commit comments

Comments
 (0)