compartment 4.2__tar.gz → 4.3__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (93) hide show
  1. {compartment-4.2/src/compartment.egg-info → compartment-4.3}/PKG-INFO +1 -1
  2. {compartment-4.2 → compartment-4.3}/pyproject.toml +1 -1
  3. {compartment-4.2 → compartment-4.3}/src/compartment/__init__.py +1 -1
  4. {compartment-4.2 → compartment-4.3}/src/compartment/menubar.py +63 -10
  5. {compartment-4.2 → compartment-4.3}/src/compartment/systray.py +66 -4
  6. {compartment-4.2 → compartment-4.3/src/compartment.egg-info}/PKG-INFO +1 -1
  7. {compartment-4.2 → compartment-4.3}/src/compartment.egg-info/SOURCES.txt +1 -0
  8. {compartment-4.2 → compartment-4.3}/tests/test_attempt_pacing.py +10 -1
  9. compartment-4.3/tests/test_stays_running.py +169 -0
  10. {compartment-4.2 → compartment-4.3}/LICENSE +0 -0
  11. {compartment-4.2 → compartment-4.3}/README.md +0 -0
  12. {compartment-4.2 → compartment-4.3}/setup.cfg +0 -0
  13. {compartment-4.2 → compartment-4.3}/src/compartment/acl.py +0 -0
  14. {compartment-4.2 → compartment-4.3}/src/compartment/agent_skill.py +0 -0
  15. {compartment-4.2 → compartment-4.3}/src/compartment/audit.py +0 -0
  16. {compartment-4.2 → compartment-4.3}/src/compartment/bench.py +0 -0
  17. {compartment-4.2 → compartment-4.3}/src/compartment/claude_desktop.py +0 -0
  18. {compartment-4.2 → compartment-4.3}/src/compartment/claude_hooks.py +0 -0
  19. {compartment-4.2 → compartment-4.3}/src/compartment/claude_memory.py +0 -0
  20. {compartment-4.2 → compartment-4.3}/src/compartment/cli.py +0 -0
  21. {compartment-4.2 → compartment-4.3}/src/compartment/clients.py +0 -0
  22. {compartment-4.2 → compartment-4.3}/src/compartment/crypto.py +0 -0
  23. {compartment-4.2 → compartment-4.3}/src/compartment/dash.py +0 -0
  24. {compartment-4.2 → compartment-4.3}/src/compartment/data/agent-skill/SKILL.md +0 -0
  25. {compartment-4.2 → compartment-4.3}/src/compartment/data/app.icns +0 -0
  26. {compartment-4.2 → compartment-4.3}/src/compartment/data/app.png +0 -0
  27. {compartment-4.2 → compartment-4.3}/src/compartment/data/hermes-plugin/__init__.py +0 -0
  28. {compartment-4.2 → compartment-4.3}/src/compartment/data/hermes-plugin/plugin.yaml +0 -0
  29. {compartment-4.2 → compartment-4.3}/src/compartment/data/menubar.png +0 -0
  30. {compartment-4.2 → compartment-4.3}/src/compartment/data/menubar@2x.png +0 -0
  31. {compartment-4.2 → compartment-4.3}/src/compartment/data/starter.mpack +0 -0
  32. {compartment-4.2 → compartment-4.3}/src/compartment/data/tray.ico +0 -0
  33. {compartment-4.2 → compartment-4.3}/src/compartment/embed.py +0 -0
  34. {compartment-4.2 → compartment-4.3}/src/compartment/home.py +0 -0
  35. {compartment-4.2 → compartment-4.3}/src/compartment/longmemeval.py +0 -0
  36. {compartment-4.2 → compartment-4.3}/src/compartment/models/bge-small-en-v1.5-int8/model_quantized.onnx +0 -0
  37. {compartment-4.2 → compartment-4.3}/src/compartment/models/bge-small-en-v1.5-int8/tokenizer.json +0 -0
  38. {compartment-4.2 → compartment-4.3}/src/compartment/offline_guard.py +0 -0
  39. {compartment-4.2 → compartment-4.3}/src/compartment/packs.py +0 -0
  40. {compartment-4.2 → compartment-4.3}/src/compartment/platforms.py +0 -0
  41. {compartment-4.2 → compartment-4.3}/src/compartment/ranking.py +0 -0
  42. {compartment-4.2 → compartment-4.3}/src/compartment/salience.py +0 -0
  43. {compartment-4.2 → compartment-4.3}/src/compartment/selftest.py +0 -0
  44. {compartment-4.2 → compartment-4.3}/src/compartment/server.py +0 -0
  45. {compartment-4.2 → compartment-4.3}/src/compartment/session.py +0 -0
  46. {compartment-4.2 → compartment-4.3}/src/compartment/store.py +0 -0
  47. {compartment-4.2 → compartment-4.3}/src/compartment/vault.py +0 -0
  48. {compartment-4.2 → compartment-4.3}/src/compartment/vaultfile.py +0 -0
  49. {compartment-4.2 → compartment-4.3}/src/compartment/vindex.py +0 -0
  50. {compartment-4.2 → compartment-4.3}/src/compartment/wire.py +0 -0
  51. {compartment-4.2 → compartment-4.3}/src/compartment.egg-info/dependency_links.txt +0 -0
  52. {compartment-4.2 → compartment-4.3}/src/compartment.egg-info/entry_points.txt +0 -0
  53. {compartment-4.2 → compartment-4.3}/src/compartment.egg-info/requires.txt +0 -0
  54. {compartment-4.2 → compartment-4.3}/src/compartment.egg-info/top_level.txt +0 -0
  55. {compartment-4.2 → compartment-4.3}/tests/test_acl_audit_packs.py +0 -0
  56. {compartment-4.2 → compartment-4.3}/tests/test_acl_precedence.py +0 -0
  57. {compartment-4.2 → compartment-4.3}/tests/test_agent_skill.py +0 -0
  58. {compartment-4.2 → compartment-4.3}/tests/test_audit_chain.py +0 -0
  59. {compartment-4.2 → compartment-4.3}/tests/test_autoconnect.py +0 -0
  60. {compartment-4.2 → compartment-4.3}/tests/test_claude_desktop.py +0 -0
  61. {compartment-4.2 → compartment-4.3}/tests/test_claude_hooks_and_integrate.py +0 -0
  62. {compartment-4.2 → compartment-4.3}/tests/test_claude_memory_and_recent.py +0 -0
  63. {compartment-4.2 → compartment-4.3}/tests/test_clients.py +0 -0
  64. {compartment-4.2 → compartment-4.3}/tests/test_connect_buttons.py +0 -0
  65. {compartment-4.2 → compartment-4.3}/tests/test_connect_environment.py +0 -0
  66. {compartment-4.2 → compartment-4.3}/tests/test_crypto.py +0 -0
  67. {compartment-4.2 → compartment-4.3}/tests/test_dash.py +0 -0
  68. {compartment-4.2 → compartment-4.3}/tests/test_instructions.py +0 -0
  69. {compartment-4.2 → compartment-4.3}/tests/test_journal_framing.py +0 -0
  70. {compartment-4.2 → compartment-4.3}/tests/test_linux_panel.py +0 -0
  71. {compartment-4.2 → compartment-4.3}/tests/test_login_item_identity.py +0 -0
  72. {compartment-4.2 → compartment-4.3}/tests/test_long_memory_recall.py +0 -0
  73. {compartment-4.2 → compartment-4.3}/tests/test_macos_bundle.py +0 -0
  74. {compartment-4.2 → compartment-4.3}/tests/test_menubar.py +0 -0
  75. {compartment-4.2 → compartment-4.3}/tests/test_offline_guard_paths.py +0 -0
  76. {compartment-4.2 → compartment-4.3}/tests/test_offline_selftest_bench.py +0 -0
  77. {compartment-4.2 → compartment-4.3}/tests/test_ondisk_format.py +0 -0
  78. {compartment-4.2 → compartment-4.3}/tests/test_pack_trust.py +0 -0
  79. {compartment-4.2 → compartment-4.3}/tests/test_platforms.py +0 -0
  80. {compartment-4.2 → compartment-4.3}/tests/test_ranking.py +0 -0
  81. {compartment-4.2 → compartment-4.3}/tests/test_relations.py +0 -0
  82. {compartment-4.2 → compartment-4.3}/tests/test_salience.py +0 -0
  83. {compartment-4.2 → compartment-4.3}/tests/test_session_entropy.py +0 -0
  84. {compartment-4.2 → compartment-4.3}/tests/test_session_lock.py +0 -0
  85. {compartment-4.2 → compartment-4.3}/tests/test_single_instance.py +0 -0
  86. {compartment-4.2 → compartment-4.3}/tests/test_starter_data_quality.py +0 -0
  87. {compartment-4.2 → compartment-4.3}/tests/test_starter_packs.py +0 -0
  88. {compartment-4.2 → compartment-4.3}/tests/test_starter_visibility.py +0 -0
  89. {compartment-4.2 → compartment-4.3}/tests/test_status_bar_install.py +0 -0
  90. {compartment-4.2 → compartment-4.3}/tests/test_systray.py +0 -0
  91. {compartment-4.2 → compartment-4.3}/tests/test_tamper.py +0 -0
  92. {compartment-4.2 → compartment-4.3}/tests/test_twofa.py +0 -0
  93. {compartment-4.2 → compartment-4.3}/tests/test_vault_ops.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: compartment
