From 2dfce9db2b0d8ed89ee5a10088483861e10eedd1 Mon Sep 17 00:00:00 2001 From: Vineet Choudhary Date: Sat, 12 Sep 2026 16:05:33 +0530 Subject: [PATCH 1/4] Add cli future plan and cross repo contracts --- AGENTS.md | 22 ++++++++++++++++++++-- 1 file changed, 20 insertions(+), 2 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 8bf59a2f..2c905b6c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -41,7 +41,25 @@ The GUI is 100% Swift/SwiftUI, business logic lives in `AppBoxCore`, and `appbox 5. **Every change ends shippable + green.** Never leave `develop` with a broken build or red tests at a session boundary. Small, self‑contained commits. 6. **Swift-native & Apple-first dependencies.** Prefer Swift SDKs over Objective-C ones (Dropbox → **SwiftyDropbox**, not `ObjectiveDropboxOfficial`) and a built-in Apple framework over a third-party dep wherever one exists (`os.Logger` not CocoaLumberjack; URLSession; CryptoKit). Keep a third-party dep only where Apple has no equivalent (ZIP → a **Swift** ZIP package, not the ObjC SSZipArchive). 7. **All cloud storage goes through `StorageProvider`.** Dropbox is just the first `DropboxStorageProvider`; the upload pipeline and UI depend on the protocol, never on a provider SDK. **No SwiftyDropbox (or any provider) type may leak outside its provider file.** Adding a backend (Google Drive, S3, …) must be a new file + a registry entry — nothing else. -8. **Keep the living docs current.** Whenever you make meaningful progress or the user gives a new instruction, update `AGENTS.md` and `plans/follow-ups.md` in the same change. The docs are the contract; stale docs are a bug. +8. **`appboxcli` is heading for full independence.** The CLI must keep working on a machine where the GUI has never run: CI runners are its main consumer, and each project wants different settings. So anything the GUI can configure has to be settable on the command line too; `AppPreferences` (the app's `com.developerinsider.AppBox` defaults domain) is a *fallback* for parity with the GUI, never the only source. Install-page settings resolve as **flag → app preference → built-in default** (more details on, IPA link off, previous versions on, 100 MB chunks); `DefaultSettings` writes those same values on the app's first launch, and `AppPreferences.FirstRun` must stay in step with it. A preference the GUI never wrote falls back to that default — never to a Swift zero value, which is how CLI uploads silently lost "more build details". Do not add a CLI behaviour that can only be driven from the GUI's preferences. +9. **No change stops at its own repo.** AppBox is six repos, and the five that ship code share one set of wire formats — every one of them can be broken from outside. Before you finish a change to the macOS app, `AppBoxCore`, `appboxcli`, `install-helper`, the iOS SDK or the fastlane plugin, **work out which of the others consume what you touched, grep those checkouts to confirm, and say what you found** — in the PR body or your summary, even when the answer is "nothing else consumes this". They are all cloned locally (paths in the table above), so there is no excuse for guessing. Editing `install-helper` means checking the GUI, CLI, SDK, fastlane plugin and WebApp; editing the CLI means checking the fastlane plugin (it shells out to `appboxcli`) and the GUI (shared store); editing the upload pipeline means checking the SDK and WebApp (they both read `appinfo.json`). A change that lands green here and silently breaks a consumer is a broken change. If a consumer needs a matching change, either make it in the same pass or write it into `plans/follow-ups.md` before you stop — never leave it undiscovered. +10. **Keep the living docs current.** Whenever you make meaningful progress or the user gives a new instruction, update `AGENTS.md` and `plans/follow-ups.md` in the same change. The docs are the contract; stale docs are a bug. + +### Cross-repo contracts (what rule 9 is protecting) + +Everything below is a format or interface with a producer in one repo and a consumer in another. Changing one without its consumers is the failure mode rule 9 exists to catch. + +| Contract | Produced by | Consumed by | Breaks if changed | +|---|---|---|---| +| `appinfo.json` — `latestVersion`, `versions[]`, `uniqueLinkShared`, `uniqueLinkShort`, and the per-build fields | `AppBoxCore` (`AppInfoJSON`, `AppInfoFile`; rewritten by `DeleteCoordinator`) | **iOS SDK** (`AppInfo.swift`), **WebApp** install page | The SDK stops finding updates; the install page renders blank fields. Both decode tolerantly, so a rename fails silently rather than loudly. | +| Install link — `https://web.getappbox.com?url=` + the Dropbox share path, with `%`, `&` and `#` escaped | `AppBoxCore` (`InstallLink.make`) | **WebApp**, **iOS SDK** (`AppBoxLink`), **install-helper** path validation | Testers get a dead install page, and the SDK cannot resolve the link it was given. | +| `install.getappbox.com` — `/appinfo/s/**`, `/appinfo/scl/**`, `/install/**`, `/cors`. Unauthenticated, per-IP 300/min, forces `dl=1`, path must start `/s/` or `/scl/` | **install-helper** | **WebApp**, **iOS SDK** | Every installed build that watches a link stops updating — including builds already on devices, which cannot be fixed by shipping new code. | +| `api.getappbox.com/api/v1/*` — `X-AppBox-Client-Token` plus the caller's Dropbox bearer token | **install-helper** | GUI + CLI via `AppBoxServiceClient` (short links, mail, webhooks, config, latest-version) | Uploads still work but lose short links, email and notifications. | +| `~/.appbox_share_value.json` — `APPBOX_SHARE_URL`, `APPBOX_IPA_URL`, `APPBOX_MANIFEST_URL` | `appboxcli` (`ShareURLExport`) | **fastlane plugin** (`appbox` action), SDK example's `Scripts/upload.sh` | Lanes report success but export no `APPBOX_*` values, so downstream steps get empty links. | +| `appboxcli upload` flags — `--ipa`, `--emails`, `--message`, `--keepsamelink`, `--dbfolder`, `--slackwebhook`, `--msteamswebhook` — and its exit codes (**111 = build is live but the email failed**, not a failure) | `appboxcli` | **fastlane plugin**, SDK example's `Scripts/upload.sh` | Renaming a flag breaks every CI lane in the wild. Treat retired options the way `--webhookmessage` is treated: accepted and ignored, never removed. | +| Shared Core Data store (pinned by `ABStorePaths`) + shared Keychain service string | GUI and CLI equally | GUI and CLI | One side orphans the other's data or Dropbox session. | +| `releases/latest/download/AppBox.dmg` — the fixed asset name the download page links straight at (no GitHub API, so no rate limit) | this repo's releases (`Scripts/build-release.sh`) | **Home** (`getappbox.com/download`), `install.sh` (`AppBox.app.zip`) | A release published without the asset 404s for every visitor. | +| Keep-same-link folder layout — one `appinfo.json` per bundle identifier, replaced in place | `AppBoxCore` upload pipeline | **iOS SDK** (its entire premise: an installed build watches one link forever) | Every future upload becomes invisible to builds already installed. | ## Module / source layout @@ -49,7 +67,7 @@ The GUI is 100% Swift/SwiftUI, business logic lives in `AppBoxCore`, and `appbox |------|------| | `AppBox/` | The GUI app — **100% Swift + SwiftUI; zero Objective‑C** (no `.m`/`.h`/`.pch`). `@main struct AppBoxApp: App` + `@NSApplicationDelegateAdaptor(AppDelegate)`; Home/Dashboard are `Window` scenes hosting Swift NSViewControllers via `NSViewControllerRepresentable`; the menu is `.commands`. SwiftUI screens are NSViewController-hosted islands (`NSHostingView`); shared styling lives in `IslandStyle.swift` (`IslandMetrics`, `IslandTypography`, `.islandTypography()`). Key dirs: `Common/`, `Model/`, `ViewController/`. No storyboard, no pch, no generated `AppBox-Swift.h` (`SWIFT_INSTALL_OBJC_HEADER = NO`). Reach the AppDelegate via `AppDelegate.appDelegate` (captured in init — `NSApp.delegate` is SwiftUI's wrapper, see [[appbox-appdelegate-adaptor-cast]]). The GUI consumes Core's **native async API** directly (`UploadCoordinator.run`/`DeleteCoordinator.run`/`IPAExtractor`) — the `@objc(AB…)` upload/delete/extract bridges are gone. | | `AppBoxCore/Sources/AppBoxCore/CoreData/Resources/AppBox.xcdatamodeld/` | Core Data model (loaded via `CoreDataStack` from `Bundle.module`). Current = `AppBox4` (frozen). | -| `AppBoxCLI/` | The CLI (Swift, `swift-argument-parser`). Links Core directly; `upload` shells out to the installed GUI. | +| `AppBoxCLI/` | The CLI (Swift, `swift-argument-parser`). Links Core directly and runs the upload itself through `BuildPublishService` — it does **not** drive the GUI. Install-page settings come from flags, falling back to `AppPreferences` (see hard rule 8). | | `AppBoxCore/` | The shared Swift package. `Backend/` holds `AppBoxServiceClient` + `DropboxAppKeyProvider` (the install-helper API client). | | `AppBoxTests/` | XCTest target (pure Swift). | | `.github/workflows/` | `xcodebuild.yml` (build + test, on `master` and `develop`), `gh-pages.yml` (docs). | From 2a2d3a0698cdde9a3c708467d8e7f8482b793ce5 Mon Sep 17 00:00:00 2001 From: Vineet Choudhary Date: Sat, 12 Sep 2026 16:06:31 +0530 Subject: [PATCH 2/4] Add install page settings flags --- .../DefaultSettings/DefaultSettings.swift | 2 + AppBox/Common/UserManager/UserData.swift | 2 +- AppBoxCLI/AppBoxCLI.swift | 43 +++++- .../AppBoxCore/Upload/AppPreferences.swift | 91 +++++++++++ .../AppBoxCoreTests/AppPreferencesTests.swift | 144 ++++++++++++++++++ docs/CommandLineInterface/index.md | 29 ++++ 6 files changed, 309 insertions(+), 2 deletions(-) create mode 100644 AppBoxCore/Sources/AppBoxCore/Upload/AppPreferences.swift create mode 100644 AppBoxCore/Tests/AppBoxCoreTests/AppPreferencesTests.swift diff --git a/AppBox/Common/DefaultSettings/DefaultSettings.swift b/AppBox/Common/DefaultSettings/DefaultSettings.swift index ff6c422b..2df07de0 100644 --- a/AppBox/Common/DefaultSettings/DefaultSettings.swift +++ b/AppBox/Common/DefaultSettings/DefaultSettings.swift @@ -3,6 +3,7 @@ // AppBox import Foundation +import AppBoxCore public final class DefaultSettings: NSObject { @@ -12,6 +13,7 @@ public final class DefaultSettings: NSObject { UserData.setDownloadIPAEnable(false) UserData.setMoreDetailsEnable(true) UserData.setShowPreviousVersions(true) + UserData.setUploadChunkSize(UploadSettings.defaultChunkSizeMB) } UserData.recordLaunchedVersion() diff --git a/AppBox/Common/UserManager/UserData.swift b/AppBox/Common/UserManager/UserData.swift index 8ab443d4..450259d3 100644 --- a/AppBox/Common/UserManager/UserData.swift +++ b/AppBox/Common/UserManager/UserData.swift @@ -145,7 +145,7 @@ public final class UserData: NSObject { public class func uploadChunkSize() -> Int { let chunkSize = defaults.integer(forKey: "UploadChunkSize") - return chunkSize > 0 ? chunkSize : 100 + return chunkSize > 0 ? chunkSize : UploadSettings.defaultChunkSizeMB } public class func setUploadChunkSize(_ chunkSize: Int) { diff --git a/AppBoxCLI/AppBoxCLI.swift b/AppBoxCLI/AppBoxCLI.swift index 375e31a3..0855c6f8 100644 --- a/AppBoxCLI/AppBoxCLI.swift +++ b/AppBoxCLI/AppBoxCLI.swift @@ -191,6 +191,35 @@ struct Upload: AsyncParsableCommand { valueName: "dropbox folder")) var dropboxFolder: String? + // Install-page settings. Each one defaults to the AppBox app's preference and + // is overridden only when the flag is actually passed, so a CI job can pin the + // settings it needs without depending on how the runner's AppBox is configured. + + @Flag( + name: .customLong("moredetails"), inversion: .prefixedNo, + help: .init( + "[Optional] \nShow the expanded build details on the install page (minimum iOS version, supported devices, build type, IPA size and provisioning profile). \nDefaults to the AppBox app setting.\n")) + var moreDetails: Bool? + + @Flag( + name: .customLong("ipalink"), inversion: .prefixedNo, + help: .init( + "[Optional] \nShow the direct IPA download link on the install page. \nDefaults to the AppBox app setting.\n")) + var includeIPALink: Bool? + + @Flag( + name: .customLong("previousversions"), inversion: .prefixedNo, + help: .init( + "[Optional] \nKeep earlier builds listed on the install page. \nDefaults to the AppBox app setting.\n")) + var keepPreviousVersions: Bool? + + @Option( + name: .customLong("chunksize"), + help: .init( + "[Optional] \nDropbox upload chunk size in MB (1-150). \nDefaults to the AppBox app setting.\n", + valueName: "MB")) + var chunkSizeMB: Int? + /// Retired in 4.0 — the notification text is generated. Still accepted so existing CI scripts don't fail on an unknown option. @Option(name: .customLong("webhookmessage"), help: .hidden) var webhookMessage: String? @@ -249,6 +278,11 @@ extension Upload { print("ERROR - Invalid Microsoft Teams Webhook URL. Only https:// webhook URLs are supported.") throw ExitCode(127) } + // 150 MB is Dropbox's ceiling for a single upload-session chunk. + if let chunkSizeMB, !(1...150).contains(chunkSizeMB) { + print("ERROR - --chunksize must be between 1 and 150 MB.") + throw ExitCode(127) + } } private func publish() async throws { @@ -263,7 +297,14 @@ extension Upload { let request = BuildUploadRequest( ipaURL: ipaURL, - settings: UploadSettings(), + // The app's install-page preferences, with any flag passed on this + // invocation taking precedence. The CLI has its own defaults domain, + // so the stored settings have to be read from the app's explicitly. + settings: AppPreferences.uploadSettings(applying: UploadSettingsOverrides( + chunkSizeMB: chunkSizeMB, + includeIPALink: includeIPALink, + includeDetails: moreDetails, + keepPreviousVersions: keepPreviousVersions)), share: share, keepSameLink: keepSameLink, bundleDirectory: dropboxFolder.map { "/" + $0.replacingOccurrences(of: " ", with: "") }) diff --git a/AppBoxCore/Sources/AppBoxCore/Upload/AppPreferences.swift b/AppBoxCore/Sources/AppBoxCore/Upload/AppPreferences.swift new file mode 100644 index 00000000..ba132bab --- /dev/null +++ b/AppBoxCore/Sources/AppBoxCore/Upload/AppPreferences.swift @@ -0,0 +1,91 @@ +// +// AppPreferences.swift +// AppBoxCore +// + +import Foundation + +/// The install-page preferences the GUI stores, read from wherever the caller runs. +public enum AppPreferences { + + /// The GUI's preference domain. + public static let suiteName = "com.developerinsider.AppBox" + + /// Keys exactly as the GUI writes them, including the misspelled IPA one. + enum Key { + static let chunkSize = "UploadChunkSize" + static let downloadIPA = "DonwloadIPAEnable" + static let moreDetails = "MoreDetailsEnable" + static let showPreviousVersions = "ShowPreviousVersions" + } + + /// What `DefaultSettings.setFirstTimeSettings()` assigns on the app's first launch. + enum FirstRun { + static let downloadIPA = false + static let moreDetails = true + static let showPreviousVersions = true + } + + /// The app's preference domain, or `nil` if it cannot be opened. + public static var appDefaults: UserDefaults? { UserDefaults(suiteName: suiteName) } + + /// The `UploadSettings` the GUI would use for the same upload, with any + /// caller-supplied overrides applied on top. + public static func uploadSettings( + from defaults: UserDefaults? = appDefaults, + applying overrides: UploadSettingsOverrides = .init()) -> UploadSettings { + var settings = storedUploadSettings(from: defaults) + if let value = overrides.chunkSizeMB { settings.chunkSizeMB = value } + if let value = overrides.includeIPALink { settings.includeIPALink = value } + if let value = overrides.includeDetails { settings.includeDetails = value } + if let value = overrides.keepPreviousVersions { settings.keepPreviousVersions = value } + return settings + } + + /// The stored preferences alone, before any override. + static func storedUploadSettings(from defaults: UserDefaults?) -> UploadSettings { + guard let defaults else { + return UploadSettings( + includeIPALink: FirstRun.downloadIPA, + includeDetails: FirstRun.moreDetails, + keepPreviousVersions: FirstRun.showPreviousVersions) + } + + func flag(_ key: String, unset fallback: Bool) -> Bool { + defaults.object(forKey: key) == nil ? fallback : defaults.bool(forKey: key) + } + + // Matches UserData.uploadChunkSize(): anything non-positive means "unset". + let chunkSize = defaults.integer(forKey: Key.chunkSize) + + return UploadSettings( + chunkSizeMB: chunkSize > 0 ? chunkSize : UploadSettings.defaultChunkSizeMB, + includeIPALink: flag(Key.downloadIPA, unset: FirstRun.downloadIPA), + includeDetails: flag(Key.moreDetails, unset: FirstRun.moreDetails), + keepPreviousVersions: flag(Key.showPreviousVersions, unset: FirstRun.showPreviousVersions)) + } +} + +/// Per-invocation overrides for the app's stored install-page preferences. +public struct UploadSettingsOverrides: Equatable, Sendable { + public var chunkSizeMB: Int? + public var includeIPALink: Bool? + public var includeDetails: Bool? + public var keepPreviousVersions: Bool? + + public init( + chunkSizeMB: Int? = nil, + includeIPALink: Bool? = nil, + includeDetails: Bool? = nil, + keepPreviousVersions: Bool? = nil) { + self.chunkSizeMB = chunkSizeMB + self.includeIPALink = includeIPALink + self.includeDetails = includeDetails + self.keepPreviousVersions = keepPreviousVersions + } + + /// True when nothing was overridden, so the stored preferences apply as-is. + public var isEmpty: Bool { + chunkSizeMB == nil && includeIPALink == nil && includeDetails == nil && keepPreviousVersions == nil + } +} diff --git a/AppBoxCore/Tests/AppBoxCoreTests/AppPreferencesTests.swift b/AppBoxCore/Tests/AppBoxCoreTests/AppPreferencesTests.swift new file mode 100644 index 00000000..aa67e11f --- /dev/null +++ b/AppBoxCore/Tests/AppBoxCoreTests/AppPreferencesTests.swift @@ -0,0 +1,144 @@ +import XCTest +@testable import AppBoxCore + +final class AppPreferencesTests: XCTestCase { + + private func makeDefaults() throws -> (UserDefaults, String) { + let suiteName = "AppBoxCoreTests-\(UUID().uuidString)" + let defaults = try XCTUnwrap(UserDefaults(suiteName: suiteName)) + return (defaults, suiteName) + } + + func testUploadSettings_readsTheAppsPreferences() throws { + let (defaults, suiteName) = try makeDefaults() + defer { defaults.removePersistentDomain(forName: suiteName) } + + defaults.set(true, forKey: AppPreferences.Key.moreDetails) + defaults.set(true, forKey: AppPreferences.Key.downloadIPA) + defaults.set(false, forKey: AppPreferences.Key.showPreviousVersions) + defaults.set(50, forKey: AppPreferences.Key.chunkSize) + + let settings = AppPreferences.uploadSettings(from: defaults) + XCTAssertTrue(settings.includeDetails) + XCTAssertTrue(settings.includeIPALink) + XCTAssertFalse(settings.keepPreviousVersions) + XCTAssertEqual(settings.chunkSizeMB, 50) + } + + func testUploadSettings_honoursAnExplicitFalse() throws { + let (defaults, suiteName) = try makeDefaults() + defer { defaults.removePersistentDomain(forName: suiteName) } + + defaults.set(false, forKey: AppPreferences.Key.moreDetails) + defaults.set(false, forKey: AppPreferences.Key.showPreviousVersions) + + let settings = AppPreferences.uploadSettings(from: defaults) + XCTAssertFalse(settings.includeDetails) + XCTAssertFalse(settings.keepPreviousVersions) + } + + func testUploadSettings_unwrittenKeysUseTheFirstRunDefaults() throws { + let (defaults, suiteName) = try makeDefaults() + defer { defaults.removePersistentDomain(forName: suiteName) } + + let settings = AppPreferences.uploadSettings(from: defaults) + XCTAssertTrue(settings.includeDetails, "a fresh app enables more details") + XCTAssertTrue(settings.keepPreviousVersions) + XCTAssertFalse(settings.includeIPALink) + XCTAssertEqual(settings.chunkSizeMB, UploadSettings.defaultChunkSizeMB) + } + + func testUploadSettings_nonPositiveChunkSizeFallsBack() throws { + let (defaults, suiteName) = try makeDefaults() + defer { defaults.removePersistentDomain(forName: suiteName) } + + defaults.set(0, forKey: AppPreferences.Key.chunkSize) + XCTAssertEqual(AppPreferences.uploadSettings(from: defaults).chunkSizeMB, UploadSettings.defaultChunkSizeMB) + + defaults.set(-5, forKey: AppPreferences.Key.chunkSize) + XCTAssertEqual(AppPreferences.uploadSettings(from: defaults).chunkSizeMB, UploadSettings.defaultChunkSizeMB) + } + + // MARK: - Per-invocation overrides + + func testUploadSettings_overridesBeatStoredPreferences() throws { + let (defaults, suiteName) = try makeDefaults() + defer { defaults.removePersistentDomain(forName: suiteName) } + + defaults.set(true, forKey: AppPreferences.Key.moreDetails) + defaults.set(false, forKey: AppPreferences.Key.downloadIPA) + defaults.set(100, forKey: AppPreferences.Key.chunkSize) + + let settings = AppPreferences.uploadSettings( + from: defaults, + applying: UploadSettingsOverrides( + chunkSizeMB: 25, + includeIPALink: true, + includeDetails: false)) + XCTAssertFalse(settings.includeDetails, "an override of false must beat a stored true") + XCTAssertTrue(settings.includeIPALink) + XCTAssertEqual(settings.chunkSizeMB, 25) + } + + func testUploadSettings_nilOverridesLeaveStoredValuesAlone() throws { + let (defaults, suiteName) = try makeDefaults() + defer { defaults.removePersistentDomain(forName: suiteName) } + + defaults.set(true, forKey: AppPreferences.Key.moreDetails) + defaults.set(true, forKey: AppPreferences.Key.downloadIPA) + defaults.set(75, forKey: AppPreferences.Key.chunkSize) + + let settings = AppPreferences.uploadSettings( + from: defaults, + applying: UploadSettingsOverrides(includeDetails: nil)) + XCTAssertTrue(settings.includeDetails) + XCTAssertTrue(settings.includeIPALink) + XCTAssertEqual(settings.chunkSizeMB, 75) + } + + func testUploadSettings_overridesApplyWithNoStoredPreferences() throws { + let (defaults, suiteName) = try makeDefaults() + defer { defaults.removePersistentDomain(forName: suiteName) } + + let settings = AppPreferences.uploadSettings( + from: defaults, + applying: UploadSettingsOverrides(includeIPALink: true, includeDetails: false)) + XCTAssertFalse(settings.includeDetails) + XCTAssertTrue(settings.includeIPALink) + } + + func testOverrides_isEmpty() { + XCTAssertTrue(UploadSettingsOverrides().isEmpty) + XCTAssertFalse(UploadSettingsOverrides(includeDetails: false).isEmpty) + XCTAssertFalse(UploadSettingsOverrides(chunkSizeMB: 10).isEmpty) + } + + func testDefaultLadder_flagBeatsPreferenceBeatsBuiltIn() throws { + let (defaults, suiteName) = try makeDefaults() + defer { defaults.removePersistentDomain(forName: suiteName) } + + XCTAssertTrue(AppPreferences.uploadSettings(from: defaults).includeDetails) + + defaults.set(false, forKey: AppPreferences.Key.moreDetails) + XCTAssertFalse(AppPreferences.uploadSettings(from: defaults).includeDetails) + + XCTAssertTrue(AppPreferences.uploadSettings( + from: defaults, + applying: UploadSettingsOverrides(includeDetails: true)).includeDetails) + } + + func testBuiltInDefaults_matchTheGUIsFirstInstallValues() { + XCTAssertTrue(AppPreferences.FirstRun.moreDetails) + XCTAssertFalse(AppPreferences.FirstRun.downloadIPA) + XCTAssertTrue(AppPreferences.FirstRun.showPreviousVersions) + XCTAssertEqual(UploadSettings.defaultChunkSizeMB, 100) + } + + func testKeys_matchWhatTheGUIWrites() { + XCTAssertEqual(AppPreferences.Key.downloadIPA, "DonwloadIPAEnable") + XCTAssertEqual(AppPreferences.Key.moreDetails, "MoreDetailsEnable") + XCTAssertEqual(AppPreferences.Key.showPreviousVersions, "ShowPreviousVersions") + XCTAssertEqual(AppPreferences.Key.chunkSize, "UploadChunkSize") + XCTAssertEqual(AppPreferences.suiteName, "com.developerinsider.AppBox") + } +} diff --git a/docs/CommandLineInterface/index.md b/docs/CommandLineInterface/index.md index aae0ec31..9b648bbf 100644 --- a/docs/CommandLineInterface/index.md +++ b/docs/CommandLineInterface/index.md @@ -136,6 +136,35 @@ Microsoft Teams Incoming Webhook URL to send notifications to a Teams channel. appboxcli --ipa app.ipa --msteamswebhook "https://outlook.office.com/webhook/YOUR/TEAMS/WEBHOOK" ``` +## Install Page Settings + +Four settings control what the install page shows. Each is resolved in this order: + +1. the flag passed to `appboxcli`, if any +2. otherwise the AppBox app's setting, if AppBox is installed and configured on the machine +3. otherwise the built-in default + +| Setting | Flag | Built-in default | +| --- | --- | --- | +| Expanded build details: minimum iOS version, supported devices, build type, IPA size, and provisioning profile (team, expiry, masked device UDIDs) | `--moredetails` / `--no-moredetails` | On | +| Direct IPA download link next to the install button | `--ipalink` / `--no-ipalink` | Off | +| Earlier builds listed on the page | `--previousversions` / `--no-previousversions` | On | +| Dropbox upload chunk size, 1-150 MB | `--chunksize ` | 100 MB | + +The app writes these same defaults on its first launch, so a fresh install and a machine that has never run AppBox behave identically. + +Pass the flags in CI, where the settings belong to the pipeline rather than to whatever the build machine's AppBox is configured for: + +```bash +appboxcli --ipa app.ipa \ + --emails "qa@example.com" \ + --moredetails \ + --no-ipalink \ + --chunksize 50 +``` + +Change the app-side values in **AppBox > Settings > General**. + ## Usage Examples ### Simple Upload From ec0b93a96c0ffc076b894551102fa8f523fc17c0 Mon Sep 17 00:00:00 2001 From: Vineet Choudhary Date: Wed, 23 Sep 2026 15:52:31 +0530 Subject: [PATCH 3/4] Auto enable "Keep Same Link" option if it was previously enabled for the same build --- AGENTS.md | 7 +- .../Common/UploadManager/UploadManager.swift | 23 ++--- .../CoreDataModel/ABProject+AppBox.swift | 25 +++--- .../DashboardView.swift | 62 +++++++++---- .../DashboardViewController.swift | 5 +- .../HomeViewController/HomeView.swift | 22 +++-- .../HomeViewController.swift | 41 ++++++++- .../UploadAdvancedSettingView.swift | 6 +- .../UploadAdvancedSettingViewController.swift | 23 +++-- AppBoxCLI/AppBoxCLI.swift | 10 ++- .../Abstractions/ArchiveExtractor.swift | 3 + .../ZipFoundationArchiveExtractor.swift | 11 +++ .../AppBoxCore/CoreData/ABUploadRecord.swift | 25 ++++++ .../CoreData/BuildDeletionService.swift | 37 +++++--- .../CoreData/BuildHistoryStore.swift | 18 +++- .../Sources/AppBoxCore/IPA/IPAExtractor.swift | 10 +++ .../AppBoxCore/Storage/StorageProvider.swift | 3 + .../AppBoxCore/Upload/BuildMetadata.swift | 31 ++++++- .../AppBoxCore/Upload/DeleteCoordinator.swift | 10 +++ .../ArchiveExtractorTests.swift | 18 ++++ .../BuildDeletionServiceTests.swift | 78 +++++++++++++++- .../BuildHistoryStoreTests.swift | 70 ++++++++++++++- .../AppBoxCoreTests/BuildMetadataTests.swift | 59 +++++++++++++ .../BuildRecordStoreTests.swift | 88 +++++++++++++++++++ .../DeleteCoordinatorTests.swift | 20 +++++ .../Tests/AppBoxCoreTests/Fakes/Fakes.swift | 2 +- .../AppBoxCoreTests/IPAExtractorTests.swift | 79 +++++++++++++++++ AppBoxTests/Model/IPAUploadInfoTests.swift | 23 +++++ Scripts/test_cli.sh | 80 ++++++++++++++++- docs/CommandLineInterface/index.md | 6 +- docs/Features/keepsamelink.md | 11 ++- 31 files changed, 807 insertions(+), 99 deletions(-) create mode 100644 AppBoxCore/Tests/AppBoxCoreTests/BuildRecordStoreTests.swift diff --git a/AGENTS.md b/AGENTS.md index 2c905b6c..21845533 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -36,12 +36,12 @@ The GUI is 100% Swift/SwiftUI, business logic lives in `AppBoxCore`, and `appbox 1. **No UI in `AppBoxCore`.** No `import AppKit` / `import SwiftUI` in Core — it must link into the CLI. UI is expressed only through protocols (e.g. `ProgressReporter`); the GUI and CLI each provide their own implementation. 2. **Dependency injection by initializer only.** Core types take their collaborators as protocol‑typed init parameters. No hidden singletons (`*.shared`) inside Core types. Each of GUI and CLI builds its own composition root (`AppEnvironment`) from production adapters. -3. **Core Data: v4 only, model is frozen.** Do **not** edit `AppBox.xcdatamodeld` (no entity or attribute renames — even the `dbDirectroy` typo stays). Swift `NSManagedObject` subclasses must keep explicit ObjC names matching the v4 model exactly: `@objc(ABProject)`, `@objc(ABUploadRecord)`, `@objc(ABProvisioningProfile)`, `@objc(ABProvisionedDevice)`, and `@objc(AppBoxService)` (**not** prefixed). Getting one wrong crashes on store load. +3. **Core Data: v4 only, model is frozen.** Do **not** edit `AppBox.xcdatamodeld` (no entity or attribute renames — even the `dbDirectroy` typo stays). Swift `NSManagedObject` subclasses must keep explicit ObjC names matching the v4 model exactly: `@objc(ABProject)`, `@objc(ABUploadRecord)`, `@objc(ABProvisioningProfile)`, `@objc(ABProvisionedDevice)`, and `@objc(AppBoxService)` (**not** prefixed). Getting one wrong crashes on store load. Read a build's app folder through `ABUploadRecord.linkSettings`, never raw `dbFolderName`: GUI and v3 records keep only the first segment of a nested folder, CLI records can hold `//`, and that folder is what a delete removes when an app's last version goes. 4. **Shared store + shared Keychain.** GUI and CLI open the same Core Data SQLite store (fixed Application Support path, pinned by `ABStorePaths` to the v3 location so upgrades don't orphan data) and share the Dropbox token through a common Keychain service string. Both are signed with team `3PQ7E4L589`. 5. **Every change ends shippable + green.** Never leave `develop` with a broken build or red tests at a session boundary. Small, self‑contained commits. 6. **Swift-native & Apple-first dependencies.** Prefer Swift SDKs over Objective-C ones (Dropbox → **SwiftyDropbox**, not `ObjectiveDropboxOfficial`) and a built-in Apple framework over a third-party dep wherever one exists (`os.Logger` not CocoaLumberjack; URLSession; CryptoKit). Keep a third-party dep only where Apple has no equivalent (ZIP → a **Swift** ZIP package, not the ObjC SSZipArchive). 7. **All cloud storage goes through `StorageProvider`.** Dropbox is just the first `DropboxStorageProvider`; the upload pipeline and UI depend on the protocol, never on a provider SDK. **No SwiftyDropbox (or any provider) type may leak outside its provider file.** Adding a backend (Google Drive, S3, …) must be a new file + a registry entry — nothing else. -8. **`appboxcli` is heading for full independence.** The CLI must keep working on a machine where the GUI has never run: CI runners are its main consumer, and each project wants different settings. So anything the GUI can configure has to be settable on the command line too; `AppPreferences` (the app's `com.developerinsider.AppBox` defaults domain) is a *fallback* for parity with the GUI, never the only source. Install-page settings resolve as **flag → app preference → built-in default** (more details on, IPA link off, previous versions on, 100 MB chunks); `DefaultSettings` writes those same values on the app's first launch, and `AppPreferences.FirstRun` must stay in step with it. A preference the GUI never wrote falls back to that default — never to a Swift zero value, which is how CLI uploads silently lost "more build details". Do not add a CLI behaviour that can only be driven from the GUI's preferences. +8. **`appboxcli` is heading for full independence.** The CLI must keep working on a machine where the GUI has never run: CI runners are its main consumer, and each project wants different settings. So anything the GUI can configure has to be settable on the command line too; `AppPreferences` (the app's `com.developerinsider.AppBox` defaults domain) is a *fallback* for parity with the GUI, never the only source. Install-page settings resolve as **flag → app preference → built-in default** (more details on, IPA link off, previous versions on, 100 MB chunks); `DefaultSettings` writes those same values on the app's first launch, and `AppPreferences.FirstRun` must stay in step with it. A preference the GUI never wrote falls back to that default — never to a Swift zero value, which is how CLI uploads silently lost "more build details". Do not add a CLI behaviour that can only be driven from the GUI's preferences. The same holds for upload history: the GUI pre-fills "Keep the same link" and its folder from the app's latest upload in the shared store, but the CLI never does. A CI run must not change behaviour because of what was uploaded from that machine before, so `--keepsamelink` / `--dbfolder` stay explicit on every run. 9. **No change stops at its own repo.** AppBox is six repos, and the five that ship code share one set of wire formats — every one of them can be broken from outside. Before you finish a change to the macOS app, `AppBoxCore`, `appboxcli`, `install-helper`, the iOS SDK or the fastlane plugin, **work out which of the others consume what you touched, grep those checkouts to confirm, and say what you found** — in the PR body or your summary, even when the answer is "nothing else consumes this". They are all cloned locally (paths in the table above), so there is no excuse for guessing. Editing `install-helper` means checking the GUI, CLI, SDK, fastlane plugin and WebApp; editing the CLI means checking the fastlane plugin (it shells out to `appboxcli`) and the GUI (shared store); editing the upload pipeline means checking the SDK and WebApp (they both read `appinfo.json`). A change that lands green here and silently breaks a consumer is a broken change. If a consumer needs a matching change, either make it in the same pass or write it into `plans/follow-ups.md` before you stop — never leave it undiscovered. 10. **Keep the living docs current.** Whenever you make meaningful progress or the user gives a new instruction, update `AGENTS.md` and `plans/follow-ups.md` in the same change. The docs are the contract; stale docs are a bug. @@ -108,6 +108,8 @@ xcodebuild test -project AppBox.xcodeproj -scheme AppBoxTests -testPlan AppBoxTe cd AppBoxCore && xcrun swift test ``` +Outside `AppBoxCore`, write `AppBoxCore.ProgressReporter`: the macOS 27 SDK adds `Foundation.ProgressReporter`, so the bare name is ambiguous in any file that imports both, and the build fails. + Coverage is enabled in `AppBoxTests/AppBoxTests.xctestplan` (scoped to the `AppBox` target). CI prints `xccov` report; gating is by diff coverage on changed Swift (ratchets up by phase). ### Release builds (Developer ID) @@ -147,6 +149,7 @@ Build intermediates (`AppBox.xcarchive`, `gui-export/`, `cli-derived/`, `cli-sta - **GUI:** launch → Dropbox login → select a real `.ipa` → upload → confirm OTA manifest + short link → confirm Slack/Teams/email notification (where configured). - **CLI:** on a machine **without the GUI**, `appboxcli --ipa ...` uploads and prints the link; auth uses the shared Keychain token or its own OAuth. Subcommands: `upload`, `login`, `logout`, `whoami`, `space`, `list`, `delete`. +- **CLI matrix:** `Scripts/test_cli.sh [n,m,…]` runs the upload scenarios against your Dropbox; `APPBOX_CLI=` tests a local build instead of the one on `PATH`. Tests 18–19 cover a nested `--dbfolder` with keep-same-link: the link stays the same, `list` shows the whole folder, and deleting the folder's only build (test 19, which is skipped while AppBox is running) leaves the parent folder's link intact. - **Core Data:** launch GUI against a v4 store copy → existing projects/uploads load; a CLI upload then appears in the GUI Dashboard (shared store). ## Resume protocol (start of every session) diff --git a/AppBox/Common/UploadManager/UploadManager.swift b/AppBox/Common/UploadManager/UploadManager.swift index fe7dba29..ce778308 100644 --- a/AppBox/Common/UploadManager/UploadManager.swift +++ b/AppBox/Common/UploadManager/UploadManager.swift @@ -86,7 +86,7 @@ public final class UploadManager: NSObject { ipaURL: ipaURL, settings: settings, keepSameLink: info.isKeepSameLinkEnabled, - bundleDirectory: info.bundleDirectory?.absoluteString, + bundleDirectory: info.isKeepSameLinkEnabled ? info.bundleDirectory?.absoluteString : nil, uuid: info.uuid ?? Common.generateUUID()) let reachability = ClosureReachability { AppDelegate.appDelegate.isInternetConnected } @@ -178,12 +178,10 @@ public final class UploadManager: NSObject { // MARK: - Delete public func deleteBuildFromDropboxAndDashboard() { - let keepSameLink = ipaUploadInfo?.isKeepSameLinkEnabled ?? false - let appInfoPath = ipaUploadInfo?.dbAppInfoJSONFullPath?.absoluteString ?? "" - let appFolder = uploadRecord?.dbFolderName ?? "" - let buildFolder = ipaUploadInfo?.dbDirectory?.absoluteString ?? "" - let requiredPaths = keepSameLink ? [appInfoPath, appFolder] : [buildFolder] - guard requiredPaths.allSatisfy({ !$0.isEmpty }) else { + createNewWorkingDirectory() + guard let record = uploadRecord, + let plan = try? DeletePlan(record: record, workingDirectory: URL(fileURLWithPath: workingDirectory ?? "")) else { + cleanupWorkingDirectory() _ = Common.showAlert( withTitle: "Can't delete from Dropbox", andMessage: "This build is missing its Dropbox location, so AppBox doesn't know what to remove.\n\nUse \"Delete only from Dashboard\" to remove the record.") @@ -191,17 +189,8 @@ public final class UploadManager: NSObject { return } - createNewWorkingDirectory() showStatus("Deleting...", showProgressBar: true, withProgress: -1) - let plan = DeletePlan( - keepSameLink: keepSameLink, - appInfoRemotePath: RemotePath(path: appInfoPath), - manifestLinkToRemove: uploadRecord?.dbSharedManifestURL ?? "", - appFolderPath: RemotePath(path: appFolder), - buildFolderPath: RemotePath(path: buildFolder), - workingDirectory: URL(fileURLWithPath: workingDirectory ?? "")) - let chunkSizeBytes = UserData.uploadChunkSize() * (1024 * 1024) let reachability = ClosureReachability { AppDelegate.appDelegate.isInternetConnected } let provider = DropboxSession.makeProvider(chunkSizeBytes: chunkSizeBytes, reachability: reachability) @@ -283,7 +272,7 @@ private final class ClosureReachability: Reachability { } } -private final class ClosureProgressReporter: ProgressReporter { +private final class ClosureProgressReporter: AppBoxCore.ProgressReporter { private let block: (String?, Double) -> Void init(_ block: @escaping (String?, Double) -> Void) { diff --git a/AppBox/Model/CoreDataModel/ABProject+AppBox.swift b/AppBox/Model/CoreDataModel/ABProject+AppBox.swift index 9fecb9bb..29e45df7 100644 --- a/AppBox/Model/CoreDataModel/ABProject+AppBox.swift +++ b/AppBox/Model/CoreDataModel/ABProject+AppBox.swift @@ -22,14 +22,9 @@ extension ABProject { } } -private extension IPAUploadInfo { - - /// The bundle-level folder, matching the old `pathComponents[0] + pathComponents[1]` derivation. - var dbFolderName: String { - let components = dbDirectory?.pathComponents ?? [] - return components.count > 1 ? components[0] + components[1] : "" - } +extension IPAUploadInfo { + /// The dashboard record for this finished upload. var buildRecordInput: BuildRecordInput { BuildRecordInput( identifier: identifer ?? "", @@ -39,19 +34,25 @@ private extension IPAUploadInfo { buildType: buildType, localIPAPath: (ipaFullPath as NSURL?)?.resourceSpecifier?.removingPercentEncoding, keepSameLink: isKeepSameLinkEnabled, - bundleDirectory: dbFolderName, + bundleDirectory: bundleDirectory?.absoluteString ?? "", buildDirectory: dbDirectory?.absoluteString ?? "", ipaRemotePath: dbIPAFullPath?.absoluteString ?? "", manifestRemotePath: dbManifestFullPath?.absoluteString ?? "", appInfoRemotePath: dbAppInfoJSONFullPath?.absoluteString ?? "", sharedIPAURL: ipaFileDBShareableURL?.absoluteString, sharedManifestURL: manifestFileSharableURL?.absoluteString, + sharedAppInfoURL: uniquelinkShareableURL?.absoluteString, shortURL: appShortShareableURL?.absoluteString, provisioning: mobileProvision.map { - MobileProvisionInfo(isValid: $0.isValid, uuid: $0.uuid, teamId: $0.teamId, - teamName: $0.teamName, buildType: $0.buildType, - createDate: $0.createDate, expirationDate: $0.expirationDate, - provisionedDevices: $0.provisionedDevices) + MobileProvisionInfo( + isValid: $0.isValid, + uuid: $0.uuid, + teamId: $0.teamId, + teamName: $0.teamName, + buildType: $0.buildType, + createDate: $0.createDate, + expirationDate: $0.expirationDate, + provisionedDevices: $0.provisionedDevices) }) } } diff --git a/AppBox/ViewController/DashboardViewController/DashboardView.swift b/AppBox/ViewController/DashboardViewController/DashboardView.swift index 4ddfd5ef..c649bcb1 100644 --- a/AppBox/ViewController/DashboardViewController/DashboardView.swift +++ b/AppBox/ViewController/DashboardViewController/DashboardView.swift @@ -15,19 +15,32 @@ public final class BuildRow: NSObject { public let date: String public let buildType: String public let team: String + public let keepSameLink: Bool + public let folder: String - public init(recordIndex: Int, name: String, bundleId: String, versionBuild: String, shortURL: String, - date: String, buildType: String, team: String) { - self.recordIndex = recordIndex - self.name = name - self.bundleId = bundleId - self.versionBuild = versionBuild - self.shortURL = shortURL - self.date = date - self.buildType = buildType - self.team = team - super.init() - } + public init( + recordIndex: Int, + name: String, + bundleId: String, + versionBuild: String, + shortURL: String, + date: String, + buildType: String, + team: String, + keepSameLink: Bool, + folder: String) { + self.recordIndex = recordIndex + self.name = name + self.bundleId = bundleId + self.versionBuild = versionBuild + self.shortURL = shortURL + self.date = date + self.buildType = buildType + self.team = team + self.keepSameLink = keepSameLink + self.folder = folder + super.init() + } } /// Bridges the build list + actions between the controller and the SwiftUI view. @@ -51,7 +64,7 @@ public final class DashboardModel: NSObject, ObservableObject { requestedShortURL = shortURL } - /// Distinct app names, sorted alphabetically (case-insensitive) for stable, predictable ordering — so deleting a build never reshuffles the app list by upload date. + /// Distinct app names, sorted alphabetically (case-insensitive). static func orderedApps(_ builds: [BuildRow]) -> [String] { var seen = Set() var names: [String] = [] @@ -65,7 +78,6 @@ public final class DashboardModel: NSObject, ObservableObject { struct DashboardView: View { @ObservedObject var model: DashboardModel - // @Published ("publishing changes from within view updates"). @State private var selectedAppName: String? @State private var selection: Int? @@ -162,13 +174,18 @@ struct DashboardView: View { private func buildRow(_ build: BuildRow) -> some View { HStack(spacing: 10) { - Image(systemName: "shippingbox.fill") - .font(IslandTypography.title3).foregroundColor(.accentColor).frame(width: 22) + BuildRowIcon() VStack(alignment: .leading, spacing: 2) { Text(build.versionBuild).font(IslandTypography.body).fontWeight(.medium).lineLimit(1) Text(build.date).font(IslandTypography.caption).foregroundColor(.secondary).lineLimit(1) } Spacer() + if build.keepSameLink { + Image(systemName: "link") + .font(IslandTypography.caption) + .foregroundColor(.secondary) + .help("Uploaded with Keep the same link") + } } .padding(.vertical, 4) } @@ -198,6 +215,8 @@ struct DashboardView: View { detailRow("Team", build.team) detailRow("Uploaded", build.date) detailRow("Short URL", build.shortURL) + detailRow("Keep same link", build.keepSameLink ? "On" : "Off") + detailRow("Dropbox folder", build.folder) } actions(recordIndex: build.recordIndex) @@ -250,6 +269,17 @@ struct DashboardView: View { } } +private struct BuildRowIcon: View { + @Environment(\.backgroundProminence) private var backgroundProminence + + var body: some View { + Image(systemName: "shippingbox.fill") + .font(IslandTypography.title3) + .foregroundColor(backgroundProminence == .increased ? .white : .accentColor) + .frame(width: 22) + } +} + /// Wraps the SwiftUI view in an NSView for the controller to host. public final class DashboardHost: NSObject { public static func makeView(model: DashboardModel) -> NSView { diff --git a/AppBox/ViewController/DashboardViewController/DashboardViewController.swift b/AppBox/ViewController/DashboardViewController/DashboardViewController.swift index 5118e947..376dd1c2 100644 --- a/AppBox/ViewController/DashboardViewController/DashboardViewController.swift +++ b/AppBox/ViewController/DashboardViewController/DashboardViewController.swift @@ -126,6 +126,7 @@ public final class DashboardViewController: NSViewController { } else { team = "N/A" } + let link = record.linkSettings return BuildRow( recordIndex: index, name: name, @@ -134,7 +135,9 @@ public final class DashboardViewController: NSViewController { shortURL: shortURL, date: date, buildType: buildType, - team: team) + team: team, + keepSameLink: link.keepSameLink, + folder: link.folder?.relativePath ?? "N/A") } private func record(at index: Int) -> ABUploadRecord? { diff --git a/AppBox/ViewController/HomeViewController/HomeView.swift b/AppBox/ViewController/HomeViewController/HomeView.swift index 9a38201f..22f54386 100644 --- a/AppBox/ViewController/HomeViewController/HomeView.swift +++ b/AppBox/ViewController/HomeViewController/HomeView.swift @@ -12,6 +12,7 @@ public final class HomeModel: NSObject, ObservableObject { @Published fileprivate var emails: String = "" @Published fileprivate var message: String = "" @Published fileprivate var keepSameLink: Bool = false + @Published fileprivate(set) var linkFolder: String? public var onChooseFile: (() -> Void)? public var onFileDropped: ((String) -> Void)? @@ -25,6 +26,7 @@ public final class HomeModel: NSObject, ObservableObject { public func setFileName(_ name: String?) { fileName = name } public func setProcessing(_ processing: Bool) { isProcessing = processing } + public func setLinkFolder(_ folder: String?) { linkFolder = folder } } struct HomeView: View { @@ -65,12 +67,20 @@ struct HomeView: View { prompt: "Add a personal message", text: $model.message) } - HStack(spacing: 8) { - Toggle("Keep the same link for this app", isOn: $model.keepSameLink) - Button { model.onSameLinkHelp?() } label: { Image(systemName: "questionmark.circle") } - .buttonStyle(.borderless) - .help("Learn more") - Spacer() + VStack(alignment: .leading, spacing: 4) { + HStack(spacing: 8) { + Toggle("Keep the same link for this app", isOn: $model.keepSameLink) + Button { model.onSameLinkHelp?() } label: { Image(systemName: "questionmark.circle") } + .buttonStyle(.borderless) + .help("Learn more") + Spacer() + } + if model.keepSameLink, let folder = model.linkFolder { + Text("Dropbox folder: \(folder)") + .font(IslandTypography.caption) + .foregroundColor(.secondary) + .textSelection(.enabled) + } } Spacer(minLength: 0) diff --git a/AppBox/ViewController/HomeViewController/HomeViewController.swift b/AppBox/ViewController/HomeViewController/HomeViewController.swift index 65a0368e..f63eca31 100644 --- a/AppBox/ViewController/HomeViewController/HomeViewController.swift +++ b/AppBox/ViewController/HomeViewController/HomeViewController.swift @@ -162,9 +162,45 @@ public final class HomeViewController: NSViewController, UploadAdvancedSettingVi model?.setFileName(fileURL.lastPathComponent) model?.emailsText = UserData.userEmail() model?.messageText = UserData.userMessage() + prefillSameLink(forIPAAt: fileURL) } } + /// Turns "Keep the same link" on, with its folder, when the app's latest upload kept its link. + private func prefillSameLink(forIPAAt fileURL: URL) { + let info = ipaUploadInfo + Task { [weak self] in + let metadata = try? await Task.detached(priority: .userInitiated) { + try IPAExtractor(archiveExtractor: ZipFoundationArchiveExtractor()).metadata(ofIPAAt: fileURL) + }.value + guard let self, let metadata, info === self.ipaUploadInfo, !AppDelegate.appDelegate.processing else { + return + } + info.identifer = metadata.identifier + do { + if let link = try BuildHistoryStore(stack: .shared).latestLink(forBundleIdentifier: metadata.identifier), + link.keepSameLink, let folder = link.folder { + self.model?.keepSameLinkEnabled = true + info.bundleDirectory = URL(string: folder.path) + } + } catch { + Self.log.error("Unable to read the upload history: \(error.localizedDescription)") + } + self.refreshLinkFolderHint() + } + } + + private func refreshLinkFolderHint() { + let requested = ipaUploadInfo.bundleDirectory?.absoluteString ?? "" + let identifier = ipaUploadInfo.identifer ?? "" + guard !requested.isEmpty || !identifier.isEmpty else { + model?.setLinkFolder(nil) + return + } + let folder = RemotePath(path: BuildRemotePaths.resolvedBundleDirectory(requested: requested, identifier: identifier)) + model?.setLinkFolder(folder.components.isEmpty ? "/" : folder.relativePath) + } + // MARK: - Actions private func advancedTapped() { @@ -239,6 +275,7 @@ public final class HomeViewController: NSViewController, UploadAdvancedSettingVi model?.emailsText = "" model?.messageText = "" model?.keepSameLinkEnabled = false + model?.setLinkFolder(nil) } model?.setProcessing(!finish) } @@ -262,7 +299,9 @@ public final class HomeViewController: NSViewController, UploadAdvancedSettingVi // MARK: - UploadAdvancedSettingViewDelegate - public func uploadAdvancedSettingSaveButtonTapped(_ sender: NSButton?) {} + public func uploadAdvancedSettingSaveButtonTapped(_ sender: NSButton?) { + refreshLinkFolderHint() + } public func uploadAdvancedSettingCancelButtonTapped(_ sender: NSButton?) {} // MARK: - Share URL diff --git a/AppBox/ViewController/UploadAdvancedSettingViewController/UploadAdvancedSettingView.swift b/AppBox/ViewController/UploadAdvancedSettingViewController/UploadAdvancedSettingView.swift index c8edc6d1..2ef5ff1a 100644 --- a/AppBox/ViewController/UploadAdvancedSettingViewController/UploadAdvancedSettingView.swift +++ b/AppBox/ViewController/UploadAdvancedSettingViewController/UploadAdvancedSettingView.swift @@ -8,6 +8,7 @@ import AppKit public final class AdvancedSettingsModel: NSObject, ObservableObject { @Published fileprivate var folderName: String let fieldEnabled: Bool + let placeholder: String public var onSave: (() -> Void)? public var onCancel: (() -> Void)? @@ -15,9 +16,10 @@ public final class AdvancedSettingsModel: NSObject, ObservableObject { /// Bridge so the controller can read the field on save. public var folderNameText: String { folderName } - public init(folderName: String, fieldEnabled: Bool) { + public init(folderName: String, fieldEnabled: Bool, placeholder: String) { self.folderName = folderName self.fieldEnabled = fieldEnabled + self.placeholder = placeholder super.init() } } @@ -31,7 +33,7 @@ struct UploadAdvancedSettingView: View { VStack(alignment: .leading, spacing: 6) { LabeledField(label: "Custom Dropbox folder name", - prompt: "e.g. MyApp", text: $model.folderName) + prompt: model.placeholder, text: $model.folderName) .disabled(!model.fieldEnabled) if !model.fieldEnabled { Text("Turn on “Keep the same link” to set a custom folder.") diff --git a/AppBox/ViewController/UploadAdvancedSettingViewController/UploadAdvancedSettingViewController.swift b/AppBox/ViewController/UploadAdvancedSettingViewController/UploadAdvancedSettingViewController.swift index 058510f5..86c86788 100644 --- a/AppBox/ViewController/UploadAdvancedSettingViewController/UploadAdvancedSettingViewController.swift +++ b/AppBox/ViewController/UploadAdvancedSettingViewController/UploadAdvancedSettingViewController.swift @@ -3,6 +3,7 @@ // AppBox import AppKit +import AppBoxCore public protocol UploadAdvancedSettingViewDelegate: AnyObject { func uploadAdvancedSettingSaveButtonTapped(_ sender: NSButton?) @@ -15,11 +16,14 @@ public final class UploadAdvancedSettingViewController: NSViewController { public weak var delegate: UploadAdvancedSettingViewDelegate? private var model: AdvancedSettingsModel? + private var initialFolder = "" public override func loadView() { - let folder = ipaUploadInfo?.bundleDirectory?.lastPathComponent ?? "" - let model = AdvancedSettingsModel(folderName: folder, - fieldEnabled: ipaUploadInfo?.isKeepSameLinkEnabled ?? false) + initialFolder = RemotePath(path: ipaUploadInfo?.bundleDirectory?.absoluteString ?? "").relativePath + let model = AdvancedSettingsModel( + folderName: initialFolder, + fieldEnabled: ipaUploadInfo?.isKeepSameLinkEnabled ?? false, + placeholder: ipaUploadInfo?.identifer ?? "e.g. MyApp") self.model = model model.onSave = { [weak self] in self?.saveSettings() } model.onCancel = { [weak self] in self?.cancelSettings() } @@ -34,13 +38,16 @@ public final class UploadAdvancedSettingViewController: NSViewController { } private func saveSettings() { - delegate?.uploadAdvancedSettingSaveButtonTapped(nil) - - if let folder = model?.folderNameText, folder != ipaUploadInfo?.identifer, !folder.isEmpty { - let bundlePath = "/\(folder)".replacingOccurrences(of: " ", with: "") - ipaUploadInfo?.bundleDirectory = URL(string: bundlePath) + let folder = (model?.folderNameText ?? "").trimmingCharacters(in: .whitespaces) + if folder != initialFolder { + if folder.isEmpty || folder == ipaUploadInfo?.identifer { + ipaUploadInfo?.bundleDirectory = nil + } else { + ipaUploadInfo?.bundleDirectory = URL(string: "/\(folder)".replacingOccurrences(of: " ", with: "")) + } } + delegate?.uploadAdvancedSettingSaveButtonTapped(nil) dismiss(self) } } diff --git a/AppBoxCLI/AppBoxCLI.swift b/AppBoxCLI/AppBoxCLI.swift index 0855c6f8..4521d363 100644 --- a/AppBoxCLI/AppBoxCLI.swift +++ b/AppBoxCLI/AppBoxCLI.swift @@ -129,14 +129,15 @@ struct List: ParsableCommand { let dateFormatter = DateFormatter() dateFormatter.dateFormat = "yyyy-MM-dd HH:mm" - var rows: [(date: String, app: String, version: String, type: String, link: String)] = - [(date: "DATE", app: "APP", version: "VERSION", type: "TYPE", link: "LINK")] + var rows: [(date: String, app: String, version: String, type: String, sameLink: String, link: String)] = + [(date: "DATE", app: "APP", version: "VERSION", type: "TYPE", sameLink: "SAME LINK", link: "LINK")] for b in builds { rows.append(( date: b.datetime.map { dateFormatter.string(from: $0) } ?? "—", app: b.appName ?? b.bundleIdentifier ?? "—", version: "\(b.version ?? "—") (\(b.build ?? "—"))", type: b.buildType ?? "—", + sameLink: b.link.keepSameLink ? (b.link.folder?.relativePath ?? "—") : "no", link: b.shortURL ?? "—")) } @@ -144,10 +145,11 @@ struct List: ParsableCommand { let wApp = rows.map { $0.app.count }.max() ?? 0 let wVersion = rows.map { $0.version.count }.max() ?? 0 let wType = rows.map { $0.type.count }.max() ?? 0 + let wSameLink = rows.map { $0.sameLink.count }.max() ?? 0 func pad(_ s: String, _ width: Int) -> String { s.padding(toLength: width, withPad: " ", startingAt: 0) } for row in rows { - print("\(pad(row.date, wDate)) \(pad(row.app, wApp)) \(pad(row.version, wVersion)) \(pad(row.type, wType)) \(row.link)") + print("\(pad(row.date, wDate)) \(pad(row.app, wApp)) \(pad(row.version, wVersion)) \(pad(row.type, wType)) \(pad(row.sameLink, wSameLink)) \(row.link)") } } } @@ -246,7 +248,7 @@ struct Upload: AsyncParsableCommand { extension Upload { /// Streams each pipeline stage to stdout so a CI log shows the same progress the GUI HUD would. - private final class PrintingProgressReporter: ProgressReporter { + private final class PrintingProgressReporter: AppBoxCore.ProgressReporter { private var lastLine = "" func report(stage: UploadStage, message: String?, fractionCompleted: Double?) { diff --git a/AppBoxCore/Sources/AppBoxCore/Abstractions/ArchiveExtractor.swift b/AppBoxCore/Sources/AppBoxCore/Abstractions/ArchiveExtractor.swift index ef333944..12bb5335 100644 --- a/AppBoxCore/Sources/AppBoxCore/Abstractions/ArchiveExtractor.swift +++ b/AppBoxCore/Sources/AppBoxCore/Abstractions/ArchiveExtractor.swift @@ -7,4 +7,7 @@ public protocol ArchiveExtractor: AnyObject { /// Lists the entry paths inside the zip (without extracting) — used to locate the payload before unzipping. func entries(ofArchiveAt archiveURL: URL) throws -> [String] + + /// Reads one entry's bytes without extracting the rest of the archive. + func contents(ofEntry path: String, inArchiveAt archiveURL: URL) throws -> Data } diff --git a/AppBoxCore/Sources/AppBoxCore/Adapters/ZipFoundationArchiveExtractor.swift b/AppBoxCore/Sources/AppBoxCore/Adapters/ZipFoundationArchiveExtractor.swift index 5b2a5871..aa532965 100644 --- a/AppBoxCore/Sources/AppBoxCore/Adapters/ZipFoundationArchiveExtractor.swift +++ b/AppBoxCore/Sources/AppBoxCore/Adapters/ZipFoundationArchiveExtractor.swift @@ -20,4 +20,15 @@ public final class ZipFoundationArchiveExtractor: ArchiveExtractor { let archive = try Archive(url: archiveURL, accessMode: .read) return archive.map { $0.path } } + + public func contents(ofEntry path: String, inArchiveAt archiveURL: URL) throws -> Data { + let archive = try Archive(url: archiveURL, accessMode: .read) + guard let entry = archive[path] else { + throw CocoaError(.fileReadNoSuchFile, userInfo: [NSFilePathErrorKey: path]) + } + var data = Data() + let checksum = try archive.extract(entry) { data.append($0) } + guard checksum == entry.checksum else { throw Archive.ArchiveError.invalidCRC32 } + return data + } } diff --git a/AppBoxCore/Sources/AppBoxCore/CoreData/ABUploadRecord.swift b/AppBoxCore/Sources/AppBoxCore/CoreData/ABUploadRecord.swift index 1d39b111..57a1f90e 100644 --- a/AppBoxCore/Sources/AppBoxCore/CoreData/ABUploadRecord.swift +++ b/AppBoxCore/Sources/AppBoxCore/CoreData/ABUploadRecord.swift @@ -27,3 +27,28 @@ public class ABUploadRecord: NSManagedObject { @NSManaged public var provisioningProfile: ABProvisioningProfile? @NSManaged public var service: AppBoxService? } + +/// Whether a build kept its app's link, and the app-level folder it was uploaded into. +public struct BuildLinkSettings: Equatable, Sendable { + public let keepSameLink: Bool + public let folder: RemotePath? + + public init(keepSameLink: Bool, folder: RemotePath?) { + self.keepSameLink = keepSameLink + self.folder = folder + } +} + +extension ABUploadRecord { + /// The link settings this build was uploaded with, read from the record's own attributes only. + public var linkSettings: BuildLinkSettings { + let keepSameLink = self.keepSameLink?.boolValue ?? false + return BuildLinkSettings( + keepSameLink: keepSameLink, + folder: BuildRemotePaths.appFolder( + keepSameLink: keepSameLink, + appInfoPath: dbAppInfoFullPath, + buildDirectory: dbDirectroy, + folderName: dbFolderName)) + } +} diff --git a/AppBoxCore/Sources/AppBoxCore/CoreData/BuildDeletionService.swift b/AppBoxCore/Sources/AppBoxCore/CoreData/BuildDeletionService.swift index 773428bf..f9465e6c 100644 --- a/AppBoxCore/Sources/AppBoxCore/CoreData/BuildDeletionService.swift +++ b/AppBoxCore/Sources/AppBoxCore/CoreData/BuildDeletionService.swift @@ -50,22 +50,12 @@ public final class BuildDeletionService { try? FileManager.default.removeItem(at: workingDirectory) } let plan: DeletePlan = try context.performAndWait { - let keepSameLink = record.keepSameLink?.boolValue ?? false - let appInfoPath = record.dbAppInfoFullPath ?? "" - let appFolder = record.dbFolderName ?? "" - let buildFolder = record.dbDirectroy ?? "" - let requiredPaths = keepSameLink ? [appInfoPath, appFolder] : [buildFolder] - guard requiredPaths.allSatisfy({ !$0.isEmpty }) else { + do { + return try DeletePlan(record: record, workingDirectory: workingDirectory) + } catch is UnusableBuildLocationError { throw NSError(domain: "com.developerinsider.AppBox", code: 9997, userInfo: [ NSLocalizedDescriptionKey: "This record is missing its Dropbox location. Use --dashboard-only to remove it from the dashboard."]) } - return DeletePlan( - keepSameLink: keepSameLink, - appInfoRemotePath: RemotePath(path: appInfoPath), - manifestLinkToRemove: record.dbSharedManifestURL ?? "", - appFolderPath: RemotePath(path: appFolder), - buildFolderPath: RemotePath(path: buildFolder), - workingDirectory: workingDirectory) } _ = try await DeleteCoordinator(provider: providerFactory()).run(plan) } @@ -81,3 +71,24 @@ public final class BuildDeletionService { try ABStorePaths.makeTemporaryWorkingDirectory(prefix: "delete-") } } + +extension DeletePlan { + /// The delete for a stored build; throws `UnusableBuildLocationError` unless the folder it may remove is known and isn't the storage root. + public init(record: ABUploadRecord, workingDirectory: URL) throws { + let link = record.linkSettings + let appInfoPath = RemotePath(path: record.dbAppInfoFullPath ?? "") + let buildFolder = RemotePath(path: record.dbDirectroy ?? "") + let hasLocation = link.keepSameLink + ? appInfoPath.components.last == UploadCoordinator.appInfoFilename && link.folder != nil + : !buildFolder.components.isEmpty + guard hasLocation else { throw UnusableBuildLocationError() } + + self.init( + keepSameLink: link.keepSameLink, + appInfoRemotePath: appInfoPath, + manifestLinkToRemove: record.dbSharedManifestURL ?? "", + appFolderPath: link.folder ?? RemotePath([]), + buildFolderPath: buildFolder, + workingDirectory: workingDirectory) + } +} diff --git a/AppBoxCore/Sources/AppBoxCore/CoreData/BuildHistoryStore.swift b/AppBoxCore/Sources/AppBoxCore/CoreData/BuildHistoryStore.swift index 15394043..8a420ad4 100644 --- a/AppBoxCore/Sources/AppBoxCore/CoreData/BuildHistoryStore.swift +++ b/AppBoxCore/Sources/AppBoxCore/CoreData/BuildHistoryStore.swift @@ -1,7 +1,7 @@ import CoreData import Foundation -/// One row of AppBox upload history — the data the GUI Dashboard shows, as plain values so the CLI never touches `NSManagedObject`. +/// One row of AppBox upload history, as plain values. public struct BuildHistoryEntry: Equatable { public let appName: String? public let bundleIdentifier: String? @@ -11,6 +11,7 @@ public struct BuildHistoryEntry: Equatable { public let teamName: String? public let datetime: Date? public let shortURL: String? + public let link: BuildLinkSettings init(record: ABUploadRecord) { appName = record.project?.name @@ -21,10 +22,11 @@ public struct BuildHistoryEntry: Equatable { teamName = record.provisioningProfile?.teamName datetime = record.datetime shortURL = record.shortURL + link = record.linkSettings } } -/// Reads the AppBox upload history from the shared Core Data store for the CLI — the counterpart to `DropboxCLISession` (which reads Dropbox). +/// Reads the AppBox upload history from the shared Core Data store. public final class BuildHistoryStore { private let stack: CoreDataStack @@ -48,4 +50,16 @@ public final class BuildHistoryStore { return try context.fetch(request).map(BuildHistoryEntry.init) } } + + /// The link settings of the newest upload of `bundleIdentifier`, or nil when that app was never uploaded. + public func latestLink(forBundleIdentifier bundleIdentifier: String) throws -> BuildLinkSettings? { + let context = try stack.loadViewContext() + return try context.performAndWait { + let request = NSFetchRequest(entityName: "UploadRecord") + request.predicate = NSPredicate(format: "project.bundleIdentifier == %@", bundleIdentifier) + request.sortDescriptors = [NSSortDescriptor(key: "datetime", ascending: false)] + request.fetchLimit = 1 + return try context.fetch(request).first?.linkSettings + } + } } diff --git a/AppBoxCore/Sources/AppBoxCore/IPA/IPAExtractor.swift b/AppBoxCore/Sources/AppBoxCore/IPA/IPAExtractor.swift index c73f44ce..cb8abea8 100644 --- a/AppBoxCore/Sources/AppBoxCore/IPA/IPAExtractor.swift +++ b/AppBoxCore/Sources/AppBoxCore/IPA/IPAExtractor.swift @@ -71,4 +71,14 @@ public final class IPAExtractor { mobileProvisionURL: layout.mobileProvisionPath.map { destination.appendingPathComponent($0) } ) } + + /// Reads the build metadata straight from the IPA's `Info.plist`, without unzipping the rest. + public func metadata(ofIPAAt ipaURL: URL) throws -> BuildMetadata { + guard let layout = ExtractedIPALocator.locate(entries: try archiveExtractor.entries(ofArchiveAt: ipaURL)), + let metadata = BuildMetadata.read( + fromInfoPlistData: try archiveExtractor.contents(ofEntry: layout.infoPlistPath, inArchiveAt: ipaURL)) else { + throw IPAExtractionError.invalidIPA + } + return metadata + } } diff --git a/AppBoxCore/Sources/AppBoxCore/Storage/StorageProvider.swift b/AppBoxCore/Sources/AppBoxCore/Storage/StorageProvider.swift index c0ac0c27..814e024e 100644 --- a/AppBoxCore/Sources/AppBoxCore/Storage/StorageProvider.swift +++ b/AppBoxCore/Sources/AppBoxCore/Storage/StorageProvider.swift @@ -42,6 +42,9 @@ public struct RemotePath: Equatable, Sendable { /// POSIX-style joined path, always leading-slashed (Dropbox-style). public var path: String { "/" + components.joined(separator: "/") } + /// The joined path without a leading slash, as people type and read it (`Team/QA`). + public var relativePath: String { components.joined(separator: "/") } + public func appending(_ component: String) -> RemotePath { RemotePath(components + [component]) } } diff --git a/AppBoxCore/Sources/AppBoxCore/Upload/BuildMetadata.swift b/AppBoxCore/Sources/AppBoxCore/Upload/BuildMetadata.swift index 9136eea5..c34b224e 100644 --- a/AppBoxCore/Sources/AppBoxCore/Upload/BuildMetadata.swift +++ b/AppBoxCore/Sources/AppBoxCore/Upload/BuildMetadata.swift @@ -42,8 +42,13 @@ public struct BuildMetadata: Equatable, Sendable { /// Reads and parses the `Info.plist` an `IPAExtractor` produced. public static func read(fromInfoPlistAt url: URL) -> BuildMetadata? { - guard let data = try? Data(contentsOf: url), - let plist = try? PropertyListSerialization.propertyList(from: data, options: [], format: nil), + guard let data = try? Data(contentsOf: url) else { return nil } + return read(fromInfoPlistData: data) + } + + /// Parses the bytes of an `Info.plist`, XML or binary. + public static func read(fromInfoPlistData data: Data) -> BuildMetadata? { + guard let plist = try? PropertyListSerialization.propertyList(from: data, options: [], format: nil), let dictionary = plist as? [AnyHashable: Any] else { return nil } @@ -61,8 +66,7 @@ public struct BuildRemotePaths: Equatable, Sendable { /// Derives the layout used since v3: `/-ver()-/`, with `appinfo.json` hoisted to the bundle directory when the short link must stay stable. public init(metadata: BuildMetadata, uuid: String, bundleDirectory: String? = nil, keepSameLink: Bool) { - let requested = (bundleDirectory?.isEmpty == false) ? bundleDirectory! : "/\(metadata.identifier)" - let validBundleDirectory = IPAName.sanitizedPath(requested) + let validBundleDirectory = Self.resolvedBundleDirectory(requested: bundleDirectory, identifier: metadata.identifier) let validName = IPAName.sanitizedPathComponent(metadata.name) let folder = validName + "-ver\(IPAName.sanitizedPathComponent(metadata.version))" @@ -78,6 +82,25 @@ public struct BuildRemotePaths: Equatable, Sendable { ? "\(validBundleDirectory)/appinfo.json" : "\(buildDirectory)/appinfo.json") } + + /// The bundle-level folder an upload lands in: the requested folder, else `/`, sanitized. + public static func resolvedBundleDirectory(requested: String?, identifier: String) -> String { + let requested = (requested?.isEmpty == false) ? requested! : "/\(identifier)" + return IPAName.sanitizedPath(requested) + } + + /// The app-level folder of a stored build, read from its first recorded path; nil when that would be the storage root. + public static func appFolder(keepSameLink: Bool, appInfoPath: String?, buildDirectory: String?, + folderName: String?) -> RemotePath? { + let recordedPaths = keepSameLink ? [appInfoPath, buildDirectory] : [buildDirectory] + let folder: RemotePath + if let recorded = recordedPaths.compactMap({ $0 }).first(where: { !$0.isEmpty }) { + folder = RemotePath(Array(RemotePath(path: recorded).components.dropLast())) + } else { + folder = RemotePath(path: folderName ?? "") + } + return folder.components.isEmpty ? nil : folder + } } /// Writes the share URLs to `~/.appbox_share_value.json` so a CI step can read them back. diff --git a/AppBoxCore/Sources/AppBoxCore/Upload/DeleteCoordinator.swift b/AppBoxCore/Sources/AppBoxCore/Upload/DeleteCoordinator.swift index 6eae1b69..c5097e87 100644 --- a/AppBoxCore/Sources/AppBoxCore/Upload/DeleteCoordinator.swift +++ b/AppBoxCore/Sources/AppBoxCore/Upload/DeleteCoordinator.swift @@ -26,6 +26,15 @@ public struct DeletePlan { } } +/// A build whose recorded location is missing or resolves to the storage root, so nothing can be deleted safely. +public struct UnusableBuildLocationError: LocalizedError, Equatable { + public init() {} + + public var errorDescription: String? { + "This build is missing its Dropbox location, so AppBox doesn't know what to remove." + } +} + /// What a delete did: removed a whole folder, or removed one version and re-uploaded `appinfo.json`. public enum DeleteOutcome: Equatable { case deletedFolder(RemotePath) @@ -86,6 +95,7 @@ public final class DeleteCoordinator { // MARK: - Steps private func delete(_ remotePath: RemotePath) async throws { + guard !remotePath.components.isEmpty else { throw UnusableBuildLocationError() } progress.report(stage: .preparing, message: "Deleting…", fractionCompleted: nil) try await withRetry { try await self.provider.delete(at: remotePath) } } diff --git a/AppBoxCore/Tests/AppBoxCoreTests/ArchiveExtractorTests.swift b/AppBoxCore/Tests/AppBoxCoreTests/ArchiveExtractorTests.swift index b1bbce44..03553052 100644 --- a/AppBoxCore/Tests/AppBoxCoreTests/ArchiveExtractorTests.swift +++ b/AppBoxCore/Tests/AppBoxCoreTests/ArchiveExtractorTests.swift @@ -42,4 +42,22 @@ final class ArchiveExtractorTests: XCTestCase { try ZipFoundationArchiveExtractor().extract(archiveAt: archive, to: outDir) XCTAssertTrue(fileManager.fileExists(atPath: outDir.appendingPathComponent("src/f.txt").path)) } + + func testReadsOneEntryWithoutExtracting() throws { + let fileManager = FileManager.default + let tmp = fileManager.temporaryDirectory.appendingPathComponent(UUID().uuidString, isDirectory: true) + try fileManager.createDirectory(at: tmp, withIntermediateDirectories: true) + defer { try? fileManager.removeItem(at: tmp) } + + let sourceDir = tmp.appendingPathComponent("Payload", isDirectory: true) + try fileManager.createDirectory(at: sourceDir, withIntermediateDirectories: true) + try Data("AppBox".utf8).write(to: sourceDir.appendingPathComponent("Info.txt")) + let archive = tmp.appendingPathComponent("app.zip") + try fileManager.zipItem(at: sourceDir, to: archive, compressionMethod: .deflate) + + let extractor = ZipFoundationArchiveExtractor() + let data = try extractor.contents(ofEntry: "Payload/Info.txt", inArchiveAt: archive) + XCTAssertEqual(String(decoding: data, as: UTF8.self), "AppBox") + XCTAssertThrowsError(try extractor.contents(ofEntry: "Payload/Missing.txt", inArchiveAt: archive)) + } } diff --git a/AppBoxCore/Tests/AppBoxCoreTests/BuildDeletionServiceTests.swift b/AppBoxCore/Tests/AppBoxCoreTests/BuildDeletionServiceTests.swift index 438228dc..cdbadda5 100644 --- a/AppBoxCore/Tests/AppBoxCoreTests/BuildDeletionServiceTests.swift +++ b/AppBoxCore/Tests/AppBoxCoreTests/BuildDeletionServiceTests.swift @@ -8,6 +8,8 @@ private final class RecordingProvider: StorageProvider { var currentAccount: StorageAccount? var deleteError: StorageError? + var appInfoJSON: Data? + private(set) var deletedPaths: [RemotePath] = [] private(set) var downloadedPaths: [RemotePath] = [] private(set) var uploadedPaths: [RemotePath] = [] @@ -22,7 +24,11 @@ private final class RecordingProvider: StorageProvider { if let deleteError { throw deleteError } deletedPaths.append(remotePath) } - func download(from remotePath: RemotePath, to localURL: URL) async throws { downloadedPaths.append(remotePath) } + func download(from remotePath: RemotePath, to localURL: URL) async throws { + downloadedPaths.append(remotePath) + guard let appInfoJSON else { throw StorageError.notFound } + try appInfoJSON.write(to: localURL) + } var usedAtAll: Bool { !deletedPaths.isEmpty || !downloadedPaths.isEmpty || !uploadedPaths.isEmpty } } @@ -80,7 +86,8 @@ final class BuildDeletionServiceTests: XCTestCase { } private func seedRecord(_ stack: CoreDataStack, name: String, keepSameLink: Bool, - buildFolder: String, manifestLink: String) throws { + buildFolder: String, manifestLink: String, + folderName: String? = nil, appInfoPath: String? = nil) throws { let ctx = try stack.loadViewContext() let project = NSEntityDescription.insertNewObject(forEntityName: "Project", into: ctx) as! ABProject project.name = name; project.bundleIdentifier = "com.\(name)" @@ -89,13 +96,23 @@ final class BuildDeletionServiceTests: XCTestCase { record.shortURL = "https://s/\(name)" record.keepSameLink = NSNumber(value: keepSameLink) record.dbDirectroy = buildFolder - record.dbFolderName = "/\(name)" - record.dbAppInfoFullPath = "/\(name)/appinfo.json" + record.dbFolderName = folderName ?? "/\(name)" + record.dbAppInfoFullPath = appInfoPath ?? "/\(name)/appinfo.json" record.dbSharedManifestURL = manifestLink record.project = project try stack.saveChanges() } + private func appInfoJSON(manifestLinks: [String]) throws -> Data { + let entries = manifestLinks.map { + AppInfoJSON.makeEntry(AppVersionInput(name: "App", version: "1", build: "1", identifier: "com.x", + manifestLink: $0, timestamp: 1, shareableIPALink: "ipa", + includeIPALink: false, includeDetails: false)) + } + return try JSONEncoder().encode(AppInfoFile(latestVersion: entries.last, versions: entries, + uniqueLinkShared: "https://s/full", uniqueLinkShort: "https://s/abc")) + } + private func recordCount(_ stack: CoreDataStack) throws -> Int { try stack.loadViewContext().count(for: NSFetchRequest(entityName: "UploadRecord")) } @@ -146,6 +163,59 @@ final class BuildDeletionServiceTests: XCTestCase { XCTAssertEqual(try recordCount(stack), 1) } + func testDeleteFromDropbox_keptNestedFolderWithTruncatedName_deletesTheWholeAppFolderOnly() async throws { + let stack = writableStack(makeModel()) + try seedRecord(stack, name: "App", keepSameLink: true, buildFolder: "/Team/QA/App-ver1.0(1)-X", manifestLink: "m1", + folderName: "/Team", appInfoPath: "/Team/QA/appinfo.json") + let provider = RecordingProvider() + provider.appInfoJSON = try appInfoJSON(manifestLinks: ["m1"]) + let service = BuildDeletionService(stack: stack, providerFactory: { provider }) + + try service.loadBuilds() + try await service.delete(at: 0, fromDropbox: true) + + XCTAssertEqual(provider.deletedPaths.map(\.path), ["/Team/QA"]) + XCTAssertEqual(try recordCount(stack), 0) + } + + func testDeleteFromDropbox_keptRecordAtTheStorageRoot_refusesWithoutTouchingDropbox() async throws { + let stack = writableStack(makeModel()) + try seedRecord(stack, name: "App", keepSameLink: true, buildFolder: "//App-ver1.0(1)-X", manifestLink: "m1", + folderName: "//", appInfoPath: "/appinfo.json") + let provider = RecordingProvider() + provider.appInfoJSON = try appInfoJSON(manifestLinks: ["m1"]) + let service = BuildDeletionService(stack: stack, providerFactory: { provider }) + + try service.loadBuilds() + do { + try await service.delete(at: 0, fromDropbox: true) + XCTFail("expected a delete at the storage root to be refused") + } catch { + XCTAssertTrue((error as NSError).localizedDescription.contains("missing its Dropbox location")) + } + XCTAssertFalse(provider.usedAtAll) + XCTAssertEqual(try recordCount(stack), 1) + } + + func testDeleteFromDropbox_keptRecordWhoseAppInfoPathIsNotAnAppInfoFile_refuses() async throws { + let stack = writableStack(makeModel()) + try seedRecord(stack, name: "App", keepSameLink: true, buildFolder: "/Team/MyApp/App-ver1.0(1)-X", manifestLink: "m1", + folderName: "/Team", appInfoPath: "/Team/MyApp") + let provider = RecordingProvider() + provider.appInfoJSON = try appInfoJSON(manifestLinks: ["m1"]) + let service = BuildDeletionService(stack: stack, providerFactory: { provider }) + + try service.loadBuilds() + do { + try await service.delete(at: 0, fromDropbox: true) + XCTFail("expected a delete without a recorded appinfo.json to be refused") + } catch { + XCTAssertTrue((error as NSError).localizedDescription.contains("missing its Dropbox location")) + } + XCTAssertFalse(provider.usedAtAll) + XCTAssertEqual(try recordCount(stack), 1) + } + func testDeleteOutOfRange_throws() async throws { let stack = writableStack(makeModel()) let service = BuildDeletionService(stack: stack, providerFactory: { RecordingProvider() }) diff --git a/AppBoxCore/Tests/AppBoxCoreTests/BuildHistoryStoreTests.swift b/AppBoxCore/Tests/AppBoxCoreTests/BuildHistoryStoreTests.swift index b0903add..bcf4b284 100644 --- a/AppBoxCore/Tests/AppBoxCoreTests/BuildHistoryStoreTests.swift +++ b/AppBoxCore/Tests/AppBoxCoreTests/BuildHistoryStoreTests.swift @@ -28,7 +28,9 @@ final class BuildHistoryStoreTests: XCTestCase { project.properties = [attr("name", .stringAttributeType), attr("bundleIdentifier", .stringAttributeType)] record.properties = [attr("version", .stringAttributeType), attr("build", .stringAttributeType), - attr("datetime", .dateAttributeType), attr("shortURL", .stringAttributeType)] + attr("datetime", .dateAttributeType), attr("shortURL", .stringAttributeType), + attr("keepSameLink", .booleanAttributeType), attr("dbFolderName", .stringAttributeType), + attr("dbDirectroy", .stringAttributeType), attr("dbAppInfoFullPath", .stringAttributeType)] profile.properties = [attr("buildType", .stringAttributeType), attr("teamName", .stringAttributeType)] func rel(_ name: String, _ dest: NSEntityDescription, toMany: Bool, ordered: Bool) -> NSRelationshipDescription { @@ -75,6 +77,22 @@ final class BuildHistoryStoreTests: XCTestCase { return record } + private func seedUpload(_ stack: CoreDataStack, bundleIdentifier: String, keepSameLink: Bool?, + folder: String, date: Date) throws { + let ctx = try stack.loadViewContext() + let project = NSEntityDescription.insertNewObject(forEntityName: "Project", into: ctx) as! ABProject + project.name = "App"; project.bundleIdentifier = bundleIdentifier + let record = NSEntityDescription.insertNewObject(forEntityName: "UploadRecord", into: ctx) as! ABUploadRecord + let buildDirectory = "\(folder)/App-ver1.0(1)-\(UUID().uuidString)" + record.datetime = date + record.keepSameLink = keepSameLink.map { NSNumber(value: $0) } + record.dbDirectroy = buildDirectory + record.dbAppInfoFullPath = keepSameLink == true ? "\(folder)/appinfo.json" : "\(buildDirectory)/appinfo.json" + record.dbFolderName = folder + record.project = project + try stack.saveChanges() + } + // MARK: Tests func testRecentBuilds_newestFirstWithMappedFields() throws { @@ -97,6 +115,56 @@ final class BuildHistoryStoreTests: XCTestCase { XCTAssertEqual(builds.last?.appName, "Older") } + func testRecentBuilds_carriesTheLinkSettings() throws { + let stack = writableStack(makeModel()) + try seedUpload(stack, bundleIdentifier: "com.app", keepSameLink: true, folder: "/Team/QA", + date: Date(timeIntervalSince1970: 1_000)) + + let entry = try XCTUnwrap(BuildHistoryStore(stack: stack).recentBuilds().first) + XCTAssertEqual(entry.link, BuildLinkSettings(keepSameLink: true, folder: RemotePath(path: "/Team/QA"))) + } + + func testLatestLink_isTheNewestUploadOfTheApp() throws { + let stack = writableStack(makeModel()) + try seedUpload(stack, bundleIdentifier: "com.app", keepSameLink: false, folder: "/com.app", + date: Date(timeIntervalSince1970: 1_000)) + try seedUpload(stack, bundleIdentifier: "com.app", keepSameLink: true, folder: "/Team/QA", + date: Date(timeIntervalSince1970: 2_000)) + try seedUpload(stack, bundleIdentifier: "com.other", keepSameLink: true, folder: "/Other", + date: Date(timeIntervalSince1970: 3_000)) + + XCTAssertEqual(try BuildHistoryStore(stack: stack).latestLink(forBundleIdentifier: "com.app"), + BuildLinkSettings(keepSameLink: true, folder: RemotePath(path: "/Team/QA"))) + } + + func testLatestLink_aNewerUnkeptUploadWins() throws { + let stack = writableStack(makeModel()) + try seedUpload(stack, bundleIdentifier: "com.app", keepSameLink: true, folder: "/Team/QA", + date: Date(timeIntervalSince1970: 1_000)) + try seedUpload(stack, bundleIdentifier: "com.app", keepSameLink: false, folder: "/com.app", + date: Date(timeIntervalSince1970: 2_000)) + + let link = try BuildHistoryStore(stack: stack).latestLink(forBundleIdentifier: "com.app") + XCTAssertEqual(link?.keepSameLink, false) + XCTAssertEqual(link?.folder?.relativePath, "com.app") + } + + func testLatestLink_legacyRecordWithoutTheFlagIsNotKept() throws { + let stack = writableStack(makeModel()) + try seedUpload(stack, bundleIdentifier: "com.app", keepSameLink: nil, folder: "/com.app", + date: Date(timeIntervalSince1970: 1_000)) + + XCTAssertEqual(try BuildHistoryStore(stack: stack).latestLink(forBundleIdentifier: "com.app")?.keepSameLink, false) + } + + func testLatestLink_nilForAnAppNeverUploaded() throws { + let stack = writableStack(makeModel()) + try seedUpload(stack, bundleIdentifier: "com.app", keepSameLink: true, folder: "/com.app", + date: Date(timeIntervalSince1970: 1_000)) + + XCTAssertNil(try BuildHistoryStore(stack: stack).latestLink(forBundleIdentifier: "com.unknown")) + } + func testRecentBuilds_emptyStore() throws { let builds = try BuildHistoryStore(stack: writableStack(makeModel())).recentBuilds() XCTAssertTrue(builds.isEmpty) diff --git a/AppBoxCore/Tests/AppBoxCoreTests/BuildMetadataTests.swift b/AppBoxCore/Tests/AppBoxCoreTests/BuildMetadataTests.swift index 683e94e5..480e714f 100644 --- a/AppBoxCore/Tests/AppBoxCoreTests/BuildMetadataTests.swift +++ b/AppBoxCore/Tests/AppBoxCoreTests/BuildMetadataTests.swift @@ -85,6 +85,65 @@ final class BuildMetadataTests: XCTestCase { XCTAssertEqual(paths.bundleDirectory, "/com.example.myapp") } + func testResolvedBundleDirectoryMatchesTheUploadLayout() { + XCTAssertEqual(BuildRemotePaths.resolvedBundleDirectory(requested: nil, identifier: "com.example.myapp"), + "/com.example.myapp") + XCTAssertEqual(BuildRemotePaths.resolvedBundleDirectory(requested: "", identifier: "com.example.myapp"), + "/com.example.myapp") + XCTAssertEqual(BuildRemotePaths.resolvedBundleDirectory(requested: "/Team/QA", identifier: "com.example.myapp"), + "/Team/QA") + + let paths = BuildRemotePaths(metadata: metadata, uuid: "ABC123", bundleDirectory: "/Team/QA", keepSameLink: true) + XCTAssertEqual(paths.bundleDirectory, + BuildRemotePaths.resolvedBundleDirectory(requested: "/Team/QA", identifier: metadata.identifier)) + } + + // MARK: - App folder of a stored build + + func testAppFolderOfAKeptBuildComesFromItsAppInfoPath() { + let folder = BuildRemotePaths.appFolder(keepSameLink: true, + appInfoPath: "/Team/QA/appinfo.json", + buildDirectory: "/Team/QA/MyApp-ver1.2(345)-ABC123", + folderName: "/Team") + XCTAssertEqual(folder?.relativePath, "Team/QA") + } + + func testAppFolderOfAKeptBuildFallsBackToItsBuildDirectory() { + let folder = BuildRemotePaths.appFolder(keepSameLink: true, + appInfoPath: nil, + buildDirectory: "/Team/QA/MyApp-ver1.2(345)-ABC123", + folderName: "/Team") + XCTAssertEqual(folder?.relativePath, "Team/QA") + } + + func testAppFolderOfAnUnkeptBuildIsItsBuildDirectorysParent() { + let folder = BuildRemotePaths.appFolder(keepSameLink: false, + appInfoPath: "/com.example.myapp/MyApp-ver1.2(345)-ABC123/appinfo.json", + buildDirectory: "/com.example.myapp/MyApp-ver1.2(345)-ABC123", + folderName: "AppBoxMyApp-ver1.2(345)-ABC123") + XCTAssertEqual(folder?.relativePath, "com.example.myapp") + } + + func testAppFolderFallsBackToTheStoredFolderNameWithoutPaths() { + let folder = BuildRemotePaths.appFolder(keepSameLink: true, appInfoPath: "", buildDirectory: nil, + folderName: "/com.example.myapp") + XCTAssertEqual(folder?.relativePath, "com.example.myapp") + } + + func testAppFolderIsNilWhenItWouldBeTheStorageRoot() { + XCTAssertNil(BuildRemotePaths.appFolder(keepSameLink: true, + appInfoPath: "/appinfo.json", + buildDirectory: "//MyApp-ver1.2(345)-ABC123", + folderName: "/com.example.myapp")) + XCTAssertNil(BuildRemotePaths.appFolder(keepSameLink: false, appInfoPath: nil, buildDirectory: nil, folderName: nil)) + } + + func testAppFolderIsNilForAFolderThatSanitizesToTheRoot() { + let paths = BuildRemotePaths(metadata: metadata, uuid: "ABC123", bundleDirectory: "//", keepSameLink: true) + XCTAssertNil(BuildRemotePaths.appFolder(keepSameLink: true, appInfoPath: paths.appInfo.path, + buildDirectory: paths.buildDirectory, folderName: paths.bundleDirectory)) + } + // MARK: - Share URL export func testWritesShareURLsAsJSON() throws { diff --git a/AppBoxCore/Tests/AppBoxCoreTests/BuildRecordStoreTests.swift b/AppBoxCore/Tests/AppBoxCoreTests/BuildRecordStoreTests.swift new file mode 100644 index 00000000..4d63d634 --- /dev/null +++ b/AppBoxCore/Tests/AppBoxCoreTests/BuildRecordStoreTests.swift @@ -0,0 +1,88 @@ +import CoreData +import XCTest +@testable import AppBoxCore + +final class BuildRecordStoreTests: XCTestCase { + + // MARK: Model (the Project ↔ UploadRecord slice of AppBox4 that `BuildRecordStore.save` writes) + + private func makeContext() throws -> NSManagedObjectContext { + func attr(_ name: String, _ type: NSAttributeType) -> NSAttributeDescription { + let a = NSAttributeDescription(); a.name = name; a.attributeType = type; a.isOptional = true; return a + } + let project = NSEntityDescription(); project.name = "Project"; project.managedObjectClassName = "ABProject" + let record = NSEntityDescription(); record.name = "UploadRecord"; record.managedObjectClassName = "ABUploadRecord" + + project.properties = [attr("name", .stringAttributeType), attr("bundleIdentifier", .stringAttributeType)] + record.properties = ["buildType", "dbAppInfoFullPath", "dbDirectroy", "dbFolderName", "dbIPAFullPath", + "dbManifestFullPath", "dbSharedIPAURL", "dbSharedManifestURL", "dbSharedAppInfoURL", + "localBuildPath", "shortURL", "build", "version"].map { attr($0, .stringAttributeType) } + + [attr("keepSameLink", .booleanAttributeType), attr("datetime", .dateAttributeType)] + + let uploads = NSRelationshipDescription(); uploads.name = "uploadRecords"; uploads.destinationEntity = record + uploads.minCount = 0; uploads.maxCount = 0; uploads.isOrdered = true; uploads.deleteRule = .cascadeDeleteRule + let owner = NSRelationshipDescription(); owner.name = "project"; owner.destinationEntity = project + owner.minCount = 0; owner.maxCount = 1; owner.deleteRule = .nullifyDeleteRule + uploads.inverseRelationship = owner; owner.inverseRelationship = uploads + project.properties += [uploads]; record.properties += [owner] + + let model = NSManagedObjectModel(); model.entities = [project, record] + let coordinator = NSPersistentStoreCoordinator(managedObjectModel: model) + try coordinator.addPersistentStore(ofType: NSInMemoryStoreType, configurationName: nil, at: nil, options: nil) + let context = NSManagedObjectContext(concurrencyType: .mainQueueConcurrencyType) + context.persistentStoreCoordinator = coordinator + return context + } + + private func input(bundleDirectory: String, keepSameLink: Bool) -> BuildRecordInput { + let metadata = BuildMetadata(name: "MyApp", version: "1.2", build: "345", identifier: "com.example.myapp", + minimumOSVersion: nil, supportedDevice: "iPhone") + let paths = BuildRemotePaths(metadata: metadata, uuid: "ABC123", bundleDirectory: bundleDirectory, + keepSameLink: keepSameLink) + return BuildRecordInput(identifier: metadata.identifier, name: metadata.name, version: metadata.version, + build: metadata.build, keepSameLink: keepSameLink, + bundleDirectory: paths.bundleDirectory, buildDirectory: paths.buildDirectory, + ipaRemotePath: paths.ipa.path, manifestRemotePath: paths.manifest.path, + appInfoRemotePath: paths.appInfo.path) + } + + private func savedRecord(_ input: BuildRecordInput, in context: NSManagedObjectContext) throws -> ABUploadRecord { + let project = try BuildRecordStore.save(input, in: context) { try context.save() } + return try XCTUnwrap(project.uploadRecords?.lastObject as? ABUploadRecord) + } + + // MARK: Tests + + func testSaveKeepsTheLinkChoiceAndTheFullNestedFolder() throws { + let record = try savedRecord(input(bundleDirectory: "/Team/QA", keepSameLink: true), in: makeContext()) + + XCTAssertEqual(record.keepSameLink?.boolValue, true) + XCTAssertEqual(record.dbFolderName, "/Team/QA") + XCTAssertEqual(record.linkSettings, BuildLinkSettings(keepSameLink: true, folder: RemotePath(path: "/Team/QA"))) + } + + func testSaveRecordsAnUnkeptUploadInTheBundleIdentifierFolder() throws { + let record = try savedRecord(input(bundleDirectory: "", keepSameLink: false), in: makeContext()) + + XCTAssertEqual(record.keepSameLink?.boolValue, false) + XCTAssertEqual(record.linkSettings, + BuildLinkSettings(keepSameLink: false, folder: RemotePath(path: "/com.example.myapp"))) + } + + func testCLIFolderWithALeadingSlashReadsBackNormalized() throws { + let record = try savedRecord(input(bundleDirectory: "//Foo", keepSameLink: true), in: makeContext()) + + XCTAssertEqual(record.linkSettings.folder?.relativePath, "Foo") + } + + func testRepeatedUploadsOfAnAppShareOneProject() throws { + let context = try makeContext() + _ = try savedRecord(input(bundleDirectory: "/Team/QA", keepSameLink: true), in: context) + let second = try savedRecord(input(bundleDirectory: "/Team/QA", keepSameLink: true), in: context) + + let projects = try context.fetch(NSFetchRequest(entityName: "Project")) + XCTAssertEqual(projects.count, 1) + XCTAssertEqual(projects.first?.uploadRecords?.count, 2) + XCTAssertEqual(second.project, projects.first) + } +} diff --git a/AppBoxCore/Tests/AppBoxCoreTests/DeleteCoordinatorTests.swift b/AppBoxCore/Tests/AppBoxCoreTests/DeleteCoordinatorTests.swift index 71672910..d654e8b5 100644 --- a/AppBoxCore/Tests/AppBoxCoreTests/DeleteCoordinatorTests.swift +++ b/AppBoxCore/Tests/AppBoxCoreTests/DeleteCoordinatorTests.swift @@ -146,6 +146,26 @@ final class DeleteCoordinatorTests: XCTestCase { XCTAssertTrue(provider.uploads.isEmpty) } + func testRefusesToDeleteTheStorageRoot() async throws { + let provider = DeleteFakeProvider() + provider.existingAppInfoJSON = appInfoData(versions: [entry("m1")], latest: entry("m1")) + var unkept = plan(keepSameLink: false) + unkept.buildFolderPath = RemotePath([]) + var kept = plan(keepSameLink: true, manifestToRemove: "m1") + kept.appFolderPath = RemotePath(path: "/") + + for rootPlan in [unkept, kept] { + do { + _ = try await DeleteCoordinator(provider: provider).run(rootPlan) + XCTFail("expected a delete of the storage root to be refused") + } catch { + XCTAssertEqual(error as? UnusableBuildLocationError, UnusableBuildLocationError()) + } + } + XCTAssertTrue(provider.deletedPaths.isEmpty) + XCTAssertTrue(provider.uploads.isEmpty) + } + func testKeepSameLink_appInfoMissing_isNoOp() async throws { let provider = DeleteFakeProvider() let outcome = try await DeleteCoordinator(provider: provider).run(plan(keepSameLink: true)) diff --git a/AppBoxCore/Tests/AppBoxCoreTests/Fakes/Fakes.swift b/AppBoxCore/Tests/AppBoxCoreTests/Fakes/Fakes.swift index 091ce334..bda30105 100644 --- a/AppBoxCore/Tests/AppBoxCoreTests/Fakes/Fakes.swift +++ b/AppBoxCore/Tests/AppBoxCoreTests/Fakes/Fakes.swift @@ -132,7 +132,7 @@ final class StubReachability: Reachability { init(isConnected: Bool = true) { self.isConnected = isConnected } } -final class RecordingProgressReporter: ProgressReporter { +final class RecordingProgressReporter: AppBoxCore.ProgressReporter { struct Entry: Equatable { let stage: UploadStage; let message: String?; let fraction: Double? } private(set) var entries: [Entry] = [] diff --git a/AppBoxCore/Tests/AppBoxCoreTests/IPAExtractorTests.swift b/AppBoxCore/Tests/AppBoxCoreTests/IPAExtractorTests.swift index c1eab7ac..5c5d185a 100644 --- a/AppBoxCore/Tests/AppBoxCoreTests/IPAExtractorTests.swift +++ b/AppBoxCore/Tests/AppBoxCoreTests/IPAExtractorTests.swift @@ -82,4 +82,83 @@ final class IPAExtractorTests: XCTestCase { .extract(ipaAt: zip, to: tmp.appendingPathComponent("out")) ) } + + // MARK: - Metadata without extracting + + private func makeIPA(in tmp: URL, infoPlistName: String = "Info.plist", infoPlist: Data? = nil, + compressionMethod: CompressionMethod = .none) throws -> URL { + let appDir = tmp.appendingPathComponent("Payload/MyApp.app", isDirectory: true) + try FileManager.default.createDirectory(at: appDir, withIntermediateDirectories: true) + let plist: [String: Any] = [ + "CFBundleName": "My App", + "CFBundleShortVersionString": "1.2", + "CFBundleVersion": "345", + "CFBundleIdentifier": "com.example.myapp" + ] + let data = try infoPlist ?? PropertyListSerialization.data(fromPropertyList: plist, format: .binary, options: 0) + try data.write(to: appDir.appendingPathComponent(infoPlistName)) + try Data(repeating: 7, count: 64 * 1024).write(to: appDir.appendingPathComponent("MyApp")) + + let ipa = tmp.appendingPathComponent("app.ipa") + try FileManager.default.zipItem(at: tmp.appendingPathComponent("Payload"), to: ipa, + compressionMethod: compressionMethod) + return ipa + } + + private func withTemporaryDirectory(_ body: (URL) throws -> Void) throws { + let tmp = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString, isDirectory: true) + try FileManager.default.createDirectory(at: tmp, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: tmp) } + try body(tmp) + } + + func testReadsMetadataFromAStoredIPA() throws { + try withTemporaryDirectory { tmp in + let metadata = try IPAExtractor(archiveExtractor: ZipFoundationArchiveExtractor()) + .metadata(ofIPAAt: makeIPA(in: tmp)) + XCTAssertEqual(metadata.identifier, "com.example.myapp") + XCTAssertEqual(metadata.name, "MyApp") + XCTAssertEqual(metadata.version, "1.2") + XCTAssertEqual(metadata.build, "345") + } + } + + func testReadsMetadataFromADeflatedIPA() throws { + try withTemporaryDirectory { tmp in + let metadata = try IPAExtractor(archiveExtractor: ZipFoundationArchiveExtractor()) + .metadata(ofIPAAt: makeIPA(in: tmp, compressionMethod: .deflate)) + XCTAssertEqual(metadata.identifier, "com.example.myapp") + } + } + + func testReadsMetadataFromALowercaseInfoPlist() throws { + try withTemporaryDirectory { tmp in + let metadata = try IPAExtractor(archiveExtractor: ZipFoundationArchiveExtractor()) + .metadata(ofIPAAt: makeIPA(in: tmp, infoPlistName: "info.plist")) + XCTAssertEqual(metadata.identifier, "com.example.myapp") + } + } + + func testMetadataThrowsForAnUnreadableInfoPlist() throws { + try withTemporaryDirectory { tmp in + let ipa = try makeIPA(in: tmp, infoPlist: Data("not a plist".utf8)) + XCTAssertThrowsError(try IPAExtractor(archiveExtractor: ZipFoundationArchiveExtractor()).metadata(ofIPAAt: ipa)) { + XCTAssertEqual($0 as? IPAExtractionError, .invalidIPA) + } + } + } + + func testMetadataThrowsOnNonIPAZip() throws { + try withTemporaryDirectory { tmp in + let junk = tmp.appendingPathComponent("junk", isDirectory: true) + try FileManager.default.createDirectory(at: junk, withIntermediateDirectories: true) + try Data("x".utf8).write(to: junk.appendingPathComponent("readme.txt")) + let zip = tmp.appendingPathComponent("junk.zip") + try FileManager.default.zipItem(at: junk, to: zip) + + XCTAssertThrowsError(try IPAExtractor(archiveExtractor: ZipFoundationArchiveExtractor()).metadata(ofIPAAt: zip)) { + XCTAssertEqual($0 as? IPAExtractionError, .invalidIPA) + } + } + } } diff --git a/AppBoxTests/Model/IPAUploadInfoTests.swift b/AppBoxTests/Model/IPAUploadInfoTests.swift index 13e50821..d690bf60 100644 --- a/AppBoxTests/Model/IPAUploadInfoTests.swift +++ b/AppBoxTests/Model/IPAUploadInfoTests.swift @@ -3,6 +3,7 @@ // AppBoxTests import XCTest +import AppBoxCore @testable import AppBox private let uniqueJSON = "appinfo.json" @@ -192,4 +193,26 @@ final class IPAUploadInfoTests: XCTestCase { XCTAssertTrue(jsonPath.contains(uniqueJSON)) } + // MARK: - buildRecordInput + + func testBuildRecordInput_KeepsTheFullNestedBundleDirectory() { + let info = IPAUploadInfo() + info.isKeepSameLinkEnabled = true + info.bundleDirectory = URL(string: "/Team/MyApp") + info.dbDirectory = URL(string: "/Team/MyApp/MyApp-ver1.0(1)-UUID") + info.dbAppInfoJSONFullPath = URL(string: "/Team/MyApp/\(uniqueJSON)") + + let input = info.buildRecordInput + XCTAssertTrue(input.keepSameLink) + XCTAssertEqual(input.bundleDirectory, "/Team/MyApp") + XCTAssertEqual(input.buildDirectory, "/Team/MyApp/MyApp-ver1.0(1)-UUID") + XCTAssertEqual(input.appInfoRemotePath, "/Team/MyApp/\(uniqueJSON)") + } + + func testBuildRecordInput_CarriesTheSharedAppInfoLink() { + let info = IPAUploadInfo() + info.uniquelinkShareableURL = URL(string: "https://www.dropbox.com/s/abc/\(uniqueJSON)") + XCTAssertEqual(info.buildRecordInput.sharedAppInfoURL, "https://www.dropbox.com/s/abc/\(uniqueJSON)") + } + } diff --git a/Scripts/test_cli.sh b/Scripts/test_cli.sh index 0ebf1438..f15dc280 100755 --- a/Scripts/test_cli.sh +++ b/Scripts/test_cli.sh @@ -14,6 +14,7 @@ TEST_TIMEOUT=900 # 15 minutes # Usage: ./test_cli.sh (run all tests) # ./test_cli.sh 15 (run only test 15) # ./test_cli.sh 1,2,15 (run tests 1, 2, and 15) +# APPBOX_CLI=/path/to/appboxcli ./test_cli.sh 18,19 (test a local build) RUN_TESTS="" if [ -n "$1" ]; then RUN_TESTS=",$1," @@ -33,8 +34,8 @@ SLACK_WEBHOOK="https://hooks.slack.com/services/T00000000/B00000000/XXXXXXXXXXXX MSTEAMS_WEBHOOK="https://outlook.office.com/webhook/XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX/IncomingWebhook/XXXXXXXXXXXXXXXX/XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX" DB_FOLDER="TestScriptBuilds" -# Path to appboxcli binary (adjust if needed) -CLI="appboxcli" +# Path to appboxcli binary (adjust if needed, or set APPBOX_CLI) +CLI="${APPBOX_CLI:-appboxcli}" # Log file (overwritten each run) SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" @@ -204,6 +205,68 @@ check_prerequisites() { log "" } +# ============================================================ +# SCENARIO HELPERS (exported so run_test's `bash -c` can call them) +# ============================================================ + +# The install link the last upload wrote to ~/.appbox_share_value.json. +last_share_url() { + local url + url=$(plutil -extract APPBOX_SHARE_URL raw -o - "$HOME/.appbox_share_value.json") && [ -n "$url" ] && echo "$url" +} + +# Fails unless the newest build in `appboxcli list` kept `link` in `folder`. +expect_newest_build() { + local cli="$1" folder="$2" link="$3" + local listing newest + listing=$("$cli" list) || return 1 + if [[ "$(sed -n 1p <<< "$listing")" != *"SAME LINK"* ]]; then + echo "$cli has no SAME LINK column in 'list'; set APPBOX_CLI to a build of this branch." + return 1 + fi + newest=$(sed -n 2p <<< "$listing") + if [[ "$newest" != *" $folder "* || "$newest" != *"$link"* ]]; then + echo "Expected the newest build to keep $link in $folder, got: $newest" + return 1 + fi +} + +# Two keep-same-link uploads into a nested folder share one link, and list shows the whole folder. +nested_folder_keeps_link() { + local cli="$1" ipa="$2" folder="$3" + local first_link second_link + "$cli" --ipa "$ipa" --keepsamelink --dbfolder "$folder" || return 1 + first_link=$(last_share_url) || return 1 + "$cli" --ipa "$ipa" --keepsamelink --dbfolder "$folder" || return 1 + second_link=$(last_share_url) || return 1 + if [ "$first_link" != "$second_link" ]; then + echo "The nested folder didn't keep its link: $first_link, then $second_link" + return 1 + fi + expect_newest_build "$cli" "$folder" "$second_link" +} + +# Deleting the only build in a nested folder removes that folder, not its parent. +nested_folder_delete_keeps_parent() { + local cli="$1" ipa="$2" parent="$3" + local child="$parent/App$(date +%Y%m%d%H%M%S)" + local parent_link child_link relinked + "$cli" --ipa "$ipa" --keepsamelink --dbfolder "$parent" || return 1 + parent_link=$(last_share_url) || return 1 + "$cli" --ipa "$ipa" --keepsamelink --dbfolder "$child" || return 1 + child_link=$(last_share_url) || return 1 + expect_newest_build "$cli" "$child" "$child_link" || return 1 + printf '1\ny\n' | "$cli" delete || return 1 + "$cli" --ipa "$ipa" --keepsamelink --dbfolder "$parent" || return 1 + relinked=$(last_share_url) || return 1 + if [ "$relinked" != "$parent_link" ]; then + echo "The parent folder lost its link ($parent_link) when its nested folder's last build was deleted." + return 1 + fi +} + +export -f last_share_url expect_newest_build nested_folder_keeps_link nested_folder_delete_keeps_parent + # ============================================================ # TEST CASES # ============================================================ @@ -291,6 +354,19 @@ else "LARGE_IPA_PATH not found: $LARGE_IPA_PATH" fi +# ---------- Test 18: Keep same link in a nested custom folder ---------- +run_test "Upload twice with --keepsamelink into a nested --dbfolder (same link, full folder listed)" \ + "nested_folder_keeps_link \"$CLI\" \"$IPA_PATH\" \"$DB_FOLDER/Nested/App\"" + +# ---------- Test 19: Delete the last build of a nested folder, parent keeps its link ---------- +if pgrep -xq AppBox; then + skip_test "Delete the only build in a nested --dbfolder (parent keeps its link)" \ + "AppBox is running, and appboxcli delete refuses to run alongside it" +else + run_test "Delete the only build in a nested --dbfolder (parent keeps its link)" \ + "nested_folder_delete_keeps_parent \"$CLI\" \"$IPA_PATH\" \"$DB_FOLDER/NestedDelete\"" +fi + # ============================================================ # SUMMARY # ============================================================ diff --git a/docs/CommandLineInterface/index.md b/docs/CommandLineInterface/index.md index 9b648bbf..a1a18a75 100644 --- a/docs/CommandLineInterface/index.md +++ b/docs/CommandLineInterface/index.md @@ -71,7 +71,7 @@ The CLI also provides: | `appboxcli logout [--force]` | Log out of Dropbox. **Note:** the CLI and the AppBox app share one Dropbox session, so this signs the app out too (it asks for confirmation unless `--force`). | | `appboxcli whoami` | Show the Dropbox account you're logged in as. | | `appboxcli space` | Show Dropbox storage usage. | -| `appboxcli list` | List your upload history (the app's Dashboard data), newest first. | +| `appboxcli list` | List your upload history (the app's Dashboard data), newest first. The `SAME LINK` column shows the Dropbox folder of builds uploaded with keep same link, or `no`. | | `appboxcli delete [--dashboard-only]` | Interactively delete a build from Dropbox and the dashboard; `--dashboard-only` leaves the Dropbox files in place. Quit AppBox.app first — both can't safely write the history store at once. | ### Basic Syntax @@ -108,12 +108,14 @@ appboxcli --ipa app.ipa --message "New build v is ready for testing!" #### `--keepsamelink` Keep the same short URL for all future uploads of IPAs with the same bundle identifier. This is useful for maintaining consistent installation links. +Unlike the app, the CLI doesn't remember this from earlier uploads, so pass it on every run that should keep the link. + ```bash appboxcli --ipa app.ipa --keepsamelink ``` #### `--dbfolder ` -Specify a custom Dropbox folder name. By default, the folder name will be the application's bundle identifier. This is used with keepsamelink option. +Specify a custom Dropbox folder name. By default, the folder name will be the application's bundle identifier. With `--keepsamelink`, every upload to the same folder shares one short URL; without it, the build still goes into this folder but gets a new link. ```bash appboxcli --ipa app.ipa --keepsamelink --dbfolder "MyCustomFolder" diff --git a/docs/Features/keepsamelink.md b/docs/Features/keepsamelink.md index ca9974ec..c6a35478 100644 --- a/docs/Features/keepsamelink.md +++ b/docs/Features/keepsamelink.md @@ -25,4 +25,13 @@ You can modify the link by specifying a unique "Custom Dropbox Folder Name" in t ## 3. How to Maintain the Same Link While Hiding Previous Versions on the Installation Page? In AppBox preferences, enable the "Don't show previous versions on app installation page" option. When both this option and the "Keep Same Link" feature are enabled during upload, the AppBox installation page will not display previous versions. -![](../Images/ABDontShowOldBuild.webp) \ No newline at end of file +![](../Images/ABDontShowOldBuild.webp) + +## 4. AppBox Remembers the Setting for Each App +When you select an IPA, AppBox looks at the latest upload of the same app (matched by bundle identifier). If that upload used "Keep Same Link", the option is turned on for you and the "Custom Dropbox Folder Name" in "Other Settings" is filled in with the folder it used, so the new build keeps the same short URL. The folder is shown under the option. If the latest upload didn't keep its link, the option stays off. + +You can still change either setting before you upload. With "Keep Same Link" turned off, the build goes into the bundle identifier folder. + +The Dashboard shows whether each build was uploaded with "Keep Same Link" and which Dropbox folder it went into. Builds that kept their link also have a link icon in the build list. + +The command-line tool never turns this on by itself: pass `--keepsamelink` (and `--dbfolder` for a custom folder) on every run. See [Command Line Interface](../CommandLineInterface/index.md). \ No newline at end of file From 8645a3319d1bd45cbad089b4cb968a379f365697 Mon Sep 17 00:00:00 2001 From: Vineet Choudhary Date: Sun, 27 Sep 2026 09:29:19 +0530 Subject: [PATCH 4/4] Update version to 4.1.0 --- AppBox.xcodeproj/project.pbxproj | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/AppBox.xcodeproj/project.pbxproj b/AppBox.xcodeproj/project.pbxproj index 59f4b02b..0e2ef79c 100644 --- a/AppBox.xcodeproj/project.pbxproj +++ b/AppBox.xcodeproj/project.pbxproj @@ -1189,7 +1189,7 @@ CODE_SIGN_ENTITLEMENTS = AppBox/AppBox.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; COMBINE_HIDPI_IMAGES = YES; - CURRENT_PROJECT_VERSION = 5; + CURRENT_PROJECT_VERSION = 1; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = 3PQ7E4L589; ENABLE_APP_SANDBOX = NO; @@ -1201,7 +1201,7 @@ "@executable_path/../Frameworks", ); MACOSX_DEPLOYMENT_TARGET = 15.0; - MARKETING_VERSION = 4.0.0; + MARKETING_VERSION = 4.1.0; PRODUCT_BUNDLE_IDENTIFIER = com.developerinsider.AppBox; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_VERSION = 5.0; @@ -1216,7 +1216,7 @@ CODE_SIGN_IDENTITY = "Developer ID Application: Vineet Choudhary (3PQ7E4L589)"; CODE_SIGN_STYLE = Manual; COMBINE_HIDPI_IMAGES = YES; - CURRENT_PROJECT_VERSION = 5; + CURRENT_PROJECT_VERSION = 1; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = ""; "DEVELOPMENT_TEAM[sdk=macosx*]" = 3PQ7E4L589; @@ -1229,7 +1229,7 @@ "@executable_path/../Frameworks", ); MACOSX_DEPLOYMENT_TARGET = 15.0; - MARKETING_VERSION = 4.0.0; + MARKETING_VERSION = 4.1.0; PRODUCT_BUNDLE_IDENTIFIER = com.developerinsider.AppBox; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = "";