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
3 changes: 3 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ All source lives in `CloudNow/`. Five functional areas:
- `CloudMatchClient.swift` — REST client for session lifecycle: create → poll queue position → active session → stop. Also retrieves and reports queue-ad lifecycle events.
- `GamesClient.swift` — GraphQL persisted queries for linked-library games and full store catalog.
- `ZoneClient.swift` — Fetches regions from the PrintedWaste community API; ranks them by 40% ping + 60% queue depth score.
- `MESClient.swift` — Fetches subscription tier and VPC region ID from NVIDIA's Membership Entitlement Service (MES). Required to determine stream resolution/FPS entitlements per tier.
- `SessionState.swift` — All data models: `StreamSettings`, `SessionInfo`, `GameInfo`, `QueueInfo`, etc.

### Streaming
Expand All @@ -82,6 +83,8 @@ All source lives in `CloudNow/`. Five functional areas:
- `SettingsView.swift` — Stream quality (resolution, FPS, codec, color, keyboard layout, game language, L4S), controller deadzone slider, zone picker, microphone toggle, account info.
- `QueueAdPlayerView.swift` — AVPlayer-based queue ad playback; reports lifecycle events to CloudMatch.
- `LoginView.swift` — Displays a QR code and PIN for NVIDIA device flow login; user scans the QR code or visits the URL on any device to complete OAuth.
- `ImmersiveStreamView.swift` — visionOS-only (`#if os(visionOS)`) immersive space scene wrapper for full-passthrough streaming.
- `SkeletonViews.swift` — Skeleton shimmer loading views used while game lists are fetching.

## Key Patterns

Expand Down
8 changes: 6 additions & 2 deletions CloudNow.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -295,13 +295,15 @@
PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = "";
STRING_CATALOG_GENERATE_SYMBOLS = YES;
SUPPORTED_PLATFORMS = "appletvos appletvsimulator xros xrsimulator";
SUPPORTS_MACCATALYST = NO;
SWIFT_APPROACHABLE_CONCURRENCY = YES;
SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor;
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_OBJC_BRIDGING_HEADER = "CloudNow/CloudNow-Bridging-Header.h";
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = 3;
TARGETED_DEVICE_FAMILY = "3,7";
};
name = Debug;
};
Expand Down Expand Up @@ -332,13 +334,15 @@
PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = "";
STRING_CATALOG_GENERATE_SYMBOLS = YES;
SUPPORTED_PLATFORMS = "appletvos appletvsimulator xros xrsimulator";
SUPPORTS_MACCATALYST = NO;
SWIFT_APPROACHABLE_CONCURRENCY = YES;
SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor;
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_OBJC_BRIDGING_HEADER = "CloudNow/CloudNow-Bridging-Header.h";
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = 3;
TARGETED_DEVICE_FAMILY = "3,7";
};
name = Release;
};
Expand Down
61 changes: 49 additions & 12 deletions CloudNow/CloudNowApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -5,28 +5,29 @@
// Created by Owen Selles on 11/04/2026.
//

import BackgroundTasks
#if os(tvOS)
import BackgroundTasks
#endif
import SwiftUI

@main
struct CloudNowApp: App {
@State private var authManager = AuthManager()
/// Owned here so it can be injected into both WindowGroup and ImmersiveSpace (visionOS),
/// sharing the same instance (pendingGame visibility). On tvOS this is the single owner too.
@State private var viewModel = GamesViewModel()
#if os(visionOS)
/// Drives the ImmersiveSpace immersion level. Seeded from AppStorage on launch;
/// the user can also toggle between .full and .mixed with the Digital Crown at runtime.
@AppStorage("gfn.immersionStyle") private var immersionStyleRaw: String = "full"
@State private var immersionStyle: ImmersionStyle = .full
#endif

init() {
URLCache.shared = URLCache(
memoryCapacity: 50 * 1024 * 1024,
diskCapacity: 200 * 1024 * 1024
)
BGTaskScheduler.shared.register(
forTaskWithIdentifier: "com.owenselles.CloudNow.tokenRefresh",
using: nil
) { [authManager] task in
Task { @MainActor in
await authManager.refreshIfNeeded()
authManager.scheduleBackgroundRefresh()
task.setTaskCompleted(success: true)
}
}
}

var body: some Scene {
Expand All @@ -39,7 +40,43 @@ struct CloudNowApp: App {
}
}
.environment(authManager)
.task { await authManager.initialize() }
.environment(viewModel)
#if os(tvOS)
.onAppear { registerBGTasks() }
#endif
.task { await authManager.initialize() }
#if os(visionOS)
.task {
immersionStyle = immersionStyleRaw == "mixed" ? .mixed : .full
}
.onChange(of: immersionStyleRaw) { _, raw in
immersionStyle = raw == "mixed" ? .mixed : .full
}
#endif
}

#if os(visionOS)
ImmersiveSpace(id: "stream") {
ImmersiveStreamView()
.environment(authManager)
.environment(viewModel)
}
.immersionStyle(selection: $immersionStyle, in: .full, .mixed)
#endif
}

