ios: full feature/appearance parity with the macOS app (client-side)
Port the Mac's MarkdownText renderer, AppPalette (color-vision modes + night-softening), appearance settings (theme/text size/color vision), and BuildBanner channel strip to the iPhone remote. Add session-row attention/unseen-completion washes and marker icons, Discard action, branch/worktree options in the new-chat composer, and a transient error bubble for host wire errors. Stamp the build channel via NucleicChannel in Info.plist (ios-release.sh passes NUCLEIC_CHANNEL, default beta). Demo mode gains NUCLEIC_DEMO_SESSION and skips the notifications prompt so UI previews are scriptable. Co-Authored-By: Claude Fable 5 <[email protected]>
This commit is contained in:
@@ -0,0 +1,133 @@
|
||||
import SwiftUI
|
||||
|
||||
/// 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
|
||||
}
|
||||
|
||||
/// Build identity for the running app: which channel it came from and its build label.
|
||||
/// The Mac embeds a git commit via a prebuild plugin; the iOS build number is derived from
|
||||
/// `git rev-list --count` at archive time (scripts/ios-release.sh), so `CFBundleVersion`
|
||||
/// plays the same role here.
|
||||
struct BuildInfo {
|
||||
let channel: BuildChannel
|
||||
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
|
||||
buildLabel = (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 strip warning that this is a non-release build — the same channel colors, icons,
|
||||
/// and copy as the Mac's `BuildBanner`: red for local builds, canary-yellow for canary, blue
|
||||
/// for beta, gold for a release candidate. The leading edge names the channel; the build
|
||||
/// label and an all-caps "EXPERIMENTAL" badge sit on the trailing 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: 15, weight: .heavy))
|
||||
Text(style.label)
|
||||
.font(.footnote.weight(.semibold))
|
||||
.lineLimit(1)
|
||||
Spacer(minLength: 12)
|
||||
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, 5)
|
||||
.frame(maxWidth: .infinity)
|
||||
.background(style.tint.opacity(0.5))
|
||||
.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 = "bird.fill"
|
||||
case .beta:
|
||||
tint = Color(red: 0.13, green: 0.40, blue: 0.86) // blue
|
||||
label = "Engineering Beta"
|
||||
icon = "person.2.badge.gearshape.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)build \(build). Not a release build."
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user