bugcap 0.6.0__tar.gz → 0.7.1__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 (98) hide show
  1. {bugcap-0.6.0/src/bugcap.egg-info → bugcap-0.7.1}/PKG-INFO +6 -3
  2. {bugcap-0.6.0 → bugcap-0.7.1}/README.md +5 -2
  3. {bugcap-0.6.0 → bugcap-0.7.1}/pyproject.toml +1 -1
  4. bugcap-0.7.1/src/bugcap/__init__.py +1 -0
  5. {bugcap-0.6.0 → bugcap-0.7.1}/src/bugcap/backends.py +73 -2
  6. bugcap-0.7.1/src/bugcap/capture.py +122 -0
  7. {bugcap-0.6.0 → bugcap-0.7.1}/src/bugcap/cli.py +2 -0
  8. {bugcap-0.6.0 → bugcap-0.7.1}/src/bugcap/drafts.py +65 -6
  9. bugcap-0.7.1/src/bugcap/live.py +1090 -0
  10. {bugcap-0.6.0 → bugcap-0.7.1}/src/bugcap/live_session.py +127 -33
  11. bugcap-0.7.1/src/bugcap/opener.py +49 -0
  12. {bugcap-0.6.0 → bugcap-0.7.1}/src/bugcap/recorder.py +74 -7
  13. bugcap-0.7.1/src/bugcap/screens.py +330 -0
  14. bugcap-0.7.1/src/bugcap/shortcuts.py +109 -0
  15. bugcap-0.7.1/src/bugcap/targets.py +100 -0
  16. {bugcap-0.6.0 → bugcap-0.7.1/src/bugcap.egg-info}/PKG-INFO +6 -3
  17. {bugcap-0.6.0 → bugcap-0.7.1}/src/bugcap.egg-info/SOURCES.txt +9 -1
  18. bugcap-0.7.1/tests/test_backends.py +295 -0
  19. bugcap-0.7.1/tests/test_capture.py +167 -0
  20. {bugcap-0.6.0 → bugcap-0.7.1}/tests/test_drafts.py +66 -0
  21. {bugcap-0.6.0 → bugcap-0.7.1}/tests/test_live_session.py +219 -1
  22. bugcap-0.7.1/tests/test_opener.py +76 -0
  23. bugcap-0.7.1/tests/test_recorder_argv.py +139 -0
  24. {bugcap-0.6.0 → bugcap-0.7.1}/tests/test_recorder_run.py +13 -0
  25. bugcap-0.7.1/tests/test_screens.py +174 -0
  26. bugcap-0.7.1/tests/test_shortcuts.py +79 -0
  27. bugcap-0.7.1/tests/test_targets.py +81 -0
  28. bugcap-0.6.0/src/bugcap/__init__.py +0 -1
  29. bugcap-0.6.0/src/bugcap/capture.py +0 -84
  30. bugcap-0.6.0/src/bugcap/live.py +0 -624
  31. bugcap-0.6.0/tests/test_backends.py +0 -133
  32. bugcap-0.6.0/tests/test_capture.py +0 -95
  33. bugcap-0.6.0/tests/test_recorder_argv.py +0 -72
  34. {bugcap-0.6.0 → bugcap-0.7.1}/LICENSE +0 -0
  35. {bugcap-0.6.0 → bugcap-0.7.1}/setup.cfg +0 -0
  36. {bugcap-0.6.0 → bugcap-0.7.1}/src/bugcap/agent_api.py +0 -0
  37. {bugcap-0.6.0 → bugcap-0.7.1}/src/bugcap/config.py +0 -0
  38. {bugcap-0.6.0 → bugcap-0.7.1}/src/bugcap/dashboard/__init__.py +0 -0
  39. {bugcap-0.6.0 → bugcap-0.7.1}/src/bugcap/dashboard/api.py +0 -0
  40. {bugcap-0.6.0 → bugcap-0.7.1}/src/bugcap/dashboard/media.py +0 -0
  41. {bugcap-0.6.0 → bugcap-0.7.1}/src/bugcap/dashboard/server.py +0 -0
  42. {bugcap-0.6.0 → bugcap-0.7.1}/src/bugcap/dashboard/static/app.css +0 -0
  43. {bugcap-0.6.0 → bugcap-0.7.1}/src/bugcap/dashboard/static/app.js +0 -0
  44. {bugcap-0.6.0 → bugcap-0.7.1}/src/bugcap/dashboard/static/index.html +0 -0
  45. {bugcap-0.6.0 → bugcap-0.7.1}/src/bugcap/errors.py +0 -0
  46. {bugcap-0.6.0 → bugcap-0.7.1}/src/bugcap/ghcli.py +0 -0
  47. {bugcap-0.6.0 → bugcap-0.7.1}/src/bugcap/ingest.py +0 -0
  48. {bugcap-0.6.0 → bugcap-0.7.1}/src/bugcap/logs.py +0 -0
  49. {bugcap-0.6.0 → bugcap-0.7.1}/src/bugcap/mcp_server.py +0 -0
  50. {bugcap-0.6.0 → bugcap-0.7.1}/src/bugcap/paths.py +0 -0
  51. {bugcap-0.6.0 → bugcap-0.7.1}/src/bugcap/refs.py +0 -0
  52. {bugcap-0.6.0 → bugcap-0.7.1}/src/bugcap/repo.py +0 -0
  53. {bugcap-0.6.0 → bugcap-0.7.1}/src/bugcap/service.py +0 -0
  54. {bugcap-0.6.0 → bugcap-0.7.1}/src/bugcap/store.py +0 -0
  55. {bugcap-0.6.0 → bugcap-0.7.1}/src/bugcap/sync.py +0 -0
  56. {bugcap-0.6.0 → bugcap-0.7.1}/src/bugcap/tomlio.py +0 -0
  57. {bugcap-0.6.0 → bugcap-0.7.1}/src/bugcap/validation.py +0 -0
  58. {bugcap-0.6.0 → bugcap-0.7.1}/src/bugcap.egg-info/dependency_links.txt +0 -0
  59. {bugcap-0.6.0 → bugcap-0.7.1}/src/bugcap.egg-info/entry_points.txt +0 -0
  60. {bugcap-0.6.0 → bugcap-0.7.1}/src/bugcap.egg-info/requires.txt +0 -0
  61. {bugcap-0.6.0 → bugcap-0.7.1}/src/bugcap.egg-info/top_level.txt +0 -0
  62. {bugcap-0.6.0 → bugcap-0.7.1}/tests/test_agent_api.py +0 -0
  63. {bugcap-0.6.0 → bugcap-0.7.1}/tests/test_cli_attach.py +0 -0
  64. {bugcap-0.6.0 → bugcap-0.7.1}/tests/test_cli_dashboard.py +0 -0
  65. {bugcap-0.6.0 → bugcap-0.7.1}/tests/test_cli_init_capture_list.py +0 -0
  66. {bugcap-0.6.0 → bugcap-0.7.1}/tests/test_cli_live_headless.py +0 -0
  67. {bugcap-0.6.0 → bugcap-0.7.1}/tests/test_cli_media.py +0 -0
  68. {bugcap-0.6.0 → bugcap-0.7.1}/tests/test_cli_no_image_delete.py +0 -0
  69. {bugcap-0.6.0 → bugcap-0.7.1}/tests/test_cli_record.py +0 -0
  70. {bugcap-0.6.0 → bugcap-0.7.1}/tests/test_cli_setup.py +0 -0
  71. {bugcap-0.6.0 → bugcap-0.7.1}/tests/test_cli_triage.py +0 -0
  72. {bugcap-0.6.0 → bugcap-0.7.1}/tests/test_dashboard_api.py +0 -0
  73. {bugcap-0.6.0 → bugcap-0.7.1}/tests/test_dashboard_media.py +0 -0
  74. {bugcap-0.6.0 → bugcap-0.7.1}/tests/test_dashboard_mutations.py +0 -0
  75. {bugcap-0.6.0 → bugcap-0.7.1}/tests/test_dashboard_security.py +0 -0
  76. {bugcap-0.6.0 → bugcap-0.7.1}/tests/test_dashboard_ui_static.py +0 -0
  77. {bugcap-0.6.0 → bugcap-0.7.1}/tests/test_file_attachments.py +0 -0
  78. {bugcap-0.6.0 → bugcap-0.7.1}/tests/test_ghcli.py +0 -0
  79. {bugcap-0.6.0 → bugcap-0.7.1}/tests/test_github_media_sync.py +0 -0
  80. {bugcap-0.6.0 → bugcap-0.7.1}/tests/test_github_pull.py +0 -0
  81. {bugcap-0.6.0 → bugcap-0.7.1}/tests/test_github_sync.py +0 -0
  82. {bugcap-0.6.0 → bugcap-0.7.1}/tests/test_image_refs.py +0 -0
  83. {bugcap-0.6.0 → bugcap-0.7.1}/tests/test_ingest.py +0 -0
  84. {bugcap-0.6.0 → bugcap-0.7.1}/tests/test_ingest_url.py +0 -0
  85. {bugcap-0.6.0 → bugcap-0.7.1}/tests/test_mcp.py +0 -0
  86. {bugcap-0.6.0 → bugcap-0.7.1}/tests/test_mcp_logging.py +0 -0
  87. {bugcap-0.6.0 → bugcap-0.7.1}/tests/test_paths.py +0 -0
  88. {bugcap-0.6.0 → bugcap-0.7.1}/tests/test_recorder_devices.py +0 -0
  89. {bugcap-0.6.0 → bugcap-0.7.1}/tests/test_recorder_media_real.py +0 -0
  90. {bugcap-0.6.0 → bugcap-0.7.1}/tests/test_refs.py +0 -0
  91. {bugcap-0.6.0 → bugcap-0.7.1}/tests/test_refs_validate_rewrite.py +0 -0
  92. {bugcap-0.6.0 → bugcap-0.7.1}/tests/test_regression_output.py +0 -0
  93. {bugcap-0.6.0 → bugcap-0.7.1}/tests/test_repo_config.py +0 -0
  94. {bugcap-0.6.0 → bugcap-0.7.1}/tests/test_repo_values.py +0 -0
  95. {bugcap-0.6.0 → bugcap-0.7.1}/tests/test_service_media.py +0 -0
  96. {bugcap-0.6.0 → bugcap-0.7.1}/tests/test_service_refs.py +0 -0
  97. {bugcap-0.6.0 → bugcap-0.7.1}/tests/test_store_migration.py +0 -0
  98. {bugcap-0.6.0 → bugcap-0.7.1}/tests/test_sync_consent.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: bugcap
3
- Version: 0.6.0
3
+ Version: 0.7.1
4
4
  Summary: Local-first, agent-readable bug capture: annotated screenshots + notes, stored locally, synced to trackers on your own schedule.
5
5
  Author: Konrad Tagnon Amen ALAHASSA
6
6
  License: MIT
@@ -15,6 +15,7 @@ Requires-Dist: pytest>=7; extra == "dev"
15
15
  Requires-Dist: ruff>=0.6; extra == "dev"
16
16
  Dynamic: license-file
17
17
 
18
+ <!-- mcp-name: io.github.amenalahassa/bugcap -->
18
19
  <p align="center">
19
20
  <img src="https://raw.githubusercontent.com/amenalahassa/bugcap/master/docs/assets/logo.svg" alt="bugcap" width="96">
20
21
  </p>
@@ -64,7 +65,7 @@ bugcap keeps the whole loop in one place:
64
65
  | 🔁 **Sync when ready** | Push to a GitHub issue with the usual inline image, plus a plain copy agents can read. |
65
66
  | 🎞️ **Record what happens** | Short screen recordings as animated GIF, video or keyframes, with size caps. |
66
67
  | 🖥️ **Triage in a browser** | A local dashboard on `127.0.0.1` to browse, filter and set statuses. |
67
- | 🪟 **Live mode** | An always-on-top window (top-right) to capture or record, stage several items, then file a new bug, add to an existing one, or log a note-only bug. |
68
+ | 🪟 **Live mode** | An always-on-top window (top-right) to capture or record, stage several items (open or remove any of them), then file a new bug, add to an existing one, or log a note-only bug. |
68
69
 
