bugcap 0.6.0__tar.gz → 0.7.0__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.
- {bugcap-0.6.0/src/bugcap.egg-info → bugcap-0.7.0}/PKG-INFO +5 -3
- {bugcap-0.6.0 → bugcap-0.7.0}/README.md +4 -2
- {bugcap-0.6.0 → bugcap-0.7.0}/pyproject.toml +1 -1
- bugcap-0.7.0/src/bugcap/__init__.py +1 -0
- {bugcap-0.6.0 → bugcap-0.7.0}/src/bugcap/backends.py +73 -2
- bugcap-0.7.0/src/bugcap/capture.py +122 -0
- {bugcap-0.6.0 → bugcap-0.7.0}/src/bugcap/cli.py +2 -0
- {bugcap-0.6.0 → bugcap-0.7.0}/src/bugcap/drafts.py +65 -6
- bugcap-0.7.0/src/bugcap/live.py +1090 -0
- {bugcap-0.6.0 → bugcap-0.7.0}/src/bugcap/live_session.py +127 -33
- bugcap-0.7.0/src/bugcap/opener.py +49 -0
- {bugcap-0.6.0 → bugcap-0.7.0}/src/bugcap/recorder.py +74 -7
- bugcap-0.7.0/src/bugcap/screens.py +330 -0
- bugcap-0.7.0/src/bugcap/shortcuts.py +109 -0
- bugcap-0.7.0/src/bugcap/targets.py +100 -0
- {bugcap-0.6.0 → bugcap-0.7.0/src/bugcap.egg-info}/PKG-INFO +5 -3
- {bugcap-0.6.0 → bugcap-0.7.0}/src/bugcap.egg-info/SOURCES.txt +9 -1
- bugcap-0.7.0/tests/test_backends.py +295 -0
- bugcap-0.7.0/tests/test_capture.py +167 -0
- {bugcap-0.6.0 → bugcap-0.7.0}/tests/test_drafts.py +66 -0
- {bugcap-0.6.0 → bugcap-0.7.0}/tests/test_live_session.py +219 -1
- bugcap-0.7.0/tests/test_opener.py +76 -0
- bugcap-0.7.0/tests/test_recorder_argv.py +139 -0
- {bugcap-0.6.0 → bugcap-0.7.0}/tests/test_recorder_run.py +13 -0
- bugcap-0.7.0/tests/test_screens.py +174 -0
- bugcap-0.7.0/tests/test_shortcuts.py +79 -0
- bugcap-0.7.0/tests/test_targets.py +81 -0
- bugcap-0.6.0/src/bugcap/__init__.py +0 -1
- bugcap-0.6.0/src/bugcap/capture.py +0 -84
- bugcap-0.6.0/src/bugcap/live.py +0 -624
- bugcap-0.6.0/tests/test_backends.py +0 -133
- bugcap-0.6.0/tests/test_capture.py +0 -95
- bugcap-0.6.0/tests/test_recorder_argv.py +0 -72
- {bugcap-0.6.0 → bugcap-0.7.0}/LICENSE +0 -0
- {bugcap-0.6.0 → bugcap-0.7.0}/setup.cfg +0 -0
- {bugcap-0.6.0 → bugcap-0.7.0}/src/bugcap/agent_api.py +0 -0
- {bugcap-0.6.0 → bugcap-0.7.0}/src/bugcap/config.py +0 -0
- {bugcap-0.6.0 → bugcap-0.7.0}/src/bugcap/dashboard/__init__.py +0 -0
- {bugcap-0.6.0 → bugcap-0.7.0}/src/bugcap/dashboard/api.py +0 -0
- {bugcap-0.6.0 → bugcap-0.7.0}/src/bugcap/dashboard/media.py +0 -0
- {bugcap-0.6.0 → bugcap-0.7.0}/src/bugcap/dashboard/server.py +0 -0
- {bugcap-0.6.0 → bugcap-0.7.0}/src/bugcap/dashboard/static/app.css +0 -0
- {bugcap-0.6.0 → bugcap-0.7.0}/src/bugcap/dashboard/static/app.js +0 -0
- {bugcap-0.6.0 → bugcap-0.7.0}/src/bugcap/dashboard/static/index.html +0 -0
- {bugcap-0.6.0 → bugcap-0.7.0}/src/bugcap/errors.py +0 -0
- {bugcap-0.6.0 → bugcap-0.7.0}/src/bugcap/ghcli.py +0 -0
- {bugcap-0.6.0 → bugcap-0.7.0}/src/bugcap/ingest.py +0 -0
- {bugcap-0.6.0 → bugcap-0.7.0}/src/bugcap/logs.py +0 -0
- {bugcap-0.6.0 → bugcap-0.7.0}/src/bugcap/mcp_server.py +0 -0
- {bugcap-0.6.0 → bugcap-0.7.0}/src/bugcap/paths.py +0 -0
- {bugcap-0.6.0 → bugcap-0.7.0}/src/bugcap/refs.py +0 -0
- {bugcap-0.6.0 → bugcap-0.7.0}/src/bugcap/repo.py +0 -0
- {bugcap-0.6.0 → bugcap-0.7.0}/src/bugcap/service.py +0 -0
- {bugcap-0.6.0 → bugcap-0.7.0}/src/bugcap/store.py +0 -0
- {bugcap-0.6.0 → bugcap-0.7.0}/src/bugcap/sync.py +0 -0
- {bugcap-0.6.0 → bugcap-0.7.0}/src/bugcap/tomlio.py +0 -0
- {bugcap-0.6.0 → bugcap-0.7.0}/src/bugcap/validation.py +0 -0
- {bugcap-0.6.0 → bugcap-0.7.0}/src/bugcap.egg-info/dependency_links.txt +0 -0
- {bugcap-0.6.0 → bugcap-0.7.0}/src/bugcap.egg-info/entry_points.txt +0 -0
- {bugcap-0.6.0 → bugcap-0.7.0}/src/bugcap.egg-info/requires.txt +0 -0
- {bugcap-0.6.0 → bugcap-0.7.0}/src/bugcap.egg-info/top_level.txt +0 -0
- {bugcap-0.6.0 → bugcap-0.7.0}/tests/test_agent_api.py +0 -0
- {bugcap-0.6.0 → bugcap-0.7.0}/tests/test_cli_attach.py +0 -0
- {bugcap-0.6.0 → bugcap-0.7.0}/tests/test_cli_dashboard.py +0 -0
- {bugcap-0.6.0 → bugcap-0.7.0}/tests/test_cli_init_capture_list.py +0 -0
- {bugcap-0.6.0 → bugcap-0.7.0}/tests/test_cli_live_headless.py +0 -0
- {bugcap-0.6.0 → bugcap-0.7.0}/tests/test_cli_media.py +0 -0
- {bugcap-0.6.0 → bugcap-0.7.0}/tests/test_cli_no_image_delete.py +0 -0
- {bugcap-0.6.0 → bugcap-0.7.0}/tests/test_cli_record.py +0 -0
- {bugcap-0.6.0 → bugcap-0.7.0}/tests/test_cli_setup.py +0 -0
- {bugcap-0.6.0 → bugcap-0.7.0}/tests/test_cli_triage.py +0 -0
- {bugcap-0.6.0 → bugcap-0.7.0}/tests/test_dashboard_api.py +0 -0
- {bugcap-0.6.0 → bugcap-0.7.0}/tests/test_dashboard_media.py +0 -0
- {bugcap-0.6.0 → bugcap-0.7.0}/tests/test_dashboard_mutations.py +0 -0
- {bugcap-0.6.0 → bugcap-0.7.0}/tests/test_dashboard_security.py +0 -0
- {bugcap-0.6.0 → bugcap-0.7.0}/tests/test_dashboard_ui_static.py +0 -0
- {bugcap-0.6.0 → bugcap-0.7.0}/tests/test_file_attachments.py +0 -0
- {bugcap-0.6.0 → bugcap-0.7.0}/tests/test_ghcli.py +0 -0
- {bugcap-0.6.0 → bugcap-0.7.0}/tests/test_github_media_sync.py +0 -0
- {bugcap-0.6.0 → bugcap-0.7.0}/tests/test_github_pull.py +0 -0
- {bugcap-0.6.0 → bugcap-0.7.0}/tests/test_github_sync.py +0 -0
- {bugcap-0.6.0 → bugcap-0.7.0}/tests/test_image_refs.py +0 -0
- {bugcap-0.6.0 → bugcap-0.7.0}/tests/test_ingest.py +0 -0
- {bugcap-0.6.0 → bugcap-0.7.0}/tests/test_ingest_url.py +0 -0
- {bugcap-0.6.0 → bugcap-0.7.0}/tests/test_mcp.py +0 -0
- {bugcap-0.6.0 → bugcap-0.7.0}/tests/test_mcp_logging.py +0 -0
- {bugcap-0.6.0 → bugcap-0.7.0}/tests/test_paths.py +0 -0
- {bugcap-0.6.0 → bugcap-0.7.0}/tests/test_recorder_devices.py +0 -0
- {bugcap-0.6.0 → bugcap-0.7.0}/tests/test_recorder_media_real.py +0 -0
- {bugcap-0.6.0 → bugcap-0.7.0}/tests/test_refs.py +0 -0
- {bugcap-0.6.0 → bugcap-0.7.0}/tests/test_refs_validate_rewrite.py +0 -0
- {bugcap-0.6.0 → bugcap-0.7.0}/tests/test_regression_output.py +0 -0
- {bugcap-0.6.0 → bugcap-0.7.0}/tests/test_repo_config.py +0 -0
- {bugcap-0.6.0 → bugcap-0.7.0}/tests/test_repo_values.py +0 -0
- {bugcap-0.6.0 → bugcap-0.7.0}/tests/test_service_media.py +0 -0
- {bugcap-0.6.0 → bugcap-0.7.0}/tests/test_service_refs.py +0 -0
- {bugcap-0.6.0 → bugcap-0.7.0}/tests/test_store_migration.py +0 -0
- {bugcap-0.6.0 → bugcap-0.7.0}/tests/test_sync_consent.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: bugcap
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.7.0
|
|
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
|
|
@@ -64,7 +64,7 @@ bugcap keeps the whole loop in one place:
|
|
|
64
64
|
| 🔁 **Sync when ready** | Push to a GitHub issue with the usual inline image, plus a plain copy agents can read. |
|
|
65
65
|
| 🎞️ **Record what happens** | Short screen recordings as animated GIF, video or keyframes, with size caps. |
|
|
66
66
|
| 🖥️ **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. |
|
|
67
|
+
| 🪟 **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
68
|
|
|
69
69
|
## Quick start
|
|
70
70
|
|
|
@@ -179,7 +179,9 @@ Module map (`src/bugcap/`):
|
|
|
179
179
|
| `ingest.py` | Path/glob/URL inputs: magic-byte validation, size/timeout/redirect limits, atomic copy into `images/`. |
|
|
180
180
|
| `refs.py` | `@` reference parsing (code/email aware), validation, rewrite, display. |
|
|
181
181
|
| `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. |
|
|
182
|
+
| `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. |
|
|
183
|
+
| `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. |
|
|
184
|
+
| `shortcuts.py` + `opener.py` | Live mode's shortcut table (bindings, button hints, F1 help); opening a staged item in the OS default app. |
|
|
183
185
|
| `dashboard/` | `http.server` UI + JSON API (loopback, Host/Origin checks, write token, Range media, static allowlist). |
|
|
184
186
|
| `repo.py` + `tomlio.py` | `.bugcap.toml` discovery/read/write (tag, github slug, `[sync]`). |
|
|
185
187
|
| `config.py` | Global `config.toml`: `[sync]` defaults, `[consent]` for image commits, `[log]`. |
|
|
@@ -47,7 +47,7 @@ bugcap keeps the whole loop in one place:
|
|
|
47
47
|
| 🔁 **Sync when ready** | Push to a GitHub issue with the usual inline image, plus a plain copy agents can read. |
|
|
48
48
|
| 🎞️ **Record what happens** | Short screen recordings as animated GIF, video or keyframes, with size caps. |
|
|
49
49
|
| 🖥️ **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. |
|
|
50
|
+
| 🪟 **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
51
|
|
|
52
52
|
## Quick start
|
|
53
53
|
|
|
@@ -162,7 +162,9 @@ Module map (`src/bugcap/`):
|
|
|
162
162
|
| `ingest.py` | Path/glob/URL inputs: magic-byte validation, size/timeout/redirect limits, atomic copy into `images/`. |
|
|
163
163
|
| `refs.py` | `@` reference parsing (code/email aware), validation, rewrite, display. |
|
|
164
164
|
| `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. |
|
|
165
|
+
| `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. |
|
|
166
|
+
| `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. |
|
|
167
|
+
| `shortcuts.py` + `opener.py` | Live mode's shortcut table (bindings, button hints, F1 help); opening a staged item in the OS default app. |
|
|
166
168
|
| `dashboard/` | `http.server` UI + JSON API (loopback, Host/Origin checks, write token, Range media, static allowlist). |
|
|
167
169
|
| `repo.py` + `tomlio.py` | `.bugcap.toml` discovery/read/write (tag, github slug, `[sync]`). |
|
|
168
170
|
| `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.
|
|
7
|
+
version = "0.7.0"
|
|
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.0"
|
|
@@ -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
|
|
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(
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|