From c3d98bdad55f902e19fe1d3c411dd104611b6e58 Mon Sep 17 00:00:00 2001 From: raiseCatError <315733358+raiseCatError@users.noreply.github.com> Date: Sun, 27 Sep 2026 12:46:43 +0000 Subject: [PATCH 01/30] fix: give the iOS QUIC listener exclusive use of UDP 9001 and read streams only when ready On iPhone every QUIC dial failed. The listener's new-flow path received datagrams of an already accepted connection, failed to register a duplicate flow for the same 4-tuple (NECP ADD_FLOW EEXIST) and took the live connection down (EINVAL); its streams then failed their preface read with ENOTCONN, which was misreported as invalidStreamPreface. - The QUIC listener no longer opts into local-endpoint reuse, which the TCP listener needs but a UDP QUIC server does not: a second binding of UDP 9001 now fails and retries instead of silently sharing the port. - Listener parameters come from one factory shared with the loopback tests, so CI exercises the production configuration. - Accepted streams are read only after reaching .ready, and a failed read or stream is a transport failure, never a protocol violation. - Bounded DEBUG logging of accepted connections and ready streams. --- MacTests/QUICReceiverAuthorityTests.swift | 48 ++++++++ MacTests/QUICSecureTransportTests.swift | 142 +++++++++++++++++++++- Shared/QUICReceiverSession.swift | 116 +++++++++++++++--- 3 files changed, 284 insertions(+), 22 deletions(-) diff --git a/MacTests/QUICReceiverAuthorityTests.swift b/MacTests/QUICReceiverAuthorityTests.swift index 32220511..4aaabf8e 100644 --- a/MacTests/QUICReceiverAuthorityTests.swift +++ b/MacTests/QUICReceiverAuthorityTests.swift @@ -396,4 +396,52 @@ final class QUICReceiverAuthorityTests: XCTestCase { XCTAssertTrue(group.isClosed) control.cancel() } + + // MARK: - Preface read classification (physical-iPhone regression) + + /// A read that fails because the stream or its connection went away is a + /// TRANSPORT failure. On iPhone every such failure ("Socket is not + /// connected") was reported as `invalidStreamPreface`, hiding the real + /// fault; only bytes that are not a preface are a protocol violation. + func testPrefaceReadErrorIsATransportFailureNotAViolation() { + for error in [NWError.posix(.ENOTCONN), .posix(.ECONNRESET), .posix(.EINVAL), .posix(.ECANCELED)] { + guard case .transportFailure = QUICReceiverGroup.prefaceReadOutcome( + data: nil, isComplete: false, error: error) else { + return XCTFail("\(error) must be a transport failure") + } + // Even with partial bytes in hand, a failed read is transport. + guard case .transportFailure = QUICReceiverGroup.prefaceReadOutcome( + data: Data([0x4D, 0x45]), isComplete: true, error: error) else { + return XCTFail("\(error) with partial data must be a transport failure") + } + } + } + + func testPrefaceReadClassifiesPeerBytes() { + for channel in TransportChannel.allCases { + XCTAssertEqual(QUICReceiverGroup.prefaceReadOutcome( + data: QUICStreamPreface(channel: channel).encode(), isComplete: false, error: nil), .parsed(channel)) + } + XCTAssertEqual(QUICReceiverGroup.prefaceReadOutcome(data: Data("GET / HT".utf8), isComplete: false, error: nil), + .violation(.invalidStreamPreface)) + XCTAssertEqual(QUICReceiverGroup.prefaceReadOutcome( + data: Data([0x4D, 0x45, 0x4F, 0x57, 2, 1, 0, 0]), isComplete: false, error: nil), + .violation(.unsupportedTransportVersion)) + XCTAssertEqual(QUICReceiverGroup.prefaceReadOutcome( + data: Data([0x4D, 0x45, 0x4F, 0x57, 1, 9, 0, 0]), isComplete: false, error: nil), + .violation(.unexpectedChannel)) + // The peer ended the stream before a whole preface: a violation. + XCTAssertEqual(QUICReceiverGroup.prefaceReadOutcome(data: Data([0x4D, 0x45]), isComplete: true, error: nil), + .violation(.invalidStreamPreface)) + XCTAssertEqual(QUICReceiverGroup.prefaceReadOutcome(data: nil, isComplete: true, error: nil), + .violation(.invalidStreamPreface)) + } + + func testReceiverListenerParametersDoNotReuseTheLocalEndpoint() { + let parameters = QUICReceiverListener.listenerParameters(quic: NWProtocolQUIC.Options(alpn: [QUICTransport.alpn])) + XCTAssertFalse(parameters.allowLocalEndpointReuse, + "the QUIC listener must own UDP 9001 exclusively") + XCTAssertTrue(parameters.includePeerToPeer) + XCTAssertEqual(parameters.serviceClass, .interactiveVideo) + } } diff --git a/MacTests/QUICSecureTransportTests.swift b/MacTests/QUICSecureTransportTests.swift index c7e6f0c4..cb93006c 100644 --- a/MacTests/QUICSecureTransportTests.swift +++ b/MacTests/QUICSecureTransportTests.swift @@ -129,8 +129,9 @@ final class QUICSecureTransportTests: XCTestCase { } } - /// Starts a receiver-shaped QUIC listener (production options + the same - /// stream limits `QUICReceiverListener` sets). The server never reads + /// Starts a receiver-shaped QUIC listener (production options, the same + /// stream limits and the SAME listener parameters `QUICReceiverListener` + /// uses — `listenerParameters(quic:)`). The server never reads /// the Video stream past its preface — so if Control only arrived behind /// Video, it would never arrive at all. private func startServer(identity: TestIdentity, pinned: [Data], outcome: Outcome) throws -> NWListener { @@ -138,7 +139,7 @@ final class QUICSecureTransportTests: XCTestCase { identity: identity.identity, pinnedSPKIs: { pinned }, isListener: true, queue: queue)) options.initialMaxStreamsBidirectional = QUICChannelRegistry.maxStreams options.initialMaxStreamsUnidirectional = 0 - let listener = try NWListener(using: NWParameters(quic: options), on: .any) + let listener = try NWListener(using: QUICReceiverListener.listenerParameters(quic: options), on: .any) let queue = self.queue listener.newConnectionGroupHandler = { group in group.newConnectionHandler = { stream in @@ -337,4 +338,139 @@ final class QUICSecureTransportTests: XCTestCase { XCTAssertNotEqual(wrongKey, .firstConnection) XCTAssertFalse(state.isAdmitted) } + + // MARK: - Production receiver path (physical-iPhone regression) + + private func receiverOptions(_ identity: TestIdentity, pinning client: Data) throws -> NWProtocolQUIC.Options { + let options = try XCTUnwrap(TLSConfigurator.pinnedQUICOptions( + identity: identity.identity, pinnedSPKIs: { [client] }, isListener: true, queue: queue)) + options.initialMaxStreamsBidirectional = QUICChannelRegistry.maxStreams + options.initialMaxStreamsUnidirectional = 0 + return options + } + + private func startedListener(_ parameters: NWParameters, on port: NWEndpoint.Port) throws -> NWListener { + let listener = try NWListener(using: parameters, on: port) + listener.newConnectionGroupHandler = { group in group.cancel() } + listener.start(queue: queue) + waitUntil(3) { + switch listener.state { + case .ready, .failed, .cancelled: return true + default: return false + } + } + return listener + } + + /// The listener's UDP port must never be shared with a second binding + /// (a stale listener during a re-arm): with local-endpoint reuse — what + /// the receiver used before — a second listener on the same port comes + /// up silently, which is the precondition for another binding's + /// new-flow path seeing a live connection's datagrams. + func testProductionQUICListenerNeverSharesItsUDPPort() throws { + let identity = try makeIdentity() + let peer = try makeIdentity().spki + let production = QUICReceiverListener.listenerParameters(quic: try receiverOptions(identity, pinning: peer)) + XCTAssertFalse(production.allowLocalEndpointReuse) + XCTAssertTrue(production.includePeerToPeer) + + let first = try startedListener(production, on: .any) + guard first.state == .ready, let port = first.port else { + first.cancel() + throw XCTSkip("loopback QUIC listener unavailable (\(first.state))") + } + let second = try startedListener( + QUICReceiverListener.listenerParameters(quic: try receiverOptions(identity, pinning: peer)), on: port) + XCTAssertNotEqual(second.state, .ready, "a second production QUIC listener must not share UDP \(port)") + second.cancel() + + // Contrast: the previous reuse-enabled parameters let it share. + let reusing = QUICReceiverListener.listenerParameters(quic: try receiverOptions(identity, pinning: peer)) + reusing.allowLocalEndpointReuse = true + let reused = try startedListener(reusing, on: port) + XCTAssertEqual(reused.state, .ready, "local-endpoint reuse is what allowed a shared UDP port") + reused.cancel() + first.cancel() + } + + private final class GroupEvents: @unchecked Sendable { + private let lock = NSLock() + private var _controls = 0 + private var _media = 0 + private var _owners: [QUICReceiverGroup] = [] + func control() { lock.lock(); _controls += 1; lock.unlock() } + func media() { lock.lock(); _media += 1; lock.unlock() } + func owner(_ o: QUICReceiverGroup) { lock.lock(); _owners.append(o); lock.unlock() } + var controls: Int { lock.lock(); defer { lock.unlock() }; return _controls } + var mediaCount: Int { lock.lock(); defer { lock.unlock() }; return _media } + var owners: [QUICReceiverGroup] { lock.lock(); defer { lock.unlock() }; return _owners } + } + + /// The real receiver edge — production listener parameters and + /// `QUICReceiverGroup` accept/preface handling — over real QUIC, dialed + /// the way `MacSenderTransportController.openQUICStreams` does (three + /// streams opened together, each preface sent right after `start`). The + /// previous loopback tests used their own ad-hoc stream handling, so + /// this production path had never run over a real connection. + func testProductionReceiverGroupAcceptsAllThreeChannels() throws { + let server = try makeIdentity() + let client = try makeIdentity() + let listener = try NWListener( + using: QUICReceiverListener.listenerParameters(quic: try receiverOptions(server, pinning: client.spki)), + on: .any) + let registry = QUICReceiverGroupRegistry() + let events = GroupEvents() + let queue = self.queue + listener.newConnectionGroupHandler = { group in + let owner = registry.admit { id in + QUICReceiverGroup(id: id, group: group, registry: registry, queue: queue, + onControlStream: { _ in events.control() }, + onMediaReady: { _ in events.media() }) + } + guard let owner else { group.cancel(); return } + events.owner(owner) + owner.start() + } + listener.start(queue: queue) + waitUntil(5) { listener.port != nil && listener.state == .ready } + guard listener.state == .ready, let port = listener.port else { + listener.cancel() + throw XCTSkip("loopback QUIC listener unavailable (\(listener.state))") + } + + let group = NWConnectionGroup(with: NWMultiplexGroup(to: .hostPort(host: "127.0.0.1", port: port)), + using: NWParameters(quic: try clientOptions(client, pinning: server.spki))) + let streams = OutcomeStreams() + group.stateUpdateHandler = { state in + guard case .ready = state else { return } + for channel in [TransportChannel.control, .video, .audio] { + let created: NWConnection? = NWConnection(from: group) + guard let stream = created else { return } + streams.keep(stream) + stream.start(queue: queue) + stream.send(content: QUICStreamPreface(channel: channel).encode(), completion: .contentProcessed { _ in }) + } + } + group.newConnectionHandler = { stream in stream.cancel() } + group.start(queue: queue) + + waitUntil(8) { events.controls == 1 && events.mediaCount == 2 } + XCTAssertEqual(events.controls, 1, "the Control stream reaches the session pipeline") + XCTAssertEqual(events.mediaCount, 2, "Video and Audio are validated and parked") + XCTAssertEqual(events.owners.count, 1, "all three streams ride one QUIC connection") + XCTAssertFalse(events.owners.first?.isClosed ?? true, "a healthy connection is not closed") + XCTAssertEqual(events.owners.first?.takePendingMedia().count, 2) + + group.cancel() + streams.cancelAll() + registry.closeAll(error: nil) + listener.cancel() + } + + private final class OutcomeStreams: @unchecked Sendable { + private let lock = NSLock() + private var streams: [NWConnection] = [] + func keep(_ s: NWConnection) { lock.lock(); streams.append(s); lock.unlock() } + func cancelAll() { lock.lock(); let all = streams; lock.unlock(); all.forEach { $0.cancel() } } + } } diff --git a/Shared/QUICReceiverSession.swift b/Shared/QUICReceiverSession.swift index ba89f4a3..5277f61e 100644 --- a/Shared/QUICReceiverSession.swift +++ b/Shared/QUICReceiverSession.swift @@ -254,6 +254,9 @@ final class QUICReceiverGroup: @unchecked Sendable { self.accept(stream) } group.start(queue: queue) + #if DEBUG + Log.info("quicListener: connection \(id) accepted") + #endif queue.asyncAfter(deadline: .now() + QUICReceiverLimits.applicationHandshakeTimeout) { [weak self] in guard let self, !self.isClosed, !self.hasHandedOffControl else { return } Log.info("quicListener: connection \(id) presented no Control stream in time — closing") @@ -281,34 +284,90 @@ final class QUICReceiverGroup: @unchecked Sendable { close(topologyError) return } - stream.start(queue: queue) let id = self.id + // Read nothing until the accepted stream is `.ready`. A stream that + // fails first is a TRANSPORT failure of this connection (on iOS the + // accepted stream flow is still setting up when it is handed to us, + // and dies with its connection) — never a peer protocol violation. + stream.stateUpdateHandler = { [weak self] state in + guard let self, !self.isClosed, !self.isRegistered(stream) else { return } + switch state { + case .ready: + #if DEBUG + Log.info("quicListener: connection \(id) stream ready — reading its preface") + #endif + self.readPreface(of: stream) + case .failed(let error), .waiting(let error): + self.closeForTransportFailure("stream \(state) before its preface: \(error)") + default: + break + } + } + stream.start(queue: queue) queue.asyncAfter(deadline: .now() + QUICReceiverLimits.prefaceTimeout) { [weak self] in guard let self, !self.isClosed, !self.isRegistered(stream) else { return } Log.info("quicListener: connection \(id) stream sent no valid preface in time — closing") self.close(.invalidStreamPreface) } - // Exactly 8 bytes: Network.framework reassembles a split preface, - // and nothing past it is consumed here. + } + + /// Exactly 8 bytes: Network.framework reassembles a split preface, and + /// nothing past it is consumed here. Only ever issued once per stream, + /// from its `.ready` transition. + private func readPreface(of stream: NWConnection) { + lock.lock() + let first = !prefaceReads.contains(ObjectIdentifier(stream)) + if first { prefaceReads.insert(ObjectIdentifier(stream)) } + lock.unlock() + guard first else { return } stream.receive(minimumIncompleteLength: QUICStreamPreface.length, - maximumLength: QUICStreamPreface.length) { [weak self] data, _, _, error in + maximumLength: QUICStreamPreface.length) { [weak self] data, _, isComplete, error in guard let self else { return } - guard error == nil, let data, data.count == QUICStreamPreface.length else { - self.close(.invalidStreamPreface) - return - } - switch QUICStreamPreface.parse(data) { - case .parsed(let preface, _): - self.register(stream, channel: preface.channel) - case .rejected(let rejection): - self.close(rejection) - case .needMoreData: - self.close(.invalidStreamPreface) + switch Self.prefaceReadOutcome(data: data, isComplete: isComplete, error: error) { + case .parsed(let channel): + self.register(stream, channel: channel) + case .violation(let violation): + self.close(violation) + case .transportFailure(let detail): + self.closeForTransportFailure("preface read failed: \(detail)") } } } + enum PrefaceReadOutcome: Equatable { + case parsed(TransportChannel) + /// The peer sent bytes that are not a valid preface, or ended the + /// stream before a whole one: a protocol violation. + case violation(QUICApplicationError) + /// The read itself failed (the stream or its connection went away): + /// a transport failure, reported as such and never as a violation. + case transportFailure(String) + } + + /// Pure classification of the preface read's completion. + static func prefaceReadOutcome(data: Data?, isComplete: Bool, error: NWError?) -> PrefaceReadOutcome { + if let error { return .transportFailure("\(error)") } + guard let data, !data.isEmpty else { + return isComplete ? .violation(.invalidStreamPreface) : .transportFailure("no data") + } + guard data.count == QUICStreamPreface.length else { return .violation(.invalidStreamPreface) } + switch QUICStreamPreface.parse(data) { + case .parsed(let preface, _): return .parsed(preface.channel) + case .rejected(let rejection): return .violation(rejection) + case .needMoreData: return .violation(.invalidStreamPreface) + } + } + + /// The connection broke underneath us (not the peer's fault as far as + /// the protocol is concerned): close it without an application error. + private func closeForTransportFailure(_ detail: String) { + Log.info("quicListener: connection \(id) transport failure — \(detail)") + close(nil) + } + private var registeredStreams: [ObjectIdentifier] = [] + /// Streams whose single preface read has been issued (under `lock`). + private var prefaceReads: Set = [] private func isRegistered(_ stream: NWConnection) -> Bool { lock.lock(); defer { lock.unlock() } @@ -399,10 +458,7 @@ enum QUICReceiverListener { quic.initialMaxStreamsBidirectional = QUICChannelRegistry.maxStreams quic.initialMaxStreamsUnidirectional = 0 do { - let params = NWParameters(quic: quic) - params.includePeerToPeer = true - params.allowLocalEndpointReuse = true - params.serviceClass = .interactiveVideo + let params = listenerParameters(quic: quic) let listener = try NWListener(using: params, on: NWEndpoint.Port(rawValue: QUICTransport.udpPort)!) context.install(listener) listener.service = quicService(advertisementState: advertisementState, installID: installID, @@ -459,6 +515,28 @@ enum QUICReceiverListener { } } + /// The QUIC listener's parameters — the one definition production and + /// the loopback QUIC tests share. + /// + /// Deliberately WITHOUT `allowLocalEndpointReuse` (unlike the TCP + /// listener, where it only eases re-binding after TIME_WAIT). A QUIC + /// server demultiplexes every accepted connection by UDP 4-tuple on the + /// listener's own port; with local-endpoint reuse on, a second binding + /// of UDP 9001 (a stale listener during a re-arm) is silently allowed, + /// and on iPhone the listener's new-flow path was observed receiving + /// datagrams of an already-accepted connection, failing to register a + /// duplicate flow for that same 4-tuple (NECP ADD_FLOW "File exists"), + /// and taking the live connection down with it (EINVAL). Without reuse a + /// re-arm that races an old binding fails with EADDRINUSE and retries + /// (`startIfNeeded`'s 1 s backoff) instead of sharing the port. + static func listenerParameters(quic: NWProtocolQUIC.Options) -> NWParameters { + let params = NWParameters(quic: quic) + params.includePeerToPeer = true + params.allowLocalEndpointReuse = false + params.serviceClass = .interactiveVideo + return params + } + /// `_meowdisp-q._udp`: `id`/`pv`/`qv` only — a capability HINT, never /// trust, never the one-shot `cr` token. static func quicService(advertisementState: ReceiverAdvertisementState, installID: String, From 49a8d06ba821f742d99c3b9e0733bd9ee27f8ec9 Mon Sep 17 00:00:00 2001 From: raiseCatError <315733358+raiseCatError@users.noreply.github.com> Date: Sun, 27 Sep 2026 12:52:13 +0000 Subject: [PATCH 02/30] test: temporary QUIC stream-delivery diagnostics --- MacTests/QUICSecureTransportTests.swift | 6 - MacTests/QUICStreamDiagnosticsTests.swift | 181 ++++++++++++++++++++++ 2 files changed, 181 insertions(+), 6 deletions(-) create mode 100644 MacTests/QUICStreamDiagnosticsTests.swift diff --git a/MacTests/QUICSecureTransportTests.swift b/MacTests/QUICSecureTransportTests.swift index cb93006c..e322f71c 100644 --- a/MacTests/QUICSecureTransportTests.swift +++ b/MacTests/QUICSecureTransportTests.swift @@ -384,12 +384,6 @@ final class QUICSecureTransportTests: XCTestCase { XCTAssertNotEqual(second.state, .ready, "a second production QUIC listener must not share UDP \(port)") second.cancel() - // Contrast: the previous reuse-enabled parameters let it share. - let reusing = QUICReceiverListener.listenerParameters(quic: try receiverOptions(identity, pinning: peer)) - reusing.allowLocalEndpointReuse = true - let reused = try startedListener(reusing, on: port) - XCTAssertEqual(reused.state, .ready, "local-endpoint reuse is what allowed a shared UDP port") - reused.cancel() first.cancel() } diff --git a/MacTests/QUICStreamDiagnosticsTests.swift b/MacTests/QUICStreamDiagnosticsTests.swift new file mode 100644 index 00000000..9fc7433a --- /dev/null +++ b/MacTests/QUICStreamDiagnosticsTests.swift @@ -0,0 +1,181 @@ +import XCTest +import Network +import Security +import CryptoKit +import X509 +import SwiftASN1 + +/// TEMPORARY diagnostics (removed before merge): what a production-shaped +/// QUIC listener actually receives per stream, under variants of the +/// sender's stream pattern. Logs only; asserts nothing. +final class QUICStreamDiagnosticsTests: XCTestCase { + private let queue = DispatchQueue(label: "quic.stream.diagnostics") + private var keychain: SecKeychain? + private var keychainPath = "" + + override func setUpWithError() throws { + keychainPath = NSTemporaryDirectory() + "meow-quic-diag-\(UUID().uuidString).keychain" + let password = UUID().uuidString + var created: SecKeychain? + guard SecKeychainCreate(keychainPath, UInt32(password.utf8.count), password, false, nil, &created) == errSecSuccess, + let created else { throw XCTSkip("no keychain") } + keychain = created + } + + override func tearDown() { + if let keychain { SecKeychainDelete(keychain) } + try? FileManager.default.removeItem(atPath: keychainPath) + } + + private final class Log2: @unchecked Sendable { + private let lock = NSLock() + private var lines: [String] = [] + private let start = Date() + func add(_ s: String) { + lock.lock(); lines.append(String(format: "+%.0fms ", Date().timeIntervalSince(start) * 1000) + s); lock.unlock() + } + var all: [String] { lock.lock(); defer { lock.unlock() }; return lines } + } + + private func identity() throws -> (SecIdentity, Data) { + // Same construction as QUICSecureTransportTests.makeIdentity. + let test = QUICSecureTransportTestsIdentityFactory(keychain: keychain!) + return try test.make() + } + + private func wait(_ t: TimeInterval) { RunLoop.current.run(until: Date().addingTimeInterval(t)) } + + private func run(_ name: String, serverLimits: Bool, payloadWithPreface: Bool, channels: [TransportChannel]) throws { + let (serverID, serverSPKI) = try identity() + let (clientID, clientSPKI) = try identity() + let log = Log2() + let serverOptions = try XCTUnwrap(TLSConfigurator.pinnedQUICOptions( + identity: serverID, pinnedSPKIs: { [clientSPKI] }, isListener: true, queue: queue)) + if serverLimits { + serverOptions.initialMaxStreamsBidirectional = QUICChannelRegistry.maxStreams + serverOptions.initialMaxStreamsUnidirectional = 0 + } + let listener = try NWListener(using: QUICReceiverListener.listenerParameters(quic: serverOptions), on: .any) + let queue = self.queue + let holder = Holder() + listener.newConnectionGroupHandler = { group in + log.add("server: group accepted") + group.stateUpdateHandler = { state in log.add("server: group state \(state)") } + group.newConnectionHandler = { stream in + let md = stream.metadata(definition: NWProtocolQUIC.definition) as? NWProtocolQUIC.Metadata + log.add("server: stream delivered id=\(md.map { String($0.streamIdentifier) } ?? "nil") state=\(stream.state)") + holder.keep(stream) + stream.stateUpdateHandler = { state in + let md2 = stream.metadata(definition: NWProtocolQUIC.definition) as? NWProtocolQUIC.Metadata + log.add("server: stream id=\(md2.map { String($0.streamIdentifier) } ?? "nil") state \(state)") + if case .ready = state { + let label = md2.map { String($0.streamIdentifier) } ?? "nil" + Self.readAll(stream, label: label, log: log) + } + } + stream.start(queue: queue) + } + holder.keep(group) + group.start(queue: queue) + } + listener.start(queue: queue) + let deadline = Date().addingTimeInterval(5) + while listener.port == nil && Date() < deadline { wait(0.05) } + let clientOptions = try XCTUnwrap(TLSConfigurator.pinnedQUICOptions( + identity: clientID, pinnedSPKIs: { [serverSPKI] }, isListener: false, queue: queue)) + let group = NWConnectionGroup(with: NWMultiplexGroup(to: .hostPort(host: "127.0.0.1", port: listener.port!)), + using: NWParameters(quic: clientOptions)) + group.stateUpdateHandler = { state in + log.add("client: group state \(state)") + guard case .ready = state else { return } + for channel in channels { + let created: NWConnection? = NWConnection(from: group) + guard let stream = created else { log.add("client: NWConnection(from:) nil"); return } + holder.keep(stream) + stream.stateUpdateHandler = { s in + let md = stream.metadata(definition: NWProtocolQUIC.definition) as? NWProtocolQUIC.Metadata + log.add("client: \(channel) id=\(md.map { String($0.streamIdentifier) } ?? "nil") state \(s)") + } + stream.start(queue: queue) + var bytes = QUICStreamPreface(channel: channel).encode() + if payloadWithPreface { bytes += QUICFrameAssembler.frame(Data("{\"type\":\"x\"}".utf8)) } + stream.send(content: bytes, completion: .contentProcessed { e in + log.add("client: \(channel) sent \(bytes.count)B error=\(String(describing: e))") + }) + } + } + group.newConnectionHandler = { $0.cancel() } + group.start(queue: queue) + wait(4) + print("=== QUICDIAG \(name) ===") + for l in log.all { print("QUICDIAG \(name) \(l)") } + group.cancel() + listener.cancel() + holder.cancelAll() + wait(0.3) + } + + private static func readAll(_ stream: NWConnection, label: String, log: Log2) { + stream.receive(minimumIncompleteLength: 1, maximumLength: 64) { data, _, complete, error in + log.add("server: stream id=\(label) read \(data?.count ?? -1)B first=\(data.map { Array($0.prefix(8)) } ?? []) complete=\(complete) error=\(String(describing: error))") + if data != nil, error == nil, !complete { readAll(stream, label: label, log: log) } + } + } + + private final class Holder: @unchecked Sendable { + private let lock = NSLock() + private var connections: [NWConnection] = [] + private var groups: [NWConnectionGroup] = [] + func keep(_ c: NWConnection) { lock.lock(); connections.append(c); lock.unlock() } + func keep(_ g: NWConnectionGroup) { lock.lock(); groups.append(g); lock.unlock() } + func cancelAll() { + lock.lock(); let c = connections; let g = groups; connections = []; groups = []; lock.unlock() + c.forEach { $0.cancel() }; g.forEach { $0.cancel() } + } + } + + func testDiagnoseStreamDelivery() throws { + try run("A-limits3-prefaceOnly-3streams", serverLimits: true, payloadWithPreface: false, + channels: [.control, .video, .audio]) + try run("B-limits3-withPayload-3streams", serverLimits: true, payloadWithPreface: true, + channels: [.control, .video, .audio]) + try run("C-defaultLimits-prefaceOnly-3streams", serverLimits: false, payloadWithPreface: false, + channels: [.control, .video, .audio]) + try run("D-limits3-prefaceOnly-2streams", serverLimits: true, payloadWithPreface: false, + channels: [.video, .control]) + } +} + +/// Identity factory shared with the diagnostics (mirrors QUICSecureTransportTests). +struct QUICSecureTransportTestsIdentityFactory { + let keychain: SecKeychain + + func make() throws -> (SecIdentity, Data) { + var cfError: Unmanaged? + guard let privateKey = SecKeyCreateRandomKey([ + kSecAttrKeyType: kSecAttrKeyTypeECSECPrimeRandom, + kSecAttrKeySizeInBits: 256, + kSecUseKeychain: keychain, + kSecPrivateKeyAttrs: [kSecAttrIsPermanent: true], + ] as CFDictionary, &cfError), + let publicKey = SecKeyCopyPublicKey(privateKey), + let x963 = SecKeyCopyExternalRepresentation(publicKey, &cfError) as Data?, + let p256 = try? P256.Signing.PublicKey(x963Representation: x963) else { throw XCTSkip("key") } + let signer = Certificate.PrivateKey(P256.Signing.PrivateKey()) + let name = try DistinguishedName { CommonName("meow-diag-\(UUID().uuidString)") } + let now = Date() + let certificate = try Certificate( + version: .v3, serialNumber: Certificate.SerialNumber(), publicKey: Certificate.PublicKey(p256), + notValidBefore: now.addingTimeInterval(-3600), notValidAfter: now.addingTimeInterval(3600), + issuer: name, subject: name, signatureAlgorithm: .ecdsaWithSHA256, + extensions: try Certificate.Extensions { Critical(BasicConstraints.notCertificateAuthority) }, + issuerPrivateKey: signer) + var serializer = DER.Serializer() + try serializer.serialize(certificate) + guard let secCert = SecCertificateCreateWithData(nil, Data(serializer.serializedBytes) as CFData) else { throw XCTSkip("cert") } + guard SecItemAdd([kSecClass: kSecClassCertificate, kSecValueRef: secCert, kSecUseKeychain: keychain] as CFDictionary, nil) == errSecSuccess else { throw XCTSkip("add") } + var identity: SecIdentity? + guard SecIdentityCreateWithCertificate(keychain, secCert, &identity) == errSecSuccess, let identity else { throw XCTSkip("identity") } + return (identity, p256.derRepresentation) + } +} From eb2e4833ad737af2f3ec8ea14f2c2c7a5f5d43d8 Mon Sep 17 00:00:00 2001 From: raiseCatError <315733358+raiseCatError@users.noreply.github.com> Date: Sun, 27 Sep 2026 12:57:43 +0000 Subject: [PATCH 03/30] test: fix the QUIC diagnostics' readiness wait; log stream IDs in DEBUG --- MacTests/QUICStreamDiagnosticsTests.swift | 10 +++++++--- Shared/QUICReceiverSession.swift | 4 +++- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/MacTests/QUICStreamDiagnosticsTests.swift b/MacTests/QUICStreamDiagnosticsTests.swift index 9fc7433a..1973227f 100644 --- a/MacTests/QUICStreamDiagnosticsTests.swift +++ b/MacTests/QUICStreamDiagnosticsTests.swift @@ -45,14 +45,15 @@ final class QUICStreamDiagnosticsTests: XCTestCase { private func wait(_ t: TimeInterval) { RunLoop.current.run(until: Date().addingTimeInterval(t)) } - private func run(_ name: String, serverLimits: Bool, payloadWithPreface: Bool, channels: [TransportChannel]) throws { + private func run(_ name: String, serverLimits: Bool, payloadWithPreface: Bool, channels: [TransportChannel], + bidiLimit: Int = QUICChannelRegistry.maxStreams) throws { let (serverID, serverSPKI) = try identity() let (clientID, clientSPKI) = try identity() let log = Log2() let serverOptions = try XCTUnwrap(TLSConfigurator.pinnedQUICOptions( identity: serverID, pinnedSPKIs: { [clientSPKI] }, isListener: true, queue: queue)) if serverLimits { - serverOptions.initialMaxStreamsBidirectional = QUICChannelRegistry.maxStreams + serverOptions.initialMaxStreamsBidirectional = bidiLimit serverOptions.initialMaxStreamsUnidirectional = 0 } let listener = try NWListener(using: QUICReceiverListener.listenerParameters(quic: serverOptions), on: .any) @@ -80,7 +81,8 @@ final class QUICStreamDiagnosticsTests: XCTestCase { } listener.start(queue: queue) let deadline = Date().addingTimeInterval(5) - while listener.port == nil && Date() < deadline { wait(0.05) } + while listener.state != .ready && Date() < deadline { wait(0.05) } + log.add("server: listener \(listener.state) port=\(String(describing: listener.port))") let clientOptions = try XCTUnwrap(TLSConfigurator.pinnedQUICOptions( identity: clientID, pinnedSPKIs: { [serverSPKI] }, isListener: false, queue: queue)) let group = NWConnectionGroup(with: NWMultiplexGroup(to: .hostPort(host: "127.0.0.1", port: listener.port!)), @@ -143,6 +145,8 @@ final class QUICStreamDiagnosticsTests: XCTestCase { channels: [.control, .video, .audio]) try run("D-limits3-prefaceOnly-2streams", serverLimits: true, payloadWithPreface: false, channels: [.video, .control]) + try run("E-limits4-prefaceOnly-3streams", serverLimits: true, payloadWithPreface: false, + channels: [.control, .video, .audio], bidiLimit: 4) } } diff --git a/Shared/QUICReceiverSession.swift b/Shared/QUICReceiverSession.swift index 5277f61e..1602fe3f 100644 --- a/Shared/QUICReceiverSession.swift +++ b/Shared/QUICReceiverSession.swift @@ -294,7 +294,9 @@ final class QUICReceiverGroup: @unchecked Sendable { switch state { case .ready: #if DEBUG - Log.info("quicListener: connection \(id) stream ready — reading its preface") + let streamID = (stream.metadata(definition: NWProtocolQUIC.definition) as? NWProtocolQUIC.Metadata) + .map { String($0.streamIdentifier) } ?? "?" + Log.info("quicListener: connection \(id) stream \(streamID) ready — reading its preface") #endif self.readPreface(of: stream) case .failed(let error), .waiting(let error): From f0656a6fae6847e4f153d6b4abeabb5511182447 Mon Sep 17 00:00:00 2001 From: raiseCatError <315733358+raiseCatError@users.noreply.github.com> Date: Sun, 27 Sep 2026 13:06:06 +0000 Subject: [PATCH 04/30] fix: accept QUIC streams only once ready and leave room for Network.framework's stream 0 Physical iPhone QUIC failed on every dial, and the same receiver path fails over macOS loopback once it is actually exercised: - Network.framework delivers one object of its own per QUIC connection to newConnectionHandler (no stream ID, never ready, no data). The receiver treated it as a MEOW stream: it counted it against the three-stream topology, armed a preface timeout on it and read from it. On iPhone that read fails at once (ENOTCONN) and every connection was closed as invalidStreamPreface. Delivered objects now count and are read only once they are .ready; a never-ready object that fails is dropped. Delivered objects stay bounded. - The sender's streams start at stream ID 4 because stream 0 is Network. framework's, so initial_max_streams_bidi = 3 left the sender's third stream blocked forever. The limit is now 4, set from one shared place. - Reverts the listener local-endpoint-reuse change: the evidence did not support it. - The loopback test now runs the production QUICReceiverGroup with three streams, production listener parameters and stream limits. --- MacTests/QUICReceiverAuthorityTests.swift | 42 ++++- MacTests/QUICSecureTransportTests.swift | 72 +++------ MacTests/QUICStreamDiagnosticsTests.swift | 185 ---------------------- PROTOCOL.md | 5 +- Shared/QUICReceiverSession.swift | 133 ++++++++++------ 5 files changed, 150 insertions(+), 287 deletions(-) delete mode 100644 MacTests/QUICStreamDiagnosticsTests.swift diff --git a/MacTests/QUICReceiverAuthorityTests.swift b/MacTests/QUICReceiverAuthorityTests.swift index 4aaabf8e..ac0ba615 100644 --- a/MacTests/QUICReceiverAuthorityTests.swift +++ b/MacTests/QUICReceiverAuthorityTests.swift @@ -437,11 +437,45 @@ final class QUICReceiverAuthorityTests: XCTestCase { .violation(.invalidStreamPreface)) } - func testReceiverListenerParametersDoNotReuseTheLocalEndpoint() { - let parameters = QUICReceiverListener.listenerParameters(quic: NWProtocolQUIC.Options(alpn: [QUICTransport.alpn])) - XCTAssertFalse(parameters.allowLocalEndpointReuse, - "the QUIC listener must own UDP 9001 exclusively") + /// Network.framework uses QUIC stream 0 itself, so three MeowDisplay + /// streams need a bidirectional stream limit of four. + func testStreamLimitsLeaveRoomForNetworkFrameworksOwnStream() { + let options = NWProtocolQUIC.Options(alpn: [QUICTransport.alpn]) + QUICReceiverListener.configureStreamLimits(options) + XCTAssertEqual(options.initialMaxStreamsBidirectional, QUICChannelRegistry.maxStreams + 1) + XCTAssertEqual(options.initialMaxStreamsUnidirectional, 0) + XCTAssertEqual(QUICReceiverLimits.maxDeliveredStreamObjects, QUICChannelRegistry.maxStreams + 1) + let parameters = QUICReceiverListener.listenerParameters(quic: options) XCTAssertTrue(parameters.includePeerToPeer) XCTAssertEqual(parameters.serviceClass, .interactiveVideo) } + + /// A delivered object that never becomes a stream (Network.framework's + /// own, or any that fails first) must not close the connection. + func testANeverReadyDeliveredObjectDoesNotCloseTheConnection() throws { + let registry = QUICReceiverGroupRegistry() + let group = try XCTUnwrap(admitGroup(registry)) + // Dials a closed loopback port: never `.ready`, then fails. + let neverReady = makeStream() + group.accept(neverReady) + let deadline = Date().addingTimeInterval(2) + while Date() < deadline, neverReady.state != .cancelled { + if case .failed = neverReady.state { break } + RunLoop.current.run(until: Date().addingTimeInterval(0.05)) + } + RunLoop.current.run(until: Date().addingTimeInterval(0.3)) + XCTAssertFalse(group.isClosed, "only a real stream's failure is the connection's failure") + group.close(nil) + } + + func testDeliveredObjectsAreBounded() throws { + let registry = QUICReceiverGroupRegistry() + let group = try XCTUnwrap(admitGroup(registry)) + for _ in 0.. NWListener { let options = try XCTUnwrap(TLSConfigurator.pinnedQUICOptions( identity: identity.identity, pinnedSPKIs: { pinned }, isListener: true, queue: queue)) - options.initialMaxStreamsBidirectional = QUICChannelRegistry.maxStreams - options.initialMaxStreamsUnidirectional = 0 + QUICReceiverListener.configureStreamLimits(options) let listener = try NWListener(using: QUICReceiverListener.listenerParameters(quic: options), on: .any) let queue = self.queue listener.newConnectionGroupHandler = { group in @@ -344,49 +343,10 @@ final class QUICSecureTransportTests: XCTestCase { private func receiverOptions(_ identity: TestIdentity, pinning client: Data) throws -> NWProtocolQUIC.Options { let options = try XCTUnwrap(TLSConfigurator.pinnedQUICOptions( identity: identity.identity, pinnedSPKIs: { [client] }, isListener: true, queue: queue)) - options.initialMaxStreamsBidirectional = QUICChannelRegistry.maxStreams - options.initialMaxStreamsUnidirectional = 0 + QUICReceiverListener.configureStreamLimits(options) return options } - private func startedListener(_ parameters: NWParameters, on port: NWEndpoint.Port) throws -> NWListener { - let listener = try NWListener(using: parameters, on: port) - listener.newConnectionGroupHandler = { group in group.cancel() } - listener.start(queue: queue) - waitUntil(3) { - switch listener.state { - case .ready, .failed, .cancelled: return true - default: return false - } - } - return listener - } - - /// The listener's UDP port must never be shared with a second binding - /// (a stale listener during a re-arm): with local-endpoint reuse — what - /// the receiver used before — a second listener on the same port comes - /// up silently, which is the precondition for another binding's - /// new-flow path seeing a live connection's datagrams. - func testProductionQUICListenerNeverSharesItsUDPPort() throws { - let identity = try makeIdentity() - let peer = try makeIdentity().spki - let production = QUICReceiverListener.listenerParameters(quic: try receiverOptions(identity, pinning: peer)) - XCTAssertFalse(production.allowLocalEndpointReuse) - XCTAssertTrue(production.includePeerToPeer) - - let first = try startedListener(production, on: .any) - guard first.state == .ready, let port = first.port else { - first.cancel() - throw XCTSkip("loopback QUIC listener unavailable (\(first.state))") - } - let second = try startedListener( - QUICReceiverListener.listenerParameters(quic: try receiverOptions(identity, pinning: peer)), on: port) - XCTAssertNotEqual(second.state, .ready, "a second production QUIC listener must not share UDP \(port)") - second.cancel() - - first.cancel() - } - private final class GroupEvents: @unchecked Sendable { private let lock = NSLock() private var _controls = 0 @@ -400,12 +360,16 @@ final class QUICSecureTransportTests: XCTestCase { var owners: [QUICReceiverGroup] { lock.lock(); defer { lock.unlock() }; return _owners } } - /// The real receiver edge — production listener parameters and - /// `QUICReceiverGroup` accept/preface handling — over real QUIC, dialed - /// the way `MacSenderTransportController.openQUICStreams` does (three - /// streams opened together, each preface sent right after `start`). The - /// previous loopback tests used their own ad-hoc stream handling, so - /// this production path had never run over a real connection. + /// The real receiver edge — production listener parameters, stream limits + /// and `QUICReceiverGroup` accept/preface handling — over real QUIC, + /// dialed the way `MacSenderTransportController.openQUICStreams` does + /// (three streams opened together, each preface sent right after + /// `start`). The earlier loopback tests used ad-hoc stream handling and + /// only two streams, so they missed both defects the physical iPhone hit: + /// Network.framework's own extra (never-ready) object delivered to + /// `newConnectionHandler` was treated as a MEOW stream, and a stream + /// limit of 3 blocked the sender's third stream because stream 0 is + /// Network.framework's. func testProductionReceiverGroupAcceptsAllThreeChannels() throws { let server = try makeIdentity() let client = try makeIdentity() @@ -435,6 +399,7 @@ final class QUICSecureTransportTests: XCTestCase { let group = NWConnectionGroup(with: NWMultiplexGroup(to: .hostPort(host: "127.0.0.1", port: port)), using: NWParameters(quic: try clientOptions(client, pinning: server.spki))) let streams = OutcomeStreams() + let receiverOpened = GroupEvents() group.stateUpdateHandler = { state in guard case .ready = state else { return } for channel in [TransportChannel.control, .video, .audio] { @@ -445,7 +410,11 @@ final class QUICSecureTransportTests: XCTestCase { stream.send(content: QUICStreamPreface(channel: channel).encode(), completion: .contentProcessed { _ in }) } } - group.newConnectionHandler = { stream in stream.cancel() } + // Production treats any stream arriving here as a protocol violation. + group.newConnectionHandler = { stream in + receiverOpened.control() + stream.cancel() + } group.start(queue: queue) waitUntil(8) { events.controls == 1 && events.mediaCount == 2 } @@ -454,6 +423,11 @@ final class QUICSecureTransportTests: XCTestCase { XCTAssertEqual(events.owners.count, 1, "all three streams ride one QUIC connection") XCTAssertFalse(events.owners.first?.isClosed ?? true, "a healthy connection is not closed") XCTAssertEqual(events.owners.first?.takePendingMedia().count, 2) + // Past the preface timeout: nothing (e.g. Network.framework's own + // never-ready object) may close the healthy connection later. + waitUntil(QUICReceiverLimits.prefaceTimeout + 1) { false } + XCTAssertFalse(events.owners.first?.isClosed ?? true, "still open after the preface timeout") + XCTAssertEqual(receiverOpened.controls, 0, "the dialer never sees a receiver-opened stream") group.cancel() streams.cancelAll() diff --git a/MacTests/QUICStreamDiagnosticsTests.swift b/MacTests/QUICStreamDiagnosticsTests.swift deleted file mode 100644 index 1973227f..00000000 --- a/MacTests/QUICStreamDiagnosticsTests.swift +++ /dev/null @@ -1,185 +0,0 @@ -import XCTest -import Network -import Security -import CryptoKit -import X509 -import SwiftASN1 - -/// TEMPORARY diagnostics (removed before merge): what a production-shaped -/// QUIC listener actually receives per stream, under variants of the -/// sender's stream pattern. Logs only; asserts nothing. -final class QUICStreamDiagnosticsTests: XCTestCase { - private let queue = DispatchQueue(label: "quic.stream.diagnostics") - private var keychain: SecKeychain? - private var keychainPath = "" - - override func setUpWithError() throws { - keychainPath = NSTemporaryDirectory() + "meow-quic-diag-\(UUID().uuidString).keychain" - let password = UUID().uuidString - var created: SecKeychain? - guard SecKeychainCreate(keychainPath, UInt32(password.utf8.count), password, false, nil, &created) == errSecSuccess, - let created else { throw XCTSkip("no keychain") } - keychain = created - } - - override func tearDown() { - if let keychain { SecKeychainDelete(keychain) } - try? FileManager.default.removeItem(atPath: keychainPath) - } - - private final class Log2: @unchecked Sendable { - private let lock = NSLock() - private var lines: [String] = [] - private let start = Date() - func add(_ s: String) { - lock.lock(); lines.append(String(format: "+%.0fms ", Date().timeIntervalSince(start) * 1000) + s); lock.unlock() - } - var all: [String] { lock.lock(); defer { lock.unlock() }; return lines } - } - - private func identity() throws -> (SecIdentity, Data) { - // Same construction as QUICSecureTransportTests.makeIdentity. - let test = QUICSecureTransportTestsIdentityFactory(keychain: keychain!) - return try test.make() - } - - private func wait(_ t: TimeInterval) { RunLoop.current.run(until: Date().addingTimeInterval(t)) } - - private func run(_ name: String, serverLimits: Bool, payloadWithPreface: Bool, channels: [TransportChannel], - bidiLimit: Int = QUICChannelRegistry.maxStreams) throws { - let (serverID, serverSPKI) = try identity() - let (clientID, clientSPKI) = try identity() - let log = Log2() - let serverOptions = try XCTUnwrap(TLSConfigurator.pinnedQUICOptions( - identity: serverID, pinnedSPKIs: { [clientSPKI] }, isListener: true, queue: queue)) - if serverLimits { - serverOptions.initialMaxStreamsBidirectional = bidiLimit - serverOptions.initialMaxStreamsUnidirectional = 0 - } - let listener = try NWListener(using: QUICReceiverListener.listenerParameters(quic: serverOptions), on: .any) - let queue = self.queue - let holder = Holder() - listener.newConnectionGroupHandler = { group in - log.add("server: group accepted") - group.stateUpdateHandler = { state in log.add("server: group state \(state)") } - group.newConnectionHandler = { stream in - let md = stream.metadata(definition: NWProtocolQUIC.definition) as? NWProtocolQUIC.Metadata - log.add("server: stream delivered id=\(md.map { String($0.streamIdentifier) } ?? "nil") state=\(stream.state)") - holder.keep(stream) - stream.stateUpdateHandler = { state in - let md2 = stream.metadata(definition: NWProtocolQUIC.definition) as? NWProtocolQUIC.Metadata - log.add("server: stream id=\(md2.map { String($0.streamIdentifier) } ?? "nil") state \(state)") - if case .ready = state { - let label = md2.map { String($0.streamIdentifier) } ?? "nil" - Self.readAll(stream, label: label, log: log) - } - } - stream.start(queue: queue) - } - holder.keep(group) - group.start(queue: queue) - } - listener.start(queue: queue) - let deadline = Date().addingTimeInterval(5) - while listener.state != .ready && Date() < deadline { wait(0.05) } - log.add("server: listener \(listener.state) port=\(String(describing: listener.port))") - let clientOptions = try XCTUnwrap(TLSConfigurator.pinnedQUICOptions( - identity: clientID, pinnedSPKIs: { [serverSPKI] }, isListener: false, queue: queue)) - let group = NWConnectionGroup(with: NWMultiplexGroup(to: .hostPort(host: "127.0.0.1", port: listener.port!)), - using: NWParameters(quic: clientOptions)) - group.stateUpdateHandler = { state in - log.add("client: group state \(state)") - guard case .ready = state else { return } - for channel in channels { - let created: NWConnection? = NWConnection(from: group) - guard let stream = created else { log.add("client: NWConnection(from:) nil"); return } - holder.keep(stream) - stream.stateUpdateHandler = { s in - let md = stream.metadata(definition: NWProtocolQUIC.definition) as? NWProtocolQUIC.Metadata - log.add("client: \(channel) id=\(md.map { String($0.streamIdentifier) } ?? "nil") state \(s)") - } - stream.start(queue: queue) - var bytes = QUICStreamPreface(channel: channel).encode() - if payloadWithPreface { bytes += QUICFrameAssembler.frame(Data("{\"type\":\"x\"}".utf8)) } - stream.send(content: bytes, completion: .contentProcessed { e in - log.add("client: \(channel) sent \(bytes.count)B error=\(String(describing: e))") - }) - } - } - group.newConnectionHandler = { $0.cancel() } - group.start(queue: queue) - wait(4) - print("=== QUICDIAG \(name) ===") - for l in log.all { print("QUICDIAG \(name) \(l)") } - group.cancel() - listener.cancel() - holder.cancelAll() - wait(0.3) - } - - private static func readAll(_ stream: NWConnection, label: String, log: Log2) { - stream.receive(minimumIncompleteLength: 1, maximumLength: 64) { data, _, complete, error in - log.add("server: stream id=\(label) read \(data?.count ?? -1)B first=\(data.map { Array($0.prefix(8)) } ?? []) complete=\(complete) error=\(String(describing: error))") - if data != nil, error == nil, !complete { readAll(stream, label: label, log: log) } - } - } - - private final class Holder: @unchecked Sendable { - private let lock = NSLock() - private var connections: [NWConnection] = [] - private var groups: [NWConnectionGroup] = [] - func keep(_ c: NWConnection) { lock.lock(); connections.append(c); lock.unlock() } - func keep(_ g: NWConnectionGroup) { lock.lock(); groups.append(g); lock.unlock() } - func cancelAll() { - lock.lock(); let c = connections; let g = groups; connections = []; groups = []; lock.unlock() - c.forEach { $0.cancel() }; g.forEach { $0.cancel() } - } - } - - func testDiagnoseStreamDelivery() throws { - try run("A-limits3-prefaceOnly-3streams", serverLimits: true, payloadWithPreface: false, - channels: [.control, .video, .audio]) - try run("B-limits3-withPayload-3streams", serverLimits: true, payloadWithPreface: true, - channels: [.control, .video, .audio]) - try run("C-defaultLimits-prefaceOnly-3streams", serverLimits: false, payloadWithPreface: false, - channels: [.control, .video, .audio]) - try run("D-limits3-prefaceOnly-2streams", serverLimits: true, payloadWithPreface: false, - channels: [.video, .control]) - try run("E-limits4-prefaceOnly-3streams", serverLimits: true, payloadWithPreface: false, - channels: [.control, .video, .audio], bidiLimit: 4) - } -} - -/// Identity factory shared with the diagnostics (mirrors QUICSecureTransportTests). -struct QUICSecureTransportTestsIdentityFactory { - let keychain: SecKeychain - - func make() throws -> (SecIdentity, Data) { - var cfError: Unmanaged? - guard let privateKey = SecKeyCreateRandomKey([ - kSecAttrKeyType: kSecAttrKeyTypeECSECPrimeRandom, - kSecAttrKeySizeInBits: 256, - kSecUseKeychain: keychain, - kSecPrivateKeyAttrs: [kSecAttrIsPermanent: true], - ] as CFDictionary, &cfError), - let publicKey = SecKeyCopyPublicKey(privateKey), - let x963 = SecKeyCopyExternalRepresentation(publicKey, &cfError) as Data?, - let p256 = try? P256.Signing.PublicKey(x963Representation: x963) else { throw XCTSkip("key") } - let signer = Certificate.PrivateKey(P256.Signing.PrivateKey()) - let name = try DistinguishedName { CommonName("meow-diag-\(UUID().uuidString)") } - let now = Date() - let certificate = try Certificate( - version: .v3, serialNumber: Certificate.SerialNumber(), publicKey: Certificate.PublicKey(p256), - notValidBefore: now.addingTimeInterval(-3600), notValidAfter: now.addingTimeInterval(3600), - issuer: name, subject: name, signatureAlgorithm: .ecdsaWithSHA256, - extensions: try Certificate.Extensions { Critical(BasicConstraints.notCertificateAuthority) }, - issuerPrivateKey: signer) - var serializer = DER.Serializer() - try serializer.serialize(certificate) - guard let secCert = SecCertificateCreateWithData(nil, Data(serializer.serializedBytes) as CFData) else { throw XCTSkip("cert") } - guard SecItemAdd([kSecClass: kSecClassCertificate, kSecValueRef: secCert, kSecUseKeychain: keychain] as CFDictionary, nil) == errSecSuccess else { throw XCTSkip("add") } - var identity: SecIdentity? - guard SecIdentityCreateWithCertificate(keychain, secCert, &identity) == errSecSuccess, let identity else { throw XCTSkip("identity") } - return (identity, p256.derRepresentation) - } -} diff --git a/PROTOCOL.md b/PROTOCOL.md index 4f3ec3e8..75f6df54 100644 --- a/PROTOCOL.md +++ b/PROTOCOL.md @@ -210,7 +210,10 @@ After the preface each stream uses the section 3 framing the Control stream's reverse direction. Receivers MUST reject (close the QUIC connection): a bad magic, version or non-zero flags; an unknown channel; a second stream for a channel; more than three streams; any -unidirectional stream; a payload of the wrong kind for its channel; a +unidirectional stream (the receiver advertises a bidirectional stream limit +of four, because Apple's Network.framework uses QUIC stream 0 itself and the +sender's streams start at stream 4; only streams that actually open count +toward the three); a payload of the wrong kind for its channel; a declared length of 0 or above the channel cap (Control and Audio 1 MiB, Video 32 MiB — the raw 4:2:0 size of a 4096×4096 frame plus headroom, larger than any legitimate access unit at the receivers' 4096-pixel decode diff --git a/Shared/QUICReceiverSession.swift b/Shared/QUICReceiverSession.swift index 1602fe3f..f1a64403 100644 --- a/Shared/QUICReceiverSession.swift +++ b/Shared/QUICReceiverSession.swift @@ -31,6 +31,14 @@ enum QUICReceiverLimits { static let maxLiveGroups = 4 /// From stream arrival to a complete, valid 8-byte preface. static let prefaceTimeout: TimeInterval = 5 + /// The QUIC `initial_max_streams_bidi` transport parameter. Network. + /// framework's own use of stream 0 counts against it (the sender's + /// first stream is ID 4), so exactly three MeowDisplay streams need a + /// limit of four; at three, the sender's third stream stayed blocked. + static let transportBidirectionalStreamLimit = QUICChannelRegistry.maxStreams + 1 + /// Objects `newConnectionHandler` may deliver per connection: the three + /// streams plus Network.framework's own one. One more is a violation. + static let maxDeliveredStreamObjects = QUICChannelRegistry.maxStreams + 1 /// From connection acceptance to its Control stream reaching the session /// pipeline; a connection that never presents Control is closed. static let applicationHandshakeTimeout: TimeInterval = 10 @@ -264,8 +272,18 @@ final class QUICReceiverGroup: @unchecked Sendable { } } - /// A stream the Mac opened. Topology is enforced before anything is - /// read from it; its channel comes only from its preface. + /// An object the QUIC connection handed to `newConnectionHandler`. + /// + /// Not every such object is a MeowDisplay stream: Network.framework also + /// delivers one of its own per connection (the connection uses QUIC + /// stream 0 itself — the sender's first stream is ID 4), which never + /// becomes `.ready` and never carries data. Reading it fails at once on + /// iPhone ("Socket is not connected"), which previously closed every + /// connection as `invalidStreamPreface`. So nothing is read, timed or + /// counted against the channel topology until an object is `.ready`; a + /// not-yet-ready object that fails is simply dropped (the connection's + /// own state reports a broken connection). Delivered objects are still + /// bounded, so a peer cannot pile them up. func accept(_ stream: NWConnection) { lock.lock() if closed { @@ -273,55 +291,79 @@ final class QUICReceiverGroup: @unchecked Sendable { stream.cancel() return } - // The receiver never opens streams, so every incoming stream is - // sender-initiated; unidirectional streams are refused at the - // transport (`initialMaxStreamsUnidirectional = 0`). - let topologyError = channels.beginStream(initiatedBy: .sender) - if topologyError == nil { streams.append(stream) } + let overLimit = deliveredObjects >= QUICReceiverLimits.maxDeliveredStreamObjects + if !overLimit { + deliveredObjects += 1 + streams.append(stream) + } lock.unlock() - if let topologyError { + if overLimit { stream.cancel() - close(topologyError) + close(.protocolViolation) return } - let id = self.id - // Read nothing until the accepted stream is `.ready`. A stream that - // fails first is a TRANSPORT failure of this connection (on iOS the - // accepted stream flow is still setting up when it is handed to us, - // and dies with its connection) — never a peer protocol violation. stream.stateUpdateHandler = { [weak self] state in guard let self, !self.isClosed, !self.isRegistered(stream) else { return } switch state { case .ready: - #if DEBUG - let streamID = (stream.metadata(definition: NWProtocolQUIC.definition) as? NWProtocolQUIC.Metadata) - .map { String($0.streamIdentifier) } ?? "?" - Log.info("quicListener: connection \(id) stream \(streamID) ready — reading its preface") - #endif - self.readPreface(of: stream) + self.streamBecameReady(stream) case .failed(let error), .waiting(let error): - self.closeForTransportFailure("stream \(state) before its preface: \(error)") + if self.isOpened(stream) { + // A real stream died before its preface: the connection + // is broken — a transport failure, not a violation. + self.closeForTransportFailure("stream \(state) before its preface: \(error)") + } else { + // Never became a stream (e.g. Network.framework's own + // object): nothing of ours depends on it. + stream.cancel() + } default: break } } stream.start(queue: queue) + } + + /// A delivered object became a usable stream: it now counts against the + /// three-channel topology and must present its preface in time. + private func streamBecameReady(_ stream: NWConnection) { + lock.lock() + let key = ObjectIdentifier(stream) + guard !opened.contains(key) else { + lock.unlock() + return + } + let topologyError = channels.beginStream(initiatedBy: .sender) + if topologyError == nil { opened.insert(key) } + lock.unlock() + if let topologyError { + stream.cancel() + close(topologyError) + return + } + let id = self.id + #if DEBUG + let streamID = (stream.metadata(definition: NWProtocolQUIC.definition) as? NWProtocolQUIC.Metadata) + .map { String($0.streamIdentifier) } ?? "?" + Log.info("quicListener: connection \(id) stream \(streamID) ready — reading its preface") + #endif queue.asyncAfter(deadline: .now() + QUICReceiverLimits.prefaceTimeout) { [weak self] in guard let self, !self.isClosed, !self.isRegistered(stream) else { return } Log.info("quicListener: connection \(id) stream sent no valid preface in time — closing") self.close(.invalidStreamPreface) } + readPreface(of: stream) + } + + private func isOpened(_ stream: NWConnection) -> Bool { + lock.lock(); defer { lock.unlock() } + return opened.contains(ObjectIdentifier(stream)) } /// Exactly 8 bytes: Network.framework reassembles a split preface, and - /// nothing past it is consumed here. Only ever issued once per stream, - /// from its `.ready` transition. + /// nothing past it is consumed here. Issued once per stream, from + /// `streamBecameReady`. private func readPreface(of stream: NWConnection) { - lock.lock() - let first = !prefaceReads.contains(ObjectIdentifier(stream)) - if first { prefaceReads.insert(ObjectIdentifier(stream)) } - lock.unlock() - guard first else { return } stream.receive(minimumIncompleteLength: QUICStreamPreface.length, maximumLength: QUICStreamPreface.length) { [weak self] data, _, isComplete, error in guard let self else { return } @@ -368,8 +410,10 @@ final class QUICReceiverGroup: @unchecked Sendable { } private var registeredStreams: [ObjectIdentifier] = [] - /// Streams whose single preface read has been issued (under `lock`). - private var prefaceReads: Set = [] + /// Delivered objects that reached `.ready` and count as streams (under `lock`). + private var opened: Set = [] + /// Everything `newConnectionHandler` delivered, ready or not (under `lock`). + private var deliveredObjects = 0 private func isRegistered(_ stream: NWConnection) -> Bool { lock.lock(); defer { lock.unlock() } @@ -455,10 +499,7 @@ enum QUICReceiverListener { identity: identity, pinnedSPKIs: { TrustStore.shared.allPinnedPeerSPKIs() }, isListener: true, queue: queue) else { return } - // Exactly the three bidirectional sender-opened streams; no - // unidirectional streams (wrong direction for this protocol). - quic.initialMaxStreamsBidirectional = QUICChannelRegistry.maxStreams - quic.initialMaxStreamsUnidirectional = 0 + configureStreamLimits(quic) do { let params = listenerParameters(quic: quic) let listener = try NWListener(using: params, on: NWEndpoint.Port(rawValue: QUICTransport.udpPort)!) @@ -518,27 +559,23 @@ enum QUICReceiverListener { } /// The QUIC listener's parameters — the one definition production and - /// the loopback QUIC tests share. - /// - /// Deliberately WITHOUT `allowLocalEndpointReuse` (unlike the TCP - /// listener, where it only eases re-binding after TIME_WAIT). A QUIC - /// server demultiplexes every accepted connection by UDP 4-tuple on the - /// listener's own port; with local-endpoint reuse on, a second binding - /// of UDP 9001 (a stale listener during a re-arm) is silently allowed, - /// and on iPhone the listener's new-flow path was observed receiving - /// datagrams of an already-accepted connection, failing to register a - /// duplicate flow for that same 4-tuple (NECP ADD_FLOW "File exists"), - /// and taking the live connection down with it (EINVAL). Without reuse a - /// re-arm that races an old binding fails with EADDRINUSE and retries - /// (`startIfNeeded`'s 1 s backoff) instead of sharing the port. + /// the loopback QUIC tests share, so CI exercises this configuration. static func listenerParameters(quic: NWProtocolQUIC.Options) -> NWParameters { let params = NWParameters(quic: quic) params.includePeerToPeer = true - params.allowLocalEndpointReuse = false + params.allowLocalEndpointReuse = true params.serviceClass = .interactiveVideo return params } + /// The three bidirectional sender-opened streams (plus Network.framework's + /// own stream 0 — see `transportBidirectionalStreamLimit`); no + /// unidirectional streams (wrong direction for this protocol). + static func configureStreamLimits(_ quic: NWProtocolQUIC.Options) { + quic.initialMaxStreamsBidirectional = QUICReceiverLimits.transportBidirectionalStreamLimit + quic.initialMaxStreamsUnidirectional = 0 + } + /// `_meowdisp-q._udp`: `id`/`pv`/`qv` only — a capability HINT, never /// trust, never the one-shot `cr` token. static func quicService(advertisementState: ReceiverAdvertisementState, installID: String, From 5e9f45ff35ee25255352fd0dde8c9019a44675ca Mon Sep 17 00:00:00 2001 From: raiseCatError <315733358+raiseCatError@users.noreply.github.com> Date: Sun, 27 Sep 2026 21:14:09 +0000 Subject: [PATCH 05/30] fix: let a waiting QUIC group reach ready; step aside for iPad system gestures QUIC (Mac sender): NWConnectionGroup `.waiting` is no longer fatal. A physical Mac -> iPhone dial reported `.waiting(ENETDOWN)` and then `.ready`; failing on `.waiting` tore the tunnel down before its streams opened. The state handling moves into a pure QUICGroupStatePolicy: `.waiting` is uniformly nonterminal, `.failed` stays fatal, `.cancelled` is unchanged, and the existing 8 s application-handshake timeout still bounds a group that never becomes ready. Control-stream adoption (becomeReady), endpoint selection, includePeerToPeer, stream limits, pinned mutual TLS, ALPN and no-0-RTT are unchanged. iPad receiver: - VideoView opts out of the three-finger editing interactions. - Screen-edge system gestures are deferred only while the remote surface is shown and input may reach the Mac. - While VoiceOver runs, every custom multi-finger recognizer (two-finger viewport, two-finger double-tap, three-finger swipe/tap, 4/5-finger pinch/spread) steps aside, following runtime VoiceOver changes; force-cancel toggles restore the gate's state, never a blind `true`. - Opt-in (hidden) Function Tray items for Launchpad and Show Desktop, which have no reliable keyboard shortcut; saved profiles gain them hidden. - Document why UIRequiresFullScreen stays for now. Tests cover the group-state policy, the handshake-timeout class, explicit QUIC never falling back to TCP, a loopback run of the production dialer adopting the Control stream, the VoiceOver gate and the new tray items. --- Mac/MacSenderTransportController.swift | 33 ++++---- Mac/TransportProtocolSelection.swift | 42 ++++++++++ MacTests/InputRoutingTests.swift | 36 ++++++++ MacTests/QUICSecureTransportTests.swift | 82 +++++++++++++++++++ MacTests/ReceiverControlsTests.swift | 40 +++++++++ .../TransportProtocolSelectionTests.swift | 72 ++++++++++++++++ PROTOCOL.md | 4 + Shared/ControlProfile.swift | 46 +++++++++-- Shared/ReceiverGesture.swift | 52 ++++++++++++ iOS/OpenSidecarPhoneApp.swift | 73 +++++++++++++++-- iOS/ReceiverControls.swift | 11 ++- project.yml | 5 ++ 12 files changed, 460 insertions(+), 36 deletions(-) diff --git a/Mac/MacSenderTransportController.swift b/Mac/MacSenderTransportController.swift index 70596566..ec8e648f 100644 --- a/Mac/MacSenderTransportController.swift +++ b/Mac/MacSenderTransportController.swift @@ -719,11 +719,11 @@ extension MacSenderTransportController { queue.asyncAfter(deadline: .now() + Self.quicApplicationHandshakeTimeout) { [weak self] in guard let self, let session = self.currentQUICSession(generation: generation), !session.isApplicationReady else { return } - // Nothing answered the handshake at all: ordinary reachability. - // A peer that DID complete the pinned handshake but then never - // said hello is not "unreachable" — no fallback for that. - let failureClass: QUICFailureClass = session.groupReadyAt == nil && session.peerVerifiedAt == nil - ? .reachability : .indeterminate + // Also bounds a group that sits in `.waiting` and never becomes + // ready. A peer that DID complete the pinned handshake but then + // never said hello is not "unreachable" — no fallback for that. + let failureClass = QUICGroupStatePolicy.handshakeTimeoutClass( + groupReady: session.groupReadyAt != nil, peerVerified: session.peerVerifiedAt != nil) self.failQUIC(generation: generation, error: nil, failureClass: failureClass, detail: "no authenticated application handshake within " + "\(Int(Self.quicApplicationHandshakeTimeout))s") @@ -749,21 +749,18 @@ extension MacSenderTransportController { private func handleQUICGroupState(_ state: NWConnectionGroup.State, generation: Int) { guard let session = currentQUICSession(generation: generation) else { return } - switch state { - case .ready: - guard session.groupReadyAt == nil else { return } + switch QUICGroupStatePolicy.action(for: state, groupAlreadyReady: session.groupReadyAt != nil) { + case .openStreams: session.groupReadyAt = Date() openQUICStreams(session) - case .waiting(let error): - failQUIC(generation: generation, error: error, - failureClass: QUICFailureClassifier.classify(error), detail: "group waiting") - case .failed(let error): - failQUIC(generation: generation, error: error, - failureClass: QUICFailureClassifier.classify(error), detail: "group failed") - case .cancelled: - failQUIC(generation: generation, error: nil, failureClass: .indeterminate, detail: "group cancelled") - default: - break + case .fail(let failureClass, let detail): + var error: NWError? + if case .failed(let stateError) = state { error = stateError } + failQUIC(generation: generation, error: error, failureClass: failureClass, detail: detail) + case .ignore: + if case .waiting(let error) = state { + Log.info("quic: group waiting generation=\(generation) error=\(error) — letting it retry") + } } } diff --git a/Mac/TransportProtocolSelection.swift b/Mac/TransportProtocolSelection.swift index 1ec69924..cb7feca5 100644 --- a/Mac/TransportProtocolSelection.swift +++ b/Mac/TransportProtocolSelection.swift @@ -93,6 +93,48 @@ enum QUICFailureClassifier { // a TCP downgrade. } +/// What the dialing Mac does with each `NWConnectionGroup` state of its +/// QUIC tunnel. Pure so the transient-vs-fatal split is testable without a +/// network. +enum QUICGroupStatePolicy { + enum Action: Equatable { + /// First `.ready`: open Control, Video and Audio. + case openStreams + /// Nothing to do — including `.waiting`, which Network.framework + /// retries on its own (a physical Mac -> iPhone dial reported + /// `.waiting(ENETDOWN)` and then `.ready` moments later). + case ignore + case fail(QUICFailureClass, detail: String) + } + + /// `.waiting` is never terminal, whatever error it carries: `.failed` is + /// the group's fatal state. A group that never progresses is bounded by + /// the application-handshake timer before the hello, and by the ping + /// watchdog after it. + static func action(for state: NWConnectionGroup.State, groupAlreadyReady: Bool) -> Action { + switch state { + case .ready: + return groupAlreadyReady ? .ignore : .openStreams + case .waiting: + return .ignore + case .failed(let error): + return .fail(QUICFailureClassifier.classify(error), detail: "group failed") + case .cancelled: + return .fail(.indeterminate, detail: "group cancelled") + default: + return .ignore + } + } + + /// Class of the application-handshake timeout. Only a tunnel that never + /// became ready AND never showed the receiver's pinned certificate is + /// "unreachable"; a peer that completed the handshake but never said + /// hello is not, so it never earns an Auto fallback. + static func handshakeTimeoutClass(groupReady: Bool, peerVerified: Bool) -> QUICFailureClass { + !groupReady && !peerVerified ? .reachability : .indeterminate + } +} + /// What the Mac knows about this peer's QUIC support, strongest first. enum PeerQUICSupport: Equatable, Sendable { /// Positive capability from a previous AUTHENTICATED hello. diff --git a/MacTests/InputRoutingTests.swift b/MacTests/InputRoutingTests.swift index dd92b35f..471ee262 100644 --- a/MacTests/InputRoutingTests.swift +++ b/MacTests/InputRoutingTests.swift @@ -2,6 +2,42 @@ import CoreGraphics import XCTest final class InputRoutingTests: XCTestCase { + /// VoiceOver owns multi-finger gestures: every custom multi-finger + /// recognizer — two-finger viewport/scroll, two-finger double-tap, + /// three-finger swipe and tap, 4/5-finger pinch/spread — steps aside. + func testEveryMultiFingerRecognizerStepsAsideWhileVoiceOverRuns() { + XCTAssertEqual(Set(ReceiverMultiFingerRecognizer.allCases), + [.twoFingerViewport, .viewportDoubleTap, .threeFingerSwipe, .threeFingerTap, .pinchSpread]) + let off = ReceiverMultiFingerGestureGate(voiceOverRunning: false) + let on = ReceiverMultiFingerGestureGate(voiceOverRunning: true) + for kind in ReceiverMultiFingerRecognizer.allCases { + XCTAssertTrue(off.isEnabled(kind), "\(kind)") + XCTAssertFalse(on.isEnabled(kind), "\(kind)") + } + } + + /// VoiceOver toggled while the app runs: the gate follows it both ways + /// and reports only real changes (so the view re-applies only then). + func testMultiFingerGateFollowsRuntimeVoiceOverChanges() { + var gate = ReceiverMultiFingerGestureGate(voiceOverRunning: false) + XCTAssertFalse(gate.update(voiceOverRunning: false)) + XCTAssertTrue(gate.update(voiceOverRunning: true)) + XCTAssertFalse(gate.recognizersEnabled) + XCTAssertFalse(gate.update(voiceOverRunning: true)) + XCTAssertTrue(gate.update(voiceOverRunning: false)) + XCTAssertTrue(gate.recognizersEnabled) + for kind in ReceiverMultiFingerRecognizer.allCases { XCTAssertTrue(gate.isEnabled(kind)) } + } + + /// Screen-edge deferral only while the remote surface is shown and input + /// may reach the Mac — never on the idle screen or with input off. + func testScreenEdgesAreDeferredOnlyForALiveInputSurface() { + XCTAssertTrue(ReceiverScreenEdgePolicy.defersScreenEdges(surfaceShown: true, inputAllowed: true)) + XCTAssertFalse(ReceiverScreenEdgePolicy.defersScreenEdges(surfaceShown: true, inputAllowed: false)) + XCTAssertFalse(ReceiverScreenEdgePolicy.defersScreenEdges(surfaceShown: false, inputAllowed: true)) + XCTAssertFalse(ReceiverScreenEdgePolicy.defersScreenEdges(surfaceShown: false, inputAllowed: false)) + } + func testThreeFingerDirectionsRequireMeaningfulPredominantMovement() { XCTAssertNil(ReceiverGesture.swipe(translationX: 0, translationY: -79.9)) XCTAssertEqual(ReceiverGesture.swipe(translationX: 0, translationY: -80), .missionControl) diff --git a/MacTests/QUICSecureTransportTests.swift b/MacTests/QUICSecureTransportTests.swift index 3f85ae73..721a31f4 100644 --- a/MacTests/QUICSecureTransportTests.swift +++ b/MacTests/QUICSecureTransportTests.swift @@ -435,6 +435,88 @@ final class QUICSecureTransportTests: XCTestCase { listener.cancel() } + private final class ControllerEvents: MacSenderTransportDelegate, @unchecked Sendable { + private let lock = NSLock() + private let transport: SenderTransport + private var _ready: [NWConnection] = [] + private var _receiving: [NWConnection] = [] + private var _failures: [QUICTransportFailure] = [] + init(transport: SenderTransport) { self.transport = transport } + func transportSessionBecameReady(on connection: NWConnection) { lock.lock(); _ready.append(connection); lock.unlock() } + func transportShouldBeginReceiving(on connection: NWConnection) { lock.lock(); _receiving.append(connection); lock.unlock() } + func transportPathChanged(_ route: ConnectionRoute?) {} + func transportSessionInvalidated(reason: String) {} + func transportLinkDied(_ detail: String) {} + func transportCancelActiveInput() {} + func transportPeerDeviceKind() -> String? { nil } + func transportDialingContext() -> (transport: SenderTransport, peerAddrs: [String]) { (transport, []) } + func transportQUICFailed(_ failure: QUICTransportFailure) { lock.lock(); _failures.append(failure); lock.unlock() } + var ready: [NWConnection] { lock.lock(); defer { lock.unlock() }; return _ready } + var receiving: [NWConnection] { lock.lock(); defer { lock.unlock() }; return _receiving } + var failures: [QUICTransportFailure] { lock.lock(); defer { lock.unlock() }; return _failures } + } + + /// The production dialer end to end: `MacSenderTransportController. + /// connectQUIC` against the production receiver group. Once the group is + /// ready, the Control stream must be ADOPTED — `becomeReady` flips the + /// controller ready and starts the control read loop — or the hello is + /// never sent and the session dies at the handshake timeout. This fails + /// if that adoption path is removed or the group state policy stops + /// opening streams. + func testProductionSenderControllerAdoptsControlStreamOnceReady() throws { + let server = try makeIdentity() + let client = try makeIdentity() + let listener = try NWListener( + using: QUICReceiverListener.listenerParameters(quic: try receiverOptions(server, pinning: client.spki)), + on: .any) + let registry = QUICReceiverGroupRegistry() + let events = GroupEvents() + let queue = self.queue + listener.newConnectionGroupHandler = { group in + let owner = registry.admit { id in + QUICReceiverGroup(id: id, group: group, registry: registry, queue: queue, + onControlStream: { _ in events.control() }, + onMediaReady: { _ in events.media() }) + } + guard let owner else { group.cancel(); return } + events.owner(owner) + owner.start() + } + listener.start(queue: queue) + waitUntil(5) { listener.port != nil && listener.state == .ready } + guard listener.state == .ready, let port = listener.port else { + listener.cancel() + throw XCTSkip("loopback QUIC listener unavailable (\(listener.state))") + } + + let endpoint = NWEndpoint.hostPort(host: "127.0.0.1", port: port) + let tls = TLSSessionConfig(identity: SendableSecIdentity(value: client.identity), + pinnedPeerSPKI: server.spki, peerID: "quic-controller-test") + let controllerQueue = DispatchQueue(label: "quic.secure.transport.tests.controller") + let controller = MacSenderTransportController(queue: controllerQueue, endpointName: "test", + statusSink: MacSenderStatusSink()) + let delegate = ControllerEvents(transport: .tcp(endpoint, tls: tls)) + controller.delegate = delegate + let dialed = controllerQueue.sync { controller.connectQUIC(to: endpoint, tls: tls) } + XCTAssertTrue(dialed) + + waitUntil(6) { delegate.receiving.count == 1 && events.controls == 1 && events.mediaCount == 2 } + XCTAssertEqual(delegate.ready.count, 1, "the Control stream was adopted (becomeReady)") + XCTAssertEqual(delegate.receiving.count, 1, "the control read loop was started") + controllerQueue.sync { + XCTAssertTrue(controller.isReady) + XCTAssertEqual(controller.activeNetworkProtocol, .quic) + XCTAssertTrue(controller.currentConnection === delegate.ready.first) + } + XCTAssertEqual(events.controls, 1) + XCTAssertEqual(events.mediaCount, 2) + XCTAssertTrue(delegate.failures.isEmpty, "no failure before the hello: \(delegate.failures)") + + controllerQueue.sync { controller.cancelAndClearConnection() } + registry.closeAll(error: nil) + listener.cancel() + } + private final class OutcomeStreams: @unchecked Sendable { private let lock = NSLock() private var streams: [NWConnection] = [] diff --git a/MacTests/ReceiverControlsTests.swift b/MacTests/ReceiverControlsTests.swift index 4538f301..46f2eb9b 100644 --- a/MacTests/ReceiverControlsTests.swift +++ b/MacTests/ReceiverControlsTests.swift @@ -501,6 +501,46 @@ final class ReceiverControlsTests: XCTestCase { XCTAssertEqual(resolved.items.first { $0.id == "undo" }?.item.title, "Undo") } + /// Launchpad and Show Desktop have no reliable Mac keyboard shortcut, so + /// these opt-in items are their only route besides the 4/5-finger pinch. + /// Hidden by default: the default tray is unchanged. + func testLaunchpadAndShowDesktopAreOptInFunctionTrayGestures() throws { + let profile = FunctionTrayProfile.canonical() + XCTAssertFalse(profile.visibleItems.contains { $0.id == "launchpad" || $0.id == "show-desktop" }) + let launchpad = try XCTUnwrap(profile.items.first { $0.id == "launchpad" }) + let showDesktop = try XCTUnwrap(profile.items.first { $0.id == "show-desktop" }) + XCTAssertFalse(launchpad.isVisible) + XCTAssertFalse(showDesktop.isVisible) + XCTAssertEqual(launchpad.item.action, .receiverGesture(ReceiverGesture.launchpad.rawValue)) + XCTAssertEqual(showDesktop.item.action, .receiverGesture(ReceiverGesture.showDesktop.rawValue)) + XCTAssertEqual(launchpad.group, showDesktop.group) + XCTAssertFalse(profile.items.filter { $0.item.action != launchpad.item.action + && $0.item.action != showDesktop.item.action }.contains { $0.group == launchpad.group }) + } + + func testSavedFunctionTrayGainsNewItemsHiddenWithoutChangingItsTray() throws { + var saved = FunctionTrayProfile.canonical(slot: .profile2) + saved.items.removeAll { $0.id == "launchpad" || $0.id == "show-desktop" } + saved.items.swapAt(0, 1) + saved.items[2].isVisible = false + let resolved = saved.resolvingCanonicalMetadata() + XCTAssertEqual(resolved.visibleItems.map(\.id), saved.visibleItems.map(\.id)) + XCTAssertEqual(Array(resolved.items.map(\.id).prefix(saved.items.count)), saved.items.map(\.id)) + XCTAssertEqual(Array(resolved.items.map(\.id).suffix(2)), ["launchpad", "show-desktop"]) + XCTAssertFalse(resolved.items.suffix(2).contains { $0.isVisible }) + // Idempotent: nothing is appended twice. + XCTAssertEqual(resolved.resolvingCanonicalMetadata().items.map(\.id), resolved.items.map(\.id)) + } + + func testReceiverGestureActionRoundTripsThroughPersistence() throws { + let item = ShortcutItem(id: "launchpad", title: "Launchpad", gesture: .launchpad, systemImage: "square.grid.3x3") + let decoded = try JSONDecoder().decode(ShortcutItem.self, from: JSONEncoder().encode(item)) + XCTAssertEqual(decoded, item) + let legacy = try JSONDecoder().decode(ShortcutItem.self, from: JSONEncoder().encode( + ShortcutItem(id: "undo", title: "Undo", displayKey: "Z", usage: 29, modifiers: ModifierChord([.command])))) + XCTAssertEqual(legacy.action, .keyboardShortcut(KeyboardShortcut(usage: 29, modifiers: ModifierChord([.command])))) + } + func testSchemaSevenProfilesGainDockImmediatelyBeforeKeyboard() throws { let suite = "ReceiverControlsMigrationTests.\(UUID().uuidString)" let defaults = try XCTUnwrap(UserDefaults(suiteName: suite)) diff --git a/MacTests/TransportProtocolSelectionTests.swift b/MacTests/TransportProtocolSelectionTests.swift index 1dc295fd..7ea57574 100644 --- a/MacTests/TransportProtocolSelectionTests.swift +++ b/MacTests/TransportProtocolSelectionTests.swift @@ -241,6 +241,78 @@ final class TransportProtocolSelectionTests: XCTestCase { XCTAssertEqual(selector.quicFailed(observed, preference: .auto), .retryQUIC, "no TCP fallback") } + // MARK: - QUIC group state policy (physical Mac -> iPhone regression) + + /// The physical dial reported `.waiting(ENETDOWN)` and then `.ready` + /// moments later; treating `.waiting` as fatal tore the tunnel down + /// before it could open its streams. + func testGroupWaitingIsNotFatal() { + for code in [POSIXErrorCode.ENETDOWN, .ENETUNREACH, .EHOSTUNREACH, .ECONNREFUSED, .ETIMEDOUT, .ENOTCONN] { + XCTAssertEqual(QUICGroupStatePolicy.action(for: .waiting(.posix(code)), groupAlreadyReady: false), + .ignore, "\(code)") + XCTAssertEqual(QUICGroupStatePolicy.action(for: .waiting(.posix(code)), groupAlreadyReady: true), + .ignore, "\(code) on a ready group") + } + // Uniformly nonterminal, whatever the error: only `.failed` is fatal. + for error in [NWError.tls(errSSLPeerCertUnknown), .tls(errSSLBadCert)] { + XCTAssertEqual(QUICGroupStatePolicy.action(for: .waiting(error), groupAlreadyReady: false), .ignore) + } + XCTAssertEqual(QUICGroupStatePolicy.action(for: .setup, groupAlreadyReady: false), .ignore) + } + + func testGroupReadyAfterWaitingOpensTheStreamsOnce() { + let sequence: [NWConnectionGroup.State] = [.setup, .waiting(.posix(.ENETDOWN)), .ready, .ready] + var groupReady = false + var opened = 0 + for state in sequence { + switch QUICGroupStatePolicy.action(for: state, groupAlreadyReady: groupReady) { + case .openStreams: + groupReady = true + opened += 1 + case .fail(let failureClass, let detail): + XCTFail("\(state) failed the tunnel: \(failureClass) \(detail)") + case .ignore: + break + } + } + XCTAssertEqual(opened, 1) + } + + func testGroupFailedIsFatalWithItsClassification() { + XCTAssertEqual(QUICGroupStatePolicy.action(for: .failed(.posix(.ENETDOWN)), groupAlreadyReady: false), + .fail(.reachability, detail: "group failed")) + XCTAssertEqual(QUICGroupStatePolicy.action(for: .failed(.posix(.ENOTCONN)), groupAlreadyReady: true), + .fail(.indeterminate, detail: "group failed")) + XCTAssertEqual(QUICGroupStatePolicy.action(for: .failed(.tls(errSSLBadCert)), groupAlreadyReady: false), + .fail(.security, detail: "group failed")) + XCTAssertEqual(QUICGroupStatePolicy.action(for: .cancelled, groupAlreadyReady: true), + .fail(.indeterminate, detail: "group cancelled")) + } + + /// The application-handshake timer still bounds a group that waits + /// forever; it is reachability only if nothing ever answered. + func testHandshakeTimeoutBoundsAGroupThatNeverProgresses() { + XCTAssertEqual(MacSenderTransportController.quicApplicationHandshakeTimeout, 8) + XCTAssertEqual(QUICGroupStatePolicy.handshakeTimeoutClass(groupReady: false, peerVerified: false), .reachability) + XCTAssertEqual(QUICGroupStatePolicy.handshakeTimeoutClass(groupReady: false, peerVerified: true), .indeterminate) + XCTAssertEqual(QUICGroupStatePolicy.handshakeTimeoutClass(groupReady: true, peerVerified: false), .indeterminate) + XCTAssertEqual(QUICGroupStatePolicy.handshakeTimeoutClass(groupReady: true, peerVerified: true), .indeterminate) + } + + func testExplicitQUICNeverFallsBackToTCP() { + for failureClass in [QUICFailureClass.security, .protocolViolation, .indeterminate, .reachability] { + for established in [false, true] { + var selector = TransportProtocolSelector() + let action = selector.quicFailed(failureClass, preference: .quic, established: established) + XCTAssertNotEqual(action, .fallbackToTCP, "\(failureClass) established=\(established)") + if failureClass == .security || failureClass == .protocolViolation { + XCTAssertEqual(action, .stop(failureClass)) + } + XCTAssertNotEqual(selector.decide(inputs(.quic)), .tcp) + } + } + } + // MARK: - Stores func testAuthenticatedCapabilityIsRememberedAndTransientAbsenceIsNot() { diff --git a/PROTOCOL.md b/PROTOCOL.md index 75f6df54..ceb8d841 100644 --- a/PROTOCOL.md +++ b/PROTOCOL.md @@ -252,6 +252,10 @@ removes it. otherwise QUIC. Whichever protocol first completes the authenticated `hello` is kept for the rest of the logical session — no periodic re-challenge, no metric-driven switching. +* A dial the platform reports as merely *waiting* (Network.framework's + `.waiting`, e.g. `ENETDOWN` while a path comes up) is not a failure: it + is left to become ready, bounded by the same 8 s application-handshake + timeout. Only the platform's *failed* state ends the dial. * Only an ordinary **reachability** failure (no answer within 8 s of dialing, ICMP-refused, unreachable network/host, DNS) lets Auto fall back to secure TCP; it then starts a 10-minute in-memory cooldown for that peer and route diff --git a/Shared/ControlProfile.swift b/Shared/ControlProfile.swift index 97fe41d7..69911a96 100644 --- a/Shared/ControlProfile.swift +++ b/Shared/ControlProfile.swift @@ -127,6 +127,11 @@ struct KeyboardShortcut: Codable, Equatable { enum ControlAction: Codable, Equatable { case keyboardShortcut(KeyboardShortcut) + /// A semantic `ReceiverGesture` (its raw value) sent as the existing + /// `gesture` message — for Mac actions that have no reliable keyboard + /// shortcut (Launchpad, Show Desktop), so they stay reachable if the + /// 4/5-finger pinch collides with iPadOS's multitasking gestures. + case receiverGesture(String) } struct ShortcutItem: Codable, Equatable, Identifiable { @@ -148,6 +153,14 @@ struct ShortcutItem: Codable, Equatable, Identifiable { action = .keyboardShortcut(KeyboardShortcut(usage: usage, modifiers: modifiers)) self.systemImage = systemImage } + + init(id: String, title: String, gesture: ReceiverGesture, systemImage: String) { + self.id = id + self.title = title + displayKey = title + action = .receiverGesture(gesture.rawValue) + self.systemImage = systemImage + } } struct ChordPalette: Codable, Equatable, Identifiable { @@ -433,16 +446,23 @@ struct FunctionTrayProfile: Codable, Equatable, Identifiable { } /// Refreshes presentation and shortcut metadata by stable action ID. - /// Visibility, ordering, and grouping remain entirely user-owned. + /// Visibility, ordering, and grouping remain entirely user-owned. A + /// canonical item a saved profile predates is appended HIDDEN, so the + /// editor can offer it without changing the tray the user already has. func resolvingCanonicalMetadata() -> FunctionTrayProfile { - let canonicalByID = Dictionary(uniqueKeysWithValues: - Self.canonical(slot: slot).items.map { ($0.id, $0.item) }) - return FunctionTrayProfile(slot: slot, items: items.map { configuration in - guard let canonical = canonicalByID[configuration.id] else { return configuration } - return FunctionTrayItemConfiguration(item: canonical, + let canonical = Self.canonical(slot: slot).items + let canonicalByID = Dictionary(uniqueKeysWithValues: canonical.map { ($0.id, $0.item) }) + let savedIDs = Set(items.map(\.id)) + let resolved: [FunctionTrayItemConfiguration] = items.map { configuration in + guard let item = canonicalByID[configuration.id] else { return configuration } + return FunctionTrayItemConfiguration(item: item, isVisible: configuration.isVisible, group: configuration.group) - }) + } + let added = canonical.filter { !savedIDs.contains($0.id) }.map { + FunctionTrayItemConfiguration(item: $0.item, isVisible: false, group: $0.group) + } + return FunctionTrayProfile(slot: slot, items: resolved + added) } } @@ -462,6 +482,7 @@ extension FunctionTrayProfile { // `FunctionTrayProfile` itself knows "zoom" or "edit". let zoomGroup = 0 let editGroup = 1 + let systemGroup = 2 let items: [(ShortcutItem, Int)] = [ (ShortcutItem(id: "zoom-in", title: "Zoom In", displayKey: "+", usage: 46, modifiers: command, systemImage: "plus.magnifyingglass"), zoomGroup), @@ -472,8 +493,19 @@ extension FunctionTrayProfile { (ShortcutItem(id: "redo", title: "Redo", displayKey: "Z", usage: 29, modifiers: commandShift, systemImage: "arrow.uturn.forward"), editGroup), ] + // Opt-in (hidden by default): the only routes to Launchpad and Show + // Desktop besides the 4/5-finger pinch/spread, which can collide + // with iPadOS's own multitasking gestures. + let systemItems: [(ShortcutItem, Int)] = [ + (ShortcutItem(id: "launchpad", title: "Launchpad", gesture: .launchpad, + systemImage: "square.grid.3x3"), systemGroup), + (ShortcutItem(id: "show-desktop", title: "Show Desktop", gesture: .showDesktop, + systemImage: "rectangle.dashed"), systemGroup), + ] return FunctionTrayProfile(slot: slot, items: items.map { FunctionTrayItemConfiguration(item: $0.0, isVisible: true, group: $0.1) + } + systemItems.map { + FunctionTrayItemConfiguration(item: $0.0, isVisible: false, group: $0.1) }) } } diff --git a/Shared/ReceiverGesture.swift b/Shared/ReceiverGesture.swift index 19b57ca5..7bb2831f 100644 --- a/Shared/ReceiverGesture.swift +++ b/Shared/ReceiverGesture.swift @@ -48,6 +48,58 @@ enum ReceiverGesture: String, CaseIterable { } } +/// The receiver surface's custom direct-touch multi-finger recognizers. +enum ReceiverMultiFingerRecognizer: CaseIterable { + /// Two-finger remote scroll and local viewport pinch/zoom/pan/rotation + /// (`TwoFingerViewportGestureRecognizer`). + case twoFingerViewport + /// Two-finger double-tap viewport reset/restore. + case viewportDoubleTap + /// Three-finger swipe: Mission Control, App Exposé, Spaces. + case threeFingerSwipe + /// Three-finger tap: Spotlight. + case threeFingerTap + /// Four/five-finger pinch/spread: Launchpad, Show Desktop. + case pinchSpread +} + +/// Whether the multi-finger recognizers may run. VoiceOver owns two-, three- +/// and four-finger gestures (two-finger double tap, rotor, three-finger +/// scroll/tap, …), so every `ReceiverMultiFingerRecognizer` steps aside +/// while it runs, and comes back when it stops. Ordinary one-finger input is +/// not gated here. Held as a value so a recognizer force-cancelled by an +/// `isEnabled` false/true toggle is restored to THIS state, never to a +/// blind `true` that would undo the VoiceOver gate. +struct ReceiverMultiFingerGestureGate: Equatable { + private(set) var voiceOverRunning: Bool + + init(voiceOverRunning: Bool) { + self.voiceOverRunning = voiceOverRunning + } + + var recognizersEnabled: Bool { !voiceOverRunning } + + func isEnabled(_ recognizer: ReceiverMultiFingerRecognizer) -> Bool { + recognizersEnabled + } + + /// Returns whether availability changed (VoiceOver toggled at runtime). + mutating func update(voiceOverRunning: Bool) -> Bool { + guard voiceOverRunning != self.voiceOverRunning else { return false } + self.voiceOverRunning = voiceOverRunning + return true + } +} + +/// Screen-edge system gestures are deferred (the first swipe goes to the +/// Mac, a second still reaches iPadOS) only while the remote surface is on +/// screen AND input may reach the Mac. +enum ReceiverScreenEdgePolicy { + static func defersScreenEdges(surfaceShown: Bool, inputAllowed: Bool) -> Bool { + surfaceShown && inputAllowed + } +} + /// The mean of every active fingertip pair gives pinch/spread a symmetric /// measure that does not assume one particular contact is the thumb. enum ReceiverGestureGeometry { diff --git a/iOS/OpenSidecarPhoneApp.swift b/iOS/OpenSidecarPhoneApp.swift index d3c41369..0b42dfdf 100644 --- a/iOS/OpenSidecarPhoneApp.swift +++ b/iOS/OpenSidecarPhoneApp.swift @@ -91,6 +91,13 @@ struct ReceiverScreen: View { model.receiver.session.allowsLiveInput && controlStore.preferences.allowInput } + /// Screen edges whose system gestures wait for a second swipe — see + /// `ReceiverScreenEdgePolicy`. + private var deferredSystemGestureEdges: Edge.Set { + ReceiverScreenEdgePolicy.defersScreenEdges(surfaceShown: showsReceiverSurface, + inputAllowed: inputReachesMac) ? .all : [] + } + /// Everything outside AVKit that decides whether Picture in Picture may /// run — see `ReceiverPictureInPictureConditions`. private var pictureInPictureConditions: ReceiverPictureInPictureConditions { @@ -247,6 +254,13 @@ struct ReceiverScreen: View { .ignoresSafeArea(edges: showsReceiverSurface ? .all : []) .statusBarHidden(showsReceiverSurface) .persistentSystemOverlays(showsReceiverSurface ? .hidden : .automatic) + // SwiftUI forwards this to the hosting controller's + // `preferredScreenEdgesDeferringSystemGestures` and invalidates it + // (`setNeedsUpdateOfScreenEdgesDeferringSystemGestures`) whenever the + // value changes. Deferral only makes the FIRST edge swipe reach the + // remote Mac; a second swipe still opens Control Center, the Home + // indicator etc. Never while input is off or outside the surface. + .defersSystemGestures(on: deferredSystemGestureEdges) .sheet(isPresented: $showSettings) { SettingsView(receiver: model.receiver, controlStore: controlStore, pictureInPicture: model.pictureInPicture, haptics: haptics) @@ -2462,6 +2476,7 @@ struct VideoLayerView: UIViewRepresentable { viewportDoubleTap.delegate = view view.viewportDoubleTapRecognizer = viewportDoubleTap view.addGestureRecognizer(viewportDoubleTap) + view.applyMultiFingerGate() // Local cursor echo: position updates ride the ~2ms control path // instead of the ~30ms video path, so the pointer feels native. @@ -2656,6 +2671,9 @@ struct VideoLayerView: UIViewRepresentable { NotificationCenter.default.addObserver( self, selector: #selector(keyboardWillChangeFrame(_:)), name: UIResponder.keyboardWillChangeFrameNotification, object: nil) + NotificationCenter.default.addObserver( + self, selector: #selector(voiceOverStatusDidChange(_:)), + name: UIAccessibility.voiceOverStatusDidChangeNotification, object: nil) // The zoomed/panned content layer can extend beyond `bounds` — // clip it so it never bleeds into sibling SwiftUI content. clipsToBounds = true @@ -2679,6 +2697,49 @@ struct VideoLayerView: UIViewRepresentable { NotificationCenter.default.removeObserver(self) } + /// The remote surface is not text: opt it out of the iPadOS + /// three-finger undo/redo/copy/paste editing gestures (and their + /// HUD), which otherwise compete with the three-finger Mac-system + /// recognizers below. Scoped to this view only, never app-wide. + override var editingInteractionConfiguration: UIEditingInteractionConfiguration { .none } + + // VoiceOver gate for every multi-finger recognizer — see + // `ReceiverMultiFingerGestureGate`. + private var multiFingerGate = ReceiverMultiFingerGestureGate(voiceOverRunning: false) + + private func recognizer(for kind: ReceiverMultiFingerRecognizer) -> UIGestureRecognizer? { + switch kind { + case .twoFingerViewport: return twoFingerRecognizer + case .viewportDoubleTap: return viewportDoubleTapRecognizer + case .threeFingerSwipe: return threeFingerPanRecognizer + case .threeFingerTap: return threeFingerTapRecognizer + case .pinchSpread: return pinchSpreadGestureRecognizer + } + } + + /// Called once the multi-finger recognizers are attached, and again + /// whenever VoiceOver starts or stops. Disabling a recognizer + /// mid-gesture cancels it, which its action already handles. + func applyMultiFingerGate() { + _ = multiFingerGate.update(voiceOverRunning: UIAccessibility.isVoiceOverRunning) + for kind in ReceiverMultiFingerRecognizer.allCases { + recognizer(for: kind)?.isEnabled = multiFingerGate.isEnabled(kind) + } + } + + /// Forces `kind`'s recognizer to `.cancelled` (UIKit's `isEnabled` + /// toggle) and leaves it enabled only if the VoiceOver gate allows. + private func cancelRecognizer(_ kind: ReceiverMultiFingerRecognizer) { + guard let target = recognizer(for: kind) else { return } + target.isEnabled = false + target.isEnabled = multiFingerGate.isEnabled(kind) + } + + @objc private func voiceOverStatusDidChange(_ note: Notification) { + guard multiFingerGate.update(voiceOverRunning: UIAccessibility.isVoiceOverRunning) else { return } + applyMultiFingerGate() + } + func installVideoLayer(_ layer: CALayer) { videoLayer?.removeFromSuperlayer() videoLayer = layer @@ -2871,8 +2932,7 @@ struct VideoLayerView: UIViewRepresentable { // mid-deciding or already committed to — toggling `isEnabled` // is UIKit's standard way to force a recognizer to `.cancelled` // (same trick used elsewhere for gesture-ownership hand-off). - twoFingerRecognizer?.isEnabled = false - twoFingerRecognizer?.isEnabled = true + cancelRecognizer(.twoFingerViewport) } /// Releases any pointer-engine-held mouse button and forgets every @@ -3842,8 +3902,7 @@ struct VideoLayerView: UIViewRepresentable { // Preempt the legacy scroll/pinch recognizer from // also claiming these same two touches — see // `PointerGestureEngine.isChordContinuation`'s doc. - twoFingerRecognizer?.isEnabled = false - twoFingerRecognizer?.isEnabled = true + cancelRecognizer(.twoFingerViewport) } case .mouseUp(let button, let clickCount): receiver.sendPointerUp(button: button, clickCount: clickCount) @@ -3980,10 +4039,8 @@ struct VideoLayerView: UIViewRepresentable { } if recognizerResetPending, activeFingerTouchIDs.isEmpty { recognizerResetPending = false - twoFingerRecognizer?.isEnabled = false - twoFingerRecognizer?.isEnabled = true - viewportDoubleTapRecognizer?.isEnabled = false - viewportDoubleTapRecognizer?.isEnabled = true + cancelRecognizer(.twoFingerViewport) + cancelRecognizer(.viewportDoubleTap) } for touch in touches { surfaceAdmission.end(ObjectIdentifier(touch)) } } diff --git a/iOS/ReceiverControls.swift b/iOS/ReceiverControls.swift index e75a6c14..515ed2d6 100644 --- a/iOS/ReceiverControls.swift +++ b/iOS/ReceiverControls.swift @@ -744,9 +744,14 @@ struct ReceiverControlOverlay: View { } private func performFunctionAction(_ item: ShortcutItem) { - guard case .keyboardShortcut(let shortcut) = item.action else { return } - receiver.sendKeyboardPress(usage: shortcut.usage, - modifiers: shortcut.modifiers.modifiers.map(\.rawValue)) + switch item.action { + case .keyboardShortcut(let shortcut): + receiver.sendKeyboardPress(usage: shortcut.usage, + modifiers: shortcut.modifiers.modifiers.map(\.rawValue)) + case .receiverGesture(let name): + guard ReceiverGesture(rawValue: name) != nil else { return } + receiver.sendGesture(name: name) + } haptics.play(.confirmation) store.scheduleAutoHide() } diff --git a/project.yml b/project.yml index 3daf458f..4fcb4e3e 100644 --- a/project.yml +++ b/project.yml @@ -307,6 +307,11 @@ targets: CFBundleShortVersionString: "$(MARKETING_VERSION)" CFBundleVersion: "$(CURRENT_PROJECT_VERSION)" UILaunchScreen: {} + # Opts the iPad out of Split View/Slide Over/Stage Manager resizing: + # the receiver surface assumes it owns the whole screen (safe-area, + # notch and tray geometry). Apple has deprecated this key; dropping it + # means supporting arbitrary window sizes, which is its own change. + # TODO: remove once the receiver surface handles resizable windows. UIRequiresFullScreen: true NSLocalNetworkUsageDescription: MeowDisplay advertises itself on the local network so your Mac can connect over WiFi. NSFaceIDUsageDescription: MeowDisplay asks for Face ID or your passcode to confirm that you want to trust a new device. From fd21e5b928d9ed9a1b0476644b09de062b7ac93d Mon Sep 17 00:00:00 2001 From: raiseCatError <315733358+raiseCatError@users.noreply.github.com> Date: Wed, 30 Sep 2026 01:34:39 +0530 Subject: [PATCH 06/30] feat: first-class iPad receiver layouts, adaptive Settings, and viewport freedom Preferences (schema 16): new installs default to Smart Touch on, Auto-hide on, Pinch -> Viewport, Rotation -> Disabled, Prefer MeowDisplay Gestures on and the iPad Strip layout. Pre-release installs migrate those four older values once; choices saved under schema 16 are never re-migrated. Custom layouts decode lossily so one unreadable entry can't reset preferences. Settings: the single NavigationStack + Form becomes one category model and per-category pages. Regular width shows a NavigationSplitView sidebar (presented full screen on iPad); compact width collapses the same view into a category list with pushed pages. Pages observe only what they show. iPad controls (never on iPhone): - Strip: a solid edge rail on any edge; the Mac canvas fits beside it. Optional control hints. - Overlay: full-screen Mac, floating controls with small material halos drawn only where a control covers the rendered display. - Main and Function controls choose independent edges; Main is centered and grows outward, Function groups split toward the edge's ends. - Control Size (0.85-1.4). - Custom: up to five layouts, separate landscape/portrait arrangements, a visual editor, and a radial corner template whose chord palettes bloom as arcs/rings/rows/columns clear of other controls. - Two-Hand Assist: opposite-side idle Function actions, replaced by helper modifiers that act on the same ControlInteractionState chord. Layouts store placement only; actions still come from Control Profiles and the Function Tray. Viewport: panning no longer requires zoom; any edge of the remote display may reach the viewport center but never pass it (rotation-aware). Move View (iPad) lets one finger pan and pinch zoom without sending input to the Mac; two-finger scrolling is unchanged. Gestures: Prefer MeowDisplay Gestures controls screen-edge deferral and the three-finger editing opt-out. VoiceOver always wins, and Move View parks the multi-finger recognizers. Home: centered two-card iPad composition and an optically aligned icon. Pairing: a high-contrast success banner announced to VoiceOver. No wire-protocol, transport or security changes. --- MacTests/PadControlLayoutTests.swift | 668 +++++++++++++ MacTests/ReceiverControlsTests.swift | 4 +- MacTests/RemoteViewportTests.swift | 16 +- MacTests/SmartTouchTests.swift | 15 +- MacTests/ViewportPanFreedomTests.swift | 195 ++++ Shared/ControlProfile.swift | 84 +- Shared/CustomControlLayout.swift | 587 +++++++++++ Shared/MobileSettingsCategory.swift | 107 ++ Shared/PadControlLayout.swift | 511 ++++++++++ Shared/ReceiverGesture.swift | 33 +- Shared/RemoteViewport.swift | 136 ++- iOS/CustomLayoutEditor.swift | 483 +++++++++ iOS/OpenSidecarPhoneApp.swift | 1249 +++++++----------------- iOS/ReceiverControls.swift | 900 +++++++++++++++-- iOS/ReceiverSettings.swift | 1187 ++++++++++++++++++++++ iOS/RemotePairingView.swift | 33 +- project.yml | 3 + 17 files changed, 5154 insertions(+), 1057 deletions(-) create mode 100644 MacTests/PadControlLayoutTests.swift create mode 100644 MacTests/ViewportPanFreedomTests.swift create mode 100644 Shared/CustomControlLayout.swift create mode 100644 Shared/MobileSettingsCategory.swift create mode 100644 Shared/PadControlLayout.swift create mode 100644 iOS/CustomLayoutEditor.swift create mode 100644 iOS/ReceiverSettings.swift diff --git a/MacTests/PadControlLayoutTests.swift b/MacTests/PadControlLayoutTests.swift new file mode 100644 index 00000000..278686a7 --- /dev/null +++ b/MacTests/PadControlLayoutTests.swift @@ -0,0 +1,668 @@ +import CoreGraphics +import XCTest + +/// Receiver defaults (schema 16), iPad Strip/Overlay geometry, control +/// scale, display-aware backdrop, Custom layouts, radial palettes and +/// Two-Hand Assist — all pure policy, no UIKit. +final class PadControlLayoutTests: XCTestCase { + private let container = CGRect(x: 0, y: 0, width: 1194, height: 834) + private let metrics = PadControlMetrics() + + // MARK: - Defaults and migration + + func testNewInstallDefaults() { + let preferences = ReceiverControlPreferences() + XCTAssertTrue(preferences.smartTouchEnabled) + XCTAssertTrue(preferences.autoHideEnabled) + XCTAssertEqual(preferences.pinchTarget, .viewport) + XCTAssertEqual(preferences.rotateTarget, .disabled) + XCTAssertTrue(preferences.preferMeowDisplayGestures) + XCTAssertEqual(preferences.padControlLayout, .strip) + XCTAssertTrue(preferences.padShowControlHints) + XCTAssertEqual(preferences.padControlScale, 1) + XCTAssertTrue(preferences.customLayouts.isEmpty, "no empty Custom layouts are pre-created") + XCTAssertFalse(preferences.audioPreferred, "audio stays opt-in") + XCTAssertTrue(preferences.allowInput) + } + + func testSchema15InstallMigratesOnceToTheNewDefaults() throws { + let (repository, defaults, suite) = try makeRepository() + defer { defaults.removePersistentDomain(forName: suite) } + var old = ReceiverControlPreferences() + old.version = 15 + old.smartTouchEnabled = false + old.autoHideEnabled = false + old.pinchTarget = .app + old.rotateTarget = .viewport + old.inputMode = .trackpad + old.hapticsEnabled = false + var json = try XCTUnwrap(JSONSerialization.jsonObject(with: JSONEncoder().encode(old)) as? [String: Any]) + for key in ["preferMeowDisplayGestures", "padControlLayout", "padMainEdge", "padFunctionEdge", + "padShowControlHints", "padControlScale", "customLayouts"] { + json.removeValue(forKey: key) + } + defaults.set(try JSONSerialization.data(withJSONObject: json), + forKey: ReceiverControlPreferencesRepository.defaultsKey) + + let loaded = repository.load() + XCTAssertEqual(loaded.version, 16) + XCTAssertTrue(loaded.smartTouchEnabled) + XCTAssertTrue(loaded.autoHideEnabled) + XCTAssertEqual(loaded.pinchTarget, .viewport) + XCTAssertEqual(loaded.rotateTarget, .disabled) + XCTAssertTrue(loaded.preferMeowDisplayGestures) + XCTAssertEqual(loaded.padControlLayout, .strip) + XCTAssertEqual(loaded.inputMode, .trackpad, "unrelated choices survive") + XCTAssertFalse(loaded.hapticsEnabled, "unrelated choices survive") + } + + func testChoicesSavedUnderSchema16AreNeverRemigrated() throws { + let (repository, defaults, suite) = try makeRepository() + defer { defaults.removePersistentDomain(forName: suite) } + var chosen = repository.load() + chosen.smartTouchEnabled = false + chosen.autoHideEnabled = false + chosen.pinchTarget = .app + chosen.rotateTarget = .viewport + chosen.preferMeowDisplayGestures = false + chosen.padControlLayout = .overlay + repository.save(chosen) + let reloaded = repository.load() + XCTAssertFalse(reloaded.smartTouchEnabled) + XCTAssertFalse(reloaded.autoHideEnabled) + XCTAssertEqual(reloaded.pinchTarget, .app) + XCTAssertEqual(reloaded.rotateTarget, .viewport) + XCTAssertFalse(reloaded.preferMeowDisplayGestures) + XCTAssertEqual(reloaded.padControlLayout, .overlay) + } + + func testPersistedControlScaleIsClampedOnLoad() throws { + let (repository, defaults, suite) = try makeRepository() + defer { defaults.removePersistentDomain(forName: suite) } + var json = try XCTUnwrap(JSONSerialization.jsonObject( + with: JSONEncoder().encode(ReceiverControlPreferences())) as? [String: Any]) + json["padControlScale"] = 9.0 + defaults.set(try JSONSerialization.data(withJSONObject: json), + forKey: ReceiverControlPreferencesRepository.defaultsKey) + XCTAssertEqual(repository.load().padControlScale, PadControlScale.range.upperBound) + } + + func testAnUnreadableCustomLayoutIsDroppedWithoutResettingPreferences() throws { + let (repository, defaults, suite) = try makeRepository() + defer { defaults.removePersistentDomain(forName: suite) } + var preferences = ReceiverControlPreferences() + preferences.hapticsEnabled = false + preferences.createCustomLayout(id: "keep") + preferences.createCustomLayout(id: "future") + var json = try XCTUnwrap(JSONSerialization.jsonObject( + with: JSONEncoder().encode(preferences)) as? [String: Any]) + var layouts = try XCTUnwrap(json["customLayouts"] as? [[String: Any]]) + layouts[1]["landscape"] = "not an arrangement" + json["customLayouts"] = layouts + defaults.set(try JSONSerialization.data(withJSONObject: json), + forKey: ReceiverControlPreferencesRepository.defaultsKey) + let loaded = repository.load() + XCTAssertEqual(loaded.customLayouts.map(\.id), ["keep"]) + XCTAssertFalse(loaded.hapticsEnabled) + } + + func testAnUnknownControlKindDropsOnlyThatPlacement() throws { + let json = """ + {"corner":"bottomLeading","placements":[ + {"id":"a","kind":{"kind":"tray","value":"command"},"x":0.1,"y":0.9,"size":1}, + {"id":"b","kind":{"kind":"hologram"},"x":0.2,"y":0.9,"size":1} + ]} + """ + let arrangement = try JSONDecoder().decode(CustomControlArrangement.self, from: Data(json.utf8)) + XCTAssertEqual(arrangement.corner, .bottomLeading) + XCTAssertEqual(arrangement.placements.map(\.id), ["a"]) + } + + // MARK: - Control scale + + func testControlScaleIsBoundedWithAClearDefault() { + XCTAssertEqual(PadControlScale.defaultValue, 1) + XCTAssertTrue(PadControlScale.range.contains(1)) + XCTAssertEqual(PadControlScale.clamped(0.1), PadControlScale.range.lowerBound) + XCTAssertEqual(PadControlScale.clamped(5), PadControlScale.range.upperBound) + XCTAssertEqual(PadControlScale.clamped(.nan), 1) + XCTAssertEqual(PadControlScale.clamped(1.2), 1.2) + // Never an impractically small hit target. + XCTAssertGreaterThanOrEqual(PadControlMetrics(scale: 0).item, 37) + XCTAssertLessThanOrEqual(PadControlMetrics(scale: 99).item, 62) + } + + // MARK: - Strip / Overlay geometry + + func testStripReservesARailAndCentersTheMainControls() throws { + let layout = PadEdgeGeometry.layout(container: container, safeInsets: .zero, reservesStrips: true, + mainEdge: .trailing, functionEdge: .trailing, + mainCount: 9, functionGroupCounts: [2, 2], metrics: metrics) + XCTAssertEqual(layout.strips.map(\.edge), [.trailing]) + let strip = try XCTUnwrap(layout.strips.first).frame + XCTAssertEqual(strip, CGRect(x: 1130, y: 0, width: 64, height: 834)) + XCTAssertEqual(layout.canvas, CGRect(x: 0, y: 0, width: 1130, height: 834)) + let main = try XCTUnwrap(layout.mainFrame) + XCTAssertEqual(main.midY, container.midY, accuracy: 0.5) + XCTAssertEqual(main.midX, strip.midX, accuracy: 0.5) + XCTAssertTrue(strip.contains(main)) + } + + func testStripOnEachEdgeKeepsTheCanvasBesideIt() { + for edge in ControlEdge.allCases { + let layout = PadEdgeGeometry.layout(container: container, safeInsets: .zero, reservesStrips: true, + mainEdge: edge, functionEdge: edge, + mainCount: 6, functionGroupCounts: [2], metrics: metrics) + XCTAssertEqual(layout.strips.count, 1, "\(edge)") + let strip = layout.strips[0].frame + XCTAssertFalse(strip.intersects(layout.canvas), "\(edge)") + XCTAssertEqual(strip.union(layout.canvas), container, "\(edge)") + if let main = layout.mainFrame { + XCTAssertTrue(strip.contains(main), "\(edge)") + if edge.stacksVertically { + XCTAssertEqual(main.midY, container.midY, accuracy: 0.5) + } else { + XCTAssertEqual(main.midX, container.midX, accuracy: 0.5) + } + } else { + XCTFail("missing main frame on \(edge)") + } + } + } + + func testStripIncludesTheSafeInsetButKeepsControlsClearOfIt() throws { + let safe = ControlSafeInsets(top: 0, leading: 0, bottom: 20, trailing: 0) + let layout = PadEdgeGeometry.layout(container: container, safeInsets: safe, reservesStrips: true, + mainEdge: .bottom, functionEdge: .bottom, + mainCount: 6, functionGroupCounts: [], metrics: metrics) + let strip = try XCTUnwrap(layout.strips.first).frame + XCTAssertEqual(strip.maxY, container.maxY) + XCTAssertEqual(strip.height, metrics.stripThickness(on: .bottom) + 20) + XCTAssertLessThanOrEqual(try XCTUnwrap(layout.mainFrame).maxY, container.maxY - 20) + } + + func testOverlayUsesTheWholeCanvas() { + let layout = PadEdgeGeometry.layout(container: container, safeInsets: .zero, reservesStrips: false, + mainEdge: .leading, functionEdge: .trailing, + mainCount: 9, functionGroupCounts: [2, 2], metrics: metrics) + XCTAssertTrue(layout.strips.isEmpty) + XCTAssertEqual(layout.canvas, container) + } + + func testFunctionGroupsSplitTowardTopAndBottomOnAVerticalEdge() throws { + let layout = PadEdgeGeometry.layout(container: container, safeInsets: .zero, reservesStrips: false, + mainEdge: .trailing, functionEdge: .trailing, + mainCount: 9, functionGroupCounts: [2, 2], metrics: metrics) + let main = try XCTUnwrap(layout.mainFrame) + XCTAssertEqual(layout.functionFrames.count, 2) + XCTAssertLessThanOrEqual(layout.functionFrames[0].maxY, main.minY - metrics.groupGap + 0.5) + XCTAssertGreaterThanOrEqual(layout.functionFrames[1].minY, main.maxY + metrics.groupGap - 0.5) + for frame in layout.functionFrames { + XCTAssertEqual(frame.midX, main.midX, accuracy: 0.5) + XCTAssertFalse(frame.intersects(main)) + } + } + + func testFunctionGroupsSplitTowardLeftAndRightOnAHorizontalEdge() throws { + let layout = PadEdgeGeometry.layout(container: container, safeInsets: .zero, reservesStrips: true, + mainEdge: .bottom, functionEdge: .bottom, + mainCount: 9, functionGroupCounts: [2, 2, 2], metrics: metrics) + let main = try XCTUnwrap(layout.mainFrame) + XCTAssertEqual(layout.functionFrames.count, 3) + XCTAssertLessThan(layout.functionFrames[0].maxX, layout.functionFrames[1].minX) + XCTAssertLessThan(layout.functionFrames[1].maxX, main.minX) + XCTAssertGreaterThan(layout.functionFrames[2].minX, main.maxX) + for frame in layout.functionFrames { + XCTAssertEqual(frame.midY, main.midY, accuracy: 0.5) + } + } + + func testMainAndFunctionEdgesAreIndependent() throws { + let layout = PadEdgeGeometry.layout(container: container, safeInsets: .zero, reservesStrips: true, + mainEdge: .trailing, functionEdge: .leading, + mainCount: 9, functionGroupCounts: [2, 2], metrics: metrics) + XCTAssertEqual(Set(layout.strips.map(\.edge)), [.leading, .trailing]) + XCTAssertEqual(layout.canvas.minX, 64) + XCTAssertEqual(layout.canvas.maxX, 1130) + let leadingStrip = try XCTUnwrap(layout.strips.first { $0.edge == .leading }).frame + // Split to the two ends of the other edge, away from the center. + XCTAssertTrue(leadingStrip.contains(layout.functionFrames[0])) + XCTAssertTrue(leadingStrip.contains(layout.functionFrames[1])) + XCTAssertLessThan(layout.functionFrames[0].maxY, container.midY) + XCTAssertGreaterThan(layout.functionFrames[1].minY, container.midY) + } + + func testPerpendicularEdgesDoNotCollideAtTheirSharedCorner() throws { + let layout = PadEdgeGeometry.layout(container: container, safeInsets: .zero, reservesStrips: false, + mainEdge: .bottom, functionEdge: .trailing, + mainCount: 14, functionGroupCounts: [3, 3], metrics: metrics) + let main = try XCTUnwrap(layout.mainFrame) + for frame in layout.functionFrames { XCTAssertFalse(frame.intersects(main)) } + } + + func testBottomControlsRiseAboveTheKeyboard() throws { + let layout = PadEdgeGeometry.layout(container: container, safeInsets: .zero, reservesStrips: false, + mainEdge: .bottom, functionEdge: .trailing, + mainCount: 6, functionGroupCounts: [2, 2], metrics: metrics, + keyboardTop: 500) + XCTAssertLessThanOrEqual(try XCTUnwrap(layout.mainFrame).maxY, 500) + for frame in layout.functionFrames { XCTAssertLessThanOrEqual(frame.maxY, 500) } + } + + func testHiddenControlsReserveNoStrip() { + let layout = PadEdgeGeometry.layout(container: container, safeInsets: .zero, reservesStrips: true, + mainEdge: .trailing, functionEdge: .leading, + mainCount: 0, functionGroupCounts: [], metrics: metrics) + XCTAssertTrue(layout.strips.isEmpty) + XCTAssertEqual(layout.canvas, container) + XCTAssertNil(layout.mainFrame) + } + + func testHintsWidenTheStrip() { + let hinted = PadControlMetrics(showsHints: true) + XCTAssertGreaterThan(hinted.stripThickness(on: .trailing), metrics.stripThickness(on: .trailing)) + XCTAssertGreaterThan(hinted.stripThickness(on: .bottom), metrics.stripThickness(on: .bottom)) + } + + func testPaletteOpensTowardTheCanvasAndStaysInside() { + let anchor = CGRect(x: 1140, y: 187, width: 44, height: 460) + let bounds = CGRect(x: 12, y: 12, width: 1130, height: 810) + let frame = PadEdgeGeometry.paletteFrame(size: CGSize(width: 44, height: 300), anchor: anchor, + edge: .trailing, bounds: bounds, gap: 8) + XCTAssertEqual(frame.maxX, 1132) + XCTAssertEqual(frame.midY, anchor.midY, accuracy: 0.5) + XCTAssertTrue(bounds.contains(frame)) + } + + func testPaletteGridWrapsIntoMoreColumnsWhenAColumnWouldOverflow() { + let grid = PadEdgeGeometry.paletteGrid(count: 12, key: CGSize(width: 40, height: 40), gap: 5, + edge: .trailing, available: CGSize(width: 900, height: 300)) + XCTAssertEqual(grid.rows, 6) + XCTAssertEqual(grid.columns, 2) + XCTAssertLessThanOrEqual(grid.size.height, 300) + } + + func testAutoHideNeverAppliesToTheIPadStrip() { + XCTAssertFalse(ControlAutoHidePolicy.applies(isPad: true, layout: .strip)) + XCTAssertTrue(ControlAutoHidePolicy.applies(isPad: true, layout: .overlay)) + XCTAssertTrue(ControlAutoHidePolicy.applies(isPad: true, layout: .custom)) + XCTAssertTrue(ControlAutoHidePolicy.applies(isPad: false, layout: .strip)) + } + + // MARK: - Display-aware backdrop + + private func footprint(rotation: CGFloat = 0, origin: CGPoint = .zero) -> DisplayFootprint { + let base = RemoteViewportCalculator.normal(viewBounds: CGRect(x: 0, y: 0, width: 1000, height: 1000), + remoteAspectSize: CGSize(width: 16, height: 9)) + let transform = RemoteViewportTransform(remoteCrop: base.remoteCrop, displayedRect: base.displayedRect, + rotationRadians: rotation) + return DisplayFootprint(transform: transform, surfaceOrigin: origin, + surfaceSize: CGSize(width: 1000, height: 1000)) + } + + func testControlOverTheRenderedDisplayGetsAFullHalo() { + XCTAssertEqual(ControlBackdropPolicy.haloStrength(for: CGRect(x: 100, y: 400, width: 44, height: 44), + footprint: footprint()), 1) + } + + func testControlOverLetterboxingGetsNoHalo() { + // The 16:9 picture spans y 218.75...781.25; y 50 is the black bar. + XCTAssertEqual(ControlBackdropPolicy.haloStrength(for: CGRect(x: 100, y: 50, width: 44, height: 44), + footprint: footprint()), 0) + XCTAssertEqual(ControlBackdropPolicy.haloStrength(for: CGRect(x: 100, y: 400, width: 44, height: 44), + footprint: .none), 0) + } + + func testPartialOverlapScalesTheHalo() { + let coverage = ControlBackdropPolicy.coverage(of: CGRect(x: 100, y: 196.75, width: 44, height: 44), + footprint: footprint()) + XCTAssertEqual(coverage, 0.6, accuracy: 0.001) + let strength = ControlBackdropPolicy.haloStrength(coverage: coverage) + XCTAssertEqual(strength, 1) + let sliver = ControlBackdropPolicy.haloStrength(coverage: 0.2) + XCTAssertGreaterThan(sliver, 0) + XCTAssertLessThan(sliver, 1) + XCTAssertEqual(ControlBackdropPolicy.haloStrength(coverage: 0.02), 0) + } + + func testBackdropFollowsRotationAndTheSurfaceOrigin() { + let rotated = footprint(rotation: .pi / 2) + // Rotated a quarter turn, the picture covers the top of the view + // and no longer covers the left edge. + XCTAssertEqual(ControlBackdropPolicy.haloStrength(for: CGRect(x: 480, y: 60, width: 40, height: 40), + footprint: rotated), 1) + XCTAssertEqual(ControlBackdropPolicy.haloStrength(for: CGRect(x: 40, y: 480, width: 40, height: 40), + footprint: rotated), 0) + // A Strip on the left shifts the surface; overlay coordinates follow. + let shifted = footprint(origin: CGPoint(x: 64, y: 0)) + XCTAssertEqual(ControlBackdropPolicy.haloStrength(for: CGRect(x: 20, y: 400, width: 40, height: 40), + footprint: shifted), 0) + XCTAssertEqual(ControlBackdropPolicy.haloStrength(for: CGRect(x: 90, y: 400, width: 40, height: 40), + footprint: shifted), 1) + } + + func testBackdropFollowsZoomAndPan() { + let base = RemoteViewportCalculator.normal(viewBounds: CGRect(x: 0, y: 0, width: 1000, height: 1000), + remoteAspectSize: CGSize(width: 16, height: 9)) + let zoomed = RemoteViewportCalculator.applyManualZoom(to: base, state: ManualViewportState(scale: 2)) + let print = DisplayFootprint(transform: zoomed, surfaceOrigin: .zero, + surfaceSize: CGSize(width: 1000, height: 1000)) + // Zoomed 2x, the picture now fills what was the letterbox bar. + XCTAssertEqual(ControlBackdropPolicy.haloStrength(for: CGRect(x: 100, y: 50, width: 44, height: 44), + footprint: print), 1) + } + + // MARK: - Custom layouts + + func testAtMostFiveCustomLayouts() { + var preferences = ReceiverControlPreferences() + for index in 0..<5 { + XCTAssertTrue(preferences.canCreateCustomLayout) + XCTAssertNotNil(preferences.createCustomLayout(id: "layout-\(index)")) + } + XCTAssertFalse(preferences.canCreateCustomLayout) + XCTAssertNil(preferences.createCustomLayout()) + XCTAssertEqual(preferences.customLayouts.count, 5) + XCTAssertEqual(Set(preferences.customLayouts.map(\.name)).count, 5, "names are unique") + } + + func testCreateRenameAndDeleteCustomLayouts() throws { + var preferences = ReceiverControlPreferences() + preferences.padControlLayout = .custom + let first = try XCTUnwrap(preferences.createCustomLayout(id: "a")) + let second = try XCTUnwrap(preferences.createCustomLayout(id: "b")) + XCTAssertEqual(preferences.activeCustomLayout?.id, second, "a new layout becomes active") + preferences.renameCustomLayout(first, to: " Drawing ") + XCTAssertEqual(preferences.customLayouts.first?.name, "Drawing") + preferences.renameCustomLayout(first, to: " ") + XCTAssertEqual(preferences.customLayouts.first?.name, "Drawing", "blank names are ignored") + preferences.deleteCustomLayout(second) + XCTAssertEqual(preferences.activeCustomLayout?.id, first) + XCTAssertEqual(preferences.padControlLayout, .custom) + preferences.deleteCustomLayout(first) + XCTAssertNil(preferences.activeCustomLayout) + XCTAssertEqual(preferences.padControlLayout, .overlay, "Custom with nothing to show falls back") + } + + func testPortraitAndLandscapeLayoutsAreIndependent() throws { + var layout = CustomControlLayout.radialTemplate(name: "Test") + var portrait = layout.arrangement(portrait: true) + portrait.placements[0].x = 0.5 + portrait.placements.removeLast() + let landscapeBefore = layout.arrangement(portrait: false) + layout.setArrangement(portrait, portrait: true) + XCTAssertEqual(layout.arrangement(portrait: false), landscapeBefore) + XCTAssertEqual(layout.arrangement(portrait: true), portrait) + let data = try JSONEncoder().encode(layout) + XCTAssertEqual(try JSONDecoder().decode(CustomControlLayout.self, from: data), layout) + } + + func testPlacementsStayNormalizedAndBounded() { + let placement = CustomControlPlacement(kind: .tray(.escape), x: 1.7, y: -3, size: 9) + XCTAssertEqual(placement.x, 1) + XCTAssertEqual(placement.y, 0) + XCTAssertEqual(placement.size, CustomControlPlacement.sizeRange.upperBound) + let area = CustomLayoutGeometry.layoutArea(container: container, + safeInsets: ControlSafeInsets(top: 24, leading: 0, + bottom: 20, trailing: 0)) + let frame = CustomLayoutGeometry.frame(for: placement, in: area, baseDiameter: 44) + XCTAssertTrue(area.contains(frame), "clamped clear of the Home indicator and edges") + XCTAssertLessThanOrEqual(frame.maxY, container.maxY - 20) + let normalized = CustomLayoutGeometry.normalizedPoint(for: CGPoint(x: -50, y: area.midY), in: area) + XCTAssertEqual(normalized.x, 0) + XCTAssertEqual(normalized.y, 0.5, accuracy: 0.001) + } + + func testAlignmentSnapOnlyNearAGridLine() { + XCTAssertEqual(CustomLayoutGeometry.snapped(0.505), 0.5) + XCTAssertEqual(CustomLayoutGeometry.snapped(0.52), 0.52) + } + + func testRadialTemplateClustersAroundEachCornerWithoutOverlap() { + for corner in ControlCorner.allCases { + for portrait in [false, true] { + let arrangement = CustomControlArrangement.radialTemplate(corner: corner, portrait: portrait) + let reference = portrait ? CustomControlArrangement.referencePortrait + : CustomControlArrangement.referenceLandscape + XCTAssertEqual(arrangement.corner, corner) + let points = arrangement.placements.map { + (CGPoint(x: $0.x * reference.width, y: $0.y * reference.height), 44 * $0.size) + } + for i in points.indices { + for j in points.indices where j > i { + let d = hypot(points[i].0.x - points[j].0.x, points[i].0.y - points[j].0.y) + XCTAssertGreaterThanOrEqual(d, (points[i].1 + points[j].1) / 2, + "\(corner) portrait=\(portrait) \(i)/\(j)") + } + } + // Settings sits closest to the corner. + let cornerPoint = CGPoint(x: corner.unitPoint.x * reference.width, + y: corner.unitPoint.y * reference.height) + let nearest = arrangement.placements.min { + hypot($0.x * reference.width - cornerPoint.x, $0.y * reference.height - cornerPoint.y) + < hypot($1.x * reference.width - cornerPoint.x, $1.y * reference.height - cornerPoint.y) + } + XCTAssertEqual(nearest?.kind, .tray(.settings)) + XCTAssertEqual(Set(ControlModifier.allCases), Set(arrangement.placements.compactMap(\.kind.modifier))) + // The whole constellation stays in the corner's quadrant. + for placement in arrangement.placements { + XCTAssertEqual(placement.x < 0.5, corner.isLeading) + XCTAssertEqual(placement.y < 0.5, corner.isTop) + } + } + } + } + + // MARK: - Radial palettes + + func testArcKeysAreEvenlySpacedAndFanTowardTheDirection() { + let center = CGPoint(x: 1000, y: 700) + let direction = RadialPaletteLayout.direction(from: center, toward: CGPoint(x: 597, y: 417)) + let points = RadialPaletteLayout.points(count: 5, center: center, shape: .arc, itemDiameter: 44, + spacing: 6, minimumRadius: 110, direction: direction, + bounds: container) + XCTAssertEqual(points.count, 5) + for (a, b) in zip(points, points.dropFirst()) { + XCTAssertGreaterThanOrEqual(hypot(a.x - b.x, a.y - b.y), 50 - 0.01) + } + for point in points { + XCTAssertEqual(hypot(point.x - center.x, point.y - center.y), 110, accuracy: 0.01) + XCTAssertLessThan(point.x, center.x + 1, "fans toward the screen, away from the corner") + } + let middle = points[2] + XCTAssertEqual(atan2(middle.y - center.y, middle.x - center.x), direction, accuracy: 0.001) + } + + func testLongArcsContinueOnALargerArc() { + let points = RadialPaletteLayout.points(count: 12, center: CGPoint(x: 600, y: 400), shape: .arc, + itemDiameter: 44, spacing: 6, minimumRadius: 80, + direction: -.pi / 2, bounds: container) + let radii = Set(points.map { Int(hypot($0.x - 600, $0.y - 400).rounded()) }) + XCTAssertGreaterThan(radii.count, 1) + for i in points.indices { + for j in points.indices where j > i { + XCTAssertGreaterThanOrEqual(hypot(points[i].x - points[j].x, points[i].y - points[j].y), 44) + } + } + } + + func testRingRowAndColumnShapes() { + let center = CGPoint(x: 600, y: 400) + let ring = RadialPaletteLayout.points(count: 8, center: center, shape: .ring, itemDiameter: 44, + spacing: 6, minimumRadius: 44, direction: 0, bounds: container) + for (a, b) in zip(ring, ring.dropFirst() + [ring[0]]) { + XCTAssertGreaterThanOrEqual(hypot(a.x - b.x, a.y - b.y), 50 - 0.01) + } + let row = RadialPaletteLayout.points(count: 3, center: center, shape: .row, itemDiameter: 44, + spacing: 6, minimumRadius: 60, direction: -.pi / 2, bounds: container) + XCTAssertEqual(Set(row.map { $0.y }), [340]) + XCTAssertEqual(row.map { $0.x }, [550, 600, 650]) + let column = RadialPaletteLayout.points(count: 3, center: center, shape: .column, itemDiameter: 44, + spacing: 6, minimumRadius: 60, direction: .pi, bounds: container) + XCTAssertEqual(Set(column.map { $0.x }), [540]) + } + + func testPaletteGrowsOutwardPastOtherControls() { + let center = CGPoint(x: 600, y: 400) + let blocker = CGRect(x: 578, y: 278, width: 44, height: 44) // straight up at radius 100 + let points = RadialPaletteLayout.points(count: 3, center: center, shape: .arc, itemDiameter: 44, + spacing: 6, minimumRadius: 100, direction: -.pi / 2, + bounds: container, obstacles: [blocker]) + for point in points { + XCTAssertFalse(blocker.insetBy(dx: -22, dy: -22).contains(point)) + } + } + + func testPaletteKeysStayOnScreen() { + let points = RadialPaletteLayout.points(count: 6, center: CGPoint(x: 20, y: 20), shape: .ring, + itemDiameter: 44, spacing: 6, minimumRadius: 60, + direction: 0, bounds: container) + for point in points { + XCTAssertTrue(container.insetBy(dx: 21.9, dy: 21.9).contains(point)) + } + } + + // MARK: - Two-Hand Assist + + func testAssistProjectsTheSharedChordState() { + var interaction = ControlInteractionState() + XCTAssertEqual(TwoHandAssist.state(enabled: true, interaction: interaction), .idle) + XCTAssertEqual(TwoHandAssist.state(enabled: false, interaction: interaction), .hidden) + interaction.press(.command) + XCTAssertEqual(TwoHandAssist.state(enabled: true, interaction: interaction), .helper(active: [.command])) + } + + func testHelperModifierJoinsTheHeldChord() { + var interaction = ControlInteractionState() + interaction.press(.command) + let effects = interaction.toggleAssistModifier(.option) + XCTAssertTrue(effects.contains(.modifierDown(.command))) + XCTAssertTrue(effects.contains(.modifierDown(.option))) + XCTAssertEqual(interaction.activeChord, ModifierChord([.command, .option])) + XCTAssertEqual(interaction.paletteChord, ModifierChord([.command, .option]), + "the primary palette updates to the combined chord") + XCTAssertEqual(TwoHandAssist.state(enabled: true, interaction: interaction), + .helper(active: [.command, .option])) + // Tapping it again on the helper removes it again. + _ = interaction.toggleAssistModifier(.option) + XCTAssertEqual(interaction.activeChord, ModifierChord([.command])) + _ = interaction.toggleAssistModifier(.shift) + // Releasing the hold (executing a shortcut) releases everything, and + // the idle Function actions come back. + let action = ShortcutItem(title: "Redo", displayKey: "Z", usage: 29, + modifiers: ModifierChord([.command, .shift])) + let release = interaction.finish(with: action) + XCTAssertTrue(release.contains(.execute(action))) + XCTAssertTrue(release.contains(.modifierUp(.command))) + XCTAssertTrue(release.contains(.modifierUp(.shift))) + XCTAssertEqual(TwoHandAssist.state(enabled: true, interaction: interaction), .idle) + } + + func testHelperModifierLatchesAlongsideALatchedChord() { + var interaction = ControlInteractionState() + _ = interaction.tap(.command) + _ = interaction.toggleAssistModifier(.option) + XCTAssertEqual(interaction.latchedModifiers, [.command, .option]) + XCTAssertEqual(TwoHandAssist.state(enabled: true, interaction: interaction), + .helper(active: [.command, .option])) + _ = interaction.resetAll() + XCTAssertEqual(TwoHandAssist.state(enabled: true, interaction: interaction), .idle) + } + + func testHelperAppearsOnTheOppositeSide() { + XCTAssertEqual(TwoHandAssist.helperAnchor(clusterCentroid: CGPoint(x: 0.875, y: 0.75)), + CGPoint(x: 0.125, y: 0.75)) + XCTAssertEqual(TwoHandAssist.helperAnchor(clusterCentroid: CGPoint(x: 0.1, y: 0.2)), + CGPoint(x: 0.9, y: 0.2)) + XCTAssertEqual(TwoHandAssist.helperAnchor(clusterCentroid: CGPoint(x: 0.5, y: 0.875)), + CGPoint(x: 0.5, y: 0.125), "a bottom-centered cluster mirrors to the top") + let area = CGRect(x: 12, y: 12, width: 1170, height: 810) + let points = TwoHandAssist.helperPoints(count: 4, clusterCentroid: CGPoint(x: 0.9, y: 0.9), area: area, + itemDiameter: 44, spacing: 6) + XCTAssertEqual(points.count, 4) + for point in points { + XCTAssertLessThan(point.x, area.midX) + XCTAssertGreaterThan(point.y, area.midY) + XCTAssertTrue(area.contains(point)) + } + } + + func testTemplateEnablesTwoHandAssistWithIdleFunctionActions() { + let layout = CustomControlLayout.radialTemplate(name: "Test", corner: .bottomLeading) + XCTAssertTrue(layout.twoHandAssist) + XCTAssertEqual(layout.assistActionIDs, ["undo", "redo", "zoom-in", "zoom-out"]) + let canonicalIDs = Set(FunctionTrayProfile.canonical().items.map(\.id)) + XCTAssertTrue(Set(layout.assistActionIDs).isSubset(of: canonicalIDs), + "idle actions reuse existing Function Tray items") + XCTAssertLessThan(layout.landscape.clusterCentroid.x, 0.5) + } + + // MARK: - Gesture preference + + func testPreferMeowDisplayGesturesControlsEdgeDeferral() { + XCTAssertTrue(ReceiverScreenEdgePolicy.defersScreenEdges(surfaceShown: true, inputAllowed: true, + preferMeowDisplayGestures: true)) + XCTAssertFalse(ReceiverScreenEdgePolicy.defersScreenEdges(surfaceShown: true, inputAllowed: true, + preferMeowDisplayGestures: false)) + XCTAssertTrue(ReceiverEditingInteractionPolicy.suppressesEditingInteractions(preferMeowDisplayGestures: true)) + XCTAssertFalse(ReceiverEditingInteractionPolicy.suppressesEditingInteractions(preferMeowDisplayGestures: false)) + } + + func testVoiceOverAlwaysWins() { + XCTAssertFalse(ReceiverScreenEdgePolicy.defersScreenEdges(surfaceShown: true, inputAllowed: true, + preferMeowDisplayGestures: true, + voiceOverRunning: true)) + var gate = ReceiverMultiFingerGestureGate(voiceOverRunning: true) + XCTAssertFalse(gate.recognizersEnabled) + _ = gate.update(viewportNavigationActive: false) + XCTAssertFalse(gate.recognizersEnabled, "no preference or mode re-enables them under VoiceOver") + } + + func testMoveViewParksTheMultiFingerRecognizers() { + var gate = ReceiverMultiFingerGestureGate(voiceOverRunning: false) + XCTAssertTrue(gate.update(viewportNavigationActive: true)) + for kind in ReceiverMultiFingerRecognizer.allCases { XCTAssertFalse(gate.isEnabled(kind)) } + XCTAssertFalse(gate.update(viewportNavigationActive: true)) + XCTAssertTrue(gate.update(viewportNavigationActive: false)) + XCTAssertTrue(gate.recognizersEnabled) + } + + // MARK: - Settings + + func testSettingsCategoriesAreSharedByBothSizeClasses() { + XCTAssertFalse(MobileSettingsCategory.visible(debug: false).contains(.developer)) + XCTAssertTrue(MobileSettingsCategory.visible(debug: true).contains(.developer)) + XCTAssertEqual(MobileSettingsCategory.initialSelection(regularWidth: true), .general) + XCTAssertNil(MobileSettingsCategory.initialSelection(regularWidth: false)) + let ids = MobileSettingsCategory.allCases.map(\.id) + XCTAssertEqual(ids.count, Set(ids).count) + for category in MobileSettingsCategory.allCases { + XCTAssertFalse(category.title.isEmpty) + XCTAssertFalse(category.systemImage.isEmpty) + } + } + + func testIPadOnlyControlSettingsNeverShowOnIPhone() { + for layout in PadControlLayoutMode.allCases { + let phone = PadControlSettingsVisibility(isPad: false, layout: layout) + XCTAssertFalse(phone.showsLayoutPicker) + XCTAssertFalse(phone.showsControlSize) + XCTAssertFalse(phone.showsEdgePickers) + XCTAssertFalse(phone.showsControlHints) + XCTAssertFalse(phone.showsCustomLayouts) + XCTAssertTrue(phone.showsPhoneTrayPlacement) + } + XCTAssertTrue(PadControlSettingsVisibility(isPad: true, layout: .strip).showsControlHints) + XCTAssertFalse(PadControlSettingsVisibility(isPad: true, layout: .overlay).showsControlHints) + XCTAssertFalse(PadControlSettingsVisibility(isPad: true, layout: .custom).showsEdgePickers) + XCTAssertTrue(PadControlSettingsVisibility(isPad: true, layout: .custom).showsCustomLayouts) + } + + // MARK: - Helpers + + private func makeRepository() throws -> (ReceiverControlPreferencesRepository, UserDefaults, String) { + let suite = "PadControlLayoutTests.\(UUID().uuidString)" + let defaults = try XCTUnwrap(UserDefaults(suiteName: suite)) + return (ReceiverControlPreferencesRepository(defaults: defaults), defaults, suite) + } +} diff --git a/MacTests/ReceiverControlsTests.swift b/MacTests/ReceiverControlsTests.swift index 46f2eb9b..5617bf94 100644 --- a/MacTests/ReceiverControlsTests.swift +++ b/MacTests/ReceiverControlsTests.swift @@ -1315,8 +1315,10 @@ final class ReceiverControlsTests: XCTestCase { defaults.set(try JSONEncoder().encode(old), forKey: ReceiverControlPreferencesRepository.defaultsKey) let loaded = ReceiverControlPreferencesRepository(defaults: defaults).load() + // Schema 16 moves pre-release installs to Pinch → Viewport, + // Rotation → Disabled once. XCTAssertEqual(loaded.pinchTarget, .viewport) - XCTAssertEqual(loaded.rotateTarget, .viewport) + XCTAssertEqual(loaded.rotateTarget, .disabled) XCTAssertTrue(loaded.snapRotation) XCTAssertEqual(loaded.version, ReceiverControlPreferences.schemaVersion) } diff --git a/MacTests/RemoteViewportTests.swift b/MacTests/RemoteViewportTests.swift index b510c03b..20e1f02b 100644 --- a/MacTests/RemoteViewportTests.swift +++ b/MacTests/RemoteViewportTests.swift @@ -499,21 +499,21 @@ final class RemoteViewportTests: XCTestCase { XCTAssertEqual(clamped.scale, ManualViewportState.maxScale) } - func testReturningToMinimumScaleClearsPan() { + func testMinimumScaleKeepsPanWithinHalfTheContent() { + // 100% no longer forces the view back to center; an edge may reach + // the viewport's center and no further. let clamped = ManualViewportState(scale: 1, panX: 999, panY: -999).clamped(against: squareBounds) - XCTAssertEqual(clamped.panX, 0) - XCTAssertEqual(clamped.panY, 0) + XCTAssertEqual(clamped.panX, squareBounds.width / 2) + XCTAssertEqual(clamped.panY, -squareBounds.height / 2) } - func testPanClampsSoScaledContentAlwaysCoversTheBase() { + func testZoomedPanStopsWhenAnEdgeReachesTheViewportCenter() { let state = ManualViewportState(scale: 2, panX: 10_000, panY: -10_000) let clamped = state.clamped(against: squareBounds) let width = squareBounds.width * clamped.scale let height = squareBounds.height * clamped.scale - let maxPanX = (width - squareBounds.width) / 2 - let maxPanY = (height - squareBounds.height) / 2 - XCTAssertEqual(clamped.panX, maxPanX, accuracy: 0.001) - XCTAssertEqual(clamped.panY, -maxPanY, accuracy: 0.001) + XCTAssertEqual(clamped.panX, width / 2, accuracy: 0.001) + XCTAssertEqual(clamped.panY, -height / 2, accuracy: 0.001) } func testPinchZoomsAroundTheGestureMidpoint() { diff --git a/MacTests/SmartTouchTests.swift b/MacTests/SmartTouchTests.swift index 69675cc7..08c85de5 100644 --- a/MacTests/SmartTouchTests.swift +++ b/MacTests/SmartTouchTests.swift @@ -585,8 +585,8 @@ final class SmartTouchTests: XCTestCase { // MARK: - Preference persistence - func testSmartTouchDefaultsOffAndMigratesFromOlderSchema() throws { - XCTAssertFalse(ReceiverControlPreferences().smartTouchEnabled) + func testSmartTouchDefaultsOnAndMigratesFromOlderSchema() throws { + XCTAssertTrue(ReceiverControlPreferences().smartTouchEnabled) XCTAssertTrue(ReceiverControlPreferences().smartTouchLongPressHapticEnabled) let defaults = try XCTUnwrap(UserDefaults(suiteName: "SmartTouchTests.\(UUID().uuidString)")) var old = ReceiverControlPreferences() @@ -599,13 +599,14 @@ final class SmartTouchTests: XCTestCase { let repository = ReceiverControlPreferencesRepository(defaults: defaults) let loaded = repository.load() XCTAssertEqual(loaded.version, ReceiverControlPreferences.schemaVersion) - XCTAssertFalse(loaded.smartTouchEnabled) + // Schema 16 turns Smart Touch on for pre-release installs once. + XCTAssertTrue(loaded.smartTouchEnabled) XCTAssertTrue(loaded.smartTouchLongPressHapticEnabled) XCTAssertEqual(loaded.inputMode, .trackpad, "other preferences survive") - var enabled = loaded - enabled.smartTouchEnabled = true - repository.save(enabled) - XCTAssertTrue(repository.load().smartTouchEnabled) + var disabled = loaded + disabled.smartTouchEnabled = false + repository.save(disabled) + XCTAssertFalse(repository.load().smartTouchEnabled, "a schema-16 choice is never re-migrated") } } diff --git a/MacTests/ViewportPanFreedomTests.swift b/MacTests/ViewportPanFreedomTests.swift new file mode 100644 index 00000000..f905666a --- /dev/null +++ b/MacTests/ViewportPanFreedomTests.swift @@ -0,0 +1,195 @@ +import CoreGraphics +import XCTest + +/// Viewport translation at 100% and beyond: any edge of the remote display +/// may reach the viewport's center but never pass it, and Move View's +/// navigation session reuses the same clamp. +final class ViewportPanFreedomTests: XCTestCase { + /// A letterboxed 16:10 picture in a 1000x800 surface: 1000x625, centered. + private let base = CGRect(x: 0, y: 87.5, width: 1000, height: 625) + private var center: CGPoint { CGPoint(x: base.midX, y: base.midY) } + + private func displayed(_ state: ManualViewportState) -> RemoteViewportTransform { + RemoteViewportCalculator.applyManualZoom( + to: RemoteViewportTransform(remoteCrop: CGRect(x: 0, y: 0, width: 1, height: 1), displayedRect: base), + state: state) + } + + func testPanWorksAtExactlyOneHundredPercent() { + let state = ManualViewportState(scale: 1, panX: 120, panY: -40).clamped(against: base) + XCTAssertEqual(state.scale, 1) + XCTAssertEqual(state.panX, 120) + XCTAssertEqual(state.panY, -40) + XCTAssertFalse(state.isIdentity) + XCTAssertEqual(displayed(state).displayedRect, base.offsetBy(dx: 120, dy: -40)) + } + + func testLeftEdgeReachesTheCenterButNotPast() { + let rect = displayed(ManualViewportState(scale: 1, panX: 10_000).clamped(against: base)).displayedRect + XCTAssertEqual(rect.minX, center.x, accuracy: 0.001) + } + + func testRightEdgeReachesTheCenterButNotPast() { + let rect = displayed(ManualViewportState(scale: 1, panX: -10_000).clamped(against: base)).displayedRect + XCTAssertEqual(rect.maxX, center.x, accuracy: 0.001) + } + + func testTopEdgeReachesTheCenterButNotPast() { + let rect = displayed(ManualViewportState(scale: 1, panY: 10_000).clamped(against: base)).displayedRect + XCTAssertEqual(rect.minY, center.y, accuracy: 0.001) + } + + func testBottomEdgeReachesTheCenterButNotPast() { + let rect = displayed(ManualViewportState(scale: 1, panY: -10_000).clamped(against: base)).displayedRect + XCTAssertEqual(rect.maxY, center.y, accuracy: 0.001) + } + + func testTheDisplayCanNeverLeaveTheViewport() { + for panX in stride(from: -5_000.0, through: 5_000, by: 1_250) { + for panY in stride(from: -5_000.0, through: 5_000, by: 1_250) { + for scale in [1.0, 1.7, 3.0] { + let rect = displayed(ManualViewportState(scale: CGFloat(scale), panX: CGFloat(panX), + panY: CGFloat(panY)).clamped(against: base)).displayedRect + // The picture always still touches the viewport's center. + XCTAssertLessThanOrEqual(rect.minX, center.x + 0.001) + XCTAssertGreaterThanOrEqual(rect.maxX, center.x - 0.001) + XCTAssertLessThanOrEqual(rect.minY, center.y + 0.001) + XCTAssertGreaterThanOrEqual(rect.maxY, center.y - 0.001) + } + } + } + } + + func testHigherZoomAllowsProportionallyMoreTravel() { + let state = ManualViewportState(scale: 2, panX: 10_000, panY: -10_000).clamped(against: base) + let rect = displayed(state).displayedRect + XCTAssertEqual(rect.minX, center.x, accuracy: 0.001) + XCTAssertEqual(rect.maxY, center.y, accuracy: 0.001) + } + + func testRotatedExtentsLimitTravelToTheCenter() throws { + let quarter = ManualViewportState(scale: 1, panX: 10_000, panY: 10_000, rotationRadians: .pi / 2) + .clamped(against: base) + let limits = try XCTUnwrap(quarter.panLimits(against: base)) + // A quarter turn swaps the extents. + XCTAssertEqual(limits.x, base.height / 2, accuracy: 0.001) + XCTAssertEqual(limits.y, base.width / 2, accuracy: 0.001) + let transform = displayed(quarter) + // The rotated picture's leftmost/topmost point sits on the center. + let corners = [CGPoint(x: 0, y: 0), CGPoint(x: 1, y: 0), CGPoint(x: 0, y: 1), CGPoint(x: 1, y: 1)] + .map(transform.viewPoint(forRemote:)) + XCTAssertEqual(corners.map(\.x).min() ?? 0, center.x, accuracy: 0.01) + XCTAssertEqual(corners.map(\.y).min() ?? 0, center.y, accuracy: 0.01) + + let tilted = ManualViewportState(scale: 1.5, panX: -10_000, rotationRadians: .pi / 6).clamped(against: base) + let tiltedCorners = [CGPoint(x: 0, y: 0), CGPoint(x: 1, y: 0), CGPoint(x: 0, y: 1), CGPoint(x: 1, y: 1)] + .map(displayed(tilted).viewPoint(forRemote:)) + XCTAssertEqual(tiltedCorners.map(\.x).max() ?? 0, center.x, accuracy: 0.01) + } + + func testInvalidGeometryResetsToIdentity() { + let state = ManualViewportState(scale: 2, panX: 50, panY: 50, rotationRadians: 1) + XCTAssertEqual(state.clamped(against: .zero), ManualViewportState(scale: 2)) + XCTAssertNil(state.panLimits(against: CGRect(x: 0, y: 0, width: 0, height: 100))) + let nonFinite = ManualViewportState(scale: 1, panX: .nan, panY: .infinity).clamped(against: base) + XCTAssertEqual(nonFinite.panX, 0) + XCTAssertEqual(nonFinite.panY, 0) + } + + func testResetAndRestoreIncludeAPanOnlyView() throws { + let panned = ManualViewportState(scale: 1, panX: 200, panY: 0) + let reset = try XCTUnwrap(ViewportResetRestorePolicy.toggled(current: panned, memory: nil, base: base)) + XCTAssertEqual(reset.current, .identity) + XCTAssertEqual(reset.memory, panned) + let restored = try XCTUnwrap(ViewportResetRestorePolicy.toggled(current: reset.current, + memory: reset.memory, base: base)) + XCTAssertEqual(restored.current, panned) + // Restoring into a smaller viewport re-clamps rather than overshoots. + let small = CGRect(x: 0, y: 0, width: 200, height: 200) + let reclamped = try XCTUnwrap(ViewportResetRestorePolicy.toggled(current: .identity, + memory: panned, base: small)) + XCTAssertEqual(reclamped.current.panX, 100) + } + + func testInputMappingStaysTheInverseOfRendering() { + let states = [ManualViewportState(scale: 1, panX: 300, panY: -200), + ManualViewportState(scale: 2.4, panX: -900, panY: 400, rotationRadians: 0.7), + ManualViewportState(scale: 1, panX: 499, panY: 0, rotationRadians: .pi / 2)] + for state in states { + let transform = displayed(state.clamped(against: base)) + for remote in [CGPoint(x: 0.1, y: 0.2), CGPoint(x: 0.5, y: 0.5), CGPoint(x: 0.93, y: 0.71)] { + let view = transform.viewPoint(forRemote: remote) + XCTAssertTrue(transform.containsViewPoint(view)) + guard let back = transform.remotePoint(forView: view) else { return XCTFail("unmappable") } + XCTAssertEqual(back.x, remote.x, accuracy: 0.0001) + XCTAssertEqual(back.y, remote.y, accuracy: 0.0001) + } + } + } + + func testPinchCommittedPanAtOneHundredPercentTranslates() { + let result = ManualViewportState.pinching(from: .identity, initialBase: base, + initialMidpoint: center, + currentMidpoint: CGPoint(x: center.x + 80, y: center.y), + scaleRatio: 1) + XCTAssertEqual(result.scale, 1) + XCTAssertEqual(result.panX, 80, accuracy: 0.001) + } + + // MARK: - Move View + + func testOneFingerMoveViewPans() { + var session = ViewportNavigationSession() + var state = ManualViewportState.identity + state = session.update(points: [CGPoint(x: 100, y: 100)], current: state, base: base, + allowsZoom: true, allowsRotation: false) + XCTAssertEqual(state, .identity, "the first sample only sets the baseline") + state = session.update(points: [CGPoint(x: 160, y: 70)], current: state, base: base, + allowsZoom: true, allowsRotation: false) + XCTAssertEqual(state.panX, 60, accuracy: 0.001) + XCTAssertEqual(state.panY, -30, accuracy: 0.001) + XCTAssertEqual(state.scale, 1) + } + + func testAddingAFingerReBaselinesWithoutAJump() { + var session = ViewportNavigationSession() + var state = ManualViewportState.identity + state = session.update(points: [CGPoint(x: 100, y: 100)], current: state, base: base, + allowsZoom: true, allowsRotation: false) + state = session.update(points: [CGPoint(x: 150, y: 100)], current: state, base: base, + allowsZoom: true, allowsRotation: false) + let beforeSecondFinger = state + state = session.update(points: [CGPoint(x: 150, y: 100), CGPoint(x: 400, y: 400)], current: state, + base: base, allowsZoom: true, allowsRotation: false) + XCTAssertEqual(state, beforeSecondFinger) + } + + func testMoveViewPinchZoomsAndRotatesOnlyWhenAllowed() { + func pinch(allowsZoom: Bool, allowsRotation: Bool) -> ManualViewportState { + var session = ViewportNavigationSession() + var state = ManualViewportState.identity + state = session.update(points: [CGPoint(x: 400, y: 400), CGPoint(x: 600, y: 400)], current: state, + base: base, allowsZoom: allowsZoom, allowsRotation: allowsRotation) + // Twice as far apart, and turned a quarter. + return session.update(points: [CGPoint(x: 500, y: 200), CGPoint(x: 500, y: 600)], current: state, + base: base, allowsZoom: allowsZoom, allowsRotation: allowsRotation) + } + let both = pinch(allowsZoom: true, allowsRotation: true) + XCTAssertEqual(both.scale, 2, accuracy: 0.001) + XCTAssertEqual(both.rotationRadians, .pi / 2, accuracy: 0.001) + let zoomOnly = pinch(allowsZoom: true, allowsRotation: false) + XCTAssertEqual(zoomOnly.scale, 2, accuracy: 0.001) + XCTAssertEqual(zoomOnly.rotationRadians, 0) + let panOnly = pinch(allowsZoom: false, allowsRotation: false) + XCTAssertEqual(panOnly.scale, 1) + } + + func testMoveViewStillStopsAtTheCenter() { + var session = ViewportNavigationSession() + var state = session.update(points: [CGPoint(x: 0, y: 0)], current: .identity, base: base, + allowsZoom: true, allowsRotation: false) + state = session.update(points: [CGPoint(x: 5_000, y: 0)], current: state, base: base, + allowsZoom: true, allowsRotation: false) + XCTAssertEqual(displayed(state).displayedRect.minX, center.x, accuracy: 0.001) + } +} diff --git a/Shared/ControlProfile.swift b/Shared/ControlProfile.swift index 69911a96..1178e761 100644 --- a/Shared/ControlProfile.swift +++ b/Shared/ControlProfile.swift @@ -544,7 +544,7 @@ enum LandscapeTrayCorner: String, Codable, CaseIterable, Identifiable { } struct ReceiverControlPreferences: Codable, Equatable { - static let schemaVersion = 15 + static let schemaVersion = 16 var version = schemaVersion var trayEnabled = true @@ -559,9 +559,11 @@ struct ReceiverControlPreferences: Codable, Equatable { /// `ReceiverControlOverlay` itself, and never persisted here, so a /// relaunch always starts visible according to `trayCollapsed`/ /// `trayEnabled` as normal. Independent of `trayCollapsed`, which stays - /// the user's manual, persisted collapse choice. Defaults off so - /// existing users see no behavior change until they opt in. - var autoHideEnabled = false + /// the user's manual, persisted collapse choice. Defaults on (schema 16: + /// the Mac display is the canvas, so controls get out of its way); the + /// iPad Strip layout keeps its reserved rail visible regardless — see + /// `ControlAutoHidePolicy`. + var autoHideEnabled = true /// Whether THIS session currently has effective input, mirrored from the /// Mac's per-session consent decision once connected (see /// `ReceiverControlStore.sessionInputState`/`applySessionInputState`, @@ -583,8 +585,8 @@ struct ReceiverControlPreferences: Codable, Equatable { var trackpadSensitivity = PointerGestureConfig.defaultTrackpadSensitivity /// Smart Touch (Experimental): lets a one-finger Direct Touch swipe /// scroll when the Mac confirms the touched element is scrollable. - /// No effect in Trackpad mode. Defaults off. - var smartTouchEnabled = false + /// No effect in Trackpad mode. Defaults on (schema 16). + var smartTouchEnabled = true /// Smart Touch Haptics: every Smart Touch hold haptic — the long press /// that switches a touch into plain Direct Touch, and the title bar /// hold's build-up and confirmation. Also silenced whenever @@ -611,8 +613,17 @@ struct ReceiverControlPreferences: Codable, Equatable { /// while video production is off. It never participates in hit-testing. var showSurfaceGrid = true var pinchTarget = ReceiverGestureTarget.viewport - var rotateTarget = ReceiverGestureTarget.viewport + /// Defaults to Disabled (schema 16): an accidental twist while pinching + /// should never tilt the Mac display. Viewport rotation stays one + /// setting away. + var rotateTarget = ReceiverGestureTarget.disabled var snapRotation = true + /// Prefer MeowDisplay Gestures: while the receiver surface controls the + /// Mac, ask iPadOS/iOS to defer its screen-edge gestures and opt the + /// surface out of UIKit's three-finger editing interactions. Only public + /// API, so system-owned gestures (VoiceOver, 4/5-finger multitasking) + /// still win — see `ReceiverSystemGesturePolicy`. + var preferMeowDisplayGestures = true /// App-mode command chords — see App Gesture Commands (spec section D). /// Independent of `pinchTarget`/`rotateTarget`: changing a binding never /// changes which target is active, and "Reset to Defaults" on this page @@ -634,6 +645,26 @@ struct ReceiverControlPreferences: Codable, Equatable { /// starts. Defaults on. var pictureInPictureEnabled = true + // MARK: iPad-only presentation (never shown or applied on iPhone) + + /// Strip reserves an edge rail and fits the Mac beside it; Overlay floats + /// controls over a full-screen Mac; Custom uses `activeCustomLayoutID`. + var padControlLayout = PadControlLayoutMode.strip + /// Independent edges for the Main controls and the Function controls in + /// Strip and Overlay — the iPad replacement for `functionTrayPosition`. + var padMainEdge = ControlEdge.trailing + var padFunctionEdge = ControlEdge.trailing + /// Strip only: compact key/action captions beside the controls. + var padShowControlHints = true + /// Multiplier on every iPad control, clamped to `PadControlScale.range`. + var padControlScale = PadControlScale.defaultValue + /// Adds the Move View control to the Strip/Overlay Main controls. + var padShowMoveViewControl = true + /// At most `CustomControlLayout.maximumCount`; none exist until the user + /// creates one — see `createCustomLayout`. + var customLayouts: [CustomControlLayout] = [] + var activeCustomLayoutID: String? + init(profiles: [ControlProfile] = ControlProfileSlot.allCases.map { ControlProfile.canonical(slot: $0) }, functionTrayProfiles: [FunctionTrayProfile] = ControlProfileSlot.allCases.map { FunctionTrayProfile.canonical(slot: $0) }) { self.profiles = profiles @@ -703,16 +734,34 @@ struct ReceiverControlPreferences: Codable, Equatable { audioPreferred = try value(.audioPreferred, fallback.audioPreferred) avSyncOffsetMs = AVSyncOffset.clamped(try value(.avSyncOffsetMs, fallback.avSyncOffsetMs)) // Absent (schema < 13) means "written before Auto-hide existed" — - // default off, matching a brand-new install. + // take a brand-new install's value (the schema-16 migration below + // then settles it). autoHideEnabled = try value(.autoHideEnabled, fallback.autoHideEnabled) // Absent (schema < 14) means "written before Smart Touch existed" — - // default off, matching a brand-new install. + // take a brand-new install's value (the schema-16 migration below + // then settles it). smartTouchEnabled = try value(.smartTouchEnabled, fallback.smartTouchEnabled) smartTouchLongPressHapticEnabled = try value(.smartTouchLongPressHapticEnabled, fallback.smartTouchLongPressHapticEnabled) // Absent (schema < 15) means "written before Picture in Picture // existed" — default on, matching a brand-new install. pictureInPictureEnabled = try value(.pictureInPictureEnabled, fallback.pictureInPictureEnabled) + // Absent (schema < 16) means "written before Prefer MeowDisplay + // Gestures and the iPad layouts existed" — default to a brand-new + // install's values. + preferMeowDisplayGestures = try value(.preferMeowDisplayGestures, fallback.preferMeowDisplayGestures) + padControlLayout = try value(.padControlLayout, fallback.padControlLayout) + padMainEdge = try value(.padMainEdge, fallback.padMainEdge) + padFunctionEdge = try value(.padFunctionEdge, fallback.padFunctionEdge) + padShowControlHints = try value(.padShowControlHints, fallback.padShowControlHints) + padControlScale = PadControlScale.clamped(try value(.padControlScale, fallback.padControlScale)) + padShowMoveViewControl = try value(.padShowMoveViewControl, fallback.padShowMoveViewControl) + // Lossy: one layout written by a newer build (an unknown control + // kind, say) is dropped on its own instead of resetting every + // preference above. + customLayouts = Array((try value(.customLayouts, LossyDecodableArray())) + .elements.prefix(CustomControlLayout.maximumCount)) + activeCustomLayoutID = try container.decodeIfPresent(String.self, forKey: .activeCustomLayoutID) } /// Restores only the four App Gesture Commands to their canonical @@ -852,12 +901,12 @@ struct ReceiverControlPreferencesRepository { value.version = 12 } // Schema 12 predates Auto-hide; the custom decoder above already - // defaulted `autoHideEnabled` to off — nothing to transform. + // defaulted `autoHideEnabled` — nothing to transform. if value.version < 13 { value.version = 13 } // Schema 13 predates Smart Touch; the custom decoder above already - // defaulted `smartTouchEnabled` to off — nothing to transform. + // defaulted `smartTouchEnabled` — nothing to transform. if value.version < 14 { value.version = 14 } @@ -867,6 +916,19 @@ struct ReceiverControlPreferencesRepository { if value.version < 15 { value.version = 15 } + // Schema 16 changes four pre-release defaults (Smart Touch on, + // Auto-hide on, Pinch → Viewport, Rotation → Disabled). MeowDisplay + // has not shipped, so existing test installs move to them once; + // anything the user changes afterwards is saved as schema 16 and + // never touched here again. + if value.version < 16 { + let defaults = ReceiverControlPreferences() + value.smartTouchEnabled = defaults.smartTouchEnabled + value.autoHideEnabled = defaults.autoHideEnabled + value.pinchTarget = defaults.pinchTarget + value.rotateTarget = defaults.rotateTarget + value.version = 16 + } // Old Function Tray profiles predate `ShortcutItem.systemImage`. // Resolve current canonical metadata by ID without rewriting the // user's visibility, order, or group choices. diff --git a/Shared/CustomControlLayout.swift b/Shared/CustomControlLayout.swift new file mode 100644 index 00000000..48803839 --- /dev/null +++ b/Shared/CustomControlLayout.swift @@ -0,0 +1,587 @@ +// iPad Custom control layouts: where controls APPEAR, never what they DO. +// Every placement points at an existing control — a Main tray item +// (`ControlTrayItem`, whose modifiers drive the one `ControlInteractionState` +// chord machine), a Function Tray item by its stable `ShortcutItem` id, or +// Move View — so shortcuts, chords and wire messages stay exactly where they +// already live. CoreGraphics + Foundation only (hostless-testable). + +import CoreGraphics +import Foundation + +/// Decodes an array while dropping elements that fail to decode, so one +/// entry written by a newer build can't make the whole blob unreadable. +struct LossyDecodableArray: Decodable { + var elements: [Element] + + init(_ elements: [Element] = []) { self.elements = elements } + + init(from decoder: Decoder) throws { + var container = try decoder.unkeyedContainer() + var elements: [Element] = [] + while !container.isAtEnd { + if let element = try? container.decode(Element.self) { + elements.append(element) + } else { + _ = try? container.decode(DiscardedElement.self) + } + } + self.elements = elements + } + + private struct DiscardedElement: Decodable {} +} + +enum ControlCorner: String, Codable, CaseIterable, Identifiable { + case topLeading + case topTrailing + case bottomLeading + case bottomTrailing + + var id: String { rawValue } + + var title: String { + switch self { + case .topLeading: return String(localized: "Top Left") + case .topTrailing: return String(localized: "Top Right") + case .bottomLeading: return String(localized: "Bottom Left") + case .bottomTrailing: return String(localized: "Bottom Right") + } + } + + var isLeading: Bool { self == .topLeading || self == .bottomLeading } + var isTop: Bool { self == .topLeading || self == .topTrailing } + + /// The corner as a normalized point (0 or 1 on each axis). + var unitPoint: CGPoint { CGPoint(x: isLeading ? 0 : 1, y: isTop ? 0 : 1) } +} + +/// What a Custom placement shows. Resolves against existing models only. +enum CustomControlKind: Hashable, Codable { + /// A Main tray item: a modifier, Escape, Tab, Toggle Dock, Keyboard or + /// Settings. + case tray(ControlTrayItem) + /// A Function Tray item, by its stable `ShortcutItem.id`. + case function(String) + /// The receiver-local Move View mode toggle. + case moveView + + private enum CodingKeys: String, CodingKey { case kind, value } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + switch try container.decode(String.self, forKey: .kind) { + case "tray": + let raw = try container.decode(String.self, forKey: .value) + guard let item = ControlTrayItem(rawValue: raw) else { + throw DecodingError.dataCorruptedError(forKey: .value, in: container, + debugDescription: "Unknown tray item \(raw)") + } + self = .tray(item) + case "function": + self = .function(try container.decode(String.self, forKey: .value)) + case "moveView": + self = .moveView + default: + throw DecodingError.dataCorruptedError(forKey: .kind, in: container, + debugDescription: "Unknown control kind") + } + } + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + switch self { + case .tray(let item): + try container.encode("tray", forKey: .kind) + try container.encode(item.rawValue, forKey: .value) + case .function(let id): + try container.encode("function", forKey: .kind) + try container.encode(id, forKey: .value) + case .moveView: + try container.encode("moveView", forKey: .kind) + } + } + + var modifier: ControlModifier? { + if case .tray(let item) = self { return item.modifier } + return nil + } +} + +enum PaletteShape: String, Codable, CaseIterable, Identifiable { + case arc + case ring + case row + case column + + var id: String { rawValue } + + var title: String { + switch self { + case .arc: return String(localized: "Arc", comment: "Chord palette shape.") + case .ring: return String(localized: "Ring", comment: "Chord palette shape.") + case .row: return String(localized: "Row", comment: "Chord palette shape.") + case .column: return String(localized: "Column", comment: "Chord palette shape.") + } + } +} + +/// How a modifier's chord palette blooms around it. +struct PalettePresentation: Codable, Equatable { + static let spacingRange: ClosedRange = 0.8...2 + var shape = PaletteShape.arc + /// Multiplier on the automatic bloom radius. + var spacing = 1.0 + /// Bloom direction in degrees (0 = right, 90 = down); `nil` blooms + /// toward the middle of the screen. + var directionDegrees: Double? +} + +struct CustomControlPlacement: Codable, Equatable, Identifiable { + static let sizeRange: ClosedRange = 0.8...1.6 + + var id: String + var kind: CustomControlKind + /// Center, normalized to the safe layout area (0...1 on each axis) so a + /// layout reads the same on every iPad size. + var x: Double + var y: Double + /// Per-control size multiplier on top of the global Control Size. + var size: Double + /// Modifiers only. + var palette: PalettePresentation? + + init(id: String = UUID().uuidString, kind: CustomControlKind, x: Double, y: Double, + size: Double = 1, palette: PalettePresentation? = nil) { + self.id = id + self.kind = kind + self.x = Self.unit(x) + self.y = Self.unit(y) + self.size = Self.clampedSize(size) + self.palette = palette ?? (kind.modifier != nil ? PalettePresentation() : nil) + } + + static func unit(_ value: Double) -> Double { + value.isFinite ? min(max(value, 0), 1) : 0.5 + } + + static func clampedSize(_ value: Double) -> Double { + value.isFinite ? min(max(value, sizeRange.lowerBound), sizeRange.upperBound) : 1 + } +} + +/// One orientation's layout. +struct CustomControlArrangement: Codable, Equatable { + /// The corner the permanent cluster was built around — drives Two-Hand + /// Assist's opposite side and the palette's bloom direction. + var corner: ControlCorner + var placements: [CustomControlPlacement] + + init(corner: ControlCorner, placements: [CustomControlPlacement]) { + self.corner = corner + self.placements = placements + } + + private enum CodingKeys: String, CodingKey { case corner, placements } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + corner = try container.decodeIfPresent(ControlCorner.self, forKey: .corner) ?? .bottomTrailing + placements = (try container.decodeIfPresent(LossyDecodableArray.self, + forKey: .placements))?.elements ?? [] + } + + func placement(for modifier: ControlModifier) -> CustomControlPlacement? { + placements.first { $0.kind.modifier == modifier } + } + + /// Mean normalized center of the modifier anchors (or of everything, + /// when there are none) — which side of the screen the permanent + /// cluster lives on. + var clusterCentroid: CGPoint { + let anchors = placements.filter { $0.kind.modifier != nil } + let points = (anchors.isEmpty ? placements : anchors).map { CGPoint(x: $0.x, y: $0.y) } + guard !points.isEmpty else { return corner.unitPoint } + let sum = points.reduce(CGPoint.zero) { CGPoint(x: $0.x + $1.x, y: $0.y + $1.y) } + return CGPoint(x: sum.x / CGFloat(points.count), y: sum.y / CGFloat(points.count)) + } +} + +struct CustomControlLayout: Codable, Equatable, Identifiable { + static let maximumCount = 5 + static let defaultAssistActionIDs = ["undo", "redo", "zoom-in", "zoom-out"] + + var id: String + var name: String + var landscape: CustomControlArrangement + var portrait: CustomControlArrangement + var twoHandAssist: Bool + /// Function Tray item ids shown on the opposite side while idle. + var assistActionIDs: [String] + + func arrangement(portrait isPortrait: Bool) -> CustomControlArrangement { + isPortrait ? portrait : landscape + } + + mutating func setArrangement(_ arrangement: CustomControlArrangement, portrait isPortrait: Bool) { + if isPortrait { portrait = arrangement } else { landscape = arrangement } + } + + /// The radial starter: both orientations built around `corner`, with + /// Two-Hand Assist on. + static func radialTemplate(id: String = UUID().uuidString, name: String, + corner: ControlCorner = .bottomTrailing) -> CustomControlLayout { + CustomControlLayout(id: id, name: name, + landscape: .radialTemplate(corner: corner, portrait: false), + portrait: .radialTemplate(corner: corner, portrait: true), + twoHandAssist: true, + assistActionIDs: defaultAssistActionIDs) + } +} + +extension CustomControlArrangement { + /// Reference safe-area sizes the template is authored against (an 11" + /// iPad). Positions are stored normalized, so other sizes scale. + static let referenceLandscape = CGSize(width: 1150, height: 780) + static let referencePortrait = CGSize(width: 780, height: 1150) + + /// The radial corner constellation: Settings in the corner, the four + /// modifiers on an inner quarter arc, and Escape/Tab/Keyboard/Move View + /// on an outer one. + static func radialTemplate(corner: ControlCorner, portrait: Bool) -> CustomControlArrangement { + let reference = portrait ? referencePortrait : referenceLandscape + let anchor = CGPoint(x: corner.isLeading ? 30 : reference.width - 30, + y: corner.isTop ? 30 : reference.height - 30) + // Quarter arc from "along the horizontal edge" to "along the + // vertical edge", pointing into the screen. + let horizontal: CGFloat = corner.isLeading ? 0 : .pi + let vertical: CGFloat = corner.isTop ? .pi / 2 : -.pi / 2 + func arcPoints(count: Int, radius: CGFloat) -> [CGPoint] { + (0.. .pi { delta -= 2 * .pi } + if delta < -.pi { delta += 2 * .pi } + let inset: CGFloat = 0.05 + let angle = horizontal + delta * (inset + t * (1 - 2 * inset)) + return CGPoint(x: anchor.x + radius * cos(angle), y: anchor.y + radius * sin(angle)) + } + } + func normalized(_ point: CGPoint) -> (Double, Double) { + (Double(point.x / reference.width), Double(point.y / reference.height)) + } + var placements: [CustomControlPlacement] = [] + let settings = normalized(anchor) + placements.append(CustomControlPlacement(id: "settings", kind: .tray(.settings), + x: settings.0, y: settings.1, size: 0.9)) + let modifiers: [ControlTrayItem] = [.command, .option, .control, .shift] + for (item, point) in zip(modifiers, arcPoints(count: modifiers.count, radius: 100)) { + let p = normalized(point) + placements.append(CustomControlPlacement(id: item.rawValue, kind: .tray(item), x: p.0, y: p.1)) + } + let outer: [CustomControlKind] = [.tray(.escape), .tray(.tab), .tray(.keyboard), .moveView] + for (kind, point) in zip(outer, arcPoints(count: outer.count, radius: 172)) { + let p = normalized(point) + let id: String + switch kind { + case .tray(let item): id = item.rawValue + case .function(let value): id = value + case .moveView: id = "move-view" + } + placements.append(CustomControlPlacement(id: id, kind: kind, x: p.0, y: p.1, size: 0.9)) + } + return CustomControlArrangement(corner: corner, placements: placements) + } +} + +// MARK: - Library (create / rename / delete / select) + +extension ReceiverControlPreferences { + var canCreateCustomLayout: Bool { customLayouts.count < CustomControlLayout.maximumCount } + + /// The selected Custom layout, falling back to the first one. + var activeCustomLayout: CustomControlLayout? { + customLayouts.first { $0.id == activeCustomLayoutID } ?? customLayouts.first + } + + /// Creates a radial starter layout and selects it. `nil` once + /// `CustomControlLayout.maximumCount` exist. + @discardableResult + mutating func createCustomLayout(corner: ControlCorner = .bottomTrailing, + id: String = UUID().uuidString) -> String? { + guard canCreateCustomLayout else { return nil } + let layout = CustomControlLayout.radialTemplate(id: id, name: nextCustomLayoutName(), corner: corner) + customLayouts.append(layout) + activeCustomLayoutID = layout.id + return layout.id + } + + mutating func renameCustomLayout(_ id: String, to name: String) { + let trimmed = name.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty, let index = customLayouts.firstIndex(where: { $0.id == id }) else { return } + customLayouts[index].name = String(trimmed.prefix(40)) + } + + /// Deleting the active layout selects the first remaining one; deleting + /// the last one leaves Custom mode with nothing to show, so the layout + /// mode falls back to Overlay. + mutating func deleteCustomLayout(_ id: String) { + customLayouts.removeAll { $0.id == id } + if activeCustomLayoutID == id { activeCustomLayoutID = customLayouts.first?.id } + if customLayouts.isEmpty, padControlLayout == .custom { padControlLayout = .overlay } + } + + mutating func updateCustomLayout(_ layout: CustomControlLayout) { + guard let index = customLayouts.firstIndex(where: { $0.id == layout.id }) else { return } + customLayouts[index] = layout + } + + private func nextCustomLayoutName() -> String { + let names = Set(customLayouts.map(\.name)) + var number = customLayouts.count + 1 + while names.contains(String(localized: "Custom Layout \(number)")) { number += 1 } + return String(localized: "Custom Layout \(number)") + } +} + +// MARK: - Geometry + +enum CustomLayoutGeometry { + static let margin: CGFloat = 12 + + /// The area normalized placements map into: the safe area, less a + /// small margin, so nothing lands under the Home indicator or a + /// rounded corner. + static func layoutArea(container: CGRect, safeInsets: ControlSafeInsets) -> CGRect { + let horizontalInset: CGFloat = safeInsets.leading + safeInsets.trailing + margin * 2 + let verticalInset: CGFloat = safeInsets.top + safeInsets.bottom + margin * 2 + let width: CGFloat = max(0, container.width - horizontalInset) + let height: CGFloat = max(0, container.height - verticalInset) + return CGRect(x: container.minX + safeInsets.leading + margin, + y: container.minY + safeInsets.top + margin, + width: width, height: height) + } + + /// A placement's circle, clamped so all of it stays inside `area`. + static func frame(for placement: CustomControlPlacement, in area: CGRect, baseDiameter: CGFloat) -> CGRect { + let diameter = baseDiameter * CGFloat(CustomControlPlacement.clampedSize(placement.size)) + let center = CGPoint(x: area.minX + CGFloat(CustomControlPlacement.unit(placement.x)) * area.width, + y: area.minY + CGFloat(CustomControlPlacement.unit(placement.y)) * area.height) + return PadEdgeGeometry.clamped(CGRect(x: center.x - diameter / 2, y: center.y - diameter / 2, + width: diameter, height: diameter), into: area) + } + + /// The normalized position for a view point (the editor's drag), clamped + /// to the area. + static func normalizedPoint(for point: CGPoint, in area: CGRect) -> (x: Double, y: Double) { + guard area.width > 0, area.height > 0 else { return (0.5, 0.5) } + return (CustomControlPlacement.unit(Double((point.x - area.minX) / area.width)), + CustomControlPlacement.unit(Double((point.y - area.minY) / area.height))) + } + + /// Palette keys are a touch smaller than the controls they bloom from. + static func paletteKeyDiameter(baseDiameter: CGFloat) -> CGFloat { + baseDiameter * 0.92 + } + + /// Where a modifier's palette keys go: around `anchor`, shaped and + /// spaced by `style`, blooming toward the middle of `area` unless + /// `style` fixes a direction, and clear of the other controls. The one + /// source for both the live overlay and the editor's preview. + static func paletteCenters(count: Int, anchor: CGRect, style: PalettePresentation, area: CGRect, + baseDiameter: CGFloat, spacing: CGFloat, obstacles: [CGRect]) -> [CGPoint] { + let center = CGPoint(x: anchor.midX, y: anchor.midY) + let direction = style.directionDegrees.map { CGFloat($0) * .pi / 180 } + ?? RadialPaletteLayout.direction(from: center, toward: CGPoint(x: area.midX, y: area.midY)) + let spread = CGFloat(min(max(style.spacing, PalettePresentation.spacingRange.lowerBound), + PalettePresentation.spacingRange.upperBound)) + return RadialPaletteLayout.points(count: count, center: center, shape: style.shape, + itemDiameter: paletteKeyDiameter(baseDiameter: baseDiameter), + spacing: spacing, minimumRadius: baseDiameter * 1.5 * spread, + direction: direction, bounds: area, obstacles: obstacles) + } + + /// Snaps a normalized coordinate to a 1/`divisions` grid when within + /// `tolerance` of a line — the editor's alignment assist. + static func snapped(_ value: Double, divisions: Int = 24, tolerance: Double = 0.012) -> Double { + guard divisions > 0 else { return value } + let step = 1 / Double(divisions) + let nearest = (value / step).rounded() * step + return abs(nearest - value) <= tolerance ? nearest : value + } +} + +/// Structured palette geometry: arc/fan, ring, row, or column around a +/// center, with collision avoidance against the other controls. +enum RadialPaletteLayout { + /// The widest an arc fans before starting another, larger arc. + static let maximumArcSpan: CGFloat = 150 * .pi / 180 + + /// - Parameters: + /// - minimumRadius: distance from `center` to the first ring/line. + /// - direction: bloom direction in radians (y-down, 0 = right). + /// - obstacles: frames the keys must not overlap (other controls); + /// the radius grows until they're clear, a bounded number of times. + static func points(count: Int, center: CGPoint, shape: PaletteShape, + itemDiameter: CGFloat, spacing: CGFloat, + minimumRadius: CGFloat, direction: CGFloat, + bounds: CGRect, obstacles: [CGRect] = []) -> [CGPoint] { + guard count > 0, itemDiameter > 0, direction.isFinite, minimumRadius.isFinite else { return [] } + var radius = max(minimumRadius, itemDiameter) + var result = raw(count: count, center: center, shape: shape, diameter: itemDiameter, + spacing: spacing, radius: radius, direction: direction) + for _ in 0..<10 where collides(result, diameter: itemDiameter, spacing: spacing, obstacles: obstacles) { + radius += (itemDiameter + spacing) / 2 + result = raw(count: count, center: center, shape: shape, diameter: itemDiameter, + spacing: spacing, radius: radius, direction: direction) + } + let inner = bounds.insetBy(dx: itemDiameter / 2, dy: itemDiameter / 2) + guard inner.width >= 0, inner.height >= 0 else { return result } + return result.map { CGPoint(x: min(max($0.x, inner.minX), inner.maxX), + y: min(max($0.y, inner.minY), inner.maxY)) } + } + + /// Radians from `point` toward `target` (y-down). + static func direction(from point: CGPoint, toward target: CGPoint) -> CGFloat { + let dx = target.x - point.x + let dy = target.y - point.y + guard dx != 0 || dy != 0 else { return -.pi / 2 } + return atan2(dy, dx) + } + + private static func raw(count: Int, center: CGPoint, shape: PaletteShape, diameter: CGFloat, + spacing: CGFloat, radius: CGFloat, direction: CGFloat) -> [CGPoint] { + let pitch = diameter + spacing + switch shape { + case .arc: + var points: [CGPoint] = [] + var ringRadius = radius + while points.count < count { + let step = angularStep(pitch: pitch, radius: ringRadius) + let capacity = max(1, Int(maximumArcSpan / step) + 1) + let inRing = min(capacity, count - points.count) + for index in 0.. 1 ? pitch / (2 * sin(.pi / CGFloat(count))) : radius + let ringRadius = max(radius, needed) + return (0.. CGFloat { + 2 * asin(min(1, pitch / (2 * radius))) + } + + private static func collides(_ points: [CGPoint], diameter: CGFloat, spacing: CGFloat, + obstacles: [CGRect]) -> Bool { + let reach = diameter / 2 + spacing / 2 + return points.contains { point in + obstacles.contains { $0.insetBy(dx: -reach, dy: -reach).contains(point) } + } + } +} + +// MARK: - Two-Hand Assist + +/// What the opposite side shows. A projection of the one shared +/// `ControlInteractionState` — never a chord state of its own. +enum TwoHandAssistState: Equatable { + /// Assist off, or the layout has no Two-Hand Assist. + case hidden + /// No chord in progress: the idle Function actions show. + case idle + /// A modifier is pressed, held or latched: the helper modifiers show, + /// with `active` highlighted exactly as on the primary side. + case helper(active: Set) +} + +enum TwoHandAssist { + static func state(enabled: Bool, interaction: ControlInteractionState) -> TwoHandAssistState { + guard enabled else { return .hidden } + let active = interaction.latchedModifiers.union(interaction.temporaryModifiers) + switch interaction.phase { + case .idle, .cancelled, .executing: + return active.isEmpty ? .idle : .helper(active: active) + case .pressed(let modifier): + return .helper(active: active.union([modifier])) + case .latched, .palette: + return .helper(active: active) + } + } + + /// The helper's anchor: the permanent cluster mirrored across the + /// screen. A cluster on the left/right mirrors horizontally (bottom- + /// right → bottom-left); one centered along the top or bottom mirrors + /// vertically instead. + static func helperAnchor(clusterCentroid c: CGPoint) -> CGPoint { + let horizontalBias = abs(c.x - 0.5) + let verticalBias = abs(c.y - 0.5) + return horizontalBias >= verticalBias + ? CGPoint(x: 1 - c.x, y: c.y) + : CGPoint(x: c.x, y: 1 - c.y) + } + + /// Centers for `count` helper (or idle) controls: a compact arc around + /// the mirrored anchor, fanning toward the middle of `area`. + static func helperPoints(count: Int, clusterCentroid: CGPoint, area: CGRect, + itemDiameter: CGFloat, spacing: CGFloat) -> [CGPoint] { + guard count > 0, area.width > 0, area.height > 0 else { return [] } + let anchorUnit = helperAnchor(clusterCentroid: clusterCentroid) + let inner = area.insetBy(dx: itemDiameter / 2, dy: itemDiameter / 2) + let anchor = CGPoint(x: min(max(area.minX + anchorUnit.x * area.width, inner.minX), inner.maxX), + y: min(max(area.minY + anchorUnit.y * area.height, inner.minY), inner.maxY)) + let direction = RadialPaletteLayout.direction(from: anchor, toward: CGPoint(x: area.midX, y: area.midY)) + return RadialPaletteLayout.points(count: count, center: anchor, shape: .arc, + itemDiameter: itemDiameter, spacing: spacing, + minimumRadius: itemDiameter * 1.1, direction: direction, + bounds: area) + } + + /// The modifiers the helper offers, in canonical order. + static let helperModifiers: [ControlModifier] = ControlModifier.allCases +} + +extension ControlInteractionState { + /// A tap on a Two-Hand Assist helper modifier, applied to this same + /// chord state: + /// - during a primary-side hold (pressed or palette), toggles the + /// modifier as a temporary member of the held chord, so the palette + /// updates at once and everything releases with the hold; + /// - otherwise (a latched chord, or nothing yet), it's an ordinary + /// latch toggle — exactly a tap on that modifier's own button. + mutating func toggleAssistModifier(_ modifier: ControlModifier) -> [ControlInteractionEffect] { + switch phase { + case .pressed(let held): + var effects = beginPalette(with: held) + if modifier != held { effects += addTemporaryModifier(modifier) } + return effects + case .palette: + if temporaryModifiers.contains(modifier) { + return updateTemporaryChord(temporaryModifiers.subtracting([modifier])) + } + return addTemporaryModifier(modifier) + case .idle, .latched, .executing, .cancelled: + return tap(modifier) + } + } +} diff --git a/Shared/MobileSettingsCategory.swift b/Shared/MobileSettingsCategory.swift new file mode 100644 index 00000000..1dc81fbb --- /dev/null +++ b/Shared/MobileSettingsCategory.swift @@ -0,0 +1,107 @@ +import Foundation + +/// The iPhone/iPad receiver's Settings categories. One model drives both +/// presentations: a `NavigationSplitView` sidebar in regular width and a +/// plain category list with pushed pages in compact width. +enum MobileSettingsCategory: String, CaseIterable, Identifiable, Hashable { + case general + case display + case input + case gestures + case controls + case audio + case connections + case pairedMacs + case remoteAccess + case permissions + case diagnostics + case about + case developer + + var id: String { rawValue } + + var title: String { + switch self { + case .general: return String(localized: "General") + case .display: return String(localized: "Display") + case .input: return String(localized: "Input") + case .gestures: return String(localized: "Gestures") + case .controls: return String(localized: "Controls") + case .audio: return String(localized: "Audio") + case .connections: return String(localized: "Connections") + case .pairedMacs: return String(localized: "Paired Macs") + case .remoteAccess: return String(localized: "Remote Access") + case .permissions: return String(localized: "Permissions") + case .diagnostics: return String(localized: "Diagnostics") + case .about: return String(localized: "About") + case .developer: return String(localized: "Developer") + } + } + + var systemImage: String { + switch self { + case .general: return "gearshape" + case .display: return "display" + case .input: return "hand.point.up.left" + case .gestures: return "hand.draw" + case .controls: return "command" + case .audio: return "speaker.wave.2" + case .connections: return "cable.connector" + case .pairedMacs: return "laptopcomputer" + case .remoteAccess: return "network" + case .permissions: return "lock.shield" + case .diagnostics: return "stethoscope" + case .about: return "info.circle" + case .developer: return "hammer" + } + } + + /// Sidebar sections, in order. + enum Group: CaseIterable { + case receiver + case connection + case support + } + + var group: Group { + switch self { + case .general, .display, .input, .gestures, .controls, .audio: return .receiver + case .connections, .pairedMacs, .remoteAccess, .permissions: return .connection + case .diagnostics, .about, .developer: return .support + } + } + + /// Categories offered on this build. Developer exists only in DEBUG. + static func visible(debug: Bool) -> [MobileSettingsCategory] { + allCases.filter { $0 != .developer || debug } + } + + static var visibleInThisBuild: [MobileSettingsCategory] { + #if DEBUG + visible(debug: true) + #else + visible(debug: false) + #endif + } + + /// Regular width always shows a page beside the sidebar; compact width + /// starts on the category list. + static func initialSelection(regularWidth: Bool) -> MobileSettingsCategory? { + regularWidth ? .general : nil + } +} + +/// Which iPad-only control settings a device shows. The iPhone keeps its +/// compact tray, so none of these appear there. +struct PadControlSettingsVisibility: Equatable { + let isPad: Bool + let layout: PadControlLayoutMode + + var showsLayoutPicker: Bool { isPad } + var showsControlSize: Bool { isPad } + var showsEdgePickers: Bool { isPad && layout != .custom } + var showsControlHints: Bool { isPad && layout == .strip } + var showsCustomLayouts: Bool { isPad && layout == .custom } + /// The iPhone's landscape side / Same–Opposite Side pickers. + var showsPhoneTrayPlacement: Bool { !isPad } +} diff --git a/Shared/PadControlLayout.swift b/Shared/PadControlLayout.swift new file mode 100644 index 00000000..695ee6cf --- /dev/null +++ b/Shared/PadControlLayout.swift @@ -0,0 +1,511 @@ +// iPad-only control presentation: Strip / Overlay edge geometry, control +// scale, palette placement, and the display-overlap backdrop policy. +// CoreGraphics + Foundation only, so the hostless Mac test target covers +// all of it (see MacTests/PadControlLayoutTests.swift). The iPhone keeps +// `ControlTrayGeometry` and never reads anything here. + +import CoreGraphics +import Foundation + +/// The iPad `Control Layout` setting. +enum PadControlLayoutMode: String, Codable, CaseIterable, Identifiable { + /// A solid edge rail; the Mac canvas fits beside it. + case strip + /// Controls float over a full-screen Mac canvas. + case overlay + /// The active user-built layout — see `CustomControlLayout`. + case custom + + var id: String { rawValue } + + var title: String { + switch self { + case .strip: return String(localized: "Strip", comment: "iPad control layout: an edge rail beside the Mac display.") + case .overlay: return String(localized: "Overlay", comment: "iPad control layout: controls float over the Mac display.") + case .custom: return String(localized: "Custom", comment: "iPad control layout: a user-built layout.") + } + } +} + +/// A screen edge for the iPad Main or Function controls. +enum ControlEdge: String, Codable, CaseIterable, Identifiable { + case leading + case trailing + case top + case bottom + + var id: String { rawValue } + + var title: String { + switch self { + case .leading: return String(localized: "Left") + case .trailing: return String(localized: "Right") + case .top: return String(localized: "Top") + case .bottom: return String(localized: "Bottom") + } + } + + /// Controls along a left/right edge stack vertically. + var stacksVertically: Bool { self == .leading || self == .trailing } +} + +/// Bounded iPad `Control Size`. The floor keeps a control's hit target +/// (`PadControlMetrics.baseItem` × scale, plus touch slack) around 40 pt; +/// the ceiling keeps a full Main cluster on a portrait 11" edge. +enum PadControlScale { + static let range: ClosedRange = 0.85...1.4 + static let defaultValue = 1.0 + + static func clamped(_ value: Double) -> Double { + guard value.isFinite else { return defaultValue } + return min(max(value, range.lowerBound), range.upperBound) + } +} + +/// Point sizes for one scale/hints combination. +struct PadControlMetrics: Equatable { + static let baseItem: CGFloat = 44 + static let baseGap: CGFloat = 8 + static let baseGroupGap: CGFloat = 20 + static let hintHeight: CGFloat = 12 + static let hintWidthAllowance: CGFloat = 18 + static let stripPadding: CGFloat = 10 + static let edgeMargin: CGFloat = 12 + + let scale: CGFloat + let showsHints: Bool + + init(scale: Double = PadControlScale.defaultValue, showsHints: Bool = false) { + self.scale = CGFloat(PadControlScale.clamped(scale)) + self.showsHints = showsHints + } + + var item: CGFloat { (Self.baseItem * scale).rounded() } + var gap: CGFloat { Self.baseGap * scale } + var groupGap: CGFloat { Self.baseGroupGap * scale } + + /// One control's footprint: its circle, plus a caption line below it + /// (and room for the caption's width) while hints show. + var cell: CGSize { + showsHints + ? CGSize(width: item + Self.hintWidthAllowance, height: item + Self.hintHeight + 2) + : CGSize(width: item, height: item) + } + + /// Cross-edge thickness of a cluster on `edge`. + func clusterThickness(on edge: ControlEdge) -> CGFloat { + edge.stacksVertically ? cell.width : cell.height + } + + /// Along-edge length of `count` controls. + func clusterLength(count: Int, on edge: ControlEdge) -> CGFloat { + guard count > 0 else { return 0 } + let along = edge.stacksVertically ? cell.height : cell.width + return CGFloat(count) * along + CGFloat(count - 1) * gap + } + + func clusterSize(count: Int, on edge: ControlEdge) -> CGSize { + let along = clusterLength(count: count, on: edge) + let thickness = clusterThickness(on: edge) + return edge.stacksVertically + ? CGSize(width: thickness, height: along) + : CGSize(width: along, height: thickness) + } + + /// The Strip rail's thickness before any safe-area inset on its edge. + func stripThickness(on edge: ControlEdge) -> CGFloat { + clusterThickness(on: edge) + Self.stripPadding * 2 + } +} + +struct PadStrip: Equatable { + var edge: ControlEdge + /// The solid rail, including the part under the edge's safe-area inset. + var frame: CGRect +} + +/// One Strip/Overlay layout pass. +struct PadEdgeLayout: Equatable { + /// Where the Mac display is presented: the container minus any Strip + /// rails in Strip mode, the whole container in Overlay. + var canvas: CGRect + var strips: [PadStrip] + /// `nil` when no Main controls show. + var mainFrame: CGRect? + /// One frame per Function group, in group order. + var functionFrames: [CGRect] +} + +enum PadEdgeGeometry { + /// The iPad Strip/Overlay layout. Main controls are centered along + /// their edge and grow outward from the center. Function groups split + /// in two: the first half toward the start of their edge (top, or + /// left), the rest toward the end (bottom, or right). On the Main edge + /// they continue outward from the Main cluster; on any other edge they + /// sit at that edge's two ends. + /// + /// - Parameter keyboardTop: the docked software keyboard's top edge in + /// `container` coordinates, if one is open. Bottom controls rise above + /// it and side controls stop short of it; Strip rails don't move, so + /// the canvas never jumps while typing. + static func layout(container: CGRect, + safeInsets: ControlSafeInsets, + reservesStrips: Bool, + mainEdge: ControlEdge, + functionEdge: ControlEdge, + mainCount: Int, + functionGroupCounts: [Int], + metrics: PadControlMetrics, + keyboardTop: CGFloat? = nil) -> PadEdgeLayout { + guard container.width > 0, container.height > 0 else { + return PadEdgeLayout(canvas: container, strips: [], mainFrame: nil, functionFrames: []) + } + let groups = functionGroupCounts.filter { $0 > 0 } + var usedEdges: [ControlEdge] = [] + if mainCount > 0 { usedEdges.append(mainEdge) } + if !groups.isEmpty, !usedEdges.contains(functionEdge) { usedEdges.append(functionEdge) } + + let strips = reservesStrips + ? stripFrames(container: container, safeInsets: safeInsets, edges: usedEdges, metrics: metrics) + : [] + let canvas = canvasRect(container: container, strips: strips) + + func rail(for edge: ControlEdge) -> CGRect { + var rail = railRect(edge: edge, container: container, safeInsets: safeInsets, + strip: strips.first { $0.edge == edge }, metrics: metrics) + // Keep a corner shared with another used edge for that edge. + for other in usedEdges where other != edge && other.stacksVertically != edge.stacksVertically { + let clearance = metrics.clusterThickness(on: other) + metrics.gap + switch other { + case .leading: rail = trimStart(rail, by: clearance, vertical: false) + case .trailing: rail = trimEnd(rail, by: clearance, vertical: false) + case .top: rail = trimStart(rail, by: clearance, vertical: true) + case .bottom: rail = trimEnd(rail, by: clearance, vertical: true) + } + } + return avoidingKeyboard(rail, edge: edge, keyboardTop: keyboardTop, metrics: metrics) + } + + var mainFrame: CGRect? + if mainCount > 0 { + let mainRail = rail(for: mainEdge) + let size = metrics.clusterSize(count: mainCount, on: mainEdge) + mainFrame = clamped(CGRect(x: mainRail.midX - size.width / 2, y: mainRail.midY - size.height / 2, + width: size.width, height: size.height), + into: mainRail) + } + + var functionFrames: [CGRect] = [] + if !groups.isEmpty { + let functionRail = rail(for: functionEdge) + let vertical = functionEdge.stacksVertically + let startCount = (groups.count + 1) / 2 + let startGroups = Array(groups.prefix(startCount)) + let endGroups = Array(groups.dropFirst(startCount)) + let sizes = groups.map { metrics.clusterSize(count: $0, on: functionEdge) } + func along(_ size: CGSize) -> CGFloat { vertical ? size.height : size.width } + func frame(start: CGFloat, size: CGSize) -> CGRect { + vertical + ? CGRect(x: functionRail.midX - size.width / 2, y: start, width: size.width, height: size.height) + : CGRect(x: start, y: functionRail.midY - size.height / 2, width: size.width, height: size.height) + } + var startFrames: [CGRect] = [] + var endFrames: [CGRect] = [] + let railStart = vertical ? functionRail.minY : functionRail.minX + let railEnd = vertical ? functionRail.maxY : functionRail.maxX + if let mainFrame, functionEdge == mainEdge { + var cursor = (vertical ? mainFrame.minY : mainFrame.minX) - metrics.groupGap + for index in startGroups.indices.reversed() { + cursor -= along(sizes[index]) + startFrames.insert(frame(start: cursor, size: sizes[index]), at: 0) + cursor -= metrics.groupGap + } + cursor = (vertical ? mainFrame.maxY : mainFrame.maxX) + metrics.groupGap + for offset in endGroups.indices { + let size = sizes[startCount + offset] + endFrames.append(frame(start: cursor, size: size)) + cursor += along(size) + metrics.groupGap + } + } else { + var cursor = railStart + for index in startGroups.indices { + startFrames.append(frame(start: cursor, size: sizes[index])) + cursor += along(sizes[index]) + metrics.groupGap + } + cursor = railEnd + for offset in endGroups.indices.reversed() { + let size = sizes[startCount + offset] + cursor -= along(size) + endFrames.insert(frame(start: cursor, size: size), at: 0) + cursor -= metrics.groupGap + } + } + functionFrames = (startFrames + endFrames).map { clamped($0, into: functionRail) } + } + + return PadEdgeLayout(canvas: canvas, strips: strips, mainFrame: mainFrame, functionFrames: functionFrames) + } + + /// Strip rails for `edges`. Left/right rails run the full height; top/ + /// bottom rails run between them. Each includes its edge's safe inset. + static func stripFrames(container: CGRect, safeInsets: ControlSafeInsets, + edges: [ControlEdge], metrics: PadControlMetrics) -> [PadStrip] { + func thickness(_ edge: ControlEdge) -> CGFloat { + metrics.stripThickness(on: edge) + inset(safeInsets, edge) + } + let leading = edges.contains(.leading) ? thickness(.leading) : 0 + let trailing = edges.contains(.trailing) ? thickness(.trailing) : 0 + return edges.map { edge in + let t = thickness(edge) + let frame: CGRect + switch edge { + case .leading: + frame = CGRect(x: container.minX, y: container.minY, width: t, height: container.height) + case .trailing: + frame = CGRect(x: container.maxX - t, y: container.minY, width: t, height: container.height) + case .top: + frame = CGRect(x: container.minX + leading, y: container.minY, + width: max(0, container.width - leading - trailing), height: t) + case .bottom: + frame = CGRect(x: container.minX + leading, y: container.maxY - t, + width: max(0, container.width - leading - trailing), height: t) + } + return PadStrip(edge: edge, frame: frame) + } + } + + static func canvasRect(container: CGRect, strips: [PadStrip]) -> CGRect { + var canvas = container + for strip in strips { + switch strip.edge { + case .leading: + canvas = CGRect(x: strip.frame.maxX, y: canvas.minY, + width: max(0, canvas.maxX - strip.frame.maxX), height: canvas.height) + case .trailing: + canvas.size.width = max(0, strip.frame.minX - canvas.minX) + case .top: + canvas = CGRect(x: canvas.minX, y: strip.frame.maxY, + width: canvas.width, height: max(0, canvas.maxY - strip.frame.maxY)) + case .bottom: + canvas.size.height = max(0, strip.frame.minY - canvas.minY) + } + } + return canvas + } + + /// Where a palette opens beside the Main cluster: toward the canvas, + /// centered on `anchor` along the edge, and kept inside `bounds`. + static func paletteFrame(size: CGSize, anchor: CGRect, edge: ControlEdge, + bounds: CGRect, gap: CGFloat) -> CGRect { + var frame: CGRect + switch edge { + case .leading: + frame = CGRect(x: anchor.maxX + gap, y: anchor.midY - size.height / 2, + width: size.width, height: size.height) + case .trailing: + frame = CGRect(x: anchor.minX - gap - size.width, y: anchor.midY - size.height / 2, + width: size.width, height: size.height) + case .top: + frame = CGRect(x: anchor.midX - size.width / 2, y: anchor.maxY + gap, + width: size.width, height: size.height) + case .bottom: + frame = CGRect(x: anchor.midX - size.width / 2, y: anchor.minY - gap - size.height, + width: size.width, height: size.height) + } + return clamped(frame, into: bounds) + } + + /// Rows and columns for `count` palette keys laid along `edge`, + /// wrapping into more lines once one line would leave `available`. + static func paletteGrid(count: Int, key: CGSize, gap: CGFloat, edge: ControlEdge, + available: CGSize) -> (rows: Int, columns: Int, size: CGSize) { + let n = max(count, 1) + let perLine: Int + if edge.stacksVertically { + perLine = max(1, Int((available.height + gap) / (key.height + gap))) + } else { + perLine = max(1, Int((available.width + gap) / (key.width + gap))) + } + let lines = Int(ceil(Double(n) / Double(perLine))) + let inLine = min(n, perLine) + let rows = edge.stacksVertically ? inLine : lines + let columns = edge.stacksVertically ? lines : inLine + let size = CGSize(width: CGFloat(columns) * key.width + CGFloat(columns - 1) * gap, + height: CGFloat(rows) * key.height + CGFloat(rows - 1) * gap) + return (rows, columns, size) + } + + /// Each control's cell inside a cluster frame, in order along `edge`. + static func cellFrames(in cluster: CGRect, count: Int, edge: ControlEdge, + metrics: PadControlMetrics) -> [CGRect] { + guard count > 0 else { return [] } + let cell = metrics.cell + return (0.. CGRect { + CGRect(x: cell.midX - metrics.item / 2, y: cell.minY, width: metrics.item, height: metrics.item) + } + + // MARK: - Helpers + + private static func inset(_ insets: ControlSafeInsets, _ edge: ControlEdge) -> CGFloat { + switch edge { + case .leading: return insets.leading + case .trailing: return insets.trailing + case .top: return insets.top + case .bottom: return insets.bottom + } + } + + /// The band a cluster on `edge` is centered in: inside its Strip rail + /// (clear of the safe inset), or — in Overlay — a margin inside the + /// safe area. + private static func railRect(edge: ControlEdge, container: CGRect, safeInsets: ControlSafeInsets, + strip: PadStrip?, metrics: PadControlMetrics) -> CGRect { + let safe = CGRect(x: container.minX + safeInsets.leading, + y: container.minY + safeInsets.top, + width: max(0, container.width - safeInsets.leading - safeInsets.trailing), + height: max(0, container.height - safeInsets.top - safeInsets.bottom)) + let thickness = metrics.clusterThickness(on: edge) + let margin = PadControlMetrics.edgeMargin + if let strip { + let frame = strip.frame + switch edge { + case .leading: + return CGRect(x: frame.minX + safeInsets.leading, y: safe.minY + margin, + width: frame.width - safeInsets.leading, height: max(0, safe.height - margin * 2)) + case .trailing: + return CGRect(x: frame.minX, y: safe.minY + margin, + width: frame.width - safeInsets.trailing, height: max(0, safe.height - margin * 2)) + case .top: + return CGRect(x: frame.minX + margin, y: frame.minY + safeInsets.top, + width: max(0, frame.width - margin * 2), height: frame.height - safeInsets.top) + case .bottom: + return CGRect(x: frame.minX + margin, y: frame.minY, + width: max(0, frame.width - margin * 2), height: frame.height - safeInsets.bottom) + } + } + let area = safe.insetBy(dx: margin, dy: margin) + switch edge { + case .leading: return CGRect(x: area.minX, y: area.minY, width: thickness, height: area.height) + case .trailing: return CGRect(x: area.maxX - thickness, y: area.minY, width: thickness, height: area.height) + case .top: return CGRect(x: area.minX, y: area.minY, width: area.width, height: thickness) + case .bottom: return CGRect(x: area.minX, y: area.maxY - thickness, width: area.width, height: thickness) + } + } + + private static func avoidingKeyboard(_ rail: CGRect, edge: ControlEdge, keyboardTop: CGFloat?, + metrics: PadControlMetrics) -> CGRect { + guard let keyboardTop, keyboardTop.isFinite, keyboardTop < rail.maxY else { return rail } + let limit = keyboardTop - PadControlMetrics.edgeMargin + switch edge { + case .bottom: + let height = rail.height + return CGRect(x: rail.minX, y: max(rail.minY - (rail.maxY - limit), 0), width: rail.width, height: height) + case .leading, .trailing: + return CGRect(x: rail.minX, y: rail.minY, width: rail.width, height: max(0, limit - rail.minY)) + case .top: + return rail + } + } + + private static func trimStart(_ rect: CGRect, by amount: CGFloat, vertical: Bool) -> CGRect { + vertical + ? CGRect(x: rect.minX, y: rect.minY + amount, width: rect.width, height: max(0, rect.height - amount)) + : CGRect(x: rect.minX + amount, y: rect.minY, width: max(0, rect.width - amount), height: rect.height) + } + + private static func trimEnd(_ rect: CGRect, by amount: CGFloat, vertical: Bool) -> CGRect { + vertical + ? CGRect(x: rect.minX, y: rect.minY, width: rect.width, height: max(0, rect.height - amount)) + : CGRect(x: rect.minX, y: rect.minY, width: max(0, rect.width - amount), height: rect.height) + } + + /// Shifts `frame` inside `bounds` (centering it on any axis where it is + /// larger), never resizing it. + static func clamped(_ frame: CGRect, into bounds: CGRect) -> CGRect { + func axis(_ origin: CGFloat, _ length: CGFloat, _ lower: CGFloat, _ upper: CGFloat) -> CGFloat { + if length >= upper - lower { return lower + (upper - lower - length) / 2 } + return min(max(origin, lower), upper - length) + } + return CGRect(x: axis(frame.minX, frame.width, bounds.minX, bounds.maxX), + y: axis(frame.minY, frame.height, bounds.minY, bounds.maxY), + width: frame.width, height: frame.height) + } +} + +// MARK: - Display-aware control backdrop + +/// Where the Mac is actually drawn, in the controls' coordinate space: the +/// receiver surface's live `RemoteViewportTransform` (the same one input +/// mapping uses), the surface's origin in that space, and its size (the +/// video view clips to its bounds). +struct DisplayFootprint: Equatable { + var transform: RemoteViewportTransform + var surfaceOrigin: CGPoint + var surfaceSize: CGSize + + static let none = DisplayFootprint(transform: .invalid, surfaceOrigin: .zero, surfaceSize: .zero) + + func contains(_ point: CGPoint) -> Bool { + let local = CGPoint(x: point.x - surfaceOrigin.x, y: point.y - surfaceOrigin.y) + guard local.x >= 0, local.y >= 0, local.x <= surfaceSize.width, local.y <= surfaceSize.height else { + return false + } + return transform.containsViewPoint(local) + } +} + +/// Whether (and how strongly) a floating control gets its small material +/// halo: only where it actually covers rendered Mac content — never over +/// letterboxing, empty canvas, or a Strip rail. +enum ControlBackdropPolicy { + /// Below this much coverage the halo is skipped entirely. + static let minimumCoverage: CGFloat = 0.05 + /// Coverage at which the halo reaches full strength. + static let fullCoverage: CGFloat = 0.5 + + /// Fraction of `rect` over the rendered display, sampled on a grid so + /// rotation and partial overlap need no polygon clipping. + static func coverage(of rect: CGRect, footprint: DisplayFootprint, samples: Int = 5) -> CGFloat { + guard rect.width > 0, rect.height > 0, samples > 0, footprint.transform.isValid else { return 0 } + var inside = 0 + for row in 0.. Double { + guard coverage >= minimumCoverage else { return 0 } + let t = (coverage - minimumCoverage) / (fullCoverage - minimumCoverage) + return Double(min(max(t, 0), 1)) + } + + static func haloStrength(for rect: CGRect, footprint: DisplayFootprint) -> Double { + haloStrength(coverage: coverage(of: rect, footprint: footprint)) + } +} + +/// Auto-hide never applies to the iPad Strip: its rail is reserved space, +/// and fading the controls would leave an empty black band. +enum ControlAutoHidePolicy { + static func applies(isPad: Bool, layout: PadControlLayoutMode) -> Bool { + !(isPad && layout == .strip) + } +} diff --git a/Shared/ReceiverGesture.swift b/Shared/ReceiverGesture.swift index 7bb2831f..16672145 100644 --- a/Shared/ReceiverGesture.swift +++ b/Shared/ReceiverGesture.swift @@ -69,15 +69,25 @@ enum ReceiverMultiFingerRecognizer: CaseIterable { /// while it runs, and comes back when it stops. Ordinary one-finger input is /// not gated here. Held as a value so a recognizer force-cancelled by an /// `isEnabled` false/true toggle is restored to THIS state, never to a -/// blind `true` that would undo the VoiceOver gate. +/// blind `true` that would undo the VoiceOver gate. Move View (local +/// viewport navigation) also parks them, so no Mac gesture fires while the +/// user is only moving the view. struct ReceiverMultiFingerGestureGate: Equatable { private(set) var voiceOverRunning: Bool + private(set) var viewportNavigationActive = false init(voiceOverRunning: Bool) { self.voiceOverRunning = voiceOverRunning } - var recognizersEnabled: Bool { !voiceOverRunning } + var recognizersEnabled: Bool { !voiceOverRunning && !viewportNavigationActive } + + /// Returns whether availability changed. + mutating func update(viewportNavigationActive: Bool) -> Bool { + guard viewportNavigationActive != self.viewportNavigationActive else { return false } + self.viewportNavigationActive = viewportNavigationActive + return true + } func isEnabled(_ recognizer: ReceiverMultiFingerRecognizer) -> Bool { recognizersEnabled @@ -93,10 +103,23 @@ struct ReceiverMultiFingerGestureGate: Equatable { /// Screen-edge system gestures are deferred (the first swipe goes to the /// Mac, a second still reaches iPadOS) only while the remote surface is on -/// screen AND input may reach the Mac. +/// screen, input may reach the Mac, Prefer MeowDisplay Gestures is on, and +/// VoiceOver is off — VoiceOver always owns its own gestures. enum ReceiverScreenEdgePolicy { - static func defersScreenEdges(surfaceShown: Bool, inputAllowed: Bool) -> Bool { - surfaceShown && inputAllowed + static func defersScreenEdges(surfaceShown: Bool, inputAllowed: Bool, + preferMeowDisplayGestures: Bool = true, + voiceOverRunning: Bool = false) -> Bool { + surfaceShown && inputAllowed && preferMeowDisplayGestures && !voiceOverRunning + } +} + +/// Prefer MeowDisplay Gestures' effect on UIKit's three-finger editing +/// interactions (undo/redo/copy/paste and their HUD) over the receiver +/// surface. Public API only: this never claims iPadOS's 4/5-finger +/// multitasking gestures, and VoiceOver's gestures are untouched by it. +enum ReceiverEditingInteractionPolicy { + static func suppressesEditingInteractions(preferMeowDisplayGestures: Bool) -> Bool { + preferMeowDisplayGestures } } diff --git a/Shared/RemoteViewport.swift b/Shared/RemoteViewport.swift index a4d4c225..b5fc62d3 100644 --- a/Shared/RemoteViewport.swift +++ b/Shared/RemoteViewport.swift @@ -449,47 +449,54 @@ struct ManualViewportState: Equatable { var isIdentity: Bool { self == .identity } - /// Clamps `scale` to `[minScale, maxScale]` and `pan{X,Y}` so that - /// `base`, scaled by the result, can never reveal blank space beyond - /// `base`'s own footprint — i.e. the scaled rect always fully contains - /// `base` (PRODUCT RULE: "cannot drag the remote display completely - /// away and reveal arbitrary blank space"). At `minScale`, pan is - /// forced to zero so returning to 1x always lands at exactly the - /// normal, unpanned presentation. The single source of truth for this - /// math — both `applyManualZoom` and the live pinch/pan policy - /// (`pinching(from:...)`) funnel through this rather than duplicating - /// clamp arithmetic, which also makes it the one place that re-derives - /// valid pan bounds whenever `base` changes (rotation, keyboard - /// open/close) — see the type's use from `VideoView.layoutSubviews`. + /// Clamps `scale` to `[minScale, maxScale]` and `pan{X,Y}` to + /// `panLimits(against:)`: translation is independent of zoom (100% can + /// pan too), but any edge of the remote display may travel at most to + /// the viewport's center, never past it — so the Mac can be brought + /// within reach from any direction yet never flung off screen. The + /// viewport's center is `base`'s center: the normal presentation is + /// centered in the surface, and the keyboard-open one in the area above + /// the keyboard. The single source of truth for this math — both + /// `applyManualZoom` and the live pinch/pan policy (`pinching(from:...)`) + /// funnel through this rather than duplicating clamp arithmetic, which + /// also makes it the one place that re-derives valid pan bounds whenever + /// `base` changes (rotation, keyboard open/close, a Strip rail) — see + /// the type's use from `VideoView.layoutSubviews`. func clamped(against base: CGRect) -> ManualViewportState { var copy = self copy.scale = scale.isFinite ? min(max(scale, Self.minScale), Self.maxScale) : Self.minScale copy.rotationRadians = rotationRadians.isFinite ? Self.normalizedAngle(rotationRadians) : 0 - guard base.width > 0, base.height > 0, - copy.scale > Self.minScale || abs(copy.rotationRadians) > 0.0001 else { + guard let limits = copy.panLimits(against: base) else { copy.panX = 0 copy.panY = 0 copy.rotationRadians = 0 return copy } - let width = base.width * copy.scale - let height = base.height * copy.scale - guard width.isFinite, height.isFinite, width > 0, height > 0 else { - copy.panX = 0 - copy.panY = 0 - return copy - } - let c = abs(cos(copy.rotationRadians)) - let s = abs(sin(copy.rotationRadians)) - let extentWidth = width * c + height * s - let extentHeight = width * s + height * c - let maxPanX = max(0, (extentWidth - base.width) / 2) - let maxPanY = max(0, (extentHeight - base.height) / 2) - copy.panX = panX.isFinite ? min(max(panX, -maxPanX), maxPanX) : 0 - copy.panY = panY.isFinite ? min(max(panY, -maxPanY), maxPanY) : 0 + copy.panX = panX.isFinite ? min(max(panX, -limits.x), limits.x) : 0 + copy.panY = panY.isFinite ? min(max(panY, -limits.y), limits.y) : 0 + // Floating-point dust from a pinch that ended back at rest must not + // leave a technically-non-identity state behind. + if abs(copy.panX) < 0.01 { copy.panX = 0 } + if abs(copy.panY) < 0.01 { copy.panY = 0 } return copy } + /// The largest |panX|/|panY| for this scale and rotation: half the + /// displayed content's axis-aligned extent, which is exactly how far + /// its center can move before one of its (rotated) edges would cross + /// the viewport's center. `nil` for unusable geometry. + func panLimits(against base: CGRect) -> (x: CGFloat, y: CGFloat)? { + guard base.width > 0, base.height > 0, base.width.isFinite, base.height.isFinite else { return nil } + let scale = self.scale.isFinite ? min(max(self.scale, Self.minScale), Self.maxScale) : Self.minScale + let rotation = rotationRadians.isFinite ? rotationRadians : 0 + let width = base.width * scale + let height = base.height * scale + guard width.isFinite, height.isFinite, width > 0, height > 0 else { return nil } + let c = abs(cos(rotation)) + let s = abs(sin(rotation)) + return ((width * c + height * s) / 2, (width * s + height * c) / 2) + } + /// Pure pinch/pan policy for a live two-finger gesture: given the /// manual state at gesture-start and the gesture's initial/current /// two-finger midpoint and distance ratio, returns the manual state @@ -681,10 +688,11 @@ extension RemoteViewportCalculator { /// `base.displayedRect`; `viewPoint`/`remotePoint` need no changes to /// stay each other's exact inverse under it. /// - /// At `state.scale <= 1` (identity), returns `base` completely + /// At identity (1x, no pan, no rotation) returns `base` completely /// unchanged — not merely numerically equal — so resetting manual zoom /// always lands at exactly the normal/keyboard-adjusted presentation, - /// with no accumulated floating-point drift. + /// with no accumulated floating-point drift. A 1x state that is only + /// panned is a plain translation of `base`. static func applyManualZoom(to base: RemoteViewportTransform, state: ManualViewportState) -> RemoteViewportTransform { guard base.isValid else { return base } @@ -701,3 +709,69 @@ extension RemoteViewportCalculator { rotationRadians: clamped.rotationRadians) } } + +/// Move View: while active, the receiver surface navigates the local +/// viewport instead of controlling the Mac. One finger pans; two fingers +/// pan plus pinch-zoom (when `allowsZoom`) and rotate (when +/// `allowsRotation`). Every change in the number of fingers re-baselines +/// from the current state, so lifting or adding a finger never jumps. +/// Reuses `ManualViewportState.pinching` — the same anchored math and clamp +/// as an ordinary viewport pinch — rather than a second viewport model. +struct ViewportNavigationSession { + private var start = ManualViewportState.identity + private var base = CGRect.zero + private var initialCentroid = CGPoint.zero + private var initialDistance: CGFloat = 0 + private var initialAngle: CGFloat = 0 + private(set) var touchCount = 0 + + /// Feeds the current finger positions (surface coordinates). Returns + /// the viewport state to show; `current` unchanged when there is + /// nothing to do (no fingers, a re-baseline, or unusable geometry). + mutating func update(points: [CGPoint], current: ManualViewportState, base: CGRect, + allowsZoom: Bool, allowsRotation: Bool) -> ManualViewportState { + let fingers = Array(points.prefix(2)) + guard !fingers.isEmpty, base.width > 0, base.height > 0 else { + touchCount = 0 + return current + } + let centroid = Self.centroid(fingers) + if fingers.count != touchCount || base != self.base { + touchCount = fingers.count + start = current + self.base = base + initialCentroid = centroid + initialDistance = fingers.count == 2 ? Self.distance(fingers) : 0 + initialAngle = fingers.count == 2 ? Self.angle(fingers) : 0 + return current + } + var scaleRatio: CGFloat = 1 + var rotationDelta: CGFloat = 0 + if fingers.count == 2 { + if allowsZoom, initialDistance > 0 { scaleRatio = Self.distance(fingers) / initialDistance } + if allowsRotation { + rotationDelta = ManualViewportState.normalizedAngle(Self.angle(fingers) - initialAngle) + } + } + return ManualViewportState.pinching(from: start, initialBase: base, + initialMidpoint: initialCentroid, currentMidpoint: centroid, + scaleRatio: scaleRatio, rotationDelta: rotationDelta) + } + + mutating func end() { + touchCount = 0 + } + + private static func centroid(_ points: [CGPoint]) -> CGPoint { + let sum = points.reduce(CGPoint.zero) { CGPoint(x: $0.x + $1.x, y: $0.y + $1.y) } + return CGPoint(x: sum.x / CGFloat(points.count), y: sum.y / CGFloat(points.count)) + } + + private static func distance(_ points: [CGPoint]) -> CGFloat { + hypot(points[1].x - points[0].x, points[1].y - points[0].y) + } + + private static func angle(_ points: [CGPoint]) -> CGFloat { + atan2(points[1].y - points[0].y, points[1].x - points[0].x) + } +} diff --git a/iOS/CustomLayoutEditor.swift b/iOS/CustomLayoutEditor.swift new file mode 100644 index 00000000..fa559a04 --- /dev/null +++ b/iOS/CustomLayoutEditor.swift @@ -0,0 +1,483 @@ +import SwiftUI +import UIKit + +// MARK: - Custom layout list (Settings → Controls, iPad only) + +/// The user's Custom layouts, plus `Create Custom Layout` until +/// `CustomControlLayout.maximumCount` exist. Nothing is pre-created. +struct CustomLayoutListSection: View { + @ObservedObject var store: ReceiverControlStore + + var body: some View { + Section { + if store.preferences.customLayouts.count > 1 { + Picker("Active Layout", selection: Binding( + get: { store.preferences.activeCustomLayout?.id ?? "" }, + set: { id in store.update { $0.activeCustomLayoutID = id } })) { + ForEach(store.preferences.customLayouts) { Text($0.name).tag($0.id) } + } + } + ForEach(store.preferences.customLayouts) { layout in + NavigationLink { + CustomLayoutEditor(store: store, layoutID: layout.id) + } label: { + HStack { + Text(layout.name) + Spacer() + if layout.id == store.preferences.activeCustomLayout?.id { + Text("Active").font(.footnote).foregroundStyle(.secondary) + } + } + } + } + .onDelete { offsets in + let ids = offsets.map { store.preferences.customLayouts[$0].id } + store.update { preferences in ids.forEach { preferences.deleteCustomLayout($0) } } + } + if store.preferences.canCreateCustomLayout { + Button { + store.update { $0.createCustomLayout() } + } label: { + Label("Create Custom Layout", systemImage: "plus") + } + } + } header: { + Text("Custom Layouts") + } footer: { + Text("Up to \(CustomControlLayout.maximumCount) layouts, each with its own landscape and portrait arrangement. New layouts start from the radial corner template.") + } + } +} + +// MARK: - Editor + +/// A controller-style editor: an iPad-surface preview where controls are +/// dragged, selected, resized and removed, per orientation. Only placement +/// and presentation are edited here — what a control does still comes from +/// the Control Profile and Function Tray. +struct CustomLayoutEditor: View { + @ObservedObject var store: ReceiverControlStore + let layoutID: String + + @State private var editingPortrait: Bool + @State private var selectedID: String? + @State private var dragPositions: [String: CGPoint] = [:] + @State private var previewPalette = false + @State private var confirmingTemplate: ControlCorner? + @State private var name = "" + + init(store: ReceiverControlStore, layoutID: String) { + self.store = store + self.layoutID = layoutID + let bounds = Self.screenBounds + _editingPortrait = State(initialValue: bounds.height > bounds.width) + } + + private var layout: CustomControlLayout? { + store.preferences.customLayouts.first { $0.id == layoutID } + } + + private var arrangement: CustomControlArrangement? { + layout?.arrangement(portrait: editingPortrait) + } + + private var selected: CustomControlPlacement? { + arrangement?.placements.first { $0.id == selectedID } + } + + var body: some View { + Group { + if let layout { + ScrollView { + VStack(alignment: .leading, spacing: 20) { + Picker("Orientation", selection: $editingPortrait) { + Text("Landscape").tag(false) + Text("Portrait").tag(true) + } + .pickerStyle(.segmented) + preview + if let selected { + inspector(for: selected) + } else { + Text("Drag a control to move it. Tap one to resize it, change its palette, or remove it.") + .font(.footnote) + .foregroundStyle(.secondary) + } + layoutOptions(layout) + } + .padding() + .frame(maxWidth: 820) + .frame(maxWidth: .infinity) + } + .background(Color(.systemGroupedBackground)) + .navigationTitle(layout.name) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .primaryAction) { addMenu } + } + .onAppear { name = layout.name } + .onChange(of: editingPortrait) { _ in + selectedID = nil + previewPalette = false + } + .confirmationDialog("Replace this orientation with the radial template?", + isPresented: Binding(get: { confirmingTemplate != nil }, + set: { if !$0 { confirmingTemplate = nil } }), + titleVisibility: .visible) { + Button("Use Template", role: .destructive) { + if let corner = confirmingTemplate { + mutate { $0 = .radialTemplate(corner: corner, portrait: editingPortrait) } + } + confirmingTemplate = nil + selectedID = nil + } + } message: { + Text("Only the \(editingPortrait ? String(localized: "portrait") : String(localized: "landscape")) arrangement changes.") + } + } else { + Text("This layout no longer exists.").foregroundStyle(.secondary) + } + } + } + + // MARK: Preview + + /// The device's full-screen size in the chosen orientation. + private static var screenBounds: CGRect { + let scene = UIApplication.shared.connectedScenes.compactMap { $0 as? UIWindowScene }.first + return scene?.screen.bounds ?? CGRect(x: 0, y: 0, width: 1194, height: 834) + } + + private var deviceSize: CGSize { + let bounds = Self.screenBounds + let long = max(bounds.width, bounds.height) + let short = min(bounds.width, bounds.height) + return editingPortrait ? CGSize(width: short, height: long) : CGSize(width: long, height: short) + } + + /// The live window's insets, mapped onto the orientation being edited + /// (iPad: a thin top inset, and the Home indicator at the bottom). + private var deviceSafeInsets: ControlSafeInsets { + let window = UIApplication.shared.connectedScenes.compactMap { $0 as? UIWindowScene } + .flatMap(\.windows).first(where: \.isKeyWindow) + let insets = window?.safeAreaInsets ?? UIEdgeInsets(top: 24, left: 0, bottom: 20, right: 0) + return ControlSafeInsets(top: insets.top, leading: 0, bottom: max(insets.bottom, 20), trailing: 0) + } + + private var preview: some View { + let device = deviceSize + let metrics = PadControlMetrics(scale: store.preferences.padControlScale) + let container = CGRect(origin: .zero, size: device) + let area = CustomLayoutGeometry.layoutArea(container: container, safeInsets: deviceSafeInsets) + return GeometryReader { proxy in + let scale = min(proxy.size.width / device.width, proxy.size.height / device.height) + let frames = placementFrames(area: area, base: metrics.item) + ZStack(alignment: .topLeading) { + // The surface: a stand-in Mac desktop, the safe layout area, + // and alignment guides. + RoundedRectangle(cornerRadius: 18 / scale, style: .continuous) + .fill(LinearGradient(colors: [Color(red: 0.16, green: 0.2, blue: 0.32), + Color(red: 0.32, green: 0.22, blue: 0.36)], + startPoint: .topLeading, endPoint: .bottomTrailing)) + .frame(width: device.width, height: device.height) + alignmentGuides(area: area) + ForEach(arrangement?.placements ?? []) { placement in + if let frame = frames[placement.id] { + editorChip(placement, frame: frame, area: area) + } + } + if previewPalette, let selected, let modifier = selected.kind.modifier, + let anchor = frames[selected.id] { + palettePreview(modifier: modifier, anchor: anchor, style: selected.palette ?? PalettePresentation(), + area: area, base: metrics.item, + obstacles: frames.filter { $0.key != selected.id }.map(\.value)) + } + } + .frame(width: device.width, height: device.height, alignment: .topLeading) + .scaleEffect(scale, anchor: .topLeading) + .frame(width: device.width * scale, height: device.height * scale, alignment: .topLeading) + .clipShape(RoundedRectangle(cornerRadius: 18, style: .continuous)) + .overlay(RoundedRectangle(cornerRadius: 18, style: .continuous) + .strokeBorder(Color.primary.opacity(0.25), lineWidth: 1)) + .contentShape(Rectangle()) + .onTapGesture { selectedID = nil } + .frame(maxWidth: .infinity) + } + .aspectRatio(device.width / device.height, contentMode: .fit) + .accessibilityElement(children: .contain) + .accessibilityLabel(editingPortrait ? "Portrait layout preview" : "Landscape layout preview") + } + + private func placementFrames(area: CGRect, base: CGFloat) -> [String: CGRect] { + var result: [String: CGRect] = [:] + for placement in arrangement?.placements ?? [] { + var frame = CustomLayoutGeometry.frame(for: placement, in: area, baseDiameter: base) + if let dragged = dragPositions[placement.id] { + frame = PadEdgeGeometry.clamped(CGRect(x: dragged.x - frame.width / 2, y: dragged.y - frame.height / 2, + width: frame.width, height: frame.height), into: area) + } + result[placement.id] = frame + } + return result + } + + private func alignmentGuides(area: CGRect) -> some View { + ZStack(alignment: .topLeading) { + // The layout area: controls can't leave it, so nothing ends up + // under the Home indicator or the screen's rounded corners. + Rectangle() + .strokeBorder(style: StrokeStyle(lineWidth: 1.5, dash: [8, 6])) + .foregroundStyle(.white.opacity(0.35)) + .frame(width: area.width, height: area.height) + .position(x: area.midX, y: area.midY) + Path { path in + path.move(to: CGPoint(x: area.midX, y: area.minY)) + path.addLine(to: CGPoint(x: area.midX, y: area.maxY)) + path.move(to: CGPoint(x: area.minX, y: area.midY)) + path.addLine(to: CGPoint(x: area.maxX, y: area.midY)) + } + .stroke(.white.opacity(0.15), lineWidth: 1) + } + .allowsHitTesting(false) + } + + @ViewBuilder + private func editorChip(_ placement: CustomControlPlacement, frame: CGRect, area: CGRect) -> some View { + let isSelected = placement.id == selectedID + ZStack { + Circle() + .fill(Color.black.opacity(0.5)) + .overlay(Circle().strokeBorder(isSelected ? Color.accentColor : .white.opacity(0.35), + lineWidth: isSelected ? 3 : 1)) + controlGlyph(placement.kind, diameter: frame.width) + .foregroundStyle(.white) + } + .frame(width: frame.width, height: frame.height) + .position(x: frame.midX, y: frame.midY) + .gesture(DragGesture(minimumDistance: 2) + .onChanged { value in + selectedID = placement.id + dragPositions[placement.id] = value.location + } + .onEnded { value in + dragPositions[placement.id] = nil + let point = CustomLayoutGeometry.normalizedPoint(for: value.location, in: area) + updatePlacement(placement.id) { + $0.x = CustomLayoutGeometry.snapped(point.x) + $0.y = CustomLayoutGeometry.snapped(point.y) + } + }) + .onTapGesture { selectedID = isSelected ? nil : placement.id } + .accessibilityLabel(title(for: placement.kind)) + .accessibilityAddTraits(isSelected ? [.isSelected, .isButton] : .isButton) + } + + private func palettePreview(modifier: ControlModifier, anchor: CGRect, style: PalettePresentation, + area: CGRect, base: CGFloat, obstacles: [CGRect]) -> some View { + let actions = store.activeProfile.actions(for: ModifierChord([modifier])) + let diameter = CustomLayoutGeometry.paletteKeyDiameter(baseDiameter: base) + let centers = CustomLayoutGeometry.paletteCenters(count: actions.count, anchor: anchor, style: style, + area: area, baseDiameter: base, spacing: 8, + obstacles: obstacles) + return ForEach(Array(zip(actions, centers)), id: \.0.id) { action, center in + Text(action.displayKey) + .font(.system(size: 15, weight: .semibold)) + .foregroundStyle(.white) + .frame(width: diameter, height: diameter) + .background(Circle().fill(Color.accentColor.opacity(0.55))) + .position(center) + .allowsHitTesting(false) + } + } + + // MARK: Inspector + + @ViewBuilder + private func inspector(for placement: CustomControlPlacement) -> some View { + VStack(alignment: .leading, spacing: 14) { + HStack { + Label { + Text(title(for: placement.kind)).font(.headline) + } icon: { + controlGlyph(placement.kind, diameter: 28) + } + Spacer() + Button(role: .destructive) { + mutate { $0.placements.removeAll { $0.id == placement.id } } + selectedID = nil + } label: { + Label("Remove", systemImage: "trash") + } + .disabled(placement.kind == .tray(.settings)) + } + VStack(alignment: .leading, spacing: 4) { + Text("Size") + Slider(value: Binding(get: { placement.size }, + set: { value in updatePlacement(placement.id) { $0.size = value } }), + in: CustomControlPlacement.sizeRange, step: 0.05) + } + if placement.kind.modifier != nil { + let style = placement.palette ?? PalettePresentation() + Picker("Palette Shape", selection: Binding( + get: { style.shape }, + set: { shape in updatePlacement(placement.id) { $0.palette = paletteStyle($0, shape: shape) } })) { + ForEach(PaletteShape.allCases) { Text($0.title).tag($0) } + } + .pickerStyle(.segmented) + VStack(alignment: .leading, spacing: 4) { + Text("Palette Distance") + Slider(value: Binding(get: { style.spacing }, + set: { value in updatePlacement(placement.id) { $0.palette = paletteStyle($0, spacing: value) } }), + in: PalettePresentation.spacingRange, step: 0.1) + } + Toggle("Bloom Toward Screen Center", isOn: Binding( + get: { style.directionDegrees == nil }, + set: { automatic in + updatePlacement(placement.id) { + $0.palette = paletteStyle($0, direction: automatic ? .some(nil) : .some(-90)) + } + })) + if let degrees = style.directionDegrees { + VStack(alignment: .leading, spacing: 4) { + Text("Direction") + Slider(value: Binding(get: { degrees }, + set: { value in updatePlacement(placement.id) { $0.palette = paletteStyle($0, direction: .some(value)) } }), + in: -180...180, step: 15) + } + } + Toggle("Preview Palette", isOn: $previewPalette) + } + } + .padding() + .background(Color(.secondarySystemGroupedBackground), in: RoundedRectangle(cornerRadius: 12)) + } + + private func paletteStyle(_ placement: CustomControlPlacement, shape: PaletteShape? = nil, + spacing: Double? = nil, direction: Double?? = nil) -> PalettePresentation { + var style = placement.palette ?? PalettePresentation() + if let shape { style.shape = shape } + if let spacing { style.spacing = spacing } + if let direction { style.directionDegrees = direction } + return style + } + + @ViewBuilder + private func layoutOptions(_ layout: CustomControlLayout) -> some View { + VStack(alignment: .leading, spacing: 14) { + TextField("Layout Name", text: $name) + .textFieldStyle(.roundedBorder) + .onSubmit { store.update { $0.renameCustomLayout(layoutID, to: name) } } + Toggle(isOn: Binding(get: { layout.twoHandAssist }, + set: { value in mutateLayout { $0.twoHandAssist = value } })) { + VStack(alignment: .leading, spacing: 2) { + Text("Two-Hand Assist") + Text("While a modifier is held or latched, modifier keys appear on the opposite side, so your other hand can add to the same chord. Otherwise that side offers Undo, Redo and Zoom.") + .font(.footnote) + .foregroundStyle(.secondary) + } + } + Menu { + ForEach(ControlCorner.allCases) { corner in + Button(corner.title) { confirmingTemplate = corner } + } + } label: { + Label("Reset to Radial Template…", systemImage: "circle.grid.cross") + } + } + .padding() + .background(Color(.secondarySystemGroupedBackground), in: RoundedRectangle(cornerRadius: 12)) + .onDisappear { store.update { $0.renameCustomLayout(layoutID, to: name) } } + } + + // MARK: Adding controls + + private var addMenu: some View { + Menu { + let placed = Set(arrangement?.placements.map(\.kind) ?? []) + Section("Controls") { + ForEach(ControlTrayItem.allCases) { item in + Button(item.title) { add(.tray(item)) } + .disabled(placed.contains(.tray(item))) + } + Button("Move View") { add(.moveView) } + .disabled(placed.contains(.moveView)) + } + Section("Function Tray") { + ForEach(store.activeFunctionTrayProfile.items.map(\.item)) { item in + Button(item.title) { add(.function(item.id)) } + .disabled(placed.contains(.function(item.id))) + } + } + } label: { + Label("Add Control", systemImage: "plus") + } + } + + private func add(_ kind: CustomControlKind) { + let placement = CustomControlPlacement(kind: kind, x: 0.5, y: 0.5) + mutate { $0.placements.append(placement) } + selectedID = placement.id + } + + // MARK: Helpers + + @ViewBuilder + private func controlGlyph(_ kind: CustomControlKind, diameter: CGFloat) -> some View { + switch kind { + case .tray(let item): + if let modifier = item.modifier { + Text(modifier.symbol).font(.system(size: diameter * 0.46, weight: .semibold)) + } else if item == .escape || item == .tab { + Text(item.displayLabel).font(.system(size: diameter * 0.3, weight: .semibold)) + } else { + Image(systemName: item.displayLabel).font(.system(size: diameter * 0.4, weight: .semibold)) + } + case .moveView: + Image(systemName: "arrow.up.and.down.and.arrow.left.and.right") + .font(.system(size: diameter * 0.38, weight: .semibold)) + case .function(let id): + let item = store.activeFunctionTrayProfile.items.first { $0.id == id }?.item + if let symbol = item?.systemImage { + Image(systemName: symbol).font(.system(size: diameter * 0.4, weight: .semibold)) + } else { + Text(item?.displayKey ?? "?").font(.system(size: diameter * 0.34, weight: .semibold)) + } + } + } + + private func title(for kind: CustomControlKind) -> String { + switch kind { + case .tray(let item): return item.title + case .moveView: return String(localized: "Move View") + case .function(let id): + return store.activeFunctionTrayProfile.items.first { $0.id == id }?.item.title ?? id + } + } + + private func mutateLayout(_ change: (inout CustomControlLayout) -> Void) { + guard var layout else { return } + change(&layout) + store.update { $0.updateCustomLayout(layout) } + } + + private func mutate(_ change: (inout CustomControlArrangement) -> Void) { + let portrait = editingPortrait + mutateLayout { layout in + var arrangement = layout.arrangement(portrait: portrait) + change(&arrangement) + layout.setArrangement(arrangement, portrait: portrait) + } + } + + private func updatePlacement(_ id: String, _ change: (inout CustomControlPlacement) -> Void) { + mutate { arrangement in + guard let index = arrangement.placements.firstIndex(where: { $0.id == id }) else { return } + change(&arrangement.placements[index]) + let placement = arrangement.placements[index] + // Re-normalize through the initializer's clamps. + arrangement.placements[index] = CustomControlPlacement(id: placement.id, kind: placement.kind, + x: placement.x, y: placement.y, + size: placement.size, palette: placement.palette) + } + } +} diff --git a/iOS/OpenSidecarPhoneApp.swift b/iOS/OpenSidecarPhoneApp.swift index 0b42dfdf..ceb2f4ae 100644 --- a/iOS/OpenSidecarPhoneApp.swift +++ b/iOS/OpenSidecarPhoneApp.swift @@ -53,7 +53,14 @@ struct ReceiverScreen: View { // single-sided physical notch (see that type's doc). @State private var physicalNotchSide: LandscapeTraySide? @State private var occupiedControlFrames: [CGRect] = [] + /// iPad: where the Mac canvas sits (a Strip rail reserves an edge). + /// `nil` means the whole screen. + @State private var padCanvas: CGRect? + /// Deliberately `@State`, not observed: only the control backdrop layer + /// observes the per-frame display footprint. + @State private var footprintStore = DisplayFootprintStore() @Environment(\.scenePhase) private var scenePhase + @Environment(\.accessibilityVoiceOverEnabled) private var voiceOverEnabled @AppStorage("showAnalytics") private var showAnalytics = false @AppStorage("metalRenderer") private var metalRenderer = false // First-run onboarding (issue #49): explain the Mac app is required. @@ -94,8 +101,19 @@ struct ReceiverScreen: View { /// Screen edges whose system gestures wait for a second swipe — see /// `ReceiverScreenEdgePolicy`. private var deferredSystemGestureEdges: Edge.Set { - ReceiverScreenEdgePolicy.defersScreenEdges(surfaceShown: showsReceiverSurface, - inputAllowed: inputReachesMac) ? .all : [] + ReceiverScreenEdgePolicy.defersScreenEdges( + surfaceShown: showsReceiverSurface, + inputAllowed: inputReachesMac, + preferMeowDisplayGestures: controlStore.preferences.preferMeowDisplayGestures, + voiceOverRunning: voiceOverEnabled) ? .all : [] + } + + /// The Mac canvas in screen coordinates: the iPad Strip's remaining + /// area, or the whole screen. + private func canvas(in size: CGSize) -> CGRect { + let full = CGRect(origin: .zero, size: size) + guard controlStore.isPad, let padCanvas, padCanvas.width > 0, padCanvas.height > 0 else { return full } + return padCanvas.intersection(full) } /// Everything outside AVKit that decides whether Picture in Picture may @@ -119,6 +137,11 @@ struct ReceiverScreen: View { isStreaming && model.receiver.session.allowsLiveInput && model.receiver.macSupportsKeyboardWire } + private var settingsView: some View { + SettingsView(receiver: model.receiver, controlStore: controlStore, + pictureInPicture: model.pictureInPicture, haptics: haptics) + } + // Below the force floor → present the blocking gate (issue #135). The // fullScreenCover binding's setter is a no-op so the user can't dismiss it. private var requiredUpdate: VersionGate.Update? { @@ -144,6 +167,7 @@ struct ReceiverScreen: View { // Re-read from the proxy on every pass: a fold or resize yields a // complete new snapshot, never an update to the previous one. let reservedRegions = ControlReservedRegion.active(in: geo) + let canvas = canvas(in: geo.size) ZStack { if showsReceiverSurface { ReceiverSafeAreaProbe { insets, notchSide in @@ -170,7 +194,11 @@ struct ReceiverScreen: View { snapRotation: controlStore.preferences.snapRotation, appGestureCommands: controlStore.preferences.appGestureCommands, safeInsets: effectiveSafeInsets, - occupiedControlFrames: occupiedControlFrames, + // Controls report screen coordinates; the + // surface may sit beside a Strip rail. + occupiedControlFrames: occupiedControlFrames.map { + $0.offsetBy(dx: -canvas.minX, dy: -canvas.minY) + }, reservedDivisions: ControlReservedRegion.frames(of: .division, in: reservedRegions), onRotationSnap: { haptics.play(.selection) }, @@ -179,8 +207,14 @@ struct ReceiverScreen: View { smartTouchHaptics: controlStore.preferences.smartTouchLongPressHapticEnabled), onKeyboardVisibleRectChange: { keyboardVisibleRect = $0 }, onActivityBegan: { controlStore.beginAutoHideActivity() }, - onActivityEnded: { controlStore.scheduleAutoHide() }) + onActivityEnded: { controlStore.scheduleAutoHide() }, + preferMeowDisplayGestures: controlStore.preferences.preferMeowDisplayGestures, + moveViewActive: controlStore.moveViewActive, + viewportResetGeneration: controlStore.viewportResetGeneration, + onDisplayFootprintChange: { footprintStore.update($0) }) .id(metalRenderer) // rebuild the layer tree on toggle + .frame(width: canvas.width, height: canvas.height) + .position(x: canvas.midX, y: canvas.midY) .ignoresSafeArea() // Allow Input OFF disables ALL touch/gesture // delivery to the video layer in one place — no @@ -238,7 +272,9 @@ struct ReceiverScreen: View { notchSide: physicalNotchSide, reservedRegions: reservedRegions, haptics: haptics, - onOccupiedFramesChange: { occupiedControlFrames = $0 }) + onOccupiedFramesChange: { occupiedControlFrames = $0 }, + footprintStore: footprintStore, + onCanvasChange: { padCanvas = $0 }) } else { IdleView(receiver: model.receiver, wakeConnect: model.wakeConnect, showSettings: $showSettings) } @@ -261,9 +297,13 @@ struct ReceiverScreen: View { // remote Mac; a second swipe still opens Control Center, the Home // indicator etc. Never while input is off or outside the surface. .defersSystemGestures(on: deferredSystemGestureEdges) - .sheet(isPresented: $showSettings) { - SettingsView(receiver: model.receiver, controlStore: controlStore, - pictureInPicture: model.pictureInPicture, haptics: haptics) + // iPhone: a sheet. iPad: full screen, so the split view has the + // width for its sidebar (a form sheet would collapse it). + .sheet(isPresented: controlStore.isPad ? .constant(false) : $showSettings) { + settingsView + } + .fullScreenCover(isPresented: controlStore.isPad ? $showSettings : .constant(false)) { + settingsView } // One sheet for the whole secure pairing ceremony on every transport // (LAN, USB, Remote): SAS → waiting for the other device. The Remote @@ -279,16 +319,25 @@ struct ReceiverScreen: View { PairingConfirmationSheet(prompt: model.receiver.pairingPrompt) } .sessionInvitationPrompt(receiver: model.receiver) - .onChange(of: model.receiver.pairingSuccessCount) { _ in + .onChange(of: model.receiver.pairingSuccessCount) { count in UINotificationFeedbackGenerator().notificationOccurred(.success) - withAnimation { showPairedToast = true } + UIAccessibility.post(notification: .announcement, + argument: String(localized: "Paired successfully. You can now connect to this Mac.")) + withAnimation(.spring(response: 0.35, dampingFraction: 0.85)) { showPairedToast = true } Task { - try? await Task.sleep(nanoseconds: 2_000_000_000) - withAnimation { showPairedToast = false } + try? await Task.sleep(nanoseconds: 2_800_000_000) + // A newer success restarts the clock instead of being cut short. + guard count == model.receiver.pairingSuccessCount else { return } + withAnimation(.easeInOut(duration: 0.25)) { showPairedToast = false } } } .overlay(alignment: .top) { - if showPairedToast { PairedToast().padding(.top, 12).transition(.move(edge: .top).combined(with: .opacity)) } + if showPairedToast { + PairedToast() + .padding(.top, 12) + .padding(.horizontal, 16) + .transition(.move(edge: .top).combined(with: .opacity)) + } } .onChange(of: model.receiver.displayModeConfirmationGeneration) { _ in haptics.play(.confirmation) @@ -375,7 +424,10 @@ struct ReceiverScreen: View { Log.info("inputTrace: controlStore.preferences.allowInput -> \(allowed) " + "displayState=\(model.receiver.displayState)") #endif - if !allowed { keyboardActive = false } + if !allowed { + keyboardActive = false + controlStore.setMoveViewActive(false) + } } #if DEBUG .onAppear { @@ -717,11 +769,14 @@ struct IdleView: View { // written by SettingsView's already-gated toggle, so it can't be true // while locked. @AppStorage(CatMode.enabledDefaultsKey) private var catModeEnabled = false + @Environment(\.horizontalSizeClass) private var horizontalSizeClass var body: some View { GeometryReader { proxy in Group { - if proxy.size.width > proxy.size.height { + if horizontalSizeClass == .regular { + regularLayout(size: proxy.size) + } else if proxy.size.width > proxy.size.height { landscapeLayout } else { portraitLayout(minHeight: proxy.size.height) @@ -729,7 +784,7 @@ struct IdleView: View { } .frame(maxWidth: .infinity, maxHeight: .infinity) } - .background(Color(.systemBackground)) + .background(horizontalSizeClass == .regular ? Color(.systemGroupedBackground) : Color(.systemBackground)) .sheet(isPresented: Binding( get: { showRemotePairing && receiver.pairingPrompt.pending == nil }, set: { presented in @@ -793,12 +848,8 @@ struct IdleView: View { VStack(spacing: 12) { Spacer(minLength: 0) if instructionsExpanded { - HStack(spacing: 14) { - logo(width: 76) - VStack(alignment: .leading, spacing: 4) { - brandTitle(font: .title.bold()) - statusLine - } + HStack(spacing: 0) { + brandHeader(logoWidth: 68, titleFont: .title.bold(), titleStyle: .title1) Spacer(minLength: 0) } } else { @@ -836,6 +887,112 @@ struct IdleView: View { .frame(width: width, height: width) } + /// The artwork's visible squircle spans ~89% of the PNG (a transparent + /// margin plus soft glow surround it). Beside text, lay the icon out by + /// its visible edge so spacing and centering are measured from what + /// the eye sees; the glow still draws, just outside the layout frame. + private func visibleLogo(width: CGFloat) -> some View { + Image("MeowLogo") + .resizable() + .scaledToFit() + .frame(width: width * Self.logoArtworkScale, height: width * Self.logoArtworkScale) + .frame(width: width, height: width) + .accessibilityHidden(true) + } + + private static let logoArtworkScale: CGFloat = 1254.0 / 1118.0 + + /// Icon beside the wordmark and status, optically centered: the icon's + /// middle lines up with the middle of the text's ink (title cap height + /// down to the status baseline), not with the text's line boxes, whose + /// leading makes a bounding-box center sit visibly low. + private func brandHeader(logoWidth: CGFloat, titleFont: Font, + titleStyle: UIFont.TextStyle) -> some View { + let capHeight = UIFont.preferredFont(forTextStyle: titleStyle).capHeight + // The status line's baseline: the last baseline unless a pairing + // status follows it, then one callout line below the first. + let statusBelowFirstBaseline: CGFloat? = receiver.pairingPrompt.status == nil + ? nil : UIFont.preferredFont(forTextStyle: .callout).lineHeight + 4 + return HStack(alignment: .brandInkCenter, spacing: logoWidth * 0.26) { + visibleLogo(width: logoWidth) + .alignmentGuide(.brandInkCenter) { $0[VerticalAlignment.center] } + VStack(alignment: .leading, spacing: 4) { + brandTitle(font: titleFont) + statusLine + if let pairingStatus = receiver.pairingPrompt.status { + Text(pairingStatus) + .font(.callout.weight(.medium)) + .foregroundStyle(.secondary) + } + } + .alignmentGuide(.brandInkCenter) { dimensions in + let statusBaseline = statusBelowFirstBaseline.map { dimensions[.firstTextBaseline] + $0 } + ?? dimensions[.lastTextBaseline] + return (dimensions[.firstTextBaseline] - capHeight + statusBaseline) / 2 + } + } + } + + // MARK: Regular width (iPad) + + /// iPad: a centered, width-limited page — identity and connection on + /// one side, devices on the other — instead of a phone column + /// stretched across the screen. + private func regularLayout(size: CGSize) -> some View { + let twoColumns = size.width >= 760 + return ScrollView { + VStack(spacing: 24) { + if twoColumns { + HStack(alignment: .top, spacing: 24) { + VStack(spacing: 20) { + statusCard + instructionsSection + } + .frame(maxWidth: .infinity) + devicesCard + .frame(maxWidth: .infinity) + } + } else { + VStack(spacing: 20) { + statusCard + devicesCard + instructionsSection + } + .frame(maxWidth: 560) + } + VStack(spacing: 10) { + settingsButton + tip + } + .padding(.top, 4) + } + .frame(maxWidth: 940) + .padding(.horizontal, 32) + .padding(.vertical, 40) + .frame(maxWidth: .infinity, minHeight: size.height) + } + .scrollBounceBehavior(.basedOnSize) + } + + private var statusCard: some View { + VStack(alignment: .leading, spacing: 16) { + brandHeader(logoWidth: 72, titleFont: .largeTitle.bold(), titleStyle: .largeTitle) + Divider() + Toggle("Auto-Reconnect", isOn: $receiver.autoReconnectEnabled) + .toggleStyle(.switch) + } + .padding(20) + .frame(maxWidth: .infinity, alignment: .leading) + .background(Color(.secondarySystemGroupedBackground), in: RoundedRectangle(cornerRadius: 20, style: .continuous)) + } + + private var devicesCard: some View { + deviceSections(maxWidth: .infinity, horizontalPadding: 0) + .padding(20) + .frame(maxWidth: .infinity, alignment: .leading) + .background(Color(.secondarySystemGroupedBackground), in: RoundedRectangle(cornerRadius: 20, style: .continuous)) + } + private func brandTitle(font: Font) -> some View { HStack(spacing: 6) { Text(verbatim: "MeowDisplay").font(font) @@ -921,7 +1078,10 @@ struct IdleView: View { .font(.footnote) .padding(20) .frame(maxWidth: 420, alignment: .leading) - .background(Color(.secondarySystemBackground), + // On iPad's grouped page the card needs the grouped card color to + // stand apart from the background. + .background(horizontalSizeClass == .regular ? Color(.secondarySystemGroupedBackground) + : Color(.secondarySystemBackground), in: RoundedRectangle(cornerRadius: 16)) } @@ -940,19 +1100,24 @@ struct IdleView: View { // MARK: Device sections (shared by portrait and landscape) - private var deviceSections: some View { + private var deviceSections: some View { deviceSections(maxWidth: 420, horizontalPadding: 16) } + + private func deviceSections(maxWidth: CGFloat, horizontalPadding: CGFloat) -> some View { VStack(alignment: .leading, spacing: 16) { nearbyDevices + .padding(.horizontal, horizontalPadding) Button { showRemotePairing = true } label: { Label("Pair over Remote", systemImage: "network") } .buttonStyle(.bordered) - .padding(.horizontal, 16) + .padding(.horizontal, horizontalPadding) + if horizontalPadding == 0 { Divider() } remoteAccess + .padding(.horizontal, horizontalPadding) } - .frame(maxWidth: 420) + .frame(maxWidth: maxWidth, alignment: .leading) } private var nearbyDevices: some View { @@ -992,7 +1157,6 @@ struct IdleView: View { } } } - .padding(.horizontal, 16) } /// Paired Macs that have a saved Remote endpoint, with display names. @@ -1032,7 +1196,6 @@ struct IdleView: View { } } } - .padding(.horizontal, 16) } private func presentRemoteSettings(peerID: String?) { @@ -1232,894 +1395,6 @@ struct OnboardingView: View { } } -// MARK: - Settings / help sheet - -struct SettingsView: View { - @ObservedObject var receiver: StreamReceiver - @ObservedObject var controlStore: ReceiverControlStore - @ObservedObject var pictureInPicture: ReceiverPictureInPictureController - let haptics: ReceiverHaptics - @Environment(\.dismiss) private var dismiss - @AppStorage("showAnalytics") private var showAnalytics = false - @AppStorage("metalRenderer") private var metalRenderer = false - @AppStorage("zoomWhileTyping") private var zoomWhileTyping = true - #if DEBUG - @AppStorage("notchDebugOverlay") private var notchDebugOverlayEnabled = false - // Developer / Audio Diagnostics (receiver-side AAC investigation): a - // physical iPhone's sandboxed UserDefaults can't receive a Mac - // terminal's `defaults write` the way a Simulator or a Mac-native app - // can, so these need an in-app control to be usable during a real - // on-device retest — see `StreamReceiver`'s matching keys, all read - // fresh (never latched) so a toggle here takes effect immediately. - @AppStorage("audioPlaybackPath") private var audioPlaybackPath = "pcmEngine" - @AppStorage("audioPCMSchedulingMode") private var audioPCMSchedulingMode = "continuous" - @AppStorage("audioReceiverLocalDecode") private var audioReceiverLocalDecode = false - @AppStorage("audioReceiverDumpEnabled") private var audioReceiverDumpEnabled = false - @AppStorage("audioAACIntegrityLogging") private var audioAACIntegrityLogging = false - #endif - @State private var confirmingReset = false - @State private var confirmingFunctionTrayReset = false - @State private var trustRefresh = 0 - @State private var forgetConfirmation = PeerForgetPrompt() - - // Cat Mode (hidden easter egg — nine taps on the About/version row - // below). Local-only presentation state: never synced, never on the - // wire. `catModeTapCount` intentionally isn't persisted — a relaunch - // mid-tapping just resets the count, it's not meant to be a puzzle - // across sessions. - @AppStorage(CatMode.unlockedDefaultsKey) private var catModeUnlocked = false - @AppStorage(CatMode.enabledDefaultsKey) private var catModeEnabledStorage = false - @AppStorage(CatMode.tapCountDefaultsKey) private var catModeTapCount = 0 - @State private var showCatModeUnlockedAlert = false - - private var catModeEnabled: Bool { - CatMode.resolveEnabled(requestedEnabled: catModeEnabledStorage, unlocked: catModeUnlocked) - } - - private var catModeToggleBinding: Binding { - Binding( - get: { catModeEnabled }, - set: { catModeEnabledStorage = CatMode.resolveEnabled(requestedEnabled: $0, unlocked: catModeUnlocked) } - ) - } - - private func registerCatModeTap() { - let result = CatMode.registerTap(tapCount: catModeTapCount, alreadyUnlocked: catModeUnlocked) - catModeTapCount = result.tapCount - if result.justUnlocked { - catModeUnlocked = true - showCatModeUnlockedAlert = true - } - } - - private var version: String { - Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "dev" - } - - var body: some View { - NavigationStack { - Form { - Section { - let peers = TrustStore.shared.pinnedPeers() - if peers.isEmpty { - Text("No paired Macs").foregroundStyle(.secondary) - } else { - AutomaticallyAllowConnectionsToggle() - ForEach(peers, id: \.peerID) { peer in - HStack { - Text(peer.displayName) - Spacer() - Button("Forget", role: .destructive) { - forgetConfirmation.request(peerID: peer.peerID, name: peer.displayName) - } - } - IncomingSessionPolicyPicker(peerID: peer.peerID, receiver: receiver) - .font(.subheadline) - } - } - } header: { - Text("Paired Macs") - } footer: { - Text("Connection Requests: Default follows Automatically Allow Connections. Blocking keeps the Mac paired.") - } - .id(trustRefresh) - .alert("Forget \u{201C}\(forgetConfirmation.candidate?.name ?? "This Mac")\u{201D}?", - isPresented: Binding(get: { forgetConfirmation.isPresented }, - set: { if !$0 { forgetConfirmation.cancel() } })) { - Button("Forget", role: .destructive) { - forgetConfirmation.confirm { peerID in - receiver.forgetPeer(peerID) - receiver.pairingPrompt.cancel() - trustRefresh &+= 1 - } - } - Button("Cancel", role: .cancel) { forgetConfirmation.cancel() } - } message: { - Text("You'll need to pair with this Mac again before connecting.") - } - Section { - NavigationLink("Remote Access") { - RemoteAccessSettingsView(receiver: receiver) - } - .disabled(TrustStore.shared.pinnedPeers().isEmpty) - } - .id(trustRefresh) - Section("Status") { - LabeledContent("Connection", - value: receiver.connected ? receiver.status : receiver.canonicalPhaseTitle) - if receiver.videoSize != .zero { - LabeledContent("Stream", - value: "\(Int(receiver.videoSize.width))×\(Int(receiver.videoSize.height)) @ \(receiver.fps) fps") - } - if receiver.connected { - Button("Disconnect", role: .destructive) { - receiver.disconnect() - } - } - } - - Section { - Toggle("Auto-Reconnect", isOn: $receiver.autoReconnectEnabled) - } header: { - Text("Connection") - } footer: { - Text("Automatically reconnect to paired devices after connection interruptions. Turning this off only stops automatic reconnecting — Connect, Reconnect, and Wake & Connect still work, and an active session stays connected. Also shown on the Home screen.") - } - - Section("Display") { - VStack(alignment: .leading, spacing: 4) { - Toggle("Video", isOn: Binding( - get: { receiver.videoEnabled }, - set: { receiver.requestVideoEnabled($0) })) - .disabled(!receiver.connected || !receiver.macSupportsVideoControl) - Text("Turning video off keeps the connection, keyboard, controls, and selected input mode active.") - .font(.footnote) - .foregroundStyle(.secondary) - } - VStack(alignment: .leading, spacing: 4) { - Toggle("Picture in Picture", isOn: preferenceBinding(\.pictureInPictureEnabled)) - Text("Leaving MeowDisplay during a session keeps your Mac visible in a floating window. It’s view-only — control stays in MeowDisplay.") - .font(.footnote) - .foregroundStyle(.secondary) - if let note = pictureInPictureNote { - Text(note) - .font(.footnote) - .foregroundStyle(.secondary) - } - } - if pictureInPicture.isShowingWindow { - Button("Stop Picture in Picture") { pictureInPicture.stop() } - } else if pictureInPicture.availability == .available { - Button { - // Started from the tap itself: AVKit only honors a - // manual start made in response to user action. - pictureInPicture.start() - dismiss() - } label: { - Label("Start Picture in Picture", systemImage: "pip.enter") - } - } - Toggle("Show Surface Grid", isOn: preferenceBinding(\.showSurfaceGrid)) - VStack(alignment: .leading, spacing: 4) { - Toggle("Avoid Notch", isOn: preferenceBinding(\.avoidNotch)) - Text("Keeps controls clear of the iPhone’s notch or Dynamic Island in landscape.") - .font(.footnote) - .foregroundStyle(.secondary) - Text("Experimental") - .font(.caption2) - .foregroundStyle(.tertiary) - } - if let confirmedMode = receiver.confirmedDisplayMode { - Picker("Display Mode", selection: Binding( - get: { receiver.pendingDisplayMode ?? confirmedMode }, - set: { receiver.requestDisplayMode($0) })) { - ForEach(ReceiverDisplayMode.allCases) { mode in - Text(mode.title).tag(mode) - .disabled(!receiver.videoEnabled && mode == .extend) - } - } - .disabled(!receiver.connected - || receiver.macProtocolVersion < WireProtocol.displayModeWireVersion - || receiver.pendingDisplayMode != nil) - if let pendingMode = receiver.pendingDisplayMode { - LabeledContent("Switching to \(pendingMode.title)") { - ProgressView() - } - } - if confirmedMode == .mirror, - receiver.macProtocolVersion >= WireProtocol.mirrorDisplayWireVersion { - mirrorDisplaySourcePicker - } - if confirmedMode == .extend, - receiver.macProtocolVersion >= WireProtocol.extendShapeWireVersion { - extendShapePicker - } - } else { - LabeledContent("Display Mode", - value: receiver.connected ? String(localized: "Waiting for Mac") : String(localized: "Unavailable")) - } - } - - Section { - VStack(alignment: .leading, spacing: 4) { - Toggle("Audio", isOn: Binding( - get: { controlStore.preferences.audioPreferred }, - set: { value in - controlStore.update { $0.audioPreferred = value } - receiver.requestAudioEnabled(value) - })) - .disabled(!receiver.connected || !receiver.macSupportsAudio) - Text("Plays a copy of what the Mac is playing. It keeps playing there too — this never changes the Mac's output device.") - .font(.footnote) - .foregroundStyle(.secondary) - } - VStack(alignment: .leading, spacing: 6) { - HStack { - Text("A/V Sync") - Spacer() - Text(avSyncOffsetLabel(controlStore.preferences.avSyncOffsetMs)) - .foregroundStyle(.secondary) - if controlStore.preferences.avSyncOffsetMs != 0 { - Button("Reset") { - controlStore.update { $0.avSyncOffsetMs = 0 } - } - .font(.footnote) - } - } - Slider(value: Binding( - get: { Double(controlStore.preferences.avSyncOffsetMs) }, - set: { value in - let stepped = Int((value / Double(AVSyncOffset.stepMs)).rounded()) * AVSyncOffset.stepMs - controlStore.update { $0.avSyncOffsetMs = AVSyncOffset.clamped(stepped) } - }), - in: Double(AVSyncOffset.range.lowerBound)...Double(AVSyncOffset.range.upperBound), - step: Double(AVSyncOffset.stepMs)) - Text("Adjust if sound plays slightly before or after the picture.") - .font(.footnote) - .foregroundStyle(.secondary) - Button("Resync") { - receiver.resync() - } - .font(.footnote) - .disabled(!receiver.connected || !receiver.audioEnabled) - Text("If audio drifts or stutters, Resync re-establishes timing without reconnecting. It doesn't change your A/V Sync adjustment above.") - .font(.footnote) - .foregroundStyle(.secondary) - } - .disabled(!controlStore.preferences.audioPreferred) - } header: { - Text("Audio") - } - - Section { - // Isolated from the receiver: the Status section above - // re-renders on every stream update, and a TextField that - // rebuilds mid-tap loses focus (the "tap twice to edit" - // bug). This subview owns its focus and doesn't observe - // the receiver, so it survives those rebuilds. - DeviceNameField { receiver.setServiceName($0) } - } header: { - Text("Name") - } footer: { - Text("Shown in the Mac app's WiFi connection menu. iOS hides this \(deviceKind)'s real name from apps, so set it here once.") - } - - Section { - Toggle("Zoom While Typing", isOn: $zoomWhileTyping) - } header: { - Text("Keyboard") - } footer: { - Text("Enlarge the area you're typing in when the keyboard is open.") - } - - Section { - // Never optimistic: this only ever reflects the Mac's - // last CONFIRMED decision (`ReceiverControlStore. - // sessionInputState`) — tapping Request Control does not - // flip this label until the Mac actually replies. - LabeledContent("Control", value: controlStore.sessionInputState.receiverDisplayText) - switch controlStore.sessionInputState { - case .off, .notAllowed: - Button("Request Control") { receiver.requestAllowInput(true) } - case .requesting: - EmptyView() - case .allowed: - Button("Turn Off", role: .destructive) { receiver.requestAllowInput(false) } - case .requestsDisabled: - Text("This Mac isn't accepting control requests from this device right now. Enable it from the Mac's Input settings.") - .font(.footnote) - .foregroundStyle(.secondary) - } - Picker("Input Mode", selection: preferenceBinding(\.inputMode)) { - ForEach(PointerInputMode.allCases) { Text($0.title).tag($0) } - } - .pickerStyle(.segmented) - Text(controlStore.preferences.inputMode.explanation) - .font(.footnote) - .foregroundStyle(.secondary) - if controlStore.preferences.inputMode == .direct { - Toggle(isOn: preferenceBinding(\.smartTouchEnabled)) { - VStack(alignment: .leading, spacing: 2) { - HStack(spacing: 6) { - Text(verbatim: "Smart Touch") - Text("Experimental") - .font(.caption2.weight(.semibold)) - .padding(.horizontal, 6) - .padding(.vertical, 2) - .background(Capsule().fill(Color.orange.opacity(0.2))) - .foregroundStyle(.orange) - } - Text("Scroll with one finger in any direction. Hold a title bar, then drag, to move its window. Elsewhere, touch and hold for normal Direct Touch. Falls back to Direct Touch when the Mac can't identify the area.") - .font(.footnote) - .foregroundStyle(.secondary) - } - } - if controlStore.preferences.smartTouchEnabled { - Toggle(isOn: preferenceBinding(\.smartTouchLongPressHapticEnabled)) { - VStack(alignment: .leading, spacing: 2) { - Text("Smart Touch Haptics", comment: "Toggle label. \"Smart Touch\" is the feature's brand name and must stay untranslated; only \"Haptics\" is translatable.") - Text(controlStore.preferences.hapticsEnabled - ? "Builds while you hold a title bar, then ticks when the window can move. Also confirms switching to Direct Touch." - : "Off while Haptics is turned off.") - .font(.footnote) - .foregroundStyle(.secondary) - } - } - .disabled(!controlStore.preferences.hapticsEnabled) - } - } - HStack { - Text("Trackpad Sensitivity") - Spacer() - if controlStore.preferences.trackpadSensitivity != PointerGestureConfig.defaultTrackpadSensitivity { - Button("Reset") { - controlStore.update { $0.trackpadSensitivity = PointerGestureConfig.defaultTrackpadSensitivity } - } - .font(.footnote) - } - } - Slider(value: preferenceBinding(\.trackpadSensitivity), - in: PointerGestureConfig.trackpadSensitivityRange, step: 0.1) { - Text("Trackpad Sensitivity") - } minimumValueLabel: { - Text("Slow") - } maximumValueLabel: { - Text("Fast") - } - .font(.footnote) - } header: { - Text("Input") - } footer: { - Text("Settings always stays reachable, even with input turned off. Sensitivity only affects Trackpad mode's one-finger pointer movement.") - } - - Section { - VStack(alignment: .leading, spacing: 4) { - Text("Pinch / Zoom") - Picker("Pinch / Zoom", selection: preferenceBinding(\.pinchTarget)) { - ForEach(ReceiverGestureTarget.allCases) { Text($0.title).tag($0) } - } - .pickerStyle(.segmented) - .labelsHidden() - if controlStore.preferences.pinchTarget == .app { - Text("Experimental app command mode") - .font(.footnote) - .foregroundStyle(.secondary) - } - } - VStack(alignment: .leading, spacing: 4) { - Text("Rotation") - Picker("Rotation", selection: preferenceBinding(\.rotateTarget)) { - ForEach(ReceiverGestureTarget.allCases) { Text($0.title).tag($0) } - } - .pickerStyle(.segmented) - .labelsHidden() - if controlStore.preferences.rotateTarget == .app { - Text("Experimental app command mode") - .font(.footnote) - .foregroundStyle(.secondary) - } - } - Toggle("Snap Rotation", isOn: preferenceBinding(\.snapRotation)) - if controlStore.preferences.pinchTarget == .app || controlStore.preferences.rotateTarget == .app { - NavigationLink("App Gesture Commands") { - AppGestureCommandsView(store: controlStore) - } - } - } header: { - Text("Gestures") - } footer: { - Text("Video Off temporarily routes pinch and rotate to App without changing these saved choices. App mode is experimental: instead of injecting native gestures into the foreground app, it sends the keyboard commands configured in App Gesture Commands.") - } - - Section("Receiver Controls") { - Toggle("Show Control Tray", isOn: preferenceBinding(\.trayEnabled)) - .disabled(!controlStore.preferences.allowInput) - Toggle("Show Keyboard Button", isOn: preferenceBinding(\.keyboardButtonEnabled)) - Toggle("Haptics", isOn: preferenceBinding(\.hapticsEnabled)) - Toggle("Collapse Control Tray", isOn: preferenceBinding(\.trayCollapsed)) - Toggle("Auto-hide Control Trays", isOn: preferenceBinding(\.autoHideEnabled)) - landscapeTraySidePicker - Picker("Active Profile", selection: Binding( - get: { controlStore.preferences.activeControlProfile }, - set: { profile in - controlStore.update { $0.activeControlProfile = profile } - haptics.play(.profileChange) - })) { - ForEach(ControlProfileSlot.allCases) { Text($0.title).tag($0) } - } - NavigationLink("Edit Current Profile") { - ControlProfileEditor(store: controlStore, haptics: haptics) - } - Button("Reset Profile to Default", role: .destructive) { - confirmingReset = true - } - } - - streamingProfileSection - maxFPSSection - - Section { - Toggle("Show Function Tray", isOn: preferenceBinding(\.functionTrayEnabled)) - .disabled(!controlStore.preferences.allowInput) - Picker("Function Tray Position", - selection: preferenceBinding(\.functionTrayPosition)) { - ForEach(FunctionTrayPosition.allCases) { Text($0.title).tag($0) } - } - .pickerStyle(.segmented) - Picker("Function Profile", selection: Binding( - get: { controlStore.preferences.activeFunctionTrayProfile }, - set: { profile in - controlStore.update { $0.activeFunctionTrayProfile = profile } - haptics.play(.profileChange) - })) { - ForEach(ControlProfileSlot.allCases) { Text($0.title).tag($0) } - } - NavigationLink("Edit Function Tray") { - FunctionTrayProfileEditor(store: controlStore) - } - Button("Reset Function Tray to Default", role: .destructive) { - confirmingFunctionTrayReset = true - } - } header: { - Text("Function Tray") - } footer: { - Text("A second, independent tray of one-tap shortcuts (Undo, Redo, …), separate from the Main Tray above. \"Same Side\" groups it with the Main Tray; \"Opposite Side\" puts it on the other edge of the screen.") - } - - Section { - Toggle("Performance overlay", isOn: $showAnalytics) - Toggle("Metal renderer (experimental)", isOn: $metalRenderer) - } header: { - Text("Analytics") - } footer: { - Text("The overlay shows FPS, bitrate, frame timing, stalls, and latency graphs at the bottom of the screen while streaming. The experimental Metal renderer decodes and presents frames manually — it adds decode and true on-glass latency metrics to the overlay, but in our measurements the system video layer displays frames faster. Leave it off unless you're debugging.") - } - - Section { - Button("Open iOS Settings for MeowDisplay") { - if let url = URL(string: UIApplication.openSettingsURLString) { - UIApplication.shared.open(url) - } - } - } header: { - Text("Permissions") - } footer: { - Text("WiFi mode needs Local Network access. If your Mac can't find this \(deviceKind), enable it under Settings → Privacy & Security → Local Network → MeowDisplay. USB mode works without it.") - } - - Section { - NavigationLink { - DiagnosticsLogView() - } label: { - Label("Connection log", systemImage: "doc.text.magnifyingglass") - } - } header: { - Text("Diagnostics") - } footer: { - Text("What this \(deviceKind) saw while connecting: sessions, restarts, decoder trouble. No screen content and nothing leaves the \(deviceKind) unless you share it. Attach it to a GitHub issue if a connection won't come up.") - } - - #if DEBUG - Section { - Toggle("Notch Debug Overlay", isOn: $notchDebugOverlayEnabled) - } header: { - Text("Developer") - } footer: { - Text("Draws the computed unsafe/obstacle regions (red) and the raw vs. Avoid-Notch-adjusted Main Tray frame (yellow/green) directly over the stream.") - } - Section { - LabeledContent("Production Codec", value: "AAC") - Picker("Playback Path", selection: $audioPlaybackPath) { - Text("PCM Engine").tag("pcmEngine") - Text("Legacy SampleBuffer Renderer").tag("legacyRenderer") - } - .onChange(of: audioPlaybackPath) { path in - Log.info("audioTrace: playbackPath=\(path)") - } - Picker("PCM Scheduling", selection: $audioPCMSchedulingMode) { - Text("Continuous").tag("continuous") - Text("Precise Scheduled").tag("preciseScheduled") - } - .onChange(of: audioPCMSchedulingMode) { mode in - Log.info("audioTrace: audioPCMSchedulingMode=\(mode)") - } - Toggle("Receiver Local AAC Decode", isOn: $audioReceiverLocalDecode) - .onChange(of: audioReceiverLocalDecode) { enabled in - Log.info("audioTrace: audioReceiverLocalDecode=\(enabled)") - } - Toggle("AAC Integrity Logging", isOn: $audioAACIntegrityLogging) - .onChange(of: audioAACIntegrityLogging) { enabled in - Log.info("audioTrace: audioAACIntegrityLogging=\(enabled)") - } - Toggle("Audio Comparison Dump", isOn: $audioReceiverDumpEnabled) - .onChange(of: audioReceiverDumpEnabled) { enabled in - Log.info("audioTrace: audioReceiverDumpEnabled=\(enabled)") - } - Button("Reset Audio Diagnostics", role: .destructive) { - audioPlaybackPath = "pcmEngine" - audioPCMSchedulingMode = "continuous" - audioReceiverLocalDecode = false - audioAACIntegrityLogging = false - audioReceiverDumpEnabled = false - Log.info("audioTrace: audio diagnostics reset to defaults") - } - } header: { - Text("Developer — Audio Diagnostics") - } footer: { - Text("PCM Engine is the default production audio path: AAC is still the only thing sent over the network, decoded on this \(deviceKind) and played through AVAudioEngine. Legacy SampleBuffer Renderer is the older AVSampleBufferAudioRenderer path, kept as a fallback/reference. PCM Scheduling controls how PCM Engine schedules buffers: Continuous (default) chains them on the player's own timeline after one startup anchor; Precise Scheduled independently re-targets every buffer from its own capture timestamp — this reintroduces electrical/robotic noise and exists only for A/B comparison. Receiver Local AAC Decode independently decodes received AAC and logs decode anomalies (clipping, discontinuities, NaN/Inf). AAC Integrity Logging adds a periodic checksum you can compare against the Mac's own log for the same packet. Audio Comparison Dump writes ~5s of the locally-decoded audio to a file in this app's Documents folder (Files app → On My \(deviceKind) → MeowDisplay) once Local AAC Decode is also on. All diagnostics off by default; a fresh Audio Off→On or reconnect applies a change.") - } - #endif - - #if DEBUG - Section { - WakeTestingView() - } header: { - Text("Developer — Wake Testing") - } footer: { - Text("Sends a standard Wake-on-LAN magic packet to an already-paired Mac's last-learned local network address. Same-LAN only — this never uses Remote/Tailscale.") - } - Section { - PromoteInteractiveWakeView(receiver: receiver) - } header: { - Text("Developer — Promote Interactive Wake") - } footer: { - Text("Manual diagnostic, not automated: asks the connected Mac to declare remote user activity, to test whether that promotes a dark/network wake into a full interactive wake.") - } - Section { - Button("Reset Cat Mode", role: .destructive) { - catModeUnlocked = false - catModeEnabledStorage = false - catModeTapCount = 0 - } - } header: { - Text("Developer — Cat Mode") - } footer: { - Text("Re-locks the About/version row's nine-tap easter egg for retesting the unlock flow.") - } - #endif - - Section { - Label("USB: plug in the cable, run the Mac app — it connects automatically through the wire (lowest latency).", - systemImage: "cable.connector") - Label("WiFi: both devices on the same network, then pick this \(deviceKind) in the Mac app's Connection menu.", - systemImage: "wifi") - Label("Rotate the \(deviceKind) for a vertical second monitor.", - systemImage: "rectangle.portrait.rotate") - Label("Touch: tap to click, drag to drag, two-finger pan to scroll.", - systemImage: "hand.tap") - } header: { - Text("How to connect") - } - - Section { - Link(destination: macAppURL) { - Label("Get the Mac app", systemImage: "arrow.down.circle") - } - } footer: { - Text("MeowDisplay needs the Mac app running on a Mac on the same cable or WiFi network. Download it here if you haven't yet.") - } - - Section { - LabeledContent("Version", value: version) - // Hidden unlock gesture: nine taps here (a cat's - // nine lives) reveals Cat Mode below. No visible - // affordance before unlock — this reads like an - // ordinary, non-interactive detail row. - .contentShape(Rectangle()) - .onTapGesture { registerCatModeTap() } - Link(destination: macAppURL) { - Label("GitHub — raiseCatError/MeowDisplay", systemImage: "link") - } - if catModeUnlocked { - Toggle(isOn: catModeToggleBinding) { - Label("Cat Mode", systemImage: "pawprint.fill") - } - } - } header: { - HStack(spacing: 4) { - Text("About") - if catModeEnabled { - Image(systemName: "pawprint.fill") - .accessibilityHidden(true) - } - } - } - } - .navigationTitle("MeowDisplay") - .navigationBarTitleDisplayMode(.inline) - .toolbar { - ToolbarItem(placement: .confirmationAction) { - Button("Done") { dismiss() } - } - } - .alert("Cat Mode unlocked 🐾", isPresented: $showCatModeUnlockedAlert) { - Button("Nice", role: .cancel) {} - } - } - .confirmationDialog("Reset \(controlStore.preferences.activeControlProfile.title)?", - isPresented: $confirmingReset, titleVisibility: .visible) { - Button("Reset Profile", role: .destructive) { - controlStore.update { $0.resetProfile($0.activeControlProfile) } - haptics.play(.reset) - } - } message: { - Text("This restores its tray layout and shortcut palettes. Other receiver settings stay unchanged.") - } - .confirmationDialog("Reset \(controlStore.preferences.activeFunctionTrayProfile.title)?", - isPresented: $confirmingFunctionTrayReset, titleVisibility: .visible) { - Button("Reset Function Tray", role: .destructive) { - controlStore.update { $0.resetFunctionTrayProfile($0.activeFunctionTrayProfile) } - haptics.play(.reset) - } - } message: { - Text("This restores its default Undo/Redo layout. Other receiver settings stay unchanged.") - } - } - - /// Remote control for the Mac's own canonical Mirror capture source - /// (`SenderController.mirrorDisplayUUID`) — never an independent - /// iOS-only preference. `nil` selection means Auto, mirroring the Mac's - /// own nil-means-automatic semantic exactly (see - /// `Mac/MirrorDisplaySelection.swift`). - @ViewBuilder - private var mirrorDisplaySourcePicker: some View { - let state = receiver.mirrorDisplayState - Picker("Mirror Display", selection: Binding( - get: { state?.selectedUUID == nil ? "auto" : "manual" }, - set: { newValue in - if newValue == "auto" { - receiver.requestMirrorDisplaySelection(nil) - } else if let uuid = state?.selectedUUID ?? state?.displays.first?.uuid { - receiver.requestMirrorDisplaySelection(uuid) - } - })) { - Text("Auto").tag("auto") - Text("Manual").tag("manual") - } - .disabled(!receiver.connected || state == nil) - if let state, state.selectedUUID != nil { - if state.displays.isEmpty { - Text("No displays reported.") - .font(.footnote) - .foregroundStyle(.secondary) - } else { - ForEach(state.displays, id: \.uuid) { display in - Button { - receiver.requestMirrorDisplaySelection(display.uuid) - } label: { - HStack { - Text(display.isMain ? String(localized: "\(display.name) (Main)", comment: "A display name, marked as the Mac's main display.") : display.name) - .foregroundStyle(.primary) - Spacer() - if state.selectedUUID == display.uuid { - Image(systemName: "checkmark").foregroundStyle(Color.accentColor) - } - } - } - } - if let selected = state.selectedUUID, !state.displays.contains(where: { $0.uuid == selected }) { - Text("Selected display unavailable") - .font(.footnote) - .foregroundStyle(.orange) - } - } - } - } - - /// Requests an Extend virtual-display shape change (PROTOCOL.md 6.7). - /// The Mac remains authoritative — this only requests; the shown value - /// always tracks `confirmedExtendShape`/`pendingExtendShape`, the same - /// request/confirm contract as `requestDisplayMode` above. - @ViewBuilder - private var extendShapePicker: some View { - if let confirmed = receiver.confirmedExtendShape { - let current = receiver.pendingExtendShape ?? confirmed - Picker("Extend Shape", selection: Binding( - get: { current.shape }, - set: { shape in - var preference = current - preference.shape = shape - receiver.requestExtendShape(preference) - })) { - ForEach(ExtendDisplayShape.allCases) { shape in - Text(shape.title).tag(shape) - } - } - .disabled(!receiver.connected || receiver.pendingExtendShape != nil) - if current.shape == .automatic { - Toggle("Use Full Display", isOn: Binding( - get: { current.useFullDisplay }, - set: { value in - var preference = current - preference.useFullDisplay = value - receiver.requestExtendShape(preference) - })) - .disabled(!receiver.connected || receiver.pendingExtendShape != nil) - } - if receiver.pendingExtendShape != nil { - LabeledContent("Updating Extend shape…") { ProgressView() } - } - if let text = fpsLimitationText { - Text(text) - .font(.footnote) - .foregroundStyle(.secondary) - } - } else { - LabeledContent("Extend Shape", value: receiver.connected ? String(localized: "Waiting for Mac") : String(localized: "Unavailable")) - } - } - - /// PART 5/6 exact user-facing text, re-sent by the Mac on every capture - /// (re)start — including right after an Extend shape change — so this - /// updates with the picker rather than needing a manual refresh. - /// Derived entirely from `receiver.lastMaxFPSState` (the Mac's own - /// `EncoderCapability`/`StreamingFPSPolicy` result for the CURRENT final - /// encode size), never guessed from the shape/ratio alone. Nil (no text) - /// when nothing is actually limiting the request below what was asked. - private var fpsLimitationText: String? { - guard let state = receiver.lastMaxFPSState else { return nil } - if state.encoderSafeFPS >= state.requestedFPS { - return nil - } - return String(localized: "\(receiver.streamingProfile.label) requests \(state.requestedFPS) FPS. Limited to \(state.encoderSafeFPS) FPS at this display size.", - comment: "The first value is a streaming profile name, such as Performance.") - } - - @ViewBuilder - private var streamingProfileSection: some View { - Section("Streaming") { - let profileBinding = Binding( - get: { receiver.streamingProfile }, - set: { receiver.requestStreamingProfile($0, customFrameRate: receiver.customFrameRate) }) - Picker("Streaming Profile", selection: profileBinding) { - ForEach(StreamingProfile.allCases) { profile in - Text(profile.label).tag(profile) - } - } - Text(receiver.streamingProfile.explanation) - .font(.footnote) - .foregroundStyle(.secondary) - if receiver.streamingProfile == .custom { - let frameRateBinding = Binding( - get: { receiver.customFrameRate }, - set: { receiver.requestStreamingProfile(.custom, customFrameRate: $0) }) - Picker("Frame Rate", selection: frameRateBinding) { - ForEach(CustomFrameRateSelection.allCases) { frameRate in - Text(frameRate.label).tag(frameRate) - } - } - } - let priorityBinding = Binding( - get: { receiver.streamingPriority }, - set: { receiver.requestStreamingPriority($0) }) - Picker("Streaming Priority", selection: priorityBinding) { - ForEach(StreamingPriority.allCases) { priority in - Text(priority.label).tag(priority) - } - } - Text(receiver.streamingPriority.explanation) - .font(.footnote) - .foregroundStyle(.secondary) - } - } - - /// PART 2/3/4/6: receiver-enforced max-FPS control, request/confirm-aware - /// same as `extendShapePicker` above — disabled while a request is in - /// flight, and the picker only offers tiers `receiver.lastMaxFPSState` - /// (the Mac's own capability/encoder calculation) says are actually - /// reachable right now. - @ViewBuilder - private var maxFPSSection: some View { - if receiver.macProtocolVersion >= WireProtocol.maxFPSWireVersion { - Section { - if let confirmed = receiver.confirmedMaxFPS { - let current = receiver.pendingMaxFPS ?? confirmed - Toggle("Enforce Maximum FPS", isOn: Binding( - get: { current.enabled }, - set: { enabled in - var preference = current - preference.enabled = enabled - receiver.requestMaxFPS(preference) - })) - .disabled(!receiver.connected || receiver.pendingMaxFPS != nil) - if current.enabled { - let tiers = MaxFPSPicker.tiers(reported: receiver.lastMaxFPSState?.availableTiers) - Picker("Maximum FPS", selection: Binding( - get: { MaxFPSPicker.selection(current, among: tiers) }, - set: { fps in - var preference = current - preference.maxFPS = fps - receiver.requestMaxFPS(preference) - })) { - ForEach(tiers, id: \.self) { fps in - Text("\(fps)").tag(fps) - } - } - .disabled(!receiver.connected || receiver.pendingMaxFPS != nil) - } - if receiver.pendingMaxFPS != nil { - LabeledContent("Updating Maximum FPS…") { ProgressView() } - } - } else { - LabeledContent("Maximum FPS", value: receiver.connected ? String(localized: "Waiting for Mac") : String(localized: "Unavailable")) - } - } footer: { - Text("Caps how fast this Mac streams to this device, on top of its normal profile/display limits.") - .font(.footnote) - .foregroundStyle(.secondary) - } - } - } - - private var landscapeTraySidePicker: some View { - Picker("Landscape Tray Side", selection: preferenceBinding(\.preferredLandscapeSide)) { - ForEach(LandscapeTraySide.allCases) { side in - Text(side.title).tag(side) - } - } - .pickerStyle(.segmented) - } - - /// Why Picture in Picture can't run even though it's turned on, when - /// that reason is something other than simply waiting for live video. - private var pictureInPictureNote: String? { - switch pictureInPicture.availability { - case .unsupported: - return String(localized: "Picture in Picture isn’t supported on this device.") - case .requiresSystemVideoLayer: - return String(localized: "Unavailable while the experimental Metal renderer is on.") - case .available, .turnedOff, .waitingForVideo: - return nil - } - } - - private func preferenceBinding(_ keyPath: WritableKeyPath) -> Binding { - Binding(get: { controlStore.preferences[keyPath: keyPath] }, - set: { value in controlStore.update { $0[keyPath: keyPath] = value } }) - } - - private func avSyncOffsetLabel(_ ms: Int) -> String { - ms == 0 ? "0 ms" : (ms > 0 ? "+\(ms) ms" : "\(ms) ms") - } -} - -/// The device-name editor, deliberately kept out of any high-frequency -/// @ObservedObject so streaming updates can't rebuild it and steal focus. -private struct DeviceNameField: View { - @AppStorage("deviceName") private var deviceName = UIDevice.current.name - @FocusState private var focused: Bool - let onChange: (String) -> Void - - var body: some View { - TextField("Device name", text: $deviceName) - .textInputAutocapitalization(.words) - .autocorrectionDisabled() - .focused($focused) - .onChange(of: deviceName) { name in onChange(name) } - } -} - /// The receiver decode ceiling this build advertises via /// `hello.maxEncodeWide/High` (PROTOCOL.md 6.5). iOS has no public API that /// reports the VideoToolbox H.264 hardware decoder's actual pixel ceiling, @@ -2369,6 +1644,15 @@ struct VideoLayerView: UIViewRepresentable { /// reveal from any receiver-surface interaction, not only a tray touch. let onActivityBegan: () -> Void let onActivityEnded: () -> Void + /// Prefer MeowDisplay Gestures — see `ReceiverEditingInteractionPolicy`. + var preferMeowDisplayGestures = true + /// Move View: the surface navigates the local viewport instead of + /// controlling the Mac — see `ViewportNavigationSession`. + var moveViewActive = false + /// Bumped to reset the local viewport (Move View's Reset). + var viewportResetGeneration = 0 + /// Where the Mac is drawn, in window coordinates — see `DisplayFootprint`. + var onDisplayFootprintChange: @MainActor (DisplayFootprint) -> Void = { _ in } func makeUIView(context: Context) -> VideoView { let view = VideoView() @@ -2391,6 +1675,9 @@ struct VideoLayerView: UIViewRepresentable { view.onActivityBegan = onActivityBegan view.onActivityEnded = onActivityEnded view.smartTouchHaptics.isEnabled = smartTouchHapticsEnabled + view.preferMeowDisplayGestures = preferMeowDisplayGestures + view.onDisplayFootprintChange = onDisplayFootprintChange + view.setViewportResetGeneration(viewportResetGeneration) receiver.onSmartTouchProbeResult = { [weak view] id, target in view?.resolveSmartTouchProbe(id: id, target: target) } @@ -2477,6 +1764,7 @@ struct VideoLayerView: UIViewRepresentable { view.viewportDoubleTapRecognizer = viewportDoubleTap view.addGestureRecognizer(viewportDoubleTap) view.applyMultiFingerGate() + view.setMoveViewActive(moveViewActive) // Local cursor echo: position updates ride the ~2ms control path // instead of the ~30ms video path, so the pointer feels native. @@ -2514,6 +1802,10 @@ struct VideoLayerView: UIViewRepresentable { uiView.onActivityBegan = onActivityBegan uiView.onActivityEnded = onActivityEnded uiView.smartTouchHaptics.isEnabled = smartTouchHapticsEnabled + uiView.preferMeowDisplayGestures = preferMeowDisplayGestures + uiView.onDisplayFootprintChange = onDisplayFootprintChange + uiView.setMoveViewActive(moveViewActive) + uiView.setViewportResetGeneration(viewportResetGeneration) // videoSize arrives after the format description — re-fit the layers. uiView.setNeedsLayout() } @@ -2701,7 +1993,100 @@ struct VideoLayerView: UIViewRepresentable { /// three-finger undo/redo/copy/paste editing gestures (and their /// HUD), which otherwise compete with the three-finger Mac-system /// recognizers below. Scoped to this view only, never app-wide. - override var editingInteractionConfiguration: UIEditingInteractionConfiguration { .none } + /// + /// Only while Prefer MeowDisplay Gestures is on; off restores UIKit's + /// default behavior. + override var editingInteractionConfiguration: UIEditingInteractionConfiguration { + ReceiverEditingInteractionPolicy.suppressesEditingInteractions( + preferMeowDisplayGestures: preferMeowDisplayGestures) ? .none : super.editingInteractionConfiguration + } + + var preferMeowDisplayGestures = true + + // MARK: - Move View + + private var moveViewActive = false + private var navigationSession = ViewportNavigationSession() + private var navigationTouches: [ObjectIdentifier: UITouch] = [:] + private var lastViewportResetGeneration: Int? + + /// Entering Move View cancels whatever input was in flight — the + /// surface is about to stop talking to the Mac — and parks every + /// multi-finger recognizer (see `ReceiverMultiFingerGestureGate`). + func setMoveViewActive(_ active: Bool) { + guard active != moveViewActive else { return } + moveViewActive = active + navigationTouches.removeAll() + navigationSession.end() + if active { clearInputStateForPause() } + if multiFingerGate.update(viewportNavigationActive: active) { applyMultiFingerGate() } + } + + /// Move View's Reset: back to exactly the normal presentation, + /// remembering the moved view for a two-finger double-tap restore. + func setViewportResetGeneration(_ generation: Int) { + defer { lastViewportResetGeneration = generation } + guard let last = lastViewportResetGeneration, last != generation, !manualZoom.isIdentity else { return } + manualZoomMemory = manualZoom + manualZoom = .identity + navigationSession.end() + animateTransformChange(duration: 0.25, options: .curveEaseInOut) + } + + /// While Move View is on, every touch drives the local viewport and + /// nothing reaches the Mac. + private func routeNavigationTouches(_ touches: Set, ended: Bool) { + for touch in touches { + let id = ObjectIdentifier(touch) + if ended { navigationTouches.removeValue(forKey: id) } else { navigationTouches[id] = touch } + } + guard videoEnabled, !navigationTouches.isEmpty else { + navigationSession.end() + return + } + let points = navigationTouches.values + .sorted { ObjectIdentifier($0).hashValue < ObjectIdentifier($1).hashValue } + .map { $0.location(in: self) } + let base = unzoomedBaseTransform().displayedRect + var next = navigationSession.update(points: points, current: manualZoom, base: base, + allowsZoom: true, + allowsRotation: rotateTarget == .viewport) + if rotateTarget == .viewport { + next.rotationRadians = ViewportRotationSnap.snappedAngle(next.rotationRadians, + enabled: snapRotation).angle + } + guard next != manualZoom else { return } + manualZoom = next + setNeedsLayout() + layoutIfNeeded() + } + + // MARK: - Display footprint + + var onDisplayFootprintChange: (@MainActor (DisplayFootprint) -> Void)? + private var lastFootprint = DisplayFootprint.none + + override var frame: CGRect { + didSet { reportDisplayFootprint() } + } + + override var center: CGPoint { + didSet { reportDisplayFootprint() } + } + + /// Publishes where the Mac is drawn (window coordinates) whenever + /// it changes — the controls use it to decide where a soft backdrop + /// is warranted. Video off draws no Mac, so no footprint. + private func reportDisplayFootprint() { + guard window != nil else { return } + let footprint = videoEnabled + ? DisplayFootprint(transform: currentTransform, surfaceOrigin: convert(CGPoint.zero, to: nil), + surfaceSize: bounds.size) + : .none + guard footprint != lastFootprint else { return } + lastFootprint = footprint + onDisplayFootprintChange?(footprint) + } // VoiceOver gate for every multi-finger recognizer — see // `ReceiverMultiFingerGestureGate`. @@ -2971,6 +2356,7 @@ struct VideoLayerView: UIViewRepresentable { updateCursorLayout() updateSurfaceLayout() CATransaction.commit() + reportDisplayFootprint() // Rotation diagnostics — one line per layout change. let video = receiver?.videoSize ?? .zero let displayed = currentTransform.displayedRect @@ -4056,6 +3442,11 @@ struct VideoLayerView: UIViewRepresentable { } override func touchesBegan(_ touches: Set, with event: UIEvent?) { + if moveViewActive { + onActivityBegan?() + routeNavigationTouches(touches, ended: false) + return + } // Any new touch beginning — pointer, chord, pinch, system // gesture, or a fresh scroll's own first samples — cancels a // still-coasting momentum phase immediately (PRODUCT RULE: @@ -4069,9 +3460,18 @@ struct VideoLayerView: UIViewRepresentable { routeTouches("began", touches, event, ended: false) } override func touchesMoved(_ touches: Set, with event: UIEvent?) { + if moveViewActive || touches.contains(where: { navigationTouches[ObjectIdentifier($0)] != nil }) { + routeNavigationTouches(touches, ended: false) + return + } routeTouches("moved", touches, event, ended: false) } override func touchesEnded(_ touches: Set, with event: UIEvent?) { + if moveViewActive || touches.contains(where: { navigationTouches[ObjectIdentifier($0)] != nil }) { + routeNavigationTouches(touches, ended: true) + if navigationTouches.isEmpty { onActivityEnded?() } + return + } routeTouches("ended", touches, event, ended: true) // Only once every finger has actually lifted — not per-touch in // a multi-finger gesture — is it safe to restart Auto-hide's @@ -4079,6 +3479,11 @@ struct VideoLayerView: UIViewRepresentable { if activeFingerTouchIDs.isEmpty { onActivityEnded?() } } override func touchesCancelled(_ touches: Set, with event: UIEvent?) { + if moveViewActive || touches.contains(where: { navigationTouches[ObjectIdentifier($0)] != nil }) { + routeNavigationTouches(touches, ended: true) + if navigationTouches.isEmpty { onActivityEnded?() } + return + } routeTouches("cancelled", touches, event, ended: true) if activeFingerTouchIDs.isEmpty { onActivityEnded?() } } @@ -4515,3 +3920,13 @@ final class InputCaptureEngine: NSObject { onPencil?(phase, x, y, pressure, azimuth, altitude) } } + +private extension VerticalAlignment { + /// The middle of the MeowDisplay wordmark/status ink — see + /// `IdleView.brandHeader`. + enum BrandInkCenter: AlignmentID { + static func defaultValue(in context: ViewDimensions) -> CGFloat { context[VerticalAlignment.center] } + } + + static let brandInkCenter = VerticalAlignment(BrandInkCenter.self) +} diff --git a/iOS/ReceiverControls.swift b/iOS/ReceiverControls.swift index 515ed2d6..b9cc9334 100644 --- a/iOS/ReceiverControls.swift +++ b/iOS/ReceiverControls.swift @@ -25,12 +25,40 @@ final class ReceiverControlStore: ObservableObject { private static let autoHideDelay: Duration = .seconds(9) private let repository: ReceiverControlPreferencesRepository var onInputResetRequested: (() -> Void)? - - init(repository: ReceiverControlPreferencesRepository = ReceiverControlPreferencesRepository()) { + /// The iPad-only presentation (Strip/Overlay/Custom, Control Size, …) + /// applies only when true; the iPhone keeps its compact tray. + let isPad: Bool + /// Move View: while true the receiver surface moves the local viewport + /// instead of controlling the Mac. Transient — never persisted, and + /// dropped whenever input stops reaching the Mac. + @Published private(set) var moveViewActive = false + /// Bumped to ask the receiver surface to reset its local viewport. + @Published private(set) var viewportResetGeneration = 0 + + init(repository: ReceiverControlPreferencesRepository = ReceiverControlPreferencesRepository(), + isPad: Bool = UIDevice.current.userInterfaceIdiom == .pad) { self.repository = repository + self.isPad = isPad preferences = repository.load() } + /// The iPad layout mode actually in effect: Custom needs a layout to + /// show, otherwise it presents as Overlay. + var effectivePadLayout: PadControlLayoutMode { + let stored = preferences.padControlLayout + return stored == .custom && preferences.activeCustomLayout == nil ? .overlay : stored + } + + func setMoveViewActive(_ active: Bool) { + guard moveViewActive != active else { return } + moveViewActive = active + if active { beginAutoHideActivity() } else { scheduleAutoHide() } + } + + func requestViewportReset() { + viewportResetGeneration &+= 1 + } + /// Call at the start of ANY meaningful interaction — anywhere on the /// receiver surface, not only the tray. Reveals immediately (never /// touches `trayEnabled`/`functionTrayEnabled`/`trayCollapsed`/profile @@ -51,17 +79,21 @@ final class ReceiverControlStore: ObservableObject { /// cancelled it. func scheduleAutoHide(interactionIdle: Bool = true) { autoHideTask?.cancel() - guard preferences.autoHideEnabled, preferences.trayCanBeShown, - !preferences.trayCollapsed, interactionIdle else { return } + guard autoHideAllowed, interactionIdle else { return } autoHideTask = Task { @MainActor [weak self] in try? await Task.sleep(for: Self.autoHideDelay) - guard let self, !Task.isCancelled else { return } - guard self.preferences.autoHideEnabled, self.preferences.trayCanBeShown, - !self.preferences.trayCollapsed else { return } + guard let self, !Task.isCancelled, self.autoHideAllowed else { return } self.autoHidden = true } } + /// Move View keeps its controls up so the mode can always be left. + private var autoHideAllowed: Bool { + preferences.autoHideEnabled && preferences.trayCanBeShown && !preferences.trayCollapsed + && !moveViewActive + && ControlAutoHidePolicy.applies(isPad: isPad, layout: effectivePadLayout) + } + /// Cancels any pending countdown and clears auto-hidden state, then /// restarts the countdown from now. The shared "something happened" /// entry point for state transitions (new session, profile switch, a @@ -241,6 +273,20 @@ final class SmartTouchHaptics { } } +/// The live rendered-display footprint, published by the receiver surface +/// (`VideoView`) on every layout pass that changes it. Kept out of every +/// other observed object on purpose: during a pinch it changes per frame, +/// and only the small control-backdrop layer observes it. +@MainActor +final class DisplayFootprintStore: ObservableObject { + @Published private(set) var footprint = DisplayFootprint.none + + func update(_ footprint: DisplayFootprint) { + guard footprint != self.footprint else { return } + self.footprint = footprint + } +} + private struct ControlFramePreference: PreferenceKey { static let defaultValue: [String: CGRect] = [:] static func reduce(value: inout [String: CGRect], nextValue: () -> [String: CGRect]) { @@ -265,8 +311,15 @@ struct ReceiverControlOverlay: View { let reservedRegions: [ControlReservedRegion] let haptics: ReceiverHaptics let onOccupiedFramesChange: ([CGRect]) -> Void + /// iPad only: the rendered-display footprint, for control halos. + var footprintStore: DisplayFootprintStore? + /// iPad only: where the Mac canvas goes (the Strip reserves rails). + var onCanvasChange: (CGRect) -> Void = { _ in } @State private var interaction = ControlInteractionState() + @State private var initiatingMoveView = false + /// The modifier whose placement a Custom-layout palette blooms around. + @State private var paletteAnchor: ControlModifier? @State private var frames: [String: CGRect] = [:] @State private var holdTask: Task? @State private var initiatingModifier: ControlModifier? @@ -328,15 +381,102 @@ struct ReceiverControlOverlay: View { // `functionGroups`'s doc above; the full tray (settings gear // included, since it is one of `visibleTrayItems` whenever the tray // isn't manually collapsed) stays mounted and fades out in place. - return profile.visibleTrayItems.filter { - if $0.modifier != nil { - return receiver.macProtocolVersion >= WireProtocol.receiverControlsWireVersion - } - return $0 != .keyboard || (store.preferences.keyboardButtonEnabled && keyboardAvailable) + return profile.visibleTrayItems.filter(capabilityAllows) + } + + /// Modifiers need a Mac that understands receiver controls; Keyboard + /// needs the keyboard wire and its own switch. + private func capabilityAllows(_ item: ControlTrayItem) -> Bool { + if item.modifier != nil { + return receiver.macProtocolVersion >= WireProtocol.receiverControlsWireVersion } + return item != .keyboard || (store.preferences.keyboardButtonEnabled && keyboardAvailable) + } + + /// The tray items the chord gesture can start on in the current + /// presentation. Custom layouts place items themselves, independent of + /// the profile's tray visibility. + private var interactiveTrayItems: [ControlTrayItem] { + guard store.isPad, store.effectivePadLayout == .custom else { return controls } + guard store.preferences.trayCanBeShown, !store.preferences.trayCollapsed else { return [.settings] } + return ControlTrayItem.allCases.filter(capabilityAllows) } var body: some View { + Group { + if store.isPad { + padBody + } else { + phoneBody + } + } + .onAppear { + if !interaction.latchedModifiers.isEmpty || !interaction.temporaryModifiers.isEmpty { + apply(interaction.resetAll()) + } + } + // A fresh mount (new session, view recreated) always begins visible + // according to the normal tray settings, then starts counting down + // if Auto-hide is on. + .onAppear { store.resetAutoHide() } + .animation(.snappy(duration: 0.24), value: store.preferences.trayCollapsed) + // One coordinated animation for the whole Auto-hide fade (Main Tray + + // Function Tray, see their `.opacity`/`.scaleEffect`/`.offset`) + // — restrained and short, never the tray's own spring/snappy feel. + // Reduce Motion collapses it to a near-instant fade. + .animation(reduceMotion ? .easeInOut(duration: 0.12) : .easeInOut(duration: 0.28), value: store.autoHidden) + .animation(.snappy(duration: 0.2), value: interaction.phase) + // Any session interruption — pause, recovery, a failed recovery or a + // plain disconnect — drops every latched modifier/held control, so no + // transient control state survives into the next live session. + .onChange(of: receiver.session.phase) { phase in + if phase != .connected { + apply(interaction.resetAll()) + store.setMoveViewActive(false) + } + store.resetAutoHide() + } + .onChange(of: receiver.displayState) { state in + if state != .running { + apply(interaction.resetAll()) + store.setMoveViewActive(false) + } + store.resetAutoHide() + } + .onChange(of: receiver.videoEnabled) { enabled in + if !enabled { store.setMoveViewActive(false) } + } + .onChange(of: store.preferences.activeControlProfile) { _ in + apply(interaction.resetAll()) + store.resetAutoHide() + } + .onChange(of: store.preferences.activeFunctionTrayProfile) { _ in + apply(interaction.resetAll()) + store.resetAutoHide() + } + // The Mac released its synthetic input state; drop the matching + // local latch silently. A Mac-originated refresh is not a + // receiver-confirmed action and must not buzz. + .onChange(of: receiver.inputResetGeneration) { _ in + apply(interaction.resetAll()) + store.resetAutoHide() + } + .onChange(of: receiver.controlResetGeneration) { _ in + apply(interaction.resetAll()) + store.resetAutoHide() + } + .onChange(of: store.preferences.autoHideEnabled) { _ in store.resetAutoHide() } + .onChange(of: store.preferences.trayCollapsed) { _ in store.resetAutoHide() } + .onChange(of: store.preferences.trayCanBeShown) { _ in store.resetAutoHide() } + .onChange(of: store.preferences.padControlLayout) { _ in + apply(interaction.resetAll()) + store.resetAutoHide() + } + .onChange(of: store.preferences.activeCustomLayoutID) { _ in apply(interaction.resetAll()) } + } + + @ViewBuilder + private var phoneBody: some View { // The gear is permanent receiver chrome and must always be // reachable while this overlay exists at all (its parent only // mounts it while actively streaming) — `controls` and `collapsed` @@ -500,8 +640,8 @@ struct ReceiverControlOverlay: View { Color.clear .frame(width: containerSize.width, height: containerSize.height) - .contentShape(ControlRegionShape(tray: autoHiding ? .zero : layout.trayFrame, - palette: paletteChord == nil ? nil : layout.paletteFrame)) + .contentShape(ControlRegionShape(rects: [autoHiding ? .zero : layout.trayFrame] + + (paletteChord == nil ? [] : [layout.paletteFrame]))) .gesture(controlGesture()) #if DEBUG @@ -542,55 +682,6 @@ struct ReceiverControlOverlay: View { } .onAppear { onOccupiedFramesChange(occupiedFrames) } .onChange(of: occupiedFrames) { onOccupiedFramesChange($0) } - .onAppear { - if !interaction.latchedModifiers.isEmpty || !interaction.temporaryModifiers.isEmpty { - apply(interaction.resetAll()) - } - } - // A fresh mount (new session, view recreated) always begins visible - // according to the normal tray settings, then starts counting down - // if Auto-hide is on. - .onAppear { store.resetAutoHide() } - .animation(.snappy(duration: 0.24), value: store.preferences.trayCollapsed) - // One coordinated animation for the whole Auto-hide fade (Main Tray + - // Function Tray, see their `.opacity`/`.scaleEffect`/`.offset` above) - // — restrained and short, never the tray's own spring/snappy feel. - // Reduce Motion collapses it to a near-instant fade. - .animation(reduceMotion ? .easeInOut(duration: 0.12) : .easeInOut(duration: 0.28), value: store.autoHidden) - .animation(.snappy(duration: 0.2), value: interaction.phase) - // Any session interruption — pause, recovery, a failed recovery or a - // plain disconnect — drops every latched modifier/held control, so no - // transient control state survives into the next live session. - .onChange(of: receiver.session.phase) { phase in - if phase != .connected { apply(interaction.resetAll()) } - store.resetAutoHide() - } - .onChange(of: receiver.displayState) { state in - if state != .running { apply(interaction.resetAll()) } - store.resetAutoHide() - } - .onChange(of: store.preferences.activeControlProfile) { _ in - apply(interaction.resetAll()) - store.resetAutoHide() - } - .onChange(of: store.preferences.activeFunctionTrayProfile) { _ in - apply(interaction.resetAll()) - store.resetAutoHide() - } - // The Mac released its synthetic input state; drop the matching - // local latch silently. A Mac-originated refresh is not a - // receiver-confirmed action and must not buzz. - .onChange(of: receiver.inputResetGeneration) { _ in - apply(interaction.resetAll()) - store.resetAutoHide() - } - .onChange(of: receiver.controlResetGeneration) { _ in - apply(interaction.resetAll()) - store.resetAutoHide() - } - .onChange(of: store.preferences.autoHideEnabled) { _ in store.resetAutoHide() } - .onChange(of: store.preferences.trayCollapsed) { _ in store.resetAutoHide() } - .onChange(of: store.preferences.trayCanBeShown) { _ in store.resetAutoHide() } } #if DEBUG @@ -812,16 +903,24 @@ struct ReceiverControlOverlay: View { } } - private func shortcutButton(_ action: ShortcutItem, portrait: Bool) -> some View { + private func shortcutButton(_ action: ShortcutItem, portrait: Bool, + diameter: CGFloat = Metrics.paletteKey) -> some View { let selected = interaction.selectedActionID == action.id + let fontScale = diameter / Metrics.paletteKey return Text(action.displayKey) - .font(.system(size: action.displayKey.count > 2 ? 11 : 15, weight: .semibold)) + .font(.system(size: (action.displayKey.count > 2 ? 11 : 15) * fontScale, weight: .semibold)) .minimumScaleFactor(0.7) .lineLimit(1) .foregroundStyle(selected ? Color.black : Color.white) - .frame(width: Metrics.paletteKey, height: Metrics.paletteKey) + .frame(width: diameter, height: diameter) .background(frameReader("action:\(action.id)")) - .background(chip(selected: selected)) + .background(Group { + if store.isPad { + PadChip(selected: selected, onRail: false) + } else { + chip(selected: selected) + } + }) .scaleEffect(selected ? 1.12 : 1) .animation(.snappy(duration: 0.14), value: selected) .accessibilityLabel(action.title) @@ -857,8 +956,11 @@ struct ReceiverControlOverlay: View { // Auto-hide fire out from under it mid-interaction. store.beginAutoHideActivity() initiatingItem = trayItem(at: value.startLocation) + initiatingMoveView = initiatingItem == nil + && frames["control:moveView"]?.contains(value.startLocation) == true if let modifier = initiatingItem?.modifier { initiatingModifier = modifier + paletteAnchor = modifier interaction.press(modifier) holdTask?.cancel() holdTask = Task { @MainActor in @@ -905,11 +1007,16 @@ struct ReceiverControlOverlay: View { } else if let item = initiatingItem, frames["tray:\(item.rawValue)"]?.contains(value.location) == true { perform(item) + } else if initiatingMoveView, + frames["control:moveView"]?.contains(value.location) == true { + store.setMoveViewActive(!store.moveViewActive) + haptics.play(.selection) } else if interaction.paletteChord != nil { apply(interaction.finish(with: nil)) } initiatingModifier = nil initiatingItem = nil + initiatingMoveView = false lastHoveredModifier = nil gestureActive = false // The touch lifted: safe to (re)start counting down again, @@ -920,7 +1027,7 @@ struct ReceiverControlOverlay: View { } private func trayItem(at point: CGPoint) -> ControlTrayItem? { - controls.first { item in + interactiveTrayItems.first { item in if let modifier = item.modifier { return frames["modifier:\(modifier.rawValue)"]?.contains(point) == true } @@ -991,14 +1098,663 @@ struct ReceiverControlOverlay: View { } } +// MARK: - iPad presentation (Strip / Overlay / Custom) + +/// One placed iPad control, resolved for this layout pass. +private struct PadPlacedControl: Identifiable { + let id: String + let kind: CustomControlKind + /// The control's circle. + let frame: CGRect + /// The circle plus its caption while Strip hints show. + let cell: CGRect +} + +extension ReceiverControlOverlay { + private var padMetricsScale: Double { store.preferences.padControlScale } + private var padCollapsed: Bool { store.preferences.trayCollapsed || !store.preferences.trayCanBeShown } + + /// Move View only matters while the picture is live. + private var padShowsMoveView: Bool { + !padCollapsed && receiver.videoEnabled + } + + @ViewBuilder + var padBody: some View { + let mode = store.effectivePadLayout + let container = CGRect(origin: .zero, size: containerSize) + ZStack(alignment: .topLeading) { + if mode == .custom, let layout = store.preferences.activeCustomLayout { + customLayoutContent(layout, container: container) + } else { + edgeLayoutContent(mode: mode, container: container) + } + if store.moveViewActive { + moveViewBanner + .position(x: containerSize.width / 2, y: safeInsets.top + 34) + .transition(.move(edge: .top).combined(with: .opacity)) + } + } + .coordinateSpace(name: "receiverControls") + .frame(width: containerSize.width, height: containerSize.height, alignment: .topLeading) + .onPreferenceChange(ControlFramePreference.self) { frames = $0 } + .animation(.snappy(duration: 0.22), value: store.moveViewActive) + } + + // MARK: Strip / Overlay + + @ViewBuilder + private func edgeLayoutContent(mode: PadControlLayoutMode, container: CGRect) -> some View { + let strip = mode == .strip + let hints = strip && store.preferences.padShowControlHints + let metrics = PadControlMetrics(scale: padMetricsScale, showsHints: hints) + let mainKinds = padMainKinds + let groups = functionGroups + let mainEdge = store.preferences.padMainEdge + let functionEdge = store.preferences.padFunctionEdge + let firstPass = PadEdgeGeometry.layout( + container: container, safeInsets: safeInsets, reservesStrips: strip, + mainEdge: mainEdge, functionEdge: functionEdge, + mainCount: padCollapsed ? 0 : mainKinds.count, + functionGroupCounts: groups.map(\.count), metrics: metrics) + let keyboardTop = keyboardVisibleRect.map { $0.maxY + firstPass.canvas.minY } + let layout = keyboardTop == nil ? firstPass : PadEdgeGeometry.layout( + container: container, safeInsets: safeInsets, reservesStrips: strip, + mainEdge: mainEdge, functionEdge: functionEdge, + mainCount: padCollapsed ? 0 : mainKinds.count, + functionGroupCounts: groups.map(\.count), metrics: metrics, keyboardTop: keyboardTop) + // Collapsed (or the tray can't show): only the gear, floating at the + // Main edge, never a rail. + let gearFrame: CGRect? = padCollapsed ? PadEdgeGeometry.layout( + container: container, safeInsets: safeInsets, reservesStrips: false, + mainEdge: mainEdge, functionEdge: functionEdge, mainCount: 1, functionGroupCounts: [], + metrics: PadControlMetrics(scale: padMetricsScale), keyboardTop: keyboardTop).mainFrame : nil + let mainControls: [PadPlacedControl] = layout.mainFrame.map { frame in + let cells = PadEdgeGeometry.cellFrames(in: frame, count: mainKinds.count, edge: mainEdge, metrics: metrics) + return zip(mainKinds, cells).map { kind, cell in + PadPlacedControl(id: padID(kind), kind: kind, + frame: PadEdgeGeometry.circleFrame(inCell: cell, metrics: metrics), cell: cell) + } + } ?? [] + let functionControls: [(item: ShortcutItem, frame: CGRect, cell: CGRect)] = + zip(groups, layout.functionFrames).flatMap { group, frame in + zip(group, PadEdgeGeometry.cellFrames(in: frame, count: group.count, edge: functionEdge, + metrics: metrics)).map { item, cell in + (item, PadEdgeGeometry.circleFrame(inCell: cell, metrics: metrics), cell) + } + } + let autoHiding = store.autoHidden + let paletteChord = interaction.paletteChord + let actions = paletteChord.map(profile.actions(for:)) ?? [] + let keySize = hints + ? CGSize(width: 132 * metrics.scale, height: 36 * metrics.scale) + : CGSize(width: metrics.item * 0.92, height: metrics.item * 0.92) + let paletteBounds = layout.canvas.insetBy(dx: PadControlMetrics.edgeMargin, dy: PadControlMetrics.edgeMargin) + let grid = PadEdgeGeometry.paletteGrid(count: actions.count, key: keySize, gap: metrics.gap, + edge: mainEdge, available: paletteBounds.size) + let paletteFrame: CGRect? = paletteChord.flatMap { _ in + (layout.mainFrame ?? gearFrame).map { + PadEdgeGeometry.paletteFrame(size: actions.isEmpty ? CGSize(width: 220, height: 36) : grid.size, + anchor: $0, edge: mainEdge, bounds: paletteBounds, gap: metrics.gap * 2) + } + } + let paletteKeys: [(action: ShortcutItem, frame: CGRect)] = paletteFrame.map { frame in + actions.enumerated().map { index, action in + // Column-major along a vertical edge, row-major along a + // horizontal one — so keys read outward from the rail. + let row = mainEdge.stacksVertically ? index % grid.rows : index / grid.columns + let column = mainEdge.stacksVertically ? index / grid.rows : index % grid.columns + let origin = CGPoint(x: frame.minX + CGFloat(column) * (keySize.width + metrics.gap), + y: frame.minY + CGFloat(row) * (keySize.height + metrics.gap)) + return (action, CGRect(origin: origin, size: keySize)) + } + } ?? [] + let occupied = [layout.mainFrame, gearFrame, paletteFrame].compactMap { $0 } + layout.functionFrames + + ZStack(alignment: .topLeading) { + ForEach(Array(layout.strips.enumerated()), id: \.offset) { _, strip in + PadStripBackground(edge: strip.edge) + .frame(width: strip.frame.width, height: strip.frame.height) + .position(x: strip.frame.midX, y: strip.frame.midY) + } + // Halos: only floating controls and palette keys over the Mac. + if let footprintStore { + ControlBackdropLayer(store: footprintStore, + circles: (strip ? [] : mainControls.map(\.frame) + functionControls.map(\.frame) + + (gearFrame.map { [$0] } ?? [])) + + paletteKeys.map(\.frame), + capsules: hints) + .opacity(autoHiding ? 0 : 1) + .allowsHitTesting(false) + } + if let gearFrame { + padControlChip(.tray(.settings), frame: gearFrame, onRail: false, hint: nil) + } + ForEach(mainControls) { control in + padControlChip(control.kind, frame: control.frame, onRail: strip, + hint: hints ? padHint(control.kind) : nil) + .opacity(autoHiding ? 0 : 1) + .allowsHitTesting(!autoHiding) + } + ForEach(Array(functionControls.enumerated()), id: \.element.item.id) { _, control in + padFunctionChip(control.item, frame: control.frame, onRail: strip, + hint: hints ? control.item.title.lowercased() : nil) + .opacity(autoHiding ? 0 : 1) + .allowsHitTesting(!autoHiding) + } + if let paletteChord, let paletteFrame { + padPalette(actions: actions, keys: paletteKeys, frame: paletteFrame, chord: paletteChord, + pills: hints) + } + if let selected = selectedAction(in: actions), let paletteFrame { + shortcutHUD(selected) + .position(x: paletteFrame.midX, + y: max(safeInsets.top + 30, paletteFrame.minY - 28)) + .transition(.opacity.combined(with: .scale)) + } + Color.clear + .frame(width: containerSize.width, height: containerSize.height) + .contentShape(ControlRegionShape( + rects: (autoHiding ? [] : mainControls.map { $0.frame.insetBy(dx: -4, dy: -4) }) + + (gearFrame.map { [$0.insetBy(dx: -4, dy: -4)] } ?? []) + + (paletteFrame.map { [$0] } ?? []))) + .gesture(controlGesture()) + } + .onAppear { + onCanvasChange(layout.canvas) + onOccupiedFramesChange(occupied) + } + .onChange(of: layout.canvas) { onCanvasChange($0) } + .onChange(of: occupied) { onOccupiedFramesChange($0) } + } + + /// Main controls for Strip/Overlay: the profile's visible tray items, + /// with Move View before the Settings gear. + private var padMainKinds: [CustomControlKind] { + var kinds = controls.map(CustomControlKind.tray) + if padShowsMoveView, store.preferences.padShowMoveViewControl { + let index = kinds.firstIndex(of: .tray(.settings)) ?? kinds.endIndex + kinds.insert(.moveView, at: index) + } + return kinds + } + + // MARK: Custom + + @ViewBuilder + private func customLayoutContent(_ layout: CustomControlLayout, container: CGRect) -> some View { + let portrait = containerSize.height > containerSize.width + let arrangement = layout.arrangement(portrait: portrait) + let metrics = PadControlMetrics(scale: padMetricsScale) + let area = CustomLayoutGeometry.layoutArea(container: container, safeInsets: safeInsets) + let placements = customPlacements(arrangement) + let controlsPlaced: [PadPlacedControl] = placements.map { placement in + let frame = CustomLayoutGeometry.frame(for: placement, in: area, baseDiameter: metrics.item) + return PadPlacedControl(id: placement.id, kind: placement.kind, frame: frame, cell: frame) + } + let functionItems = store.activeFunctionTrayProfile.items.map(\.item) + let autoHiding = store.autoHidden + let paletteChord = interaction.paletteChord + let actions = paletteChord.map(profile.actions(for:)) ?? [] + let keyDiameter = CustomLayoutGeometry.paletteKeyDiameter(baseDiameter: metrics.item) + let anchorModifier = paletteAnchor.flatMap { arrangement.placement(for: $0) != nil ? $0 : nil } + ?? ControlModifier.allCases.first { interaction.activeChord.contains($0) + && arrangement.placement(for: $0) != nil } + let anchorControl = anchorModifier.flatMap { modifier in + controlsPlaced.first { $0.kind.modifier == modifier } + } + let anchorStyle = anchorModifier.flatMap { arrangement.placement(for: $0)?.palette } ?? PalettePresentation() + let paletteKeys: [(action: ShortcutItem, frame: CGRect)] = { + guard paletteChord != nil, let anchorControl else { return [] } + let points = CustomLayoutGeometry.paletteCenters( + count: actions.count, anchor: anchorControl.frame, style: anchorStyle, area: area, + baseDiameter: metrics.item, spacing: metrics.gap, + obstacles: controlsPlaced.filter { $0.id != anchorControl.id }.map(\.frame)) + return zip(actions, points).map { action, point in + (action, CGRect(x: point.x - keyDiameter / 2, y: point.y - keyDiameter / 2, + width: keyDiameter, height: keyDiameter)) + } + }() + let assistState = TwoHandAssist.state( + enabled: layout.twoHandAssist && !padCollapsed && store.preferences.allowInput, + interaction: interaction) + let assistIdleItems = layout.assistActionIDs.compactMap { id in functionItems.first { $0.id == id } } + let assistCount: Int = { + switch assistState { + case .hidden: return 0 + case .idle: return store.preferences.functionTrayCanBeShown ? assistIdleItems.count : 0 + case .helper: return TwoHandAssist.helperModifiers.count + } + }() + let assistPoints = TwoHandAssist.helperPoints(count: assistCount, clusterCentroid: arrangement.clusterCentroid, + area: area, itemDiameter: metrics.item, spacing: metrics.gap * 1.5) + let assistFrames = assistPoints.map { + CGRect(x: $0.x - metrics.item / 2, y: $0.y - metrics.item / 2, width: metrics.item, height: metrics.item) + } + let regionControls = controlsPlaced.filter { + if case .function = $0.kind { return false } + return true + } + let occupied = controlsPlaced.map(\.frame) + paletteKeys.map(\.frame) + assistFrames + + ZStack(alignment: .topLeading) { + if let footprintStore { + ControlBackdropLayer(store: footprintStore, + circles: (autoHiding ? [] : controlsPlaced.map(\.frame) + assistFrames) + + paletteKeys.map(\.frame), + capsules: false) + .allowsHitTesting(false) + } + ForEach(controlsPlaced) { control in + Group { + if case .function(let id) = control.kind { + if let item = functionItems.first(where: { $0.id == id }) { + padFunctionChip(item, frame: control.frame, onRail: false, hint: nil) + } + } else { + padControlChip(control.kind, frame: control.frame, onRail: false, hint: nil) + } + } + .opacity(autoHiding && control.kind != .tray(.settings) ? 0 : 1) + .allowsHitTesting(!autoHiding || control.kind == .tray(.settings)) + } + if let paletteChord { + if paletteKeys.isEmpty, let anchorControl { + emptyPaletteLabel(paletteChord) + .position(x: anchorControl.frame.midX, + y: anchorControl.frame.minY - metrics.item * 0.9) + } else { + ForEach(paletteKeys, id: \.action.id) { key in + shortcutButton(key.action, portrait: portrait, diameter: key.frame.width) + .position(x: key.frame.midX, y: key.frame.midY) + .transition(.scale(scale: 0.6).combined(with: .opacity)) + } + } + } + twoHandAssistLayer(state: assistState, idleItems: assistIdleItems, frames: assistFrames) + .opacity(autoHiding ? 0 : 1) + .allowsHitTesting(!autoHiding) + if let selected = selectedAction(in: actions) { + shortcutHUD(selected) + .position(x: containerSize.width / 2, y: safeInsets.top + 44) + .transition(.opacity.combined(with: .scale)) + } + Color.clear + .frame(width: containerSize.width, height: containerSize.height) + .contentShape(ControlRegionShape( + rects: regionControls.filter { !autoHiding || $0.kind == .tray(.settings) } + .map { $0.frame.insetBy(dx: -4, dy: -4) } + + paletteKeys.map { $0.frame.insetBy(dx: -3, dy: -3) })) + .gesture(controlGesture()) + } + .animation(.snappy(duration: 0.22), value: assistState) + .onAppear { + onCanvasChange(container) + onOccupiedFramesChange(occupied) + } + .onChange(of: occupied) { onOccupiedFramesChange($0) } + } + + /// The placements to render: those whose control can act right now. + /// Settings is permanent chrome, so a layout without it still gets one + /// in its corner. + private func customPlacements(_ arrangement: CustomControlArrangement) -> [CustomControlPlacement] { + let allowed = Set(interactiveTrayItems) + var result = arrangement.placements.filter { placement in + switch placement.kind { + case .tray(let item): return allowed.contains(item) + case .function: return store.preferences.functionTrayCanBeShown && !padCollapsed + case .moveView: return padShowsMoveView + } + } + if !result.contains(where: { $0.kind == .tray(.settings) }) { + let corner = arrangement.corner.unitPoint + result.append(CustomControlPlacement(id: "settings", kind: .tray(.settings), + x: Double(corner.x), y: Double(corner.y), size: 0.9)) + } + return result + } + + /// The opposite side: idle Function actions, or — while a chord is in + /// progress — helper modifiers projecting the same chord state. + @ViewBuilder + private func twoHandAssistLayer(state: TwoHandAssistState, idleItems: [ShortcutItem], + frames: [CGRect]) -> some View { + switch state { + case .hidden: + EmptyView() + case .idle: + ForEach(Array(zip(idleItems, frames)), id: \.0.id) { item, frame in + padFunctionChip(item, frame: frame, onRail: false, hint: nil) + .transition(.opacity) + } + case .helper(let active): + ForEach(Array(zip(TwoHandAssist.helperModifiers, frames)), id: \.0) { modifier, frame in + let selected = active.contains(modifier) + Text(modifier.symbol) + .font(.system(size: frame.width * 0.46, weight: .semibold)) + .foregroundStyle(selected ? Color.black : Color.white) + .frame(width: frame.width, height: frame.height) + .background(PadChip(selected: selected, onRail: false)) + .contentShape(Circle()) + .onTapGesture { + apply(interaction.toggleAssistModifier(modifier)) + store.beginAutoHideActivity() + } + .position(x: frame.midX, y: frame.midY) + .accessibilityLabel(modifier.title) + .accessibilityValue(selected ? "On" : "Off") + .transition(.opacity.combined(with: .scale(scale: 0.85))) + } + } + } + + // MARK: Pieces + + private func padID(_ kind: CustomControlKind) -> String { + switch kind { + case .tray(let item): return "tray-\(item.rawValue)" + case .function(let id): return "function-\(id)" + case .moveView: return "moveView" + } + } + + private func padHint(_ kind: CustomControlKind) -> String { + switch kind { + case .tray(let item): + switch item { + case .command: return String(localized: "command", comment: "Strip control hint under the ⌘ key.") + case .option: return String(localized: "option", comment: "Strip control hint under the ⌥ key.") + case .control: return String(localized: "control", comment: "Strip control hint under the ⌃ key.") + case .shift: return String(localized: "shift", comment: "Strip control hint under the ⇧ key.") + case .escape: return String(localized: "escape", comment: "Strip control hint.") + case .tab: return String(localized: "tab", comment: "Strip control hint.") + case .dock: return String(localized: "dock", comment: "Strip control hint.") + case .keyboard: return String(localized: "keyboard", comment: "Strip control hint.") + case .settings: return String(localized: "settings", comment: "Strip control hint.") + } + case .function(let id): return id + case .moveView: return String(localized: "move view", comment: "Strip control hint.") + } + } + + /// A Main control: modifier, tray action, or Move View. Taps are handled + /// by `controlGesture` (via the frames reported here), exactly as on + /// iPhone. + @ViewBuilder + private func padControlChip(_ kind: CustomControlKind, frame: CGRect, onRail: Bool, hint: String?) -> some View { + let diameter = frame.width + let selected: Bool = { + switch kind { + case .tray(let item): + guard let modifier = item.modifier else { return false } + return interaction.latchedModifiers.contains(modifier) + || interaction.temporaryModifiers.contains(modifier) + || interaction.phase == .pressed(modifier) + case .moveView: return store.moveViewActive + case .function: return false + } + }() + let frameID: String = { + switch kind { + case .tray(let item): + return item.modifier.map { "modifier:\($0.rawValue)" } ?? "tray:\(item.rawValue)" + case .moveView: return "control:moveView" + case .function(let id): return "function:\(id)" + } + }() + VStack(spacing: 2) { + Group { + switch kind { + case .tray(let item) where item.modifier != nil: + Text(item.modifier?.symbol ?? "") + .font(.system(size: diameter * 0.46, weight: .semibold)) + case .tray(let item) where item == .escape || item == .tab: + Text(item.displayLabel).font(.system(size: diameter * 0.3, weight: .semibold)) + case .tray(let item): + Image(systemName: item.displayLabel).font(.system(size: diameter * 0.4, weight: .semibold)) + case .moveView: + Image(systemName: "arrow.up.and.down.and.arrow.left.and.right") + .font(.system(size: diameter * 0.38, weight: .semibold)) + case .function: + EmptyView() + } + } + .foregroundStyle(selected ? Color.black : Color.white) + .frame(width: diameter, height: diameter) + .background(frameReader(frameID)) + .background(PadChip(selected: selected, onRail: onRail)) + if let hint { + Text(hint) + .font(.system(size: 10, weight: .medium)) + .foregroundStyle(.white.opacity(0.7)) + .lineLimit(1) + .minimumScaleFactor(0.7) + .frame(width: diameter + PadControlMetrics.hintWidthAllowance, + height: PadControlMetrics.hintHeight) + } + } + .position(x: frame.midX, y: frame.minY + (diameter + (hint == nil ? 0 : PadControlMetrics.hintHeight + 2)) / 2) + .accessibilityElement(children: .ignore) + .accessibilityLabel(padAccessibilityLabel(kind)) + .accessibilityValue(kind.modifier != nil || kind == .moveView ? (selected ? "On" : "Off") : "") + } + + private func padAccessibilityLabel(_ kind: CustomControlKind) -> String { + switch kind { + case .tray(let item): return item.title + case .moveView: return String(localized: "Move View") + case .function(let id): return id + } + } + + /// A Function control fires on tap, exactly like the iPhone tray. + private func padFunctionChip(_ item: ShortcutItem, frame: CGRect, onRail: Bool, hint: String?) -> some View { + let diameter = frame.width + return VStack(spacing: 2) { + Group { + if let systemImage = item.systemImage { + Image(systemName: systemImage).font(.system(size: diameter * 0.4, weight: .semibold)) + } else { + Text(item.displayKey).font(.system(size: diameter * 0.34, weight: .semibold)) + } + } + .foregroundStyle(.white) + .frame(width: diameter, height: diameter) + .background(PadChip(selected: false, onRail: onRail)) + .contentShape(Circle()) + .onTapGesture { performFunctionAction(item) } + if let hint { + Text(hint) + .font(.system(size: 10, weight: .medium)) + .foregroundStyle(.white.opacity(0.7)) + .lineLimit(1) + .minimumScaleFactor(0.7) + .frame(width: diameter + PadControlMetrics.hintWidthAllowance, + height: PadControlMetrics.hintHeight) + } + } + .position(x: frame.midX, y: frame.minY + (diameter + (hint == nil ? 0 : PadControlMetrics.hintHeight + 2)) / 2) + .accessibilityElement(children: .ignore) + .accessibilityLabel(item.title) + .accessibilityAddTraits(.isButton) + } + + @ViewBuilder + private func padPalette(actions: [ShortcutItem], keys: [(action: ShortcutItem, frame: CGRect)], + frame: CGRect, chord: ModifierChord, pills: Bool) -> some View { + if actions.isEmpty { + emptyPaletteLabel(chord) + .position(x: frame.midX, y: frame.midY) + } else { + ForEach(keys, id: \.action.id) { key in + Group { + if pills { + paletteHintKey(key.action, size: key.frame.size) + } else { + shortcutButton(key.action, portrait: true, diameter: key.frame.width) + } + } + .position(x: key.frame.midX, y: key.frame.midY) + .transition(.scale(scale: 0.8).combined(with: .opacity)) + } + } + } + + /// Strip hint key: the key cap beside its action name ("C Copy"). + private func paletteHintKey(_ action: ShortcutItem, size: CGSize) -> some View { + let selected = interaction.selectedActionID == action.id + return HStack(spacing: 8) { + Text(action.displayKey) + .font(.system(size: 14, weight: .semibold, design: .rounded)) + .lineLimit(1) + .minimumScaleFactor(0.6) + .frame(minWidth: 22) + Text(action.title) + .font(.system(size: 13, weight: .medium)) + .lineLimit(1) + .minimumScaleFactor(0.75) + Spacer(minLength: 0) + } + .padding(.horizontal, 12) + .foregroundStyle(selected ? Color.black : Color.white) + .frame(width: size.width, height: size.height) + .background(frameReader("action:\(action.id)")) + .background(Capsule().fill(selected ? Color.white.opacity(0.92) : Color.black.opacity(0.55))) + .overlay(Capsule().strokeBorder(.white.opacity(selected ? 0.5 : 0.2), lineWidth: 0.75)) + .scaleEffect(selected ? 1.05 : 1) + .animation(.snappy(duration: 0.14), value: selected) + .accessibilityLabel(action.title) + } + + private func emptyPaletteLabel(_ chord: ModifierChord) -> some View { + Text("No shortcuts for \(chord.displayName)") + .font(.caption) + .foregroundStyle(.white.opacity(0.9)) + .padding(.horizontal, 12).padding(.vertical, 7) + .background(Capsule().fill(Color.black.opacity(0.6))) + .overlay(Capsule().strokeBorder(.white.opacity(0.22), lineWidth: 0.5)) + } + + /// Move View is a mode, so it says so while it's on — with a way back. + private var moveViewBanner: some View { + HStack(spacing: 12) { + Label("Move View", systemImage: "arrow.up.and.down.and.arrow.left.and.right") + .font(.subheadline.weight(.semibold)) + .foregroundStyle(.primary) + Divider().frame(height: 18) + Button("Reset") { + store.requestViewportReset() + haptics.play(.reset) + } + Button("Done") { + store.setMoveViewActive(false) + haptics.play(.selection) + } + .fontWeight(.semibold) + } + .font(.subheadline) + .padding(.horizontal, 16) + .padding(.vertical, 9) + .background(.regularMaterial, in: Capsule()) + .overlay(Capsule().strokeBorder(Color.primary.opacity(0.12), lineWidth: 0.5)) + .shadow(color: .black.opacity(0.25), radius: 10, y: 3) + .environment(\.colorScheme, .dark) + .accessibilityElement(children: .contain) + } +} + +/// The iPad control face. On a Strip rail it reads like a key on the rail; +/// floating, a darker disc keeps white glyphs legible over any content — +/// the soft blur comes from `ControlBackdropLayer`, only where the Mac is +/// actually behind the control. +private struct PadChip: View { + let selected: Bool + let onRail: Bool + + var body: some View { + Circle() + .fill(selected ? Color.white.opacity(0.92) + : (onRail ? Color.white.opacity(0.1) : Color.black.opacity(0.42))) + .overlay(Circle().strokeBorder(.white.opacity(selected ? 0.5 : (onRail ? 0.1 : 0.22)), + lineWidth: 0.75)) + .shadow(color: .black.opacity(onRail ? 0 : 0.25), radius: 4, y: 1) + } +} + +/// The Strip rail: solid black, with a hairline where it meets the canvas. +private struct PadStripBackground: View { + let edge: ControlEdge + + var body: some View { + Rectangle() + .fill(Color.black) + .overlay(alignment: innerAlignment) { + Rectangle() + .fill(Color.white.opacity(0.08)) + .frame(width: edge.stacksVertically ? 0.5 : nil, height: edge.stacksVertically ? nil : 0.5) + } + } + + private var innerAlignment: Alignment { + switch edge { + case .leading: return .trailing + case .trailing: return .leading + case .top: return .bottom + case .bottom: return .top + } + } +} + +/// Small, soft material halos under floating controls — drawn only where a +/// control actually covers rendered Mac content (see +/// `ControlBackdropPolicy`), and never as one big panel. The only view that +/// observes the per-frame display footprint. +private struct ControlBackdropLayer: View { + @ObservedObject var store: DisplayFootprintStore + let circles: [CGRect] + /// Palette pills (Strip hints) are capsules, not circles. + let capsules: Bool + + private static let spread: CGFloat = 14 + + var body: some View { + ZStack(alignment: .topLeading) { + ForEach(Array(circles.enumerated()), id: \.offset) { _, rect in + let strength = ControlBackdropPolicy.haloStrength(for: rect, footprint: store.footprint) + if strength > 0 { + let size = CGSize(width: rect.width + Self.spread * 2, height: rect.height + Self.spread * 2) + halo(size: size, capsule: capsules && rect.width > rect.height * 1.5) + .opacity(strength) + .position(x: rect.midX, y: rect.midY) + } + } + } + .animation(.easeOut(duration: 0.15), value: store.footprint) + } + + @ViewBuilder + private func halo(size: CGSize, capsule: Bool) -> some View { + let fade = RadialGradient(colors: [.black, .black, .black.opacity(0)], center: .center, + startRadius: 0, endRadius: max(size.width, size.height) / 2) + Group { + if capsule { + Capsule().fill(.ultraThinMaterial) + } else { + Circle().fill(.ultraThinMaterial) + } + } + .frame(width: size.width, height: size.height) + .mask(fade) + } +} + private struct ControlRegionShape: Shape { - var tray: CGRect - var palette: CGRect? + var rects: [CGRect] func path(in rect: CGRect) -> Path { var path = Path() - path.addRect(tray) - if let palette { path.addRect(palette) } + for region in rects where !region.isEmpty { path.addRect(region) } return path } } diff --git a/iOS/ReceiverSettings.swift b/iOS/ReceiverSettings.swift new file mode 100644 index 00000000..fa952a26 --- /dev/null +++ b/iOS/ReceiverSettings.swift @@ -0,0 +1,1187 @@ +import SwiftUI +import UIKit + +// MARK: - Adaptive Settings + +/// Receiver Settings: one category model (`MobileSettingsCategory`) and one +/// set of pages for both size classes. Regular width (iPad) shows a native +/// sidebar with the selected page beside it; compact width (iPhone) +/// collapses the same split view into a category list with pushed pages. +/// +/// The root deliberately observes nothing: each page observes only what it +/// shows, so stream telemetry never rebuilds pages (or text fields) that +/// don't display it. +struct SettingsView: View { + let receiver: StreamReceiver + let controlStore: ReceiverControlStore + let pictureInPicture: ReceiverPictureInPictureController + let haptics: ReceiverHaptics + @Environment(\.dismiss) private var dismiss + @Environment(\.horizontalSizeClass) private var horizontalSizeClass + @State private var selection: MobileSettingsCategory? + @State private var columnVisibility = NavigationSplitViewVisibility.all + + var body: some View { + NavigationSplitView(columnVisibility: $columnVisibility) { + List(selection: $selection) { + ForEach(MobileSettingsCategory.Group.allCases, id: \.self) { group in + Section { + ForEach(MobileSettingsCategory.visibleInThisBuild.filter { $0.group == group }) { category in + NavigationLink(value: category) { + Label(category.title, systemImage: category.systemImage) + } + } + } + } + } + .navigationTitle("Settings") + .toolbar { + ToolbarItem(placement: .confirmationAction) { + Button("Done") { dismiss() } + } + } + } detail: { + NavigationStack { + if let selection { + page(for: selection) + } else { + Text("Select a category") + .foregroundStyle(.secondary) + } + } + // A new category starts at its own root, never inside the + // previous category's pushed pages. + .id(selection) + } + .navigationSplitViewStyle(.balanced) + .onAppear { + if selection == nil { + selection = MobileSettingsCategory.initialSelection(regularWidth: horizontalSizeClass == .regular) + } + } + } + + @ViewBuilder + private func page(for category: MobileSettingsCategory) -> some View { + switch category { + case .general: + GeneralSettingsPage(receiver: receiver, controlStore: controlStore) + case .display: + DisplaySettingsPage(receiver: receiver, controlStore: controlStore, pictureInPicture: pictureInPicture) + case .input: + InputSettingsPage(receiver: receiver, controlStore: controlStore) + case .gestures: + GestureSettingsPage(controlStore: controlStore) + case .controls: + ControlSettingsPage(controlStore: controlStore, haptics: haptics) + case .audio: + AudioSettingsPage(receiver: receiver, controlStore: controlStore) + case .connections: + ConnectionSettingsPage(receiver: receiver) + case .pairedMacs: + PairedMacsSettingsPage(receiver: receiver) + case .remoteAccess: + RemoteAccessSettingsPage(receiver: receiver) + case .permissions: + PermissionsSettingsPage() + case .diagnostics: + DiagnosticsSettingsPage() + case .about: + AboutSettingsPage() + case .developer: + #if DEBUG + DeveloperSettingsPage(receiver: receiver) + #else + EmptyView() + #endif + } + } +} + +/// Binds one receiver preference through `ReceiverControlStore.update`. +@MainActor +private func preferenceBinding(_ store: ReceiverControlStore, + _ keyPath: WritableKeyPath) -> Binding { + Binding(get: { store.preferences[keyPath: keyPath] }, + set: { value in store.update { $0[keyPath: keyPath] = value } }) +} + +/// A setting's secondary explanation line. +private struct SettingNote: View { + let text: LocalizedStringKey + init(_ text: LocalizedStringKey) { self.text = text } + var body: some View { + Text(text).font(.footnote).foregroundStyle(.secondary) + } +} + +// MARK: - General + +private struct GeneralSettingsPage: View { + let receiver: StreamReceiver + @ObservedObject var controlStore: ReceiverControlStore + + var body: some View { + Form { + Section { + // Isolated from the receiver: a TextField that rebuilds + // mid-tap loses focus (the "tap twice to edit" bug). This + // subview owns its focus and doesn't observe the receiver. + DeviceNameField { receiver.setServiceName($0) } + } header: { + Text("Name") + } footer: { + Text("Shown in the Mac app's WiFi connection menu. iOS hides this \(deviceKind)'s real name from apps, so set it here once.") + } + Section { + AutoReconnectToggle(receiver: receiver) + } header: { + Text("Connection") + } footer: { + Text("Automatically reconnect to paired devices after connection interruptions. Turning this off only stops automatic reconnecting — Connect, Reconnect, and Wake & Connect still work, and an active session stays connected. Also shown on the Home screen.") + } + Section { + Toggle("Haptics", isOn: preferenceBinding(controlStore, \.hapticsEnabled)) + } + } + .navigationTitle(MobileSettingsCategory.general.title) + } +} + +/// Its own view so only this toggle observes the receiver. +private struct AutoReconnectToggle: View { + @ObservedObject var receiver: StreamReceiver + var body: some View { + Toggle("Auto-Reconnect", isOn: $receiver.autoReconnectEnabled) + } +} + +/// The device-name editor, deliberately kept out of any high-frequency +/// @ObservedObject so streaming updates can't rebuild it and steal focus. +struct DeviceNameField: View { + @AppStorage("deviceName") private var deviceName = UIDevice.current.name + @FocusState private var focused: Bool + let onChange: (String) -> Void + + var body: some View { + TextField("Device name", text: $deviceName) + .textInputAutocapitalization(.words) + .autocorrectionDisabled() + .focused($focused) + .onChange(of: deviceName) { name in onChange(name) } + } +} + +// MARK: - Display + +private struct DisplaySettingsPage: View { + @ObservedObject var receiver: StreamReceiver + @ObservedObject var controlStore: ReceiverControlStore + @ObservedObject var pictureInPicture: ReceiverPictureInPictureController + @Environment(\.dismiss) private var dismiss + + var body: some View { + Form { + Section { + VStack(alignment: .leading, spacing: 4) { + Toggle("Video", isOn: Binding( + get: { receiver.videoEnabled }, + set: { receiver.requestVideoEnabled($0) })) + .disabled(!receiver.connected || !receiver.macSupportsVideoControl) + SettingNote("Turning video off keeps the connection, keyboard, controls, and selected input mode active.") + } + displayModeRows + Toggle("Show Surface Grid", isOn: preferenceBinding(controlStore, \.showSurfaceGrid)) + } + Section { + VStack(alignment: .leading, spacing: 4) { + Toggle("Picture in Picture", isOn: preferenceBinding(controlStore, \.pictureInPictureEnabled)) + SettingNote("Leaving MeowDisplay during a session keeps your Mac visible in a floating window. It’s view-only — control stays in MeowDisplay.") + if let note = pictureInPictureNote { + Text(note).font(.footnote).foregroundStyle(.secondary) + } + } + if pictureInPicture.isShowingWindow { + Button("Stop Picture in Picture") { pictureInPicture.stop() } + } else if pictureInPicture.availability == .available { + Button { + // Started from the tap itself: AVKit only honors a + // manual start made in response to user action. + pictureInPicture.start() + dismiss() + } label: { + Label("Start Picture in Picture", systemImage: "pip.enter") + } + } + } + streamingProfileSection + maxFPSSection + } + .navigationTitle(MobileSettingsCategory.display.title) + } + + @ViewBuilder + private var displayModeRows: some View { + if let confirmedMode = receiver.confirmedDisplayMode { + Picker("Display Mode", selection: Binding( + get: { receiver.pendingDisplayMode ?? confirmedMode }, + set: { receiver.requestDisplayMode($0) })) { + ForEach(ReceiverDisplayMode.allCases) { mode in + Text(mode.title).tag(mode) + .disabled(!receiver.videoEnabled && mode == .extend) + } + } + .disabled(!receiver.connected + || receiver.macProtocolVersion < WireProtocol.displayModeWireVersion + || receiver.pendingDisplayMode != nil) + if let pendingMode = receiver.pendingDisplayMode { + LabeledContent("Switching to \(pendingMode.title)") { + ProgressView() + } + } + if confirmedMode == .mirror, + receiver.macProtocolVersion >= WireProtocol.mirrorDisplayWireVersion { + mirrorDisplaySourcePicker + } + if confirmedMode == .extend, + receiver.macProtocolVersion >= WireProtocol.extendShapeWireVersion { + extendShapePicker + } + } else { + LabeledContent("Display Mode", + value: receiver.connected ? String(localized: "Waiting for Mac") : String(localized: "Unavailable")) + } + } + + /// Remote control for the Mac's own canonical Mirror capture source + /// (`SenderController.mirrorDisplayUUID`) — never an independent + /// iOS-only preference. `nil` selection means Auto, mirroring the Mac's + /// own nil-means-automatic semantic exactly (see + /// `Mac/MirrorDisplaySelection.swift`). + @ViewBuilder + private var mirrorDisplaySourcePicker: some View { + let state = receiver.mirrorDisplayState + Picker("Mirror Display", selection: Binding( + get: { state?.selectedUUID == nil ? "auto" : "manual" }, + set: { newValue in + if newValue == "auto" { + receiver.requestMirrorDisplaySelection(nil) + } else if let uuid = state?.selectedUUID ?? state?.displays.first?.uuid { + receiver.requestMirrorDisplaySelection(uuid) + } + })) { + Text("Auto").tag("auto") + Text("Manual").tag("manual") + } + .disabled(!receiver.connected || state == nil) + if let state, state.selectedUUID != nil { + if state.displays.isEmpty { + SettingNote("No displays reported.") + } else { + ForEach(state.displays, id: \.uuid) { display in + Button { + receiver.requestMirrorDisplaySelection(display.uuid) + } label: { + HStack { + Text(display.isMain ? String(localized: "\(display.name) (Main)", comment: "A display name, marked as the Mac's main display.") : display.name) + .foregroundStyle(.primary) + Spacer() + if state.selectedUUID == display.uuid { + Image(systemName: "checkmark").foregroundStyle(Color.accentColor) + } + } + } + } + if let selected = state.selectedUUID, !state.displays.contains(where: { $0.uuid == selected }) { + Text("Selected display unavailable") + .font(.footnote) + .foregroundStyle(.orange) + } + } + } + } + + /// Requests an Extend virtual-display shape change (PROTOCOL.md 6.7). + /// The Mac remains authoritative — this only requests; the shown value + /// always tracks `confirmedExtendShape`/`pendingExtendShape`. + @ViewBuilder + private var extendShapePicker: some View { + if let confirmed = receiver.confirmedExtendShape { + let current = receiver.pendingExtendShape ?? confirmed + Picker("Extend Shape", selection: Binding( + get: { current.shape }, + set: { shape in + var preference = current + preference.shape = shape + receiver.requestExtendShape(preference) + })) { + ForEach(ExtendDisplayShape.allCases) { shape in + Text(shape.title).tag(shape) + } + } + .disabled(!receiver.connected || receiver.pendingExtendShape != nil) + if current.shape == .automatic { + Toggle("Use Full Display", isOn: Binding( + get: { current.useFullDisplay }, + set: { value in + var preference = current + preference.useFullDisplay = value + receiver.requestExtendShape(preference) + })) + .disabled(!receiver.connected || receiver.pendingExtendShape != nil) + } + if receiver.pendingExtendShape != nil { + LabeledContent("Updating Extend shape…") { ProgressView() } + } + if let text = fpsLimitationText { + Text(text).font(.footnote).foregroundStyle(.secondary) + } + } else { + LabeledContent("Extend Shape", value: receiver.connected ? String(localized: "Waiting for Mac") : String(localized: "Unavailable")) + } + } + + /// Re-sent by the Mac on every capture (re)start, derived entirely from + /// `receiver.lastMaxFPSState` — never guessed from the shape alone. + private var fpsLimitationText: String? { + guard let state = receiver.lastMaxFPSState else { return nil } + if state.encoderSafeFPS >= state.requestedFPS { + return nil + } + return String(localized: "\(receiver.streamingProfile.label) requests \(state.requestedFPS) FPS. Limited to \(state.encoderSafeFPS) FPS at this display size.", + comment: "The first value is a streaming profile name, such as Performance.") + } + + @ViewBuilder + private var streamingProfileSection: some View { + Section("Streaming") { + let profileBinding = Binding( + get: { receiver.streamingProfile }, + set: { receiver.requestStreamingProfile($0, customFrameRate: receiver.customFrameRate) }) + Picker("Streaming Profile", selection: profileBinding) { + ForEach(StreamingProfile.allCases) { profile in + Text(profile.label).tag(profile) + } + } + Text(receiver.streamingProfile.explanation).font(.footnote).foregroundStyle(.secondary) + if receiver.streamingProfile == .custom { + let frameRateBinding = Binding( + get: { receiver.customFrameRate }, + set: { receiver.requestStreamingProfile(.custom, customFrameRate: $0) }) + Picker("Frame Rate", selection: frameRateBinding) { + ForEach(CustomFrameRateSelection.allCases) { frameRate in + Text(frameRate.label).tag(frameRate) + } + } + } + let priorityBinding = Binding( + get: { receiver.streamingPriority }, + set: { receiver.requestStreamingPriority($0) }) + Picker("Streaming Priority", selection: priorityBinding) { + ForEach(StreamingPriority.allCases) { priority in + Text(priority.label).tag(priority) + } + } + Text(receiver.streamingPriority.explanation).font(.footnote).foregroundStyle(.secondary) + } + } + + /// Receiver-enforced max-FPS control, request/confirm-aware — disabled + /// while a request is in flight, and only offering tiers the Mac says are + /// reachable right now. + @ViewBuilder + private var maxFPSSection: some View { + if receiver.macProtocolVersion >= WireProtocol.maxFPSWireVersion { + Section { + if let confirmed = receiver.confirmedMaxFPS { + let current = receiver.pendingMaxFPS ?? confirmed + Toggle("Enforce Maximum FPS", isOn: Binding( + get: { current.enabled }, + set: { enabled in + var preference = current + preference.enabled = enabled + receiver.requestMaxFPS(preference) + })) + .disabled(!receiver.connected || receiver.pendingMaxFPS != nil) + if current.enabled { + let tiers = MaxFPSPicker.tiers(reported: receiver.lastMaxFPSState?.availableTiers) + Picker("Maximum FPS", selection: Binding( + get: { MaxFPSPicker.selection(current, among: tiers) }, + set: { fps in + var preference = current + preference.maxFPS = fps + receiver.requestMaxFPS(preference) + })) { + ForEach(tiers, id: \.self) { fps in + Text("\(fps)").tag(fps) + } + } + .disabled(!receiver.connected || receiver.pendingMaxFPS != nil) + } + if receiver.pendingMaxFPS != nil { + LabeledContent("Updating Maximum FPS…") { ProgressView() } + } + } else { + LabeledContent("Maximum FPS", value: receiver.connected ? String(localized: "Waiting for Mac") : String(localized: "Unavailable")) + } + } footer: { + Text("Caps how fast this Mac streams to this device, on top of its normal profile/display limits.") + } + } + } + + /// Why Picture in Picture can't run even though it's turned on, when + /// that reason is something other than simply waiting for live video. + private var pictureInPictureNote: String? { + switch pictureInPicture.availability { + case .unsupported: + return String(localized: "Picture in Picture isn’t supported on this device.") + case .requiresSystemVideoLayer: + return String(localized: "Unavailable while the experimental Metal renderer is on.") + case .available, .turnedOff, .waitingForVideo: + return nil + } + } +} + +// MARK: - Input + +private struct InputSettingsPage: View { + let receiver: StreamReceiver + @ObservedObject var controlStore: ReceiverControlStore + @AppStorage("zoomWhileTyping") private var zoomWhileTyping = true + + var body: some View { + Form { + Section { + // Never optimistic: this only ever reflects the Mac's last + // CONFIRMED decision (`ReceiverControlStore.sessionInputState`). + LabeledContent("Control", value: controlStore.sessionInputState.receiverDisplayText) + switch controlStore.sessionInputState { + case .off, .notAllowed: + Button("Request Control") { receiver.requestAllowInput(true) } + case .requesting: + EmptyView() + case .allowed: + Button("Turn Off", role: .destructive) { receiver.requestAllowInput(false) } + case .requestsDisabled: + SettingNote("This Mac isn't accepting control requests from this device right now. Enable it from the Mac's Input settings.") + } + } footer: { + Text("Settings always stays reachable, even with input turned off.") + } + Section { + Picker("Input Mode", selection: preferenceBinding(controlStore, \.inputMode)) { + ForEach(PointerInputMode.allCases) { Text($0.title).tag($0) } + } + .pickerStyle(.segmented) + Text(controlStore.preferences.inputMode.explanation) + .font(.footnote) + .foregroundStyle(.secondary) + if controlStore.preferences.inputMode == .direct { + smartTouchRows + } + } header: { + Text("Input Mode") + } + Section { + HStack { + Text("Trackpad Sensitivity") + Spacer() + if controlStore.preferences.trackpadSensitivity != PointerGestureConfig.defaultTrackpadSensitivity { + Button("Reset") { + controlStore.update { $0.trackpadSensitivity = PointerGestureConfig.defaultTrackpadSensitivity } + } + .font(.footnote) + } + } + Slider(value: preferenceBinding(controlStore, \.trackpadSensitivity), + in: PointerGestureConfig.trackpadSensitivityRange, step: 0.1) { + Text("Trackpad Sensitivity") + } minimumValueLabel: { + Text("Slow") + } maximumValueLabel: { + Text("Fast") + } + .font(.footnote) + } footer: { + Text("Sensitivity only affects Trackpad mode's one-finger pointer movement.") + } + Section { + Toggle("Zoom While Typing", isOn: $zoomWhileTyping) + } header: { + Text("Keyboard") + } footer: { + Text("Enlarge the area you're typing in when the keyboard is open.") + } + } + .navigationTitle(MobileSettingsCategory.input.title) + } + + @ViewBuilder + private var smartTouchRows: some View { + Toggle(isOn: preferenceBinding(controlStore, \.smartTouchEnabled)) { + VStack(alignment: .leading, spacing: 2) { + HStack(spacing: 6) { + Text(verbatim: "Smart Touch") + Text("Experimental") + .font(.caption2.weight(.semibold)) + .padding(.horizontal, 6) + .padding(.vertical, 2) + .background(Capsule().fill(Color.orange.opacity(0.2))) + .foregroundStyle(.orange) + } + SettingNote("Scroll with one finger in any direction. Hold a title bar, then drag, to move its window. Elsewhere, touch and hold for normal Direct Touch. Falls back to Direct Touch when the Mac can't identify the area.") + } + } + if controlStore.preferences.smartTouchEnabled { + Toggle(isOn: preferenceBinding(controlStore, \.smartTouchLongPressHapticEnabled)) { + VStack(alignment: .leading, spacing: 2) { + Text("Smart Touch Haptics", comment: "Toggle label. \"Smart Touch\" is the feature's brand name and must stay untranslated; only \"Haptics\" is translatable.") + Text(controlStore.preferences.hapticsEnabled + ? "Builds while you hold a title bar, then ticks when the window can move. Also confirms switching to Direct Touch." + : "Off while Haptics is turned off.") + .font(.footnote) + .foregroundStyle(.secondary) + } + } + .disabled(!controlStore.preferences.hapticsEnabled) + } + } +} + +// MARK: - Gestures + +private struct GestureSettingsPage: View { + @ObservedObject var controlStore: ReceiverControlStore + + var body: some View { + Form { + Section { + targetPicker("Pinch / Zoom", \.pinchTarget) + targetPicker("Rotation", \.rotateTarget) + Toggle("Snap Rotation", isOn: preferenceBinding(controlStore, \.snapRotation)) + .disabled(controlStore.preferences.rotateTarget != .viewport) + if controlStore.preferences.pinchTarget == .app || controlStore.preferences.rotateTarget == .app { + NavigationLink("App Gesture Commands") { + AppGestureCommandsView(store: controlStore) + } + } + } header: { + Text("Pinch and Rotation") + } footer: { + Text("Video Off temporarily routes pinch and rotate to App without changing these saved choices. App mode is experimental: instead of injecting native gestures into the foreground app, it sends the keyboard commands configured in App Gesture Commands.") + } + Section { + Toggle("Prefer MeowDisplay Gestures", isOn: preferenceBinding(controlStore, \.preferMeowDisplayGestures)) + } footer: { + Text("While you’re controlling the Mac, MeowDisplay gets priority for the gestures it supports: the first swipe from a screen edge goes to the Mac, and three-finger swipes aren’t taken by editing shortcuts. Some \(deviceKind) and accessibility gestures always stay with the system, and VoiceOver always takes precedence.") + } + Section { + if controlStore.isPad { + LabeledContent("Move View", value: String(localized: "Drag to move, pinch to zoom")) + } + LabeledContent("Reset View", value: String(localized: "Double-tap with two fingers")) + } header: { + Text("Viewport") + } footer: { + if controlStore.isPad { + Text("Move View lets one finger move the picture — at any zoom, until an edge of your Mac reaches the middle of the screen — without scrolling or clicking on the Mac. Two-finger scrolling always goes to the Mac.") + } else { + Text("Pinch to zoom and move the picture, until an edge of your Mac reaches the middle of the screen. Two-finger scrolling always goes to the Mac.") + } + } + } + .navigationTitle(MobileSettingsCategory.gestures.title) + } + + private func targetPicker(_ title: LocalizedStringKey, + _ keyPath: WritableKeyPath) -> some View { + VStack(alignment: .leading, spacing: 4) { + Text(title) + Picker(title, selection: preferenceBinding(controlStore, keyPath)) { + ForEach(ReceiverGestureTarget.allCases) { Text($0.title).tag($0) } + } + .pickerStyle(.segmented) + .labelsHidden() + if controlStore.preferences[keyPath: keyPath] == .app { + SettingNote("Experimental app command mode") + } + } + } +} + +// MARK: - Controls + +private struct ControlSettingsPage: View { + @ObservedObject var controlStore: ReceiverControlStore + let haptics: ReceiverHaptics + @State private var confirmingReset = false + @State private var confirmingFunctionTrayReset = false + + private var visibility: PadControlSettingsVisibility { + PadControlSettingsVisibility(isPad: controlStore.isPad, layout: controlStore.preferences.padControlLayout) + } + + var body: some View { + Form { + if visibility.showsLayoutPicker { + padLayoutSection + } + if visibility.showsCustomLayouts { + CustomLayoutListSection(store: controlStore) + } + Section { + Toggle("Show Control Tray", isOn: preferenceBinding(controlStore, \.trayEnabled)) + .disabled(!controlStore.preferences.allowInput) + Toggle("Show Keyboard Button", isOn: preferenceBinding(controlStore, \.keyboardButtonEnabled)) + Toggle("Collapse Control Tray", isOn: preferenceBinding(controlStore, \.trayCollapsed)) + Toggle("Auto-hide Control Trays", isOn: preferenceBinding(controlStore, \.autoHideEnabled)) + if visibility.showsPhoneTrayPlacement { + Picker("Landscape Tray Side", selection: preferenceBinding(controlStore, \.preferredLandscapeSide)) { + ForEach(LandscapeTraySide.allCases) { Text($0.title).tag($0) } + } + .pickerStyle(.segmented) + VStack(alignment: .leading, spacing: 4) { + Toggle("Avoid Notch", isOn: preferenceBinding(controlStore, \.avoidNotch)) + SettingNote("Keeps controls clear of the iPhone’s notch or Dynamic Island in landscape.") + Text("Experimental").font(.caption2).foregroundStyle(.tertiary) + } + } + } header: { + Text("Main Controls") + } footer: { + if controlStore.isPad { + Text("Auto-hide fades Overlay and Custom controls after a few seconds without use. The Strip always stays visible.") + } + } + Section { + Picker("Active Profile", selection: Binding( + get: { controlStore.preferences.activeControlProfile }, + set: { profile in + controlStore.update { $0.activeControlProfile = profile } + haptics.play(.profileChange) + })) { + ForEach(ControlProfileSlot.allCases) { Text($0.title).tag($0) } + } + NavigationLink("Edit Current Profile") { + ControlProfileEditor(store: controlStore, haptics: haptics) + } + Button("Reset Profile to Default", role: .destructive) { + confirmingReset = true + } + } header: { + Text("Control Profile") + } footer: { + Text("Which controls appear, and the shortcuts each modifier chord offers.") + } + Section { + Toggle("Show Function Tray", isOn: preferenceBinding(controlStore, \.functionTrayEnabled)) + .disabled(!controlStore.preferences.allowInput) + if visibility.showsPhoneTrayPlacement { + Picker("Function Tray Position", selection: preferenceBinding(controlStore, \.functionTrayPosition)) { + ForEach(FunctionTrayPosition.allCases) { Text($0.title).tag($0) } + } + .pickerStyle(.segmented) + } + Picker("Function Profile", selection: Binding( + get: { controlStore.preferences.activeFunctionTrayProfile }, + set: { profile in + controlStore.update { $0.activeFunctionTrayProfile = profile } + haptics.play(.profileChange) + })) { + ForEach(ControlProfileSlot.allCases) { Text($0.title).tag($0) } + } + NavigationLink("Edit Function Tray") { + FunctionTrayProfileEditor(store: controlStore) + } + Button("Reset Function Tray to Default", role: .destructive) { + confirmingFunctionTrayReset = true + } + } header: { + Text("Function Tray") + } footer: { + if visibility.showsPhoneTrayPlacement { + Text("A second, independent tray of one-tap shortcuts (Undo, Redo, …), separate from the Main Tray above. \"Same Side\" groups it with the Main Tray; \"Opposite Side\" puts it on the other edge of the screen.") + } else { + Text("A second, independent set of one-tap shortcuts (Undo, Redo, …), separate from the Main controls.") + } + } + } + .navigationTitle(MobileSettingsCategory.controls.title) + .confirmationDialog("Reset \(controlStore.preferences.activeControlProfile.title)?", + isPresented: $confirmingReset, titleVisibility: .visible) { + Button("Reset Profile", role: .destructive) { + controlStore.update { $0.resetProfile($0.activeControlProfile) } + haptics.play(.reset) + } + } message: { + Text("This restores its tray layout and shortcut palettes. Other receiver settings stay unchanged.") + } + .confirmationDialog("Reset \(controlStore.preferences.activeFunctionTrayProfile.title)?", + isPresented: $confirmingFunctionTrayReset, titleVisibility: .visible) { + Button("Reset Function Tray", role: .destructive) { + controlStore.update { $0.resetFunctionTrayProfile($0.activeFunctionTrayProfile) } + haptics.play(.reset) + } + } message: { + Text("This restores its default Undo/Redo layout. Other receiver settings stay unchanged.") + } + } + + /// iPad only (see `PadControlSettingsVisibility`). + @ViewBuilder + private var padLayoutSection: some View { + Section { + Picker("Control Layout", selection: Binding( + get: { controlStore.preferences.padControlLayout }, + set: { mode in + controlStore.update { preferences in + preferences.padControlLayout = mode + // Custom always has something to show. + if mode == .custom, preferences.customLayouts.isEmpty { + preferences.createCustomLayout() + } + } + })) { + ForEach(PadControlLayoutMode.allCases) { Text($0.title).tag($0) } + } + .pickerStyle(.segmented) + if visibility.showsEdgePickers { + Picker("Main Controls", selection: preferenceBinding(controlStore, \.padMainEdge)) { + ForEach(ControlEdge.allCases) { Text($0.title).tag($0) } + } + Picker("Function Controls", selection: preferenceBinding(controlStore, \.padFunctionEdge)) { + ForEach(ControlEdge.allCases) { Text($0.title).tag($0) } + } + Toggle("Show Move View Control", isOn: preferenceBinding(controlStore, \.padShowMoveViewControl)) + } + if visibility.showsControlHints { + Toggle("Show Control Hints", isOn: preferenceBinding(controlStore, \.padShowControlHints)) + } + VStack(alignment: .leading, spacing: 6) { + HStack { + Text("Control Size") + Spacer() + Text(controlStore.preferences.padControlScale, format: .percent.precision(.fractionLength(0))) + .foregroundStyle(.secondary) + .monospacedDigit() + if controlStore.preferences.padControlScale != PadControlScale.defaultValue { + Button("Reset") { controlStore.update { $0.padControlScale = PadControlScale.defaultValue } } + .font(.footnote) + .buttonStyle(.borderless) + } + } + Slider(value: Binding( + get: { controlStore.preferences.padControlScale }, + set: { value in controlStore.update { $0.padControlScale = PadControlScale.clamped(value) } }), + in: PadControlScale.range, step: 0.05) { + Text("Control Size") + } minimumValueLabel: { + Image(systemName: "circle.fill").font(.system(size: 8)).accessibilityHidden(true) + } maximumValueLabel: { + Image(systemName: "circle.fill").font(.system(size: 16)).accessibilityHidden(true) + } + } + } header: { + Text("Control Layout") + } footer: { + switch controlStore.preferences.padControlLayout { + case .strip: + Text("A solid rail at the edge of the screen holds the controls, and your Mac fits beside it.") + case .overlay: + Text("Your Mac uses the whole screen and the controls float over it.") + case .custom: + Text("Place every control yourself, with separate landscape and portrait layouts.") + } + } + } +} + +// MARK: - Audio + +private struct AudioSettingsPage: View { + @ObservedObject var receiver: StreamReceiver + @ObservedObject var controlStore: ReceiverControlStore + + var body: some View { + Form { + Section { + VStack(alignment: .leading, spacing: 4) { + Toggle("Audio", isOn: Binding( + get: { controlStore.preferences.audioPreferred }, + set: { value in + controlStore.update { $0.audioPreferred = value } + receiver.requestAudioEnabled(value) + })) + .disabled(!receiver.connected || !receiver.macSupportsAudio) + SettingNote("Plays a copy of what the Mac is playing. It keeps playing there too — this never changes the Mac's output device.") + } + } + Section { + VStack(alignment: .leading, spacing: 6) { + HStack { + Text("A/V Sync") + Spacer() + Text(avSyncOffsetLabel(controlStore.preferences.avSyncOffsetMs)) + .foregroundStyle(.secondary) + if controlStore.preferences.avSyncOffsetMs != 0 { + Button("Reset") { + controlStore.update { $0.avSyncOffsetMs = 0 } + } + .font(.footnote) + .buttonStyle(.borderless) + } + } + Slider(value: Binding( + get: { Double(controlStore.preferences.avSyncOffsetMs) }, + set: { value in + let stepped = Int((value / Double(AVSyncOffset.stepMs)).rounded()) * AVSyncOffset.stepMs + controlStore.update { $0.avSyncOffsetMs = AVSyncOffset.clamped(stepped) } + }), + in: Double(AVSyncOffset.range.lowerBound)...Double(AVSyncOffset.range.upperBound), + step: Double(AVSyncOffset.stepMs)) + SettingNote("Adjust if sound plays slightly before or after the picture.") + } + VStack(alignment: .leading, spacing: 4) { + Button("Resync") { + receiver.resync() + } + .disabled(!receiver.connected || !receiver.audioEnabled) + SettingNote("If audio drifts or stutters, Resync re-establishes timing without reconnecting. It doesn't change your A/V Sync adjustment above.") + } + } + .disabled(!controlStore.preferences.audioPreferred) + } + .navigationTitle(MobileSettingsCategory.audio.title) + } + + private func avSyncOffsetLabel(_ ms: Int) -> String { + ms == 0 ? "0 ms" : (ms > 0 ? "+\(ms) ms" : "\(ms) ms") + } +} + +// MARK: - Connections + +private struct ConnectionSettingsPage: View { + @ObservedObject var receiver: StreamReceiver + + var body: some View { + Form { + Section("Status") { + LabeledContent("Connection", + value: receiver.connected ? receiver.status : receiver.canonicalPhaseTitle) + if receiver.videoSize != .zero { + LabeledContent("Stream", + value: "\(Int(receiver.videoSize.width))×\(Int(receiver.videoSize.height)) @ \(receiver.fps) fps") + } + if receiver.connected { + Button("Disconnect", role: .destructive) { + receiver.disconnect() + } + } + } + Section { + Label("USB: plug in the cable, run the Mac app — it connects automatically through the wire (lowest latency).", + systemImage: "cable.connector") + Label("WiFi: both devices on the same network, then pick this \(deviceKind) in the Mac app's Connection menu.", + systemImage: "wifi") + Label("Rotate the \(deviceKind) for a vertical second monitor.", + systemImage: "rectangle.portrait.rotate") + Label("Touch: tap to click, drag to drag, two-finger pan to scroll.", + systemImage: "hand.tap") + } header: { + Text("How to connect") + } + } + .navigationTitle(MobileSettingsCategory.connections.title) + } +} + +// MARK: - Paired Macs + +private struct PairedMacsSettingsPage: View { + let receiver: StreamReceiver + @State private var trustRefresh = 0 + @State private var forgetConfirmation = PeerForgetPrompt() + + var body: some View { + Form { + Section { + let peers = TrustStore.shared.pinnedPeers() + if peers.isEmpty { + Text("No paired Macs").foregroundStyle(.secondary) + } else { + AutomaticallyAllowConnectionsToggle() + ForEach(peers, id: \.peerID) { peer in + HStack { + Text(peer.displayName) + Spacer() + Button("Forget", role: .destructive) { + forgetConfirmation.request(peerID: peer.peerID, name: peer.displayName) + } + .buttonStyle(.borderless) + } + IncomingSessionPolicyPicker(peerID: peer.peerID, receiver: receiver) + .font(.subheadline) + } + } + } footer: { + Text("Connection Requests: Default follows Automatically Allow Connections. Blocking keeps the Mac paired.") + } + .id(trustRefresh) + } + .navigationTitle(MobileSettingsCategory.pairedMacs.title) + .alert("Forget \u{201C}\(forgetConfirmation.candidate?.name ?? "This Mac")\u{201D}?", + isPresented: Binding(get: { forgetConfirmation.isPresented }, + set: { if !$0 { forgetConfirmation.cancel() } })) { + Button("Forget", role: .destructive) { + forgetConfirmation.confirm { peerID in + receiver.forgetPeer(peerID) + receiver.pairingPrompt.cancel() + trustRefresh &+= 1 + } + } + Button("Cancel", role: .cancel) { forgetConfirmation.cancel() } + } message: { + Text("You'll need to pair with this Mac again before connecting.") + } + } +} + +// MARK: - Remote Access + +private struct RemoteAccessSettingsPage: View { + let receiver: StreamReceiver + + var body: some View { + if TrustStore.shared.pinnedPeers().isEmpty { + Form { + Section { + Text("Pair with a Mac first, then set up Remote Access for it here.") + .foregroundStyle(.secondary) + } + } + .navigationTitle(MobileSettingsCategory.remoteAccess.title) + } else { + RemoteAccessSettingsView(receiver: receiver) + } + } +} + +// MARK: - Permissions + +private struct PermissionsSettingsPage: View { + var body: some View { + Form { + Section { + Button("Open iOS Settings for MeowDisplay") { + if let url = URL(string: UIApplication.openSettingsURLString) { + UIApplication.shared.open(url) + } + } + } footer: { + Text("WiFi mode needs Local Network access. If your Mac can't find this \(deviceKind), enable it under Settings → Privacy & Security → Local Network → MeowDisplay. USB mode works without it.") + } + } + .navigationTitle(MobileSettingsCategory.permissions.title) + } +} + +// MARK: - Diagnostics + +private struct DiagnosticsSettingsPage: View { + @AppStorage("showAnalytics") private var showAnalytics = false + @AppStorage("metalRenderer") private var metalRenderer = false + + var body: some View { + Form { + Section { + NavigationLink { + DiagnosticsLogView() + } label: { + Label("Connection log", systemImage: "doc.text.magnifyingglass") + } + } footer: { + Text("What this \(deviceKind) saw while connecting: sessions, restarts, decoder trouble. No screen content and nothing leaves the \(deviceKind) unless you share it. Attach it to a GitHub issue if a connection won't come up.") + } + Section { + Toggle("Performance overlay", isOn: $showAnalytics) + Toggle("Metal renderer (experimental)", isOn: $metalRenderer) + } header: { + Text("Analytics") + } footer: { + Text("The overlay shows FPS, bitrate, frame timing, stalls, and latency graphs at the bottom of the screen while streaming. The experimental Metal renderer decodes and presents frames manually — it adds decode and true on-glass latency metrics to the overlay, but in our measurements the system video layer displays frames faster. Leave it off unless you're debugging.") + } + } + .navigationTitle(MobileSettingsCategory.diagnostics.title) + } +} + +// MARK: - About + +private struct AboutSettingsPage: View { + // Cat Mode (hidden easter egg — nine taps on the version row). Local-only + // presentation state: never synced, never on the wire. + @AppStorage(CatMode.unlockedDefaultsKey) private var catModeUnlocked = false + @AppStorage(CatMode.enabledDefaultsKey) private var catModeEnabledStorage = false + @AppStorage(CatMode.tapCountDefaultsKey) private var catModeTapCount = 0 + @State private var showCatModeUnlockedAlert = false + + private var catModeEnabled: Bool { + CatMode.resolveEnabled(requestedEnabled: catModeEnabledStorage, unlocked: catModeUnlocked) + } + + private var version: String { + Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "dev" + } + + var body: some View { + Form { + Section { + LabeledContent("Version", value: version) + // Hidden unlock gesture: nine taps here (a cat's nine + // lives) reveals Cat Mode below. No visible affordance + // before unlock. + .contentShape(Rectangle()) + .onTapGesture { registerCatModeTap() } + Link(destination: macAppURL) { + Label("GitHub — raiseCatError/MeowDisplay", systemImage: "link") + } + if catModeUnlocked { + Toggle(isOn: Binding( + get: { catModeEnabled }, + set: { catModeEnabledStorage = CatMode.resolveEnabled(requestedEnabled: $0, unlocked: catModeUnlocked) })) { + Label("Cat Mode", systemImage: "pawprint.fill") + } + } + } header: { + HStack(spacing: 4) { + Text("About") + if catModeEnabled { + Image(systemName: "pawprint.fill").accessibilityHidden(true) + } + } + } + Section { + Link(destination: macAppURL) { + Label("Get the Mac app", systemImage: "arrow.down.circle") + } + } footer: { + Text("MeowDisplay needs the Mac app running on a Mac on the same cable or WiFi network. Download it here if you haven't yet.") + } + } + .navigationTitle(MobileSettingsCategory.about.title) + .alert("Cat Mode unlocked 🐾", isPresented: $showCatModeUnlockedAlert) { + Button("Nice", role: .cancel) {} + } + } + + private func registerCatModeTap() { + let result = CatMode.registerTap(tapCount: catModeTapCount, alreadyUnlocked: catModeUnlocked) + catModeTapCount = result.tapCount + if result.justUnlocked { + catModeUnlocked = true + showCatModeUnlockedAlert = true + } + } +} + +// MARK: - Developer + +#if DEBUG +private struct DeveloperSettingsPage: View { + let receiver: StreamReceiver + @AppStorage("notchDebugOverlay") private var notchDebugOverlayEnabled = false + // Developer / Audio Diagnostics (receiver-side AAC investigation): a + // physical iPhone's sandboxed UserDefaults can't receive a Mac + // terminal's `defaults write`, so these need an in-app control during a + // real on-device retest — see `StreamReceiver`'s matching keys, all read + // fresh (never latched) so a toggle here takes effect immediately. + @AppStorage("audioPlaybackPath") private var audioPlaybackPath = "pcmEngine" + @AppStorage("audioPCMSchedulingMode") private var audioPCMSchedulingMode = "continuous" + @AppStorage("audioReceiverLocalDecode") private var audioReceiverLocalDecode = false + @AppStorage("audioReceiverDumpEnabled") private var audioReceiverDumpEnabled = false + @AppStorage("audioAACIntegrityLogging") private var audioAACIntegrityLogging = false + @AppStorage(CatMode.unlockedDefaultsKey) private var catModeUnlocked = false + @AppStorage(CatMode.enabledDefaultsKey) private var catModeEnabledStorage = false + @AppStorage(CatMode.tapCountDefaultsKey) private var catModeTapCount = 0 + + var body: some View { + Form { + Section { + Toggle("Notch Debug Overlay", isOn: $notchDebugOverlayEnabled) + } footer: { + Text("Draws the computed unsafe/obstacle regions (red) and the raw vs. Avoid-Notch-adjusted Main Tray frame (yellow/green) directly over the stream.") + } + Section { + LabeledContent("Production Codec", value: "AAC") + Picker("Playback Path", selection: $audioPlaybackPath) { + Text("PCM Engine").tag("pcmEngine") + Text("Legacy SampleBuffer Renderer").tag("legacyRenderer") + } + .onChange(of: audioPlaybackPath) { path in + Log.info("audioTrace: playbackPath=\(path)") + } + Picker("PCM Scheduling", selection: $audioPCMSchedulingMode) { + Text("Continuous").tag("continuous") + Text("Precise Scheduled").tag("preciseScheduled") + } + .onChange(of: audioPCMSchedulingMode) { mode in + Log.info("audioTrace: audioPCMSchedulingMode=\(mode)") + } + Toggle("Receiver Local AAC Decode", isOn: $audioReceiverLocalDecode) + .onChange(of: audioReceiverLocalDecode) { enabled in + Log.info("audioTrace: audioReceiverLocalDecode=\(enabled)") + } + Toggle("AAC Integrity Logging", isOn: $audioAACIntegrityLogging) + .onChange(of: audioAACIntegrityLogging) { enabled in + Log.info("audioTrace: audioAACIntegrityLogging=\(enabled)") + } + Toggle("Audio Comparison Dump", isOn: $audioReceiverDumpEnabled) + .onChange(of: audioReceiverDumpEnabled) { enabled in + Log.info("audioTrace: audioReceiverDumpEnabled=\(enabled)") + } + Button("Reset Audio Diagnostics", role: .destructive) { + audioPlaybackPath = "pcmEngine" + audioPCMSchedulingMode = "continuous" + audioReceiverLocalDecode = false + audioAACIntegrityLogging = false + audioReceiverDumpEnabled = false + Log.info("audioTrace: audio diagnostics reset to defaults") + } + } header: { + Text("Audio Diagnostics") + } footer: { + Text("PCM Engine is the default production audio path: AAC is still the only thing sent over the network, decoded on this \(deviceKind) and played through AVAudioEngine. Legacy SampleBuffer Renderer is the older AVSampleBufferAudioRenderer path, kept as a fallback/reference. PCM Scheduling controls how PCM Engine schedules buffers: Continuous (default) chains them on the player's own timeline after one startup anchor; Precise Scheduled independently re-targets every buffer from its own capture timestamp — this reintroduces electrical/robotic noise and exists only for A/B comparison. Receiver Local AAC Decode independently decodes received AAC and logs decode anomalies (clipping, discontinuities, NaN/Inf). AAC Integrity Logging adds a periodic checksum you can compare against the Mac's own log for the same packet. Audio Comparison Dump writes ~5s of the locally-decoded audio to a file in this app's Documents folder (Files app → On My \(deviceKind) → MeowDisplay) once Local AAC Decode is also on. All diagnostics off by default; a fresh Audio Off→On or reconnect applies a change.") + } + Section { + WakeTestingView() + } header: { + Text("Wake Testing") + } footer: { + Text("Sends a standard Wake-on-LAN magic packet to an already-paired Mac's last-learned local network address. Same-LAN only — this never uses Remote/Tailscale.") + } + Section { + PromoteInteractiveWakeView(receiver: receiver) + } header: { + Text("Promote Interactive Wake") + } footer: { + Text("Manual diagnostic, not automated: asks the connected Mac to declare remote user activity, to test whether that promotes a dark/network wake into a full interactive wake.") + } + Section { + Button("Reset Cat Mode", role: .destructive) { + catModeUnlocked = false + catModeEnabledStorage = false + catModeTapCount = 0 + } + } header: { + Text("Cat Mode") + } footer: { + Text("Re-locks the About/version row's nine-tap easter egg for retesting the unlock flow.") + } + } + .navigationTitle(MobileSettingsCategory.developer.title) + } +} +#endif diff --git a/iOS/RemotePairingView.swift b/iOS/RemotePairingView.swift index 26f5e8d6..ebdc8f2d 100644 --- a/iOS/RemotePairingView.swift +++ b/iOS/RemotePairingView.swift @@ -102,14 +102,37 @@ struct RemotePairingView: View { } /// Small transient confirmation; not a notification framework. +/// Compact success banner. Reads on black, on bright desktop content and +/// over the Home screen: a nearly opaque system background (not a thin +/// material that takes on whatever is behind it), a green success mark, +/// primary text, and a hairline border plus soft shadow for separation. struct PairedToast: View { var body: some View { - VStack(spacing: 2) { - Text("Paired Successfully").font(.subheadline.weight(.semibold)) - Text("You can now connect to this Mac.").font(.caption).foregroundStyle(.secondary) + HStack(spacing: 12) { + Image(systemName: "checkmark.circle.fill") + .font(.title2) + .symbolRenderingMode(.palette) + .foregroundStyle(.white, .green) + .accessibilityHidden(true) + VStack(alignment: .leading, spacing: 1) { + Text("Paired Successfully") + .font(.subheadline.weight(.semibold)) + .foregroundStyle(.primary) + Text("You can now connect to this Mac.") + .font(.footnote) + .foregroundStyle(.secondary) + } } - .padding(.horizontal, 16).padding(.vertical, 10) - .background(.regularMaterial, in: RoundedRectangle(cornerRadius: 14)) + .padding(.leading, 14) + .padding(.trailing, 18) + .padding(.vertical, 11) + .background(Color(.secondarySystemGroupedBackground).opacity(0.97), + in: RoundedRectangle(cornerRadius: 16, style: .continuous)) + .overlay(RoundedRectangle(cornerRadius: 16, style: .continuous) + .strokeBorder(Color.green.opacity(0.35), lineWidth: 1)) + .shadow(color: .black.opacity(0.28), radius: 14, y: 4) + .frame(maxWidth: 420) .accessibilityElement(children: .combine) + .accessibilityAddTraits(.isStaticText) } } diff --git a/project.yml b/project.yml index 4fcb4e3e..eb111e77 100644 --- a/project.yml +++ b/project.yml @@ -249,6 +249,9 @@ targets: - Shared/ControlProfile.swift - Shared/ControlInteraction.swift - Shared/ControlTrayGeometry.swift + - Shared/PadControlLayout.swift + - Shared/CustomControlLayout.swift + - Shared/MobileSettingsCategory.swift - Shared/Pairing.swift - Shared/PairingPromptModel.swift - Shared/RemotePairing.swift From 84fd41f03db2df1c6ccbbd6f9286173ee469d0f3 Mon Sep 17 00:00:00 2001 From: raiseCatError <315733358+raiseCatError@users.noreply.github.com> Date: Wed, 30 Sep 2026 04:57:44 +0530 Subject: [PATCH 07/30] feat: refine iPad/iPhone receiver controls from physical QA MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rail and controls: - Strip/Overlay rail groups: system actions (Menu Bar, Dock, Show Desktop, Control Center) at the rail's start, modifiers and keys centered, Move View and Settings at its end. A lane packer keeps Main and Function groups from ever overlapping on a shared edge, moving overflow to an inner lane (a Strip widens to hold it). - Standard Controls: per-control visibility on iPad; Settings always stays. - Overlay groups sit in compact dark trays; the per-control blur/backdrop and its display-footprint plumbing are removed. Control Hints default off. - Menu Bar (Control-F2) and Control Center (Fn-C) are new semantic gestures on the existing path; older Macs ignore them. - While the software keyboard is up, a keyboard accessory bar (⌘ ⌥ ⌃ ⇧ esc tab, hide) replaces the normal controls; its modifiers are the same chord state, and a latched chord combines with typed keys (⌘ then C → ⌘C). Normal controls return unchanged afterwards. Actions: - Editable Function Tray: add, remove, reorder, rename, and a face (SF Symbol, text, emoji, keys) stored apart from the action. - One-tap keyboard chords (including multi-key) and bounded sequences of existing keyboard/semantic actions, with balanced key events. - Chord palettes take user-defined actions, edited from the Control Profile or a Custom layout's modifier; runtime and Preview read the same data. Custom layouts: - Concentric corner arcs: Settings → utilities → modifiers → palette on the next arc; Two-Hand Assist mirrors the modifier arc; Arc fans into free space from any corner or moved anchor; new Wheel palette style. - Full-screen editor with Edit/Preview, Alignment Guides, Snap to Edges and Snap to Guides, contextual help, a dormant Two-Hand Assist while editing, per-layout Function profile, system controls, and shortcut buttons. - MDL1 share codes with validated import (as a pushed page); custom actions a layout uses travel with it as new local definitions. Input and viewport: - Allow Local View Navigation (default on) is separate from Allow Input: pan/zoom/rotate/reset/Move View work with input off and send nothing. - A blocked remote touch shows an ephemeral Request/Enable Input prompt. It never changes a setting; automatic requests need the explicit per-Mac Request Input Automatically switch (not the connection Always Allow policy), are rate-limited, and the Mac still decides. - Per-axis scroll inversion (both on by default). Settings: search on iPad and iPhone over a shared index, scroll-to and highlight of results, and Settings owning its status bar so its header stays below the system area. Preferences schema 16 → 18 with one-time migrations; explicit choices are never re-migrated. --- Mac/InputRouting.swift | 8 + MacTests/CustomLayoutRefinementTests.swift | 450 ++++++ MacTests/PadControlLayoutTests.swift | 434 ++++-- MacTests/ReceiverControlsTests.swift | 5 +- MacTests/SecondQARefinementTests.swift | 505 +++++++ MacTests/ThirdQARefinementTests.swift | 200 +++ PROTOCOL.md | 6 + Shared/ControlProfile.swift | 354 ++++- Shared/CustomControlLayout.swift | 577 +++++++- Shared/CustomLayoutSharing.swift | 342 +++++ Shared/MobileSettingsSearch.swift | 164 +++ Shared/PadControlLayout.swift | 640 +++++--- Shared/ReceiverGesture.swift | 30 +- Shared/ReceiverInputPrompt.swift | 178 +++ Shared/RemoteViewport.swift | 67 + Shared/StreamReceiver.swift | 1 + iOS/CustomLayoutEditor.swift | 1544 ++++++++++++++++---- iOS/FunctionTrayEditor.swift | 573 ++++++++ iOS/OpenSidecarPhoneApp.swift | 328 ++++- iOS/ReceiverControls.swift | 958 +++++++----- iOS/ReceiverSettings.swift | 282 +++- project.yml | 3 + 22 files changed, 6470 insertions(+), 1179 deletions(-) create mode 100644 MacTests/CustomLayoutRefinementTests.swift create mode 100644 MacTests/SecondQARefinementTests.swift create mode 100644 MacTests/ThirdQARefinementTests.swift create mode 100644 Shared/CustomLayoutSharing.swift create mode 100644 Shared/MobileSettingsSearch.swift create mode 100644 Shared/ReceiverInputPrompt.swift create mode 100644 iOS/FunctionTrayEditor.swift diff --git a/Mac/InputRouting.swift b/Mac/InputRouting.swift index bbdda922..e9d09031 100644 --- a/Mac/InputRouting.swift +++ b/Mac/InputRouting.swift @@ -362,6 +362,14 @@ enum SystemGestureShortcutMapping { case .spotlight: // Command-Space opens Spotlight. return SystemGestureShortcut(keyCode: 49, flags: .maskCommand) + case .controlCenter: + // Fn/Globe-C: "Show or hide Control Center" (macOS 12+). + return SystemGestureShortcut(keyCode: 8, flags: .maskSecondaryFn) + case .menuBar: + // Control-F2: "Move focus to the menu bar" — reveals it even in + // full screen; Escape (or a click elsewhere) leaves it. F2 needs + // SecondaryFn in a posted CGEvent, like F11 above. + return SystemGestureShortcut(keyCode: 120, flags: [.maskControl, .maskSecondaryFn]) } } } diff --git a/MacTests/CustomLayoutRefinementTests.swift b/MacTests/CustomLayoutRefinementTests.swift new file mode 100644 index 00000000..01a73bae --- /dev/null +++ b/MacTests/CustomLayoutRefinementTests.swift @@ -0,0 +1,450 @@ +import CoreGraphics +import XCTest + +/// Concentric radial layouts, mirrored Two-Hand Assist, one-tap keyboard +/// chords, share codes, the editor Preview, Settings search, and the Local +/// View Navigation / Allow Input split. +final class CustomLayoutRefinementTests: XCTestCase { + private let base: CGFloat = 44 + + private func referenceArea(portrait: Bool) -> CGRect { + CGRect(origin: .zero, size: portrait ? CustomControlArrangement.referencePortrait + : CustomControlArrangement.referenceLandscape) + } + + private func center(_ rect: CGRect) -> CGPoint { CGPoint(x: rect.midX, y: rect.midY) } + + private func distance(_ a: CGPoint, _ b: CGPoint) -> CGFloat { hypot(a.x - b.x, a.y - b.y) } + + /// Unwrapped angles of `points` around `origin`, relative to the + /// corner's horizontal edge, in sweep order (0...π/2). + private func sweepOffsets(_ points: [CGPoint], origin: CGPoint, corner: ControlCorner) -> [CGFloat] { + let start = CornerArcGeometry.horizontalEdgeAngle(corner) + let sign: CGFloat = CornerArcGeometry.sweep(corner) > 0 ? 1 : -1 + return points.map { point in + ManualViewportState.normalizedAngle((atan2(point.y - origin.y, point.x - origin.x) - start) * sign) + } + } + + // MARK: - Concentric radial template + + func testTemplateIsSettingsThenUtilitiesThenModifiersOnConcentricArcs() throws { + for corner in ControlCorner.allCases { + for portrait in [false, true] { + let label = "\(corner) portrait=\(portrait)" + let arrangement = CustomControlArrangement.radialTemplate(corner: corner, portrait: portrait) + let area = referenceArea(portrait: portrait) + let frames = CustomLayoutGeometry.frames(for: arrangement, in: area, baseDiameter: base) + let origin = center(try XCTUnwrap(frames["settings"])) + let utilities = CustomControlArrangement.templateUtilities.map { center(frames[$0.stableID]!) } + let modifiers = CustomControlArrangement.templateModifiers.map { center(frames[$0.rawValue]!) } + let utilityRadii = utilities.map { distance($0, origin) } + let modifierRadii = modifiers.map { distance($0, origin) } + // Every layer is one true arc: a single radius. + for radius in utilityRadii { XCTAssertEqual(radius, utilityRadii[0], accuracy: 0.5, label) } + for radius in modifierRadii { XCTAssertEqual(radius, modifierRadii[0], accuracy: 0.5, label) } + // Settings → utilities → modifiers, each layer clear of the last. + XCTAssertGreaterThanOrEqual(utilityRadii[0], base * 0.9 + 8 - 0.5, label) + XCTAssertGreaterThanOrEqual(modifierRadii[0] - utilityRadii[0], + base * 0.45 + base / 2 + 8 - 0.5, label) + // Even angular distribution, in order along the sweep. + for points in [utilities, modifiers] { + let offsets = sweepOffsets(points, origin: origin, corner: corner) + let steps = zip(offsets, offsets.dropFirst()).map { $1 - $0 } + for step in steps { + XCTAssertGreaterThan(step, 0, "\(label) ordered") + XCTAssertEqual(step, steps[0], accuracy: 0.01, "\(label) evenly spaced") + } + for offset in offsets { + XCTAssertGreaterThanOrEqual(offset, -0.001, label) + XCTAssertLessThanOrEqual(offset, .pi / 2 + 0.001, label) + } + } + } + } + } + + func testArcAnglesSweepFromEachCornersHorizontalEdgeTowardItsVerticalEdge() { + let expected: [ControlCorner: (start: CGFloat, end: CGFloat)] = [ + .topLeading: (0, .pi / 2), .topTrailing: (.pi, .pi / 2), + .bottomLeading: (0, -.pi / 2), .bottomTrailing: (.pi, 1.5 * .pi), + ] + for corner in ControlCorner.allCases { + let angles = CornerArcGeometry.angles(count: 3, radius: 200, corner: corner, diameter: 10, + edgeDistances: CGSize(width: 100, height: 100)) + let (start, end) = expected[corner]! + XCTAssertEqual(angles[0], start, accuracy: 0.001, "\(corner)") + XCTAssertEqual(angles[1], (start + end) / 2, accuracy: 0.001, "\(corner)") + XCTAssertEqual(angles[2], end, accuracy: 0.001, "\(corner)") + } + } + + func testArcsStayClearOfTheCornerEdges() { + let edges = CGSize(width: 20, height: 30) + for corner in ControlCorner.allCases { + let origin = CGPoint(x: 500, y: 500) + let points = CornerArcGeometry.points(count: 5, radius: 120, origin: origin, corner: corner, + diameter: 44, edgeDistances: edges) + for point in points { + // Distance to the vertical and horizontal edge lines. + XCTAssertGreaterThanOrEqual(abs(point.x - origin.x) + edges.width, 22 - 0.01, "\(corner)") + XCTAssertGreaterThanOrEqual(abs(point.y - origin.y) + edges.height, 22 - 0.01, "\(corner)") + } + } + } + + // MARK: - Palette on the next arc + + func testPaletteBloomsOnTheArcDirectlyOutsideTheModifiers() throws { + for corner in ControlCorner.allCases { + let arrangement = CustomControlArrangement.radialTemplate(corner: corner, portrait: false) + let area = referenceArea(portrait: false) + let frames = CustomLayoutGeometry.frames(for: arrangement, in: area, baseDiameter: base) + let origin = center(try XCTUnwrap(frames["settings"])) + let modifierRadius = distance(center(frames["command"]!), origin) + let keyDiameter = CustomLayoutGeometry.paletteKeyDiameter(baseDiameter: base) + let points = CustomLayoutGeometry.palettePoints(count: 5, anchorID: "command", arrangement: arrangement, + frames: frames, area: area, baseDiameter: base, spacing: 8) + XCTAssertEqual(points.count, 5) + let expected = modifierRadius + base / 2 + keyDiameter / 2 + 8 + for point in points { + XCTAssertEqual(distance(point, origin), expected, accuracy: 0.5, "\(corner): attached to the modifiers") + } + let offsets = sweepOffsets(points, origin: origin, corner: corner) + let steps = zip(offsets, offsets.dropFirst()).map { $1 - $0 } + for step in steps { XCTAssertEqual(step, steps[0], accuracy: 0.01, "\(corner) evenly spaced") } + for point in points { + for frame in frames.values { + XCTAssertGreaterThanOrEqual(distance(point, center(frame)), frame.width / 2 + keyDiameter / 2 - 0.5) + } + } + } + } + + func testLargePalettesContinueOnTheNextArcOut() throws { + let arrangement = CustomControlArrangement.radialTemplate(corner: .bottomTrailing, portrait: false) + let area = referenceArea(portrait: false) + let frames = CustomLayoutGeometry.frames(for: arrangement, in: area, baseDiameter: base) + let origin = center(try XCTUnwrap(frames["settings"])) + let points = CustomLayoutGeometry.palettePoints(count: 14, anchorID: "option", arrangement: arrangement, + frames: frames, area: area, baseDiameter: base, spacing: 8) + let radii = Set(points.map { Int(distance($0, origin).rounded()) }) + XCTAssertGreaterThan(radii.count, 1) + for i in points.indices { + for j in points.indices where j > i { + XCTAssertGreaterThanOrEqual(distance(points[i], points[j]), + CustomLayoutGeometry.paletteKeyDiameter(baseDiameter: base) - 0.5) + } + } + } + + func testAModifierMovedAwayFromTheCornerBloomsAroundItself() throws { + var arrangement = CustomControlArrangement.radialTemplate(corner: .bottomTrailing, portrait: false) + let index = try XCTUnwrap(arrangement.placements.firstIndex { $0.id == "command" }) + arrangement.placements[index].x = 0.4 + arrangement.placements[index].y = 0.4 + let area = referenceArea(portrait: false) + let frames = CustomLayoutGeometry.frames(for: arrangement, in: area, baseDiameter: base) + XCTAssertNil(CornerCluster.resolve(arrangement: arrangement, frames: frames, area: area)) + let anchor = center(frames["command"]!) + let points = CustomLayoutGeometry.palettePoints(count: 4, anchorID: "command", arrangement: arrangement, + frames: frames, area: area, baseDiameter: base, spacing: 8) + for point in points { XCTAssertLessThan(distance(point, anchor), 200) } + } + + // MARK: - Two-Hand Assist + + func testHelperModifiersAreTheModifierArcMirrored() throws { + for corner in ControlCorner.allCases { + for portrait in [false, true] { + let arrangement = CustomControlArrangement.radialTemplate(corner: corner, portrait: portrait) + let area = referenceArea(portrait: portrait) + let frames = CustomLayoutGeometry.frames(for: arrangement, in: area, baseDiameter: base) + let helpers = TwoHandAssist.helperPoints(count: 4, arrangement: arrangement, frames: frames, + area: area, itemDiameter: base, spacing: 8) + let mirroredModifiers = CustomControlArrangement.templateModifiers.map { item -> CGPoint in + let point = center(frames[item.rawValue]!) + return CGPoint(x: area.minX + area.maxX - point.x, y: point.y) + } + XCTAssertEqual(helpers.count, 4) + for (helper, expected) in zip(helpers, mirroredModifiers) { + XCTAssertEqual(helper.x, expected.x, accuracy: 0.5, "\(corner) portrait=\(portrait)") + XCTAssertEqual(helper.y, expected.y, accuracy: 0.5, "\(corner) portrait=\(portrait)") + } + // A real arc around the mirrored anchor. + let settings = center(frames["settings"]!) + let mirroredOrigin = CGPoint(x: area.minX + area.maxX - settings.x, y: settings.y) + let radii = helpers.map { distance($0, mirroredOrigin) } + for radius in radii { XCTAssertEqual(radius, radii[0], accuracy: 0.5) } + let offsets = sweepOffsets(helpers, origin: mirroredOrigin, + corner: CornerArcGeometry.mirroredHorizontally(corner)) + let steps = zip(offsets, offsets.dropFirst()).map { $1 - $0 } + for step in steps { XCTAssertEqual(step, steps[0], accuracy: 0.01) } + } + } + } + + func testMoreIdleActionsWidenTheMirroredArcInsteadOfCrowdingIt() { + let arrangement = CustomControlArrangement.radialTemplate(corner: .bottomTrailing, portrait: true) + let area = referenceArea(portrait: true) + let frames = CustomLayoutGeometry.frames(for: arrangement, in: area, baseDiameter: base) + let points = TwoHandAssist.helperPoints(count: 7, arrangement: arrangement, frames: frames, area: area, + itemDiameter: base, spacing: 8) + for (a, b) in zip(points, points.dropFirst()) { + XCTAssertGreaterThanOrEqual(distance(a, b), base + 8 - 0.5) + } + for point in points { XCTAssertLessThan(point.x, area.midX) } + } + + // MARK: - One-tap keyboard shortcuts + + func testChordPlanPressesThenReleasesEveryKeyInReverse() { + let shortcut = KeyboardShortcut(usage: 14, modifiers: ModifierChord([.shift, .command]), additionalUsages: [32]) + let events = KeyboardChordPlan.events(for: shortcut) + XCTAssertEqual(events.map(\.phase), [.down, .down, .up, .up]) + XCTAssertEqual(events.map(\.usage), [14, 32, 32, 14]) + XCTAssertTrue(events.allSatisfy { $0.modifiers == ["command", "shift"] }) + var held = Set() + for event in events { + if event.phase == .down { XCTAssertTrue(held.insert(event.usage).inserted) } + else { XCTAssertNotNil(held.remove(event.usage)) } + } + XCTAssertTrue(held.isEmpty, "no key is left held") + } + + func testKeyboardShortcutCodableStaysBackwardCompatible() throws { + let plain = KeyboardShortcut(usage: 24, modifiers: ModifierChord([.control])) + let json = String(decoding: try JSONEncoder().encode(plain), as: UTF8.self) + XCTAssertFalse(json.contains("additionalUsages"), "ordinary shortcuts encode exactly as before") + let legacy = try JSONDecoder().decode(KeyboardShortcut.self, + from: Data(#"{"usage":24,"modifiers":{"modifiers":["control"]}}"#.utf8)) + XCTAssertEqual(legacy, plain) + let chord = KeyboardShortcut(usage: 14, modifiers: ModifierChord(), additionalUsages: [32]) + XCTAssertEqual(try JSONDecoder().decode(KeyboardShortcut.self, from: JSONEncoder().encode(chord)), chord) + XCTAssertTrue(chord.isValid) + XCTAssertFalse(KeyboardShortcut(usage: 14, modifiers: ModifierChord(), additionalUsages: [14]).isValid) + XCTAssertFalse(KeyboardShortcut(usage: 999, modifiers: ModifierChord()).isValid) + } + + func testShortcutPlacementsRoundTrip() throws { + let item = ShortcutItem(id: "u", title: "Underline", displayKey: "U", usage: 24, + modifiers: ModifierChord([.control]), systemImage: "underline") + let placement = CustomControlPlacement(kind: .shortcut(item), x: 0.3, y: 0.7) + let decoded = try JSONDecoder().decode(CustomControlPlacement.self, from: JSONEncoder().encode(placement)) + XCTAssertEqual(decoded, placement) + XCTAssertNil(decoded.palette) + } + + // MARK: - Share codes + + private func sampleLayout() -> CustomControlLayout { + var layout = CustomControlLayout.radialTemplate(id: "local-id", name: "Drawing", corner: .bottomLeading) + let shortcut = ShortcutItem(id: "k3", title: "K then 3", displayKey: "K3", usage: 14, + modifiers: ModifierChord(), systemImage: nil) + var item = shortcut + item.action = .keyboardShortcut(KeyboardShortcut(usage: 14, modifiers: ModifierChord(), additionalUsages: [32])) + layout.portrait.placements.append(CustomControlPlacement(id: "k3", kind: .shortcut(item), x: 0.5, y: 0.2, size: 1.2)) + layout.landscape.placements.removeLast() + layout.twoHandAssist = false + return layout + } + + func testShareCodeRoundTripsTheLayout() throws { + let layout = sampleLayout() + let code = try CustomLayoutShareCode.encode(layout) + XCTAssertTrue(code.hasPrefix("MDL1-")) + let summary = try CustomLayoutShareCode.decode(code).get() + XCTAssertNotEqual(summary.layout.id, layout.id, "an import is a new layout") + XCTAssertEqual(summary.layout.name, "Drawing") + XCTAssertEqual(summary.layout.landscape, layout.landscape) + XCTAssertEqual(summary.layout.portrait, layout.portrait, "portrait survives independently") + XCTAssertEqual(summary.layout.twoHandAssist, false) + XCTAssertEqual(summary.shortcutCount, 1) + XCTAssertEqual(summary.landscapeControls, layout.landscape.placements.count) + XCTAssertEqual(summary.portraitControls, layout.portrait.placements.count) + XCTAssertEqual(summary.warnings, []) + // Whitespace from chat apps doesn't matter. + XCTAssertNoThrow(try CustomLayoutShareCode.decode(" \n" + code.prefix(20) + "\n" + code.dropFirst(20)).get()) + } + + func testCorruptedAndForeignCodesAreRejected() throws { + let code = try CustomLayoutShareCode.encode(sampleLayout()) + var chars = Array(code) + let index = chars.count / 2 + chars[index] = chars[index] == "A" ? "B" : "A" + XCTAssertEqual(CustomLayoutShareCode.decode(String(chars)).failure, .corrupted) + XCTAssertEqual(CustomLayoutShareCode.decode(String(code.dropLast(3))).failure, .corrupted) + XCTAssertEqual(CustomLayoutShareCode.decode("hello").failure, .notAShareCode) + XCTAssertEqual(CustomLayoutShareCode.decode("MDL2-" + code.dropFirst(5)).failure, .unsupportedVersion(2)) + XCTAssertEqual(CustomLayoutShareCode.decode("MDL1-" + String(repeating: "A", count: 70_000)).failure, + .tooLarge) + } + + func testShareCodesCarryOnlyLayoutData() throws { + let code = try CustomLayoutShareCode.encode(sampleLayout()) + let body = code.dropFirst(5).split(separator: ".")[0] + var base64 = body.replacingOccurrences(of: "-", with: "+").replacingOccurrences(of: "_", with: "/") + base64 += String(repeating: "=", count: (4 - base64.count % 4) % 4) + let compressed = try XCTUnwrap(Data(base64Encoded: base64)) + let json = try (compressed as NSData).decompressed(using: .zlib) as Data + let object = try XCTUnwrap(JSONSerialization.jsonObject(with: json) as? [String: Any]) + XCTAssertEqual(Set(object.keys), ["name", "landscape", "portrait", "twoHandAssist", "assistActionIDs"]) + let text = String(decoding: json, as: UTF8.self).lowercased() + for secret in ["local-id", "peer", "trust", "certificate", "token", "tailscale", "allowinput"] { + XCTAssertFalse(text.contains(secret), secret) + } + } + + func testImportsAddANewLayoutWithinTheLimit() throws { + var preferences = ReceiverControlPreferences() + preferences.createCustomLayout(id: "a") + let existing = preferences.customLayouts[0] + let summary = try CustomLayoutShareCode.decode(CustomLayoutShareCode.encode(existing)).get() + guard case .imported(let id) = preferences.importCustomLayout(summary.layout) else { + return XCTFail("import refused") + } + XCTAssertEqual(preferences.customLayouts.count, 2) + XCTAssertEqual(preferences.customLayouts[0], existing, "never overwrites") + XCTAssertEqual(preferences.customLayouts.first { $0.id == id }?.name, existing.name + " 2") + while preferences.canCreateCustomLayout { preferences.createCustomLayout() } + XCTAssertEqual(preferences.importCustomLayout(summary.layout), .limitReached) + XCTAssertEqual(preferences.customLayouts.count, CustomControlLayout.maximumCount) + } + + func testImportSanitizesAndWarnsAboutUnknownContent() throws { + var layout = sampleLayout() + layout.name = String(repeating: "x", count: 200) + layout.portrait.placements.append(CustomControlPlacement(id: "f", kind: .function("teleport"), x: 0.5, y: 0.5)) + let summary = try CustomLayoutShareCode.decode(CustomLayoutShareCode.encode(layout)).get() + XCTAssertEqual(summary.layout.name.count, CustomLayoutShareCode.maximumNameLength) + XCTAssertEqual(summary.warnings, [.unknownActions(1)]) + } + + // MARK: - Editor and Preview + + func testPreviewNeverSendsAndShowsWhatWouldBeSent() throws { + var session = CustomLayoutPreviewSession() + session.tapModifier(.command) + session.tapAssistModifier(.shift) + XCTAssertEqual(session.interaction.activeChord, ModifierChord([.command, .shift])) + let redo = ShortcutItem(title: "Redo", displayKey: "Z", usage: 29, modifiers: ModifierChord([.command, .shift])) + session.tapPaletteAction(redo) + XCTAssertEqual(session.feedback, .wouldSend("⌘⇧Z")) + let showDesktop = try XCTUnwrap(FunctionTrayProfile.canonical().items.first { $0.id == "show-desktop" }).item + session.tapAction(showDesktop) + XCTAssertEqual(session.feedback, .wouldPerform("Show Desktop")) + session.reset() + XCTAssertEqual(session.interaction, ControlInteractionState()) + } + + func testEditorAndRuntimeShareOneGeometryAndSnapOnlyChangesStoredPositions() { + let area = CGRect(x: 12, y: 36, width: 1170, height: 766) + let point = CGPoint(x: area.minX + area.width * 0.503, y: area.minY + area.height * 0.25) + let free = CustomLayoutGeometry.droppedPosition(for: point, in: area, diameter: 44, + snapToEdges: false, snapToGuides: false) + XCTAssertEqual(free.x, 0.503, accuracy: 0.0001) + let snapped = CustomLayoutGeometry.droppedPosition(for: point, in: area, diameter: 44, + snapToEdges: false, snapToGuides: true) + XCTAssertEqual(snapped.x, 0.5) + XCTAssertEqual(snapped.y, 0.25, accuracy: 0.0001) + let arrangement = CustomControlArrangement.radialTemplate(corner: .topLeading, portrait: false) + XCTAssertEqual(CustomLayoutGeometry.frames(for: arrangement, in: area, baseDiameter: base), + CustomLayoutGeometry.frames(for: arrangement, in: area, baseDiameter: base)) + } + + // MARK: - Settings search + + func testSearchFindsRealSettingsOnTheirPages() { + let expectations: [(String, MobileSettingsCategory)] = [ + ("Smart Touch", .input), ("Rotation", .gestures), ("Control Layout", .controls), + ("Auto-hide", .controls), ("Move View", .gestures), ("Remote Access", .remoteAccess), + ("QUIC", .connections), ("Control Size", .controls), ("Two-Hand Assist", .controls), + ("show desktop", .controls), ("lip sync", .audio), + ] + for (query, category) in expectations { + XCTAssertEqual(MobileSettingsSearchIndex.search(query, isPad: true, debug: false).first?.category, + category, query) + } + } + + func testSearchRespectsDeviceAndBuild() { + XCTAssertTrue(MobileSettingsSearchIndex.search("Control Size", isPad: false, debug: true).isEmpty) + XCTAssertTrue(MobileSettingsSearchIndex.search("notch debug", isPad: true, debug: false).isEmpty) + XCTAssertFalse(MobileSettingsSearchIndex.search("notch debug", isPad: true, debug: true).isEmpty) + XCTAssertTrue(MobileSettingsSearchIndex.search(" ", isPad: true, debug: true).isEmpty) + XCTAssertEqual(MobileSettingsSearchIndex.search("gestures", isPad: true, debug: false).first?.id, + "category.gestures") + let ids = MobileSettingsSearchIndex.items(isPad: true, debug: true).map(\.id) + XCTAssertEqual(ids.count, Set(ids).count) + } + + // MARK: - Local View Navigation + + func testLocalNavigationWorksWithInputOff() { + XCTAssertTrue(LocalViewNavigationPolicy.surfaceAcceptsTouches(remoteInputAllowed: false, + localNavigationAllowed: true, videoEnabled: true)) + XCTAssertFalse(LocalViewNavigationPolicy.surfaceAcceptsTouches(remoteInputAllowed: false, + localNavigationAllowed: false, videoEnabled: true), + "both off: the view is locked") + XCTAssertTrue(LocalViewNavigationPolicy.surfaceAcceptsTouches(remoteInputAllowed: true, + localNavigationAllowed: false, videoEnabled: true)) + var gate = ReceiverMultiFingerGestureGate(voiceOverRunning: false) + XCTAssertTrue(gate.update(remoteInputAllowed: false, localNavigationAllowed: true)) + XCTAssertTrue(gate.isEnabled(.twoFingerViewport)) + XCTAssertTrue(gate.isEnabled(.viewportDoubleTap)) + XCTAssertFalse(gate.isEnabled(.threeFingerSwipe)) + XCTAssertFalse(gate.isEnabled(.threeFingerTap)) + XCTAssertFalse(gate.isEnabled(.pinchSpread)) + _ = gate.update(remoteInputAllowed: false, localNavigationAllowed: false) + for kind in ReceiverMultiFingerRecognizer.allCases { XCTAssertFalse(gate.isEnabled(kind)) } + _ = gate.update(remoteInputAllowed: true, localNavigationAllowed: false) + XCTAssertFalse(gate.isEnabled(.viewportDoubleTap)) + XCTAssertTrue(gate.isEnabled(.twoFingerViewport), "remote scrolling still works") + } + + func testLocalNavigationNeverRoutesAnythingToTheMac() { + for intent in [TwoFingerGestureIntent.undecided, .scroll, .viewportZoomPan] { + for localNavigation in [true, false] { + for video in [true, false] { + for pinch in ReceiverGestureTarget.allCases { + for rotate in ReceiverGestureTarget.allCases { + let routes = TwoFingerRoutingPolicy.routes( + intent: intent, remoteInputAllowed: false, localNavigationAllowed: localNavigation, + videoEnabled: video, pinchTarget: pinch, rotateTarget: rotate) + XCTAssertTrue(routes.isSubset(of: [.viewport])) + } + } + } + } + } + XCTAssertEqual(TwoFingerRoutingPolicy.routes(intent: .viewportZoomPan, remoteInputAllowed: false, + localNavigationAllowed: true, videoEnabled: true, + pinchTarget: .viewport, rotateTarget: .disabled), [.viewport]) + } + + func testDisabledLocalNavigationLeavesRemoteRoutesIntact() { + XCTAssertEqual(TwoFingerRoutingPolicy.routes(intent: .viewportZoomPan, remoteInputAllowed: true, + localNavigationAllowed: false, videoEnabled: true, + pinchTarget: .viewport, rotateTarget: .disabled), []) + XCTAssertEqual(TwoFingerRoutingPolicy.routes(intent: .viewportZoomPan, remoteInputAllowed: true, + localNavigationAllowed: false, videoEnabled: true, + pinchTarget: .app, rotateTarget: .disabled), [.appCommands]) + XCTAssertEqual(TwoFingerRoutingPolicy.routes(intent: .scroll, remoteInputAllowed: true, + localNavigationAllowed: false, videoEnabled: true, + pinchTarget: .viewport, rotateTarget: .disabled), [.remoteScroll]) + XCTAssertFalse(LocalViewNavigationPolicy.allowsViewportChanges(localNavigationAllowed: true, videoEnabled: false)) + } + + func testVoiceOverStillOverridesEverything() { + var gate = ReceiverMultiFingerGestureGate(voiceOverRunning: true) + _ = gate.update(remoteInputAllowed: true, localNavigationAllowed: true) + for kind in ReceiverMultiFingerRecognizer.allCases { XCTAssertFalse(gate.isEnabled(kind)) } + } +} + +private extension Result { + var failure: Failure? { + if case .failure(let error) = self { return error } + return nil + } +} diff --git a/MacTests/PadControlLayoutTests.swift b/MacTests/PadControlLayoutTests.swift index 278686a7..2414733d 100644 --- a/MacTests/PadControlLayoutTests.swift +++ b/MacTests/PadControlLayoutTests.swift @@ -2,7 +2,7 @@ import CoreGraphics import XCTest /// Receiver defaults (schema 16), iPad Strip/Overlay geometry, control -/// scale, display-aware backdrop, Custom layouts, radial palettes and +/// scale, Custom layouts, radial palettes and /// Two-Hand Assist — all pure policy, no UIKit. final class PadControlLayoutTests: XCTestCase { private let container = CGRect(x: 0, y: 0, width: 1194, height: 834) @@ -18,7 +18,10 @@ final class PadControlLayoutTests: XCTestCase { XCTAssertEqual(preferences.rotateTarget, .disabled) XCTAssertTrue(preferences.preferMeowDisplayGestures) XCTAssertEqual(preferences.padControlLayout, .strip) - XCTAssertTrue(preferences.padShowControlHints) + XCTAssertFalse(preferences.padShowControlHints, "hints default off (schema 17)") + XCTAssertTrue(preferences.allowLocalViewNavigation) + XCTAssertEqual(preferences.padStandardControls, PadStandardControlVisibility()) + XCTAssertTrue(PadStandardControl.allCases.allSatisfy(preferences.padStandardControls.isVisible)) XCTAssertEqual(preferences.padControlScale, 1) XCTAssertTrue(preferences.customLayouts.isEmpty, "no empty Custom layouts are pre-created") XCTAssertFalse(preferences.audioPreferred, "audio stays opt-in") @@ -45,7 +48,7 @@ final class PadControlLayoutTests: XCTestCase { forKey: ReceiverControlPreferencesRepository.defaultsKey) let loaded = repository.load() - XCTAssertEqual(loaded.version, 16) + XCTAssertEqual(loaded.version, ReceiverControlPreferences.schemaVersion) XCTAssertTrue(loaded.smartTouchEnabled) XCTAssertTrue(loaded.autoHideEnabled) XCTAssertEqual(loaded.pinchTarget, .viewport) @@ -76,6 +79,46 @@ final class PadControlLayoutTests: XCTestCase { XCTAssertEqual(reloaded.padControlLayout, .overlay) } + func testSchema16InstallMigratesOnceToSchema17() throws { + let (repository, defaults, suite) = try makeRepository() + defer { defaults.removePersistentDomain(forName: suite) } + var old = ReceiverControlPreferences() + old.version = 16 + old.padShowControlHints = true + old.inputMode = .trackpad + var json = try XCTUnwrap(JSONSerialization.jsonObject(with: JSONEncoder().encode(old)) as? [String: Any]) + json.removeValue(forKey: "padStandardControls") + json.removeValue(forKey: "allowLocalViewNavigation") + json["padShowMoveViewControl"] = false // schema 16's own Move View switch + defaults.set(try JSONSerialization.data(withJSONObject: json), + forKey: ReceiverControlPreferencesRepository.defaultsKey) + + let loaded = repository.load() + XCTAssertEqual(loaded.version, ReceiverControlPreferences.schemaVersion) + XCTAssertFalse(loaded.padShowControlHints, "pre-release installs take the new default once") + XCTAssertTrue(loaded.allowLocalViewNavigation) + XCTAssertFalse(loaded.padStandardControls.isVisible(.moveView), "a hidden Move View stays hidden") + XCTAssertTrue(loaded.padStandardControls.isVisible(.showDesktop)) + XCTAssertEqual(loaded.inputMode, .trackpad) + } + + func testChoicesSavedUnderSchema17AreNeverRemigrated() throws { + let (repository, defaults, suite) = try makeRepository() + defer { defaults.removePersistentDomain(forName: suite) } + var chosen = repository.load() + chosen.padShowControlHints = true + chosen.allowLocalViewNavigation = false + chosen.padStandardControls.setVisible(false, .controlCenter) + repository.save(chosen) + for _ in 0..<2 { + let reloaded = repository.load() + XCTAssertTrue(reloaded.padShowControlHints) + XCTAssertFalse(reloaded.allowLocalViewNavigation) + XCTAssertFalse(reloaded.padStandardControls.isVisible(.controlCenter)) + repository.save(reloaded) + } + } + func testPersistedControlScaleIsClampedOnLoad() throws { let (repository, defaults, suite) = try makeRepository() defer { defaults.removePersistentDomain(forName: suite) } @@ -134,128 +177,256 @@ final class PadControlLayoutTests: XCTestCase { // MARK: - Strip / Overlay geometry + private let railGroups = [3, 7, 2] // system, keys, view + private let padSafe = ControlSafeInsets(top: 24, leading: 0, bottom: 20, trailing: 0) + + private func layout(_ container: CGRect? = nil, strip: Bool = true, main: ControlEdge = .trailing, + function: ControlEdge = .trailing, mainGroups: [Int]? = nil, functionGroups: [Int] = [2, 2], + metrics: PadControlMetrics? = nil, safe: ControlSafeInsets = .zero, + keyboardTop: CGFloat? = nil) -> PadEdgeLayout { + PadEdgeGeometry.layout(container: container ?? self.container, safeInsets: safe, reservesStrips: strip, + mainEdge: main, functionEdge: function, mainGroupCounts: mainGroups ?? railGroups, + functionGroupCounts: functionGroups, metrics: metrics ?? self.metrics, + keyboardTop: keyboardTop) + } + + private func assertNoOverlap(_ frames: [CGRect], gap: CGFloat, _ message: String, + file: StaticString = #filePath, line: UInt = #line) { + for i in frames.indices { + for j in frames.indices where j > i { + let a = frames[i].insetBy(dx: -gap / 2 + 0.01, dy: -gap / 2 + 0.01) + let b = frames[j].insetBy(dx: -gap / 2 + 0.01, dy: -gap / 2 + 0.01) + XCTAssertFalse(a.intersects(b), "\(message): \(frames[i]) vs \(frames[j])", file: file, line: line) + } + } + } + func testStripReservesARailAndCentersTheMainControls() throws { - let layout = PadEdgeGeometry.layout(container: container, safeInsets: .zero, reservesStrips: true, - mainEdge: .trailing, functionEdge: .trailing, - mainCount: 9, functionGroupCounts: [2, 2], metrics: metrics) - XCTAssertEqual(layout.strips.map(\.edge), [.trailing]) - let strip = try XCTUnwrap(layout.strips.first).frame + let result = layout(function: .leading) + let strip = try XCTUnwrap(result.strips.first { $0.edge == .trailing }).frame XCTAssertEqual(strip, CGRect(x: 1130, y: 0, width: 64, height: 834)) - XCTAssertEqual(layout.canvas, CGRect(x: 0, y: 0, width: 1130, height: 834)) - let main = try XCTUnwrap(layout.mainFrame) + XCTAssertEqual(result.canvas, CGRect(x: 64, y: 0, width: 1066, height: 834)) + let main = try XCTUnwrap(result.mainFrame) XCTAssertEqual(main.midY, container.midY, accuracy: 0.5) XCTAssertEqual(main.midX, strip.midX, accuracy: 0.5) XCTAssertTrue(strip.contains(main)) + XCTAssertEqual(result.mainCells.count, 12) + } + + func testMainGroupsKeepAVisibleGap() { + let result = layout(function: .leading) + XCTAssertEqual(result.mainSegments.map(\.cells.count), railGroups) + for (a, b) in zip(result.mainSegments, result.mainSegments.dropFirst()) { + XCTAssertEqual(b.frame.minY - a.frame.maxY, metrics.groupGap, accuracy: 0.01) + } } func testStripOnEachEdgeKeepsTheCanvasBesideIt() { for edge in ControlEdge.allCases { - let layout = PadEdgeGeometry.layout(container: container, safeInsets: .zero, reservesStrips: true, - mainEdge: edge, functionEdge: edge, - mainCount: 6, functionGroupCounts: [2], metrics: metrics) - XCTAssertEqual(layout.strips.count, 1, "\(edge)") - let strip = layout.strips[0].frame - XCTAssertFalse(strip.intersects(layout.canvas), "\(edge)") - XCTAssertEqual(strip.union(layout.canvas), container, "\(edge)") - if let main = layout.mainFrame { - XCTAssertTrue(strip.contains(main), "\(edge)") - if edge.stacksVertically { - XCTAssertEqual(main.midY, container.midY, accuracy: 0.5) - } else { - XCTAssertEqual(main.midX, container.midX, accuracy: 0.5) - } + let result = layout(main: edge, function: edge, functionGroups: []) + XCTAssertEqual(result.strips.count, 1, "\(edge)") + let strip = result.strips[0].frame + XCTAssertFalse(strip.intersects(result.canvas), "\(edge)") + XCTAssertEqual(strip.union(result.canvas), container, "\(edge)") + guard let main = result.mainFrame else { return XCTFail("missing main frame on \(edge)") } + XCTAssertTrue(strip.contains(main), "\(edge)") + if edge.stacksVertically { + XCTAssertEqual(main.midY, container.midY, accuracy: 0.5) } else { - XCTFail("missing main frame on \(edge)") + XCTAssertEqual(main.midX, container.midX, accuracy: 0.5) } } } func testStripIncludesTheSafeInsetButKeepsControlsClearOfIt() throws { - let safe = ControlSafeInsets(top: 0, leading: 0, bottom: 20, trailing: 0) - let layout = PadEdgeGeometry.layout(container: container, safeInsets: safe, reservesStrips: true, - mainEdge: .bottom, functionEdge: .bottom, - mainCount: 6, functionGroupCounts: [], metrics: metrics) - let strip = try XCTUnwrap(layout.strips.first).frame + let result = layout(main: .bottom, function: .bottom, functionGroups: [], + safe: ControlSafeInsets(top: 0, leading: 0, bottom: 20, trailing: 0)) + let strip = try XCTUnwrap(result.strips.first).frame XCTAssertEqual(strip.maxY, container.maxY) XCTAssertEqual(strip.height, metrics.stripThickness(on: .bottom) + 20) - XCTAssertLessThanOrEqual(try XCTUnwrap(layout.mainFrame).maxY, container.maxY - 20) + XCTAssertLessThanOrEqual(try XCTUnwrap(result.mainFrame).maxY, container.maxY - 20) } func testOverlayUsesTheWholeCanvas() { - let layout = PadEdgeGeometry.layout(container: container, safeInsets: .zero, reservesStrips: false, - mainEdge: .leading, functionEdge: .trailing, - mainCount: 9, functionGroupCounts: [2, 2], metrics: metrics) - XCTAssertTrue(layout.strips.isEmpty) - XCTAssertEqual(layout.canvas, container) - } - - func testFunctionGroupsSplitTowardTopAndBottomOnAVerticalEdge() throws { - let layout = PadEdgeGeometry.layout(container: container, safeInsets: .zero, reservesStrips: false, - mainEdge: .trailing, functionEdge: .trailing, - mainCount: 9, functionGroupCounts: [2, 2], metrics: metrics) - let main = try XCTUnwrap(layout.mainFrame) - XCTAssertEqual(layout.functionFrames.count, 2) - XCTAssertLessThanOrEqual(layout.functionFrames[0].maxY, main.minY - metrics.groupGap + 0.5) - XCTAssertGreaterThanOrEqual(layout.functionFrames[1].minY, main.maxY + metrics.groupGap - 0.5) - for frame in layout.functionFrames { - XCTAssertEqual(frame.midX, main.midX, accuracy: 0.5) - XCTAssertFalse(frame.intersects(main)) + let result = layout(strip: false, main: .leading, function: .trailing) + XCTAssertTrue(result.strips.isEmpty) + XCTAssertEqual(result.canvas, container) + } + + func testFunctionGroupsTakeTheEndsOfASharedVerticalEdge() throws { + let result = layout(strip: false, mainGroups: [3, 4, 1], safe: padSafe) + let main = try XCTUnwrap(result.mainFrame) + XCTAssertEqual(result.functionSegments.count, 2) + XCTAssertEqual(result.laneCounts[.trailing], 1) + XCTAssertEqual(result.functionSegments[0].frame.minY, 24 + PadControlMetrics.edgeMargin, accuracy: 0.01) + XCTAssertEqual(result.functionSegments[1].frame.maxY, 834 - 20 - PadControlMetrics.edgeMargin, accuracy: 0.01) + XCTAssertLessThan(result.functionSegments[0].frame.maxY, main.minY) + XCTAssertGreaterThan(result.functionSegments[1].frame.minY, main.maxY) + } + + func testFunctionGroupsTakeTheEndsOfASharedHorizontalEdge() throws { + let result = layout(main: .bottom, function: .bottom, functionGroups: [2, 2, 2]) + let main = try XCTUnwrap(result.mainFrame) + XCTAssertEqual(result.functionSegments.count, 3) + XCTAssertLessThan(result.functionSegments[0].frame.maxX, result.functionSegments[1].frame.minX) + XCTAssertLessThan(result.functionSegments[1].frame.maxX, main.minX) + XCTAssertGreaterThan(result.functionSegments[2].frame.minX, main.maxX) + } + + func testMainShiftsOffCenterRatherThanOverlapping() throws { + // 766 pt of rail: Function (96) + gap + Main (640) fits only if Main + // leaves the exact center. + let result = layout(strip: false, main: .leading, function: .leading, functionGroups: [2], safe: padSafe) + let main = try XCTUnwrap(result.mainFrame) + let function = try XCTUnwrap(result.functionSegments.first).frame + XCTAssertEqual(result.laneCounts[.leading], 1) + XCTAssertEqual(main.minY, function.maxY + metrics.groupGap, accuracy: 0.01) + XCTAssertGreaterThan(main.midY, container.midY) + XCTAssertEqual(function.midX, main.midX, accuracy: 0.01) + } + + func testOverflowMovesToAnotherLaneAndWidensTheStrip() throws { + let result = layout(main: .leading, function: .leading, functionGroups: [2, 2, 2], safe: padSafe) + XCTAssertEqual(result.laneCounts[.leading], 2) + let strip = try XCTUnwrap(result.strips.first).frame + XCTAssertEqual(strip.width, metrics.clusterThickness(on: .leading) * 2 + metrics.gap + + PadControlMetrics.stripPadding * 2, accuracy: 0.01) + let main = try XCTUnwrap(result.mainFrame) + for segment in result.functionSegments { + XCTAssertGreaterThan(segment.frame.minX, main.maxX, "Function moved to the inner lane") + XCTAssertTrue(strip.contains(segment.frame)) } - } - - func testFunctionGroupsSplitTowardLeftAndRightOnAHorizontalEdge() throws { - let layout = PadEdgeGeometry.layout(container: container, safeInsets: .zero, reservesStrips: true, - mainEdge: .bottom, functionEdge: .bottom, - mainCount: 9, functionGroupCounts: [2, 2, 2], metrics: metrics) - let main = try XCTUnwrap(layout.mainFrame) - XCTAssertEqual(layout.functionFrames.count, 3) - XCTAssertLessThan(layout.functionFrames[0].maxX, layout.functionFrames[1].minX) - XCTAssertLessThan(layout.functionFrames[1].maxX, main.minX) - XCTAssertGreaterThan(layout.functionFrames[2].minX, main.maxX) - for frame in layout.functionFrames { - XCTAssertEqual(frame.midY, main.midY, accuracy: 0.5) + assertNoOverlap(result.mainCells + result.functionCells, gap: metrics.gap, "overflow") + } + + func testAGroupLongerThanTheEdgeWrapsDeterministically() { + let portrait = CGRect(x: 0, y: 0, width: 834, height: 1194) + let large = PadControlMetrics(scale: 1.4) + let first = layout(portrait, main: .top, function: .top, mainGroups: [20], functionGroups: [], metrics: large) + let second = layout(portrait, main: .top, function: .top, mainGroups: [20], functionGroups: [], metrics: large) + XCTAssertEqual(first, second) + XCTAssertEqual(first.mainCells.count, 20) + XCTAssertGreaterThan(first.laneCounts[.top] ?? 0, 1) + assertNoOverlap(first.mainCells, gap: large.gap, "wrapped group") + } + + func testNothingOverlapsOnAnyEdgeScaleOrientationOrMode() { + let landscape = container + let portrait = CGRect(x: 0, y: 0, width: 834, height: 1194) + for size in [landscape, portrait] { + for strip in [true, false] { + for scale in [0.85, 1.0, 1.4] { + for hints in [false, true] where strip || !hints { + let metrics = PadControlMetrics(scale: scale, showsHints: hints) + for main in ControlEdge.allCases { + for function in ControlEdge.allCases { + let result = layout(size, strip: strip, main: main, function: function, + functionGroups: [2, 2, 2], metrics: metrics, safe: padSafe) + let label = "\(size.size) strip=\(strip) scale=\(scale) hints=\(hints) \(main)/\(function)" + let cells = result.mainCells + result.functionCells + XCTAssertEqual(result.mainCells.count, 12, label) + XCTAssertEqual(result.functionCells.count, 6, label) + assertNoOverlap(cells, gap: metrics.gap, label) + let safeArea = CGRect(x: 0, y: 24, width: size.width, height: size.height - 44) + for cell in cells { + XCTAssertTrue(safeArea.contains(cell), "\(label) outside safe area: \(cell)") + if strip { + XCTAssertFalse(cell.intersects(result.canvas.insetBy(dx: 0.5, dy: 0.5)), + "\(label) control over the canvas") + } + } + } + } + } + } + } } } - func testMainAndFunctionEdgesAreIndependent() throws { - let layout = PadEdgeGeometry.layout(container: container, safeInsets: .zero, reservesStrips: true, - mainEdge: .trailing, functionEdge: .leading, - mainCount: 9, functionGroupCounts: [2, 2], metrics: metrics) - XCTAssertEqual(Set(layout.strips.map(\.edge)), [.leading, .trailing]) - XCTAssertEqual(layout.canvas.minX, 64) - XCTAssertEqual(layout.canvas.maxX, 1130) - let leadingStrip = try XCTUnwrap(layout.strips.first { $0.edge == .leading }).frame - // Split to the two ends of the other edge, away from the center. - XCTAssertTrue(leadingStrip.contains(layout.functionFrames[0])) - XCTAssertTrue(leadingStrip.contains(layout.functionFrames[1])) - XCTAssertLessThan(layout.functionFrames[0].maxY, container.midY) - XCTAssertGreaterThan(layout.functionFrames[1].minY, container.midY) - } - - func testPerpendicularEdgesDoNotCollideAtTheirSharedCorner() throws { - let layout = PadEdgeGeometry.layout(container: container, safeInsets: .zero, reservesStrips: false, - mainEdge: .bottom, functionEdge: .trailing, - mainCount: 14, functionGroupCounts: [3, 3], metrics: metrics) - let main = try XCTUnwrap(layout.mainFrame) - for frame in layout.functionFrames { XCTAssertFalse(frame.intersects(main)) } - } - func testBottomControlsRiseAboveTheKeyboard() throws { - let layout = PadEdgeGeometry.layout(container: container, safeInsets: .zero, reservesStrips: false, - mainEdge: .bottom, functionEdge: .trailing, - mainCount: 6, functionGroupCounts: [2, 2], metrics: metrics, - keyboardTop: 500) - XCTAssertLessThanOrEqual(try XCTUnwrap(layout.mainFrame).maxY, 500) - for frame in layout.functionFrames { XCTAssertLessThanOrEqual(frame.maxY, 500) } + let result = layout(strip: false, main: .bottom, function: .trailing, keyboardTop: 500) + XCTAssertLessThanOrEqual(try XCTUnwrap(result.mainFrame).maxY, 500) + for frame in result.functionSegments.map(\.frame) { XCTAssertLessThanOrEqual(frame.maxY, 500) } } func testHiddenControlsReserveNoStrip() { - let layout = PadEdgeGeometry.layout(container: container, safeInsets: .zero, reservesStrips: true, - mainEdge: .trailing, functionEdge: .leading, - mainCount: 0, functionGroupCounts: [], metrics: metrics) - XCTAssertTrue(layout.strips.isEmpty) - XCTAssertEqual(layout.canvas, container) - XCTAssertNil(layout.mainFrame) + let result = layout(main: .trailing, function: .leading, mainGroups: [], functionGroups: []) + XCTAssertTrue(result.strips.isEmpty) + XCTAssertEqual(result.canvas, container) + XCTAssertNil(result.mainFrame) + } + + func testPaletteAnchorCoversEveryLaneOnTheMainEdge() throws { + let result = layout(main: .leading, function: .leading, functionGroups: [2, 2, 2], safe: padSafe) + let anchor = try XCTUnwrap(PadEdgeGeometry.paletteAnchor(for: result, mainEdge: .leading, functionEdge: .leading)) + for segment in result.functionSegments { XCTAssertTrue(anchor.contains(segment.frame)) } + } + + // MARK: - Standard rail + + private let everything = PadRailComposition.Availability(modifiers: ControlModifier.allCases, + keyboardAvailable: true, moveViewAvailable: true) + + func testRailGroupsSystemThenKeysThenView() { + let groups = PadRailComposition.mainGroups(visibility: PadStandardControlVisibility(), availability: everything) + XCTAssertEqual(groups, [ + [.function("menu-bar"), .tray(.dock), .function("show-desktop"), .function("control-center")], + [.tray(.command), .tray(.option), .tray(.control), .tray(.shift), .tray(.escape), .tray(.tab), .tray(.keyboard)], + [.moveView, .tray(.settings)], + ]) + } + + func testEveryStandardControlCanBeHiddenButSettingsStays() { + var visibility = PadStandardControlVisibility() + for control in PadStandardControl.allCases { visibility.setVisible(false, control) } + let groups = PadRailComposition.mainGroups(visibility: visibility, availability: everything) + XCTAssertEqual(groups, [ControlModifier.allCases.map { .tray(ControlTrayItem.item(for: $0)) }, + [.tray(.settings)]]) + let bare = PadRailComposition.mainGroups( + visibility: visibility, + availability: .init(modifiers: [], keyboardAvailable: false, moveViewAvailable: false)) + XCTAssertEqual(bare, [[.tray(.settings)]]) + } + + func testMoveViewCanBeHiddenAndNeedsLocalNavigation() { + var visibility = PadStandardControlVisibility() + visibility.setVisible(false, .moveView) + XCTAssertFalse(PadRailComposition.mainGroups(visibility: visibility, availability: everything) + .joined().contains(.moveView)) + var unavailable = everything + unavailable.moveViewAvailable = false + XCTAssertFalse(PadRailComposition.mainGroups(visibility: PadStandardControlVisibility(), availability: unavailable) + .joined().contains(.moveView), "showing the button never bypasses Local View Navigation") + XCTAssertFalse(LocalViewNavigationPolicy.showsMoveView(controlVisible: true, localNavigationAllowed: false, + videoEnabled: true)) + XCTAssertTrue(LocalViewNavigationPolicy.showsMoveView(controlVisible: true, localNavigationAllowed: true, + videoEnabled: true)) + } + + func testShowDesktopAndControlCenterAreSemanticFunctionActions() throws { + let items = FunctionTrayProfile.canonical().items + let desktop = try XCTUnwrap(items.first { $0.id == "show-desktop" }) + let controlCenter = try XCTUnwrap(items.first { $0.id == "control-center" }) + XCTAssertEqual(desktop.item.action, .receiverGesture(ReceiverGesture.showDesktop.rawValue)) + XCTAssertEqual(controlCenter.item.action, .receiverGesture(ReceiverGesture.controlCenter.rawValue)) + XCTAssertEqual(PadStandardControl.showDesktop.kind, .function(desktop.id)) + XCTAssertEqual(PadStandardControl.controlCenter.kind, .function(controlCenter.id)) + // A saved profile that predates Control Center gains it, hidden. + var saved = FunctionTrayProfile.canonical() + saved.items.removeAll { $0.id == "control-center" } + let resolved = saved.resolvingCanonicalMetadata() + XCTAssertEqual(resolved.items.first { $0.id == "control-center" }?.isVisible, false) + let mapping = SystemGestureShortcutMapping.shortcut(for: .controlCenter) + XCTAssertEqual(mapping.keyCode, 8) + XCTAssertEqual(mapping.flags, .maskSecondaryFn) + } + + func testStandardControlVisibilityDecodesLossily() throws { + let decoded = try JSONDecoder().decode(PadStandardControlVisibility.self, + from: Data(#"["moveView","hologram","dock"]"#.utf8)) + XCTAssertFalse(decoded.isVisible(.moveView)) + XCTAssertFalse(decoded.isVisible(.dock)) + XCTAssertTrue(decoded.isVisible(.controlCenter)) } func testHintsWidenTheStrip() { @@ -289,69 +460,6 @@ final class PadControlLayoutTests: XCTestCase { XCTAssertTrue(ControlAutoHidePolicy.applies(isPad: false, layout: .strip)) } - // MARK: - Display-aware backdrop - - private func footprint(rotation: CGFloat = 0, origin: CGPoint = .zero) -> DisplayFootprint { - let base = RemoteViewportCalculator.normal(viewBounds: CGRect(x: 0, y: 0, width: 1000, height: 1000), - remoteAspectSize: CGSize(width: 16, height: 9)) - let transform = RemoteViewportTransform(remoteCrop: base.remoteCrop, displayedRect: base.displayedRect, - rotationRadians: rotation) - return DisplayFootprint(transform: transform, surfaceOrigin: origin, - surfaceSize: CGSize(width: 1000, height: 1000)) - } - - func testControlOverTheRenderedDisplayGetsAFullHalo() { - XCTAssertEqual(ControlBackdropPolicy.haloStrength(for: CGRect(x: 100, y: 400, width: 44, height: 44), - footprint: footprint()), 1) - } - - func testControlOverLetterboxingGetsNoHalo() { - // The 16:9 picture spans y 218.75...781.25; y 50 is the black bar. - XCTAssertEqual(ControlBackdropPolicy.haloStrength(for: CGRect(x: 100, y: 50, width: 44, height: 44), - footprint: footprint()), 0) - XCTAssertEqual(ControlBackdropPolicy.haloStrength(for: CGRect(x: 100, y: 400, width: 44, height: 44), - footprint: .none), 0) - } - - func testPartialOverlapScalesTheHalo() { - let coverage = ControlBackdropPolicy.coverage(of: CGRect(x: 100, y: 196.75, width: 44, height: 44), - footprint: footprint()) - XCTAssertEqual(coverage, 0.6, accuracy: 0.001) - let strength = ControlBackdropPolicy.haloStrength(coverage: coverage) - XCTAssertEqual(strength, 1) - let sliver = ControlBackdropPolicy.haloStrength(coverage: 0.2) - XCTAssertGreaterThan(sliver, 0) - XCTAssertLessThan(sliver, 1) - XCTAssertEqual(ControlBackdropPolicy.haloStrength(coverage: 0.02), 0) - } - - func testBackdropFollowsRotationAndTheSurfaceOrigin() { - let rotated = footprint(rotation: .pi / 2) - // Rotated a quarter turn, the picture covers the top of the view - // and no longer covers the left edge. - XCTAssertEqual(ControlBackdropPolicy.haloStrength(for: CGRect(x: 480, y: 60, width: 40, height: 40), - footprint: rotated), 1) - XCTAssertEqual(ControlBackdropPolicy.haloStrength(for: CGRect(x: 40, y: 480, width: 40, height: 40), - footprint: rotated), 0) - // A Strip on the left shifts the surface; overlay coordinates follow. - let shifted = footprint(origin: CGPoint(x: 64, y: 0)) - XCTAssertEqual(ControlBackdropPolicy.haloStrength(for: CGRect(x: 20, y: 400, width: 40, height: 40), - footprint: shifted), 0) - XCTAssertEqual(ControlBackdropPolicy.haloStrength(for: CGRect(x: 90, y: 400, width: 40, height: 40), - footprint: shifted), 1) - } - - func testBackdropFollowsZoomAndPan() { - let base = RemoteViewportCalculator.normal(viewBounds: CGRect(x: 0, y: 0, width: 1000, height: 1000), - remoteAspectSize: CGSize(width: 16, height: 9)) - let zoomed = RemoteViewportCalculator.applyManualZoom(to: base, state: ManualViewportState(scale: 2)) - let print = DisplayFootprint(transform: zoomed, surfaceOrigin: .zero, - surfaceSize: CGSize(width: 1000, height: 1000)) - // Zoomed 2x, the picture now fills what was the letterbox bar. - XCTAssertEqual(ControlBackdropPolicy.haloStrength(for: CGRect(x: 100, y: 50, width: 44, height: 44), - footprint: print), 1) - } - // MARK: - Custom layouts func testAtMostFiveCustomLayouts() { @@ -444,8 +552,10 @@ final class PadControlLayoutTests: XCTestCase { } XCTAssertEqual(nearest?.kind, .tray(.settings)) XCTAssertEqual(Set(ControlModifier.allCases), Set(arrangement.placements.compactMap(\.kind.modifier))) - // The whole constellation stays in the corner's quadrant. - for placement in arrangement.placements { + // The constellation stays in the corner's quadrant (the + // system group sits on the opposite side on purpose). + for placement in arrangement.placements + where !CustomControlArrangement.templateSystem.contains(placement.kind) { XCTAssertEqual(placement.x < 0.5, corner.isLeading) XCTAssertEqual(placement.y < 0.5, corner.isTop) } diff --git a/MacTests/ReceiverControlsTests.swift b/MacTests/ReceiverControlsTests.swift index 5617bf94..518108de 100644 --- a/MacTests/ReceiverControlsTests.swift +++ b/MacTests/ReceiverControlsTests.swift @@ -514,8 +514,9 @@ final class ReceiverControlsTests: XCTestCase { XCTAssertEqual(launchpad.item.action, .receiverGesture(ReceiverGesture.launchpad.rawValue)) XCTAssertEqual(showDesktop.item.action, .receiverGesture(ReceiverGesture.showDesktop.rawValue)) XCTAssertEqual(launchpad.group, showDesktop.group) - XCTAssertFalse(profile.items.filter { $0.item.action != launchpad.item.action - && $0.item.action != showDesktop.item.action }.contains { $0.group == launchpad.group }) + // The system group holds exactly the semantic system actions. + XCTAssertEqual(Set(profile.items.filter { $0.group == launchpad.group }.map(\.id)), + ["menu-bar", "launchpad", "show-desktop", "control-center"]) } func testSavedFunctionTrayGainsNewItemsHiddenWithoutChangingItsTray() throws { diff --git a/MacTests/SecondQARefinementTests.swift b/MacTests/SecondQARefinementTests.swift new file mode 100644 index 00000000..2a2fd6a2 --- /dev/null +++ b/MacTests/SecondQARefinementTests.swift @@ -0,0 +1,505 @@ +import CoreGraphics +import XCTest + +/// Second physical-QA pass: system actions, rail anchoring, software-keyboard +/// chords, the editable Function Tray and its sequences, scroll direction, +/// Wheel and fan geometry, editor/import flows, and Request Input. +final class SecondQARefinementTests: XCTestCase { + private let container = CGRect(x: 0, y: 0, width: 1194, height: 834) + private let safe = ControlSafeInsets(top: 24, leading: 0, bottom: 20, trailing: 0) + private let metrics = PadControlMetrics() + + // MARK: - System actions + + func testSystemActionsMapToDocumentedMacShortcuts() { + let menuBar = SystemGestureShortcutMapping.shortcut(for: .menuBar) + XCTAssertEqual(menuBar.keyCode, 120, "Control-F2 focuses the menu bar") + XCTAssertEqual(menuBar.flags, [.maskControl, .maskSecondaryFn]) + XCTAssertEqual(SystemGestureShortcutMapping.shortcut(for: .controlCenter).keyCode, 8) + XCTAssertEqual(SystemGestureShortcutMapping.shortcut(for: .showDesktop).keyCode, 103) + XCTAssertTrue(ReceiverGesture.shouldRoute(name: "menuBar", inputAllowed: true)) + XCTAssertFalse(ReceiverGesture.shouldRoute(name: "menuBar", inputAllowed: false)) + } + + func testStandardSystemGroupIsMenuBarDockDesktopControlCenter() throws { + let items = FunctionTrayProfile.canonical().items + let menuBar = try XCTUnwrap(items.first { $0.id == "menu-bar" }) + XCTAssertEqual(menuBar.item.action, .receiverGesture(ReceiverGesture.menuBar.rawValue)) + XCTAssertFalse(menuBar.isVisible, "opt-in in the Function Tray itself") + let groups = PadRailComposition.mainGroups( + visibility: PadStandardControlVisibility(), + availability: .init(modifiers: ControlModifier.allCases, keyboardAvailable: true, moveViewAvailable: true)) + XCTAssertEqual(groups.first, [.function("menu-bar"), .tray(.dock), .function("show-desktop"), + .function("control-center")]) + XCTAssertEqual(PadRailComposition.alignments(for: groups), [.start, .center, .end]) + // Dock stays the existing ⌥⌘D tray action. + XCTAssertEqual(PadStandardControl.dock.kind, .tray(.dock)) + } + + // MARK: - Rail anchoring + + private func railLayout(main: ControlEdge = .trailing, function: ControlEdge = .trailing, + functionGroups: [Int] = [2, 2], strip: Bool = false, + metrics: PadControlMetrics? = nil, keyboardTop: CGFloat? = nil, + size: CGRect? = nil) -> PadEdgeLayout { + PadEdgeGeometry.layout(container: size ?? container, safeInsets: safe, reservesStrips: strip, + mainEdge: main, functionEdge: function, mainGroupCounts: [4, 7, 2], + mainGroupAlignments: [.start, .center, .end], functionGroupCounts: functionGroups, + metrics: metrics ?? self.metrics, keyboardTop: keyboardTop) + } + + func testSystemGroupStartsAtTheTopAndSettingsSitsAtTheBottom() throws { + let layout = railLayout(functionGroups: []) + let railTop = 24 + PadControlMetrics.edgeMargin + let railBottom = 834 - 20 - PadControlMetrics.edgeMargin + XCTAssertEqual(layout.mainSegments.first?.frame.minY ?? 0, railTop, accuracy: 0.01) + XCTAssertEqual(layout.mainSegments.last?.frame.maxY ?? 0, railBottom, accuracy: 0.01) + XCTAssertGreaterThan(try XCTUnwrap(layout.mainSegments.first).frame.midX, container.midX, "top-right") + } + + func testSharedEdgeFunctionGroupsSitBetweenTheAnchoredGroups() { + let layout = railLayout(size: CGRect(x: 0, y: 0, width: 834, height: 1194)) + XCTAssertEqual(layout.laneCounts[.trailing], 1) + let system = layout.mainSegments[0].frame + let settings = layout.mainSegments[2].frame + for segment in layout.functionSegments { + XCTAssertGreaterThan(segment.frame.minY, system.maxY) + XCTAssertLessThan(segment.frame.maxY, settings.minY) + } + } + + func testAnchoredRailsNeverOverlapAnywhere() { + for size in [container, CGRect(x: 0, y: 0, width: 834, height: 1194)] { + for strip in [true, false] { + for scale in [0.85, 1.4] { + for main in ControlEdge.allCases { + for function in ControlEdge.allCases { + let metrics = PadControlMetrics(scale: scale) + let layout = railLayout(main: main, function: function, functionGroups: [2, 2, 3], + strip: strip, metrics: metrics, size: size) + let cells = layout.mainCells + layout.functionCells + XCTAssertEqual(cells.count, 20) + for i in cells.indices { + for j in cells.indices where j > i { + XCTAssertFalse(cells[i].insetBy(dx: -metrics.gap / 2 + 0.01, dy: -metrics.gap / 2 + 0.01) + .intersects(cells[j].insetBy(dx: -metrics.gap / 2 + 0.01, dy: -metrics.gap / 2 + 0.01)), + "\(size.size) strip=\(strip) \(scale) \(main)/\(function)") + } + } + } + } + } + } + } + } + + func testSettingsStaysReachableAboveTheKeyboard() throws { + let layout = railLayout(functionGroups: [], keyboardTop: 480) + let settings = try XCTUnwrap(layout.mainSegments.last).frame + let system = try XCTUnwrap(layout.mainSegments.first).frame + XCTAssertEqual(settings.maxY, 480 - PadControlMetrics.edgeMargin, accuracy: 0.01) + XCTAssertEqual(system.minY, 24 + PadControlMetrics.edgeMargin, accuracy: 0.01) + XCTAssertEqual(settings.midX, system.midX, accuracy: 0.01, "both stay on the outer lane") + let cells = layout.mainCells + for i in cells.indices { + for j in cells.indices where j > i { XCTAssertFalse(cells[i].intersects(cells[j])) } + XCTAssertLessThanOrEqual(cells[i].maxY, 480) + } + } + + // MARK: - Modifier buttons + software keyboard + + func testTypedKeysCombineWithTheActiveChord() throws { + let copy = try XCTUnwrap(SoftwareKeyboardChordPolicy.press(for: "c", modifiers: [.command])) + XCTAssertEqual(copy.usage, 6) + XCTAssertEqual(copy.modifiers, ["command"]) + let selectAll = try XCTUnwrap(SoftwareKeyboardChordPolicy.press(for: "a", modifiers: [.command])) + XCTAssertEqual(selectAll.usage, 4) + let palette = try XCTUnwrap(SoftwareKeyboardChordPolicy.press(for: "P", modifiers: [.command])) + XCTAssertEqual(palette.usage, 19) + XCTAssertEqual(palette.modifiers, ["command", "shift"], "an uppercase letter adds Shift") + XCTAssertEqual(SoftwareKeyboardChordPolicy.press(for: "3", modifiers: [.command, .shift])?.usage, 32) + XCTAssertNil(SoftwareKeyboardChordPolicy.press(for: "c", modifiers: []), "no chord: ordinary text") + XCTAssertNil(SoftwareKeyboardChordPolicy.press(for: "cc", modifiers: [.command])) + XCTAssertEqual(SoftwareKeyboardChordPolicy.modifiers(["shift"], chord: [.command]), ["command", "shift"]) + } + + // MARK: - Function Tray editing + + private func customItem(_ id: String = "u") -> ShortcutItem { + ShortcutItem(id: id, title: "Underline", displayKey: "U", usage: 24, modifiers: ModifierChord([.control])) + } + + func testFunctionTrayAddRemoveReorder() { + var profile = FunctionTrayProfile.canonical() + let count = profile.items.count + XCTAssertTrue(profile.addCustomItem(customItem())) + XCTAssertEqual(profile.items.count, count + 1) + XCTAssertTrue(profile.visibleItems.contains { $0.id == "u" }) + XCTAssertFalse(profile.addCustomItem(customItem()), "ids stay unique") + profile.moveItems(from: IndexSet(integer: profile.items.count - 1), to: 0) + XCTAssertEqual(profile.items.first?.id, "u") + profile.removeItem(id: "u") + XCTAssertFalse(profile.items.contains { $0.id == "u" }, "custom actions are deleted") + profile.removeItem(id: "undo") + XCTAssertEqual(profile.items.first { $0.id == "undo" }?.isVisible, false, "built-ins are hidden") + XCTAssertEqual(profile.resolvingCanonicalMetadata().items.first { $0.id == "undo" }?.isVisible, false) + var invalid = customItem("bad") + invalid.action = .keyboardShortcut(KeyboardShortcut(usage: 999, modifiers: ModifierChord())) + XCTAssertFalse(profile.addCustomItem(invalid)) + } + + func testRenamesAndFacesPersistAndSurviveCanonicalRefresh() throws { + var profile = FunctionTrayProfile.canonical() + profile.rename(id: "undo", to: "Back") + profile.setDisplay(id: "undo", .emoji("⏪")) + profile.addCustomItem(customItem()) + profile.setDisplay(id: "u", .text("U̲")) + profile.rename(id: "u", to: "Underline It") + let data = try JSONEncoder().encode(profile) + let decoded = try JSONDecoder().decode(FunctionTrayProfile.self, from: data).resolvingCanonicalMetadata() + let undo = try XCTUnwrap(decoded.allItems.first { $0.id == "undo" }) + XCTAssertEqual(undo.title, "Back") + XCTAssertEqual(undo.face, .text("⏪")) + XCTAssertEqual(undo.action, FunctionTrayProfile.canonical().items.first { $0.id == "undo" }?.item.action, + "the action is untouched by its label") + let custom = try XCTUnwrap(decoded.allItems.first { $0.id == "u" }) + XCTAssertEqual(custom.title, "Underline It") + XCTAssertEqual(custom.face, .text("U̲")) + var keys = customItem() + keys.display = .keys + XCTAssertEqual(keys.face, .text("⌃U")) + var symbol = customItem() + symbol.display = .symbol("underline") + XCTAssertEqual(symbol.face, .symbol("underline")) + XCTAssertEqual(customItem().face, .text("U"), "default: the key cap") + } + + // MARK: - Sequences + + private func sequenceAction() -> ControlAction { + .sequence([ + ControlActionStep(action: .keyboardShortcut(KeyboardShortcut(usage: 14, modifiers: ModifierChord([.command]))), + delayMs: 200), + ControlActionStep(action: .keyboardShortcut(KeyboardShortcut(usage: 14, modifiers: ModifierChord(), + additionalUsages: [32])), delayMs: 100), + ControlActionStep(action: .receiverGesture(ReceiverGesture.showDesktop.rawValue)), + ]) + } + + func testSequencesRunInOrderWithBalancedKeys() { + let operations = ControlActionPlan.operations(for: sequenceAction()) + XCTAssertEqual(operations, [ + .press(usage: 14, modifiers: ["command"]), + .pause(milliseconds: 200), + .key(KeyboardChordEvent(phase: .down, usage: 14, modifiers: [])), + .key(KeyboardChordEvent(phase: .down, usage: 32, modifiers: [])), + .key(KeyboardChordEvent(phase: .up, usage: 32, modifiers: [])), + .key(KeyboardChordEvent(phase: .up, usage: 14, modifiers: [])), + .pause(milliseconds: 100), + .gesture("showDesktop"), + ]) + var held = Set() + for case .key(let event) in operations { + if event.phase == .down { held.insert(event.usage) } else { held.remove(event.usage) } + } + XCTAssertTrue(held.isEmpty) + } + + func testSequencesAreBounded() { + let step = ControlActionStep(action: .receiverGesture("spotlight")) + XCTAssertTrue(ControlAction.sequence([step]).isValid) + XCTAssertFalse(ControlAction.sequence([]).isValid) + XCTAssertFalse(ControlAction.sequence(Array(repeating: step, count: 9)).isValid) + XCTAssertFalse(ControlAction.sequence([ControlActionStep(action: .sequence([step]))]).isValid, "no nesting") + XCTAssertFalse(ControlAction.sequence([ControlActionStep(action: .receiverGesture("rm -rf"))]).isValid) + XCTAssertFalse(ControlAction.sequence([ControlActionStep(action: .receiverGesture("spotlight"), delayMs: 9_000)]).isValid) + XCTAssertEqual(ControlActionPlan.operations(for: .sequence([])), [], "invalid actions do nothing") + } + + func testSequenceItemsRoundTrip() throws { + var item = customItem("macro") + item.action = sequenceAction() + item.display = .emoji("🚀") + XCTAssertEqual(try JSONDecoder().decode(ShortcutItem.self, from: JSONEncoder().encode(item)), item) + XCTAssertEqual(item.keysDescription, "⌘K → K+3 → Underline") + } + + // MARK: - Scroll direction + + func testScrollInversionIsPerAxis() { + let natural = ScrollDirectionPolicy.apply(dx: 3, dy: -5, invertVertical: true, invertHorizontal: true) + XCTAssertEqual(natural.dx, 3) + XCTAssertEqual(natural.dy, -5) + let vertical = ScrollDirectionPolicy.apply(dx: 0, dy: -5, invertVertical: false, invertHorizontal: true) + XCTAssertEqual(vertical.dy, 5) + let horizontal = ScrollDirectionPolicy.apply(dx: 3, dy: 0, invertVertical: true, invertHorizontal: false) + XCTAssertEqual(horizontal.dx, -3) + let diagonal = ScrollDirectionPolicy.apply(dx: 3, dy: -5, invertVertical: false, invertHorizontal: true) + XCTAssertEqual(diagonal.dx, 3, "each axis follows only its own setting") + XCTAssertEqual(diagonal.dy, 5) + let both = ScrollDirectionPolicy.apply(dx: 3, dy: -5, invertVertical: false, invertHorizontal: false) + XCTAssertEqual(both.dx, -3) + XCTAssertEqual(both.dy, 5) + } + + func testSchema18DefaultsAndMigration() throws { + let preferences = ReceiverControlPreferences() + XCTAssertTrue(preferences.invertVerticalScroll) + XCTAssertTrue(preferences.invertHorizontalScroll) + XCTAssertEqual(preferences.padOverlayPaletteStyle, .keys) + let suite = "SecondQA.\(UUID().uuidString)" + let defaults = try XCTUnwrap(UserDefaults(suiteName: suite)) + defer { defaults.removePersistentDomain(forName: suite) } + var old = ReceiverControlPreferences() + old.version = 17 + old.padShowControlHints = true + var json = try XCTUnwrap(JSONSerialization.jsonObject(with: JSONEncoder().encode(old)) as? [String: Any]) + for key in ["invertVerticalScroll", "invertHorizontalScroll", "padOverlayPaletteStyle"] { json.removeValue(forKey: key) } + defaults.set(try JSONSerialization.data(withJSONObject: json), forKey: ReceiverControlPreferencesRepository.defaultsKey) + let repository = ReceiverControlPreferencesRepository(defaults: defaults) + var loaded = repository.load() + XCTAssertEqual(loaded.version, 18) + XCTAssertTrue(loaded.padShowControlHints, "a schema-17 choice survives") + XCTAssertTrue(loaded.invertVerticalScroll) + loaded.invertHorizontalScroll = false + repository.save(loaded) + XCTAssertFalse(repository.load().invertHorizontalScroll) + } + + // MARK: - Custom system controls and editor + + func testTemplatePlacesTheSystemGroupOnTheOppositeSide() { + for corner in ControlCorner.allCases { + let arrangement = CustomControlArrangement.radialTemplate(corner: corner, portrait: false) + let system = arrangement.placements.filter { CustomControlArrangement.templateSystem.contains($0.kind) } + XCTAssertEqual(system.map(\.kind), CustomControlArrangement.templateSystem) + for placement in system { + XCTAssertEqual(placement.x < 0.5, !corner.isLeading, "\(corner): opposite side") + XCTAssertEqual(placement.y < 0.5, corner.isTop) + } + } + } + + func testTwoHandAssistStaysDormantWhileEditing() { + var interaction = ControlInteractionState() + interaction.press(.command) + _ = interaction.toggleAssistModifier(.option) + XCTAssertEqual(EditorTwoHandPresentation.state(previewing: false, showingAssistPreview: false, enabled: true, + interaction: interaction), .hidden) + XCTAssertEqual(EditorTwoHandPresentation.state(previewing: false, showingAssistPreview: true, enabled: true, + interaction: ControlInteractionState()), .helper(active: [.command])) + XCTAssertEqual(EditorTwoHandPresentation.state(previewing: true, showingAssistPreview: false, enabled: true, + interaction: interaction), .helper(active: [.command, .option])) + XCTAssertEqual(EditorTwoHandPresentation.state(previewing: true, showingAssistPreview: true, enabled: false, + interaction: interaction), .hidden) + } + + func testSnapToEdgesAndSnapToGuidesAreSeparate() { + let area = CGRect(x: 12, y: 36, width: 1170, height: 766) + let nearLeft = CGPoint(x: area.minX + 22 + 8, y: area.minY + 300) + let edge = CustomLayoutGeometry.droppedPosition(for: nearLeft, in: area, diameter: 44, + snapToEdges: true, snapToGuides: false) + XCTAssertEqual(edge.x, Double(22 / area.width), accuracy: 0.0001, "flush against the edge") + let free = CustomLayoutGeometry.droppedPosition(for: nearLeft, in: area, diameter: 44, + snapToEdges: false, snapToGuides: false) + XCTAssertEqual(free.x, Double(30 / area.width), accuracy: 0.0001) + let nearCenter = CGPoint(x: area.midX + 7, y: area.minY + area.height * 0.2503) + let centered = CustomLayoutGeometry.droppedPosition(for: nearCenter, in: area, diameter: 44, + snapToEdges: true, snapToGuides: true) + XCTAssertEqual(centered.x, 0.5, accuracy: 0.0001, "center line") + XCTAssertEqual(centered.y, 0.25, accuracy: 0.0001, "guide line") + } + + // MARK: - Wheel + + func testWheelDividesARingIntoEqualWedgesAroundTheModifier() throws { + let anchor = CGRect(x: 580, y: 380, width: 44, height: 44) + let wheel = try XCTUnwrap(WheelPaletteGeometry.layout(count: 6, anchor: anchor, keyDiameter: 40, spacing: 8, + bounds: container)) + XCTAssertEqual(wheel.center, CGPoint(x: anchor.midX, y: anchor.midY)) + XCTAssertGreaterThanOrEqual(wheel.innerRadius, 22 + 8) + XCTAssertEqual(wheel.segments.count, 6) + let total = wheel.segments.map { $0.endAngle - $0.startAngle }.reduce(0, +) + XCTAssertEqual(total, 2 * .pi, accuracy: 0.0001) + for (index, segment) in wheel.segments.enumerated() { + XCTAssertEqual(segment.endAngle - segment.startAngle, .pi / 3, accuracy: 0.0001) + XCTAssertEqual(hypot(segment.labelPoint.x - wheel.center.x, segment.labelPoint.y - wheel.center.y), + (wheel.innerRadius + wheel.outerRadius) / 2, accuracy: 0.001) + XCTAssertEqual(wheel.segmentIndex(at: segment.labelPoint), index) + } + XCTAssertEqual(wheel.segments[0].labelPoint.x, wheel.center.x, accuracy: 0.001, "first segment on top") + XCTAssertNil(wheel.segmentIndex(at: wheel.center), "the center is the anchor, not an action") + } + + func testWheelMovesInwardToStayOnScreenAndWidensForManyActions() throws { + let corner = CGRect(x: 1150, y: 790, width: 44, height: 44) + let wheel = try XCTUnwrap(WheelPaletteGeometry.layout(count: 8, anchor: corner, keyDiameter: 40, spacing: 8, + bounds: container)) + XCTAssertTrue(container.contains(wheel.frame)) + let crowded = try XCTUnwrap(WheelPaletteGeometry.layout(count: 16, anchor: CGRect(x: 580, y: 380, width: 44, height: 44), + keyDiameter: 40, spacing: 8, bounds: container)) + let middle = (crowded.innerRadius + crowded.outerRadius) / 2 + XCTAssertGreaterThanOrEqual(2 * .pi * middle / 16, 40 * 1.15 - 0.01, "more than a key's width per wedge") + XCTAssertGreaterThanOrEqual(crowded.outerRadius - crowded.innerRadius, 40 * 1.5 - 0.01, "deep enough to hit") + } + + // MARK: - Arc fan in every corner + + func testArcFansIntoFreeSpaceFromEveryCornerAndMovedAnchor() { + let bounds = CGRect(x: 12, y: 36, width: 1170, height: 766) + let anchors: [CGPoint] = [CGPoint(x: 60, y: 80), CGPoint(x: 1140, y: 80), CGPoint(x: 60, y: 760), + CGPoint(x: 1140, y: 760), CGPoint(x: 597, y: 760), CGPoint(x: 60, y: 420), + CGPoint(x: 597, y: 419)] + for anchor in anchors { + let toward = RadialPaletteLayout.direction(from: anchor, toward: CGPoint(x: bounds.midX, y: bounds.midY)) + let points = RadialPaletteLayout.points(count: 6, center: anchor, shape: .arc, itemDiameter: 40, spacing: 8, + minimumRadius: 110, direction: toward, bounds: bounds) + XCTAssertEqual(points.count, 6, "\(anchor)") + let radii = points.map { hypot($0.x - anchor.x, $0.y - anchor.y) } + for radius in radii { XCTAssertEqual(radius, radii[0], accuracy: 0.01, "\(anchor): one arc") } + let angles = points.map { atan2($0.y - anchor.y, $0.x - anchor.x) } + let steps = zip(angles, angles.dropFirst()).map { ManualViewportState.normalizedAngle($1 - $0) } + for step in steps { XCTAssertEqual(step, steps[0], accuracy: 0.001, "\(anchor): even spacing") } + let inner = bounds.insetBy(dx: 20, dy: 20) + for point in points { XCTAssertTrue(inner.contains(point), "\(anchor): on screen \(point)") } + // It opens toward the room available, not into a corner. + let mean = CGPoint(x: points.map(\.x).reduce(0, +) / 6 - anchor.x, + y: points.map(\.y).reduce(0, +) / 6 - anchor.y) + XCTAssertGreaterThan(mean.x * cos(toward) + mean.y * sin(toward), 0, "\(anchor)") + } + } + + func testCornerClusterFollowsWhereTheAnchorActuallyIs() { + // Stored as bottom-right, but arranged top-left. + var arrangement = CustomControlArrangement.radialTemplate(corner: .topLeading, portrait: false) + arrangement.corner = .bottomTrailing + let area = CGRect(origin: .zero, size: CustomControlArrangement.referenceLandscape) + let frames = CustomLayoutGeometry.frames(for: arrangement, in: area, baseDiameter: 44) + XCTAssertEqual(CornerCluster.resolve(arrangement: arrangement, frames: frames, area: area)?.corner, .topLeading) + } + + // MARK: - Import + + func testImportFlowOnlyFinishesAfterASuccessfulImport() throws { + var preferences = ReceiverControlPreferences() + preferences.createCustomLayout(id: "a") + let code = try CustomLayoutShareCode.encode(preferences.customLayouts[0]) + var flow = CustomLayoutImportFlow() + flow.validate() + XCTAssertEqual(flow.stage, .entering) + flow.paste("garbage") + XCTAssertEqual(flow.stage, .failed(.notAShareCode)) + XCTAssertFalse(flow.isFinished) + flow.paste(code) + guard case .reviewing = flow.stage else { return XCTFail("expected a summary") } + XCTAssertFalse(flow.isFinished, "reviewing never closes the screen") + flow.importLayout(into: &preferences) + XCTAssertTrue(flow.isFinished) + XCTAssertEqual(preferences.customLayouts.count, 2) + var full = preferences + while full.canCreateCustomLayout { full.createCustomLayout() } + var second = CustomLayoutImportFlow() + second.paste(code) + second.importLayout(into: &full) + guard case .limitReached = second.stage else { return XCTFail("expected the limit") } + XCTAssertFalse(second.isFinished) + second.edit("") + XCTAssertEqual(second.stage, .entering) + } + + func testSharedLayoutsCarryTheirCustomActions() throws { + var layout = CustomControlLayout.radialTemplate(name: "Macro", corner: .bottomTrailing) + layout.landscape.placements.append(CustomControlPlacement(id: "m", kind: .function("my-macro"), x: 0.5, y: 0.5)) + layout.landscape.placements.append(CustomControlPlacement(id: "z", kind: .function("zoom-in"), x: 0.4, y: 0.5)) + layout.assistActionIDs = ["undo", "my-macro"] + var macro = customItem("my-macro") + macro.action = sequenceAction() + macro.display = .emoji("🚀") + let unrelated = customItem("unused") + let code = try CustomLayoutShareCode.encode(layout, functionItems: [macro, unrelated]) + let summary = try CustomLayoutShareCode.decode(code).get() + let placements = summary.layout.landscape.placements + let adopted = try XCTUnwrap(placements.first { $0.id == "m" }) + guard case .shortcut(let item) = adopted.kind else { return XCTFail("the macro travels as a one-tap button") } + XCTAssertEqual(item.action, macro.action) + XCTAssertEqual(item.display, .emoji("🚀")) + XCTAssertNotEqual(item.id, "my-macro", "a new local definition") + XCTAssertEqual(placements.first { $0.id == "z" }?.kind, .function("zoom-in"), "built-ins travel by id") + XCTAssertEqual(summary.layout.assistActionIDs, ["undo"]) + XCTAssertFalse(code.isEmpty) + // Only what the layout uses. + let body = code.dropFirst(5).split(separator: ".")[0] + var base64 = body.replacingOccurrences(of: "-", with: "+").replacingOccurrences(of: "_", with: "/") + base64 += String(repeating: "=", count: (4 - base64.count % 4) % 4) + let json = try (XCTUnwrap(Data(base64Encoded: base64)) as NSData).decompressed(using: .zlib) as Data + XCTAssertFalse(String(decoding: json, as: UTF8.self).contains("unused")) + } + + // MARK: - Request Input + + func testBlockedTouchOffersRequestInputThenFades() { + var prompt = ReceiverInputPrompt() + prompt.setSessionLive(true) + XCTAssertEqual(prompt.blockedAttempt(now: 0, userTurnedOff: false, autoRequest: false), []) + XCTAssertEqual(prompt.kind, .requestInput) + XCTAssertTrue(prompt.isVisible(at: 1)) + _ = prompt.blockedAttempt(now: 3, userTurnedOff: false, autoRequest: false) + XCTAssertTrue(prompt.isVisible(at: 6), "another attempt refreshes the timer") + prompt.tick(now: 7) + XCTAssertNil(prompt.kind, "it fades once attempts stop") + _ = prompt.blockedAttempt(now: 10, userTurnedOff: true, autoRequest: false) + XCTAssertEqual(prompt.kind, .enableInput) + } + + func testRequestIsSentOnceAndTheGrantShowsBriefly() { + var prompt = ReceiverInputPrompt() + prompt.setSessionLive(true) + _ = prompt.blockedAttempt(now: 0, userTurnedOff: false, autoRequest: false) + XCTAssertEqual(prompt.requestTapped(now: 0.5), [.sendRequest]) + XCTAssertEqual(prompt.kind, .requesting) + XCTAssertEqual(prompt.requestTapped(now: 0.7), [], "no duplicate requests") + prompt.inputStateChanged(.requesting, now: 0.8) + XCTAssertEqual(prompt.blockedAttempt(now: 1, userTurnedOff: false, autoRequest: true), [], "already pending") + XCTAssertEqual(prompt.kind, .requesting) + prompt.inputStateChanged(.allowed, now: 2) + XCTAssertEqual(prompt.kind, .enabled) + prompt.tick(now: 2 + ReceiverInputPrompt.enabledDuration + 0.01) + XCTAssertNil(prompt.kind) + XCTAssertEqual(prompt.blockedAttempt(now: 5, userTurnedOff: false, autoRequest: true), [], "input works now") + } + + func testAlwaysAllowPeersRequestAutomaticallyButRateLimited() { + var prompt = ReceiverInputPrompt() + prompt.setSessionLive(true) + XCTAssertEqual(prompt.blockedAttempt(now: 0, userTurnedOff: false, autoRequest: true), [.sendRequest]) + prompt.inputStateChanged(.off, now: 0.2) + XCTAssertEqual(prompt.blockedAttempt(now: 1, userTurnedOff: false, autoRequest: true), [], "rate-limited") + XCTAssertEqual(prompt.blockedAttempt(now: 10, userTurnedOff: false, autoRequest: true), [.sendRequest]) + prompt.inputStateChanged(.notAllowed, now: 11) + XCTAssertEqual(prompt.blockedAttempt(now: 30, userTurnedOff: false, autoRequest: true), [], + "a declined request is never re-sent automatically") + XCTAssertEqual(prompt.blockedAttempt(now: 40, userTurnedOff: true, autoRequest: true), [], + "input the user turned off stays off until they ask") + } + + func testNoPromptWithoutALiveSessionOrWhenTheMacDisablesRequests() { + var prompt = ReceiverInputPrompt() + XCTAssertEqual(prompt.blockedAttempt(now: 0, userTurnedOff: false, autoRequest: true), []) + XCTAssertNil(prompt.kind) + prompt.setSessionLive(true) + prompt.inputStateChanged(.requestsDisabled, now: 0) + _ = prompt.blockedAttempt(now: 1, userTurnedOff: false, autoRequest: true) + XCTAssertEqual(prompt.kind, .unavailable) + XCTAssertEqual(prompt.requestTapped(now: 2), []) + } + + func testLocalViewGesturesNeverAskForInput() { + XCTAssertFalse(BlockedInputAttemptPolicy.isRemoteIntent(maximumTouchCount: 2, moveViewActive: false), + "two fingers are local pan/zoom/rotate") + XCTAssertFalse(BlockedInputAttemptPolicy.isRemoteIntent(maximumTouchCount: 1, moveViewActive: true)) + XCTAssertTrue(BlockedInputAttemptPolicy.isRemoteIntent(maximumTouchCount: 1, moveViewActive: false)) + XCTAssertTrue(BlockedInputAttemptPolicy.isRemoteIntent(maximumTouchCount: 3, moveViewActive: false)) + XCTAssertFalse(BlockedInputAttemptPolicy.isRemoteIntent(maximumTouchCount: 0, moveViewActive: false)) + } +} diff --git a/MacTests/ThirdQARefinementTests.swift b/MacTests/ThirdQARefinementTests.swift new file mode 100644 index 00000000..184cde6e --- /dev/null +++ b/MacTests/ThirdQARefinementTests.swift @@ -0,0 +1,200 @@ +import CoreGraphics +import XCTest + +/// Third physical-QA pass: blocked touches never enable input on their own, +/// local navigation stays independent of input, the keyboard accessory bar, +/// and user-defined chord palettes. +final class ThirdQARefinementTests: XCTestCase { + private func makeDefaults() throws -> (UserDefaults, String) { + let suite = "ThirdQA.\(UUID().uuidString)" + return (try XCTUnwrap(UserDefaults(suiteName: suite)), suite) + } + + // MARK: - Blocked touches and input permission + + func testBlockedTouchWithoutOptInOnlyShowsThePrompt() { + var prompt = ReceiverInputPrompt() + prompt.setSessionLive(true) + let preferencesBefore = ReceiverControlPreferences() + var preferences = preferencesBefore + preferences.allowInput = false + let snapshot = preferences + for time in stride(from: 0.0, to: 30, by: 1.5) { + XCTAssertEqual(prompt.blockedAttempt(now: time, userTurnedOff: false, autoRequest: false), [], + "no request is ever sent by itself") + } + XCTAssertEqual(prompt.kind, .requestInput) + XCTAssertEqual(preferences, snapshot, "a blocked touch never touches the input preference") + } + + func testLocallyTurnedOffInputOffersEnableAndNeverAutoRequests() { + var prompt = ReceiverInputPrompt() + prompt.setSessionLive(true) + XCTAssertEqual(prompt.blockedAttempt(now: 0, userTurnedOff: true, autoRequest: true), []) + XCTAssertEqual(prompt.kind, .enableInput) + // Only the user's explicit tap asks the Mac. + XCTAssertEqual(prompt.requestTapped(now: 1), [.sendRequest]) + } + + func testConnectionAlwaysAllowIsNotConsentToRequestInput() throws { + let (defaults, suite) = try makeDefaults() + defer { defaults.removePersistentDomain(forName: suite) } + IncomingSessionPolicyStore.setPolicy(.alwaysAllow, peerID: "mac", defaults: defaults) + XCTAssertFalse(InputAutoRequestStore.isEnabled(peerID: "mac", defaults: defaults)) + XCTAssertFalse(InputAutoRequestPolicy.shouldAutoRequest( + peerOptedIn: InputAutoRequestStore.isEnabled(peerID: "mac", defaults: defaults))) + XCTAssertFalse(InputAutoRequestStore.isEnabled(peerID: nil, defaults: defaults)) + } + + func testExplicitOptInSendsOnlyTheRealRequest() throws { + let (defaults, suite) = try makeDefaults() + defer { defaults.removePersistentDomain(forName: suite) } + InputAutoRequestStore.setEnabled(true, peerID: "mac", defaults: defaults) + let auto = InputAutoRequestPolicy.shouldAutoRequest( + peerOptedIn: InputAutoRequestStore.isEnabled(peerID: "mac", defaults: defaults)) + XCTAssertTrue(auto) + var prompt = ReceiverInputPrompt() + prompt.setSessionLive(true) + XCTAssertEqual(prompt.blockedAttempt(now: 0, userTurnedOff: false, autoRequest: auto), [.sendRequest]) + XCTAssertEqual(prompt.kind, .requesting, "requested — not granted") + XCTAssertEqual(prompt.blockedAttempt(now: 1, userTurnedOff: false, autoRequest: auto), [], "deduped") + // Only the Mac's confirmed state can show input as enabled. + prompt.inputStateChanged(.allowed, now: 2) + XCTAssertEqual(prompt.kind, .enabled) + InputAutoRequestStore.remove(peerID: "mac", defaults: defaults) + XCTAssertFalse(InputAutoRequestStore.isEnabled(peerID: "mac", defaults: defaults), "forgetting clears it") + } + + // MARK: - Local navigation without input + + func testLocalNavigationWorksAndNeverAsksForInput() { + var gate = ReceiverMultiFingerGestureGate(voiceOverRunning: false) + _ = gate.update(remoteInputAllowed: false, localNavigationAllowed: true) + XCTAssertTrue(gate.isEnabled(.twoFingerViewport)) + XCTAssertTrue(gate.isEnabled(.viewportDoubleTap)) + XCTAssertEqual(TwoFingerRoutingPolicy.routes(intent: .viewportZoomPan, remoteInputAllowed: false, + localNavigationAllowed: true, videoEnabled: true, + pinchTarget: .viewport, rotateTarget: .viewport), [.viewport]) + XCTAssertEqual(TwoFingerRoutingPolicy.routes(intent: .scroll, remoteInputAllowed: false, + localNavigationAllowed: true, videoEnabled: true, + pinchTarget: .viewport, rotateTarget: .viewport), []) + XCTAssertTrue(LocalViewNavigationPolicy.surfaceAcceptsTouches(remoteInputAllowed: false, + localNavigationAllowed: true, videoEnabled: true)) + // Pinch, pan, rotate and the reset double tap are two-finger; + // Move View is its own mode — neither refreshes the prompt. + var prompt = ReceiverInputPrompt() + prompt.setSessionLive(true) + XCTAssertFalse(BlockedInputAttemptPolicy.isRemoteIntent(maximumTouchCount: 2, moveViewActive: false)) + XCTAssertFalse(BlockedInputAttemptPolicy.isRemoteIntent(maximumTouchCount: 1, moveViewActive: true)) + XCTAssertNil(prompt.kind) + var session = ViewportNavigationSession() + let base = CGRect(x: 0, y: 0, width: 800, height: 500) + var state = session.update(points: [CGPoint(x: 300, y: 250), CGPoint(x: 500, y: 250)], current: .identity, + base: base, allowsZoom: true, allowsRotation: true) + state = session.update(points: [CGPoint(x: 250, y: 250), CGPoint(x: 550, y: 250)], current: state, + base: base, allowsZoom: true, allowsRotation: true) + XCTAssertEqual(state.scale, 1.5, accuracy: 0.001) + } + + // MARK: - Keyboard accessory bar + + func testKeyboardBarHoldsOnlyKeyboardCompanions() { + XCTAssertEqual(ReceiverControlPresentation.mode(softwareKeyboardVisible: true), .keyboardBar) + XCTAssertEqual(ReceiverControlPresentation.mode(softwareKeyboardVisible: false), .normal) + XCTAssertEqual(KeyboardBarItem.defaultItems, [.modifier(.command), .modifier(.option), .modifier(.control), + .modifier(.shift), .escape, .tab, .dismissKeyboard]) + } + + func testNormalControlsReturnUnchangedAfterTheKeyboard() { + let preferences = ReceiverControlPreferences() + func rail() -> PadEdgeLayout { + PadEdgeGeometry.layout(container: CGRect(x: 0, y: 0, width: 1194, height: 834), + safeInsets: ControlSafeInsets(top: 24, leading: 0, bottom: 20, trailing: 0), + reservesStrips: preferences.padControlLayout == .strip, + mainEdge: preferences.padMainEdge, functionEdge: preferences.padFunctionEdge, + mainGroupCounts: [4, 7, 2], mainGroupAlignments: [.start, .center, .end], + functionGroupCounts: [2, 2], metrics: PadControlMetrics()) + } + let before = rail() + // The keyboard bar is a presentation switch only — nothing about the + // stored layout changes while it shows. + _ = ReceiverControlPresentation.mode(softwareKeyboardVisible: true) + XCTAssertEqual(ReceiverControlPresentation.mode(softwareKeyboardVisible: false), .normal) + XCTAssertEqual(rail(), before) + } + + func testLatchedModifierAndTypedKeyAreOneChord() throws { + var interaction = ControlInteractionState() + let latch = interaction.tap(.command) + XCTAssertEqual(latch.first, .modifierDown(.command)) + let press = try XCTUnwrap(SoftwareKeyboardChordPolicy.press(for: "c", modifiers: interaction.activeChord.modifiers)) + XCTAssertEqual(press.usage, 6) + XCTAssertEqual(press.modifiers, ["command"], "⌘C in a single press") + XCTAssertEqual(interaction.latchedModifiers, [.command], "the latch stays, as for palette taps") + } + + // MARK: - User-defined palettes + + private func customPalette() -> [ShortcutItem] { + var spotlight = ShortcutItem(id: "s", title: "Spotlight", displayKey: "Space", usage: 44, + modifiers: ModifierChord([.command])) + spotlight.display = .emoji("🔎") + var macro = ShortcutItem(id: "m", title: "Macro", displayKey: "⋯", usage: 4, modifiers: ModifierChord()) + macro.action = .sequence([ControlActionStep(action: .keyboardShortcut(KeyboardShortcut(usage: 14, + modifiers: ModifierChord([.command])))), + ControlActionStep(action: .receiverGesture("showDesktop"))]) + let desktop = ShortcutItem(id: "d", title: "Show Desktop", gesture: .showDesktop, systemImage: "rectangle.dashed") + let copy = ShortcutItem(id: "c", title: "Copy", displayKey: "C", usage: 6, modifiers: ModifierChord([.command])) + return [copy, spotlight, macro, desktop] + } + + func testCustomPaletteActionsPersist() throws { + let (defaults, suite) = try makeDefaults() + defer { defaults.removePersistentDomain(forName: suite) } + let repository = ReceiverControlPreferencesRepository(defaults: defaults) + var preferences = repository.load() + var profile = preferences.profile(for: .default) + profile.setActions(customPalette(), for: ModifierChord([.command])) + preferences.updateProfile(profile) + repository.save(preferences) + let loaded = repository.load().profile(for: .default).actions(for: ModifierChord([.command])) + XCTAssertEqual(loaded, customPalette()) + XCTAssertEqual(loaded[1].face, .text("🔎"), "the face is stored apart from the action") + XCTAssertEqual(loaded[1].action, .keyboardShortcut(KeyboardShortcut(usage: 44, modifiers: ModifierChord([.command])))) + } + + func testPaletteActionsCanBeReorderedAndRemoved() { + var profile = ControlProfile.canonical() + let chord = ModifierChord([.command]) + profile.setActions(customPalette(), for: chord) + profile.moveActions(for: chord, from: IndexSet(integer: 3), to: 0) + XCTAssertEqual(profile.actions(for: chord).map(\.id), ["d", "c", "s", "m"]) + var actions = profile.actions(for: chord) + actions.removeAll { $0.id == "s" } + profile.setActions(actions, for: chord) + XCTAssertEqual(profile.actions(for: chord).map(\.id), ["d", "c", "m"]) + } + + func testPreviewAndRuntimeReadTheSamePalette() throws { + var preferences = ReceiverControlPreferences() + var profile = preferences.profile(for: .default) + profile.setActions(customPalette(), for: ModifierChord([.command])) + preferences.updateProfile(profile) + // Both the overlay and the editor's Preview ask the active profile. + let runtime = preferences.profile(for: preferences.activeControlProfile).actions(for: ModifierChord([.command])) + var session = CustomLayoutPreviewSession() + session.tapModifier(.command) + let chord = try XCTUnwrap(session.interaction.paletteChord) + let preview = preferences.profile(for: preferences.activeControlProfile).actions(for: chord) + XCTAssertEqual(preview, runtime) + session.tapPaletteAction(preview[3]) + XCTAssertEqual(session.feedback, .wouldPerform("Show Desktop"), + "a system action in a palette previews without sending") + session.tapPaletteAction(preview[0]) + XCTAssertEqual(session.feedback, .wouldSend("⌘C")) + // What a runtime tap would send, for each kind. + XCTAssertEqual(ControlActionPlan.operations(for: runtime[2].action).first, + .press(usage: 14, modifiers: ["command"])) + XCTAssertEqual(ControlActionPlan.operations(for: runtime[3].action), [.gesture("showDesktop")]) + } +} diff --git a/PROTOCOL.md b/PROTOCOL.md index ceb8d841..00c91797 100644 --- a/PROTOCOL.md +++ b/PROTOCOL.md @@ -595,6 +595,12 @@ official receiver recognizes these gestures: * `"previousSpace"`: three-finger swipe right. * `"showDesktop"`: four- or five-finger spread. * `"launchpad"`: four- or five-finger pinch. +* `"spotlight"`: three-finger tap. +* `"controlCenter"`: a one-tap receiver control (no gesture). +* `"menuBar"`: a one-tap receiver control that reveals and focuses the menu + bar (no gesture). Momentary; no visibility state is reported. + +The receiver may also send any of these from a one-tap control. Three-finger gestures require a directional movement rather than a tap; the four- and five-finger gestures require a meaningful change in fingertip diff --git a/Shared/ControlProfile.swift b/Shared/ControlProfile.swift index 1178e761..4912d984 100644 --- a/Shared/ControlProfile.swift +++ b/Shared/ControlProfile.swift @@ -120,21 +120,157 @@ struct TrayItemConfiguration: Codable, Equatable, Identifiable { var id: ControlTrayItem { item } } -struct KeyboardShortcut: Codable, Equatable { +struct KeyboardShortcut: Codable, Hashable { var usage: Int var modifiers: ModifierChord + /// Further ordinary keys held together with `usage` (e.g. K + 3). Empty + /// for every ordinary shortcut, and then never encoded, so the wire and + /// stored form of existing shortcuts is unchanged. + var additionalUsages: [Int] = [] + + /// Every ordinary key in the chord, in press order. + var usages: [Int] { [usage] + additionalUsages } + + /// USB HID keyboard-page usages a chord may use (modifier keys excluded: + /// those travel as `modifiers`). + static let validUsages: ClosedRange = 4...221 + static let maximumKeys = 4 + + var isValid: Bool { + usages.count <= Self.maximumKeys && usages.allSatisfy(Self.validUsages.contains) + && Set(usages).count == usages.count + } } -enum ControlAction: Codable, Equatable { +extension KeyboardShortcut { + private enum CodingKeys: String, CodingKey { case usage, modifiers, additionalUsages } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + usage = try container.decode(Int.self, forKey: .usage) + modifiers = try container.decode(ModifierChord.self, forKey: .modifiers) + additionalUsages = try container.decodeIfPresent([Int].self, forKey: .additionalUsages) ?? [] + } + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(usage, forKey: .usage) + try container.encode(modifiers, forKey: .modifiers) + if !additionalUsages.isEmpty { try container.encode(additionalUsages, forKey: .additionalUsages) } + } +} + +/// One key transition of a one-tap chord. +struct KeyboardChordEvent: Equatable { + enum Phase: Equatable { case down, up } + var phase: Phase + var usage: Int + var modifiers: [String] +} + +/// How a one-tap shortcut reaches the Mac over the existing `keyboard` +/// messages: a single key uses the atomic `press` (unchanged behavior); a +/// multi-key chord presses every key down in order, then releases them in +/// reverse, so nothing is ever left held. +enum KeyboardChordPlan { + static func events(for shortcut: KeyboardShortcut) -> [KeyboardChordEvent] { + let modifiers = ControlModifier.allCases.filter(shortcut.modifiers.contains).map(\.rawValue) + let keys = shortcut.usages + return keys.map { KeyboardChordEvent(phase: .down, usage: $0, modifiers: modifiers) } + + keys.reversed().map { KeyboardChordEvent(phase: .up, usage: $0, modifiers: modifiers) } + } +} + +indirect enum ControlAction: Codable, Hashable, Sendable { case keyboardShortcut(KeyboardShortcut) /// A semantic `ReceiverGesture` (its raw value) sent as the existing /// `gesture` message — for Mac actions that have no reliable keyboard /// shortcut (Launchpad, Show Desktop), so they stay reachable if the /// 4/5-finger pinch collides with iPadOS's multitasking gestures. case receiverGesture(String) + /// A bounded macro: existing keyboard/semantic actions run in order — + /// never scripts, never nested sequences. See `ControlActionPlan`. + case sequence([ControlActionStep]) + + static let maximumSequenceSteps = 8 + static let maximumStepDelayMs = 2000 + + /// Sequences are 1…`maximumSequenceSteps` flat steps; shortcuts must be + /// valid chords; gestures must be known semantic actions. + var isValid: Bool { + switch self { + case .keyboardShortcut(let shortcut): + return shortcut.isValid + case .receiverGesture(let name): + return ReceiverGesture(rawValue: name) != nil + case .sequence(let steps): + return (1...Self.maximumSequenceSteps).contains(steps.count) && steps.allSatisfy { step in + if case .sequence = step.action { return false } + return step.action.isValid && (0...Self.maximumStepDelayMs).contains(step.delayMs) + } + } + } } -struct ShortcutItem: Codable, Equatable, Identifiable { +/// One step of a `ControlAction.sequence`, and the pause after it. +struct ControlActionStep: Codable, Hashable, Sendable { + static let defaultDelayMs = 150 + var action: ControlAction + var delayMs: Int = ControlActionStep.defaultDelayMs +} + +/// One thing a one-tap control does on the existing wire. +enum ControlActionOperation: Equatable { + /// The atomic `keyboard` press (a single key with its modifiers). + case press(usage: Int, modifiers: [String]) + /// One half of a multi-key chord. + case key(KeyboardChordEvent) + /// The semantic `gesture` message. + case gesture(String) + /// A pause between sequence steps. + case pause(milliseconds: Int) +} + +/// The ordered wire operations for a one-tap action. Deterministic; every +/// key that goes down comes back up within its own step, so a sequence can +/// never leave a key held between steps. +enum ControlActionPlan { + static func operations(for action: ControlAction) -> [ControlActionOperation] { + guard action.isValid else { return [] } + switch action { + case .keyboardShortcut(let shortcut): + let modifiers = ControlModifier.allCases.filter(shortcut.modifiers.contains).map(\.rawValue) + if shortcut.additionalUsages.isEmpty { return [.press(usage: shortcut.usage, modifiers: modifiers)] } + return KeyboardChordPlan.events(for: shortcut).map(ControlActionOperation.key) + case .receiverGesture(let name): + return [.gesture(name)] + case .sequence(let steps): + return steps.enumerated().flatMap { index, step -> [ControlActionOperation] in + operations(for: step.action) + + (index < steps.count - 1 && step.delayMs > 0 ? [.pause(milliseconds: step.delayMs)] : []) + } + } + } +} + +/// How a one-tap button looks — separate from what it does. +enum ShortcutButtonDisplay: Codable, Hashable, Sendable { + case symbol(String) + case text(String) + case emoji(String) + /// The keys it sends, e.g. `⌃U`. + case keys + + static let maximumTextLength = 4 +} + +/// What a button face actually draws. +enum ShortcutButtonFace: Equatable { + case symbol(String) + case text(String) +} + +struct ShortcutItem: Codable, Hashable, Identifiable { var id: String var title: String var displayKey: String @@ -144,6 +280,35 @@ struct ShortcutItem: Codable, Equatable, Identifiable { /// showing `displayKey`'s plain keycap text. Never required: any /// renderer can fall back to `displayKey` when this is `nil`. var systemImage: String? + /// The user's chosen face (symbol, text, emoji or keys); `nil` keeps the + /// default of `systemImage`, else `displayKey`. + var display: ShortcutButtonDisplay? + + /// What to draw on this button. + var face: ShortcutButtonFace { + switch display { + case .symbol(let name) where !name.isEmpty: return .symbol(name) + case .text(let text) where !text.isEmpty: return .text(String(text.prefix(ShortcutButtonDisplay.maximumTextLength))) + case .emoji(let emoji) where !emoji.isEmpty: return .text(String(emoji.prefix(2))) + case .keys: return .text(keysDescription) + default: return systemImage.map(ShortcutButtonFace.symbol) ?? .text(displayKey) + } + } + + /// The keys this sends, e.g. `⌘⇧P` or `K+3`; a sequence shows its steps. + var keysDescription: String { + func describe(_ action: ControlAction) -> String { + switch action { + case .keyboardShortcut(let shortcut): + return shortcut.modifiers.symbols + shortcut.usages.map(appGestureCommandKeyLabel(for:)).joined(separator: "+") + case .receiverGesture: + return title + case .sequence(let steps): + return steps.map { describe($0.action) }.joined(separator: " → ") + } + } + return describe(action) + } init(id: String = UUID().uuidString, title: String, displayKey: String, usage: Int, modifiers: ModifierChord, systemImage: String? = nil) { @@ -403,7 +568,20 @@ struct FunctionTrayItemConfiguration: Codable, Equatable, Identifiable { var item: ShortcutItem var isVisible: Bool var group: Int + /// The user's name for a built-in action (custom items rename `item` + /// itself). Survives canonical metadata refreshes. + var customTitle: String? + /// The user's face for a built-in action — see `ShortcutButtonDisplay`. + var customDisplay: ShortcutButtonDisplay? var id: String { item.id } + + /// `item` with the user's name and face applied. + var resolvedItem: ShortcutItem { + var resolved = item + if let customTitle, !customTitle.isEmpty { resolved.title = customTitle } + if let customDisplay { resolved.display = customDisplay } + return resolved + } } /// Independent of `ControlProfile` (the Main Tray's profile type) even @@ -420,9 +598,12 @@ struct FunctionTrayProfile: Codable, Equatable, Identifiable { var id: String { slot.rawValue } var visibleItems: [ShortcutItem] { - items.filter(\.isVisible).map(\.item) + items.filter(\.isVisible).map(\.resolvedItem) } + /// Every action, visible or not, as it should appear. + var allItems: [ShortcutItem] { items.map(\.resolvedItem) } + /// `visibleItems` clustered into consecutive runs of the same `group` /// value, in tray order — the rendering seam between one small bubble /// cluster and the next. A profile with every item in the same group @@ -436,7 +617,7 @@ struct FunctionTrayProfile: Codable, Equatable, Identifiable { groups.append([]) currentGroup = configuration.group } - groups[groups.count - 1].append(configuration.item) + groups[groups.count - 1].append(configuration.resolvedItem) } return groups } @@ -457,7 +638,9 @@ struct FunctionTrayProfile: Codable, Equatable, Identifiable { guard let item = canonicalByID[configuration.id] else { return configuration } return FunctionTrayItemConfiguration(item: item, isVisible: configuration.isVisible, - group: configuration.group) + group: configuration.group, + customTitle: configuration.customTitle, + customDisplay: configuration.customDisplay) } let added = canonical.filter { !savedIDs.contains($0.id) }.map { FunctionTrayItemConfiguration(item: $0.item, isVisible: false, group: $0.group) @@ -471,6 +654,55 @@ extension FunctionTrayProfile { /// "Undo/Redo tray" or "Zoom tray" — so future actions (Disconnect, /// Paste, contextual app actions, …) are just more items (and, /// optionally, groups) in this one list. + static let canonicalIDs: Set = Set(canonical().items.map(\.id)) + + static func isBuiltIn(_ id: String) -> Bool { canonicalIDs.contains(id) } + + static let maximumCustomItems = 24 + + /// Adds a user-made action (a shortcut or a sequence), visible, in its + /// own group at the end. `false` when it isn't valid or the tray is full. + @discardableResult + mutating func addCustomItem(_ item: ShortcutItem) -> Bool { + guard item.action.isValid, !items.contains(where: { $0.id == item.id }), + items.filter({ !Self.isBuiltIn($0.id) }).count < Self.maximumCustomItems else { return false } + let group = (items.map(\.group).max() ?? 0) + 1 + items.append(FunctionTrayItemConfiguration(item: item, isVisible: true, group: group)) + return true + } + + /// Replaces a custom action's definition (its name, keys, face). + mutating func updateCustomItem(_ item: ShortcutItem) { + guard item.action.isValid, !Self.isBuiltIn(item.id), + let index = items.firstIndex(where: { $0.id == item.id }) else { return } + items[index].item = item + } + + /// Built-in actions can't be deleted — they come back hidden on the + /// next refresh — so removing one hides it; custom actions are deleted. + mutating func removeItem(id: String) { + if Self.isBuiltIn(id) { + if let index = items.firstIndex(where: { $0.id == id }) { items[index].isVisible = false } + } else { + items.removeAll { $0.id == id } + } + } + + mutating func rename(id: String, to title: String) { + guard let index = items.firstIndex(where: { $0.id == id }) else { return } + let trimmed = String(title.trimmingCharacters(in: .whitespacesAndNewlines).prefix(40)) + if Self.isBuiltIn(id) { + items[index].customTitle = trimmed.isEmpty ? nil : trimmed + } else if !trimmed.isEmpty { + items[index].item.title = trimmed + } + } + + mutating func setDisplay(id: String, _ display: ShortcutButtonDisplay?) { + guard let index = items.firstIndex(where: { $0.id == id }) else { return } + if Self.isBuiltIn(id) { items[index].customDisplay = display } else { items[index].item.display = display } + } + static func canonical(slot: ControlProfileSlot = .default) -> FunctionTrayProfile { let command = ModifierChord([.command]) let commandShift = ModifierChord([.command, .shift]) @@ -497,10 +729,14 @@ extension FunctionTrayProfile { // Desktop besides the 4/5-finger pinch/spread, which can collide // with iPadOS's own multitasking gestures. let systemItems: [(ShortcutItem, Int)] = [ + (ShortcutItem(id: "menu-bar", title: "Menu Bar", gesture: .menuBar, + systemImage: "menubar.rectangle"), systemGroup), (ShortcutItem(id: "launchpad", title: "Launchpad", gesture: .launchpad, systemImage: "square.grid.3x3"), systemGroup), (ShortcutItem(id: "show-desktop", title: "Show Desktop", gesture: .showDesktop, systemImage: "rectangle.dashed"), systemGroup), + (ShortcutItem(id: "control-center", title: "Control Center", gesture: .controlCenter, + systemImage: "switch.2"), systemGroup), ] return FunctionTrayProfile(slot: slot, items: items.map { FunctionTrayItemConfiguration(item: $0.0, isVisible: true, group: $0.1) @@ -544,7 +780,7 @@ enum LandscapeTrayCorner: String, Codable, CaseIterable, Identifiable { } struct ReceiverControlPreferences: Codable, Equatable { - static let schemaVersion = 16 + static let schemaVersion = 18 var version = schemaVersion var trayEnabled = true @@ -618,6 +854,11 @@ struct ReceiverControlPreferences: Codable, Equatable { /// setting away. var rotateTarget = ReceiverGestureTarget.disabled var snapRotation = true + /// Remote scroll direction per axis. On (the default) is natural + /// scrolling: content follows the fingers. Diagonal scrolls apply both. + /// Never affects local viewport panning. See `ScrollDirectionPolicy`. + var invertVerticalScroll = true + var invertHorizontalScroll = true /// Prefer MeowDisplay Gestures: while the receiver surface controls the /// Mac, ask iPadOS/iOS to defer its screen-edge gestures and opt the /// surface out of UIKit's three-finger editing interactions. Only public @@ -654,12 +895,20 @@ struct ReceiverControlPreferences: Codable, Equatable { /// Strip and Overlay — the iPad replacement for `functionTrayPosition`. var padMainEdge = ControlEdge.trailing var padFunctionEdge = ControlEdge.trailing - /// Strip only: compact key/action captions beside the controls. - var padShowControlHints = true + /// Strip only: compact key/action captions beside the controls. Off by + /// default (schema 17): the rail reads cleaner without labels. + var padShowControlHints = false /// Multiplier on every iPad control, clamped to `PadControlScale.range`. var padControlScale = PadControlScale.defaultValue - /// Adds the Move View control to the Strip/Overlay Main controls. - var padShowMoveViewControl = true + /// Which standard system/utility controls the Strip and Overlay rails + /// show — see `PadRailComposition`. Custom layouts place their own. + var padStandardControls = PadStandardControlVisibility() + /// Local View Navigation: pan, zoom, rotate (when Rotation targets the + /// viewport), Move View and viewport reset — purely receiver-local, so + /// independent of Allow Input, which only gates what reaches the Mac. + var allowLocalViewNavigation = true + /// How Strip/Overlay chord palettes appear: separate keys, or a Wheel. + var padOverlayPaletteStyle = PadPaletteStyle.keys /// At most `CustomControlLayout.maximumCount`; none exist until the user /// creates one — see `createCustomLayout`. var customLayouts: [CustomControlLayout] = [] @@ -755,7 +1004,17 @@ struct ReceiverControlPreferences: Codable, Equatable { padFunctionEdge = try value(.padFunctionEdge, fallback.padFunctionEdge) padShowControlHints = try value(.padShowControlHints, fallback.padShowControlHints) padControlScale = PadControlScale.clamped(try value(.padControlScale, fallback.padControlScale)) - padShowMoveViewControl = try value(.padShowMoveViewControl, fallback.padShowMoveViewControl) + padStandardControls = try value(.padStandardControls, fallback.padStandardControls) + // Schema 16 stored Move View's visibility on its own. + let legacy = try decoder.container(keyedBy: LegacyCodingKeys.self) + if try legacy.decodeIfPresent(Bool.self, forKey: .padShowMoveViewControl) == false { + padStandardControls.setVisible(false, .moveView) + } + allowLocalViewNavigation = try value(.allowLocalViewNavigation, fallback.allowLocalViewNavigation) + // Absent (schema < 18): natural scrolling on both axes, as before. + invertVerticalScroll = try value(.invertVerticalScroll, fallback.invertVerticalScroll) + invertHorizontalScroll = try value(.invertHorizontalScroll, fallback.invertHorizontalScroll) + padOverlayPaletteStyle = try value(.padOverlayPaletteStyle, fallback.padOverlayPaletteStyle) // Lossy: one layout written by a newer build (an unknown control // kind, say) is dropped on its own instead of resetting every // preference above. @@ -764,6 +1023,10 @@ struct ReceiverControlPreferences: Codable, Equatable { activeCustomLayoutID = try container.decodeIfPresent(String.self, forKey: .activeCustomLayoutID) } + private enum LegacyCodingKeys: String, CodingKey { + case padShowMoveViewControl + } + /// Restores only the four App Gesture Commands to their canonical /// defaults — never gesture targets, Snap Rotation, or anything else /// (spec section G). @@ -929,6 +1192,20 @@ struct ReceiverControlPreferencesRepository { value.rotateTarget = defaults.rotateTarget value.version = 16 } + // Schema 17 turns Strip Control Hints off by default. Schema 16 only + // ever existed on pre-release builds with hints on as its initial + // value, so those installs take the new default once. Local View + // Navigation and standard-control visibility are new keys the + // decoder already defaulted. + if value.version < 17 { + value.padShowControlHints = ReceiverControlPreferences().padShowControlHints + value.version = 17 + } + // Schema 17 predates per-axis scroll direction and the Wheel palette; + // the decoder already defaulted them to the unchanged behavior. + if value.version < 18 { + value.version = 18 + } // Old Function Tray profiles predate `ShortcutItem.systemImage`. // Resolve current canonical metadata by ID without rewriting the // user's visibility, order, or group choices. @@ -1014,3 +1291,56 @@ struct ReceiverUIPreferenceUpdate: Equatable { if let appGestureCommands { preferences.appGestureCommands = appGestureCommands } } } + +/// Strip/Overlay chord palette presentation. +enum PadPaletteStyle: String, Codable, CaseIterable, Identifiable { + /// Separate keys in a row or column beside the rail. + case keys + /// A segmented wheel — see `WheelPaletteGeometry`. + case wheel + + var id: String { rawValue } + var title: String { + self == .keys ? String(localized: "Keys") : String(localized: "Wheel") + } +} + +/// Per-axis remote scroll direction. The gesture pipeline delivers natural +/// deltas (content follows the fingers); turning an axis's inversion off +/// flips that axis alone. +enum ScrollDirectionPolicy { + static func apply(dx: Double, dy: Double, invertVertical: Bool, invertHorizontal: Bool) -> (dx: Double, dy: Double) { + (invertHorizontal ? dx : -dx, invertVertical ? dy : -dy) + } +} + +/// Software-keyboard typing while a MeowDisplay modifier chord is active: +/// the typed key is sent as that chord (⌘ latched, then C → ⌘C) instead of +/// as text. The chord state stays the overlay's `ControlInteractionState`. +enum SoftwareKeyboardChordPolicy { + /// The key press for one typed character under `modifiers`, or `nil` + /// when no chord is active or the character has no key. + static func press(for text: String, modifiers: Set) -> (usage: Int, modifiers: [String])? { + guard !modifiers.isEmpty, text.count == 1, let character = text.first else { return nil } + var chord = modifiers + let usage: Int? + if character == " " { + usage = 44 + } else if character == "\n" { + usage = 40 + } else if character.isLetter, character.isASCII, let scalar = character.lowercased().unicodeScalars.first { + if character.isUppercase { chord.insert(.shift) } + usage = Int(scalar.value) - 97 + 4 + } else { + usage = appGestureCommandEditableKeys.first { $0.0 == String(character) }?.1 + } + guard let usage else { return nil } + return (usage, ControlModifier.allCases.filter(chord.contains).map(\.rawValue)) + } + + /// Modifier names for a special or hardware key: its own plus the chord. + static func modifiers(_ own: [String], chord: Set) -> [String] { + let combined = Set(own).union(chord.map(\.rawValue)) + return ControlModifier.allCases.map(\.rawValue).filter(combined.contains) + } +} diff --git a/Shared/CustomControlLayout.swift b/Shared/CustomControlLayout.swift index 48803839..e2ce4de5 100644 --- a/Shared/CustomControlLayout.swift +++ b/Shared/CustomControlLayout.swift @@ -64,8 +64,11 @@ enum CustomControlKind: Hashable, Codable { case function(String) /// The receiver-local Move View mode toggle. case moveView + /// A user-made one-tap keyboard shortcut, owned by this placement. Uses + /// the same `ShortcutItem`/`KeyboardShortcut` model as every palette. + case shortcut(ShortcutItem) - private enum CodingKeys: String, CodingKey { case kind, value } + private enum CodingKeys: String, CodingKey { case kind, value, shortcut } init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) @@ -81,6 +84,13 @@ enum CustomControlKind: Hashable, Codable { self = .function(try container.decode(String.self, forKey: .value)) case "moveView": self = .moveView + case "shortcut": + let item = try container.decode(ShortcutItem.self, forKey: .shortcut) + guard case .keyboardShortcut(let shortcut) = item.action, shortcut.isValid else { + throw DecodingError.dataCorruptedError(forKey: .shortcut, in: container, + debugDescription: "Invalid shortcut") + } + self = .shortcut(item) default: throw DecodingError.dataCorruptedError(forKey: .kind, in: container, debugDescription: "Unknown control kind") @@ -98,6 +108,9 @@ enum CustomControlKind: Hashable, Codable { try container.encode(id, forKey: .value) case .moveView: try container.encode("moveView", forKey: .kind) + case .shortcut(let item): + try container.encode("shortcut", forKey: .kind) + try container.encode(item, forKey: .shortcut) } } @@ -105,6 +118,15 @@ enum CustomControlKind: Hashable, Codable { if case .tray(let item) = self { return item.modifier } return nil } + + /// Fires once on tap (a Function action or a one-tap shortcut), rather + /// than joining the chord gesture. + var isOneTapAction: Bool { + switch self { + case .function, .shortcut: return true + case .tray, .moveView: return false + } + } } enum PaletteShape: String, Codable, CaseIterable, Identifiable { @@ -112,11 +134,15 @@ enum PaletteShape: String, Codable, CaseIterable, Identifiable { case ring case row case column + /// A ring of wedge-shaped segments around the modifier — see + /// `WheelPaletteGeometry`. + case wheel var id: String { rawValue } var title: String { switch self { + case .wheel: return String(localized: "Wheel", comment: "Chord palette shape.") case .arc: return String(localized: "Arc", comment: "Chord palette shape.") case .ring: return String(localized: "Ring", comment: "Chord palette shape.") case .row: return String(localized: "Row", comment: "Chord palette shape.") @@ -217,6 +243,10 @@ struct CustomControlLayout: Codable, Equatable, Identifiable { var twoHandAssist: Bool /// Function Tray item ids shown on the opposite side while idle. var assistActionIDs: [String] + /// Which Function Tray profile this layout's Function controls come + /// from; `nil` follows the active one. The layout owns where; the + /// profile owns what. + var functionProfile: ControlProfileSlot? func arrangement(portrait isPortrait: Bool) -> CustomControlArrangement { isPortrait ? portrait : landscape @@ -244,56 +274,272 @@ extension CustomControlArrangement { static let referenceLandscape = CGSize(width: 1150, height: 780) static let referencePortrait = CGSize(width: 780, height: 1150) - /// The radial corner constellation: Settings in the corner, the four - /// modifiers on an inner quarter arc, and Escape/Tab/Keyboard/Move View - /// on an outer one. + /// Where the template's Settings anchor sits, from each corner edge. + static let templateAnchorInset: CGFloat = 34 + static let templateBaseDiameter: CGFloat = PadControlMetrics.baseItem + static let templateSpacing: CGFloat = 8 + static let templateUtilities: [CustomControlKind] = [.tray(.keyboard), .tray(.escape), .tray(.tab), .moveView] + static let templateModifiers: [ControlTrayItem] = [.command, .option, .control, .shift] + /// Menu Bar, Dock, Show Desktop, Control Center — the Strip's system group. + static let templateSystem: [CustomControlKind] = [.function("menu-bar"), .tray(.dock), + .function("show-desktop"), .function("control-center")] + static let utilitySize = 0.9 + static let settingsSize = 0.9 + + /// The radial corner template, as true concentric arcs around Settings: + /// Settings (the anchor) → a utility arc (Keyboard, Escape, Tab, Move + /// View) → the modifier arc (⌘ ⌥ ⌃ ⇧). A held chord's palette blooms on + /// the next arc out (`CustomLayoutGeometry.palettePoints`). static func radialTemplate(corner: ControlCorner, portrait: Bool) -> CustomControlArrangement { let reference = portrait ? referencePortrait : referenceLandscape - let anchor = CGPoint(x: corner.isLeading ? 30 : reference.width - 30, - y: corner.isTop ? 30 : reference.height - 30) - // Quarter arc from "along the horizontal edge" to "along the - // vertical edge", pointing into the screen. - let horizontal: CGFloat = corner.isLeading ? 0 : .pi - let vertical: CGFloat = corner.isTop ? .pi / 2 : -.pi / 2 - func arcPoints(count: Int, radius: CGFloat) -> [CGPoint] { - (0.. .pi { delta -= 2 * .pi } - if delta < -.pi { delta += 2 * .pi } - let inset: CGFloat = 0.05 - let angle = horizontal + delta * (inset + t * (1 - 2 * inset)) - return CGPoint(x: anchor.x + radius * cos(angle), y: anchor.y + radius * sin(angle)) - } - } + let inset = templateAnchorInset + let origin = CGPoint(x: corner.isLeading ? inset : reference.width - inset, + y: corner.isTop ? inset : reference.height - inset) + let edges = CGSize(width: inset, height: inset) + let base = templateBaseDiameter + let utilityDiameter = base * CGFloat(utilitySize) + let radii = CornerArcGeometry.layerRadii( + counts: [templateUtilities.count, templateModifiers.count], + diameters: [utilityDiameter, base], + innerDiameter: base * CGFloat(settingsSize), spacing: templateSpacing, + corner: corner, edgeDistances: edges) func normalized(_ point: CGPoint) -> (Double, Double) { (Double(point.x / reference.width), Double(point.y / reference.height)) } var placements: [CustomControlPlacement] = [] - let settings = normalized(anchor) + let settings = normalized(origin) placements.append(CustomControlPlacement(id: "settings", kind: .tray(.settings), - x: settings.0, y: settings.1, size: 0.9)) - let modifiers: [ControlTrayItem] = [.command, .option, .control, .shift] - for (item, point) in zip(modifiers, arcPoints(count: modifiers.count, radius: 100)) { + x: settings.0, y: settings.1, size: settingsSize)) + let utilityPoints = CornerArcGeometry.points(count: templateUtilities.count, radius: radii[0], + origin: origin, corner: corner, + diameter: utilityDiameter, edgeDistances: edges) + for (kind, point) in zip(templateUtilities, utilityPoints) { + let p = normalized(point) + placements.append(CustomControlPlacement(id: kind.stableID, kind: kind, x: p.0, y: p.1, size: utilitySize)) + } + let modifierPoints = CornerArcGeometry.points(count: templateModifiers.count, radius: radii[1], + origin: origin, corner: corner, + diameter: base, edgeDistances: edges) + for (item, point) in zip(templateModifiers, modifierPoints) { let p = normalized(point) placements.append(CustomControlPlacement(id: item.rawValue, kind: .tray(item), x: p.0, y: p.1)) } - let outer: [CustomControlKind] = [.tray(.escape), .tray(.tab), .tray(.keyboard), .moveView] - for (kind, point) in zip(outer, arcPoints(count: outer.count, radius: 172)) { + // System actions on the opposite corner's inner arc — inside the + // arc Two-Hand Assist uses there, so the two never collide. + let mirrored = CornerArcGeometry.mirroredHorizontally(corner) + let mirroredOrigin = CGPoint(x: reference.width - origin.x, y: origin.y) + let systemPoints = CornerArcGeometry.points(count: templateSystem.count, radius: radii[0], + origin: mirroredOrigin, corner: mirrored, + diameter: utilityDiameter, edgeDistances: edges) + for (kind, point) in zip(templateSystem, systemPoints) { let p = normalized(point) - let id: String - switch kind { - case .tray(let item): id = item.rawValue - case .function(let value): id = value - case .moveView: id = "move-view" - } - placements.append(CustomControlPlacement(id: id, kind: kind, x: p.0, y: p.1, size: 0.9)) + placements.append(CustomControlPlacement(id: kind.stableID, kind: kind, x: p.0, y: p.1, size: utilitySize)) } return CustomControlArrangement(corner: corner, placements: placements) } } +extension CustomControlKind { + /// A readable id for a template placement. + var stableID: String { + switch self { + case .tray(let item): return item.rawValue + case .function(let id): return id + case .moveView: return "move-view" + case .shortcut(let item): return item.id + } + } +} + +/// Polar geometry for controls arranged on concentric quarter arcs around a +/// corner origin. Angles are in radians in a y-down space (0 = right, +/// π/2 = down). Each arc spans from the corner's horizontal edge to its +/// vertical edge, trimmed so every control stays clear of both edges, with +/// controls evenly spaced by angle. +enum CornerArcGeometry { + /// Direction along the corner's horizontal edge, into the screen. + static func horizontalEdgeAngle(_ corner: ControlCorner) -> CGFloat { corner.isLeading ? 0 : .pi } + + /// Signed sweep from the horizontal edge toward the vertical edge (±π/2). + static func sweep(_ corner: ControlCorner) -> CGFloat { + // leading+top: 0 → π/2 (+); trailing+top: π → π/2 (−); + // leading+bottom: 0 → −π/2 (−); trailing+bottom: π → 3π/2 (+). + (corner.isLeading == corner.isTop) ? .pi / 2 : -.pi / 2 + } + + /// Offsets from the horizontal edge (0...π/2) that keep a control of + /// `diameter` on an arc of `radius` inside both edges. `edgeDistances` + /// is the origin's distance to the vertical edge (width) and to the + /// horizontal edge (height). + static func usableOffsets(radius: CGFloat, diameter: CGFloat, + edgeDistances: CGSize) -> ClosedRange { + guard radius > 0 else { return (.pi / 4)...(.pi / 4) } + func clearance(_ distance: CGFloat) -> CGFloat { + asin(min(1, max(0, (diameter / 2 - distance) / radius))) + } + let lower = clearance(edgeDistances.height) + let upper = .pi / 2 - clearance(edgeDistances.width) + return lower <= upper ? lower...upper : ((lower + upper) / 2)...((lower + upper) / 2) + } + + /// Evenly spaced angles for `count` controls on the arc of `radius`. + static func angles(count: Int, radius: CGFloat, corner: ControlCorner, diameter: CGFloat, + edgeDistances: CGSize) -> [CGFloat] { + guard count > 0 else { return [] } + let usable = usableOffsets(radius: radius, diameter: diameter, edgeDistances: edgeDistances) + let direction: CGFloat = sweep(corner) > 0 ? 1 : -1 + return (0.. [CGPoint] { + angles(count: count, radius: radius, corner: corner, diameter: diameter, edgeDistances: edgeDistances) + .map { CGPoint(x: origin.x + radius * cos($0), y: origin.y + radius * sin($0)) } + } + + /// Neighbors' center distance on an arc: the chord between angles. + static func chord(radius: CGFloat, angle: CGFloat) -> CGFloat { 2 * radius * sin(abs(angle) / 2) } + + /// The smallest radius ≥ `minimum` whose arc holds `count` controls at + /// least `pitch` apart (center to center). + static func radius(count: Int, pitch: CGFloat, minimum: CGFloat, diameter: CGFloat, + corner: ControlCorner, edgeDistances: CGSize) -> CGFloat { + guard count > 1 else { return minimum } + var radius = max(minimum, 1) + for _ in 0..<2000 { + let usable = usableOffsets(radius: radius, diameter: diameter, edgeDistances: edgeDistances) + let step = (usable.upperBound - usable.lowerBound) / CGFloat(count - 1) + if chord(radius: radius, angle: step) >= pitch - 0.01 { return radius } + radius += 1 + } + return radius + } + + /// How many controls fit on the arc of `radius` at least `pitch` apart. + static func capacity(radius: CGFloat, pitch: CGFloat, diameter: CGFloat, edgeDistances: CGSize) -> Int { + guard radius > 0, pitch > 0 else { return 1 } + let usable = usableOffsets(radius: radius, diameter: diameter, edgeDistances: edgeDistances) + let step = 2 * asin(min(1, pitch / (2 * radius))) + guard step > 0 else { return 1 } + return max(1, Int((usable.upperBound - usable.lowerBound) / step + 0.0001) + 1) + } + + /// Radii for successive layers around an inner control of + /// `innerDiameter`: each layer sits one `spacing` clear of the previous + /// one, and is widened only as much as its own count requires. + static func layerRadii(counts: [Int], diameters: [CGFloat], innerDiameter: CGFloat, spacing: CGFloat, + corner: ControlCorner, edgeDistances: CGSize) -> [CGFloat] { + var radii: [CGFloat] = [] + var previousRadius: CGFloat = 0 + var previousDiameter = innerDiameter + for (count, diameter) in zip(counts, diameters) { + let minimum = previousRadius + previousDiameter / 2 + diameter / 2 + spacing + let radius = self.radius(count: count, pitch: diameter + spacing, minimum: minimum, + diameter: diameter, corner: corner, edgeDistances: edgeDistances) + radii.append(radius) + previousRadius = radius + previousDiameter = diameter + } + return radii + } + + static func mirroredHorizontally(_ corner: ControlCorner) -> ControlCorner { + switch corner { + case .topLeading: return .topTrailing + case .topTrailing: return .topLeading + case .bottomLeading: return .bottomTrailing + case .bottomTrailing: return .bottomLeading + } + } +} + +/// A Custom arrangement read as a corner cluster: the Settings anchor as +/// origin, and every modifier on the screen side of it, near its corner. +/// When an arrangement isn't one (controls dragged elsewhere), palettes and +/// Two-Hand Assist fall back to local geometry. +struct CornerCluster: Equatable { + var origin: CGPoint + var corner: ControlCorner + /// The outermost modifier arc's radius. + var modifierRadius: CGFloat + /// The largest modifier diameter. + var modifierDiameter: CGFloat + /// The origin's distances to its vertical and horizontal area edges. + var edgeDistances: CGSize + + static func resolve(arrangement: CustomControlArrangement, frames: [String: CGRect], + area: CGRect) -> CornerCluster? { + guard let settings = arrangement.placements.first(where: { $0.kind == .tray(.settings) }), + let settingsFrame = frames[settings.id] else { return nil } + let origin = CGPoint(x: settingsFrame.midX, y: settingsFrame.midY) + // The corner the anchor actually sits in — the stored corner can be + // stale once the cluster has been dragged elsewhere. + let corner: ControlCorner = origin.y < area.midY + ? (origin.x < area.midX ? .topLeading : .topTrailing) + : (origin.x < area.midX ? .bottomLeading : .bottomTrailing) + let modifierFrames = arrangement.placements.filter { $0.kind.modifier != nil }.compactMap { frames[$0.id] } + guard !modifierFrames.isEmpty else { return nil } + let reach = min(area.width, area.height) * 0.5 + let inward = CGPoint(x: corner.isLeading ? 1 : -1, y: corner.isTop ? 1 : -1) + var radius: CGFloat = 0 + for frame in modifierFrames { + let dx = frame.midX - origin.x + let dy = frame.midY - origin.y + // On the screen side of the anchor (a little slack for edges). + guard dx * inward.x >= -frame.width / 2, dy * inward.y >= -frame.height / 2 else { return nil } + let distance = hypot(dx, dy) + guard distance <= reach else { return nil } + radius = max(radius, distance) + } + let edges = CGSize(width: corner.isLeading ? origin.x - area.minX : area.maxX - origin.x, + height: corner.isTop ? origin.y - area.minY : area.maxY - origin.y) + return CornerCluster(origin: origin, corner: corner, modifierRadius: radius, + modifierDiameter: modifierFrames.map(\.width).max() ?? 0, + edgeDistances: CGSize(width: max(0, edges.width), height: max(0, edges.height))) + } + + /// The same cluster mirrored across the area's vertical center line — + /// Two-Hand Assist's opposite side. + func mirrored(in area: CGRect) -> CornerCluster { + CornerCluster(origin: CGPoint(x: area.minX + area.maxX - origin.x, y: origin.y), + corner: CornerArcGeometry.mirroredHorizontally(corner), + modifierRadius: modifierRadius, modifierDiameter: modifierDiameter, + edgeDistances: edgeDistances) + } + + /// Palette keys on the arc(s) directly outside the modifier arc: the + /// first ring one `spacing` beyond it; more keys than fit continue on + /// the next ring out. Each ring's keys are spread evenly by angle. + func palettePoints(count: Int, keyDiameter: CGFloat, spacing: CGFloat) -> [CGPoint] { + guard count > 0 else { return [] } + let pitch = keyDiameter + spacing + var radius = modifierRadius + modifierDiameter / 2 + keyDiameter / 2 + spacing + var remaining = count + var result: [CGPoint] = [] + while remaining > 0 { + let fits = CornerArcGeometry.capacity(radius: radius, pitch: pitch, diameter: keyDiameter, + edgeDistances: edgeDistances) + let ring = min(fits, remaining) + result += CornerArcGeometry.points(count: ring, radius: radius, origin: origin, corner: corner, + diameter: keyDiameter, edgeDistances: edgeDistances) + remaining -= ring + radius += pitch + } + return result + } + + /// Controls on the modifier arc itself (Two-Hand Assist helpers and idle + /// actions use the mirrored cluster's modifier arc). + func modifierArcPoints(count: Int, diameter: CGFloat) -> [CGPoint] { + CornerArcGeometry.points(count: count, radius: modifierRadius, origin: origin, corner: corner, + diameter: diameter, edgeDistances: edgeDistances) + } +} + // MARK: - Library (create / rename / delete / select) extension ReceiverControlPreferences { @@ -401,6 +647,68 @@ enum CustomLayoutGeometry { direction: direction, bounds: area, obstacles: obstacles) } + /// Every placement's resolved circle, by placement id. + static func frames(for arrangement: CustomControlArrangement, in area: CGRect, + baseDiameter: CGFloat) -> [String: CGRect] { + Dictionary(arrangement.placements.map { ($0.id, frame(for: $0, in: area, baseDiameter: baseDiameter)) }, + uniquingKeysWith: { first, _ in first }) + } + + /// Where the palette of the modifier placed at `anchorID` goes — the one + /// source for the live overlay, the editor and Preview. A default + /// (automatic arc) palette on a corner cluster blooms on the arc right + /// outside the modifier arc; any other style blooms around its own + /// modifier, clear of the other controls. + static func palettePoints(count: Int, anchorID: String, arrangement: CustomControlArrangement, + frames: [String: CGRect], area: CGRect, baseDiameter: CGFloat, + spacing: CGFloat) -> [CGPoint] { + guard count > 0, let anchor = frames[anchorID], + let placement = arrangement.placements.first(where: { $0.id == anchorID }) else { return [] } + let style = placement.palette ?? PalettePresentation() + let keyDiameter = paletteKeyDiameter(baseDiameter: baseDiameter) + if style.shape == .arc, style.directionDegrees == nil, + let cluster = CornerCluster.resolve(arrangement: arrangement, frames: frames, area: area) { + let inner = area.insetBy(dx: keyDiameter / 2, dy: keyDiameter / 2) + return cluster.palettePoints(count: count, keyDiameter: keyDiameter, spacing: spacing * CGFloat(style.spacing)) + .map { CGPoint(x: min(max($0.x, inner.minX), inner.maxX), y: min(max($0.y, inner.minY), inner.maxY)) } + } + return paletteCenters(count: count, anchor: anchor, style: style, area: area, baseDiameter: baseDiameter, + spacing: spacing, obstacles: frames.filter { $0.key != anchorID }.map(\.value)) + } + + static let edgeSnapTolerance: CGFloat = 12 + + /// Where a control of `diameter` dropped at `point` is stored. + /// - Snap to Edges pulls it flush against the layout area's edges, or + /// onto its center lines, when it lands within `edgeSnapTolerance`. + /// - Snap to Guides pulls it onto the nearest alignment-guide line. + /// Guide display (Dots / Grid Lines) is editor chrome and never reaches + /// the layout; only these stored positions do. + static func droppedPosition(for point: CGPoint, in area: CGRect, diameter: CGFloat, + snapToEdges: Bool, snapToGuides: Bool) -> (x: Double, y: Double) { + func edgeSnap(_ value: CGFloat, lower: CGFloat, upper: CGFloat, middle: CGFloat) -> CGFloat? { + let tolerance = edgeSnapTolerance + if abs(value - diameter / 2 - lower) <= tolerance { return lower + diameter / 2 } + if abs(upper - value - diameter / 2) <= tolerance { return upper - diameter / 2 } + if abs(value - middle) <= tolerance { return middle } + return nil + } + var x = point.x + var y = point.y + var snappedX = false + var snappedY = false + if snapToEdges { + if let edge = edgeSnap(x, lower: area.minX, upper: area.maxX, middle: area.midX) { x = edge; snappedX = true } + if let edge = edgeSnap(y, lower: area.minY, upper: area.maxY, middle: area.midY) { y = edge; snappedY = true } + } + var position = normalizedPoint(for: CGPoint(x: x, y: y), in: area) + if snapToGuides { + if !snappedX { position.x = snapped(position.x) } + if !snappedY { position.y = snapped(position.y) } + } + return position + } + /// Snaps a normalized coordinate to a 1/`divisions` grid when within /// `tolerance` of a line — the editor's alignment assist. static func snapped(_ value: Double, divisions: Int = 24, tolerance: Double = 0.012) -> Double { @@ -427,6 +735,16 @@ enum RadialPaletteLayout { minimumRadius: CGFloat, direction: CGFloat, bounds: CGRect, obstacles: [CGRect] = []) -> [CGPoint] { guard count > 0, itemDiameter > 0, direction.isFinite, minimumRadius.isFinite else { return [] } + if shape == .arc { + return fan(count: count, center: center, itemDiameter: itemDiameter, spacing: spacing, + minimumRadius: max(minimumRadius, itemDiameter), direction: direction, + bounds: bounds, obstacles: obstacles) + } + if shape == .wheel { + return WheelPaletteGeometry.layout(count: count, anchor: CGRect(x: center.x, y: center.y, width: 0, height: 0), + keyDiameter: itemDiameter, spacing: spacing, bounds: bounds)? + .segments.map(\.labelPoint) ?? [] + } var radius = max(minimumRadius, itemDiameter) var result = raw(count: count, center: center, shape: shape, diameter: itemDiameter, spacing: spacing, radius: radius, direction: direction) @@ -441,6 +759,89 @@ enum RadialPaletteLayout { y: min(max($0.y, inner.minY), inner.maxY)) } } + /// An arc of evenly spaced keys at one radius around `center`, fanned + /// into free space: the angular window closest to `direction` in which + /// every key stays inside `bounds` and clear of `obstacles`. When no + /// window is wide enough, the radius grows; more keys than one arc + /// holds (≤ `maximumArcSpan`) continue on the next arc out. + static func fan(count: Int, center: CGPoint, itemDiameter: CGFloat, spacing: CGFloat, + minimumRadius: CGFloat, direction: CGFloat, bounds: CGRect, + obstacles: [CGRect] = []) -> [CGPoint] { + let pitch = itemDiameter + spacing + let inner = bounds.insetBy(dx: itemDiameter / 2, dy: itemDiameter / 2) + let reach = itemDiameter / 2 + spacing / 2 + func valid(_ angle: CGFloat, _ radius: CGFloat) -> Bool { + let point = CGPoint(x: center.x + radius * cos(angle), y: center.y + radius * sin(angle)) + return inner.contains(point) && !obstacles.contains { $0.insetBy(dx: -reach, dy: -reach).contains(point) } + } + var result: [CGPoint] = [] + var radius = minimumRadius + var remaining = count + var attempts = 0 + while remaining > 0, attempts < 40 { + attempts += 1 + let step = angularStep(pitch: pitch, radius: radius) + let capacity = max(1, Int(maximumArcSpan / step + 0.0001) + 1) + let inArc = min(capacity, remaining) + let needed = step * CGFloat(inArc - 1) + if let middle = window(needed: needed, preferred: direction, radius: radius, valid: valid) { + for index in 0.. 0 { + // Nowhere fits (a tiny screen): fall back to the plain arc, clamped. + let rest = raw(count: remaining, center: center, shape: .ring, diameter: itemDiameter, + spacing: spacing, radius: radius, direction: direction) + result += rest.map { CGPoint(x: min(max($0.x, inner.minX), inner.maxX), + y: min(max($0.y, inner.minY), inner.maxY)) } + } + return result + } + + /// The center angle of a valid window `needed` radians wide, as close to + /// `preferred` as the free arcs at `radius` allow; `nil` if none fits. + private static func window(needed: CGFloat, preferred: CGFloat, radius: CGFloat, + valid: (CGFloat, CGFloat) -> Bool) -> CGFloat? { + let samples = 720 + let sample = 2 * CGFloat.pi / CGFloat(samples) + let flags = (0..= needed { + // Closest angle to `preferred` (mod 2π) the window's middle may take. + let lower = arc.start + needed / 2 + let upper = arc.end - needed / 2 + var candidate = preferred + while candidate < lower - .pi { candidate += 2 * .pi } + while candidate > upper + .pi { candidate -= 2 * .pi } + let clamped = min(max(candidate, lower), upper) + let distance = abs(ManualViewportState.normalizedAngle(clamped - preferred)) + if best == nil || distance < best!.distance { best = (clamped, distance) } + } + return best?.angle + } + /// Radians from `point` toward `target` (y-down). static func direction(from point: CGPoint, toward target: CGPoint) -> CGFloat { let dx = target.x - point.x @@ -476,6 +877,10 @@ enum RadialPaletteLayout { let angle = direction + CGFloat(index) * 2 * .pi / CGFloat(count) return CGPoint(x: center.x + ringRadius * cos(angle), y: center.y + ringRadius * sin(angle)) } + case .wheel: + return WheelPaletteGeometry.layout(count: count, anchor: CGRect(x: center.x, y: center.y, width: 0, height: 0), + keyDiameter: diameter, spacing: spacing, + bounds: .infinite)?.segments.map(\.labelPoint) ?? [] case .row, .column: let origin = CGPoint(x: center.x + radius * cos(direction), y: center.y + radius * sin(direction)) return (0.. [CGPoint] { + guard count > 0 else { return [] } + guard let cluster = CornerCluster.resolve(arrangement: arrangement, frames: frames, area: area) else { + return helperPoints(count: count, clusterCentroid: arrangement.clusterCentroid, area: area, + itemDiameter: itemDiameter, spacing: spacing) + } + var mirrored = cluster.mirrored(in: area) + // More controls than the modifier arc holds widen the arc, never + // crowd it. + mirrored.modifierRadius = CornerArcGeometry.radius( + count: count, pitch: itemDiameter + spacing, minimum: mirrored.modifierRadius, + diameter: itemDiameter, corner: mirrored.corner, edgeDistances: mirrored.edgeDistances) + return mirrored.modifierArcPoints(count: count, diameter: itemDiameter) + } + /// The modifiers the helper offers, in canonical order. static let helperModifiers: [ControlModifier] = ControlModifier.allCases } @@ -585,3 +1009,86 @@ extension ControlInteractionState { } } } + +// MARK: - Wheel palette + +/// A ring of wedge-shaped segments around a center disc — the Wheel palette +/// style. Pure geometry: the renderer draws the wedges and the chord +/// gesture hit-tests them with `segmentIndex(at:)`. +struct WheelPaletteLayout: Equatable { + struct Segment: Equatable { + var startAngle: CGFloat + var endAngle: CGFloat + /// Where the segment's key label sits. + var labelPoint: CGPoint + } + + var center: CGPoint + var innerRadius: CGFloat + var outerRadius: CGFloat + var segments: [Segment] + + var frame: CGRect { + CGRect(x: center.x - outerRadius, y: center.y - outerRadius, width: outerRadius * 2, height: outerRadius * 2) + } + + /// The segment under `point`, including a small slack outside the ring. + func segmentIndex(at point: CGPoint, slack: CGFloat = 14) -> Int? { + guard !segments.isEmpty else { return nil } + let distance = hypot(point.x - center.x, point.y - center.y) + guard distance >= innerRadius - slack, distance <= outerRadius + slack else { return nil } + let first = segments[0].startAngle + var angle = atan2(point.y - center.y, point.x - center.x) - first + while angle < 0 { angle += 2 * .pi } + while angle >= 2 * .pi { angle -= 2 * .pi } + return min(segments.count - 1, Int(angle / (2 * .pi / CGFloat(segments.count)))) + } +} + +enum WheelPaletteGeometry { + /// The wheel for `count` actions around `anchor` (a modifier, or a + /// rail): a ring just clear of the anchor, wide enough for a key per + /// segment, with the first segment centered at the top. The center + /// moves inward when the whole wheel wouldn't fit inside `bounds`. + static func layout(count: Int, anchor: CGRect, keyDiameter: CGFloat, spacing: CGFloat, + bounds: CGRect) -> WheelPaletteLayout? { + guard count > 0, keyDiameter > 0 else { return nil } + // Generous wedges: deeper than a key, and at least a key and a bit + // wide at mid-ring, so a thumb hits them reliably. + let thickness = keyDiameter * 1.5 + var inner = max(anchor.width, anchor.height) / 2 + spacing + let minimumMid = CGFloat(count) * keyDiameter * 1.15 / (2 * .pi) + inner = max(inner, minimumMid - thickness / 2, keyDiameter * 0.6) + let outer = inner + thickness + var center = CGPoint(x: anchor.midX, y: anchor.midY) + if !bounds.isInfinite, bounds.width >= outer * 2, bounds.height >= outer * 2 { + center.x = min(max(center.x, bounds.minX + outer), bounds.maxX - outer) + center.y = min(max(center.y, bounds.minY + outer), bounds.maxY - outer) + } + let sweep = 2 * CGFloat.pi / CGFloat(count) + let first = -CGFloat.pi / 2 - sweep / 2 + let middle = (inner + outer) / 2 + let segments = (0.. WheelPaletteLayout.Segment in + let start = first + CGFloat(index) * sweep + let mid = start + sweep / 2 + return .init(startAngle: start, endAngle: start + sweep, + labelPoint: CGPoint(x: center.x + middle * cos(mid), y: center.y + middle * sin(mid))) + } + return WheelPaletteLayout(center: center, innerRadius: inner, outerRadius: outer, segments: segments) + } +} + +// MARK: - Editor Two-Hand Assist + +/// Two-Hand Assist in the editor stays dormant while arranging: moving or +/// selecting a modifier never triggers it. Only Preview runs the real +/// projection; "Preview Two-Hand Assist" in Edit shows its helper arc +/// statically. +enum EditorTwoHandPresentation { + static func state(previewing: Bool, showingAssistPreview: Bool, enabled: Bool, + interaction: ControlInteractionState) -> TwoHandAssistState { + guard enabled else { return .hidden } + if previewing { return TwoHandAssist.state(enabled: true, interaction: interaction) } + return showingAssistPreview ? .helper(active: [.command]) : .hidden + } +} diff --git a/Shared/CustomLayoutSharing.swift b/Shared/CustomLayoutSharing.swift new file mode 100644 index 00000000..bdfc0876 --- /dev/null +++ b/Shared/CustomLayoutSharing.swift @@ -0,0 +1,342 @@ +// Portable share codes for Custom layouts, and the editor's display-only +// Preview session. Foundation + CryptoKit only (hostless-testable). + +import CryptoKit +import Foundation + +/// A Custom layout as text: `MDL-.`, where `body` is +/// the zlib-compressed JSON payload in URL-safe base64 and `check` is the +/// first 4 bytes of its SHA-256. Generated and read entirely on-device. +/// +/// The payload holds only what defines the layout — name, both +/// arrangements (positions, sizes, palette styles, one-tap shortcuts) and +/// Two-Hand Assist — never trust data, keys, device or peer IDs, Remote +/// Access details, permissions or history. Importing validates, sanitizes +/// and summarizes; it never executes anything. +enum CustomLayoutShareCode { + static let currentVersion = 1 + static let maximumCodeLength = 60_000 + static let maximumPayloadBytes = 256 * 1024 + static let maximumPlacements = 64 + static let maximumNameLength = 40 + + /// Everything a share code carries — deliberately a separate type from + /// `CustomControlLayout` so its local `id` never leaves the device. + struct Payload: Codable, Equatable { + var name: String + var landscape: CustomControlArrangement + var portrait: CustomControlArrangement + var twoHandAssist: Bool + var assistActionIDs: [String] + /// Definitions of the user-made Function actions the layout places + /// (built-in actions travel by id). Omitted when there are none. + var functionActions: [ShortcutItem]? + } + + enum ShareCodeError: Error, Equatable { + case notAShareCode + case unsupportedVersion(Int) + case corrupted + case tooLarge + } + + struct ImportSummary: Equatable { + enum Warning: Equatable { + /// Controls from a newer MeowDisplay this version doesn't know. + case skippedControls(Int) + /// Function Tray actions this version doesn't have. + case unknownActions(Int) + } + + /// Ready to add — with a fresh local id. + var layout: CustomControlLayout + var landscapeControls: Int + var portraitControls: Int + var shortcutCount: Int + var twoHandAssist: Bool + var warnings: [Warning] + } + + /// - Parameter functionItems: the Function actions the layout's + /// profile defines; only user-made ones the layout places are carried. + static func encode(_ layout: CustomControlLayout, functionItems: [ShortcutItem] = []) throws -> String { + let placed = Set((layout.landscape.placements + layout.portrait.placements).compactMap { placement -> String? in + if case .function(let id) = placement.kind, !FunctionTrayProfile.isBuiltIn(id) { return id } + return nil + }) + let actions = functionItems.filter { placed.contains($0.id) } + let payload = Payload(name: layout.name, landscape: layout.landscape, portrait: layout.portrait, + twoHandAssist: layout.twoHandAssist, assistActionIDs: layout.assistActionIDs, + functionActions: actions.isEmpty ? nil : actions) + let encoder = JSONEncoder() + encoder.outputFormatting = .sortedKeys + let json = try encoder.encode(payload) + let compressed = try (json as NSData).compressed(using: .zlib) as Data + return "MDL\(currentVersion)-" + base64URL(compressed) + "." + checksum(json) + } + + static func decode(_ text: String) -> Result { + let code = text.filter { !$0.isWhitespace } + guard code.count <= maximumCodeLength else { return .failure(.tooLarge) } + guard code.hasPrefix("MDL"), let dash = code.firstIndex(of: "-") else { return .failure(.notAShareCode) } + guard let version = Int(code[code.index(code.startIndex, offsetBy: 3).. ImportSummary { + // Carried custom actions become layout-owned one-tap buttons with + // fresh ids — never merged into, or overwriting, local profiles. + var definitions: [String: ShortcutItem] = [:] + for action in (payload.functionActions ?? []).prefix(maximumPlacements) where action.action.isValid { + var item = action + item.id = UUID().uuidString + definitions[action.id] = item + } + func adopt(_ arrangement: CustomControlArrangement) -> CustomControlArrangement { + var result = arrangement + result.placements = arrangement.placements.map { placement in + guard case .function(let id) = placement.kind, let item = definitions[id] else { return placement } + var adopted = placement + adopted.kind = .shortcut(item) + return adopted + } + return result + } + let landscape = sanitized(adopt(payload.landscape)) + let portrait = sanitized(adopt(payload.portrait)) + let trimmedName = payload.name.trimmingCharacters(in: .whitespacesAndNewlines) + let layout = CustomControlLayout( + id: UUID().uuidString, + name: trimmedName.isEmpty ? String(localized: "Imported Layout") : String(trimmedName.prefix(maximumNameLength)), + landscape: landscape, portrait: portrait, + twoHandAssist: payload.twoHandAssist, + assistActionIDs: payload.assistActionIDs.filter(FunctionTrayProfile.isBuiltIn).prefix(8) + .map { String($0.prefix(40)) }) + let all = landscape.placements + portrait.placements + let known = Set(FunctionTrayProfile.canonical().items.map(\.id)) + let unknown = all.filter { if case .function(let id) = $0.kind { return !known.contains(id) } else { return false } } + var warnings: [ImportSummary.Warning] = [] + if let rawPlacementCount, rawPlacementCount > all.count { + warnings.append(.skippedControls(rawPlacementCount - all.count)) + } + if !unknown.isEmpty { warnings.append(.unknownActions(unknown.count)) } + let shortcuts = all.filter { if case .shortcut = $0.kind { return true } else { return false } }.count + return ImportSummary(layout: layout, landscapeControls: landscape.placements.count, + portraitControls: portrait.placements.count, shortcutCount: shortcuts, + twoHandAssist: payload.twoHandAssist, warnings: warnings) + } + + private static func sanitized(_ arrangement: CustomControlArrangement) -> CustomControlArrangement { + var seen = Set() + let placements = arrangement.placements.prefix(maximumPlacements).compactMap { placement -> CustomControlPlacement? in + let id = seen.insert(placement.id).inserted && !placement.id.isEmpty && placement.id.count <= 64 + ? placement.id : UUID().uuidString + var kind = placement.kind + if case .shortcut(var item) = kind { + guard item.action.isValid else { return nil } + item.title = String(item.title.prefix(maximumNameLength)) + item.displayKey = String(item.displayKey.prefix(24)) + item.systemImage = item.systemImage.map { String($0.prefix(60)) } + kind = .shortcut(item) + } + var palette = placement.palette + if var style = palette { + style.spacing = style.spacing.isFinite + ? min(max(style.spacing, PalettePresentation.spacingRange.lowerBound), + PalettePresentation.spacingRange.upperBound) : 1 + style.directionDegrees = style.directionDegrees.flatMap { $0.isFinite ? min(max($0, -180), 180) : nil } + palette = style + } + // The initializer re-clamps position and size. + return CustomControlPlacement(id: id, kind: kind, x: placement.x, y: placement.y, + size: placement.size, palette: palette) + } + return CustomControlArrangement(corner: arrangement.corner, placements: Array(placements)) + } + + private static func rawPlacementCount(_ json: Data) -> Int? { + guard let object = try? JSONSerialization.jsonObject(with: json) as? [String: Any] else { return nil } + let count = { (key: String) -> Int in + ((object[key] as? [String: Any])?["placements"] as? [Any])?.count ?? 0 + } + return min(count("landscape"), maximumPlacements) + min(count("portrait"), maximumPlacements) + } + + // MARK: Encoding helpers + + private static func checksum(_ data: Data) -> String { + SHA256.hash(data: data).prefix(4).map { String(format: "%02x", $0) }.joined() + } + + private static func base64URL(_ data: Data) -> String { + data.base64EncodedString() + .replacingOccurrences(of: "+", with: "-") + .replacingOccurrences(of: "/", with: "_") + .replacingOccurrences(of: "=", with: "") + } + + private static func data(base64URL text: String) -> Data? { + guard text.allSatisfy({ $0.isLetter || $0.isNumber || $0 == "-" || $0 == "_" }), text.allSatisfy(\.isASCII) else { + return nil + } + var base64 = text.replacingOccurrences(of: "-", with: "+").replacingOccurrences(of: "_", with: "/") + base64 += String(repeating: "=", count: (4 - base64.count % 4) % 4) + return Data(base64Encoded: base64) + } +} + +extension ReceiverControlPreferences { + enum CustomLayoutImportResult: Equatable { + case imported(id: String) + case limitReached + } + + /// Adds an imported layout as a new layout — never overwriting one — + /// within `CustomControlLayout.maximumCount`. A clashing name gets a + /// number. + mutating func importCustomLayout(_ layout: CustomControlLayout) -> CustomLayoutImportResult { + guard canCreateCustomLayout else { return .limitReached } + var imported = layout + imported.id = UUID().uuidString + let names = Set(customLayouts.map(\.name)) + if names.contains(imported.name) { + var number = 2 + while names.contains("\(imported.name) \(number)") { number += 1 } + imported.name = "\(imported.name) \(number)" + } + customLayouts.append(imported) + return .imported(id: imported.id) + } +} + +// MARK: - Editor Preview + +/// The Custom editor's Preview: the real chord state machine driving the +/// real layout geometry, with every would-be Mac command turned into a +/// display-only note. It has no way to reach a receiver or the wire. +struct CustomLayoutPreviewSession: Equatable { + /// What Preview shows instead of sending. + enum Feedback: Equatable { + /// The keys that a tap would send, e.g. `⌘C`. + case wouldSend(String) + /// A non-keyboard action, e.g. Show Desktop. + case wouldPerform(String) + } + + private(set) var interaction = ControlInteractionState() + private(set) var feedback: Feedback? + + /// Tap-to-latch, exactly like the live modifier buttons. + mutating func tapModifier(_ modifier: ControlModifier) { + _ = interaction.tap(modifier) + } + + /// A helper modifier on the Two-Hand Assist side. + mutating func tapAssistModifier(_ modifier: ControlModifier) { + _ = interaction.toggleAssistModifier(modifier) + } + + mutating func tapPaletteAction(_ action: ShortcutItem) { + let chord = interaction.activeChord + let effects = interaction.finish(with: action) + guard effects.contains(.execute(action)) else { return } + switch action.action { + case .keyboardShortcut: + feedback = .wouldSend(chord.hudText(for: action.displayKey)) + case .receiverGesture: + feedback = .wouldPerform(action.title) + case .sequence: + feedback = .wouldSend(action.keysDescription) + } + } + + /// A one-tap control (Function action or custom shortcut). + mutating func tapAction(_ item: ShortcutItem) { + switch item.action { + case .keyboardShortcut(let shortcut): + let keys = shortcut.usages.map(appGestureCommandKeyLabel(for:)).joined(separator: "+") + feedback = .wouldSend(shortcut.modifiers.symbols + keys) + case .receiverGesture: + feedback = .wouldPerform(item.title) + case .sequence: + feedback = .wouldSend(item.keysDescription) + } + } + + mutating func reset() { + _ = interaction.resetAll() + feedback = nil + } +} + +// MARK: - Import flow + +/// Paste → Validate → review → Import, as state. The flow only finishes — +/// and the screen only closes — after a successful import (or a cancel, +/// which the view handles); every other step keeps the code editable. +struct CustomLayoutImportFlow: Equatable { + enum Stage: Equatable { + case entering + case reviewing(CustomLayoutShareCode.ImportSummary) + case failed(CustomLayoutShareCode.ShareCodeError) + case limitReached(CustomLayoutShareCode.ImportSummary) + case imported(id: String) + } + + private(set) var code = "" + private(set) var stage = Stage.entering + + var isFinished: Bool { + if case .imported = stage { return true } + return false + } + + var canValidate: Bool { !code.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty } + + mutating func edit(_ text: String) { + guard text != code else { return } + code = text + stage = .entering + } + + /// Paste from Clipboard: take the pasted text and validate it at once. + mutating func paste(_ text: String?) { + edit(text ?? "") + if canValidate { validate() } + } + + mutating func validate() { + guard canValidate else { stage = .entering; return } + switch CustomLayoutShareCode.decode(code) { + case .success(let summary): stage = .reviewing(summary) + case .failure(let error): stage = .failed(error) + } + } + + mutating func importLayout(into preferences: inout ReceiverControlPreferences) { + let summary: CustomLayoutShareCode.ImportSummary + switch stage { + case .reviewing(let reviewed), .limitReached(let reviewed): summary = reviewed + default: return + } + switch preferences.importCustomLayout(summary.layout) { + case .imported(let id): stage = .imported(id: id) + case .limitReached: stage = .limitReached(summary) + } + } +} diff --git a/Shared/MobileSettingsSearch.swift b/Shared/MobileSettingsSearch.swift new file mode 100644 index 00000000..8895bdb3 --- /dev/null +++ b/Shared/MobileSettingsSearch.swift @@ -0,0 +1,164 @@ +import Foundation + +/// One searchable receiver setting (or a whole category). `id` doubles as +/// the setting's anchor on its page, so a result can scroll to and briefly +/// highlight it. +struct MobileSettingsSearchItem: Identifiable, Hashable { + enum Availability: Hashable { + case everywhere + case padOnly + case phoneOnly + } + + let id: String + let title: String + let category: MobileSettingsCategory + let keywords: [String] + let availability: Availability + /// A category row rather than a single setting. + let isCategory: Bool + + init(_ id: String, _ title: String, _ category: MobileSettingsCategory, keywords: [String] = [], + availability: Availability = .everywhere, isCategory: Bool = false) { + self.id = id + self.title = title + self.category = category + self.keywords = keywords + self.availability = availability + self.isCategory = isCategory + } +} + +/// The one Settings search model for both size classes: the iPad sidebar +/// and the iPhone category list search the same index. +enum MobileSettingsSearchIndex { + static func items(isPad: Bool, debug: Bool) -> [MobileSettingsSearchItem] { + let visible = Set(MobileSettingsCategory.visible(debug: debug)) + let categories = MobileSettingsCategory.visible(debug: debug).map { + MobileSettingsSearchItem("category.\($0.rawValue)", $0.title, $0, isCategory: true) + } + return categories + settings.filter { item in + guard visible.contains(item.category) else { return false } + switch item.availability { + case .everywhere: return true + case .padOnly: return isPad + case .phoneOnly: return !isPad + } + } + } + + /// Ranked matches. Every query word must match: a prefix of any word of + /// the title, keywords or category name, or a substring of the title. + static func search(_ query: String, isPad: Bool, debug: Bool) -> [MobileSettingsSearchItem] { + let words = tokens(query) + guard !words.isEmpty else { return [] } + let order = Dictionary(uniqueKeysWithValues: MobileSettingsCategory.allCases.enumerated().map { ($1, $0) }) + return items(isPad: isPad, debug: debug) + .compactMap { item -> (MobileSettingsSearchItem, Int)? in + score(item, words: words, phrase: normalized(query)).map { (item, $0) } + } + .sorted { + if $0.1 != $1.1 { return $0.1 > $1.1 } + if $0.0.category != $1.0.category { return order[$0.0.category, default: 0] < order[$1.0.category, default: 0] } + return $0.0.title < $1.0.title + } + .map(\.0) + } + + private static func score(_ item: MobileSettingsSearchItem, words: [String], phrase: String) -> Int? { + let title = normalized(item.title) + let titleWords = tokens(item.title) + let keywordWords = item.keywords.flatMap(tokens) + let categoryWords = tokens(item.category.title) + var total = 0 + for word in words { + if titleWords.contains(where: { $0.hasPrefix(word) }) { + total += 30 + } else if title.contains(word) { + total += 20 + } else if keywordWords.contains(where: { $0.hasPrefix(word) }) { + total += 15 + } else if categoryWords.contains(where: { $0.hasPrefix(word) }) { + total += 5 + } else { + return nil + } + } + if title == phrase { total += 100 } else if title.hasPrefix(phrase) { total += 50 } + if item.isCategory, title.hasPrefix(phrase) { total += 40 } + return total + } + + private static func normalized(_ text: String) -> String { + text.folding(options: [.caseInsensitive, .diacriticInsensitive, .widthInsensitive], locale: nil) + .trimmingCharacters(in: .whitespacesAndNewlines) + } + + private static func tokens(_ text: String) -> [String] { + normalized(text).split(whereSeparator: { !$0.isLetter && !$0.isNumber }).map(String.init) + } + + // swiftlint:disable line_length + private static let settings: [MobileSettingsSearchItem] = [ + // General + .init("deviceName", String(localized: "Name"), .general, keywords: ["device name", "rename", "bonjour"]), + .init("autoReconnect", String(localized: "Auto-Reconnect"), .general, keywords: ["reconnect", "automatic"]), + .init("haptics", String(localized: "Haptics"), .general, keywords: ["vibration", "feedback"]), + // Display + .init("video", String(localized: "Video"), .display, keywords: ["picture", "stream off"]), + .init("displayMode", String(localized: "Display Mode"), .display, keywords: ["mirror", "extend", "second screen"]), + .init("surfaceGrid", String(localized: "Show Surface Grid"), .display, keywords: ["video off", "dots"]), + .init("pictureInPicture", String(localized: "Picture in Picture"), .display, keywords: ["pip", "floating window"]), + .init("streamingProfile", String(localized: "Streaming Profile"), .display, keywords: ["quality", "frame rate", "bitrate", "priority", "fps"]), + .init("maxFPS", String(localized: "Enforce Maximum FPS"), .display, keywords: ["frame rate", "fps", "limit"]), + // Input + .init("control", String(localized: "Control"), .input, keywords: ["request control", "allow input", "permission"]), + .init("inputMode", String(localized: "Input Mode"), .input, keywords: ["direct touch", "trackpad", "pointer", "mouse"]), + .init("smartTouch", "Smart Touch", .input, keywords: ["scroll", "one finger", "title bar", "window"]), + .init("smartTouchHaptics", String(localized: "Smart Touch Haptics"), .input, keywords: ["vibration"]), + .init("trackpadSensitivity", String(localized: "Trackpad Sensitivity"), .input, keywords: ["speed", "pointer"]), + .init("scrollDirection", String(localized: "Invert Vertical Scrolling"), .input, keywords: ["invert horizontal scrolling", "natural scrolling", "scroll direction", "reverse"]), + .init("zoomWhileTyping", String(localized: "Zoom While Typing"), .input, keywords: ["keyboard"]), + // Gestures + .init("pinch", String(localized: "Pinch / Zoom"), .gestures, keywords: ["magnify", "zoom"]), + .init("rotation", String(localized: "Rotation"), .gestures, keywords: ["rotate", "snap"]), + .init("preferGestures", String(localized: "Prefer MeowDisplay Gestures"), .gestures, keywords: ["edge", "three finger", "system gestures", "undo"]), + .init("localViewNavigation", String(localized: "Allow Local View Navigation"), .gestures, keywords: ["pan", "zoom", "move", "view", "viewport", "inspect"]), + .init("moveView", String(localized: "Move View"), .gestures, keywords: ["pan", "viewport"], availability: .padOnly), + .init("resetView", String(localized: "Reset View"), .gestures, keywords: ["viewport", "double tap"]), + // Controls + .init("controlLayout", String(localized: "Control Layout"), .controls, keywords: ["strip", "overlay", "custom", "sidecar", "rail"], availability: .padOnly), + .init("controlEdges", String(localized: "Main Controls"), .controls, keywords: ["function controls", "edge", "left", "right", "top", "bottom", "position"], availability: .padOnly), + .init("standardControls", String(localized: "Standard Controls"), .controls, keywords: ["menu bar", "dock", "show desktop", "control center", "escape", "tab", "keyboard", "move view"], availability: .padOnly), + .init("controlHints", String(localized: "Show Control Hints"), .controls, keywords: ["labels", "captions"], availability: .padOnly), + .init("controlSize", String(localized: "Control Size"), .controls, keywords: ["scale", "bigger", "smaller", "button size"], availability: .padOnly), + .init("customLayouts", String(localized: "Custom Layouts"), .controls, keywords: ["two hand assist", "radial", "share", "import", "editor", "shortcut button"], availability: .padOnly), + .init("showTray", String(localized: "Show Control Tray"), .controls, keywords: ["controls", "hide"]), + .init("autoHide", String(localized: "Auto-hide Control Trays"), .controls, keywords: ["auto hide", "fade"]), + .init("trayPlacement", String(localized: "Landscape Tray Side"), .controls, keywords: ["left", "right", "function tray position", "avoid notch"], availability: .phoneOnly), + .init("controlProfile", String(localized: "Active Profile"), .controls, keywords: ["control profile", "shortcuts", "palette", "modifier"]), + .init("functionTray", String(localized: "Show Function Tray"), .controls, keywords: ["undo", "redo", "zoom in", "zoom out", "launchpad", "edit function tray", "macro", "sequence", "shortcut button", "emoji"]), + .init("paletteStyle", String(localized: "Palette Style"), .controls, keywords: ["wheel", "chord", "keys"], availability: .padOnly), + // Audio + .init("audio", String(localized: "Audio"), .audio, keywords: ["sound"]), + .init("avSync", String(localized: "A/V Sync"), .audio, keywords: ["lip sync", "delay", "latency", "resync"]), + // Connections + .init("connectionStatus", String(localized: "Connection"), .connections, keywords: ["status", "disconnect", "quic", "tcp", "usb", "wifi", "transport"]), + // Paired Macs + .init("pairedMacs", String(localized: "Paired Macs"), .pairedMacs, keywords: ["forget", "trust", "automatically allow connections", "block"]), + // Remote Access + .init("remoteAccess", String(localized: "Remote Access"), .remoteAccess, keywords: ["tailscale", "internet", "remote"]), + // Permissions + .init("localNetwork", String(localized: "Open iOS Settings for MeowDisplay"), .permissions, keywords: ["local network", "privacy"]), + // Diagnostics + .init("connectionLog", String(localized: "Connection log"), .diagnostics, keywords: ["logs", "debug"]), + .init("performanceOverlay", String(localized: "Performance overlay"), .diagnostics, keywords: ["fps", "latency", "analytics"]), + .init("metalRenderer", String(localized: "Metal renderer (experimental)"), .diagnostics, keywords: ["rendering"]), + // About + .init("version", String(localized: "Version"), .about, keywords: ["about", "github", "mac app"]), + // Developer (DEBUG builds only — filtered with its category) + .init("notchDebug", String(localized: "Notch Debug Overlay"), .developer, keywords: ["debug"]), + .init("audioDiagnostics", String(localized: "Audio Diagnostics"), .developer, keywords: ["aac", "pcm"]), + ] + // swiftlint:enable line_length +} diff --git a/Shared/PadControlLayout.swift b/Shared/PadControlLayout.swift index 695ee6cf..34b57af6 100644 --- a/Shared/PadControlLayout.swift +++ b/Shared/PadControlLayout.swift @@ -1,5 +1,5 @@ // iPad-only control presentation: Strip / Overlay edge geometry, control -// scale, palette placement, and the display-overlap backdrop policy. +// scale, rail composition and palette placement. // CoreGraphics + Foundation only, so the hostless Mac test target covers // all of it (see MacTests/PadControlLayoutTests.swift). The iPhone keeps // `ControlTrayGeometry` and never reads anything here. @@ -124,138 +124,348 @@ struct PadStrip: Equatable { var frame: CGRect } +/// One placed run of controls: a whole group, or the part of one that had +/// to continue in another lane. +struct PadSegment: Equatable { + var group: Int + var cells: [CGRect] + var frame: CGRect { + cells.dropFirst().reduce(cells.first ?? .null) { $0.union($1) } + } +} + /// One Strip/Overlay layout pass. struct PadEdgeLayout: Equatable { /// Where the Mac display is presented: the container minus any Strip /// rails in Strip mode, the whole container in Overlay. var canvas: CGRect var strips: [PadStrip] - /// `nil` when no Main controls show. - var mainFrame: CGRect? - /// One frame per Function group, in group order. - var functionFrames: [CGRect] + var mainSegments: [PadSegment] + var functionSegments: [PadSegment] + /// Lanes per used edge (more than one only when an edge overflows). + var laneCounts: [ControlEdge: Int] + + /// Main control cells, in item order. + var mainCells: [CGRect] { mainSegments.flatMap(\.cells) } + /// Function control cells, in item order. + var functionCells: [CGRect] { functionSegments.flatMap(\.cells) } + var mainFrame: CGRect? { + let frames = mainSegments.map(\.frame) + return frames.isEmpty ? nil : frames.dropFirst().reduce(frames[0]) { $0.union($1) } + } + + static let empty = PadEdgeLayout(canvas: .zero, strips: [], mainSegments: [], functionSegments: [], + laneCounts: [:]) } enum PadEdgeGeometry { - /// The iPad Strip/Overlay layout. Main controls are centered along - /// their edge and grow outward from the center. Function groups split - /// in two: the first half toward the start of their edge (top, or - /// left), the rest toward the end (bottom, or right). On the Main edge - /// they continue outward from the Main cluster; on any other edge they - /// sit at that edge's two ends. + /// The iPad Strip/Overlay layout, as lanes of control groups along each + /// used edge. + /// + /// - Main groups (system, keys, view — see `PadRailComposition`) keep + /// their order with a group gap between them and sit centered on + /// their edge. + /// - Function groups split in two: the first half toward the start of + /// their edge (top, or left), the rest toward the end. + /// - On a shared edge, Function groups take the two ends and the Main + /// run shifts off center as far as needed to clear them. + /// - Nothing ever overlaps. When an edge can't hold everything in one + /// lane, the overflow moves to the next lane inward, deterministically: + /// Main groups wrap at group boundaries (a group longer than the edge + /// wraps at the last control that fits), and Function groups that + /// don't fit beside the Main run get a lane of their own. A Strip + /// widens to hold every lane. /// /// - Parameter keyboardTop: the docked software keyboard's top edge in - /// `container` coordinates, if one is open. Bottom controls rise above - /// it and side controls stop short of it; Strip rails don't move, so - /// the canvas never jumps while typing. + /// `container` coordinates, if one is open. Bottom lanes rise above it + /// and side lanes stop short of it; Strip rails don't move. static func layout(container: CGRect, safeInsets: ControlSafeInsets, reservesStrips: Bool, mainEdge: ControlEdge, functionEdge: ControlEdge, - mainCount: Int, + mainGroupCounts: [Int], + mainGroupAlignments: [PadGroupAlignment] = [], functionGroupCounts: [Int], metrics: PadControlMetrics, keyboardTop: CGFloat? = nil) -> PadEdgeLayout { guard container.width > 0, container.height > 0 else { - return PadEdgeLayout(canvas: container, strips: [], mainFrame: nil, functionFrames: []) + return PadEdgeLayout(canvas: container, strips: [], mainSegments: [], functionSegments: [], laneCounts: [:]) } - let groups = functionGroupCounts.filter { $0 > 0 } + let mainAlignments = mainGroupCounts.indices.filter { mainGroupCounts[$0] > 0 }.map { + $0 < mainGroupAlignments.count ? mainGroupAlignments[$0] : PadGroupAlignment.center + } + let main = mainGroupCounts.filter { $0 > 0 } + let function = functionGroupCounts.filter { $0 > 0 } var usedEdges: [ControlEdge] = [] - if mainCount > 0 { usedEdges.append(mainEdge) } - if !groups.isEmpty, !usedEdges.contains(functionEdge) { usedEdges.append(functionEdge) } + if !main.isEmpty { usedEdges.append(mainEdge) } + if !function.isEmpty, !usedEdges.contains(functionEdge) { usedEdges.append(functionEdge) } + + // Rails depend on the lanes of perpendicular edges and vice versa; + // settle lane counts with a few deterministic passes. + var lanes = Dictionary(uniqueKeysWithValues: usedEdges.map { ($0, 1) }) + var packed: [ControlEdge: [PlacedBlock]] = [:] + for _ in 0..<4 { + var next: [ControlEdge: Int] = [:] + for edge in usedEdges { + let range = alongRange(edge: edge, container: container, safeInsets: safeInsets, + reservesStrips: reservesStrips, lanes: lanes, metrics: metrics, + keyboardTop: keyboardTop) + let blocks = pack(length: range.upperBound - range.lowerBound, edge: edge, metrics: metrics, + main: edge == mainEdge ? main : [], alignments: mainAlignments, + function: edge == functionEdge ? function : []) + packed[edge] = blocks + next[edge] = max(1, (blocks.map(\.lane).max() ?? 0) + 1) + } + if next == lanes { break } + lanes = next + } let strips = reservesStrips - ? stripFrames(container: container, safeInsets: safeInsets, edges: usedEdges, metrics: metrics) + ? stripFrames(container: container, safeInsets: safeInsets, lanes: lanes, metrics: metrics) : [] - let canvas = canvasRect(container: container, strips: strips) - - func rail(for edge: ControlEdge) -> CGRect { - var rail = railRect(edge: edge, container: container, safeInsets: safeInsets, - strip: strips.first { $0.edge == edge }, metrics: metrics) - // Keep a corner shared with another used edge for that edge. - for other in usedEdges where other != edge && other.stacksVertically != edge.stacksVertically { - let clearance = metrics.clusterThickness(on: other) + metrics.gap - switch other { - case .leading: rail = trimStart(rail, by: clearance, vertical: false) - case .trailing: rail = trimEnd(rail, by: clearance, vertical: false) - case .top: rail = trimStart(rail, by: clearance, vertical: true) - case .bottom: rail = trimEnd(rail, by: clearance, vertical: true) + var mainSegments: [PadSegment] = [] + var functionSegments: [PadSegment] = [] + for edge in usedEdges { + let range = alongRange(edge: edge, container: container, safeInsets: safeInsets, + reservesStrips: reservesStrips, lanes: lanes, metrics: metrics, + keyboardTop: keyboardTop) + for block in packed[edge] ?? [] { + let cross = laneCenter(edge: edge, lane: block.lane, container: container, safeInsets: safeInsets, + reservesStrips: reservesStrips, metrics: metrics, keyboardTop: keyboardTop) + let cells = (0.. CGRect in + let along = range.lowerBound + block.start + + CGFloat(index) * ((edge.stacksVertically ? metrics.cell.height : metrics.cell.width) + metrics.gap) + return edge.stacksVertically + ? CGRect(x: cross - metrics.cell.width / 2, y: along, + width: metrics.cell.width, height: metrics.cell.height) + : CGRect(x: along, y: cross - metrics.cell.height / 2, + width: metrics.cell.width, height: metrics.cell.height) } + let segment = PadSegment(group: block.group, cells: cells) + if block.isMain { mainSegments.append(segment) } else { functionSegments.append(segment) } } - return avoidingKeyboard(rail, edge: edge, keyboardTop: keyboardTop, metrics: metrics) - } - - var mainFrame: CGRect? - if mainCount > 0 { - let mainRail = rail(for: mainEdge) - let size = metrics.clusterSize(count: mainCount, on: mainEdge) - mainFrame = clamped(CGRect(x: mainRail.midX - size.width / 2, y: mainRail.midY - size.height / 2, - width: size.width, height: size.height), - into: mainRail) - } - - var functionFrames: [CGRect] = [] - if !groups.isEmpty { - let functionRail = rail(for: functionEdge) - let vertical = functionEdge.stacksVertically - let startCount = (groups.count + 1) / 2 - let startGroups = Array(groups.prefix(startCount)) - let endGroups = Array(groups.dropFirst(startCount)) - let sizes = groups.map { metrics.clusterSize(count: $0, on: functionEdge) } - func along(_ size: CGSize) -> CGFloat { vertical ? size.height : size.width } - func frame(start: CGFloat, size: CGSize) -> CGRect { - vertical - ? CGRect(x: functionRail.midX - size.width / 2, y: start, width: size.width, height: size.height) - : CGRect(x: start, y: functionRail.midY - size.height / 2, width: size.width, height: size.height) + } + // Item order: by group, then by the order the runs were placed. + mainSegments = mainSegments.enumerated().sorted { ($0.element.group, $0.offset) < ($1.element.group, $1.offset) } + .map(\.element) + functionSegments = functionSegments.enumerated() + .sorted { ($0.element.group, $0.offset) < ($1.element.group, $1.offset) }.map(\.element) + return PadEdgeLayout(canvas: canvasRect(container: container, strips: strips), strips: strips, + mainSegments: mainSegments, functionSegments: functionSegments, laneCounts: lanes) + } + + /// Where a palette opens: beyond everything on the Main edge. + static func paletteAnchor(for layout: PadEdgeLayout, mainEdge: ControlEdge, functionEdge: ControlEdge) -> CGRect? { + let frames = layout.mainSegments.map(\.frame) + + (functionEdge == mainEdge ? layout.functionSegments.map(\.frame) : []) + return frames.isEmpty ? nil : frames.dropFirst().reduce(frames[0]) { $0.union($1) } + } + + // MARK: Packing + + private struct PlacedBlock { + var isMain: Bool + var group: Int + var count: Int + var lane: Int + /// Along-edge offset from the start of the rail. + var start: CGFloat + } + + private struct Run { + var isMain: Bool + var group: Int + var count: Int + } + + /// Packs Main and Function runs into lanes along a rail `length` long. + /// + /// Lane 0 reads, from the rail's start: start-aligned Main groups, the + /// first half of the Function groups, the centered Main groups, the + /// other Function half, and the end-aligned Main groups (e.g. Settings + /// last). What doesn't fit moves inward, Function first. + private static func pack(length: CGFloat, edge: ControlEdge, metrics: PadControlMetrics, + main: [Int], alignments: [PadGroupAlignment], function: [Int]) -> [PlacedBlock] { + guard length > 0 else { return [] } + let along = edge.stacksVertically ? metrics.cell.height : metrics.cell.width + let perLane = max(1, Int((length + metrics.gap) / (along + metrics.gap))) + let gap = metrics.groupGap + func runLength(_ run: Run) -> CGFloat { metrics.clusterLength(count: run.count, on: edge) } + func span(_ runs: [Run]) -> CGFloat { + runs.isEmpty ? 0 : runs.map(runLength).reduce(0, +) + CGFloat(runs.count - 1) * gap + } + func chunks(_ counts: [Int], isMain: Bool, groupOffset: Int = 0) -> [Run] { + counts.enumerated().flatMap { index, count -> [Run] in + stride(from: 0, to: count, by: perLane).map { + Run(isMain: isMain, group: groupOffset + index, count: min(perLane, count - $0)) + } } - var startFrames: [CGRect] = [] - var endFrames: [CGRect] = [] - let railStart = vertical ? functionRail.minY : functionRail.minX - let railEnd = vertical ? functionRail.maxY : functionRail.maxX - if let mainFrame, functionEdge == mainEdge { - var cursor = (vertical ? mainFrame.minY : mainFrame.minX) - metrics.groupGap - for index in startGroups.indices.reversed() { - cursor -= along(sizes[index]) - startFrames.insert(frame(start: cursor, size: sizes[index]), at: 0) - cursor -= metrics.groupGap + } + // Greedy lanes, in order, never splitting a run. + func lanesOf(_ runs: [Run]) -> [[Run]] { + var lanes: [[Run]] = [] + for run in runs { + if let last = lanes.last, span(last) + gap + runLength(run) <= length + 0.001 { + lanes[lanes.count - 1].append(run) + } else { + lanes.append([run]) } - cursor = (vertical ? mainFrame.maxY : mainFrame.maxX) + metrics.groupGap - for offset in endGroups.indices { - let size = sizes[startCount + offset] - endFrames.append(frame(start: cursor, size: size)) - cursor += along(size) + metrics.groupGap + } + return lanes + } + func placed(_ runs: [Run], lane: Int, from start: CGFloat) -> [PlacedBlock] { + var cursor = start + return runs.map { run in + defer { cursor += runLength(run) + gap } + return PlacedBlock(isMain: run.isMain, group: run.group, count: run.count, lane: lane, start: cursor) + } + } + func joined(_ parts: [[Run]]) -> [Run] { parts.flatMap { $0 } } + /// Places head at the start, tail at the end, and middle centered + /// between them; `false` if they don't fit. + func fits(head: [Run], middle: [Run], tail: [Run]) -> Bool { + let pieces = [head, middle, tail].filter { !$0.isEmpty } + return pieces.map(span).reduce(0, +) + CGFloat(max(0, pieces.count - 1)) * gap <= length + 0.001 + } + func place(head: [Run], middle: [Run], tail: [Run], lane: Int) -> [PlacedBlock] { + let headSpan = span(head) + let tailSpan = span(tail) + let middleSpan = span(middle) + let lower = head.isEmpty ? 0 : headSpan + gap + let upper = length - middleSpan - (tail.isEmpty ? 0 : tailSpan + gap) + let centered = (length - middleSpan) / 2 + return placed(head, lane: lane, from: 0) + + placed(middle, lane: lane, from: min(max(centered, lower), max(lower, upper))) + + placed(tail, lane: lane, from: length - tailSpan) + } + + let mainRuns = chunks(main, isMain: true) + let alignment = { (run: Run) -> PadGroupAlignment in + run.group < alignments.count ? alignments[run.group] : .center + } + let mainStart = mainRuns.filter { alignment($0) == .start } + let mainCenter = mainRuns.filter { alignment($0) == .center } + let mainEnd = mainRuns.filter { alignment($0) == .end } + let startCount = (function.count + 1) / 2 + let startRuns = chunks(Array(function.prefix(startCount)), isMain: false) + let endRuns = chunks(Array(function.dropFirst(startCount)), isMain: false, groupOffset: startCount) + + if fits(head: mainStart + startRuns, middle: mainCenter, tail: endRuns + mainEnd) { + return place(head: mainStart + startRuns, middle: mainCenter, tail: endRuns + mainEnd, lane: 0) + } + var result: [PlacedBlock] = [] + var nextLane = 0 + if !mainRuns.isEmpty { + if fits(head: mainStart, middle: mainCenter, tail: mainEnd) { + result = place(head: mainStart, middle: mainCenter, tail: mainEnd, lane: 0) + nextLane = 1 + } else if !mainCenter.isEmpty, fits(head: mainStart, middle: [], tail: mainEnd) { + // Anchored groups keep the outer lane's ends (system actions + // first, Settings last); the centered groups move inward. + result = place(head: mainStart, middle: [], tail: mainEnd, lane: 0) + let lanes = lanesOf(mainCenter) + for (offset, runs) in lanes.enumerated() { + result += placed(runs, lane: 1 + offset, from: (length - span(runs)) / 2) } + nextLane = 1 + lanes.count } else { - var cursor = railStart - for index in startGroups.indices { - startFrames.append(frame(start: cursor, size: sizes[index])) - cursor += along(sizes[index]) + metrics.groupGap + let lanes = lanesOf(joined([mainStart, mainCenter, mainEnd])) + for (lane, runs) in lanes.enumerated() { + result += placed(runs, lane: lane, from: (length - span(runs)) / 2) } - cursor = railEnd - for offset in endGroups.indices.reversed() { - let size = sizes[startCount + offset] - cursor -= along(size) - endFrames.insert(frame(start: cursor, size: size), at: 0) - cursor -= metrics.groupGap + nextLane = lanes.count + } + } + if !function.isEmpty { + // Lanes of their own: ends first, then plain wrapping. + if fits(head: startRuns, middle: [], tail: endRuns) { + result += place(head: startRuns, middle: [], tail: endRuns, lane: nextLane) + } else { + for (offset, runs) in lanesOf(startRuns + endRuns).enumerated() { + result += placed(runs, lane: nextLane + offset, from: 0) } } - functionFrames = (startFrames + endFrames).map { clamped($0, into: functionRail) } } + return result + } + + // MARK: Rails and lanes + + private static func laneThickness(edge: ControlEdge, lanes: Int, metrics: PadControlMetrics) -> CGFloat { + let one = metrics.clusterThickness(on: edge) + return CGFloat(lanes) * one + CGFloat(max(0, lanes - 1)) * metrics.gap + } + + /// The along-edge range controls may occupy on `edge`. + private static func alongRange(edge: ControlEdge, container: CGRect, safeInsets: ControlSafeInsets, + reservesStrips: Bool, lanes: [ControlEdge: Int], metrics: PadControlMetrics, + keyboardTop: CGFloat?) -> ClosedRange { + let margin = PadControlMetrics.edgeMargin + func band(_ other: ControlEdge) -> CGFloat { + guard let count = lanes[other] else { return 0 } + return reservesStrips + ? laneThickness(edge: other, lanes: count, metrics: metrics) + PadControlMetrics.stripPadding * 2 + + inset(safeInsets, other) + : laneThickness(edge: other, lanes: count, metrics: metrics) + metrics.gap + } + var lower: CGFloat + var upper: CGFloat + if edge.stacksVertically { + lower = container.minY + safeInsets.top + margin + upper = container.maxY - safeInsets.bottom - margin + if !reservesStrips { + // Clear the corners any top/bottom lanes occupy. + lower += band(.top) + upper -= band(.bottom) + } + if let keyboardTop, keyboardTop.isFinite { upper = min(upper, keyboardTop - margin) } + } else { + lower = container.minX + safeInsets.leading + margin + upper = container.maxX - safeInsets.trailing - margin + if reservesStrips { + // Top/bottom strips run between the side strips. + if lanes[.leading] != nil { lower = max(lower, container.minX + band(.leading) + margin) } + if lanes[.trailing] != nil { upper = min(upper, container.maxX - band(.trailing) - margin) } + } else { + lower += band(.leading) + upper -= band(.trailing) + } + } + return lower...max(lower, upper) + } - return PadEdgeLayout(canvas: canvas, strips: strips, mainFrame: mainFrame, functionFrames: functionFrames) + /// Cross-edge center of `lane` (0 = outermost). + private static func laneCenter(edge: ControlEdge, lane: Int, container: CGRect, safeInsets: ControlSafeInsets, + reservesStrips: Bool, metrics: PadControlMetrics, keyboardTop: CGFloat?) -> CGFloat { + let one = metrics.clusterThickness(on: edge) + let offset = (reservesStrips ? PadControlMetrics.stripPadding : PadControlMetrics.edgeMargin) + + CGFloat(lane) * (one + metrics.gap) + one / 2 + switch edge { + case .leading: return container.minX + safeInsets.leading + offset + case .trailing: return container.maxX - safeInsets.trailing - offset + case .top: return container.minY + safeInsets.top + offset + case .bottom: + var outer = container.maxY - safeInsets.bottom + if let keyboardTop, keyboardTop.isFinite { + outer = min(outer, keyboardTop) + } + return outer - offset + } } - /// Strip rails for `edges`. Left/right rails run the full height; top/ - /// bottom rails run between them. Each includes its edge's safe inset. + /// Strip rails for the used edges. Left/right rails run the full height; + /// top/bottom rails run between them. Each includes its edge's safe inset + /// and is as thick as its lanes need. static func stripFrames(container: CGRect, safeInsets: ControlSafeInsets, - edges: [ControlEdge], metrics: PadControlMetrics) -> [PadStrip] { + lanes: [ControlEdge: Int], metrics: PadControlMetrics) -> [PadStrip] { func thickness(_ edge: ControlEdge) -> CGFloat { - metrics.stripThickness(on: edge) + inset(safeInsets, edge) + laneThickness(edge: edge, lanes: lanes[edge] ?? 1, metrics: metrics) + + PadControlMetrics.stripPadding * 2 + inset(safeInsets, edge) } - let leading = edges.contains(.leading) ? thickness(.leading) : 0 - let trailing = edges.contains(.trailing) ? thickness(.trailing) : 0 - return edges.map { edge in + let leading = lanes[.leading] != nil ? thickness(.leading) : 0 + let trailing = lanes[.trailing] != nil ? thickness(.trailing) : 0 + return ControlEdge.allCases.filter { lanes[$0] != nil }.map { edge in let t = thickness(edge) let frame: CGRect switch edge { @@ -350,6 +560,11 @@ enum PadEdgeGeometry { } } + /// The span of a segment's control circles (captions excluded). + static func circleRun(of segment: PadSegment, metrics: PadControlMetrics) -> CGRect { + segment.cells.map { circleFrame(inCell: $0, metrics: metrics) }.reduce(CGRect.null) { $0.union($1) } + } + /// The control circle within a cell (its caption, if any, sits below). static func circleFrame(inCell cell: CGRect, metrics: PadControlMetrics) -> CGRect { CGRect(x: cell.midX - metrics.item / 2, y: cell.minY, width: metrics.item, height: metrics.item) @@ -366,70 +581,6 @@ enum PadEdgeGeometry { } } - /// The band a cluster on `edge` is centered in: inside its Strip rail - /// (clear of the safe inset), or — in Overlay — a margin inside the - /// safe area. - private static func railRect(edge: ControlEdge, container: CGRect, safeInsets: ControlSafeInsets, - strip: PadStrip?, metrics: PadControlMetrics) -> CGRect { - let safe = CGRect(x: container.minX + safeInsets.leading, - y: container.minY + safeInsets.top, - width: max(0, container.width - safeInsets.leading - safeInsets.trailing), - height: max(0, container.height - safeInsets.top - safeInsets.bottom)) - let thickness = metrics.clusterThickness(on: edge) - let margin = PadControlMetrics.edgeMargin - if let strip { - let frame = strip.frame - switch edge { - case .leading: - return CGRect(x: frame.minX + safeInsets.leading, y: safe.minY + margin, - width: frame.width - safeInsets.leading, height: max(0, safe.height - margin * 2)) - case .trailing: - return CGRect(x: frame.minX, y: safe.minY + margin, - width: frame.width - safeInsets.trailing, height: max(0, safe.height - margin * 2)) - case .top: - return CGRect(x: frame.minX + margin, y: frame.minY + safeInsets.top, - width: max(0, frame.width - margin * 2), height: frame.height - safeInsets.top) - case .bottom: - return CGRect(x: frame.minX + margin, y: frame.minY, - width: max(0, frame.width - margin * 2), height: frame.height - safeInsets.bottom) - } - } - let area = safe.insetBy(dx: margin, dy: margin) - switch edge { - case .leading: return CGRect(x: area.minX, y: area.minY, width: thickness, height: area.height) - case .trailing: return CGRect(x: area.maxX - thickness, y: area.minY, width: thickness, height: area.height) - case .top: return CGRect(x: area.minX, y: area.minY, width: area.width, height: thickness) - case .bottom: return CGRect(x: area.minX, y: area.maxY - thickness, width: area.width, height: thickness) - } - } - - private static func avoidingKeyboard(_ rail: CGRect, edge: ControlEdge, keyboardTop: CGFloat?, - metrics: PadControlMetrics) -> CGRect { - guard let keyboardTop, keyboardTop.isFinite, keyboardTop < rail.maxY else { return rail } - let limit = keyboardTop - PadControlMetrics.edgeMargin - switch edge { - case .bottom: - let height = rail.height - return CGRect(x: rail.minX, y: max(rail.minY - (rail.maxY - limit), 0), width: rail.width, height: height) - case .leading, .trailing: - return CGRect(x: rail.minX, y: rail.minY, width: rail.width, height: max(0, limit - rail.minY)) - case .top: - return rail - } - } - - private static func trimStart(_ rect: CGRect, by amount: CGFloat, vertical: Bool) -> CGRect { - vertical - ? CGRect(x: rect.minX, y: rect.minY + amount, width: rect.width, height: max(0, rect.height - amount)) - : CGRect(x: rect.minX + amount, y: rect.minY, width: max(0, rect.width - amount), height: rect.height) - } - - private static func trimEnd(_ rect: CGRect, by amount: CGFloat, vertical: Bool) -> CGRect { - vertical - ? CGRect(x: rect.minX, y: rect.minY, width: rect.width, height: max(0, rect.height - amount)) - : CGRect(x: rect.minX, y: rect.minY, width: max(0, rect.width - amount), height: rect.height) - } - /// Shifts `frame` inside `bounds` (centering it on any axis where it is /// larger), never resizing it. static func clamped(_ frame: CGRect, into bounds: CGRect) -> CGRect { @@ -443,69 +594,148 @@ enum PadEdgeGeometry { } } -// MARK: - Display-aware control backdrop +/// Auto-hide never applies to the iPad Strip: its rail is reserved space, +/// and fading the controls would leave an empty black band. +enum ControlAutoHidePolicy { + static func applies(isPad: Bool, layout: PadControlLayoutMode) -> Bool { + !(isPad && layout == .strip) + } +} + +// MARK: - Standard rail controls + +/// The standard system and utility controls the iPad Strip/Overlay rail can +/// show, each independently. Modifiers come from the Control Profile, and +/// Settings is permanent, so neither is listed here. +enum PadStandardControl: String, Codable, CaseIterable, Identifiable { + case menuBar + case dock + case showDesktop + case controlCenter + case escape + case tab + case keyboard + case moveView -/// Where the Mac is actually drawn, in the controls' coordinate space: the -/// receiver surface's live `RemoteViewportTransform` (the same one input -/// mapping uses), the surface's origin in that space, and its size (the -/// video view clips to its bounds). -struct DisplayFootprint: Equatable { - var transform: RemoteViewportTransform - var surfaceOrigin: CGPoint - var surfaceSize: CGSize + var id: String { rawValue } - static let none = DisplayFootprint(transform: .invalid, surfaceOrigin: .zero, surfaceSize: .zero) + var title: String { + switch self { + case .menuBar: return String(localized: "Menu Bar") + case .dock: return String(localized: "Dock") + case .showDesktop: return String(localized: "Show Desktop") + case .controlCenter: return String(localized: "Control Center") + case .escape: return String(localized: "Escape") + case .tab: return String(localized: "Tab") + case .keyboard: return String(localized: "Keyboard") + case .moveView: return String(localized: "Move View") + } + } + + static let systemKinds: Set = [ + PadStandardControl.menuBar.kind, PadStandardControl.dock.kind, + PadStandardControl.showDesktop.kind, PadStandardControl.controlCenter.kind, + ] - func contains(_ point: CGPoint) -> Bool { - let local = CGPoint(x: point.x - surfaceOrigin.x, y: point.y - surfaceOrigin.y) - guard local.x >= 0, local.y >= 0, local.x <= surfaceSize.width, local.y <= surfaceSize.height else { - return false + /// The existing control this entry shows. Dock, Escape, Tab and Keyboard + /// are Main tray items; Show Desktop and Control Center are the Function + /// Tray's semantic `ReceiverGesture` actions — one action system. + var kind: CustomControlKind { + switch self { + case .menuBar: return .function("menu-bar") + case .dock: return .tray(.dock) + case .showDesktop: return .function("show-desktop") + case .controlCenter: return .function("control-center") + case .escape: return .tray(.escape) + case .tab: return .tray(.tab) + case .keyboard: return .tray(.keyboard) + case .moveView: return .moveView } - return transform.containsViewPoint(local) } } -/// Whether (and how strongly) a floating control gets its small material -/// halo: only where it actually covers rendered Mac content — never over -/// letterboxing, empty canvas, or a Strip rail. -enum ControlBackdropPolicy { - /// Below this much coverage the halo is skipped entirely. - static let minimumCoverage: CGFloat = 0.05 - /// Coverage at which the halo reaches full strength. - static let fullCoverage: CGFloat = 0.5 - - /// Fraction of `rect` over the rendered display, sampled on a grid so - /// rotation and partial overlap need no polygon clipping. - static func coverage(of rect: CGRect, footprint: DisplayFootprint, samples: Int = 5) -> CGFloat { - guard rect.width > 0, rect.height > 0, samples > 0, footprint.transform.isValid else { return 0 } - var inside = 0 - for row in 0.. = [] + + init(hidden: Set = []) { self.hidden = hidden } + + func isVisible(_ control: PadStandardControl) -> Bool { !hidden.contains(control) } + + mutating func setVisible(_ visible: Bool, _ control: PadStandardControl) { + if visible { hidden.remove(control) } else { hidden.insert(control) } + } + + init(from decoder: Decoder) throws { + let raw = try decoder.singleValueContainer().decode([String].self) + hidden = Set(raw.compactMap(PadStandardControl.init(rawValue:))) + } + + func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + try container.encode(PadStandardControl.allCases.filter(hidden.contains).map(\.rawValue)) + } +} + +/// Where a Main group sits along its rail. +enum PadGroupAlignment: Equatable { + /// At the rail's start (the top of a side rail). + case start + case center + /// At the rail's end (the bottom of a side rail). + case end +} + +/// The Strip/Overlay Main rail, as visually separate groups in a fixed, +/// Sidecar-like order: system actions at the rail's start (top-right by +/// default), modifiers and keys centered, and view controls with the +/// permanent Settings gear at the rail's end (bottom-right by default). +/// Empty groups vanish; Settings keeps the last group from ever being empty. +enum PadRailComposition { + /// Alignment for each of `mainGroups`' groups, in the same order. + static func alignments(for groups: [[CustomControlKind]]) -> [PadGroupAlignment] { + groups.map { group in + if group.contains(.tray(.settings)) { return .end } + if group.contains(where: { PadStandardControl.systemKinds.contains($0) }) { return .start } + return .center } - return CGFloat(inside) / CGFloat(samples * samples) } - /// Halo strength for a coverage fraction: none below - /// `minimumCoverage`, then rising linearly to 1 at `fullCoverage`. - static func haloStrength(coverage: CGFloat) -> Double { - guard coverage >= minimumCoverage else { return 0 } - let t = (coverage - minimumCoverage) / (fullCoverage - minimumCoverage) - return Double(min(max(t, 0), 1)) + struct Availability: Equatable { + /// Visible modifiers from the active Control Profile (the Mac must + /// also understand receiver controls). + var modifiers: [ControlModifier] + var keyboardAvailable: Bool + /// Move View needs live video and Local View Navigation. + var moveViewAvailable: Bool } - static func haloStrength(for rect: CGRect, footprint: DisplayFootprint) -> Double { - haloStrength(coverage: coverage(of: rect, footprint: footprint)) + static func mainGroups(visibility: PadStandardControlVisibility, + availability: Availability) -> [[CustomControlKind]] { + func shown(_ control: PadStandardControl) -> Bool { + guard visibility.isVisible(control) else { return false } + switch control { + case .keyboard: return availability.keyboardAvailable + case .moveView: return availability.moveViewAvailable + default: return true + } + } + let system = [PadStandardControl.menuBar, .dock, .showDesktop, .controlCenter].filter(shown).map(\.kind) + let keys = availability.modifiers.map { CustomControlKind.tray(ControlTrayItem.item(for: $0)) } + + [PadStandardControl.escape, .tab, .keyboard].filter(shown).map(\.kind) + let view = [PadStandardControl.moveView].filter(shown).map(\.kind) + [.tray(.settings)] + return [system, keys, view].filter { !$0.isEmpty } } } -/// Auto-hide never applies to the iPad Strip: its rail is reserved space, -/// and fading the controls would leave an empty black band. -enum ControlAutoHidePolicy { - static func applies(isPad: Bool, layout: PadControlLayoutMode) -> Bool { - !(isPad && layout == .strip) +extension ControlTrayItem { + static func item(for modifier: ControlModifier) -> ControlTrayItem { + switch modifier { + case .command: return .command + case .option: return .option + case .control: return .control + case .shift: return .shift + } } } diff --git a/Shared/ReceiverGesture.swift b/Shared/ReceiverGesture.swift index 16672145..1cf28001 100644 --- a/Shared/ReceiverGesture.swift +++ b/Shared/ReceiverGesture.swift @@ -9,6 +9,13 @@ enum ReceiverGesture: String, CaseIterable { case showDesktop case launchpad case spotlight + /// macOS Control Center — a one-tap semantic action (no gesture maps to + /// it). Older senders ignore unknown names. + case controlCenter + /// Reveals and focuses the Mac's menu bar — also when a full-screen app + /// hides it. Momentary: macOS doesn't report menu bar visibility, so no + /// shown/hidden state is claimed. One-tap only. + case menuBar static let swipeMinimumDistance = 80.0 static let swipeDominanceRatio = 1.5 @@ -72,9 +79,16 @@ enum ReceiverMultiFingerRecognizer: CaseIterable { /// blind `true` that would undo the VoiceOver gate. Move View (local /// viewport navigation) also parks them, so no Mac gesture fires while the /// user is only moving the view. +/// +/// Remote-only recognizers (three-finger, pinch/spread) also need Allow +/// Input; the viewport double tap needs Local View Navigation; the +/// two-finger recognizer needs either (it both scrolls the Mac and moves +/// the local view — see `TwoFingerRoutingPolicy`). struct ReceiverMultiFingerGestureGate: Equatable { private(set) var voiceOverRunning: Bool private(set) var viewportNavigationActive = false + private(set) var remoteInputAllowed = true + private(set) var localNavigationAllowed = true init(voiceOverRunning: Bool) { self.voiceOverRunning = voiceOverRunning @@ -82,6 +96,15 @@ struct ReceiverMultiFingerGestureGate: Equatable { var recognizersEnabled: Bool { !voiceOverRunning && !viewportNavigationActive } + /// Returns whether availability changed. + mutating func update(remoteInputAllowed: Bool, localNavigationAllowed: Bool) -> Bool { + guard remoteInputAllowed != self.remoteInputAllowed + || localNavigationAllowed != self.localNavigationAllowed else { return false } + self.remoteInputAllowed = remoteInputAllowed + self.localNavigationAllowed = localNavigationAllowed + return true + } + /// Returns whether availability changed. mutating func update(viewportNavigationActive: Bool) -> Bool { guard viewportNavigationActive != self.viewportNavigationActive else { return false } @@ -90,7 +113,12 @@ struct ReceiverMultiFingerGestureGate: Equatable { } func isEnabled(_ recognizer: ReceiverMultiFingerRecognizer) -> Bool { - recognizersEnabled + guard recognizersEnabled else { return false } + switch recognizer { + case .twoFingerViewport: return remoteInputAllowed || localNavigationAllowed + case .viewportDoubleTap: return localNavigationAllowed + case .threeFingerSwipe, .threeFingerTap, .pinchSpread: return remoteInputAllowed + } } /// Returns whether availability changed (VoiceOver toggled at runtime). diff --git a/Shared/ReceiverInputPrompt.swift b/Shared/ReceiverInputPrompt.swift new file mode 100644 index 00000000..da9c746b --- /dev/null +++ b/Shared/ReceiverInputPrompt.swift @@ -0,0 +1,178 @@ +import Foundation + +/// The lightweight on-surface "Request Input" affordance: when a touch was +/// meant for the Mac but input isn't allowed, offer the existing request +/// flow right there instead of sending the user to Settings. Ephemeral — +/// it fades once the user stops trying — and never spams requests. The Mac +/// stays the only authority: this only ever sends the existing request. +struct ReceiverInputPrompt: Equatable { + enum Kind: Equatable { + /// The user turned input off themselves. + case enableInput + /// Input isn't granted for this session yet (or was declined). + case requestInput + /// A request is waiting for the Mac. + case requesting + /// Just granted — shown briefly, then gone. + case enabled + /// The Mac isn't accepting requests from this device. + case unavailable + } + + enum Effect: Equatable { + /// Send the existing allow-input request. + case sendRequest + } + + /// How long the prompt lingers after the last blocked attempt. + static let visibleDuration: TimeInterval = 3.5 + static let enabledDuration: TimeInterval = 1.6 + /// Automatic requests at most this often. + static let minimumAutomaticInterval: TimeInterval = 8 + /// A tapped request can't be re-sent faster than this. + static let minimumTappedInterval: TimeInterval = 1.5 + + private(set) var kind: Kind? + private(set) var visibleUntil: TimeInterval = 0 + private(set) var lastRequestAt: TimeInterval? + private var state: SessionInputWireState = .off + private var sessionLive = false + + func isVisible(at now: TimeInterval) -> Bool { kind != nil && now < visibleUntil } + + /// The live session came or went. No prompt without a live session. + mutating func setSessionLive(_ live: Bool) { + sessionLive = live + if !live { kind = nil } + } + + /// The Mac-confirmed input state changed. + mutating func inputStateChanged(_ newState: SessionInputWireState, now: TimeInterval) { + let previous = state + state = newState + guard kind != nil, now < visibleUntil || kind == .requesting else { return } + switch newState { + case .allowed where previous != .allowed: + kind = .enabled + visibleUntil = now + Self.enabledDuration + case .requesting: + kind = .requesting + case .requestsDisabled: + kind = .unavailable + visibleUntil = max(visibleUntil, now + Self.visibleDuration) + case .notAllowed, .off: + if kind == .requesting { kind = .requestInput; visibleUntil = now + Self.visibleDuration } + case .allowed: + break + } + } + + /// A touch that would have reached the Mac was dropped. With + /// `autoRequest` (this Mac is set to Always Allow on this device) the + /// first attempt sends the request itself, at most once per interval. + mutating func blockedAttempt(now: TimeInterval, userTurnedOff: Bool, autoRequest: Bool) -> [Effect] { + guard sessionLive, state != .allowed else { return [] } + visibleUntil = now + Self.visibleDuration + switch state { + case .requesting: + kind = .requesting + return [] + case .requestsDisabled: + kind = .unavailable + return [] + case .off, .notAllowed, .allowed: + kind = userTurnedOff ? .enableInput : .requestInput + guard autoRequest, !userTurnedOff, state != .notAllowed, + canRequest(now: now, interval: Self.minimumAutomaticInterval) else { return [] } + lastRequestAt = now + kind = .requesting + return [.sendRequest] + } + } + + /// The user tapped Request/Enable Input. + mutating func requestTapped(now: TimeInterval) -> [Effect] { + guard sessionLive, state != .allowed, state != .requestsDisabled, state != .requesting, + canRequest(now: now, interval: Self.minimumTappedInterval) else { return [] } + lastRequestAt = now + kind = .requesting + visibleUntil = now + Self.visibleDuration + return [.sendRequest] + } + + /// Drops a prompt whose time is up. + mutating func tick(now: TimeInterval) { + if kind != nil, now >= visibleUntil { kind = nil } + } + + private func canRequest(now: TimeInterval, interval: TimeInterval) -> Bool { + lastRequestAt.map { now - $0 >= interval } ?? true + } +} + +/// Which dropped touch sequences count as trying to control the Mac. A +/// two-finger sequence is Local View Navigation (pan/zoom/rotate), never a +/// reason to ask for input; nor is anything in Move View. +enum BlockedInputAttemptPolicy { + static func isRemoteIntent(maximumTouchCount: Int, moveViewActive: Bool) -> Bool { + guard !moveViewActive, maximumTouchCount > 0 else { return false } + return maximumTouchCount != 2 + } +} + +/// Whether a blocked touch may send the input request by itself. Only an +/// explicit, per-Mac, input-specific opt-in counts — never the connection +/// policy (accepting a Mac's connections with Always Allow says nothing +/// about letting a stray touch ask for control). Even then this only sends +/// the normal request; the Mac alone grants input. +enum InputAutoRequestPolicy { + static func shouldAutoRequest(peerOptedIn: Bool) -> Bool { peerOptedIn } +} + +/// The per-Mac "Request Input Automatically" opt-in, keyed by the pinned +/// peer ID like `IncomingSessionPolicyStore`. Off unless the user turns it +/// on; forgetting the Mac removes it. +enum InputAutoRequestStore { + static let defaultsKey = "inputAutoRequestPeers.v1" + + static func isEnabled(peerID: String?, defaults: UserDefaults = .standard) -> Bool { + guard let peerID else { return false } + return (defaults.stringArray(forKey: defaultsKey) ?? []).contains(peerID) + } + + static func setEnabled(_ enabled: Bool, peerID: String, defaults: UserDefaults = .standard) { + var peers = Set(defaults.stringArray(forKey: defaultsKey) ?? []) + if enabled { peers.insert(peerID) } else { peers.remove(peerID) } + defaults.set(peers.sorted(), forKey: defaultsKey) + } + + static func remove(peerID: String, defaults: UserDefaults = .standard) { + setEnabled(false, peerID: peerID, defaults: defaults) + } +} + +/// What the receiver's own controls show. +enum ReceiverControlPresentation: Equatable { + /// Strip, Overlay, Custom, or the iPhone tray. + case normal + /// The software keyboard is up: a flat accessory bar right above it. + case keyboardBar + + static func mode(softwareKeyboardVisible: Bool) -> ReceiverControlPresentation { + softwareKeyboardVisible ? .keyboardBar : .normal + } +} + +/// The keyboard accessory bar's contents — keyboard companions only, never +/// the session controls (Undo, Zoom, Dock, Move View, Settings, …). Kept as +/// data so its contents can become configurable later. +enum KeyboardBarItem: Hashable { + case modifier(ControlModifier) + case escape + case tab + /// Dismisses the software keyboard. + case dismissKeyboard + + static let defaultItems: [KeyboardBarItem] = ControlModifier.allCases.map(KeyboardBarItem.modifier) + + [.escape, .tab, .dismissKeyboard] +} diff --git a/Shared/RemoteViewport.swift b/Shared/RemoteViewport.swift index b5fc62d3..f935bef8 100644 --- a/Shared/RemoteViewport.swift +++ b/Shared/RemoteViewport.swift @@ -775,3 +775,70 @@ struct ViewportNavigationSession { atan2(points[1].y - points[0].y, points[1].x - points[0].x) } } + +/// Local View Navigation vs. Allow Input: two separate permissions. Allow +/// Input gates everything that reaches the Mac (pointer, keyboard, remote +/// scroll, gesture commands); Local View Navigation gates only the +/// receiver-local view (pan, zoom, rotation, Move View, reset), which never +/// sends anything. With input off and navigation on, the Mac can be +/// inspected but not controlled; with both off, the view is locked. +enum LocalViewNavigationPolicy { + /// Whether the receiver surface should receive touches at all. + static func surfaceAcceptsTouches(remoteInputAllowed: Bool, localNavigationAllowed: Bool, + videoEnabled: Bool) -> Bool { + remoteInputAllowed || allowsViewportChanges(localNavigationAllowed: localNavigationAllowed, + videoEnabled: videoEnabled) + } + + /// Manual viewport changes only apply to live video. + static func allowsViewportChanges(localNavigationAllowed: Bool, videoEnabled: Bool) -> Bool { + localNavigationAllowed && videoEnabled + } + + /// The Move View control shows only when it's wanted and would work. + static func showsMoveView(controlVisible: Bool, localNavigationAllowed: Bool, videoEnabled: Bool) -> Bool { + controlVisible && allowsViewportChanges(localNavigationAllowed: localNavigationAllowed, + videoEnabled: videoEnabled) + } +} + +/// What a committed two-finger gesture may do. +enum TwoFingerRoute: Equatable, Hashable { + /// Remote Mac scrolling. + case remoteScroll + /// Local viewport pan/zoom/rotate. + case viewport + /// App Gesture Commands (keyboard chords sent to the Mac). + case appCommands +} + +enum TwoFingerRoutingPolicy { + /// Routes for a committed intent. Anything that reaches the Mac requires + /// `remoteInputAllowed`; the local viewport requires Local View + /// Navigation. The effective targets already fold in video-off routing. + static func routes(intent: TwoFingerGestureIntent, + remoteInputAllowed: Bool, + localNavigationAllowed: Bool, + videoEnabled: Bool, + pinchTarget: ReceiverGestureTarget, + rotateTarget: ReceiverGestureTarget) -> Set { + switch intent { + case .undecided: + return [] + case .scroll: + return remoteInputAllowed ? [.remoteScroll] : [] + case .viewportZoomPan: + var routes: Set = [] + if LocalViewNavigationPolicy.allowsViewportChanges(localNavigationAllowed: localNavigationAllowed, + videoEnabled: videoEnabled) { + routes.insert(.viewport) + } + let pinch = VideoInteractionPolicy.effectiveTarget(stored: pinchTarget, videoEnabled: videoEnabled) + let rotate = VideoInteractionPolicy.effectiveTarget(stored: rotateTarget, videoEnabled: videoEnabled) + if remoteInputAllowed, pinch == .app || rotate == .app { + routes.insert(.appCommands) + } + return routes + } + } +} diff --git a/Shared/StreamReceiver.swift b/Shared/StreamReceiver.swift index 40699ded..62876818 100644 --- a/Shared/StreamReceiver.swift +++ b/Shared/StreamReceiver.swift @@ -1548,6 +1548,7 @@ final class StreamReceiver: ObservableObject { func forgetPeer(_ peerID: String) { TrustStore.shared.forget(peerID: peerID) IncomingSessionPolicyStore.removePolicy(peerID: peerID) + InputAutoRequestStore.remove(peerID: peerID) let uiSink = self.uiSink DispatchQueue.main.async { uiSink.publishLastForgottenPeerID(peerID) } revokeSessionAuthority(peerID: peerID, trustRemoved: true) diff --git a/iOS/CustomLayoutEditor.swift b/iOS/CustomLayoutEditor.swift index fa559a04..d9984a6b 100644 --- a/iOS/CustomLayoutEditor.swift +++ b/iOS/CustomLayoutEditor.swift @@ -4,9 +4,16 @@ import UIKit // MARK: - Custom layout list (Settings → Controls, iPad only) /// The user's Custom layouts, plus `Create Custom Layout` until -/// `CustomControlLayout.maximumCount` exist. Nothing is pre-created. +/// `CustomControlLayout.maximumCount` exist, and Import. Nothing is +/// pre-created. Spatial editing happens in `CustomLayoutEditorScreen`; +/// Settings only manages the layouts. struct CustomLayoutListSection: View { @ObservedObject var store: ReceiverControlStore + /// Presented by the page that owns the Form (`CustomLayoutPresentations`) + /// — a presentation attached to a List section can be torn down when + /// the list re-renders. + @Binding var editorRequest: CustomLayoutEditorRequest? + @Binding var deleting: CustomControlLayout? var body: some View { Section { @@ -19,7 +26,7 @@ struct CustomLayoutListSection: View { } ForEach(store.preferences.customLayouts) { layout in NavigationLink { - CustomLayoutEditor(store: store, layoutID: layout.id) + CustomLayoutDetailPage(store: store, layoutID: layout.id) } label: { HStack { Text(layout.name) @@ -29,6 +36,25 @@ struct CustomLayoutListSection: View { } } } + .contextMenu { + Button { editorRequest = .init(layoutID: layout.id, preview: false) } label: { + Label("Edit Layout", systemImage: "square.and.pencil") + } + Button { editorRequest = .init(layoutID: layout.id, preview: true) } label: { + Label("Preview", systemImage: "play.rectangle") + } + if let code = try? CustomLayoutShareCode.encode(layout, functionItems: store.functionItems(for: layout)) { + ShareLink(item: code, subject: Text(layout.name)) { + Label("Share Layout", systemImage: "square.and.arrow.up") + } + Button { UIPasteboard.general.string = code } label: { + Label("Copy Share Code", systemImage: "doc.on.doc") + } + } + Button(role: .destructive) { deleting = layout } label: { + Label("Delete", systemImage: "trash") + } + } } .onDelete { offsets in let ids = offsets.map { store.preferences.customLayouts[$0].id } @@ -41,319 +67,761 @@ struct CustomLayoutListSection: View { Label("Create Custom Layout", systemImage: "plus") } } + NavigationLink { + CustomLayoutImportPage(store: store) + } label: { + Label("Import Layout…", systemImage: "square.and.arrow.down") + } } header: { Text("Custom Layouts") } footer: { - Text("Up to \(CustomControlLayout.maximumCount) layouts, each with its own landscape and portrait arrangement. New layouts start from the radial corner template.") + Text("Up to \(CustomControlLayout.maximumCount) layouts, each with its own landscape and portrait arrangement. New layouts start from the radial corner template. Touch and hold a layout to edit, preview or share it.") } } } -// MARK: - Editor - -/// A controller-style editor: an iPad-surface preview where controls are -/// dragged, selected, resized and removed, per orientation. Only placement -/// and presentation are edited here — what a control does still comes from -/// the Control Profile and Function Tray. -struct CustomLayoutEditor: View { +/// The Custom layout list's editor cover and delete confirmation, attached +/// to the page's Form rather than to the list section itself. +struct CustomLayoutPresentations: ViewModifier { @ObservedObject var store: ReceiverControlStore - let layoutID: String - - @State private var editingPortrait: Bool - @State private var selectedID: String? - @State private var dragPositions: [String: CGPoint] = [:] - @State private var previewPalette = false - @State private var confirmingTemplate: ControlCorner? - @State private var name = "" + @Binding var editorRequest: CustomLayoutEditorRequest? + @Binding var deleting: CustomControlLayout? - init(store: ReceiverControlStore, layoutID: String) { - self.store = store - self.layoutID = layoutID - let bounds = Self.screenBounds - _editingPortrait = State(initialValue: bounds.height > bounds.width) + func body(content: Content) -> some View { + content + .fullScreenCover(item: $editorRequest) { request in + CustomLayoutEditorScreen(store: store, layoutID: request.layoutID, startsInPreview: request.preview) + } + .confirmationDialog("Delete \u{201C}\(deleting?.name ?? "")\u{201D}?", + isPresented: Binding(get: { deleting != nil }, set: { if !$0 { deleting = nil } }), + titleVisibility: .visible) { + Button("Delete Layout", role: .destructive) { + if let id = deleting?.id { store.update { $0.deleteCustomLayout(id) } } + deleting = nil + } + } } +} - private var layout: CustomControlLayout? { - store.preferences.customLayouts.first { $0.id == layoutID } +extension ReceiverControlStore { + /// The Function actions a Custom layout draws from: its chosen profile, + /// or the active one. + func functionItems(for layout: CustomControlLayout?) -> [ShortcutItem] { + preferences.functionTrayProfile(for: layout?.functionProfile ?? preferences.activeFunctionTrayProfile).allItems } +} - private var arrangement: CustomControlArrangement? { - layout?.arrangement(portrait: editingPortrait) - } +struct CustomLayoutEditorRequest: Identifiable { + let layoutID: String + let preview: Bool + var id: String { layoutID + (preview ? ".preview" : ".edit") } +} - private var selected: CustomControlPlacement? { - arrangement?.placements.first { $0.id == selectedID } - } +// MARK: - Layout detail (Settings) + +/// One layout's page: name, the editor, preview, sharing and deletion. +private struct CustomLayoutDetailPage: View { + @ObservedObject var store: ReceiverControlStore + let layoutID: String + @State private var name = "" + @State private var editorRequest: CustomLayoutEditorRequest? + @State private var confirmingDelete = false + @State private var copied = false + @Environment(\.dismiss) private var dismiss + + private var layout: CustomControlLayout? { store.preferences.customLayouts.first { $0.id == layoutID } } var body: some View { - Group { + Form { if let layout { - ScrollView { - VStack(alignment: .leading, spacing: 20) { - Picker("Orientation", selection: $editingPortrait) { - Text("Landscape").tag(false) - Text("Portrait").tag(true) + Section { + TextField("Layout Name", text: $name) + .onSubmit { store.update { $0.renameCustomLayout(layoutID, to: name) } } + if store.preferences.activeCustomLayout?.id != layoutID { + Button("Use This Layout") { store.update { $0.activeCustomLayoutID = layoutID } } + } else { + LabeledContent("Status", value: String(localized: "Active")) + } + } + Section { + Button { editorRequest = .init(layoutID: layoutID, preview: false) } label: { + Label("Edit Layout", systemImage: "square.and.pencil") + } + Button { editorRequest = .init(layoutID: layoutID, preview: true) } label: { + Label("Preview", systemImage: "play.rectangle") + } + } footer: { + Text("The editor opens full screen, with separate landscape and portrait arrangements. Preview lets you try the layout without sending anything to your Mac.") + } + Section { + Toggle(isOn: Binding(get: { layout.twoHandAssist }, + set: { value in + var updated = layout + updated.twoHandAssist = value + store.update { $0.updateCustomLayout(updated) } + })) { + HStack(spacing: 6) { + Text("Two-Hand Assist") + InfoButton(title: "Two-Hand Assist", text: HelpText.twoHandAssist) + } + } + } + if let code = try? CustomLayoutShareCode.encode(layout, functionItems: store.functionItems(for: layout)) { + Section { + ShareLink(item: code, subject: Text(layout.name), + message: Text("A MeowDisplay Custom layout. Import it in Settings → Controls → Custom Layouts.")) { + Label("Share Layout", systemImage: "square.and.arrow.up") } - .pickerStyle(.segmented) - preview - if let selected { - inspector(for: selected) - } else { - Text("Drag a control to move it. Tap one to resize it, change its palette, or remove it.") - .font(.footnote) - .foregroundStyle(.secondary) + Button { + UIPasteboard.general.string = code + copied = true + } label: { + Label(copied ? "Copied" : "Copy Share Code", systemImage: copied ? "checkmark" : "doc.on.doc") } - layoutOptions(layout) + } footer: { + Text("A share code contains only this layout — positions, sizes, palettes, shortcut buttons and Two-Hand Assist. Never pairing, devices or permissions.") + } + } + Section { + Button("Delete Layout", role: .destructive) { confirmingDelete = true } + } + } + } + .navigationTitle(layout?.name ?? "") + .onAppear { name = layout?.name ?? "" } + .onDisappear { store.update { $0.renameCustomLayout(layoutID, to: name) } } + .fullScreenCover(item: $editorRequest) { request in + CustomLayoutEditorScreen(store: store, layoutID: request.layoutID, startsInPreview: request.preview) + } + .confirmationDialog("Delete this layout?", isPresented: $confirmingDelete, titleVisibility: .visible) { + Button("Delete Layout", role: .destructive) { + store.update { $0.deleteCustomLayout(layoutID) } + dismiss() + } + } + } +} + +// MARK: - Import + +/// Import Layout → paste (field or Paste from Clipboard) → Validate → +/// review → Import. A pushed page in Settings' own navigation, so nothing +/// can dismiss it early: it closes only after a successful import, or when +/// the user navigates back. Importing only adds a layout; it never runs +/// anything, and a bad code changes nothing. See `CustomLayoutImportFlow`. +struct CustomLayoutImportPage: View { + @ObservedObject var store: ReceiverControlStore + @Environment(\.dismiss) private var dismiss + @State private var flow = CustomLayoutImportFlow() + + var body: some View { + Form { + Section { + TextField("MDL1-…", text: Binding(get: { flow.code }, set: { flow.edit($0) }), axis: .vertical) + .lineLimit(3...8) + .font(.footnote.monospaced()) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + Button { + flow.paste(UIPasteboard.general.string) + } label: { + Label("Paste from Clipboard", systemImage: "doc.on.clipboard") + } + Button("Validate") { flow.validate() } + .disabled(!flow.canValidate) + } header: { + Text("Share Code") + } footer: { + Text("Paste a code someone shared with you. It starts with MDL1-.") + } + switch flow.stage { + case .entering: + EmptyView() + case .reviewing(let summary), .limitReached(let summary): + Section("Layout") { + LabeledContent("Name", value: summary.layout.name) + LabeledContent("Landscape", value: controlCount(summary.landscapeControls)) + LabeledContent("Portrait", value: controlCount(summary.portraitControls)) + LabeledContent("Shortcut Buttons", value: "\(summary.shortcutCount)") + LabeledContent("Two-Hand Assist", value: summary.twoHandAssist ? String(localized: "On") : String(localized: "Off")) + ForEach(Array(summary.warnings.enumerated()), id: \.offset) { _, warning in + Label(warningText(warning), systemImage: "exclamationmark.triangle") + .foregroundStyle(.orange) + .font(.footnote) } - .padding() - .frame(maxWidth: 820) - .frame(maxWidth: .infinity) - } - .background(Color(.systemGroupedBackground)) - .navigationTitle(layout.name) - .navigationBarTitleDisplayMode(.inline) - .toolbar { - ToolbarItem(placement: .primaryAction) { addMenu } - } - .onAppear { name = layout.name } - .onChange(of: editingPortrait) { _ in - selectedID = nil - previewPalette = false - } - .confirmationDialog("Replace this orientation with the radial template?", - isPresented: Binding(get: { confirmingTemplate != nil }, - set: { if !$0 { confirmingTemplate = nil } }), - titleVisibility: .visible) { - Button("Use Template", role: .destructive) { - if let corner = confirmingTemplate { - mutate { $0 = .radialTemplate(corner: corner, portrait: editingPortrait) } + } + Section { + if case .limitReached = flow.stage { + Text("You already have \(CustomControlLayout.maximumCount) Custom layouts. Delete one first, then import this one.") + .foregroundStyle(.secondary) + } else { + Button("Import Layout") { + store.update { flow.importLayout(into: &$0) } } - confirmingTemplate = nil - selectedID = nil } - } message: { - Text("Only the \(editingPortrait ? String(localized: "portrait") : String(localized: "landscape")) arrangement changes.") + } footer: { + Text("Importing adds a new layout. Its shortcut buttons do nothing until you tap them.") } - } else { - Text("This layout no longer exists.").foregroundStyle(.secondary) + case .failed(let error): + Section { + Label(errorText(error), systemImage: "xmark.octagon").foregroundStyle(.red) + } + case .imported: + EmptyView() } } + .navigationTitle("Import Layout") + .onChange(of: flow.isFinished) { finished in if finished { dismiss() } } } - // MARK: Preview + private func controlCount(_ count: Int) -> String { + String(localized: "\(count) controls") + } - /// The device's full-screen size in the chosen orientation. - private static var screenBounds: CGRect { - let scene = UIApplication.shared.connectedScenes.compactMap { $0 as? UIWindowScene }.first - return scene?.screen.bounds ?? CGRect(x: 0, y: 0, width: 1194, height: 834) + private func warningText(_ warning: CustomLayoutShareCode.ImportSummary.Warning) -> String { + switch warning { + case .skippedControls(let count): + return String(localized: "\(count) controls from a newer MeowDisplay were skipped.") + case .unknownActions(let count): + return String(localized: "\(count) Function actions aren’t available in this version and won’t appear.") + } } - private var deviceSize: CGSize { - let bounds = Self.screenBounds - let long = max(bounds.width, bounds.height) - let short = min(bounds.width, bounds.height) - return editingPortrait ? CGSize(width: short, height: long) : CGSize(width: long, height: short) + private func errorText(_ error: CustomLayoutShareCode.ShareCodeError) -> String { + switch error { + case .notAShareCode: return String(localized: "This isn’t a MeowDisplay layout share code.") + case .unsupportedVersion: return String(localized: "This layout was shared from a newer MeowDisplay. Update to import it.") + case .corrupted: return String(localized: "This share code is damaged or incomplete. Copy it again.") + case .tooLarge: return String(localized: "This share code is too large.") + } } +} - /// The live window's insets, mapped onto the orientation being edited - /// (iPad: a thin top inset, and the Home indicator at the bottom). - private var deviceSafeInsets: ControlSafeInsets { - let window = UIApplication.shared.connectedScenes.compactMap { $0 as? UIWindowScene } - .flatMap(\.windows).first(where: \.isKeyWindow) - let insets = window?.safeAreaInsets ?? UIEdgeInsets(top: 24, left: 0, bottom: 20, right: 0) - return ControlSafeInsets(top: insets.top, leading: 0, bottom: max(insets.bottom, 20), trailing: 0) +// MARK: - Help + +private enum HelpText { + static let alignmentGuides = String(localized: "Dots or grid lines for lining controls up while you edit. They aren’t part of your Mac’s screen and never appear during normal use.") + static let snapToEdges = String(localized: "A control dropped near the edge of the usable area, or near its center lines, settles exactly against it.") + static let snapToGuides = String(localized: "A control dropped near a guide line settles exactly on it. Needs Alignment Guides on.") + static let paletteStyle = String(localized: "How a modifier’s shortcuts appear while you hold or latch it. Wheel surrounds it with wedge-shaped segments; Arc fans them along the next ring out; Ring circles it; Row and Column line them up.") + static let paletteDistance = String(localized: "How far the shortcuts sit from the modifier.") + static let paletteDirection = String(localized: "Which way the shortcuts open. Toward Screen Center keeps them clear of the edges.") + static let twoHandAssist = String(localized: "While a modifier is held or latched, modifier keys appear on the opposite side, so your other hand can add to the same chord. Otherwise that side offers actions like Undo, Redo and Zoom.") + static let orientations = String(localized: "Landscape and portrait are arranged separately — MeowDisplay uses whichever matches how you hold your iPad.") + static let systemArea = String(localized: "The shaded strips are the system’s areas (status bar and Home indicator). Controls always stay inside the dashed outline.") +} + +/// A small ⓘ that explains one concept in a popover. +private struct InfoButton: View { + let title: LocalizedStringKey + let text: String + @State private var showing = false + + var body: some View { + Button { showing = true } label: { + Image(systemName: "info.circle") + .imageScale(.medium) + .foregroundStyle(.secondary) + } + .buttonStyle(.borderless) + .accessibilityLabel(Text("About \(Text(title))")) + .popover(isPresented: $showing) { + VStack(alignment: .leading, spacing: 8) { + Text(title).font(.headline) + Text(text).font(.subheadline).fixedSize(horizontal: false, vertical: true) + } + .padding() + .frame(width: 300) + } } +} - private var preview: some View { - let device = deviceSize - let metrics = PadControlMetrics(scale: store.preferences.padControlScale) - let container = CGRect(origin: .zero, size: device) - let area = CustomLayoutGeometry.layoutArea(container: container, safeInsets: deviceSafeInsets) - return GeometryReader { proxy in - let scale = min(proxy.size.width / device.width, proxy.size.height / device.height) - let frames = placementFrames(area: area, base: metrics.item) - ZStack(alignment: .topLeading) { - // The surface: a stand-in Mac desktop, the safe layout area, - // and alignment guides. - RoundedRectangle(cornerRadius: 18 / scale, style: .continuous) - .fill(LinearGradient(colors: [Color(red: 0.16, green: 0.2, blue: 0.32), - Color(red: 0.32, green: 0.22, blue: 0.36)], - startPoint: .topLeading, endPoint: .bottomTrailing)) - .frame(width: device.width, height: device.height) - alignmentGuides(area: area) - ForEach(arrangement?.placements ?? []) { placement in - if let frame = frames[placement.id] { - editorChip(placement, frame: frame, area: area) +private struct CustomLayoutHelpSheet: View { + @Environment(\.dismiss) private var dismiss + + var body: some View { + NavigationStack { + List { + Section { + step(1, "Place utility controls", "Keyboard, Escape and Tab sit on the inner arc around Settings. Drag any control where your thumb can reach it.") + step(2, "Place modifier controls", "⌘ ⌥ ⌃ ⇧ form the next arc out. Hold or tap a modifier to use it.") + step(3, "Configure shortcut palettes", "Select a modifier to choose how its shortcuts appear. By default they bloom on the arc just outside the modifiers.") + step(4, "Optionally configure Two-Hand Assist", "Your other hand gets modifier keys on the opposite side while a chord is in progress.") + step(5, "Preview", "Try the layout exactly as it will look. Preview never sends anything to your Mac.") + step(6, "Done", "Your layout is saved. Choose it under Settings → Controls → Control Layout → Custom.") + } header: { + Text("How Custom Layouts Work") + } + Section("Guides") { + LabeledContent("Alignment Guides") { Text(HelpText.alignmentGuides).foregroundStyle(.secondary) } + LabeledContent("Snap to Edges") { Text(HelpText.snapToEdges).foregroundStyle(.secondary) } + LabeledContent("Snap to Guides") { Text(HelpText.snapToGuides).foregroundStyle(.secondary) } + LabeledContent("System Areas") { Text(HelpText.systemArea).foregroundStyle(.secondary) } + LabeledContent("Landscape & Portrait") { Text(HelpText.orientations).foregroundStyle(.secondary) } + } + Section { + Text("Add Control → Keyboard Shortcut makes a button that sends any key combination once, like ⌃U or ⌘⇧P.") + } header: { + Text("Shortcut Buttons") + } + } + .navigationTitle("Help") + .navigationBarTitleDisplayMode(.inline) + .toolbar { ToolbarItem(placement: .confirmationAction) { Button("Done") { dismiss() } } } + } + } + + private func step(_ number: Int, _ title: LocalizedStringKey, _ detail: LocalizedStringKey) -> some View { + HStack(alignment: .top, spacing: 12) { + Image(systemName: "\(number).circle.fill").font(.title2).foregroundStyle(.tint) + VStack(alignment: .leading, spacing: 2) { + Text(title).font(.headline) + Text(detail).font(.subheadline).foregroundStyle(.secondary) + } + } + .padding(.vertical, 2) + } +} + +// MARK: - Editor + +/// The full-screen Custom layout editor. Edits a draft, so Edit ↔ Preview +/// is instant and nothing is saved until Done. +struct CustomLayoutEditorScreen: View { + @ObservedObject var store: ReceiverControlStore + let layoutID: String + + @Environment(\.dismiss) private var dismiss + @State private var draft: CustomControlLayout? + @State private var previewing: Bool + @State private var editingPortrait: Bool + @State private var selectedID: String? + @State private var dragPositions: [String: CGPoint] = [:] + @State private var session = CustomLayoutPreviewSession() + @State private var previewAnchor: ControlModifier? + @State private var showingHelp = false + @State private var confirmingTemplate: ControlCorner? + @State private var confirmingDiscard = false + @State private var editingShortcut: ShortcutEditorRequest? + @State private var showingAssistPreview = false + @AppStorage("customLayoutEditor.guideStyle") private var guideStyle = AlignmentGuideStyle.dots + @AppStorage("customLayoutEditor.snapToEdges") private var snapsToEdges = true + @AppStorage("customLayoutEditor.snapToGuides") private var snapsToGuides = false + @AppStorage("customLayoutEditor.tipSeen") private var tipSeen = false + + init(store: ReceiverControlStore, layoutID: String, startsInPreview: Bool = false) { + self.store = store + self.layoutID = layoutID + _previewing = State(initialValue: startsInPreview) + let bounds = EditorDevice.screenBounds + _editingPortrait = State(initialValue: bounds.height > bounds.width) + } + + private var arrangement: CustomControlArrangement? { draft?.arrangement(portrait: editingPortrait) } + private var selected: CustomControlPlacement? { arrangement?.placements.first { $0.id == selectedID } } + private var stored: CustomControlLayout? { store.preferences.customLayouts.first { $0.id == layoutID } } + + var body: some View { + NavigationStack { + GeometryReader { proxy in + let wide = proxy.size.width > proxy.size.height + Group { + if previewing { + canvas + } else if wide { + HStack(spacing: 0) { + canvas + inspectorPanel.frame(width: 340) + } + } else { + VStack(spacing: 0) { + canvas + inspectorPanel.frame(maxHeight: 320) + } } } - if previewPalette, let selected, let modifier = selected.kind.modifier, - let anchor = frames[selected.id] { - palettePreview(modifier: modifier, anchor: anchor, style: selected.palette ?? PalettePresentation(), - area: area, base: metrics.item, - obstacles: frames.filter { $0.key != selected.id }.map(\.value)) + } + .background(Color(.systemGroupedBackground)) + .navigationTitle(draft?.name ?? "") + .navigationBarTitleDisplayMode(.inline) + .toolbar { toolbar } + .statusBarHidden(false) + } + .onAppear { if draft == nil { draft = stored } } + .onChange(of: editingPortrait) { _ in + selectedID = nil + session.reset() + } + .onChange(of: previewing) { _ in + session.reset() + previewAnchor = nil + selectedID = nil + } + .sheet(isPresented: $showingHelp) { CustomLayoutHelpSheet() } + .sheet(item: $editingShortcut) { request in + ShortcutButtonEditor(initial: request.item) { item in + if let placementID = request.placementID { + updatePlacement(placementID) { $0.kind = .shortcut(item) } + } else { + let placement = CustomControlPlacement(kind: .shortcut(item), x: 0.5, y: 0.5) + mutate { $0.placements.append(placement) } + selectedID = placement.id } } - .frame(width: device.width, height: device.height, alignment: .topLeading) - .scaleEffect(scale, anchor: .topLeading) - .frame(width: device.width * scale, height: device.height * scale, alignment: .topLeading) - .clipShape(RoundedRectangle(cornerRadius: 18, style: .continuous)) - .overlay(RoundedRectangle(cornerRadius: 18, style: .continuous) - .strokeBorder(Color.primary.opacity(0.25), lineWidth: 1)) - .contentShape(Rectangle()) - .onTapGesture { selectedID = nil } - .frame(maxWidth: .infinity) } - .aspectRatio(device.width / device.height, contentMode: .fit) - .accessibilityElement(children: .contain) - .accessibilityLabel(editingPortrait ? "Portrait layout preview" : "Landscape layout preview") + .confirmationDialog("Replace this orientation with the radial template?", + isPresented: Binding(get: { confirmingTemplate != nil }, + set: { if !$0 { confirmingTemplate = nil } }), + titleVisibility: .visible) { + Button("Use Template", role: .destructive) { + if let corner = confirmingTemplate { + mutate { $0 = .radialTemplate(corner: corner, portrait: editingPortrait) } + } + confirmingTemplate = nil + selectedID = nil + } + } message: { + Text("Only the \(editingPortrait ? String(localized: "portrait") : String(localized: "landscape")) arrangement changes. Shortcut buttons you added are removed from it.") + } + .confirmationDialog("Discard your changes?", isPresented: $confirmingDiscard, titleVisibility: .visible) { + Button("Discard Changes", role: .destructive) { dismiss() } + } } - private func placementFrames(area: CGRect, base: CGFloat) -> [String: CGRect] { - var result: [String: CGRect] = [:] - for placement in arrangement?.placements ?? [] { - var frame = CustomLayoutGeometry.frame(for: placement, in: area, baseDiameter: base) - if let dragged = dragPositions[placement.id] { - frame = PadEdgeGeometry.clamped(CGRect(x: dragged.x - frame.width / 2, y: dragged.y - frame.height / 2, - width: frame.width, height: frame.height), into: area) + @ToolbarContentBuilder + private var toolbar: some ToolbarContent { + ToolbarItem(placement: .cancellationAction) { + Button("Cancel") { + if draft != stored { confirmingDiscard = true } else { dismiss() } } - result[placement.id] = frame } - return result + ToolbarItem(placement: .principal) { + HStack(spacing: 12) { + Picker("Mode", selection: $previewing) { + Text("Edit").tag(false) + Text("Preview").tag(true) + } + .pickerStyle(.segmented) + .frame(width: 180) + Picker("Orientation", selection: $editingPortrait) { + Label("Landscape", systemImage: "rectangle").tag(false) + Label("Portrait", systemImage: "rectangle.portrait").tag(true) + } + .pickerStyle(.segmented) + .frame(width: 200) + InfoButton(title: "Landscape & Portrait", text: HelpText.orientations) + } + } + ToolbarItemGroup(placement: .primaryAction) { + if !previewing { + addMenu + Menu { + Picker("Alignment Guides", selection: $guideStyle) { + ForEach(AlignmentGuideStyle.allCases) { Text($0.title).tag($0) } + } + Toggle("Snap to Edges", isOn: $snapsToEdges) + Toggle("Snap to Guides", isOn: $snapsToGuides) + } label: { + Label("Guides", systemImage: "grid") + } + Menu { + Section("Radial Template") { + ForEach(ControlCorner.allCases) { corner in + Button(corner.title) { confirmingTemplate = corner } + } + } + } label: { + Label("Template", systemImage: "circle.grid.cross") + } + } + Button { showingHelp = true } label: { Label("Help", systemImage: "questionmark.circle") } + Button("Done") { + if let draft { store.update { $0.updateCustomLayout(draft) } } + dismiss() + } + .fontWeight(.semibold) + } } - private func alignmentGuides(area: CGRect) -> some View { - ZStack(alignment: .topLeading) { - // The layout area: controls can't leave it, so nothing ends up - // under the Home indicator or the screen's rounded corners. - Rectangle() - .strokeBorder(style: StrokeStyle(lineWidth: 1.5, dash: [8, 6])) - .foregroundStyle(.white.opacity(0.35)) - .frame(width: area.width, height: area.height) - .position(x: area.midX, y: area.midY) - Path { path in - path.move(to: CGPoint(x: area.midX, y: area.minY)) - path.addLine(to: CGPoint(x: area.midX, y: area.maxY)) - path.move(to: CGPoint(x: area.minX, y: area.midY)) - path.addLine(to: CGPoint(x: area.maxX, y: area.midY)) + // MARK: Canvas + + private var canvas: some View { + CustomLayoutCanvas( + arrangement: arrangement ?? CustomControlArrangement(corner: .bottomTrailing, placements: []), + portrait: editingPortrait, + previewing: previewing, + guideStyle: previewing ? .off : guideStyle, + twoHandAssist: EditorTwoHandPresentation.state(previewing: previewing, + showingAssistPreview: showingAssistPreview, + enabled: draft?.twoHandAssist ?? false, + interaction: session.interaction), + assistItems: (draft?.assistActionIDs ?? []).compactMap { id in functionItems.first { $0.id == id } }, + metrics: PadControlMetrics(scale: store.preferences.padControlScale), + selectedID: selectedID, + dragPositions: dragPositions, + session: session, + previewAnchor: previewAnchor, + functionItems: functionItems, + paletteActions: { store.activeProfile.actions(for: $0) }, + onSelect: { selectedID = $0 }, + onDrag: { id, point in dragPositions[id] = point }, + onDrop: { id, point, area, diameter in + dragPositions[id] = nil + let position = CustomLayoutGeometry.droppedPosition(for: point, in: area, diameter: diameter, + snapToEdges: snapsToEdges, + snapToGuides: snapsToGuides && guideStyle != .off) + updatePlacement(id) { $0.x = position.x; $0.y = position.y } + }, + onPreviewModifier: { modifier in + previewAnchor = modifier + session.tapModifier(modifier) + }, + onPreviewAssist: { session.tapAssistModifier($0) }, + onPreviewPalette: { session.tapPaletteAction($0) }, + onPreviewAction: { session.tapAction($0) }) + .overlay(alignment: .top) { + if !tipSeen && !previewing { + firstRunTip.padding(12) } - .stroke(.white.opacity(0.15), lineWidth: 1) } - .allowsHitTesting(false) } - @ViewBuilder - private func editorChip(_ placement: CustomControlPlacement, frame: CGRect, area: CGRect) -> some View { - let isSelected = placement.id == selectedID - ZStack { - Circle() - .fill(Color.black.opacity(0.5)) - .overlay(Circle().strokeBorder(isSelected ? Color.accentColor : .white.opacity(0.35), - lineWidth: isSelected ? 3 : 1)) - controlGlyph(placement.kind, diameter: frame.width) - .foregroundStyle(.white) + private var firstRunTip: some View { + HStack(alignment: .top, spacing: 12) { + Image(systemName: "hand.draw").font(.title2).foregroundStyle(.tint) + VStack(alignment: .leading, spacing: 4) { + Text("Arrange your controls").font(.headline) + Text("Drag a control to move it, and tap it to change its size or shortcut palette. Use Preview to try the layout — it never sends anything to your Mac.") + .font(.subheadline) + .foregroundStyle(.secondary) + } + Spacer(minLength: 0) + Button("Got It") { withAnimation { tipSeen = true } } + .buttonStyle(.borderedProminent) } - .frame(width: frame.width, height: frame.height) - .position(x: frame.midX, y: frame.midY) - .gesture(DragGesture(minimumDistance: 2) - .onChanged { value in - selectedID = placement.id - dragPositions[placement.id] = value.location - } - .onEnded { value in - dragPositions[placement.id] = nil - let point = CustomLayoutGeometry.normalizedPoint(for: value.location, in: area) - updatePlacement(placement.id) { - $0.x = CustomLayoutGeometry.snapped(point.x) - $0.y = CustomLayoutGeometry.snapped(point.y) - } - }) - .onTapGesture { selectedID = isSelected ? nil : placement.id } - .accessibilityLabel(title(for: placement.kind)) - .accessibilityAddTraits(isSelected ? [.isSelected, .isButton] : .isButton) - } - - private func palettePreview(modifier: ControlModifier, anchor: CGRect, style: PalettePresentation, - area: CGRect, base: CGFloat, obstacles: [CGRect]) -> some View { - let actions = store.activeProfile.actions(for: ModifierChord([modifier])) - let diameter = CustomLayoutGeometry.paletteKeyDiameter(baseDiameter: base) - let centers = CustomLayoutGeometry.paletteCenters(count: actions.count, anchor: anchor, style: style, - area: area, baseDiameter: base, spacing: 8, - obstacles: obstacles) - return ForEach(Array(zip(actions, centers)), id: \.0.id) { action, center in - Text(action.displayKey) - .font(.system(size: 15, weight: .semibold)) - .foregroundStyle(.white) - .frame(width: diameter, height: diameter) - .background(Circle().fill(Color.accentColor.opacity(0.55))) - .position(center) - .allowsHitTesting(false) + .padding(14) + .frame(maxWidth: 560) + .background(.regularMaterial, in: RoundedRectangle(cornerRadius: 14, style: .continuous)) + .shadow(color: .black.opacity(0.15), radius: 10, y: 3) + } + + // MARK: Panels + + @ViewBuilder + private var inspectorPanel: some View { + if let selected { + Form { inspector(for: selected) } + } else { + layoutPanel } } - // MARK: Inspector + private var layoutPanel: some View { + Form { + if let draft { + Section { + Text("Tap a control on the canvas to edit it, or drag it to move it.") + .font(.footnote).foregroundStyle(.secondary) + } + Section { + Toggle("Use Two-Hand Assist", isOn: Binding(get: { draft.twoHandAssist }, + set: { value in self.draft?.twoHandAssist = value })) + Toggle("Preview Two-Hand Assist", isOn: $showingAssistPreview) + .disabled(!draft.twoHandAssist) + } header: { + HStack(spacing: 6) { + Text("Two-Hand Assist") + InfoButton(title: "Two-Hand Assist", text: HelpText.twoHandAssist) + } + } footer: { + Text("While you arrange controls, Two-Hand Assist stays still. Turn on Preview Two-Hand Assist to see where its helper keys appear, or use Preview to try it for real.") + } + Section { + Picker("Actions From", selection: Binding( + get: { draft.functionProfile }, + set: { value in self.draft?.functionProfile = value })) { + Text("Active Function Profile").tag(ControlProfileSlot?.none) + ForEach(ControlProfileSlot.allCases) { Text($0.title).tag(ControlProfileSlot?.some($0)) } + } + NavigationLink("Edit Function Tray…") { + FunctionTrayEditor(store: store, + slot: draft.functionProfile ?? store.preferences.activeFunctionTrayProfile) + } + } header: { + Text("Function Tray") + } footer: { + Text("This layout decides where Function controls go; the Function Tray profile decides what they do. Add them with Add Control → Actions.") + } + Section { + Picker(selection: $guideStyle) { + ForEach(AlignmentGuideStyle.allCases) { Text($0.title).tag($0) } + } label: { + HStack(spacing: 6) { + Text("Alignment Guides") + InfoButton(title: "Alignment Guides", text: HelpText.alignmentGuides) + } + } + Toggle(isOn: $snapsToEdges) { + HStack(spacing: 6) { + Text("Snap to Edges") + InfoButton(title: "Snap to Edges", text: HelpText.snapToEdges) + } + } + Toggle(isOn: $snapsToGuides) { + HStack(spacing: 6) { + Text("Snap to Guides") + InfoButton(title: "Snap to Guides", text: HelpText.snapToGuides) + } + } + .disabled(guideStyle == .off) + } header: { + Text("Guides") + } footer: { + Text(HelpText.systemArea) + } + } + } + } @ViewBuilder private func inspector(for placement: CustomControlPlacement) -> some View { - VStack(alignment: .leading, spacing: 14) { + Section { HStack { - Label { - Text(title(for: placement.kind)).font(.headline) - } icon: { - controlGlyph(placement.kind, diameter: 28) - } + CustomControlGlyph(kind: placement.kind, diameter: 30, functionItems: functionItems) + .frame(width: 36, height: 36) + .background(PadChip(selected: false, onRail: false)) + Text(title(for: placement.kind)).font(.headline) Spacer() - Button(role: .destructive) { - mutate { $0.placements.removeAll { $0.id == placement.id } } - selectedID = nil - } label: { - Label("Remove", systemImage: "trash") - } - .disabled(placement.kind == .tray(.settings)) + Button("Done") { selectedID = nil } + .buttonStyle(.borderless) } VStack(alignment: .leading, spacing: 4) { Text("Size") Slider(value: Binding(get: { placement.size }, set: { value in updatePlacement(placement.id) { $0.size = value } }), - in: CustomControlPlacement.sizeRange, step: 0.05) + in: CustomControlPlacement.sizeRange, step: 0.05) { + Text("Size") + } minimumValueLabel: { + Image(systemName: "circle.fill").font(.system(size: 8)).accessibilityHidden(true) + } maximumValueLabel: { + Image(systemName: "circle.fill").font(.system(size: 16)).accessibilityHidden(true) + } } - if placement.kind.modifier != nil { - let style = placement.palette ?? PalettePresentation() - Picker("Palette Shape", selection: Binding( - get: { style.shape }, - set: { shape in updatePlacement(placement.id) { $0.palette = paletteStyle($0, shape: shape) } })) { - ForEach(PaletteShape.allCases) { Text($0.title).tag($0) } + if case .shortcut(let item) = placement.kind { + Button { + editingShortcut = ShortcutEditorRequest(placementID: placement.id, item: item) + } label: { + Label("Edit Shortcut…", systemImage: "keyboard") } - .pickerStyle(.segmented) - VStack(alignment: .leading, spacing: 4) { - Text("Palette Distance") - Slider(value: Binding(get: { style.spacing }, - set: { value in updatePlacement(placement.id) { $0.palette = paletteStyle($0, spacing: value) } }), - in: PalettePresentation.spacingRange, step: 0.1) - } - Toggle("Bloom Toward Screen Center", isOn: Binding( - get: { style.directionDegrees == nil }, - set: { automatic in - updatePlacement(placement.id) { - $0.palette = paletteStyle($0, direction: automatic ? .some(nil) : .some(-90)) + } + } + if placement.kind.modifier != nil { + paletteSection(for: placement) + } + Section { + Button(role: .destructive) { + mutate { $0.placements.removeAll { $0.id == placement.id } } + selectedID = nil + } label: { + Label("Remove from Layout", systemImage: "trash") + } + .disabled(placement.kind == .tray(.settings)) + } footer: { + if placement.kind == .tray(.settings) { + Text("Settings always stays in the layout, so it can never become unreachable.") + } + } + } + + @ViewBuilder + private func paletteSection(for placement: CustomControlPlacement) -> some View { + let style = placement.palette ?? PalettePresentation() + Section { + HStack(spacing: 8) { + ForEach(PaletteShape.allCases) { shape in + Button { + updatePlacement(placement.id) { $0.palette = restyled($0, shape: shape) } + } label: { + VStack(spacing: 4) { + PaletteShapeThumbnail(shape: shape) + .frame(width: 44, height: 44) + Text(shape.title).font(.caption) } - })) - if let degrees = style.directionDegrees { - VStack(alignment: .leading, spacing: 4) { - Text("Direction") - Slider(value: Binding(get: { degrees }, - set: { value in updatePlacement(placement.id) { $0.palette = paletteStyle($0, direction: .some(value)) } }), - in: -180...180, step: 15) + .padding(6) + .frame(maxWidth: .infinity) + .background(RoundedRectangle(cornerRadius: 10) + .fill(style.shape == shape ? Color.accentColor.opacity(0.18) : Color.clear)) + .overlay(RoundedRectangle(cornerRadius: 10) + .strokeBorder(style.shape == shape ? Color.accentColor : Color.secondary.opacity(0.3))) } + .buttonStyle(.plain) + .accessibilityAddTraits(style.shape == shape ? .isSelected : []) + } + } + Text(shapeDescription(style.shape)).font(.footnote).foregroundStyle(.secondary) + VStack(alignment: .leading, spacing: 4) { + HStack(spacing: 6) { + Text("Palette Distance") + InfoButton(title: "Palette Distance", text: HelpText.paletteDistance) + } + Slider(value: Binding(get: { style.spacing }, + set: { value in updatePlacement(placement.id) { $0.palette = restyled($0, spacing: value) } }), + in: PalettePresentation.spacingRange, step: 0.1) { + Text("Palette Distance") + } minimumValueLabel: { Text("Close") } maximumValueLabel: { Text("Far") } + .font(.footnote) + } + Toggle(isOn: Binding( + get: { style.directionDegrees == nil }, + set: { automatic in + updatePlacement(placement.id) { $0.palette = restyled($0, direction: automatic ? .some(nil) : .some(-90)) } + })) { + HStack(spacing: 6) { + Text("Toward Screen Center") + InfoButton(title: "Palette Direction", text: HelpText.paletteDirection) + } + } + if let degrees = style.directionDegrees { + VStack(alignment: .leading, spacing: 4) { + Text("Palette Direction") + Slider(value: Binding(get: { degrees }, + set: { value in updatePlacement(placement.id) { $0.palette = restyled($0, direction: .some(value)) } }), + in: -180...180, step: 15) { + Text("Palette Direction") + } minimumValueLabel: { Image(systemName: "arrow.counterclockwise") } maximumValueLabel: { + Image(systemName: "arrow.clockwise") + } + } + } + if let modifier = placement.kind.modifier { + NavigationLink { + ChordPaletteEditor(store: store, chord: ModifierChord([modifier]), haptics: ReceiverHaptics { false }) + } label: { + Label("Edit Palette Actions", systemImage: "square.grid.3x3.topleft.filled") } - Toggle("Preview Palette", isOn: $previewPalette) } + } header: { + HStack(spacing: 6) { + Text("Shortcut Palette") + InfoButton(title: "Palette Style", text: HelpText.paletteStyle) + } + } footer: { + Text("The canvas shows this modifier’s palette while it’s selected. Its actions come from your Control Profile, so the same palette appears everywhere you use this modifier.") } - .padding() - .background(Color(.secondarySystemGroupedBackground), in: RoundedRectangle(cornerRadius: 12)) } - private func paletteStyle(_ placement: CustomControlPlacement, shape: PaletteShape? = nil, - spacing: Double? = nil, direction: Double?? = nil) -> PalettePresentation { + private func shapeDescription(_ shape: PaletteShape) -> String { + switch shape { + case .wheel: return String(localized: "Wheel — a ring of wedge-shaped segments around the modifier.") + case .arc: return String(localized: "Arc — shortcuts fan outward along part of a circle.") + case .ring: return String(localized: "Ring — shortcuts surround the modifier.") + case .row: return String(localized: "Row — shortcuts appear in a horizontal line.") + case .column: return String(localized: "Column — shortcuts appear in a vertical line.") + } + } + + private func restyled(_ placement: CustomControlPlacement, shape: PaletteShape? = nil, + spacing: Double? = nil, direction: Double?? = nil) -> PalettePresentation { var style = placement.palette ?? PalettePresentation() if let shape { style.shape = shape } if let spacing { style.spacing = spacing } @@ -361,51 +829,35 @@ struct CustomLayoutEditor: View { return style } - @ViewBuilder - private func layoutOptions(_ layout: CustomControlLayout) -> some View { - VStack(alignment: .leading, spacing: 14) { - TextField("Layout Name", text: $name) - .textFieldStyle(.roundedBorder) - .onSubmit { store.update { $0.renameCustomLayout(layoutID, to: name) } } - Toggle(isOn: Binding(get: { layout.twoHandAssist }, - set: { value in mutateLayout { $0.twoHandAssist = value } })) { - VStack(alignment: .leading, spacing: 2) { - Text("Two-Hand Assist") - Text("While a modifier is held or latched, modifier keys appear on the opposite side, so your other hand can add to the same chord. Otherwise that side offers Undo, Redo and Zoom.") - .font(.footnote) - .foregroundStyle(.secondary) - } - } - Menu { - ForEach(ControlCorner.allCases) { corner in - Button(corner.title) { confirmingTemplate = corner } - } - } label: { - Label("Reset to Radial Template…", systemImage: "circle.grid.cross") - } - } - .padding() - .background(Color(.secondarySystemGroupedBackground), in: RoundedRectangle(cornerRadius: 12)) - .onDisappear { store.update { $0.renameCustomLayout(layoutID, to: name) } } - } - // MARK: Adding controls private var addMenu: some View { Menu { let placed = Set(arrangement?.placements.map(\.kind) ?? []) - Section("Controls") { - ForEach(ControlTrayItem.allCases) { item in - Button(item.title) { add(.tray(item)) } - .disabled(placed.contains(.tray(item))) + Button { + editingShortcut = ShortcutEditorRequest(placementID: nil, item: nil) + } label: { + Label("Keyboard Shortcut…", systemImage: "keyboard") + } + Section("Modifiers") { + ForEach([ControlTrayItem.command, .option, .control, .shift]) { item in + Button(item.title) { add(.tray(item)) }.disabled(placed.contains(.tray(item))) } - Button("Move View") { add(.moveView) } - .disabled(placed.contains(.moveView)) } - Section("Function Tray") { - ForEach(store.activeFunctionTrayProfile.items.map(\.item)) { item in - Button(item.title) { add(.function(item.id)) } - .disabled(placed.contains(.function(item.id))) + Section("System") { + ForEach(CustomControlArrangement.templateSystem, id: \.self) { kind in + Button(title(for: kind)) { add(kind) }.disabled(placed.contains(kind)) + } + } + Section("Keys & Utilities") { + ForEach([ControlTrayItem.escape, .tab, .keyboard, .dock]) { item in + Button(item.title) { add(.tray(item)) }.disabled(placed.contains(.tray(item))) + } + Button("Move View") { add(.moveView) }.disabled(placed.contains(.moveView)) + } + Section("Actions") { + ForEach(functionItems) { item in + Button(item.title) { add(.function(item.id)) }.disabled(placed.contains(.function(item.id))) } } } label: { @@ -421,52 +873,23 @@ struct CustomLayoutEditor: View { // MARK: Helpers - @ViewBuilder - private func controlGlyph(_ kind: CustomControlKind, diameter: CGFloat) -> some View { - switch kind { - case .tray(let item): - if let modifier = item.modifier { - Text(modifier.symbol).font(.system(size: diameter * 0.46, weight: .semibold)) - } else if item == .escape || item == .tab { - Text(item.displayLabel).font(.system(size: diameter * 0.3, weight: .semibold)) - } else { - Image(systemName: item.displayLabel).font(.system(size: diameter * 0.4, weight: .semibold)) - } - case .moveView: - Image(systemName: "arrow.up.and.down.and.arrow.left.and.right") - .font(.system(size: diameter * 0.38, weight: .semibold)) - case .function(let id): - let item = store.activeFunctionTrayProfile.items.first { $0.id == id }?.item - if let symbol = item?.systemImage { - Image(systemName: symbol).font(.system(size: diameter * 0.4, weight: .semibold)) - } else { - Text(item?.displayKey ?? "?").font(.system(size: diameter * 0.34, weight: .semibold)) - } - } - } + private var functionItems: [ShortcutItem] { store.functionItems(for: draft) } private func title(for kind: CustomControlKind) -> String { switch kind { case .tray(let item): return item.title case .moveView: return String(localized: "Move View") - case .function(let id): - return store.activeFunctionTrayProfile.items.first { $0.id == id }?.item.title ?? id + case .function(let id): return functionItems.first { $0.id == id }?.title ?? id + case .shortcut(let item): return item.title } } - private func mutateLayout(_ change: (inout CustomControlLayout) -> Void) { - guard var layout else { return } - change(&layout) - store.update { $0.updateCustomLayout(layout) } - } - private func mutate(_ change: (inout CustomControlArrangement) -> Void) { - let portrait = editingPortrait - mutateLayout { layout in - var arrangement = layout.arrangement(portrait: portrait) - change(&arrangement) - layout.setArrangement(arrangement, portrait: portrait) - } + guard var layout = draft else { return } + var arrangement = layout.arrangement(portrait: editingPortrait) + change(&arrangement) + layout.setArrangement(arrangement, portrait: editingPortrait) + draft = layout } private func updatePlacement(_ id: String, _ change: (inout CustomControlPlacement) -> Void) { @@ -481,3 +904,488 @@ struct CustomLayoutEditor: View { } } } + +/// The device's full-screen size and safe insets, for the editor canvas. +private enum EditorDevice { + @MainActor static var screenBounds: CGRect { + let scene = UIApplication.shared.connectedScenes.compactMap { $0 as? UIWindowScene }.first + return scene?.screen.bounds ?? CGRect(x: 0, y: 0, width: 1194, height: 834) + } + + @MainActor static func size(portrait: Bool) -> CGSize { + let bounds = screenBounds + let long = max(bounds.width, bounds.height) + let short = min(bounds.width, bounds.height) + return portrait ? CGSize(width: short, height: long) : CGSize(width: long, height: short) + } + + /// The system areas the receiver surface keeps controls out of: a thin + /// top inset and the Home indicator (iPad has no side insets). + @MainActor static var safeInsets: ControlSafeInsets { + let window = UIApplication.shared.connectedScenes.compactMap { $0 as? UIWindowScene } + .flatMap(\.windows).first(where: \.isKeyWindow) + let insets = window?.safeAreaInsets ?? UIEdgeInsets(top: 24, left: 0, bottom: 20, right: 0) + return ControlSafeInsets(top: insets.top, leading: 0, bottom: max(insets.bottom, 20), trailing: 0) + } +} + +// MARK: - Canvas + +/// The iPad surface at scale: the same geometry as the live overlay +/// (`CustomLayoutGeometry.frames`, `palettePoints`, `TwoHandAssist`), +/// drawn with editor guides in Edit and exactly as at runtime in Preview. +private struct CustomLayoutCanvas: View { + let arrangement: CustomControlArrangement + let portrait: Bool + let previewing: Bool + let guideStyle: AlignmentGuideStyle + /// Already resolved for Edit vs Preview — see `EditorTwoHandPresentation`. + let twoHandAssist: TwoHandAssistState + let assistItems: [ShortcutItem] + let metrics: PadControlMetrics + let selectedID: String? + let dragPositions: [String: CGPoint] + let session: CustomLayoutPreviewSession + let previewAnchor: ControlModifier? + let functionItems: [ShortcutItem] + let paletteActions: (ModifierChord) -> [ShortcutItem] + let onSelect: (String?) -> Void + let onDrag: (String, CGPoint) -> Void + let onDrop: (String, CGPoint, CGRect, CGFloat) -> Void + let onPreviewModifier: (ControlModifier) -> Void + let onPreviewAssist: (ControlModifier) -> Void + let onPreviewPalette: (ShortcutItem) -> Void + let onPreviewAction: (ShortcutItem) -> Void + + var body: some View { + let device = EditorDevice.size(portrait: portrait) + let container = CGRect(origin: .zero, size: device) + let safe = EditorDevice.safeInsets + let area = CustomLayoutGeometry.layoutArea(container: container, safeInsets: safe) + GeometryReader { proxy in + let scale = min((proxy.size.width - 32) / device.width, (proxy.size.height - 32) / device.height) + let origin = CGPoint(x: (proxy.size.width - device.width * scale) / 2, + y: (proxy.size.height - device.height * scale) / 2) + let frames = liveFrames(area: area) + ZStack(alignment: .topLeading) { + surface(device: device, safe: safe, area: area, scale: scale) + .contentShape(Rectangle()) + .onTapGesture { if !previewing { onSelect(nil) } } + if previewing { + previewLayer(frames: frames, area: area, scale: scale) + } else { + editLayer(frames: frames, area: area, scale: scale) + } + } + .frame(width: device.width * scale, height: device.height * scale, alignment: .topLeading) + .clipShape(RoundedRectangle(cornerRadius: 22 * scale + 6, style: .continuous)) + .overlay(RoundedRectangle(cornerRadius: 22 * scale + 6, style: .continuous) + .strokeBorder(Color.primary.opacity(0.25), lineWidth: 1)) + .offset(x: origin.x, y: origin.y) + } + .accessibilityElement(children: .contain) + .accessibilityLabel(previewing ? Text("Layout preview") : Text("Layout editor canvas")) + } + + private func liveFrames(area: CGRect) -> [String: CGRect] { + var frames = CustomLayoutGeometry.frames(for: arrangement, in: area, baseDiameter: metrics.item) + for (id, point) in dragPositions { + guard let frame = frames[id] else { continue } + frames[id] = PadEdgeGeometry.clamped(CGRect(x: point.x - frame.width / 2, y: point.y - frame.height / 2, + width: frame.width, height: frame.height), into: area) + } + return frames + } + + // MARK: Surface + + private func surface(device: CGSize, safe: ControlSafeInsets, area: CGRect, scale: CGFloat) -> some View { + ZStack(alignment: .topLeading) { + // A calm neutral surface, like the trackpad's — the iPad screen, + // not any particular Mac content. + Color(white: 0.17) + if !previewing { + // System areas the layout keeps out of. + Rectangle().fill(Color.black.opacity(0.28)) + .frame(width: device.width * scale, height: safe.top * scale) + Rectangle().fill(Color.black.opacity(0.28)) + .frame(width: device.width * scale, height: safe.bottom * scale) + .offset(y: (device.height - safe.bottom) * scale) + if guideStyle != .off { + AlignmentGuides(style: guideStyle, area: area, scale: scale) + } + Rectangle() + .strokeBorder(style: StrokeStyle(lineWidth: 1.5, dash: [8, 6])) + .foregroundStyle(.white.opacity(0.7)) + .frame(width: area.width * scale, height: area.height * scale) + .offset(x: area.minX * scale, y: area.minY * scale) + .allowsHitTesting(false) + } + } + } + + // MARK: Edit + + @ViewBuilder + private func editLayer(frames: [String: CGRect], area: CGRect, scale: CGFloat) -> some View { + // The selected modifier's palette, on the same arc it will use live. + if let selectedID, let placement = arrangement.placements.first(where: { $0.id == selectedID }), + let modifier = placement.kind.modifier, placement.palette?.shape == .wheel, let frame = frames[selectedID] { + let actions = paletteActions(ModifierChord([modifier])) + if let wheel = scaledWheel(count: actions.count, anchor: frame, area: area, scale: scale) { + WheelPaletteView(layout: wheel, actions: actions, selectedID: nil, centerLabel: modifier.symbol) + .opacity(0.85) + } + } else if let selectedID, let placement = arrangement.placements.first(where: { $0.id == selectedID }), + let modifier = placement.kind.modifier { + let actions = paletteActions(ModifierChord([modifier])) + let points = CustomLayoutGeometry.palettePoints(count: actions.count, anchorID: selectedID, + arrangement: arrangement, frames: frames, area: area, + baseDiameter: metrics.item, spacing: metrics.gap) + let key = CustomLayoutGeometry.paletteKeyDiameter(baseDiameter: metrics.item) + ForEach(Array(zip(actions, points)), id: \.0.id) { action, point in + Text(action.displayKey) + .font(.system(size: 14 * scale, weight: .semibold)) + .foregroundStyle(.white) + .frame(width: key * scale, height: key * scale) + .background(Circle().fill(Color.accentColor.opacity(0.7))) + .position(x: point.x * scale, y: point.y * scale) + .allowsHitTesting(false) + } + } + // Two-Hand Assist: dormant while editing; its helper arc shows only + // when "Preview Two-Hand Assist" is on, never while dragging. + if case .helper(let active) = twoHandAssist { + let helpers = TwoHandAssist.helperPoints(count: TwoHandAssist.helperModifiers.count, arrangement: arrangement, + frames: frames, area: area, itemDiameter: metrics.item, + spacing: metrics.gap) + ForEach(Array(zip(TwoHandAssist.helperModifiers, helpers)), id: \.0) { modifier, point in + Text(modifier.symbol) + .font(.system(size: metrics.item * 0.46 * scale, weight: .semibold)) + .foregroundStyle(.white.opacity(active.contains(modifier) ? 1 : 0.75)) + .frame(width: metrics.item * scale, height: metrics.item * scale) + .overlay(Circle().strokeBorder(style: StrokeStyle(lineWidth: 1.5, dash: [4, 3])) + .foregroundStyle(.white.opacity(0.7))) + .position(x: point.x * scale, y: point.y * scale) + .allowsHitTesting(false) + } + } + ForEach(arrangement.placements) { placement in + if let frame = frames[placement.id] { + let isSelected = placement.id == selectedID + CustomControlGlyph(kind: placement.kind, diameter: frame.width * scale, functionItems: functionItems) + .foregroundStyle(.white) + .frame(width: frame.width * scale, height: frame.height * scale) + .background(PadChip(selected: false, onRail: false)) + .overlay(Circle().strokeBorder(Color.accentColor, lineWidth: isSelected ? 3 : 0)) + .position(x: frame.midX * scale, y: frame.midY * scale) + .gesture(DragGesture(minimumDistance: 2) + .onChanged { value in + onSelect(placement.id) + onDrag(placement.id, CGPoint(x: value.location.x / scale, y: value.location.y / scale)) + } + .onEnded { value in + onDrop(placement.id, CGPoint(x: value.location.x / scale, y: value.location.y / scale), area, + frame.width) + }) + .onTapGesture { onSelect(isSelected ? nil : placement.id) } + .accessibilityLabel(Text(accessibilityTitle(placement.kind))) + .accessibilityAddTraits(isSelected ? [.isSelected, .isButton] : .isButton) + } + } + } + + // MARK: Preview + + @ViewBuilder + private func previewLayer(frames: [String: CGRect], area: CGRect, scale: CGFloat) -> some View { + let interaction = session.interaction + let chord = interaction.paletteChord + let actions = chord.map(paletteActions) ?? [] + let anchor = (previewAnchor.flatMap { interaction.activeChord.contains($0) ? $0 : nil } + ?? ControlModifier.allCases.first(where: interaction.activeChord.contains)) + .flatMap { arrangement.placement(for: $0) } + let points = anchor.map { + CustomLayoutGeometry.palettePoints(count: actions.count, anchorID: $0.id, arrangement: arrangement, + frames: frames, area: area, baseDiameter: metrics.item, + spacing: metrics.gap) + } ?? [] + let key = CustomLayoutGeometry.paletteKeyDiameter(baseDiameter: metrics.item) + let assistState = twoHandAssist + let wheelActions = anchor.flatMap { placement -> WheelPaletteLayout? in + guard placement.palette?.shape == .wheel, let frame = frames[placement.id] else { return nil } + return scaledWheel(count: actions.count, anchor: frame, area: area, scale: scale) + } + let assistCount: Int = { + switch assistState { + case .hidden: return 0 + case .idle: return assistItems.count + case .helper: return TwoHandAssist.helperModifiers.count + } + }() + let assistPoints = TwoHandAssist.helperPoints(count: assistCount, arrangement: arrangement, frames: frames, + area: area, itemDiameter: metrics.item, spacing: metrics.gap) + ForEach(arrangement.placements) { placement in + if let frame = frames[placement.id] { + previewControl(placement.kind, frame: frame, scale: scale, active: interaction.activeChord) + } + } + if let wheel = wheelActions, let chord { + WheelPaletteView(layout: wheel, actions: actions, selectedID: nil, centerLabel: chord.symbols) + Color.clear + .frame(width: wheel.frame.width, height: wheel.frame.height) + .contentShape(Circle()) + .position(wheel.center) + .gesture(SpatialTapGesture().onEnded { value in + let point = CGPoint(x: wheel.frame.minX + value.location.x, y: wheel.frame.minY + value.location.y) + if let index = wheel.segmentIndex(at: point), index < actions.count { onPreviewPalette(actions[index]) } + }) + } + ForEach(Array(zip(actions, wheelActions == nil ? points : [])), id: \.0.id) { action, point in + Text(action.displayKey) + .font(.system(size: 15 * scale, weight: .semibold)) + .foregroundStyle(.white) + .frame(width: key * scale, height: key * scale) + .background(PadChip(selected: false, onRail: false)) + .position(x: point.x * scale, y: point.y * scale) + .onTapGesture { onPreviewPalette(action) } + .transition(.scale(scale: 0.6).combined(with: .opacity)) + } + switch assistState { + case .hidden: + EmptyView() + case .idle: + ForEach(Array(zip(assistItems, assistPoints)), id: \.0.id) { item, point in + previewDisc(scale: scale, selected: false) { + CustomControlGlyph(kind: .function(item.id), diameter: metrics.item * scale, functionItems: functionItems) + } + .position(x: point.x * scale, y: point.y * scale) + .onTapGesture { onPreviewAction(item) } + .transition(.opacity) + } + case .helper(let active): + ForEach(Array(zip(TwoHandAssist.helperModifiers, assistPoints)), id: \.0) { modifier, point in + previewDisc(scale: scale, selected: active.contains(modifier)) { + Text(modifier.symbol).font(.system(size: metrics.item * 0.46 * scale, weight: .semibold)) + } + .position(x: point.x * scale, y: point.y * scale) + .onTapGesture { onPreviewAssist(modifier) } + .transition(.opacity.combined(with: .scale(scale: 0.85))) + } + } + if let feedback = session.feedback { + Text(feedbackText(feedback)) + .font(.system(size: 15, weight: .semibold, design: .rounded)) + .foregroundStyle(.white) + .padding(.horizontal, 14).padding(.vertical, 8) + .background(Capsule().fill(Color.black.opacity(0.6))) + .position(x: area.midX * scale, y: (area.minY + 30) * scale) + .allowsHitTesting(false) + } + } + + @ViewBuilder + private func previewControl(_ kind: CustomControlKind, frame: CGRect, scale: CGFloat, + active: ModifierChord) -> some View { + let selected = kind.modifier.map(active.contains) ?? false + previewDisc(scale: scale, diameter: frame.width, selected: selected) { + CustomControlGlyph(kind: kind, diameter: frame.width * scale, functionItems: functionItems) + } + .position(x: frame.midX * scale, y: frame.midY * scale) + .onTapGesture { + switch kind { + case .tray(let item): + if let modifier = item.modifier { onPreviewModifier(modifier) } + case .function(let id): + if let item = functionItems.first(where: { $0.id == id }) { onPreviewAction(item) } + case .shortcut(let item): + onPreviewAction(item) + case .moveView: + break + } + } + } + + /// A control exactly as it looks live. + private func previewDisc(scale: CGFloat, diameter: CGFloat? = nil, selected: Bool, + @ViewBuilder content: () -> Content) -> some View { + let size = (diameter ?? metrics.item) * scale + return content() + .foregroundStyle(selected ? Color.black : Color.white) + .frame(width: size, height: size) + .background(PadChip(selected: selected, onRail: false)) + } + + /// The Wheel as it will appear live, in the canvas's scaled space. + private func scaledWheel(count: Int, anchor: CGRect, area: CGRect, scale: CGFloat) -> WheelPaletteLayout? { + func scaled(_ rect: CGRect) -> CGRect { + CGRect(x: rect.minX * scale, y: rect.minY * scale, width: rect.width * scale, height: rect.height * scale) + } + return WheelPaletteGeometry.layout(count: count, anchor: scaled(anchor), + keyDiameter: CustomLayoutGeometry.paletteKeyDiameter(baseDiameter: metrics.item) * scale, + spacing: metrics.gap * scale, bounds: scaled(area)) + } + + private func feedbackText(_ feedback: CustomLayoutPreviewSession.Feedback) -> String { + switch feedback { + case .wouldSend(let keys): return String(localized: "Preview · \(keys)") + case .wouldPerform(let action): return String(localized: "Preview · \(action)") + } + } + + private func accessibilityTitle(_ kind: CustomControlKind) -> String { + switch kind { + case .tray(let item): return item.title + case .moveView: return String(localized: "Move View") + case .function(let id): return functionItems.first { $0.id == id }?.title ?? id + case .shortcut(let item): return item.title + } + } +} + +/// Editor-only alignment guides: never part of the layout, never shown at +/// runtime. +enum AlignmentGuideStyle: String, CaseIterable, Identifiable { + case off + case dots + case lines + + var id: String { rawValue } + var title: String { + switch self { + case .off: return String(localized: "Off") + case .dots: return String(localized: "Dots") + case .lines: return String(localized: "Grid Lines") + } + } +} + +private struct AlignmentGuides: View { + let style: AlignmentGuideStyle + let area: CGRect + let scale: CGFloat + private let divisions = 24 + + var body: some View { + ZStack { + switch style { + case .off: + EmptyView() + case .dots: + Path { path in + for column in 0...divisions { + for row in 0...divisions { + let x = (area.minX + CGFloat(column) / CGFloat(divisions) * area.width) * scale + let y = (area.minY + CGFloat(row) / CGFloat(divisions) * area.height) * scale + path.addEllipse(in: CGRect(x: x - 1, y: y - 1, width: 2, height: 2)) + } + } + } + .fill(Color.white.opacity(0.28)) + case .lines: + Path { path in + for index in 0...divisions { + let t = CGFloat(index) / CGFloat(divisions) + let x = (area.minX + t * area.width) * scale + let y = (area.minY + t * area.height) * scale + path.move(to: CGPoint(x: x, y: area.minY * scale)) + path.addLine(to: CGPoint(x: x, y: area.maxY * scale)) + path.move(to: CGPoint(x: area.minX * scale, y: y)) + path.addLine(to: CGPoint(x: area.maxX * scale, y: y)) + } + } + .stroke(Color.white.opacity(0.12), lineWidth: 0.5) + } + // Center lines — also where Snap to Edges settles. + Path { path in + path.move(to: CGPoint(x: area.midX * scale, y: area.minY * scale)) + path.addLine(to: CGPoint(x: area.midX * scale, y: area.maxY * scale)) + path.move(to: CGPoint(x: area.minX * scale, y: area.midY * scale)) + path.addLine(to: CGPoint(x: area.maxX * scale, y: area.midY * scale)) + } + .stroke(Color.white.opacity(0.22), lineWidth: 1) + } + .allowsHitTesting(false) + } +} + +/// A tiny drawing of a palette shape: a modifier and its shortcuts. +private struct PaletteShapeThumbnail: View { + let shape: PaletteShape + + var body: some View { + Canvas { context, size in + let center = CGPoint(x: size.width / 2, y: size.height / 2) + let dot: CGFloat = 7 + let points: [CGPoint] + switch shape { + case .arc: + points = (0..<4).map { index in + let angle = -CGFloat.pi * 0.85 + CGFloat(index) * 0.45 + return CGPoint(x: center.x + 16 * cos(angle), y: center.y + 6 + 16 * sin(angle)) + } + case .ring: + points = (0..<6).map { index in + let angle = CGFloat(index) * .pi / 3 + return CGPoint(x: center.x + 15 * cos(angle), y: center.y + 15 * sin(angle)) + } + case .row: + points = (0..<4).map { CGPoint(x: center.x - 15 + CGFloat($0) * 10, y: center.y - 12) } + case .column: + points = (0..<4).map { CGPoint(x: center.x + 12, y: center.y - 15 + CGFloat($0) * 10) } + case .wheel: + for index in 0..<6 { + let start = CGFloat(index) * .pi / 3 + 0.08 + var wedge = Path() + wedge.addArc(center: center, radius: 18, startAngle: .radians(start), + endAngle: .radians(start + .pi / 3 - 0.16), clockwise: false) + wedge.addArc(center: center, radius: 9, startAngle: .radians(start + .pi / 3 - 0.16), + endAngle: .radians(start), clockwise: true) + wedge.closeSubpath() + context.fill(wedge, with: .color(.accentColor)) + } + points = [] + } + let anchor = shape == .arc ? CGPoint(x: center.x, y: center.y + 6) : center + context.fill(Path(ellipseIn: CGRect(x: anchor.x - 5, y: anchor.y - 5, width: 10, height: 10)), + with: .color(.primary)) + for point in points { + context.fill(Path(ellipseIn: CGRect(x: point.x - dot / 2, y: point.y - dot / 2, width: dot, height: dot)), + with: .color(.accentColor)) + } + } + .accessibilityHidden(true) + } +} + +/// A Custom control's symbol, the same way the live overlay draws it. +struct CustomControlGlyph: View { + let kind: CustomControlKind + let diameter: CGFloat + let functionItems: [ShortcutItem] + + var body: some View { + switch kind { + case .tray(let item): + if let modifier = item.modifier { + Text(modifier.symbol).font(.system(size: diameter * 0.46, weight: .semibold)) + } else if item == .escape || item == .tab { + Text(item.displayLabel).font(.system(size: diameter * 0.3, weight: .semibold)) + } else { + Image(systemName: item.displayLabel).font(.system(size: diameter * 0.4, weight: .semibold)) + } + case .moveView: + Image(systemName: "arrow.up.and.down.and.arrow.left.and.right") + .font(.system(size: diameter * 0.38, weight: .semibold)) + case .function(let id): + itemGlyph(functionItems.first { $0.id == id }) + case .shortcut(let item): + itemGlyph(item) + } + } + + @ViewBuilder + private func itemGlyph(_ item: ShortcutItem?) -> some View { + ShortcutButtonFaceView(face: item?.face ?? .text("?"), diameter: diameter) + } +} diff --git a/iOS/FunctionTrayEditor.swift b/iOS/FunctionTrayEditor.swift new file mode 100644 index 00000000..bb5c1be0 --- /dev/null +++ b/iOS/FunctionTrayEditor.swift @@ -0,0 +1,573 @@ +import SwiftUI +import UIKit + +// MARK: - Function Tray editor + +/// Edits one Function Tray profile: add, remove, reorder, rename, and pick +/// how each button looks. Built-in actions (Undo, Zoom, Menu Bar, …) can be +/// hidden but not deleted; user-made shortcuts and sequences can be edited +/// and deleted. The same profile feeds the Strip, Overlay and — when a +/// Custom layout uses it — Custom controls. +struct FunctionTrayEditor: View { + @ObservedObject var store: ReceiverControlStore + let slot: ControlProfileSlot + @State private var creating: ShortcutEditorRequest? + + private var profile: FunctionTrayProfile { store.preferences.functionTrayProfile(for: slot) } + + var body: some View { + List { + Section { + ForEach(profile.items) { configuration in + let item = configuration.resolvedItem + NavigationLink { + FunctionItemEditor(store: store, slot: slot, itemID: item.id) + } label: { + HStack(spacing: 12) { + ShortcutButtonFaceView(face: item.face, diameter: 30) + .foregroundStyle(.white) + .frame(width: 30, height: 30) + .background(Circle().fill(Color(white: 0.22))) + VStack(alignment: .leading, spacing: 1) { + Text(item.title) + Text(item.keysDescription).font(.caption).foregroundStyle(.secondary).lineLimit(1) + } + Spacer() + if !configuration.isVisible { + Text("Hidden").font(.footnote).foregroundStyle(.secondary) + } + } + } + .swipeActions(edge: .leading) { + Button(configuration.isVisible ? "Hide" : "Show") { + update { profile in + if let index = profile.items.firstIndex(where: { $0.id == item.id }) { + profile.items[index].isVisible.toggle() + } + } + } + .tint(.gray) + } + } + .onMove { source, destination in update { $0.moveItems(from: source, to: destination) } } + .onDelete { offsets in + let ids = offsets.map { profile.items[$0].id } + update { profile in ids.forEach { profile.removeItem(id: $0) } } + } + } footer: { + Text("Swipe left to remove (built-in actions are hidden instead), swipe right to show or hide. Tap an action to rename it or change how it looks.") + } + } + .navigationTitle("Function Tray — \(slot.title)") + .toolbar { + ToolbarItemGroup(placement: .primaryAction) { + addMenu + EditButton() + } + } + .sheet(item: $creating) { _ in + ShortcutButtonEditor(initial: nil) { item in update { $0.addCustomItem(item) } } + } + } + + private var addMenu: some View { + Menu { + Button { creating = ShortcutEditorRequest(placementID: nil, item: nil) } label: { + Label("Shortcut or Sequence…", systemImage: "keyboard") + } + let hidden = profile.items.filter { !$0.isVisible } + if !hidden.isEmpty { + Section("Show a Built-in Action") { + ForEach(hidden) { configuration in + Button(configuration.resolvedItem.title) { + update { profile in + if let index = profile.items.firstIndex(where: { $0.id == configuration.id }) { + profile.items[index].isVisible = true + } + } + } + } + } + } + } label: { + Label("Add Action", systemImage: "plus") + } + } + + private func update(_ change: (inout FunctionTrayProfile) -> Void) { + var profile = self.profile + change(&profile) + store.update { $0.updateFunctionTrayProfile(profile) } + } +} + +/// One Function Tray action: its name, its face, and — for your own +/// actions — what it sends. +private struct FunctionItemEditor: View { + @ObservedObject var store: ReceiverControlStore + let slot: ControlProfileSlot + let itemID: String + @State private var name = "" + @State private var editing: ShortcutEditorRequest? + + private var configuration: FunctionTrayItemConfiguration? { + store.preferences.functionTrayProfile(for: slot).items.first { $0.id == itemID } + } + + var body: some View { + Form { + if let configuration { + let item = configuration.resolvedItem + Section { + HStack { + Spacer() + ShortcutButtonFaceView(face: item.face, diameter: 56) + .foregroundStyle(.white) + .frame(width: 56, height: 56) + .background(Circle().fill(Color(white: 0.2))) + Spacer() + } + TextField("Name", text: $name) + .onSubmit { update { $0.rename(id: itemID, to: name) } } + LabeledContent("Sends", value: item.keysDescription) + } + Section("Button Face") { + ButtonFacePicker(display: Binding( + get: { item.display }, + set: { value in update { $0.setDisplay(id: itemID, value) } })) + } + if !FunctionTrayProfile.isBuiltIn(itemID) { + Section { + Button("Edit Keys or Steps…") { + editing = ShortcutEditorRequest(placementID: itemID, item: configuration.item) + } + } + } + Section { + Button(FunctionTrayProfile.isBuiltIn(itemID) ? "Hide from Function Tray" : "Delete Action", + role: .destructive) { + update { $0.removeItem(id: itemID) } + } + } + } + } + .navigationTitle(configuration?.resolvedItem.title ?? "") + .onAppear { name = configuration?.resolvedItem.title ?? "" } + .onDisappear { update { $0.rename(id: itemID, to: name) } } + .sheet(item: $editing) { request in + ShortcutButtonEditor(initial: request.item) { item in update { $0.updateCustomItem(item) } } + } + } + + private func update(_ change: (inout FunctionTrayProfile) -> Void) { + var profile = store.preferences.functionTrayProfile(for: slot) + change(&profile) + store.update { $0.updateFunctionTrayProfile(profile) } + } +} + +// MARK: - Button face + +/// Choose how a one-tap button looks: symbol, short text, emoji, its keys, +/// or its default. The action it performs is unaffected. +struct ButtonFacePicker: View { + @Binding var display: ShortcutButtonDisplay? + + private enum Style: String, CaseIterable, Identifiable { + case automatic, symbol, text, emoji, keys + var id: String { rawValue } + var title: String { + switch self { + case .automatic: return String(localized: "Default") + case .symbol: return String(localized: "Symbol") + case .text: return String(localized: "Text") + case .emoji: return String(localized: "Emoji") + case .keys: return String(localized: "Keys") + } + } + } + + static let symbols = ["star", "bolt", "paintbrush", "pencil", "scissors", "textformat", "underline", "bold", + "italic", "folder", "magnifyingglass", "play", "arrow.clockwise", "camera", + "square.and.arrow.down", "wand.and.stars", "command", "keyboard", "hammer", "sparkles"] + + private var style: Style { + switch display { + case .none: return .automatic + case .symbol: return .symbol + case .text: return .text + case .emoji: return .emoji + case .keys: return .keys + } + } + + var body: some View { + Picker("Style", selection: Binding(get: { style }, set: { newStyle in + switch newStyle { + case .automatic: display = nil + case .symbol: display = .symbol(Self.symbols[0]) + case .text: display = .text("") + case .emoji: display = .emoji("⭐️") + case .keys: display = .keys + } + })) { + ForEach(Style.allCases) { Text($0.title).tag($0) } + } + .pickerStyle(.segmented) + switch display { + case .symbol(let name): + LazyVGrid(columns: Array(repeating: GridItem(.flexible()), count: 5), spacing: 10) { + ForEach(Self.symbols, id: \.self) { symbol in + Button { display = .symbol(symbol) } label: { + Image(systemName: symbol) + .frame(width: 40, height: 40) + .background(Circle().fill(symbol == name ? Color.accentColor.opacity(0.25) : Color.clear)) + } + .buttonStyle(.plain) + .accessibilityLabel(symbol) + } + } + case .text(let text): + TextField("Up to \(ShortcutButtonDisplay.maximumTextLength) characters", text: Binding( + get: { text }, + set: { display = .text(String($0.prefix(ShortcutButtonDisplay.maximumTextLength))) })) + case .emoji(let emoji): + TextField("Emoji", text: Binding(get: { emoji }, set: { display = .emoji(String($0.prefix(2))) })) + case .keys, .none: + EmptyView() + } + } +} + +// MARK: - Shortcut / sequence editor + +struct ShortcutEditorRequest: Identifiable { + let placementID: String? + let item: ShortcutItem? + var id: String { placementID ?? "new" } +} + +/// Makes or edits a one-tap button: one keyboard chord (Control+U, ⌘⇧P, +/// K+3) or a short sequence of existing keyboard and system actions, plus +/// its name and face. Only the existing keyboard/semantic actions — never +/// scripts — and at most `ControlAction.maximumSequenceSteps` steps. +struct ShortcutButtonEditor: View { + let initial: ShortcutItem? + /// Modifiers a new chord starts with (a palette's own chord). + var defaultModifiers: Set = [] + let onSave: (ShortcutItem) -> Void + @Environment(\.dismiss) private var dismiss + + private enum Kind: String, CaseIterable, Identifiable { + case shortcut, sequence + var id: String { rawValue } + } + + @State private var kind = Kind.shortcut + @State private var chord = ChordDraft() + @State private var steps: [StepDraft] = [StepDraft()] + @State private var title = "" + @State private var display: ShortcutButtonDisplay? + @State private var recording = false + + var body: some View { + NavigationStack { + Form { + Section { + HStack { + Spacer() + ShortcutButtonFaceView(face: item.face, diameter: 56) + .foregroundStyle(.white) + .frame(width: 56, height: 56) + .background(Circle().fill(Color(white: 0.2))) + Spacer() + } + LabeledContent("Sends", value: item.keysDescription).font(.body.monospaced()) + Picker("Type", selection: $kind) { + Text("Keyboard Shortcut").tag(Kind.shortcut) + Text("Sequence").tag(Kind.sequence) + } + .pickerStyle(.segmented) + } + switch kind { + case .shortcut: + Section { + Button { + recording.toggle() + } label: { + Label(recording ? "Press Keys Now…" : "Record from Keyboard", systemImage: "record.circle") + } + if recording { + ShortcutRecorder { modifiers, keys in + chord = ChordDraft(modifiers: modifiers, keys: Array(keys.prefix(KeyboardShortcut.maximumKeys))) + recording = false + } + .frame(height: 1) + Text("Press the combination on a hardware keyboard.").font(.footnote).foregroundStyle(.secondary) + } + } footer: { + Text("Or choose the keys below. Keys are pressed together, then released.") + } + ChordFields(chord: $chord) + case .sequence: + Section { + ForEach($steps) { $step in + StepRow(step: $step) + } + .onDelete { offsets in if steps.count > 1 { steps.remove(atOffsets: offsets) } } + .onMove { steps.move(fromOffsets: $0, toOffset: $1) } + if steps.count < ControlAction.maximumSequenceSteps { + Button("Add Step") { steps.append(StepDraft()) } + } + } header: { + Text("Steps") + } footer: { + Text("Steps run in order, each after the pause before it. Up to \(ControlAction.maximumSequenceSteps) steps.") + } + } + Section("Button") { + TextField("Name", text: $title) + ButtonFacePicker(display: $display) + } + } + .navigationTitle(initial == nil ? "New Button" : "Edit Button") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { Button("Cancel") { dismiss() } } + ToolbarItem(placement: .confirmationAction) { + Button("Save") { + onSave(item) + dismiss() + } + .disabled(!action.isValid) + } + } + .onAppear(perform: load) + } + } + + private var action: ControlAction { + switch kind { + case .shortcut: return .keyboardShortcut(chord.shortcut) + case .sequence: return .sequence(steps.map(\.step)) + } + } + + private var item: ShortcutItem { + var item = ShortcutItem(id: initial?.id ?? UUID().uuidString, title: "", displayKey: "", usage: 4, + modifiers: ModifierChord()) + item.action = action + let keys = item.keysDescription + item.displayKey = kind == .shortcut ? keys : "⋯" + item.title = title.trimmingCharacters(in: .whitespaces).isEmpty ? keys : title + item.display = display + item.systemImage = nil + return item + } + + private func load() { + guard let initial else { + if !defaultModifiers.isEmpty { chord = ChordDraft(modifiers: defaultModifiers, keys: [6]) } + return + } + title = initial.title + display = initial.display ?? initial.systemImage.map(ShortcutButtonDisplay.symbol) + switch initial.action { + case .keyboardShortcut(let shortcut): + kind = .shortcut + chord = ChordDraft(shortcut) + case .sequence(let existing): + kind = .sequence + steps = existing.map(StepDraft.init) + case .receiverGesture: + kind = .sequence + steps = [StepDraft(ControlActionStep(action: initial.action))] + } + } +} + +private struct ChordDraft: Equatable { + var modifiers: Set = [] + var keys: [Int] = [24] + + init(modifiers: Set = [], keys: [Int] = [24]) { + self.modifiers = modifiers + self.keys = keys.isEmpty ? [24] : keys + } + + init(_ shortcut: KeyboardShortcut) { + self.init(modifiers: shortcut.modifiers.modifiers, keys: shortcut.usages) + } + + var shortcut: KeyboardShortcut { + KeyboardShortcut(usage: keys[0], modifiers: ModifierChord(modifiers), additionalUsages: Array(keys.dropFirst())) + } +} + +private struct ChordFields: View { + @Binding var chord: ChordDraft + + var body: some View { + Section("Modifiers") { + ForEach(ControlModifier.allCases) { modifier in + Toggle("\(modifier.symbol) \(modifier.title)", isOn: Binding( + get: { chord.modifiers.contains(modifier) }, + set: { on in if on { chord.modifiers.insert(modifier) } else { chord.modifiers.remove(modifier) } })) + } + } + Section { + ForEach(chord.keys.indices, id: \.self) { index in + Picker(chord.keys.count > 1 ? "Key \(index + 1)" : "Key", selection: Binding( + get: { chord.keys[index] }, set: { chord.keys[index] = $0 })) { + ForEach(appGestureCommandEditableKeys, id: \.1) { Text($0.0).tag($0.1) } + } + } + .onDelete { offsets in if chord.keys.count > 1 { chord.keys.remove(atOffsets: offsets) } } + if chord.keys.count < KeyboardShortcut.maximumKeys { + Button("Add Another Key") { + chord.keys.append(appGestureCommandEditableKeys.first { !chord.keys.contains($0.1) }?.1 ?? 4) + } + } + } header: { + Text("Keys") + } + } +} + +private struct StepDraft: Identifiable, Equatable { + enum Kind: String, CaseIterable { case keys, system } + let id = UUID() + var kind = Kind.keys + var chord = ChordDraft(modifiers: [.command], keys: [14]) + var gesture = ReceiverGesture.showDesktop + var delayMs = ControlActionStep.defaultDelayMs + + init() {} + + init(_ step: ControlActionStep) { + delayMs = step.delayMs + switch step.action { + case .keyboardShortcut(let shortcut): + kind = .keys + chord = ChordDraft(shortcut) + case .receiverGesture(let name): + kind = .system + gesture = ReceiverGesture(rawValue: name) ?? .showDesktop + case .sequence: + break + } + } + + var step: ControlActionStep { + ControlActionStep(action: kind == .keys ? .keyboardShortcut(chord.shortcut) : .receiverGesture(gesture.rawValue), + delayMs: delayMs) + } +} + +private struct StepRow: View { + @Binding var step: StepDraft + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + Picker("Step", selection: $step.kind) { + Text("Keys").tag(StepDraft.Kind.keys) + Text("System Action").tag(StepDraft.Kind.system) + } + .pickerStyle(.segmented) + if step.kind == .keys { + HStack(spacing: 6) { + ForEach(ControlModifier.allCases) { modifier in + Toggle(modifier.symbol, isOn: Binding( + get: { step.chord.modifiers.contains(modifier) }, + set: { on in + if on { step.chord.modifiers.insert(modifier) } else { step.chord.modifiers.remove(modifier) } + })) + .toggleStyle(.button) + } + Spacer() + Picker("Key", selection: Binding(get: { step.chord.keys[0] }, set: { step.chord.keys[0] = $0 })) { + ForEach(appGestureCommandEditableKeys, id: \.1) { Text($0.0).tag($0.1) } + } + } + } else { + Picker("Action", selection: $step.gesture) { + ForEach(ReceiverGesture.oneTapActions, id: \.self) { Text($0.title).tag($0) } + } + } + Stepper("Then wait \(step.delayMs) ms", value: $step.delayMs, + in: 0...ControlAction.maximumStepDelayMs, step: 50) + .font(.footnote) + } + .padding(.vertical, 4) + } +} + +extension ReceiverGesture { + /// Semantic actions a button or sequence step may use. + static let oneTapActions: [ReceiverGesture] = [.menuBar, .showDesktop, .controlCenter, .missionControl, + .appExpose, .launchpad, .spotlight, .nextSpace, .previousSpace] + + var title: String { + switch self { + case .missionControl: return String(localized: "Mission Control") + case .appExpose: return String(localized: "App Exposé") + case .nextSpace: return String(localized: "Next Space") + case .previousSpace: return String(localized: "Previous Space") + case .showDesktop: return String(localized: "Show Desktop") + case .launchpad: return String(localized: "Launchpad") + case .spotlight: return String(localized: "Spotlight") + case .controlCenter: return String(localized: "Control Center") + case .menuBar: return String(localized: "Menu Bar") + } + } +} + +/// Captures one key combination from a hardware keyboard: modifiers held, +/// plus every ordinary key pressed before the first release. +struct ShortcutRecorder: UIViewRepresentable { + let onRecord: (Set, [Int]) -> Void + + func makeUIView(context: Context) -> RecorderView { + let view = RecorderView() + view.onRecord = onRecord + DispatchQueue.main.async { view.becomeFirstResponder() } + return view + } + + func updateUIView(_ view: RecorderView, context: Context) { + view.onRecord = onRecord + } + + final class RecorderView: UIView { + var onRecord: ((Set, [Int]) -> Void)? + private var keys: [Int] = [] + private var modifiers: Set = [] + + override var canBecomeFirstResponder: Bool { true } + + override func pressesBegan(_ presses: Set, with event: UIPressesEvent?) { + for press in presses { + guard let key = press.key else { continue } + modifiers.formUnion(Self.modifiers(key.modifierFlags)) + let usage = key.keyCode.rawValue + if KeyboardShortcut.validUsages.contains(usage), !keys.contains(usage) { keys.append(usage) } + } + } + + override func pressesEnded(_ presses: Set, with event: UIPressesEvent?) { + guard !keys.isEmpty else { return } + onRecord?(modifiers, keys) + keys = [] + modifiers = [] + } + + private static func modifiers(_ flags: UIKeyModifierFlags) -> Set { + var result: Set = [] + if flags.contains(.command) { result.insert(.command) } + if flags.contains(.alternate) { result.insert(.option) } + if flags.contains(.control) { result.insert(.control) } + if flags.contains(.shift) { result.insert(.shift) } + return result + } + } +} diff --git a/iOS/OpenSidecarPhoneApp.swift b/iOS/OpenSidecarPhoneApp.swift index ceb2f4ae..f4c30d69 100644 --- a/iOS/OpenSidecarPhoneApp.swift +++ b/iOS/OpenSidecarPhoneApp.swift @@ -56,9 +56,8 @@ struct ReceiverScreen: View { /// iPad: where the Mac canvas sits (a Strip rail reserves an edge). /// `nil` means the whole screen. @State private var padCanvas: CGRect? - /// Deliberately `@State`, not observed: only the control backdrop layer - /// observes the per-frame display footprint. - @State private var footprintStore = DisplayFootprintStore() + /// The on-surface Request Input affordance — see `ReceiverInputPrompt`. + @State private var inputPrompt = ReceiverInputPrompt() @Environment(\.scenePhase) private var scenePhase @Environment(\.accessibilityVoiceOverEnabled) private var voiceOverEnabled @AppStorage("showAnalytics") private var showAnalytics = false @@ -137,6 +136,60 @@ struct ReceiverScreen: View { isStreaming && model.receiver.session.allowsLiveInput && model.receiver.macSupportsKeyboardWire } + // MARK: Software keyboard + chord + + /// Typed text goes to the Mac as text — unless an on-screen chord is + /// held or latched, when each typed key is sent as that chord (⌘ then C + /// → ⌘C). The chord itself stays in the overlay's interaction state. + private func commitKeyboardText(_ text: String) { + let chord = controlStore.activeChordModifiers + guard !chord.isEmpty else { + model.receiver.sendKeyboardText(text) + return + } + var plain = "" + for character in text { + if let press = SoftwareKeyboardChordPolicy.press(for: String(character), modifiers: chord) { + if !plain.isEmpty { model.receiver.sendKeyboardText(plain); plain = "" } + model.receiver.sendKeyboardPress(usage: press.usage, modifiers: press.modifiers) + } else { + plain.append(character) + } + } + if !plain.isEmpty { model.receiver.sendKeyboardText(plain) } + } + + // MARK: Request Input + + /// Relative UI timing only (the prompt's fade); a wall clock is plenty. + private var uptime: TimeInterval { Date().timeIntervalSinceReferenceDate } + + /// A touch meant for the Mac was dropped: show (or refresh) the prompt. + /// It never changes any input preference. Only a Mac explicitly set to + /// Request Input Automatically (Paired Macs) gets the normal request + /// sent by itself — the connection Always Allow policy doesn't count — + /// and the Mac still decides. + private func noteBlockedInputAttempt() { + let autoRequest = InputAutoRequestPolicy.shouldAutoRequest( + peerOptedIn: InputAutoRequestStore.isEnabled(peerID: model.receiver.authenticatedPeerID)) + withAnimation(.easeOut(duration: 0.2)) { + performPromptEffects(inputPrompt.blockedAttempt(now: uptime, userTurnedOff: controlStore.userTurnedOffInput, + autoRequest: autoRequest)) + } + } + + private func requestInputFromPrompt() { + withAnimation(.easeOut(duration: 0.2)) { + performPromptEffects(inputPrompt.requestTapped(now: uptime)) + } + } + + private func performPromptEffects(_ effects: [ReceiverInputPrompt.Effect]) { + guard effects.contains(.sendRequest) else { return } + controlStore.noteInputTurnedOffByUser(false) + model.receiver.requestAllowInput(true) + } + private var settingsView: some View { SettingsView(receiver: model.receiver, controlStore: controlStore, pictureInPicture: model.pictureInPicture, haptics: haptics) @@ -211,21 +264,25 @@ struct ReceiverScreen: View { preferMeowDisplayGestures: controlStore.preferences.preferMeowDisplayGestures, moveViewActive: controlStore.moveViewActive, viewportResetGeneration: controlStore.viewportResetGeneration, - onDisplayFootprintChange: { footprintStore.update($0) }) + allowLocalViewNavigation: controlStore.preferences.allowLocalViewNavigation, + invertVerticalScroll: controlStore.preferences.invertVerticalScroll, + invertHorizontalScroll: controlStore.preferences.invertHorizontalScroll, + onBlockedInputAttempt: { noteBlockedInputAttempt() }) .id(metalRenderer) // rebuild the layer tree on toggle .frame(width: canvas.width, height: canvas.height) .position(x: canvas.midX, y: canvas.midY) .ignoresSafeArea() - // Allow Input OFF disables ALL touch/gesture - // delivery to the video layer in one place — no - // touch reaches `VideoView` or any of its gesture - // recognizers to begin with, covering direct touch, - // trackpad, clicks, drag, scroll, pinch and system - // gestures together (SETTINGS / ALLOW INPUT - // INVARIANTS). The Settings gear lives in - // `ReceiverControlOverlay`, a sibling view outside - // this hit-testing gate, so it stays reachable. - .allowsHitTesting(inputReachesMac) + // Allow Input OFF: touches still arrive (for Local + // View Navigation and the Request Input prompt), but + // `VideoView` routes nothing to the Mac: pointer/ + // touch/Pencil routing stops at `routeTouches`, the + // remote-only recognizers are parked by + // `ReceiverMultiFingerGestureGate`, two-finger routes + // follow `TwoFingerRoutingPolicy`, and hover is + // dropped (SETTINGS / ALLOW INPUT INVARIANTS). + // The surface always takes touches so a blocked one + // can offer Request Input; what they may do is + // decided inside `VideoView`. if model.pictureInPicture.isShowingWindow { ReceiverPictureInPicturePlaceholder() } @@ -247,13 +304,18 @@ struct ReceiverScreen: View { } RemoteKeyboardInputView( isActive: $keyboardActive, - onCommitText: { model.receiver.sendKeyboardText($0) }, - onSpecialPress: { model.receiver.sendKeyboardPress(usage: $0) }, + onCommitText: { commitKeyboardText($0) }, + onSpecialPress: { usage in + model.receiver.sendKeyboardPress(usage: usage, modifiers: SoftwareKeyboardChordPolicy.modifiers( + [], chord: controlStore.activeChordModifiers)) + }, onHardwareKeyDown: { usage, modifiers in - model.receiver.sendKeyboardDown(usage: usage, modifiers: modifiers) + model.receiver.sendKeyboardDown(usage: usage, modifiers: SoftwareKeyboardChordPolicy.modifiers( + modifiers, chord: controlStore.activeChordModifiers)) }, onHardwareKeyUp: { usage, modifiers in - model.receiver.sendKeyboardUp(usage: usage, modifiers: modifiers) + model.receiver.sendKeyboardUp(usage: usage, modifiers: SoftwareKeyboardChordPolicy.modifiers( + modifiers, chord: controlStore.activeChordModifiers)) }, onRequestDismiss: { keyboardActive = false } ) @@ -273,8 +335,15 @@ struct ReceiverScreen: View { reservedRegions: reservedRegions, haptics: haptics, onOccupiedFramesChange: { occupiedControlFrames = $0 }, - footprintStore: footprintStore, - onCanvasChange: { padCanvas = $0 }) + onCanvasChange: { padCanvas = $0 }, + surfaceOrigin: canvas.origin) + if let kind = inputPrompt.kind { + InputPromptButton(kind: kind) { requestInputFromPrompt() } + .position(x: canvas.midX, + y: keyboardVisibleRect.map { canvas.minY + $0.maxY - 110 } + ?? geo.size.height - effectiveSafeInsets.bottom - 76) + .transition(.move(edge: .bottom).combined(with: .opacity)) + } } else { IdleView(receiver: model.receiver, wakeConnect: model.wakeConnect, showSettings: $showSettings) } @@ -424,10 +493,26 @@ struct ReceiverScreen: View { Log.info("inputTrace: controlStore.preferences.allowInput -> \(allowed) " + "displayState=\(model.receiver.displayState)") #endif - if !allowed { - keyboardActive = false - controlStore.setMoveViewActive(false) - } + if !allowed { keyboardActive = false } + } + .onAppear { inputPrompt.setSessionLive(model.receiver.session.allowsLiveInput) } + .onChange(of: model.receiver.session.allowsLiveInput) { live in + withAnimation { inputPrompt.setSessionLive(live) } + } + .onChange(of: controlStore.sessionInputState) { state in + withAnimation { inputPrompt.inputStateChanged(state, now: uptime) } + } + // Fades the prompt once its time is up; each new attempt moves the + // deadline and restarts this. + .task(id: inputPrompt.visibleUntil) { + let delay = inputPrompt.visibleUntil - uptime + guard inputPrompt.kind != nil else { return } + if delay > 0 { try? await Task.sleep(nanoseconds: UInt64((delay + 0.05) * 1_000_000_000)) } + guard !Task.isCancelled else { return } + withAnimation(.easeOut(duration: 0.35)) { inputPrompt.tick(now: uptime) } + } + .onChange(of: controlStore.preferences.allowLocalViewNavigation) { allowed in + if !allowed { controlStore.setMoveViewActive(false) } } #if DEBUG .onAppear { @@ -1651,8 +1736,12 @@ struct VideoLayerView: UIViewRepresentable { var moveViewActive = false /// Bumped to reset the local viewport (Move View's Reset). var viewportResetGeneration = 0 - /// Where the Mac is drawn, in window coordinates — see `DisplayFootprint`. - var onDisplayFootprintChange: @MainActor (DisplayFootprint) -> Void = { _ in } + /// Local View Navigation — see `LocalViewNavigationPolicy`. + var allowLocalViewNavigation = true + var invertVerticalScroll = true + var invertHorizontalScroll = true + /// See `VideoView.onBlockedInputAttempt`. + var onBlockedInputAttempt: @MainActor () -> Void = {} func makeUIView(context: Context) -> VideoView { let view = VideoView() @@ -1665,6 +1754,7 @@ struct VideoLayerView: UIViewRepresentable { view.setTrackpadSensitivity(trackpadSensitivity) view.setSmartTouchEnabled(smartTouchEnabled) view.setAllowInput(allowInput) + view.setLocalNavigationAllowed(allowLocalViewNavigation) view.setVideoEnabled(videoEnabled, showGrid: showSurfaceGrid) view.setSurfaceContext(safeInsets: safeInsets, occupiedControlFrames: occupiedControlFrames, reservedDivisions: reservedDivisions) @@ -1676,7 +1766,8 @@ struct VideoLayerView: UIViewRepresentable { view.onActivityEnded = onActivityEnded view.smartTouchHaptics.isEnabled = smartTouchHapticsEnabled view.preferMeowDisplayGestures = preferMeowDisplayGestures - view.onDisplayFootprintChange = onDisplayFootprintChange + view.onBlockedInputAttempt = onBlockedInputAttempt + view.setScrollDirection(invertVertical: invertVerticalScroll, invertHorizontal: invertHorizontalScroll) view.setViewportResetGeneration(viewportResetGeneration) receiver.onSmartTouchProbeResult = { [weak view] id, target in view?.resolveSmartTouchProbe(id: id, target: target) @@ -1704,6 +1795,7 @@ struct VideoLayerView: UIViewRepresentable { view.inputEngine.normalize = { [weak view] point in view?.normalized(point) } view.inputEngine.onPencil = { [weak receiver, weak view] phase, x, y, pressure, azimuth, altitude in + guard view?.remoteInputAllowed == true else { return } // M4 typing-focus anchor: a real pencil touch-down counts as a // meaningful primary interaction; hover does not (PRODUCT RULE). if phase == "down" { view?.noteAnchorFromNormalized(x: x, y: y) } @@ -1711,7 +1803,8 @@ struct VideoLayerView: UIViewRepresentable { pressure: pressure, azimuth: azimuth, altitude: altitude) } - view.inputEngine.onProximity = { [weak receiver] entering, x, y in + view.inputEngine.onProximity = { [weak receiver, weak view] entering, x, y in + guard view?.remoteInputAllowed == true else { return } receiver?.sendProximity(entering: entering, x: x, y: y) } view.inputEngine.install(on: view) @@ -1792,6 +1885,7 @@ struct VideoLayerView: UIViewRepresentable { uiView.setTrackpadSensitivity(trackpadSensitivity) uiView.setSmartTouchEnabled(smartTouchEnabled) uiView.setAllowInput(allowInput) + uiView.setLocalNavigationAllowed(allowLocalViewNavigation) uiView.setVideoEnabled(videoEnabled, showGrid: showSurfaceGrid) uiView.setSurfaceContext(safeInsets: safeInsets, occupiedControlFrames: occupiedControlFrames, reservedDivisions: reservedDivisions) @@ -1803,7 +1897,8 @@ struct VideoLayerView: UIViewRepresentable { uiView.onActivityEnded = onActivityEnded uiView.smartTouchHaptics.isEnabled = smartTouchHapticsEnabled uiView.preferMeowDisplayGestures = preferMeowDisplayGestures - uiView.onDisplayFootprintChange = onDisplayFootprintChange + uiView.onBlockedInputAttempt = onBlockedInputAttempt + uiView.setScrollDirection(invertVertical: invertVerticalScroll, invertHorizontal: invertHorizontalScroll) uiView.setMoveViewActive(moveViewActive) uiView.setViewportResetGeneration(viewportResetGeneration) // videoSize arrives after the format description — re-fit the layers. @@ -2040,7 +2135,7 @@ struct VideoLayerView: UIViewRepresentable { let id = ObjectIdentifier(touch) if ended { navigationTouches.removeValue(forKey: id) } else { navigationTouches[id] = touch } } - guard videoEnabled, !navigationTouches.isEmpty else { + guard videoEnabled, localNavigationAllowed, !navigationTouches.isEmpty else { navigationSession.end() return } @@ -2061,31 +2156,79 @@ struct VideoLayerView: UIViewRepresentable { layoutIfNeeded() } - // MARK: - Display footprint + // MARK: - Blocked input attempts - var onDisplayFootprintChange: (@MainActor (DisplayFootprint) -> Void)? - private var lastFootprint = DisplayFootprint.none + /// A touch sequence that was meant for the Mac but couldn't be sent + /// (input not allowed) — see `BlockedInputAttemptPolicy`. Local + /// two-finger navigation never counts. + var onBlockedInputAttempt: (@MainActor () -> Void)? + private var blockedSequenceMaxTouches = 0 + private var blockedSequenceTouchIDs = Set() - override var frame: CGRect { - didSet { reportDisplayFootprint() } + private func trackBlockedSequence(_ touches: Set, began: Bool, ended: Bool) { + guard !allowsInput else { + blockedSequenceTouchIDs.removeAll() + blockedSequenceMaxTouches = 0 + return + } + let fingers = touches.filter { $0.type == .direct || $0.type == .pencil } + if began { + blockedSequenceTouchIDs.formUnion(fingers.map(ObjectIdentifier.init)) + blockedSequenceMaxTouches = max(blockedSequenceMaxTouches, blockedSequenceTouchIDs.count) + } + guard ended else { return } + for touch in fingers { blockedSequenceTouchIDs.remove(ObjectIdentifier(touch)) } + guard blockedSequenceTouchIDs.isEmpty else { return } + if BlockedInputAttemptPolicy.isRemoteIntent(maximumTouchCount: blockedSequenceMaxTouches, + moveViewActive: moveViewActive) { + onBlockedInputAttempt?() + } + blockedSequenceMaxTouches = 0 } - override var center: CGPoint { - didSet { reportDisplayFootprint() } + // MARK: - Scroll direction + + private var invertVerticalScroll = true + private var invertHorizontalScroll = true + + func setScrollDirection(invertVertical: Bool, invertHorizontal: Bool) { + invertVerticalScroll = invertVertical + invertHorizontalScroll = invertHorizontal } - /// Publishes where the Mac is drawn (window coordinates) whenever - /// it changes — the controls use it to decide where a soft backdrop - /// is warranted. Video off draws no Mac, so no footprint. - private func reportDisplayFootprint() { - guard window != nil else { return } - let footprint = videoEnabled - ? DisplayFootprint(transform: currentTransform, surfaceOrigin: convert(CGPoint.zero, to: nil), - surfaceSize: bounds.size) - : .none - guard footprint != lastFootprint else { return } - lastFootprint = footprint - onDisplayFootprintChange?(footprint) + /// Every remote scroll (two-finger, momentum, Smart Touch/Trackpad) + /// goes through here, so the per-axis direction applies everywhere + /// and local viewport panning never does. + private func sendRemoteScroll(dx: CGFloat, dy: CGFloat) { + let directed = ScrollDirectionPolicy.apply(dx: Double(dx), dy: Double(dy), + invertVertical: invertVerticalScroll, + invertHorizontal: invertHorizontalScroll) + receiver?.sendScroll(dx: directed.dx, dy: directed.dy) + } + + // MARK: - Local View Navigation + + /// Pan/zoom/rotate/reset of the local view — independent of + /// `allowsInput`, which gates only what reaches the Mac. See + /// `LocalViewNavigationPolicy`. + private var localNavigationAllowed = true + + fileprivate var remoteInputAllowed: Bool { allowsInput } + + func setLocalNavigationAllowed(_ allowed: Bool) { + guard allowed != localNavigationAllowed else { return } + localNavigationAllowed = allowed + if !allowed { + navigationTouches.removeAll() + navigationSession.end() + } + updateInputGate() + } + + private func updateInputGate() { + if multiFingerGate.update(remoteInputAllowed: allowsInput, localNavigationAllowed: localNavigationAllowed) { + applyMultiFingerGate() + } } // VoiceOver gate for every multi-finger recognizer — see @@ -2192,6 +2335,7 @@ struct VideoLayerView: UIViewRepresentable { func setAllowInput(_ allowed: Bool) { guard allowed != allowsInput else { return } allowsInput = allowed + updateInputGate() guard !allowed else { return } clearInputStateForPause() } @@ -2356,7 +2500,6 @@ struct VideoLayerView: UIViewRepresentable { updateCursorLayout() updateSurfaceLayout() CATransaction.commit() - reportDisplayFootprint() // Rotation diagnostics — one line per layout change. let video = receiver?.videoSize ?? .zero let displayed = currentTransform.displayedRect @@ -2670,6 +2813,7 @@ struct VideoLayerView: UIViewRepresentable { } private func takeGestureOwnershipAndSend(_ gesture: ReceiverGesture) { + guard allowsInput else { return } twoFingerActive = false cancelTouchForGestureOwnership() receiver?.sendGesture(name: gesture.rawValue) @@ -2696,25 +2840,31 @@ struct VideoLayerView: UIViewRepresentable { + "systemGestureSequenceOwned=\(systemGestureSequenceOwned)") #endif guard !systemGestureSequenceOwned else { return } + // Remote scroll and App Gesture Commands need Allow Input; the + // local viewport needs Local View Navigation. + let routes = TwoFingerRoutingPolicy.routes( + intent: recognizer.intent, remoteInputAllowed: allowsInput, + localNavigationAllowed: localNavigationAllowed, videoEnabled: videoEnabled, + pinchTarget: pinchTarget, rotateTarget: rotateTarget) switch recognizer.state { case .began: switch recognizer.intent { case .scroll: - beginScroll(at: recognizer.midpoint) + if routes.contains(.remoteScroll) { beginScroll(at: recognizer.midpoint) } case .viewportZoomPan: beginViewportManipulation() - applyViewportPinchUpdate(recognizer) - beginAppGesturesIfNeeded(recognizer) + if routes.contains(.viewport) { applyViewportPinchUpdate(recognizer) } + if routes.contains(.appCommands) { beginAppGesturesIfNeeded(recognizer) } case .undecided: break // never begins while undecided — see the recognizer. } case .changed: switch recognizer.intent { case .scroll: - continueScroll(recognizer) + if routes.contains(.remoteScroll) { continueScroll(recognizer) } case .viewportZoomPan: - applyViewportPinchUpdate(recognizer) - updateAppGestures(recognizer) + if routes.contains(.viewport) { applyViewportPinchUpdate(recognizer) } + if routes.contains(.appCommands) { updateAppGestures(recognizer) } case .undecided: break } @@ -2880,7 +3030,7 @@ struct VideoLayerView: UIViewRepresentable { // Deltas in video pixels, natural-scrolling direction. let stepX = t.x - lastPan.x let stepY = t.y - lastPan.y - receiver?.sendScroll(dx: stepX / scale, dy: stepY / scale) + sendRemoteScroll(dx: stepX / scale, dy: stepY / scale) // Recorded in view points (pre-scale), matching the domain // `cancelScrollMomentum`/`beginScrollMomentum` convert from — // see `tickScrollMomentum`. @@ -2911,7 +3061,7 @@ struct VideoLayerView: UIViewRepresentable { } let (delta, alive) = session.tick(now: CACurrentMediaTime()) if delta.dx != 0 || delta.dy != 0 { - receiver?.sendScroll(dx: delta.dx / scale, dy: delta.dy / scale) + sendRemoteScroll(dx: delta.dx / scale, dy: delta.dy / scale) } if !alive { cancelScrollMomentum() } } @@ -2938,7 +3088,7 @@ struct VideoLayerView: UIViewRepresentable { // recognizer stays attached to a system gesture's leftover // touches too. guard !systemGestureSequenceOwned else { return } - guard recognizer.state == .ended, videoEnabled else { return } + guard recognizer.state == .ended, videoEnabled, localNavigationAllowed else { return } let base = unzoomedBaseTransform().displayedRect guard let toggled = ViewportResetRestorePolicy.toggled( current: manualZoom, memory: manualZoomMemory, base: base) else { return } @@ -3301,7 +3451,7 @@ struct VideoLayerView: UIViewRepresentable { // Same units, direction, and momentum sampling as the // two-finger scroll path (`continueScroll`). guard let scale = pointsPerRemotePixel(video: video) else { continue } - receiver.sendScroll(dx: dx / scale, dy: dy / scale) + sendRemoteScroll(dx: CGFloat(dx) / scale, dy: CGFloat(dy) / scale) scrollVelocityTracker.record(dx: CGFloat(dx), dy: CGFloat(dy), at: CACurrentMediaTime()) case .scrollEnded(let momentum): if momentum { @@ -3359,6 +3509,11 @@ struct VideoLayerView: UIViewRepresentable { } private func routeTouches(_ phase: String, _ touches: Set, _ event: UIEvent?, ended: Bool) { + // With Allow Input off the surface may still receive touches for + // Local View Navigation (the two-finger and double-tap + // recognizers see them independently), but nothing from here + // on — pointer, touch, Pencil — may reach the Mac. + guard allowsInput else { return } #if DEBUG // TEMP diagnostic for the "all input dead" regression report — // remove once root-caused. Checkpoint A/B: confirms touches are @@ -3442,6 +3597,7 @@ struct VideoLayerView: UIViewRepresentable { } override func touchesBegan(_ touches: Set, with event: UIEvent?) { + trackBlockedSequence(touches, began: true, ended: false) if moveViewActive { onActivityBegan?() routeNavigationTouches(touches, ended: false) @@ -3467,6 +3623,7 @@ struct VideoLayerView: UIViewRepresentable { routeTouches("moved", touches, event, ended: false) } override func touchesEnded(_ touches: Set, with event: UIEvent?) { + trackBlockedSequence(touches, began: false, ended: true) if moveViewActive || touches.contains(where: { navigationTouches[ObjectIdentifier($0)] != nil }) { routeNavigationTouches(touches, ended: true) if navigationTouches.isEmpty { onActivityEnded?() } @@ -3479,6 +3636,7 @@ struct VideoLayerView: UIViewRepresentable { if activeFingerTouchIDs.isEmpty { onActivityEnded?() } } override func touchesCancelled(_ touches: Set, with event: UIEvent?) { + trackBlockedSequence(touches, began: false, ended: true) if moveViewActive || touches.contains(where: { navigationTouches[ObjectIdentifier($0)] != nil }) { routeNavigationTouches(touches, ended: true) if navigationTouches.isEmpty { onActivityEnded?() } @@ -3930,3 +4088,53 @@ private extension VerticalAlignment { static let brandInkCenter = VerticalAlignment(BrandInkCenter.self) } + +/// The small Request Input / Enable Input affordance on the receiver +/// surface — see `ReceiverInputPrompt`. +private struct InputPromptButton: View { + let kind: ReceiverInputPrompt.Kind + let action: () -> Void + + var body: some View { + Button(action: action) { + HStack(spacing: 8) { + if kind == .requesting { + ProgressView().controlSize(.small).tint(.white) + } else { + Image(systemName: symbol) + } + Text(title).font(.subheadline.weight(.semibold)) + } + .foregroundStyle(.white) + .padding(.horizontal, 16) + .padding(.vertical, 10) + .background(Capsule().fill(Color(white: 0.16).opacity(0.94))) + .overlay(Capsule().strokeBorder(Color.white.opacity(0.14), lineWidth: 0.75)) + .shadow(color: .black.opacity(0.3), radius: 8, y: 2) + } + .buttonStyle(.plain) + .disabled(!isActionable) + .accessibilityHint(isActionable ? Text("Asks your Mac to allow input from this device.") : Text("")) + } + + private var isActionable: Bool { kind == .requestInput || kind == .enableInput } + + private var title: String { + switch kind { + case .enableInput: return String(localized: "Enable Input") + case .requestInput: return String(localized: "Request Input") + case .requesting: return String(localized: "Requesting Input…") + case .enabled: return String(localized: "Input Enabled") + case .unavailable: return String(localized: "This Mac isn’t accepting input requests") + } + } + + private var symbol: String { + switch kind { + case .enableInput, .requestInput: return "hand.tap" + case .requesting: return "hourglass" + case .enabled: return "checkmark.circle.fill" + case .unavailable: return "hand.raised.slash" + } + } +} diff --git a/iOS/ReceiverControls.swift b/iOS/ReceiverControls.swift index b9cc9334..400e5aa6 100644 --- a/iOS/ReceiverControls.swift +++ b/iOS/ReceiverControls.swift @@ -34,6 +34,24 @@ final class ReceiverControlStore: ObservableObject { @Published private(set) var moveViewActive = false /// Bumped to ask the receiver surface to reset its local viewport. @Published private(set) var viewportResetGeneration = 0 + /// The modifiers the on-screen chord currently holds down on the Mac — + /// a read-only projection of the overlay's `ControlInteractionState`, + /// so the software keyboard can type ⌘C after ⌘ is latched. Never a + /// second modifier state. + @Published private(set) var activeChordModifiers: Set = [] + /// Whether the user turned input off themselves (Settings → Input → + /// Turn Off), so the on-surface prompt offers Enable rather than + /// Request. Transient. + @Published private(set) var userTurnedOffInput = false + + func projectChord(_ modifiers: Set) { + guard modifiers != activeChordModifiers else { return } + activeChordModifiers = modifiers + } + + func noteInputTurnedOffByUser(_ turnedOff: Bool) { + userTurnedOffInput = turnedOff + } init(repository: ReceiverControlPreferencesRepository = ReceiverControlPreferencesRepository(), isPad: Bool = UIDevice.current.userInterfaceIdiom == .pad) { @@ -273,20 +291,6 @@ final class SmartTouchHaptics { } } -/// The live rendered-display footprint, published by the receiver surface -/// (`VideoView`) on every layout pass that changes it. Kept out of every -/// other observed object on purpose: during a pinch it changes per frame, -/// and only the small control-backdrop layer observes it. -@MainActor -final class DisplayFootprintStore: ObservableObject { - @Published private(set) var footprint = DisplayFootprint.none - - func update(_ footprint: DisplayFootprint) { - guard footprint != self.footprint else { return } - self.footprint = footprint - } -} - private struct ControlFramePreference: PreferenceKey { static let defaultValue: [String: CGRect] = [:] static func reduce(value: inout [String: CGRect], nextValue: () -> [String: CGRect]) { @@ -311,13 +315,16 @@ struct ReceiverControlOverlay: View { let reservedRegions: [ControlReservedRegion] let haptics: ReceiverHaptics let onOccupiedFramesChange: ([CGRect]) -> Void - /// iPad only: the rendered-display footprint, for control halos. - var footprintStore: DisplayFootprintStore? /// iPad only: where the Mac canvas goes (the Strip reserves rails). var onCanvasChange: (CGRect) -> Void = { _ in } + /// Where the receiver surface starts in this overlay's coordinates — + /// `keyboardVisibleRect` is in the surface's own. + var surfaceOrigin: CGPoint = .zero @State private var interaction = ControlInteractionState() @State private var initiatingMoveView = false + /// The Wheel palette on screen, for hit-testing its wedges. + @State private var wheelHit: WheelHitTarget? /// The modifier whose placement a Custom-layout palette blooms around. @State private var paletteAnchor: ControlModifier? @State private var frames: [String: CGRect] = [:] @@ -397,17 +404,27 @@ struct ReceiverControlOverlay: View { /// presentation. Custom layouts place items themselves, independent of /// the profile's tray visibility. private var interactiveTrayItems: [ControlTrayItem] { + if presentation == .keyboardBar { return keyboardBarTrayItems } guard store.isPad, store.effectivePadLayout == .custom else { return controls } guard store.preferences.trayCanBeShown, !store.preferences.trayCollapsed else { return [.settings] } return ControlTrayItem.allCases.filter(capabilityAllows) } + private var presentation: ReceiverControlPresentation { + ReceiverControlPresentation.mode(softwareKeyboardVisible: keyboardVisibleRect != nil) + } + var body: some View { Group { - if store.isPad { - padBody - } else { - phoneBody + switch presentation { + case .keyboardBar: + keyboardBarBody + case .normal: + if store.isPad { + padBody + } else { + phoneBody + } } } .onAppear { @@ -473,6 +490,9 @@ struct ReceiverControlOverlay: View { store.resetAutoHide() } .onChange(of: store.preferences.activeCustomLayoutID) { _ in apply(interaction.resetAll()) } + .onChange(of: interaction.activeChord) { store.projectChord($0.modifiers) } + .onDisappear { store.projectChord([]) } + .onPreferenceChange(WheelHitPreference.self) { wheelHit = $0 } } @ViewBuilder @@ -817,15 +837,7 @@ struct ReceiverControlOverlay: View { } private func functionButton(_ item: ShortcutItem) -> some View { - Group { - if let systemImage = item.systemImage { - Image(systemName: systemImage) - .font(.system(size: 17, weight: .semibold)) - } else { - Text(item.displayKey) - .font(.system(size: 15, weight: .semibold)) - } - } + ShortcutButtonFaceView(face: item.face, diameter: Metrics.trayItem) .foregroundStyle(.white) .frame(width: Metrics.trayItem, height: Metrics.trayItem) .background(chip(selected: false)) @@ -835,14 +847,7 @@ struct ReceiverControlOverlay: View { } private func performFunctionAction(_ item: ShortcutItem) { - switch item.action { - case .keyboardShortcut(let shortcut): - receiver.sendKeyboardPress(usage: shortcut.usage, - modifiers: shortcut.modifiers.modifiers.map(\.rawValue)) - case .receiverGesture(let name): - guard ReceiverGesture(rawValue: name) != nil else { return } - receiver.sendGesture(name: name) - } + OneTapActionRunner.run(item.action, receiver: receiver, inputAllowed: { store.preferences.allowInput }) haptics.play(.confirmation) store.scheduleAutoHide() } @@ -906,11 +911,7 @@ struct ReceiverControlOverlay: View { private func shortcutButton(_ action: ShortcutItem, portrait: Bool, diameter: CGFloat = Metrics.paletteKey) -> some View { let selected = interaction.selectedActionID == action.id - let fontScale = diameter / Metrics.paletteKey - return Text(action.displayKey) - .font(.system(size: (action.displayKey.count > 2 ? 11 : 15) * fontScale, weight: .semibold)) - .minimumScaleFactor(0.7) - .lineLimit(1) + return ShortcutButtonFaceView(face: action.face, diameter: diameter) .foregroundStyle(selected ? Color.black : Color.white) .frame(width: diameter, height: diameter) .background(frameReader("action:\(action.id)")) @@ -1040,9 +1041,12 @@ struct ReceiverControlOverlay: View { } private func action(at point: CGPoint) -> ShortcutItem? { - profile.actions(for: interaction.activeChord).first { - frames["action:\($0.id)"]?.contains(point) == true + let actions = profile.actions(for: interaction.activeChord) + if let wheelHit, wheelHit.actionIDs == actions.map(\.id), + let index = wheelHit.layout.segmentIndex(at: point) { + return actions[index] } + return actions.first { frames["action:\($0.id)"]?.contains(point) == true } } private func selectedAction(in actions: [ShortcutItem]) -> ShortcutItem? { @@ -1052,10 +1056,8 @@ struct ReceiverControlOverlay: View { /// The highlighted shortcut, but only if the finger actually lifted on it /// — sliding off a key must cancel it, not fire it. private func selectedAction(under point: CGPoint) -> ShortcutItem? { - profile.actions(for: interaction.activeChord).first { - $0.id == interaction.selectedActionID - && frames["action:\($0.id)"]?.contains(point) == true - } + guard let under = action(at: point), under.id == interaction.selectedActionID else { return nil } + return under } private func perform(_ item: ControlTrayItem) { @@ -1088,9 +1090,14 @@ struct ReceiverControlOverlay: View { case .modifierDown(let modifier): receiver.sendModifier(modifier, down: true) case .modifierUp(let modifier): receiver.sendModifier(modifier, down: false) case .execute(let item): - if case .keyboardShortcut(let shortcut) = item.action { + if case .keyboardShortcut(let shortcut) = item.action, shortcut.additionalUsages.isEmpty { receiver.sendKeyboardPress(usage: shortcut.usage, modifiers: shortcut.modifiers.modifiers.map(\.rawValue)) + } else { + // User-made palette actions: chords, sequences, system + // actions — the same runner as one-tap buttons. + OneTapActionRunner.run(item.action, receiver: receiver, + inputAllowed: { store.preferences.allowInput }) } case .haptic(let event): haptics.play(event) } @@ -1098,6 +1105,141 @@ struct ReceiverControlOverlay: View { } } +// MARK: - Keyboard accessory bar + +extension ReceiverControlOverlay { + /// The tray items the bar's gesture can start on — see `KeyboardBarItem`. + fileprivate var keyboardBarTrayItems: [ControlTrayItem] { + keyboardBarItems.map { item in + switch item { + case .modifier(let modifier): return ControlTrayItem.item(for: modifier) + case .escape: return .escape + case .tab: return .tab + case .dismissKeyboard: return .keyboard + } + } + } + + private var keyboardBarItems: [KeyboardBarItem] { + KeyboardBarItem.defaultItems.filter { item in + if case .modifier = item { return capabilityAllows(.command) } + return true + } + } + + /// While the software keyboard is up, a flat bar right above it holds + /// only keyboard companions — modifiers, Escape, Tab, and a dismiss + /// key — instead of the whole receiver UI squeezed into what's left. + /// Modifiers here are the same chord as everywhere else: latch ⌘, type + /// C, and the Mac gets ⌘C. The normal controls return, unchanged, when + /// the keyboard closes. + @ViewBuilder + var keyboardBarBody: some View { + let keyboardTop = (keyboardVisibleRect?.maxY ?? containerSize.height) + surfaceOrigin.y + let items = keyboardBarItems + let item: CGFloat = store.isPad ? (PadControlMetrics.baseItem * CGFloat(PadControlScale.clamped(store.preferences.padControlScale))).rounded() : 38 + let gap: CGFloat = 8 + let padding: CGFloat = 6 + let barWidth = CGFloat(items.count) * item + CGFloat(items.count - 1) * gap + padding * 2 + let bar = CGRect(x: containerSize.width / 2 - barWidth / 2, y: keyboardTop - 8 - item - padding * 2, + width: barWidth, height: item + padding * 2) + let cells = items.indices.map { index in + CGRect(x: bar.minX + padding + CGFloat(index) * (item + gap), y: bar.minY + padding, width: item, height: item) + } + let paletteChord = interaction.paletteChord + let actions = paletteChord.map(profile.actions(for:)) ?? [] + let key = item * 0.92 + let perRow = max(1, Int((containerSize.width - 32 + gap) / (key + gap))) + let rows = Int(ceil(Double(max(actions.count, 1)) / Double(perRow))) + let columns = min(max(actions.count, 1), perRow) + let paletteSize = CGSize(width: CGFloat(columns) * key + CGFloat(columns - 1) * gap + padding * 2, + height: CGFloat(rows) * key + CGFloat(rows - 1) * gap + padding * 2) + let paletteFrame = CGRect(x: containerSize.width / 2 - paletteSize.width / 2, + y: bar.minY - 10 - paletteSize.height, + width: paletteSize.width, height: paletteSize.height) + let keys: [(action: ShortcutItem, frame: CGRect)] = actions.enumerated().map { index, action in + let row = index / perRow + let column = index % perRow + return (action, CGRect(x: paletteFrame.minX + padding + CGFloat(column) * (key + gap), + y: paletteFrame.minY + padding + CGFloat(row) * (key + gap), width: key, height: key)) + } + ZStack(alignment: .topLeading) { + ControlTray() + .frame(width: bar.width, height: bar.height) + .position(x: bar.midX, y: bar.midY) + .allowsHitTesting(false) + ForEach(Array(zip(items, cells)), id: \.0) { entry, cell in + keyboardBarKey(entry, frame: cell) + } + if let paletteChord { + if actions.isEmpty { + emptyPaletteLabel(paletteChord) + .position(x: paletteFrame.midX, y: paletteFrame.maxY - 18) + } else { + ControlTray() + .frame(width: paletteFrame.width, height: paletteFrame.height) + .position(x: paletteFrame.midX, y: paletteFrame.midY) + .allowsHitTesting(false) + ForEach(keys, id: \.action.id) { key in + shortcutButton(key.action, portrait: true, diameter: key.frame.width) + .position(x: key.frame.midX, y: key.frame.midY) + } + } + } + if let selected = selectedAction(in: actions) { + shortcutHUD(selected) + .position(x: containerSize.width / 2, y: max(safeInsets.top + 30, paletteFrame.minY - 26)) + } + Color.clear + .frame(width: containerSize.width, height: containerSize.height) + .contentShape(ControlRegionShape(rects: [bar] + (paletteChord == nil ? [] : [paletteFrame]))) + .gesture(controlGesture()) + } + .coordinateSpace(name: "receiverControls") + .frame(width: containerSize.width, height: containerSize.height, alignment: .topLeading) + .onPreferenceChange(ControlFramePreference.self) { frames = $0 } + .onAppear { onOccupiedFramesChange([bar]) } + } + + @ViewBuilder + private func keyboardBarKey(_ entry: KeyboardBarItem, frame: CGRect) -> some View { + switch entry { + case .modifier(let modifier): + let selected = interaction.latchedModifiers.contains(modifier) + || interaction.temporaryModifiers.contains(modifier) + || interaction.phase == .pressed(modifier) + Text(modifier.symbol) + .font(.system(size: frame.width * 0.46, weight: .semibold)) + .foregroundStyle(selected ? Color.black : Color.white) + .frame(width: frame.width, height: frame.height) + .background(frameReader("modifier:\(modifier.rawValue)")) + .background(PadChip(selected: selected, onRail: true)) + .position(x: frame.midX, y: frame.midY) + .accessibilityLabel(modifier.title) + .accessibilityValue(selected ? "On" : "Off") + case .escape, .tab: + let item: ControlTrayItem = entry == .escape ? .escape : .tab + Text(item.displayLabel) + .font(.system(size: frame.width * 0.3, weight: .semibold)) + .foregroundStyle(.white) + .frame(width: frame.width, height: frame.height) + .background(frameReader("tray:\(item.rawValue)")) + .background(PadChip(selected: false, onRail: true)) + .position(x: frame.midX, y: frame.midY) + .accessibilityLabel(item.title) + case .dismissKeyboard: + Image(systemName: "keyboard.chevron.compact.down") + .font(.system(size: frame.width * 0.38, weight: .semibold)) + .foregroundStyle(.white) + .frame(width: frame.width, height: frame.height) + .background(frameReader("tray:\(ControlTrayItem.keyboard.rawValue)")) + .background(PadChip(selected: false, onRail: true)) + .position(x: frame.midX, y: frame.midY) + .accessibilityLabel(Text("Hide Keyboard")) + } + } +} + // MARK: - iPad presentation (Strip / Overlay / Custom) /// One placed iPad control, resolved for this layout pass. @@ -1114,9 +1256,12 @@ extension ReceiverControlOverlay { private var padMetricsScale: Double { store.preferences.padControlScale } private var padCollapsed: Bool { store.preferences.trayCollapsed || !store.preferences.trayCanBeShown } - /// Move View only matters while the picture is live. + /// Move View needs live video and Local View Navigation — the button + /// never bypasses that policy. private var padShowsMoveView: Bool { - !padCollapsed && receiver.videoEnabled + !padCollapsed && LocalViewNavigationPolicy.showsMoveView( + controlVisible: true, localNavigationAllowed: store.preferences.allowLocalViewNavigation, + videoEnabled: receiver.videoEnabled) } @ViewBuilder @@ -1148,40 +1293,36 @@ extension ReceiverControlOverlay { let strip = mode == .strip let hints = strip && store.preferences.padShowControlHints let metrics = PadControlMetrics(scale: padMetricsScale, showsHints: hints) - let mainKinds = padMainKinds + let mainGroups = padCollapsed ? [] : padMainGroups + let mainKinds = mainGroups.flatMap { $0 } let groups = functionGroups let mainEdge = store.preferences.padMainEdge let functionEdge = store.preferences.padFunctionEdge - let firstPass = PadEdgeGeometry.layout( - container: container, safeInsets: safeInsets, reservesStrips: strip, - mainEdge: mainEdge, functionEdge: functionEdge, - mainCount: padCollapsed ? 0 : mainKinds.count, - functionGroupCounts: groups.map(\.count), metrics: metrics) + let pass: (CGFloat?) -> PadEdgeLayout = { keyboardTop in + PadEdgeGeometry.layout(container: container, safeInsets: safeInsets, reservesStrips: strip, + mainEdge: mainEdge, functionEdge: functionEdge, + mainGroupCounts: mainGroups.map(\.count), + mainGroupAlignments: PadRailComposition.alignments(for: mainGroups), + functionGroupCounts: groups.map(\.count), metrics: metrics, + keyboardTop: keyboardTop) + } + let firstPass = pass(nil) let keyboardTop = keyboardVisibleRect.map { $0.maxY + firstPass.canvas.minY } - let layout = keyboardTop == nil ? firstPass : PadEdgeGeometry.layout( - container: container, safeInsets: safeInsets, reservesStrips: strip, - mainEdge: mainEdge, functionEdge: functionEdge, - mainCount: padCollapsed ? 0 : mainKinds.count, - functionGroupCounts: groups.map(\.count), metrics: metrics, keyboardTop: keyboardTop) + let layout = keyboardTop == nil ? firstPass : pass(keyboardTop) // Collapsed (or the tray can't show): only the gear, floating at the // Main edge, never a rail. let gearFrame: CGRect? = padCollapsed ? PadEdgeGeometry.layout( container: container, safeInsets: safeInsets, reservesStrips: false, - mainEdge: mainEdge, functionEdge: functionEdge, mainCount: 1, functionGroupCounts: [], + mainEdge: mainEdge, functionEdge: functionEdge, mainGroupCounts: [1], mainGroupAlignments: [.end], + functionGroupCounts: [], metrics: PadControlMetrics(scale: padMetricsScale), keyboardTop: keyboardTop).mainFrame : nil - let mainControls: [PadPlacedControl] = layout.mainFrame.map { frame in - let cells = PadEdgeGeometry.cellFrames(in: frame, count: mainKinds.count, edge: mainEdge, metrics: metrics) - return zip(mainKinds, cells).map { kind, cell in - PadPlacedControl(id: padID(kind), kind: kind, - frame: PadEdgeGeometry.circleFrame(inCell: cell, metrics: metrics), cell: cell) - } - } ?? [] - let functionControls: [(item: ShortcutItem, frame: CGRect, cell: CGRect)] = - zip(groups, layout.functionFrames).flatMap { group, frame in - zip(group, PadEdgeGeometry.cellFrames(in: frame, count: group.count, edge: functionEdge, - metrics: metrics)).map { item, cell in - (item, PadEdgeGeometry.circleFrame(inCell: cell, metrics: metrics), cell) - } + let mainControls: [PadPlacedControl] = zip(mainKinds, layout.mainCells).map { kind, cell in + PadPlacedControl(id: padID(kind), kind: kind, + frame: PadEdgeGeometry.circleFrame(inCell: cell, metrics: metrics), cell: cell) + } + let functionControls: [(item: ShortcutItem, frame: CGRect)] = + zip(groups.flatMap { $0 }, layout.functionCells).map { item, cell in + (item, PadEdgeGeometry.circleFrame(inCell: cell, metrics: metrics)) } let autoHiding = store.autoHidden let paletteChord = interaction.paletteChord @@ -1189,16 +1330,24 @@ extension ReceiverControlOverlay { let keySize = hints ? CGSize(width: 132 * metrics.scale, height: 36 * metrics.scale) : CGSize(width: metrics.item * 0.92, height: metrics.item * 0.92) - let paletteBounds = layout.canvas.insetBy(dx: PadControlMetrics.edgeMargin, dy: PadControlMetrics.edgeMargin) + let paletteBounds = Self.boundsAboveKeyboard( + layout.canvas.insetBy(dx: PadControlMetrics.edgeMargin, dy: PadControlMetrics.edgeMargin), + keyboardTop: keyboardTop) + let wheel: WheelPaletteLayout? = store.preferences.padOverlayPaletteStyle == .wheel && !actions.isEmpty + ? padWheelAnchor(mainControls: mainControls, gearFrame: gearFrame).flatMap { + WheelPaletteGeometry.layout(count: actions.count, anchor: $0, keyDiameter: metrics.item * 0.92, + spacing: metrics.gap, bounds: paletteBounds) + } : nil let grid = PadEdgeGeometry.paletteGrid(count: actions.count, key: keySize, gap: metrics.gap, edge: mainEdge, available: paletteBounds.size) + let paletteAnchor = PadEdgeGeometry.paletteAnchor(for: layout, mainEdge: mainEdge, functionEdge: functionEdge) let paletteFrame: CGRect? = paletteChord.flatMap { _ in - (layout.mainFrame ?? gearFrame).map { + (paletteAnchor ?? gearFrame).map { PadEdgeGeometry.paletteFrame(size: actions.isEmpty ? CGSize(width: 220, height: 36) : grid.size, anchor: $0, edge: mainEdge, bounds: paletteBounds, gap: metrics.gap * 2) } } - let paletteKeys: [(action: ShortcutItem, frame: CGRect)] = paletteFrame.map { frame in + let paletteKeys: [(action: ShortcutItem, frame: CGRect)] = wheel != nil ? [] : paletteFrame.map { frame in actions.enumerated().map { index, action in // Column-major along a vertical edge, row-major along a // horizontal one — so keys read outward from the rail. @@ -1209,7 +1358,12 @@ extension ReceiverControlOverlay { return (action, CGRect(origin: origin, size: keySize)) } } ?? [] - let occupied = [layout.mainFrame, gearFrame, paletteFrame].compactMap { $0 } + layout.functionFrames + // Overlay groups sit in compact floating trays; the Strip's solid + // rail already holds its controls. + let trays: [CGRect] = strip ? [] : (layout.mainSegments + layout.functionSegments) + .map { PadEdgeGeometry.circleRun(of: $0, metrics: metrics).insetBy(dx: -Self.trayPadding, dy: -Self.trayPadding) } + let occupied = layout.mainSegments.map(\.frame) + layout.functionSegments.map(\.frame) + + [gearFrame, paletteFrame].compactMap { $0 } ZStack(alignment: .topLeading) { ForEach(Array(layout.strips.enumerated()), id: \.offset) { _, strip in @@ -1217,13 +1371,10 @@ extension ReceiverControlOverlay { .frame(width: strip.frame.width, height: strip.frame.height) .position(x: strip.frame.midX, y: strip.frame.midY) } - // Halos: only floating controls and palette keys over the Mac. - if let footprintStore { - ControlBackdropLayer(store: footprintStore, - circles: (strip ? [] : mainControls.map(\.frame) + functionControls.map(\.frame) - + (gearFrame.map { [$0] } ?? [])) - + paletteKeys.map(\.frame), - capsules: hints) + ForEach(Array(trays.enumerated()), id: \.offset) { _, tray in + ControlTray() + .frame(width: tray.width, height: tray.height) + .position(x: tray.midX, y: tray.midY) .opacity(autoHiding ? 0 : 1) .allowsHitTesting(false) } @@ -1231,33 +1382,54 @@ extension ReceiverControlOverlay { padControlChip(.tray(.settings), frame: gearFrame, onRail: false, hint: nil) } ForEach(mainControls) { control in - padControlChip(control.kind, frame: control.frame, onRail: strip, - hint: hints ? padHint(control.kind) : nil) - .opacity(autoHiding ? 0 : 1) - .allowsHitTesting(!autoHiding) + Group { + if case .function(let id) = control.kind, let item = functionItem(id) { + padFunctionChip(item, frame: control.frame, onRail: true, + hint: hints ? item.title.lowercased() : nil) + } else { + padControlChip(control.kind, frame: control.frame, onRail: true, + hint: hints ? padHint(control.kind) : nil) + } + } + .opacity(autoHiding ? 0 : 1) + .allowsHitTesting(!autoHiding) } ForEach(Array(functionControls.enumerated()), id: \.element.item.id) { _, control in - padFunctionChip(control.item, frame: control.frame, onRail: strip, + padFunctionChip(control.item, frame: control.frame, onRail: true, hint: hints ? control.item.title.lowercased() : nil) .opacity(autoHiding ? 0 : 1) .allowsHitTesting(!autoHiding) } - if let paletteChord, let paletteFrame { + if let paletteChord, let wheel { + WheelPaletteView(layout: wheel, actions: actions, selectedID: interaction.selectedActionID, + centerLabel: paletteChord.symbols) + .transition(.scale(scale: 0.85).combined(with: .opacity)) + } else if let paletteChord, let paletteFrame { + if !actions.isEmpty { + ControlTray() + .frame(width: paletteFrame.width + Self.trayPadding * 2, + height: paletteFrame.height + Self.trayPadding * 2) + .position(x: paletteFrame.midX, y: paletteFrame.midY) + .allowsHitTesting(false) + } padPalette(actions: actions, keys: paletteKeys, frame: paletteFrame, chord: paletteChord, pills: hints) } - if let selected = selectedAction(in: actions), let paletteFrame { + if let selected = selectedAction(in: actions), let hudAnchor = wheel?.frame ?? paletteFrame { shortcutHUD(selected) - .position(x: paletteFrame.midX, - y: max(safeInsets.top + 30, paletteFrame.minY - 28)) + .position(x: hudAnchor.midX, + y: max(safeInsets.top + 30, hudAnchor.minY - 28)) .transition(.opacity.combined(with: .scale)) } + // One-tap Function actions (Show Desktop, Control Center) sit + // outside this region and take their own taps. Color.clear .frame(width: containerSize.width, height: containerSize.height) .contentShape(ControlRegionShape( - rects: (autoHiding ? [] : mainControls.map { $0.frame.insetBy(dx: -4, dy: -4) }) + rects: (autoHiding ? [] : mainControls.filter { !$0.kind.isOneTapAction } + .map { $0.frame.insetBy(dx: -4, dy: -4) }) + (gearFrame.map { [$0.insetBy(dx: -4, dy: -4)] } ?? []) - + (paletteFrame.map { [$0] } ?? []))) + + (wheel.map { [$0.frame.insetBy(dx: -8, dy: -8)] } ?? paletteFrame.map { [$0] } ?? []))) .gesture(controlGesture()) } .onAppear { @@ -1268,15 +1440,38 @@ extension ReceiverControlOverlay { .onChange(of: occupied) { onOccupiedFramesChange($0) } } - /// Main controls for Strip/Overlay: the profile's visible tray items, - /// with Move View before the Settings gear. - private var padMainKinds: [CustomControlKind] { - var kinds = controls.map(CustomControlKind.tray) - if padShowsMoveView, store.preferences.padShowMoveViewControl { - let index = kinds.firstIndex(of: .tray(.settings)) ?? kinds.endIndex - kinds.insert(.moveView, at: index) - } - return kinds + static let trayPadding: CGFloat = 6 + + /// Chord palettes and previews stay above the software keyboard. + static func boundsAboveKeyboard(_ bounds: CGRect, keyboardTop: CGFloat?) -> CGRect { + guard let keyboardTop, keyboardTop.isFinite, keyboardTop < bounds.maxY else { return bounds } + let limit = keyboardTop - PadControlMetrics.edgeMargin + return CGRect(x: bounds.minX, y: bounds.minY, width: bounds.width, height: max(0, limit - bounds.minY)) + } + + /// The Wheel opens around the modifier that opened it. + private func padWheelAnchor(mainControls: [PadPlacedControl], gearFrame: CGRect?) -> CGRect? { + let modifier = paletteAnchor.flatMap { anchor in interaction.activeChord.contains(anchor) ? anchor : nil } + ?? ControlModifier.allCases.first(where: interaction.activeChord.contains) + return mainControls.first { $0.kind.modifier == modifier && modifier != nil }?.frame + ?? mainControls.first { $0.kind.modifier != nil }?.frame ?? gearFrame + } + + /// The Strip/Overlay Main rail — see `PadRailComposition`: system + /// actions, then modifiers and keys, then Move View and Settings. + private var padMainGroups: [[CustomControlKind]] { + let modifiers = profile.visibleTrayItems.compactMap(\.modifier) + .filter { _ in capabilityAllows(.command) } + return PadRailComposition.mainGroups( + visibility: store.preferences.padStandardControls, + availability: .init(modifiers: modifiers, + keyboardAvailable: capabilityAllows(.keyboard), + moveViewAvailable: padShowsMoveView)) + } + + /// Any Function Tray item by id, visible in the tray or not. + private func functionItem(_ id: String) -> ShortcutItem? { + store.activeFunctionTrayProfile.allItems.first { $0.id == id } } // MARK: Custom @@ -1284,15 +1479,23 @@ extension ReceiverControlOverlay { @ViewBuilder private func customLayoutContent(_ layout: CustomControlLayout, container: CGRect) -> some View { let portrait = containerSize.height > containerSize.width - let arrangement = layout.arrangement(portrait: portrait) + let arrangement = customArrangementWithSettings(layout.arrangement(portrait: portrait)) let metrics = PadControlMetrics(scale: padMetricsScale) - let area = CustomLayoutGeometry.layoutArea(container: container, safeInsets: safeInsets) - let placements = customPlacements(arrangement) - let controlsPlaced: [PadPlacedControl] = placements.map { placement in - let frame = CustomLayoutGeometry.frame(for: placement, in: area, baseDiameter: metrics.item) - return PadPlacedControl(id: placement.id, kind: placement.kind, frame: frame, cell: frame) + // With the software keyboard up, the whole layout compresses into the + // space above it, so every control stays reachable, then returns. + let keyboardTop = keyboardVisibleRect?.maxY + let area = CustomLayoutGeometry.layoutArea( + container: Self.boundsAboveKeyboard(container, keyboardTop: keyboardTop.map { $0 + PadControlMetrics.edgeMargin }), + safeInsets: keyboardTop == nil ? safeInsets + : ControlSafeInsets(top: safeInsets.top, leading: safeInsets.leading, bottom: 0, + trailing: safeInsets.trailing)) + let allFrames = CustomLayoutGeometry.frames(for: arrangement, in: area, baseDiameter: metrics.item) + let functionItems = store.preferences.functionTrayProfile( + for: layout.functionProfile ?? store.preferences.activeFunctionTrayProfile).allItems + let item: (String) -> ShortcutItem? = { id in functionItems.first { $0.id == id } } + let controlsPlaced: [PadPlacedControl] = customPlacements(arrangement).compactMap { placement in + allFrames[placement.id].map { PadPlacedControl(id: placement.id, kind: placement.kind, frame: $0, cell: $0) } } - let functionItems = store.activeFunctionTrayProfile.items.map(\.item) let autoHiding = store.autoHidden let paletteChord = interaction.paletteChord let actions = paletteChord.map(profile.actions(for:)) ?? [] @@ -1303,13 +1506,19 @@ extension ReceiverControlOverlay { let anchorControl = anchorModifier.flatMap { modifier in controlsPlaced.first { $0.kind.modifier == modifier } } - let anchorStyle = anchorModifier.flatMap { arrangement.placement(for: $0)?.palette } ?? PalettePresentation() + let anchorStyle = anchorControl.flatMap { control in + arrangement.placements.first { $0.id == control.id }?.palette + } ?? PalettePresentation() + let wheel: WheelPaletteLayout? = anchorStyle.shape == .wheel && paletteChord != nil && !actions.isEmpty + ? anchorControl.flatMap { + WheelPaletteGeometry.layout(count: actions.count, anchor: $0.frame, keyDiameter: keyDiameter, + spacing: metrics.gap, bounds: area) + } : nil let paletteKeys: [(action: ShortcutItem, frame: CGRect)] = { - guard paletteChord != nil, let anchorControl else { return [] } - let points = CustomLayoutGeometry.paletteCenters( - count: actions.count, anchor: anchorControl.frame, style: anchorStyle, area: area, - baseDiameter: metrics.item, spacing: metrics.gap, - obstacles: controlsPlaced.filter { $0.id != anchorControl.id }.map(\.frame)) + guard paletteChord != nil, wheel == nil, let anchorControl else { return [] } + let points = CustomLayoutGeometry.palettePoints( + count: actions.count, anchorID: anchorControl.id, arrangement: arrangement, + frames: allFrames, area: area, baseDiameter: metrics.item, spacing: metrics.gap) return zip(actions, points).map { action, point in (action, CGRect(x: point.x - keyDiameter / 2, y: point.y - keyDiameter / 2, width: keyDiameter, height: keyDiameter)) @@ -1318,7 +1527,7 @@ extension ReceiverControlOverlay { let assistState = TwoHandAssist.state( enabled: layout.twoHandAssist && !padCollapsed && store.preferences.allowInput, interaction: interaction) - let assistIdleItems = layout.assistActionIDs.compactMap { id in functionItems.first { $0.id == id } } + let assistIdleItems = layout.assistActionIDs.compactMap(item) let assistCount: Int = { switch assistState { case .hidden: return 0 @@ -1326,39 +1535,37 @@ extension ReceiverControlOverlay { case .helper: return TwoHandAssist.helperModifiers.count } }() - let assistPoints = TwoHandAssist.helperPoints(count: assistCount, clusterCentroid: arrangement.clusterCentroid, - area: area, itemDiameter: metrics.item, spacing: metrics.gap * 1.5) + let assistPoints = TwoHandAssist.helperPoints(count: assistCount, arrangement: arrangement, frames: allFrames, + area: area, itemDiameter: metrics.item, spacing: metrics.gap) let assistFrames = assistPoints.map { CGRect(x: $0.x - metrics.item / 2, y: $0.y - metrics.item / 2, width: metrics.item, height: metrics.item) } - let regionControls = controlsPlaced.filter { - if case .function = $0.kind { return false } - return true - } + let regionControls = controlsPlaced.filter { !$0.kind.isOneTapAction } let occupied = controlsPlaced.map(\.frame) + paletteKeys.map(\.frame) + assistFrames + + (wheel.map { [$0.frame] } ?? []) ZStack(alignment: .topLeading) { - if let footprintStore { - ControlBackdropLayer(store: footprintStore, - circles: (autoHiding ? [] : controlsPlaced.map(\.frame) + assistFrames) - + paletteKeys.map(\.frame), - capsules: false) - .allowsHitTesting(false) - } ForEach(controlsPlaced) { control in Group { - if case .function(let id) = control.kind { - if let item = functionItems.first(where: { $0.id == id }) { + switch control.kind { + case .function(let id): + if let item = item(id) { padFunctionChip(item, frame: control.frame, onRail: false, hint: nil) } - } else { + case .shortcut(let item): + padFunctionChip(item, frame: control.frame, onRail: false, hint: nil) + case .tray, .moveView: padControlChip(control.kind, frame: control.frame, onRail: false, hint: nil) } } .opacity(autoHiding && control.kind != .tray(.settings) ? 0 : 1) .allowsHitTesting(!autoHiding || control.kind == .tray(.settings)) } - if let paletteChord { + if let paletteChord, let wheel { + WheelPaletteView(layout: wheel, actions: actions, selectedID: interaction.selectedActionID, + centerLabel: paletteChord.symbols) + .transition(.scale(scale: 0.85).combined(with: .opacity)) + } else if let paletteChord { if paletteKeys.isEmpty, let anchorControl { emptyPaletteLabel(paletteChord) .position(x: anchorControl.frame.midX, @@ -1384,7 +1591,8 @@ extension ReceiverControlOverlay { .contentShape(ControlRegionShape( rects: regionControls.filter { !autoHiding || $0.kind == .tray(.settings) } .map { $0.frame.insetBy(dx: -4, dy: -4) } - + paletteKeys.map { $0.frame.insetBy(dx: -3, dy: -3) })) + + paletteKeys.map { $0.frame.insetBy(dx: -3, dy: -3) } + + (wheel.map { [$0.frame.insetBy(dx: -8, dy: -8)] } ?? []))) .gesture(controlGesture()) } .animation(.snappy(duration: 0.22), value: assistState) @@ -1395,24 +1603,28 @@ extension ReceiverControlOverlay { .onChange(of: occupied) { onOccupiedFramesChange($0) } } - /// The placements to render: those whose control can act right now. /// Settings is permanent chrome, so a layout without it still gets one /// in its corner. + private func customArrangementWithSettings(_ arrangement: CustomControlArrangement) -> CustomControlArrangement { + guard !arrangement.placements.contains(where: { $0.kind == .tray(.settings) }) else { return arrangement } + var result = arrangement + let corner = arrangement.corner.unitPoint + result.placements.append(CustomControlPlacement(id: "settings", kind: .tray(.settings), + x: Double(corner.x), y: Double(corner.y), size: 0.9)) + return result + } + + /// The placements to render: those whose control can act right now. private func customPlacements(_ arrangement: CustomControlArrangement) -> [CustomControlPlacement] { let allowed = Set(interactiveTrayItems) - var result = arrangement.placements.filter { placement in + return arrangement.placements.filter { placement in switch placement.kind { case .tray(let item): return allowed.contains(item) case .function: return store.preferences.functionTrayCanBeShown && !padCollapsed + case .shortcut: return store.preferences.trayCanBeShown && !padCollapsed case .moveView: return padShowsMoveView } } - if !result.contains(where: { $0.kind == .tray(.settings) }) { - let corner = arrangement.corner.unitPoint - result.append(CustomControlPlacement(id: "settings", kind: .tray(.settings), - x: Double(corner.x), y: Double(corner.y), size: 0.9)) - } - return result } /// The opposite side: idle Function actions, or — while a chord is in @@ -1456,6 +1668,7 @@ extension ReceiverControlOverlay { case .tray(let item): return "tray-\(item.rawValue)" case .function(let id): return "function-\(id)" case .moveView: return "moveView" + case .shortcut(let item): return "shortcut-\(item.id)" } } @@ -1474,6 +1687,7 @@ extension ReceiverControlOverlay { case .settings: return String(localized: "settings", comment: "Strip control hint.") } case .function(let id): return id + case .shortcut(let item): return item.title.lowercased() case .moveView: return String(localized: "move view", comment: "Strip control hint.") } } @@ -1492,7 +1706,7 @@ extension ReceiverControlOverlay { || interaction.temporaryModifiers.contains(modifier) || interaction.phase == .pressed(modifier) case .moveView: return store.moveViewActive - case .function: return false + case .function, .shortcut: return false } }() let frameID: String = { @@ -1501,6 +1715,7 @@ extension ReceiverControlOverlay { return item.modifier.map { "modifier:\($0.rawValue)" } ?? "tray:\(item.rawValue)" case .moveView: return "control:moveView" case .function(let id): return "function:\(id)" + case .shortcut(let item): return "shortcut:\(item.id)" } }() VStack(spacing: 2) { @@ -1516,7 +1731,7 @@ extension ReceiverControlOverlay { case .moveView: Image(systemName: "arrow.up.and.down.and.arrow.left.and.right") .font(.system(size: diameter * 0.38, weight: .semibold)) - case .function: + case .function, .shortcut: EmptyView() } } @@ -1545,6 +1760,7 @@ extension ReceiverControlOverlay { case .tray(let item): return item.title case .moveView: return String(localized: "Move View") case .function(let id): return id + case .shortcut(let item): return item.title } } @@ -1552,13 +1768,7 @@ extension ReceiverControlOverlay { private func padFunctionChip(_ item: ShortcutItem, frame: CGRect, onRail: Bool, hint: String?) -> some View { let diameter = frame.width return VStack(spacing: 2) { - Group { - if let systemImage = item.systemImage { - Image(systemName: systemImage).font(.system(size: diameter * 0.4, weight: .semibold)) - } else { - Text(item.displayKey).font(.system(size: diameter * 0.34, weight: .semibold)) - } - } + ShortcutButtonFaceView(face: item.face, diameter: diameter) .foregroundStyle(.white) .frame(width: diameter, height: diameter) .background(PadChip(selected: false, onRail: onRail)) @@ -1664,21 +1874,157 @@ extension ReceiverControlOverlay { } } -/// The iPad control face. On a Strip rail it reads like a key on the rail; -/// floating, a darker disc keeps white glyphs legible over any content — -/// the soft blur comes from `ControlBackdropLayer`, only where the Mac is -/// actually behind the control. -private struct PadChip: View { +/// The iPad control face, in the same restrained neutral language as the +/// trackpad surface: a quiet key inside a rail or tray, and a solid neutral +/// disc when floating on its own (Custom) — no glow, no halo. +struct PadChip: View { let selected: Bool let onRail: Bool var body: some View { Circle() .fill(selected ? Color.white.opacity(0.92) - : (onRail ? Color.white.opacity(0.1) : Color.black.opacity(0.42))) - .overlay(Circle().strokeBorder(.white.opacity(selected ? 0.5 : (onRail ? 0.1 : 0.22)), + : (onRail ? Color.white.opacity(0.12) : Color(white: 0.2).opacity(0.88))) + .overlay(Circle().strokeBorder(.white.opacity(selected ? 0.5 : (onRail ? 0.08 : 0.16)), lineWidth: 0.75)) - .shadow(color: .black.opacity(onRail ? 0 : 0.25), radius: 4, y: 1) + .shadow(color: .black.opacity(onRail ? 0 : 0.22), radius: 3, y: 1) + } +} + +/// A compact floating tray holding one Overlay control group (or a chord +/// palette): rounded, dark and translucent, with a hairline edge. +struct ControlTray: View { + var body: some View { + GeometryReader { proxy in + let radius = min(proxy.size.width, proxy.size.height) / 2 + RoundedRectangle(cornerRadius: radius, style: .continuous) + .fill(.ultraThinMaterial) + .overlay(RoundedRectangle(cornerRadius: radius, style: .continuous).fill(Color.black.opacity(0.32))) + .overlay(RoundedRectangle(cornerRadius: radius, style: .continuous) + .strokeBorder(Color.white.opacity(0.12), lineWidth: 0.75)) + .shadow(color: .black.opacity(0.25), radius: 8, y: 2) + .environment(\.colorScheme, .dark) + } + } +} + +// MARK: - Wheel palette + +/// The Wheel on screen, for hit-testing its wedges in `controlGesture`. +struct WheelHitTarget: Equatable { + var layout: WheelPaletteLayout + var actionIDs: [String] +} + +struct WheelHitPreference: PreferenceKey { + static let defaultValue: WheelHitTarget? = nil + static func reduce(value: inout WheelHitTarget?, nextValue: () -> WheelHitTarget?) { + value = nextValue() ?? value + } +} + +/// A ring of wedge segments around a center disc showing the held chord — +/// the Wheel palette. Drawn in the overlay's coordinate space. +struct WheelPaletteView: View { + let layout: WheelPaletteLayout + let actions: [ShortcutItem] + let selectedID: String? + let centerLabel: String + + var body: some View { + ZStack(alignment: .topLeading) { + ForEach(Array(zip(layout.segments.indices, actions)), id: \.1.id) { index, action in + let segment = layout.segments[index] + let selected = action.id == selectedID + WheelWedge(center: layout.center, inner: layout.innerRadius, outer: layout.outerRadius, + start: segment.startAngle, end: segment.endAngle) + .fill(selected ? Color.white.opacity(0.92) : Color(white: 0.18).opacity(0.9)) + WheelWedge(center: layout.center, inner: layout.innerRadius, outer: layout.outerRadius, + start: segment.startAngle, end: segment.endAngle) + .stroke(selected ? Color.accentColor : Color.white.opacity(0.14), lineWidth: selected ? 2 : 0.75) + ShortcutButtonFaceView(face: action.face, diameter: min(layout.outerRadius - layout.innerRadius, 52)) + .foregroundStyle(selected ? Color.black : Color.white) + .frame(width: layout.outerRadius - layout.innerRadius, + height: layout.outerRadius - layout.innerRadius) + .scaleEffect(selected ? 1.12 : 1) + .position(segment.labelPoint) + .accessibilityLabel(action.title) + } + Circle() + .fill(Color(white: 0.12).opacity(0.92)) + .overlay(Circle().strokeBorder(Color.white.opacity(0.14), lineWidth: 0.75)) + .overlay(Text(centerLabel).font(.system(size: 17, weight: .semibold)).foregroundStyle(.white)) + .frame(width: (layout.innerRadius - 4) * 2, height: (layout.innerRadius - 4) * 2) + .position(layout.center) + .accessibilityHidden(true) + } + .shadow(color: .black.opacity(0.25), radius: 8, y: 2) + .allowsHitTesting(false) + .preference(key: WheelHitPreference.self, value: WheelHitTarget(layout: layout, actionIDs: actions.map(\.id))) + } +} + +private struct WheelWedge: Shape { + let center: CGPoint + let inner: CGFloat + let outer: CGFloat + let start: CGFloat + let end: CGFloat + + func path(in rect: CGRect) -> Path { + // A hairline of air between wedges. + let inset = min(0.04, (end - start) * 0.08) + var path = Path() + path.addArc(center: center, radius: outer, startAngle: .radians(start + inset), + endAngle: .radians(end - inset), clockwise: false) + path.addArc(center: center, radius: inner, startAngle: .radians(end - inset), + endAngle: .radians(start + inset), clockwise: true) + path.closeSubpath() + return path + } +} + +/// Runs a one-tap action over the existing wire in plan order. Single +/// actions go out at once; a sequence's pauses run on the main actor, and +/// stop between steps if input is revoked — each step's keys are sent +/// down and up together, so nothing is left held. +@MainActor +enum OneTapActionRunner { + static func run(_ action: ControlAction, receiver: StreamReceiver, inputAllowed: @escaping @MainActor () -> Bool) { + let operations = ControlActionPlan.operations(for: action) + guard !operations.isEmpty, inputAllowed() else { return } + let pauses = operations.contains { if case .pause = $0 { return true } else { return false } } + guard pauses else { + operations.forEach { perform($0, receiver: receiver) } + return + } + Task { @MainActor in + for operation in operations { + if case .pause(let milliseconds) = operation { + try? await Task.sleep(nanoseconds: UInt64(milliseconds) * 1_000_000) + continue + } + guard inputAllowed() else { return } + perform(operation, receiver: receiver) + } + } + } + + private static func perform(_ operation: ControlActionOperation, receiver: StreamReceiver) { + switch operation { + case .press(let usage, let modifiers): + receiver.sendKeyboardPress(usage: usage, modifiers: modifiers) + case .key(let event): + switch event.phase { + case .down: receiver.sendKeyboardDown(usage: event.usage, modifiers: event.modifiers) + case .up: receiver.sendKeyboardUp(usage: event.usage, modifiers: event.modifiers) + } + case .gesture(let name): + guard ReceiverGesture(rawValue: name) != nil else { return } + receiver.sendGesture(name: name) + case .pause: + break + } } } @@ -1706,49 +2052,6 @@ private struct PadStripBackground: View { } } -/// Small, soft material halos under floating controls — drawn only where a -/// control actually covers rendered Mac content (see -/// `ControlBackdropPolicy`), and never as one big panel. The only view that -/// observes the per-frame display footprint. -private struct ControlBackdropLayer: View { - @ObservedObject var store: DisplayFootprintStore - let circles: [CGRect] - /// Palette pills (Strip hints) are capsules, not circles. - let capsules: Bool - - private static let spread: CGFloat = 14 - - var body: some View { - ZStack(alignment: .topLeading) { - ForEach(Array(circles.enumerated()), id: \.offset) { _, rect in - let strength = ControlBackdropPolicy.haloStrength(for: rect, footprint: store.footprint) - if strength > 0 { - let size = CGSize(width: rect.width + Self.spread * 2, height: rect.height + Self.spread * 2) - halo(size: size, capsule: capsules && rect.width > rect.height * 1.5) - .opacity(strength) - .position(x: rect.midX, y: rect.midY) - } - } - } - .animation(.easeOut(duration: 0.15), value: store.footprint) - } - - @ViewBuilder - private func halo(size: CGSize, capsule: Bool) -> some View { - let fade = RadialGradient(colors: [.black, .black, .black.opacity(0)], center: .center, - startRadius: 0, endRadius: max(size.width, size.height) / 2) - Group { - if capsule { - Capsule().fill(.ultraThinMaterial) - } else { - Circle().fill(.ultraThinMaterial) - } - } - .frame(width: size.width, height: size.height) - .mask(fade) - } -} - private struct ControlRegionShape: Shape { var rects: [CGRect] @@ -1804,157 +2107,116 @@ struct ControlProfileEditor: View { } } -/// Editor for the Function Tray's own, independent profile — visibility -/// and order only; items themselves aren't user-authored this milestone -/// (see `FunctionTrayProfile.canonical`'s doc on staying generic for -/// future actions). -struct FunctionTrayProfileEditor: View { +/// The actions in one modifier chord's palette — the same data the Strip, +/// Overlay, Custom (every palette style, Wheel included) and the editor's +/// Preview all show. Add keyboard chords, sequences, Function actions or +/// system actions; remove, reorder, and change each one's face separately +/// from what it does. +struct ChordPaletteEditor: View { @ObservedObject var store: ReceiverControlStore + let chord: ModifierChord + let haptics: ReceiverHaptics + @State private var editing: ShortcutEditorRequest? + + private var actions: [ShortcutItem] { store.activeProfile.actions(for: chord) } var body: some View { List { - Section("Function Tray") { - ForEach(store.activeFunctionTrayProfile.items) { configuration in - Toggle(configuration.item.title, isOn: Binding( - get: { configuration.isVisible }, - set: { visible in - store.updateActiveFunctionTrayProfile { profile in - if let index = profile.items.firstIndex(where: { $0.id == configuration.id }) { - profile.items[index].isVisible = visible - } + Section { + ForEach(actions) { action in + Button { + editing = ShortcutEditorRequest(placementID: action.id, item: action) + } label: { + HStack(spacing: 12) { + ShortcutButtonFaceView(face: action.face, diameter: 30) + .foregroundStyle(.white) + .frame(width: 30, height: 30) + .background(Circle().fill(Color(white: 0.22))) + VStack(alignment: .leading, spacing: 1) { + Text(action.title).foregroundStyle(.primary) + Text(action.keysDescription).font(.caption).foregroundStyle(.secondary).lineLimit(1) } - })) + } + } } .onMove { source, destination in - store.updateActiveFunctionTrayProfile { $0.moveItems(from: source, to: destination) } + store.updateActiveProfile { $0.moveActions(for: chord, from: source, to: destination) } } - } - } - .navigationTitle("Edit \(store.preferences.activeFunctionTrayProfile.title)") - .toolbar { EditButton() } - } -} - -struct ChordPaletteEditor: View { - @ObservedObject var store: ReceiverControlStore - let chord: ModifierChord - let haptics: ReceiverHaptics - @State private var adding = false - - var body: some View { - List { - ForEach(store.activeProfile.actions(for: chord)) { action in - NavigationLink { - ShortcutItemEditor(store: store, chord: chord, itemID: action.id) - } label: { - LabeledContent(action.title, value: action.displayKey) + .onDelete { offsets in + store.updateActiveProfile { profile in + var actions = profile.actions(for: chord) + actions.remove(atOffsets: offsets) + profile.setActions(actions, for: chord) + } } + } footer: { + Text("Hold or latch \(chord.symbols) to show these. Tap one to change what it sends or how it looks; drag to reorder.") } - .onMove { source, destination in - store.updateActiveProfile { $0.moveActions(for: chord, from: source, to: destination) } - } - .onDelete { offsets in - store.updateActiveProfile { profile in - var actions = profile.actions(for: chord) - actions.remove(atOffsets: offsets) - profile.setActions(actions, for: chord) + Section { + Button("Restore This Palette") { + let defaults = ControlProfile.canonical().actions(for: chord) + store.updateActiveProfile { $0.setActions(defaults, for: chord) } + haptics.play(.reset) } } } .navigationTitle(chord.displayName) .toolbar { - EditButton() - Button { adding = true } label: { Image(systemName: "plus") } - } - .sheet(isPresented: $adding) { - NavigationStack { - NewShortcutEditor(store: store, chord: chord, isPresented: $adding) + ToolbarItemGroup(placement: .primaryAction) { + addMenu + EditButton() } } - .safeAreaInset(edge: .bottom) { - Button("Restore This Palette") { - let defaults = ControlProfile.canonical().actions(for: chord) - store.updateActiveProfile { $0.setActions(defaults, for: chord) } - haptics.play(.reset) + .sheet(item: $editing) { request in + ShortcutButtonEditor(initial: request.item, defaultModifiers: chord.modifiers) { item in + store.updateActiveProfile { profile in + var actions = profile.actions(for: chord) + if let index = actions.firstIndex(where: { $0.id == item.id }) { + actions[index] = item + } else { + actions.append(item) + } + profile.setActions(actions, for: chord) + } } - .buttonStyle(.bordered).padding(8) } } -} - -private let editableShortcutKeys: [(String, Int)] = { - let letters = zip(Array("ABCDEFGHIJKLMNOPQRSTUVWXYZ"), 4...29).map { (String($0), $1) } - return letters + [("1", 30), ("2", 31), ("3", 32), ("4", 33), ("5", 34), - ("Esc", 41), ("Space", 44), ("Return", 40), ("Tab", 43)] -}() - -struct ShortcutItemEditor: View { - @ObservedObject var store: ReceiverControlStore - let chord: ModifierChord - let itemID: String - - private var item: ShortcutItem? { - store.activeProfile.actions(for: chord).first(where: { $0.id == itemID }) - } - var body: some View { - Form { - if let item { - TextField("Name", text: itemBinding(item, keyPath: \.title)) - Picker("Key", selection: usageBinding(item)) { - ForEach(editableShortcutKeys, id: \.1) { Text($0.0).tag($0.1) } + private var addMenu: some View { + Menu { + Button { editing = ShortcutEditorRequest(placementID: nil, item: nil) } label: { + Label("Shortcut or Sequence…", systemImage: "keyboard") + } + Menu("Function Action") { + ForEach(store.activeFunctionTrayProfile.allItems) { item in + Button(item.title) { append(copy(of: item)) } } - Section("Modifiers") { - ForEach(ControlModifier.allCases) { modifier in - Toggle(modifier.title, isOn: modifierBinding(item, modifier)) + } + Menu("System Action") { + ForEach(ReceiverGesture.oneTapActions, id: \.self) { gesture in + Button(gesture.title) { + append(ShortcutItem(id: UUID().uuidString, title: gesture.title, gesture: gesture, + systemImage: "sparkles")) } } } - } - .navigationTitle("Shortcut") - } - - private func update(_ transform: (inout ShortcutItem) -> Void) { - store.updateActiveProfile { profile in - var actions = profile.actions(for: chord) - guard let index = actions.firstIndex(where: { $0.id == itemID }) else { return } - transform(&actions[index]) - profile.setActions(actions, for: chord) + } label: { + Label("Add Action", systemImage: "plus") } } - private func itemBinding(_ item: ShortcutItem, keyPath: WritableKeyPath) -> Binding { - Binding(get: { self.item?[keyPath: keyPath] ?? item[keyPath: keyPath] }, - set: { value in update { $0[keyPath: keyPath] = value } }) + /// Palette entries own their definition; a copied Function action is + /// independent of the tray it came from. + private func copy(of item: ShortcutItem) -> ShortcutItem { + var copy = item + copy.id = UUID().uuidString + return copy } - private func usageBinding(_ item: ShortcutItem) -> Binding { - Binding(get: { - guard let current = self.item, case .keyboardShortcut(let shortcut) = current.action else { return 4 } - return shortcut.usage - }, set: { usage in - update { - let key = editableShortcutKeys.first(where: { $0.1 == usage })?.0 ?? "Key" - $0.displayKey = key - $0.action = .keyboardShortcut(KeyboardShortcut(usage: usage, modifiers: chord)) - } - }) - } - - private func modifierBinding(_ item: ShortcutItem, _ modifier: ControlModifier) -> Binding { - Binding(get: { - let current = self.item ?? item - guard case .keyboardShortcut(let shortcut) = current.action else { return false } - return shortcut.modifiers.contains(modifier) - }, set: { enabled in - update { - guard case .keyboardShortcut(var shortcut) = $0.action else { return } - var values = shortcut.modifiers.modifiers - if enabled { values.insert(modifier) } else { values.remove(modifier) } - shortcut.modifiers = ModifierChord(values) - $0.action = .keyboardShortcut(shortcut) - } - }) + private func append(_ item: ShortcutItem) { + store.updateActiveProfile { profile in + profile.setActions(profile.actions(for: chord) + [item], for: chord) + } } } @@ -2065,35 +2327,21 @@ struct AppGestureCommandEditor: View { } } -struct NewShortcutEditor: View { - @ObservedObject var store: ReceiverControlStore - let chord: ModifierChord - @Binding var isPresented: Bool - @State private var title = "Shortcut" - @State private var usage = 4 +/// A one-tap button's face: its SF Symbol, or its text, emoji or keys. +struct ShortcutButtonFaceView: View { + let face: ShortcutButtonFace + let diameter: CGFloat var body: some View { - Form { - TextField("Name", text: $title) - Picker("Key", selection: $usage) { - ForEach(editableShortcutKeys, id: \.1) { Text($0.0).tag($0.1) } - } - } - .navigationTitle("Add Shortcut") - .toolbar { - ToolbarItem(placement: .cancellationAction) { Button("Cancel") { isPresented = false } } - ToolbarItem(placement: .confirmationAction) { - Button("Add") { - let key = editableShortcutKeys.first(where: { $0.1 == usage })?.0 ?? "Key" - store.updateActiveProfile { profile in - var actions = profile.actions(for: chord) - actions.append(ShortcutItem(title: title, displayKey: key, - usage: usage, modifiers: chord)) - profile.setActions(actions, for: chord) - } - isPresented = false - } - } + switch face { + case .symbol(let name): + Image(systemName: name).font(.system(size: diameter * 0.4, weight: .semibold)) + case .text(let text): + Text(text) + .font(.system(size: diameter * (text.count > 2 ? 0.28 : 0.36), weight: .semibold, design: .rounded)) + .lineLimit(1) + .minimumScaleFactor(0.5) + .padding(.horizontal, diameter * 0.08) } } } diff --git a/iOS/ReceiverSettings.swift b/iOS/ReceiverSettings.swift index fa952a26..73ead3b9 100644 --- a/iOS/ReceiverSettings.swift +++ b/iOS/ReceiverSettings.swift @@ -20,30 +20,49 @@ struct SettingsView: View { @Environment(\.horizontalSizeClass) private var horizontalSizeClass @State private var selection: MobileSettingsCategory? @State private var columnVisibility = NavigationSplitViewVisibility.all + @State private var query = "" + /// The setting a search result pointed at; its page scrolls to it and + /// tints it briefly. + @State private var highlight: String? + + private static var debugBuild: Bool { + #if DEBUG + true + #else + false + #endif + } var body: some View { NavigationSplitView(columnVisibility: $columnVisibility) { List(selection: $selection) { - ForEach(MobileSettingsCategory.Group.allCases, id: \.self) { group in - Section { - ForEach(MobileSettingsCategory.visibleInThisBuild.filter { $0.group == group }) { category in - NavigationLink(value: category) { - Label(category.title, systemImage: category.systemImage) + if query.trimmingCharacters(in: .whitespaces).isEmpty { + ForEach(MobileSettingsCategory.Group.allCases, id: \.self) { group in + Section { + ForEach(MobileSettingsCategory.visibleInThisBuild.filter { $0.group == group }) { category in + NavigationLink(value: category) { + Label(category.title, systemImage: category.systemImage) + } } } } + } else { + searchResults } } .navigationTitle("Settings") + .searchable(text: $query, placement: .sidebar, prompt: Text("Search Settings")) .toolbar { ToolbarItem(placement: .confirmationAction) { Button("Done") { dismiss() } } } + .modifier(HidesSidebarToggle()) } detail: { NavigationStack { if let selection { page(for: selection) + .environment(\.settingsHighlight, highlight) } else { Text("Select a category") .foregroundStyle(.secondary) @@ -54,11 +73,50 @@ struct SettingsView: View { .id(selection) } .navigationSplitViewStyle(.balanced) + // Settings owns its system chrome: the receiver surface behind it + // hides the status bar and home indicator, and a presentation that + // inherited that would lay its navigation bar out under the top + // system region. With the status bar shown here, the split view's + // own safe area keeps every header and toolbar below it — through + // rotation and re-presentation alike. + .statusBarHidden(false) + .persistentSystemOverlays(.automatic) .onAppear { if selection == nil { selection = MobileSettingsCategory.initialSelection(regularWidth: horizontalSizeClass == .regular) } } + .task(id: highlight) { + guard highlight != nil else { return } + try? await Task.sleep(nanoseconds: 2_500_000_000) + withAnimation(.easeOut(duration: 0.4)) { highlight = nil } + } + } + + @ViewBuilder + private var searchResults: some View { + let results = MobileSettingsSearchIndex.search(query, isPad: controlStore.isPad, debug: Self.debugBuild) + if results.isEmpty { + Text("No Results").foregroundStyle(.secondary) + } else { + ForEach(results) { item in + Button { + highlight = item.isCategory ? nil : item.id + selection = item.category + } label: { + Label { + VStack(alignment: .leading, spacing: 2) { + Text(item.title).foregroundStyle(.primary) + if !item.isCategory { + Text(item.category.title).font(.footnote).foregroundStyle(.secondary) + } + } + } icon: { + Image(systemName: item.category.systemImage) + } + } + } + } } @ViewBuilder @@ -106,6 +164,64 @@ private func preferenceBinding(_ store: ReceiverControlStore, set: { value in store.update { $0[keyPath: keyPath] = value } }) } +/// The setting a search result points at — see `SettingsForm`. +private struct SettingsHighlightKey: EnvironmentKey { + static let defaultValue: String? = nil +} + +extension EnvironmentValues { + var settingsHighlight: String? { + get { self[SettingsHighlightKey.self] } + set { self[SettingsHighlightKey.self] = newValue } + } +} + +/// A settings page: a `Form` that scrolls to, and briefly tints, the row a +/// search result pointed at (rows opt in with `settingsAnchor`). +private struct SettingsForm: View { + @Environment(\.settingsHighlight) private var highlight + @ViewBuilder let content: Content + + var body: some View { + ScrollViewReader { proxy in + Form { content } + .task(id: highlight) { + guard let highlight else { return } + // Let the pushed page finish laying out first. + try? await Task.sleep(nanoseconds: 350_000_000) + withAnimation { proxy.scrollTo(highlight, anchor: .center) } + } + } + } +} + +private struct SettingsAnchor: ViewModifier { + let id: String + @Environment(\.settingsHighlight) private var highlight + + func body(content: Content) -> some View { + content + .id(id) + .listRowBackground(highlight == id ? Color.accentColor.opacity(0.18) : nil) + } +} + +private extension View { + /// Marks a row as the target of the search item with this id. + func settingsAnchor(_ id: String) -> some View { modifier(SettingsAnchor(id: id)) } +} + +/// Settings' sidebar is its navigation, so it never collapses away. +private struct HidesSidebarToggle: ViewModifier { + func body(content: Content) -> some View { + if #available(iOS 17.0, *) { + content.toolbar(removing: .sidebarToggle) + } else { + content + } + } +} + /// A setting's secondary explanation line. private struct SettingNote: View { let text: LocalizedStringKey @@ -122,12 +238,13 @@ private struct GeneralSettingsPage: View { @ObservedObject var controlStore: ReceiverControlStore var body: some View { - Form { + SettingsForm { Section { // Isolated from the receiver: a TextField that rebuilds // mid-tap loses focus (the "tap twice to edit" bug). This // subview owns its focus and doesn't observe the receiver. DeviceNameField { receiver.setServiceName($0) } + .settingsAnchor("deviceName") } header: { Text("Name") } footer: { @@ -135,6 +252,7 @@ private struct GeneralSettingsPage: View { } Section { AutoReconnectToggle(receiver: receiver) + .settingsAnchor("autoReconnect") } header: { Text("Connection") } footer: { @@ -142,6 +260,7 @@ private struct GeneralSettingsPage: View { } Section { Toggle("Haptics", isOn: preferenceBinding(controlStore, \.hapticsEnabled)) + .settingsAnchor("haptics") } } .navigationTitle(MobileSettingsCategory.general.title) @@ -181,7 +300,7 @@ private struct DisplaySettingsPage: View { @Environment(\.dismiss) private var dismiss var body: some View { - Form { + SettingsForm { Section { VStack(alignment: .leading, spacing: 4) { Toggle("Video", isOn: Binding( @@ -190,8 +309,10 @@ private struct DisplaySettingsPage: View { .disabled(!receiver.connected || !receiver.macSupportsVideoControl) SettingNote("Turning video off keeps the connection, keyboard, controls, and selected input mode active.") } + .settingsAnchor("video") displayModeRows Toggle("Show Surface Grid", isOn: preferenceBinding(controlStore, \.showSurfaceGrid)) + .settingsAnchor("surfaceGrid") } Section { VStack(alignment: .leading, spacing: 4) { @@ -201,6 +322,7 @@ private struct DisplaySettingsPage: View { Text(note).font(.footnote).foregroundStyle(.secondary) } } + .settingsAnchor("pictureInPicture") if pictureInPicture.isShowingWindow { Button("Stop Picture in Picture") { pictureInPicture.stop() } } else if pictureInPicture.availability == .available { @@ -234,6 +356,7 @@ private struct DisplaySettingsPage: View { .disabled(!receiver.connected || receiver.macProtocolVersion < WireProtocol.displayModeWireVersion || receiver.pendingDisplayMode != nil) + .settingsAnchor("displayMode") if let pendingMode = receiver.pendingDisplayMode { LabeledContent("Switching to \(pendingMode.title)") { ProgressView() @@ -363,6 +486,7 @@ private struct DisplaySettingsPage: View { Text(profile.label).tag(profile) } } + .settingsAnchor("streamingProfile") Text(receiver.streamingProfile.explanation).font(.footnote).foregroundStyle(.secondary) if receiver.streamingProfile == .custom { let frameRateBinding = Binding( @@ -403,6 +527,7 @@ private struct DisplaySettingsPage: View { receiver.requestMaxFPS(preference) })) .disabled(!receiver.connected || receiver.pendingMaxFPS != nil) + .settingsAnchor("maxFPS") if current.enabled { let tiers = MaxFPSPicker.tiers(reported: receiver.lastMaxFPSState?.availableTiers) Picker("Maximum FPS", selection: Binding( @@ -452,18 +577,25 @@ private struct InputSettingsPage: View { @AppStorage("zoomWhileTyping") private var zoomWhileTyping = true var body: some View { - Form { + SettingsForm { Section { // Never optimistic: this only ever reflects the Mac's last // CONFIRMED decision (`ReceiverControlStore.sessionInputState`). LabeledContent("Control", value: controlStore.sessionInputState.receiverDisplayText) + .settingsAnchor("control") switch controlStore.sessionInputState { case .off, .notAllowed: - Button("Request Control") { receiver.requestAllowInput(true) } + Button("Request Control") { + controlStore.noteInputTurnedOffByUser(false) + receiver.requestAllowInput(true) + } case .requesting: EmptyView() case .allowed: - Button("Turn Off", role: .destructive) { receiver.requestAllowInput(false) } + Button("Turn Off", role: .destructive) { + controlStore.noteInputTurnedOffByUser(true) + receiver.requestAllowInput(false) + } case .requestsDisabled: SettingNote("This Mac isn't accepting control requests from this device right now. Enable it from the Mac's Input settings.") } @@ -475,6 +607,7 @@ private struct InputSettingsPage: View { ForEach(PointerInputMode.allCases) { Text($0.title).tag($0) } } .pickerStyle(.segmented) + .settingsAnchor("inputMode") Text(controlStore.preferences.inputMode.explanation) .font(.footnote) .foregroundStyle(.secondary) @@ -504,11 +637,22 @@ private struct InputSettingsPage: View { Text("Fast") } .font(.footnote) + .settingsAnchor("trackpadSensitivity") } footer: { Text("Sensitivity only affects Trackpad mode's one-finger pointer movement.") } + Section { + Toggle("Invert Vertical Scrolling", isOn: preferenceBinding(controlStore, \.invertVerticalScroll)) + .settingsAnchor("scrollDirection") + Toggle("Invert Horizontal Scrolling", isOn: preferenceBinding(controlStore, \.invertHorizontalScroll)) + } header: { + Text("Scrolling") + } footer: { + Text("On, content moves with your fingers, like a Mac trackpad’s natural scrolling. Turn an axis off to reverse just that direction; diagonal scrolls follow both. Moving your own view of the Mac isn’t affected.") + } Section { Toggle("Zoom While Typing", isOn: $zoomWhileTyping) + .settingsAnchor("zoomWhileTyping") } header: { Text("Keyboard") } footer: { @@ -534,6 +678,7 @@ private struct InputSettingsPage: View { SettingNote("Scroll with one finger in any direction. Hold a title bar, then drag, to move its window. Elsewhere, touch and hold for normal Direct Touch. Falls back to Direct Touch when the Mac can't identify the area.") } } + .settingsAnchor("smartTouch") if controlStore.preferences.smartTouchEnabled { Toggle(isOn: preferenceBinding(controlStore, \.smartTouchLongPressHapticEnabled)) { VStack(alignment: .leading, spacing: 2) { @@ -546,6 +691,7 @@ private struct InputSettingsPage: View { } } .disabled(!controlStore.preferences.hapticsEnabled) + .settingsAnchor("smartTouchHaptics") } } } @@ -556,10 +702,12 @@ private struct GestureSettingsPage: View { @ObservedObject var controlStore: ReceiverControlStore var body: some View { - Form { + SettingsForm { Section { targetPicker("Pinch / Zoom", \.pinchTarget) + .settingsAnchor("pinch") targetPicker("Rotation", \.rotateTarget) + .settingsAnchor("rotation") Toggle("Snap Rotation", isOn: preferenceBinding(controlStore, \.snapRotation)) .disabled(controlStore.preferences.rotateTarget != .viewport) if controlStore.preferences.pinchTarget == .app || controlStore.preferences.rotateTarget == .app { @@ -574,21 +722,28 @@ private struct GestureSettingsPage: View { } Section { Toggle("Prefer MeowDisplay Gestures", isOn: preferenceBinding(controlStore, \.preferMeowDisplayGestures)) + .settingsAnchor("preferGestures") } footer: { Text("While you’re controlling the Mac, MeowDisplay gets priority for the gestures it supports: the first swipe from a screen edge goes to the Mac, and three-finger swipes aren’t taken by editing shortcuts. Some \(deviceKind) and accessibility gestures always stay with the system, and VoiceOver always takes precedence.") } Section { - if controlStore.isPad { - LabeledContent("Move View", value: String(localized: "Drag to move, pinch to zoom")) + Toggle("Allow Local View Navigation", isOn: preferenceBinding(controlStore, \.allowLocalViewNavigation)) + .settingsAnchor("localViewNavigation") + if controlStore.preferences.allowLocalViewNavigation { + if controlStore.isPad { + LabeledContent("Move View", value: String(localized: "Drag to move, pinch to zoom")) + .settingsAnchor("moveView") + } + LabeledContent("Reset View", value: String(localized: "Double-tap with two fingers")) + .settingsAnchor("resetView") } - LabeledContent("Reset View", value: String(localized: "Double-tap with two fingers")) } header: { Text("Viewport") } footer: { if controlStore.isPad { - Text("Move View lets one finger move the picture — at any zoom, until an edge of your Mac reaches the middle of the screen — without scrolling or clicking on the Mac. Two-finger scrolling always goes to the Mac.") + Text("Pan, zoom and rotate your own view of the Mac — until an edge of your Mac reaches the middle of the screen. This never sends anything to the Mac, so it works even while Allow Input is off. Move View lets one finger move the picture without clicking or scrolling on the Mac; two-finger scrolling always goes to the Mac.") } else { - Text("Pinch to zoom and move the picture, until an edge of your Mac reaches the middle of the screen. Two-finger scrolling always goes to the Mac.") + Text("Pan, zoom and rotate your own view of the Mac — until an edge of your Mac reaches the middle of the screen. This never sends anything to the Mac, so it works even while Allow Input is off. Two-finger scrolling always goes to the Mac.") } } } @@ -618,30 +773,40 @@ private struct ControlSettingsPage: View { let haptics: ReceiverHaptics @State private var confirmingReset = false @State private var confirmingFunctionTrayReset = false + @State private var customEditorRequest: CustomLayoutEditorRequest? + @State private var deletingCustomLayout: CustomControlLayout? private var visibility: PadControlSettingsVisibility { PadControlSettingsVisibility(isPad: controlStore.isPad, layout: controlStore.preferences.padControlLayout) } var body: some View { - Form { + SettingsForm { if visibility.showsLayoutPicker { padLayoutSection } if visibility.showsCustomLayouts { - CustomLayoutListSection(store: controlStore) + CustomLayoutListSection(store: controlStore, editorRequest: $customEditorRequest, + deleting: $deletingCustomLayout) + .settingsAnchor("customLayouts") + } + if visibility.showsEdgePickers { + standardControlsSection } Section { Toggle("Show Control Tray", isOn: preferenceBinding(controlStore, \.trayEnabled)) .disabled(!controlStore.preferences.allowInput) + .settingsAnchor("showTray") Toggle("Show Keyboard Button", isOn: preferenceBinding(controlStore, \.keyboardButtonEnabled)) Toggle("Collapse Control Tray", isOn: preferenceBinding(controlStore, \.trayCollapsed)) Toggle("Auto-hide Control Trays", isOn: preferenceBinding(controlStore, \.autoHideEnabled)) + .settingsAnchor("autoHide") if visibility.showsPhoneTrayPlacement { Picker("Landscape Tray Side", selection: preferenceBinding(controlStore, \.preferredLandscapeSide)) { ForEach(LandscapeTraySide.allCases) { Text($0.title).tag($0) } } .pickerStyle(.segmented) + .settingsAnchor("trayPlacement") VStack(alignment: .leading, spacing: 4) { Toggle("Avoid Notch", isOn: preferenceBinding(controlStore, \.avoidNotch)) SettingNote("Keeps controls clear of the iPhone’s notch or Dynamic Island in landscape.") @@ -664,6 +829,7 @@ private struct ControlSettingsPage: View { })) { ForEach(ControlProfileSlot.allCases) { Text($0.title).tag($0) } } + .settingsAnchor("controlProfile") NavigationLink("Edit Current Profile") { ControlProfileEditor(store: controlStore, haptics: haptics) } @@ -678,6 +844,7 @@ private struct ControlSettingsPage: View { Section { Toggle("Show Function Tray", isOn: preferenceBinding(controlStore, \.functionTrayEnabled)) .disabled(!controlStore.preferences.allowInput) + .settingsAnchor("functionTray") if visibility.showsPhoneTrayPlacement { Picker("Function Tray Position", selection: preferenceBinding(controlStore, \.functionTrayPosition)) { ForEach(FunctionTrayPosition.allCases) { Text($0.title).tag($0) } @@ -693,7 +860,7 @@ private struct ControlSettingsPage: View { ForEach(ControlProfileSlot.allCases) { Text($0.title).tag($0) } } NavigationLink("Edit Function Tray") { - FunctionTrayProfileEditor(store: controlStore) + FunctionTrayEditor(store: controlStore, slot: controlStore.preferences.activeFunctionTrayProfile) } Button("Reset Function Tray to Default", role: .destructive) { confirmingFunctionTrayReset = true @@ -709,6 +876,8 @@ private struct ControlSettingsPage: View { } } .navigationTitle(MobileSettingsCategory.controls.title) + .modifier(CustomLayoutPresentations(store: controlStore, editorRequest: $customEditorRequest, + deleting: $deletingCustomLayout)) .confirmationDialog("Reset \(controlStore.preferences.activeControlProfile.title)?", isPresented: $confirmingReset, titleVisibility: .visible) { Button("Reset Profile", role: .destructive) { @@ -729,6 +898,24 @@ private struct ControlSettingsPage: View { } } + /// Which standard controls the Strip/Overlay rail shows — one model + /// (`PadStandardControlVisibility`) for all of them. Settings is always + /// there; modifiers come from the Control Profile. + private var standardControlsSection: some View { + Section { + ForEach(PadStandardControl.allCases) { control in + Toggle(control.title, isOn: Binding( + get: { controlStore.preferences.padStandardControls.isVisible(control) }, + set: { visible in controlStore.update { $0.padStandardControls.setVisible(visible, control) } })) + } + } header: { + Text("Standard Controls") + } footer: { + Text("Shown on the Strip and Overlay rail, grouped as system actions, then keys, then view controls. Settings always stays on the rail. Move View also needs Allow Local View Navigation.") + } + .settingsAnchor("standardControls") + } + /// iPad only (see `PadControlSettingsVisibility`). @ViewBuilder private var padLayoutSection: some View { @@ -747,17 +934,25 @@ private struct ControlSettingsPage: View { ForEach(PadControlLayoutMode.allCases) { Text($0.title).tag($0) } } .pickerStyle(.segmented) + .settingsAnchor("controlLayout") if visibility.showsEdgePickers { Picker("Main Controls", selection: preferenceBinding(controlStore, \.padMainEdge)) { ForEach(ControlEdge.allCases) { Text($0.title).tag($0) } } + .settingsAnchor("controlEdges") Picker("Function Controls", selection: preferenceBinding(controlStore, \.padFunctionEdge)) { ForEach(ControlEdge.allCases) { Text($0.title).tag($0) } } - Toggle("Show Move View Control", isOn: preferenceBinding(controlStore, \.padShowMoveViewControl)) + } + if visibility.showsEdgePickers { + Picker("Palette Style", selection: preferenceBinding(controlStore, \.padOverlayPaletteStyle)) { + ForEach(PadPaletteStyle.allCases) { Text($0.title).tag($0) } + } + .settingsAnchor("paletteStyle") } if visibility.showsControlHints { Toggle("Show Control Hints", isOn: preferenceBinding(controlStore, \.padShowControlHints)) + .settingsAnchor("controlHints") } VStack(alignment: .leading, spacing: 6) { HStack { @@ -783,6 +978,7 @@ private struct ControlSettingsPage: View { Image(systemName: "circle.fill").font(.system(size: 16)).accessibilityHidden(true) } } + .settingsAnchor("controlSize") } header: { Text("Control Layout") } footer: { @@ -805,7 +1001,7 @@ private struct AudioSettingsPage: View { @ObservedObject var controlStore: ReceiverControlStore var body: some View { - Form { + SettingsForm { Section { VStack(alignment: .leading, spacing: 4) { Toggle("Audio", isOn: Binding( @@ -815,6 +1011,7 @@ private struct AudioSettingsPage: View { receiver.requestAudioEnabled(value) })) .disabled(!receiver.connected || !receiver.macSupportsAudio) + .settingsAnchor("audio") SettingNote("Plays a copy of what the Mac is playing. It keeps playing there too — this never changes the Mac's output device.") } } @@ -867,10 +1064,11 @@ private struct ConnectionSettingsPage: View { @ObservedObject var receiver: StreamReceiver var body: some View { - Form { + SettingsForm { Section("Status") { LabeledContent("Connection", value: receiver.connected ? receiver.status : receiver.canonicalPhaseTitle) + .settingsAnchor("connectionStatus") if receiver.videoSize != .zero { LabeledContent("Stream", value: "\(Int(receiver.videoSize.width))×\(Int(receiver.videoSize.height)) @ \(receiver.fps) fps") @@ -906,13 +1104,14 @@ private struct PairedMacsSettingsPage: View { @State private var forgetConfirmation = PeerForgetPrompt() var body: some View { - Form { + SettingsForm { Section { let peers = TrustStore.shared.pinnedPeers() if peers.isEmpty { Text("No paired Macs").foregroundStyle(.secondary) } else { AutomaticallyAllowConnectionsToggle() + .settingsAnchor("pairedMacs") ForEach(peers, id: \.peerID) { peer in HStack { Text(peer.displayName) @@ -924,10 +1123,11 @@ private struct PairedMacsSettingsPage: View { } IncomingSessionPolicyPicker(peerID: peer.peerID, receiver: receiver) .font(.subheadline) + RequestInputAutomaticallyToggle(peerID: peer.peerID) } } } footer: { - Text("Connection Requests: Default follows Automatically Allow Connections. Blocking keeps the Mac paired.") + Text("Connection Requests: Default follows Automatically Allow Connections. Blocking keeps the Mac paired. Request Input Automatically lets a touch on the Mac’s screen ask that Mac for control without tapping Request Input first — the Mac still decides.") } .id(trustRefresh) } @@ -949,6 +1149,24 @@ private struct PairedMacsSettingsPage: View { } } +/// Per-Mac opt-in for sending the input request from a blocked touch. Kept +/// separate from the connection policy on purpose. +private struct RequestInputAutomaticallyToggle: View { + let peerID: String + @State private var enabled = false + + var body: some View { + Toggle("Request Input Automatically", isOn: Binding( + get: { enabled }, + set: { value in + enabled = value + InputAutoRequestStore.setEnabled(value, peerID: peerID) + })) + .font(.subheadline) + .onAppear { enabled = InputAutoRequestStore.isEnabled(peerID: peerID) } + } +} + // MARK: - Remote Access private struct RemoteAccessSettingsPage: View { @@ -956,7 +1174,7 @@ private struct RemoteAccessSettingsPage: View { var body: some View { if TrustStore.shared.pinnedPeers().isEmpty { - Form { + SettingsForm { Section { Text("Pair with a Mac first, then set up Remote Access for it here.") .foregroundStyle(.secondary) @@ -973,13 +1191,14 @@ private struct RemoteAccessSettingsPage: View { private struct PermissionsSettingsPage: View { var body: some View { - Form { + SettingsForm { Section { Button("Open iOS Settings for MeowDisplay") { if let url = URL(string: UIApplication.openSettingsURLString) { UIApplication.shared.open(url) } } + .settingsAnchor("localNetwork") } footer: { Text("WiFi mode needs Local Network access. If your Mac can't find this \(deviceKind), enable it under Settings → Privacy & Security → Local Network → MeowDisplay. USB mode works without it.") } @@ -995,7 +1214,7 @@ private struct DiagnosticsSettingsPage: View { @AppStorage("metalRenderer") private var metalRenderer = false var body: some View { - Form { + SettingsForm { Section { NavigationLink { DiagnosticsLogView() @@ -1007,7 +1226,9 @@ private struct DiagnosticsSettingsPage: View { } Section { Toggle("Performance overlay", isOn: $showAnalytics) + .settingsAnchor("performanceOverlay") Toggle("Metal renderer (experimental)", isOn: $metalRenderer) + .settingsAnchor("metalRenderer") } header: { Text("Analytics") } footer: { @@ -1037,9 +1258,10 @@ private struct AboutSettingsPage: View { } var body: some View { - Form { + SettingsForm { Section { LabeledContent("Version", value: version) + .settingsAnchor("version") // Hidden unlock gesture: nine taps here (a cat's nine // lives) reveals Cat Mode below. No visible affordance // before unlock. @@ -1108,9 +1330,10 @@ private struct DeveloperSettingsPage: View { @AppStorage(CatMode.tapCountDefaultsKey) private var catModeTapCount = 0 var body: some View { - Form { + SettingsForm { Section { Toggle("Notch Debug Overlay", isOn: $notchDebugOverlayEnabled) + .settingsAnchor("notchDebug") } footer: { Text("Draws the computed unsafe/obstacle regions (red) and the raw vs. Avoid-Notch-adjusted Main Tray frame (yellow/green) directly over the stream.") } @@ -1123,6 +1346,7 @@ private struct DeveloperSettingsPage: View { .onChange(of: audioPlaybackPath) { path in Log.info("audioTrace: playbackPath=\(path)") } + .settingsAnchor("audioDiagnostics") Picker("PCM Scheduling", selection: $audioPCMSchedulingMode) { Text("Continuous").tag("continuous") Text("Precise Scheduled").tag("preciseScheduled") diff --git a/project.yml b/project.yml index eb111e77..73563424 100644 --- a/project.yml +++ b/project.yml @@ -252,6 +252,9 @@ targets: - Shared/PadControlLayout.swift - Shared/CustomControlLayout.swift - Shared/MobileSettingsCategory.swift + - Shared/MobileSettingsSearch.swift + - Shared/CustomLayoutSharing.swift + - Shared/ReceiverInputPrompt.swift - Shared/Pairing.swift - Shared/PairingPromptModel.swift - Shared/RemotePairing.swift From 5f74a974fe2b68d9e0b7d05c907b461a5b665457 Mon Sep 17 00:00:00 2001 From: peetzweg/ <839848+peetzweg@users.noreply.github.com> Date: Tue, 29 Sep 2026 10:19:42 +0200 Subject: [PATCH 08/30] fix(mac): keep the session through display sleep and screen lock (#320) Ported onto this line's capture-recovery design (CaptureRecoveryBudget / captureRecoveryScheduled) rather than upstream's replaced recovery loop: a recovery tick that finds the main display asleep or the screen locked re-arms without running a round, so a sleep longer than the budget no longer ends the session. The wait logs once and recovery resumes when the console can capture again. The existing single-pending-tick flag already prevents two recovery chains, so upstream's work-item/in-flight tracking is not needed here. (cherry picked from commit 09e717edf3ed795869fc8c5a5b094dabb95701d6) --- Mac/MacSender.swift | 36 ++++++++++++++++++++++++++++++++++-- 1 file changed, 34 insertions(+), 2 deletions(-) diff --git a/Mac/MacSender.swift b/Mac/MacSender.swift index bdf66753..80fd5f47 100644 --- a/Mac/MacSender.swift +++ b/Mac/MacSender.swift @@ -836,6 +836,11 @@ final class MacSender: NSObject, SCStreamOutput, SCStreamDelegate { // `queue`. private var captureRecoveryBudget = CaptureRecoveryBudget() private var captureRecoveryScheduled = false + // Display sleep and screen lock make every recovery attempt fail until + // the user is back, so rounds are deferred instead of spent on them — + // otherwise any sleep longer than the budget ends the session. Set while + // deferring so the wait logs once. On `queue`. + private var waitingForConsole = false // Consecutive actively-refused dials on a previously connected session. // Refusal is unambiguous: the device is reachable but nothing listens, @@ -2705,6 +2710,7 @@ final class MacSender: NSObject, SCStreamOutput, SCStreamDelegate { // without ever resetting the counter, and the next unrelated death // starts with as little as one round left. captureRecoveryBudget.reset() + waitingForConsole = false // Every successful capture start is authoritative. This also // clears a paused state retained by the receiver when changing // modes replaces the old session with a new sender. @@ -2796,6 +2802,7 @@ final class MacSender: NSObject, SCStreamOutput, SCStreamDelegate { } invalidateCapturePipeline(discardingLastFrame: true) captureDisplayID = 0 // definitive teardown — nothing is being captured anymore + waitingForConsole = false cursorTimer?.cancel() cursorTimer = nil cursorImageTimer?.cancel() @@ -3457,8 +3464,10 @@ final class MacSender: NSObject, SCStreamOutput, SCStreamDelegate { captureStateSnapshot().shouldRetryCapture else { return } captureRecoveryScheduled = true let attempt = captureRecoveryBudget.failedAttempts + 1 - Log.info("capture recovery starting mode=\(mode.rawValue) " - + "attempt=\(attempt)/\(captureRecoveryBudget.maximumAttempts) delay=3s") + if !waitingForConsole { + Log.info("capture recovery starting mode=\(mode.rawValue) " + + "attempt=\(attempt)/\(captureRecoveryBudget.maximumAttempts) delay=3s") + } let selfBox = self.selfBox queue.asyncAfter(deadline: .now() + 3.0) { guard let self = selfBox.currentOnQueue() else { return } @@ -3466,6 +3475,21 @@ final class MacSender: NSObject, SCStreamOutput, SCStreamDelegate { guard self.videoEnabled || self.desiredAudioEnabled, !self.stopped, self.captureOwnership.liveStream == nil, self.captureStateSnapshot().shouldRetryCapture else { return } + // A doomed attempt must not count against the budget: re-arm + // without running a round until the console can capture again. + guard self.consoleCanCapture else { + if !self.waitingForConsole { + self.waitingForConsole = true + Log.info("capture down while the display sleeps or the screen is locked — " + + "waiting for the user before retrying") + } + self.scheduleCaptureRecovery() + return + } + if self.waitingForConsole { + self.waitingForConsole = false + Log.info("console is back — resuming capture recovery") + } Task { await self.runCaptureRecovery(attempt: attempt) } } } @@ -3641,6 +3665,14 @@ final class MacSender: NSObject, SCStreamOutput, SCStreamDelegate { return onConsole && !locked } + /// Whether a recovery attempt can succeed right now. SCK finds no + /// capturable displays while the main display sleeps or the screen is + /// locked (display sleep drops capture seconds before the lock engages), + /// so attempts then are doomed and must not count against the budget. + private var consoleCanCapture: Bool { + consoleIsInteractive && CGDisplayIsAsleep(CGMainDisplayID()) == 0 + } + /// On `queue`: after a recovery round, re-arm the loop while capture is /// still down — up to the cap, then declare the session gone. A capture /// dead this many rounds is not coming back by itself, and ending the From eaf3102e639df7eabc138c310f84c9869a51d84d Mon Sep 17 00:00:00 2001 From: raiseCatError <315733358+raiseCatError@users.noreply.github.com> Date: Wed, 30 Sep 2026 05:05:32 +0530 Subject: [PATCH 09/30] chore(mac-receiver): sync generated Info.plist with project.yml The tracked copy predates the QUIC listener and lacked its _meowdisp-q._udp Bonjour type, which every generated build already declares. --- MacReceiver/Info.plist | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/MacReceiver/Info.plist b/MacReceiver/Info.plist index 309f123a..5bdcf7e4 100644 --- a/MacReceiver/Info.plist +++ b/MacReceiver/Info.plist @@ -25,6 +25,7 @@ NSBonjourServices _opensidecar._tcp + _meowdisp-q._udp _opendisplay-pair._tcp _opendisplay-mac-pair._tcp @@ -32,11 +33,6 @@ MeowDisplay Receiver advertises itself on the local network so another Mac can connect and use this Mac as a display. NSPrincipalClass NSApplication - SUEnableAutomaticChecks From dd3b1170f4cc3ca361a9a11ff716b3f03beaa21c Mon Sep 17 00:00:00 2001 From: raiseCatError <315733358+raiseCatError@users.noreply.github.com> Date: Wed, 30 Sep 2026 05:10:37 +0530 Subject: [PATCH 10/30] fix(mac): recover a broken HEVC stream to H.264 and re-check HEVC on reconnect The encode-failure safety net only ever dropped the frame rate, so an HEVC session that was created but produced no frames stayed black or spent the capture-recovery budget recreating HEVC. It now falls back to H.264 once per sender (the existing runtimeFallback reason) and re-arms the streak so the H.264 encoder keeps its own one-time FPS recovery. Synchronous VTCompressionSessionEncodeFrame rejections now count toward the same zero-success streak as failed outputs. A hello that no longer offers HEVC while HEVC is running (a reconnect to an H.264-only receiver build) now rebuilds the live encoder for the codec that hello allows, instead of re-confirming HEVC. Auto stays H.264-first; the user's codec preference model is unchanged. --- Mac/MacSender.swift | 50 +++++++++++++++++--- Mac/MacSenderPipelineState.swift | 31 +++++++++++-- MacTests/CodecCapabilityTests.swift | 53 ++++++++++++++++++++++ MacTests/MacSenderPipelineStateTests.swift | 37 +++++++++++++++ Shared/CodecCapability.swift | 14 +++++- 5 files changed, 174 insertions(+), 11 deletions(-) diff --git a/Mac/MacSender.swift b/Mac/MacSender.swift index 80fd5f47..f1d18c8c 100644 --- a/Mac/MacSender.swift +++ b/Mac/MacSender.swift @@ -495,6 +495,11 @@ final class MacSender: NSObject, SCStreamOutput, SCStreamDelegate { /// read and wrong for output from a session that has since been replaced. private var activeCodec: StreamCodec = .h264 private var activeCodecReason: String = CodecSelectionPolicy.Reason.explicitPreference.rawValue + /// An HEVC session was created and then produced no frames (the encode + /// failure safety net). Every later codec decision on this sender + /// recovers to H.264 (`CodecSelectionPolicy.Reason.runtimeFallback`). + /// On `queue`. + private var hevcFailedAtRuntime = false /// Cached once per process: whether this Mac has a real, usable /// hardware HEVC encoder (VideoToolbox's actual encoder list, never an /// OS-version guess). `VTCopyVideoEncoderList` enumerates every @@ -1263,10 +1268,14 @@ final class MacSender: NSObject, SCStreamOutput, SCStreamDelegate { /// No-op while there is no live stream — the next `startCapture` picks /// up the new preference on its own, and one already starting applies /// it when it commits. - private func applyEffectiveFPSChange() { + /// + /// `rebuildEncoder` also rebuilds when the rate is unchanged — for a + /// codec decision that changed underneath the live encoder (a runtime + /// HEVC failure, or a hello that no longer offers HEVC). + private func applyEffectiveFPSChange(rebuildEncoder: Bool = false) { guard let stream = captureOwnership.liveStream, capturePixelsWide > 0, capturePixelsHigh > 0 else { return } let fpsResult = effectiveFPS(width: capturePixelsWide, height: capturePixelsHigh) - guard fpsResult.fps != captureTargetFPS else { return } + guard rebuildEncoder || fpsResult.fps != captureTargetFPS else { return } logEncodeCapability(width: capturePixelsWide, height: capturePixelsHigh, result: fpsResult) needsKeyframe = true let config = SCStreamConfiguration() @@ -1285,6 +1294,9 @@ final class MacSender: NSObject, SCStreamOutput, SCStreamDelegate { do { try self.setupEncoder(width: self.capturePixelsWide, height: self.capturePixelsHigh, fps: fpsResult.fps) + if rebuildEncoder { + self.pipelineState.rearmEncodeFailureRecovery(generation: self.captureGenerationNow) + } } catch { Log.info("FPS-change encoder setup failed: \(error) — entering capture recovery") guard self.updateCaptureState({ $0.unexpectedStop() }) else { return } @@ -3407,9 +3419,20 @@ final class MacSender: NSObject, SCStreamOutput, SCStreamDelegate { /// means the size itself is the problem, not the rate — falls through /// to the ordinary capture-recovery/reconnect path instead of an /// infinite downgrade loop. + /// + /// An HEVC encoder in that state is recovered by codec first: once per + /// sender, the stream switches to H.264 (whose encoder then gets its own + /// safety net) before any rate is given up. private func recoverFromEncoderFailureStreak(generation: UInt64) { guard generation == captureGenerationNow, let stream, capturePixelsWide > 0, capturePixelsHigh > 0 else { return } + if activeCodec == .hevc, !hevcFailedAtRuntime { + Log.info("encoder failure safety net: HEVC produced no frames after \(encodeFailureStreakLimit) " + + "attempts at \(capturePixelsWide)x\(capturePixelsHigh) — falling back to H.264 for this session") + hevcFailedAtRuntime = true + applyEffectiveFPSChange(rebuildEncoder: true) + return + } let tiers = EncoderCapability.supportedFPSTiers guard let currentIndex = tiers.firstIndex(where: { $0 >= captureTargetFPS }), currentIndex > 0 else { @@ -4885,7 +4908,18 @@ final class MacSender: NSObject, SCStreamOutput, SCStreamDelegate { // Mac's encoder stayed on HEVC. Without this resend, the // receiver would misclassify the next HEVC keyframe's // VPS/SPS/PPS using H.264's `&0x1F` NAL-type space. - sendStreamCodecState() + // + // Unless this hello no longer offers HEVC (a reconnect can + // land on an H.264-only build of the receiver): then the + // live encoder is rebuilt for the codec this hello allows, + // and that rebuild announces it. + if activeCodec == .hevc, !info.receiverSupportsHEVC, + captureOwnership.liveStream != nil { + Log.info("receiver no longer offers HEVC — re-selecting the codec for the live stream") + applyEffectiveFPSChange(rebuildEncoder: true) + } else { + sendStreamCodecState() + } if info.protocolVersion >= WireProtocol.mirrorDisplayWireVersion { sendMirrorDisplayState() } @@ -5497,7 +5531,8 @@ final class MacSender: NSObject, SCStreamOutput, SCStreamDelegate { preference: codecPreference, senderSupportsHEVC: Self.senderSupportsHEVC, receiverSupportsHEVC: receiverSupportsHEVC, - requestedWidth: width, requestedHeight: height, requestedFPS: fps) + requestedWidth: width, requestedHeight: height, requestedFPS: fps, + hevcFailedAtRuntime: hevcFailedAtRuntime) return CodecSelectionPolicy.select(input) } @@ -5895,10 +5930,13 @@ final class MacSender: NSObject, SCStreamOutput, SCStreamDelegate { // an unthrottled line here is ~60/sec for as long as the problem // lasts. Report at most once a second and carry the count: the // status code is the diagnosis, the rate is just a number. - let logAction = pipelineState.recordEncodeSubmitFailure( - submitStatus, at: ProcessInfo.processInfo.systemUptime + let (logAction, shouldAttemptRecovery) = pipelineState.recordEncodeSubmitFailure( + submitStatus, at: ProcessInfo.processInfo.systemUptime, generation: generation ) handleEncodeFailureLogAction(logAction) + if shouldAttemptRecovery { + queue.async { selfBox.currentOnQueue()?.recoverFromEncoderFailureStreak(generation: generation) } + } } } diff --git a/Mac/MacSenderPipelineState.swift b/Mac/MacSenderPipelineState.swift index 1a2c18bc..7d746e2a 100644 --- a/Mac/MacSenderPipelineState.swift +++ b/Mac/MacSenderPipelineState.swift @@ -273,6 +273,12 @@ final class MacSenderPipelineState: @unchecked Sendable { lock.lock() defer { lock.unlock() } let logAction = encodeOutputFailureLogPolicy.record(status, at: time) + return (logAction, recordFailureStreakLocked(generation: generation)) + } + + /// Caller holds `lock`. One failed encode — asynchronous output or + /// synchronous submission — toward `generation`'s zero-success streak. + private func recordFailureStreakLocked(generation: UInt64) -> Bool { if encodeFailureStreakGeneration != generation { encodeFailureStreakGeneration = generation encodeFailureStreakCount = 0 @@ -282,7 +288,18 @@ final class MacSenderPipelineState: @unchecked Sendable { && encodeFailureStreakCount >= encodeFailureStreakLimit && encoderRecoveryDowngradedGeneration != generation if shouldAttemptRecovery { encoderRecoveryDowngradedGeneration = generation } - return (logAction, shouldAttemptRecovery) + return shouldAttemptRecovery + } + + /// The failing encoder of `generation` was replaced by one of another + /// codec (the one-time HEVC -> H.264 fallback): give the replacement its + /// own streak and its own one-time recovery, as a fresh generation has. + func rearmEncodeFailureRecovery(generation: UInt64) { + lock.lock() + defer { lock.unlock() } + guard encodeFailureStreakGeneration == generation else { return } + encodeFailureStreakCount = 0 + if encoderRecoveryDowngradedGeneration == generation { encoderRecoveryDowngradedGeneration = nil } } func recordEncodeSuccess(generation: UInt64) { @@ -298,13 +315,19 @@ final class MacSenderPipelineState: @unchecked Sendable { /// failure and decrements `pendingEncodes` for the frame that never made /// it into the pipeline, atomically (matches the previous single locked /// region in `encode`). + /// + /// With a `generation`, the rejected submission also counts toward that + /// generation's zero-success streak: a session that refuses every frame + /// synchronously is as broken as one that fails every output. func recordEncodeSubmitFailure( - _ status: OSStatus, at time: TimeInterval - ) -> ThrottledLogPolicy.Action { + _ status: OSStatus, at time: TimeInterval, generation: UInt64? = nil + ) -> (logAction: ThrottledLogPolicy.Action, shouldAttemptRecovery: Bool) { lock.lock() defer { lock.unlock() } pendingEncodes = max(0, pendingEncodes - 1) - return encodeFailureLogPolicy.record(status, at: time) + let logAction = encodeFailureLogPolicy.record(status, at: time) + guard let generation else { return (logAction, false) } + return (logAction, recordFailureStreakLocked(generation: generation)) } func flushEncodeSubmitFailureLog(at time: TimeInterval) -> ThrottledLogPolicy.Report? { diff --git a/MacTests/CodecCapabilityTests.swift b/MacTests/CodecCapabilityTests.swift index afb69120..cab524e6 100644 --- a/MacTests/CodecCapabilityTests.swift +++ b/MacTests/CodecCapabilityTests.swift @@ -391,6 +391,59 @@ final class CodecCapabilityTests: XCTestCase { XCTAssertEqual(Set(reasons.map(\.rawValue)).count, reasons.count) } + // MARK: - Runtime HEVC failure (encode-failure safety net) + + func testExplicitHEVCRecoversToH264AfterARuntimeHEVCFailure() { + let result = CodecSelectionPolicy.select(.init( + preference: .hevc, senderSupportsHEVC: true, receiverSupportsHEVC: true, + requestedWidth: 1920, requestedHeight: 1080, requestedFPS: 60, + hevcFailedAtRuntime: true)) + XCTAssertEqual(result.codec, .h264) + XCTAssertEqual(result.reason, CodecSelectionPolicy.Reason.runtimeFallback.rawValue) + } + + func testAutoRecoversToH264AfterARuntimeHEVCFailureWhereItWouldPickHEVC() { + // Same input as `testAutoUsesHEVCWhenH264WouldReduceAndHEVCIsMutuallySupported`. + let input = CodecSelectionPolicy.Input( + preference: .auto, senderSupportsHEVC: true, receiverSupportsHEVC: true, + requestedWidth: 3840, requestedHeight: 2160, requestedFPS: 120) + XCTAssertEqual(CodecSelectionPolicy.select(input).codec, .hevc) + let result = CodecSelectionPolicy.select(.init( + preference: .auto, senderSupportsHEVC: true, receiverSupportsHEVC: true, + requestedWidth: 3840, requestedHeight: 2160, requestedFPS: 120, + hevcFailedAtRuntime: true)) + XCTAssertEqual(result.codec, .h264) + XCTAssertEqual(result.reason, CodecSelectionPolicy.Reason.runtimeFallback.rawValue) + } + + func testRuntimeHEVCFailureLeavesH264DecisionsAndTheirReasonsUnchanged() { + // Auto stays H.264-first: the flag only ever rewrites an HEVC pick. + for preference in [CodecPreference.auto, .h264] { + let unflagged = CodecSelectionPolicy.select(.init( + preference: preference, senderSupportsHEVC: true, receiverSupportsHEVC: true, + requestedWidth: 1920, requestedHeight: 1080, requestedFPS: 60)) + let flagged = CodecSelectionPolicy.select(.init( + preference: preference, senderSupportsHEVC: true, receiverSupportsHEVC: true, + requestedWidth: 1920, requestedHeight: 1080, requestedFPS: 60, + hevcFailedAtRuntime: true)) + XCTAssertEqual(unflagged.codec, .h264) + XCTAssertEqual(flagged, unflagged) + } + } + + func testReconnectToAReceiverWithoutHEVCReselectsH264() { + // A hello that no longer offers HEVC (no `codecs`, or `codecs` + // without "hevc") makes the same request resolve to H.264 — the + // decision `MacSender` re-runs for the live encoder on that hello. + for codecs in [nil, ["h264"]] as [[String]?] { + let result = CodecSelectionPolicy.select(.init( + preference: .hevc, senderSupportsHEVC: true, + receiverSupportsHEVC: CodecCapabilityProbe.receiverSupportsHEVC(codecs: codecs), + requestedWidth: 1920, requestedHeight: 1080, requestedFPS: 60)) + XCTAssertEqual(result.codec, .h264) + } + } + // MARK: - Wire message round-trip (streamCodecState) func testStreamCodecStateRoundTripsThroughWireFields() { diff --git a/MacTests/MacSenderPipelineStateTests.swift b/MacTests/MacSenderPipelineStateTests.swift index d1914ee8..e7e252dc 100644 --- a/MacTests/MacSenderPipelineStateTests.swift +++ b/MacTests/MacSenderPipelineStateTests.swift @@ -142,6 +142,24 @@ final class MacSenderPipelineStateTests: XCTestCase { XCTAssertFalse(shouldAttempt) } + func testRearmGivesAReplacementEncoderItsOwnStreakAndRecovery() { + let state = MacSenderPipelineState(maxPendingEncodes: 2, encodeFailureStreakLimit: 2) + _ = state.recordEncodeOutputFailure(-1, at: 0, generation: 1) + XCTAssertTrue(state.recordEncodeOutputFailure(-1, at: 0, generation: 1).shouldAttemptRecovery) + state.rearmEncodeFailureRecovery(generation: 1) + // The streak restarts: one failure is not yet a streak. + XCTAssertFalse(state.recordEncodeOutputFailure(-1, at: 0, generation: 1).shouldAttemptRecovery) + XCTAssertTrue(state.recordEncodeOutputFailure(-1, at: 0, generation: 1).shouldAttemptRecovery) + XCTAssertFalse(state.recordEncodeOutputFailure(-1, at: 0, generation: 1).shouldAttemptRecovery) + } + + func testRearmForAnotherGenerationLeavesTheCurrentStreakAlone() { + let state = MacSenderPipelineState(maxPendingEncodes: 2, encodeFailureStreakLimit: 2) + _ = state.recordEncodeOutputFailure(-1, at: 0, generation: 2) + state.rearmEncodeFailureRecovery(generation: 1) + XCTAssertTrue(state.recordEncodeOutputFailure(-1, at: 0, generation: 2).shouldAttemptRecovery) + } + // MARK: - Encoder submit-failure bookkeeping func testRecordEncodeSubmitFailureDecrementsPendingEncodes() { @@ -156,4 +174,23 @@ final class MacSenderPipelineStateTests: XCTestCase { state.incrementPendingEncodes() XCTAssertTrue(state.isBackedUp()) } + + func testSubmitFailuresCountTowardTheGenerationsStreak() { + let state = MacSenderPipelineState(maxPendingEncodes: 5, encodeFailureStreakLimit: 3) + XCTAssertFalse(state.recordEncodeSubmitFailure(-1, at: 0, generation: 1).shouldAttemptRecovery) + XCTAssertFalse(state.recordEncodeOutputFailure(-1, at: 0, generation: 1).shouldAttemptRecovery) + XCTAssertTrue(state.recordEncodeSubmitFailure(-1, at: 0, generation: 1).shouldAttemptRecovery) + XCTAssertFalse(state.recordEncodeSubmitFailure(-1, at: 0, generation: 1).shouldAttemptRecovery) + } + + func testSubmitFailureWithoutAGenerationNeverTriggersRecovery() { + let state = MacSenderPipelineState(maxPendingEncodes: 5, encodeFailureStreakLimit: 1) + XCTAssertFalse(state.recordEncodeSubmitFailure(-1, at: 0).shouldAttemptRecovery) + } + + func testSubmitFailuresAfterASuccessfulFrameNeverTriggerRecovery() { + let state = MacSenderPipelineState(maxPendingEncodes: 5, encodeFailureStreakLimit: 1) + state.recordEncodeSuccess(generation: 1) + XCTAssertFalse(state.recordEncodeSubmitFailure(-1, at: 0, generation: 1).shouldAttemptRecovery) + } } diff --git a/Shared/CodecCapability.swift b/Shared/CodecCapability.swift index c055d2e6..47168068 100644 --- a/Shared/CodecCapability.swift +++ b/Shared/CodecCapability.swift @@ -209,15 +209,21 @@ enum CodecSelectionPolicy { let requestedWidth: Int let requestedHeight: Int let requestedFPS: Int + /// An HEVC session on this sender was created but then produced no + /// frames (the encode-failure safety net). Sticky for the sender's + /// life: every decision that would pick HEVC recovers to H.264. + let hevcFailedAtRuntime: Bool init(preference: CodecPreference, senderSupportsHEVC: Bool, receiverSupportsHEVC: Bool, - requestedWidth: Int, requestedHeight: Int, requestedFPS: Int) { + requestedWidth: Int, requestedHeight: Int, requestedFPS: Int, + hevcFailedAtRuntime: Bool = false) { self.preference = preference self.senderSupportsHEVC = senderSupportsHEVC self.receiverSupportsHEVC = receiverSupportsHEVC self.requestedWidth = requestedWidth self.requestedHeight = requestedHeight self.requestedFPS = requestedFPS + self.hevcFailedAtRuntime = hevcFailedAtRuntime } var hevcMutuallySupported: Bool { senderSupportsHEVC && receiverSupportsHEVC } @@ -231,6 +237,12 @@ enum CodecSelectionPolicy { /// Pure decision function. Never touches VideoToolbox/UserDefaults — /// callers gather `Input` from actual capability probes and pass it in. static func select(_ input: Input) -> Result { + let result = selectFromCapabilities(input) + guard input.hevcFailedAtRuntime, result.codec == .hevc else { return result } + return Result(codec: .h264, reason: Reason.runtimeFallback.rawValue) + } + + private static func selectFromCapabilities(_ input: Input) -> Result { switch input.preference { case .h264: return Result(codec: .h264, reason: Reason.explicitPreference.rawValue) From c99feaedf7c23e0615e8f1243b31bff7742ad40b Mon Sep 17 00:00:00 2001 From: raiseCatError <315733358+raiseCatError@users.noreply.github.com> Date: Wed, 30 Sep 2026 05:14:12 +0530 Subject: [PATCH 11/30] docs(mac): point debug-knob comments at MeowDisplay's defaults domain --- Mac/MacSender.swift | 6 +++--- Mac/TestPatternWindow.swift | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/Mac/MacSender.swift b/Mac/MacSender.swift index f1d18c8c..ced80cd8 100644 --- a/Mac/MacSender.swift +++ b/Mac/MacSender.swift @@ -724,7 +724,7 @@ final class MacSender: NSObject, SCStreamOutput, SCStreamDelegate { pcmPacketSeq = 0 activeAudioIsPCM = UserDefaults.standard.string(forKey: "audioDebugMode") == "pcm" // FORENSIC NOTE: a previous pass's instructions told the user to - // `defaults write com.peetzweg.opensidecar.mac.debug ...` — the + // `defaults write com.raisecaterror.meowdisplay.mac.debug ...` — the // upstream/tracked bundle ID (`project.yml`). This checkout's local // signing override (`project.local.yml`, see repo CLAUDE.md) rebuilds // the Debug target as `com.raisecaterror.meowdisplay.mac.debug`, so @@ -861,7 +861,7 @@ final class MacSender: NSObject, SCStreamOutput, SCStreamDelegate { // capture→encode→stream→display latency (~30ms perceived). Instead we // hide it from capture and stream its position on the control channel — // the phone draws it locally on the ~2ms path the touches use. - // Escape hatch: `defaults write com.peetzweg.opensidecar.mac localCursor -bool false`. + // Escape hatch: `defaults write com.raisecaterror.meowdisplay.mac localCursor -bool false`. private let localCursor = UserDefaults.standard.object(forKey: "localCursor") == nil || UserDefaults.standard.bool(forKey: "localCursor") private var cursorTimer: DispatchSourceTimer? @@ -2153,7 +2153,7 @@ final class MacSender: NSObject, SCStreamOutput, SCStreamDelegate { sessionGeneration: sessionGeneration) await logDisplayTopologyDiagnostics(reason: "setupExtend", vdID: vdID) - // Debug aid (`defaults write com.peetzweg.opensidecar.mac testPattern -bool true`): + // Debug aid (`defaults write com.raisecaterror.meowdisplay.mac testPattern -bool true`): // an animated window on the virtual display generates a constant frame // stream so steady-state latency can be measured without user activity. if UserDefaults.standard.bool(forKey: "testPattern") { diff --git a/Mac/TestPatternWindow.swift b/Mac/TestPatternWindow.swift index 346408ba..e97ba024 100644 --- a/Mac/TestPatternWindow.swift +++ b/Mac/TestPatternWindow.swift @@ -4,7 +4,7 @@ import AppKit /// Debug aid: a full-screen animated window on the virtual display so the /// pipeline streams continuously — without it, ScreenCaptureKit emits nothing /// while the screen is static and steady-state latency can't be measured. -/// Enable with `defaults write com.peetzweg.opensidecar.mac testPattern -bool true`. +/// Enable with `defaults write com.raisecaterror.meowdisplay.mac testPattern -bool true`. @MainActor enum TestPattern { // One window per virtual display — multi-device sessions each get their From fc4e36634327c0d3bcbaea24b84a2145fea36789 Mon Sep 17 00:00:00 2001 From: raiseCatError <315733358+raiseCatError@users.noreply.github.com> Date: Wed, 30 Sep 2026 05:14:12 +0530 Subject: [PATCH 12/30] feat: link every About screen to GitHub and prepare an optional Ko-fi link ProjectLinks is now the one definition of the project's external links. The Mac app, Mac Receiver and iPhone/iPad About sections link to the GitHub repository. A "Support MeowDisplay" link appears only once ProjectLinks.koFiPage holds a real https://ko-fi.com/ address; it is nil until that page exists, so nothing ships pointing nowhere. --- Mac/SystemSettingsView.swift | 8 ++++++ MacReceiver/ReceiverSystemSettingsView.swift | 8 ++++++ MacTests/ProjectLinksTests.swift | 28 +++++++++++++++++++ Shared/AppStore.swift | 2 +- Shared/ProjectLinks.swift | 29 ++++++++++++++++++++ iOS/OpenSidecarPhoneApp.swift | 2 +- iOS/ReceiverSettings.swift | 9 ++++-- project.yml | 1 + 8 files changed, 83 insertions(+), 4 deletions(-) create mode 100644 MacTests/ProjectLinksTests.swift create mode 100644 Shared/ProjectLinks.swift diff --git a/Mac/SystemSettingsView.swift b/Mac/SystemSettingsView.swift index 4a52dd0d..34d396f0 100644 --- a/Mac/SystemSettingsView.swift +++ b/Mac/SystemSettingsView.swift @@ -157,6 +157,14 @@ struct SystemSettingsView: View { // non-interactive detail row. .contentShape(Rectangle()) .onTapGesture { registerCatModeTap() } + Link(destination: ProjectLinks.gitHub) { + Label("MeowDisplay on GitHub", systemImage: "link") + } + if let support = ProjectLinks.support { + Link(destination: support) { + Label("Support MeowDisplay", systemImage: "heart") + } + } if catModeUnlocked { Toggle(isOn: catModeToggleBinding) { Label("Cat Mode", systemImage: "pawprint.fill") diff --git a/MacReceiver/ReceiverSystemSettingsView.swift b/MacReceiver/ReceiverSystemSettingsView.swift index c8802dc4..58d69823 100644 --- a/MacReceiver/ReceiverSystemSettingsView.swift +++ b/MacReceiver/ReceiverSystemSettingsView.swift @@ -67,6 +67,14 @@ private struct ReceiverSystemPage: View { Section("About") { ReceiverValueRow("Version", value: appVersion) + Link(destination: ProjectLinks.gitHub) { + Label("MeowDisplay on GitHub", systemImage: "link") + } + if let support = ProjectLinks.support { + Link(destination: support) { + Label("Support MeowDisplay", systemImage: "heart") + } + } } } } diff --git a/MacTests/ProjectLinksTests.swift b/MacTests/ProjectLinksTests.swift new file mode 100644 index 00000000..0929569a --- /dev/null +++ b/MacTests/ProjectLinksTests.swift @@ -0,0 +1,28 @@ +import XCTest + +final class ProjectLinksTests: XCTestCase { + func testGitHubIsTheProjectPageEverywhere() { + XCTAssertEqual(ProjectLinks.gitHub.absoluteString, "https://github.com/raiseCatError/meowdisplay") + XCTAssertEqual(AppStore.projectURL, ProjectLinks.gitHub) + } + + func testNoSupportActionShipsWithoutAConfiguredPage() { + // Until the Ko-fi page exists, no "Support MeowDisplay" action is shown. + XCTAssertEqual(ProjectLinks.support, ProjectLinks.supportURL(from: ProjectLinks.koFiPage)) + if ProjectLinks.koFiPage == nil { XCTAssertNil(ProjectLinks.support) } + } + + func testARealKoFiPageIsOffered() { + for page in ["https://ko-fi.com/meowdisplay", "https://www.ko-fi.com/meowdisplay"] { + XCTAssertEqual(ProjectLinks.supportURL(from: page)?.absoluteString, page) + } + } + + func testAnythingButAKoFiPageIsNeverOffered() { + for page in [nil, "", " ", "https://ko-fi.com", "https://ko-fi.com/", "http://ko-fi.com/meowdisplay", + "https://example.com/meowdisplay", "https://ko-fi.com.evil.example/meowdisplay", + "ko-fi.com/meowdisplay", "javascript:alert(1)", "https://ko-fi.com/"] { + XCTAssertNil(ProjectLinks.supportURL(from: page), page ?? "nil") + } + } +} diff --git a/Shared/AppStore.swift b/Shared/AppStore.swift index 0dcdc863..e0add50a 100644 --- a/Shared/AppStore.swift +++ b/Shared/AppStore.swift @@ -18,7 +18,7 @@ enum AppStore { /// The project page: how to get and update every MeowDisplay app, and /// where an update link points while there is no App Store listing. - static let projectURL = URL(string: "https://github.com/raiseCatError/MeowDisplay")! + static var projectURL: URL { ProjectLinks.gitHub } /// Opens the App Store app directly on the listing (with an Update /// button). Nil while there is no listing. diff --git a/Shared/ProjectLinks.swift b/Shared/ProjectLinks.swift new file mode 100644 index 00000000..487522bd --- /dev/null +++ b/Shared/ProjectLinks.swift @@ -0,0 +1,29 @@ +// Compiled into every app target. MeowDisplay's external project and support +// links, in one place so every About screen and update fallback agree. + +import Foundation + +enum ProjectLinks { + /// The source repository and project page: how to get, update and + /// report issues for every MeowDisplay app. + static let gitHub = URL(string: "https://github.com/raiseCatError/meowdisplay")! + + /// The Ko-fi page (e.g. "https://ko-fi.com/"), or nil while there + /// is none. The one place to configure: set it once the page exists, and + /// every "Support MeowDisplay" action appears. Until then those actions + /// are hidden — never a placeholder that leads nowhere. + static let koFiPage: String? = nil + + /// The support link to show, or nil to show none. + static var support: URL? { supportURL(from: koFiPage) } + + /// Only a real `https://ko-fi.com/` link is ever offered — matched + /// on the raw text, since newer Foundation percent-encodes what + /// `URL(string:)` would otherwise reject. + static func supportURL(from page: String?) -> URL? { + guard let page, + page.range(of: #"^https://(www\.)?ko-fi\.com/[A-Za-z0-9_-]+/?$"#, + options: [.regularExpression, .caseInsensitive]) != nil else { return nil } + return URL(string: page) + } +} diff --git a/iOS/OpenSidecarPhoneApp.swift b/iOS/OpenSidecarPhoneApp.swift index f4c30d69..233c0e65 100644 --- a/iOS/OpenSidecarPhoneApp.swift +++ b/iOS/OpenSidecarPhoneApp.swift @@ -9,7 +9,7 @@ import Combine /// Repository — hosts the Mac app download and explains the two-app setup. /// MeowDisplay does not yet have its own landing-page domain, so this points /// at the GitHub repo rather than a hosted site. -let macAppURL = URL(string: "https://github.com/raiseCatError/MeowDisplay")! +let macAppURL = ProjectLinks.gitHub @main struct OpenSidecarPhoneApp: App { diff --git a/iOS/ReceiverSettings.swift b/iOS/ReceiverSettings.swift index 73ead3b9..e20b90ae 100644 --- a/iOS/ReceiverSettings.swift +++ b/iOS/ReceiverSettings.swift @@ -1267,8 +1267,13 @@ private struct AboutSettingsPage: View { // before unlock. .contentShape(Rectangle()) .onTapGesture { registerCatModeTap() } - Link(destination: macAppURL) { - Label("GitHub — raiseCatError/MeowDisplay", systemImage: "link") + Link(destination: ProjectLinks.gitHub) { + Label("MeowDisplay on GitHub", systemImage: "link") + } + if let support = ProjectLinks.support { + Link(destination: support) { + Label("Support MeowDisplay", systemImage: "heart") + } } if catModeUnlocked { Toggle(isOn: Binding( diff --git a/project.yml b/project.yml index 73563424..9f79eeec 100644 --- a/project.yml +++ b/project.yml @@ -242,6 +242,7 @@ targets: - Shared/PairingNetwork.swift - Shared/LocalOwnerAuthenticator.swift - Shared/AppStore.swift + - Shared/ProjectLinks.swift - Shared/RemoteViewport.swift - Shared/PointerGestureEngine.swift - Shared/ScrollMomentum.swift From 84536f93da807903b607947dccd38fd5e0a1f5c5 Mon Sep 17 00:00:00 2001 From: raiseCatError <315733358+raiseCatError@users.noreply.github.com> Date: Wed, 30 Sep 2026 05:17:06 +0530 Subject: [PATCH 13/30] docs: bring release docs up to date with the shipping apps README: iPad Strip/Overlay/Custom controls, the editable Function Tray, local view navigation apart from input, the input request prompt, system requirements, a GitHub link, and Smart Touch's new default. PROTOCOL.md: document optional HEVC (hello.codecs, streamCodecState, pv 19) and the missing pv 15/16/18 rows. APP_STORE_LISTING.md: iPad controls in the description, drop Apple Pencil per the listing's own rule, explain Local Network, Face ID and background audio in the review notes, and allow a manual Organizer upload. RELEASE.md: the manual first-release path and the two values left to fill. --- PROTOCOL.md | 38 ++++++++++++++++++++++++- README.md | 52 +++++++++++++++++++++++++++++++++-- RELEASE.md | 28 +++++++++++++++++++ fastlane/APP_STORE_LISTING.md | 11 ++++++-- 4 files changed, 124 insertions(+), 5 deletions(-) diff --git a/PROTOCOL.md b/PROTOCOL.md index 00c91797..83a94096 100644 --- a/PROTOCOL.md +++ b/PROTOCOL.md @@ -340,7 +340,8 @@ control data to route it to the video path). ## 5. Video The video stream is **H.264 Annex B**, one *access unit* (one encoded -picture) per wire frame. +picture) per wire frame. A receiver that advertises it may instead be sent +**HEVC (H.265) Main 8-bit**, framed the same way (section 5.4). ### 5.1 Frame layout @@ -390,6 +391,36 @@ an IDR (with SPS/PPS, per 5.1). Senders SHOULD also send an IDR unprompted whenever a connection is (re)established, including replaying the last captured frame if the screen is static and the capturer produces nothing. +### 5.4 HEVC (`hello.codecs`, `streamCodecState`, `pv` 19) + +HEVC is optional and additive; H.264 stays the universal baseline. + +* **Capability.** A receiver lists the codecs it can decode in hardware as + `hello.codecs` (array of strings): always `"h264"`, plus `"hevc"` only when + a hardware HEVC decoder exists. The field itself is the capability signal: + a receiver that omits it is H.264-only whatever its `pv`, and a sender + MUST NOT send it HEVC. +* **Choice.** The sender alone picks the codec, from its user's preference + (Auto, H.264 or HEVC) and both sides' hardware support. Auto uses H.264 + unless H.264 would have to reduce the requested stream and HEVC is + supported on both sides. The decode ceiling (section 6.5) applies to both + codecs. +* **Announcement.** `{"type":"streamCodecState","codec":"h264"|"hevc","reason":}` + (sender -> receiver) names the codec of the frames that follow. It is sent + whenever the encoder is (re)built and again after every `hello`, only to a + receiver that sent `codecs`. It travels on the channel that carries video + frames (on QUIC, the Video stream — section 2.4) so it stays ordered with + them. `reason` is diagnostic text; receivers MUST NOT branch on it, and + MUST ignore a `codec` they do not know. +* **Framing.** HEVC access units follow 5.1 with HEVC NAL headers: the NAL + type is `(byte0 >> 1) & 0x3F`, and every IDR is prefixed with the current + VPS, SPS and PPS. On a codec change the receiver drops its parameter sets + and decoder state and waits for the new codec's keyframe. +* **Fallback.** The sender may switch an HEVC stream back to H.264 at any + time — when the HEVC encoder cannot be created or produces no frames, or + when a later `hello` no longer lists `"hevc"` — announcing it with + `streamCodecState` before the first H.264 frame. + ## 5A. Mac system audio (`pv` 12) A copy of the sender's system audio — never a reroute of its physical @@ -1338,7 +1369,11 @@ Mechanics at a glance (the policy behind them lives in COMPATIBILITY.md): | 12 | Mac system audio: typed `0x01` media-frame marker (section 5A), AAC-LC config/packet frames, `audioRequest` / `audioState` | | 13 | Mac-authoritative Mirror capture-source selection: `mirrorDisplayRequest` / `mirrorDisplayState` | | 14 | Mac-authoritative Extend display shape: `extendShapeRequest` / `extendShapeState` (section 6.7) | +| 15 | Encoder-safe FPS ceiling and receiver-enforced maximum FPS: `maxFPSRequest` / `maxFPSState` (section 6.8) | +| 16 | Streaming priority: `streamingPriorityRequest` / `streamingPriorityState` | | 17 | `mirrorUnavailable`: headless-Mirror offer to switch to Extend via the existing `displayModeRequest` | +| 18 | Session-scoped input consent: `allowInputState.state` | +| 19 | Optional HEVC video (section 5.4): `hello.codecs`, `streamCodecState`; codec capability comes from the field, never from `pv` | | 20 | Smart Touch (Experimental): `smartTouchProbe` / `smartTouchProbeResult` | | 21 | Session invitations: `sessionInvite` / `sessionInviteResponse` / `sessionInviteCancel` (section 6.9) | | 22 | Optional QUIC transport (section 2.4): `_meowdisp-q._udp` hint, ALPN `meowdisplay-quic/1`, MEOW stream preface, `transports`/`qv` capability in `hello`/`welcome`; TCP unchanged, `min` stays 1 | @@ -1434,4 +1469,5 @@ This file is versioned by git; the authoritative change log is | 2026-09-18 | `pv` 14: Mac-authoritative Extend display shape (`extendShapeRequest` / `extendShapeState`, section 6.7); additive `hello.maxEncodeWide`/`maxEncodeHigh` now advertised by the official iOS receiver | | 2026-09-18 | `pv` 15: encoder-safe FPS ceiling and receiver-enforced maximum FPS (`maxFPSRequest` / `maxFPSState`, section 6.8) | | 2026-09-27 | `pv` 22: optional reliable-stream QUIC transport (section 2.4) with authenticated `transports`/`qv` capability | +| 2026-09-30 | Documented optional HEVC video (section 5.4, `pv` 19) and the `pv` 15/16/18 rows; no wire change. The sender now also falls back to H.264 when an HEVC encoder produces no frames or a new `hello` drops `"hevc"` | | 2026-09-26 | Hardening, no `pv` change: session admission and input grants are per connection (section 6.9); the same `hello` identity/current-pin check on USB (section 2.2); the Remote Access knock declares its intent | diff --git a/README.md b/README.md index 9a00e6ef..4c7170e7 100644 --- a/README.md +++ b/README.md @@ -125,6 +125,17 @@ the **Settings gear stays reachable**. The Mac can **Revoke Control** in the device's settings, or the receiver can **Turn Off** control. These permissions are separate from permission to connect. +A touch that input doesn't allow shows a brief **Request Input** prompt; it +never changes a setting on its own. Requests are only sent automatically when +you turn on **Request Input Automatically** for that Mac, and the Mac still +decides. + +**Local view navigation is separate from input.** With **Allow Local View +Navigation** on (the default), you can pan, zoom, rotate and reset the view +on the receiver even while input is off — nothing is sent to the Mac. On +iPad, **Move View** lets one finger pan and a pinch zoom without touching the +Mac at all. + ### Precise Control **Precise Control works in Direct Touch and Trackpad.** Keep one finger down @@ -149,8 +160,8 @@ for the full gesture details. ### Smart Touch -**Smart Touch (Experimental)** is an optional addition to Direct Touch, off -by default. It uses the Mac's Accessibility information to recognize supported +**Smart Touch (Experimental)** is an optional addition to Direct Touch, on +by default and easy to turn off in the receiver's Settings. It uses the Mac's Accessibility information to recognize supported scrollable areas and window title bars: - Swipe a scrollable area with one finger, with momentum in either axis. @@ -243,6 +254,30 @@ A separate tray of one-tap actions — **Zoom In**, **Zoom Out**, **Undo** and **Redo** by default. It's customized independently of the Main Control Tray, with its own profiles, and can sit on the same or the opposite side. +Add, remove, reorder and rename its actions, and give each one a face — an +SF Symbol, text, an emoji, or its keys. Custom actions can be one-tap +keyboard shortcuts (including multi-key chords) or short sequences of +existing actions, and chord palettes can offer them too. + +### iPad controls + +On iPad, choose how the controls sit around the Mac: + +- **Strip** — a solid rail on any edge, with the Mac's picture fitted beside + it. +- **Overlay** — the Mac fills the screen and the controls float over it in + compact trays. +- **Custom** — up to five layouts of your own, with separate landscape and + portrait arrangements, built in a full-screen visual editor with Preview, + alignment guides and snapping. Layouts can be shared as a code and + imported on another iPad. + +The Main and Function controls pick their edges independently, **Control +Size** scales them, and **Two-Hand Assist** puts helper modifiers on the +opposite side. While the on-screen keyboard is up, a keyboard accessory bar +with modifiers, Escape and Tab takes the controls' place. iPhone keeps the +Control Trays above. + ### Auto-hide **There when you need it. Out of the way when you don't.** With Auto-hide, the @@ -339,6 +374,17 @@ Picture in Picture is new; physical-device validation is not yet complete. More in [SECURITY.md](SECURITY.md) and the [Privacy page](https://meowdisplay.app/privacy.html). +## Requirements + +| App | Runs on | +|---|---| +| **MeowDisplay** (the Mac app that shares its display) | macOS 14 or later | +| **MeowDisplay Receiver** (a spare Mac as a display) | macOS 12 or later | +| **MeowDisplay** for iPhone and iPad | iOS / iPadOS 16.4 or later | + +Remote control of the Mac needs Accessibility permission; capturing a +display needs Screen Recording. + ## Getting started 1. Run MeowDisplay on your Mac. @@ -384,6 +430,8 @@ troubleshooting are in [SETUP.md](SETUP.md); release process in ## Contributing +Source, issues and releases live at +[github.com/raiseCatError/meowdisplay](https://github.com/raiseCatError/meowdisplay). Issues and pull requests are welcome, especially testing on iPad, different Mac models, external-display setups and networks. Please open an issue before starting large architecture work. diff --git a/RELEASE.md b/RELEASE.md index 96b26256..a8d155ad 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -84,6 +84,34 @@ If App Review rejects the build, fix it on `main`, let release-please update the PR, and upload a new candidate with the same version (a new run gives it a higher build number). +## First release without CI secrets (manual path) + +None of the secrets below exist yet, so the first release can be built +locally instead of by the workflows: + +- **Version.** The inherited tag sequence ends at `v1.19.0` and the open + release-please PR proposes **1.20.0**, so the first release is 1.20.0. + Build it with `MARKETING_VERSION=1.20.0` and a `CURRENT_PROJECT_VERSION` + higher than any build already uploaded for that version. +- **iOS.** Generate the project with `DEVELOPMENT_TEAM` set, archive the + `OpenSidecariOS` scheme in Release, then upload from Xcode Organizer → + Distribute App → App Store Connect. A later `app-store-candidate.yml` run + numbers its builds from its own run counter, so give a manual upload a + build number above the run numbers that workflow will reach for the same + version (or re-upload under a new version). +- **Mac.** Archive `OpenSidecarMac` and `OpenSidecarMacReceiver` in Release, + export each with Developer ID, notarize (`xcrun notarytool submit … + --wait`), staple, and package as `MeowDisplay.dmg` / + `MeowDisplayReceiver.dmg` as `fastlane mac build_release` would. + +Values still to fill in before or at release: + +- [ ] `AppStore.iOSAppID` in `Shared/AppStore.swift` — the app's numeric + Apple ID from App Store Connect (see `fastlane/APP_STORE_LISTING.md`). +- [ ] `ProjectLinks.koFiPage` in `Shared/ProjectLinks.swift` — the Ko-fi page + URL, once it exists. Until then no app shows a support link. Add the + same URL to the README's Contributing section and `SUPPORT.md` then. + ## Required secrets None are currently configured on this fork. Exact inventory (see the diff --git a/fastlane/APP_STORE_LISTING.md b/fastlane/APP_STORE_LISTING.md index dd3b069c..3e7e5fd4 100644 --- a/fastlane/APP_STORE_LISTING.md +++ b/fastlane/APP_STORE_LISTING.md @@ -77,9 +77,10 @@ CONTROL • Direct Touch: tap exactly where you want on screen • Trackpad: move a pointer with familiar gestures • Click, double-click, drag, right-click and scroll -• Apple Pencil on supported iPads, with tilt and hover • Multi-finger gestures for Mission Control, Spaces, Spotlight and more • A keyboard, a Main Control Tray for modifier keys, a customizable Function Tray, and Chords with shortcut palettes +• On iPad, place the controls as an edge Strip, a floating Overlay, or your own Custom layouts +• Pan and zoom the view on your device without sending anything to the Mac • Controls can hide automatically while you work CONNECT @@ -133,7 +134,7 @@ Work through this before pressing **Submit for Review**. | App Review notes | See [App Review notes](#app-review-notes) | | Sign-in required | No — there is no account | | Screenshots | See [Screenshot plan](#screenshot-plan) — **not captured yet** | -| Build | Select the processed build uploaded by `fastlane ios release` | +| Build | Select the processed build — uploaded by `fastlane ios release`, or from a local Release archive via Xcode Organizer → Distribute App → App Store Connect → Upload | | Version release | Choose **Manually release this version** | ### Before the first submission @@ -173,6 +174,11 @@ How to test: 6. Touch the screen to control the Mac: Direct Touch taps where you touch; Trackpad moves a pointer. The keyboard button and control trays send keys and shortcuts. Remote Access and Wake & Connect are optional and need extra network setup; they are not needed to review the core features. + +Permissions and background use: +- Local Network: the app advertises itself over Bonjour so the user's own Mac can find it and connect directly. No other devices are contacted. +- Face ID / passcode: only to confirm trusting a newly paired device. +- Background audio (UIBackgroundModes "audio"): keeps the system Picture in Picture window of the user's Mac, and the Mac audio the user turned on, playing after they leave the app. With Picture in Picture off, leaving the app stops playback. ``` --- @@ -282,6 +288,7 @@ Real captures from the running app only. No fake UI, no price wording | 4 | Direct Touch or Trackpad in use, control tray visible | Touch or trackpad control | | 5 | Function Tray / Chords shortcut palette open | Shortcuts at your fingertips | | 6 | Connected devices / connection choice | USB, Wi-Fi or Remote Access | +| 7 | iPad with Strip or Overlay controls (iPad set only) | Controls that fit your iPad | Capture the same set on iPhone and iPad (the app is universal). Use a clean desktop, neutral content, and no personal names, addresses, or notifications. From 824ff21eb169063e811f7131ba5a3d623a93a7c4 Mon Sep 17 00:00:00 2001 From: raiseCatError <315733358+raiseCatError@users.noreply.github.com> Date: Wed, 30 Sep 2026 05:54:58 +0530 Subject: [PATCH 14/30] feat(ios): point update links at MeowDisplay's App Store listing Set AppStore.iOSAppID to 6817552864, the app's Apple ID in App Store Connect, so the iOS update screen and the Mac's updateRequired message open MeowDisplay's own listing instead of the GitHub project page. Record the app's identity in the listing reference and check off the release item. --- MacTests/AppStoreTests.swift | 7 +++++++ RELEASE.md | 6 +++--- Shared/AppStore.swift | 9 ++++----- fastlane/APP_STORE_LISTING.md | 24 +++++++++++++++++------- 4 files changed, 31 insertions(+), 15 deletions(-) diff --git a/MacTests/AppStoreTests.swift b/MacTests/AppStoreTests.swift index a3c86f46..b433376a 100644 --- a/MacTests/AppStoreTests.swift +++ b/MacTests/AppStoreTests.swift @@ -69,6 +69,13 @@ final class AppStoreTests: XCTestCase { } } + func testTheShippingListingIsMeowDisplaysOwn() { + XCTAssertEqual(AppStore.iOSAppID, "6817552864") + XCTAssertEqual(AppStore.updateURL?.absoluteString, "itms-apps://apps.apple.com/app/id6817552864") + XCTAssertEqual(AppStore.webURL?.absoluteString, "https://apps.apple.com/app/id6817552864") + XCTAssertEqual(AppStore.receiverUpdateURL, AppStore.updateURL) + } + // MARK: - With a listing func testOwnListingIsLinkedOnceConfigured() { diff --git a/RELEASE.md b/RELEASE.md index a8d155ad..7c49a645 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -106,10 +106,10 @@ locally instead of by the workflows: Values still to fill in before or at release: -- [ ] `AppStore.iOSAppID` in `Shared/AppStore.swift` — the app's numeric - Apple ID from App Store Connect (see `fastlane/APP_STORE_LISTING.md`). +- [x] `AppStore.iOSAppID` in `Shared/AppStore.swift` — `6817552864`, the + app's Apple ID from App Store Connect. - [ ] `ProjectLinks.koFiPage` in `Shared/ProjectLinks.swift` — the Ko-fi page - URL, once it exists. Until then no app shows a support link. Add the + URL, once it exists (deferred past 1.20.0). Until then no app shows a support link. Add the same URL to the README's Contributing section and `SUPPORT.md` then. ## Required secrets diff --git a/Shared/AppStore.swift b/Shared/AppStore.swift index e0add50a..3cbbb954 100644 --- a/Shared/AppStore.swift +++ b/Shared/AppStore.swift @@ -2,10 +2,9 @@ // an out-of-date receiver is sent to update, centralized so the iOS app's // update screen AND the Mac's `updateRequired` message use the same link. // -// There is deliberately no listing yet: the ID this file used to carry was -// upstream OpenDisplay's, and no MeowDisplay build may send anyone to another -// app's listing. Until `iOSAppID` is set, every update link resolves to the -// project page instead. +// The ID this file once carried was upstream OpenDisplay's; no MeowDisplay +// build may send anyone to another app's listing. Without `iOSAppID`, every +// update link resolves to the project page instead. import Foundation @@ -14,7 +13,7 @@ enum AppStore { /// `apps.apple.com/app/id…`), or nil while it has none. The one place to /// configure: set it once App Store Connect assigns the app its Apple ID, /// and every URL below follows. - static let iOSAppID: String? = nil + static let iOSAppID: String? = "6817552864" /// The project page: how to get and update every MeowDisplay app, and /// where an update link points while there is no App Store listing. diff --git a/fastlane/APP_STORE_LISTING.md b/fastlane/APP_STORE_LISTING.md index 3e7e5fd4..dcccc25e 100644 --- a/fastlane/APP_STORE_LISTING.md +++ b/fastlane/APP_STORE_LISTING.md @@ -16,6 +16,18 @@ that is not recorded in this repository. --- +## App identity + +| Field | Value | +|---|---| +| Name | MeowDisplay | +| Bundle ID | `com.raisecaterror.meowdisplay.ios` | +| Apple ID (App Store Connect) | `6817552864` | +| First version / build | 1.20.0 (1) | +| Source | `https://github.com/raiseCatError/meowdisplay` | + +No donation or support-payment link ships in 1.20.0. + ## App Information (applies to all versions) **Name** @@ -139,13 +151,11 @@ Work through this before pressing **Submit for Review**. ### Before the first submission -- [ ] **Set `AppStore.iOSAppID`** in `Shared/AppStore.swift` to this app's - Apple ID from App Store Connect (the digits only). It is `nil` until - then, so the iOS update screen and the Mac's `updateRequired` message - link to the GitHub project page instead of any App Store listing. It - must never be upstream OpenDisplay's ID — `AppStoreTests` fails if that - appears anywhere in the repository. Set it in the build that ships, or - the first release still points users at GitHub to update. +- [x] **`AppStore.iOSAppID`** in `Shared/AppStore.swift` is this app's Apple + ID, `6817552864`, so the iOS update screen and the Mac's + `updateRequired` message open MeowDisplay's own listing. It must never be + upstream OpenDisplay's ID — `AppStoreTests` fails if that appears + anywhere in the repository. - [ ] **Provide a Mac build App Review can install.** There is no public notarized Mac download yet. Publish one (for example an unlisted or pre-release GitHub Release asset) or attach a download link in the review From a252e824f555ae671c636087cd701b4a5f23d95f Mon Sep 17 00:00:00 2001 From: raiseCatError <315733358+raiseCatError@users.noreply.github.com> Date: Wed, 30 Sep 2026 16:56:46 +0530 Subject: [PATCH 15/30] chore(ios): declare no non-exempt encryption for App Store uploads Set ITSAppUsesNonExemptEncryption to NO in the iOS target's Info.plist properties, matching the App Store Connect export-compliance answer for 1.20.0: the app uses only standard, exempt encryption and needs no documentation, so later uploads skip the questionnaire. It declares no non-exempt encryption, not an absence of cryptography. The listing reference records the answer and that 1.20.0 is not distributed in France. --- MacTests/PrivacyManifestTests.swift | 33 +++++++++++++++++++++-------- fastlane/APP_STORE_LISTING.md | 13 +++++++----- project.yml | 5 +++++ 3 files changed, 37 insertions(+), 14 deletions(-) diff --git a/MacTests/PrivacyManifestTests.swift b/MacTests/PrivacyManifestTests.swift index fb0e9c6b..69c9cc97 100644 --- a/MacTests/PrivacyManifestTests.swift +++ b/MacTests/PrivacyManifestTests.swift @@ -90,15 +90,7 @@ final class PrivacyManifestTests: XCTestCase { /// compiles in. func testProjectCopiesTheManifestIntoTheIOSApp() throws { let spec = try String(contentsOf: Self.repoRoot.appendingPathComponent("project.yml"), encoding: .utf8) - let lines = spec.components(separatedBy: "\n") - let targets = try XCTUnwrap(lines.firstIndex(of: "targets:")) - let start = try XCTUnwrap(lines[targets...].firstIndex(of: " OpenSidecariOS:"), "iOS target not found") - // The block ends at the next key indented as deeply as a target or less. - let end = lines[(start + 1)...].firstIndex { line in - let content = line.trimmingCharacters(in: .whitespaces) - return !content.isEmpty && !content.hasPrefix("#") && line.prefix { $0 == " " }.count <= 2 - } ?? lines.endIndex - let target = lines[start.. [String] { + let lines = spec.components(separatedBy: "\n") + let targets = try XCTUnwrap(lines.firstIndex(of: "targets:")) + let start = try XCTUnwrap(lines[targets...].firstIndex(of: " OpenSidecariOS:"), "iOS target not found") + // The block ends at the next key indented as deeply as a target or less. + let end = lines[(start + 1)...].firstIndex { line in + let content = line.trimmingCharacters(in: .whitespaces) + return !content.isEmpty && !content.hasPrefix("#") && line.prefix { $0 == " " }.count <= 2 + } ?? lines.endIndex + return lines[start.. Date: Thu, 1 Oct 2026 00:43:00 +0530 Subject: [PATCH 16/30] feat(mac): enable Sparkle updates with MeowDisplay's own feeds and key Both Mac apps now point Sparkle at MeowDisplay-owned feeds (meowdisplay.app/appcast.xml and appcast-receiver.xml) and trust a new MeowDisplay EdDSA public key. Signed feeds and verify-before-extraction are required. SUEnableAutomaticChecks is no longer forced off, so Sparkle asks the user before checking in the background. The release workflow now signs and verifies both appcasts with Sparkle 2.10.0 tools (tarball pinned by SHA-256) and uploads them to the GitHub Release instead of the unused GitHub Pages path. meowdisplay.app redirects the feed URLs to the latest release. The Mac build number is now the marketing version, so Sparkle sees every release as newer, and the signing key is scoped to the one step that uses it. SparkleConfigurationTests pins the feeds, key and consent behaviour, and rejects upstream OpenDisplay's feeds and key. --- .github/workflows/release.yml | 109 ++++++++--------- COMPATIBILITY.md | 2 +- MacReceiver/Info.plist | 10 +- MacTests/SparkleConfigurationTests.swift | 106 +++++++++++++++++ RELEASE.md | 143 ++++++++++++++++++----- SECURITY.md | 9 ++ project.yml | 40 ++++--- 7 files changed, 310 insertions(+), 109 deletions(-) create mode 100644 MacTests/SparkleConfigurationTests.swift diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index fe85480d..7522dc72 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -7,9 +7,6 @@ on: permissions: contents: write pull-requests: write - # Lets the build-mac job dispatch the Pages deploy after committing the - # Sparkle appcast (a GITHUB_TOKEN push won't auto-trigger pages.yml). - actions: write # Serialize release runs on the same ref so two quick merges can't race two # uploads. Never cancel an in-flight run — it may be mid-upload or notarizing. @@ -37,11 +34,6 @@ jobs: needs: release-please if: needs.release-please.outputs.release_created == 'true' runs-on: macos-26 - # The `secrets` context can't be referenced in a step-level `if:` (it's a - # workflow-validation error). Map the Sparkle key to a job env var so the - # appcast step can guard on its presence via the allowed `env` context. - env: - SPARKLE_PRIVATE_KEY: ${{ secrets.SPARKLE_PRIVATE_KEY }} steps: - uses: actions/checkout@v6 @@ -72,9 +64,13 @@ jobs: APP_STORE_CONNECT_KEY_CONTENT: ${{ secrets.APP_STORE_CONNECT_KEY_CONTENT }} DEVELOPMENT_TEAM: ${{ secrets.DEVELOPMENT_TEAM }} TAG_NAME: ${{ needs.release-please.outputs.tag_name }} - APP_BUILD_NUMBER: ${{ github.run_number }} run: | export APP_MARKETING_VERSION="${TAG_NAME#v}" + # Sparkle orders updates by CFBundleVersion, so the Mac build number + # must grow with every release, including the first one (1.20.0), + # which was built by hand. The marketing version does; this + # workflow's run number restarts at 1 on the fork and would not. + export APP_BUILD_NUMBER="$APP_MARKETING_VERSION" bundle exec fastlane mac build_release bundle exec fastlane mac build_receiver_release @@ -90,79 +86,70 @@ jobs: # --- Sparkle auto-update: generate + publish the appcasts (issue #52) --- # Builds appcast.xml (sender) and appcast-receiver.xml (receiver app) - # from the just-released, notarized DMGs and commits them to public/ on - # main. One feed per app: each bundle has its own SUFeedURL, and a mixed - # feed would offer the wrong app's update. + # from the just-released, notarized DMGs and uploads them to the same + # GitHub Release. One feed per app: each bundle has its own SUFeedURL, + # and a mixed feed would offer the wrong app's update. # - # Currently a no-op regardless: client-side Sparkle checks are disabled - # (no SUFeedURL/SUPublicEDKey in project.yml — see its Sparkle comment) - # until MeowDisplay has its own hosted domain and a signing key pair - # that isn't upstream OpenDisplay's. Re-wire this step's target URL/feed - # once that infrastructure exists. + # The apps' SUFeedURLs (https://meowdisplay.app/appcast.xml and + # /appcast-receiver.xml) redirect on Cloudflare to + # releases/latest/download/, so the feed goes live together with + # the release and needs no website deploy. See RELEASE.md. # - # No-ops gracefully until the maintainer adds the SPARKLE_PRIVATE_KEY - # secret (the EdDSA private key from Sparkle's `generate_keys`), so this - # never hard-fails a release before auto-update is switched on. - - name: Publish Sparkle appcast to GitHub Pages - if: ${{ env.SPARKLE_PRIVATE_KEY != '' }} + # The apps require signed feeds (SURequireSignedFeed), which + # generate_appcast produces when it finds that key in the archived app. + # Required: without SPARKLE_PRIVATE_KEY no feed can be signed, and + # installed apps would never be offered this release. + - name: Publish Sparkle appcasts to the GitHub Release env: GH_TOKEN: ${{ github.token }} TAG_NAME: ${{ needs.release-please.outputs.tag_name }} - # Sparkle release that ships the generate_appcast / sign_update tools. - SPARKLE_TOOLS_VERSION: "2.6.4" + # Only this step sees the signing key. + SPARKLE_PRIVATE_KEY: ${{ secrets.SPARKLE_PRIVATE_KEY }} + # Sparkle release that ships the generate_appcast / sign_update + # tools, and the SHA-256 GitHub publishes for its tarball. + SPARKLE_TOOLS_VERSION: "2.10.0" + SPARKLE_TOOLS_SHA256: "c2bf58aa8387266ac179357b1415d6f2635f044da8be41042af32425dae6da0c" run: | set -euo pipefail - # Sparkle's CLI tools (generate_appcast, sign_update) ship in the - # official release tarball — download the matching version. + if [ -z "$SPARKLE_PRIVATE_KEY" ]; then + echo "::error::SPARKLE_PRIVATE_KEY is not set; cannot sign the appcasts" + exit 1 + fi + curl -fsSL -o /tmp/sparkle.tar.xz \ "https://github.com/sparkle-project/Sparkle/releases/download/${SPARKLE_TOOLS_VERSION}/Sparkle-${SPARKLE_TOOLS_VERSION}.tar.xz" + echo "${SPARKLE_TOOLS_SHA256} /tmp/sparkle.tar.xz" | shasum -a 256 -c - mkdir -p /tmp/sparkle && tar -xf /tmp/sparkle.tar.xz -C /tmp/sparkle - GEN_APPCAST="$(find /tmp/sparkle -name generate_appcast -type f | head -1)" - echo "Using $GEN_APPCAST" - - # The EdDSA private key (CI secret) → file for --ed-key-file. - printf '%s' "$SPARKLE_PRIVATE_KEY" > /tmp/sparkle_ed_priv + SPARKLE_BIN=/tmp/sparkle/bin # generate_appcast wants a directory of update archives per feed. - # Point the download links at the GitHub Release assets where the - # DMGs live. + # Download links point at this release's immutable per-tag assets. mkdir -p /tmp/appcast-updates /tmp/appcast-receiver-updates cp build/export/MeowDisplay.dmg /tmp/appcast-updates/ cp build/export/MeowDisplayReceiver.dmg /tmp/appcast-receiver-updates/ - "$GEN_APPCAST" \ - --ed-key-file /tmp/sparkle_ed_priv \ + # The key goes in on stdin (`--ed-key-file -`), never to disk. + printf '%s' "$SPARKLE_PRIVATE_KEY" | "$SPARKLE_BIN/generate_appcast" \ + --ed-key-file - \ --download-url-prefix "https://github.com/${GITHUB_REPOSITORY}/releases/download/${TAG_NAME}/" \ + --link "https://meowdisplay.app/" \ -o /tmp/appcast-updates/appcast.xml \ /tmp/appcast-updates - "$GEN_APPCAST" \ - --ed-key-file /tmp/sparkle_ed_priv \ + printf '%s' "$SPARKLE_PRIVATE_KEY" | "$SPARKLE_BIN/generate_appcast" \ + --ed-key-file - \ --download-url-prefix "https://github.com/${GITHUB_REPOSITORY}/releases/download/${TAG_NAME}/" \ + --link "https://meowdisplay.app/" \ -o /tmp/appcast-receiver-updates/appcast-receiver.xml \ /tmp/appcast-receiver-updates - # Commit the feed into public/ on main; Pages picks it up from there. - git config user.name "github-actions[bot]" - git config user.email "github-actions[bot]@users.noreply.github.com" - git fetch origin main - git checkout main - mkdir -p public - cp /tmp/appcast-updates/appcast.xml public/appcast.xml - cp /tmp/appcast-receiver-updates/appcast-receiver.xml public/appcast-receiver.xml - rm -f /tmp/sparkle_ed_priv - git add public/appcast.xml public/appcast-receiver.xml - # Compare the STAGED files: plain `git diff` ignores untracked files, - # so the first-ever appcast (a brand-new public/appcast.xml) looked - # "unchanged" and was never committed. `--cached` sees the staged add. - if ! git diff --cached --quiet -- public/appcast.xml public/appcast-receiver.xml; then - git commit -m "chore(release): publish Sparkle appcasts for ${TAG_NAME} [skip ci]" - git push origin main - # Pushes made with GITHUB_TOKEN do NOT trigger other workflows - # (GitHub blocks recursive runs), so kick the Pages deploy - # explicitly via its workflow_dispatch entry point. - gh workflow run pages.yml --ref main || \ - echo "could not dispatch pages.yml — deploy it manually" - else - echo "appcast.xml unchanged — nothing to publish" - fi + # The apps reject an unsigned feed or update, so check both + # signatures with Sparkle's own verifier before publishing. + for feed in /tmp/appcast-updates/appcast.xml /tmp/appcast-receiver-updates/appcast-receiver.xml; do + grep -q 'sparkle:edSignature=' "$feed" + printf '%s' "$SPARKLE_PRIVATE_KEY" | "$SPARKLE_BIN/sign_update" --ed-key-file - --verify "$feed" + done + + gh release upload "$TAG_NAME" --clobber \ + /tmp/appcast-updates/appcast.xml \ + /tmp/appcast-receiver-updates/appcast-receiver.xml diff --git a/COMPATIBILITY.md b/COMPATIBILITY.md index 237ba00a..56eac553 100644 --- a/COMPATIBILITY.md +++ b/COMPATIBILITY.md @@ -23,7 +23,7 @@ We cut Mac and iOS from one release tag, but distribution is not simultaneous: | | Update channel | Time to reach a user | |---|---|---| -| **Mac** | Sparkle appcast (upstream: `opendisplay.app/appcast.xml`; MeowDisplay's Sparkle checks are off until it hosts its own) | hours — auto, silent | +| **Mac** | Sparkle appcast (`meowdisplay.app/appcast.xml`, `appcast-receiver.xml`) | within a day once the user allows automatic checks (Sparkle asks); installs when the user accepts, unless they opted into automatic installs | | **iOS** | App Store (review + user tapping Update) | days → weeks, with a **long tail** | So at any moment the field holds many version pairings. The dominant one is diff --git a/MacReceiver/Info.plist b/MacReceiver/Info.plist index 5bdcf7e4..039ebb79 100644 --- a/MacReceiver/Info.plist +++ b/MacReceiver/Info.plist @@ -33,7 +33,13 @@ MeowDisplay Receiver advertises itself on the local network so another Mac can connect and use this Mac as a display. NSPrincipalClass NSApplication - SUEnableAutomaticChecks - + SUFeedURL + https://meowdisplay.app/appcast-receiver.xml + SUPublicEDKey + M9Je3RXE9GYGv7ZvVmJj2xK2FrpSR/rQvbuZFfYORcU= + SURequireSignedFeed + + SUVerifyUpdateBeforeExtraction + diff --git a/MacTests/SparkleConfigurationTests.swift b/MacTests/SparkleConfigurationTests.swift new file mode 100644 index 00000000..1c97604f --- /dev/null +++ b/MacTests/SparkleConfigurationTests.swift @@ -0,0 +1,106 @@ +import XCTest + +/// Pins the Sparkle auto-update configuration both direct-distribution Mac +/// apps ship with: MeowDisplay's own feeds and EdDSA public key, signed-feed +/// enforcement, and no forced automatic checks or installs. Checks +/// project.yml (the source of truth) and the Info.plists XcodeGen generates +/// from it (what actually lands in the app bundles). +final class SparkleConfigurationTests: XCTestCase { + private static let repoRoot = URL(fileURLWithPath: #filePath) + .resolvingSymlinksInPath() + .deletingLastPathComponent() + .deletingLastPathComponent() + + private static let publicEDKey = "M9Je3RXE9GYGv7ZvVmJj2xK2FrpSR/rQvbuZFfYORcU=" + + private struct MacApp { + let target: String + let infoPlist: String + let feedURL: String + } + + private static let apps = [ + MacApp(target: "OpenSidecarMac", infoPlist: "Mac/Info.plist", + feedURL: "https://meowdisplay.app/appcast.xml"), + MacApp(target: "OpenSidecarMacReceiver", infoPlist: "MacReceiver/Info.plist", + feedURL: "https://meowdisplay.app/appcast-receiver.xml"), + ] + + /// Upstream OpenDisplay's feed hosts and public key: trusting either + /// would let upstream's releases update MeowDisplay installs. + private static let upstreamPublicEDKey = "rYxlIePmwzi2bRo/qIsuY2TqTnQ34li2gQhJpGBiumw=" + private static let upstreamFeedHosts = ["opendisplay.app", "peetzweg.github.io"] + + func testProjectConfiguresEachMacAppsOwnFeedAndKey() throws { + for app in Self.apps { + let info = try Self.infoProperties(ofTarget: app.target) + XCTAssertEqual(info["SUFeedURL"], app.feedURL, app.target) + XCTAssertEqual(info["SUPublicEDKey"], Self.publicEDKey, app.target) + XCTAssertEqual(info["SUVerifyUpdateBeforeExtraction"], "true", app.target) + XCTAssertEqual(info["SURequireSignedFeed"], "true", app.target) + } + } + + /// With SUEnableAutomaticChecks unset Sparkle asks before it starts + /// checking in the background; setting it either way skips that prompt. + /// Automatic installation stays the user's opt-in too. + func testProjectLeavesAutomaticChecksAndInstallsToTheUser() throws { + for app in Self.apps { + let info = try Self.infoProperties(ofTarget: app.target) + XCTAssertNil(info["SUEnableAutomaticChecks"], app.target) + XCTAssertNil(info["SUAutomaticallyUpdate"], app.target) + } + } + + func testGeneratedInfoPlistsCarryTheSparkleConfiguration() throws { + for app in Self.apps { + let url = Self.repoRoot.appendingPathComponent(app.infoPlist) + let data = try Data(contentsOf: url) + let plist = try XCTUnwrap( + PropertyListSerialization.propertyList(from: data, format: nil) as? [String: Any]) + XCTAssertEqual(plist["SUFeedURL"] as? String, app.feedURL, app.infoPlist) + XCTAssertEqual(plist["SUPublicEDKey"] as? String, Self.publicEDKey, app.infoPlist) + XCTAssertEqual(plist["SUVerifyUpdateBeforeExtraction"] as? Bool, true, app.infoPlist) + XCTAssertEqual(plist["SURequireSignedFeed"] as? Bool, true, app.infoPlist) + XCTAssertNil(plist["SUEnableAutomaticChecks"], app.infoPlist) + XCTAssertNil(plist["SUAutomaticallyUpdate"], app.infoPlist) + } + } + + func testNothingPointsAtUpstreamOpenDisplayUpdates() throws { + var sources = [try String(contentsOf: Self.repoRoot.appendingPathComponent("project.yml"), + encoding: .utf8)] + for app in Self.apps { + sources.append(try String(contentsOf: Self.repoRoot.appendingPathComponent(app.infoPlist), + encoding: .utf8)) + } + for source in sources { + XCTAssertFalse(source.contains(Self.upstreamPublicEDKey)) + for host in Self.upstreamFeedHosts { + XCTAssertFalse(source.contains("https://\(host)/"), host) + } + } + } + + /// `key: value` pairs directly under a target's `info.properties`. + private static func infoProperties(ofTarget name: String) throws -> [String: String] { + let spec = try String(contentsOf: repoRoot.appendingPathComponent("project.yml"), encoding: .utf8) + let lines = spec.components(separatedBy: "\n") + let targets = try XCTUnwrap(lines.firstIndex(of: "targets:")) + let start = try XCTUnwrap(lines[targets...].firstIndex(of: " \(name):"), "\(name) not found") + let properties = try XCTUnwrap( + lines[start...].firstIndex(of: " properties:"), "\(name) has no info properties") + var result: [String: String] = [:] + for line in lines[(properties + 1)...] { + let content = line.trimmingCharacters(in: .whitespaces) + if content.isEmpty || content.hasPrefix("#") { continue } + // The block ends at the first key indented less than a property. + guard line.prefix(while: { $0 == " " }).count >= 8 else { break } + guard let colon = content.firstIndex(of: ":") else { continue } + let key = String(content[../appcast.xml + ``` + + Upload both DMGs and both feeds to the GitHub Release, just as CI does. Values still to fill in before or at release: @@ -114,11 +129,17 @@ Values still to fill in before or at release: ## Required secrets -None are currently configured on this fork. Exact inventory (see the -release-audit issue for the full table): `DEVELOPMENT_TEAM`, -`MATCH_GIT_URL`, `MATCH_DEPLOY_KEY`, `MATCH_PASSWORD`, +Exact inventory (see the release-audit issue for the full table): +`DEVELOPMENT_TEAM`, `MATCH_GIT_URL`, `MATCH_DEPLOY_KEY`, `MATCH_PASSWORD`, `APP_STORE_CONNECT_KEY_ID`, `APP_STORE_CONNECT_ISSUER_ID`, -`APP_STORE_CONNECT_KEY_CONTENT`, and optionally `SPARKLE_PRIVATE_KEY`. +`APP_STORE_CONNECT_KEY_CONTENT`, and `SPARKLE_PRIVATE_KEY`. The Mac +release job fails without `SPARKLE_PRIVATE_KEY`, because unsigned feeds +would leave installed apps without the update. + +Configured on this fork: `DEVELOPMENT_TEAM`, `MATCH_GIT_URL`, +`MATCH_DEPLOY_KEY`, `SPARKLE_PRIVATE_KEY`. Still missing: `MATCH_PASSWORD` +and the three `APP_STORE_CONNECT_*` secrets, so CI cannot yet build a +release by itself. ## Verification @@ -126,6 +147,14 @@ Once a release build exists: - `codesign --verify --deep --strict MeowDisplay.app` - `spctl -a -vv MeowDisplay.dmg` (Gatekeeper assessment — should say "accepted") +- `sign_update --account meowdisplay --verify appcast.xml` (and + `appcast-receiver.xml`): the feed's embedded signature is valid. + `sign_update --account meowdisplay --verify MeowDisplay.dmg ` + with the enclosure's `sparkle:edSignature`: the DMG is the one the feed + signed. +- Once the release is published: `https://meowdisplay.app/appcast.xml` and + `/appcast-receiver.xml` serve those same files, and **Check for + Updates…** in the previous version finds the new one. - Mount the DMG and launch the app on a non-development Mac account. - For iOS, confirm the build processes in App Store Connect and shows the expected version and build number before submitting it for review. The @@ -139,21 +168,77 @@ Once a release build exists: - A GitHub Release is just tagged, uploaded artifacts — a bad release can be marked as a pre-release/draft or deleted from the Releases page without affecting `main` or the tag history. -- Because Sparkle auto-update is currently disabled client-side (no - `SUFeedURL`/`SUPublicEDKey` in `project.yml`), a bad DMG upload does not - propagate to existing installs — there is nothing consuming the appcast - yet. Once Sparkle is enabled, rollback will additionally require - correcting or removing the bad entry from `public/appcast.xml`. +- A bad Mac release reaches existing installs through Sparkle. To stop it + spreading, delete that release's `appcast.xml` / `appcast-receiver.xml` + assets, or mark the release as a draft or pre-release so that + `releases/latest` moves back to the previous release and its feeds. + Sparkle never downgrades, so anyone already updated needs a fixed, higher + version. Never hand-edit a published feed: it is signed, and the apps + reject an edited copy. Regenerate it with `generate_appcast` instead. - An iOS version still in review can be withdrawn from App Store Connect. A released App Store version cannot be pulled back to an earlier build; ship a fixed version instead, or remove the app from sale if needed. - A bad TestFlight build can be expired in App Store Connect. -## Sparkle auto-update status - -Disabled intentionally: `project.yml` has `SUEnableAutomaticChecks: false` -and no feed URL/public key, because MeowDisplay does not yet have its own -hosted appcast domain or signing key pair (must not reuse upstream -OpenDisplay's). Re-enable by generating a fresh MeowDisplay-owned Sparkle -key pair, adding `SUFeedURL`/`SUPublicEDKey` to `project.yml`, and setting -the `SPARKLE_PRIVATE_KEY` CI secret. +## Sparkle auto-update + +Both direct-distribution Mac apps update through Sparkle, starting with +1.20.0. The iOS app does not include Sparkle. `SparkleConfigurationTests` +pins the configuration below. + +**Configuration** (`project.yml`, Mac targets' `info.properties`): + +| | MeowDisplay | MeowDisplay Receiver | +|---|---|---| +| `SUFeedURL` | `https://meowdisplay.app/appcast.xml` | `https://meowdisplay.app/appcast-receiver.xml` | +| `SUPublicEDKey` | `M9Je3RXE9GYGv7ZvVmJj2xK2FrpSR/rQvbuZFfYORcU=` | same key | + +- `SUVerifyUpdateBeforeExtraction` and `SURequireSignedFeed` are on. Sparkle + checks the DMG's EdDSA signature before extracting it, and ignores any + feed or release notes not signed with the MeowDisplay key. +- `SUEnableAutomaticChecks` is left unset on purpose. Sparkle then asks on + the second launch whether to check automatically, and nothing runs in the + background until the user agrees. Automatic installation + (`SUAutomaticallyUpdate`) stays off unless the user turns it on in that + prompt. **Check for Updates…** (Settings → System → Updates, and the + Receiver's app menu) always works. +- Never point either app at upstream OpenDisplay's feeds or key. + +**Signing key.** An Ed25519 key made with Sparkle 2.10.0's `generate_keys` +for MeowDisplay only. It is not upstream OpenDisplay's key. + +- The private key lives in the maintainer's login Keychain under + `generate_keys`' `--account meowdisplay` (the Keychain item is "Private + key for signing Sparkle updates"). The same key is stored in the + `SPARKLE_PRIVATE_KEY` Actions secret. The private key is never committed + or printed. +- GitHub secrets can't be read back, so keep an offline backup: + `generate_keys --account meowdisplay -x `, stored in a password + manager, with the file deleted afterwards. On another Mac, + `generate_keys --account meowdisplay -f ` imports it. +- To confirm which public key the Keychain holds: + `generate_keys --account meowdisplay -p`. + +**Feed hosting.** CI uploads each release's signed feeds as assets of the +GitHub Release. Their enclosures point at that tag's immutable +`releases/download/vX.Y.Z/*.dmg` URLs. On Cloudflare, meowdisplay.app +redirects (302) each feed to the latest release's copy: + +- `/appcast.xml` → `https://github.com/raiseCatError/meowdisplay/releases/latest/download/appcast.xml` +- `/appcast-receiver.xml` → `https://github.com/raiseCatError/meowdisplay/releases/latest/download/appcast-receiver.xml` + +So a feed goes live together with its release, and no website deploy is +involved. Sparkle follows HTTPS redirects, and the feed's own signature +protects it, not the hosting. A release without feed assets (drafts and +pre-releases are never "latest") makes the feeds 404 until the next full +release. Installed apps then find no update and do not break. + +**Key rotation.** Change only when necessary, such as a lost or leaked key. +Because updates are Developer ID-signed DMGs, Sparkle accepts an update that +changes *either* the EdDSA key *or* the Developer ID certificate, but never +both at once. Ship an update signed with the old key whose app carries the +new `SUPublicEDKey`, then switch `SPARKLE_PRIVATE_KEY`. If the old private +key is lost, installed apps reject feeds signed with the new key until +`SUSignedFeedFailureExpirationInterval` (20 days by default) passes. After +that, Sparkle accepts the feed without signature validation but strips its +release notes and links. diff --git a/SECURITY.md b/SECURITY.md index a1c2b096..52057b84 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -142,6 +142,15 @@ other end. It does not by itself admit a session: Remote Access, your traffic does traverse whatever private network you've chosen (e.g. Tailscale's infrastructure, per Tailscale's own security model) — that's a property of the network you picked, not of MeowDisplay. +- **Update checks (Mac apps only)**: Sparkle fetches + `https://meowdisplay.app/appcast.xml` (the Receiver fetches + `appcast-receiver.xml`). That URL redirects to the feed on the project's + GitHub Releases page. Checks happen only after the user allows automatic + checks (Sparkle asks), or when the user chooses **Check for Updates…**. + No system profile is sent. Both the feed and the downloaded update must + carry a valid signature from MeowDisplay's EdDSA key, and macOS code + signing still applies to the updated app. The iOS app makes no + update-check request. ## What this doesn't claim diff --git a/project.yml b/project.yml index 6843b03e..1bf9d2fe 100644 --- a/project.yml +++ b/project.yml @@ -3,15 +3,14 @@ options: createIntermediateGroups: true # Swift Package Manager dependencies. Sparkle is the macOS apps' out-of-store # auto-update framework (issue #52): embedded open-source code + CLI tooling, -# not a hosted service. Its checks are currently switched off — MeowDisplay -# has no appcast or signing key of its own yet (see SUEnableAutomaticChecks -# on the Mac targets below). +# not a hosted service. Feed URLs and the EdDSA public key are set on the Mac +# targets below. packages: Sparkle: url: https://github.com/sparkle-project/Sparkle - # 2.x is the current major (latest 2.9.x) and the first Sparkle line to - # support SPM. `from:` allows compatible 2.x updates. - from: "2.6.0" + # 2.9 is the first release that supports signed feeds + # (SURequireSignedFeed below). `from:` allows compatible 2.x updates. + from: "2.9.0" SwiftCertificates: url: https://github.com/apple/swift-certificates from: "1.19.1" @@ -75,13 +74,20 @@ targets: NSLocalNetworkUsageDescription: MeowDisplay connects to your iPad or iPhone over the local network in WiFi mode. NSBonjourServices: ["_opensidecar._tcp", "_meowdisp-q._udp", "_opendisplay-pair._tcp", "_opendisplay-mac-pair._tcp"] LSApplicationCategoryType: public.app-category.utilities - # --- Sparkle auto-update --- - # Disabled: MeowDisplay does not yet have its own appcast + signing - # infrastructure, and must not consume upstream OpenDisplay's feed or - # trust its signing key. Re-add SUFeedURL + SUPublicEDKey (a fresh - # MeowDisplay-owned key pair — never reuse upstream's) and flip this - # back to true once MeowDisplay hosts its own appcast. - SUEnableAutomaticChecks: false + # --- Sparkle auto-update (see RELEASE.md) --- + # MeowDisplay's own feed and EdDSA public key — never upstream + # OpenDisplay's. Both Mac apps trust the same key; each has its own + # feed so neither is offered the other app's update. + # SUEnableAutomaticChecks is deliberately unset: Sparkle then asks the + # user before turning on background checks. + SUFeedURL: https://meowdisplay.app/appcast.xml + SUPublicEDKey: M9Je3RXE9GYGv7ZvVmJj2xK2FrpSR/rQvbuZFfYORcU= + # Check the EdDSA signature before extracting an update, and only + # accept an appcast/release notes signed with that key (a compromised + # feed host can't redirect users elsewhere). generate_appcast signs + # the feed when it sees these keys in the archived app. + SUVerifyUpdateBeforeExtraction: true + SURequireSignedFeed: true settings: base: PRODUCT_BUNDLE_IDENTIFIER: com.raisecaterror.meowdisplay.mac @@ -138,9 +144,11 @@ targets: NSLocalNetworkUsageDescription: MeowDisplay Receiver advertises itself on the local network so another Mac can connect and use this Mac as a display. NSBonjourServices: ["_opensidecar._tcp", "_meowdisp-q._udp", "_opendisplay-pair._tcp", "_opendisplay-mac-pair._tcp"] LSApplicationCategoryType: public.app-category.utilities - # Disabled: see the sender target's Sparkle comment above — no - # MeowDisplay-owned appcast/signing infrastructure exists yet. - SUEnableAutomaticChecks: false + # Sparkle: see the sender target's comment above. Own feed, same key. + SUFeedURL: https://meowdisplay.app/appcast-receiver.xml + SUPublicEDKey: M9Je3RXE9GYGv7ZvVmJj2xK2FrpSR/rQvbuZFfYORcU= + SUVerifyUpdateBeforeExtraction: true + SURequireSignedFeed: true settings: base: PRODUCT_BUNDLE_IDENTIFIER: com.raisecaterror.meowdisplay.mac.receiver From f4f097dae6de777225bcba742fc534d8bf91afae Mon Sep 17 00:00:00 2001 From: raiseCatError <315733358+raiseCatError@users.noreply.github.com> Date: Sun, 11 Oct 2026 16:39:02 +0530 Subject: [PATCH 17/30] feat(ios): add populated demo mode for App Review Adds an Explore Demo entry on the home screen that opens a clearly labeled sample session with a fictional "Review Mac", so the receiver, its controls and every Settings page can be explored without a Mac. - DemoSession holds all sample state in memory. It has no StreamReceiver, and never touches TrustStore, RemoteEndpointStore, the connection log or the standard defaults. - ReceiverControlOverlay is generic over ReceiverControlSource; StreamReceiver is the real source, DemoSession turns sends into local "Demo, not sent" feedback. - Demo control preferences use a separate defaults domain, cleared on entry and exit. Allow Input is a local demo state. - Settings routes Mac-dependent categories to demo pages; local-only pages are reused with the demo store. A real session ends the demo. - Unit tests for the isolation boundary and an iPad/iPhone UI smoke test (launch, Explore Demo, all Settings categories, exit). --- MacTests/DemoSessionTests.swift | 114 +++++ iOS/DemoMode.swift | 765 ++++++++++++++++++++++++++++ iOS/DemoSession.swift | 196 +++++++ iOS/OpenSidecarPhoneApp.swift | 67 ++- iOS/ReceiverControls.swift | 29 +- iOS/ReceiverSettings.swift | 45 +- iOSUITests/DemoModeSmokeTests.swift | 82 +++ project.yml | 18 + 8 files changed, 1286 insertions(+), 30 deletions(-) create mode 100644 MacTests/DemoSessionTests.swift create mode 100644 iOS/DemoMode.swift create mode 100644 iOS/DemoSession.swift create mode 100644 iOSUITests/DemoModeSmokeTests.swift diff --git a/MacTests/DemoSessionTests.swift b/MacTests/DemoSessionTests.swift new file mode 100644 index 00000000..db99b530 --- /dev/null +++ b/MacTests/DemoSessionTests.swift @@ -0,0 +1,114 @@ +import XCTest + +/// iOS Demo Mode's boundary: the sample session stays in memory and never +/// reaches trust, remote endpoints, real preferences or the wire. +@MainActor +final class DemoSessionTests: XCTestCase { + private func standardDefaultsSnapshot() -> [String: String] { + UserDefaults.standard.dictionaryRepresentation().mapValues { String(describing: $0) } + } + + func testEnteringDemoWritesNoTrustEndpointsOrStandardDefaults() { + let before = standardDefaultsSnapshot() + let demo = DemoSession() + demo.setInputAllowed(false) + demo.setInputAllowed(true) + demo.displayMode = .mirror + demo.setVideoEnabled(false) + demo.audioEnabled = false + demo.remoteAccessEnabled = false + demo.requestInputAutomatically = true + demo.deviceName = "Renamed" + demo.sendKeyboardPress(usage: 6, modifiers: ["command"]) + + XCTAssertFalse(TrustStore.shared.hasPin(peerID: DemoSession.peerID)) + XCTAssertFalse(TrustStore.shared.pinnedPeers().contains { $0.peerID == DemoSession.peerID }) + XCTAssertNil(RemoteEndpointStore.endpoint(forPeerID: DemoSession.peerID)) + XCTAssertFalse(InputAutoRequestStore.isEnabled(peerID: DemoSession.peerID)) + XCTAssertEqual(standardDefaultsSnapshot(), before) + } + + func testDemoStartsPopulatedWithInputAllowed() { + let demo = DemoSession() + XCTAssertTrue(demo.inputAllowed) + XCTAssertEqual(demo.inputState, .allowed) + XCTAssertEqual(demo.displayMode, .extend) + XCTAssertTrue(demo.videoEnabled) + XCTAssertTrue(demo.audioEnabled) + XCTAssertEqual(demo.codec, .hevc) + XCTAssertEqual(demo.fps, 60) + XCTAssertEqual(demo.controlSessionPhase, .connected) + XCTAssertGreaterThanOrEqual(demo.macProtocolVersion, WireProtocol.receiverControlsWireVersion) + } + + func testAllowInputTogglesOnlyDemoState() { + let demo = DemoSession() + demo.setInputAllowed(false) + XCTAssertFalse(demo.inputAllowed) + XCTAssertEqual(demo.inputState, .off) + demo.setInputAllowed(true) + XCTAssertTrue(demo.inputAllowed) + XCTAssertEqual(demo.events.suffix(2).map(\.message), ["Demo input turned off", "Demo input allowed"]) + } + + /// Demo controls produce local feedback only. The type holds no + /// `StreamReceiver`, so there is no wire to reach. + func testDemoSendsBecomeLocalFeedbackOnly() { + let demo = DemoSession() + let generation = demo.actionGeneration + demo.sendKeyboardPress(usage: 6, modifiers: ["command"]) + XCTAssertEqual(demo.lastAction, "⌘C") + demo.sendModifier(.option, down: true) + XCTAssertEqual(demo.lastAction, "Option") + demo.sendKeyboardPress(usage: 41) + XCTAssertEqual(demo.lastAction, "Esc") + XCTAssertEqual(demo.actionGeneration, generation + 3) + XCTAssertFalse(Mirror(reflecting: demo).children.contains { $0.value is StreamReceiver }) + } + + func testDiagnosticsAreSampleAndPerInstance() { + let first = DemoSession() + XCTAssertEqual(first.events.first?.message, "Demo session started (sample data, no network)") + XCTAssertTrue(first.events.allSatisfy { $0.message.contains("Demo") || $0.message.contains("Sample") }) + first.log("Demo audio resynced") + let second = DemoSession() + XCTAssertFalse(second.events.contains { $0.message == "Demo audio resynced" }) + } + + func testSampleRemoteHostIsReservedDomain() { + XCTAssertTrue(DemoSession.remoteHost.hasSuffix(".example.ts.net")) + } + + func testRealSessionAlwaysWinsAndExitRestoresIdle() { + XCTAssertEqual(ReceiverPresentationRoute.resolve(demoActive: true, realSurfaceShown: false), .demo) + XCTAssertEqual(ReceiverPresentationRoute.resolve(demoActive: true, realSurfaceShown: true), .receiver) + XCTAssertEqual(ReceiverPresentationRoute.resolve(demoActive: false, realSurfaceShown: false), .idle) + XCTAssertEqual(ReceiverPresentationRoute.resolve(demoActive: false, realSurfaceShown: true), .receiver) + } + + func testEveryReleaseSettingsCategoryHasDemoPresentation() { + let release = MobileSettingsCategory.visible(debug: false) + XCTAssertFalse(release.contains(.developer)) + for category in release { + XCTAssertNotNil(DemoSettingsPresentation.presentation(for: category), "\(category)") + } + XCTAssertNil(DemoSettingsPresentation.presentation(for: .developer)) + // Pages that need a real Mac get demo content, never an empty page. + for category: MobileSettingsCategory in [.general, .display, .audio, .connections, + .pairedMacs, .remoteAccess, .permissions, .diagnostics] { + XCTAssertEqual(DemoSettingsPresentation.presentation(for: category), .demo, "\(category)") + } + } + + /// The demo control store saves to its own domain, never the real key. + func testDemoControlPreferencesUseSeparateDomain() throws { + let defaults = try XCTUnwrap(UserDefaults(suiteName: DemoSession.controlDefaultsSuite)) + defaults.removePersistentDomain(forName: DemoSession.controlDefaultsSuite) + let realBefore = UserDefaults.standard.data(forKey: ReceiverControlPreferencesRepository.defaultsKey) + var preferences = ReceiverControlPreferences() + preferences.hapticsEnabled.toggle() + ReceiverControlPreferencesRepository(defaults: defaults).save(preferences) + XCTAssertEqual(UserDefaults.standard.data(forKey: ReceiverControlPreferencesRepository.defaultsKey), realBefore) + defaults.removePersistentDomain(forName: DemoSession.controlDefaultsSuite) + } +} diff --git a/iOS/DemoMode.swift b/iOS/DemoMode.swift new file mode 100644 index 00000000..33015d67 --- /dev/null +++ b/iOS/DemoMode.swift @@ -0,0 +1,765 @@ +import SwiftUI +import UIKit + +// MARK: - Demo Mode +// +// Demo Mode shows the receiver with a sample Mac so MeowDisplay can be +// explored without one. The boundary: the real control overlay, Settings +// shell and local-only pages are reused, driven by a `DemoSession` (in place +// of `StreamReceiver`) and a `ReceiverControlStore` saved to its own defaults +// domain. Nothing here holds the real receiver's wire, trust or endpoints. + +/// The demo's own control store: saved to a separate defaults domain that is +/// cleared on entry and exit, so the reviewer's changes never reach the real +/// receiver preferences. +@MainActor +enum DemoControlStoreFactory { + static func make() -> ReceiverControlStore { + let defaults = UserDefaults(suiteName: DemoSession.controlDefaultsSuite) ?? UserDefaults() + defaults.removePersistentDomain(forName: DemoSession.controlDefaultsSuite) + let store = ReceiverControlStore(repository: ReceiverControlPreferencesRepository(defaults: defaults)) + store.update { $0.audioPreferred = true } + store.applySessionInputState(.allowed) + return store + } + + static func discard() { + UserDefaults(suiteName: DemoSession.controlDefaultsSuite)? + .removePersistentDomain(forName: DemoSession.controlDefaultsSuite) + } +} + +/// The sample stream size: this device's own screen in pixels, landscape. +@MainActor +func demoVideoSize() -> CGSize { + let bounds = UIScreen.main.nativeBounds.size + return CGSize(width: max(bounds.width, bounds.height), height: min(bounds.width, bounds.height)) +} + +// MARK: - Receiver surface + +struct DemoReceiverScreen: View { + @ObservedObject var demo: DemoSession + @ObservedObject var store: ReceiverControlStore + @Binding var showSettings: Bool + @Binding var keyboardActive: Bool + let haptics: ReceiverHaptics + let onExit: () -> Void + + @State private var padCanvas: CGRect? + @State private var keyboardRect: CGRect? + @State private var tapPoint: CGPoint? + @State private var tapGeneration = 0 + @State private var showInputOffHint = false + @State private var feedbackVisible = false + + var body: some View { + GeometryReader { geo in + let insets = ControlSafeInsets(top: geo.safeAreaInsets.top, leading: geo.safeAreaInsets.leading, + bottom: geo.safeAreaInsets.bottom, trailing: geo.safeAreaInsets.trailing) + let canvas = canvas(in: geo.size) + ZStack { + Color.black.ignoresSafeArea() + SampleDesktopView(mode: demo.displayMode, videoEnabled: demo.videoEnabled, + showGrid: store.preferences.showSurfaceGrid) + .overlay { tapRipple } + .contentShape(Rectangle()) + .onTapGesture(coordinateSpace: .local) { location in surfaceTapped(at: location) } + .frame(width: canvas.width, height: canvas.height) + .clipped() + .position(x: canvas.midX, y: canvas.midY) + .accessibilityElement(children: .ignore) + .accessibilityLabel(Text("Sample Mac display")) + .accessibilityHint(Text("Demo content drawn on this device. Taps show locally and are not sent.")) + if demo.showPerformanceOverlay { + VStack { + Spacer() + PerfOverlay(stats: Self.sampleStats(demo), videoSize: demo.videoSize) + .padding(.bottom, 10) + } + .allowsHitTesting(false) + } + RemoteKeyboardInputView( + isActive: $keyboardActive, + onCommitText: { demo.noteTyped($0) }, + onSpecialPress: { demo.sendKeyboardPress(usage: $0) }, + onHardwareKeyDown: { usage, modifiers in demo.sendKeyboardPress(usage: usage, modifiers: modifiers) }, + onHardwareKeyUp: { _, _ in }, + onRequestDismiss: { keyboardActive = false }) + .frame(width: 1, height: 1) + .opacity(0) + .allowsHitTesting(false) + ReceiverControlOverlay( + store: store, + receiver: demo, + keyboardActive: $keyboardActive, + showSettings: $showSettings, + keyboardAvailable: demo.inputAllowed, + keyboardVisibleRect: keyboardRect, + containerSize: geo.size, + safeInsets: insets, + notchSide: nil, + reservedRegions: [], + haptics: haptics, + onOccupiedFramesChange: { _ in }, + onCanvasChange: { padCanvas = $0 }, + surfaceOrigin: canvas.origin) + VStack(spacing: 8) { + DemoSessionBanner(demo: demo, onExit: onExit) + if feedbackVisible, let action = demo.lastAction { + DemoFeedbackChip(action: action) + .transition(.opacity.combined(with: .move(edge: .top))) + } + if showInputOffHint && !demo.inputAllowed { + Button { + demo.setInputAllowed(true) + } label: { + Label("Input is off (Demo). Allow Input", systemImage: "hand.tap") + .font(.subheadline.weight(.semibold)) + .padding(.horizontal, 14) + .padding(.vertical, 8) + .background(Capsule().fill(Color(white: 0.16).opacity(0.94))) + .foregroundStyle(.white) + } + .buttonStyle(.plain) + .transition(.opacity) + } + Spacer() + } + .padding(.top, max(insets.top, 12)) + .padding(.horizontal, 16) + } + } + .ignoresSafeArea(.keyboard) + .onAppear { store.applySessionInputState(demo.inputState) } + .onChange(of: demo.inputState) { state in + store.applySessionInputState(state) + if state != .allowed { keyboardActive = false } + withAnimation { showInputOffHint = false } + } + .onChange(of: demo.actionGeneration) { generation in + withAnimation(.easeOut(duration: 0.15)) { feedbackVisible = true } + Task { + try? await Task.sleep(nanoseconds: 1_600_000_000) + guard generation == demo.actionGeneration else { return } + withAnimation(.easeIn(duration: 0.3)) { feedbackVisible = false } + } + } + .onReceive(NotificationCenter.default.publisher(for: UIResponder.keyboardWillChangeFrameNotification)) { note in + guard let frame = note.userInfo?[UIResponder.keyboardFrameEndUserInfoKey] as? CGRect else { return } + let screen = UIScreen.main.bounds + keyboardRect = frame.minY >= screen.maxY - 1 ? nil : frame + } + .onReceive(NotificationCenter.default.publisher(for: UIResponder.keyboardWillHideNotification)) { _ in + keyboardRect = nil + } + } + + private func canvas(in size: CGSize) -> CGRect { + let full = CGRect(origin: .zero, size: size) + guard store.isPad, let padCanvas, padCanvas.width > 0, padCanvas.height > 0 else { return full } + return padCanvas.intersection(full) + } + + @ViewBuilder + private var tapRipple: some View { + if let tapPoint { + Circle() + .strokeBorder(Color.white.opacity(0.9), lineWidth: 2) + .background(Circle().fill(Color.white.opacity(0.25))) + .frame(width: 44, height: 44) + .position(tapPoint) + .id(tapGeneration) + .transition(.scale(scale: 0.4).combined(with: .opacity)) + .allowsHitTesting(false) + } + } + + private func surfaceTapped(at location: CGPoint) { + guard demo.inputAllowed else { + withAnimation { showInputOffHint = true } + return + } + store.beginAutoHideActivity() + store.scheduleAutoHide() + withAnimation(.easeOut(duration: 0.15)) { + tapPoint = location + tapGeneration &+= 1 + } + demo.noteAction(String(localized: "Click")) + let generation = tapGeneration + Task { + try? await Task.sleep(nanoseconds: 450_000_000) + guard generation == tapGeneration else { return } + withAnimation(.easeIn(duration: 0.2)) { tapPoint = nil } + } + } + + static func sampleStats(_ demo: DemoSession) -> PerfStats { + var stats = PerfStats() + stats.fps = demo.fps + stats.mbps = demo.throughputMbps + stats.avgFrameMs = 16.7 + stats.maxFrameMs = 19.2 + stats.e2eP50 = Double(demo.latencyMs) + stats.e2eP95 = Double(demo.latencyMs) + 6 + stats.rttMs = 4 + stats.transport = String(localized: "Sample") + stats.samples = (0..<120).map { 16.7 + sin(Double($0) / 6) * 0.8 } + stats.e2eSamples = (0..<120).map { Double(demo.latencyMs) + sin(Double($0) / 9) * 2 } + stats.capFps = demo.fps + return stats + } +} + +/// Always-visible Demo Mode identification, with Allow Input and Exit Demo. +struct DemoSessionBanner: View { + @ObservedObject var demo: DemoSession + let onExit: () -> Void + + var body: some View { + HStack(spacing: 10) { + DemoBadge() + VStack(alignment: .leading, spacing: 0) { + Text(verbatim: DemoSession.macName).font(.subheadline.weight(.semibold)) + Text("Sample session").font(.caption) + .foregroundStyle(.white.opacity(0.75)) + } + .accessibilityElement(children: .combine) + Divider().frame(height: 24).overlay(Color.white.opacity(0.3)) + Toggle(isOn: Binding(get: { demo.inputAllowed }, set: { demo.setInputAllowed($0) })) { + Text("Input").font(.subheadline) + } + .toggleStyle(.button) + .tint(.white) + .accessibilityLabel(Text("Allow Input (Demo)")) + .accessibilityValue(demo.inputAllowed ? Text("On") : Text("Off")) + Button(action: onExit) { + Text("Exit Demo").font(.subheadline.weight(.semibold)) + } + .buttonStyle(.borderedProminent) + .tint(.orange) + .accessibilityHint(Text("Returns to the home screen.")) + } + .foregroundStyle(.white) + .padding(.leading, 10) + .padding(.trailing, 6) + .padding(.vertical, 6) + .background(Capsule().fill(Color.black.opacity(0.72))) + .overlay(Capsule().strokeBorder(Color.orange.opacity(0.8), lineWidth: 1)) + .fixedSize() + } +} + +/// The DEMO label. Text, not only color, so it reads in every mode. +struct DemoBadge: View { + var body: some View { + Text("DEMO") + .font(.caption.weight(.heavy)) + .padding(.horizontal, 7) + .padding(.vertical, 3) + .background(Capsule().fill(Color.orange)) + .foregroundStyle(.black) + .accessibilityLabel(Text("Demo Mode")) + } +} + +private struct DemoFeedbackChip: View { + let action: String + + var body: some View { + Text("\(action) · Demo, not sent") + .font(.footnote.weight(.semibold)) + .padding(.horizontal, 12) + .padding(.vertical, 6) + .background(Capsule().fill(Color(white: 0.16).opacity(0.94))) + .foregroundStyle(.white) + .accessibilityAddTraits(.updatesFrequently) + } +} + +// MARK: - Sample desktop + +/// A generic sample workspace drawn by MeowDisplay itself. It stands in for +/// the Mac's picture in Demo Mode; it is not a screenshot of any real app. +struct SampleDesktopView: View { + let mode: ReceiverDisplayMode + let videoEnabled: Bool + let showGrid: Bool + + var body: some View { + GeometryReader { geo in + let size = geo.size + let unit = min(size.width, size.height) / 100 + ZStack(alignment: .topLeading) { + if videoEnabled { + LinearGradient(colors: [Color(red: 0.24, green: 0.18, blue: 0.45), + Color(red: 0.12, green: 0.36, blue: 0.52), + Color(red: 0.20, green: 0.55, blue: 0.50)], + startPoint: .topLeading, endPoint: .bottomTrailing) + Image(systemName: "pawprint.fill") + .font(.system(size: unit * 40)) + .foregroundStyle(.white.opacity(0.06)) + .position(x: size.width * 0.72, y: size.height * 0.62) + menuBar(unit: unit) + if mode == .mirror { + notesWindow(unit: unit) + .frame(width: size.width * 0.42, height: size.height * 0.5) + .position(x: size.width * 0.29, y: size.height * 0.36) + photosWindow(unit: unit) + .frame(width: size.width * 0.38, height: size.height * 0.42) + .position(x: size.width * 0.70, y: size.height * 0.52) + } else { + chartWindow(unit: unit) + .frame(width: size.width * 0.5, height: size.height * 0.5) + .position(x: size.width * 0.5, y: size.height * 0.44) + } + dock(unit: unit) + .position(x: size.width / 2, y: size.height - unit * 7) + if showGrid { grid(size: size) } + } else { + Color.black + VStack(spacing: 10) { + Image(systemName: "video.slash").font(.largeTitle) + Text("Video is off (Demo)").font(.headline) + Text("Controls and input keep working.").font(.subheadline) + } + .foregroundStyle(.white.opacity(0.8)) + .frame(width: size.width, height: size.height) + } + } + } + } + + private func menuBar(unit: CGFloat) -> some View { + HStack(spacing: unit * 3) { + Image(systemName: "pawprint.fill") + Text(verbatim: "Sample Workspace").fontWeight(.semibold) + Spacer() + Text(mode == .mirror ? "Mirror (Demo)" : "Extend (Demo)") + Text(verbatim: "9:41") + } + .font(.system(size: unit * 2.2)) + .foregroundStyle(.white) + .padding(.horizontal, unit * 2) + .frame(height: unit * 4.4) + .background(Color.black.opacity(0.28)) + } + + private func window(title: String, unit: CGFloat, + @ViewBuilder content: () -> Content) -> some View { + VStack(spacing: 0) { + HStack(spacing: unit * 0.9) { + ForEach([Color.red, .yellow, .green], id: \.self) { color in + Circle().fill(color.opacity(0.85)).frame(width: unit * 1.6, height: unit * 1.6) + } + Spacer() + Text(verbatim: title).font(.system(size: unit * 2, weight: .semibold)) + Spacer() + Color.clear.frame(width: unit * 6) + } + .padding(.horizontal, unit * 1.4) + .frame(height: unit * 4) + .background(Color(white: 0.92)) + content() + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + .background(Color.white) + } + .foregroundStyle(Color(white: 0.15)) + .clipShape(RoundedRectangle(cornerRadius: unit * 1.4, style: .continuous)) + .shadow(color: .black.opacity(0.35), radius: unit * 2, y: unit * 0.8) + } + + private func notesWindow(unit: CGFloat) -> some View { + window(title: "Welcome.txt", unit: unit) { + VStack(alignment: .leading, spacing: unit * 1.4) { + Text("Welcome to the MeowDisplay demo").font(.system(size: unit * 2.8, weight: .bold)) + Text("This sample workspace is drawn on this device. With the Mac app, your Mac’s real screen appears here.") + .font(.system(size: unit * 2)) + ForEach(["Tap to click", "Drag to move", "Two fingers to scroll", "Use the trays for shortcuts"], id: \.self) { line in + Label(LocalizedStringKey(line), systemImage: "checkmark.circle") + .font(.system(size: unit * 2)) + } + } + .padding(unit * 2) + } + } + + private func photosWindow(unit: CGFloat) -> some View { + window(title: "Gallery", unit: unit) { + let colors: [[Color]] = [[.orange, .pink], [.teal, .blue], [.purple, .indigo], + [.green, .mint], [.yellow, .orange], [.cyan, .purple]] + LazyVGrid(columns: Array(repeating: GridItem(.flexible(), spacing: unit), count: 3), spacing: unit) { + ForEach(colors.indices, id: \.self) { index in + RoundedRectangle(cornerRadius: unit, style: .continuous) + .fill(LinearGradient(colors: colors[index], startPoint: .topLeading, endPoint: .bottomTrailing)) + .aspectRatio(1.3, contentMode: .fit) + } + } + .padding(unit * 1.5) + } + } + + private func chartWindow(unit: CGFloat) -> some View { + window(title: "Extended Display", unit: unit) { + VStack(alignment: .leading, spacing: unit * 1.2) { + Text("A second screen for your Mac").font(.system(size: unit * 2.8, weight: .bold)) + Text("In Extend, this device adds space beside your Mac’s own display. Move windows here to use both.") + .font(.system(size: unit * 2)) + HStack(alignment: .bottom, spacing: unit * 1.2) { + ForEach([0.45, 0.7, 0.55, 0.9, 0.65, 0.8, 0.5], id: \.self) { value in + RoundedRectangle(cornerRadius: unit * 0.5) + .fill(Color.accentColor.opacity(0.75)) + .frame(height: unit * 18 * value) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + } + .padding(unit * 2) + } + } + + private func dock(unit: CGFloat) -> some View { + HStack(spacing: unit * 1.4) { + ForEach(["doc.text", "photo", "chart.bar", "terminal", "music.note", "gearshape"], id: \.self) { symbol in + Image(systemName: symbol) + .font(.system(size: unit * 3)) + .foregroundStyle(.white) + .frame(width: unit * 7, height: unit * 7) + .background(RoundedRectangle(cornerRadius: unit * 1.6, style: .continuous) + .fill(Color.white.opacity(0.18))) + } + } + .padding(unit * 1.2) + .background(RoundedRectangle(cornerRadius: unit * 2.4, style: .continuous).fill(Color.black.opacity(0.25))) + } + + private func grid(size: CGSize) -> some View { + Path { path in + let step = min(size.width, size.height) / 8 + stride(from: step, to: size.width, by: step).forEach { + path.move(to: CGPoint(x: $0, y: 0)); path.addLine(to: CGPoint(x: $0, y: size.height)) + } + stride(from: step, to: size.height, by: step).forEach { + path.move(to: CGPoint(x: 0, y: $0)); path.addLine(to: CGPoint(x: size.width, y: $0)) + } + } + .stroke(Color.white.opacity(0.25), lineWidth: 1) + } +} + +// MARK: - Home entry point + +struct ExploreDemoCard: View { + let onExplore: () -> Void + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + Text("Explore MeowDisplay").font(.headline) + Text("See how display, input, controls, audio and connections work using a sample Mac. The demo uses sample content and doesn’t connect to another device.") + .font(.subheadline) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + Button(action: onExplore) { + Label("Explore Demo", systemImage: "play.rectangle") + .frame(maxWidth: .infinity) + } + .buttonStyle(.borderedProminent) + .controlSize(.large) + .accessibilityIdentifier("exploreDemo") + .accessibilityHint(Text("Opens a sample session. Nothing connects to another device.")) + } + .padding(16) + .frame(maxWidth: 560, alignment: .leading) + .background(Color(.secondarySystemGroupedBackground), in: RoundedRectangle(cornerRadius: 16, style: .continuous)) + } +} + +// MARK: - Settings + +/// Shown along the bottom of Settings while Demo Mode is active. +struct DemoSettingsBanner: View { + var body: some View { + HStack(spacing: 8) { + DemoBadge() + Text("Sample session with \(DemoSession.macName). No Mac is connected.") + .font(.footnote) + .foregroundStyle(.secondary) + Spacer(minLength: 0) + } + .padding(.horizontal, 16) + .padding(.vertical, 8) + .background(.bar) + .accessibilityElement(children: .combine) + } +} + +/// Settings pages that need a real Mac, populated from the demo session. +/// Every value here is labeled Demo or Sample, and nothing is persisted. +struct DemoSettingsPage: View { + let category: MobileSettingsCategory + @ObservedObject var demo: DemoSession + @ObservedObject var controlStore: ReceiverControlStore + + var body: some View { + SettingsForm { + switch category { + case .general: general + case .display: display + case .audio: audio + case .connections: connections + case .pairedMacs: pairedMacs + case .remoteAccess: remoteAccess + case .permissions: permissions + case .diagnostics: diagnostics + default: EmptyView() + } + } + .navigationTitle(category.title) + } + + private func sample(_ value: String) -> String { + String(localized: "\(value) (Sample)", comment: "A demo value, such as 60 fps (Sample).") + } + + private var streamText: String { + "\(Int(demo.videoSize.width))×\(Int(demo.videoSize.height)) @ \(demo.fps) fps" + } + + // MARK: General + + @ViewBuilder + private var general: some View { + Section { + TextField("Device name", text: $demo.deviceName) + .textInputAutocapitalization(.words) + .autocorrectionDisabled() + } header: { + Text("Name") + } footer: { + Text("Demo: changes here last only for this demo and don't rename this \(deviceKind).") + } + Section { + Toggle("Auto-Reconnect", isOn: $demo.autoReconnect) + } header: { + Text("Connection") + } footer: { + Text("Automatically reconnect to paired devices after connection interruptions. In the demo this only changes the sample session.") + } + Section { + Toggle("Haptics", isOn: preferenceBinding(controlStore, \.hapticsEnabled)) + } + } + + // MARK: Display + + @ViewBuilder + private var display: some View { + Section { + LabeledContent("Mac", value: DemoSession.macName) + LabeledContent("Stream", value: sample(streamText)) + LabeledContent("Codec", value: sample(demo.codec == .hevc ? "HEVC" : "H.264")) + } header: { + Text("Sample Session") + } + Section { + VStack(alignment: .leading, spacing: 4) { + Toggle("Video", isOn: Binding(get: { demo.videoEnabled }, set: { demo.setVideoEnabled($0) })) + SettingNote("Turning video off keeps the connection, keyboard, controls, and selected input mode active.") + } + Picker("Display Mode", selection: $demo.displayMode) { + ForEach(ReceiverDisplayMode.allCases) { mode in + Text(mode.title).tag(mode) + } + } + SettingNote("Demo: switching changes the sample display only. With a real Mac, the Mac switches between mirroring its screen and adding this device as a second display.") + Toggle("Show Surface Grid", isOn: preferenceBinding(controlStore, \.showSurfaceGrid)) + } + Section { + VStack(alignment: .leading, spacing: 4) { + Toggle("Picture in Picture", isOn: preferenceBinding(controlStore, \.pictureInPictureEnabled)) + SettingNote("Leaving MeowDisplay during a session keeps your Mac visible in a floating window. It’s view-only — control stays in MeowDisplay.") + SettingNote("Picture in Picture needs live video from a Mac, so it doesn't start from the demo.") + } + } + Section("Streaming") { + Picker("Streaming Profile", selection: $demo.streamingProfile) { + ForEach(StreamingProfile.allCases) { profile in + Text(profile.label).tag(profile) + } + } + Text(demo.streamingProfile.explanation).font(.footnote).foregroundStyle(.secondary) + } + } + + // MARK: Audio + + @ViewBuilder + private var audio: some View { + Section { + VStack(alignment: .leading, spacing: 4) { + Toggle("Audio", isOn: Binding( + get: { demo.audioEnabled }, + set: { value in + demo.audioEnabled = value + controlStore.update { $0.audioPreferred = value } + })) + SettingNote("Plays a copy of what the Mac is playing. It keeps playing there too — this never changes the Mac's output device.") + } + LabeledContent("System Audio", value: demo.audioEnabled ? sample(String(localized: "Playing")) : String(localized: "Off")) + } footer: { + Text("Demo: no sound plays from the sample Mac.") + } + Section { + VStack(alignment: .leading, spacing: 6) { + HStack { + Text("A/V Sync") + Spacer() + Text("\(controlStore.preferences.avSyncOffsetMs) ms").foregroundStyle(.secondary) + } + Slider(value: Binding( + get: { Double(controlStore.preferences.avSyncOffsetMs) }, + set: { value in + let stepped = Int((value / Double(AVSyncOffset.stepMs)).rounded()) * AVSyncOffset.stepMs + controlStore.update { $0.avSyncOffsetMs = AVSyncOffset.clamped(stepped) } + }), + in: Double(AVSyncOffset.range.lowerBound)...Double(AVSyncOffset.range.upperBound), + step: Double(AVSyncOffset.stepMs)) + SettingNote("Adjust if sound plays slightly before or after the picture.") + } + Button("Resync") { demo.log("Demo audio resynced") } + .disabled(!demo.audioEnabled) + } + .disabled(!demo.audioEnabled) + } + + // MARK: Connections + + @ViewBuilder + private var connections: some View { + Section { + LabeledContent("Mac", value: DemoSession.macName) + LabeledContent("Connection", value: String(localized: "Connected (Demo)")) + LabeledContent("Route", value: sample("Wi-Fi")) + LabeledContent("Stream", value: sample(streamText)) + LabeledContent("Latency", value: sample("\(demo.latencyMs) ms")) + LabeledContent("Throughput", value: sample("\(Int(demo.throughputMbps)) Mbps")) + } header: { + Text("Status") + } footer: { + Text("Sample values for the demo. No network connection is open.") + } + Section { + Label("USB: plug in the cable, run the Mac app — it connects automatically through the wire (lowest latency).", + systemImage: "cable.connector") + Label("WiFi: both devices on the same network, then pick this \(deviceKind) in the Mac app's Connection menu.", + systemImage: "wifi") + Label("Rotate the \(deviceKind) for a vertical second monitor.", + systemImage: "rectangle.portrait.rotate") + Label("Touch: tap to click, drag to drag, two-finger pan to scroll.", + systemImage: "hand.tap") + } header: { + Text("How to connect") + } + } + + // MARK: Paired Macs + + @ViewBuilder + private var pairedMacs: some View { + Section { + HStack { + Image(systemName: "laptopcomputer").accessibilityHidden(true) + VStack(alignment: .leading, spacing: 2) { + Text(verbatim: DemoSession.macName) + Text("Paired · Connected").font(.footnote).foregroundStyle(.secondary) + } + Spacer() + DemoBadge() + } + .accessibilityElement(children: .combine) + Toggle("Request Input Automatically", isOn: $demo.requestInputAutomatically) + .font(.subheadline) + Button("Forget", role: .destructive) {} + .disabled(true) + } footer: { + Text("Demo: \(DemoSession.macName) is a sample and isn't saved as a paired Mac. Forget is unavailable for it. Your real paired Macs aren't shown in the demo. Pairing with a real Mac is confirmed on both devices.") + } + } + + // MARK: Remote Access + + @ViewBuilder + private var remoteAccess: some View { + Section { + Text("Use a reachable private-network address for this paired Mac, such as its Tailscale address or MagicDNS name. This lets you connect when the Mac isn't on the same network — it's only used to reach the device, it does not grant trust, and every connection still requires the existing secure pairing.") + .font(.caption) + .foregroundStyle(.secondary) + } + Section { + LabeledContent("Paired Mac", value: DemoSession.macName) + LabeledContent("Network", value: sample("Tailscale")) + LabeledContent("Address", value: DemoSession.remoteHost) + LabeledContent("Port", value: String(DemoSession.remotePort)) + Toggle("Use Remote Access", isOn: $demo.remoteAccessEnabled) + LabeledContent("Status", value: demo.remoteAccessEnabled ? sample(String(localized: "Ready")) : String(localized: "Off")) + } header: { + Text("Remote Access") + } footer: { + Text("Sample configuration. Nothing is saved and no connection is attempted.") + } + } + + // MARK: Permissions + + @ViewBuilder + private var permissions: some View { + Section { + Button("Open iOS Settings for MeowDisplay") { + if let url = URL(string: UIApplication.openSettingsURLString) { + UIApplication.shared.open(url) + } + } + } header: { + Text("This \(deviceKind)") + } footer: { + Text("WiFi mode needs Local Network access. If your Mac can't find this \(deviceKind), enable it under Settings → Privacy & Security → Local Network → MeowDisplay. USB mode works without it. Face ID or your passcode confirms each new pairing.") + } + Section { + Label("Screen Recording: lets the Mac app send its screen to this \(deviceKind).", systemImage: "rectangle.dashed.badge.record") + Label("Accessibility: lets touches and keys from this \(deviceKind) control the Mac.", systemImage: "accessibility") + } header: { + Text("On the Mac") + } footer: { + Text("Shown for reference in the demo. These are granted on the Mac when you set up the Mac app. The demo hasn't checked or changed any permission.") + } + } + + // MARK: Diagnostics + + @ViewBuilder + private var diagnostics: some View { + Section { + ForEach(demo.events) { event in + HStack(alignment: .firstTextBaseline) { + Text(verbatim: "+\(event.secondsIntoSession)s") + .font(.caption.monospacedDigit()) + .foregroundStyle(.secondary) + .frame(width: 44, alignment: .leading) + Text(event.message).font(.callout) + } + } + } header: { + Text("Demo Diagnostics") + } footer: { + Text("Sample session data. It isn't saved to the connection log, and no network activity took place.") + } + Section { + Toggle("Performance overlay", isOn: $demo.showPerformanceOverlay) + } header: { + Text("Analytics") + } footer: { + Text("Shows FPS, bitrate, frame timing and latency graphs over the display. In the demo the numbers are samples.") + } + } +} diff --git a/iOS/DemoSession.swift b/iOS/DemoSession.swift new file mode 100644 index 00000000..82000af3 --- /dev/null +++ b/iOS/DemoSession.swift @@ -0,0 +1,196 @@ +import Combine +import CoreGraphics +import Foundation + +/// What the receiver controls need from a session: the state they react to +/// and the input they send. `StreamReceiver` is the real implementation; +/// `DemoSession` is the sample one, which never reaches a network. +@MainActor +protocol ReceiverControlSource: ObservableObject { + var macProtocolVersion: Int { get } + var controlSessionPhase: ReceiverSessionPhase { get } + var displayState: DisplayState { get } + var videoEnabled: Bool { get } + var inputResetGeneration: Int { get } + var controlResetGeneration: Int { get } + func sendKeyboardPress(usage: Int, modifiers: [String]) + func sendKeyboardDown(usage: Int, modifiers: [String]) + func sendKeyboardUp(usage: Int, modifiers: [String]) + func sendModifier(_ modifier: ControlModifier, down: Bool) + func sendGesture(name: String) +} + +extension ReceiverControlSource { + func sendKeyboardPress(usage: Int) { sendKeyboardPress(usage: usage, modifiers: []) } +} + +/// Which screen the root shows. A real session always wins over the demo, +/// so a Mac connecting while the demo is open ends it. +enum ReceiverPresentationRoute: Equatable { + case idle + case demo + case receiver + + static func resolve(demoActive: Bool, realSurfaceShown: Bool) -> ReceiverPresentationRoute { + if realSurfaceShown { return .receiver } + return demoActive ? .demo : .idle + } +} + +/// How a Settings category is presented in Demo Mode: the real page with +/// demo-local stores, or a demo page in place of one that needs a real Mac. +enum DemoSettingsPresentation: Equatable { + case shared + case demo + + static func presentation(for category: MobileSettingsCategory) -> DemoSettingsPresentation? { + switch category { + case .input, .gestures, .controls, .about: return .shared + case .general, .display, .audio, .connections, .pairedMacs, + .remoteAccess, .permissions, .diagnostics: return .demo + case .developer: return nil + } + } +} + +/// Demo Mode's whole session: a sample Mac, its sample stream and the local +/// state the reviewer can change. Everything lives in memory and dies with +/// the instance. It holds no `StreamReceiver`, opens no connection, and +/// never touches `TrustStore`, `RemoteEndpointStore`, the connection log or +/// `UserDefaults.standard`. +@MainActor +final class DemoSession: ObservableObject { + static let macName = "Review Mac" + /// Never a real paired peer ID (those are SPKI hashes). + static let peerID = "demo.review-mac" + /// RFC 2606 reserved domain, so it can never resolve to a real host. + static let remoteHost = "review-mac.example.ts.net" + static let remotePort = 7300 + /// Separate defaults domain for the demo's control preferences. + static let controlDefaultsSuite = "com.raisecaterror.meowdisplay.demo" + + struct Event: Identifiable, Equatable { + let id: Int + let secondsIntoSession: Int + let message: String + } + + let videoSize: CGSize + let codec: StreamCodec = .hevc + let fps = 60 + let latencyMs = 18 + let throughputMbps = 42.0 + + @Published var displayMode: ReceiverDisplayMode = .extend { didSet { if displayMode != oldValue { log("Demo display mode set to \(displayMode.title)") } } } + @Published private(set) var videoEnabled = true + @Published var audioEnabled = true { didSet { if audioEnabled != oldValue { log(audioEnabled ? "Demo audio on" : "Demo audio off") } } } + @Published private(set) var inputState: SessionInputWireState = .allowed + @Published var streamingProfile: StreamingProfile = .performance + @Published var deviceName: String + @Published var autoReconnect = true + @Published var showPerformanceOverlay = false + @Published var remoteAccessEnabled = true + @Published var requestInputAutomatically = false + @Published private(set) var events: [Event] = [] + /// The last control the reviewer used, shown as local feedback only. + @Published private(set) var lastAction: String? + @Published private(set) var actionGeneration = 0 + private var clock = 0 + + init(videoSize: CGSize = CGSize(width: 2732, height: 2048), deviceName: String = "Sample iPad") { + self.videoSize = videoSize + self.deviceName = deviceName + log("Demo session started (sample data, no network)") + log("Sample peer \(Self.macName) shown as paired") + log("Sample \(codec == .hevc ? "HEVC" : "H.264") stream selected") + log("Sample video \(Int(videoSize.width))×\(Int(videoSize.height)) @ \(fps) fps") + log("Demo audio enabled") + log("Demo input allowed") + } + + var inputAllowed: Bool { inputState == .allowed } + + /// Demo Allow Input. Local only: no Mac is asked and nothing is sent. + func setInputAllowed(_ allowed: Bool) { + let state: SessionInputWireState = allowed ? .allowed : .off + guard state != inputState else { return } + inputState = state + log(allowed ? "Demo input allowed" : "Demo input turned off") + } + + func setVideoEnabled(_ enabled: Bool) { + guard enabled != videoEnabled else { return } + videoEnabled = enabled + log(enabled ? "Demo video on" : "Demo video off") + } + + /// Records a control the reviewer used, in place of sending it. + func noteAction(_ description: String) { + lastAction = description + actionGeneration &+= 1 + } + + /// Adds a line to the in-memory demo diagnostics. + func log(_ message: String) { + events.append(Event(id: events.count, secondsIntoSession: clock, message: message)) + clock += 1 + } +} + +extension DemoSession: ReceiverControlSource { + var macProtocolVersion: Int { WireProtocol.version } + var controlSessionPhase: ReceiverSessionPhase { .connected } + var displayState: DisplayState { .running } + var inputResetGeneration: Int { 0 } + var controlResetGeneration: Int { 0 } + + func sendKeyboardPress(usage: Int, modifiers: [String]) { + noteAction(Self.describe(usage: usage, modifiers: modifiers)) + } + + func sendKeyboardDown(usage: Int, modifiers: [String]) { + noteAction(Self.describe(usage: usage, modifiers: modifiers)) + } + + func sendKeyboardUp(usage: Int, modifiers: [String]) {} + + func sendModifier(_ modifier: ControlModifier, down: Bool) { + if down { noteAction(modifier.rawValue.capitalized) } + } + + func sendGesture(name: String) { + noteAction(name) + } + + func noteTyped(_ text: String) { + noteAction(String(localized: "Typed “\(text)”")) + } + + private static let symbols: [String: String] = [ + "control": "⌃", "option": "⌥", "shift": "⇧", "command": "⌘", + ] + + private static let keyNames: [Int: String] = [ + 40: "Return", 41: "Esc", 42: "Delete", 43: "Tab", 44: "Space", + 79: "→", 80: "←", 81: "↓", 82: "↑", + ] + + /// A readable key name for the local feedback chip (⌘C, ⌥⌘D, Esc). + static func describe(usage: Int, modifiers: [String]) -> String { + let prefix = ["control", "option", "shift", "command"] + .filter(modifiers.contains).compactMap { symbols[$0] }.joined() + let key: String + if let name = keyNames[usage] { + key = name + } else if (4...29).contains(usage), let scalar = UnicodeScalar(UInt32(usage - 4) + 65) { + key = String(Character(scalar)) + } else if (30...38).contains(usage) { + key = String(usage - 29) + } else if usage == 39 { + key = "0" + } else { + key = String(localized: "Key") + } + return prefix + key + } +} diff --git a/iOS/OpenSidecarPhoneApp.swift b/iOS/OpenSidecarPhoneApp.swift index 233c0e65..d6b78e57 100644 --- a/iOS/OpenSidecarPhoneApp.swift +++ b/iOS/OpenSidecarPhoneApp.swift @@ -70,6 +70,10 @@ struct ReceiverScreen: View { @State private var keyboardActive = false // M4: "Zoom While Typing" — default ON (see SettingsView). @AppStorage("zoomWhileTyping") private var zoomWhileTyping = true + // Demo Mode (see DemoMode.swift): a sample session with its own + // control store. Both are nil outside the demo. + @State private var demo: DemoSession? + @State private var demoControlStore: ReceiverControlStore? private var haptics: ReceiverHaptics { ReceiverHaptics { controlStore.preferences.hapticsEnabled } @@ -89,6 +93,29 @@ struct ReceiverScreen: View { model.receiver.session.retainsReceiverSurface && model.receiver.videoSize != .zero } + private var route: ReceiverPresentationRoute { + .resolve(demoActive: demo != nil, realSurfaceShown: showsReceiverSurface) + } + + /// Full-screen chrome rules apply to the demo surface as well. + private var fullScreenSurface: Bool { route != .idle } + + private func enterDemo() { + demoControlStore = DemoControlStoreFactory.make() + demo = DemoSession(videoSize: demoVideoSize(), deviceName: String(localized: "Sample \(deviceKind)")) + Log.info("demo mode entered") + } + + private func exitDemo() { + guard demo != nil else { return } + showSettings = false + keyboardActive = false + demo = nil + demoControlStore = nil + DemoControlStoreFactory.discard() + Log.info("demo mode exited") + } + /// Input may only leave this device while the session is genuinely live. /// Folded into the existing master Allow Input gate rather than added as a /// second one, so the established OFF-transition cleanup in `VideoView` @@ -190,9 +217,16 @@ struct ReceiverScreen: View { model.receiver.requestAllowInput(true) } + @ViewBuilder private var settingsView: some View { - SettingsView(receiver: model.receiver, controlStore: controlStore, - pictureInPicture: model.pictureInPicture, haptics: haptics) + if let demo, let demoControlStore { + SettingsView(receiver: model.receiver, controlStore: demoControlStore, + pictureInPicture: model.pictureInPicture, + haptics: ReceiverHaptics { demoControlStore.preferences.hapticsEnabled }, demo: demo) + } else { + SettingsView(receiver: model.receiver, controlStore: controlStore, + pictureInPicture: model.pictureInPicture, haptics: haptics) + } } // Below the force floor → present the blocking gate (issue #135). The @@ -344,8 +378,14 @@ struct ReceiverScreen: View { ?? geo.size.height - effectiveSafeInsets.bottom - 76) .transition(.move(edge: .bottom).combined(with: .opacity)) } + } else if route == .demo, let demo, let demoControlStore { + DemoReceiverScreen(demo: demo, store: demoControlStore, showSettings: $showSettings, + keyboardActive: $keyboardActive, + haptics: ReceiverHaptics { demoControlStore.preferences.hapticsEnabled }, + onExit: { exitDemo() }) } else { - IdleView(receiver: model.receiver, wakeConnect: model.wakeConnect, showSettings: $showSettings) + IdleView(receiver: model.receiver, wakeConnect: model.wakeConnect, showSettings: $showSettings, + onExploreDemo: { enterDemo() }) } } .onAppear { model.receiver.setOrientation(portrait: geo.size.height > geo.size.width) } @@ -356,9 +396,13 @@ struct ReceiverScreen: View { OnboardingView { onboardingDismissed = true } } } - .ignoresSafeArea(edges: showsReceiverSurface ? .all : []) - .statusBarHidden(showsReceiverSurface) - .persistentSystemOverlays(showsReceiverSurface ? .hidden : .automatic) + .ignoresSafeArea(edges: fullScreenSurface ? .all : []) + .statusBarHidden(fullScreenSurface) + .persistentSystemOverlays(fullScreenSurface ? .hidden : .automatic) + // A real session always wins: a Mac connecting ends the demo. + .onChange(of: showsReceiverSurface) { shown in + if shown { exitDemo() } + } // SwiftUI forwards this to the hosting controller's // `preferredScreenEdgesDeferringSystemGestures` and invalidates it // (`setNeedsUpdateOfScreenEdgesDeferringSystemGestures`) whenever the @@ -841,6 +885,7 @@ struct IdleView: View { @ObservedObject var receiver: StreamReceiver @ObservedObject var wakeConnect: WakeConnectCoordinator @Binding var showSettings: Bool + let onExploreDemo: () -> Void @State private var showRemoteAccessSetup = false @State private var showRemotePairing = false /// Persisted expand/collapse state of the Connection Instructions section. @@ -918,6 +963,7 @@ struct IdleView: View { autoReconnectToggle instructionsSection deviceSections + ExploreDemoCard(onExplore: onExploreDemo) Spacer() settingsButton tip.padding(.bottom, 8) @@ -953,7 +999,13 @@ struct IdleView: View { // Right: devices fill the top, Settings & tip sit at the bottom. VStack(spacing: 12) { - ScrollView { deviceSections.padding(.top, 12) } + ScrollView { + VStack(spacing: 12) { + deviceSections + ExploreDemoCard(onExplore: onExploreDemo) + } + .padding(.top, 12) + } .scrollBounceBehavior(.basedOnSize) settingsButton tip @@ -1046,6 +1098,7 @@ struct IdleView: View { .frame(maxWidth: 560) } VStack(spacing: 10) { + ExploreDemoCard(onExplore: onExploreDemo) settingsButton tip } diff --git a/iOS/ReceiverControls.swift b/iOS/ReceiverControls.swift index 400e5aa6..ead48fe3 100644 --- a/iOS/ReceiverControls.swift +++ b/iOS/ReceiverControls.swift @@ -298,9 +298,9 @@ private struct ControlFramePreference: PreferenceKey { } } -struct ReceiverControlOverlay: View { +struct ReceiverControlOverlay: View { @ObservedObject var store: ReceiverControlStore - @ObservedObject var receiver: StreamReceiver + @ObservedObject var receiver: Receiver @Binding var keyboardActive: Bool @Binding var showSettings: Bool let keyboardAvailable: Bool @@ -347,14 +347,14 @@ struct ReceiverControlOverlay: View { /// themselves. The geometry below has to agree with the chip sizes — the /// tray/palette frames it produces are what the gesture region hit-tests. private enum Metrics { - static let trayItem: CGFloat = 40 - static let trayGap: CGFloat = 4 - static let collapsed: CGFloat = 46 - static let paletteKey: CGFloat = 38 - static let paletteGap: CGFloat = 5 + static var trayItem: CGFloat { 40 } + static var trayGap: CGFloat { 4 } + static var collapsed: CGFloat { 46 } + static var paletteKey: CGFloat { 38 } + static var paletteGap: CGFloat { 5 } /// Slack around the chips so a slightly-off finger still lands inside /// the gesture region. - static let touchSlack: CGFloat = 4 + static var touchSlack: CGFloat { 4 } } private var profile: ControlProfile { store.activeProfile } @@ -446,7 +446,7 @@ struct ReceiverControlOverlay: View { // Any session interruption — pause, recovery, a failed recovery or a // plain disconnect — drops every latched modifier/held control, so no // transient control state survives into the next live session. - .onChange(of: receiver.session.phase) { phase in + .onChange(of: receiver.controlSessionPhase) { phase in if phase != .connected { apply(interaction.resetAll()) store.setMoveViewActive(false) @@ -1440,7 +1440,7 @@ extension ReceiverControlOverlay { .onChange(of: occupied) { onOccupiedFramesChange($0) } } - static let trayPadding: CGFloat = 6 + static var trayPadding: CGFloat { 6 } /// Chord palettes and previews stay above the software keyboard. static func boundsAboveKeyboard(_ bounds: CGRect, keyboardTop: CGFloat?) -> CGRect { @@ -1990,7 +1990,7 @@ private struct WheelWedge: Shape { /// down and up together, so nothing is left held. @MainActor enum OneTapActionRunner { - static func run(_ action: ControlAction, receiver: StreamReceiver, inputAllowed: @escaping @MainActor () -> Bool) { + static func run(_ action: ControlAction, receiver: some ReceiverControlSource, inputAllowed: @escaping @MainActor () -> Bool) { let operations = ControlActionPlan.operations(for: action) guard !operations.isEmpty, inputAllowed() else { return } let pauses = operations.contains { if case .pause = $0 { return true } else { return false } } @@ -2010,7 +2010,7 @@ enum OneTapActionRunner { } } - private static func perform(_ operation: ControlActionOperation, receiver: StreamReceiver) { + private static func perform(_ operation: ControlActionOperation, receiver: some ReceiverControlSource) { switch operation { case .press(let usage, let modifiers): receiver.sendKeyboardPress(usage: usage, modifiers: modifiers) @@ -2345,3 +2345,8 @@ struct ShortcutButtonFaceView: View { } } } + +/// The real control source: the live session's state and wire. +extension StreamReceiver: ReceiverControlSource { + var controlSessionPhase: ReceiverSessionPhase { session.phase } +} diff --git a/iOS/ReceiverSettings.swift b/iOS/ReceiverSettings.swift index e20b90ae..afa2d9c2 100644 --- a/iOS/ReceiverSettings.swift +++ b/iOS/ReceiverSettings.swift @@ -16,6 +16,9 @@ struct SettingsView: View { let controlStore: ReceiverControlStore let pictureInPicture: ReceiverPictureInPictureController let haptics: ReceiverHaptics + /// Demo Mode: sample session state. Pages that need a real Mac show + /// demo pages instead (see `DemoSettingsPresentation`). + var demo: DemoSession? @Environment(\.dismiss) private var dismiss @Environment(\.horizontalSizeClass) private var horizontalSizeClass @State private var selection: MobileSettingsCategory? @@ -73,6 +76,9 @@ struct SettingsView: View { .id(selection) } .navigationSplitViewStyle(.balanced) + .safeAreaInset(edge: .bottom, spacing: 0) { + if demo != nil { DemoSettingsBanner() } + } // Settings owns its system chrome: the receiver surface behind it // hides the status bar and home indicator, and a presentation that // inherited that would lay its navigation bar out under the top @@ -121,13 +127,24 @@ struct SettingsView: View { @ViewBuilder private func page(for category: MobileSettingsCategory) -> some View { + if let demo, DemoSettingsPresentation.presentation(for: category) == .demo { + DemoSettingsPage(category: category, demo: demo, controlStore: controlStore) + } else { + realPage(for: category) + } + } + + @ViewBuilder + private func realPage(for category: MobileSettingsCategory) -> some View { switch category { case .general: GeneralSettingsPage(receiver: receiver, controlStore: controlStore) case .display: DisplaySettingsPage(receiver: receiver, controlStore: controlStore, pictureInPicture: pictureInPicture) case .input: - InputSettingsPage(receiver: receiver, controlStore: controlStore) + InputSettingsPage(controlStore: controlStore, isDemo: demo != nil) { [demo] allowed in + if let demo { demo.setInputAllowed(allowed) } else { receiver.requestAllowInput(allowed) } + } case .gestures: GestureSettingsPage(controlStore: controlStore) case .controls: @@ -158,7 +175,7 @@ struct SettingsView: View { /// Binds one receiver preference through `ReceiverControlStore.update`. @MainActor -private func preferenceBinding(_ store: ReceiverControlStore, +func preferenceBinding(_ store: ReceiverControlStore, _ keyPath: WritableKeyPath) -> Binding { Binding(get: { store.preferences[keyPath: keyPath] }, set: { value in store.update { $0[keyPath: keyPath] = value } }) @@ -178,7 +195,7 @@ extension EnvironmentValues { /// A settings page: a `Form` that scrolls to, and briefly tints, the row a /// search result pointed at (rows opt in with `settingsAnchor`). -private struct SettingsForm: View { +struct SettingsForm: View { @Environment(\.settingsHighlight) private var highlight @ViewBuilder let content: Content @@ -206,7 +223,7 @@ private struct SettingsAnchor: ViewModifier { } } -private extension View { +extension View { /// Marks a row as the target of the search item with this id. func settingsAnchor(_ id: String) -> some View { modifier(SettingsAnchor(id: id)) } } @@ -223,7 +240,7 @@ private struct HidesSidebarToggle: ViewModifier { } /// A setting's secondary explanation line. -private struct SettingNote: View { +struct SettingNote: View { let text: LocalizedStringKey init(_ text: LocalizedStringKey) { self.text = text } var body: some View { @@ -572,8 +589,10 @@ private struct DisplaySettingsPage: View { // MARK: - Input private struct InputSettingsPage: View { - let receiver: StreamReceiver @ObservedObject var controlStore: ReceiverControlStore + /// Demo Mode: Allow Input is a local sample state, never a Mac request. + let isDemo: Bool + let requestInput: (Bool) -> Void @AppStorage("zoomWhileTyping") private var zoomWhileTyping = true var body: some View { @@ -585,22 +604,26 @@ private struct InputSettingsPage: View { .settingsAnchor("control") switch controlStore.sessionInputState { case .off, .notAllowed: - Button("Request Control") { + Button(isDemo ? String(localized: "Allow Input (Demo)") : String(localized: "Request Control")) { controlStore.noteInputTurnedOffByUser(false) - receiver.requestAllowInput(true) + requestInput(true) } case .requesting: EmptyView() case .allowed: - Button("Turn Off", role: .destructive) { + Button(isDemo ? String(localized: "Turn Off Input (Demo)") : String(localized: "Turn Off"), role: .destructive) { controlStore.noteInputTurnedOffByUser(true) - receiver.requestAllowInput(false) + requestInput(false) } case .requestsDisabled: SettingNote("This Mac isn't accepting control requests from this device right now. Enable it from the Mac's Input settings.") } } footer: { - Text("Settings always stays reachable, even with input turned off.") + if isDemo { + Text("Demo: Allow Input is simulated on this \(deviceKind). No Mac is asked and nothing is sent. Settings always stays reachable, even with input turned off.") + } else { + Text("Settings always stays reachable, even with input turned off.") + } } Section { Picker("Input Mode", selection: preferenceBinding(controlStore, \.inputMode)) { diff --git a/iOSUITests/DemoModeSmokeTests.swift b/iOSUITests/DemoModeSmokeTests.swift new file mode 100644 index 00000000..e7b74ab5 --- /dev/null +++ b/iOSUITests/DemoModeSmokeTests.swift @@ -0,0 +1,82 @@ +import XCTest + +/// App Review path: launch, Explore Demo, every release Settings category, +/// Allow Input off and on, then Exit Demo back to the home screen. +final class DemoModeSmokeTests: XCTestCase { + private let categories = ["General", "Display", "Input", "Gestures", "Controls", "Audio", + "Connections", "Paired Macs", "Remote Access", "Permissions", + "Diagnostics", "About"] + + override func setUp() { + continueAfterFailure = false + } + + @MainActor + func testExploreDemoSettingsAndExitLandscape() { + XCUIDevice.shared.orientation = .landscapeLeft + defer { XCUIDevice.shared.orientation = .portrait } + testExploreDemoSettingsAndExit() + } + + @MainActor + func testExploreDemoSettingsAndExit() { + let app = XCUIApplication() + app.launch() + if app.buttons["Close"].waitForExistence(timeout: 3) { app.buttons["Close"].tap() } + + let explore = app.buttons["Explore Demo"] + XCTAssertTrue(explore.waitForExistence(timeout: 5)) + snapshot(app, "01-home") + explore.tap() + + let exit = app.buttons["Exit Demo"] + XCTAssertTrue(exit.waitForExistence(timeout: 5)) + XCTAssertTrue(app.descendants(matching: .any)["Demo Mode"].firstMatch.exists) + snapshot(app, "02-demo-surface") + + let input = app.descendants(matching: .any)["Allow Input (Demo)"].firstMatch + XCTAssertTrue(input.exists) + input.tap() + snapshot(app, "03-input-off") + input.tap() + + app.descendants(matching: .any)["Settings"].firstMatch.tap() + XCTAssertTrue(app.navigationBars["Settings"].waitForExistence(timeout: 5) + || app.staticTexts["Settings"].waitForExistence(timeout: 5)) + for category in categories { + let cell = app.buttons[category].firstMatch + if UIDevice.current.userInterfaceIdiom == .phone, !cell.isHittable { app.swipeUp() } + XCTAssertTrue(cell.waitForExistence(timeout: 3), category) + cell.tap() + XCTAssertTrue(app.navigationBars[category].waitForExistence(timeout: 3), category) + snapshot(app, "settings-\(category)") + if app.navigationBars[category].buttons.firstMatch.exists, + UIDevice.current.userInterfaceIdiom == .phone { + app.navigationBars[category].buttons.firstMatch.tap() + } + } + XCTAssertFalse(app.buttons["Developer"].exists && !isDebug) + app.buttons["Done"].firstMatch.tap() + + XCTAssertTrue(exit.waitForExistence(timeout: 5)) + exit.tap() + XCTAssertTrue(explore.waitForExistence(timeout: 5)) + XCTAssertFalse(exit.exists) + } + + private var isDebug: Bool { + #if DEBUG + true + #else + false + #endif + } + + @MainActor + private func snapshot(_ app: XCUIApplication, _ name: String) { + let attachment = XCTAttachment(screenshot: app.screenshot()) + attachment.name = name + attachment.lifetime = .keepAlways + add(attachment) + } +} diff --git a/project.yml b/project.yml index 1bf9d2fe..e5133f78 100644 --- a/project.yml +++ b/project.yml @@ -29,6 +29,9 @@ schemes: build: targets: OpenSidecariOS: all + test: + targets: + - OpenSidecariOSUITests OpenSidecarMacReceiver: build: targets: @@ -261,6 +264,8 @@ targets: - Shared/PadControlLayout.swift - Shared/CustomControlLayout.swift - Shared/MobileSettingsCategory.swift + # iOS Demo Mode boundary (Foundation only, no UIKit). + - iOS/DemoSession.swift - Shared/MobileSettingsSearch.swift - Shared/CustomLayoutSharing.swift - Shared/ReceiverInputPrompt.swift @@ -349,3 +354,16 @@ targets: PRODUCT_BUNDLE_IDENTIFIER: com.raisecaterror.meowdisplay.ios TARGETED_DEVICE_FAMILY: "1,2" ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon + # Demo Mode smoke path for App Review (launch, Explore Demo, Settings, exit). + OpenSidecariOSUITests: + type: bundle.ui-testing + platform: iOS + deploymentTarget: "16.4" + sources: + - iOSUITests + dependencies: + - target: OpenSidecariOS + settings: + base: + GENERATE_INFOPLIST_FILE: "YES" + PRODUCT_BUNDLE_IDENTIFIER: com.raisecaterror.meowdisplay.ios.uitests From 146df173bfad22c27c7cd42fd68af55a5ca38e2a Mon Sep 17 00:00:00 2001 From: raiseCatError <315733358+raiseCatError@users.noreply.github.com> Date: Sun, 11 Oct 2026 17:24:53 +0530 Subject: [PATCH 18/30] fix(ios): fully isolate demo mode state - Use the reserved review-mac.example hostname for the sample Remote Access entry instead of a name under the real ts.net domain. - Shared Settings pages take their @AppStorage store from DemoSession.preferenceDefaults: the real .standard domain normally, the demo domain (wiped on entry and exit) in Demo Mode. Zoom While Typing and Cat Mode taps no longer reach real preferences. - Inset the Demo Settings banner inside each column so it no longer covers the lower Settings rows on iPhone in landscape. - Tests for the zoom and Cat Mode isolation and the real-mode store; UI smoke test waits for transitions and runs in landscape too. --- MacTests/DemoSessionTests.swift | 48 ++++++++++++++++++++++++++++- iOS/DemoMode.swift | 19 +++++++++--- iOS/DemoSession.swift | 18 +++++++++-- iOS/ReceiverSettings.swift | 33 ++++++++++++++------ iOSUITests/DemoModeSmokeTests.swift | 8 +++++ 5 files changed, 110 insertions(+), 16 deletions(-) diff --git a/MacTests/DemoSessionTests.swift b/MacTests/DemoSessionTests.swift index db99b530..5a7e48e4 100644 --- a/MacTests/DemoSessionTests.swift +++ b/MacTests/DemoSessionTests.swift @@ -76,7 +76,53 @@ final class DemoSessionTests: XCTestCase { } func testSampleRemoteHostIsReservedDomain() { - XCTAssertTrue(DemoSession.remoteHost.hasSuffix(".example.ts.net")) + XCTAssertEqual(DemoSession.remoteHost, "review-mac.example") + XCTAssertTrue(DemoSession.remoteHost.hasSuffix(".example")) + } + + func testRealModeKeepsStandardPreferences() { + XCTAssertTrue(DemoSession.preferenceDefaults(demoActive: false) === UserDefaults.standard) + XCTAssertFalse(DemoSession.preferenceDefaults(demoActive: true) === UserDefaults.standard) + } + + /// Zoom While Typing in Demo Mode lives in the demo domain only, and + /// exiting discards it. + func testDemoZoomWhileTypingIsIsolatedAndDiscarded() { + let key = "zoomWhileTyping" + let realBefore = UserDefaults.standard.object(forKey: key) as? Bool + DemoSession.discardPreferences() + let demo = DemoSession.preferenceDefaults(demoActive: true) + demo.set(!(realBefore ?? true), forKey: key) + XCTAssertEqual(UserDefaults.standard.object(forKey: key) as? Bool, realBefore) + XCTAssertNotNil(DemoSession.preferenceDefaults(demoActive: true).object(forKey: key)) + DemoSession.discardPreferences() + XCTAssertNil(DemoSession.preferenceDefaults(demoActive: true).object(forKey: key)) + XCTAssertEqual(UserDefaults.standard.object(forKey: key) as? Bool, realBefore) + } + + /// Nine demo taps unlock Cat Mode in the demo domain only. + func testDemoCatModeTapsDoNotTouchRealCatMode() { + let keys = [CatMode.unlockedDefaultsKey, CatMode.enabledDefaultsKey, CatMode.tapCountDefaultsKey] + let realBefore = keys.map { UserDefaults.standard.object(forKey: $0).map { String(describing: $0) } } + DemoSession.discardPreferences() + let demo = DemoSession.preferenceDefaults(demoActive: true) + var count = 0 + var unlocked = false + for _ in 0..<9 { + let result = CatMode.registerTap(tapCount: count, alreadyUnlocked: unlocked) + count = result.tapCount + demo.set(count, forKey: CatMode.tapCountDefaultsKey) + if result.justUnlocked { + unlocked = true + demo.set(true, forKey: CatMode.unlockedDefaultsKey) + demo.set(true, forKey: CatMode.enabledDefaultsKey) + } + } + XCTAssertTrue(unlocked) + XCTAssertTrue(demo.bool(forKey: CatMode.unlockedDefaultsKey)) + XCTAssertEqual(keys.map { UserDefaults.standard.object(forKey: $0).map { String(describing: $0) } }, realBefore) + DemoSession.discardPreferences() + XCTAssertFalse(DemoSession.preferenceDefaults(demoActive: true).bool(forKey: CatMode.unlockedDefaultsKey)) } func testRealSessionAlwaysWinsAndExitRestoresIdle() { diff --git a/iOS/DemoMode.swift b/iOS/DemoMode.swift index 33015d67..e801a56d 100644 --- a/iOS/DemoMode.swift +++ b/iOS/DemoMode.swift @@ -15,8 +15,8 @@ import UIKit @MainActor enum DemoControlStoreFactory { static func make() -> ReceiverControlStore { - let defaults = UserDefaults(suiteName: DemoSession.controlDefaultsSuite) ?? UserDefaults() - defaults.removePersistentDomain(forName: DemoSession.controlDefaultsSuite) + DemoSession.discardPreferences() + let defaults = DemoSession.preferenceDefaults(demoActive: true) let store = ReceiverControlStore(repository: ReceiverControlPreferencesRepository(defaults: defaults)) store.update { $0.audioPreferred = true } store.applySessionInputState(.allowed) @@ -24,8 +24,7 @@ enum DemoControlStoreFactory { } static func discard() { - UserDefaults(suiteName: DemoSession.controlDefaultsSuite)? - .removePersistentDomain(forName: DemoSession.controlDefaultsSuite) + DemoSession.discardPreferences() } } @@ -763,3 +762,15 @@ struct DemoSettingsPage: View { } } } + +/// Adds the Demo Settings banner inside a scrolling column, so the column +/// insets its content and no row is left under the banner. +struct DemoSettingsBannerInset: ViewModifier { + let active: Bool + + func body(content: Content) -> some View { + content.safeAreaInset(edge: .bottom, spacing: 0) { + if active { DemoSettingsBanner() } + } + } +} diff --git a/iOS/DemoSession.swift b/iOS/DemoSession.swift index 82000af3..002cc1ef 100644 --- a/iOS/DemoSession.swift +++ b/iOS/DemoSession.swift @@ -63,12 +63,26 @@ final class DemoSession: ObservableObject { static let macName = "Review Mac" /// Never a real paired peer ID (those are SPKI hashes). static let peerID = "demo.review-mac" - /// RFC 2606 reserved domain, so it can never resolve to a real host. - static let remoteHost = "review-mac.example.ts.net" + /// Under the RFC 2606 reserved `.example` TLD, so it can never be a real + /// host. Shown only; nothing resolves or dials it. + static let remoteHost = "review-mac.example" static let remotePort = 7300 /// Separate defaults domain for the demo's control preferences. static let controlDefaultsSuite = "com.raisecaterror.meowdisplay.demo" + /// Where shared Settings pages keep their `@AppStorage` values: the + /// real `.standard` domain, or the demo domain (wiped on entry and exit) + /// so Demo Mode never reads or writes a real preference. + static func preferenceDefaults(demoActive: Bool) -> UserDefaults { + guard demoActive else { return .standard } + return UserDefaults(suiteName: controlDefaultsSuite) ?? UserDefaults() + } + + /// Drops everything Demo Mode saved. + static func discardPreferences() { + UserDefaults(suiteName: controlDefaultsSuite)?.removePersistentDomain(forName: controlDefaultsSuite) + } + struct Event: Identifiable, Equatable { let id: Int let secondsIntoSession: Int diff --git a/iOS/ReceiverSettings.swift b/iOS/ReceiverSettings.swift index afa2d9c2..55cc5ed4 100644 --- a/iOS/ReceiverSettings.swift +++ b/iOS/ReceiverSettings.swift @@ -53,6 +53,7 @@ struct SettingsView: View { searchResults } } + .modifier(DemoSettingsBannerInset(active: demo != nil)) .navigationTitle("Settings") .searchable(text: $query, placement: .sidebar, prompt: Text("Search Settings")) .toolbar { @@ -66,6 +67,7 @@ struct SettingsView: View { if let selection { page(for: selection) .environment(\.settingsHighlight, highlight) + .modifier(DemoSettingsBannerInset(active: demo != nil)) } else { Text("Select a category") .foregroundStyle(.secondary) @@ -76,9 +78,6 @@ struct SettingsView: View { .id(selection) } .navigationSplitViewStyle(.balanced) - .safeAreaInset(edge: .bottom, spacing: 0) { - if demo != nil { DemoSettingsBanner() } - } // Settings owns its system chrome: the receiver surface behind it // hides the status bar and home indicator, and a presentation that // inherited that would lay its navigation bar out under the top @@ -162,7 +161,7 @@ struct SettingsView: View { case .diagnostics: DiagnosticsSettingsPage() case .about: - AboutSettingsPage() + AboutSettingsPage(defaults: DemoSession.preferenceDefaults(demoActive: demo != nil)) case .developer: #if DEBUG DeveloperSettingsPage(receiver: receiver) @@ -593,7 +592,16 @@ private struct InputSettingsPage: View { /// Demo Mode: Allow Input is a local sample state, never a Mac request. let isDemo: Bool let requestInput: (Bool) -> Void - @AppStorage("zoomWhileTyping") private var zoomWhileTyping = true + /// Demo Mode keeps this in the demo domain, never the real preference. + @AppStorage private var zoomWhileTyping: Bool + + init(controlStore: ReceiverControlStore, isDemo: Bool, requestInput: @escaping (Bool) -> Void) { + self.controlStore = controlStore + self.isDemo = isDemo + self.requestInput = requestInput + _zoomWhileTyping = AppStorage(wrappedValue: true, "zoomWhileTyping", + store: DemoSession.preferenceDefaults(demoActive: isDemo)) + } var body: some View { SettingsForm { @@ -1266,10 +1274,17 @@ private struct DiagnosticsSettingsPage: View { private struct AboutSettingsPage: View { // Cat Mode (hidden easter egg — nine taps on the version row). Local-only - // presentation state: never synced, never on the wire. - @AppStorage(CatMode.unlockedDefaultsKey) private var catModeUnlocked = false - @AppStorage(CatMode.enabledDefaultsKey) private var catModeEnabledStorage = false - @AppStorage(CatMode.tapCountDefaultsKey) private var catModeTapCount = 0 + // presentation state: never synced, never on the wire. Demo Mode passes + // the demo domain, so its taps never reach the real Cat Mode state. + @AppStorage private var catModeUnlocked: Bool + @AppStorage private var catModeEnabledStorage: Bool + @AppStorage private var catModeTapCount: Int + + init(defaults: UserDefaults = .standard) { + _catModeUnlocked = AppStorage(wrappedValue: false, CatMode.unlockedDefaultsKey, store: defaults) + _catModeEnabledStorage = AppStorage(wrappedValue: false, CatMode.enabledDefaultsKey, store: defaults) + _catModeTapCount = AppStorage(wrappedValue: 0, CatMode.tapCountDefaultsKey, store: defaults) + } @State private var showCatModeUnlockedAlert = false private var catModeEnabled: Bool { diff --git a/iOSUITests/DemoModeSmokeTests.swift b/iOSUITests/DemoModeSmokeTests.swift index e7b74ab5..3e3ec03a 100644 --- a/iOSUITests/DemoModeSmokeTests.swift +++ b/iOSUITests/DemoModeSmokeTests.swift @@ -23,6 +23,8 @@ final class DemoModeSmokeTests: XCTestCase { let app = XCUIApplication() app.launch() if app.buttons["Close"].waitForExistence(timeout: 3) { app.buttons["Close"].tap() } + // Let the onboarding sheet finish closing before the next tap. + waitForNonExistence(app.buttons["Close"]) let explore = app.buttons["Explore Demo"] XCTAssertTrue(explore.waitForExistence(timeout: 5)) @@ -53,6 +55,7 @@ final class DemoModeSmokeTests: XCTestCase { if app.navigationBars[category].buttons.firstMatch.exists, UIDevice.current.userInterfaceIdiom == .phone { app.navigationBars[category].buttons.firstMatch.tap() + XCTAssertTrue(app.navigationBars["Settings"].waitForExistence(timeout: 3), category) } } XCTAssertFalse(app.buttons["Developer"].exists && !isDebug) @@ -64,6 +67,11 @@ final class DemoModeSmokeTests: XCTestCase { XCTAssertFalse(exit.exists) } + private func waitForNonExistence(_ element: XCUIElement) { + let gone = expectation(for: NSPredicate(format: "exists == false"), evaluatedWith: element) + wait(for: [gone], timeout: 5) + } + private var isDebug: Bool { #if DEBUG true From 4e95c439057f62393550072146e6e049d9155dac Mon Sep 17 00:00:00 2001 From: raiseCatError <315733358+raiseCatError@users.noreply.github.com> Date: Sun, 11 Oct 2026 21:35:24 +0530 Subject: [PATCH 19/30] feat(ios): start the Function Tray opposite the Main Tray - New preferences place the Function Tray on the opposite side of the Main Tray on iPhone, and the iPad Function controls on the leading edge, opposite the trailing Main controls. - Saved preferences always win. A saved blob without the key (written before this default) keeps the earlier same-side / trailing placement. - The schema 5 migration test now drops the Function Tray keys, as a real schema 5 save had none, and still expects same-side. --- MacTests/ReceiverControlsTests.swift | 8 +++++++- Shared/ControlProfile.swift | 14 ++++++++++---- 2 files changed, 17 insertions(+), 5 deletions(-) diff --git a/MacTests/ReceiverControlsTests.swift b/MacTests/ReceiverControlsTests.swift index 518108de..df29cdc1 100644 --- a/MacTests/ReceiverControlsTests.swift +++ b/MacTests/ReceiverControlsTests.swift @@ -630,12 +630,18 @@ final class ReceiverControlsTests: XCTestCase { let repository = ReceiverControlPreferencesRepository(defaults: defaults) var old = ReceiverControlPreferences() old.version = 5 - defaults.set(try JSONEncoder().encode(old), + // Schema 5 had no Function Tray, so its saves carry none of its keys. + var json = try XCTUnwrap(JSONSerialization.jsonObject(with: JSONEncoder().encode(old)) as? [String: Any]) + for key in ["functionTrayEnabled", "activeFunctionTrayProfile", "functionTrayPosition", "functionTrayProfiles"] { + json.removeValue(forKey: key) + } + defaults.set(try JSONSerialization.data(withJSONObject: json), forKey: ReceiverControlPreferencesRepository.defaultsKey) let migrated = repository.load() XCTAssertEqual(migrated.version, ReceiverControlPreferences.schemaVersion) XCTAssertTrue(migrated.functionTrayEnabled) + // Older saves keep the earlier same-side placement. XCTAssertEqual(migrated.functionTrayPosition, .sameSide) XCTAssertEqual(migrated.functionTrayProfile(for: .default), .canonical(slot: .default)) } diff --git a/Shared/ControlProfile.swift b/Shared/ControlProfile.swift index 4912d984..4e4c935f 100644 --- a/Shared/ControlProfile.swift +++ b/Shared/ControlProfile.swift @@ -837,7 +837,10 @@ struct ReceiverControlPreferences: Codable, Equatable { /// Tray's; only `allowInput` gates both. var functionTrayEnabled = true var activeFunctionTrayProfile = ControlProfileSlot.default - var functionTrayPosition = FunctionTrayPosition.sameSide + /// New preferences start on the opposite side of the Main Tray. A saved + /// value always wins, and a saved blob without this key keeps the + /// earlier `.sameSide` (see `init(from:)`). + var functionTrayPosition = FunctionTrayPosition.oppositeSide var functionTrayProfiles: [FunctionTrayProfile] /// Global (never profile-specific) layout preference: whether tray/ /// control geometry conditionally shifts inward to clear the notch/ @@ -894,7 +897,9 @@ struct ReceiverControlPreferences: Codable, Equatable { /// Independent edges for the Main controls and the Function controls in /// Strip and Overlay — the iPad replacement for `functionTrayPosition`. var padMainEdge = ControlEdge.trailing - var padFunctionEdge = ControlEdge.trailing + /// New preferences start opposite the Main controls; a saved blob + /// without this key keeps the earlier `.trailing` (see `init(from:)`). + var padFunctionEdge = ControlEdge.leading /// Strip only: compact key/action captions beside the controls. Off by /// default (schema 17): the rail reads cleaner without labels. var padShowControlHints = false @@ -965,7 +970,8 @@ struct ReceiverControlPreferences: Codable, Equatable { // preserve for a tray that didn't exist yet. functionTrayEnabled = try value(.functionTrayEnabled, fallback.functionTrayEnabled) activeFunctionTrayProfile = try value(.activeFunctionTrayProfile, fallback.activeFunctionTrayProfile) - functionTrayPosition = try value(.functionTrayPosition, fallback.functionTrayPosition) + // Older saves predate the opposite-side default: keep what they had. + functionTrayPosition = try value(.functionTrayPosition, FunctionTrayPosition.sameSide) functionTrayProfiles = try value(.functionTrayProfiles, fallback.functionTrayProfiles) // Absent (schema < 7) means "written before Avoid Notch existed" — // default true, matching a brand-new install (and the explicit @@ -1001,7 +1007,7 @@ struct ReceiverControlPreferences: Codable, Equatable { preferMeowDisplayGestures = try value(.preferMeowDisplayGestures, fallback.preferMeowDisplayGestures) padControlLayout = try value(.padControlLayout, fallback.padControlLayout) padMainEdge = try value(.padMainEdge, fallback.padMainEdge) - padFunctionEdge = try value(.padFunctionEdge, fallback.padFunctionEdge) + padFunctionEdge = try value(.padFunctionEdge, ControlEdge.trailing) padShowControlHints = try value(.padShowControlHints, fallback.padShowControlHints) padControlScale = PadControlScale.clamped(try value(.padControlScale, fallback.padControlScale)) padStandardControls = try value(.padStandardControls, fallback.padStandardControls) From a38c4ab157368b344f67f16d7925bfdfcee0ce04 Mon Sep 17 00:00:00 2001 From: raiseCatError <315733358+raiseCatError@users.noreply.github.com> Date: Sun, 11 Oct 2026 21:35:32 +0530 Subject: [PATCH 20/30] feat(ios): make demo mode an interactive sample desktop Demo Mode now shows a working sample Mac desktop instead of a static placeholder, so App Review can try display, input and controls without a Mac. Everything stays in memory and nothing is sent anywhere. - DemoDesktop: the sample desktop model (windows, Files list, Preview, Notes, pointer) and what each interaction did, for local feedback. - DemoSurface: the receiver screen. Direct Touch and Trackpad input, Extend (automatic 16:10 or Use Full Display) and Mirror with a dock, and Video Off as a Trackpad Preview that still moves the pointer. - A DEMO banner stays on screen with Allow Input, options and Exit Demo; with Allow Input off, touches meant for the Mac are blocked. - Extend Shape is demo-local and starts on automatic. - Explore Demo sits above the connection instructions in portrait. - Touch timing uses CACurrentMediaTime, like the rest of the app, so the privacy manifest needs no new required-reason category. - Unit tests for the desktop model, demo-local sends, blocked input and Extend Shape. A visual tour UI test walks every mode in portrait and landscape; the smoke test scrolls Explore Demo into view first. --- Mac/InfoPlist.xcstrings | 26 +- Mac/Localizable.xcstrings | 343 +++--- MacTests/DemoDesktopTests.swift | 366 +++++++ MacTests/DemoSessionTests.swift | 45 +- iOS/DemoDesktop.swift | 840 +++++++++++++++ iOS/DemoMode.swift | 449 +------- iOS/DemoSession.swift | 75 +- iOS/DemoSurface.swift | 891 +++++++++++++++ iOS/InfoPlist.xcstrings | 26 +- iOS/Localizable.xcstrings | 1255 ++++++++++++++++++---- iOS/OpenSidecarPhoneApp.swift | 2 +- iOSUITests/DemoModeSmokeTests.swift | 16 +- iOSUITests/DemoModeVisualTourTests.swift | 207 ++++ project.yml | 1 + 14 files changed, 3751 insertions(+), 791 deletions(-) create mode 100644 MacTests/DemoDesktopTests.swift create mode 100644 iOS/DemoDesktop.swift create mode 100644 iOS/DemoSurface.swift create mode 100644 iOSUITests/DemoModeVisualTourTests.swift diff --git a/Mac/InfoPlist.xcstrings b/Mac/InfoPlist.xcstrings index 11ef0966..4922edbb 100644 --- a/Mac/InfoPlist.xcstrings +++ b/Mac/InfoPlist.xcstrings @@ -1,6 +1,30 @@ { "sourceLanguage" : "en", "strings" : { + "CFBundleDisplayName" : { + "comment" : "Bundle display name", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "MeowDisplay Dev" + } + } + } + }, + "CFBundleName" : { + "comment" : "Bundle name", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "MeowDisplay Dev" + } + } + } + }, "NSLocalNetworkUsageDescription" : { "comment" : "Privacy - Local Network Usage Description", "extractionState" : "extracted_with_value", @@ -15,4 +39,4 @@ } }, "version" : "1.0" -} +} \ No newline at end of file diff --git a/Mac/Localizable.xcstrings b/Mac/Localizable.xcstrings index d89e816f..955f0726 100644 --- a/Mac/Localizable.xcstrings +++ b/Mac/Localizable.xcstrings @@ -6,9 +6,6 @@ }, "%@ (Main)" : { "comment" : "A display name, marked as the Mac's main display." - }, - "%@ Mbit/s" : { - }, "%@ backing" : { "comment" : "Display size in pixels, such as 5120×2880." @@ -27,6 +24,9 @@ }, "%@ logical" : { "comment" : "Display size in points, such as 2560×1440." + }, + "%@ Mbit/s" : { + }, "%@ requests %lld FPS. Limited to %lld FPS at this display size." : { "comment" : "The first value is a streaming profile name, such as Performance.", @@ -74,9 +74,6 @@ }, "A/V Sync is set on the receiver — this Mac can only display it." : { - }, - "AWDL" : { - }, "About" : { @@ -86,6 +83,9 @@ }, "Active Display" : { + }, + "Active Profile" : { + }, "Allow" : { @@ -95,14 +95,17 @@ }, "Allow & Mirror" : { + }, + "Allow for This Session" : { + }, "Allow Input" : { }, - "Allow Permanently" : { + "Allow Local View Navigation" : { }, - "Allow for This Session" : { + "Allow Permanently" : { }, "Allowed for this session" : { @@ -134,6 +137,9 @@ }, "Approve MeowDisplay in System Settings → General → Login Items to finish enabling Start at Login." : { + }, + "Arc" : { + "comment" : "Chord palette shape." }, "Arrange Displays" : { @@ -155,6 +161,9 @@ }, "Audio & Sync" : { + }, + "Audio Diagnostics" : { + }, "Audio Streaming" : { @@ -167,6 +176,12 @@ }, "Auto" : { + }, + "Auto uses QUIC when this device supports it and uses TCP if QUIC can't reach it. Both use the same pairing and encryption. USB is unaffected." : { + + }, + "Auto-hide Control Trays" : { + }, "Auto-Reconnect" : { @@ -200,6 +215,9 @@ }, "Block" : { "comment" : "Per-device connection policy option: refuse session requests from this device." + }, + "Bottom" : { + }, "Bottom Left" : { @@ -207,7 +225,7 @@ "Bottom Right" : { }, - "Broadcast" : { + "caffeinate -i" : { }, "Can't Connect" : { @@ -231,19 +249,22 @@ "Codec" : { }, - "Codes Match" : { + "Codes did not match" : { }, - "Codes did not match" : { + "Codes Match" : { + }, + "Column" : { + "comment" : "Chord palette shape." }, "Command" : { "comment" : "Keyboard modifier key." }, - "Confirm that you want to trust this device in MEOW." : { + "Confirm that you want to trust “%@” in MEOW." : { }, - "Confirm that you want to trust “%@” in MEOW." : { + "Confirm that you want to trust this device in MEOW." : { }, "Confirm that you want to update the remote address for “%@” in MEOW." : { @@ -254,9 +275,6 @@ }, "Connect" : { - }, - "Connect to the Mac first." : { - }, "Connect to this paired Mac through a reachable private-network address, such as its Tailscale address or MagicDNS name. This address is only used to reach the device — it does not grant trust; every connection still requires the existing secure pairing." : { @@ -270,10 +288,10 @@ "Connected" : { }, - "Connected via Remote" : { + "Connected · Remote" : { }, - "Connected · Remote" : { + "Connected via Remote" : { }, "Connecting…" : { @@ -281,6 +299,9 @@ }, "Connection" : { + }, + "Connection log" : { + }, "Connection Lost" : { @@ -293,14 +314,26 @@ }, "Control" : { "comment" : "Keyboard modifier key." + }, + "Control allowed" : { + + }, + "Control Center" : { + + }, + "Control Layout" : { + }, "Control Request" : { + }, + "Control Size" : { + }, "Control Tray" : { }, - "Control allowed" : { + "Controls" : { }, "Copy Command" : { @@ -317,8 +350,17 @@ }, "Current Stream" : { + }, + "Currently" : { + }, "Custom" : { + "comment" : "iPad control layout: a user-built layout." + }, + "Custom Layout %lld" : { + + }, + "Custom Layouts" : { }, "Default" : { @@ -330,19 +372,19 @@ "Developer" : { }, - "Device Diagnostics" : { + "Device identity changed" : { }, - "Device Settings…" : { + "Device owner authentication failed" : { }, - "Device identity changed" : { + "Device Settings…" : { }, - "Device owner authentication failed" : { + "Devices" : { }, - "Devices" : { + "Diagnostics" : { }, "Direct" : { @@ -359,6 +401,12 @@ }, "Disconnect All" : { + }, + "Display" : { + + }, + "Display layout" : { + }, "Display Mode" : { @@ -368,9 +416,6 @@ }, "Display Request" : { - }, - "Display layout" : { - }, "Displays" : { @@ -386,9 +431,6 @@ }, "Efficiency" : { - }, - "Enable All Routes" : { - }, "Enabled" : { @@ -431,12 +473,6 @@ }, "Favors the newest frame and bounded latency, accepting lower frame rate under pressure." : { - }, - "Force Remote Only" : { - - }, - "Forget" : { - }, "Forget %@?" : { "comment" : "Confirmation alert title; request.name is the paired device's name." @@ -462,11 +498,17 @@ "Future requests from this device connect automatically. Change anytime in Settings." : { }, - "Granted" : { + "General" : { + + }, + "Gestures" : { }, "Grant…" : { + }, + "Granted" : { + }, "Half resolution — lowest latency and bandwidth, visibly softer. Good for WiFi." : { @@ -477,10 +519,10 @@ "Host (Tailscale IP or MagicDNS name)" : { }, - "IPv4" : { + "Idle" : { }, - "Idle" : { + "Imported Layout" : { }, "Input" : { @@ -495,10 +537,10 @@ "Input Requests" : { }, - "Interface" : { + "Invalid pairing message" : { }, - "Invalid pairing message" : { + "Invert Vertical Scrolling" : { }, "It can move the pointer, type, and use touch/scroll input until you turn this off." : { @@ -524,11 +566,14 @@ }, "Keyboard Button" : { + }, + "Keys" : { + }, "Known Devices" : { }, - "LAN" : { + "Landscape Tray Side" : { }, "Left" : { @@ -543,47 +588,50 @@ "Lower power/bandwidth, up to 60 FPS." : { }, - "MAC address" : { + "Mac Disconnected" : { }, - "MEOW automatically chooses the best connection, codec, quality, frame rate and streaming behavior for your devices." : { + "macOS didn't allow the sleep-prevention request, so this Mac may still sleep." : { }, - "MEOW needs Screen Recording and Accessibility permissions to work fully." : { + "Main Controls" : { }, - "Mac" : { + "Make sure the address is correct and Pair Over Remote is turned on on the Mac." : { }, - "Mac Disconnected" : { + "Manual streaming controls." : { }, - "Make sure the address is correct and Pair Over Remote is turned on on the Mac." : { + "Manually control streaming profile, priority, codec, frame rate and quality." : { }, - "Manual Endpoint (DEBUG)" : { + "Master switch for remote control. While off, no connected device can control this Mac, no matter what it was previously granted." : { }, - "Manual streaming controls." : { + "Maximum for this display size: %lld FPS." : { }, - "Manually control streaming profile, priority, codec, frame rate and quality." : { + "Maximum FPS" : { }, - "Master switch for remote control. While off, no connected device can control this Mac, no matter what it was previously granted." : { + "Menu Bar" : { }, - "Maximum FPS" : { + "MEOW automatically chooses the best connection, codec, quality, frame rate and streaming behavior for your devices." : { }, - "Maximum for this display size: %lld FPS." : { + "MEOW needs Screen Recording and Accessibility permissions to work fully." : { }, - "Menu Bar" : { + "MeowDisplay on GitHub" : { }, "MeowDisplay Settings" : { "comment" : "Settings window title. \"MeowDisplay\" is the product name and must stay untranslated; only \"Settings\" is translatable." + }, + "Metal renderer (experimental)" : { + }, "Mirror" : { "comment" : "Display mode: duplicate the Mac's screen onto the device." @@ -606,22 +654,25 @@ "Move the pointer relative to your finger." : { }, - "Nearby" : { + "Move View" : { }, - "Never Allow Requests" : { + "Name" : { }, - "Nice" : { + "Nearby" : { }, - "No LAN wake info learned yet for this Mac — connect to it at least once first." : { + "Network" : { }, - "No Results" : { + "Network Transport" : { + + }, + "Never Allow Requests" : { }, - "No active LAN interface found" : { + "Nice" : { }, "No active display" : { @@ -639,14 +690,17 @@ "No known devices" : { }, - "No paired Macs" : { + "No paired devices yet." : { }, - "No paired devices yet." : { + "No Results" : { }, "No settings found for “%@”" : { + }, + "Not allowed" : { + }, "Not Granted" : { @@ -654,7 +708,7 @@ "Not Now" : { }, - "Not allowed" : { + "Notch Debug Overlay" : { }, "Off" : { @@ -677,6 +731,9 @@ }, "Open Displays…" : { + }, + "Open iOS Settings for MeowDisplay" : { + }, "Open Pair over Remote" : { @@ -701,6 +758,9 @@ }, "Output" : { + }, + "Overlay" : { + "comment" : "iPad control layout: controls float over the Mac display." }, "Overview" : { @@ -720,19 +780,19 @@ "Pair with %@?" : { }, - "Paired device" : { + "Paired · Nearby" : { }, - "Paired with %@" : { + "Paired · Offline" : { }, - "Paired · Nearby" : { + "Paired device" : { }, - "Paired · Offline" : { + "Paired Macs" : { }, - "Pairing Request" : { + "Paired with %@" : { }, "Pairing could not be verified" : { @@ -743,6 +803,9 @@ }, "Pairing in progress…" : { + }, + "Pairing Request" : { + }, "Pairing timed out" : { @@ -755,6 +818,9 @@ }, "Pairing was rejected" : { + }, + "Palette Style" : { + }, "Pause" : { @@ -767,9 +833,18 @@ }, "Performance" : { + }, + "Performance overlay" : { + }, "Permissions" : { + }, + "Picture in Picture" : { + + }, + "Pinch / Zoom" : { + }, "Pinch Gesture" : { @@ -780,7 +855,7 @@ "Port" : { }, - "Power lifecycle (sleep/wake/screen) events are logged continuously to the app log — see System → Reveal Log Files." : { + "possibly virtual" : { }, "Prefer FPS" : { @@ -788,6 +863,9 @@ }, "Prefer Latency" : { + }, + "Prefer MeowDisplay Gestures" : { + }, "Prefer Terminal?" : { @@ -806,18 +884,15 @@ }, "Profile 2" : { - }, - "Promote Mac to Interactive Wake" : { - - }, - "Promote to Interactive Wake" : { - }, "Quality" : { }, "Quality & Bitrate" : { + }, + "QUIC isn't available for this device yet, so Auto uses TCP. It becomes available after a connection with a MeowDisplay version that supports it." : { + }, "Quit" : { @@ -827,9 +902,6 @@ }, "Quit MeowDisplay entirely — equivalent to ⌘Q. Closing just this window keeps MEOW and any active stream running." : { - }, - "Re-locks the About/version row's nine-tap easter egg for retesting the unlock flow." : { - }, "Re-pair" : { @@ -840,10 +912,10 @@ "Ready to connect" : { }, - "Receiver Controls" : { + "Receiver controls" : { }, - "Receiver controls" : { + "Receiver Controls" : { }, "Recheck" : { @@ -854,14 +926,11 @@ }, "Reconnecting…" : { - }, - "Reject Permanently" : { - }, "Reject for This Session" : { }, - "Remote" : { + "Reject Permanently" : { }, "Remote Access" : { @@ -870,10 +939,10 @@ "Remote Endpoint" : { }, - "Remote Pairing" : { + "Remote input stays off until you allow it." : { }, - "Remote input stays off until you allow it." : { + "Remote Pairing" : { }, "Remove Remote Endpoint" : { @@ -885,16 +954,16 @@ "Requests disabled by Mac" : { }, - "Required for WiFi mode. If no device appears in the Devices list, allow MeowDisplay under Privacy & Security → Local Network on this Mac AND on the device — and keep the MeowDisplay app open there." : { + "Required for touch input from the device." : { }, - "Required for touch input from the device." : { + "Required for WiFi mode. If no device appears in the Devices list, allow MeowDisplay under Privacy & Security → Local Network on this Mac AND on the device — and keep the MeowDisplay app open there." : { }, "Required to capture the display." : { }, - "Reset Cat Mode" : { + "Reset View" : { }, "Resolution" : { @@ -920,6 +989,9 @@ }, "Right" : { + }, + "Ring" : { + "comment" : "Chord palette shape." }, "Rotate Gesture" : { @@ -930,11 +1002,14 @@ "Rotate Right" : { }, - "Route" : { + "Rotation" : { }, - "Route Overrides" : { + "Route" : { + }, + "Row" : { + "comment" : "Chord palette shape." }, "Same Side" : { @@ -947,14 +1022,11 @@ }, "Screen Recording" : { - }, - "Selected display isn't currently available — using Automatic until it reconnects." : { - }, "Select…" : { }, - "Send LAN Wake Packet" : { + "Selected display isn't currently available — using Automatic until it reconnects." : { }, "Sensitivity: %.1f×" : { @@ -968,20 +1040,32 @@ }, "Shift" : { "comment" : "Keyboard modifier key." + }, + "Show app in" : { + + }, + "Show Control Hints" : { + }, "Show Control Tray" : { + }, + "Show Desktop" : { + + }, + "Show Function Tray" : { + }, "Show Keyboard Button" : { }, - "Show app in" : { + "Show Surface Grid" : { }, "Skips this prompt for future requests from this device. Change anytime in Settings." : { }, - "Sleep This Mac for WoL Test" : { + "Smart Touch Haptics" : { }, "Snap Rotation" : { @@ -992,6 +1076,9 @@ }, "Some Mac apps use different shortcuts for zooming and rotating. Customize the commands sent to this device when App mode is selected." : { + }, + "Standard Controls" : { + }, "Start at Login" : { @@ -1007,9 +1094,6 @@ }, "Starting video…" : { - }, - "State" : { - }, "Status" : { @@ -1035,19 +1119,19 @@ "Streaming Profile" : { }, - "Subnet mask" : { - + "Strip" : { + "comment" : "iPad control layout: an edge rail beside the Mac display." }, "Support" : { }, - "System" : { + "Support MeowDisplay" : { }, - "Tab" : { + "System" : { }, - "Tailscale host or IP" : { + "Tab" : { }, "Terminates the application, ending any active connection." : { @@ -1055,9 +1139,6 @@ }, "That doesn't look like a valid host or IP address." : { - }, - "The Mac ended this session." : { - }, "The device is requesting to pair again." : { @@ -1065,7 +1146,7 @@ "The display can still turn off normally. Closing a MacBook's lid can still put it to sleep; closed-lid use requires your MacBook to be connected to power and is up to macOS." : { }, - "This Mac and this device can't work together yet." : { + "The Mac ended this session." : { }, "This device must be updated before pairing" : { @@ -1076,6 +1157,9 @@ }, "This is a request policy, not a live toggle: even Always Allow Requests still requires the device to explicitly ask each session, and never bypasses the Allow Input master switch on the Input page." : { + }, + "This Mac and this device can't work together yet." : { + }, "This removes trust, connection hints, and wake metadata for this device. It must be re-paired to connect again." : { @@ -1085,6 +1169,9 @@ }, "Too many pairing attempts. Try again in 30 seconds." : { + }, + "Top" : { + }, "Top Left" : { @@ -1097,6 +1184,9 @@ }, "Trackpad" : { "comment" : "Input mode: relative pointer control like a laptop trackpad, not the physical accessory." + }, + "Trackpad Sensitivity" : { + }, "Tray item layout is configured on the device." : { @@ -1115,9 +1205,6 @@ }, "Turning this off only stops automatic connecting — Connect, Reconnect, and Wake & Connect still work, and an active session stays connected." : { - }, - "USB" : { - }, "Update MeowDisplay on both devices to pair" : { @@ -1163,12 +1250,6 @@ }, "Waiting for your approval…" : { - }, - "Wake" : { - - }, - "Wake Testing" : { - }, "Wake for Network Access" : { @@ -1182,11 +1263,14 @@ "We couldn't restore the connection." : { }, - "When a paired Mac starts sharing with this device, connect without asking. Blocked Macs are never connected." : { - + "Wheel" : { + "comment" : "Chord palette shape." }, "When a paired device asks to connect, start sharing without asking. Remote input always starts off." : { + }, + "When a paired Mac starts sharing with this device, connect without asking. Blocked Macs are never connected." : { + }, "Your Mac" : { @@ -1197,22 +1281,7 @@ "Zoom Out" : { }, - "caffeinate -i" : { - - }, - "macOS didn't allow the sleep-prevention request, so this Mac may still sleep." : { - - }, - "normalEndpoint=%@" : { - - }, - "pairingEndpoint=%@" : { - - }, - "peer=%@" : { - - }, - "possibly virtual" : { + "Zoom While Typing" : { } }, diff --git a/MacTests/DemoDesktopTests.swift b/MacTests/DemoDesktopTests.swift new file mode 100644 index 00000000..4034d591 --- /dev/null +++ b/MacTests/DemoDesktopTests.swift @@ -0,0 +1,366 @@ +import XCTest + +/// Demo Mode's interactive desktop, driven with real finger sequences the +/// way the demo screen feeds them. +@MainActor +final class DemoDesktopTests: XCTestCase { + /// 800×500 points showing a 960×600 desktop: 1.2 desktop points per view point. + private let area = CGRect(x: 0, y: 0, width: 800, height: 500) + private var clock: TimeInterval = 100 + + private func makeInteraction(mode: ReceiverDisplayMode = .mirror) -> DemoInteraction { + DemoInteraction(desktop: DemoDesktop(mode: mode, size: DemoDesktop.size(forAspect: 1.6))) + } + + private func base(_ interaction: DemoInteraction) -> RemoteViewportTransform { + DemoDisplayGeometry.baseTransform(area: area, desktopSize: interaction.desktop.size) + } + + /// View point for a desktop point at the base (unzoomed) presentation. + private func view(_ p: CGPoint) -> CGPoint { CGPoint(x: p.x / 1.2, y: p.y / 1.2) } + + @discardableResult + private func run(_ interaction: inout DemoInteraction, _ frames: [[CGPoint]], + _ settings: DemoInputSettings = DemoInputSettings()) -> [DemoFeedback] { + var feedback: [DemoFeedback] = [] + for points in frames + [[]] { + clock += 0.02 + feedback += interaction.touches(points, at: clock, base: base(interaction), settings: settings) + } + clock += 1 + return feedback + } + + private func center(_ frame: CGRect) -> CGPoint { CGPoint(x: frame.midX, y: frame.midY) } + + // MARK: Direct Touch + + func testDirectTapFocusesWindowAndSelectsFile() throws { + var interaction = makeInteraction() + let files = try XCTUnwrap(interaction.desktop.window(.files)) + let row = CGPoint(x: files.frame.midX, y: files.frame.minY + DemoDesktop.titleBarHeight + 45) + run(&interaction, [[view(row)]]) + XCTAssertEqual(interaction.desktop.focused, .files) + XCTAssertEqual(interaction.desktop.windows.last?.kind, .files) + XCTAssertEqual(interaction.desktop.selectedFile, DemoFile.samples[1].id) + } + + func testDirectDoubleTapOpensFileInPreview() throws { + var interaction = makeInteraction() + let files = try XCTUnwrap(interaction.desktop.window(.files)) + let row = view(CGPoint(x: files.frame.midX, y: files.frame.minY + DemoDesktop.titleBarHeight + 75)) + run(&interaction, [[row]]) + clock -= 0.9 + let feedback = run(&interaction, [[row]]) + XCTAssertEqual(interaction.desktop.previewFile, DemoFile.samples[2].id) + XCTAssertEqual(interaction.desktop.focused, .preview) + XCTAssertTrue(feedback.contains { if case .local = $0 { return true } else { return false } }) + } + + func testDirectDragMovesWindowByTitleBar() throws { + var interaction = makeInteraction() + let notes = try XCTUnwrap(interaction.desktop.window(.notes)) + let start = view(CGPoint(x: notes.frame.midX, y: notes.frame.minY + 10)) + run(&interaction, [[start], [CGPoint(x: start.x + 25, y: start.y + 10)], + [CGPoint(x: start.x + 50, y: start.y + 20)]]) + let moved = try XCTUnwrap(interaction.desktop.window(.notes)) + XCTAssertEqual(moved.frame.minX, notes.frame.minX + 60, accuracy: 0.5) + XCTAssertEqual(moved.frame.minY, notes.frame.minY + 24, accuracy: 0.5) + XCTAssertEqual(interaction.desktop.focused, .notes) + } + + func testOneFingerDragScrollsContentInDirectTouch() throws { + var interaction = makeInteraction() + let files = try XCTUnwrap(interaction.desktop.window(.files)) + let start = view(CGPoint(x: files.frame.midX, y: files.frame.maxY - 20)) + run(&interaction, [[start], [CGPoint(x: start.x, y: start.y - 30)], [CGPoint(x: start.x, y: start.y - 60)]]) + XCTAssertGreaterThan(try XCTUnwrap(interaction.desktop.window(.files)).scrollOffset, 0) + } + + // MARK: Trackpad + + func testTrackpadMovesPointerRelativelyWithSensitivity() { + var interaction = makeInteraction() + let startCursor = interaction.cursor + var settings = DemoInputSettings(inputMode: .trackpad) + // Touching down far from the pointer must not jump it there. + run(&interaction, [[CGPoint(x: 10, y: 10)], [CGPoint(x: 30, y: 10)], [CGPoint(x: 60, y: 20)]], settings) + XCTAssertEqual(interaction.cursor.x, startCursor.x + 60, accuracy: 0.5) + XCTAssertEqual(interaction.cursor.y, startCursor.y + 12, accuracy: 0.5) + settings.trackpadSensitivity = 2 + let before = interaction.cursor + run(&interaction, [[CGPoint(x: 400, y: 300)], [CGPoint(x: 410, y: 300)]], settings) + XCTAssertEqual(interaction.cursor.x, before.x + 24, accuracy: 0.5) + } + + func testTrackpadPointerIsClampedToDesktop() { + var interaction = makeInteraction() + let settings = DemoInputSettings(inputMode: .trackpad) + run(&interaction, [[CGPoint(x: 0, y: 0)], [CGPoint(x: 2000, y: 2000)]], settings) + XCTAssertLessThanOrEqual(interaction.cursor.x, interaction.desktop.size.width) + XCTAssertLessThanOrEqual(interaction.cursor.y, interaction.desktop.size.height) + } + + func testTrackpadTapClicksAtPointerNotFinger() throws { + var interaction = makeInteraction() + let files = try XCTUnwrap(interaction.desktop.window(.files)) + interaction.cursor = CGPoint(x: files.frame.midX, y: files.frame.minY + DemoDesktop.titleBarHeight + 15) + // Tap far away from the Files window. + run(&interaction, [[CGPoint(x: 20, y: 480)]], DemoInputSettings(inputMode: .trackpad)) + XCTAssertEqual(interaction.desktop.selectedFile, DemoFile.samples[0].id) + XCTAssertEqual(interaction.desktop.focused, .files) + } + + func testTwoFingerScrollAtTrackpadPointer() throws { + var interaction = makeInteraction() + let files = try XCTUnwrap(interaction.desktop.window(.files)) + interaction.cursor = CGPoint(x: files.frame.midX, y: files.frame.midY) + let a = CGPoint(x: 300, y: 300), b = CGPoint(x: 340, y: 300) + run(&interaction, [[a, b]] + (1...6).map { step in + let dy = CGFloat(step) * -12 + return [CGPoint(x: a.x, y: a.y + dy), CGPoint(x: b.x, y: b.y + dy)] + }, DemoInputSettings(inputMode: .trackpad)) + XCTAssertGreaterThan(try XCTUnwrap(interaction.desktop.window(.files)).scrollOffset, 0) + XCTAssertEqual(interaction.viewport, .identity) + } + + // MARK: Local view + + private func pinch(_ interaction: inout DemoInteraction, spread: CGFloat, turn: CGFloat = 0, + _ settings: DemoInputSettings = DemoInputSettings()) -> [DemoFeedback] { + let c = CGPoint(x: 400, y: 250) + let frames: [[CGPoint]] = (0...8).map { step in + let t = CGFloat(step) / 8 + let r = 60 + spread * t + let angle = turn * t + return [CGPoint(x: c.x - r * cos(angle), y: c.y - r * sin(angle)), + CGPoint(x: c.x + r * cos(angle), y: c.y + r * sin(angle))] + } + return run(&interaction, frames, settings) + } + + func testPinchZoomsLocalViewAndTwoFingerDoubleTapResets() { + var interaction = makeInteraction() + pinch(&interaction, spread: 60) + XCTAssertGreaterThan(interaction.viewport.scale, 1.3) + XCTAssertEqual(interaction.viewport.rotationRadians, 0, "Rotation defaults to Disabled") + // Zoomed in: the same view point now lands on a different desktop point. + let zoomedScale = interaction.desktopScale(base: base(interaction), videoEnabled: true) + XCTAssertLessThan(zoomedScale, 1.2) + let a = CGPoint(x: 380, y: 250), b = CGPoint(x: 420, y: 250) + run(&interaction, [[a, b]]) + clock -= 0.9 + run(&interaction, [[a, b]]) + XCTAssertEqual(interaction.viewport, .identity) + } + + func testRotationFollowsRotateTargetAndSnaps() { + var interaction = makeInteraction() + var settings = DemoInputSettings(rotateTarget: .viewport, snapRotation: false) + pinch(&interaction, spread: 0, turn: 0.5, settings) + XCTAssertEqual(interaction.viewport.rotationRadians, 0.5, accuracy: 0.05) + interaction.resetViewport() + settings.snapRotation = true + pinch(&interaction, spread: 0, turn: .pi / 2 - 0.05, settings) + XCTAssertEqual(interaction.viewport.rotationRadians, .pi / 2, accuracy: 0.001) + } + + func testPinchSetToAppPreviewsCommandInsteadOfZooming() { + var interaction = makeInteraction() + let feedback = pinch(&interaction, spread: 60, DemoInputSettings(pinchTarget: .app)) + XCTAssertEqual(interaction.viewport.scale, 1) + XCTAssertTrue(feedback.contains(.macAppCommand(.zoomIn))) + } + + func testLocalViewNavigationOffLeavesViewAndExplains() { + var interaction = makeInteraction() + let feedback = pinch(&interaction, spread: 60, DemoInputSettings(allowLocalViewNavigation: false)) + XCTAssertEqual(interaction.viewport, .identity) + XCTAssertTrue(feedback.contains { if case .gestureOff = $0 { return true } else { return false } }) + } + + func testMoveViewPansWithOneFinger() { + var interaction = makeInteraction() + run(&interaction, [[CGPoint(x: 300, y: 200)], [CGPoint(x: 340, y: 230)]], + DemoInputSettings(moveViewActive: true)) + XCTAssertEqual(interaction.viewport.panX, 40, accuracy: 0.5) + XCTAssertEqual(interaction.viewport.panY, 30, accuracy: 0.5) + } + + // MARK: Input off, Video off + + func testInputOffBlocksDesktopInputButKeepsLocalZoom() throws { + var interaction = makeInteraction() + let before = interaction.desktop + let settings = DemoInputSettings(inputAllowed: false) + let files = try XCTUnwrap(interaction.desktop.window(.files)) + let feedback = run(&interaction, [[view(center(files.frame))]], settings) + XCTAssertEqual(feedback, [.inputOff]) + XCTAssertEqual(interaction.desktop, before) + pinch(&interaction, spread: 60, settings) + XCTAssertGreaterThan(interaction.viewport.scale, 1) + } + + func testVideoOffKeepsTrackpadButNoViewChanges() { + var interaction = makeInteraction() + let settings = DemoInputSettings(inputMode: .trackpad, videoEnabled: false) + let cursor = interaction.cursor + run(&interaction, [[CGPoint(x: 100, y: 100)], [CGPoint(x: 150, y: 100)]], settings) + XCTAssertGreaterThan(interaction.cursor.x, cursor.x) + pinch(&interaction, spread: 60, settings) + XCTAssertEqual(interaction.viewport, .identity) + } + + // MARK: Mirror, Extend, shape + + func testMirrorAndExtendShowDifferentDesktops() { + let mirror = DemoDesktop(mode: .mirror, size: DemoDesktop.size(forAspect: 1.6)) + let extend = DemoDesktop(mode: .extend, size: DemoDesktop.size(forAspect: 1.6)) + XCTAssertTrue(mirror.hasDock) + XCTAssertFalse(extend.hasDock) + XCTAssertNotEqual(Set(mirror.windows.map(\.kind)), Set(extend.windows.map(\.kind))) + XCTAssertNotNil(extend.window(.arrangement)) + XCTAssertNil(mirror.window(.arrangement)) + } + + func testWindowsStartBelowMenuBarInsideDesktop() { + for mode in [ReceiverDisplayMode.mirror, .extend] { + for aspect in [1512.0 / 982.0, 1.6, 16.0 / 9, 32.0 / 9, 1.0, 0.46, 4.0 / 3] { + let size = DemoDesktop.size(forAspect: aspect) + for window in DemoDesktop(mode: mode, size: size).windows { + XCTAssertGreaterThanOrEqual(window.frame.minY, DemoDesktop.menuBarHeight, "\(mode) \(aspect) \(window.kind)") + XCTAssertLessThanOrEqual(window.frame.maxY, size.height, "\(mode) \(aspect) \(window.kind)") + XCTAssertGreaterThan(window.frame.height, DemoDesktop.titleBarHeight * 2, "\(mode) \(aspect) \(window.kind)") + } + } + } + } + + func testSwitchingModeKeepsNotesAndSelection() { + var interaction = makeInteraction() + interaction.desktop.raise(.notes) + XCTAssertTrue(interaction.desktop.type(" Extra")) + interaction.reconfigure(mode: .extend, size: DemoDesktop.size(forAspect: 1.6)) + XCTAssertEqual(interaction.desktop.mode, .extend) + XCTAssertTrue(interaction.desktop.notes.hasSuffix(" Extra")) + } + + func testExtendShapesKeepTheirRatios() { + let receiver = CGSize(width: 874, height: 402) + for shape in ExtendDisplayShape.allCases where shape != .automatic { + let aspect = DemoDisplayGeometry.desktopAspect( + mode: .extend, shape: ExtendDisplayShapePreference(shape: shape, useFullDisplay: false), receiverSize: receiver) + XCTAssertEqual(aspect, try XCTUnwrap(shape.explicitAspect), accuracy: 0.0001, shape.rawValue) + let size = DemoDesktop.size(forAspect: aspect) + XCTAssertEqual(Double(size.width / size.height), aspect, accuracy: 0.01, shape.rawValue) + // Fitting never stretches: the shown rect keeps the ratio. + let shown = DemoDisplayGeometry.baseTransform(area: area, desktopSize: size).displayedRect + XCTAssertEqual(Double(shown.width / shown.height), aspect, accuracy: 0.01, shape.rawValue) + } + } + + func testAutomaticIs16x10AndFullDisplayFollowsOrientation() { + let landscape = CGSize(width: 874, height: 402) + let portrait = CGSize(width: 402, height: 874) + XCTAssertEqual(DemoDisplayGeometry.desktopAspect(mode: .extend, shape: .standard, receiverSize: landscape), + 16.0 / 10.0, accuracy: 0.0001) + let full = ExtendDisplayShapePreference(shape: .automatic, useFullDisplay: true) + XCTAssertEqual(DemoDisplayGeometry.desktopAspect(mode: .extend, shape: full, receiverSize: landscape), + 874.0 / 402.0, accuracy: 0.0001) + let tall = DemoDisplayGeometry.desktopAspect(mode: .extend, shape: full, receiverSize: portrait) + XCTAssertEqual(tall, 402.0 / 874.0, accuracy: 0.0001) + let size = DemoDesktop.size(forAspect: tall) + XCTAssertGreaterThan(size.height, size.width) + // A tall desktop stacks its windows, all inside the desktop. + let desktop = DemoDesktop(mode: .extend, size: size) + for window in desktop.windows { + XCTAssertTrue(CGRect(origin: .zero, size: size).contains(window.frame), "\(window.kind)") + } + } + + func testMirrorUsesSampleMacShapeRegardlessOfExtendShape() { + let shape = ExtendDisplayShapePreference(shape: .r1x1, useFullDisplay: false) + XCTAssertEqual(DemoDisplayGeometry.desktopAspect(mode: .mirror, shape: shape, receiverSize: CGSize(width: 400, height: 900)), + 1512.0 / 982.0, accuracy: 0.0001) + } + + func testExtendPointSizeComesFromRealSizing() { + let full = ExtendDisplayShapePreference(shape: .automatic, useFullDisplay: true) + let size = DemoDisplayGeometry.extendPointSize(shape: full, receiverPixels: CGSize(width: 2752, height: 2064)) + XCTAssertEqual(size.wide, 1376) + XCTAssertEqual(size.high, 1032) + } + + // MARK: Keys + + func testControlKeysActLocallyWhereTheyCan() { + var desktop = DemoDesktop(mode: .mirror, size: DemoDesktop.size(forAspect: 1.6)) + desktop.raise(.notes) + XCTAssertEqual(desktop.key(usage: 4, modifiers: []), .local("Typed in Notes")) + XCTAssertTrue(desktop.notes.hasSuffix("a")) + XCTAssertEqual(desktop.key(usage: 29, modifiers: ["command"]), .local("Undo")) + XCTAssertEqual(desktop.key(usage: 29, modifiers: ["command", "shift"]), .local("Redo")) + XCTAssertNil(desktop.key(usage: 6, modifiers: ["command"]), "Copy needs a Mac") + XCTAssertEqual(desktop.key(usage: 43, modifiers: []), .local("Focused Files")) + XCTAssertEqual(desktop.focused, .files) + _ = desktop.key(usage: 81, modifiers: []) + XCTAssertEqual(desktop.selectedFile, DemoFile.samples[0].id) + } + + func testDockAndCloseButtonWork() throws { + var desktop = DemoDesktop(mode: .mirror, size: DemoDesktop.size(forAspect: 1.6)) + let notes = try XCTUnwrap(desktop.window(.notes)) + _ = desktop.click(at: CGPoint(x: notes.frame.minX + 15, y: notes.frame.minY + 14)) + XCTAssertNil(desktop.window(.notes)) + let dockNotes = try XCTUnwrap(desktop.dockFrames().first { $0.0 == .notes }).1 + _ = desktop.click(at: CGPoint(x: dockNotes.midX, y: dockNotes.midY)) + XCTAssertNotNil(desktop.window(.notes)) + XCTAssertEqual(desktop.focused, .notes) + } + + // MARK: Tray defaults + + func testNewControlPreferencesPutFunctionTrayOpposite() { + let preferences = ReceiverControlPreferences() + XCTAssertEqual(preferences.functionTrayPosition, .oppositeSide) + XCTAssertEqual(preferences.padMainEdge, .trailing) + XCTAssertEqual(preferences.padFunctionEdge, .leading) + } + + func testSavedControlPreferencesKeepTheirTrayPlacement() throws { + let suite = "DemoDesktopTests.\(UUID().uuidString)" + let defaults = try XCTUnwrap(UserDefaults(suiteName: suite)) + defer { defaults.removePersistentDomain(forName: suite) } + let repository = ReceiverControlPreferencesRepository(defaults: defaults) + + // Explicit saved choices win. + var saved = ReceiverControlPreferences() + saved.functionTrayPosition = .sameSide + saved.padFunctionEdge = .trailing + repository.save(saved) + XCTAssertEqual(repository.load().functionTrayPosition, .sameSide) + XCTAssertEqual(repository.load().padFunctionEdge, .trailing) + + // Saves from before these keys existed keep the earlier placement. + var json = try XCTUnwrap(JSONSerialization.jsonObject(with: JSONEncoder().encode(ReceiverControlPreferences())) as? [String: Any]) + json.removeValue(forKey: "functionTrayPosition") + json.removeValue(forKey: "padFunctionEdge") + defaults.set(try JSONSerialization.data(withJSONObject: json), forKey: ReceiverControlPreferencesRepository.defaultsKey) + XCTAssertEqual(repository.load().functionTrayPosition, .sameSide) + XCTAssertEqual(repository.load().padFunctionEdge, .trailing) + } + + // MARK: Lifecycle + + func testReenteringDemoStartsFresh() { + let first = DemoSession() + first.workspace.interaction.desktop.raise(.notes) + _ = first.workspace.interaction.desktop.type("changed") + first.workspace.interaction.viewport.scale = 2 + first.extendShape = ExtendDisplayShapePreference(shape: .r4x3, useFullDisplay: false) + let second = DemoSession() + XCTAssertEqual(second.workspace.interaction.desktop.notes, DemoDesktop.initialNotes) + XCTAssertEqual(second.workspace.interaction.viewport, .identity) + XCTAssertEqual(second.extendShape, .standard) + } +} diff --git a/MacTests/DemoSessionTests.swift b/MacTests/DemoSessionTests.swift index 5a7e48e4..b79b3015 100644 --- a/MacTests/DemoSessionTests.swift +++ b/MacTests/DemoSessionTests.swift @@ -51,21 +51,48 @@ final class DemoSessionTests: XCTestCase { XCTAssertEqual(demo.events.suffix(2).map(\.message), ["Demo input turned off", "Demo input allowed"]) } - /// Demo controls produce local feedback only. The type holds no - /// `StreamReceiver`, so there is no wire to reach. - func testDemoSendsBecomeLocalFeedbackOnly() { + /// Demo controls act on the sample desktop or preview what a Mac would + /// get. The type holds no `StreamReceiver`, so there is no wire to reach. + func testDemoSendsBecomeLocalActionsOrMacPreviews() { let demo = DemoSession() - let generation = demo.actionGeneration demo.sendKeyboardPress(usage: 6, modifiers: ["command"]) - XCTAssertEqual(demo.lastAction, "⌘C") + XCTAssertEqual(demo.workspace.feedback, .macShortcut(usage: 6, modifiers: ["command"])) + XCTAssertEqual(DemoFeedback.shortcutName(usage: 6, modifiers: ["command"]), "Copy") demo.sendModifier(.option, down: true) - XCTAssertEqual(demo.lastAction, "Option") - demo.sendKeyboardPress(usage: 41) - XCTAssertEqual(demo.lastAction, "Esc") - XCTAssertEqual(demo.actionGeneration, generation + 3) + XCTAssertEqual(demo.workspace.feedback, .modifierHeld(.option)) + demo.sendGesture(name: ReceiverGesture.missionControl.rawValue) + XCTAssertEqual(demo.workspace.feedback, .macGesture("missionControl")) + // Show Desktop and Undo are carried out locally. + demo.sendGesture(name: ReceiverGesture.showDesktop.rawValue) + XCTAssertTrue(demo.workspace.interaction.desktop.showingDesktop) + demo.workspace.interaction.desktop.reconfigure(mode: .mirror, size: DemoDesktop.size(forAspect: 1.6)) + demo.workspace.focusNotesForTyping() + demo.typeText("Hi") + XCTAssertTrue(demo.workspace.interaction.desktop.notes.hasSuffix("Hi")) + demo.sendKeyboardPress(usage: 29, modifiers: ["command"]) + XCTAssertEqual(demo.workspace.feedback, .local("Undo")) + XCTAssertFalse(demo.workspace.interaction.desktop.notes.hasSuffix("Hi")) XCTAssertFalse(Mirror(reflecting: demo).children.contains { $0.value is StreamReceiver }) } + func testInputOffBlocksSimulatedMacInput() { + let demo = DemoSession() + demo.setInputAllowed(false) + let before = demo.workspace.interaction.desktop + demo.sendKeyboardPress(usage: 6, modifiers: ["command"]) + XCTAssertEqual(demo.workspace.feedback, .inputOff) + demo.typeText("x") + demo.sendGesture(name: ReceiverGesture.showDesktop.rawValue) + XCTAssertEqual(demo.workspace.interaction.desktop, before) + } + + func testExtendShapeIsDemoLocalAndStartsAutomatic() { + let demo = DemoSession() + XCTAssertEqual(demo.extendShape, .standard) + demo.extendShape = ExtendDisplayShapePreference(shape: .r21x9, useFullDisplay: false) + XCTAssertEqual(DemoSession().extendShape, .standard) + } + func testDiagnosticsAreSampleAndPerInstance() { let first = DemoSession() XCTAssertEqual(first.events.first?.message, "Demo session started (sample data, no network)") diff --git a/iOS/DemoDesktop.swift b/iOS/DemoDesktop.swift new file mode 100644 index 00000000..74884b7d --- /dev/null +++ b/iOS/DemoDesktop.swift @@ -0,0 +1,840 @@ +import CoreGraphics +import Foundation + +// MARK: - Demo desktop model +// +// The interactive part of Demo Mode, kept free of UIKit/SwiftUI so the unit +// tests drive the same gesture sequences the screen does. Geometry and +// gesture policy reuse the real receiver's pure models +// (`RemoteViewportCalculator`, `ManualViewportState`, +// `ViewportNavigationSession`, `TwoFingerGestureSession`, +// `TwoFingerRoutingPolicy`, `ExtendDisplayShapePreference`). Nothing here can +// reach a network or a store: every effect lands in this value or is +// returned as `DemoFeedback` for the screen to show. + +/// What one demo interaction did, for the screen's feedback chip. +enum DemoFeedback: Equatable { + /// Happened here, on the sample desktop. + case local(String) + /// A key combination a real session would send to the Mac. + case macShortcut(usage: Int, modifiers: [String]) + /// A semantic Mac action (`ReceiverGesture` raw value). + case macGesture(String) + /// An App Gesture Command a real session would send. + case macAppCommand(AppGestureCommandKind) + /// A two-finger tap: a secondary click on the Mac. + case macSecondaryClick + /// Typing with no sample field focused. + case macTyping + /// An on-screen modifier held for the next key. + case modifierHeld(ControlModifier) + /// A touch was meant for the Mac while Allow Input is off. + case inputOff + /// A pinch or rotation that Settings turns off. + case gestureOff(String) +} + +/// Sample windows. A small, fixed set: each one exists to show a behavior. +enum DemoWindowKind: String, CaseIterable, Identifiable { + case notes + case files + case preview + case arrangement + + var id: String { rawValue } +} + +struct DemoWindow: Identifiable, Equatable { + let kind: DemoWindowKind + /// Desktop points, top-left origin. + var frame: CGRect + var scrollOffset: CGFloat = 0 + var id: DemoWindowKind { kind } +} + +struct DemoFile: Identifiable, Equatable { + let id: String + let name: String + let symbol: String + let detail: String + + static let samples: [DemoFile] = [ + DemoFile(id: "plan", name: "Project Plan", symbol: "doc.text", detail: "Text · 4 KB"), + DemoFile(id: "sketch", name: "Interface Sketch", symbol: "pencil.and.outline", detail: "Drawing · 1.2 MB"), + DemoFile(id: "chart", name: "Weekly Chart", symbol: "chart.bar", detail: "Chart · 88 KB"), + DemoFile(id: "photo", name: "Desk Photo", symbol: "photo", detail: "Image · 2.4 MB"), + DemoFile(id: "slides", name: "Team Slides", symbol: "rectangle.on.rectangle", detail: "Slides · 6.1 MB"), + DemoFile(id: "recording", name: "Voice Memo", symbol: "waveform", detail: "Audio · 3.0 MB"), + DemoFile(id: "budget", name: "Budget", symbol: "tablecells", detail: "Table · 120 KB"), + DemoFile(id: "archive", name: "Old Drafts", symbol: "archivebox", detail: "Folder · 12 items"), + DemoFile(id: "map", name: "Route Map", symbol: "map", detail: "Image · 900 KB"), + DemoFile(id: "readme", name: "Read Me", symbol: "doc.plaintext", detail: "Text · 2 KB"), + ] +} + +/// Where a desktop point lands. +enum DemoHit: Equatable { + case menuBar + case dock(DemoWindowKind) + case close(DemoWindowKind) + case titleBar(DemoWindowKind) + case fileRow(DemoWindowKind, String) + case arrangementDisplay(Int) + case content(DemoWindowKind) + case desktop +} + +struct DemoDesktop: Equatable { + static let longEdge: CGFloat = 960 + static let menuBarHeight: CGFloat = 24 + static let titleBarHeight: CGFloat = 28 + static let rowHeight: CGFloat = 30 + static let dockHeight: CGFloat = 56 + static let dockItemSize: CGFloat = 44 + static let notesLineHeight: CGFloat = 22 + static let initialNotes = """ + Welcome to MeowDisplay. + + This sample desktop runs on this device, so you can try the controls without a Mac. + + Tap or click to focus a window. Drag a title bar to move it. Scroll the Files list. Double-click a file to preview it. + + Open the keyboard to type here. + """ + + private(set) var mode: ReceiverDisplayMode + private(set) var size: CGSize + /// Back to front. + private(set) var windows: [DemoWindow] + private(set) var focused: DemoWindowKind? + private(set) var selectedFile: String? + private(set) var previewFile: String = DemoFile.samples[0].id + private(set) var previewZoom: CGFloat = 1 + private(set) var notes = Self.initialNotes + private(set) var highlightedDisplay = 1 + private(set) var showingDesktop = false + private var undoStack: [String] = [] + private var redoStack: [String] = [] + + init(mode: ReceiverDisplayMode, size: CGSize) { + self.mode = mode + self.size = size + windows = Self.layout(for: mode, size: size) + focused = windows.last?.kind + } + + /// Desktop size for an aspect ratio (width / height), long edge fixed. + static func size(forAspect aspect: Double) -> CGSize { + let aspect = aspect.isFinite && aspect > 0 ? CGFloat(aspect) : 1.6 + return aspect >= 1 + ? CGSize(width: longEdge, height: (longEdge / aspect).rounded()) + : CGSize(width: (longEdge * aspect).rounded(), height: longEdge) + } + + var hasDock: Bool { mode == .mirror } + var visibleWindows: [DemoWindow] { showingDesktop ? [] : windows } + + func window(_ kind: DemoWindowKind) -> DemoWindow? { windows.first { $0.kind == kind } } + + // MARK: Layout + + static func windowKinds(for mode: ReceiverDisplayMode) -> [DemoWindowKind] { + mode == .mirror ? [.files, .preview, .notes] : [.arrangement, .notes, .preview] + } + + /// Initial frames, back to front. Landscape-ish desktops place windows + /// side by side; tall ones stack them. + static func layout(for mode: ReceiverDisplayMode, size: CGSize) -> [DemoWindow] { + let top = menuBarHeight + 12 + let bottom = size.height - (mode == .mirror ? dockHeight + 16 : 12) + let usable = CGRect(x: 12, y: top, width: size.width - 24, height: max(bottom - top, 120)) + let kinds = windowKinds(for: mode) + var frames: [DemoWindowKind: CGRect] = [:] + if size.width >= size.height * 1.15 { + let w = usable.width, h = usable.height + switch mode { + case .mirror: + frames[.notes] = CGRect(x: usable.minX, y: usable.minY, width: w * 0.46, height: h * 0.78) + frames[.files] = CGRect(x: usable.minX + w * 0.5, y: usable.minY, width: w * 0.5, height: h * 0.52) + frames[.preview] = CGRect(x: usable.minX + w * 0.42, y: usable.minY + h * 0.4, width: w * 0.5, height: h * 0.6) + case .extend: + frames[.preview] = CGRect(x: usable.minX, y: usable.minY, width: w * 0.58, height: h * 0.9) + frames[.notes] = CGRect(x: usable.minX + w * 0.6, y: usable.minY + h * 0.36, width: w * 0.4, height: h * 0.64) + frames[.arrangement] = CGRect(x: usable.minX + w * 0.6, y: usable.minY, width: w * 0.4, height: h * 0.34) + } + } else { + let gap: CGFloat = 10 + let h = (usable.height - gap * 2) / 3 + for (index, kind) in kinds.reversed().enumerated() { + frames[kind] = CGRect(x: usable.minX, y: usable.minY + CGFloat(index) * (h + gap), + width: usable.width, height: h) + } + } + return kinds.map { DemoWindow(kind: $0, frame: frames[$0]!.integral) } + } + + /// New mode or desktop size: a fresh arrangement for that desktop, with + /// the text, selection and preview carried over. + mutating func reconfigure(mode: ReceiverDisplayMode, size: CGSize) { + guard mode != self.mode || size != self.size else { return } + self.mode = mode + self.size = size + windows = Self.layout(for: mode, size: size) + if let focused, windows.contains(where: { $0.kind == focused }) { raise(focused) } else { + focused = windows.last?.kind + } + showingDesktop = false + } + + mutating func resetLayout() { + windows = Self.layout(for: mode, size: size) + focused = windows.last?.kind + showingDesktop = false + previewZoom = 1 + } + + // MARK: Hit testing + + func dockFrames() -> [(DemoWindowKind, CGRect)] { + guard hasDock else { return [] } + let kinds: [DemoWindowKind] = [.notes, .files, .preview] + let spacing: CGFloat = 10 + let total = CGFloat(kinds.count) * Self.dockItemSize + CGFloat(kinds.count - 1) * spacing + let originX = (size.width - total) / 2 + let y = size.height - Self.dockHeight + (Self.dockHeight - Self.dockItemSize) / 2 - 6 + return kinds.enumerated().map { index, kind in + (kind, CGRect(x: originX + CGFloat(index) * (Self.dockItemSize + spacing), y: y, + width: Self.dockItemSize, height: Self.dockItemSize)) + } + } + + static func closeButtonFrame(in frame: CGRect) -> CGRect { + CGRect(x: frame.minX + 8, y: frame.minY + 7, width: 14, height: 14).insetBy(dx: -6, dy: -6) + } + + func arrangementFrames(in frame: CGRect) -> [CGRect] { + let content = CGRect(x: frame.minX + 16, y: frame.minY + Self.titleBarHeight + 34, + width: frame.width - 32, height: max(frame.height - Self.titleBarHeight - 46, 30)) + let mac = CGRect(x: content.minX, y: content.minY + 6, width: content.width * 0.52, height: content.height - 12) + let device = CGRect(x: mac.maxX + 8, y: content.minY + content.height * 0.2, + width: content.width - mac.width - 8, height: content.height * 0.6) + return [mac, device] + } + + func hit(_ point: CGPoint) -> DemoHit { + if point.y < Self.menuBarHeight { return .menuBar } + for (kind, frame) in dockFrames() where frame.insetBy(dx: -4, dy: -4).contains(point) { return .dock(kind) } + for window in visibleWindows.reversed() where window.frame.contains(point) { + if Self.closeButtonFrame(in: window.frame).contains(point) { return .close(window.kind) } + if point.y < window.frame.minY + Self.titleBarHeight { return .titleBar(window.kind) } + switch window.kind { + case .files: + let y = point.y - window.frame.minY - Self.titleBarHeight + window.scrollOffset + let index = Int(y / Self.rowHeight) + if DemoFile.samples.indices.contains(index) { return .fileRow(.files, DemoFile.samples[index].id) } + case .arrangement: + if let index = arrangementFrames(in: window.frame).firstIndex(where: { $0.contains(point) }) { + return .arrangementDisplay(index) + } + case .notes, .preview: + break + } + return .content(window.kind) + } + return .desktop + } + + // MARK: Actions + + mutating func raise(_ kind: DemoWindowKind) { + guard let index = windows.firstIndex(where: { $0.kind == kind }) else { return } + windows.append(windows.remove(at: index)) + focused = kind + } + + /// One click (or tap) at a desktop point. + mutating func click(at point: CGPoint) -> DemoFeedback? { + switch hit(point) { + case .menuBar: + return .macGesture(ReceiverGesture.menuBar.rawValue) + case .dock(let kind): + showingDesktop = false + if window(kind) == nil { + windows.append(Self.layout(for: mode, size: size).first { $0.kind == kind } + ?? DemoWindow(kind: kind, frame: CGRect(x: 40, y: 60, width: 320, height: 240))) + } + raise(kind) + return .local(String(localized: "Opened \(Self.title(kind))")) + case .close(let kind): + windows.removeAll { $0.kind == kind } + focused = windows.last?.kind + return .local(String(localized: "Closed \(Self.title(kind))")) + case .titleBar(let kind), .content(let kind): + raise(kind) + return nil + case .fileRow(let kind, let id): + raise(kind) + selectedFile = id + return nil + case .arrangementDisplay(let index): + raise(.arrangement) + highlightedDisplay = index + return nil + case .desktop: + if showingDesktop { showingDesktop = false; return .local(String(localized: "Windows restored")) } + focused = nil + selectedFile = nil + return nil + } + } + + /// A double-click: opens a file in Preview, or zooms Preview. + mutating func doubleClick(at point: CGPoint) -> DemoFeedback? { + switch hit(point) { + case .fileRow(_, let id): + selectedFile = id + previewFile = id + if window(.preview) == nil { + windows.append(Self.layout(for: mode, size: size).first { $0.kind == .preview } + ?? DemoWindow(kind: .preview, frame: CGRect(x: 60, y: 60, width: 360, height: 260))) + } + raise(.preview) + return .local(String(localized: "Opened \(DemoFile.samples.first { $0.id == id }?.name ?? "")")) + case .content(.preview): + raise(.preview) + previewZoom = previewZoom > 1 ? 1 : 1.6 + return .local(previewZoom > 1 ? String(localized: "Zoomed in") : String(localized: "Actual size")) + default: + return click(at: point) + } + } + + /// Moves a window by its title bar, keeping the bar reachable. + mutating func moveWindow(_ kind: DemoWindowKind, by delta: CGVector) { + guard let index = windows.firstIndex(where: { $0.kind == kind }) else { return } + var frame = windows[index].frame.offsetBy(dx: delta.dx, dy: delta.dy) + frame.origin.x = min(max(frame.minX, 40 - frame.width), size.width - 40) + frame.origin.y = min(max(frame.minY, Self.menuBarHeight), size.height - Self.titleBarHeight) + windows[index].frame = frame + } + + func contentHeight(_ kind: DemoWindowKind) -> CGFloat { + switch kind { + case .files: return CGFloat(DemoFile.samples.count) * Self.rowHeight + 8 + case .notes: return CGFloat(notes.components(separatedBy: "\n").count + 6) * Self.notesLineHeight + case .preview, .arrangement: return 0 + } + } + + func maxScroll(_ kind: DemoWindowKind) -> CGFloat { + guard let window = window(kind) else { return 0 } + return max(contentHeight(kind) - (window.frame.height - Self.titleBarHeight), 0) + } + + /// Scrolls the window under `point` by `dy` content points. Returns + /// whether anything scrolled. + @discardableResult + mutating func scroll(at point: CGPoint, dy: CGFloat) -> Bool { + let kind: DemoWindowKind + switch hit(point) { + case .titleBar(let k), .content(let k), .fileRow(let k, _), .close(let k): kind = k + default: return false + } + guard let index = windows.firstIndex(where: { $0.kind == kind }) else { return false } + let limit = maxScroll(kind) + let next = min(max(windows[index].scrollOffset + dy, 0), limit) + guard next != windows[index].scrollOffset else { return false } + windows[index].scrollOffset = next + return true + } + + // MARK: Keys and text + + mutating func type(_ text: String) -> Bool { + guard focused == .notes, window(.notes) != nil, !text.isEmpty else { return false } + record() + notes += text + return true + } + + mutating func deleteBackward() -> Bool { + guard focused == .notes, !notes.isEmpty else { return false } + record() + notes.removeLast() + return true + } + + private mutating func record() { + undoStack.append(notes) + if undoStack.count > 50 { undoStack.removeFirst() } + redoStack.removeAll() + } + + mutating func undo() -> Bool { + guard let previous = undoStack.popLast() else { return false } + redoStack.append(notes) + notes = previous + return true + } + + mutating func redo() -> Bool { + guard let next = redoStack.popLast() else { return false } + undoStack.append(notes) + notes = next + return true + } + + /// Ordinary keys, as HID usages with modifier names. Returns what + /// happened locally, or `nil` when only a Mac could act on it. + mutating func key(usage: Int, modifiers: [String]) -> DemoFeedback? { + let mods = Set(modifiers) + switch (usage, mods) { + case (29, ["command"]): + return undo() ? .local(String(localized: "Undo")) : .local(String(localized: "Nothing to undo")) + case (29, ["command", "shift"]): + return redo() ? .local(String(localized: "Redo")) : .local(String(localized: "Nothing to redo")) + case (46, ["command"]) where focused == .preview: + previewZoom = min(previewZoom + 0.25, 2.5) + return .local(String(localized: "Preview zoomed in")) + case (45, ["command"]) where focused == .preview: + previewZoom = max(previewZoom - 0.25, 0.5) + return .local(String(localized: "Preview zoomed out")) + case (26, ["command"]): + guard let focused else { return nil } + windows.removeAll { $0.kind == focused } + self.focused = windows.last?.kind + return .local(String(localized: "Closed \(Self.title(focused))")) + case (43, []): + guard !windows.isEmpty else { return nil } + raise(windows[0].kind) + return .local(String(localized: "Focused \(Self.title(focused ?? .notes))")) + case (41, []): + if showingDesktop { showingDesktop = false; return .local(String(localized: "Windows restored")) } + selectedFile = nil + return .local(String(localized: "Selection cleared")) + case (42, []): + return deleteBackward() ? .local(String(localized: "Deleted")) : nil + case (40, []): + return type("\n") ? .local(String(localized: "New line")) : nil + case (81, []), (82, []): + guard focused == .files else { return nil } + let ids = DemoFile.samples.map(\.id) + let current = selectedFile.flatMap { ids.firstIndex(of: $0) } ?? -1 + let next = min(max(current + (usage == 81 ? 1 : -1), 0), ids.count - 1) + selectedFile = ids[next] + return .local(String(localized: "Selected \(DemoFile.samples[next].name)")) + default: + if mods.isEmpty || mods == ["shift"], let character = Self.character(usage: usage, shift: mods.contains("shift")), + type(character) { + return .local(String(localized: "Typed in Notes")) + } + return nil + } + } + + mutating func toggleShowDesktop() -> DemoFeedback { + showingDesktop.toggle() + return .local(showingDesktop ? String(localized: "Showing the desktop") : String(localized: "Windows restored")) + } + + static func character(usage: Int, shift: Bool) -> String? { + switch usage { + case 4...29: + let scalar = UnicodeScalar(UInt8(usage - 4) + (shift ? 65 : 97)) + return String(Character(scalar)) + case 30...38: return String(usage - 29) + case 39: return "0" + case 44: return " " + default: return nil + } + } + + static func title(_ kind: DemoWindowKind) -> String { + switch kind { + case .notes: return String(localized: "Notes") + case .files: return String(localized: "Files") + case .preview: return String(localized: "Preview") + case .arrangement: return String(localized: "Displays") + } + } +} + +// MARK: - Display geometry + +enum DemoDisplayGeometry { + /// The sample Mac's own built-in display (points), which Mirror shows. + static let mirrorSourceSize = CGSize(width: 1512, height: 982) + + /// The simulated Mac desktop's aspect (width / height). Mirror follows + /// the sample Mac's display; Extend uses the real Extend Shape model. + static func desktopAspect(mode: ReceiverDisplayMode, shape: ExtendDisplayShapePreference, + receiverSize: CGSize) -> Double { + switch mode { + case .mirror: + return Double(mirrorSourceSize.width / mirrorSourceSize.height) + case .extend: + let physical = receiverSize.height > 0 ? Double(receiverSize.width / receiverSize.height) : nil + return shape.resolvedAspect(receiverPhysicalAspect: physical) + } + } + + /// The point size a real Mac would build this Extend display at, from + /// the same `ExtendDisplaySizing` the Mac uses. + static func extendPointSize(shape: ExtendDisplayShapePreference, receiverPixels: CGSize) -> (wide: Int, high: Int) { + let wide = Int(receiverPixels.width), high = Int(receiverPixels.height) + let aspect = shape.resolvedAspect(receiverPhysicalAspect: high > 0 ? Double(wide) / Double(high) : nil) + return ExtendDisplaySizing.pointSize(receiverPixelsWide: wide, receiverPixelsHigh: high, aspect: aspect) + } + + /// The desktop aspect-fit into the available area: never stretched. + static func baseTransform(area: CGRect, desktopSize: CGSize) -> RemoteViewportTransform { + RemoteViewportCalculator.normal(viewBounds: area, remoteAspectSize: desktopSize) + } +} + +// MARK: - Input + +/// The settings that decide what a touch does, read from the demo's own +/// control store and session. +struct DemoInputSettings: Equatable { + var inputMode: PointerInputMode = .direct + var trackpadSensitivity: Double = PointerGestureConfig.defaultTrackpadSensitivity + var pinchTarget: ReceiverGestureTarget = .viewport + var rotateTarget: ReceiverGestureTarget = .disabled + var snapRotation = true + var allowLocalViewNavigation = true + var invertVerticalScroll = true + var inputAllowed = true + var videoEnabled = true + var moveViewActive = false +} + +/// One touch sequence's interpretation, fed the live finger positions in +/// view coordinates. Owns the sample desktop, the local viewport and the +/// pointer; every effect lands here or comes back as feedback. +struct DemoInteraction: Equatable { + static let tapSlop: CGFloat = 10 + static let doubleTapInterval: TimeInterval = 0.35 + static let doubleTapDistance: CGFloat = 30 + + var desktop: DemoDesktop + var viewport = ManualViewportState.identity + /// Pointer position, desktop points. + var cursor: CGPoint + /// Brief click marker at a desktop point (cleared by the screen). + private(set) var clickMarker: CGPoint? + private(set) var clickGeneration = 0 + + private enum Phase: Equatable { + case idle + case one(start: CGPoint, last: CGPoint, began: TimeInterval, moved: Bool, drag: DemoWindowKind?, trackpadDrag: Bool) + case two(initial: [CGPoint], began: TimeInterval) + case finished + } + + private var phase = Phase.idle + private var twoFinger = TwoFingerGestureSession() + private var navigation = ViewportNavigationSession() + private var navigationActive = false + private var lastTap: (time: TimeInterval, point: CGPoint)? + private var lastTwoFingerTap: TimeInterval? + private var reportedGestureOff = false + private var reportedAppCommand = false + + init(desktop: DemoDesktop) { + self.desktop = desktop + cursor = CGPoint(x: desktop.size.width / 2, y: desktop.size.height / 2) + } + + static func == (lhs: DemoInteraction, rhs: DemoInteraction) -> Bool { + lhs.desktop == rhs.desktop && lhs.viewport == rhs.viewport && lhs.cursor == rhs.cursor + && lhs.clickMarker == rhs.clickMarker && lhs.clickGeneration == rhs.clickGeneration + } + + // MARK: Geometry + + func transform(base: RemoteViewportTransform, videoEnabled: Bool) -> RemoteViewportTransform { + VideoInteractionPolicy.viewportTransform(base: base, state: viewport, videoEnabled: videoEnabled) + } + + func desktopPoint(forView point: CGPoint, base: RemoteViewportTransform, videoEnabled: Bool) -> CGPoint? { + guard let normalized = transform(base: base, videoEnabled: videoEnabled).remotePoint(forView: point) else { return nil } + return CGPoint(x: normalized.x * desktop.size.width, y: normalized.y * desktop.size.height) + } + + /// Desktop points per view point at the current magnification. + func desktopScale(base: RemoteViewportTransform, videoEnabled: Bool) -> CGFloat { + let shown = transform(base: base, videoEnabled: videoEnabled).displayedRect.width + return shown > 0 ? desktop.size.width / shown : 1 + } + + mutating func resetViewport() { viewport = .identity } + + mutating func clearClickMarker() { clickMarker = nil } + + mutating func reconfigure(mode: ReceiverDisplayMode, size: CGSize) { + let old = desktop.size + desktop.reconfigure(mode: mode, size: size) + if old != size, old.width > 0, old.height > 0 { + cursor = CGPoint(x: cursor.x / old.width * size.width, y: cursor.y / old.height * size.height) + } + viewport = .identity + } + + private mutating func clickAt(_ point: CGPoint, now: TimeInterval, viewPoint: CGPoint) -> DemoFeedback? { + let isDouble = lastTap.map { now - $0.time < Self.doubleTapInterval + && hypot($0.point.x - viewPoint.x, $0.point.y - viewPoint.y) < Self.doubleTapDistance } ?? false + lastTap = isDouble ? nil : (now, viewPoint) + clickMarker = point + clickGeneration &+= 1 + return isDouble ? desktop.doubleClick(at: point) : desktop.click(at: point) + } + + // MARK: Touches + + /// Feeds the current set of fingers (view coordinates); an empty array + /// ends the sequence. + mutating func touches(_ points: [CGPoint], at now: TimeInterval, base: RemoteViewportTransform, + settings: DemoInputSettings) -> [DemoFeedback] { + var feedback: [DemoFeedback] = [] + let viewportAllowed = LocalViewNavigationPolicy.allowsViewportChanges( + localNavigationAllowed: settings.allowLocalViewNavigation, videoEnabled: settings.videoEnabled) + + if points.isEmpty { + feedback += end(at: now, settings: settings) + phase = .idle + return feedback + } + if case .finished = phase { return [] } + + // Move View: fingers navigate the local view only. + if settings.moveViewActive, viewportAllowed { + if !navigationActive { navigation = ViewportNavigationSession(); navigationActive = true } + viewport = navigation.update(points: points, current: viewport, base: base.displayedRect, + allowsZoom: true, allowsRotation: settings.rotateTarget == .viewport) + phase = .two(initial: points, began: now) + return [] + } + + if points.count >= 2 { + let pair = Array(points.prefix(2)) + switch phase { + case .two(let initial, _): + feedback += twoFingerMoved(initial: initial, current: pair, base: base, settings: settings, + viewportAllowed: viewportAllowed) + default: + phase = .two(initial: pair, began: now) + twoFinger = TwoFingerGestureSession() + navigation = ViewportNavigationSession() + navigationActive = false + reportedGestureOff = false + reportedAppCommand = false + _ = navigation.update(points: pair, current: viewport, base: base.displayedRect, + allowsZoom: true, allowsRotation: true) + } + return feedback + } + + let point = points[0] + switch phase { + case .idle: + var drag: DemoWindowKind? + var trackpadDrag = false + if settings.inputAllowed { + switch settings.inputMode { + case .direct: + if let desktopPoint = desktopPoint(forView: point, base: base, videoEnabled: settings.videoEnabled) { + cursor = desktopPoint + if case .titleBar(let kind) = desktop.hit(desktopPoint) { + drag = kind + desktop.raise(kind) + } + } + case .trackpad: + // Tap, then touch again and move: drag what's under the pointer. + if let lastTap, now - lastTap.time < Self.doubleTapInterval, + case .titleBar(let kind) = desktop.hit(cursor) { + drag = kind + trackpadDrag = true + desktop.raise(kind) + } + } + } + phase = .one(start: point, last: point, began: now, moved: false, drag: drag, trackpadDrag: trackpadDrag) + case .one(let start, let last, let began, var moved, let drag, let trackpadDrag): + if hypot(point.x - start.x, point.y - start.y) > Self.tapSlop { moved = true } + guard settings.inputAllowed else { + phase = .one(start: start, last: point, began: began, moved: moved, drag: drag, trackpadDrag: trackpadDrag) + break + } + let scale = desktopScale(base: base, videoEnabled: settings.videoEnabled) + let delta = CGVector(dx: (point.x - last.x) * scale, dy: (point.y - last.y) * scale) + switch settings.inputMode { + case .direct: + if let drag { + desktop.moveWindow(drag, by: delta) + } else if moved, let desktopPoint = desktopPoint(forView: point, base: base, videoEnabled: settings.videoEnabled) { + // A one-finger drag over content scrolls it, like dragging a list. + desktop.scroll(at: desktopPoint, dy: -delta.dy) + cursor = desktopPoint + } + if let desktopPoint = desktopPoint(forView: point, base: base, videoEnabled: settings.videoEnabled) { + cursor = desktopPoint + } + case .trackpad: + let gain = CGFloat(settings.trackpadSensitivity) + let moveDelta = CGVector(dx: delta.dx * gain, dy: delta.dy * gain) + let before = cursor + moveCursor(by: moveDelta) + if let drag { + desktop.moveWindow(drag, by: CGVector(dx: cursor.x - before.x, dy: cursor.y - before.y)) + } + } + phase = .one(start: start, last: point, began: began, moved: moved, drag: drag, trackpadDrag: trackpadDrag) + case .two, .finished: + break + } + return feedback + } + + mutating func moveCursor(by delta: CGVector) { + cursor = CGPoint(x: min(max(cursor.x + delta.dx, 0), desktop.size.width - 1), + y: min(max(cursor.y + delta.dy, 0), desktop.size.height - 1)) + } + + private mutating func twoFingerMoved(initial: [CGPoint], current: [CGPoint], base: RemoteViewportTransform, + settings: DemoInputSettings, viewportAllowed: Bool) -> [DemoFeedback] { + let initialDistance = hypot(initial[1].x - initial[0].x, initial[1].y - initial[0].y) + let currentDistance = hypot(current[1].x - current[0].x, current[1].y - current[0].y) + let initialMid = CGPoint(x: (initial[0].x + initial[1].x) / 2, y: (initial[0].y + initial[1].y) / 2) + let currentMid = CGPoint(x: (current[0].x + current[1].x) / 2, y: (current[0].y + current[1].y) / 2) + let initialAngle = atan2(initial[1].y - initial[0].y, initial[1].x - initial[0].x) + let currentAngle = atan2(current[1].y - current[0].y, current[1].x - current[0].x) + let rotation = ManualViewportState.normalizedAngle(currentAngle - initialAngle) + let intent = twoFinger.update(initialDistance: initialDistance, currentDistance: currentDistance, + initialMidpoint: initialMid, currentMidpoint: currentMid, + rotationDelta: rotation) + let routes = TwoFingerRoutingPolicy.routes( + intent: intent, remoteInputAllowed: settings.inputAllowed, + localNavigationAllowed: settings.allowLocalViewNavigation, videoEnabled: settings.videoEnabled, + pinchTarget: settings.pinchTarget, rotateTarget: settings.rotateTarget) + var feedback: [DemoFeedback] = [] + switch intent { + case .undecided: + break + case .scroll: + guard routes.contains(.remoteScroll) else { + if !reportedGestureOff { reportedGestureOff = true; feedback.append(.inputOff) } + break + } + let previous = navigationMid + navigationMid = currentMid + let dyView = currentMid.y - (previous ?? currentMid).y + let scale = desktopScale(base: base, videoEnabled: settings.videoEnabled) + let natural = settings.invertVerticalScroll + let dy = (natural ? -dyView : dyView) * scale + let target: CGPoint + if settings.inputMode == .direct, + let point = desktopPoint(forView: currentMid, base: base, videoEnabled: settings.videoEnabled) { + target = point + } else { + target = cursor + } + desktop.scroll(at: target, dy: dy) + case .viewportZoomPan: + let pinch = VideoInteractionPolicy.effectiveTarget(stored: settings.pinchTarget, videoEnabled: settings.videoEnabled) + let rotate = VideoInteractionPolicy.effectiveTarget(stored: settings.rotateTarget, videoEnabled: settings.videoEnabled) + if routes.contains(.viewport) { + viewport = navigation.update(points: current, current: viewport, base: base.displayedRect, + allowsZoom: pinch == .viewport, allowsRotation: rotate == .viewport) + navigationActive = true + } + if routes.contains(.appCommands), !reportedAppCommand { + reportedAppCommand = true + let rotating = abs(rotation) > TwoFingerGestureClassifier.rotationIntentThreshold + if rotating, rotate == .app { + feedback.append(.macAppCommand(rotation > 0 ? .rotateRight : .rotateLeft)) + } else if pinch == .app { + feedback.append(.macAppCommand(currentDistance > initialDistance ? .zoomIn : .zoomOut)) + } + } + if !routes.contains(.viewport), !routes.contains(.appCommands), !reportedGestureOff { + reportedGestureOff = true + if !viewportAllowed, settings.videoEnabled, pinch == .viewport { + feedback.append(.gestureOff(String(localized: "Local View Navigation is off"))) + } else if pinch == .disabled { + feedback.append(.gestureOff(String(localized: "Pinch is off in Settings, Gestures"))) + } else if !settings.inputAllowed { + feedback.append(.inputOff) + } + } + } + return feedback + } + + private var navigationMid: CGPoint? + + private mutating func end(at now: TimeInterval, settings: DemoInputSettings) -> [DemoFeedback] { + defer { + navigation.end() + navigationActive = false + navigationMid = nil + } + switch phase { + case .one(let start, let last, let began, let moved, let drag, _): + guard !moved, drag == nil || hypot(last.x - start.x, last.y - start.y) <= Self.tapSlop, + now - began < 0.6 else { return [] } + guard settings.inputAllowed else { return [.inputOff] } + return clickAt(cursor, now: now, viewPoint: settings.inputMode == .direct ? start : .zero).map { [$0] } ?? [] + case .two(_, let began): + if settings.snapRotation, viewport.rotationRadians != 0 { + viewport.rotationRadians = ViewportRotationSnap.snappedAngle(viewport.rotationRadians, enabled: true).angle + } + guard twoFinger.intent == .undecided, now - began < 0.35 else { return [] } + // Two-finger double tap resets the view, like the real receiver. + if let previous = lastTwoFingerTap, now - previous < 0.5, + LocalViewNavigationPolicy.allowsViewportChanges(localNavigationAllowed: settings.allowLocalViewNavigation, + videoEnabled: settings.videoEnabled) { + lastTwoFingerTap = nil + viewport = .identity + return [.local(String(localized: "View reset"))] + } + lastTwoFingerTap = now + guard settings.inputAllowed else { return [.inputOff] } + return [.macSecondaryClick] + case .idle, .finished: + return [] + } + } +} + +extension DemoFeedback { + /// Names for common Mac shortcuts, shown in the preview chip. + static func shortcutName(usage: Int, modifiers: [String]) -> String? { + let mods = Set(modifiers) + guard mods.contains("command") else { + switch usage { + case 41: return String(localized: "Escape") + case 43: return String(localized: "Tab") + default: return nil + } + } + switch usage { + case 4: return String(localized: "Select All") + case 6: return String(localized: "Copy") + case 25: return String(localized: "Paste") + case 27: return String(localized: "Cut") + case 22: return String(localized: "Save") + case 9: return String(localized: "Find") + case 20: return String(localized: "Quit") + case 26: return String(localized: "Close Window") + case 29: return mods.contains("shift") ? String(localized: "Redo") : String(localized: "Undo") + case 43: return String(localized: "Switch Apps") + case 44: return String(localized: "Spotlight") + case 46: return String(localized: "Zoom In") + case 45: return String(localized: "Zoom Out") + case 7 where mods.contains("option"): return String(localized: "Show or Hide Dock") + default: return nil + } + } +} diff --git a/iOS/DemoMode.swift b/iOS/DemoMode.swift index e801a56d..1a2b3fb1 100644 --- a/iOS/DemoMode.swift +++ b/iOS/DemoMode.swift @@ -35,418 +35,6 @@ func demoVideoSize() -> CGSize { return CGSize(width: max(bounds.width, bounds.height), height: min(bounds.width, bounds.height)) } -// MARK: - Receiver surface - -struct DemoReceiverScreen: View { - @ObservedObject var demo: DemoSession - @ObservedObject var store: ReceiverControlStore - @Binding var showSettings: Bool - @Binding var keyboardActive: Bool - let haptics: ReceiverHaptics - let onExit: () -> Void - - @State private var padCanvas: CGRect? - @State private var keyboardRect: CGRect? - @State private var tapPoint: CGPoint? - @State private var tapGeneration = 0 - @State private var showInputOffHint = false - @State private var feedbackVisible = false - - var body: some View { - GeometryReader { geo in - let insets = ControlSafeInsets(top: geo.safeAreaInsets.top, leading: geo.safeAreaInsets.leading, - bottom: geo.safeAreaInsets.bottom, trailing: geo.safeAreaInsets.trailing) - let canvas = canvas(in: geo.size) - ZStack { - Color.black.ignoresSafeArea() - SampleDesktopView(mode: demo.displayMode, videoEnabled: demo.videoEnabled, - showGrid: store.preferences.showSurfaceGrid) - .overlay { tapRipple } - .contentShape(Rectangle()) - .onTapGesture(coordinateSpace: .local) { location in surfaceTapped(at: location) } - .frame(width: canvas.width, height: canvas.height) - .clipped() - .position(x: canvas.midX, y: canvas.midY) - .accessibilityElement(children: .ignore) - .accessibilityLabel(Text("Sample Mac display")) - .accessibilityHint(Text("Demo content drawn on this device. Taps show locally and are not sent.")) - if demo.showPerformanceOverlay { - VStack { - Spacer() - PerfOverlay(stats: Self.sampleStats(demo), videoSize: demo.videoSize) - .padding(.bottom, 10) - } - .allowsHitTesting(false) - } - RemoteKeyboardInputView( - isActive: $keyboardActive, - onCommitText: { demo.noteTyped($0) }, - onSpecialPress: { demo.sendKeyboardPress(usage: $0) }, - onHardwareKeyDown: { usage, modifiers in demo.sendKeyboardPress(usage: usage, modifiers: modifiers) }, - onHardwareKeyUp: { _, _ in }, - onRequestDismiss: { keyboardActive = false }) - .frame(width: 1, height: 1) - .opacity(0) - .allowsHitTesting(false) - ReceiverControlOverlay( - store: store, - receiver: demo, - keyboardActive: $keyboardActive, - showSettings: $showSettings, - keyboardAvailable: demo.inputAllowed, - keyboardVisibleRect: keyboardRect, - containerSize: geo.size, - safeInsets: insets, - notchSide: nil, - reservedRegions: [], - haptics: haptics, - onOccupiedFramesChange: { _ in }, - onCanvasChange: { padCanvas = $0 }, - surfaceOrigin: canvas.origin) - VStack(spacing: 8) { - DemoSessionBanner(demo: demo, onExit: onExit) - if feedbackVisible, let action = demo.lastAction { - DemoFeedbackChip(action: action) - .transition(.opacity.combined(with: .move(edge: .top))) - } - if showInputOffHint && !demo.inputAllowed { - Button { - demo.setInputAllowed(true) - } label: { - Label("Input is off (Demo). Allow Input", systemImage: "hand.tap") - .font(.subheadline.weight(.semibold)) - .padding(.horizontal, 14) - .padding(.vertical, 8) - .background(Capsule().fill(Color(white: 0.16).opacity(0.94))) - .foregroundStyle(.white) - } - .buttonStyle(.plain) - .transition(.opacity) - } - Spacer() - } - .padding(.top, max(insets.top, 12)) - .padding(.horizontal, 16) - } - } - .ignoresSafeArea(.keyboard) - .onAppear { store.applySessionInputState(demo.inputState) } - .onChange(of: demo.inputState) { state in - store.applySessionInputState(state) - if state != .allowed { keyboardActive = false } - withAnimation { showInputOffHint = false } - } - .onChange(of: demo.actionGeneration) { generation in - withAnimation(.easeOut(duration: 0.15)) { feedbackVisible = true } - Task { - try? await Task.sleep(nanoseconds: 1_600_000_000) - guard generation == demo.actionGeneration else { return } - withAnimation(.easeIn(duration: 0.3)) { feedbackVisible = false } - } - } - .onReceive(NotificationCenter.default.publisher(for: UIResponder.keyboardWillChangeFrameNotification)) { note in - guard let frame = note.userInfo?[UIResponder.keyboardFrameEndUserInfoKey] as? CGRect else { return } - let screen = UIScreen.main.bounds - keyboardRect = frame.minY >= screen.maxY - 1 ? nil : frame - } - .onReceive(NotificationCenter.default.publisher(for: UIResponder.keyboardWillHideNotification)) { _ in - keyboardRect = nil - } - } - - private func canvas(in size: CGSize) -> CGRect { - let full = CGRect(origin: .zero, size: size) - guard store.isPad, let padCanvas, padCanvas.width > 0, padCanvas.height > 0 else { return full } - return padCanvas.intersection(full) - } - - @ViewBuilder - private var tapRipple: some View { - if let tapPoint { - Circle() - .strokeBorder(Color.white.opacity(0.9), lineWidth: 2) - .background(Circle().fill(Color.white.opacity(0.25))) - .frame(width: 44, height: 44) - .position(tapPoint) - .id(tapGeneration) - .transition(.scale(scale: 0.4).combined(with: .opacity)) - .allowsHitTesting(false) - } - } - - private func surfaceTapped(at location: CGPoint) { - guard demo.inputAllowed else { - withAnimation { showInputOffHint = true } - return - } - store.beginAutoHideActivity() - store.scheduleAutoHide() - withAnimation(.easeOut(duration: 0.15)) { - tapPoint = location - tapGeneration &+= 1 - } - demo.noteAction(String(localized: "Click")) - let generation = tapGeneration - Task { - try? await Task.sleep(nanoseconds: 450_000_000) - guard generation == tapGeneration else { return } - withAnimation(.easeIn(duration: 0.2)) { tapPoint = nil } - } - } - - static func sampleStats(_ demo: DemoSession) -> PerfStats { - var stats = PerfStats() - stats.fps = demo.fps - stats.mbps = demo.throughputMbps - stats.avgFrameMs = 16.7 - stats.maxFrameMs = 19.2 - stats.e2eP50 = Double(demo.latencyMs) - stats.e2eP95 = Double(demo.latencyMs) + 6 - stats.rttMs = 4 - stats.transport = String(localized: "Sample") - stats.samples = (0..<120).map { 16.7 + sin(Double($0) / 6) * 0.8 } - stats.e2eSamples = (0..<120).map { Double(demo.latencyMs) + sin(Double($0) / 9) * 2 } - stats.capFps = demo.fps - return stats - } -} - -/// Always-visible Demo Mode identification, with Allow Input and Exit Demo. -struct DemoSessionBanner: View { - @ObservedObject var demo: DemoSession - let onExit: () -> Void - - var body: some View { - HStack(spacing: 10) { - DemoBadge() - VStack(alignment: .leading, spacing: 0) { - Text(verbatim: DemoSession.macName).font(.subheadline.weight(.semibold)) - Text("Sample session").font(.caption) - .foregroundStyle(.white.opacity(0.75)) - } - .accessibilityElement(children: .combine) - Divider().frame(height: 24).overlay(Color.white.opacity(0.3)) - Toggle(isOn: Binding(get: { demo.inputAllowed }, set: { demo.setInputAllowed($0) })) { - Text("Input").font(.subheadline) - } - .toggleStyle(.button) - .tint(.white) - .accessibilityLabel(Text("Allow Input (Demo)")) - .accessibilityValue(demo.inputAllowed ? Text("On") : Text("Off")) - Button(action: onExit) { - Text("Exit Demo").font(.subheadline.weight(.semibold)) - } - .buttonStyle(.borderedProminent) - .tint(.orange) - .accessibilityHint(Text("Returns to the home screen.")) - } - .foregroundStyle(.white) - .padding(.leading, 10) - .padding(.trailing, 6) - .padding(.vertical, 6) - .background(Capsule().fill(Color.black.opacity(0.72))) - .overlay(Capsule().strokeBorder(Color.orange.opacity(0.8), lineWidth: 1)) - .fixedSize() - } -} - -/// The DEMO label. Text, not only color, so it reads in every mode. -struct DemoBadge: View { - var body: some View { - Text("DEMO") - .font(.caption.weight(.heavy)) - .padding(.horizontal, 7) - .padding(.vertical, 3) - .background(Capsule().fill(Color.orange)) - .foregroundStyle(.black) - .accessibilityLabel(Text("Demo Mode")) - } -} - -private struct DemoFeedbackChip: View { - let action: String - - var body: some View { - Text("\(action) · Demo, not sent") - .font(.footnote.weight(.semibold)) - .padding(.horizontal, 12) - .padding(.vertical, 6) - .background(Capsule().fill(Color(white: 0.16).opacity(0.94))) - .foregroundStyle(.white) - .accessibilityAddTraits(.updatesFrequently) - } -} - -// MARK: - Sample desktop - -/// A generic sample workspace drawn by MeowDisplay itself. It stands in for -/// the Mac's picture in Demo Mode; it is not a screenshot of any real app. -struct SampleDesktopView: View { - let mode: ReceiverDisplayMode - let videoEnabled: Bool - let showGrid: Bool - - var body: some View { - GeometryReader { geo in - let size = geo.size - let unit = min(size.width, size.height) / 100 - ZStack(alignment: .topLeading) { - if videoEnabled { - LinearGradient(colors: [Color(red: 0.24, green: 0.18, blue: 0.45), - Color(red: 0.12, green: 0.36, blue: 0.52), - Color(red: 0.20, green: 0.55, blue: 0.50)], - startPoint: .topLeading, endPoint: .bottomTrailing) - Image(systemName: "pawprint.fill") - .font(.system(size: unit * 40)) - .foregroundStyle(.white.opacity(0.06)) - .position(x: size.width * 0.72, y: size.height * 0.62) - menuBar(unit: unit) - if mode == .mirror { - notesWindow(unit: unit) - .frame(width: size.width * 0.42, height: size.height * 0.5) - .position(x: size.width * 0.29, y: size.height * 0.36) - photosWindow(unit: unit) - .frame(width: size.width * 0.38, height: size.height * 0.42) - .position(x: size.width * 0.70, y: size.height * 0.52) - } else { - chartWindow(unit: unit) - .frame(width: size.width * 0.5, height: size.height * 0.5) - .position(x: size.width * 0.5, y: size.height * 0.44) - } - dock(unit: unit) - .position(x: size.width / 2, y: size.height - unit * 7) - if showGrid { grid(size: size) } - } else { - Color.black - VStack(spacing: 10) { - Image(systemName: "video.slash").font(.largeTitle) - Text("Video is off (Demo)").font(.headline) - Text("Controls and input keep working.").font(.subheadline) - } - .foregroundStyle(.white.opacity(0.8)) - .frame(width: size.width, height: size.height) - } - } - } - } - - private func menuBar(unit: CGFloat) -> some View { - HStack(spacing: unit * 3) { - Image(systemName: "pawprint.fill") - Text(verbatim: "Sample Workspace").fontWeight(.semibold) - Spacer() - Text(mode == .mirror ? "Mirror (Demo)" : "Extend (Demo)") - Text(verbatim: "9:41") - } - .font(.system(size: unit * 2.2)) - .foregroundStyle(.white) - .padding(.horizontal, unit * 2) - .frame(height: unit * 4.4) - .background(Color.black.opacity(0.28)) - } - - private func window(title: String, unit: CGFloat, - @ViewBuilder content: () -> Content) -> some View { - VStack(spacing: 0) { - HStack(spacing: unit * 0.9) { - ForEach([Color.red, .yellow, .green], id: \.self) { color in - Circle().fill(color.opacity(0.85)).frame(width: unit * 1.6, height: unit * 1.6) - } - Spacer() - Text(verbatim: title).font(.system(size: unit * 2, weight: .semibold)) - Spacer() - Color.clear.frame(width: unit * 6) - } - .padding(.horizontal, unit * 1.4) - .frame(height: unit * 4) - .background(Color(white: 0.92)) - content() - .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) - .background(Color.white) - } - .foregroundStyle(Color(white: 0.15)) - .clipShape(RoundedRectangle(cornerRadius: unit * 1.4, style: .continuous)) - .shadow(color: .black.opacity(0.35), radius: unit * 2, y: unit * 0.8) - } - - private func notesWindow(unit: CGFloat) -> some View { - window(title: "Welcome.txt", unit: unit) { - VStack(alignment: .leading, spacing: unit * 1.4) { - Text("Welcome to the MeowDisplay demo").font(.system(size: unit * 2.8, weight: .bold)) - Text("This sample workspace is drawn on this device. With the Mac app, your Mac’s real screen appears here.") - .font(.system(size: unit * 2)) - ForEach(["Tap to click", "Drag to move", "Two fingers to scroll", "Use the trays for shortcuts"], id: \.self) { line in - Label(LocalizedStringKey(line), systemImage: "checkmark.circle") - .font(.system(size: unit * 2)) - } - } - .padding(unit * 2) - } - } - - private func photosWindow(unit: CGFloat) -> some View { - window(title: "Gallery", unit: unit) { - let colors: [[Color]] = [[.orange, .pink], [.teal, .blue], [.purple, .indigo], - [.green, .mint], [.yellow, .orange], [.cyan, .purple]] - LazyVGrid(columns: Array(repeating: GridItem(.flexible(), spacing: unit), count: 3), spacing: unit) { - ForEach(colors.indices, id: \.self) { index in - RoundedRectangle(cornerRadius: unit, style: .continuous) - .fill(LinearGradient(colors: colors[index], startPoint: .topLeading, endPoint: .bottomTrailing)) - .aspectRatio(1.3, contentMode: .fit) - } - } - .padding(unit * 1.5) - } - } - - private func chartWindow(unit: CGFloat) -> some View { - window(title: "Extended Display", unit: unit) { - VStack(alignment: .leading, spacing: unit * 1.2) { - Text("A second screen for your Mac").font(.system(size: unit * 2.8, weight: .bold)) - Text("In Extend, this device adds space beside your Mac’s own display. Move windows here to use both.") - .font(.system(size: unit * 2)) - HStack(alignment: .bottom, spacing: unit * 1.2) { - ForEach([0.45, 0.7, 0.55, 0.9, 0.65, 0.8, 0.5], id: \.self) { value in - RoundedRectangle(cornerRadius: unit * 0.5) - .fill(Color.accentColor.opacity(0.75)) - .frame(height: unit * 18 * value) - } - } - .frame(maxWidth: .infinity, alignment: .leading) - } - .padding(unit * 2) - } - } - - private func dock(unit: CGFloat) -> some View { - HStack(spacing: unit * 1.4) { - ForEach(["doc.text", "photo", "chart.bar", "terminal", "music.note", "gearshape"], id: \.self) { symbol in - Image(systemName: symbol) - .font(.system(size: unit * 3)) - .foregroundStyle(.white) - .frame(width: unit * 7, height: unit * 7) - .background(RoundedRectangle(cornerRadius: unit * 1.6, style: .continuous) - .fill(Color.white.opacity(0.18))) - } - } - .padding(unit * 1.2) - .background(RoundedRectangle(cornerRadius: unit * 2.4, style: .continuous).fill(Color.black.opacity(0.25))) - } - - private func grid(size: CGSize) -> some View { - Path { path in - let step = min(size.width, size.height) / 8 - stride(from: step, to: size.width, by: step).forEach { - path.move(to: CGPoint(x: $0, y: 0)); path.addLine(to: CGPoint(x: $0, y: size.height)) - } - stride(from: step, to: size.height, by: step).forEach { - path.move(to: CGPoint(x: 0, y: $0)); path.addLine(to: CGPoint(x: size.width, y: $0)) - } - } - .stroke(Color.white.opacity(0.25), lineWidth: 1) - } -} - // MARK: - Home entry point struct ExploreDemoCard: View { @@ -521,8 +109,25 @@ struct DemoSettingsPage: View { String(localized: "\(value) (Sample)", comment: "A demo value, such as 60 fps (Sample).") } + /// This device's screen in pixels, current orientation. + private var receiverPixels: CGSize { + let screen = UIScreen.main + return CGSize(width: screen.bounds.width * screen.scale, height: screen.bounds.height * screen.scale) + } + + /// Mirror streams the sample Mac's own display; Extend streams the + /// display the real `ExtendDisplaySizing` would build (Retina, 2x). private var streamText: String { - "\(Int(demo.videoSize.width))×\(Int(demo.videoSize.height)) @ \(demo.fps) fps" + let wide: Int, high: Int + if demo.displayMode == .mirror { + wide = Int(DemoDisplayGeometry.mirrorSourceSize.width) * 2 + high = Int(DemoDisplayGeometry.mirrorSourceSize.height) * 2 + } else { + let points = DemoDisplayGeometry.extendPointSize(shape: demo.extendShape, receiverPixels: receiverPixels) + wide = points.wide * 2 + high = points.high * 2 + } + return "\(wide)×\(high) @ \(demo.fps) fps" } // MARK: General @@ -572,6 +177,24 @@ struct DemoSettingsPage: View { } } SettingNote("Demo: switching changes the sample display only. With a real Mac, the Mac switches between mirroring its screen and adding this device as a second display.") + if demo.displayMode == .extend { + Picker("Extend Shape", selection: Binding( + get: { demo.extendShape.shape }, + set: { demo.extendShape.shape = $0 })) { + ForEach(ExtendDisplayShape.allCases) { shape in + Text(shape.title).tag(shape) + } + } + if demo.extendShape.shape == .automatic { + Toggle("Use Full Display", isOn: Binding( + get: { demo.extendShape.useFullDisplay }, + set: { demo.extendShape.useFullDisplay = $0 })) + } + SettingNote("Automatic uses 16:10. Use Full Display matches this \(deviceKind)'s screen in its current orientation. The sample desktop changes shape to match.") + } else { + LabeledContent("Mirror Display", value: sample(String(localized: "\(DemoSession.macName) built-in display"))) + SettingNote("Mirror shows the Mac's own display at its own shape. Extend Shape applies only to Extend.") + } Toggle("Show Surface Grid", isOn: preferenceBinding(controlStore, \.showSurfaceGrid)) } Section { diff --git a/iOS/DemoSession.swift b/iOS/DemoSession.swift index 002cc1ef..433b51fd 100644 --- a/iOS/DemoSession.swift +++ b/iOS/DemoSession.swift @@ -105,10 +105,14 @@ final class DemoSession: ObservableObject { @Published var showPerformanceOverlay = false @Published var remoteAccessEnabled = true @Published var requestInputAutomatically = false + /// Extend Shape, demo-local. Uses the real `ExtendDisplayShapePreference`. + @Published var extendShape = ExtendDisplayShapePreference.standard { + didSet { if extendShape != oldValue { log("Demo Extend shape set to \(extendShape.shape.title)") } } + } @Published private(set) var events: [Event] = [] - /// The last control the reviewer used, shown as local feedback only. - @Published private(set) var lastAction: String? - @Published private(set) var actionGeneration = 0 + /// The interactive sample desktop. Its own object, so touches and + /// pointer movement never re-render Settings. + let workspace = DemoWorkspace() private var clock = 0 init(videoSize: CGSize = CGSize(width: 2732, height: 2048), deviceName: String = "Sample iPad") { @@ -138,12 +142,6 @@ final class DemoSession: ObservableObject { log(enabled ? "Demo video on" : "Demo video off") } - /// Records a control the reviewer used, in place of sending it. - func noteAction(_ description: String) { - lastAction = description - actionGeneration &+= 1 - } - /// Adds a line to the in-memory demo diagnostics. func log(_ message: String) { events.append(Event(id: events.count, secondsIntoSession: clock, message: message)) @@ -158,34 +156,41 @@ extension DemoSession: ReceiverControlSource { var inputResetGeneration: Int { 0 } var controlResetGeneration: Int { 0 } + // Every "send" lands on the sample desktop or becomes a preview of what + // a real session would send. Nothing leaves the device. + func sendKeyboardPress(usage: Int, modifiers: [String]) { - noteAction(Self.describe(usage: usage, modifiers: modifiers)) + guard inputAllowed else { return workspace.show(.inputOff) } + workspace.key(usage: usage, modifiers: modifiers) } func sendKeyboardDown(usage: Int, modifiers: [String]) { - noteAction(Self.describe(usage: usage, modifiers: modifiers)) + sendKeyboardPress(usage: usage, modifiers: modifiers) } func sendKeyboardUp(usage: Int, modifiers: [String]) {} func sendModifier(_ modifier: ControlModifier, down: Bool) { - if down { noteAction(modifier.rawValue.capitalized) } + if down, inputAllowed { workspace.show(.modifierHeld(modifier)) } } func sendGesture(name: String) { - noteAction(name) + guard inputAllowed else { return workspace.show(.inputOff) } + workspace.gesture(name: name) } - func noteTyped(_ text: String) { - noteAction(String(localized: "Typed “\(text)”")) + func typeText(_ text: String) { + guard inputAllowed else { return workspace.show(.inputOff) } + workspace.type(text) } - private static let symbols: [String: String] = [ + static let symbols: [String: String] = [ "control": "⌃", "option": "⌥", "shift": "⇧", "command": "⌘", ] private static let keyNames: [Int: String] = [ 40: "Return", 41: "Esc", 42: "Delete", 43: "Tab", 44: "Space", + 45: "-", 46: "=", 47: "[", 48: "]", 79: "→", 80: "←", 81: "↓", 82: "↑", ] @@ -208,3 +213,41 @@ extension DemoSession: ReceiverControlSource { return prefix + key } } + +/// The interactive sample desktop: the desktop, pointer and local view, plus +/// the latest feedback for the screen's chip. In memory only. +@MainActor +final class DemoWorkspace: ObservableObject { + @Published var interaction = DemoInteraction( + desktop: DemoDesktop(mode: .extend, size: DemoDesktop.size(forAspect: ExtendDisplayShape.defaultAutomaticAspect))) + @Published private(set) var feedback: DemoFeedback? + @Published private(set) var feedbackGeneration = 0 + + func show(_ feedback: DemoFeedback) { + self.feedback = feedback + feedbackGeneration &+= 1 + } + + func key(usage: Int, modifiers: [String]) { + show(interaction.desktop.key(usage: usage, modifiers: modifiers) + ?? .macShortcut(usage: usage, modifiers: modifiers)) + } + + func gesture(name: String) { + if name == ReceiverGesture.showDesktop.rawValue { + show(interaction.desktop.toggleShowDesktop()) + } else { + show(.macGesture(name)) + } + } + + func type(_ text: String) { + show(interaction.desktop.type(text) ? .local(String(localized: "Typed in Notes")) : .macTyping) + } + + /// The keyboard opened: typing has somewhere visible to go. + func focusNotesForTyping() { + guard interaction.desktop.window(.notes) != nil else { return } + interaction.desktop.raise(.notes) + } +} diff --git a/iOS/DemoSurface.swift b/iOS/DemoSurface.swift new file mode 100644 index 00000000..cf1bd9a7 --- /dev/null +++ b/iOS/DemoSurface.swift @@ -0,0 +1,891 @@ +import SwiftUI +import UIKit + +// MARK: - Demo receiver surface +// +// Draws the interactive sample desktop (`DemoWorkspace`) where a real +// session shows the Mac, under the real control overlay. Touches go to +// `DemoInteraction`, which applies the configured input mode, gesture +// targets and local view policy. Nothing here sends anything. + +struct DemoReceiverScreen: View { + @ObservedObject var demo: DemoSession + @ObservedObject var workspace: DemoWorkspace + @ObservedObject var store: ReceiverControlStore + @Binding var showSettings: Bool + @Binding var keyboardActive: Bool + let haptics: ReceiverHaptics + let onExit: () -> Void + + @State private var padCanvas: CGRect? + @State private var keyboardRect: CGRect? + @State private var hint: DemoHint? + @State private var hintGeneration = 0 + @State private var feedbackVisible = false + @State private var showGuide = false + + init(demo: DemoSession, store: ReceiverControlStore, showSettings: Binding, + keyboardActive: Binding, haptics: ReceiverHaptics, onExit: @escaping () -> Void) { + self.demo = demo + _workspace = ObservedObject(wrappedValue: demo.workspace) + self.store = store + _showSettings = showSettings + _keyboardActive = keyboardActive + self.haptics = haptics + self.onExit = onExit + } + + private var inputSettings: DemoInputSettings { + let p = store.preferences + return DemoInputSettings(inputMode: p.inputMode, trackpadSensitivity: p.trackpadSensitivity, + pinchTarget: p.pinchTarget, rotateTarget: p.rotateTarget, + snapRotation: p.snapRotation, allowLocalViewNavigation: p.allowLocalViewNavigation, + invertVerticalScroll: p.invertVerticalScroll, inputAllowed: demo.inputAllowed, + videoEnabled: demo.videoEnabled, moveViewActive: store.moveViewActive) + } + + var body: some View { + GeometryReader { geo in + let insets = windowInsets(fallback: geo.safeAreaInsets) + let canvas = canvas(in: geo.size) + let area = displayArea(canvas: canvas, insets: insets) + let base = DemoDisplayGeometry.baseTransform(area: area, desktopSize: workspace.interaction.desktop.size) + let targetSize = desktopSize(receiverSize: geo.size) + ZStack(alignment: .topLeading) { + Color(white: 0.03).ignoresSafeArea() + if demo.videoEnabled { + desktopLayer(base: base) + .frame(width: geo.size.width, height: geo.size.height) + .mask(Rectangle().frame(width: area.width, height: area.height).position(x: area.midX, y: area.midY)) + } else { + DemoTrackpadPreview(interaction: workspace.interaction, inputMode: store.preferences.inputMode, + inputAllowed: demo.inputAllowed) + .frame(width: area.width, height: area.height) + .position(x: area.midX, y: area.midY) + .allowsHitTesting(false) + } + DemoTouchSurface { points, time in + handle(points.map { CGPoint(x: $0.x + area.minX, y: $0.y + area.minY) }, at: time, base: base) + } + .frame(width: area.width, height: area.height) + .position(x: area.midX, y: area.midY) + .accessibilityElement() + .accessibilityLabel(Text("Sample Mac desktop")) + .accessibilityHint(Text(store.preferences.inputMode == .trackpad + ? "Trackpad. Move one finger to move the pointer, tap to click." + : "Direct Touch. Touch the desktop where you want to click.")) + .accessibilityAction(named: Text("Click at pointer")) { + tapAtPointer(base: base) + } + if !demo.videoEnabled { + videoOffActions + .position(x: area.midX, y: area.minY + 44) + } + RemoteKeyboardInputView( + isActive: $keyboardActive, + onCommitText: { demo.typeText($0) }, + onSpecialPress: { demo.sendKeyboardPress(usage: $0) }, + onHardwareKeyDown: { usage, modifiers in demo.sendKeyboardPress(usage: usage, modifiers: modifiers) }, + onHardwareKeyUp: { _, _ in }, + onRequestDismiss: { keyboardActive = false }) + .frame(width: 1, height: 1) + .opacity(0) + .allowsHitTesting(false) + ReceiverControlOverlay( + store: store, + receiver: demo, + keyboardActive: $keyboardActive, + showSettings: $showSettings, + keyboardAvailable: demo.inputAllowed, + keyboardVisibleRect: keyboardRect, + containerSize: geo.size, + safeInsets: insets, + notchSide: nil, + reservedRegions: [], + haptics: haptics, + onOccupiedFramesChange: { _ in }, + onCanvasChange: { padCanvas = $0 }, + surfaceOrigin: canvas.origin) + chrome(canvas: canvas, insets: insets) + } + .onAppear { reconfigure(to: targetSize) } + .onChange(of: DesktopKey(mode: demo.displayMode, size: targetSize)) { key in reconfigure(to: key.size) } + } + .ignoresSafeArea(.keyboard) + .sheet(isPresented: $showGuide) { DemoGuideSheet() } + .onAppear { store.applySessionInputState(demo.inputState) } + .onChange(of: demo.inputState) { state in + store.applySessionInputState(state) + if state != .allowed { keyboardActive = false } + showHint(state == .allowed ? .inputOn : .inputOff) + } + .onChange(of: store.preferences.inputMode) { mode in showHint(mode == .trackpad ? .trackpad : .direct) } + .onChange(of: demo.displayMode) { mode in showHint(mode == .mirror ? .mirror : .extend) } + .onChange(of: demo.extendShape) { shape in showHint(.shape(shape)) } + .onChange(of: demo.videoEnabled) { enabled in showHint(enabled ? .videoOn : .videoOff) } + .onChange(of: store.viewportResetGeneration) { _ in + withAnimation(.easeOut(duration: 0.25)) { workspace.interaction.resetViewport() } + } + .onChange(of: keyboardActive) { active in if active { workspace.focusNotesForTyping() } } + .onChange(of: workspace.feedbackGeneration) { generation in + withAnimation(.easeOut(duration: 0.15)) { feedbackVisible = true } + Task { + try? await Task.sleep(nanoseconds: 2_200_000_000) + guard generation == workspace.feedbackGeneration else { return } + withAnimation(.easeIn(duration: 0.3)) { feedbackVisible = false } + } + } + .onChange(of: workspace.interaction.clickGeneration) { generation in + Task { + try? await Task.sleep(nanoseconds: 350_000_000) + guard generation == workspace.interaction.clickGeneration else { return } + workspace.interaction.clearClickMarker() + } + } + .onReceive(NotificationCenter.default.publisher(for: UIResponder.keyboardWillChangeFrameNotification)) { note in + guard let frame = note.userInfo?[UIResponder.keyboardFrameEndUserInfoKey] as? CGRect else { return } + keyboardRect = frame.minY >= UIScreen.main.bounds.maxY - 1 ? nil : frame + } + .onReceive(NotificationCenter.default.publisher(for: UIResponder.keyboardWillHideNotification)) { _ in + keyboardRect = nil + } + } + + // MARK: Geometry + + private struct DesktopKey: Equatable { + let mode: ReceiverDisplayMode + let size: CGSize + } + + /// The desktop the sample Mac would show: Mirror follows the sample + /// Mac's own display; Extend uses the Extend Shape and, for Automatic + + /// Use Full Display, this device's screen in its current orientation. + private func desktopSize(receiverSize: CGSize) -> CGSize { + DemoDesktop.size(forAspect: DemoDisplayGeometry.desktopAspect( + mode: demo.displayMode, shape: demo.extendShape, receiverSize: receiverSize)) + } + + private func reconfigure(to size: CGSize) { + withAnimation(.easeInOut(duration: 0.25)) { + workspace.interaction.reconfigure(mode: demo.displayMode, size: size) + } + } + + private func canvas(in size: CGSize) -> CGRect { + let full = CGRect(origin: .zero, size: size) + guard store.isPad, let padCanvas, padCanvas.width > 0, padCanvas.height > 0 else { return full } + return padCanvas.intersection(full) + } + + /// The surface ignores the safe area, so read the window's real insets + /// (notch, Dynamic Island, home indicator) for the banner band. + private func windowInsets(fallback: EdgeInsets) -> ControlSafeInsets { + let window = UIApplication.shared.connectedScenes + .compactMap { ($0 as? UIWindowScene)?.keyWindow }.first + guard let insets = window?.safeAreaInsets else { + return ControlSafeInsets(top: fallback.top, leading: fallback.leading, + bottom: fallback.bottom, trailing: fallback.trailing) + } + return ControlSafeInsets(top: insets.top, leading: insets.left, bottom: insets.bottom, trailing: insets.right) + } + + /// Where the banner row starts. + private static func bandTop(canvas: CGRect, insets: ControlSafeInsets) -> CGFloat { + max(canvas.minY, insets.top) + 6 + } + + /// Below the banner row, so the banner never covers the desktop. + private func displayArea(canvas: CGRect, insets: ControlSafeInsets) -> CGRect { + let top = Self.bandTop(canvas: canvas, insets: insets) + DemoSessionBanner.rowHeight + 6 + return CGRect(x: canvas.minX, y: top, width: canvas.width, height: max(canvas.maxY - top, 1)) + } + + // MARK: Input + + private func handle(_ points: [CGPoint], at time: TimeInterval, base: RemoteViewportTransform) { + let feedback = workspace.interaction.touches(points, at: time, base: base, settings: inputSettings) + feedback.forEach(workspace.show) + if points.isEmpty { store.scheduleAutoHide() } else { store.beginAutoHideActivity() } + } + + private func tapAtPointer(base: RemoteViewportTransform) { + let point = workspace.interaction.transform(base: base, videoEnabled: demo.videoEnabled) + .viewPoint(forRemote: CGPoint(x: workspace.interaction.cursor.x / workspace.interaction.desktop.size.width, + y: workspace.interaction.cursor.y / workspace.interaction.desktop.size.height)) + let now = CACurrentMediaTime() + handle([point], at: now, base: base) + handle([], at: now + 0.05, base: base) + } + + // MARK: Layers + + private func desktopLayer(base: RemoteViewportTransform) -> some View { + let interaction = workspace.interaction + let transform = interaction.transform(base: base, videoEnabled: true) + let rect = transform.displayedRect + let size = interaction.desktop.size + return DemoDesktopView(interaction: interaction, typing: keyboardActive, + showPointer: true, showGrid: store.preferences.showSurfaceGrid, + deviceKind: deviceKind) + .frame(width: size.width, height: size.height) + .scaleEffect(size.width > 0 ? rect.width / size.width : 1) + .frame(width: rect.width, height: rect.height) + .rotationEffect(.radians(transform.rotationRadians)) + .position(x: rect.midX, y: rect.midY) + .allowsHitTesting(false) + .accessibilityHidden(true) + } + + @ViewBuilder + private var videoOffActions: some View { + HStack(spacing: 10) { + if store.preferences.inputMode == .direct { + Button { + store.update { $0.inputMode = .trackpad } + } label: { + Label("Try Trackpad", systemImage: "rectangle.and.hand.point.up.left") + } + .buttonStyle(.borderedProminent) + } + Button { + demo.setVideoEnabled(true) + } label: { + Label("Turn Video On", systemImage: "video") + } + .buttonStyle(.bordered) + } + .tint(Color(white: 0.85)) + .controlSize(.regular) + } + + private func chrome(canvas: CGRect, insets: ControlSafeInsets) -> some View { + VStack(spacing: 6) { + DemoSessionBanner(demo: demo, compact: canvas.width < 520, onExit: onExit, + onGuide: { showGuide = true }, + onResetDesktop: { + withAnimation(.easeInOut(duration: 0.25)) { + workspace.interaction.desktop.resetLayout() + workspace.interaction.resetViewport() + } + }, + onResetView: { + withAnimation(.easeOut(duration: 0.25)) { workspace.interaction.resetViewport() } + }) + if let hint { + DemoHintChip(hint: hint) + .transition(.opacity.combined(with: .move(edge: .top))) + } else if feedbackVisible, let feedback = workspace.feedback { + DemoFeedbackChip(feedback: feedback) { demo.setInputAllowed(true) } + .transition(.opacity) + } + } + .frame(width: canvas.width) + .padding(.top, Self.bandTop(canvas: canvas, insets: insets)) + .offset(x: canvas.minX) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + } + + private func showHint(_ newHint: DemoHint) { + hintGeneration &+= 1 + let generation = hintGeneration + withAnimation(.easeOut(duration: 0.2)) { hint = newHint } + UIAccessibility.post(notification: .announcement, argument: newHint.title) + Task { + try? await Task.sleep(nanoseconds: 2_600_000_000) + guard generation == hintGeneration else { return } + withAnimation(.easeIn(duration: 0.3)) { hint = nil } + } + } +} + +// MARK: - Touch capture + +/// Reports every finger on the surface, in order of touch-down. +private struct DemoTouchSurface: UIViewRepresentable { + let onTouches: ([CGPoint], TimeInterval) -> Void + + func makeUIView(context: Context) -> TouchView { + let view = TouchView() + view.onTouches = onTouches + return view + } + + func updateUIView(_ view: TouchView, context: Context) { + view.onTouches = onTouches + } + + final class TouchView: UIView { + var onTouches: (([CGPoint], TimeInterval) -> Void)? + private var active: [UITouch] = [] + + override init(frame: CGRect) { + super.init(frame: frame) + isMultipleTouchEnabled = true + backgroundColor = .clear + } + + required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } + + override func touchesBegan(_ touches: Set, with event: UIEvent?) { + active += touches.sorted { $0.timestamp < $1.timestamp } + report(event) + } + + override func touchesMoved(_ touches: Set, with event: UIEvent?) { report(event) } + + override func touchesEnded(_ touches: Set, with event: UIEvent?) { + active.removeAll { touches.contains($0) } + report(event) + } + + override func touchesCancelled(_ touches: Set, with event: UIEvent?) { + active.removeAll { touches.contains($0) } + report(event) + } + + private func report(_ event: UIEvent?) { + onTouches?(active.map { $0.location(in: self) }, event?.timestamp ?? CACurrentMediaTime()) + } + } +} + +// MARK: - Sample desktop drawing + +/// The sample Mac desktop: neutral wallpaper, a menu bar, a few working +/// windows and, on the Mac's own display (Mirror), a small dock. +struct DemoDesktopView: View { + let interaction: DemoInteraction + let typing: Bool + let showPointer: Bool + let showGrid: Bool + let deviceKind: String + + private var desktop: DemoDesktop { interaction.desktop } + + var body: some View { + ZStack(alignment: .topLeading) { + wallpaper + if showGrid { grid } + ForEach(desktop.visibleWindows) { window in + DemoWindowView(window: window, desktop: desktop, focused: desktop.focused == window.kind, + typing: typing, deviceKind: deviceKind) + .frame(width: window.frame.width, height: window.frame.height) + .offset(x: window.frame.minX, y: window.frame.minY) + } + menuBar + if desktop.hasDock { dock } + if let marker = interaction.clickMarker { + Circle() + .strokeBorder(Color.white.opacity(0.85), lineWidth: 2) + .frame(width: 30, height: 30) + .offset(x: marker.x - 15, y: marker.y - 15) + } + if showPointer { + Image(systemName: "cursorarrow") + .font(.system(size: 20, weight: .regular)) + .foregroundStyle(.white) + .shadow(color: .black.opacity(0.6), radius: 1.5, y: 1) + .offset(x: interaction.cursor.x - 5, y: interaction.cursor.y - 3) + } + } + .frame(width: desktop.size.width, height: desktop.size.height, alignment: .topLeading) + .clipped() + } + + private var wallpaper: some View { + ZStack(alignment: .bottomTrailing) { + LinearGradient(colors: desktop.mode == .mirror + ? [Color(white: 0.20), Color(white: 0.09)] + : [Color(red: 0.13, green: 0.15, blue: 0.19), Color(white: 0.07)], + startPoint: .top, endPoint: .bottom) + if desktop.mode == .extend { + Text("Extended display") + .font(.system(size: 13, weight: .medium)) + .foregroundStyle(.white.opacity(0.28)) + .padding(14) + } + } + } + + private var grid: some View { + Path { path in + let step: CGFloat = 80 + stride(from: step, to: desktop.size.width, by: step).forEach { + path.move(to: CGPoint(x: $0, y: 0)); path.addLine(to: CGPoint(x: $0, y: desktop.size.height)) + } + stride(from: step, to: desktop.size.height, by: step).forEach { + path.move(to: CGPoint(x: 0, y: $0)); path.addLine(to: CGPoint(x: desktop.size.width, y: $0)) + } + } + .stroke(Color.white.opacity(0.05), lineWidth: 1) + } + + private var menuBar: some View { + HStack(spacing: 16) { + Text(verbatim: desktop.focused.map(DemoDesktop.title) ?? "Desktop").fontWeight(.semibold) + Text("File") + Text("Edit") + Text("View") + Text("Window") + Spacer() + Image(systemName: "wifi") + Text(verbatim: "9:41") + } + .font(.system(size: 12)) + .foregroundStyle(.white.opacity(0.9)) + .padding(.horizontal, 14) + .frame(width: desktop.size.width, height: DemoDesktop.menuBarHeight) + .background(Color.black.opacity(0.35)) + } + + private var dock: some View { + let frames = desktop.dockFrames() + let bounds = frames.reduce(CGRect.null) { $0.union($1.1) }.insetBy(dx: -10, dy: -6) + return ZStack(alignment: .topLeading) { + RoundedRectangle(cornerRadius: 16, style: .continuous) + .fill(Color.white.opacity(0.12)) + .overlay(RoundedRectangle(cornerRadius: 16, style: .continuous).strokeBorder(Color.white.opacity(0.12))) + .frame(width: bounds.width, height: bounds.height) + .offset(x: bounds.minX, y: bounds.minY) + ForEach(frames, id: \.0) { kind, frame in + VStack(spacing: 3) { + Image(systemName: DemoWindowView.symbol(kind)) + .font(.system(size: 19, weight: .medium)) + .foregroundStyle(.white) + .frame(width: frame.width, height: frame.height - 8) + .background(RoundedRectangle(cornerRadius: 10, style: .continuous).fill(Color(white: 0.28))) + Circle().fill(Color.white.opacity(desktop.window(kind) != nil ? 0.8 : 0)).frame(width: 4, height: 4) + } + .frame(width: frame.width, height: frame.height) + .offset(x: frame.minX, y: frame.minY) + } + } + } +} + +struct DemoWindowView: View { + let window: DemoWindow + let desktop: DemoDesktop + let focused: Bool + let typing: Bool + let deviceKind: String + + static func symbol(_ kind: DemoWindowKind) -> String { + switch kind { + case .notes: return "note.text" + case .files: return "folder" + case .preview: return "eye" + case .arrangement: return "rectangle.on.rectangle" + } + } + + var body: some View { + VStack(spacing: 0) { + titleBar + // Taller content (a long list) scrolls inside the window; it must + // never grow the window. + content + .frame(minWidth: 0, maxWidth: .infinity, minHeight: 0, maxHeight: .infinity, alignment: .topLeading) + .clipped() + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) + .background(Color(white: 0.14)) + .clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous)) + .overlay(RoundedRectangle(cornerRadius: 10, style: .continuous) + .strokeBorder(Color.white.opacity(focused ? 0.22 : 0.1), lineWidth: 1)) + .shadow(color: .black.opacity(focused ? 0.55 : 0.3), radius: focused ? 18 : 8, y: focused ? 8 : 3) + } + + private var titleBar: some View { + ZStack { + Text(verbatim: DemoDesktop.title(window.kind)) + .font(.system(size: 12, weight: .semibold)) + .foregroundStyle(.white.opacity(focused ? 0.92 : 0.5)) + HStack(spacing: 7) { + ZStack { + Circle().fill(Color(white: focused ? 0.55 : 0.32)) + if focused { + Image(systemName: "xmark").font(.system(size: 7, weight: .bold)).foregroundStyle(.black.opacity(0.7)) + } + } + .frame(width: 13, height: 13) + Circle().fill(Color(white: 0.32)).frame(width: 13, height: 13) + Circle().fill(Color(white: 0.32)).frame(width: 13, height: 13) + Spacer() + } + .padding(.leading, 9) + } + .frame(height: DemoDesktop.titleBarHeight) + .background(Color(white: focused ? 0.21 : 0.17)) + } + + @ViewBuilder + private var content: some View { + switch window.kind { + case .notes: notes + case .files: files + case .preview: preview + case .arrangement: arrangement + } + } + + private var notes: some View { + (Text(desktop.notes) + Text(focused && typing ? "|" : "").foregroundColor(.accentColor)) + .font(.system(size: 13)) + .lineSpacing(6) + .foregroundStyle(.white.opacity(0.88)) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 14) + .padding(.top, 10) + .offset(y: -window.scrollOffset) + } + + private var files: some View { + VStack(spacing: 0) { + ForEach(DemoFile.samples) { file in + HStack(spacing: 10) { + Image(systemName: file.symbol) + .font(.system(size: 13)) + .frame(width: 18) + .foregroundStyle(.white.opacity(0.75)) + Text(verbatim: file.name).font(.system(size: 13)) + Spacer(minLength: 6) + Text(verbatim: file.detail).font(.system(size: 11)).foregroundStyle(.white.opacity(0.45)) + } + .foregroundStyle(.white.opacity(0.9)) + .padding(.horizontal, 12) + .frame(height: DemoDesktop.rowHeight) + .background(desktop.selectedFile == file.id + ? Color.accentColor.opacity(focused ? 0.55 : 0.28) : Color.clear) + } + } + .offset(y: -window.scrollOffset) + } + + private var preview: some View { + let file = DemoFile.samples.first { $0.id == desktop.previewFile } ?? DemoFile.samples[0] + return VStack(spacing: 8) { + Image(systemName: file.symbol) + .font(.system(size: 54 * desktop.previewZoom, weight: .thin)) + .foregroundStyle(.white.opacity(0.85)) + .frame(maxHeight: .infinity) + Text(verbatim: file.name).font(.system(size: 14, weight: .semibold)).foregroundStyle(.white) + Text(verbatim: file.detail).font(.system(size: 11)).foregroundStyle(.white.opacity(0.5)) + Text("Double-click to zoom").font(.system(size: 10)).foregroundStyle(.white.opacity(0.35)) + .padding(.bottom, 10) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(Color(white: 0.1)) + .clipped() + } + + private var arrangement: some View { + let frames = desktop.arrangementFrames(in: window.frame) + let origin = CGPoint(x: window.frame.minX, y: window.frame.minY + DemoDesktop.titleBarHeight) + let labels = [String(localized: "Review Mac"), String(localized: "This \(deviceKind)")] + let roles = [String(localized: "Main display"), String(localized: "Extended")] + return ZStack(alignment: .topLeading) { + Text("Displays").font(.system(size: 11)).foregroundStyle(.white.opacity(0.5)) + .offset(x: 16, y: 10) + ForEach(frames.indices, id: \.self) { index in + let frame = frames[index] + VStack(spacing: 2) { + Text(verbatim: labels[index]).font(.system(size: 11, weight: .semibold)) + Text(verbatim: roles[index]).font(.system(size: 10)).foregroundStyle(.white.opacity(0.55)) + } + .foregroundStyle(.white) + .frame(width: frame.width, height: frame.height) + .background(RoundedRectangle(cornerRadius: 6).fill(Color(white: index == 0 ? 0.24 : 0.19))) + .overlay(RoundedRectangle(cornerRadius: 6) + .strokeBorder(desktop.highlightedDisplay == index ? Color.accentColor : Color.white.opacity(0.15), + lineWidth: desktop.highlightedDisplay == index ? 2 : 1)) + .offset(x: frame.minX - origin.x, y: frame.minY - origin.y) + } + } + } +} + +// MARK: - Video Off + +/// Video Off: the stream is hidden, but the surface still works as an input +/// surface. Shows where the pointer is on the (hidden) sample desktop. +struct DemoTrackpadPreview: View { + let interaction: DemoInteraction + let inputMode: PointerInputMode + let inputAllowed: Bool + + var body: some View { + VStack(spacing: 10) { + Spacer(minLength: 56) + Image(systemName: "rectangle.and.hand.point.up.left") + .font(.system(size: 30, weight: .light)) + .foregroundStyle(.white.opacity(0.7)) + Text("Trackpad Preview").font(.headline).foregroundStyle(.white) + Text("Video is off. With a connected Mac, this surface can still control the pointer.") + .font(.footnote) + .foregroundStyle(.white.opacity(0.6)) + .multilineTextAlignment(.center) + .fixedSize(horizontal: false, vertical: true) + .frame(maxWidth: 360) + pointerMap + hints + Spacer(minLength: 0) + } + .padding(16) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(RoundedRectangle(cornerRadius: 22, style: .continuous).fill(Color(white: 0.08))) + .overlay(RoundedRectangle(cornerRadius: 22, style: .continuous).strokeBorder(Color.white.opacity(0.1))) + .padding(10) + } + + /// A small outline of the sample desktop with the pointer on it. + private var pointerMap: some View { + let desktop = interaction.desktop + let width: CGFloat = 150 + let scale = width / max(desktop.size.width, 1) + return ZStack(alignment: .topLeading) { + RoundedRectangle(cornerRadius: 5).fill(Color(white: 0.14)) + ForEach(desktop.visibleWindows) { window in + RoundedRectangle(cornerRadius: 2) + .strokeBorder(Color.white.opacity(desktop.focused == window.kind ? 0.5 : 0.2)) + .frame(width: window.frame.width * scale, height: window.frame.height * scale) + .offset(x: window.frame.minX * scale, y: window.frame.minY * scale) + } + Circle().fill(Color.accentColor) + .frame(width: 7, height: 7) + .offset(x: interaction.cursor.x * scale - 3.5, y: interaction.cursor.y * scale - 3.5) + } + .frame(width: width, height: desktop.size.height * scale) + .accessibilityHidden(true) + } + + private var hints: some View { + let items: [(String, String)] = inputMode == .trackpad + ? [("hand.point.up.left", String(localized: "Move one finger")), + ("hand.tap", String(localized: "Tap to click")), + ("arrow.up.and.down", String(localized: "Two fingers to scroll"))] + : [("hand.point.up", String(localized: "Direct Touch maps your finger to the Mac's screen"))] + return HStack(spacing: 8) { + ForEach(items, id: \.1) { symbol, text in + Label(text, systemImage: symbol) + .font(.caption) + .padding(.horizontal, 9) + .padding(.vertical, 5) + .background(Capsule().fill(Color.white.opacity(0.08))) + } + } + .foregroundStyle(.white.opacity(inputAllowed ? 0.75 : 0.35)) + } +} + +// MARK: - Chrome + +/// Always visible: DEMO, the sample Mac, Allow Input, options and Exit Demo. +struct DemoSessionBanner: View { + static let rowHeight: CGFloat = 38 + + @ObservedObject var demo: DemoSession + let compact: Bool + let onExit: () -> Void + let onGuide: () -> Void + let onResetDesktop: () -> Void + let onResetView: () -> Void + + var body: some View { + HStack(spacing: 8) { + DemoBadge() + HStack(spacing: 4) { + Text(verbatim: DemoSession.macName).font(.footnote.weight(.semibold)) + if !compact { + Text("· Sample session").font(.footnote).foregroundStyle(.white.opacity(0.6)) + } + } + .accessibilityElement(children: .combine) + Button { + demo.setInputAllowed(!demo.inputAllowed) + } label: { + Image(systemName: demo.inputAllowed ? "hand.tap.fill" : "hand.raised.slash") + .frame(width: 28, height: 28) + .background(Circle().fill(Color.white.opacity(demo.inputAllowed ? 0.16 : 0.06))) + } + .accessibilityLabel(Text("Allow Input (Demo)")) + .accessibilityValue(demo.inputAllowed ? Text("On") : Text("Off")) + Menu { + Button { onGuide() } label: { Label("What can I try?", systemImage: "lightbulb") } + Button { onResetView() } label: { Label("Reset View", systemImage: "arrow.counterclockwise") } + Button { onResetDesktop() } label: { Label("Reset Sample Desktop", systemImage: "macwindow") } + } label: { + Image(systemName: "ellipsis").frame(width: 28, height: 28) + } + .accessibilityLabel(Text("Demo Options")) + Button(action: onExit) { + Text("Exit Demo").font(.footnote.weight(.semibold)) + } + .buttonStyle(.bordered) + .tint(.white) + .controlSize(.small) + .accessibilityHint(Text("Returns to the home screen.")) + } + .foregroundStyle(.white) + .padding(.leading, 10) + .padding(.trailing, 5) + .frame(height: Self.rowHeight) + .background(Capsule().fill(.ultraThinMaterial)) + .overlay(Capsule().strokeBorder(Color.white.opacity(0.12))) + .environment(\.colorScheme, .dark) + .fixedSize() + } +} + +/// The DEMO label: text, never only color. +struct DemoBadge: View { + var body: some View { + Text("DEMO") + .font(.caption2.weight(.bold)) + .tracking(0.6) + .padding(.horizontal, 6) + .padding(.vertical, 2) + .overlay(Capsule().strokeBorder(Color.secondary, lineWidth: 1)) + .accessibilityLabel(Text("Demo Mode")) + } +} + +/// A short note when a meaningful mode changes. +enum DemoHint: Equatable { + case direct, trackpad, mirror, extend, videoOn, videoOff, inputOn, inputOff + case shape(ExtendDisplayShapePreference) + + var title: String { + switch self { + case .direct: return String(localized: "Direct Touch. Touch the desktop directly.") + case .trackpad: return String(localized: "Trackpad. Move your finger to control the pointer.") + case .mirror: return String(localized: "Mirror. You see the Mac's own screen.") + case .extend: return String(localized: "Extend. This device adds a second desktop beside the Mac.") + case .videoOn: return String(localized: "Video on.") + case .videoOff: return String(localized: "Video off. The surface still works as a trackpad.") + case .inputOn: return String(localized: "Input allowed (Demo).") + case .inputOff: return String(localized: "Input off (Demo). Controls are hidden; view navigation still works.") + case .shape(let shape): + if shape.shape == .automatic { + return shape.useFullDisplay + ? String(localized: "Extend fills this display.") + : String(localized: "Extend Automatic: 16:10.") + } + return String(localized: "Extend shape \(shape.shape.title).") + } + } +} + +private struct DemoHintChip: View { + let hint: DemoHint + + var body: some View { + Text(hint.title) + .font(.footnote.weight(.medium)) + .multilineTextAlignment(.center) + .padding(.horizontal, 12) + .padding(.vertical, 6) + .background(Capsule().fill(.ultraThinMaterial)) + .environment(\.colorScheme, .dark) + .foregroundStyle(.white) + .padding(.horizontal, 16) + } +} + +private struct DemoFeedbackChip: View { + let feedback: DemoFeedback + let onAllowInput: () -> Void + + var body: some View { + HStack(spacing: 8) { + Image(systemName: symbol).foregroundStyle(.white.opacity(0.75)) + Text(text).font(.footnote.weight(.medium)).lineLimit(2) + if feedback == .inputOff { + Button("Allow Input", action: onAllowInput) + .font(.footnote.weight(.semibold)) + .buttonStyle(.bordered) + .controlSize(.mini) + .tint(.white) + } + } + .foregroundStyle(.white) + .padding(.horizontal, 12) + .padding(.vertical, 6) + .background(Capsule().fill(.ultraThinMaterial)) + .environment(\.colorScheme, .dark) + .padding(.horizontal, 16) + .accessibilityElement(children: .combine) + .accessibilityAddTraits(.updatesFrequently) + } + + private var symbol: String { + switch feedback { + case .local: return "checkmark.circle" + case .modifierHeld: return "command" + case .inputOff: return "hand.raised.slash" + case .gestureOff: return "nosign" + case .macShortcut, .macGesture, .macAppCommand, .macSecondaryClick, .macTyping: return "laptopcomputer" + } + } + + private var text: String { + let toMac = String(localized: "would be sent to the Mac") + switch feedback { + case .local(let text): return text + case .modifierHeld(let modifier): + return String(localized: "\(DemoSession.symbols[modifier.rawValue] ?? modifier.rawValue) held. Choose a shortcut.") + case .inputOff: return String(localized: "Input is off (Demo).") + case .gestureOff(let reason): return reason + case .macShortcut(let usage, let modifiers): + let keys = DemoSession.describe(usage: usage, modifiers: modifiers) + if let name = DemoFeedback.shortcutName(usage: usage, modifiers: modifiers) { + return "\(keys) \(name) · \(toMac)" + } + return "\(keys) · \(toMac)" + case .macGesture(let raw): + return "\(ReceiverGesture(rawValue: raw)?.title ?? raw) · \(toMac)" + case .macAppCommand(let kind): + return String(localized: "\(kind.title) command · \(toMac)") + case .macSecondaryClick: + return String(localized: "Secondary click · \(toMac)") + case .macTyping: + return String(localized: "Typing \(toMac). Open Notes to type here.") + } + } +} + +/// "What can I try?": a short list, nothing more. +private struct DemoGuideSheet: View { + @Environment(\.dismiss) private var dismiss + + private let items: [(String, String, String)] = [ + ("hand.tap", String(localized: "Tap and drag"), String(localized: "Tap a window to focus it, drag its title bar, scroll the Files list, double-click a file.")), + ("rectangle.and.hand.point.up.left", String(localized: "Trackpad"), String(localized: "Settings, Input: switch to Trackpad and move the pointer with one finger.")), + ("arrow.up.left.and.arrow.down.right", String(localized: "Zoom your view"), String(localized: "Pinch to zoom, drag with two fingers to pan, double-tap with two fingers to reset.")), + ("rectangle.on.rectangle", String(localized: "Mirror and Extend"), String(localized: "Settings, Display: switch modes and try Extend Shape.")), + ("video.slash", String(localized: "Video Off"), String(localized: "Turn video off to use the surface as a trackpad.")), + ("command", String(localized: "Controls"), String(localized: "Use the control trays and keyboard. Mac-only actions show what would be sent.")), + ] + + var body: some View { + NavigationStack { + List(items, id: \.1) { symbol, title, detail in + Label { + VStack(alignment: .leading, spacing: 2) { + Text(title).font(.body.weight(.semibold)) + Text(detail).font(.footnote).foregroundStyle(.secondary) + } + } icon: { + Image(systemName: symbol) + } + } + .navigationTitle("What can I try?") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .confirmationAction) { Button("Done") { dismiss() } } + } + } + .presentationDetents([.medium, .large]) + } +} diff --git a/iOS/InfoPlist.xcstrings b/iOS/InfoPlist.xcstrings index 826af062..26690de4 100644 --- a/iOS/InfoPlist.xcstrings +++ b/iOS/InfoPlist.xcstrings @@ -1,6 +1,30 @@ { "sourceLanguage" : "en", "strings" : { + "CFBundleDisplayName" : { + "comment" : "Bundle display name", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "MeowDisplay" + } + } + } + }, + "CFBundleName" : { + "comment" : "Bundle name", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "OpenSidecariOS" + } + } + } + }, "NSFaceIDUsageDescription" : { "comment" : "Privacy - Face ID Usage Description", "extractionState" : "extracted_with_value", @@ -27,4 +51,4 @@ } }, "version" : "1.0" -} +} \ No newline at end of file diff --git a/iOS/Localizable.xcstrings b/iOS/Localizable.xcstrings index 84e2b11f..a650cc1e 100644 --- a/iOS/Localizable.xcstrings +++ b/iOS/Localizable.xcstrings @@ -1,9 +1,28 @@ { "sourceLanguage" : "en", "strings" : { + "" : { + + }, + "%@ %@" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "%1$@ %2$@" + } + } + } + }, + "%@ · Demo, not sent" : { + + }, "%@ (Main)" : { "comment" : "A display name, marked as the Mac's main display." }, + "%@ (Sample)" : { + "comment" : "A demo value, such as 60 fps (Sample)." + }, "%@ requests %lld FPS. Limited to %lld FPS at this display size." : { "comment" : "The first value is a streaming profile name, such as Performance.", "localizations" : { @@ -20,36 +39,114 @@ }, "%lld" : { + }, + "%lld controls" : { + + }, + "%lld controls from a newer MeowDisplay were skipped." : { + + }, + "%lld Function actions aren’t available in this version and won’t appear." : { + + }, + "%lld ms" : { + + }, + "⌘ ⌥ ⌃ ⇧ form the next arc out. Hold or tap a modifier to use it." : { + + }, + "A control dropped near a guide line settles exactly on it. Needs Alignment Guides on." : { + + }, + "A control dropped near the edge of the usable area, or near its center lines, settles exactly against it." : { + + }, + "A MeowDisplay Custom layout. Import it in Settings → Controls → Custom Layouts." : { + }, "A newer version of MeowDisplay is available." : { + }, + "A second screen for your Mac" : { + + }, + "A second, independent set of one-tap shortcuts (Undo, Redo, …), separate from the Main controls." : { + }, "A second, independent tray of one-tap shortcuts (Undo, Redo, …), separate from the Main Tray above. \"Same Side\" groups it with the Main Tray; \"Opposite Side\" puts it on the other edge of the screen." : { }, - "A/V Sync" : { + "A share code contains only this layout — positions, sizes, palettes, shortcut buttons and Two-Hand Assist. Never pairing, devices or permissions." : { + + }, + "A solid rail at the edge of the screen holds the controls, and your Mac fits beside it." : { }, - "AAC Integrity Logging" : { + "A/V Sync" : { }, "About" : { + }, + "About %@" : { + + }, + "Accessibility: lets touches and keys from this %@ control the Mac." : { + + }, + "Action" : { + + }, + "Actions" : { + + }, + "Actions From" : { + + }, + "Active" : { + + }, + "Active Function Profile" : { + + }, + "Active Layout" : { + }, "Active Profile" : { }, - "Add" : { + "Add Action" : { + + }, + "Add Another Key" : { + + }, + "Add Control" : { + + }, + "Add Control → Keyboard Shortcut makes a button that sends any key combination once, like ⌃U or ⌘⇧P." : { + + }, + "Add Step" : { }, - "Add Shortcut" : { + "Address" : { }, "Adjust if sound plays slightly before or after the picture." : { + }, + "Alignment Guides" : { + }, "Allow" : { + }, + "Allow Input (Demo)" : { + + }, + "Allow Local View Navigation" : { + }, "Allow Permanently" : { @@ -68,23 +165,41 @@ }, "App" : { "comment" : "Gesture target: send the gesture to the Mac app." + }, + "App Exposé" : { + }, "App Gesture Commands" : { + }, + "Arc" : { + "comment" : "Chord palette shape." + }, + "Arc — shortcuts fan outward along part of a circle." : { + + }, + "Arrange your controls" : { + + }, + "Asks your Mac to allow input from this device." : { + }, "Audio" : { }, - "Audio Comparison Dump" : { + "Audio Diagnostics" : { }, "Auto" : { }, - "Auto-Reconnect" : { + "Auto-hide Control Trays" : { }, - "Auto-hide Control Trays" : { + "Auto-hide fades Overlay and Custom controls after a few seconds without use. The Strip always stays visible." : { + + }, + "Auto-Reconnect" : { }, "Automatic" : { @@ -92,6 +207,9 @@ }, "Automatically Allow Connections" : { + }, + "Automatically reconnect to paired devices after connection interruptions. In the demo this only changes the sample session." : { + }, "Automatically reconnect to paired devices after connection interruptions. Turning this off only stops automatic reconnecting — Connect, Reconnect, and Wake & Connect still work, and an active session stays connected. Also shown on the Home screen." : { @@ -107,6 +225,9 @@ }, "Block" : { "comment" : "Per-device connection policy option: refuse session requests from this device." + }, + "Bottom" : { + }, "Bottom Left" : { @@ -116,6 +237,12 @@ }, "Builds while you hold a title bar, then ticks when the window can move. Also confirms switching to Direct Touch." : { + }, + "Button" : { + + }, + "Button Face" : { + }, "Can't Connect" : { @@ -137,30 +264,48 @@ }, "Choose Another Mac" : { + }, + "Click" : { + }, "Close" : { }, - "Codes Match" : { + "Codec" : { }, "Codes did not match" : { + }, + "Codes Match" : { + }, "Collapse Control Tray" : { }, "Collapsed" : { + }, + "Column" : { + "comment" : "Chord palette shape." + }, + "Column — shortcuts appear in a vertical line." : { + + }, + "command" : { + "comment" : "Strip control hint under the ⌘ key." }, "Command" : { "comment" : "Keyboard modifier key." }, - "Confirm that you want to trust this device in MEOW." : { + "Configure shortcut palettes" : { }, "Confirm that you want to trust “%@” in MEOW." : { + }, + "Confirm that you want to trust this device in MEOW." : { + }, "Confirm that you want to update the remote address for “%@” in MEOW." : { @@ -173,9 +318,6 @@ }, "Connect the %@ by USB, or join the same WiFi" : { - }, - "Connect to the Mac first." : { - }, "Connect with Extend" : { @@ -185,6 +327,9 @@ }, "Connected" : { + }, + "Connected (Demo)" : { + }, "Connecting…" : { @@ -192,58 +337,124 @@ "Connection" : { }, - "Connection Instructions" : { + "Connection failed" : { }, - "Connection Lost" : { + "Connection Instructions" : { }, - "Connection Requests" : { + "Connection log" : { }, - "Connection Requests: Default follows Automatically Allow Connections. Blocking keeps the Mac paired." : { + "Connection Lost" : { }, - "Connection failed" : { + "Connection Requests" : { }, - "Connection log" : { + "Connection Requests: Default follows Automatically Allow Connections. Blocking keeps the Mac paired. Request Input Automatically lets a touch on the Mac’s screen ask that Mac for control without tapping Request Input first — the Mac still decides." : { }, "Connection timed out" : { }, - "Continuous" : { + "Connections" : { + }, + "control" : { + "comment" : "Strip control hint under the ⌃ key." }, "Control" : { "comment" : "Keyboard modifier key." + }, + "Control Center" : { + + }, + "Control Layout" : { + + }, + "Control Profile" : { + + }, + "Control Size" : { + + }, + "Controls" : { + + }, + "Controls and input keep working." : { + + }, + "Copied" : { + + }, + "Copy Share Code" : { + }, "Couldn't reach the Mac" : { }, "Couldn't save this endpoint. Try again." : { + }, + "Create Custom Layout" : { + }, "Custom" : { + "comment" : "iPad control layout: a user-built layout." + }, + "Custom Layout %lld" : { + + }, + "Custom Layouts" : { }, "Default" : { }, - "Developer" : { + "Delete" : { + + }, + "Delete “%@”?" : { + + }, + "Delete Action" : { + + }, + "Delete Layout" : { + + }, + "Delete this layout?" : { + + }, + "DEMO" : { + + }, + "Demo content drawn on this device. Taps show locally and are not sent." : { + + }, + "Demo Diagnostics" : { + + }, + "Demo Mode" : { + + }, + "Demo: %@ is a sample and isn't saved as a paired Mac. Forget is unavailable for it. Your real paired Macs aren't shown in the demo. Pairing with a real Mac is confirmed on both devices." : { + + }, + "Demo: Allow Input is simulated on this %@. No Mac is asked and nothing is sent. Settings always stays reachable, even with input turned off." : { }, - "Developer — Audio Diagnostics" : { + "Demo: changes here last only for this demo and don't rename this %@." : { }, - "Developer — Cat Mode" : { + "Demo: no sound plays from the sample Mac." : { }, - "Developer — Promote Interactive Wake" : { + "Demo: switching changes the sample display only. With a real Mac, the Mac switches between mirroring its screen and adding this device as a second display." : { }, - "Developer — Wake Testing" : { + "Developer" : { }, "Device identity changed" : { @@ -263,6 +474,12 @@ }, "Disabled" : { + }, + "Discard Changes" : { + + }, + "Discard your changes?" : { + }, "Disconnect" : { @@ -275,30 +492,75 @@ }, "Display Paused" : { + }, + "dock" : { + "comment" : "Strip control hint." + }, + "Dock" : { + }, "Done" : { + }, + "Dots" : { + + }, + "Dots or grid lines for lining controls up while you edit. They aren’t part of your Mac’s screen and never appear during normal use." : { + }, "Double-tap to %@" : { }, - "Draws the computed unsafe/obstacle regions (red) and the raw vs. Avoid-Notch-adjusted Main Tray frame (yellow/green) directly over the stream." : { + "Double-tap with two fingers" : { + + }, + "Drag a control to move it, and tap it to change its size or shortcut palette. Use Preview to try the layout — it never sends anything to your Mac." : { + + }, + "Drag to move, pinch to zoom" : { + + }, + "Edit" : { }, "Edit %@" : { + }, + "Edit Button" : { + }, "Edit Current Profile" : { }, "Edit Function Tray" : { + }, + "Edit Function Tray…" : { + + }, + "Edit Keys or Steps…" : { + + }, + "Edit Layout" : { + + }, + "Edit Palette Actions" : { + }, "Edit Remote Details…" : { + }, + "Edit Shortcut…" : { + }, "Efficiency" : { + }, + "Emoji" : { + + }, + "Enable Input" : { + }, "Enforce Maximum FPS" : { @@ -311,9 +573,15 @@ }, "Enter a port between 1 and 65535." : { + }, + "escape" : { + "comment" : "Strip control hint." }, "Escape" : { + }, + "Exit Demo" : { + }, "Expanded" : { @@ -323,12 +591,24 @@ }, "Experimental app command mode" : { + }, + "Explore Demo" : { + + }, + "Explore MeowDisplay" : { + }, "Extend" : { "comment" : "Display mode: add the device as a second, separate screen (not the verb \"to extend\")." + }, + "Extend (Demo)" : { + }, "Extend Shape" : { + }, + "Far" : { + }, "Fast" : { @@ -342,121 +622,244 @@ "Forget" : { }, - "Forget the device to pair again." : { + "Forget “%@”?" : { }, - "Forget “%@”?" : { + "Forget the device to pair again." : { }, "Frame Rate" : { + }, + "Function Action" : { + + }, + "Function Controls" : { + }, "Function Profile" : { }, "Function Tray" : { + }, + "Function Tray — %@" : { + }, "Function Tray Position" : { }, - "Gestures" : { + "General" : { }, - "Get the Mac app" : { + "Gestures" : { }, - "GitHub — raiseCatError/MeowDisplay" : { + "Get the Latest Version" : { }, - "Haptics" : { + "Get the Mac app" : { }, - "Host (Tailscale IP or MagicDNS name)" : { + "Got It" : { }, - "How to connect" : { + "Grid Lines" : { }, - "If audio drifts or stutters, Resync re-establishes timing without reconnecting. It doesn't change your A/V Sync adjustment above." : { + "Guides" : { }, - "Input" : { + "Haptics" : { }, - "Input Mode" : { + "Help" : { }, - "Install the MeowDisplay Mac app on your Mac" : { + "Hidden" : { }, - "Invalid pairing message" : { + "Hide" : { }, - "It will connect using Extend." : { + "Hide from Function Tray" : { }, - "It will connect using Mirror." : { + "Hide Keyboard" : { }, - "Keep this app open — streaming starts automatically" : { + "Hold or latch %@ to show these. Tap one to change what it sends or how it looks; drag to reorder." : { }, - "Keep this app open — streaming starts on its own" : { + "Host (Tailscale IP or MagicDNS name)" : { }, - "Keeps controls clear of the iPhone’s notch or Dynamic Island in landscape." : { + "How a modifier’s shortcuts appear while you hold or latch it. Wheel surrounds it with wedge-shaped segments; Arc fans them along the next ring out; Ring circles it; Row and Column line them up." : { }, - "Key" : { + "How Custom Layouts Work" : { }, - "Keyboard" : { + "How far the shortcuts sit from the modifier." : { }, - "Landscape Tray Side" : { + "How to connect" : { }, - "Later" : { + "If audio drifts or stutters, Resync re-establishes timing without reconnecting. It doesn't change your A/V Sync adjustment above." : { }, - "Left" : { + "Import Layout" : { }, - "Legacy SampleBuffer Renderer" : { + "Import Layout…" : { }, - "Lower power/bandwidth, up to 60 FPS." : { + "Imported Layout" : { }, - "MEOW automatically chooses the best connection, codec, quality, frame rate and streaming behavior for your devices." : { + "Importing adds a new layout. Its shortcut buttons do nothing until you tap them." : { }, - "Mac" : { + "In Extend, this device adds space beside your Mac’s own display. Move windows here to use both." : { }, - "Mac Address" : { + "Input" : { }, - "Mac Disconnected" : { + "Input Enabled" : { }, - "Mac pairing removed" : { + "Input is off (Demo). Allow Input" : { }, - "Mac requires app update" : { + "Input Mode" : { }, - "Main Tray" : { + "Install the MeowDisplay Mac app on your Mac" : { }, - "Make sure the address is correct and Pair Over Remote is turned on on the Mac." : { + "Invalid pairing message" : { }, - "Manual" : { + "Invert Horizontal Scrolling" : { }, - "Manual diagnostic, not automated: asks the connected Mac to declare remote user activity, to test whether that promotes a dark/network wake into a full interactive wake." : { + "Invert Vertical Scrolling" : { + + }, + "It will connect using Extend." : { + + }, + "It will connect using Mirror." : { + + }, + "Keep this app open — streaming starts automatically" : { + + }, + "Keep this app open — streaming starts on its own" : { + + }, + "Keeps controls clear of the iPhone’s notch or Dynamic Island in landscape." : { + + }, + "Key" : { + + }, + "Key %lld" : { + + }, + "keyboard" : { + "comment" : "Strip control hint." + }, + "Keyboard" : { + + }, + "Keyboard Shortcut" : { + + }, + "Keyboard Shortcut…" : { + + }, + "Keyboard, Escape and Tab sit on the inner arc around Settings. Drag any control where your thumb can reach it." : { + + }, + "Keys" : { + + }, + "Keys & Utilities" : { + + }, + "landscape" : { + + }, + "Landscape" : { + + }, + "Landscape & Portrait" : { + + }, + "Landscape and portrait are arranged separately — MeowDisplay uses whichever matches how you hold your iPad." : { + + }, + "Landscape Tray Side" : { + + }, + "Latency" : { + + }, + "Later" : { + + }, + "Launchpad" : { + + }, + "Layout" : { + + }, + "Layout editor canvas" : { + + }, + "Layout Name" : { + + }, + "Layout preview" : { + + }, + "Leaving MeowDisplay during a session keeps your Mac visible in a floating window. It’s view-only — control stays in MeowDisplay." : { + + }, + "Left" : { + + }, + "Lower power/bandwidth, up to 60 FPS." : { + + }, + "Mac" : { + + }, + "Mac Address" : { + + }, + "Mac Disconnected" : { + + }, + "Mac pairing removed" : { + + }, + "Mac requires app update" : { + + }, + "Main Controls" : { + + }, + "Main Tray" : { + + }, + "Make sure the address is correct and Pair Over Remote is turned on on the Mac." : { + + }, + "Manual" : { }, "Manual streaming controls." : { @@ -468,11 +871,20 @@ "Maximum FPS" : { }, - "MeowDisplay" : { + "MDL1-…" : { + + }, + "Menu Bar" : { + + }, + "MEOW automatically chooses the best connection, codec, quality, frame rate and streaming behavior for your devices." : { }, "MeowDisplay needs the Mac app running on a Mac on the same cable or WiFi network. Download it here if you haven't yet." : { + }, + "MeowDisplay on GitHub" : { + }, "MeowDisplay turns this %@ into a second screen for your Mac — but it needs the **MeowDisplay Mac app** running on a Mac connected by the same USB cable or on the same WiFi network." : { @@ -482,6 +894,9 @@ }, "Mirror" : { "comment" : "Display mode: duplicate the Mac's screen onto the device." + }, + "Mirror (Demo)" : { + }, "Mirror Display" : { @@ -491,399 +906,716 @@ }, "Mirror requires an active physical display." : { + }, + "Mission Control" : { + + }, + "Mode" : { + }, "Modifiers" : { }, "Move the pointer relative to your finger." : { + }, + "move view" : { + "comment" : "Strip control hint." + }, + "Move View" : { + }, "Name" : { }, "Nearby Devices" : { + }, + "Network" : { + + }, + "New Button" : { + + }, + "Next Space" : { + }, "Nice" : { }, - "No LAN wake info learned yet for this Mac — connect to it at least once first." : { + "No devices nearby" : { + + }, + "No displays reported." : { + + }, + "No paired Macs" : { + + }, + "No Results" : { + + }, + "No shortcuts for %@" : { + + }, + "Not allowed" : { + + }, + "Not connected" : { + + }, + "Notch Debug Overlay" : { + + }, + "Off" : { + + }, + "Off while Haptics is turned off." : { + + }, + "OK" : { + + }, + "On" : { + + }, + "On the Mac" : { + + }, + "On the Mac, open Devices and choose Open Pair over Remote, then enter the Mac's Tailscale address or MagicDNS name here." : { + + }, + "On, content moves with your fingers, like a Mac trackpad’s natural scrolling. Turn an axis off to reverse just that direction; diagonal scrolls follow both. Moving your own view of the Mac isn’t affected." : { + + }, + "One more app to go" : { + + }, + "Only the %@ arrangement changes. Shortcut buttons you added are removed from it." : { + + }, + "Open iOS Settings for MeowDisplay" : { + + }, + "Opens a sample session. Nothing connects to another device." : { + + }, + "Opposite Side" : { + + }, + "option" : { + "comment" : "Strip control hint under the ⌥ key." + }, + "Option" : { + "comment" : "Keyboard modifier key." + }, + "Optionally configure Two-Hand Assist" : { + + }, + "Options for %@" : { + + }, + "Or choose the keys below. Keys are pressed together, then released." : { + + }, + "Or choose this %@ under WiFi in the Mac app" : { + + }, + "Orientation" : { + + }, + "Overlay" : { + "comment" : "iPad control layout: controls float over the Mac display." + }, + "Pair" : { + + }, + "Pair over Remote" : { + + }, + "Pair with %@?" : { + + }, + "Pair with a Mac first — Remote Access uses the same secure pairing as local connections." : { + + }, + "Pair with a Mac first, then set up Remote Access for it here." : { + + }, + "Paired · Connected" : { + + }, + "Paired Mac" : { + + }, + "Paired Macs" : { + + }, + "Paired Successfully" : { + + }, + "Paired successfully. You can now connect to this Mac." : { + + }, + "Paired with %@" : { + + }, + "Pairing could not be verified" : { + + }, + "Pairing failed" : { + + }, + "Pairing timed out" : { + + }, + "Pairing was cancelled" : { + + }, + "Pairing was cancelled on the other device" : { + + }, + "Pairing was rejected" : { + + }, + "Palette Direction" : { + + }, + "Palette Distance" : { + + }, + "Palette Style" : { + + }, + "Pan, zoom and rotate your own view of the Mac — until an edge of your Mac reaches the middle of the screen. This never sends anything to the Mac, so it works even while Allow Input is off. Move View lets one finger move the picture without clicking or scrolling on the Mac; two-finger scrolling always goes to the Mac." : { + + }, + "Pan, zoom and rotate your own view of the Mac — until an edge of your Mac reaches the middle of the screen. This never sends anything to the Mac, so it works even while Allow Input is off. Two-finger scrolling always goes to the Mac." : { + + }, + "Paste a code someone shared with you. It starts with MDL1-." : { + + }, + "Paste from Clipboard" : { + + }, + "Performance" : { + + }, + "Performance overlay" : { + + }, + "Permissions" : { + + }, + "Picture in Picture" : { + + }, + "Picture in Picture isn’t supported on this device." : { + + }, + "Picture in Picture needs live video from a Mac, so it doesn't start from the demo." : { + + }, + "Pinch / Zoom" : { + + }, + "Pinch and Rotation" : { + + }, + "Place every control yourself, with separate landscape and portrait layouts." : { + + }, + "Place modifier controls" : { + + }, + "Place utility controls" : { + + }, + "Playing" : { + + }, + "Plays a copy of what the Mac is playing. It keeps playing there too — this never changes the Mac's output device." : { + + }, + "Plug in the USB cable and start the Mac app" : { + + }, + "Port" : { + + }, + "portrait" : { + + }, + "Portrait" : { + + }, + "Prefer FPS" : { + + }, + "Prefer Latency" : { + + }, + "Prefer MeowDisplay Gestures" : { + + }, + "Press Keys Now…" : { + + }, + "Press the combination on a hardware keyboard." : { + + }, + "Preview" : { + + }, + "Preview · %@" : { + + }, + "Preview Two-Hand Assist" : { + + }, + "Previous Space" : { + + }, + "Prioritize responsiveness, up to the receiver's supported refresh rate, capped at 120 FPS." : { + + }, + "Profile 1" : { + + }, + "Profile 2" : { + + }, + "Radial Template" : { + + }, + "Ready" : { + + }, + "Reconnect" : { + + }, + "Reconnecting…" : { + + }, + "Record from Keyboard" : { + + }, + "Reject for This Session" : { + + }, + "Reject Permanently" : { + + }, + "Remote Access" : { + + }, + "Remote Access options" : { + + }, + "Remote Access Settings…" : { + + }, + "Remote endpoint unavailable" : { + + }, + "Remove from Layout" : { + + }, + "Remove Remote Details" : { + + }, + "Replace this orientation with the radial template?" : { + + }, + "Request Control" : { + + }, + "Request Input" : { + + }, + "Request Input Automatically" : { + + }, + "Requesting Input…" : { + + }, + "Requesting…" : { + + }, + "Requests disabled by Mac" : { + + }, + "Reset" : { + + }, + "Reset %@?" : { + + }, + "Reset App Gesture Commands to their defaults?" : { }, - "No devices nearby" : { + "Reset Function Tray" : { }, - "No displays reported." : { + "Reset Function Tray to Default" : { }, - "No paired Macs" : { + "Reset Profile" : { }, - "No shortcuts for %@" : { + "Reset Profile to Default" : { }, - "Not allowed" : { + "Reset to Defaults" : { }, - "Not connected" : { + "Reset View" : { }, - "Notch Debug Overlay" : { + "Restart MeowDisplay and try again." : { }, - "OK" : { + "Restore This Palette" : { }, - "Off" : { + "Resume from MeowDisplay on your Mac." : { }, - "Off while Haptics is turned off." : { + "Resync" : { }, - "On" : { + "Returns to the home screen." : { }, - "On the Mac, open Devices and choose Open Pair over Remote, then enter the Mac's Tailscale address or MagicDNS name here." : { + "Right" : { }, - "One more app to go" : { - + "Ring" : { + "comment" : "Chord palette shape." }, - "Open iOS Settings for MeowDisplay" : { + "Ring — shortcuts surround the modifier." : { }, - "Opposite Side" : { + "Rotate Left" : { }, - "Option" : { - "comment" : "Keyboard modifier key." + "Rotate Right" : { + }, - "Options for %@" : { + "Rotate the %@ for a vertical second monitor." : { }, - "Or choose this %@ under WiFi in the Mac app" : { + "Rotation" : { }, - "PCM Engine" : { + "Route" : { }, - "PCM Engine is the default production audio path: AAC is still the only thing sent over the network, decoded on this %@ and played through AVAudioEngine. Legacy SampleBuffer Renderer is the older AVSampleBufferAudioRenderer path, kept as a fallback/reference. PCM Scheduling controls how PCM Engine schedules buffers: Continuous (default) chains them on the player's own timeline after one startup anchor; Precise Scheduled independently re-targets every buffer from its own capture timestamp — this reintroduces electrical/robotic noise and exists only for A/B comparison. Receiver Local AAC Decode independently decodes received AAC and logs decode anomalies (clipping, discontinuities, NaN/Inf). AAC Integrity Logging adds a periodic checksum you can compare against the Mac's own log for the same packet. Audio Comparison Dump writes ~5s of the locally-decoded audio to a file in this app's Documents folder (Files app → On My %@ → MeowDisplay) once Local AAC Decode is also on. All diagnostics off by default; a fresh Audio Off→On or reconnect applies a change." : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "new", - "value" : "PCM Engine is the default production audio path: AAC is still the only thing sent over the network, decoded on this %1$@ and played through AVAudioEngine. Legacy SampleBuffer Renderer is the older AVSampleBufferAudioRenderer path, kept as a fallback/reference. PCM Scheduling controls how PCM Engine schedules buffers: Continuous (default) chains them on the player's own timeline after one startup anchor; Precise Scheduled independently re-targets every buffer from its own capture timestamp — this reintroduces electrical/robotic noise and exists only for A/B comparison. Receiver Local AAC Decode independently decodes received AAC and logs decode anomalies (clipping, discontinuities, NaN/Inf). AAC Integrity Logging adds a periodic checksum you can compare against the Mac's own log for the same packet. Audio Comparison Dump writes ~5s of the locally-decoded audio to a file in this app's Documents folder (Files app → On My %2$@ → MeowDisplay) once Local AAC Decode is also on. All diagnostics off by default; a fresh Audio Off→On or reconnect applies a change." - } - } - } + "Row" : { + "comment" : "Chord palette shape." }, - "PCM Scheduling" : { + "Row — shortcuts appear in a horizontal line." : { }, - "Pair" : { + "Same Side" : { }, - "Pair over Remote" : { + "Sample" : { }, - "Pair with %@?" : { + "Sample %@" : { }, - "Pair with a Mac first — Remote Access uses the same secure pairing as local connections." : { + "Sample configuration. Nothing is saved and no connection is attempted." : { }, - "Paired Mac" : { + "Sample Mac display" : { }, - "Paired Macs" : { + "Sample session" : { }, - "Paired Successfully" : { + "Sample Session" : { }, - "Paired with %@" : { + "Sample session data. It isn't saved to the connection log, and no network activity took place." : { }, - "Pairing could not be verified" : { + "Sample session with %@. No Mac is connected." : { }, - "Pairing failed" : { + "Sample values for the demo. No network connection is open." : { }, - "Pairing timed out" : { + "Save" : { }, - "Pairing was cancelled" : { + "Saved" : { }, - "Pairing was cancelled on the other device" : { + "Screen Recording: lets the Mac app send its screen to this %@." : { }, - "Pairing was rejected" : { + "Scroll with one finger in any direction. Hold a title bar, then drag, to move its window. Elsewhere, touch and hold for normal Direct Touch. Falls back to Direct Touch when the Mac can't identify the area." : { }, - "Performance" : { + "Scrolling" : { }, - "Performance overlay" : { + "Search Settings" : { }, - "Permissions" : { + "See how display, input, controls, audio and connections work using a sample Mac. The demo uses sample content and doesn’t connect to another device." : { }, - "Pinch / Zoom" : { + "Select a category" : { }, - "Playback Path" : { + "Select a modifier to choose how its shortcuts appear. By default they bloom on the arc just outside the modifiers." : { }, - "Plays a copy of what the Mac is playing. It keeps playing there too — this never changes the Mac's output device." : { + "Select…" : { }, - "Plug in the USB cable and start the Mac app" : { + "Selected display unavailable" : { }, - "Port" : { + "Sends" : { }, - "Precise Scheduled" : { + "Sensitivity only affects Trackpad mode's one-finger pointer movement." : { }, - "Prefer FPS" : { + "Sequence" : { }, - "Prefer Latency" : { + "Set Up Remote Access" : { }, - "Preview" : { - + "settings" : { + "comment" : "Strip control hint." }, - "Prioritize responsiveness, up to the receiver's supported refresh rate, capped at 120 FPS." : { + "Settings" : { }, - "Production Codec" : { + "Settings & Help" : { }, - "Profile 1" : { + "Settings always stays in the layout, so it can never become unreachable." : { }, - "Profile 2" : { + "Settings always stays reachable, even with input turned off." : { }, - "Promote Mac to Interactive Wake" : { + "Share Code" : { }, - "Re-locks the About/version row's nine-tap easter egg for retesting the unlock flow." : { + "Share Layout" : { }, - "Receiver Controls" : { - + "shift" : { + "comment" : "Strip control hint under the ⇧ key." }, - "Receiver Local AAC Decode" : { - + "Shift" : { + "comment" : "Keyboard modifier key." }, - "Reconnect" : { + "Shortcut Buttons" : { }, - "Reconnecting…" : { + "Shortcut or Sequence…" : { }, - "Reject Permanently" : { + "Shortcut Palette" : { }, - "Reject for This Session" : { + "Shortcut Palettes" : { }, - "Remote Access" : { + "Show" : { }, - "Remote Access Settings…" : { + "Show a Built-in Action" : { }, - "Remote Access options" : { + "Show Control Hints" : { }, - "Remote endpoint unavailable" : { + "Show Control Tray" : { }, - "Remove Remote Details" : { + "Show Desktop" : { }, - "Request Control" : { + "Show Function Tray" : { }, - "Requesting…" : { + "Show Keyboard Button" : { }, - "Requests disabled by Mac" : { + "Show Surface Grid" : { }, - "Reset" : { + "Showing in Picture in Picture" : { }, - "Reset %@?" : { + "Shown for reference in the demo. These are granted on the Mac when you set up the Mac app. The demo hasn't checked or changed any permission." : { }, - "Reset App Gesture Commands to their defaults?" : { + "Shown in the Mac app's WiFi connection menu. iOS hides this %@'s real name from apps, so set it here once." : { }, - "Reset Audio Diagnostics" : { + "Shown on the Strip and Overlay rail, grouped as system actions, then keys, then view controls. Settings always stays on the rail. Move View also needs Allow Local View Navigation." : { }, - "Reset Cat Mode" : { + "Shows FPS, bitrate, frame timing and latency graphs over the display. In the demo the numbers are samples." : { }, - "Reset Function Tray" : { + "Size" : { }, - "Reset Function Tray to Default" : { + "Slow" : { }, - "Reset Profile" : { - + "Smart Touch Haptics" : { + "comment" : "Toggle label. \"Smart Touch\" is the feature's brand name and must stay untranslated; only \"Haptics\" is translatable." }, - "Reset Profile to Default" : { + "Snap Rotation" : { }, - "Reset to Defaults" : { + "Snap to Edges" : { }, - "Restart MeowDisplay and try again." : { + "Snap to Guides" : { }, - "Restore This Palette" : { + "Some Mac apps use different shortcuts for zooming and rotating. Customize the commands MeowDisplay sends when App mode is selected." : { }, - "Resume from MeowDisplay on your Mac." : { + "Spotlight" : { }, - "Resync" : { + "Standard Controls" : { }, - "Right" : { + "Start Picture in Picture" : { }, - "Rotate Left" : { + "Starting video…" : { }, - "Rotate Right" : { + "Status" : { }, - "Rotate the %@ for a vertical second monitor." : { + "Step" : { }, - "Rotation" : { + "Steps" : { }, - "Same Side" : { + "Steps run in order, each after the pause before it. Up to %lld steps." : { }, - "Save" : { + "Stop Picture in Picture" : { }, - "Saved" : { + "Stream" : { }, - "Scroll with one finger in any direction. Hold a title bar, then drag, to move its window. Elsewhere, touch and hold for normal Direct Touch. Falls back to Direct Touch when the Mac can't identify the area." : { + "Streaming" : { }, - "Selected display unavailable" : { + "Streaming Priority" : { }, - "Select…" : { + "Streaming Profile" : { }, - "Send LAN Wake Packet" : { - + "Strip" : { + "comment" : "iPad control layout: an edge rail beside the Mac display." }, - "Sends a standard Wake-on-LAN magic packet to an already-paired Mac's last-learned local network address. Same-LAN only — this never uses Remote/Tailscale." : { + "Style" : { }, - "Set Up Remote Access" : { + "Support MeowDisplay" : { }, - "Settings" : { + "Swipe left to remove (built-in actions are hidden instead), swipe right to show or hide. Tap an action to rename it or change how it looks." : { }, - "Settings & Help" : { + "Switching to %@" : { }, - "Settings always stays reachable, even with input turned off. Sensitivity only affects Trackpad mode's one-finger pointer movement." : { + "Symbol" : { }, - "Shift" : { - "comment" : "Keyboard modifier key." - }, - "Shortcut" : { + "System" : { }, - "Shortcut Palettes" : { + "System Action" : { }, - "Show Control Tray" : { + "System Areas" : { }, - "Show Function Tray" : { + "System Audio" : { }, - "Show Keyboard Button" : { - + "tab" : { + "comment" : "Strip control hint." }, - "Show Surface Grid" : { + "Tab" : { }, - "Shown in the Mac app's WiFi connection menu. iOS hides this %@'s real name from apps, so set it here once." : { + "Tailscale IP or MagicDNS name" : { }, - "Slow" : { + "Tap a control on the canvas to edit it, or drag it to move it." : { }, - "Smart Touch Haptics" : { - "comment" : "Toggle label. \"Smart Touch\" is the feature's brand name and must stay untranslated; only \"Haptics\" is translatable." - }, - "Snap Rotation" : { + "Template" : { }, - "Some Mac apps use different shortcuts for zooming and rotating. Customize the commands MeowDisplay sends when App mode is selected." : { + "Text" : { }, - "Starting video…" : { + "That doesn't look like a valid host or IP address." : { }, - "Status" : { + "The canvas shows this modifier’s palette while it’s selected. Its actions come from your Control Profile, so the same palette appears everywhere you use this modifier." : { }, - "Stream" : { + "The editor opens full screen, with separate landscape and portrait arrangements. Preview lets you try the layout without sending anything to your Mac." : { }, - "Streaming" : { + "The log could not be read." : { }, - "Streaming Priority" : { + "The Mac ended this session." : { }, - "Streaming Profile" : { + "The overlay shows FPS, bitrate, frame timing, stalls, and latency graphs at the bottom of the screen while streaming. The experimental Metal renderer decodes and presents frames manually — it adds decode and true on-glass latency metrics to the overlay, but in our measurements the system video layer displays frames faster. Leave it off unless you're debugging." : { }, - "Switching to %@" : { + "The shaded strips are the system’s areas (status bar and Home indicator). Controls always stay inside the dashed outline." : { }, - "Tab" : { + "Then wait %lld ms" : { }, - "Tailscale IP or MagicDNS name" : { + "This %@" : { }, - "That doesn't look like a valid host or IP address." : { + "This device must be updated before pairing" : { }, - "The Mac ended this session." : { + "This isn’t a MeowDisplay layout share code." : { }, - "The log could not be read." : { + "This layout decides where Function controls go; the Function Tray profile decides what they do. Add them with Add Control → Actions." : { }, - "The overlay shows FPS, bitrate, frame timing, stalls, and latency graphs at the bottom of the screen while streaming. The experimental Metal renderer decodes and presents frames manually — it adds decode and true on-glass latency metrics to the overlay, but in our measurements the system video layer displays frames faster. Leave it off unless you're debugging." : { + "This layout was shared from a newer MeowDisplay. Update to import it." : { }, "This Mac and this device can't work together yet." : { @@ -895,7 +1627,7 @@ "This Mac isn't accepting control requests from this device right now. Enable it from the Mac's Input settings." : { }, - "This device must be updated before pairing" : { + "This Mac isn’t accepting input requests" : { }, "This restores its default Undo/Redo layout. Other receiver settings stay unchanged." : { @@ -903,9 +1635,21 @@ }, "This restores its tray layout and shortcut palettes. Other receiver settings stay unchanged." : { + }, + "This sample workspace is drawn on this device. With the Mac app, your Mac’s real screen appears here." : { + + }, + "This share code is damaged or incomplete. Copy it again." : { + + }, + "This share code is too large." : { + }, "This version of MeowDisplay is no longer supported. Update from the App Store to keep using your second display." : { + }, + "Throughput" : { + }, "Tip: shake the %@ to open settings anytime" : { @@ -915,6 +1659,9 @@ }, "Too many pairing attempts. Try again in 30 seconds." : { + }, + "Top" : { + }, "Top Left" : { @@ -927,6 +1674,9 @@ }, "Touch: tap to click, drag to drag, two-finger pan to scroll." : { + }, + "Toward Screen Center" : { + }, "Trackpad" : { "comment" : "Input mode: relative pointer control like a laptop trackpad, not the physical accessory." @@ -942,30 +1692,51 @@ }, "Try Again" : { + }, + "Try the layout exactly as it will look. Preview never sends anything to your Mac." : { + }, "Trying to restore the connection to your Mac." : { }, "Turn Off" : { + }, + "Turn Off Input (Demo)" : { + }, "Turning video off keeps the connection, keyboard, controls, and selected input mode active." : { }, - "USB: plug in the cable, run the Mac app — it connects automatically through the wire (lowest latency)." : { + "Two-Hand Assist" : { + + }, + "Type" : { + + }, + "Typed “%@”" : { }, "Unavailable" : { }, - "Update" : { + "Unavailable while the experimental Metal renderer is on." : { }, - "Update MeowDisplay on both devices to pair" : { + "Up to %lld characters" : { + + }, + "Up to %lld layouts, each with its own landscape and portrait arrangement. New layouts start from the radial corner template. Touch and hold a layout to edit, preview or share it." : { + + }, + "Update" : { }, "Update available" : { + }, + "Update MeowDisplay on both devices to pair" : { + }, "Update on the App Store" : { @@ -978,6 +1749,12 @@ }, "Updating Maximum FPS…" : { + }, + "USB: plug in the cable, run the Mac app — it connects automatically through the wire (lowest latency)." : { + + }, + "Use a reachable private-network address for this paired Mac, such as its Tailscale address or MagicDNS name. This lets you connect when the Mac isn't on the same network — it's only used to reach the device, it does not grant trust, and every connection still requires the existing secure pairing." : { + }, "Use Extend" : { @@ -985,7 +1762,16 @@ "Use Full Display" : { }, - "Use a reachable private-network address for this paired Mac, such as its Tailscale address or MagicDNS name. This lets you connect when the Mac isn't on the same network — it's only used to reach the device, it does not grant trust, and every connection still requires the existing secure pairing." : { + "Use Remote Access" : { + + }, + "Use Template" : { + + }, + "Use This Layout" : { + + }, + "Use Two-Hand Assist" : { }, "Uses H.264 unless HEVC is needed and available to satisfy the requested stream." : { @@ -993,12 +1779,18 @@ }, "Uses HEVC when both this Mac and the connected device support it, otherwise falls back to H.264." : { + }, + "Validate" : { + }, "Version" : { }, "Video" : { + }, + "Video is off (Demo)" : { + }, "Video Off temporarily routes pinch and rotate to App without changing these saved choices. App mode is experimental: instead of injecting native gestures into the foreground app, it sends the keyboard commands configured in App Gesture Commands." : { @@ -1009,10 +1801,10 @@ "Waiting for %@ to accept…" : { }, - "Waiting for Mac" : { + "Waiting for a Mac…" : { }, - "Waiting for a Mac…" : { + "Waiting for Mac" : { }, "Wake & Connect" : { @@ -1029,6 +1821,9 @@ }, "Welcome" : { + }, + "Welcome to the MeowDisplay demo" : { + }, "What this %@ saw while connecting: sessions, restarts, decoder trouble. No screen content and nothing leaves the %@ unless you share it. Attach it to a GitHub issue if a connection won't come up." : { "localizations" : { @@ -1039,15 +1834,42 @@ } } } + }, + "Wheel" : { + "comment" : "Chord palette shape." + }, + "Wheel — a ring of wedge-shaped segments around the modifier." : { + }, "When a paired Mac starts sharing with this device, connect without asking. Blocked Macs are never connected." : { + }, + "Which controls appear, and the shortcuts each modifier chord offers." : { + + }, + "Which way the shortcuts open. Toward Screen Center keeps them clear of the edges." : { + + }, + "While a modifier is held or latched, modifier keys appear on the opposite side, so your other hand can add to the same chord. Otherwise that side offers actions like Undo, Redo and Zoom." : { + + }, + "While you arrange controls, Two-Hand Assist stays still. Turn on Preview Two-Hand Assist to see where its helper keys appear, or use Preview to try it for real." : { + + }, + "While you’re controlling the Mac, MeowDisplay gets priority for the gestures it supports: the first swipe from a screen edge goes to the Mac, and three-finger swipes aren’t taken by editing shortcuts. Some %@ and accessibility gestures always stay with the system, and VoiceOver always takes precedence." : { + }, "WiFi mode needs Local Network access. If your Mac can't find this %@, enable it under Settings → Privacy & Security → Local Network → MeowDisplay. USB mode works without it." : { + }, + "WiFi mode needs Local Network access. If your Mac can't find this %@, enable it under Settings → Privacy & Security → Local Network → MeowDisplay. USB mode works without it. Face ID or your passcode confirms each new pairing." : { + }, "WiFi: both devices on the same network, then pick this %@ in the Mac app's Connection menu." : { + }, + "You already have %lld Custom layouts. Delete one first, then import this one." : { + }, "You can find this link again anytime in Settings — shake the %@ to open it." : { @@ -1057,9 +1879,18 @@ }, "You'll need to pair with this Mac again before connecting." : { + }, + "Your layout is saved. Choose it under Settings → Controls → Control Layout → Custom." : { + }, "Your Mac" : { + }, + "Your Mac uses the whole screen and the controls float over it." : { + + }, + "Your other hand gets modifier keys on the opposite side while a chord is in progress." : { + }, "Zoom" : { diff --git a/iOS/OpenSidecarPhoneApp.swift b/iOS/OpenSidecarPhoneApp.swift index d6b78e57..e6b0aec8 100644 --- a/iOS/OpenSidecarPhoneApp.swift +++ b/iOS/OpenSidecarPhoneApp.swift @@ -961,9 +961,9 @@ struct IdleView: View { logo(width: 132) titleAndStatus autoReconnectToggle + ExploreDemoCard(onExplore: onExploreDemo) instructionsSection deviceSections - ExploreDemoCard(onExplore: onExploreDemo) Spacer() settingsButton tip.padding(.bottom, 8) diff --git a/iOSUITests/DemoModeSmokeTests.swift b/iOSUITests/DemoModeSmokeTests.swift index 3e3ec03a..7091bf18 100644 --- a/iOSUITests/DemoModeSmokeTests.swift +++ b/iOSUITests/DemoModeSmokeTests.swift @@ -25,11 +25,12 @@ final class DemoModeSmokeTests: XCTestCase { if app.buttons["Close"].waitForExistence(timeout: 3) { app.buttons["Close"].tap() } // Let the onboarding sheet finish closing before the next tap. waitForNonExistence(app.buttons["Close"]) + sleep(1) let explore = app.buttons["Explore Demo"] XCTAssertTrue(explore.waitForExistence(timeout: 5)) snapshot(app, "01-home") - explore.tap() + tapWhenSettled(explore, in: app) let exit = app.buttons["Exit Demo"] XCTAssertTrue(exit.waitForExistence(timeout: 5)) @@ -67,6 +68,19 @@ final class DemoModeSmokeTests: XCTestCase { XCTAssertFalse(exit.exists) } + /// Explore Demo can sit below the fold of the home column in iPhone + /// landscape. Scroll it in slowly and let the scroll settle first: a tap + /// during scroll deceleration only stops the scroll. + @MainActor + private func tapWhenSettled(_ button: XCUIElement, in app: XCUIApplication) { + let column = app.scrollViews.containing(.button, identifier: "exploreDemo").firstMatch + for _ in 0..<4 where !button.isHittable { + (column.exists ? column : app).swipeUp(velocity: .slow) + } + sleep(1) + button.tap() + } + private func waitForNonExistence(_ element: XCUIElement) { let gone = expectation(for: NSPredicate(format: "exists == false"), evaluatedWith: element) wait(for: [gone], timeout: 5) diff --git a/iOSUITests/DemoModeVisualTourTests.swift b/iOSUITests/DemoModeVisualTourTests.swift new file mode 100644 index 00000000..09b1d02f --- /dev/null +++ b/iOSUITests/DemoModeVisualTourTests.swift @@ -0,0 +1,207 @@ +import XCTest + +/// Walks the Demo Mode features a reviewer would try and keeps a screenshot +/// of each: Extend and Mirror, Automatic 16:10 and Use Full Display, +/// Trackpad, Video Off and the key Settings pages. +final class DemoModeVisualTourTests: XCTestCase { + private var app: XCUIApplication! + + override func setUp() { + continueAfterFailure = false + } + + @MainActor + func testVisualTourPortrait() { + XCUIDevice.shared.orientation = .portrait + tour(prefix: "portrait") + } + + @MainActor + func testVisualTourLandscape() { + XCUIDevice.shared.orientation = .landscapeLeft + defer { XCUIDevice.shared.orientation = .portrait } + tour(prefix: "landscape") + } + + @MainActor + private func tour(prefix: String) { + app = XCUIApplication() + app.launch() + sleep(2) // let a launch-time rotation settle before any screenshot + if app.buttons["Close"].waitForExistence(timeout: 3) { app.buttons["Close"].tap() } + waitForNonExistence(app.buttons["Close"]) + sleep(1) + let explore = app.buttons["Explore Demo"] + XCTAssertTrue(explore.waitForExistence(timeout: 5)) + snapshot("\(prefix)-00-home") + tapWhenSettled(explore, in: app) + XCTAssertTrue(app.buttons["Exit Demo"].waitForExistence(timeout: 5)) + sleep(1) + snapshot("\(prefix)-01-extend-automatic-16x10") + + // Direct Touch: tap the middle of the desktop, then drag. + let surface = app.descendants(matching: .any)["Sample Mac desktop"].firstMatch + XCTAssertTrue(surface.exists) + surface.coordinate(withNormalizedOffset: CGVector(dx: 0.3, dy: 0.3)).tap() + snapshot("\(prefix)-02-direct-tap") + + // Use Full Display. + openSettings("Display") + let fullDisplay = app.switches["Use Full Display"].firstMatch + reveal(fullDisplay) + XCTAssertTrue(fullDisplay.waitForExistence(timeout: 3)) + snapshot("\(prefix)-03-settings-display-extend") + tapSwitch(fullDisplay) + closeSettings() + sleep(1) + snapshot("\(prefix)-04-extend-full-display") + + // Mirror. + openSettings("Display") + choose("Display Mode", option: "Mirror") + snapshot("\(prefix)-05-settings-display-mirror") + closeSettings() + sleep(1) + snapshot("\(prefix)-06-mirror") + + // Trackpad: move the pointer with one finger. + openSettings("Input") + app.buttons["Trackpad"].firstMatch.tap() + snapshot("\(prefix)-07-settings-input") + closeSettings() + let start = surface.coordinate(withNormalizedOffset: CGVector(dx: 0.3, dy: 0.5)) + start.press(forDuration: 0.05, thenDragTo: surface.coordinate(withNormalizedOffset: CGVector(dx: 0.7, dy: 0.4))) + snapshot("\(prefix)-08-trackpad-moved") + + // Controls page, then Video Off. + openSettings("Controls") + snapshot("\(prefix)-09-settings-controls") + selectCategory("Display") + tapSwitch(app.switches["Video"].firstMatch) + closeSettings() + sleep(1) + snapshot("\(prefix)-10-video-off-trackpad-preview") + start.press(forDuration: 0.05, thenDragTo: surface.coordinate(withNormalizedOffset: CGVector(dx: 0.6, dy: 0.6))) + snapshot("\(prefix)-11-video-off-moved") + XCTAssertTrue(app.buttons["Turn Video On"].exists) + app.buttons["Turn Video On"].tap() + + app.buttons["Exit Demo"].tap() + XCTAssertTrue(explore.waitForExistence(timeout: 5)) + } + + // MARK: Helpers + + @MainActor + private func openSettings(_ category: String) { + let gear = app.descendants(matching: .any)["Settings"].firstMatch + if !gear.isHittable { + // Auto-hide faded the trays; any touch on the surface brings them back. + app.descendants(matching: .any)["Sample Mac desktop"].firstMatch + .coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.02)).tap() + sleep(1) + } + gear.tap() + XCTAssertTrue(app.navigationBars["Settings"].waitForExistence(timeout: 5)) + selectCategory(category) + } + + @MainActor + private func selectCategory(_ category: String) { + if app.navigationBars[category].exists { return } + if UIDevice.current.userInterfaceIdiom == .phone, !app.navigationBars["Settings"].exists { + app.navigationBars.firstMatch.buttons.firstMatch.tap() + XCTAssertTrue(app.navigationBars["Settings"].waitForExistence(timeout: 3)) + } + let row = app.buttons[category].firstMatch + reveal(row) + XCTAssertTrue(row.waitForExistence(timeout: 3), category) + row.tap() + XCTAssertTrue(app.navigationBars[category].waitForExistence(timeout: 3), category) + } + + @MainActor + private func closeSettings() { + if UIDevice.current.userInterfaceIdiom == .phone, !app.navigationBars["Settings"].exists { + app.navigationBars.firstMatch.buttons.firstMatch.tap() + XCTAssertTrue(app.navigationBars["Settings"].waitForExistence(timeout: 3)) + } + app.buttons["Done"].firstMatch.tap() + XCTAssertTrue(app.buttons["Exit Demo"].waitForExistence(timeout: 5)) + } + + /// Explore Demo can sit below the fold of the home column in iPhone + /// landscape. Scroll it in slowly and let the scroll settle first: a tap + /// during scroll deceleration only stops the scroll. + @MainActor + private func tapWhenSettled(_ button: XCUIElement, in app: XCUIApplication) { + let column = app.scrollViews.containing(.button, identifier: "exploreDemo").firstMatch + for _ in 0..<4 where !button.isHittable { + (column.exists ? column : app).swipeUp(velocity: .slow) + } + sleep(1) + button.tap() + } + + /// Scrolls a Settings form until a row is on screen (forms build rows + /// lazily). Short, slow drags that hold at the end, so there is no fling, + /// and only ever upward: a downward drag at the top of a sheet dismisses it. + /// A newly opened page starts at the top, so searching down is enough. + @MainActor + private func reveal(_ element: XCUIElement) { + // The demo footer covers the bottom of Settings; a row under it is + // not really tappable. + let usableBottom = app.frame.maxY - 60 + func visible() -> Bool { + element.exists && element.isHittable && element.frame.maxY < usableBottom + } + guard let form = app.collectionViews.allElementsBoundByIndex.last else { return } + for _ in 0..<10 where !visible() { + form.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.7)) + .press(forDuration: 0.1, + thenDragTo: form.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.4)), + withVelocity: .slow, + thenHoldForDuration: 0.3) + } + } + + @MainActor + private func choose(_ picker: String, option: String) { + let control = app.buttons.matching(NSPredicate(format: "label BEGINSWITH %@", picker)).firstMatch + reveal(control) + XCTAssertTrue(control.waitForExistence(timeout: 3), picker) + control.tap() + let item = app.buttons[option].firstMatch + XCTAssertTrue(item.waitForExistence(timeout: 3), option) + item.tap() + } + + @MainActor + private func tapSwitch(_ toggle: XCUIElement) { + reveal(toggle) + XCTAssertTrue(toggle.waitForExistence(timeout: 3)) + // Tap the switch control itself, not the label (and not by screen + // coordinates, which are unreliable in landscape). + let control = toggle.switches.firstMatch + let before = toggle.value as? String + (control.exists ? control : toggle).tap() + XCTAssertNotEqual(toggle.value as? String, before, "Toggle did not change") + } + + private func waitForNonExistence(_ element: XCUIElement) { + let gone = expectation(for: NSPredicate(format: "exists == false"), evaluatedWith: element) + wait(for: [gone], timeout: 5) + } + + @MainActor + private func snapshot(_ name: String) { + let attachment = XCTAttachment(screenshot: app.screenshot()) + attachment.name = name + attachment.lifetime = .keepAlways + add(attachment) + // Landscape XCUITest captures come out rotated; an external + // `simctl io screenshot` keyed on this marker gets the real frame. + print("TOURSHOT \(name)") + sleep(2) + } +} diff --git a/project.yml b/project.yml index e5133f78..65df8708 100644 --- a/project.yml +++ b/project.yml @@ -266,6 +266,7 @@ targets: - Shared/MobileSettingsCategory.swift # iOS Demo Mode boundary (Foundation only, no UIKit). - iOS/DemoSession.swift + - iOS/DemoDesktop.swift - Shared/MobileSettingsSearch.swift - Shared/CustomLayoutSharing.swift - Shared/ReceiverInputPrompt.swift From 7bda99fd5fa85530c54d98b1d2cd0789b2d9c0e5 Mon Sep 17 00:00:00 2001 From: raiseCatError <315733358+raiseCatError@users.noreply.github.com> Date: Sun, 11 Oct 2026 21:48:51 +0530 Subject: [PATCH 21/30] fix(ios): keep demo accessible across home layouts Explore Demo sat below the fold on iPhone in landscape. The home screen now places it by whether a Mac is paired, in every layout. - No paired Mac: a compact Explore Demo card ahead of the connection instructions in portrait, in the left landscape column and on iPad. The left landscape column scrolls only when its content can't fit. - Paired Mac: a collapsed Demo Mode section, in the Connection Instructions style, after the connection sections. - Pairing state comes from the trust store's in-memory pin snapshot, re-read after pairing, Forget and Settings. Forgetting the last paired Mac brings the card back. No Keychain or network I/O. - The demo can't start while a real session's surface is up, so it never waits behind one. - The card uses the plain card color on iPhone, so it no longer disappears into the white page in light mode. - Tests for the presentation decision and the demo entry rule. --- MacTests/DemoSessionTests.swift | 28 ++++++++ iOS/DemoMode.swift | 123 +++++++++++++++++++++++++++----- iOS/DemoSession.swift | 24 +++++++ iOS/OpenSidecarPhoneApp.swift | 98 +++++++++++++++++-------- 4 files changed, 229 insertions(+), 44 deletions(-) diff --git a/MacTests/DemoSessionTests.swift b/MacTests/DemoSessionTests.swift index b79b3015..ba0dae78 100644 --- a/MacTests/DemoSessionTests.swift +++ b/MacTests/DemoSessionTests.swift @@ -159,6 +159,34 @@ final class DemoSessionTests: XCTestCase { XCTAssertEqual(ReceiverPresentationRoute.resolve(demoActive: false, realSurfaceShown: true), .receiver) } + func testDemoNeverStartsBehindARealSession() { + XCTAssertTrue(ReceiverPresentationRoute.canEnterDemo(realSurfaceShown: false)) + XCTAssertFalse(ReceiverPresentationRoute.canEnterDemo(realSurfaceShown: true)) + } + + // MARK: Home screen entry + + func testExploreDemoIsProminentWithoutAPairedMac() { + let presentation = ExploreDemoPresentation.resolve(pairedMacCount: 0) + XCTAssertEqual(presentation, .prominent) + XCTAssertTrue(presentation.leadsConnectionSetup, "ahead of the instructions, so it is visible without scrolling") + } + + func testExploreDemoIsCompactOnceAMacIsPaired() { + for count in [1, 2, 5] { + let presentation = ExploreDemoPresentation.resolve(pairedMacCount: count) + XCTAssertEqual(presentation, .compact, "\(count) paired") + XCTAssertFalse(presentation.leadsConnectionSetup, "after the connection sections") + } + } + + func testForgettingTheLastPairedMacBringsBackTheProminentEntry() { + // Two paired Macs, forgotten one at a time. + XCTAssertEqual(ExploreDemoPresentation.resolve(pairedMacCount: 2), .compact) + XCTAssertEqual(ExploreDemoPresentation.resolve(pairedMacCount: 1), .compact, "one Mac is still paired") + XCTAssertEqual(ExploreDemoPresentation.resolve(pairedMacCount: 0), .prominent) + } + func testEveryReleaseSettingsCategoryHasDemoPresentation() { let release = MobileSettingsCategory.visible(debug: false) XCTAssertFalse(release.contains(.developer)) diff --git a/iOS/DemoMode.swift b/iOS/DemoMode.swift index 1a2b3fb1..3e07fa92 100644 --- a/iOS/DemoMode.swift +++ b/iOS/DemoMode.swift @@ -37,28 +37,119 @@ func demoVideoSize() -> CGSize { // MARK: - Home entry point +/// The home screen's entry while no Mac is paired: one row-shaped button, +/// compact enough to sit ahead of the connection instructions in iPhone +/// landscape without pushing anything off screen. struct ExploreDemoCard: View { let onExplore: () -> Void + @Environment(\.horizontalSizeClass) private var horizontalSizeClass var body: some View { - VStack(alignment: .leading, spacing: 8) { - Text("Explore MeowDisplay").font(.headline) - Text("See how display, input, controls, audio and connections work using a sample Mac. The demo uses sample content and doesn’t connect to another device.") - .font(.subheadline) - .foregroundStyle(.secondary) - .fixedSize(horizontal: false, vertical: true) - Button(action: onExplore) { - Label("Explore Demo", systemImage: "play.rectangle") - .frame(maxWidth: .infinity) + Button(action: onExplore) { + HStack(spacing: 12) { + Image(systemName: "play.rectangle.fill") + .font(.title3) + .foregroundStyle(.white) + .frame(width: 36, height: 36) + .background(Color.accentColor, in: RoundedRectangle(cornerRadius: 9, style: .continuous)) + VStack(alignment: .leading, spacing: 2) { + Text("Explore Demo") + .font(.headline) + .foregroundStyle(.primary) + Text("Try MeowDisplay with an interactive sample Mac. No connection required.") + .font(.footnote) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + .frame(maxWidth: .infinity, alignment: .leading) + Image(systemName: "chevron.forward") + .font(.footnote.weight(.semibold)) + .foregroundStyle(.tertiary) } - .buttonStyle(.borderedProminent) - .controlSize(.large) - .accessibilityIdentifier("exploreDemo") - .accessibilityHint(Text("Opens a sample session. Nothing connects to another device.")) + .padding(.horizontal, 14) + .padding(.vertical, 12) + .frame(maxWidth: .infinity, minHeight: 44, alignment: .leading) + // iPad's grouped page needs the grouped card color to stand apart; + // the phone page is plain, like the instructions card. + .background(horizontalSizeClass == .regular ? Color(.secondarySystemGroupedBackground) + : Color(.secondarySystemBackground), + in: RoundedRectangle(cornerRadius: 16, style: .continuous)) + .contentShape(RoundedRectangle(cornerRadius: 16, style: .continuous)) + } + .buttonStyle(.plain) + // As wide as the status card on iPad; the phone column's width elsewhere. + .frame(maxWidth: horizontalSizeClass == .regular ? .infinity : 420) + .accessibilityElement(children: .ignore) + .accessibilityLabel(Text("Explore Demo")) + .accessibilityHint(Text("Opens a sample session. Nothing connects to another device.")) + .accessibilityIdentifier("exploreDemo") + } +} + +/// The home screen's entry once a Mac is paired: a collapsed section in the +/// Connection Instructions style, with the one Explore Demo action inside. +struct ExploreDemoSection: View { + let onExplore: () -> Void + /// Persisted like the instructions section; starts collapsed. + @AppStorage("home.demoSectionExpanded") private var expanded = false + @Environment(\.horizontalSizeClass) private var horizontalSizeClass + + var body: some View { + VStack(spacing: 8) { + Button { + withAnimation(.easeInOut(duration: 0.25)) { expanded.toggle() } + } label: { + HStack { + Text("Demo Mode") + .font(.subheadline.weight(.semibold)) + Spacer() + Image(systemName: expanded ? "chevron.up" : "chevron.down") + .font(.footnote.weight(.semibold)) + } + .foregroundStyle(expanded ? HierarchicalShapeStyle.primary : .secondary) + .padding(.horizontal, 4) + .frame(minHeight: 36) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .accessibilityValue(expanded ? "Expanded" : "Collapsed") + .accessibilityHint("Double-tap to \(expanded ? "collapse" : "expand")") + + if expanded { + VStack(alignment: .leading, spacing: 12) { + Text("Try the controls with a sample Mac. No connection required.") + .font(.footnote) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + Button(action: onExplore) { + Label("Explore Demo", systemImage: "play.rectangle") + } + .buttonStyle(.bordered) + .accessibilityIdentifier("exploreDemo") + .accessibilityHint(Text("Opens a sample session. Nothing connects to another device.")) + } + .padding(16) + .frame(maxWidth: .infinity, alignment: .leading) + .background(horizontalSizeClass == .regular ? Color(.secondarySystemGroupedBackground) + : Color(.secondarySystemBackground), + in: RoundedRectangle(cornerRadius: 16)) + .transition(.opacity) + } + } + .frame(maxWidth: 420) + } +} + +/// The home screen's Demo Mode entry for the current presentation. +struct ExploreDemoEntry: View { + let presentation: ExploreDemoPresentation + let onExplore: () -> Void + + var body: some View { + switch presentation { + case .prominent: ExploreDemoCard(onExplore: onExplore) + case .compact: ExploreDemoSection(onExplore: onExplore) } - .padding(16) - .frame(maxWidth: 560, alignment: .leading) - .background(Color(.secondarySystemGroupedBackground), in: RoundedRectangle(cornerRadius: 16, style: .continuous)) } } diff --git a/iOS/DemoSession.swift b/iOS/DemoSession.swift index 433b51fd..60daffbe 100644 --- a/iOS/DemoSession.swift +++ b/iOS/DemoSession.swift @@ -35,6 +35,30 @@ enum ReceiverPresentationRoute: Equatable { if realSurfaceShown { return .receiver } return demoActive ? .demo : .idle } + + /// The demo only starts from the home screen. While a real session's + /// surface is up, entering would leave a hidden demo that appears once + /// the session ends. + static func canEnterDemo(realSurfaceShown: Bool) -> Bool { !realSurfaceShown } +} + +/// How the home screen offers Demo Mode. Decided by whether any Mac is +/// paired right now (trusted pins), not by whether one ever connected: +/// forgetting the last paired Mac brings the prominent entry back. +enum ExploreDemoPresentation: Equatable { + /// No paired Mac: a card ahead of the connection instructions, visible + /// without scrolling in every phone layout. + case prominent + /// At least one paired Mac: a collapsed Demo Mode section after the + /// connection sections, so it doesn't compete with them. + case compact + + static func resolve(pairedMacCount: Int) -> ExploreDemoPresentation { + pairedMacCount > 0 ? .compact : .prominent + } + + /// Whether the entry sits ahead of the connection instructions. + var leadsConnectionSetup: Bool { self == .prominent } } /// How a Settings category is presented in Demo Mode: the real page with diff --git a/iOS/OpenSidecarPhoneApp.swift b/iOS/OpenSidecarPhoneApp.swift index e6b0aec8..adc0fe9c 100644 --- a/iOS/OpenSidecarPhoneApp.swift +++ b/iOS/OpenSidecarPhoneApp.swift @@ -101,6 +101,8 @@ struct ReceiverScreen: View { private var fullScreenSurface: Bool { route != .idle } private func enterDemo() { + // A real session always wins (see `route`); never start a demo behind it. + guard demo == nil, ReceiverPresentationRoute.canEnterDemo(realSurfaceShown: showsReceiverSurface) else { return } demoControlStore = DemoControlStoreFactory.make() demo = DemoSession(videoSize: demoVideoSize(), deviceName: String(localized: "Sample \(deviceKind)")) Log.info("demo mode entered") @@ -900,6 +902,21 @@ struct IdleView: View { // while locked. @AppStorage(CatMode.enabledDefaultsKey) private var catModeEnabled = false @Environment(\.horizontalSizeClass) private var horizontalSizeClass + /// Paired Macs, read from the trust store's in-memory pin snapshot (no + /// Keychain or network I/O). Re-read whenever pairing can have changed. + @State private var pairedMacCount = TrustStore.shared.allPinnedPeerSPKIs().count + + private var demoPresentation: ExploreDemoPresentation { + .resolve(pairedMacCount: pairedMacCount) + } + + private var demoEntry: some View { + ExploreDemoEntry(presentation: demoPresentation, onExplore: onExploreDemo) + } + + private func refreshPairedMacCount() { + pairedMacCount = TrustStore.shared.allPinnedPeerSPKIs().count + } var body: some View { GeometryReader { proxy in @@ -943,6 +960,13 @@ struct IdleView: View { RemoteAccessSettingsView(receiver: receiver, initialPeerID: remoteEditPeerID) } } + // Pairing can change through a pairing ceremony, Forget (Settings or + // Remote Access) or a reset in Settings; re-read after each. + .onAppear(perform: refreshPairedMacCount) + .onChange(of: receiver.pairingSuccessCount) { _ in refreshPairedMacCount() } + .onChange(of: receiver.lastForgottenPeerID) { _ in refreshPairedMacCount() } + .onChange(of: remoteRefresh) { _ in refreshPairedMacCount() } + .onChange(of: showSettings) { shown in if !shown { refreshPairedMacCount() } } } /// Scrolls only when the content is taller than the screen; when it fits, @@ -961,9 +985,10 @@ struct IdleView: View { logo(width: 132) titleAndStatus autoReconnectToggle - ExploreDemoCard(onExplore: onExploreDemo) + if demoPresentation.leadsConnectionSetup { demoEntry } instructionsSection deviceSections + if !demoPresentation.leadsConnectionSetup { demoEntry } Spacer() settingsButton tip.padding(.bottom, 8) @@ -971,40 +996,27 @@ struct IdleView: View { .padding() } - /// Two fixed columns for landscape. Neither column scrolls as a whole; - /// only the device sections scroll if they outgrow the space. + /// Two columns for landscape. The left one scrolls only when its content + /// is taller than the screen (small phones, large text, instructions + /// expanded); when it fits, `minHeight` keeps it vertically centered. + /// Explore Demo comes before the instructions there, so it is on screen + /// without scrolling while no Mac is paired. The right column's device + /// sections scroll on their own. private var landscapeLayout: some View { HStack(alignment: .top, spacing: 24) { - // Left: brand + setup, vertically centered in the available height. - VStack(spacing: 12) { - Spacer(minLength: 0) - if instructionsExpanded { - HStack(spacing: 0) { - brandHeader(logoWidth: 68, titleFont: .title.bold(), titleStyle: .title1) - Spacer(minLength: 0) - } - } else { - // Freed space: larger, centered branding like portrait. - VStack(spacing: 8) { - logo(width: 104) - titleAndStatus(font: .title.bold()) - } - .frame(maxWidth: .infinity) + GeometryReader { column in + ScrollView(showsIndicators: false) { + landscapeSetupColumn + .frame(maxWidth: .infinity, minHeight: column.size.height) } - autoReconnectToggle - instructionsSection - Spacer(minLength: 0) + .scrollBounceBehavior(.basedOnSize) } - .frame(maxWidth: .infinity, maxHeight: .infinity) // Right: devices fill the top, Settings & tip sit at the bottom. VStack(spacing: 12) { ScrollView { - VStack(spacing: 12) { - deviceSections - ExploreDemoCard(onExplore: onExploreDemo) - } - .padding(.top, 12) + deviceSections + .padding(.top, 12) } .scrollBounceBehavior(.basedOnSize) settingsButton @@ -1017,6 +1029,33 @@ struct IdleView: View { .padding(.bottom, 12) } + /// Left landscape column: brand, Auto-Reconnect, Explore Demo while no + /// Mac is paired, then the instructions (and the compact Demo Mode + /// section once one is). + private var landscapeSetupColumn: some View { + VStack(spacing: 12) { + Spacer(minLength: 0) + if instructionsExpanded { + HStack(spacing: 0) { + brandHeader(logoWidth: 68, titleFont: .title.bold(), titleStyle: .title1) + Spacer(minLength: 0) + } + } else { + // Freed space: larger, centered branding like portrait. + VStack(spacing: 8) { + logo(width: 104) + titleAndStatus(font: .title.bold()) + } + .frame(maxWidth: .infinity) + } + autoReconnectToggle + if demoPresentation.leadsConnectionSetup { demoEntry } + instructionsSection + if !demoPresentation.leadsConnectionSetup { demoEntry } + Spacer(minLength: 0) + } + } + private func logo(width: CGFloat) -> some View { Image("MeowLogo") .resizable() @@ -1083,7 +1122,9 @@ struct IdleView: View { HStack(alignment: .top, spacing: 24) { VStack(spacing: 20) { statusCard + if demoPresentation.leadsConnectionSetup { demoEntry } instructionsSection + if !demoPresentation.leadsConnectionSetup { demoEntry } } .frame(maxWidth: .infinity) devicesCard @@ -1092,13 +1133,14 @@ struct IdleView: View { } else { VStack(spacing: 20) { statusCard + if demoPresentation.leadsConnectionSetup { demoEntry } devicesCard instructionsSection + if !demoPresentation.leadsConnectionSetup { demoEntry } } .frame(maxWidth: 560) } VStack(spacing: 10) { - ExploreDemoCard(onExplore: onExploreDemo) settingsButton tip } From dbb8a8d0f908509a413dec83e2f986764a207290 Mon Sep 17 00:00:00 2001 From: raiseCatError <315733358+raiseCatError@users.noreply.github.com> Date: Sun, 11 Oct 2026 22:12:54 +0530 Subject: [PATCH 22/30] fix: drop build-captured bundle names from InfoPlist catalogs a38c4ab committed CFBundleName and CFBundleDisplayName entries that Xcode extracted from whichever build last ran: "MeowDisplay Dev" from a Debug build in the Mac catalog, and the iOS target name in the iOS one. These would override the configured names if they were ever compiled into the app. Restore both catalogs to their earlier content, so the names come only from project.yml. --- Mac/InfoPlist.xcstrings | 26 +------------------------- iOS/InfoPlist.xcstrings | 26 +------------------------- 2 files changed, 2 insertions(+), 50 deletions(-) diff --git a/Mac/InfoPlist.xcstrings b/Mac/InfoPlist.xcstrings index 4922edbb..11ef0966 100644 --- a/Mac/InfoPlist.xcstrings +++ b/Mac/InfoPlist.xcstrings @@ -1,30 +1,6 @@ { "sourceLanguage" : "en", "strings" : { - "CFBundleDisplayName" : { - "comment" : "Bundle display name", - "extractionState" : "extracted_with_value", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "new", - "value" : "MeowDisplay Dev" - } - } - } - }, - "CFBundleName" : { - "comment" : "Bundle name", - "extractionState" : "extracted_with_value", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "new", - "value" : "MeowDisplay Dev" - } - } - } - }, "NSLocalNetworkUsageDescription" : { "comment" : "Privacy - Local Network Usage Description", "extractionState" : "extracted_with_value", @@ -39,4 +15,4 @@ } }, "version" : "1.0" -} \ No newline at end of file +} diff --git a/iOS/InfoPlist.xcstrings b/iOS/InfoPlist.xcstrings index 26690de4..826af062 100644 --- a/iOS/InfoPlist.xcstrings +++ b/iOS/InfoPlist.xcstrings @@ -1,30 +1,6 @@ { "sourceLanguage" : "en", "strings" : { - "CFBundleDisplayName" : { - "comment" : "Bundle display name", - "extractionState" : "extracted_with_value", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "new", - "value" : "MeowDisplay" - } - } - } - }, - "CFBundleName" : { - "comment" : "Bundle name", - "extractionState" : "extracted_with_value", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "new", - "value" : "OpenSidecariOS" - } - } - } - }, "NSFaceIDUsageDescription" : { "comment" : "Privacy - Face ID Usage Description", "extractionState" : "extracted_with_value", @@ -51,4 +27,4 @@ } }, "version" : "1.0" -} \ No newline at end of file +} From 1858ff7c12736043e89e94991faf21c1562ae275 Mon Sep 17 00:00:00 2001 From: raiseCatError <315733358+raiseCatError@users.noreply.github.com> Date: Sun, 11 Oct 2026 22:12:54 +0530 Subject: [PATCH 23/30] chore(ios): sync demo strings in the String Catalog Xcode's extraction of the current Demo Mode strings: adds the interactive desktop and home entry strings and drops the ones the earlier demo used. English source text only; no translations change. --- iOS/Localizable.xcstrings | 292 +++++++++++++++++++++++++++++++++----- 1 file changed, 259 insertions(+), 33 deletions(-) diff --git a/iOS/Localizable.xcstrings b/iOS/Localizable.xcstrings index a650cc1e..00d9c3f6 100644 --- a/iOS/Localizable.xcstrings +++ b/iOS/Localizable.xcstrings @@ -3,6 +3,9 @@ "strings" : { "" : { + }, + "· Sample session" : { + }, "%@ %@" : { "localizations" : { @@ -13,15 +16,28 @@ } } } - }, - "%@ · Demo, not sent" : { - }, "%@ (Main)" : { "comment" : "A display name, marked as the Mac's main display." }, "%@ (Sample)" : { "comment" : "A demo value, such as 60 fps (Sample)." + }, + "%@ built-in display" : { + + }, + "%@ command · %@" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "%1$@ command · %2$@" + } + } + } + }, + "%@ held. Choose a shortcut." : { + }, "%@ requests %lld FPS. Limited to %lld FPS at this display size." : { "comment" : "The first value is a streaming profile name, such as Performance.", @@ -51,6 +67,9 @@ }, "%lld ms" : { + }, + "|" : { + }, "⌘ ⌥ ⌃ ⇧ form the next arc out. Hold or tap a modifier to use it." : { @@ -66,9 +85,6 @@ }, "A newer version of MeowDisplay is available." : { - }, - "A second screen for your Mac" : { - }, "A second, independent set of one-tap shortcuts (Undo, Redo, …), separate from the Main controls." : { @@ -114,6 +130,9 @@ }, "Active Profile" : { + }, + "Actual size" : { + }, "Add Action" : { @@ -141,6 +160,9 @@ }, "Allow" : { + }, + "Allow Input" : { + }, "Allow Input (Demo)" : { @@ -204,6 +226,9 @@ }, "Automatic" : { + }, + "Automatic uses 16:10. Use Full Display matches this %@'s screen in its current orientation. The sample desktop changes shape to match." : { + }, "Automatically Allow Connections" : { @@ -265,11 +290,17 @@ "Choose Another Mac" : { }, - "Click" : { + "Click at pointer" : { }, "Close" : { + }, + "Close Window" : { + + }, + "Closed %@" : { + }, "Codec" : { @@ -382,10 +413,10 @@ "Controls" : { }, - "Controls and input keep working." : { + "Copied" : { }, - "Copied" : { + "Copy" : { }, "Copy Share Code" : { @@ -408,6 +439,9 @@ }, "Custom Layouts" : { + }, + "Cut" : { + }, "Default" : { @@ -427,10 +461,10 @@ "Delete this layout?" : { }, - "DEMO" : { + "Deleted" : { }, - "Demo content drawn on this device. Taps show locally and are not sent." : { + "DEMO" : { }, "Demo Diagnostics" : { @@ -438,6 +472,9 @@ }, "Demo Mode" : { + }, + "Demo Options" : { + }, "Demo: %@ is a sample and isn't saved as a paired Mac. Forget is unavailable for it. Your real paired Macs aren't shown in the demo. Pairing with a real Mac is confirmed on both devices." : { @@ -471,6 +508,15 @@ }, "Direct Touch" : { "comment" : "Input mode: touch where you want the pointer to go." + }, + "Direct Touch maps your finger to the Mac's screen" : { + + }, + "Direct Touch. Touch the desktop directly." : { + + }, + "Direct Touch. Touch the desktop where you want to click." : { + }, "Disabled" : { @@ -492,6 +538,9 @@ }, "Display Paused" : { + }, + "Displays" : { + }, "dock" : { "comment" : "Strip control hint." @@ -507,6 +556,9 @@ }, "Dots or grid lines for lining controls up while you edit. They aren’t part of your Mac’s screen and never appear during normal use." : { + }, + "Double-click to zoom" : { + }, "Double-tap to %@" : { @@ -594,18 +646,30 @@ }, "Explore Demo" : { - }, - "Explore MeowDisplay" : { - }, "Extend" : { "comment" : "Display mode: add the device as a second, separate screen (not the verb \"to extend\")." }, - "Extend (Demo)" : { + "Extend Automatic: 16:10." : { + + }, + "Extend fills this display." : { }, "Extend Shape" : { + }, + "Extend shape %@." : { + + }, + "Extend. This device adds a second desktop beside the Mac." : { + + }, + "Extended" : { + + }, + "Extended display" : { + }, "Far" : { @@ -618,6 +682,18 @@ }, "Favors the newest frame and bounded latency, accepting lower frame rate under pressure." : { + }, + "File" : { + + }, + "Files" : { + + }, + "Find" : { + + }, + "Focused %@" : { + }, "Forget" : { @@ -721,20 +797,23 @@ "Importing adds a new layout. Its shortcut buttons do nothing until you tap them." : { }, - "In Extend, this device adds space beside your Mac’s own display. Move windows here to use both." : { + "Input" : { }, - "Input" : { + "Input allowed (Demo)." : { }, "Input Enabled" : { }, - "Input is off (Demo). Allow Input" : { + "Input is off (Demo)." : { }, "Input Mode" : { + }, + "Input off (Demo). Controls are hidden; view navigation still works." : { + }, "Install the MeowDisplay Mac app on your Mac" : { @@ -831,6 +910,9 @@ }, "Left" : { + }, + "Local View Navigation is off" : { + }, "Lower power/bandwidth, up to 60 FPS." : { @@ -852,6 +934,9 @@ }, "Main Controls" : { + }, + "Main display" : { + }, "Main Tray" : { @@ -895,7 +980,7 @@ "Mirror" : { "comment" : "Display mode: duplicate the Mac's screen onto the device." }, - "Mirror (Demo)" : { + "Mirror and Extend" : { }, "Mirror Display" : { @@ -906,6 +991,12 @@ }, "Mirror requires an active physical display." : { + }, + "Mirror shows the Mac's own display at its own shape. Extend Shape applies only to Extend." : { + + }, + "Mirror. You see the Mac's own screen." : { + }, "Mission Control" : { @@ -915,6 +1006,9 @@ }, "Modifiers" : { + }, + "Move one finger" : { + }, "Move the pointer relative to your finger." : { @@ -936,6 +1030,9 @@ }, "New Button" : { + }, + "New line" : { + }, "Next Space" : { @@ -966,6 +1063,15 @@ }, "Notch Debug Overlay" : { + }, + "Notes" : { + + }, + "Nothing to redo" : { + + }, + "Nothing to undo" : { + }, "Off" : { @@ -996,6 +1102,9 @@ }, "Open iOS Settings for MeowDisplay" : { + }, + "Opened %@" : { + }, "Opens a sample session. Nothing connects to another device." : { @@ -1092,6 +1201,9 @@ }, "Pan, zoom and rotate your own view of the Mac — until an edge of your Mac reaches the middle of the screen. This never sends anything to the Mac, so it works even while Allow Input is off. Two-finger scrolling always goes to the Mac." : { + }, + "Paste" : { + }, "Paste a code someone shared with you. It starts with MDL1-." : { @@ -1122,6 +1234,12 @@ }, "Pinch and Rotation" : { + }, + "Pinch is off in Settings, Gestures" : { + + }, + "Pinch to zoom, drag with two fingers to pan, double-tap with two fingers to reset." : { + }, "Place every control yourself, with separate landscape and portrait layouts." : { @@ -1173,6 +1291,12 @@ }, "Preview Two-Hand Assist" : { + }, + "Preview zoomed in" : { + + }, + "Preview zoomed out" : { + }, "Previous Space" : { @@ -1185,6 +1309,9 @@ }, "Profile 2" : { + }, + "Quit" : { + }, "Radial Template" : { @@ -1200,6 +1327,9 @@ }, "Record from Keyboard" : { + }, + "Redo" : { + }, "Reject for This Session" : { @@ -1266,6 +1396,9 @@ }, "Reset Profile to Default" : { + }, + "Reset Sample Desktop" : { + }, "Reset to Defaults" : { @@ -1287,6 +1420,9 @@ }, "Returns to the home screen." : { + }, + "Review Mac" : { + }, "Right" : { @@ -1320,9 +1456,6 @@ }, "Same Side" : { - }, - "Sample" : { - }, "Sample %@" : { @@ -1330,10 +1463,7 @@ "Sample configuration. Nothing is saved and no connection is attempted." : { }, - "Sample Mac display" : { - - }, - "Sample session" : { + "Sample Mac desktop" : { }, "Sample Session" : { @@ -1366,7 +1496,7 @@ "Search Settings" : { }, - "See how display, input, controls, audio and connections work using a sample Mac. The demo uses sample content and doesn’t connect to another device." : { + "Secondary click · %@" : { }, "Select a category" : { @@ -1374,12 +1504,21 @@ }, "Select a modifier to choose how its shortcuts appear. By default they bloom on the arc just outside the modifiers." : { + }, + "Select All" : { + }, "Select…" : { + }, + "Selected %@" : { + }, "Selected display unavailable" : { + }, + "Selection cleared" : { + }, "Sends" : { @@ -1407,6 +1546,12 @@ }, "Settings always stays reachable, even with input turned off." : { + }, + "Settings, Display: switch modes and try Extend Shape." : { + + }, + "Settings, Input: switch to Trackpad and move the pointer with one finger." : { + }, "Share Code" : { @@ -1452,12 +1597,18 @@ }, "Show Keyboard Button" : { + }, + "Show or Hide Dock" : { + }, "Show Surface Grid" : { }, "Showing in Picture in Picture" : { + }, + "Showing the desktop" : { + }, "Shown for reference in the demo. These are granted on the Mac when you set up the Mac app. The demo hasn't checked or changed any permission." : { @@ -1542,6 +1693,9 @@ }, "Swipe left to remove (built-in actions are hidden instead), swipe right to show or hide. Tap an action to rename it or change how it looks." : { + }, + "Switch Apps" : { + }, "Switching to %@" : { @@ -1572,6 +1726,15 @@ }, "Tap a control on the canvas to edit it, or drag it to move it." : { + }, + "Tap a window to focus it, drag its title bar, scroll the Files list, double-click a file." : { + + }, + "Tap and drag" : { + + }, + "Tap to click" : { + }, "Template" : { @@ -1635,9 +1798,6 @@ }, "This restores its tray layout and shortcut palettes. Other receiver settings stay unchanged." : { - }, - "This sample workspace is drawn on this device. With the Mac app, your Mac’s real screen appears here." : { - }, "This share code is damaged or incomplete. Copy it again." : { @@ -1680,9 +1840,18 @@ }, "Trackpad" : { "comment" : "Input mode: relative pointer control like a laptop trackpad, not the physical accessory." + }, + "Trackpad Preview" : { + }, "Trackpad Sensitivity" : { + }, + "Trackpad. Move one finger to move the pointer, tap to click." : { + + }, + "Trackpad. Move your finger to control the pointer." : { + }, "Trust changed during pairing. Try again" : { @@ -1692,9 +1861,18 @@ }, "Try Again" : { + }, + "Try MeowDisplay with an interactive sample Mac. No connection required." : { + + }, + "Try the controls with a sample Mac. No connection required." : { + }, "Try the layout exactly as it will look. Preview never sends anything to your Mac." : { + }, + "Try Trackpad" : { + }, "Trying to restore the connection to your Mac." : { @@ -1704,9 +1882,18 @@ }, "Turn Off Input (Demo)" : { + }, + "Turn video off to use the surface as a trackpad." : { + + }, + "Turn Video On" : { + }, "Turning video off keeps the connection, keyboard, controls, and selected input mode active." : { + }, + "Two fingers to scroll" : { + }, "Two-Hand Assist" : { @@ -1714,7 +1901,10 @@ "Type" : { }, - "Typed “%@”" : { + "Typed in Notes" : { + + }, + "Typing %@. Open Notes to type here." : { }, "Unavailable" : { @@ -1722,6 +1912,9 @@ }, "Unavailable while the experimental Metal renderer is on." : { + }, + "Undo" : { + }, "Up to %lld characters" : { @@ -1767,6 +1960,9 @@ }, "Use Template" : { + }, + "Use the control trays and keyboard. Mac-only actions show what would be sent." : { + }, "Use This Layout" : { @@ -1789,11 +1985,26 @@ "Video" : { }, - "Video is off (Demo)" : { + "Video is off. With a connected Mac, this surface can still control the pointer." : { + + }, + "Video Off" : { }, "Video Off temporarily routes pinch and rotate to App without changing these saved choices. App mode is experimental: instead of injecting native gestures into the foreground app, it sends the keyboard commands configured in App Gesture Commands." : { + }, + "Video off. The surface still works as a trackpad." : { + + }, + "Video on." : { + + }, + "View" : { + + }, + "View reset" : { + }, "Viewport" : { "comment" : "Gesture target: zoom or rotate the displayed picture." @@ -1822,7 +2033,7 @@ "Welcome" : { }, - "Welcome to the MeowDisplay demo" : { + "What can I try?" : { }, "What this %@ saw while connecting: sessions, restarts, decoder trouble. No screen content and nothing leaves the %@ unless you share it. Attach it to a GitHub issue if a connection won't come up." : { @@ -1867,6 +2078,15 @@ }, "WiFi: both devices on the same network, then pick this %@ in the Mac app's Connection menu." : { + }, + "Window" : { + + }, + "Windows restored" : { + + }, + "would be sent to the Mac" : { + }, "You already have %lld Custom layouts. Delete one first, then import this one." : { @@ -1903,6 +2123,12 @@ }, "Zoom While Typing" : { + }, + "Zoom your view" : { + + }, + "Zoomed in" : { + } }, "version" : "1.0" From cbbad7fa915be82df31aaaeb93f2248329432c05 Mon Sep 17 00:00:00 2001 From: raiseCatError <315733358+raiseCatError@users.noreply.github.com> Date: Sun, 11 Oct 2026 23:10:48 +0530 Subject: [PATCH 24/30] fix(ios): link the privacy policy from Settings > About App Store review needs the privacy policy reachable inside the app. Settings > About now has a Privacy Policy link to https://meowdisplay.app/privacy, kept with the other project links in ProjectLinks. Demo Mode shows the same About page, so the link is there in both modes. - Tests pin the URL and check that the About page links it and that Demo Mode uses the shared About page. - The Demo Mode smoke test checks for the link on About. --- MacTests/ProjectLinksTests.swift | 20 ++++++++++++++++++++ Shared/ProjectLinks.swift | 4 ++++ iOS/ReceiverSettings.swift | 3 +++ iOSUITests/DemoModeSmokeTests.swift | 4 ++++ 4 files changed, 31 insertions(+) diff --git a/MacTests/ProjectLinksTests.swift b/MacTests/ProjectLinksTests.swift index 0929569a..0b1ce14c 100644 --- a/MacTests/ProjectLinksTests.swift +++ b/MacTests/ProjectLinksTests.swift @@ -6,6 +6,26 @@ final class ProjectLinksTests: XCTestCase { XCTAssertEqual(AppStore.projectURL, ProjectLinks.gitHub) } + func testPrivacyPolicyIsMeowDisplaysOwnPage() { + XCTAssertEqual(ProjectLinks.privacyPolicy.absoluteString, "https://meowdisplay.app/privacy") + XCTAssertEqual(ProjectLinks.privacyPolicy.scheme, "https") + } + + /// Settings > About links the privacy policy, and Demo Mode shows that + /// same About page (not a demo copy), so the link is there in both modes. + func testAboutLinksThePrivacyPolicyInNormalAndDemoMode() throws { + XCTAssertEqual(DemoSettingsPresentation.presentation(for: .about), .shared) + let root = URL(fileURLWithPath: #filePath).resolvingSymlinksInPath() + .deletingLastPathComponent().deletingLastPathComponent() + let settings = try String(contentsOf: root.appendingPathComponent("iOS/ReceiverSettings.swift"), encoding: .utf8) + let about = try XCTUnwrap(settings.range(of: "private struct AboutSettingsPage")) + let page = settings[about.lowerBound...] + // The struct ends at the first closing brace in column 0. + let end = page.range(of: "\n}\n")?.lowerBound ?? page.endIndex + XCTAssertTrue(page[.. About (normal and Demo Mode + /// share that page) and given to App Store Connect. + static let privacyPolicy = URL(string: "https://meowdisplay.app/privacy")! + /// The Ko-fi page (e.g. "https://ko-fi.com/"), or nil while there /// is none. The one place to configure: set it once the page exists, and /// every "Support MeowDisplay" action appears. Until then those actions diff --git a/iOS/ReceiverSettings.swift b/iOS/ReceiverSettings.swift index 55cc5ed4..ee7ebcaa 100644 --- a/iOS/ReceiverSettings.swift +++ b/iOS/ReceiverSettings.swift @@ -1308,6 +1308,9 @@ private struct AboutSettingsPage: View { Link(destination: ProjectLinks.gitHub) { Label("MeowDisplay on GitHub", systemImage: "link") } + Link(destination: ProjectLinks.privacyPolicy) { + Label("Privacy Policy", systemImage: "hand.raised") + } if let support = ProjectLinks.support { Link(destination: support) { Label("Support MeowDisplay", systemImage: "heart") diff --git a/iOSUITests/DemoModeSmokeTests.swift b/iOSUITests/DemoModeSmokeTests.swift index 7091bf18..93e5e765 100644 --- a/iOSUITests/DemoModeSmokeTests.swift +++ b/iOSUITests/DemoModeSmokeTests.swift @@ -53,6 +53,10 @@ final class DemoModeSmokeTests: XCTestCase { cell.tap() XCTAssertTrue(app.navigationBars[category].waitForExistence(timeout: 3), category) snapshot(app, "settings-\(category)") + if category == "About" { + XCTAssertTrue(app.descendants(matching: .any)["Privacy Policy"].firstMatch.exists, + "About links the privacy policy in Demo Mode") + } if app.navigationBars[category].buttons.firstMatch.exists, UIDevice.current.userInterfaceIdiom == .phone { app.navigationBars[category].buttons.firstMatch.tap() From 4e8e4e88ed23c33afb4ca6d3fc8ad41ff44f6c37 Mon Sep 17 00:00:00 2001 From: raiseCatError <315733358+raiseCatError@users.noreply.github.com> Date: Sun, 25 Oct 2026 16:44:36 +0530 Subject: [PATCH 25/30] feat(mac): add a per-device Mirror/Extend assignment model Each receiver already owns its own MacSender pipeline, but every pipeline was built from the Mac-wide mode and Mirror source, and any change rebuilt every device. Add the pure decision layer that replaces that: per-device preferences persisted by install id (following the Mac default when unset), a coordinator that names exactly the sessions a change must rebuild, a Mirror source resolver whose Automatic never picks another device's MeowDisplay virtual display, and a live virtual-display registry with an identity planner that skips identities another session holds. Refs #20 --- Mac/DisplayModeAssignment.swift | 534 +++++++++++++++++++ MacTests/DisplayModeAssignmentTests.swift | 615 ++++++++++++++++++++++ project.yml | 1 + 3 files changed, 1150 insertions(+) create mode 100644 Mac/DisplayModeAssignment.swift create mode 100644 MacTests/DisplayModeAssignmentTests.swift diff --git a/Mac/DisplayModeAssignment.swift b/Mac/DisplayModeAssignment.swift new file mode 100644 index 00000000..6510f3d1 --- /dev/null +++ b/Mac/DisplayModeAssignment.swift @@ -0,0 +1,534 @@ +import CoreGraphics +import Foundation + +// Per-device Mirror/Extend (issue #20). Every connected receiver already owns +// a full `MacSender` pipeline — its own virtual display, capture and encoder — +// so independence was never limited by the pipeline; it was limited by the +// Mac-wide `mode`/`mirrorDisplayUUID` settings that every pipeline was built +// from, and by `restartAll()` rebuilding every device whenever either changed. +// This file holds the decisions that replace that: which mode and Mirror +// source each device gets, and which sessions a change actually has to +// rebuild. Foundation/CoreGraphics only, like its siblings, so the +// multi-receiver combinations are unit testable without real displays. + +/// One device's own Mirror capture-source choice. At the preference level, +/// `nil` means "follow this Mac's default" — distinct from `.automatic`, +/// which is an explicit per-device pick of Automatic. +enum MirrorSourcePreference: Equatable { + case automatic + case display(uuid: String) + + init(displayUUID: String?) { + self = displayUUID.map { .display(uuid: $0) } ?? .automatic + } + + var displayUUID: String? { + if case .display(let uuid) = self { return uuid } + return nil + } +} + +/// What one device chose for itself. A `nil` field follows the Mac-wide +/// default, so an existing install (no per-device record anywhere) behaves +/// exactly as it did when the mode was global. +struct DeviceDisplayModePreference: Equatable { + var mode: CaptureMode? + var mirrorSource: MirrorSourcePreference? + + static let followsDefault = DeviceDisplayModePreference(mode: nil, mirrorSource: nil) + + var isFollowingDefault: Bool { mode == nil && mirrorSource == nil } +} + +/// Per-peer persistence, keyed by the receiver's stable install id — the same +/// cross-transport identity `ExtendDisplayShapeStore` and display arrangement +/// memory use, so a device keeps its mode across USB/LAN/Remote, reconnects +/// and sender restarts. +enum DeviceDisplayModeStore { + static func load(peerID: String, defaults: UserDefaults = .standard) -> DeviceDisplayModePreference { + guard let raw = defaults.dictionary(forKey: key(peerID)) else { return .followsDefault } + let mode = (raw["mode"] as? String).flatMap(CaptureMode.init(rawValue:)) + let mirrorSource: MirrorSourcePreference? = { + switch raw["mirrorSource"] as? String { + case "automatic": return .automatic + case "display": return (raw["mirrorDisplayUUID"] as? String).map { .display(uuid: $0) } + default: return nil + } + }() + return DeviceDisplayModePreference(mode: mode, mirrorSource: mirrorSource) + } + + static func save(_ preference: DeviceDisplayModePreference, peerID: String, + defaults: UserDefaults = .standard) { + guard !preference.isFollowingDefault else { + remove(peerID: peerID, defaults: defaults) + return + } + var raw: [String: Any] = [:] + if let mode = preference.mode { raw["mode"] = mode.rawValue } + switch preference.mirrorSource { + case .automatic: raw["mirrorSource"] = "automatic" + case .display(let uuid): + raw["mirrorSource"] = "display" + raw["mirrorDisplayUUID"] = uuid + case nil: break + } + defaults.set(raw, forKey: key(peerID)) + } + + /// Forget removes the device's choice with the rest of its per-peer state. + static func remove(peerID: String, defaults: UserDefaults = .standard) { + defaults.removeObject(forKey: key(peerID)) + } + + private static func key(_ peerID: String) -> String { "displayMode.\(peerID)" } +} + +/// The Mac-wide values a device without its own choice follows — the +/// pre-#20 global `mode`/`mirrorDisplayUUID` settings, unchanged in storage. +struct DisplayModeDefaults: Equatable { + var mode: CaptureMode + var mirrorDisplayUUID: String? +} + +/// What one session's pipeline is actually built with. +struct DeviceDisplayAssignment: Equatable { + let mode: CaptureMode + /// The Mirror source (`nil` = Automatic). Carried in Extend too, so a + /// later switch back to Mirror needs no extra lookup, but ignored there. + let mirrorDisplayUUID: String? + /// Whether `mode` is the device's own choice rather than the Mac default. + let modeIsDeviceChoice: Bool + /// Whether `mirrorDisplayUUID` is the device's own choice. + let mirrorSourceIsDeviceChoice: Bool + + /// Two assignments produce the same capture: the mode matches and, in + /// Mirror only, so does the source. Who chose a value never matters. + func capturesSame(as other: DeviceDisplayAssignment) -> Bool { + mode == other.mode && (mode == .extend || mirrorDisplayUUID == other.mirrorDisplayUUID) + } +} + +enum DeviceDisplayModeResolution { + static func resolve(preference: DeviceDisplayModePreference, defaults: DisplayModeDefaults, + videoEnabled: Bool) -> DeviceDisplayAssignment { + let mode = VideoModePolicy.normalized(mode: preference.mode ?? defaults.mode, videoEnabled: videoEnabled) + let mirrorDisplayUUID = preference.mirrorSource.map(\.displayUUID) ?? defaults.mirrorDisplayUUID + return DeviceDisplayAssignment(mode: mode, mirrorDisplayUUID: mirrorDisplayUUID, + modeIsDeviceChoice: preference.mode != nil, + mirrorSourceIsDeviceChoice: preference.mirrorSource != nil) + } +} + +/// Why a device's mode request was refused. The session keeps its current +/// mode; nothing else is touched. +enum DisplayModeRejection: Equatable { + /// Video is off, which only Mirror supports (`VideoModePolicy`). + case videoOff + /// Mirror needs a usable physical display (`MirrorUnavailableOfferPolicy`). + case noPhysicalDisplay + /// The session ended before the request was handled. + case unknownSession +} + +enum DisplayModeChangeOutcome: Equatable { + /// The session already captures this way. A device-choice change may + /// still have been persisted. + case unchanged + /// Rebuild only this session's pipeline with the new assignment. + case rebuild(DeviceDisplayAssignment) + case rejected(DisplayModeRejection) +} + +/// The single authority for which mode and Mirror source every live session +/// uses. `SenderController` routes every mode-affecting event through it — +/// the Mac's default pickers, each device's own controls, a receiver's +/// `displayModeRequest`/`mirrorDisplayRequest`, session approval, Video Off — +/// and rebuilds exactly the sessions it names, so changing one device never +/// rebuilds or changes another. +/// +/// Keyed by `DeviceSession.id`. A pipeline rebuilt for the same target keeps +/// its entry (that is what carries the assignment across the rebuild); an +/// entry ends with its session. +struct DisplayModeCoordinator { + struct LiveSession: Equatable { + var peerID: String? + var assignment: DeviceDisplayAssignment + } + + private(set) var defaults: DisplayModeDefaults + private(set) var videoEnabled: Bool + private(set) var live: [String: LiveSession] = [:] + private let store: UserDefaults + + init(defaults: DisplayModeDefaults, videoEnabled: Bool, store: UserDefaults = .standard) { + self.defaults = defaults + self.videoEnabled = videoEnabled + self.store = store + } + + func preference(peerID: String?) -> DeviceDisplayModePreference { + peerID.map { DeviceDisplayModeStore.load(peerID: $0, defaults: store) } ?? .followsDefault + } + + /// What a device would get right now (also for a device that is offline). + func resolvedAssignment(peerID: String?) -> DeviceDisplayAssignment { + DeviceDisplayModeResolution.resolve(preference: preference(peerID: peerID), defaults: defaults, + videoEnabled: videoEnabled) + } + + func assignment(sessionID: String) -> DeviceDisplayAssignment? { live[sessionID]?.assignment } + + /// A pipeline for `sessionID` is about to be built. A rebuild keeps the + /// entry it already has; a new session resolves from the device's stored + /// choice (or the Mac default). + mutating func begin(sessionID: String, peerID: String?) -> DeviceDisplayAssignment { + if var existing = live[sessionID] { + if existing.peerID == nil { existing.peerID = peerID } + live[sessionID] = existing + return existing.assignment + } + let assignment = resolvedAssignment(peerID: peerID) + live[sessionID] = LiveSession(peerID: peerID, assignment: assignment) + return assignment + } + + mutating func end(sessionID: String) { + live.removeValue(forKey: sessionID) + } + + /// Drops entries whose session no longer exists (a rebuild whose + /// reconnect could not start). + mutating func retain(sessionIDs: Set) { + live = live.filter { sessionIDs.contains($0.key) } + } + + /// The receiver's hello authenticated its install id. A session that + /// started without one (an unresolved USB device) used the Mac default; + /// if this device has its own choice, it needs a rebuild to honor it. + mutating func identify(sessionID: String, peerID: String) -> DisplayModeChangeOutcome { + guard var entry = live[sessionID] else { return .rejected(.unknownSession) } + guard entry.peerID == nil else { return .unchanged } + entry.peerID = peerID + let resolved = resolvedAssignment(peerID: peerID) + let changed = !resolved.capturesSame(as: entry.assignment) + entry.assignment = resolved + live[sessionID] = entry + return changed ? .rebuild(resolved) : .unchanged + } + + /// The Mac's own pickers can show why a mode is unavailable; this is the + /// authority they and every remote request share. + static func rejection(for mode: CaptureMode, videoEnabled: Bool, + hasUsablePhysicalDisplay: Bool) -> DisplayModeRejection? { + if !VideoModePolicy.allows(mode, videoEnabled: videoEnabled) { return .videoOff } + if mode == .mirror, + !MirrorUnavailableOfferPolicy.canEnterMirror(hasUsablePhysicalDisplay: hasUsablePhysicalDisplay) { + return .noPhysicalDisplay + } + return nil + } + + /// One device asks for a mode — its own control on this Mac, its + /// receiver's request, Connect with Mirror/Extend, or session approval. + /// Becomes that device's remembered choice; no other session changes. + mutating func requestMode(_ mode: CaptureMode, sessionID: String, + hasUsablePhysicalDisplay: Bool) -> DisplayModeChangeOutcome { + guard let entry = live[sessionID] else { return .rejected(.unknownSession) } + if let rejection = Self.rejection(for: mode, videoEnabled: videoEnabled, + hasUsablePhysicalDisplay: hasUsablePhysicalDisplay) { + return .rejected(rejection) + } + var preference = preference(peerID: entry.peerID) + preference.mode = mode + return apply(preference, to: sessionID) + } + + /// One device asks for a Mirror source. Rebuilds only if that device is + /// mirroring and the source actually changes. + mutating func requestMirrorSource(_ source: MirrorSourcePreference, + sessionID: String) -> DisplayModeChangeOutcome { + guard let entry = live[sessionID] else { return .rejected(.unknownSession) } + var preference = preference(peerID: entry.peerID) + preference.mirrorSource = source + return apply(preference, to: sessionID) + } + + /// "Use Mac Default": the device drops its own choice and follows the + /// default again. + mutating func followDefault(sessionID: String) -> DisplayModeChangeOutcome { + guard live[sessionID] != nil else { return .rejected(.unknownSession) } + return apply(.followsDefault, to: sessionID) + } + + /// Records a choice for a device before any session exists for it + /// (Connect with Mirror/Extend on a device with a known install id). + mutating func setDeviceMode(_ mode: CaptureMode, peerID: String) { + var preference = preference(peerID: peerID) + preference.mode = mode + DeviceDisplayModeStore.save(preference, peerID: peerID, defaults: store) + } + + /// Records a one-off mode for a session that has no install id yet, so + /// the pipeline about to be built for it (`begin`) uses it. + mutating func stage(_ mode: CaptureMode, sessionID: String) { + let base = live[sessionID]?.assignment ?? resolvedAssignment(peerID: nil) + live[sessionID] = LiveSession( + peerID: live[sessionID]?.peerID, + assignment: DeviceDisplayAssignment(mode: VideoModePolicy.normalized(mode: mode, videoEnabled: videoEnabled), + mirrorDisplayUUID: base.mirrorDisplayUUID, + modeIsDeviceChoice: true, + mirrorSourceIsDeviceChoice: base.mirrorSourceIsDeviceChoice)) + } + + /// The Mac's default mode changed. Returns only the sessions that follow + /// the default and now capture differently. + mutating func setDefaultMode(_ mode: CaptureMode) -> [String: DeviceDisplayAssignment] { + defaults.mode = mode + return reresolveAll() + } + + /// The Mac's default Mirror source changed. + mutating func setDefaultMirrorDisplay(_ uuid: String?) -> [String: DeviceDisplayAssignment] { + defaults.mirrorDisplayUUID = uuid + return reresolveAll() + } + + /// Video Off. Video is Mac-wide; with a physical display to hand off to, + /// every Extend session becomes Mirror in place (reclaiming its virtual + /// display) and turning video back on never restores Extend implicitly — + /// the pre-#20 behavior, now applied per device: the default becomes + /// Mirror, and a device whose own choice was Extend now remembers Mirror. + /// Headless, nothing changes mode. Returns the sessions the caller must + /// transition in place (not rebuild). + mutating func disableVideo(hasUsablePhysicalDisplay: Bool) -> [String] { + videoEnabled = false + guard MirrorUnavailableOfferPolicy.canEnterMirror(hasUsablePhysicalDisplay: hasUsablePhysicalDisplay) else { + return [] + } + defaults.mode = .mirror + var transitioned: [String] = [] + for (sessionID, entry) in live.sorted(by: { $0.key < $1.key }) { + if let peerID = entry.peerID { + var preference = preference(peerID: peerID) + if preference.mode == .extend { + preference.mode = .mirror + DeviceDisplayModeStore.save(preference, peerID: peerID, defaults: store) + } + } + if entry.assignment.mode == .extend { transitioned.append(sessionID) } + let resolved = resolveLive(entry) + live[sessionID]?.assignment = resolved + } + return transitioned + } + + /// Video On never restores Extend: nothing is rebuilt. + mutating func enableVideo() { + videoEnabled = true + } + + /// The Mac default mode the Displays picker shows. + var defaultMode: CaptureMode { defaults.mode } + + // MARK: - Private + + private func resolveLive(_ entry: LiveSession) -> DeviceDisplayAssignment { + if entry.peerID == nil { + // No install id: the session's staged one-off choice (if any) is + // all it has; only the parts that follow the default re-resolve. + let fromDefaults = resolvedAssignment(peerID: nil) + let mode = entry.assignment.modeIsDeviceChoice + ? VideoModePolicy.normalized(mode: entry.assignment.mode, videoEnabled: videoEnabled) + : fromDefaults.mode + return DeviceDisplayAssignment(mode: mode, mirrorDisplayUUID: fromDefaults.mirrorDisplayUUID, + modeIsDeviceChoice: entry.assignment.modeIsDeviceChoice, + mirrorSourceIsDeviceChoice: false) + } + return resolvedAssignment(peerID: entry.peerID) + } + + private mutating func reresolveAll() -> [String: DeviceDisplayAssignment] { + var rebuilds: [String: DeviceDisplayAssignment] = [:] + for (sessionID, entry) in live { + let resolved = resolveLive(entry) + if !resolved.capturesSame(as: entry.assignment) { rebuilds[sessionID] = resolved } + live[sessionID]?.assignment = resolved + } + return rebuilds + } + + private mutating func apply(_ preference: DeviceDisplayModePreference, + to sessionID: String) -> DisplayModeChangeOutcome { + guard let entry = live[sessionID] else { return .rejected(.unknownSession) } + let resolved: DeviceDisplayAssignment + if let peerID = entry.peerID { + DeviceDisplayModeStore.save(preference, peerID: peerID, defaults: store) + resolved = resolvedAssignment(peerID: peerID) + } else { + // Nothing durable to key on: the choice lasts for this session. + resolved = DeviceDisplayModeResolution.resolve(preference: preference, defaults: defaults, + videoEnabled: videoEnabled) + } + live[sessionID]?.assignment = resolved + return resolved.capturesSame(as: entry.assignment) ? .unchanged : .rebuild(resolved) + } +} + +// MARK: - Mirror source resolution + +/// A capturable display, reduced to what source selection needs — +/// `SCDisplay` itself can't be constructed in a test. +struct MirrorSourceCandidate: Equatable { + let displayID: CGDirectDisplayID + let persistentID: String? +} + +enum MirrorSourceResolution: Equatable { + /// The device's (or default's) explicit choice is present. + case selected(CGDirectDisplayID) + /// Automatic. + case automatic(CGDirectDisplayID) + /// The explicit choice is not connected; Automatic stands in until it + /// returns (the choice itself is kept). + case selectedUnavailable(uuid: String, fallback: CGDirectDisplayID) + /// Nothing this session may mirror. + case unavailable + + var displayID: CGDirectDisplayID? { + switch self { + case .selected(let id), .automatic(let id), .selectedUnavailable(_, let id): return id + case .unavailable: return nil + } + } +} + +enum MirrorSourceResolver { + /// Automatic never picks another session's MeowDisplay virtual display: + /// before #20 it was simply ScreenCaptureKit's first display, which with + /// an Extend receiver connected could be that receiver's desktop. With no + /// MeowDisplay displays present this is exactly the old first-display + /// behavior. An explicit pick of a MeowDisplay display is honored — the + /// user can deliberately mirror another device's extended desktop. + static func automatic(in candidates: [MirrorSourceCandidate], + excluding meowDisplayIDs: Set) -> CGDirectDisplayID? { + candidates.first { !meowDisplayIDs.contains($0.displayID) }?.displayID + } + + static func resolve(selectedUUID: String?, in candidates: [MirrorSourceCandidate], + excluding meowDisplayIDs: Set) -> MirrorSourceResolution { + if let selectedUUID { + if let match = candidates.first(where: { $0.persistentID == selectedUUID }) { + return .selected(match.displayID) + } + guard let fallback = automatic(in: candidates, excluding: meowDisplayIDs) else { return .unavailable } + return .selectedUnavailable(uuid: selectedUUID, fallback: fallback) + } + return automatic(in: candidates, excluding: meowDisplayIDs).map { .automatic($0) } ?? .unavailable + } + + /// Capture recovery reattaches to the display it was capturing while that + /// display still exists; otherwise Automatic, under the same exclusion. + static func recoveryDisplay(preferred: CGDirectDisplayID?, in candidates: [MirrorSourceCandidate], + excluding meowDisplayIDs: Set) -> CGDirectDisplayID? { + if let preferred, candidates.contains(where: { $0.displayID == preferred }) { return preferred } + return automatic(in: candidates, excluding: meowDisplayIDs) + } +} + +// MARK: - Live virtual displays + +/// The vendor-local identity macOS keys a virtual display's saved state on. +struct VirtualDisplayIdentity: Hashable { + let serial: UInt32 + let productID: UInt32 +} + +/// Every MeowDisplay virtual display alive in this process, and which +/// session owns it. Lets Mirror's Automatic exclude other devices' extended +/// desktops, lets a new Extend display skip an identity another live session +/// already holds (instead of retrying creation into a refusal for ~16 s), +/// and gives teardown a checkable record. `VirtualDisplay` registers on +/// successful creation and unregisters on release, so every teardown path is +/// covered by the object's own lifetime. +final class VirtualDisplayRegistry: @unchecked Sendable { + static let shared = VirtualDisplayRegistry() + + struct Entry: Equatable { + let identity: VirtualDisplayIdentity + let owner: ObjectIdentifier? + } + + private let lock = NSLock() + private var entries: [CGDirectDisplayID: Entry] = [:] + + func register(displayID: CGDirectDisplayID, identity: VirtualDisplayIdentity, owner: ObjectIdentifier?) { + lock.lock() + defer { lock.unlock() } + entries[displayID] = Entry(identity: identity, owner: owner) + } + + func unregister(displayID: CGDirectDisplayID) { + lock.lock() + defer { lock.unlock() } + entries.removeValue(forKey: displayID) + } + + var displayIDs: Set { + lock.lock() + defer { lock.unlock() } + return Set(entries.keys) + } + + var count: Int { + lock.lock() + defer { lock.unlock() } + return entries.count + } + + /// Whether a live display owned by someone other than `owner` already + /// uses `identity`. A session's own (lingering) display is not a + /// collision — the existing retry window handles that. + func isClaimed(_ identity: VirtualDisplayIdentity, byOtherThan owner: ObjectIdentifier?) -> Bool { + lock.lock() + defer { lock.unlock() } + return entries.values.contains { $0.identity == identity && (owner == nil || $0.owner != owner) } + } + + func displayIDs(ownedBy owner: ObjectIdentifier) -> Set { + lock.lock() + defer { lock.unlock() } + return Set(entries.filter { $0.value.owner == owner }.keys) + } +} + +/// Which identity offsets an Extend setup tries, in order. macOS refuses to +/// create a display whose identity a live display already holds, so probing +/// one held by ANOTHER live session would only burn the retry window before +/// moving on. Those are skipped up front; a session's own lingering display +/// is not (its serial frees up within the retry window). A skipped offset is +/// a live collision, not saved-state poisoning, so it is never persisted. +enum VirtualDisplayIdentityPlanner { + static let baseProductID: UInt32 = 0x4F53 + + static func identity(serial: UInt32, offset: UInt32) -> VirtualDisplayIdentity { + VirtualDisplayIdentity(serial: serial &+ offset, productID: baseProductID &+ offset) + } + + static func offsets(serial: UInt32, baseOffset: UInt32, probes: UInt32 = 3, + isClaimedByAnotherSession: (VirtualDisplayIdentity) -> Bool) -> [UInt32] { + var offsets: [UInt32] = [] + var candidate = baseOffset + // Bounded: never more than `probes` attempts, and never scanning + // further than a few identities past them for free ones. + var scanned: UInt32 = 0 + while UInt32(offsets.count) < probes, scanned < probes &* 4 { + if !isClaimedByAnotherSession(identity(serial: serial, offset: candidate)) { + offsets.append(candidate) + } + candidate &+= 1 + scanned += 1 + } + return offsets + } +} diff --git a/MacTests/DisplayModeAssignmentTests.swift b/MacTests/DisplayModeAssignmentTests.swift new file mode 100644 index 00000000..aa4c41bf --- /dev/null +++ b/MacTests/DisplayModeAssignmentTests.swift @@ -0,0 +1,615 @@ +import CoreGraphics +import XCTest + +/// Issue #20: independent Mirror/Extend per connected device. +/// +/// `SyntheticMac` drives the real `DisplayModeCoordinator` and +/// `VirtualDisplayRegistry` the way `SenderController`/`MacSender` do — +/// begin a pipeline, rebuild exactly the sessions the coordinator names, end +/// a session — with synthetic receivers and fake display IDs standing in for +/// real devices and `CGVirtualDisplay`s. It proves the decision logic for the +/// combinations below; it does not prove real WindowServer, ScreenCaptureKit +/// or multi-device hardware behavior (see docs/PER_DEVICE_DISPLAY_MODE.md's +/// physical checklist for that). +final class DisplayModeAssignmentTests: XCTestCase { + private var store: UserDefaults! + private var suiteName: String! + + override func setUp() { + super.setUp() + suiteName = "DisplayModeAssignmentTests.\(UUID().uuidString)" + store = UserDefaults(suiteName: suiteName) + } + + override func tearDown() { + store.removePersistentDomain(forName: suiteName) + super.tearDown() + } + + // MARK: - Synthetic multi-receiver Mac + + /// One pipeline build per session: what mode it was built with and, in + /// Extend, which virtual display it got. + private struct Pipeline: Equatable { + let assignment: DeviceDisplayAssignment + let virtualDisplayID: CGDirectDisplayID? + } + + private final class SyntheticMac { + var coordinator: DisplayModeCoordinator + let registry = VirtualDisplayRegistry() + private(set) var pipelines: [String: Pipeline] = [:] + private(set) var builds: [String: Int] = [:] + private var nextDisplayID: CGDirectDisplayID = 100 + private var owners: [String: NSObject] = [:] + var hasUsablePhysicalDisplay = true + + init(defaults: DisplayModeDefaults = .init(mode: .extend, mirrorDisplayUUID: nil), + videoEnabled: Bool = true, store: UserDefaults) { + coordinator = DisplayModeCoordinator(defaults: defaults, videoEnabled: videoEnabled, store: store) + } + + func connect(_ sessionID: String, peer: String?) { + build(sessionID, assignment: coordinator.begin(sessionID: sessionID, peerID: peer)) + } + + /// Receiver vanished (disconnect, crash, cable pull): the pipeline + /// and its display go away with the session. + func drop(_ sessionID: String) { + releaseDisplay(sessionID) + pipelines.removeValue(forKey: sessionID) + coordinator.end(sessionID: sessionID) + } + + func requestMode(_ mode: CaptureMode, _ sessionID: String) -> DisplayModeChangeOutcome { + let outcome = coordinator.requestMode(mode, sessionID: sessionID, + hasUsablePhysicalDisplay: hasUsablePhysicalDisplay) + apply(outcome, sessionID) + return outcome + } + + func requestMirrorSource(_ source: MirrorSourcePreference, _ sessionID: String) -> DisplayModeChangeOutcome { + let outcome = coordinator.requestMirrorSource(source, sessionID: sessionID) + apply(outcome, sessionID) + return outcome + } + + func setDefaultMode(_ mode: CaptureMode) -> Set { + let rebuilds = coordinator.setDefaultMode(mode) + rebuilds.forEach { rebuild($0.key, assignment: $0.value) } + return Set(rebuilds.keys) + } + + func setDefaultMirrorDisplay(_ uuid: String?) -> Set { + let rebuilds = coordinator.setDefaultMirrorDisplay(uuid) + rebuilds.forEach { rebuild($0.key, assignment: $0.value) } + return Set(rebuilds.keys) + } + + func identify(_ sessionID: String, peer: String) -> DisplayModeChangeOutcome { + let outcome = coordinator.identify(sessionID: sessionID, peerID: peer) + apply(outcome, sessionID) + return outcome + } + + func mode(_ sessionID: String) -> CaptureMode? { pipelines[sessionID]?.assignment.mode } + func display(_ sessionID: String) -> CGDirectDisplayID? { pipelines[sessionID]?.virtualDisplayID } + + func apply(_ outcome: DisplayModeChangeOutcome, _ sessionID: String) { + if case .rebuild(let assignment) = outcome { rebuild(sessionID, assignment: assignment) } + } + + /// `SenderController.rebuild(_:)`: stop the old pipeline, build a new + /// one for the same target. The coordinator entry survives because + /// `begin` finds it. + private func rebuild(_ sessionID: String, assignment: DeviceDisplayAssignment) { + releaseDisplay(sessionID) + let rebuilt = coordinator.begin(sessionID: sessionID, peerID: nil) + XCTAssertEqual(rebuilt, assignment, "a rebuild must carry the assignment it was planned with") + build(sessionID, assignment: rebuilt) + } + + private func build(_ sessionID: String, assignment: DeviceDisplayAssignment) { + builds[sessionID, default: 0] += 1 + var displayID: CGDirectDisplayID? + if assignment.mode == .extend { + let owner = owners[sessionID] ?? NSObject() + owners[sessionID] = owner + let id = nextDisplayID + nextDisplayID += 1 + registry.register(displayID: id, + identity: VirtualDisplayIdentity(serial: id, productID: 0x4F53), + owner: ObjectIdentifier(owner)) + displayID = id + } + pipelines[sessionID] = Pipeline(assignment: assignment, virtualDisplayID: displayID) + } + + private func releaseDisplay(_ sessionID: String) { + if let id = pipelines[sessionID]?.virtualDisplayID { registry.unregister(displayID: id) } + } + } + + // MARK: - Single device keeps the pre-#20 behavior + + func testDeviceWithoutItsOwnChoiceFollowsTheMacDefault() { + let mac = SyntheticMac(defaults: .init(mode: .mirror, mirrorDisplayUUID: "DISPLAY-A"), store: store) + mac.connect("usb:1", peer: "ipad") + XCTAssertEqual(mac.mode("usb:1"), .mirror) + XCTAssertEqual(mac.pipelines["usb:1"]?.assignment.mirrorDisplayUUID, "DISPLAY-A") + XCTAssertFalse(mac.pipelines["usb:1"]!.assignment.modeIsDeviceChoice) + } + + func testChangingTheDefaultRebuildsAFollowingDeviceLikeTheOldGlobalSwitch() { + let mac = SyntheticMac(store: store) + mac.connect("usb:1", peer: "iphone") + XCTAssertEqual(mac.setDefaultMode(.mirror), ["usb:1"]) + XCTAssertEqual(mac.mode("usb:1"), .mirror) + XCTAssertEqual(mac.builds["usb:1"], 2) + XCTAssertNil(mac.display("usb:1"), "Mirror owns no virtual display") + XCTAssertEqual(mac.registry.count, 0) + } + + func testSettingTheDefaultToItsCurrentValueRebuildsNothing() { + let mac = SyntheticMac(store: store) + mac.connect("usb:1", peer: "iphone") + XCTAssertEqual(mac.setDefaultMode(.extend), []) + XCTAssertEqual(mac.builds["usb:1"], 1) + } + + // MARK: - Mirror + Extend + + func testMirrorAndExtendRunSideBySideAndOnlyTheSwitchedDeviceRebuilds() { + let mac = SyntheticMac(store: store) + mac.connect("wifi:ipad", peer: "ipad") + mac.connect("usb:iphone", peer: "iphone") + let ipadDisplay = mac.display("wifi:ipad") + + XCTAssertEqual(mac.requestMode(.mirror, "usb:iphone"), + .rebuild(DeviceDisplayAssignment(mode: .mirror, mirrorDisplayUUID: nil, + modeIsDeviceChoice: true, mirrorSourceIsDeviceChoice: false))) + XCTAssertEqual(mac.mode("usb:iphone"), .mirror) + XCTAssertEqual(mac.mode("wifi:ipad"), .extend) + XCTAssertEqual(mac.builds["wifi:ipad"], 1, "the iPad's pipeline is untouched") + XCTAssertEqual(mac.display("wifi:ipad"), ipadDisplay, "and keeps the same virtual display") + XCTAssertEqual(mac.registry.displayIDs, [ipadDisplay!]) + } + + func testAMirroringDeviceIgnoresALaterDefaultChange() { + let mac = SyntheticMac(store: store) + mac.connect("a", peer: "ipad") + mac.connect("b", peer: "iphone") + _ = mac.requestMode(.mirror, "b") + // Default flips to Mirror and back: only the follower moves. + XCTAssertEqual(mac.setDefaultMode(.mirror), ["a"]) + XCTAssertEqual(mac.setDefaultMode(.extend), ["a"]) + XCTAssertEqual(mac.mode("b"), .mirror) + XCTAssertEqual(mac.builds["b"], 2) + } + + // MARK: - Extend + Extend + + func testTwoExtendedDevicesGetDistinctVirtualDisplays() { + let mac = SyntheticMac(store: store) + mac.connect("a", peer: "ipad-a") + mac.connect("b", peer: "ipad-b") + XCTAssertNotNil(mac.display("a")) + XCTAssertNotNil(mac.display("b")) + XCTAssertNotEqual(mac.display("a"), mac.display("b")) + XCTAssertEqual(mac.registry.count, 2) + } + + func testOneExtendedDeviceSwitchingToMirrorLeavesTheOtherDisplayInPlace() { + let mac = SyntheticMac(store: store) + mac.connect("a", peer: "ipad-a") + mac.connect("b", peer: "ipad-b") + let bDisplay = mac.display("b") + _ = mac.requestMode(.mirror, "a") + XCTAssertEqual(mac.registry.displayIDs, [bDisplay!], "A's display is released, B's survives") + _ = mac.requestMode(.extend, "a") + XCTAssertEqual(mac.registry.count, 2) + XCTAssertNotEqual(mac.display("a"), bDisplay) + XCTAssertEqual(mac.builds["b"], 1) + } + + // MARK: - Mirror + Mirror + + func testTwoMirroringDevicesTargetDifferentDisplaysIndependently() { + let mac = SyntheticMac(defaults: .init(mode: .mirror, mirrorDisplayUUID: nil), store: store) + mac.connect("a", peer: "iphone") + mac.connect("b", peer: "ipad") + _ = mac.requestMirrorSource(.display(uuid: "BUILT-IN"), "a") + _ = mac.requestMirrorSource(.display(uuid: "STUDIO"), "b") + XCTAssertEqual(mac.pipelines["a"]?.assignment.mirrorDisplayUUID, "BUILT-IN") + XCTAssertEqual(mac.pipelines["b"]?.assignment.mirrorDisplayUUID, "STUDIO") + XCTAssertEqual(mac.builds["a"], 2) + XCTAssertEqual(mac.builds["b"], 2) + + // Re-choosing A's source rebuilds A only. + XCTAssertEqual(mac.requestMirrorSource(.automatic, "a"), + .rebuild(DeviceDisplayAssignment(mode: .mirror, mirrorDisplayUUID: nil, + modeIsDeviceChoice: false, mirrorSourceIsDeviceChoice: true))) + XCTAssertEqual(mac.builds["b"], 2) + } + + func testDefaultMirrorSourceMovesOnlyMirroringFollowers() { + let mac = SyntheticMac(defaults: .init(mode: .mirror, mirrorDisplayUUID: nil), store: store) + mac.connect("follower", peer: "iphone") + mac.connect("chooser", peer: "ipad") + mac.connect("extender", peer: "ipad-pro") + _ = mac.requestMirrorSource(.display(uuid: "STUDIO"), "chooser") + _ = mac.requestMode(.extend, "extender") + XCTAssertEqual(mac.setDefaultMirrorDisplay("BUILT-IN"), ["follower"], + "the device with its own source and the Extend device are unaffected") + } + + func testChoosingAMirrorSourceWhileExtendingIsRememberedWithoutARebuild() { + let mac = SyntheticMac(store: store) + mac.connect("a", peer: "ipad") + XCTAssertEqual(mac.requestMirrorSource(.display(uuid: "STUDIO"), "a"), .unchanged) + XCTAssertEqual(mac.builds["a"], 1) + _ = mac.requestMode(.mirror, "a") + XCTAssertEqual(mac.pipelines["a"]?.assignment.mirrorDisplayUUID, "STUDIO") + } + + // MARK: - Three or more receivers + + func testThreeReceiversInTheIssueTableRunIndependently() { + // iPad A Extend · iPhone Mirror · iPad B Extend. + let mac = SyntheticMac(store: store) + mac.connect("ipad-a", peer: "A") + mac.connect("iphone", peer: "P") + mac.connect("ipad-b", peer: "B") + _ = mac.requestMode(.mirror, "iphone") + + XCTAssertEqual(mac.mode("ipad-a"), .extend) + XCTAssertEqual(mac.mode("iphone"), .mirror) + XCTAssertEqual(mac.mode("ipad-b"), .extend) + XCTAssertEqual(Set([mac.display("ipad-a"), mac.display("ipad-b")]).count, 2) + XCTAssertEqual(mac.builds, ["ipad-a": 1, "iphone": 2, "ipad-b": 1]) + } + + func testFiveReceiversInEveryModeMixKeepTheirOwnModes() { + let mac = SyntheticMac(store: store) + let modes: [CaptureMode] = [.extend, .mirror, .extend, .mirror, .extend] + for (index, mode) in modes.enumerated() { + mac.connect("s\(index)", peer: "peer\(index)") + _ = mac.requestMode(mode, "s\(index)") + } + for (index, mode) in modes.enumerated() { XCTAssertEqual(mac.mode("s\(index)"), mode) } + XCTAssertEqual(mac.registry.count, 3) + XCTAssertEqual(mac.setDefaultMode(.mirror), [], "every device has its own choice") + } + + // MARK: - Connect / disconnect / reconnect / restart + + func testDisconnectingOneDeviceLeavesTheOthersAndReleasesOnlyItsDisplay() { + let mac = SyntheticMac(store: store) + mac.connect("a", peer: "A") + mac.connect("b", peer: "B") + mac.connect("c", peer: "C") + _ = mac.requestMode(.mirror, "b") + let cDisplay = mac.display("c") + mac.drop("a") + XCTAssertEqual(mac.registry.displayIDs, [cDisplay!]) + XCTAssertEqual(mac.mode("b"), .mirror) + XCTAssertNil(mac.coordinator.assignment(sessionID: "a")) + } + + func testInterleavedConnectsAndDisconnectsNeverLeakEntriesOrDisplays() { + let mac = SyntheticMac(store: store) + for round in 0..<20 { + mac.connect("a\(round)", peer: "A") + mac.connect("b\(round)", peer: "B") + _ = mac.requestMode(round.isMultiple(of: 2) ? .mirror : .extend, "a\(round)") + mac.drop("b\(round)") + mac.connect("b\(round)", peer: "B") + mac.drop("a\(round)") + mac.drop("b\(round)") + } + XCTAssertTrue(mac.coordinator.live.isEmpty) + XCTAssertEqual(mac.registry.count, 0) + // A's last request (round 19: Extend) is what it remembers. + XCTAssertEqual(DeviceDisplayModeStore.load(peerID: "A", defaults: store).mode, .extend) + } + + func testReceiverCrashMidSwitchLeavesNoStaleState() { + let mac = SyntheticMac(store: store) + mac.connect("a", peer: "A") + mac.connect("b", peer: "B") + _ = mac.requestMode(.mirror, "a") + mac.drop("a") // crashed right after the rebuild began + mac.coordinator.retain(sessionIDs: ["b"]) + XCTAssertEqual(Set(mac.coordinator.live.keys), ["b"]) + mac.connect("a", peer: "A") + XCTAssertEqual(mac.mode("a"), .mirror, "the choice survives the crash") + } + + func testDeviceKeepsItsModeAcrossARouteChange() { + let mac = SyntheticMac(store: store) + mac.connect("usb:1", peer: "iphone") + _ = mac.requestMode(.mirror, "usb:1") + mac.drop("usb:1") + mac.connect("wifi:iphone", peer: "iphone") + XCTAssertEqual(mac.mode("wifi:iphone"), .mirror) + } + + func testSenderRestartRestoresEachDevicesModeAndSource() { + do { + let mac = SyntheticMac(store: store) + mac.connect("a", peer: "A") + mac.connect("b", peer: "B") + mac.connect("c", peer: "C") + _ = mac.requestMode(.mirror, "b") + _ = mac.requestMirrorSource(.display(uuid: "STUDIO"), "b") + } + // New process: nothing live, only the store survives. + let restarted = SyntheticMac(store: store) + restarted.connect("c", peer: "C") + restarted.connect("b", peer: "B") + restarted.connect("a", peer: "A") + XCTAssertEqual(restarted.mode("a"), .extend) + XCTAssertEqual(restarted.mode("b"), .mirror) + XCTAssertEqual(restarted.pipelines["b"]?.assignment.mirrorDisplayUUID, "STUDIO") + XCTAssertEqual(restarted.mode("c"), .extend) + XCTAssertEqual(restarted.registry.count, 2, "no displays carried over from the old process") + } + + func testAnUnidentifiedSessionIsRebuiltOnceItsHelloRevealsAStoredChoice() { + DeviceDisplayModeStore.save(.init(mode: .mirror, mirrorSource: nil), peerID: "iphone", defaults: store) + let mac = SyntheticMac(store: store) + mac.connect("usb:unresolved", peer: nil) + XCTAssertEqual(mac.mode("usb:unresolved"), .extend, "no install id yet: the default") + guard case .rebuild = mac.identify("usb:unresolved", peer: "iphone") else { + return XCTFail("the stored Mirror choice must be applied") + } + XCTAssertEqual(mac.mode("usb:unresolved"), .mirror) + XCTAssertEqual(mac.identify("usb:unresolved", peer: "iphone"), .unchanged, "never twice") + } + + func testIdentifyingAFollowingDeviceRebuildsNothing() { + let mac = SyntheticMac(store: store) + mac.connect("usb:1", peer: nil) + XCTAssertEqual(mac.identify("usb:1", peer: "iphone"), .unchanged) + XCTAssertEqual(mac.builds["usb:1"], 1) + } + + func testStagedModeForAnUnidentifiedSessionIsUsedByItsFirstPipeline() { + var coordinator = DisplayModeCoordinator(defaults: .init(mode: .extend, mirrorDisplayUUID: nil), + videoEnabled: true, store: store) + coordinator.stage(.mirror, sessionID: "usb:new") + XCTAssertEqual(coordinator.begin(sessionID: "usb:new", peerID: nil).mode, .mirror) + // A one-off choice survives later default changes for that session. + XCTAssertTrue(coordinator.setDefaultMode(.mirror).isEmpty) + XCTAssertTrue(coordinator.setDefaultMode(.extend).isEmpty) + XCTAssertEqual(coordinator.assignment(sessionID: "usb:new")?.mode, .mirror) + } + + func testDeviceChoiceRecordedBeforeConnectingIsUsed() { + var coordinator = DisplayModeCoordinator(defaults: .init(mode: .extend, mirrorDisplayUUID: nil), + videoEnabled: true, store: store) + coordinator.setDeviceMode(.mirror, peerID: "iphone") + XCTAssertEqual(coordinator.begin(sessionID: "wifi:iphone", peerID: "iphone").mode, .mirror) + } + + // MARK: - Rejections never touch anything + + func testMirrorRequestWithoutAPhysicalDisplayIsRejectedAndNotRemembered() { + let mac = SyntheticMac(store: store) + mac.connect("a", peer: "A") + mac.connect("b", peer: "B") + mac.hasUsablePhysicalDisplay = false + XCTAssertEqual(mac.requestMode(.mirror, "a"), .rejected(.noPhysicalDisplay)) + XCTAssertEqual(mac.mode("a"), .extend) + XCTAssertEqual(mac.builds, ["a": 1, "b": 1]) + XCTAssertTrue(DeviceDisplayModeStore.load(peerID: "A", defaults: store).isFollowingDefault) + } + + func testExtendRequestWhileVideoIsOffIsRejected() { + let mac = SyntheticMac(defaults: .init(mode: .mirror, mirrorDisplayUUID: nil), videoEnabled: false, store: store) + mac.connect("a", peer: "A") + XCTAssertEqual(mac.requestMode(.extend, "a"), .rejected(.videoOff)) + XCTAssertEqual(mac.mode("a"), .mirror) + } + + func testRequestForAnEndedSessionIsRejected() { + let mac = SyntheticMac(store: store) + XCTAssertEqual(mac.requestMode(.mirror, "gone"), .rejected(.unknownSession)) + XCTAssertEqual(mac.requestMirrorSource(.automatic, "gone"), .rejected(.unknownSession)) + XCTAssertEqual(mac.coordinator.followDefault(sessionID: "gone"), .rejected(.unknownSession)) + } + + func testRepeatingTheCurrentModeIsUnchangedButBecomesTheDevicesChoice() { + let mac = SyntheticMac(store: store) + mac.connect("a", peer: "A") + XCTAssertEqual(mac.requestMode(.extend, "a"), .unchanged) + XCTAssertEqual(DeviceDisplayModeStore.load(peerID: "A", defaults: store).mode, .extend) + XCTAssertEqual(mac.setDefaultMode(.mirror), [], "now pinned to Extend") + } + + func testFollowDefaultDropsTheDevicesChoice() { + let mac = SyntheticMac(store: store) + mac.connect("a", peer: "A") + _ = mac.requestMode(.mirror, "a") + let outcome = mac.coordinator.followDefault(sessionID: "a") + mac.apply(outcome, "a") + XCTAssertEqual(mac.mode("a"), .extend) + XCTAssertNil(store.dictionary(forKey: "displayMode.A"), "nothing left to remember") + } + + // MARK: - Video Off + + func testVideoOffTransitionsEveryExtendSessionAndRemembersMirror() { + var coordinator = DisplayModeCoordinator(defaults: .init(mode: .extend, mirrorDisplayUUID: nil), + videoEnabled: true, store: store) + _ = coordinator.begin(sessionID: "a", peerID: "A") + _ = coordinator.begin(sessionID: "b", peerID: "B") + _ = coordinator.begin(sessionID: "c", peerID: "C") + _ = coordinator.requestMode(.extend, sessionID: "b", hasUsablePhysicalDisplay: true) + _ = coordinator.requestMode(.mirror, sessionID: "c", hasUsablePhysicalDisplay: true) + + XCTAssertEqual(coordinator.disableVideo(hasUsablePhysicalDisplay: true), ["a", "b"]) + XCTAssertEqual(coordinator.defaultMode, .mirror) + XCTAssertEqual(DeviceDisplayModeStore.load(peerID: "B", defaults: store).mode, .mirror) + for id in ["a", "b", "c"] { XCTAssertEqual(coordinator.assignment(sessionID: id)?.mode, .mirror) } + + coordinator.enableVideo() + XCTAssertEqual(coordinator.resolvedAssignment(peerID: "B").mode, .mirror, "Video On never restores Extend") + } + + func testHeadlessVideoOffKeepsExtend() { + var coordinator = DisplayModeCoordinator(defaults: .init(mode: .extend, mirrorDisplayUUID: nil), + videoEnabled: true, store: store) + _ = coordinator.begin(sessionID: "a", peerID: "A") + XCTAssertEqual(coordinator.disableVideo(hasUsablePhysicalDisplay: false), []) + XCTAssertEqual(coordinator.defaultMode, .extend) + XCTAssertEqual(coordinator.assignment(sessionID: "a")?.mode, .extend) + } + + // MARK: - Persistence + + func testStoreRoundTripsEveryShape() { + let shapes: [DeviceDisplayModePreference] = [ + .init(mode: .mirror, mirrorSource: nil), + .init(mode: .extend, mirrorSource: .automatic), + .init(mode: nil, mirrorSource: .display(uuid: "X")), + .init(mode: .mirror, mirrorSource: .display(uuid: "Y")), + ] + for shape in shapes { + DeviceDisplayModeStore.save(shape, peerID: "p", defaults: store) + XCTAssertEqual(DeviceDisplayModeStore.load(peerID: "p", defaults: store), shape) + } + DeviceDisplayModeStore.save(.followsDefault, peerID: "p", defaults: store) + XCTAssertNil(store.dictionary(forKey: "displayMode.p")) + } + + func testDevicesAreStoredSeparately() { + DeviceDisplayModeStore.save(.init(mode: .mirror, mirrorSource: nil), peerID: "A", defaults: store) + XCTAssertTrue(DeviceDisplayModeStore.load(peerID: "B", defaults: store).isFollowingDefault) + DeviceDisplayModeStore.remove(peerID: "A", defaults: store) + XCTAssertTrue(DeviceDisplayModeStore.load(peerID: "A", defaults: store).isFollowingDefault) + } + + func testUnreadableRecordFollowsTheDefault() { + store.set(["mode": "sideways", "mirrorSource": "display"], forKey: "displayMode.p") + XCTAssertEqual(DeviceDisplayModeStore.load(peerID: "p", defaults: store), .followsDefault) + } + + // MARK: - Mirror source resolution and lost displays + + private let builtIn = MirrorSourceCandidate(displayID: 1, persistentID: "BUILT-IN") + private let studio = MirrorSourceCandidate(displayID: 2, persistentID: "STUDIO") + private let ipadDesktop = MirrorSourceCandidate(displayID: 100, persistentID: "MEOW-IPAD") + + func testAutomaticWithoutMeowDisplaysIsTheFirstDisplayAsBefore() { + XCTAssertEqual(MirrorSourceResolver.resolve(selectedUUID: nil, in: [studio, builtIn], excluding: []), + .automatic(2)) + } + + func testAutomaticNeverMirrorsAnotherDevicesExtendedDesktop() { + // WindowServer may list a MeowDisplay virtual display first. + XCTAssertEqual(MirrorSourceResolver.resolve(selectedUUID: nil, in: [ipadDesktop, builtIn], excluding: [100]), + .automatic(1)) + } + + func testExplicitPickOfAnotherDevicesDesktopIsHonored() { + XCTAssertEqual(MirrorSourceResolver.resolve(selectedUUID: "MEOW-IPAD", in: [ipadDesktop, builtIn], + excluding: [100]), + .selected(100)) + } + + func testLostSelectedDisplayFallsBackToAutomaticAndSaysSo() { + XCTAssertEqual(MirrorSourceResolver.resolve(selectedUUID: "STUDIO", in: [ipadDesktop, builtIn], + excluding: [100]), + .selectedUnavailable(uuid: "STUDIO", fallback: 1)) + } + + func testOnlyMeowDisplaysLeftMeansNothingToMirror() { + XCTAssertEqual(MirrorSourceResolver.resolve(selectedUUID: nil, in: [ipadDesktop], excluding: [100]), + .unavailable) + XCTAssertEqual(MirrorSourceResolver.resolve(selectedUUID: "STUDIO", in: [ipadDesktop], excluding: [100]), + .unavailable) + } + + func testCaptureRecoveryReattachesOrFallsBackPastMeowDisplays() { + XCTAssertEqual(MirrorSourceResolver.recoveryDisplay(preferred: 2, in: [ipadDesktop, builtIn, studio], + excluding: [100]), 2) + // The mirrored external display was unplugged. + XCTAssertEqual(MirrorSourceResolver.recoveryDisplay(preferred: 2, in: [ipadDesktop, builtIn], + excluding: [100]), 1) + XCTAssertNil(MirrorSourceResolver.recoveryDisplay(preferred: 2, in: [ipadDesktop], excluding: [100])) + } + + // MARK: - Virtual display identity + + func testRegistryTracksOwnershipAndCleanup() { + let registry = VirtualDisplayRegistry() + let a = NSObject(), b = NSObject() + let identity = VirtualDisplayIdentity(serial: 7, productID: 0x4F53) + registry.register(displayID: 100, identity: identity, owner: ObjectIdentifier(a)) + XCTAssertTrue(registry.isClaimed(identity, byOtherThan: ObjectIdentifier(b))) + XCTAssertFalse(registry.isClaimed(identity, byOtherThan: ObjectIdentifier(a)), "a session's own display") + XCTAssertEqual(registry.displayIDs(ownedBy: ObjectIdentifier(a)), [100]) + registry.unregister(displayID: 100) + XCTAssertFalse(registry.isClaimed(identity, byOtherThan: ObjectIdentifier(b))) + XCTAssertEqual(registry.count, 0) + } + + func testIdentityPlannerProbesTheUsualOffsetsWhenNothingCollides() { + XCTAssertEqual(VirtualDisplayIdentityPlanner.offsets(serial: 50, baseOffset: 0) { _ in false }, [0, 1, 2]) + XCTAssertEqual(VirtualDisplayIdentityPlanner.offsets(serial: 50, baseOffset: 4) { _ in false }, [4, 5, 6]) + } + + func testIdentityPlannerSkipsAnIdentityAnotherLiveSessionHolds() { + // Two devices whose serials collide (an FNV-1a hash collision, or a + // fallback identity landing on another device's base identity). + let held = VirtualDisplayIdentityPlanner.identity(serial: 50, offset: 0) + let offsets = VirtualDisplayIdentityPlanner.offsets(serial: 50, baseOffset: 0) { $0 == held } + XCTAssertEqual(offsets, [1, 2, 3]) + XCTAssertEqual(VirtualDisplayIdentityPlanner.identity(serial: 50, offset: 1), + VirtualDisplayIdentity(serial: 51, productID: 0x4F54)) + } + + func testIdentityPlannerIsBoundedWhenEverythingCollides() { + XCTAssertEqual(VirtualDisplayIdentityPlanner.offsets(serial: 50, baseOffset: 0) { _ in true }, []) + } + + func testIdentityPlannerAgainstALiveRegistry() { + let registry = VirtualDisplayRegistry() + let other = NSObject(), me = NSObject() + registry.register(displayID: 100, identity: VirtualDisplayIdentityPlanner.identity(serial: 50, offset: 0), + owner: ObjectIdentifier(other)) + let mine = VirtualDisplayIdentityPlanner.offsets(serial: 50, baseOffset: 0) { + registry.isClaimed($0, byOtherThan: ObjectIdentifier(me)) + } + XCTAssertEqual(mine.first, 1) + } + + // MARK: - Input lands on each device's own display + + func testEachReceiversInputMapsIntoItsOwnDisplay() { + // iPad A extends (VD 100 at x=1512), iPhone mirrors the built-in + // display (id 1 at the origin), iPad B extends (VD 101 at x=-1366). + let bounds: [CGDirectDisplayID: CGRect] = [ + 1: CGRect(x: 0, y: 0, width: 1512, height: 982), + 100: CGRect(x: 1512, y: 0, width: 1366, height: 1024), + 101: CGRect(x: -1366, y: 0, width: 1366, height: 1024), + ] + let receivers: [(mode: InputTargetMode, virtual: CGDirectDisplayID?, expected: CGPoint)] = [ + (.extend, 100, CGPoint(x: 1512 + 683, y: 512)), + (.mirror, nil, CGPoint(x: 756, y: 491)), + (.extend, 101, CGPoint(x: -1366 + 683, y: 512)), + ] + for receiver in receivers { + let target = InputTargetResolver.displayID(mode: receiver.mode, mirrorDisplayID: 1, + virtualDisplayID: receiver.virtual) + let point = InputCoordinateMapper.point(x: 0.5, y: 0.5, in: bounds[target!]!) + XCTAssertEqual(point, receiver.expected) + } + } + + func testExtendSessionWithoutADisplayHasNoInputTarget() { + // Mid-rebuild: input must be dropped, never sent to another display. + XCTAssertNil(InputTargetResolver.displayID(mode: .extend, mirrorDisplayID: 1, virtualDisplayID: nil)) + } +} diff --git a/project.yml b/project.yml index 65df8708..3ee9d829 100644 --- a/project.yml +++ b/project.yml @@ -211,6 +211,7 @@ targets: - Mac/AppPresentation.swift - Mac/StartAtLoginPolicy.swift - Mac/MirrorDisplaySelection.swift + - Mac/DisplayModeAssignment.swift - Mac/DisplayHealth.swift - Mac/HiDPIEnforcementBackoff.swift - Mac/RouteArbitration.swift From cdb2f2d4a286058387972cde6718da7babd9d6f8 Mon Sep 17 00:00:00 2001 From: raiseCatError <315733358+raiseCatError@users.noreply.github.com> Date: Sun, 25 Oct 2026 16:50:20 +0530 Subject: [PATCH 26/30] feat(mac): give each connected device its own Mirror/Extend mode Route every mode-affecting event through DisplayModeCoordinator and rebuild only the session it names: a receiver's displayModeRequest and mirrorDisplayRequest, Connect with Mirror/Extend, session approval and the hello of a newly identified USB device now change that device alone and are remembered for it. The Mac-wide mode and Mirror display become defaults for devices without their own choice, so a single device behaves as before. Video Off still hands every Extend session to Mirror in place. Mirror's Automatic source, capture recovery and wake recovery no longer pick another device's MeowDisplay virtual display, and an Extend display skips an identity another live session holds instead of retrying into a refusal. Forget clears the device's mode. Refs #20 --- Mac/DisplayModeAssignment.swift | 35 ++- Mac/MacSender.swift | 64 +++-- Mac/OpenSidecarMacApp.swift | 293 +++++++++++++++++----- Mac/VirtualDisplay.swift | 11 + MacTests/DisplayModeAssignmentTests.swift | 41 ++- 5 files changed, 346 insertions(+), 98 deletions(-) diff --git a/Mac/DisplayModeAssignment.swift b/Mac/DisplayModeAssignment.swift index 6510f3d1..688e9e32 100644 --- a/Mac/DisplayModeAssignment.swift +++ b/Mac/DisplayModeAssignment.swift @@ -244,9 +244,10 @@ struct DisplayModeCoordinator { return apply(preference, to: sessionID) } - /// One device asks for a Mirror source. Rebuilds only if that device is - /// mirroring and the source actually changes. - mutating func requestMirrorSource(_ source: MirrorSourcePreference, + /// One device asks for a Mirror source (`nil`: follow the Mac default + /// source again). Rebuilds only if that device is mirroring and the + /// source actually changes. + mutating func requestMirrorSource(_ source: MirrorSourcePreference?, sessionID: String) -> DisplayModeChangeOutcome { guard let entry = live[sessionID] else { return .rejected(.unknownSession) } var preference = preference(peerID: entry.peerID) @@ -254,6 +255,15 @@ struct DisplayModeCoordinator { return apply(preference, to: sessionID) } + /// The device's mode follows the Mac default again; its Mirror source + /// choice is kept. + mutating func followDefaultMode(sessionID: String) -> DisplayModeChangeOutcome { + guard let entry = live[sessionID] else { return .rejected(.unknownSession) } + var preference = preference(peerID: entry.peerID) + preference.mode = nil + return apply(preference, to: sessionID) + } + /// "Use Mac Default": the device drops its own choice and follows the /// default again. mutating func followDefault(sessionID: String) -> DisplayModeChangeOutcome { @@ -455,29 +465,34 @@ final class VirtualDisplayRegistry: @unchecked Sendable { static let shared = VirtualDisplayRegistry() struct Entry: Equatable { + let displayID: CGDirectDisplayID let identity: VirtualDisplayIdentity let owner: ObjectIdentifier? } private let lock = NSLock() - private var entries: [CGDirectDisplayID: Entry] = [:] + /// Keyed by the registering `VirtualDisplay` instance, not its display + /// ID: a late release of an old display must never remove a newer one + /// that WindowServer gave the same ID. + private var entries: [ObjectIdentifier: Entry] = [:] - func register(displayID: CGDirectDisplayID, identity: VirtualDisplayIdentity, owner: ObjectIdentifier?) { + func register(_ instance: ObjectIdentifier, displayID: CGDirectDisplayID, + identity: VirtualDisplayIdentity, owner: ObjectIdentifier?) { lock.lock() defer { lock.unlock() } - entries[displayID] = Entry(identity: identity, owner: owner) + entries[instance] = Entry(displayID: displayID, identity: identity, owner: owner) } - func unregister(displayID: CGDirectDisplayID) { + func unregister(_ instance: ObjectIdentifier) { lock.lock() defer { lock.unlock() } - entries.removeValue(forKey: displayID) + entries.removeValue(forKey: instance) } var displayIDs: Set { lock.lock() defer { lock.unlock() } - return Set(entries.keys) + return Set(entries.values.map(\.displayID)) } var count: Int { @@ -498,7 +513,7 @@ final class VirtualDisplayRegistry: @unchecked Sendable { func displayIDs(ownedBy owner: ObjectIdentifier) -> Set { lock.lock() defer { lock.unlock() } - return Set(entries.filter { $0.value.owner == owner }.keys) + return Set(entries.values.filter { $0.owner == owner }.map(\.displayID)) } } diff --git a/Mac/MacSender.swift b/Mac/MacSender.swift index ced80cd8..ea26f49f 100644 --- a/Mac/MacSender.swift +++ b/Mac/MacSender.swift @@ -1733,9 +1733,16 @@ final class MacSender: NSObject, SCStreamOutput, SCStreamDelegate { private func startMirrorCapture(preferredDisplayID: CGDirectDisplayID?, sessionGeneration: UInt64? = nil) async throws { let content = try await SCShareableContent.current - let display = preferredDisplayID.flatMap { id in - content.displays.first(where: { $0.displayID == id }) - } ?? content.displays.first + // A display that went away (unplugged mid-session) falls back to + // Automatic, which never lands on another device's extended desktop. + let chosenID = MirrorSourceResolver.recoveryDisplay( + preferred: preferredDisplayID, in: Self.mirrorSourceCandidates(content.displays), + excluding: VirtualDisplayRegistry.shared.displayIDs) + if let preferredDisplayID, chosenID != preferredDisplayID { + Log.info("displayDebug: mirrored display \(preferredDisplayID) is gone — " + + "recovering onto \(chosenID.map(String.init) ?? "none")") + } + let display = chosenID.flatMap { id in content.displays.first(where: { $0.displayID == id }) } guard let display else { throw NSError(domain: "MacSender", code: 1, userInfo: [NSLocalizedDescriptionKey: @@ -1751,14 +1758,13 @@ final class MacSender: NSObject, SCStreamOutput, SCStreamDelegate { /// preference logs the decision and falls back to Automatic. private func startMirrorCaptureUsingPreference(sessionGeneration: UInt64? = nil) async throws { let content = try await SCShareableContent.current - var display = content.displays.first - if let uuid = mirrorDisplayUUID { - if let resolved = MirrorDisplayIdentity.resolve(persistentID: uuid, in: content.displays) { - display = resolved - } else { - Log.info("displayDebug: mirrorSelection unavailable persistentID=\(uuid) — falling back to Automatic") - } + let resolution = MirrorSourceResolver.resolve( + selectedUUID: mirrorDisplayUUID, in: Self.mirrorSourceCandidates(content.displays), + excluding: VirtualDisplayRegistry.shared.displayIDs) + if case .selectedUnavailable(let uuid, _) = resolution { + Log.info("displayDebug: mirrorSelection unavailable persistentID=\(uuid) — falling back to Automatic") } + let display = resolution.displayID.flatMap { id in content.displays.first(where: { $0.displayID == id }) } guard let display else { throw NSError(domain: "MacSender", code: 1, userInfo: [NSLocalizedDescriptionKey: @@ -1767,6 +1773,15 @@ final class MacSender: NSObject, SCStreamOutput, SCStreamDelegate { try await startMirrorCapture(display: display, sessionGeneration: sessionGeneration) } + /// ScreenCaptureKit's display list in the order it reported, reduced to + /// what `MirrorSourceResolver` decides on. + private static func mirrorSourceCandidates(_ displays: [SCDisplay]) -> [MirrorSourceCandidate] { + displays.map { + MirrorSourceCandidate(displayID: $0.displayID, + persistentID: MirrorDisplayIdentity.uuidString(for: $0.displayID)) + } + } + /// Mirror has no usable physical source (headless): instead of failing /// immediately, give the receiver a bounded chance to switch to Extend /// over the EXISTING `displayModeRequest` path — never a parallel @@ -1936,6 +1951,7 @@ final class MacSender: NSObject, SCStreamOutput, SCStreamDelegate { productID: productID, refreshRate: refreshRate, restoreOrigin: restoreOrigin, + owner: ObjectIdentifier(self), onOriginChange: { origin, currentSize in DisplayArrangement.save(origin: origin, size: currentSize, device: arrangementKey) @@ -2044,8 +2060,20 @@ final class MacSender: NSObject, SCStreamOutput, SCStreamDelegate { // is not a disconnected region (this function keeps using it after // the call returns), so the compiler rejects the send outright. let selfBox = self.selfBox - identities: for probe in 0.. DeviceDisplayModePreference { + displayModes.preference(peerID: displayModes.live[session.id]?.peerID) + } + + private func handleDisplayModeOutcome(_ outcome: DisplayModeChangeOutcome, for session: DeviceSession) { + switch outcome { + case .rejected(.unknownSession): + return + case .rejected(let rejection): + Log.info("routeDebug: rejected \(session.id) mode request — \(rejection)") + session.displayModeNotice = switch rejection { + case .noPhysicalDisplay: String(localized: "Mirror needs an active physical display. This device stays in Extend.") + case .videoOff: String(localized: "Extend needs video. Turn video back on to extend onto this device.") + case .unknownSession: nil + } + // Tell this receiver its request didn't take — nobody else. + session.sender.pushDisplayModeState() + if rejection == .noPhysicalDisplay { session.sender.pushMirrorUnavailable() } + case .unchanged, .rebuild: + session.displayModeNotice = nil + // The coordinator is the authority; the session's own copy is + // what its pipeline was built with. Rebuild only if they differ. + reconcileDisplayMode(session) + } + } + + /// Rebuilds `session` if its pipeline doesn't capture the way the + /// coordinator now says it should; otherwise just refreshes the UI copy + /// (who-chose-it flags) and confirms the mode to its receiver. + private func reconcileDisplayMode(_ session: DeviceSession) { + guard let wanted = displayModes.assignment(sessionID: session.id) else { return } + guard wanted.capturesSame(as: session.displayAssignment) else { + // Yield first: this can run inside a SwiftUI picker's own update + // transaction (see `requestMode(_:)`), and a rebuild publishes. + Task { @MainActor in self.rebuild(session) } + return + } + session.displayAssignment = wanted + session.sender.pushDisplayModeState() + } + + private func rebuild(_ plan: [String: DeviceDisplayAssignment]) { + for sessionID in plan.keys.sorted() { + guard let session = session(for: sessionID) else { continue } + rebuild(session) + } + } + + /// `restartAll()` for ONE session: the same teardown and the same carried + /// invitation, so the receiver recognizes the rebuilt pipeline. The + /// coordinator entry survives (same session id), which is what carries + /// the new assignment into `startSession`. + private func rebuild(_ session: DeviceSession) { + guard sessions.contains(where: { $0 === session }), + let wanted = displayModes.assignment(sessionID: session.id), + !wanted.capturesSame(as: session.displayAssignment) else { return } + Log.info("routeDebug: rebuilding \(session.id) for \(wanted.mode.rawValue) " + + "mirrorSource=\(wanted.mirrorDisplayUUID ?? "auto") — other sessions untouched") + session.invitation.mode = wanted.mode.receiverMode + let target = session.target + let carried = session.failed ? nil : continuation(of: session) + session.sender.stop() + sessionObservations.removeValue(forKey: ObjectIdentifier(session)) + sessions.removeAll { $0 === session } + connect(to: target, continuation: carried) + displayModes.retain(sessionIDs: Set(sessions.map(\.id))) + autoConnect() // a rebuilt WiFi session may deserve its cable back + } + func requestVideoEnabled(_ enabled: Bool) { guard enabled != videoEnabled else { return } + if enabled { + displayModes.enableVideo() + videoEnabled = true + return + } + // Video is Mac-wide; the mode hand-off is per device (issue #20). + let hasPhysicalDisplay = hasUsablePhysicalDisplayNow() + let transitioning = Set(displayModes.disableVideo(hasUsablePhysicalDisplay: hasPhysicalDisplay)) // Mirror is established through the existing authoritative mode // transition before capture is stopped — this reclaims the virtual // display's system resources by handing input/capture off to a @@ -641,9 +758,8 @@ final class SenderController: ObservableObject { // video back on never restores Extend implicitly (that comment // still applies to the Mirror path below); staying in Extend here // means there is nothing to "restore" — it never left. - if !enabled, mode == .extend, - !MirrorUnavailableOfferPolicy.canEnterMirror(hasUsablePhysicalDisplay: hasUsablePhysicalDisplayNow()) { - let transitioning = sessions + if !MirrorUnavailableOfferPolicy.canEnterMirror(hasUsablePhysicalDisplay: hasPhysicalDisplay) { + let transitioning = sessions.filter { $0.displayAssignment.mode == .extend } guard !transitioning.isEmpty else { videoEnabled = false return @@ -660,27 +776,29 @@ final class SenderController: ObservableObject { } // Mirror is established through the existing authoritative mode // transition before capture is stopped. Turning video back on never - // restores Extend implicitly. - if !enabled, mode == .extend { - suppressModeRestart = true - mode = .mirror - suppressModeRestart = false - let transitioning = sessions - guard !transitioning.isEmpty else { - videoEnabled = false - return - } - var remaining = transitioning.count - for session in transitioning { - session.sender.transitionToMirrorAndDisableVideo { [weak self] in - guard let self else { return } - remaining -= 1 - if remaining == 0 { self.videoEnabled = false } - } + // restores Extend implicitly. The coordinator already moved the + // default (and any device that chose Extend) to Mirror. + suppressModeRestart = true + mode = displayModes.defaultMode + suppressModeRestart = false + let transitioningSessions = sessions.filter { transitioning.contains($0.id) } + for session in sessions { + if let assignment = displayModes.assignment(sessionID: session.id) { + session.displayAssignment = assignment } + } + guard !transitioningSessions.isEmpty else { + videoEnabled = false return } - videoEnabled = enabled + var remaining = transitioningSessions.count + for session in transitioningSessions { + session.sender.transitionToMirrorAndDisableVideo { [weak self] in + guard let self else { return } + remaining -= 1 + if remaining == 0 { self.videoEnabled = false } + } + } } private var browser: NWBrowser? @@ -1586,6 +1704,8 @@ final class SenderController: ObservableObject { QUICCooldownStore.shared.clear(peerID: $0) QUICDiscoveryHintStore.shared.remove(peerID: $0) }) + // Per-device display mode goes with the rest of the device's state. + DeviceDisplayModeStore.remove(peerID: peerID) sessionApprovalPrompt.cancelAll(peerID: peerID) // Forget must take effect immediately for a still-open listener // socket, not just for the next connection this process happens to @@ -2078,13 +2198,30 @@ final class SenderController: ObservableObject { } /// Explicit Connect with a chosen display mode (the Mac-side invitation). - /// Mirror/Extend is Mac-wide here, so a different choice switches the - /// Mac's mode first — through the same guarded transition as the picker. + /// The choice is this device's own (issue #20): remembered for it, and no + /// other connected device changes. func connect(to target: ConnectionTarget, mode requested: CaptureMode) { - if requested != mode, canEnterMode(requested) { - mode = requested + if let live = session(for: target.sessionID) ?? owningSession(for: target), !live.failed { + requestMode(requested, for: live) + return + } + if canEnterMode(requested) { + if let peerID = intendedPeerID(for: target, logicalID: logicalID(for: target)) { + displayModes.setDeviceMode(requested, peerID: peerID) + } else { + displayModes.stage(requested, sessionID: target.sessionID) + } } connect(to: target, userInitiated: true) + displayModes.retain(sessionIDs: Set(sessions.map(\.id))) + } + + /// The receiver's install id when it is known before dialing — the key + /// its per-device settings are stored under. + private func intendedPeerID(for target: ConnectionTarget, logicalID: String) -> String? { + if logicalID.hasPrefix("install:") { return String(logicalID.dropFirst("install:".count)) } + if case .wifi(let result) = target { return txtID(of: result) } + return nil } private func canEnterMode(_ requested: CaptureMode) -> Bool { @@ -2275,12 +2412,16 @@ final class SenderController: ObservableObject { } let name = label(for: target) - let sender = MacSender(transport: transport, name: name, mode: mode, + let intendedPeerID = intendedPeerID(for: target, logicalID: logicalID) + // This device's own mode/source, or the Mac default (issue #20). A + // rebuild finds the assignment it was planned with. + let assignment = displayModes.begin(sessionID: id, peerID: intendedPeerID) + let sender = MacSender(transport: transport, name: name, mode: assignment.mode, quality: effectiveQuality, displaySerial: Self.displaySerial(for: id), identityOffset: identityOffset(for: id), awaitingWake: awaitingWake, videoEnabled: videoEnabled, - mirrorDisplayUUID: mirrorDisplayUUID, + mirrorDisplayUUID: assignment.mirrorDisplayUUID, streamingProfile: effectiveStreamingProfile, customFPS: effectiveCustomFPS, extendShapePreference: extendShapeDefault, @@ -2292,19 +2433,15 @@ final class SenderController: ObservableObject { // recognizes it. let started = SessionStartPlanning.plan( continuation: continuation, initiator: initiator, userInitiated: userInitiated, - needsSenderApproval: needsSenderApproval, mode: mode.receiverMode) + needsSenderApproval: needsSenderApproval, mode: assignment.mode.receiverMode) let invitation = started.invitation let needsApproval = started.needsSenderApproval sender.sessionInvitation = invitation sender.needsSenderApproval = needsApproval - let intendedPeerID: String? = { - if logicalID.hasPrefix("install:") { return String(logicalID.dropFirst("install:".count)) } - if case .wifi(let result) = target { return txtID(of: result) } - return nil - }() let session = DeviceSession(id: id, logicalID: logicalID, intendedPeerID: intendedPeerID, attempt: attempt, - target: target, name: name, sender: sender) + target: target, name: name, sender: sender, + displayAssignment: assignment) if case .wifi(let result) = target { session.wifiServiceName = serviceName(of: result) } @@ -2326,7 +2463,8 @@ final class SenderController: ObservableObject { } sender.onDisplayModeRequest = { [weak self, weak session] requestedMode in guard let self, let session, self.owns(session) else { return } - self.requestMode(CaptureMode(requestedMode)) + // This receiver's own Mirror/Extend control: this device only. + self.requestMode(CaptureMode(requestedMode), for: session) } sender.onAllowInputRequest = { [weak self, weak session] generation in // A request for control, from the connection with this @@ -2363,10 +2501,9 @@ final class SenderController: ObservableObject { } sender.onMirrorDisplayRequest = { [weak self, weak session] requestedUUID in guard let self, let session, self.owns(session) else { return } - // Same authoritative setter the Mac's own MirrorDisplayPickerView - // writes — its didSet persists the choice and restarts capture - // when Mirror is active, so both surfaces stay one source of truth. - self.mirrorDisplayUUID = requestedUUID + // This receiver's own Mirror source: remembered for this device + // and applied to its pipeline only (issue #20). + self.requestMirrorSource(MirrorSourcePreference(displayUUID: requestedUUID), for: session) } sender.onExtendShapeChanged = { [weak session] preference in session?.extendShapePreference = preference @@ -2396,6 +2533,16 @@ final class SenderController: ObservableObject { self.sessionApprovalPrompt.request(approval) } session.receiverProtocolVersion = info.protocolVersion + // A session that started without an install id (an unresolved + // USB device) used the Mac default; now that hello proved who + // this is, apply the device's own choice. Deferred while the + // Mac's approval is pending — `resolveSessionApproval` reconciles. + if let peerID = info.id { + let outcome = self.displayModes.identify(sessionID: session.id, peerID: peerID) + if case .rebuild = outcome, !session.awaitingLocalApproval { + self.reconcileDisplayMode(session) + } + } self.applyReceiverRequestedMode(info.requestedMode, session: session) session.receiverMaxFPS = info.maxFPS if let value = info.trayEnabled { session.receiverTrayEnabled = value } @@ -2634,19 +2781,28 @@ final class SenderController: ObservableObject { // Identity comparison prevents a late callback from an old attempt // removing its newer replacement, which can have the same route id. sessions.removeAll { $0 === session } + // Same rule for its display-mode entry: a replacement with this id + // owns it now. + if !sessions.contains(where: { $0.id == session.id }) { + displayModes.end(sessionID: session.id) + } } private func owns(_ session: DeviceSession) -> Bool { sessions.contains { $0 === session } && autoConnectPolicy.isCurrent(session.attempt) } - /// Mode/quality apply per-pipeline at construction — rebuild every session. + /// Streaming settings apply per-pipeline at construction — rebuild every + /// session. (Mode no longer goes through here: see `rebuild(_:)`.) func restartAll() { guard running else { return } let rebuilds = sessions.map { ($0.target, $0.failed ? nil : continuation(of: $0)) } sessions.forEach { $0.sender.stop() } sessions.removeAll() + // Each device's display-mode entry survives (same session ids), so + // every rebuilt pipeline keeps its own mode. rebuilds.forEach { connect(to: $0.0, continuation: $0.1) } + displayModes.retain(sessionIDs: Set(sessions.map(\.id))) autoConnect() // a rebuilt WiFi session may deserve its cable back } @@ -2770,7 +2926,7 @@ final class SenderController: ObservableObject { .map { session in ActiveDisplayEntry(id: session.id, peerID: session.deviceID, name: session.name, statusText: session.statusWithRoute, route: session.route, - mode: mode, videoActive: session.videoActive, + mode: session.displayAssignment.mode, videoActive: session.videoActive, audioActive: session.audioActive, allowInput: EffectiveInputAuthorization.allowed( masterEnabled: allowInput, sessionGranted: session.sender.hasSessionInputGrant), @@ -2993,8 +3149,9 @@ final class SenderController: ObservableObject { /// A receiver-initiated request named a display mode in its hello. With /// manual approval it only becomes the panel's default (Allow & Mirror / /// Allow & Extend still decide); when auto-approved it is used if this - /// Mac can enter it. Mirror/Extend is Mac-wide, so a different mode - /// rebuilds the pipelines, carrying this session's invitation across. + /// Mac can enter it. Mirror/Extend is per device (issue #20), so a + /// different mode rebuilds this session's pipeline only, carrying its + /// invitation across. private func applyReceiverRequestedMode(_ raw: String?, session: DeviceSession) { guard session.invitation.initiator == .receiver, !session.invitationAdmitted, let requested = SessionModePlanning.requestedMode(raw) else { return } @@ -3002,13 +3159,14 @@ final class SenderController: ObservableObject { sessionApprovalPrompt.updateRequestedMode(id: session.invitation.id, mode: requested) return } + let current = session.displayAssignment.mode let planned = CaptureMode(SessionModePlanning.mode( - senderChoice: nil, receiverRequested: requested, current: mode.receiverMode)) - guard planned != mode, canEnterMode(planned) else { return } + senderChoice: nil, receiverRequested: requested, current: current.receiverMode)) + guard planned != current, canEnterMode(planned) else { return } Log.info("sessionInvite: honoring receiver-requested mode \(planned.rawValue) for \(session.logicalID)") - // `restartAll()` carries the updated invitation to the rebuilt pipeline. + // `rebuild(_:)` carries the updated invitation to the rebuilt pipeline. session.invitation.mode = planned.receiverMode - requestMode(planned) + requestMode(planned, for: session) } /// Cancel on the Mac's waiting state for an invitation (or its own @@ -3048,18 +3206,23 @@ final class SenderController: ObservableObject { } // Allow & Mirror / Allow & Extend override the receiver's request; // Allow Permanently keeps the requested (or current) mode. + let current = session.displayAssignment.mode let requested = CaptureMode(SessionModePlanning.mode( - senderChoice: plan.mode, receiverRequested: approval.mode, current: mode.receiverMode)) - if requested != mode, canEnterMode(requested) { - // Mirror/Extend is Mac-wide: switching rebuilds every pipeline. + senderChoice: plan.mode, receiverRequested: approval.mode, current: current.receiverMode)) + session.awaitingLocalApproval = false + if requested != current, canEnterMode(requested) { + // This device's choice; no other device's pipeline is touched. + _ = displayModes.requestMode(requested, sessionID: session.id, + hasUsablePhysicalDisplay: hasUsablePhysicalDisplayNow()) + } + if let wanted = displayModes.assignment(sessionID: session.id), + !wanted.capturesSame(as: session.displayAssignment) { // Capture has not started for this session, so carry it across // the rebuild already approved. - session.invitation.mode = requested.receiverMode - session.awaitingLocalApproval = false - requestMode(requested) + session.invitation.mode = wanted.mode.receiverMode + Task { @MainActor in self.rebuild(session) } return } - session.awaitingLocalApproval = false session.sender.resolveSenderApproval(accept: true) } diff --git a/Mac/VirtualDisplay.swift b/Mac/VirtualDisplay.swift index 59babfba..213c953c 100644 --- a/Mac/VirtualDisplay.swift +++ b/Mac/VirtualDisplay.swift @@ -41,6 +41,7 @@ final class VirtualDisplay { serialNum: UInt32 = 0x0001, productID: UInt32 = 0x4F53, refreshRate: Int = 60, restoreOrigin: CGPoint? = nil, + owner: ObjectIdentifier? = nil, onOriginChange: ((CGPoint, CGSize) -> Void)? = nil) { self.pointsWide = pointsWide self.pointsHigh = pointsHigh @@ -80,6 +81,12 @@ final class VirtualDisplay { return nil } Log.info("virtual display created: id=\(display.displayID) \(pointsWide)x\(pointsHigh)pt @2x") + // Registered for exactly this object's lifetime (see `deinit`), so + // every teardown path — disconnect, mode switch, rotation rebuild, + // a failed probe — releases the record with the display itself. + VirtualDisplayRegistry.shared.register( + ObjectIdentifier(self), displayID: display.displayID, + identity: VirtualDisplayIdentity(serial: serialNum, productID: productID), owner: owner) // macOS defaults the new display to its 1x mode AND can restore a // stale saved mode for this serial asynchronously, seconds after the @@ -104,6 +111,10 @@ final class VirtualDisplay { } } + deinit { + VirtualDisplayRegistry.shared.unregister(ObjectIdentifier(self)) + } + /// Change orientation without changing the virtual monitor's identity. /// Releasing a CGVirtualDisplay makes WindowServer redistribute every /// window on it before the replacement appears; with multiple devices, diff --git a/MacTests/DisplayModeAssignmentTests.swift b/MacTests/DisplayModeAssignmentTests.swift index aa4c41bf..bd221441 100644 --- a/MacTests/DisplayModeAssignmentTests.swift +++ b/MacTests/DisplayModeAssignmentTests.swift @@ -42,6 +42,8 @@ final class DisplayModeAssignmentTests: XCTestCase { private(set) var builds: [String: Int] = [:] private var nextDisplayID: CGDirectDisplayID = 100 private var owners: [String: NSObject] = [:] + /// Stands in for each live `VirtualDisplay` instance. + private var displays: [String: NSObject] = [:] var hasUsablePhysicalDisplay = true init(defaults: DisplayModeDefaults = .init(mode: .extend, mirrorDisplayUUID: nil), @@ -117,7 +119,9 @@ final class DisplayModeAssignmentTests: XCTestCase { owners[sessionID] = owner let id = nextDisplayID nextDisplayID += 1 - registry.register(displayID: id, + let display = NSObject() + displays[sessionID] = display + registry.register(ObjectIdentifier(display), displayID: id, identity: VirtualDisplayIdentity(serial: id, productID: 0x4F53), owner: ObjectIdentifier(owner)) displayID = id @@ -126,7 +130,7 @@ final class DisplayModeAssignmentTests: XCTestCase { } private func releaseDisplay(_ sessionID: String) { - if let id = pipelines[sessionID]?.virtualDisplayID { registry.unregister(displayID: id) } + if let display = displays.removeValue(forKey: sessionID) { registry.unregister(ObjectIdentifier(display)) } } } @@ -437,6 +441,19 @@ final class DisplayModeAssignmentTests: XCTestCase { XCTAssertNil(store.dictionary(forKey: "displayMode.A"), "nothing left to remember") } + func testFollowDefaultModeKeepsTheDevicesMirrorSource() { + let mac = SyntheticMac(defaults: .init(mode: .mirror, mirrorDisplayUUID: nil), store: store) + mac.connect("a", peer: "A") + _ = mac.requestMode(.extend, "a") + _ = mac.requestMirrorSource(.display(uuid: "STUDIO"), "a") + mac.apply(mac.coordinator.followDefaultMode(sessionID: "a"), "a") + XCTAssertEqual(mac.mode("a"), .mirror) + XCTAssertEqual(mac.pipelines["a"]?.assignment.mirrorDisplayUUID, "STUDIO") + mac.apply(mac.coordinator.requestMirrorSource(nil, sessionID: "a"), "a") + XCTAssertNil(mac.pipelines["a"]?.assignment.mirrorDisplayUUID) + XCTAssertNil(store.dictionary(forKey: "displayMode.A")) + } + // MARK: - Video Off func testVideoOffTransitionsEveryExtendSessionAndRemembersMirror() { @@ -544,17 +561,27 @@ final class DisplayModeAssignmentTests: XCTestCase { func testRegistryTracksOwnershipAndCleanup() { let registry = VirtualDisplayRegistry() - let a = NSObject(), b = NSObject() + let a = NSObject(), b = NSObject(), display = NSObject() let identity = VirtualDisplayIdentity(serial: 7, productID: 0x4F53) - registry.register(displayID: 100, identity: identity, owner: ObjectIdentifier(a)) + registry.register(ObjectIdentifier(display), displayID: 100, identity: identity, owner: ObjectIdentifier(a)) XCTAssertTrue(registry.isClaimed(identity, byOtherThan: ObjectIdentifier(b))) XCTAssertFalse(registry.isClaimed(identity, byOtherThan: ObjectIdentifier(a)), "a session's own display") XCTAssertEqual(registry.displayIDs(ownedBy: ObjectIdentifier(a)), [100]) - registry.unregister(displayID: 100) + registry.unregister(ObjectIdentifier(display)) XCTAssertFalse(registry.isClaimed(identity, byOtherThan: ObjectIdentifier(b))) XCTAssertEqual(registry.count, 0) } + func testLateReleaseOfAnOldDisplayKeepsANewerOneWithTheSameID() { + let registry = VirtualDisplayRegistry() + let old = NSObject(), new = NSObject(), owner = NSObject() + let identity = VirtualDisplayIdentity(serial: 7, productID: 0x4F53) + registry.register(ObjectIdentifier(old), displayID: 100, identity: identity, owner: ObjectIdentifier(owner)) + registry.register(ObjectIdentifier(new), displayID: 100, identity: identity, owner: ObjectIdentifier(owner)) + registry.unregister(ObjectIdentifier(old)) + XCTAssertEqual(registry.displayIDs, [100]) + } + func testIdentityPlannerProbesTheUsualOffsetsWhenNothingCollides() { XCTAssertEqual(VirtualDisplayIdentityPlanner.offsets(serial: 50, baseOffset: 0) { _ in false }, [0, 1, 2]) XCTAssertEqual(VirtualDisplayIdentityPlanner.offsets(serial: 50, baseOffset: 4) { _ in false }, [4, 5, 6]) @@ -576,8 +603,8 @@ final class DisplayModeAssignmentTests: XCTestCase { func testIdentityPlannerAgainstALiveRegistry() { let registry = VirtualDisplayRegistry() - let other = NSObject(), me = NSObject() - registry.register(displayID: 100, identity: VirtualDisplayIdentityPlanner.identity(serial: 50, offset: 0), + let other = NSObject(), me = NSObject(), display = NSObject() + registry.register(ObjectIdentifier(display), displayID: 100, identity: VirtualDisplayIdentityPlanner.identity(serial: 50, offset: 0), owner: ObjectIdentifier(other)) let mine = VirtualDisplayIdentityPlanner.offsets(serial: 50, baseOffset: 0) { registry.isClaimed($0, byOtherThan: ObjectIdentifier(me)) From b48fc0198c34c8c8522e4da089368f42a307a5c2 Mon Sep 17 00:00:00 2001 From: raiseCatError <315733358+raiseCatError@users.noreply.github.com> Date: Sun, 25 Oct 2026 16:53:01 +0530 Subject: [PATCH 27/30] feat(mac): show and change each device's display mode in its settings Add a Display Mode section to a connected device's settings: Mac Default, Extend or Mirror, its own Mirror display (Mac Default, Automatic or a specific display), what it is currently doing, and why a request was refused. The Displays page now presents the mode and Mirror display as defaults for devices without their own choice and lists each connected device's mode when more than one is connected. Refs #20 --- Mac/DisplayModeAssignment.swift | 26 +++++ Mac/DisplaysSettingsView.swift | 38 +++++-- Mac/MacSender.swift | 9 +- Mac/MirrorDisplayPickerView.swift | 2 + Mac/ReceiverDeviceDetailView.swift | 133 ++++++++++++++++++++++ MacTests/DisplayModeAssignmentTests.swift | 12 ++ 6 files changed, 205 insertions(+), 15 deletions(-) diff --git a/Mac/DisplayModeAssignment.swift b/Mac/DisplayModeAssignment.swift index 688e9e32..8d6223f8 100644 --- a/Mac/DisplayModeAssignment.swift +++ b/Mac/DisplayModeAssignment.swift @@ -109,6 +109,32 @@ struct DeviceDisplayAssignment: Equatable { } } +extension CaptureMode { + var title: String { + switch self { + case .mirror: return String(localized: "Mirror", comment: "Display mode: duplicate the Mac's screen onto the device.") + case .extend: return String(localized: "Extend", comment: "Display mode: add the device as a second, separate screen (not the verb \"to extend\").") + } + } +} + +extension DeviceDisplayAssignment { + /// One line for a device's settings: its mode, whether that is its own + /// choice, and in Mirror whether a specific source is selected. + var summary: String { + let origin = modeIsDeviceChoice + ? String(localized: "this device's choice", comment: "Where a device's display mode comes from.") + : String(localized: "Mac default", comment: "Where a device's display mode comes from.") + var parts = ["\(mode.title) (\(origin))"] + if mode == .mirror { + parts.append(mirrorDisplayUUID == nil + ? String(localized: "Automatic display", comment: "Mirror source: chosen automatically.") + : String(localized: "Selected display", comment: "Mirror source: a display picked by the user.")) + } + return parts.joined(separator: " · ") + } +} + enum DeviceDisplayModeResolution { static func resolve(preference: DeviceDisplayModePreference, defaults: DisplayModeDefaults, videoEnabled: Bool) -> DeviceDisplayAssignment { diff --git a/Mac/DisplaysSettingsView.swift b/Mac/DisplaysSettingsView.swift index f72d72b9..05ebdd6d 100644 --- a/Mac/DisplaysSettingsView.swift +++ b/Mac/DisplaysSettingsView.swift @@ -21,22 +21,40 @@ struct DisplaysSettingsView: View { } .pickerStyle(.segmented) } header: { - Text("Display Mode") + Text("Default Display Mode") } footer: { - // Disabling the segment alone still lets a stale keyboard/ - // accessibility selection reach it — `requestMode` is the - // actual authority and rejects Mirror regardless; this is - // just telling the user why. - if !controller.hasUsablePhysicalDisplay { - Text("Mirror requires an active physical display.") - .font(.caption) - .foregroundStyle(.secondary) + VStack(alignment: .leading, spacing: 4) { + Text("Applies to devices that haven't chosen their own mode. Each device can Mirror or Extend on its own — set it in that device's settings, or from the device itself.") + // Disabling the segment alone still lets a stale keyboard/ + // accessibility selection reach it — `requestMode` is the + // actual authority and rejects Mirror regardless; this is + // just telling the user why. + if !controller.hasUsablePhysicalDisplay { + Text("Mirror requires an active physical display.") + } } + .font(.caption) + .foregroundStyle(.secondary) } if controller.mode == .mirror { - Section("Mirror") { + Section { MirrorDisplayPickerView(controller: controller) + } header: { + Text("Default Mirror Display") + } footer: { + Text("Used by mirroring devices that haven't chosen their own display.") + .font(.caption) + .foregroundStyle(.secondary) + } + } + + let connected = controller.activeDisplayEntries + if connected.count > 1 { + Section("Connected Devices") { + ForEach(connected) { entry in + LabeledContent(entry.name, value: entry.mode.title) + } } } diff --git a/Mac/MacSender.swift b/Mac/MacSender.swift index ea26f49f..9e15702c 100644 --- a/Mac/MacSender.swift +++ b/Mac/MacSender.swift @@ -391,9 +391,8 @@ final class MacSender: NSObject, SCStreamOutput, SCStreamDelegate { /// the Mac-authoritative setting and broadcasts it to every session. @MainActor var onVideoEnabledRequest: ((Bool) -> Void)? /// A receiver asked to change the Mirror capture source. Handed to - /// `SenderController`, which owns the canonical `mirrorDisplayUUID` - /// setting (the same one its own Settings picker writes) and the - /// existing session-rebuild path that applies it. `nil` requests Auto. + /// `SenderController`, which remembers it as THIS device's own source + /// (issue #20) and rebuilds this session only. `nil` requests Auto. @MainActor var onMirrorDisplayRequest: ((String?) -> Void)? /// Fires whenever this peer's Extend shape preference changes — from a /// receiver's `extendShapeRequest` or from `requestExtendShape` (the @@ -5221,8 +5220,8 @@ final class MacSender: NSObject, SCStreamOutput, SCStreamDelegate { self.onStreamingPriorityRequest?(priority) } case WireMessage.mirrorDisplayRequest: - // Like every Mac-wide request, reached only for an admitted - // connection (see the gate at the top of `handleControl`). + // Like every controller-handled request, reached only for an + // admitted connection (see the gate at the top of `handleControl`). guard let info = lastHello, info.protocolVersion >= WireProtocol.mirrorDisplayWireVersion else { return } let requestedUUID = obj["selectedUUID"] as? String diff --git a/Mac/MirrorDisplayPickerView.swift b/Mac/MirrorDisplayPickerView.swift index b1c92896..5dfe822b 100644 --- a/Mac/MirrorDisplayPickerView.swift +++ b/Mac/MirrorDisplayPickerView.swift @@ -2,6 +2,8 @@ import SwiftUI /// Only meaningful for Mirror mode — Extend always uses MEOW's own virtual /// display, so this view only ever appears when `controller.mode == .mirror`. +/// It edits the Mac DEFAULT source; a device's own source is picked in its +/// device settings (`ReceiverDeviceDetailView`, issue #20). struct MirrorDisplayPickerView: View { @ObservedObject var controller: SenderController @State private var candidates: [MirrorDisplayCandidate] = [] diff --git a/Mac/ReceiverDeviceDetailView.swift b/Mac/ReceiverDeviceDetailView.swift index 434ced25..d5cac6f9 100644 --- a/Mac/ReceiverDeviceDetailView.swift +++ b/Mac/ReceiverDeviceDetailView.swift @@ -44,6 +44,7 @@ struct ReceiverDeviceDetailView: View { } if let session { + displayModeSection(session) extendShapeSection(session) maxFPSSection(session) } @@ -160,6 +161,54 @@ struct ReceiverDeviceDetailView: View { } } + /// This device's own Mirror/Extend mode and Mirror source (issue #20). + /// Mac-authoritative and Mac-local like Extend shape; changing it + /// rebuilds this device's stream only — other connected devices keep + /// running untouched. + @ViewBuilder + private func displayModeSection(_ session: DeviceSession) -> some View { + let preference = controller.displayModePreference(for: session) + Section { + Picker("Mode", selection: Binding( + get: { DeviceModeChoice(preference.mode) }, + set: { choice in + if let mode = choice.mode { + controller.requestMode(mode, for: session) + } else { + controller.followDefaultMode(for: session) + } + })) { + Text("Mac Default (\(controller.mode.title))", + comment: "Per-device display mode option: follow the Mac's default mode, named in parentheses.") + .tag(DeviceModeChoice.macDefault) + Text("Extend", comment: "Display mode: add the device as a second, separate screen (not the verb \"to extend\").") + .tag(DeviceModeChoice.extend) + .disabled(!controller.videoEnabled) + Text("Mirror", comment: "Display mode: duplicate the Mac's screen onto the device.") + .tag(DeviceModeChoice.mirror) + .disabled(!controller.hasUsablePhysicalDisplay) + } + if session.displayAssignment.mode == .mirror { + DeviceMirrorSourcePicker( + choice: preference.mirrorSource, + defaultUUID: controller.mirrorDisplayUUID, + onChange: { controller.requestMirrorSource($0, for: session) }) + } + LabeledContent("Currently", value: session.displayAssignment.summary) + if let notice = session.displayModeNotice { + Text(notice) + .font(.caption) + .foregroundStyle(.orange) + } + } header: { + Text("Display Mode") + } footer: { + Text("Only affects this device. Changing it restarts this device's stream; other connected devices keep running.") + .font(.caption) + .foregroundStyle(.secondary) + } + } + /// This device's own Extend shape (PROTOCOL.md 6.7) — Mac-authoritative, /// unlike the receiver-reported sections below: `session.sender` is the /// live source of truth and this control writes straight through it via @@ -444,3 +493,87 @@ struct ReceiverDeviceDetailView: View { } } } + +/// The per-device Mode picker's three choices. +private enum DeviceModeChoice: Hashable { + case macDefault + case extend + case mirror + + init(_ mode: CaptureMode?) { + switch mode { + case nil: self = .macDefault + case .extend: self = .extend + case .mirror: self = .mirror + } + } + + var mode: CaptureMode? { + switch self { + case .macDefault: return nil + case .extend: return .extend + case .mirror: return .mirror + } + } +} + +/// This device's own Mirror source: the Mac's default source, Automatic, +/// or a specific display — including another device's extended desktop. +private struct DeviceMirrorSourcePicker: View { + let choice: MirrorSourcePreference? + let defaultUUID: String? + let onChange: (MirrorSourcePreference?) -> Void + @State private var candidates: [MirrorDisplayCandidate] = [] + @State private var loaded = false + + private static let macDefaultTag = "\u{0}default" + private static let automaticTag = "\u{0}automatic" + + private var tag: String { + switch choice { + case nil: return Self.macDefaultTag + case .automatic: return Self.automaticTag + case .display(let uuid): return uuid + } + } + + /// The display this device would mirror if it isn't using Automatic. + private var explicitUUID: String? { choice.map(\.displayUUID) ?? defaultUUID } + + var body: some View { + VStack(alignment: .leading, spacing: 4) { + Picker("Mirror Display", selection: Binding( + // A remembered display that is disconnected has no tag; it + // shows as Automatic, which is what capture uses meanwhile. + // The stored choice is kept. + get: { + PickerSelection.valid(tag, among: [Self.macDefaultTag, Self.automaticTag] + + candidates.compactMap(\.persistentID), fallback: Self.automaticTag) + }, + set: { value in + switch value { + case Self.macDefaultTag: onChange(nil) + case Self.automaticTag: onChange(.automatic) + default: onChange(.display(uuid: value)) + } + })) { + Text("Mac Default").tag(Self.macDefaultTag) + Text("Automatic").tag(Self.automaticTag) + ForEach(candidates) { candidate in + Text(candidate.label) + .tag(candidate.persistentID ?? "") + .disabled(candidate.persistentID == nil) + } + } + if let explicitUUID, loaded, !candidates.contains(where: { $0.persistentID == explicitUUID }) { + Text("Selected display isn't currently available — using Automatic until it reconnects.") + .font(.caption) + .foregroundStyle(.orange) + } + } + .task { + candidates = await MirrorDisplayCandidate.listCandidates() + loaded = true + } + } +} diff --git a/MacTests/DisplayModeAssignmentTests.swift b/MacTests/DisplayModeAssignmentTests.swift index bd221441..9024e52e 100644 --- a/MacTests/DisplayModeAssignmentTests.swift +++ b/MacTests/DisplayModeAssignmentTests.swift @@ -483,6 +483,18 @@ final class DisplayModeAssignmentTests: XCTestCase { XCTAssertEqual(coordinator.assignment(sessionID: "a")?.mode, .extend) } + func testSummaryNamesTheModeItsOriginAndTheMirrorSource() { + XCTAssertEqual(DeviceDisplayAssignment(mode: .extend, mirrorDisplayUUID: "X", modeIsDeviceChoice: false, + mirrorSourceIsDeviceChoice: false).summary, + "Extend (Mac default)") + XCTAssertEqual(DeviceDisplayAssignment(mode: .mirror, mirrorDisplayUUID: nil, modeIsDeviceChoice: true, + mirrorSourceIsDeviceChoice: false).summary, + "Mirror (this device's choice) · Automatic display") + XCTAssertEqual(DeviceDisplayAssignment(mode: .mirror, mirrorDisplayUUID: "X", modeIsDeviceChoice: false, + mirrorSourceIsDeviceChoice: true).summary, + "Mirror (Mac default) · Selected display") + } + // MARK: - Persistence func testStoreRoundTripsEveryShape() { From 4f4fbb976514fcd40dd37a6503eb011e09c951cc Mon Sep 17 00:00:00 2001 From: raiseCatError <315733358+raiseCatError@users.noreply.github.com> Date: Sun, 25 Oct 2026 16:54:46 +0530 Subject: [PATCH 28/30] fix(mac): keep the refused-Mirror notice true for a device already mirroring Refs #20 --- Mac/OpenSidecarMacApp.swift | 2 +- MacTests/DisplayModeAssignmentTests.swift | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/Mac/OpenSidecarMacApp.swift b/Mac/OpenSidecarMacApp.swift index 9c7428dc..456b0f62 100644 --- a/Mac/OpenSidecarMacApp.swift +++ b/Mac/OpenSidecarMacApp.swift @@ -678,7 +678,7 @@ final class SenderController: ObservableObject { case .rejected(let rejection): Log.info("routeDebug: rejected \(session.id) mode request — \(rejection)") session.displayModeNotice = switch rejection { - case .noPhysicalDisplay: String(localized: "Mirror needs an active physical display. This device stays in Extend.") + case .noPhysicalDisplay: String(localized: "Mirror needs an active physical display. This device keeps its current mode.") case .videoOff: String(localized: "Extend needs video. Turn video back on to extend onto this device.") case .unknownSession: nil } diff --git a/MacTests/DisplayModeAssignmentTests.swift b/MacTests/DisplayModeAssignmentTests.swift index 9024e52e..9743cc65 100644 --- a/MacTests/DisplayModeAssignmentTests.swift +++ b/MacTests/DisplayModeAssignmentTests.swift @@ -9,8 +9,8 @@ import XCTest /// a session — with synthetic receivers and fake display IDs standing in for /// real devices and `CGVirtualDisplay`s. It proves the decision logic for the /// combinations below; it does not prove real WindowServer, ScreenCaptureKit -/// or multi-device hardware behavior (see docs/PER_DEVICE_DISPLAY_MODE.md's -/// physical checklist for that). +/// or multi-device hardware behavior (see MULTI_DEVICE.md's physical +/// checklist for that). final class DisplayModeAssignmentTests: XCTestCase { private var store: UserDefaults! private var suiteName: String! From 66de410ecdeddf428f7a279d85089441a0880d46 Mon Sep 17 00:00:00 2001 From: raiseCatError <315733358+raiseCatError@users.noreply.github.com> Date: Sun, 25 Oct 2026 16:54:46 +0530 Subject: [PATCH 29/30] docs: describe per-device Mirror/Extend and its physical test checklist Refs #20 --- ARCHITECTURE.md | 7 +++ MULTI_DEVICE.md | 138 ++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 145 insertions(+) create mode 100644 MULTI_DEVICE.md diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 39acbe60..36ec60ff 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -64,6 +64,7 @@ compatibility; the product name is MeowDisplay. `src/`, `public/` and | Capture (ScreenCaptureKit) and its lifecycle/recovery | `Mac/MacSender.swift`, `Mac/CaptureLifecycle.swift`, `Mac/CaptureOwnership.swift`, `Mac/MacSenderPipelineState.swift` | | Virtual display for Extend (private `CGVirtualDisplay` SPI, isolated here) | `Mac/VirtualDisplay.swift`, `Mac/DisplayArrangement.swift` | | Mirror source selection | `Mac/MirrorDisplaySelection.swift` | +| Per-device Mirror/Extend, live virtual displays ([MULTI_DEVICE.md](MULTI_DEVICE.md)) | `Mac/DisplayModeAssignment.swift` | | Video encode (VideoToolbox) | `Mac/MacSenderVideoEncoder.swift` | | System audio capture + AAC encode | `Mac/AudioCaptureEncoder.swift` | | Input injection and targeting | `Mac/InputInjector.swift`, `Mac/InputRouting.swift`, `Mac/SystemGestureInvoker.swift` | @@ -142,6 +143,11 @@ framed media over the active route ─────────────── private `CGVirtualDisplay` SPI is used. - Capture lifecycle and transport lifecycle are related but deliberately separate: a route change or reconnect should not have to rebuild capture. +- Mirror/Extend is chosen **per device**: each connected device has its own + pipeline, and `DisplayModeCoordinator` decides each one's mode and Mirror + source and which single session a change rebuilds. The Mac-wide settings + are defaults for devices without their own choice. See + [MULTI_DEVICE.md](MULTI_DEVICE.md). ## Input pipeline @@ -377,3 +383,4 @@ hints and an in-memory cooldown. USB never uses QUIC. - [SECURITY.md](SECURITY.md) — security model and vulnerability reporting - [SUPPORT.md](SUPPORT.md) — where to get help - [TECHNICAL_NOTES.md](TECHNICAL_NOTES.md) — platform constraints behind the design +- [MULTI_DEVICE.md](MULTI_DEVICE.md) — per-device Mirror/Extend and its physical test checklist diff --git a/MULTI_DEVICE.md b/MULTI_DEVICE.md new file mode 100644 index 00000000..6bfc4ba6 --- /dev/null +++ b/MULTI_DEVICE.md @@ -0,0 +1,138 @@ +# Multiple devices: per-device Mirror/Extend + +Each connected iPhone, iPad or receiving Mac chooses its own display mode +([issue #20](https://github.com/raiseCatError/meowdisplay/issues/20)). One +device can mirror the Mac's built-in display while two iPads extend it as two +separate desktops, and changing one device never changes or restarts the +others. + +## How it works + +Every connected device already had its own `MacSender` pipeline: its own +virtual display (Extend), ScreenCaptureKit stream, encoder and connection. +What made devices share a mode was the Mac-wide `mode` and +`mirrorDisplayUUID` settings that every pipeline was built from, plus +`restartAll()`, which rebuilt every device whenever either changed. + +| Piece | Where | Role | +| --- | --- | --- | +| Per-device choice | `DeviceDisplayModeStore` | Mode and Mirror source per receiver install id (`displayMode.` in the Mac's defaults). Unset fields follow the Mac default. | +| Mac defaults | `SenderController.mode`, `.mirrorDisplayUUID` | The pre-#20 global settings, same storage, now the default for devices without their own choice. | +| Authority | `DisplayModeCoordinator` | Resolves each live session's assignment and names exactly the sessions a change must rebuild. | +| Live assignment | `DeviceSession.displayAssignment` | What that session's pipeline was actually built with. | +| Rebuild | `SenderController.rebuild(_:)` | `restartAll()` for one session: same teardown, same carried invitation. | +| Mirror source | `MirrorSourceResolver` | Automatic, capture recovery and wake recovery skip MeowDisplay virtual displays. | +| Live virtual displays | `VirtualDisplayRegistry` | Every MeowDisplay virtual display alive in this process and its owning session; `VirtualDisplay` registers for its own lifetime. | +| Display identity | `VirtualDisplayIdentityPlanner` | An Extend display skips a serial/product identity another live session holds. | + +All of these except the controller and views are Foundation/CoreGraphics +only and run in the hostless `OpenSidecarMacTests` bundle. + +### What changes what + +| Event | Effect | +| --- | --- | +| Displays → Default Display Mode / Default Mirror Display | Rebuilds only devices that follow the default and now capture differently. | +| Device settings → Display Mode / Mirror Display | That device only; remembered for it. **Mac Default** returns it to following the default. | +| The receiver's own Mirror/Extend or Mirror display control | That device only; remembered for it. | +| Connect with Mirror / Connect with Extend | That device only; remembered for it. | +| Session approval (Allow & Mirror / Allow & Extend) | That device only. | +| Video Off (Mac-wide) | With a physical display: every Extend device moves to Mirror in place (no reconnect), the default becomes Mirror, and a device whose own choice was Extend now remembers Mirror. Video On never restores Extend. Headless: nothing changes mode. | +| Streaming profile, priority, codec, quality | Still Mac-wide `restartAll()`; each device keeps its own mode across it. | +| Forget | Removes the device's display mode with its other per-device state. | + +A rejected request (Mirror without a usable physical display, Extend while +video is off) leaves that device as it was, tells only that receiver, and +shows the reason in the device's settings. + +### Mirror source and lost displays + +- **Automatic** is the first capturable display that is not a MeowDisplay + virtual display. Before #20 it was simply the first display, which with an + Extend device connected could be that device's desktop. With no + MeowDisplay displays present, behavior is unchanged. +- An explicit pick of another device's extended desktop is honored, so a + phone can deliberately mirror an iPad's Extend desktop. +- If the chosen display is missing at start, the device mirrors Automatic and + keeps its choice; the picker says so. If the mirrored display disappears + mid-session, capture recovery moves to Automatic under the same exclusion. + If only MeowDisplay displays remain, Mirror fails with the existing + "No display available to mirror" error instead of mirroring another device. + +### No protocol change + +`displayModeRequest`, `displayModeState`, `mirrorDisplayRequest` and +`mirrorDisplayState` were already per connection. Existing iPhone, iPad and +Mac receivers work unchanged. Their mode control now affects only themselves. + +### Limits + +- macOS decides how many virtual displays and simultaneous captures the Mac + can sustain. MeowDisplay sets no limit of its own and has not measured one. +- A device without a known install id when it connects (a USB device whose + install id isn't resolved yet) starts with the Mac default. Its own choice + is applied by rebuilding that session once its hello authenticates. +- Changing a device's mode restarts that device's stream (a short reconnect) + and, like any new connection, its input grant: it must request control + again. Other devices are not touched. + +## Automated coverage + +`MacTests/DisplayModeAssignmentTests.swift` drives the real coordinator and +registry with synthetic receivers: + +- Mirror + Extend, Extend + Extend, Mirror + Mirror (different sources) +- three receivers (the issue's table) and five in every mix +- changing one device while others are active; default changes reaching only + followers +- interleaved connects and disconnects with no leaked entries or displays; + a receiver crash mid-switch; route changes; sender restart from the store +- a USB session identified late; staged one-off choices +- rejections (no physical display, Video Off, ended session) +- Video Off and Video On semantics +- Automatic exclusion, explicit pick of a MeowDisplay display, a lost + selected display, recovery after the mirrored display is unplugged +- identity collisions and registry ownership, including a late release of an + old display +- input from three receivers landing in three different display rectangles + +These prove the decision logic. They do not prove WindowServer, +ScreenCaptureKit or multi-device hardware behavior. + +## Physical test checklist + +Needs one Mac with a built-in display (plus an external display for the +Mirror-source rows), an iPhone and two iPads (or an iPad and a receiving Mac), +all paired. Run on a build from this branch. + +1. **Single device, unchanged.** One iPhone: switch Mirror ⇄ Extend from the + Displays page and from the iPhone. Both work as in 1.20.0. +2. **Mirror + Extend.** iPad A Extend, then iPhone → Mirror from the iPhone. + iPad A keeps its desktop and windows, no reconnect. iPhone shows the + built-in display, not iPad A's desktop. +3. **Extend + Extend.** iPad A and iPad B in Extend: System Settings → + Displays shows two separate MeowDisplay displays; arrange them apart; drag + a window onto each. +4. **The issue's table.** iPad A Extend · iPhone Mirror · iPad B Extend at + once. Touch on each device lands on its own screen. +5. **Switch one while others stream.** Change iPad B to Mirror from its device + settings: only iPad B reconnects; iPad A's display, window placement and + input grant survive. +6. **Mirror + Mirror.** With an external display: iPhone mirrors built-in, + iPad mirrors the external display (each from its device settings). +7. **Lost display.** While a device mirrors the external display, unplug it: + that device moves to the built-in display (not an iPad's desktop); the + others are unaffected. Plug it back and reselect. +8. **Defaults.** Set Default Display Mode to Mirror: only devices on + **Mac Default** switch. +9. **Reconnect and restart.** Disconnect and reconnect each device, then quit + and relaunch the Mac app: every device returns in its own mode, and + System Settings → Displays shows no leftover MeowDisplay displays after + all devices disconnect. +10. **Video Off.** With two Extend devices, turn Video Off: both move to + Mirror without reconnecting. Turn it back on: both stay in Mirror. +11. **Headless.** On a Mac with no active physical display (for example a + Mac mini with its monitor unplugged) and one device extending: a Mirror + request from that device is refused with a message on that device only. +12. **Forget.** Forget a device with its own mode, pair again: it follows the + Mac default. From 9512ef291e9b96e303d257b3648a0f825b338dd6 Mon Sep 17 00:00:00 2001 From: raiseCatError <315733358+raiseCatError@users.noreply.github.com> Date: Mon, 26 Oct 2026 12:52:10 +0530 Subject: [PATCH 30/30] fix(ios): build the edge-layout touch region from typed pieces Xcode 26.6's type checker gives up on the Strip/Overlay hit region in edgeLayoutContent (ReceiverControls.swift:1426): two `+` concatenations over an untyped ternary, `Optional.map { [...] } ?? []` and a `??` chain make the solver try every `+` overload together with each `??` and literal choice. Built as three [CGRect] lets ahead of the ZStack, the same rects, in the same order, reach ControlRegionShape. (cherry picked from commit 18bc616166ba3ea87781dce0873efac3cc12d2af) --- iOS/ReceiverControls.swift | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/iOS/ReceiverControls.swift b/iOS/ReceiverControls.swift index ead48fe3..f8ff3f0d 100644 --- a/iOS/ReceiverControls.swift +++ b/iOS/ReceiverControls.swift @@ -1364,6 +1364,12 @@ extension ReceiverControlOverlay { .map { PadEdgeGeometry.circleRun(of: $0, metrics: metrics).insetBy(dx: -Self.trayPadding, dy: -Self.trayPadding) } let occupied = layout.mainSegments.map(\.frame) + layout.functionSegments.map(\.frame) + [gearFrame, paletteFrame].compactMap { $0 } + // The touch region, built from typed pieces: as one `+`/`??` chain + // inside the ZStack it exceeded Xcode 26's type-checking budget. + let mainRegion: [CGRect] = autoHiding ? [] : mainControls.filter { !$0.kind.isOneTapAction } + .map { $0.frame.insetBy(dx: -4, dy: -4) } + let gearRegion: [CGRect] = gearFrame.map { [$0.insetBy(dx: -4, dy: -4)] } ?? [] + let paletteRegion: [CGRect] = wheel.map { [$0.frame.insetBy(dx: -8, dy: -8)] } ?? paletteFrame.map { [$0] } ?? [] ZStack(alignment: .topLeading) { ForEach(Array(layout.strips.enumerated()), id: \.offset) { _, strip in @@ -1425,11 +1431,7 @@ extension ReceiverControlOverlay { // outside this region and take their own taps. Color.clear .frame(width: containerSize.width, height: containerSize.height) - .contentShape(ControlRegionShape( - rects: (autoHiding ? [] : mainControls.filter { !$0.kind.isOneTapAction } - .map { $0.frame.insetBy(dx: -4, dy: -4) }) - + (gearFrame.map { [$0.insetBy(dx: -4, dy: -4)] } ?? []) - + (wheel.map { [$0.frame.insetBy(dx: -8, dy: -8)] } ?? paletteFrame.map { [$0] } ?? []))) + .contentShape(ControlRegionShape(rects: mainRegion + gearRegion + paletteRegion)) .gesture(controlGesture()) } .onAppear {