bugcap 0.5.1__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.5.1/src/bugcap.egg-info → bugcap-0.7.0}/PKG-INFO +9 -5
- {bugcap-0.5.1 → bugcap-0.7.0}/README.md +8 -4
- {bugcap-0.5.1 → bugcap-0.7.0}/pyproject.toml +1 -1
- bugcap-0.7.0/src/bugcap/__init__.py +1 -0
- {bugcap-0.5.1 → bugcap-0.7.0}/src/bugcap/agent_api.py +10 -1
- bugcap-0.7.0/src/bugcap/backends.py +247 -0
- bugcap-0.7.0/src/bugcap/capture.py +122 -0
- {bugcap-0.5.1 → bugcap-0.7.0}/src/bugcap/cli.py +57 -14
- {bugcap-0.5.1 → bugcap-0.7.0}/src/bugcap/dashboard/media.py +10 -3
- {bugcap-0.5.1 → bugcap-0.7.0}/src/bugcap/dashboard/server.py +5 -1
- {bugcap-0.5.1 → bugcap-0.7.0}/src/bugcap/dashboard/static/app.js +5 -1
- {bugcap-0.5.1 → bugcap-0.7.0}/src/bugcap/drafts.py +65 -6
- {bugcap-0.5.1 → bugcap-0.7.0}/src/bugcap/ingest.py +93 -10
- bugcap-0.7.0/src/bugcap/live.py +1090 -0
- {bugcap-0.5.1 → bugcap-0.7.0}/src/bugcap/live_session.py +163 -36
- {bugcap-0.5.1 → bugcap-0.7.0}/src/bugcap/mcp_server.py +28 -5
- bugcap-0.7.0/src/bugcap/opener.py +49 -0
- {bugcap-0.5.1 → bugcap-0.7.0}/src/bugcap/paths.py +7 -1
- {bugcap-0.5.1 → bugcap-0.7.0}/src/bugcap/recorder.py +74 -7
- {bugcap-0.5.1 → bugcap-0.7.0}/src/bugcap/refs.py +5 -4
- {bugcap-0.5.1 → bugcap-0.7.0}/src/bugcap/repo.py +65 -2
- bugcap-0.7.0/src/bugcap/screens.py +330 -0
- {bugcap-0.5.1 → bugcap-0.7.0}/src/bugcap/service.py +27 -6
- bugcap-0.7.0/src/bugcap/shortcuts.py +109 -0
- {bugcap-0.5.1 → bugcap-0.7.0}/src/bugcap/store.py +38 -4
- {bugcap-0.5.1 → bugcap-0.7.0}/src/bugcap/sync.py +3 -0
- bugcap-0.7.0/src/bugcap/targets.py +100 -0
- {bugcap-0.5.1 → bugcap-0.7.0/src/bugcap.egg-info}/PKG-INFO +9 -5
- {bugcap-0.5.1 → bugcap-0.7.0}/src/bugcap.egg-info/SOURCES.txt +10 -1
- bugcap-0.7.0/tests/test_backends.py +295 -0
- bugcap-0.7.0/tests/test_capture.py +167 -0
- {bugcap-0.5.1 → bugcap-0.7.0}/tests/test_cli_setup.py +29 -0
- {bugcap-0.5.1 → bugcap-0.7.0}/tests/test_cli_triage.py +4 -1
- {bugcap-0.5.1 → bugcap-0.7.0}/tests/test_drafts.py +66 -0
- bugcap-0.7.0/tests/test_file_attachments.py +217 -0
- {bugcap-0.5.1 → bugcap-0.7.0}/tests/test_live_session.py +219 -1
- {bugcap-0.5.1 → bugcap-0.7.0}/tests/test_mcp.py +1 -1
- {bugcap-0.5.1 → bugcap-0.7.0}/tests/test_mcp_logging.py +13 -0
- bugcap-0.7.0/tests/test_opener.py +76 -0
- bugcap-0.7.0/tests/test_recorder_argv.py +139 -0
- {bugcap-0.5.1 → bugcap-0.7.0}/tests/test_recorder_run.py +13 -0
- {bugcap-0.5.1 → bugcap-0.7.0}/tests/test_regression_output.py +16 -0
- {bugcap-0.5.1 → bugcap-0.7.0}/tests/test_repo_config.py +46 -0
- bugcap-0.7.0/tests/test_screens.py +174 -0
- bugcap-0.7.0/tests/test_shortcuts.py +79 -0
- {bugcap-0.5.1 → bugcap-0.7.0}/tests/test_store_migration.py +62 -7
- bugcap-0.7.0/tests/test_targets.py +81 -0
- bugcap-0.5.1/src/bugcap/__init__.py +0 -1
- bugcap-0.5.1/src/bugcap/backends.py +0 -133
- bugcap-0.5.1/src/bugcap/capture.py +0 -84
- bugcap-0.5.1/src/bugcap/live.py +0 -564
- bugcap-0.5.1/tests/test_backends.py +0 -76
- bugcap-0.5.1/tests/test_capture.py +0 -95
- bugcap-0.5.1/tests/test_recorder_argv.py +0 -72
- {bugcap-0.5.1 → bugcap-0.7.0}/LICENSE +0 -0
- {bugcap-0.5.1 → bugcap-0.7.0}/setup.cfg +0 -0
- {bugcap-0.5.1 → bugcap-0.7.0}/src/bugcap/config.py +0 -0
- {bugcap-0.5.1 → bugcap-0.7.0}/src/bugcap/dashboard/__init__.py +0 -0
- {bugcap-0.5.1 → bugcap-0.7.0}/src/bugcap/dashboard/api.py +0 -0
- {bugcap-0.5.1 → bugcap-0.7.0}/src/bugcap/dashboard/static/app.css +0 -0
- {bugcap-0.5.1 → bugcap-0.7.0}/src/bugcap/dashboard/static/index.html +0 -0
- {bugcap-0.5.1 → bugcap-0.7.0}/src/bugcap/errors.py +0 -0
- {bugcap-0.5.1 → bugcap-0.7.0}/src/bugcap/ghcli.py +0 -0
- {bugcap-0.5.1 → bugcap-0.7.0}/src/bugcap/logs.py +0 -0
- {bugcap-0.5.1 → bugcap-0.7.0}/src/bugcap/tomlio.py +0 -0
- {bugcap-0.5.1 → bugcap-0.7.0}/src/bugcap/validation.py +0 -0
- {bugcap-0.5.1 → bugcap-0.7.0}/src/bugcap.egg-info/dependency_links.txt +0 -0
- {bugcap-0.5.1 → bugcap-0.7.0}/src/bugcap.egg-info/entry_points.txt +0 -0
- {bugcap-0.5.1 → bugcap-0.7.0}/src/bugcap.egg-info/requires.txt +0 -0
- {bugcap-0.5.1 → bugcap-0.7.0}/src/bugcap.egg-info/top_level.txt +0 -0
- {bugcap-0.5.1 → bugcap-0.7.0}/tests/test_agent_api.py +0 -0
- {bugcap-0.5.1 → bugcap-0.7.0}/tests/test_cli_attach.py +0 -0
- {bugcap-0.5.1 → bugcap-0.7.0}/tests/test_cli_dashboard.py +0 -0
- {bugcap-0.5.1 → bugcap-0.7.0}/tests/test_cli_init_capture_list.py +0 -0
- {bugcap-0.5.1 → bugcap-0.7.0}/tests/test_cli_live_headless.py +0 -0
- {bugcap-0.5.1 → bugcap-0.7.0}/tests/test_cli_media.py +0 -0
- {bugcap-0.5.1 → bugcap-0.7.0}/tests/test_cli_no_image_delete.py +0 -0
- {bugcap-0.5.1 → bugcap-0.7.0}/tests/test_cli_record.py +0 -0
- {bugcap-0.5.1 → bugcap-0.7.0}/tests/test_dashboard_api.py +0 -0
- {bugcap-0.5.1 → bugcap-0.7.0}/tests/test_dashboard_media.py +0 -0
- {bugcap-0.5.1 → bugcap-0.7.0}/tests/test_dashboard_mutations.py +0 -0
- {bugcap-0.5.1 → bugcap-0.7.0}/tests/test_dashboard_security.py +0 -0
- {bugcap-0.5.1 → bugcap-0.7.0}/tests/test_dashboard_ui_static.py +0 -0
- {bugcap-0.5.1 → bugcap-0.7.0}/tests/test_ghcli.py +0 -0
- {bugcap-0.5.1 → bugcap-0.7.0}/tests/test_github_media_sync.py +0 -0
- {bugcap-0.5.1 → bugcap-0.7.0}/tests/test_github_pull.py +0 -0
- {bugcap-0.5.1 → bugcap-0.7.0}/tests/test_github_sync.py +0 -0
- {bugcap-0.5.1 → bugcap-0.7.0}/tests/test_image_refs.py +0 -0
- {bugcap-0.5.1 → bugcap-0.7.0}/tests/test_ingest.py +0 -0
- {bugcap-0.5.1 → bugcap-0.7.0}/tests/test_ingest_url.py +0 -0
- {bugcap-0.5.1 → bugcap-0.7.0}/tests/test_paths.py +0 -0
- {bugcap-0.5.1 → bugcap-0.7.0}/tests/test_recorder_devices.py +0 -0
- {bugcap-0.5.1 → bugcap-0.7.0}/tests/test_recorder_media_real.py +0 -0
- {bugcap-0.5.1 → bugcap-0.7.0}/tests/test_refs.py +0 -0
- {bugcap-0.5.1 → bugcap-0.7.0}/tests/test_refs_validate_rewrite.py +0 -0
- {bugcap-0.5.1 → bugcap-0.7.0}/tests/test_repo_values.py +0 -0
- {bugcap-0.5.1 → bugcap-0.7.0}/tests/test_service_media.py +0 -0
- {bugcap-0.5.1 → bugcap-0.7.0}/tests/test_service_refs.py +0 -0
- {bugcap-0.5.1 → 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
|
|
|
@@ -83,6 +83,8 @@ bugcap dashboard --open # browse and triage in the browser
|
|
|
83
83
|
The [usage guide](https://amenalahassa.github.io/bugcap/usage.html) covers every command: images
|
|
84
84
|
and labels, recording, live mode, `@` references, GitHub pull and sync, and the MCP tools.
|
|
85
85
|
|
|
86
|
+
If a report ID is not found, the error suggests `bugcap list --all` to see report IDs across all repos.
|
|
87
|
+
|
|
86
88
|
## Connect your agent (MCP)
|
|
87
89
|
|
|
88
90
|
Requires the `mcp` extra. Then give your agent access to the reports:
|
|
@@ -98,7 +100,7 @@ Or in a project's `.mcp.json`:
|
|
|
98
100
|
```
|
|
99
101
|
|
|
100
102
|
The server exposes six tools: `list_reports`, `get_report` (returns the screenshot), `request_screenshot`,
|
|
101
|
-
`pull_issues`, `attach_image` and `update_notes`. Logs go to `logs/mcp-server.log` in the data
|
|
103
|
+
`pull_issues`, `attach_image`, `attach_file` and `update_notes`. Logs go to `logs/mcp-server.log` in the data
|
|
102
104
|
directory, never to stdout.
|
|
103
105
|
|
|
104
106
|
Ask your agent something like *"list the open bugcap reports for this repo and fix the one with
|
|
@@ -147,7 +149,7 @@ pull and sync, MCP, and where data lives per OS.
|
|
|
147
149
|
|
|
148
150
|
Notes on the more involved behaviour:
|
|
149
151
|
|
|
150
|
-
- **References.** In a note, `@i1` is image 1, `@v2` a video, `@g3` an animated GIF, `@f4` a frame set, and `@login-error` a labelled item. `#3` points at report 3 (a missing one is kept as text with a warning). Media references are validated on save, and relabelling or removing a referenced item needs `--force`.
|
|
152
|
+
- **References.** In a note, `@i1` is image 1, `@v2` a video, `@g3` an animated GIF, `@f4` a frame set, `@d5` an attached file (`attach --file`, any type), and `@login-error` a labelled item. `#3` points at report 3 (a missing one is kept as text with a warning). Media references are validated on save, and relabelling or removing a referenced item needs `--force`.
|
|
151
153
|
- **Repo values** (`init`, `sync`, `config repo set`) are checked for format and, with `gh`, for existence. Changing the repo identity offers to move existing reports.
|
|
152
154
|
- **Media sync** over `[sync] max_upload_mb` (default 25) is skipped with a warning.
|
|
153
155
|
|
|
@@ -177,7 +179,9 @@ Module map (`src/bugcap/`):
|
|
|
177
179
|
| `ingest.py` | Path/glob/URL inputs: magic-byte validation, size/timeout/redirect limits, atomic copy into `images/`. |
|
|
178
180
|
| `refs.py` | `@` reference parsing (code/email aware), validation, rewrite, display. |
|
|
179
181
|
| `recorder.py` | `ffmpeg`/`wf-recorder` argv per OS, stop/caps watchdogs, animated/frames post-processing. |
|
|
180
|
-
| `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. |
|
|
181
185
|
| `dashboard/` | `http.server` UI + JSON API (loopback, Host/Origin checks, write token, Range media, static allowlist). |
|
|
182
186
|
| `repo.py` + `tomlio.py` | `.bugcap.toml` discovery/read/write (tag, github slug, `[sync]`). |
|
|
183
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
|
|
|
@@ -66,6 +66,8 @@ bugcap dashboard --open # browse and triage in the browser
|
|
|
66
66
|
The [usage guide](https://amenalahassa.github.io/bugcap/usage.html) covers every command: images
|
|
67
67
|
and labels, recording, live mode, `@` references, GitHub pull and sync, and the MCP tools.
|
|
68
68
|
|
|
69
|
+
If a report ID is not found, the error suggests `bugcap list --all` to see report IDs across all repos.
|
|
70
|
+
|
|
69
71
|
## Connect your agent (MCP)
|
|
70
72
|
|
|
71
73
|
Requires the `mcp` extra. Then give your agent access to the reports:
|
|
@@ -81,7 +83,7 @@ Or in a project's `.mcp.json`:
|
|
|
81
83
|
```
|
|
82
84
|
|
|
83
85
|
The server exposes six tools: `list_reports`, `get_report` (returns the screenshot), `request_screenshot`,
|
|
84
|
-
`pull_issues`, `attach_image` and `update_notes`. Logs go to `logs/mcp-server.log` in the data
|
|
86
|
+
`pull_issues`, `attach_image`, `attach_file` and `update_notes`. Logs go to `logs/mcp-server.log` in the data
|
|
85
87
|
directory, never to stdout.
|
|
86
88
|
|
|
87
89
|
Ask your agent something like *"list the open bugcap reports for this repo and fix the one with
|
|
@@ -130,7 +132,7 @@ pull and sync, MCP, and where data lives per OS.
|
|
|
130
132
|
|
|
131
133
|
Notes on the more involved behaviour:
|
|
132
134
|
|
|
133
|
-
- **References.** In a note, `@i1` is image 1, `@v2` a video, `@g3` an animated GIF, `@f4` a frame set, and `@login-error` a labelled item. `#3` points at report 3 (a missing one is kept as text with a warning). Media references are validated on save, and relabelling or removing a referenced item needs `--force`.
|
|
135
|
+
- **References.** In a note, `@i1` is image 1, `@v2` a video, `@g3` an animated GIF, `@f4` a frame set, `@d5` an attached file (`attach --file`, any type), and `@login-error` a labelled item. `#3` points at report 3 (a missing one is kept as text with a warning). Media references are validated on save, and relabelling or removing a referenced item needs `--force`.
|
|
134
136
|
- **Repo values** (`init`, `sync`, `config repo set`) are checked for format and, with `gh`, for existence. Changing the repo identity offers to move existing reports.
|
|
135
137
|
- **Media sync** over `[sync] max_upload_mb` (default 25) is skipped with a warning.
|
|
136
138
|
|
|
@@ -160,7 +162,9 @@ Module map (`src/bugcap/`):
|
|
|
160
162
|
| `ingest.py` | Path/glob/URL inputs: magic-byte validation, size/timeout/redirect limits, atomic copy into `images/`. |
|
|
161
163
|
| `refs.py` | `@` reference parsing (code/email aware), validation, rewrite, display. |
|
|
162
164
|
| `recorder.py` | `ffmpeg`/`wf-recorder` argv per OS, stop/caps watchdogs, animated/frames post-processing. |
|
|
163
|
-
| `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. |
|
|
164
168
|
| `dashboard/` | `http.server` UI + JSON API (loopback, Host/Origin checks, write token, Range media, static allowlist). |
|
|
165
169
|
| `repo.py` + `tomlio.py` | `.bugcap.toml` discovery/read/write (tag, github slug, `[sync]`). |
|
|
166
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"
|
|
@@ -97,10 +97,19 @@ def get_report(store: Store, id: int) -> dict:
|
|
|
97
97
|
|
|
98
98
|
def attach_image(store: Store, id: int, sources: list[str], labels: Optional[list] = None) -> dict:
|
|
99
99
|
"""Attach images (paths, globs, http(s) URLs) to a report; partial batches are reported."""
|
|
100
|
+
return _attach(store, id, sources, labels, service.add_media)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def attach_file(store: Store, id: int, sources: list[str], labels: Optional[list] = None) -> dict:
|
|
104
|
+
"""Attach files of any type (paths, globs, http(s) URLs) to a report."""
|
|
105
|
+
return _attach(store, id, sources, labels, service.add_files)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _attach(store: Store, id: int, sources: list[str], labels, add) -> dict:
|
|
100
109
|
if not sources or len(sources) > 20:
|
|
101
110
|
return {"code": "bad_query", "error": "sources must contain 1 to 20 entries"}
|
|
102
111
|
try:
|
|
103
|
-
result =
|
|
112
|
+
result = add(store, id, list(sources), labels)
|
|
104
113
|
except ServiceError as exc:
|
|
105
114
|
return {"error": exc.message, **exc.as_dict()}
|
|
106
115
|
return {
|
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
"""Capture backends: detection, install suggestions and (opt-in) installation, per OS."""
|
|
2
|
+
import os
|
|
3
|
+
import shutil
|
|
4
|
+
import subprocess
|
|
5
|
+
import sys
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Optional
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def platform_key() -> str:
|
|
12
|
+
if sys.platform == "win32":
|
|
13
|
+
return "windows"
|
|
14
|
+
if sys.platform == "darwin":
|
|
15
|
+
return "macos"
|
|
16
|
+
return "linux"
|
|
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
|
+
|
|
89
|
+
@dataclass
|
|
90
|
+
class Backend:
|
|
91
|
+
name: str
|
|
92
|
+
binaries: list[str] # every one must be resolvable (PATH, or a known fallback location)
|
|
93
|
+
description: str
|
|
94
|
+
platforms: tuple[str, ...]
|
|
95
|
+
annotates: bool
|
|
96
|
+
# platform -> [(package manager binary, argv)]
|
|
97
|
+
installs: dict[str, list[tuple[str, list[str]]]] = field(default_factory=dict)
|
|
98
|
+
manual: dict[str, str] = field(default_factory=dict) # platform -> guidance
|
|
99
|
+
|
|
100
|
+
def available(self) -> bool:
|
|
101
|
+
if platform_key() not in self.platforms:
|
|
102
|
+
return False
|
|
103
|
+
return all(resolve(b) for b in self.binaries)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
BACKENDS = [
|
|
107
|
+
Backend(
|
|
108
|
+
name="flameshot",
|
|
109
|
+
binaries=["flameshot"],
|
|
110
|
+
description="Cross-platform capture + annotation UI (recommended everywhere)",
|
|
111
|
+
platforms=("linux", "macos", "windows"),
|
|
112
|
+
annotates=True,
|
|
113
|
+
installs={
|
|
114
|
+
"linux": [
|
|
115
|
+
("apt-get", ["apt-get", "install", "-y", "flameshot"]),
|
|
116
|
+
("dnf", ["dnf", "install", "-y", "flameshot"]),
|
|
117
|
+
("pacman", ["pacman", "-S", "--noconfirm", "flameshot"]),
|
|
118
|
+
("zypper", ["zypper", "install", "-y", "flameshot"]),
|
|
119
|
+
("brew", ["brew", "install", "flameshot"]),
|
|
120
|
+
],
|
|
121
|
+
"macos": [("brew", ["brew", "install", "--cask", "flameshot"])],
|
|
122
|
+
"windows": [
|
|
123
|
+
("winget", ["winget", "install", "--id", "Flameshot.Flameshot", "-e"]),
|
|
124
|
+
("scoop", ["scoop", "install", "flameshot"]),
|
|
125
|
+
("choco", ["choco", "install", "-y", "flameshot"]),
|
|
126
|
+
],
|
|
127
|
+
},
|
|
128
|
+
manual={
|
|
129
|
+
"linux": "See https://flameshot.org/docs/installation/",
|
|
130
|
+
"macos": "brew install --cask flameshot, or https://flameshot.org/#download",
|
|
131
|
+
"windows": "winget install Flameshot.Flameshot, or https://flameshot.org/#download",
|
|
132
|
+
},
|
|
133
|
+
),
|
|
134
|
+
Backend(
|
|
135
|
+
name="satty",
|
|
136
|
+
binaries=["satty", "grim"],
|
|
137
|
+
description="Wayland-native annotation (needs grim for the grab)",
|
|
138
|
+
platforms=("linux",),
|
|
139
|
+
annotates=True,
|
|
140
|
+
installs={
|
|
141
|
+
"linux": [
|
|
142
|
+
("pacman", ["pacman", "-S", "--noconfirm", "satty", "grim"]),
|
|
143
|
+
("brew", ["brew", "install", "satty"]),
|
|
144
|
+
]
|
|
145
|
+
},
|
|
146
|
+
manual={
|
|
147
|
+
"linux": "Install grim from your distro, and satty from "
|
|
148
|
+
"https://github.com/Satty-org/Satty (or `cargo install satty`)",
|
|
149
|
+
},
|
|
150
|
+
),
|
|
151
|
+
Backend(
|
|
152
|
+
name="screencapture",
|
|
153
|
+
binaries=["screencapture"],
|
|
154
|
+
description="macOS built-in region capture (no annotation)",
|
|
155
|
+
platforms=("macos",),
|
|
156
|
+
annotates=False,
|
|
157
|
+
manual={"macos": "Built in to macOS; nothing to install."},
|
|
158
|
+
),
|
|
159
|
+
]
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def by_name(name: str) -> Backend:
|
|
163
|
+
for backend in BACKENDS:
|
|
164
|
+
if backend.name == name:
|
|
165
|
+
return backend
|
|
166
|
+
raise KeyError(name)
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def _sync_path_from_registry() -> None:
|
|
170
|
+
"""Pick up a freshly-installed Windows tool without a new terminal.
|
|
171
|
+
|
|
172
|
+
winget/scoop/choco update the user/machine PATH in the registry, but an
|
|
173
|
+
already-running process (and any shell that was already open when the
|
|
174
|
+
install happened) keeps its original PATH until it restarts. That made
|
|
175
|
+
`bugcap live` report "No capture tool found" right after `bugcap setup`
|
|
176
|
+
had just installed flameshot in the same session. Re-reading PATH from
|
|
177
|
+
the registry before every detection closes that gap. Best-effort: any
|
|
178
|
+
failure here just leaves PATH as-is.
|
|
179
|
+
"""
|
|
180
|
+
if sys.platform != "win32":
|
|
181
|
+
return
|
|
182
|
+
try:
|
|
183
|
+
import winreg
|
|
184
|
+
except ImportError:
|
|
185
|
+
return
|
|
186
|
+
try:
|
|
187
|
+
chunks = [os.environ.get("PATH", "")]
|
|
188
|
+
for hive, subkey in (
|
|
189
|
+
(winreg.HKEY_CURRENT_USER, r"Environment"),
|
|
190
|
+
(winreg.HKEY_LOCAL_MACHINE, r"SYSTEM\CurrentControlSet\Control\Session Manager\Environment"),
|
|
191
|
+
):
|
|
192
|
+
try:
|
|
193
|
+
with winreg.OpenKey(hive, subkey) as key:
|
|
194
|
+
value, _ = winreg.QueryValueEx(key, "Path")
|
|
195
|
+
chunks.append(value)
|
|
196
|
+
except OSError:
|
|
197
|
+
continue
|
|
198
|
+
seen = set()
|
|
199
|
+
merged = []
|
|
200
|
+
for chunk in chunks:
|
|
201
|
+
for part in chunk.split(os.pathsep):
|
|
202
|
+
key = part.lower()
|
|
203
|
+
if part and key not in seen:
|
|
204
|
+
seen.add(key)
|
|
205
|
+
merged.append(part)
|
|
206
|
+
os.environ["PATH"] = os.pathsep.join(merged)
|
|
207
|
+
except OSError:
|
|
208
|
+
pass
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def detect() -> Optional[Backend]:
|
|
212
|
+
"""First available backend, in preference order."""
|
|
213
|
+
_sync_path_from_registry()
|
|
214
|
+
return next((b for b in BACKENDS if b.available()), None)
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def recommended() -> Backend:
|
|
218
|
+
"""What to suggest installing on this OS. Flameshot is cross-platform and annotates,
|
|
219
|
+
so it is the recommendation on every supported OS (satty is the Wayland alternative)."""
|
|
220
|
+
return by_name("flameshot")
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def install_command(backend: Backend) -> Optional[list[str]]:
|
|
224
|
+
"""Full argv (with sudo when needed) for the first usable package manager, or None."""
|
|
225
|
+
for manager, argv in backend.installs.get(platform_key(), []):
|
|
226
|
+
if not shutil.which(manager):
|
|
227
|
+
continue
|
|
228
|
+
needs_root = manager in {"apt-get", "dnf", "pacman", "zypper"}
|
|
229
|
+
if needs_root and hasattr(os, "geteuid") and os.geteuid() != 0:
|
|
230
|
+
if not shutil.which("sudo"):
|
|
231
|
+
continue
|
|
232
|
+
return ["sudo", *argv]
|
|
233
|
+
return list(argv)
|
|
234
|
+
return None
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def guidance(backend: Backend) -> str:
|
|
238
|
+
return backend.manual.get(platform_key(), "See the project's website for install steps.")
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def install(backend: Backend) -> int:
|
|
242
|
+
cmd = install_command(backend)
|
|
243
|
+
if cmd is None:
|
|
244
|
+
raise RuntimeError(
|
|
245
|
+
f"No supported package manager found for {backend.name}. {guidance(backend)}"
|
|
246
|
+
)
|
|
247
|
+
return subprocess.run(cmd).returncode
|
|
@@ -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
|
|
@@ -24,7 +24,10 @@ def resolve_report(store: Store, report_id: int):
|
|
|
24
24
|
"""Return the report, or print the standard not-found error and return None."""
|
|
25
25
|
report = store.get(report_id)
|
|
26
26
|
if report is None:
|
|
27
|
-
print(
|
|
27
|
+
print(
|
|
28
|
+
f"error: no report with id {report_id}; run `bugcap list --all` to see report ids",
|
|
29
|
+
file=sys.stderr,
|
|
30
|
+
)
|
|
28
31
|
return report
|
|
29
32
|
|
|
30
33
|
|
|
@@ -40,7 +43,7 @@ def print_service_error(exc: ServiceError) -> None:
|
|
|
40
43
|
def print_ingest(added, rejected) -> None:
|
|
41
44
|
"""One line per input: `added #<idx> ...` on stdout, `skipped ...` on stderr."""
|
|
42
45
|
for media in added:
|
|
43
|
-
name = os.path.basename(media.path or "")
|
|
46
|
+
name = os.path.basename((media.source if media.kind == "file" and media.source else media.path) or "")
|
|
44
47
|
print(f"added {refs.media_token(media)} {name} ({media.kind}, {human_size(media.size_bytes)})")
|
|
45
48
|
for item in rejected:
|
|
46
49
|
print(f"skipped {item['source']}: {item['reason']}", file=sys.stderr)
|
|
@@ -225,7 +228,15 @@ def _setup_capture(args: argparse.Namespace) -> int:
|
|
|
225
228
|
if rc != 0:
|
|
226
229
|
print(f"error: install command failed (exit {rc}).", file=sys.stderr)
|
|
227
230
|
return 1
|
|
228
|
-
|
|
231
|
+
if backends.detect() is not None:
|
|
232
|
+
for binary in rec.binaries:
|
|
233
|
+
backends.ensure_on_user_path(binary)
|
|
234
|
+
print(f"{rec.name} installed.")
|
|
235
|
+
return 0
|
|
236
|
+
print(
|
|
237
|
+
f"{rec.name} installed, but this terminal can't see it on PATH yet.\n"
|
|
238
|
+
f"Close and reopen your terminal (or start a new one), then run `bugcap setup` again."
|
|
239
|
+
)
|
|
229
240
|
return 0
|
|
230
241
|
|
|
231
242
|
|
|
@@ -536,14 +547,22 @@ def cmd_attach(args: argparse.Namespace) -> int:
|
|
|
536
547
|
added: list = []
|
|
537
548
|
rejected: list = []
|
|
538
549
|
shot_path = None
|
|
539
|
-
if args.image:
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
550
|
+
if args.image or args.file:
|
|
551
|
+
batches = (
|
|
552
|
+
(args.image, service.add_media, labels),
|
|
553
|
+
(args.file, service.add_files, list(args.file_label or [])),
|
|
554
|
+
)
|
|
555
|
+
for sources, add, batch_labels in batches:
|
|
556
|
+
if not sources:
|
|
557
|
+
continue
|
|
558
|
+
try:
|
|
559
|
+
result = add(store, args.id, list(sources), batch_labels)
|
|
560
|
+
except ServiceError as exc:
|
|
561
|
+
print_service_error(exc)
|
|
562
|
+
return 1
|
|
563
|
+
added += result.added
|
|
564
|
+
rejected += result.rejected
|
|
565
|
+
print_ingest(result.added, result.rejected)
|
|
547
566
|
else:
|
|
548
567
|
try:
|
|
549
568
|
shot_path = capture.capture_screenshot()
|
|
@@ -568,7 +587,7 @@ def cmd_attach(args: argparse.Namespace) -> int:
|
|
|
568
587
|
return 1
|
|
569
588
|
current = store.get(args.id)
|
|
570
589
|
_warn_report_refs(store, current.notes)
|
|
571
|
-
if args.image:
|
|
590
|
+
if args.image or args.file:
|
|
572
591
|
try:
|
|
573
592
|
refs.validate_references(current.notes, current.media)
|
|
574
593
|
except ServiceError as exc:
|
|
@@ -901,12 +920,16 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
901
920
|
p.add_argument("tags", nargs="+")
|
|
902
921
|
p.set_defaults(func=cmd_tag)
|
|
903
922
|
|
|
904
|
-
p = sub.add_parser("attach", help="Attach a screenshot (captured or imported) to a report.")
|
|
923
|
+
p = sub.add_parser("attach", help="Attach a screenshot (captured or imported) or any file to a report.")
|
|
905
924
|
p.add_argument("id", type=int)
|
|
906
925
|
p.add_argument("--image", action="append", metavar="SRC",
|
|
907
926
|
help="Import an image (path, glob or http(s) URL) instead of launching a capture tool; repeatable.")
|
|
908
927
|
p.add_argument("--label", action="append", help="Label for the --image at the same position (repeatable).")
|
|
909
|
-
p.add_argument("--
|
|
928
|
+
p.add_argument("--file", action="append", metavar="SRC",
|
|
929
|
+
help="Attach a file of any type (path, glob or http(s) URL); repeatable. "
|
|
930
|
+
"Refer to it in notes as @d1, @d2, ... or by label.")
|
|
931
|
+
p.add_argument("--file-label", action="append", help="Label for the --file at the same position (repeatable).")
|
|
932
|
+
p.add_argument("--note", help="Text appended to the report's notes, tied to the item(s) added here.")
|
|
910
933
|
p.set_defaults(func=cmd_attach)
|
|
911
934
|
|
|
912
935
|
p = sub.add_parser("images", help="List a report's images, or relabel/remove one.")
|
|
@@ -982,7 +1005,27 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
982
1005
|
return parser
|
|
983
1006
|
|
|
984
1007
|
|
|
1008
|
+
def _use_utf8_streams() -> None:
|
|
1009
|
+
"""Make stdout/stderr tolerant of report text the console's codepage can't show.
|
|
1010
|
+
|
|
1011
|
+
Report titles and notes are free-form user text and may contain characters
|
|
1012
|
+
(accents, emoji, …) outside the active console codepage. On Windows that
|
|
1013
|
+
codepage is often cp1252, so a bare `print()` of such text raises
|
|
1014
|
+
UnicodeEncodeError and crashes the CLI (e.g. `bugcap show`). Reconfiguring
|
|
1015
|
+
to UTF-8 with 'replace' keeps the command running and shows readable output
|
|
1016
|
+
instead of a traceback.
|
|
1017
|
+
"""
|
|
1018
|
+
for stream in (sys.stdout, sys.stderr):
|
|
1019
|
+
reconfigure = getattr(stream, "reconfigure", None)
|
|
1020
|
+
if reconfigure is not None:
|
|
1021
|
+
try:
|
|
1022
|
+
reconfigure(encoding="utf-8", errors="replace")
|
|
1023
|
+
except (ValueError, OSError):
|
|
1024
|
+
pass
|
|
1025
|
+
|
|
1026
|
+
|
|
985
1027
|
def main(argv: list[str] | None = None) -> int:
|
|
1028
|
+
_use_utf8_streams()
|
|
986
1029
|
parser = build_parser()
|
|
987
1030
|
args = parser.parse_args(argv)
|
|
988
1031
|
return args.func(args)
|
|
@@ -31,8 +31,10 @@ def parse_range(header: str | None, size: int):
|
|
|
31
31
|
return start, min(end, size - 1)
|
|
32
32
|
|
|
33
33
|
|
|
34
|
-
def serve_media(handler, rel_path, mime: str, range_header: str | None) -> None:
|
|
35
|
-
"""`rel_path` is the stored data-relative path; it must resolve inside
|
|
34
|
+
def serve_media(handler, rel_path, mime: str, range_header: str | None, download_name: str | None = None) -> None:
|
|
35
|
+
"""`rel_path` is the stored data-relative path; it must resolve inside the media store.
|
|
36
|
+
With `download_name` (attached files of any type) the bytes are always sent as an opaque
|
|
37
|
+
download, never rendered, so an uploaded HTML or SVG file cannot run on the dashboard origin."""
|
|
36
38
|
try:
|
|
37
39
|
path = resolve_data_path(rel_path)
|
|
38
40
|
if not path.is_file():
|
|
@@ -53,7 +55,12 @@ def serve_media(handler, rel_path, mime: str, range_header: str | None) -> None:
|
|
|
53
55
|
start, end = rng if rng else (0, size - 1)
|
|
54
56
|
handler.send_response(206 if rng else 200)
|
|
55
57
|
handler.send_security_headers()
|
|
56
|
-
|
|
58
|
+
if download_name is not None:
|
|
59
|
+
safe = re.sub(r"[^A-Za-z0-9._-]", "_", download_name)[:100] or "file"
|
|
60
|
+
handler.send_header("Content-Type", "application/octet-stream")
|
|
61
|
+
handler.send_header("Content-Disposition", f'attachment; filename="{safe}"')
|
|
62
|
+
else:
|
|
63
|
+
handler.send_header("Content-Type", mime)
|
|
57
64
|
handler.send_header("Accept-Ranges", "bytes")
|
|
58
65
|
handler.send_header("Cache-Control", "private, max-age=3600")
|
|
59
66
|
handler.send_header("Content-Length", str(end - start + 1) if size else "0")
|