69
70
  ## Quick start
70
71
 
@@ -179,7 +180,9 @@ Module map (`src/bugcap/`):
179
180
  | `ingest.py` | Path/glob/URL inputs: magic-byte validation, size/timeout/redirect limits, atomic copy into `images/`. |
180
181
  | `refs.py` | `@` reference parsing (code/email aware), validation, rewrite, display. |
181
182
  | `recorder.py` | `ffmpeg`/`wf-recorder` argv per OS, stop/caps watchdogs, animated/frames post-processing. |
182
- | `live.py` + `live_session.py` + `drafts.py` | Tk control window (thin) over a pure state machine; drafts on disk. |
183
+ | `live.py` + `live_session.py` + `drafts.py` | Tk control window (thin) over a pure state machine; drafts (and the autosaved bug in progress) on disk. |
184
+ | `screens.py` + `targets.py` | Monitors and windows per OS (Win32 via `ctypes`, `xrandr`/`wmctrl`, `wlr-randr`/`swaymsg`), what each platform can capture, and the remembered capture target. |
185
+ | `shortcuts.py` + `opener.py` | Live mode's shortcut table (bindings, button hints, F1 help); opening a staged item in the OS default app. |
183
186
  | `dashboard/` | `http.server` UI + JSON API (loopback, Host/Origin checks, write token, Range media, static allowlist). |
