Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 24 additions & 3 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,20 +36,38 @@ 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. **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. 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.

### 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

| Path | What |
|------|------|
| `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). |
Expand Down Expand Up @@ -90,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)
Expand Down Expand Up @@ -129,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 <path> ...` 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=<path>` 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)
Expand Down
8 changes: 4 additions & 4 deletions AppBox.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand All @@ -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;
Expand All @@ -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 = "";
Expand Down
2 changes: 2 additions & 0 deletions AppBox/Common/DefaultSettings/DefaultSettings.swift
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
// AppBox

import Foundation
import AppBoxCore

public final class DefaultSettings: NSObject {

Expand All @@ -12,6 +13,7 @@ public final class DefaultSettings: NSObject {
UserData.setDownloadIPAEnable(false)
UserData.setMoreDetailsEnable(true)
UserData.setShowPreviousVersions(true)
UserData.setUploadChunkSize(UploadSettings.defaultChunkSizeMB)
}

UserData.recordLaunchedVersion()
Expand Down
23 changes: 6 additions & 17 deletions AppBox/Common/UploadManager/UploadManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand Down Expand Up @@ -178,30 +178,19 @@ 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.")
errorBlock?(nil, true)
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)
Expand Down Expand Up @@ -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) {
Expand Down
2 changes: 1 addition & 1 deletion AppBox/Common/UserManager/UserData.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
25 changes: 13 additions & 12 deletions AppBox/Model/CoreDataModel/ABProject+AppBox.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 ?? "",
Expand All @@ -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)
})
}
}
Loading
Loading