Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
161 changes: 161 additions & 0 deletions .github/workflows/multi-account-ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
name: Multi-Account CI

# Compile-verification CI for the multi-account signing feature.
#
# This project can only be built on macOS (Xcode), so this workflow is the
# authoritative way to verify that the multi-account changes compile and that
# the test targets still build/run. It mirrors the proven build steps from
# pr.yml but is triggered on pushes to the feature branch so every push gets
# a fresh build signal.
#
# The archive build uses CODE_SIGNING_ALLOWED=NO (see the Makefile), so no
# signing secrets are required.

on:
push:
branches:
- feature/multi-account-support
paths-ignore:
- '**.md'
- 'LICENSE'
- '.editorconfig'
- '.gitignore'
workflow_dispatch:

concurrency:
group: multi-account-ci-${{ github.ref }}
cancel-in-progress: true

jobs:
build:
name: Build (archive, no signing)
runs-on: macos-26
steps:
- uses: actions/checkout@v4
with:
submodules: recursive
fetch-depth: 1

- run: brew install ldid xcbeautify

- name: Setup Xcode
uses: maxim-lobanov/setup-xcode@v1.6.0
with:
xcode-version: "26.2"

- name: Restore Cache (exact)
id: xcode-cache-exact
uses: actions/cache/restore@v3
with:
path: |
~/Library/Developer/Xcode/DerivedData
~/Library/Caches/org.swift.swiftpm
key: ma-xcode-build-cache-${{ github.ref_name }}-${{ github.sha }}

- name: Restore Cache (last)
if: steps.xcode-cache-exact.outputs.cache-hit != 'true'
uses: actions/cache/restore@v3
with:
path: |
~/Library/Developer/Xcode/DerivedData
~/Library/Caches/org.swift.swiftpm
key: ma-xcode-build-cache-${{ github.ref_name }}-

- name: Build
run: |
mkdir -p build/logs
set -o pipefail && NSUnbufferedIO=YES make -B build \
2>&1 | tee build/logs/build.log | xcbeautify --renderer github-actions

- name: Package IPA
run: |
# fakesign + package into an (unsigned) .ipa that can be sideloaded with your own Apple ID.
make fakesign | tee -a build/logs/build.log
# Strip embedded app extensions (the AltWidget appex) before packaging. When sideloading
# with a FREE Apple ID, every embedded extension needs its own App ID + provisioning
# profile; that frequently fails with "The app extension is missing a valid provisioning
# profile." and also burns the free 10-App-IDs-per-week limit. The app and the
# multi-account feature work fine without the home-screen widget.
rm -rf "SideStore.xcarchive/Products/Applications/SideStore.app/PlugIns"
make ipa | tee -a build/logs/build.log

- name: Save Cache
if: always()
uses: actions/cache/save@v3
with:
path: |
~/Library/Developer/Xcode/DerivedData
~/Library/Caches/org.swift.swiftpm
key: ma-xcode-build-cache-${{ github.ref_name }}-${{ github.sha }}

- name: Upload build log
if: always()
uses: actions/upload-artifact@v4
with:
name: multi-account-build-log
path: build/logs/build.log

- name: Upload IPA
uses: actions/upload-artifact@v4
with:
name: SideStore-multi-account.ipa
path: SideStore.ipa

unit-tests:
name: Unit tests (DataStructures plan)
runs-on: macos-26
# NOTE: build-for-testing currently fails on this project — including on the unmodified
# upstream baseline — with "Multiple commands produce .../libem_proxy_static.a" (the app host
# and the test bundle both build the em_proxy SwiftPM static library into the same path). This
# is a pre-existing build-graph issue unrelated to the multi-account feature, so this job is
# allowed to fail without failing the workflow. The "Build (archive, no signing)" job above is
# the authoritative compile signal for the multi-account changes.
continue-on-error: true
steps:
- uses: actions/checkout@v4
with:
submodules: recursive
fetch-depth: 1

- run: brew install ldid xcbeautify

