From c711e136d43c72c72c5cdee1d7b296a128e670e5 Mon Sep 17 00:00:00 2001 From: saphid <4596216+saphid@users.noreply.github.com> Date: Sun, 27 Sep 2026 18:16:14 +1000 Subject: [PATCH] iPhone app: a first screen that says exactly what to do MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The app finds the Frame by itself (Valve's _steamos-devkit._tcp Bonjour service, else the saved address or frame.local on port 22), so setup is two numbered steps: wake your Frame (Looking… / Found ✓, and after a few seconds exactly what to check), then the Developer Mode password and Connect. Manual address and key options sit under Other ways to connect. A paired Frame that doesn't answer is almost always asleep: instead of an error, Waiting for your Frame says how to wake it and connects as soon as its SSH port answers (checked every 3 s; 4 s after the stand-in came back). Co-Authored-By: Claude Opus 5.5 (1M context) --- ios/FrameControl.xcodeproj/project.pbxproj | 4 + ios/FrameControl/App/AppModel.swift | 2 +- ios/FrameControl/Info.plist | 6 +- ios/FrameControl/SSH/FrameFinder.swift | 125 +++++++++++ ios/FrameControl/Views/RootView.swift | 46 ++++ ios/FrameControl/Views/SetupView.swift | 239 +++++++++++++++------ ios/project.yml | 3 +- 7 files changed, 360 insertions(+), 65 deletions(-) create mode 100644 ios/FrameControl/SSH/FrameFinder.swift diff --git a/ios/FrameControl.xcodeproj/project.pbxproj b/ios/FrameControl.xcodeproj/project.pbxproj index 441e5a1..d23f1b8 100644 --- a/ios/FrameControl.xcodeproj/project.pbxproj +++ b/ios/FrameControl.xcodeproj/project.pbxproj @@ -19,6 +19,7 @@ 9657F7BC23E3352E5AB30777 /* SetupView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB544223FC60A59CC3E8EF5F /* SetupView.swift */; }; A8C7AED25A6280682FCE45DC /* Citadel in Frameworks */ = {isa = PBXBuildFile; productRef = 6BA549B6CC0A0CB847126456 /* Citadel */; }; DC043FB74BE2D23F3A5826BF /* FrameControlTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1740B691F9C25E5FB6F9EFC3 /* FrameControlTests.swift */; }; + E6898C714A92D3979F73B6E1 /* FrameFinder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2F288DF6636A417F0CA3A6CD /* FrameFinder.swift */; }; F94D0252F8CC5854314B84B2 /* AppModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8A11F3431826A26B247C0695 /* AppModel.swift */; }; /* End PBXBuildFile section */ @@ -36,6 +37,7 @@ 16644E7FDA7ADD5B232EB700 /* FrameLink.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FrameLink.swift; sourceTree = ""; }; 1740B691F9C25E5FB6F9EFC3 /* FrameControlTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FrameControlTests.swift; sourceTree = ""; }; 237D9AF04EEA257AB382F60E /* Keys.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Keys.swift; sourceTree = ""; }; + 2F288DF6636A417F0CA3A6CD /* FrameFinder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FrameFinder.swift; sourceTree = ""; }; 6B5B6718C5EA77FA67F6B14C /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = ""; }; 6FBC8D0B5ED7BF1C06F99892 /* FrameControlTests.xctest */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.cfbundle; path = FrameControlTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 8A11F3431826A26B247C0695 /* AppModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppModel.swift; sourceTree = ""; }; @@ -74,6 +76,7 @@ 5064A5B6FE5B17B18E5FA4B8 /* SSH */ = { isa = PBXGroup; children = ( + 2F288DF6636A417F0CA3A6CD /* FrameFinder.swift */, 16644E7FDA7ADD5B232EB700 /* FrameLink.swift */, EDC7BA8014DBC302D08FD397 /* HeadsetServer.swift */, 237D9AF04EEA257AB382F60E /* Keys.swift */, @@ -255,6 +258,7 @@ files = ( F94D0252F8CC5854314B84B2 /* AppModel.swift in Sources */, 78427FC66780623F31E7501E /* FrameControlApp.swift in Sources */, + E6898C714A92D3979F73B6E1 /* FrameFinder.swift in Sources */, 12B21D3319BAF5AE79948560 /* FrameLink.swift in Sources */, 0DEE50BD563B1D8C328C4C0A /* HeadsetServer.swift in Sources */, 4622FE0F0D6499CD642C29A2 /* InstallLink.swift in Sources */, diff --git a/ios/FrameControl/App/AppModel.swift b/ios/FrameControl/App/AppModel.swift index 7308e3f..94e25fa 100644 --- a/ios/FrameControl/App/AppModel.swift +++ b/ios/FrameControl/App/AppModel.swift @@ -92,7 +92,7 @@ final class AppModel: ObservableObject { } /// "host", "host:port" or "[v6]:port", plus a user name. - static func parse(host: String, user: String) -> FrameSettings? { + nonisolated static func parse(host: String, user: String) -> FrameSettings? { var target = FrameSettings(host: host.trimmingCharacters(in: .whitespaces), user: user.trimmingCharacters(in: .whitespaces)) if target.host.hasPrefix("["), let close = target.host.firstIndex(of: "]") { let rest = target.host[target.host.index(after: close)...] diff --git a/ios/FrameControl/Info.plist b/ios/FrameControl/Info.plist index 210d075..40018d4 100644 --- a/ios/FrameControl/Info.plist +++ b/ios/FrameControl/Info.plist @@ -43,8 +43,12 @@ NSAllowsLocalNetworking + NSBonjourServices + + _steamos-devkit._tcp + NSLocalNetworkUsageDescription - Frame Control connects to your Steam Frame over your local network with SSH. + Frame Control finds your Steam Frame on your network and connects to it. UILaunchScreen UIColorName diff --git a/ios/FrameControl/SSH/FrameFinder.swift b/ios/FrameControl/SSH/FrameFinder.swift new file mode 100644 index 0000000..55bab85 --- /dev/null +++ b/ios/FrameControl/SSH/FrameFinder.swift @@ -0,0 +1,125 @@ +import Foundation +import Network + +/// Finds the Frame on the local network so nobody has to type its address. +/// A Frame in Developer Mode advertises Valve's devkit service over Bonjour +/// (`_steamos-devkit._tcp`); failing that, `fallback` (the saved address, or +/// frame.local) is checked by opening its SSH port. Both repeat until stopped. +@MainActor +final class FrameFinder: ObservableObject { + struct Found: Equatable { + let host: String // what to connect to + let name: String // what to call it + } + + @Published private(set) var found: Found? + /// When the search began, to tell "still looking" from "can't find it". + @Published private(set) var since = Date() + + private var browser: NWBrowser? + private var probeTask: Task? + private var fallback = "frame.local" + + func start(fallback: String?) { + stop() + self.fallback = (fallback?.isEmpty == false ? fallback : nil) ?? "frame.local" + found = nil + since = Date() + browse() + probeTask = Task { [weak self] in + while !Task.isCancelled { + guard let self else { return } + let host = self.fallback + if self.found == nil, await Self.sshAnswers(host: host) { + self.found = Found(host: host, name: host) + } + try? await Task.sleep(nanoseconds: 3_000_000_000) + } + } + } + + func stop() { + browser?.cancel() + browser = nil + probeTask?.cancel() + probeTask = nil + } + + private func browse() { + let browser = NWBrowser(for: .bonjour(type: "_steamos-devkit._tcp", domain: nil), using: .tcp) + browser.browseResultsChangedHandler = { [weak self] results, _ in + for result in results { + guard case let .service(name, _, _, _) = result.endpoint else { continue } + Self.resolve(result.endpoint) { host in + Task { @MainActor in + guard let self, let host else { return } + // A found device is used over the fallback probe. + self.found = Found(host: host, name: name) + } + } + } + } + browser.start(queue: .main) + self.browser = browser + } + + /// The device's IP address: connect to the service and read where it went. + nonisolated private static func resolve(_ endpoint: NWEndpoint, done: @escaping @Sendable (String?) -> Void) { + let connection = NWConnection(to: endpoint, using: .tcp) + let once = Once() + connection.stateUpdateHandler = { state in + switch state { + case .ready: + var host: String? + if case let .hostPort(h, _)? = connection.currentPath?.remoteEndpoint { + host = "\(h)".components(separatedBy: "%").first // drop an IPv6 interface suffix + } + connection.cancel() + if once.claim() { done(host) } + case .failed, .cancelled: + if once.claim() { done(nil) } + default: + break + } + } + connection.start(queue: .global()) + DispatchQueue.global().asyncAfter(deadline: .now() + 5) { + connection.cancel() + if once.claim() { done(nil) } + } + } + + /// Whether something answers on the SSH port of "host" or "host:port" within a few seconds. + nonisolated static func sshAnswers(host address: String) async -> Bool { + var host = address, port: UInt16 = 22 + if let target = AppModel.parse(host: address, user: "steamos") { + host = target.host + port = UInt16(target.port) + } + return await sshAnswers(host: host, port: port) + } + + nonisolated static func sshAnswers(host: String, port: UInt16) async -> Bool { + await withCheckedContinuation { (c: CheckedContinuation) in + let connection = NWConnection(host: NWEndpoint.Host(host), port: NWEndpoint.Port(rawValue: port) ?? 22, using: .tcp) + let once = Once() + connection.stateUpdateHandler = { state in + switch state { + case .ready: + connection.cancel() + if once.claim() { c.resume(returning: true) } + case .failed, .waiting: + connection.cancel() + if once.claim() { c.resume(returning: false) } + default: + break + } + } + connection.start(queue: .global()) + DispatchQueue.global().asyncAfter(deadline: .now() + 3) { + connection.cancel() + if once.claim() { c.resume(returning: false) } + } + } + } +} diff --git a/ios/FrameControl/Views/RootView.swift b/ios/FrameControl/Views/RootView.swift index 007003b..76ac099 100644 --- a/ios/FrameControl/Views/RootView.swift +++ b/ios/FrameControl/Views/RootView.swift @@ -11,6 +11,9 @@ struct RootView: View { SetupView(model: model) case .connecting(let step): ConnectingView(step: step, host: model.settings?.host) { model.showSetup() } + case .failed(let message) where model.retrying && !model.needsPairing: + WaitingView(host: model.settings.map { $0.port == 22 ? $0.host : "\($0.host):\($0.port)" } ?? "", detail: message, deviceName: model.deviceName, + reachable: { Task { await model.connect(quiet: true) } }, change: { model.showSetup() }) case .failed(let message): FailedView(message: message, canRetry: model.settings != nil, retrying: model.retrying, needsPairing: model.needsPairing, retry: { Task { await model.connect() } }, change: { model.showSetup() }) @@ -73,3 +76,46 @@ struct FailedView: View { .frame(maxWidth: 480) } } + +/// A paired Frame that isn't answering is almost always asleep: say how to wake +/// it, and connect the moment it does (its SSH port is checked every 3 s). +struct WaitingView: View { + let host: String + let detail: String + let deviceName: String + let reachable: () -> Void + let change: () -> Void + @State private var pulse = false + + var body: some View { + VStack(spacing: 18) { + Image("AppIconImage").resizable().frame(width: 76, height: 76) + .clipShape(RoundedRectangle(cornerRadius: 17)) + .opacity(pulse ? 1 : 0.55) + .animation(.easeInOut(duration: 1.2).repeatForever(autoreverses: true), value: pulse) + Text("Waiting for your Frame").font(.title3.bold()) + Text("Put the headset on, or press its power button, to wake it. Frame Control connects by itself as soon as it's awake.") + .multilineTextAlignment(.center) + VStack(alignment: .leading, spacing: 10) { + Tip(icon: "wifi", text: "Same Wi-Fi as this \(deviceName), or both on Tailscale.") + Tip(icon: "bolt.horizontal", text: "Asleep, the Frame drops off the network entirely; nothing can wake it remotely.") + } + .padding(14) + .background(Color.framePanel, in: RoundedRectangle(cornerRadius: 12)) + Text(detail).font(.footnote).foregroundStyle(Color.frameMuted).multilineTextAlignment(.center) + Button("Connect to a different Frame", action: change).font(.footnote) + } + .padding(28) + .frame(maxWidth: 480) + .onAppear { pulse = true } + .task(id: host) { + while !Task.isCancelled { + try? await Task.sleep(nanoseconds: 3_000_000_000) + if !host.isEmpty, await FrameFinder.sshAnswers(host: host) { + reachable() + return + } + } + } + } +} diff --git a/ios/FrameControl/Views/SetupView.swift b/ios/FrameControl/Views/SetupView.swift index aab21e0..0800aac 100644 --- a/ios/FrameControl/Views/SetupView.swift +++ b/ios/FrameControl/Views/SetupView.swift @@ -1,82 +1,197 @@ import SwiftUI import UIKit -/// Pairing: the Developer Mode password is used once, to add this phone's own -/// key to the Frame. It isn't stored. +/// First run: two steps. Wake the Frame (the app finds it by itself), then type the +/// Developer Mode password once. Everything else waits under "Other ways to connect". struct SetupView: View { @ObservedObject var model: AppModel - @State private var host = "" - @State private var user = "steamos" + @StateObject private var finder = FrameFinder() @State private var password = "" - @FocusState private var focus: Field? - private enum Field { case host, user, password } + @State private var manualHost = "" + @State private var user = "steamos" + @State private var showOther = false + @State private var showHelp = false + @FocusState private var passwordFocused: Bool + + /// Where Connect goes: what the finder saw, else what was typed, else frame.local. + private var host: String { + let typed = manualHost.trimmingCharacters(in: .whitespaces) + return finder.found?.host ?? (typed.isEmpty ? "frame.local" : typed) + } var body: some View { - NavigationStack { - Form { - Section { - VStack(alignment: .leading, spacing: 10) { - Image("AppIconImage").resizable().frame(width: 64, height: 64).clipShape(RoundedRectangle(cornerRadius: 14)) - Text("Connect to your Steam Frame").font(.title2.bold()) - Text("See what the headset sees, install games and Android apps, and send files and text, from this \(model.deviceName).") - .foregroundStyle(Color.frameMuted) - } - .padding(.vertical, 6) - .listRowBackground(Color.clear) - } - Section { - Label("On the Frame, open Steam Settings → System and turn on Developer Mode.", systemImage: "1.circle") - Label("Then Developer → Set User Password.", systemImage: "2.circle") - Label("Enter the headset's address and that password here, once.", systemImage: "3.circle") - } header: { Text("Before you start") } - Section { - TextField("frame.local or 192.168.1.20", text: $host) - .textContentType(.URL).keyboardType(.URL).autocorrectionDisabled().textInputAutocapitalization(.never) - .focused($focus, equals: .host).submitLabel(.next).onSubmit { focus = .password } - TextField("User", text: $user) - .autocorrectionDisabled().textInputAutocapitalization(.never).focused($focus, equals: .user) - SecureField("Developer Mode password", text: $password) - .textContentType(.password).focused($focus, equals: .password).submitLabel(.go).onSubmit(pair) - } header: { Text("Headset") } footer: { - Text("The password is only used to add this \(model.deviceName)'s own SSH key to the Frame; it isn't saved. The Frame and this \(model.deviceName) need to be on the same network, or both on Tailscale.") - } - Section { - Button(action: pair) { - Text("Pair").frame(maxWidth: .infinity).fontWeight(.semibold) - } - .disabled(host.trimmingCharacters(in: .whitespaces).isEmpty || password.isEmpty) - if model.settings != nil { - Button("Use \(model.settings!.host) again") { Task { await model.connect() } } - Button("Forget this headset", role: .destructive) { Task { await model.forget() } } - } - } - Section { - Text(model.authorizedKeysLine) - .font(.system(.caption2, design: .monospaced)).lineLimit(3).textSelection(.enabled) - Button("Copy this \(model.deviceName)'s key") { UIPasteboard.general.string = model.authorizedKeysLine } - Button("Connect with the key") { - let (h, u) = (host, user) - Task { await model.useKey(host: h, user: u) } - } - .disabled(host.trimmingCharacters(in: .whitespaces).isEmpty) - } header: { Text("Or add the key yourself") } footer: { - Text("If you already reach the Frame over SSH, add this line to ~/.ssh/authorized_keys there, then connect without a password.") - } + ScrollView { + VStack(alignment: .leading, spacing: 22) { + header + StepCard(number: 1, title: "Wake your Frame", done: finder.found != nil) { wakeStep } + StepCard(number: 2, title: "Enter its Developer Mode password", done: false) { passwordStep } + otherWays } - .scrollContentBackground(.hidden) - .background(Color.frameBackground) - .navigationTitle("Frame Control") - .navigationBarTitleDisplayMode(.inline) + .padding(20) + .frame(maxWidth: 560) + .frame(maxWidth: .infinity) } + .scrollDismissesKeyboard(.interactively) + .background(Color.frameBackground) .onAppear { - host = model.settings?.host ?? "frame.local" + manualHost = model.settings?.host ?? "" user = model.settings?.user ?? "steamos" + var fallback = model.settings?.host + #if DEBUG + fallback = ProcessInfo.processInfo.environment["FRAME_TEST_FALLBACK"] ?? fallback // Simulator test hook + #endif + finder.start(fallback: fallback) + } + .onDisappear { finder.stop() } + // After a few seconds of not finding it, say exactly what to check. + .task(id: finder.since) { + try? await Task.sleep(nanoseconds: 8_000_000_000) + showHelp = true } } - private func pair() { + private var header: some View { + VStack(alignment: .leading, spacing: 8) { + Image("AppIconImage").resizable().frame(width: 56, height: 56).clipShape(RoundedRectangle(cornerRadius: 13)) + Text("Connect to your Steam Frame").font(.title2.bold()) + Text("One time only. After this, the app connects by itself whenever your Frame is awake.") + .foregroundStyle(Color.frameMuted) + } + } + + // MARK: step 1 + + @ViewBuilder private var wakeStep: some View { + if let found = finder.found { + Label { + VStack(alignment: .leading, spacing: 2) { + Text("Found your Frame").fontWeight(.semibold) + Text(found.name == found.host ? found.host : "\(found.name) · \(found.host)") + .font(.footnote).foregroundStyle(Color.frameMuted) + } + } icon: { + Image(systemName: "checkmark.circle.fill").foregroundStyle(.green) + } + } else { + HStack(spacing: 10) { + ProgressView() + Text("Looking for it on this network…").foregroundStyle(Color.frameMuted) + } + Text("Put the headset on, or press its power button, so it's awake.") + if showHelp { + VStack(alignment: .leading, spacing: 10) { + Text("Still can't see it? Check:").font(.subheadline.weight(.semibold)) + Tip(icon: "wifi", text: "The Frame and this \(model.deviceName) are on the same Wi-Fi.") + Tip(icon: "hammer", text: "Developer Mode is on: on the Frame, Steam Settings → System → Enable Developer Mode.") + Tip(icon: "network", text: "Local Network is allowed for Frame Control: \(model.deviceName) Settings → Apps → Frame Control.") + } + .padding(.top, 4) + } + } + } + + // MARK: step 2 + + @ViewBuilder private var passwordStep: some View { + SecureField("Developer Mode password", text: $password) + .textContentType(.password) + .submitLabel(.go) + .focused($passwordFocused) + .onSubmit(connect) + .padding(12) + .background(Color.black.opacity(0.28), in: RoundedRectangle(cornerRadius: 10)) + Text("Haven't set one? On the Frame: Steam Settings → Developer → Set User Password. It's only used now, to let this \(model.deviceName) in; it isn't saved.") + .font(.footnote).foregroundStyle(Color.frameMuted) + Button(action: connect) { + Text(finder.found == nil ? "Connect to \(host)" : "Connect") + .fontWeight(.semibold).frame(maxWidth: .infinity).padding(.vertical, 4) + } + .buttonStyle(.borderedProminent) + .controlSize(.large) + .disabled(password.isEmpty) + } + + // MARK: everything else, out of the way + + private var otherWays: some View { + DisclosureGroup(isExpanded: $showOther) { + VStack(alignment: .leading, spacing: 14) { + VStack(alignment: .leading, spacing: 6) { + Text("Address").font(.footnote).foregroundStyle(Color.frameMuted) + TextField("frame.local, an IP, or a Tailscale name", text: $manualHost) + .keyboardType(.URL).textInputAutocapitalization(.never).autocorrectionDisabled() + .onSubmit { finder.start(fallback: manualHost) } + .padding(10).background(Color.black.opacity(0.28), in: RoundedRectangle(cornerRadius: 8)) + TextField("User", text: $user) + .textInputAutocapitalization(.never).autocorrectionDisabled() + .padding(10).background(Color.black.opacity(0.28), in: RoundedRectangle(cornerRadius: 8)) + Text("Typing an address here uses it instead of searching.").font(.caption).foregroundStyle(Color.frameMuted) + } + VStack(alignment: .leading, spacing: 6) { + Text("Already reach the Frame over SSH? Add this \(model.deviceName)'s key to ~/.ssh/authorized_keys there, then connect without a password.") + .font(.footnote).foregroundStyle(Color.frameMuted) + HStack { + Button("Copy key") { UIPasteboard.general.string = model.authorizedKeysLine } + Spacer() + Button("Connect with the key") { + let (h, u) = (host, user) + Task { await model.useKey(host: h, user: u) } + } + } + } + if let saved = model.settings { + Button("Forget \(saved.host)", role: .destructive) { Task { await model.forget() } } + } + } + .padding(.top, 10) + } label: { + Text("Other ways to connect").foregroundStyle(Color.frameMuted) + } + .onChange(of: manualHost) { _, value in + // A typed address replaces the search. + if !value.trimmingCharacters(in: .whitespaces).isEmpty, finder.found?.host != value { finder.start(fallback: value) } + } + } + + private func connect() { + guard !password.isEmpty else { passwordFocused = true; return } let (h, u, p) = (host, user, password) password = "" + finder.stop() Task { await model.pair(host: h, user: u, password: p) } } } + +/// A numbered step with a tick once it's done. +struct StepCard: View { + let number: Int + let title: String + let done: Bool + @ViewBuilder let content: Content + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + HStack(spacing: 10) { + ZStack { + Circle().fill(done ? Color.green : Color.frameBlue).frame(width: 26, height: 26) + if done { Image(systemName: "checkmark").font(.caption.bold()) } else { Text("\(number)").font(.subheadline.bold()) } + } + .foregroundStyle(.white) + Text(title).font(.headline) + } + content + } + .padding(16) + .frame(maxWidth: .infinity, alignment: .leading) + .background(Color.framePanel, in: RoundedRectangle(cornerRadius: 14)) + } +} + +struct Tip: View { + let icon: String + let text: String + + var body: some View { + Label { Text(text).font(.subheadline).fixedSize(horizontal: false, vertical: true) } icon: { Image(systemName: icon).foregroundStyle(Color.frameBlue) } + } +} diff --git a/ios/project.yml b/ios/project.yml index 20c086a..4a25bb1 100644 --- a/ios/project.yml +++ b/ios/project.yml @@ -39,7 +39,8 @@ targets: UISupportedInterfaceOrientations: [UIInterfaceOrientationPortrait, UIInterfaceOrientationLandscapeLeft, UIInterfaceOrientationLandscapeRight] UISupportedInterfaceOrientations~ipad: [UIInterfaceOrientationPortrait, UIInterfaceOrientationPortraitUpsideDown, UIInterfaceOrientationLandscapeLeft, UIInterfaceOrientationLandscapeRight] UIUserInterfaceStyle: Dark - NSLocalNetworkUsageDescription: Frame Control connects to your Steam Frame over your local network with SSH. + NSLocalNetworkUsageDescription: Frame Control finds your Steam Frame on your network and connects to it. + NSBonjourServices: [_steamos-devkit._tcp] NSAppTransportSecurity: NSAllowsLocalNetworking: true LSApplicationQueriesSchemes: [ssh, sftp, steamlink, rdp]