From 25b127803f3a57c2d2187cb8a9bb642463720da7 Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Wed, 29 Jul 2026 01:28:32 +0200 Subject: [PATCH] feat(client/apple): bind a host to a profile, connect with one, pin one as its own card MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The host surfaces (design §5.2/§5.2a). A bound host wears a tinted chip that says what a click will do. The card menu grows "Connect with ▸" — a ONE-OFF that never rebinds, with a checkmark on the binding and an explicit "Set Default Profile" as its last item for the users who do want to rebind from there — plus "Pin as Card ▸" and "Copy Link". A pinned host+profile combo becomes its own card next to its host: same record, same live status, the profile as the prominent subtitle, one click to connect. It is an extra grid ENTRY, never a duplicated host record — duplicating would fork pairing, Wake-on-LAN and renames. Its menu carries only connect-shaped actions; edit, pair, forget and remove stay on the primary card, where the thing they act on lives. On the gamepad carousel and tvOS the same pins are tiles, which is the whole point: focus-and-press is what those surfaces do well, and menus are not. The edit sheet binds and pins; both rows vanish when no profiles exist, so a user who never makes one sees exactly today's sheet. A dangling binding renders as "Default settings (profile deleted)" and is cleaned up on save. The speed test finally writes where the tested host reads. Unbound: the global, as before. Bound to a profile that overrides bitrate: that override. Bound to one that inherits it: both are offered, because either is defensible and guessing would silently pick one. Every button names its target, and the probe now runs at the mode that host would actually stream — the measurement is the streaming path. Shortcuts gains `ProfileEntity` over the App-Group catalog and a Profile parameter on Connect, still round-tripping through the URL router rather than opening a second connect path. Connect and Wake drop their iOS wall — AppIntents is real on macOS and tvOS, and "Stream Desktop with Work" from Spotlight was the ask; only the phrases provider stays iOS-gated, since it bundles the LiveActivityIntent. The deep-link observer moved out with them: an intent that posts to nobody is a shortcut that silently does nothing. Co-Authored-By: Claude Opus 5 (1M context) --- .../Sources/PunktfunkClient/ContentView.swift | 19 ++- .../PunktfunkClient/Home/AddHostSheet.swift | 64 +++++++- .../Home/GamepadHomeView.swift | 62 ++++--- .../PunktfunkClient/Home/HomeView.swift | 60 ++++++- .../PunktfunkClient/Home/HostCards.swift | 155 +++++++++++++++++- .../PunktfunkClient/Home/SpeedTestSheet.swift | 61 +++++-- .../Intents/SessionShortcuts.swift | 39 +++-- .../Screenshots/ScreenshotScenes.swift | 8 +- .../Session/StreamHUDView.swift | 9 + .../Support/LinkClipboard.swift | 34 ++++ .../PunktfunkShared/ProfileEntity.swift | 53 ++++++ .../Sources/PunktfunkShared/StoredHost.swift | 2 +- 12 files changed, 499 insertions(+), 67 deletions(-) create mode 100644 clients/apple/Sources/PunktfunkClient/Support/LinkClipboard.swift create mode 100644 clients/apple/Sources/PunktfunkShared/ProfileEntity.swift 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