synodic-client 0.0.1.dev71__tar.gz → 0.0.1.dev73__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 (105) hide show
  1. {synodic_client-0.0.1.dev71 → synodic_client-0.0.1.dev73}/PKG-INFO +2 -2
  2. {synodic_client-0.0.1.dev71 → synodic_client-0.0.1.dev73}/pyproject.toml +6 -14
  3. synodic_client-0.0.1.dev73/synodic_client/application/bootstrap.py +63 -0
  4. {synodic_client-0.0.1.dev71 → synodic_client-0.0.1.dev73}/synodic_client/application/data.py +18 -0
  5. synodic_client-0.0.1.dev73/synodic_client/application/debug.py +271 -0
  6. {synodic_client-0.0.1.dev71 → synodic_client-0.0.1.dev73}/synodic_client/application/instance.py +60 -2
  7. {synodic_client-0.0.1.dev71 → synodic_client-0.0.1.dev73}/synodic_client/application/qt.py +114 -26
  8. {synodic_client-0.0.1.dev71 → synodic_client-0.0.1.dev73}/synodic_client/application/schema.py +4 -0
  9. {synodic_client-0.0.1.dev71 → synodic_client-0.0.1.dev73}/synodic_client/application/screen/__init__.py +13 -0
  10. {synodic_client-0.0.1.dev71 → synodic_client-0.0.1.dev73}/synodic_client/application/screen/action_card.py +37 -4
  11. {synodic_client-0.0.1.dev71 → synodic_client-0.0.1.dev73}/synodic_client/application/screen/install.py +57 -16
  12. {synodic_client-0.0.1.dev71 → synodic_client-0.0.1.dev73}/synodic_client/application/screen/install_workers.py +17 -23
  13. {synodic_client-0.0.1.dev71 → synodic_client-0.0.1.dev73}/synodic_client/application/screen/plugin_row.py +8 -2
  14. {synodic_client-0.0.1.dev71 → synodic_client-0.0.1.dev73}/synodic_client/application/screen/schema.py +4 -2
  15. {synodic_client-0.0.1.dev71 → synodic_client-0.0.1.dev73}/synodic_client/application/screen/screen.py +16 -11
  16. {synodic_client-0.0.1.dev71 → synodic_client-0.0.1.dev73}/synodic_client/application/screen/settings.py +1 -1
  17. {synodic_client-0.0.1.dev71 → synodic_client-0.0.1.dev73}/synodic_client/application/screen/tool_update_controller.py +13 -7
  18. {synodic_client-0.0.1.dev71 → synodic_client-0.0.1.dev73}/synodic_client/application/screen/tray.py +34 -16
  19. {synodic_client-0.0.1.dev71 → synodic_client-0.0.1.dev73}/synodic_client/application/update_controller.py +12 -9
  20. {synodic_client-0.0.1.dev71 → synodic_client-0.0.1.dev73}/synodic_client/application/update_model.py +1 -1
  21. {synodic_client-0.0.1.dev71 → synodic_client-0.0.1.dev73}/synodic_client/application/workers.py +4 -2
  22. synodic_client-0.0.1.dev73/synodic_client/cli/__init__.py +74 -0
  23. synodic_client-0.0.1.dev73/synodic_client/cli/config.py +92 -0
  24. synodic_client-0.0.1.dev73/synodic_client/cli/context.py +29 -0
  25. synodic_client-0.0.1.dev73/synodic_client/cli/debug.py +85 -0
  26. synodic_client-0.0.1.dev73/synodic_client/cli/output.py +60 -0
  27. synodic_client-0.0.1.dev73/synodic_client/cli/project.py +77 -0
  28. synodic_client-0.0.1.dev73/synodic_client/cli/tool.py +93 -0
  29. synodic_client-0.0.1.dev73/synodic_client/cli/update.py +81 -0
  30. {synodic_client-0.0.1.dev71 → synodic_client-0.0.1.dev73}/synodic_client/logging.py +5 -4
  31. synodic_client-0.0.1.dev73/synodic_client/operations/__init__.py +84 -0
  32. synodic_client-0.0.1.dev73/synodic_client/operations/bootstrap.py +48 -0
  33. synodic_client-0.0.1.dev73/synodic_client/operations/config.py +67 -0
  34. synodic_client-0.0.1.dev73/synodic_client/operations/install.py +150 -0
  35. synodic_client-0.0.1.dev73/synodic_client/operations/project.py +87 -0
  36. synodic_client-0.0.1.dev73/synodic_client/operations/schema.py +180 -0
  37. synodic_client-0.0.1.dev73/synodic_client/operations/tool.py +388 -0
  38. synodic_client-0.0.1.dev73/synodic_client/operations/update.py +104 -0
  39. {synodic_client-0.0.1.dev71 → synodic_client-0.0.1.dev73}/synodic_client/protocol.py +1 -1
  40. {synodic_client-0.0.1.dev71 → synodic_client-0.0.1.dev73}/synodic_client/schema.py +1 -1
  41. {synodic_client-0.0.1.dev71 → synodic_client-0.0.1.dev73}/synodic_client/subprocess_patch.py +2 -3
  42. synodic_client-0.0.1.dev73/tests/unit/operations/__init__.py +1 -0
  43. synodic_client-0.0.1.dev73/tests/unit/operations/test_config.py +77 -0
  44. synodic_client-0.0.1.dev73/tests/unit/operations/test_install.py +221 -0
  45. synodic_client-0.0.1.dev73/tests/unit/operations/test_project.py +85 -0
  46. synodic_client-0.0.1.dev73/tests/unit/operations/test_tool.py +231 -0
  47. synodic_client-0.0.1.dev73/tests/unit/operations/test_update.py +147 -0
  48. {synodic_client-0.0.1.dev71 → synodic_client-0.0.1.dev73}/tests/unit/qt/test_action_card.py +145 -0
  49. {synodic_client-0.0.1.dev71 → synodic_client-0.0.1.dev73}/tests/unit/qt/test_gather_packages.py +1 -2
  50. {synodic_client-0.0.1.dev71 → synodic_client-0.0.1.dev73}/tests/unit/qt/test_install_preview.py +48 -51
  51. {synodic_client-0.0.1.dev71 → synodic_client-0.0.1.dev73}/tests/unit/qt/test_log_panel.py +21 -29
  52. {synodic_client-0.0.1.dev71 → synodic_client-0.0.1.dev73}/tests/unit/qt/test_logging.py +1 -1
  53. {synodic_client-0.0.1.dev71 → synodic_client-0.0.1.dev73}/tests/unit/qt/test_update_controller.py +4 -2
  54. synodic_client-0.0.1.dev73/tests/unit/test_cli.py +337 -0
  55. synodic_client-0.0.1.dev71/synodic_client/application/bootstrap.py +0 -57
  56. synodic_client-0.0.1.dev71/synodic_client/cli.py +0 -46
  57. synodic_client-0.0.1.dev71/tests/unit/test_cli.py +0 -56
  58. {synodic_client-0.0.1.dev71 → synodic_client-0.0.1.dev73}/LICENSE.md +0 -0
  59. {synodic_client-0.0.1.dev71 → synodic_client-0.0.1.dev73}/README.md +0 -0
  60. {synodic_client-0.0.1.dev71 → synodic_client-0.0.1.dev73}/synodic_client/__init__.py +0 -0
  61. {synodic_client-0.0.1.dev71 → synodic_client-0.0.1.dev73}/synodic_client/__main__.py +0 -0
  62. {synodic_client-0.0.1.dev71 → synodic_client-0.0.1.dev73}/synodic_client/application/__init__.py +0 -0
  63. {synodic_client-0.0.1.dev71 → synodic_client-0.0.1.dev73}/synodic_client/application/config_store.py +0 -0
  64. {synodic_client-0.0.1.dev71 → synodic_client-0.0.1.dev73}/synodic_client/application/icon.py +0 -0
  65. {synodic_client-0.0.1.dev71 → synodic_client-0.0.1.dev73}/synodic_client/application/init.py +0 -0
  66. {synodic_client-0.0.1.dev71 → synodic_client-0.0.1.dev73}/synodic_client/application/package_state.py +0 -0
  67. {synodic_client-0.0.1.dev71 → synodic_client-0.0.1.dev73}/synodic_client/application/screen/card.py +0 -0
  68. {synodic_client-0.0.1.dev71 → synodic_client-0.0.1.dev73}/synodic_client/application/screen/log_panel.py +0 -0
  69. {synodic_client-0.0.1.dev71 → synodic_client-0.0.1.dev73}/synodic_client/application/screen/projects.py +0 -0
  70. {synodic_client-0.0.1.dev71 → synodic_client-0.0.1.dev73}/synodic_client/application/screen/sidebar.py +0 -0
  71. {synodic_client-0.0.1.dev71 → synodic_client-0.0.1.dev73}/synodic_client/application/screen/spinner.py +0 -0
  72. {synodic_client-0.0.1.dev71 → synodic_client-0.0.1.dev73}/synodic_client/application/screen/update_banner.py +0 -0
  73. {synodic_client-0.0.1.dev71 → synodic_client-0.0.1.dev73}/synodic_client/application/theme.py +0 -0
  74. {synodic_client-0.0.1.dev71 → synodic_client-0.0.1.dev73}/synodic_client/application/uri.py +0 -0
  75. {synodic_client-0.0.1.dev71 → synodic_client-0.0.1.dev73}/synodic_client/client.py +0 -0
  76. {synodic_client-0.0.1.dev71 → synodic_client-0.0.1.dev73}/synodic_client/config.py +0 -0
  77. {synodic_client-0.0.1.dev71 → synodic_client-0.0.1.dev73}/synodic_client/py.typed +0 -0
  78. {synodic_client-0.0.1.dev71 → synodic_client-0.0.1.dev73}/synodic_client/resolution.py +0 -0
  79. {synodic_client-0.0.1.dev71 → synodic_client-0.0.1.dev73}/synodic_client/startup.py +0 -0
  80. {synodic_client-0.0.1.dev71 → synodic_client-0.0.1.dev73}/synodic_client/updater.py +0 -0
  81. {synodic_client-0.0.1.dev71 → synodic_client-0.0.1.dev73}/tests/__init__.py +0 -0
  82. {synodic_client-0.0.1.dev71 → synodic_client-0.0.1.dev73}/tests/conftest.py +0 -0
  83. {synodic_client-0.0.1.dev71 → synodic_client-0.0.1.dev73}/tests/unit/__init__.py +0 -0
  84. {synodic_client-0.0.1.dev71 → synodic_client-0.0.1.dev73}/tests/unit/qt/__init__.py +0 -0
  85. {synodic_client-0.0.1.dev71 → synodic_client-0.0.1.dev73}/tests/unit/qt/conftest.py +0 -0
  86. {synodic_client-0.0.1.dev71 → synodic_client-0.0.1.dev73}/tests/unit/qt/test_preview_model.py +0 -0
  87. {synodic_client-0.0.1.dev71 → synodic_client-0.0.1.dev73}/tests/unit/qt/test_settings.py +0 -0
  88. {synodic_client-0.0.1.dev71 → synodic_client-0.0.1.dev73}/tests/unit/qt/test_sidebar.py +0 -0
  89. {synodic_client-0.0.1.dev71 → synodic_client-0.0.1.dev73}/tests/unit/qt/test_tray_window_show.py +0 -0
  90. {synodic_client-0.0.1.dev71 → synodic_client-0.0.1.dev73}/tests/unit/qt/test_update_banner.py +0 -0
  91. {synodic_client-0.0.1.dev71 → synodic_client-0.0.1.dev73}/tests/unit/qt/test_update_feedback.py +0 -0
  92. {synodic_client-0.0.1.dev71 → synodic_client-0.0.1.dev73}/tests/unit/test_client_updater.py +0 -0
  93. {synodic_client-0.0.1.dev71 → synodic_client-0.0.1.dev73}/tests/unit/test_client_version.py +0 -0
  94. {synodic_client-0.0.1.dev71 → synodic_client-0.0.1.dev73}/tests/unit/test_config.py +0 -0
  95. {synodic_client-0.0.1.dev71 → synodic_client-0.0.1.dev73}/tests/unit/test_examples.py +0 -0
  96. {synodic_client-0.0.1.dev71 → synodic_client-0.0.1.dev73}/tests/unit/test_init.py +0 -0
  97. {synodic_client-0.0.1.dev71 → synodic_client-0.0.1.dev73}/tests/unit/test_install.py +0 -0
  98. {synodic_client-0.0.1.dev71 → synodic_client-0.0.1.dev73}/tests/unit/test_resolution.py +0 -0
  99. {synodic_client-0.0.1.dev71 → synodic_client-0.0.1.dev73}/tests/unit/test_updater.py +0 -0
  100. {synodic_client-0.0.1.dev71 → synodic_client-0.0.1.dev73}/tests/unit/test_uri.py +0 -0
  101. {synodic_client-0.0.1.dev71 → synodic_client-0.0.1.dev73}/tests/unit/test_workers.py +0 -0
  102. {synodic_client-0.0.1.dev71 → synodic_client-0.0.1.dev73}/tests/unit/windows/__init__.py +0 -0
  103. {synodic_client-0.0.1.dev71 → synodic_client-0.0.1.dev73}/tests/unit/windows/conftest.py +0 -0
  104. {synodic_client-0.0.1.dev71 → synodic_client-0.0.1.dev73}/tests/unit/windows/test_protocol.py +0 -0
  105. {synodic_client-0.0.1.dev71 → synodic_client-0.0.1.dev73}/tests/unit/windows/test_startup.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: synodic_client
