Skip to content
C.W.K.
Lesson 03 of 05 · published

HideApplications — the First Child, Line by Line

~15 min · hideapplications, subclass, protocol-seam, testing

Level 0Idle Hands
0 XP0/39 lessons0/12 achievements
0/100 XP to next level100 XP to go0% complete
"Application visibility only: no UI tree, clicks, typing, payment or password access." — the doc comment on Tinker's system visibility adapter

Identity and Input

HideApplications calls the root initialiser with the ID applications.hide, the name "Hide applications" and the permission set [.accessibility]. Its input type is a one-field struct, HideInput, holding a list of bundle identifiers. validate accepts an empty list — a preset still being filled in is legitimate saved data — but refuses any entry that is blank or contains no dot, because a bundle identifier is reverse-DNS by convention and a bare word is almost certainly an app name typed by mistake.

prepare: Snapshot, Filter, Flag

Prepare refuses an empty list with a message that says what to do ("No applications are listed to hide yet. Add some in Tinker Settings."), because a key press that silently does nothing teaches the user that the tool is broken. Otherwise it takes one snapshot of the running applications, keeps those whose bundle ID is in the list, and emits a plan for each — key from the app's identity, payload from that identity, needsApply set to not already hidden. Only if at least one plan actually needs applying does it ask the standing-refusal question (secure keyboard entry, covered in track 7). A preset whose apps are all already hidden or not running is not refused; it simply has nothing to do.

apply: Revalidate at the Last Moment

Time passes between prepare and apply — the journal write sits in between, and a preset may have several apps. So apply checks again, immediately before the effect: is this exact process still running and still visible? If it is now hidden, someone else hid it; if it is gone, it quit. Either way Tinker has asked the OS for nothing, and it says so with a specific error type you will meet in the next lesson. Only then does it request the hide.

undo: Only If Still Ours to Undo

Undo checks that the same process is still hidden. If Dad has already brought the app back by hand, or the app has quit, the answer is .noLongerApplicable and nothing is touched. Otherwise it requests the unhide and reports .restored.

The Seam: ApplicationVisibility

HideApplications never calls AppKit directly. It talks to a small protocol — list running apps, ask whether one is hidden, set hidden, give a refusal reason — and the app injects SystemApplicationVisibility, the real AppKit adapter. Tests inject a fake that can simulate pre-hidden apps, relaunched processes, slow confirmations and failures on demand. That one seam is why the engine's hardest cases are covered by fast, deterministic unit tests instead of by poking real apps.

Code

The child's three verbs (from TinkerCore/HideApplications.swift, trimmed)·swift
public override func prepare(_ input: JSONValue) throws -> [ChangePlan] {
  let options = try decodeValue(HideInput.self, input).checked()
  guard !options.bundleIDs.isEmpty else {
    throw TinkerError("No applications are listed to hide yet. Add some in Tinker Settings.")
  }
  let ids = Set(options.bundleIDs)
  let plans = try applications.running().filter { ids.contains($0.identity.bundleID) }.map {
    ChangePlan(key: $0.identity.key, payload: try jsonValue($0.identity), needsApply: !$0.hidden)
  }
  if plans.contains(where: \.needsApply), let refusal = applications.hideRefusal() {
    throw TinkerError(refusal)   // known before the first receipt: leaves none
  }
  return plans
}

public override func apply(_ change: JSONValue) async throws {
  let identity = try decodeValue(ApplicationIdentity.self, change)
  guard applications.hidden(identity) == false else {
    throw ChangeNotAttempted("\(identity.name) quit or was hidden by someone else before Tinker acted; it was left alone.")
  }
  try await applications.setHidden(true, for: identity)
}

public override func undo(_ change: JSONValue) async throws -> UndoResult {
  let identity = try decodeValue(ApplicationIdentity.self, change)
  guard applications.hidden(identity) == true else { return .noLongerApplicable }
  try await applications.setHidden(false, for: identity)
  return .restored
}
The seam that makes the hard cases testable·swift
@MainActor
public protocol ApplicationVisibility {
  func running() -> [ApplicationSnapshot]
  func hidden(_ identity: ApplicationIdentity) -> Bool?   // nil: not this process any more
  func setHidden(_ value: Bool, for identity: ApplicationIdentity) async throws
  /// Why nothing may be hidden right now, or nil when hiding is allowed.
  func hideRefusal() -> String?
}

public extension ApplicationVisibility {
  func hideRefusal() -> String? { nil }
}

External links

Exercise

Write a fake ApplicationVisibility for tests that can (a) start an app as already hidden, (b) replace an app's process with a new one under the same bundle ID, and (c) make the next setHidden call throw. Then name one engine behaviour each of those three switches lets you prove.
Hint
(a) proves pre-hidden apps are never acquired or shown on restore; (b) proves a new process does not inherit the old receipt's restore; (c) proves that a failed action restores the changes already confirmed for that owner. Tinker's own EngineTests cover all three with names close to these.

Progress

Progress is local-only — sign in to sync across devices.
Spotted a bug or have feedback on this page?Report an Issue

Comments 0

🔔 Reply notifications (sign in)
Sign in — Please sign in to comment.

No comments yet — be the first.