diff --git a/clients/apple/Sources/PunktfunkClient/ContentView.swift b/clients/apple/Sources/PunktfunkClient/ContentView.swift index aa59b97f..a37c3f2e 100644 --- a/clients/apple/Sources/PunktfunkClient/ContentView.swift +++ b/clients/apple/Sources/PunktfunkClient/ContentView.swift @@ -200,16 +200,19 @@ struct ContentView: View { } } // The Live Activity's / Shortcuts' End button runs EndStreamIntent in-process, which posts - // this — tear the session down deliberately (quit-close the host). + // this — tear the session down deliberately (quit-close the host). iOS-only along with + // the intent itself (LiveActivityIntent is ActivityKit's world). .onReceive(NotificationCenter.default.publisher(for: .punktfunkEndActiveSession)) { _ in model.disconnect(deliberate: true) } - // Connect App Intent (Siri/Shortcuts): route its punktfunk:// URL through the same handler - // as a widget tap. + #endif + // Connect App Intent (Siri/Shortcuts/Spotlight): route its punktfunk:// URL through the + // same handler a widget tap uses. NOT iOS-gated — the Connect intent compiles on macOS and + // tvOS too, and an intent that posts to nobody would be a shortcut that silently does + // nothing. .onReceive(NotificationCenter.default.publisher(for: .punktfunkOpenDeepLink)) { note in if let url = note.object as? URL { handleDeepLink(url) } } - #endif .onChange(of: model.phase) { _, phase in switch phase { case .streaming: @@ -505,13 +508,13 @@ struct ContentView: View { GamepadHomeView( store: store, model: model, discovery: discovery, libraryTarget: $libraryTarget, waker: waker, - connect: { connect($0) }, connectDiscovered: connectDiscovered) + connect: { connect($0, profile: $1) }, connectDiscovered: connectDiscovered) } else { HomeView( store: store, model: model, discovery: discovery, showAddHost: $showAddHost, pairingTarget: $pairingTarget, speedTestTarget: $speedTestTarget, libraryTarget: $libraryTarget, - connect: { connect($0) }, connectDiscovered: connectDiscovered, + connect: { connect($0, profile: $1) }, connectDiscovered: connectDiscovered, onPaired: handlePaired, onLaunchTitle: launchTitle, wake: { wakeOnly($0) }) } } @@ -521,7 +524,7 @@ struct ContentView: View { GamepadHomeView( store: store, model: model, discovery: discovery, libraryTarget: $libraryTarget, waker: waker, - connect: { connect($0) }, connectDiscovered: connectDiscovered) + connect: { connect($0, profile: $1) }, connectDiscovered: connectDiscovered) // On tvOS pairing/library normally present from HomeView's navigationDestinations // — which aren't mounted while the gamepad launcher is up. Give the launcher its // own presenters (exactly one of the two homes is mounted at a time, so these can @@ -545,7 +548,7 @@ struct ContentView: View { showAddHost: $showAddHost, pairingTarget: $pairingTarget, speedTestTarget: $speedTestTarget, libraryTarget: $libraryTarget, showSettings: $showSettings, - connect: { connect($0) }, connectDiscovered: connectDiscovered, + connect: { connect($0, profile: $1) }, connectDiscovered: connectDiscovered, onPaired: handlePaired, onLaunchTitle: launchTitle, wake: { wakeOnly($0) }) } } diff --git a/clients/apple/Sources/PunktfunkClient/Home/AddHostSheet.swift b/clients/apple/Sources/PunktfunkClient/Home/AddHostSheet.swift index b719b236..eabb20a6 100644 --- a/clients/apple/Sources/PunktfunkClient/Home/AddHostSheet.swift +++ b/clients/apple/Sources/PunktfunkClient/Home/AddHostSheet.swift @@ -20,6 +20,16 @@ struct AddHostSheet: View { @State private var address: String @State private var port: Int @State private var mac: String + #if !os(tvOS) + /// This host's DEFAULT settings profile — what a plain click/tap uses. Empty = Default + /// settings (design/client-settings-profiles.md §5.2). Changing it here is the only way the + /// default moves; "Connect with ▸" is deliberately a one-off. + @State private var profileID: String + /// Profiles pinned as their own cards for this host (§5.2a) — presentation only, and + /// independent of the default above. + @State private var pinnedIDs: Set + @ObservedObject private var profiles = ProfileStore.shared + #endif #if os(macOS) /// Share the clipboard with this host (macOS sessions only; design /// clipboard-and-file-transfer.md §5.3). Off by default; honored only when the host @@ -50,6 +60,10 @@ struct AddHostSheet: View { #if os(macOS) _clipboardSync = State(initialValue: existing?.clipboardSync ?? false) #endif + #if !os(tvOS) + _profileID = State(initialValue: existing?.profileID ?? "") + _pinnedIDs = State(initialValue: Set(existing?.pinnedProfileIDs ?? [])) + #endif } var body: some View { @@ -108,6 +122,7 @@ struct AddHostSheet: View { #if os(macOS) Toggle("Share clipboard with this host", isOn: $clipboardSync) #endif + profileRows } #if !os(tvOS) .formStyle(.grouped) @@ -142,8 +157,9 @@ struct AddHostSheet: View { #endif } #if os(iOS) - // Four fields + the action row — a touch taller than the 3-field add sheet used to be. - .presentationDetents([.height(392)]) + // Four fields + the action row — a touch taller than the 3-field add sheet used to be, + // and taller again once there are profiles to bind and pin. + .presentationDetents([.height(profiles.profiles.isEmpty ? 392 : 480)]) .presentationDragIndicator(.visible) #endif #if os(macOS) @@ -153,6 +169,39 @@ struct AddHostSheet: View { #endif } + /// The per-host profile rows: which profile this host uses by default, and which extra ones + /// get their own card in the grid. Absent when no profiles exist — a user who has never made + /// one sees exactly today's sheet, which is the "zero profiles = zero new clutter" rule. + @ViewBuilder private var profileRows: some View { + #if !os(tvOS) + if !profiles.profiles.isEmpty { + Picker("Profile", selection: $profileID) { + Text("Default settings").tag("") + ForEach(profiles.profiles) { profile in + Text(profile.name).tag(profile.id) + } + // A binding whose profile was deleted resolves as Default settings anyway; saying + // so beats an empty picker, and saving cleans the field up. + if !profileID.isEmpty, profiles.profile(id: profileID) == nil { + Text("Default settings (profile deleted)").tag(profileID) + } + } + DisclosureGroup("Pinned cards") { + ForEach(profiles.profiles) { profile in + Toggle(profile.name, isOn: Binding( + get: { pinnedIDs.contains(profile.id) }, + set: { on in + if on { pinnedIDs.insert(profile.id) } else { pinnedIDs.remove(profile.id) } + })) + } + Text("A pinned profile gets its own card next to this host — one click, no menu.") + .font(.geist(12, relativeTo: .caption)) + .foregroundStyle(.secondary) + } + } + #endif + } + private func save() { var host = existing ?? StoredHost(name: "", address: "") host.name = name.trimmingCharacters(in: .whitespaces) @@ -164,6 +213,17 @@ struct AddHostSheet: View { // opted in" and "opted out" read the same — off). host.clipboardSync = clipboardSync ? true : nil #endif + #if !os(tvOS) + // nil rather than "" for the same forward-compat reason, and a dangling binding is + // cleaned up on this save (§6) instead of lingering as a stale id forever. + host.profileID = profiles.profile(id: profileID) == nil ? nil : profileID + // Keep the stored ORDER (it is card order) and drop what this sheet unpinned; anything + // newly ticked goes on the end. + let kept = (host.pinnedProfileIDs ?? []).filter { pinnedIDs.contains($0) } + let added = pinnedIDs.filter { !kept.contains($0) }.sorted() + let pins = kept + added + host.pinnedProfileIDs = pins.isEmpty ? nil : pins + #endif onSave(host) dismiss() } diff --git a/clients/apple/Sources/PunktfunkClient/Home/GamepadHomeView.swift b/clients/apple/Sources/PunktfunkClient/Home/GamepadHomeView.swift index a0d63d3f..fc30e3d4 100644 --- a/clients/apple/Sources/PunktfunkClient/Home/GamepadHomeView.swift +++ b/clients/apple/Sources/PunktfunkClient/Home/GamepadHomeView.swift @@ -26,7 +26,9 @@ import GameController /// One navigable tile: a saved host, a discovered-but-unsaved one, or the trailing Add Host /// action. Hashable so it can be the carousel's scroll-position identity. private enum GamepadHomeTarget: Hashable { - case saved(UUID) + /// A saved host's own tile, or one of its pinned host+profile cards (§5.2a) — which on a + /// controller-first surface are THE profile affordance: focus and press, no menus. + case saved(UUID, profile: String?) case discovered(String) case addHost } @@ -37,6 +39,9 @@ private struct HomeTile: Identifiable { let id: GamepadHomeTarget let title: String let subtitle: String + /// The profile this tile connects with — shown as a tinted chip. Set on a pinned card; nil on + /// a plain host tile unless the host is bound to one (then it answers "what will A do?"). + var profile: StreamProfile? var isOnline = false var isPaired = false var isConnecting = false @@ -59,9 +64,12 @@ struct GamepadHomeView: View { /// Wake-and-wait driver — gates the carousel while its overlay is up, and the carousel's /// activate routes an offline+wakeable host through it (see ContentView.startSession). @ObservedObject var waker: HostWaker - let connect: (StoredHost) -> Void + let connect: (StoredHost, ProfileSelection) -> Void let connectDiscovered: (DiscoveredHost) -> Void + /// The profile catalog — pinned host+profile combos render as their own tiles here, which is + /// how a controller picks a profile: one focus-and-press instead of a menu (design §5.4). + @ObservedObject private var profiles = ProfileStore.shared /// Same gate the touch grid's "Browse Library…" context-menu item uses (default ON; the /// Settings "Game library" toggle opts out). @AppStorage(DefaultsKey.libraryEnabled) private var libraryEnabled = true @@ -250,22 +258,34 @@ struct GamepadHomeView: View { /// then discovered-but-unsaved ones, then the Add Host action tile (so the strip is never /// empty and manual entry is always one press away). private var tiles: [HomeTile] { - let saved = store.hosts.map { host in - HomeTile( - id: .saved(host.id), - title: host.displayName, - subtitle: "\(host.address):\(String(host.port))", - // Online = advertising on mDNS OR proven reachable by the probe (a routed/VPN host - // never advertises); the wake item is offered only when neither holds. - isOnline: discovery.advertises(host) || store.probedOnline.contains(host.id), - isPaired: host.pinnedSHA256 != nil, - isConnecting: model.phase == .connecting && model.activeHost?.id == host.id, - filled: true, - hasLibrary: true, - canWake: autoWakeEnabled && PunktfunkConnection.wakeOnLANAvailable - && !discovery.advertises(host) && !store.probedOnline.contains(host.id) - && !host.wakeMacs.isEmpty, - activate: { connect(host) }) + var saved: [HomeTile] = [] + for host in store.hosts { + // Online = advertising on mDNS OR proven reachable by the probe (a routed/VPN host + // never advertises); the wake item is offered only when neither holds. + let online = discovery.advertises(host) || store.probedOnline.contains(host.id) + let bound = profiles.binding(for: host) + let connecting = model.phase == .connecting && model.activeHost?.id == host.id + // The host's own tile, then one per pinned profile — the same order the touch grid + // uses, so a pin reads as belonging to its host on both surfaces. + for profile in [StreamProfile?.none] + profiles.pinned(for: host).map(Optional.some) { + saved.append(HomeTile( + id: .saved(host.id, profile: profile?.id), + title: host.displayName, + subtitle: "\(host.address):\(String(host.port))", + profile: profile ?? bound, + isOnline: online, + isPaired: host.pinnedSHA256 != nil, + isConnecting: connecting, + filled: true, + // A pinned card is a shortcut, not a second host — Y (library) stays on the + // host's own tile, where the host-level actions live. + hasLibrary: profile == nil, + canWake: autoWakeEnabled && PunktfunkConnection.wakeOnLANAvailable + && !online && !host.wakeMacs.isEmpty, + activate: { + connect(host, profile.map { .profile($0.id) } ?? .inherit) + })) + } } let discovered = discovery.unsaved(among: store.hosts).map { d in HomeTile( @@ -287,7 +307,7 @@ struct GamepadHomeView: View { /// Only saved hosts have a library — matches the touch grid, where "Browse Library…" is a /// `HostCardView`-only action never offered on `DiscoveredCardView`. private func openLibraryForSelected() { - guard libraryEnabled, case .saved(let id) = selection, + guard libraryEnabled, case .saved(let id, let profile) = selection, profile == nil, let host = store.hosts.first(where: { $0.id == id }) else { return } libraryTarget = host @@ -353,6 +373,10 @@ private struct GamepadHostTile: View { .foregroundStyle(.white) .lineLimit(1) .minimumScaleFactor(0.7) + if let profile = tile.profile { + ProfileChip(profile: profile, size: Self.statusFont, prominent: true) + .padding(.top, 4) + } Text(tile.subtitle) .font(.geist(Self.subtitleFont, relativeTo: .caption)) .foregroundStyle(.white.opacity(0.55)) diff --git a/clients/apple/Sources/PunktfunkClient/Home/HomeView.swift b/clients/apple/Sources/PunktfunkClient/Home/HomeView.swift index 5dd3d3c9..ef7f817c 100644 --- a/clients/apple/Sources/PunktfunkClient/Home/HomeView.swift +++ b/clients/apple/Sources/PunktfunkClient/Home/HomeView.swift @@ -13,6 +13,9 @@ struct HomeView: View { @ObservedObject var store: HostStore @ObservedObject var model: SessionModel @ObservedObject var discovery: HostDiscovery + /// The profile catalog — the source of the card chips, the "Connect with ▸" menu, and the + /// pinned host+profile cards the grid renders alongside their host (design §5.2a). + @ObservedObject private var profiles = ProfileStore.shared @Binding var showAddHost: Bool @Binding var pairingTarget: StoredHost? @Binding var speedTestTarget: StoredHost? @@ -20,7 +23,9 @@ struct HomeView: View { #if !os(macOS) @Binding var showSettings: Bool #endif - let connect: (StoredHost) -> Void + /// Start a session with this host, using the given profile selection — `.inherit` for a plain + /// card tap (the host's binding), an explicit pick from "Connect with ▸" or a pinned card. + let connect: (StoredHost, ProfileSelection) -> Void let connectDiscovered: (DiscoveredHost) -> Void /// Pairing succeeded (tvOS PairSheet route) — pin + connect (ContentView guards staleness). let onPaired: (StoredHost, Data) -> Void @@ -44,8 +49,8 @@ struct HomeView: View { ScrollView { if !store.hosts.isEmpty { LazyVGrid(columns: gridColumns, spacing: gridSpacing) { - ForEach(store.hosts) { host in - hostCard(host) + ForEach(gridEntries) { entry in + hostCard(entry.host, pinned: entry.profile) } } .padding() @@ -180,22 +185,63 @@ struct HomeView: View { // MARK: - Cards - private func hostCard(_ host: StoredHost) -> some View { + /// One grid tile: a host's primary card, or one of its pinned host+profile cards. A pin is + /// presentation only — same record, same live status, so it is an extra ENTRY here rather than + /// a duplicated host (which would fork pairing, WoL and renames — design §5.2a). + private struct HostGridEntry: Identifiable { + let host: StoredHost + let profile: StreamProfile? + var id: String { "\(host.id.uuidString)#\(profile?.id ?? "")" } + } + + /// Saved hosts, each immediately followed by its pinned cards — so a pin reads as belonging to + /// its host rather than as a stray tile somewhere in the grid. + private var gridEntries: [HostGridEntry] { + store.hosts.flatMap { host in + [HostGridEntry(host: host, profile: nil)] + + profiles.pinned(for: host).map { HostGridEntry(host: host, profile: $0) } + } + } + + private func hostCard(_ host: StoredHost, pinned: StreamProfile?) -> some View { let onBrowseLibrary: (() -> Void)? = libraryEnabled ? { libraryTarget = host } : nil + // A pinned card connects with ITS profile; the primary card follows the binding. + let selection: ProfileSelection = pinned.map { .profile($0.id) } ?? .inherit return HostCardView( host: host, isOnline: discovery.advertises(host) || store.probedOnline.contains(host.id), isConnecting: model.phase == .connecting && model.activeHost?.id == host.id, - isMostRecent: host.id == mostRecentHostID, + // The accent ring marks the most recent HOST; pinned cards stay quiet so the grid + // doesn't grow several "most recent" bars for one machine. + isMostRecent: pinned == nil && host.id == mostRecentHostID, isBusy: model.isBusy, - onConnect: { connect(host) }, + onConnect: { connect(host, selection) }, onPair: { if !model.isBusy { pairingTarget = host } }, onSpeedTest: { if !model.isBusy { speedTestTarget = host } }, onForget: { store.forgetIdentity(host) }, onRemove: { store.remove(host) }, onBrowseLibrary: onBrowseLibrary, onWake: { wake(host) }, - onEdit: { editTarget = host }) + onEdit: { editTarget = host }, + profileMenu: profileMenu(for: host), + pinnedProfile: pinned) + } + + /// The profile affordances every host card carries (§5.2/§5.2a). + private func profileMenu(for host: StoredHost) -> HostProfileMenu { + HostProfileMenu( + profiles: profiles.profiles, + boundID: host.profileID, + pinnedIDs: host.pinnedProfileIDs ?? [], + connectWith: { selection in connect(host, selection) }, + setDefault: { store.setProfile(host.id, profileID: $0) }, + togglePin: { id in + let pinned = (host.pinnedProfileIDs ?? []).contains(id) + store.setPinned(host.id, profileID: id, pinned: !pinned) + }, + copyLink: { profileID in + LinkClipboard.copy(DeepLink.forHost(host, profile: profileID).urlString) + }) } private var discoveredSection: some View { diff --git a/clients/apple/Sources/PunktfunkClient/Home/HostCards.swift b/clients/apple/Sources/PunktfunkClient/Home/HostCards.swift index 1484455d..b96c0018 100644 --- a/clients/apple/Sources/PunktfunkClient/Home/HostCards.swift +++ b/clients/apple/Sources/PunktfunkClient/Home/HostCards.swift @@ -70,8 +70,32 @@ private func monogramTile(_ letter: String, m: CardMetrics, connecting: Bool, fi } } +/// Everything a card's profile affordances need: the catalog to offer, what this host is bound +/// and pinned to, and the acts a menu can perform (design/client-settings-profiles.md §5.2/§5.2a). +/// +/// Passed as one value rather than eight closures because every surface that renders a host card +/// has to offer the SAME set — a menu that quietly lacks "Pin as card" on one screen is how a +/// feature becomes folklore. +struct HostProfileMenu { + var profiles: [StreamProfile] + /// The host's default profile — the chip, and the checkmark in "Connect with ▸". + var boundID: String? + var pinnedIDs: [String] + /// A ONE-OFF connect. Never rebinds: rebinding is `setDefault`, an explicit act (§5.2). + var connectWith: (ProfileSelection) -> Void + var setDefault: (String?) -> Void + var togglePin: (String) -> Void + /// Copy a `punktfunk://` link for this host, optionally carrying a profile. + var copyLink: (String?) -> Void +} + /// A saved host. A left accent bar marks the most-recently-connected one; the context menu /// pairs / speed-tests / forgets / removes. Disabled while a session is busy. +/// +/// The same view renders a PINNED host+profile card (§5.2a): same host, same live status, with +/// the profile as the prominent subtitle. A pinned card is a shortcut, not a second host, so its +/// menu carries only connect-shaped actions — edit/pair/forget/remove stay on the primary card, +/// where the thing they act on actually lives. struct HostCardView: View { let host: StoredHost /// Currently advertising on the LAN (matched against live mDNS discovery). False means @@ -92,6 +116,18 @@ struct HostCardView: View { var onWake: (() -> Void)? = nil /// Open the edit sheet (name / address / port / Wake-on-LAN MAC). var onEdit: (() -> Void)? = nil + /// This card's profile affordances — nil on surfaces that don't offer them. + var profileMenu: HostProfileMenu? = nil + /// Set on a PINNED card: the profile this card connects with. nil = the host's primary card, + /// which follows the binding. + var pinnedProfile: StreamProfile? = nil + + /// The profile this card announces: a pinned card's own, else the host's binding. + private var shownProfile: StreamProfile? { + pinnedProfile ?? profileMenu.flatMap { menu in + menu.boundID.flatMap { id in menu.profiles.first { $0.id == id } } + } + } var body: some View { let m = CardMetrics.current @@ -103,6 +139,9 @@ struct HostCardView: View { .font(.geist(m.name, .bold, relativeTo: .title3)) .foregroundStyle(.primary) .lineLimit(1) + if let profile = shownProfile { + ProfileChip(profile: profile, size: m.status, prominent: pinnedProfile != nil) + } Text("\(host.address):\(String(host.port))") .font(.geist(m.meta, relativeTo: .caption)) .foregroundStyle(.secondary) @@ -138,10 +177,32 @@ struct HostCardView: View { .buttonStyle(.plain) #endif .disabled(isBusy) - .contextMenu { + .contextMenu { menuItems } + } + + @ViewBuilder private var menuItems: some View { + if let pinned = pinnedProfile, let menu = profileMenu { + // A pinned card is a shortcut, not a second host: only connect-shaped actions, plus + // the way to remove the shortcut itself. Unpinning touches neither the profile nor + // the host's default binding. + connectWithMenu(menu) + if LinkClipboard.isAvailable { + Button("Copy Link") { menu.copyLink(pinned.id) } + } + Button("Unpin Card", systemImage: "pin.slash", role: .destructive) { + menu.togglePin(pinned.id) + } + } else { if let onEdit { Button("Edit…", systemImage: "pencil", action: onEdit) } + if let menu = profileMenu { + connectWithMenu(menu) + pinMenu(menu) + if LinkClipboard.isAvailable { + Button("Copy Link") { menu.copyLink(nil) } + } + } Button("Pair with PIN…", action: onPair) Button("Test Network Speed…", action: onSpeedTest) if let onBrowseLibrary { @@ -159,6 +220,57 @@ struct HostCardView: View { } } + /// "Connect with ▸" — a ONE-OFF pick that never rebinds the host, with a checkmark on what a + /// plain click would use. Its last item is the explicit way to rebind, for the users who do + /// want that from here. + @ViewBuilder private func connectWithMenu(_ menu: HostProfileMenu) -> some View { + if !menu.profiles.isEmpty { + Menu("Connect with") { + Button { + menu.connectWith(.defaults) + } label: { + Label( + "Default settings", + systemImage: menu.boundID == nil ? "checkmark" : "") + } + ForEach(menu.profiles) { profile in + Button { + menu.connectWith(.profile(profile.id)) + } label: { + Label( + profile.name, + systemImage: menu.boundID == profile.id ? "checkmark" : "") + } + } + Divider() + Menu("Set Default Profile") { + Button("Default settings") { menu.setDefault(nil) } + ForEach(menu.profiles) { profile in + Button(profile.name) { menu.setDefault(profile.id) } + } + } + } + } + } + + /// "Pin as card ▸" — a host+profile combo gets its own one-click card in the grid, which is + /// what turns a regularly-used profile from a menu dive into a press (§5.2a). + @ViewBuilder private func pinMenu(_ menu: HostProfileMenu) -> some View { + if !menu.profiles.isEmpty { + Menu("Pin as Card") { + ForEach(menu.profiles) { profile in + Button { + menu.togglePin(profile.id) + } label: { + Label( + profile.name, + systemImage: menu.pinnedIDs.contains(profile.id) ? "checkmark" : "") + } + } + } + } + } + /// Technical status line: a square presence pip + monospaced ONLINE/OFFLINE, and PAIRED when a /// certificate is pinned (the lock state, spelled out). @ViewBuilder private func statusRow(_ m: CardMetrics) -> some View { @@ -181,6 +293,47 @@ struct HostCardView: View { } } +/// The profile a card connects with, as a tinted pill. Quiet on a bound primary card (it only +/// answers "what will a click do?"); prominent on a pinned card, where the profile IS the reason +/// the card exists — which is where the catalog's `accent` earns its keep. +struct ProfileChip: View { + let profile: StreamProfile + let size: CGFloat + var prominent = false + + var body: some View { + let tint = profile.accentColor ?? Color.brand + return HStack(spacing: 5) { + Circle() + .fill(tint) + .frame(width: size * 0.55, height: size * 0.55) + .accessibilityHidden(true) // the name is right there + Text(profile.name) + .font(.geist(prominent ? size + 2 : size, .semibold, relativeTo: .caption2)) + .lineLimit(1) + } + .foregroundStyle(tint) + .padding(.horizontal, 7) + .padding(.vertical, 2) + .background(Capsule().fill(tint.opacity(prominent ? 0.22 : 0.13))) + .accessibilityLabel("Profile \(profile.name)") + } +} + +extension StreamProfile { + /// The `#RRGGBB` accent as a colour, or nil when the profile hasn't been given one (the + /// schema reserves the field; every surface falls back to the brand tint). + var accentColor: Color? { + guard let accent, accent.hasPrefix("#"), accent.count == 7, + let value = UInt32(accent.dropFirst(), radix: 16) + else { return nil } + return Color( + red: Double((value >> 16) & 0xFF) / 255, + green: Double((value >> 8) & 0xFF) / 255, + blue: Double(value & 0xFF) / 255) + } +} + /// A host found on the LAN but not yet saved. A tinted-outline monogram + dashed panel border /// distinguish it from saved cards; tapping saves it and connects (or pairs, if required). struct DiscoveredCardView: View { diff --git a/clients/apple/Sources/PunktfunkClient/Home/SpeedTestSheet.swift b/clients/apple/Sources/PunktfunkClient/Home/SpeedTestSheet.swift index 467b2352..3463a564 100644 --- a/clients/apple/Sources/PunktfunkClient/Home/SpeedTestSheet.swift +++ b/clients/apple/Sources/PunktfunkClient/Home/SpeedTestSheet.swift @@ -34,10 +34,10 @@ struct SpeedTestSheet: View { @Environment(\.dismiss) private var dismiss let host: StoredHost - @AppStorage(DefaultsKey.streamWidth) private var width = 1920 - @AppStorage(DefaultsKey.streamHeight) private var height = 1080 - @AppStorage(DefaultsKey.streamHz) private var hz = 60 @AppStorage(DefaultsKey.bitrateKbps) private var bitrateKbps = 0 + /// The catalog, so the Apply button can write to the layer this host actually reads its + /// bitrate from (design/client-settings-profiles.md §5.3). + @ObservedObject private var profiles = ProfileStore.shared private enum Phase: Equatable { case connecting @@ -87,14 +87,7 @@ struct SpeedTestSheet: View { .keyboardShortcut(.cancelAction) #endif if case .done(let result) = phase, let rec = Self.recommendedKbps(result) { - Button("Use \(Self.mbpsLabel(kbps: rec))") { - bitrateKbps = rec - dismiss() - } - .glassProminentButtonStyle() - #if !os(tvOS) - .keyboardShortcut(.defaultAction) - #endif + applyButtons(rec) } if case .failed = phase { Button("Retry") { run() } @@ -122,6 +115,45 @@ struct SpeedTestSheet: View { .onDisappear { token.cancelled = true } } + /// Apply writes to **the layer this host actually resolves its bitrate from** (§5.3) — the + /// sharpest symptom of the missing feature was a per-host measurement overwriting the global. + /// + /// • unbound host → the global, exactly as before; + /// • bound to a profile that already overrides bitrate → that override; + /// • bound to a profile that INHERITS bitrate → both are offered, because either is a + /// defensible answer and guessing would silently pick one. + /// + /// Every button names its target, so what a click changes is never inferred from context. + @ViewBuilder private func applyButtons(_ recommended: Int) -> some View { + let bound = profiles.binding(for: host) + let label = Self.mbpsLabel(kbps: recommended) + if let bound { + Button("Apply to “\(bound.name)”") { + profiles.setOverride(bound.id, \.bitrateKbps, recommended) + dismiss() + } + .glassProminentButtonStyle() + #if !os(tvOS) + .keyboardShortcut(.defaultAction) + #endif + if bound.overrides.bitrateKbps == nil { + Button("Set as default (\(label))") { + bitrateKbps = recommended + dismiss() + } + } + } else { + Button("Use \(label)") { + bitrateKbps = recommended + dismiss() + } + .glassProminentButtonStyle() + #if !os(tvOS) + .keyboardShortcut(.defaultAction) + #endif + } + } + private var phaseIsFinal: Bool { switch phase { case .done, .failed: return true @@ -190,7 +222,12 @@ struct SpeedTestSheet: View { let address = host.address let port = host.port let pin = host.pinnedSHA256 - let (w, h, fps) = (UInt32(clamping: width), UInt32(clamping: height), UInt32(clamping: hz)) + // Probe at the mode this host would actually stream at — its profile's, if it is bound to + // one. The measurement IS the streaming path, so it should be the streaming path's mode. + let mode = EffectiveSettings.resolve(host: host, catalog: profiles.catalog) + let (w, h, fps) = ( + UInt32(clamping: mode.width), UInt32(clamping: mode.height), + UInt32(clamping: mode.refreshHz)) Task.detached(priority: .userInitiated) { // Connect (blocking) — same identity/trust as a session, but TOFU results are // NOT persisted from here. diff --git a/clients/apple/Sources/PunktfunkClient/Intents/SessionShortcuts.swift b/clients/apple/Sources/PunktfunkClient/Intents/SessionShortcuts.swift index 903f7460..7c23c643 100644 --- a/clients/apple/Sources/PunktfunkClient/Intents/SessionShortcuts.swift +++ b/clients/apple/Sources/PunktfunkClient/Intents/SessionShortcuts.swift @@ -1,13 +1,17 @@ -// Siri / Shortcuts / Spotlight surface (design §M4). Deliberately thin: every action already has an -// internal entry point — M0's deep-link router (connect / connect-and-launch), M3's in-process -// end-session hook, and the existing Wake-on-LAN path — so these intents only wrap them. +// Siri / Shortcuts / Spotlight surface (design §M4, extended by client-deep-links.md §6). +// Deliberately thin: every action already has an internal entry point — the deep-link router +// (connect / connect-and-launch / connect-with-a-profile), the in-process end-session hook, and +// the existing Wake-on-LAN path — so these intents only wrap them. // -// Gated os(iOS): the AppShortcutsProvider bundles `EndStreamIntent`, which is a LiveActivityIntent -// (iPhone/iPad only). Connect/Wake themselves are plain AppIntents; they live here with the -// provider rather than being split across platforms. `HostEntity` (the parameter type) is in -// PunktfunkShared so the widget's configuration intent can share it. +// Connect and Wake compile on macOS and tvOS too: AppIntents is genuinely available there +// (macOS 13+ / tvOS 16+), and "Stream Desktop with Work" from Spotlight on a Mac is part of the +// ask. Only the AppShortcutsProvider stays iOS-gated, because it bundles `EndStreamIntent` — a +// LiveActivityIntent, and ActivityKit is iPhone/iPad only. +// +// `HostEntity` and `ProfileEntity` (the parameter types) live in PunktfunkShared so a widget's +// configuration intent, which executes in the EXTENSION process, can share them. -#if os(iOS) +#if canImport(AppIntents) import AppIntents import Foundation import PunktfunkKit @@ -21,9 +25,10 @@ private func loadStoredHost(_ id: UUID) -> StoredHost? { return hosts.first { $0.id == id } } -/// Start a session with a stored host (optionally launching a title). Foregrounds the app and -/// routes through the SAME `.onOpenURL` path a widget tap uses — trust policy, WoL and the approval -/// sheet all apply, and its guards (unknown host, already-streaming) hold. +/// Start a session with a stored host (optionally launching a title, optionally with a settings +/// profile). Foregrounds the app and routes through the SAME `.onOpenURL` path a widget tap uses — +/// trust policy, WoL and the approval sheet all apply, and its guards (unknown host, ambiguous or +/// unknown profile, already-streaming) hold. One router, not a second connect path. struct ConnectToHostIntent: AppIntent { static let title: LocalizedStringResource = "Connect to Host" static let description = IntentDescription("Start a Punktfunk streaming session with a host.") @@ -32,9 +37,13 @@ struct ConnectToHostIntent: AppIntent { @Parameter(title: "Host") var host: HostEntity @Parameter(title: "Game ID", description: "Optional store id like steam:570") var launchID: String? + @Parameter( + title: "Profile", + description: "Which settings profile to use — leave empty for the host's default") + var profile: ProfileEntity? func perform() async throws -> some IntentResult { - let url = DeepLink.connect(host: host.id, launchID: launchID).url + let url = DeepLink.connect(host: host.id, launchID: launchID, profile: profile?.id).url await MainActor.run { NotificationCenter.default.post(name: .punktfunkOpenDeepLink, object: url) } @@ -74,8 +83,11 @@ enum IntentError: Error, CustomLocalizedStringResourceConvertible { } } +#if os(iOS) /// Zero-setup Siri / Spotlight phrases. Parameterized phrases resolve a `HostEntity` by name; stays -/// well under the 10-shortcut cap. +/// well under the 10-shortcut cap. Phrases stay host-parameterized — App Shortcut phrases can't +/// embed a second arbitrary `AppEntity`, so the profile is picked in the Shortcuts editor. No +/// phrase regression. struct PunktfunkShortcuts: AppShortcutsProvider { static var appShortcuts: [AppShortcut] { AppShortcut( @@ -100,3 +112,4 @@ struct PunktfunkShortcuts: AppShortcutsProvider { } } #endif +#endif diff --git a/clients/apple/Sources/PunktfunkClient/Screenshots/ScreenshotScenes.swift b/clients/apple/Sources/PunktfunkClient/Screenshots/ScreenshotScenes.swift index 2b980e82..9133a0f9 100644 --- a/clients/apple/Sources/PunktfunkClient/Screenshots/ScreenshotScenes.swift +++ b/clients/apple/Sources/PunktfunkClient/Screenshots/ScreenshotScenes.swift @@ -114,7 +114,7 @@ private struct ShotHome: View { store: store, model: model, discovery: discovery, showAddHost: .constant(false), pairingTarget: .constant(nil), speedTestTarget: .constant(nil), libraryTarget: .constant(nil), - connect: { _ in }, connectDiscovered: { _ in }, + connect: { _, _ in }, connectDiscovered: { _ in }, onPaired: { _, _ in }, onLaunchTitle: { _, _ in }, wake: { _ in }) #else HomeView( @@ -122,7 +122,7 @@ private struct ShotHome: View { showAddHost: .constant(false), pairingTarget: .constant(nil), speedTestTarget: .constant(nil), libraryTarget: .constant(nil), showSettings: .constant(false), - connect: { _ in }, connectDiscovered: { _ in }, + connect: { _, _ in }, connectDiscovered: { _ in }, onPaired: { _, _ in }, onLaunchTitle: { _, _ in }, wake: { _ in }) #endif } @@ -141,7 +141,7 @@ private struct ShotGamepadHome: View { GamepadHomeView( store: store, model: model, discovery: discovery, libraryTarget: .constant(nil), waker: waker, - connect: { _ in }, connectDiscovered: { _ in }) + connect: { _, _ in }, connectDiscovered: { _ in }) } } @@ -197,7 +197,7 @@ private struct ShotConnect: View { GamepadHomeView( store: store, model: model, discovery: discovery, libraryTarget: .constant(nil), waker: waker, - connect: { _ in }, connectDiscovered: { _ in }) + connect: { _, _ in }, connectDiscovered: { _ in }) } else { ShotHome() } diff --git a/clients/apple/Sources/PunktfunkClient/Session/StreamHUDView.swift b/clients/apple/Sources/PunktfunkClient/Session/StreamHUDView.swift index dc0a3761..80a128da 100644 --- a/clients/apple/Sources/PunktfunkClient/Session/StreamHUDView.swift +++ b/clients/apple/Sources/PunktfunkClient/Session/StreamHUDView.swift @@ -85,6 +85,15 @@ struct StreamHUDView: View { .frame(width: 7, height: 7) Text("\(connection.width)×\(connection.height)@\(connection.refreshHz) \(model.fps) fps \(model.mbps, specifier: "%.1f") Mb/s") .font(.system(.caption, design: .monospaced)) + // Which settings profile this session resolved to, if any (design §5.2). Near-zero + // cost, and it answers "which profile am I on?" without leaving the stream — + // otherwise the only evidence is the settings themselves, which is a guessing game. + if let profile = model.settings.profileName { + Text("· \(profile)") + .font(.system(.caption, design: .monospaced)) + .foregroundStyle(Color.accentColor) + .lineLimit(1) + } } if model.endToEndValid { // Stage-2: the end-to-end headline (capture→on-glass, measured directly, skew- diff --git a/clients/apple/Sources/PunktfunkClient/Support/LinkClipboard.swift b/clients/apple/Sources/PunktfunkClient/Support/LinkClipboard.swift new file mode 100644 index 00000000..c433d5de --- /dev/null +++ b/clients/apple/Sources/PunktfunkClient/Support/LinkClipboard.swift @@ -0,0 +1,34 @@ +// "Copy Link" on a host or pinned card puts a `punktfunk://` URL on the clipboard — the same +// self-emitted form a shortcut or a Playnite entry would carry (design/client-deep-links.md §5): +// stable id first, with the address and pin alongside so it still resolves after the record is +// re-addressed or the store is wiped. +// +// The pasteboard is the one part of that with no cross-platform API, hence this two-line shim. +// tvOS has no clipboard at all, so the affordance simply isn't offered there. + +#if os(macOS) +import AppKit +#elseif os(iOS) +import UIKit +#endif + +enum LinkClipboard { + /// True where a link can actually be copied — the card menus hide the item elsewhere rather + /// than offering an action that silently does nothing. + static var isAvailable: Bool { + #if os(macOS) || os(iOS) + return true + #else + return false + #endif + } + + static func copy(_ text: String) { + #if os(macOS) + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(text, forType: .string) + #elseif os(iOS) + UIPasteboard.general.string = text + #endif + } +} diff --git a/clients/apple/Sources/PunktfunkShared/ProfileEntity.swift b/clients/apple/Sources/PunktfunkShared/ProfileEntity.swift new file mode 100644 index 00000000..ac3595c1 --- /dev/null +++ b/clients/apple/Sources/PunktfunkShared/ProfileEntity.swift @@ -0,0 +1,53 @@ +// A settings profile as an App Intents entity — the parameter type for "Stream with +// " in Shortcuts (design/client-deep-links.md §6). Lives beside `HostEntity` in the +// shared module for the same reason: an intent (and, later, a configurable widget) executes +// outside the app, so the entity and its query must not need PunktfunkKit. +// +// The query reads the App-Group catalog blob directly — no Rust core, no app process — exactly +// like `HostEntityQuery` reads the saved hosts. + +#if canImport(AppIntents) +import AppIntents +import Foundation + +public struct ProfileEntity: AppEntity, Identifiable { + public static let typeDisplayRepresentation = TypeDisplayRepresentation(name: "Settings Profile") + public static let defaultQuery = ProfileEntityQuery() + + /// The catalog id — stable across renames, which is why a shortcut keeps working after the + /// user renames the profile it points at. + public let id: String + public let name: String + /// `#RRGGBB`, when the profile has been given one. Carried so a future display representation + /// (and the configurable widget) can tint without a second lookup. + public let accent: String? + + public init(id: String, name: String, accent: String? = nil) { + self.id = id + self.name = name + self.accent = accent + } + + public init(_ profile: StreamProfile) { + self.init(id: profile.id, name: profile.name, accent: profile.accent) + } + + public var displayRepresentation: DisplayRepresentation { + DisplayRepresentation(title: "\(name)") + } +} + +public struct ProfileEntityQuery: EntityQuery { + public init() {} + + public func entities(for identifiers: [String]) async throws -> [ProfileEntity] { + ProfileCatalog.load().profiles + .filter { identifiers.contains($0.id) } + .map(ProfileEntity.init) + } + + public func suggestedEntities() async throws -> [ProfileEntity] { + ProfileCatalog.load().profiles.map(ProfileEntity.init) + } +} +#endif diff --git a/clients/apple/Sources/PunktfunkShared/StoredHost.swift b/clients/apple/Sources/PunktfunkShared/StoredHost.swift index f0cf17ec..5ca84dd0 100644 --- a/clients/apple/Sources/PunktfunkShared/StoredHost.swift +++ b/clients/apple/Sources/PunktfunkShared/StoredHost.swift @@ -15,7 +15,7 @@ import Foundation /// it without the shared module taking a dependency on the kit. public let punktfunkDefaultMgmtPort: UInt16 = 47990 -public struct StoredHost: Identifiable, Codable, Hashable { +public struct StoredHost: Identifiable, Codable, Hashable, Sendable { public var id = UUID() public var name: String public var address: String