Add IndieAbout + IndieBackup, TestFlight release pipeline

IndieAbout: About section in iOS Settings and macOS About window; LICENSE
switched to the portfolio-standard ISC text (reflowed for in-app rendering).

IndieBackup: files-only backup/restore of the iCloud container documents
('.notesbackup' document type on both platforms), SwiftData cache rebuilt
via SyncEngine.rebuildCache() after restore, monitors suspended during the
file swap, onOpenURL import with pre-launch queueing.

Release pipeline: Scripts/release.sh (ios|mac|all) archives and uploads via
xcodebuild + ASC API key; platform-partitioned build numbers (iOS even,
macOS odd) since both targets share one bundle ID and build sequence.
This commit is contained in:
2026-07-14 20:50:06 -04:00
parent 9b231a1978
commit cf8616107b
17 changed files with 460 additions and 7 deletions
+16
View File
@@ -0,0 +1,16 @@
# Copy to .env.release (gitignored) in the app's repo root and fill in.
# Used by Scripts/release.sh and the asc-*.swift scripts.
#
# These are account-level — the same Apple developer account and API key
# serve every indie project, so copy this file (as .env.release) from an
# existing sibling project rather than filling it out from scratch.
#
# Apple Developer team that signs the build (same as DEVELOPMENT_TEAM).
APPLE_TEAM_ID=C32Z8JNLG6
# App Store Connect API key (App Store Connect > Users and Access > Integrations > App Store Connect API).
# Create a key with "App Manager" access, download the .p8 ONCE, and store it somewhere safe.
ASC_KEY_ID=XXXXXXXXXX
ASC_ISSUER_ID=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx
# Absolute path to the downloaded AuthKey_XXXXXXXXXX.p8 file.
ASC_KEY_PATH=/Users/rzen/.appstoreconnect/private_keys/AuthKey_XXXXXXXXXX.p8
+1
View File
@@ -3,3 +3,4 @@ DerivedData/
*.xcodeproj
.DS_Store
xcuserdata/
.env.release
+4
View File
@@ -1,3 +1,7 @@
**July 2026**
First version: take quick text notes that remember where and when they were taken, see the most relevant ones resurface when you return to the same place, organize with tags, search everything, and sync across iPhone and Mac through iCloud
Back up all notes to a single file and restore from it, with automatic safety backups before every restore
The About section in Settings shows the version, changelog, and license
+6 -3
View File
@@ -1,4 +1,7 @@
Copyright © 2026 rzen. All rights reserved.
**ISC License**
This software and its source code are proprietary. No permission is granted to
use, copy, modify, or distribute this software without prior written consent.
Copyright (c) 2026 Notes Contributors
Permission to use, copy, modify, and/or distribute this software for any purpose with or without fee is hereby granted, provided that the above copyright notice and this permission notice appear in all copies.
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+11 -1
View File
@@ -1,4 +1,5 @@
import Foundation
import IndieBackup
import SwiftData
/// Owns all service objects; created asynchronously at launch and injected
@@ -9,14 +10,23 @@ final class AppServices {
let modelContainer: ModelContainer
let syncEngine: SyncEngine
let contextService: ContextService
let backupController: BackupController
init() async {
let container = NotesModelContainer.make()
self.modelContainer = container
self.syncEngine = SyncEngine(modelContainer: container)
let engine = SyncEngine(modelContainer: container)
self.syncEngine = engine
self.contextService = ContextService()
self.backupController = BackupController(
configuration: NotesBackupConfiguration(syncEngine: engine)
)
}
/// The file extension this app owns for backup documents. Used to route
/// `onOpenURL` imports to a restore.
var backupFileExtension: String { backupController.configuration.backupFileExtension }
/// Slow startup work (iCloud container discovery) runs after the UI is up.
func startDeferredServices() async {
await syncEngine.connect()
@@ -0,0 +1,37 @@
import Foundation
import IndieBackup
/// Backup configuration for IndieBackup (files-only).
///
/// IndieBackup backs up the entire iCloud container `Documents` folder
/// (`backupRoot`, the package default) exactly where the source of truth
/// lives (`Records/`, `Stubs/`). The SwiftData cache is rebuildable and not
/// part of the backup; it's regenerated from the restored files via
/// `rebuildCacheAfterRestore`.
struct NotesBackupConfiguration: BackupConfiguration {
/// The sync engine whose observers are suspended during restore and whose
/// cache is rebuilt afterwards.
let syncEngine: SyncEngine
var backupFileExtension: String { "notesbackup" }
var backupDisplayName: String { "Notes Backup" }
/// Suspend the metadata observers for the whole restore so the bulk file
/// replacement isn't processed as a flood of live sync events.
func prepareForRestore() async {
await syncEngine.suspendForRestore()
}
/// Restart fresh observers once the restored files have settled a new
/// NSMetadataQuery re-baselines on gather, so nothing replays.
func finishRestore() async {
await syncEngine.resumeAfterRestore()
}
/// Rebuild the SwiftData cache so it exactly mirrors the restored files.
func rebuildCacheAfterRestore(progress: @escaping (Double, String) -> Void) async throws {
progress(0.0, "Rebuilding notes…")
await syncEngine.rebuildCache()
progress(1.0, "Rebuilt")
}
}
+42
View File
@@ -1,9 +1,11 @@
import IndieAbout
import SwiftData
import SwiftUI
@main
struct NotesApp: App {
@State private var appServices: AppServices?
@State private var pendingBackupURL: URL?
@Environment(\.scenePhase) private var scenePhase
var body: some Scene {
@@ -14,6 +16,7 @@ struct NotesApp: App {
.modelContainer(appServices.modelContainer)
.environment(appServices.syncEngine)
.environment(appServices.contextService)
.environmentObject(appServices.backupController)
} else {
ProgressView()
}
@@ -23,8 +26,15 @@ struct NotesApp: App {
let services = await AppServices()
appServices = services
await services.startDeferredServices()
if let pending = pendingBackupURL {
pendingBackupURL = nil
restore(pending, with: services)
}
}
}
.onOpenURL { url in
handle(url: url)
}
.onChange(of: scenePhase) { _, phase in
guard phase == .active, let appServices else { return }
let syncEngine = appServices.syncEngine
@@ -43,7 +53,12 @@ struct NotesApp: App {
case .available:
// Pull in records that synced down (and drop ones
// deleted elsewhere) while the app was backgrounded.
// Skip during a restore it stops the monitors and
// rebuilds the cache itself; a reconcile racing the
// file swap would import a half-restored state.
if !appServices.backupController.isRestoring {
await syncEngine.reconcile()
}
case .checking:
break
}
@@ -52,6 +67,33 @@ struct NotesApp: App {
}
#if os(macOS)
.defaultSize(width: 900, height: 700)
.commands {
IndieAboutCommand(configuration: .init(
documents: [
.license(filename: "LICENSE", extension: "md"),
.custom(title: "Changelog", filename: "CHANGELOG", extension: "md")
]
))
}
#endif
}
/// A tapped `.notesbackup` file (Files, AirDrop, Mail) restores the
/// backup. If it arrives before services finish launching, it's queued.
private func handle(url: URL) {
guard url.isFileURL, url.pathExtension == "notesbackup" else { return }
if let appServices {
restore(url, with: appServices)
} else {
pendingBackupURL = url
}
}
private func restore(_ url: URL, with services: AppServices) {
let didScope = url.startAccessingSecurityScopedResource()
Task {
defer { if didScope { url.stopAccessingSecurityScopedResource() } }
try? await services.backupController.restoreBackup(from: url)
}
}
}
+38
View File
@@ -49,5 +49,43 @@
<string>Notes</string>
</dict>
</dict>
<key>LSSupportsOpeningDocumentsInPlace</key>
<true/>
<key>CFBundleDocumentTypes</key>
<array>
<dict>
<key>CFBundleTypeName</key>
<string>Notes Backup</string>
<key>CFBundleTypeRole</key>
<string>Editor</string>
<key>LSHandlerRank</key>
<string>Owner</string>
<key>LSItemContentTypes</key>
<array>
<string>dev.rzen.indie.Notes.backup</string>
</array>
</dict>
</array>
<key>UTExportedTypeDeclarations</key>
<array>
<dict>
<key>UTTypeIdentifier</key>
<string>dev.rzen.indie.Notes.backup</string>
<key>UTTypeDescription</key>
<string>Notes Backup</string>
<key>UTTypeConformsTo</key>
<array>
<string>public.data</string>
<string>public.archive</string>
</array>
<key>UTTypeTagSpecification</key>
<dict>
<key>public.filename-extension</key>
<array>
<string>notesbackup</string>
</array>
</dict>
</dict>
</array>
</dict>
</plist>
+36
View File
@@ -40,5 +40,41 @@
<string>Notes</string>
</dict>
</dict>
<key>CFBundleDocumentTypes</key>
<array>
<dict>
<key>CFBundleTypeName</key>
<string>Notes Backup</string>
<key>CFBundleTypeRole</key>
<string>Editor</string>
<key>LSHandlerRank</key>
<string>Owner</string>
<key>LSItemContentTypes</key>
<array>
<string>dev.rzen.indie.Notes.backup</string>
</array>
</dict>
</array>
<key>UTExportedTypeDeclarations</key>
<array>
<dict>
<key>UTTypeIdentifier</key>
<string>dev.rzen.indie.Notes.backup</string>
<key>UTTypeDescription</key>
<string>Notes Backup</string>
<key>UTTypeConformsTo</key>
<array>
<string>public.data</string>
<string>public.archive</string>
</array>
<key>UTTypeTagSpecification</key>
<dict>
<key>public.filename-extension</key>
<array>
<string>notesbackup</string>
</array>
</dict>
</dict>
</array>
</dict>
</plist>
+15
View File
@@ -398,6 +398,21 @@ final class SyncEngine {
.sorted()
}
// MARK: - Restore support
/// Stops the metadata observers so a backup restore's bulk file
/// replacement isn't observed as live sync events.
func suspendForRestore() {
stopMonitoring()
}
/// Restarts fresh observers after a restore. The new NSMetadataQuery
/// re-baselines its known files on gather, so the restored tree does not
/// replay as a flood of add/remove events.
func resumeAfterRestore() {
startMonitoring()
}
// MARK: - Maintenance
private func performLaunchMaintenance() {
+14
View File
@@ -1,8 +1,11 @@
import IndieAbout
import IndieBackup
import SwiftUI
struct SettingsView: View {
@Environment(SyncEngine.self) private var syncEngine
@Environment(ContextService.self) private var contextService
@EnvironmentObject private var backupController: BackupController
var body: some View {
NavigationStack {
@@ -48,6 +51,17 @@ struct SettingsView: View {
}
.disabled(contextService.isRefreshing)
}
BackupsSectionView(controller: backupController)
Section {
IndieAbout(configuration: .init(
documents: [
.license(filename: "LICENSE", extension: "md"),
.custom(title: "Changelog", filename: "CHANGELOG", extension: "md")
]
))
}
}
.formStyle(.grouped)
.navigationTitle("Settings")
+2
View File
@@ -16,6 +16,8 @@ the top of the list.
- **Search** — full-text over note text, tags, and captured place names
- **iCloud sync** — notes live as JSON files in your iCloud Drive (visible in Files.app) and sync across iPhone and Mac; the local database is just a rebuildable cache
- **iOS + macOS** — same app, same data, both platforms
- **Backups** — one-tap local backup and restore of all notes (IndieBackup); backup files can be shared and imported across devices
- **About screen** — version info, changelog, and license in Settings (iOS) and the About window (macOS) via IndieAbout
Planned: voice notes with transcription, richer context signals, smarter ranking.
+24
View File
@@ -0,0 +1,24 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<!-- TestFlight / App Store distribution for the iOS app (an embedded
watchOS app, if any, is exported as part of the same bundle). -->
<key>method</key>
<string>app-store-connect</string>
<key>teamID</key>
<string>C32Z8JNLG6</string>
<!-- destination=upload makes -exportArchive push the build straight to
App Store Connect instead of writing a local .ipa. -->
<key>destination</key>
<string>upload</string>
<key>signingStyle</key>
<string>automatic</string>
<key>uploadSymbols</key>
<true/>
<key>stripSwiftSymbols</key>
<true/>
<key>manageAppVersionAndBuildNumber</key>
<false/>
</dict>
</plist>
+26
View File
@@ -0,0 +1,26 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<!-- TestFlight / App Store distribution for the macOS target. -->
<key>method</key>
<string>app-store-connect</string>
<!-- Hardcoded because -exportOptionsPlist takes a static file path (no env-var
substitution); release.sh does not template this plist at run time. Must
match APPLE_TEAM_ID in .env.release — update both if the team ever changes. -->
<key>teamID</key>
<string>C32Z8JNLG6</string>
<!-- destination=upload makes -exportArchive push the build straight to
App Store Connect instead of writing a local .pkg. -->
<key>destination</key>
<string>upload</string>
<key>signingStyle</key>
<string>automatic</string>
<key>uploadSymbols</key>
<true/>
<key>stripSwiftSymbols</key>
<true/>
<key>manageAppVersionAndBuildNumber</key>
<false/>
</dict>
</plist>
+114
View File
@@ -0,0 +1,114 @@
#!/usr/bin/env bash
#
# release.sh — archive Notes and upload to TestFlight / App Store Connect.
#
# Usage: Scripts/release.sh [ios|mac|all] (default: all)
#
# No third-party tooling: pure xcodebuild + the App Store Connect API key.
# Credentials live in .env.release (gitignored); see .env.release.example.
#
# What it does, per platform:
# 1. Regenerates the Xcode project with XcodeGen.
# 2. Stamps CFBundleVersion from the git commit count, platform-partitioned
# (iOS even, macOS odd — Scripts/update_build_number.sh, a post-build
# phase wired in project.yml).
# 3. xcodebuild archive -> .xcarchive
# 4. xcodebuild -exportArchive with destination=upload -> App Store Connect.
#
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
cd "$ROOT"
PROJECT="Notes.xcodeproj"
BUILD_DIR="$ROOT/build"
PLATFORM="${1:-all}"
# ---- credentials ------------------------------------------------------------
ENV_FILE="$ROOT/.env.release"
if [ -f "$ENV_FILE" ]; then
set -a; . "$ENV_FILE"; set +a
fi
: "${APPLE_TEAM_ID:?Set APPLE_TEAM_ID (copy .env.release.example -> .env.release)}"
: "${ASC_KEY_ID:?Set ASC_KEY_ID in .env.release}"
: "${ASC_ISSUER_ID:?Set ASC_ISSUER_ID in .env.release}"
: "${ASC_KEY_PATH:?Set ASC_KEY_PATH in .env.release}"
[ -f "$ASC_KEY_PATH" ] || { echo "❌ API key not found at: $ASC_KEY_PATH"; exit 1; }
AUTH=(
-authenticationKeyPath "$ASC_KEY_PATH"
-authenticationKeyID "$ASC_KEY_ID"
-authenticationKeyIssuerID "$ASC_ISSUER_ID"
-allowProvisioningUpdates
)
# ---- regenerate project -----------------------------------------------------
if command -v xcodegen >/dev/null 2>&1; then
echo "🧩 Generating $PROJECT ..."
xcodegen generate
elif [ ! -d "$PROJECT" ]; then
echo "$PROJECT missing and xcodegen not installed."; exit 1
fi
# ---- versioning -------------------------------------------------------------
# The Info.plists hold the literal "$(MARKETING_VERSION)" (resolved at build
# time), so read the real value from project.yml. The build number is stamped
# per-platform by Scripts/update_build_number.sh during the archive (even for
# iOS, odd for macOS — see that script); shown here for reference.
BUILD_NUMBER="$(git rev-list HEAD --count)"
MARKETING_VERSION="$(grep -m1 'MARKETING_VERSION:' project.yml | sed -E 's/.*"([^"]+)".*/\1/')"
echo "📦 Version $MARKETING_VERSION — iOS build $((BUILD_NUMBER * 2)), macOS build $((BUILD_NUMBER * 2 + 1))"
mkdir -p "$BUILD_DIR"
# ---- per-platform archive + upload ------------------------------------------
# args: <scheme> <generic destination> <ExportOptions plist>
archive_and_upload() {
local scheme="$1" destination="$2" export_plist="$3"
local archive_path="$BUILD_DIR/$scheme.xcarchive"
local export_path="$BUILD_DIR/$scheme-export"
echo ""
echo "──────── $scheme ────────"
rm -rf "$archive_path" "$export_path"
echo "🛠 Archiving $scheme ..."
xcodebuild archive \
-project "$PROJECT" \
-scheme "$scheme" \
-configuration Release \
-destination "$destination" \
-archivePath "$archive_path" \
CODE_SIGN_STYLE=Automatic \
DEVELOPMENT_TEAM="$APPLE_TEAM_ID" \
"${AUTH[@]}"
echo "🚀 Exporting + uploading $scheme to App Store Connect ..."
xcodebuild -exportArchive \
-archivePath "$archive_path" \
-exportPath "$export_path" \
-exportOptionsPlist "$export_plist" \
"${AUTH[@]}"
echo "$scheme uploaded."
}
do_ios() {
archive_and_upload "Notes" "generic/platform=iOS" "$SCRIPT_DIR/ExportOptions-iOS.plist"
}
do_mac() {
archive_and_upload "NotesMac" "generic/platform=macOS" "$SCRIPT_DIR/ExportOptions-macOS.plist"
}
case "$PLATFORM" in
ios) do_ios ;;
mac|macos) do_mac ;;
all) do_ios; do_mac ;;
*) echo "Usage: Scripts/release.sh [ios|mac|all]"; exit 1 ;;
esac
echo ""
echo "🎉 Done. Builds appear in App Store Connect > TestFlight after processing (~515 min)."
+61
View File
@@ -0,0 +1,61 @@
#!/bin/sh
set -eu
## Notes: platform-aware build info (post-build phase).
##
## Sets, on every build:
## - CFBundleVersion = git commit count, platform-partitioned (see below)
## - BuildDate = today's date, e.g. 2026-07-14
## - BuildHash = short git commit hash
## in both the target Info.plist and its dSYM Info.plist, so IndieAbout's
## version line has something to read.
##
## Platform partitioning: the iOS and macOS targets share one bundle ID and
## therefore one App Store Connect build-number sequence, so stamping both
## with the raw commit count makes the second upload collide ("bundle version
## already used"). iOS gets even numbers, macOS odd — both strictly increase
## with every commit and can never equal each other. (Same scheme as
## QuickRabbit's update_build_number.sh.)
git=$(sh /etc/profile; which git || true)
if [ -z "$git" ] || [ ! -x "$git" ]; then
echo "error: update_build_number.sh: could not resolve a 'git' executable — refusing to stamp a build number." >&2
exit 1
fi
number_of_commits=$("$git" rev-list HEAD --count)
case "$number_of_commits" in
''|*[!0-9]*)
echo "error: update_build_number.sh: 'git rev-list HEAD --count' returned '$number_of_commits', not a positive integer." >&2
exit 1
;;
esac
case "${PLATFORM_NAME:-}" in
macosx*) build_number=$((number_of_commits * 2 + 1)) ;;
*) build_number=$((number_of_commits * 2)) ;;
esac
git_commit=$("$git" rev-parse --short HEAD)
build_date=$(date +%F)
target_plist="$TARGET_BUILD_DIR/$INFOPLIST_PATH"
dsym_plist="$DWARF_DSYM_FOLDER_PATH/$DWARF_DSYM_FILE_NAME/Contents/Info.plist"
if [ ! -f "$target_plist" ] && [ ! -f "$dsym_plist" ]; then
echo "warning: update_build_number.sh: neither $target_plist nor $dsym_plist exists — check the phase's Input Files"
fi
echo "update_build_number: b$build_number ($PLATFORM_NAME) $git_commit $build_date"
for plist in "$target_plist" "$dsym_plist"; do
if [ -f "$plist" ]; then
/usr/libexec/PlistBuddy -c "Set :CFBundleVersion $build_number" "$plist"
/usr/libexec/PlistBuddy -c "Set :BuildDate $build_date" "$plist" 2>/dev/null || \
/usr/libexec/PlistBuddy -c "Add :BuildDate string $build_date" "$plist"
/usr/libexec/PlistBuddy -c "Set :BuildHash $git_commit" "$plist" 2>/dev/null || \
/usr/libexec/PlistBuddy -c "Add :BuildHash string $git_commit" "$plist"
fi
done
+12 -2
View File
@@ -19,6 +19,12 @@ packages:
IndieSync:
url: https://git.rzen.dev/rzen/indie-sync.git
from: "0.1.0"
IndieAbout:
url: https://git.rzen.dev/rzen/indie-about.git
from: "0.1.0"
IndieBackup:
url: https://git.rzen.dev/rzen/indie-backup.git
from: "2.0.0"
schemes:
Notes:
@@ -70,6 +76,8 @@ targets:
type: file
dependencies:
- package: IndieSync
- package: IndieAbout
- package: IndieBackup
settings:
base:
PRODUCT_BUNDLE_IDENTIFIER: dev.rzen.indie.Notes
@@ -82,7 +90,7 @@ targets:
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME: AccentColor
TARGETED_DEVICE_FAMILY: "1,2"
postBuildScripts:
- script: '"${SRCROOT}/../indie-skills/skills/app-versioning/scripts/update_build_info.sh"'
- script: '"${SRCROOT}/Scripts/update_build_number.sh"'
name: Update Build Info
shell: /bin/sh
basedOnDependencyAnalysis: false
@@ -109,6 +117,8 @@ targets:
type: file
dependencies:
- package: IndieSync
- package: IndieAbout
- package: IndieBackup
settings:
base:
PRODUCT_BUNDLE_IDENTIFIER: dev.rzen.indie.Notes
@@ -121,7 +131,7 @@ targets:
ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME: AccentColor
postBuildScripts:
- script: '"${SRCROOT}/../indie-skills/skills/app-versioning/scripts/update_build_info.sh"'
- script: '"${SRCROOT}/Scripts/update_build_number.sh"'
name: Update Build Info
shell: /bin/sh
basedOnDependencyAnalysis: false