3
- Version: 0.0.1.dev71
3
+ Version: 0.0.1.dev73
4
4
  Author-Email: Synodic Software <contact@synodic.software>
5
5
  License: LGPL-3.0-or-later
6
6
  Project-URL: homepage, https://github.com/synodic/synodic-client
@@ -8,7 +8,7 @@ Project-URL: repository, https://github.com/synodic/synodic-client
8
8
  Requires-Python: <3.15,>=3.14
9
9
  Requires-Dist: pyside6>=6.10.2
10
10
  Requires-Dist: packaging>=26.0
11
- Requires-Dist: porringer>=0.2.1.dev81
11
+ Requires-Dist: porringer>=0.2.1.dev85
12
12
  Requires-Dist: qasync>=0.28.0
13
13
  Requires-Dist: velopack>=0.0.1444.dev49733
14
14
  Requires-Dist: typer>=0.24.1
@@ -10,12 +10,12 @@ requires-python = ">=3.14, <3.15"
10
10
  dependencies = [
11
11
  "pyside6>=6.10.2",
12
12
  "packaging>=26.0",
13
- "porringer>=0.2.1.dev81",
13
+ "porringer>=0.2.1.dev85",
14
14
  "qasync>=0.28.0",
15
15
  "velopack>=0.0.1444.dev49733",
16
16
  "typer>=0.24.1",
17
17
  ]