- name: Setup Xcode
uses: maxim-lobanov/setup-xcode@v1.6.0
with:
xcode-version: "26.4"

- name: Boot simulator
run: |
xcrun simctl boot "iPhone 17 Pro" || true

- name: Build for testing (DataStructures plan)
run: |
mkdir -p build/logs
set -o pipefail && NSUnbufferedIO=YES xcodebuild build-for-testing \
-project AltStore.xcodeproj \
-scheme SideStore \
-testPlan DataStructureTests \
-destination 'generic/platform=iOS Simulator' \
-enableCodeCoverage YES \
CODE_SIGNING_REQUIRED=NO AD_HOC_CODE_SIGNING_ALLOWED=YES CODE_SIGNING_ALLOWED=NO \
DEVELOPMENT_TEAM=XYZ0123456 ORG_IDENTIFIER=com.SideStore \
2>&1 | tee build/logs/tests-build.log | xcbeautify --renderer github-actions

- name: Run unit tests (DataStructures plan)
run: |
set -o pipefail && NSUnbufferedIO=YES xcodebuild test-without-building \
-project AltStore.xcodeproj \
-scheme SideStore \
-testPlan DataStructureTests \
-destination 'platform=iOS Simulator,name=iPhone 17 Pro' \
-enableCodeCoverage YES \
CODE_SIGNING_REQUIRED=NO AD_HOC_CODE_SIGNING_ALLOWED=YES CODE_SIGNING_ALLOWED=NO \
DEVELOPMENT_TEAM=XYZ0123456 ORG_IDENTIFIER=com.SideStore \
2>&1 | tee build/logs/tests-run.log | xcbeautify --renderer github-actions

