Class: Space::Src::Launchd::Agent

Inherits:
Object
  • Object
show all
Defined in:
lib/space_src/launchd/agent.rb

Overview

launchctl wrapper. Holds an injected command runner (the real default goes through Space::Src::Shell inside a Sync{} block; tests inject a RecordingRunner that captures argv and returns canned output — gate G2).

All public methods return Dry::Monads::Result. A non-zero launchctl exit surfaces as Failure({argv:, stderr:, status:}) — the same shape Shell.run uses — NOT a raise. A non-zero runner exit propagates as Failure to the caller.

Domain: every operation targets gui/<UID> (the user's per-GUI-session launchd domain — the conventional domain for user-installed LaunchAgents on macOS). The UID is resolved via Process.uid by default; tests may inject a different UID to assert the exact argv (G2).

Defined Under Namespace

Classes: ShellRunner

Constant Summary collapse

DEFAULT_LABEL =
"io.github.jetpks.space-src.sync"

Instance Attribute Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(runner: ShellRunner.new, uid: Process.uid, label: DEFAULT_LABEL) ⇒ Agent

Returns a new instance of Agent.



48
49
50
51
52
# File 'lib/space_src/launchd/agent.rb', line 48

def initialize(runner: ShellRunner.new, uid: Process.uid, label: DEFAULT_LABEL)
  @runner = runner
  @uid = uid
  @label = label
end

Instance Attribute Details

#labelObject (readonly)

Returns the value of attribute label.



54
55
56
# File 'lib/space_src/launchd/agent.rb', line 54

def label
  @label
end

Instance Method Details

#install(plist_path) ⇒ Object

launchctl bootstrap gui/<UID> <abs-plist-path>



57
58
59
# File 'lib/space_src/launchd/agent.rb', line 57

def install(plist_path)
  run("bootstrap", "gui/#{@uid}", plist_path)
end

#restartObject

launchctl kickstart -k gui/<UID>/<label>-k kills the running instance first so the new one always starts.



104
105
106
# File 'lib/space_src/launchd/agent.rb', line 104

def restart
  run("kickstart", "-k", "gui/#{@uid}/#{@label}")
end

#start(plist_path) ⇒ Object

bootstrap the plist, then enable the service. Both must succeed (both 0 exit) for the operation to be a Success; the first failure short-circuits.



78
79
80
81
82
# File 'lib/space_src/launchd/agent.rb', line 78

def start(plist_path)
  r1 = run("bootstrap", "gui/#{@uid}", plist_path)
  return r1 if r1.failure?
  run("enable", "gui/#{@uid}/#{@label}")
end

#statusObject

Returns a defensive parse of launchctl list (the machine-readable form — launchctl print is documented as "not API"). We run launchctl list (no service target) and search the output for our label.

The parser tolerates: empty output, a "Could not find" line, malformed rows, and PID values that are not integers. On any of those, we return Success(loaded: false) — the gate G4 "no raise on malformed" guarantee.



117
118
119
120
121
# File 'lib/space_src/launchd/agent.rb', line 117

def status
  result = run("list")
  return result if result.failure?
  parse_list(result.success)
end

#stopObject

bootout the service, then disable it.

Idempotency (Slice 5 / CF5): a bootout Failure with status == 3 ("No such process") or matching the not-loaded stderr is treated as already not loaded and is not propagated — the disable step still runs so the persistent disable override stays in place (matching the gate's recorded-argv assertion [[bootout,…], [disable,…]] and the "stopped" semantic). A non-benign bootout Failure (e.g. status 1 "Operation not permitted") short- circuits as before.



96
97
98
99
100
# File 'lib/space_src/launchd/agent.rb', line 96

def stop
  r1 = run("bootout", "gui/#{@uid}/#{@label}")
  return r1 if r1.failure? && !benign_bootout_failure?(r1)
  run("disable", "gui/#{@uid}/#{@label}")
end

#uninstallObject

launchctl bootout gui/<UID>/<label>

Idempotency (Slice 5 / CF5): a benign bootout Failure (status 3 / "No such process" / "Could not find specified service") is mapped to Success — uninstalling a not-loaded agent is a no-op for the bootout step. The plist removal in the CLI command layer is independent of this result.



69
70
71
72
73
# File 'lib/space_src/launchd/agent.rb', line 69

def uninstall
  r = run("bootout", "gui/#{@uid}/#{@label}")
  return Dry::Monads::Success("") if benign_bootout_failure?(r)
  r
end