3
- Version: 4.2
3
+ Version: 4.3
4
4
  Summary: Compartment - high-security, fully offline, encrypted vector memory for AI agents (MCP)
5
5
  License: Apache-2.0
6
6
  Project-URL: Homepage, https://github.com/MaxFreedomPollard/Compartment
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "compartment"
7
- version = "4.2"
7
+ version = "4.3"
8
8
  description = "Compartment - high-security, fully offline, encrypted vector memory for AI agents (MCP)"
9
9
  readme = "README.md"
10
10
  license = { text = "Apache-2.0" }
@@ -1,6 +1,6 @@
1
1
  """Compartment - high-security, fully offline, encrypted vector memory for AI agents."""
2
2
 
3
- __version__ = "4.2"
3
+ __version__ = "4.3"
4
4
 
5
5
  from . import offline_guard as _og
6
6
 
@@ -664,11 +664,12 @@ def _launcher_argv() -> list[str]:
664
664
  def _set_login_agent(enabled: bool) -> str:
665
665
  plist = _agent_plist()
666
666
  if not enabled:
667
- try:
668
- subprocess.run(["launchctl", "unload", str(plist)],
669
- capture_output=True, timeout=10)
670
- except (OSError, subprocess.SubprocessError):
671
- pass
667
+ # bootout first: with KeepAlive set, a running copy that is still
668
+ # registered would be restarted by launchd after the plist was
669
+ # deleted, and the icon would come back from a file that no longer
670
+ # exists. Unload stays as the fallback for older macOS.
671
+ _launchctl("bootout", f"{_gui_domain()}/{LAUNCH_AGENT_LABEL}")
672
+ _launchctl("unload", str(plist))
672
673
  plist.unlink(missing_ok=True)
