From 333c08724fe287fa19ab70f7e82e53f0da329336 Mon Sep 17 00:00:00 2001 From: Trey T Date: Thu, 16 Apr 2026 21:04:17 -0500 Subject: [PATCH] Redesign UI for iOS 26 Liquid Glass - Fix port-formatting bug: Int interpolation was adding a locale grouping separator ("5,900"); now renders "5900" via portLabel helper. - LiquidGlass helpers: glassSurface/interactiveGlassSurface/glassButton wrap iOS 26's .glassEffect / .buttonStyle(.glass) / scrollEdgeEffectStyle with iOS 18 fallbacks (ultraThinMaterial + stroke) gated by #available. - List: searchable, labeled Bonjour section with "looking for computers" state, empty-state CTA, hover-ready rounded discovery buttons, subtle dark gradient background, connection cards with color swatch + monospaced host:port and chevron. - Session: floating glass back-pill + connection-status pill + toolbar capsule; three-finger tap toggles chrome; disconnect dialog upgraded to a 28pt glass card with role-based glyphs/tints. - Soft keyboard bar redesigned as a rounded glass panel with pill keys. - Add/Edit form: horizontal color-tag picker, show/hide password eye, helpful footers (Tailscale hint, 8-char VNC-password reminder, View-only explainer). - Settings: app-icon-style hero, grouped sections with footers, links to privacy policy and RoyalVNCKit. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../VNCUI/Edit/AddConnectionView.swift | 131 +++++++++++++--- .../Sources/VNCUI/List/ConnectionCard.swift | 39 +++-- .../VNCUI/List/ConnectionListView.swift | 147 ++++++++++++++---- .../VNCUI/Session/SessionToolbar.swift | 109 ++++++++----- .../Sources/VNCUI/Session/SessionView.swift | 127 ++++++++++++--- .../VNCUI/Session/SoftKeyboardBar.swift | 95 ++++++----- .../Sources/VNCUI/Settings/SettingsView.swift | 85 ++++++++-- .../Sources/VNCUI/Style/LiquidGlass.swift | 64 ++++++++ 8 files changed, 619 insertions(+), 178 deletions(-) create mode 100644 Packages/VNCUI/Sources/VNCUI/Style/LiquidGlass.swift diff --git a/Packages/VNCUI/Sources/VNCUI/Edit/AddConnectionView.swift b/Packages/VNCUI/Sources/VNCUI/Edit/AddConnectionView.swift index 68084b1..a143aac 100644 --- a/Packages/VNCUI/Sources/VNCUI/Edit/AddConnectionView.swift +++ b/Packages/VNCUI/Sources/VNCUI/Edit/AddConnectionView.swift @@ -25,6 +25,7 @@ public struct AddConnectionView: View { @State private var host = "" @State private var port = "5900" @State private var password = "" + @State private var revealPassword = false @State private var colorTag: ColorTag = .blue @State private var quality: QualityPreset = .adaptive @State private var inputMode: InputModePreference = .touch @@ -39,30 +40,75 @@ public struct AddConnectionView: View { } private var isEditing: Bool { editing != nil } + private var canSave: Bool { + !displayName.trimmingCharacters(in: .whitespaces).isEmpty && + !host.trimmingCharacters(in: .whitespaces).isEmpty + } public var body: some View { NavigationStack { Form { - Section("Connection") { + Section { TextField("Display name", text: $displayName) + .font(.headline) TextField("Host or IP", text: $host) #if os(iOS) .textInputAutocapitalization(.never) #endif .autocorrectionDisabled() - TextField("Port", text: $port) - #if os(iOS) - .keyboardType(.numberPad) - #endif + .font(.body.monospacedDigit()) + HStack { + Text("Port") + Spacer() + TextField("5900", text: $port) + #if os(iOS) + .keyboardType(.numberPad) + #endif + .multilineTextAlignment(.trailing) + .font(.body.monospacedDigit()) + .frame(maxWidth: 100) + } + } header: { + Text("Connection") + } footer: { + Text("Tailscale IPs and MagicDNS names work as long as the Tailscale app is connected.") } - Section("Authentication") { - SecureField(isEditing ? "Replace password (leave blank to keep current)" : "VNC password", - text: $password) - Text("Stored in iOS Keychain (this device only).") - .font(.caption) - .foregroundStyle(.secondary) + + Section { + HStack { + Group { + if revealPassword { + TextField("VNC password", text: $password) + } else { + SecureField(isEditing ? "Replace password" : "VNC password", + text: $password) + } + } + .font(.body) + Button { + revealPassword.toggle() + } label: { + Image(systemName: revealPassword ? "eye.slash" : "eye") + .foregroundStyle(.secondary) + } + .buttonStyle(.plain) + } + } header: { + Text("Authentication") + } footer: { + Text(isEditing + ? "Leave blank to keep the current password. Stored in iOS Keychain (this device only)." + : "macOS Screen Sharing's VNC password is limited to 8 characters. Stored in iOS Keychain (this device only).") } - Section("Defaults") { + + Section { + colorTagPicker + .listRowInsets(EdgeInsets(top: 4, leading: 16, bottom: 8, trailing: 16)) + } header: { + Text("Tag") + } + + Section { Picker("Default input", selection: $inputMode) { ForEach(InputModePreference.allCases, id: \.self) { mode in Text(mode == .touch ? "Touch" : "Trackpad").tag(mode) @@ -75,23 +121,21 @@ public struct AddConnectionView: View { } Toggle("Sync clipboard", isOn: $clipboardSync) Toggle("View only", isOn: $viewOnly) + } header: { + Text("Defaults") + } footer: { + Text("View-only sends no input to the remote computer — useful as a watchdog screen.") } - Section("Appearance") { - Picker("Color tag", selection: $colorTag) { - ForEach(ColorTag.allCases, id: \.self) { tag in - HStack { - Circle().fill(tag.color).frame(width: 14, height: 14) - Text(tag.rawValue.capitalized) - } - .tag(tag) - } - } - } + Section("Notes") { TextField("Optional", text: $notes, axis: .vertical) .lineLimit(2...6) } } + #if os(iOS) + .scrollContentBackground(.hidden) + #endif + .background(formBackground.ignoresSafeArea()) .navigationTitle(isEditing ? "Edit Connection" : "New Connection") #if os(iOS) .navigationBarTitleDisplayMode(.inline) @@ -102,13 +146,52 @@ public struct AddConnectionView: View { } ToolbarItem(placement: .confirmationAction) { Button(isEditing ? "Save" : "Add") { save() } - .disabled(displayName.isEmpty || host.isEmpty) + .disabled(!canSave) } } .onAppear { loadExistingIfNeeded() } } } + private var formBackground: some View { + LinearGradient( + colors: [ + Color(red: 0.04, green: 0.05, blue: 0.10), + Color(red: 0.02, green: 0.02, blue: 0.05) + ], + startPoint: .top, + endPoint: .bottom + ) + } + + private var colorTagPicker: some View { + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 12) { + ForEach(ColorTag.allCases, id: \.self) { tag in + Button { + colorTag = tag + } label: { + ZStack { + Circle() + .fill(tag.color) + .frame(width: 28, height: 28) + if tag == colorTag { + Circle() + .stroke(Color.primary, lineWidth: 2) + .frame(width: 36, height: 36) + } + } + .frame(width: 40, height: 40) + .accessibilityLabel(tag.rawValue.capitalized) + .accessibilityAddTraits(tag == colorTag ? .isSelected : []) + } + .buttonStyle(.plain) + } + } + .padding(.vertical, 4) + } + } + private func loadExistingIfNeeded() { guard !hasLoadedExisting else { return } hasLoadedExisting = true diff --git a/Packages/VNCUI/Sources/VNCUI/List/ConnectionCard.swift b/Packages/VNCUI/Sources/VNCUI/List/ConnectionCard.swift index e6fa74c..65d6c90 100644 --- a/Packages/VNCUI/Sources/VNCUI/List/ConnectionCard.swift +++ b/Packages/VNCUI/Sources/VNCUI/List/ConnectionCard.swift @@ -5,29 +5,46 @@ struct ConnectionCard: View { let connection: SavedConnection var body: some View { - HStack(spacing: 12) { - Circle() - .fill(connection.colorTag.color) - .frame(width: 14, height: 14) + HStack(spacing: 14) { + ZStack { + Circle() + .fill(connection.colorTag.color.opacity(0.18)) + .frame(width: 38, height: 38) + Circle() + .fill(connection.colorTag.color) + .frame(width: 14, height: 14) + } VStack(alignment: .leading, spacing: 2) { Text(connection.displayName) .font(.headline) - Text("\(connection.host):\(connection.port)") - .font(.caption) + .foregroundStyle(.primary) + .lineLimit(1) + Text("\(connection.host):\(portLabel(connection.port))") + .font(.caption.monospacedDigit()) .foregroundStyle(.secondary) + .lineLimit(1) + .truncationMode(.middle) if connection.viewOnly { - Text("View only") + Label("View only", systemImage: "eye") + .labelStyle(.titleAndIcon) .font(.caption2) .foregroundStyle(.tertiary) } } - Spacer() - if let last = connection.lastConnectedAt { - Text(last, style: .relative) - .font(.caption2) + Spacer(minLength: 8) + VStack(alignment: .trailing, spacing: 4) { + if let last = connection.lastConnectedAt { + Text(last, format: .relative(presentation: .numeric, unitsStyle: .abbreviated)) + .font(.caption2) + .foregroundStyle(.tertiary) + .lineLimit(1) + } + Image(systemName: "chevron.right") + .font(.caption2.weight(.bold)) .foregroundStyle(.tertiary) } } + .padding(.vertical, 6) .contentShape(Rectangle()) } } diff --git a/Packages/VNCUI/Sources/VNCUI/List/ConnectionListView.swift b/Packages/VNCUI/Sources/VNCUI/List/ConnectionListView.swift index f3cf346..c9d9e18 100644 --- a/Packages/VNCUI/Sources/VNCUI/List/ConnectionListView.swift +++ b/Packages/VNCUI/Sources/VNCUI/List/ConnectionListView.swift @@ -13,21 +13,32 @@ public struct ConnectionListView: View { @State private var editingConnection: SavedConnection? @State private var path: [SessionRoute] = [] @State private var resolvingHostID: String? + @State private var search = "" public init() {} public var body: some View { NavigationStack(path: $path) { List { - if !discovery.hosts.isEmpty { + if !discovery.hosts.isEmpty || discovery.isBrowsing { discoveredSection } savedSection } #if os(iOS) .listStyle(.insetGrouped) + .scrollContentBackground(.hidden) #endif + .softScrollEdge() + .background(backgroundGradient.ignoresSafeArea()) .navigationTitle("Screens") + #if os(iOS) + .searchable(text: $search, + placement: .navigationBarDrawer(displayMode: .always), + prompt: "Search connections") + #else + .searchable(text: $search, prompt: "Search connections") + #endif .toolbar { ToolbarItem(placement: .primaryAction) { Button { @@ -49,11 +60,7 @@ public struct ConnectionListView: View { } #else ToolbarItem { - Button { - showingSettings = true - } label: { - Image(systemName: "gear") - } + Button { showingSettings = true } label: { Image(systemName: "gear") } } #endif } @@ -83,44 +90,96 @@ public struct ConnectionListView: View { } } + private var backgroundGradient: some View { + LinearGradient( + colors: [ + Color(red: 0.04, green: 0.05, blue: 0.10), + Color(red: 0.02, green: 0.02, blue: 0.05) + ], + startPoint: .top, + endPoint: .bottom + ) + } + private var discoveredSection: some View { Section { - ForEach(discovery.hosts) { host in - Button { - Task { await prepareDiscoveredHost(host) } - } label: { - HStack { - Image(systemName: "bonjour") - .foregroundStyle(.secondary) - VStack(alignment: .leading) { - Text(host.displayName) - Text(host.serviceType) - .font(.caption2) - .foregroundStyle(.tertiary) - } - Spacer() - if resolvingHostID == host.id { - ProgressView() - } - } + if discovery.hosts.isEmpty { + HStack(spacing: 12) { + ProgressView().controlSize(.small) + Text("Looking for computers on this network…") + .font(.callout) + .foregroundStyle(.secondary) + } + .padding(.vertical, 6) + } else { + ForEach(discovery.hosts) { host in + Button { + Task { await prepareDiscoveredHost(host) } + } label: { + HStack(spacing: 14) { + ZStack { + Circle() + .fill(.tint.opacity(0.15)) + .frame(width: 38, height: 38) + Image(systemName: "wifi") + .font(.callout) + .foregroundStyle(.tint) + } + VStack(alignment: .leading, spacing: 2) { + Text(host.displayName) + .font(.headline) + .foregroundStyle(.primary) + Text(serviceLabel(host.serviceType)) + .font(.caption) + .foregroundStyle(.secondary) + } + Spacer() + if resolvingHostID == host.id { + ProgressView().controlSize(.small) + } else { + Image(systemName: "plus.circle.fill") + .font(.title3) + .foregroundStyle(.tint) + } + } + .padding(.vertical, 4) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .disabled(resolvingHostID != nil) } - .disabled(resolvingHostID != nil) } } header: { - Text("Discovered on this network") + Label("On this network", systemImage: "antenna.radiowaves.left.and.right") + .font(.footnote.weight(.semibold)) + .textCase(nil) + .foregroundStyle(.secondary) } } private var savedSection: some View { Section { - if connections.isEmpty { - ContentUnavailableView( - "No saved connections", - systemImage: "display", - description: Text("Tap + to add a computer to control.") - ) + if filteredConnections.isEmpty { + if connections.isEmpty { + ContentUnavailableView { + Label("No saved connections", systemImage: "display") + } description: { + Text("Tap the + button above to add a Mac, Linux box, or Raspberry Pi.") + } actions: { + Button { + addPrefill = nil + showingAdd = true + } label: { + Label("Add a computer", systemImage: "plus") + } + .glassButton(prominent: true) + } + .padding(.vertical, 10) + } else { + ContentUnavailableView.search(text: search) + } } else { - ForEach(connections) { connection in + ForEach(filteredConnections) { connection in NavigationLink(value: SessionRoute(connectionID: connection.id)) { ConnectionCard(connection: connection) } @@ -161,7 +220,27 @@ public struct ConnectionListView: View { } } } header: { - Text("Saved") + Label("Saved", systemImage: "bookmark") + .font(.footnote.weight(.semibold)) + .textCase(nil) + .foregroundStyle(.secondary) + } + } + + private var filteredConnections: [SavedConnection] { + let trimmed = search.trimmingCharacters(in: .whitespaces) + guard !trimmed.isEmpty else { return connections } + return connections.filter { connection in + connection.displayName.localizedCaseInsensitiveContains(trimmed) || + connection.host.localizedCaseInsensitiveContains(trimmed) + } + } + + private func serviceLabel(_ raw: String) -> String { + switch raw { + case "_rfb._tcp": return "VNC (Screen Sharing)" + case "_workstation._tcp": return "Apple workstation" + default: return raw } } diff --git a/Packages/VNCUI/Sources/VNCUI/Session/SessionToolbar.swift b/Packages/VNCUI/Sources/VNCUI/Session/SessionToolbar.swift index 8938b7e..d52c651 100644 --- a/Packages/VNCUI/Sources/VNCUI/Session/SessionToolbar.swift +++ b/Packages/VNCUI/Sources/VNCUI/Session/SessionToolbar.swift @@ -10,55 +10,94 @@ struct SessionToolbar: View { var onDisconnect: () -> Void var body: some View { - HStack(spacing: 12) { - Picker("Input Mode", selection: $inputMode) { - Text("Touch").tag(InputMode.touch) - Text("Trackpad").tag(InputMode.trackpad) - } - .pickerStyle(.segmented) - .fixedSize() + HStack(spacing: 10) { + modePicker + .frame(maxWidth: 180) - Button { + iconButton(systemName: "keyboard", + label: "Toggle keyboard bar", + isOn: showKeyboardBar) { showKeyboardBar.toggle() - } label: { - Image(systemName: "keyboard") } - .accessibilityLabel("Toggle keyboard bar") if controller.screens.count > 1 { - Menu { - Button("All screens") { selectedScreenID = nil } - ForEach(controller.screens) { screen in - Button { - selectedScreenID = screen.id - } label: { - Text("Screen \(screen.id) (\(Int(screen.frame.width))×\(Int(screen.frame.height)))") - } - } - } label: { - Image(systemName: "rectangle.on.rectangle") - } - .accessibilityLabel("Choose monitor") + screenMenu } - Button { + iconButton(systemName: "camera", + label: "Take screenshot") { onScreenshot() - } label: { - Image(systemName: "camera") } - .accessibilityLabel("Screenshot") - Spacer() + Spacer(minLength: 0) - Button(role: .destructive) { + iconButton(systemName: "xmark", + label: "Disconnect", + tint: .red) { onDisconnect() - } label: { - Label("Disconnect", systemImage: "xmark.circle.fill") - .labelStyle(.iconOnly) } } + .padding(.horizontal, 14) + .padding(.vertical, 8) + .glassSurface(in: Capsule()) .padding(.horizontal, 12) - .padding(.vertical, 6) - .background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 12, style: .continuous)) + } + + private var modePicker: some View { + Picker("Input mode", selection: $inputMode) { + Image(systemName: "hand.tap.fill") + .accessibilityLabel("Touch") + .tag(InputMode.touch) + Image(systemName: "rectangle.and.hand.point.up.left.filled") + .accessibilityLabel("Trackpad") + .tag(InputMode.trackpad) + } + .pickerStyle(.segmented) + } + + private var screenMenu: some View { + Menu { + Button { + selectedScreenID = nil + } label: { + Label("All screens", + systemImage: selectedScreenID == nil ? "checkmark" : "rectangle.on.rectangle") + } + ForEach(controller.screens) { screen in + Button { + selectedScreenID = screen.id + } label: { + Label( + "Screen \(String(screen.id)) \(Int(screen.frame.width))×\(Int(screen.frame.height))", + systemImage: selectedScreenID == screen.id ? "checkmark" : "display" + ) + } + } + } label: { + iconBadge(systemName: "rectangle.on.rectangle", tint: .primary) + } + .accessibilityLabel("Choose monitor") + } + + private func iconButton(systemName: String, + label: String, + isOn: Bool = false, + tint: Color = .primary, + action: @escaping () -> Void) -> some View { + Button(action: action) { + iconBadge(systemName: systemName, tint: tint, isOn: isOn) + } + .accessibilityLabel(label) + .buttonStyle(.plain) + } + + private func iconBadge(systemName: String, tint: Color = .primary, isOn: Bool = false) -> some View { + Image(systemName: systemName) + .font(.callout.weight(.semibold)) + .foregroundStyle(tint == .red ? Color.red : tint) + .frame(width: 34, height: 34) + .background( + Circle().fill(isOn ? tint.opacity(0.20) : Color.clear) + ) } } diff --git a/Packages/VNCUI/Sources/VNCUI/Session/SessionView.swift b/Packages/VNCUI/Sources/VNCUI/Session/SessionView.swift index 4856340..60107bd 100644 --- a/Packages/VNCUI/Sources/VNCUI/Session/SessionView.swift +++ b/Packages/VNCUI/Sources/VNCUI/Session/SessionView.swift @@ -21,6 +21,7 @@ public struct SessionView: View { @State private var trackpadCursor: CGPoint = CGPoint(x: 0.5, y: 0.5) @State private var selectedScreenID: UInt32? @State private var screenshotItem: ScreenshotShareItem? + @State private var chromeVisible = true public init(connection: SavedConnection) { self.connection = connection @@ -38,6 +39,9 @@ public struct SessionView: View { trackpadCursor: $trackpadCursor ) .ignoresSafeArea() + .onTapGesture(count: 3) { + withAnimation(.snappy(duration: 0.22)) { chromeVisible.toggle() } + } if inputMode == .trackpad, let size = controller.framebufferSize { @@ -50,7 +54,11 @@ public struct SessionView: View { } statusOverlay(for: controller.state, controller: controller) - overlayChrome(controller: controller) + + if chromeVisible { + overlayChrome(controller: controller) + .transition(.opacity.combined(with: .move(edge: .top))) + } } else { ProgressView("Preparing session…") .tint(.white) @@ -60,8 +68,7 @@ public struct SessionView: View { .navigationTitle(controller?.desktopName ?? connection.displayName) #if os(iOS) .navigationBarTitleDisplayMode(.inline) - #endif - #if os(iOS) + .toolbar(.hidden, for: .navigationBar) .toolbar(.hidden, for: .tabBar) #endif .task(id: connection.id) { @@ -83,7 +90,26 @@ public struct SessionView: View { @ViewBuilder private func overlayChrome(controller: SessionController) -> some View { - VStack { + VStack(spacing: 8) { + HStack(spacing: 10) { + Button { + stopAndDismiss() + } label: { + Image(systemName: "chevron.left") + .font(.callout.weight(.semibold)) + .frame(width: 34, height: 34) + } + .buttonStyle(.plain) + .glassSurface(in: Circle()) + .accessibilityLabel("Back") + + connectionLabel(controller: controller) + + Spacer() + } + .padding(.horizontal, 12) + .padding(.top, 4) + SessionToolbar( controller: controller, inputMode: $inputMode, @@ -92,18 +118,35 @@ public struct SessionView: View { onScreenshot: { takeScreenshot(controller: controller) }, onDisconnect: { stopAndDismiss() } ) - .padding(.horizontal, 12) - .padding(.top, 6) + Spacer() + if showKeyboardBar { SoftKeyboardBar(controller: controller, isExpanded: $showFunctionRow) - .padding(.horizontal, 12) .padding(.bottom, 12) .transition(.move(edge: .bottom).combined(with: .opacity)) } } } + @ViewBuilder + private func connectionLabel(controller: SessionController) -> some View { + VStack(alignment: .leading, spacing: 0) { + Text(controller.desktopName ?? connection.displayName) + .font(.headline) + .foregroundStyle(.white) + .lineLimit(1) + if let size = controller.framebufferSize { + Text("\(size.width)×\(size.height)") + .font(.caption2.monospacedDigit()) + .foregroundStyle(.white.opacity(0.6)) + } + } + .padding(.horizontal, 14) + .padding(.vertical, 6) + .glassSurface(in: Capsule()) + } + @ViewBuilder private func statusOverlay(for state: SessionState, controller: SessionController) -> some View { @@ -126,45 +169,49 @@ public struct SessionView: View { @ViewBuilder private func disconnectedOverlay(reason: DisconnectReason, controller: SessionController) -> some View { - VStack(spacing: 12) { - Image(systemName: "exclamationmark.triangle.fill") - .font(.largeTitle) - .foregroundStyle(.yellow) - Text("Disconnected") + VStack(spacing: 14) { + Image(systemName: glyph(for: reason)) + .font(.system(size: 36, weight: .semibold)) + .foregroundStyle(tint(for: reason)) + Text(headline(for: reason)) .font(.headline) .foregroundStyle(.white) Text(humanReadable(reason: reason, lastError: controller.lastErrorMessage)) .font(.callout) - .foregroundStyle(.secondary) + .foregroundStyle(.white.opacity(0.75)) .multilineTextAlignment(.center) .padding(.horizontal, 24) - HStack { + HStack(spacing: 12) { Button { controller.reconnectNow() } label: { Label("Reconnect", systemImage: "arrow.clockwise") } - .buttonStyle(.borderedProminent) + .glassButton(prominent: true) Button("Close") { stopAndDismiss() } - .buttonStyle(.bordered) + .glassButton() } if controller.isReconnecting { - ProgressView("Reconnecting attempt \(controller.reconnectAttempt)…") - .tint(.white) - .foregroundStyle(.white) + HStack(spacing: 8) { + ProgressView().controlSize(.small) + Text("Retrying… attempt \(controller.reconnectAttempt)") + .font(.caption) + .foregroundStyle(.white.opacity(0.7)) + } } } - .padding(24) - .background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 16)) + .padding(28) + .frame(maxWidth: 360) + .glassSurface(in: RoundedRectangle(cornerRadius: 28, style: .continuous)) } @ViewBuilder private func messageOverlay(@ViewBuilder _ content: () -> V) -> some View { content() - .padding(20) - .background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 12)) + .padding(22) + .glassSurface(in: RoundedRectangle(cornerRadius: 22, style: .continuous)) } private func selectedScreen(for controller: SessionController) -> RemoteScreen? { @@ -174,7 +221,7 @@ public struct SessionView: View { private func startSession() async { if controller == nil { - inputMode = InputMode(rawValue: defaultInputModeRaw) ?? .touch + inputMode = preferredInputMode() let provider = DefaultPasswordProvider() let controller = SessionController(connection: connection, passwordProvider: provider) @@ -183,6 +230,13 @@ public struct SessionView: View { } } + private func preferredInputMode() -> InputMode { + switch connection.inputMode { + case .trackpad: return .trackpad + case .touch: return InputMode(rawValue: defaultInputModeRaw) ?? .touch + } + } + private func stopAndDismiss() { controller?.stop() persistLastConnected() @@ -204,6 +258,31 @@ public struct SessionView: View { } } + private func glyph(for reason: DisconnectReason) -> String { + switch reason { + case .userRequested: "checkmark.circle" + case .authenticationFailed: "lock.trianglebadge.exclamationmark" + case .networkError, .remoteClosed: "wifi.exclamationmark" + case .protocolError: "exclamationmark.triangle" + } + } + + private func tint(for reason: DisconnectReason) -> Color { + switch reason { + case .userRequested: .green + case .authenticationFailed: .yellow + default: .orange + } + } + + private func headline(for reason: DisconnectReason) -> String { + switch reason { + case .userRequested: "Session ended" + case .authenticationFailed: "Authentication failed" + default: "Disconnected" + } + } + private func takeScreenshot(controller: SessionController) { #if canImport(UIKit) guard let cgImage = controller.currentImage else { return } diff --git a/Packages/VNCUI/Sources/VNCUI/Session/SoftKeyboardBar.swift b/Packages/VNCUI/Sources/VNCUI/Session/SoftKeyboardBar.swift index 8d0af11..836f9aa 100644 --- a/Packages/VNCUI/Sources/VNCUI/Session/SoftKeyboardBar.swift +++ b/Packages/VNCUI/Sources/VNCUI/Session/SoftKeyboardBar.swift @@ -7,60 +7,79 @@ struct SoftKeyboardBar: View { var body: some View { VStack(spacing: 8) { - HStack(spacing: 12) { + HStack(spacing: 8) { + key("esc", wide: false) { controller.sendEscape() } + key("tab", wide: false) { controller.sendTab() } + key("⏎", wide: false) { controller.sendReturn() } + iconKey("delete.left") { controller.sendBackspace() } + Spacer(minLength: 6) + arrowCluster Button { - withAnimation { isExpanded.toggle() } + withAnimation(.snappy(duration: 0.22)) { isExpanded.toggle() } } label: { - Label("Function keys", - systemImage: isExpanded ? "chevron.down" : "chevron.up") - .labelStyle(.iconOnly) + Image(systemName: isExpanded ? "chevron.down.circle.fill" : "fn") + .font(.callout.weight(.semibold)) } - Button("Esc") { controller.sendEscape() } - Button("Tab") { controller.sendTab() } - Button("⏎") { controller.sendReturn() } - Button(action: { controller.sendBackspace() }) { - Image(systemName: "delete.left") - } - Spacer() - arrowButtons + .buttonStyle(.plain) + .frame(width: 36, height: 32) + .accessibilityLabel(isExpanded ? "Collapse function keys" : "Expand function keys") } - .buttonStyle(.bordered) - .controlSize(.small) if isExpanded { - HStack { + HStack(spacing: 6) { ForEach(1...12, id: \.self) { idx in Button("F\(idx)") { controller.sendFunctionKey(idx) } - .buttonStyle(.bordered) - .controlSize(.mini) + .buttonStyle(.plain) + .font(.caption.weight(.medium)) + .frame(maxWidth: .infinity) + .frame(height: 28) + .background( + RoundedRectangle(cornerRadius: 8, style: .continuous) + .fill(Color.primary.opacity(0.08)) + ) } } } } .padding(.horizontal, 12) - .padding(.vertical, 6) - .background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 12, style: .continuous)) + .padding(.vertical, 10) + .glassSurface(in: RoundedRectangle(cornerRadius: 22, style: .continuous)) + .padding(.horizontal, 12) } - private var arrowButtons: some View { - HStack(spacing: 4) { - Button(action: { controller.sendArrow(.left) }) { - Image(systemName: "arrow.left") - } - VStack(spacing: 2) { - Button(action: { controller.sendArrow(.up) }) { - Image(systemName: "arrow.up") - } - Button(action: { controller.sendArrow(.down) }) { - Image(systemName: "arrow.down") - } - } - Button(action: { controller.sendArrow(.right) }) { - Image(systemName: "arrow.right") - } + private func key(_ label: String, wide: Bool, action: @escaping () -> Void) -> some View { + Button(action: action) { + Text(label) + .font(.callout.weight(.medium)) + .frame(width: wide ? 64 : 44, height: 32) + .background( + RoundedRectangle(cornerRadius: 10, style: .continuous) + .fill(Color.primary.opacity(0.10)) + ) + } + .buttonStyle(.plain) + } + + private func iconKey(_ systemName: String, action: @escaping () -> Void) -> some View { + Button(action: action) { + Image(systemName: systemName) + .font(.callout.weight(.medium)) + .frame(width: 44, height: 32) + .background( + RoundedRectangle(cornerRadius: 10, style: .continuous) + .fill(Color.primary.opacity(0.10)) + ) + } + .buttonStyle(.plain) + } + + private var arrowCluster: some View { + HStack(spacing: 4) { + iconKey("arrow.left") { controller.sendArrow(.left) } + iconKey("arrow.up") { controller.sendArrow(.up) } + iconKey("arrow.down") { controller.sendArrow(.down) } + iconKey("arrow.right") { controller.sendArrow(.right) } } - .buttonStyle(.bordered) - .controlSize(.small) } } diff --git a/Packages/VNCUI/Sources/VNCUI/Settings/SettingsView.swift b/Packages/VNCUI/Sources/VNCUI/Settings/SettingsView.swift index 8eca557..7d7cec5 100644 --- a/Packages/VNCUI/Sources/VNCUI/Settings/SettingsView.swift +++ b/Packages/VNCUI/Sources/VNCUI/Settings/SettingsView.swift @@ -14,30 +14,80 @@ public struct SettingsView: View { public var body: some View { NavigationStack { Form { - Section("Input") { + Section { + HStack(spacing: 14) { + ZStack { + RoundedRectangle(cornerRadius: 14, style: .continuous) + .fill(LinearGradient( + colors: [.blue, .purple], + startPoint: .topLeading, + endPoint: .bottomTrailing + )) + .frame(width: 56, height: 56) + Image(systemName: "display") + .font(.title.weight(.semibold)) + .foregroundStyle(.white) + } + VStack(alignment: .leading, spacing: 2) { + Text("Screens") + .font(.headline) + Text("Version \(Self.shortVersion) (\(Self.buildNumber))") + .font(.caption) + .foregroundStyle(.secondary) + } + Spacer() + } + .padding(.vertical, 4) + } + + Section { Picker("Default input mode", selection: $defaultInputModeRaw) { Text("Touch").tag("touch") Text("Trackpad").tag("trackpad") } + } header: { + Text("Input") + } footer: { + Text("Each saved connection can override this.") } - Section("Connection") { + + Section { Toggle("Auto-reconnect on drop", isOn: $autoReconnect) + } header: { + Text("Connection") + } footer: { + Text("Retries with jittered exponential backoff up to ~30 seconds.") } - Section("Privacy") { - Toggle("Sync clipboard with remote (default)", isOn: $clipboardSyncDefault) - Text("Each connection can override this; secrets never sync to iCloud.") - .font(.caption) - .foregroundStyle(.secondary) + + Section { + Toggle("Sync clipboard with remote", isOn: $clipboardSyncDefault) + } header: { + Text("Privacy") + } footer: { + Text("Each connection can override. Passwords are stored in iOS Keychain on this device only — they never sync to iCloud.") } - Section("Display") { + + Section { Toggle("Reduce motion in session", isOn: $reduceMotion) + } header: { + Text("Display") } - Section("About") { - LabeledContent("Version", value: Self.shortVersion) - LabeledContent("Build", value: Self.buildNumber) - Link("Privacy policy", destination: URL(string: "https://example.com/privacy")!) + + Section { + Link(destination: URL(string: "https://example.com/privacy")!) { + Label("Privacy policy", systemImage: "hand.raised") + } + Link(destination: URL(string: "https://github.com/royalapplications/royalvnc")!) { + Label("RoyalVNCKit on GitHub", systemImage: "link") + } + } header: { + Text("About") } } + #if os(iOS) + .scrollContentBackground(.hidden) + #endif + .background(formBackground.ignoresSafeArea()) .navigationTitle("Settings") #if os(iOS) .navigationBarTitleDisplayMode(.inline) @@ -50,6 +100,17 @@ public struct SettingsView: View { } } + private var formBackground: some View { + LinearGradient( + colors: [ + Color(red: 0.04, green: 0.05, blue: 0.10), + Color(red: 0.02, green: 0.02, blue: 0.05) + ], + startPoint: .top, + endPoint: .bottom + ) + } + private static var shortVersion: String { Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "0.0" } diff --git a/Packages/VNCUI/Sources/VNCUI/Style/LiquidGlass.swift b/Packages/VNCUI/Sources/VNCUI/Style/LiquidGlass.swift new file mode 100644 index 0000000..17abcbc --- /dev/null +++ b/Packages/VNCUI/Sources/VNCUI/Style/LiquidGlass.swift @@ -0,0 +1,64 @@ +import SwiftUI + +extension View { + /// Applies the iOS 26 Liquid Glass effect when available, falling back to + /// `ultraThinMaterial` on earlier OSes. Pass a clipping shape so the glass + /// has the right silhouette. + @ViewBuilder + func glassSurface(in shape: S = RoundedRectangle(cornerRadius: 22, style: .continuous)) -> some View { + if #available(iOS 26.0, macOS 26.0, *) { + self.glassEffect(.regular, in: shape) + } else { + self + .background(.ultraThinMaterial, in: shape) + .overlay(shape.stroke(Color.white.opacity(0.08), lineWidth: 1)) + } + } + + /// Interactive version of `glassSurface` — pressed states animate. + @ViewBuilder + func interactiveGlassSurface(in shape: S = Capsule()) -> some View { + if #available(iOS 26.0, macOS 26.0, *) { + self.glassEffect(.regular.interactive(), in: shape) + } else { + self + .background(.thinMaterial, in: shape) + .overlay(shape.stroke(Color.white.opacity(0.10), lineWidth: 1)) + } + } + + /// Replaces a button's chrome with a Liquid Glass capsule when available. + @ViewBuilder + func glassButton(prominent: Bool = false) -> some View { + if #available(iOS 26.0, macOS 26.0, *) { + if prominent { + self.buttonStyle(.glassProminent) + } else { + self.buttonStyle(.glass) + } + } else { + if prominent { + self.buttonStyle(.borderedProminent) + } else { + self.buttonStyle(.bordered) + } + } + } + + /// Soft top-edge fade so content slips under the floating toolbar without + /// a hard cutoff. + @ViewBuilder + func softScrollEdge() -> some View { + if #available(iOS 26.0, macOS 26.0, *) { + self.scrollEdgeEffectStyle(.soft, for: .top) + } else { + self + } + } +} + +/// Pretty-prints a port number without locale grouping ("5900", not "5,900"). +@inlinable +func portLabel(_ port: Int) -> String { + String(port) +}