#if os(tvOS)
private func registerBGTasks() {
BGTaskScheduler.shared.register(
forTaskWithIdentifier: "com.owenselles.CloudNow.tokenRefresh",
using: nil
) { task in
Task { @MainActor in
await authManager.refreshIfNeeded()
authManager.scheduleBackgroundRefresh()
task.setTaskCompleted(success: true)
}
}
}
#endif
}
28 changes: 19 additions & 9 deletions CloudNow/Streaming/GFNStreamController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -174,7 +174,9 @@ final class GFNStreamController: NSObject {
private var signaling: GFNSignalingClient?
private var inputSender: InputSender?
private(set) var videoView: VideoSurfaceView?
private(set) var remoteMode: RemoteInputMode = .mouse
#if os(tvOS)
private(set) var remoteMode: RemoteInputMode = .mouse
#endif
private var statsTimer: Timer?
private var videoReceiver: LKRTCRtpReceiver?
private var protocolVersion = 2
Expand Down Expand Up @@ -347,9 +349,11 @@ final class GFNStreamController: NSObject {

// MARK: Input Control

func toggleRemoteMode() {
inputSender?.toggleRemoteMode()
}
#if os(tvOS)
func toggleRemoteMode() {
inputSender?.toggleRemoteMode()
}
#endif

func setInputPaused(_ paused: Bool) {
inputSender?.setPaused(paused)
Expand Down Expand Up @@ -402,7 +406,9 @@ final class GFNStreamController: NSObject {
videoView?.menuPressHandler = nil
videoView?.onDecodedVideoFormatChanged = nil
videoView = nil
remoteMode = .mouse
#if os(tvOS)
remoteMode = .mouse
#endif
menuPressCount = 0
timeWarning = nil
videoDiagnostics = VideoPipelineSnapshot()
Expand Down Expand Up @@ -839,6 +845,8 @@ final class GFNStreamController: NSObject {
private func attachMicrophone(to pc: LKRTCPeerConnection) async {
#if os(tvOS)
let granted = true
#elseif os(visionOS)
let granted = await AVAudioApplication.requestRecordPermission()
#else
let granted = await withCheckedContinuation { cont in
AVAudioSession.sharedInstance().requestRecordPermission { cont.resume(returning: $0) }
Expand Down Expand Up @@ -1567,10 +1575,12 @@ extension GFNStreamController: LKRTCDataChannelDelegate {
remoteMode = settings.defaultRemoteInputMode
videoView?.gamepadModeActive = (remoteMode == .gamepad || remoteMode == .dualsense)
sender.menuToggleHandler = { [weak self] in self?.handleMenuPress() }
sender.onRemoteModeChanged = { [weak self] mode in
self?.remoteMode = mode
self?.videoView?.gamepadModeActive = (mode == .gamepad || mode == .dualsense)
}
#if os(tvOS)
sender.onRemoteModeChanged = { [weak self] mode in
self?.remoteMode = mode
self?.videoView?.gamepadModeActive = (mode == .gamepad || mode == .dualsense)
}
#endif
sender.start()
inputSender = sender
inputSendQueue.async { [weak self, weak sender] in
Expand Down
26 changes: 19 additions & 7 deletions CloudNow/UI/GamesViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,12 @@ class GamesViewModel {
/// Top 5 lowest-latency zones, populated on launch.
var topZones: [GFNZone] = []

#if os(visionOS)
/// Set before opening the ImmersiveSpace so the content view can read the pending game.
var pendingGame: GameInfo?
var pendingSession: ActiveSessionInfo?
#endif

private let gamesClient = GamesClient()
private let cloudMatchClient = CloudMatchClient()

Expand Down Expand Up @@ -118,12 +124,14 @@ class GamesViewModel {
{
lastSession = session
}
// tvOS currently caps at 60 Hz; clamp any saved value to the screen maximum.
// If Apple raises the cap in a future tvOS release this will automatically unlock.
let screenMax = (UIApplication.shared.connectedScenes.compactMap { $0 as? UIWindowScene }.first?.screen.maximumFramesPerSecond) ?? 60
if streamSettings.fps > screenMax {
streamSettings.fps = screenMax
}
#if os(tvOS)
// tvOS currently caps at 60 Hz; clamp any saved value to the screen maximum.
// If Apple raises the cap in a future tvOS release this will automatically unlock.
let screenMax = (UIApplication.shared.connectedScenes.compactMap { $0 as? UIWindowScene }.first?.screen.maximumFramesPerSecond) ?? 60
if streamSettings.fps > screenMax {
streamSettings.fps = screenMax
}
#endif
streamSettings = streamSettings.normalizedForClient
print(
"[Localization] preferred=\(Locale.preferredLanguages.first ?? "nil") ui=\(L10n.localeCode) keyboard=\(streamSettings.keyboardLayout) gameLanguage=\(streamSettings.gameLanguage) effectiveGameLanguage=\(streamSettings.effectiveGameLanguage)"
Expand All @@ -150,7 +158,11 @@ class GamesViewModel {
/// screen's maximum refresh rate. Today tvOS caps at 60 Hz; if Apple raises it
/// in a future update this will automatically expose the higher option.
var availableFps: [Int] {
let maxFps = (UIApplication.shared.connectedScenes.compactMap { $0 as? UIWindowScene }.first?.screen.maximumFramesPerSecond) ?? 60
#if os(tvOS)
let maxFps = (UIApplication.shared.connectedScenes.compactMap { $0 as? UIWindowScene }.first?.screen.maximumFramesPerSecond) ?? 60
#else
let maxFps = 120
#endif
guard let resos = subscription?.entitledResolutions, !resos.isEmpty else {
return [30, 60].filter { $0 <= maxFps }
}
Expand Down
8 changes: 7 additions & 1 deletion CloudNow/UI/HomeView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -131,7 +131,9 @@ struct HomeView: View {
}
.padding(60)
}
#if os(tvOS)
.focusSection()
#endif
}

// MARK: Hero Banner
Expand Down Expand Up @@ -181,7 +183,9 @@ struct HomeView: View {
.padding(.horizontal, 60)
.padding(.vertical, 20)
}
#if os(tvOS)
.focusSection()
#endif
.scrollClipDisabled()
}
}
Expand Down Expand Up @@ -287,6 +291,8 @@ private struct HeroBannerView: View {
.frame(height: 420)
.clipShape(RoundedRectangle(cornerRadius: 20))
.padding(.horizontal, 60)
.focusSection()
#if os(tvOS)
.focusSection()
#endif
}
}
32 changes: 32 additions & 0 deletions CloudNow/UI/ImmersiveStreamView.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
#if os(visionOS)
import SwiftUI

/// Root content view for the streaming ImmersiveSpace scene.
/// Reads the pending game set by MainTabView before the space was opened,
/// hosts StreamView full-screen, and dismisses the space when the stream ends.
struct ImmersiveStreamView: View {
@Environment(GamesViewModel.self) var viewModel
@Environment(AuthManager.self) var authManager
@Environment(\.dismissImmersiveSpace) var dismissImmersiveSpace

var body: some View {
if let game = viewModel.pendingGame {
ZStack {
Color.black.ignoresSafeArea()
StreamView(
game: game,
settings: viewModel.streamSettings,
existingSession: viewModel.pendingSession,
onDismiss: {
viewModel.pendingGame = nil
viewModel.pendingSession = nil
Task { await dismissImmersiveSpace() }
}
)
.environment(authManager)
.environment(viewModel)
}
}
}
}
#endif
58 changes: 35 additions & 23 deletions CloudNow/UI/LibraryView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -133,34 +133,38 @@ struct LibraryView: View {
GameCardLabel(game: game)
}
.aspectRatio(2 / 3, contentMode: .fit)
.buttonStyle(.card)
.contextMenu {
Button {
viewModel.toggleFavorite(game.id)
} label: {
let isFav = viewModel.favoriteIds.contains(game.id)
Label(
isFav ? L10n.text("remove_from_favorites") : L10n.text("add_to_favorites"),
systemImage: isFav ? "star.slash.fill" : "star"
)
}
if game.variants.count > 1 {
Menu(L10n.text("launch_via")) {
ForEach(game.variants, id: \.id) { variant in
Button {
viewModel.setPreferredStore(gameId: game.id, variantId: variant.id)
} label: {
let isSelected = viewModel.preferredVariantId(for: game) == variant.id
if isSelected {
Label(variant.storeName, systemImage: "checkmark")
} else {
Text(variant.storeName)
#if os(tvOS)
.buttonStyle(.card)
#else
.buttonStyle(.plain)
#endif
.contextMenu {
Button {
viewModel.toggleFavorite(game.id)
} label: {
let isFav = viewModel.favoriteIds.contains(game.id)
Label(
isFav ? L10n.text("remove_from_favorites") : L10n.text("add_to_favorites"),
systemImage: isFav ? "star.slash.fill" : "star"
)
}
if game.variants.count > 1 {
Menu(L10n.text("launch_via")) {
ForEach(game.variants, id: \.id) { variant in
Button {
viewModel.setPreferredStore(gameId: game.id, variantId: variant.id)
} label: {
let isSelected = viewModel.preferredVariantId(for: game) == variant.id
if isSelected {
Label(variant.storeName, systemImage: "checkmark")
} else {
Text(variant.storeName)
}
}
}
}
}
}
}
}
}
.padding(60)
Expand Down Expand Up @@ -278,7 +282,11 @@ struct GameCardView: View {
Button(action: onPlay) {
GameCardLabel(game: game)
}
#if os(tvOS)
.buttonStyle(.card)
#else
.buttonStyle(.plain)
#endif
}
}

Expand All @@ -294,6 +302,10 @@ struct LibraryCardView: View {
Button(action: onPlay) {
GameCardLabel(game: game)
}
#if os(tvOS)
.buttonStyle(.card)
#else
.buttonStyle(.plain)
#endif
}
}
Loading
Loading