184
187
  | `repo.py` + `tomlio.py` | `.bugcap.toml` discovery/read/write (tag, github slug, `[sync]`). |
185
188
  | `config.py` | Global `config.toml`: `[sync]` defaults, `[consent]` for image commits, `[log]`. |
@@ -1,3 +1,4 @@
1
+ <!-- mcp-name: io.github.amenalahassa/bugcap -->
1
2
  <p align="center">
2
3
  <img src="https://raw.githubusercontent.com/amenalahassa/bugcap/master/docs/assets/logo.svg" alt="bugcap" width="96">
3
4
  </p>
@@ -47,7 +48,7 @@ bugcap keeps the whole loop in one place:
47
48
  | 🔁 **Sync when ready** | Push to a GitHub issue with the usual inline image, plus a plain copy agents can read. |
48
49
  | 🎞️ **Record what happens** | Short screen recordings as animated GIF, video or keyframes, with size caps. |
49
50
  | 🖥️ **Triage in a browser** | A local dashboard on `127.0.0.1` to browse, filter and set statuses. |
50
- | 🪟 **Live mode** | An always-on-top window (top-right) to capture or record, stage several items, then file a new bug, add to an existing one, or log a note-only bug. |
51
+ | 🪟 **Live mode** | An always-on-top window (top-right) to capture or record, stage several items (open or remove any of them), then file a new bug, add to an existing one, or log a note-only bug. |
51
52
 
52
53
  ## Quick start
53
54
 
@@ -162,7 +163,9 @@ Module map (`src/bugcap/`):
162
163
  | `ingest.py` | Path/glob/URL inputs: magic-byte validation, size/timeout/redirect limits, atomic copy into `images/`. |
163
164
  | `refs.py` | `@` reference parsing (code/email aware), validation, rewrite, display. |
164
165
  | `recorder.py` | `ffmpeg`/`wf-recorder` argv per OS, stop/caps watchdogs, animated/frames post-processing. |
165
- | `live.py` + `live_session.py` + `drafts.py` | Tk control window (thin) over a pure state machine; drafts on disk. |
166
+ | `live.py` + `live_session.py` + `drafts.py` | Tk control window (thin) over a pure state machine; drafts (and the autosaved bug in progress) on disk. |
167
+ | `screens.py` + `targets.py` | Monitors and windows per OS (Win32 via `ctypes`, `xrandr`/`wmctrl`, `wlr-randr`/`swaymsg`), what each platform can capture, and the remembered capture target. |
168
+ | `shortcuts.py` + `opener.py` | Live mode's shortcut table (bindings, button hints, F1 help); opening a staged item in the OS default app. |
166
169
  | `dashboard/` | `http.server` UI + JSON API (loopback, Host/Origin checks, write token, Range media, static allowlist). |
167
170
  | `repo.py` + `tomlio.py` | `.bugcap.toml` discovery/read/write (tag, github slug, `[sync]`). |
168
171
  | `config.py` | Global `config.toml`: `[sync]` defaults, `[consent]` for image commits, `[log]`. |
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "bugcap"
7
- version = "0.6.0"
7
+ version = "0.7.1"
8
8
  description = "Local-first, agent-readable bug capture: annotated screenshots + notes, stored locally, synced to trackers on your own schedule."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -0,0 +1 @@
1
+ __version__ = "0.7.1"
@@ -4,6 +4,7 @@ import shutil
4
4
  import subprocess
5
5
  import sys
6
6
  from dataclasses import dataclass, field
7
+ from pathlib import Path
7
8
  from typing import Optional
8
9
 
9
10
 
@@ -15,10 +16,80 @@ def platform_key() -> str:
15
16
  return "linux"
16
17
 
17
18
 
