Skip to content
Merged
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
65 changes: 55 additions & 10 deletions Sources/CoffeeBarUI/PreferencesView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -322,18 +322,63 @@ public struct PreferencesView: View {
// check reads.
HStack(spacing: 8) {
Button(helperAvailability.buttonTitle) {
// The availability gate is re-read inside `arm`, so a
// build that cannot register never reaches the OS.
armingInFlight = true
Task {
let outcome = await PrivilegedHelperClient()
.arm(seconds: model.holdInForce)
helperStatus = outcome.statusLine
armingInFlight = false
// WHAT the press does is decided on the model side and
// read here, for the reason the paragraph above gives
// about sentences: design §5.4 forbids asserting on
// rendered AppKit text, so a `switch` on
// `helperAvailability` written in this closure is a
// decision no check reads.
// `theButtonThatOffersToCopyTheCommandCopiesTheCommand`
// holds the mapping and
// `theArmButtonIsNotDisabledOnTheBuildWhoseTitleOffersACopy`
// holds this file's half.
switch helperAvailability.buttonAction {
case .copyCommand(let command):
// ISSUE #142. The title on this build has always
// read "Copy the command instead", and the
// `.disabled` clause below carried
// `helperAvailability == .unavailable`, so the
// control named an action and refused it. On the
// Homebrew bundle, which is ad-hoc signed and always
// takes this branch, that greyed button was the only
// route the product offered to lid-closed mode.
//
// `clearContents()` first, because `setString`
// alone appends a representation to whatever the
// user was already holding.
//
// `command` and not a second lookup of
// `ServingModel.lidClosedCommand`: the action
// carries the string the sentence beside this button
// names, and one spelling cannot drift from itself.
let board = NSPasteboard.general
board.clearContents()
board.setString(command, forType: .string)
// The window looks identical before and after a
// copy, so a press with no sentence is a press the
// user cannot tell from a dead control. The line is
// the model's, like every other line here.
helperStatus = ServingModel.commandCopiedNote
case .arm:
// The availability gate is re-read inside `arm`, so
// a build that cannot register never reaches the OS.
// That is unchanged by #142, and it is what makes
// enabling this control safe: the branch above ends
// at the pasteboard, and this branch is closed to
// an unavailable build twice over.
armingInFlight = true
Task {
let outcome = await PrivilegedHelperClient()
.arm(seconds: model.holdInForce)
helperStatus = outcome.statusLine
armingInFlight = false
}
}
}
.disabled(armingInFlight || removalInFlight
|| helperAvailability == .unavailable)
// NO availability term, and its absence is issue #142's fix.
// What the press does depends on the build; whether it can
// be pressed does not. A copy is always possible.
.disabled(armingInFlight || removalInFlight)

if armingInFlight {
ProgressView().controlSize(.small)
Expand Down
71 changes: 70 additions & 1 deletion Sources/CoffeeBarUI/PrivilegedHelperClient.swift
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,36 @@ public enum HelperAvailability: Equatable, Sendable {
}
}

/// What a press on the control actually does.
///
/// **A VALUE and not a branch inside the button's closure**, for the reason
/// `buttonTitle` and `explanation` are composed here: M1 design §5.4 forbids
/// asserting on rendered AppKit text, so a `switch` written in a `View` is a
/// decision no check reads. This is the decision issue #142 found wrong, and
/// `theButtonThatOffersToCopyTheCommandCopiesTheCommand` reads it here.
///
/// **The unavailable case is a COPY and not a refusal, which is #142.** That
/// build's title has always read "Copy the command instead", and the view
/// disabled the control on the same term, so it named an action and refused
/// it. The Homebrew bundle is ad-hoc signed and always takes this branch,
/// where the greyed button was the only route the product offered to
/// lid-closed mode: the command was recoverable only by selecting the
/// sentence beside it by hand.
///
/// **The safety property is unchanged.** A press on an unavailable build
/// reaches the pasteboard and nothing else. `arm(seconds:)` opens with
/// `register()`, which re-reads `availability()`, so the OS-facing path
/// stays closed to a build that cannot register even if a view ever routed
/// a press into it by mistake.
public var buttonAction: HelperButtonAction {
switch self {
case .registrable:
return .arm
case .unavailable:
return .copyCommand(ServingModel.lidClosedCommand)
}
}

/// The sentence the window shows beside the button.
///
/// Composed here rather than in the view, for the reason every other
Expand Down Expand Up @@ -96,6 +126,28 @@ public enum HelperAvailability: Equatable, Sendable {
}
}

/// What a press on the lid-closed control is for, on this build.
///
/// TWO CASES because there are two builds, and issue #142 is what happens when
/// one control has to serve both. A signed bundle registers a helper and asks
/// it; an ad-hoc or unsigned one cannot, and the useful thing it can do is hand
/// the user the command that has always worked.
///
/// The command travels IN THE CASE rather than being looked up by the view. A
/// view that fetched `ServingModel.lidClosedCommand` for itself is a second
/// spelling of one string, and the sentence beside the button already carries
/// the first: the two would drift, and the copied line would stop matching the
/// line the user is reading.
public enum HelperButtonAction: Equatable, Sendable {
/// Register the helper if needed, then ask it to hold sleep.
case arm

/// Put this on the pasteboard. The user runs it in their own shell, which
/// is how lid-closed mode has always been reached on a build that names no
/// team, and coffee-bar never elevates its own privilege to do it for them.
case copyCommand(String)
}

/// What a click on the button did.
public enum HelperArmOutcome: Equatable, Sendable {
/// The helper armed, for this many seconds — the value IT recorded.
Expand All @@ -119,10 +171,27 @@ public enum HelperArmOutcome: Equatable, Sendable {
/// Composed here rather than in the view, for the reason every other
/// sentence in this layer is: M1 design §5.4 forbids asserting on rendered
/// AppKit text.
///
/// **The armed case NAMES THE BLANK SCREEN, and that is issue #143.** The
/// click puts the display to sleep immediately: `ArmService` is built with
/// `display: PmsetDisplaySleeper(runner:)`, whose `forceSleep()` runs
/// `pmset displaysleepnow`, and `docs/coffee-bar-HANDOFF.md` records it as
/// "Force display off ... Required alongside disablesleep". It is the single
/// most visible consequence of the press, this sentence is the only thing
/// the window says afterwards, and the maintainer read the blanking as a
/// crash on first live use of the shipped button.
///
/// "was put to sleep" and NOT "is asleep", deliberately. `LidClosedSession`
/// treats a `nil` from `DisplayStateProbe` as not-awake (the measured Apple
/// Silicon answer), so an `.armed` reply proves `pmset displaysleepnow`
/// returned nought and not that the panel is dark. This product does not
/// state what it cannot observe, and the ACTION is what it observed.
/// `theArmedSentenceSaysTheDisplayWasPutToSleepAndTheLidMayClose` holds it.
public var statusLine: String {
switch self {
case .armed(let seconds):
return "Lid-closed mode is armed for \(ServingModel.holdLabel(for: seconds)). "
return "Lid-closed mode is armed for \(ServingModel.holdLabel(for: seconds)), "
+ "and the display was put to sleep so you can close the lid. "
+ "coffee-bar's helper is supervising it and will put the setting back."
case .refused(let reason):
return reason
Expand Down
26 changes: 24 additions & 2 deletions Sources/CoffeeBarUI/ServingModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -778,6 +778,27 @@ public final class ServingModel {
nonisolated public static let lidClosedCommand =
"sudo \(shellQuoted(privilegedProbePath)) \(ProbeVerb.arm.rawValue)"

/// The line the Preferences window shows once the button has copied that
/// command (issue #142).
///
/// Composed on the model side, like every other sentence that window
/// renders: M1 design §5.4 forbids asserting on rendered AppKit text, so a
/// string written in the view is a string no check reads.
///
/// **It NAMES what was copied rather than saying "done".** The window looks
/// identical before and after the press, the pasteboard is not a surface the
/// user can see from here, and the whole reason this build has a button at
/// all is that the command was otherwise recoverable only by selecting the
/// sentence beside it by hand. Printing it back is also the confirmation:
/// a user who pasted into the wrong window can read what they should have.
///
/// Built ON `lidClosedCommand` and not beside it, for the reason
/// `lidClosedCommand(holdingFor:)` gives: two spellings of one command line
/// drift, and this one is quoted for a root shell.
nonisolated public static let commandCopiedNote =
"\(lidClosedCommand) is on the clipboard. Paste it into Terminal and "
+ "run it there: coffee-bar never elevates its own privilege."

/// The same command, carrying the hold the user chose (issue #74).
///
/// **This function IS the channel the setting travels down.** The value a
Expand Down Expand Up @@ -946,8 +967,9 @@ public final class ServingModel {
///
/// **The third clause is OVER-BROAD rather than false, and the difference is
/// worth keeping straight.** `HelperArmOutcome.statusLine` does report what
/// a click armed — "Lid-closed mode is armed for N. coffee-bar's helper is
/// supervising it and will put the setting back." — so "coffee-bar cannot
/// a click armed ("Lid-closed mode is armed for N, and the display was put
/// to sleep so you can close the lid. coffee-bar's helper is supervising it
/// and will put the setting back."), so "coffee-bar cannot
/// show you whether it is armed" is untrue at that moment. It is true again
/// a moment later: that line is `@State` in `PreferencesView`, `nil` until a
/// click and gone on the next launch, and `LidClosedControl` carries exactly
Expand Down
Loading