From 3c123b8dcea975a4246d6f3ed2ea7803ab7e560a Mon Sep 17 00:00:00 2001 From: Nick Hamze Date: Wed, 29 Jul 2026 08:26:49 -0500 Subject: [PATCH] Improve Analogue Pocket Core setup --- CHANGELOG.md | 23 + PRIVACY.md | 20 +- SUPPORT.md | 12 +- Sources/SwanSongApp/PocketCoreSetup.swift | 716 +++++++++++++++++- Sources/SwanSongKit/PocketCoreInstaller.swift | 12 +- .../PocketCoreReleaseServiceTests.swift | 231 +++++- .../PocketCoreInstallerTests.swift | 17 +- docs/FAQ.md | 8 +- docs/INSTALL.md | 4 +- docs/wiki/Analogue-Pocket-SD-Setup.md | 54 +- docs/wiki/Home.md | 2 + 11 files changed, 1028 insertions(+), 71 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 241ef1a..46fca5f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,29 @@ records those user-visible changes. Published releases use semantic versioning. ## [Unreleased] +### Added + +- Adds a privacy-safe Analogue Pocket card check with clear layout, Core, + game-count, legacy-folder, and repair findings plus a copyable support + summary that omits card names, paths, device identifiers, game names, and + save data. +- Adds state-aware Pocket actions for first install, update, already-current + verification, and repair, followed by exact next steps on the Pocket. + +### Changed + +- Gives SwanSong Core releases their own strict `core-vX.Y.Z` namespace so + unrelated homebrew published from the same repository is never presented as + a Core update. + +### Fixed + +- Blocks automatic Core downgrades and development-version replacement, and + rechecks the selected card before and after downloading so a changed card + cannot bypass those protections. +- Correctly treats the repository’s current SWANFRAME release as unrelated + homebrew instead of a broken or mutable SwanSong Core release. + ## [0.9.1] - 2026-07-28 **A Better Welcome.** SwanSong 0.9.1 makes the first launch clearer, the diff --git a/PRIVACY.md b/PRIVACY.md index 7a75fbf..4484701 100644 --- a/PRIVACY.md +++ b/PRIVACY.md @@ -45,8 +45,13 @@ identity, saves, and states. When you choose an Analogue Pocket SD card, SwanSong reads the mounted volume's name, filesystem type, capacity, top-level folder names, installed SwanSong `core.json` version (if present), and only the Core-managed files that a -verified package may replace. It does not read or upload game, save, Memory, -Settings, or Preset contents from the card. +verified package may replace. If you choose **Check Card**, it also reads the +names, file types, and extensions of bounded entries below the standard +WonderSwan game folder to count regular `.ws` and `.wsc` files; it does not +open or hash them and does not follow symbolic links. The copyable summary +omits the card name, path, device identifier, game filenames, and save data. +SwanSong does not read or upload game contents, save contents, Memories, +Settings, or Presets from the card. In Cartridge Lab, when you choose a flash-cartridge SD-card folder, SwanSong reads that folder only to select a non-conflicting installer filename, writes @@ -245,11 +250,12 @@ install games, or invoke the separate Analogue Pocket installer. The **Analogue Pocket** tool makes no request at launch, in the background, or merely because you open its page. Choosing **Check for Core Release** requests -the official `RegionallyFamous/swansong-core` latest-release record through the -GitHub HTTPS API. If an immutable authorized stable release exists, SwanSong -then fetches that release's small `release-manifest.json` and `SHA256SUMS` -assets. Choosing and confirming **Prepare SD Card** downloads the named Core ZIP -from the same official GitHub Release. +the official `RegionallyFamous/swansong-core` release list through the GitHub +HTTPS API. SwanSong ignores releases outside the stable `core-vX.Y.Z` +namespace. If an immutable authorized Core release exists, SwanSong then +fetches that release's small `release-manifest.json` and `SHA256SUMS` assets. +Confirming an install, update, verification, or repair downloads the named Core +ZIP from the same official GitHub Release. The manifest's publisher, release authorization, completed release gates, package identity, byte count, and SHA-256 must agree with the GitHub release diff --git a/SUPPORT.md b/SUPPORT.md index ad9315d..fcefcf8 100644 --- a/SUPPORT.md +++ b/SUPPORT.md @@ -85,13 +85,19 @@ Pocket SD Card…**. Then: 1. Find the official SwanSong Core. 2. Choose the mounted SD card itself. -3. Review the exact card and version, then install. +3. Review the exact card, installed version, available version, and proposed + action. +4. Optionally choose **Check Card**. If you need help, copy its privacy-safe + summary. +5. Install, update, verify or repair, or repair as offered. Use an exFAT or FAT32 card and back it up first. SwanSong works with blank and existing Pocket cards. It does not format the card or remove games, saves, settings, Memories, Presets, or other cores. It checks the available space, -verifies the files after writing them, and rolls back if the install cannot be -confirmed. Eject the card in Finder before removing it. +blocks automatic downgrades, verifies the files after writing them, and rolls +back if the install cannot be confirmed. The card-check summary omits card +names, paths, device identifiers, game names, and save data. Eject the card in +Finder before removing it. ### How do I read my own cartridge? diff --git a/Sources/SwanSongApp/PocketCoreSetup.swift b/Sources/SwanSongApp/PocketCoreSetup.swift index cf4ef92..d938b29 100644 --- a/Sources/SwanSongApp/PocketCoreSetup.swift +++ b/Sources/SwanSongApp/PocketCoreSetup.swift @@ -19,6 +19,46 @@ struct AvailablePocketCoreRelease: Equatable, Sendable { let releasePageURL: URL } +struct PocketCoreVersion: Comparable, Equatable, Hashable, Sendable { + let major: Int + let minor: Int + let patch: Int + + init?(_ value: String) { + let components = value.split(separator: ".", omittingEmptySubsequences: false) + guard components.count == 3 else { return nil } + let numbers = components.compactMap { component -> Int? in + guard !component.isEmpty, + component.allSatisfy(\.isNumber), + component == "0" || component.first != "0" else { + return nil + } + return Int(component) + } + guard numbers.count == 3 else { return nil } + major = numbers[0] + minor = numbers[1] + patch = numbers[2] + } + + init?(releaseTag: String) { + let prefix = "core-v" + guard releaseTag.hasPrefix(prefix) else { return nil } + self.init(String(releaseTag.dropFirst(prefix.count))) + guard releaseTag == "core-v\(description)" else { return nil } + } + + var description: String { + "\(major).\(minor).\(patch)" + } + + static func < (lhs: PocketCoreVersion, rhs: PocketCoreVersion) -> Bool { + if lhs.major != rhs.major { return lhs.major < rhs.major } + if lhs.minor != rhs.minor { return lhs.minor < rhs.minor } + return lhs.patch < rhs.patch + } +} + enum PocketCoreReleaseServiceError: LocalizedError { case invalidResponse(String) case missingAsset(String) @@ -301,8 +341,8 @@ private final class PocketCoreDownloadDelegate: NSObject, URLSessionDataDelegate } struct PocketCoreReleaseService: Sendable { - static let latestReleaseURL = URL( - string: "https://api.github.com/repos/RegionallyFamous/swansong-core/releases/latest" + static let releaseListURL = URL( + string: "https://api.github.com/repos/RegionallyFamous/swansong-core/releases?per_page=30" )! private static let maximumReleaseMetadataBytes = 2 * 1_024 * 1_024 private static let maximumPackageBytes = 32 * 1_024 * 1_024 @@ -368,7 +408,7 @@ struct PocketCoreReleaseService: Sendable { } func latestRelease() async throws -> AvailablePocketCoreRelease? { - var request = URLRequest(url: Self.latestReleaseURL) + var request = URLRequest(url: Self.releaseListURL) request.setValue("application/vnd.github+json", forHTTPHeaderField: "Accept") request.setValue("identity", forHTTPHeaderField: "Accept-Encoding") request.setValue("SwanSong-Desktop", forHTTPHeaderField: "User-Agent") @@ -384,20 +424,43 @@ struct PocketCoreReleaseService: Sendable { } let data = response.data - let release: GitHubRelease + let releases: [GitHubRelease] do { - release = try JSONDecoder().decode(GitHubRelease.self, from: data) + releases = try JSONDecoder().decode([GitHubRelease].self, from: data) } catch { throw PocketCoreReleaseServiceError.invalidResponse( "The release metadata is malformed." ) } - guard !release.draft, !release.prerelease else { + + var candidates: [(release: GitHubRelease, version: PocketCoreVersion)] = [] + for release in releases where !release.draft && !release.prerelease { + if release.tagName.hasPrefix("core-v"), + PocketCoreVersion(releaseTag: release.tagName) == nil { + throw PocketCoreReleaseServiceError.invalidResponse( + "An official Core release has a malformed version tag." + ) + } + guard let version = PocketCoreVersion(releaseTag: release.tagName) else { + continue + } + candidates.append((release, version)) + } + guard !candidates.isEmpty else { + return nil + } + guard Set(candidates.map(\.version)).count == candidates.count else { + throw PocketCoreReleaseServiceError.invalidResponse( + "The repository contains duplicate official Core release versions." + ) + } + guard let selected = candidates.max(by: { $0.version < $1.version }) else { return nil } + let release = selected.release guard release.immutable else { throw PocketCoreReleaseServiceError.invalidResponse( - "The stable release is not protected as an immutable GitHub Release." + "The latest official Core release is not protected as an immutable GitHub Release." ) } try validateReleasePageURL(release.htmlURL, releaseTag: release.tagName) @@ -821,6 +884,94 @@ struct PocketCardSelection: Equatable, Sendable { let availableByteCount: Int64? let hasPocketLayout: Bool let installedCoreVersion: String? + let coreInstallationNeedsRepair: Bool +} + +enum PocketCoreInstallAction: Equatable, Sendable { + case unavailable + case install(version: String) + case update(installed: String, available: String) + case verifyOrRepair(version: String) + case repair(version: String) + case blockedNewer(installed: String, available: String) + case blockedUnrecognized(installed: String) + + var canProceed: Bool { + switch self { + case .install, .update, .verifyOrRepair, .repair: + true + case .unavailable, .blockedNewer, .blockedUnrecognized: + false + } + } +} + +struct PocketCoreInstallationInspection: Equatable, Sendable { + let version: String? + let needsRepair: Bool +} + +enum PocketCardHealthSeverity: Int, Comparable, Sendable { + case ready + case attention + case problem + + static func < ( + lhs: PocketCardHealthSeverity, + rhs: PocketCardHealthSeverity + ) -> Bool { + lhs.rawValue < rhs.rawValue + } +} + +struct PocketCardHealthFinding: Equatable, Sendable { + let severity: PocketCardHealthSeverity + let message: String +} + +struct PocketCardHealthReport: Equatable, Sendable { + let checkedAt: Date + let hasPocketLayout: Bool + let formatDescription: String + let availableByteCount: Int64? + let coreVersion: String? + let gameCount: Int + let findings: [PocketCardHealthFinding] + + var severity: PocketCardHealthSeverity { + findings.map(\.severity).max() ?? .ready + } + + var supportSummary: String { + let status: String + switch severity { + case .ready: status = "Ready" + case .attention: status = "Needs attention" + case .problem: status = "Problem found" + } + var lines = [ + "SwanSong Pocket Card Check", + "Status: \(status)", + "Pocket layout: \(hasPocketLayout ? "detected" : "blank card")", + "File system: \(formatDescription)", + "SwanSong Core: \(coreVersion ?? "not detected")", + "WonderSwan games found: \(gameCount)", + ] + if let availableByteCount { + lines.append( + "Free space: " + + ByteCountFormatter.string( + fromByteCount: availableByteCount, + countStyle: .file + ) + ) + } + lines.append(contentsOf: findings.map { "- \($0.message)" }) + lines.append( + "Privacy: no card name, path, device identifier, game names, or save data included." + ) + return lines.joined(separator: "\n") + } } enum PocketCardInspector { @@ -897,6 +1048,7 @@ enum PocketCardInspector { } else { mountIdentity = try PocketVolumeInspection.mountIdentity(at: root) } + let installation = coreInstallation(on: root) return PocketCardSelection( rootURL: root, volumeName: values.volumeName ?? root.lastPathComponent, @@ -904,11 +1056,31 @@ enum PocketCardInspector { formatDescription: formatDescription, availableByteCount: try PocketVolumeInspection.availableByteCount(at: root), hasPocketLayout: hasPocketLayout, - installedCoreVersion: installedVersion(on: root) + installedCoreVersion: installation.version, + coreInstallationNeedsRepair: installation.needsRepair ) } - private static func installedVersion(on root: URL) -> String? { + static func coreInstallation(on root: URL) -> PocketCoreInstallationInspection { + let coreRoot = root.appendingPathComponent( + "Cores/RegionallyFamous.SwanSong", + isDirectory: true + ) + var isDirectory: ObjCBool = false + guard FileManager.default.fileExists( + atPath: coreRoot.path, + isDirectory: &isDirectory + ) else { + return PocketCoreInstallationInspection(version: nil, needsRepair: false) + } + guard isDirectory.boolValue, + let rootValues = try? coreRoot.resourceValues( + forKeys: [.isDirectoryKey, .isSymbolicLinkKey] + ), + rootValues.isDirectory == true, + rootValues.isSymbolicLink != true else { + return PocketCoreInstallationInspection(version: nil, needsRepair: true) + } let coreJSON = root.appendingPathComponent( "Cores/RegionallyFamous.SwanSong/core.json" ) @@ -922,8 +1094,246 @@ enum PocketCardInspector { let data = try? Data(contentsOf: coreJSON), let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any], let core = object["core"] as? [String: Any], - let metadata = core["metadata"] as? [String: Any] else { return nil } - return metadata["version"] as? String + let metadata = core["metadata"] as? [String: Any], + let version = metadata["version"] as? String, + !version.isEmpty else { + return PocketCoreInstallationInspection(version: nil, needsRepair: true) + } + return PocketCoreInstallationInspection(version: version, needsRepair: false) + } +} + +enum PocketCardHealthChecker { + private static let requiredCoreFiles = [ + "audio.json", "core.json", "data.json", "icon.bin", "info.txt", + "input.json", "interact.json", "variants.json", "video.json", + ] + + static func check(_ card: PocketCardSelection) -> PocketCardHealthReport { + let root = card.rootURL + var findings: [PocketCardHealthFinding] = [] + + if !card.hasPocketLayout { + findings.append( + PocketCardHealthFinding( + severity: .attention, + message: "This is a blank card; Pocket folders will be created during installation." + ) + ) + } + if card.coreInstallationNeedsRepair { + findings.append( + PocketCardHealthFinding( + severity: .problem, + message: "The existing SwanSong Core installation is incomplete or unreadable." + ) + ) + } else if card.installedCoreVersion == nil { + findings.append( + PocketCardHealthFinding( + severity: .attention, + message: "SwanSong Core is not installed yet." + ) + ) + } else { + inspectInstalledCore(on: root, findings: &findings) + } + + let oldCore = root.appendingPathComponent( + "Cores/agg23.WonderSwan", + isDirectory: true + ) + if ordinaryDirectoryExists(at: oldCore) { + findings.append( + PocketCardHealthFinding( + severity: .attention, + message: "An older WonderSwan core is also installed; keep it only if you still use it." + ) + ) + } + + let oldSaves = root.appendingPathComponent( + "Saves/wonderswan/common", + isDirectory: true + ) + if ordinaryDirectoryExists(at: oldSaves) { + findings.append( + PocketCardHealthFinding( + severity: .attention, + message: "A legacy WonderSwan save folder is present. Back up saves before reorganizing them." + ) + ) + } + + let gameInventory = countGames( + in: root.appendingPathComponent( + "Assets/wonderswan/common", + isDirectory: true + ) + ) + if gameInventory.reachedSafetyLimit { + findings.append( + PocketCardHealthFinding( + severity: .attention, + message: "The game count stopped at its safety limit; the card may contain additional games." + ) + ) + } + if gameInventory.count == 0 { + findings.append( + PocketCardHealthFinding( + severity: .attention, + message: "No .ws or .wsc game files were found in the standard WonderSwan folder." + ) + ) + } + if findings.isEmpty { + findings.append( + PocketCardHealthFinding( + severity: .ready, + message: "The card layout and SwanSong Core files look ready." + ) + ) + } + return PocketCardHealthReport( + checkedAt: Date(), + hasPocketLayout: card.hasPocketLayout, + formatDescription: card.formatDescription, + availableByteCount: card.availableByteCount, + coreVersion: card.installedCoreVersion, + gameCount: gameInventory.count, + findings: findings + ) + } + + private static func inspectInstalledCore( + on root: URL, + findings: inout [PocketCardHealthFinding] + ) { + let coreRoot = root.appendingPathComponent( + "Cores/RegionallyFamous.SwanSong", + isDirectory: true + ) + let missing = requiredCoreFiles.filter { + !ordinaryFileExists(at: coreRoot.appendingPathComponent($0)) + } + if !missing.isEmpty { + findings.append( + PocketCardHealthFinding( + severity: .problem, + message: "The SwanSong Core folder is missing \(missing.count) required file\(missing.count == 1 ? "" : "s")." + ) + ) + } + let sharedFiles = [ + root.appendingPathComponent("Platforms/wonderswan.json"), + root.appendingPathComponent("Platforms/_images/wonderswan.bin"), + ] + if sharedFiles.contains(where: { !ordinaryFileExists(at: $0) }) { + findings.append( + PocketCardHealthFinding( + severity: .problem, + message: "The shared WonderSwan platform definition or artwork is missing." + ) + ) + } + let coreJSON = coreRoot.appendingPathComponent("core.json") + guard let values = try? coreJSON.resourceValues( + forKeys: [.isRegularFileKey, .isSymbolicLinkKey, .fileSizeKey] + ), + values.isRegularFile == true, + values.isSymbolicLink != true, + let size = values.fileSize, + size <= 256 * 1_024, + let data = try? Data(contentsOf: coreJSON), + let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let core = object["core"] as? [String: Any], + let framework = core["framework"] as? [String: Any], + let bitstreams = core["cores"] as? [[String: Any]], + let bitstream = bitstreams.first?["filename"] as? String, + let chip32 = framework["chip32_vm"] as? String else { + return + } + let runtimeFiles = [bitstream, chip32] + let missingRuntimeFiles = runtimeFiles.filter { + !ordinaryFileExists(at: coreRoot.appendingPathComponent($0)) + } + if !missingRuntimeFiles.isEmpty { + findings.append( + PocketCardHealthFinding( + severity: .problem, + message: "The SwanSong Core runtime is incomplete and should be repaired." + ) + ) + } + } + + private static func countGames( + in directory: URL + ) -> (count: Int, reachedSafetyLimit: Bool) { + guard ordinaryDirectoryExists(at: directory) else { + return (0, false) + } + let maximumEntries = 20_000 + let maximumDepth = 16 + var pending: [(url: URL, depth: Int)] = [(directory, 0)] + var inspectedEntryCount = 0 + var gameCount = 0 + + while let next = pending.popLast() { + guard let entries = try? FileManager.default.contentsOfDirectory( + at: next.url, + includingPropertiesForKeys: [ + .isDirectoryKey, .isRegularFileKey, .isSymbolicLinkKey, + ], + options: [.skipsHiddenFiles] + ) else { + continue + } + for entry in entries { + inspectedEntryCount += 1 + guard inspectedEntryCount <= maximumEntries else { + return (gameCount, true) + } + guard let values = try? entry.resourceValues( + forKeys: [ + .isDirectoryKey, .isRegularFileKey, .isSymbolicLinkKey, + ] + ), + values.isSymbolicLink != true else { + continue + } + if values.isDirectory == true { + if next.depth < maximumDepth { + pending.append((entry, next.depth + 1)) + } + continue + } + let ext = entry.pathExtension.lowercased() + if values.isRegularFile == true && (ext == "ws" || ext == "wsc") { + gameCount += 1 + } + } + } + return (gameCount, false) + } + + private static func ordinaryDirectoryExists(at url: URL) -> Bool { + guard let values = try? url.resourceValues( + forKeys: [.isDirectoryKey, .isSymbolicLinkKey] + ) else { + return false + } + return values.isDirectory == true && values.isSymbolicLink != true + } + + private static func ordinaryFileExists(at url: URL) -> Bool { + guard let values = try? url.resourceValues( + forKeys: [.isRegularFileKey, .isSymbolicLinkKey] + ) else { + return false + } + return values.isRegularFile == true && values.isSymbolicLink != true } } @@ -933,10 +1343,13 @@ final class PocketCoreSetupModel { var release: AvailablePocketCoreRelease? var card: PocketCardSelection? var isChecking = false + var isCheckingCard = false var isPreparing = false var noReleaseIsPublished = false var errorMessage: String? var result: PocketCoreInstallResult? + var healthReport: PocketCardHealthReport? + var supportSummaryWasCopied = false private let service: PocketCoreReleaseService @@ -945,7 +1358,38 @@ final class PocketCoreSetupModel { } var canPrepare: Bool { - release != nil && card != nil && !isChecking && !isPreparing + installAction.canProceed && !isChecking && !isCheckingCard && !isPreparing + } + + var installAction: PocketCoreInstallAction { + guard let release, let card else { return .unavailable } + return Self.installAction(release: release, card: card) + } + + private static func installAction( + release: AvailablePocketCoreRelease, + card: PocketCardSelection + ) -> PocketCoreInstallAction { + let available = release.metadata.version + if card.coreInstallationNeedsRepair { + return .repair(version: available) + } + guard let installed = card.installedCoreVersion else { + return .install(version: available) + } + guard let installedVersion = PocketCoreVersion(installed) else { + return .blockedUnrecognized(installed: installed) + } + guard let availableVersion = PocketCoreVersion(available) else { + return .unavailable + } + if installedVersion < availableVersion { + return .update(installed: installed, available: available) + } + if installedVersion == availableVersion { + return .verifyOrRepair(version: available) + } + return .blockedNewer(installed: installed, available: available) } func checkForRelease() { @@ -967,7 +1411,7 @@ final class PocketCoreSetupModel { } func chooseCard() { - guard !isPreparing else { return } + guard !isPreparing, !isCheckingCard else { return } let panel = NSOpenPanel() panel.title = "Choose the Analogue Pocket SD Card" panel.message = "Choose the mounted card itself. SwanSong merges only verified core files and does not erase games, saves, settings, or other cores." @@ -983,6 +1427,8 @@ final class PocketCoreSetupModel { card = try PocketCardInspector.inspect(url) errorMessage = nil result = nil + healthReport = nil + supportSummaryWasCopied = false } catch { errorMessage = error.localizedDescription } @@ -998,9 +1444,11 @@ final class PocketCoreSetupModel { do { let currentCard = try PocketCardInspector.inspect(card.rootURL) try requireSameCard(card, currentCard) + try requireInstallAllowed(release: release, card: currentCard) let package = try await service.downloadPackage(release) let readyCard = try PocketCardInspector.inspect(card.rootURL) try requireSameCard(currentCard, readyCard) + try requireInstallAllowed(release: release, card: readyCard) let destination = readyCard.rootURL result = try await Task.detached(priority: .userInitiated) { try PocketCoreCardPreparer().apply( @@ -1009,12 +1457,39 @@ final class PocketCoreSetupModel { ) }.value self.card = try? PocketCardInspector.inspect(destination) + if let updatedCard = self.card { + healthReport = await Task.detached(priority: .utility) { + PocketCardHealthChecker.check(updatedCard) + }.value + } } catch { errorMessage = error.localizedDescription } } } + func checkCardHealth() { + guard let card, !isPreparing, !isCheckingCard else { return } + isCheckingCard = true + supportSummaryWasCopied = false + Task { + defer { isCheckingCard = false } + healthReport = await Task.detached(priority: .utility) { + PocketCardHealthChecker.check(card) + }.value + } + } + + func copySupportSummary() { + guard let healthReport else { return } + NSPasteboard.general.clearContents() + NSPasteboard.general.setString( + healthReport.supportSummary, + forType: .string + ) + supportSummaryWasCopied = true + } + private func requireSameCard( _ expected: PocketCardSelection, _ observed: PocketCardSelection @@ -1027,6 +1502,28 @@ final class PocketCoreSetupModel { ) } } + + private func requireInstallAllowed( + release: AvailablePocketCoreRelease, + card: PocketCardSelection + ) throws { + switch Self.installAction(release: release, card: card) { + case .install, .update, .verifyOrRepair, .repair: + return + case let .blockedNewer(installed, available): + throw PocketCoreInstallerError.invalidDestination( + "This card changed and now has SwanSong Core \(installed), which is newer than official version \(available). Automatic downgrades are blocked." + ) + case let .blockedUnrecognized(installed): + throw PocketCoreInstallerError.invalidDestination( + "This card changed and now has development or unrecognized SwanSong Core \(installed). Choose the card again after reviewing it." + ) + case .unavailable: + throw PocketCoreInstallerError.invalidDestination( + "The Core version on this card cannot be safely compared with the official release." + ) + } + } } struct PocketCoreSetupView: View { @@ -1055,15 +1552,15 @@ struct PocketCoreSetupView: View { .navigationTitle("Analogue Pocket") .accessibilityIdentifier(PocketCoreSetupAccessibility.page) .alert( - "Install on \(setup.card?.volumeName ?? "SD Card")?", + confirmationTitle, isPresented: $showsPrepareConfirmation ) { Button("Cancel", role: .cancel) {} - Button("Install SwanSong Core") { + Button(confirmationButtonTitle) { setup.prepareCard() } } message: { - Text("SwanSong will install verified Core \(setup.release?.metadata.version ?? "") on \(setup.card?.rootURL.path ?? "the selected card"). Your games, saves, settings, and other cores stay in place. Back up the card first.") + Text(confirmationMessage) } } @@ -1092,7 +1589,7 @@ struct PocketCoreSetupView: View { symbol: "checkmark.seal.fill", tint: .green, title: "SwanSong Core \(release.metadata.version)", - detail: "Released \(release.metadata.releaseDate) · package signature and checksum verified" + detail: "Released \(release.metadata.releaseDate) · authorization and checksum verified" ) HStack { Button("Check Again", action: setup.checkForRelease) @@ -1136,6 +1633,30 @@ struct PocketCoreSetupView: View { .font(.caption.monospaced()) .foregroundStyle(.secondary) .textSelection(.enabled) + HStack { + if setup.isCheckingCard { + ProgressView() + .controlSize(.small) + Text("Checking card…") + .foregroundStyle(.secondary) + } else { + Button("Check Card") { + setup.checkCardHealth() + } + } + if setup.healthReport != nil { + Button( + setup.supportSummaryWasCopied + ? "Copy Support Summary Again" + : "Copy Support Summary" + ) { + setup.copySupportSummary() + } + } + } + if let report = setup.healthReport { + healthReport(report) + } } else { Text("Use a blank or existing Pocket card formatted as exFAT or FAT32.") .foregroundStyle(.secondary) @@ -1143,13 +1664,13 @@ struct PocketCoreSetupView: View { Button(setup.card == nil ? "Choose SD Card…" : "Choose Another Card…") { setup.chooseCard() } - .disabled(setup.isPreparing) + .disabled(setup.isPreparing || setup.isCheckingCard) .accessibilityIdentifier(PocketCoreSetupAccessibility.chooseCard) } } private var prepareCard: some View { - stepCard(number: 3, title: "Review and install") { + stepCard(number: 3, title: "Review and prepare") { if setup.isPreparing { HStack(spacing: 10) { ProgressView() @@ -1162,19 +1683,26 @@ struct PocketCoreSetupView: View { statusRow( symbol: "checkmark.circle.fill", tint: .green, - title: "SD card is ready", - detail: "SwanSong Core \(result.version) verified · \(result.installedFileCount) file\(result.installedFileCount == 1 ? "" : "s") installed" + title: result.installedFileCount == 0 + ? "SwanSong Core is verified" + : "SD card is ready", + detail: completionDetail(result) ) Button("Show Card in Finder") { NSWorkspace.shared.open(result.destinationURL) } - Text("Eject the card in Finder before removing it from the Mac.") + VStack(alignment: .leading, spacing: 5) { + Text("Next on your Pocket") + .font(.subheadline.bold()) + Text("1. Eject the card in Finder, then return it to your Pocket.") + Text("2. Open openFPGA, choose WonderSwan, then choose SwanSong.") + Text("3. Pick a .ws or .wsc game from Assets/wonderswan/common.") + } .font(.caption) .foregroundStyle(.secondary) } else { - Text("SwanSong shows the exact card and Core version before it changes anything.") - .foregroundStyle(.secondary) - Button("Install SwanSong Core…") { + installActionMessage + Button(installButtonTitle) { showsPrepareConfirmation = true } .buttonStyle(.borderedProminent) @@ -1249,7 +1777,9 @@ struct PocketCoreSetupView: View { private func cardDetail(_ card: PocketCardSelection) -> String { var details = [card.formatDescription] details.append(card.hasPocketLayout ? "existing Pocket layout" : "blank card") - if let installed = card.installedCoreVersion { + if card.coreInstallationNeedsRepair { + details.append("SwanSong Core needs repair") + } else if let installed = card.installedCoreVersion { details.append("SwanSong Core \(installed) installed") } if let bytes = card.availableByteCount { @@ -1257,4 +1787,138 @@ struct PocketCoreSetupView: View { } return details.joined(separator: " · ") } + + @ViewBuilder + private var installActionMessage: some View { + switch setup.installAction { + case .unavailable: + Text("Choose a card and find an official Core release to continue.") + .foregroundStyle(.secondary) + case let .install(version): + Text("SwanSong Core \(version) will be added to this card.") + .foregroundStyle(.secondary) + case let .update(installed, available): + Text("Update SwanSong Core \(installed) to \(available). Your games, saves, settings, and other cores stay in place.") + .foregroundStyle(.secondary) + case let .verifyOrRepair(version): + Text("SwanSong Core \(version) is already installed. Verify every official file and replace only anything missing or changed.") + .foregroundStyle(.secondary) + case let .repair(version): + Text("The existing SwanSong Core installation is incomplete or unreadable. Repair it with verified version \(version).") + .foregroundStyle(.orange) + case let .blockedNewer(installed, available): + Label( + "This card has SwanSong Core \(installed), which is newer than official version \(available). Automatic downgrades are blocked.", + systemImage: "exclamationmark.shield.fill" + ) + .foregroundStyle(.orange) + case let .blockedUnrecognized(installed): + Label( + "SwanSong Core \(installed) is a development or unrecognized version. Back up the card and review that installation before replacing it with a stable release.", + systemImage: "exclamationmark.shield.fill" + ) + .foregroundStyle(.orange) + } + } + + private var installButtonTitle: String { + switch setup.installAction { + case .install: + "Install SwanSong Core…" + case .update: + "Update SwanSong Core…" + case .verifyOrRepair: + "Verify or Repair…" + case .repair: + "Repair SwanSong Core…" + case .unavailable, .blockedNewer, .blockedUnrecognized: + "Install Unavailable" + } + } + + private var confirmationTitle: String { + let card = setup.card?.volumeName ?? "SD Card" + return switch setup.installAction { + case .update: + "Update \(card)?" + case .verifyOrRepair: + "Verify \(card)?" + case .repair: + "Repair \(card)?" + case .install, .unavailable, .blockedNewer, .blockedUnrecognized: + "Install on \(card)?" + } + } + + private var confirmationButtonTitle: String { + switch setup.installAction { + case .update: + "Update Core" + case .verifyOrRepair: + "Verify or Repair" + case .repair: + "Repair Core" + case .install, .unavailable, .blockedNewer, .blockedUnrecognized: + "Install Core" + } + } + + private var confirmationMessage: String { + let version = setup.release?.metadata.version ?? "" + let path = setup.card?.rootURL.path ?? "the selected card" + return "SwanSong will use verified Core \(version) files on \(path). Your games, saves, settings, and other cores stay in place. Back up the card first." + } + + private func completionDetail(_ result: PocketCoreInstallResult) -> String { + if result.installedFileCount == 0 { + return "Every official file for SwanSong Core \(result.version) already matched." + } + return "SwanSong Core \(result.version) verified · \(result.installedFileCount) file\(result.installedFileCount == 1 ? "" : "s") installed" + } + + private func healthReport(_ report: PocketCardHealthReport) -> some View { + VStack(alignment: .leading, spacing: 8) { + Label( + healthTitle(report.severity), + systemImage: healthSymbol(report.severity) + ) + .font(.subheadline.bold()) + .foregroundStyle(healthTint(report.severity)) + ForEach(Array(report.findings.enumerated()), id: \.offset) { _, finding in + Text("• \(finding.message)") + .font(.caption) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + Text("The support summary contains counts and status only—never your card name, path, device ID, game names, or save data.") + .font(.caption2) + .foregroundStyle(.tertiary) + } + .padding(12) + .swanSurface(.recessed, tint: healthTint(report.severity), cornerRadius: 10) + } + + private func healthTitle(_ severity: PocketCardHealthSeverity) -> String { + switch severity { + case .ready: "Card check passed" + case .attention: "Card check found a few notes" + case .problem: "Card check found a problem" + } + } + + private func healthSymbol(_ severity: PocketCardHealthSeverity) -> String { + switch severity { + case .ready: "checkmark.circle.fill" + case .attention: "info.circle.fill" + case .problem: "exclamationmark.triangle.fill" + } + } + + private func healthTint(_ severity: PocketCardHealthSeverity) -> Color { + switch severity { + case .ready: .green + case .attention: .orange + case .problem: .red + } + } } diff --git a/Sources/SwanSongKit/PocketCoreInstaller.swift b/Sources/SwanSongKit/PocketCoreInstaller.swift index 28ba45a..87eacbd 100644 --- a/Sources/SwanSongKit/PocketCoreInstaller.swift +++ b/Sources/SwanSongKit/PocketCoreInstaller.swift @@ -141,6 +141,13 @@ public enum PocketCoreReleaseVerifier { } let version = try requiredText(manifest["version"], name: "version", maximum: 64) + guard version.wholeMatch( + of: /(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)/ + ) != nil else { + throw PocketCoreInstallerError.invalidRelease( + "the version is not a stable semantic version." + ) + } let releaseDate = try requiredText( manifest["date_release"], name: "release date", @@ -151,10 +158,7 @@ public enum PocketCoreReleaseVerifier { "the release date is malformed." ) } - let normalizedTag = githubTag.hasPrefix("v") - ? String(githubTag.dropFirst()) - : githubTag - guard normalizedTag == version else { + guard githubTag == "core-v\(version)" else { throw PocketCoreInstallerError.invalidRelease( "the GitHub tag does not match manifest version \(version)." ) diff --git a/Tests/SwanSongAppSnapshotTests/PocketCoreReleaseServiceTests.swift b/Tests/SwanSongAppSnapshotTests/PocketCoreReleaseServiceTests.swift index 04d4f5e..8cf86d5 100644 --- a/Tests/SwanSongAppSnapshotTests/PocketCoreReleaseServiceTests.swift +++ b/Tests/SwanSongAppSnapshotTests/PocketCoreReleaseServiceTests.swift @@ -30,7 +30,7 @@ final class PocketCoreReleaseServiceTests: XCTestCase { XCTAssertEqual(PocketReleaseURLProtocol.requestCount, 1) XCTAssertEqual( PocketReleaseURLProtocol.lastRequest?.url, - PocketCoreReleaseService.latestReleaseURL + PocketCoreReleaseService.releaseListURL ) XCTAssertEqual( PocketReleaseURLProtocol.lastRequest?.value(forHTTPHeaderField: "User-Agent"), @@ -40,7 +40,7 @@ final class PocketCoreReleaseServiceTests: XCTestCase { func testMutableStableReleaseFailsBeforeAnyAssetRequest() async { let body = Data( - #"{"tag_name":"1.0.0","html_url":"https://github.com/RegionallyFamous/swansong-core/releases/tag/1.0.0","draft":false,"prerelease":false,"immutable":false,"assets":[]}"# + #"[{"tag_name":"core-v1.0.0","html_url":"https://github.com/RegionallyFamous/swansong-core/releases/tag/core-v1.0.0","draft":false,"prerelease":false,"immutable":false,"assets":[]}]"# .utf8 ) PocketReleaseURLProtocol.respond(statusCode: 200, body: body) @@ -54,6 +54,35 @@ final class PocketCoreReleaseServiceTests: XCTestCase { XCTAssertEqual(PocketReleaseURLProtocol.requestCount, 1) } + func testUnrelatedStableHomebrewReleaseIsIgnored() async throws { + let body = Data( + #"[{"tag_name":"swanframe-v0.1.0","html_url":"https://github.com/RegionallyFamous/swansong-core/releases/tag/swanframe-v0.1.0","draft":false,"prerelease":false,"immutable":false,"assets":[]}]"# + .utf8 + ) + PocketReleaseURLProtocol.respond(statusCode: 200, body: body) + + let release = try await makeService().latestRelease() + + XCTAssertNil(release) + XCTAssertEqual(PocketReleaseURLProtocol.requestCount, 1) + } + + func testMalformedOfficialCoreTagIsRejected() async { + let body = Data( + #"[{"tag_name":"core-v01.2.3","html_url":"https://github.com/RegionallyFamous/swansong-core/releases/tag/core-v01.2.3","draft":false,"prerelease":false,"immutable":true,"assets":[]}]"# + .utf8 + ) + PocketReleaseURLProtocol.respond(statusCode: 200, body: body) + + do { + _ = try await makeService().latestRelease() + XCTFail("Malformed official Core tags must not be accepted") + } catch { + XCTAssertTrue(error.localizedDescription.contains("malformed version tag")) + } + XCTAssertEqual(PocketReleaseURLProtocol.requestCount, 1) + } + func testOversizedReleaseMetadataIsStoppedAtTheStreamingLimit() async { PocketReleaseURLProtocol.respond( statusCode: 200, @@ -79,7 +108,7 @@ final class PocketCoreReleaseServiceTests: XCTestCase { ) XCTAssertTrue( PocketCoreReleaseService.productionTrustsRedirectForTesting( - URL(string: "https://github.com/RegionallyFamous/swansong-core/releases/download/v1/core.zip")! + URL(string: "https://github.com/RegionallyFamous/swansong-core/releases/download/core-v1.0.0/core.zip")! ) ) XCTAssertFalse( @@ -101,10 +130,34 @@ final class PocketCoreReleaseServiceTests: XCTestCase { func testVerifiedImmutableReleaseIsAssembledFromBoundedAssets() async throws { let fixture = makeValidReleaseFixture() + var releases = try XCTUnwrap( + JSONSerialization.jsonObject(with: fixture.release) as? [[String: Any]] + ) + releases.insert( + [ + "tag_name": "core-v1.0.0", + "html_url": "https://github.com/RegionallyFamous/swansong-core/releases/tag/core-v1.0.0", + "draft": false, + "prerelease": false, + "immutable": true, + "assets": [], + ], + at: 0 + ) + releases.append( + [ + "tag_name": "swanframe-v99.0.0", + "html_url": "https://github.com/RegionallyFamous/swansong-core/releases/tag/swanframe-v99.0.0", + "draft": false, + "prerelease": false, + "immutable": true, + "assets": [], + ] + ) PocketReleaseURLProtocol.respond( - to: PocketCoreReleaseService.latestReleaseURL, + to: PocketCoreReleaseService.releaseListURL, statusCode: 200, - body: fixture.release + body: try JSONSerialization.data(withJSONObject: releases) ) PocketReleaseURLProtocol.respond( to: fixture.manifestURL, @@ -128,7 +181,7 @@ final class PocketCoreReleaseServiceTests: XCTestCase { func testPackageDownloadRejectsAnIncompleteTransferBeforeExtraction() async { let packageURL = URL( - string: "https://github.com/RegionallyFamous/swansong-core/releases/download/v1.2.3/core.zip" + string: "https://github.com/RegionallyFamous/swansong-core/releases/download/core-v1.2.3/core.zip" )! PocketReleaseURLProtocol.respond( to: packageURL, @@ -144,10 +197,10 @@ final class PocketCoreReleaseServiceTests: XCTestCase { packageByteCount: 3, packageSHA256: String(repeating: "b", count: 64) ), - releaseTag: "v1.2.3", + releaseTag: "core-v1.2.3", packageDownloadURL: packageURL, releasePageURL: URL( - string: "https://github.com/RegionallyFamous/swansong-core/releases/tag/v1.2.3" + string: "https://github.com/RegionallyFamous/swansong-core/releases/tag/core-v1.2.3" )! ) @@ -160,6 +213,119 @@ final class PocketCoreReleaseServiceTests: XCTestCase { XCTAssertEqual(PocketReleaseURLProtocol.requestCount, 1) } + func testOfficialCoreVersionsAreStrictAndComparable() { + XCTAssertEqual(PocketCoreVersion(releaseTag: "core-v1.2.3")?.description, "1.2.3") + XCTAssertNil(PocketCoreVersion(releaseTag: "v1.2.3")) + XCTAssertNil(PocketCoreVersion(releaseTag: "core-v01.2.3")) + XCTAssertNil(PocketCoreVersion("1.2")) + XCTAssertTrue(PocketCoreVersion("1.9.9")! < PocketCoreVersion("2.0.0")!) + } + + func testInstallActionDistinguishesInstallUpdateVerifyRepairAndDowngrade() { + let model = PocketCoreSetupModel(service: makeService()) + model.release = makeAvailableRelease(version: "1.2.3") + + model.card = makeCard(version: nil) + XCTAssertEqual(model.installAction, .install(version: "1.2.3")) + + model.card = makeCard(version: "1.0.0") + XCTAssertEqual( + model.installAction, + .update(installed: "1.0.0", available: "1.2.3") + ) + + model.card = makeCard(version: "1.2.3") + XCTAssertEqual(model.installAction, .verifyOrRepair(version: "1.2.3")) + + model.card = makeCard(version: nil, needsRepair: true) + XCTAssertEqual(model.installAction, .repair(version: "1.2.3")) + + model.card = makeCard(version: "2.0.0") + XCTAssertEqual( + model.installAction, + .blockedNewer(installed: "2.0.0", available: "1.2.3") + ) + XCTAssertFalse(model.canPrepare) + + model.card = makeCard(version: "1.3.0-dev.1") + XCTAssertEqual( + model.installAction, + .blockedUnrecognized(installed: "1.3.0-dev.1") + ) + XCTAssertFalse(model.canPrepare) + } + + func testBrokenExistingCoreIsReportedAsNeedingRepair() throws { + let root = FileManager.default.temporaryDirectory.appendingPathComponent( + "SwanSong-Core-Inspection-\(UUID().uuidString)", + isDirectory: true + ) + defer { try? FileManager.default.removeItem(at: root) } + let coreRoot = root.appendingPathComponent( + "Cores/RegionallyFamous.SwanSong", + isDirectory: true + ) + try FileManager.default.createDirectory( + at: coreRoot, + withIntermediateDirectories: true + ) + try Data("not-json".utf8).write( + to: coreRoot.appendingPathComponent("core.json") + ) + + XCTAssertEqual( + PocketCardInspector.coreInstallation(on: root), + PocketCoreInstallationInspection(version: nil, needsRepair: true) + ) + } + + func testCardHealthSupportSummaryOmitsPrivateIdentifiers() { + let card = makeCard(version: nil) + let report = PocketCardHealthChecker.check(card) + + XCTAssertFalse(report.supportSummary.contains(card.volumeName)) + XCTAssertFalse(report.supportSummary.contains(card.rootURL.path)) + XCTAssertFalse(report.supportSummary.contains(card.mountIdentity)) + XCTAssertTrue(report.supportSummary.contains("Privacy:")) + } + + func testCardHealthCountsGamesInSubfoldersWithoutFollowingSymlinks() throws { + let root = FileManager.default.temporaryDirectory.appendingPathComponent( + "SwanSong-Card-Health-\(UUID().uuidString)", + isDirectory: true + ) + defer { try? FileManager.default.removeItem(at: root) } + let nested = root.appendingPathComponent( + "Assets/wonderswan/common/Favorites", + isDirectory: true + ) + try FileManager.default.createDirectory( + at: nested, + withIntermediateDirectories: true + ) + try Data("game".utf8).write( + to: nested.appendingPathComponent("example.wsc") + ) + try FileManager.default.createSymbolicLink( + at: nested.appendingPathComponent("loop"), + withDestinationURL: root + ) + let card = PocketCardSelection( + rootURL: root, + volumeName: "PRIVATE-CARD-NAME", + mountIdentity: "uuid:PRIVATE-DEVICE-ID", + formatDescription: "ExFAT", + availableByteCount: 1_000_000, + hasPocketLayout: true, + installedCoreVersion: nil, + coreInstallationNeedsRepair: false + ) + + let report = PocketCardHealthChecker.check(card) + + XCTAssertEqual(report.gameCount, 1) + } + private func makeService() -> PocketCoreReleaseService { let configuration = URLSessionConfiguration.ephemeral configuration.protocolClasses = [PocketReleaseURLProtocol.self] @@ -168,6 +334,45 @@ final class PocketCoreReleaseServiceTests: XCTestCase { ) } + private func makeAvailableRelease(version: String) -> AvailablePocketCoreRelease { + AvailablePocketCoreRelease( + metadata: PocketCoreReleaseMetadata( + version: version, + releaseDate: "2026-07-29", + sourceCommit: String(repeating: "a", count: 40), + packageFilename: "core.zip", + packageByteCount: 3, + packageSHA256: String(repeating: "b", count: 64) + ), + releaseTag: "core-v\(version)", + packageDownloadURL: URL( + string: "https://github.com/RegionallyFamous/swansong-core/releases/download/core-v\(version)/core.zip" + )!, + releasePageURL: URL( + string: "https://github.com/RegionallyFamous/swansong-core/releases/tag/core-v\(version)" + )! + ) + } + + private func makeCard( + version: String?, + needsRepair: Bool = false + ) -> PocketCardSelection { + PocketCardSelection( + rootURL: URL( + fileURLWithPath: "/Volumes/PRIVATE-CARD-NAME", + isDirectory: true + ), + volumeName: "PRIVATE-CARD-NAME", + mountIdentity: "uuid:PRIVATE-DEVICE-ID", + formatDescription: "ExFAT", + availableByteCount: 1_000_000, + hasPocketLayout: true, + installedCoreVersion: version, + coreInstallationNeedsRepair: needsRepair + ) + } + private func makeValidReleaseFixture() -> ( release: Data, manifest: Data, @@ -225,13 +430,13 @@ final class PocketCoreReleaseServiceTests: XCTestCase { """.utf8 ) - let base = "https://github.com/RegionallyFamous/swansong-core/releases/download/v1.2.3" + let base = "https://github.com/RegionallyFamous/swansong-core/releases/download/core-v1.2.3" let manifestURL = URL(string: "\(base)/release-manifest.json")! let checksumsURL = URL(string: "\(base)/SHA256SUMS")! let packageURL = URL(string: "\(base)/\(packageFilename)")! let releaseObject: [String: Any] = [ - "tag_name": "v1.2.3", - "html_url": "https://github.com/RegionallyFamous/swansong-core/releases/tag/v1.2.3", + "tag_name": "core-v1.2.3", + "html_url": "https://github.com/RegionallyFamous/swansong-core/releases/tag/core-v1.2.3", "draft": false, "prerelease": false, "immutable": true, @@ -254,7 +459,7 @@ final class PocketCoreReleaseServiceTests: XCTestCase { ], ] return ( - try! JSONSerialization.data(withJSONObject: releaseObject), + try! JSONSerialization.data(withJSONObject: [releaseObject]), manifest, checksums, manifestURL, @@ -319,7 +524,7 @@ private final class PocketReleaseURLProtocol: URLProtocol, @unchecked Sendable { static func respond(statusCode: Int, body: Data) { respond( - to: PocketCoreReleaseService.latestReleaseURL, + to: PocketCoreReleaseService.releaseListURL, statusCode: statusCode, body: body ) diff --git a/Tests/SwanSongKitTests/PocketCoreInstallerTests.swift b/Tests/SwanSongKitTests/PocketCoreInstallerTests.swift index 892951b..c39129b 100644 --- a/Tests/SwanSongKitTests/PocketCoreInstallerTests.swift +++ b/Tests/SwanSongKitTests/PocketCoreInstallerTests.swift @@ -11,7 +11,7 @@ struct PocketCoreInstallerTests { let release = try PocketCoreReleaseVerifier.verify( manifestData: fixture.manifest, checksumsData: fixture.checksums, - githubTag: "v1.2.3" + githubTag: "core-v1.2.3" ) #expect(release.version == "1.2.3") @@ -31,7 +31,20 @@ struct PocketCoreInstallerTests { try PocketCoreReleaseVerifier.verify( manifestData: fixture.manifest, checksumsData: fixture.checksums, - githubTag: "1.2.3" + githubTag: "core-v1.2.3" + ) + } + } + + @Test + func rejectsLegacyUnnamespacedCoreTag() throws { + let fixture = try releaseFixture(package: Data("official-pocket-package".utf8)) + + #expect(throws: PocketCoreInstallerError.self) { + try PocketCoreReleaseVerifier.verify( + manifestData: fixture.manifest, + checksumsData: fixture.checksums, + githubTag: "v1.2.3" ) } } diff --git a/docs/FAQ.md b/docs/FAQ.md index 18864b8..9571ffd 100644 --- a/docs/FAQ.md +++ b/docs/FAQ.md @@ -116,13 +116,19 @@ Yes, through an explicit **Analogue Pocket** SD-card workflow. The FPGA build and its release authorization remain owned by the separate repository at [`RegionallyFamous/swansong-core`](https://github.com/RegionallyFamous/swansong-core). Desktop checks only when asked and will accept only an immutable, authorized -stable release whose manifest, asset size, and SHA-256 all agree. It then +stable `core-vX.Y.Z` release whose manifest, asset size, and SHA-256 all agree; +unrelated homebrew releases from the same repository are ignored. It then merges the verified Core files onto a selected mounted exFAT/FAT32 card, keeps replaced files recoverable until every managed file reads back exactly, and rolls back a failed write. It does not format the card or change games, saves, settings, Memories, Presets, or other cores. The tool performs no write while the Core repository has no verified public release. +The screen distinguishes install, update, verify or repair, and repair. It +blocks automatic downgrades and development-version replacement. **Check +Card** can also create a privacy-safe support summary without card names, +paths, device IDs, game names, or save data. + Installing the Core does not update the macOS app, and Sparkle app updates do not run the Core installer. diff --git a/docs/INSTALL.md b/docs/INSTALL.md index 7e7568a..6d17af2 100644 --- a/docs/INSTALL.md +++ b/docs/INSTALL.md @@ -87,7 +87,9 @@ corresponding-source archive, manifest, and checksums all agree. Open **Analogue Pocket** in SwanSong's sidebar or choose **File → Prepare Analogue Pocket SD Card…**. The workflow is designed to merge an authorized SwanSong Core without formatting the card or changing games, saves, Memories, -settings, Presets, or unrelated cores. +settings, Presets, or unrelated cores. It distinguishes install, update, +verify or repair, and repair; automatic downgrades are blocked. **Check Card** +can produce a privacy-safe support summary before or after installation. Back up the complete card first. Until the separate SwanSong Core project publishes a verified stable release, SwanSong reports that none is available diff --git a/docs/wiki/Analogue-Pocket-SD-Setup.md b/docs/wiki/Analogue-Pocket-SD-Setup.md index fd88f4a..b939fff 100644 --- a/docs/wiki/Analogue-Pocket-SD-Setup.md +++ b/docs/wiki/Analogue-Pocket-SD-Setup.md @@ -14,13 +14,14 @@ Sparkle never invokes the Pocket workflow. ## Current availability -No authorized stable SwanSong Core package is currently published. SwanSong -Current stable and beta releases report that state and perform no package +No authorized stable SwanSong Core package is currently published. Current +stable and beta versions of SwanSong report that state and perform no package download or card write. The workflow unlocks only after `swansong-core` publishes an immutable stable -release that satisfies the embedded authorization policy, package manifest, -asset-size, and SHA-256 requirements. +release tagged exactly `core-vX.Y.Z` that satisfies the embedded authorization +policy, package manifest, asset-size, and SHA-256 requirements. Other releases +from that repository, including `swanframe-vX.Y.Z` homebrew, are ignored. ## User workflow @@ -29,10 +30,15 @@ asset-size, and SHA-256 requirements. 3. Open **Analogue Pocket** in SwanSong's sidebar or choose **File > Prepare Analogue Pocket SD Card…**. 4. Explicitly check for the official stable Core release. -5. Select the mounted card itself and review the exact volume and Core version. -6. Confirm the install. -7. After SwanSong completes its read-back verification, eject the card in - Finder before removing it. +5. Select the mounted card itself and review the exact volume, installed + version, available version, and proposed action. +6. Optionally choose **Check Card** to review the Pocket layout, Core status, + legacy folders, and game count. Copy the privacy-safe support summary if you + need help. +7. Confirm **Install**, **Update**, **Verify or Repair**, or **Repair**. +8. After SwanSong completes its read-back verification, eject the card in + Finder, return it to the Pocket, then open **openFPGA > WonderSwan > + SwanSong**. SwanSong accepts only an ordinary writable volume mounted directly under `/Volumes` whose kernel filesystem is exFAT or FAT32/MS-DOS. The card must be @@ -44,7 +50,8 @@ filesystems are rejected. The release check is manual and has no launch-time or background request. The client accepts only the official `RegionallyFamous/swansong-core` repository's -stable GitHub Releases and binds all of the following before extraction: +stable `core-vX.Y.Z` GitHub Releases and binds all of the following before +extraction: - repository, release, tag, and asset identity; - embedded release authorization policy; @@ -57,6 +64,13 @@ The package download is streamed with a fixed upper bound. A tag, asset, manifest, size, checksum, repository, or policy mismatch fails before the card is modified. +SwanSong compares stable semantic versions before downloading. It offers an +update only when the official version is newer, verifies or repairs an +already-current installation, recognizes an incomplete installation as a +repair, and blocks automatic downgrades or replacement of a development or +unrecognized version. It repeats that comparison immediately before and after +the download in case the card changed. + ## Archive safety The Core archive extractor rejects: @@ -103,13 +117,25 @@ Pocket firmware, games, homebrew ROMs, or BIOS files. It does not change: Installing the Core is separate from the Pocket `.sav` exchange described in [[Playing and Library]]. +## Privacy-safe card check + +**Check Card** reads only the layout needed to diagnose setup: expected Core +and platform files, the installed Core version, the number of regular `.ws` and +`.wsc` files in the standard folder, free space, and known legacy WonderSwan +folders. It does not open or hash game contents or read saves. + +The copyable support summary deliberately omits the card name, mount path, +device identifier, game filenames, and save data. + ## Test coverage -Automated coverage includes release-policy and manifest mismatch, checksum and -size drift, trusted redirects, bounded downloads, malformed JSON, unsafe ZIP -paths, symlinks, resource limits, card eligibility, insufficient space, -identity change before write, successful merge/read-back, post-write mismatch, -rollback, cleanup, and preservation of unrelated card content. +Automated coverage includes Core/homebrew release separation, strict semantic +version tags, release-policy and manifest mismatch, checksum and size drift, +trusted redirects, bounded downloads, malformed JSON, unsafe ZIP paths, +symlinks, resource limits, card eligibility, install/update/verify/repair +selection, downgrade blocking, insufficient space, identity change before +write, successful merge/read-back, post-write mismatch, rollback, cleanup, and +preservation of unrelated card content. Physical release coverage still requires real SD cards, readers, exFAT/FAT32 volumes, Finder eject behavior, and a published Core package exercised on diff --git a/docs/wiki/Home.md b/docs/wiki/Home.md index 5c28a2b..0e74513 100644 --- a/docs/wiki/Home.md +++ b/docs/wiki/Home.md @@ -72,6 +72,8 @@ and Add to Library are choices you make. release onto a selected card without formatting it or touching games, saves, Memories, settings, Presets, or unrelated cores. The workflow remains locked until the separate SwanSong Core project publishes a verified stable release. +It distinguishes install, update, verification, and repair, blocks automatic +downgrades, and offers a privacy-safe card check for support. [[Cartridge Lab]] shows how SwanSong can use a WonderSwan Color or SwanCrystal, Yokoi Boot, and a 3.3 V ExtFriend-compatible adapter to make verified cartridge