Темизация: adaptive цвета чата, context menu, attachment picker, auth + instant отклик DarkMode кнопки

This commit is contained in:
2026-04-05 12:16:24 +05:00
parent a72a1bd6c9
commit d65624ad35
21 changed files with 803 additions and 159 deletions

View File

@@ -1,9 +1,10 @@
import Foundation import Foundation
/// Call signaling packet (0x1A / 26). /// Call signaling packet (0x1A / 26).
/// Wire format mirrors desktop/android: /// Wire format mirrors server/desktop legacy signaling:
/// `signalType` always first, then short-form for busy/disconnected, /// `signalType` always first, then short-form for busy/disconnected/ringingTimeout,
/// otherwise `src`, `dst`, optional `sharedPublic`, optional `roomId`. /// otherwise `src`, `dst`, optional `sharedPublic`, optional `callId/joinToken`,
/// optional `roomId` for create-room fallback compatibility.
enum SignalType: Int, Sendable { enum SignalType: Int, Sendable {
case call = 0 case call = 0
case keyExchange = 1 case keyExchange = 1
@@ -12,6 +13,8 @@ enum SignalType: Int, Sendable {
case createRoom = 4 case createRoom = 4
case endCallBecausePeerDisconnected = 5 case endCallBecausePeerDisconnected = 5
case endCallBecauseBusy = 6 case endCallBecauseBusy = 6
case accept = 7
case ringingTimeout = 8
} }
struct PacketSignalPeer: Packet { struct PacketSignalPeer: Packet {
@@ -21,11 +24,13 @@ struct PacketSignalPeer: Packet {
var dst: String = "" var dst: String = ""
var sharedPublic: String = "" var sharedPublic: String = ""
var signalType: SignalType = .call var signalType: SignalType = .call
var callId: String = ""
var joinToken: String = ""
var roomId: String = "" var roomId: String = ""
func write(to stream: Stream) { func write(to stream: Stream) {
stream.writeInt8(signalType.rawValue) stream.writeInt8(signalType.rawValue)
if signalType == .endCallBecauseBusy || signalType == .endCallBecausePeerDisconnected { if isShortSignal {
return return
} }
stream.writeString(src) stream.writeString(src)
@@ -33,14 +38,27 @@ struct PacketSignalPeer: Packet {
if signalType == .keyExchange { if signalType == .keyExchange {
stream.writeString(sharedPublic) stream.writeString(sharedPublic)
} }
if signalType == .createRoom { if hasLegacyCallMetadata {
stream.writeString(callId)
stream.writeString(joinToken)
}
// Signal code 4 is mode-aware:
// - legacy ACTIVE: no roomId on wire
// - create-room fallback: roomId present on wire
if signalType == .createRoom && !roomId.isEmpty {
stream.writeString(roomId) stream.writeString(roomId)
} }
} }
mutating func read(from stream: Stream) { mutating func read(from stream: Stream) {
src = ""
dst = ""
sharedPublic = ""
callId = ""
joinToken = ""
roomId = ""
signalType = SignalType(rawValue: stream.readInt8()) ?? .call signalType = SignalType(rawValue: stream.readInt8()) ?? .call
if signalType == .endCallBecauseBusy || signalType == .endCallBecausePeerDisconnected { if isShortSignal {
return return
} }
src = stream.readString() src = stream.readString()
@@ -48,8 +66,25 @@ struct PacketSignalPeer: Packet {
if signalType == .keyExchange { if signalType == .keyExchange {
sharedPublic = stream.readString() sharedPublic = stream.readString()
} }
if hasLegacyCallMetadata {
callId = stream.readString()
joinToken = stream.readString()
}
// Signal code 4 is mode-aware on read:
// - empty roomId => legacy ACTIVE
// - non-empty roomId => create-room fallback
if signalType == .createRoom { if signalType == .createRoom {
roomId = stream.readString() roomId = stream.readString()
} }
} }
private var isShortSignal: Bool {
signalType == .endCallBecauseBusy
|| signalType == .endCallBecausePeerDisconnected
|| signalType == .ringingTimeout
}
private var hasLegacyCallMetadata: Bool {
signalType == .call || signalType == .accept || signalType == .endCall
}
} }

View File

@@ -356,6 +356,8 @@ final class ProtocolManager: @unchecked Sendable {
src: String = "", src: String = "",
dst: String = "", dst: String = "",
sharedPublic: String = "", sharedPublic: String = "",
callId: String = "",
joinToken: String = "",
roomId: String = "" roomId: String = ""
) { ) {
var packet = PacketSignalPeer() var packet = PacketSignalPeer()
@@ -363,6 +365,8 @@ final class ProtocolManager: @unchecked Sendable {
packet.src = src packet.src = src
packet.dst = dst packet.dst = dst
packet.sharedPublic = sharedPublic packet.sharedPublic = sharedPublic
packet.callId = callId
packet.joinToken = joinToken
packet.roomId = roomId packet.roomId = roomId
sendPacket(packet) sendPacket(packet)
} }

View File

@@ -144,6 +144,10 @@ extension CallManager {
func beginCallSession(peerPublicKey: String, title: String, username: String) { func beginCallSession(peerPublicKey: String, title: String, username: String) {
finishCall(reason: nil, notifyPeer: false) finishCall(reason: nil, notifyPeer: false)
signalingMode = .undecided
callId = ""
joinToken = ""
pendingIncomingAccept = false
uiState = CallUiState( uiState = CallUiState(
phase: .idle, phase: .idle,
peerPublicKey: peerPublicKey, peerPublicKey: peerPublicKey,
@@ -158,11 +162,14 @@ extension CallManager {
guard !isFinishingCall, uiState.phase != .idle else { return } guard !isFinishingCall, uiState.phase != .idle else { return }
isFinishingCall = true isFinishingCall = true
pendingCallKitAccept = false pendingCallKitAccept = false
pendingIncomingAccept = false
defer { isFinishingCall = false } defer { isFinishingCall = false }
callLogger.notice("[Call] finishCall(reason=\(reason ?? "nil", privacy: .public)) phase=\(self.uiState.phase.rawValue, privacy: .public)") callLogger.notice("[Call] finishCall(reason=\(reason ?? "nil", privacy: .public)) phase=\(self.uiState.phase.rawValue, privacy: .public)")
let snapshot = uiState let snapshot = uiState
let snapshotCallId = callId
let snapshotJoinToken = joinToken
// Step 0: Cancel recovery/rebind tasks and clear packet buffer. // Step 0: Cancel recovery/rebind tasks and clear packet buffer.
disconnectRecoveryTask?.cancel() disconnectRecoveryTask?.cancel()
@@ -221,7 +228,9 @@ extension CallManager {
ProtocolManager.shared.sendCallSignal( ProtocolManager.shared.sendCallSignal(
signalType: .endCall, signalType: .endCall,
src: ownPublicKey, src: ownPublicKey,
dst: snapshot.peerPublicKey dst: snapshot.peerPublicKey,
callId: snapshotCallId,
joinToken: snapshotJoinToken
) )
} }
@@ -244,7 +253,10 @@ extension CallManager {
// Step 6: Reset all state. // Step 6: Reset all state.
role = nil role = nil
signalingMode = .undecided
roomId = "" roomId = ""
callId = ""
joinToken = ""
localPrivateKey = nil localPrivateKey = nil
localPublicKeyHex = "" localPublicKeyHex = ""
sharedKey = nil sharedKey = nil

View File

@@ -18,7 +18,10 @@ final class CallManager: NSObject, ObservableObject {
var ownPublicKey: String = "" var ownPublicKey: String = ""
var role: CallRole? var role: CallRole?
var signalingMode: CallSignalingMode = .undecided
var roomId: String = "" var roomId: String = ""
var callId: String = ""
var joinToken: String = ""
var localPrivateKey: Curve25519.KeyAgreement.PrivateKey? var localPrivateKey: Curve25519.KeyAgreement.PrivateKey?
var localPublicKeyHex: String = "" var localPublicKeyHex: String = ""
var sharedKey: Data? var sharedKey: Data?
@@ -49,6 +52,9 @@ final class CallManager: NSObject, ObservableObject {
/// Pending accept: user tapped Accept on CallKit before WebSocket delivered .call signal. /// Pending accept: user tapped Accept on CallKit before WebSocket delivered .call signal.
/// When handleSignalPacket(.call) arrives, auto-accept if this is true. /// When handleSignalPacket(.call) arrives, auto-accept if this is true.
var pendingCallKitAccept = false var pendingCallKitAccept = false
/// Deferred incoming accept: user accepted, but call signaling metadata/mode is
/// not yet sufficient to send the next signal (legacy ACCEPT or create-room KEY_EXCHANGE).
var pendingIncomingAccept = false
/// True after CallKit fires didActivate audio session has the entitlement. /// True after CallKit fires didActivate audio session has the entitlement.
/// WebRTC peer connection MUST NOT be created before this flag is true, /// WebRTC peer connection MUST NOT be created before this flag is true,
/// otherwise AURemoteIO init fails with "Missing entitlement" (-12988). /// otherwise AURemoteIO init fails with "Missing entitlement" (-12988).
@@ -98,22 +104,39 @@ final class CallManager: NSObject, ObservableObject {
/// Sets up incoming call state directly from VoIP push payload. /// Sets up incoming call state directly from VoIP push payload.
/// Called when app was killed PushKit wakes it WebSocket not yet connected. /// Called when app was killed PushKit wakes it WebSocket not yet connected.
/// The .call signal may never arrive (fire-and-forget), so we set up state from push. /// The .call signal may never arrive (fire-and-forget), so we set up state from push.
func setupIncomingCallFromPush(callerKey: String, callerName: String) { func setupIncomingCallFromPush(
guard uiState.phase == .idle else { return } callerKey: String,
guard !callerKey.isEmpty else { return } callerName: String,
callLogger.notice("setupIncomingCallFromPush: callerKey=\(callerKey.prefix(12), privacy: .public) name=\(callerName, privacy: .public)") callId: String? = nil,
joinToken: String? = nil
) {
let peer = callerKey.trimmingCharacters(in: .whitespacesAndNewlines)
guard !peer.isEmpty else { return }
mergeLegacySessionMetadata(callId: callId, joinToken: joinToken, source: "push")
if hasLegacySessionMetadata {
setSignalingMode(.legacy, reason: "push metadata")
}
guard uiState.phase == .idle else {
completePendingIncomingAcceptIfPossible(trigger: "push update")
return
}
callLogger.notice(
"setupIncomingCallFromPush: callerKey=\(peer.prefix(12), privacy: .public) name=\(callerName, privacy: .public) mode=\(self.signalingMode.rawValue, privacy: .public)"
)
// Don't call beginCallSession() it calls finishCall() which kills the // Don't call beginCallSession() it calls finishCall() which kills the
// CallKit call that PushKit just reported. Set state directly instead. // CallKit call that PushKit just reported. Set state directly instead.
uiState = CallUiState( uiState = CallUiState(
phase: .incoming, phase: .incoming,
peerPublicKey: callerKey, peerPublicKey: peer,
peerTitle: callerName, peerTitle: callerName,
peerUsername: "" peerUsername: ""
) )
role = .callee role = .callee
uiState.statusText = "Incoming call..." uiState.statusText = "Incoming call..."
ProtocolManager.shared.beginCallBackgroundTask() ProtocolManager.shared.beginCallBackgroundTask()
hydratePeerIdentity(for: callerKey) hydratePeerIdentity(for: peer)
startRingTimeout() startRingTimeout()
// Auto-accept if user already tapped Accept on CallKit before this ran. // Auto-accept if user already tapped Accept on CallKit before this ran.
@@ -125,6 +148,7 @@ final class CallManager: NSObject, ObservableObject {
let result = acceptIncomingCall() let result = acceptIncomingCall()
callLogger.info("setupIncomingCallFromPush: auto-accept result=\(String(describing: result), privacy: .public)") callLogger.info("setupIncomingCallFromPush: auto-accept result=\(String(describing: result), privacy: .public)")
} }
completePendingIncomingAcceptIfPossible(trigger: "push setup")
} }
func onAuthenticated() { func onAuthenticated() {
@@ -143,6 +167,7 @@ final class CallManager: NSObject, ObservableObject {
beginCallSession(peerPublicKey: target, title: title, username: username) beginCallSession(peerPublicKey: target, title: title, username: username)
role = .caller role = .caller
setSignalingMode(.undecided, reason: "outgoing call started")
ensureLocalSessionKeys() ensureLocalSessionKeys()
uiState.phase = .outgoing uiState.phase = .outgoing
uiState.statusText = "Calling..." uiState.statusText = "Calling..."
@@ -172,16 +197,14 @@ final class CallManager: NSObject, ObservableObject {
ensureLocalSessionKeys() ensureLocalSessionKeys()
guard localPublicKeyHex.isEmpty == false else { return .invalidTarget } guard localPublicKeyHex.isEmpty == false else { return .invalidTarget }
ProtocolManager.shared.sendCallSignal( pendingIncomingAccept = true
signalType: .keyExchange,
src: ownPublicKey,
dst: uiState.peerPublicKey,
sharedPublic: localPublicKeyHex
)
uiState.phase = .keyExchange uiState.phase = .keyExchange
uiState.isMinimized = false // Show full-screen custom overlay after accept uiState.isMinimized = false // Show full-screen custom overlay after accept
uiState.statusText = "Exchanging keys..." uiState.statusText = "Connecting..."
callLogger.notice(
"[Call] acceptIncomingCall: queued mode=\(self.signalingMode.rawValue, privacy: .public) hasMetadata=\(self.hasLegacySessionMetadata.description, privacy: .public)"
)
completePendingIncomingAcceptIfPossible(trigger: "acceptIncomingCall")
return .started return .started
} }
@@ -192,7 +215,9 @@ final class CallManager: NSObject, ObservableObject {
ProtocolManager.shared.sendCallSignal( ProtocolManager.shared.sendCallSignal(
signalType: .endCall, signalType: .endCall,
src: ownPublicKey, src: ownPublicKey,
dst: uiState.peerPublicKey dst: uiState.peerPublicKey,
callId: callId,
joinToken: joinToken
) )
} }
finishCall(reason: nil, notifyPeer: false) finishCall(reason: nil, notifyPeer: false)
@@ -264,8 +289,80 @@ final class CallManager: NSObject, ObservableObject {
} }
} }
private var hasLegacySessionMetadata: Bool {
!callId.isEmpty && !joinToken.isEmpty
}
private func setSignalingMode(_ mode: CallSignalingMode, reason: String) {
guard signalingMode != mode else { return }
callLogger.notice(
"[Call] signaling mode \(self.signalingMode.rawValue, privacy: .public) -> \(mode.rawValue, privacy: .public) (\(reason, privacy: .public))"
)
signalingMode = mode
}
private func mergeLegacySessionMetadata(callId: String?, joinToken: String?, source: String) {
let normalizedCallId = callId?
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
let normalizedJoinToken = joinToken?
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
if !normalizedCallId.isEmpty && self.callId != normalizedCallId {
callLogger.info(
"[Call] merge callId from \(source, privacy: .public): \(normalizedCallId.prefix(12), privacy: .public)"
)
self.callId = normalizedCallId
}
if !normalizedJoinToken.isEmpty && self.joinToken != normalizedJoinToken {
callLogger.info("[Call] merge joinToken from \(source, privacy: .public)")
self.joinToken = normalizedJoinToken
}
}
private func completePendingIncomingAcceptIfPossible(trigger: String) {
guard pendingIncomingAccept else { return }
guard ownPublicKey.isEmpty == false else { return }
guard uiState.peerPublicKey.isEmpty == false else { return }
switch signalingMode {
case .legacy:
guard hasLegacySessionMetadata else {
callLogger.info("[Call] pending accept waits for legacy metadata (\(trigger, privacy: .public))")
return
}
ProtocolManager.shared.sendCallSignal(
signalType: .accept,
src: ownPublicKey,
dst: uiState.peerPublicKey,
callId: callId,
joinToken: joinToken
)
pendingIncomingAccept = false
uiState.statusText = "Waiting for key exchange..."
callLogger.notice("[Call] pending accept completed via ACCEPT (\(trigger, privacy: .public))")
case .createRoom:
ensureLocalSessionKeys()
guard !localPublicKeyHex.isEmpty else { return }
ProtocolManager.shared.sendCallSignal(
signalType: .keyExchange,
src: ownPublicKey,
dst: uiState.peerPublicKey,
sharedPublic: localPublicKeyHex
)
pendingIncomingAccept = false
uiState.statusText = "Exchanging keys..."
callLogger.notice("[Call] pending accept completed via KEY_EXCHANGE (\(trigger, privacy: .public))")
case .undecided:
callLogger.info("[Call] pending accept waits for signaling mode decision (\(trigger, privacy: .public))")
}
}
private func handleSignalPacket(_ packet: PacketSignalPeer) { private func handleSignalPacket(_ packet: PacketSignalPeer) {
callLogger.notice("[Call] handleSignalPacket: type=\(String(describing: packet.signalType), privacy: .public) phase=\(self.uiState.phase.rawValue, privacy: .public)") callLogger.notice(
"[Call] handleSignalPacket: type=\(String(describing: packet.signalType), privacy: .public) phase=\(self.uiState.phase.rawValue, privacy: .public) mode=\(self.signalingMode.rawValue, privacy: .public)"
)
switch packet.signalType { switch packet.signalType {
case .endCallBecauseBusy: case .endCallBecauseBusy:
finishCall(reason: "User is busy", notifyPeer: false, skipAttachment: true) finishCall(reason: "User is busy", notifyPeer: false, skipAttachment: true)
@@ -273,6 +370,9 @@ final class CallManager: NSObject, ObservableObject {
case .endCallBecausePeerDisconnected: case .endCallBecausePeerDisconnected:
finishCall(reason: "Peer disconnected", notifyPeer: false) finishCall(reason: "Peer disconnected", notifyPeer: false)
return return
case .ringingTimeout:
finishCall(reason: "No answer", notifyPeer: false, skipAttachment: true)
return
case .endCall: case .endCall:
finishCall(reason: "Call ended", notifyPeer: false) finishCall(reason: "Call ended", notifyPeer: false)
return return
@@ -290,12 +390,23 @@ final class CallManager: NSObject, ObservableObject {
case .call: case .call:
let incomingPeer = packet.src.trimmingCharacters(in: .whitespacesAndNewlines) let incomingPeer = packet.src.trimmingCharacters(in: .whitespacesAndNewlines)
guard incomingPeer.isEmpty == false else { return } guard incomingPeer.isEmpty == false else { return }
mergeLegacySessionMetadata(
callId: packet.callId,
joinToken: packet.joinToken,
source: "signal.call"
)
if hasLegacySessionMetadata {
setSignalingMode(.legacy, reason: "incoming .call with call metadata")
} else if signalingMode == .undecided {
setSignalingMode(.createRoom, reason: "incoming .call without metadata")
}
guard uiState.phase == .idle else { guard uiState.phase == .idle else {
// Already in a call with this peer ignore duplicate .call signal. // Already in a call with this peer ignore duplicate .call signal.
// Server re-delivers .call after WebSocket reconnect; without this guard, // Server re-delivers .call after WebSocket reconnect; without this guard,
// the code sends .endCallBecauseBusy which terminates the active call. // the code sends .endCallBecauseBusy which terminates the active call.
if incomingPeer == uiState.peerPublicKey { if incomingPeer == uiState.peerPublicKey {
callLogger.info("Ignoring duplicate .call signal — already in call with this peer (phase=\(self.uiState.phase.rawValue, privacy: .public))") callLogger.info("Ignoring duplicate .call signal — already in call with this peer (phase=\(self.uiState.phase.rawValue, privacy: .public))")
completePendingIncomingAcceptIfPossible(trigger: "duplicate .call")
return return
} }
// Different peer trying to call send busy. // Different peer trying to call send busy.
@@ -306,7 +417,26 @@ final class CallManager: NSObject, ObservableObject {
) )
return return
} }
let cachedCallId = callId
let cachedJoinToken = joinToken
beginCallSession(peerPublicKey: incomingPeer, title: "", username: "") beginCallSession(peerPublicKey: incomingPeer, title: "", username: "")
mergeLegacySessionMetadata(
callId: packet.callId,
joinToken: packet.joinToken,
source: "signal.call post-reset"
)
if !hasLegacySessionMetadata {
mergeLegacySessionMetadata(
callId: cachedCallId,
joinToken: cachedJoinToken,
source: "signal.call cached metadata"
)
}
if hasLegacySessionMetadata {
setSignalingMode(.legacy, reason: "incoming .call session start")
} else {
setSignalingMode(.createRoom, reason: "incoming .call session start")
}
role = .callee role = .callee
uiState.phase = .incoming uiState.phase = .incoming
ProtocolManager.shared.beginCallBackgroundTask() ProtocolManager.shared.beginCallBackgroundTask()
@@ -335,12 +465,50 @@ final class CallManager: NSObject, ObservableObject {
let result = acceptIncomingCall() let result = acceptIncomingCall()
callLogger.info("Auto-accept: result=\(String(describing: result), privacy: .public) ownKey=\(self.ownPublicKey.isEmpty ? "EMPTY" : String(self.ownPublicKey.prefix(12)), privacy: .public) phase=\(self.uiState.phase.rawValue, privacy: .public) wsState=\(String(describing: ProtocolManager.shared.connectionState), privacy: .public)") callLogger.info("Auto-accept: result=\(String(describing: result), privacy: .public) ownKey=\(self.ownPublicKey.isEmpty ? "EMPTY" : String(self.ownPublicKey.prefix(12)), privacy: .public) phase=\(self.uiState.phase.rawValue, privacy: .public) wsState=\(String(describing: ProtocolManager.shared.connectionState), privacy: .public)")
} }
completePendingIncomingAcceptIfPossible(trigger: "incoming .call")
case .accept:
mergeLegacySessionMetadata(
callId: packet.callId,
joinToken: packet.joinToken,
source: "signal.accept"
)
guard role == .caller else { return }
if signalingMode == .undecided {
setSignalingMode(.legacy, reason: "caller received ACCEPT")
} else if signalingMode == .createRoom {
callLogger.notice("[Call] ACCEPT ignored — mode already createRoom")
return
}
ensureLocalSessionKeys()
guard !localPublicKeyHex.isEmpty else { return }
ProtocolManager.shared.sendCallSignal(
signalType: .keyExchange,
src: ownPublicKey,
dst: uiState.peerPublicKey,
sharedPublic: localPublicKeyHex
)
uiState.phase = .keyExchange
uiState.statusText = "Exchanging keys..."
callLogger.notice("[Call] legacy path: ACCEPT -> KEY_EXCHANGE")
case .keyExchange: case .keyExchange:
if role == .caller, signalingMode == .undecided {
setSignalingMode(.createRoom, reason: "caller received KEY_EXCHANGE before ACCEPT")
}
handleKeyExchange(packet) handleKeyExchange(packet)
case .createRoom: case .createRoom:
let incomingRoomId = packet.roomId.trimmingCharacters(in: .whitespacesAndNewlines) let incomingRoomId = packet.roomId.trimmingCharacters(in: .whitespacesAndNewlines)
guard incomingRoomId.isEmpty == false else { return } if incomingRoomId.isEmpty {
roomId = incomingRoomId if signalingMode == .undecided {
setSignalingMode(.legacy, reason: "signal 4 without roomId")
}
callLogger.notice("[Call] signal 4 handled as ACTIVE (legacy)")
} else {
roomId = incomingRoomId
if signalingMode == .undecided {
setSignalingMode(.createRoom, reason: "signal 4 with roomId")
}
callLogger.notice("[Call] signal 4 handled as CREATE_ROOM roomId=\(incomingRoomId.prefix(16), privacy: .public)")
}
uiState.phase = .webRtcExchange uiState.phase = .webRtcExchange
uiState.statusText = "Connecting..." uiState.statusText = "Connecting..."
if audioSessionActivated { if audioSessionActivated {
@@ -373,7 +541,7 @@ final class CallManager: NSObject, ObservableObject {
} }
case .activeCall: case .activeCall:
break break
case .endCall, .endCallBecausePeerDisconnected, .endCallBecauseBusy: case .ringingTimeout, .endCall, .endCallBecausePeerDisconnected, .endCallBecauseBusy:
break break
} }
} }
@@ -406,22 +574,66 @@ final class CallManager: NSObject, ObservableObject {
switch role { switch role {
case .caller: case .caller:
ProtocolManager.shared.sendCallSignal( switch signalingMode {
signalType: .keyExchange, case .legacy:
src: ownPublicKey, ProtocolManager.shared.sendCallSignal(
dst: uiState.peerPublicKey, signalType: .createRoom,
sharedPublic: localPublicKeyHex src: ownPublicKey,
) dst: uiState.peerPublicKey
ProtocolManager.shared.sendCallSignal( )
signalType: .createRoom, uiState.phase = .keyExchange
src: ownPublicKey, uiState.statusText = "Finalizing call..."
dst: uiState.peerPublicKey callLogger.notice("[Call] legacy path: KEY_EXCHANGE complete -> ACTIVE")
) case .createRoom:
uiState.phase = .webRtcExchange ProtocolManager.shared.sendCallSignal(
uiState.statusText = "Creating room..." signalType: .keyExchange,
src: ownPublicKey,
dst: uiState.peerPublicKey,
sharedPublic: localPublicKeyHex
)
ProtocolManager.shared.sendCallSignal(
signalType: .createRoom,
src: ownPublicKey,
dst: uiState.peerPublicKey,
roomId: roomId
)
uiState.phase = .webRtcExchange
uiState.statusText = "Creating room..."
callLogger.notice("[Call] create-room path: KEY_EXCHANGE complete -> CREATE_ROOM")
case .undecided:
setSignalingMode(.createRoom, reason: "fallback on caller key exchange")
ProtocolManager.shared.sendCallSignal(
signalType: .keyExchange,
src: ownPublicKey,
dst: uiState.peerPublicKey,
sharedPublic: localPublicKeyHex
)
ProtocolManager.shared.sendCallSignal(
signalType: .createRoom,
src: ownPublicKey,
dst: uiState.peerPublicKey,
roomId: roomId
)
uiState.phase = .webRtcExchange
uiState.statusText = "Creating room..."
}
case .callee: case .callee:
uiState.phase = .keyExchange switch signalingMode {
uiState.statusText = "Waiting for room..." case .legacy:
ProtocolManager.shared.sendCallSignal(
signalType: .keyExchange,
src: ownPublicKey,
dst: uiState.peerPublicKey,
sharedPublic: localPublicKeyHex
)
uiState.phase = .keyExchange
uiState.statusText = "Waiting for active signal..."
callLogger.notice("[Call] legacy path: sent KEY_EXCHANGE, waiting for ACTIVE")
case .createRoom, .undecided:
uiState.phase = .keyExchange
uiState.statusText = "Waiting for room..."
callLogger.notice("[Call] create-room path: waiting for room signal")
}
case .none: case .none:
break break
} }

View File

@@ -15,6 +15,12 @@ enum CallRole: Sendable {
case callee case callee
} }
enum CallSignalingMode: String, Sendable {
case undecided
case legacy
case createRoom
}
enum CallActionResult: Sendable { enum CallActionResult: Sendable {
case started case started
case alreadyInCall case alreadyInCall

View File

@@ -17,7 +17,7 @@ struct DarkModeWrapper<Content: View>: View {
content content
.onAppear { .onAppear {
if overlayWindow == nil { if overlayWindow == nil {
if let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene { if let windowScene = activeWindowScene {
let overlayWindow = UIWindow(windowScene: windowScene) let overlayWindow = UIWindow(windowScene: windowScene)
overlayWindow.tag = 0320 overlayWindow.tag = 0320
overlayWindow.isHidden = false overlayWindow.isHidden = false
@@ -27,31 +27,41 @@ struct DarkModeWrapper<Content: View>: View {
} }
} }
.onChange(of: activateDarkMode, initial: true) { _, newValue in .onChange(of: activateDarkMode, initial: true) { _, newValue in
if let keyWindow = (UIApplication.shared.connectedScenes.first as? UIWindowScene)?.windows.first(where: { $0.isKeyWindow }) { if let windowScene = activeWindowScene {
keyWindow.overrideUserInterfaceStyle = newValue ? .dark : .light let style: UIUserInterfaceStyle = newValue ? .dark : .light
for window in windowScene.windows {
window.overrideUserInterfaceStyle = style
}
} }
} }
} }
private var activeWindowScene: UIWindowScene? {
let scenes = UIApplication.shared.connectedScenes.compactMap { $0 as? UIWindowScene }
return scenes.first(where: { $0.activationState == .foregroundActive }) ?? scenes.first
}
} }
/// Theme toggle button with sun/moon icon and circular reveal animation. /// Theme toggle button with sun/moon icon and circular reveal animation.
struct DarkModeButton: View { struct DarkModeButton: View {
@State private var buttonRect: CGRect = .zero @State private var buttonRect: CGRect = .zero
@AppStorage("rosetta_dark_mode") private var toggleDarkMode: Bool = true /// Local icon state changes INSTANTLY on tap (no round-trip through UserDefaults).
@State private var showMoonIcon: Bool = true
@AppStorage("rosetta_dark_mode") private var activateDarkMode: Bool = true @AppStorage("rosetta_dark_mode") private var activateDarkMode: Bool = true
var body: some View { var body: some View {
Button(action: { Button(action: {
toggleDarkMode.toggle() showMoonIcon.toggle()
animateScheme() animateScheme()
}, label: { }, label: {
Image(systemName: toggleDarkMode ? "moon.fill" : "sun.max.fill") Image(systemName: showMoonIcon ? "moon.fill" : "sun.max.fill")
.font(.system(size: 16, weight: .medium)) .font(.system(size: 16, weight: .medium))
.foregroundStyle(RosettaColors.Adaptive.text) .foregroundStyle(RosettaColors.Adaptive.text)
.symbolEffect(.bounce, value: toggleDarkMode) .symbolEffect(.bounce, value: showMoonIcon)
.frame(width: 44, height: 44) .frame(width: 44, height: 44)
}) })
.buttonStyle(.plain) .buttonStyle(.plain)
.onAppear { showMoonIcon = activateDarkMode }
.darkModeButtonRect { rect in .darkModeButtonRect { rect in
buttonRect = rect buttonRect = rect
} }
@@ -59,45 +69,52 @@ struct DarkModeButton: View {
@MainActor @MainActor
func animateScheme() { func animateScheme() {
guard let windowScene = activeWindowScene,
let window = windowScene.windows.first(where: { $0.isKeyWindow }),
let overlayWindow = windowScene.windows.first(where: { $0.tag == 0320 })
else { return }
let targetDark = showMoonIcon
let frameSize = window.frame.size
// 1. Capture old state SYNCHRONOUSLY afterScreenUpdates:false is fast (~5ms).
let previousImage = window.darkModeSnapshotFast(frameSize)
// 2. Show freeze frame IMMEDIATELY user sees instant response.
// Overlay stays non-interactive so button taps always pass through.
let imageView = UIImageView(image: previousImage)
imageView.frame = window.frame
imageView.contentMode = .scaleAspectFit
overlayWindow.addSubview(imageView)
// 3. Switch theme underneath the freeze frame (invisible to user).
activateDarkMode = targetDark
// 4. Capture new state asynchronously after layout settles.
Task { Task {
if let windows = (UIApplication.shared.connectedScenes.first as? UIWindowScene)?.windows, try? await Task.sleep(for: .seconds(0.06))
let window = windows.first(where: { $0.isKeyWindow }), window.layoutIfNeeded()
let overlayWindow = windows.first(where: { $0.tag == 0320 }) { let currentImage = window.darkModeSnapshot(frameSize)
overlayWindow.isUserInteractionEnabled = true let swiftUIView = DarkModeOverlayView(
let imageView = UIImageView() buttonRect: buttonRect,
imageView.frame = window.frame previousImage: previousImage,
imageView.image = window.darkModeSnapshot(window.frame.size) currentImage: currentImage
imageView.contentMode = .scaleAspectFit )
overlayWindow.addSubview(imageView)
let frameSize = window.frame.size let hostingController = UIHostingController(rootView: swiftUIView)
// Capture old state hostingController.view.backgroundColor = .clear
activateDarkMode = !toggleDarkMode hostingController.view.frame = window.frame
let previousImage = window.darkModeSnapshot(frameSize) hostingController.view.tag = 1009
// Switch to new state overlayWindow.addSubview(hostingController.view)
activateDarkMode = toggleDarkMode imageView.removeFromSuperview()
// Allow layout to settle
try await Task.sleep(for: .seconds(0.01))
let currentImage = window.darkModeSnapshot(frameSize)
try await Task.sleep(for: .seconds(0.01))
let swiftUIView = DarkModeOverlayView(
buttonRect: buttonRect,
previousImage: previousImage,
currentImage: currentImage
)
let hostingController = UIHostingController(rootView: swiftUIView)
hostingController.view.backgroundColor = .clear
hostingController.view.frame = window.frame
hostingController.view.tag = 1009
overlayWindow.addSubview(hostingController.view)
imageView.removeFromSuperview()
}
} }
} }
private var activeWindowScene: UIWindowScene? {
let scenes = UIApplication.shared.connectedScenes.compactMap { $0 as? UIWindowScene }
return scenes.first(where: { $0.activationState == .foregroundActive }) ?? scenes.first
}
} }
// MARK: - Button Rect Tracking // MARK: - Button Rect Tracking
@@ -175,7 +192,6 @@ private struct DarkModeOverlayView: View {
for view in window.subviews { for view in window.subviews {
view.removeFromSuperview() view.removeFromSuperview()
} }
window.isUserInteractionEnabled = false
} }
} }
} }
@@ -198,10 +214,20 @@ private struct DarkModeOverlayView: View {
// MARK: - UIView Snapshot // MARK: - UIView Snapshot
private extension UIView { private extension UIView {
/// Full-fidelity snapshot waits for pending layout. Use for the NEW state.
func darkModeSnapshot(_ size: CGSize) -> UIImage { func darkModeSnapshot(_ size: CGSize) -> UIImage {
let renderer = UIGraphicsImageRenderer(size: size) let renderer = UIGraphicsImageRenderer(size: size)
return renderer.image { _ in return renderer.image { _ in
drawHierarchy(in: .init(origin: .zero, size: size), afterScreenUpdates: true) drawHierarchy(in: .init(origin: .zero, size: size), afterScreenUpdates: true)
} }
} }
/// Fast snapshot of CURRENT appearance no layout wait (~5ms vs ~80ms).
/// Use for the OLD state (already on screen, nothing pending).
func darkModeSnapshotFast(_ size: CGSize) -> UIImage {
let renderer = UIGraphicsImageRenderer(size: size)
return renderer.image { _ in
drawHierarchy(in: .init(origin: .zero, size: size), afterScreenUpdates: false)
}
}
} }

View File

@@ -54,11 +54,11 @@ private extension ConfirmSeedPhraseView {
VStack(spacing: 12) { VStack(spacing: 12) {
Text("Confirm Backup") Text("Confirm Backup")
.font(.system(size: 28, weight: .bold)) .font(.system(size: 28, weight: .bold))
.foregroundStyle(.white) .foregroundStyle(RosettaColors.Adaptive.text)
Text("Enter words #2, #5, #9, #12 to confirm\nyou've backed up your phrase.") Text("Enter words #2, #5, #9, #12 to confirm\nyou've backed up your phrase.")
.font(.system(size: 15)) .font(.system(size: 15))
.foregroundStyle(Color.white.opacity(0.7)) .foregroundStyle(RosettaColors.Adaptive.textSecondary)
.multilineTextAlignment(.center) .multilineTextAlignment(.center)
.lineSpacing(3) .lineSpacing(3)
} }
@@ -130,7 +130,7 @@ private extension ConfirmSeedPhraseView {
TextField("enter", text: $confirmationInputs[inputIndex]) TextField("enter", text: $confirmationInputs[inputIndex])
.font(.system(size: 17, weight: .semibold, design: .monospaced)) .font(.system(size: 17, weight: .semibold, design: .monospaced))
.foregroundStyle(.white) .foregroundStyle(RosettaColors.Adaptive.text)
.autocorrectionDisabled() .autocorrectionDisabled()
.textInputAutocapitalization(.never) .textInputAutocapitalization(.never)
.focused($focusedInputIndex, equals: inputIndex) .focused($focusedInputIndex, equals: inputIndex)

View File

@@ -53,11 +53,11 @@ private extension ImportSeedPhraseView {
VStack(spacing: 12) { VStack(spacing: 12) {
Text("Import Account") Text("Import Account")
.font(.system(size: 28, weight: .bold)) .font(.system(size: 28, weight: .bold))
.foregroundStyle(.white) .foregroundStyle(RosettaColors.Adaptive.text)
Text("Enter your 12-word recovery phrase\nto restore your account.") Text("Enter your 12-word recovery phrase\nto restore your account.")
.font(.system(size: 15)) .font(.system(size: 15))
.foregroundStyle(Color.white.opacity(0.7)) .foregroundStyle(RosettaColors.Adaptive.textSecondary)
.multilineTextAlignment(.center) .multilineTextAlignment(.center)
.lineSpacing(3) .lineSpacing(3)
} }
@@ -140,7 +140,7 @@ private extension ImportSeedPhraseView {
TextField("word", text: $importedWords[index]) TextField("word", text: $importedWords[index])
.font(.system(size: 17, weight: .semibold, design: .monospaced)) .font(.system(size: 17, weight: .semibold, design: .monospaced))
.foregroundStyle(hasContent ? color : .white) .foregroundStyle(hasContent ? color : RosettaColors.Adaptive.text)
.autocorrectionDisabled() .autocorrectionDisabled()
.textInputAutocapitalization(.never) .textInputAutocapitalization(.never)
.focused($focusedWordIndex, equals: index) .focused($focusedWordIndex, equals: index)

View File

@@ -52,7 +52,7 @@ struct PasswordStrengthIndicator: View {
HStack(spacing: 4) { HStack(spacing: 4) {
ForEach(0..<3, id: \.self) { index in ForEach(0..<3, id: \.self) { index in
RoundedRectangle(cornerRadius: 2) RoundedRectangle(cornerRadius: 2)
.fill(index <= strength.rawValue ? strength.color : Color.white.opacity(0.1)) .fill(index <= strength.rawValue ? strength.color : RosettaColors.Adaptive.text.opacity(0.1))
.frame(height: 4) .frame(height: 4)
.animation(.easeInOut(duration: 0.25), value: strength) .animation(.easeInOut(duration: 0.25), value: strength)
} }
@@ -107,7 +107,7 @@ struct WeakPasswordWarning: View {
Text("Your password is too weak. Consider using at least 6 characters for better security.") Text("Your password is too weak. Consider using at least 6 characters for better security.")
.font(.system(size: 13)) .font(.system(size: 13))
.foregroundStyle(Color.white.opacity(0.7)) .foregroundStyle(RosettaColors.Adaptive.textSecondary)
.lineSpacing(2) .lineSpacing(2)
} }
.padding(14) .padding(14)

View File

@@ -48,13 +48,13 @@ private extension SeedPhraseView {
VStack(spacing: 12) { VStack(spacing: 12) {
Text("Your Recovery Phrase") Text("Your Recovery Phrase")
.font(.system(size: 28, weight: .bold)) .font(.system(size: 28, weight: .bold))
.foregroundStyle(.white) .foregroundStyle(RosettaColors.Adaptive.text)
.opacity(isContentVisible ? 1.0 : 0.0) .opacity(isContentVisible ? 1.0 : 0.0)
.animation(.easeOut(duration: 0.3), value: isContentVisible) .animation(.easeOut(duration: 0.3), value: isContentVisible)
Text("Write down these 12 words in order.\nYou'll need them to restore your account.") Text("Write down these 12 words in order.\nYou'll need them to restore your account.")
.font(.system(size: 15)) .font(.system(size: 15))
.foregroundStyle(Color.white.opacity(0.7)) .foregroundStyle(RosettaColors.Adaptive.textSecondary)
.multilineTextAlignment(.center) .multilineTextAlignment(.center)
.lineSpacing(3) .lineSpacing(3)
.opacity(isContentVisible ? 1.0 : 0.0) .opacity(isContentVisible ? 1.0 : 0.0)

View File

@@ -111,13 +111,13 @@ private extension SetPasswordView {
VStack(spacing: 8) { VStack(spacing: 8) {
Text(isImportMode ? "Recover Account" : "Protect Your Account") Text(isImportMode ? "Recover Account" : "Protect Your Account")
.font(.system(size: 24, weight: .bold)) .font(.system(size: 24, weight: .bold))
.foregroundStyle(.white) .foregroundStyle(RosettaColors.Adaptive.text)
Text(isImportMode Text(isImportMode
? "Set a password to protect your recovered account.\nYou'll need it to unlock Rosetta." ? "Set a password to protect your recovered account.\nYou'll need it to unlock Rosetta."
: "This password encrypts your keys locally.\nYou'll need it to unlock Rosetta.") : "This password encrypts your keys locally.\nYou'll need it to unlock Rosetta.")
.font(.system(size: 14)) .font(.system(size: 14))
.foregroundStyle(Color.white.opacity(0.7)) .foregroundStyle(RosettaColors.Adaptive.textSecondary)
.multilineTextAlignment(.center) .multilineTextAlignment(.center)
.lineSpacing(2) .lineSpacing(2)
} }
@@ -178,7 +178,7 @@ private extension SetPasswordView {
Text("Your password is never stored or sent anywhere. It's only used to encrypt your keys locally.") Text("Your password is never stored or sent anywhere. It's only used to encrypt your keys locally.")
.font(.system(size: 13)) .font(.system(size: 13))
.foregroundStyle(Color.white.opacity(0.7)) .foregroundStyle(RosettaColors.Adaptive.textSecondary)
.lineSpacing(2) .lineSpacing(2)
} }
.padding(16) .padding(16)
@@ -291,7 +291,7 @@ private struct SecureToggleField: UIViewRepresentable {
tf.isSecureTextEntry = true tf.isSecureTextEntry = true
tf.font = .systemFont(ofSize: 16) tf.font = .systemFont(ofSize: 16)
tf.textColor = .white tf.textColor = .label
tf.tintColor = UIColor(RosettaColors.primaryBlue) tf.tintColor = UIColor(RosettaColors.primaryBlue)
tf.autocapitalizationType = .none tf.autocapitalizationType = .none
tf.autocorrectionType = .no tf.autocorrectionType = .no
@@ -318,7 +318,7 @@ private struct SecureToggleField: UIViewRepresentable {
let config = UIImage.SymbolConfiguration(pointSize: 14, weight: .thin) let config = UIImage.SymbolConfiguration(pointSize: 14, weight: .thin)
let eyeButton = UIButton(type: .system) let eyeButton = UIButton(type: .system)
eyeButton.setImage(UIImage(systemName: "eye", withConfiguration: config), for: .normal) eyeButton.setImage(UIImage(systemName: "eye", withConfiguration: config), for: .normal)
eyeButton.tintColor = UIColor.white.withAlphaComponent(0.5) eyeButton.tintColor = UIColor.secondaryLabel
eyeButton.addTarget( eyeButton.addTarget(
context.coordinator, context.coordinator,
action: #selector(Coordinator.toggleSecure), action: #selector(Coordinator.toggleSecure),

View File

@@ -80,7 +80,7 @@ private extension WelcomeView {
var titleSection: some View { var titleSection: some View {
Text("Your Keys,\nYour Messages") Text("Your Keys,\nYour Messages")
.font(.system(size: 32, weight: .bold)) .font(.system(size: 32, weight: .bold))
.foregroundStyle(.white) .foregroundStyle(RosettaColors.Adaptive.text)
.multilineTextAlignment(.center) .multilineTextAlignment(.center)
.opacity(isVisible ? 1.0 : 0.0) .opacity(isVisible ? 1.0 : 0.0)
.offset(y: isVisible ? 0 : 16) .offset(y: isVisible ? 0 : 16)
@@ -90,7 +90,7 @@ private extension WelcomeView {
var subtitleSection: some View { var subtitleSection: some View {
Text("Secure messaging with\ncryptographic keys") Text("Secure messaging with\ncryptographic keys")
.font(.system(size: 16)) .font(.system(size: 16))
.foregroundStyle(Color.white.opacity(0.7)) .foregroundStyle(RosettaColors.Adaptive.textSecondary)
.multilineTextAlignment(.center) .multilineTextAlignment(.center)
.opacity(isVisible ? 1.0 : 0.0) .opacity(isVisible ? 1.0 : 0.0)
.offset(y: isVisible ? 0 : 12) .offset(y: isVisible ? 0 : 12)
@@ -122,7 +122,7 @@ private extension WelcomeView {
Text(label) Text(label)
.font(.system(size: 13, weight: .medium)) .font(.system(size: 13, weight: .medium))
.foregroundStyle(Color.white.opacity(0.7)) .foregroundStyle(RosettaColors.Adaptive.textSecondary)
} }
.accessibilityElement(children: .combine) .accessibilityElement(children: .combine)
.accessibilityLabel(label) .accessibilityLabel(label)

View File

@@ -132,7 +132,7 @@ struct AttachmentPanelView: View {
VStack(spacing: 0) { VStack(spacing: 0) {
// Custom drag indicator (replaces system .presentationDragIndicator) // Custom drag indicator (replaces system .presentationDragIndicator)
Capsule() Capsule()
.fill(Color.white.opacity(0.3)) .fill(RosettaColors.Adaptive.text.opacity(0.3))
.frame(width: 36, height: 5) .frame(width: 36, height: 5)
.padding(.top, 5) .padding(.top, 5)
.padding(.bottom, 14) .padding(.bottom, 14)
@@ -144,7 +144,7 @@ struct AttachmentPanelView: View {
dismiss() dismiss()
} label: { } label: {
CloseIconShape() CloseIconShape()
.fill(.white) .fill(RosettaColors.Adaptive.text)
.frame(width: 14, height: 14) .frame(width: 14, height: 14)
.frame(width: 44, height: 44) .frame(width: 44, height: 44)
} }
@@ -157,10 +157,10 @@ struct AttachmentPanelView: View {
HStack(spacing: 5) { HStack(spacing: 5) {
Text(tabTitle) Text(tabTitle)
.font(.system(size: 20, weight: .semibold)) .font(.system(size: 20, weight: .semibold))
.foregroundStyle(.white) .foregroundStyle(RosettaColors.Adaptive.text)
Image(systemName: "chevron.down") Image(systemName: "chevron.down")
.font(.system(size: 13, weight: .bold)) .font(.system(size: 13, weight: .bold))
.foregroundStyle(.white.opacity(0.45)) .foregroundStyle(RosettaColors.Adaptive.text.opacity(0.45))
} }
Spacer() Spacer()
@@ -195,9 +195,9 @@ struct AttachmentPanelView: View {
Text("\(selectedAssets.count)") Text("\(selectedAssets.count)")
.font(.system(size: 12, weight: .bold)) .font(.system(size: 12, weight: .bold))
} }
.foregroundStyle(.white) .foregroundStyle(.white) // White on blue badge is always correct
.frame(width: 44, height: 28) .frame(width: 44, height: 28)
.background(Color(hex: 0x008BFF), in: Capsule()) .background(RosettaColors.primaryBlue, in: Capsule())
} }
/// Glass circle background matching GlassBackButton (ButtonStyles.swift lines 2234). /// Glass circle background matching GlassBackButton (ButtonStyles.swift lines 2234).
@@ -233,12 +233,12 @@ struct AttachmentPanelView: View {
// Title // Title
Text("Send Avatar") Text("Send Avatar")
.font(.system(size: 20, weight: .semibold)) .font(.system(size: 20, weight: .semibold))
.foregroundStyle(.white) .foregroundStyle(RosettaColors.Adaptive.text)
// Subtitle // Subtitle
Text("Share your profile avatar\nwith this contact") Text("Share your profile avatar\nwith this contact")
.font(.system(size: 14)) .font(.system(size: 14))
.foregroundStyle(.white.opacity(0.5)) .foregroundStyle(RosettaColors.Adaptive.textSecondary)
.multilineTextAlignment(.center) .multilineTextAlignment(.center)
// Send button (capsule style matching File tab's "Browse Files" button) // Send button (capsule style matching File tab's "Browse Files" button)
@@ -253,10 +253,10 @@ struct AttachmentPanelView: View {
} label: { } label: {
Text(hasAvatar ? "Send Avatar" : "Set Avatar") Text(hasAvatar ? "Send Avatar" : "Set Avatar")
.font(.system(size: 15, weight: .medium)) .font(.system(size: 15, weight: .medium))
.foregroundStyle(.white) .foregroundStyle(.white) // White on blue button is always correct
.padding(.horizontal, 24) .padding(.horizontal, 24)
.padding(.vertical, 10) .padding(.vertical, 10)
.background(Color(hex: 0x008BFF), in: Capsule()) .background(RosettaColors.primaryBlue, in: Capsule())
} }
Spacer() Spacer()
@@ -284,12 +284,12 @@ struct AttachmentPanelView: View {
// Title // Title
Text("Send File") Text("Send File")
.font(.system(size: 20, weight: .semibold)) .font(.system(size: 20, weight: .semibold))
.foregroundStyle(.white) .foregroundStyle(RosettaColors.Adaptive.text)
// Subtitle // Subtitle
Text("Select a file to send") Text("Select a file to send")
.font(.system(size: 14)) .font(.system(size: 14))
.foregroundStyle(.white.opacity(0.5)) .foregroundStyle(RosettaColors.Adaptive.textSecondary)
.multilineTextAlignment(.center) .multilineTextAlignment(.center)
// Browse button (capsule style matching avatar tab) // Browse button (capsule style matching avatar tab)
@@ -298,10 +298,10 @@ struct AttachmentPanelView: View {
} label: { } label: {
Text("Browse Files") Text("Browse Files")
.font(.system(size: 15, weight: .medium)) .font(.system(size: 15, weight: .medium))
.foregroundStyle(.white) .foregroundStyle(.white) // White on blue button is always correct
.padding(.horizontal, 24) .padding(.horizontal, 24)
.padding(.vertical, 10) .padding(.vertical, 10)
.background(Color(hex: 0x008BFF), in: Capsule()) .background(RosettaColors.primaryBlue, in: Capsule())
} }
Spacer() Spacer()
@@ -337,14 +337,14 @@ struct AttachmentPanelView: View {
} }
.animation(.easeInOut(duration: 0.25), value: hasSelection) .animation(.easeInOut(duration: 0.25), value: hasSelection)
.background { .background {
// iOS < 26: gradient fade behind tab bar (dark theme). // iOS < 26: gradient fade behind tab bar.
// iOS 26+: no gradient Liquid Glass pill is self-contained. // iOS 26+: no gradient Liquid Glass pill is self-contained.
if #unavailable(iOS 26) { if #unavailable(iOS 26) {
LinearGradient( LinearGradient(
stops: [ stops: [
.init(color: .clear, location: 0), .init(color: .clear, location: 0),
.init(color: .black.opacity(0.6), location: 0.3), .init(color: RosettaColors.Adaptive.surface.opacity(0.85), location: 0.3),
.init(color: .black, location: 0.8), .init(color: RosettaColors.Adaptive.surface, location: 0.8),
], ],
startPoint: .top, startPoint: .top,
endPoint: .bottom endPoint: .bottom
@@ -364,8 +364,8 @@ struct AttachmentPanelView: View {
// Caption text field // Caption text field
TextField("Add a caption...", text: $captionText) TextField("Add a caption...", text: $captionText)
.font(.system(size: 16)) .font(.system(size: 16))
.foregroundStyle(.white) .foregroundStyle(RosettaColors.Adaptive.text)
.tint(Color(hex: 0x008BFF)) .tint(RosettaColors.primaryBlue)
.padding(.leading, 6) .padding(.leading, 6)
// Emoji icon (exact ChatDetail match: TelegramVectorIcon emojiMoon) // Emoji icon (exact ChatDetail match: TelegramVectorIcon emojiMoon)
@@ -389,7 +389,7 @@ struct AttachmentPanelView: View {
) )
.frame(width: 22, height: 19) .frame(width: 22, height: 19)
.frame(width: 38, height: 36) .frame(width: 38, height: 36)
.background { Capsule().fill(Color(hex: 0x008BFF)) } .background { Capsule().fill(RosettaColors.primaryBlue) }
} }
} }
.padding(3) .padding(3)
@@ -457,7 +457,7 @@ struct AttachmentPanelView: View {
private func legacyTabButton(_ tab: AttachmentTab, icon: String, unselectedIcon: String, label: String) -> some View { private func legacyTabButton(_ tab: AttachmentTab, icon: String, unselectedIcon: String, label: String) -> some View {
let isSelected = selectedTab == tab let isSelected = selectedTab == tab
let tint = isSelected ? Color(hex: 0x008BFF) : .white let tint = isSelected ? RosettaColors.primaryBlue : RosettaColors.Adaptive.text
return Button { return Button {
UIImpactFeedbackGenerator(style: .light).impactOccurred() UIImpactFeedbackGenerator(style: .light).impactOccurred()

View File

@@ -66,6 +66,10 @@ final class ComposerView: UIView, UITextViewDelegate {
// Mic button (glass circle, 42×42) // Mic button (glass circle, 42×42)
private let micButton = UIButton(type: .system) private let micButton = UIButton(type: .system)
private let micGlass = TelegramGlassUIView(frame: .zero) private let micGlass = TelegramGlassUIView(frame: .zero)
private var attachIconLayer: CAShapeLayer?
private var emojiIconLayer: CAShapeLayer?
private var sendIconLayer: CAShapeLayer?
private var micIconLayer: CAShapeLayer?
// MARK: - Layout Constants // MARK: - Layout Constants
@@ -113,6 +117,7 @@ final class ComposerView: UIView, UITextViewDelegate {
color: .label color: .label
) )
attachButton.layer.addSublayer(attachIcon) attachButton.layer.addSublayer(attachIcon)
attachIconLayer = attachIcon
attachButton.tag = 1 // for icon centering in layoutSubviews attachButton.tag = 1 // for icon centering in layoutSubviews
attachButton.addTarget(self, action: #selector(attachTapped), for: .touchUpInside) attachButton.addTarget(self, action: #selector(attachTapped), for: .touchUpInside)
addSubview(attachButton) addSubview(attachButton)
@@ -188,6 +193,7 @@ final class ComposerView: UIView, UITextViewDelegate {
color: .secondaryLabel color: .secondaryLabel
) )
emojiButton.layer.addSublayer(emojiIcon) emojiButton.layer.addSublayer(emojiIcon)
emojiIconLayer = emojiIcon
emojiButton.tag = 2 emojiButton.tag = 2
emojiButton.addTarget(self, action: #selector(emojiTapped), for: .touchUpInside) emojiButton.addTarget(self, action: #selector(emojiTapped), for: .touchUpInside)
inputContainer.addSubview(emojiButton) inputContainer.addSubview(emojiButton)
@@ -204,6 +210,7 @@ final class ComposerView: UIView, UITextViewDelegate {
color: .white color: .white
) )
sendButton.layer.addSublayer(sendIcon) sendButton.layer.addSublayer(sendIcon)
sendIconLayer = sendIcon
sendButton.tag = 3 sendButton.tag = 3
sendButton.addTarget(self, action: #selector(sendTapped), for: .touchUpInside) sendButton.addTarget(self, action: #selector(sendTapped), for: .touchUpInside)
sendButton.alpha = 0 sendButton.alpha = 0
@@ -221,9 +228,19 @@ final class ComposerView: UIView, UITextViewDelegate {
color: .label color: .label
) )
micButton.layer.addSublayer(micIcon) micButton.layer.addSublayer(micIcon)
micIconLayer = micIcon
micButton.tag = 4 micButton.tag = 4
micButton.addTarget(self, action: #selector(micTapped), for: .touchUpInside) micButton.addTarget(self, action: #selector(micTapped), for: .touchUpInside)
addSubview(micButton) addSubview(micButton)
updateThemeColors()
if #available(iOS 17.0, *) {
registerForTraitChanges([UITraitUserInterfaceStyle.self]) { (self: ComposerView, previousTraitCollection: UITraitCollection) in
guard self.traitCollection.userInterfaceStyle != previousTraitCollection.userInterfaceStyle else { return }
self.updateThemeColors()
}
}
} }
// MARK: - Public API // MARK: - Public API
@@ -426,6 +443,19 @@ final class ComposerView: UIView, UITextViewDelegate {
} }
} }
private func updateThemeColors() {
attachIconLayer?.fillColor = UIColor.label.cgColor
emojiIconLayer?.fillColor = UIColor.secondaryLabel.cgColor
sendIconLayer?.fillColor = UIColor.white.cgColor
micIconLayer?.fillColor = UIColor.label.cgColor
replyTitleLabel.textColor = .label
replyPreviewLabel.textColor = .label
replyCancelButton.tintColor = .secondaryLabel
textView.textColor = .label
textView.placeholderLabel.textColor = .placeholderText
}
// MARK: - Text Height // MARK: - Text Height
private func recalculateTextHeight() { private func recalculateTextHeight() {
@@ -498,6 +528,12 @@ final class ComposerView: UIView, UITextViewDelegate {
} }
} }
override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) {
if #available(iOS 17.0, *) { return }
guard traitCollection.userInterfaceStyle != previousTraitCollection?.userInterfaceStyle else { return }
updateThemeColors()
}
// MARK: - UITextViewDelegate // MARK: - UITextViewDelegate
func textViewShouldBeginEditing(_ textView: UITextView) -> Bool { func textViewShouldBeginEditing(_ textView: UITextView) -> Bool {

View File

@@ -66,13 +66,46 @@ final class NativeMessageCell: UICollectionViewCell {
ctx.fillPath() ctx.fillPath()
let image = UIGraphicsGetImageFromCurrentImageContext() ?? UIImage() let image = UIGraphicsGetImageFromCurrentImageContext() ?? UIImage()
UIGraphicsEndImageContext() UIGraphicsEndImageContext()
return image.withRenderingMode(.alwaysOriginal) return image.withRenderingMode(.alwaysTemplate)
}() }()
// Telegram-exact stretchable bubble images (raster, not vector only way to get exact tail) // Telegram-exact stretchable bubble images (raster, not vector only way to get exact tail).
private static let bubbleImages = BubbleImageFactory.generate( // `var` so they can be regenerated on theme switch (colors baked into raster at generation time).
private static var bubbleImages = BubbleImageFactory.generate(
outgoingColor: outgoingColor, outgoingColor: outgoingColor,
incomingColor: incomingColor incomingColor: incomingColor
) )
private static var bubbleImagesStyle: UIUserInterfaceStyle = .unspecified
/// Regenerate cached bubble images after theme change.
/// Must be called on main thread. `performAsCurrent` ensures dynamic
/// `incomingColor` resolves with the correct light/dark traits.
static func regenerateBubbleImages(with traitCollection: UITraitCollection) {
traitCollection.performAsCurrent {
bubbleImages = BubbleImageFactory.generate(
outgoingColor: outgoingColor,
incomingColor: incomingColor
)
}
bubbleImagesStyle = normalizedInterfaceStyle(from: traitCollection)
}
/// Ensure bubble image cache matches the current interface style.
/// Covers cases where the theme was changed while chat screen was not mounted.
static func ensureBubbleImages(for traitCollection: UITraitCollection) {
let style = normalizedInterfaceStyle(from: traitCollection)
guard bubbleImagesStyle != style else { return }
regenerateBubbleImages(with: traitCollection)
}
private static func normalizedInterfaceStyle(from traitCollection: UITraitCollection) -> UIUserInterfaceStyle {
switch traitCollection.userInterfaceStyle {
case .dark, .light:
return traitCollection.userInterfaceStyle
default:
let prefersDark = UserDefaults.standard.object(forKey: "rosetta_dark_mode") as? Bool ?? true
return prefersDark ? .dark : .light
}
}
private static let blurHashCache: NSCache<NSString, UIImage> = { private static let blurHashCache: NSCache<NSString, UIImage> = {
let cache = NSCache<NSString, UIImage>() let cache = NSCache<NSString, UIImage>()
cache.countLimit = 200 cache.countLimit = 200
@@ -456,13 +489,20 @@ final class NativeMessageCell: UICollectionViewCell {
bubbleView.addSubview(highlightOverlay) bubbleView.addSubview(highlightOverlay)
// Swipe reply icon circle + Telegram-exact arrow (same vector as SwiftUI SwipeToReplyModifier) // Swipe reply icon circle + Telegram-exact arrow (same vector as SwiftUI SwipeToReplyModifier)
replyCircleView.backgroundColor = UIColor.white.withAlphaComponent(0.12) replyCircleView.backgroundColor = UIColor { traits in
traits.userInterfaceStyle == .dark
? UIColor.white.withAlphaComponent(0.12)
: UIColor.black.withAlphaComponent(0.12)
}
replyCircleView.layer.cornerRadius = 17 // 34pt / 2 replyCircleView.layer.cornerRadius = 17 // 34pt / 2
replyCircleView.alpha = 0 replyCircleView.alpha = 0
contentView.addSubview(replyCircleView) contentView.addSubview(replyCircleView)
replyIconView.image = Self.telegramReplyArrowImage replyIconView.image = Self.telegramReplyArrowImage
replyIconView.contentMode = .scaleAspectFit replyIconView.contentMode = .scaleAspectFit
replyIconView.tintColor = UIColor { traits in
traits.userInterfaceStyle == .dark ? .white : .black
}
replyIconView.alpha = 0 replyIconView.alpha = 0
contentView.addSubview(replyIconView) contentView.addSubview(replyIconView)
@@ -513,16 +553,18 @@ final class NativeMessageCell: UICollectionViewCell {
// Same CTTypesetter pipeline identical line breaks, zero recomputation. // Same CTTypesetter pipeline identical line breaks, zero recomputation.
textLabel.textLayout = textLayout textLabel.textLayout = textLayout
// Timestamp // Timestamp dynamic UIColor for incoming so theme changes resolve instantly
timestampLabel.text = timestamp timestampLabel.text = timestamp
if isMediaStatus { if isMediaStatus {
timestampLabel.textColor = .white timestampLabel.textColor = .white
} else if isOutgoing {
timestampLabel.textColor = UIColor.white.withAlphaComponent(0.55)
} else { } else {
let isDark = traitCollection.userInterfaceStyle == .dark timestampLabel.textColor = UIColor { traits in
let tsAlpha: CGFloat = isOutgoing ? 0.55 : 0.6 traits.userInterfaceStyle == .dark
timestampLabel.textColor = (isOutgoing || isDark) ? UIColor.white.withAlphaComponent(0.6)
? UIColor.white.withAlphaComponent(tsAlpha) : UIColor.black.withAlphaComponent(0.45)
: UIColor.black.withAlphaComponent(0.45) }
} }
// Delivery checkmarks (Telegram two-node pattern: checkSent + checkRead) // Delivery checkmarks (Telegram two-node pattern: checkSent + checkRead)
@@ -566,7 +608,6 @@ final class NativeMessageCell: UICollectionViewCell {
// Reply quote Telegram parity colors // Reply quote Telegram parity colors
if let replyName { if let replyName {
replyContainer.isHidden = false replyContainer.isHidden = false
let isDark = traitCollection.userInterfaceStyle == .dark
replyContainer.backgroundColor = isOutgoing replyContainer.backgroundColor = isOutgoing
? UIColor.white.withAlphaComponent(0.12) ? UIColor.white.withAlphaComponent(0.12)
: Self.outgoingColor.withAlphaComponent(0.12) : Self.outgoingColor.withAlphaComponent(0.12)
@@ -574,7 +615,11 @@ final class NativeMessageCell: UICollectionViewCell {
replyNameLabel.text = replyName replyNameLabel.text = replyName
replyNameLabel.textColor = isOutgoing ? .white : Self.outgoingColor replyNameLabel.textColor = isOutgoing ? .white : Self.outgoingColor
replyTextLabel.text = replyText ?? "" replyTextLabel.text = replyText ?? ""
replyTextLabel.textColor = (isOutgoing || isDark) ? .white : .darkGray replyTextLabel.textColor = isOutgoing
? .white
: UIColor { traits in
traits.userInterfaceStyle == .dark ? .white : .darkGray
}
} else { } else {
replyContainer.isHidden = true replyContainer.isHidden = true
} }
@@ -786,6 +831,7 @@ final class NativeMessageCell: UICollectionViewCell {
override func layoutSubviews() { override func layoutSubviews() {
super.layoutSubviews() super.layoutSubviews()
guard let layout = currentLayout else { return } guard let layout = currentLayout else { return }
Self.ensureBubbleImages(for: traitCollection)
let cellW = contentView.bounds.width let cellW = contentView.bounds.width
let tailProtrusion = Self.bubbleMetrics.tailProtrusion let tailProtrusion = Self.bubbleMetrics.tailProtrusion

View File

@@ -183,6 +183,15 @@ final class NativeMessageListController: UIViewController {
self, selector: #selector(handleAvatarDidUpdate), self, selector: #selector(handleAvatarDidUpdate),
name: Notification.Name("avatarDidUpdate"), object: nil name: Notification.Name("avatarDidUpdate"), object: nil
) )
// Regenerate bubble images + full cell refresh on theme switch.
registerForTraitChanges([UITraitUserInterfaceStyle.self]) { (self: NativeMessageListController, previousTraitCollection: UITraitCollection) in
let oldStyle = previousTraitCollection.userInterfaceStyle
let newStyle = self.traitCollection.userInterfaceStyle
guard oldStyle != newStyle else { return }
NativeMessageCell.regenerateBubbleImages(with: self.traitCollection)
self.calculateLayouts()
self.refreshAllMessageCells()
}
} }
@objc private func handleAvatarDidUpdate() { @objc private func handleAvatarDidUpdate() {
@@ -483,6 +492,9 @@ final class NativeMessageListController: UIViewController {
let imageView = UIImageView(image: Self.makeTelegramDownButtonImage()) let imageView = UIImageView(image: Self.makeTelegramDownButtonImage())
imageView.contentMode = .center imageView.contentMode = .center
imageView.frame = rect imageView.frame = rect
imageView.tintColor = UIColor { traits in
traits.userInterfaceStyle == .dark ? .white : .black
}
button.addSubview(imageView) button.addSubview(imageView)
let badgeView = UIView(frame: .zero) let badgeView = UIView(frame: .zero)
@@ -585,7 +597,7 @@ final class NativeMessageListController: UIViewController {
gc.addLine(to: CGPoint(x: cx, y: cy + 4.5)) // bottom-center gc.addLine(to: CGPoint(x: cx, y: cy + 4.5)) // bottom-center
gc.addLine(to: CGPoint(x: cx + 9.0, y: cy - 4.5)) // top-right gc.addLine(to: CGPoint(x: cx + 9.0, y: cy - 4.5)) // top-right
gc.strokePath() gc.strokePath()
}.withRenderingMode(.alwaysOriginal) }.withRenderingMode(.alwaysTemplate)
} }
private static func compactUnreadCountString(_ count: Int) -> String { private static func compactUnreadCountString(_ count: Int) -> String {
@@ -614,7 +626,9 @@ final class NativeMessageListController: UIViewController {
c.addSubview(glass) c.addSubview(glass)
let l = UILabel() let l = UILabel()
l.font = UIFont.systemFont(ofSize: 12, weight: .medium) l.font = UIFont.systemFont(ofSize: 12, weight: .medium)
l.textColor = .white l.textColor = UIColor { traits in
traits.userInterfaceStyle == .dark ? .white : .black
}
l.textAlignment = .center l.textAlignment = .center
c.addSubview(l) c.addSubview(l)
return (c, l) return (c, l)
@@ -1071,6 +1085,18 @@ final class NativeMessageListController: UIViewController {
dataSource.apply(snapshot, animatingDifferences: false) dataSource.apply(snapshot, animatingDifferences: false)
} }
/// Reconfigure all message cells and force a layout pass.
/// Used for deterministic theme refresh when trait style changes.
private func refreshAllMessageCells() {
var snapshot = dataSource.snapshot()
let allIds = snapshot.itemIdentifiers
guard !allIds.isEmpty else { return }
snapshot.reconfigureItems(allIds)
dataSource.apply(snapshot, animatingDifferences: false)
collectionView.collectionViewLayout.invalidateLayout()
collectionView.layoutIfNeeded()
}
// MARK: - Bubble Position // MARK: - Bubble Position
private func bubblePosition(for message: ChatMessage, at reversedIndex: Int) -> BubblePosition { private func bubblePosition(for message: ChatMessage, at reversedIndex: Int) -> BubblePosition {

View File

@@ -18,13 +18,25 @@ final class TelegramContextMenuCardView: UIView {
private static let iconSize: CGFloat = 24 private static let iconSize: CGFloat = 24
private static let screenPixel = 1.0 / max(UIScreen.main.scale, 1) private static let screenPixel = 1.0 / max(UIScreen.main.scale, 1)
// MARK: - Colors (Telegram dark theme) // MARK: - Colors (adaptive light/dark)
private static let tintBg = UIColor(red: 0x25/255, green: 0x25/255, blue: 0x25/255, alpha: 0.78) private static let tintBg = UIColor { traits in
private static let textColor = UIColor.white traits.userInterfaceStyle == .dark
? UIColor(red: 0x25/255, green: 0x25/255, blue: 0x25/255, alpha: 0.78)
: UIColor(red: 0xF5/255, green: 0xF5/255, blue: 0xF7/255, alpha: 0.78)
}
private static let textColor = UIColor.label
private static let destructiveColor = UIColor(red: 0xEB/255, green: 0x55/255, blue: 0x45/255, alpha: 1) private static let destructiveColor = UIColor(red: 0xEB/255, green: 0x55/255, blue: 0x45/255, alpha: 1)
private static let separatorColor = UIColor(white: 1, alpha: 0.15) private static let separatorColor = UIColor { traits in
private static let highlightColor = UIColor(white: 1, alpha: 0.15) traits.userInterfaceStyle == .dark
? UIColor(white: 1, alpha: 0.15)
: UIColor(white: 0, alpha: 0.1)
}
private static let highlightColor = UIColor { traits in
traits.userInterfaceStyle == .dark
? UIColor(white: 1, alpha: 0.15)
: UIColor(white: 0, alpha: 0.08)
}
// MARK: - Public // MARK: - Public
@@ -33,7 +45,7 @@ final class TelegramContextMenuCardView: UIView {
// MARK: - Views // MARK: - Views
private let blurView = UIVisualEffectView(effect: UIBlurEffect(style: .systemMaterialDark)) private let blurView = UIVisualEffectView(effect: UIBlurEffect(style: .systemMaterial))
private let tintView = UIView() private let tintView = UIView()
private let items: [TelegramContextMenuItem] private let items: [TelegramContextMenuItem]

View File

@@ -530,12 +530,13 @@ extension AppDelegate: PKPushRegistryDelegate {
} }
let data = payload.dictionaryPayload let data = payload.dictionaryPayload
Logger.voip.info("VoIP push received: \(data.description, privacy: .public)") Logger.voip.info("VoIP push received: \(data.description, privacy: .public)")
// Server sends: { "type": "CALL", "from": "<pubkey>", "callId": "<uuid>" } // Server sends: { "type": "CALL", "from": "<pubkey>", "callId": "<uuid>", "joinToken": "<token>" }
// Fallback to "dialog" for backward compat with older server versions. // Fallback to "dialog" for backward compat with older server versions.
let callerKey = data["from"] as? String let callerKey = data["from"] as? String
?? data["dialog"] as? String ?? data["dialog"] as? String
?? "" ?? ""
let callId = data["callId"] as? String let callId = data["callId"] as? String
let joinToken = data["joinToken"] as? String
// Resolve caller display name from multiple sources. // Resolve caller display name from multiple sources.
let callerName: String = { let callerName: String = {
// 1. Push payload (if server sends title) // 1. Push payload (if server sends title)
@@ -554,7 +555,7 @@ extension AppDelegate: PKPushRegistryDelegate {
} }
return "Rosetta" return "Rosetta"
}() }()
Logger.voip.info("VoIP resolved: key=\(callerKey.prefix(16), privacy: .public) name=\(callerName, privacy: .public) callId=\(callId ?? "nil", privacy: .public)") Logger.voip.info("VoIP resolved: key=\(callerKey.prefix(16), privacy: .public) name=\(callerName, privacy: .public) callId=\(callId ?? "nil", privacy: .public) joinTokenPresent=\((joinToken?.isEmpty == false).description, privacy: .public)")
// Apple REQUIREMENT: reportNewIncomingCall MUST be called SYNCHRONOUSLY. // Apple REQUIREMENT: reportNewIncomingCall MUST be called SYNCHRONOUSLY.
// Using Task { @MainActor } would introduce an async hop that may be // Using Task { @MainActor } would introduce an async hop that may be
@@ -597,7 +598,9 @@ extension AppDelegate: PKPushRegistryDelegate {
} }
CallManager.shared.setupIncomingCallFromPush( CallManager.shared.setupIncomingCallFromPush(
callerKey: callerKey, callerKey: callerKey,
callerName: callerName callerName: callerName,
callId: callId,
joinToken: joinToken
) )
} }
@@ -671,7 +674,6 @@ struct RosettaApp: App {
@AppStorage("hasCompletedOnboarding") private var hasCompletedOnboarding = false @AppStorage("hasCompletedOnboarding") private var hasCompletedOnboarding = false
@AppStorage("isLoggedIn") private var isLoggedIn = false @AppStorage("isLoggedIn") private var isLoggedIn = false
@AppStorage("hasLaunchedBefore") private var hasLaunchedBefore = false @AppStorage("hasLaunchedBefore") private var hasLaunchedBefore = false
@AppStorage("rosetta_dark_mode") private var isDarkMode: Bool = true
@State private var appState: AppState? @State private var appState: AppState?
@State private var transitionOverlay: Bool = false @State private var transitionOverlay: Bool = false
@@ -695,7 +697,11 @@ struct RosettaApp: App {
.animation(.easeInOut(duration: 0.035), value: transitionOverlay) .animation(.easeInOut(duration: 0.035), value: transitionOverlay)
} }
} }
.preferredColorScheme(isDarkMode ? .dark : .light) // NOTE: preferredColorScheme removed DarkModeWrapper is the single
// source of truth via window.overrideUserInterfaceStyle. Having both
// caused snapshot races where the hosting controller's stale
// preferredColorScheme(.dark) blocked the window's .light override,
// making darklight circular reveal animation invisible.
.onAppear { .onAppear {
if appState == nil { if appState == nil {
appState = initialState() appState = initialState()

View File

@@ -2,12 +2,23 @@ import XCTest
@testable import Rosetta @testable import Rosetta
final class CallPacketParityTests: XCTestCase { final class CallPacketParityTests: XCTestCase {
func testSignalPeerRoundTripForCallKeyExchangeAndCreateRoom() throws { func testSignalPeerRoundTripForLegacyCallAcceptEndCallAndKeyExchange() throws {
let call = PacketSignalPeer( let call = PacketSignalPeer(
src: "02caller", src: "02caller",
dst: "02callee", dst: "02callee",
sharedPublic: "", sharedPublic: "",
signalType: .call, signalType: .call,
callId: "call-123",
joinToken: "join-123",
roomId: ""
)
let accept = PacketSignalPeer(
src: "02callee",
dst: "02caller",
sharedPublic: "",
signalType: .accept,
callId: "call-123",
joinToken: "join-123",
roomId: "" roomId: ""
) )
let keyExchange = PacketSignalPeer( let keyExchange = PacketSignalPeer(
@@ -15,14 +26,18 @@ final class CallPacketParityTests: XCTestCase {
dst: "02caller", dst: "02caller",
sharedPublic: "abcdef012345", sharedPublic: "abcdef012345",
signalType: .keyExchange, signalType: .keyExchange,
callId: "",
joinToken: "",
roomId: "" roomId: ""
) )
let createRoom = PacketSignalPeer( let endCall = PacketSignalPeer(
src: "02caller", src: "02caller",
dst: "02callee", dst: "02callee",
sharedPublic: "", sharedPublic: "",
signalType: .createRoom, signalType: .endCall,
roomId: "room-42" callId: "call-123",
joinToken: "join-123",
roomId: ""
) )
let decodedCall = try decodeSignal(call) let decodedCall = try decodeSignal(call)
@@ -30,29 +45,67 @@ final class CallPacketParityTests: XCTestCase {
XCTAssertEqual(decodedCall.src, "02caller") XCTAssertEqual(decodedCall.src, "02caller")
XCTAssertEqual(decodedCall.dst, "02callee") XCTAssertEqual(decodedCall.dst, "02callee")
XCTAssertEqual(decodedCall.sharedPublic, "") XCTAssertEqual(decodedCall.sharedPublic, "")
XCTAssertEqual(decodedCall.callId, "call-123")
XCTAssertEqual(decodedCall.joinToken, "join-123")
XCTAssertEqual(decodedCall.roomId, "") XCTAssertEqual(decodedCall.roomId, "")
let decodedAccept = try decodeSignal(accept)
XCTAssertEqual(decodedAccept.signalType, .accept)
XCTAssertEqual(decodedAccept.callId, "call-123")
XCTAssertEqual(decodedAccept.joinToken, "join-123")
let decodedKeyExchange = try decodeSignal(keyExchange) let decodedKeyExchange = try decodeSignal(keyExchange)
XCTAssertEqual(decodedKeyExchange.signalType, .keyExchange) XCTAssertEqual(decodedKeyExchange.signalType, .keyExchange)
XCTAssertEqual(decodedKeyExchange.src, "02callee") XCTAssertEqual(decodedKeyExchange.src, "02callee")
XCTAssertEqual(decodedKeyExchange.dst, "02caller") XCTAssertEqual(decodedKeyExchange.dst, "02caller")
XCTAssertEqual(decodedKeyExchange.sharedPublic, "abcdef012345") XCTAssertEqual(decodedKeyExchange.sharedPublic, "abcdef012345")
XCTAssertEqual(decodedKeyExchange.callId, "")
XCTAssertEqual(decodedKeyExchange.joinToken, "")
XCTAssertEqual(decodedKeyExchange.roomId, "") XCTAssertEqual(decodedKeyExchange.roomId, "")
let decodedEndCall = try decodeSignal(endCall)
XCTAssertEqual(decodedEndCall.signalType, .endCall)
XCTAssertEqual(decodedEndCall.callId, "call-123")
XCTAssertEqual(decodedEndCall.joinToken, "join-123")
}
func testSignalCodeFourRoundTripForLegacyActiveAndCreateRoomFallback() throws {
let legacyActive = PacketSignalPeer(
src: "02caller",
dst: "02callee",
sharedPublic: "",
signalType: .createRoom,
callId: "",
joinToken: "",
roomId: ""
)
let createRoom = PacketSignalPeer(
src: "02caller",
dst: "02callee",
sharedPublic: "",
signalType: .createRoom,
callId: "",
joinToken: "",
roomId: "room-42"
)
let decodedLegacyActive = try decodeSignal(legacyActive)
XCTAssertEqual(decodedLegacyActive.signalType, .createRoom)
XCTAssertEqual(decodedLegacyActive.roomId, "")
let decodedCreateRoom = try decodeSignal(createRoom) let decodedCreateRoom = try decodeSignal(createRoom)
XCTAssertEqual(decodedCreateRoom.signalType, .createRoom) XCTAssertEqual(decodedCreateRoom.signalType, .createRoom)
XCTAssertEqual(decodedCreateRoom.src, "02caller")
XCTAssertEqual(decodedCreateRoom.dst, "02callee")
XCTAssertEqual(decodedCreateRoom.sharedPublic, "")
XCTAssertEqual(decodedCreateRoom.roomId, "room-42") XCTAssertEqual(decodedCreateRoom.roomId, "room-42")
} }
func testSignalPeerRoundTripForBusyAndPeerDisconnectedShortFormat() throws { func testSignalPeerRoundTripForBusyPeerDisconnectedAndRingingTimeoutShortFormat() throws {
let busy = PacketSignalPeer( let busy = PacketSignalPeer(
src: "02should-not-be-sent", src: "02should-not-be-sent",
dst: "02should-not-be-sent", dst: "02should-not-be-sent",
sharedPublic: "ignored", sharedPublic: "ignored",
signalType: .endCallBecauseBusy, signalType: .endCallBecauseBusy,
callId: "ignored",
joinToken: "ignored",
roomId: "ignored-room" roomId: "ignored-room"
) )
let disconnected = PacketSignalPeer( let disconnected = PacketSignalPeer(
@@ -60,6 +113,17 @@ final class CallPacketParityTests: XCTestCase {
dst: "02should-not-be-sent", dst: "02should-not-be-sent",
sharedPublic: "ignored", sharedPublic: "ignored",
signalType: .endCallBecausePeerDisconnected, signalType: .endCallBecausePeerDisconnected,
callId: "ignored",
joinToken: "ignored",
roomId: "ignored-room"
)
let ringingTimeout = PacketSignalPeer(
src: "02should-not-be-sent",
dst: "02should-not-be-sent",
sharedPublic: "ignored",
signalType: .ringingTimeout,
callId: "ignored",
joinToken: "ignored",
roomId: "ignored-room" roomId: "ignored-room"
) )
@@ -68,6 +132,8 @@ final class CallPacketParityTests: XCTestCase {
XCTAssertEqual(decodedBusy.src, "") XCTAssertEqual(decodedBusy.src, "")
XCTAssertEqual(decodedBusy.dst, "") XCTAssertEqual(decodedBusy.dst, "")
XCTAssertEqual(decodedBusy.sharedPublic, "") XCTAssertEqual(decodedBusy.sharedPublic, "")
XCTAssertEqual(decodedBusy.callId, "")
XCTAssertEqual(decodedBusy.joinToken, "")
XCTAssertEqual(decodedBusy.roomId, "") XCTAssertEqual(decodedBusy.roomId, "")
let decodedDisconnected = try decodeSignal(disconnected) let decodedDisconnected = try decodeSignal(disconnected)
@@ -75,7 +141,18 @@ final class CallPacketParityTests: XCTestCase {
XCTAssertEqual(decodedDisconnected.src, "") XCTAssertEqual(decodedDisconnected.src, "")
XCTAssertEqual(decodedDisconnected.dst, "") XCTAssertEqual(decodedDisconnected.dst, "")
XCTAssertEqual(decodedDisconnected.sharedPublic, "") XCTAssertEqual(decodedDisconnected.sharedPublic, "")
XCTAssertEqual(decodedDisconnected.callId, "")
XCTAssertEqual(decodedDisconnected.joinToken, "")
XCTAssertEqual(decodedDisconnected.roomId, "") XCTAssertEqual(decodedDisconnected.roomId, "")
let decodedTimeout = try decodeSignal(ringingTimeout)
XCTAssertEqual(decodedTimeout.signalType, .ringingTimeout)
XCTAssertEqual(decodedTimeout.src, "")
XCTAssertEqual(decodedTimeout.dst, "")
XCTAssertEqual(decodedTimeout.sharedPublic, "")
XCTAssertEqual(decodedTimeout.callId, "")
XCTAssertEqual(decodedTimeout.joinToken, "")
XCTAssertEqual(decodedTimeout.roomId, "")
} }
func testWebRtcRoundTripForOfferAnswerAndIceCandidate() throws { func testWebRtcRoundTripForOfferAnswerAndIceCandidate() throws {

View File

@@ -97,12 +97,14 @@ struct SignalPeerCallFlowTests {
let caller = "02a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2" let caller = "02a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2"
let callee = "03f0e1d2c3b4a5968778695a4b3c2d1e0f9e8d7c6b5a49382716051a2b3c4d5e6f" let callee = "03f0e1d2c3b4a5968778695a4b3c2d1e0f9e8d7c6b5a49382716051a2b3c4d5e6f"
let packet = PacketSignalPeer(src: caller, dst: callee, sharedPublic: "", let packet = PacketSignalPeer(src: caller, dst: callee, sharedPublic: "",
signalType: .call, roomId: "") signalType: .call, callId: "call-1", joinToken: "join-1", roomId: "")
let decoded = try decode(packet) let decoded = try decode(packet)
#expect(decoded.signalType == .call) #expect(decoded.signalType == .call)
#expect(decoded.src == caller) #expect(decoded.src == caller)
#expect(decoded.dst == callee) #expect(decoded.dst == callee)
#expect(decoded.sharedPublic == "") #expect(decoded.sharedPublic == "")
#expect(decoded.callId == "call-1")
#expect(decoded.joinToken == "join-1")
#expect(decoded.roomId == "") #expect(decoded.roomId == "")
} }
@@ -153,6 +155,19 @@ struct SignalPeerCallFlowTests {
#expect(decoded.signalType == .endCallBecausePeerDisconnected) #expect(decoded.signalType == .endCallBecausePeerDisconnected)
} }
@Test("RINGING_TIMEOUT short format — 3 bytes wire size")
func ringingTimeoutShortFormat() throws {
let packet = PacketSignalPeer(src: "ignored", dst: "ignored", sharedPublic: "ignored",
signalType: .ringingTimeout, roomId: "ignored")
let data = PacketRegistry.encode(packet)
#expect(data.count == 3)
let decoded = try decode(packet)
#expect(decoded.signalType == .ringingTimeout)
#expect(decoded.src == "")
#expect(decoded.dst == "")
}
private func decode(_ packet: PacketSignalPeer) throws -> PacketSignalPeer { private func decode(_ packet: PacketSignalPeer) throws -> PacketSignalPeer {
let data = PacketRegistry.encode(packet) let data = PacketRegistry.encode(packet)
guard let result = PacketRegistry.decode(from: data), guard let result = PacketRegistry.decode(from: data),
@@ -171,7 +186,8 @@ struct CallPushEnumParityTests {
arguments: [ arguments: [
(SignalType.call, 0), (SignalType.keyExchange, 1), (SignalType.activeCall, 2), (SignalType.call, 0), (SignalType.keyExchange, 1), (SignalType.activeCall, 2),
(SignalType.endCall, 3), (SignalType.createRoom, 4), (SignalType.endCall, 3), (SignalType.createRoom, 4),
(SignalType.endCallBecausePeerDisconnected, 5), (SignalType.endCallBecauseBusy, 6) (SignalType.endCallBecausePeerDisconnected, 5), (SignalType.endCallBecauseBusy, 6),
(SignalType.accept, 7), (SignalType.ringingTimeout, 8)
]) ])
func signalTypeEnumValues(pair: (SignalType, Int)) { func signalTypeEnumValues(pair: (SignalType, Int)) {
#expect(pair.0.rawValue == pair.1) #expect(pair.0.rawValue == pair.1)
@@ -222,12 +238,12 @@ struct CallPushWireFormatTests {
#expect(data[14] == 0x00); #expect(data[15] == 0x42) #expect(data[14] == 0x00); #expect(data[15] == 0x42)
} }
@Test("SignalPeer call byte layout: signalType→src→dst") @Test("SignalPeer call byte layout: signalType→src→dst→callId→joinToken")
func signalPeerCallByteLayout() { func signalPeerCallByteLayout() {
let packet = PacketSignalPeer(src: "S", dst: "D", sharedPublic: "", let packet = PacketSignalPeer(src: "S", dst: "D", sharedPublic: "",
signalType: .call, roomId: "") signalType: .call, roomId: "")
let data = PacketRegistry.encode(packet) let data = PacketRegistry.encode(packet)
#expect(data.count == 15) #expect(data.count == 23)
// packetId = 0x001A // packetId = 0x001A
#expect(data[0] == 0x00); #expect(data[1] == 0x1A) #expect(data[0] == 0x00); #expect(data[1] == 0x1A)
@@ -241,6 +257,12 @@ struct CallPushWireFormatTests {
#expect(data[9] == 0x00); #expect(data[10] == 0x00) #expect(data[9] == 0x00); #expect(data[10] == 0x00)
#expect(data[11] == 0x00); #expect(data[12] == 0x01) #expect(data[11] == 0x00); #expect(data[12] == 0x01)
#expect(data[13] == 0x00); #expect(data[14] == 0x44) #expect(data[13] == 0x00); #expect(data[14] == 0x44)
// callId "": length=0
#expect(data[15] == 0x00); #expect(data[16] == 0x00)
#expect(data[17] == 0x00); #expect(data[18] == 0x00)
// joinToken "": length=0
#expect(data[19] == 0x00); #expect(data[20] == 0x00)
#expect(data[21] == 0x00); #expect(data[22] == 0x00)
} }
} }

View File

@@ -1,3 +1,4 @@
import CryptoKit
import XCTest import XCTest
@testable import Rosetta @testable import Rosetta
@@ -101,4 +102,127 @@ final class CallRoutingTests: XCTestCase {
XCTAssertEqual(CallManager.shared.uiState.peerPublicKey, peerA) XCTAssertEqual(CallManager.shared.uiState.peerPublicKey, peerA)
XCTAssertEqual(CallManager.shared.uiState.statusText, "Calling...") XCTAssertEqual(CallManager.shared.uiState.statusText, "Calling...")
} }
func testLegacyOutgoingFlowCallAcceptKeyExchangeActive() {
let start = CallManager.shared.startOutgoingCall(
toPublicKey: peerA,
title: "Peer A",
username: "peer_a"
)
XCTAssertEqual(start, .started)
let accept = PacketSignalPeer(
src: peerA,
dst: ownKey,
sharedPublic: "",
signalType: .accept,
callId: "call-legacy-1",
joinToken: "join-legacy-1",
roomId: ""
)
CallManager.shared.testHandleSignalPacket(accept)
XCTAssertEqual(CallManager.shared.signalingMode, .legacy)
XCTAssertEqual(CallManager.shared.callId, "call-legacy-1")
XCTAssertEqual(CallManager.shared.joinToken, "join-legacy-1")
XCTAssertEqual(CallManager.shared.uiState.phase, .keyExchange)
let keyExchange = PacketSignalPeer(
src: peerA,
dst: ownKey,
sharedPublic: makePeerPublicHex(),
signalType: .keyExchange,
roomId: ""
)
CallManager.shared.testHandleSignalPacket(keyExchange)
XCTAssertEqual(CallManager.shared.uiState.phase, .keyExchange)
// Legacy ACTIVE arrives as signal code 4 with empty roomId.
let active = PacketSignalPeer(
src: peerA,
dst: ownKey,
sharedPublic: "",
signalType: .createRoom,
roomId: ""
)
CallManager.shared.testHandleSignalPacket(active)
XCTAssertEqual(CallManager.shared.uiState.phase, .webRtcExchange)
}
func testCreateRoomFallbackOutgoingFlowKeyExchangeBeforeAccept() {
let start = CallManager.shared.startOutgoingCall(
toPublicKey: peerA,
title: "Peer A",
username: "peer_a"
)
XCTAssertEqual(start, .started)
let keyExchange = PacketSignalPeer(
src: peerA,
dst: ownKey,
sharedPublic: makePeerPublicHex(),
signalType: .keyExchange,
roomId: ""
)
CallManager.shared.testHandleSignalPacket(keyExchange)
XCTAssertEqual(CallManager.shared.signalingMode, .createRoom)
XCTAssertEqual(CallManager.shared.uiState.phase, .webRtcExchange)
}
func testDeferredAcceptCompletesWhenMetadataArrivesLater() {
CallManager.shared.setupIncomingCallFromPush(
callerKey: peerA,
callerName: "Peer A"
)
XCTAssertEqual(CallManager.shared.uiState.phase, .incoming)
let acceptResult = CallManager.shared.acceptIncomingCall()
XCTAssertEqual(acceptResult, .started)
XCTAssertTrue(CallManager.shared.pendingIncomingAccept)
XCTAssertEqual(CallManager.shared.uiState.phase, .keyExchange)
XCTAssertEqual(CallManager.shared.signalingMode, .undecided)
let delayedCall = PacketSignalPeer(
src: peerA,
dst: ownKey,
sharedPublic: "",
signalType: .call,
callId: "call-delayed-1",
joinToken: "join-delayed-1",
roomId: ""
)
CallManager.shared.testHandleSignalPacket(delayedCall)
XCTAssertEqual(CallManager.shared.signalingMode, .legacy)
XCTAssertFalse(CallManager.shared.pendingIncomingAccept)
XCTAssertEqual(CallManager.shared.callId, "call-delayed-1")
XCTAssertEqual(CallManager.shared.joinToken, "join-delayed-1")
XCTAssertEqual(CallManager.shared.uiState.phase, .keyExchange)
}
func testRingingTimeoutSignalTearsDownCall() {
let start = CallManager.shared.startOutgoingCall(
toPublicKey: peerA,
title: "Peer A",
username: "peer_a"
)
XCTAssertEqual(start, .started)
let timeoutPacket = PacketSignalPeer(
src: "",
dst: "",
sharedPublic: "",
signalType: .ringingTimeout,
roomId: ""
)
CallManager.shared.testHandleSignalPacket(timeoutPacket)
XCTAssertEqual(CallManager.shared.uiState.phase, .idle)
XCTAssertEqual(CallManager.shared.uiState.statusText, "No answer")
}
private func makePeerPublicHex() -> String {
Curve25519.KeyAgreement.PrivateKey().publicKey.rawRepresentation.hexString
}
} }