diff --git a/.claude/gotchas.md b/.claude/gotchas.md index 601e46bf..e8e0039c 100644 --- a/.claude/gotchas.md +++ b/.claude/gotchas.md @@ -1015,21 +1015,81 @@ case .disconnected(let string, let code): --- -## AppManager — `_install()` context parameter must not be ignored +## AppManager — `_install()` context parameter: MiniStore-only divergence (UNDER REVIEW) -### Always pass `context ?? InstallAppOperationContext(...)` — never create unconditionally +### `context ?? InstallAppOperationContext(...)` does **NOT** match upstream — the 2026-03-28 note was wrong -`_install()` accepts an optional `context: InstallAppOperationContext?` parameter. Callers in `_activate()` and `_backup()` pass a pre-built `appContext` carrying provisioning profiles, installed app references, and inter-operation error state. If `_install()` always creates a new context (ignoring the parameter), all that state is lost and activate/backup/restore operations fail silently. +`_install()` accepts an optional `context: InstallAppOperationContext?`. Current MiniStore +(AppManager.swift:1292) honors it: `let context = context ?? InstallAppOperationContext(...)`. + +**Correction (2026-06-28):** the original claim that this "matches upstream SideStore" is +**false**. Verified directly against both `SideStore/SideStore@develop` and the user's fork +`The-Big-Mini/SideStore@develop`: both create the context **unconditionally** and ignore the +parameter — -**Fixed** (2026-03-28): ```swift -// WRONG — always discards caller's context +// upstream + fork (both): _install always uses a fresh context, parameter ignored let context = InstallAppOperationContext(bundleIdentifier: app.bundleIdentifier, authenticatedContext: group.context) +``` + +There, `restoreContext`/`appContext` are populated **only** by `_activate()`/`_backup()`'s own +completion-mapping closures, never by `_install()`'s internal pipeline. MiniStore's `context ??` +is a self-introduced divergence. + +**Call sites passing a context** (the only ones `context ??` affects): the two real installs +in `_activate`/`_backup` (`context: appContext`) and the AltBackup placeholder install in +`_installBackupApp` (`context: restoreContext`). For the placeholder, the nested install now +writes its `.error`/`.installedApp` onto `restoreContext`, which the later +`BackupAppOperation(.restore)` reads — and `AppOperationContext.error`'s setter propagates to +`group.context`. So a placeholder-install **failure** can short-circuit the restore + the real +reinstall, potentially leaving the row on the AltBackup `1.0` placeholder (BUG-3 shape). + +**Status:** left AS-IS for now. On the happy path `context ??` and always-new are functionally +equivalent (completion mappings populate the contexts identically), and the leak only bites on +the placeholder-failure path — so this is likely **not** the primary cause of the reported +reactivate bug. The actual reactivate fixes shipped 2026-06-28 were the Open-URL-scheme bundle +ID (`ResignAppOperation`) and the SwiftUI banner version staleness (`MyAppsView`). If activate/ +restore misbehavior persists, revisit this: the safest correction is to match upstream by NOT +forwarding `restoreContext` into the placeholder `_install` (give it a fresh throwaway context), +leaving the real-install call sites unchanged. + +--- + +## ResignAppOperation — Open URL scheme must use the customized bundle ID -// CORRECT — matches upstream SideStore -let context = context ?? InstallAppOperationContext(bundleIdentifier: app.bundleIdentifier, authenticatedContext: group.context) +The `sidestore-://` scheme baked into the installed app's `CFBundleURLSchemes` (so the +Open button can launch it) must be built from `context.bundleIdentifier` (the customized/ +remapped ID tracked in CoreData, which `InstalledApp.openAppURL` requests), **not** the on-disk +resigned `ALTApplication.bundleIdentifier`. They differ for remapped IDs (debug builds appending +the team ID, user-customized IDs); a mismatch means the baked scheme ≠ what Open launches → the +Open button silently fails after install/activate. + +```swift +// CORRECT (upstream + fork): ResignAppOperation.swift:120 +let openURL = InstalledApp.openAppURL(for: AnyApp(from: app, bundleId: context.bundleIdentifier)) +// WRONG (was in MiniStore until 2026-06-28): drops the AnyApp wrap → uses app.bundleIdentifier ``` +Fixed 2026-06-28. This was the "Open does not work once reactivated" symptom. + +--- + +## My Apps (SwiftUI) — banner reads NSManagedObject props live; thread them as value inputs + +`InstalledAppBannerRepresentable.updateUIView` reads `app.version`/`app.name` off the +`InstalledApp` (a plain `NSManagedObject`, **not** `@Observable`). Reading them there registers +no SwiftUI dependency, so an in-place CoreData change (e.g. version after a reinstall/reactivate) +does not change any of the row's stored inputs (`app` ref, `isActive`, `now`, `icon`). The +`@FetchRequest` republishes the same `objectID`s, `ForEach(id: \.objectID)` keeps the +reference-equal row, and SwiftUI **skips** `updateUIView` — so the banner kept the stale AltBackup +`1.0` placeholder version until the next `now` clock tick. Fix: pass `version`/`name` as +value-type inputs to `InstalledAppRow` + the representable (mirrors how `icon` is threaded). +Fixed 2026-06-28. This was the "stays on the 1.0 placeholder size" symptom. + +**General rule:** any UIViewRepresentable in a CoreData-driven list that reads managed-object +properties inside `updateUIView` must also receive those properties as value inputs, or it won't +re-render on in-place change. + --- ## AppManager — Extension mismatch check must not block refresh diff --git a/.claude/sidestore-delta.md b/.claude/sidestore-delta.md index 0b44b912..812a5a61 100644 --- a/.claude/sidestore-delta.md +++ b/.claude/sidestore-delta.md @@ -143,3 +143,22 @@ and `sidenightly.json` (nightly), served from this repo's `develop` branch (the | `.github/workflows/*.yml` | CI pipelines are independently maintained | --- + +## Removed by MiniStore + +### Enable JIT + +**Intentionally removed.** MiniStore deleted `AltStore/Operations/EnableJITOperation.swift`, +the `EnableJITContext` protocol, `AppManager.enableJIT(...)`, and the My Apps "Enable JIT" +context-menu + swipe actions. Only inert enum cases remain (`OperationError.enableJIT`, +`LoggedError`), which are harmless. + +The underlying minimuxer primitives are still present (`SideStore/MinimuxerWrapper.swift`: +`debugApp(_:)` → `Minimuxer.debugApp(appId:)`, and `attachDebugger(_:)`), so JIT can be +re-added without touching the Rust bridge. To restore it, port `EnableJITOperation.swift` + +`AppManager.enableJIT` + `enableJITResultNotificationID` from the fork +(`The-Big-Mini/SideStore@develop`), add an `enableJIT` method to `MyAppsViewModel`, and wire +context-menu/swipe actions in `MyAppsView`. Confirmed available 2026-06-28; left out per owner's +decision (must be device-verified before shipping). + +--- diff --git a/AltBackup/AppDelegate.swift b/AltBackup/AppDelegate.swift index 541a753e..b110b022 100644 --- a/AltBackup/AppDelegate.swift +++ b/AltBackup/AppDelegate.swift @@ -144,7 +144,11 @@ private extension AppDelegate "errorDescription": error.localizedDescription].map { URLQueryItem(name: $0, value: $1) } } - guard let responseURL = components.url else { return } + guard let responseURL = components.url else { + logger.error("operationDidFinish: failed to construct response URL — delivering result via local notification only") + fireCompletionNotification(result: result) + return + } // If the user has switched to another app, fire a local notification so they // know the operation finished. We still attempt the URL callback — on iOS the diff --git a/AltStore/App Detail/AppDetailView.swift b/AltStore/App Detail/AppDetailView.swift index 7f78551f..8de2483c 100644 --- a/AltStore/App Detail/AppDetailView.swift +++ b/AltStore/App Detail/AppDetailView.swift @@ -252,8 +252,7 @@ struct AppDetailView: View { } .frame(height: maxContentY) - contentCard - .frame(minHeight: geoHeight - 44, alignment: .top) + contentCard(geoHeight: geoHeight) } } .coordinateSpace(name: "appDetailScroll") @@ -279,7 +278,7 @@ struct AppDetailView: View { // MARK: Content card - private var contentCard: some View { + private func contentCard(geoHeight: CGFloat) -> some View { let radius = max(0, cornerRadius * (1.0 - model.navBarFraction)) return VStack(alignment: .leading, spacing: 0) { if let subtitle = model.app.subtitle, !subtitle.isEmpty { @@ -299,7 +298,11 @@ struct AppDetailView: View { permissionsSection Color.clear.frame(height: 30) } - .frame(maxWidth: .infinity, alignment: .leading) + // minHeight here (not on the call-site wrapper) so the altBackground below fills the + // full card frame. Applying the frame AFTER .background left the expanded bottom region + // transparent, revealing a grey system backdrop instead of the page background — the + // "grey band with OLED on" in the lower detail page. + .frame(maxWidth: .infinity, minHeight: geoHeight - 44, alignment: .topLeading) .background(Color(uiColor: .altBackground)) .clipShape(UnevenRoundedRectangle( topLeadingRadius: radius, @@ -610,7 +613,11 @@ private struct DetailScreenshotView: View { ZStack { Color(uiColor: .secondarySystemBackground) if let img = image { - Image(uiImage: img).resizable().scaledToFit() + Image(uiImage: img) + .resizable() + .scaledToFill() + .frame(width: preferredHeight * aspectRatio, height: preferredHeight) + .clipped() } else { ProgressView() } @@ -718,7 +725,11 @@ private struct PreviewScreenshotView: View { ZStack { Color(uiColor: .secondarySystemBackground) if let img = image { - Image(uiImage: img).resizable().scaledToFit() + Image(uiImage: img) + .resizable() + .scaledToFill() + .frame(width: w, height: h) + .clipped() } else { ProgressView() } diff --git a/AltStore/LaunchViewController.swift b/AltStore/LaunchViewController.swift index ed6e9e9f..261722d5 100644 --- a/AltStore/LaunchViewController.swift +++ b/AltStore/LaunchViewController.swift @@ -239,7 +239,7 @@ extension LaunchViewController { destinationVC.didMove(toParent: self) self.destinationViewController = destinationVC destinationVC.view.transform = CGAffineTransform(scaleX: 0.96, y: 0.96) - UIView.animate(withDuration: 0.25, delay: 0, + UIView.animate(withDuration: 0.2, delay: 0, usingSpringWithDamping: 0.9, initialSpringVelocity: 0.3, options: .allowUserInteraction) { diff --git a/AltStore/My Apps/MyAppsView.swift b/AltStore/My Apps/MyAppsView.swift index 78548d9b..80a9a9bd 100644 --- a/AltStore/My Apps/MyAppsView.swift +++ b/AltStore/My Apps/MyAppsView.swift @@ -57,6 +57,13 @@ private struct InstalledAppBannerRepresentable: UIViewRepresentable { let isCompact: Bool let now: Date let icon: UIImage? + // version + name are read off the NSManagedObject (not @Observable), so reading them + // live inside updateUIView registers no SwiftUI dependency. Threading them as value + // inputs makes a CoreData change break the row's reference-equality and forces + // updateUIView to re-run — otherwise the banner keeps a stale version (e.g. the + // AltBackup "1.0" placeholder) after a reinstall/reactivate until the next clock tick. + let version: String + let name: String let onAction: () -> Void let onNavigate: () -> Void @@ -100,7 +107,7 @@ private struct InstalledAppBannerRepresentable: UIViewRepresentable { v.configure(for: app, action: .custom(timeStr.uppercased())) - let ver = app.version + let ver = version if let dev = app.storeApp?.developerName { v.subtitleLabel.text = "\(dev) · v\(ver)" } else { @@ -109,7 +116,7 @@ private struct InstalledAppBannerRepresentable: UIViewRepresentable { v.buttonLabel.isHidden = isExpired v.buttonLabel.text = NSLocalizedString("Expires in", comment: "") - v.button.accessibilityLabel = String(format: NSLocalizedString("Refresh %@", comment: ""), app.name) + v.button.accessibilityLabel = String(format: NSLocalizedString("Refresh %@", comment: ""), name) let days = app.expirationDate.numberOfCalendarDays(since: now) switch days { @@ -121,14 +128,14 @@ private struct InstalledAppBannerRepresentable: UIViewRepresentable { } else { v.tintColor = .tertiaryLabel v.configure(for: app, action: .custom(NSLocalizedString("ACTIVATE", comment: ""))) - let ver = app.version + let ver = version if let dev = app.storeApp?.developerName { v.subtitleLabel.text = "\(dev) · v\(ver)" } else { v.subtitleLabel.text = "v\(ver)" } v.buttonLabel.isHidden = true - v.button.accessibilityLabel = String(format: NSLocalizedString("Activate %@", comment: ""), app.name) + v.button.accessibilityLabel = String(format: NSLocalizedString("Activate %@", comment: ""), name) } v.iconImageView.image = icon @@ -170,6 +177,8 @@ private struct InstalledAppRow: View { let isDragEnabled: Bool let model: MyAppsViewModel let now: Date + let version: String + let name: String @AppStorage("isCompactMyAppsCards") private var isCompact = false @State private var icon: UIImage? @@ -196,6 +205,8 @@ private struct InstalledAppRow: View { isCompact: isCompact, now: now, icon: icon, + version: version, + name: name, onAction: isActive ? { model.refresh(app) } : { model.activate(app) }, onNavigate: { if UserDefaults.standard.isHapticFeedbackEnabled { @@ -714,7 +725,7 @@ struct MyAppsView: View { private var activeSection: some View { Section { ForEach(activeApps, id: \.objectID) { app in - InstalledAppRow(app: app, isActive: true, isDragEnabled: canDrag(app), model: model, now: now) + InstalledAppRow(app: app, isActive: true, isDragEnabled: canDrag(app), model: model, now: now, version: app.version, name: app.name) .listRowBackground(Color(uiColor: .altBackground)) .listRowInsets(EdgeInsets(top: 6, leading: 16, bottom: 6, trailing: 16)) .listRowSeparator(.hidden) @@ -742,7 +753,7 @@ struct MyAppsView: View { private var inactiveSection: some View { Section { ForEach(inactiveApps, id: \.objectID) { app in - InstalledAppRow(app: app, isActive: false, isDragEnabled: canDrag(app), model: model, now: now) + InstalledAppRow(app: app, isActive: false, isDragEnabled: canDrag(app), model: model, now: now, version: app.version, name: app.name) .listRowBackground(Color(uiColor: .altBackground)) .listRowInsets(EdgeInsets(top: 6, leading: 16, bottom: 6, trailing: 16)) .listRowSeparator(.hidden) diff --git a/AltStore/My Apps/MyAppsViewModel.swift b/AltStore/My Apps/MyAppsViewModel.swift index 8585170a..6a38060a 100644 --- a/AltStore/My Apps/MyAppsViewModel.swift +++ b/AltStore/My Apps/MyAppsViewModel.swift @@ -107,7 +107,6 @@ final class MyAppsViewModel { @ObservationIgnored private var vpnReturnWorkItem: DispatchWorkItem? @ObservationIgnored private var vpnBackgroundTask: UIBackgroundTaskIdentifier = .invalid @ObservationIgnored private var refreshGroup: RefreshGroup? - @ObservationIgnored private let coordinator = NSFileCoordinator() @ObservationIgnored private let operationQueue = OperationQueue() // MARK: - Navigation @@ -432,7 +431,11 @@ final class MyAppsViewModel { } func restorePreviousBackup(for installedApp: InstalledApp) { - guard let backupURL = FileManager.default.backupDirectoryURL(for: installedApp) else { return } + guard let backupURL = FileManager.default.backupDirectoryURL(for: installedApp) else { + Logger.main.error("restorePreviousBackup: missing backup directory (app group) for \(installedApp.bundleIdentifier, privacy: .public)") + toast(text: NSLocalizedString("Couldn't locate the backup folder.", comment: "")) + return + } let bakURL = ImportExport.getPreviousBackupURL(backupURL) guard FileManager.default.fileExists(atPath: bakURL.path) else { return } do { @@ -442,13 +445,18 @@ final class MyAppsViewModel { try FileManager.default.copyItem(at: bakURL, to: backupURL) } catch { Logger.main.error("restorePreviousBackup: \(error.localizedDescription, privacy: .public)") + toast(error: error, opensLog: true) return } promptRestore(installedApp) } func exportBackup(for installedApp: InstalledApp) { - guard let backupURL = FileManager.default.backupDirectoryURL(for: installedApp) else { return } + guard let backupURL = FileManager.default.backupDirectoryURL(for: installedApp) else { + Logger.main.error("exportBackup: missing backup directory (app group) for \(installedApp.bundleIdentifier, privacy: .public)") + toast(text: NSLocalizedString("Couldn't locate the backup folder.", comment: "")) + return + } let picker = UIDocumentPickerViewController(forExporting: [backupURL], asCopy: true) presentingViewController?.present(picker, animated: true) } @@ -689,16 +697,16 @@ final class MyAppsViewModel { func backupExists(for installedApp: InstalledApp) -> Bool { guard let backupURL = FileManager.default.backupDirectoryURL(for: installedApp) else { return false } - var exists = false - var outError: NSError? - coordinator.coordinate(readingItemAt: backupURL, options: [.withoutChanges], error: &outError) { url in - #if DEBUG && targetEnvironment(simulator) - exists = true - #else - exists = FileManager.default.fileExists(atPath: url.path) - #endif - } - return exists + #if DEBUG && targetEnvironment(simulator) + return true + #else + // Direct fileExists — no NSFileCoordinator. The URL is constructed locally + // (no symlink/redirect resolution needed) and this is called on the main thread + // from the SwiftUI body. The synchronous coordinator form would block the main + // thread if AltBackup still holds a write-coordination on the same URL after + // completing a backup, causing the "instant freeze" on restore. + return FileManager.default.fileExists(atPath: backupURL.path) + #endif } func previousBackupExists(for installedApp: InstalledApp) -> Bool { diff --git a/AltStore/Operations/BackgroundRefreshAppsOperation.swift b/AltStore/Operations/BackgroundRefreshAppsOperation.swift index 06cae596..58994a71 100644 --- a/AltStore/Operations/BackgroundRefreshAppsOperation.swift +++ b/AltStore/Operations/BackgroundRefreshAppsOperation.swift @@ -81,6 +81,13 @@ final class BackgroundRefreshAppsOperation: ResultOperation<[String: Result], Error>) { + // super.finish() is internally idempotent (guards on isFinished), but the side + // effects below are NOT: scheduleFinishedRefreshingNotification persists a + // RefreshAttempt row and arms a local notification. Guard before super flips + // isFinished so a second finish call can't duplicate them. No current path + // double-finishes, but this keeps the contract robust against future edits. + guard !self.isFinished else { return } + super.finish(result) self.scheduleFinishedRefreshingNotification(for: result, delay: 0) diff --git a/AltStore/Operations/DownloadAppOperation.swift b/AltStore/Operations/DownloadAppOperation.swift index 599d7c91..9628a1ff 100644 --- a/AltStore/Operations/DownloadAppOperation.swift +++ b/AltStore/Operations/DownloadAppOperation.swift @@ -203,7 +203,12 @@ private extension DownloadAppOperation // Manually update the app's bundle identifier to match the one specified in the source. // This allows people who previously installed the app to still update and refresh normally. infoPlist[kCFBundleIdentifierKey as String] = StoreApp.dolphinAppID - (infoPlist as NSDictionary).write(to: application.bundle.infoPlistURL, atomically: true) + if !(infoPlist as NSDictionary).write(to: application.bundle.infoPlistURL, atomically: true) { + // Don't fail the install — the un-rewritten bundle ID still produces a working app, + // it just won't update/refresh in place for prior installs. Surface it so the cause + // is diagnosable instead of silently producing the wrong bundle ID. + Logger.sideload.error("DownloadAppOperation: failed to rewrite Dolphin bundle identifier in Info.plist at \(application.bundle.infoPlistURL.path, privacy: .public)") + } } } diff --git a/AltStore/Operations/ResignAppOperation.swift b/AltStore/Operations/ResignAppOperation.swift index 2f4af497..5c495c9c 100644 --- a/AltStore/Operations/ResignAppOperation.swift +++ b/AltStore/Operations/ResignAppOperation.swift @@ -117,7 +117,11 @@ private extension ResignAppOperation let progress = Progress.discreteProgress(totalUnitCount: 1) let bundleIdentifier = context.bundleIdentifier - let openURL = InstalledApp.openAppURL(for: app) + // Use the customized/remapped bundle identifier (the one tracked in CoreData and + // requested by the Open button via InstalledApp.openAppURL), not the on-disk resigned + // app's identifier. Otherwise the sidestore-:// scheme baked into CFBundleURLSchemes + // won't match what Open launches, so Open silently fails for remapped bundle IDs. + let openURL = InstalledApp.openAppURL(for: AnyApp(from: app, bundleId: context.bundleIdentifier)) let fileURL = app.fileURL // Capture screen scale on the main thread before entering the background work block. diff --git a/AltStore/Operations/VerifyAppOperation.swift b/AltStore/Operations/VerifyAppOperation.swift index 1f5e7c84..e58e7513 100644 --- a/AltStore/Operations/VerifyAppOperation.swift +++ b/AltStore/Operations/VerifyAppOperation.swift @@ -117,7 +117,10 @@ private extension VerifyAppOperation // Do nothing if source doesn't provide hash. guard let expectedHash = await $appVersion.sha256 else { return } - let data = try Data(contentsOf: ipaURL) + // Memory-map rather than fully loading the IPA — large apps (hundreds of MB) + // would otherwise spike resident memory mid-install. SHA256 reads sequentially, + // so the mapped pages are touched once and released by the kernel as needed. + let data = try Data(contentsOf: ipaURL, options: .mappedIfSafe) let sha256Hash = SHA256.hash(data: data) let hashString = sha256Hash.compactMap { String(format: "%02x", $0) }.joined() diff --git a/AltStore/Settings/WhatsNewView.swift b/AltStore/Settings/WhatsNewView.swift index b0b1877c..09102f98 100644 --- a/AltStore/Settings/WhatsNewView.swift +++ b/AltStore/Settings/WhatsNewView.swift @@ -75,7 +75,7 @@ private final class WhatsNewViewModel { return ReleaseEntry(tag: tag, name: name, body: body, date: dateStr) } - withAnimation(.easeOut(duration: 0.2)) { + withAnimation(.easeOut(duration: 0.15)) { if replacing { entries = fetched } else { @@ -209,7 +209,7 @@ private struct ReleaseCard: View { .foregroundStyle(.secondary) .lineLimit(expanded ? nil : Self.collapseThreshold) .multilineTextAlignment(.leading) - .animation(.easeOut(duration: 0.2), value: expanded) + .animation(.easeOut(duration: 0.15), value: expanded) if isLongBody { SwiftUI.Button { diff --git a/AltStore/TabBarController.swift b/AltStore/TabBarController.swift index 4b426f22..68318f31 100644 --- a/AltStore/TabBarController.swift +++ b/AltStore/TabBarController.swift @@ -205,11 +205,11 @@ extension TabBarController: UITabBarControllerDelegate { } } -/// A short cross-dissolve between tab content. Kept deliberately brief (0.15s, ease-out) so +/// A short cross-dissolve between tab content. Kept deliberately brief (0.1s, ease-out) so /// tab switching feels crisp instead of a soft, lingering fade. private final class TabSwitchFadeAnimator: NSObject, UIViewControllerAnimatedTransitioning { func transitionDuration(using transitionContext: UIViewControllerContextTransitioning?) -> TimeInterval { - return 0.15 + return 0.1 } func animateTransition(using transitionContext: UIViewControllerContextTransitioning) { diff --git a/AltStore/Updates/UpdatesView.swift b/AltStore/Updates/UpdatesView.swift index 0bad0602..0b1dbf93 100644 --- a/AltStore/Updates/UpdatesView.swift +++ b/AltStore/Updates/UpdatesView.swift @@ -65,7 +65,7 @@ final class UpdatesModel { Task { [weak self] in try? await Task.sleep(for: .seconds(1.5)) await MainActor.run { - withAnimation(.easeOut(duration: 0.2)) { + withAnimation(.easeOut(duration: 0.15)) { self?.progressState = .hidden } self?.authContext = AuthenticatedOperationContext() @@ -125,7 +125,7 @@ final class UpdatesModel { updateQueue.removeAll() for key in buttonStates.keys { buttonStates[key] = .update } activeProgress = nil - withAnimation(.easeOut(duration: 0.2)) { + withAnimation(.easeOut(duration: 0.15)) { progressState = .hidden } authContext = AuthenticatedOperationContext() @@ -399,7 +399,7 @@ private struct UpdateRowView: View { } .background(tintColor.opacity(buttonState == .queued ? 0.6 : 1), in: Capsule()) .disabled(buttonState == .updating) - .animation(.easeOut(duration: 0.2), value: buttonState) + .animation(.easeOut(duration: 0.15), value: buttonState) .accessibilityLabel(String(format: NSLocalizedString("Update %@", comment: ""), installedApp.name)) } @@ -563,7 +563,7 @@ private struct UpdateDetailView: View { in: RoundedRectangle(cornerRadius: 14, style: .continuous) ) .disabled(buttonState != .update) - .animation(.easeOut(duration: 0.2), value: buttonState) + .animation(.easeOut(duration: 0.15), value: buttonState) } @MainActor diff --git a/SideStore/Utils/iostreams/ConsoleLogger.swift b/SideStore/Utils/iostreams/ConsoleLogger.swift index 3a7550c0..e614050a 100644 --- a/SideStore/Utils/iostreams/ConsoleLogger.swift +++ b/SideStore/Utils/iostreams/ConsoleLogger.swift @@ -85,12 +85,15 @@ public class AbstractConsoleLogger: ConsoleLogger{ private func readHandler(isError: Bool) -> (FileHandle) -> Void { return { [weak self] _ in - // Lock first before touching anything - self?.shutdownLock.lock() - defer { self?.shutdownLock.unlock() } - - // Capture strong self *after* lock is acquired + // Capture a strong reference for the handler's whole duration *before* locking. + // Otherwise the logger could deallocate between `lock()` and the strong bind, + // leaving shutdownLock held while the deferred `unlock()` no-ops through a nil + // weak self — which would deadlock deinit's stopCapturing() (it also locks). guard let self = self else { return } + + // Lock before touching any pipe/handle state. + self.shutdownLock.lock() + defer { self.shutdownLock.unlock() } let handle = isError ? self.errorHandle : self.outputHandle guard let data = handle?.availableData else { return }