Merge branch 'dev' into nucleic/trunk
This commit is contained in:
@@ -8,6 +8,7 @@
|
|||||||
|
|
||||||
/* Begin PBXBuildFile section */
|
/* Begin PBXBuildFile section */
|
||||||
BF00000000000000000001 /* NucleicProtocol in Frameworks */ = {isa = PBXBuildFile; productRef = PD00000000000000000001 /* NucleicProtocol */; };
|
BF00000000000000000001 /* NucleicProtocol in Frameworks */ = {isa = PBXBuildFile; productRef = PD00000000000000000001 /* NucleicProtocol */; };
|
||||||
|
BF00000000000000000003 /* NucleicTailnet in Frameworks */ = {isa = PBXBuildFile; productRef = PD00000000000000000002 /* NucleicTailnet */; };
|
||||||
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 */; };
|
||||||
@@ -93,6 +94,7 @@
|
|||||||
buildActionMask = 2147483647;
|
buildActionMask = 2147483647;
|
||||||
files = (
|
files = (
|
||||||
BF00000000000000000001 /* NucleicProtocol in Frameworks */,
|
BF00000000000000000001 /* NucleicProtocol in Frameworks */,
|
||||||
|
BF00000000000000000003 /* NucleicTailnet in Frameworks */,
|
||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
};
|
};
|
||||||
@@ -153,6 +155,7 @@
|
|||||||
name = NucleicRemote;
|
name = NucleicRemote;
|
||||||
packageProductDependencies = (
|
packageProductDependencies = (
|
||||||
PD00000000000000000001 /* NucleicProtocol */,
|
PD00000000000000000001 /* NucleicProtocol */,
|
||||||
|
PD00000000000000000002 /* NucleicTailnet */,
|
||||||
);
|
);
|
||||||
productName = NucleicRemote;
|
productName = NucleicRemote;
|
||||||
productReference = FR00000000000000000001 /* NucleicRemote.app */;
|
productReference = FR00000000000000000001 /* NucleicRemote.app */;
|
||||||
@@ -293,7 +296,7 @@
|
|||||||
COPY_PHASE_STRIP = NO;
|
COPY_PHASE_STRIP = NO;
|
||||||
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
||||||
GCC_NO_COMMON_BLOCKS = YES;
|
GCC_NO_COMMON_BLOCKS = YES;
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
|
IPHONEOS_DEPLOYMENT_TARGET = 18.1;
|
||||||
ONLY_ACTIVE_ARCH = YES;
|
ONLY_ACTIVE_ARCH = YES;
|
||||||
SDKROOT = iphoneos;
|
SDKROOT = iphoneos;
|
||||||
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
|
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
|
||||||
@@ -310,7 +313,7 @@
|
|||||||
COPY_PHASE_STRIP = NO;
|
COPY_PHASE_STRIP = NO;
|
||||||
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
||||||
GCC_NO_COMMON_BLOCKS = YES;
|
GCC_NO_COMMON_BLOCKS = YES;
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
|
IPHONEOS_DEPLOYMENT_TARGET = 18.1;
|
||||||
SDKROOT = iphoneos;
|
SDKROOT = iphoneos;
|
||||||
SWIFT_COMPILATION_MODE = wholemodule;
|
SWIFT_COMPILATION_MODE = wholemodule;
|
||||||
VALIDATE_PRODUCT = YES;
|
VALIDATE_PRODUCT = YES;
|
||||||
@@ -342,7 +345,7 @@
|
|||||||
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
||||||
INFOPLIST_KEY_CFBundleDisplayName = "Nucleic$(NUCLEIC_NAME_SUFFIX)";
|
INFOPLIST_KEY_CFBundleDisplayName = "Nucleic$(NUCLEIC_NAME_SUFFIX)";
|
||||||
NUCLEIC_NAME_SUFFIX = "";
|
NUCLEIC_NAME_SUFFIX = "";
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
|
IPHONEOS_DEPLOYMENT_TARGET = 18.1;
|
||||||
LD_RUNPATH_SEARCH_PATHS = (
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
@@ -389,7 +392,7 @@
|
|||||||
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
||||||
INFOPLIST_KEY_CFBundleDisplayName = "Nucleic$(NUCLEIC_NAME_SUFFIX)";
|
INFOPLIST_KEY_CFBundleDisplayName = "Nucleic$(NUCLEIC_NAME_SUFFIX)";
|
||||||
NUCLEIC_NAME_SUFFIX = "";
|
NUCLEIC_NAME_SUFFIX = "";
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
|
IPHONEOS_DEPLOYMENT_TARGET = 18.1;
|
||||||
LD_RUNPATH_SEARCH_PATHS = (
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
@@ -423,7 +426,7 @@
|
|||||||
INFOPLIST_FILE = NucleicRemoteWidgets/Info.plist;
|
INFOPLIST_FILE = NucleicRemoteWidgets/Info.plist;
|
||||||
INFOPLIST_KEY_CFBundleDisplayName = NucleicRemoteWidgets;
|
INFOPLIST_KEY_CFBundleDisplayName = NucleicRemoteWidgets;
|
||||||
INFOPLIST_KEY_NSHumanReadableCopyright = "";
|
INFOPLIST_KEY_NSHumanReadableCopyright = "";
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
|
IPHONEOS_DEPLOYMENT_TARGET = 18.1;
|
||||||
LD_RUNPATH_SEARCH_PATHS = (
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
@@ -454,7 +457,7 @@
|
|||||||
INFOPLIST_FILE = NucleicRemoteWidgets/Info.plist;
|
INFOPLIST_FILE = NucleicRemoteWidgets/Info.plist;
|
||||||
INFOPLIST_KEY_CFBundleDisplayName = NucleicRemoteWidgets;
|
INFOPLIST_KEY_CFBundleDisplayName = NucleicRemoteWidgets;
|
||||||
INFOPLIST_KEY_NSHumanReadableCopyright = "";
|
INFOPLIST_KEY_NSHumanReadableCopyright = "";
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
|
IPHONEOS_DEPLOYMENT_TARGET = 18.1;
|
||||||
LD_RUNPATH_SEARCH_PATHS = (
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
@@ -517,6 +520,10 @@
|
|||||||
isa = XCSwiftPackageProductDependency;
|
isa = XCSwiftPackageProductDependency;
|
||||||
productName = NucleicProtocol;
|
productName = NucleicProtocol;
|
||||||
};
|
};
|
||||||
|
PD00000000000000000002 /* NucleicTailnet */ = {
|
||||||
|
isa = XCSwiftPackageProductDependency;
|
||||||
|
productName = NucleicTailnet;
|
||||||
|
};
|
||||||
/* End XCSwiftPackageProductDependency section */
|
/* End XCSwiftPackageProductDependency section */
|
||||||
};
|
};
|
||||||
rootObject = PJ00000000000000000001 /* Project object */;
|
rootObject = PJ00000000000000000001 /* Project object */;
|
||||||
|
|||||||
@@ -3,8 +3,10 @@ import Security
|
|||||||
import NucleicProtocol
|
import NucleicProtocol
|
||||||
|
|
||||||
/// What the phone pins about its Mac at pairing (SYNC §4.2): the host's static key (for IK
|
/// What the phone pins about its Mac at pairing (SYNC §4.2): the host's static key (for IK
|
||||||
/// reconnect), a display name, and an optional LAN hint. The pairing secret is *not* stored —
|
/// reconnect), a display name, and the transport + connection hint from the QR — LAN
|
||||||
/// it's one-time. Non-secret, so UserDefaults is fine; the device private key goes to Keychain.
|
/// host:port or the Mac's tailnet IP. The pairing secret is *not* stored — it's one-time.
|
||||||
|
/// Non-secret, so UserDefaults is fine; the device private key goes to Keychain. The new
|
||||||
|
/// optional fields decode as nil from a pre-transport record (= LAN).
|
||||||
struct PairedHost: Codable, Equatable {
|
struct PairedHost: Codable, Equatable {
|
||||||
var deviceID: String
|
var deviceID: String
|
||||||
var hostName: String
|
var hostName: String
|
||||||
@@ -12,6 +14,12 @@ struct PairedHost: Codable, Equatable {
|
|||||||
var fingerprint: String
|
var fingerprint: String
|
||||||
var lanHost: String?
|
var lanHost: String?
|
||||||
var lanPort: UInt16?
|
var lanPort: UInt16?
|
||||||
|
/// `SyncTransportHint` raw value; nil = LAN (records saved before transports existed).
|
||||||
|
var transport: String?
|
||||||
|
var tailnetHost: String?
|
||||||
|
var tailnetPort: UInt16?
|
||||||
|
|
||||||
|
var transportHint: SyncTransportHint { transport.flatMap(SyncTransportHint.init(rawValue:)) ?? .lan }
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Loads/persists this device's long-term `DeviceIdentity` (Keychain) and the pinned host
|
/// Loads/persists this device's long-term `DeviceIdentity` (Keychain) and the pinned host
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import Foundation
|
|||||||
import Network
|
import Network
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
import NucleicProtocol
|
import NucleicProtocol
|
||||||
|
import NucleicTailnet
|
||||||
|
|
||||||
/// On the phone there's no app-side `SessionSummary` view-model to collide with, so the wire
|
/// On the phone there's no app-side `SessionSummary` view-model to collide with, so the wire
|
||||||
/// type *is* the model. Alias it under the host's name so the shared vocabulary reads the same.
|
/// type *is* the model. Alias it under the host's name so the shared vocabulary reads the same.
|
||||||
@@ -16,7 +17,7 @@ final class RemoteStore: ObservableObject {
|
|||||||
case unpaired
|
case unpaired
|
||||||
case connecting
|
case connecting
|
||||||
case reconnecting
|
case reconnecting
|
||||||
case connected // LAN
|
case connected(SyncTransportHint)
|
||||||
case hostOffline
|
case hostOffline
|
||||||
case failed(String)
|
case failed(String)
|
||||||
|
|
||||||
@@ -25,12 +26,15 @@ final class RemoteStore: ObservableObject {
|
|||||||
case .unpaired: "Not paired"
|
case .unpaired: "Not paired"
|
||||||
case .connecting: "Connecting…"
|
case .connecting: "Connecting…"
|
||||||
case .reconnecting: "Reconnecting…"
|
case .reconnecting: "Reconnecting…"
|
||||||
case .connected: "Connected · LAN"
|
case .connected(let transport): "Connected · \(transport.label)"
|
||||||
case .hostOffline: "Mac offline"
|
case .hostOffline: "Mac offline"
|
||||||
case .failed(let m): m
|
case .failed(let m): m
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
var isLive: Bool { self == .connected }
|
var isLive: Bool {
|
||||||
|
if case .connected = self { return true }
|
||||||
|
return false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Published private(set) var connectivity: Connectivity = .unpaired
|
@Published private(set) var connectivity: Connectivity = .unpaired
|
||||||
@@ -142,6 +146,18 @@ final class RemoteStore: ObservableObject {
|
|||||||
let discovery = LANDiscovery()
|
let discovery = LANDiscovery()
|
||||||
private var client: SyncClient?
|
private var client: SyncClient?
|
||||||
private var eventTask: Task<Void, Never>?
|
private var eventTask: Task<Void, Never>?
|
||||||
|
/// In-flight async connection setup (tailnet node start + dial); cancelled on teardown.
|
||||||
|
private var connectTask: Task<Void, Never>?
|
||||||
|
/// The pending reconnect backoff timer; cancelled on teardown so a stale retry can't
|
||||||
|
/// tear down a newer in-flight attempt.
|
||||||
|
private var retryTask: Task<Void, Never>?
|
||||||
|
/// The transport the current connection attempt uses (drives the "Connected · …" chip).
|
||||||
|
private var activeTransport: SyncTransportHint = .lan
|
||||||
|
/// The phone's embedded Tailscale node state, for Settings (nil = not running).
|
||||||
|
@Published private(set) var tailnetStatus: String?
|
||||||
|
/// The Tailscale interactive-login URL while the node waits for a browser login (a
|
||||||
|
/// first tailnet start with no auth key). Auto-opened; Settings shows a re-open button.
|
||||||
|
@Published private(set) var tailnetLoginURL: URL?
|
||||||
private var seenSeq: Set<UInt64> = []
|
private var seenSeq: Set<UInt64> = []
|
||||||
private var reconnectAttempts = 0
|
private var reconnectAttempts = 0
|
||||||
|
|
||||||
@@ -170,7 +186,7 @@ final class RemoteStore: ObservableObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func seedDemo() {
|
private func seedDemo() {
|
||||||
connectivity = .connected
|
connectivity = .connected(.lan)
|
||||||
hostName = "Andrew's Mac"
|
hostName = "Andrew's Mac"
|
||||||
grantedScope = .control
|
grantedScope = .control
|
||||||
capabilities = WireCapabilities(
|
capabilities = WireCapabilities(
|
||||||
@@ -277,46 +293,242 @@ final class RemoteStore: ObservableObject {
|
|||||||
""")
|
""")
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Pair from a scanned QR (SYNC §4.2): connect (LAN hint first, else Bonjour), run XXpsk0,
|
/// One dialable way to reach the Mac. A connect builds an ordered candidate list — LAN
|
||||||
|
/// first (cheapest when reachable), then the tailnet (SYNC §3.2's LAN-then-fallback
|
||||||
|
/// ordering) — and `attempt` walks it until one carries a session.
|
||||||
|
private enum TransportAttempt {
|
||||||
|
case lan(NWEndpoint)
|
||||||
|
case tailnet(host: String, port: UInt16)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The in-progress connect: remaining candidates plus everything needed to start a
|
||||||
|
/// client on whichever one succeeds. Cleared on `.ready` (chain done) and by teardown.
|
||||||
|
private struct ConnectPlan {
|
||||||
|
var remaining: [TransportAttempt]
|
||||||
|
let hostStaticKey: Data
|
||||||
|
let mode: SyncClient.Mode
|
||||||
|
let deviceID: String
|
||||||
|
let pairingPayload: PairingPayload?
|
||||||
|
}
|
||||||
|
private var connectPlan: ConnectPlan?
|
||||||
|
/// Kills a LAN attempt whose TCP connect just hangs (stale IP hint) so the chain can
|
||||||
|
/// move on — NWConnection's own timeout is far too slow for a fallback decision.
|
||||||
|
private var lanConnectTimeout: Task<Void, Never>?
|
||||||
|
|
||||||
|
/// Pair from a scanned QR (SYNC §4.2): try the QR's transports in order (LAN hint or
|
||||||
|
/// Bonjour first, then the Mac's tailnet IP via the phone's embedded node), run XXpsk0,
|
||||||
/// and on success pin the host key for future IK reconnects.
|
/// and on success pin the host key for future IK reconnects.
|
||||||
func pair(with payload: PairingPayload) {
|
func pair(with payload: PairingPayload) {
|
||||||
teardown()
|
teardown()
|
||||||
connectivity = .connecting
|
connectivity = .connecting
|
||||||
hostName = payload.hostName
|
hostName = payload.hostName
|
||||||
let deviceID = IdentityStore.deviceID()
|
guard let hint = payload.transportHint else {
|
||||||
guard let endpoint = resolveEndpoint(
|
connectivity = .failed("This pairing code needs a newer version of Nucleic Remote.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
guard hint != .relay else {
|
||||||
|
connectivity = .failed("Relay connections aren't supported yet.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let candidates = buildCandidates(
|
||||||
fingerprint: payload.hostStaticKey.fingerprintHex,
|
fingerprint: payload.hostStaticKey.fingerprintHex,
|
||||||
lanHost: payload.lanHost, lanPort: payload.lanPort)
|
lanHost: payload.lanHost, lanPort: payload.lanPort,
|
||||||
else { connectivity = .failed("No Mac found on this network"); return }
|
tailnet: hint == .tailnet ? (payload.tailnetHost, payload.tailnetPort) : nil)
|
||||||
|
guard !candidates.isEmpty else {
|
||||||
let channel = makeChannel(endpoint)
|
connectivity = .failed(hint == .tailnet && !TailnetSupport.isBuiltIn
|
||||||
let client = SyncClient(
|
? TailnetError.notBuiltIn.errorDescription ?? "Tailscale support isn't built in"
|
||||||
channel: channel, identity: identity, hostStaticKey: payload.hostStaticKey,
|
: "No Mac found on this network")
|
||||||
mode: .pair(secret: payload.pairingSecret), deviceID: deviceID,
|
return
|
||||||
deviceLabel: UIDevice.current.name, pushToken: PushRegistrar.shared.tokenHex,
|
}
|
||||||
releaseChannel: BuildInfo.current.channel.releaseChannel)
|
connectPlan = ConnectPlan(
|
||||||
self.client = client
|
remaining: candidates, hostStaticKey: payload.hostStaticKey,
|
||||||
consume(client, pairingPayload: payload)
|
mode: .pair(secret: payload.pairingSecret), deviceID: IdentityStore.deviceID(),
|
||||||
|
pairingPayload: payload)
|
||||||
|
_ = tryNextCandidate()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Reconnect to the already-paired host using IK against the pinned static key.
|
/// Reconnect to the already-paired host using IK against the pinned static key: LAN
|
||||||
|
/// when reachable, else the pairing's tailnet hint — so a phone that leaves the Mac's
|
||||||
|
/// Wi‑Fi rolls over to the tailnet and rolls back when it returns.
|
||||||
func reconnect() {
|
func reconnect() {
|
||||||
guard let host = IdentityStore.loadPairedHost() else { connectivity = .unpaired; return }
|
guard let host = IdentityStore.loadPairedHost() else { connectivity = .unpaired; return }
|
||||||
teardown()
|
teardown()
|
||||||
connectivity = reconnectAttempts == 0 ? .connecting : .reconnecting
|
connectivity = reconnectAttempts == 0 ? .connecting : .reconnecting
|
||||||
hostName = host.hostName
|
hostName = host.hostName
|
||||||
guard let endpoint = resolveEndpoint(
|
guard host.transportHint != .relay else {
|
||||||
fingerprint: host.fingerprint, lanHost: host.lanHost, lanPort: host.lanPort)
|
connectivity = .failed("Relay connections aren't supported yet.")
|
||||||
else { connectivity = .hostOffline; scheduleRetry(); return }
|
return
|
||||||
|
}
|
||||||
|
let candidates = buildCandidates(
|
||||||
|
fingerprint: host.fingerprint,
|
||||||
|
lanHost: host.lanHost, lanPort: host.lanPort,
|
||||||
|
tailnet: host.transportHint == .tailnet ? (host.tailnetHost, host.tailnetPort) : nil)
|
||||||
|
guard !candidates.isEmpty else {
|
||||||
|
connectivity = .hostOffline
|
||||||
|
scheduleRetry()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
connectPlan = ConnectPlan(
|
||||||
|
remaining: candidates, hostStaticKey: host.hostStaticKey,
|
||||||
|
mode: .reconnect, deviceID: host.deviceID, pairingPayload: nil)
|
||||||
|
_ = tryNextCandidate()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// LAN first (explicit hint, else a Bonjour match), tailnet second when the pairing
|
||||||
|
/// carries one and this build can dial it.
|
||||||
|
private func buildCandidates(
|
||||||
|
fingerprint: String?, lanHost: String?, lanPort: UInt16?,
|
||||||
|
tailnet: (host: String?, port: UInt16?)?
|
||||||
|
) -> [TransportAttempt] {
|
||||||
|
var candidates: [TransportAttempt] = []
|
||||||
|
if let endpoint = resolveEndpoint(fingerprint: fingerprint, lanHost: lanHost, lanPort: lanPort) {
|
||||||
|
candidates.append(.lan(endpoint))
|
||||||
|
}
|
||||||
|
if let tailnet, let host = tailnet.host, let port = tailnet.port, TailnetSupport.isBuiltIn {
|
||||||
|
candidates.append(.tailnet(host: host, port: port))
|
||||||
|
}
|
||||||
|
return candidates
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pop and dial the next candidate. False when the plan is exhausted (or gone) — the
|
||||||
|
/// caller then applies its terminal failure handling.
|
||||||
|
private func tryNextCandidate() -> Bool {
|
||||||
|
guard var plan = connectPlan, !plan.remaining.isEmpty else { return false }
|
||||||
|
let next = plan.remaining.removeFirst()
|
||||||
|
connectPlan = plan
|
||||||
|
attempt(next, plan: plan)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
private func attempt(_ candidate: TransportAttempt, plan: ConnectPlan) {
|
||||||
|
teardownClient()
|
||||||
|
switch candidate {
|
||||||
|
case .lan(let endpoint):
|
||||||
|
activeTransport = .lan
|
||||||
let channel = makeChannel(endpoint)
|
let channel = makeChannel(endpoint)
|
||||||
|
// Close the channel if TCP isn't up within the window (a stale IP hint would
|
||||||
|
// otherwise hang the chain on NWConnection's slow timeout); the finished stream
|
||||||
|
// then advances to the next candidate. The timer checks readiness itself — it's
|
||||||
|
// bound to exactly this channel, so a stale timer can never hit a later attempt.
|
||||||
|
lanConnectTimeout?.cancel()
|
||||||
|
lanConnectTimeout = Task { [weak channel] in
|
||||||
|
try? await Task.sleep(for: .seconds(4))
|
||||||
|
guard !Task.isCancelled, let channel, !channel.isReady else { return }
|
||||||
|
channel.close()
|
||||||
|
}
|
||||||
|
startClient(
|
||||||
|
channel: channel, hostStaticKey: plan.hostStaticKey,
|
||||||
|
mode: plan.mode, deviceID: plan.deviceID, pairingPayload: plan.pairingPayload)
|
||||||
|
case .tailnet(let host, let port):
|
||||||
|
activeTransport = .tailnet
|
||||||
|
connectTask = Task { [weak self] in
|
||||||
|
guard let self else { return }
|
||||||
|
do {
|
||||||
|
let channel = try await self.tailnetChannel(host: host, port: port)
|
||||||
|
guard !Task.isCancelled else { channel.close(); return }
|
||||||
|
self.startClient(
|
||||||
|
channel: channel, hostStaticKey: plan.hostStaticKey,
|
||||||
|
mode: plan.mode, deviceID: plan.deviceID, pairingPayload: plan.pairingPayload)
|
||||||
|
} catch {
|
||||||
|
guard !Task.isCancelled else { return }
|
||||||
|
self.tailnetAttemptFailed(error, isPairing: plan.pairingPayload != nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The tailnet is always the last candidate, so its failure ends the chain: terminal
|
||||||
|
/// for pairing and for anything retrying can't fix; otherwise offline + backoff.
|
||||||
|
private func tailnetAttemptFailed(_ error: Error, isPairing: Bool) {
|
||||||
|
if tryNextCandidate() { return }
|
||||||
|
if isPairing {
|
||||||
|
connectivity = .failed(error.localizedDescription)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
switch error {
|
||||||
|
case TailnetError.notBuiltIn, TailnetError.notConfigured:
|
||||||
|
connectivity = .failed(error.localizedDescription)
|
||||||
|
default:
|
||||||
|
connectivity = .hostOffline
|
||||||
|
scheduleRetry()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/// Create the `SyncClient` on an established channel and start consuming its events —
|
||||||
|
/// the tail of every connect path, LAN or tailnet, pair or reconnect.
|
||||||
|
private func startClient(
|
||||||
|
channel: any FrameChannel, hostStaticKey: Data, mode: SyncClient.Mode,
|
||||||
|
deviceID: String, pairingPayload: PairingPayload?
|
||||||
|
) {
|
||||||
let client = SyncClient(
|
let client = SyncClient(
|
||||||
channel: channel, identity: identity, hostStaticKey: host.hostStaticKey,
|
channel: channel, identity: identity, hostStaticKey: hostStaticKey,
|
||||||
mode: .reconnect, deviceID: host.deviceID, deviceLabel: UIDevice.current.name,
|
mode: mode, deviceID: deviceID,
|
||||||
pushToken: PushRegistrar.shared.tokenHex,
|
deviceLabel: UIDevice.current.name, pushToken: PushRegistrar.shared.tokenHex,
|
||||||
releaseChannel: BuildInfo.current.channel.releaseChannel)
|
releaseChannel: BuildInfo.current.channel.releaseChannel)
|
||||||
self.client = client
|
self.client = client
|
||||||
consume(client, pairingPayload: nil)
|
consume(client, pairingPayload: pairingPayload)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Bring the phone's embedded Tailscale node up (first run needs the auth key from
|
||||||
|
/// Settings ▸ Tailscale; afterwards the on-disk state carries the registration) and dial
|
||||||
|
/// the Mac's tailnet address.
|
||||||
|
private func tailnetChannel(host: String, port: UInt16) async throws -> FDFrameChannel {
|
||||||
|
guard TailnetSupport.isBuiltIn else { throw TailnetError.notBuiltIn }
|
||||||
|
let config = Self.phoneTailnetConfig()
|
||||||
|
tailnetStatus = "Starting…"
|
||||||
|
// Mirror node status while the start is in flight. A first start with no auth key
|
||||||
|
// goes through the interactive browser login; pairing usually runs from the scanner
|
||||||
|
// sheet — not Settings — so take the user straight to the approval page.
|
||||||
|
let watcher = Task { [weak self] in
|
||||||
|
for await status in await TailnetNode.shared.statusStream() {
|
||||||
|
guard let self, !Task.isCancelled else { break }
|
||||||
|
self.tailnetStatus = status.label
|
||||||
|
if case .needsLogin(let url) = status, let loginURL = URL(string: url) {
|
||||||
|
if self.tailnetLoginURL != loginURL {
|
||||||
|
self.tailnetLoginURL = loginURL
|
||||||
|
// Eject to Safari only for user-initiated pairing — the user is
|
||||||
|
// actively watching. A routine reconnect that suddenly needs a
|
||||||
|
// login (node revoked, state wiped) must not yank them out of the
|
||||||
|
// app; Settings ▸ Tailscale carries the login link instead.
|
||||||
|
if self.connectPlan?.pairingPayload != nil {
|
||||||
|
UIApplication.shared.open(loginURL, options: [:], completionHandler: nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
self.tailnetLoginURL = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
defer {
|
||||||
|
watcher.cancel()
|
||||||
|
tailnetLoginURL = nil
|
||||||
|
}
|
||||||
|
do {
|
||||||
|
try await TailnetNode.shared.ensureRunning(config: config)
|
||||||
|
} catch TailnetError.timedOut(let message) {
|
||||||
|
// A login/auth timeout won't fix itself — retrying would just block per lap.
|
||||||
|
// Rethrow as .notConfigured so reconnect() treats it as terminal, not offline.
|
||||||
|
tailnetStatus = await TailnetNode.shared.status.label
|
||||||
|
throw TailnetError.notConfigured(message)
|
||||||
|
} catch {
|
||||||
|
tailnetStatus = await TailnetNode.shared.status.label
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
tailnetStatus = await TailnetNode.shared.status.label
|
||||||
|
return try await TailnetNode.shared.dial(host: host, port: port)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The phone's embedded-node config. State lives in this app's sandboxed Application
|
||||||
|
/// Support (no cross-channel collision — each channel is its own app container).
|
||||||
|
private static func phoneTailnetConfig() -> TailnetConfig {
|
||||||
|
let base = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0]
|
||||||
|
.appendingPathComponent("Nucleic", isDirectory: true)
|
||||||
|
.appendingPathComponent("tailnet", isDirectory: true)
|
||||||
|
return TailnetConfig(
|
||||||
|
hostName: TailnetConfig.nodeName(for: UIDevice.current.name),
|
||||||
|
stateDirectory: base,
|
||||||
|
authKey: TailnetAuthStore.loadAuthKey())
|
||||||
}
|
}
|
||||||
|
|
||||||
func unpair() {
|
func unpair() {
|
||||||
@@ -324,6 +536,9 @@ final class RemoteStore: ObservableObject {
|
|||||||
IdentityStore.clearPairedHost()
|
IdentityStore.clearPairedHost()
|
||||||
connectivity = .unpaired
|
connectivity = .unpaired
|
||||||
sessions = []
|
sessions = []
|
||||||
|
// Nothing left to dial — spin the embedded Tailscale node down if it was running.
|
||||||
|
Task { await TailnetNode.shared.stop() }
|
||||||
|
tailnetStatus = nil
|
||||||
LiveActivityManager.shared.end()
|
LiveActivityManager.shared.end()
|
||||||
NotificationRouter.shared.updateBadge(0)
|
NotificationRouter.shared.updateBadge(0)
|
||||||
}
|
}
|
||||||
@@ -736,7 +951,11 @@ final class RemoteStore: ObservableObject {
|
|||||||
eventTask = Task { [weak self] in
|
eventTask = Task { [weak self] in
|
||||||
let stream = await client.start()
|
let stream = await client.start()
|
||||||
for await event in stream {
|
for await event in stream {
|
||||||
await self?.handle(event, pairingPayload: pairingPayload)
|
// A replaced client's tail events (`.failed` is always chased by `.closed`)
|
||||||
|
// must not leak into the new attempt — they'd advance the candidate chain
|
||||||
|
// or schedule retries against a connection that no longer exists.
|
||||||
|
guard let self, self.client === client else { break }
|
||||||
|
await self.handle(event, pairingPayload: pairingPayload)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -747,7 +966,8 @@ final class RemoteStore: ObservableObject {
|
|||||||
break
|
break
|
||||||
case .ready(let welcome):
|
case .ready(let welcome):
|
||||||
reconnectAttempts = 0
|
reconnectAttempts = 0
|
||||||
connectivity = .connected
|
connectPlan = nil // the chain found its transport
|
||||||
|
connectivity = .connected(activeTransport)
|
||||||
hostName = welcome.host.hostName
|
hostName = welcome.host.hostName
|
||||||
capabilities = welcome.capabilities
|
capabilities = welcome.capabilities
|
||||||
grantedScope = welcome.grantedScope
|
grantedScope = welcome.grantedScope
|
||||||
@@ -756,7 +976,9 @@ final class RemoteStore: ObservableObject {
|
|||||||
IdentityStore.savePairedHost(PairedHost(
|
IdentityStore.savePairedHost(PairedHost(
|
||||||
deviceID: IdentityStore.deviceID(), hostName: welcome.host.hostName,
|
deviceID: IdentityStore.deviceID(), hostName: welcome.host.hostName,
|
||||||
hostStaticKey: hostKey, fingerprint: hostKey.fingerprintHex,
|
hostStaticKey: hostKey, fingerprint: hostKey.fingerprintHex,
|
||||||
lanHost: payload.lanHost, lanPort: payload.lanPort))
|
lanHost: payload.lanHost, lanPort: payload.lanPort,
|
||||||
|
transport: payload.transport, tailnetHost: payload.tailnetHost,
|
||||||
|
tailnetPort: payload.tailnetPort))
|
||||||
}
|
}
|
||||||
send(.listSessions)
|
send(.listSessions)
|
||||||
send(.listDashboard)
|
send(.listDashboard)
|
||||||
@@ -826,12 +1048,33 @@ final class RemoteStore: ObservableObject {
|
|||||||
// 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`.
|
||||||
guard error.code != .alreadyResolved else { break }
|
guard error.code != .alreadyResolved else { break }
|
||||||
|
// While the connect chain is still resolving a transport, a pre-ready error
|
||||||
|
// (e.g. "handshake closed" from a LAN probe about to fall back to tailnet) is
|
||||||
|
// routine, not news — the follow-on `.closed` advances the chain silently.
|
||||||
|
guard connectPlan == nil else { break }
|
||||||
showError(error.message, sessionID: error.sessionID)
|
showError(error.message, sessionID: error.sessionID)
|
||||||
case .failed(let message):
|
case .failed(let message):
|
||||||
connectivity = .failed(message)
|
// Another candidate may still carry the session (e.g. LAN died → tailnet).
|
||||||
|
if tryNextCandidate() { break }
|
||||||
|
connectPlan = nil
|
||||||
|
// The channel's onFailed may have just surfaced a friendlier transport-level
|
||||||
|
// cause (denied Local Network, connection refused) — don't clobber it.
|
||||||
|
if case .failed = connectivity {} else { connectivity = .failed(message) }
|
||||||
scheduleRetry()
|
scheduleRetry()
|
||||||
case .closed:
|
case .closed:
|
||||||
if connectivity == .connected { connectivity = .reconnecting }
|
if !connectivity.isLive, tryNextCandidate() { break }
|
||||||
|
if connectivity.isLive {
|
||||||
|
connectivity = .reconnecting
|
||||||
|
} else if connectPlan?.pairingPayload != nil {
|
||||||
|
// A pairing chain died silently (every candidate closed pre-welcome).
|
||||||
|
// There's no retry loop before a pairing succeeds, so without a terminal
|
||||||
|
// state this would sit on "Connecting…" forever. Keep a friendlier
|
||||||
|
// transport-level failure if one was already surfaced.
|
||||||
|
if case .failed = connectivity {} else {
|
||||||
|
connectivity = .failed("Couldn't connect to your Mac — check that it's reachable, then scan again.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
connectPlan = nil
|
||||||
scheduleRetry()
|
scheduleRetry()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -858,14 +1101,29 @@ final class RemoteStore: ObservableObject {
|
|||||||
guard isPaired else { return }
|
guard isPaired else { return }
|
||||||
reconnectAttempts += 1
|
reconnectAttempts += 1
|
||||||
let delay = min(Double(reconnectAttempts) * 1.5, 10)
|
let delay = min(Double(reconnectAttempts) * 1.5, 10)
|
||||||
Task { [weak self] in
|
retryTask?.cancel()
|
||||||
|
retryTask = Task { [weak self] in
|
||||||
|
// `try?` swallows the sleep's CancellationError, so check explicitly.
|
||||||
try? await Task.sleep(for: .seconds(delay))
|
try? await Task.sleep(for: .seconds(delay))
|
||||||
guard let self, self.connectivity != .connected else { return }
|
guard !Task.isCancelled, let self, !self.connectivity.isLive else { return }
|
||||||
self.reconnect()
|
self.reconnect()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func teardown() {
|
private func teardown() {
|
||||||
|
retryTask?.cancel()
|
||||||
|
retryTask = nil
|
||||||
|
connectTask?.cancel()
|
||||||
|
connectTask = nil
|
||||||
|
connectPlan = nil
|
||||||
|
teardownClient()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Drop just the current client/channel — what moving to the next transport candidate
|
||||||
|
/// needs, without discarding the rest of the plan.
|
||||||
|
private func teardownClient() {
|
||||||
|
lanConnectTimeout?.cancel()
|
||||||
|
lanConnectTimeout = nil
|
||||||
eventTask?.cancel()
|
eventTask?.cancel()
|
||||||
eventTask = nil
|
eventTask = nil
|
||||||
if let client { Task { await client.disconnect() } }
|
if let client { Task { await client.disconnect() } }
|
||||||
|
|||||||
@@ -17,6 +17,16 @@ final class NWFrameChannel: FrameChannel, @unchecked Sendable {
|
|||||||
var onReady: (@Sendable () -> Void)?
|
var onReady: (@Sendable () -> Void)?
|
||||||
var onFailed: (@Sendable (String) -> Void)?
|
var onFailed: (@Sendable (String) -> Void)?
|
||||||
|
|
||||||
|
/// Whether TCP reached `.ready` — latched, thread-safe. The connect-timeout guard
|
||||||
|
/// polls this instead of relying on a callback that could race connection start.
|
||||||
|
private let stateLock = NSLock()
|
||||||
|
private var ready = false
|
||||||
|
var isReady: Bool {
|
||||||
|
stateLock.lock()
|
||||||
|
defer { stateLock.unlock() }
|
||||||
|
return ready
|
||||||
|
}
|
||||||
|
|
||||||
init(endpoint: NWEndpoint) {
|
init(endpoint: NWEndpoint) {
|
||||||
let params = NWParameters.tcp
|
let params = NWParameters.tcp
|
||||||
params.includePeerToPeer = true
|
params.includePeerToPeer = true
|
||||||
@@ -28,7 +38,7 @@ final class NWFrameChannel: FrameChannel, @unchecked Sendable {
|
|||||||
|
|
||||||
connection.stateUpdateHandler = { [weak self] state in
|
connection.stateUpdateHandler = { [weak self] state in
|
||||||
switch state {
|
switch state {
|
||||||
case .ready: self?.onReady?()
|
case .ready: self?.markReady(); self?.onReady?()
|
||||||
case .failed(let error): self?.onFailed?("\(error)"); self?.continuation.finish()
|
case .failed(let error): self?.onFailed?("\(error)"); self?.continuation.finish()
|
||||||
case .cancelled: self?.continuation.finish()
|
case .cancelled: self?.continuation.finish()
|
||||||
default: break
|
default: break
|
||||||
@@ -38,6 +48,12 @@ final class NWFrameChannel: FrameChannel, @unchecked Sendable {
|
|||||||
receiveLoop()
|
receiveLoop()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func markReady() {
|
||||||
|
stateLock.lock()
|
||||||
|
ready = true
|
||||||
|
stateLock.unlock()
|
||||||
|
}
|
||||||
|
|
||||||
func frames() -> AsyncStream<Data> { stream }
|
func frames() -> AsyncStream<Data> { stream }
|
||||||
|
|
||||||
func send(_ frame: Data) {
|
func send(_ frame: Data) {
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
import NucleicProtocol
|
import NucleicProtocol
|
||||||
|
import NucleicTailnet
|
||||||
|
|
||||||
struct SettingsView: View {
|
struct SettingsView: View {
|
||||||
@EnvironmentObject var store: RemoteStore
|
@EnvironmentObject var store: RemoteStore
|
||||||
@State private var showScanner = false
|
@State private var showScanner = false
|
||||||
|
@State private var tailscaleAuthKey: String = TailnetAuthStore.loadAuthKey() ?? ""
|
||||||
|
@FocusState private var tailscaleKeyFocused: Bool
|
||||||
@AppStorage("nucleic.showRawEvents") private var showRaw = false
|
@AppStorage("nucleic.showRawEvents") private var showRaw = false
|
||||||
@AppStorage("nucleic.showLockEvents") private var showLockEvents = true
|
@AppStorage("nucleic.showLockEvents") private var showLockEvents = true
|
||||||
@AppStorage(HeartbeatSettings.shareAnonymousUsageKey) private var shareAnonymousUsage = true
|
@AppStorage(HeartbeatSettings.shareAnonymousUsageKey) private var shareAnonymousUsage = true
|
||||||
@@ -53,10 +56,44 @@ struct SettingsView: View {
|
|||||||
if !store.hostName.isEmpty {
|
if !store.hostName.isEmpty {
|
||||||
LabeledContent("Mac", value: store.hostName)
|
LabeledContent("Mac", value: store.hostName)
|
||||||
}
|
}
|
||||||
|
// What this pairing can dial — the live transport is in Status above.
|
||||||
|
if let host = IdentityStore.loadPairedHost() {
|
||||||
|
LabeledContent("Transports", value: host.tailnetHost != nil ? "LAN + Tailnet" : "LAN")
|
||||||
|
}
|
||||||
Button("Reconnect") { store.reconnect() }
|
Button("Reconnect") { store.reconnect() }
|
||||||
.disabled(!store.isPaired)
|
.disabled(!store.isPaired)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Section {
|
||||||
|
if TailnetSupport.isBuiltIn {
|
||||||
|
// Commit on editing end, not per keystroke — a per-change save would
|
||||||
|
// clear the valid Keychain key on the first backspace of an edit.
|
||||||
|
SecureField("Auth key (tskey-auth-…)", text: $tailscaleAuthKey)
|
||||||
|
.autocorrectionDisabled()
|
||||||
|
.textInputAutocapitalization(.never)
|
||||||
|
.focused($tailscaleKeyFocused)
|
||||||
|
.onSubmit { TailnetAuthStore.saveAuthKey(tailscaleAuthKey) }
|
||||||
|
.onChange(of: tailscaleKeyFocused) {
|
||||||
|
if !tailscaleKeyFocused { TailnetAuthStore.saveAuthKey(tailscaleAuthKey) }
|
||||||
|
}
|
||||||
|
if let status = store.tailnetStatus {
|
||||||
|
LabeledContent("Node", value: status)
|
||||||
|
}
|
||||||
|
if let loginURL = store.tailnetLoginURL {
|
||||||
|
Link(destination: loginURL) {
|
||||||
|
Label("Open Tailscale login", systemImage: "arrow.up.forward.app")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Text("This build doesn't include Tailscale support.")
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
} header: {
|
||||||
|
Text("Tailscale")
|
||||||
|
} footer: {
|
||||||
|
Text("Needed only when your Mac shares over Tailscale (Mac ▸ Settings ▸ Remote ▸ Connect via). Leave the key empty to approve this iPhone in your browser on first connect, or create an auth key in the Tailscale admin console (kept in the Keychain, used once to join your tailnet).")
|
||||||
|
}
|
||||||
|
|
||||||
Section("This device") {
|
Section("This device") {
|
||||||
LabeledContent("Scope", value: store.grantedScope.rawValue.capitalized)
|
LabeledContent("Scope", value: store.grantedScope.rawValue.capitalized)
|
||||||
LabeledContent("Key fingerprint", value: store.deviceFingerprint)
|
LabeledContent("Key fingerprint", value: store.deviceFingerprint)
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
# Nucleic release version state — the single source of truth for this app's version.
|
# Nucleic release version state — the single source of truth for this app's version.
|
||||||
# Bumped by scripts/bump-version.sh on every release (Mac: ./VERSION; iOS: ios/VERSION).
|
# Bumped by scripts/bump-version.sh on every release (Mac: ./VERSION; iOS: ios/VERSION).
|
||||||
# Shell-sourceable (KEY=value). Don't hand-edit during a release — bump with e.g.:
|
# Shell-sourceable (KEY=value). Don't hand-edit during a release — bump with e.g.:
|
||||||
# make ios-release-beta # public beta, minor (the beta default): 0.1.0 -> 0.2.0, build +1
|
# make release-beta # 0.1.0 -> 0.2.0 (minor — the Mac default), build +1
|
||||||
# make ios-release-canary # canary, build-only (the canary default): 0.1.0 -> 0.1.0, build +1
|
# make release-beta BUMP=major # 0.1.0 -> 1.0.0, build +1
|
||||||
# make ios-release-beta BUMP=major # 0.1.0 -> 1.0.0, build +1
|
# make release-beta BUMP=patch # 0.1.0 -> 0.1.1, build +1
|
||||||
# make ios-release-beta BUMP=patch # 0.1.0 -> 0.1.1, build +1
|
# make release-beta BUMP=build # 0.1.0 -> 0.1.0 (build number only), build +1
|
||||||
# make ios-release-beta BUMP=build # 0.1.0 -> 0.1.0 (build number only), build +1
|
# make ios-release BUMP=minor # same knob for the iOS app (ios/VERSION)
|
||||||
#
|
#
|
||||||
# NUCLEIC_MARKETING_VERSION semantic version -> CFBundleShortVersionString
|
# NUCLEIC_MARKETING_VERSION semantic version -> CFBundleShortVersionString
|
||||||
# NUCLEIC_BUILD_NUMBER monotonic build -> CFBundleVersion (always +1 per release)
|
# NUCLEIC_BUILD_NUMBER monotonic build -> CFBundleVersion (always +1 per release)
|
||||||
NUCLEIC_MARKETING_VERSION=0.1.0
|
NUCLEIC_MARKETING_VERSION=0.2.0
|
||||||
NUCLEIC_BUILD_NUMBER=0
|
NUCLEIC_BUILD_NUMBER=4
|
||||||
|
|||||||
Reference in New Issue
Block a user