diff --git a/NucleicRemote/NucleicRemote/Models/HostConnection.swift b/NucleicRemote/NucleicRemote/Models/HostConnection.swift index b1d5247..c8a4e6a 100644 --- a/NucleicRemote/NucleicRemote/Models/HostConnection.swift +++ b/NucleicRemote/NucleicRemote/Models/HostConnection.swift @@ -177,6 +177,39 @@ final class HostConnection { callbacks.didUpdate() } + /// Translate a gossiped mesh member (mesh "join") into a pinnable/dialable `PairedHost`. The + /// member carries the host static key + last-known addresses — everything IK reconnect needs. + /// No relay token yet (the phone never directly paired this Mac); it's adopted from the host's + /// `relayMembership` push after the first LAN/tailnet contact, so first contact needs one of + /// those paths. + static func pairedHost(from member: MeshMember) -> PairedHost { + let (lanHost, lanPort) = splitHostPort(member.addresses?.lanHint) + let tailnetHost = member.addresses?.tailnet + let hasTailnet = !(tailnetHost?.isEmpty ?? true) + return PairedHost( + deviceID: IdentityStore.deviceID(), + hostName: member.label, + hostStaticKey: member.staticPublicKey, + fingerprint: member.staticPublicKey.fingerprintHex, + lanHost: lanHost, lanPort: lanPort, + transport: (hasTailnet ? SyncTransportHint.tailnet : .lan).rawValue, + tailnetHost: hasTailnet ? tailnetHost : nil, + // The sync port on a tailnet is fixed protocol-wide (SyncTransportSetting.tailnetPort); + // a gossiped address carries only the IP. + tailnetPort: hasTailnet ? 43_753 : nil, + relayRoomID: member.addresses?.relayRoomID, + relayMembershipToken: nil, relayURL: nil) + } + + /// Split a "host:port" hint (last-colon split so a bracketed IPv6 host survives). + private static func splitHostPort(_ hint: String?) -> (String?, UInt16?) { + guard let hint, let colon = hint.lastIndex(of: ":"), + let port = UInt16(hint[hint.index(after: colon)...]) else { return (nil, nil) } + var host = String(hint[..