673
674
  return "off"
674
675
  argv = _launcher_argv()
@@ -684,15 +685,67 @@ def _set_login_agent(enabled: bool) -> str:
684
685
  ' <key>ProgramArguments</key>\n'
685
686
  f' <array>\n{args} </array>\n'
686
687
  ' <key>RunAtLoad</key><true/>\n'
687
- ' <key>KeepAlive</key><false/>\n'
688
+ # KeepAlive was <false/>, which made this a one-shot: launchd started
689
+ # the app at login and then never looked at it again. Anything that
690
+ # ended the process - a crash, an ObjC exception, macOS reclaiming
691
+ # memory, an upgrade replacing the binary underneath it - took the
692
+ # icon out of the menu bar until the next login, with nothing said.
693
+ # A status bar app is meant to sit there for as long as the machine
694
+ # is up.
695
+ #
696
+ # SuccessfulExit false is the form that means "bring it back if it
697
+ # died, leave it alone if it left". Quit calls NSApp.terminate_, which
698
+ # exits 0, so the one way to remove the icon is still the one the user
699
+ # asked for. A crash exits non-zero and comes straight back.
700
+ ' <key>KeepAlive</key>\n'
701
+ ' <dict>\n'
702
+ ' <key>SuccessfulExit</key><false/>\n'
703
+ ' </dict>\n'
688
704
  ' </dict>\n'