18
- version = "0.0.1.dev71"
18
+ version = "0.0.1.dev73"
19
19
 
20
20
  [project.license]
21
21
  text = "LGPL-3.0-or-later"
@@ -35,7 +35,7 @@ build = [
35
35
  "pyinstaller>=6.19.0",
36
36
  ]
37
37
  lint = [
38
- "ruff>=0.15.5",
38
+ "ruff>=0.15.6",
39
39
  "pyrefly>=0.56.0",
40
40
  ]
41
41
  test = [
@@ -62,6 +62,8 @@ ignore = [
62
62
  "E111",
63
63
  "E114",
64
64
  "E117",
65
+ "PLC0415",
66
+ "PLR0913",
65
67
  ]
66
68
  select = [
67
69
  "D",
@@ -75,14 +77,6 @@ select = [
75
77
  "PT",
76
78
  ]
77
79
 
78
- [tool.ruff.lint.per-file-ignores]
79
- "synodic_client/application/bootstrap.py" = [
80
- "E402",
81
- ]
82
- "synodic_client/cli.py" = [
83
- "PLC0415",
84
- ]
85
-
86
80
  [tool.ruff.lint.pydocstyle]
87
81
  convention = "google"
88
82
 
@@ -108,14 +102,12 @@ allow-prereleases = true
108
102
 
109
103
  [tool.pdm.scripts]
110
104
  analyze = "ruff check"
105
+ dev = "synodic-c --dev"
111
106
  format = "ruff format"
112
107
  test = "pytest --cov=synodic_client --verbose tests"
113
108
  type-check = "pyrefly check"
114
109
  serve = "zensical serve"
115
110
 
116
- [tool.pdm.scripts.dev]
117
- call = "tool.scripts.dev:main"
118
-
119
111
  [tool.pdm.scripts.lint]
120
112
  composite = [
121
113
  "analyze",
@@ -0,0 +1,63 @@
1
+ """Bootstrap entry point for PyInstaller builds.
2
+
3
+ Runs the lightweight startup preamble — logging, Velopack hooks, and
4
+ protocol registration — **before** importing heavy modules (PySide6,
5
+ porringer). Velopack's install/uninstall/update hooks have strict
6
+ timeouts (15–30 s) and must complete before the process is killed.
7
+
8
+ Import order matters:
9
+ 1. stdlib + config (pure-Python, fast)
10
+ 2. configure_logging() — now Qt-free
11
+ 3. initialize_velopack() — hooks run with logging active
12
+ 4. run_startup_preamble() — protocol, config seed, auto-startup
13
+ 5. import qt.application — PySide6 / porringer loaded here
14
+ """
15
+
16
+ import logging
17
+ import sys
18
+ import traceback
19
+
20
+
21
+ def bootstrap() -> None:
22
+ """Execute the ordered bootstrap sequence."""
23
+ try:
24
+ from synodic_client.config import set_dev_mode
25
+ from synodic_client.logging import configure_logging
26
+ from synodic_client.protocol import extract_uri_from_args
27
+ from synodic_client.subprocess_patch import apply as _apply_subprocess_patch
28
+ from synodic_client.updater import initialize_velopack
29
+ except Exception:
30
+ # Last-resort crash log when imports fail before logging is configured.
31
+ import os
32
+
33
+ _fallback = os.path.join(os.environ.get('LOCALAPPDATA', '.'), 'Synodic', 'logs', 'bootstrap-crash.log')
34
+ os.makedirs(os.path.dirname(_fallback), exist_ok=True)
35
+ with open(_fallback, 'a', encoding='utf-8') as _f:
36
+ _f.write(traceback.format_exc())
37
+ raise
38
+
39
+ # Parse flags early so logging uses the right filename and level.
40
+ dev_mode = '--dev' in sys.argv[1:]
41
+ debug = '--debug' in sys.argv[1:]
42
+ set_dev_mode(dev_mode)
43
+ _apply_subprocess_patch()
44
+
45
+ configure_logging(debug=debug)
46
+
47
+ logger = logging.getLogger(__name__)
48
+ logger.info('Bootstrap started (exe=%s, argv=%s)', sys.executable, sys.argv)
49
+
50
+ initialize_velopack()
51
+
52
+ if not dev_mode:
53
+ from synodic_client.application.init import run_startup_preamble
54
+
55
+ run_startup_preamble(sys.executable)
56
+
57
+ # Heavy imports happen here — PySide6, porringer, etc.
58
+ from synodic_client.application.qt import application
59
+
60
+ application(uri=extract_uri_from_args(), dev_mode=dev_mode, debug=debug)
61
+
62
+
63
+ bootstrap()
@@ -64,6 +64,11 @@ class DataCoordinator:
64
64
  """Shortcut to the current ``DiscoveredPlugins`` instance."""
65
65
  return self._snapshot.discovered
66
66
 
67
+ @property
68
+ def is_stale(self) -> bool:
69
+ """Whether the cached data needs refreshing."""
70
+ return self._stale
71
+
67
72
  def invalidate(self) -> None:
68
73
  """Mark the cached data as stale.
69
74
 
@@ -173,14 +178,27 @@ class DataCoordinator:
173
178
  if isinstance(env, PluginManager) and env.is_available():
174
179
  managers[env.tool_name()] = env
175
180
 
181
+ # Step 5: collect protocol capabilities for each plugin
182
+ capabilities: dict[str, frozenset] = {
183
+ plugin.name: frozenset(discovered.capabilities(plugin.name)) for plugin in plugins
184
+ }
185
+
176
186
  # Derive the un-validated directory list for callers that only
177
187
  # need path + name (e.g. _gather_packages).
178
188
  directories = [r.directory for r in validated]
179
189
 
190
+ logger.info(
191
+ 'Discovery complete: %d plugin(s), %d directory(ies), %d plugin manager(s)',
192
+ len(plugins),
193
+ len(directories),
194
+ len(managers),
195
+ )
196
+
180
197
  return Snapshot(
181
198
  plugins=plugins,
182
199
  directories=directories,
183
200
  validated_directories=validated,
184
201
  discovered=discovered,
185
202
  plugin_managers=managers,
203
+ plugin_capabilities=capabilities,
186
204
  )
@@ -0,0 +1,271 @@
1
+ """Debug automation interface for agent and developer interaction.
2
+
3
+ Provides a command handler that serialises application domain state and
4
+ dispatches deterministic actions via the existing controller/service
5
+ layer. Commands arrive over the :class:`SingleInstance` IPC socket and
6
+ responses are returned as JSON strings.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import dataclasses
12
+ import json
13
+ import logging
14
+ import sys
15
+ from collections.abc import Callable
16
+ from pathlib import Path
17
+ from typing import TYPE_CHECKING
18
+
19
+ from synodic_client.config import is_dev_mode
20
+
21
+ if TYPE_CHECKING:
22
+ from porringer.api import API
23
+
24
+ from synodic_client.application.config_store import ConfigStore
25
+ from synodic_client.application.data import DataCoordinator
26
+ from synodic_client.application.screen.screen import MainWindow
27
+ from synodic_client.application.screen.settings import SettingsWindow
28
+ from synodic_client.application.screen.tool_update_controller import ToolUpdateOrchestrator
29
+ from synodic_client.application.update_controller import UpdateController
30
+ from synodic_client.application.update_model import UpdateModel
31
+ from synodic_client.client import Client
32
+
33
+ logger = logging.getLogger(__name__)
34
+
35
+ _ACTIONS: dict[str, str] = {
36
+ 'check_update': 'Trigger a self-update check.',
37
+ 'tool_update': 'Run tool/package updates for all plugins.',
38
+ 'refresh_data': 'Mark cached data as stale (next refresh re-fetches).',
39
+ 'show_main': 'Show and raise the main window.',
40
+ 'show_settings': 'Show the settings window.',
41
+ 'apply_update': 'Apply a downloaded update and restart.',
42
+ 'list_projects': 'List cached project directories with validation status.',
43
+ 'add_project': 'Add a directory to the project cache. Arg: <path>',
44
+ 'remove_project': 'Remove a directory from the project cache. Arg: <path>',
45
+ 'project_status': 'Dump per-action preview status. Arg (optional): <path>',
46
+ 'select_project': 'Select a project in the sidebar. Arg: <path>',
47
+ }
48
+
49
+
50
+ @dataclasses.dataclass
51
+ class DebugServices:
52
+ """Bundle of service references for the debug handler."""
53
+
54
+ client: Client
55
+ porringer: API
56
+ coordinator: DataCoordinator | None
57
+ config_store: ConfigStore
58
+ update_controller: UpdateController
59
+ update_model: UpdateModel
60
+ tool_orchestrator: ToolUpdateOrchestrator
61
+ main_window: MainWindow
62
+ settings_window: SettingsWindow
63
+
64
+
65
+ class DebugHandler:
66
+ """Routes debug IPC commands to the application service layer."""
67
+
68
+ def __init__(self, services: DebugServices) -> None:
69
+ """Initialize the debug handler with service references."""
70
+ self._s = services
71
+
72
+ def handle(self, command: str) -> str:
73
+ """Dispatch a debug command and return a JSON response string."""
74
+ logger.debug('Debug command received: %s', command)
75
+
76
+ if command == 'state':
77
+ return self._handle_state()
78
+ if command == 'actions':
79
+ return self._handle_actions()
80
+ if command.startswith('action:'):
81
+ remainder = command[len('action:') :]
82
+ name, _, arg = remainder.partition(':')
83
+ return self._handle_action(name, arg or None)
84
+
85
+ return json.dumps({'error': f'unknown command: {command}'})
86
+
87
+ def _handle_state(self) -> str:
88
+ """Serialise current application domain state."""
89
+ config = self._s.config_store.config
90
+ coordinator = self._s.coordinator
91
+ model = self._s.update_model
92
+
93
+ snapshot = coordinator.snapshot if coordinator is not None else None
94
+ data_stale = coordinator.is_stale if coordinator is not None else None
95
+
96
+ state = {
97
+ 'app': {
98
+ 'version': str(self._s.client.version),
99
+ 'dev_mode': is_dev_mode(),
100
+ 'frozen': getattr(sys, 'frozen', False),
101
+ 'platform': sys.platform,
102
+ },
103
+ 'config': dataclasses.asdict(config),
104
+ 'update': {
105
+ 'phase': model.phase.name,
106
+ 'version': model.version,
107
+ 'error_message': model.error_message,
108
+ },
109
+ 'data': {
110
+ 'plugin_count': len(snapshot.plugins) if snapshot else 0,
111
+ 'directory_count': len(snapshot.directories) if snapshot else 0,
112
+ 'manager_count': len(snapshot.plugin_managers) if snapshot else 0,
113
+ 'stale': data_stale,
114
+ },
115
+ 'windows': {
116
+ 'main_visible': self._s.main_window.isVisible(),
117
+ 'settings_visible': self._s.settings_window.isVisible(),
118
+ },
119
+ }
120
+ return json.dumps(state, default=str)
121
+
122
+ @staticmethod
123
+ def _handle_actions() -> str:
124
+ """Return a list of available action names with descriptions."""
125
+ return json.dumps({'actions': _ACTIONS})
126
+
127
+ def _handle_action(self, name: str, arg: str | None = None) -> str:
128
+ """Dispatch a named action to the appropriate controller."""
129
+ if name not in _ACTIONS:
130
+ return json.dumps({'error': f'unknown action: {name}', 'available': list(_ACTIONS)})
131
+
132
+ # Actions that return their own JSON response
133
+ _project_dispatch: dict[str, Callable[[str | None], str]] = {
134
+ 'list_projects': lambda _: self._handle_list_projects(),
135
+ 'add_project': self._handle_add_project,
136
+ 'remove_project': self._handle_remove_project,
137
+ 'project_status': self._handle_project_status,
138
+ 'select_project': self._handle_select_project,
139
+ }
140
+
141
+ if name in _project_dispatch:
142
+ return _project_dispatch[name](arg)
143
+
144
+ # Fire-and-forget actions
145
+ if name == 'check_update':
146
+ self._s.update_controller.check_now(silent=False)
147
+ elif name == 'tool_update':
148
+ self._s.tool_orchestrator.on_tool_update()
149
+ elif name == 'refresh_data':
150
+ if self._s.coordinator is not None:
151
+ self._s.coordinator.invalidate()
152
+ elif name == 'show_main':
153
+ self._s.main_window.show()
154
+ self._s.main_window.raise_()
155
+ self._s.main_window.activateWindow()
156
+ elif name == 'show_settings':
157
+ self._s.settings_window.show()
158
+ elif name == 'apply_update':
159
+ self._s.update_controller.request_apply()
160
+
161
+ return json.dumps({'ok': True, 'action': name})
162
+
163
+ # -- Project management actions ----------------------------------------
164
+
165
+ def _handle_list_projects(self) -> str:
166
+ """List all cached project directories with validation status."""
167
+ from synodic_client.operations.project import list_projects
168
+
169
+ projects = list_projects(self._s.porringer)
170
+ return json.dumps({'projects': [dataclasses.asdict(p) for p in projects]})
171
+
172
+ def _handle_add_project(self, arg: str | None) -> str:
173
+ """Add a directory to the porringer cache."""
174
+ if not arg:
175
+ return json.dumps({'error': 'add_project requires a path argument'})
176
+
177
+ from synodic_client.operations.project import add_project
178
+
179
+ try:
180
+ add_project(self._s.porringer, arg)
181
+ except (NotADirectoryError, ValueError) as exc:
182
+ return json.dumps({'error': str(exc)})
183
+
184
+ if self._s.coordinator is not None:
185
+ self._s.coordinator.invalidate()
186
+
187
+ projects_view = self._s.main_window._projects_view
188
+ if projects_view is not None:
189
+ projects_view.refresh()
190
+
191
+ return json.dumps({'ok': True, 'action': 'add_project', 'path': arg})
192
+
193
+ def _handle_remove_project(self, arg: str | None) -> str:
194
+ """Remove a directory from the porringer cache."""
195
+ if not arg:
196
+ return json.dumps({'error': 'remove_project requires a path argument'})
197
+
198
+ from synodic_client.operations.project import remove_project
199
+
200
+ remove_project(self._s.porringer, arg)
201
+
202
+ if self._s.coordinator is not None:
203
+ self._s.coordinator.invalidate()
204
+
205
+ projects_view = self._s.main_window._projects_view
206
+ if projects_view is not None:
207
+ projects_view.refresh()
208
+
209
+ return json.dumps({'ok': True, 'action': 'remove_project', 'path': arg})
210
+
211
+ def _handle_project_status(self, arg: str | None) -> str:
212
+ """Dump per-action preview status for a project."""
213
+ projects_view = self._s.main_window._projects_view
214
+ if projects_view is None:
215
+ return json.dumps({'error': 'projects view not initialised — run show_main first'})
216
+
217
+ if arg:
218
+ target = Path(arg)
219
+ else:
220
+ target = projects_view._sidebar.selected_path
221
+ if target is None:
222
+ return json.dumps({'error': 'no project selected and no path argument provided'})
223
+
224
+ widget = projects_view._widgets.get(target)
225
+ if widget is None:
226
+ available = [str(p) for p in projects_view._widgets]
227
+ return json.dumps({'error': f'no widget for path: {target}', 'available_paths': available})
228
+
229
+ model = widget.model
230
+ actions = []
231
+ for state in model.action_states:
232
+ act = state.action
233
+ entry: dict[str, object] = {
234
+ 'description': act.description,
235
+ 'kind': act.kind.name if act.kind else None,
236
+ 'status': state.status,
237
+ }
238
+ if act.package is not None:
239
+ entry['package'] = act.package.name
240
+ if act.package.constraint:
241
+ entry['constraint'] = act.package.constraint
242
+ if act.installer:
243
+ entry['installer'] = act.installer
244
+ actions.append(entry)
245
+
246
+ needed = sum(1 for s in model.action_states if s.status == 'Needed')
247
+ satisfied = sum(1 for s in model.action_states if '\u2713' in s.status)
248
+ pending = sum(1 for s in model.action_states if s.status == 'Pending')
249
+ upgradable = len(model.upgradable_keys)
250
+
251
+ return json.dumps({
252
+ 'path': str(target),
253
+ 'phase': model.phase.name,
254
+ 'action_count': len(model.action_states),
255
+ 'checked_count': model.checked_count,
256
+ 'actions': actions,
257
+ 'summary': {
258
+ 'needed': needed,
259
+ 'satisfied': satisfied,
260
+ 'pending': pending,
261
+ 'upgradable': upgradable,
262
+ },
263
+ })
264
+
265
+ def _handle_select_project(self, arg: str | None) -> str:
266
+ """Select a project in the sidebar."""
267
+ if not arg:
268
+ return json.dumps({'error': 'select_project requires a path argument'})
269
+
270
+ self._s.main_window._navigate_to_project(arg)
271
+ return json.dumps({'ok': True, 'action': 'select_project', 'path': arg})
@@ -3,9 +3,13 @@
3
3
  Ensures only one instance of Synodic Client runs at a time. When a second
4
4
  instance is launched (e.g. by clicking a ``synodic://`` URI), it sends the
5
5
  URI to the already-running instance and exits.
6
+
7
+ Debug commands (prefixed with ``debug:``) receive a JSON response on the
8
+ same connection before it is closed.
6
9
  """
7
10
 
8
11
  import logging
12
+ from collections.abc import Callable
9
13
 
10
14
  from PySide6.QtCore import QByteArray, QObject, Signal
11
15
  from PySide6.QtNetwork import QLocalServer, QLocalSocket
@@ -18,6 +22,13 @@ logger = logging.getLogger(__name__)
18
22
  _SERVER_NAME = 'synodic-client'
19
23
  _SERVER_NAME_DEV = 'synodic-client-dev'
20
24
 
25
+ _DEBUG_PREFIX = 'debug:'
26
+
27
+
28
+ def _decode(raw: bytes | bytearray | memoryview) -> str:
29
+ """Decode raw socket data to a UTF-8 string."""
30
+ return bytes(raw).decode('utf-8')
31
+
21
32
 
22
33
  def _server_name() -> str:
23
34
  """Return the server name, namespaced for dev mode."""
@@ -41,6 +52,16 @@ class SingleInstance(QObject):
41
52
  """
42
53
  super().__init__(parent)
43
54
  self._server: QLocalServer | None = None
55
+ self._debug_handler: Callable[[str], str] | None = None
56
+
57
+ def set_debug_handler(self, handler: Callable[[str], str]) -> None:
58
+ """Register a handler for ``debug:`` IPC commands.
59
+
60
+ Args:
61
+ handler: Callable that accepts a command string (without the
62
+ ``debug:`` prefix) and returns a JSON response string.
63
+ """
64
+ self._debug_handler = handler
44
65
 
45
66
  @staticmethod
46
67
  def try_send_to_existing(message: str) -> bool:
@@ -65,6 +86,34 @@ class SingleInstance(QObject):
65
86
 
66
87
  return False
67
88
 
89
+ @staticmethod
90
+ def send_debug_command(command: str) -> str:
91
+ """Send a debug command to the running instance and return its response.
92
+
93
+ Args:
94
+ command: The debug command (without the ``debug:`` prefix).
95
+
96
+ Returns:
97
+ The JSON response string from the running instance, or an
98
+ error JSON string if the connection failed.
99
+ """
100
+ socket = QLocalSocket()
101
+ socket.connectToServer(_server_name())
102
+
103
+ if not socket.waitForConnected(SOCKET_TIMEOUT_MS):
104
+ return '{"error": "No running instance found"}'
105
+
106
+ socket.write(QByteArray(f'{_DEBUG_PREFIX}{command}'.encode()))
107
+ socket.waitForBytesWritten(SOCKET_TIMEOUT_MS)
108
+
109
+ if not socket.waitForReadyRead(SOCKET_TIMEOUT_MS):
110
+ socket.disconnectFromServer()
111
+ return '{"error": "Timed out waiting for response"}'
112
+
113
+ raw = socket.readAll().data()
114
+ socket.disconnectFromServer()
115
+ return _decode(raw)
116
+
68
117
  def start_server(self) -> bool:
69
118
  """Start the local socket server to listen for incoming messages.
70
119
 
@@ -98,8 +147,17 @@ class SingleInstance(QObject):
98
147
 
99
148
  if socket.waitForReadyRead(SOCKET_TIMEOUT_MS):
100
149
  raw = socket.readAll().data()
101
- data = raw.decode('utf-8') if isinstance(raw, (bytes, bytearray)) else str(raw)
102
- if data:
150
+ data = _decode(raw)
151
+
152
+ if data.startswith(_DEBUG_PREFIX):
153
+ command = data[len(_DEBUG_PREFIX) :]
154
+ if self._debug_handler is not None:
155
+ response = self._debug_handler(command)
156
+ else:
157
+ response = '{"error": "debug handler not registered"}'
158
+ socket.write(QByteArray(response.encode('utf-8')))
159
+ socket.waitForBytesWritten(SOCKET_TIMEOUT_MS)
160
+ elif data:
103
161
  logger.info('Received message from another instance: %s', data)
104
162
  self.uri_received.emit(data)
105
163
  else: