Merge branch 'dev' into canary

This commit is contained in:
2026-07-02 22:13:52 -07:00
4 changed files with 79 additions and 10 deletions
@@ -11,6 +11,7 @@
BF00000000000000000002 /* NucleicRemoteWidgets.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = FR00000000000000000002 /* NucleicRemoteWidgets.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; BF00000000000000000002 /* NucleicRemoteWidgets.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = FR00000000000000000002 /* NucleicRemoteWidgets.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
E7D43FF12FF0806800BF2407 /* app-logo-file.icon in Resources */ = {isa = PBXBuildFile; fileRef = E7D43FF02FF0806800BF2407 /* app-logo-file.icon */; }; E7D43FF12FF0806800BF2407 /* app-logo-file.icon in Resources */ = {isa = PBXBuildFile; fileRef = E7D43FF02FF0806800BF2407 /* app-logo-file.icon */; };
E7D43FF12FF0806800BF2408 /* app-logo-canary.icon in Resources */ = {isa = PBXBuildFile; fileRef = E7D43FF02FF0806800BF2408 /* app-logo-canary.icon */; }; E7D43FF12FF0806800BF2408 /* app-logo-canary.icon in Resources */ = {isa = PBXBuildFile; fileRef = E7D43FF02FF0806800BF2408 /* app-logo-canary.icon */; };
E7D43FF12FF0806800BF2409 /* app-logo-beta.icon in Resources */ = {isa = PBXBuildFile; fileRef = E7D43FF02FF0806800BF2409 /* app-logo-beta.icon */; };
/* End PBXBuildFile section */ /* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */ /* Begin PBXContainerItemProxy section */
@@ -40,6 +41,7 @@
/* Begin PBXFileReference section */ /* Begin PBXFileReference section */
E7D43FF02FF0806800BF2407 /* app-logo-file.icon */ = {isa = PBXFileReference; lastKnownFileType = folder.iconcomposer.icon; path = "app-logo-file.icon"; sourceTree = "<group>"; }; E7D43FF02FF0806800BF2407 /* app-logo-file.icon */ = {isa = PBXFileReference; lastKnownFileType = folder.iconcomposer.icon; path = "app-logo-file.icon"; sourceTree = "<group>"; };
E7D43FF02FF0806800BF2408 /* app-logo-canary.icon */ = {isa = PBXFileReference; lastKnownFileType = folder.iconcomposer.icon; name = "app-logo-canary.icon"; path = "../../app-logo-canary.icon"; sourceTree = "<group>"; }; E7D43FF02FF0806800BF2408 /* app-logo-canary.icon */ = {isa = PBXFileReference; lastKnownFileType = folder.iconcomposer.icon; name = "app-logo-canary.icon"; path = "../../app-logo-canary.icon"; sourceTree = "<group>"; };
E7D43FF02FF0806800BF2409 /* app-logo-beta.icon */ = {isa = PBXFileReference; lastKnownFileType = folder.iconcomposer.icon; name = "app-logo-beta.icon"; path = "../../app-logo-beta.icon"; sourceTree = "<group>"; };
FR00000000000000000001 /* NucleicRemote.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = NucleicRemote.app; sourceTree = BUILT_PRODUCTS_DIR; }; FR00000000000000000001 /* NucleicRemote.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = NucleicRemote.app; sourceTree = BUILT_PRODUCTS_DIR; };
FR00000000000000000002 /* NucleicRemoteWidgets.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = NucleicRemoteWidgets.appex; sourceTree = BUILT_PRODUCTS_DIR; }; FR00000000000000000002 /* NucleicRemoteWidgets.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = NucleicRemoteWidgets.appex; sourceTree = BUILT_PRODUCTS_DIR; };
/* End PBXFileReference section */ /* End PBXFileReference section */
@@ -113,6 +115,7 @@
GR00000000000000000002 /* Products */, GR00000000000000000002 /* Products */,
E7D43FF02FF0806800BF2407 /* app-logo-file.icon */, E7D43FF02FF0806800BF2407 /* app-logo-file.icon */,
E7D43FF02FF0806800BF2408 /* app-logo-canary.icon */, E7D43FF02FF0806800BF2408 /* app-logo-canary.icon */,
E7D43FF02FF0806800BF2409 /* app-logo-beta.icon */,
); );
sourceTree = "<group>"; sourceTree = "<group>";
}; };
@@ -136,6 +139,7 @@
BP00000000000000000003 /* Frameworks */, BP00000000000000000003 /* Frameworks */,
BP00000000000000000004 /* Resources */, BP00000000000000000004 /* Resources */,
BP00000000000000000005 /* Embed Foundation Extensions */, BP00000000000000000005 /* Embed Foundation Extensions */,
BP00000000000000000009 /* Stamp git commit into Info.plist */,
); );
buildRules = ( buildRules = (
); );
@@ -223,6 +227,7 @@
files = ( files = (
E7D43FF12FF0806800BF2407 /* app-logo-file.icon in Resources */, E7D43FF12FF0806800BF2407 /* app-logo-file.icon in Resources */,
E7D43FF12FF0806800BF2408 /* app-logo-canary.icon in Resources */, E7D43FF12FF0806800BF2408 /* app-logo-canary.icon in Resources */,
E7D43FF12FF0806800BF2409 /* app-logo-beta.icon in Resources */,
); );
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
}; };
@@ -235,6 +240,24 @@
}; };
/* End PBXResourcesBuildPhase section */ /* End PBXResourcesBuildPhase section */
/* Begin PBXShellScriptBuildPhase section */
BP00000000000000000009 /* Stamp git commit into Info.plist */ = {
isa = PBXShellScriptBuildPhase;
alwaysOutOfDate = 1;
buildActionMask = 2147483647;
files = (
);
inputPaths = (
);
name = "Stamp git commit into Info.plist";
outputPaths = (
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "# Stamp the short git commit into the built Info.plist (NucleicCommit key), the iOS\n# mirror of the Mac's EmbedGitCommit prebuild plugin. Runs before code signing so the\n# signature covers the stamped value; alwaysOutOfDate keeps it fresh on every build. A\n# trailing \"+\" marks a dirty tree; \"unknown\" (no git) makes the app fall back to the\n# bundle build number.\nset -e\nplist=\"${TARGET_BUILD_DIR}/${INFOPLIST_PATH}\"\n[ -f \"$plist\" ] || exit 0\nhash=$(git -C \"${SRCROOT}\" rev-parse --short HEAD 2>/dev/null || echo unknown)\nif [ \"$hash\" != unknown ] && [ -n \"$(git -C \"${SRCROOT}\" status --porcelain 2>/dev/null)\" ]; then\n hash=\"${hash}+\"\nfi\n/usr/libexec/PlistBuddy -c \"Set :NucleicCommit $hash\" \"$plist\" 2>/dev/null || /usr/libexec/PlistBuddy -c \"Add :NucleicCommit string $hash\" \"$plist\"\n";
};
/* End PBXShellScriptBuildPhase section */
/* Begin PBXSourcesBuildPhase section */ /* Begin PBXSourcesBuildPhase section */
BP00000000000000000002 /* Sources */ = { BP00000000000000000002 /* Sources */ = {
isa = PBXSourcesBuildPhase; isa = PBXSourcesBuildPhase;
@@ -305,6 +328,9 @@
CURRENT_PROJECT_VERSION = 1; CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = L7UDTQ6F5W; DEVELOPMENT_TEAM = L7UDTQ6F5W;
ENABLE_PREVIEWS = YES; ENABLE_PREVIEWS = YES;
// The "Stamp git commit into Info.plist" phase shells out to git and rewrites the
// built Info.plist, both of which script sandboxing would block.
ENABLE_USER_SCRIPT_SANDBOXING = NO;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = NucleicRemote/Info.plist; INFOPLIST_FILE = NucleicRemote/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = "Nucleic$(NUCLEIC_NAME_SUFFIX)"; INFOPLIST_KEY_CFBundleDisplayName = "Nucleic$(NUCLEIC_NAME_SUFFIX)";
@@ -349,6 +375,9 @@
CURRENT_PROJECT_VERSION = 1; CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = L7UDTQ6F5W; DEVELOPMENT_TEAM = L7UDTQ6F5W;
ENABLE_PREVIEWS = YES; ENABLE_PREVIEWS = YES;
// The "Stamp git commit into Info.plist" phase shells out to git and rewrites the
// built Info.plist, both of which script sandboxing would block.
ENABLE_USER_SCRIPT_SANDBOXING = NO;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = NucleicRemote/Info.plist; INFOPLIST_FILE = NucleicRemote/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = "Nucleic$(NUCLEIC_NAME_SUFFIX)"; INFOPLIST_KEY_CFBundleDisplayName = "Nucleic$(NUCLEIC_NAME_SUFFIX)";
+5
View File
@@ -8,6 +8,11 @@
</array> </array>
<key>NucleicChannel</key> <key>NucleicChannel</key>
<string>$(NUCLEIC_CHANNEL)</string> <string>$(NUCLEIC_CHANNEL)</string>
<!-- The short git commit this build came from, shown in the non-release banner to match
the Mac. Left empty here and overwritten in the built product by the NucleicRemote
target's "Stamp git commit into Info.plist" build phase (git rev-parse --short HEAD). -->
<key>NucleicCommit</key>
<string></string>
<key>NSSupportsLiveActivities</key> <key>NSSupportsLiveActivities</key>
<true/> <true/>
<!-- Export compliance: the app ships a Noise-Protocol secure channel (X25519 / <!-- Export compliance: the app ships a Noise-Protocol secure channel (X25519 /
@@ -293,7 +293,8 @@ final class RemoteStore: ObservableObject {
let client = SyncClient( let client = SyncClient(
channel: channel, identity: identity, hostStaticKey: payload.hostStaticKey, channel: channel, identity: identity, hostStaticKey: payload.hostStaticKey,
mode: .pair(secret: payload.pairingSecret), deviceID: deviceID, mode: .pair(secret: payload.pairingSecret), deviceID: deviceID,
deviceLabel: UIDevice.current.name, pushToken: PushRegistrar.shared.tokenHex) deviceLabel: UIDevice.current.name, pushToken: PushRegistrar.shared.tokenHex,
releaseChannel: BuildInfo.current.channel.releaseChannel)
self.client = client self.client = client
consume(client, pairingPayload: payload) consume(client, pairingPayload: payload)
} }
@@ -312,7 +313,8 @@ final class RemoteStore: ObservableObject {
let client = SyncClient( let client = SyncClient(
channel: channel, identity: identity, hostStaticKey: host.hostStaticKey, channel: channel, identity: identity, hostStaticKey: host.hostStaticKey,
mode: .reconnect, deviceID: host.deviceID, deviceLabel: UIDevice.current.name, mode: .reconnect, deviceID: host.deviceID, deviceLabel: UIDevice.current.name,
pushToken: PushRegistrar.shared.tokenHex) pushToken: PushRegistrar.shared.tokenHex,
releaseChannel: BuildInfo.current.channel.releaseChannel)
self.client = client self.client = client
consume(client, pairingPayload: nil) consume(client, pairingPayload: nil)
} }
@@ -781,6 +783,15 @@ final class RemoteStore: ObservableObject {
openDiff = diff openDiff = diff
diffLoading = false diffLoading = false
case .wireError(let error): case .wireError(let error):
// A channel mismatch means the host and this remote were built from incompatible
// release channels. Surface it as a persistent failure with a clear message rather
// than a transient bubble; the follow-on `.closed` still schedules a backoff retry,
// so the connection recovers on its own once either side is updated to a matching
// channel.
if error.code == .channelMismatch {
connectivity = .failed(error.message)
break
}
// Not fatal — surface as a transient bubble (the Mac's last-error overlay). // Not fatal — surface as a transient bubble (the Mac's last-error overlay).
// Losing an approval race isn't an error worth interrupting for; the card // Losing an approval race isn't an error worth interrupting for; the card
// collapses on the matching `approvalResolved`. // collapses on the matching `approvalResolved`.
@@ -1,4 +1,5 @@
import SwiftUI import SwiftUI
import NucleicProtocol
/// How this copy of the remote was built — the iOS mirror of the Mac's `BuildChannel`. /// 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)`, /// Stamped at archive time via the `NucleicChannel` Info.plist key (`$(NUCLEIC_CHANNEL)`,
@@ -16,14 +17,31 @@ enum BuildChannel {
case releaseCandidate case releaseCandidate
/// The `stable` channel — no banner. /// The `stable` channel — no banner.
case release 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. /// 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 /// Both platforms show the short git commit: the Mac embeds it via a prebuild plugin, and
/// `git rev-list --count` at archive time (scripts/ios-release.sh), so `CFBundleVersion` /// here the NucleicRemote target's "Stamp git commit into Info.plist" build phase writes
/// plays the same role here. /// `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 { struct BuildInfo {
let channel: BuildChannel 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 let buildLabel: String
/// `CFBundleShortVersionString` when present, else `nil`. /// `CFBundleShortVersionString` when present, else `nil`.
let version: String? let version: String?
@@ -34,7 +52,13 @@ struct BuildInfo {
init() { init() {
let info = Bundle.main.infoDictionary let info = Bundle.main.infoDictionary
version = info?["CFBundleShortVersionString"] as? String version = info?["CFBundleShortVersionString"] as? String
buildLabel = (info?["CFBundleVersion"] as? String) ?? "local" // 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) channel = Self.detectChannel(stamped: info?["NucleicChannel"] as? String)
} }
@@ -111,11 +135,11 @@ struct BuildBanner: View {
case .canary: case .canary:
tint = Color(red: 1.00, green: 0.87, blue: 0.00) // bright canary yellow tint = Color(red: 1.00, green: 0.87, blue: 0.00) // bright canary yellow
label = "Canary Build" label = "Canary Build"
icon = "bird.fill" icon = "bell.and.waves.left.and.right.fill"
case .beta: case .beta:
tint = Color(red: 0.13, green: 0.40, blue: 0.86) // blue tint = Color(red: 0.13, green: 0.40, blue: 0.86) // blue
label = "Engineering Beta" label = "Public Beta"
icon = "person.2.badge.gearshape.fill" icon = "person.3.fill"
case .releaseCandidate: case .releaseCandidate:
tint = Color(red: 0.83, green: 0.65, blue: 0.12) // gold tint = Color(red: 0.83, green: 0.65, blue: 0.12) // gold
label = "Release Candidate" label = "Release Candidate"
@@ -127,7 +151,7 @@ struct BuildBanner: View {
func tooltip(version: String?, build: String) -> String { func tooltip(version: String?, build: String) -> String {
let v = version.map { "\($0) " } ?? "" let v = version.map { "\($0) " } ?? ""
return "\(label) — \(v)build \(build). Not a release build." return "\(label) — \(v)commit \(build). Not a release build."
} }
} }
} }