mirror of
https://github.com/ARMSX2/ARMSX2.git
synced 2026-08-24 16:50:16 -07:00
iOS: ask before replacing a skin that's already installed
Re-importing a skin you already have quietly produced a second copy called "Something 2", which is almost never what an author updating their own work wants. Now the picker notices and asks: Replace, Keep Both, or Cancel. The match is on the resolved display name from the manifest, not on the filename the zip arrived under. Two zips of the same skin with different filenames are the normal case - the author renames the file and the name inside stays put. Catalog installs are excluded, since replacing one by hand would leave its catalogID pointing at nothing and the browser would stop showing it as installed. Replace goes through importSkin(replacingSkinID:), which imports first and only then retires the old descriptor, carrying per-game picks and layout assignments across. A failed import leaves the skin you already had exactly where it was. Keep Both is just the old behaviour, and Cancel skips the archive and says so. The dialog matches the delete one already in this file. The import parks on a continuation while it is up, holding the staging directory, so every button resumes it and so does the dismissal binding if the dialog goes away without one - it resumes at most once either way.
This commit is contained in:
@@ -429,9 +429,34 @@ final class VPadSkinLibraryStore: @unchecked Sendable {
|
||||
)
|
||||
}
|
||||
|
||||
/// The name this extracted skin is asking for, before uniquing pushes it to
|
||||
/// "Something 2". Callers use it to spot a re-import of what they already
|
||||
/// have.
|
||||
func intendedDisplayName(forExtractedSkinAt url: URL) -> String {
|
||||
let files = skinImportFiles(from: url)
|
||||
return sanitizedDisplayName(
|
||||
manifest(in: files)?.manifest.name,
|
||||
fallback: sanitizedDisplayName(
|
||||
sourceName(from: url.lastPathComponent),
|
||||
fallback: "Imported Skin"
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
/// Matched on the display name, not the file it arrived in - the same skin
|
||||
/// gets re-zipped under a new filename all the time. Catalog installs are
|
||||
/// left out; replacing one by hand would strand its catalogID and break the
|
||||
/// installed badge in the browser.
|
||||
func existingImportedSkin(matchingName name: String) -> VPadSkinDescriptor? {
|
||||
importedDescriptors.first {
|
||||
$0.catalogID == nil && $0.displayName.caseInsensitiveCompare(name) == .orderedSame
|
||||
}
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func importSkinArchive(
|
||||
from sourceURL: URL,
|
||||
replacingSkinID: String? = nil,
|
||||
layoutPresets: PadLayoutPresetStore
|
||||
) async throws -> VPadSkinImportResult {
|
||||
let accessGranted = sourceURL.startAccessingSecurityScopedResource()
|
||||
@@ -462,6 +487,7 @@ final class VPadSkinLibraryStore: @unchecked Sendable {
|
||||
return try await importSkin(
|
||||
from: archiveDirectory,
|
||||
originalImportName: sourceURL.lastPathComponent,
|
||||
replacingSkinID: replacingSkinID,
|
||||
layoutPresets: layoutPresets
|
||||
)
|
||||
}
|
||||
|
||||
@@ -11,6 +11,43 @@ private enum DynamicActionRole {
|
||||
case holdFire
|
||||
}
|
||||
|
||||
private struct SkinReplacePrompt: Identifiable {
|
||||
let id = UUID()
|
||||
let name: String
|
||||
let existingSkinID: String
|
||||
}
|
||||
|
||||
private enum SkinReplaceChoice {
|
||||
case replace(String)
|
||||
case keepBoth
|
||||
case cancel
|
||||
}
|
||||
|
||||
/// Holds the answer the import is waiting on. The view owns one, so leaving the
|
||||
/// screen with the dialog still up unblocks the import on the way out instead of
|
||||
/// parking it on a continuation nobody is left to resume.
|
||||
@MainActor
|
||||
private final class SkinReplaceGate {
|
||||
private var continuation: CheckedContinuation<SkinReplaceChoice, Never>?
|
||||
|
||||
func wait() async -> SkinReplaceChoice {
|
||||
await withCheckedContinuation { continuation in
|
||||
self.continuation = continuation
|
||||
}
|
||||
}
|
||||
|
||||
/// Answers at most once, whichever of the buttons or the dismissal gets here first.
|
||||
func resume(_ choice: SkinReplaceChoice) {
|
||||
guard let continuation else { return }
|
||||
self.continuation = nil
|
||||
continuation.resume(returning: choice)
|
||||
}
|
||||
|
||||
deinit {
|
||||
continuation?.resume(returning: .cancel)
|
||||
}
|
||||
}
|
||||
|
||||
struct VirtualPadSettingsView: View {
|
||||
@State private var settings = SettingsStore.shared
|
||||
@State private var dynamicSettings = DynamicThumbstickSettings.shared
|
||||
@@ -28,6 +65,8 @@ struct VirtualPadSettingsView: View {
|
||||
@State private var skinPendingDelete: VPadSkinDescriptor?
|
||||
@State private var skinPendingRename: VPadSkinDescriptor?
|
||||
@State private var skinRenameDraft = ""
|
||||
@State private var skinReplacePrompt: SkinReplacePrompt?
|
||||
@State private var skinReplaceGate = SkinReplaceGate()
|
||||
@State private var automaticFireBlockedByHardcore = false
|
||||
|
||||
var body: some View {
|
||||
@@ -697,6 +736,37 @@ struct VirtualPadSettingsView: View {
|
||||
} message: { skin in
|
||||
Text("This removes the imported skin. Linked layout presets are kept.")
|
||||
}
|
||||
.confirmationDialog(
|
||||
"\(skinReplacePrompt?.name ?? "This skin") is already installed",
|
||||
isPresented: Binding<Bool>(
|
||||
get: { skinReplacePrompt != nil },
|
||||
set: {
|
||||
if !$0 {
|
||||
skinReplacePrompt = nil
|
||||
// A tapped button gets there first. This only catches a
|
||||
// dialog that went away without one, which would
|
||||
// otherwise leave the import waiting forever.
|
||||
DispatchQueue.main.async { resumeSkinReplace(.cancel) }
|
||||
}
|
||||
}
|
||||
),
|
||||
presenting: skinReplacePrompt
|
||||
) { prompt in
|
||||
Button("Replace") {
|
||||
skinReplacePrompt = nil
|
||||
resumeSkinReplace(.replace(prompt.existingSkinID))
|
||||
}
|
||||
Button("Keep Both") {
|
||||
skinReplacePrompt = nil
|
||||
resumeSkinReplace(.keepBoth)
|
||||
}
|
||||
Button("Cancel", role: .cancel) {
|
||||
skinReplacePrompt = nil
|
||||
resumeSkinReplace(.cancel)
|
||||
}
|
||||
} message: { _ in
|
||||
Text("Replace it, or keep both copies?")
|
||||
}
|
||||
.fullScreenCover(isPresented: $showLayoutEditor) {
|
||||
PadLayoutEditView(
|
||||
onDismiss: { showLayoutEditor = false },
|
||||
@@ -825,10 +895,26 @@ struct VirtualPadSettingsView: View {
|
||||
messages.append("No usable skin files were imported from \(sourceURL.lastPathComponent).")
|
||||
continue
|
||||
}
|
||||
|
||||
var replacingSkinID: String?
|
||||
let intendedName = skinLibrary.intendedDisplayName(forExtractedSkinAt: archiveDirectory)
|
||||
if let existing = skinLibrary.existingImportedSkin(matchingName: intendedName) {
|
||||
switch await askAboutExistingSkin(named: intendedName, existingSkinID: existing.id) {
|
||||
case .replace(let id):
|
||||
replacingSkinID = id
|
||||
case .keepBoth:
|
||||
break
|
||||
case .cancel:
|
||||
messages.append("Kept the existing '\(intendedName)'.")
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
do {
|
||||
let result = try await skinLibrary.importSkin(
|
||||
from: archiveDirectory,
|
||||
originalImportName: sourceURL.lastPathComponent,
|
||||
replacingSkinID: replacingSkinID,
|
||||
layoutPresets: layoutPresets
|
||||
)
|
||||
latestResult = result
|
||||
@@ -844,6 +930,15 @@ struct VirtualPadSettingsView: View {
|
||||
return (message, latestResult)
|
||||
}
|
||||
|
||||
private func askAboutExistingSkin(named name: String, existingSkinID: String) async -> SkinReplaceChoice {
|
||||
skinReplacePrompt = SkinReplacePrompt(name: name, existingSkinID: existingSkinID)
|
||||
return await skinReplaceGate.wait()
|
||||
}
|
||||
|
||||
private func resumeSkinReplace(_ choice: SkinReplaceChoice) {
|
||||
skinReplaceGate.resume(choice)
|
||||
}
|
||||
|
||||
private func isSkinArchive(_ url: URL) -> Bool {
|
||||
let ext = url.pathExtension.lowercased()
|
||||
return ext == "zip" || ext == "skin" || ext == "manic"
|
||||
|
||||
Reference in New Issue
Block a user