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) <noreply@anthropic.com>
This commit is contained in:
Trey T
2026-04-16 21:04:17 -05:00
parent 8e01068ad3
commit 333c08724f
8 changed files with 619 additions and 178 deletions

View File

@@ -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

View File

@@ -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())
}
}

View File

@@ -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
}
}

View File

@@ -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)
)
}
}

View File

@@ -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<V: View>(@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 }

View File

@@ -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)
}
}

View File

@@ -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"
}

View File

@@ -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<S: Shape>(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<S: Shape>(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)
}