Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
74 changes: 67 additions & 7 deletions .claude/gotchas.md
Original file line number Diff line number Diff line change
Expand Up @@ -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-<id>://` 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
Expand Down
19 changes: 19 additions & 0 deletions .claude/sidestore-delta.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).

---
6 changes: 5 additions & 1 deletion AltBackup/AppDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
23 changes: 17 additions & 6 deletions AltStore/App Detail/AppDetailView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -252,8 +252,7 @@ struct AppDetailView: View {
}
.frame(height: maxContentY)

contentCard
.frame(minHeight: geoHeight - 44, alignment: .top)
contentCard(geoHeight: geoHeight)
}
}
.coordinateSpace(name: "appDetailScroll")
Expand All @@ -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 {
Expand All @@ -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,
Expand Down Expand Up @@ -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()
}
Expand Down Expand Up @@ -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()
}
Expand Down
2 changes: 1 addition & 1 deletion AltStore/LaunchViewController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
23 changes: 17 additions & 6 deletions AltStore/My Apps/MyAppsView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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 {
Expand All @@ -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 {
Expand All @@ -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
Expand Down Expand Up @@ -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?
Expand All @@ -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 {
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down
34 changes: 21 additions & 13 deletions AltStore/My Apps/MyAppsViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand All @@ -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)
}
Expand Down Expand Up @@ -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 {
Expand Down
7 changes: 7 additions & 0 deletions AltStore/Operations/BackgroundRefreshAppsOperation.swift
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,13 @@ final class BackgroundRefreshAppsOperation: ResultOperation<[String: Result<Inst

override func finish(_ result: Result<[String: Result<InstalledApp, Error>], 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)
Expand Down
Loading