Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 15 additions & 5 deletions CloudNow/Auth/AuthManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -100,8 +100,12 @@ final class AuthManager {
let savedRefreshToken = tokens.refreshToken // preserve device-flow refreshToken
let savedIdToken = tokens.idToken // preserve device-flow idToken
tokens = rebound
if tokens.refreshToken == nil { tokens.refreshToken = savedRefreshToken }
if tokens.idToken == nil { tokens.idToken = savedIdToken }
if tokens.refreshToken == nil {
tokens.refreshToken = savedRefreshToken
}
if tokens.idToken == nil {
tokens.idToken = savedIdToken
}
// Re-fetch clientToken for the re-bound session
if let ct2 = try? await api.fetchClientToken(accessToken: tokens.accessToken) {
tokens.clientToken = ct2.token
Expand Down Expand Up @@ -231,7 +235,9 @@ final class AuthManager {
print("[Auth] client_token grant did not return a refreshToken — preserving previous one")
updated.tokens.refreshToken = savedRefreshToken
}
if updated.tokens.idToken == nil { updated.tokens.idToken = savedIdToken }
if updated.tokens.idToken == nil {
updated.tokens.idToken = savedIdToken
}
} else if let refreshToken = s.tokens.refreshToken {
print("[Auth] client_token path unavailable or failed, falling back to refresh_token grant")
let savedRefreshToken = updated.tokens.refreshToken
Expand All @@ -241,7 +247,9 @@ final class AuthManager {
print("[Auth] refresh_token grant did not return a new refreshToken — preserving previous one")
updated.tokens.refreshToken = savedRefreshToken
}
if updated.tokens.idToken == nil { updated.tokens.idToken = savedIdToken }
if updated.tokens.idToken == nil {
updated.tokens.idToken = savedIdToken
}
print("[Auth] refresh via refresh_token grant succeeded")
} else if let idToken = s.tokens.idToken {
// Third path: the idToken is a longer-lived JWT (typically 30 days) that NVIDIA
Expand Down Expand Up @@ -270,7 +278,9 @@ final class AuthManager {
updated.tokens.refreshToken = savedRefreshToken
}
// Preserve the idToken used for bootstrap so we can re-use it on the next cycle
if updated.tokens.idToken == nil { updated.tokens.idToken = idToken }
if updated.tokens.idToken == nil {
updated.tokens.idToken = idToken
}
} else {
print("[Auth] refresh failed: no usable clientToken, refreshToken, or idToken available")
throw AuthError.tokenRefreshFailed("All refresh mechanisms exhausted.")
Expand Down
12 changes: 9 additions & 3 deletions CloudNow/Auth/NVIDIAAuthAPI.swift
Original file line number Diff line number Diff line change
Expand Up @@ -278,10 +278,14 @@ actor NVIDIAAuthAPI {
request.httpBody = body.data(using: .utf8)
let (data, response) = try await session.data(for: request)
let statusCode = (response as? HTTPURLResponse)?.statusCode ?? 0
if statusCode == 200 { return try parseTokenResponse(data) }
if statusCode == 200 {
return try parseTokenResponse(data)
}
let responseBody = String(data: data, encoding: .utf8) ?? "(empty)"
lastError = AuthError.clientTokenFailed("HTTP \(statusCode): \(responseBody)")
if statusCode < 400 || statusCode >= 500 { throw lastError }
if statusCode < 400 || statusCode >= 500 {
throw lastError
}
// 4xx with clientID mismatch → try next clientID
}
throw lastError
Expand Down Expand Up @@ -355,7 +359,9 @@ actor NVIDIAAuthAPI {
func fetchUserInfo(tokens: AuthTokens) async throws -> AuthUser {
// Try JWT payload first (fast path)
let jwt = tokens.idToken ?? tokens.accessToken
if let user = parseUserFromJWT(jwt) { return user }
if let user = parseUserFromJWT(jwt) {
return user
}

var request = URLRequest(url: URL(string: NVIDIAAuth.userinfoEndpoint)!)
request.setValue("Bearer \(tokens.accessToken)", forHTTPHeaderField: "Authorization")
Expand Down
44 changes: 39 additions & 5 deletions CloudNow/Localization/AppLocalization.swift
Original file line number Diff line number Diff line change
Expand Up @@ -180,11 +180,21 @@ enum L10n {
hasContentLightLevelMetadata: Bool
) -> String {
var parts: [String] = []
if transferFunction == nil { parts.append(text("no_transfer")) }
if colorPrimaries == nil { parts.append(text("no_primaries")) }
if yCbCrMatrix == nil { parts.append(text("no_matrix")) }
if !hasDisplayColorVolumeMetadata { parts.append(text("no_mastering_metadata")) }
if !hasContentLightLevelMetadata { parts.append(text("no_content_light_metadata")) }
if transferFunction == nil {
parts.append(text("no_transfer"))
}
if colorPrimaries == nil {
parts.append(text("no_primaries"))
}
if yCbCrMatrix == nil {
parts.append(text("no_matrix"))
}
if !hasDisplayColorVolumeMetadata {
parts.append(text("no_mastering_metadata"))
}
if !hasContentLightLevelMetadata {
parts.append(text("no_content_light_metadata"))
}
return parts.isEmpty ? text("metadata_complete") : parts.joined(separator: " · ")
}

Expand Down Expand Up @@ -258,6 +268,30 @@ enum L10n {
}
}

static func controllerSequenceButtonLabel(_ button: ControllerSequenceButton) -> String {
switch button {
case .dpadUp: text("d_pad_up")
case .dpadDown: text("d_pad_down")
case .dpadLeft: text("d_pad_left")
case .dpadRight: text("d_pad_right")
case .buttonA: text("a")
case .buttonB: text("b")
case .buttonX: text("x")
case .buttonY: text("y")
case .menu: text("menu_(≡)")
case .options: text("options/view_(⊟)")
case .leftShoulder: text("l1")
case .rightShoulder: text("r1")
case .leftThumbstick: text("l3")
case .rightThumbstick: text("r3")
}
}

static func controllerButtonSequenceLabel(_ sequence: ControllerButtonSequence) -> String {
let labels = sequence.buttons.map(controllerSequenceButtonLabel)
return labels.isEmpty ? text("not_set") : labels.joined(separator: " + ")
}

static func remoteInputModeLabel(_ mode: RemoteInputMode) -> String {
switch mode {
case .mouse: text("remote_mouse")
Expand Down
33 changes: 33 additions & 0 deletions CloudNow/Localization/L10nEN.swift
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,10 @@ enum L10nEN {
"controller_rumble_intensity_description": "Boosts vibration strength. Don't set it too high — very strong sustained rumble can wear out or damage a controller's motors. Applies to the next session.",
"overlay_button": "Overlay Button",
"overlay_button_description": "Long-press this button during play to open the app overlay. Switch if it conflicts with an in-game action.",
"text_input_buttons": "Text Input Buttons",
"text_input_buttons_description": "Choose the controller button chord that opens the Apple TV keyboard during streaming.",
"text_input_hold_delay": "Text Input Hold Delay",
"text_input_hold_delay_description": "How long the text input buttons must be held before the Apple TV keyboard opens. Default: 150 ms.",
"steam_overlay_gesture": "Steam Overlay Gesture",
"steam_overlay_gesture_description": "Long-press the other button (the one not set as Overlay Button) to send Shift+Tab and open the Steam overlay. For example, with Overlay on Start, long-press View/Back.",
"default_input_mode": "Default Input Mode",
Expand Down Expand Up @@ -117,6 +121,35 @@ enum L10nEN {
"end_session_message": "This will end your GeForce NOW session. To return later, use Leave Game instead.",
"resume": "Resume",
"leave_game": "Leave Game",
"controller_text_entry_title": "Type with Apple TV Keyboard",
"controller_text_entry_instructions": "Enter text locally, then send it to GeForce NOW when you're done.",
"controller_text_entry_placeholder": "Enter text",
"controller_text_entry_send": "Send",
"capture_text_input_buttons": "Set Text Input Buttons",
"capture_text_input_buttons_instructions": "Press the controller buttons you want to use together. The sequence is saved after you release them all.",
"current_sequence": "Current Sequence",
"press_buttons_now": "Press buttons now",
"release_buttons_to_save": "Release buttons to save",
"detected_sequence": "Detected Sequence",
"button_sequence_empty": "No supported buttons were detected.",
"button_sequence_too_long": "Use up to four buttons in a sequence.",
"button_sequence_conflicts_overlay": "That sequence uses the current overlay button by itself. Choose a different button or add another button.",
"button_sequence_conflicts_steam": "That sequence matches the current Steam overlay button by itself. Choose a different button or add another button.",
"not_set": "Not Set",
"d_pad_up": "D-Pad Up",
"d_pad_down": "D-Pad Down",
"d_pad_left": "D-Pad Left",
"d_pad_right": "D-Pad Right",
"a": "A",
"b": "B",
"x": "X",
"y": "Y",
"menu_(≡)": "Menu (≡)",
"options/view_(⊟)": "Options/View (⊟)",
"l1": "L1",
"r1": "R1",
"l3": "L3",
"r3": "R3",
"statistics_disabled": "Statistics disabled",
"rtt": "RTT",
"fps": "FPS",
Expand Down
56 changes: 42 additions & 14 deletions CloudNow/Session/CloudMatchClient.swift
Original file line number Diff line number Diff line change
Expand Up @@ -354,8 +354,12 @@ private func resolveSignalingUrl(serverIp: String, resourcePath: String) -> Stri
return "wss://\(host)/nvst/"
}
}
if resourcePath.hasPrefix("wss://") { return resourcePath }
if resourcePath.hasPrefix("/") { return "wss://\(serverIp):443\(resourcePath)" }
if resourcePath.hasPrefix("wss://") {
return resourcePath
}
if resourcePath.hasPrefix("/") {
return "wss://\(serverIp):443\(resourcePath)"
}
return "wss://\(serverIp):443/nvst/"
}

Expand Down Expand Up @@ -579,7 +583,9 @@ actor CloudMatchClient {
let (data, resp) = try await urlSession.data(for: request)
let httpStatus = (resp as? HTTPURLResponse)?.statusCode ?? -1
print("[CloudMatch] getActiveSessions HTTP \(httpStatus), \(data.count) bytes")
if let raw = String(data: data, encoding: .utf8) { print("[CloudMatch] getActiveSessions raw: \(raw.prefix(500))") }
if let raw = String(data: data, encoding: .utf8) {
print("[CloudMatch] getActiveSessions raw: \(raw.prefix(500))")
}
try validateHTTPStatus(resp, data: data, context: "getActiveSessions")
let decoded = try JSONDecoder().decode(GetSessionsResponse.self, from: data)
try validateAPIStatus(decoded, context: "getActiveSessions")
Expand Down Expand Up @@ -653,7 +659,9 @@ actor CloudMatchClient {
)

// If still queuing, return as-is — caller polls from here
if preflight.status == 1 || preflight.isInQueue { return preflight }
if preflight.status == 1 || preflight.isInQueue {
return preflight
}

// Status 2 or 3: send RESUME PUT
let resumeBase = preflight.streamingBaseUrl
Expand Down Expand Up @@ -793,8 +801,12 @@ actor CloudMatchClient {
/// isAdsRequired lives in several possible places
func bool(_ key: String, in obj: [String: Any]) -> Bool? {
guard let v = obj[key] else { return nil }
if let b = v as? Bool { return b }
if let i = v as? Int { return i != 0 }
if let b = v as? Bool {
return b
}
if let i = v as? Int {
return i != 0
}
return nil
}

Expand Down Expand Up @@ -834,7 +846,9 @@ actor CloudMatchClient {
}

// Only return an ad state if there's actually something to act on
if !isAdsRequired, ads.isEmpty, isQueuePaused != true { return nil }
if !isAdsRequired, ads.isEmpty, isQueuePaused != true {
return nil
}

return SessionAdState(
isAdsRequired: isAdsRequired,
Expand All @@ -846,16 +860,26 @@ actor CloudMatchClient {
}

private func doubleValue(_ key: String, in obj: [String: Any]) -> Double? {
if let number = obj[key] as? NSNumber { return number.doubleValue }
if let string = obj[key] as? String { return Double(string) }
if let number = obj[key] as? NSNumber {
return number.doubleValue
}
if let string = obj[key] as? String {
return Double(string)
}
return nil
}

private func adMediaPreference(_ profile: String?) -> Int {
let profile = profile?.lowercased() ?? ""
if profile.contains("mp4deinterlaced720p") { return 0 }
if profile.contains("webm") { return 1 }
if profile.contains("hlsadaptive") { return 2 }
if profile.contains("mp4deinterlaced720p") {
return 0
}
if profile.contains("webm") {
return 1
}
if profile.contains("hlsadaptive") {
return 2
}
return 3
}

Expand All @@ -880,8 +904,12 @@ actor CloudMatchClient {
"adAction": action.rawValue,
"clientTimestamp": Int(Date().timeIntervalSince1970),
]
if let ms = watchedTimeMs { adUpdate["watchedTimeInMs"] = max(0, ms) }
if let ms = pausedTimeMs { adUpdate["pausedTimeInMs"] = max(0, ms) }
if let ms = watchedTimeMs {
adUpdate["watchedTimeInMs"] = max(0, ms)
}
if let ms = pausedTimeMs {
adUpdate["pausedTimeInMs"] = max(0, ms)
}
let body: [String: Any] = ["action": 6, "adUpdates": [adUpdate]]
guard let bodyData = try? JSONSerialization.data(withJSONObject: body) else { return }
var request = URLRequest(url: url)
Expand Down
32 changes: 24 additions & 8 deletions CloudNow/Session/GamesClient.swift
Original file line number Diff line number Diff line change
Expand Up @@ -167,10 +167,18 @@ actor GamesClient {
for variant in variants {
for flag in variant.gfn?.features ?? [] {
switch flag.key {
case "RTX_ENABLED": if flag.value == "true" { found.insert(.rtx) }
case "HDR_ENABLED": if flag.value == "true" { found.insert(.hdr) }
case "SUPPORTED_HDR_VERSION": if !(flag.values ?? []).isEmpty { found.insert(.hdr) }
case "REFLEX_ENABLED": if flag.value == "true" { found.insert(.reflex) }
case "RTX_ENABLED": if flag.value == "true" {
found.insert(.rtx)
}
case "HDR_ENABLED": if flag.value == "true" {
found.insert(.hdr)
}
case "SUPPORTED_HDR_VERSION": if !(flag.values ?? []).isEmpty {
found.insert(.hdr)
}
case "REFLEX_ENABLED": if flag.value == "true" {
found.insert(.reflex)
}
default: break
}
}
Expand Down Expand Up @@ -228,7 +236,9 @@ actor GamesClient {
}
if let nested = dict[key] as? [String: Any] {
let entries = nested.values.compactMap { $0 as? [String: Any] }
if !entries.isEmpty { return entries }
if !entries.isEmpty {
return entries
}
}
}
let entries = dict.values.compactMap { $0 as? [String: Any] }
Expand Down Expand Up @@ -286,7 +296,9 @@ actor GamesClient {
?? "GFN"
return GameVariant(id: id, appStore: store, appId: isNumericId(id) ? id : nil)
}
if !variants.isEmpty { return variants }
if !variants.isEmpty {
return variants
}
}

let id = stringValue(entry["appId"])
Expand Down Expand Up @@ -551,8 +563,12 @@ actor GamesClient {
}

private func stringValue(_ value: Any?) -> String? {
if let string = value as? String, !string.isEmpty { return string }
if let number = value as? NSNumber { return number.stringValue }
if let string = value as? String, !string.isEmpty {
return string
}
if let number = value as? NSNumber {
return number.stringValue
}
return nil
}

Expand Down
Loading
Loading