19
+ # Windows binaries known to install outside PATH with no fallback shim: their installer is a
20
+ # plain MSI/WiX package with no winget "Commands" entry, so neither the installer nor winget
21
+ # ever registers them on PATH (unlike scoop/choco, which always add their own shim dir, or
22
+ # winget's own "portable" installer type, which winget manages the PATH entry for itself).
23
+ # Checked against this project's other backends: satty/grim/wf-recorder are Linux-only and
24
+ # screencapture is macOS-only, so this table only needs Windows-only entries.
25
+ _WINDOWS_FALLBACK_DIRS: dict[str, list[tuple[str, ...]]] = {
26
+ "flameshot": [("Flameshot", "bin")],
27
+ }
28
+
29
+
30
+ def _windows_fallback_candidates(name: str) -> list[Path]:
31
+ # Path parts, not a single string: on CI, tests simulate "windows" by monkeypatching
32
+ # sys.platform while pathlib.Path stays bound to the real (often POSIX) OS, which would
33
+ # treat a literal "Flameshot\bin" as one oddly-named component instead of two.
34
+ rel_parts_list = _WINDOWS_FALLBACK_DIRS.get(name, [])
35
+ roots = [os.environ.get("ProgramFiles"), os.environ.get("ProgramFiles(x86)")]
36
+ return [Path(root, *rel_parts) for root in roots if root for rel_parts in rel_parts_list]
37
+
38
+
39
+ def resolve(name: str) -> Optional[str]:
40
+ """Full path to the `name` executable: PATH first, then (on Windows) known install
41
+ locations for binaries whose installer never touches PATH. Use this instead of
42
+ `shutil.which` directly so a capture/record backend can actually be launched, not just
43
+ detected.
44
+ """
45
+ found = shutil.which(name)
46
+ if found:
47
+ return found
48
+ if platform_key() != "windows":
49
+ return None
50
+ for directory in _windows_fallback_candidates(name):
51
+ candidate = directory / f"{name}.exe"
52
+ if candidate.is_file():
53
+ return str(candidate)
54
+ return None
55
+
56
+
57
+ def ensure_on_user_path(name: str) -> None:
58
+ """If `name` was only found via a fallback install location (not already on PATH),
59
+ persist that directory to the user's PATH registry so later shells and tools - not just
60
+ bugcap - can find it without needing the fallback. Best-effort: any failure is silent,
61
+ since `resolve()` already makes bugcap itself work without this.
62
+ """
63
+ if platform_key() != "windows" or shutil.which(name):
64
+ return
65
+ resolved = resolve(name)
66
+ if not resolved:
67
+ return
68
+ directory = str(Path(resolved).parent)
69
+ try:
70
+ import winreg
71
+
72
+ with winreg.OpenKey(
73
+ winreg.HKEY_CURRENT_USER, "Environment", 0, winreg.KEY_READ | winreg.KEY_SET_VALUE
74
+ ) as key:
75
+ try:
76
+ current, kind = winreg.QueryValueEx(key, "Path")
77
+ except FileNotFoundError:
78
+ current, kind = "", winreg.REG_EXPAND_SZ
79
+ parts = [p for p in current.split(os.pathsep) if p]
80
+ if any(p.lower() == directory.lower() for p in parts):
81
+ return
82
+ parts.append(directory)
83
+ winreg.SetValueEx(key, "Path", 0, kind, os.pathsep.join(parts))
84
+ os.environ["PATH"] = os.environ.get("PATH", "") + os.pathsep + directory
85
+ except OSError:
86
+ pass
87
+
88
+
18
89
  @dataclass
19
90
  class Backend:
20
91
  name: str
21
- binaries: list[str] # every one must be on PATH
92
+ binaries: list[str] # every one must be resolvable (PATH, or a known fallback location)
22
93
  description: str
23
94
  platforms: tuple[str, ...]
24
95
  annotates: bool
@@ -29,7 +100,7 @@ class Backend:
29
100
  def available(self) -> bool:
30
101
  if platform_key() not in self.platforms:
31
102
  return False
32
- return all(shutil.which(b) for b in self.binaries)
103
+ return all(resolve(b) for b in self.binaries)
33
104
 
34
105
 
