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:
J1coding
2026-07-28 20:18:32 +02:00
committed by Jeen
parent 57739f4d9c
commit a42d7c0d62
2 changed files with 121 additions and 0 deletions
@@ -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"