689
705
  '</plist>\n', encoding="utf-8")
706
+ return _bootstrap_agent(plist)
707
+
708
+
709
+ def _launchctl(*argv: str) -> tuple[int, str]:
710
+ """Run launchctl and actually look at what it said."""
690
711
  try:
691
- subprocess.run(["launchctl", "load", str(plist)],
692
- capture_output=True, timeout=10)
712
+ r = subprocess.run(["launchctl", *argv], capture_output=True,
713
+ text=True, timeout=15)
693
714
  except (OSError, subprocess.SubprocessError) as exc:
694
- return f"failed: {exc}"
695
- return "on"
715
+ return 1, str(exc)
716
+ return r.returncode, (r.stderr or r.stdout or "").strip()
717
+
718
+
719
+ def _gui_domain() -> str:
720
+ """launchd's per-user domain. `os.getuid` is POSIX-only and this module
721
+ is imported on every platform, so it is asked for rather than assumed:
722
+ an AttributeError here would take down anything that merely touched the
723
+ login-agent code on Windows."""
724
+ uid = getattr(os, "getuid", None)
725
+ return f"gui/{uid() if uid else 0}"
726
+
727
+
728
+ def _bootstrap_agent(plist: Path) -> str:
729
+ """Load the agent, and report a refusal as one.
730
+
731
+ The old code ran `launchctl load` and threw the result away, so a plist
732
+ that was written but never loaded reported "on" and started nothing at
733
+ the next login - which is exactly the state a machine ends up in when
734
+ the label is already bootstrapped and load quietly refuses.
735
+
736
+ `bootstrap` is the modern verb and the only one that reports properly.
737
+ An existing registration is booted out first so this is idempotent, and
738
+ `load` remains as the fallback for macOS versions that predate it.
739
+ """
740
+ domain = _gui_domain()
741
+ _launchctl("bootout", f"{domain}/{LAUNCH_AGENT_LABEL}") # ok if absent
742
+ code, msg = _launchctl("bootstrap", domain, str(plist))
743
+ if code == 0:
744
+ return "on"
745
+ code, msg2 = _launchctl("load", "-w", str(plist)) # older macOS
746
+ if code == 0:
747
+ return "on"
748
+ return f"failed: {msg or msg2 or 'launchctl refused the agent'}"
696
749
 
697
750
 
698
751
  def login_status() -> str:
@@ -223,6 +223,52 @@ def remove_desktop_entry() -> bool:
223
223
  return False
224
224
 
225
225
 