35
106
  BACKENDS = [
@@ -0,0 +1,122 @@
1
+ import os
2
+ import shutil
3
+ import subprocess
4
+ import sys
5
+ import uuid
6
+ from pathlib import Path
7
+
8
+ from . import backends
9
+ from .paths import images_dir
10
+ from .recorder import TargetUnsupported, _target_parts, platform_key
11
+
12
+ __all__ = ["CaptureError", "TargetUnsupported", "build_screenshot_argv", "capture_screenshot", "has_display",
13
+ "import_image"]
14
+
15
+
16
+ class CaptureError(RuntimeError):
17
+ pass
18
+
19
+
20
+ def has_display() -> bool:
21
+ """Whether an interactive graphical session is available for a capture UI.
22
+
23
+ Linux: a running X11/Wayland session (``DISPLAY`` or ``WAYLAND_DISPLAY``).
24
+ macOS/Windows: assumed present (the window server runs for logged-in users).
25
+ """
26
+ if sys.platform == "win32" or sys.platform == "darwin":
27
+ return True
28
+ return bool(os.environ.get("DISPLAY") or os.environ.get("WAYLAND_DISPLAY"))
29
+
30
+
31
+ def _no_backend_error() -> CaptureError:
32
+ rec = backends.recommended()
33
+ return CaptureError(
34
+ "No capture tool found. Run `bugcap setup` to detect/install one "
35
+ f"(recommended: {rec.name} - {backends.guidance(rec)}), "
36
+ "or pass an existing screenshot with --image."
37
+ )
38
+
39
+
40
+ def import_image(source: Path) -> Path:
41
+ """Copy an existing image into the store (no capture tool needed)."""
42
+ if not source.is_file():
43
+ raise CaptureError(f"Image not found: {source}")
44
+ dest = images_dir() / f"{uuid.uuid4()}{source.suffix.lower() or '.png'}"
45
+ shutil.copyfile(source, dest)
46
+ return dest
47
+
48
+
49
+ def _geometry(rect) -> str:
50
+ x, y, w, h = rect
51
+ return f"{w}x{h}+{x}+{y}"
52
+
53
+
54
+ def build_screenshot_argv(backend: str, target, dest, platform=None, raw=None,
55
+ resolve=lambda name: name) -> list[list[str]]:
56
+ """The command(s) that capture `target` (a `targets.CaptureTarget`, or None for the
57
+ backend's usual interactive capture) into `dest`, run in order. satty needs a grim grab
58
+ into `raw` first. Raises TargetUnsupported for a combination the backend cannot do."""
59
+ kind, rect = _target_parts(target, for_recording=False)
60
+ dest = str(dest)
61
+ if backend == "flameshot":
62
+ exe = resolve("flameshot")
63
+ if kind == "monitor": # annotate on that screen only (bugcap report: multi-monitor Windows)
64
+ return [[exe, "screen", "-n", str(target.monitor.number), "-e", "--path", dest]]
65
+ if kind in ("region", "window"):
66
+ return [[exe, "gui", "--region", _geometry(rect), "--path", dest]]
67
+ return [[exe, "gui", "--path", dest]]
68
+ if backend == "satty":
69
+ grim = [resolve("grim")]
70
+ if kind == "monitor":
71
+ grim += ["-o", target.monitor.id]
72
+ elif kind == "region":
73
+ x, y, w, h = rect
74
+ grim += ["-g", f"{x},{y} {w}x{h}"]
75
+ elif kind == "window":
76
+ raise TargetUnsupported("grim cannot capture a single window")
77
+ return [grim + [str(raw)], [resolve("satty"), "--filename", str(raw), "--output-filename", dest]]
78
+ if backend == "screencapture":
79
+ exe = resolve("screencapture")
80
+ if kind == "monitor":
81
+ return [[exe, "-D", str(target.monitor.number + 1), dest]]
82
+ if kind == "region":
83
+ return [[exe, "-R", ",".join(str(v) for v in rect), dest]]
84
+ if kind == "window":
85
+ return [[exe, "-i", "-w", dest]]
86
+ # -i interactive region/window selection (space toggles window mode)
87
+ return [[exe, "-i", dest]]
88
+ raise CaptureError(f"Unknown backend: {backend}")
89
+
90
+
91
+ def capture_screenshot(target=None) -> Path:
92
+ """Launch the detected backend's capture and return the saved PNG path. Without a
93
+ `target` it is the backend's interactive capture UI; with one (live mode) it covers just
94
+ that monitor, region or window. Blocks until the user finishes (saves or cancels).
95
+ """
96
+ backend = backends.detect()
97
+ if backend is None:
98
+ raise _no_backend_error()
99
+ dest = images_dir() / f"{uuid.uuid4()}.png"
100
+ raw = images_dir() / f"{uuid.uuid4()}-raw.png" if backend.name == "satty" else None
101
+ commands = build_screenshot_argv(backend.name, target, dest, platform_key(), raw,
102
+ resolve=lambda name: backends.resolve(name) or name)
103
+ try:
104
+ for argv in commands:
105
+ result = subprocess.run(argv, capture_output=True, text=True)
106
+ if result.returncode != 0 and backend.name == "flameshot" and argv[1] == "screen":
107
+ # flameshot numbers screens its own way; the monitor's rectangle always works
108
+ fallback = [argv[0], "gui", "--region", _geometry(target.monitor.rect), "--path", str(dest)]
109
+ result = subprocess.run(fallback, capture_output=True, text=True)
110
+ if result.returncode != 0:
111
+ name = Path(argv[0]).stem
112
+ raise CaptureError(f"{name} exited with {result.returncode}: {result.stderr}")
113
+ finally:
114
+ if raw is not None:
115
+ raw.unlink(missing_ok=True)
116
+
117
+ if not dest.exists():
118
+ raise CaptureError(
119
+ "Capture backend exited without saving a file "
120
+ "(did you cancel the capture instead of saving it?)."
121
+ )
122
+ return dest
@@ -229,6 +229,8 @@ def _setup_capture(args: argparse.Namespace) -> int:
229
229
  print(f"error: install command failed (exit {rc}).", file=sys.stderr)
230
230
  return 1
231
231
  if backends.detect() is not None:
232
+ for binary in rec.binaries:
233
+ backends.ensure_on_user_path(binary)
232
234
  print(f"{rec.name} installed.")
233
235
  return 0
234
236
  print(
@@ -1,10 +1,15 @@
1
1
  """Live-mode drafts: unsaved staged media (any number of images / recordings, or none) plus
2
2
  whatever was typed, kept on disk until it is saved as a report or discarded. One
3
3
  `<uuid>.json` sidecar per draft; its media files sit beside it as `<uuid>-*`. A draft made by
4
- an older version is just `<uuid>.png` + `<uuid>.json` and still loads."""
4
+ an older version is just `<uuid>.png` + `<uuid>.json` and still loads.
5
+
6
+ Live mode also autosaves the bug being written as a *progress* draft (`write_progress`): the
7
+ same sidecar, rewritten atomically under a stable id, pointing at the media where it was
8
+ staged (absolute `"path"`) instead of moving it here."""
5
9
  from __future__ import annotations
6
10
 
7
11
  import json
12
+ import os
8
13
  import shutil
9
14
  import sys
10
15
  import uuid
@@ -29,6 +34,7 @@ class Draft:
29
34
  status: str = "open"
30
35
  # [{"kind", "path" (absolute or None), "mime", "size_bytes", "frames": [(path, size)]}]
31
36
  items: list = field(default_factory=list)
37
+ launch_repo: Optional[str] = None # where live mode ran when a progress draft was written
32
38
 
33
39
  def fields(self) -> dict:
34
40
  return {"title": self.title, "notes": self.notes, "tags": list(self.tags or []), "status": self.status}
@@ -47,6 +53,11 @@ def _move(source, target: Path) -> Path:
47
53
  return target
48
54
 
49
55
 
56
+ def _copy(source, target: Path) -> Path:
57
+ shutil.copyfile(str(source), target)
58
+ return target
59
+
60
+
50
61
  def save_draft(repo: Optional[str], png_path, fields: dict, items: Optional[list] = None) -> Draft:
51
62
  """Move the media into drafts/ and write the sidecar. `items` are dicts shaped like
52
63
  `Draft.items`; for a plain screenshot pass `png_path` instead."""
@@ -57,13 +68,16 @@ def save_draft(repo: Optional[str], png_path, fields: dict, items: Optional[list
57
68
  listed = []
58
69
  for n, item in enumerate(items, start=1):
59
70
  path = item.get("path")
60
- new_path = _move(path, directory / f"{draft_id}-{n}{Path(path).suffix}") if path else None
71
+ # a shared upload is stored once under its content hash and may belong to reports: copy it
72
+ keep = _copy if item.get("shared") else _move
73
+ new_path = keep(path, directory / f"{draft_id}-{n}{Path(path).suffix}") if path else None
61
74
  frames = []
62
75
  for m, (frame_path, size) in enumerate(item.get("frames") or [], start=1):
63
76
  frames.append([_move(frame_path, directory / f"{draft_id}-{n}-f{m:03d}.png").name, size])
64
77
  listed.append({
65
78
  "kind": item["kind"], "file": new_path.name if new_path else None,
66
79
  "mime": item.get("mime", "image/png"), "size_bytes": item.get("size_bytes", 0), "frames": frames,
80
+ "number": item.get("number") or n, "source": item.get("source"), "shared": bool(item.get("shared")),
67
81
  })
68
82
  meta = {
69
83
  "created_at": datetime.now(timezone.utc).isoformat(),
@@ -78,6 +92,46 @@ def save_draft(repo: Optional[str], png_path, fields: dict, items: Optional[list
78
92
  return load_draft(draft_id) # type: ignore[return-value]
79
93
 
80
94
 
95
+ def write_progress(draft_id: Optional[str], repo: Optional[str], fields: dict, items: list,
96
+ launch_repo: Optional[str] = None) -> str:
97
+ """Write (or rewrite) the progress draft `draft_id` (a new id when None) and return its id.
98
+ The media stays where it is; the sidecar records absolute paths. Written to a temporary
99
+ file and then renamed, so a crash leaves either the old sidecar or the new one."""
100
+ draft_id = draft_id or str(uuid.uuid4())
101
+ directory = drafts_dir()
102
+ sidecar = directory / f"{draft_id}.json"
103
+ now = datetime.now(timezone.utc).isoformat()
104
+ try:
105
+ created = json.loads(sidecar.read_text()).get("created_at") or now
106
+ except (OSError, ValueError, AttributeError):
107
+ created = now
108
+ meta = {
109
+ "version": 2,
110
+ "created_at": created,
111
+ "updated_at": now,
112
+ "repo": repo,
113
+ "launch_repo": launch_repo if launch_repo is not None else repo,
114
+ "title": fields.get("title", ""),
115
+ "notes": fields.get("notes", ""),
116
+ "tags": list(fields.get("tags") or []),
117
+ "status": fields.get("status") or "open",
118
+ "media": [{
119
+ "number": item.get("number"), "kind": item["kind"], "path": item.get("path"), "file": None,
120
+ "mime": item.get("mime", "image/png"), "size_bytes": item.get("size_bytes", 0),
121
+ "frames": [[str(p), size] for p, size in item.get("frames") or []],
122
+ "source": item.get("source"), "shared": bool(item.get("shared")),
123
+ } for item in items],
124
+ }
125
+ temp = directory / f"{draft_id}.json.tmp"
126
+ temp.write_text(json.dumps(meta, indent=2))
127
+ try:
128
+ os.replace(temp, sidecar)
129
+ except OSError:
130
+ temp.unlink(missing_ok=True)
131
+ raise
132
+ return draft_id
133
+
134
+
81
135
  def _items_from_meta(directory: Path, draft_id: str, meta: dict) -> list:
82
136
  if "media" not in meta: # legacy: one screenshot
83
137
  png = directory / f"{draft_id}.png"
@@ -85,8 +139,9 @@ def _items_from_meta(directory: Path, draft_id: str, meta: dict) -> list:
85
139
  raise ValueError("incomplete draft")
86
140
  return [{"kind": "image", "path": str(png), "mime": "image/png", "size_bytes": png.stat().st_size, "frames": []}]
87
141
  items = []
88
- for entry in meta["media"]:
89
- path = directory / entry["file"] if entry.get("file") else None
142
+ for position, entry in enumerate(meta["media"], start=1):
143
+ # `path` (absolute, media left in place) for a progress draft, `file` (in drafts/) otherwise
144
+ path = Path(entry["path"]) if entry.get("path") else directory / entry["file"] if entry.get("file") else None
90
145
  frames = [(str(directory / name), int(size)) for name, size in entry.get("frames", [])]
91
146
  files = ([path] if path else []) + [Path(p) for p, _ in frames]
92
147
  if not files or not all(f.is_file() for f in files):
@@ -94,6 +149,8 @@ def _items_from_meta(directory: Path, draft_id: str, meta: dict) -> list:
94
149
  items.append({
95
150
  "kind": str(entry["kind"]), "path": str(path) if path else None, "mime": str(entry.get("mime", "")),
96
151
  "size_bytes": int(entry.get("size_bytes", 0)), "frames": frames,
152
+ "number": int(entry.get("number") or position), "source": entry.get("source"),
153
+ "shared": bool(entry.get("shared", False)),
97
154
  })
98
155
  return items
99
156
 
@@ -110,6 +167,7 @@ def load_draft(draft_id: str) -> Optional[Draft]:
110
167
  str(meta.get("title", "")), str(meta.get("notes", "")),
111
168
  [str(t) for t in meta.get("tags", [])], str(meta.get("status", "open")),
112
169
  _items_from_meta(directory, draft_id, meta),
170
+ meta.get("launch_repo", meta.get("repo")),
113
171
  )
114
172
  except (OSError, ValueError, TypeError, KeyError) as exc:
115
173
  print(f"warning: skipping unreadable draft {draft_id}: {exc}", file=sys.stderr)
@@ -117,11 +175,12 @@ def load_draft(draft_id: str) -> Optional[Draft]:
117
175
 
118
176
 
119
177
  def list_drafts(repo: Optional[str]) -> list[Draft]:
120
- """Drafts saved for this repo (or without a repo), oldest first."""
178
+ """Drafts saved for this repo (or without a repo), oldest first. A bug in progress is
179
+ listed both for the repo picked in its form and for the repo live mode ran in."""
121
180
  found = []
122
181
  for sidecar in sorted(drafts_dir().glob("*.json")):
123
182
  draft = load_draft(sidecar.stem)
124
- if draft is not None and draft.repo == repo:
183
+ if draft is not None and repo in (draft.repo, draft.launch_repo):
125
184
  found.append(draft)
126
185
  return sorted(found, key=lambda d: d.created_at)
127
186