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:
@@ -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
|
||||
@@ -3,3 +3,4 @@ DerivedData/
|
||||
*.xcodeproj
|
||||
.DS_Store
|
||||
xcuserdata/
|
||||
.env.release
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
Executable
+114
@@ -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 (~5–15 min)."
|
||||
Executable
+61
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user