226
+ # --- starting at login on Linux ---------------------------------------------
227
+ # An entry in the applications menu makes Compartment findable. It does not
228
+ # make it run, and the two were being treated as one thing: `login_status`
229
+ # answered "on" whenever the menu entry existed, so a desktop that had never
230
+ # started Compartment at login reported that it did. The autostart directory
231
+ # is a different directory with a different meaning, and this is it.
232
+
233
+ def autostart_entry_path() -> Path:
234
+ base = os.environ.get("XDG_CONFIG_HOME") or str(Path.home() / ".config")
235
+ return Path(base) / "autostart" / DESKTOP_FILE
236
+
237
+
238
+ def install_autostart_entry(vault: str | None = None) -> str:
239
+ """Start Compartment at login, the way every XDG desktop reads it."""
240
+ path = autostart_entry_path()
241
+ try:
242
+ path.parent.mkdir(parents=True, exist_ok=True)
243
+ icon = app_icon_path()
244
+ path.write_text(
245
+ "[Desktop Entry]\n"
246
+ "Type=Application\n"
247
+ "Name=Compartment\n"
248
+ "Comment=Encrypted memory for AI agents\n"
249
+ f"Exec={_panel_command(vault)}\n"
250
+ + (f"Icon={icon}\n" if icon.is_file() else "")
251
+ + "Terminal=false\n"
252
+ # GNOME and KDE both read these, and without them a session can
253
+ # decide an autostart entry is stale and skip it.
254
+ "X-GNOME-Autostart-enabled=true\n"
255
+ "Hidden=false\n"
256
+ "NoDisplay=false\n",
257
+ encoding="utf-8")
258
+ path.chmod(0o644)
259
+ except OSError as exc:
260
+ return f"error: {exc}"
261
+ return str(path)
262
+
263
+
264
+ def remove_autostart_entry() -> bool:
265
+ try:
266
+ autostart_entry_path().unlink(missing_ok=True)
267
+ return True
268
+ except OSError:
269
+ return False
270
+
271
+
226
272
  def _is_linux() -> bool:
227
273
  """This module draws the panel on Windows and on everything that is not
228
274
  macOS. Only the latter uses desktop entries, and macOS must never get
@@ -233,9 +279,10 @@ def _is_linux() -> bool:
233
279
 
234
280
  def login_status() -> str:
235
281
  if _is_linux():
236
- # No tray to sit in, so nothing worth starting at login: the panel is
237
- # opened when it is wanted, from the applications menu.
238
- return "on" if desktop_entry_path().is_file() else "off"
282
+ # The autostart entry, not the applications-menu entry. Reading the
283
+ # menu entry meant this said "on" for a machine that had never
284
+ # started Compartment at login in its life.
285
+ return "on" if autostart_entry_path().is_file() else "off"
239
286
  try:
240
287
  winreg = _winreg()
241
288
  with winreg.OpenKey(winreg.HKEY_CURRENT_USER, RUN_KEY) as k:
@@ -251,9 +298,15 @@ def set_login(enabled: bool) -> str:
251
298
  """Register or drop the Run entry. Returns what actually happened."""
252
299
  if _is_linux():
253
300
  if not enabled:
301
+ remove_autostart_entry()
254
302
  return "off" if remove_desktop_entry() else "error"
303
+ # Both, and they are not the same thing: the menu entry is how the
304
+ # app is found, the autostart entry is how it comes up at login.
255
305
  out = install_desktop_entry()
256
- return "on" if not out.startswith("error") else out
306
+ if out.startswith("error"):
307
+ return out
308
+ auto = install_autostart_entry()
309
+ return "on" if not auto.startswith("error") else auto
257
310
  if sys.platform != "win32":
258
311
  return "error: start at login is handled by the menu bar app here"
259
312
  try:
@@ -262,6 +315,13 @@ def set_login(enabled: bool) -> str:
262
315
  if enabled:
263
316
  winreg.SetValueEx(k, RUN_VALUE, 0, winreg.REG_SZ,
264
317
  _autostart_command())
318
+ # Read it back. A write that the registry accepted and then
319
+ # dropped - a policy, a locked hive, a roaming profile - used
320
+ # to report "on" and start nothing at the next sign-in.
321
+ try:
322
+ winreg.QueryValueEx(k, RUN_VALUE)
323
+ except FileNotFoundError:
324
+ return "error: the Run entry did not survive the write"
265
325
  return "on"
266
326
  try:
267
327
  winreg.DeleteValue(k, RUN_VALUE)
@@ -718,4 +778,6 @@ def quit_running() -> bool:
718
778
 
719
779
  __all__ = ["run", "self_check", "login_status", "set_login", "quit_running",
720
780
  "panel_rows",
781
+ "autostart_entry_path", "install_autostart_entry",
782
+ "remove_autostart_entry",
721
783
  "icon_path"]
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: compartment
3
- Version: 4.2
3
+ Version: 4.3
4
4
  Summary: Compartment - high-security, fully offline, encrypted vector memory for AI agents (MCP)
5
5
  License: Apache-2.0
6
6
  Project-URL: Homepage, https://github.com/MaxFreedomPollard/Compartment
@@ -84,6 +84,7 @@ tests/test_starter_data_quality.py
84
84
  tests/test_starter_packs.py
85
85
  tests/test_starter_visibility.py
86
86
  tests/test_status_bar_install.py
87
+ tests/test_stays_running.py
87
88
  tests/test_systray.py
88
89
  tests/test_tamper.py
89
90
  tests/test_twofa.py
@@ -78,12 +78,21 @@ def test_no_lockout_the_right_passphrase_still_opens_after_many_failures(
78
78
 
79
79
  def test_a_correct_attempt_clears_the_pacing(vault_path, fast):
80
80
  Vault.create(vault_path, PASS, creator="t").lock()
81
+ # What an unlock costs with nothing pacing it. Argon2 and opening the
82
+ # file are not free, and on a loaded CI runner they can take longer than
83
+ # the whole test interval, so "no wait was added" has to be measured
84
+ # against that cost rather than against a flat number. Comparing to 0.4s
85
+ # made this a test of how busy the machine was.
86
+ start = time.monotonic()
87
+ Vault.unlock(vault_path, passphrase=PASS).lock()
88
+ baseline = time.monotonic() - start
89
+
81
90
  with pytest.raises((TamperError, CryptoError)):
82
91
  Vault.unlock(vault_path, passphrase="wrong")
83
92
  Vault.unlock(vault_path, passphrase=PASS).lock() # pays the wait once
84
93
  start = time.monotonic()
85
94
  Vault.unlock(vault_path, passphrase=PASS).lock() # and not again
86
- assert time.monotonic() - start < fast
95
+ assert time.monotonic() - start < baseline + fast
87
96
 
88
97
 
89
98
  def test_the_user_is_told_why_it_is_waiting(vault_path, fast, capsys):
@@ -0,0 +1,169 @@
1
+ """The icon is supposed to be there until somebody quits it.
2
+
3
+ A status bar app is not a program you launch, it is a program that is simply
4
+ there, like the clock and the battery. Every fault covered here ended the same
5
+ way for the user: the icon was gone, nothing said why, and the fix was to know
6
+ a command. So these are about persistence rather than appearance.
7
+
8
+ - macOS started the app once at login and then stopped caring. Anything
9
+ that ended the process took the icon away until the next login.
10
+ - macOS also reported success for an agent it had failed to load, so an
11
+ install could say "start at login: on" and start nothing.
12
+ - Linux answered the same question by looking at the applications menu,
13
+ which says whether Compartment can be found, not whether it runs.
14
+ - Windows wrote a Run entry without ever reading it back.
15
+ """
16
+ import plistlib
17
+ import sys
18
+
19
+ import pytest
20
+
21
+ from compartment import menubar, systray
22
+
23
+
24
+ # ------------------------------------------------------------------- macOS
25
+
26
+ def _plist(monkeypatch, tmp_path):
27
+ """Write the login agent into a temp home and hand back the parsed dict."""
28
+ monkeypatch.setattr(menubar.Path, "home", classmethod(lambda cls: tmp_path))
29
+ monkeypatch.setattr(menubar, "_launcher_argv", lambda: ["/bin/true"])
30
+ calls = []
31
+ monkeypatch.setattr(menubar, "_launchctl",
32
+ lambda *a: (calls.append(a), (0, ""))[1])
33
+ out = menubar._set_login_agent(True)
34
+ path = tmp_path / "Library" / "LaunchAgents" / f"{menubar.LAUNCH_AGENT_LABEL}.plist"
35
+ return out, plistlib.loads(path.read_bytes()), calls, path
36
+
37
+
38
+ def test_launchd_brings_the_icon_back_after_a_crash(monkeypatch, tmp_path):
39
+ _out, data, _calls, _p = _plist(monkeypatch, tmp_path)
40
+ # Not False, and not True either. False is the one-shot that lost the
41
+ # icon on any unexpected exit; plain True would fight the Quit button.
42
+ assert data["KeepAlive"] == {"SuccessfulExit": False}
43
+ assert data["RunAtLoad"] is True
44
+
45
+
46
+ def test_quitting_on_purpose_is_still_allowed_to_win(monkeypatch, tmp_path):
47
+ """SuccessfulExit false means launchd relaunches a non-zero exit only.
48
+ Quit goes through NSApp.terminate_, which exits zero, so the icon stays
49
+ gone until the user asks for it back."""
50
+ _out, data, _calls, _p = _plist(monkeypatch, tmp_path)
51
+ assert data["KeepAlive"]["SuccessfulExit"] is False
52
+
53
+
54
+ def test_the_agent_is_actually_loaded_not_just_written(monkeypatch, tmp_path):
55
+ _out, _data, calls, path = _plist(monkeypatch, tmp_path)
56
+ verbs = [c[0] for c in calls]
57
+ assert "bootstrap" in verbs, calls
58
+ # Booted out first, so re-running is idempotent rather than a refusal.
59
+ assert verbs.index("bootout") < verbs.index("bootstrap")
60
+ assert any(str(path) in c for c in calls)
61
+
62
+
63
+ def test_a_refused_agent_is_reported_as_a_failure(monkeypatch, tmp_path):
64
+ """The old code ran `launchctl load`, discarded the result and returned
65
+ "on", so an agent that never loaded looked wired."""
66
+ monkeypatch.setattr(menubar.Path, "home", classmethod(lambda cls: tmp_path))
67
+ monkeypatch.setattr(menubar, "_launcher_argv", lambda: ["/bin/true"])
68
+ monkeypatch.setattr(menubar, "_launchctl",
69
+ lambda *a: (1, "Load failed: 5: Input/output error"))
70
+ out = menubar._set_login_agent(True)
71
+ assert out.startswith("failed"), out
72
+ assert "Input/output error" in out
73
+
74
+
75
+ def test_turning_it_off_deregisters_before_deleting(monkeypatch, tmp_path):
76
+ """With KeepAlive set, deleting the plist while the job is still
77
+ registered would have launchd relaunch it from a file that is gone."""
78
+ monkeypatch.setattr(menubar.Path, "home", classmethod(lambda cls: tmp_path))
79
+ monkeypatch.setattr(menubar, "_launcher_argv", lambda: ["/bin/true"])
80
+ monkeypatch.setattr(menubar, "_launchctl", lambda *a: (0, ""))
81
+ menubar._set_login_agent(True)
82
+
83
+ calls = []
84
+ monkeypatch.setattr(menubar, "_launchctl",
85
+ lambda *a: (calls.append(a), (0, ""))[1])
86
+ assert menubar._set_login_agent(False) == "off"
87
+ assert calls and calls[0][0] == "bootout"
88
+ path = tmp_path / "Library" / "LaunchAgents" / f"{menubar.LAUNCH_AGENT_LABEL}.plist"
89
+ assert not path.exists()
90
+
91
+
92
+ # ------------------------------------------------------------------- Linux
93
+
94
+ def test_linux_autostart_is_a_different_file_from_the_menu_entry(monkeypatch,
95
+ tmp_path):
96
+ monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path / "cfg"))
97
+ monkeypatch.setenv("XDG_DATA_HOME", str(tmp_path / "data"))
98
+ auto = systray.autostart_entry_path()
99
+ menu = systray.desktop_entry_path()
100
+ assert auto != menu
101
+ assert auto.parent.name == "autostart"
102
+ assert menu.parent.name == "applications"
103
+
104
+
105
+ def test_linux_login_status_reads_autostart_not_the_menu(monkeypatch, tmp_path):
106
+ """The bug: an applications-menu entry made this report "on" for a
107
+ machine that had never started Compartment at login."""
108
+ monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path / "cfg"))
109
+ monkeypatch.setenv("XDG_DATA_HOME", str(tmp_path / "data"))
110
+ monkeypatch.setattr(systray, "_is_linux", lambda: True)
111
+
112
+ systray.install_desktop_entry("/v/memory.vault") # findable...
113
+ assert systray.login_status() == "off" # ...but not running
114
+
115
+ systray.install_autostart_entry("/v/memory.vault")
116
+ assert systray.login_status() == "on"
117
+
118
+
119
+ def test_linux_autostart_entry_is_one_desktops_will_honour(monkeypatch,
120
+ tmp_path):
121
+ monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path / "cfg"))
122
+ systray.install_autostart_entry("/v/memory.vault")
123
+ text = systray.autostart_entry_path().read_text()
124
+ assert text.startswith("[Desktop Entry]")
125
+ assert "Type=Application" in text
126
+ # Without these a session is entitled to treat the entry as stale.
127
+ assert "X-GNOME-Autostart-enabled=true" in text
128
+ assert "Hidden=false" in text
129
+ assert "/v/memory.vault" in text
130
+
131
+
132
+ def test_linux_turning_it_off_removes_the_autostart_entry(monkeypatch,
133
+ tmp_path):
134
+ monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path / "cfg"))
135
+ monkeypatch.setenv("XDG_DATA_HOME", str(tmp_path / "data"))
136
+ monkeypatch.setattr(systray, "_is_linux", lambda: True)
137
+ systray.set_login(True)
138
+ assert systray.login_status() == "on"
139
+ systray.set_login(False)
140
+ assert systray.login_status() == "off"
141
+ assert not systray.autostart_entry_path().exists()
142
+
143
+
144
+ def test_linux_set_login_installs_both(monkeypatch, tmp_path):
145
+ """Turning on start-at-login must not cost the applications-menu entry
146
+ that was there before it."""
147
+ monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path / "cfg"))
148
+ monkeypatch.setenv("XDG_DATA_HOME", str(tmp_path / "data"))
149
+ monkeypatch.setattr(systray, "_is_linux", lambda: True)
150
+ assert systray.set_login(True) == "on"
151
+ assert systray.autostart_entry_path().is_file()
152
+ assert systray.desktop_entry_path().is_file()
153
+
154
+
155
+ # ----------------------------------------------------------------- Windows
156
+
157
+ @pytest.mark.skipif(sys.platform != "win32", reason="Run key is Windows-only")
158
+ def test_windows_reads_the_run_entry_back(monkeypatch):
159
+ """A write the registry accepted and then dropped used to report "on"."""
160
+ import winreg
161
+ real_query = winreg.QueryValueEx
162
+
163
+ def vanished(key, name):
164
+ if name == systray.RUN_VALUE:
165
+ raise FileNotFoundError(name)
166
+ return real_query(key, name)
167
+
168
+ monkeypatch.setattr(winreg, "QueryValueEx", vanished)
169
+ assert systray.set_login(True).startswith("error")
File without changes
File without changes
File without changes
File without changes
File without changes