- name: Upload test logs
if: always()
uses: actions/upload-artifact@v4
with:
name: multi-account-test-logs
path: build/logs/*.log
3 changes: 3 additions & 0 deletions AltStore.xcodeproj/xcshareddata/xcschemes/SideStore.xcscheme
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,9 @@
reference = "container:SideStore/Tests/SideStoreTests.xctestplan"
default = "YES">
</TestPlanReference>
<TestPlanReference
reference = "container:SideStore/Tests/DataStructureTests.xctestplan">
</TestPlanReference>
</TestPlans>
<Testables>
<TestableReference
Expand Down
5 changes: 5 additions & 0 deletions AltStore/AppDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,11 @@ final class AppDelegate: UIResponder, UIApplicationDelegate {
else
{
debugLog("Started DatabaseManager.")

// Migrate a pre-multi-account installation: re-home the existing single account's
// credentials into per-account storage and stamp each installed app with its
// signing account. Both steps are idempotent.
AccountManager.shared.performStartupMigrations()
}
}

Expand Down
2 changes: 2 additions & 0 deletions AltStore/Info.plist
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,8 @@
<string>6.0</string>
<key>CFBundleName</key>
<string>$(PRODUCT_NAME)</string>
<key>CFBundleDisplayName</key>
<string>MultiStore</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
Expand Down
9 changes: 6 additions & 3 deletions AltStore/LaunchViewController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -88,9 +88,12 @@ final class LaunchViewController: UIViewController, UIDocumentPickerDelegate {
})

alert.addAction(UIAlertAction(title: NSLocalizedString("Select File", comment: ""), style: .default) { _ in
var types = UTType.types(tag: "plist", tagClass: .filenameExtension, conformingTo: nil)
types.append(contentsOf: UTType.types(tag: "mobiledevicepairing", tagClass: .filenameExtension, conformingTo: .data))
types.append(.xml)
// Accept any file: pairing files exported by external tools come with varied extensions
// (.plist, .mobiledevicepairing, none, …) and iOS sometimes tags a plain .plist in a way
// the narrow type list didn't match, greying it out. `.item` is the universal supertype,
// so every file is selectable; the chosen file is validated when minimuxer starts, so a
// wrong pick just fails gracefully instead of blocking selection.
let types: [UTType] = [.propertyList, .xml, .text, .data, .item]
let picker = UIDocumentPickerViewController(forOpeningContentTypes: types)
picker.delegate = self
picker.shouldShowFileExtensions = true
Expand Down
112 changes: 112 additions & 0 deletions AltStore/Managing Apps/AccountManager+Actions.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
//
// AccountManager+Actions.swift
// AltStore
//
// App-layer account actions that require the signing/refresh pipeline (AppManager,
// AuthenticationOperation). The data/credential facade lives in
// AltStoreCore/Managers/AccountManager.swift; this extension adds the interactive operations.
//

import Foundation
import CoreData
import UIKit

import AltStoreCore
import AltSign

extension AccountManager
{
/// Add a new Apple account by presenting the sign-in UI, forcing a fresh login so a *new*
/// Apple ID can be entered rather than silently re-authenticating the current account. The
/// newly added account becomes the default account for new installs. Returns the resolved
/// `Account` (a view-context object) on success.
@discardableResult
func addAccount(presentingViewController: UIViewController, completionHandler: @escaping (Result<Account, Error>) -> Void) -> AuthenticationOperation
{
let context = AuthenticatedOperationContext()
context.ignoresCachedCredentials = true

return AppManager.shared.authenticate(presentingViewController: presentingViewController, context: context, skipDeviceRegistration: false) { result in
switch result
{
case .failure(let error):
completionHandler(.failure(error))

case .success(let (team, _, _)):
let accountID = team.account.identifier
DispatchQueue.main.async {
if let account = self.account(accountID, in: DatabaseManager.shared.viewContext)
{
completionHandler(.success(account))
}
else
{
completionHandler(.failure(OperationError.unknown()))
}
}
}
}
}

/// Refresh every installed app signed by the given account, authenticating as that account.
@discardableResult
func refreshAccount(_ accountID: String, presentingViewController: UIViewController?, completionHandler: @escaping (Result<[String: Result<InstalledApp, Error>], Error>) -> Void = { _ in }) -> RefreshGroup
{
let apps = self.appsForAccount(accountID, in: DatabaseManager.shared.viewContext)

let group = RefreshGroup()
group.context.accountID = accountID
group.completionHandler = { results in
completionHandler(.success(results))
}

// Nothing to refresh — complete through the group so any group-based observers finish too.
guard !apps.isEmpty else {
group.completionHandler?([:])
return group
}

return AppManager.shared.refresh(apps, presentingViewController: presentingViewController, group: group)
}

/// Remove an account and its stored credentials on a background context.
func removeAccount(_ accountID: String, completionHandler: @escaping (Result<Void, Error>) -> Void)
{
DatabaseManager.shared.persistentContainer.performBackgroundTask { context in
do
{
try self.deleteAccount(accountID, in: context)
DispatchQueue.main.async { completionHandler(.success(())) }
}
catch
{
DispatchQueue.main.async { completionHandler(.failure(error)) }
}
}
}

/// Reassign an installed app to a different signing account and re-sign it with that account.
func changeSigningAccount(for installedApp: InstalledApp, to accountID: String, presentingViewController: UIViewController?, completionHandler: @escaping (Result<InstalledApp, Error>) -> Void)
{
let bundleIdentifier = installedApp.bundleIdentifier
let context = DatabaseManager.shared.persistentContainer.newBackgroundContext()
context.performAndWait {
guard self.assignAccount(accountID, toAppWithBundleIdentifier: bundleIdentifier, in: context) else {
DispatchQueue.main.async { completionHandler(.failure(OperationError.appNotFound(name: installedApp.name))) }
return
}

do { try context.save() }
catch {
DispatchQueue.main.async { completionHandler(.failure(error)) }
return
}

DispatchQueue.main.async {
// Re-sign with the newly-assigned account (inferred from the app's signingAccountID).
let app = DatabaseManager.shared.viewContext.object(with: installedApp.objectID) as? InstalledApp ?? installedApp
_ = AppManager.shared.resign(app, presentingViewController: presentingViewController, completionHandler: completionHandler)
}
}
}
}
Loading
Loading