192 lines
8.1 KiB
Swift
192 lines
8.1 KiB
Swift
import SwiftUI
|
|
import NucleicProtocol
|
|
|
|
/// How this copy of the remote was built — the iOS mirror of the Mac's `BuildChannel`.
|
|
/// Stamped at archive time via the `NucleicChannel` Info.plist key (`$(NUCLEIC_CHANNEL)`,
|
|
/// see scripts/ios-release.sh); unset builds fall back to runtime detection (Debug → local,
|
|
/// TestFlight → beta, App Store → release).
|
|
enum BuildChannel {
|
|
/// The `dev` channel — red "Local Development Build" banner.
|
|
case local
|
|
/// The `canary` channel — canary-yellow "Canary" banner with the bird. The bleeding-edge
|
|
/// distributed channel, cut before beta (see BUILD.md).
|
|
case canary
|
|
/// The `beta` channel — blue "Beta" banner.
|
|
case beta
|
|
/// The `rc` channel — gold "Release Candidate" banner.
|
|
case releaseCandidate
|
|
/// The `stable` channel — no banner.
|
|
case release
|
|
|
|
/// The wire projection sent to the host in `hello`, so it can gate the connection on
|
|
/// channel compatibility (`ReleaseChannel.isCompatible`).
|
|
var releaseChannel: ReleaseChannel {
|
|
switch self {
|
|
case .local: .local
|
|
case .canary: .canary
|
|
case .beta: .beta
|
|
case .releaseCandidate: .releaseCandidate
|
|
case .release: .release
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Build identity for the running app: which channel it came from and its build label.
|
|
/// Both platforms show the short git commit: the Mac embeds it via a prebuild plugin, and
|
|
/// here the NucleicRemote target's "Stamp git commit into Info.plist" build phase writes
|
|
/// `git rev-parse --short HEAD` into the `NucleicCommit` Info.plist key. Falls back to
|
|
/// `CFBundleVersion` (the `git rev-list --count` build number from scripts/ios-release.sh)
|
|
/// when git isn't available.
|
|
struct BuildInfo {
|
|
let channel: BuildChannel
|
|
/// The short git commit the binary was built from (e.g. "a1b2c3d", "+" suffix when the
|
|
/// tree was dirty) — matching the Mac's banner. Falls back to `CFBundleVersion` / "local"
|
|
/// if the build phase couldn't resolve a hash.
|
|
let buildLabel: String
|
|
/// `CFBundleShortVersionString` when present, else `nil`.
|
|
let version: String?
|
|
|
|
/// Resolved once — none of this changes while the app is running.
|
|
static let current = BuildInfo()
|
|
|
|
init() {
|
|
let info = Bundle.main.infoDictionary
|
|
version = info?["CFBundleShortVersionString"] as? String
|
|
// Prefer the stamped git commit (mirrors the Mac's GitCommit.hash); an empty or
|
|
// "unknown" value means git wasn't available at build time, so fall back to the
|
|
// bundle build number.
|
|
let commit = info?["NucleicCommit"] as? String
|
|
buildLabel = commit.flatMap { $0.isEmpty || $0 == "unknown" ? nil : $0 }
|
|
?? (info?["CFBundleVersion"] as? String)
|
|
?? "local"
|
|
channel = Self.detectChannel(stamped: info?["NucleicChannel"] as? String)
|
|
}
|
|
|
|
/// Prefer the archive-time stamp (mirrors the Mac's `NUCLEIC_CHANNEL` build setting);
|
|
/// fall back to what the runtime can tell on its own.
|
|
private static func detectChannel(stamped: String?) -> BuildChannel {
|
|
switch stamped?.lowercased() {
|
|
case "dev", "local": return .local
|
|
case "canary": return .canary
|
|
case "beta": return .beta
|
|
case "rc": return .releaseCandidate
|
|
case "stable", "release": return .release
|
|
default: break
|
|
}
|
|
#if DEBUG
|
|
return .local
|
|
#else
|
|
// TestFlight installs carry the sandbox receipt; App Store installs don't.
|
|
if Bundle.main.appStoreReceiptURL?.lastPathComponent == "sandboxReceipt" { return .beta }
|
|
return .release
|
|
#endif
|
|
}
|
|
}
|
|
|
|
/// Header warning that this is a non-release build — the same channel colors, icons, and copy
|
|
/// as the Mac's `BuildBanner`. A channel-colored stripe — red for local builds, canary-yellow
|
|
/// for canary, blue for beta, gold for a release candidate — spans the full width, with its
|
|
/// content (channel name, build label, and an all-caps "EXPERIMENTAL" pill) held at the
|
|
/// leading edge. Renders nothing for a shipping App Store release.
|
|
struct BuildBanner: View {
|
|
var info: BuildInfo = .current
|
|
|
|
var body: some View {
|
|
if let style = Style(channel: info.channel) {
|
|
HStack(spacing: 8) {
|
|
Image(systemName: style.icon)
|
|
.font(.system(size: 12, weight: .heavy))
|
|
Text(style.label)
|
|
.font(.footnote.weight(.semibold))
|
|
.lineLimit(1)
|
|
Text(info.buildLabel)
|
|
.font(.caption.monospaced())
|
|
.opacity(0.85)
|
|
Text("EXPERIMENTAL")
|
|
.font(.caption2.weight(.heavy))
|
|
.tracking(0.9)
|
|
.padding(.horizontal, 6)
|
|
.padding(.vertical, 2)
|
|
.background(.white.opacity(0.16), in: .capsule)
|
|
}
|
|
.foregroundStyle(.white.opacity(0.92))
|
|
.padding(.horizontal, 12)
|
|
.padding(.vertical, 3)
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
// The channel-colored stripe spans the full width, with the content (channel name,
|
|
// build label, and the EXPERIMENTAL pill) held at the leading edge. Its height is
|
|
// just the row of text plus padding; the trailing vertical padding is transparent
|
|
// margin that keeps the stripe from filling the header's full height.
|
|
.background(style.tint.opacity(0.9))
|
|
.padding(.vertical, 4)
|
|
.accessibilityLabel(style.tooltip(version: info.version, build: info.buildLabel))
|
|
}
|
|
}
|
|
|
|
/// Per-channel appearance — identical constants to the Mac. `nil` for `.release` so the
|
|
/// banner disappears entirely.
|
|
private struct Style {
|
|
var tint: Color
|
|
var label: String
|
|
var icon: String
|
|
|
|
init?(channel: BuildChannel) {
|
|
switch channel {
|
|
case .local:
|
|
tint = Color(red: 0.78, green: 0.18, blue: 0.18) // red
|
|
label = "Local Development Build"
|
|
icon = "hammer.fill"
|
|
case .canary:
|
|
tint = Color(red: 1.00, green: 0.87, blue: 0.00) // bright canary yellow
|
|
label = "Canary Build"
|
|
icon = "bell.and.waves.left.and.right.fill"
|
|
case .beta:
|
|
tint = Color(red: 0.13, green: 0.40, blue: 0.86) // blue
|
|
label = "Public Beta"
|
|
icon = "person.3.fill"
|
|
case .releaseCandidate:
|
|
tint = Color(red: 0.83, green: 0.65, blue: 0.12) // gold
|
|
label = "Release Candidate"
|
|
icon = "checkmark.seal.fill"
|
|
case .release:
|
|
return nil
|
|
}
|
|
}
|
|
|
|
func tooltip(version: String?, build: String) -> String {
|
|
let v = version.map { "\($0) " } ?? ""
|
|
return "\(label) — \(v)commit \(build). Not a release build."
|
|
}
|
|
}
|
|
}
|
|
|
|
/// A slim strip shown while the app is in offline demo mode, so sample data is never mistaken
|
|
/// for a live connection to a Mac. Sits under the `BuildBanner` in `RootView`.
|
|
struct DemoBanner: View {
|
|
@EnvironmentObject var store: RemoteStore
|
|
|
|
var body: some View {
|
|
if store.demoMode {
|
|
HStack(spacing: 8) {
|
|
Image(systemName: "play.circle.fill").font(.system(size: 14, weight: .bold))
|
|
Text("Demo — sample data, no Mac connected")
|
|
.font(.footnote.weight(.semibold))
|
|
.lineLimit(1)
|
|
Spacer(minLength: 8)
|
|
Text("DEMO")
|
|
.font(.caption2.weight(.heavy))
|
|
.tracking(0.9)
|
|
.padding(.horizontal, 6)
|
|
.padding(.vertical, 2)
|
|
.background(.white.opacity(0.18), in: .capsule)
|
|
}
|
|
.foregroundStyle(.white.opacity(0.95))
|
|
.padding(.horizontal, 12)
|
|
.padding(.vertical, 5)
|
|
.frame(maxWidth: .infinity)
|
|
.background(Color(red: 0.36, green: 0.36, blue: 0.42))
|
|
.accessibilityLabel("Demo mode — sample data, not connected to a Mac.")
|
|
}
|
|
}
|
|
}
|