Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,9 +43,9 @@ tracked — edit `project.yml`, never the generated project.

| Target | Sources | Role |
| --- | --- | --- |
| `OpenSidecarMac` | `Mac/`, `Shared/` | Mac Sender app |
| `OpenSidecarMac` | `Mac/`, `Shared/`, `MacReceiver/` (minus its entry point and resources) | MeowDisplay: the sender, plus Use This Mac as a Display ([UNIFIED_MAC_APP.md](UNIFIED_MAC_APP.md)) |
| `OpenSidecariOS` | `iOS/`, `Shared/` (bundles `iOS/PrivacyInfo.xcprivacy`) | iPhone/iPad Receiver app |
| `OpenSidecarMacReceiver` | `MacReceiver/`, `Shared/`, a few `Mac/` utilities | Mac Receiver app |
| `OpenSidecarMacReceiver` | `MacReceiver/`, `Shared/`, a few `Mac/` utilities | Standalone Mac Receiver app, for macOS 12–13 and existing users |
| `OpenSidecarMacTests` | `MacTests/` plus selected pure-logic files | Hostless unit tests (run by `.github/workflows/tests.yml`) |

The `OpenSidecar*` names are inherited from OpenDisplay and are kept for
Expand Down Expand Up @@ -389,3 +389,4 @@ hints and an in-memory cooldown. USB never uses QUIC.
- [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
- [RECEIVER_INPUT.md](RECEIVER_INPUT.md) — Mac Receiver keyboard and trackpad input, and its physical test checklist
- [UNIFIED_MAC_APP.md](UNIFIED_MAC_APP.md) — one Mac app for both roles: design, migration and its physical test checklist
173 changes: 173 additions & 0 deletions Mac/DisplayRoleSection.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,173 @@
import SwiftUI

/// "Use This Mac as a Display": the unified app's receiver role, one switch
/// away from the sender. Off by default; while on, this Mac shares nothing
/// and waits for another Mac to connect to it as a display.
struct DisplayRoleSection: View {
@ObservedObject var controller: SenderController
/// Overview's link to the "This Mac as a Display" settings page.
var onOpenSettings: (() -> Void)?
@ObservedObject private var receiver = ReceiverController.shared

var body: some View {
Section {
Toggle("Use This Mac as a Display", isOn: Binding(
get: { controller.receiverModeEnabled },
set: { controller.setReceiverModeEnabled($0) }))
if controller.receiverModeEnabled {
if receiver.statusNeedsAttention {
// What to quit or fix; the role starts by itself
// afterwards, except after a lock problem.
Text(receiver.statusTitle)
.font(.caption)
.foregroundStyle(receiver.statusColor)
if receiver.lockProblem != nil {
Button("Try Again") { receiver.start() }
.controlSize(.small)
}
} else {
LabeledContent("Status", value: receiver.statusTitle)
}
}
if receiver.streaming || onOpenSettings != nil {
HStack {
if receiver.streaming {
Button("Show Window") { receiver.showWindow() }
.controlSize(.small)
}
if let onOpenSettings {
Button("Display Settings…", action: onOpenSettings)
.controlSize(.small)
}
Spacer()
}
}
} header: {
Text("This Mac as a Display")
} footer: {
Text(controller.receiverModeEnabled
? "Another Mac can connect and use this Mac as a display, with this Mac's keyboard and trackpad once it allows control. Sharing this Mac to your devices is paused until you turn this off."
: "Let another Mac use this Mac as a display. Sharing this Mac to your devices pauses while it's on.")
.font(.caption)
.foregroundStyle(.secondary)
}
}
}

/// "This Mac as a Display" in MeowDisplay's Settings: the receiver role's
/// own settings, on one page apart from the sender's. Built from the
/// standalone receiver's sections, so both manage the role identically:
/// status, name, the sending Macs paired with this role (Automatically Allow
/// Connections, each Mac's Connection Requests, Connect, Forget) and audio
/// with A/V Sync. Everything here reads and writes the receiver role's own
/// trust and settings (`TrustStore.receiverRole`, `RolePreferences.receiverRole`).
struct DisplayRoleSettingsView: View {
@ObservedObject var controller: SenderController
@ObservedObject private var receiverController = ReceiverController.shared

var body: some View {
Form {
DisplayRoleSection(controller: controller)

Section {
ReceiverNameField { receiverController.setAdvertisedName($0) }
} header: {
Text("Name")
} footer: {
DisplayRoleCaption("How this Mac appears in the sending Mac's Devices list.")
}

if let receiver = receiverController.receiver, let wakeConnect = receiverController.wakeConnect {
ReceiverPairedMacsSection(receiver: receiver, wakeConnect: wakeConnect)
ReceiverAudioSection(receiver: receiver, controller: receiverController)
} else {
DisplayRoleStoredMacsSection()
}
}
.formStyle(.grouped)
}
}

/// The display role's paired Macs while the role is off: still listed and
/// forgettable, so trust can be revoked without first putting this Mac on
/// the network as a display. Nothing is live, so Forget is the stored part
/// of the receiver's Forget (`StreamReceiver.removeStoredTrust`).
private struct DisplayRoleStoredMacsSection: View {
@State private var forgetConfirmation = PeerForgetPrompt()
@State private var trustRefresh = 0

private var pairedMacs: [(peerID: String, displayName: String)] {
_ = trustRefresh
return TrustStore.receiverRole.pinnedPeers()
}

var body: some View {
Section {
if pairedMacs.isEmpty {
DisplayRoleCaption("No paired Macs")
} else {
AutomaticallyAllowConnectionsToggle()
ForEach(pairedMacs, id: \.peerID) { mac in
HStack {
Text(verbatim: mac.displayName)
Spacer()
Button("Forget…", role: .destructive) {
forgetConfirmation.request(peerID: mac.peerID, name: mac.displayName)
}
.controlSize(.small)
}
}
}
} header: {
Text("Paired Macs")
} footer: {
DisplayRoleCaption("Macs paired with this Mac as a display — separate from the devices this Mac shares to. Turn on Use This Mac as a Display to connect, pair, set each Mac's connection requests, or change audio.")
}
.alert(Text("Forget “\(forgetConfirmation.candidate?.name ?? String(localized: "This Mac"))”?"),
isPresented: Binding(get: { forgetConfirmation.isPresented },
set: { if !$0 { forgetConfirmation.cancel() } })) {
Button("Forget", role: .destructive) {
forgetConfirmation.confirm { peerID in
StreamReceiver.removeStoredTrust(peerID: peerID)
trustRefresh &+= 1
}
}
Button("Cancel", role: .cancel) { forgetConfirmation.cancel() }
} message: {
Text("That Mac will need to pair with this Mac again before using it as a display.")
}
}
}

private struct DisplayRoleCaption: View {
let text: LocalizedStringKey

init(_ text: LocalizedStringKey) { self.text = text }

var body: some View {
Text(text)
.font(.caption)
.foregroundStyle(.secondary)
}
}

/// The same switch in the menu bar's quick view.
struct DisplayRoleMenuToggle: View {
@ObservedObject var controller: SenderController
@ObservedObject private var receiver = ReceiverController.shared

var body: some View {
VStack(alignment: .leading, spacing: 2) {
Toggle("Use This Mac as a Display", isOn: Binding(
get: { controller.receiverModeEnabled },
set: { controller.setReceiverModeEnabled($0) }))
.toggleStyle(.switch)
.controlSize(.small)
if controller.receiverModeEnabled {
Text(receiver.statusTitle)
.font(.caption)
.foregroundStyle(.secondary)
}
}
}
}
84 changes: 84 additions & 0 deletions Mac/MacAppRole.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
import Foundation

/// The unified MeowDisplay app's two roles: Share This Mac (the sender, as
/// it always was) and Use This Mac as a Display (the Mac Receiver, hosted in
/// the same bundle). Exactly one has live sessions at a time: while this Mac
/// is a display, the sender starts no sessions — no auto-connect, Connect,
/// receiver connect request or Remote knock — and it resumes when the
/// display role is turned off. That rules out a Mac streaming to itself, two
/// Macs streaming into each other, and input looping between them, without
/// tearing down the sender's listeners or pairing.
struct MacAppRoleState: Equatable {
enum Effect: Equatable {
/// End every sender session without suppressing auto-reconnect, so
/// the devices come back by themselves when the role switches back.
case endSenderSessions
/// Stop advertising this Mac as a Mac to pair with and connect to
/// (`_opendisplay-mac-pair._tcp`): while it's a display, other
/// receivers shouldn't list it as a Mac that will share with them.
case withdrawSenderAdvertisement
case startReceiver
/// Stop the receiver, sending `closing` to a connected sender.
case stopReceiver
/// Advertise the sender again.
case advertiseSender
/// Let the sender reconnect its devices.
case resumeSender
}

static let defaultsKey = "receiverModeEnabled"

private(set) var receiverEnabled: Bool

init(receiverEnabled: Bool) {
self.receiverEnabled = receiverEnabled
}

init(defaults: UserDefaults) {
receiverEnabled = defaults.bool(forKey: Self.defaultsKey)
}

/// Whether the sender may start (or keep) a session.
var senderMayConnect: Bool { !receiverEnabled }

/// What launching does: the display role comes back only if the user
/// left it on. Off by default — a sender install never advertises a
/// receiver it didn't ask for.
var launchEffects: [Effect] { receiverEnabled ? [.withdrawSenderAdvertisement, .startReceiver] : [] }

mutating func setReceiverEnabled(_ enabled: Bool, defaults: UserDefaults) -> [Effect] {
guard enabled != receiverEnabled else { return [] }
receiverEnabled = enabled
defaults.set(enabled, forKey: Self.defaultsKey)
return enabled
? [.endSenderSessions, .withdrawSenderAdvertisement, .startReceiver]
: [.stopReceiver, .advertiseSender, .resumeSender]
}
}

/// The standalone MeowDisplay Receiver (still shipped for macOS 12–13, and
/// installed by existing users) listens on the same ports and advertises the
/// same services. Current versions take `ReceiverOwnershipLock` like the
/// display role does; already-released builds don't, so while any version
/// runs the display role waits (`ReceiverController.otherReceiverRunning`)
/// rather than fail to bind next to it.
enum StandaloneReceiverConflict {
static let bundleIdentifiers: Set<String> = [
"com.raisecaterror.meowdisplay.mac.receiver",
"com.raisecaterror.meowdisplay.mac.receiver.debug",
]

static func isRunning(among runningBundleIdentifiers: [String?]) -> Bool {
runningBundleIdentifiers.contains { $0.map(bundleIdentifiers.contains) ?? false }
}
}

/// The unified app is a menu-bar (accessory) app unless the user chose a
/// Dock icon. While the display role's video window is up it is a regular
/// app, so it has a Dock icon and Command-Tab can leave the full-screen
/// display; changing the presentation setting meanwhile must not undo that.
enum MacAppActivationPolicy {
static func isRegular(showsDockIcon: Bool, displayWindowVisible: Bool) -> Bool {
showsDockIcon || displayWindowVisible
}
}
9 changes: 8 additions & 1 deletion Mac/MacSettingsView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -437,7 +437,8 @@ struct MacSettingsDetailView: View {
permissions: permissions,
onOpenDisplays: { navigationModel.navigateTo(.displays) },
onOpenDevices: { navigationModel.navigateTo(.devices) },
onOpenSystem: { navigationModel.navigateTo(.system) }
onOpenSystem: { navigationModel.navigateTo(.system) },
onOpenDisplayRole: { navigationModel.navigateTo(.displayRole) }
)
case .displays:
DisplaysSettingsView(controller: controller)
Expand All @@ -449,6 +450,8 @@ struct MacSettingsDetailView: View {
DevicesSettingsView(controller: controller)
case .remoteAccess:
RemoteAccessSettingsView(controller: controller)
case .displayRole:
DisplayRoleSettingsView(controller: controller)
case .system:
SystemSettingsView(controller: controller, permissions: permissions, updater: updater)
#if DEBUG
Expand All @@ -465,6 +468,10 @@ struct MacSettingsDetailView: View {
.id(SettingsDetailIdentity(category: navigationModel.current, rootGeneration: navigationModel.rootGeneration))
.frame(minWidth: 500, minHeight: 450)
.background(DetailNavigationKeyboardShortcuts(navigationModel: navigationModel))
// "Use This Mac as a Display": the sending Mac is headless and Mirror
// was asked for. Shown on whichever page is open (onNeedsAttention
// brings this window up), as the standalone receiver does.
.modifier(MirrorUnavailableOfferAlert(controller: ReceiverController.shared))
}
}

Expand Down
2 changes: 2 additions & 0 deletions Mac/MenuBarQuickView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,8 @@ struct MenuBarQuickView: View {
}
}

DisplayRoleMenuToggle(controller: controller)

Button("Open Settings…") { MacSettingsWindow.show() }
.controlSize(.small)

Expand Down
Loading
Loading